Webpack 性能优化(构建+渲染效率)

Webpack 性能优化

为什么要做优化

webpack 打包优化并没有什么固定的模式,一般我们常见的优化就是拆包、分块、压缩等,并不是对每一个项目都适用,针对于特定项目,需要不断调试不断优化;这边主要记录的是整体思路,每一块都可以单独去深入实践;

打包效率优化

如何分析打包速度

使用speed-measure-webpack-plugin 测量你的 webpack 构建期间各个阶段花费的时间;

// 分析打包时间
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
// ...
module.exports = smp.wrap(prodWebpackConfig) // prodWebpackConfig 为webpack的配置项
复制代码

分析影响打包效率的原因

  1. 开始打包,我们需要获取所有的依赖模块----搜索时间

  2. 解析所有的依赖模块(解析成浏览器可运行的代码)-----解析时间

    webpack 根据我们配置的 loader 解析相应的文件。日常开发中我们需要使用 loader 对 js ,css ,图片,字体等文件做转换操作,并且转换的文件数据量也是非常大。由于 js 单线程的特性使得这些转换操作不能并发处理文件,而是需要一个个文件进行处理

  3. 将所有的依赖模块打包到一个文件 -----压缩时间

    将所有解析完成的代码,打包到一个文件中,为了使浏览器加载的包更新(减小白屏时间),所以 webpack 会对代码进行优化。

    JS 压缩是发布编译的最后阶段,通常 webpack 需要卡好一会,这是因为压缩 JS 需要先将代码解析成 AST 语法树,然后需要根据复杂的规则去分析和处理 AST,最后将 AST 还原成 JS,这个过程涉及到大量计算,因此比较耗时,打包就容易卡住

  4. 二次打包 ----- 二次打包时间

    当更改项目中一个小小的文件时,我们需要重新打包,所有的文件都必须要重新打包,需要花费同初次打包相同的时间,但项目中大部分文件都没有变更,尤其是第三方库

针对每个不同节点的优化手段

搜索时间

缩小文件搜索范围 减小不必要的编译工作

webpack 打包时,会从配置的 entry 触发,解析入口文件的导入语句,再递归的解析,在遇到导入语句时 webpack 会做两件事情:

  • 根据导入语句去寻找对应的要导入的文件。例如 require('react') 导入语句对应的文件是 ./node_modules/react/react.jsrequire('./util') 对应的文件是 ./util.js
  • 根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用 ES6 开发的 JavaScript 文件需要使用 babel-loader 去处理

主要优化手段

  1. 优化 loader 配置

    使用 Loader 时可以通过 testincludeexclude 三个配置项来命中 Loader 要应用规则的文件

  2. 优化 resolve.module 配置

    resolve.modules 用于配置 webpack 去哪些目录下寻找第三方模块,resolve.modules 的默认值是 ['node_modules'] ,含义是先去当前目录下的 ./node_modules 目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules 中找,再没有就去 ../../node_modules 中找,以此类推

  3. 优化 resolve.alias 配置

    resolve.alias 配置项通过别名来把原导入路径映射成一个新的导入路径,减少耗时的递归解析操作。

  4. 优化 resolve.extensions 配置

    • resolve.extensions 列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中
    • 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
    • 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。
  5. 优化 module.noParse 配置

    module.noParse 配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。

配置示例参考

// 编译代码的基础配置
module.exports = {
  // ...
  module: {
    // 项目中使用的 jquery 并没有采用模块化标准,webpack 忽略它
    noParse: /jquery/,
    rules: [
      {
        // 这里编译 js、jsx
        // 注意:如果项目源码中没有 jsx 文件就不要写 /\.jsx?$/,提升正则表达式性能
        test: /\.(js|jsx)$/,
        // babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
        use: ['babel-loader?cacheDirectory'],
        // 排除 node_modules 目录下的文件
        // node_modules 目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
        exclude: /node_modules/,
      },
    ]
  },
  resolve: {
    // 设置模块导入规则,import/require时会直接在这些目录找文件
    // 可以指明存放第三方模块的绝对路径,以减少寻找
    modules: [
      path.resolve(`${project}/client/components`), 
      path.resolve('h5_commonr/components'), 
      'node_modules'
    ],
    // import导入时省略后缀
    // 注意:尽可能的减少后缀尝试的可能性
    extensions: ['.js', '.jsx', '.react.js', '.css', '.json'],
    // import导入时别名,减少耗时的递归解析操作
    alias: {
      '@compontents': path.resolve(`${project}/compontents`),
    }
  },
};
复制代码

优化解析时间 - 开启多进程打包

