Vue3 最 Low 版實(shí)現(xiàn)

引言

我在上篇文章 聊一聊 Vue3 中響應(yīng)式原理 對(duì)Vue3 響應(yīng)式的實(shí)現(xiàn)原理做了介紹,想必大家對(duì) Vue3 中的如何利用 Proxy 實(shí)現(xiàn)數(shù)據(jù)代理,以及如何實(shí)現(xiàn)數(shù)據(jù)的響應(yīng)式有了一定的了解,今天我們?cè)俅芜M(jìn)階,就看看它是如何與 view 視圖層聯(lián)系起來(lái)的,實(shí)現(xiàn)一個(gè)Low版的 Vue3。

實(shí)現(xiàn)思路

首先我們要知道的是,不管是 Vue 還是 React,它們的整體實(shí)現(xiàn)思路都是先將我們寫(xiě)的template模版或者jsx 代碼轉(zhuǎn)換成虛擬節(jié)點(diǎn),然后經(jīng)過(guò)一系列的邏輯處理后,最后通過(guò) render 方法掛在到指定的節(jié)點(diǎn)上,渲染出真實(shí)的 DOM.

所以,我們第一步要實(shí)現(xiàn)的就是 render 方法,將虛擬 DOM 節(jié)點(diǎn)轉(zhuǎn)換成真實(shí)的 DOM 節(jié)點(diǎn)并渲染到頁(yè)面上。

虛擬節(jié)點(diǎn)的渲染

要實(shí)現(xiàn) render 方法,首先得有虛擬 DOM , 這里我們以一個(gè)經(jīng)典的 計(jì)數(shù)器 為例。

// 計(jì)數(shù)器虛擬節(jié)點(diǎn)
const vnode = {
    tag: 'div',
    props: {
        style: {
            textAlign: 'center'
        }
    },
    children: [
        {
            tag: 'h1',
            props: {
                style : {
                  color: 'red'
                }
            },
            children: '計(jì)數(shù)器'
        },
        {
            tag: 'button',
            props: {
                onClick: () => alert('Congratulations!')
            },
            children: 'Click Me!'
        }
    ]
}

這樣一來(lái),可以確定 render 方法有2個(gè)固定參數(shù),一個(gè)是 虛擬節(jié)點(diǎn) vnode,另一個(gè)是要渲染的容器 container,這里先以 #app 為例。

  • render 方法
// 渲染函數(shù)
export function render (vnode, container) {
    // 渲染處理函數(shù)
    patch(null, vnode, container);
}

render只是做了初始化的參數(shù)接收,參考源碼,我們也構(gòu)建一個(gè) patch 方法用來(lái)做渲染。

  • patch 方法
// 渲染
function patch (n1, n2, container) {
    // 如果是普通標(biāo)簽
    if( typeof n2.tag === 'string'){
        // 掛載元素
        mountElement(n2,container);
    }else if( typeof n2.tag === 'object'){
        // 如果是組件
    }
}

patch 方法不光要用于初始化的渲染,還會(huì)用于后續(xù)的更新操作, 因此需要三個(gè)參數(shù),分別是 老節(jié)點(diǎn)n1新節(jié)點(diǎn)n2 , 以及 容器container。 另外要考慮到 標(biāo)簽組件 兩種形式,需要進(jìn)行單獨(dú)的判斷,我們先從簡(jiǎn)單的標(biāo)簽開(kāi)始。

  • mountElment 方法

mountElment 方法就是掛載普通元素,其核心就是遞歸。

pass: 在掛載元素中會(huì)頻繁使用一些 dom 操作,因此需要將其這些常用的工具方法放到一個(gè) runtime-dom.js 文件中。

// 掛載元素
function mountElement (vnode, container) {
    const { tag, props, children } = vnode;
    // 創(chuàng)建元素,將虛擬節(jié)點(diǎn)和真實(shí)節(jié)點(diǎn)建立映射關(guān)系
    let el = (vnode.el = nodeOps.createElement(tag));

    // 處理屬性
    if ( props ) {
        for ( let key in props ) {
            nodeOps.hostPatchProps(el, key, {}, props[key])
        }
    }

    // children 是數(shù)組
    if ( Array.isArray(children) ) {
        mountChildren(children, el)
    } else {
        // 字符串
        nodeOps.hostSetElementText(el, children);
    }
    // 插入節(jié)點(diǎn)
    nodeOps.insert(el, container)
}

