程序性能相关整理 第 12 - 17 章
MutationObserver 接口是出于性能考虑而设计的,其核心是异步回调与记录队列模型。为了在大量变化事件发生时不影响性能,每次变化的信息(由观察者实例决定)会保存在 MutationRecord 实例中,然后添加到记录队列。这个队列对每个 MutationObserver 实例都是唯一的,是所有 DOM变化事件的有序列表。
DOM Level 2 规范中描述的 MutationEvent 定义了一组会在各种 DOM 变化时触发的事件。由于浏览器事件的实现机制,这个接口出现了严重的性能问题。因此,DOM Level 3 规定废弃了这些事件。MutationObserver 接口就是为替代这些事件而设计的更实用、性能更好的方案。
将变化回调委托给微任务来执行可以保证事件同步触发,同时避免随之而来的混乱。为 MutationObserver 而实现的记录队列,可以保证即使变化事件被爆发式地触发,也不会显著地拖慢浏览器。
无论如何,使用 MutationObserver 仍然不是没有代价的。因此理解什么时候避免出现这种情况就很重要了。
文档对象模型(DOM,Document Object Model)是语言中立的 HTML 和 XML 文档的 API。DOM Level 1 将 HTML 和 XML 文档定义为一个节点的多层级结构,并暴露出 JavaScript 接口以操作文档的底层结构和外观。DOM 由一系列节点类型构成,主要包括以下几种。
DOM 编程在多数情况下没什么问题,在涉及<script>
和<style>
元素时会有一点兼容性问题。因为这些元素分别包含脚本和样式信息,所以浏览器会将它们与其他元素区别对待。
要理解 DOM,最关键的一点是知道影响其性能的问题所在。DOM 操作在 JavaScript 代码中是代价比较高的,NodeList 对象尤其需要注意。NodeList 对象是“实时更新”的,这意味着每次访问它都 会执行一次新的查询。考虑到这些问题,实践中要尽量减少 DOM 操作的数量。
MutationObserver 是为代替性能不好的 MutationEvent 而问世的。使用它可以有效精准地监控DOM 变化,而且 API 也相对简单。
JavaScript 库中最流行的一种能力就是根据 CSS 选择符的模式匹配 DOM 元素。比如,jQuery 就完全以 CSS 选择符查询 DOM 获取元素引用,而不是使用 getElementById()和 getElementsByTagName()。
Selectors API(参见 W3C 网站上的 Selectors API Level 1)是 W3C 推荐标准,规定了浏览器原生支持的 CSS 查询 API。支持这一特性的所有 JavaScript 库都会实现一个基本的 CSS 解析器,然后使用已有的 DOM 方法搜索文档并匹配目标节点。虽然库开发者在不断改进其性能,但 JavaScript 代码能做到的毕竟有限。通过浏览器原生支持这个 API,解析和遍历 DOM 树可以通过底层编译语言实现,性能也有了数量级的提升。
Selectors API Level 1 的核心是两个方法:querySelector()和 querySelectorAll()。在兼容浏览器中,Document 类型和 Element 类型的实例上都会暴露这两个方法。Selectors API Level 2 规范在 Element 类型上新增了更多方法,比如 matches()、find()和findAll()。不过,目前还没有浏览器实现或宣称实现 find()和 findAll()。
自 HTML4 被广泛采用以来,Web 开发中一个主要的变化是 class 属性用得越来越多,其用处是为元素添加样式以及语义信息。自然地,JavaScript 与 CSS 类的交互就增多了,包括动态修改类名,以及根据给定的一个或一组类名查询元素,等等。为了适应开发者和他们对 class 属性的认可,HTML5 增加了一些特性以方便使用 CSS 类。
getElementsByClassName()是 HTML5 新增的最受欢迎的一个方法,暴露在 document 对象和所有 HTML 元素上。这个方法脱胎于基于原有 DOM 特性实现该功能的 JavaScript 库,提供了性能高好的原生实现。
getElementsByClassName()方法接收一个参数,即包含一个或多个类名的字符串,返回类名中包含相应类的元素的 NodeList。如果提供了多个类名,则顺序无关紧要。下面是几个示例:
// 取得所有类名中包含"username"和"current"元素 // 这两个类名的顺序无关紧要 let allCurrentUsernames = document.getElementsByClassName("username current"); // 取得 ID 为"myDiv"的元素子树中所有包含"selected"类的元素 let selected = document.getElementById("myDiv").getElementsByClassName("selected"); 这个方法只会返回以调用它的对象为根元素的子树中所有匹配的元素。在 document 上调用getElementsByClassName()返回文档中所有匹配的元素,而在特定元素上调用 getElementsByClassName()则返回该元素后代中匹配的元素。
使用本节介绍的方法替换子节点可能在浏览器(特别是 IE)中导致内存问题。比如,如果被移除的子树元素中之前有关联的事件处理程序或其他 JavaScript 对象(作为元素的属性),那它们之间的绑定关系会滞留在内存中。如果这种替换操作频繁发生,页面的内存占用就会持续攀升。在使用 innerHTML、outerHTML 和 insertAdjacentHTML()之前,最好手动删除要被替换的元素上关联的事件处理程序和JavaScript 对象。
使用这些属性当然有其方便之处,特别是 innerHTML。一般来讲,插入大量的新 HTML 使用innerHTML 比使用多次 DOM 操作创建节点再插入来得更便捷。这是因为 HTML 解析器会解析设置给 innerHTML(或 outerHTML)的值。解析器在浏览器中是底层代码(通常是 C++代码),比 JavaScript快得多。不过,HTML 解析器的构建与解构也不是没有代价,因此最好限制使用 innerHTML 和 outerHTML 的次数。比如,下面的代码使用 innerHTML 创建了一些列表项:
for (let value of values){
ul.innerHTML += '<li>${value}</li>'; // 别这样做!
}
这段代码效率低,因为每次迭代都要设置一次 innerHTML。不仅如此,每次循环还要先读取innerHTML,也就是说循环一次要访问两次 innerHTML。为此,最好通过循环先构建一个独立的字符 串,最后再一次性把生成的字符串赋值给 innerHTML,比如:
let itemsHtml = "";
for (let value of values){
itemsHtml += '<li>${value}</li>';
}
ul.innerHTML = itemsHtml;
这样修改之后效率就高多了,因为只有对 innerHTML 的一次赋值。当然,像下面这样一行代码也可以搞定:
ul.innerHTML = values.map(value => '<li>${value}</li>').join('');
本节介绍的属性和方法并不是 DOM2 Style 规范中定义的,但与 HTML 元素的样式有关。DOM 一直缺乏页面中元素实际尺寸的规定。IE 率先增加了一些属性,向开发者暴露元素的尺寸信息。这些属性 现在已经得到所有主流浏览器支持
第一组属性涉及偏移尺寸(offset dimensions),包含元素在屏幕上占用的所有视觉空间。元素在页面上的视觉空间由其高度和宽度决定,包括所有内边距、滚动条和边框(但不包含外边距)。以下 4 个属性用于取得元素的偏移尺寸。
其中,offsetLeft 和 offsetTop 是相对于包含元素的,包含元素保存在 offsetParent 属性中。offsetParent 不一定是 parentNode。比如,元素的 offsetParent 是作为其祖先的
元素,因为是节点层级中第一个提供尺寸的元素。图 16-1 展示了这些属性代表的不同尺寸
要确定一个元素在页面中的偏移量,可以把它的 offsetLeft 和 offsetTop 属性分别与 offsetParent的相同属性相加,一直加到根元素。下面是一个例子:
function getElementLeft(element) {
let actualLeft = element.offsetLeft;
let current = element.offsetParent;
while (current !== null) {
actualLeft += current.offsetLeft;
current = current.offsetParent;
}
return actualLeft;
}
function getElementTop(element) {
let actualTop = element.offsetTop;
let current = element.offsetParent;
while (current !== null) {
actualTop += current.offsetTop;
current = current.offsetParent;
}
return actualTop;
}
这两个函数使用 offsetParent 在 DOM 树中逐级上溯,将每一级的偏移属性相加,最终得到元素的实际偏移量。对于使用 CSS 布局的简单页面,这两个函数是很精确的。而对于使用表格和内嵌窗 格的页面布局,它们返回的值会因浏览器不同而有所差异,因为浏览器实现这些元素的方式不同。一般来说,包含在
元素中所有元素都以为其 offsetParent,因此 getElementleft() 和 getElementTop()返回的值与 offsetLeft 和 offsetTop 返回的值相同。
注意 所有这些偏移尺寸属性都是只读的,每次访问都会重新计算。因此,应该尽量减少查询它们的次数。比如把查询的值保存在局部量中,就可以避免影响性能。
因为事件处理程序在现代 Web 应用中可以实现交互,所以很多开发者会错误地在页面中大量使用它们。在创建 GUI 的语言如 C#中,通常会给 GUI 上的每个按钮设置一个 onclick 事件处理程序。这 样做不会有什么性能损耗。在 JavaScript 中,页面中事件处理程序的数量与页面整体性能直接相关。原因有很多。首先,每个函数都是对象,都占用内存空间,对象越多,性能越差。其次,为指定事件处理程序所需访问 DOM 的次数会先期造成整个页面交互的延迟。只要在使用事件处理程序时多注意一些方法,就可以改善页面性能。
只要可行,就应该考虑只给 document 添加一个事件处理程序,通过它处理页面中所有某种类型的事件。相对于之前的技术,事件委托具有如下优点。
最适合使用事件委托的事件包括:click、mousedown、mouseup、keydown 和 keypress。mouseover 和 mouseout 事件冒泡,但很难适当处理,且经常需要计算元素位置(因为 mouseout 会 在光标从一个元素移动到它的一个后代节点以及移出元素之外时触发)
把事件处理程序指定给元素后,在浏览器代码和负责页面交互的 JavaScript 代码之间就建立了联系。这种联系建立得越多,页面性能就越差。除了通过事件委托来限制这种连接之外,还应该及时删除不用的事件处理程序。很多 Web 应用性能不佳都是由于无用的事件处理程序长驻内存导致的。
导致这个问题的原因主要有两个。第一个是删除带有事件处理程序的元素。比如通过真正的 DOM方法 removeChild()或 replaceChild()删除节点。最常见的还是使用 innerHTML 整体替换页面的 某一部分。这时候,被 innerHTML 删除的元素上如果有事件处理程序,就不会被垃圾收集程序正常清理。比如下面的例子:
<div id="myDiv">
<input type="button" value="Click Me" id="myBtn">
</div>
<script type="text/javascript">
let btn = document.getElementById("myBtn");
btn.onclick = function() {
// 执行操作
document.getElementById("myDiv").innerHTML = "Processing...";
// 不好!
};
</script>
这里的按钮在
元素中。单击按钮,会将自己删除并替换为一条消息,以阻止双击发生。这是很多网站上常见的做法。问题在于,按钮被删除之后仍然关联着一个事件处理程序。在
元素上设 置 innerHTML 会完全删除按钮,但事件处理程序仍然挂在按钮上面。某些浏览器,特别是 IE8 及更早版本,在这时候就会有问题了。很有可能元素的引用和事件处理程序的引用都会残留在内存中。如果知道某个元素会被删除,那么最好在删除它之前手工删除它的事件处理程序,比如:
<div id="myDiv">
<input type="button" value="Click Me" id="myBtn">
</div>
<script type="text/javascript">
let btn = document.getElementById("myBtn");
btn.onclick = function() {
// 执行操作
btn.onclick = null; // 删除事件处理程序
document.getElementById("myDiv").innerHTML = "Processing...";
};
</script>
在这个重写后的例子中,设置
元素的 innerHTML 属性之前,按钮的事件处理程序先被删除了。这样就可以确保内存被回收,按钮也可以安全地从 DOM 中删掉。但也要注意,在事件处理程序中删除按钮会阻止事件冒泡。只有事件目标仍然存在于文档中时,事件才会冒泡。
注意 事件委托也有助于解决这种问题。如果提前知道页面某一部分会被使用innerHTML删除,就不要直接给该部分中的元素添加事件处理程序了。把事件处理程序添加到更高层 级的节点上同样可以处理该区域的事件。
另一个可能导致内存中残留引用的问题是页面卸载。同样,IE8 及更早版本在这种情况下有很多问题,不过好像所有浏览器都会受这个问题影响。如果在页面卸载后事件处理程序没有被清理,则它们仍然会残留在内存中。之后,浏览器每次加载和卸载页面(比如通过前进、后退或刷新),内存中残留对象的数量都会增加,这是因为事件处理程序不会被回收。
一般来说,最好在 onunload 事件处理程序中趁页面尚未卸载先删除所有事件处理程序。这时候也能体现使用事件委托的优势,因为事件处理程序很少,所以很容易记住要删除哪些。关于卸载页面时的 清理,可以记住一点:onload 事件处理程序中做了什么,最好在 onunload 事件处理程序中恢复。
注意 在页面中使用 onunload 事件处理程序意味着页面不会被保存在往返缓存(bfcache)中。如果这对应用很重要,可以考虑只在 IE 中使用 onunload 来删除事件处理程序。
事件是 JavaScript 与网页结合的主要方式。最常见的事件是在 DOM3 Events 规范或 HTML5 中定义的。虽然基本的事件都有规范定义,但很多浏览器在规范之外实现了自己专有的事件,以方便开发者更好地满足用户交互需求,其中一些专有事件直接与特殊的设备相关。围绕着使用事件,需要考虑内存与性能问题。例如:
使用 JavaScript 也可以在浏览器中模拟事件。DOM2 Events 和 DOM3 Events 规范提供了模拟方法,可以模拟所有原生 DOM 事件。键盘事件一定程度上也是可以模拟的,有时候需要组合其他技术。IE8及更早版本也支持事件模拟,只是接口与 DOM 方式不同。事件是 JavaScript 中最重要的主题之一,理解事件的原理及其对性能的影响非常重要。
-- 未完待续 --