建立一個簡單的迷你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部分,用於效能優化。
後面第二章要實現的則是資料雙向繫結和響應式的部分了。