MrErHu / blog

Star 就是最大的鼓励 👏👏👏
MIT License
605 stars 40 forks source link

从Preact了解一个类React的框架是怎么实现的(三): 组件 #24

Open MrErHu opened 7 years ago

MrErHu commented 7 years ago

前言

  首先欢迎大家关注我的掘金账号和Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。   之前分享过几篇关于React的文章:

  其实我在阅读React源码的时候,真的非常痛苦。React的代码及其复杂、庞大,阅读起来挑战非常大,但是这却又挡不住我们的React的原理的好奇。前段时间有人就安利过Preact,千行代码就基本实现了React的绝大部分功能,相比于React动辄几万行的代码,Preact显得别样的简洁,这也就为了我们学习React开辟了另一条路。本系列文章将重点分析类似于React的这类框架是如何实现的,欢迎大家关注和讨论。如有不准确的地方,欢迎大家指正。      在前两篇文章

  我们分别了解了Preact中元素创建以及diff算法,其中就讲到了组件相关一部分内容。对于一个类React库,组件(Component)可能是最需要着重分析的部分,因为编写类React程序的过程中,我们几乎都是在写一个个组件(Component)并将其组合起来形成我们所需要的应用。下面我们就从头开始了解一下Preact中的组件是怎么实现的。   

组件渲染

  首先我们来了解组件返回的虚拟dom是怎么渲染为真实dom,来看一下Preact的组件是如何构造的:   

//component.js
function Component(props, context) {
    this._dirty = true;

    this.context = context;

    this.props = props;

    this.state = this.state || {};
}

extend(Component.prototype, {

    setState(state, callback) {
        //......
    },

    forceUpdate(callback) {
        //......
    },

    render() {}

});

  可能我们会想当然地认为组件Component的构造函数定义将会及其复杂,事实上恰恰相反,Preact的组件定义代码极少。组件的实例属性仅仅有四个:

  通过extends方法(原理类似于ES6中的Object.assign或者Underscore.js中的_.extends),我们给组件的构造函数的原型中创建一下几个方法:

  所以当我们编写组件(Component)类继承preact.Component时,也就仅仅只能继承上述的方法和属性,这样所以对于用户而言,不仅提供了及其简洁的API以供使用,而且最重要的是我们将组件内部的逻辑封装起来,与用户相隔离,避免用户无意间修改了组件的内部实现,造成不必要的错误。      对于阅读过从Preact了解一个类React的框架是怎么实现的(二): 元素diff的同学应该还记的preact所提供的render函数调用了内部的diff函数,而diff实际会调用idiff函数(更详细的可以阅读第二篇文章):   

  从上面的图中可以看到,在idiff函数内部中在开始如果vnode.nodeName是函数(function)类型,则会调用函数buildComponentFromVNode:   

function buildComponentFromVNode(dom, vnode, context, mountAll) {
    //block-1
    let c = dom && dom._component,
        originalComponent = c,
        oldDom = dom,
        isDirectOwner = c && dom._componentConstructor===vnode.nodeName,
        isOwner = isDirectOwner,
        props = getNodeProps(vnode);
    //block-2   
    while (c && !isOwner && (c=c._parentComponent)) {
        isOwner = c.constructor===vnode.nodeName;
    }
    //block-3
    if (c && isOwner && (!mountAll || c._component)) {
        setComponentProps(c, props, ASYNC_RENDER, context, mountAll);
        dom = c.base;
    }
    else {
    //block-4
        if (originalComponent && !isDirectOwner) {
            unmountComponent(originalComponent);
            dom = oldDom = null;
        }

        c = createComponent(vnode.nodeName, props, context);
        if (dom && !c.nextBase) {
            c.nextBase = dom;
            oldDom = null;
        }
        setComponentProps(c, props, SYNC_RENDER, context, mountAll);
        dom = c.base;

        if (oldDom && dom!==oldDom) {
            oldDom._component = null;
            recollectNodeTree(oldDom, false);
        }
    }
    return dom;
}

  函数buildComponentFromVNode的作用就是将表示组件的虚拟dom(VNode)转化成真实dom。参数分别是:

  为了方便分析,我们将函数分解成几个部分,依次分析:   

