有一段时间了,我想要在 Node 生态系统中执行标准库和常用软件包的代码演练。我想现在是时候把这个意愿改变为行动,并且实际写出一篇文章。所以在这里,我的第一个带注释的代码演练。
我想先看一下Node标准库中最基本的模块之一:querystring
。querystring
是一个允许用户提取 URL 的查询部分的值和从键值关联的对象构建查询的模块。这是一个快速的代码片段,显示了由 querystring
暴露的四种不同的API函数,escape
,parse
,stringify
和 unescape
。
> const querystring = require("querystring");
> querystring.escape("key=It's the final countdown");
'key%3DIt\'s%20the%20final%20countdown'
> querystring.parse("foo=bar&abc=xyz&abc=123");
{ foo: 'bar', abc: [ 'xyz', '123' ] }
> querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: 'i' });
'foo=bar&baz=qux&baz=quux&corge=i'
> querystring.unescape("key%3DIt\'s%20the%20final%20countdown");
'key=It\'s the final countdown'
好的!让我们深入了解有趣的部分。我将查看 querystring
模块的代码作为我写这篇文章的标准。你可以在这里找到这个版本的副本。
引起我注意的第一件事是47-64行的这段代码。
const unhexTable = [
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 0 - 15
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 16 - 31
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 32 - 47
+0, +1, +2, +3, +4, +5, +6, +7, +8, +9, -1, -1, -1, -1, -1, -1, // 48 - 63
-1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 64 - 79
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 80 - 95
-1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 96 - 111
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 112 - 127
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 128 ...
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 // ... 255
];
这是什么胡言乱语?我在整个代码库中搜索了 unhexTable
这个术语,以找出它在哪里使用。除了定义语句之外,搜索还返回了另外两个结果。它们出现在代码库的第86和91行。这里是包含这些引用的代码块。
if (currentChar === 37 /*'%'*/ && index < maxLength) {
currentChar = s.charCodeAt(++index);
hexHigh = unhexTable[currentChar];
if (!(hexHigh >= 0)) {
out[outIndex++] = 37; // '%'
} else {
nextChar = s.charCodeAt(++index);
hexLow = unhexTable[nextChar];
if (!(hexLow >= 0)) {
out[outIndex++] = 37; // '%'
out[outIndex++] = currentChar;
currentChar = nextChar;
} else {
hasHex = true;
currentChar = hexHigh * 16 + hexLow;
}
}
}
所有这些都发生在 unescapeBuffer
函数中。快速搜索后,我发现 unescapeBuffer
函数由模块公开的 unescape
函数调用(请参见第113行)。所以这里是发生在 querystring
中的 unescape 动作。
好的!那么,unhexTable
的所有逻辑是什么?我开始通读 unescapeBuffer
函数来弄清楚它在做什么。我从第67行开始。
var out = Buffer.allocUnsafe(s.length);
所以函数首先初始化一个传递给函数的字符串长度的 Buffer。此时,我可以深入了解 Buffer
类中的 allocUnsafe
正在做什么,但是我将预留它为另一篇博客文章。之后,有几个语句会初始化为稍后将在函数中使用的不同变量。
var index = 0;
var outIndex = 0;
var currentChar;
var nextChar;
var hexHigh;
var hexLow;
var maxLength = s.length - 2;
// Flag to know if some hex chars have been decoded
var hasHex = false;
下一块代码是一个 while 循环,遍历字符串中的每个字符。如果字符是 +
,并且函数设置为将 +
更改为空格,则会将转义的字符串中该字符的值设置为空格。
while (index < s.length) {
currentChar = s.charCodeAt(index);
if (currentChar === 43 /*'+'*/ && decodeSpaces) {
out[outIndex++] = 32; // ' '
index++;
continue;
}
第二组 if 语句检查迭代器是否处于以 %
开始的字符序列,这表示接下来的字符将代表十六进制代码。然后程序获取字符代码。接着程序使用该字符代码作为查找 unhexTable
列表中的索引。如果查找返回的值为 -1
,则该函数将输出字符串中的字符值设置为百分号。如果从 unhexTable
中的查找返回的值大于 -1
,则函数会将分隔字符解析为十六进制字符代码。
if (currentChar === 37 /*'%'*/ && index < maxLength) {
currentChar = s.charCodeAt(++index);
hexHigh = unhexTable[currentChar];
if (!(hexHigh >= 0)) {
out[outIndex++] = 37; // '%'
} else {
nextChar = s.charCodeAt(++index);
hexLow = unhexTable[nextChar];
if (!(hexLow >= 0)) {
out[outIndex++] = 37; // '%'
out[outIndex++] = currentChar;
currentChar = nextChar;
} else {
hasHex = true;
currentChar = hexHigh * 16 + hexLow;
}
}
}
out[outIndex++] = currentChar;
index++;
}
让我们再深入一点这段代码。所以,如果第一个字符是有效的十六进制代码,它将使用下一个字符的字符代码作为 unhexTable
的查找索引。这个值是在 hexLow
变量中。如果该变量等于 -1
,则该值不会被解析为十六进制字符序列。如果不等于 -1
,则该字符被解析为十六进制字符代码。该函数取十六进制代码的最高位(第二位)(hexHigh
)的值,将其乘以16并将其加到十六进制代码的值的第一位中。
} else {
nextChar = s.charCodeAt(++index);
hexLow = unhexTable[nextChar];
if (!(hexLow >= 0)) {
out[outIndex++] = 37; // '%'
out[outIndex++] = currentChar;
currentChar = nextChar;
} else {
hasHex = true;
currentChar = hexHigh * 16 + hexLow;
}
}
函数的最后一行让我困惑了一会儿。
return hasHex ? out.slice(0, outIndex) : out;
如果我们在查询中检测到一个十六进制序列,则将输出字符串从 0
到 outIndex
的切片,否则保持原样。这使我感到困惑,因为我认为 outIndex
的值将等于程序结束时输出字符串的长度。 我本可以花时间弄清楚这个假设是否属实,但说实话,现在已经快到午夜了,而且我已经没有精力在深夜做这种荒唐举动了。所以我在代码库上运行 git blame
,并试图找出哪些提交与这个特别的改动相关联。事实证明,这并没有太大的帮助。我期待着那里有一个孤立的提交,它描述了为什么那个特别的一行代码是这样的,但最近的变化是属于
escape
函数的一个更大的重构的一部分。 我越看越确定这里不需要三元运算符,但我还没有找到一些可重现的证据。
我研究的下一个函数是 parse
函数。函数的第一部分进行一些基本的设置。函数默认分析查询字符串中的 1000 个键值对,但用户可以在 options
对象中传递 maxKeys
值以更改此值。 该函数还使用我们前面介绍的 unescape
函数,除非用户在选项对象中提供了不同的东西。
function parse(qs, sep, eq, options) {
const obj = Object.create(null);
if (typeof qs !== 'string' || qs.length === 0) {
return obj;
}
var sepCodes = (!sep ? defSepCodes : charCodes(sep + ''));
var eqCodes = (!eq ? defEqCodes : charCodes(eq + ''));
const sepLen = sepCodes.length;
const eqLen = eqCodes.length;
var pairs = 1000;
if (options && typeof options.maxKeys === 'number') {
// -1 is used in place of a value like Infinity for meaning
// "unlimited pairs" because of additional checks V8 (at least as of v5.4)
// has to do when using variables that contain values like Infinity. Since
// `pairs` is always decremented and checked explicitly for 0, -1 works
// effectively the same as Infinity, while providing a significant
// performance boost.
pairs = (options.maxKeys > 0 ? options.maxKeys : -1);
}
var decode = QueryString.unescape;
if (options && typeof options.decodeURIComponent === 'function') {
decode = options.decodeURIComponent;
}
const customDecode = (decode !== qsUnescape);
然后函数遍历查询字符串中的每个字符并获取该字符的字符代码。
var lastPos = 0;
var sepIdx = 0;
var eqIdx = 0;
var key = '';
var value = '';
var keyEncoded = customDecode;
var valEncoded = customDecode;
const plusChar = (customDecode ? '%20' : ' ');
var encodeCheck = 0;
for (var i = 0; i < qs.length; ++i) {
const code = qs.charCodeAt(i);
函数然后检查被检查的字符是否对应于键值分隔符(例如查询字符串中的”&“字符)并执行一些特殊的逻辑。它会检查“&”后面是否有“key=value”段,并尝试从中提取相应的键和值对(第304-347行)。
如果字符代码不与分隔符相对应,函数会检查它是否与“=”符号或其它的用于提取键值的分隔符相对应。
接下来,该函数检查字符是否为“+”符号。如果是这种情况,那么函数会生成一个空格分隔的字符串。如果字符是“%”,则该函数会正确解码后面的十六进制字符。
if (code === 43/*+*/) {
if (lastPos < i)
value += qs.slice(lastPos, i);
value += plusChar;
lastPos = i + 1;
} else if (!valEncoded) {
// Try to match an (valid) encoded byte (once) to minimize unnecessary
// calls to string decoding functions
if (code === 37/*%*/) {
encodeCheck = 1;
} else if (encodeCheck > 0) {
// eslint-disable-next-line no-extra-boolean-cast
if (!!isHexTable[code]) {
if (++encodeCheck === 3)
valEncoded = true;
} else {
encodeCheck = 0;
}
}
}
还需要在未处理的数据上完成剩余的检查。换句话说,函数要检查是否还有一个需要添加的键值对,或者该函数是否可以返回空数据。我认为这里包含了处理解析时可能出现的边界情况。
// Deal with any leftover key or value data
if (lastPos < qs.length) {
if (eqIdx < eqLen)
key += qs.slice(lastPos);
else if (sepIdx < sepLen)
value += qs.slice(lastPos);
} else if (eqIdx === 0 && key.length === 0) {
// We ended on an empty substring
return obj;
}
最后一组检查,它会检查是否需要对键或值进行解码(使用 unescape
函数),或者是否需要将特定键的值构造为数组。
if (key.length > 0 && keyEncoded)
key = decodeStr(key, decode);
if (value.length > 0 && valEncoded)
value = decodeStr(value, decode);
if (obj[key] === undefined) {
obj[key] = value;
} else {
const curValue = obj[key];
// A simple Array-specific property check is enough here to
// distinguish from a string value and is faster and still safe since
// we are generating all of the values being assigned.
if (curValue.pop)
curValue[curValue.length] = value;
else
obj[key] = [curValue, value];
}
这就是 parse
函数!
好的!我继续看看 querystring
模块暴露的另一个函数 stringify
。stringify
函数首先初始化一些必需的变量。它默认使用 escape
函数来编码值,用户可以在选项中提供自定义的编码函数。
function stringify(obj, sep, eq, options) {
sep = sep || '&';
eq = eq || '=';
var encode = QueryString.escape;
if (options && typeof options.encodeURIComponent === 'function') {
encode = options.encodeURIComponent;
}
之后,该函数会迭代对象中的每个键值对。当它遍历每个键值对时,它会对键进行编码和串化。
if (obj !== null && typeof obj === 'object') {
var keys = Object.keys(obj);
var len = keys.length;
var flast = len - 1;
var fields = '';
for (var i = 0; i < len; ++i) {
var k = keys[i];
var v = obj[k];
var ks = encode(stringifyPrimitive(k)) + eq;
接下来,它检查键值对中的值是否为数组。如果是,则它遍历数组中的每个元素,并向该字符串添加 ks=element
关联。如果不是,则该函数根据键值对构建 ks=v
关联。
if (Array.isArray(v)) {
var vlen = v.length;
var vlast = vlen - 1;
for (var j = 0; j < vlen; ++j) {
fields += ks + encode(stringifyPrimitive(v[j]));
if (j < vlast)
fields += sep;
}
if (vlen && i < flast)
fields += sep;
} else {
fields += ks + encode(stringifyPrimitive(v));
if (i < flast)
fields += sep;
}
这个函数对我来说很简单。在由API公开的最后一个函数中,escape
。该函数遍历字符串中的每个字符并获取与该字符相对应的字符代码。
function qsEscape(str) {
if (typeof str !== 'string') {
if (typeof str === 'object')
str = String(str);
else
str += '';
}
var out = '';
var lastPos = 0;
for (var i = 0; i < str.length; ++i) {
var c = str.charCodeAt(i);
如果字符代码小于 0x80
,表示所代表的字符是有效的ASCII字符(ASCII字符的十六进制代码范围为 0
到 0x7F
)。该函数然后通过在 noEscape
表中查找来检查字符是否应该被转义。该表允许标点符号,数字或字符的字符不会被转义,并要求其他所有内容都被转义。然后它检查字符的位置是否大于 lastPos
发现的位置(意味着已经超过了字符串的长度)并适当地对字符串进行分片。最后,如果字符确实需要转义,它将查找
hexTable
中的字符代码并将其附加到输出字符串。
if (c < 0x80) {
if (noEscape[c] === 1)
continue;
if (lastPos < i)
out += str.slice(lastPos, i);
lastPos = i + 1;
out += hexTable[c];
continue;
}
下一个if语句会检查字符是否是多字节字符代码。多字节字符通常代表重音和非英文字母的字符。
if (c < 0x800) {
lastPos = i + 1;
out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];
continue;
}
在这种情况下,使用 hexTable
中的以下查找来计算输出字符串。
out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];
好的!这里有很多事情要做,所以我开始研究它。 hexTable
在 internal/querystring
支持模块中定义,并且像这样生成。
const hexTable = new Array(256);
for (var i = 0; i < 256; ++i)
hexTable[i] = '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase();
所以输出结果是一个字符串数组,代表256个字符的十六进制字符代码。它看起来有点像 ['%00', '%01', '%02',..., '%FD', '%FE', '%FF']
。所以,上面的查询语句。
out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];
语句 c >> 6
将字符代码右移6位,并与192的二进制表示执行按位或。语句 c & 0x3F
将字符串和63的二进制表示执行按位与,然后和 0x80
执行按位或。两条语句执行的结果各自进行一次 hexTable
查询,并将结果相连接。所以我知道多字节序列从 0x80
开始,但我无法弄清楚到底发生了什么。
下一个被检查的情况是这样的。
if (c < 0xD800 || c >= 0xE000) {
lastPos = i + 1;
out += hexTable[0xE0 | (c >> 12)] +
hexTable[0x80 | ((c >> 6) & 0x3F)] +
hexTable[0x80 | (c & 0x3F)];
continue;
}
在所有其他情况下,该函数使用以下策略来生成输出字符串。
var c2 = str.charCodeAt(i) & 0x3FF;
lastPos = i + 1;
c = 0x10000 + (((c & 0x3FF) << 10) | c2);
out += hexTable[0xF0 | (c >> 18)] +
hexTable[0x80 | ((c >> 12) & 0x3F)] +
hexTable[0x80 | ((c >> 6) & 0x3F)] +
hexTable[0x80 | (c & 0x3F)];
我真的被所有这些困惑了。当我去做一些调查时,我发现所有这些与十六进制相关的代码都来自这个单独的提交。它似乎是因性能相关的一部分而出现。关于为什么使用这种特定的方法,并没有大量的信息,我怀疑这个逻辑是从另一个编码函数中复制的。在某些时候我会进一步深入研究。
最后,有一些逻辑处理输出字符串返回的方式。如果 lastPos
的值为0,表示没有处理字符,则返回原始字符串。否则,返回生成的输出字符串。
if (lastPos === 0)
return str;
if (lastPos < str.length)
return out + str.slice(lastPos);
return out;
就是这样!我介绍了由Node querystring
模块公开的四个函数。
about | source code | rss feed | twitter Keyboard shortcuts
j/k:next/previous item
enter:open link
a/z:up/down vote item