likes
comments
collection
share

Vue3源码分析(9)-组件粒度更新实现原理

作者站长头像
站长
· 阅读数 16

本文介绍

之前我们介绍了组件的创建过程、初始化过程、本文主要讲解组件挂载流程当中如何设置副作用,当改变响应式变量后会重新调用副作用函数触发更新流程。我们先来回顾一下挂载组件的函数(mountComponent)

const mountComponent = (
    initialVNode,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    //创建组件实例
    const instance = (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ));
    //初始化props slots
    //创建setupCtx,调用setup函数等
    setupComponent(instance);
    setupRenderEffect(
      instance,
      initialVNode,
      container,
      anchor,
      parentSuspense,
      isSVG,
      optimized
    );
  };
  • 我们在之前的文章中已经分析过了createComponent、setupComponent的执行过程。大家可以在系列文章中了解详情。这里就简单的介绍一下。
  • createComponent:根据创建的Vnode创建组件实例对象
  • setupComponent:初始化PropsSlots、调用setup、beforeCreate等函数、适配Vue2的选项式风格配置、注册生命周期钩子等。
  • setupRenderEffect:创建副作用更新函数,收集依赖,这也是Vue实现组件级更新的核心

更新副作用

1.setupRenderEffect

//设置更新副作用
const setupRenderEffect = (
  instance, //当前组件的实例
  initialVNode, //当前组件的VNode
  container, //挂载的容器
  anchor,
  parentSuspense,
  isSVG, //是否是SVG图标
  optimized
) => {
  const componentUpdateFn = () => {}
  //创建响应式副作用用于渲染
  //在componentUpdateFn收集依赖
  //后续更新也是调用componentUpdateFn
  const effect = (instance.effect = new reactivity.ReactiveEffect(
    componentUpdateFn,
    () => queueJob(update),
    instance.scope //只在这个实例作用域追踪
  ));
  //将update赋值到instance上
  const update = (instance.update = () => effect.run());
  update.id = instance.uid; //设置更新的id
  //切换支持递归更新
  toggleRecurse(instance, true);
  //给update绑定当前实例
  update.ownerInstance = instance;
  //找到componentUpdateFn中的响应式对象进行track
  update();
};
  • ReactiveEffect类接受三个参数,第一个参数为收集依赖时访问的函数,当调用这个函数后,这个函数中所有的响应式变量的读取操作都会被收集依赖,当修改响应式变量后则会调用第二个参数传递的函数
  • 我们需要注意的是setup、beforeCreate、created中是不会收集依赖的。
  • queueJob:给调度器添加任务并执行,详情可以看Vue3源码分析(8)
  • update:调用update=>effect.run()=>componentUpdateFn,调用update相当于调用componentUpdateFn也就是执行组件的更新。但是使用effect.run调用将会在调用过程中收集响应式依赖
  • setupRenderEffect执行流程:创建更新组件的副作用函数,创建ReactiveEffect实例实现响应式更新,先调用update实现组件第一次挂载的更新,同时收集响应式依赖。当改变响应式数据后会触发()=>queueJob(update)。也就实现了改变响应式数据视图也发生相应的变化。

2.副作用更新函数(componentUpdateFn)

const componentUpdateFn = () => {
  //判断当前实例是否已经被挂载
  if (!instance.isMounted) {}
  //已经挂载过了 更新流程
  else {}
};

对于这个函数主要分为挂载流程和更新流程

3.挂载流程

if (!instance.isMounted) {
  let vnodeHook;
  //获取元素和element和props
  const { el, props } = initialVNode;
  //获取beforeMount mount钩子
  const { bm, m, parent } = instance;
  //pre-lifecycle不能递归收集当前更新的依赖设置为false
  toggleRecurse(instance, false);
  //这里的bm已经经过处理了 不需要try catch包裹
  //调用beforeMount函数
  if (bm) {
    shared.invokeArrayFns(bm);
  }
  //切换为可递归
  toggleRecurse(instance, true);
  //省略第二部分代码
}
  • invokeArrayFns:在执行beforeMount的时候包裹一层try catch,因为beforeMount是用户写的代码可能出现错误,为了保证程序能继续运行下去所以需要一层保护机制
  • 第一次挂载需要调用beforeMounted钩子,这里我们可以发现切换为了非递归。如何理解这个非递归呢?我们知道当前处于收集依赖状态,只要使用了响应式变量都会收集到依赖,那么用户就可以在beforeMount钩子当中使用响应式变量并且修改他们,假设在beforeMount中修改了响应式变量那么就会执行()=>queueJob(update),update任务将会放入queue队列,如果不设置禁止递归那么这个任务将会放入当前执行这个任务的后面,并且当前执行的任务和加入的任务是同一个任务,而目前还没有调用模版渲染函数,所以并不需要再次执行update任务。所以设置为禁止递归就可以禁止当前任务添加到队列当中
  • 当然mounted钩子就不需要设置禁止递归了,因为mounted表示模版渲染函数已经执行过了,拿到了最新的数据,后续的渲染也是根据这个数据渲染的。那么在mounted中修改数据就需要再次渲染了所以反而需要开启递归
