likes
comments
collection
share

面试官:如何进行前端性能优化?

作者站长头像
站长
· 阅读数 19
  • 做优化应该是一个有指标、有比较、有数据的过程
  • 一个完整的解决方案应该是说清楚标准,讲清楚缘由,算清楚结果,最后用数据与收益来说明工作成果

一、建立衡量标准

在确认指标之后,还要有量化基础,有数据积累。

理论基础 - 以『用户为核心』的 RAIL 性能模型

  • Response(响应):应该尽可能快速的响应给客户,应该在 100ms 或者 100ms 以内响应用户输入;
  • Animation(动画):在展示动画的时候,每一帧应该以 16ms 进行渲染,这样可以保持动画效果的一次性,并且避免卡顿;
  • Idle(浏览器空闲时间):当使用 JavaScript 主线程时,应该把任务划分到执行时间小于 50ms 的片段中,这样可以释放线程以进行用户交互;
  • Load(加载):应该在 1s 的时间内加载完你的网站,并可以进行用户交互。

衡量工具

  • Chrome DevTools
    • Performance:可查看性能指标,并有网页快照。
    • Network:可以查看各个资源的加载时间。
  • Lighthouse:非常流行的第三方性能评测工具,支持移动端和 PC。 在报告中会对诸如初次内容渲染、可交互时间、加载等进行具体的数值量化打分。 面试官:如何进行前端性能优化? 需要注意:
    • Lighthouse 并不能真实的反应出每个用户的设备的实际性能数据;
    • Lighthouse 的分数反应的是业界的标准,而非项目实际需求的标准。 配置:Chrome ➡️ 开发者工具 ➡️ Lighthouse ➡️ generate report。

面试官:如何进行前端性能优化?

面试官:如何进行前端性能优化?

面试官:如何进行前端性能优化?

性能指标

  • Navigation Timing API:浏览器提供的 JS API,用于在客户端测量网页加载的性能。
  • Lighthouse Performance
    • FCP(First Contentful Paint):记录首次加载并绘制内容的时间。
    • TTI(Time to Interact):页面可交互的时间。通常通过记录window.performance.timing中的 loadEventStartfetchStart的时间差来完成。
    • LCP(Lagest Contentful Paint):最大可见元素绘制。
    • TBT(Total Blocking Time):指从一个请求发送到接收到响应所花费的时间,通常以毫秒为单位。
    • CLS(Cumulative Layout Shift):衡量在网页的整个生命周期内发生的所有意外布局偏移的得分总和。得分是零到任意正数,其中 0 表示无偏移,且数字越大,网页的布局偏移越大。为了提供良好的用户体验,网站应努力使 CLS 得分不超过0.1。

面试官:如何进行前端性能优化?

二、制定优化方案

我们可以从以下几方面进行优化。

  • DNS 解析
  • TCP 连接优化
  • 请求优化
  • 页面渲染优化
  • JS 优化
  • CSS 优化
  • React 优化
  • 图片优化
  • Webpack 打包优化

DNS 解析

1. DNS 预解析

通过使用dns-prefetchDNS 预解析,提前获取 IP 地址。

<link rel="dns-prefetch" href="xxx" />

在工程化环境下,写一个脚本文件,读取打包结果,提取代码中的外链接,进行 DNS 预解析。

const fs = require("fs");
const path = require("path");
const { parse } = require("node-html-parser");
const { glob } = require("glob");
const urlRegex = require("url-regex");
const { strict } = require("assert");

// 获取外部链接的正则表达式
const urlPattern = /(https?:\/\/[^/]*)/i;
const urls = new Set();
// 遍历 dist 目录中的所有 HTML、js、css 文件
async function searchDomain() {
  const files = (await glob("build/**/*.{html,css,js}")) || [];
  for (const file of files) {
    const source = fs.readFileSync(file, "utf-8");
    const matches = source.match(urlRegex({ strict: true }));
    if (matches) {
      matches.forEach((url) => {
        const match = url.match(urlPattern);
        if (match && match[1]) {
          urls.add(match[1]);
        }
      });
    }
  }
}

