3

Vue.js设计与实现之组件的实现原理

 1 year ago
source link: https://www.fly63.com/article/detial/11388
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.

1 . 写在前面

上篇文章介绍使用虚拟节点来描述组件,讨论了组件在挂载的时候,响应式数据发生变化会导致组件频繁渲染,对此采用微任务队列可以避免频繁执行。介绍了如何创建组件实例,通过instance实例上的isMounted的状态,区分组件的挂载与更新。那么本文将继续讲解组件的实现细节。

2 . props与组件的被动更新

props

在虚拟dom中,组件的props和普通html标签上的属性差别并不大。

<MyComponent name="pingping" age="18"/>

对应的虚拟DOM是:

const vnode = {
  type: MyComponent,
  props: {
    name:"pingping",
    age:18
  }
}

对于组件而言:

const MyComponent = {
  name:"MyComponent",
  props:{
    name:String,
    age: Number
  },
  render(){
    return {
      type:"div",
      children:`my name is ${this.name}, my age is: ${this.age}`
    }
  }
}

对于组件而言,需要关心的props内容有两部分:

  • 为组件传递数据的props,即vnode.props对象
  • 组件内部选项自定义的props,即MyComponent.props

组件在渲染时解析props数据需要结合这两个选项,最终解析出组件在渲染时需要使用到的props和attrs。

function mountComponent(vnode, container, anchor){
  const componentOptions = vnode.type;
  // 从组件选项中获取到的props对象即propsOption
  const { render, data,props: propsOption } = componentOptions;
  
  // 在数据初始化前
  beforeCreate && beforeCreate();
  // 将原始数据对象data封装成响应式数据
  const state = reactive(data());
  
  // 调用resolveProps 函数解析最终的props和attrs数据
  const [props, attrs] = resolveProps(propsOptions, vnode.props);
  
  // 组件实例
  const instance = {
    // 组件状态数据
    state,
    // 组件挂载状态
    isMounted: false,
    // 组件渲染内容
    subTree: null,
    props: shallowReactive(props);
  }
  
  // 将组件实例设置在vnode上,方便后续更新
  vnode.component = instance;
  //... 代码省略
}

再看看将props解析成最终的props和attrs的resolveProps函数:

在上面代码中,没有定义在组件的props选项中的props数据将会被存储在attrs对象中,实际上还需要对其进行默认值处理。

function resolveProps(options, propsData){
  const props = {};
  const attrs = {};
  //遍历为组件传递的props数据
  for(const key in propsData){
    // 鉴别是否为组件约定的props
    if(key in options){
      props[key] = propsData[key];
    }else{
      attrs[key] = propsData[key];
    }
  }
  return [props, attrs]
}

组件的被动更新

其实,子组件的props数据本质上就是来自于父组件传递的,在props发生变化时,会触发父组件的重新渲染。

假定父组件初次要渲染的虚拟DOM:

const vnode = {
  type: MyComponent,
  props:{
    name:"pingping",
    age:18
  }
}

在name或age的数据发生变化时,父组件的渲染函数会重新执行,从而产生新的虚拟DOM:

const vnode = {
  type: MyComponent,
  props:{
    name:"onechuan",
    age:18
  }
}

由于父组件要渲染的虚拟DOM内容发生变化,此时就需要进行自更新,在更新时会使用patchComponent函数进行子组件的更新。

function patch(n1, n2, container, anchor){
  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 === Fragement){
    //...片段
  }else if(typeof type === "object"){
    // vnode.type的值是选项对象,作为组件处理
    if(!n1){
      //挂载组件
      mountComponent(n2, container, anchor);
    }else{
      //更新组件
      patchComponent(n1, n2, anchor);
    }
  }
}

由父组件更新引起的子组件更新叫做子组件的被动更新,在子组件更新时需要检测子组件是否真的需要更新,如果需要更新则更新子组件的props和slots等内容。具体的patchComponent代码如下所示:

function patchComponent(n1, n2, anchor){
  //获取组件实例,新旧组件实例是一样的
  const instance = (n2.component = n1.component);
  const {props} = instance;
  
  if(hasPropsChanged(n1.props, n2.props)){
    const [nextProps] = resovleProps(n1.props, n2.props);
    // 更新props
    for(const k in nextProps){
      props[k] = nextProps[k]
    }
    // 删除不存在的props
    for(const k in props){
      if(!(k in nextProps)) delete props[k];
    }
  }
}

hasPropsChanged函数用于判断新旧props内容是否有改动,有改动则进行组件的更新。

function hasPropsChanged(prevProps, nextProps){
  const nextKeys = Object.keys(nextProps);
  cosnt prevKeys = Object.keys(prevProps);
  // 新旧数量是否改变
  if(nextKeys.length !== prevKeys.length){
    return true
  }
  // 是否有不相等的props
  for(let i = 0; i < nextKeys.length; i++){
    const key = nextKeys[i];
    if(nextProps[key] !== prevProps[key]) return true
  }
  return false
}

