由一个HTML解析Bug引发的思考

本文首次发表于华为云社区开发与运营版块,主要是作者在学习上云知识过程中的经验产出。这次和大家分享的是前端开发过程中可能使用到的进制知识。还在纠结明明代码一毛一样运行结果就是不对吗?还在为判断文件是否为图片的需求而烦恼吗?还在担忧位运算的知识点没掌握吗?跟着我的脚步,一一为您解答!

start.png

↑开局一张图,故事全靠编。

故事的开头又是从一个Bug讲起,【WEB前端全栈训练营】的@张辉 大大偶遇了一个代码解析的“惊天大Bug”--a标签中的链接无法正常解析,原本链接中应该存在的斜杠莫名其妙的不见了。通过下载大大提供的示例代码,我开始了我的分析之旅,最终想到了对比文件的进制码来分析原因,在解决问题之余我也想起了曾经遇到的一些进制相关的知识点,如根据文件十六进制码判断文件类型、根据32位二进制理解位运算……

斜杠去哪儿了

从开局的图中,我们清晰的看到HTML解析出了问题,那究竟是怎样的代码会有这个解析错误的问题呢?诚然我四年多的前端职业生涯还没遇到过如此牛啤加握草的代码,首先是写不出来,其次是真的不知道怎么写出来的。不过,作为资深前端Copy攻城狮,既然有现成的代码,稍微运功使出我的“CV大法”信手拈来。
解析正常的代码:

<a href="https://classroom.devcloud.huaweicloud.com/home">首页</a>

解析错误的代码:

<a href="https://classroom.devcloud.huaweicloud.com/home">首页</a>

Are you you kidding me你个糟老头子坏的很,这不是一毛一样的代码吗?不用怀疑,的确不是一样的代码,只是长得像而已。起初,我也以为是一样的,但是解析出了的结果却是不一样的,眼见不一定为实啊,感觉文件到底是什么样的内容,也许只有机器才懂,毕竟在它的心里。我使用了VS Codecompare folders(文件对比)功能对比了这两行代码,发现的确不一样。于是安装了hexdump插件对比了文件编码,结果真相大白。
compare.png

如果说上图还不够明显,那么下图足以说明一切!其实是空格导致的,我估计是不小心复制到了不正确的空格,导致了在编辑器上肉眼无法观察到,但在浏览器中能够正常解析出来为nbsp;,多么熟悉的空格啊!所以执行一下格式化代码,问题就迎刃而解了!

compare2.png

丢失的斜杠终于找到了,起初以为是a标签的问题,看来错怪它了,真正带走斜杠的原来是编码为C2 A0空格,像极了翻转的故事情节。

文件头标识

通过上面的分析,我又想到了一个文件上传的问题,作为WEB全栈开发工程师的你,也许会遇到文件上传的需求,我们知道光靠后缀名是无法判断文件的类型的,一个以.jpg结尾的文件有可能是恶意木马文件,或者会遇到用户上传的是.jpg结尾的文件却显示失败。其实最主要的原因是单纯截取文件名称后缀来获取图片格式的方式是不准确的,因为后缀名是可以修改的,记得以前经常将.avi结尾的文件改为.txt来掩饰我内心的躁动。那文件头标识长啥样呢?可以参考filesignatures.net,比如JPG的文件头标识为FF D8 FF E0,我们可以用VS Code打开一张正经的JPG图片和TXT打包成RAR修改为JPG的文件进行对比。

compare3.png

图中标出位置为文件头标识,理论上不管文件名后缀怎么改,文件编码都是最开始创建的那种格式。这也意味着我们不能通过新建文本文件改为.js文件的方式来新建JavaScript文件,表面上我们看不出什么问题,其实内部已经出了大问题!

于是文件上传时的文件类型校验可以这么写(出处:Node.JS 识别图片类型):