// 读取域名,生成 link 元素,加入到打包结果的 head 元素中
async function insertLinks() {
  const files = (await glob("build/*.html")) || [];
  const links = [...urls]
    .map((url) => `<link rel="dns-prefetch" href="${url}">`)
    .join("\n");
  for (const file of files) {
    const html = fs.readFileSync(file, "utf-8");
    const root = parse(html);
    const head = root.querySelector("head");
    head.insertAdjacentHTML("afterbegin", links);
    fs.writeFileSync(file, root.toString());
  }
}

async function main() {
  await searchDomain();
  // 在 head 标签中添加预取链接
  await insertLinks();
}

main();

在webpack打包时执行上述文件,就可以实现批量 DNS 预解析。

2. 域名收敛

减少页面中域名的数量,从而减少 DNS 解析次数。

TCP 连接优化

1. 使用preconnect提前和目标服务器进行连接

这个过程包括 DNS 查询得到 IP、TCP 三次握手、HTTP 或 HTTPS 连接。

<link rel="preconnect" href="xxx" />

请求优化

1. 可以使用 http/2 协议

依赖 http/2 的多路复用、头部压缩、二进制传输、服务端推送等特性,从而加快整体请求的响应速度,加快页面的渲染展示。

  • 多路复用 多路复用允许同时通过单一的 http/2 连接发送多重请求-响应信息。这个功能相当于是长连接的增强,每个 request 请求可以随机的混杂在一起,接收方可以根据 request 的 id 将 request 再归属到各自不同的服务端请求里。 另外,多路复用也支持了流的优先级,允许客户端告诉服务器哪些内容是更高优先级的资源,可以优先传输。 改善了:在 http1.1 中,浏览器客户端在同一时间,针对同一域名下的请求有一定数量限制(连接数量),超过限制会被阻塞。

  • 头部压缩 HTTP1.x 的 header 带有大量信息,而且每次都要重复发送。 HTTP2.0 使用 HPACK 算法对 header 的数据进行压缩,减少需要传输的 header 大小,通讯双方各自缓存一份 header fields 表,差量更新 HTTP 头,既避免了重复 header 的传输,又减小了需要传输的大小。

  • 二进制传输 HTTP1.0 的解析是基于文本的,HTTP 2.0 会将所有的传输信息分割为更小的信息或者帧,并对它们进行二进制编码,基于二进制可以让协议有更多的扩展性,比如使用帧来传输数据和指令。 HTTP2.0 在应用层(HTTP2.0)和传输层(TCP/UDP)之间增加一个二进制分帧层。在不改动 HTTP1.x 的语义、方法、状态码、URI 以及首部字段的情况下,解决了 HTTP 1.1 的性能限制,改进传输性能,实现低延迟和高吞吐量。 在二进制分帧层中,HTTP2.0 会将所有传输的信息分割为更小的消息和帧(frame),并对它们采用二进制格式的编码,其中 HTTP1.x 的首部信息会被封装到 HEADER frame,而相应的 Request Body 则封装到 DATA frame 里面。

    帧: HTTP 2.0 数据通信的最小单位信息(指 HTTP 2.0 中逻辑上的 HTTP 信息)。例如请求和响应等,消息由一个或多个帧组成。 流: 存在于连接中的一个虚拟通道。流可以承载双向消息,每个流都有一个唯一的整数 ID。

  • 服务端推送 服务端推送是一种在客户端请求之前发送数据的机制。 服务端可以在发送 HTML 页面时主动推送其他资源,而不用等到浏览器解析到相应位置,发起请求再响应。例如,服务端可以主动把 JS、CSS 文件推送给服务端,而不需要客户端解析 HTML 时再发送这些请求。 服务端推送的这些资源被保存在了客户端的某处地方,客户端直接从本地加载这些资源就可以了,不用走网络,速度自然快很多。

