手寫 Vue (一):虛擬 DOM

前言

最近公司面試了一些中高級前端,因爲公司技術棧以 Vue 爲主,而對於中高級前端,必不可少要問及 Vue 源碼的問題。不少面試者,對於源碼只能簡單講到響應式是基於 Object.defineProperty 或者 Proxy 等老生常談的基礎概念。Vue 通過這麼多年的發展,成了不少前端開發者職業生涯不可或缺的一個框架。誠然,每一個人均可以在短期學習一個框架的使用,可是要深刻閱讀它的源碼確實不是一件容易的事。這裏面有不少因素,除了業務開發繁忙外,面對一個複雜龐大的代碼庫,以及衆多平時不常用的構建工具和新的編程語言等干擾因素,咱們時常不知道該從哪裏切入。爲了應付面試,只能經過一些面經文章和博客,快速得到一些基本的認知,但一旦面試官深刻拷問,真正看過源碼仍是隻看過文章,就水落石出。真正讀懂源碼不是靠一場突擊戰就能作到的,而是像澆花種樹同樣,日積月累,反覆刻意的練習和回顧,到最後甚至能夠本身寫出一個框架,纔算真正掌握。既然是一場持久戰,咱們就不能期望在短期內把整個框架一口吃進去,而是將其分割成一個個小的技術點,一次消化一個單一技術點,連點成線,最後就能吃下整個框架。本文以及接下來一系列文章,嘗試將 Vue 源碼拆分紅獨立的技術點,並動手編碼實現。html

如何編寫一個 Vue 框架?

雖然,絕大多數開發者,職業生涯幾乎不會參與到一個框架的開發,更不用說開發一個成功的被普遍使用的框架。可是,咱們不妨假設,開發一個框架和開發一個業務產品的基本邏輯是同樣的,就是首先,咱們須要產品需求分析,而後將需求拆分紅不一樣子模塊,分別開發各個子模塊後,再集成到一塊兒組成一個完整的系統。前端

開發一個框架也應如此。vue

首先,需求分析,咱們應該先問本身,這個框架要提供的核心功能是什麼;其次,要實現這些功能,咱們須要實現哪些技術點;最後,如何將這些分離的技術點組合複用成一個完整知足需求的框架。node

按照這個邏輯,那麼,Vue 的核心功能是什麼?Vue2 爲例,建立一個最簡單的 Vue 應用的代碼以下:面試

<div id="app"></div>
<script src="vue.js"></script>
<script> var vm = new Vue( { data: { text: 'hello world!' }, render(h) { return h('div', this.text) } } ).$mount('#app') </script>
複製代碼

這段代碼,使用框架導出的一個構造函數 Vue ,傳入包含字段datarender的選項對象,建立一個 Vue 實例 vm,並掛載到idappdom元素上。算法

這段代碼在瀏覽器運行後,能夠看到原來的dom元素<div id="app"></div>被替換成<div>hello world!</div>, 並能夠在控制檯鍵入 vm.text = 'hello china!',能夠看到在實例的text屬性改變後,對應的dom元素的文本內容當即改變了。編程

這裏包含如下三個環節:數組

  1. data定義的字段(例如text)被映射到Vue實例的屬性中;
  2. render函數傳入了一個函數h,並用h函數建立虛擬節點,調用h使用了 1. 中映射的屬性字段(this.text);
  3. 實例方法$moutrender返回的虛擬節點渲染到真實dom中;

首先,咱們定義Vue的構造函數,讀取選項對象的data字段,遍歷data的全部鍵值,並克隆到實例對象this上。瀏覽器

function Vue(options) {
  var data = options.data
  var keys = Object.keys(data)
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    this[key] = data[key]
  }
}
複製代碼

第二步,在 Vue 構造函數調用選項傳入的render函數,經過callrender函數上下文對象this指向Vue實例,這樣render函數內部能夠經過this訪問實例的數據,也就是選項對象傳入的datamarkdown

