Read the rendering process of Vue3 in one article

Vue3There is a picture like the following on the official website, which basically shows Vue3the rendering principle:

render pipeline

This article will take a cursory look at Vue3the entire running process from the perspective of source code, aiming to deepen the understanding of the above figure, starting from the following very simple usage example:

import { createApp, ref } from "vue";

createApp({
  template: `
        <div class="card">
            <button type="button" @click="count++">count is {{ count }}</button>
        </div>
    `,
  setup() {
    const count = ref(0);
    return {
      count,
    };
  },
}).mount("#app");

Create an application instance through createAppa method, pass an option object of a component, including a template template, a APIcombined entry setupfunction, use it in setupthe function to refcreate a responsive data, and then returnuse it for the template, and finally call mountthe method of the instance to render the template to idthe appwithin the element. As long as the value is modified later count, the page will be automatically refreshed. Although the sparrow is small, it also represents Vuethe core of the core.

First the method is called createApp:

const createApp = ((...args) => {
    const app = createRenderer(rendererOptions).createApp(...args);
    return app;
});

By createRenderercreating a renderer, rendererOptionsit is an object, and the above is mainly DOMthe method of operation:

{
    insert: (child, parent, anchor) => {
        parent.insertBefore(child, anchor || null);
    },
    //...
}

This is mainly to facilitate cross-platform, for example, in other non-browser environments, it can be replaced with the corresponding node operation method.

function createRenderer(options) {
    return baseCreateRenderer(options);
}

function baseCreateRenderer(options, createHydrationFns) {
    // ...
    return {
        render,
        hydrate,
        createApp: createAppAPI(render, hydrate)
    };
}

baseCreateRendererThe method is very long, including all the methods of the renderer, such as mount, patchetc., which are returned createAppby method calls:createAppAPI

function createAppAPI(render, hydrate) {
    return function createApp(rootComponent, rootProps = null) {
        if (!isFunction(rootComponent)) {
            rootComponent = Object.assign({}, rootComponent);
        }
        const context = createAppContext();
        let isMounted = false;
        const app = (context.app = {
            _uid: uid$1++,
            _component: rootComponent,
            _props: rootProps,
            _container: null,
            _context: context,
            _instance: null,
            version,
            get config() {},
            set config() {},
            use(){},
            mixin(){},
            component(){},
            directive(){},
            mount(){},
            unmount(){},
            provide(){}
        });
        return app;
    }
}

This is the final createAppmethod. The so-called application instance appis actually an object. The component options we pass in are stored on _componentthe property as the root component. In addition, you can also see some methods provided by the application instance, such as usethe method of registering plug-ins and mounting instances. method mountetc.

contextIn fact, it is also an ordinary object:

function createAppContext() {
    return {
        app: null,
        config: {
            isNativeTag: NO,
            performance: false,
            globalProperties: {},
            optionMergeStrategies: {},
            errorHandler: undefined,
            warnHandler: undefined,
            compilerOptions: {}
        },
        mixins: [],
        components: {},
        directives: {},
        provides: Object.create(null),
        optionsCache: new WeakMap(),
        propsCache: new WeakMap(),
        emitsCache: new WeakMap()
    };
}

This context object will be stored on the application instance and VNodeon the root, possibly for subsequent rendering.

接下来看一下创建实例后挂载的mount方法:

mount(rootContainer, isHydrate, isSVG) {
    // 没有挂载过
    if (!isMounted) {
        // 创建虚拟DOM
        const vnode = createVNode(rootComponent, rootProps);
        vnode.appContext = context;
        // 渲染
        render(vnode, rootContainer, isSVG);
        isMounted = true;
        // 实例和容器元素互相关联
        app._container = rootContainer;
        rootContainer.__vue_app__ = app;
        // 返回根组件的实例
        return getExposeProxy(vnode.component) || vnode.component.proxy;
    }
}

主要就是做了两件事,创建虚拟DOM,然后渲染。

createVNode方法:

const createVNode = _createVNode;
function _createVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
    const shapeFlag = isString(type)
        ? 1 /* ShapeFlags.ELEMENT */
        : isSuspense(type)
            ? 128 /* ShapeFlags.SUSPENSE */
            : isTeleport(type)
                ? 64 /* ShapeFlags.TELEPORT */
                : isObject(type)
                    ? 4 /* ShapeFlags.STATEFUL_COMPONENT */
                    : isFunction(type)
                        ? 2 /* ShapeFlags.FUNCTIONAL_COMPONENT */
                        : 0;
    return createBaseVNode(type, props, children, patchFlag, dynamicProps, shapeFlag, isBlockNode, true);
}

