阅读 182

🔍 前端侦探笔记<之>探秘隐身字符

我,是一名代码侦探,我的工作就是侦破 Bug,当然偶尔也亲自制造 Bug。

YYYY 年 MM 月 DD 日,我接到一份委托:解决一桩发生在表单输入框上的灵异事件。

一、案情还原

事情是这样的,公司有一个后台管理系统,主要用来通过表单配置营销活动。表单中有一个配置项,是活动页面上某按钮的跳转链接,运营同学把 URL 粘贴进输入框,在表单页能实时生成一个二维码,方便扫码验证。

表单示意图 (URL 以掘金文章链接代为示例,下文同)

而这次配置活动时,运营同学却扫不开二维码了。但这还不是重点。

运营同学找到了产品同学反馈问题,产品同学又找到测试同学对问题进行分诊。测试同学尝试复现问题,就在这时,更诡异的事情出现了。

测试同学把输入框里的 URL 复制出来,粘贴到飞书中发送出去,再复制发送出的消息里的 URL,粘贴到输入框里后,生成的二维码是可以打开的!

经过飞书发送后的 URL 正常

了解案情后,我首先想到要确定波及范围。抽查了几个往期活动的配置表单,其中的链接二维码均正常。我松了一口气,万幸万幸,没有捅出新的娄子 —— 毕竟刚刚改过这个输入框的代码的人,正是在下。我昨天恰好给这个输入框的 v-model 加了 trim 修饰符。

其他二维码正常,再加上经过飞书发送后的 URL 可访问,综合这两条线索,可以确定输入框 <input v-model.trim /> 和二维码生成组件是没有问题的。灵异事件的波及范围基本可以圈定在这次活动配置中,并且可以断定,问题根源就来自运营同学粘贴进来的 URL。

可是经过飞书加工后的链接,和运营同学粘贴进来的链接,是一模一样的,至少从肉眼来看,二者没有任何差别。难道飞书还有开光辟邪之功效?

二、抽丝剥茧

知乎上讲,先问是不是,再问为什么。首先需要确认的是,这两个 URL 是否真的一样。干咱们这行的都知道,不能相信自己的眼睛,更何况我还是高度近视。

我把经飞书开过光的 URL 复制出来,又在灵异输入框上打开 Chrome 开发者工具,拿到了那个中了邪的 URL。(这里有个小窍门,在 Elements 面板中选中元素,在 Console 里可以直接用 $0 拿到对应的 DOM 对象,而访问 $0.value 就能获取到输入框的内容。)

为了方便描述,我把能正常访问的 URL 称为「真链接」,而另一个自然就叫「假链接」。把涉事双方拉出来当堂对质,连谛听都不用问:

真假对质

果如各位看官所料,二者确不相同。

两个字符串不相等,可能是字符个数不同,可能是字符顺序不同,也可能兼而有之。视觉表现上,二者的字符排列顺序是一致的;而从长度看,就算你用游标卡尺多次测量取平均值,二者也是毫厘不差。但如果真用尺子计算字符串长度,恐怕我要被业界除名了。代码圈儿里的事,还得用代码手段摆平。通过比较 length 属性,我发现,假链接要比真链接多一个字符。

两个 URL 的同与异

那么课后作业来了:如何找出两个字符串的不同之处?这道算法题留给有余力的同学,有更优方案的话,放学别走评论区见。

为了快速排查,我顾不上性能和优雅,简单粗暴地从头到尾遍历对比。幸运的是,从第一个字符开始就不一样;不幸的是,这第一个字符,我看不见它。

凶手现身

这真让人头秃啊。它没有宽度,所以肯定不是空格;它影响了字符串长度,所以肯定也不是空字符。它,隐身了……

神奇的隐身字符

朋友们,凡是会隐身的可都是不好惹的狠角色啊。一连串身影从我脑海里闪过:死亡圣器加身的哈利•波特、《神奇四侠》中的隐形女、痴迷至尊魔戒的咕噜、上线就隐身的 QQ 好友…… 咳咳,扯远了。反正,对于看不见摸不着的存在,我总是有那么点肝儿颤的。

稳妥起见,我再回头验证一下,看两个 URL 是不是只有第一个字符不同:

去掉首个字符的假链接就是真链接

呼…… 还好还好,这样的祸害有一个就够了。

这样一个透明的字符,怎么会出现在这里呢?我回访了当事人运营同学,她说这个 URL 的产生经历了这样的步骤:

  1. 别人通过飞书把 URL 发送给她。
  2. 她把 URL 复制粘贴到飞书的消息输入框,手动更改了链接中的几个字符。
  3. 复制手改后的 URL,粘贴进案发现场的输入框。

我打开飞书依样画葫芦,很快就摸索到了此中机巧:在飞书的聊天输入框换行后,第二行文字的开头就会多出一个字符,恰恰就是我刚刚揪出来的灵异字符。但把多行文字发送出去后,飞书会把灵异字符替换成回车符,所以发出的 URL 是正常的。

在飞书中还原案情

第二行开头多了个字符