getNodeProps(vnode) {
    let props = extend({}, vnode.attributes);
    props.children = vnode.children;

    let defaultProps = vnode.nodeName.defaultProps;
    if (defaultProps!==undefined) {
        for (let i in defaultProps) {
            if (props[i]===undefined) {
                props[i] = defaultProps[i];
            }
        }
    }

    return props;
}

  函数getNodeProps的逻辑并不复杂,将vnodeattributeschidlren的属性赋值到props,然后如果存在组件中存在defaultProps的话,将defaultProps存在的属性并且对应props不存在的属性赋值进入了props中,并将props返回。

HOC  => component => DOM元素

  上面HOC代表高阶组件,返回组件component,然后组件component渲染DOM元素。在Preact,这种高阶组件与返回的子组件之间存在属性标识,即HOC的组件实例中的_component指向compoent的组件实例而组件component实例的_parentComponent属性指向HOC实例。我们知道,DOM中的属性_component指向的是对应的组件实例,需要注意的是在上面的例子中DOM对应的_component指向的是HOC实例,而不是component实例。如果理解了上面的部分,就能理解为什么会存在这个循环了,其目的就是为了找到最开始渲染该DOM的高阶组件(防止某些情况下dom对应的_component属性指代的实例被修改),然后再判断该高阶组件是否与当前的vnode类型一致。

  其实如果之前就阅读过Preact的diff算法的同学来说,其实整个组件大致渲染的流程我们已经清楚了,但是如果想要更深层次的了解其中的细节我们必须去深究函数createComponentsetComponentProps的内部细节。   

createComponent

  关于函数createComponent,我们看一下component-recycler.js文件:   

import { Component } from '../component';

const components = {};

export function collectComponent(component) {
    let name = component.constructor.name;
    (components[name] || (components[name] = [])).push(component);
}

export function createComponent(Ctor, props, context) {
    let list = components[Ctor.name],
        inst;

    if (Ctor.prototype && Ctor.prototype.render) {
        inst = new Ctor(props, context);
        Component.call(inst, props, context);
    }
    else {
        inst = new Component(props, context);
        inst.constructor = Ctor;
        inst.render = doRender;
    }

    if (list) {
        for (let i=list.length; i--; ) {
            if (list[i].constructor===Ctor) {
                inst.nextBase = list[i].nextBase;
                list.splice(i, 1);
                break;
            }
        }
    }
    return inst;
}

function doRender(props, state, context) {
    return this.constructor(props, context);
}

  变量components的主要作用就是为了能重用组件渲染的内容而设置的共享池(Share Pool),通过函数collectComponent就可以实现回收一个组件以供以后重复利用。在函数collectComponent中通过组件名(component.constructor.name)分类将可重用的组件缓存在缓存池中。      函数createComponent主要作用就是创建组件实例。参数propscontext分别对应的是组件的中属性和context(与React一致),而Ctor组件则是需要创建的组件类型(函数或者是类)。我们知道如果我们的组件定义用ES6定义如下:

class App extends Component{}

  我们知道class仅仅只是一个语法糖,上面的代码使用ES5去实现相当于:

function App(){}
App.prototype = Object.create(Component.prototype, {
    constructor: {
        value: App,
        enumerable: true,
        writable: true,
        configurable: true
    }
});

  如果你对ES5中的Object.create也不熟悉的话,我简要的介绍一下,Object.create的作用就是实现原型继承(Prototypal Inheritance)来实现基于已有对象创建新对象。Object.create的第一个参数就是所要继承的原型对象,第二个参数就是新对象定义额外属性的对象(类似于Object.defineProperty的参数),如果要我自己实现一个简单的Object.create函数我们可以这样写:   

