Skip to content

前端性能优化

x

前言

性能优化意义何在

  • 高性能 → 用户参与度 & 用户留存 → 高转换率 & SEO 排名 → 业务收益
  • 业界经验:WPO Stats 性能优化案例库

优化做些什么?

  • First Request
  • Fetch Resources
  • Decompress, Parse/Compile, Render

优化的标准

  • Loading: 1000ms
  • Finger Down: max 100ms
  • Animation: max 6ms
  • Finger Up: 50ms

优化方法大纲

  • 性能优化指标与测量工具
  • 渲染优化
  • 代码优化
  • 资源优化
  • 构建优化
  • 传输加载优化
  • 更多流行优化技术

性能指标与测量工具

性能优化指标:加载

  • Waterfall 资源加载

    • 如图 x
    • 1) 资源调度阶段: 排队等
    • 2) 连接开始阶段: DNS 查找、初始化 HTTP 连接、SSL 协商等
    • 3) 请求/响应阶段: TTFB、Content 下载等
  • 基于 HAR 存储与重建性能信息

    • 保存为 HAR (HTTP Archive format) 文件
    • 用性能分析工具查看
  • Lighthouse

    • 如图 pic
    • First Contentful Paint: 从白屏到真正出现内容
    • Speed Index: <= 4s
    • 页面加载时间 Loaded (红色线)
  • 重要测量指标

    • Speed Index
    • TTFB
    • 页面加载时间
    • 首次渲染

性能优化指标:响应

  • 渲染帧速监控

    • 图示 pic
    • Performance → Command + Shift + P → Frame (FPS)
    • 要求 >= 60fps
  • 交互动作的反馈时间

  • 异步请求的完成时间:要求 <= 1s 完成 or loading 动画

RAIL 测量模型

  • 介绍

    • RAIL是一个以用户为中心的性能模型,它把用户的体验拆分成几个关键点(例如 tap、scroll、load),并且帮你定义好了每一个的性能指标。
  • Response 响应

    • 图示

    pic

    • 定义:用户交互响应是否及时, 处理事件应该在 50ms 以内完成
    • 目标:用户的输入到响应的时间不超过100ms,给用户的感受是瞬间就完成了。
    • 优化方案

      • 事件处理函数在50ms内完成,考虑到idle task的情况,事件会排队,等待时间大概在50ms。适用于click,toggle,starting animations等,不适用于drag和scroll。
      • 复杂的js计算尽可能放在后台,如web worker,避免对用户输入造成阻塞
      • 超过50ms的响应,一定要提供反馈,比如倒计时,进度百分比等。
  • Animation 动画

    • 定义:动画是否足够流畅,每 10ms 产生一帧。
    • 目标

      • 产生每一帧的时间不要超过10ms,为了保证浏览器60帧,每一帧的时间在16ms左右,但浏览器需要用6ms来渲染每一帧。
      • 旨在视觉上的平滑。用户对帧率变化感知很敏感。
    • 优化方案

      • 在一些高压点上,比如动画,不要去挑战cpu,尽可能地少做事,如:取offset,设置style等操作。尽可能地保证60帧的体验。
  • Idle 空闲

    • 定义:最大化空闲时间
    • 目标:让浏览器有足够的空闲时间,处理交互;以增大50ms内响应用户输入的几率。
    • 优化方案

      • 空闲时间来完成一些延后的工作,如先加载页面可见的部分,然后利用空闲时间加载剩余部分,此处可以使用 requestIdleCallback API
      • 在空闲时间内执行的任务尽量控制在50ms以内,如果更长的话,会影响input handle的pending时间
      • 如果用户在空闲时间任务进行时进行交互,必须以此为最高优先级,并暂停空闲时间的任务
  • Load 加载

    • 定义:传输内容到页面可交互的时间不超过 5
    • 目标

      • 优化加载速度,可以根据设备、网络等条件。目前,比较好的一个方式是,让你的页面在一个中配的3G网络手机上打开时间不超过5秒
      • 对于第二次打开,尽量不超过2秒
    • 优化方案

      • 可以采用 lazy loadcode-splitting 等 其他优化手段,让第一次加载的资源更少