那么现在来龙去脉就明朗显现了:隐身字符挡在了 URL 中的网络协议前面,二维码生成组件不管这个字符串是否是有效 URL,统统尽职尽责地转为了二维码;扫码工具解析二维码,得到字符串后一看 —— 这才不是一个 URL 呢哼,有内鬼终止交易,于是导致无法访问到 URL 对应的页面。

三、刨根问底

现在我倒是捉住了罪魁祸首,可是没办法看破它的真面目。在中国的神话体系里,这叫做沾上了不干净的东西 —— 我上哪弄牛眼泪或者犀角去?

别慌,我们知道,计算机系统中能展示和使用哪些字符,要取决于该系统支持什么样的字符集。只要不是黑户,每个字符的户口信息一定是记录在案的。既然这个隐身字符能在浏览器里兴风作浪,那么在字符集中一定记录着它的身份证号 —— 码点(Code point)。

我用 codePointAt() 得到了它的码点,当然,得到的码点是十进制的,是给人类看的,要想拿来查户口,还得用 toString(16) 转为 16 进制才行,得到了结果 2063。

十进制码点

有了身份证号码,我来到了专管字符户籍的衙门口 —— Unicode 官网,输入 2063,按图索骥找到了隐身字符的户籍信息。

隐身字符的官方定义

从官方定义来看,它是个隐形的逗号,用来联结两侧的数学符号,以构成一个列表。

以往这种在输入框中去掉首尾多余字符的需求,我们会习惯性地依赖 v-model.trim 去解决,但从现状来看,代号 2063 明显是突破了 trim 修饰符的围追堵截,倔犟地挡在了 URL 的前头 —— 玩老鹰捉小鸡呢这是?可见 trim 不是万能的。这真让人没有安全感啊,我得知道到底哪些能拦住、哪些不能。

要想知道 trim 修饰符的内部逻辑,最准确的答案无疑就在 Vue 源码中。我在 Vue 源码中找到了关于 trim 修饰符的部分。可以看到,Vue 是直接使用了 JavaScript 中的 trim(),没有做过多的处理,也没有对 trim 方法进行再次封装,可以说是原汁原味。这样做大概是为了保证与原生 trim 方法的语义统一吧。

trim 修饰符的实现原理

既然是这样,那下一步自然就是看看 JavaScript 是如何定义 trim() 的。

我到 ECMAScript 官方文档中翻到了 trim 方法的逻辑定义:

trim() 的官方定义

拔萝卜带出泥,这个 TrimString 好像是个生面孔?原来,官方文档为了方便说明语法逻辑,专门定义了一些内部方法。它们仅存在于文档范围内,各个语言(如 JavaScript)在实现 ECMAScript 标准时,无需实现这些内部方法。那咱们就顺藤摸瓜打入内部,看看 TrimString 的定义:

内部逻辑 TrimString

是不是立马看困了?其实歌词大意就是,TrimString 接收两个参数:要处理的字符串、要处理的位置。位置可以是字符串开头或者结尾,默认是头尾兼顾。JavaScript 引擎会按照参数把相应位置的「White space」去掉,最后返回处理后的字符串。

在我们的印象中,使用 trim() 的时候,从来没有传过参数,它这个脾气哪管什么头尾啊。为什么 ECMAScript 官方要多此一举在内部逻辑 TrimString 里设置位置参数呢?原来 trim 厂牌里还有两个小兄弟 trimStart() 和 trimEnd(),二者的内部逻辑里是给 TrimString 传递了位置参数的。

刚刚说到,在内部逻辑里会把「White space」去掉,这就是我来爬文档的重点 —— 什么是快乐星球,啊呸,什么是「White space」?

在 TrimString 的定义下面有这样一段话:

什么是空字符

文档中所指的「White space」包括 WhiteSpace 和 LineTerminator 两个集合:

WhiteSpace

LineTerminator

果然,代号 2063 并不在 trim() 的三包范围之内。

四、斩妖除魔

所以该怎么去掉这个隐身字符呢?问题虽不复杂,但也不是一颗银弹就能命中目标的。要把大象装冰箱,我得把问题拆解开,由点到面击破。

首先把准星聚焦到 URL —— 灵异事件的根本所在。要去掉 URL 开头的 \u2063,可以用正则表达式进行检查、替换。

正则替换

接着再把输入框括入视野。对于用户在 <input> 里输入的内容,我可以用表单验证对字段值进行校验,如果检测到 URL 开头有任何不法分子,就会在界面上亮出红牌。以 iView 表单校验方式为例:

validateRules: {
  url: [{
    validator: (rule, value, callback) => {
      let reg = /^https*:\/\//
      if (reg.test(value1)) {
        callback(new Error('链接应以 http 或 https 开头'))
      }
      callback()
    },
    trigger: 'change'
  }]
}
复制代码

可是这不过是把皮球踢给了用户而已。当运营同学把灵异 URL 粘贴进来,表单校验确实能提示输入内容不是合法 URL,可是并不能告知哪里不合法。显然应该由前端代码直接把 \u2063 处理掉,让用户无感知地直接得到能正常扫码打开的二维码,这才是更优解。