function create(prototype, ...obj){
    function F(){}
    F.prototype = prototype;
    return Object.defineProperties(new F(), ...obj);
}

  现在你肯定知道了如果你的组件继承了Preact中的Component的话,在原型中一定存在render方法,这时候通过new创建Ctor的实例inst(实例中已经含有了你自定义的render函数),但是如果没有给父级构造函数super传入propscontext,那么inst中的propscontext的属性为undefined,通过强制调用Component.call(inst, props, context)可以给instpropscontext进行初始化赋值。      如果组件中不存在render函数,说明该函数是PFC(Pure Function Component)类型,即是纯函数组件。这时直接调用函数Component创建实例,实例的constructor属性设置为传入的函数。由于实例中不存在render函数,则将doRender函数作为实例的render属性,doRender函数会将Ctor的返回的虚拟dom作为结果返回。      然后我们从组件回收的共享池中那拿到同类型组件的实例,从其中取出该实例之前渲染的实例(nextBase),然后将其赋值到我们的新创建组件实例的nextBase属性上,其目的就是为了能基于此DOM元素进行渲染,以更少的代价进行相关的渲染。

setComponentProps

function setComponentProps(component, props, opts, context, mountAll) {
    if (component._disable) return;
    component._disable = true;

    if ((component.__ref = props.ref)) delete props.ref;
    if ((component.__key = props.key)) delete props.key;

    if (!component.base || mountAll) {
        if (component.componentWillMount) component.componentWillMount();
    }
    else if (component.componentWillReceiveProps) {
        component.componentWillReceiveProps(props, context);
    }

    if (context && context!==component.context) {
        if (!component.prevContext) component.prevContext = component.context;
        component.context = context;
    }

    if (!component.prevProps) component.prevProps = component.props;
    component.props = props;

    component._disable = false;

    if (opts!==NO_RENDER) {
        if (opts===SYNC_RENDER || !component.base) {
            renderComponent(component, SYNC_RENDER, mountAll);
        }
        else {
            enqueueRender(component);
        }
    }

    if (component.__ref) component.__ref(component);
}

  函数setComponentProps的主要作用就是为组件实例设置属性(props),其中props通常来源于JSX中的属性(attributes)。函数的参数componentpropscontextmountAll的含义从名字就可以看出来,值得注意地是参数opts,代表的是不同的刷新模式:

  首先如果组件component_disable属性为true时则直接退出,否则将属性_disable置为true,其目的相当于一个,保证修改过程的原子性。如果传入组件的属性props中存在refkey,则将其分别缓存在组件的__ref__key,并将其从props将其删除。      组件实例中的base中存放的是之前组件实例对应的真实dom节点,如果不存在该属性,说明是该组件的初次渲染,如果组件中定义了生命周期函数(钩子函数)componentWillMount,则在此处执行。如果不是首次执行,如果存在生命周期函数componentWillReceiveProps,则需要将最新的propscontext作为参数调用componentWillReceiveProps。然后分别将当前的属性contextprops缓存在组件的preContextprevProps属性中,并将contextprops属性更新为最新的contextprops。最后将组件的_disable属性置回false。      如果组件更新的模式为NO_RENDER,则不需要进行渲染。如果是同步渲染(SYNC_RENDER)或者是首次渲染(base属性为空),则执行函数renderComponent,其余情况下(例如setState触发的异步渲染ASYNC_RENDER)均执行函数enqueueRender(enqueueRender函数将在setState处分析)。在函数的最后,如果存在ref函数,则将组件实例作为参数调用ref函数。在这里我们可以显然可以看出在Preact中是不支持React的中字符串类型的ref属性,不过这个也并不重要,因为React本身也不推荐使用字符串类型的ref属性,并表示可能会在将来版本中废除这一属性。      接下来我们还需要了解renderComponent函数(非常冗长)与enqueueRender函数的作用:   