props和attrs本质上都是根据组件的props选项定义和给组件传递的props数据进行处理的。但是由于props数据与组件本身的状态数据都需要暴露到渲染函数中,渲染函数中可以通过this进行访问,对此需要封装一个渲染上下文对象。

function mountComponent(vnode, container, anchor){
  // 省略代码...
  
  // 组件实例
  const instance = {
    state,
    isMounted: false,
    subTree: null,
    props: shallowReactive(props);
  }
  
  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r){
      // 获取组件自身状态和props数据
      const {state, props} = t;
      // 先尝试读取自身数据
      if(state && k in state){
        return state[k]
      }else if(k in props){
        return props[k]
      }else{
        console.log("不存在");
      }
    },
    set(t, k, v, r){
      const {state, props} = t;
      if(state && k in state){
        state[k] = v
      }else if(k in props){
        props[k] = v
      }else{
        console.log("不存在");
      }
    }
  })
 
   created && created.call(renderCOntext
  //代码省略...
}

在上面代码中,通过为组件实例创建一个代理对象,即渲染上下文对象,对数据状态拦截实现读取和设置操作。在渲染函数或生命周期钩子中可以通过this读取数据时,会优先从组件自身状态中获取,倘若组件自身没有对应数据,则从props数据中进行读取。渲染上下文对象其实就是作为渲染函数和生命周期钩子的this值。

当然,渲染上下文对象处理的不仅仅是组件自身的数据和props数据,还包括:methods、computed等选项的数据和方法。

3 . setup函数的作用与实现

组件的setup函数是vue.js3新增的组件选项,主要用于配合组合式api进行建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup函数只会在被挂载时执行一次,返回值可以是组件的渲染函数也可以是暴露出的响应式数据到渲染函数中。

const Comp = {
  //setup函数可以返回一个函数作为组件的渲染函数
  setup(){
    return ()=>{
      return {
        type:"div",
        children:"pingping"
      }
    }
  }
}

但是,这种方式通常用于不是以模板来渲染内容,如果组件是模板来渲染内容,那么setup函数就不可以返回函数,否则会与模板编译的渲染函数冲突。

返回对象的情况,是将对象的数据暴露给模板使用,setup函数暴露的数据可以通过this进行访问。

const Comp = {
  props:{
    name:String
  },
  //setup函数可以返回一个函数作为组件的渲染函数
  setup(props, setupContext){
    console.log(`my name is ${props.name}`);
    const age = ref(18);
    // setupContex包含与组件接口相关的重要数据
    const {slots, emit, attrs} = setupContext;
    return {
      age
    }
  },
  render(){
    return {
      type:"div",
      children:`my age is ${this.age}`
    }
  }
}

那么setup函数是如何设计与实现的呢?

function mountComponent(vnode, container, anchor){ 
  const componentOptions = vnode.type;
  //从选项组件中取出setup函数
  let {render, data, setup, /*...*/} = componentOptions;
  
  beforeCreate && beforeCreate();
  
  const state = data ? reactive(data()) : null;
  const [props, attrs] = resolveProps(propsOption, vnode.props);
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null
  }
  
  const setupContext = { attrs };
  const setupResult = setup(shallowReadonly(instance.props), setupContext);
  // 存储setup返回的数据
  let setupState = null;
  // 判断setup返回的是函数还是数据对象
  if(typeof setupResult === "function"){
    // 报告冲突
    if(render) console.error("setup函数返回渲染函数,render选项可以忽略");
    render = setupResult;
  }else{
    setupState = setupContext;
  }
  
  vnode.component = instance;
  
  const renderContext = new Proxy(instance,{
    get(t, k, r){
      const {state, props} = t;
      if(state && k in state){
        return state[k];
      }else if(k in props){
        return props[k]
      }else if(setupState && k in setupState){
        return setupState[k]
      }else{
        console.log("不存在");
      }
    },
    set(t, k, v, r){
      const {state, props} = t;
      if(state && k in state){
        state[k] = v;
      }else if(k in props){
        props[k] = v;
      }else if(setupState && k in setupState){
        setupState[k] = v;
      }else{
        console.log("不存在");
      }
    }
  })
  //省略部分代码...
}

4 . 组件事件与emit的实现

emit是用于父组件传递方法到子组件,是一个发射事件的自定义事件。

<MyComponent @change="handle"/>

上面组件的虚拟DOM:

const CompVNode = {
  type:MyComponent,
  props:{
    onChange:handler
  }
}

const MyComponent = {
  name:"MyComponent",
  setup(props, {emit}){
    emit("change", 1, 1)
    return ()=>{
      return //...
    }
  }
}

emit发射事件的本质是:通过事件名称去props对象数据中寻找对应的事件处理函数并执行。

