免责声明:本文以及文中涉及的代码仅用于个人学习交流,禁止转载。同时请勿将本文介绍的相关技术用于非法用途,对于非法用途或者滥用相关技术而产生的风险和相关的法律后果,与本文作者无关。请在阅读本文内容时,确保仅用于个人学习交流,并遵守当地法律法规。

故事的开始

不知道大家有没有用过一些剪藏助手的浏览器插件,就可以把在网页上看到内容,通过插件将内容收藏起来。我自己平时就会把这些自己觉得有收藏价值的内容,通过印象笔记、金山文档等剪藏助手收藏起来,供以后翻看。

但是前段时间,在剪藏一篇文章时,发现了一个奇怪的事情。我打开了一个网站,这个网站是讲历史的,在拿出我的剪藏助手想要收藏时,遇到了下面的问题。这里我随便找一篇这个网站的文章作为例子截图演示一下:

以这篇文章为例演示一下

但是剪藏完之后一看,发现不对的地方了,怎么句子都读不通顺?难道剪藏助手出bug了?

语句都读不通顺的内容

要知道,所谓的剪藏助手,它的原理就是读取网页的HTML,把里面的文字和样式获取到然后塞到自己新创建的文档里,正常来讲不应该有问题的。打开这个网站浏览器开发者工具看一下:

DOM结构中的内容,和最终渲染的内容不一样

可以看到DOM中的内容,和最终展示的内容确实不一样,而剪藏助手截取的内容,也确实是DOM中的内容无疑,并不是剪藏助手的bug。

这就奇怪了,为啥内容会不一样呢?明明就是很简单的纯文字渲染,也没有什么魔法在里面。
这个时候我注意到右下角有一个CSS属性:font-family,它的值是很奇怪的一个内容:

一串好像是编码后的字符串

初现端倪

这个font-family看起来有点奇怪,我们来尝试改一下看看。

修改font-family为其他字体

可以看到,在改了字体之后,渲染出来的内容,和DOM里的文字就对得上了,同样的读不通顺的句子。并且在来回切换字体的时候,页面渲染出来的内容也随着字体变化而变化:

渲染的UI随着font-family变化

看来这个font-family就是问题所在。

揭开面纱

既然找到了问题所在,那么我们来看下这个有点特别的font-family是什么,既然不是一个标准字体,那就应该是用户自定义的字体了,自定义在DOM中全局搜一下这个字体:

自定义字体

可以看到这个字体是通过base64的方式引入的,并且内容也恰好是data:font/ttf这样一个字体格式。

TTF(TrueType Font),一种二进制格式的文件。TTF 文件包含字体的矢量信息、度量信息和其他相关数据,用于在计算机上显示和打印文本。

我们知道,base64是可以解码的,那我们把这段base64复制下来,还原为二进制ttf文件,可以参考下面的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64.split(',')[1]);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}

