Preact源码分析

最近打算学习React源码,发现了一个简易版的框架Preact,且与React的API比较相似,因此决定先看看它的代码。

<!--more-->

本文使用源码版本preact 10.0.0-beta.3,复制了部分核心源码,删除了一些逻辑分支并增加了注释。

1. 开发环境

克隆整个项目,安装依赖,然后进行断点调试

git clone git@github.com:preactjs/preact.git
# 安装项目依赖
cd preact
npm i

# 进入demo项目,安装webpack、babel等相关依赖
cd demo 
npm i

# 启动demo项目,开始进行断点调试
npm run start

修改demo/index.js中的代码,构建一个最基本的应用

import { createElement, render, Component } from 'preact';
class Home extends Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 1
        };
    }
    render() {
        let { count } = this.state;
        let { msg } = this.props;
        return (
            <div>
                <h1>{msg}</h1>
                <p>count:{count}</p>
            </div>
        );
    }
}
let vnode = (
    <Home msg="hello msg"/>
);
console.log(vnode);

let app = document.createElement('div');
document.body.appendChild(app);

render(vnode, app);

我们构建了一个叫Home的组件,然后将它挂载到一个DOM节点上上,从上面代码可以看出,我们首先需要了解Component类和render函数

2. 渲染流程

2.1. Component

下面是Component类的源码,

// src/component.js
export function Component(props, context) {
    this.props = props;
    this.context = context;
}
Component.prototype.setState = function(update, callback) {}
Component.prototype.forceUpdate = function(callback) {}
Component.prototype.render = Fragment

// src/create-element.js
export function Fragment(props) {
    return props.children;
}

可以把Component看做是一个类,我们暂时不需要关心其方法的作用和实现。

2.2. vnode

再回过头来看看<Home />到底是啥东西

// demo/index.js
let vnode = (
    <Home msg="hello msg"/>
);
// 被babel转化成下面代码,chrome调试模式->network面板->main.js中可查看babel编译后的代码
var vnode = Object(preact__WEBPACK_IMPORTED_MODULE_0__["createElement"])(Home, {
    msg: "hello msg"
});
// 打印vnode, 控制台输出下面内容,可见标签上的属性转换成了props
{"props":{"msg":"hello msg"},"_children":null,"_parent":null,"_depth":0,"_dom":null,"_lastDomChild":null,"_component":null}

babel编译JSX,将其转换成createElement方法调用,这也是为什么demo/index.js文件头部需要手动引入一个createElement方法的原因,查看createElement相关源码

// src/create-element.js
export function createElement(type, props, children) {
    props = assign({}, props);

    if (arguments.length>3) {
        // children后传入多个参数时转换为数组
        // ...
    }
    if (type!=null && type.defaultProps!=null) {
        // 处理type.defaultProps,并将其合并到props上
        // ...
    }
    // 处理key和ref
    let ref = props.ref;
    let key = props.key;
    if (ref!=null) delete props.ref;
    if (key!=null) delete props.key;
    // 调用createVNode,因此createElement 返回的是一个vnode
    return createVNode(type, props, key, ref);
}

注意propschildren参数都是有babel编译JSX时,通过解析模板替我们传入的参数。顺藤摸瓜,我们来看看createNode

// src/create-element.js
export function createVNode(type, props, key, ref) {
    // 已经把vnode简化成一个对象字面量了,可以看到这跟上面打印的<Home />基本一致
    const vnode = {
        type,
        props,
        key,
        ref,
        _children: null,
        _parent: null,
        _depth: 0,
        _dom: null,
        _lastDomChild: null,
        _component: null,
        constructor: undefined
    };

    return vnode;
}

2.3. render

现在我们知道了<Home />实际上就是一个vnode,接下来再看看render(<Home />, document.body)中的逻辑