运行在 Node.js 之上的 webpack 是单线程模式的,也就是说,webpack 打包只能逐个文件处理,当 webpack 需要打包大量文件时,打包时间就会比较漫长

  1. thread-loader

    // ...
    const threadLoader = require('thread-loader');
    
    const jsWorkerPool = {
      // options
      
      // 产生的 worker 的数量,默认是 (cpu 核心数 - 1)
      // 当 require('os').cpus() 是 undefined 时,则为 1
      workers: 2,
      
      // 闲置时定时删除 worker 进程
      // 默认为 500ms
      // 可以设置为无穷大, 这样在监视模式(--watch)下可以保持 worker 持续存在
      poolTimeout: 2000
    };
    
    const cssWorkerPool = {
      // 一个 worker 进程中并行执行工作的数量
      // 默认为 20
      workerParallelJobs: 2,
      poolTimeout: 2000
    };
    
    threadLoader.warmup(jsWorkerPool, ['babel-loader']);
    threadLoader.warmup(cssWorkerPool, ['css-loader', 'postcss-loader']);
    
    
    module.exports = {
      // ...
      module: {
        rules: [
          {
            test: /\.js$/,
            exclude: /node_modules/,
            use: [
              {
                loader: 'thread-loader',
                options: jsWorkerPool
              },
              'babel-loader'
            ]
          },
          {
            test: /\.s?css$/,
            exclude: /node_modules/,
            use: [
              'style-loader',
              {
                loader: 'thread-loader',
                options: cssWorkerPool
              },
              {
                loader: 'css-loader',
                options: {
                  modules: true,
                  localIdentName: '[name]__[local]--[hash:base64:5]',
                  importLoaders: 1
                }
              },
              'postcss-loader'
            ]
          }
          // ...
        ]
        // ...
      }
      // ...
    }
    
    复制代码

优化压缩时间

webpack4 默认内置使用 terser-webpack-plugin 插件压缩优化代码;使用多进程并行运行来提高构建速度。并发运行的默认数量为 os.cpus().length - 1

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: true,
      }),
    ],
  },
};
复制代码

合理利用缓存

使用 webpack 缓存的方法有几种,例如使用 DLLcache-loaderHardSourceWebpackPluginbabel-loadercacheDirectory 标志。 所有这些缓存方法都有启动的开销。 重新运行期间在本地节省的时间很大,但是初始(冷)运行实际上会更慢

  • Babel-loader cacheDirectory

    {
      test: /\.js$/,
      use: 'babel-loader?cacheDirectory',
      include: [resolve('src'), resolve('test') ,resolve('node_modules/webpack-dev-server/client')]
    }
    复制代码
  • Cache-loader

    module.exports = {
      module: {
        rules: [
          {
            test: /\.ext$/,
            use: ['cache-loader', ...loaders],
            include: path.resolve('src'),
          },
        ],
      },
    };
    复制代码
  • HardSourceWebpackPlugin --- 二次构建速度会非常的快

页面加载性能优化

如何分析

浏览器渲染原理中入手

image.png

结论:

  1. 网络通信更快 - 网络层面
  2. 拿到数据后,渲染更快 - 渲染层面

关键指标

  1. 白屏时间

    白屏时间节点指的是从用户进入网站(输入url、刷新、跳转等方式)的时刻开始计算,一直到页面有内容展示出来的时间节点;

    这个过程包括dns查询、建立tcp连接、发送首个http请求(如果使用https还要介入TLS的验证时间)、返回html文档、html文档head解析完毕

    • 怎么计算?

      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <title>白屏时间</title>
          <script>
              // 开始时间
              window.pageStartTime = Date.now();
          </script>
          <link rel="stylesheet" href="">
          <link rel="stylesheet" href="">
          <script>
              // 白屏结束时间
              window.firstPaint = Date.now()
          </script>
      </head>
      <body>
          <div>123</div>
      </body>
      </html>
      
      在html文档的head中所有的静态资源以及内嵌脚本/样式之前记录一个时间点,
      在head最底部记录另一个时间点,两者的差值作为白屏时间
      白屏时间 = firstPaint - pageStartTime
      
      复制代码
  2. 首屏时间

    • 首屏时间 = 白屏时间 + 首屏渲染时间

    • 如何计算

      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <title>首屏时间</title>
          <script>
              // 开始时间
              window.pageStartTime = Date.now();
          </script>
          <link rel="stylesheet" href="">
          <link rel="stylesheet" href="">
      </head>
      <body>
          <div>123</div>
          <div>456</div>
          // 首屏可见内容
          <script>
              // 首屏结束时间
              window.firstPaint = Date.now();
          </script>
          // 首屏不可见内容
          <div class=" "></div>
      </body>
      </html>
      由于浏览器解析HTML是按照顺序解析的,当解析到某个元素的时候,
      觉得首屏完成了,就在此元素后面加入<script>计算首屏完成时间
      首屏时间 = firstPaint - pageStartTime
      复制代码
  3. 可操作时间

    // 原生JS实现dom ready
    window.addEventListener('DOMContentLoaded', (event) => {
        console.log('DOM fully loaded and parsed');
    });
    复制代码
  4. 总下载时间

    总下载时间即window.onload触发的时间节点
    复制代码
  5. window.performance

    - memory字段代表JavaScript对内存的占用
    - navigation 统计的是一些网页导航相关的数据
    - timing 它包含了网络、解析等一系列的时间数据
    复制代码
    相关的时间计算:
    DNS查询耗时 = domainLookupEnd - domainLookupStart
    TCP链接耗时 = connectEnd - connectStart
    request请求耗时 = responseEnd - responseStart
    解析dom树耗时 = domComplete - domInteractive
    白屏时间 = domloading - fetchStart
    domready可操作时间 = domContentLoadedEventEnd - fetchStart
    onload总下载时间 = loadEventEnd - fetchStart
    复制代码

