Virtual DOM到底有什么迷人之處?如何搭建一款迷你版Virtual DOM庫?
為什么使用Virtual DOM
- 手動(dòng)操作DOM比較麻煩。還需要考慮瀏覽器兼容性問題,雖然有JQuery等庫簡(jiǎn)化DOM操作,但是隨著項(xiàng)目的復(fù)雜DOM操作復(fù)雜提升。
- 為了簡(jiǎn)化DOM的復(fù)雜操作于是出現(xiàn)了各種MVVM框架,MVVM框架解決了視圖和狀態(tài)的同步問題
- 為了簡(jiǎn)化視圖的操作我們可以使用模板引擎,但是模板引擎沒有解決跟蹤狀態(tài)變化的問題,于是Virtual DOM出現(xiàn)了
- Virtual DOM的好處是當(dāng)狀態(tài)改變時(shí)不需要立即更新DOM,只需要?jiǎng)?chuàng)建一個(gè)虛擬樹來描述DOM,Virtual DOM內(nèi)部將弄清楚如何有效的更新DOM(利用Diff算法實(shí)現(xiàn))。
Virtual DOM的特性
- Virtual DOM可以維護(hù)程序的狀態(tài),跟蹤上一次的狀態(tài)。
- 通過比較前后兩次的狀態(tài)差異更新真實(shí)DOM。
實(shí)現(xiàn)一個(gè)基礎(chǔ)的Virtual DOM庫
我們可以仿照snabbdom庫https://github.com/snabbdom/snabbdom.git自己動(dòng)手實(shí)現(xiàn)一款迷你版Virtual DOM庫。
首先,我們創(chuàng)建一個(gè)index.html文件,寫一下我們需要展示的內(nèi)容,內(nèi)容如下:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>vdom</title>
- <style>
- .main {
- color: #00008b;
- }
- .main1{
- font-weight: bold;
- }
- </style>
- </head>
- <body>
- <div id="app"></div>
- <script src="./vdom.js"></script>
- <script>
- function render() {
- return h('div', {
- style: useObjStr({
- 'color': '#ccc',
- 'font-size': '20px'
- })
- }, [
- h('div', {}, [h('span', {
- onClick: () => {
- alert('1');
- }
- }, '文本'), h('a', {
- href: 'https://www.baidu.com',
- class: 'main main1'
- }, '點(diǎn)擊')
- ]),
- ])
- }
- // 頁面改變
- function render1() {
- return h('div', {
- style: useStyleStr({
- 'color': '#ccc',
- 'font-size': '20px'
- })
- }, [
- h('div', {}, [h('span', {
- onClick: () => {
- alert('1');
- }
- }, '文本改變了')
- ]),
- ])
- }
- // 首次加載
- mountNode(render, '#app');
- // 狀態(tài)改變
- setTimeout(()=>{
- mountNode(render1, '#app');
- },3000)
- </script>
- </body>
- </html>
我們?cè)赽ody標(biāo)簽內(nèi)創(chuàng)建了一個(gè)id是app的DOM元素,用于被掛載節(jié)點(diǎn)。接著我們引入了一個(gè)vdom.js文件,這個(gè)文件就是我們將要實(shí)現(xiàn)的迷你版Virtual DOM庫。最后,我們?cè)趕cript標(biāo)簽內(nèi)定義了一個(gè)render方法,返回為一個(gè)h方法。調(diào)用mountNode方法掛載到id是app的DOM元素上。h方法中數(shù)據(jù)結(jié)構(gòu)我們是借鑒snabbdom庫,第一個(gè)參數(shù)是標(biāo)簽名,第二個(gè)參數(shù)是屬性,最后一個(gè)參數(shù)是子節(jié)點(diǎn)。還有,你可能會(huì)注意到在h方法中我們使用了useStyleStr方法,這個(gè)方法主要作用是將style樣式轉(zhuǎn)化成頁面能識(shí)別的結(jié)構(gòu),實(shí)現(xiàn)代碼我會(huì)在最后給出。
思路理清楚了,展示頁面的代碼也寫完了。下面我們將重點(diǎn)看下vdom.js,如何一步一步地實(shí)現(xiàn)它。
第一步
我們看到index.html文件中首先需要調(diào)用mountNode方法,所以,我們先在vdom.js文件中定義一個(gè)mountNode方法。
- // Mount node
- function mountNode(render, selector) {
- }
接著,我們會(huì)看到mountNode方法第一個(gè)參數(shù)是render方法,render方法返回了h方法,并且看到第一個(gè)參數(shù)是標(biāo)簽,第二個(gè)參數(shù)是屬性,第三個(gè)參數(shù)是子節(jié)點(diǎn)。
那么,我們接著在vdom.js文件中再定義一個(gè)h方法。
- function h(tag, props, children) {
- return { tag, props, children };
- }
還沒有結(jié)束,我們需要根據(jù)傳入的三個(gè)參數(shù)tag、props、children來掛載到頁面上。
我們需要這樣操作。我們?cè)趍ountNode方法內(nèi)封裝一個(gè)mount方法,將傳給mountNode方法的參數(shù)經(jīng)過處理傳給mount方法。
- // Mount node
- function mountNode(render, selector) {
- mount(render(), document.querySelector(selector))
- }
接著,我們定義一個(gè)mount方法。
- function mount(vnode, container) {
- const el = document.createElement(vnode.tag);
- vnode.el = el;
- // props
- if (vnode.props) {
- for (const key in vnode.props) {
- if (key.startsWith('on')) {
- el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{
- passive:true
- })
- } else {
- el.setAttribute(key, vnode.props[key]);
- }
- }
- }
- if (vnode.children) {
- if (typeof vnode.children === "string") {
- el.textContent = vnode.children;
- } else {
- vnode.children.forEach(child => {
- mount(child, el);
- });
- }
- }
- container.appendChild(el);
- }
第一個(gè)參數(shù)是調(diào)用傳進(jìn)來的render方法,它返回的是h方法,而h方返回一個(gè)同名參數(shù)的對(duì)象{ tag, props, children },那么我們就可以通過vnode.tag、vnode.props、vnode.children取到它們。
我們看到先是判斷屬性,如果屬性字段開頭含有,on標(biāo)識(shí)就是代表事件,那么就從屬性字段第三位截取,利用addEventListenerAPI創(chuàng)建一個(gè)監(jiān)聽事件。否則,直接利用setAttributeAPI設(shè)置屬性。
接著,再判斷子節(jié)點(diǎn),如果是字符串,我們直接將字符串賦給文本節(jié)點(diǎn)。否則就是節(jié)點(diǎn),我們就遞歸調(diào)用mount方法。
最后,我們將使用appendChildAPI把節(jié)點(diǎn)內(nèi)容掛載到真實(shí)DOM中。
頁面正常顯示。
第二步
我們知道Virtual DOM有以下兩個(gè)特性:
- Virtual DOM可以維護(hù)程序的狀態(tài),跟蹤上一次的狀態(tài)。
- 通過比較前后兩次的狀態(tài)差異更新真實(shí)DOM。
這就利用到了我們之前提到的diff算法。
我們首先定義一個(gè)patch方法。因?yàn)橐獙?duì)比前后狀態(tài)的差異,所以第一個(gè)參數(shù)是舊節(jié)點(diǎn),第二個(gè)參數(shù)是新節(jié)點(diǎn)。
- function patch(n1, n2) {
- }
下面,我們還需要做一件事,那就是完善mountNode方法,為什么這樣操作呢?是因?yàn)楫?dāng)狀態(tài)改變時(shí),只更新狀態(tài)改變的DOM,也就是我們所說的差異更新。這時(shí)就需要配合patch方法做diff算法。
相比之前,我們加上了對(duì)是否掛載節(jié)點(diǎn)進(jìn)行了判斷。如果沒有掛載的話,就直接調(diào)用mount方法掛載節(jié)點(diǎn)。否則,調(diào)用patch方法進(jìn)行差異更新。
- let isMounted = false;
- let oldTree;
- // Mount node
- function mountNode(render, selector) {
- if (!isMounted) {
- mount(oldTree = render(), document.querySelector(selector));
- isMounted = true;
- } else {
- const newTree = render();
- patch(oldTree, newTree);
- oldTree = newTree;
- }
- }
那么下面我們將主動(dòng)看下patch方法,這也是在這個(gè)庫中最復(fù)雜的方法。
- function patch(n1, n2) {
- // Implement this
- // 1. check if n1 and n2 are of the same type
- if (n1.tag !== n2.tag) {
- // 2. if not, replace
- const parent = n1.el.parentNode;
- const anchor = n1.el.nextSibling;
- parent.removeChild(n1.el);
- mount(n2, parent, anchor);
- return
- }
- const el = n2.el = n1.el;
- // 3. if yes
- // 3.1 diff props
- const oldProps = n1.props || {};
- const newProps = n2.props || {};
- for (const key in newProps) {
- const newValue = newProps[key];
- const oldValue = oldProps[key];
- if (newValue !== oldValue) {
- if (newValue != null) {
- el.setAttribute(key, newValue);
- } else {
- el.removeAttribute(key);
- }
- }
- }
- for (const key in oldProps) {
- if (!(key in newProps)) {
- el.removeAttribute(key);
- }
- }
- // 3.2 diff children
- const oc = n1.children;
- const nc = n2.children;
- if (typeof nc === 'string') {
- if (nc !== oc) {
- el.textContent = nc;
- }
- } else if (Array.isArray(nc)) {
- if (Array.isArray(oc)) {
- // array diff
- const commonLength = Math.min(oc.length, nc.length);
- for (let i = 0; i < commonLength; i++) {
- patch(oc[i], nc[i]);
- }
- if (nc.length > oc.length) {
- nc.slice(oc.length).forEach(c => mount(c, el));
- } else if (oc.length > nc.length) {
- oc.slice(nc.length).forEach(c => {
- el.removeChild(c.el);
- })
- }
- } else {
- el.innerHTML = '';
- nc.forEach(c => mount(c, el));
- }
- }
- }
我們從patch方法入?yún)㈤_始,兩個(gè)參數(shù)分別是在mountNode方法中傳進(jìn)來的舊節(jié)點(diǎn)oldTree和新節(jié)點(diǎn)newTree,首先我們進(jìn)行對(duì)新舊節(jié)點(diǎn)的標(biāo)簽進(jìn)行對(duì)比。
如果新舊節(jié)點(diǎn)的標(biāo)簽不相等,就移除舊節(jié)點(diǎn)。另外,利用nextSiblingAPI取指定節(jié)點(diǎn)之后緊跟的節(jié)點(diǎn)(在相同的樹層級(jí)中)。然后,傳給mount方法第三個(gè)參數(shù)。這時(shí)你可能會(huì)有疑問,mount方法不是有兩個(gè)參數(shù)嗎?對(duì),但是這里我們需要傳進(jìn)去第三個(gè)參數(shù),主要是為了對(duì)同級(jí)節(jié)點(diǎn)進(jìn)行處理。
- if (n1.tag !== n2.tag) {
- // 2. if not, replace
- const parent = n1.el.parentNode;
- const anchor = n1.el.nextSibling;
- parent.removeChild(n1.el);
- mount(n2, parent, anchor);
- return
- }
所以,我們重新修改下mount方法。我們看到我們只是加上了對(duì)anchor參數(shù)是否為空的判斷。
如果anchor參數(shù)不為空,我們使用insertBeforeAPI,在參考節(jié)點(diǎn)之前插入一個(gè)擁有指定父節(jié)點(diǎn)的子節(jié)點(diǎn)。insertBeforeAPI第一個(gè)參數(shù)是用于插入的節(jié)點(diǎn),第二個(gè)參數(shù)將要插在這個(gè)節(jié)點(diǎn)之前,如果這個(gè)參數(shù)為 null 則用于插入的節(jié)點(diǎn)將被插入到子節(jié)點(diǎn)的末尾。
如果anchor參數(shù)為空,直接在父節(jié)點(diǎn)下的子節(jié)點(diǎn)列表末尾添加子節(jié)點(diǎn)。
- function mount(vnode, container, anchor) {
- const el = document.createElement(vnode.tag);
- vnode.el = el;
- // props
- if (vnode.props) {
- for (const key in vnode.props) {
- if (key.startsWith('on')) {
- el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{
- passive:true
- })
- } else {
- el.setAttribute(key, vnode.props[key]);
- }
- }
- }
- if (vnode.children) {
- if (typeof vnode.children === "string") {
- el.textContent = vnode.children;
- } else {
- vnode.children.forEach(child => {
- mount(child, el);
- });
- }
- }
- if (anchor) {
- container.insertBefore(el, anchor);
- } else {
- container.appendChild(el);
- }
- }
下面,我們?cè)倩氐絧atch方法。如果新舊節(jié)點(diǎn)的標(biāo)簽相等,我們首先要遍歷新舊節(jié)點(diǎn)的屬性。我們先遍歷新節(jié)點(diǎn)的屬性,判斷新舊節(jié)點(diǎn)的屬性值是否相同,如果不相同,再進(jìn)行進(jìn)一步處理。判斷新節(jié)點(diǎn)的屬性值是否為null,否則直接移除屬性。然后,遍歷舊節(jié)點(diǎn)的屬性,如果屬性名不在新節(jié)點(diǎn)屬性表中,則直接移除屬性。
分析完了對(duì)新舊節(jié)點(diǎn)屬性的對(duì)比,接下來,我們來分析第三個(gè)參數(shù)子節(jié)點(diǎn)。
首先,我們分別定義兩個(gè)變量oc、nc,分別賦予舊節(jié)點(diǎn)的children屬性和新節(jié)點(diǎn)的children屬性。如果新節(jié)點(diǎn)的children屬性是字符串,并且新舊節(jié)點(diǎn)的內(nèi)容不相同,那么就直接將新節(jié)點(diǎn)的文本內(nèi)容賦予即可。
接下來,我們看到利用Array.isArray()方法判斷新節(jié)點(diǎn)的children屬性是否是數(shù)組,如果是數(shù)組的話,就執(zhí)行下面這些代碼。
- else if (Array.isArray(nc)) {
- if (Array.isArray(oc)) {
- // array diff
- const commonLength = Math.min(oc.length, nc.length);
- for (let i = 0; i < commonLength; i++) {
- patch(oc[i], nc[i]);
- }
- if (nc.length > oc.length) {
- nc.slice(oc.length).forEach(c => mount(c, el));
- } else if (oc.length > nc.length) {
- oc.slice(nc.length).forEach(c => {
- el.removeChild(c.el);
- })
- }
- } else {
- el.innerHTML = '';
- nc.forEach(c => mount(c, el));
- }
- }
我們看到里面又判斷舊節(jié)點(diǎn)的children屬性是否是數(shù)組。
如果是,我們?nèi)⌒屡f子節(jié)點(diǎn)數(shù)組的長度兩者的最小值。然后,我們將其循環(huán)遞歸patch方法。為什么取最小值呢?是因?yàn)槿绻〉氖撬麄児灿械拈L度。然后,每次遍歷遞歸時(shí),判斷nc.length和oc.length的大小,循環(huán)執(zhí)行對(duì)應(yīng)的方法。
如果不是,直接將節(jié)點(diǎn)內(nèi)容清空,重新循環(huán)執(zhí)行mount方法。
這樣,我們搭建的迷你版Virtual DOM庫就這樣完成了。
頁面如下所示。
源碼
index.html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>vdom</title>
- <style>
- .main {
- color: #00008b;
- }
- .main1{
- font-weight: bold;
- }
- </style>
- </head>
- <body>
- <div id="app"></div>
- <script src="./vdom.js"></script>
- <script>
- function render() {
- return h('div', {
- style: useObjStr({
- 'color': '#ccc',
- 'font-size': '20px'
- })
- }, [
- h('div', {}, [h('span', {
- onClick: () => {
- alert('1');
- }
- }, '文本'), h('a', {
- href: 'https://www.baidu.com',
- class: 'main main1'
- }, '點(diǎn)擊')
- ]),
- ])
- }
- // 頁面改變
- function render1() {
- return h('div', {
- style: useStyleStr({
- 'color': '#ccc',
- 'font-size': '20px'
- })
- }, [
- h('div', {}, [h('span', {
- onClick: () => {
- alert('1');
- }
- }, '文本改變了')
- ]),
- ])
- }
- // 首次加載
- mountNode(render, '#app');
- // 狀態(tài)改變
- setTimeout(()=>{
- mountNode(render1, '#app');
- },3000)
- </script>
- </body>
- </html>
vdom.js
- // vdom ---
- function h(tag, props, children) {
- return { tag, props, children };
- }
- function mount(vnode, container, anchor) {
- const el = document.createElement(vnode.tag);
- vnode.el = el;
- // props
- if (vnode.props) {
- for (const key in vnode.props) {
- if (key.startsWith('on')) {
- el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key],{
- passive:true
- })
- } else {
- el.setAttribute(key, vnode.props[key]);
- }
- }
- }
- if (vnode.children) {
- if (typeof vnode.children === "string") {
- el.textContent = vnode.children;
- } else {
- vnode.children.forEach(child => {
- mount(child, el);
- });
- }
- }
- if (anchor) {
- container.insertBefore(el, anchor);
- } else {
- container.appendChild(el);
- }
- }
- // processing strings
- function useStyleStr(obj) {
- const reg = /^{|}/g;
- const reg1 = new RegExp('"',"g");
- const str = JSON.stringify(obj);
- const ustr = str.replace(reg, '').replace(',', ';').replace(reg1,'');
- return ustr;
- }
- function patch(n1, n2) {
- // Implement this
- // 1. check if n1 and n2 are of the same type
- if (n1.tag !== n2.tag) {
- // 2. if not, replace
- const parent = n1.el.parentNode;
- const anchor = n1.el.nextSibling;
- parent.removeChild(n1.el);
- mount(n2, parent, anchor);
- return
- }
- const el = n2.el = n1.el;
- // 3. if yes
- // 3.1 diff props
- const oldProps = n1.props || {};
- const newProps = n2.props || {};
- for (const key in newProps) {
- const newValue = newProps[key];
- const oldValue = oldProps[key];
- if (newValue !== oldValue) {
- if (newValue != null) {
- el.setAttribute(key, newValue);
- } else {
- el.removeAttribute(key);
- }
- }
- }
- for (const key in oldProps) {
- if (!(key in newProps)) {
- el.removeAttribute(key);
- }
- }
- // 3.2 diff children
- const oc = n1.children;
- const nc = n2.children;
- if (typeof nc === 'string') {
- if (nc !== oc) {
- el.textContent = nc;
- }
- } else if (Array.isArray(nc)) {
- if (Array.isArray(oc)) {
- // array diff
- const commonLength = Math.min(oc.length, nc.length);
- for (let i = 0; i < commonLength; i++) {
- patch(oc[i], nc[i]);
- }
- if (nc.length > oc.length) {
- nc.slice(oc.length).forEach(c => mount(c, el));
- } else if (oc.length > nc.length) {
- oc.slice(nc.length).forEach(c => {
- el.removeChild(c.el);
- })
- }
- } else {
- el.innerHTML = '';
- nc.forEach(c => mount(c, el));
- }
- }
- }
- let isMounted = false;
- let oldTree;
- // Mount node
- function mountNode(render, selector) {
- if (!isMounted) {
- mount(oldTree = render(), document.querySelector(selector));
- isMounted = true;
- } else {
- const newTree = render();
- patch(oldTree, newTree);
- oldTree = newTree;
- }
- }