1

奇葩说框架之Vue3渲染系统

 2 years ago
source link: https://zhuanlan.zhihu.com/p/425356772
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

奇葩说框架之Vue3渲染系统

前言

提到马拉松,大家都知道马拉松是世界上最长的田径项目(全程42.195公里),是所有体育运动中体力消耗最大,同时也是最磨练一个人的意志的项目。如果说你可以坚持跑完整个马拉松,那还有什么是你不可以坚持下来的呢。

同样,在我们学习研究一些优秀类库的源码时,整个过程也是枯燥乏味,亦如参加一场源码级的马拉松。那今天笔者就带着大家一起跑一场「关于Vue.js 3.0渲染系统的源码解析版」的马拉松,在整个过程中,笔者也会给大家提供「补给站」(流程图),方便大家阅读。

思考

在开始今天的文章之前,大家可以想一下:

  1. vue文件是如何转换成DOM节点,并渲染到浏览器上的?
  2. 数据更新时,整个的「更新流程」又是怎么样的?

️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️ ️

vuejs有两个阶段:「编译时」「运行时」

编译时

我们平常开发时写的.vue文件是无法直接运行在浏览器中的,所以在webpack「编译阶段」,需要通过vue-loader.vue文件编译生成对应的js代码,vue组件对应的template模板会被「编译器」转化为render函数

运行时

接下来,当编译后的代码真正运行在浏览器时,便会执行render函数并返回VNode,也就是所谓的 虚拟DOM,最后将VNode渲染成真实的DOM节点

v2-8b8c041de68ecfa00f98c2f4865008d9_720w.jpg

了解完vue组件渲染的思路后,接下来让我们从「Vue.js 3.0」(后续简称vue3)的源码出发,深入了解vue组件的「整个渲染流程」是怎么样的?

准备

❝ 本文主要是分析vue3的渲染系统,为了方便调试,我们直接通过引入vue.js文件的方式进行源码调试分析。

  1. vue3源码下载
# 源码地址(推荐ssh方式下载)
https://github.com/vuejs/vue-next
# 或者下载笔者做笔记用的版本
https://github.com/AsyncGuo/vue-next/tree/vue3_notes
  1. 生成vue.global.js文件
npm run dev
# bundles .../vue-next/packages/vue/src/index.ts → packages/vue/dist/vue.global.js...
# created packages/vue/dist/vue.global.js in 2.8s
  1. 启动开发环境
npm run serve
<!-- 调试代码目录:/packages/vue/examples/test.html -->

<script src="./../dist/vue.global.js"></script>

<div id="app">
  <div>static node</div>
  <div>{{title}}</div>
  <button @click="add">click</button>
  <Item :msg="title"/>
</div>

<script>
  const Item = {
    props: ['msg'],
    template: `<div>{{ msg }}</div>`
  }
  const app = Vue.createApp({
    components: {
      Item
    },
    setup() {
      return {
        title: Vue.ref(0)
      }
    },
    methods: {
      add() {
        this.title += 1
      }
    },
  })

  app.mount('#app')
</script>

创建应用

从上面的测试代码,我们会发现vue3vue2的挂载方式是不同的,vue3是通过createApp这个入口函数进行应用的创建。接下来我们来看下createApp的具体实现:

// 入口文件: /vue-next/packages/runtime-dom/src/index.ts
const createApp = ((...args) => {
  console.log('createApp入参:', ...args);
  // 创建应用
  const app = ensureRenderer().createApp(...args);
  const { mount } = app;
  // 重写mount
  app.mount = (containerOrSelector) => {
    // ...
  };
  return app;
});

ensureRenderer

首先通过ensureRenderer创建web端的渲染器,我们来看下具体实现:

// 更新属性的方法
const patchProp = () => {
 // ...
}
// 操作DOM的方法
const nodeOps = {
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null)
  },
  remove: child => {
    const parent = child.parentNode
    if (parent) {
      parent.removeChild(child)
    }
  },
  ...
}
// web端的渲染器所需的参数设置
const rendererOptions = extend({ patchProp }, nodeOps);
let renderer;
// 延迟创建renderer
function ensureRenderer() {
  return (renderer || (renderer = createRenderer(rendererOptions)));
}

❝ 在这里可以看出,通过延迟创建渲染器,当我们只依赖响应式包的情况下,可以通过「tree-shaking」移除渲染相关的代码,大大减少包的体积。

createRenderer

