從頭開始構建一個簡單的虛擬dom
1. 背景說明:virtual-dom是什麼
- Virtual DOM一般引用表示實際DOM的普通對象。它不具備任何編程接口。與實際DOM相比,這使得它們變得輕量級
- 舉例說明
const $app = document.getElementById('app');
在頁面上得到<div id =「app」> </ div>的DOM。這個DOM將有一些編程接口供你控制
$app.innerHTML = 'Hello world';
要使普通對象表明$app,咱們能夠這樣編寫:
const vApp = {
tagName: 'div',
attrs: {
id: 'app',
},
};
複製代碼
2. 項目初始化
mkdir /***/vdommm
複製代碼
- 進入vdommm目錄,初始化package.json
npm init -y
複製代碼
npm install parcel-bundler
複製代碼
<html>
<head>
<title>hello world</title>
</head>
<body>
Hello world
<script src="./main.js"></script>
</body>
</html>
複製代碼
const vApp = {
tagName: 'div',
attrs: {
id: 'app',
},
};
console.log(vApp);
複製代碼
{
...
"scripts": {
"dev": "parcel src/index.html"
}
...
}
複製代碼
> vdommm@0.0.1 dev /private/tmp/vdommm
> parcel src/index.html
Server running at http://localhost:1234
Built in 959ms.
複製代碼
- 在瀏覽器地址欄輸入http://localhost:1234 在頁面上能看到 Hello world,在console能看到定義的vApp,說明項目初始化是成功的。
3. 建立元素
- src/vdom/createElement.js
export default (tagName, {attrs = {}, children = []} = {}) => {
const vElem = Object.create(null)
Object.assign(vElem, {
tagName,
attrs,
children
})
return vElem
}
複製代碼
- 爲何使用Object.create(null)建立對象解釋以下
- 由於{a:3}自動從Object繼承。這意味着{a:3}將具備在Object.prototype中定義的方法,如hasOwnProperty,toString等。咱們能夠經過使用Object.create(null)使虛擬DOM有更「純粹」。這將建立一個真正的普通對象,它不會從Object繼承而是從null繼承。
4. 渲染
- src/vdom/render.js
- render函數將虛擬DOM轉換爲真正的DOM。讓咱們定義渲染(vNode),它將接收虛擬節點並返回相應的DOM。
- 渲染虛擬元素
const render = (vNode) => {
// 建立元素
// 例如. <div></div>
const $el = document.createElement(vNode.tagName);
// 爲指定的元素添加 vNode.attrs屬性
// 例如. <div id="app"></div>
for (const [k, v] of Object.entries(vNode.attrs)) {
$el.setAttribute(k, v);
}
// 添加子節點
// 例如. <div id="app"><img></div>
for (const child of vNode.children) {
$el.appendChild(render(child));
}
return $el;
};
export default render;
複製代碼
- 在真正的DOM中,有8種類型的節點。在本文中,咱們只介紹兩種類型:ElementNode和TextNode,目前render()函數只支持ElementNode節點,改寫一下支持擴展渲染TextNode節點,如下是render函數的所有代碼
const renderElem = (vNode) => {
// 建立元素 <div></div>
const $el = document.createElement(vNode.tagName)
// 添加全部的vNode.attrs屬性
// 例如<div id='app'></div>
for (const [k, v] of Object.entries(vNode.attrs)) {
$el.setAttribute(k, v)
}
// 添加全部的孩子節點 vNode.children
// e.g <div id='app'><img></div>
for (const child of vNode.children) {
$el.appendChild(render(child))
}
return $el
}
const render = (vNode) => {
if (typeof vNode === 'string') {
return document.createTextNode(vNode)
}
return renderElem(vNode)
}
export default render
複製代碼
5. 掛載(mount)
<html>
<head>
<title>hello world</title>
</head>
<body>
<div id="app"></div>
<script src="./main.js"></script>
</body>
</html>
複製代碼
export default ($node, $target) => {
$target.replaceWith($node);
return $node
}
複製代碼
import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';
const createVApp = count => createElement('div', {
attrs: {
id: 'app',
dataCount: count, // we use the count here
},
children: [
'The current count is: ',
String(count), // and here
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
},
}),
],
});
let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
mount($app, document.getElementById('app'));
複製代碼
- 運行程序能夠看到虛擬節點轉化爲dom節點,並掛載到頁面上
- 修改一下main.js,用setInterval每秒遞增計數,並在頁面上再次建立,渲染和掛載咱們的程序。
import createElement from './vdom/createElement';
import render from './vdom/render';
import mount from './vdom/mount';
const createVApp = count => createElement('div', {
attrs: {
id: 'app',
dataCount: count, // we use the count here
},
children: [
'The current count is: ',
String(count), // and here
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif',
},
}),
],
});
let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));
setInterval(() => {
count++;
$rootEl = mount(render(createVApp(count)), $rootEl);
}, 1000);
複製代碼
- 咱們如今得到了以聲明方式建立應用程序的能力。應用程序以可預測的方式呈現,但每秒從新渲染整個應用程序有幾個問題:
- 真正的DOM比虛擬DOM重得多。將整個應用程序渲染到真實DOM可能很昂貴。
- 元素將失去他們的狀態。例如,只要應用程序從新渲染到頁面,input就會失去焦點。
- input失去焦點示例
6. diff
- newVTree未定義
- 它們都是TextNode(字符串)
- 若是它們是相同的字符串,則不執行任何操做。
- 若是不是,用render(newVTree)替換$node節點。
- 其中一個樹是TextNode,另外一個是ElementNode
- 在這種狀況下,它們顯然不是一回事,咱們將用render(newVTree)替換node。
- oldVTree.tagName !== newVTree.tagName
- 在這種狀況下咱們假設,oldVTree、newVTree徹底不一樣。
- 不會試圖找到兩棵樹之間的差別,咱們將只用render(newVTree)替換節點。
- 不一樣類型的兩個元素將產生不一樣的樹。
import render from './render';
const diff = (oldVTree, newVTree) => {
if (newVTree === undefined) {
return $node => {
$node.remove();
return undefined;
}
}
if (typeof oldVTree === 'string' ||
typeof newVTree === 'string') {
if (oldVTree !== newVTree) {
// 這裏包含兩種狀況
// 1.oldVTree、newVTree 都是字符串它們的值不一樣
// 2.oldVTree、newVTree其中一個是文本節點,另外一個是元素節點
// 不管哪一種狀況,調用render(newVTree)
return $node => {
const $newNode = render(newVTree);
$node.replaceWith($newNode);
return $newNode;
};
} else {
return $node => $node;
}
}
if (oldVTree.tagName !== newVTree.tagName) {
// 當tagName名不一樣時,認爲兩個虛擬組件徹底不一樣,
// 不爲去比較發現它們的不一樣,僅渲染新的newVtree
return $node => {
const $newNode = render(newVTree);
$node.replaceWith($newNode);
return $newNode;
};
}
// (A)
};
export default diff;
複製代碼
- oldVTree和newVTree都是虛擬元素。
- 它們具備相同的tagName。
- 他們可能有不一樣的屬性和孩子節點。
- 下面咱們須要實現diffAttrs及diffChildren方法
const diffAttrs = (oldAttrs, newAttrs) => {
const patches = []
// 設置新屬性
for (const [k, v] of Object.entries(newAttrs)) {
patches.push($node => {
$node.setAttribute(k, v);
return $node;
});
}
// 刪除屬性
for (const k in oldAttrs) {
if (!(k in newAttrs)) {
patches.push($node => {
$node.removeAttribute(k);
return $node;
});
}
}
return $node => {
for (const patch of patches) {
patch($node)
}
return $node
}
}
複製代碼
const zip = (xs, ys) => {
const zipped = []
for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
zipped.push([xs[i], ys[i]])
}
return zipped
}
const diffChildren = (oldVChildren, newVChildren) => {
const childPatches = [];
oldVChildren.forEach((oldVChild, i) => {
childPatches.push(diff(oldVChild, newVChildren[i]));
});
// newVChildren 新增的子元素
// 好比 old [1,2,3] new [1, 2, 3, 4, 5] 將[4, 5] 追加
const additionalPatches = [];
for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild));
return $node;
});
}
return $parnet => {
for (const [patch, $child] of zip(childPatches, $parnet.childNodes)) {
patch($child);
}
for (const patch of additionalPatches) {
patch($parnet);
}
return $parnet;
}
}
複製代碼
import render from './render'
const diffAttrs = (oldAttrs, newAttrs) => {
const patches = []
// 設置新屬性
for (const [k, v] of Object.entries(newAttrs)) {
patches.push($node => {
$node.setAttribute(k, v);
return $node;
});
}
// 刪除屬性
for (const k in oldAttrs) {
if (!(k in newAttrs)) {
patches.push($node => {
$node.removeAttribute(k);
return $node;
});
}
}
return $node => {
for (const patch of patches) {
patch($node)
}
return $node
}
}
const zip = (xs, ys) => {
const zipped = []
for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
zipped.push([xs[i], ys[i]])
}
return zipped
}
const diffChildren = (oldVChildren, newVChildren) => {
const childPatches = [];
oldVChildren.forEach((oldVChild, i) => {
childPatches.push(diff(oldVChild, newVChildren[i]));
});
// newVChildren 新增的子元素
// 好比 old [1,2,3] new [1, 2, 3, 4, 5] 將[4, 5] 追加
const additionalPatches = [];
for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild));
return $node;
});
}
return $parnet => {
for (const [patch, $child] of zip(childPatches, $parnet.childNodes)) {
patch($child);
}
for (const patch of additionalPatches) {
patch($parnet);
}
return $parnet;
}
}
const diff = (oldVTree, newVTree) => {
if (newVTree === undefined) {
return $node => {
$node.remove()
return undefined
}
}
if (typeof oldVTree === 'string' || typeof newVTree === 'string') {
if (oldVTree !== newVTree ) {
// 這裏包含兩種狀況
// 1.oldVTree、newVTree 都是字符串它們的值不一樣
// 2.oldVTree、newVTree其中一個是文本節點,另外一個是元素節點
// 不管哪一種狀況,調用render(newVTree)
return $node => {
const $newNode = render(newVTree)
$node.replaceWith($newNode)
return $newNode
}
} else {
// 字符串,且值相同
return $node => $node
}
}
if (oldVTree.tagName !== newVTree.tagName) {
// 當tagName 名不一樣時,認爲兩個虛擬組件徹底不一樣,
// 不爲去比較發現它們的不一樣,僅渲染新的newVtree並掛載
return $node => {
const $newNode = render(newVTree)
$node.replaceWith($newNode)
return $newNode
}
}
const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs)
const patchChildren = diffChildren(oldVTree.children, newVTree.children)
return $node => {
patchAttrs($node);
patchChildren($node);
return $node
}
}
export default diff
複製代碼
import createElement from './vdom/createElement'
import render from './vdom/render'
import mount from './vdom/mount'
import diff from './vdom/diff'
const createVApp = count => createElement('div', {
attrs: {
id: 'app',
dataCout: count
},
children: [
'The current count is: ',
String(count), // 表明文本節點
...Array.from({length: count}, () => createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
}))
]
})
let vApp = createVApp(2);
const $app = render(vApp);
let $rootEl = mount($app, document.getElementById('app'));
setInterval(() => {
const n = Math.floor(Math.random() * 10)
const vNewApp = createVApp(n)
const patch = diff(vApp, vNewApp)
$rootEl = patch($rootEl);
vApp = vNewApp
}, 1000);
console.log($app);
複製代碼
參考連接