您好,登錄后才能下訂單哦!
React 的代碼庫現在已經比較龐大了,加上 v16 的 Fiber 重構,初學者很容易陷入細節的大海,搞懂了會讓人覺得自己很牛逼,搞不懂很容易讓人失去信心, 懷疑自己是否應該繼續搞前端。那么嘗試在本文這里找回一點自信吧(高手繞路).
Preact 是 React 的縮略版, 體積非常小, 但五臟俱全. 如果你想了解 React 的基本原理, 可以去學習學習 Preact 的源碼, 這也正是本文的目的。
關于 React 原理的優秀的文章已經非常多, 本文就是老酒裝新瓶, 算是自己的一點總結,也為后面的文章作一下鋪墊吧.
文章篇幅較長,閱讀時間約 20min,主要被代碼占據,另外也畫了流程圖配合理解代碼。
注意:代碼有所簡化,忽略掉 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 變更的成本.
Virtual-DOM 是比較有爭議性,推薦閱讀《網上都說操作真實 DOM 慢,但測試結果卻比 React 更快,為什么?》 。切記永遠都不要離開場景去評判一個技術的好壞。當初網上把 React 吹得多么牛逼, 一些小白就會覺得 Virtual-DOM 很吊,JQuery 弱爆了。
我覺得兩個可比性不大,從性能上看, 框架再怎么牛逼它也是需要操作原生 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 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;
// ...
// ??遍歷新children
for (i = 0; i < newChildren.length; i++) {
childVNode = newChildren[i] = coerceToVNode(newChildren[i]); // 規范化VNode
if (childVNode == null) continue
// ??查找oldChildren中是否有對應的元素,如果找到則通過設置為undefined,從oldChildren中移除
// 如果沒有找到則保持為null
oldVNode = 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,表示是一個新的
}
// ?? 遞歸比對VNode
newDom = 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>
// <> <>
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。