通过ensureRenderer可以看出,真正的入口是这个createRenderer方法:

// /vue-next/packages/runtime-core/src/renderer.ts
export function createRenderer(options) {
  return baseCreateRenderer(options)
}

function baseCreateRenderer(options, createHydrationFns) {
  // 通用的DOM操作方法
  const {
    insert: hostInsert,
    remove: hostRemove,
    ...
  } = options
  
  // =======================
  // 渲染的核心流程
  // 通过闭包缓存内敛函数
  // =======================
  
  const patch = () => {}  // 核心diff过程
  const processElement = () => {} // 处理element
  const mountElement = () => {} // 挂载element
  const mountChildren = () => {} // 挂载子节点
  const processFragment = () => {} // 处理fragment节点
  const processComponent = () => {} // 处理组件
  const mountComponent = () => {} // 挂载组件
  const setupRenderEffect = () => {}  // 运行带副作用的render函数
  const render = () => {} // 渲染挂载流程
  // ...
  
  // =======================
  //  2000+行的内敛函数 
  // =======================
  
  return {
    render,
    hydrate, // 服务端渲染相关
    createApp: createAppAPI(render, hydrate)
  }
}

接下来我们先跳过这些内敛函数的实现(后面的渲染流程用到时,我们再具体分析),来看下createAppAPI的具体实现:

createAppAPI

function createAppAPI(render, hydrate) {
  // 真正创建app的入口
  return function createApp(rootComponent, rootProps = null) {
    // 创建vue应用上下文
    const context = createAppContext();
    // 已安装的vue插件
    const installedPlugins = new Set();
    let isMounted = false;
    const app = (context.app = {
      _uid: uid++,
      _component: rootComponent, // 根组件
      use(plugin, ...options) {
        // ...
       return app
      },
      mixin(mixin) {},
      component(name, component) {},
      directive(name, directive) {},
      mount(rootContainer) {},
      unmount() {},
      provide(key, value) {}
    });
    return app;
  };
}

可以看出,createAppAPI返回的createApp函数才是真正「创建应用」的入口。在createApp里会创建vue应用的上下文,同时初始化app,并绑定应用上下文到app实例上,最后返回app

❝ 这里有个值得注意的点:app对象上的usemixincomponentdirective方法都返回了app应用实例,开发者可以「链式调用」

// 一直use一直爽   
createApp(App).use(Router).use(Vuex).component('component',{}).mount("#app")

「到此app应用实例已经创建好了~,打印查看下创建的app应用:」

