送給前端開發者的一份新年禮物

yck發表於2017-12-31

大家好,新年快樂!今天,我開源了一個 React 的專案。這個專案雖小,但是五臟六腑俱全。

先來介紹下這個專案的技術棧:

  • React 全家桶:React 16 + Redux + React-router 4.0 + Immutable.js
  • ES6 + ES7 語法
  • 網路請求:Axios + Socket.io
  • UI 框架:Antd-mobile
  • 後端:Express + MongoDB

React 是什麼

React 其實只是一個 UI 框架,頻繁進行 DOM 操作的代價是很昂貴的,所以 React 使用了虛擬 DOM 的技術,每當狀態發生改變,就會生成新的虛擬 DOM 並與原本的進行改變,讓變化的地方去渲染。並且為了效能的考慮,只對狀態進行淺比較(這是一個很大的優化點)。

React 已經成為當今最流行的框架之一,但是他的學習成本並不低並且需要你有一個良好的 JS 基礎。由於React 只是一個 UI 框架,所以你想完成一個專案,你就得使用他的全家桶,更加提高了一個學習成本。所以本課程也是針對初學者,讓初學者能夠快速的上手 React 。

React 元件

如何寫好規劃好一個元件決定了你的 React 玩的溜不溜。一個元件你需要考慮他提供幾個對外暴露的介面,內部狀態通過區域性狀態改變還是全域性狀態改變好。並且你的元件應該是利於複用和維護的。

元件的生命週期

生命週期

  • render 函式會在 UI 渲染時呼叫,你多次渲染就會多次呼叫,所以控制一個元件的重複渲染對於效能優化很重要
  • componentDidMount 函式只會在元件渲染以後呼叫一次,通常會在這個發起資料請求
  • shouldComponentUpdate 是一個很重要的函式,他的返回值決定了是否需要生成一個新的虛擬 DOM 去和之前的比較。通常遇到的效能問題你可以在這裡得到很好的解決
  • componentWillMount 函式會在元件即將銷燬時呼叫,專案中在清除聊天未讀訊息中用到了這個函式
父子元件引數傳遞

在專案中我使用的方式是單個模組頂層父元件通過 connect 與 Redux 通訊。子元件通過引數傳遞的方式獲取需要的引數,對於引數型別我們應該規則好,便於後期 debug。

效能上考慮,我們在引數傳遞的過程中儘量只傳遞必須的引數。

路由

在 React-router 4.0 版本,官方也選擇了元件的方式去書寫路由。

下面介紹一下專案中使用到的按需載入路由高階元件

import React, { Component } from "react";
// 其實高階元件就是一個元件通過引數傳遞的方式生成新的元件
export default function asyncComponent(importComponent) {
  class AsyncComponent extends Component {
    constructor(props) {
      super(props);
        // 儲存元件
      this.state = {
        component: null
      };
    }

    async componentDidMount() {
    // 引入元件是需要下載檔案的,所以是個非同步操作
      const { default: component } = await importComponent();

      this.setState({
        component: component
      });
    }
    // 渲染時候判斷檔案下完沒有,下完了就渲染出來
    render() {
      const C = this.state.component;

      return C ? <C {...this.props} /> : null;
    }
  }

  return AsyncComponent;
}

複製程式碼

Redux

Redux 通常是個另新手困惑的點。首先,不是每個專案都需要使用 Redux,元件間通訊不多,邏輯不復雜,你也就不需要使用這個庫,畢竟這個使用這個庫的開發成本很大。

Redux 是與 React 解耦的,所以你想和 Redux 通訊就需要使用 React-redux,你在 action 中使用非同步請求就得使用 Redux-thunk,因為 action 只支援同步操作。

Redux 的組成

Redux 由三部分組成:action,store,reducer。

Action 顧名思義,就是你發起一個操作,具體使用如下:

export function getOrderSuccess(data) {
// 返回的就是一個 action,除了第一個引數一般這樣寫,其餘的引數名隨意
  return { type: GET_ORDER_SUCCESS, payload: data };
}
複製程式碼

Action 發出去以後,會丟給 Reducer。Reducer 是一個純函式(不依賴於且不改變它作用域之外的變數狀態的函式),他接收一個之前的 state 和 action 引數,然後返回一個新的 state 給 store。

export default function(state = initialState, action) {
  switch (action.type) {
    case GET_ALL_ORDERS:
      return state.set("allOrders", action.payload);
    default:
      break;
  }
  return state;
}
複製程式碼

