假造 DOM 险些已经是当代 JS 框架的标配了。那末该怎样将 HTML 字符串编译为假造 DOM 呢?如许的编译器并不是什么黑科技,这里只用了不到 50 行 JS 就完成了一个。
Demo
在 HTML Toy Parser Demo 中,可以将输入的 HTML 字符串编译成假造 DOM 并衬着在页面上。这个玩具项目的源码在 Github 上。
作为一个玩具编译器,它还不能支撑一些罕见的 HTML 花样,如相似 <h2>123<small>456</small></h2>
如许将值和标签夹杂的写法。不过,这个玩具是能完美地剖析多个并列标签或深层嵌套标签的。下面分享一下怎样从头最先搭建出如许一个简朴的编译器。
编译器 101
编译器和诠释器差别的处所在于,编译器是将一种编程言语的代码编译为另一种(比方将高等言语编译为机器言语),而诠释器则是将一种编程言语的代码逐条诠释实行(比方实行种种脚本言语)。编译器并不须要实行编译取得的代码(如 gcc xxx.c
今后是经由历程 OS 来实行编译取得的 x86 机器码)而诠释器是直接实行言语代码(如种种脚本言语都须要经由历程诸如 python xxx.py
或 node xxx.js
的体式格局来实行)。
所以,将 HTML 字符串转换为 DOM 对象的顺序就是一个编译器(虽然异常大略)。根据典范的教科书,平常一个完全的编译历程由三步构成:词法剖析、语法剖析和语义剖析。这三个流程各对应一个模块:词法剖析器、语法剖析器和语义盘算模块。
以 <p>123</p>
这段字符串为例,对它的编译历程,起首始于相似【分词】操纵的词法剖析。这个历程就是输入一段字符串,输出 <p>
/ 123
/ </p>
三个词法 Token 的历程。这些 Token 都有各自的属性(或范例),比方 <p>
是一个最先标签、而 </p>
是一个完毕标签等。
词法剖析器输入的这些 Token 被输入语法剖析器中举行语法剖析。语法剖析,实在就是将输入的一连串 Token 数组构建为一棵笼统语法树(AST)的历程。比方,相似 <h2><small>123</small></h2>
如许嵌套的标签,剖析成语法树后,<small>
就是 <h2>
的子节点。而相似 <div>123</div> <a>456</a>
如许并列的标签则是语法树中的兄弟节点。构建好这棵语法树后,就可以举行语义盘算了。
末了的语义盘算历程就是遍历语法树的历程。比方在遍历一棵假造 DOM 语法树的历程当中,可以将每一个语法树上的节点都衬着为实在的 DOM 节点,从而将假造 DOM 绑定到实在 DOM,如许就完成了完全的从 HTML 字符串编译到 DOM 元素的流程。
词法剖析
这里的词法剖析器 Lexer 就是一个切分 HTML 字符串的东西。在最简化的情况下,HTML 字符串所包括的内容可以分为这三种:
肇端标签,如
<body>
/<div>
/<span>
等标签内容,如
123
/abc
/!@#$%
等完毕标签,如
</body>
/</div>
/</span>
等
一个学术上严谨的词法剖析器,须要用有限状态机来将文本切分红以上的三种范例。这里为了简朴起见,使用了用正则表达式来切分文本。算法很简朴:
从字符串开首最先,起首婚配一个完毕标签 Token
假如没有婚配到完毕标签,那末从字符串开首最先婚配一个最先标签 Token
假如照样没有婚配到最先标签,那末婚配一段标签值 Token
每次婚配到一个 Token,都记录下这个 Token 的范例和文本
将 Token 的 HTML 字符串去撤除,回到步骤 1 直到切完字符串为止
词法剖析完成后,所取得的 Token 数组内容大抵以下:
tokens = [
{ type: 'TagOpen', val: '<p>' },
{ type: 'Value', val: 'hello' },
{ type: 'TagClose', val: '</p>' },
{ type: 'TagOpen', val: '<div>' },
{ type: 'TagOpen', val: '<h2>' },
{ type: 'TagOpen', val: '<small>' },
{ type: 'Value', val: 'world' },
{ type: 'TagClose', val: '</small>' }
// ...
]
语法剖析
语法剖析是将上面取得的 tokens
数组组织为一棵语法树的历程,完成语法剖析器 Parser 也是完成简朴编译器时的难点。Parser 的算法有自顶向下(LL)和自底向上(LR)之分,对照议论临时略过,下面引见这个简朴编译器的 Parser 完成:
起首,词法剖析中取得的 Tokens 所取得的 TagOpen
/ Value
/ TagClose
这三种范例,在语法树中的位置是有区分的。比方,只要 Value
能成为恭弘=叶 恭弘子节点,而 TagOpen
和 TagClose
这两种范例只能用来包裹出一个 HTML 标签 Tag
范例。而一个或多个 Tag
范例又可以构成 Tags
范例。而一棵语法树的根节点则是一个只要一个 Tags
子节点的 Html
范例。
如今我们有了五种范例:即 TagOpen
/ Value
/ TagClose
/ Tag
/ Tags
。这五种范例中,前三种是从词法剖析直接取得的,称他们为【终止符】,然后两种为构建语法树历程当中的 “笼统” 范例,称它们为【非终止符】
这个 Parser 采纳了最简朴的递归下落算法来剖析 Tokens 数组。递归下落的历程是如许的:
起首从语法树顶部的根节点最先,向前【婚配非终止符】。每一个【婚配非终止符】的历程,都是挪用一个函数的历程。比方婚配
Tag
须要挪用tag()
函数,婚配Tags
须要挪用tags()
函数等每一个非终止符的函数中,都根据这个非终止符的语法结构,顺次婚配种种终止符或非终止符。比方
tag()
函数须要顺次婚配TagOpen
–Value
–TagClose
三个终止符,或许TagOpen
–Tag
–TagClose
如许两个终止符和一个非终止符。假如在tag()
函数中碰到了又须要婚配Tag
的状况(这就是 HTML 标签嵌套的情况)时,就须要再次挪用tag()
函数来向下婚配一个新的Tag
,这也就是所谓的递归下落了。当一切的 Token 都被吃入并婚配后,完成婚配。
教科书级的代码示例是如许的(然则这不是伪代码,是可以现实实行语法剖析的):
// 简化的 parser.js
// tokens 为输入的词法 Token 数组
// currIndex 为当前语法剖析历程所婚配到的下标,只会逐一向前递增,不回退
// lookahead 为当前语法剖析碰到的 Token,即 tokens[currIndex]
var tokens, currIndex, lookahead
// 返回下一个 token 并将下标前移一名
function nextToken() {
return tokens[++currIndex]
}
// 根据所需婚配的终止符范例,婚配下一个终止符
// 若下一个终止符和须要婚配的范例不一向,则申明代码中存在语法错误
// 如在剖析 <a> 123 <a> 这三个 Token 时,末了须要 match('TagClose')
// 但此时末了一个 Token 范例为 TagOpen,这时刻就会抛出语法错误
function match(terminalType) {
if (lookahead && terminalType === lookahead.type) lookahead = nextToken()
else throw 'SyntaxError'
}
// LL 中的函数均是用于婚配非终止符的函数
// 假如有更庞杂的非终止符,在此增加它们所对应的函数即可
const LL = {
// 婚配 Html 范例非终止符的函数
html() {
// 当存在 lookahead 时,不断向前婚配 Tag 标签
while (lookahead) LL.tag()
// 当完成对一切 Token 的婚配后,lookahead 为越界的 undefined
// 这时刻退出轮回,在此完毕语法剖析历程
console.log('parse complete!')
},
// 婚配 Tag 范例非终止符的函数
tag() {
// HTML 标签的第一个 Token 一定是 TagOpen 范例
match('TagOpen')
// 婚配完成 TagOpen 后,能够须要婚配一个嵌套的标签
// 也能够须要婚配一个标签的 Value
// 这时刻候就须要经由历程向前看标记 lookahead 来推断怎样婚配
// 若须要婚配嵌套的标签,那末下一个标记必定是 TagOpen 范例
lookahead.type == 'TagOpen' ? LL.tag() : match('Value')
// 末了婚配一个完毕标签,即 TagClose 范例的 Token
match('TagClose')
// 实行到这里时,就完成了对一个 HTML 标签的语法剖析
console.log('tag matched')
}
}
export default {
parse(inputTokens) {
// 初始化各变量
tokens = inputTokens, currIndex = 0, lookahead = tokens[currIndex]
// 最先语法剖析,目的是将 Tokens 剖析为一悉数 HTML 范例
LL.html()
}
}
语义剖析
上面的语法剖析历程当中,并没有显式构建一棵语法树的代码。现实上,语法树是在 LL
中各个婚配非终止符的函数的相互挪用中,隐式地构建出来的。要将这棵语法树转换为假造 DOM,只须要在 tag()
和 html()
等相互挪用的函数中传入参数即可。
比方将 tag()
函数署名修正成以下的情势,即可完成
tag(currNode) {
match('TagOpen')
// 在碰到嵌套标签的状况时,递归向下剖析
if (lookahead.type == 'TagOpen') {
// 将当前节点作为参数,挪用 tags 婚配掉嵌套的标签
// 将会返回挂载完成了一切子节点的当前节点
currNode = NT.tags(currNode)
} else {
// 当前标签是一个恭弘=叶 恭弘子节点,这时刻直接修正当前节点的值
// 这时刻 lookahead 指向的已经是一个 Value 范例的 Token 了
currNode.val = lookahead.val
// 婚配掉这个 Value 范例,
match('Value')
// 这时刻的 lookahead 指向 TagClose 范例
}
match('TagClose')
// 末了返回盘算完成的节点给上层
return currNode
}
所以,这类语法剖析体式格局下,语义盘算的完全代码现实上耦合在了语法剖析器中。末了 html()
函数返回的效果,就是一棵假造 DOM 语法树了。
要将取得的假造 DOM 衬着为实在 DOM,是异常轻易的。只须要深度遍历这棵假造 DOM 树,将每一个节点经由历程 API 插进去 DOM 中即可:
// generator.js
function renderNode(target, nodes) {
// nodes 由挪用者传入,是挪用者的悉数子节点
nodes.forEach(node => {
// trim 用于修剪标签的首尾文本,比方将 <p> 剪为 p
// 然后天生一个全新的 DOM 节点 newNode
let newNode = document.createElement(trim(node.type))
// node.val 不存在时,申明当前节点不是子节点
// 此时传入 node 的子节点递归挪用本身,深度优先遍历树
if (!node.val) newNode = renderNode(newNode, node.children)
// node.val 存在时,申明当前 node 是恭弘=叶 恭弘子节点
// 此时 node.val 就是当前 DOM 元素的 innerHTML
else newNode.innerHTML = node.val
// 将新天生的节点挂载到 DOM 上
target.appendChild(newNode)
})
// 向挪用者返回挂载后的元素
return target
}
TODO
上面的一套流程走完后,现实上就完成了从 HTML 字符串到假造 DOM 再到实在 DOM 的流程了。因为假造 DOM 的笼统性,因而可以在 HTML 字符串中经由历程模板语法来绑定多少变量,然后在这些变量转变后,修正假造 DOM 对应的位置,并将假造 DOM 的响应部份从新衬着到实在 DOM,从而削减手动从新绘制 DOM 的冗余代码,并经由历程只管少地重绘 DOM 来进步机能。
固然了,这个编译器的语法剖析部份采纳的是教科书中最简朴的递归下落算法,递归的体式格局在许多时刻机能都不是最好的。假如愿望语法剖析可以有只管高的机能,那末表驱动的 LR 剖析可以做到这一点。不过 LR 剖析中组织剖析表的历程是相称庞杂的,在此并没有杀鸡用牛刀的必要。
末了,这个玩具级的编译器能支撑的文法实在相称有限,只是 HTML 的一个子集罢了。愿望它可以为编写别的更风趣的 Parser 供应一些启示吧。