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

我心飞翔 分类:javascript

首发:mp.weixin.qq.com/s/HOGmlICuH…

不同于 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 中的本地依赖,预期最终输出目录中,包含这些被引用的资源文件;

  • 压缩 WXML 文件内容;

三、实现思路

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

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

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

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

四、实战踩坑

以上,我们的 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 (").

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

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

  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 会解析成

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

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

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

基于对规范的理解,我们再回过头看刚刚的 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 所示的属性值中含有应当被转译的实体字符:

预期:

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

实际:

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

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

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

<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 互转很方便。

六、参考链接

html.spec.whatwg.org/multipage/s…

github.com/kangax/html…

我是彩蛋分割线

Hello,各位老伙计,不知道你们有没有发现,我们的公众号多了两个菜单-> 内推 和 店铺。

上一篇文章中我们提到,希望把天赋带到公众号,捣腾一番。思来想去,觉得大多数人都在开课,而我们作为全职选手,其实没有那么多的精力去做这样的事情。

但是我们却可以帮忙模拟面试!之所以有这样的想法,是因为可能有校招/社招小伙伴们不了解当下的面试趋势和难度,以及多少有点对实战面试有抵触心理和些许恐惧,或者是害怕没有准备好,浪费了面试机会。

那么我们就是想为这类小伙伴提供一个真人模拟面试的机会。那么我们为什么能有底气做这件事呢?首先我们是一群来自不同大厂的小伙伴,从能力上,多少还是有保证的(拍胸脯),其次,我们人多(?),如果一个人持续做模拟面试这件事,那么其实是很耗费时间精力的,毕竟一次面试时间也不短。但是我们有多位靠谱的小伙伴可以持续性提供这样的服务,所以相对而言能够保证把这件事持续做下去。

以及我们也会考虑提供简历修改意见等服务,因为有很多小伙伴苦于做了事情,却不知道怎么去写去表达,那么我们可以一一帮你解决这类似问题。

当然,这个事情还在筹划中,我们的店铺中的相关服务商品,也还没有完善好,但是你们已经可以访问店铺中的商品了。伙计们不妨在评论区或者私信告诉我们有什么建议或者想法,或者是其他希望我们提供的帮助,因为我们目前想要做的就是集思广益,看一下我们能为大家做些什么有意义的事。以上~

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

回复

我来回复
  • 暂无回复内容