如果在项目中遇到下面这段 JavaScript 代码编译报错,并且是因为字符串中的 <
,你会诧异吗?更诡异的是就算注释掉也无济于事,你必须将 <
删除才行!
export default {
data() {
return {
something: "<",
};
},
};
上面这段代码在正常情况下的确是不存在任何问题,完全满足 JavaScript 的规范,但它却真的导致我们项目编译报错了。
问题初现
今天快下班的时候一个同事找到我说遇到个问题,从 master
更新了代码后发现 Vue 项目编译报错,怎么都跑不起来,报错信息又很奇怪,看不出任何实质性的问题!
编译报错如下:
看似是因为在某个 vue
文件中访问了某个 undefined
变量的 data
属性,一般情况下这种错误是因为运行时访问了 a.data
但 a
是 undefined
导致的,但这明显是在项目启动阶段,即编译期间,而且提示的是 Syntax Error
,因此这种错误只会是编译过程中脚本报错,确实很有误导性,同事找到我的时候还叮嘱我说他排查了整个 withholding-xyd-dialog.vue
中所有和 data
有关的地方,都看不出代码有任何问题,这个代码困扰了他一天了 ~
正好,页面也报错了,但是错误提示略有不同:
打印的调用栈信息都是在 node_modules
的包里,一般情况下,我们的确应该怀疑是自己的代码有问题,而不应该首先觉得是 node_modules
中的包出了问题。
有 BUG,大 BUG !!!
浮出水面
我建议他先按照二分法来排查,即先删除出现问题文件中一半的代码,如果还有问题再删除剩余的一半,直到不再存在问题就可以定位到是哪里引起的。经过他一步一步的二分,最后定位到这段代码:
export default {
// ...
data() {
return {
// ...
updateRuleTips: "0.00 < 划扣金额 ≤ 2000.00", // 删除这行代码就好了,就算是注释掉都不行,必须删除才可以
// ...
};
},
// ...
};
不管怎么说,这行代码属于怎么看都不可能出问题的那种,但偏偏就是它有问题,最后定位到字符串中的 <
符号,删掉它就好了,加上又会出问题,即使是注释掉也同样报错。另外搜索了一下项目的代码,发现目前也有 script
块中字符串里面有 <
的情况,不过比较特殊,在字符串中是 <span>
这种,即内容是一个标签的字符串,然后当我们在报错的字符串中的 <
之后的几个位置加上 >
后,错误就消失了!
第一感觉就是 <
被当成了标签的首字符,而在该字符串中没解析到结束符号 >
,因此导致报错,但这只是一种最可能的猜想,通常编译器遇到字符串都只是整个拿出来作为一个 AST Node
,一般也并不关心里面是什么内容。那么在编译期间谁会去处理一个字符串字面量里面的内容呢?匪夷所思!
命令提示符中的错误信息的确没有任何帮助,但页面中报错相对来说还能看出编译期间的调用栈信息,即
test
函数:babel-preset-typescript-vue\lib\index.js:18:38
parse
函数:fast-xml-parser\src\parser.js:28:39
getTraversalObj
函数:fast-xml-parser\src\xmlstr2xmlnode.js:248:29
这里面有 fast-xml-parser
,它就是最后报错的地方,而且也和标签能扯上关系,另外我知道 babel-preset-typescript-vue
是在上一次上线的需求中为了支持 vue 文件内的 <script lang="ts">
而添加的第三方包,我尝试打开了 node_modules
下的 babel-preset-typescript-vue\lib\index.js
文件,本来是想打一个断点看看,但它的代码确实太少了,一览无余:
test
函数就在这里,整个包的思路也非常简单,就是通过 babel
的 overrides
来针对性的修改某些配置,而这里的逻辑就是通过 test
检测文件是否是 .vue
结尾,如果是就用 fast-xml-parser
来将它解析成三个块,即 template
、script
和 style
块,然后判断 script
是否带有 lang=ts
的参数,如果是 .vue
结尾的文件并且 <script>
中有 lang="ts"
的属性就加一个 @babel/plugin-transform-typescript
的插件来处理。
这也正是页面中报错的调用栈对应的地方,看来是来对了位置。大致看下来,应该就是 fast-xml-parser
将整个文件的内容当作 xml
文件进行解析,它并不知道 <script>
中是 JavaScript 代码,也不知道 <style>
中是样式相关的代码,统统都按照 xml
进行解析,我们知道在编写 xml
文件的时候如果内容中需要用到 <
则要用字符实体 <
来表示,即 less than
的首字母,大于符号 >
则要用 >
来表示,即 greater than
,因此当解析到 "0.00 < 划扣金额 ≤ 2000.00"
中 <
的时候它把 <
字符当做标签开始符号进行解析,从而导致了编译报错。但我们的项目代码并不能写成 <
来表示的,因为它存在于一个 JavaScript 的字符串中,并不是模板中。如果写成 <
则无法自动转换成 <
字符。
不过这样的解释大概能解释通两个问题,即:
-
为什么
<
导致了编译报错- 因为
<
被当做了标签的开始符号进行解析
- 因为
-
为什么必须要删除掉导致问题的那行代码才不会报错
- 因为对于
fast-xml-parser
来说 JavaScript 的注释并不能起任何作用,只是在源文件中增加了几个字符而已,它该干嘛还是干嘛
- 因为对于
然而,这样的解释并不能 cover 住所有的场景。
比如在某些 vue 文件 script
部分的代码中会出现 a < b ? xxx : yyy
这样的代码,但并不会导致编译报错,如果按照之前的解释来说, fast-xml-parser
将整个 vue 文件中的内容完全当做 xml
进行解析,被 <script>
标签包含在内的 JavaScript 代码也被当做标签内容进行解析的话,那 JavaScript 表达式 a < b
中的 <
和字符串 "0.00 < 划扣金额 ≤ 2000.00"
中的 <
对于 fast-xml-parser
来说都是一视同仁的,按道理都会被当做标签开始符号进行解析,但为什么这里就没报错呢?难道 fast-xml-parser
还能识别 JavaScript 字符串,只对 <script>
内部的字符串做了处理?
引人入胜
没有办法,我必须得进到 fast-xml-parser
里面去探一探究竟。
我们以一段最简单的 Vue 代码来进行测试:
<script>
export default {
data() {
return {
a: "<",
b: 1 < 2 ? 1 : 2,
};
},
};
</script>
其中 a: "<"
满足报错的情况,b: 1 < 2 ? 1 : 2
则打算用来测试为什么它里面的 <
不会导致编译报错。
编写一段测试代码:
const vueCode = `
<script>
export default {
data() {
return {
a: "<",
b: 1 < 2 ? 1 : 2,
};
},
};
</script>
`;
require("fast-xml-parser").parse(vueCode.trim(), { ignoreAttributes: false });
我们到 fast-xml-parser
报错的地方打上断点
可以看到和其它解析器一样,它会循环将每个字符取出来进行处理,截图框里的地方就是当处理到字符 <
时的流程分支。
在上面那段测试代码中,首先执行到 <script>
的 <
会走到这里,其次就是 a: "<"
中的 <
会再次进入这个分支。
通过对 fast-xml-parser
源码的调试,我发现当解析到 <
后会遍历后面所有的字符,试图找到一个 >
来结束当前标签,具体代码如下面截图:
正常情况下是这样的流程:
- 当遇到一个
<
默认为标签开始字符,然后继续处理后面的字符 - 如果遇到
'
或者"
则记录下来(框 2),再次遇到对应的'
或者"
则认为两个引号中间这一段属于标签属性的值,并清空attrBoundary
的值以便处理接下来的属性(框 1),正常情况下引号是前后匹配的,不会出现只有一个引号的情况。 - 如果遇到
>
则停止遍历,返回处理当前标签后的索引和得到的匹配结果
但我们出现异常的流程就在上图【框1】
那里,在 a: "<"
这段字符串中,由于第一个引号在 <
之前,而解析标签属性的逻辑在遇到 <
时才开始,因此 fast-xml-parser
将后面这个 "
当做了第一个引号,但遍历后面的代码无法解析出对应的第二个引号。 从而一直无法清空 attrBoundary
的值,导致每次循环都走的【框1】
的分支,即使是遇到最后的 </script>
中的 >
也不会去处理,最终由于遍历完了所有剩余的字符,函数直接退出,而没有任何返回值。而外层会获取其返回值的 data
属性:
当遇到 <
之后,会执行到上图标记 1
的地方去调用函数 closingIndexForOpeningTag
处理标签属性,
- 正常情况下,如果有引号也是成对匹配的,它会遇到
>
并结束,此时有返回值,然后会在上图标记2
的地方去获取返回值的data
属性 - 异常情况下,它会一直处理传入的字符串直到最后一个字符并退出循环跳出
closingIndexForOpeningTag
,此时没有任何返回值。我们的报错就是这种情况。
现在再解释为什么 b: 1 < 2 ? 1 : 2
不会导致报错也能解释得通了。因为这是一段 JavaScript 表达式,遇到 <
后 fast-xml-parser
开启了标签解析,但并没有出现引号不匹配的情况,因此始终会找到一个 >
来结束标签解析(可能直到 </script>
中的 >
,总之是 <
之后最近的 >
)。
聪明的你可能会说,即便是不会报错,但这样解析出来的 xml
岂不是完全是错的吗?在 <
之后直到第一个 >
之前的所有代码都被当做了标签内属性进行处理,这错误也太明显了,滑天下之大稽!
是的,你说的没错,和我们想的一样,当我删除 a: "<",
这行代码之后再进行测试,会走到下面这里:
从左边的监视里面我们可以看到,此时的 ch
为 >
,表示当前已经处理到 >
这个字符,tagExp
就是 <
到 >
之间的所有字符。
也许你想问,这么明显的解析错误,babel-preset-typescript-vue
的作者怎么没有发现呢?
因为 babel-preset-typescript-vue
需要知道的只是 <script>
中是否带了 lang="ts"
的属性,只要 <script>
标签解析对了,里面是什么结果并不关心。也可能作者并没有意识到把 vue
文件当做 xml
来解析的潜在问题。
不过此时已经清楚了问题的来龙去脉,我来捋一下:
babel-preset-typescript-vue
通过修改 babel 配置来达到支持<script lang="ts">
的目的,它用了fast-xml-parser
来解析 vue 文件的几个核心块fast-xml-parser
将script
中的字符串当作xml
来进行解析,因此需要满足xml
的基本语法,<
会被当做标签的首字符,需要有对应的结束字符>
才可以,在xml
中如果要表示<
则用<
代替,但并不满足我们的场景。fast-xml-parser
遇到<
之后开启标签解析,遇到引号会记录下来并且一直到对应的第二个引号到来之前都不会走到其它分支中,即使遇到>
也同样不会进行处理,因此会一直处理直到传入文本的最后,然后结束循环,但没有任何返回值。当发生这种情况时去访问解析结果的data
字段,就会报TypeError: Cannot read property 'data' of undefined
的错误。
解决问题
要解决这个问题非常简单,之前我也在部门内部编写了一些修正和规范代码格式的工具,借助之前的经验,我采用 vue-template-compiler
来实现,它是 vue
官方提供的包,用于处理 vue
文件再好不过了。以下是我对 test
内部逻辑的调整:
其中红框中是新增的逻辑,白框中是删除掉的源码。
const result = require("vue-template-compiler").parseComponent(
fs.readFileSync(filePath, {
encoding: "utf8",
})
);
return result.script?.attrs?.lang === "ts";
parseComponent
也会解析出 vue
文件的几个代码块,然后保持和原逻辑一样,判断 script
块中的 lang
是否是 ts
即可。
经过这样的调整之后,重新运行代码,错误消失。
总结
刚开始排查到是因为 JavaScript 中字符串带有 <
导致编译错误的时候真的不敢相信自己的眼睛,甚至怀疑自己写了这么久 JavaScript 难道还存在这样的知识盲区 ... 但当打开 babel-preset-typescript-vue
源码的那一刻,一切都清楚了...
babel-preset-typescript-vue
这个库的代码确实不多,并且我现在已经非常清楚它的原理和实现方式,因此打算将它的代码优化后放到项目中,后期不再使用该库。
到这里问题就基本解决了,另外如果各位有什么问题或者建议,可以随时留言给我,如果有写得不明确的地方也欢迎指出。
又是一个愉快的周五,下班。
祝大家周末愉快! 🙂
对了,还有个小插曲,当我准备写这份总结的时候,我尝试还原该包代码以便复现错误,但发现虽然代码还原了,重新启动项目依旧没有报错。我首先怀疑是缓存的问题,但是为什么第一次修改代码重新跑就 OK 了呢?于是我删除了 node_modules/.cache
目录和 node_modules/babel-preset-typescript-vue
包,然后重新 npm i
后再启动项目,果然错误又出现了,因此我猜测的确是缓存导致的,第一次修改代码然后成功运行是因为一开始为了排查这个问题,我拉了最新代码之后导致这个文件内容发生变化从而导致启动的时候缓存失效,因此需要重新编译,但编译过程中发生报错,因此没有生成新的缓存,当我修改了第三方库中的代码重新启动之后,成功运行并生成了该项目代码文件对应的缓存,当我再将第三方库中代码还原后重新启动时,由于已经有缓存并且文件未发生任何改动,因此直接走缓存而没有再次编译该文件,错误也就无法复现,当我对该文件做简单修改之后,必将会出现之前的错误。