奇葩说框架之Vue3渲染系统

发表于 2年以前  | 总阅读数:640 次

前言

提到马拉松,大家都知道马拉松是世界上最长的田径项目(全程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节点

了解完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

2 . 生成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

3 . 启动开发环境

npm run serve

4 . 测试代码

<!-- 调试代码目录:/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结构

渲染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内容:

instance内容

2 . 设置组件实例

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);
}

2 . 设置组件实例的render方法,分析finishComponentSetup函数,render函数有三种设置方式: 3 . 若setup返回值为函数类型,则instance.render = setupResult 4 . 若组件存在render方法,则instance.render = component.render 5 . 若组件存在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的内容有发生什么变化:

设置组件后的instance

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

3 . 设置并运行带有副作用的渲染函数

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 effectwatch 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的内容:

sub tree

此时,可能有些同学开始疑惑了,为什么会有两颗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

或者打印查看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
}

2 . 访问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的内容,验证一下我们的分析:

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整个渲染过程的轮廓,有了整体的印象,再去分析了解更加细节的点的时候,也会更有思路和方向。

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

整个渲染流程图

参考资料

[1] Vue.js 3.0源码地址: https://github.com/vuejs/vue-next

[2]细说 Vue.js 3.2 关于响应式部分的优化: https://zhuanlan.zhihu.com/p/401416696

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/4mpP4-9D_aih44rRijib_A

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:7月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:7月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:7月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:7月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:7月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:7月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:7月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:7月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:7月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:7月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:7月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:7月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:7月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:7月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:7月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:7月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:7月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:7月以前  |  398次阅读  |  详细内容 »
 目录