var render = options.render
this.vnode = render.call(this, createVNode)
複製代碼

這裏傳入的函數createVNode也就是上文中的h函數。createVNode能夠接受3個參數。

  • tag: string, 節點標籤
  • data: object, 節點屬性數據(包含 id, class, style)
  • children: array, 子節點數組

返回一個VNode對象,也就是一般我所說的虛擬DOM。要實現createVNode函數,咱們須要先知道VNode到底爲什麼物。所謂虛擬DOM,就是用一個普通的JS對象去建模真實的DOM,所以,直接修改虛擬DOM的屬性,不會觸發咱們在頁面可見DOM的改變,可是,它的結構是和真實DOM節點一一對應的。咱們知道在瀏覽器中,每個DOM節點都是一棵「樹」。做爲樹中一個節點,至少包含兩個部分,即節點數據和子節點。對應到DOM,一個節點自身的數據就是元素的標籤和屬性,子節點能夠包含任意多個,所以使用數組表示。createVNode函數用於提供給應用構建視圖的虛擬節點樹,建立樹的過程由外部提供,所以自身不須要遞歸建立子節點,而是簡單接受參數,並根據參數傳入類型和數量來決定VNode對應屬性賦值。

目前,我須要的VNode的完整字段包含:

var vnode = {
  tag,
  data,
  children,
  text
}
複製代碼

tag 爲元素標籤,data爲屬性數據,當節點是葉子節點,沒有children,那麼就用text表示節點顯示的文本(事實上,文本在真實DOM中也是一個特殊的節點,它沒有tag,所以爲了處理方便,在虛擬節點中,children 中表示是有 tag 的元素節點)。

所以,createVNode 接受的參數與咱們返回的結果基本一致,僅僅對傳入的第2個參數進行判斷,若是是字符串,就認爲要建立的是一個只有文本的葉子節點,不然將第二個參數做爲節點屬性數據,第三個參數做爲子節點數組。

function createVNode(tag, data, children) {
  var vnode = { tag: tag, data: undefined, children: undefined, text: undefined }
  if (typeof data === 'string') {
    vnode.text = data
  } else {
    vnode.data = data
    if (Array.isArray(children)) {
      vnode.children = children
    } else {
      vnode.children = [ children ]
    }
  }
  return vnode
}
複製代碼

因爲children參數的存在,在外部,可使用createVNodeh建立一個節點樹,例如:

var vnode = createVNode('ul', {}, [
  createVNode('li', {}, [
    createVNode('span', 'text')
  ]),
  createVNode('li', {}, [
    createVNode('span', 'text')
  ])
])
複製代碼

建立的虛擬節點樹,只是框架對應用視圖的內部表示,要得到真實可見的DOM,須要一個函數將VNode轉換成真實DOM。定義這個函數爲createElm。這個函數除了將VNode轉換成真實DOM元素,同時還將建立的DOM元素插入頁面中。插入的位置包含了兩個真實DOM元素,即插入元素的父節點,以及參考節點,參考節點是要替換的節點,是可選的,存在則插入到參考節點前面,並刪除參考節點,不存在則直接將新建立的節點(根據VNode建立的真實DOM節點)插入到父節點中。和createVNode不一樣的是,createElm接受的vnode參數是一課樹,所以,須要使用遞歸遍歷整個VNode樹,最後獲得實際也是一個真實DOM節點樹。

function createElm(vnode, parentElm, refElm) {
  var elm
  // 建立真實DOM節點
  if (vnode.tag) {
    elm = document.createElement(vnode.tag)
  } else if (vnode.text) {
    elm = document.createTextNode(vnode.text)
  }
  // 將真實DOM節點插入到文檔中
  if (refElm) {
    parentElm.insertBefore(elm, refElm)
    parentElm.removeChild(refElm)
  } else {
    parentElm.appendChild(elm)
  }

  // 遞歸建立子節點
  if (Array.isArray(vnode.children)) {
    for (var i = 0, l = vnode.children.length; i < l; i++) {
      var childVNode = vnode.children[i]
      createElm(childVNode, elm)
    }
  } else if (vnode.text) {
    elm.textContent = vnode.text
  }

  return elm
}
複製代碼