function saveArrayBufferToFile(buffer, filename) {
const blob = new Blob([buffer], { type: 'font/ttf' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

// 示例用法
const base64Data = 'data:font/ttf;base64,AAEAAAALAIAAAwAwT1MvMg8S...'; // 你的 base64 编码数据
const arrayBuffer = base64ToArrayBuffer(base64Data);
saveArrayBufferToFile(arrayBuffer, 'font.ttf');

或者通过网上的一下小工具来实现这一步:将base64格式的字体信息解码成可用的字体文件

ttf字体文件

用一些字体编辑器打开这个字体文件发现,这个字体文件里有一些自定义的字形(Glyphs)

字体文件包含自定义字形

而如果我们去了解一下字体相关的知识的话,就会知道:

字符是Unicode码位标识的逻辑单元,而字形则是字体的图形单元。

Characters are logical text units identified by Unicode code points, whereas glyphs are graphical font units.

字符和字形

我们都知道,Unicode又叫万国码,是Unicode联盟整理和编码的字符集,可以用来展示世界上大部分的文字系统。

Unicode编码从0开始,为每一个字符分配一个唯一的编号,这个编号就叫做码位(code point,也叫码点),以“计算机”的“机”字举例,“机”的码位为26426(十进制),即Unicode字符集中第26426个字符,就是“机”。

但是在表示一个Unicode的字符时,通常会用U+然后紧接着一组十六进制的数字来表示这一个字符。也就是说,“机”在Unicode中的表示方式是U+673A,码位是673A

更多关于字符编码的知识可以看这篇文章:
字符编码简史:从二进制到UTF-8

但是这个Unicode编码只是一个逻辑单元,在逻辑上给每个字符分配了一个编号。真正要展示的时候,还是需要给每个编号对应的字符,一个真正的图形,去展示去渲染,这个图形就是所谓的字形。

而在字体文件中,比如ttf,字形通常可以用SVG来标识,或者是使用一种叫做 “字形描述语言”(Glyph Description Language)的语言来描述。这种语言可以像SVG一样描述复杂的字形,包括直线、曲线和其他形状。

在字体文件中,每个字形都有一个唯一的标识符,这个标识符通常就是这个字符的Unicode码位。字形和Unicode码位之间的映射关系是在字体文件中定义的。

当浏览器读取到一个字体文件时,往往会进行以下步骤来渲染对应的字体:

  1. 解码字体数据:浏览器会通过CSS的font-face拿到字体文件或者字体的base64编码字符串,解析得到字体文件的原始数据
  2. 加载字体:浏览器将解码后的字体数据加载到内存中,然后解析字体文件,读取其中的字形和对应的Unicode码位
  3. 渲染字体:当需要渲染一个字符时,浏览器会使用这个字符的Unicode码位,去字体文件中找到对应的字形,然后使用对应的字形来渲染这个字符

谜底

到这里,谜底差不多就已经揭晓了。

既然每个字符在渲染的时候,都有一个字形去控制渲染的形状,那么同一个字符可以根据不同的风格以不同的方式渲染或绘制,比如字母A,在不同的字体下可能有不同的形状:

不同字体下的“A”

同时从另一个角度看,有时字形可能看起来相同,但代表不同的字符。例如,拉丁字母A、西里尔字母А 和希腊大写字母Alpha Α看起来都一样,但代表不同文字的不同字符。

那更进一步呢?我们是不是在一个字体里,可以把字符“A”的字形,映射到字形“B”,把字符“B”的字形,映射到字形“C”?这样的话一段字符串“AB”,最终浏览器渲染出来的,会是“BC”。

答案揭晓,上面剪藏内容对不上,就是使用了这种技术,这种常被称为“字形替换”或“字形混淆”,或者换一个爬虫领域常用的名字,“字体反爬”。

它的原理就是使用一个特殊的字体,这个字体将每个字符映射到一个不同的字形。例如,字体可能将字符“A”映射到字形“B”,将字符“B”映射到字形“C”,等等。

这样,当用户查看网页时,可以将看似乱码的内容,映射到正确的文本,用户会看到正确的文本。但是当他们尝试复制和粘贴文本时,或者说通过剪藏助手这种爬虫工具爬取页面内容时,得到的就是错误的文本。

我们来简单验证一下这个结论:

随便找几个字

随便找几个对不上的字看一下,用红框圈出来,去看下字体文件里这几个字的字形:

字体文件里对应的字形

可以看到果然这些字都在自定义字形里,每个字的左下角是字符,中间是字形,这些字形恰好对应上了上面浏览器里的渲染结果。

点开一个字查看具体信息也可以看到,比如“管”这个字:

“管”这个字

码位是U+7BA1,去第三方网站上搜一下这个码位对应的字符也确实是“管”:

U+7BA1

而这个字符的字形,确实是“这”的形状:

“管”对应“这”的形状

“这”的形状矢量图

可以看到是类似于SVG的这种矢量图,我们还可以自己随便拖拽这些点,改变字形:

被编辑的“这”的矢量图

而如果有兴趣的话,还可以自己画或者设计一款专属字体,比如我下面自己用鼠标随便画了一个形状,字母“a”的字形,有点丑:

自定义“a”

导出这个ttf文件并在CSS中引用,就可以看到我们自己的字体了:

使用自定义“a”

攻与防

到这里已经把上面遇到的“奇怪”事情搞清楚了,这个网站通过这种技术,来实现反爬虫,防止爬虫抓取内容,也可以防止剪藏助手这类工具剪藏一些内容,因为即便爬虫抓取或者剪藏了HTML里的内容,拿到的也是混淆后的错误内容,想要看到正确的内容,只能通过实时网络获取编码后的字体,来渲染为正确的内容。

可以说内容的“正确”,只会体现在浏览器的渲染层面,数据层面本身就是错误的。并且,这个字形的混淆以及对应的字体,每次请求都不一样。即每次加载页面请求服务端时,服务端都会先预先对内容进行一次混淆,每次使用的混淆字形都是不同的字符和字形。

通过上面这些操作,防止了第三方获取到本站的内容,本站内容只能在本站查看,提高了网站的用户留存率之类的。

即便爬虫在每次抓取内容时,获取对应的字体文件,也很难解析到字符和字形的对应关系,大大提高了爬虫爬取的成本。

但是上面说的这些,都是“防”,是一种防御手段,还记得标题吗,“攻与防”,“攻”体现在哪里?

其实在知道了字形混淆的原理,一些钓鱼网站可以用这种方式来进行攻击,比如:

  • 钓鱼网站通过字形混淆来绕过一些安全检查器,检查器获取到的是无意义的乱码,但是用户看到的是可读的文字,通过这种方式来进行钓鱼攻击
  • 将恶意Linux命令伪装成安全命令(使rm -rf /h*看起来像echo hello),粗心的用户可能直接复制粘贴到终端并执行,造成严重后果
  • 通过将垃圾邮件伪装成有用邮件来绕过垃圾邮件过滤
  • 通过内嵌某种字体到pdf文档中(pdf可以内嵌字体,发给其他人查看时不需要其他人在他们电脑上安装对应字体),来绕过某些对文档、论文等内容的查重处理,比如将字形进行混淆,查重工具读到的是乱码,那么查重结果自然不会有什么问题,同时通过pdf内嵌的字体,保证了人在看文档的时候的可读性(注:抄袭剽窃可耻,学术造假侵权违法,不要这样做

上面这些都是利用字形混淆的方式来进行攻击的场景,我们这里只是讨论和学习这种技术可能存在的攻击手段,不是教导大家去这样搞,法网恢恢疏而不漏,大家不要去做这种攻击的事情

同时这也提醒我们在复制网上一些内容时,一定要检查内容的正确性。

自己尝试实现一下

在知道了上面的原理后,我们自己也可以实现字形混淆,但是总不能手动编辑每个字的字形矢量图吧,而且像是上面那种每次打开都会重新对不同字符的字形进行混淆的,肯定是需要把字形混淆的操作脚本化的,那假设我们自己也有一个网站需要对内容进行保护,我们要怎么实现上面的字形混淆逻辑呢?

我们的目标是:对于一段提供好的文本,针对其中部分字符或者全部字符,进行字形混淆,并生成混淆后的文本以及对应的字体文件。

预期处理流程

整体思路是:

  1. 准备一个基础字体文件
    这个基础字体文件用于获取字体中字符信息和字形信息。
  2. 确定好原始文本中需要进行混淆的字符列表
    比如对于原始文本“ABCD”,需要对其中的“ABC”这三个字符进行混淆。
  3. 确定好混淆的目标字符列表
    比如上面“ABCD”中,对“ABC”三个字符进行混淆,分别混淆成“XYZ”。最终生成“XYZD”,但是展示出来的样子还是“ABCD”的样子。
  4. 从基础字体文件中,获取字形信息
    获取原始字符(比如“A”)和混淆的字符(比如混淆成“X”)各自的字形信息
  5. 替换字形形状的矢量图
    将混淆字符(“X”)的字形信息中,描述字形形状的矢量图路径,替换为原始字符(“A”)的字形信息中的描述字形形状的矢量图路径。
    把所有需要替换字形形状的字符(“ABC”)都替换完。
  6. 反向替换字形形状的矢量图
    但是把“XYZ”的字形形状改成“ABC”之后,真的需要展示“XYZ”的形状了怎么办?这个时候就需要进行反向混淆,把“ABC”这三个字符,随机指向“XYZ”的字形的形状。
    比如字符“A”的字形形状改为“Y”,字符“B”的字形形状改为“Z”,字符“C”的字形形状改为“X”。操作方法同上。
    这里随机指向而不是直接“A”指向“X”,“B”指向“Y”,“C”指向“Z”,也是为了加大破解难度,如果字符比较多的话,比如100个字符的字形随机相互指向,那么在没有字体文件的情况破解难度会很大。
  7. 修改原始文本中的字符为混淆后的字符
    把原始文本中的字符,根据上面的字形替换映射关系,改成对应的混淆字符,生成最终混淆后的文本。即,把“ABCD”编辑修改为“XYZD”,在DOM中写“XYZD”即可。

最终效果就是,在DOM中写“XYZD”,展示出来的时候,展示为“ABCD”,如果需要展示“XYZ”本身,那么在DOM中写“CBA”。

下面给出一份代码Demo,根据提供的文本,随机从中间选出一些字符作为需要进行混淆的字符。然后从100个最常用的中文字符中,随机选出相同数量的字符作为混淆的目标字符。并最终返回混淆后的文本、字体文件,以及对应的映射关系。

支持指定混淆的等级,分别进行低、中、高不同程度以及全部文本字符的混淆:

fontObfuscateRandom.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
const opentype = require('opentype.js');
const fs = require('fs');
const path = require('path');
const { frequentlyUsedCnChar } = require('./const');

const BASE_FONT_PATH = path.resolve(__dirname, './微软雅黑.ttf');
const OUTPUT_PATH = path.resolve(__dirname, '../output');

/**
* Randomly pop an element from the given array, and return it. Will modify the original array.
* @param {*} arr The array
* @returns {*} The popped element
*/
function arrRandomPop(arr) {
const index = Math.floor(Math.random() * arr.length);
return arr.splice(index, 1)[0];
}

/**
* Get specified count of random characters from the given list, excluding the characters in the except list
* @param { string[] } charList The list of characters
* @param { number } count The count of characters to get
* @param { string[] } exceptList The list of characters to exclude
* @returns { string }
*/
function getRandomCharsFromList(charList, count, exceptList = []) {
const charSet = new Set(charList);
const result = [];
while (result.length < count) {
const index = Math.floor(Math.random() * charSet.size);
const randomChar = Array.from(charSet)[index];
if (exceptList.includes(randomChar)) {
continue;
}
result.push(randomChar);
charSet.delete(randomChar);
}
return result;
}

/**
* @typedef { Object } IObfuscatePlusParams
* @property { string } originalText The original text that need to obfuscate
* @property { 'low' | 'middle' | 'high' | 'all' } obfuscateLevel The level of obfuscation
* @property { string } fontName The name of the font family
* @property { string } fontFilename The name of the output font file
*/

/**
* Obfuscate a font by replacing the paths of glyphs with the paths of other glyphs
*
* @param { IObfuscatePlusParams } params
* @returns { Promise<{ obfuscatedText: string, fontFilePath: string, obfuscatedMap: { [key: string]: string } }> }
*/
function obfuscateRandom(params) {
const { originalText, obfuscateLevel, fontName, fontFilename } = params;
const allCharsSet = new Set(originalText);
const levelMap = { low: 0.3, middle: 0.5, high: 0.7, all: 1 };
const countOfLevel = Math.round(allCharsSet.size * levelMap[obfuscateLevel]);
const count = Math.min(countOfLevel, frequentlyUsedCnChar.length);

const obfuscateMap = {};
const oppositeMap = {};
const displayChars = getRandomCharsFromList(originalText, count);
const obfuscateChars = getRandomCharsFromList(
frequentlyUsedCnChar,
count,
displayChars
);

for (let i = 0; i < count; i++) {
const displayChar = displayChars[i];
const obfuscateChar = obfuscateChars[i];
obfuscateMap[displayChar] = obfuscateChar;
}
obfuscateChars.forEach(obfuscateChar => {
const randomDisplayChar = arrRandomPop(displayChars);
oppositeMap[obfuscateChar] = randomDisplayChar;
});

const finalMap = { ...obfuscateMap, ...oppositeMap };
const finalObfuscateList = Object.keys(finalMap).map(key => ({
displayChar: key,
obfuscatedChar: finalMap[key],
}));

const obfuscatedText = originalText
.split('')
.map(char => finalMap[char] || char)
.join('');

return new Promise((resolve, reject) => {
opentype.load(BASE_FONT_PATH, (err, font) => {
if (err) {
console.error('Could not load font: ' + err);
reject(err);
return;
}

const obfuscatedGlyphs = finalObfuscateList.map(item => {
const { displayChar, obfuscatedChar } = item;
// Get the glyph for the display character and the obfuscated character
const displayGlyph = font.charToGlyph(displayChar);
const obfuscatedGlyph = font.charToGlyph(obfuscatedChar);
// Clone the obfuscated glyph and replace its path with the display glyph's path
const cloneObfuscatedGlyph = Object.assign(
Object.create(Object.getPrototypeOf(obfuscatedGlyph)),
obfuscatedGlyph
);
cloneObfuscatedGlyph.path = displayGlyph.path;
cloneObfuscatedGlyph.name = displayChar;
return cloneObfuscatedGlyph;
});

// This .notdef glyph is required, it's used for characters that doesn't have a glyph in the font
const notDefGlyph = new opentype.Glyph({
name: '.notdef',
advanceWidth: 650,
path: new opentype.Path(),
});

// Create a new font object with the obfuscated glyphs
const newFont = new opentype.Font({
familyName: fontName,
styleName: 'Regular',
unitsPerEm: font.unitsPerEm,
ascender: font.ascender,
descender: font.descender,
glyphs: [notDefGlyph, ...obfuscatedGlyphs],
});

// Save the new font to a file
const outputFilePath = path.resolve(OUTPUT_PATH, `${fontFilename}.ttf`);
const buffer = newFont.toArrayBuffer();
fs.writeFileSync(outputFilePath, Buffer.from(buffer));
resolve({
obfuscatedText,
fontFilePath: outputFilePath,
obfuscatedMap: finalMap,
});
});
});
}

async function start() {
const result = await obfuscateRandom({
originalText: '找到夺嫡密码的,自始至终都只有一个人而已。',
obfuscateLevel: 'middle',
fontName: 'MyObfuscatedFont',
fontFilename: 'my-obfuscated-font',
});
console.log('obfuscatedText: \n', result.obfuscatedText);
console.log('\n');
console.log('fontFilePath: \n', result.fontFilePath);
console.log('\n');
console.log('obfuscatedMap: \n', result.obfuscatedMap);
}

start();

运行之后效果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ node ./src/fontObfuscateRandom.js
obfuscatedText:
找到部嫡进码的说量多至终机们点一个来分是。


fontFilePath:
/Users/your_user_name/font-obfucate-demo/output/my-obfuscated-font.ttf


obfuscatedMap:
{
'只': '们',
'密': '进',
'而': '分',
'已': '是',
'夺': '部',
'始': '多',
'都': '机',
',': '说',
'有': '点',
'人': '来',
'自': '量',
'们': '已',
'进': '只',
'分': '自',
'是': '都',
'部': '而',
'多': ',',
'机': '有',
'说': '夺',
'点': '密',
'来': '人',
'量': '始'
}

在使用的时候如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<html>
<head>
<style>
@font-face {
font-family: MyObfuscatedFont;
src: url('my-obfuscated-font.ttf') format('truetype');
}
.myFont {
font-family: 'MyObfuscatedFont';
}
</style>
</head>
<body>
<p class="myFont">找到部嫡进码的说量多至终机们点一个来分是。</p>
</body>
</html>

最终效果:

最终效果

选中这段文字进行复制,复制到剪贴板的内容也是DOM中的“找到部嫡进码的说量多至终机们点一个来分是。”。

如果打开生成的字体文件看一下的话,会发现和上面讨论的那个网站的字体文件中的效果也是类似的:

最终字体文件

有我们生成的自定义的字形,并且和上面DOM和渲染中的结果也是一一对应的。

而如果进行了最大程度的混淆,即对原始文本中所有字符混淆,最终混淆的内容将完全不可读:

完全混淆

而且由于是随机选择文本和目标字符进行混淆的,每次执行混淆脚本,都会生成完全不同的字体和混淆文本,这个也和上面内容里提到的效果对得上。

到此为止,我们也算是揭开了字形混淆的“神秘面纱”,也基本上通过代码实现了类似的效果,还可以进一步做的事情就是把字体文件进行base64编码并通过base64字符串来在CSS里引用了。

那除了上面的这些场景,字形混淆还有哪些用法呢?

一些其他的用法

字形混淆除了用在上面的场景外,还有一种无关反爬虫或者恶意攻击的用法,那就是字体图标,比如比较出名的iconfont,大家可能还用过。

这是阿里提供的一个矢量图标库,可以下载各种矢量图,同时也支持通过字体的方式来渲染图标。原理就是上面提到的,把一些字符的字形,改为一些icon图标的形状,在用这个图标的地方就可以通过下面这种方式来使用:

  1. 声明字体
1
2
3
4
@font-face {
font-family: 'iconfont';
src: url('iconfont.tff') format('tff');
}
  1. 引用字体
1
2
3
4
5
6
.iconfont {
font-family: "iconfont";
font-size: 16px;
font-style: normal;
color: red;
}
  1. 将字符渲染成icon
1
<span class="iconfont">&#xe650;</span>

这样就可以渲染出这个字符对应的图标,比如我这里:

iconfont demo

这里有一个点要说一下,因为是使用了替换某些字符的字形来实现的icon,为了不影响这些字符的正常展示,肯定是不能把常用中文或者英文等字符的字形来进行替换的,所以目前我看到的是他们使用了Unicode第一平面内的私人使用区的一些字符:

私人使用区

私人使用区

可以看到上面span里的字符&#xe650;是用的Unicode的HTML实体字符的形式来写的,码位是e650,可以看到e650正好是落在了第一平面内的私人使用区范围内:

U+E650

而顾名思义,私人使用区就是没有公共定义的Unicode区域,这些区域没有在Unicode标准中指定对应的具体字符是哪个,可以由个人或组织根据需要自行使用。把这些私人使用区的字符的字形,在字体文件中指定为需要使用到的icon,就可以做到不影响常用字符的渲染和展示了。

而且私人使用区内的字符,因为没有真正的字符来对应,在自定义字体文件没有加载好的时候,也不用担心会使用默认字体渲染出其他字符,而是会展示出一个框,就像上面DOM结构里看到的那样。

那这种字体图标的好处在哪里呢?为什么不直接使用图片?其实目前看下来有以下几个优势:

  1. 相比于图片,字体是矢量的,可以随意缩放而不影响图片的清晰度,在不同分辨率的屏幕上表现都很好
  2. 即使是图片中的矢量图SVG,体积大小也比字体文件中专门优化过的、效率更高的字形描述语言描述的字形,体积要更大,做了一个对比,同样是25个上面的那种图标,字体文件要比SVG的体积小上很多

字体文件和SVG体积对比

  1. 减少网络请求次数,如果每个SVG单独下载,那么会发起多个网络请求,但是如何合并到一个字体文件里,只需要一次网络请求即可
  2. 图标作为字体来渲染,可以像操作字体一样来操作图标,比如设置颜色、字重、斜体、字号等,常规图片展示则没办法通过这些来控制

使用字体属性控制icon

结语

到这里这篇文章就差不多结束了,简单总结一下字形混淆在各个场景的应用:

1. 内容保护

可以用在内容保护上,防止爬虫爬取或者文档剪藏工具剪藏,保护网站内容,不过从其他角度多想一下,感觉这种技术也不是没有缺点,比如下面的:

  • 可访问性:这种技术可能会影响到屏幕阅读器等辅助技术的使用,因为这些工具通常会读取DOM中原始的字符,而不是渲染后的字形
  • 搜索引擎优化(SEO):搜索引擎通常会索引网页的原始文本,而不是渲染后的文本。使用字形替换技术可能会影响到网站在搜索结果中的排名
  • 用户体验:如果用户需要引用网站的内容,这种技术就可能会带来不好的用户体验。而且从防爬防复制角度来说,虽然这种技术可以防止用户直接复制文本,但是它不能防止用户通过其他方式获取文本,例如通过截图和OCR技术,甚至是物理拍照的方式,只能在某种程度上保护网站内容。

另外除了字形混淆外,还有其他方式来保护内容,比如用canvas来渲染所有的文本内容,canvas元素本身只是一个绘图区域,里面的内容是通过JS绘制的,绘制的文本实际上是作为图像的一部分存在的,不会直接出现在HTML文档中。但是这种方案仍然存在上面的几个潜在问题。

综合来讲还是要根据自己的业务场景来区分是否要混淆,如果需要提高用户留存率,甚至是保护一些付费内容的话,还是很有字形混淆的必要性的。

2. 恶意攻击

一些钓鱼网站或者垃圾邮件,可能通过这种技术来绕过检测和拦截进行攻击。同时我们要注意不受信的网站上复制的内容要注意也是不可信的,可能复制到非预期的危险内容,需要注意鉴别。

3. icon图标

通过对一些字符进行字形替换,可以用字体的方式来控制和渲染icon图标,做到比直接使用图片更好的展示效果和更低的开发成本。

这篇文章算是上一篇文章《字符编码简史:从二进制到UTF-8》的一个延伸,分别从字符和字形的角度,探寻日常编程中那些不易察觉的微妙场景,感觉还是很有趣的。

参考链接

免责声明:本文以及文中涉及的代码仅用于个人学习交流,禁止转载。同时请勿将本文介绍的相关技术用于非法用途,对于非法用途或者滥用相关技术而产生的风险和相关的法律后果,与本文作者无关。请在阅读本文内容时,确保仅用于个人学习交流,并遵守当地法律法规。

前言

鸿蒙系统,一个从诞生就一直处于舆论风口浪尖上的系统,从最开始的“套壳”安卓的说法,到去年的不再兼容安卓的NEXT版本的技术预览版发布,对于鸿蒙到底是什么,以及鸿蒙的应用开发的讨论从来没停止过。

这次我们就从一个前端开发的角度来了解一下鸿蒙,学习一下鸿蒙应用的开发。

一、 什么是鸿蒙

在开始之前,先问大家一个问题,大家听说过几种鸿蒙?

其实到目前为止,我们经常听到的鸿蒙系统,总共有三种,分别是:

OpenHarmony,HarmonyOS,以及HarmonyOS NEXT。

1. OpenHarmony

OpenHarmony

OpenHarmony(开源鸿蒙系统),由开放原子开源基金会进行管理。开放原子开源基金会由华为、阿里、腾讯、百度、浪潮、招商银行、360等十家互联网企业共同发起组建。包含了“鸿蒙操作系统”的基础能力,是“纯血”鸿蒙的底座。

这个版本的鸿蒙是开源的,代码仓库的地址在这里:https://gitee.com/openharmony

从我个人的一些粗浅理解来看,OpenHarmony类似于Android里的AOSP,可以装到各种设备上,比如手表、电视甚至是一些嵌入式设备上,详见可见官网的一些例子

2. HarmonyOS

HarmonyOS

基于 OpenHarmony、AOSP等开源项目,同时加入了自己的HMS(因为被美国限制后无法使用GMS)的商用版本,可以兼容安卓,也可以运行部分OpenHarmony开发的鸿蒙原生应用。

这个也是目前经常被吐槽是“套壳”安卓的系统,截止到目前(2024.04)已经更新到了HarmonyOS 4.2。

3. HarmonyOS NEXT

HarmonyOS NEXT

2023年秋季发布的技术预览版,在当前HarmonyOS的基础上去除了AOSP甚至是JVM,不再兼容安卓,只能运行鸿蒙原生应用,同时对OpenHarmony的能里进行了大量的更新,增加和修改了很多API。

这个也就是所谓的“纯血”鸿蒙系统,可惜的是这个目前我们用不到,需要以公司名义找华为合作开权限,或者个人开发者使用一台Mate60 Pro做专门的开发机。并且目前由于有保密协议,网上也没有太多关于最新API的消息。

NEXT版本文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-releases/releasenotes-overview-0000001602419138

无法直接访问的NEXT版本的开发文档

据说目前HarmonyOS NEXT使用的API版本已经到了API12,目前官网可以访问的最新文档还是API9,所以接下来的内容也都是基于API9的版本来的。

4. 小结

所以一个粗略的视角来看,OpenHarmony、HarmonyOS以及HarmonyOS NEXT这三者之间的关系是这样的:

三者之间的关系

二、 初识鸿蒙开发

在大概知道了什么是鸿蒙之后,我们先来简单看一下鸿蒙开发的套件。下图是官网所描述的一些开发套件,包括了设计、开发、测试、上架所涉及到的技术和产品。

鸿蒙开发套件

我们这篇文章里主要讨论右下角的三个:ArkTSArkUIArkCompiler

ArkTS&ArkUI

ArkCompiler

三、 关于ArkTS的一些疑惑

作为一个前端开发,最常用的编程语言就是JavaScript或者TypeScript,那么在看到鸿蒙应用开发用到的编程语言是ArkTS之后,我脑子里最先蹦出来的就是下面这几个问题:

1. ArkTS语言的运行时是啥?
既然编程语言是TS(TS的拓展,ArkTS),那么它的运行时是什么呢?是V8?JSC?Hermes?还是其他什么呢?
2. ArkTS还是单线程语言吗?
ArkTS还是和JS一样,是单线程语言吗?
3. 基于TS拓展了什么?
TS是JS的超集,对JS进行了拓展,增加了开发时的类型支持。而ArkTS对对TS又进行了拓展,是TS的超集,那它基于TS拓展了什么内容呢?

下面我们一个一个来看。

1. Question1 - ArkTS语言的运行时

先说结论,ArkTS的运行时不是V8,不是JSC、Hermes,不是目前任何一种JS引擎。ArkTS的运行时是一个自研的运行时,叫做方舟语言运行时(简称方舟运行时)。

方舟运行时

而这个运行时,执行的也不是JS/TS/ArkTS代码,而是执行的字节码和机器码
这是因为方舟运行时是ArkCompiler(方舟编译器)的一部分,对于JS/TS/ArkTS的编译在运行前就进行了(和Hermes有点像,下面会讲到)。

方舟开发框架示意图

我们来简单了解一下ArkCompiler,从官网的描述可以看到,ArkCompiler关注的重点主要有三个方面:

  • AOT 编译模式
  • LiteActor 轻量化并发
  • 源码安全

AOT 编译模式

首先是编译模式,我们知道,目前编程语言大多以下几方式运行:

  • 机器码AOT编译
    在程序运行之前进行AST生成和代码编译,编译为机器码,在运行的时候无需编译,直接运行,比如C语言。
  • 中间产物AOT编译
    在程序运行前进行AST生成并进行编译,但不是编译为机器码,而是编译为中间产物,之后在运行时将字节码解释为机器码再执行。比如Hermes或Java编译为字节码,之后运行时由Hermes引擎或JVM解释执行字节码。
  • 完全的解释执行
    在程序运行前不进行任何编译,在运行时动态地根据源码生成AST,再编译为字节码,最后解释执行字节码。比如没有开启JIT的V8引擎执行JS代码时的流程。
  • 混合的JIT编译
    在通过解释执行字节码时(运行时动态生成或者AOT编译生成),对多次执行的热点代码进行进一步的优化编译,生成机器码,后续执行到这部分逻辑时,直接执行优化后的机器码。比如开启JIT的V8引擎运行JS或者支持JIT的JVM运行class文件。

当然,以上仅考虑生产环境下的运行方式,不考虑部分语言在生产和开发阶段不同的运行方式。比如Dart和Swift,一般是开发阶段通过JIT实时编译快速启动,生产环境下为了性能通过AOT编译。

在V8 JIT出现之前,所有的JS虚拟机所采用的都是采用的完全解释执行的方式,在运行时把源码生成AST语法树,之后生成字节码,然后将字节码解释为机器码执行,这是JS执行速度过慢的主要原因之一。

而这么做有以下两个方面的原因:

  • JS是动态语言,变量类型在运行时可能改变
  • JS主要用于Web应用,Web应用如果提前编译为字节码将导致体积增大很多,对网络资源的消耗会更大

我们一个一个来说。

a. JS变量类型在运行时可能改变

首先我们来看一张图,这张图描述了现在V8引擎的工作流程,目前Chrome和Node里的JS引擎都是这个:

V8现有工作流程

从上面可以看到,V8在拿到JS源码后,会先解析成AST,之后经过Ignition解释器把语法树编译成字节码,然后再解释字节码执行。

于此同时还会收集热点代码,比如代码一共运行了多少次、如何运行的等信息,也就是上面的Feedback的流程。

如果发现一段代码会被重复执行,则监视器会将此段代码标记为热点代码,交给V8的Turbofan编译器对这段字节码进行编译,编译为对应平台(Intel、ARM、MIPS等)的二进制机器码,并执行机器码,也就是图里的Optimize流程。

等后面V8再次执行这段代码,则会跳过解释器,直接运行优化后的机器码,从而提升这段代码的运行效率。

但是我们发现,图里面除了Optimize外,还有一个Deoptimize,反优化,也就是说被优化成机器码的代码逻辑,可能还会被反优化回字节码,这是为什么呢?

其实原因就是上面提到的“JS变量类型在运行时可能改变”,我们来看一个例子:

JS变量类型在运行时可能改变

比如一个add函数,因为JS没有类型信息,底层编译成字节码后伪代码逻辑大概如这张图所示。会判断xy的各种类型,逻辑比较复杂。

在Ignition解释器执行add(1, 2)时,已经知道add函数的两个参数都是整数,那么TurboFan在进一步编译字节码时,就可以假定add函数的参数是整数,这样可以极大地简化生成的汇编代码,不再判断各种类型,伪代码如第三张图里所示。

接下来的add(3, 4)add(5, 6)由于入参也是整数,可以直接执行之前编译的机器码,但是add("7", "8")时,发现并不是整数,这时候就只能将这段逻辑Deoptimize为字节码,然后解释执行字节码。

这就是所谓的Deoptimize,反优化。可以看出,如果我们的JS代码中变量的类型变来变去,是会给V8引擎增加不少麻烦,为了提高性能,我们可以尽量不要去改变变量的类型。

虽然说使用TS可以部分缓解这个问题,但是TS只能约束开发时的类型,运行的时候TS的类型信息是会被丢弃的,也无法约束,V8还是要做上面的一些假定类型的优化,无法一开始就编译为机器码。

TS类型信息运行时被丢弃

可以说TS的类型信息被浪费了,没有给运行时代码特别大的好处。

b. JS编译为字节码将导致体积增大

上面说到JS主要用于Web应用,Web应用如果提前编译为字节码将导致体积增大很多,对网络资源的消耗会更大。那么对于非Web应用,其实是可以做到提前编译为字节码的,比如Hermes引擎。

Hermes作为React Native的运行时,是作为App预装到用户的设备上,除了热更新这种场景外,绝大部分情况下是不需要打开App时动态下载代码资源的,所以体积增大的问题影响不是很大,但是预编译带来的运行时效率提升的好处却比较明显。

所以相对于V8,Hermes去掉了JIT,支持了生成字节码,在构建App的时候,就把JS代码进行了预编译,预编译为了Hermes运行时可以直接处理的字节码,省去了在运行阶段解析AST语法树、编译为字节码的工作。

Hermes对JS编译和执行流程的改进

一句题外话,Hermes去除了对JIT的支持,除了因为JIT会导致JS引擎启动时间变长、内存占用增大外,还有一部分可能的原因是,在iOS上,苹果为了安全考虑,不允许除了Safari和WebView(只有WKWebView支持JIT,UIWebView不支持)之外的第三方应用里直接使用JSC的JIT能力,也不允许第三方JS运行时支持JIT(相关问题)。

甚至V8专门出了一个去掉JIT的JIT-less V8版本来在iOS上集成,Hermes似乎也不太可能完全没考虑到这一点。

c. 取长补短

在讨论了V8的JIT和Hermes的预编译之后,我们再来看看ArkCompiler,截取一段官方博客里的描述

博客描述

还记得上面说的“TS的类型信息被浪费了”吗?TS的类型信息只在开发时有用,在编译阶段就被丢弃了,而ArkCompiler就是利用了这一点,直接在App构建阶段,利用TS的类型信息直接预编译为字节码以及优化机器码。

即在ArkCompiler中,不存在TS->JS的这一步转译,而是直接从TS编译为了字节码和优化机器码(这里存疑,官网文档没有找到很明确的说法,不是特别确定是否有TS->JS的转译)。

同时由于鸿蒙应用也是一个App而不是Web应用,所以ArkCompiler和Hermes一样,也是在构建App时就进行了预编译,而不是在运行阶段做这个事情。

ArkCompiler对JS/TS编译和执行流程的改进

简单总结下来,ArkCompiler像Hermes一样支持生成字节码,同时又将V8引擎JIT生成机器码的工作也提前在预编译阶段做了。是比Hermes只生成字节码的AOT更进一步的AOT(同时生成字节码和部分优化后的机器码)。

LiteActor轻量化并发

到这里其实已经可以回答上面讲到的第二个问题了,ArkTS还是单线程语言吗?

答案是:是的,还是单线程语言。但是ArkTS里通过Worker和TaskTool这两种方式支持并发。

同时ArkCompiler对现有的Worker进行了一些优化,直接看官网博客

LiteActor轻量化并发

LiteActor轻量化并发博客描述

这里的Actor是什么呢?Actor是一种并发编程里的线程模型。

线程模型比较常见的就是共享内存模型,多个线程之间共享内存,比如Java里多个线程共享内存数据,需要通过synchronized同步锁之类的来防止数据一致性的问题。

Actor模型是另一种线程模型,“Actor”是处理并发计算的基本单位,每个Actor都有自己的状态,并且可以接收和发送消息。当一个Actor接收到消息时,它可以改变自己的状态,发送消息给其他Actor,或者创建新的Actor。

这种模型可以帮助开发者更好地管理复杂的状态和并发问题,因为每个Actor都是独立的,它们之间不会共享状态,这可以避免很多并发问题。同时,Actor模型也使得代码更易于理解和维护,因为每个Actor都是独立的,它们的行为可以被清晰地定义和隔离。

到这里大家应该已经比较明白了,前端里的Web Worker就是这种线程模型的一种体现,通过Worker来开启不同的线程。

源码安全

按照官网的说法,ArkCompiler会把ArkTS编译为字节码,并且ArkCompiler使用多种混淆技术提供更高强度的混淆与保护,使得HarmonyOS应用包中装载的是多重混淆后的字节码,有效提高了应用代码安全的强度。

源码安全

2. Question2 - ArkTS还是单线程语言吗

这个刚刚已经回答了,还是单线程语言,借用官网的描述:

HarmonyOS应用中每个进程都会有一个主线程,主线程有如下职责:

  1. 执行UI绘制;
  2. 管理主线程的ArkTS引擎实例,使多个UIAbility组件能够运行在其之上;
  3. 管理其他线程(例如Worker线程)的ArkTS引擎实例,例如启动和终止其他线程;
  4. 分发交互事件;
  5. 处理应用代码的回调,包括事件处理和生命周期管理;
  6. 接收Worker线程发送的消息;

除主线程外,还有一类与主线程并行的独立线程Worker,主要用于执行耗时操作,但不可以直接操作UI。Worker线程在主线程中创建,与主线程相互独立。最多可以创建8个Worker。

ArkTS线程模型

3. Question3 - 基于TS拓展了什么

当前,ArkTS在TS的基础上主要扩展了如下能力:

  • 基本语法:ArkTS定义了声明式UI描述、自定义组件和动态扩展UI元素的能力,再配合ArkUI开发框架中的系统组件及其相关的事件方法、属性方法等共同构成了UI开发的主体。
  • 状态管理:ArkTS提供了多维度的状态管理机制。在UI开发框架中,与UI相关联的数据可以在组件内使用,也可以在不同组件层级间传递,比如父子组件之间、爷孙组件之间,还可以在应用全局范围内传递或跨设备传递。另外,从数据的传递形式来看,可分为只读的单向传递和可变更的双向传递。开发者可以灵活地利用这些能力来实现数据和UI的联动。
  • 渲染控制:ArkTS提供了渲染控制的能力。条件渲染可根据应用的不同状态,渲染对应状态下的UI内容。循环渲染可从数据源中迭代获取数据,并在每次迭代过程中创建相应的组件。数据懒加载从数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。

而上面这些,也就是我们接下来要介绍的ArkTS+ArkUI的语法。

四、 ArkTS & ArkUI

首先,在聊ArkUI之前,还有一个问题大家可能比较感兴趣:ArkUI是怎么渲染我们写的UI呢?

答案是自绘,类似于Flutter,使用自己的渲染引擎(应该是发展于Skia),而不是像RN那样将UI转为不同平台上的底层UI。

不管是从官网的描述[1]、[2]来看,还是社区里的讨论来看,ArkUI的渲染无疑是自绘制的,并且ArkUI和Flutter之间的联系很密切:

社区里的一些讨论

1. 基本语法

从前端的角度来看,ArkTS和ArkUI的定位其实就是类似于前端中TS+React+配套状态管理工具(如Redux),可以用TS写声明式UI(有点像写jsx),下面是基本语法:

基本语法

  • 装饰器
    用于装饰类、结构、方法以及变量,并赋予其特殊的含义。如上述示例中@Entry、@Component和@State都是装饰器,@Component表示自定义组件,@Entry表示该自定义组件为入口组件,@State表示组件中的状态变量,状态变量变化会触发UI刷新
  • 自定义组件
    可复用的UI单元,可组合其他组件,如上述被@Component装饰的struct Hello
  • UI描述
    以声明式的方式来描述UI的结构,例如build()方法中的代码块
  • 系统组件
    ArkUI框架中默认内置的基础和容器组件,可直接被开发者调用,比如示例中的ColumnTextDividerButton
  • 事件方法
    组件可以通过链式调用设置多个事件的响应逻辑,如跟随在Button后面的onClick()
  • 属性方法
    组件可以通过链式调用配置多项属性,如fontSize()width()height()backgroundColor()

2. 数据驱动UI

作为一个声明式的UI框架,ArkUI和其他众多UI框架(比如React、Vue)一样,都是通过数据来驱动UI变化的,即UI = f(State)。我们这里引用一下官网的描述:

在声明式UI编程框架中,UI是程序状态的运行结果,用户构建了一个UI模型,其中应用的运行时的状态是参数。当参数改变时,UI作为返回结果,也将进行对应的改变。这些运行时的状态变化所带来的UI的重新渲染,在ArkUI中统称为状态管理机制。

自定义组件拥有变量,变量必须被装饰器装饰才可以成为状态变量,状态变量的改变会引起UI的渲染刷新。如果不使用状态变量,UI只能在初始化时渲染,后续将不会再刷新。 下图展示了State和View(UI)之间的关系。

State和UI

View(UI):UI渲染,指将build方法内的UI描述和@Builder装饰的方法内的UI描述映射到界面。
State:状态,指驱动UI更新的数据。用户通过触发组件的事件方法,改变状态数据。状态数据的改变,引起UI的重新渲染。

在ArkUI中,提供了大量的状态管理相关的装饰器,比如@State@Prop@Link等。

ArkTS & ArkUI的状态管理总览

更多细节详见状态管理

3. 渲染控制

在ArkUI中,可以像React那样,通过if elsefor each等进行跳转渲染、列表渲染等,更多细节详见渲染控制

ArkUI通过自定义组件build()函数和@builder装饰器中的声明式UI描述语句构建相应的UI。在声明式描述语句中开发者除了使用系统组件外,还可以使用渲染控制语句来辅助UI的构建,这些渲染控制语句包括控制组件是否显示的条件渲染语句,基于数组数据快速生成组件的循环渲染语句以及针对大数据量场景的数据懒加载语句。

4. 更多语法

语法其实不是我们这篇文章的重点,上面是一些大概的介绍,更多语法可以详见官网,或者我的另外一篇专门讲解语法的笔记《前端视角下的ArkTS语法》(先留个占位符,有时间了补充一下)。

5. ArkTS & ArkUI小结

从前面的内容其实可以看到,ArkUI和RN相似点还挺多的:

  1. 都是使用JS/TS作为语言(ArkTS)
  2. 都有自己的JS引擎/运行时(ArkCompiler,方舟运行时)
  3. 引擎还都支持直接AOT编译成字节码

不同的是RN是将JS声明的UI,转换成iOS、Android原生的组件来渲染,而ArkUI则是采用自绘制的渲染引擎来自绘UI。

从这点来看,鸿蒙更像是Flutter,只不过把开发语言从Dart换成了JS/TS(ArkTS),和Flutter同样是自绘制的渲染引擎。

社区里其实也有类似的思考:其它方向的探索:JS Engine + Flutter RenderPipeLine。而ArkUI则是对这种思路的实现。

感觉这也可以从侧面解释为什么ArkUI的语法和Flutter比较像,应该参考了不少Flutter的实现(比如渲染引擎)。

而华为宣称鸿蒙可以反向兼容Flutter甚至是RN也就没有那么难以理解了,毕竟ArkUI里Flutter和RN的影子确实不少。

另外,除了ArkUI以外,华为还提供了一个跨平台的开发框架ArkUI-X,可以像Flutter那样,跨HarmonyOS、Android、iOS三个平台。

这么看来,ArkTS&ArkUI从开发语言、声明式UI的语法、设计思想来看,不管是前端、iOS、安卓、或者Flutter、RN,鸿蒙应用开发都是比较入门友好的。

五、 其他

1. 包管理工具

HarmonyOS开发中,使用的包管理工具是ohpm,目前看来像是一个借鉴pnpm的三方包管理工具,详见官方文档

另外,鸿蒙也提供了第三方包发布的仓库:https://ohpm.openharmony.cn

2. 应用程序结构

在鸿蒙系统中,一个应用包含一个或者多个Module,每一个Module都可以独立进行编译和运行。

应用程序结构

发布时,每个Module编译为一个.hap后缀的文件,即HAP。每个HarmonyOS应用可以包含多个.hap文件。

在应用上架到应用市场时,需要把应用包含的所有.hap文件打包为一个.app后缀的文件用于上架。

但是.app包不能直接安装到设备上,只是上架应用市场的单元,安装到设备上的是.hap

打包结构

开发态和打包后视图

鸿蒙应用的整体开发调试与发布部署流程大概是这样的:

开发-调试-发布-部署

HAP可分为Entry和Feature两种类型:

  • Entry类型的HAP:是应用的主模块
    在同一个应用中,同一设备类型只支持一个Entry类型的HAP,通常用于实现应用的入口界面、入口图标、主特性功能等。
  • Feature类型的HAP:是应用的动态特性模块
    一个应用程序包可以包含一个或多个Feature类型的HAP,也可以不包含;Feature类型的HAP通常用于实现应用的特性功能,可按需下载安装

而设计成多hap,主要是有3个目标:

  1. 为了解耦应用的各个模块,比如一个支付类型的App,Entry类型的hap可以是首页主界面,上面的扫一扫、消息、理财等可以的feature类型的HAP
  2. 方便开发者将多HAP合理地组合并部署到不同的设备上,比如有三个HAP,Entry、Feature1和Feature2,其中A类型的设备只能部署Entry和Feature1。B类型的设备只能部署Entry和Feature2
  3. 方便应用资源共享,减少程序包大小。多个HAP都需要用到的资源文件可以放到单独的HAP中

多说一句:从这些描述来看,给我的感觉是每个.hap有点类似于前端项目中Mono-repo仓库中的一个package,各个package之间有一定的依赖,同时每个package可以独立发布。

另外,HarmonyOS也支持类似RN热更新的功能,叫做快速修复(quick fix)

六、 总结

现在再回到最开始那个问题:什么是鸿蒙?从前端视角来看,它是这样一个系统:

  • ArkTS作为应用开发语言
  • 类Flutter、Compose、Swift的声明式UI语法
  • 和React有些相似的数组驱动UI的设计思想
  • ArkCompiler进行字节码和机器码的AOT编译 + 方舟运行时
  • 类似Flutter Skia渲染引擎的自绘制渲染引擎
  • 通过提供一系列ohos.xxx的系统内置包来提供TS访问系统底层的能力(比如网络、媒体、文件、USB等)

所以关于HarmonyOS是不是安卓套壳,个人感觉其实已经比较明了了:以前应该是,但快要发布的HarmonyOS NEXT大概率不再是了。

其他一些讨论

其实在华为宣布了HarmonyOS NEXT不再兼容安卓后,安卓套壳的声音越来越少了,但现在网上另外一种声音越来越多了:

  1. HarmonyOS NEXT是一个大号的小程序底座,上面的应用都是网页应用,应用可以直接右键查看源码,没有安全性可言
  2. HarmonyOS NEXT上的微信小程序就是在小程序里运行小程序
  3. 因为使用的是ArkTS开发,所以的HarmonyOS NEXT上的应用性能必然很差

这种说法往往来自于只知道鸿蒙系统应用开发语言是TS,但是没有去进一步了解的人,而且这种说法还有很多人信。其实只要稍微看下文档,就知道这种说法是完全错误的

首先它的View层不是DOM,而是类似Flutter的自绘制的渲染引擎,不能因为使用了TS就说是网页,就像可以说React Web是网页应用,但不能说React Native是网页应用,同样也不是说Flutter是网页应用。

另外开发语言本身并不能决定最终运行性能,还是要看编译器和运行时的优化。同样是JS,从完全的解释执行(JS->AST->字节码->执行),到开启JIT的V8,性能都会有质的飞跃。从一些编程语言性能测试中可以看到,开启JIT的NodeJs的性能,甚至和Flutter所使用的Dart差不多。

而ArkCompiler是结合了Hermes和V8 JIT的特点,AOT编译为字节码和机器码,所以理论上讲性能应该相当不错。

(当然我也没有实机可以测试,只能根据文档来分析)。

上面这种HarmonyOS NEXT是网页应用的说法还有可能是由于,最早鸿蒙应用支持使用HTML、CSS、JS三件套进行兼容Web的开发,导致了刻板印象。这种开发方式使用的是FA模型,而目前这种方式已经不是鸿蒙主推的开发方式了

到这里这篇文章就结束了,整体上是站在一个前端开发的视角下来认识和了解鸿蒙开发的,希望能帮助一些像我一样对鸿蒙开发感兴趣的前端开发入门。大家如果感兴趣可以到鸿蒙官网查看更多的了解。

如果感觉对你有帮助,可以点个赞哦~

参考链接

void运算符,一个在 JavaScript 中很常见,但你却不一定很熟悉的运算符。它出现在各个地方,你可能在下面这些地方见到过:

  • HTML的a标签里

    1
    <a href="javascript:void(0);">This is a link</a>
  • JS表达式(尤其是 tsc 或者 babel 编译后的代码里)

    1
    var result = foo === null || foo === void 0 ? void 0 : foo.bar;
  • 箭头函数

    1
    button.onclick = () => void doSomething();

那么void运算符的作用究竟是什么呢?

缘起

之所以突然聊这个,是因为在某次代码CR里讨论到了TypeScript的可选链(Optional Chaining)语法(当然从ES2020开始,JS中也支持了这个语法)。

可选链大家应该都不陌生,在获取深层次对象属性的时候极其方便,比如

1
const result = foo?.bar;

上面这行代码作用类似于下面这种写法:

1
2
const temp = foo === null || foo === undefined ? undefined : foo.bar;
const result = temp;

虽然是这样说,但是作为一个凡事都要试一试的人来说,还是自己编译一下看看比较好,在用 tsc 试了一下之后,发现编译后的代码如下所示:

1
var result = foo === null || foo === void 0 ? void 0 : foo.bar;

好像和上面的有点不一样,这里的代码里所有的undefined全部换成了void 0,这是为什么呢?

分析

其实从MDN上,我们很容易知道void运算符的作用:

void运算符对给定的表达式进行求值,然后返回undefined

即不论void运算符后面跟的是什么,这个运算符总是返回undefined。比如下面这些写法都会返回undefine

  • void 0
  • void(0)
  • void 'hello'
  • void (1+2)
  • void(console.log('Hello world'));

也就是说上面 tsc 编辑后的语法中void 0undefined是等价的。

而之所以不直接使用undefined,是因为在早期的 JavaScript 版本中,undefined是可以被重新赋值的,而使用void 0,则可以获取到**undefined的原始值**。

我们来看个例子:

1
2
3
4
var undefined = 1;
var foo;
console.log(foo === undefined); // false
console.log(foo === void 0); // true

上面这段代码运行后,第一个console将会打印false,第二个将会打印true,这就是因为undefined被重新赋值为了1,不再是原始的值,而为了保证编译后代码的正确性和稳定性,一般都会使用void 0来获取原始的undefined值。

到这里我们就搞明白刚刚那个问题了,那除了这个场景,void运算符还有哪些作用呢?

作用

1. 获取undefined的原始值

这个上面的例子中已经说到了,这里就不再赘述。

2. 用在 JavaScript URI 中,以及阻止a标签的默认行为

当用户点击一个以javascript:开头的 URI 时,它会执行 URI 中的代码,然后用返回的值替换页面内容,除非返回的值是undefined。这个时候void运算符就派上了用场,比如:

1
2
3
4
5
6
7
8
9
<html>
<head>
<title>Test</title>
</head>
<body>
<a href="javascript:'zzzzz'">Click me</a>
<div>12345</div>
</body>
</html>

在这个例子里,点击a标签后,整个页面的内容,包括下面的12345,都会被替换为zzzzz

如果使用了void,则不会这样:

1
2
3
4
<a href="javascript:void('zzzzz');">这个链接点击之后不会做任何事情</a>
<a href="javascript:void(document.body.style.backgroundColor='green');">
点击这个链接会让页面背景变成绿色。
</a>

所以,很多地方的a标签都会添加一个简单的href="javascript:void(0)来阻止a标签的默认行为,达到类似preventDefault的效果。

可能有小伙伴有疑问,直接不写href属性或者href设为空字符串不就行了吗?其实还是有差别的,可以看下面的示例,从上到下三个a标签分别为:

  1. 没有href属性
  2. href值为空
  3. href值为javascript: void(0);

image.png

可以看到还是有一些区别的。

不过,目前除了javascript: void(0);比较常见之外,其他利用javascript:伪协议来执行 JavaScript 代码已经十分少见了,同时这种行为也是不太推荐的做法,推荐的做法是给对应的元素绑定事件处理器。

PS:

这里有一些历史背景在里面,在早期,各个浏览器有着不同的 DOM2 事件处理接口,对于阻止默认行为也有不同的实现,比如

  • 在支持W3C标准的浏览器中

    1
    2
    3
    linkEle.addEventListener('click', function (event) {
    event.preventDefault(); // 阻止默认行为
    });
  • 而在IE中则是

    1
    2
    3
    linkEle.attachEvent('onclick', function (event) {
    event.returnValue = false; // 阻止默认行为
    });

所以很多开发者为了省事,不想写两套兼容代码,就直接省事在a标签的href属性中添加javascript: void(0);来阻止默认行为了,之后点击行为的自定义处理再通过 DOM0 支持的事件处理器来处理:

1
2
3
linkEle.onclick = function () {
// do something...
}

这种做法流传很广,所以即便是在现代浏览器对 W3C 标准支持的比较好的今天,我们还是能在很多地方看到javascript: void(0);

3. 用于立即执行函数表达式(IIFE)

在使用立即执行函数表达式时,如果像下面这样写,会得到一个报错:

1
2
3
function myFn () {
console.log('Hello, world!');
}(); // Uncaught SyntaxError: Unexpected token ')'

为了解决这个问题,有两种方式:

  • 给函数体包一层圆括号:

    1
    2
    3
    (function () {
    console.log('Hello world');
    })();
  • 或者使用void运算符:

    1
    2
    3
    void function() {
    console.log('Hello, world!');
    }();

不过话说回来,IIFE 多用于 ES6 之前实现模块化,在 ES6 引入了importexport之后,IIFE 在实际的开发中用到的场景越来越少了,是一种过时的技术,这里是为了讲解void运算符才使用的,大家可以只做了解即可。

4. 强制返回undefined(常见于箭头函数中)

在箭头函数中,允许在函数体不使用花括号来直接返回值,比如:

1
button.onclick = () => doSomething();

但是这样做会把doSomething函数的执行结果作为返回值来返回给调用方,而如果doSomething函数的返回值从undefined变成了其他值,可能会造成预料之外的结果。

所以如果明确onclick不需要返回值时,可以通过增加void运算符来强制返回undefined,防止因为doSomething返回值的变化带来其他副作用:

1
button.onclick = () => void doSomething();

当然了,这里除了这种方式外,也可以通过给箭头函数增加一对花括号来解决:

1
2
3
button.onclick = () => {
doSomething();
};

结语

上面这些就是void运算符用的比较多的场景了,如果还有需要补充的,欢迎在评论区留言~

前言

最近 Next.js 14 发布了,很多地方都在讨论,虽然之前也有用过 Next.js,也看过一些关于 SSR 的文章,了解了一些 SSR 的原理,但是一直没有动手实现过。这次正好趁着这个机会,从零开始手动实现一个 React SSR 服务端渲染的项目,来加深一下对 SSR 的理解。

一、 什么是SSR

SSR,即Server Side Render,服务端渲染。和服务端渲染相对的,就是CSR,Client Side Render,客户端渲染。

从字面意思上就有看出,这两种渲染方式的差别就在于页面渲染的时机:

  • 服务端渲染是页面在服务端的时候就渲染完成了
  • 而客户端渲染是页面在客户端(浏览器或者WebView之类的)进行渲染,服务端只会返回空页面(下面例子中会讲到)

二、 为什么需要SSR

在讨论为什么需要SSR之前,我们先来看看常见的CSR,比如下面这个很简单的React渲染的页面:

page_overview.png

页面包含一个count计数,点击“Increment”、“Decrement”和“Reset”按钮,分别可以增加计数,减小计数以及重设计数。

现在我们打开DevTool,看看访问这个地址的时候服务端返回的内容:

csr_html_content.png

从DevTool可以看到,服务端一共返回了两个文件,一个HTML一个JS。

我们发现HTML里没有任何页面上的元素,是空的,只有一个root节点和一个main.js脚本。而所有的这些count计数以及下面这些按钮,都是在HTML页面以及main.js下载完成后,浏览器通过执行main.js来进行渲染生成的,这也就是所谓的客户端渲染。

通过这个我们可以发现,CSR的应用有两个比较明显的问题:

1. CSR应用十分不利于SEO

SEO,也就是Search Engine Optimization,搜索引擎优化。CSR应用从服务端返回的HTML是一个空的页面,页面内容元素完全依赖JS代码来生成,导致搜索引擎爬虫在抓取和解析网页时,无法获取到完整的网页内容,从而不利于搜索引擎优化搜索结果和搜索排名。

题外话:现在的一些搜索引擎(比如Google)已经可以解析CSR应用里的JS,所以CSR目前对SEO的影响可能没有以前那么大了,但是如果SEO对你来说很重要,那么最好还是做一些SSR服务端渲染

2. 首屏加载时间可能较长

由于CSR应用页面里所有的内容,都是通过JS动态生成的,那么在访问页面的时候,除了下载HTML外,还需要额外下载JS脚本才可以展示出页面。

衡量首屏加载性能的指标有很多,我们这里用常用的FCP(First Contentful Paint),即“首次内容渲染”时间来看下这个页面的表现。由于我们这个页面太过简单,而且是在本地127.0.0.1启动的服务,所以直接感受可能不明显,我们可以在DevTool里设置网络状态,改成“低速3G”来模拟:

csr_network_panel.png

而FCP除了可以用performance API获取到之外,也可以直接在Chrome DevTool的“性能”面板,通过点击面板里的“重新加载”按钮录制得到:

csr_perf_panel.png

从上面的网络瀑布和性能面板可以看到,在“低速3G”的网络状态下,页面在获取到2.01s获取到HTML后,并没有渲染任何内容,而是在又等了4.76s等到JS下载完成之后,才渲染出内容,页面的FCP总计是6822.2ms。

也就是说,在2.01s的时候,HTML已经下载好的情况下,用户还是无法看到内容,额外等待了4.76s等待JS的下载,首屏加载时间较长。

下面我们就来尝试把上面这个项目改成支持服务端渲染,来改善这两个CSR的弊端。

三、 准备好一个项目

首先我们先准备好上面这个项目的代码,这是项目的GitHub地址,大家可自取:react-no-ssr-demo

项目的目录结构长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.
├── src
│ ├── components
│ │ └── Button
│ │ ├── index.module.css
│ │ └── index.tsx
│ ├── Home.module.css
│ ├── Home.tsx
│ ├── index.html
│ └── index.tsx
├── webpack
│ └── client.config.js
├── .babelrc
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── package-lock.json
├── package.json
├── README.md
├── tsconfig.json
└── types.d.ts

关键的几个文件内容如下:

  1. src/components/Button/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react';
import classNames from 'classnames';
import styles from './index.module.css';

export interface IButtonProps {
text: string;
className?: string;
onClick?: () => void;
}

export const Button: React.FC<IButtonProps> = ({
text,
className,
onClick,
}) => {
return (
<button onClick={onClick} className={classNames(styles.btn, className)}>
{text}
</button>
);
};
  1. src/Home.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React, { useState } from 'react';
import { Button } from '@/components/Button';
import styles from './Home.module.css';

export const Home: React.FC = () => {
const [count, setCount] = useState(0);

return (
<div>
<p style={{ color: 'red' }}>count: {count}</p>
<div className={styles.btnWrapper}>
<Button
text="Increment"
className={styles.btn}
onClick={() => setCount(prev => prev + 1)}
/>
<Button
text="Decrement"
className={styles.btn}
onClick={() => setCount(prev => prev - 1)}
/>
<Button
text="Reset"
className={styles.btn}
onClick={() => setCount(0)}
/>
</div>
</div>
);
};
  1. src/index.tsx
1
2
3
4
5
import React from 'react';
import ReactDOM from 'react-dom';
import { Home } from './Home';

ReactDOM.render(<Home />, document.getElementById('root'));
  1. webpack/client.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

const rootDir = path.resolve(__dirname, '..');

module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(rootDir, 'dist/client'),
filename: 'main.js',
},
resolve: {
modules: ['node_modules', path.resolve(rootDir, 'src')],
extensions: ['.ts', '.js', '.tsx'],
},
module: {
rules: [
{
test: /\.(tsx?|jsx?)$/,
use: 'babel-loader',
exclude: '/node_modules/',
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
inject: 'body',
scriptLoading: 'defer',
}),
new CleanWebpackPlugin(),
new ForkTsCheckerWebpackPlugin({}),
],
performance: {
hints: false,
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
};
  1. package.json
1
2
3
4
5
6
{
"scripts": {
"dev": "webpack --config webpack/client.config.js --mode development --watch --stats verbose",
"build": "webpack build --config webpack/client.config.js --mode production --stats verbose"
}
}

除此之前,其他一些都是代码规范化相关的配置,比如eslint、prettier之类的,不是这次重点讨论的范围,感兴趣的可以看我之前这篇文章《前端代码规范化配置最佳实践 - 掘金》

通过在本地执行npm run dev或者npm run build,就可以编译项目,这里我没有使用webpack dev server,而是起了一个Nginx服务,简单改了一下Nginx配置:

1
2
3
4
5
6
7
8
9
server {
listen 3006;
server_name localhost;

location / {
root /your-project-location/react-no-ssr-demo/dist/client;
index index.html index.htm;
}
}

这样直接在浏览器访问127.0.0.1:3006或者localhost:3006就可以了。

四、 使用服务端渲染

现在让我们开始给项目添加服务端渲染,既然要服务端渲染,就必须在服务端使用React的renderToString来渲染好HTML再返回给客户端,既然要求在服务端跑JS代码,那么服务端就必须要引入Node了,也就是说做服务端渲染,必须要有一个Node做中间层(Next.js的SSG不算是严格意义上的服务端渲染,不在讨论范围内)。

1. 添加Node服务

现在让我们先加一个基础的Node服务:

1
2
npm install --save express
npm install --save-dev @types/express

添加一个src/server.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { Home } from './Home';

const app = express();

app.get('/', (req, res) => {
const app = ReactDOMServer.renderToString(<Home />);
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR</title>
</head>
<body>
<div id="root">${app}</div>
</body>
</html>
`;
res.send(html);
});

const PORT = process.env.PORT || 3007;
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`);
console.log(`http://localhost:${PORT}`);
console.log(`http://127.0.0.1:${PORT}`);
});

这里我们换了一个3007端口,为了和刚刚的CSR项目的3006端口区分开来(另外使用了Node服务之后,也就不需要Nginx了)。

从上面的代码可以看到,src/server.tsx中,先创建了一个express服务,然后监听了3007端口,在访问127.0.0.1:3007或者localhost:3007的时候,服务端调用ReactDOMServer的renderToString方法,将我们的Home组件渲染为了HTML字符串,并且拼接到了一个HTML模板中,返回给了客户端。

2. 添加src/server.tsx的编译配置

由于src/server.tsx使用了TS和JSX的语法,那么这个文件也需要使用webpack和babel进行编译,让我们添加一下这个server文件的编译配置,webpack/server.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const path = require('path');

const rootDir = path.resolve(__dirname, '..');

module.exports = {
target: 'node',
entry: './src/server.tsx',
output: {
path: path.resolve(rootDir, 'dist'),
filename: 'server.js',
},
module: {
rules: [
{
test: /\.(tsx?|jsx?)$/,
use: 'babel-loader',
exclude: '/node_modules/',
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
};

然后在package.json中添加一下编译服务端的scripts命令:

1
2
3
4
5
6
7
{
"scripts": {
"start": "node dist/server.js",
"dev:server": "webpack --config webpack/server.config.js --mode development --watch --stats verbose",
"build:server": "webpack build --config webpack/server.config.js --mode production --stats verbose"
}
}

执行npm run dev:server以及npm start之后,打开127.0.0.1:3007或者localhost:3007看下:

ssr_no_js.gif

果然,这次服务端返回的HTML不再是空的了,页面上的元素直接就可以在HTML中看到。

但是这里奇怪的是,页面的样式没有了,点击这些按钮也不再生效了,同时从网络请求里看到,服务端也只返回了一个HTML,没有JS,这是为什么呢?

五、 同构 & hydrate

其实,这是因为我们上面实现的SSR还不是一个完整的React SSR项目。renderToString虽然可以在服务端把组件渲染为HTML,但是却无法实现事件监听器的挂载或者绑定(毕竟事件绑定是要绑定到浏览器上真实的DOM上,而不是HTML字符串上),所以在renderToString的时候会把事件处理器给过滤掉。

1. 同构

那么为了实现完整的SSR,就需要引入“同构渲染”的概念了。这个词相信大家之前都或多或少听过,其实很简单,同构渲染就是同一份代码,既在服务端运行(SSR),又在客户端运行(CSR)。

最开始我们提到的那个CSR代码,只是在客户端运行,后来加上的服务端渲染的能力,只是在服务端运行(客户端只是接收了一个HTML,并没有运行什么JS代码)。现在需要将两者结合起来,接下来让我们开始改造一下:

首先,src/server.tsx中,我们不再直接返回一个模板HTML,而是在上面CSR项目编译出来的HTML中直接加上服务端渲染的内容,同时在服务端提供静态资源访问服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import fs from 'fs';
import path from 'path';
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { Home } from './Home';

const clientDistDir = path.resolve(__dirname, '../dist/client');
const htmlPath = path.resolve(clientDistDir, 'index.html');

const app = express();

app.get('/', (req, res) => {
// 读取 dist/client/index.html 文件
const html = fs.readFileSync(htmlPath, 'utf-8');
const app = ReactDOMServer.renderToString(<Home />);
// 将渲染后的 React HTML 插入到 div#root 中
const finalHtml = html.replace(
'<div id="root"></div>',
`<div id="root">${app}</div>`
);
res.send(finalHtml);
});

// 提供静态资源访问服务
app.use(express.static(clientDistDir));

const PORT = process.env.PORT || 3007;
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`);
console.log(`http://localhost:${PORT}`);
console.log(`http://127.0.0.1:${PORT}`);
});

直接在命令行执行:

1
2
3
npm run dev
npm run dev:server
npm start

打开127.0.0.1:3007或者localhost:3007看下:

ssr_render.gif

看起来好像OK了,既有服务端渲染(返回的HTML不为空,直接就有页面上的元素),又有客户端渲染(事件绑定成功,有页面交互)。但是如果这个时候你查看一下控制台的话,会发现会有一个Waring:

ssr_render_warn.png

说是调用ReactDOM.render()去渲染(水合,或者说注水)一个服务端渲染的页面的行为会在React 18停止支持。

2. hydrate

其实这个从React官网也可以看到,在服务端使用renderToString外进行服务端渲染后,还需要在客户端使用hydrate(或者hydrateRoot,后者是React 18中的写法),来完成事件绑定和页面的交互性逻辑。

来改下代码,在src/index.tsx中,改为如下内容:

1
2
3
4
5
import React from 'react';
import ReactDOM from 'react-dom';
import { Home } from './Home';

ReactDOM.hydrate(<Home />, document.getElementById('root'));

同时为了方便调试,安装一下两个依赖:

1
2
npm install --save nodemon
npm install --save-dev npm-run-all

修改package.json

1
2
3
4
5
6
7
8
9
10
11
{
"scripts": {
"start": "nodemon --inspect dist/server.js",
"dev": "npm-run-all --parallel dev:*",
"dev:client": "webpack --config webpack/client.config.js --mode development --watch --stats verbose",
"dev:server": "webpack --config webpack/server.config.js --mode development --watch --stats verbose",
"build": "npm-run-all build:*",
"build:client": "webpack build --config webpack/client.config.js --mode production --stats verbose",
"build:server": "webpack build --config webpack/server.config.js --mode production --stats verbose"
}
}

其中nodemon用于监听dist/server.js的变化,一旦修改了src/server.tsx webpack会重新编译,生成新的dist/server.js,这个时候nodemon会重新运行新的dist/server.js

npm-run-all则用于同时运行多个npm命令。

这个时候再运行:

1
2
npm run dev
npm start

访问127.0.0.1:3007或者localhost:3007,发现已经没有Waring了:

ssr_hydrate_no_warn.png

3. hydrate和render的区别

render()hydrate()在大部分情况下的行为是相似的,这两个都会将React元素渲染到指定的DOM节点中,但是在处理服务端渲染返回的HTML是有一些区别。

服务端渲染的时候,服务端会渲染React元素并且生成一个HTML字符串返回给客户端(也就是浏览器),之后客户端会用这个HTML来生成DOM。在同构渲染的时候,客户端还会重新执行一遍JS代码,重新生成一个React组件树和相应的DOM节点。而render()hydrate()的区别就在这里。

render()会直接创建一个新的React组件数和相应的DOM节点,而hydrate()则是在生成的时候,会判断这个节点是否已经在服务端渲染好,会尽可能地保留现有的DOM,只更新必要的部分

这也就是React官网所说的:

Call hydrate in React 17 and below to “attach” React to existing HTML that was already rendered by React in a server environment.

React will attach to the HTML that exists inside the domNode, and take over managing the DOM inside it.

在React 17及以下版本中调用hydrate,可以将React“附加”到在服务器环境中已经由React渲染的现有HTML上。

React将会附加到domNode内部现有的HTML,并接管有关的DOM的管理。

六、 再来看下CSR的两个痛点

到这里就算是完成了一个最基本的React服务端渲染,现在我们回头来看一下,是否解决了上面CSR项目的两个痛点。

1. SEO

首先是之前SEO不友好的问题。

在做了SSR渲染后,从服务端返回的HTML里就已经包含了页面上的元素,搜索引擎爬虫在抓取和解析网页时,可以获取到完整的网页内容,显然SSR渲染可以解决这个问题(当前想要更好的SEO效果,还有其他可以优化的地方,不过这些就和CSR/SSR无关了)。

2. FCP

其次,我们来看一下首屏的加载时间,还是通过设置DevTool里设置网络状态,改成“低速3G”来看一下FCP:

ssr_network_panel.png

ssr_perf_panel.png

从上面可以看到,虽然网络面板内的HTML和JS整体的加载时间和之前几乎一样(都是6.8s左右),但是从性能面板里可以看到,页面的FCP是2043.2ms,比之前的6822.2ms少了将近70%。

从时间轴的截图上也可以发现,页面在HTML下载成功之后(2.02s),就立刻可以看到页面内容,虽然页面的交互还是需要等到6.8s JS下载完成,但是从用户体验上来讲,缩短页面的白屏时间,用户可以更快的看到页面内容,对于用户体验是一个很大的提升。

也就是说,SSR确实解决了CSR的两个痛点。

七、 服务端渲染一些主要注意的事情

下面是一些做服务端渲染时需要注意的点:

1. React的生命周期和一些Hooks

React的一些生命周期函数,比如类组件的componentDidMountcomponentDidUpdatecomponentWillUnmount,以及函数组件的useEffectuseLayoutEffect,都不会在服务端渲染的时候执行。

2. 浏览器专属的API

浏览器专属的API,比如windowdocumentlocalStorage等,都不能在服务器端运行,需要判断只有在当前环境是客户端才可以执行:

1
2
3
4
if (typeof window !== 'undefined') {
// 下面的代码只会在浏览器环境下执行
window.localStorage.setItem('key', 'value');
}

或者

1
2
3
4
useEffect(() => {
// 下面的代码只会在浏览器环境下执行
window.localStorage.setItem('key', 'value');
}, []);

3. 事件处理函数

如上面提到的那样,服务端渲染的时候,不会执行事件处理函数,也不会触发任何事件,需要在客户端处理。

4. 服务端渲染和客户端渲染时的差异

在进行同构渲染的时候,请务必保证客户端渲染出来的内容和服务端渲染的内容完全相同。如果客户端和服务端渲染出来的内容不一致,React会尝试对不一致的地方进行修复,而这些修复是非常耗时的。如果差异过大甚至会重新渲染整个应用(类似于ReactDOM.render)。

所以应尽量避免客户端渲染出来的内容和服务端渲染出来的内容不一致。

八、 小结

通过上面的内容,我们从零手动完成了一个React服务端渲染的Demo项目,这只是一个最基础的项目,还有更多的比如React路由服务端渲染、服务端渲染时的数据脱水和注水等等,都需要添加更加复杂的配置,有时间了再单独写一篇聊一下。

这里附上文章里提到的两个Demo项目地址:

参考资料

1
docker pull nginx:latest
1
docker run --name nginx -p 8080:80 -d nginx
1
2
3
docker exec -it nginx bash
cat /etc/nginx/nginx.conf
exit
1
mkdir -p /opt/docker/nginx/conf
1
2
3
4
docker cp nginx:/etc/nginx/nginx.conf /opt/docker/nginx/conf/nginx.conf
docker cp nginx:/etc/nginx/conf.d/ /opt/docker/nginx/conf/conf.d
docker cp nginx:/var/log/nginx/ /opt/docker/nginx/logs
docker cp nginx:/usr/share/nginx/html/ /opt/docker/nginx/html
1
2
3
docker stop nginx
docker rm nginx
docker ps -a
1
2
3
4
5
6
7
8
docker run \
-p 80:80 \
--name nginx \
-v /opt/docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
-v /opt/docker/nginx/conf/conf.d:/etc/nginx/conf.d \
-v /opt/docker/nginx/logs:/var/log/nginx \
-v /opt/docker/nginx/html:/usr/share/nginx/html \
-d nginx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 下面这个是有问题的,这样设置里之后,访问 `http://localhost:80` 依然可以访问到 nginx 的默认页面
# 因为这个命名使用了 `--net host` 选项,这个选项会让容器使用宿主机的网络,所以 Docker 中的 Nginx 依然会监听宿主机的 80 端口

# 如果是在云服务器上使用 Docker 安装 Nginx,那么下面的命令会导致,使用 8080 端口也可能访问呢不到 Nginx 的默认页面,因为一般情况下云服务器默认的防火墙会关闭 8080 端口。

docker run \
-p 8080:80 \
--name nginx \
--net host \
-v /opt/docker/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
-v /opt/docker/nginx/conf/conf.d:/etc/nginx/conf.d \
-v /opt/docker/nginx/logs:/var/log/nginx \
-v /opt/docker/nginx/html:/usr/share/nginx/html \
-d nginx

参考资料:

一、前言

Sentry大家应该都不陌生,即便没有使用过,也应该听过Sentry的大名。

作为一个实时事件日志监控平台,Sentry可以记录和聚合我们应用中的报错、打点等,不管是Sentry自动捕获的错误,还是我们主动上报的错误,都可以在Sentry提供的可视化平台看到,方便开发者及时发现、分析和排查应用中存在的问题。

但是在使用Sentry的过程中,我们发现了一些使用起来不那么方便的地方,这个就是我们今天要一起讨论的问题——Sentry Issue的分组(Issue Grouping)。

二、先看看什么是Sentry Issue

在Sentry中,每一条日志上报都是一个事件(Event),在Sentry的Discover面板中,我们可以看到所有上报的Event,比如我这个项目:

Discover面板

Event分为两种类型,Transaction和Error。

Transaction事件主要用于性能监控。它记录了一个请求或任务从开始到结束的完整生命周期,包括各种详细的性能数据,如请求的开始时间、结束时间、总耗时、各个阶段的耗时等。

Error事件主要用于错误跟踪。它记录了应用运行过程中发生的错误或异常,包括错误的类型、位置、堆栈跟踪等信息。

Issue就是Error类型的Event的聚合,Sentry会把一些相似的Error进行聚合,合并成一个Issue,这样我们就可以看到某个特定Error发生的频率和趋势,而不仅仅是只能看到单个Error Event。

Sentry的Issue可以在Issues面板中看到,如下图所示:

Issue面板

从这个面板可以看到某个Issue(即某个类型的Error),上报了几次,有多少用户遇到了这个Error,以及这个Error数量变化的趋势,帮助我们快速确认问题的严重程度和影响范围。

三、我们遇到啥问题了?

从上面的描述可以看到,Sentry把Error进行聚合,合并成一个个Issue,帮助我们查看某个类型Error的一些信息,看起来是挺好的。

但是在我们的项目里,Sentry好像并不是这么做的,比如下图:

重复的Issue

说好的自动聚合分组呢?

同样的一个Error(或者是极其相似的Error),并没有被聚合为同一个Issue,而是分到了不同的几个Issue里,并且这些Issue的名字几乎一摸一样,每个Issue还各自展示一个Event次数。

而且这个问题不止出现在某一种类型的Error上,几乎所有的Error上报都或多或少地存在这种问题,导致不能很好地分析某种Error的影响或者变化趋势。

而且有时候即便我们手动Ignore某个Issue,未来还是会不断地有新的这个Issue出现,或者我们像下面这样手动Merge两个Issue,也还是会源源不断地产生新的、没有被Merge进手动Merge的分组内的Issue。

手动Merge

四、研究下Sentry是怎么对Error分组的

在解决我们遇到的问题之前,还是要先了解下Sentry是怎么对Error进行分组的,知道原理才能着手解决。

看了下官方文档Issue Grouping | Sentry Documentation,这才揭开了Sentry分组的面纱。

1. Sentry Issue的Fingerprint和分组

在Sentry中,有一个“指纹”的概念,Fingerprint,Fingerprint是标识Event的一种方式,每个Event(包括Error和Transaction)都有一个Fingerprint。

Sentry会根据某种规则,来给每一个Event生成Fingerprint,具有相同Fingerprint的Event会被Sentry分为一组,这就是Sentry分组的基本原则。

1.1 如何在Sentry上查看一个Event的Fingerprint呢?

从Discover或者Issues列表中,随便点击一个进入Error详情(Transaction不行,下面会讲原因),点击查看这个Error对应原始JSON数据:

查看原始JSON

在原始JSON中搜索fingerprint字段,可以看到如下所示:

1
2
3
4
5
{
"fingerprint": [
"{{ default }}"
]
}

一个{{ default }},这说明使用的是Sentry默认规则生成的Fingerprint。如果是其他的规则,则会展示为其他的值。

1.2 Event默认的Fingerprint生成规则

不同类型的Event,有不同的Fingerprint生成规则:

  • Error类型:Error类型会基于这个Error的调用堆栈Stack Trace,异常类型Exception,和日志消息message,从这三个方面来生成Fingerprint
  • Transaction类型:通过这个类型的Spans来生成,可以查看原始JSON数据中的spans字段

我们这次只讨论Error类型的Event Fingerprint生成规则。

首先,Sentry每个版本生成Fingerprint的默认规则可能会有一些差异,每次Sentry默认的Fingerprint生成规则变化了之后,Sentry都会发布一个新版本,所以Fingerprint生成规则变化了之后,不会影响已有的Event。

每次新建一个Project,都会自动使用目前最新版本的Fingerprint生成规则,如果想要现有的Project升级到最新的Fingerprint生成规则,需要在设置里手动修改,具体位置为:Settings > Project > [Your Project] > Processing > Issue Grouping > Upgrade Grouping.如下图:

升级分组

所有版本的Fingerprint生成规则都是最先考虑Stack Trace,然后是Exception, 最后是message

a. 按照Stack Trace分组

对于一个上报的Error Event,如果他的原始数据中有调用堆栈信息,就会完全根据调用堆栈来进行分组(即不考虑其他的),主要会使用下列信息:

  • 模块名module
  • 文件名(去除哈希值等之后的名字)filename
  • 上下文,行号列号等信息

这里的堆栈信息只包括和当前Project有关的堆栈,和当前项目无关的堆栈信息不会用于分组。

堆栈信息可以在原始的JSON数据中的exception.valuesstacktrace字段中看到,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"exception": {
"values": [
{
"type": "Error",
"value": "xxx err",
"stacktrace": {
"frames": [
{
"function": "xxx",
"module": "yyy",
"filename": "zzz",
"abs_path": "app:///aaa/bbb/ccc.js",
"lineno": 10,
"colno": 30,
"in_app": true
},
{
"function": "rrr",
"module": "uuu",
"filename": "bbb",
"abs_path": "app:///ddd/eee/fff.js",
"lineno": 20,
"colno": 57,
"in_app": true
},
// ......
]
}
}
]
}
}

stacktrace字段中有个frames,是一个数据,记录的是当前Error发生时的调用堆栈帧列表,数组中的每一项就是一个调用帧(frame),每一帧中都有如下信息:

1
2
3
4
5
6
7
8
9
{
"function": "xxx",
"module": "yyy",
"filename": "zzz",
"abs_path": "app:///aaa/bbb/ccc.js",
"lineno": 10,
"colno": 30,
"in_app": true
}

而Sentry就是根据这些调用栈的帧列表,来生成Fingerprint。即相同调用堆栈的错误,被认为是同一种类Error,会被归为同一组。

用这个方法来分组,一般来说效果都挺不错,但是如果出现下面这些情况,就会导致这个分组方法或者说分组规则,效果不那么好:

  1. 代码经过混淆或者压缩(比如TS/JS代码经过Babel编译)
    由于混淆或者压缩之后,代码的变量名、函数名、代码结构等都会发生变化,即便对于同一个Error,不同版本的代码(比如两个release版本之间,或者两次不同的构建之间)的调用堆栈信息也会发生变化,导致Sentry认为这些是不同的Error,从而没有进行聚合分组。
    如果代码有混淆或者压缩,就需要上传Source Maps到Sentry,让Sentry通过原始的堆栈信息生成Fingerprint,来避免分组混乱。

  2. 代码通过装饰器等引入了新的堆栈层级,也会导致调用堆栈发生变化。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const decoratorFn = (target, keyName, descriptor) => {
const originFn = descriptor.value;
descriptor.value = function () {
console.log('Before function execution');
const ret = originFn.apply(this, arguments);
console.log("After function execution");
return ret;
};
return descriptor;
};

class MyClass {
@decoratorFn
handleClick(e) {
console.log('Inside function');
}
}

上面这个例子中,myFunctiondecoratorFn装饰。当调用myFunction时,实际上是在调用decoratorFn返回的函数。因此,如果在这个过程中发生错误并生成堆栈信息,堆栈信息中将会包含decoratorFn返回的函数的调用,这就是通过引入新的堆栈层级来修改堆栈信息。

可以通过自定义的beforeSend来自定义删除或者修改这些堆栈信息(下面会讲到)。

b.按照Exception分组

如果上报的Error Event的数据中没有堆栈调用信息,但是有完整的Exception信息(这里的完整指的是Exception的type和value同时存在),就会按照Exception的type和value进行分组,但是这种分组可靠性比较低,因为错误的信息可能会改变。

这里的Exception指的就是各种Error,JS里的Exception就是各种类型的Error,比如ReferenceErrorTypeErrorRangeErrorSyntaxError等。

  • Exception的type就是Error的name,如果有自定义的name就是自定义的name,比如DownloadFileError,没有自定义的话默认就是各个类型名
  • Exception的value指的就是err.message

比如访问一个未定义的变量,JS会抛出一个ReferenceError,type就是“ReferenceError”,value可能是“x is not defined”。

如果是下面的自定义name的Error:

1
2
const err = new Error('This is a mock err msg');
err.name = 'MyMockError';

那么这个Error的type就是“MyMockError”,value就是“This is a mock err msg”。

这里的type和value也可以直接在Sentry上任意一个Error原始的JSON数据中看到,JSON中的metadata字段,记录了这个Error的type和value:

1
2
3
4
5
6
7
8
9
10
{
"metadata": {
"display_title_with_tree_label": false,
"filename": "app:///aaa/bbb/ccc.js",
"function": "xxx",
"title": "MyMockError: This is a mock err msg",
"type": "MyMockError",
"value": "This is a mock err msg"
}
}

问:
什么情况下会出现存在Exception但是不包含堆栈信息?

ChatGPT:
在大多数情况下,当JavaScript抛出一个异常时,这个异常都会包含一个堆栈跟踪。堆栈跟踪记录了错误发生时的函数调用序列,这对于理解和调试错误是非常有用的。
然而,在某些情况下,异常可能不包含堆栈跟踪。例如:

  1. 如果你手动创建并抛出一个错误,但没有提供堆栈跟踪,那么这个错误就不会有堆栈跟踪。例如,throw new Error()会创建一个包含堆栈跟踪的错误,但throw { message: ‘error’ }则不会。

  2. 在某些JavaScript环境中,例如某些旧版本的浏览器或某些JavaScript引擎,可能不支持堆栈跟踪,或者支持的方式与标准不同。

  3. 如果错误发生在异步代码中,并且这个错误没有被正确地捕获和处理,那么可能只有错误信息,没有堆栈跟踪。

  4. 如果你的代码中有捕获错误并处理的逻辑,可能会修改或移除堆栈跟踪。

c. 兜底的分组

如果上面两种情况都没办法对Event进行分组,那么就会使用兜底的分组,即直接使用上报的时候收到的Event消息来分组。

1.3 分析一下

到这里我们可以先分析一下,为什么我们的项目会出现上面说的问题了。

首先,我们的项目没有修改过任何和Event Fingerprint有关的设置,使用的是默认分组规则,即使用调用堆栈Stack Trace,异常类型Exception,和日志消息message来进行分组。而绝大部分都是使用调用堆栈进行分组。

我们的JS项目由于某种原因,在编译后没有把Source Maps上传到Sentry,导致代码的变量名、函数名、代码结构等在不同版本或者不同的构建记录后,都会发生变化,所以即便某个Issue被Ignore或者被手动Merge,到下一个版本,由于同一个Error的调用栈变化了,生成了完全不同的Fingerprint,导致没有被分为一组。

混淆压缩后的代码的调用栈

💡注意:
代码混淆之后并不是会让Sentry没办法对Error生成Fingerprint以及分组,即使代码被混淆和压缩,只要所有用户都使用的是同一份混淆和压缩后的代码,那么同一个地方的Error应该会生成相同的堆栈跟踪,Sentry应该能够正确地将这些错误分到同一组。

真正的问题在于多个版本或者多个构建之间,每次压缩混淆后的代码都不一样,从而导致不同版本直接Error分组混乱。

看来使用默认的Fingerprint生成规则不行了,至少在我们项目上传Source Maps之前不行。需要看下怎么自定义分组。

1.4 自定义分组

首先,只有Error类型的Event支持自定义分组,Transaction类型的暂时无法自定义。这也是为什么上面说Transaction类型的Event,无法在原始JSON数据中看到fingerprint字段的原因,因为Transaction Event无法自定义,所以也就不会展示在JSON数据里。

对于Error类型的Event,从简单到复杂有以下4种方式来自定义分组:

  1. 在Sentry Admin对应的项目Issues列表中,手动Merge
    手动合并(你认为是)相同的Issues,最简单,不需要修改任何设置和配置项。

  2. 在Sentry Admin对应的项目设置中,设置自定义的Fingerprint Rules
    设置Fingerprint Rules,只影响新上报的的Event,不影响已经上报的Event。

  3. 在Sentry Admin对应的项目设置中,设置自定义的Stack Trace Rules
    设置Stack Trace Rules,只影响新上报的的Event,不影响已经上报的Event。

  4. 在使用Sentry SDK的本地项目里,使用SDK Fingerprinting
    在本地项目中,使用SDK上报之前,设置Event的Fingerprint。

下面我们一个个来看。

2. 手动合并Issue

在Sentry项目的Issues列表中,手动选择2或者更多个Issue,然后点击Merge,即可合并为一个分组。

手动Merge

需要注意的是,Sentry并不会根据我们如何手动Merge,来改变或者推断出任何新的分组规则,新产生的Issue还是会按照之前的规则来分组,然后根据放到我们手动Merge的Issue集合中。

这也解释了为什么我们项目中,每次手动Merge之后,还是会产生新的没有被进入Merge后的分组,因为“Sentry并不会根据我们如何手动Merge,来改变或者推断出任何新的分组规则”。

3. Stack Trace Rules

虽然按照Sentry官网的文档的说法,Stack Trace Rules要比Fingerprint Rules复杂一些,我们还是先来讲下Stack Trace Rules。

在比较旧的Sentry版本中,Stack Trace Rules也叫作Grouping Enhancements或者Custom Grouping Enhancements。

具体设置位置为:Settings > Project > [Your Project] > Processing > Issue Grouping > Stack Trace Rules

或者旧版本中为:Settings > Project > [Your Project] > General Settings > Custom Grouping Enhancements

Stack Trace Rules

修改Stack Trace Rules会影响输入到Stack Trace分组算法中的数据。我们可以通过规则来改变哪些stack trace frames被视为”in-app”,这会影响Sentry如何将Issue分组。例如,我们可以将某些通常被视为”not in-app”的frames标记为”in-app”,这样它们就会被包含在分组算法中。

在自定义的Stack Trace Rules中,每一行都是一条规则。每条规则有匹配项(matcher)、表达式(expression),以及跟在后面的操作(action)组成:

1
matcher-name:expression other-matcher:expression ... action1 action2 ...

一条规则里可以有多个匹配表达式,后面也可以有多个action。这些action会在前面所有匹配表达式匹配的时候执行。

所有的规则会从上到下,对调用堆栈信息里的所有帧(Frames)执行。

如果要表达否定,那么就在matcher前加上一个感叹号!,某一行以#开头则表达这一行是注释。

下面是一些例子:

1
2
3
4
5
6
7
8
# mark all functions in the std namespace to be outside the app
family:native stack.function:std::* -app

# mark all code in node modules not to be in app
stack.abs_path:**/node_modules/** -app

# remove all generated javascript code from all grouping
stack.abs_path:**/generated/**.js -group

由于Stack Trace Rules不是我们这次讨论的重点,这里就不太说太多了,更多详细的关于Matchers和Actions的信息,详见官方文档:MatchersActions

4. Fingerprint Rules

在比较旧的Sentry版本中,也叫作Server Side Fingerprinting(叫这个名字是为了和SDK Fingerprinting对应)。

具体设置位置为:Settings > Project > [Your Project] > Processing > Issue Grouping > Fingerprint Rules

或者旧版本中为:Settings > Project > [Your Project] > General Settings > Server Side Fingerprinting

Fingerprint Rules

Fingerprint Rules的配置方式和Stack Trace Rules类似,只有语法上不同。但是和Stack Trace Rules不同的是,Fingerprint Rules允许我们直接指定一个Issue的Fingerprint,它会完全覆盖默认的分组规则。

可以理解为,Stack Trace Rules更关注如何改变分组算法的输入数据(比如翻转一些标志位,或者对调用栈做一些裁剪),而Fingerprint Rules则直接指定了分组的结果

首先,Fingerprint Rules同样是每一行是一条规则。每一条规则的Matcher和Stack Trace Rules的语法规则也是一样的,并且都可以设置!来表示取反,以及设置#来注释。

1
2
3
4
# You can use comments to explain the rules.  Rules themselves follow the
# following syntax:
matcher:expression -> list of values
# The list of values can be hardcoded or substituted values.

Fingerprint Rules也是把一个Event从上到下进行匹配,每条规则都是对调用堆栈信息里的所有帧(Frames)执行,并且会把匹配到的第一条规则作为Event的Fingerprint。

不同的是,Stack Trace Rules的Matcher右侧是对Stack Trace Frames数据进行的一些操作,Fingerprint Rules的Matcher右侧直接就是需要指定的Fingerprint的值,可以是一些写死的常量,也可以是一些内置的变量(Variables)

下面的例子就是把Error类型的Event根据type和value进行分组:

1
2
3
4
5
6
# 把 DatabaseUnavailable 和 ConnectionError 这两种类型的 Error,都标记为 system-down
error.type:DatabaseUnavailable -> system-down
error.type:ConnectionError -> system-down

# 把 Error message 中,包含“connection error: ”的,都标记为 connection-error,同时把当时 Error 的 transaction 字段也拼接到 Fingerprint 中
error.value:"connection error: *" -> connection-error, {{ transaction }}

4.1 Matchers

对于Matchers,Sentry允许使用glob patterns语法。Sentry包含了以下的这些Matcher:

  • error.type
    匹配Error的type(name),对应的是JSON中的metadata.type,大小写敏感:
1
2
error.type:ZeroDivisionError -> zero-division
error.type:ConnectionError -> connection-error
  • error.value
    匹配Error的value(message),对应的是JSON中的metadata.value,允许使用通配符,大小写不敏感:
1
2
error.value:"connection error (code: *)" -> connection-error
error.value:"could not connect (*)" -> connection-error
  • message
    匹配日志消息,对应的是JSON中的message字段,允许使用通配符,大小写不敏感:
1
message:"system encountered a fatal problem: *" -> fatal-log
  • logger
    匹配当前的logger的名称,对应的是JSON中的logger字段,允许使用通配符,大小写敏感:
1
logger:"com.myapp.mypackage.*" -> mypackage-logger
  • level
    匹配当前Event的日志级别,对应的是JSON中的level字段,允许使用通配符,大小写不敏感:
1
logger:"com.myapp.FooLogger" level:"error" -> mylogger-error
  • tags.tag_name
    匹配某个tag,某个标签名,允许使用通配符。
    这里的tag_name,对应的是JSON中的tags字段中,每一项的名字。tags是一个数字,代表多个标签,每一项是一个标签,每个标签也是一个数字,数组有两个元素,第一个元素是标签名,即tag_name,第二个是标签值。例如下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{  
"tags": [
[
"device",
"iPhone10,2"
],
[
"device.family",
"iOS"
],
[
"os",
"iOS 16.1.2"
],
[
"os.name",
"iOS"
],
[
"environment",
"dev"
],
[
"release",
"dev-v3.24"
],
[
"dist",
"3.24.1023"
],
// ......
]
}
1
tags.release:"dev-v3.x" -> dev-v3-error
  • stack.abs_path
    匹配调用栈帧的绝对路径,对应的是每一帧中的abs_path字段,允许使用通配符,且大小写不敏感:
1
stack.abs_path:"**/my-utils/*.js" -> my-utils, {{ error.type }}
  • stack.module
    匹配调用栈帧的模块名,对应的是每一帧中的module字段,允许使用通配符,大小写敏感:
1
stack.module:"*/my-utils/*" -> my-utils, {{ error.type }}
  • stack.function
    匹配调用栈帧的方法名,对应的是每一帧中的function字段,大小写敏感:
1
stack.function:"my_assertion_failed" -> my-assertion-failed
  • stack.package
    匹配当前帧所在的package,允许使用通配符:
1
2
stack.package:"**/libcurl.dylib" -> libcurl
stack.package:"**/libcurl.so" -> libcurl
  • family
    通常用来缩小匹配范围,且通常和其他Matcher一起使用,目前包含以下值:
    • javascript,任何来自于JavaScript的Event
    • native,任何来自于Native的Event
    • other,其他任何Event
1
family:native !stack.module:"myproject::*" -> not-from-my-project
  • app
    匹配当前帧是否是在app内,通常和其他Matcher一起使用,包含yes和no两个值,对应的是每一帧中的in_app字段:
1
app:yes stack.function:"assert" -> assert

更多关于Matchers的信息,详见Matchers

4.2 Variables

在一条Fingerprint Rule的右侧,就是Variables,这里其实不只可以是变量,也可以是一些写死的常量。

对于变量来说,它们和Matchers的名字一样,并且会自动把变量对应的原始的值填入,用于生成Fingerprint。

例如:

1
stack.function:"evaluate_script" -> script-evaluation, {{ error.type }}

这条规则会匹配调用栈中方法名为evaluate_script的Error,并且会把常量script-evaluation和当前Error的type(name)作为一部分,一起生成Fingerprint。

例如,["script-evaluation", "ReferenceError"]

或者,["script-evaluation", "TypeError"]

其他的变量和Matchers的名字一样,都是使用{{ }}包裹起来的,详见Variables

4.3 自定义标题

在设置Fingerprint Rules时,我们往往是想要按照自己的规则对Event进行分组,但是Event通常都是使用type和value来作为标题展示在Sentry中的,如果只改了Fingerprint Rules,那么原始的Event标题可能不那么友好,或者具有一定的误导性。

这个时候,我们可以在添加Fingerprint Rules的时候,额外添加title字段,即可设置这个分组的标题。比如:

1
logger:my.package.* level:error -> error-logger, {{ logger }} title="Error from Logger {{ logger }}"

自定义标题前:

自定义标题前

自定义标题后:

自定义标题后

在设置了自定义标题后,就可以在Error的原始JSON数据中看到title发生了变化:

自定义标题后的JSON

🔔 注意:

只有比较新的版本(比如Sentry 23.x)才支持设置自定义的title在旧版本的Sentry中(比如Sentry 20.x),上面的写法会让Sentry把后面的title="Error from Logger {{ logger }}"认为是Fingerprint的一部分。

具体是哪个版本开始支持的我没在网上查到,如果你私有部署的Sentry版本发现不支持,可以尝试升级一下版本。

4.4 怎么确定有没有匹配上自定义的Fingerprint Rules?

在添加了自定义的Fingerprint Rules之后,我们如何确定某个Event有没有命中呢?

其实我们直接查看对于的JSON数据即可,如果匹配上的话,会看到下图这样:

匹配上的JSON

我们可以在_fingerprint_info中看到当前Event的各种信息

  • client_fingerprint,对应的是这个Event的SDK Fingerprint(下面会讲到,当前Event没有设置SDK Fingerprint,所以为default)
  • matched_rule,对应的是Fingerprint Rules,比如这里显示当前Event命中的Matchers是哪个,以及当前Matchers设置的Fingerprint,还有我们自定义的title

同时下面的fingerprint字段,也展示了当前Event最终的Fingerprint。

如果是旧版Sentry的话,这里就没有_fingerprint_info这个字段了,同时会把我们设置的title认为是Fingerprint的一部分,会是下面这样:

旧版Sentry的JSON

5. SDK Fingerprint

如果上面说的Fingerprint Rules,不能满足我们的需要,那么我们可以使用SDK Fingerprint来更灵活地生成Fingerprint。

从上面的Fingerprint Rules文档可以看到,Fingerprint Rules只有少部分Matchers支持设置通配符,所以可能没那么灵活。

如果从上面的官方文档Issue Grouping | Sentry Documentation中看,会发现文档里没有单独SDK Fingerprint的文档。

这是因为SDK Fingerprint是针对不同的Sentry SDK的,不同的项目会使用不同的Sentry SDK。每个SDK中设置Fingerprint的方式都不一样,甚至可能部分SDK不支持设置Fingerprint。所以要针对不同的平台,查看各自平台的SDK文档,这里以JavaScript Sentry SDK为例SDK Fingerprinting for Browser JavaScript

更多平台请看这里:Platforms

官网文档提供了比较友好的三个例子:

5.1 基础示例

单独处理某个上报Event的Fingerprint

1
2
3
4
5
6
7
8
9
function makeRequest(method, path, options) {
return fetch(method, path, options).catch(function(err) {
Sentry.withScope(function(scope) {
// group errors together based on their request and response
scope.setFingerprint([method, path, String(err.statusCode)]);
Sentry.captureException(err);
});
});
}

我们可以使用变量替换,把一些Fingerprint Rules支持的变量填入,作为我们设置的Fingerprint的一部分,比如{{ default }}{{ stack.abs_path }}{{ error.type }}等,详见上面提到的Fingerprint Rules变量。

5.2 更细粒度地控制分组

在原有的Fingerprint后拼接上自定义的一些字段,可以达到比默认的规则更细粒度的控制。

比如下面例子,进一步拆分Sentry创建的默认分组(由{{ default }}表示),同时考虑错误对象的一些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MyRPCError extends Error {
constructor(message, functionName, errorCode) {
super(message);

// The name of the RPC function that was called (e.g. "getAllBlogArticles")
this.functionName = functionName;

// For example a HTTP status code returned by the server.
this.errorCode = errorCode;
}
}

Sentry.init({
// ...
beforeSend: function (event, hint) {
const exception = hint.originalException;

if (exception instanceof MyRPCError) {
event.fingerprint = [
"{{ default }}",
String(exception.functionName),
String(exception.errorCode),
];
}

return event;
},
});

5.3 完全重写Fingerprint

还可以直接整个重写Fingerprint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DatabaseConnectionError extends Error {}

Sentry.init({
// ...
beforeSend: function (event, hint) {
const exception = hint.originalException;

if (exception instanceof DatabaseConnectionError) {
event.fingerprint = ["database-connection-error"];
}

return event;
},
});

5.4 和Fingerprint Rules怎么划分职责?

Fingerprint Rules和SDK Fingerprint都可以实现相同的功能,那么我们在设置自定义Fingerprint的时候,要怎么取舍,或者说什么时候用Fingerprint Rules,什么时候用SDK Fingerprint?

Fingerprint Rules:

  • 优势:
    • 可以随时修改规则,不需要进行代码的变更
    • 可以同时在线上所有版本生效
  • 劣势:
    • 没有SDK Fingerprint灵活,有些处理不了,比如error.type不支持通配符匹配

SDK Fingerprint:

  • 优势:
    • 灵活,可以用JS很方便地处理或者自定义Fingerprint
  • 劣势:
    • 需要修改代码
    • 分组规则和代码版本耦合,如果应用需要用户手动升级的话,那么旧版本应用内的Sentry上报没办法处理

从上面的优劣对比来看,可以看到Fingerprint Rules和SDK Fingerprint是优劣互补的,一方的优势恰好是另一方的劣势

对比下来,我们在项目中使用的时候,建议如果可以使用Fingerprint Rules实现的,都用Fingerprint Rules,只有在Fingerprint Rules无法满足的情况下,再用考虑使用SDK Fingerprint

6. Filter

上面说了这么多关于Issue分组的,那么对于一些我完全不想要的上报,有没有办法完全不分组,直接过滤掉呢?

也是有的,可以在Sentry平台上,直接设置一些Filter过滤器来过滤,而不需要我们手动在使用SDK的地方修改。

具体设置位置为:Settings > Project > [Your Project] > Processing > Inbound Filters

过滤器分为内置的一些过滤器,和自定义的过滤器。

6.1 内置过滤器

Sentry平台内置了一些可以直接启用的过滤器,这些过滤器包括:

  • 浏览器拓展插件的error
  • 来自于localhost的event
  • 已知的旧版浏览器错误,比如IE的
  • 已知的网络爬虫错误
  • React hydrate的报错(和React服务端渲染有关的错误)
  • ……

这些过滤器可能和不同版本的Sentry有关,比较旧的版本中,可能会缺少一些过滤器。

内置过滤器

6.2 自定义过滤器

可以创建自定义过滤器,目前支持以下三种,以下三种在匹配时,都是大小写不敏感的。

自定义过滤器

a. 特定IP地址

可以设置IP地址,过滤特定IP的错误上报,比如127.0.0.1

b. 特定发布版本
  • 发布版本,指的是在Sentry.init的时候,传入的release字段。
  • 可以使用通配符,比如production-v3.24.*
  • 如果某个Event不包含release字段,那么这个Event不会被过滤
1
2
3
4
5
Sentry.init({
dsn: 'xxx',
release: `${env}-${version}`,
...
});

如果不确定自己项目上报后最终的release字段是什么,可以直接查看任意一个Error Event的原始JSON数据中的release字段(前提是init时传入了这个字段或者Event数据中有这个字段)

c. Error Message
  • 可以设置多个匹配项,每行一个。只要任意一个匹配项匹配成功,那么就会过滤这一条上报
  • 对于Error类型的Event,会根据设置的匹配项,对格式为{exception.type}: {exception.value}的整个错误描述进行匹配。
    但是不建议直接匹配整个描述,比如把冒号也加在里面,一般都是通过通配符来进行匹配。比如*ConnectionError*
  • Transaction类型的Event,不会被过滤

在设置完之后,可以检查下Issue的原始JSON数据,设置的过滤器会根据JSON里的title字段进行匹配,可以检查下是否有问题。

在设置好过滤器之后,我们就可以看到有多少Event被过滤掉了:

过滤掉的Event

五、治理

到这里我们已经搞明白Sentry对Issue分组的原理了,也知道了怎么自定义分组。那我们是使用Stack Trace Rules还是Fingerprint Rules来处理呢?

使用Stack Trace Rules本质上还是根据调用栈来进行分组,但是这就需要我们必须上传Source Maps。

在上传了Source Maps的情况下,可以通过设置调用栈Stack Trace Rules来裁切调用栈,或者限制Sentry在生成调用栈Fingerprint的时候需要考虑的top帧数量

考虑到目前我们的项目因为某种原因,还不能上传Source Maps,同时代码每个版本变化可能会导致同样的问题的调用堆栈信息不同。基于我们的需求来看,完全自定义的Fingerprint Rules更符合我们的情况。

所以我们的项目会做如下处理:

  • 在Sentry平台上设置Fingerprint Rules,处理绝大部分可以处理的Error
  • 少部分Fingerprint Rules无法处理的Error(比如error.type不支持通配符),通过SDK Fingerprint,在代码中Sentry.init的时候,增加beforeSend进行处理
  • 一些不需要关注的Error,设置Inbound Filters直接过滤

六、小结

一番调研下来,通过Fingerprint Rules,Stack Trace Rules,SDK Fingerprint,以及Inbound Filters,我们把项目的Issue进行了自定义分组,更方便我们排查问题,分析处理。

Sentry是一个简单易上手的监控平台,但是Sentry上也有许多十分复杂的配置项,这篇文章只是Sentry文档的一小部分,有哪里不正确的,还请多多指正。

所谓字符编码(Character encoding),就是把字符集中的字符,以某种指定的格式或规则,映射到另外一个集合中某一个值(相当于是把字符从一种形式转换成另一种形式),以便字符在计算机中存储或通过网络传递。

从计算机诞生到现在,对于字符的编码经历了多个阶段的变化,让我们一起来了解下字符编码的发展简史。

一、为什么聊这个

为什么会想要聊这个呢?这还得从开发中遇到的一个bug说起。

在一次开发中,由于要对一些字符串中的字符做一些高亮和截断,但是最开始只考虑了英文字符,单个字符的长度都是1,但是后续由于部分中文、emoji的加入,导致长度判断出了问题,出现了一些乱码。我们发现emoji的长度大多数为2甚至更长,比如“😂”:

1
'😂'.length; // 2

这激起了我的一些好奇心,于是乎从头开始,完整了解了一下字符编码的发展简史。

二、从二进制到各国编码

1. 二进制

我们都知道,由于电子管只有这两种状态,所以计算机采用的是二进制来存储数据,也就是说所有的数据,最终都是以二进制的形式被计算机存储起来的。例如,a、b、c、d这样的52个字母(包括大写)以及0、1等数字还有一些常用的符号(例如*、#、@等)在计算机中存储时也要使用二进制数来表示。

采用二进制可以更方便计算机进行运算,但是对于使用计算机的人类来说,就不那么方便了。为了方便人可以看懂计算机的二进制数据,需要设计一套规则来说明用哪些二进制数字表示哪个符号,这就是编码

2. ASCII

为了防止不同的计算机使用不同的编码规则造成混乱,美国有关的标准化组织就推出了ASCII编码(American Standard Code for Information Interchange,美国信息交换标准代码)

具体来说,ASCII编码一共规定了128个字符的编码,包括大小写英文字母、数字、常用符号,以及32个无法打印出来的控制符号。128个字符分别用数字0 ~ 127(十进制)来表示,对应的二进制为0000 0000 ~ 0111 1111。比如a对应是97(十进制),相应的二进制为0110 0001

这样的话,任意英文都可以在计算机中用二进制表示了,比如“Hello world”这句话,在计算机中的表示就是下面这样:

1
01001000 01100101 01101100 01101100 01101111 00100000 01110111 01101111 01110010 01101100 01100100

按理说128个字符,用7位二进制表示就够了(2^7=128),为什么要用8位二进制来表示呢。因为计算机里数据的计量单位是字节(byte),一个字节是8比特(bit),也就是8位,所以使用8位二进制(一个字节)来表示这128个字符,最前面的一位统一规定为0。

该编码的字符集如下所示:

x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF
0x NUL SOH STX ETX EOT ENQ ACK BEL BS HT LF VT FF CR SO SI
1x DLE DC1 DC2 DC3 DC4 NAK SYN ETB CAN EM SUB ESC FS GS RS US
2x SP ! # $ % & ( ) * + , - . /
3x 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
4x @ A B C D E F G H I J K L M N O
5x P Q R S T U V W X Y Z [ \ ] ^ _
6x ` a b c d e f g h i j k l m n o
7x p q r s t u v w x y z { | } ~ DEL

在上表中,0x20是空格。0x00 ~ 0x1F0x7F表示不同的控制字符

3. 编码的“战国时期”

ASCII码是美国人发明的,对于只用英文的美国人来说,ASCII码其实就够用了,但是在计算机传到欧洲时情况就有变化了。虽然很多欧洲国家的语言中也是包含26个英文字母,但是很多国家还会包含一些带有声调或者其他附加符号的字母或者其他字母,如ÀÖ等。对于这些字母,ASCII码无法表示。

3.1 Latin-1

在这种背景下,欧洲推出了自己的一种编码,Latin-1编码(又叫做ISO/IEC 8859-1)。这个编码以ASCII为基础,使用了ASCII码中没有用到的第一位,在空置的0xA0 ~ 0xFF(即十进制的160 ~ 255,二进制的1010 0000 ~ 1111 1111)的范围内,加入96个字母及符号,以供使用附加符号的拉丁字母语言使用。

该编码的字符集如下所示:

x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF
0x
1x
2x SP ! # $ % & ( ) * + , - . /
3x 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
4x @ A B C D E F G H I J K L M N O
5x P Q R S T U V W X Y Z [ \ ] ^ _
6x ` a b c d e f g h i j k l m n o
7x p q r s t u v w x y z { | } ~
8x
9x
Ax NBSP ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ SHY ® ¯
Bx ° ± ² ³ ´ µ · ¸ ¹ º » ¼ ½ ¾ ¿
Cx À Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï
Dx Ð Ñ Ò Ó Ô Õ Ö × Ø Ù Ú Û Ü Ý Þ ß
Ex à á â ã ä å æ ç è é ê ë ì í î ï
Fx ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ

在上表中,0x20(32)是空格、0xA0(160)是不换行空格、0xAD是选择性连接号。0x00 ~ 0x1F0x7F0x80 ~ 0x9F在此字符集中没有定义(控制字符是由ISO/IEC 6429定义)。

3.2 其他欧洲字符集

可是只有Latin-1还是不够,上面这个字符集只能用来表示西欧的字符,对于中欧、北欧,以及包括俄文在内的斯拉夫语族的字符都不包含在内,于是乎各个地区的国家,又陆续以同样的方式,使用ASCII编码中没有使用到的第一位进行拓展,产生了Latin-2Latin-3Latin-4等等欧洲地区的其他Latin编码,这些编码后来统一定义在了ISO/IEC 8859标准里,并且分别命名为ISO/IEC 8859-1、ISO/IEC 8859-2等。

这些编码虽然很好地支持了不同的国家的语言,但是在不同国家之间的兼容性上,可以说是几乎没有。由于它们都是基于ASCII进行拓展,占用了ASCII未使用到的第一位,所以对于0x7F(十进制0111 1111)之后的字符,同样的二进制数,在不同国家地区代表了不同的字符。某个国家的文件在另一个国家打开,一般情况下看到的都是乱码。

3.3 亚洲地区的编码

同时随着计算机在亚洲的流行,像中文、日文、韩文这种动则几千上万字符的语言,都基于ASCII增加或者自创了自己的编码,比如中文的GB2312编码,日文的Shift_JIS等。

由于越来越多的编码出现,电脑上一份文件要想展示正确的内容,需要电脑包含所有这些不同的字符集,并且采用正确的方式打开,否则看到的将会是乱码。这也就是乱码产生的本质,即使用了错误的编码方式/字符集来展示某个文件。

为了解决这个问题,Unicode编码出现了。

三、Unicode:万国码

Unicode,又叫做万国码,其官方机构Unicode联盟整理、编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字。目前最新的版本为2022年9月公布的15.0.0,已经收录超过14万个字符。

1. 码位

Unicode编码从0开始,为每一个字符分配一个唯一的编号,这个编号就叫做码位(code point,也叫码点),以“计算机”的“机”字举例,“机”的码位为26426(十进制),即Unicode字符集中第26426个字符,就是“机”。

但是在表示一个Unicode的字符时,通常会用U+然后紧接着一组十六进制的数字来表示这一个字符。也就是说,“机”在Unicode中的表示方式是U+673A,码位是673A

Unicode为了和已有的编码方式相互兼容,其首256个字符保留给ISO/IEC 8859-1所定义的字符(Latin-1编码),使既有的西欧语系文字(包括英文)的转换不需特别考量;并且把大量相同的字符重复编到不同的字符码中去,使得旧有纷杂的编码方式得以和Unicode编码间互相直接转换,而不会丢失任何信息。

2. 平面

对于不同国家数十万的字符,Unicode联盟显然不可能一下子就给出全部字符的码位定义,更何况现在的有些字符,在那个时候还没有出现。Unicode联盟把所有字符,分为了17组进行编排,每组称为平面(Plane),而每平面拥有65536(即216)个代码位。

也就是说目前一共有17个平面,共计2^16 * 17(1114112)个码位,最多可以表示一百多万个字符。

平面 始末字符值 名称 简称
0号平面 U+0000 ~ U+FFFF 基本多文种平面 BMP
1号平面 U+10000 ~ U+1FFFF 多文种补充平面 SMP
2号平面 U+20000 ~ U+2FFFF 表意文字补充平面 SIP
3号平面 U+30000 ~ U+3FFFF 表意文字第三平面 TIP
4-13号平面 U+40000 ~ U+DFFFF (尚未使用)
14号平面 U+E0000 ~ U+EFFFF 特别用途补充平面 SSP
15号平面 U+F0000 ~ U+FFFFF 保留作为私人使用区(A区) PUA-A
16号平面 U+100000 ~ U+10FFFF 保留作为私人使用区(B区) PUA-B

其中我们常用的一些字符,都定义在了第一个平面里,即基本多文种平面(Basic Multilingual Plane, BMP),或称基本平面0号平面(Plane 0)。比如大部分的汉字,都是定义在U+4E00 ~ U+9FFF代表的中日韩统一表意文字内(我们上面所说的“机”,U+673A,也在里面)。

而除了基本平台其他的十六个平面,都叫做辅助平面,用来放一些不常用的字符(如扑克牌花色),或者已经被废弃的古文字(如甲骨文)等。还有一些用作私人使用区,主要是指没有在Unicode标准中指定,而是由合作用户之间的私人协议决定其用途的编码区。

现在看起来只需要使用Unicode编码,就可以囊括所有的语言符号,再也不会有乱码的问题了,真美好。

3. 好像还有些事情要做

可是事情真的像预期的那边美好吗?

Unicode定义了字符的编码方式,但是没有定义这些编码的实现方式。就是说Unicode编码虽然给每一个字符一个唯一的编码,解决了字符集不统一的情况,但是没有定义在计算机底层,要怎么存储或者传输每一个Unicode字符。

例如ASCII定义了用一个字节(8位二进制)来存储每个字符,GB2312是用两个字节来存储每个汉字,Unicode的每一个字符应该用多大的空间来存储呢?这就涉及到Unicode编码的实现方式了,或者称为Unicode转换格式。

四、UTF

Unicode编码的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)。由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对Unicode编码的实现方式也有所不同,下面我们说几个经常听到的转换格式。

0. 码元

在开始之前,先引入一个概念,码元(Code Unit),是指一种编码转换格式中具有最短的二进制组合的单元,即字符占用的最少二进制位。比如上面说的ASCII编码,每个字符都是占用8位二进制,所以ASCII编码的码元是8。

1. UTF-32

首先,最简单的就是,用一个能包含所有Unicode编码的空间,来存储每一个字符。Unicode目前一共有17个平面,共计216 * 17个码位。

2^16 * 17 = 2^20 + 2^16

2^20 < 2^20 + 2^16 < 2^21

只需要使用大于21位的二进制来存储就可以了,例如UTF-32编码,这个编码用32位(4个字节)的二进制来存储Unicode编码的字符,不算固定为0的首位数字,总计能表示 231个字符,够用了。

由于UTF-32可以表示的字符比Unicode所有字符还多,可以做到UTF-32的编码与字符的Unicode码位的数值完全一致。由于UTF-32种每个字符都是用用32位(4个字节)来表示,所以UTF-32的码元就是32

还是以上面的“机”为例,它的码位是U+673A(即十六进制0x673A),那么它在UTF-32中的编码值为0x0000673A(因为总共有32位,4个字节,所以要在前面补上两个字节的0,凑够4字节)

字符:
码位: U+673A
码位十六进制: 0x673A
码位二进制: 01100111 00111010
UTF-32编码: 0x0000 673A
UTF-32编码的二进制形式: 00000000 00000000 01100111 00111010

看起来UTF-32挺好的,可以通过Unicode码位直接知道对应的UTF-32编码,查找编码是一个常数时间的操作。但是它有个巨大的缺陷,就是占用空间太大。

在大多数文本中,非基本平面的字符非常罕见,绝大多数常见字符都位于基本平面里,一般只需要1-2个字节就可以表示,比如上面的“机”(01100111 00111010),只需要两个字节就可以表示,但是UTF-32中却占了4个字节。

对于纯英文的文本来说,这种情况更甚,比如字母a的码位为0110 0001,只需要一个字节,但是在UTF-32同样要用4个字节来表示,00000000 00000000 00000000 01100001

这也就造成UTF-32所需空间接近UTF-16的两倍和UTF-8的四倍,空间浪费较多。所以UTF-32编码,目前基本没有人使用,甚至在HTML5标准中明确规定在HTML中禁止使用UTF-32编码

2. UTF-16

2.1 变长编码

既然在大多数文本中,非基本平面的字符非常罕见,绝大多数常见字符都位于基本平面里,那么有没有一种更节省空间的实现方式?有,那就是UTF-16。(当然这个是从现在这个时间点来看的,UTF-16设计出来的目的并不是为了解决UTF-32的问题,还涉及到一个UCS-2的编码实现方式,下面会讲到)

UTF-16是一个变长的编码转换格式,也就是说相比UTF-32这种固定长度(4字节)的来说,它的编码长度取决于字符在Unicode中的码位,可能是2个字节,有可能是4个字节。

根据上面的Unicode平面对应的码位,可以看到基本平面内的字符U+0000 ~ U+FFFF,长度最长只有2个字节。所以对于基本平面的字符,在UTF-16中用2个字节来表示。对于其他辅助平面内的字符U+10000 ~ U+10FFFF,则用4个字节来表示。由于UTF-16中字符最少要占用2个字节(16位),所以UTF-16的码元是16,即UTF-16中字符要么用1个码元来表示,要么用2个码元来表示。

以上面“计算机”中的“机”为例,“机”的码位是U+673A,位于基本平面内,用两个字节表示,这个时候它的UTF-16编码和它在Unicode中的码位是一样的,即0x673A,换成二进制就是01100111 00111010

字符:
码位: U+673A
码位十六进制: 0x673A
码位二进制: 01100111 00111010
UTF-16 编码: 0x673A
UTF-16 编码的二进制形式: 01100111 00111010

这样在绝大多数情况下,用2个字节就可以存储或者表示字符,极少数情况下用到的辅助平面字符,才会用到4个字节的表示方式。

但是这样也会带来一个问题,计算机在读取字符的二进制时,比如读取了2个字节,要怎么知道这2个字节表示的是一个字符,还是说要再加上后面2个字节连起来组成一个字符呢?

2.2 前导代理和后尾代理

我们知道,需要用4个字节来表示的字符,都是位于16个辅助平面内的字符U+10000 ~ U+10FFFF,16辅助平面内的字符总用有220个:

2^16 * 16 = 2^20

这些辅助平面内的字符,相对于辅助平面内的第一个字符U+10000,偏移量分别是 0 ~ 2^20 - 1

0x10000 - 0x10000 = 0

0x10FFFF - 0x10000 = 2^20 - 1

只需要记录下每个辅助平面内字符相对于第一个辅助平面字符的偏移量,就可以知道每个字符在Unicode中的码位。

而220个数字只需要用20位长度的二进制就可以表示,20位长度的二进制拆分到4个字节里,每2个字节存储10位长度的二进制。

𐐷这个字符为例,这个字符的码位是U+10437

字符: 𐐷
码位: U+10437
相对于0x10000的偏移量: 0x10437 - 0x10000 = 0x00437
偏移量的二进制表示: 0000 0000 0100 0011 0111
前10位: 0000000001
后10位: 0000110111

也就是说𐐷这个字符占4个字节,前2个字节是00000000 00000001,后2个字节是00000000 00110111

但是到这里依然没有解决上面的问题,计算机在读取到前2个字节00000000 00000001时,不知道这表示的是SOH这个控制字符,还是说要和后面2个字节连起来,共同组成一个字符𐐷。因此,需要在基本平面中保留不对应任何Unicode字符的两个区域,用于标识UTF-16的4字节字符的前10位和后10位。这两个区域就是

0xD800 ~ 0xDBFF 容纳前10位的区域,区域大小为 210
0xDC00 ~ 0xDFFF 容纳后10位的区域,区域大小为 210

而只需要把上面的前10位00000000 00000001和后10位00000000 00110111,分别加上这两个区域的起始值,就是这个字符在UTF-16中真正的表示:

字符: 𐐷
码位: U+10437
相对于0x10000的偏移量: 0x10437 - 0x10000 = 0x00437
偏移量的二进制表示: 0000 0000 0100 0011 0111
前10位: 0000000001, 0x0001
后10位: 0000110111, 0x0037
**前10位 + **0xD800 0xD800 + 0x0001 = 0xD801, 11011000 00000001
**后10位 + **0xDC00 0xDC00 + 0x0037 = 0xDC37, 11011100 00110111
UTF-16 中真正的表示: 0xD801``0xDC3711011000 00000001``11011100 00110111

所以𐐷这个字符,在UTF-16中的真正表示为0xD801 0xDC37,以二进制形式就是11011000 00000001 11011100 00110111,前后各2个字节分别叫做这个字符的前导代理(lead surrogates)和后尾代理(trail surrogates),这个字符就是由这样一个代理对(Surrogate Pair)来表示。

2.3 计算机读取

计算机在读取时,每读取2个字节,如果这两个字节范围处于0xD800 ~ 0xDBFF这个区域,那么就知道这2个字节,要和后面的2个字节连起来,共同组成一个字符。

以上面为例,在读取到11011000 00000001时,发现这个数字位于0xD800 ~ 0xDBFF,那么就知道11011000 00000001表示这个字符的前导代理而不是一个真正字符,需要再读取后2个字节里的后尾代理,具体步骤为:

  1. 读取:11011000 00000001 11011100 00110111,即0xD801 0xDC37
  2. 分别减去0xD8000xDC000x0001 0x0037,即0000000001 0000110111
  3. 组合起来就是0000 0000 0100 0011 0111,即0x00437
  4. 也就是说字符相对于0x10000的偏移量是0x00437,或者换种说法,这个字符是辅助平面字符里的第0x00437个辅助平面字符
  5. 所以真正的码位是0x10000 + 0x00437 = 0x10437,即U+10437,所表示字符为𐐷

2.4 小结

到这里UTF-16基本是说完了,看起来很复杂,但总结下来就三点:

  1. 对于基本平面内的字符,用2个字节表示;对于其他辅助平面内的字符,用4个字节表示
  2. 对于辅助平面内的字符,拆成两半
    1. 一半映射在0xD800 ~ 0xDBFF
    2. 一半映射在0xDC00 ~ 0xDFFF
  3. 读取2个字节,如果发现这2个字节的码位处于0xD800 ~ 0xDBFF,那么就当做4字节字符处理,再读取后面的2个字节共同组成字符。否则就直接作为2字节字符

3. UTF-8

既然UTF-16可以通过对字符做区分,不同字符使用不同长度的字节来表示,那应该有更加节省空间的变长编码转换格式才对。而UTF-8就是这样一个变长的编码转换格式。

3.1 编码规则

UTF-8从名字就可以知道,这是一种最小长度为8位二进制(即1个字节)的编码转换格式。使用 1 ~ 4 个字节来表示字符(1、2、3、4个字节都有可能),具体的规则如下:

码位范围 字节数 Byte 1 Byte 2 Byte 3 Byte 4
U+0000 ~ U+007F 1 0xxxxxxx
U+0080 ~ U+07FF 2 110xxxxx 10xxxxxx
U+0800 ~ U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10 FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

对于一个字符,首先确定其在Unicode中的码位,根据上面表格第一列,确定这个码位所属的范围所需要用到的字节。

  1. 如果是单字节字符,那么这个字节首位为0,用剩下的7位来表示这个字符。可以看到,对于ASCII中的字符,在ASCII和UTF-8中的编码规则是一致的,也就是说UTF-8兼容ASCII编码
  2. 如果是多字节字符,假设是n字节字符,就在第一个字节开头用n1来填充,第n + 1位用0填充,且后面的字节的前两个字符,都用10来填充。剩下的没有被填充的位,就是用来填充这个字符Unicode码位的二进制,从右向左填充,未填充满的用0补齐

由于UTF-8中字符最少占据1个字节(8位),所以UTF-8的码元是8,即UTF-8中字符用 1 ~ 4 个码元来表示。
还是以上面的“计算机”的“机”为例:

  1. “机”的码位为U+673A
  2. 根据上面的表格,处于U+0800 ~ U+FFFF这个范围内,所以要用3个字节来表示
  3. 也就是说格式为1110xxxx 10xxxxxx 10xxxxxxx为这个字符码位二进制填充位
  4. U+673A的二进制为110011100111010,从右向左依次填充,得到:

11100110 10011100 10111010
即字符“机”在UTF-8中的编码为11100110 10011100 10111010,用16进制表示为0xE69CBA

字符
码位: U+673A
码位十六进制: 0x673A
码位二进制: 01100111 00111010
UTF-8 编码: 0xE69CBA
UTF-8 编码的二进制形式: 11100110 10011100 10111010

3.2 真的最节省空间吗?

UTF-8最小字节单位为 1 个字节,相比起UTF-16和UTF-32最小字节单位是 2 字节和 4 字节来说,确实是可以节省下不少空间。但是这个节省空间是相对的。

对于以拉丁字母作为主要语言的英语、西欧语言等来说,绝大多数情况下只需要 1 个字节或者 2 个字节就可以表示字符。对于这些语言来说,最节省空间的确实是UTF-8。但是对于很多亚洲国家来说,比如中日韩,却不是这样。

从上面的内容可以知道,对于中日韩这些文字来说,在UTF-16中,绝大多数文字都只需要 2 个字节就可以表示,但是在UTF-8中,却需要 3 个字节来表示,占用空间比UTF-16多出了 50%,这样看来,对于中日韩文字CJK文字来说,最节省空间的反而是UTF-16编码了。

也就是说,哪种编码转换格式最节省空间是由字符内容决定的,UTF-8只有在英语等拉丁语言的情况下才是最节省空间的,但是对于亚洲地区来说,最合适的编码方式是UTF-16。

4. Big Endian 和 Little Endian

其实除了上面说的内容,对于多字节的编码转换格式来说,还有一个 Big Endian(大端序,简称BE)Little Endian(小端序,简称LE)的概念。因为这些多字节的编码中字符占用多个字节,在将字符二进制拆分成多个字节后,多个字节可能会有排列顺序的区分。

比如上面的“机”,UTF-16编码中会把码位二进制01100111 00111010放到两个字节里:

字符:
码位: U+673A
码位十六进制: 0x673A
码位二进制: 01100111 00111010

那么在UTF-16 BE和UTF-16 LE中,编码分别为01100111 0011101000111010 01100111,两个字节的顺序正好相反。

UTF-16 BE 编码 01100111 00111010
UTF-16 LE 编码 00111010 01100111

我们上面举例的一些字符,都是以BE的形式来表示的。这个其实没有一个优劣,更多是一种人们情绪化的选择,就好像鸡蛋应该从大的那头拨开还是小的那头拨开一样。感兴趣的可以看看这个大小端问题的起源

五、JavaScript中使用的编码

说了这么多,在JavaScript中使用的是哪种编码呢?

1. 编码格式

其实JavaScript使用的是一个叫做UCS-2的编码,并不是上面的任何一种。UCS-2是一种定长的编码转换格式,用2个字节来表示字符,可以理解为是只能用来表示基本平面内字符的UTF-16,不能表示辅助平面内的字符。或者说UTF-16是基于UCS-2的超集

同样的,Java和 Objective-C都是使用的是UCS-2。这些语言之所以选择这个编码转换格式,是因为在Unicode早期,大家都以为用2个字节就足够表示所有字符了,所以UCS-2使用了2字节定长编码,且字符编码和字符的Unicode码位的一样的,获取字符的开销是一个常数操作,对于需要处理字符串的编程语言来说是最合适的选择。

但是到后期随着Unicode的扩充,2字节定长编码的UCS-2无法满足需求,也就诞生了基于UCS-2的UTF-16编码,在基本平面内兼容UCS-2,同时通过可变的长度来支持辅助平面内字符。这些编程语言也基本过渡到了UTF-16上。

但是一些稍微新一些的编程语言,比如Python3,Go等,默认的编码转换格式都已经是UTF-8了。

2. 获取字符串长度

既然知道了JavaScript中的编码转换格式,那我们来试下获取字符长度:

1
2
3
'a'.length;   // 1
'机'.length; // 1
'😂'.length; // 2

可以发现,同样是一个字符,“a”和“机”的长度都是“1”,但是一个笑哭的emoji“😂”,长度却是2。这是为什么呢?
因为获取字符串长度,本质上就是获取字符串在当前编码格式中占用的码元(Code Unit)数量。
在UTF-16(UCS-2)中,码元为16(2个字节):

  • “a”和“机”都是基本平面内的字符,都可以用 2 个字节,即 1 个UTF-16码元来表示,所以长度是1
  • “😂”这个emoji的Unicode码位是U+1F602,已经超出了基本平面U+0000 ~ U+FFFF,是辅助平面内的字符,要用 4 个字节,即 2 个UTF-16码元来表示,所以长度是2

Java和OC中执行结果也和上面一样。相应的,如果这些字符串放在默认编码方式为UTF-8的语言中,如Go,得到的长度就会是下面这样:

1
2
3
len("a")    // 1
len("机") // 3
len("😂") // 4

这是因为在UTF-8中,码元为8(1个字节):

  • “a”,U+0061,是用 1 个字节表示,1个码元,所以长度是1
  • “机”,U+673A,用 3 个字节表示,即 3 个UTF-8码元,所以长度是3
  • “😂”,U+1F602,用 4 个字节表示,即 4 个UTF-8码元,所以长度是4

所以,在一些前后端默认字符编码编码格式不一样的场景,比如前端(JS/Java/OC),后端Go,就可能会出现一些字符串长度判断不一致的情况(看到一篇文章里就有遇到这种情况)。

3. 获取字符串中真正的字符个数

说了这么多,有没有办法在JS中获取到真正的字符个数呢,比如“😂”。有的,通过Array.from(str)

1
Array.from('😂').length;  // 1

再来看一个特殊情况:

1
2
3
'👩‍👩‍👧‍👧'.length;                // 11
Array.from('👩‍👩‍👧‍👧').length; // 7
Array.from('👩‍👩‍👧‍👧'); // ['👩', '‍', '👩', '‍', '👧', '‍', '👧']

可以看到“👩‍👩‍👧‍👧”这个emoji,其实是由4个emoji组合到一起的,并且中间用了U+200D这个名字叫做“零宽度连字符”的字符来连接,这个字符的长度是1,所以这个emoji本质上是7个字符组合到一起的,总计7个字符,长度为:

2 + 1 + 2 + 1 + 2 + 1 + 2 = 11

我们也可以反向来试一下:

1
2
['👩', '‍', '👩', '‍', '👧', '‍', '👧'].join('');    // 👩‍👩‍👧‍👧 这里的 join 方法使用的是空字符串
['👩', '👩', '👧', '👧'].join('\u{200D}'); // 👩‍👩‍👧‍👧 这里的 join 方法使用的是 U+200D

4. 其他一些和字符编码有关的方法和逻辑

4.1 获取码位

在JS中,想要获取一个字符的Unicode码位很简单,只需要通过String.prototype.codePointAt()即可获取码位的十进制值codePointAt接受一个参数,可以用来获取指定码元处码位,不传则默认为0,即这个字符第一个码元处的码位的值。

1
2
3
4
5
'a'.codePointAt();    // 97 (十进制)
'机'.codePointAt(); // 26426
'😂'.codePointAt(); // 128514
'😂'.codePointAt(0); // 128514
'😂'.codePointAt(1); // 56834

如果指定的这个码元位置的码位是UTF-16代理对的前导代理,那么就会返回这个字符完整的Unicode码位,否则就直接返回这个位置的码位,详见MDN

如上面'😂'.codePointAt(0),因为“😂”这个字符在UTF-16中是用2个码元(4个字节)来表示的,根据规则组成这个字符的 2个码元是一个代理对,它的第一个码元是前导代理,所以直接返回了“😂”这个字符的完整Unicode码位128514,即U+1F602

但是'😂'.codePointAt(1),因为索引为1的码元,是一个后尾代理,所以直接返回了这个后尾代理的码位56834,即U+DE02。而“😂”的在UTF-16中的完整编码格式为U+D83D U+DE02

字符: 😂
码位: U+1F602
前导代理: U+DE02
后尾代理: U+DE02
UTF-16 BE 中的表示: U+D83D U+DE02

4.2 用码位表示字符

JS中是允许用码位来表示字符的,表示方式是\u{xxxx},即反斜杠 + u + 花括号,花括号内为16进制的码位。又由于JS使用的是UTF-16,所以在JS中可以用下面的方式来表示“😂”:

1
2
3
'😂' === '\u{1F602}';                 // true Unicode码位表示
'😂' === '\u{D83D}\u{DE02}'; // true UTF-16编码格式表示
'\u{1F602}' === '\u{D83D}\u{DE02}'; // true

4.3 直接查看文件中字符的二进制

说了这么多,都是在讲解,那有什么办法直接查看某个字符或者字符串的二进制编码呢?最简单的我们可以通过一些文本编辑器来看,比如sublime。还是以“机”和“𐐷”为例。

在sublime中输入一个字符,然后以某种编码格式保存,我们这里分别用UTF-8,UTF-16 BE,UTF-16 LE来保存:

save_by_sublime.png

然后打开终端,通过xbb -b命令分别查看三个文件的二进制内容:

show_binary.png

可以看到二进制内容,和上面讲到的是完全一致的。

六、小结

到这里基本上已经说完了,字符编码从二进制,到ASCII问世,到Latin-1、2、3…,再到GB2312、Shift_JIS的“战国时代”,直到最后Unicode万国码“一统天下”。而随Unicode诞生的,还有UTF-32、UTF-16、UTF-8、UCS-2等。

可以看到一个小的字符编码引起的Bug,后面可以挖出这么多,有时候日常开发中不那么起眼的一些点,后面也有很多有趣的内容。

参考链接

感谢下面这些参考链接的作者,看这些文章收获很多!

什么是深色模式

深色模式(Dark Mode),或者叫暗色模式,黑夜模式,是和日常使用的浅色(亮色)模式(Light Mode)相对应的一种UI主题。

深色模式最早来源于人机交互领域的研究和实践,从2018年左右开始,Apple推出了iOS 13,其中包含了系统级别的深色模式,可以将整个系统的界面切换为暗色调。

Google也在Android 10中推出了类似的深色模式功能,使深色模式得到了更广泛的应用和推广。

iOS官网的深色模式示例

它不是简单的把背景变为黑色,文字变为白色,而是一整套的配色主题,这种模式相比浅色模式更加柔和,可以减少亮度对用户眼睛造成的刺激和疲劳。

随着越来越多的应用开始支持深色模式,作为开发也理应多了解下深色模式。

首先,怎么打开深色模式

在说怎么实现之前,先来说说我们要怎么打开深色模式,一般来说只需要在系统调节亮度的地方就可以调节深色模式,具体我们可以看各个系统的官方网站即可:
如何打开深色模式

但是在开发调试调试时,不断切换深色模式可能比较麻烦,这时浏览器就提供了一种模拟系统深色模式的方法,可以让当前的Web页面临时变为深色模式,以Chrome为例:
浏览器模拟深色/浅色模式

  1. 打开Chrome DevTools
  2. Command+Shift+P
  3. 输入dark或者light
  4. 打开深色或者浅色模式打开深色模式
    打开浅色模式

不过要注意的是,浏览器DevTools里开启深色模式,在关闭开发者工具后就会失效。

自动适配 - 声明页面支持深色模式

其实,在支持深色模式的浏览器中,有一套默认的深色模式,只需要我们在应用中声明,即可自动适配深色模式,声明有两种方式:

1. 添加color-schememeta标签

在HTML的head标签中增加color-schememeta标签,如下所示:

1
2
3
4
5
<!--
The page supports both dark and light color schemes,
and the page author prefers light.
-->
<meta name="color-scheme" content="light dark">

通过上述声明,告诉浏览器这个页面支持深色模式和浅色模式,并且页面更倾向于浅色模式。在声明了这个之后,当系统切换到深色模式时,浏览器将会把我们的页面自动切换到默认的深色模式配色,如下所示:

左边浅色,右边是浏览器自动适配的深色

2. 在CSS里添加color-scheme属性

1
2
3
4
5
6
7
/*
The page supports both dark and light color schemes,
and the page author prefers light.
*/
:root {
color-scheme: light dark;
}

通过上面在:root元素上添加color-scheme属性,值为light dark,可以实现和meta标签一样的效果,同时这个属性不只可用于:root级别,也可用于单个元素级别,比meta标签更灵活。

但是提供color-schemeCSS属性需要首先下载CSS(如果通过<link rel="stylesheet">引用)并进行解析,使用meta可以更快地使用所需配色方案呈现页面背景。两者各有优劣吧。

自定义适配

1. 自动适配的问题

在上面说了我们可以通过一些标签或者CSS属性声明,来自动适配深色模式,但是从自动适配的结果来看,适配的并不理想:

左边浅色,右边是浏览器自动适配的深色

  • 首先是默认的黑色字体,到深色模式下变成了纯白色#FFFFFF,和黑色背景(虽然说不是纯黑)对比起来很扎眼,在一些设计相关的文章[1][2]里提到,深色模式下避免使用纯黑和纯白,否则更容易使人眼睛👁疲劳,同时容易在页面滚动时出现拖影:

    滚动时出现拖影,图片来源「即刻」

  • 自动适配只能适配没有指定颜色和背景色的内容,比如上面的1、2、3级文字还有背景,没有显式设置colorbackground-color

    对于设置了颜色和背景色(这种现象在开发中很常见吧)的内容,就无法自动适配,比如上面的7个色块的背景色,写死了颜色,但是色块上的文字没有设置颜色。最终在深色渲染下渲染出的效果就是,色块背景色没变,但是色块上的文字变成了白色,导致一些文字很难看清。

所以,最好还是自定义适配逻辑,除了解决上面的问题,还可以加一下其他的东西,比如加一些深浅色模式变化时的过渡动画等。

2. 如何自定义适配

自定义适配有两种方式,CSS媒体查询和通过JS监听主题模式

1). CSS媒体查询

prefers-color-scheme - CSS:层叠样式表 | MDN
我们可以通过在CSS中设置媒体查询@media (prefers-color-scheme: dark),来设置深色模式下的自定义颜色。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.textLevel1 {
color: #404040;
margin-bottom: 0;
}
.textLevel2 {
color: #808080;
margin-bottom: 0;
}
.textLevel3 {
color: #bfbfbf;
margin-bottom: 0;
}

@media (prefers-color-scheme: dark) {
.textLevel1 {
color: #FFFFFF;
opacity: 0.9;
}
.textLevel2 {
color: #FFFFFF;
opacity: 0.6;
}
.textLevel3 {
color: #FFFFFF;
opacity: 0.3;
}
}

通过媒体查询设置元素在深色模式下的1、2、3级文字的颜色,在浅色模式下设置不同的颜色,在深色模式下,增加不透明度:

左边的是自动适配的浅色深色,右边是自定义适配的浅色深色

对于prefers-color-scheme的兼容性也不必担心,主流浏览器基本都支持了:

prefers-color-scheme

2). JS监听主题颜色