為了處理多個(gè) children 的情況,我們?cè)賮?lái)定義一個(gè) mountChildren 方法,用于遞歸掛載。

  • mountChildren 方法
// 遞歸掛載子節(jié)點(diǎn)
function mountChildren (children, container) {
    for ( let i = 0; i < children.length; i++ ) {
        let child = children[i];
        // 遞歸掛載節(jié)點(diǎn)
        patch(null, child, container);
    }
}

至此,經(jīng)過(guò)這一大波一系列操作,我們已經(jīng)可以成功將我們的 vnode 渲染到頁(yè)面上去了. 動(dòng)動(dòng)手指點(diǎn)一點(diǎn),功能也都 Ok ~

組件的掛載

上面我們已經(jīng)實(shí)現(xiàn)了簡(jiǎn)單的標(biāo)簽掛載,接下來(lái)我們來(lái)看看組件的掛載是如何實(shí)現(xiàn)的。

之前提到了,組件的 tag 是一個(gè)對(duì)象 object,首先我們先構(gòu)建一個(gè)自定義組件。

// my component
const MyComponent = {
    setup () {
        return () => {  // render 函數(shù)
            return {
                tag: 'div',
                props: { style: { color: 'blue' } },
                children: [
                    {
                        tag: 'h3',
                        props: null,
                        children: '我是一個(gè)自定義組件'
                    }
                ]
            }
        }
     }
}

要注意的是 Vue3 中的 setup 方法可以返回一個(gè)函數(shù),即渲染函數(shù),以此來(lái)聲明組件,具體可參考文檔。

pass: 如果我們沒(méi)有返回渲染函數(shù),vue 內(nèi)部會(huì)將 template 模版編譯成渲染函數(shù),再將結(jié)果掛載到 setup 的返回值中。

我們將 MyComponent 放到我們的 vnode 中,其結(jié)構(gòu)如下:

{
    tag: MyComponent,
    props: null,  // 組件的屬性
    children: null // 插槽
}

pass: 我們這里暫時(shí)沒(méi)有考慮 propschildren,即對(duì)應(yīng)組件的 props 屬性和組件的 slot 插槽。

  • mountComponent 方法

組件的掛載過(guò)程大致是:先構(gòu)建一個(gè)組件實(shí)例,作為組件的的上下文 context,調(diào)用組件的 setup 方法返回 render 函數(shù),在調(diào)用 render 得到組件的虛擬節(jié)點(diǎn),最后通過(guò) patch 方法渲染到頁(yè)面中。

// 掛載組件
function mountComponent (vnode, container) {
    // 根據(jù)組件創(chuàng)建一個(gè)示例
    const instance = {
        vnode: vnode, // 虛擬節(jié)點(diǎn)
        render: null,   // setup的返回值
        subtree: null, // render返回的結(jié)果
    }
    // 聲明組件
    const Component = vnode.tag;
    // 調(diào)用 setup 返回 render
    instance.render = Component.setup(vnode.props, instance);
    // 調(diào)用 render  返回 subtree
    instance.subtree = instance.render && instance.render();
    // 渲染組件
    patch(null, instance.subtree, container)
}

最后將 mountComponent 方法放到 vnode.tag === "object" 分支中即可,可以順利得到結(jié)果。

數(shù)據(jù)響應(yīng)式

上面我們已經(jīng)實(shí)現(xiàn)了普通標(biāo)簽和組件的渲染操作,事件也是簡(jiǎn)單的 alert ,接著我們需要將其與 data 聯(lián)系起來(lái)。

我們先聲明一個(gè)data,作為頁(yè)面的數(shù)據(jù)來(lái)源,

const data = {
    count: 0
}

再將之前的 vnodechildren 部分做一個(gè)簡(jiǎn)單的修改:

{
    tag: 'h1',
    props: {
        style: {
            color: 'red'
        }
    },
    children: '計(jì)數(shù)器,當(dāng)前值:' + data.count
},
{
    tag: 'button',
    props: {
        onClick: () => data.count++
    },
    children: 'Increment!'
},
{
    tag: 'button',
    props: {
        onClick: () => data.count--
    },
    children: 'Minus!'
}

其渲染結(jié)果如下圖:

現(xiàn)在我們要實(shí)現(xiàn)的需求很簡(jiǎn)單,當(dāng)我們點(diǎn)擊 incrementminus 按鈕的時(shí)候,當(dāng)前的count 值會(huì)對(duì)應(yīng) 加1 或者 減1