总结一下创建app应用实例的过程:

  1. 创建web端对应的渲染器(「延迟创建,tree-shaking」
  2. 执行baseCreateRenderer方法(「通过闭包缓存内敛函数,后续挂载阶段的主流程」
  3. 执行createAppAPI方法(「1. 创建应用上下文;2. 创建app并返回」

挂载阶段

接下来,当我们执行app.mount时,便会开始挂载组件。而我们调用的app.mount则是重写后的mount方法:

const createApp = ((...args) => {
  // ...
  const { mount } = app; // 缓存原始的mount方法
  // 重写mount
  app.mount = (containerOrSelector) => {
    // 获取容器
    const container = normalizeContainer(containerOrSelector);
    if (!container) return;
    const component = app._component;
    // 判断如果传入的根组件不是函数&根组件没有render函数&没有template,就把容器的内容设置为根组件的template
    if (!isFunction(component) && !component.render && !component.template) {
      component.template = container.innerHTML;
    }
    // 清空容器内容
    container.innerHTML = '';
    // 执行缓存的mount方法
    const proxy = mount(container, false, container);
    return proxy;
  };
  return app;
});

执行完web端重写的mount方法后,才是真正挂载组件的开始,即调用createAppAPI返回的app应用上的mount方法:

function createAppAPI(render, hydrate) {
  // 真正创建app的入口
  return function createApp(rootComponent, rootProps = null) {
    // ...
    const app = (context.app = {
      // 挂载根组件
      mount(rootContainer, isHydrate, isSVG) {
        if (!isMounted) {
          // 创建根组件对应的vnode
          const vnode = createVNode(rootComponent, rootProps);
          // 根级vnode存在应用上下文
          vnode.appContext = context;
          // 将虚拟vnode节点渲染成真实节点,并挂载
          render(vnode, rootContainer, isSVG);
          isMounted = true;
          // 记录应用的根组件容器
          app._container = rootContainer;
          rootContainer.__vue_app__ = app;
          app._instance = vnode.component;
          return vnode.component.proxy;
        }
      }
    });
    return app;
  };
}

总结一下,mount方法主要做了什么呢?

  1. 创建根组件对应的vnode
  2. 根组件vnode绑定应用上下文context
  3. 渲染vnode成真实节点,并挂载
  4. 记录挂载状态

❝ 细心的同学可能已经发现了,这里的mount方法是一个标准的跨平台渲染流程,抽象vnode,然后通过rootContainer实现特定平台的渲染,例如在浏览器环境下,它就是一个DOM对象,在其他平台就是其他特定的值。这也就是为什么我们在调用runtime-dom包的creataApp方法时,重写mount方法,完善不同平台的渲染逻辑。

创建vnode

❝ 提到vnode,可能更多人会和「高性能」联想到一起,误以为vnode的性能就一定比手动操作DOM的高,其实不然。vnode的底层同样是要操作DOM,相反如果vnodepatch过程过长,「同样会导致页面的卡顿」。 而vnode的提出则是对原生DOM的抽象,在跨平台设计的处理上会起到一定的抽象化。例如:服务端渲染、小程序端渲染、weex平台...

接下来,我们来看下创建vnode的过程:

function _createVNode(
  type,
  props,
  children,
  patchFlag,
  ...
): VNode {
  // 规范化class & style
  // 例如:class=[]、class={}、style=[]等格式,需规范化
  if (props) {
    // ...
  }

  // 获取vnode类型
 const shapeFlag = isString(type)
    ? 1 /* ELEMENT */
    : isSuspense(type)
        ? 128 /* SUSPENSE */
        : isTeleport(type)
            ? 64 /* TELEPORT */
            : isObject(type)
                ? 4 /* STATEFUL_COMPONENT */
                : isFunction(type)
                    ? 2 /* FUNCTIONAL_COMPONENT */
                    : 0;

  return createBaseVNode()
}

function createBaseVNode(
 type, 
  props = null, 
  children = null,
  ...
) {
  // vnode的默认结构
  const vnode = {
    __v_isVNode: true, // 是否为vnode
    __v_skip: true, // 跳过响应式数据化
    type, // 创建vnode的第一个参数
    props, // DOM参数
    children,
    component: null, // 组件实例(instance),通过createComponentInstance创建
    shapeFlag, // 类型标记,在patch阶段,通过匹配shapeFlag进行相应的渲染过程
    ...
  };
  
  // 标准化子节点
  if (needFullChildrenNormalization) {
   normalizeChildren(vnode, children);
  }
  
  // 收集动态子代节点或子代block到父级block tree
  if (isBlockTreeEnabled > 0 &&
      !isBlockNode &&
      currentBlock &&
      (vnode.patchFlag > 0 || shapeFlag & 6 /* COMPONENT */) &&
      vnode.patchFlag !== 32 /* HYDRATE_EVENTS */) {
    currentBlock.push(vnode);
  }
  return vnode;
}

通过上面的代码,我们可以总结一下,创建vnode阶段都做了什么:

  1. 规范化「class & style」(例如:class=[]、class={}、style=[]等格式)
  2. 标记vnode的类型shapeFlag,即根组件对应的vnode类型(「type」即为根组件rootComponent,此时根组件为对象格式,所以shapeFlag即为4)
  3. 标准化子节点(初始化时,「children」为空)
  4. 收集动态子代节点或子代block到父级block tree(这里便是vue3引入的新概念:block tree,篇幅有限,本文就不展开陈述了)

「这里,我们可以打印查看一下此时根组件对应的vnode结构:」

渲染vnode

通过createVNode获取到根组件对应的vnode,然后执行render方法,而这里的render函数便是baseCreateRenderer通过闭包缓存的render函数:

// 实际调用的render方法即为baseCreateRenderer方法中缓存的render方法
function baseCreateRenderer() {
  const render = (vnode, container) => {
    if (vnode == null) {
      if (container._vnode) {
        // 卸载组件
        unmount()
      }
    } else {
      // 正常挂载
      patch(container._vnode || null, vnode, container)
    }
  }
}
  1. 当传入的vnodenull&存在老的vnode,则进行「卸载组件」
  2. 否则,「正常挂载」
  3. 挂载完成后,「批量执行组件生命周期」
  4. 绑定vnode到容器上,以便后续更新阶段通过新旧vnode「进行」patch

❝ ⚠️:接下来,整个渲染过程将会在baseCreateRenderer这个核心函数的内敛函数中执行~

patch

接下来,我们来看下render过程中的patch函数的实现:

const patch = (
  n1, // 旧的vnode
  n2, // 新的vnode
  container, // 挂载的容器
  ...
) => {
  // ...
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      // 处理文本
      processText(n1, n2, container, anchor)
      break
    case Comment:
      // 注释节点
      processCommentNode(n1, n2, container, anchor)
      break
    case Static:
      // 静态节点
      if (n1 == null) {
        mountStaticNode(n2, container, anchor, isSVG)
      }
      break
    case Fragment:
      // fragment节点
      processFragment(n1, n2, container, ...)
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) { // 处理DOM元素
        processElement(n1, n2, container, ...);
      }
      else if (shapeFlag & 6 /* COMPONENT */) { // 处理组件
        processComponent(n1, n2, container, ...);
      }
      else if (shapeFlag & 64 /* TELEPORT */) {
        type.process(n1, n2, container, ...);
      }
      else if (shapeFlag & 128 /* SUSPENSE */) {
        type.process(n1, n2, container, ...);
      }
  }
}

分析patch函数,我们会发现patch函数会通过判断typeshapeFlag的不同来走不同的处理逻辑,今天我们主要分析「组件类型」「普通DOM元素」的处理。

processComponent

「初始化渲染」时,typeobject并且shapeFlag对应的值为4(「位运算4 & 6」),即对应processComponent组件的处理方法:

const processComponent = (n1, n2, container, ...) => {
  if (n1 == null) {
    if (n2.shapeFlag & 512 /* COMPONENT_KEPT_ALIVE */) {
      // 激活组件(已缓存的组件)
      parentComponent.ctx.activate(n2, container, ...);
    }
    else {
      // 挂载组件
      mountComponent(n2, container, ...);
    }
  }
  else {
    // 更新组件
    updateComponent(n1, n2, optimized);
  }
};

如果n1null,则执行「挂载组件」;否则「更新组件」

mountComponent

接下来我们继续看挂载组件的mountComponent函数的实现:

const mountComponent = (initialVNode, container, ...) => {
  // 1. 创建组件实例
  const instance = (
    // 这个时候就把组件实例挂载到了组件vnode的component属性上了
    initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense)
  );
  // 2. 设置组件实例
  setupComponent(instance);
  // 3. 设置并运行带有副作用的渲染函数
  setupRenderEffect(instance, initialVNode, container,...);
};