Window.matchMedia() - Web API 接口参考 | MDN

通过CSS媒体查询适配深色模式已经很方便了,完全不需要修改JS代码逻辑,那为什么还需要JS监听主题颜色呢?

因为通过CSS媒体查询虽然方便,但是只能跟随系统的主题颜色变化,假设用户想要类似于下面的自定义主题颜色,不跟随系统变化,或者业务上想做一些什么高级的操作,那么CSS媒体查询就无能为力了。

custom_theme.gif

在JS中,可以通过window.matchMedia('(prefers-color-scheme: dark)').match来判断当前的颜色主题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

if (darkModeMediaQuery.matches) {
// dark mode
} else {
// light mode
}

darkModeMediaQuery.addEventListener('change', (event: MediaQueryListEvent) => {
if (event.matches) {
// dark mode
} else {
// light mode
}
});

如果使用的是React,还可以写一个自定义hooks,来监听当前的颜色主题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export enum EColorScheme {
LIGHT = 'light',
DARK = 'dark',
}

export const useColorScheme = (): EColorScheme => {
const [scheme, setScheme] = useState(
darkModeMediaQuery.matches ? EColorScheme.DARK : EColorScheme.LIGHT
);

useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setScheme(event.matches ? EColorScheme.DARK : EColorScheme.LIGHT);
};
darkModeMediaQuery.addEventListener('change', listener);
return () => {
darkModeMediaQuery.removeEventListener('change', listener);
};
}, []);

