深入Preact原始碼分析(4.20更新)

flytam發表於2018-04-15

React的原始碼多達幾萬行,對於我們想要快速閱讀並看懂是相當有難度的,而Preact是一個輕量級的類react庫,幾千行程式碼就實現了react的大部分功能。因此閱讀preact原始碼,對於我們學習react的思想並加強認識是非常有用的。

本文的倉庫在github上,持續更新中。歡迎大佬們star或提意見。

下面是正文部分

原始碼結構

Preact匯出的函式結構

深入Preact原始碼分析(4.20更新)

import { h, h as createElement } from './h';
import { cloneElement } from './clone-element';
import { Component } from './component';
import { render } from './render';
import { rerender } from './render-queue';
import options from './options';
/**
 * h函式和createElement函式是同一個函式
 *
 * */
export default {
    h,
    createElement,
    cloneElement,
    Component,
    render,
    rerender,
    options
};

export {
    h,
    createElement,
    cloneElement,
    Component,
    render,
    rerender,
    options
};
複製程式碼

jsx是如何轉化成virtualDOM的

jsx要轉化成virtualDOM,首先經過babel,再經過h函式的呼叫形成virtualDOM。具體如下

原始碼連結 src/h.js

相當於react得createElement(),jsx經過babel轉碼後是h的迴圈呼叫,生成virtualDOM。

// jsx
<div>
<span className="sss" fpp="xxx">123</span>
<Hello/>
<span>xxx</span>
</div>

// h結果
h(
  "div",
  null,
  h(
    "span",
    { className: "sss", fpp: "xxx" },
    "123"
  ),
h(Hello, null),
  h(
    "span",
    null,
    "xxx"
  )
);
複製程式碼

通過原始碼中h的函式定義也可以看見。h的函式第一個引數是標籤名(如果是元件型別的化就是元件名)、第二個引數是屬性值的key-value物件,後面的引數是所有子元件。

vnode的結構

h函式會根據子元件的不同型別進行封裝,具體如下

  • bool 返回 null
  • null 返回 ""
  • number 返回 String(number)

最後賦值給child變數並存進childdren陣列中,再封裝成下面的vnode結構並返回

{
    nodeName:"div",//標籤名
    children:[],//子元件組成的陣列,每一項也是一個vnode
    key:"",//key
    attributes:{}//jsx的屬性
}
複製程式碼

virtualDOM如何變為真實dom

// 一個簡單的Preact demo
import { h, render, Component } from 'preact';

class Clock extends Component {
	render() {
		let time = new Date().toLocaleTimeString();
		return <span>{ time }</span>;
	}
}

render(<Clock />, document.body);
複製程式碼

呼叫了preact的render方法將virtualDOM渲染到真實dom。

// render.js
import { diff } from './vdom/diff';
export function render(vnode, parent, merge) {
	return diff(merge, vnode, {}, false, parent, false);
}
複製程式碼

可見,render方法的第一個引數一個vnode,第二個引數是要掛載到的dom的節點,這裡暫時不考慮第三個引數。而render方法實際上又是 去呼叫/vdom/diff.js下的diff方法

//diff函式的定義
export function diff(dom, vnode, context, mountAll, parent, componentRoot) {}
複製程式碼

render函式使vnode轉換成真實dom主要進行了以下操作

  • render函式實際上呼叫了diff方法,diff方法進而呼叫了idiff。
  • idiff方法會返回真實的html。idiff內將vnode分為4大型別進行處理封裝在html
  • 然後呼叫diffAttributes,將vnode上的屬性值更新到html domnode的屬性上。(通過setAccessor)
  • 初次render時,下面if條件恆為真,所以真實html就這樣被裝進了。
 if (parent && ret.parentNode !== parent) parent.appendChild(ret);
複製程式碼

這樣初次的vnode轉化成真實html就完成了

流程圖如下

深入Preact原始碼分析(4.20更新)

tips:在diff中會見到很多的out[ATTR_KEY],這個是用來將dom的attributrs陣列每一項的name value轉化為鍵值對存進 out[ATTR_KEY]。

元件的buildComponentFromNode是怎樣的?

buildComponentFromNode的定義

