1. 程式人生 > 實用技巧 >建立一個簡單的迷你Vue3-0

建立一個簡單的迷你Vue3-0

最近看尤大一些關於Vue3的視訊資料和相關文章。決定自己也來建立一個簡單的迷你Vue3,通過這個過程來加強對Vue的核心實質的理解。

那麼如何建立一個簡單的迷你Vue3呢,我們先來看看一個mini Vue3需要哪些東西。

1、模板編譯及渲染系統(將模板語法等編譯為最終執行的Javascript程式碼並渲染到對應宿主,通過VDom實現虛擬VNode)

2、Reactivity響應式資料系統,利用Proxy實現(實現資料雙向繫結以及根據依賴自動觸發更新操作)

3、生命週期等各類API

因此我們首先就需要來實現一個模板編譯及渲染系統,考慮到編譯系統實現過於複雜,因此我們先簡單實現渲染系統即可。

渲染系統

思路

想象一下,如果我們要實現一個渲染系統,我們肯定需要有一個抽象的DOM系統,這樣我們才能夠實現平臺無關,否則我們的程式碼都需要依賴宿主平臺了。也即是所謂的VDom,

有了這個VDom之後,我們還需要一個渲染函式,用於將我們字串式的html內容通過JS程式碼來描述。有了這兩個能力之後,我們就可以將所需要渲染的html內容描述為js程式碼,並

通過渲染系統轉換為VNode,然後通過宿主系統提供的能力渲染到頁面上(我們這裡的宿主當然是瀏覽器了)

實現

1、新建dom.html,包含Vue3渲染相關的三個函式,h、mount,patch

<html>
    <head>
<title>dom</title> </head> <body> <div id="app"></div> <script> function h(tag, props, children) { } function mount(vnode, container) { } function patch(oldNode, newNode) { } const vdom
= h('div', { class: 'red' }, [ h('span', null, 'hello') ]); mount(vdom, document.getElementById('app')); </script> </body> </html>

熟悉Vue的同學應該知道,這裡的函式命名與Vue的完全一致。

“h”即渲染函式,用於將我們通過函式描述的模板字串轉換成JS物件形式並傳遞給編譯系統用於渲染為VNode。

“mount”則是將虛擬VNode實際渲染到我們需要渲染到的DOM節點container上。

“patch”則是與響應式系統有關,當模板中所使用到的值發生改變時,會觸發重新渲染,生成新的VNode,然後與舊的VNode進行比對,找出變化的部分,並呼叫DOM介面更新DOM,這部分邏輯我們最後

處理。

2、處理“h”函式,我們提到了,h函式是用於將我們所寫的函式描述的模板轉換為JS物件形式並傳遞給編譯系統渲染為VNode

<html>
    <head>
        <title>dom</title>
    </head>
    <body>
      <div id="app"></div>
      <script>
        function h(tag, props, children) {
          // 這裡我們簡單處理,就不對各種異常情況做處理了,直接返回json物件
          return {
            tag, props, children
          }
        }

        function mount(vnode, container) {
        }

        function patch(oldNode, newNode) {

        }

        const vdom = h('div', { class: 'red' }, [
          h('span', null, 'hello')
        ]);
        
        mount(vdom, document.getElementById('app'));
      </script>
    </body>
</html>

3、經過第二步,我們能通過h函式得到vdom,接下來我們就需要將vdom給裝載到頁面上,需要實現mount方法