然而,實(shí)際上我們點(diǎn)擊的時(shí)候,頁(yè)面并沒(méi)有發(fā)生任何變化,其實(shí) count 的值已經(jīng)更新了,大伙可以打個(gè)斷點(diǎn)看看就知道了。

造成這結(jié)果的原因就是,我們還沒(méi)有將視圖與我們的數(shù)據(jù)聯(lián)系在一起,即缺少一個(gè)橋梁,類(lèi)似 vue2 中的 watcher 一樣。

這時(shí)候需要用到 vue3 中響應(yīng)式中的兩個(gè)方法 —— reactiveeffect 方法,其作用就是數(shù)據(jù)的依賴(lài)收集以及副作用的執(zhí)行,詳情請(qǐng)戳 聊一聊 Vue3 中響應(yīng)式原理,這里就不再贅述,直接用即可。

首先通過(guò) reactive 方法,將 data 通過(guò) proxy 進(jìn)行代理:

const data = reactive({
    count: 0
})

之后通過(guò) effect 方法將其聯(lián)系起來(lái):

effect(() => {
    const vnode = {
        tag: 'div',
        props: {
            style: {
                textAlign: 'center'
            }
        },
        children: [
            {
                tag: 'h1',
                props: {
                    style: {
                        color: 'red'
                    }
                },
                children: '計(jì)數(shù)器,當(dāng)前值:' + data.count
            },
            {
                tag: 'button',
                props: {
                    onClick: () => data.count++
                },
                children: 'Increment!'
            },
            {
                tag: 'button',
                props: {
                    onClick: () => data.count--
                },
                children: 'Minus!'
            },
            {
                tag: MyComponent,
                props: null,  // 組件的屬性
                children: null // 插槽
            }
        ]
    }

    render(vnode, app)
})

通過(guò) effect 包裹之后,reactive 進(jìn)行依賴(lài)收集,就可以達(dá)到將數(shù)據(jù)于視圖聯(lián)系起來(lái)的效果。

我們點(diǎn)擊試試,可以看到結(jié)果如下:

count 的結(jié)果是對(duì)了,但是我們發(fā)現(xiàn)無(wú)論我們點(diǎn)擊 increment 還是 minus 都會(huì)再次創(chuàng)建一個(gè)新的 vnode 插入到頁(yè)面上,這是因?yàn)闀簳r(shí)我們沒(méi)有做 dom-diff 造成的,后面我們?cè)賮?lái)解決這個(gè)問(wèn)題。

組件的局部更新

我們先來(lái)看組件中的一個(gè)問(wèn)題,我們先給 data 新增一個(gè) num 屬性,再將我們的自定義組件作如下修改:

{
    tag: 'div',
    props: { style: { color: 'blue' } },
    children: [
        {
            tag: 'h3',
            props: null,
            children: '我是一個(gè)自定義組件,num:' + data.num
        },
        {
            tag : 'button',
            props : {
                onClick: () => {
                    data.num++;
                }
            },
            children: '更新num'
        }
    ]
}

接著我們?cè)陧?yè)面中點(diǎn)擊 更新num 這個(gè)按鈕,可以看到跟上述更新 count 類(lèi)似結(jié)果,即:

問(wèn)題就出在這里,我們更新的是組件的 num , 原則上跟 count 沒(méi)有關(guān)系,那么應(yīng)當(dāng)不用更新與 count 相關(guān)的 dom , 只做組件自己內(nèi)部的更新。

所以,我們需要對(duì)每個(gè)組件自己內(nèi)部做依賴(lài)收集,來(lái)實(shí)現(xiàn)組件的局部刷新。

只需在我們組件 patch 的時(shí)候加上 effct 即可:

effect(()=>{
    // 調(diào)用 setup 返回 render
    instance.render = Component.setup(vnode.props, instance);
    // 調(diào)用 render  返回 subtree
    instance.subtree = instance.render && instance.render();
    // 渲染組件
    patch(null, instance.subtree, container)
})

這樣一來(lái)就實(shí)現(xiàn)了組件的局部更新。

DOM-DIFF

造成上述數(shù)據(jù)更新,頁(yè)面不停 append 的原因就是沒(méi)有做 dom-diff,接下來(lái)我們一起來(lái)聊一聊,做一個(gè)簡(jiǎn)單的 dom-diff。

