写点什么

BUG!从编写 Loader 到窥探大佬 Debug 全过程

用户头像
HZFEStudio
关注
发布于: 2021 年 04 月 24 日

原文:BUG!从编写 Loader 到窥探大佬 Debug 全过程

​审核:k、nightcat、geeku、L·Lawliet、goodboy


不同于 web 开发的 html + js + css,在原生小程序开发中,我们使用的是 wxml + js + wxss。web 开发中,我们常借助 webpack 的能力进行代码打包,小程序中同理可用。


今天我们的目标是编写一个真实可用的 wxml-loader,这个 loader 主要用于收集 wxml 中的本地资源,比如图片,然后就可以交由 file-loader 来进行文件的处理;以及支持输出压缩后的 wxml 文件,减少文件大小。


一、术语解释

  1. WXML 是小程序的一套标签语言,可类比于 HTML。

  2. AST 是抽象语法树(Abstract Syntax Tree)。

  3. sax 是可以用于 XML 和 HTML 的解析器。

  4. html-minifier 是基于 JavaScript 开发的 HTML 压缩工具


二、目标功能

  • 收集 WXML 中的本地依赖,预期最终输出目录中,包含这些被引用的资源文件;

<image src="../img1.jpg"></image>
复制代码


  • 压缩 WXML 文件内容;

<!-- 输入 --><view>  123</view><!-- 输出 --><view>123</view>
复制代码


三、实现思路

  • 收集依赖:获取到 WXML 字符串内容后,我们自然而然希望把他转换为 AST 进行分析,此处我们借助第三方工具 sax parser,通过解析后的数据,根据节点类型、属性类型匹配的情况,按需收集对应的本地资源地址。

// webpack 匹配到 wxml 后缀文件后// 交由给当前 loader 处理// content 参数获取 wxml 文件内容function wxmlLoader(content) {  let requests = [];  const rootCtx = this.rootContext;  const parser = sax.parser(    false,    { lowercase: true }  );    // ...
// opened a tag. param: node parser.onopentag = function(n) { // 解析 node 节点内容进行 // 本地资源收集 (getResource) // 然后调用 urlToRequest // 将资源 url 转为 webpack 模块请求 requests = requests.concat( getResource(n).map((_) => urlToRequest(_, rootCtx) ) ); };
parser .write(`<R>${content}</R>`) .close();};
复制代码


  • 压缩文件:此处可以直接使用第三方工具 html-minifier 。