省略掉无关主流程的代码后,可以看到,mountComponent函数主要做了三件事:

  1. 创建组件实例
function createComponentInstance(vnode, parent, suspense) {
  const type = vnode.type;
  // 绑定应用上下文
  const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext;
  // 组件实例的默认值
  const instance = {
    uid: uid$1++, //组件唯一id
    vnode, // 当前组件的vnode
    type, // vnode节点类型
    parent, // 父组件的实例instance
    appContext, // 应用上下文
    root: null, // 根实例
    next: null, // 当前组件mounted时,为null,将设置为instance.vnode,下次update时,将执行updateComponentPreRender
    subTree: null, // 组件的渲染vnode,由组件的render函数生成,创建后同步
    update: null, // 组件内容挂载或更新到视图的执行回调,创建后同步
    scope: new EffectScope(true /* detached */),
    render: null, // 组件的render函数,在setupStatefulComponent阶段赋值
    proxy: null, // 是一个proxy代理ctx字段,内部使用this时,指向它
    // local resovled assets
    // resolved props and emits options
    // emit
    // props default value
    // inheritAttrs
    // state
    // suspense related
    // lifecycle hooks
  };
  {
    instance.ctx = createDevRenderContext(instance);
  }
  instance.root = parent ? parent.root : instance;
  instance.emit = emit.bind(null, instance);
  return instance;
}

createComponentInstance函数主要是初始化组件实例并返回,打印查看下根组件对应的instance内容:

  1. 设置组件实例
function setupComponent(instance, isSSR = false) {
  const { props, children } = instance.vnode;
  // 判断是否为状态组件
  const isStateful = isStatefulComponent(instance);
  // 初始化组件属性、slots
  initProps(instance, props, isStateful, isSSR);
  initSlots(instance, children);
  
  // 当状态组件时,挂载setup信息
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined;
  return setupResult;
}