pass: 筆者能力有限,暫時(shí)沒(méi)有考慮組件 componentdiff;

先以一個(gè) li 的例子說(shuō)明 diff

const oldVNode = {
    tag: 'ul',
    props: null,
    children: [
        {
            tag: 'li',
            props: { style: { color: 'red' }, key: 'A' },
            children: 'A'
        },
        {
            tag: 'li',
            props: { style: { color: 'orange' }, key: 'B' },
            children: 'B'
        },
    ]
}

render(oldVNode, app)

setTimeout(() => {
    const newVNode = {
        tag: 'ul',
        props: null,
        children: [
            {
                tag: 'li',
                props: { style: { color: 'red' }, key: 'A' },
                children: 'A'
            },
            {
                tag: 'li',
                props: { style: { color: 'orange' }, key: 'B' },
                children: 'B'
            },
            {
                tag: 'li',
                props: { style: { color: 'blue' }, key: 'C' },
                children: 'C'
            },
            {
                tag: 'li',
                props: { style: { color: 'green' }, key: 'D' },
                children: 'D'
            }
        ]
    }
    render(newVNode, app)
}, 1500)

上述 vnode 表示先渲染 oldVNode 得到 AB,E 三個(gè)不同 li , 過(guò)了 1.5s 后,先修改了 B 的顏色屬性,再刪除 E ,最后添加兩條新的 li,CD。

涉及到了 dom 的復(fù)用(A),屬性的修改(B),刪除(E),新增(C,D);

  • patchProps 方法

先看看最簡(jiǎn)單的 props 屬性的對(duì)比操作,其思路就是將新增的屬性添加上去,用新的值替換老的屬性值,并刪除到老的有的屬性而新的沒(méi)有的屬性。

function patchProps (el, oldProps, newProps) {
  if ( oldProps !== newProps ) {
      /* 1.將新的屬性設(shè)置上去 */
      for ( let key in newProps ) {
          // 老的屬性值
          const prev = oldProps[key];
          // 新的屬性值
          const next = newProps[key];
          if ( prev !== next ) {
              // 設(shè)置新的值
              nodeOps.hostPatchProps(el, key, prev, next)
          }
      }
      /* 2.將舊的有而新的沒(méi)有的刪除 */
      for ( let key in oldProps ) {
          if ( !newProps.hasOwnProperty(key) ) {
              // 清空新的沒(méi)有的屬性
              nodeOps.hostPatchProps(el, key, oldProps[key], null)
          }
      }
  }
}

這樣一來(lái)就完成了屬性的對(duì)比,接著就是子元素的對(duì)比。

  • patchChildren 方法

子元素的對(duì)比分為這么幾種情況:

  • 新節(jié)點(diǎn)的子元素是簡(jiǎn)單的字符串,直接做字符串替換即可,將新的文本設(shè)置到對(duì)應(yīng)的節(jié)點(diǎn)上
  • 否則新節(jié)點(diǎn)是數(shù)組,也有兩種情況,一是老節(jié)點(diǎn)是簡(jiǎn)單的字符串,則直接將老節(jié)點(diǎn)刪除掉,將新的節(jié)點(diǎn)掛載上去即可。二是老節(jié)點(diǎn)也是數(shù)組,最復(fù)雜的情況,則新節(jié)點(diǎn)需要與老節(jié)點(diǎn)一一對(duì)比。
// 子元素對(duì)比
function patchChildren (n1, n2, container) {
  const c1 = n1.children;
  const c2 = n2.children;

  if ( typeof c2 == 'string' ) { // new 子元素是字符串,文本替換
      if ( c1 !== c2 ) {
          nodeOps.hostSetElementText(container, c2);
      }
  } else { // new 子元素是數(shù)組
      if ( typeof c1 == "string" ) {    // 先刪除 old 原有的內(nèi)容,然后插入新內(nèi)容
          nodeOps.hostSetElementText(container, '');
          // 掛在新的children
          mountChildren(c2, container);
      } else {
          // new 和 old 的 children 都是數(shù)組

      }
  }
}

上述方法即可完成簡(jiǎn)單的文本替換和新節(jié)點(diǎn)的掛載,對(duì)于新老元素的 children 都是數(shù)組的情況,則需要通過(guò) patchKeyChildren 方法來(lái)實(shí)現(xiàn)。

  • patchKeyChildren 方法((暫時(shí)不考慮沒(méi)有 key 的情況))