性能测量工具

  • Chrome DevTools 开发调试工具

    • Audit(Lighthouse)
    • Throttling 调整网络吞吐
    • Performance 网络性能分析
    • Network 网络加载分析
  • Lighthouse 网站整理质量评估

    • 安装
    # 安装
    $ npm install -g lighthouse
    
    # 检测
    $ lighthouse http://www.baidu.com
    
  • WebPageTest 多测试地点,全面性能报告

    • waterfall chart 请求瀑布图
    • first view 首次访问
    • repeat view 二次访问
    • 安装
    # 运行 server 实例
    $ docker run -d -p 4000:80 webpagetest/server 
    
    # 运行 agent 实例
    $ docker run -d -p 4001:80 \
      --network="host" \
      -e "SERVER_URL=http://localhost:4000/work/" \
      -e "LOCATION=Test" \
      webpagetest/agent
    

性能测量 APIs

  • 关键时间节点 (Navigation Timing, Resource Timing)
  • 网络状态 (Network APIs)
  • 客户端服务端协商 (HTTP Client Hints) & 网页显示状态 (UI APIs)
  • Timing 指标

pic

DNS 查询耗时: domainLookupEnd - domainLookupStart

TCP 链接耗时: connectEnd - connectStart

SSL 安全连接耗时: connectEnd - secureConnectStart

网络请求请求耗时(TTFB): responseStart - requestStart

数据传输耗时: responseEnd - responseStart

DOM 解析耗时: domInteractive - responseEnd

资源加载耗时: loadEventStart - domContentLoadedEventEnd

First Byte 时间: responseStart - domainLookupStart

白屏时间: responseEnd - fetchStart

首次可交互时间: domInteractive - fetchStart

DOM Ready 时间: domContentLoadedEventEnd - fetchStart

页面完全加载时间: loadEventStart - fetchStart

HTTP 头部大小: transferSize - encodedBodySize

重定向次数: performance.navigation.redirectCount

重定向耗时: redirectEnd - redirectStart
  • 示例

    • Case 1. 可交互时间(TTI)
    window.addEventListener('load', event => {
      const timing = performance.getEntriesByType('navigation')[0];
      // Time To Interactive 可交互时间
      let tti = timing.domInteractive - timing.fetchStart;
      console.log('TTI:', tti);
    });
    
    • Case 2. 页面不可见
    /*
     * 页面不可见时可以做节流等优化
     */
    let vEvent = 'visibilitychange';
    if (document.webkitHidden !== undefined) {
      vEvent = 'webkitvisibilitychange';
    }
    
    window.addEventListener(vEvent, () => {
      if (document.hidden || document.webkitHidden) {
              console.log('页面不可见!');
      } else {
              console.log('页面可见!');
      }
    }, false);
    
    • Case 3. 网络状态监控
    /*
     * 监控网络状态
     */
    const con = navigator.connection || navigator.webkitConnection;
    
    let type = con.effectiveType;
    
    con.addEventListener('change', () => {
      console.log('connection type changed from ' + type + ' to ' + con.effectiveType);
       type = con.effectiveType;
    });
    

渲染优化

现代浏览器渲染原理

  • 关键渲染路径

    • 关键渲染路径,即 Critical Rendering Path;
    • 浏览器渲染流程: Javascript -> Style -> Layout -> Paint -> Composite
    • 图示

    pic

  • 核心阶段

    • Javascript 阶段: Running JavaScript while Build DOM & Build CSSOM

      • 创建 DOM 树。DOM 树是 HTML 页面完全解析后的一种树状表示方式。
      • 创建 CSSOM 树。CSSOM 树是对附在DOM结构上的样式的一种表示方式。每个节点都带上样式 ,包括明确定义的和隐式继承的。
      • 执行JavaScript。JavaScript是一种 解析阻塞资源(parser blocking resource),它能阻塞HTML页面的解析。
    • Style 阶段:Creating the Render Tree

      • 渲染树是 DOM 和 CSSOM 的结合,是最终能渲染到页面的元素的树形结构表示。
      • 只包含能在页面中最终呈现的元素,而不包含那些用CSS样式隐藏的元素,比如带有 display: none;属性的元素。
    • Layout 阶段:Generating the Layout。

      • 布局决定了视口的大小,为 CSS 样式提供了依据,比如百分比的换算或者视口的总像素值。
    • Paint 阶段:绘制。

      • 页面上可见的内容就会转化为屏幕上的像素点。
    • Composite 阶段:合成。

      • 浏览器为了保证效率,将元素画在不同图层,最后对这些图层合成在一起。
  • 要点

    • CSS是一种 渲染阻塞资源(render blocking resource),它需要 完全被解析完毕之后才能进入生成渲染树的环节
    • 因为 JavaScript脚本的执行必须等到 CSSOM 生成之后,所以说CSS也会 阻塞脚本(script blocking)
    • JavaScript是一种 解析阻塞资源(parser blocking resource)浏览器遇到 script 标记,DOM 停止构建,若 CSSOM 正在下载和构建,则等其下载和构建完毕,执行脚本,执行完之后 DOM 接着构建
    • 但是script标签有 async属性 的时候,就可以指示浏览器在等待脚本可用期间不阻止 DOM 构建。