/** Apply the Component referenced by a VNode to the DOM.
*	@param {Element} dom	The DOM node to mutate
*	@param {VNode} vnode	A Component-referencing VNode
*	@returns {Element} dom	The created/mutated element
*	@private
*/
export function buildComponentFromVNode(dom, vnode, context, mountAll) {}
複製程式碼

初次呼叫時 buildComponentFromNode(undefined,vnode,{},false)。因此,初次render時的buildComponentFromVNode內部只是呼叫瞭如下的邏輯(不執行的程式碼去掉了)


export function buildComponentFromVNode(dom, vnode, context, mountAll) {
   let c = dom && dom._component, // undefined
   	originalComponent = c,//undefined
   	oldDom = dom,// undefined
   	isDirectOwner = c && dom._componentConstructor===vnode.nodeName,//undefined
   	props = getNodeProps(vnode);// 這個函式除了一般的props獲取外,還會加上defaultProps。
   	c = createComponent(vnode.nodeName, props, context);// 建立元件
   	setComponentProps(c, props, SYNC_RENDER, context, mountAll);
   	dom = c.base;
   return dom;
}
複製程式碼

緊接上節,Preact元件從vnode到真實html的過程發生了什麼?

...
// buildComponentFromVNode方法內部
// buildComponentFromVNode(undefined, vnode, {}, false);
c = createComponent(vnode.nodeName, props, context);// 建立元件
setComponentProps(c, props, SYNC_RENDER, context, mountAll);
dom = c.base;
    return dom;
....
複製程式碼

從上節元件變成真實dom的過程中最重要的函式就是createComponentsetComponentProps。我們可以發現,在先後執行了createComponentsetComponentProps後,真實dom就是c.base了。那麼 這個createComponent幹了什麼?去掉一些初始渲染時不會去執行的程式碼,簡化後的程式碼如下:

// 如果是用class定義的那種有生命週期的元件,上文程式碼中的```vnode.nodeName```其實就是我們定義的那個class。
export function createComponent(Ctor, props, context) {
    let inst;
    if (Ctor.prototype && Ctor.prototype.render) {
        // 正常的元件 class xxx extends Component{} 定義的
        //首先是對自己的元件例項化
        inst = new Ctor(props, context);
        //然後再在我們例項化的元件,去獲得一些Preact的內建屬性(props、state,這兩個是掛在例項上的)和一些內建方法(setState、render之類的,這些方法是掛在原型上的)
        Component.call(inst, props, context);
    } else {
        // 無狀態元件
        //無狀態元件是沒有定義render的,它的render方法就是這個無狀態元件本身
        inst = new Component(props, context);
        inst.constructor = Ctor;
        inst.render = doRender;
    }
    return inst;
}

function doRender(props, state, context) {
    // 無狀態元件的render方法就是自己本身
    return this.constructor(props, context);
}
複製程式碼

Component的定義如下。通過上面和下面的程式碼可以知道,createComponent的主要作用就是讓我們編寫的class型和無狀態型元件例項化, 這個例項是具有相似的結構。並供後面的setComponentProps去使用產生真實dom。

// Component的定義
export function Component(props, context) {
	this._dirty = true;// 這個東西先不管,應該是和diff有關
	this.context = context;// context這個東西我也暫時不知道有什麼用
	this.props = props;
	this.state = this.state || {};
}
// 這裡的extend就是一個工具函式,把setState、forceUpdate、render方法掛載到原型上
extend(Component.prototype,{
    setState(state,callback){},
    forceUpdate(callback){},
    render() {}
})
複製程式碼

setComponentProps產生真實dom的過程。

setComponentProps(c, props, SYNC_RENDER, {}, false);

export function setComponentProps(component, props, opts, context, mountAll) {
    // 同理去除條件不成立的程式碼,只保留首次渲染時執行的關鍵步驟
    if (!component.base || mountAll) {
        // 可見。componentWillMount生命週期方法只會在未載入之前執行,
        if (component.componentWillMount) component.componentWillMount();
    }
    renderComponent(component, SYNC_RENDER, mountAll);
}
複製程式碼

由上面程式碼可見,setComponentProps內部,實際上關鍵是呼叫了renderComponent方法。renderComponent邏輯有點繞, 精簡版程式碼如下。

renderComponent主要邏輯簡單來說如下: 1、呼叫元件例項的render方法去產生vnode。