createVNode方法会根据组件的类型生成一个标志,后续会通过这个标志做一些优化之类的处理。我们传的是一个组件选项,也就是一个普通对象,shapeFlag的值为4

然后调用了createBaseVNode方法:

function createBaseVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, shapeFlag = type === Fragment ? 0 : 1 /* ShapeFlags.ELEMENT */, isBlockNode = false, needFullChildrenNormalization = false) {
    const vnode = {
        __v_isVNode: true,
        __v_skip: true,
        type,
        props,
        key: props && normalizeKey(props),
        ref: props && normalizeRef(props),
        scopeId: currentScopeId,
        slotScopeIds: null,
        children,
        component: null,
        suspense: null,
        ssContent: null,
        ssFallback: null,
        dirs: null,
        transition: null,
        el: null,
        anchor: null,
        target: null,
        targetAnchor: null,
        staticCount: 0,
        shapeFlag,
        patchFlag,
        dynamicProps,
        dynamicChildren: null,
        appContext: null,
        ctx: currentRenderingInstance
    };
    return vnode;
}

可以看到返回的虚拟DOM也是一个普通对象,我们传进去的组件选项会存储在type属性上。

虚拟DOM创建完后就会调用render方法将虚拟DOM渲染为实际的DOM节点,render方法是通过参数传给createAppAPI的:

const render = (vnode, container, isSVG) => {
    if (vnode == null) {
        // 卸载
        if (container._vnode) {
            unmount(container._vnode, null, null, true);
        }
    }
    else {
        // 首次渲染或者更新
        patch(container._vnode || null, vnode, container, null, null, null, isSVG);
    }
    flushPreFlushCbs();
    flushPostFlushCbs();
    container._vnode = vnode;
};

如果要渲染的新VNode不存在,那么从容器元素上获取之前VNode进行卸载,否则调用patch方法进行打补丁,如果是首次渲染,container._vnode不存在,那么直接将新VNode渲染为DOM元素即可,否则会对比新旧VNode,使用diff算法进行打补丁,Vue2中使用的是双端diff算法,Vue3中使用的是快速diff算法。

打补丁结束后清空了两个回调队列,可以看到事件队列还分为前后两个,那么我们常用的nextTick方法注册的回调在哪个队列呢,实际上,两个都不在:

const resolvedPromise = Promise.resolve();
let currentFlushPromise = null;

function nextTick(fn) {
    const p = currentFlushPromise || resolvedPromise;
    return fn ? p.then(this ? fn.bind(this) : fn) : p;
}

Promise.resolve()方法会创建一个Resolved状态的Promise对象。

nextTick方法就是这么简单,如果currentFlushPromise有值,那么使用这个Promise注册回调,否则使用默认的resolvedPromise将回调放到微任务队列。

currentFlushPromise会在调用queueFlush方法时赋值,也就是生成一个新的Promise对象:

function queueFlush() {
    if (!isFlushing && !isFlushPending) {
        isFlushPending = true;
        currentFlushPromise = resolvedPromise.then(flushJobs);
    }
}

flushJobs和前面的flushPreFlushCbs方法里冲刷的都是queue队列,而flushPostFlushCbs方法里冲刷的是pendingPostFlushCbs队列,flushJobs方法在冲刷完queue队列后才会冲刷pendingPostFlushCbs队列。而如果是冲刷中调用nextTick添加的回调会在这两个队列都清空后才会执行。

扯远了,回到render方法,接下来看看render方法里调用的patch方法:

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = (process.env.NODE_ENV !== 'production') && isHmrUpdating ? false : !!n2.dynamicChildren) => {
    	// 新旧VNode相同直接返回
        if (n1 === n2) {
            return;
        }
    	// 如果新旧VNode的类型不同,那么也不需要打补丁了,直接卸载旧的,挂载新的
        if (n1 && !isSameVNodeType(n1, n2)) {
            anchor = getNextHostNode(n1);
            unmount(n1, parentComponent, parentSuspense, true);
            n1 = null;
        }
        const { type, ref, shapeFlag } = n2;
        switch (type) {
              case Text:
                // ...
                break;
              // ...
              default:
                // ...
                else if (shapeFlag & 6 /* ShapeFlags.COMPONENT */) {
                    processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
                }
                // ...
        }
}