可优化的渲染环节及方法

  • 布局和绘制

    • 关键渲染路径上代价最高的两个步骤:Layout 和 Paint。
  • 回流诱因

    • 添加/删除元素
    • 操作 styles
    • display: none
    • offsetLeft、scrollTop、 clientWidth
    • 移动元素位置
    • 修改浏览器大小,字体大小
  • 布局抖动 Layout Thrashing

    • 定义:避免频繁修改 DOM 元素,而导致 回流 发生。
    • 避免回流:比如 vDOM 机制
    • 读写分离

      • 批量读取部署信息,批量进行修改
      • FastDOM
    • 案例

    /*
     * 布局抖动
     * 如下,循环读取 offsetTop,并修改元素样式会导致 布局抖动
     */ 
    let images = []; // 一系列图片元素
    
    function update(timestamp) {
     for (let i = 0; i < images.length; i++) {
         images[i].style.width = (Math.sin(images[i].offsetTop + timestamp / 1000) + 1) * 500 + 'px';
     }
    
     window.requestAnimationFrame(update);
    }
    
    • 优化
    /*
     * 解决布局抖动
     * FastDOM 进行读写分离
     */ 
    let images = []; // 一系列图片元素
    
    function update(timestamp) {
     for (let i = 0; i < images.length; i++) {
      fastdom.measure(() => {
       // 读:读取 top 值
       let top = images[i].offsetTop;
    
       fastdom.mutate(() => {
        // 写: 设置新的 width
        images[i].style.width = (Math.sin(top + timestamp / 1000) + 1) * 500 + 'px';
       });
      });
     }
    
     window.requestAnimationFrame(update);
    }
    
  • 图层与复合线程

    • 复合线程(compositor thread)

      • 将页面拆分图层(layers) 进行绘制再进行复合
    • 4 things a browser can animate cheaply: 只触发 composite,而不引发 layout 和 paint

      • Position: transform: translate(npx, npx);
      • Scale: transform: scale(n);
      • Rotation: transform: rotate(ndeg);
      • Opacity: opacity: 0...1;
  • 减少重绘 (Repaint)

    • 利用 will-change 创建新的图层
    • 利用 DevTools 识别 paint 的瓶颈,判断是否有必要创建图层
    • 尽量使用 transform 和 opacity 来处理动画效果
  • 防抖(Debonce)

    • 高频事件处理函数: 可能在一帧里多次触发,会导致 CPU 负担变重。
    • debounce 实现

      • setTimeout 实现,可通过 delay 指定触发频率
      • requestAnimationFrame 实现,按照每帧一次的频率触发
      /*
       * requestAnimationFrame 实现防抖
       */
      function debounce(fn) {
       let ticking = false;
      
       return function () {
        if (ticking) {
         return ;
        }
      
        ticking = true;
        window.requestAnimationFrame(() => {
         fn();
         ticking = false;
        });
       }
      }
      
  • React 时间调度

    • 一帧的渲染周期

    pic

    • requestIdleCallback 定义:在一帧剩余时间执行任务; chrome 官方提供的一个标准。
    • requestIdleCallback 的实现思路

      • 双向环形链表,根据优先级,调度任务
      • 利用 requestAnimationFrame 实现 requestHostCallback,执行 React 的调度任务

代码优化

JavaScript

  • 1)代码开销

    • 加载(load) -> 解析&编译(parse/compile) -> 执行(execute)
    • 开销对比 170KB:Javascript VS Image

    x

    • 页面所有代码中,JavaScript 开销最为昂贵,最可能成为性能短板
  • 2)加载优化

    • Code splitting 代码拆分,按需加载。
    • Tree shaking 代码减重。
  • 3)解析/执行优化

    • 减少主线程工作量

      • 避免长任务(Long Task)
      • 避免超过 1kB 的 行内代码:因为阻塞 DOM 解析,导致加载渲染受阻变慢;浏览器没办法对行内代码进行优化
      • 使用 rAF 和 rIC 进行时间调度
    • 渐进式启动(Progressive Bootstrapping)

      • 可见不可见交互 vs 最小可交互资源集

HTML

  • 减少 iframes 使用

    • 阻碍父文档加载过程
    • 可使用延迟加载
  • 压缩空白符,删除注释

  • 避免深层次嵌套
  • 避免 table 布局
  • CSS&Javascript 尽量外链
  • 删除默认属性
  • 借助工具: html-minifier

CSS

  • 用 performance 查看 Recaculate Style 的开销
  • 降低 CSS 对渲染的阻塞

    • 尽早加载,减小体积
  • 利用 GPU 进行完成动画

    • will-change 等
  • 使用 contain 属性

    • contain 内部子元素布局,与外部无关,不会导致其他元素重绘
    • render tree 阶段,样式计算时,不继承父样式
  • 使用 font-display 属性

    • 解决 FOUC 文字闪动问题,让文字尽早展示

附:Javascript 编译原理

  • V8 引擎

    • 图示

    x

    • 正常编译:源码 -(parser)-> 抽象语法树 -(interpreter)-> 字节码 Bytecode
    • 优化:字节码 Bytecode -(compiler)-> 机器码
    • 反优化:机器码 -> 字节码 Bytecode
    • 编译过程中 会进行优化;运行时 可能发生反优化
    • 目的:了解 V8 引擎基本原理,写出一些 V8 可以帮忙优化的代码,避免在运行时可能发生的反优化。
    • Case 1. 反优化
    // de-opt.js
    const { performance, PerformanceObserver } = require('perf_hooks');
    
    const add = (a, b) => a + b;
    
    const num1 = 1;
    const num2 = 2;
    
    performance.mark('start');
    
    for (let i = 0; i < 10000000; i++) {
     add(num1, num2);
    }
    
    // add(num1, '字符串'); // 比较有无这行代码的执行时间
    
    for (let i = 0; i < 10000000; i++) {
     add(num1, num2);
    }
    
    performance.mark('end');
    
    const observer = new PerformanceObserver(list => {
     console.log(list.getEntries()[0]);
    });
    observer.observe({ entryTypes: ['measure'] });
    
    performance.measure('测量1', 'start', 'end');
    
    node --trace-opt --trace-deopt de-opt.js
    
  • V8 优化机制

    • 脚本流

      • 下载 -> 解析/编译 -> 运行
      • 下载脚本达到足够大,V8 单独开启一个线程,给这一段代码进行解析;最后所有脚本下载解析完成,合并解析结果。
    • 字节码缓存

      • 频繁使用的相同逻辑的字节码缓存起来,不再重复解析,直接复用。
    • 懒解析

      • 对于声明的函数,不马上解析(不用生成语法树,不用分配空间),真正调用时再解析。
  • 函数优化

    • 懒解析 lazy parsing vs 饥饿解析 eager parsing
    • 利用 Optimize.js 优化初次加载时间
    • Case 1. 解析
    /* 1. 懒解析 */
    export default () => {
     const add = (a, b) => a + b; // 默认懒解析
    
     add(1, 2); // 调用时才解析
    };
    
    • Case 2. 饥饿解析
    /* 2. 饥饿解析 */
    export default () => {
     const add = ((a, b) => a + b); // 强制饥饿解析
    
     add(1, 2); // 直接调用,不必再解析
    };
    
    • 可在 weppack 打包时将以上模块单独打包;用 performance 查看比较该模块的 Evaluate Script 阶段的总时长。

  • 对象结构及优化

    • 对象结构

      • 隐藏类(Hidden Class)
      • 可索引属性(Elements)
      • 命名属性(Properties)

        • 对象内属性(in-object)
        • 快属性(fast)
        • 慢属性(slow)
    • 对象优化

      • 以相同的顺序初始化成员对象,避免 隐藏类(Hidden Class) 的调整
      • 实例化后避免添加属性
      • 尽量使用 Array 代替 array-like 对象
      • 避免读取超过数组的长度

        • 会沿着原型链查找越界属性,造成额外开销
      • 避免元素类型的转换

        • let arr = [1, 2, 3]; arr.push('x');
        • 编译器判断元素类型,进行优化;
        • 若发现类型转换,之前的优化失效。
      • Case 1

      /*
       * case 1
       * 以相同的顺序初始化成员对象,避免隐藏类的调整
       */
      class Foo { // Hidden Class 0
       constructor(a, b) {
        this.a = a; // Hidden Class 1
        this.b = b; // Hidden Class 2
       }
      }
      
      // 可以复用 Hidden Class
      const foo1 = new Foo(1, 2);
      const foo2 = new Foo(3, 4);
      
      // 否则,需要重新创建 Hidden Class
      const bar1 = { x: 'a' }; // HC 0
      bar1.y = 2; // HC 1
      
      const bar2 = { y: 3 }; // HC 2
      bar1.x = 'b'; // HC 3
      
      • Case 2
      /*
       * case 2
       * 实力化后避免添加属性
       */
      // x: in-object 属性,存储在堆
      const bar1 = { x: 'a' };
      
      // y: Normal/Fast 属性
      // 存储在 properties store 里,需要通过描述数组间接查找
      bar1.y = 2;
      
      • Case 3
      /*
       * case 3
       * 尽量使用 Array 代理 array-like 对象
       */
      Array.prototype.forEach.call(arrLikeObj, (val, index) => {
       console.log(val, index);
      });
      
      // 以上操作不如在真实数组操作效率高
      const arr = Array.prototype.slice.call(arrLikeObj, 0);
      arr.forEach((val, index) => {
       console.log(val, index);
      });
      
      • Case 4
      /*
       * case 4
       * 避免读取超过数组的长度
       */
      const arr = [];
      
      for (let i = 0; i < arr.length + 1; i++) {
       console.log(arr[i]); // 会沿着原型链查找,造成额外开销
      }
      

资源优化

Guideline

  • 压缩 & 合并
  • 图片格式
  • 图片加载
  • 字体优化

HTML 压缩

  • 使用在线工具: terser
  • 使用 html-minifier 等 npm 工具

CSS 压缩

  • 使用在线工具
  • 使用 clean-css 等 npm 工具

JS 压缩与混淆

  • webpack

构建优化

Guideline

  • webpack 的优化配置
  • 代码拆分
  • 代码压缩
  • 持久化缓存
  • 监测与分析
  • 按需加载

Webpack Mode

  • Convention Over Configuration: meaning, definition, explanation。

  • 图示

x

  • ModuleConcatenationPlugin: 作用域提升
  • TerserPlugin: 代码体积减少,Tree Shaking

Tree-shaking

  • 上下文中未用到的代码(dead code)
  • 只识别基于 ES6 import & export
  • 注意 Babel 默认配置的影响

    • @babel/preset-env 配置 modules: fasle,保留 ES6 模块化语法
  • package.json 中配置 sideEffects

    • terser 识别副作用特别的耗时且不准确
    • 告诉 terser 哪些文件有副作用或者所有文件都没有副作用,提升打包速度
    • 示例
    // package.json
    {
      "sideEffects": [
        "dist/*",
        "es/**/style/*",
        "lib/**/style/*",
        "*.css"
      ]
    }
    

    作用域提升

  • 代码体积较小

  • 提升执行效率
  • 同上,注意 Babel 的 modules 的配置

Bable 7 配置

  • 在需要的地方 pollyfill
  • 辅助函数 的按需引入

    • 比如,每次声明类时都会生成一个叫 _classCallCheck 的辅助函数;
    • 通过配置 babel/plugin-transform-runtime,复用辅助函数
  • 根据目标浏览器按需转换代码

  • 示例
// 源码
class A {}

// 经过babel 转换
function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  } 
}

var A = function A() {
  _classCallCheck(this, A);
};

_classCallCheck 就是一个辅助函数。

Webpack 依赖优化

  • noParse

    • 提升构建速度
    • 直接通知 webpack 忽略较大的库,比如 lodash
    • 被忽略的库不能有 import, require, defined 的引入方式
  • DllPlguin

    • 避免打包时对不变的库重复构建, 提升构建速度