renderComponent

renderComponent(component, opts, mountAll, isChild) {
    if (component._disable) return;

    let props = component.props,
        state = component.state,
        context = component.context,
        previousProps = component.prevProps || props,
        previousState = component.prevState || state,
        previousContext = component.prevContext || context,
        isUpdate = component.base,
        nextBase = component.nextBase,
        initialBase = isUpdate || nextBase,
        initialChildComponent = component._component,
        skip = false,
        rendered, inst, cbase;
    // block-1
    if (isUpdate) {
        component.props = previousProps;
        component.state = previousState;
        component.context = previousContext;
        if (opts!==FORCE_RENDER
            && component.shouldComponentUpdate
            && component.shouldComponentUpdate(props, state, context) === false) {
            skip = true;
        }
        else if (component.componentWillUpdate) {
            component.componentWillUpdate(props, state, context);
        }
        component.props = props;
        component.state = state;
        component.context = context;
    }

    component.prevProps = component.prevState = component.prevContext = component.nextBase = null;
    component._dirty = false;
    if (!skip) {
        // block-2
        rendered = component.render(props, state, context);

        if (component.getChildContext) {
            context = extend(extend({}, context), component.getChildContext());
        }

        let childComponent = rendered && rendered.nodeName,
            toUnmount, base;
        //block-3
        if (typeof childComponent==='function') {
            let childProps = getNodeProps(rendered);
            inst = initialChildComponent;

            if (inst && inst.constructor===childComponent && childProps.key==inst.__key) {
                setComponentProps(inst, childProps, SYNC_RENDER, context, false);
            }
            else {
                toUnmount = inst;

                component._component = inst = createComponent(childComponent, childProps, context);
                inst.nextBase = inst.nextBase || nextBase;
                inst._parentComponent = component;
                setComponentProps(inst, childProps, NO_RENDER, context, false);
                renderComponent(inst, SYNC_RENDER, mountAll, true);
            }

            base = inst.base;
        }
        else {
        //block-4
            cbase = initialBase;

            toUnmount = initialChildComponent;
            if (toUnmount) {
                cbase = component._component = null;
            }

            if (initialBase || opts===SYNC_RENDER) {
                if (cbase) cbase._component = null;
                base = diff(cbase, rendered, context, mountAll || !isUpdate, initialBase && initialBase.parentNode, true);
            }
        }
        // block-5
        if (initialBase && base!==initialBase && inst!==initialChildComponent) {
            let baseParent = initialBase.parentNode;
            if (baseParent && base!==baseParent) {
                baseParent.replaceChild(base, initialBase);

                if (!toUnmount) {
                    initialBase._component = null;
                    recollectNodeTree(initialBase, false);
                }
            }
        }

        if (toUnmount) {
            unmountComponent(toUnmount);
        }

        //block-6
        component.base = base;
        if (base && !isChild) {
            let componentRef = component,
                t = component;
            while ((t=t._parentComponent)) {
                (componentRef = t).base = base;
            }
            base._component = componentRef;
            base._componentConstructor = componentRef.constructor;
        }
    }
    //block-7
    if (!isUpdate || mountAll) {
        mounts.unshift(component);
    }
    else if (!skip) {

        if (component.componentDidUpdate) {
            component.componentDidUpdate(previousProps, previousState, previousContext);
        }
    }

    if (component._renderCallbacks!=null) {
        while (component._renderCallbacks.length) component._renderCallbacks.pop().call(component);
    }
    //block-8
    if (!diffLevel && !isChild) flushMounts();
}

  为了方便阅读,我们将代码分成了八个部分,不过为了更方便的阅读代码,我们首先看一下函数开始处的变量声明:      所要渲染的component实例中的propscontextstate属性表示的是最新的所要渲染的组件实例属性。而对应的prePropspreContextpreState代表的是渲染之前上一个状态组件实例属性。变量isUpdate代表的是当前是处于组件更新的过程还是组件渲染的过程(mount),我们通过之前组件实例是否对应存在真实DOM节点来判断,如果存在则认为是更新的过程,否则认为是渲染(mount)过程。nextBase表示可以基于此DOM元素进行修改(可能来源于上一次渲染或者是回收之前同类型的组件实例),以寻求最小的渲染代价。 组件实例中的_component属性表示的组件的子组件,仅仅只有当组件返回的是组件时(也就是当前组件为高阶组件),才会存在。变量skip用来标志是否需要跳过更新的过程(例如: 生命周期函数shouldComponentUpdate返回false)。

  我们知道idiff函数返回的是虚拟dom对应渲染后的真实dom节点,所以变量base存储的就是本次组件渲染的真实DOM元素。