setupComponent的逻辑也很简单,首先初始化组件propsslots挂载到组件实例instance上,然后根据组件类型vnode.shapeFlag===4,判断是否挂载setup信息(「也就是vue3的composition api」)。

function setupStatefulComponent(instance, isSSR) {
  const Component = instance.type;
  // 创建渲染上下文的属性访问缓存
  instance.accessCache = Object.create(null);
  // 创建渲染上下文代理
  instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers));
  const { setup } = Component;
  // 判断组件是否存在setup
  if (setup) {
    // 判断setup是否有参数,有的话,创建setup上下文并挂载组件实例
    // 例如:setup(props) => {}
    const setupContext = (instance.setupContext =
                          setup.length > 1 ? createSetupContext(instance) : null);
    // 执行setup函数
    const setupResult = callWithErrorHandling(setup, instance, 0 /* SETUP_FUNCTION */, [shallowReadonly(instance.props) , setupContext]);
    handleSetupResult(instance, setupResult, isSSR);
  }
  else {
    finishComponentSetup(instance, isSSR);
  }
}

判断组件是否设置了setup函数:

  1. 若设置了setup函数,则执行setup函数,并判断其返回值的类型。若返回值类型为函数时,则设置组件实例render的值为setupResult,否则作为组件实例setupState的值
function handleSetupResult(instance, setupResult, isSSR) {
  // 判断setup返回值类型
  if (isFunction(setupResult)) {
    // 返回值为函数时,则当作组件实例的render方法
    instance.render = setupResult;
  }
  else if (isObject(setupResult)) {
    // 返回值为对象时,则当作组件实例的setupState
    instance.setupState = proxyRefs(setupResult);
  }
  else if (setupResult !== undefined) {
    warn$1(`setup() should return an object. Received: ${setupResult === null ? 'null' : typeof setupResult}`);
  }
  finishComponentSetup(instance, isSSR);
}
  1. 设置组件实例的render方法,分析finishComponentSetup函数,render函数有三种设置方式:
  2. setup返回值为函数类型,则instance.render = setupResult
  3. 若组件存在render方法,则instance.render = component.render
  4. 若组件存在template模板,则instance.render = compile(template)

❝ 组件实例的render优化级:instance.render = setup() || component.render || compile(template)

function finishComponentSetup(instance, ...) {
  const Component = instance.type;
  // 绑定render方法到组件实例上
  if (!instance.render) {
    if (compile && !Component.render) {
      const template = Component.template;
      if (template) {
        // 通过编译器编译template,生成render函数
        Component.render = compile(template, ...);
      }
    }
    instance.render = (Component.render || NOOP);
  }
  // support for 2.x options
  ...
}

「设置完组件后,我们可以再查看下instance的内容有发生什么变化:」

这个时候组件实例instancedataproxyrendersetupState已经绑定上了初始值。

  1. 设置并运行带有副作用的渲染函数
const setupRenderEffect = (instance, initialVNode, container, ...) => {
  // 创建响应式的副作用函数
  const componentUpdateFn = () => {
    // 首次渲染
    if (!instance.isMounted) {
      // 渲染组件生成子树vnode
      const subTree = (instance.subTree = renderComponentRoot(instance));
      patch(null, subTree, container, ...);
      initialVNode.el = subTree.el;
      instance.isMounted = true;
    }
    else {
      // 更新
    }
  };
  // 创建渲染effcet
  const effect = new ReactiveEffect(
    componentUpdateFn, 
    () => queueJob(instance.update), 
    instance.scope // track it in component's effect scope
  );
  const update = (instance.update = effect.run.bind(effect));
  update.id = instance.uid;
  update();
};

接下来继续执行setupRenderEffect函数,首先会创建渲染effect「响应式系统」还包括其他副作用:「computed effect」「watch effect」),并绑定副作用执行函数到组件实例的update属性上(更新流程会再次触发update函数),并立即执行update函数,触发「首次更新」

function renderComponentRoot(instance) {
  const { proxy, withProxy, render, ... } = instance;
  let result;
  try {
    const proxyToUse = withProxy || proxy;
    // 执行实例的render方法,返回vnode,然后再标准化vnode
    // 执行render方法时,会调用proxyToUse,即会触发PublicInstanceProxyHandlers的get
    result = normalizeVNode(render.call(proxyToUse, proxyToUse, ...));
  }
  
  return result;
}