2. 静态资源使用 CDN

CDN(内容分发网络,Content Delivery/Distribut Nectwork),是建立并覆盖在承载网之上,由分配在不同区域的边缘节点服务器群组成的分布式网络。

CDN 加速的本质是缓存加速。将服务器上存储的静态内容缓存到 CDN 节点上,当访问这些静态内容时,无需访问服务器源站,就近访问 CDN 节点即可获取相同内容,从而达到加速的效果,同时减轻服务器源站的压力。 简单来说,CDN就是根据用户位置分配最近的资源。

面试官:如何进行前端性能优化? CDN 工作原理 在没有 CDN 时,我们使用域名访问一个站点的路径为:用户提交域名 -> 浏览器对域名进行解析 -> DNS 解析得到目的主机的 IP 地址 -> 根据 IP 地址发出请求 -> 得到请求数据并回复。 使用 CDN 后,DNS 返回的不再是一个 IP 地址,而是一个 CName(Canonical Name)别名记录,指向 CDN 的全局负载均衡。

面试官:如何进行前端性能优化?

  1. 当用户在浏览器上输入 URL 时,经过本地 DNS 系统解析,DNS 系统会最终将域名的解析权交给 CNAME 指向的 CDN 专用 DNS 服务器;
  2. CDN 专用 DNS 服务器将 CDN 全局负载均衡设备的 IP 地址返回给用户;
  3. 用户向 CDN 全局负载均衡设备发起内容 URL 访问请求;
  4. CDN 全局负载均衡设备根据用户 IP 地址以及用户请求的内容 URL,选择一台用户所属区域的 CDN 区域负载均衡设备,告诉用户向这台设备发起请求;
  5. CDN 区域负载均衡设备会为用户选择一台合适的缓存服务器提供服务,然后 CDN 区域负载均衡设备会向全局负载均衡设备返回一台缓存服务器的 IP 地址(选择的依据包括:根据用户 IP 地址,判断哪一台服务器距用户最近;根据用户所请求的 URL 中携带的内容名称,判断哪一台服务器上有用户所需内容;查询各个服务器当前的负载情况,判断哪一台服务器尚有负载能力等等);
  6. 全局负载均衡设备把缓存服务器的 IP 地址返回给用户;
  7. 用户向缓存服务器发起请求,缓存服务器响应用户请求,将用户所需内容返回给用户。如果这台缓存服务器上没有用户想要的内容,而区域负载均衡设备依然将它分配给了用户,那么这台缓存服务器就需要向它的上一层缓存服务器请求内容,直至追溯到网站的源服务器将内容拉回到本地。

4. 请求接口优化

  • 接口请求合并

页面渲染优化

1. 骨架屏、Loading 图标

2. 服务端渲染(SSR)、Next.js

3. 虚拟列表

核心原理:整个虚拟列表分为三个区域,上缓冲区(0或2个元素)、可视区、下缓冲区(2个元素),每当我们滚动一个元素离开可视区时,就去掉上缓冲区最上面的一个元素,然后在下缓冲区增加一个元素。

JS 优化

