(給前端大全加星標,提升前端技能)
作者:繁星
https://zhuanlan.zhihu.com/p/103692400
聊聊 React 的 class 組件組件是 React 應用的構建塊,自上而下的數據流結合組件可以將 UI 解構為獨立且可復用的單元。組件主要做的事情主要有以下三點:
假如現在有一個新聞列表頁面,列表的每一項都包含有標題、概要、詳情和縮略圖,如圖所示:
只是渲染內容。如果不考慮查看詳情這個交互,新聞列表的每一項是很純的,也就是 props 傳入什麼數據,就能渲染出一一對應的結果:
letNewsItem= (props) => {
return(
<li>
<img src={props.imgUrl} />
<div>
<h2>{props.title}</h2>
<p>{props.summary}</p>
<p style={{display: 'none'}}>{props.detail}</p>
<a>查看詳情</a>
</div>
</li>
)
}
要考慮查看詳情這個交互,就必須在 NewsItem 裡加入一個 isDetailShow 的 state 來表示新聞摘要與詳情的互斥顯示。到目前為止,NewsItem 還是很純的,並沒有和外部有交互。
要實現新聞圖片的懶加載,只有 NewsItem 進入可視區時才將 img 的 src 替換為真實的 url,這就要求 NewsItem 必須監聽瀏覽器事件,並在組件被卸載時移除這些監聽(防止內存洩漏)。此時,NewsItem 便不是一個純的組件了,因為與外部有了交互,這種與外部的交互被稱為副作用(函數式編程裡沒有任何副作用的函數被稱為純函數)。
組件的副作用是不可避免的,最常見的有 fetch data,訂閱事件,進行 DOM 操作,使用其他 JavaScript 庫(比如 jQuery,Map 等)。在這個例子中,NewsItem 並沒有 fetch data,相關職責由不純的父組件來承擔。
綜上,我們的組件需要 state 來存儲一定的邏輯狀態,並且需要可以訪問並更改 state 的方法函數。
class 就是一個很好的表現形式:要渲染的內容(props 或 state)放在類的屬性裡,那些處理用戶交互的回調函數和生命周期函數放在類的方法裡。方法與屬性通過 class 的形式建立了關聯,有能力訪問和更改屬性。回調函數通過更改對應屬性處理用戶操作,生命周期函數則給予開發者處理組件與外部的交互能力(處理副作用)。
這樣通過 class 組件,ReactDOM 就能做到渲染數據,綁定事件,並在不同的生命周期調用開發者所編寫的代碼,按需求將數據渲染成 HTML DOM,然後被瀏覽器渲染展示出來。
將組件渲染粗暴地分為若干個階段,通過生命周期函數處理副作用會帶來一些問題:
重複邏輯,被吐槽最多的例子如下:
async componentDidMount() {
const res = awaitget(`/users`);
this.setState({ users: res.data });
};
async componentDidUpdate(prevProps) {
if(prevProps.resource !== this.props.resource) {
const res = awaitget(`/users`);
this.setState({ users: res.data });
}
};
同一職責代碼有可能需要被強行分拆到不同的生命周期,例如同一個事件的訂閱與取消訂閱;
一部分代碼被分割到不同生命周期中,會導致組件沒有優雅的復用 state 邏輯代碼的能力,高階組件或 render props 等模式引入了嵌套,複雜且不靈活;
越來越多邏輯被放入不同生命周期函數中,這種組織方式導致代碼越來越複雜難懂;
除了這些,class 組件中的 this 也常被人們拿出來吐槽。那麼,是否有更優雅的設計呢?
閉包為什麼在某種程度上能取代 class?我們的程序在執行的時候主要做了兩件事:
為了實現復用,我們將具有特定單一功能的邏輯放在函數裡,這樣既可以消滅掉重複代碼,又可以讓我們在思考問題時能夠進行合理的分解,降低代碼複雜度。
但是只有函數是不夠的,函數是一個標準的輸入-加工-輸出模型,輸入和輸出的都是變量裡所存儲的數據,當一個系統的複雜度高到一定程度的時候,將函數與其所操作的數據(環境)關聯起來就很有必要了。
註:函數式編程要求把I/O限制到最小,幹掉所有不必要的讀寫行為,保持計算過程的單純性。
最常見的將變量與函數關聯起來方式有:
函數對於其詞法環境(lexical environment)的引用共同構成閉包(closure),簡單說,一個函數內部能夠訪問到函數外的變量,如果這 個函數內部引用了其外部的變量,且自身又被別處引用,那這個不會被銷毀的函數就和它所引用的外部變量一起構成閉包。例如:
// 模塊化下可以將 makeCounter 內部代碼放在 makeCounter.js 中,並將 return 改為 export
const makeCounter = () => {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return{
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
};
// 使用 makeCounter
const counter = makeCounter();
console.log(counter.value()); /* logs 0 */
counter.increment();
counter.increment();
console.log(counter.value()); /* logs 2 */
counter.decrement();
console.log(counter.value()); /* logs 1 */
看,我們使用閉包將變量 privateCounter 與幾個函數關聯了起來,從這點來講能力與面向對象編程相同。
組件的 API 設計API 的核心在於表達能力,對於 React 組件來說,就是如何讓開發者將需求良好地表達出來,然後被 ReactDOM 識別並渲染。
class 組件和 functional 組件所要表達的內容是是一樣的,只是表現形式不同。它們都努力做到了一點:將存儲組件狀態的 state 與處理這些 state 的方法關聯起來。具體一點說就是一下三點:
state 存儲組件的狀態,並可被渲染成 HTML DOM;
用來處理用戶操作事件的回調函數可以訪問並變更 state,觸發組件重新渲染;
用來處理與組件外部交互(副作用)的函數可以訪問並變更 state,觸發組件重新渲染;
2 中函數的執行是確定的,用戶的操作觸發某個事件後就會執行相應的回調函數,更改 state,觸發新的渲染。開發者需要有能力控制 3 中的函數執行,確定要不要執行以及在什麼時候執行。在 class 組件中,生命周期函數給開發者提供了這種控制能力。
那麼,如果我們通過一套 API 設計實現以上三點且避開 class 組件的缺陷,提供更好的分離關注點能力,讓代碼復用更加簡易,是不是一件很值得期待的事情呢?React Hooks 就是滿足這些要求的新設計。
React Hooks 原理先來看一個使用 React Hooks 的例子:
functionCounter() {
const[counter, setCounter] = useState(0);
function increment() {
setCounter(counter+1);
}
function decrement() {
setCounter(counter-1);
}
return(
<div className="content">
<h1>MyAwesomeCounter</h1>
<hr/>
<h2 className="count">{counter}</h2>
<div className="buttons">
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
</div>
);
}
是的,你看到了這個例子與閉包例子中的 makeCounter 十分相似。makerCounter 使用程序控制並通過 console 出結果,Counter 通過用戶點擊控制,輸出包含結果且可以被渲染的組件。除了這點不同,其他部分代碼原理是完全一致的,只是 Hook 進行了一些封裝,讓開發者編寫代碼體驗更好。
我們來看下 useState 的簡化實現:
// React useState hooks
constReact= (function() {
let hooks = [];
let idx = 0;
return{
render(Component) {
const C = Component();
C.render();
idx = 0; // reset for next render
return C;
},
useState(initVal) {
const state = hooks[idx] || initVal;
const _idx = idx;
const setState = newVal => {
hooks[_idx] = newVal;
};
idx++;
return[state, setState];
}
};
})();
// Component which use useState
const{ useState, render } = React;
functionCounter() {
const[count, setCount] = useState(0);
const[text, setText] = useState('apple');
return{
render() {
console.log(`text: ${text}, count: ${count}`);
},
click() {
setCount(count + 1);
},
type(type) {
setText(type)
}
};
}
// simulate render
const counter = render(Counter); // text: apple, count: 0
counter.click();
render(Counter); // text: apple, count: 1
counter.type("pear");
render(Counter); //text: pear, count: 1
代碼很簡單,這裡不做解讀,這裡重點說幾點:
React 的 API 設計能力確實不錯,用解構賦值將 state 和對應的 setState 放在一起,簡潔明了;
useState 的第一次執行可以取代 class 的構造函數初始化過程,值為 useState 的參數 initVal,運行後存儲在閉包中所對應的 hooks[index] 變量裡。從第二次 render 時開始訪問 hooks[index] 而不是 initVal;
初始化時每調用一次 useState ,閉包裡 hooks 便會遞增分配對應的 index key 來存儲對應的值。render 結束後 index 會重置為 0,下一次 render 執行 useState 時會按照相同順序訪問 hooks[index];
正是因為 hooks 是這樣實現的,我們在調用 hooks 的時候必須要嚴格保證每一次 render 都能獲得一致的執行順序,所以必須要做到:
到目前為止,我們已經可以通過 hooks 的形式管理 state,並通過調用包含 setState 的回調函數處理用戶操作。剩下要解決的便是副作用的問題,useEffect 是 hooks 所提供的方案,下面來看一下 useEffect 的簡化實現原理(並不完整):
useEffect(cb, depArray) {
const hasNoDeps = !depArray;
hooks[idx] = hooks[idx] || {};
const{deps, cleanup} = hooks[idx]; // undefined when first render
const hasChanged = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if(hasNoDeps || hasChanged) {
cleanup && cleanup();
hooks[idx].cleanup = cb();
hooks[idx].deps = depArray;
}
idx++;
}
完整簡化代碼地址:https://stackblitz.com/edit/behind-react-hook
useEffect 提供了一個函數(上面代碼中的 cb)運行的容器,這個容器有以下幾個特點:
useEffect 容器在每次 render 後運行;
不區分 Mounting 和 Updating ,每次 render 後都會執行容器 useEffect;
cb 運行時可以訪問到 Functional 組件的內部變量(包含通過 useState 生成的任何 state 和 setState);
cb 是否執行取決於依賴數組裡的依賴項是否發生變化。如果沒有依賴數組,每次 render 後都會調用 cb。如果依賴數組為[],僅在第一次 render 後調用;
容器中的 cb 執行後可以返回一個函數 cleanup,在下一次執行 cb 之前會調用 cleanup;
在 Unmounting 時如果有返回的 cleanup,也會調用(簡化代碼沒有實現);
通過將副作用相關代碼放在 useEffect 的 cb 中,並在 cb 返回的函數裡移除副作用,我們可以在一個 useEffect 中實現任何想要的生命周期控制:
依賴數組為 [] 可以實現僅在 Mouting 時執行;
不寫依賴數組可以實現 Mouting 和 Updating 時執行;
cb 返回的 cleanup 函數可以執行 Unmounting 時執行的代碼;
可以通過依賴數組裡的內容是否變更來控制 cb 是否執行;
這種設計最大的好處就是我們可以將單一職責的代碼放在一個獨立的 useEffect 容器裡,而不是粗暴地將它們拆分在各個生命周期函數中。同時也要注意的是,useEffect 的 cb 必須要返回一個 cleanup 函數或者 undefined,所以不可以是 async 函數;
React Hooks 的優點通過 Hooks 我們可以對 state 邏輯進行良好的封裝,輕鬆做到隔離和復用,優點主要體現在:
復用代碼更容易:hooks 是普通的 JavaScript 函數,所以開發者可以將內置的 hooks 組合到處理 state 邏輯的自定義 hooks中,這樣複雜的問題可以轉化一個單一職責的函數,並可以被整個應用或者 React 社區所使用;
使用組合方式更優雅:不同於 render props 或高階組件等的模式,hooks 不會在組件樹中引入不必要的嵌套,也不會受到 mixins 的負面影響;
更少的代碼量:一個 useEffect 執行單一職責,可以幹掉生命周期函數中的重複代碼。避免將同一職責代碼分拆在幾個生命周期函數中,更好的復用能力可以幫助優秀的開發者最大限度降低代碼量;
代碼邏輯更清晰:hooks 幫助開發者將組件拆分為功能獨立的函數單元,輕鬆做到「分離關注點」,代碼邏輯更加清晰易懂;
單元測試:處理 state 邏輯的自定義 hooks 可以被獨立進行單元測試,更加可靠;
本文主要介紹了 React Hooks 設計思想和優點,但 hooks 也是有不少」坑點「的,我們在使用的時候要利用好優點,努力避開」坑點「。後面我會單獨寫一篇文章來介紹 React Hooks 的實踐。
覺得本文對你有幫助?請分享給更多人
關注「前端大全」加星標,提升前端技能
好文章,我在看❤️