Store 很容易和 state 混淆。你可以把 Store 看成一個容器,state 儲存在這個容器中。Store 提供一些 API 讓你可以對 state 進行訪問,改變等等。

PS:state 只允許在 reducer 中進行改變。

說明完了這些基本概念,我覺得是時候對 Redux 進行一點深入的挖掘。

自己實現 Redux

之前說過 Store 是個容器,那麼可以寫下如下程式碼

class Store {
  constructor() {}
    
    // 以下兩個都是 store 的常用 API
  dispatch() {}

  subscribe() {}
}
複製程式碼

Store 容納了 state,並且能隨時訪問 state 的值,那麼可以寫下如下程式碼

class Store {
  constructor(initState) {
  // _ 代表私有,當然不是真的私有,便於教學就這樣寫了
    this._state = initState
  }
  
  getState() {
    return this._state
  }
    
    // 以下兩個都是 store 的常用 API
  dispatch() {}

  subscribe() {}
}
複製程式碼

接下來我們考慮 dispatch 邏輯。首先 dispatch 應該接收一個 action 引數,並且傳送給 reducer 更新 state。然後如果使用者 subscribe 了 state,我們還應該呼叫函式,那麼可以寫下如下程式碼

dispatch(action) {
    this._state = this.reducer(this.state, action)
    this.subscribers.forEach(fn => fn(this.getState()))
}
複製程式碼

reducer 邏輯很簡單,在 constructor 時將 reducer 儲存起來即可,那麼可以寫下如下程式碼

constructor(initState, reducer) {
    this._state = initState
    this._reducer = reducer
}
複製程式碼

現在一個 Redux 的簡易半成品已經完成了,我們可以來執行下以下程式碼

const initState = {value: 0}
function reducer(state = initState, action) {
    switch (action.type) {
        case 'increase':
            return {...state, value: state.value + 1}
        case 'decrease': {
            return {...state, value: state.value - 1}
        }
    }
    return state
}
const store = new Store(initState, reducer)
store.dispatch({type: 'increase'})
console.log(store.getState()); // -> 1
store.dispatch({type: 'increase'})
console.log(store.getState()); // -> 2
複製程式碼

最後一步讓我們來完成 subscribe 函式, subscribe 函式呼叫如下

store.subscribe(() =>
  console.log(store.getState())
)
複製程式碼

所以 subscribe 函式應該接收一個函式引數,將該函式引數 push 進陣列中,並且呼叫該函式

subscribe(fn) {
    this.subscribers = [...this.subscribers, fn];
    fn(this.value);
}
constructor(initState, reducer) {
    this._state = initState
    this._reducer = reducer
    this.subscribers = []
}
複製程式碼

自此,一個簡單的 Redux 的內部邏輯就完成了,大家可以執行下程式碼試試。

Redux 中介軟體的實現我會在課程中講解,這裡就先放下。通過這段分析,我相信大家應該不會對 Redux 還是很迷惑了。

Immutable.js

我在該專案中使用了該庫,具體使用大家可以看專案,這裡講一下這個庫到底解決了什麼問題。

首先 JS 的物件都是引用關係,當然你可以深拷貝一個物件,但是這個操作對於複雜資料結構來說是相當損耗效能的。

Immutable 就是解決這個問題而產生的。這個庫的資料型別都是不可變的,當你想改變其中的資料時,他會clone 該節點以及它的父節點,所以操作起來是相當高效的。

送給前端開發者的一份新年禮物

這個庫帶來的好處是相當大的: - 防止了非同步安全問題 - 高效能,並且對於做 React 渲染優化提供了很大幫助 - 強大的語法糖 - 時空穿梭 (就是撤銷恢復)

當然缺點也是有點: - 專案傾入性太大 (不推薦老專案使用) - 有學習成本 - 經常忘了重新賦值。。。

對於 Immutable.js 的使用也會在視訊中講述

效能優化

  • 減少不必要的渲染次數
  • 使用良好的資料結構
  • 資料快取,使用 Reselect

具體該如何實現效能優化,在課程的後期也會講述

聊天相關

在聊天功能中我用了 Socket.io 這個庫。該庫會在支援的瀏覽器上使用 Websocket,不支援的會降級使用別的協議。

Websocket 底下使用了 TCP 協議,在生產環境中,對於 TCP 的長連結理論上只需要保證服務端收到訊息並且回覆一個 ACK 就行。