//简单模拟一下这个过程
const r = reactive({a:1})
const effect = new ReactiveEffect(()=>r,()=>queue.push(currentTast))
effect.run()//调用()=>r收集依赖
function currentTask(){
  //修改了变量导致()=>queue.push(currentTask)执行
  r.a = 2
}
const queue = [currentTask]//目前的queue队列
let index = 0
for(;index === queue.length;index++){
  queue[index]()
}
  • 执行currentTask的同时,修改了响应式变量r.a引起()=>queue.push(currentTask)的调用。我们可以发现新添加的任务和当前执行的任务是同一个任务,但是我们的目的是不需要重复执行同一个任务,所以我们可以设置禁止递归,如果当前添加的任务和执行的任务是同一个任务就不添加。
//获取调用render函数后的vNode(这里会调用render函数)
//那么render函数有用到所有包含模板的变量,这些在模板
//中使用的响应式变量 都会被收集到这个依赖函数componentUpdateFn
const subTree = (instance.subTree = renderComponentRoot(instance));
//diff比较
patch(
  null, //挂载流程需要比较的元素
  subTree, //组件已经挂载成功了,现在需要挂载render函数返回的内容
  container,
  anchor,
  instance,
  parentSuspense,
  isSVG
);
initialVNode.el = subTree.el;
//放入vue的调度后置队列,这将会让其在渲染后调用这个钩子
if (m) {
  queuePostRenderEffect(m, parentSuspense);
}
//挂载流程已经完成,设置为true
instance.isMounted = true;
initialVNode = container = anchor = null;
  • 首先调用renderComponentRoot,简单理解为调用模版渲染函数render,获取vnode
  • 执行patch,根据获得的vnode比较并渲染到页面,由于当前处于挂载流程所以第一个参数为null。这个函数比较复杂,我们后续单独开一章节讲解Vue的diff策略
  • queuePostRenderEffect:Vue调度器的后置队列,这里的任务将会在普通队列任务全部执行完成后再执行,而组件副作用更新函数(componentUpdateFn)普通任务,所以mounted钩子一定会在渲染完成之后执行
  • 最后设置实例对象的isMounted为true,表示当前组件已经挂载完成

4.renderComponentRoot

  1. 有状态组件调用render函数。
  2. 无状态组件直接调用函数本身。
  3. 处理了透传的问题,对于Fragmenttext无法透传,其他类型的节点,根节点可以透传(合并绑定的class style onXXX事件)等。
function renderComponentRoot(instance){
  const {
    type: Component,//传递的组件
    vnode,//组件对应的vnode
    proxy,//methods、data等方法中通过this访问到的代理数据
    withProxy,
    props,//传递的props
    propsOptions: [propsOptions],//用户设置的props
    slots,//插槽
    attrs,//透传属性
    emit,//emit方法
    render,//渲染函数
    renderCache,
    data,
    setupState,//setup函数的返回值
    ctx,
    inheritAttrs,//用户指定的参数,用于控制是否允许透传
  } = instance;
  let result;
  let fallthroughAttrs;
  const prev = setCurrentRenderingInstance(instance);
  //省略第二部分代码
}
  • 这部分代码主要是从实例上获取一些属性。

1.有状态组件直接调用render函数获取vnode

try {
  //当前vnode是有状态的组件
  if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    const proxyToUse = proxy;
    //调用渲染函数获取Vnode
    result = normalizeVNode(
      render.call(
        proxyToUse,
        proxyToUse,
        renderCache,
        props,
        setupState,
        data,
        ctx
      )
    );
    fallthroughAttrs = attrs
   }
   else{
   //省略第二部分代码
   }
}
catch(err){
  console.error(err)
}
//省略第三部分代码
  • STATEFUL_COMPONENT:如果在.vue文件中暴露的是对象那么是状态组件,如果暴露的是函数那么是无状态组件。
  • 由于模版函数中仍然有可能出错,需要使用try catch添加保护机制。
  • 我们可以发现render函数this指向proxy。而proxy中代理了data、methods、computed、setupState中的属性,你可以通过key直接访问到他们。这就是为什么我们可以在模版中通过this访问到他们。
  • normalizeVnode:标准化vnode,因为render函数可以让用户写,但是写出来的render函数可能不会像编译出来的那样标准,所以需要标准化。例如:组件返回了含有多个子节点的结构则需要包裹Fragment,但是自己写render函数可能会忘记这样包裹,这时候normalizeVnode就会为其添加。

2.无状态组件直接调用函数本身