return scheme;
};

一些题外话

我写完这个hooks之后,有一次偶然用Chat GPT让它写一段监听深色模式的代码,写成hooks,最后生成的代码和这个几乎一模一样,Chat GPT还是有点东西的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useState, useEffect } from 'react';

type ThemeType = 'light' | 'dark';

function useTheme(): ThemeType {
const [theme, setTheme] = useState<ThemeType>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
);

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

const handleChange = () => setTheme(mediaQuery.matches ? 'dark' : 'light');

mediaQuery.addEventListener('change', handleChange);

return () => mediaQuery.removeEventListener('change', handleChange);
}, []);

return theme;
}

export default useTheme;

window.matchMedia的兼容性也挺好的:

window.matchMedia

通过JS监听颜色主题变化之后,那可玩性就很多了,我们可以通过下面这些方式来适配深色模式:

  • 动态添加类名覆盖样式

    通过判断深色模式来添加一个深色模式的类名,覆盖浅色模式样式:

    1
    2
    3
    4
    5
    6
    7
    8
    <div
    className={classnames(
    style.wrapper,
    scheme === EColorScheme.DARK && style.darkModeWrapper
    )}
    >
    {/* some code here */}
    </div>
  • 对于深色模式直接引用不同的CSS资源文件

  • 用一些第三方的库,比如postcss-darkmode