此时,renderComponentRoot函数会执行实例的render方法,即setupComponent阶段绑定在实例render方法上的函数,同时标准化render返回的vnode并返回,作为子树vnode

「同样我们可以打印查看一下子树vnode的内容:」

此时,可能有些同学开始疑惑了,为什么会有两颗vnode树呢?这两颗vnode树又有什么区别呢?

  1. 「initialVNode」
    initialVNode就是组件的vnode,即描述整个组件对象的,组件vnode会定义一些和组件相关的属性:dataprops生命周期等。通过渲染组件vnode,生成子树vnode
  2. 「sub tree」
    子树vnode是通过组件vnoderender方法生成的,其实也就是对组件模板template的描述,即真正要渲染到浏览器的DOM vnode

生成subTree后,接下来就继续通过patch方法,把subTree节点挂载到container上。 接下来,我们继续往下分析,大家可以看下上面subTree的截图:subTreetype值为Fragment,回忆下patch方法的实现:

const patch = (
  n1, // 旧的vnode
  n2, // 新的vnode
  container, // 挂载的容器
  ...
) => {
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Fragment:
      // fragment节点
      processFragment(n1, n2, container, ...)
      break
    default:
      // ...
  }
}

Fragment也就是vue3提到的新特性之一,在vue2中,是不支持多根节点组件,而vue3则是正式支持的。细想一下,其实还是单个根节点组件,只是vue3的底层用Fragment包裹了一层。我们再看下processFragment的实现:

const processFragment = (n1, n2, container, ...) => {
  // 创建碎片开始、结束的文本节点
  const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''));
  const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''));
  
  if (n1 == null) {
    hostInsert(fragmentStartAnchor, container, anchor);
    hostInsert(fragmentEndAnchor, container, anchor);
    // 挂载子节点数组
    mountChildren(n2.children, container, ...);
  } else {
    // 更新
  }
};

接下来继续挂载子节点数组:

const mountChildren = (children, container, ...) => {
  for (let i = start; i < children.length; i++) {
    const child = (children[i] = optimized
                   ? cloneIfMounted(children[i])
                   : normalizeVNode(children[i]));
    patch(null, child, container, ...);
  }
};

遍历子节点,patch每个子节点,根据child节点的type递归处理。接下来,我们主要看下typeELEMENT类型的DOM元素,即processElement

const processElement = (n1, n2, container, ...) => {
  if (n1 == null) {
    // 挂载DOM元素
    mountElement(n2, container,...)
  } else {
    // 更新
  }
}

const mountElement = (vnode, container, ...) => {
  let el;
  let vnodeHook;
  const { type, props, shapeFlag, ... } = vnode;
  {
    // 创建DOM节点,并绑定到当前vnode的el上
    el = vnode.el = hostCreateElement(vnode.type, ...);
  }
  // 插入父级节点
  hostInsert(el, container, anchor);
};

创建DOM节点,并挂载到vnode.el上,然后把DOM节点挂载到container中,继续递归其他vnode的处理,最后挂载整个vnode到浏览器视图中,至此完成vue3的首次渲染整个流程。mountElement方法中提到到hostCreateElementhostInsert也就是在最开始创建渲染器时传入的参数对应的处理方法,也就完成整个跨平台的「初次渲染流程」

更新流程

分析完vue3首次渲染的整个流程后,那么在数据更新后,vue3又是怎么「更新渲染」呢?接下来分析更新流程阶段就要涉及到vue3「响应式系统」的知识了(由于篇幅有限,我们不会展开更多响应式的知识,大家可以持续关注我们的公众号,期待后续篇章的更加详细的分析)。

依赖收集

回忆下在首次渲染时的设置组件实例setupComponent阶段会创建「渲染上下文代理」,而在生成subTree阶段,会通过renderComponentRoot函数,执行组件vnoderender方法,同时会触发「渲染上下文代理」PublicInstanceProxyHandlersget,从而实现「依赖收集。」

function setupStatefulComponent(instance, isSSR) {
  ...
  // 创建渲染上下文代理
  instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers));
}

function renderComponentRoot(instance) {
  const proxyToUse = withProxy || proxy;
  // 执行render方法时,会调用proxyToUse,即会触发PublicInstanceProxyHandlers的get
  result = normalizeVNode(
    render.call(proxyToUse, proxyToUse, ...)
  );
  return result;
}