該方法先根據(jù)新節(jié)點(diǎn)的 key 生成一個(gè) index 映射表,之后去老節(jié)點(diǎn)中去查找是否有對(duì)應(yīng)的元素,如果有就要復(fù)用,之后刪掉老節(jié)點(diǎn)中多余的部分,添加新節(jié)點(diǎn)中新增的部分,最后通過(guò)確定key 和 屬性值判斷是否進(jìn)行移動(dòng)。

官方源碼中利用了最長(zhǎng)遞增子序列 LIS 算法,用于確定不用移動(dòng)的元素索引,提升性能。

function patchKeyChildren (c1, c2, container) {
    // 1.根據(jù)新節(jié)點(diǎn)生成 key 對(duì)應(yīng) index 的映射表
    let e1 = c1.length - 1; // old 最后一項(xiàng)索引
    let e2 = c2.length - 1; // new 最后一項(xiàng)索引
    //
    const keyToNewIndexMap = new Map();
    for ( let i = 0; i <= e2; i++ ) {
        const currentEle = c2[i]; // 當(dāng)前元素
        keyToNewIndexMap.set(currentEle.props.key, i)
    }
    // 2.查找老節(jié)點(diǎn) 有無(wú)對(duì)應(yīng)的 key ,有就復(fù)用
    const newIndexToOldIndexMap = new Array(e2 + 1);
    // 用于標(biāo)識(shí)哪個(gè)元素被patch過(guò)
    for ( let i = 0; i <= e2; i++ ) newIndexToOldIndexMap[i] = -1;

    for ( let i = 0; i <= e1; i++ ) {
        const oldVNode = c1[i];
        // 新的索引
        let newIndex = keyToNewIndexMap.get(oldVNode.props.key);
        if ( newIndex === undefined ) { // old 有,new 沒(méi)有
            nodeOps.remove(oldVNode.el) // 直接刪除 old 節(jié)點(diǎn)
        } else {// 復(fù)用
            // 比對(duì)屬性
            newIndexToOldIndexMap[newIndex] = i + 1;
            patch(oldVNode, c2[newIndex], container);
        }
    }

    let sequence = getSequence(newIndexToOldIndexMap);  // 獲取最長(zhǎng)序列個(gè)數(shù)
    let j = sequence.length - 1; // 獲取最后的索引

    // 以上方法僅僅對(duì)比和刪除無(wú)用節(jié)點(diǎn),沒(méi)有移動(dòng)操作

    // 從后往前插入
    for ( let i = e2; i >= 0; i-- ) {
        let currentEle = c2[i];
        const anchor = (i + 1 <= e2) ? c2[i + 1].el : null;
        // 新的節(jié)點(diǎn)比老得多
        if ( newIndexToOldIndexMap[i] === -1 ) { // 新元素,需要插入到列表中
            patch(null, currentEle, container, anchor); // 插入到 anchor 前面
        } else {
            // 獲取最長(zhǎng)遞增子序列,來(lái)確定不用移動(dòng)的元素,直接跳過(guò)即可
            if ( i === sequence[j] ) {
                j--;
            } else {
                // 插入元素
                nodeOps.insert(currentEle.el, container, anchor);
            }
        }
    }
}

getSequence 算法源碼請(qǐng)戳。

我們先在原有的 vnodechildren 子元素的 props 中添加對(duì)應(yīng)的 key 元素,再來(lái)試試就發(fā)現(xiàn)ok了~

總結(jié)

至此,我們實(shí)現(xiàn)了一個(gè)非常簡(jiǎn)陋的 vue3 簡(jiǎn)易版,實(shí)現(xiàn)了基本的vnode渲染以及簡(jiǎn)單的dom-diff操作,讓我們對(duì) vue3 的內(nèi)部實(shí)現(xiàn)有了一定的了解。

vue3 真正的內(nèi)部實(shí)現(xiàn),遠(yuǎn)比這復(fù)雜得多,有很多代碼的實(shí)現(xiàn)思路和方法個(gè)人理解起來(lái)比較困,確也都是值得我們學(xué)習(xí)借鑒的。本篇文章也是我對(duì)學(xué)習(xí) vue3 過(guò)程中的一點(diǎn)知識(shí)積累和個(gè)人記錄,希望能給大家起一個(gè)拋磚引玉的作用,大家加油~

最后附上 github地址 , 望大家批評(píng)斧正。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容