回到上面话题,通过JS可以监听到系统的颜色主题,那怎么实现用户主动选择颜色主题,不随系统的改变呢?其实也很简单,可以在本地store中设置一个颜色主题的值,用户设置了就优先选用store里的,没有设置就跟随系统,以上面的hooks为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export const useColorScheme = (): EColorScheme => {
// 从 store 中取出用户手动设置的主题
const manualScheme = useSelector(selectManualColorScheme);
const [scheme, setScheme] = useState(
darkModeMediaQuery.matches ? EColorScheme.DARK : EColorScheme.LIGHT
);

useEffect(() => {
const listener = (event: MediaQueryListEvent) => {
setScheme(event.matches ? EColorScheme.DARK : EColorScheme.LIGHT);
};
darkModeMediaQuery.addEventListener('change', listener);
return () => {
darkModeMediaQuery.removeEventListener('change', listener);
};
}, []);

// 优先取用户手动设置的主题
return manualScheme || scheme;
};

React Native中的适配

上面说的都是在浏览器里对深色模式的适配,那在React Native里面要怎么适配深色模式呢?

1. 大于等于0.62的版本

Appearance · React Native

在React Native 0.62版本中,引入了Appearance模块,通过这个模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type ColorSchemeName = 'light' | 'dark' | null | undefined;