const render = Component;
//对于函数组件只传入props,{}两个参数
result = normalizeVNode(
  render.length > 1
    ? render(props, {
        get attrs() {
          return attrs;
        },
        slots,
        emit,
      })
    : render(props, null)
);
//获取style class onXXX属性
fallthroughAttrs = attrs 
  • 因为是无状态组件 (.vue文件中导出的是一个函数) 所以本身就是render函数
  • 这里做了一个小优化,如果render函数含有超过一个的形参才会传递第二个实参,否则传递null
  • fallthroughAttrs:renderComponentRoot函数中声明的变量,获取到需要透传的属性。

3.处理透传的问题

  • Vue官网中对于透传的解释:透传attribute指的是传递给一个组件,却没有被该组件声明为propsemitsattribute或者v-on事件监听器。最常见的例子就是class、style 和 id
  • 当一个组件以单个元素为根作渲染时,透传的attribute会自动被添加到根元素上。举例来说,假如我们有一个<MyButton>组件,它的模板长这样:
<!-- <MyButton> 的模板 -->
<button>click me</button>
  • 一个父组件使用了这个组件,并且传入了class:
<MyButton class="large" />
  • 最后渲染出的DOM结果是:
<button class="large">click me</button>
  • 这里<MyButton>并没有将class声明为一个它所接受的prop,所以class被视作透传 attribute,自动透传到了<MyButton>的根元素上

我们看代码实现:

//第三部分代码实现
let root = result; //vnode的结果
//inheritAttrs(处理透传的逻辑)
//对于Fragment 和 text节点无法透传
if (fallthroughAttrs && inheritAttrs !== false) {
  //{style:{},class:{},onClick:{}}=>["style","class","onClick"]
  const keys = Object.keys(fallthroughAttrs);
  //获取render函数结果的根元素的类型
  const { shapeFlag } = root;
  //当前有需要透传的属性
  if (keys.length) {
    //render执行后元素是ELEMENT类型或者组件类型
    if (shapeFlag & (ShapeFlags.ELEMENT | ShapeFlags.COMPONENT)) {
      //将父亲的属性透传到子节点中
      root = cloneVNode(root, fallthroughAttrs);
    }
    //透传不能传递到Fragment or Text warn提示
    else if (root.type !== Comment) {
      const allAttrs = Object.keys(attrs);
      const eventAttrs = [];
      const extraAttrs = [];
      for (let i = 0, l = allAttrs.length; i < l; i++) {
        const key = allAttrs[i];
        if (shared.isOn(key)) {
          //忽略v-model
          if (!shared.isModelListener(key)) {
            //"onClick"=>"onclick"
            eventAttrs.push(key[2].toLowerCase() + key.slice(3));
          }
        }
        //非事件的监听器作为extraAttrs
        else {
          extraAttrs.push(key);
        }
      }
      if (extraAttrs.length) {
        console.warn();
      }
      if (eventAttrs.length) {
        console.warn();
      }
    }
  }
}
//省略第四部分代码
  • 通过attrs获取到需要透传的属性,我们还需要区分,比如FRAGMENT与TEXT类型的节点就不能够透传,而ELEMENT、COMPONENT (包含有状态组件和无状态组件) 就能够透传。如果透传给组件,组件又继续透传到组件的子节点上最终总会落到ELMEMENT上。
  • 如果是ELEMENT、COMPONENT,将attrs属性克隆到子节点的vnode中。
  • 对于非ELEMENT、COMPONENT、COMMENT节点 (例如FRAGMENT、TEXT等) 主要是区分了透传的属性是事件属性还是其他属性,然后对用户进行了警告。
let root = result; //vnode的结果
//透传directives
if (vnode.dirs) {
  if (!isElementRoot(root)) {
    warn(
      `Runtime directive used on component with non-element root node. ` +
        `The directives will not function as intended.`
    );
  }
  root = cloneVNode(root);
  root.dirs = root.dirs ? root.dirs.concat(vnode.dirs) : vnode.dirs;
}
//透传transition
if (vnode.transition) {
  //当前root是COMPONENT、ELEMENT、COMMENT
  if (!isElementRoot(root)) {
    warn(
      `Component inside <Transition> renders non-element root node ` +
        `that cannot be animated.`
    );
  }
  root.transition = vnode.transition;
}
//将当前实例设置为之前的
setCurrentRenderingInstance(prev);
return result;
  • isElementRoot:如果rootCOMPONENT、ELEMENT、COMMENT类型返回true
  • 透传自定义指令transition
  • 透传的本质就是将attrs中的属性传递到组件模版渲染的根vnode上;attrs是指在使用组件的时候传递了参数但是没有在组件内部声明的属性
  • 最终将模版渲染函数返回的vnode返回。

5.更新流程

  • 好的,目前我们已经讲解完了组件的挂载流程,下面我们来看看组件的更新流程。