2、如果這個元件產生的vnode不再是元件了。則通過diff函式去產生真實dom並掛載(前面已經分析過)diff(cbase, rendered, context, mountAll || !isUpdate, initialBase && initialBase.parentNode, true);

3、如果這個元件的子vnode還是子元件的話。則再次呼叫setComponentPropsrenderComponent去進一步生成真實dom,直到2中條件成立。(判斷步驟和2、3類似),但是有點區別的是。這種呼叫程式碼是

setComponentProps(inst, childProps, NO_RENDER, context, false);// 不渲染。只是去執行下生命週期方法,在這個setComponentProps內部是不呼叫 renderComponent的。 至於為啥。。暫時我也不知道。NO_RENDER標誌位
renderComponent(inst, SYNC_RENDER, mountAll, true);
複製程式碼

精簡版程式碼

export function renderComponent(component, opts, mountAll, isChild) {
    // 這個函式其實很長有點複雜的,只保留了初次渲染時執行的部分和關鍵的部分。
        // 呼叫元件的render方法,返回vnode
        rendered = component.render(props, state, context);//*****
        let childComponent = rendered && rendered.nodeName,base;
        if (typeof childComponent === 'function') {
            // 子節點也是自定義元件的情況
            let childProps = getNodeProps(rendered);
                component._component = inst = createComponent(childComponent, childProps, context);
				setComponentProps(inst, childProps, NO_RENDER, context, false);// 不渲染啊。只是去執行下生命週期方法
                renderComponent(inst, SYNC_RENDER, mountAll, true);// 對比  renderComponent(component, SYNC_RENDER, mountAll);
        } else {
            base = diff(。。。);// 掛載
        }
        component.base = base; //把真實dom掛載到base屬性上
        if (!diffLevel && !isChild) flushMounts();
}
複製程式碼

前面看到了componentWillMount生命週期了,那麼componentDidMount這個生命週期呢?它就是在flushMounts。這個if語句成立的條件是在祖先元件並且初次渲染時才執行(初次渲染的diffLevel值為0)。

export function flushMounts() {
    let c;
    while ((c = mounts.pop())) {
        if (options.afterMount) options.afterMount(c);
        if (c.componentDidMount) c.componentDidMount();
    }
}
複製程式碼

flushMounts中的mounts就是當前掛載的元件的例項。它是一個棧的結構並依次出棧執行componentDidMount。所以, 這就能說明了Preact(React也一樣)父子元件的生命週期執行順序了 parentWillMount -> parentRender -> childWillMount -> childRender -> childDidMount -> parentDidParent。

至此元件型別的vnode產生真實dom的分析就結束了。

流程圖如下

深入Preact原始碼分析(4.20更新)

setState發生了什麼

setState(state, callback) {
    let s = this.state;
    if (!this.prevState) this.prevState = extend({}, s);
    extend(s, typeof state==='function' ? state(s, this.props) : state);// 語句3
    if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
    enqueueRender(this);
},
複製程式碼

setState的定義如上,程式碼邏輯很容易看出

1、prevState若不存在,將要更新的state合併到prevState上

2、可以看出Preact中setState引數也是可以接收函式作為引數的。將要更新的state合併到當前的state

3、如果提供了回撥函式,則將回撥函式放進_renderCallbacks佇列

4、呼叫enqueueRender進行元件更新

why?我剛看到setState的第2、3行程式碼的時候也是一臉矇蔽。為什麼它要這樣又搞一個this.prevState又搞一個this.state,又有個state呢?WTF。 通過理清Preact的setState的執行原理。

應該是用於處理一個元件在一次流程中呼叫了兩次setState的情況。

// 例如這裡的handleClick是繫結click事件

handleClick = () =>{
    // 注意,preact中setState後state的值是會馬上更新的
    this.setState({a:this.state.a+1});
    console.log(this.state.a);
    this.setState({a:this.state.a+1});
    console.log(this.state.a);
} 
複製程式碼

基本上每一個學react的人,都知道上述程式碼函式在react中執行之後a的值只會加一,but!!!!在Preact中是加2的!!!!通過分析Preact的setState可以解釋這個原因。 在上面的語句3,extend函式呼叫後,當前的state值已經改變了。但是即使state的值改變了,但是多次setState仍然是會只進行一次元件的更新(通過setTimeout把更新操作放在當前事件迴圈的最後),以最新的state為準。所以,這裡的prevState應該是用於記錄當前setState之前的上一次state的值,用於後面的diff計算。在enqueueRender執行diff時比較prevState和當前state的值