<html>
<head> <title>dom</title>
<style type="style/css">
.red { color: red }
</style> </head> <body> <div id="app"></div> <script> function h(tag, props, children) { } function mount(vnode, container) { // 建立對應的dom物件 const el = document.createElement(el.tag); // 新增屬性 if (vnode.props) { for (const key in vnode.props) { // 簡單處理,假定全部都是attribute el.setAttribute(key, vnode.props[key]); } } // 新增子元素 if (vnode.children) { // 對children也做簡單處理,只分為純文字和陣列節點 if (typeof vnode.children === 'string') { el.textContent = vnode.children; } else { vnode.children.forEach(child => { // 這裡用遞迴即可 mount(child, el); }); } } container.appendChild(el); } function patch(oldNode, newNode) { } // 使用h方法建立vdom const vdom = h('div', { class: 'red' }, [ h('span', null, 'hello') ]); // 將vdom掛載到指定container上 mount(vdom, document.getElementById('app')); </script> </body> </html>

此時我們已經可以看到效果了

4、現在我們已經實現了一個簡單的編譯和渲染系統了,但是還有一點關鍵的沒有實現,那就是patch方法,因為當我們的響應式資料更新時,需要重新生成vdom,然後與原來的vdom對比,如果發現不一致,則需要更新,我們現在來填充patch方法,實際Vue其中的patch方法有著

許許多多的分支處理邏輯,因此我們這裡簡單處理下,理解主要概念即可。首先處理兩個節點完全不同的情況

<html>
    <head>
        <title>dom</title>
        <style type="style/css">
          .red { color: red }
          .green { color: green }
        </style>
    </head>
    <body>
      <div id="app"></div>
      <script>
        function h(tag, props, children) {
        }

        function mount(vnode, container) {
          // 建立對應的dom物件
          const el = document.createElement(el.tag);
          // 新增屬性
          if (vnode.props) {
            for (const key in vnode.props) {
               // 簡單處理,假定全部都是attribute
               el.setAttribute(key, vnode.props[key]);
            }
          }
          // 新增子元素
          if (vnode.children) {
            // 對children也做簡單處理,只分為純文字和陣列節點
            if (typeof vnode.children === 'string') {
              el.textContent = vnode.children;
            } else {
              vnode.children.forEach(child => {
                // 這裡用遞迴即可
                mount(child, el);
              });
            }
          }

          container.appendChild(el);
        }

        function patch(oldNode, newNode) {
          if (oldNode.tag === newNode.tag) {
            // 稍後處理
          } else {
            // 對於標籤都不一樣的,直接替換即可
            const parent = oldNode.el.parentNode;
            mount(newNode, parent);
            parent.removeChild(oldNode.el);
            oldNode.el = newNode.el;
          }
        }
        // 使用h方法建立vdom
        const vdom = h('div', { class: 'red' }, [
          h('span', null, 'hello')
        ]);
        // 將vdom掛載到指定container上
        mount(vdom, document.getElementById('app'));

        // 節點修改,patch
        const vdom2 = h('span', { class: 'green' }, 'I have changed');
        patch(vdom, vdom2);
      </script>
    </body>
</html>

執行這段程式,可以看到新的結果了

接下來就是最為複雜的節點對比了,這裡我們也儘量簡單處理,理解理念即可

<html>
  <head>
    <title>mini-vue</title>
    <style>
      .red { color: red; }
      .green { color: green; }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script>
      // 渲染函式,將函式描述的模版轉換為物件表述的json物件
      function h(tag, props, children) {
        return {
          tag,
          props,
          children,
        };
      }

      // 將指定的vnode裝在到container上
      function mount(vnode, container) {
        const el = document.createElement(vnode.tag);
        // 儲存vnode的dom引用
        vnode.el = el;

        // props
        if (vnode.props) {
          for (const key in vnode.props) {
            const value = vnode.props[key];
            el.setAttribute(key, value);
          }
        }

        // children
        if (vnode.children) {
          // 區分字串還是array,簡單處理
          if (typeof vnode.children === 'string') {
            el.textContent = vnode.children;
          } else {
            vnode.children.forEach(child => {
              mount(child, el);
            });
          }
        }

        container.appendChild(el);
      }

      // 比對已有vnode和新node,進行更新
      function patch(oldNode, newNode) {
        if (oldNode.tag === newNode.tag) {
          const el = oldNode.el;
      // 新節點的el也要引用,後續更新dom時會需要
      newNode.el = el;
// 如果tag相同,則需要進行下一步的各種判斷,props的判斷,更新,children的判斷,更新 // 首先處理props const oldProps = oldNode.props || {}; const newProps = newNode.props || {}; // 首先判斷新props,如果舊的有,則更新,無,則新增 for (const key in newProps) { const oldValue = oldProps[key]; const newValue = newProps[key]; if (newValue !== oldValue) { // 如果新的與舊的不同,則更新 el.setAttribute(key, newValue); } } // 然後判斷舊props裡面,如果新的沒有,則刪除 for (const key in oldProps) { if (!(key in newProps)) { el.removeAttribute(key); } } // 然後將props設定為新 oldNode.props = newProps; // 接著處理children,分為 } else { // 如果tag都不同,則直接替換即可 const parent = oldNode.el.parentNode; mount(newNode, parent); parent.removeChild(oldNode.el); oldNode.el = newNode.el; } } const vdom = h('div', { class: 'red' }, [ h('span', null, 'hello'), ]); mount(vdom, document.getElementById('app'));

      const vdom2 = h('div', { class: 'green' }, [
        h('span', null, 'hello'),
      ]);

setTimeout(()
=> { patch(vdom, vdom2); }, 5000); </script> </body> </html>

props的修改已經加上,我們看下5s之後的效果:

類名已經修改為green。剩下就是最複雜的children對比了,這裡我們也稍微簡單點,假定children要麼是string,要麼是array,不會有其他情況

最新程式碼如下:

<html>
  <head>
    <title>mini-vue</title>
    <style>
      .red { color: red; }
      .green { color: green; }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script>
      // 渲染函式,將函式描述的模版轉換為物件表述的json物件
      function h(tag, props, children) {
        return {
          tag,
          props,
          children,
        };
      }

      // 將指定的vnode裝在到container上
      function mount(vnode, container) {
        const el = document.createElement(vnode.tag);
        // 儲存vnode的dom引用
        vnode.el = el;

        // props
        if (vnode.props) {
          for (const key in vnode.props) {
            const value = vnode.props[key];
            el.setAttribute(key, value);
          }
        }

        // children
        if (vnode.children) {
          // 區分字串還是array,簡單處理
          if (typeof vnode.children === 'string') {
            el.textContent = vnode.children;
          } else {
            vnode.children.forEach(child => {
              mount(child, el);
            });
          }
        }

        container.appendChild(el);
      }

      // 比對已有vnode和新node,進行更新
      function patch(oldNode, newNode) {
        if (oldNode.tag === newNode.tag) {
          const el = oldNode.el;
          // 如果tag相同,則需要進行下一步的各種判斷,props的判斷,更新,children的判斷,更新
          // 首先處理props
          const oldProps = oldNode.props || {};
          const newProps = newNode.props || {};

          // 首先判斷新props,如果舊的有,則更新,無,則新增
          for (const key in newProps) {
            const oldValue = oldProps[key];
            const newValue = newProps[key];

            if (newValue !== oldValue) {
              // 如果新的與舊的不同,則更新
              el.setAttribute(key, newValue);
            }
          }
          // 然後判斷舊props裡面,如果新的沒有,則刪除
          for (const key in oldProps) {
            if (!(key in newProps)) {
              el.removeAttribute(key);
            }
          }

          // 然後將props設定為新
          oldNode.props = newProps;

          // 接著處理children, 這裡我們簡單處理,認為children要麼是string,要麼是array
          const oldChildren = oldNode.children;
          const newChildren = newNode.children;

          if (typeof oldChildren === 'string') {
            // 對於舊子節點為string的情況
            if (typeof newChildren === 'string') {
              // 如果新子節點也是string,則直接替換即可
              el.textContent = newChildren;
              oldNode.children = newChildren;
            } else {
              // 新節點是array
              // 清空原有子節點內容
              el.innerHTML = '';
              newChildren.forEach(child => {
                mount(child, el);
              });
              oldNode.children = newChildren;
            }
          } else {
            // 舊節點是array
            if (typeof newChildren === 'string') {
              el.innerHTML = newChildren;
              oldNode.children = newChildren;
            } else {
              // 兩個都是array,就簡單處理,只是比對同順序

              // 先取兩個陣列同樣長度對比,直接patch
              const sameLength = Math.min(oldChildren.length, newChildren.length);
              for (let i = 0; i < sameLength; i++) {
                // 替換新元素
                patch(oldChildren[i], newChildren[i]);
              }
              // 然後如果舊陣列還有,則移除多出部分
              if (oldChildren.length > sameLength) {
                oldChildren.slice(sameLength).forEach(child => {
                  el.removeChild(child.el);
                });
              }

              // 如果新陣列還有,則新增多出部分
              if (newChildren.length > sameLength) {
                newChildren.slice(sameLength).forEach(child => {
                  mount(child, el);
                });
              }
            }
          }
          
        } else {
          // 如果tag都不同,則直接替換即可
          const parent = oldNode.el.parentNode;
          mount(newNode, parent);
          parent.removeChild(oldNode.el);
          oldNode.el = newNode.el;
        }
      }

      const vdom = h('div', { class: 'red' }, [
        h('span', null, 'hello'),
      ]);
      mount(vdom, document.getElementById('app'));
      const vdom2 = h('div', { class: 'green' }, [
        h('span', null, 'hello'),
        h('span', null, 'world'),
      ]);

      setTimeout(() => {
        patch(vdom, vdom2);
      }, 5000);
    </script>
  </body>
</html>

至此一個Vue3的基礎班的編譯和渲染系統已實現,當然實際Vue的實現需要考慮到各種邊界情況,但是這個並不影響核心邏輯和理念。

但是這裡我們並沒有去真正實現編譯系統,而我們在使用Vue的過程中所寫的單模板檔案,<template>中的內容其實最終也是會被編譯為h渲染函式的形式。這一步是Vue的編譯器幫我們實現了,如果想

瞭解具體的實現,可以直接去看Vue3的原始碼。這部分邏輯還有非常多的hint部分,用於效能優化。

後面第二章要實現的則是資料雙向繫結和響應式的部分了。