我们可以查看下此时组件vnoderender方法的内容:

或者打印查看render方法内容:

(function anonymous(
) {
const _Vue = Vue
const { createVNode: _createVNode, createElementVNode: _createElementVNode } = _Vue

const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "static node", -1 /* HOISTED */)
const _hoisted_2 = ["onClick"]

return function render(_ctx, _cache) {
  with (_ctx) {
    const { createElementVNode: _createElementVNode, toDisplayString: _toDisplayString, resolveComponent: _resolveComponent, createVNode: _createVNode, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    const _component_item = _resolveComponent("item")

    return (_openBlock(), _createElementBlock(_Fragment, null, [
      _hoisted_1,
      _createElementVNode("div", null, _toDisplayString(title), 1 /* TEXT */),
      _createElementVNode("button", { onClick: add }, "click", 8 /* PROPS */, _hoisted_2),
      _createVNode(_component_item, { msg: title }, null, 8 /* PROPS */, ["msg"])
    ], 64 /* STABLE_FRAGMENT */))
  }
}
})

仔细观察render的第一个参数_ctx,即传入的渲染上下文代理proxy,当访问title字段时,就会触发PublicInstanceProxyHandlersget方法,那PublicInstanceProxyHandlers的逻辑又是怎么呢?

// 代理渲染上下文的handler实现
const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { ctx, setupState, data, props, accessCache, type, appContext } = instance;
    let normalizedProps;
    // key值不以$开头的属性
    if (key[0] !== '$') {
      // 优先从缓存中判断当前属性需要从哪里获取
      // 性能优化:缓存属性应该根据哪种类型获取,避免每次都触发hasOwn的开销
      const n = accessCache[key];
      if (n !== undefined) {
        switch (n) {
          case 0 /* SETUP */:
            return setupState[key];
          case 1 /* DATA */:
            return data[key];
          case 3 /* CONTEXT */:
            return ctx[key];
          case 2 /* PROPS */:
            return props[key];
            // default: just fallthrough
        }
      }
      // 获取属性值的顺序:setupState => data => props => ctx => 取值失败
      else if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
        accessCache[key] = 0 /* SETUP */;
        return setupState[key];
      }
      else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
        accessCache[key] = 1 /* DATA */;
        return data[key];
      }
      else if (
        (normalizedProps = instance.propsOptions[0]) &&
        hasOwn(normalizedProps, key)) {
        accessCache[key] = 2 /* PROPS */;
        return props[key];
      }
      else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
        accessCache[key] = 3 /* CONTEXT */;
        return ctx[key];
      }
      else if (shouldCacheAccess) {
        accessCache[key] = 4 /* OTHER */;
      }
    }
  },
  set() {},
  has() {}
};

接下来我们以keytitle的例子简单介绍下get的逻辑:

  1. 首先判断key值是否已$开头,明显title走否的逻辑
  2. 再看accessCache缓存中是否存在
    性能优化:缓存属性应该根据哪种类型获取,避免每次都触发**hasOwn**的开销
  3. 最后再按照顺序获取:setupState => data => props => ctx PublicInstanceProxyHandlerssethas的处理逻辑,同样以这个顺序处理
  4. 若存在时,先设置缓存accessCache,再从setupState中获取title对应的值

「重点来了」,当访问setupState.title时,触发proxyget的流程会有两个阶段:

  1. 首先触发setupState对应的proxyget,然后获取title的值,判断其是否为Ref
  2. 是:继续获取ref.value,即触发ref类型的依赖收集流程
  3. 否:直接返回,即为普通数据类型,不进行依赖收集
// 设置组件实例时会设置setupState的代理prxoy
// 设置流程:setupComponent=>setupStatefulComponent=>handleSetupResult
instance.setupState = proxyRefs(setupResult)

export function proxyRefs(objectWithRefs) {
  return isReactive(objectWithRefs)
    ? objectWithRefs
    : new Proxy(objectWithRefs, {
        get: (target, key, receiver) => {
          return unref(Reflect.get(target, key, receiver))
        },
        set: (target, key, value, receiver) => {}
      })
}

export function unref(ref) {
  return isRef(ref) ? ref.value : ref
}
  1. 访问ref.value时,触发ref的依赖收集。那我们先来分析Vue.ref()的实现逻辑又是什么呢?