function unmountComponent(component) {
    let base = component.base;
    component._disable = true;

    if (component.componentWillUnmount) component.componentWillUnmount();

    component.base = null;

    let inner = component._component;
    if (inner) {
        unmountComponent(inner);
    }
    else if (base) {
        if (base[ATTR_KEY] && base[ATTR_KEY].ref) base[ATTR_KEY].ref(null);
        component.nextBase = base;
        removeNode(base);
        collectComponent(component);
        removeChildren(base);
    }
    if (component.__ref) component.__ref(null);
}

  来看unmountComponent函数的作用,首先将函数实例中的_disable置为true表示组件禁用,如果组件存在生命周期函数componentWillUnmount进行调用。然后递归调用函数unmountComponent递归卸载组件。如果之前组件渲染的DOM节点,并且最外层节点存在ref函数,则以参数null执行(和React保持一致,ref函数会执行两次,第一次是mount会以DOM元素或者组件实例回调,第二次是unmount会回调null表示卸载)。然后将DOM元素存入nextBase用以回收。调用removeNode函数作用是将base节点的父节点脱离出来。函数removeChildren的目的是用递归遍历所有的子DOM元素,回收节点(之前的文章已经介绍过,其中就涉及到子元素的ref调用)。最后如果组件本身存在ref属性,则直接以null为参数调用。

HOC1 => HOC2 => component => DOM元素

  其中HOC代表高阶组件,component代表自定义组件。你会发现HOC1HOC2compoentbase属性都指向最后的DOM元素,而DOM元素的中的_component是指向HOC1的组价实例的。看懂了这个你就能明白为什么会存在下面这个循环语句,其目的就是为了给父组件赋值正确的base属性以及为DOM节点的_component属性赋值正确的组件实例。

function flushMounts() {
    let c;
    while ((c=mounts.pop())) {
        if (c.componentDidMount) c.componentDidMount();
    }
}

  其实flushMounts也是非常的简单,就是将队列mounts中取出组件实例,然后如果存在生命周期函数componentDidMount,则对应执行。      其实如果阅读了之前diff的文章的同学应该记得在diff函数中有:

function diff(dom, vnode, context, mountAll, parent, componentRoot) {
    //......
    if (!--diffLevel) {
        // ......
        if (!componentRoot) flushMounts();
    }
}

  上面有两处调用函数flushMounts,一个是在renderComponent内部①,一个是在diff函数②。那么在什么情况下触发上下两段代码呢?首先componentRoot表示的是当前diff是不是以组件中渲染内容的形式调用(比如组件中render函数返回HTML类型的VNode),那么preact.render函数调用时肯定componentRootfalsediffLevel表示渲染的层次,diffLevel回减到0说明已经要结束diff的调用,所以在使用preact.render渲染的最后肯定会使用上面的代码去调用函数flushMounts。但是如果其中某个已经渲染的组件通过setState或者forceUpdate的方式导致了重新渲染并且致使子组件创建了新的实例(比如前后两次返回了不同的组件类型),这时,就会采用第一种方式在调用flushMounts函数。

