前言
昨天在浏览 RSS 推送的文章时,看到奇舞周刊推送了一篇 《我对 Svelte 的看法》 的文章,看到其中一段代码示例,使用原生 JavaScript 实现一个不使用 Virtual DOM 的 Counter:
const target = document.querySelector('#app');
// state
let count = 0;
// view
const div = document.createElement('div');
const countText = document.createTextNode(`${count}`);
div.appendChild(countText);
const button1 = document.createElement('button');
const button1Text = document.createTextNode(`+`);
button1.appendChild(button1Text);
const button2 = document.createElement('button');
const button2Text = document.createTextNode(`-`);
button2.appendChild(button2Text);
target.appendChild(div);
target.appendChild(button1);
target.appendChild(button2);
// event
button1.addEventListener('click', () => {
count += 1;
});
button2.addEventListener('click', () => {
count -= 1;
});
发现示例中给相关的 DOM 插入 Text 使用的是 document.createTextNode,由于我在写代码的过程中基本没有使用过这种方式来创建 TextNode 并作为子节点插入到相关的 DOM 中,于是对这个方法有了一丝好奇。
通常情况下,我都会选择使用 innerText 的方式将 Text 插入到相关的 DOM 中,这就产生了一个问题:
- 在写代码的时候,到底是使用
document.createTextNode创建 TextNode 并插入到相关的 DOM 中,还是直接使用innerText将 Text 插入更加高效?
关注 「Hello FE」 获取更多
思路
为了获得这个问题的答案,第一个想到的办法是使用 Jest 分别跑两段测试代码,然后查看两者之间的时间差。
但是仔细一想,Jest 是跑在 Node 环境下的,不是浏览器环境,也就没有 DOM 对象,只能通过 JSDOM 来模拟,这样的话就是运行纯 JavaScript 了,少了 JavaScript 通过 Web IDL 修改 DOM 对象的过程,也就没办法测出这两种方式的差异。
关于这个知识点,可以看一下这份知乎回答:前端为什么操作 DOM 是最耗性能的呢? - justjavac 的回答 - 知乎。
不过嘛,既然是测试,跑一跑也无妨,验证一下是不是真的如我想的那样无法测出非 DOM 操作和 DOM 操作之间的差异。
JSDOM
document.title & document.xxx
先上两段代码,来验证一下在 Node 环境下是不是真的无法测出非 DOM 操作和 DOM 操作之间的差异。
const {JSDOM} = require('jsdom');
const {window} = new JSDOM(``);
const {document} = window;
console.time();
for (let i = 0; i <= 1e4; i++) {
document.title = i;
}
console.timeEnd();
const {JSDOM} = require('jsdom');
const {window} = new JSDOM(``);
const {document} = window;
console.time();
for (let i = 0; i <= 1e4; i++) {
document.xxx = i;
}
console.timeEnd();
运行之后发现,事实并非我们想象的那样:
$ node jsdom/dom.js
default: 195.461ms
$ node jsdom/non-dom
default: 0.281ms
问题来了,为什么即使是在 Node 环境下也有如此大的差异?
Debug 了一轮之后发现,对 document.title 做修改的时候,会触发 setter:
而对 document.xxx 做修改的时候仅仅只是一个赋值操作:
这就出现区别了,document 对象是使用 Object.defineProperty 包装过部分属性的,其中 title 就重写了它的 getter/setter,因此在修改 document.title 的时候耗时明显增加。
不过这是在测试修改 document.title 和 document.xxx 的差异,跑题了!
现在回到测试 document.createTextNode 和 innerText 这两者的差异上。
createTextNode & innerText
const {JSDOM} = require('jsdom');
const {window} = new JSDOM(`
<div id="root"></div>
`);
const {document} = window;
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e4; i++) {
const text = document.createTextNode(i);
root.append(text);
}
console.timeEnd();
const {JSDOM} = require('jsdom');
const {window} = new JSDOM(`
<div id="root"></div>
`);
const {document} = window;
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e4; i++) {
root.innerText += i;
}
console.timeEnd();
结果出人意料:
$ node jsdom/createTextNode
default: 103.349ms
$ node jsdom/innerText
default: 0.459ms
非常意外!于是我又 Debug 了一轮,发现 JSDOM 提供的 getElementById 的方法返回的 HTMLElement 并没有 innerText 这个属性。
与上面修改 document.xxx 的情况一样,修改 innerText 变成了一个简单的赋值操作。
Chrome
Version 1
既然在 Node 环境下没办法完成这个测试,于是我将试验场更换到了 Chrome,让我们看一下在 Chrome 中运行会有什么差异。
于是我创建了两个页面来进行测试:
<div id="root"></div>
<script>
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e4; i++) {
const text = document.createTextNode(i);
root.appendChild(text);
}
console.timeEnd();
</script>
<div id="root"></div>
<script>
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e4; i++) {
root.innerText += i;
}
console.timeEnd();
</script>
结果出人意料,差距实在太大了:
// default: 3.133056640625 ms
// default: 11603.7099609375 ms
这样看来,createTextNode 的性能显然比修改 innerText 好太多了。
不应该会有这么大的差距啊!是什么问题呢?
回到 JavaScript 代码,对比之后发现,修改 innerText 为了实现和 createTextNode 一样的行为,有一个 += 操作。
难道是这里耗时严重?
思考之后,决定重新设计这个实验,去掉 += 的操作,同时要保证最终的实现效果一致。
Version 2
<div id="root"></div>
<script>
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e4; i++) {
const child = document.createElement('div');
const text = document.createTextNode(i);
child.appendChild(text);
root.appendChild(child);
}
console.timeEnd();
</script>
<div id="root"></div>
<script>
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e4; i++) {
const child = document.createElement('div');
child.innerText = i;
root.appendChild(child);
}
console.timeEnd();
</script>
实验改进之后,应该算严格控制了两个实验行为一致了,去掉了 += 操作,现在结果就非常接近了:
// default: 6.649169921875 ms
// default: 6.679931640625 ms
为了确保这个结果是准确的,我们将循环次数放大一个数量级,再测试一次。
循环次数放大一个数量级之后,结果是这样的:
// default: 54.6650390625 ms
// default: 54.66015625 ms
依然非常接近,基本没有差别。
是不是就能确定这两种不同的写法性能上没区别呢?
事实上改进后的实验,可能还存在问题,问题出在哪呢?
Version 3
其实问题出在了循环体中的代码顺序上:
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e5; i++) {
const child = document.createElement('div');
const text = document.createTextNode(i);
child.appendChild(text);
root.appendChild(child);
}
console.timeEnd();
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e5; i++) {
const child = document.createElement('div');
child.innerText = i;
root.appendChild(child);
}
console.timeEnd();
为什么说问题出在了循环体中的代码顺序上呢?
因为在 child 被插入到 root 之前,它还是一个 JavaScript 对象,对 JavaScript 对象的属性做修改的速度是非常快的。
所以我们又修改了一下,把 child 插入 root 的代码提前,先插入到 root 中,再修改 Text。
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e5; i++) {
const child = document.createElement('div');
root.appendChild(child);
const text = document.createTextNode(i);
child.appendChild(text);
}
console.timeEnd();
const root = document.getElementById('root');
console.time();
for (let i = 0; i <= 1e5; i++) {
const child = document.createElement('div');
root.appendChild(child);
child.innerText = i;
}
console.timeEnd();
再来看结果:
// default: 57.946044921875 ms
// default: 56.62109375 ms
到这里我们应该能够确定,这两种方式插入 Text 在性能上没有区别了。
总结
因为一次技术文章的阅读引出这么一些内容,然后测试了一下,也不确定测试方法对不对,如果有跟了解的大佬可以指点一下~