vdom實現


1 簡介

1-1 傳統模板引擎

傳統模板引擎編譯生成HTML字符串。
通常在後臺腳本語言中將模板和數據合併成html字符串輸出到前端
或者後臺輸出數據,前端使用模板引擎組合數據和模板生成界面

當數據發生變化的時候,最簡單的就是模板重新渲染,或者模板引擎局部界面重新渲染。

當頁面包含的數據很多的時候,這種代碼操作中包含很多DOM操作,編碼複雜不便維護。而且重新渲染導致性能問題

1-2 vdom

虛擬dom則將這個過程分爲兩步

第一步編譯模板生成vdom的渲染函數render
在需要的渲染的時候調用渲染函數render組成的樹狀vdom

在mvvm數據綁定的結構中,將視圖部分的渲染組織爲vdom的渲染函數。可以優化編碼結構與渲染效率

2 模板引擎和vdome

2-1 模板引擎簡介

下面是一個普通的模板引擎語法。支持循環語句(each) 條件語句(if elseif)和文本填充{}。

;簡單的模板語法
<div>
  <h1>{title}</h1>
  <ul>
    {each users as user i}
    <li class="user-item">
      <img src="/avatars/{user.id}" />
      <span>NO.{i + 1} - {user.name}</span>
      {if user.isAdmin}
        I am admin
      {elseif user.isAuthor}
        I am author
      {else}
        I am nobody
      {/if}
    </li>
    {/each}
  </ul>
</div>

對於上述模板語法,輸入下面數據

var data = {
  title: 'Users List',
  users: [
    {id: 'user0', name: 'Jerry', isAdmin: true},
    {id: 'user1', name: 'Lucy', isAuthor: true},
    {id: 'user2', name: 'Tomy'}
  ]
}

模板引擎解析後生成的html字符串如下

<div>
  <h1>Users List</h1>
  <ul>
    <li class="user-item">
       <img src="/avatars/user0" />
       <span>NO.1 - Jerry</span>
       I am admin
    </li>
    <li class="user-item">
       <img src="/avatars/user1" />
       <span>NO.2 - Lucy</span>
       I am author
    </li>
    <li class="user-item">
       <img src="/avatars/user2" />
       <span>NO.3 - Tomy</span>
       I am nobody
    </li>
  </ul>
</div>

將字符串插入文檔中即可實現渲染界面

2-2 vdom

如果上述數據中的data.title發生變化,
則需要使用dom操作語法重新修改模板結構

可以參考ReactJs的JSX的做法,
將模板編譯爲一個生成vdom的的render函數。
render函數接受傳入的數據生成不同的vdom。
然後可以根據vdom的算法diff和patch來比較局部渲染

;vdom簡單流程

;模板編譯生成渲染函數render
var render = template(tplString) 

;接受初始化數據,返回初始化vdom結果
var root1 = render(state1) 

;生成真正的dom,插入文檔中
var dom = root.render() 
document.body.appendChild(dom)

;接受變化後的數據,生成另外的vdom
var root2 = render(state2) 
;對比兩個vdom
var patches = diff(root1, root2) 
;渲染對比結果
patch(dom, patches)

這樣將模板編譯與結果渲染分離,可以重複使用編譯結果,提高執行效率。

而將結果渲染分爲比較和局部渲染,可以優化代碼的組織結構

總體流程如下

1 模板編譯生成一個render函數,接受數據返回不同的vdom
2 接受數據生成vdom,結合渲染平臺生成真正的dom元素,插入文檔
3 數據變化後,渲染函數接受數據,生成新的vdom
4 新舊的vdom進行diff,然後局部patch到文檔的dom元素中

模板編譯生成render函數結構簡單如下