// src/render.js
export function render(vnode, parentDom, replaceNode) {
    if (options._root) options._root(vnode, parentDom);
    let oldVNode = parentDom._children;
    vnode = createElement(Fragment, null, [vnode]); // 使用Fragment包裹了实际的vnode

    let mounts = [];
    diff(
        parentDom, // document.body
        replaceNode ? vnode : (parentDom._children = vnode), // parentDom._children = vnode
        oldVNode || EMPTY_OBJ, // {}
        EMPTY_OBJ, // {}
        parentDom.ownerSVGElement !== undefined, // false
        replaceNode
            ? [replaceNode]
            : oldVNode
                ? null
                : EMPTY_ARR.slice.call(parentDom.childNodes), // document.body所有DOM子节点
        mounts, // []
        false,
        replaceNode || EMPTY_OBJ, // {}
    );
    commitRoot(mounts, vnode);
}

通过断点发现,在diff方法结束之后页面进行了渲染,那么在该方法内,肯定实现了从vnode到实际DOM节点的转变。至此,整个渲染流程分析基本完毕。

2.4. 小结

大致流程如下

  • 创建了一个组件类Home,然后构造了一个vnode,最后调用render方法将该vnode挂载到了页面DOM节点上
  • render函数内部,调用了diff方法,将递归遍历以该vnode构造的AST,并将所有vnode转换成DOM节点,完成页面渲染

preact把渲染相关的操作一并放在了diff代码中,因此看起来涉及到的流程还是比较多的。初始化时可以当做新vnode与旧的空节点做比较,因此第一次渲染也使用与页面更新时相同的diff逻辑来完成渲染。

那么,diff方法内部的流程到底是如何实现的呢?

3. diff三部曲

该函数有点长,我们现在暂时只需要关注初始化时页面的渲染流程,因此下面源码删除了与初始化无关的条件分支。记住,我们现在把初识化的过程当做一个全新的vnode与空节点之间的对比。

// src/diff/index.js
/*
parentDom: 父DOM节点
newVNode: 新的AST根节点
oldVNode: 旧的AST根节点
context: 当前context
isSvg: 是否是svg节点
excessDomChildren: 父节点下其余的DOM节点
mounts: 一个表示需要触发挂载成功的组件列表,从根节点一直透传到所有叶子节点,并收集所有需要出发的节点
force: 是否强制更新
oldDom: 当前DOM节点
*/
export function diff(parentDom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, force, oldDom) {
    let tmp, newType = newVNode.type;

    try {
        outer: if (typeof newType==='function') {
      // 根据oldVNode是否存在判断是更新还是新增节点,初始化相关数组和组件实例
            let c, isNew, oldProps, oldState, snapshot, clearProcessingException;
            let newProps = newVNode.props;
            let cctx =  context;
            // 如果是一个注册的Component组件,则调用构造函数获取组件实例,因此Home组件就是在此处实例化的
      if (newType.prototype && newType.prototype.render) {
        // vnode通过_component维持了对于组件实例的引用,因此可以newVNode._component.setState()等方式调用组件方法
        newVNode._component = c = new newType(newProps, cctx); // eslint-disable-line new-cap
      }else {
        // 根节点由Fragment组件包裹,无render方法,因此直接调用Component
        newVNode._component = c = new Component(newProps, cctx);
        c.constructor = newType;
        c.render = doRender; //  (props, state, context) => this.constructor(props, context)
      }

      c.props = newProps;
      if (!c.state) c.state = {}; // 设置组件默认的state
      c.context = cctx;
      c._context = context;
      isNew = c._dirty = true;
      c._renderCallbacks = [];

            if (c._nextState==null) {
                c._nextState = c.state;
            }
      // 调用组件的getDerivedStateFromProps生命周期,该钩子函数是组件的一个静态方法
      if (newType.getDerivedStateFromProps!=null) {
                assign(c._nextState==c.state ? (c._nextState = assign({}, c._nextState)) : c._nextState, newType.getDerivedStateFromProps(newProps, c._nextState));
            }
      // 调用componentWillMount声明周期函数,可见父组件的componentWillMount先于子组件调用
      // 将注册了componentDidMount声明周期函数的组件放在mounts数组中,等待所有子节点都挂载完毕后在render的commitRoot方法中统一调用
            if (isNew) {
                if (newType.getDerivedStateFromProps==null && c.componentWillMount!=null) c.componentWillMount();
                if (c.componentDidMount!=null) mounts.push(c);
            }

            oldProps = c.props;
            oldState = c.state;

            c.context = cctx;
            c.props = newProps;
      // 设置_nextState的初始值为state
      if (c._nextState==null) {
                c._nextState = c.state;
            }

            c._dirty = false;
            c._vnode = newVNode;
            c._parentDom = parentDom;

      tmp = c.render(c.props, c.state, c.context); // 调用组件render方法

      // 将tmp子节点转换为一个一维数组, 并存放在newVNode._children中
      // 其中coerceToVNode接收一个vnode作为参数,如果vnode已经有了_dom属性,则返回一个克隆后的vnode;否则返回当前vnode
      toChildArray(tmp, newVNode._children=[], coerceToVNode, true); 

      // 开始对比子节点,其内部递归调用了diff方法,通过diffElementNodes获取子节点的真实dom
      // 然后调用parentDom.appendChild(newDom)或parentDom.insertBefore(newDom, oldDom),将dom插入页面
            diffChildren(parentDom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, oldDom);

            c.base = newVNode._dom;
            while (tmp=c._renderCallbacks.pop()) tmp.call(c);
        }else {
      // 一个封装组件的最底层都是用html标签构造的,当newType不是Component时,表示渲染的是元素DOM,其内部调用了document.createElement方法渲染真正的dom
            newVNode._dom = diffElementNodes(oldVNode._dom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts);
        }
    }catch (e) {
        catchErrorInComponent(e, newVNode._parent);
    }

    return newVNode._dom;
}