在該專案的聊天資料庫結構設計上,我將每個聊天儲存為一個 Document,這樣後續只需要給這個 Document 的 messages 欄位 push 訊息就行。

const chatSchema = new Schema({
  messageId: String,
  // 聊天雙方
  bothSide: [
    {
      user: {
        type: Schema.Types.ObjectId
      },
      name: {
        type: String
      },
      lastId: {
        type: String
      }
    }
  ],
  messages: [
    {
      // 傳送方
      from: {
        type: Schema.Types.ObjectId,
        ref: "user"
      },
      // 接收方
      to: {
        type: Schema.Types.ObjectId,
        ref: "user"
      },
      // 傳送的訊息
      message: String,
      // 傳送日期
      date: { type: Date, default: Date.now }
    }
  ]
});
// 聊天具體後端邏輯
module.exports = function() {
  io.on("connection", function(client) {
    // 將使用者儲存一起
    client.on("user", user => {
      clients[user] = client.id;
      client.user = user;
    });
    // 斷開連線清除使用者資訊
    client.on("disconnect", () => {
      if (client.user) {
        delete clients[client.user];
      }
    });
    // 傳送聊天物件暱稱
    client.on("getUserName", id => {
      User.findOne({ _id: id }, (error, user) => {
        if (user) {
          client.emit("userName", user.user);
        } else {
          client.emit("serverError", { errorMsg: "找不到該使用者" });
        }
      });
    });
    // 接收資訊
    client.on("sendMessage", data => {
      const { from, to, message } = data;
      const messageId = [from, to].sort().join("");
      const obj = {
        from,
        to,
        message,
        date: Date()
      };
      // 非同步操作,找到聊天雙方
      async.parallel(
        [
          function(callback) {
            User.findOne({ _id: from }, (error, user) => {
              if (error || !user) {
                callback(error, null);
              }
              callback(null, { from: user.user });
            });
          },
          function(callback) {
            User.findOne({ _id: to }, (error, user) => {
              if (error || !user) {
                callback(error, null);
              }
              callback(null, { to: user.user });
            });
          }
        ],
        function(err, results) {
          if (err) {
            client.emit("error", { errorMsg: "找不到聊天物件" });
          } else {
            // 尋找該 messageId 是否存在
            Chat.findOne({
              messageId
            }).exec(function(err, doc) {
              // 不存在就自己建立儲存
              if (!doc) {
                var chatModel = new Chat({
                  messageId,
                  bothSide: [
                    {
                      user: from,
                      name: results[0].hasOwnProperty("from")
                        ? results[0].from
                        : results[1].from
                    },
                    {
                      user: to,
                      name: results[0].hasOwnProperty("to")
                        ? results[0].to
                        : results[1].to
                    }
                  ],
                  messages: [obj]
                });
                chatModel.save(function(err, chat) {
                  if (err || !chat) {
                    client.emit("serverError", { errorMsg: "後端出錯" });
                  }
                  if (clients[to]) {
                    // 該 messageId 不存在就得傳送傳送方暱稱
                    io.to(clients[to]).emit("message", {
                      obj: chat.messages[chat.messages.length - 1],
                      name: results[0].hasOwnProperty("from")
                        ? results[0].from
                        : results[1].from
                    });
                  }
                });
              } else {
                doc.messages.push(obj);

                doc.save(function(err, chat) {
                  if (err || !chat) {
                    client.emit("serverError", { errorMsg: "後端出錯" });
                  }
                  if (clients[to]) {
                    io.to(clients[to]).emit("message", {
                      obj: chat.messages[chat.messages.length - 1]
                    });
                  }
                });
              }
            });
          }
        }
      );
    });
  });
};
複製程式碼

課程中的這塊功能將會以重點來講述,並且會單獨開一個小視訊講解應用層及傳輸層必知知識。

課程相關

視訊預計會在 20 小時以上,但是本人畢竟不是專職講師,還是一線開發者,所以一週只會更新 2 - 3 小時視訊,視訊會在群內第一時間更新連結。

因為大家太熱情了,幾天不到加了600多人,所以還是開通了一個訂閱號用於釋出視訊更新。

送給前端開發者的一份新年禮物

最後

這是專案地址,覺得不錯的可以給我點個 Star。

本篇文章也是我 18 年的第一篇部落格,祝大家新年快樂,在新的一年學習更多的知識!

相關文章