React 的代碼庫如今已經比較龐大了,加上 v16 的 Fiber 重構,初學者很容易陷入細節的大海,搞懂了會讓人以爲本身很牛逼,搞不懂很容易讓人失去信心, 懷疑本身是否應該繼續搞前端。那麼嘗試在本文這裏找回一點自信吧(高手繞路).前端
Preact 是 React 的縮略版, 體積很是小, 但五臟俱全. 若是你想了解 React 的基本原理, 能夠去學習學習 Preact 的源碼, 這也正是本文的目的。node
關於 React 原理的優秀的文章已經很是多, 本文就是老酒裝新瓶, 算是本身的一點總結,也爲後面的文章做一下鋪墊吧.react
文章篇幅較長,閱讀時間約 20min,主要被代碼佔據,另外也畫了流程圖配合理解代碼。web
注意:代碼有所簡化,忽略掉 svg、replaceNode、context 等特性 本文代碼基於 Preact v10 版本算法
Virtual-DOM
從 createElement 開始
Component 的實現
diff 算法
diffChildren
diff
diffElementNodes
diffProps
Hooks 的實現
useState
useEffect
技術地圖
擴展數組
Virtual-DOM瀏覽器
Virtual-DOM 其實就是一顆對象樹,沒有什麼特別的,這個對象樹最終要映射到圖形對象. Virtual-DOM 比較核心的是它的diff算法.緩存
你能夠想象這裏有一個DOM映射器,見名知義,這個’DOM 映射器‘的工做就是將 Virtual-DOM 對象樹映射瀏覽器頁面的 DOM,只不過爲了提升 DOM 的'操做性能'. 它不是每一次都全量渲染整個 Virtual-DOM 樹,而是支持接收兩顆 Virtual-DOM 對象樹(一個更新前,一個更新後), 經過 diff 算法計算出兩顆 Virtual-DOM 樹差別的地方,而後只應用這些差別的地方到實際的 DOM 樹, 從而減小 DOM 變動的成本.babel
Virtual-DOM 是比較有爭議性,推薦閱讀《網上都說操做真實 DOM 慢,但測試結果卻比 React 更快,爲何?》 。切記永遠都不要離開場景去評判一個技術的好壞。當初網上把 React 吹得多麼牛逼, 一些小白就會以爲 Virtual-DOM 很吊,JQuery 弱爆了。markdown
我以爲兩個可比性不大,從性能上看, 框架再怎麼牛逼它也是須要操做原生 DOM 的,並且它未必有你使用 JQuery 手動操做 DOM 來得'精細'. 框架不合理使用也可能出現修改一個小狀態,致使渲染雪崩(大範圍從新渲染)的狀況; 同理 JQuery 雖然能夠精細化操做 DOM, 可是不合理的 DOM 更新策略可能也會成爲應用的性能瓶頸. 因此關鍵還得看你怎麼用.
那爲何須要 Virtual-DOM?
我我的的理解就是爲了解放生產力。現現在硬件的性能愈來愈好,web 應用也愈來愈複雜,生產力也是要跟上的. 儘管手動操做 DOM 可能能夠達到更高的性能和靈活性,可是這樣對大部分開發者來講過低效了,咱們是能夠接受犧牲一點性能換取更高的開發效率的.
因此說 Virtual-DOM 更大的意義在於開發方式的改變: 聲明式、 數據驅動, 讓開發者不須要關心 DOM 的操做細節(屬性操做、事件綁定、DOM 節點變動),也就是說應用的開發方式變成了view=f(state), 這對生產力的解放是有很大推進做用的.
固然 Virtual-DOM 不是惟一,也不是第一個的這樣解決方案. 好比 AngularJS, Vue1.x 這些基於模板的實現方式, 也能夠說實現這種開發方式轉變的. 那相對於他們 Virtual-DOM 的買點可能就是更高的性能了, 另外 Virtual-DOM 在渲染層上面的抽象更加完全, 再也不耦合於 DOM 自己,好比能夠渲染爲 ReactNative,PDF,終端 UI 等等。
從 createElement 開始
不少小白將 JSX 等價爲 Virtual-DOM,其實這二者並無直接的關係, 咱們知道 JSX 不過是一個語法糖.
例如<a rel="nofollow" href="/"><span>Home</span></a>最終會轉換爲h('a', { href:'/' }, h('span', null, 'Home'))這種形式, h是 JSX Element 工廠方法.
h 在 React 下約定是React.createElement, 而大部分 Virtual-DOM 框架則使用h. h 是 createElement 的別名, Vue 生態系統也是使用這個慣例, 具體爲何沒做考究(比較簡短?)。
可使用@jsx註解或 babel 配置項來配置 JSX 工廠:
/**
如今來看看createElement, createElement 不過就是構造一個對象(VNode):
// ⚛️type 節點的類型,有DOM元素(string)和自定義組件,以及Fragment, 爲null時表示文本節點
export function createElement(type, props, children) {
props.children = children;
// ⚛️應用defaultProps
if (type != null && type.defaultProps != null)
for (let i in type.defaultProps)
if (props[i] === undefined) props[i] = type.defaultProps[i];
let ref = props.ref;
let key = props.key;
// ...
// ⚛️構建VNode對象
return createVNode(type, props, key, ref);
}
export function createVNode(type, props, key, ref) {
return { type, props, key, ref, / ... 忽略部份內置字段 / constructor: undefined };
}
複製代碼
經過 JSX 和組件, 能夠構造複雜的對象樹:
render(
<div className="container">
<SideBar />
<Body />
</div>,
root,
);
複製代碼
Component 的實現
對於一個視圖框架來講,組件就是它的靈魂, 就像函數之於函數式語言,類之於面嚮對象語言, 沒有組件則沒法組成複雜的應用.
組件化的思惟推薦將一個應用分而治之, 拆分和組合不一樣級別的組件,這樣能夠簡化應用的開發和維護,讓程序更好理解. 從技術上看組件是一個自定義的元素類型,能夠聲明組件的輸入(props)、有本身的生命週期和狀態以及方法、最終輸出 Virtual-DOM 對象樹, 做爲應用 Virtual-DOM 樹的一個分支存在.
Preact 的自定義組件是基於 Component 類實現的. 對組件來講最基本的就是狀態的維護, 這個經過 setState 來實現:
function Component(props, context) {}
// ⚛️setState實現
Component.prototype.setState = function(update, callback) {
// 克隆下一次渲染的State, _nextState會在一些生命週期方式中用到(例如shouldComponentUpdate)
let s = (this._nextState !== this.state && this._nextState) ||
(this._nextState = assign({}, this.state));
// state更新
if (typeof update !== 'function' || (update = update(s, this.props)))
assign(s, update);
if (this._vnode) { // 已掛載
// 推入渲染回調隊列, 在渲染完成後批量調用
if (callback) this._renderCallbacks.push(callback);
// 放入異步調度隊列
enqueueRender(this);
}
};
複製代碼
enqueueRender 將組件放進一個異步的批執行隊列中,這樣能夠歸併頻繁的 setState 調用,實現也很是簡單:
let q = [];
// 異步調度器,用於異步執行一個回調
const defer = typeof Promise == 'function'
? Promise.prototype.then.bind(Promise.resolve()) // micro task
: setTimeout; // 回調到setTimeout
function enqueueRender(c) {
// 不須要重複推入已經在隊列的Component
if (!c._dirty && (c._dirty = true) && q.push(c) === 1)
defer(process); // 當隊列從空變爲非空時,開始調度
}
// 批量清空隊列, 調用Component的forceUpdate
function process() {
let p;
// 排序隊列,從低層的組件優先更新?
q.sort((a, b) => b._depth - a._depth);
while ((p = q.pop()))
if (p._dirty) p.forceUpdate(false); // false表示不要強制更新,即不要忽略shouldComponentUpdate
}
複製代碼
Ok, 上面的代碼能夠看出 setState 本質上是調用 forceUpdate 進行組件從新渲染的,來往下挖一挖 forceUpdate 的實現.
這裏暫且忽略 diff, 將 diff 視做一個黑盒,他就是一個 DOM 映射器, 像上面說的 diff 接收兩棵 VNode 樹, 以及一個 DOM 掛載點, 在比對的過程當中它能夠會建立、移除或更新組件和 DOM 元素,觸發對應的生命週期方法.
Component.prototype.forceUpdate = function(callback) { // callback放置渲染完成後的回調
let vnode = this._vnode, dom = this._vnode._dom, parentDom = this._parentDom;
if (parentDom) { // 已掛載過
const force = callback !== false;
let mounts = [];
// 調用diff對當前組件進行從新渲染和Virtual-DOM比對
// ⚛️暫且忽略這些參數, 將diff視做一個黑盒,他就是一個DOM映射器,
dom = diff(parentDom, vnode, vnode, mounts, this._ancestorComponent, force, dom);
if (dom != null && dom.parentNode !== parentDom)
parentDom.appendChild(dom);
commitRoot(mounts, vnode);
}
if (callback) callback();
};
複製代碼
在看看 render 方法, 實現跟 forceUpdate 差很少, 都是調用 diff 算法來執行 DOM 更新,只不過由外部指定一個 DOM 容器:
// 簡化版
export function render(vnode, parentDom) {
vnode = createElement(Fragment, null, [vnode]);
parentDom.childNodes.forEach(i => i.remove())
let mounts = [];
diffChildren(parentDom, null oldVNode, mounts, vnode, EMPTY_OBJ);
commitRoot(mounts, vnode);
}
複製代碼
梳理一下上面的流程:
到目前爲止沒有看到組件的其餘功能,如初始化、生命週期函數。這些特性在 diff 函數中定義,也就是說在組件掛載或更新的過程當中被調用。下一節就會介紹 diff
diff 算法
千呼萬喚始出來,經過上文能夠看出,createElement 和 Component 邏輯都很薄, 主要的邏輯仍是集中在 diff 函數中. React 將這個過程稱爲 Reconciliation, 在 Preact 中稱爲 Differantiate.
爲了簡化程序 Preact 的實現將 diff 和 DOM 雜糅在一塊兒, 但邏輯仍是很清晰,看下目錄結構就知道了:
src/diff
├── children.js # 比對children數組
├── index.js # 比對兩個節點
└── props.js # 比對兩個DOM節點的props
複製代碼
在深刻 diff 程序以前,先看一下基本的對象結構, 方便後面理解程序流程. 先來看下 VNode 的外形:
type ComponentFactory<P> = preact.ComponentClass<P> | FunctionalComponent<P>;
interface VNode<P = {}> {
// 節點類型, 內置DOM元素爲string類型,而自定義組件則是Component類型,Preact中函數組件只是特殊的Component類型
type: string | ComponentFactory<P> | null;
props: P & { children: ComponentChildren } | string | number | null;
key: Key
ref: Ref<any> | null;
/**
diffChildren
先從最簡單的開始, 上面已經猜出 diffChildren 用於比對兩個 VNode 列表.
如上圖, 首先這裏須要維護一個表示當前插入位置的變量 oldDOM, 它一開始指向 DOM childrenNode 的第一個元素, 後面每次插入更新或插入 newDOM,都會指向 newDOM 的下一個兄弟元素.
在遍歷 newChildren 列表過程當中, 會嘗試找出相同 key 的舊 VNode,和它進行 diff. 若是新 VNode 和舊 VNode 位置不同,這就須要移動它們;對於新增的 DOM,若是插入位置(oldDOM)已經到告終尾,則直接追加到父節點, 不然插入到 oldDOM 以前。
最後卸載舊 VNode 列表中未使用的 VNode.
來詳細看看源碼:
export function diffChildren(
parentDom, // children的父DOM元素
newParentVNode, // children的新父VNode
oldParentVNode, // children的舊父VNode,diffChildren主要比對這兩個Vnode的children
mounts, // 保存在此次比對過程當中被掛載的組件實例,在比對後,會觸發這些組件的componentDidMount生命週期函數
ancestorComponent, // children的直接父'組件', 即渲染(render)VNode的組件實例
oldDom, // 當前掛載的DOM,對於diffChildren來講,oldDom一開始指向第一個子節點
) {
let newChildren = newParentVNode._children || toChildArray(newParentVNode.props.children, (newParentVNode._children = []), coerceToVNode, true,);
let oldChildren = (oldParentVNode && oldParentVNode._children) || EMPTY_ARR;
// ...
// ⚛️遍歷新childrenfor (i = 0; i < newChildren.length; i++) {childVNode = newChildren[i] = coerceToVNode(newChildren[i]); // 規範化VNodeif (childVNode == null) continue// ⚛️查找oldChildren中是否有對應的元素,若是找到則經過設置爲undefined,從oldChildren中移除// 若是沒有找到則保持爲nulloldVNode = oldChildren[i];for (j = 0; j < oldChildrenLength; j++) {oldVNode = oldChildren[j];if (oldVNode && childVNode.key == oldVNode.key && childVNode.type === oldVNode.type) {oldChildren[j] = undefined;break;}oldVNode = null; // 沒有找到任何舊node,表示是一個新的}// ⚛️ 遞歸比對VNodenewDom = diff(parentDom, childVNode, oldVNode, mounts, ancestorComponent, null, oldDom);// vnode沒有被diff卸載掉if (newDom != null) {if (childVNode._lastDomChild != null) {// ⚛️當前VNode是Fragment類型// 只有Fragment或組件返回Fragment的Vnode會有非null的_lastDomChild, 從Fragment的結尾的DOM樹開始比對:// <A> <A>// <> <>