可见在diff中调用了diffChildren方法来比较两个vNode的所有子节点的差异,让我们紧随其后,一探究竟

// src/diff/children.js
export function diffChildren(parentDom, newParentVNode, oldParentVNode, context, isSvg, excessDomChildren, mounts, oldDom) {
    let childVNode, i, j, oldVNode, newDom, sibDom, firstChildDom, refs;
    // 在上一层的diff方法中已经调用了toChildArray将其组件的render函数返回值转换成了_children属性,
    // 如果render函数未返回数据,则再次调用toChildArray将其props.children属性转换成_children属性
    // 这就是为什么在无render函数的时候还可以染组件内标签的原因
    let newChildren =newParentVNode._children || toChildArray(newParentVNode.props.children, newParentVNode._children=[], coerceToVNode, true); 
    // 获取旧的子节点列表
    let oldChildren = (oldParentVNode && oldParentVNode._children) || EMPTY_ARR;
    for (i=0; i<newChildren.length; i++) {
        // 如果vnode已被使用且关联了一个_dom元素,则克隆出一个新的vnode
        childVNode = newChildren[i] = coerceToVNode(newChildren[i]);
        // 跳过为null的子节点
        if (childVNode!=null) {
            childVNode._parent = newParentVNode;
            childVNode._depth = newParentVNode._depth + 1;

            oldVNode = oldChildren[i];
            // 处理oldChildren[i],如果存在某些未与childVNode做比较的子节点,则再后面会调用unmount进行移除
            if (oldVNode===null || (oldVNode && childVNode.key == oldVNode.key && childVNode.type === oldVNode.type)) {
                oldChildren[i] = undefined;
            }else {
                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;
                }
            }
      oldVNode = oldVNode || EMPTY_OBJ;

      // 开始比较每个子节点的区别,递归调用diff方法内的diffChildren方法,
      // 此时我们将跳转会diff方法,并最终跳转到diffElementNodes方法,获取到一个真实的dom节点,因此这里先阅读下面的 diffElementNodes 源码部分
      newDom = diff(parentDom, childVNode, oldVNode, context, isSvg, excessDomChildren, mounts, null, oldDom);

      // 阅读完diffElementNodes源码,我们知道了diff方法返回的是一个oldVNode._dom经过初始化、diffChildren和diffProps后的DOM节点
      // 此时 newVNode._dom = newDom
          if (newDom!=null) {
                if (firstChildDom == null) {
                    firstChildDom = newDom;
                }
                if (childVNode._lastDomChild != null) {
                    // 我们知道一个组件只能包含一个最外层的子节点,
                    // 如果childVNode.type是一个组件,那么将childVNode保存的_lastDomChild属性赋值给newDom,无需进行下面分支的判断比较
                    newDom = childVNode._lastDomChild;
                    childVNode._lastDomChild = null;
                }else if (excessDomChildren==oldVNode || newDom!=oldDom || newDom.parentNode==null) {
                    outer: if (oldDom==null || oldDom.parentNode!==parentDom) {
                        // 如果父节点都已经修改,则直接向新的parentDom中追加newDom即可
                        parentDom.appendChild(newDom);
                    }
                    else {
                        // 如果父节点相同,则判断newDom是否已经存在parentDom中,不存在则调用insertBefore插入newDom
                        // todo 这里为什么调用的是insertBefore
                        // `j<oldChildrenLength; j+=2` is an alternative to `j++<oldChildrenLength/2`
                        for (sibDom=oldDom, j=0; (sibDom=sibDom.nextSibling) && j<oldChildrenLength; j+=2) {
                            if (sibDom==newDom) {
                                break outer;
                            }
                        }
                        parentDom.insertBefore(newDom, oldDom);
                    }
                }

                oldDom = newDom.nextSibling;
                // 如果childVNode.type是一个组件,保存newDom到其_lastDomChild属性
                if (typeof newParentVNode.type == 'function') {
                    newParentVNode._lastDomChild = newDom;
                }
            }
        }
    }
    newParentVNode._dom = firstChildDom;
    // 如果还存在未被设置为undefined的旧节点,如oldChildrenLength > newChildren.length 的情况,则需要移除旧节点
    for (i=oldChildrenLength; i--; ) if (oldChildren[i]!=null) unmount(oldChildren[i], newParentVNode);
}