1. 防抖和节流

  • 节流 规定在一个单位时间内只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。 适用场景: 节流适用于需要控制函数执行频率的场景,比如滚动、按钮点击、提交表单等。
    // 手写简化版实现
    // 1. 定时器实现(非立即执行版)
    const throttle = (fn, delay = 500) => {
      let flag = false;
      return (...args) => {
        if(flag) return;
        flag = true;
        setTimeout(() => {
          fn.apply(this, args);
            flag = false;
        }, delay);
      }
    }
    
    // 2. 时间戳实现(立即执行版)
    const throttle = (fn, delay = 500) => {
      let preTime = 0;
      return (...args) => {
            const nowTime = Date.now();
            if(nowTime - preTime >= delay) {
                preTime = Date.now();
                fn.apply(this, args);
            }
        }
    }
    
    // 3. 实现参数控制是立即执行还是非立即执行的节流
    const throttle = (fn, delay = 500, immediate = false) => {
      let preTime = immediate ? 0 : Date.now();
      return (...args) => {
          const nowTime = Date.now();
          if(nowTime - preTime >= delay) {
              preTime = Date.now();
              fn.apply(this, args);
          }
      }
    }
    
    // 4. lodash.throttle (https://www.lodashjs.com/docs/lodash.throttle)
    const throttle = lodash.throttle(() => fn(), delay, options)
    
  • 防抖 在事件被触发 n 秒后再执行回调,如果在这 n 秒内又被触发,则重新计时。 适用场景: 防抖适用于需要等待用户停止某个操作后执行的场景,如搜索、输入框变化、联想词等。
    // 手写简化版实现
    // 1. 定时器实现(非立即执行)
    const debounce = (fn, delay) => {
      let timeId = null;
      return (...args) => {
        if(timeId) {
          clearTimeout(timeId);
        }
        timeId = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      }
    };
    
    // 2. lodash.debounce(https://www.lodashjs.com/docs/lodash.debounce)
    const debounce = lodash.debounce(() => fun(), delay, options);
    
    // 3. 防抖如果需要立即执行,则需要第三个参数
    const debounce = function(func, delay, immediate) {
      let timeId;
      return function () {
        let context = this;
        let args = arguments;
        if (timeId) clearTimeout(timeId); // timeId 不为null
        if (immediate) {
          let callNow = !timeId; // 第一次会立即执行,以后只有事件执行后才会再次触发
          timeId = setTimeout(function () {
            timeId = null;
          }, delay)
          if (callNow) {
            func.apply(context, args);
          }
        } else {
          timeId = setTimeout(function () {
            func.apply(context, args);
          }, delay);
        }
      }
    }
    

CSS 优化

React 优化

  1. 尽量使用shouldComponentReact.PureComponentReact.memo,避免不必要的 render。
  2. 类组件中避免使用内联函数,避免每次调用 render 函数时都要重新创建一个新的函数实例。
    // bad
    <button onClick={() => handleClick()}>button1</button>
    
    // good
    <button onClick={handleClick}>button1</button>
    const handleClick = () => {};
    
  3. 使用Fragement标签减少层级,避免额外标记。
  4. 循环使用key,但注意key值不要设置为index
    <div>
      {
        array.map(item => <div key={item.id}>{item.name}</div>)
      }
    </div>
    
  5. Hook 组件使用useMemo缓存值,useCallback缓存函数。
  6. 使用React.lazySuspense组件来实现大组件按需加载/懒加载。
    const MyComponent = React.lazy(() => import('./MyComponent'));
    
    export const johanAsyncComponent = props => (
      <React.Suspense fallback={<Spinner />}>
        <johanComponent {...props} />
      </React.Suspense>
    );
    
  7. 路由懒加载 在使用前端路由时,使用按需加载路由组件,以减少初始加载体积。
    const Home = () => import('./Home.vue');
    

图片优化

1. 选择合适的图片格式

类型优点缺点适用场景
JPEG/JPG色彩丰富;文件小;无兼容问题有损压缩,反复保存图片质量下降明显;不支持动画;不支持透明色彩丰富的图片/渐变图像
PNG简单图片尺寸小;无损压缩;支持透明不支持动画;色彩丰富的图片尺寸大logo、icon、透明图
GIF文件小;支持动画、透明;无兼容性问题只支持 256 种颜色色彩简单的 logo、icon、动图
WebP文件小;支持有损和无损压缩;支持动画、透明浏览器兼容性不好支持 webp 格式的 app 和 webview

2. 图片压缩

  • webpack 压缩
    # 安装依赖
    npm install image-webpack-loader -D
    
    // 配置 webpack
    module.exports = {
      ...
      module: {
        rules: [
          {
            test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
            use: [
              {
                loader: 'file-loader',
                options: {
                  name: '[name].[hash:8].[ext]'
                },
              },
              {
                loader: 'image-webpack-loader',
                options: {
                  // 用于指定使用 MozJPEG 来压缩 JPEG 图片
                  mozjpeg: {
                    progressive: true,  // 表示是否使用渐进式的方式压缩图片,这意味着在图片加载过程中,用户可以逐步看到清晰度递增的图片
                    quality: 75,  // 质量(1-100),质量越高体积越大,质量越低体积越小;如果使用数组的话,就可以设置 0 到 1 之间的小数
                  },
                  // 用于指定使用 OptiPNG 来压缩 PNG 图片
                  optipng: {
                    enabled: true,  // 表示是否启用图片压缩的设置
                  },
                  // 用于指定使用 PNGQuant 来压缩 PNG 图片
                  pngquant: {
                    quality: [0.5, 0.65],
                    speed: 4,  // 指定压缩的速度(0-11),其中 0 为最快但质量最差,11 为最慢但质量最好
                  },
                  // 用于指定使用 Gifsicle 来压缩 GIF 图片
                  gifsicle: {
                    interlaced: false,
                  },
                  // 不支持WEBP就不要写这一项
                  webp: { 
                    quality: 75
                  },
                },
              },
            ],
          },
        ],
      },
    }
    
  • 工具压缩

3. 使用雪碧图

雪碧图(CSS Sprites),国内也叫 CSS 精灵,是一种 CSS 图像合成技术,主要用于小图片显示。 把诸多小图合成一张大图,用background-position属性来确定图片位置,可以有效的减少请求个数。 适用于页面多且图片丰富的场景。 在 webpack 中webpack-spritesmith插件提供了自动生成雪碧图的功能,并且可以自动生成对应的样式文件。

4. 使用 iconfont

iconfont(字体图标),通过字体的方式展示图标,多用于渲染图标、简单图形、特殊字体等。 阿里字体图标库

5. 小图片转为 base64 编码字符串

// webpack.prod.js
module.exports = {
  modules: {
    rules: [
      // 图片 — 考虑 base64 编码的情况
      {
        test: /\.(png|jpg|jpeg|gif)$/,
        use: {
          use: "url-loader",
          options: {
            limit: 5 * 1024,  // 小于 5kb 的图片用 base64 格式产出;否则,依然延用 file-loader 的形式,产出 url
            outputPath: "/img1/",  // 打包到 img 目录下
          },
        },
      },
    ]
  }
}

6. 图片懒加载

  • lazy='loading'
    <img src="img.png" lazy="loading" />
    
  • 使用 js 监听页面的滚动 使用 js 实现的主要原理是判断当前图片是否到了可视区域:
    1. 拿到所有的图片 DOM;
    2. 遍历每个图片,判断当前图片是否到达可视区与范围;
    3. 如果到了,就设置src属性(页面初始化时,图片地址设置在data-src属性上);
    4. 绑定windowscroll事件,对其进行事件监听。
    <img
      data-src="真实图片地址" 
      src="初始化图片地址"
    />
    <script>
      window.addEventListener('scroll', throttle(lazyLoad, 200));
      function lazyLoad() {
        let viewHeight = document.body.clientHeight; // 获取可视区高度
        let imgs = document.querySelectorAll('img[data-src]');
    
        imgs.forEach((item) => {
          if (!item.dataset.src) {
            return;
          }
          // 用于获得页面中某个元素的左,上,右和下分别相对浏览器视窗的位置
          let rect = item.getBoundingClientRect();
          if (rect.bottom >= 0 && rect.top < viewHeight) {
            item.src = item.dataset.src;
            item.removeAttribute('data-src');
          }
        });
      }
    </script>
    
  • 使用交叉观察器IntersectionObserver IntersectionObserver是浏览器原生提供的构造函数,可以自动“观察”元素是否可见。 IntersectionObserver接收两个参数:
    • callback:可见性变化时的回调函数;
    • option:配置选项,可选。
    目标元素的可见性变化时,会调用观察器的callback回调函数。callback一般会触发两次,一次是目标元素刚刚进入视口(开始可见),一次是完全离开视口(开始不可见)。
    const imgs = document.querySelectorAll('img[data-src]')
    const config = {
      rootMargin: '0px',
      threshold: 0,
    };
    let observer = new IntersectionObserver((entries, self) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          let img = entry.target;
          let src = img.dataset.src;
          if (src) {
            img.src = src;
            img.removeAttribute('data-src');
          }
          // 解除观察
          self.unobserve(entry.target);
        }
      })
    }, config);
    
    imgs.forEach((image) => {
      // 开始观察
      observer.observe(image);
    });
    

Webpack 打包优化

提高打包速度

1. 优化 babel loader

  • 优化 loader 的文件搜索范围
    module.exports = {
      module: {
        rules: [
          {
            test: /\.js$/,  // js 文件才使用 babel
            loader: ['babel-loader'],
            include: [resolve('src')],  // 只在 src 文件下查找
            exclude: /node_modules/  // 不会去查找的文件
            }
        ]
      }
    }
    
  • 将 babel 编译过的文件缓存起来
    module.exports = {
      module: {
        rules: [
          {
            test: /\.js$/,  // js 文件才使用 babel
            use: {
              loader: 'babel-loader',
              options: {
                cacheDirectory: true
              }
            },
          }
        ]
      }
    }
    

2. IgnorePlugin:避免引入无用模块

比如,使用 moment库:import moment from 'moment',默认会引入所有语言 js 代码,代码过大,那么如何只引入中文?

// index.js
import 'moment/locale/zh-cn';

// webpack.prod.js
module.exports = {
  plugin: [
    new webpack.IgnorePlugin({  // 忽略 moment 下的 /locale 目录
      resourceRegExp: /^\.\/locale$/,
      contextRegExp: /moment$/,
    }),
  ]
}

3. noParse:避免重复打包

module.exports = {
  module: {
    noParse: [/react\/.min\.js$/]
  }
}
  • IgnorePlugin 直接不引入,代码中没有
  • noParse 引入,但不打包

4. HappyPack:多进程打包

JS 是单线程的,开启多进程打包。 提高构建速度,特别是对于多核 CPU。 面试官:如何进行前端性能优化? happypack 已经不维护了,可以使用 thread-loader 代替

// webpack.prod.js
const HappyPack = require('happypack');
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ["happyPack/loader?id=babel"],  // 把对 .js 文件的处理转交给 id 为 babel 的 HappyPack 实例
        include: srcPath,
      },
    ]
  },
  plugins: [
    // happyPack 开启多进程打包
    new HappyPack({
      id: "babel",  // 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
      loaders: ["babel-loader?cacheDirectory"],   // 如何处理 .js 文件,用法和 Loader 配置中一样
    }),
  ]
}

5. ParallelUglifyPlugin:多进程压缩 JS

webpack 内置 Uglify 工具压缩 js。 JS 是单线程的,开启多进程压缩更快。 和 happyPack 同理。

关于开启多进程打包:

  • 项目较大,打包较慢,开启多进程能提高速度
  • 项目较小,打包很快,开启多进程会降低速度(进程开销)
// webpack.prod.js
module.exports = {
  plugins: [
    // 使用 ParallelUglifyPlugin 并行压缩输出的 js 代码
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      // 还是使用 UglifyJS 压缩,只不过帮助开启了多进程
      uglifyJS: {
        output: {
          beautify: false, // 最紧凑的输出
          comments: false, // 删除所有的注释
        },
        compress: {
          drop_console: true,  // 删除所有的 console 语句,可以兼容 IE 浏览器
          collapse_vars: true,  // 内嵌定义了但是只用过一次的变量
          reduce_vars: true,  // 提取出出现多次,但是没有被定义成变量去引用的静态值
        },
      },
    }),
  ]
}
/**
  * var a = 10;
  * var b = 20;
  * var c = a + b;
  * 会被编译成
  * var c = 30;
  */

