当前位置: 首页 > news >正文

淘宝客网站如何做电销app

淘宝客网站如何做,电销app,高端企业网站要多少钱,廊坊网站建设解决方案解析器本质上是一个状态机。但我们也曾提到#xff0c;正则表达式其实也是一个状态机。因此在编写 parser 的时候#xff0c;利用正则表达式能够让我们少写不少代码。本章我们将更多地利用正则表达式来实现 HTML 解析器。另外#xff0c;一个完善的 HTML 解析器远比想象的要…解析器本质上是一个状态机。但我们也曾提到正则表达式其实也是一个状态机。因此在编写 parser 的时候利用正则表达式能够让我们少写不少代码。本章我们将更多地利用正则表达式来实现 HTML 解析器。另外一个完善的 HTML 解析器远比想象的要复杂。我们知道浏览器会对 HTML 文本进行解析那么它是如何做的呢其实关于 HTML 文本的解析是有规范可循的即 WHATWG 关于 HTML 的解析规范其中定义了完整的错误处理和状态机的状态迁移流程还提及了一些特殊的状态例如 DATA、CDATA、RCDATA、RAWTEXT 等。那么这些状态有什么含义呢它们对解析器有哪些影响呢什么是 HTML 实体以及 Vue.js 模板解析器需要如何处理HTML 实体呢 1、文本模式及其对解析器的影响 文本模式指的是解析器在工作时所进入的一些特殊状态在不同的特殊状态下解析器对文本的解析行为会有所不同。具体来说当解析器遇到一些特殊标签时会切换模式从而影响其对文本的解析行为。这些特殊标签是 title 标签、textarea 标签当解析器遇到这两个标签时会切换到 RCDATA 模式style、xmp、iframe、noembed、noframes、noscript 等标签当解析器遇到这些标签时会切换到 RAWTEXT 模式当解析器遇到 ![CDATA[ 字符串时会进入 CDATA 模式。 解析器的初始模式则是 DATA 模式。对于 Vue.js 的模板 DSL 来说模板中不允许出现 script 标签因此 Vue.js 模板解析器在遇到 script 标签时也会切换到 RAWTEXT 模式。 解析器的行为会因工作模式的不同而不同。下图出了初始模式下解析器的工作流程 我们对上图做一些必要的解释。在默认的 DATA 模式下解析器在遇到字符 时会切换到标签开始状态tag open state。换句话说在该模式下解析器能够解析标签元素。当解析器遇到字符 时会切换到字符引用状态character reference state也称 HTML 字符实体状态。也就是说在DATA 模式下解析器能够处理 HTML 字符实体。 我们再来看看当解析器处于 RCDATA 状态时它的工作情况如何。下图给出了 WHATWG 规范第 13.2.5.2 节的内容 由上图可知当解析器遇到字符 时不会再切换到标签开始状态而会切换到 RCDATA less-than sign state 状态。下图给出了 RCDATA less-than sign state 状态下解析器的工作方式 由下图可知在 RCDATA less-than sign state 状态下如果解析器遇到字符 /则直接切换到 RCDATA 的结束标签状态即 RCDATA end tag open state否则会将当前字符 作为普通字符处理然后继续处理后面的字符。由此可知在RCDATA 状态下解析器不能识别标签元素。这其实间接说明了在 textarea 内可以将字符 作为普通文本解析器并不会认为字符 是标签开始的标志如下面的代码所示 01 textarea 02 divasdf/divasdfasdf 03 /textarea在上面这段 HTML 代码中textarea 标签内存在一个div 标签。但解析器并不会把 div 解析为标签元素而是作为普通文本处理。但是由上上图可知在 RCDATA 模式下解析器仍然支持 HTML 实体。因为当解析器遇到字符 时会切换到字符引用状态如下面的代码所示 01 textareacopy;/textarea浏览器在渲染这段 HTML 代码时会在文本框内展示字符 ©。 解析器在 RAWTEXT 模式下的工作方式与在 RCDATA 模式下类似。唯一不同的是在 RAWTEXT 模式下解析器将不再支持HTML 实体。下图给出了 WHATWG 规范第 13.2.5.3 节中所定义的 RAWTEXT 模式下状态机的工作方式 RAWTEXT 模式的确不支持HTML 实体。在该模式下解析器会将 HTML 实体字符作为普通字符处理。Vue.js 的单文件组件的解析器在遇到 script 标签时就会进入 RAWTEXT 模式这时它会把 script 标签内的内容全部作为普通文本处理。 CDATA 模式在 RAWTEXT 模式的基础上更进一步。下图给出了 WHATWG 规范第 13.2.5.69 节中所定义的 CDATA 模式下状态机的工作方式 在 CDATA 模式下解析器将把任何字符都作为普通字符处理直到遇到 CDATA 的结束标志为止。 实际上在 WHATWG 规范中还定义了 PLAINTEXT 模式该模式与 RAWTEXT 模式类似。不同的是解析器一旦进入PLAINTEXT 模式将不会再退出。另外Vue.js 的模板 DSL 解析器是用不到 PLAINTEXT 模式的因此我们不会过多介绍它。 下表汇总了不同的模式及各其特性 除了上表列出的特性之外不同的模式还会影响解析器对于终止解析的判断后文会具体讨论。另外后续编写解析器代码时我们会将上述模式定义为状态表如下面的代码所示 01 const TextModes { 02 DATA: DATA, 03 RCDATA: RCDATA, 04 RAWTEXT: RAWTEXT, 05 CDATA: CDATA 06 }2、递归下降算法构造模板 AST 从本节开始我们将着手实现一个更加完善的模板解析器。解析器的基本架构模型如下 01 // 定义文本模式作为一个状态表 02 const TextModes { 03 DATA: DATA, 04 RCDATA: RCDATA, 05 RAWTEXT: RAWTEXT, 06 CDATA: CDATA 07 } 08 09 // 解析器函数接收模板作为参数 10 function parse(str) { 11 // 定义上下文对象 12 const context { 13 // source 是模板内容用于在解析过程中进行消费 14 source: str, 15 // 解析器当前处于文本模式初始模式为 DATA 16 mode: TextModes.DATA 17 } 18 // 调用 parseChildren 函数开始进行解析它返回解析后得到的子节点 19 // parseChildren 函数接收两个参数 20 // 第一个参数是上下文对象 context 21 // 第二个参数是由父代节点构成的节点栈初始时栈为空 22 const nodes parseChildren(context, []) 23 24 // 解析器返回 Root 根节点 25 return { 26 type: Root, 27 // 使用 nodes 作为根节点的 children 28 children: nodes 29 } 30 }在上面这段代码中我们首先定义了一个状态表 TextModes它用来描述预定义的文本模式。然后我们定义了 parse 函数即解析器函数在其中定义了上下文对象 context用来维护解析程序执行过程中程序的各种状态。接着调用parseChildren 函数进行解析该函数会返回解析后得到的子节点并使用这些子节点作为 children 来创建 Root 根节点。最后parse 函数返回根节点完成模板 AST 的构建。 在上面这段代码中parseChildren 函数是整个解析器的核心。后续我们会递归地调用它来不断地消费模板内容。parseChildren 函数会返回解析后得到的子节点。举个例子假设有如下模板 01 p1/p 02 p2/p上面这段模板有两个根节点即两个 p 标签。parseChildren 函数在解析这段模板后会得到由这两个 p节点组成的数组 01 [ 02 { type: Element, tag: p, children: [/*...*/] }, 03 { type: Element, tag: p, children: [/*...*/] }, 04 ]之后这个数组将作为 Root 根节点的 children。 parseChildren 函数接收两个参数 第一个参数上下文对象 context。第二个参数由父代节点构成的栈用于维护节点间的父子级关系。 parseChildren 函数本质上也是一个状态机该状态机有多少种状态取决于子节点的类型数量。在模板中元素的子节点可以是以下几种 标签节点例如 div。文本插值节点例如 {{ val }}。普通文本节点例如text。注释节点例如 !----。CDATA 节点例如 ![CDATA[ xxx ]]。 在标准的 HTML 中节点的类型将会更多例如 DOCTYPE 节点等。为了降低复杂度我们仅考虑上述类型的节点。 上图给出了 parseChildren 函数在解析模板过程中的状态迁移过程 我们可以把上图所展示的状态迁移过程总结如下 当遇到字符 时进入临时状态。如果下一个字符匹配正则 /a-z/i则认为这是一个标签节点于是调用 parseElement 函数完成标签的解析。注意正则表达式 /a-z/i 中的 i意思是忽略大小写case-insensitive。如果字符串以 !-- 开头则认为这是一个注释节点于是调用 parseComment 函数完成注释节点的解析。如果字符串以 ![CDATA[ 开头则认为这是一个 CDATA 节点于是调用 parseCDATA 函数完成 CDATA 节点的解析。如果字符串以 {{ 开头则认为这是一个插值节点于是调用parseInterpolation 函数完成插值节点的解析。其他情况都作为普通文本调用 parseText 函数完成文本节点的解析。 落实到代码时我们还需要结合文本模式如下面的代码所示 01 function parseChildren(context, ancestors) { 02 // 定义 nodes 数组存储子节点它将作为最终的返回值 03 let nodes [] 04 // 从上下文对象中取得当前状态包括模式 mode 和模板内容 source 05 const { mode, source } context 06 07 // 开启 while 循环只要满足条件就会一直对字符串进行解析 08 // 关于 isEnd() 后文会详细讲解 09 while(!isEnd(context, ancestors)) { 10 let node 11 // 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析 12 if (mode TextModes.DATA || mode TextModes.RCDATA) { 13 // 只有 DATA 模式才支持标签节点的解析 14 if (mode TextModes.DATA source[0] ) { 15 if (source[1] !) { 16 if (source.startsWith(!--)) { 17 // 注释 18 node parseComment(context) 19 } else if (source.startsWith(![CDATA[)) { 20 // CDATA 21 node parseCDATA(context, ancestors) 22 } 23 } else if (source[1] /) { 24 // 结束标签这里需要抛出错误后文会详细解释原因 25 } else if (/[a-z]/i.test(source[1])) { 26 // 标签 27 node parseElement(context, ancestors) 28 } 29 } else if (source.startsWith({{)) { 30 // 解析插值 31 node parseInterpolation(context) 32 } 33 } 34 35 // node 不存在说明处于其他模式即非 DATA 模式且非 RCDATA 模式 36 // 这时一切内容都作为文本处理 37 if (!node) { 38 // 解析文本节点 39 node parseText(context) 40 } 41 42 // 将节点添加到 nodes 数组中 43 nodes.push(node) 44 } 45 46 // 当 while 循环停止后说明子节点解析完毕返回子节点 47 return nodes 48 }上面这段代码完整地描述了上图所示的状态迁移过程这里有几点需要注意 parseChildren 函数的返回值是由子节点组成的数组每次while 循环都会解析一个或多个节点这些节点会被添加到nodes 数组中并作为 parseChildren 函数的返回值返回。解析过程中需要判断当前的文本模式。根据上表可知只有处于 DATA 模式或 RCDATA 模式时解析器才支持插值节点的解析。并且只有处于 DATA 模式时解析器才支持标签节点、注释节点和 CDATA 节点的解析。当遇到特定标签时解析器会切换模式。一旦解析器切换到 DATA 模式和 RCDATA 模式之外的模式时一切字符都将作为文本节点被解析。当然即使在 DATA 模式或 RCDATA 模式下如果无法匹配标签节点、注释节点、CDATA 节点、插值节点那么也会作为文本节点解析。 除了上述三点内容外你可能对这段代码仍然有疑问其中之一是 while 循环何时停止以及 isEnd() 函数的用途是什么这里我们给出简单的解释parseChildren 函数是用来解析子节点的因此 while 循环一定要遇到父级节点的结束标签才会停止这是正常的思路。但这个思路存在一些问题不过我们这里暂时将其忽略后文会详细讨论。 我们可以通过一个例子来更加直观地了解 parseChildren 函数以及其他解析函数在解析模板时的工作职责和工作流程。以下面的模板为例 01 const template div 02 pText1/p 03 pText2/p 04 /div这里需要强调的是在解析模板时我们不能忽略空白字符。这些空白字符包括换行符\n、回车符\r、空格、制表符\t以及换页符\f。如果我们用加号代表换行符用减号-代表空格字符。那么上面的模板可以表示为 01 const template div--pText1/p--pText2/p/div接下来我们以这段模板作为输入来执行解析过程。 解析器一开始处于 DATA 模式。开始执行解析后解析器遇到的第一个字符为 并且第二个字符能够匹配正则表达式 /a-z/i所以解析器会进入标签节点状态并调用 parseElement 函数进行解析。 parseElement 函数会做三件事解析开始标签解析子节点解析结束标签。可以用下面的伪代码来表达 parseElement 函数所做的事情 01 function parseElement() { 02 // 解析开始标签 03 const element parseTag() 04 // 这里递归地调用 parseChildren 函数进行 div 标签子节点的解析 05 element.children parseChildren() 06 // 解析结束标签 07 parseEndTag() 08 09 return element 10 }如果一个标签不是自闭合标签则可以认为一个完整的标签元素是由开始标签、子节点和结束标签这三部分构成的。因此在 parseElement 函数内我们分别调用三个解析函数来处理这三部分内容。以上述模板为例。 parseTag 解析开始标签。parseTag 函数用于解析开始标签包括开始标签上的属性和指令。因此在 parseTag 解析函数执行完毕后会消费字符串中的内容 div处理后的模板内容将变为 01 const template --pText1/p--pText2/p/div递归地调用 parseChildren 函数解析子节点。parseElement 函数在解析开始标签时会产生一个标签节点 element。在parseElement 函数执行完毕后剩下的模板内容应该作为element 的子节点被解析即 element.children。因此我们要递归地调用 parseChildren 函数。在这个过程中parseChildren 函数会消费字符串的内容--pText1/p--pText2/p。处理后的模板内容将变为 01 const template /divparseEndTag 处理结束标签。可以看到在经过parseChildren 函数处理后模板内容只剩下一个结束标签了。因此只需要调用 parseEndTag 解析函数来消费它即可。 经过上述三个步骤的处理后这段模板就被解析完毕了最终得到了模板 AST。但这里值得注意的是为了解析标签的子节点我们递归地调用了 parseChildren 函数。这意味着一个新的状态机开始运行了我们称其为“状态机 2”。“状态机2”所处理的模板内容为 01 const template --pText1/p--pText2/p接下来我们继续分析“状态机 2”的状态迁移流程。在“状态机 2”开始运行时模板的第一个字符是换行符字符 代表换行符。因此解析器会进入文本节点状态并调用parseText 函数完成文本节点的解析。parseText 函数会将下一个 字符之前的所有字符都视作文本节点的内容。换句话说parseText 函数会消费模板内容 ±-并产生一个文本节点。在parseText 解析函数执行完毕后剩下的模板内容为 01 const template pText1/p--pText2/p接着parseChildren 函数继续执行。此时模板的第一个字符为并且下一个字符能够匹配正则 /a-z/i。于是解析器再次进入parseElement 解析函数的执行阶段这会消费模板内容pText1/p。在这一步过后剩下的模板内容为 01 const template --pText2/p可以看到此时模板的第一个字符是换行符于是调用parseText 函数消费模板内容 ±-。现在模板中剩下的内容是 01 const template pText2/p解析器会再次调用 parseElement 函数处理标签节点。在这之后剩下的模板内容为 01 const template 可以看到现在模板内容只剩下一个换行符了。parseChildren 函数会继续执行并调用 parseText 函数消费剩下的内容并产生一个文本节点。最终模板被解析完毕“状态机 2”停止运行。 在“状态机 2”运行期间为了处理标签节点我们又调用了两次 parseElement 函数。第一次调用用于处理内容pText1/p第二次调用用于处理内容 pText2/p。我们知道parseElement 函数会递归地调用 parseChildren 函数完成子节点的解析这就意味着解析器会再开启了两个新的状态机。 通过上述例子我们能够认识到parseChildren 解析函数是整个状态机的核心状态迁移操作都在该函数内完成。在parseChildren 函数运行过程中为了处理标签节点会调用parseElement 解析函数这会间接地调用 parseChildren 函数并产生一个新的状态机。随着标签嵌套层次的增加新的状态机会随着 parseChildren 函数被递归地调用而不断创建这就是“递归下降”中“递归”二字的含义。而上级parseChildren 函数的调用用于构造上级模板 AST 节点被递归调用的下级 parseChildren 函数则用于构造下级模板 AST 节点。最终会构造出一棵树型结构的模板 AST这就是“递归下降”中“下降”二字的含义。 3、状态机的开启与停止 在上一节中我们讨论了递归下降算法的含义。我们知道parseChildren 函数本质上是一个状态机它会开启一个 while 循环使得状态机自动运行如下面的代码所示 01 function parseChildren(context, ancestors) { 02 let nodes [] 03 04 const { mode } context 05 // 运行状态机 06 while(!isEnd(context, ancestors)) { 07 // 省略部分代码 08 } 09 10 return nodes 11 }这里的问题在于状态机何时停止呢换句话说while 循环应该何时停止运行呢这涉及 isEnd() 函数的判断逻辑。为了搞清楚这个问题我们需要模拟状态机的运行过程。 我们知道在调用 parseElement 函数解析标签节点时会递归地调用 parseChildren 函数从而开启新的状态机如下图所示 为了便于描述我们可以把上图中所示的新的状态机称为“状态机 1”。“状态机 1”开始运行继续解析模板直到遇到下一个 p 标签如下图所示 因为遇到了 p 标签所以“状态机 1”也会调用parseElement 函数进行解析。于是又重复了上述过程即把当前解析的标签节点压入父级节点栈然后递归地调用parseChildren 函数开启新的状态机即“状态机 2”。可以看到此时有两个状态机在同时运行。 此时“状态机 2”拥有程序的执行权它持续解析模板直到遇到结束标签 /p。因为这是一个结束标签并且在父级节点栈中存在与该结束标签同名的标签节点所以“状态机 2”会停止运行并弹出父级节点栈中处于栈顶的节点如下图所示 此时“状态机 2”已经停止运行了但“状态机 1”仍在运行中于是会继续解析模板直到遇到下一个 p 标签。这时“状态机 1”会再次调用 parseElement 函数解析标签节点因此又会执行压栈并开启新的“状态机 3”如下图 所示 此时“状态机 3”拥有程序的执行权它会继续解析模板直到遇到结束标签 /p。因为这是一个结束标签并且在父级节点栈中存在与该结束标签同名的标签节点所以“状态机 3”会停止运行并弹出父级节点栈中处于栈顶的节点如下图所示 当“状态机 3”停止运行后程序的执行权交还给“状态机1”。“状态机 1”会继续解析模板直到遇到最后的 /div结束标签。这时“状态机 1”发现父级节点栈中存在与结束标签同名的标签节点于是将该节点弹出父级节点栈并停止运行如下图所示 这时父级节点栈为空状态机全部停止运行模板解析完毕。 通过上面的描述我们能够清晰地认识到解析器会在何时开启新的状态机以及状态机会在何时停止。结论是**当解析器遇到开始标签时会将该标签压入父级节点栈同时开启新的状态机。当解析器遇到结束标签并且父级节点栈中存在与该标签同名的开始标签节点时会停止当前正在运行的状态机。**根据上述规则我们可以给出 isEnd 函数的逻辑如下面的代码所示 01 function isEnd(context, ancestors) { 02 // 当模板内容解析完毕后停止 03 if (!context.source) return true 04 // 获取父级标签节点 05 const parent ancestors[ancestors.length - 1] 06 // 如果遇到结束标签并且该标签与父级标签节点同名则停止 07 if (parent context.source.startsWith(/${parent.tag})) { 08 return true 09 } 10 }上面这段代码展示了状态机的停止时机具体如下 第一个停止时机是当模板内容被解析完毕时第二个停止时机则是在遇到结束标签时这时解析器会取得父级节点栈栈顶的节点作为父节点检查该结束标签是否与父节点的标签同名如果相同则状态机停止运行。 这里需要注意的是在第二个停止时机中我们直接比较结束标签的名称与栈顶节点的标签名称。这么做的确可行但严格来讲是有瑕疵的。例如下面的模板所示 01 divspan/div/span观察上述模板它存在一个明显的问题你能发现吗实际上这段模板有两种解释方式下图给出了第一种 如上图所示这种解释方式的流程如下 状态机 1”遇到 div 开始标签调用 parseElement 解析函数这会开启“状态机 2”来完成子节点的解析。“状态机 2”遇到 span 开始标签调用 parseElement 解析函数这会开启“状态机 3”来完成子节点的解析。“状态机 3”遇到 /div 结束标签。由于此时父级节点栈栈顶的节点名称是 span并不是 div所以“状态机 3”不会停止运行。这时“状态机 3”遭遇了不符合预期的状态因为结束标签 /div 缺少与之对应的开始标签所以这时“状态机3”会抛出错误“无效的结束标签”。 上述流程的思路与我们当前的实现相符状态机会遭遇不符合预期的状态。下面 parseChildren 函数的代码能够体现这一点 01 function parseChildren(context, ancestors) { 02 let nodes [] 03 04 const { mode } context 05 06 while(!isEnd(context, ancestors)) { 07 let node 08 09 if (mode TextModes.DATA || mode TextModes.RCDATA) { 10 if (mode TextModes.DATA context.source[0] ) { 11 if (context.source[1] !) { 12 // 省略部分代码 13 } else if (context.source[1] /) { 14 // 状态机遭遇了闭合标签此时应该抛出错误因为它缺少与之对应的开始标签 15 console.error(无效的结束标签) 16 continue 17 } else if (/[a-z]/i.test(context.source[1])) { 18 // 省略部分代码 19 } 20 } else if (context.source.startsWith({{)) { 21 // 省略部分代码 22 } 23 } 24 // 省略部分代码 25 } 26 27 return nodes 28 }换句话说按照我们当前的实现思路来解析上述例子中的模板最终得到的错误信息是“无效的结束标签”。但其实还有另外一种更好的解析方式。观察上例中给出的模板其中存在一段完整的内容如下图所示 从上图中可以看到模板中存在一段完整的内容我们希望解析器可以正常对其进行解析这很可能也是符合用户意图的。但实际上无论哪一种解释方式对程序的影响都不大。两者的区别体现在错误处理上。对于第一种解释方式我们得到的错误信息是“无效的结束标签”。而对于第二种解释方式在“完整的内容”部分被解析完毕后解析器就会打印错误信息“span 标签缺少闭合标签”。很显然第二种解释方式更加合理。 为了实现第二种解释方式我们需要调整 isEnd 函数的逻辑。当判断状态机是否应该停止时我们不应该总是与栈顶的父级节点做比较而是应该与整个父级节点栈中的所有节点做比较。只要父级节点栈中存在与当前遇到的结束标签同名的节点就停止状态机如下面的代码所示 01 function isEnd(context, ancestors) { 02 if (!context.source) return true 03 04 // 与父级节点栈内所有节点做比较 05 for (let i ancestors.length - 1; i 0; --i) { 06 // 只要栈中存在与当前结束标签同名的节点就停止状态机 07 if (context.source.startsWith(/${ancestors[i].tag})) { 08 return true 09 } 10 } 11 }按照新的思路再次对如下模板执行解析 01 divspan/div/span其流程如下 “状态机 1”遇到 div 开始标签调用 parseElement 解析函数并开启“状态机 2”解析子节点。“状态机 2”遇到 span 开始标签调用 parseElement 解析函数并开启“状态机 3”解析子节点。“状态机 3”遇到 /div 结束标签由于节点栈中存在名为div 的标签节点于是“状态机 3”停止了。 在这个过程中“状态机 2”在调用 parseElement 解析函数时parseElement 函数能够发现 span 缺少闭合标签于是会打印错误信息“span 标签缺少闭合标签”如下面的代码所示 01 function parseElement(context, ancestors) { 02 const element parseTag(context) 03 if (element.isSelfClosing) return element 04 05 ancestors.push(element) 06 element.children parseChildren(context, ancestors) 07 ancestors.pop() 08 09 if (context.source.startsWith(/${element.tag})) { 10 parseTag(context, end) 11 } else { 12 // 缺少闭合标签 13 console.error(${element.tag} 标签缺少闭合标签) 14 } 15 16 return element 17 }4、解析标签节点 在上一节给出的 parseElement 函数的实现中无论是解析开始标签还是闭合标签我们都调用了 parseTag 函数。同时我们使用 parseChildren 函数来解析开始标签与闭合标签中间的部分如下面的代码及注释所示 01 function parseElement(context, ancestors) { 02 // 调用 parseTag 函数解析开始标签 03 const element parseTag(context) 04 if (element.isSelfClosing) return element 05 06 ancestors.push(element) 07 element.children parseChildren(context, ancestors) 08 ancestors.pop() 09 10 if (context.source.startsWith(/${element.tag})) { 11 // 再次调用 parseTag 函数解析结束标签传递了第二个参数end 12 parseTag(context, end) 13 } else { 14 console.error(${element.tag} 标签缺少闭合标签) 15 } 16 17 return element 18 }标签节点的整个解析过程如下图所示 这里需要注意的是由于开始标签与结束标签的格式非常类似所以我们统一使用 parseTag 函数处理并通过该函数的第二个参数来指定具体的处理类型。当第二个参数值为字符串’end’ 时意味着解析的是结束标签。另外无论处理的是开始标签还是结束标签parseTag 函数都会消费对应的内容。为了实现对模板内容的消费我们需要在上下文对象中新增两个工具函数如下面的代码所示 01 function parse(str) { 02 // 上下文对象 03 const context { 04 // 模板内容 05 source: str, 06 mode: TextModes.DATA, 07 // advanceBy 函数用来消费指定数量的字符它接收一个数字作为参数 08 advanceBy(num) { 09 // 根据给定字符数 num截取位置 num 后的模板内容并替换当前模板内容 10 context.source context.source.slice(num) 11 }, 12 // 无论是开始标签还是结束标签都可能存在无用的空白字符例如 div 13 advanceSpaces() { 14 // 匹配空白字符 15 const match /^[\t\r\n\f ]/.exec(context.source) 16 if (match) { 17 // 调用 advanceBy 函数消费空白字符 18 context.advanceBy(match[0].length) 19 } 20 } 21 } 22 23 const nodes parseChildren(context, []) 24 25 return { 26 type: Root, 27 children: nodes 28 } 29 }在上面这段代码中我们为上下文对象增加了 advanceBy 函数和 advanceSpaces 函数。其中 advanceBy 函数用来消费指定数量的字符。其实现原理很简单即调用字符串的 slice 函数根据指定位置截取剩余字符串并使用截取后的结果作为新的模板内容。advanceSpaces 函数则用来消费无用的空白字符因为标签中可能存在空白字符例如在模板 div---- 中减号-代表空白字符。 有了 advanceBy 和 advanceSpaces 函数后我们就可以给出parseTag 函数的实现了如下面的代码所示 01 // 由于 parseTag 既用来处理开始标签也用来处理结束标签因此我们设计第二个参数 type 02 // 用来代表当前处理的是开始标签还是结束标签type 的默认值为 start即默认作为开始标签处理 03 function parseTag(context, type start) { 04 // 从上下文对象中拿到 advanceBy 函数 05 const { advanceBy, advanceSpaces } context 06 07 // 处理开始标签和结束标签的正则表达式不同 08 const match type start 09 // 匹配开始标签 10 ? /^([a-z][^\t\r\n\f /]*)/i.exec(context.source) 11 // 匹配结束标签 12 : /^\/([a-z][^\t\r\n\f /]*)/i.exec(context.source) 13 // 匹配成功后正则表达式的第一个捕获组的值就是标签名称 14 const tag match[1] 15 // 消费正则表达式匹配的全部内容例如 div 这段内容 16 advanceBy(match[0].length) 17 // 消费标签中无用的空白字符 18 advanceSpaces() 19 20 // 在消费匹配的内容后如果字符串以 / 开头则说明这是一个自闭合标签 21 const isSelfClosing context.source.startsWith(/) 22 // 如果是自闭合标签则消费 / 否则消费 23 advanceBy(isSelfClosing ? 2 : 1) 24 25 // 返回标签节点 26 return { 27 type: Element, 28 // 标签名称 29 tag, 30 // 标签的属性暂时留空 31 props: [], 32 // 子节点留空 33 children: [], 34 // 是否自闭合 35 isSelfClosing 36 } 37 }上面这段代码有两个关键点 由于 parseTag 函数既用于解析开始标签又用于解析结束标签因此需要用一个参数来标识当前处理的标签类型即type。对于开始标签和结束标签用于匹配它们的正则表达式只有一点不同结束标签是以字符串 / 开头的。下图给出了用于匹配开始标签的正则表达式的含义。 下面给出了几个使用上图所示的正则来匹配开始标签的例子 对于字符串 div会匹配出字符串 ‘div剩余 ’。对于字符串 div/会匹配出字符串 div剩余 /。对于字符串 div----其中减号-代表空白符会匹配出字符串 div剩余 ----。 另外上图中所示的正则拥有一个捕获组它用来捕获标签名称。 除了正则表达式外parseTag 函数的另外几个关键点如下 在完成正则匹配后需要调用 advanceBy 函数消费由正则匹配的全部内容。根据上面给出的第三个正则匹配例子可知由于标签中可能存在无用的空白字符例如 div----因此我们需要调用advanceSpaces 函数消费空白字符。在消费由正则匹配的内容后需要检查剩余模板内容是否以字符串 / 开头。如果是则说明当前解析的是一个自闭合标签这时需要将标签节点的 isSelfClosing 属性设置为 true。最后判断标签是否自闭合。如果是则调用 advnaceBy 函数消费内容 /否则只需要消费内容 即可。 在经过上述处理后parseTag 函数会返回一个标签节点。parseElement 函数在得到由 parseTag 函数产生的标签节点后需要根据节点的类型完成文本模式的切换如下面的代码所示 01 function parseElement(context, ancestors) { 02 const element parseTag(context) 03 if (element.isSelfClosing) return element 04 05 // 切换到正确的文本模式 06 if (element.tag textarea || element.tag title) { 07 // 如果由 parseTag 解析得到的标签是 textarea 或 title则切换到 RCDATA 模式 08 context.mode TextModes.RCDATA 09 } else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) { 10 // 如果由 parseTag 解析得到的标签是 11 // style、xmp、iframe、noembed、noframes、noscript 12 // 则切换到 RAWTEXT 模式 13 context.mode TextModes.RAWTEXT 14 } else { 15 // 否则切换到 DATA 模式 16 context.mode TextModes.DATA 17 } 18 19 ancestors.push(element) 20 element.children parseChildren(context, ancestors) 21 ancestors.pop() 22 23 if (context.source.startsWith(/${element.tag})) { 24 parseTag(context, end) 25 } else { 26 console.error(${element.tag} 标签缺少闭合标签) 27 } 28 29 return element 30 }至此我们就实现了对标签节点的解析。但是目前的实现忽略了节点中的属性和指令下一节将会讲解。 5、解析属性 上一节中介绍的 parseTag 解析函数会消费整个开始标签这意味着该函数需要有能力处理开始标签中存在的属性与指令例如 01 div idfoo v-showdisplay/上面这段模板中的 div 标签存在一个 id 属性和一个 v-show 指令。为了处理属性和指令我们需要在 parseTag 函数中增加parseAttributes 解析函数如下面的代码所示 01 function parseTag(context, type start) { 02 const { advanceBy, advanceSpaces } context 03 04 const match type start 05 ? /^([a-z][^\t\r\n\f /]*)/i.exec(context.source) 06 : /^\/([a-z][^\t\r\n\f /]*)/i.exec(context.source) 07 const tag match[1] 08 09 advanceBy(match[0].length) 10 advanceSpaces() 11 // 调用 parseAttributes 函数完成属性与指令的解析并得到 props 数组 12 // props 数组是由指令节点与属性节点共同组成的数组 13 const props parseAttributes(context) 14 15 const isSelfClosing context.source.startsWith(/) 16 advanceBy(isSelfClosing ? 2 : 1) 17 18 return { 19 type: Element, 20 tag, 21 props, // 将 props 数组添加到标签节点上 22 children: [], 23 isSelfClosing 24 } 25 }上面这段代码的关键点之一是我们需要在消费标签的“开始部分”和无用的空白字符之后再调用 parseAttribute 函数。举个例子假设标签的内容如下 01 div idfoo v-showdisplay 标签的“开始部分”指的是字符串 div所以当消耗标签的“开始部分”以及无用空白字符后剩下的内容为 01 idfoo v-showdisplay 上面这段内容才是 parseAttributes 函数要处理的内容。由于该函数只用来解析属性和指令因此它会不断地消费上面这段模板内容直到遇到标签的“结束部分”为止。其中结束部分指的是字符 或者字符串 /。据此我们可以给出parseAttributes 函数的整体框架如下面的代码所示 01 function parseAttributes(context) { 02 // 用来存储解析过程中产生的属性节点和指令节点 03 const props [] 04 05 // 开启 while 循环不断地消费模板内容直至遇到标签的“结束部分”为止 06 while ( 07 !context.source.startsWith() 08 !context.source.startsWith(/) 09 ) { 10 // 解析属性或指令 11 } 12 // 将解析结果返回 13 return props 14 }实际上parseAttributes 函数消费模板内容的过程就是不断地解析属性名称、等于号、属性值的过程如下图所示 parseAttributes 函数会按照从左到右的顺序不断地消费字符串。以上图为例该函数的解析过程如下 首先解析出第一个属性的名称 id并消费字符串 ‘id’。此时剩余模板内容为 01 foo v-showdisplay 在解析属性名称时除了要消费属性名称之外还要消费属性名称后面可能存在的空白字符。如下面这段模板中属性名称和等于号之间存在空白字符 01 id foo v-showdisplay 但无论如何在属性名称解析完毕之后模板剩余内容一定是以等于号开头的即 01 foo v-showdisplay 如果消费属性名称之后模板内容不以等于号开头则说明模板内容不合法我们可以选择性地抛出错误。 接着我们需要消费等于号字符。由于等于号和属性值之间也可能存在空白字符所以我们也需要消费对应的空白字符。在这一步操作过后模板的剩余内容如下 01 foo v-showdisplay 接下来到了处理属性值的环节。模板中的属性值存在三种情况 属性值被双引号包裹id“foo”。属性值被单引号包裹id‘foo’。属性值没有引号包裹idfoo。 按照上述例子此时模板的内容一定以双引号开头。因此我们可以通过检查当前模板内容是否以引号开头来确定属性值是否被引用。如果属性值被引号引用则消费引号。此时模板的剩余内容为 01 foo v-showdisplay 既然属性值被引号引用了就意味着在剩余模板内容中下一个引号之前的内容都应该被解析为属性值。在这个例子中属性值的内容是字符串 foo。于是我们消费属性值及其后面的引号。当然如果属性值没有被引号引用那么在剩余模板内容中下一个空白字符之前的所有字符都应该作为属性值。 当属性值和引号被消费之后由于属性值与下一个属性名称之间可能存在空白字符所以我们还要消费对应的空白字符。在这一步处理过后剩余模板内容为 01 v-showdisplay 可以看到经过上述操作之后第一个属性就处理完毕了。 此时模板中还剩下一个指令我们只需重新执行上述步骤即可完成 v-show 指令的解析。当 v-show 指令解析完毕后将会遇到标签的“结束部分”即字符 。这时parseAttributes 函数中的 while 循环将会停止完成属性和指令的解析。 下面的 parseAttributes 函数给出了上述逻辑的具体实现 01 function parseAttributes(context) { 02 const { advanceBy, advanceSpaces } context 03 const props [] 04 05 while ( 06 !context.source.startsWith() 07 !context.source.startsWith(/) 08 ) { 09 // 该正则用于匹配属性名称 10 const match /^[^\t\r\n\f /][^\t\r\n\f /]*/.exec(context.source) 11 // 得到属性名称 12 const name match[0] 13 14 // 消费属性名称 15 advanceBy(name.length) 16 // 消费属性名称与等于号之间的空白字符 17 advanceSpaces() 18 // 消费等于号 19 advanceBy(1) 20 // 消费等于号与属性值之间的空白字符 21 advanceSpaces() 22 23 // 属性值 24 let value 25 26 // 获取当前模板内容的第一个字符 27 const quote context.source[0] 28 // 判断属性值是否被引号引用 29 const isQuoted quote || quote 30 31 if (isQuoted) { 32 // 属性值被引号引用消费引号 33 advanceBy(1) 34 // 获取下一个引号的索引 35 const endQuoteIndex context.source.indexOf(quote) 36 if (endQuoteIndex -1) { 37 // 获取下一个引号之前的内容作为属性值 38 value context.source.slice(0, endQuoteIndex) 39 // 消费属性值 40 advanceBy(value.length) 41 // 消费引号 42 advanceBy(1) 43 } else { 44 // 缺少引号错误 45 console.error(缺少引号) 46 } 47 } else { 48 // 代码运行到这里说明属性值没有被引号引用 49 // 下一个空白字符之前的内容全部作为属性值 50 const match /^[^\t\r\n\f ]/.exec(context.source) 51 // 获取属性值 52 value match[0] 53 // 消费属性值 54 advanceBy(value.length) 55 } 56 // 消费属性值后面的空白字符 57 advanceSpaces() 58 59 // 使用属性名称 属性值创建一个属性节点添加到 props 数组中 60 props.push({ 61 type: Attribute, 62 name, 63 value 64 }) 65 66 } 67 // 返回 68 return props 69 }在上面这段代码中有两个重要的正则表达式 /^[^\t\r\n\f /][^\t\r\n\f /]*/用来匹配属性名称/^[^\t\r\n\f ]/用来匹配没有使用引号引用的属性值。 我们分别来看看这两个正则表达式是如何工作的。下图给出了用于匹配属性名称的正则表达式的匹配原理 如上图所示我们可以将这个正则表达式分为 A、B 两个部分来看 部分 A 用于匹配一个位置这个位置不能是空白字符也不能是字符 / 或字符 并且字符串要以该位置开头。部分 B 则用于匹配 0 个或多个位置这些位置不能是空白字符也不能是字符 /、、。注意这些位置不允许出现等于号字符这就实现了只匹配等于号之前的内容即属性名称。 下图给出了第二个正则表达式的匹配原理 该正则表达式从字符串的开始位置进行匹配并且会匹配一个或多个非空白字符、非字符 。换句话说该正则表达式会一直对字符串进行匹配直到遇到空白字符或字符 为止这就实现了属性值的提取。 配合 parseAttributes 函数假设给出如下模板 01 div idfoo v-showdisplay/div解析上面这段模板将会得到如下 AST 01 const ast { 02 type: Root, 03 children: [ 04 { 05 type: Element 06 tag: div, 07 props: [ 08 // 属性 09 { type: Attribute, name: id, value: foo }, 10 { type: Attribute, name: v-show, value: display } 11 ] 12 } 13 ] 14 }可以看到在 div 标签节点的 props 属性中包含两个类型为Attribute 的节点这两个节点就是 parseAttributes 函数的解析结果。 我们可以增加更多在 Vue.js 中常见的属性和指令进行测试如以下模板所示 01 div :iddynamicId clickhandler v-on:mousedownonMouseDown /div上面这段模板经过解析后得到如下 AST 01 const ast { 02 type: Root, 03 children: [ 04 { 05 type: Element 06 tag: div, 07 props: [ 08 // 属性 09 { type: Attribute, name: :id, value: dynamicId }, 10 { type: Attribute, name: click, value: handler }, 11 { type: Attribute, name: v-on:mousedown, value: onMouseDown } 12 ] 13 } 14 ] 15 }可以看到在类型为 Attribute 的属性节点中其 name 字段完整地保留着模板中编写的属性名称。我们可以对属性名称做进一步的分析从而得到更具体的信息。例如属性名称以字符 开头则认为它是一个 v-on 指令绑定。我们甚至可以把以 v- 开头的属性看作指令绑定从而为它赋予不同的节点类型例如 01 // 指令类型为 Directive 02 { type: Directive, name: v-on:mousedown, value: onMouseDown } 03 { type: Directive, name: click, value: handler } 04 // 普通属性 05 { type: Attribute, name: id, value: foo }不仅如此为了得到更加具体的信息我们甚至可以进一步分析指令节点的数据也可以设计更多语法规则这完全取决于框架设计者在语法层面的设计以及为框架赋予的能力。 6、解析文本与解码 HTML 实体 6.1、解析文本 本节我们将讨论文本节点的解析。给出如下模板 01 const template divText/div解析器在解析上面这段模板时会先经过 parseTag 函数的处理这会消费标签的开始部分 ‘ ’。处理完毕后剩余模板内容为 01 const template Text/div紧接着解析器会调用 parseChildren 函数开启一个新的状态机来处理这段模板。我们来回顾一下状态机的状态迁移过程如下图所示 状态机始于“状态 1”。在“状态 1”下读取模板的第一个字符 T由于该字符既不是字符 也不是插值定界符 {{因此状态机会进入“状态 7”即调用 parseText 函数处理文本内容。此时解析器会在模板中寻找下一个 字符或插值定界符 {{的位置索引记为索引 I。然后解析器会从模板的头部到索引I 的位置截取内容这段截取出来的字符串将作为文本节点的内容。以下面的模板内容为例 01 const template Text/divparseText 函数会尝试在这段模板内容中找到第一个出现的字符 的位置索引。在这个例子中字符 的索引值为 4。然后parseText 函数会截取介于索引 [0, 4) 的内容作为文本内容。在这个例子中文本内容就是字符串 ‘Text’。 假设模板中存在插值如下面的模板所示 01 const template Text-{{ val }}/div在处理这段模板时parseText 函数会找到第一个插值定界符 {{出现的位置索引。在这个例子中定界符的索引为 5。于是parseText 函数会截取介于索引 [0, 5) 的内容作为文本内容。在这个例子中文本内容就是字符串 ‘Text-’。 下面的 parseText 函数给出了具体实现 01 function parseText(context) { 02 // endIndex 为文本内容的结尾索引默认将整个模板剩余内容都作为文本内容 03 let endIndex context.source.length 04 // 寻找字符 的位置索引 05 const ltIndex context.source.indexOf() 06 // 寻找定界符 {{ 的位置索引 07 const delimiterIndex context.source.indexOf({{) 08 09 // 取 ltIndex 和当前 endIndex 中较小的一个作为新的结尾索引 10 if (ltIndex -1 ltIndex endIndex) { 11 endIndex ltIndex 12 } 13 // 取 delimiterIndex 和当前 endIndex 中较小的一个作为新的结尾索引 14 if (delimiterIndex -1 delimiterIndex endIndex) { 15 endIndex delimiterIndex 16 } 17 18 // 此时 endIndex 是最终的文本内容的结尾索引调用 slice 函数截取文本内容 19 const content context.source.slice(0, endIndex) 20 // 消耗文本内容 21 context.advanceBy(content.length) 22 23 // 返回文本节点 24 return { 25 // 节点类型 26 type: Text, 27 // 文本内容 28 content 29 } 30 }如上面的代码所示由于字符 与定界符 {{ 的出现顺序是未知的所以我们需要取两者中较小的一个作为文本截取的终点。有了截取终点后只需要调用字符串的 slice 函数对字符串进行截取即可截取出来的内容就是文本节点的文本内容。最后我们创建一个类型为 Text 的文本节点将其作为 parseText 函数的返回值。 配合上述 parseText 函数解析如下模板 01 const ast parse(divText/div)得到如下 AST 01 const ast { 02 type: Root, 03 children: [ 04 { 05 type: Element, 06 tag: div, 07 props: [], 08 isSelfClosing: false, 09 children: [ 10 // 文本节点 11 { type: Text, content: Text } 12 ] 13 } 14 ] 15 }这样我们就实现了对文本节点的解析。解析文本节点本身并不复杂复杂点在于我们需要对解析后的文本内容进行HTML 实体的解码工作。为此我们有必要先了解什么是HTML 实体。 6.2、解码命名字符引用 HTML 实体是一段以字符 开始的文本内容。实体用来描述HTML 中的保留字符和一些难以通过普通键盘输入的字符以及一些不可见的字符。例如在 HTML 中字符 具有特殊含义如果希望以普通文本的方式来显示字符 需要通过实体来表达 01 divAlt;B/div其中字符串 lt; 就是一个 HTML 实体用来表示字符 。如果我们不用 HTML 实体而是直接使用字符 那么将会产生非法的 HTML 内容 01 divAB/div这会导致浏览器的解析结果不符合预期。 HTML 实体总是以字符 开头以字符 ; 结尾。在 Web 诞生的初期HTML 实体的数量较少因此允许省略其中的尾分号。但随着 HTML 字符集越来越大HTML 实体出现了包含的情况例如 lt 和 ltcc 都是合法的实体如果不加分号浏览器将无法区分它们。因此WHATWG 规范中明确规定如果不为实体加分号将会产生解析错误。但考虑到历史原因互联网上存在大量省略分号的情况现代浏览器都能够解析早期规范中定义的那些可以省略分号的 HTML 实体。 HTML 实体有两类一类叫作命名字符引用named character reference也叫命名实体named entity顾名思义这类实体具有特定的名称例如上文中的 lt;。WHATWG 规范中给出了全部的命名字符引用有 2000 多个可以通过命名字符引用表查询。下面列出了部分内容 01 // 共 2000 02 { 03 GT: , 04 gt: , 05 LT: , 06 lt: , 07 // 省略部分代码 08 awint;: ⨑, 09 bcong;: ≌, 10 bdquo;: „, 11 bepsi;: ϶, 12 blank;: ␣, 13 blk12;: ▒, 14 blk14;: ░, 15 blk34;: ▓, 16 block;: █, 17 boxDL;: ╗, 18 boxDl;: ╖, 19 boxdL;: ╕, 20 // 省略部分代码 21 }除了命名字符引用之外还有一类字符引用没有特定的名称只能用数字表示这类实体叫作数字字符引用numeric character reference。与命名字符引用不同数字字符引用以字符串 # 开头比命名字符引用的开头部分多出了字符#例如 #60;。实际上#60; 对应的字符也是 换句话说#60; 与 lt; 是等价的。数字字符引用既可以用十进制来表示也可以使用十六进制来表示。例如十进制数字 60 对应的十六进制值为 3c因此实体 #60; 也可以表示为 #x3c;。可以看到当使用十六进制数表示实体时需要以字符串 #x 开头。 理解了 HTML 实体后我们再来讨论为什么 Vue.js 模板的解析器要对文本节点中的 HTML 实体进行解码。为了理解这个问题我们需要先明白一个大前提在 Vue.js 模板中文本节点所包含的 HTML 实体不会被浏览器解析。这是因为模板中的文本节点最终将通过如 el.textContent 等文本操作方法设置到页面而通过 el.textContent 设置的文本内容是不会经过 HTML 实体解码的例如 01 el.textContent lt;最终 el 的文本内容将会原封不动地呈现为字符串 lt;而不会呈现字符 。这就意味着如果用户在 Vue.js 模板中编写了HTML 实体而模板解析器不对其进行解码那么最终渲染到页面的内容将不符合用户的预期。因此我们应该在解析阶段对文本节点中存在的 HTML 实体进行解码。 模板解析器的解码行为应该与浏览器的行为一致。因此我们应该按照 WHATWG 规范实现解码逻辑。规范中明确定义了解码 HTML 实体时状态机的状态迁移流程。下图给出了简化版的状态迁移流程我们会在后文中对其进行补充 假定状态机当前处于初始的 DATA 模式。由上图可知当解析器遇到字符 时会进入“字符引用状态”并消费字符接着解析下一个字符。如果下一个字符是 ASCII 字母或数字ASCII alphanumeric则进入“命名字符引用状态”其中 ASCII 字母或数字指的是 0~9 这十个数字以及字符集合a~z 再加上字符集合 A~Z。当然如果下一个字符是 #则进入“数字字符引用状态”。 一旦状态机进入命名字符引用状态解析器将会执行比较复杂的匹配流程。我们通过几个例子来直观地感受一下这个过程。假设文本内容为 01 altb上面这段文本会被解析为 01 ab为什么会得到这样的解析结果呢接下来我们分析整个解析过程 首先当解析器遇到字符 时会进入字符引用状态。接着解析下一个字符 l这会使得解析器进入命名字符引用状态并在命名字符引用表后文简称“引用表”中查找以字符 l 开头的项。由于引用表中存在诸多以字符 l 开头的项例如lt、lg、le 等因此解析器认为此时是“匹配”的。于是开始解析下一个字符 t并尝试去引用表中查找以 lt 开头的项。由于引用表中也存在多个以 lt 开头的项例如 lt、ltcc;、ltri; 等因此解析器认为此时也是“匹配”的。于是又开始解析下一个字符 b并尝试去引用表中查找以 ltb 开头的项结果发现引用表中不存在符合条件的项至此匹配结束。 当匹配结束时解析器会检查最后一个匹配的字符。如果该字符是分号;则会产生一个合法的匹配并渲染对应字符。但在上例中最后一个匹配的字符是字符 t并不是分号;因此会产生一个解析错误但由于历史原因浏览器仍然能够解析它。在这种情况下浏览器的解析规则是最短原则。其中“最短”指的是命名字符引用的名称最短。举个例子假设文本内容为 01 altcc;我们知道 ltcc; 是一个合法的命名字符引用因此上述文本会被渲染为a⪦。但如果去掉上述文本中的分号即 01 altcc解析器在处理这段文本中的实体时最后匹配的字符将不再是分号而是字符 c。按照“最短原则”解析器只会渲染名称更短的字符引用。在字符串 ltcc 中lt 的名称要短于 ltcc因此最终会将 lt 作为合法的字符引用来渲染而字符串 cc 将作为普通字符来渲染。所以上面的文本最终会被渲染为acc。 需要说明的是上述解析过程仅限于不用作属性值的普通文本。换句话说用作属性值的文本会有不同的解析规则。举例来说给出如下 HTML 文本 01 a hreffoo.com?a1lt2foo.com?a1lt2/a可以看到a 标签的 href 属性值与它的文本子节点具有同样的内容但它们被解析之后的结果不同。其中属性值中出现的 lt 将原封不动地展示而文本子节点中出现的 lt 将会被解析为字符 。这也是符合期望的很明显lt2 将构成链接中的查询参数如果将其中的 lt 解码为字符 将会破坏用户的URL。实际上WHATWG 规范中对此也有完整的定义出于历史原因的考虑对于属性值中的字符引用如果最后一个匹配的字符不是分号并且该匹配的字符的下一个字符是等于号、ASCII 字母或数字那么该匹配项将作为普通文本被解析。 明白了原理我们就着手实现。我们面临的第一个问题是如何处理省略分号的情况关于字符引用中的分号我们可以总结如下 当存在分号时执行完整匹配。当省略分号时执行最短匹配。 为此我们需要精心设计命名字符引用表。由于命名字符引用的数量非常多因此这里我们只取其中一部分作为命名字符引用表的内容如下面的代码所示 01 const namedCharacterReferences { 02 gt: , 03 gt;: , 04 lt: , 05 lt;: , 06 ltcc;: ⪦ 07 }上面这张表是经过精心设计的。观察namedCharacterReferences 对象可以发现相同的字符对应的实体会有多个即带分号的版本和不带分号的版本例如gt 和 “gt;”。另外一些实体则只有带分号的版本因为这些实体不允许省略分号例如 “ltcc;”。我们可以根据这张表来实现实体的解码逻辑。假设我们有如下文本内容 01 altccbbb在解码这段文本时我们首先根据字符 将文本分为两部分 一部分是普通文本a。另一部分则是ltccbbb。 对于普通文本部分由于它不需要被解码因此索引原封不动地保留。而对于可能是字符引用的部分执行解码工作 第一步计算出命名字符引用表中实体名称的最大长度。由于在 namedCharacterReferences 对象中名称最长的实体是ltcc;它具有 5 个字符因此最大长度是 5。第二步根据最大长度截取字符串 ltccbbb即’ltccbbb’.slice(0, 5)最终结果是‘ltccb’第三步用截取后的字符串 ‘ltccb’ 作为键去命名字符引用表中查询对应的值即解码。由于引用表namedCharacterReferences 中不存在键值为 ‘ltccb’ 的项因此不匹配。第四步当发现不匹配时我们将最大长度减 1并重新执行第二步直到找到匹配项为止。在上面这个例子中最终的匹配项将会是 ‘lt’。因此上述文本最终会被解码为 01 accbbb这样我们就实现了当字符引用省略分号时按照“最短原则”进行解码。 下面的 decodeHtml 函数给出了具体实现 01 // 第一个参数为要被解码的文本内容 02 // 第二个参数是一个布尔值代表文本内容是否作为属性值 03 function decodeHtml(rawText, asAttr false) { 04 let offset 0 05 const end rawText.length 06 // 经过解码后的文本将作为返回值被返回 07 let decodedText 08 // 引用表中实体名称的最大长度 09 let maxCRNameLength 0 10 11 // advance 函数用于消费指定长度的文本 12 function advance(length) { 13 offset length 14 rawText rawText.slice(length) 15 } 16 17 // 消费字符串直到处理完毕为止 18 while (offset end) { 19 // 用于匹配字符引用的开始部分如果匹配成功那么 head[0] 的值将有三种可能 20 // 1. head[0] 这说明该字符引用是命名字符引用 21 // 2. head[0] #这说明该字符引用是用十进制表示的数字字符引用 22 // 3. head[0] #x这说明该字符引用是用十六进制表示的数字字符引用 23 const head /(?:#x?)?/i.exec(rawText) 24 // 如果没有匹配说明已经没有需要解码的内容了 25 if (!head) { 26 // 计算剩余内容的长度 27 const remaining end - offset 28 // 将剩余内容加到 decodedText 上 29 decodedText rawText.slice(0, remaining) 30 // 消费剩余内容 31 advance(remaining) 32 break 33 } 34 35 // head.index 为匹配的字符 在 rawText 中的位置索引 36 // 截取字符 之前的内容加到 decodedText 上 37 decodedText rawText.slice(0, head.index) 38 // 消费字符 之前的内容 39 advance(head.index) 40 41 // 如果满足条件则说明是命名字符引用否则为数字字符引用 42 if (head[0] ) { 43 let name 44 let value 45 // 字符 的下一个字符必须是 ASCII 字母或数字这样才是合法的命名字符引用 46 if (/[0-9a-z]/i.test(rawText[1])) { 47 // 根据引用表计算实体名称的最大长度 48 if (!maxCRNameLength) { 49 maxCRNameLength Object.keys(namedCharacterReferences).reduce( 50 (max, name) Math.max(max, name.length), 51 0 52 ) 53 } 54 // 从最大长度开始对文本进行截取并试图去引用表中找到对应的项 55 for (let length maxCRNameLength; !value length 0; --length) { 56 // 截取字符 到最大长度之间的字符作为实体名称 57 name rawText.substr(1, length) 58 // 使用实体名称去索引表中查找对应项的值 59 value (namedCharacterReferences)[name] 60 } 61 // 如果找到了对应项的值说明解码成功 62 if (value) { 63 // 检查实体名称的最后一个匹配字符是否是分号 64 const semi name.endsWith(;) 65 // 如果解码的文本作为属性值最后一个匹配的字符不是分号 66 // 并且最后一个匹配字符的下一个字符是等于号、ASCII 字母或数字 67 // 由于历史原因将字符 和实体名称 name 作为普通文本 68 if ( 69 asAttr 70 !semi 71 /[a-z0-9]/i.test(rawText[name.length 1] || ) 72 ) { 73 decodedText name 74 advance(1 name.length) 75 } else { 76 // 其他情况下正常使用解码后的内容拼接到 decodedText 上 77 decodedText value 78 advance(1 name.length) 79 } 80 } else { 81 // 如果没有找到对应的值说明解码失败 82 decodedText name 83 advance(1 name.length) 84 } 85 } else { 86 // 如果字符 的下一个字符不是 ASCII 字母或数字则将字符 作为普通文本 87 decodedText 88 advance(1) 89 } 90 } 91 } 92 return decodedText 93 }有了 decodeHtml 函数之后我们就可以在解析文本节点时通过它对文本内容进行解码 01 function parseText(context) { 02 // 省略部分代码 03 04 return { 05 type: Text, 06 content: decodeHtml(content) // 调用 decodeHtml 函数解码内容 07 } 08 }6.3、解码数字字符引用 在上一节中我们使用下面的正则表达式来匹配一个文本中字符引用的开始部分 01 const head /(?:#x?)?/i.exec(rawText)我们可以根据该正则的匹配结果来判断字符引用的类型 -如果 head[0] 则说明匹配的是命名字符引用。●如果 head[0] #则说明匹配的是以十进制表示的数字字符引用。 -如果 head[0] #x则说明匹配的是以十六进制表示的数字字符引用。 如果 head[0] #x’则说明匹配的是以十六进制表示的数字字符引用。 数字字符引用的格式是前缀 Unicode 码点。解码数字字符引用的关键在于如何提取字符引用中的 Unicode 码点。考虑到数字字符引用的前缀可以是以十进制表示#也可以是以十六进制表示#x所以我们使用下面的代码来完成码点的提取 01 // 判断是以十进制表示还是以十六进制表示 02 const hex head[0] #x 03 // 根据不同进制表示法选用不同的正则 04 const pattern hex ? /^#x([0-9a-f]);?/i : /^#([0-9]);?/ 05 // 最终body[1] 的值就是 Unicode 码点 06 const body pattern.exec(rawText)有了 Unicode 码点之后只需要调用 String.fromCodePoint 函数即可将其解码为对应的字符 01 if (body) { 02 // 根据对应的进制将码点字符串转换为数字 03 const cp parseInt(body[1], hex ? 16 : 10) 04 // 解码 05 const char String.fromCodePoint(cp) 06 }不过在真正进行解码前需要对码点的值进行合法性检查。WHATWG 规范中对此也有明确的定义: 如果码点值为 0x00即十进制的数字 0它在 Unicode 中代表空字符NULL这将是一个解析错误解析器会将码点值替换为 0xFFFD。如果码点值大于 0x10FFFF0x10FFFF 为 Unicode 的最大值这也是一个解析错误解析器会将码点值替换为0xFFFD。如果码点值处于代理对surrogate pair范围内这也是一个解析错误解析器会将码点值替换为 0xFFFD其中surrogate pair 是预留给 UTF-16 的码位其范围是[0xD800, 0xDFFF]。如果码点值是 noncharacter这也是一个解析错误但什么都不需要做。这里的 noncharacter 代表 Unicode 永久保留的码点用于 Unicode 内部它的取值范围是[0xFDD0,0xFDEF]还包括0xFFFE、0xFFFF、0x1FFFE、0x1FFFF、0x2FFFE、0x2FFFF、0x3FFFE、0x3FFFF、0x4FFFE、0x4FFFF、0x5FFFE、0x5FFFF、0x6FFFE、0x6FFFF、0x7FFFE、0x7FFFF、0x8FFFE、0x8FFFF、0x9FFFE、0x9FFFF、0xAFFFE、0xAFFFF、0xBFFFE、0xBFFFF、0xCFFFE、0xCFFFF、0xDFFFE、0xDFFFF、0xEFFFE、0xEFFFF、0xFFFFE、0xFFFFF、0x10FFFE、0x10FFFF。 如果码点值对应的字符是回车符0x0D或者码点值为控制字符集control character中的非 ASCII 空白符ASCII whitespace则是一个解析错误。这时需要将码点作为索引在下表中查找对应的替换码点 01 const CCR_REPLACEMENTS { 02 0x80: 0x20ac, 03 0x82: 0x201a, 04 0x83: 0x0192, 05 0x84: 0x201e, 06 0x85: 0x2026, 07 0x86: 0x2020, 08 0x87: 0x2021, 09 0x88: 0x02c6, 10 0x89: 0x2030, 11 0x8a: 0x0160, 12 0x8b: 0x2039, 13 0x8c: 0x0152, 14 0x8e: 0x017d, 15 0x91: 0x2018, 16 0x92: 0x2019, 17 0x93: 0x201c, 18 0x94: 0x201d, 19 0x95: 0x2022, 20 0x96: 0x2013, 21 0x97: 0x2014, 22 0x98: 0x02dc, 23 0x99: 0x2122, 24 0x9a: 0x0161, 25 0x9b: 0x203a, 26 0x9c: 0x0153, 27 0x9e: 0x017e, 28 0x9f: 0x0178 29 }如果存在对应的替换码点则渲染该替换码点对应的字符否则直接渲染原码点对应的字符。 上述关于码点合法性检查的具体实现如下 01 if (body) { 02 // 根据对应的进制将码点字符串转换为数字 03 const cp parseInt(body[1], hex ? 16 : 10) 04 // 检查码点的合法性 05 if (cp 0) { 06 // 如果码点值为 0x00替换为 0xfffd 07 cp 0xfffd 08 } else if (cp 0x10ffff) { 09 // 如果码点值超过 Unicode 的最大值替换为 0xfffd 10 cp 0xfffd 11 } else if (cp 0xd800 cp 0xdfff) { 12 // 如果码点值处于 surrogate pair 范围内替换为 0xfffd 13 cp 0xfffd 14 } else if ((cp 0xfdd0 cp 0xfdef) || (cp 0xfffe) 0xfffe) { 15 // 如果码点值处于 noncharacter 范围内则什么都不做交给平台处理 16 // noop 17 } else if ( 18 // 控制字符集的范围是[0x01, 0x1f] 加上 [0x7f, 0x9f] 19 // 去掉 ASICC 空白符0x09(TAB)、0x0A(LF)、0x0C(FF) 20 // 0x0D(CR) 虽然也是 ASICC 空白符但需要包含 21 (cp 0x01 cp 0x08) || 22 cp 0x0b || 23 (cp 0x0d cp 0x1f) || 24 (cp 0x7f cp 0x9f) 25 ) { 26 // 在 CCR_REPLACEMENTS 表中查找替换码点如果找不到则使用原码点 27 cp CCR_REPLACEMENTS[cp] || cp 28 } 29 // 最后进行解码 30 const char String.fromCodePoint(cp) 31 }在上面这段代码中我们完整地还原了码点合法性检查的逻辑它有如下几个关键点: 其中控制字符集control character的码点范围是[0x01,0x1f] 和 [0x7f, 0x9f]。这个码点范围包含了 ASCII 空白符0x09(TAB)、0x0A(LF)、0x0C(FF) 和 0x0D(CR)但WHATWG 规范中要求包含 0x0D(CR)。码点 0xfffd 对应的符号是 。你一定在出现“乱码”的情况下见过这个字符它是 Unicode 中的替换字符通常表示在解码过程中出现“错误”例如使用了错误的解码方式等。 最后我们将上述代码整合到 decodeHtml 函数中这样就实现一个完善的 HTML 文本解码函数 01 function decodeHtml(rawText, asAttr false) { 02 // 省略部分代码 03 04 // 消费字符串直到处理完毕为止 05 while (offset end) { 06 // 省略部分代码 07 08 // 如果满足条件则说明是命名字符引用否则为数字字符引用 09 if (head[0] ) { 10 // 省略部分代码 11 } else { 12 // 判断是十进制表示还是十六进制表示 13 const hex head[0] #x 14 // 根据不同进制表示法选用不同的正则 15 const pattern hex ? /^#x([0-9a-f]);?/i : /^#([0-9]);?/ 16 // 最终body[1] 的值就是 Unicode 码点 17 const body pattern.exec(rawText) 18 19 // 如果匹配成功则调用 String.fromCodePoint 函数进行解码 20 if (body) { 21 // 根据对应的进制将码点字符串转换为数字 22 const cp Number.parseInt(body[1], hex ? 16 : 10) 23 // 码点的合法性检查 24 if (cp 0) { 25 // 如果码点值为 0x00替换为 0xfffd 26 cp 0xfffd 27 } else if (cp 0x10ffff) { 28 // 如果码点值超过 Unicode 的最大值替换为 0xfffd 29 cp 0xfffd 30 } else if (cp 0xd800 cp 0xdfff) { 31 // 如果码点值处于 surrogate pair 范围内替换为 0xfffd 32 cp 0xfffd 33 } else if ((cp 0xfdd0 cp 0xfdef) || (cp 0xfffe) 0xfffe) { 34 // 如果码点值处于 noncharacter 范围内则什么都不做交给平台处理 35 // noop 36 } else if ( 37 // 控制字符集的范围是[0x01, 0x1f] 加上 [0x7f, 0x9f] 38 // 去掉 ASICC 空白符0x09(TAB)、0x0A(LF)、0x0C(FF) 39 // 0x0D(CR) 虽然也是 ASICC 空白符但需要包含 40 (cp 0x01 cp 0x08) || 41 cp 0x0b || 42 (cp 0x0d cp 0x1f) || 43 (cp 0x7f cp 0x9f) 44 ) { 45 // 在 CCR_REPLACEMENTS 表中查找替换码点如果找不到则使用原码点 46 cp CCR_REPLACEMENTS[cp] || cp 47 } 48 // 解码后追加到 decodedText 上 49 decodedText String.fromCodePoint(cp) 50 // 消费整个数字字符引用的内容 51 advance(body[0].length) 52 } else { 53 // 如果没有匹配则不进行解码操作只是把 head[0] 追加到 decodedText 上并消费 54 decodedText head[0] 55 advance(head[0].length) 56 } 57 } 58 } 59 return decodedText 60 }7、解析插值与注释 文本插值是 Vue.js 模板中用来渲染动态数据的常用方法 01 {{ count }}默认情况下插值以字符串 {{ 开头并以字符串 }} 结尾。我们通常将这两个特殊的字符串称为定界符。定界符中间的内容可以是任意合法的 JavaScript 表达式例如 01 {{ obj.foo }}或 01 {{ obj.fn() }}解析器在遇到文本插值的起始定界符({{)时会进入文本“插值状态 6”并调用 parseInterpolation 函数来解析插值内容如下图所示 解析器在解析插值时只需要将文本插值的开始定界符与结束定界符之间的内容提取出来作为 JavaScript 表达式即可具体实现如下 01 function parseInterpolation(context) { 02 // 消费开始定界符 03 context.advanceBy({{.length) 04 // 找到结束定界符的位置索引 05 closeIndex context.source.indexOf(}}) 06 if (closeIndex 0) { 07 console.error(插值缺少结束定界符) 08 } 09 // 截取开始定界符与结束定界符之间的内容作为插值表达式 10 const content context.source.slice(0, closeIndex) 11 // 消费表达式的内容 12 context.advanceBy(content.length) 13 // 消费结束定界符 14 context.advanceBy(}}.length) 15 16 // 返回类型为 Interpolation 的节点代表插值节点 17 return { 18 type: Interpolation, 19 // 插值节点的 content 是一个类型为 Expression 的表达式节点 20 content: { 21 type: Expression, 22 // 表达式节点的内容则是经过 HTML 解码后的插值表达式 23 content: decodeHtml(content) 24 } 25 } 26 }配合上面的 parseInterpolation 函数解析如下模板内容 01 const ast parse(divfoo {{ bar }} baz/div)最终将得到如下 AST 01 const ast { 02 type: Root, 03 children: [ 04 { 05 type: Element, 06 tag: div, 07 isSelfClosing: false, 08 props: [], 09 children: [ 10 { type: Text, content: foo }, 11 // 插值节点 12 { 13 type: Interpolation, 14 content: [ 15 type: Expression, 16 content: bar 17 ] 18 }, 19 { type: Text, content: baz } 20 ] 21 } 22 ] 23 }解析注释的思路与解析插值非常相似如下面的parseComment 函数所示 01 function parseComment(context) { 02 // 消费注释的开始部分 03 context.advanceBy(!--.length) 04 // 找到注释结束部分的位置索引 05 closeIndex context.source.indexOf(--) 06 // 截取注释节点的内容 07 const content context.source.slice(0, closeIndex) 08 // 消费内容 09 context.advanceBy(content.length) 10 // 消费注释的结束部分 11 context.advanceBy(--.length) 12 // 返回类型为 Comment 的节点 13 return { 14 type: Comment, 15 content 16 } 17 }配合 parseComment 函数解析如下模板内容 01 const ast parse(div!-- comments --/div)最终得到如下 AST 01 const ast { 02 type: Root, 03 children: [ 04 { 05 type: Element, 06 tag: div, 07 isSelfClosing: false, 08 props: [], 09 children: [ 10 { type: Comment, content: comments } 11 ] 12 } 13 ] 14 }
http://www.hkea.cn/news/14309220/