接下来看看diffElementNodes是何方神圣

// src/diff/index.js
function diffElementNodes(dom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts) {
    let i;
    let oldProps = oldVNode.props;
    let newProps = newVNode.props;

    isSvg = newVNode.type==='svg' || isSvg;
    // 在diff方法中传入的是oldVNode._dom,第一次调用时会初始化,生成真实的dom节点
    if (dom==null) {
        // 无type类型,返回纯文本节点
        if (newVNode.type===null) {
            return document.createTextNode(newProps);
        }
        // 有类型,如div、h1、p标签等,则返回实际dom节点
        dom = isSvg ? document.createElementNS('http://www.w3.org/2000/svg', newVNode.type) : document.createElement(newVNode.type);
    }
    // 如果节点未发生变化,则可以以该节点为根的子AST未发生变化,即所有子节点均无变化,diff到此为止
    // 只有当新节点发生变化时,才进行该条件判断的逻辑,其内部会继续调用diffChildren判断相关子节点
    if (newVNode!==oldVNode) {
        oldProps = oldVNode.props || EMPTY_OBJ;

        // 替换dom节点html内容为dangerouslySetInnerHTML属性传递的内容
        let oldHtml = oldProps.dangerouslySetInnerHTML;
        let newHtml = newProps.dangerouslySetInnerHTML;
        if ((newHtml || oldHtml) && excessDomChildren==null) {
            // Avoid re-applying the same '__html' if it did not changed between re-render
            if (!newHtml || !oldHtml || newHtml.__html!=oldHtml.__html) {
                dom.innerHTML = newHtml && newHtml.__html || '';
            }
        }
        // 处理multiple属性
        if (newProps.multiple) {
            dom.multiple = newProps.multiple;
        }
        // 将dom作为parentDom,并开始对比newVNode和oldVNode的子节点列表
        diffChildren(dom, newVNode, oldVNode, context, newVNode.type==='foreignObject' ? false : isSvg, excessDomChildren, mounts, EMPTY_OBJ);

        // 将新旧属性的变化复制到新的dom节点上,如style、value、checked等属性
        diffProps(dom, newProps, oldProps, isSvg);
    }
    // 返回新的dom节点,此时跳回diffChildren调用diff方法的地方,调用diff方法得到的就是这个dom节点
    return dom;
}

diff方法需要结合diffChildrendiffElementNodes这两个方法一起阅读,他们内部互相嵌套调用,直至遍历完整个vnode组成的AST。

  • 首先调用diff,根据newType的类型判断调用diffChildren还是diffElementNodes
  • diffChildren中,获取新旧节点的子节点列表,依次递归调用diff方法;
  • diffElementNodes,通过判断newVNode和oldVNode是否相同,如果不相同,则递归调用diffChildren,如果相同,则表示无变化,递归出栈。