有了createElm函數,實現$mount方法的基本功能也就簡單了。

Vue.prototype.$mount = function (id) {
  var refElm = document.querySelector(id)
  var parentElm = refElm.parentNode
  createElm(this.vnode, parentElm, refElm)
  return this
}
複製代碼

驗證最小應用

到此爲止,彷佛已經將前文建立簡單Vue應用用到的全部功能實現了一遍。接下來,咱們將代碼整合一下,保存到文件myvue.js:

function Vue(options) {
  var data = options.data
  var keys = Object.keys(data)
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i]
    this[key] = data[key]
  }

  var render = options.render
  this.vnode = render.call(this, createVNode)
}

function createVNode(tag, data, children) {
  var vnode = { tag: tag, data: undefined, children: undefined, text: undefined }
  if (typeof data === 'string') {
    vnode.text = data
  } else {
    vnode.data = data
    if (Array.isArray(children)) {
      vnode.children = children
    } else {
      vnode.children = [ children ]
    }
  }
  return vnode
}

function createElm(vnode, parentElm, refElm) {
  var elm
  // 建立真實DOM節點
  if (vnode.tag) {
    elm = document.createElement(vnode.tag)
  } else if (vnode.text) {
    elm = document.createTextNode(vnode.text)
  }
  // 將真實DOM節點插入到文檔中
  if (refElm) {
    parentElm.insertBefore(elm, refElm)
    parentElm.removeChild(refElm)
  } else {
    parentElm.appendChild(elm)
  }

  // 遞歸建立子節點
  if (Array.isArray(vnode.children)) {
    for (var i = 0, l = vnode.children.length; i < l; i++) {
      var childVNode = vnode.children[i]
      createElm(childVNode, elm)
    }
  } else if (vnode.text) {
    elm.textContent = vnode.text
  }

  return elm
}

Vue.prototype.$mount = function (id) {
  var refElm = document.querySelector(id)
  var parentElm = refElm.parentNode
  createElm(this.vnode, parentElm, refElm)
  return this
}
複製代碼

而後將html文件中的vue.js改爲myvue.js:

<div id="app"></div>
<script src="myvue.js"></script>
<script> var vm = new Vue( { data: { text: 'hello world!' }, render(h) { return h('div', this.text) } } ).$mount('#app') </script>
複製代碼

在瀏覽器打開html文件,能夠看到,結果與vue.js顯示一致。爲了測試節點樹的渲染,咱們不妨修改一下選項對象:

{
  data: {
    items: [
      'item1',
      'item2',
      'item3',
    ]
  },
  render(h) {
    var children = this.items.map(item => h('li', item))
    var vnode = h('ul', null, children)
    console.log(vnode)
    return vnode
  }
}
複製代碼

還要作什麼?

眨一看,好像一切如咱們所料。它成功利用咱們傳入的數據和渲染函數,建立虛擬節點,而且掛載到真實DOM上。可是,目前來看它至少還缺乏兩個關鍵功能。

  1. 從新修改實例屬性值(例如vm.text)並不能觸發頁面的從新渲染,也就是沒有響應式;
  2. 只有完整建立一個新的DOM樹的方法,對於已經建立好的DOM,從新更新,必須銷燬整個DOM樹,從新建立,即沒有對新舊vnodediff算法,實現只對發生改變的節點從新建立;

別急,萬丈高樓平地起,正如本文開篇所講,咱們須要的是一場持久戰,而不是突擊戰。有了最小可用功能,後面就是在此基礎上作迭代和優化。感興趣的讀者,請關注後續系列更新。

相關文章
相關標籤/搜索