function getImageSuffix(fileBuffer) {
  // 将上文提到的 文件标识头 按 字节 整理到数组中
  const imageBufferHeaders = [
    { bufBegin: [0xff, 0xd8], bufEnd: [0xff, 0xd9], suffix: '.jpg' },
    { bufBegin: [0x00, 0x00, 0x02, 0x00, 0x00], suffix: '.tga' },
    { bufBegin: [0x00, 0x00, 0x10, 0x00, 0x00], suffix: '.rle' },
    {
      bufBegin: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
      suffix: '.png'
    },
    { bufBegin: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], suffix: '.gif' },
    { bufBegin: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], suffix: '.gif' },
    { bufBegin: [0x42, 0x4d], suffix: '.bmp' },
    { bufBegin: [0x0a], suffix: '.pcx' },
    { bufBegin: [0x49, 0x49], suffix: '.tif' },
    { bufBegin: [0x4d, 0x4d], suffix: '.tif' },
    {
      bufBegin: [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x20, 0x20],
      suffix: '.ico'
    },
    {
      bufBegin: [0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x20, 0x20],
      suffix: '.cur'
    },
    { bufBegin: [0x46, 0x4f, 0x52, 0x4d], suffix: '.iff' },
    { bufBegin: [0x52, 0x49, 0x46, 0x46], suffix: '.ani' }
  ]
  for (const imageBufferHeader of imageBufferHeaders) {
    let isEqual
    // 判断标识头前缀
    if (imageBufferHeader.bufBegin) {
      const buf = Buffer.from(imageBufferHeader.bufBegin)
      isEqual = buf.equals(
        //使用 buffer.slice 方法 对 buffer 以字节为单位切割
        fileBuffer.slice(0, imageBufferHeader.bufBegin.length)
      )
    }
    // 判断标识头后缀
    if (isEqual && imageBufferHeader.bufEnd) {
      const buf = Buffer.from(imageBufferHeader.bufEnd)
      isEqual = buf.equals(fileBuffer.slice(-imageBufferHeader.bufEnd.length))
    }
    if (isEqual) {
      return imageBufferHeader.suffix
    }
  }
  // 未能识别到该文件类型
  return ''
}

位运算

位运算也就是按位操作符,包含&(按位与)|(按位或)^(按位异或)~(按位非)<<(左移)>>(有符号右移)<<<(无符号右移)。照搬下MDN的解释:

运算符 用法 描述
按位与( AND) a & b 对于每一个比特位,只有两个操作数相应的比特位都是1时,结果才为1,否则为0。
按位或(OR) a "s" b 对于每一个比特位,当两个操作数相应的比特位至少有一个1时,结果为1,否则为0。
按位异或(XOR) a ^ b 对于每一个比特位,当两个操作数相应的比特位有且只有一个1时,结果为1,否则为0。
按位非(NOT) ~ a 反转操作数的比特位,即0变成1,1变成0。
左移(Left shift) a << b 将 a 的二进制形式向左移 b (< 32) 比特位,右边用0填充。
有符号右移 a >> b 将 a 的二进制表示向右移 b (< 32) 位,丢弃被移出的位。
无符号右移 a >>> b 将 a 的二进制表示向右移 b (< 32) 位,丢弃被移出的位,并使用 0 在左侧填充。

按位操作符操作数字的二进制形式,返回的依然是标准的JavaScript数值,举个栗子:

14 & 9  // 8
14 | 9  // 15
1 << 4  // 16

底层的运算过程可能是这样的:先将数字转换为不骂形式的有符号32位整数;如:

14 (base 10) = 00000000000000000000000000001110 (base 2)
 9 (base 10) = 00000000000000000000000000001001 (base 2)
 1 (base 10) = 00000000000000000000000000000001 (base 2)

进行位运算的时候根据运算规则处理01,如按位与&表示相应比特位都为1时结果才为1否则为0;对比上面的数据,前28为都为0,除了29位有两个1结果为1,其他位结果都为0,所以等于00000000000000000000000000001000,就是十进制的8;如按位或的运算规则是至少有一个1时结果为1否则为0,对比上面的数据,前28为依旧都为0,后面四位全部为1所以等于00000000000000000000000000001111,就是十进制的15。如对1进行左移4比特位计算,得到00000000000000000000000000010000结果为16。JavaScript中的进制转换可以用toStringparseInt

vueandreact.png

说了那么多,那这个位运算有什么用呢?其实前端三大框架中Vue.js和React源码中都有用到位运算。基本上涉及到状态组合的场景,都可以用位运算中的左移来简化逻辑;最常见的应用之一就是权限控制,具体实践可参考JavaScript 中的位运算和权限设计。另外在一些算法题中,我们也能用到位运算来解题,如只出现一次的数字II (Single Number II)的一种解法:

/**
 * @param {number[]} nums
 * @return {number}
 */
var singleNumber = function(nums) {
 let a = 0;
 let b = 0;
 for (let i = 0; i < nums.length; i++) {
  a = a ^ nums[i] & ~b;
  b = b ^ nums[i] & ~a;
 }
 return a
};

位运算的其他应用可参考前端笔记-位运算,涉及到的场景有取整、判断奇偶、RGB进制转换等。

后记

原本以为能紧扣中心思想来阐述主题,结果到最后也不知道自己在讲啥,前端路漫漫,慢慢上下求索,至于全干攻城狮,感觉自身目前的水平能做好Copy攻城狮就已经不错了。几年前就注册了一个公众号,可惜一直没运营起来,同期的小伙伴很多都是流量主了,更是形成了个人影响力。而我,继续努力吧!

微@信@公@众@号:胡琦

huqi
833 声望15 粉丝

我以为的就是我以为的?