export namespace Appearance {
type AppearancePreferences = {
colorScheme: ColorSchemeName;
};

type AppearanceListener = (preferences: AppearancePreferences) => void;

/**
* Note: Although color scheme is available immediately, it may change at any
* time. Any rendering logic or styles that depend on this should try to call
* this function on every render, rather than caching the value (for example,
* using inline styles rather than setting a value in a `StyleSheet`).
*
* Example: `const colorScheme = Appearance.getColorScheme();`
*/
export function getColorScheme(): ColorSchemeName;

/**
* Add an event handler that is fired when appearance preferences change.
*/
export function addChangeListener(listener: AppearanceListener): EventSubscription;

/**
* Remove an event handler.
*/
export function removeChangeListener(listener: AppearanceListener): EventSubscription;
}

/**
* A new useColorScheme hook is provided as the preferred way of accessing
* the user's preferred color scheme (aka Dark Mode).
*/
export function useColorScheme(): ColorSchemeName;

通过Appearance模块,可以获得当前的系统颜色主题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const colorScheme = Appearance.getColorScheme();
if (colorScheme === 'dark') {
// dark mode
} else {
// light mode
}

Appearance.addChangeListener((prefer: Appearance.AppearancePreferences) => {
if (prefer.colorScheme === 'dark') {
// dark mode
} else {
// light mode
}
});