// 调用Vue.ref(0),从而触发createRef的流程
// 省略其他无关代码
function ref(value) {
  return createRef(value, false)
}
function createRef(rawValue) {
  return new RefImpl(rawValue, false)
}
// ref的实现
class RefImpl {
  constructor(value) {
    this._rawValue = toRaw(value)
    this._value = toReactive(value)
  }

  get value() {
    trackRefValue(this)
    return this._value
  }
}
function trackRefValue(ref) {
  if (isTracking()) {
    if (!ref.dep) {
      ref.dep = new Set()
    }
    // 添加副作用,进行依赖收集
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

分析ref的实现,会发现当访问ref.value时,会触发RefImpl实例的value方法,从而触发trackRefValue,进行依赖收集dep.add(activeEffect)。那这时的activeEffect又是谁呢?

回忆下setupRenderEffect阶段的实现:

const setupRenderEffect = (instance, initialVNode, container, ...) => {
  // 创建响应式的副作用函数
  const componentUpdateFn = () => {};
  // 创建渲染effcet
  const effect = new ReactiveEffect(
    componentUpdateFn, 
    () => queueJob(instance.update), 
    instance.scope
  );
  const update = (instance.update = effect.run.bind(effect));
  update();
};

// 创建effect类的实现
class ReactiveEffect {
  run() {
    try {
      effectStack.push((activeEffect = this))
      // ...
      return this.fn()
    } finally {}
  }
}

当执行update函数时(即渲染effect实例的run方法),从而设置全局activeEffect当前渲染effect,也就是说此时dep.add(activeEffect)收集的activeEffect就是这个渲染effect,从而实现了依赖收集。

我们可以打印一下setupState的内容,验证一下我们的分析:

通过截图,我们可以看到此时title收集的「副作用」就是渲染effect,细心的同学就发现了截图中的fn方法就是componentUpdateFn函数,执行fn()继续挂载children

派发更新

分析完「依赖收集」阶段,我们再看下,vue3又是如何进行「派发更新」呢?

当我们点击按钮执行this.title += 1时,同样会触发PublicInstanceProxyHandlersset方法,而set的触发顺序同样和get一致:setupState=>data=>其他不允许修改的判断(例如:props$开头的保留字段

// 代理渲染上下文的handler实现
const PublicInstanceProxyHandlers = {
  set({ _: instance }, key, value) {
    const { data, setupState, ctx } = instance;
    // 1. 更新setupState的属性值
    if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
      setupState[key] = value;
    }
    // 2. 更新data的属性值
    else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
      data[key] = value;
    }
    // ...
    return true;
  }
};

设置setupState[key]从而继续触发setupStateset方法:

const shallowUnwrapHandlers: ProxyHandler<any> = {
  set: (target, key, value, receiver) => {
    const oldValue = target[key]
    // oldValue为ref类型&value不是ref时执行
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      // 否则,直接返回
      return Reflect.set(target, key, value, receiver)
    }
  }
}

当设置oldValue.value的值时继续触发refset方法,判断ref是否存在dep,执行副作用effect.run(),从而派发更新,完成更新流程。

class RefImpl{
  set value(newVal) {
    newVal = this._shallow ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : toReactive(newVal)
      triggerRefValue(this, newVal)
    }
  }
}

// 判断ref是否存在依赖,从而派发更新
function triggerRefValue(ref) {
  ref = toRaw(ref)
  if (ref.dep) {
    triggerEffects(ref.dep)
  }
}
// 派发更新
function triggerEffects(dep) {
  for (const effect of isArray(dep) ? dep : [...dep]) {
    if (effect !== activeEffect || effect.allowRecurse) {
      // 执行副作用
      effect.run()
    }
  }
}

总结

综上,我们分析完了vue3的整个「渲染过程」「更新流程」,当然我们只是从主要的渲染流程分析,完整的渲染过程的复杂度不止于此,比如基于block tree的优化实现,patch阶段的diff优化以及在「更新流程」中的「响应式阶段」的优化又是怎样的等细节。感兴趣的同学可以自己分析学习或者关注我们公众号的后续分析。

本文的初衷便是给大家提供「分析vue3整个渲染过程的轮廓」,有了整体的印象,再去分析了解更加细节的点的时候,也会更有思路和方向。

最后,附一张完整的渲染流程图,与君共享。

v2-f92f36cef1ba0da688da26af7218e085_720w.jpg

参考资料

[1] Vue.js 3.0源码地址

[2] 细说 Vue.js 3.2 关于响应式部分的优化


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK