七、JavaScript 和危险驱动开发

*"In JavaScript, there is a beautiful, elegant, highly expressive language that is buried under a steaming pile of good intentions and blunders."

– Douglas Crockford, JavaScript: The Good Parts*

这段引文从本质上表达了优化 JavaScript 代码的意义。

开发人员常常被最新的闪亮特性或故意或自命不凡地展示自己能力的需要所吸引,有时会陷入一种神秘的清醒睡眠状态,通过这种状态,他会被炫耀过于复杂的代码的需要或使用最新特性的欲望所征服,尽管他内心深处知道,这意味着他将不得不牺牲计算机程序的长期稳定性和效率。这种构建应用程序的方式可以称为“危险驱动开发”。JavaScript 有许多非常糟糕的部分,但有足够多的好部分来弥补不好的部分。话虽如此,危险驱动开发的问题在于,开发人员倾听 JavaScript 可怕部分的警报,却牺牲了最终用户的满意度。

在本章中,我们将介绍一些 JavaScript 最好和最差的部分,特别是那些与代码效率和总体性能有关的部分,以及开发人员应该如何始终编写安全、可靠和高效的 JavaScript 代码,即使这样做没有编写最新的 JavaScript 代码那么迷人。

因此,我们将涵盖以下几点:

  • 全局对象和局部变量
  • 避免使用不好的习语,注意那些非常糟糕的部分
  • 有效地使用 DOM
  • 构建和加载 JavaScript 应用程序

全局对象和局部变量

JavaScript 的全局对象是所有全局变量的容器。任何编译单元的任何顶级变量都将存储在全局对象中。如果没有正确使用,全局对象是 JavaScript 最糟糕的部分之一,因为它很容易被不需要的变量膨胀,并且在严重依赖 JavaScript 默认行为时,开发人员可能会在不知不觉中滥用它。以下是这类滥用的两个例子:

  • 当运行一个简单的代码,如total = add(3, 4);时,实际上,您正在全局对象中创建一个名为total的属性。这对性能不是一件好事,因为您可能会在堆上保留许多变量,而其中大多数变量只在应用程序执行的某一时刻需要。
  • 当忽略使用new关键字来创建对象时,JavaScript 将执行普通函数调用,并将this变量绑定到全局对象。这是一件非常糟糕的事情,不仅出于安全原因,因为可能会破坏其他变量,而且出于性能原因,因为开发人员可能认为他正在将值存储在对象的属性中,而实际上,他正在将这些值直接存储在全局对象中,因此,如果他已经在代码的其他地方实例化了所需的对象,则会膨胀全局对象并将这些值存储在两个不同的内存空间中。

为了有效地使用全局对象,您应该将所有变量包装在单个应用程序对象中,根据需要对其应用函数,在应用于应用程序对象的函数中强制执行类型验证,以确保正确实例化它,并通过将全局对象视为一种不可变对象(带有一些作为应用程序对象的副作用函数)来访问全局对象。

避免全局变量

可以访问全局变量,以便在应用程序的任何范围内读取或写入。他们是必要的邪恶。实际上,任何应用程序都需要组织其代码结构,以便处理输入值并返回适当的响应或输出。当代码没有很好地组织时,当代码的任何部分因此可以修改应用程序其余部分的全局状态并修改程序的整体预期行为时,问题和 bug 就会开始出现。

首先,组织不良的代码意味着脚本引擎或解释器在试图查找变量名时要做更多的工作,因为它必须经过许多作用域,直到在全局作用域中找到它为止。

其次,组织不良的代码意味着内存中的堆始终大于运行相同功能所需的堆,因为在脚本执行结束之前,许多多余的变量将保留在内存中。

这个问题的解决方案是尽可能避免使用全局变量,并且几乎总是使用带名称空间的变量。此外,使用局部作用域变量还有一个额外的优点,即确保在局部作用域丢失时自动取消设置变量。

以下示例(chap7_js_variables_1.html)向我们展示了全局变量的使用可能会产生很大的问题,并且最终会非常低效,尤其是在日益复杂的应用程序中:

<!DOCTYPE html>

<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JS Variables</title>

    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body onload="myJS()" style="margin:0;">

<div id="main"></div>

<script type="text/javascript">

    function Sum(n1, n2)
    {
        // These will be global when called from the myJSAgain() function.
        this.number1 = Number(n1);
        this.number2 = Number(n2);

        return this.number1 + this.number2;
    }

    function myJS()
    {
        // Side-effect: creates a global variable named 'total'.
        total = new Sum(3, 4);
        alert( window.total ); // Object

        // Side-effect: modifies the global variable named 'total'.
        myJSAgain();

        // Global 'total' variable got clobbered.
        alert( window.total ); // 3
    }

    function myJSAgain()
    {
        // Missing 'new' keyword. Will clobber the global 'total' variable.
        total = Sum(1, 2);

        // There are now two sets of 'number1' and 'number2' variables!
        alert( window.number2 ); // 2
    }

</script>

</body>

</html>

简单的解决方案是使用模块和名称空间来组织代码。通过将所有变量和函数包装在单个应用程序对象中,以便在设置或修改变量时强制执行特定的关联行为,并从全局对象中保留应用程序的机密,可以轻松实现这一点。闭包还可用于从全局范围隐藏重要值。让我们修改前面的脚本,这次记住名称空间:

function myJS()
    {
        function MyJSObject(n1, n2)
        {
            var number1 = Number(n1);
            var number2 = Number(n2);

            return {
                set_number1: function (n1) {
                    number1 = Number(n1);
                },
                set_number2: function (n2) {
                    number2 = Number(n2);
                },
                sum: function ( ) {
                    return number1 + number2;
                }
            };
        }

        var oApp1 = new MyJSObject(3, 4);
        alert( oApp1.sum() ); // 7

        var app2 = MyJSObject(1, 2);
        alert( app2.sum() ); // 3
        alert( oApp1.sum() ); // 7
        alert( window.number1 ); // undefined
    }

通过以这种方式使用let关键字,开发人员仍然可以获得正确的值,同时避免重击全局变量和无意中修改整个应用程序的全局状态,即使他忘记使用new关键字。此外,全局对象通过避免不必要的膨胀和减少命名空间查找所花费的时间来查找被调用函数或存储值,从而保持精简和高效。

计算局部变量

正如我们在前面的示例中所看到的,在局部变量声明之前省略letvar关键字会使其成为全局变量。在所有情况下,函数和对象都不能通过修改其局部范围之外的变量值来产生函数副作用。因此,在函数或结构范围内声明变量时,应始终使用let关键字。例如,在大多数浏览器中,只要将全局变量移动到在本地循环中使用它们的函数的本地范围,性能就会提高近 30%。

另外,当使用let关键字声明变量时,您可以使用 block scope,应该尽可能多地使用它。因此,循环完成后,for循环中使用的变量将不在范围内。这允许更好的变量封装和隔离,更有效的垃圾收集和更好的总体性能。

轻松跟踪变量声明的一种方法是使用 JavaScript 的严格模式。我们将在本章的下一节中更详细地解释 ES5 功能。

避免使用不好的习语,注意那些非常糟糕的部分

与大多数基于 C 的编程语言一样,最好避免某些通常导致代码效率低下和 bug 的错误习惯用法。

坏习语

以下是一些应该被认定为有问题的坏习惯用法:

  • 在 JavaScript 中,在第一次使用时声明变量是一个坏主意,因为开发人员很可能会给出变量的全局范围,以便以后访问它。最好从项目一开始就组织代码,并使用直观且有意义的名称空间,以便在整个应用程序中组织变量的使用。
  • 在所有情况下,都应避免以不明确的方式使用结构,或以并非最初预期的方式使用结构。例如,让switch语句失效或在条件语句的条件内为变量赋值都是非常糟糕的习惯用法,永远不应该使用。
  • 依靠自动分号插入是一个坏主意,可能会导致代码误解。它应该永远避免。
  • 在数组和对象中拖尾逗号是个坏主意,因为某些浏览器无法正确解释它们。
  • 当使用带有一个命令行的block语句时,应始终避免省略大括号。

当然,构造代码的艺术首先充分依赖于对结构本身的良好了解。JavaScript 中有一些不好的构造,应该随时避免。让我们花点时间看一些。

错误的构造–with 语句

with语句就是这些糟糕构造的一个例子。with语句的初衷是帮助开发人员访问对象属性,而不必每次都键入整个名称空间。这是一种use语句,因为我们可能会在 PHP 等其他语言中遇到它。例如,您可以通过以下方式使用with语句:

foo.bar.baz.myVar    = false;
foo.bar.baz.otherVar = false;

with (foo.bar.baz) {
    myVar = true;
    otherVar = true;
}

这里的问题是,当我们查看这段代码时,我们不能完全确定引擎是否正在碰撞名为myVarotherVar的全局变量。处理长名称空间的最佳方法是将它们分配给局部变量,然后使用它们:

let fBrBz = foo.bar.baz;

fBrBz.myVar = true;
fBrBz.otherVar = true;

错误的构造–eval 语句

另一个坏消息是eval()声明。这种说法不仅效率低下,而且在大多数情况下都是无用的。事实上,人们通常认为使用eval()语句是处理所提供字符串的正确方法。但事实并非如此。您可以简单地使用数组语法来做同样的事情。例如,我们可以用以下方式使用eval()语句:

function getObjectProperty(oString)
{
    let oRef;
    eval("oRef = foo.bar.baz." + oString);
    return oRef;
}

要获得显著的速度提升(从 80%提高到 95%),可以使用以下代码替换以前的代码:

function getObjectProperty(oString)
{
    return foo.bar.baz[oString];
}

错误构造–try-catch-finally 构造

需要注意的是,应该避免在性能关键函数中使用 try-catch-finally 构造。原因与此构造必须创建一个运行时变量来捕获异常对象有关。这种运行时创建在 JavaScript 中是一种特殊情况,并非所有浏览器都能以相同的效率处理它,这意味着这种操作可能会在应用程序的关键路径上造成问题,特别是在性能至关重要的情况下。您可以轻松地用简单的测试条件替换此构造,并在将充当应用程序错误注册表的对象中插入错误消息。

避免低效循环

在 JavaScript 中编码这些类型的结构时,嵌套循环是要避免的第一件事。

另外,大多数情况下,使用for-in循环不是一个好主意,因为引擎必须创建可枚举属性的完整列表,这不是很有效。大多数情况下,for循环将完美完成工作。在应用程序的关键路径上发现的性能关键型功能尤其如此。

此外,在处理循环时要注意隐式对象转换。通常,乍一看,当反复访问对象上的length属性时,很难看到引擎盖下发生了什么。但在某些情况下,JavaScript 会在循环的每次迭代中创建一个对象,而该对象不是事先专门创建的。请参见以下代码示例(chap7_js_loops_1.html

function myJS()
{
    let myString = "abcdefg";

    let result = "";

    for(let i = 0; i < myString.length; i++) {
        result += i + " = " + myString.charAt(i) + ", ";
        console.log(myString);
    }

    alert(result);
}

在 Google Chrome 的开发者工具中查看控制台结果时,我们得到以下结果:

Seven string objects were created in all, one on each iteration of the 'for' loop

实际上,JavaScript 引擎在循环的每次迭代中都会创建一个 string 对象。为了避免这个问题,我们将在进入循环(chap7_js_loops_2.html之前显式实例化一个 string 对象:

function myJS()
{
    let oMyString = new String("abcdefg");

    let result = "";

    for(let i = 0; i < oMyString.length; i++) {
        result += i + " = " + oMyString.charAt(i) + ", ";
        console.log(oMyString);
    }

    alert(result);
}

新脚本的结果如下所示:

Only one object is created and is shown seven times

控制台的日志现在七次向我们显示相同的对象。很容易理解这是如何优化循环性能的,特别是当循环可能导致引擎创建数十个、数百个甚至数千个对象以完成其工作时。

过梁和严格模式

JavaScript 中还有其他一些不好的部分,在某些情况下可能会导致性能问题。为了关注所有这些不好的部分并用 JavaScript 的好部分替换它们,强烈建议您使用一个工具,该工具允许您在第一次运行代码之前就发现代码中的问题。这些工具是过梁。

JSLintESLintPrettier是可以帮助您发现并修复松散代码的工具,在某些情况下甚至可以自动修复。一些 linter,例如ESLint,甚至可以通过减少语句的数量来帮助您改进代码,通过用函数和承诺替换它们来减少结构的嵌套,识别圈复杂度,即测量单个结构代码所具有的分支数,并可能允许您用更多功能代码替换这些结构代码,我们将在下一章中看到。您可以在以下地址找到这些工具:

使用 linter 的另一个好处是,它们使 JavaScript 代码与 ES5 的严格模式兼容。只要可能,应使用严格模式。为了使用它,只需在脚本或函数的开头添加一个use strict;语句即可。使用 strict 模式的诸多好处包括简化变量名到变量定义的映射(优化名称空间查找),禁止使用with语句,通过使用eval语句防止意外地将变量引入当前范围,防止“装箱”(强制实例化)this变量,当它不包含对象并传递给函数时,这将大大降低性能成本,并消除大多数性能障碍,如访问函数调用方的变量和在运行时“遍历”JavaScript 堆栈。

Packt Publishing 出版了许多关于 JavaScript 性能的优秀书籍和视频,我强烈建议您阅读它们,以便掌握所有这些优秀工具。

有效地使用 DOM

文档对象模型DOM)操作仍然是 JavaScript 中最昂贵的操作之一。事实上,重新喷漆或回流焊应保持在最低限度,以避免总体性能问题。

尽管如此,在需要 DOM 操作并导致重绘或回流时,为了保持脚本的速度,还必须避免其他陷阱。这些陷阱涉及如何修改文档树、如何更新不可见元素、如何更改样式、如何搜索节点、如何管理从一个文档到另一个文档的引用以及在检查大量节点时如何执行。

修改文档树

重要的是要知道,在遍历树时进行修改是非常昂贵的。最好创建一个要处理的临时集合,而不是在树的所有节点上循环时直接修改树。

实际上,最好的方法是使用一个未显示的 DOM 树片段,一次进行所有更改,然后一起显示它们。以下是如何实现这一目标的理论示例:

function myJS()
{
    let docFragment = document.createDocumentFragment();
    let element, content;

    for(let i = 0; i < list.length; i++) {
        element = document.createElement("p");
        content = document.createTextNode(list[i]);
        element.appendChild(content);
        docFragment.appendChild(element);
    }

    document.body.appendChild(docFragment);
}

也可以克隆元素,以便在触发页面回流之前完全修改它。以下代码显示了如何执行此操作:

function myJS()
{
    let container = document.getElementById("container1");

    let cloned = container.cloneNode(true);

    cloned.setAttribute("width", "50%");

    let element, content;

    for(let i = 0; i < list.length; i++) {
        element = document.createElement("p");
        content = document.createTextNode(list[i]);
        element.appendChild(content);
        cloned.appendChild(element);
    }

    container.parentNode.replaceChild(cloned, container);
}

通过使用这些技术,开发人员可以避免 JavaScript 中一些性能方面最昂贵的操作。

更新不可见元素

另一种技术是将元素的显示样式设置为none。因此,当其内容被更改时,它将不需要重新绘制。下面是一个代码示例,演示了如何执行此操作:

function myJS()
{
    let container = document.getElementById("container1");

    container.style.display = "none";
    container.style.color = "red";
    container.appendChild(moreNodes);
    container.style.display = "block";
}

这是一种在避免多次重绘或回流的同时修改节点的简单快捷的方法。

改变风格

与我们提到的在遍历 DOM 树时如何一次修改多个节点的方式相同,可以同时对文档片段进行多个样式更改,以尽量减少重绘或回流的次数。以以下代码段为例:

function myJS()
{
    let container = document.getElementById("container1");
    let modifStyle = "background: " + newBackgound + ";" +
        "color: " + newColor + ";" +
        "border: " + newBorder + ";";
    if(typeof(container.style.cssText) != "undefined") {
        container.style.cssText = modifStyle;
    } else {
        container.setAttribute("style", modifStyle);
    }
}

如我们所见,任何数量的样式属性都可以通过这种方式修改,以便只触发一次重新绘制或回流。

搜索节点

在整个 DOM 中搜索节点时,最好使用 XPath。通常使用for循环,如下例所示,其中正在搜索h2h3h4元素:

function myJS()
{
    let elements = document.getElementsByTagName("*");

    for(let i = 0; i < elements.length; i++) {
        if(elements[i].tagName.match("/^h[2-4]$/i")) {
            // Do something with the node that was found
        }
    }
}

可以使用 XPath 迭代器对象来获得相同的结果,而不是使用这个for循环,只不过效率要高得多:

function myJS()
{
    let allHeadings = document.evaluate("//h2|//h3|//h4", document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
    let singleheading;

    while(singleheading = allHeadings.iterateNext()) {
        // Do something with the node that was found
    }
}

在包含一千多个节点的 DOM 中使用 XPath 肯定会对性能产生影响。

检查大量节点

另一个要避免的陷阱是试图同时检查大量节点。最好将搜索范围缩小到特定的节点子集,然后使用内置方法查找所需的节点。例如,如果我们知道我们正在寻找的节点可以在特定的div元素中找到,那么我们可以使用以下代码示例:

function myJS()
{
    let subsetElements = document.getElementById("specific-div").getElementsByTagName("*");

    for(let i = 0; i < subsetElements.length; i++) {
        if(subsetElements[i].hasAttribute("someattribute")) {
            // Do something with the node that was found...
            break;
        }
    }
}

因此,与在大量节点中搜索相比,此搜索将更加高效,返回结果的速度也会更快。

管理从一个文档到另一个文档的引用

在 JavaScript 中管理对许多文档的引用时,在不再需要文档时销毁这些引用非常重要。例如,如果某个文档位于弹出窗口、框架、内联框架或对象中,并且该文档已被用户删除,则该文档的节点将保留在内存中,并将继续在 DOM 中膨胀。销毁这些未使用的引用可以显著提高性能。

缓存 DOM 值

当重复访问对象时,将其存储在局部变量中以便反复使用会更加有效。例如,以下代码生成分组 DOM 值的本地副本,而不是单独访问每个值:

function myJS()
{
    let group = document.getElementById("grouped");

    group.property1 = "value1";
    group.property2 = "value2";
    group.property3 = "value3";
    group.property4 = "value4";

    // Instead of:
    //
    // document.getElementById("grouped").property1 = "value1";
    // document.getElementById("grouped").property2 = "value2";
    // document.getElementById("grouped").property3 = "value3";
    // document.getElementById("grouped").property4 = "value4";

}

这样做可以避免与动态查找相关的性能开销。

构建和加载 JavaScript 应用程序

在考虑如何构造和加载 JavaScript 应用程序时,记住某些重要原则很重要。

最大限度地减少成本高昂的操作

在 JavaScript 中最昂贵的操作是:

  • 通过网络 I/O 请求资源
  • 由于动态内容更改(如使元素可见),显示网页的重绘(也称为重绘)
  • 回流,这可能是由窗口大小调整引起的
  • 对页面样式的 DOM 操作或动态更改

显然,底线是所有这些操作都应该保持在最低限度,以保持良好的总体性能。当使用执行速度太慢的脚本时,这些是谷歌 Chrome 的时间线工具需要寻找的最重要的元素,可以通过 Chrome 的开发者工具访问,如本书第 1 章更快的 Web–入门所述。

清理、缩小和压缩资源

当然,排除捆绑包中未使用的导出(也称为树抖动),通过清理死代码缩小脚本,然后压缩脚本文件,对于 JavaScript 性能来说总是一件好事,尤其是在处理网络延迟时。在帮助您实现这一目标的非常好的工具中,有网页包https://webpack.js.org/ ),结合UglifyJS插件(https://github.com/webpack-contrib/uglifyjs-webpack-plugin 及其压缩插件( https://github.com/webpack-contrib/compression-webpack-plugin ),它将树震动您的代码,通过删除任何未使用或死代码缩小您的脚本,并压缩生成的文件。

当使用树摇动的第三方依赖项时,将主要感受到树摇动的优势。为了更好地理解如何使用这些工具,强烈建议您阅读以下教程:

另一个优化 JavaScript 代码(树抖动、缩小和压缩)的伟大工具是谷歌的闭包,尽管它是用 Java 构建的。您可以在以下地址找到此工具:https://developers.google.com/closure/

加载页面资源

在 HTML 文档的 head 部分加载脚本文件时,避免阻塞页面的呈现非常重要。脚本应始终加载在正文部分的末尾,以确保渲染不会依赖于获取所需 JavaScript 文件时可能发生的网络延迟。

另外,重要的是要知道,最好将内联脚本放在 CSS 样式表之前,因为 CSS 通常会阻止脚本在下载完成之前运行。

此外,拆分脚本文件有效负载和异步下载脚本都是在构建 JavaScript 应用程序以提高性能时必须考虑的技术。

此外,Steve Souders已经写了很多关于提高网页性能的好书和文章,您应该阅读它们,以获得关于这些非常重要的技术和原则的更多信息(https://stevesouders.com/ )。

缓存页面资源

另一件需要记住的重要事情是,我们将在第 9 章提高 Web 服务器性能中更详细地看到,服务器端和客户端的缓存技术将帮助您显著提高 Web 页面的性能。利用这些技术可以减少反复获取相同 JavaScript 文件所需的请求数量。

总结

在本章中,我们讨论了 JavaScript 的一些最好和最坏的部分,特别是可能导致性能问题的陷阱。我们已经看到,编写安全、可靠和高效的 JavaScript 代码可能不像使用最新的闪亮特性那样令人兴奋,也不像惰性编码那样诱人,但肯定会帮助任何 JavaScript 应用程序成为更快 Web 的一部分。

在下一章中,我们将看到 JavaScript 如何越来越成为一种功能性语言,以及这种编程范式如何在不久的将来成为性能的载体。我们将快速查看即将推出的语言特性,这些特性将有助于提高 JavaScript 应用程序的性能。