const componentUpdateFn = function (instance) {
  if (!instance.isMounted) {
    /*分析完毕*/
  }
  //这里为更新流程
  else {
    //这是由于组件自身状态改变触发的,或调用了instance.update
    let { next, bu, u, parent, vnode } = instance;
    let originNext = next;
    let vnodeHook;
    toggleRecurse(instance, false);
    if (next) {
      next.el = vnode.el;
      updateComponentPreRender(instance, next, optimized);
    } else {
      next = vnode;
    }
    //调用beforeUpdate
    if (bu) {
      shared.invokeArrayFns(bu);
    }
    //设置可递归
    toggleRecurse(instance, true);
    //再次调用render函数
    const nextTree = renderComponentRoot(instance);
    const prevTree = instance.subTree;
    instance.subTree = nextTree;
    //再次调用patch比较
    patch(
      prevTree,
      nextTree,
      hostParentNode(prevTree.el), //获取父节点
      getNextHostNode(prevTree), //anchor
      instance,
      parentSuspense,
      isSVG
    );

    next.el = nextTree.el;
    if (originNext === null) {
      updateHOCHostEl(instance, nextTree.el);
    }
    //更新完成 调用updated 放入后置队列在
    //渲染完成之后在调用
    if (u) {
      queuePostRenderEffect(u, parentSuspense);
    }
  }
};
  • 对于beforeUpdate的调用需要禁止递归,避免重复执行相同的任务;而updated的调用需要允许递归。上面已经讲述过了不再赘述。
  • instance.next:父组件的响应式状态发生变化,调用了父组件的update函数进行更新,当更新到子组件的时候需要调用子组件实例的update函数以实现子组件的更新,而子组件本身的属性更新是放到子组件实例的update中完成的,但是在子组件实例中无法获取到最新的子组件vnode所以需要在调用子组件更新之前给子组件的实例的next属性赋值,这个值就是子组件最新的vnode
//这里简化了代码
const updateComponent = (beforeVnode, currentVnode, optimized) => {
  const instance = (currentVnode.component = beforeVnode.component);
    //当父组件自身调用update更新后
    //会遇到子组件,子组件也需要更新
    //但是子组件本身的更新(例如props、slots等需要更新)
    //并不是在这里完成的,而是在子组件的update中完成的
    //所以这里给最新子组件的vode做了缓存
    instance.next = currentVnode;
    //在子组件真正调用update更新中才对子组件的属性进行更新
    instance.update();
  }
};
  • updateComponentPreRender:在渲染之前更新组件。上面的子组件最新vnode就需要用在这个函数中,用最新的vnode覆盖旧的vnode
//这里的nextVNode就是调用instance.update
//之前缓存的next属性或者说是最新的子组件vnode
const updateComponentPreRender = (instance, nextVNode, optimized) => {
  nextVNode.component = instance;
  //获取之前的props
  const prevProps = instance.vnode.props;
  //修改子组件实例的虚拟节点为最新的虚拟节点
  instance.vnode = nextVNode;
  //马上利用next属性对子组件更新,
  //next属性无用了 清除缓存。
  instance.next = null;
  //更新子组件的props与slots
  updateProps(instance, nextVNode.props, prevProps, optimized);
  updateSlots(instance, nextVNode.children, optimized);
  //禁止收集副作用
  reactivity.pauseTracking();
  //执行所有的前置任务(同步执行)
  flushPreFlushCbs();
  reactivity.resetTracking();
};
  • 这个函数实际上就是更新子组件自身的属性。props与slots的更新读者可以查看源码这里不再细说了。

我们继续回到componentUpdateFn更新流程的分析中。

  • 在更新完了组件本身后,调用renderComponentRoot获取到最新的根节点vnode,然后通过patch方法diff比较prevTree与nextTree找到不同并渲染就行了。
  • updateHOCHostEl:想象这样一个场景,父组件的 subTree(调用render函数后的返回值) 依旧是一个组件,子组件的subTree是一个真实的元素所以含有el。那么对于父组件来说它的el属性就应该改为子组件的el属性
const updateHOCHostEl = function ({ vnode, parent }, el) {
  while (parent && parent.subTree === vnode) {
    //更新父组件的el为真实DOM
    (vnode = parent.vnode).el = el;
    parent = parent.parent;
  }
};

总结

  • 本文主要讲解了Vue如何实现组件粒度的更新。本质上都是调用组件实例的update函数,如果是自身的响应式状态发生了变化则调用自身的,子组件的更新可以在patch过程中调用到子组件的update进行更新。
  • 同时还讲解了beforeMount、Mounted、beforeUpdate、updated的调用时机,以及为什么有些钩子需要允许递归、有些钩子要禁止递归
  • 我们还讲解了透传的实现原理。相信大家一定收获满满吧!