function wxmlLoader(content) {  const callback = this.async();  const minimizeOpt = { /** */ };
// ...
// 在parser流结束时 // 进行 content 最后的处理 parser.onend = async function() { try { // ... if (minimizeOpt) { content = HTMLMinifier .minify( content, minimizeOpt ); } callback(null, content); } catch (error) { callback(error, content); } };}
复制代码


四、实战踩坑

以上,我们的 wxml-loader 的核心功能就完成了(不好意思,省略了很多代码)。放入我们的实际开发中项目进行验证测试。


Bug1

实践出真实,遇到了 Parse Error 的报错:

<view>  500元≤累积业绩{{'<'}}1000元</view>
复制代码

在小程序中,可以用 {{变量名}} 这样的插值表达式来绑定 WXML 文件和对应的 JavaScript 文件中的 data 对象属性。


而对于 html-minifier 而言,这个语法只是普通的字符串内容,在解析到 {{'<'}} 中的 < 时,会被理解为标签的开头,因此报了 Parse Error 的错。


为减少对已有项目的内容改动,选择了以下修复方案:

增加 html-minifier 配置,用于忽略插值表达式片段。:ignoreCustomFragments = [/{{[\s\S]*?}}/] 。


Bug2

增加配置后,webpack 编译成功。但是打开小程序编辑器,体验 dist 目录结果:项目无法运行。排查发现编译结果出现问题。

<!-- 能正常运行(输入输出内容一致)-->
<!-- 输入 --><div class="{{a?'aa':'bb'}}">1</div><!-- 输出 --><div class="{{a?'aa':'bb'}}">1</div>
复制代码


<!-- 不能正常运行(输入输出内容不一致)-->
<!-- 输入 --><div class='{{a?"aa":"bb"}}'>1</div><!-- 输出 --><div class="{{a?"aa":"bb"}}">1</div>
复制代码


这个编译问题是在我们加了忽略插值表达式的配置后才出现的。刚才添加的配置,影响了标签属性引号的处理。


我们可以找到 html-minifier 的相关源码进行分析:

// 是否禁止属性转译if (!options.preventAttributesEscaping) {  // 是否有指定过标签属性的引号是什么  if (typeof options.quoteCharacter === 'undefined') {    var apos = (attrValue.match(/'/g) || []).length;    var quot = (attrValue.match(/"/g) || []).length;    attrQuote = apos < quot ? '\'' : '"';  } else {    attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';  }
// 根据属性引号值 // 按需转译属性值内实体字符 if (attrQuote === '"') { attrValue = attrValue.replace(/"/g, '&#34;'); } else { attrValue = attrValue.replace(/'/g, '&#39;'); }}
emittedAttrValue = attrQuote + attrValue + attrQuote;
复制代码


由于我们没有特别配置过 quoteCharacter ,根据源码逻辑,他会走入 typeof options.quoteCharacter === 'undefined' 分支。


该分支逻辑是对 attrValue 中包含的单/双引号的个数进行比较:属性值中双引号多,属性引号应当用单引号,反之亦然。举个具体例子:

<div class='abcd"e'>1</div>
复制代码

这个例子中的属性值 attrValue 是 abcd"e,放进前面这段分支逻辑处理,代码逻辑会认为,这个属性值中有一个双引号,零个单引号,因此当前的属性值一定是被单引号括住,即 attrQuote 是单引号。


为什么会有这样的判断逻辑?

我们可以进一步查看 html 的相关规范。对于单引号属性值语法、双引号属性值语法,有规定:

Single-quoted attribute value syntax

The attribute name, followed by zero or more ASCII whitespace, followed by a single U+003D EQUALS SIGN character, followed by zero or more ASCII whitespace, followed by a single U+0027 APOSTROPHE character ('), followed by the attribute value, which, in addition to the requirements given above for attribute values, must not contain any literal U+0027 APOSTROPHE characters ('), and finally followed by a second single U+0027 APOSTROPHE character (').


Double-quoted attribute value syntax

The attribute name, followed by zero or more ASCII whitespace, followed by a single U+003D EQUALS SIGN character, followed by zero or more ASCII whitespace, followed by a single U+0022 QUOTATION MARK character ("), followed by the attribute value, which, in addition to the requirements given above for attribute values, must not contain any literal U+0022 QUOTATION MARK characters ("), and finally followed by a second single U+0022 QUOTATION MARK character (").


基于单引号的语法规范,我们画个图来快速理解下(双引号语法规范类似):



  1. 属性名 name

  2. 后面可以有零或若干个空格

  3. 等号 (EQUALS SIGN character)

  4. 后面可以有零或若干个空格

  5. 一个单引号 (single U+0027 APOSTROPHE character ('))

  6. 属性值 value,值中不可以有单引号

  7. 一个单引号 (single U+0027 APOSTROPHE character ('))


我们可以快速测试一下以下 3 个用例(文章后面也会再提及),以下三个用例会以双引号的语法规范进行解析

// 用例1 (name 是 testsome,value 是 a"aa)a.innerHTML =  '<div testsome  = "a"aa">123</div>'// 用例2(name 是 testsome,value 是 a\"aa)a.innerHTML =  '<div testsome  = "a\"aa">123</div>'// 用例3(name 是 testsome,value 是 a&quot;aa)a.innerHTML =  '<div testsome  = "a&quot;aa">123</div>'
复制代码

前两者的 html 会解析成



第三种写字符实体的会解析成



基于对规范的理解,我们再回过头看刚刚的 html-minifier 的实现,可以意识到,这个库是对 html 规范进行了更宽松的处理(允许属性值中含有引号,并帮你按需转译),他对于属性值的单双引号的处理逻辑是:“对 attrValue 中包含的单/双引号的个数进行比较:属性值中双引号多,属性引号用单引号,反之亦然。”。


这么做实际是为了,在没有指定单双引号值配置的前提下,尝试检查属性值中是否含有双引号或单引号,以此来推测,当前属性值是用双引号还是单引号括着的。


假如值内,有且主要是单引号,那外部肯定是用双引号,反之亦然,确定好属性引号后,再将属性值中含有的相关引号转换成字符实体,以免造成用例 1/2 中的不在预期内的解析结果。


由于我们前面设置了 ignoreCustomFragments,将所有插值表达式忽略掉,那么根据逻辑,当前的属性引号就会被认为应该取双引号,导致这个 bug:

<!-- 输入 --><div class='{{a?"aa":"bb"}}'>1</div>
<!-- 插值表达式被忽略 也就是可以被看作 --><div class=''>1</div>
<!-- 然后就会被 html-minifier 解析成 --><div class="">1</div>
<!-- 输出 属性引号为双引号 表达式内也正好是双引号 小程序运行报错 --><div class="{{a?"aa":"bb"}}">1</div>
复制代码

而为了解决这个连锁 bug2,我们可以考虑把 preventAttributesEscaping 设为 true,不让 html-minifier 进行属性值引号的处理。

基于以上对 html 规范的理解,假设我们这么做,会引入 bug3,就是用例 1/2 所示的属性值中含有应当被转译的实体字符:

预期:



实际:



因此最正确的选择应该是改业务代码,特殊字符应该用字符实体来代替。


至此,可能有人早就在质疑,为什么不直接用实体字符,为什么要写这种奇葩代码:

<view>  500元≤累积业绩{{'<'}}1000元</view>
复制代码


因为小程序不支持直接在 wxml 中书写实体字符,实体字符会被当作普通的字符串进行展示。而直接写 < ,小程序开发工具本身也会解析失败,因为会把他当作标签的开头。


因此才会有开发者,曲线的利用插值表达式,将' < '单拎出来,不让 wxml 的 parser 处理。但是其实可以使用小程序提供的 text 组件,该组件支持 decode 参数,decode 可以解析以下实体字符:

&nbsp; &lt; &gt; &amp; &apos; &ensp; &emsp;
复制代码

五、总结

<!-- web 能正常显示 < 符号 --><div><</div>
<!-- web 能正常显示 < 符号 --><div>&lt;</div>
<!-- 小程序解析出错、html-minifier解析出错 --><div><</div>
<!-- 小程序直接显示 &lt; --><div>&lt;</div>
<!-- html-minifier解析出错 需要额外加若干配置来解决 --><div>{{'<'}}</div>
复制代码


最后, wxml-loader 的编写其实很简单,难点总是在于兼容各种人写出来的代码。本文用较大的篇幅记录了一次 debug 的过程,在已有项目中使用我们所编写的 wxml-loader 时,可以通过项目实际情况,按需配置 ignoreCustomFragments 和 preventAttributesEscaping 参数规避文中所说的部分问题。


当然,如果团队代码书写规范,更正确的操作应该是迎合小程序规则,使用 text 来解决问题,就不会有这么多衍生的 bug。


同时,在踩坑过程中我们还发现一个“彩蛋”,就是 sax 作为 html parser,一直都没有掺合进来折磨我们,报错的一直都是小程序本身和 html-minifier 库。这也可以说明 html parser 的割裂问题,各自有自己的 htmlParser 的实现。

而现代化的工具一般都统一了解析方式,比如 EStree / PostCSS 等,就是为了统一定个标准出现的。html-minifier 虽然是个成熟的库,但是也比较老了,有兴趣的小伙伴可以了解一下 unifiedjs ,它定义了一个通用语法树结构, 旗下 markdown / html / text / Graphviz 互转很方便。


六、参考链接

https://html.spec.whatwg.org/multipage/syntax.html#attributes-2


https://github.com/kangax/html-minifier/blob/583e0861ee852a76acb1e8ec00b3de35a024927d/src/htmlminifier.js#L592


发布于: 2021 年 04 月 24 日阅读数: 9
用户头像

HZFEStudio

关注

HZFE全栈社区 2019.01.15 加入

HZFE 是……

评论

发布
暂无评论
BUG!从编写 Loader 到窥探大佬 Debug 全过程