深入Preact源码分析(4.20更新)

2,576 阅读14分钟

React的源码多达几万行,对于我们想要快速阅读并看懂是相当有难度的,而Preact是一个轻量级的类react库,几千行代码就实现了react的大部分功能。因此阅读preact源码,对于我们学习react的思想并加强认识是非常有用的。

本文仓库github

下面是正文部分

源码结构

Preact导出的函数结构

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就完成了

流程图如下

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的分析就结束了。

流程图如下

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。

流程图如下:

下一步,就是研究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完成了