function render (state) {
  return el('div', {}, [
    el('h1', {}, [state.title]),
    el('ul', {}, state.users.map(function (user, i) {
       return el('li', {"class": "user-item"}, [
         el('img', {"src": "/avatars/" + user.id}, []),
         el('span', {}, ['No.' + (i + 1) + ' - ' + user.name],
         (user.isAdmin 
           ? 'I am admin'
           : uesr.isAuthor 
             ? 'I am author'
             : '')
       ])
    }))
  ])
}

3 vdom-templat的實現思路

簡單的模板引擎可以適合於正則表達式對相應模板字符串進行替換生成

這裏使用編譯原理的一部基礎知識,實現把一種語言(模板語法)編譯爲另外一種語言(render的javascript函數)

4 編譯原理流程

1 詞法分析:將輸入的模板分割爲詞法單元
2 語法分析:接受詞法單元,根據文法規則轉換爲抽象語法樹
3 代碼生成:遍歷AST,生成render函數體代碼

4 可以將這個過程分爲詞法分析(lex),語法分析(parser),代碼生成(codegen)三部分。

5 模板的文法定義

可以使用文法描述模板結構的組成,作爲詞法分析與語法分析的基礎

;模板整體
Stat -> Frag Stat | ε
Frag -> IfStat | EachStat | Node | text

;語句組織
IfStat -> '{if ...}' Stat ElseIfs Else '{/if}'
ElseIfs -> ElseIf ElseIfs | ε
ElseIf -> '{elseif ...}' Stat
Else -> '{else}' Stat | ε

EachStat -> '{each ...}' Stat '{/each}'

;節點組織
Node -> OpenTag NodeTail
OpenTag -> '/[\w\-\d]+/' Attrs
NodeTail -> '>' Stat '/\<[\w\d]+\>/' | '/>'

;節點屬性
Attrs -> Attr Attrs | ε 
Attr -> '/[\w\-\d]/+' Value

;節點值
Value -> '=' '/"[\s\S]+"/' | ε

6 詞法分析 lexer

模板文法中的基礎詞法單元如下

module.exports = {
  TK_TEXT: 1, // 文本節點
  TK_IF: 2, // {if ...}
  TK_END_IF: 3, // {/if}
  TK_ELSE_IF: 4, // {elseif ...}
  TK_ELSE: 5, // {else}
  TK_EACH: 6, // {each ...}
  TK_END_EACH: 7, // {/each}
  TK_GT: 8, // >
  TK_SLASH_GT: 9, // />
  TK_TAG_NAME: 10, // <div|<span|<img|...
  TK_ATTR_NAME: 11, // 屬性名
  TK_ATTR_EQUAL: 12, // =
  TK_ATTR_STRING: 13, // "string"
  TK_CLOSE_TAG: 13, // </div>|</span>|</a>|...
  TK_EOF: 100 // end of file
}

使用js的正則表達式引擎實現詞法分析,
解析輸入的模板字符串,生成詞法單元流

;詞法單元入口
function Tokenizer (input) {
  this.input = input
  this.index = 0
  this.eof = false
}

var pp = Tokenizer.prototype

;詞法單元解析
pp.nextToken = function () {
  this.eatSpaces()
  return (
    this.readCloseTag() ||
    this.readTagName() ||
    this.readAttrName() ||
    this.readAttrEqual() ||
    this.readAttrString() ||
    this.readGT() ||
    this.readSlashGT() ||
    this.readIF() ||
    this.readElseIf() ||
    this.readElse() ||
    this.readEndIf() ||
    this.readEach() ||
    this.readEndEach() ||
    this.readText() ||
    this.readEOF() ||
    this.error()
  )
}

其中index標識字符串的位置。
nextToken()跳過所有空白字符串,
然後嘗試匹配不同類型的token
匹配失敗嘗試下一種,成功返回移動index,
上面的簡單模板列子經過詞法分析可以解析生成如下

{ type: 10, label: 'div' }
{ type: 8, label: '>' }
{ type: 10, label: 'h1' }
{ type: 8, label: '>' }
{ type: 1, label: '{title}' }
{ type: 13, label: '</h1>' }
{ type: 10, label: 'ul' }
{ type: 8, label: '>' }
{ type: 6, label: '{each users as user i}' }
{ type: 10, label: 'li' }
{ type: 11, label: 'class' }
{ type: 12, label: '=' }
{ type: 13, label: 'user-item' }
{ type: 8, label: '>' }
{ type: 10, label: 'img' }
{ type: 11, label: 'src' }
{ type: 12, label: '=' }
{ type: 13, label: '/avatars/{user.id}' }
{ type: 9, label: '/>' }
{ type: 10, label: 'span' }
{ type: 8, label: '>' }
{ type: 1, label: 'NO.' }
{ type: 1, label: '{i + 1} - ' }
{ type: 1, label: '{user.name}' }
{ type: 13, label: '</span>' }
{ type: 2, label: '{if user.isAdmin}' }
{ type: 1, label: 'I am admin\r\n        ' }
{ type: 4, label: '{elseif user.isAuthor}' }
{ type: 1, label: 'I am author\r\n        ' }
{ type: 5, label: '{else}' }
{ type: 1, label: 'I am nobody\r\n        ' }
{ type: 3, label: '{/if}' }
{ type: 13, label: '</li>' }
{ type: 7, label: '{/each}' }
{ type: 13, label: '</ul>' }
{ type: 13, label: '</div>' }
{ type: 100, label: '$' }

7 語法解析parser

將語法結構組織爲first集合和follow集如下

FIRST(Stat) = {TK_IF, TK_EACH, TK_TAG_NAME, TK_TEXT}
FOLLOW(Stat) = {TK_ELSE_IF, TK_END_IF, TK_ELSE, TK_END_EACH, TK_CLOSE_TAG, TK_EOF}

FIRST(Frag) = {TK_IF, TK_EACH, TK_TAG_NAME, TK_TEXT}
FIRST(IfStat) = {TK_IF}

FIRST(ElseIfs) = {TK_ELSE_IF}
FOLLOW(ElseIfs) = {TK_ELSE, TK_ELSE}

FIRST(ElseIf) = {TK_ELSE_IF}

FIRST(Else) = {TK_ELSE}
FOLLOW(Else) = {TK_END_IF}

FIRST(EachStat) = {TK_EACH}
FIRST(OpenTag) = {TK_TAG_NAME}
FIRST(NodeTail) = {TK_GT, TK_SLASH_GT}

FIRST(Attrs) = {TK_ATTR_NAME}
FOLLOW(Attrs) = {TK_GT, TK_SLASH_GT}

FIRST(Value) = {TK_ATTR_EQUAL}
FOLLOW(Value) = {TK_ATTR_NAME, TK_GT, TK_SLASH_GT}

遞歸下降的語法parser如下

var Tokenizer = require('./tokenizer')
var types = require('./tokentypes')

;語法解析入口
function Parser (input) {
  this.tokens = new Tokenizer(input)
  this.parse()
}

var pp = Parser.prototype

;詞法類型判斷
pp.is = function (type) {
  return (this.tokens.peekToken().type === type)
}

;語法解析
pp.parse = function () {
  this.tokens.index = 0
  this.parseStat()
  this.eat(types.TK_EOF)
}

;Stat解析
pp.parseStat = function () {
  if (
    this.is(types.TK_IF) ||
    this.is(types.TK_EACH) ||
    this.is(types.TK_TAG_NAME) ||
    this.is(types.TK_TEXT)
  ) {
    this.parseFrag()
    this.parseStat()
  } else {
    // end
  }
}

;Frag解析
pp.parseFrag = function () {
  if (this.is(types.TK_IF)) return this.parseIfStat()
  else if (this.is(types.TK_EACH)) return this.parseEachStat()
  else if (this.is(types.TK_TAG_NAME)) return this.parseNode()
  else if (this.is(types.TK_TEXT)) {
    var token = this.eat(types.TK_TEXT)
    return token.label
  } else {
    this.parseError('parseFrag')
  }
}

;等等其他子解析過程

遞歸下降分析,構建語法的樹狀表示結構AST如下

Stat: {
    type: 'Stat'
    members: [IfStat | EachStat | Node | text, ...]
}

IfStat: {
    type: 'IfStat'
    label: <string>,
    body: Stat
    elifs: [ElseIf, ...]
    elsebody: Stat
}

ElseIf: {
    type: 'ElseIf'
    label: <string>,
    body: Stat
}

EachStat: {
    type: 'EachStat'
    label: <string>,
    body: Stat
}

Node: {
    type: 'Node'
    name: <string>,
    attributes: <object>,
    body: Stat
}

可以使用具體嵌套功能的js對象或者數組表示樹狀結構的語法樹
語法樹的構建過程可以在語法分析階段同時進行,
最後上面的模板語法獲得下面的語法樹結構

8 代碼生成

從js字符串構建新的函數可以使用new Function

var newFunc = new Function('a', 'b', 'return a + b')
newFunc(1, 2) // => 3

可以將語法樹對應字符串作爲第三個參數生成render函數
只需要對AST進行遍歷,維護一個數組來保存生成的render函數的代碼

;代碼生成入口
function CodeGen (ast) {
  this.lines = []
  this.walk(ast)
  this.body = this.lines.join('\n')
}

var pp = CodeGen.prototype

;AST遍歷
pp.walk = function (node) {
  if (node.type === 'IfStat') {
    this.genIfStat(node)
  } else if (node.type === 'Stat') {
    this.genStat(node)
  } else if (node.type === 'EachStat') {
    ...
  }
  ...
}

;生成不同render
pp.genIfStat = function (node) {
  var expr = node.label.replace(/(^\{\s*if\s*)|(\s*\}$)/g, '')
  this.lines.push('if (' + expr + ') {')
  if (node.body) {
    this.walk(node.body)
  }
  if (node.elseifs) {
    var self = this
    _.each(node.elseifs, function (elseif) {
      self.walk(elseif)
    })
  }
  if (node.elsebody) {
    this.lines.push(indent + '} else {')
    this.walk(node.elsebody)
  }
  this.lines.push('}')
}

pp.genEachStat = function (node) {
  var expr = node.label.replace(/(^\{\s*each\s*)|(\s*\}$)/g, '')
  var tokens = expr.split(/\s+/)
  var list = tokens[0]
  var item = tokens[2]
  var key = tokens[3]
  this.lines.push(
    'for (var ' + key + ' = 0, len = ' + list + '.length; ' + key + ' < len; ' + key + '++) {'
  )
  this.lines.push('var ' + item + ' = ' + list + '[' + key + '];')
  if (node.body) {
    this.walk(node.body)
  }
  this.lines.push('}')
}
// ...

其中的lines包含相應的代碼結果
然後生成對應的render函數

var code = new CodeGen(ast)
var render = new Function('el', 'data', code.body)

el作爲render函數的渲染目標節點,
data需要的數據,
code.body爲解析後的render函數

9 diff和patch封裝

對於diff和patch,可以將其封裝爲setData的api。
每次數據變更,只需要setData就可以更新到DOM元素上

// vTemplate.compile 編譯模版字符串,返回一個函數
var usersListTpl = vTemplate.compile(tplStr)

// userListTpl 傳入初始數據狀態,返回一個實例
var usersList = usersListTpl({
  title: 'Users List',
  users: [
    {id: 'user0', name: 'Jerry', isAdmin: true},
    {id: 'user1', name: 'Lucy', isAuthor: true},
    {id: 'user2', name: 'Tomy'}
  ]
})

// 返回的實例有 dom 元素和一個 setData 的 API
document.appendChild(usersList.dom)

// 需要變更數據的時候,setData 一下即可
usersList.setData({
  title: 'Users',
  users: [
    {id: 'user1', name: 'Lucy', isAuthor: true},
    {id: 'user2', name: 'Tomy'}
  ]
})

參考

vdom模板引擎

vdom完整代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章