6. 使用 CDN 加速

  1. 配置 CDN 的公共路径
    module.exports = {
      output: {
        filename: "[name].[contenthash:8].js", // name 即多入口时,entry 的 key
        path: distPath,
        publicPath: "https://cdn.abc.com", // 修改所有静态文件 url 的前缀(如 cdn 域名)
      },
      module: {
        rules: [
          {
            test: /\.(png|jpg|jpeg|gif)$/,
            use: {
              loader: "url-loader",
              options: {
                // 小于 5kb 的图片用 base64 格式产出
                // 否则,依然延用 file-loader 的形式,产出 url
                limit: 5 * 1024,
                outputPath: "/img1/",  // 打包到 img 目录下
                publicPath: "http://cdn.abc.com",  // 设置图片的 cdn 地址(也可以统一在外面的 )
              },
            },
          }
        ]
      }
    }
    
  2. 将打包后的结果(dist 目录)上传到 CDN 服务器上。

缩小打包体积

1. 按需加载

使用 Webpack 等构建工具可以通过代码分割和动态导入来实现按需加载。通过import()语法,可以在运行时动态加载模块。

const module = import('./module.js');
module.then((module) => {
    // 使用加载的模块
});

2. 提取公共代码和第三方代码

// webpack.prod.js
module.exports = {
  entry: {
    index: path.join(srcPath, 'index.js'),
    other: path.join(srcPath, 'other.js')
  },
  plugins: [
    // 多入口 —— 生成 index.html
    new HtmlWebpackPlugin({
      template: path.join(srcPath, 'index.html'),
      filename: 'index.html',
      // chunks 表示该页面需要引用哪些 chunk
      chunks: ['index', 'vendor', 'common'] // 考虑代码分割
    }),
    // 多入口 —— 生成 other.html
    new HtmlWebpackPlugin({
      template: path.join(srcPath, 'other.html'),
      filename: 'other.html',
      chunks: ['other', 'vendor', 'common']
    }),
  ],
  optimization: {
    // 分割代码块
    splitChunks: {
      /**
       * initial: 入口 chunks,对于异步导入的文件不处理
       * async: 异步 chunk,只对异步导入的文件处理
       * all: 全部 chunk
       */
      chunks: 'all',

      // 缓存分组
      cacheGroups: {
        // 第三方模块
        vendor: {
          name: 'vendor', // chunk 名称
          priority: 1, // 权限更高,优先抽离,重要!!
          test: /node_modules/,
          minSize: 0, // 大小限制
          minChunks: 1, // 最少复用几次
        },

        // 公共的模块
        common: {
          name: 'common', // chunk 名称
          priority: 0, // 优先级
          minSize: 0, // 公共模块的大小限制
          minChunks: 2 // 公共模块最少复用过几次
        }
      }
    }
  }
}

3. bundle 加 hash

hash 通常被作为前端静态资源实现增量更新的方案,通过在文件名上带上一串 hash 字符串,告诉浏览器该文件是否发生更新,从而决定是否要使用缓存机制。 Webpack 打包时的 hash 有三种:fullhash(Webpack4.x 之前的叫 hash,Webpack5.x 叫 fullhash 或 hash 都可)、chunkhash 和 contenthash。

