Open livoras opened 8 years ago
写的很不错! 👍~ 赞
👍
👍
:+1:
@chuyik 我最近也在使用riotjs用来写了一个spa的电商网站。只使用了一个ajax库和riotjs库,整个项目做完js只有60多k(这些js包含了两个库还有编译好的tag),性能不错。
@livoras 比较好奇 tokenizer.js中nextToken return出来的readCloseTag这些方法的顺序是怎么确定的?
@Galen-Yip 因为 readTagName 写得比较偷懒,所以稍微调了一下顺序。实际上,除非出现了一种 token 是另外一种 token 的前缀的时候需要特别考虑顺序,否则顺序是并不大重要的
@livoras 我是考虑到 比如我的text是 '<html xxxxx' 这时候就直接被认为是readTagName了,但事实却是text
@Galen-Yip 是的,这是一个bug。所以用现成的 HTML 的 parser 会更实际一点..
@livoras 有啥现成的推举吗,现有的比较多的都只是单纯的html parser,而不是template parser
@Galen-Yip https://github.com/inikulin/parse5 parse5
很多 parser 都支持自定义 token 和语法树遍历,都很方便
赞!
很赞的文章,讲得也很详细
找有关的virtual dom的资料找到这里来了,赞,发现是同学校的师兄。哈哈,太巧了~~~~
👍
👍
不错,我也来研究一下
马克一下,正在看相关内容
感谢。学习了。
学习学习
赞学习了!!
不错,不错!!
楼主都不再更新博客了吗,好可惜...
不错,学习了。
老哥,稳 @livoras # 👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍
作者:戴嘉华
转载请注明出处并保留原文链接( https://github.com/livoras/blog/issues/14 )和作者信息。
目录
1. 前言
本文尝试构建一个 Web 前端模板引擎,并且把这个引擎和 Virtual-DOM 进行结合。把传统模板引擎编译成 HTML 字符串的方式改进为编译成 Virtual-DOM 的 render 函数,可以有效地结合模板引擎的便利性和 Virtual-DOM 的性能。类似 ReactJS 中的 JSX。
阅读本文需要一些关于 ReactJS 实现原理或者 Virtual-DOM 的相关知识,可以先阅读这篇博客:深度剖析:如何实现一个 Virtual DOM 算法 , 进行相关知识的了解。
同时还需要对编译原理相关知识有基本的了解,包括 EBNF,LL(1),递归下降的方法等。
2. 问题的提出
本人在就职的公司维护一个比较朴素的系统,前端渲染有两种方式:
当数据状态变更的时候,前端用 jQuery 修改页面元素状态,或者把局部界面用模板引擎重新渲染一遍。当页面状态很多的时候,用 jQuery 代码中会就混杂着很多的 DOM 操作,编码复杂且不便于维护;而重新渲染虽然省事,但是会导致一些性能、焦点消失的问题(具体可以看这篇博客介绍)。
因为习惯了 MVVM 数据绑定的编码方式,对于用 jQuery 选择器修改 wordings 等细枝末节的劳力操作个人感觉不甚习惯。于是就构思能否在这种朴素的编码方式上做一些改进,解放双手,提升开发效率。其实只要加入数据状态 -> 视图的 one-way data-binding 开发效率就会有较大的提升。
而这种已经在运作多年的多人维护系统,引入新的 MVVM 框架并不是一个非常好的选择,在兼容性和风险规避上大家都有诸多的考虑。于是就构思了一个方案,在前端模板引擎上做手脚。可以在几乎零学习成本的情况下,做到 one-way data-binding,大量减少 jQuery DOM 操作,提升开发效率。
3. 模板引擎和 Virtual-DOM 结合 —— Virtual-Template
考虑以下模板语法:
这只一个普通的模板引擎语法(类似 artTemplate),支持循环语句(each)、条件语句(if elseif else ..)、和文本填充({...}), 应该比较容易看懂,这里就不解释。当用下面数据渲染该模板的时候:
会得到下面的 HTML 字符串:
把这个字符串塞入文档当中就可以生成 DOM 。但是问题是如果数据变更了,例如
data.title
由Users List
修改成Users
,你只能用 jQuery 修改 DOM 或者直接重新渲染一个新的字符串塞入文档当中。然而我们可以参考 ReactJS 的 JSX 的做法,不把模板编译成 HTML, 而是把模板编译成一个返回 Virtual-DOM 的 render 函数。render 函数会根据传入的 state 不同返回不一样的 Virtual-DOM ,然后就可以根据 Virtual-DOM 算法进行 diff 和 patch:
这样做好处就是:既保留了原来模板引擎的语法,又结合了 Virtual-DOM 特性:当状态改变的时候不再需要 jQuery 了,而是跑一遍 Virtual-DOM 算法把真正的 DOM 给patch了,达到了 one-way data-binding 的效果,总结流程就是:
(恩,其实就是一个类似于 JSX 的东西)
这里重点就是,如何能把模板语法编译成一个能够返回 Virtual-DOM 的 render 函数?例如上面的模板引擎,不再返回 HTML 字符串了,而是返回一个像下面那样的 render 函数:
前面的模板和这个 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 函数的过程中,要经过几个步骤:
所以这个过程可以分成几个主要模块:tokenizer(词法分析器),parser(语法分析器),codegen(代码生成)。在此之前,还需要对模板的语法做文法定义,这是构建词法分析和语法分析的基础。
4.2 模板引擎的 EBNF
在计算机领域,对某种语言进行语法定义的时候,几乎都会用到 EBNF(扩展的巴科斯范式)。在定义模板引擎的语法的时候,也可以用到 EBNF。Virtual-Template 拥有非常简单的语法规则,支持上面所提到的 each、if 等语法:
对于
{user.name}
这样的表达式插入,可以简单地看成是字符串,在代码生成的时候再做处理。这样我们的词法和语法分析就会简化很多,基本只需要对 each、if、HTML 元素进行处理。Virtual-Template 的 EBNF:
可以把该文法转换成 LL(1) 文法,方便我们写递归下降的 parser。这个语法还是比较简单的,没有出现复杂的左递归情况。简单进行展开和提取左公因子消除冲突获得下面的 LL(1) 文法。
LL(1) 文法:
4.3 词法分析
根据上面获得的 EBNF ,单引号包含的都是非终结符,可以知道有以下几种词法单元:
使用 JavaScript 自带的正则表达式引擎编写 tokenizer 很方便,把输入的模板字符串从左到右进行扫描,按照上面的 token 的类型进行分割:
Tokenizer 会存储一个
index
,标记当前识别到哪个字符位置。每次调用nextToken
会先跳过所有的空白字符,然后尝试某一种类型的 token ,识别失败就会尝试下一种,如果成功就直接返回,并且把index
往前移;所有类型都试过都无法识别那么就是语法错误,直接抛出异常。具体每个识别的函数其实就是正则表达式的使用,这里就不详细展开,有兴趣可以阅读源码 tokenizer.js
最后会把这样的文章开头的模板例子转换成下面的 tokens stream:
4.4 语法分析与抽象语法树
拿到 tokens 以后就可以就可以按顺序读取 token,根据模板的 LL(1) 文法进行语法分析。语法分析器,也就是 parser,一般可以采取递归下降的方式来进行编写。LL(1) 不允许语法中有冲突( conflicts ),需要对文法中的产生式求解 FIRST 和 FOLLOW 集。
上面只求出了一些必要的 FIRST 和 FOLLOW 集,对于一些不需要预测的产生式就省略求解了。有了 FIRST 和 FOLLOW 集,剩下的编写递归下降的 parser 只是填空式的体力活。
完整的 parser 可以查看 parser.js。
抽象语法树(Abstract Syntax Tree)
递归下降进行语法分析的时候,可以同时构建模版语法的树状表示结构——抽象语法树,模板语法有以下的抽象语法树的节点类型:
因为 JavaScript 语法的灵活性,可以用字面量的 JavaScript 对象和数组直接表示语法树的树状结构。语法树构的建过程可以在语法分析阶段同时进行。最后,可以获取到如下图的语法树结构:
完整的语法树构建过程,可以查看 parser.js 。
从模版字符串到 tokens stream 再到 AST ,这个过程只需要对文本进行一次扫描,整个算法的时间复杂度为 O(n)。
至此,Virtual-Template 的编译器前端已经完成了。
4.5 代码生成
JavaScript 从字符串中构建一个新的函数可以直接用
new Function
即可。例如:这里需要通过语法树来还原 render 函数的函数体的内容,也就是
new Function
的第三个参数。拿到模版语法的抽象语法树以后,生成相应的 JavaScript 函数代码就很好办了。只需要地对生成的 AST 进行深度优先遍历,遍历的同时维护一个数组,这个数组保存着 render 函数的每一行的代码:
CodeGen
类接受已经生成的 AST 的根节点,然后this.walk(ast)
会对不同的节点类型进行解析。例如对于IfStat
类型的节点:genIfStat
会把'{if user.isAdmin}'
中的user.isAdmin
抽离出来,然后拼接 JavaScript 的 if 语句,push 到this.lines
中:然后会递归的对
elseifs
和elsebody
进行遍历和解析,最后给if
语句补上}
。所以如果elseifs
和elsebody
都不存在,this.lines
上就会有:其它的结构和
IfStat
同理的解析和拼接方式,例如EachStat
:最后递归构造完成以后,
this.lines.join('\n')
就把整个函数的体构建起来:这时候 render 函数的函数体就有了,直接通过
new Function
构建 render 函数:el
是需要注入的构建 Virtual-DOM 的构建函数,data
需要渲染的数据状态:从模版 -> Virtual-DOM 的 render 函数 -> Virtual-DOM 的过程就完成了。完整的代码生成的过程可以参考:codegen.js
5. 完整的 Virtual-Template
其实拿到 render 函数以后,每次手动进行 diff 和 patch 都是重复操作。可以把 diff 和 patch 也封装起来,只暴露一个
setData
的 API 。每次数据变更的时候,只需要setData
就可以更新到 DOM 元素上(就像 ReactJS 的setState
):完整的 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 的实现。
(全文完)