已经有人指出,如果你调换两个测试的顺序,那么使用===
比逐个字符比较更快。迄今为止,你所得到的解释并没有精确地界定原因。有一些问题会影响你的结果。
第一个console.log
调用很昂贵
如果我尝试这样做:
console.time("a");
console.log(1 + 2);
console.timeEnd("a");
console.time("b");
console.log("foo");
console.timeEnd("b");
我得到的类似于:
3
a: 3.864ms
foo
b: 0.050ms
如果我把代码翻转过来,就会变成这样:
console.time("b");
console.log("foo");
console.timeEnd("b");
console.time("a");
console.log(1 + 2);
console.timeEnd("a");
然后我得到类似于这样的东西:
foo
b: 3.538ms
3
a: 0.330ms
如果我在任何计时被记录之前加入console.log
代码,像这样:
console.log("start");
console.time("a");
console.log(1 + 2);
console.timeEnd("a");
console.time("b");
console.log("foo");
console.timeEnd("b");
然后我得到了类似下面的东西:
start
3
a: 0.422ms
foo
b: 0.027ms
在计时之前加入console.log
,可以将调用console.log
的初始成本从计时中排除。
根据您设置的测试方式,第一个console.log
调用是由===
或char-by-char测试中先出现的那个来执行的,因此第一个console.log
调用的成本会被添加到该测试中。无论第二个测试是哪个,它都不承担这个成本。最终,对于像这样的测试,我更愿意将console.log
移动到正在计时的区域之外。例如,第一个计时区域可以编写为:
console.time('equal');
const result1 = str === str2;
console.timeEnd('equal');
console.log(result1);
将结果存储在result1
中,然后在定时区域之外使用console.log(result1)
可确保您可以查看结果,并且同时不计算由console.log
产生的成本。
无论哪个测试先运行,都会承担v8内部创建的字符串树展平的成本
Node使用v8 JavaScript引擎运行您的JavaScript。v8以多种方式实现字符串。objects.h
在注释中显示了v8支持的类层次结构。以下是与字符串相关的部分:
在我们的讨论中有两个重要的类:SeqString
和ConsString
。它们之间的区别在于它们如何将字符串存储在内存中。 SeqString
类是一个直接的实现:字符串只是一个字符数组。(实际上,SeqString
本身是抽象的。真正的类是SeqOneByteString
和SeqTwoByteString
,但这里不重要。)ConsString
然而将字符串存储为二叉树。一个ConcString
有一个first
字段和一个second
字段,它们是指向其他字符串的指针。
考虑以下代码:
let str = "";
for (let i = 0; i < 10; ++i) {
str += i;
}
console.log(str);
如果V8使用SeqString
来实现上面的代码,则:
在迭代0时,它需要分配一个大小为1的新字符串,并将str
的旧值(""
)复制到它上面,然后追加"0"
,并将str
设置为新字符串("0"
)。
在迭代1时,它需要分配一个大小为2的新字符串,并将str
的旧值("0"
)复制到它上面,然后追加"1"
),并将str
设置为新字符串("01"
)。
...
在迭代9时,它需要分配一个大小为10的新字符串,并将str
的旧值("012345678"
)复制到它上面,然后追加"9"
,并将str
设置为新字符串("0123456789"
)。
这10个步骤中复制的字符总数是1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 = 55个字符。对于最终包含10个字符的字符串,移动了55个字符。
相反,V8实际上使用ConsString
,方法如下:
在迭代0时,分配一个新的ConcString
,将其first
设置为str
的旧值,并将second
设置为字符串"0"
,并将str
设置为刚刚分配的这个新的ConcString
。
在迭代1时,分配一个新的ConcString
,将其first
设置为str
的旧值,并将second
设置为字符串"1"
,并将str
设置为刚刚分配的这个新的ConcString
。
...
在迭代9时,分配一个新的ConcString
,将其first
设置为str
的旧值,并将second
设置为字符串"9"
。
如果我们将每个ConcString
表示为(<first>, <second>)
,其中<first>
是其first
字段的内容,<second>
是second
字段的内容,则最终结果如下:
(((((((((("", "0"), "1"), "2"), "3"), "4"), "5"), "6"), "7"), "8"), "9")
通过这种方式,V8避免了在各个步骤中反复复制字符串。每个步骤只需进行一次分配和调整一些指针。虽然将字符串存储为树有助于加快连接速度,但它的缺点是其他操作变慢。V8通过扁平化ConsString
树来缓解这一问题。将上面的示例扁平化后,它变成了:
("0123456789", "")
注意,当一个
ConsString
被展平时,
这个非常的ConsString
对象会被改变。(从JS代码的角度来看,字符串保持不变。只有它的内部v8表示发生了改变。)更容易比较展平的
ConsString
树,实际上这正是v8所做的(
参考):
bool String::Equals(Isolate* isolate, Handle<String> one, Handle<String> two) {
if (one.is_identical_to(two)) return true;
if (one->IsInternalizedString() && two->IsInternalizedString()) {
return false;
}
return SlowEquals(isolate, one, two);
}
我们所讨论的字符串并没有内部化,所以调用了 SlowEquals
(参考链接):
bool String::SlowEquals(Isolate* isolate, Handle<String> one,
Handle<String> two) {
[... some shortcuts are attempted ...]
one = String::Flatten(isolate, one);
two = String::Flatten(isolate, two);
在此我已经说明了比较字符串相等时会使其内部变得扁平化,但是调用String::Flatten
的情况在许多其他地方也可以找到。通过不同的方式,你的两个测试最终都会使字符串扁平化。
对于你的代码,重点在于:
createConstantStr
创建的字符串在内部存储为ConsString
。因此,就v8而言,str
和str2
都是ConsString
对象。
你运行的第一个测试导致str
和str2
被扁平化,因此:a)这个测试必须承担扁平化字符串的成本,b)第二个测试受益于使用已经被扁平化的ConcString
对象。(记住,当一个ConcString
对象被扁平化时,这个对象本身被改变了。因此,如果稍后再次访问它,它已经被扁平化。)