Webpack 代码拆分

  • Code Splitting, 把单个 bundle 文件拆分成若干小 bundles/chunks
  • 缩短首屏加载时间
  • 配置 splitChunks 提取公有代码,拆分业务代码与第三方库
  • 动态加载
  • 示例
optimization: {
  splitChunks: {
    cacheGroups: {
      // 拆分第三方库
      vendor: {
        name: 'vendor',
        test: /[\\/]node_modules[\\/]/,
        minSize: 0, // 将所有模块独立出来
        minChunks: 1, // 最少拆成 1 段
        priority: 10,
        chunks: 'initial'
      },
      // 拆分业务代码的公共模块
      common: {
        name: 'common',
        test: /[\\/]src[\\/]/,
        minSize: 0,
        minChunks: 2,
        chunks: 'all'
      }
    }
  }
}

Webpack 资源压缩(Minification)

  • Terser 压缩 JS
  • 压缩 CSS

    • 1) mini-css-extract-plugin 抽取出 css 文件
    • 2) optimize-css-assets-webpack-plugin 对抽取的 css 进行压缩
  • html-webpack-plugin/minify 压缩 HTML

Webpack 资源持久化缓存

  • 每个打包的资源文件有唯一的 hash 值
  • 修改后只有受影响的文件 hash 变化

    • 增量式进行更新
    • hash 整个应用的 hash 值
    • chunkhash 每个 chunk 的 hash 值(一个 chunk 拆分出来的css/js等文件,hash相同)
    • contenthash 根据文件内容计算的 hash 值
  • 充分利用浏览器缓存

监测与分析

  • Stats 分析与可视化图

    • 由内向外看,每一部分代码,由哪些模块组成
  • 每个 bundle 文件的体积分析

    • 工具一: 安装 source-map-explorer(推荐)
    • 工具二: 安装 webpack-bundle-analyzer
  • speed-measure-webpack-plugin 速度分析

    • 统计 webpack 每个阶段耗时
  • 示例

# 1. Stats 分析与可视化图
webpack --profile --json > stats.json

# 可视化结果 https://alexkuz.github.io/webpack-chart/
# 2. bundle 文件的体积分析
source-map-explorer 'dist/*.js'

# 必须配置source-map 比如 devtool: 'hidden-source-map'

React 按需加载

  • React router 基于 webpack 动态引入
  • 使用 Reloadable 高级组件

传输加载优化

Guideline

  • Gzip
  • KeepAlive
  • HTTP 缓存
  • Service Worker
  • HTTP/2
  • SSR
  • Nginx

Gzip 压缩

  • 对传输资源进行体积压缩,可高达 90%
  • 配置 nginx 启动 Gzip
  • 示例
# nginx.conf
http {
  gzip  on;              # 开启 gzip 压缩
  gzip_min_length  1k;   # 至少 1k 才进行压缩
  gzip_comp_level  6;    # 压缩级别 1 ~ 9,压缩比率越高,越消耗cpu
  gzip_types text/plain text/html application/javascript text/css text/javascript application/json;  # 压缩的资源类型
  gzip_static on;        # 压缩的静态资源,直接复用
  gzip_vary on;          # 告诉客户端启用了 gzip 压缩;增加响应头 "Vary: Accept-Encoding"
  gzip_buffers 4 16k;    # 优化压缩过程
  gzip_http_version 1.1; # http 版本

  server {
    # TO DO
  }
}

启用 KeepAlive

  • 一个持久的 TCP 连接,节省连接创建时间

    • waterfall 里的 initial connection
  • nginx 默认开启 keepalive

# nginx.conf
# keepalive-timeout 0;   # 不启用
keepalive-timeout 65;    # 超时时间
keepalive-requests 100;  # 保持连接的请求数
// 响应头 Response Headers
Connection: close // 表示没开启 keepalived
Connection:  keepalived // 表示开启 keepalived

HTTP 缓存

  • 目的:提高重复访问时资源加载速度
  • Cache-Control/Expires
  • Last-Modified + If-Modified-Since
  • Etag + If-None-Match
  • 示例
