# 高性能网站建设进阶指南第一部分

# Javascript性能

# 理解Ajax性能(性能优化前做权衡)

  • 项目三角形

    指短时间、高质量和低成本。往往只能满足其中任意两点

    算法选择运行时间快、占用内存小,抢占了市场,牺牲了代码高质量

  • 权衡影响开发方式

    增量式开发:采用任务调度和分阶段策略,系统各部分以不同时间和速度进行开发

    大爆炸集成:直接开发整个系统

  • 关注程序性能时,需斟酌权衡因素如改善代码质量、避免产生Bug的可能性(代码质量要优于程序性能)

  • 优化原则

    优化的目的是为了降低程序的整体开销,先做评估,将重点放在对程序整体开销影响最大的部分,一般是花在循环上。

  • 浏览器花费时间大多数用在DOM上

# 创建快速响应的Web应用(页面响应快准则;内存、GC对响应时间的影响)

  • “足够快”准则

    0.1秒内:用户直接操作UI中对象的感觉极限。对用户是即时的

    1秒内:用户随意地在计算机指令空间进行操作而无需过度等待的感觉极限。对用户感觉到计算机处于对指令的“处理中”。

    超过10秒:用户专注于任务的极限。需要一个百分比完成指示器,以及一个方便用户中断操作且有清晰标识的方法

  • 一些解决方案

    • 线程处理WebWorker

    • 线程处理不可用,可使用定时器setTimeout替代,但有可能不可行,需要额外逻辑处理,不是简单直接就能进行

  • 插曲:xmlHttpRequest有同步模式和异步模式,同步模式下其行为在主线程中执行,影响用户交互的延迟时间,切勿使用同步模式

  • 内存使用对响应时间的影响

    应用程序线程与同时运行的GC的互斥性。垃圾收集器要完成其工作,需要在一段时间(stop the world)内防止所有其他线程访问它正在处理的堆空间

    操作系统提供两种内存,物理内存(RAM)和虚拟内存(硬盘),内存分页是它们的最小内存单元。

    分页导致的性能降低和GC停顿不同之处,分页是导致全面、无处不在的迟钝,而GC停顿是分散、独立的,且随时间而增加停顿时间

    解决方案:delete删除对象上的无用属性;删除DOM上无用元素

# 拆分初始化负载(非初始化负载延迟加载)

  • 好处:减少初始化下载量,缩短响应时间
  • 拆分的标准:onload事件作为分割线,在此之前执行的是初始化代码,在此之后执行的是后加载代码
  • 未定义标识符(竞争状态)问题:为每个延迟加载的函数创建一个桩函数(与原函数名称相同但函数体为空),保证用户在函数下载完成前进行交互不报错无响应;或者,记录被触发的桩函数,当函数下载完毕即执行

# 无阻塞加载脚本(允许外部脚本与其他资源并行下载)

是否需要重构代码 要求同源 是否阻塞渲染 是否阻塞onload | 是否保证顺序执行
正常script(高版本支持并行下载,是否所有资源类型待验证)
xhr + eval
(R)xhr + 创建script元素注入脚本内容
iframe元素请求内联脚本的HTML文档
(R)动态创建script
(R)script标签defer属性
document.write写入脚本元素(仅旧版本支持)

注:以上几种解决方案需要在最新浏览器Chrome重新实践

# 整合异步脚本(并行下载下保证存在依赖性的外部脚本、行内代码之间顺序执行)

  • 问题1:无阻塞加载脚本部分解决方案存在异步问题,在行内代码使用到外部脚本的场景中容易出现竞争状态

  • 解决方案:异步加载时保持执行顺序

    • 硬编码回调:外部脚本调用行内脚本中的函数(与“动态创建script”方案配合)

    • window.onLoad:监听该事件,执行行内代码,利用部分无阻塞加载脚本方案(包括“动态创建script|script标签defer属性|iframe元素请求内联脚本的HTML文档|document.write”)会阻塞onload这点。缺点是行内代码延迟执行,因为外部脚本可能已经加载执行了,但是当中又加载了其他资源;仅适用于渲染无阻塞但阻塞onload的加载脚本方式

    • 定时器:定时轮询外部脚本是否加载。缺点是时间间隔设置太短增加开销,设置太长产生不必要的延时;需要处理外部脚本加载失败时导致轮询死循环,外部脚本特定标识变化的情况,代码维护成本更高

    • (R)script.onLoad:监听外部脚本加载完毕事件,执行行内脚本代码。此方案维护简单(与“动态创建script”方案配合)

    • 降级使用script标签:使用script标签包含外部脚本和行内代码,如

      <script src="./outer.js">
        // inner code
      </script>
      
      <!-- 与“动态创建script”方案配合 -->
      <script>
        // inner code...
        function main(){
          // inner code...
        }
      
        var script = document.createElement('script');
        script.src = './outer.js';
        script.innerHTML = 'main();';
        document.querySelector('head').appendChild(script);
      </script>
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      只用一个script标签,更干净;行内代码对外部脚本的依赖一目了然,更清晰;若外部脚本加载失败,内部代码不执行,避免未定义标识符报错,更安全。无需事件监听,开销小。不足之处是浏览器并不支持该写法,会执行外部脚本,忽略行内代码。为此需要在外部脚本添加额外代码进行处理,

      var targetScript = document.querySelector("script[src='./outer.js']");
      if(targetScript){
        eval(targetScript.innerHTML);
      }
      
      1
      2
      3
      4
  • 问题2:当有多个外部脚本相互依赖时,也会出现竞争状态,如何解决?

  • 解决方案

    • 管理XHR注入(仅限于脚本与页面同源):
    // 保证顺序的异步加载的脚本队列
    scriptQueues = [];
    // 加载管理xhr注入的脚本
    function loadScriptXhrInjection(url, onload){
      var len = scriptQueues.length;
      var scriptCfg = {
        response: null,
        onload,
        done: false
      };
      scriptQueues[len] = scriptCfg;
      var xhrObj = new XMLHttpRequest();
      xhrObj,onreadystatechange = function(){
        if(xhrObj.readyState === 4){
          scriptQueues[len].response = xhrObj.responseText;
          injectScript();
        }
      }
      xhrObj.open('GET',url,true);
      xhrObj,send();
    }
    // 注入脚本
    function injectScript(){
      var len = scriptQueues.length;
      for(var i = 0; i < len; i++){
        var script = scriptQueues[i];
        if(!script.done){
          if(!script.response){
            // 停止剩余脚本的注入
            break;
          } else {
            // 优化项,重复注入脚本问题?
            eval(script.response);
            script.done = true;
          }
        }
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    • 动态创建script或document写入脚本(支持脚本与页面不同源):
    // 加载动态创建的脚本(仅限于FireFox、Opera浏览器,是顺序执行)
    // 优化项,是否可以管理XHR注入一样,做一层管理?
    function loadScriptDOMElement(url, onload){
      var script = document.createElement('script');
      script.src = url;
      if(onload){
        script.onloadDone = false;
        script.onload = onload;
        script.onreadystatechange = function(){
          if(['loader','complete'].includes(script.readyState) && !script.onloadDone){
            script.onloadDone = true;
            script.onload();
          }
        }
      }
      document.querySelector('head').appendChild(script);
    }
    // 加载文档写入的脚本(默认顺序执行)
    function loadScriptDocWrite(url, onload){
      document.write(`<script src='${url}'></script>`);
      if(onload){
        window.addEventListener('load', onload, false);
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
  • 问题1、2的综合解决方案(当有多个外部脚本相互依赖,且后有行内代码依赖外部脚本时,该场景下需要我们整合前面的方案出来)

    单个脚本:直接调用loadScriptDOMElement即可

    多个脚本:同源使用loadScriptXhrInjection,异源使用loadScriptDocWrite或"管理动态创建脚本注入"

    function loadScripts(urls, onload, dependencyIdx){
      var len = urls.length;
      var diff = false;
      for(var i = 0; i < len; i++){
        if(!sameDomain(urls[i])){
          diff = true;
          break;
        }
      }
      var loadFunc = loadScriptXhrInjection;
      if(diff){
        // 优化项,此处建议使用管理动态创建脚本注入
        loadFunc = loadScriptDocWrite;
      }
      for(var i = 0; i < len; i++){
        loadFunc(urls[i], dependencyIdx === i? onload: null);
      }
    }
    function sameDomain(url){
      var diff = false;
      var current = location.protocol + '//' + location.host;
      if(url.indexOf(current) === -1){
        diff = true;
      }
      return !diff;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26

# 布置行内脚本

  • 问题:行内脚本阻塞资源并行下载和逐步渲染(浏览器已优化)

  • 解决方案

    • 行内脚本置于文档底部

    • 异步(使用定时器或onload事件监听)执行行内脚本

    • 脚本声明defer属性

  • 在同优先级下,样式应用的顺序按照代码书写的顺序,而不按照下载快慢的顺序

  • 问题:样式表阻塞行内脚本(浏览器已优化)

  • 原因:行内脚本可能含有依赖于样式表中样式的代码,如getElementByClassName

  • 解决方案

    • 调整行内脚本位置,使其不出现在样式表和任何其他资源之间

# 编写高效的Javascript

  • 性能优化除了关注页面加载时间,也要关注交互响应速度

  • 没有银弹:没有绝对有效的方式

  • 函数执行时查找变量的过程及其对性能的影响

    函数执行时,JS引擎需要解析函数里的标识符。解析过程是检查作用域链中的每个对象,直至找到指定的标识符。查找从作用域链中的第一个对象开始,这个对象就是包含该函数局部变量的活动对象(包含了this、arguments、命名参数、该函数所有的局部变量),若在活动对象中没找到标识符,则继续在作用域链的下一个对象里查找标识符,一旦找到,查找结束。

    在作用域链中查找的对象个数直接影响标识符解析的性能,标识符在作用域链中的位置越深,查找和访问它所需的时间就越长,若作用域管理不当,对脚本执行时间带来负面影响

  • 编写高效Javascript的建议

    使用局部变量:存取非局部变量比局部变量耗时要多,若存取非局部变量的操作超过一次,则为了降低性能损耗,将其存储到局部变量中。另外,未使用var声明的变量默认为全局变量。(变量在作用域链中被访问越深,脚本执行更耗时,但对使用V8 JS引擎的浏览器的影响是微乎其微)

    避免使用增长作用域链的结构:比如with语句(废弃)、try-catch语句(对使用V8 JS引擎的浏览器的影响是微乎其微)

    数组、对象数据的存取能用字面量、局部变量替代的则用:数组、对象需要根据索引或属性查找数据存储的位置,开销相较于字面量、变量要大,且随着数据结构深度增加,对数据存取速度影响也变大。(实际在Chrome浏览器实践中体现并不明显,即使数据很大,可能已经做了优化)

    // 这是一个遍历拥有一万个元素的数组示例,其中a段将数组长度用局部变量抽出,b段则每次遍历时重新获取一次。按理a段执行时间应该比b段短,然而在V8引擎中并非如此,反而慢了
    const len = 10000;
    const initialValue = '';
    const arr = new Array(len).fill(initialValue, 0 , len);
    // const arr = Array.from(new Array(len),function(){return initialValue});
    
    let m = 0;
    console.time('a');
    for(let j = 0, len = arr.length; j < len; j++){
      m++;
    }
    console.timeEnd('a');
    
    let n = 0;
    console.time('b');
    for(let k = 0; k <  arr.length; k++){
      n++;
    }
    console.timeEnd('b');
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19

    慎用HTMLCollection对象:对其进行存取操作,会重新查询DOM匹配节点。若操作频繁,可以将操作的数据(比如读取length)存储在局部变量中或者将其成员存储到一个数组再操作。总之,操作DOM对象的开销要比非DOM对象要大

    流控制

    • 分支比较多时,可以将if单层改为多层if嵌套(else不进行条件判断),或者使用switch语句代替if语句(各大最新版本的浏览器已经做到第N层与第一层的执行时间几乎接近)。套路是分支少用if,分支多用switch,更大量查询时用数组或对象映射,保证代码可读性,易维护性

    • 使用循环时,建议将循环变量递减到0,而不是递增到数组长度,使结束条件变成是否为0(自动类型转换为false),若是则停止,这减少了比较操作。

      数组方法indexOf的耗时可能比普通循环还长。

      使用for-in语句比普通循环(for、while、do-while)慢,因为需要从一个对象解析出可枚举属性,它会遍历整个原型链,增加耗时,降低循环性能。

      大数组优化手段,展开循环(又称Duff策略),即通过限制循环的次数减少开销,意味每次循环完成多次循环的工作。示例如下,

      // 8是最佳数值,不是任意取的
      var iterations = Math.ceil(values.length / 8);
      var startAt = values.length % 8;
      var i = 0;
      do {
        switch(startAt){
          case 0: process(values[i++]);
          case 7: process(values[i++]);
          case 6: process(values[i++]);
          case 5: process(values[i++]);
          case 4: process(values[i++]);
          case 3: process(values[i++]);
          case 2: process(values[i++]);
          case 1: process(values[i++]);
        }
      } while(--iterations > 0)
      
      // 利用分而治之思想对其进一步优化,将switch语句从do-while语句抽离
      var iterations = Math.floor(values.length / 8);
      var leftOver = values.length % 8;
      var i = 0;
      if(leftOver > 0){
        do {
          process(values[i++])
        } while(--leftOver > 0);
      }
      do {
          process(values[i++]);
          process(values[i++]);
          process(values[i++]);
          process(values[i++]);
          process(values[i++]);
          process(values[i++]);
          process(values[i++]);
          process(values[i++]);
      } while(--iterations > 0)
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36

    字符串操作

    • 字符串连接
    // 性能总是最佳的
    var txt = 'Hello';
    txt += ' ';
    txt += 'World!';
    // 在很久很久以前,使用数组join方法会比直接拼接快,现在肯定不是了
    var buffer = [], i = 0;
    buffer[i++] = 'Hello';
    buffer[i++] = ' ';
    buffer[i++] = 'World!';
    var txt = buffer.join('');
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    • 字符串裁剪
    function trim1(text) {
      return text.replace(/^\s+|\s+$/g,"");
    }
    function trim2(text) {
      return text.replace(/^\s+/,"").replace(/\s+$/,"");
    }
    function trim3(text) {
      text = text.replace(/^\s+/, "");
      for (var i = text.length - 1; i >= 0; i--) {
        if(/\s/.test(text.charAt(i))) {
          text = text.substring(0, i + 1);
          break;
        }
      }
      return text;
    }
    // 最佳
    String.prototype.trim
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18

    浏览器限制JS可运行最长时间(担心页面冻结而出现假死现象),可能以执行语句的数量(IE)或控制JS引擎执行总时间(火狐浏览器)或内存不足(Google浏览器)作为判断条件。使用定时器或时间分片(requestIdleCallback)或其他手段(优化代码,如是否使用过多DOM操作、循环还是递归)绕开限制,避免浏览器弹出中止运行脚本运行的警告。

    // 定时器处理数组元素
    function chunk(array, func, context){
      var arr = array.concat();
      setTimeout(function(){
        var item = arr.shift();
        func.call(context, item);
        if(arr.length > 0){
          // 使用定时器,调用本函数处理下一个元素
          setTimeout(arguments.callee, 100)
        }
      }, 100)
    }
    // 定时器排序数组元素
    function sort(arr, onComplete){
      var pos = 0;
      (function(){
        var j, value;
        for(j = arr.length; j > pos; j--){
          if(arr[j] < arr[j - 1]){
            value = arr[j];
            arr[j] = arr[j - 1];
            arr[j - 1] = value;
          }
        }
        pos++;
        if(pos < arr.length){
          // 使用定时器,调用匿名自调用函数排序下一个数组元素
          setTimeout(argument.callee, 10);
        }else{
          onComplete();
        }
      })();
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33