patch方法就是用来打补丁更新实际DOM的,switch里面根据VNode的类型不同做的处理也不同,因为我们的例子传的是一个组件选项对象,所以会走processComponent处理分支:

const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
    // 如果旧的VNode不存在,那么调用挂载方法
    if (n1 == null) {
        mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized);
    }
    // 新旧都存在,那么进行更新操作
    else {
        updateComponent(n1, n2, optimized);
    }
};

根据是否存在旧的VNode判断是调用挂载方法还是更新方法,先看mountComponent方法:

const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
    const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense));
    setupComponent(instance);
    setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized);
}

首先调用createComponentInstance方法创建组件实例,返回的其实也是一个普通对象:

function createComponentInstance(vnode, parent, suspense) {
    const type = vnode.type;
    const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext;
    const instance = {
        uid: uid++,
        vnode,
        type,
        parent,
        appContext,
        // 还有非常多属性
        // ...
    }
    return instance;
}

然后调用了setupComponent方法:

function setupComponent(instance, isSSR = false) {
    const { props, children } = instance.vnode;
    const isStateful = instance.vnode.shapeFlag & 4;
    initProps(instance, props, isStateful, isSSR);
    initSlots(instance, children);
    const setupResult = isStateful
        ? setupStatefulComponent(instance, isSSR)
        : undefined;
    return setupResult;
}

初始化propsslots,然后如果shapeFlag4会调用setupStatefulComponent方法,前面说了我们传的组件选项对应的shapeFlag就是4,所以会走setupStatefulComponent方法:

function setupStatefulComponent(instance, isSSR) {
    const { setup } = Component;
    if (setup) {
        const setupResult = callWithErrorHandling(setup, instance, 0, [instance.props, setupContext]);
        handleSetupResult(instance, setupResult, isSSR);
    }
}

在这个方法里会调用组件选项的setup方法,这个函数中返回的对象会暴露给模板和组件实例,看一下handleSetupResult方法:

function handleSetupResult(instance, setupResult, isSSR) {
    if (isFunction(setupResult)) {
        instance.render = setupResult;
    } else if (isObject(setupResult)) {
        instance.setupState = proxyRefs(setupResult);
    }
    finishComponentSetup(instance, isSSR);
}

如果setup返回的是一个函数,那么这个函数会直接被作为渲染函数。否则如果返回的是一个对象,会使用proxyRefs将这个对象转为Proxy代理的响应式对象。

最后又调用了finishComponentSetup方法:

function finishComponentSetup(instance, isSSR) {
    const Component = instance.type;
    if (!instance.render) {
        if (!isSSR && compile && !Component.render) {
            const template = Component.template ||
                  resolveMergedOptions(instance).template;
            if (template) {
                const { isCustomElement, compilerOptions } = instance.appContext.config;
                const { delimiters, compilerOptions: componentCompilerOptions } = Component;
                const finalCompilerOptions = extend(extend({
                    isCustomElement,
                    delimiters
                }, compilerOptions), componentCompilerOptions);
                Component.render = compile(template, finalCompilerOptions);
            }
        }
        instance.render = (Component.render || NOOP);
    }
}

这个函数主要是判断组件是否存在渲染函数render,如果不存在则判断是否存在template选项,我们传的组件选项显然是没有render属性,而是传的模板template,所以会使用compile方法来将模板编译成渲染函数。

回到mountComponent方法,最后调用了setupRenderEffect,这个方法很重要:

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
    // 组件更新方法
    const componentUpdateFn = () => {}
    // 创建一个effect
    const effect = (instance.effect = new ReactiveEffect(componentUpdateFn, () => queueJob(update), instance.scope));
    // 调用effect的run方法执行componentUpdateFn方法
    const update = (instance.update = () => effect.run());
    update();
}

这一步就涉及到Vue3的响应式原理了,核心就是使用Proxy拦截数据,然后在属性读取时将属性和读取该属性的函数(称为副作用函数)关联起来,然后在更新该属性时取出该属性关联的副作用函数出来执行,详细的内容网上已经有非常多的文章了,有兴趣的可以自己搜一搜,或者直接看源码也是可以的。