在生产环境下,我们对 output 中打包的文件名一般采用 chunkhash,对于 css 等样式文件,采用 contenthash,这样可以使得每个模块最小范围的改变 hash 值。 一方面可以最大程度的利用浏览器缓存机制,提升用户体验; 另一方面,合理利用 hash 也减少了 webpack 再次打包所要处理的文件数量,提升了打包速度。

  • fullhash fullhash 是全量的 hash,是整个项目级别的,只要项目中有任何的一个文件发生变动,打包后所有文件的 hash 值都会改变。

    // webpack.config.js
    module.exports = {
       output: {
          path: path.resolve(__dirname, "./dist"),
          filename: "[name].[hash].js",
          clean: true,
        }
    }
    

    执行npx webpack命令进行项目打包。

    文件改动前打包文件改动后打包
    面试官:如何进行前端性能优化?面试官:如何进行前端性能优化?
  • chunkhash chunkhash 根据不同的入口文件(entry)进行依赖文件解析、构建对应的 chunk,生成对应的 hash 值。当某个文件内容发生变动时,再次执行打包,只有该文件以及依赖该文件的文件的打包结果 hash 值会发生改变。

    // webpack.config.js
    module.exports = {
       output: {
          path: path.resolve(__dirname, "./dist"),
          filename: "[name].[chunkhash].js",
          clean: true,
        }
    }
    
    文件改动前打包文件改动后打包
    面试官:如何进行前端性能优化?面试官:如何进行前端性能优化?

    因为改动了add.js文件,所以依赖了这个文件的index.js文件的 hash 值也会发生改变,但是除此之外index.css文件的 hash 值也发生了改变,这是因为在index.js中引用了index.css,打包后它们属于一个模块。

  • contenthash contenthash 是只有当文件自己的内容发生改变时,其打包的 hash 值才会发生变动。

    // webpack.config.js
    module.exports = {
       output: {
          path: path.resolve(__dirname, "./dist"),
          filename: "[name].[conenthash].js",
          clean: true,
        }
    }
    
    文件改动前打包文件改动后打包
    面试官:如何进行前端性能优化?面试官:如何进行前端性能优化?

    只有 add.js 和 index.js 文件的 hash 值发生了改变。

4. 使用production模式

module.exports = {
  mode: 'production'
}
  • 自动开启压缩
  • Vue、React 等会自动删掉测试代码(如开发环境的 waring)
  • 启用 Tree Shaking

Tree Shaking

Tree Shaking 可以用来删除项目中未被引用的代码。

5. Scope Hoisting 作用域提升

Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中。

const ModuleConcatenationPlugin = require('webpack/lib/ModuleConcatenationPlugin');

module.exports = {
  resolve: {
    // 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin()
  ]
}

6. 抽离压缩 CSS 文件

webpack.dev.js 文件配置:

module.exports = {
  rules: [
    {
      test: /\.css$/,
      // loader 的执行顺序是从后往前,postcss-loader 是处理浏览器兼容性问题的
      use: ['style-loader', 'css-loader', 'postcss-loader']
    },
    {
      test: /\.less$/,
      // 增加 less-loader,注意顺序
      use: ['style-loader', 'css-loader', 'less-loader']
    }
  ],
}

webpack.prod.js 文件配置:

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const TerserJSPlugin = require("terser-webpack-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");

module.exports = {
  rules: [
    // 抽离 css
    {
      test: /\.css$/,
      use: [
        MiniCssExtractPlugin.loader,
        'css-loader',
        'postcss-loader',
      ]
    },
    // 抽离 less
    {
      test: /\.less$/,
      use: [
        MiniCssExtractPlugin.loader,
        'css-loader',
        'less-loader',
        'postcss-loader'
      ]
    }
  ],
  
  plugins: [
    // 抽离 css 文件
    new MiniCssExtractPlugin({
      filename: 'css/main.[contenthash:8].css'
    })
  ],
  
  optimization: {
    // 压缩 css
    minimizer: [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugiin({})]
  }
}

三、持续跟进

  • 性能优化是一个循序渐进的过程,不像 bug 能一次性解决;
  • 持续跟进统计结果,再逐渐分析性能瓶颈,持续优化;
  • 可使用第三方统计服务,如阿里云 ARMS、百度统计等等。

『性能优化方案』持续更新中,有问题欢迎指正~

面试官:如何进行前端性能优化?

转载自:https://juejin.cn/post/7347504184068931619
评论
请登录