關於enqueueRender的相關定義

let items = [];

export function enqueueRender(component) {
	// dirty 為true表明這個元件重新渲染
    if (!component._dirty && (component._dirty = true) && items.push(component) == 1) {//語句1
        // 只會執行一遍
        (options.debounceRendering || defer)(rerender); // 相當於setTimeout render 語句2
    }
}

export function rerender() {
    let p, list = items;
    items = [];
    while ((p = list.pop())) {
        if (p._dirty) renderComponent(p);
    }
}
複製程式碼

enqueueRender的邏輯主要是

1、語句1: 將呼叫了setState的元件的_dirty屬性設定為false。通過這段程式碼我們還可以發現, 如果在一次流程中,呼叫了多次setState,rerender函式實際上還是隻執行了一遍(通過判斷component._dirty的值來保證一個元件內的多次setState只執行一遍rerender和判斷items.push(component) == 1確保如果存在父元件呼叫setState,然後它的子元件也呼叫了setState,還是隻會執行一次rerender)。items佇列是用來存放當前所有dirty元件。

2、語句2。可以看作是setTimeout,將rerender函式放在本次事件迴圈結束後執行。rerender函式對所有的dirty元件執 行renderComponent進行元件更新。

在renderComponent中將會執行的程式碼。只列出和初次渲染時有區別的主要部分

export function renderComponent(component, opts=undefined, mountAll=undefined, isChild=undefined) {
    ....
    if (isUpdate) {
        component.props = previousProps;
        component.state = previousState;
        component.context = previousContext;
        if (opts !== FORCE_RENDER && // FORCE_RENDER是在呼叫元件的forceUpdate時設定的狀態位
            component.shouldComponentUpdate &&
            component.shouldComponentUpdate(props, state, context) === false) {
            skip = true;// 如果shouldComponentUpdate返回了false,設定skip標誌為為true,後面的渲染部分將會被跳過
        } else if (component.componentWillUpdate) {
            component.componentWillUpdate(props, state, context);//執行componentWillUpdate生命週期函式
        }

        // 更新元件的props state context。因為componentWillUpdate裡面有可能再次去修改它們的值
        component.props = props;
        component.state = state;
        component.context = context;
    }
    ....
    component._dirty = false;
    ....
    // 省略了diff渲染和dom更新部分程式碼
    ...
    if (!skip) {
        if (component.componentDidUpdate) {
            //componentDidUpdate生命週期函式
            component.componentDidUpdate(previousProps, previousState, previousContext);
        }
    }

    if (component._renderCallbacks != null) {
        // 執行setState的回撥
        while (component._renderCallbacks.length) component._renderCallbacks.pop().call(component);
    }
}
複製程式碼

邏輯看程式碼註釋就很清晰了。先shouldComponentUpdate生命週期,根據返回值決定是都否更新(通過skip標誌位)。然後將元件的_dirty設定為true表明已經更新了該元件。然後diff元件更新,執行componentDidUpdate生命週期,最後執行setState傳進的callback。

流程圖如下:

深入Preact原始碼分析(4.20更新)

下一步,就是研究setState元件進行更新時的diff演算法幹了啥

非元件節點的diff分析

diff的流程,我們從簡單到複雜進行分析

通過前面幾篇文章的原始碼閱讀,我們也大概清楚了diff函式引數的定義和component各引數的作用

/**
 * @param dom 初次渲染是undefinde,第二次起是指當前vnode前一次渲染出的真實dom
 * @param vnode vnode,需要和dom進行比較
 * @param context 類似與react的react
 * @param mountAll
 * @param parent
 * @param componentRoot
 * **/
function diff(dom, vnode, context, mountAll, parent, componentRoot){}
複製程式碼
// component
{

    base,// dom
    nextBase,//dom

    _component,//vnode對應的元件
    _parentComponent,// 父vnode對應的component
    _ref,// props.ref 
    _key,// props.key
    _disable,

    prevContext,
    context,

    props,
    prevProps,

    state,
    previousState

    _dirty,// true表示該元件需要被更新
    __preactattr_// 屬性值

    /***生命週期方法**/
    .....
}
複製程式碼