在render函数中调用diff方法进行初始化时,oldVnode为空,oldVnode._dom也为null,因此就会进入上面相关代码的初始化化流程。

我们知道diff主要是用来比较新旧两个VNode树,用于减少真实DOM操作的性能消耗,在状态更新引起的页面重新渲染时,我们需要继续关注diff函数的其他工作,在此之前,我们只需要关注vnode是如何转换成DOM即可。

4. setState

在diff代码中可以看见,初始化时,vnode通过vnode._component属性维持了组件实例的引用。而在调用setState更新状态之后,页面会重新渲染组件,接下来让我们看看状态更新时发生了什么。

4.1. 渲染流程

修改demo内的代码

// demo/index.js
setTimeout(() => {
    vnode._component.setState({
        count: 2
    });
}, 1000);

经过1s的延迟之后,会重新渲染文本内容为count:2,现在我们从_component.setState入手,看看调用setState之后的执行流程

// src/component.js
Component.prototype.setState = function(update, callback) {
    // 在diff中初始化时,将_nextState初始化为state,需要注意assign返回的是它的第一个参数
    let s = (this._nextState!==this.state && this._nextState) || (this._nextState = assign({}, this.state));
    if (typeof update!=='function' || (update = update(s, this.props))) {
        // 合并this._nextState和需要更新的数据update,update上的属性会覆盖this._nextState的值
        // 注意此处并不会修改当前this.state的值,setState()方法是异步的!!
        assign(s, update);
    }
    if (update==null) return;
    if (this._vnode) {
        // 收集更新后的回调,在渲染完成之后将执行该回调
        if (callback) this._renderCallbacks.push(callback);
        enqueueRender(this); // 将此次更新入队列
    }
};

然后我们来看看这个渲染队列enqueueRender的实现

let q = [];
const defer = typeof Promise=='function' ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout;

export function enqueueRender(c) {
    if (!c._dirty && (c._dirty = true) && q.push(c) === 1) {
        (options.debounceRendering || defer)(process); // 异步执行process,这里可以说明setState方法是异步的
    }
}
function process() {
    let p;
    // 将节点按深度进行排序,深度越大,排位越靠前,可见子组件先触发forceUpdate
    q.sort((a, b) => b._vnode._depth - a._vnode._depth);
    // 逐步调用forceUpdate,最后清空q
    while ((p=q.pop())) {
        // forceUpdate's callback argument is reused here to indicate a non-forced update.
        if (p._dirty) p.forceUpdate(false);
    }
}

从上面的代码我们知道了setState方法是异步的原因,可知调用setState之后,preact会把组件更新后的数据放在_nextState上,然后将该组件放入渲染队列中,等待所有的setState调用完毕,浏览器进入异步事件队列时,根据组件对应vnode的深度进行排序,依次调用组件的forceUpdate方法,接下来看看forceUpdate这个方法

// src/component.js
Component.prototype.forceUpdate = function(callback) {
    let vnode = this._vnode, oldDom = this._vnode._dom, parentDom = this._parentDom;
    if (parentDom) {
        const force = callback!==false;

        let mounts = [];
        // 调用diff方法,重新渲染页面
        let newDom = diff(parentDom, vnode, assign({}, vnode), this._context, parentDom.ownerSVGElement!==undefined, null, mounts, force, oldDom == null ? getDomSibling(vnode) : oldDom);

        commitRoot(mounts, vnode);

        if (newDom != oldDom) {
            updateParentDomPointers(vnode);
        }
    }
    if (callback) callback();
};

4.2. diff

可以发现,在forceUpdate中,调用的仍旧是diff方法,通过对比新的节点vnode和旧的节点assign({}, vnode)重新渲染页面,因此我们现在需要回到diff方法,查看当更新节点state时是如何重新渲染的。同样地,为了简化流程,移除了大部分与setState相关流程无关的代码。