相关文章:

  • 网站后台的网址忘记了泉州网站建设企业
  • 现有的网站开发技术怎么做网站相册
  • 做app护肤网站关于电商的电影或者电视剧
  • 中介网站制度建设网站分析一般要重点做哪几项内容
  • 哪些网站做的最好一键生成个人网站
  • 网站开发人员负责方面飓风 网站建设
  • html 网站 模板中文网站建设的公司怎么做
  • 广州公司的网页怎么做的上海排名优化工具价格
  • 买了个网站后怎么做photoshop电脑版怎么下载
  • 教人怎么做网页的网站电子商务网站推广与建设论文
  • 温州市建设工程质量安全管理总站网页传奇怎么赚钱
  • 手工艺品外贸公司网站建设方案一个虚拟主机如何做多个网站
  • 如何做微信网站做广告深圳全面放开
  • dw网站导航怎么做药品网站如何建设
  • 做赌钱网站设计网页的代码
  • 第一推是谁做的网站衡阳市建设网站
  • 响应式网站自助建站知名网站制作服务
  • 学校网站制作平台联想电脑建设网站前的市场分析
  • 网站建设需要注意的事情免费个人网站建设公司
  • 教育培训网站设计加盟培训机构
  • 登陆网站空间的后台附近旧模板出售市场
  • 济南网站建设app一键开启网站
  • 织梦网站源码转换成wordpress郑州市网络设计
  • 中国保密在线网站培训阿里巴巴做外贸流程
  • 深圳住建设局官方网站电商营销策划方案
  • 商城网站怎么建设哈尔滨房地产型网站建设
  • 奇信建设集团官方网站做平面设计兼职的网站
  • 网站评估内容 优帮云wordpress 修改
  • 自助建站系统模板大连装修网站推广
  • thinkphp做企业网站seo是免费推广吗?