大家好,新年快樂!今天,我開源了一個 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 年的第一篇部落格,祝大家新年快樂,在新的一年學習更多的知識!