export function diff(parentDom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, force, oldDom) {
    let tmp, newType = newVNode.type;

    try {
        outer: if (typeof newType==='function') {
            let c, isNew, oldProps, oldState, snapshot, clearProcessingException;
            let newProps = newVNode.props;
            let cctx =  context;

            // 之前已经调用过组件构造函数,因此此处直接赋值
            if (oldVNode._component) {
                c = newVNode._component = oldVNode._component;
                clearProcessingException = c._processingException = c._pendingError;
            }
            // 调用相关声明周期函数
            if (newType.getDerivedStateFromProps!=null) {
                assign(c._nextState==c.state ? (c._nextState = assign({}, c._nextState)) : c._nextState, newType.getDerivedStateFromProps(newProps, c._nextState));
            }
            if (newType.getDerivedStateFromProps==null && force==null && c.componentWillReceiveProps!=null) {
                c.componentWillReceiveProps(newProps, cctx);
            }
            // 如果组件的shouldComponentUpdate方法返回false,则不更新组件,跳出最外层outer处的if循环
            if (!force && c.shouldComponentUpdate!=null && c.shouldComponentUpdate(newProps, c._nextState, cctx)===false) {
                c.props = newProps;
                c.state = c._nextState;
                c._dirty = false;
                c._vnode = newVNode;
                newVNode._dom = oldVNode._dom;
                newVNode._children = oldVNode._children;
                break outer;
            }
            if (c.componentWillUpdate!=null) {
                c.componentWillUpdate(newProps, c._nextState, cctx);
            }

            // 获取新旧节点的props和state
            oldProps = c.props;
            oldState = c.state;

            c.context = cctx;
            c.props = newProps; // 设置新的props
            c.state = c._nextState; // 此时才开始将组件的state设置为调用setState方法传入的新值,牢记setState方法是异步的!!

            c._dirty = false;
            c._vnode = newVNode;
            c._parentDom = parentDom;

            try {
                tmp = c.render(c.props, c.state, c.context); // 重新调用render函数,生成新的vnode,新的vnode会渲染新的dom节点
                toChildArray(tmp, newVNode._children=[], coerceToVNode, true);
            }catch (e) {
                if ((tmp = options._catchRender) && tmp(e, newVNode, oldVNode)) break outer;
                throw e;
            }
            // 调用getSnapshotBeforeUpdate生命周期函数
            if (!isNew && c.getSnapshotBeforeUpdate!=null) {
                snapshot = c.getSnapshotBeforeUpdate(oldProps, oldState);
            }

            // 开始对比子节点,在内部修改newVNode._dom实际DOM节点并挂载到parentDom上,这里与上面在初识化渲染时候分析基本一致
            // 递归diffChildren、diff和diffElementNodes,获取新的newVNode._dom
            diffChildren(parentDom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts, oldDom);

            c.base = newVNode._dom;
            // 此时渲染完毕,开始执行setState时传入的回调函数
            while (tmp=c._renderCallbacks.pop()) tmp.call(c);

            // 调用componentDidUpdate生命周期函数
            if (!isNew && oldProps!=null && c.componentDidUpdate!=null) {
                c.componentDidUpdate(oldProps, oldState, snapshot);
            }
        }
        else {
            newVNode._dom = diffElementNodes(oldVNode._dom, newVNode, oldVNode, context, isSvg, excessDomChildren, mounts);
        }
    }
    catch (e) {
        catchErrorInComponent(e, newVNode._parent);
    }

    return newVNode._dom;
}

剩下的diffChildren方法与diffElementNodes在上面render流程中已基本理清,这里不再赘述。

4.3. 小结

总结一下上面的执行流程

  • 首先调用setState,其内部把需要更新的属性挂载到c._nextState属性上,然后将组件放入enqueueRender队列中
  • 当浏览器进行异步事件循环阶段时,会调用根据enqueueRender中每个组件的深度,从大到小依次调用组件的forceUpdate方法
  • 在组件的forceUpdate中,会调用diff方法重新渲染c._vnode._domDOM节点
    • 在diff方法中,会将c._nextState赋值给c.state,然后重新调用c.render方法,获取新的vnode节点,并通过toChildArray将新的vnode.children赋值为newVNode._children
    • 在diffChildren中,会依次比较newVNode._childrenoldVNode._children所有子节点,如果节点不相同,则返回新的DOM节点;最后删除多余的旧节点
    • 将更新后的DOM节点挂载到parentDom上,并移除多余的旧DOM节点,完成页面渲染的更新