简化后的ReactiveEffect类就是这样的:

let activeEffect;
class ReactiveEffect {
    constructor(fn, scheduler = null, scope) {
        this.fn = fn;
    }
    run() {
        activeEffect = this;
        try {
            return this.fn();
        } finally {
            activeEffect = null
        }
    } 
}

执行它的run方法时会把自身赋值给全局的activeEffect变量,然后执行副作用函数时如果读取了Proxy代理后的对象的某个属性时就会将对象、属性和这个ReactiveEffect示例关联存储起来,如果属性发生改变,会取出关联的ReactiveEffect实例,执行它的run方法,达到自动更新的目的。

我们使用的是ref方法创建的数据,ref方法返回的响应式数据虽然不是通过Proxy代理的,但是读取修改操作同样是会被拦截的,和Proxy代理的数据拦截时做的事情是一样的。

接下来看看传给它的组件更新方法componentUpdateFn

const componentUpdateFn = () => {
    // 组件没有挂载过
    if (!instance.isMounted) {
        const subTree = (instance.subTree = renderComponentRoot(instance));
        patch(null, subTree, container, anchor, instance, parentSuspense, isSVG);
        initialVNode.el = subTree.el;
        instance.isMounted = true;
    } else {// 组件已经挂载过
        const nextTree = renderComponentRoot(instance);
        patch(prevTree, nextTree, hostParentNode(prevTree.el), getNextHostNode(prevTree), instance, parentSuspense, isSVG);
        next.el = nextTree.el;
    }
}

组件无论是首次挂载,还是更新,做的事情核心是一样的,先调用renderComponentRoot方法生成组件模板的虚拟DOM,然后调用patch方法打补丁。

function renderComponentRoot(instance) {
    const { type: Component, vnode, proxy, withProxy, props, propsOptions: [propsOptions], slots, attrs, emit, render, renderCache, data, setupState, ctx, inheritAttrs } = instance;
    let result = render.call(proxyToUse, proxyToUse, renderCache, props, setupState, data, ctx)
    return result
}

renderComponentRoot核心就是调用组件的渲染函数render方法生成组件模板的虚拟DOM,然后扔给patch方法更新就好了。

看完了mountComponent方法,再来看看updateComponent方法:

const updateComponent = (n1, n2, optimized) => {
    const instance = (n2.component = n1.component);
    if (shouldUpdateComponent(n1, n2, optimized)) {
        // 需要更新
        instance.next = n2;
        instance.update();
    }else {
        // 不需要更新
        n2.el = n1.el;
        instance.vnode = n2;
    }
}

先调用shouldUpdateComponent方法判断组件是否需要更新,大致是通过是否存在过渡效果、是否存在动态slotsprops是否发生改变、子节点是否发改变等来判断。

如果需要更新,那么会执行instance.update方法,这个方法就是前面setupRenderEffect方法里保存的effect.run方法,所以最终执行的也是componentUpdateFn方法。

到这里,从我们创建实例到页面渲染,再到更新的全流程就讲完了,总结一下,大致就是:

1.每个Vue组件都需要产出一份虚拟DOM,也就是组件的render函数的返回值,render函数你可以直接手写,也可以通过template传递模板字符串,由Vue内部来编译成渲染函数,平常我们开发时写的Vue单文件,最终也会编译成普通的Vue组件选项对象;

2.render函数会作为副作用函数执行,也就是如果在模板中使用到了响应式数据(所谓响应式数据就是能拦截到它的各种读取、修改操作),那么响应式数据和属性会与render函数关联起来,那么当响应式数据被修改以后,就能找到依赖它的render函数,那么就可以通知依赖的组件进行更新;

2. After having a virtual DOM, Vuethe internal renderer can render it into a real one DOM. If it is an update, that is, there are two virtuals, the old and the new DOM, then Vueit will be compared and if necessary, diffan algorithm will be used to efficiently update the real DOM;

So as long as you implement a renderer that can DOMrender the virtual into reality DOM, and can efficiently DOMcomplete the update according to the comparison between the old and the new virtual, and then implement a compiler that can compile the template into a rendering function, and finally Proxyimplement a response system based on it. One Vue3, isn’t it very simple, the heart is not as good as the action, the next frame is waiting for you to create!

Guess you like

Origin juejin.im/post/7243352406133112869