那就要对 v-model 绑定的变量值进行加工(computed,还是 watch,这是个问题,而且是个面试题),二维码生成组件上绑定的、提交表单时作为接口参数的,都是加工后的 URL。

<template>
  <input type="url" v-model.trim="link">
  <vue-qrcode v-if="computedLink" :value="computedLink">
  <vue-qrcode v-if="watchedLink" :value="watchedLink"></vue-qrcode>
</template>

<script>
  data () {
    return {
      link: '', // 输入框上绑定的链接
      watchedLink: '' // 使用 watch 加工的链接
    }
  },
  computed: {
    computedLink: {
      get () {
        return this.link.replace(/\u2063/g, '')
      }
    }
  },
  watch: {
    link (val) {
      this.watchedLink = val.replace(/\u2063/g, '')
    }
  }
</script>
复制代码

这个方案看起来是 OK 的,能罩得住当前这个输入框。但我追求的不只是解决一个输入框的问题。由于加工逻辑和业务页面耦合在一起,如果需要在其他输入框、其他页面复用,会变得很繁琐。况且,让有问题的 URL 留在输入框里,于情于理都说不过去 —— 于理,会给后期的迭代和维护带来隐患;于情,我看它不顺眼。

综合以上因素来考量,一个大致的方向也就若隐若现了:我要把影响范围限制在输入框内,目标是不论用户输入什么样的内容,都力图产出合法的 URL。所以需要监听 input 事件,对输入值进行加工,再把加工后的值绑定在 value 属性上。哎?这词儿我熟啊,又是一道面试题 —— 手写 v-model。能在拧螺丝时忆起造火箭的峥嵘岁月,我很欣慰。

我将输入框封装成一个组件,把处理逻辑藏在内部,对外直接用 v-model 迎来送往即可。(不过如果历史数据存在这种灵异 URL,则需要在表单回填前额外处理。)

自定义组件:

<template>
  <input @input="handleInput" :value="trimedValue">
</template>

<script>
export default {
  props: {
    value: {
      type: String,
      default: ''
    }
  },
  computed: {
    trimedValue: {
      get () {
        return this.deepTrim(this.value)
      }
    }
  },
  methods: {
    handleInput (e) {
      this.$emit('input', this.deepTrim(e.target.value))
    },
    deepTrim (str) {
      return str.trim().replace(/\u2063/g, '')
    }
  }
}
</script>
复制代码

调用组件:

<trim-input v-model="link"></trim-input>
复制代码

到了这一步就可以结案撒花……了吗?Wait,我这无处安放的可怜的安全感 —— 根据蟑螂定律,这次逮住一个 \u2063,在暗处就必然还有未知数量的同类字符在蛰伏。

那么去哪里集齐所有隐形字符召唤神龙呢?要去 Unicode 字符集里一个一个地爬吗?可以,但没必要。

在万能的 GitHub 上,我搜索到了一位开发者做的 VS Code 插件,在说明文档里,他给出了这些隐身字符的全家福。在我检索到的结果里,这是数量最多的穷举了。全吗?不一定。谁知道 Unicode 委员会将来会继续往字符集里塞进什么奇怪的东西?我也不苛求一劳永逸,要做的是保持警惕、随时机动。

于是,完整的加工逻辑就是这样的:

    deepTrim (str) {
      return str.trim().replace(/\xAD|\uFEFF|\uFEFF|\uFFF9|\uFFFA|\u0001|\u0002|\u0003|\u0004|\u0005|\u0006|\u0007|\u000E|\u000F|\u0010|\u0011|\u0012|\u0013|\u0014|\u0015|\u0016|\u0017|\u0018|\u0019|\u001A|\u001B|\u001C|\u001D|\u001E|\u007F|\u0080|\u0081|\u0082|\u0083|\u0086|\u0087|\u0088|\u0089|\u008A|\u008B|\u008C|\u008D|\u008E|\u008F|\u0090|\u0091|\u0092|\u0093|\u0094|\u0095|\u0096|\u0097|\u0098|\u0099|\u009A|\u009B|\u009C|\u009D|\u009E|\u200B|\u200C|\u200D|\u200E|\u202A|\u202B|\u202C|\u202D|\u2060|\u2061|\u2062|\u2063|\u206A|\u206B|\u206C|\u206D/g, '')
    }
复制代码

五、结案收工

代码验证无误上线后,我又和同事们交流了这个案件,从他们那里获得了许多不一样的思路。有的说也可以通过修改剪贴板内容,提前处理 URL;有的说这类隐身字符可以用来在 Hybrid 页面的 <title> 中占位,防止 App 端插入默认的页面标题;也有的提醒我记得给飞书提 Bug……

到此,隐身字符灵异事件算是得以妥善解决,运营同学从此过上了不用担心 URL 的幸福生活,而我,也将继续和 PM、QA 展开新一轮的两两互撕……

文章分类
前端
文章标签