Vue3中组件的实现原理(一)

187 阅读6分钟

1. 什么是组件?

从用户的角度看,一个有状态组件的组件就是一个选项对象,但是从渲染器内部的实现看,一个组件是一个特殊类型的虚拟DOM节点。通过vnode.type 属性来存储标签名称。实际上,组件本身是对页面内容的封装,它用来描述页面内容的一部分,render函数用来描述组件所内容的接口。

const MyComponent = {
    // 组件名称
    name: "MyCompent",
    render(){
        // 返回虚拟dom
       return{
           type: "div", // Fragment 片段、Text 文本节点、string 普通元素、object 选项对象(组件)        
           children: `hello world`
       }
    }
}

如果type是选项对象(组件),调用mountComponentpatchCompent函数完成组件的挂载和更新,真正完成渲染任务的是 mountCompontent 函数。

function mountCompontent(vnode, container, anchor){
    // 通过vnode获取组件的选项对象
    const componentOptions = vnode.type;
    // 获取组件的渲染函数
    const { render } = componentOptions;
    // 执行渲染函数,
    const subTree = render();
    // 调用patch挂载组件
    patch(null, subTree, container, anchor);
}

2. 组件的状态与自更新是怎样的?

2.1 初始化组件状态

  • 组件自身状态的初始化分两步,首先通过组件的选项对象获得data函数,调用teactive函数将data函数返回的状态包装为响应式对象;
  • 在调用render函数时,将其this的指向设置为响应式数据state,同时将state作为render函数的第一个参数传递。

2.2 组件状态的自动更新

一但自身想响应式数据发生变化时,组件根据收集的依赖,重新执行渲染函数进行派发更新,但是因为effect函数是同步的,如果多次修改响应式数据的值,会导致渲染函数多次执行。这里设计了一个调度器缓存机制,当副作用函数需要重新执行时,优先缓冲到微任务队列中,等到执行栈清空后,再将它从微任务队列中取出来执行。

// 用set结构存储缓存队列
const queue = new Set();
// 是否正在刷新任务队列
let isFlushing = false;
// 立即执行resolve的promise实例
const p = Promise.resolve();

// 开始刷新
function queueExecute(job) {
    // 加入微队列
    queue.add(job);
    // 如果还没有刷新队列,先刷新一遍
    if(!isFlushing){
          isFlushing = true;
          p.then(() => {
              try{
                  // 执行任务队列中的任务
                  queue.forEach(job => job());
              } finally {
                  // 重置状态
                  isFlushing = false;
                  queue.clear = 0;
              }
          })
    }
}

3. 组件的生命周期

这段代码中,首先从选项对象中取的注册到组件上的生命周期函数,然后在合适时机调用不同的钩子,这就是生命周期的基本原理。

function mountComponent(vnode, container, anchor){
    const componentOptions = vnode.type;
    // 从组件选项对象中获取组件的生命周期函数
    const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions;
    // 1.调用 beforeCreate
    beforeCreate && beforeCreate();
    const state = reactive(data());
    const instance = { state, isMounted: true, subTree: null }
    vnode.component = instance;
    // 2.调用 created
    created && created.call(state);
    effect(() => {
       const subTree = render.call(state, state);
       if(!instance.isMounted){
           // 3.调用 beforeMounted
           beforeMounted && beforeMounted();
           patch(null, subTree, container, anchor);
           instance.isMounted = true;
           // 4.调用 mounted
           mounted && mounted();
        } else {
           // 5.调用 brforeUpdate
           beforeUpdate && beforeUpdate();
           patch(instance.subTree, subTree, container, anchor);
           // 6.调用 update
           updated && updated.call(state)
        }
        instance.subTree = subTree;
    }, { scheduler: queueExecute })
}

4.props与组件的被动更新

4.1 Props的传递与获取

在虚拟dom层面,组件的props与普通HTML标签的属性差别不大,为什么这么说?来看一段代码,比如这个组件:

<MyComponent title="提示" :value="val" />