setState

  对于Preact的组件而言,state是及其重要的部分。其中涉及到的API为setState,定义在函数Component的原型中,这样所有的继承于Component的自定义组件实例都可以引用到函数setState

extend(Component.prototype,{
    //.......

    setState(state, callback) {
        let s = this.state;
        if (!this.prevState) this.prevState = extend({}, s);
        extend(s, typeof ··==='function' ? state(s, this.props) : state);
        if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
        enqueueRender(this);
    }

    //......
});

  首先我们看到setState接受两个参数: 新的state以及state更新后的回调函数,其中state既可以是对象类型的部分对象,也可以是函数类型。首先使用函数extend生成当前state的拷贝prevState,存储之前的state的状态。然后如果 state类型为函数时,将函数的生成值覆盖进入state,否则直接将新的state覆盖进入state,此时this.state已经成为了新的state。如果setState存在第二个参数callback,则将其存入实例属性_renderCallbacks(如果不存在_renderCallbacks属性,则需要初始化)。然后执行函数enqueueRender

enqueueRender

  接下来我们看一下神奇的enqueueRender函数:   

let items = [];

function enqueueRender(component) {
    if (!component._dirty && (component._dirty = true) && items.push(component) == 1) {
        defer(rerender);
    }
}

function rerender() {
    let p, list = items;
    items = [];
    while ((p = list.pop())) {
        if (p._dirty) renderComponent(p);
    }
}

const defer = typeof Promise=='function' ? Promise.resolve().then.bind(Promise.resolve()) : setTimeout;

  我们可以看到当组件实例中的_dirty属性为false时,会将属性_dirty置为true,并将其放入items中。当更新队列第一次被items时,则延迟异步执行函数rerender。这个延迟异步函数在支持Promise的浏览器中,会使用Promise.resolve().then,否则会使用setTimeout。      rerender函数就是将items中待更新的组件,逐个取出,并对其执行renderComponent。其实renderComponentopt参数不传入ASYNC_RENDER,而是传入undefined两者之间并无区别。唯一要注意的是:   

//renderComponent内部
if (initialBase || opts===SYNC_RENDER) {
    base = diff(//...;
}

  我们渲染过程一定是要执行diff,那就说明initialBase一定是个非假值,这也是可以保证的。   

initialBase = isUpdate || nextBase

  其实因为之前组件已经渲染过,所以是可以保证isUpdate一定为非假值,因为isUpdate = component.base并且component.base是一定存在的并且为上次渲染的内容。大家可能会担心如果上次组件render函数返回的是null该怎么办?其实阅读过第二篇文章的同学应该知道在idiff函数内部   

if (vnode==null || typeof vnode==='boolean') vnode = '';

  即使render返回的是null也会被当做一个空文本去控制,对应会渲染成DOM中的Text类型。

  

forceUpdate

extend(Component.prototype,{
    //.......

    forceUpdate(callback) {
        if (callback) (this._renderCallbacks = (this._renderCallbacks || [])).push(callback);
        renderComponent(this, FORCE_RENDER);
    }

    //......
});

  执行forceUpdate所需要做的就是将回调函数放入组件实例中的_renderCallbacks属性并调用函数renderComponent强制刷新当前的组件。需要注意的是,我们渲染的模式是FORCE_RENDER强制刷新,与其他的模式到的区别就是不需要经过生命周期函数shouldComponentUpdate的判断,直接进行刷新。

结语

  至此我们已经看完了Preact中的组件相关的代码,可能并没有对每一个场景都进行讲解,但是我也尽量尝试去覆盖所有相关的部分。代码相对比较长,看起来也经常令人头疼,有时候为了搞清楚某个变量的部分不得不数次回顾。但是你会发现你多次地、反复性的阅读、仔细地推敲,代码的含义会逐渐清晰。书读百遍其义自见,其实对代码来说也是一样的。文章若有不正确的地方,欢迎指出,共同学习。