diff不同型別的vnode也是不同的。Preact的diff演算法,是將setState後的vnode與前一次的dom進行比較的,邊比較邊更新。diff主要進行了兩步操作(對於非文字節點來說), 先diff內容innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML != null);,再diff屬性diffAttributes(out, vnode.attributes, props);

1、字串或者布林型 如果之前也是一個文字節點,則直接修改節點的nodeValue的值;否則,建立一個新節點,並取代舊節點。並呼叫recollectNodeTree對舊的dom進行臘雞回收。

2、html的標籤型別

  • 如果vnode的標籤對比dom發生了改變(例如原來是span,後來是div),則新建一個div節點,然後把span的子元素都新增到新的div節點上,把新的div節點替換掉舊的span節點,然後回收舊的(回收節點的操作主要是把這個節點從dom中去掉,從vdom中也去掉)
    if (!dom || !isNamedNode(dom, vnodeName)) {
         // isNamedNode方法就是比較dom和vnode的標籤型別是不是一樣
        out = createNode(vnodeName, isSvgMode);
        if (dom) {
            while (dom.firstChild) out.appendChild(dom.firstChild);
            if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
            recollectNodeTree(dom, true);//recollectNodeTree
        }
    }
複製程式碼
  • 對於子節點的diff

    • Preact對於只含有一個的子字串節點直接進行特殊處理
        if (!hydrating && vchildren && vchildren.length === 1 && typeof vchildren[0] === 'string' && fc != null && fc.splitText !== undefined && fc.nextSibling == null) {
        if (fc.nodeValue != vchildren[0]) {
            fc.nodeValue = vchildren[0];
        }
    }
    複製程式碼
    • 對於一般情況
    /****/
    innerDiffNode(out, vchildren, context, mountAll, hydrating || props.dangerouslySetInnerHTML != null);
    複製程式碼

    那麼,innerDiffNode函式做了什麼? 首先,先解釋下函式內定義的一些關鍵變數到底幹了啥

        let originalChildren = dom.childNodes,// 舊dom的子node集合
        children = [],// 用來儲存舊dom中,沒有提供key屬性的dom node
        keyed = {},// 用來存舊dom中有key的dom node,
    複製程式碼

    首先,第一步的操作就是對舊的dom node進行分類。將含有key的node存進keyed變數有,這是一個鍵值對結構; 將無key的存進children中,這是一個陣列結構。

    然後,去迴圈遍歷vchildren的每一項,用vchild表示每一項。若有key屬性,則取尋找keyed中是否有該key對應的真實dom;若無,則去遍歷children 資料,尋找一個與其型別相同(例如都是div標籤這樣)的節點進行diff(用child這個變數去儲存)。然後執行idiff函式 child = idiff(child, vchild, context, mountAll);。通過前面分析idiff函式,我們知道如果傳進idiff的child為空,則會新建一個節點。所以對於普通節點的內容的diff就完成了。然後把這個返回新的dom node去取代舊的就可以了,程式碼如下

            f = originalChildren[i];
            if (child && child !== dom && child !== f) {
                if (f == null) {
                    dom.appendChild(child);
                } else if (child === f.nextSibling) {
                    removeNode(f);
                } else {
                    dom.insertBefore(child, f);
                }
            }
    複製程式碼

    當對vchildren遍歷完成diff操作後,把keyedchildren中剩餘的dom節點清除。因為他們在新的vnode結構中已經不存在了

    然後對於屬性進行diff就可以了。diffAttributes的邏輯就比較簡單了,取出新vnode 的 props和舊dom的props進行比較。新無舊有的去除,新有舊有的替代,新有舊無的新增。setAccessor是對於屬性值設定時一些保留字和特殊情況進行一層封裝處理

    function diffAttributes(dom, attrs, old) {
    let name;
    for (name in old) {
        if (!(attrs && attrs[name] != null) && old[name] != null) {
            setAccessor(dom, name, old[name], old[name] = undefined, isSvgMode);
        }
    }
    for (name in attrs) {
        if (name !== 'children' && name !== 'innerHTML' && (!(name in old) || attrs[name] !== (name === 'value' || name === 'checked' ? dom[name] : old[name]))) {
            setAccessor(dom, name, old[name], old[name] = attrs[name], isSvgMode);
        }
    }
    }
    複製程式碼

    至此,對於非元件節點的內容的diff完成了

相關文章