对应的虚拟dom会解析成:

const vnode = {
    type: Mycomponent,
    props: {
        title: "提示",
        value: this.val
    }
}

所以,对于一个组件来说,我们只需要关心两部分内容

  • 为组件传递props数据,即vnode.props对象;
  • 组件选项对象中定义的props选项,即 MyComponent.props对象; 将组件选项中定义的MyComponent.props 和 为组件传递的vnode.props相结合,最终解析出组件渲染时需要使用的props和attrs数据。 在Vue3中,没有定义在MyComponent.props 选项中的props数据将存储到attrs中。

4.2 Props数据变化与更新

props本质是父组件的数据,当props发生变化时,会触发父组件的重新渲染。在更新过程中,渲染器发现父组件的subTree包含组件类型的虚拟节点,会调用patchComponent函数完成子组件的更新。

function path(n1, n2, container, ancher){
    if(n1 && n1.type !== n2.type){
        unmount(n1);
        n1 = null;
    }
    const { type } = n2;
    if(typeof type === 'string'){
        //……
    } else if(typeof type === Text){
        //……
    } else if(typeof type === Fragment){
        //……
    } else if(typeif type === 'object'){
        // vnode.type 的值是选项对象,作为组件来处理
        if(!n1){
            mountComponent(n2, container, anchor);
        } else {
            // 更新组件
            patchComponent(n1, n2, anchor);
        }
    }
}

patchComponent函数用来完成子组件的更新,具体实现:

// 子组件的更新
function patchComponent(n1, n2, anchor){
    // 获取组件实例
    const instance = (n2.component = n1.component);
    // 获取当前props数据
    const { props } = instance;
    // 调用 hasPropsCahnged 检测为子组件传递的props是否发生变化,如果没有变化,则不需要更新
    if(hasPropsCahnged(n1.props, n2.props)){
        // 调用 resolveProps 函数重新获取props数据
        const [ nextProps ] = resolveProps(n2.type.props, n2.props);
        // 更新props
        for(const k in nextProps){
            props[k] = nextProps[k];
        }
        // 删除不存在的props
        for(const k in props){
            if(!(k in nextProps)){
                delte props[k];
            }
        }
    }
}

// 检测为子组件传递的props是否发生变化
function hasPropsCahnged(prevProps, nextProps){
    const nextKeys = Object.keys(nextProps);
    // 如果新旧 props 的数量变化了,则说明有变化
    if(nextKeys.lengt !== Object.keys(prevProps).length){
        return true;
    }
    // 遍历每一项看内部变化
    for(let i = 0; i < nextKeys.length; i++){
        const key = nextKeys[i];
        // 有不相等的props,说明有变化
        if(nextPeops[key] !== prevProps[key]){
            return true;
        }
    }
    return false;
}

以上是组件被动更新的核心逻辑,上面的处理中,并没有处理attrs和solts的更新,attrs的更新本质上和更新props相似;

总结

  • 本文首先描述的是如何使用虚拟节点来描述组件,使用虚拟节点的vnode.type 属性来存储组件对象,渲染器根据虚拟节点的type属性类型判断是否为组件,如果是组件,则调用mountComponent和patchComponent来完成组件的挂载和更新。
  • 接着,讲到了组件的自动更新,在组件挂载阶段,会为组件创建一个用于渲染内容的副作用函数。这个副作用函数会与组件自身的响应式数据建立联系,也就是依赖收集。当组件数据发生变化时,会触发副作用函数重新执行,也就是派发更新。
  • 然后,介绍了组件本质上是一个对象,包含了组件运行过程中的全部状态,例如组件是否挂载,响应式数据,以及所渲染的内容。有了这些实例,在副作用函数内,根据组件实例上的标识,来决定应该进行全新的挂载还是打补丁。
  • 最后,通过源码,实现了props与组件的被动更新,副作用自更新所引起的子组件的更新叫做子组件的被动更新,子组件的更新通过判断新旧数据的变化,来判断是否被更新。