# nginx.conf
http {
  server {
    listen 9999;
    server_name localhost;

    location / {
      root       "/Users/leo_0/Studyspace/Learn to Code/Simple Demos/learning-demo-2-vue/dist";
      index      index.html index.htm;
      try_files $uri /index.html;
      if ($request_filename ~* .*\.(?:htm|html)$)
      {
        add_header Cache-Control "no-cache, must-revalidate";
        add_header "Pragma" "no-cache";
        add_header "Expires" "0";
      }
      if ($request_filename ~* .*\.(?:js|css)$)
      {
        expires 7d;
      }
      if ($request_filename ~* .*\.(?:jpg|jpeg|gif|png|svg|mp4|ogg)$)
      {
        expires 7d;
      }
    }
  }
}
  • HTML 文件 Response Headers

    • Cache-Control: no-cache, must-revalidate
    • Expires: 0
  • JS 文件 Response Headers

    • Date: Tue, 13 Oct 2020 03:55:49 GMT
    • Expires: Tue, 20 Oct 2020 03:55:49 GMT

Service Workers

  • 加速重复访问
  • 离线支持
  • 注意:

    • 延长首屏时间,但页面总加载时间减少
    • 只能在 localhost 或 https 下使用

HTTP/2

  • 二进制传输
  • 请求响应多路复用

    • HTTP 1.1 虽然有 Keepalive 减少建立连接的开销,但资源依旧按顺序加载,相互阻塞
    • HTTP/2 多路复用,真正意义上异步 or 并发 的请求资源
    • 适合请求量大的页面,发挥较大优势
  • Server Push

    • waferfall 中没有 TTFB 阶段
    • 服务端直接 push 到浏览器,直接 Reading Push,读取资源
  • 示例

# nginx.conf 配置
server {
  # listen 9443 ssl; # 不使用 http2
  listen 9443 ssl http2; # 使用 http2
  server_name localhost;

  ssl on;

  ssl_certificate ;
  ssl_ssl_certificate_key ;

  ssl_session_cache shared:SSL:1m;
  ssl_session_timeout 5m;

  ssl_ciphers HIGH:!aNULL:!MD5;
  ssl_prefer_server_ciphers on;

  location / {
    root       "/Users/leo_0/Studyspace/Learn to Code/Simple Demos/learning-demo-2-vue/dist";
    index      index.html index.htm;
    try_files $uri /index.html;

    ## 使用 server push 资源文件
    # http2_push /img/pic0.jpg;
    # http2_push /img/pic1.jpg;
    # http2_push /img/pic2.jpg;
  }
}
# 制作自签名证书
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048
## 私钥
openssl rsa -passin pass:x -in server.pass.key -out server.key
## 证书请求文件(Certificate Signing Request)
openssl req -new -key server.key -out server.csr
## 证书
openssl x509 -req -sha256 -days 3650 -in server.csr -signkey server.key -out server.crt
  • 图示:HTTP 1.1 vs HTTP2

http2

  • Tip: Chrome 访问不安全 HTTPS 口令: thisisunsafe

SSR

  • 加速首屏加载
  • 更好的 SEO
  • Case: 基于 Next.js 实现 SSR
  • 图示:CSR vs SSR

ssr

其他优化技术

Guideline

  • SVG 优化图标
  • FlexBox 布局
  • 预加载
  • 预渲染
  • 窗口化提高列表性能
  • 骨架组件

SVG 优化图标

  • 保持图片能力,支持多色彩
  • 独立的矢量图
  • XML 语法,搜索引擎 SEO 和无障碍读屏软件读取

FlexBox 布局

  • 更高性能的实现方案

    • 比较 float: left;display: flex;flex-flow: row wrap; ;查看 performance 发现 Rendering & Painting 时间明显缩短。
  • 容器有能力决定子元素的大小,顺序,对齐,间隔等

    • Flex container 容器
    • Flex items 子元素

优化资源加载顺序

  • 图示

priority

  • 浏览器默认安排资源加载优先级 Priority
  • 使用 preload, prefetch 调整优先级

    • Preload: 提前加载较晚出现但对当前页面非常重要的资源,优先级高
    • Prefetch: 提前加载后继路由需要的资源,优先级低
  • 示例

<!-- Preload Case -->
<link rel="preload" href="main.js" as="script">

<script src="main.js"></script>
<!-- Prefetch Case -->
<!-- index.html -->
<link rel="prefetch" as="style" href="style.css" >

<!-- page-1.html -->
<link rel="stylesheet" href="style.css" />

