{"action":"created","issue":{"url":"https://api.github.com/repos/livoras/blog/issues/14","repository_url":"https://api.github.com/repos/livoras/blog","labels_url":"https://api.github.com/repos/livoras/blog/issues/14/labels{/name}","comments_url":"https://api.github.com/repos/livoras/blog/issues/14/comments","events_url":"https://api.github.com/repos/livoras/blog/issues/14/events","html_url":"https://github.com/livoras/blog/issues/14","id":131832024,"number":14,"title":"构建一个使用 Virtual-DOM 的前端模版引擎","user":{"login":"livoras","id":6436132,"avatar_url":"https://avatars.githubusercontent.com/u/6436132?v=3","gravatar_id":"","url":"https://api.github.com/users/livoras","html_url":"https://github.com/livoras","followers_url":"https://api.github.com/users/livoras/followers","following_url":"https://api.github.com/users/livoras/following{/other_user}","gists_url":"https://api.github.com/users/livoras/gists{/gist_id}","starred_url":"https://api.github.com/users/livoras/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/livoras/subscriptions","organizations_url":"https://api.github.com/users/livoras/orgs","repos_url":"https://api.github.com/users/livoras/repos","events_url":"https://api.github.com/users/livoras/events{/privacy}","received_events_url":"https://api.github.com/users/livoras/received_events","type":"User","site_admin":false},"labels":[{"url":"https://api.github.com/repos/livoras/blog/labels/reactjs","name":"reactjs","color":"5319e7"},{"url":"https://api.github.com/repos/livoras/blog/labels/template-engine","name":"template-engine","color":"bfe5bf"},{"url":"https://api.github.com/repos/livoras/blog/labels/virtual-dom","name":"virtual-dom","color":"009800"},{"url":"https://api.github.com/repos/livoras/blog/labels/web","name":"web","color":"006b75"}],"state":"open","locked":false,"assignee":null,"milestone":null,"comments":6,"created_at":"2016-02-06T07:26:33Z","updated_at":"2016-02-28T16:28:25Z","closed_at":null,"body":"作者：戴嘉华\r\n\r\n转载请注明出处并保留原文链接（ https://github.com/livoras/blog/issues/14 ）和作者信息。\r\n\r\n## 目录\r\n\r\n* 1. 前言\r\n* 2. 问题的提出\r\n* 3. 模板引擎和 Virtual-DOM 结合 —— Virtual-Template\r\n* 4. Virtual-Template 的实现\r\n  * 4.1 编译原理相关\r\n  * 4.2 模版引擎的EBNF\r\n  * 4.3 词法分析\r\n  * 4.4 语法分析与抽象语法树\r\n  * 4.5 代码生成\r\n* 5. 完整的 Virtual-Template\r\n* 6. 结语\r\n\r\n## 1. 前言\r\n\r\n本文尝试构建一个 Web 前端模板引擎，并且把这个引擎和 Virtual-DOM 进行结合。把传统模板引擎编译成 HTML 字符串的方式改进为编译成 Virtual-DOM 的 render 函数，可以有效地结合模板引擎的便利性和 Virtual-DOM 的性能。类似 ReactJS 中的 JSX。\r\n\r\n阅读本文需要一些关于 ReactJS 实现原理或者 Virtual-DOM 的相关知识，可以先阅读这篇博客：[深度剖析：如何实现一个 Virtual DOM 算法]( https://github.com/livoras/blog/issues/13) ， 进行相关知识的了解。\r\n\r\n同时还需要对编译原理相关知识有基本的了解，包括 EBNF，LL(1)，递归下降的方法等。\r\n\r\n## 2. 问题的提出\r\n\r\n本人在就职的公司维护一个比较朴素的系统，前端渲染有两种方式：\r\n\r\n1. 后台直接根据模板和数据直接把页面吐到前端。\r\n2. 后台只吐数据，前端用前端模板引擎渲染数据，动态塞到页面。\r\n\r\n当数据状态变更的时候，前端用 jQuery 修改页面元素状态，或者把局部界面用模板引擎重新渲染一遍。当页面状态很多的时候，用 jQuery 代码中会就混杂着很多的 DOM 操作，编码复杂且不便于维护；而重新渲染虽然省事，但是会导致一些性能、焦点消失的问题（具体可以看这篇[博客介绍](https://github.com/livoras/blog/issues/13)）。\r\n\r\n因为习惯了 MVVM 数据绑定的编码方式，对于用 jQuery 选择器修改 wordings 等细枝末节的劳力操作个人感觉不甚习惯。于是就构思能否在这种朴素的编码方式上做一些改进，解放双手，提升开发效率。其实只要加入数据状态 -> 视图的 one-way data-binding 开发效率就会有较大的提升。\r\n\r\n而这种已经在运作多年的多人维护系统，引入新的 MVVM 框架并不是一个非常好的选择，在兼容性和风险规避上大家都有诸多的考虑。于是就构思了一个方案，在前端模板引擎上做手脚。可以在几乎零学习成本的情况下，做到 one-way data-binding，大量减少 jQuery DOM 操作，提升开发效率。\r\n\r\n## 3. 模板引擎和 Virtual-DOM 结合 —— Virtual-Template \r\n考虑以下模板语法：\r\n\r\n```html\r\n<div>\r\n  <h1>{title}</h1>\r\n  <ul>\r\n    {each users as user i}\r\n    <li class=\"user-item\">\r\n      <img src=\"/avatars/{user.id}\" />\r\n      <span>NO.{i + 1} - {user.name}</span>\r\n      {if user.isAdmin}\r\n        I am admin\r\n      {elseif user.isAuthor}\r\n        I am author\r\n      {else}\r\n        I am nobody\r\n      {/if}\r\n    </li>\r\n    {/each}\r\n  </ul>\r\n</div>\r\n```\r\n\r\n这只一个普通的模板引擎语法（类似 artTemplate），支持循环语句（each）、条件语句（if elseif else ..）、和文本填充（{...}）， 应该比较容易看懂，这里就不解释。当用下面数据渲染该模板的时候：\r\n\r\n```javascript\r\nvar data = {\r\n  title: 'Users List',\r\n  users: [\r\n    {id: 'user0', name: 'Jerry', isAdmin: true},\r\n    {id: 'user1', name: 'Lucy', isAuthor: true},\r\n    {id: 'user2', name: 'Tomy'}\r\n  ]\r\n}\r\n```\r\n\r\n会得到下面的 HTML 字符串：\r\n\r\n```html\r\n<div>\r\n  <h1>Users List</h1>\r\n  <ul>\r\n    <li class=\"user-item\">\r\n       <img src=\"/avatars/user0\" />\r\n       <span>NO.1 - Jerry</span>\r\n\t   I am admin\r\n    </li>\r\n    <li class=\"user-item\">\r\n       <img src=\"/avatars/user1\" />\r\n       <span>NO.2 - Lucy</span>\r\n\t   I am author\r\n    </li>\r\n    <li class=\"user-item\">\r\n       <img src=\"/avatars/user2\" />\r\n       <span>NO.3 - Tomy</span>\r\n       I am nobody\r\n    </li>\r\n  </ul>\r\n</div>\r\n```\r\n\r\n把这个字符串塞入文档当中就可以生成 DOM 。但是问题是如果数据变更了，例如`data.title`由`Users List`修改成`Users`，你只能用 jQuery 修改 DOM 或者直接重新渲染一个新的字符串塞入文档当中。\r\n\r\n然而我们可以参考 ReactJS 的 JSX 的做法，不把模板编译成 HTML， 而是把模板编译成一个返回 Virtual-DOM 的 render 函数。render 函数会根据传入的 state 不同返回不一样的 Virtual-DOM ，然后就可以根据 Virtual-DOM 算法进行 diff 和 patch：\r\n\r\n```javascript\r\n// setup codes\r\n// ...\r\n\r\nvar render = template(tplString) // template 把模板编译成 render 函数而不是 HTML 字符串\r\nvar root1 = render(state1) // 根据初始状态返回的 virtual-dom\r\n\r\nvar dom = root.render() // 根据 virtual-dom 构建一个真正的 dom 元素\r\ndocument.body.appendChild(dom)\r\n\r\nvar root2 = render(state2) // 状态变更，重新渲染另外一个 virtual-dom\r\nvar patches = diff(root1, root2) // virtual-dom 的 diff 算法\r\npatch(dom, patches) // 更新真正的 dom 元素\r\n```\r\n\r\n这样做好处就是：既保留了原来模板引擎的语法，又结合了 Virtual-DOM 特性：当状态改变的时候不再需要 jQuery 了，而是跑一遍 Virtual-DOM 算法把真正的 DOM 给patch了，达到了 one-way data-binding 的效果，总结流程就是：\r\n\r\n1. 先把模板编译成一个 render 函数，这个函数会根据数据状态返回 Virtual-DOM\r\n2. 用 render 函数构建 Virtual-DOM；并根据这个 Virtual-DOM 构建真正的 DOM 元素，塞入文档当中 \r\n3. 当数据变更的时候，再用 render 函数渲染一个新的 Virtual-DOM\r\n4. 新旧的 Virtual-DOM 进行 diff，然后 patch 已经在文档中的 DOM 元素\r\n\r\n（恩，其实就是一个类似于 JSX 的东西）\r\n\r\n这里重点就是，如何能把模板语法编译成一个能够返回 Virtual-DOM 的 render 函数？例如上面的模板引擎，不再返回 HTML 字符串了，而是返回一个像下面那样的 render 函数：\r\n\r\n```javascript\r\nfunction render (state) {\r\n  return el('div', {}, [\r\n    el('h1', {}, [state.title]),\r\n    el('ul', {}, state.users.map(function (user, i) {\r\n       return el('li', {\"class\": \"user-item\"}, [\r\n         el('img', {\"src\": \"/avatars/\" + user.id}, []),\r\n         el('span', {}, ['No.' + (i + 1) + ' - ' + user.name],\r\n         (user.isAdmin \r\n\t       ? 'I am admin'\r\n\t       : uesr.isAuthor \r\n\t\t     ? 'I am author'\r\n\t\t     : '')\r\n       ])\r\n    }))\r\n  ])\r\n}\r\n```\r\n\r\n前面的模板和这个 render 函数在语义上是一样的，只要能够实现“模板 -> render 函数”这个转换，就可以跑上面所说的 Virtual-DOM 的算法流程，这样就把模板引擎和 Virtual-DOM结合起来。为了方便起见，这里把这个结合体称为 Virtual-Template 。\r\n\r\n## 4. Virtual-Template 的实现\r\n\r\n网上关于模板引擎的实现原理介绍非常多。如果语法不是太复杂的话，可以直接通过对语法标签和代码片段进行分割，识别语法标签内的内容（循环、条件语句）然后拼装代码，具体可以参考[这篇博客](http://www.cnblogs.com/hustskyking/p/principle-of-javascript-template.html)。其实就是正则表达式使用和字符串的操作，不需要对语法标签以外的内容做识别。\r\n\r\n但是对于和 HTML 语法已经差别较大的模板语法（例如 Jade ），单纯的正则和字符串操作已经不够用了，因为其语法标签以外的代码片段根本不是合法的 HTML 。这种情况下一般需要编译器相关知识发挥用途：模板引擎本质上就是把一种语言编译成另外一种语言。\r\n\r\n而对于 Virtual-Template 的情况，虽然其除了语法标签以外的代码都是合法的 HTML 字符串，但是我们的目的是把它编译成返回 Virtual-DOM 的 render 函数，在构建 Virtual-DOM 的时候，你需要知道元素的 tagName、属性等信息，所以就需要对 HTML 元素本身做识别。\r\n\r\n因此 Virtual-Template 也需要借助编译原理（编译器前端）相关的知识，把一种语言（模板语法）编译成另外一种语言（一个叫 render 的 JavaScript 函数）。\r\n\r\n### 4.1 编译原理相关\r\n\r\nCS 本科都教过编译原理，本文会用到编译器前端的一些概念。在实现模板到 render 函数的过程中，要经过几个步骤：\r\n\r\n1. 词法分析：把输入的模板分割成词法单元（tokens stream）\r\n2. 语法分析：读入 tokens stream ，根据文法规则转化成抽象语法树（Abstract Syntax Tree）\r\n3. 代码生成：遍历 AST，生成 render 函数体代码\r\n\r\n![ast](https://raw.githubusercontent.com/livoras/blog/gh-pages/virtual-template/ast.png)\r\n\r\n所以这个过程可以分成几个主要模块：tokenizer（词法分析器），parser（语法分析器），codegen（代码生成）。在此之前，还需要对模板的语法做文法定义，这是构建词法分析和语法分析的基础。\r\n\r\n### 4.2 模板引擎的 EBNF\r\n\r\n在计算机领域，对某种语言进行语法定义的时候，几乎都会用到 EBNF（扩展的巴科斯范式）。在定义模板引擎的语法的时候，也可以用到 EBNF。Virtual-Template 拥有非常简单的语法规则，支持上面所提到的 each、if 等语法：\r\n\r\n```\r\n{each users as user i }\r\n <div> {user.name} </div>\r\n ...\r\n{/each}\r\n\r\n{if user.isAdmin}\r\n ...\r\n{elseif user.isAuthor}\r\n ...\r\n{elseif user.isXXX}\r\n ...\r\n{/if}\r\n```\r\n\r\n对于 `{user.name}` 这样的表达式插入，可以简单地看成是字符串，在代码生成的时候再做处理。这样我们的词法和语法分析就会简化很多，基本只需要对 each、if、HTML 元素进行处理。\r\n\r\n**Virtual-Template 的 EBNF**：\r\n\r\n```\r\nStat -> Frag Stat | ε\r\nFrag -> IfStat | EachStat | Node | text\r\n\r\nIfStat -> '{if ...}' Stat {ElseIf} [Else] '{/if}'\r\nElseIf -> '{elseif ...}' Stat\r\nElse -> '{else}' Stat|e\r\n\r\nEachStat -> '{each ...}' Stat '{/each}'\r\n\r\nNode -> OpenTag NodeTail\r\nOpenTag -> '/[\\w\\-\\d]+/' {Attr}\r\nNodeTail -> '>' Stat '/\\<[\\w\\d]+\\>/' | '/>'\r\n\r\nAttr -> '/[\\w\\-\\d]/+' Value\r\nValue -> '=' '/\"[\\s\\S]+\"/' | ε\r\n```\r\n\r\n可以把该文法转换成 LL(1) 文法，方便我们写递归下降的 parser。这个语法还是比较简单的，没有出现复杂的左递归情况。简单进行展开和提取左公因子消除冲突获得下面的 LL(1) 文法。\r\n\r\n **LL(1) 文法：**\r\n\r\n```\r\nStat -> Frag Stat | ε\r\nFrag -> IfStat | EachStat | Node | text\r\n\r\nIfStat -> '{if ...}' Stat ElseIfs Else '{/if}'\r\nElseIfs -> ElseIf ElseIfs | ε\r\nElseIf -> '{elseif ...}' Stat\r\nElse -> '{else}' Stat | ε\r\n\r\nEachStat -> '{each ...}' Stat '{/each}'\r\n\r\nNode -> OpenTag NodeTail\r\nOpenTag -> '/[\\w\\-\\d]+/' Attrs\r\nNodeTail -> '>' Stat '/\\<[\\w\\d]+\\>/' | '/>'\r\n\r\nAttrs -> Attr Attrs | ε \r\nAttr -> '/[\\w\\-\\d]/+' Value\r\nValue -> '=' '/\"[\\s\\S]+\"/' | ε\r\n```\r\n\r\n### 4.3 词法分析\r\n\r\n根据上面获得的 EBNF ，单引号包含的都是非终结符，可以知道有以下几种词法单元：\r\n\r\n```javascript\r\nmodule.exports = {\r\n  TK_TEXT: 1, // 文本节点\r\n  TK_IF: 2, // {if ...}\r\n  TK_END_IF: 3, // {/if}\r\n  TK_ELSE_IF: 4, // {elseif ...}\r\n  TK_ELSE: 5, // {else}\r\n  TK_EACH: 6, // {each ...}\r\n  TK_END_EACH: 7, // {/each}\r\n  TK_GT: 8, // >\r\n  TK_SLASH_GT: 9, // />\r\n  TK_TAG_NAME: 10, // <div|<span|<img|...\r\n  TK_ATTR_NAME: 11, // 属性名\r\n  TK_ATTR_EQUAL: 12, // =\r\n  TK_ATTR_STRING: 13, // \"string\"\r\n  TK_CLOSE_TAG: 13, // </div>|</span>|</a>|...\r\n  TK_EOF: 100 // end of file\r\n}\r\n```\r\n使用 JavaScript 自带的正则表达式引擎编写 tokenizer 很方便，把输入的模板字符串从左到右进行扫描，按照上面的 token 的类型进行分割：\r\n\r\n```javascript\r\nfunction Tokenizer (input) {\r\n  this.input = input\r\n  this.index = 0\r\n  this.eof = false\r\n}\r\n\r\nvar pp = Tokenizer.prototype\r\n\r\npp.nextToken = function () {\r\n  this.eatSpaces()\r\n  return (\r\n    this.readCloseTag() ||\r\n    this.readTagName() ||\r\n    this.readAttrName() ||\r\n    this.readAttrEqual() ||\r\n    this.readAttrString() ||\r\n    this.readGT() ||\r\n    this.readSlashGT() ||\r\n    this.readIF() ||\r\n    this.readElseIf() ||\r\n    this.readElse() ||\r\n    this.readEndIf() ||\r\n    this.readEach() ||\r\n    this.readEndEach() ||\r\n    this.readText() ||\r\n    this.readEOF() ||\r\n    this.error()\r\n  )\r\n}\r\n\r\n// read token methods\r\n// ...\r\n```\r\n\r\nTokenizer 会存储一个 `index`，标记当前识别到哪个字符位置。每次调用 `nextToken` 会先跳过所有的空白字符，然后尝试某一种类型的 token ，识别失败就会尝试下一种，如果成功就直接返回，并且把 `index` 往前移；所有类型都试过都无法识别那么就是语法错误，直接抛出异常。\r\n\r\n\r\n具体每个识别的函数其实就是正则表达式的使用，这里就不详细展开，有兴趣可以阅读源码 [tokenizer.js](https://github.com/livoras/virtual-template/blob/dsl/src/tokenizer.js)\r\n\r\n最后会把这样的文章开头的模板例子转换成下面的 tokens stream：\r\n\r\n```javascript\r\n{ type: 10, label: 'div' }\r\n{ type: 8, label: '>' }\r\n{ type: 10, label: 'h1' }\r\n{ type: 8, label: '>' }\r\n{ type: 1, label: '{title}' }\r\n{ type: 13, label: '</h1>' }\r\n{ type: 10, label: 'ul' }\r\n{ type: 8, label: '>' }\r\n{ type: 6, label: '{each users as user i}' }\r\n{ type: 10, label: 'li' }\r\n{ type: 11, label: 'class' }\r\n{ type: 12, label: '=' }\r\n{ type: 13, label: 'user-item' }\r\n{ type: 8, label: '>' }\r\n{ type: 10, label: 'img' }\r\n{ type: 11, label: 'src' }\r\n{ type: 12, label: '=' }\r\n{ type: 13, label: '/avatars/{user.id}' }\r\n{ type: 9, label: '/>' }\r\n{ type: 10, label: 'span' }\r\n{ type: 8, label: '>' }\r\n{ type: 1, label: 'NO.' }\r\n{ type: 1, label: '{i + 1} - ' }\r\n{ type: 1, label: '{user.name}' }\r\n{ type: 13, label: '</span>' }\r\n{ type: 2, label: '{if user.isAdmin}' }\r\n{ type: 1, label: 'I am admin\\r\\n        ' }\r\n{ type: 4, label: '{elseif user.isAuthor}' }\r\n{ type: 1, label: 'I am author\\r\\n        ' }\r\n{ type: 5, label: '{else}' }\r\n{ type: 1, label: 'I am nobody\\r\\n        ' }\r\n{ type: 3, label: '{/if}' }\r\n{ type: 13, label: '</li>' }\r\n{ type: 7, label: '{/each}' }\r\n{ type: 13, label: '</ul>' }\r\n{ type: 13, label: '</div>' }\r\n{ type: 100, label: '$' }\r\n```\r\n\r\n### 4.4 语法分析与抽象语法树\r\n\r\n拿到 tokens 以后就可以就可以按顺序读取 token，根据模板的 LL(1) 文法进行语法分析。语法分析器，也就是 parser，一般可以采取**递归下降**的方式来进行编写。LL(1) 不允许语法中有冲突（ conflicts )，需要对文法中的产生式求解 FIRST 和 FOLLOW 集。\r\n\r\n```\r\nFIRST(Stat) = {TK_IF, TK_EACH, TK_TAG_NAME, TK_TEXT}\r\nFOLLOW(Stat) = {TK_ELSE_IF, TK_END_IF, TK_ELSE, TK_END_EACH, TK_CLOSE_TAG, TK_EOF}\r\nFIRST(Frag) = {TK_IF, TK_EACH, TK_TAG_NAME, TK_TEXT}\r\nFIRST(IfStat) = {TK_IF}\r\nFIRST(ElseIfs) = {TK_ELSE_IF}\r\nFOLLOW(ElseIfs) = {TK_ELSE, TK_ELSE}\r\nFIRST(ElseIf) = {TK_ELSE_IF}\r\nFIRST(Else) = {TK_ELSE}\r\nFOLLOW(Else) = {TK_END_IF}\r\nFIRST(EachStat) = {TK_EACH}\r\nFIRST(OpenTag) = {TK_TAG_NAME}\r\nFIRST(NodeTail) = {TK_GT, TK_SLASH_GT}\r\nFIRST(Attrs) = {TK_ATTR_NAME}\r\nFOLLOW(Attrs) = {TK_GT, TK_SLASH_GT}\r\nFIRST(Value) = {TK_ATTR_EQUAL}\r\nFOLLOW(Value) = {TK_ATTR_NAME, TK_GT, TK_SLASH_GT}\r\n```\r\n\r\n上面只求出了一些必要的 FIRST 和 FOLLOW 集，对于一些不需要预测的产生式就省略求解了。有了 FIRST 和 FOLLOW 集，剩下的编写递归下降的 parser 只是填空式的体力活。\r\n\r\n```javascript\r\nvar Tokenizer = require('./tokenizer')\r\nvar types = require('./tokentypes')\r\n\r\nfunction Parser (input) {\r\n  this.tokens = new Tokenizer(input)\r\n  this.parse()\r\n}\r\n\r\nvar pp = Parser.prototype\r\n\r\npp.is = function (type) {\r\n  return (this.tokens.peekToken().type === type)\r\n}\r\n\r\npp.parse = function () {\r\n  this.tokens.index = 0\r\n  this.parseStat()\r\n  this.eat(types.TK_EOF)\r\n}\r\n\r\npp.parseStat = function () {\r\n  if (\r\n    this.is(types.TK_IF) ||\r\n    this.is(types.TK_EACH) ||\r\n    this.is(types.TK_TAG_NAME) ||\r\n    this.is(types.TK_TEXT)\r\n  ) {\r\n    this.parseFrag()\r\n    this.parseStat()\r\n  } else {\r\n    // end\r\n  }\r\n}\r\n\r\npp.parseFrag = function () {\r\n  if (this.is(types.TK_IF)) return this.parseIfStat()\r\n  else if (this.is(types.TK_EACH)) return this.parseEachStat()\r\n  else if (this.is(types.TK_TAG_NAME)) return this.parseNode()\r\n  else if (this.is(types.TK_TEXT)) {\r\n    var token = this.eat(types.TK_TEXT)\r\n    return token.label\r\n  } else {\r\n    this.parseError('parseFrag')\r\n  }\r\n}\r\n\r\n// ...\r\n```\r\n\r\n完整的 parser 可以查看 [parser.js](https://github.com/livoras/virtual-template/blob/dsl/src/parser.js)。\r\n\r\n#### 抽象语法树（Abstract Syntax Tree）\r\n\r\n递归下降进行语法分析的时候，可以同时构建模版语法的树状表示结构——抽象语法树，模板语法有以下的抽象语法树的节点类型：\r\n\r\n```\r\nStat: {\r\n    type: 'Stat'\r\n    members: [IfStat | EachStat | Node | text, ...]\r\n}\r\n\r\nIfStat: {\r\n    type: 'IfStat'\r\n    label: <string>,\r\n    body: Stat\r\n    elifs: [ElseIf, ...]\r\n    elsebody: Stat\r\n}\r\n\r\nElseIf: {\r\n    type: 'ElseIf'\r\n    label: <string>,\r\n    body: Stat\r\n}\r\n\r\nEachStat: {\r\n    type: 'EachStat'\r\n    label: <string>,\r\n    body: Stat\r\n}\r\n\r\nNode: {\r\n    type: 'Node'\r\n    name: <string>,\r\n    attributes: <object>,\r\n    body: Stat\r\n}\r\n```\r\n\r\n因为 JavaScript 语法的灵活性，可以用字面量的 JavaScript 对象和数组直接表示语法树的树状结构。语法树构的建过程可以在语法分析阶段同时进行。最后，可以获取到如下图的语法树结构：\r\n\r\n![ast2](https://raw.githubusercontent.com/livoras/blog/gh-pages/virtual-template/ast3.png)\r\n\r\n完整的语法树构建过程，可以查看 [parser.js](https://github.com/livoras/virtual-template/blob/dsl/src/parser.js) 。\r\n\r\n从模版字符串到 tokens stream 再到 AST ，这个过程只需要对文本进行一次扫描，整个算法的时间复杂度为 O(n)。\r\n\r\n至此，Virtual-Template 的编译器前端已经完成了。\r\n\r\n### 4.5 代码生成\r\n\r\nJavaScript 从字符串中构建一个新的函数可以直接用 `new Function` 即可。例如：\r\n\r\n```javascript\r\nvar newFunc = new Function('a', 'b', 'return a + b')\r\nnewFunc(1, 2) // => 3\r\n```\r\n\r\n这里需要通过语法树来还原 render 函数的函数体的内容，也就是 `new Function` 的第三个参数。\r\n\r\n拿到模版语法的抽象语法树以后，生成相应的 JavaScript 函数代码就很好办了。只需要地对生成的 AST 进行深度优先遍历，遍历的同时维护一个数组，这个数组保存着 render 函数的每一行的代码：\r\n\r\n```javascript\r\nfunction CodeGen (ast) {\r\n  this.lines = []\r\n  this.walk(ast)\r\n  this.body = this.lines.join('\\n')\r\n}\r\n\r\nvar pp = CodeGen.prototype\r\n\r\npp.walk = function (node) {\r\n  if (node.type === 'IfStat') {\r\n    this.genIfStat(node)\r\n  } else if (node.type === 'Stat') {\r\n    this.genStat(node)\r\n  } else if (node.type === 'EachStat') {\r\n    ...\r\n  }\r\n  ...\r\n}\r\n\r\npp.genIfStat = function (node) {\r\n  var expr = node.label.replace(/(^\\{\\s*if\\s*)|(\\s*\\}$)/g, '')\r\n  this.lines.push('if (' + expr + ') {')\r\n  if (node.body) {\r\n    this.walk(node.body)\r\n  }\r\n  if (node.elseifs) {\r\n    var self = this\r\n    _.each(node.elseifs, function (elseif) {\r\n      self.walk(elseif)\r\n    })\r\n  }\r\n  if (node.elsebody) {\r\n    this.lines.push(indent + '} else {')\r\n    this.walk(node.elsebody)\r\n  }\r\n  this.lines.push('}')\r\n}\r\n\r\n// ...\r\n```\r\n\r\n`CodeGen` 类接受已经生成的 AST 的根节点，然后 `this.walk(ast)` 会对不同的节点类型进行解析。例如对于 `IfStat` 类型的节点：\r\n\r\n```javascript\r\n{ \r\n  type: 'IfStat',\r\n  label: '{if user.isAdmin}'\r\n  body: {...}\r\n  elseifs: [{...}, {...}, {...}],\r\n  elsebody: {...}\r\n}\r\n```\r\n\r\n`genIfStat` 会把 `'{if user.isAdmin}'` 中的 `user.isAdmin` 抽离出来，然后拼接 JavaScript 的 if 语句，push 到 `this.lines` 中：\r\n\r\n```javascript\r\nvar expr = node.label.replace(/(^\\{\\s*if\\s*)|(\\s*\\}$)/g, '')\r\nthis.lines.push('if (' + expr + ') {')\r\n```\r\n\r\n然后会递归的对 `elseifs` 和 `elsebody` 进行遍历和解析，最后给 `if` 语句补上 `}`。所以如果 `elseifs` 和 `elsebody` 都不存在，`this.lines` 上就会有：\r\n\r\n```javascript\r\n['if (user.isAdmin) {', <body>, '}']\r\n```\r\n\r\n\r\n其它的结构和 `IfStat` 同理的解析和拼接方式，例如 `EachStat`:\r\n\r\n```javascript\r\npp.genEachStat = function (node) {\r\n  var expr = node.label.replace(/(^\\{\\s*each\\s*)|(\\s*\\}$)/g, '')\r\n  var tokens = expr.split(/\\s+/)\r\n  var list = tokens[0]\r\n  var item = tokens[2]\r\n  var key = tokens[3]\r\n  this.lines.push(\r\n    'for (var ' + key + ' = 0, len = ' + list + '.length; ' + key + ' < len; ' + key + '++) {'\r\n  )\r\n  this.lines.push('var ' + item + ' = ' + list + '[' + key + '];')\r\n  if (node.body) {\r\n    this.walk(node.body)\r\n  }\r\n  this.lines.push('}')\r\n}\r\n```\r\n\r\n最后递归构造完成以后，`this.lines.join('\\n')` 就把整个函数的体构建起来：\r\n\r\n```javascript\r\nif (user.isAdmin) {\r\n...\r\n}\r\n\r\nfor (var ...) {\r\n...\r\n}\r\n```\r\n\r\n\r\n这时候 render 函数的函数体就有了，直接通过 `new Function` 构建 render 函数：\r\n\r\n```javascript\r\nvar code = new CodeGen(ast)\r\nvar render = new Function('el', 'data', code.body)\r\n```\r\n\r\n`el` 是需要注入的构建 Virtual-DOM 的构建函数，`data` 需要渲染的数据状态：\r\n\r\n```javascript\r\nvar svd = require('simple-virtual-dom')\r\nvar root = render(svd.el, {users: [{isAdmin: true}]})\r\n```\r\n\r\n从模版 -> Virtual-DOM 的 render 函数 -> Virtual-DOM 的过程就完成了。完整的代码生成的过程可以参考：[codegen.js](https://github.com/livoras/virtual-template/blob/dsl/src/codegen.js)\r\n\r\n## 5. 完整的 Virtual-Template\r\n\r\n其实拿到 render 函数以后，每次手动进行 diff 和 patch 都是重复操作。可以把 diff 和 patch 也封装起来，只暴露一个 `setData` 的 API 。每次数据变更的时候，只需要 `setData` 就可以更新到 DOM 元素上（就像 ReactJS 的 `setState`）：\r\n\r\n\r\n```javascript\r\n// vTemplate.compile 编译模版字符串，返回一个函数\r\nvar usersListTpl = vTemplate.compile(tplStr)\r\n\r\n// userListTpl 传入初始数据状态，返回一个实例\r\nvar usersList = usersListTpl({\r\n  title: 'Users List',\r\n  users: [\r\n    {id: 'user0', name: 'Jerry', isAdmin: true},\r\n    {id: 'user1', name: 'Lucy', isAuthor: true},\r\n    {id: 'user2', name: 'Tomy'}\r\n  ]\r\n})\r\n\r\n// 返回的实例有 dom 元素和一个 setData 的 API\r\ndocument.appendChild(usersList.dom)\r\n\r\n// 需要变更数据的时候，setData 一下即可\r\nusersList.setData({\r\n  title: 'Users',\r\n  users: [\r\n    {id: 'user1', name: 'Lucy', isAuthor: true},\r\n    {id: 'user2', name: 'Tomy'}\r\n  ]\r\n})\r\n```\r\n\r\n完整的 Virtual-Template 源码托管在 [github](https://github.com/livoras/virtual-template) 。\r\n\r\n## 6. 结语\r\n\r\n这个过程其实和 ReactJS 的 JSX 差不多。就拿 Babel 的 JSX 语法实现而言，它的 parser 叫 [babylon](https://github.com/babel/babel/tree/master/packages/babylon)。而 babylon 基于一个叫 [acorn](https://github.com/marijnh/acorn) 的 JavaScript 编写的 JavaScript 解释器和它的 JSX 插件 [acorn-jsx](https://github.com/RReverser/acorn-jsx)。其实就是利用 acorn 把文本分割成 tokens，而 JSX 语法分析部分由 acorn-jsx 完成。\r\n\r\nVirtual-Template 还不能应用于实际的生产环境，需要完善的东西还有很多。本文记录基本的分析和实现的过程，也有助于更好地理解和学习 ReactJS 的实现。\r\n\r\n（全文完）"},"comment":{"url":"https://api.github.com/repos/livoras/blog/issues/comments/189899369","html_url":"https://github.com/livoras/blog/issues/14#issuecomment-189899369","issue_url":"https://api.github.com/repos/livoras/blog/issues/14","id":189899369,"user":{"login":"Galen-Yip","id":8219248,"avatar_url":"https://avatars.githubusercontent.com/u/8219248?v=3","gravatar_id":"","url":"https://api.github.com/users/Galen-Yip","html_url":"https://github.com/Galen-Yip","followers_url":"https://api.github.com/users/Galen-Yip/followers","following_url":"https://api.github.com/users/Galen-Yip/following{/other_user}","gists_url":"https://api.github.com/users/Galen-Yip/gists{/gist_id}","starred_url":"https://api.github.com/users/Galen-Yip/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Galen-Yip/subscriptions","organizations_url":"https://api.github.com/users/Galen-Yip/orgs","repos_url":"https://api.github.com/users/Galen-Yip/repos","events_url":"https://api.github.com/users/Galen-Yip/events{/privacy}","received_events_url":"https://api.github.com/users/Galen-Yip/received_events","type":"User","site_admin":false},"created_at":"2016-02-28T16:28:25Z","updated_at":"2016-02-28T16:28:25Z","body":"@livoras 比较好奇 tokenizer.js中nextToken return出来的readCloseTag这些方法的顺序是怎么确定的？"}}