Virtual DOM到底有什么迷人之處?如何搭建一款迷你版Virtual DOM庫?
為什么使用Virtual DOM
- 手動操作DOM比較麻煩。還需要考慮瀏覽器兼容性問題,雖然有JQuery等庫簡化DOM操作,但是隨著項目的復雜DOM操作復雜提升。
- 為了簡化DOM的復雜操作于是出現(xiàn)了各種MVVM框架,MVVM框架解決了視圖和狀態(tài)的同步問題
- 為了簡化視圖的操作我們可以使用模板引擎,但是模板引擎沒有解決跟蹤狀態(tài)變化的問題,于是Virtual DOM出現(xiàn)了
- Virtual DOM的好處是當狀態(tài)改變時不需要立即更新DOM,只需要創(chuàng)建一個虛擬樹來描述DOM,Virtual DOM內部將弄清楚如何有效的更新DOM(利用Diff算法實現(xiàn))。
Virtual DOM的特性
- Virtual DOM可以維護程序的狀態(tài),跟蹤上一次的狀態(tài)。
- 通過比較前后兩次的狀態(tài)差異更新真實DOM。
實現(xiàn)一個基礎的Virtual DOM庫
我們可以仿照snabbdom庫https://github.com/snabbdom/snabbdom.git自己動手實現(xiàn)一款迷你版Virtual DOM庫。
首先,我們創(chuàng)建一個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'
- }, '點擊')
- ]),
- ])
- }
- // 頁面改變
- 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>
我們在body標簽內創(chuàng)建了一個id是app的DOM元素,用于被掛載節(jié)點。接著我們引入了一個vdom.js文件,這個文件就是我們將要實現(xiàn)的迷你版Virtual DOM庫。最后,我們在script標簽內定義了一個render方法,返回為一個h方法。調用mountNode方法掛載到id是app的DOM元素上。h方法中數(shù)據(jù)結構我們是借鑒snabbdom庫,第一個參數(shù)是標簽名,第二個參數(shù)是屬性,最后一個參數(shù)是子節(jié)點。還有,你可能會注意到在h方法中我們使用了useStyleStr方法,這個方法主要作用是將style樣式轉化成頁面能識別的結構,實現(xiàn)代碼我會在最后給出。
思路理清楚了,展示頁面的代碼也寫完了。下面我們將重點看下vdom.js,如何一步一步地實現(xiàn)它。
第一步
我們看到index.html文件中首先需要調用mountNode方法,所以,我們先在vdom.js文件中定義一個mountNode方法。
- // Mount node
- function mountNode(render, selector) {
- }
接著,我們會看到mountNode方法第一個參數(shù)是render方法,render方法返回了h方法,并且看到第一個參數(shù)是標簽,第二個參數(shù)是屬性,第三個參數(shù)是子節(jié)點。
那么,我們接著在vdom.js文件中再定義一個h方法。
- function h(tag, props, children) {
- return { tag, props, children };
- }
還沒有結束,我們需要根據(jù)傳入的三個參數(shù)tag、props、children來掛載到頁面上。
我們需要這樣操作。我們在mountNode方法內封裝一個mount方法,將傳給mountNode方法的參數(shù)經(jīng)過處理傳給mount方法。
- // Mount node
- function mountNode(render, selector) {
- mount(render(), document.querySelector(selector))
- }
接著,我們定義一個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);
- }
第一個參數(shù)是調用傳進來的render方法,它返回的是h方法,而h方返回一個同名參數(shù)的對象{ tag, props, children },那么我們就可以通過vnode.tag、vnode.props、vnode.children取到它們。
我們看到先是判斷屬性,如果屬性字段開頭含有,on標識就是代表事件,那么就從屬性字段第三位截取,利用addEventListenerAPI創(chuàng)建一個監(jiān)聽事件。否則,直接利用setAttributeAPI設置屬性。
接著,再判斷子節(jié)點,如果是字符串,我們直接將字符串賦給文本節(jié)點。否則就是節(jié)點,我們就遞歸調用mount方法。
最后,我們將使用appendChildAPI把節(jié)點內容掛載到真實DOM中。
頁面正常顯示。
第二步
我們知道Virtual DOM有以下兩個特性:
- Virtual DOM可以維護程序的狀態(tài),跟蹤上一次的狀態(tài)。
- 通過比較前后兩次的狀態(tài)差異更新真實DOM。
這就利用到了我們之前提到的diff算法。
我們首先定義一個patch方法。因為要對比前后狀態(tài)的差異,所以第一個參數(shù)是舊節(jié)點,第二個參數(shù)是新節(jié)點。
- function patch(n1, n2) {
- }
下面,我們還需要做一件事,那就是完善mountNode方法,為什么這樣操作呢?是因為當狀態(tài)改變時,只更新狀態(tài)改變的DOM,也就是我們所說的差異更新。這時就需要配合patch方法做diff算法。
相比之前,我們加上了對是否掛載節(jié)點進行了判斷。如果沒有掛載的話,就直接調用mount方法掛載節(jié)點。否則,調用patch方法進行差異更新。
- 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;
- }
- }
那么下面我們將主動看下patch方法,這也是在這個庫中最復雜的方法。
- 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)㈤_始,兩個參數(shù)分別是在mountNode方法中傳進來的舊節(jié)點oldTree和新節(jié)點newTree,首先我們進行對新舊節(jié)點的標簽進行對比。
如果新舊節(jié)點的標簽不相等,就移除舊節(jié)點。另外,利用nextSiblingAPI取指定節(jié)點之后緊跟的節(jié)點(在相同的樹層級中)。然后,傳給mount方法第三個參數(shù)。這時你可能會有疑問,mount方法不是有兩個參數(shù)嗎?對,但是這里我們需要傳進去第三個參數(shù),主要是為了對同級節(jié)點進行處理。
- 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方法。我們看到我們只是加上了對anchor參數(shù)是否為空的判斷。
如果anchor參數(shù)不為空,我們使用insertBeforeAPI,在參考節(jié)點之前插入一個擁有指定父節(jié)點的子節(jié)點。insertBeforeAPI第一個參數(shù)是用于插入的節(jié)點,第二個參數(shù)將要插在這個節(jié)點之前,如果這個參數(shù)為 null 則用于插入的節(jié)點將被插入到子節(jié)點的末尾。
如果anchor參數(shù)為空,直接在父節(jié)點下的子節(jié)點列表末尾添加子節(jié)點。
- 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);
- }
- }
下面,我們再回到patch方法。如果新舊節(jié)點的標簽相等,我們首先要遍歷新舊節(jié)點的屬性。我們先遍歷新節(jié)點的屬性,判斷新舊節(jié)點的屬性值是否相同,如果不相同,再進行進一步處理。判斷新節(jié)點的屬性值是否為null,否則直接移除屬性。然后,遍歷舊節(jié)點的屬性,如果屬性名不在新節(jié)點屬性表中,則直接移除屬性。
分析完了對新舊節(jié)點屬性的對比,接下來,我們來分析第三個參數(shù)子節(jié)點。
首先,我們分別定義兩個變量oc、nc,分別賦予舊節(jié)點的children屬性和新節(jié)點的children屬性。如果新節(jié)點的children屬性是字符串,并且新舊節(jié)點的內容不相同,那么就直接將新節(jié)點的文本內容賦予即可。
接下來,我們看到利用Array.isArray()方法判斷新節(jié)點的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é)點的children屬性是否是數(shù)組。
如果是,我們取新舊子節(jié)點數(shù)組的長度兩者的最小值。然后,我們將其循環(huán)遞歸patch方法。為什么取最小值呢?是因為如果取的是他們共有的長度。然后,每次遍歷遞歸時,判斷nc.length和oc.length的大小,循環(huán)執(zhí)行對應的方法。
如果不是,直接將節(jié)點內容清空,重新循環(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'
- }, '點擊')
- ]),
- ])
- }
- // 頁面改變
- 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;
- }
- }