5. props

我们知道,babel会把JSX中标签上的属性转换成props属性,然后传入createElement函数,在上面的diffElementNodes中我们知道,当vnode节点发生改变时,会递归调用diffChildren比较子节点,此外,还会调用diffProps更新当前DOM节点的属性

// src/diff/index.js
function diffElementNodes(dom, newVNode, oldVNode, ...) {
    if (newVNode!==oldVNode) {
        diffChildren(dom, newVNode, oldVNode, ...);
        diffProps(dom, newProps, oldProps, isSvg);
    }
}

前面我们只关注了diffChildren,接下来我们看看props是如何传递给子组件的,下面是diffProps的源码

export function diffProps(dom, newProps, oldProps, isSvg) {
    let i;

    const keys = Object.keys(newProps).sort();
    for (i = 0; i < keys.length; i++) {
        const k = keys[i];
        // 跳过一些特殊的属性名
        if (k!=='children' && k!=='key' && (!oldProps || ((k==='value' || k==='checked') ? dom : oldProps)[k]!==newProps[k])) {
            setProperty(dom, k, newProps[k], oldProps[k], isSvg);
        }
    }

    for (i in oldProps) {
        if (i!=='children' && i!=='key' && !(i in newProps)) {
            setProperty(dom, i, null, oldProps[i], isSvg);
        }
    }
}

可见其内部是通过setProperty更新DOM属性的,针对于属性,我们需要着重关注一下DOM事件是如何绑定的

// src/diff/props.js
function setProperty(dom, name, value, oldValue, isSvg) {
    name = isSvg ? (name==='className' ? 'class' : name) : (name==='class' ? 'className' : name);
    if (name==='style') {
        // 修改样式...
    }
    // Benchmark for comparison: https://esbench.com/bench/574c954bdb965b9a00965ac6
    else if (name[0]==='o' && name[1]==='n') {
        // 处理onClick等事件
        let useCapture = name !== (name=name.replace(/Capture$/, ''));
        let nameLower = name.toLowerCase();
        name = (nameLower in dom ? nameLower : name).slice(2);

        // 注册事件函数
        if (value) {
            if (!oldValue) dom.addEventListener(name, eventProxy, useCapture);
            (dom._listeners || (dom._listeners = {}))[name] = value;
        }
        else {
            dom.removeEventListener(name, eventProxy, useCapture);
        }
    }
    else if (name!=='list' && name!=='tagName' && !isSvg && (name in dom)) {
        // ...特殊处理select和options
    }
    else if (typeof value!=='function' && name!=='dangerouslySetInnerHTML') {
        // 调用setAttribute和removeAttribute...
    }
}

在组件更新时,如果相关的vnode上props属性发生了变化,则会进入diffProps的操作,DOM节点的属性就会随之更新。

6. 小结

本文从一段简易的demo代码触发,分析了preact几段比较核心的代码,包括

  • Component组件系统,包括组件的初始化,生命周期以及render函数的调用时机
  • createElement方法,以及vnode的作用,可见在diff操作中,基本的思路是比较新旧两个vnode
  • setState方法调用时,将组件放在队列中并调用forceUpdate来触发页面的更新
  • diff操作的流程,包括diffdiffChildrendiffElementNodesdiffProps几个方法,了解页面是如何从vnode组成的AST转换成一颗真实的DOM树,可以看见diff过程中对于vnode._dom的复用
  • 分析了props系统以及事件注册,preact的事件是注册在对应的单个DOM节点上的,貌似存在事件委托的逻辑

整体来说,Preact还是比较简单的,通过阅读源码,我们可以大致了解React的实现原理,接下来可以去了解一下preact-routerpreact-redux,这样对于学习React来说应该是有一定帮助的。最后,就应该去试试阅读React的源码了,毕竟只是当一个API使用者是远远不够的。

Preact技术栈:router和redux基本的数据结构