同时也提供了一个上面我们自己实现的hooks,useColorScheme

1
const colorScheme = useColorScheme();

一些坑

  1. Appearance这个接口在Chrome调试模式下,会不生效,永远返回light

    Appearance.getColorScheme() always returns ‘light’

  2. Appearance想要生效,还需要Native做一些配置

    React Native 0.62.2 Appearance return wrong color scheme

    Also make sure you do not have UIUserInterfaceStyle set in your Info.plist. I had it set to ‘light’ so Appearance.getColorScheme() was always returning ‘light’.

2. 小于0.62的版本

对于0.62之前的版本,由于RN没有提供官方接口,需要通过第三方的库react-native-dark-mode来实现:
GitHub - codemotionapps/react-native-dark-mode: Detect dark mode in React Native

它的实现原理感兴趣的可以看下:

react-native-dark-mode 实现原理(这段实现原理其实也是问Chat GPT得到的答案😂)

react-native-dark-mode库的实现原理比较简单,它主要是利用了原生平台的接口来检测当前系统是否处于深色模式。在iOS平台上,它使用了UIUserInterfaceStyle接口来获取当前系统的界面风格,然后判断是否为暗黑模式。在Android平台上,它使用了UiModeManager接口来获取当前系统的 UI 模式,然后判断是否为夜间模式。

具体来说,react-native-dark-mode在React Native项目中提供了一个名为useDarkMode的 React Hooks,用于获取当前系统是否处于深色模式。当使用这个Hooks时,它会首先检测当前平台是否支持暗黑模式,如果支持,就直接调用原生平台的接口获取当前系统的界面风格或UI模式,并将结果返回给调用方。如果不支持,就返回一个默认值(比如浅色模式)。

需要注意的是,由于react-native-dark-mode是一个纯JS库,它无法直接调用原生平台的接口。所以它在Native端编写了一个名为DarkMode的模块,在JS层通过NativeModules.DarkMode来调用。

  • 在iOS上,DarkMode模块会通过RCT_EXPORT_MODULE()宏将自己暴露给RN的JS层。同时,它还会使用RCT_EXPORT_METHOD()宏将检测系统界面风格的方法暴露给JS层,使得在JS中可以直接调用该方法。
  • 在Android上,DarkMode模块同样会通过@ReactModule注解将自己暴露给JS层。然后,它会创建一个名为DarkModeModule的Java类,并在该类中实现检测系统UI模式的方法。最后,它会使用@ReactMethod注解将该方法暴露给JS层,使得在JS中可以直接调用该方法。

参考链接

本文首次发布于掘金,转载请注明来源。

一、mock模块

在Jest中,对模块进行mock非常简单,只需要使用jest.mock即可,对于模块的mock主要有两种情况:

  1. 只mock模块中的非default导出

    对于只有非default导出的情况(如export constexport class等),只需要使用jest.mock,返回一个对象即可,对象中包含有你想要mock的函数或者变量:

    1
    2
    3
    4
    // mock 'moduleName' 中的 foo 函数
    jest.mock('../moduleName', () => ({
    foo: jest.fn().mockReturnValue('mockValue'),
    }));
  2. mock模块中的default导出

    对于default导出的mock,则不能返回一个简单的对象,而是需要在对象中包含一个default属性,同时添加__esModule: true

    When using the factory parameter for an ES6 module with a default export, the __esModule: true property needs to be specified. This property is normally generated by Babel / TypeScript, but here it needs to be set manually. When importing a default export, it’s an instruction to import the property named default from the export object

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import moduleName, { foo } from '../moduleName';

    jest.mock('../moduleName', () => {
    return {
    __esModule: true,
    default: jest.fn(() => 42),
    foo: jest.fn(() => 43),
    };
    });

    moduleName(); // Will return 42
    foo(); // Will return 43

二、mock模块部分内容

如果只想mock模块中的部分内容,对于其他部分保持原样,可以使用jest.requireActual来引入真实的模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { getRandom } from '../myModule';

jest.mock('../myModule', () => {
// Require the original module to not be mocked...
const originalModule = jest.requireActual('../myModule');

return {
__esModule: true, // Use it when dealing with esModules
...originalModule,
getRandom: jest.fn().mockReturnValue(10),
};
});

getRandom(); // Always returns 10

三、mock模块内部函数

设想一种情况,有一个utils.ts文件,内部导出了两个函数funcAfuncB,然后在funcB中引用了funcA:

1
2
3
4
5
6
7
8
9
// utils.ts
export const funcA = () => {
// ...
}

export const funcB = () => {
funcA();
// ...
}

这个时候在对funcB进行单元测试时,如果想要对funcA进行mock,会发现mock失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { funcA, funcB } from '../src/utils';

jest.mock('../src/utils', () => {
const originalModule = jest.requireActual('../src/utils');
return {
...originalModule,
funcA: jest.fn(),
};
});

describe('utils.ts 单元测试', () => {
test('测试 funcB', () => {
funcB();
expect(funcA).toBeCalled();
});
});

运行单测会得到一个报错

jest单测报错

很明显,我们对funcA的mock失败了,为什么会有这样的结果呢,因为我们从模块外部导入的funcA引用和模块内部直接使用的funcA引用并不是同一个,通过jest.mock修改funcA并不会影响内部的调用。对于这种情况,建议的解决方法有两种:

  • 拆分文件,将funcA拆分到不同的文件。这种做法可能会造成文件过多且分散的问题。
  • 将相互调用的函数,作为一个工具类的方法来实现。即将互相调用的函数,放到同一个工具类中。

四、mock类(class)构造函数中对其他成员函数的调用

当我们在mock一个class的方法的时候,很简单地将类对象的对应方法赋值为jest.fn()即可,但是对于在构造函数中调用的成员方法,却不能这样做。因为类里面的方法只能在实例化完成之后再进行mock,不能阻止constructor中执行原函数。

这时,我们可以考虑一下,class的本质是什么,class是ES6中的语法糖,本质上还是ES5中的原型prototype,所以类的成员方法本质上也是挂载到类原型上的方法,所以我们只需要mock类构造函数的原型上的方法即可:

1
2
3
4
5
6
7
class Person {
constructor() {
this.init();
// ...
}
public init() {}
}
1
Person.prototype.init = jest.fn();

五、mock类中的私有函数(针对TypeScript而言)

对于ts中类的私有函数(private),无法直接获取(虽然说可以ts-ignore忽略ts报错,不过不建议这样做),这时只需使用同样的方法,在类的原型上直接mock即可:

1
2
3
class Person {
private funcA() {}
}
1
Person.prototype.funcA = jest.fn();