function mountComponent(vnode, container, anchor){ 
  // 省略部分代码
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null
  }
  
  function emit(event, ...payload){
    // 如change -> onChange
    const eventName = `on${event[0].toUpperCase() + event.slice(1)}`;
    // 根据处理后的事件名称去props中寻找对应的事件处理函数
    const handler = instance.props[eventName];
    if(handler){
      handler(...payload);
    }else{
      console.error("事件不存在")
    }
  }
  
  const setupContext = { attrs, emit };
  
  //省略部分代码...
}

在上面代码中,其实就是在setupContext对象中添加emit方法,在emit函数被调用时,根据约定对事件名称便于在props数据对象中找到对应的事件处理函数。最终调用函数和传递参数,在解析props数据时需要对事件类型的props进行处理。

function resolveProps(options, propsData){
  const props = {};
  const attrs = {};
  for(const key in propsData){
    if(key in options || key.startWith("on")){
      props[key] = propsData[key]
    }else{
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

5 . 插槽的工作原理与实现

插槽就是在组件中预留槽位,具体渲染内容由用户插入:

<template>
  <header><slot name="header"/></header>
  <div>
    <slot name="body"/>
  </div>
  <footer><slot name="footer"/></footer>
</template>

父组件中使用组件,通过插槽传入自定义内容:

<MyComponent>
  <template #header>
    <h1>我是标题</h1>
  </tmeplate>
  <template #body>
    <h1>我是内容</h1>
  </tmeplate>
  <template #footer>
    <h1>我是底部内容</h1>
  </tmeplate>
</MyComponent>

父组件的模板编译成渲染函数:

function render(){
  return {
    type:MyComponent,
    children:{
      hader(){
        return {
          type:"h1",
          chidlren:"我是标题"
        }
      },
      body(){
        return {
          type:"section",
          chidlren:"我是内容"
        }
      },
      footer(){
        return {
          type:"p",
          chidlren:"我是底部"
        }
      }
    }
  }
}

组件MyComponent模板编译成渲染函数:

function render(){
  return [
    {
      type:"header",
      chidlren:[this.$slots.header()]
    },{
      type:"body",
      chidlren:[this.$slots.body()]
    },{
      type:"footer",
      chidlren:[this.$slots.footer()]
    }
  ]
}

在上面代码中,看到渲染插槽内容的过程,就是调用插槽函数比不过渲染返回内容的过程。

function mountComponent(vnode, container, anchor){
  // 省略代码
  
  const slots = vnode.children || {}
  const setupContext = {attrs, emit, slots};
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null,
    slots
  }
  const renderContext = new Proxy(instance,{
    get(t, k, r){
      const {state, props, slots} = t;
      if(k === "$slots") return slots;
      //省略部分代码
    },
    set(t,k,v,r){
      //省略部分代码
    }
    //省略部分代码
}

其实,slots的实现就是将编译好的vnode.children作为slots对象,然后将slots对象添加到setupContext对象中。

6 . 注册生命周期

在Vue.js3中,部分组合式api是用来注册生命周期钩子函数的,在setup函数中调用onMounted函数即可注册onMounted生命周期钩子函数,并且多次调用就注册多个钩子函数。

import {onMounted} from "vue";

const MyComponent = {
  setup(){
    onMounted(()=>{
      //...
    });
    onMounted(()=>{
      //...
    });
  }
}

在组件初始化并执行组件的setup函数前,需要将currenrInstance变量设置为当前组件实例进行存储,再执行组件的setup函数,这样就可以通过currenrInstance获取当前正在被初始化的组件实例,从而将那些通过onMounted函数注册的钩子函数与组件实例关联。

let currentInstance = null;
function setCurrentInstance(instance){
  currentInstance = instance;
}

function mountComponent(vnode, container, anchor){
  //省略部分代码
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null,
    slots,
    mounted:[]
  }
  //省略部分代码
  
  // setup
  const setupContext = {attrs, emit, slots};
  
  setCurrentInstance(instance);
  //执行setup
  const setupResult = setup(shallowReadonly(instance.props), setupContext);
  //重置组件实例
  setCurrentInstance(null);
  // 省略部分代码
  
  effect(()=>{
    const subTree = render.call(state, state);
    
   if(!instance.isMounted){
     //省略部分代码
     //遍历数组逐个执行
     instance.mounted && instance.mounted.forEach(hook=>hook.call(renderContext))
   }else{
     //省略部分代码
   }
   // 更新子树
   instance.subTree = subTree
  },{
    scheduler: queueJob
  })
}

function onMounted(fn){
  if(currentInstance){
    currentInstance.mounted.push(fn);
  }else{
    console.error("onMounted函数只能在setup函数中使用")
  }
}

除了onMounted钩子函数外,其他钩子函数原理同上。

7.写在最后

在本文中介绍了:props与组件的被动更新、setup函数的作用与实现、组件事件与emit的实现、插槽的工作原理与实现以及注册生命周期等。

更多:https://mp.weixin.qq.com/mp/appmsgalbum?action=getalbum&album_id=2338360203559174145
来源: 前端一码平川

链接: https://www.fly63.com/article/detial/11388


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK