構建一個使用 Virtual-DOM 的前端模版引擎

做者:戴嘉華javascript

轉載請註明出處並保留原文連接( https://github.com/livoras/blog/issues/14 )和做者信息。html

目錄

  1. 前言前端

  2. 問題的提出java

  3. 模板引擎和 Virtual-DOM 結合 —— Virtual-Templatenode

  4. Virtual-Template 的實現git

    • 4.1 編譯原理相關github

    • 4.2 模版引擎的EBNF正則表達式

    • 4.3 詞法分析算法

    • 4.4 語法分析與抽象語法樹數組

    • 4.5 代碼生成

  5. 完整的 Virtual-Template

  6. 結語

1. 前言

本文嘗試構建一個 Web 前端模板引擎,而且把這個引擎和 Virtual-DOM 進行結合。把傳統模板引擎編譯成 HTML 字符串的方式改進爲編譯成 Virtual-DOM 的 render 函數,能夠有效地結合模板引擎的便利性和 Virtual-DOM 的性能。相似 ReactJS 中的 JSX。

閱讀本文須要一些關於 ReactJS 實現原理或者 Virtual-DOM 的相關知識,能夠先閱讀這篇博客:深度剖析:如何實現一個 Virtual DOM 算法 , 進行相關知識的瞭解。

同時還須要對編譯原理相關知識有基本的瞭解,包括 EBNF,LL(1),遞歸降低的方法等。

2. 問題的提出

本人在就任的公司維護一個比較樸素的系統,前端渲染有兩種方式:

  1. 後臺直接根據模板和數據直接把頁面吐到前端。

  2. 後臺只吐數據,前端用前端模板引擎渲染數據,動態塞到頁面。

當數據狀態變動的時候,前端用 jQuery 修改頁面元素狀態,或者把局部界面用模板引擎從新渲染一遍。當頁面狀態不少的時候,用 jQuery 代碼中會就混雜着不少的 DOM 操做,編碼複雜且不便於維護;而從新渲染雖然省事,可是會致使一些性能、焦點消失的問題(具體能夠看這篇博客介紹)。

由於習慣了 MVVM 數據綁定的編碼方式,對於用 jQuery 選擇器修改 wordings 等細枝末節的勞力操做我的感受不甚習慣。因而就構思可否在這種樸素的編碼方式上作一些改進,解放雙手,提高開發效率。其實只要加入數據狀態 -> 視圖的 one-way data-binding 開發效率就會有較大的提高。

而這種已經在運做多年的多人維護系統,引入新的 MVVM 框架並非一個很是好的選擇,在兼容性和風險規避上你們都有諸多的考慮。因而就構思了一個方案,在前端模板引擎上作手腳。能夠在幾乎零學習成本的狀況下,作到 one-way data-binding,大量減小 jQuery DOM 操做,提高開發效率。

3. 模板引擎和 Virtual-DOM 結合 —— Virtual-Template

考慮如下模板語法:

<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>

這隻一個普通的模板引擎語法(相似 artTemplate),支持循環語句(each)、條件語句(if elseif else ..)、和文本填充({...}), 應該比較容易看懂,這裏就不解釋。當用下面數據渲染該模板的時候:

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>

把這個字符串塞入文檔當中就能夠生成 DOM 。可是問題是若是數據變動了,例如data.titleUsers List修改爲Users,你只能用 jQuery 修改 DOM 或者直接從新渲染一個新的字符串塞入文檔當中。

然而咱們能夠參考 ReactJS 的 JSX 的作法,不把模板編譯成 HTML, 而是把模板編譯成一個返回 Virtual-DOM 的 render 函數。render 函數會根據傳入的 state 不一樣返回不同的 Virtual-DOM ,而後就能夠根據 Virtual-DOM 算法進行 diff 和 patch:

// setup codes
// ...

var render = template(tplString) // template 把模板編譯成 render 函數而不是 HTML 字符串
var root1 = render(state1) // 根據初始狀態返回的 virtual-dom

var dom = root.render() // 根據 virtual-dom 構建一個真正的 dom 元素
document.body.appendChild(dom)

var root2 = render(state2) // 狀態變動,從新渲染另一個 virtual-dom
var patches = diff(root1, root2) // virtual-dom 的 diff 算法
patch(dom, patches) // 更新真正的 dom 元素

這樣作好處就是:既保留了原來模板引擎的語法,又結合了 Virtual-DOM 特性:當狀態改變的時候再也不須要 jQuery 了,而是跑一遍 Virtual-DOM 算法把真正的 DOM 給patch了,達到了 one-way data-binding 的效果,總結流程就是:

  1. 先把模板編譯成一個 render 函數,這個函數會根據數據狀態返回 Virtual-DOM

  2. 用 render 函數構建 Virtual-DOM;並根據這個 Virtual-DOM 構建真正的 DOM 元素,塞入文檔當中

  3. 當數據變動的時候,再用 render 函數渲染一個新的 Virtual-DOM

  4. 新舊的 Virtual-DOM 進行 diff,而後 patch 已經在文檔中的 DOM 元素

(恩,其實就是一個相似於 JSX 的東西)

這裏重點就是,如何能把模板語法編譯成一個可以返回 Virtual-DOM 的 render 函數?例如上面的模板引擎,再也不返回 HTML 字符串了,而是返回一個像下面那樣的 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'
             : '')
       ])
    }))
  ])
}

前面的模板和這個 render 函數在語義上是同樣的,只要可以實現「模板 -> render 函數」這個轉換,就能夠跑上面所說的 Virtual-DOM 的算法流程,這樣就把模板引擎和 Virtual-DOM結合起來。爲了方便起見,這裏把這個結合體稱爲 Virtual-Template 。

4. Virtual-Template 的實現

網上關於模板引擎的實現原理介紹很是多。若是語法不是太複雜的話,能夠直接經過對語法標籤和代碼片斷進行分割,識別語法標籤內的內容(循環、條件語句)而後拼裝代碼,具體能夠參考這篇博客。其實就是正則表達式使用和字符串的操做,不須要對語法標籤之外的內容作識別。

可是對於和 HTML 語法已經差異較大的模板語法(例如 Jade ),單純的正則和字符串操做已經不夠用了,由於其語法標籤之外的代碼片斷根本不是合法的 HTML 。這種狀況下通常須要編譯器相關知識發揮用途:模板引擎本質上就是把一種語言編譯成另一種語言。

而對於 Virtual-Template 的狀況,雖然其除了語法標籤之外的代碼都是合法的 HTML 字符串,可是咱們的目的是把它編譯成返回 Virtual-DOM 的 render 函數,在構建 Virtual-DOM 的時候,你須要知道元素的 tagName、屬性等信息,因此就須要對 HTML 元素自己作識別。

所以 Virtual-Template 也須要藉助編譯原理(編譯器前端)相關的知識,把一種語言(模板語法)編譯成另一種語言(一個叫 render 的 JavaScript 函數)。

4.1 編譯原理相關

CS 本科都教過編譯原理,本文會用到編譯器前端的一些概念。在實現模板到 render 函數的過程當中,要通過幾個步驟:

  1. 詞法分析:把輸入的模板分割成詞法單元(tokens stream)

  2. 語法分析:讀入 tokens stream ,根據文法規則轉化成抽象語法樹(Abstract Syntax Tree)

  3. 代碼生成:遍歷 AST,生成 render 函數體代碼

ast

因此這個過程能夠分紅幾個主要模塊:tokenizer(詞法分析器),parser(語法分析器),codegen(代碼生成)。在此以前,還須要對模板的語法作文法定義,這是構建詞法分析和語法分析的基礎。

4.2 模板引擎的 EBNF

在計算機領域,對某種語言進行語法定義的時候,幾乎都會用到 EBNF(擴展的巴科斯範式)。在定義模板引擎的語法的時候,也能夠用到 EBNF。Virtual-Template 擁有很是簡單的語法規則,支持上面所提到的 each、if 等語法:

{each users as user i }
 <div> {user.name} </div>
 ...
{/each}

{if user.isAdmin}
 ...
{elseif user.isAuthor}
 ...
{elseif user.isXXX}
 ...
{/if}

對於 {user.name} 這樣的表達式插入,能夠簡單地當作是字符串,在代碼生成的時候再作處理。這樣咱們的詞法和語法分析就會簡化不少,基本只須要對 each、if、HTML 元素進行處理。

Virtual-Template 的 EBNF

Stat -> Frag Stat | ε
Frag -> IfStat | EachStat | Node | text

IfStat -> '{if ...}' Stat {ElseIf} [Else] '{/if}'
ElseIf -> '{elseif ...}' Stat
Else -> '{else}' Stat|e

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

Node -> OpenTag NodeTail
OpenTag -> '/[\w\-\d]+/' {Attr}
NodeTail -> '>' Stat '/\<[\w\d]+\>/' | '/>'

Attr -> '/[\w\-\d]/+' Value
Value -> '=' '/"[\s\S]+"/' | ε

能夠把該文法轉換成 LL(1) 文法,方便咱們寫遞歸降低的 parser。這個語法仍是比較簡單的,沒有出現複雜的左遞歸狀況。簡單進行展開和提取左公因子消除衝突得到下面的 LL(1) 文法。

LL(1) 文法:

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]+"/' | ε

4.3 詞法分析

根據上面得到的 EBNF ,單引號包含的都是非終結符,能夠知道有如下幾種詞法單元:

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
}

使用 JavaScript 自帶的正則表達式引擎編寫 tokenizer 很方便,把輸入的模板字符串從左到右進行掃描,按照上面的 token 的類型進行分割:

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()
  )
}

// read token methods
// ...

Tokenizer 會存儲一個 index,標記當前識別到哪一個字符位置。每次調用 nextToken 會先跳過全部的空白字符,而後嘗試某一種類型的 token ,識別失敗就會嘗試下一種,若是成功就直接返回,而且把 index 往前移;全部類型都試過都沒法識別那麼就是語法錯誤,直接拋出異常。

具體每一個識別的函數其實就是正則表達式的使用,這裏就不詳細展開,有興趣能夠閱讀源碼 tokenizer.js

最後會把這樣的文章開頭的模板例子轉換成下面的 tokens stream:

{ 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: '$' }

4.4 語法分析與抽象語法樹

拿到 tokens 之後就能夠就能夠按順序讀取 token,根據模板的 LL(1) 文法進行語法分析。語法分析器,也就是 parser,通常能夠採起遞歸降低的方式來進行編寫。LL(1) 不容許語法中有衝突( conflicts ),須要對文法中的產生式求解 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}

上面只求出了一些必要的 FIRST 和 FOLLOW 集,對於一些不須要預測的產生式就省略求解了。有了 FIRST 和 FOLLOW 集,剩下的編寫遞歸降低的 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)
}

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
  }
}

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')
  }
}

// ...

完整的 parser 能夠查看 parser.js

抽象語法樹(Abstract Syntax Tree)

遞歸降低進行語法分析的時候,能夠同時構建模版語法的樹狀表示結構——抽象語法樹,模板語法有如下的抽象語法樹的節點類型:

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
}

由於 JavaScript 語法的靈活性,能夠用字面量的 JavaScript 對象和數組直接表示語法樹的樹狀結構。語法樹構的建過程能夠在語法分析階段同時進行。最後,能夠獲取到以下圖的語法樹結構:

ast2

完整的語法樹構建過程,能夠查看 parser.js

從模版字符串到 tokens stream 再到 AST ,這個過程只須要對文本進行一次掃描,整個算法的時間複雜度爲 O(n)。

至此,Virtual-Template 的編譯器前端已經完成了。

4.5 代碼生成

JavaScript 從字符串中構建一個新的函數能夠直接用 new Function 便可。例如:

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

這裏須要經過語法樹來還原 render 函數的函數體的內容,也就是 new Function 的第三個參數。

拿到模版語法的抽象語法樹之後,生成相應的 JavaScript 函數代碼就很好辦了。只須要地對生成的 AST 進行深度優先遍歷,遍歷的同時維護一個數組,這個數組保存着 render 函數的每一行的代碼:

function CodeGen (ast) {
  this.lines = []
  this.walk(ast)
  this.body = this.lines.join('\n')
}

var pp = CodeGen.prototype

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

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('}')
}

// ...

CodeGen 類接受已經生成的 AST 的根節點,而後 this.walk(ast) 會對不一樣的節點類型進行解析。例如對於 IfStat 類型的節點:

{ 
  type: 'IfStat',
  label: '{if user.isAdmin}'
  body: {...}
  elseifs: [{...}, {...}, {...}],
  elsebody: {...}
}

genIfStat 會把 '{if user.isAdmin}' 中的 user.isAdmin 抽離出來,而後拼接 JavaScript 的 if 語句,push 到 this.lines 中:

var expr = node.label.replace(/(^\{\s*if\s*)|(\s*\}$)/g, '')
this.lines.push('if (' + expr + ') {')

而後會遞歸的對 elseifselsebody 進行遍歷和解析,最後給 if 語句補上 }。因此若是 elseifselsebody 都不存在,this.lines 上就會有:

['if (user.isAdmin) {', <body>, '}']

其它的結構和 IfStat 同理的解析和拼接方式,例如 EachStat:

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('}')
}

最後遞歸構造完成之後,this.lines.join('\n') 就把整個函數的體構建起來:

if (user.isAdmin) {
...
}

for (var ...) {
...
}

這時候 render 函數的函數體就有了,直接經過 new Function 構建 render 函數:

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

el 是須要注入的構建 Virtual-DOM 的構建函數,data 須要渲染的數據狀態:

var svd = require('simple-virtual-dom')
var root = render(svd.el, {users: [{isAdmin: true}]})

從模版 -> Virtual-DOM 的 render 函數 -> Virtual-DOM 的過程就完成了。完整的代碼生成的過程能夠參考:codegen.js

5. 完整的 Virtual-Template

其實拿到 render 函數之後,每次手動進行 diff 和 patch 都是重複操做。能夠把 diff 和 patch 也封裝起來,只暴露一個 setData 的 API 。每次數據變動的時候,只須要 setData 就能夠更新到 DOM 元素上(就像 ReactJS 的 setState):

// 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'}
  ]
})

完整的 Virtual-Template 源碼託管在 github

6. 結語

這個過程其實和 ReactJS 的 JSX 差很少。就拿 Babel 的 JSX 語法實現而言,它的 parser 叫 babylon。而 babylon 基於一個叫 acorn 的 JavaScript 編寫的 JavaScript 解釋器和它的 JSX 插件 acorn-jsx。其實就是利用 acorn 把文本分割成 tokens,而 JSX 語法分析部分由 acorn-jsx 完成。

Virtual-Template 還不能應用於實際的生產環境,須要完善的東西還有不少。本文記錄基本的分析和實現的過程,也有助於更好地理解和學習 ReactJS 的實現。

(全文完)

相關文章
相關標籤/搜索