Vue.js設計與實現之元件的實現原理

語言: CN / TW / HK

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的實現、插槽的工作原理與實現以及註冊生命週期等。