<!--
查看响应
Request Method: GET
Status Code: 200 OK (from prefetch cache)
-->

预渲染页面

  • use react-snap with Vue.js/React.js

    • 页面攫取,类似爬虫,将爬取的结果拼装到页面
    • 配置 postBuild
    • 使用 ReactDOM.hydrate
    • 内联样式,避免 FOUC(样式闪动)
  • 作用:

    • 大型 SPA 应用的性能瓶颈:JS 下载 + 解析 + 执行
    • SSR 的主要问题:牺牲 TTFB 来补救 First Paint; 实现复杂
    • Pre-rendering 打包时提前渲染页面,没有服务端参与

Windowing 窗口化

  • react-window 窗口化提升列表渲染性能

    • 始终让 DOM 保持比较小的体积
    • 不同于 Lazy Loading
  • 加载大列表,大表单的每一行严重影响性能

  • Lazy Loading 仍然会让 DOM 变得过大
  • Windowing 只渲染可见的行,渲染和滚动的性能都会提升

骨架组件

  • 使用骨架组件减少布局移动(Layout Shift,Shift + Command + P
  • Skeleton/Placeholder 的作用

    • 占位
    • 提升用户感知性能
  • react-placeholder

习题

Web 加载&渲染原理

  • 问题: 从输入 URL 到页面加载显示都发生了什么?

x

  • UI Thread

    • 判断: 搜索 or URL? -> 搜索引擎 or 请求的站点
    • UI Thread 会通知 Network Thread 发起请求
    • 图示

    url

  • Network Thread

    • UI Thread & Network Thread 属于 browser process
    • 获取 HTML 资源后,通信 Renderer Process
    • 图示

    network

  • Renderer Process

    • 图示

    renderer

    renderer2

首屏加载优化

  • 问题:什么是首屏加载?怎么优化?
  • 首屏重要指标

    • FCP、LCP、TTI
    • 图示

    x

  • 解决办法

    • 资源体积太大?

      • 资源压缩,传输压缩,代码拆分,Tree shaking,HTTP/2,缓存
    • 首页内容太多?

      • 路由/组件/内容 lazy-loading,预渲染/SSR,Inline CSS
    • 加载顺序不适合?

      • prefetch, preload 调整加载的优先级
      • preload: 当浏览器解析到 preload 标签就会去加载 href 中对应的资源但不执行,待到真正使用到的时候再执行。
      • prefetch 跟 preload 不同,它的作用是告诉浏览器未来可能会使用到的某个资源,浏览器就会在闲时去加载对应的资源。

Javascript 内存管理

  • 问题:JS 是怎样进行内存管理?什么情况会造成内存泄露?
  • 内存回收

    • 局部变量,函数执行完,没有闭包引用,会被标记回收
    • 全局变量,直到浏览器被卸载页面时释放
  • GC 机制

    • 引用计数

      • 无法解决循环引用问题
    • 标记清除

      • 从 GC Root 开始扫描,所有引用是否能被访问到
      • 如果不可访问,标记,随后回收
  • 避免内存泄漏

    • 避免意外的全局变量
    • 避免反复运行引发大量闭包
    • 避免脱离的 DOM 元素
    • 示例
    /*
     * case 1. 避免意外的全局变量
     */
    function accidentalGlobal() {
      leak1 = 'leak1';
      this.leak2 = 'leak2';
    }
    accidentalGlobal();
    // window.leak1;
    // window.leak2;
    
    /*
     * case 2. 避免反复运行引发大量闭包
     */
    var store;
    
    function outer() {
      var largeData = new Array(10000000000);
      var prevStore = store;
    
      function inner() {
        if (prevStore) {
          return largeData;
        }
      }
    
      return function() {}
    }
    
    setInterval(function() {
      store = outer();
    }, 10)
    
    /*
     * case 3. 避免脱离的 DOM 元素
     */
    function createElement() {
      const div = document.createElement('div');
      div.id = 'detached';
      return div;
    }
    
    const detachedDiv = createElement(); // 此处一直有引用,所以无法回收
    document.body.appendChild(detachedDiv);
    
    function deleteElement() {
      // 从文档树中删除之后,并没有被回收
      document.body.removeChild(document.getElementById('detached'));
    }
    
    deleteElement();
    

Reference

Web 质量指标

前端性能