六、mock对象的只读属性(getter

在单测中,对于可读可写属性我们可以比较方便地进行mock,直接赋值为对应的mocK值即可,如Platform.OS。但是对于只读属性(getter)的mock却不能直接这样写。通常对于只读属性(此处以document.body.clientWidth为例)有以下两种mock方式:

  1. 通过Object.defineProperty

    1
    2
    3
    4
    Object.defineProperty(document.body, 'clientWidth', {
    value: 10,
    set: jest.fn(),
    });
  2. 通过jest.spyOn

    1
    2
    const mockClientWidth = jest.spyOn(document.body, 'clientWidth', 'get');
    mockClientWidth.mockReturnValue(10);

七、使用toBeCalledWith对参数中的匿名函数进行断言

我们需要对于某个方法测试时,有时需要断言这个方法以具体参数被调用,toBeCalledWith可以实现这个功能,但是设想下面一种情况

1
2
3
4
5
6
7
8
9
export const func = (): void => {
if (/* condition 1 */) {
moduleA.method1(1, () => {
// do something
});
} else {
moduleA.method1(2);
}
}

在某种情况下,moduleA.method1将会被传入参数1和一个匿名函数,要怎么用toBeCalledWith断言moduleA.method1被以这些参数调用了呢?因为第二个参数是一个匿名函数,外部没办法mock。这个时候,我们可以使用expect.any(Function)来断言:

1
2
3
4
moduleA.method1 = jest.fn();
// 构造出 condition 1
func();
expect(moduleA.method1).toBeCalledWith(1, expect.any(Function));

因为这里其实只关心moduleA.method1是否被传入第二个参数且参数是否为一个函数,而不关心函数的具体内容,所以可以用expect.any(Function)来断言。

八、mock localStorage

localStorage是浏览器环境下的一个全局变量,挂载在window下,在单测运行时(Node环境)是获取不到的,对于localStorage,我们可以实现一个简单的mock:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class LocalStorageMock {
private store: Record<string, string> = {};

public setItem(key: string, value: string) {
this.store[key] = String(value);
}

public getItem(key: string): string | null {
return this.store[key] || null;
}

public removeItem(key: string) {
delete this.store[key];
}

public clear() {
this.store = {};
}

public key(index: number): string | null {
return Object.keys(this.store)[index] || null;
}

public get length(): number {
return Object.keys(this.store).length;
}
}

global.localStorage = new LocalStorageMock();

建议把mock放到单独的mocks文件中,在需要测试的地方,单独引入即可:

1
import './__mocks__/localStorage';

九、mock indexedDB

对于indexedDB,情况和localStorage类似,它是浏览器环境下的一个事务型数据库系统,同样在Node环境中无法获取,但是由于indexedDB接口、类型较多,实现起来较为复杂,不建议自己实现,比较常见的做法是使用fake-indexeddb这个库,这个库使用纯js在内存中实现了indexedDB的各种接口,主要用于在Node环境中对依赖indexedDB的代码进行测试。

对于需要测试的文件,只需要在文件开头引入fake-indexeddb/auto即可:

1
import 'fake-indexeddb/auto';

如果需要对所有的文件都引入fake-indexeddb,那么只需要在jest配置中添加如下配置:

1
2
3
4
5
// jest.config.js
module.exports = {
// ...
setupFiles: ['fake-indexeddb/auto'],
};

或在package.json

1
2
3
4
"jest": {
...
"setupFiles": ["fake-indexeddb/auto"]
}

十、测试异步函数

在单测中,如果需要对异步函数进行测试,针对不同情况有如下操作:

  1. callback回调函数异步

    对于回调函数异步(如setTimeout回调),如果像同步函数一样进行测试,是没办法获取正确的断言结果的:

    1
    2
    3
    4
    5
    export const funcA = (callback: (data: number) => void): void => {
    setTimeout(() => {
    callback(1);
    }, 1000);
    };
    1
    2
    3
    test('funcA', () => {
    funcA((data) => expect(data).toEqual(2));
    });

    像上面那样,funcA会在回调里传入1,单测里就算是直接断言结果为2,也是可以直接通过单测的:

    callback异步单测

    这是因为jest在运行完funcA后就直接结束了,不会等待setTimeout的回调,自然也就没有执行expect断言。正确的做法是,传入一个done参数:

    1
    2
    3
    4
    5
    6
    test('funcA', (done) => {
    funcA((data) => {
    expect(data).toEqual(2);
    done();
    });
    });

    在回调执行完之后显式地告诉jest异步函数执行完毕,jest会等到执行了done()之后再结束,这样就能得到预期的结果了。

  2. Promise异步

    除了回调函数外,另外一种很常见的异步场景就是Promise了,对于Promise异步,不用像上面那么复杂,只需要在test用例结束时,把Promise返回即可:

    1
    2
    3
    4
    5
    6
    7
    export const funcB = (): Promise<number> => {
    return new Promise<number>((resolve) => {
    setTimeout(() => {
    resolve(1);
    }, 1000);
    });
    };
    1
    2
    3
    test('funcB', () => {
    return funcB().then((data) => expect(data).toEqual(1));
    });

    如果使用了async/await语法,就更简洁了,Promise都不需要返回,像测试同步代码一样直接书写即可:

    1
    2
    3
    4
    test('funcB', async () => {
    const data = await funcB();
    expect(data).toEqual(1);
    });

    对于Promise抛出的异常,测试方法也和上面类似:

    1
    2
    3
    4
    5
    6
    7
    8
    // 抛出异常的方法
    export const funcC = (): Promise<number> => {
    return new Promise<number>((resolve, reject) => {
    setTimeout(() => {
    reject('something wrong');
    }, 1000);
    });
    };
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    test('funcC promise', () => {
    return funcC().catch((error) => expect(error).toEqual('something wrong'));
    });
    // or
    test('funcC await', async () => {
    try {
    await funcC();
    } catch (error) {
    expect(error).toEqual('something wrong');
    }
    });

十一、不执行jest.spyOn mock的函数

我们知道,jest.fnjest.spyOn都可以用来mock一个函数,区别是jest.fn mock的函数不会去执行,而jest.spyOn mock的函数是会去正常执行的。那么有没有什么办法让jest.spyOn mock的函数不执行呢?其实上面已经用到了,在“mock对象的只读属性(getter)”中,通过jest.spyOn mock了一个getter,然后使用mockReturnValue来mock一个返回值,这个时候原函数就不会执行。

除此之外,使用mockImplementation也有同样的效果:

1
mockFn.mockImplementation(() => {});

总结下来就是可以使用mockReturnValuemockImplementation不执行jest.spyOnmock的函数。

另外多说一个建议就是,能使用jest.fn就尽量不要用jest.spyOn,因为jest.spyOn会执行原始代码,在统计单测覆盖率时会被统计进去,导致单测覆盖率看起来很高实际上却又很多代码没有相应单测。

十二、使用test.each

有时我们会遇到这种情况,要写大量单测用例,但是每个用例结构一样或相似,只有细微不同,比如测试某个format函数对于不同的字符串的返回结果,或者调用一个类不同的成员方法但返回的结果类似(如都抛出错误或return null等),对于这些情况,有时我们可以在单测内写一个数组然后遍历执行一下,但其实jest已经提供了应对这种情况的方法,即test.each,举几个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// each.ts
export const checkString = (str: string): boolean => {
if (str.length <= 0) {
throw new Error('mockError 1');
} else if (str.length > 5) {
throw new Error('mockError 2');
} else {
return true;
}
};

// each.test.ts
describe('each.ts 单元测试', () => {
test.each<{ param: string; expectRes: boolean | string }>([
{
param: '',
expectRes: 'mockError 1',
},
{
param: '123456',
expectRes: 'mockError 2',
},
{
param: '1234',
expectRes: true,
},
])('checkString', (data) => {
const { param, expectRes } = data;
try {
const result = checkString(param);
expect(result).toEqual(expectRes);
} catch (error) {
expect(error.message).toEqual(expectRes);
}
});
});

又比如在某种情况下,某个对象store的所有方法都会抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
test.each<{
func: 'get' | 'delete' | 'add' | 'update';
param?: any;
}>([
{ func: 'get', param: ['mockKey'] },
{ func: 'delete', param: ['mockKey'] },
{ func: 'add', param: ['mockKey', 'mockValue'] },
{ func: 'update', param: ['mockKey', 'mockValue'] },
])('调用 store 的方法抛出异常', (data) => {
return store[data.func](...data.param).catch((err) => {
expect(err).toEqual('mockError');
});
});

除了test.each外,还有describe.each,更多具体用法可以参考test.eachdescribe.each

十三、使用.test.js.test.ts.test.tsx

这点是一个建议,建议单测文件以.test.js.test.ts.test.tsx命名,如对于utils.ts,建议对应的单测以utils.test.ts命名,这样每个单测文件单单从文件名来说就具有清晰的语义,即这是一个单测文件,而不是一个具有具体功能的源码文件。

同时,在搜索文件或者全局搜索字符串时,列表里的文件更清晰可见容易辨认。更进一步来说,现在很多IDE的文件图片icon插件,针对不同的文件名结尾,都有不同的渲染,更加方便辨认:
search_file
global_search

十四、配合使用Jest Runner插件

另外推荐一个VSCode插件,Jest Runner,这个插件会在.test.js.test.ts.test.tsx中,渲染几个按钮选项:

run_debug

点击RunDebug,可以只运行或调试某一个test或者describe,不需要重新全局npm run test也不用单独jest执行这个文件,极大提高写单测的效率:

run_debug_result

这个插件只针对.test.js.test.ts.test.tsx这几个文件类型有效,所以这也是上面建议单测文件使用使用.test.js.test.ts.test.tsx命名的原因之一。

同时,插件提供的Debug,也省去了繁琐的launch.json配置,可以方便地进行断点调试。

十五、其他技巧

1. jest配合enzyme对React.forwardRef组件进行测试

对于React.forwardRef组件,假设有如下用例

1
2
3
4
test('render', () => {
const wrapper = mount(<Wrapper {...props} />);
expect(wrapper.find(/** ComponentName */).exists()).toBeTruthy();
});
  • 对于普通的组件,“ComponentName”只需要填入对应组件的名字即可,如”Text”

    1
    expect(wrapper.find('Text').exists()).toBeTruthy();
  • 但是对于使用了React.forwardRef来进行ref转发的组件,“ComponentName”则需要加上“ForwardRef”,如“ForwardRef(MyComponent)”

    1
    expect(wrapper.find('ForwardRef(MyComponent)').exists()).toBeTruthy();

2. 收集单元测试覆盖率

有以下几种方式收集单元测试覆盖率:

  1. 命令行执行全部单测并收集覆盖率

    1
    npx jest --coverage
  2. 命令行执行单个单测文件并收集覆盖率

    1
    npx jest src/utils/__tests__/utils.test.ts --coverage
  3. jest.config.js中配置collectCoverage,同时设置collectCoverageFrom来收集指定文件的覆盖率

    1
    2
    3
    4
    5
    6
    7
    8
    module.exports = {
    // ...
    collectCoverage: true,
    collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    ],
    };

    这样在命令行执行npx jest时,就会自动收集覆盖率了。

本文首次发布于个人博客youfindme.top,转载请注明来源。

前言

在前端项目开发过程中,我们经常会听到代码规范化这个词,即通过多种工具来对代码、Git记录等进行约束,使之达到某种规范。而这些规范工具多种多样,让人眼花缭乱,本文尝试通过一个工具集,来给出一种前端代码规范化的最佳实践。

本文将基于TypeScript来讲解,工具集中主要包括以下工具:

  • eslint:对代码进行风格和规范进行检查,对不符合规范的代码给出提示,同时可以进行一定程度的自动修复
  • prettier:自动格式化代码工具,根据prettier规范对代码进行修复,拥有比eslint更加强大的代码规范性修复能力
  • husky:Git hooks工具,通过配置一系列钩子,可以在git操作的不同阶段执行相应的命令
  • lint-staged:在提交代码前进行lint检查时,可以让lint只检查git暂存区(staged)的文件,而不会检查所有文件
  • commitzen:可以实现规范的提交说明的git-cz工具,提供选择的提交信息类别,快速生成提交说明,如果需要在项目中使用commitizen生成符合某个规范的提交说明,则需要使用对应的适配器
  • commit-lint:校验提交说明是否符合规范。commitzen只是一个实现规范提交说明的工具,但可能存在不使用这个工具直接用git提交的情况,那么就需要对每次提交说明进行检查是否符合规范

除了上面提到的,其他相关工具会在用到的时候再详细说明。

一、eslint & prettier

1. 首先安装eslintprettier及相关工具

1
2
3
4
5
npm install --save-dev eslint prettier
npm install --save-dev eslint-plugin-prettier eslint-config-prettier
npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin
npm install --save-dev eslint-plugin-import
npm install --save-dev eslint-import-resolver-typescript

其中除了eslintprettier外的其他几个工具作用分别是:

  • eslint-plugin-prettier:将 prettier 的能力集成到 eslint 中, 按照 prettier 的规则检查代码规范性,并进行修复
  • eslint-config-prettier:让所有可能会与 prettier 规则存在冲突的 eslint rule 失效,并使用 prettier 的规则进行代码检查
  • @typescript-eslint/parser: 解析器,使 eslint 可以解析 ts 语法
  • @typescript-eslint/eslint-plugin:指定了 ts 代码规范的 plugin
  • eslint-plugin-import:对 ES6+ 的导入/导出语法进行 lint, 并防止文件路径和导入名称拼写错误的问题
  • eslint-import-resolver-typescript:这个插件为eslint-plugin-import添加了 ts 支持,详见此处

2. 配置.eslintrc.js.prettierrc.js

首先配置.eslintrc.js(或者.eslintrc.json,不建议使用.eslingtrc),常用配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module.exports = {
env: {
browser: true,
node: true,
es6: true,
},
parser: '@typescript-eslint/parser', // 解析器
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], // 使用 eslint 和 typescript-eslint 建议的规则
plugins: ['@typescript-eslint', 'prettier', 'import'], // 代码规范插件
rules: {
'prettier/prettier': 'error', // 不符合 prettier 规则的代码,要进行错误提示
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
project: 'tsconfig.json',
},
},
},
};

再来配置.prettierrc.js(或者.prettierrc.json.prettierrc),常用规则如下:

1
2
3
4
5
6
7
module.exports = {
trailingComma: 'es5',
singleQuote: true,
semi: true,
tabWidth: 2,
printWidth: 80,
};

如果需要还可以加上相应的.eslintignore.prettierignore来忽略想要的目录/文件。

3. 向package.jsonscripts中添加命令

1
2
3
4
5
6
{
"scripts": {
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx ./ --fix"
}
}

现在可以尝试一下编辑任意jsts文件并保存,通过npm run lint命令来对项目进行 lint 检查,查看是否符合规则的预期。通过npm run lint:fix命令可以对不符合风格规范的代码进行自动修复。

如果需要对另外一些语法进行 lint 检查,只需添加对应的工具即可,比如想要对reactreact hooks进行检查,则安装eslint-plugin-reacteslint-plugin-react-hooks

1
npm install --save-dev eslint-plugin-react eslint-plugin-react-hooks

之后按照各自的配置方式进行配置即可,详见 eslint-plugin-reacteslint-plugin-react-hooks

4. 添加.editorconfig文件

.editorconfig是一个用于跨编辑器和 IDE 统一代码风格的配置文件,也就是说通过这个文件,可以在不同的 IDE 中统一一些代码风格,比如统一缩进、换行符、字符编码等。

现在我们项目中添加一个.editorconfig文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# More info here: http://editorconfig.org
root = true

[*]
# Unix-style newlines with a newline ending every file
end_of_line = lf
insert_final_newline = true

# Space indentation
indent_style = space
indent_size = 2

# Set default charset
charset = utf-8

# Trim trailing whitespace
trim_trailing_whitespace = true

5. 安装 VS Code 插件

如果你使用的编辑器或者 IDE 是VS Code,那么强烈建议安装下面几个VS Code插件:

首先是ESlint和Prettier的插件,通过这两个插件可以实现实时查看当前的eslint和prettier报错而不需要执行命名行检查。

然后是EditorConfig的插件,通过这个插件,可以让VS Code读取项目中的.editorconfig文件,来覆盖VS Code的默认配置或者用户自定义的配置,从而实现一个项目内的统一风格。

顺便聊一下这几个的区别

eslint 和 prettier
  • eslint侧重影响代码质量的问题(比如=====
  • prettier更侧重代码风格的问题(比如代码折行),以及对代码自动修复

eslint很多地方只能warning警告或者报错,但是不能自动修复,比如下面的例子:

  1. 对于=====的eslint问题
    eslint_issue

    eslint_issue_fix
    这个是eslint报的一个问题,点击“快速修复”,只能弹出几个选项,比如disable这个规则,或者展示文档,没有自动修复(下面的copilot不算,这个是GitHub Copilot插件AI修复,不属于自动修复)

  2. 对于prettier的代码风格问题
    prettier_issue

    prettier_issue_fix

对于这个代码风格问题,点击“快速修复”,提供了几个自动修复的选项(Fix this …/Fix all …)

prettier 和 editorconfig

如果你仔细观察的话,会发现prettier和editorconfig的功能有一些重叠,比如都可以设置缩进、换行符等,那么这两个工具有什么区别呢?

拿缩进来说,如果在一个项目中的.prettierrc.js中设置了tabWidth: 2,但是我们的VS Code的默认缩进是4个空格。那么prettier在检查代码风格时,会按照2个空格来检测,自动修复的时候,也会把缩进改成2个空格。但是我们在编辑文件时,按一下Tab键,VS Code会使用4个空格来缩进

tab_size

这个时候就需要editorconfig来发挥作用了,在项目中添加一个.editorconfig文件,设置indent_size = 2,这样就可以覆盖VS Code的默认配置,使得VS Code在编辑文件时,按Tab键,也会使用2个空格来缩进

6. 配置eslintprettier的自动修复

在上面的步骤中,我们已经完成了eslintprettier的配置,同时通过插件也实现了实时检查,但是对于不符合规范的代码,我们还需要手动去修复,这个时候可以配置一下保存时自动修复的功能。

在项目中添加一个.vscode/settings.json文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
// 在 VS Code 1.85 版本之前,这里的值要改为 "source.fixAll.eslint": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
// 添加你项目自己的语言配置,比如 javascriptreact、typescriptreact 等
}

通过上面的配置,在Ctrl + S保存文件时,将会自动修复不符合规范的代码。

二、husky

通过上面的步骤,我们已经成功安装并配置了 eslint 相关的检查,但是我们不能仅仅依靠开发人员的自觉来保证提交到仓库的代码符合eslint规范,这时候就需要husky这样一个工具来操作git提供的一些钩子hooks,在提交代码时对代码进行 lint 检查。

1. 首先是安装husky

1
npm install --save-dev husky

2. 向package.jsonscripts中添加命令

1
2
3
4
5
{
"scripts": {
"prepare": "husky install"
}
}

prepare命令会在执行npm install(不带参数的情况下)之后自动执行。也就是说当我们执行npm install安装完项目依赖后会执行husky install命令,该命令会创建.husky/并指定该目录为git hooks所在的目录。这里我们先手动执行一次npm run prepare

3. 配置husky

添加pre-commit hooks:

1
npx husky add .husky/pre-commit // 或 npx husky set .husky/pre-commit

这将在./husky/目录下生成一个pre-commit脚本文件,在文件里添加npm run lint这个命令,添加完成后文件内容为:

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run lint

通过上面三个步骤,我们在执行git commit或者通过git-cz工具提交时,将会先执行pre-commit脚本的内容,即执行npm run lint检查,对不符合代码规范的提交进行阻止,防止污染代码仓库。

4. 注意

上面配置husky的方式是针对 v5.0+ 的配置方式,针对 v5.0 之前的配置方式,则是在安装husky之后在package.json中添加如下配置:

1
2
3
4
5
6
7
{
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
}
}

这么改的原因是在husky 5.0版本中,做了破坏性的变更,导致两种配置方式不兼容,详见此处。本文推荐使用最新的配置方式。

三、lint-staged

完成husky配置之后,我们做到了通过每次git提交时都对项目做 lint 检查,防止不符合规范的代码提交到仓库,但是这带来一个问题:每次提交都将对整个项目做 lint 检查,对于一个越来越大的项目来说,这无疑是一个很耗时的操作,除此之外,对于新接入这些配置的项目,项目中可能已经存在了大量不符合规范的代码,不能要求在提交时把所有历史遗留的问题修复之后才能提交。这个时候就需要用到lint-staged这个工具了。

1. 安装lint-staged

1
npm install --save-dev lint-staged

2. 在package.json中配置lint-staged

package.json中添加如下配置,配置表明在运行lint-staged的时候将只匹配srctest目录下的tstsx文件,我们可以根据自己项目的需要修改配置:

1
2
3
4
5
6
7
8
9
10
{
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint"
],
"test/**/*.{ts,tsx}": [
"eslint"
]
}
}

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"lint-staged": "lint-staged"
}
}

3. 修改.husky/pre-commit脚本的内容

.husky/pre-commit脚本的内容改为npm run lint-staged

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run lint-staged

通过上面的步骤,就完成了lint-staged的配置,这个时候再进行 git 提交时,将只检查暂存区(staged)的文件,不会检查项目所有文件,加快了每次提交 lint 检查的速度,同时也不会被历史遗留问题影响。如下图:
lint_staged

四、commitzen

有了上面的几个配置后,其实已经算是一个比较完善的代码规范配置了,不过还有一些其他的可以去做的事,比如 git 提交记录规范。

在多人协作开发过程中,如果不注意提交记录的规范,那么每个人可能都有自己的提交风格,如“修复了xxx的bug”、“增加了一个按钮,修改了一处文案”、“config配置文件增加了xxx字段”,更有甚者会在对项目文件做了大量变更后只写一个“增加了若干功能”或“修复了若干问题”这种提交信息。

那什么才一个好的提交信息呢,我们以Angular提交信息规范来举例说明。

在 Angular 提交信息规范中,一个好的提交信息的结构应该如下所示:

1
2
3
4
5
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

整个提交说明包括三部分:header页眉、body正文、footer页脚,在每个部分之间有一个空白行分隔,其中header部分是每次提交中必须包含的内容。

  • header:
    对于header来说,又包括三部分:
  • body:
    本地提交的详细描述,说明代码提交的详细说明
  • footer:
    主要包括本次提交的 BREAKING CHANGE(不兼容变更)和要关闭的 issue

更加详细的内容请参考Angular提交信息规范

commitzen就是这样一个工具,它可以提供可以选择的提交信息类别,快速生成符合规范的提交说明。

1. 先安装commitizencz-conventional-changelog

1
npm install --save-dev commitizen cz-conventional-changelog

我们在前言中提到,如果需要在项目中使用commitizen生成符合某个规范的提交说明,则需要使用对应的适配器,而cz-conventional-changelog就是符合AngularJS规范提交说明的commitzen适配器。

2. 在package.json中配置commitizen

package.json中添加如下配置,配置指明了cz工具commitizen的适配器路径:

1
2
3
4
5
6
7
{
"config": {
"commitizen": {
"path": "node_modules/cz-conventional-changelog"
}
}
}

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"commit": "git-cz"
}
}

这时我们就可以使用npm run commit来代替git commit进行提交了:commitizen

3. 配置自定义提交信息规范

如果想定制项目的提交信息规范,可以使用cz-customizable适配器:

1
npm install --save-dev cz-customizable

然后把package.json中配置的适配器路径修改为cz-customizable的路径:

1
2
3
4
5
6
7
{
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
}
}
}

之后在根目录下新建一个.cz-config.js来配置自定义的规范,这里提供一个官方的示例cz-config-EXAMPLE.js,修改里面字段、内容为自己想要的规范即可

4. 生成日志记录

对于使用了commitzen的项目,还可以配套使用conventional-changelog-cli来生成开发日志:

1
npm install --save-dev conventional-changelog-cli

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"version": "conventional-changelog -p angular -i CHANGELOG.md -s"
}
}

这样执行npm run version之后就可以看到生成了CHANGELOG.md日志文件了。

五、commit-lint

在配置了commitizen之后,我们可以愉快地使用npm run commit规范化提交代码,但是配置commitizen并不等于不能使用git commit或者其他工具提交,还是存在提交不规范记录说明的可能,那么就需要使用commit-lint来对每次提交信息进行限制。

1. 安装commitlint@commitlint/config-conventional

1
npm install --save-dev commitlint @commitlint/config-conventional

其中@commitlint/config-conventional是一个符合Angular规范的校验规则集,就像esint也需要extends一些规则集一样,commitlint也需要extends一些规则集。

2. 在package.json中配置commitlint

package.json中添加如下配置:

1
2
3
4
5
6
7
{
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
}
}

package.jsonscripts中添加命令:

1
2
3
4
5
{
"scripts": {
"commitlint": "commitlint --edit $1"
}
}

注意,我们可能在别的地方见到过commitlint的下面这种写法

1
2
3
4
5
{
"scripts": {
"commitlint": "commitlint -E HUSKY_GIT_PARAMS"
}
}

同样的,这也是一种在husky v5.0 之前的写法,在新版husky中不在使用。

3. 向husky中添加commit-msg hooks

1
npx husky add .husky/commit-msg // 或 npx husky set .husky/commit-msg

这将在./husky/目录下生成一个commit-msg脚本文件,在文件里添加npm run commitlint这个命令,添加完成后文件内容为:

1
2
3
4
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run commitlint

这时,如果通过git commit或其他工具在提交时没有按照规范写提交信息,那么本次提交就会被阻止,如下图:
commit_lint

4. 对于自定义提交信息规范的 lint 校验

上面说的@commitlint/config-conventional是针对Angular规范的校验规则集,对于使用了cz-customizable的自定义提交信息规范,就需要使用commitlint-config-cz这个工具来对自定义规范进行校验了:

1
npm install --save-dev commitlint-config-cz

然后把package.jsoncommitlint的配置改为:

1
2
3
4
5
6
7
{
"commitlint": {
"extends": [
"cz"
]
}
}

六、总结

通过上面的这些步骤,我们算是完成了前端代码规范化的配置,到目前,我们新增的配置有:

  • .vscode/settings.json
  • .editorconfig
  • .eslintrc.js
  • .eslintignore
  • .prettierrc.js
  • .prettierignore
  • .husky
    • pre-commit
    • commit-msg
    • _ husky自动生成的一个文件夹
  • package.json 下面只列出来新增的配置,对于package.json已有的字段没有列出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
"scripts": {
"prepare": "husky install",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx ./ --fix",
"lint-staged": "lint-staged",
"commit": "git-cz",
"commitlint": "commitlint --edit $1",
"version": "conventional-changelog -p angular -i CHANGELOG.md -s"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint"
],
"test/**/*.{ts,tsx}": [
"eslint"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"config": {
"commitizen": {
"path": "node_modules/cz-conventional-changelog"
}
},
"devDependencies": {
"@commitlint/config-conventional": "^12.1.4",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"commitizen": "^4.2.4",
"commitlint": "^12.1.4",
"conventional-changelog-cli": "^2.1.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.30.0",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-prettier": "^3.4.0",
"husky": "^7.0.0",
"lint-staged": "^11.0.0",
"prettier": "^2.3.2"
}
}

另外,还安装了三个 VS Code 插件。

配置过程虽然繁琐,但是对于一个项目,特别是多人协作的大型项目来说,是必不可少的一部分。其实担心配置麻烦,导致每一个新项目都需要重新走一遍配置流程,可以考虑使用Yeoman这个脚手架工具,把一个已经完成代码规范化配置的项目作为模板,通过yo直接一键生成即可,此处是我创建的一个 demo 工程,包含了上述全部配置:https://github.com/JingzheWu/lint-demo,以供参考。

参考文章和链接

0%