优化的手段

网络通信的时间更快

  • 静态资源CDN

    • 优点: 就近原则 & 多级缓存策略

    • 如何配置

      1. index.html
      <body>
          <div id="app"></div>
          <!-- built files will be auto injected -->
          <!--生产环境-->
          <script src="https://cdn.bootcss.com/vue/2.6.11/vue.min.js">
          </script>
          <!-- 引入组件库 -->
          <script src="https://cdn.bootcss.com/vue-router/3.2.0/vue-router.min.js">
          </script>
          <script src="https://cdn.bootcss.com/axios/0.23.0/axios.min.js"></script>
          <script src="https://cdn.bootcss.com/element-ui/2.15.6/index.js"></script>
      </body>
      
      2. webpack.config.js
      configureWebpack: {
          externals: {
              "vue": "Vue",
              "vue-router": "VueRouter",
              "axios": "axios",
              "moment": "moment",
              "element-ui": "ELEMENT",
          }
      },
        
      3. 去掉原本代码上的
      import vue from 'vue';
      import vueRouter from 'vue-router';
      复制代码
  • 资源合并 (http2.0就不需要做这个)

  • 域名分片(多域名)(浏览器可并发 6-8个请求 ,http2.0就不需要做这个)

  • 缓存策略

    考虑拒绝一切缓存策略:Cache-Control:no-store
    考虑资源是否每次向服务器请求:Cache-Control:no-cache
    考虑资源是否被代理服务器缓存:Cache-Control:public/private
    考虑资源过期时间:Expires:t/Cache-Control:max-age=t,s-maxage=t
    考虑协商缓存:Last-Modified/Etag
    复制代码
    • 强缓存(资源存储在 memory cache 或 disk cache)
      • Expires: 为http1.0定义的绝对的过期时间(使用本地时间)、本地时间喝服务器时间存在较大差异会错乱
      • Cache-Control: http1.1出现的缓存控制字段、优先级更高、
    • 协商缓存
      • Last-Modified: 资源最后修改时间,当客户端再次请求该资源的时候,会在其请求头上附带上If-Modified-Since字段,值就是之前返回的Last-Modified值。如果资源未过期,命中缓存,服务器就直接返回304状态码,客户端直接使用本地的资源。否则,服务器重新发送响应资源
      • Etag: 通过校验码;这样就保证了在文件内容不变的情况下不会重复占用网络资源。响应头中Etag字段是服务器给资源打上的一个标记,利用这个标记就可以实现缓存的更新。后续发起的请求,会在请求头上附带上If-None-Match字段,其值就是这个标记的值;优先级高
    • Service Worker: Service Worker 是一个相对来说比较新的技术,其目的也主要是为了提高web app的用户体验,可以实现离线应用消息推送等等一系列的功能, 从而进一步缩小与原生应用的差距。 Service Worker可以看做是一个独立于浏览器的Javascript代理脚本,通过JS的控制,能够使应用先获取本地缓存资源(Offline First),从而在离线状态下也能提供基本的功能。 出于安全性的考虑,Service Worker 只能在https协议下使用,不过为了便于开发,现在的浏览器默认支持localhost使用Service Worker。
  • 资源压缩

    • gzip压缩方式 & br
    • 代码文件压缩(注释/空格/变量名)
    • 静态资源(图标,图片资源)
    • 头和报文(http1.1减少不必要的头,减少cookie的数据量)
    • Tree-shaking
    • split-chunk 拆包
  • 通信协议上(http1.0/http1.1/http2.0)

    • 减少http请求

      一个完整的 HTTP 请求需要经历 DNS 查找,TCP 握手,浏览器发出 HTTP 请求,服务器接收请求,服务器处理请求并发回响应,浏览器接收响应等过程

    • 启用HTTP2.0

      1. 二进制协议;解析速度快
      2. 多路复用,多个请求共用一个TCP连接
      3. 首部压缩
  • 按需加载和懒加载

渲染层面

  • 需要使用一个测试工具服务器压力测试(ab) (node --prof 生成整个运行日志,可以被解析成占用率)
  • 前端代码层面
    • html 语义化标签加强dom解析
    • 多用伪元素,减少js操作dom
    • 逻辑和展示解藕;
    • 减少作用域查找和闭包;
    • css文件放置头部,js文件放置在底部
    • js: 算法复杂度
    • web worker: 创造多线程环境
    • 减少重绘和回流
  • 渲染方式 (csr => ssr)-- next/nuxt
    • 优点: 首屏渲染快,SEO 好
    • 缺点: 配置麻烦,增加了服务器的计算压力
  • 静态站点生成方案(SSG)Gatsby/Gridsome

参考文献

猜你喜欢

转载自juejin.im/post/7084625289682092046