React Hooks 設(shè)計(jì)思想
作者:繁星https://zhuanlan.zhihu.com/p/103692400
聊聊 React 的 class 組件
組件是 React 應(yīng)用的構(gòu)建塊,自上而下的數(shù)據(jù)流結(jié)合組件可以將 UI 解構(gòu)為獨(dú)立且可復(fù)用的單元。組件主要做的事情主要有以下三點(diǎn):
將傳入的 props 和 內(nèi)部 state 渲染到頁面上;
管理內(nèi)部 state,并根據(jù) state 變化渲染出最新的結(jié)果;
處理與組件外部的交互;
假如現(xiàn)在有一個(gè)新聞列表頁面,列表的每一項(xiàng)都包含有標(biāo)題、概要、詳情和縮略圖,如圖所示:

只是渲染內(nèi)容。如果不考慮查看詳情這個(gè)交互,新聞列表的每一項(xiàng)是很純的,也就是 props 傳入什么數(shù)據(jù),就能渲染出一一對(duì)應(yīng)的結(jié)果:
let NewsItem = (props) => {
return (
<img src={props.imgUrl} />
{props.title}h2>
{props.summary}p>
<p style={{display: 'none'}}>{props.detail}p>
div>
li>
)
}要考慮查看詳情這個(gè)交互,就必須在 NewsItem 里加入一個(gè) isDetailShow 的 state 來表示新聞?wù)c詳情的互斥顯示。到目前為止,NewsItem 還是很純的,并沒有和外部有交互。
要實(shí)現(xiàn)新聞圖片的懶加載,只有 NewsItem 進(jìn)入可視區(qū)時(shí)才將 img 的 src 替換為真實(shí)的 url,這就要求 NewsItem 必須監(jiān)聽瀏覽器事件,并在組件被卸載時(shí)移除這些監(jiān)聽(防止內(nèi)存泄漏)。此時(shí),NewsItem 便不是一個(gè)純的組件了,因?yàn)榕c外部有了交互,這種與外部的交互被稱為副作用(函數(shù)式編程里沒有任何副作用的函數(shù)被稱為純函數(shù))。
組件的副作用是不可避免的,最常見的有 fetch data,訂閱事件,進(jìn)行 DOM 操作,使用其他 JavaScript 庫(比如 jQuery,Map 等)。在這個(gè)例子中,NewsItem 并沒有 fetch data,相關(guān)職責(zé)由不純的父組件來承擔(dān)。
綜上,我們的組件需要 state 來存儲(chǔ)一定的邏輯狀態(tài),并且需要可以訪問并更改 state 的方法函數(shù)。
class 就是一個(gè)很好的表現(xiàn)形式:要渲染的內(nèi)容(props 或 state)放在類的屬性里,那些處理用戶交互的回調(diào)函數(shù)和生命周期函數(shù)放在類的方法里。方法與屬性通過 class 的形式建立了關(guān)聯(lián),有能力訪問和更改屬性?;卣{(diào)函數(shù)通過更改對(duì)應(yīng)屬性處理用戶操作,生命周期函數(shù)則給予開發(fā)者處理組件與外部的交互能力(處理副作用)。
這樣通過 class 組件,ReactDOM 就能做到渲染數(shù)據(jù),綁定事件,并在不同的生命周期調(diào)用開發(fā)者所編寫的代碼,按需求將數(shù)據(jù)渲染成 HTML DOM,然后被瀏覽器渲染展示出來。
將組件渲染粗暴地分為若干個(gè)階段,通過生命周期函數(shù)處理副作用會(huì)帶來一些問題:
重復(fù)邏輯,被吐槽最多的例子如下:
async componentDidMount() {
const res = await get(`/users`);
this.setState({ users: res.data });
};
async componentDidUpdate(prevProps) {
if (prevProps.resource !== this.props.resource) {
const res = await get(`/users`);
this.setState({ users: res.data });
}
};同一職責(zé)代碼有可能需要被強(qiáng)行分拆到不同的生命周期,例如同一個(gè)事件的訂閱與取消訂閱;
一部分代碼被分割到不同生命周期中,會(huì)導(dǎo)致組件沒有優(yōu)雅的復(fù)用 state 邏輯代碼的能力,高階組件或 render props 等模式引入了嵌套,復(fù)雜且不靈活;
越來越多邏輯被放入不同生命周期函數(shù)中,這種組織方式導(dǎo)致代碼越來越復(fù)雜難懂;
除了這些,class 組件中的 this 也常被人們拿出來吐槽。那么,是否有更優(yōu)雅的設(shè)計(jì)呢?
閉包為什么在某種程度上能取代 class?
我們的程序在執(zhí)行的時(shí)候主要做了兩件事:
I/O讀寫,聲明變量,系統(tǒng)為之分配內(nèi)存,程序執(zhí)行時(shí)讀取或更改變量所存儲(chǔ)的數(shù)據(jù);
處理運(yùn)算,程序的結(jié)構(gòu)(順序、分支與循環(huán))以及對(duì)數(shù)據(jù)的運(yùn)算等,也就是程序的邏輯實(shí)現(xiàn);
為了實(shí)現(xiàn)復(fù)用,我們將具有特定單一功能的邏輯放在函數(shù)里,這樣既可以消滅掉重復(fù)代碼,又可以讓我們在思考問題時(shí)能夠進(jìn)行合理的分解,降低代碼復(fù)雜度。
但是只有函數(shù)是不夠的,函數(shù)是一個(gè)標(biāo)準(zhǔn)的輸入-加工-輸出模型,輸入和輸出的都是變量里所存儲(chǔ)的數(shù)據(jù),當(dāng)一個(gè)系統(tǒng)的復(fù)雜度高到一定程度的時(shí)候,將函數(shù)與其所操作的數(shù)據(jù)(環(huán)境)關(guān)聯(lián)起來就很有必要了。
注:函數(shù)式編程要求把I/O限制到最小,干掉所有不必要的讀寫行為,保持計(jì)算過程的單純性。
最常見的將變量與函數(shù)關(guān)聯(lián)起來方式有:
利用全局變量,在全局變量里建一個(gè)大表,將特定函數(shù)與表中對(duì)應(yīng)字段關(guān)聯(lián)起來;
面向?qū)ο缶幊?。?duì)象允許我們將某些數(shù)據(jù)(對(duì)象的屬性)與一個(gè)或多個(gè)函數(shù)方法相關(guān)聯(lián);
閉包;
函數(shù)對(duì)于其詞法環(huán)境(lexical environment)的引用共同構(gòu)成閉包(closure),簡單說,一個(gè)函數(shù)內(nèi)部能夠訪問到函數(shù)外的變量,如果這 個(gè)函數(shù)內(nèi)部引用了其外部的變量,且自身又被別處引用,那這個(gè)不會(huì)被銷毀的函數(shù)就和它所引用的外部變量一起構(gòu)成閉包。例如:
// 模塊化下可以將 makeCounter 內(nèi)部代碼放在 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 與幾個(gè)函數(shù)關(guān)聯(lián)了起來,從這點(diǎn)來講能力與面向?qū)ο缶幊滔嗤?/p>
組件的 API 設(shè)計(jì)
API 的核心在于表達(dá)能力,對(duì)于 React 組件來說,就是如何讓開發(fā)者將需求良好地表達(dá)出來,然后被 ReactDOM 識(shí)別并渲染。
class 組件和 functional 組件所要表達(dá)的內(nèi)容是是一樣的,只是表現(xiàn)形式不同。它們都努力做到了一點(diǎn):將存儲(chǔ)組件狀態(tài)的 state 與處理這些 state 的方法關(guān)聯(lián)起來。具體一點(diǎn)說就是一下三點(diǎn):
state 存儲(chǔ)組件的狀態(tài),并可被渲染成 HTML DOM;
用來處理用戶操作事件的回調(diào)函數(shù)可以訪問并變更 state,觸發(fā)組件重新渲染;
用來處理與組件外部交互(副作用)的函數(shù)可以訪問并變更 state,觸發(fā)組件重新渲染;
2 中函數(shù)的執(zhí)行是確定的,用戶的操作觸發(fā)某個(gè)事件后就會(huì)執(zhí)行相應(yīng)的回調(diào)函數(shù),更改 state,觸發(fā)新的渲染。開發(fā)者需要有能力控制 3 中的函數(shù)執(zhí)行,確定要不要執(zhí)行以及在什么時(shí)候執(zhí)行。在 class 組件中,生命周期函數(shù)給開發(fā)者提供了這種控制能力。
那么,如果我們通過一套 API 設(shè)計(jì)實(shí)現(xiàn)以上三點(diǎn)且避開 class 組件的缺陷,提供更好的分離關(guān)注點(diǎn)能力,讓代碼復(fù)用更加簡易,是不是一件很值得期待的事情呢?React Hooks 就是滿足這些要求的新設(shè)計(jì)。
React Hooks 原理
先來看一個(gè)使用 React Hooks 的例子:
function Counter() {
const [counter, setCounter] = useState(0);
function increment() {
setCounter(counter+1);
}
function decrement() {
setCounter(counter-1);
}
return (
<div className="content">
My Awesome Counter h1>
<hr/>
<h2 className="count">{counter}h2>
<div className="buttons">
<button onClick={increment}>+button>
<button onClick={decrement}>-button>
div>
div>
);
}是的,你看到了這個(gè)例子與閉包例子中的 makeCounter 十分相似。makerCounter 使用程序控制并通過 console 出結(jié)果,Counter 通過用戶點(diǎn)擊控制,輸出包含結(jié)果且可以被渲染的組件。除了這點(diǎn)不同,其他部分代碼原理是完全一致的,只是 Hook 進(jìn)行了一些封裝,讓開發(fā)者編寫代碼體驗(yàn)更好。
我們來看下 useState 的簡化實(shí)現(xiàn):
// React useState hooks
const React = (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;
function Counter() {
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代碼很簡單,這里不做解讀,這里重點(diǎn)說幾點(diǎn):
React 的 API 設(shè)計(jì)能力確實(shí)不錯(cuò),用解構(gòu)賦值將 state 和對(duì)應(yīng)的 setState 放在一起,簡潔明了;
useState 的第一次執(zhí)行可以取代 class 的構(gòu)造函數(shù)初始化過程,值為 useState 的參數(shù) initVal,運(yùn)行后存儲(chǔ)在閉包中所對(duì)應(yīng)的 hooks[index] 變量里。從第二次 render 時(shí)開始訪問 hooks[index] 而不是 initVal;
初始化時(shí)每調(diào)用一次 useState ,閉包里 hooks 便會(huì)遞增分配對(duì)應(yīng)的 index key 來存儲(chǔ)對(duì)應(yīng)的值。render 結(jié)束后 index 會(huì)重置為 0,下一次 render 執(zhí)行 useState 時(shí)會(huì)按照相同順序訪問 hooks[index];
正是因?yàn)?hooks 是這樣實(shí)現(xiàn)的,我們在調(diào)用 hooks 的時(shí)候必須要嚴(yán)格保證每一次 render 都能獲得一致的執(zhí)行順序,所以必須要做到:
不要在循環(huán)、條件語句或嵌套函數(shù)中調(diào)用 Hooks;
只能在 React 函數(shù)中調(diào)用 Hooks;
到目前為止,我們已經(jīng)可以通過 hooks 的形式管理 state,并通過調(diào)用包含 setState 的回調(diào)函數(shù)處理用戶操作。剩下要解決的便是副作用的問題,useEffect 是 hooks 所提供的方案,下面來看一下 useEffect 的簡化實(shí)現(xiàn)原理(并不完整):
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 提供了一個(gè)函數(shù)(上面代碼中的 cb)運(yùn)行的容器,這個(gè)容器有以下幾個(gè)特點(diǎn):
useEffect 容器在每次 render 后運(yùn)行;
不區(qū)分 Mounting 和 Updating ,每次 render 后都會(huì)執(zhí)行容器 useEffect;
cb 運(yùn)行時(shí)可以訪問到 Functional 組件的內(nèi)部變量(包含通過 useState 生成的任何 state 和 setState);
cb 是否執(zhí)行取決于依賴數(shù)組里的依賴項(xiàng)是否發(fā)生變化。如果沒有依賴數(shù)組,每次 render 后都會(huì)調(diào)用 cb。如果依賴數(shù)組為[],僅在第一次 render 后調(diào)用;
容器中的 cb 執(zhí)行后可以返回一個(gè)函數(shù) cleanup,在下一次執(zhí)行 cb 之前會(huì)調(diào)用 cleanup;
在 Unmounting 時(shí)如果有返回的 cleanup,也會(huì)調(diào)用(簡化代碼沒有實(shí)現(xiàn));
通過將副作用相關(guān)代碼放在 useEffect 的 cb 中,并在 cb 返回的函數(shù)里移除副作用,我們可以在一個(gè) useEffect 中實(shí)現(xiàn)任何想要的生命周期控制:
依賴數(shù)組為 [] 可以實(shí)現(xiàn)僅在 Mouting 時(shí)執(zhí)行;
不寫依賴數(shù)組可以實(shí)現(xiàn) Mouting 和 Updating 時(shí)執(zhí)行;
cb 返回的 cleanup 函數(shù)可以執(zhí)行 Unmounting 時(shí)執(zhí)行的代碼;
可以通過依賴數(shù)組里的內(nèi)容是否變更來控制 cb 是否執(zhí)行;
這種設(shè)計(jì)最大的好處就是我們可以將單一職責(zé)的代碼放在一個(gè)獨(dú)立的 useEffect 容器里,而不是粗暴地將它們拆分在各個(gè)生命周期函數(shù)中。同時(shí)也要注意的是,useEffect 的 cb 必須要返回一個(gè) cleanup 函數(shù)或者 undefined,所以不可以是 async 函數(shù);
React Hooks 的優(yōu)點(diǎn)
通過 Hooks 我們可以對(duì) state 邏輯進(jìn)行良好的封裝,輕松做到隔離和復(fù)用,優(yōu)點(diǎn)主要體現(xiàn)在:
復(fù)用代碼更容易:hooks 是普通的 JavaScript 函數(shù),所以開發(fā)者可以將內(nèi)置的 hooks 組合到處理 state 邏輯的自定義 hooks中,這樣復(fù)雜的問題可以轉(zhuǎn)化一個(gè)單一職責(zé)的函數(shù),并可以被整個(gè)應(yīng)用或者 React 社區(qū)所使用;
使用組合方式更優(yōu)雅:不同于 render props 或高階組件等的模式,hooks 不會(huì)在組件樹中引入不必要的嵌套,也不會(huì)受到 mixins 的負(fù)面影響;
更少的代碼量:一個(gè) useEffect 執(zhí)行單一職責(zé),可以干掉生命周期函數(shù)中的重復(fù)代碼。避免將同一職責(zé)代碼分拆在幾個(gè)生命周期函數(shù)中,更好的復(fù)用能力可以幫助優(yōu)秀的開發(fā)者最大限度降低代碼量;
代碼邏輯更清晰:hooks 幫助開發(fā)者將組件拆分為功能獨(dú)立的函數(shù)單元,輕松做到“分離關(guān)注點(diǎn)”,代碼邏輯更加清晰易懂;
單元測試:處理 state 邏輯的自定義 hooks 可以被獨(dú)立進(jìn)行單元測試,更加可靠;
本文主要介紹了 React Hooks 設(shè)計(jì)思想和優(yōu)點(diǎn),但 hooks 也是有不少”坑點(diǎn)“的,我們在使用的時(shí)候要利用好優(yōu)點(diǎn),努力避開”坑點(diǎn)“。后面我會(huì)單獨(dú)寫一篇文章來介紹 React Hooks 的實(shí)踐。
點(diǎn)分享 點(diǎn)點(diǎn)贊 點(diǎn)在看




