1. 程式人生 > >從一個前端菜鳥的角度聊一聊虛擬DOM

從一個前端菜鳥的角度聊一聊虛擬DOM

從一個前端菜鳥的角度聊一聊虛擬DOM

  想要解釋一下標題,對於虛擬DOM我並沒有去深入瞭解,只是略微知道個大概原理,這裡也只是略微說一下原理。

虛擬DOM是什麼

  首先,什麼是DOM?

  Document_Object_Model,文件物件模型。將html頁面元素與一個個的物件一一對應,方便我們使用js來操控html文件。此處就不細講了,具體可以看這裡

  那麼,虛擬DOM又是什麼呢?

  DOM在頁面元素和物件之間建立了一個對映關係,虛擬DOM同樣是建立了頁面元素和物件之間的對映關係,使用js物件來模擬DOM,通過對js物件的操作來操作頁面元素。事實上,你可以把虛擬DOM理解為DOM的簡化版。

  頁面中的DOM樹有著固定的結構,如果現在頁面中有一張學生表如下,按學號來排列。此時你需要在點選“數學”兩個字的時候讓它按照數學成績排序,點選“新增”的時候新新增一條資料。那你要怎麼辦?重新渲染整個DOM樹嗎?這顯然是不合適的。所以使用虛擬DOM,比較新的DOM和舊的DOM之間的差異,修改差異部分就好了。

學號 數學 語文
11201 85 77
11202 93 52
11203 74 69
新增

實現一個簡單的虛擬DOM

  事實上,虛擬DOM的實現精髓在於比較差異部分所使用的演算法,但是這裡不涉及。只是為了描述原理簡要寫一下它的實現。

  首先你需要知道我們想要得到的是哪些資料。假設有下面一段html

<div>
  <p><span>my name is wenchuyang</span></p>
  <span>my name is wenhuan</span>
</div>

  我們可以轉換為虛擬DOM資料。節點有三個資料項,分別是tag,children和text。對於文位元組點tag為’#text’,text的值則為文字內容。對於普通節點tag值是標籤名,children值是子節點。將上邊的html按要求轉換結果如下:

let nodeData = {
  tag: 'div',
  children: [
    {
      tag: 'p',
      children: [
        {
          tag: 'span',
          children: [
            {
              tag: '#text',
              text: 'my name is wenchuyang'
            }
          ]
        }
      ]
    },
    {
      tag: 'span',
      children: [
        {
          tag: '#text',
          text: 'my name is wenhuan'
        }
      ]
    }
  ]
}

  如果我們想要得到這樣的node資料,可以新建一個VNode類,用來新建node。如下

class VNode {
  constructor(tag, children, text){
    this.tag = tag
    this.children = children
    this.text = text
  }
  render(){//將這個元素放到html裡邊
    if(this.tag === '#text'){
      return document.createTextNode(this.text)
    }
    let el = document.createElement(this.tag)
    this.children.forEach(vChild => {
      el.appendChild(vChild.render())        
    });
    return el
  }
}

  這裡用的是ES6的寫法,如果你看不慣的話可以使用ES5像下邊這樣

function VNode(tag, children, text){
  this.tag = tag
  this.children = children
  this.text = text
}
VNode.prototype.render = function(){
  if(this.tag === '#text'){
    return document.createTextNode(this.text)
  }
  let el = document.createElement(this.tag)
  this.children.forEach(vChild => {
    el.appendChild(vChild.render())        
  });
  return el
}

  結果都是一樣的,就不分別解釋了。

  這樣我們建立了一個VNode類來得到節點,並使用render方法進行html渲染。加一個函式稍稍優化一下

function v(tag, children, text){
  if(typeof children === 'string'){
    text = children
    children = []
  }
  return new VNode(tag, children, text)
}

  這樣我們如果想要得到一個text節點的話,只需要let textNode = v('#text', 'I am a text node')即可,不需要手動輸入children為空陣列。用這樣的方法生成上邊的html的話,只需要執行let nodes = v('div', [v('p', [v('span', [v('#text', 'my name is wenchuyang')])]), v('span', [v('#text', 'my name is wenhuan')])]),使用console.log(nodes.render())可以在控制檯打印出渲染過後的html。

  或許你會問,這樣子有什麼用呢?

  如果你要把頁面中的第二句"my name is wenhuan"改成"hello",難道要再重新生成一遍,像下邊這樣?

let nodes = v('div', [v('p', [v('span', [v('#text', 'my name is wenchuyang')])]), v('span', [v('#text', 'hello')])])
let root = document.querySelector('#root')
root.innerHTML = ''
root.appendChild(nodes.render)

  顯然是不合適的,不然我們費這麼大勁也沒什麼意義了。我們需要一個diff演算法,比較開始的DOM和我們的虛擬DOM有什麼區別,然後只需要修改這微小的差異即可。比如我們如果說需要把“my name is wenhuan”改成“hello”,只需要修改這個text節點而已。複雜一點的diff演算法的話會考慮排序方面的問題,這裡不做深入瞭解,畢竟——不會啊emmm

  所以我們寫了如下簡單的diff演算法

function patchElement(parent, newVNode, oldVNode, index = 0) {
  if(!oldVNode) {
    parent.appendChild(newVNode.render())
  } else if(!newVNode) {
    parent.removeChild(parent.childNodes[index])
  } else if(newVNode.tag !== oldVNode.tag || newVNode.text !== oldVNode.text) {
    parent.replaceChild(newVNode.render(), parent.childNodes[index])
  }  else {
    for(let i = 0; i < newVNode.children.length || i < oldVNode.children.length; i++) {
      patchElement(parent.childNodes[index], newVNode.children[i], oldVNode.children[i], i)
    }
  }
}

  唔,解釋一下,四個引數分別是父節點,新的節點,舊的節點以及預設為0的index。

  如果舊的node不存在,那麼表示這個節點是新增的,所以使用appendChild將其新增進去。

  如果新的node不存在,那麼表示我們需要刪除舊的節點,所以使用removeChild

  而如果說新node與舊node的tag不相同,或者說text值不同,那就是新舊兩個節點不同,所以直接使用新的節點替換掉舊的節點即可。

  如果上述情況都不滿足,那麼說明你的parent傳錯了。emmm那就說明新節點和舊節點本身相同但是它們的子節點不同,所以遞迴呼叫該函式,此時index引數起到了作用,對新舊節點的子節點進行遍歷。當然我們知道,如果是打亂節點的排列順序像這樣第一個與第一個比較第二個與第二個比較的話,是沒有用的。這裡不做討論。

  所以如果有了這個函式的話,我們想要把“my name is wenhuan”修改成“hello”的話,就可以

let newNodes = v('div', [v('p', [v('span', [v('#text', 'my name is wenchuyang')])]), v('span', [v('#text', 'hello')])])
patchElement(root, newNodes, nodes)

  當然,你要是給包裹住text的span加一個id或者是class,可以使程式碼看上去更簡單

<div>
  <p><span>my name is wenchuyang</span></p>
  <span id="change">my name is wenhuan</span>
</div>
let parentNode = document.querySelector('#change')
let newNode = v('#text', 'hello')
let oldNode = parentNode.childNodes[0]
patchElement(parentNode, newNode, oldNode)

  這樣就可以通過虛擬DOM,完成頁面小部分的修改渲染了。

寫在後面

  簡單的來說,虛擬DOM先使用類來得到節點資料,然後通過render方法進行節點的渲染,讓它成為html標籤元素,再使用diff演算法,比較你需要修改的DOM和新生成的虛擬DOM有哪些不同之處,然後只需要將不同之處反映到真實的DOM樹上就大功告成了。