技术咨询、项目合作、广告投放、简历咨询、技术文档下载 点击这里 联系博主

# 性能优化实践

# 一、代码层面性能优化

# 1.1、v-if 和 v-show 区分使用场景

v-if 是 真正 的条件渲染,因为它会确保在切换过程中条件块内的事件监听器和子组件适当地被销毁和重建;也是惰性的:如果在初始渲染时条件为假,则什么也不做——直到条件第一次变为真时,才会开始渲染条件块。

v-show  就简单得多, 不管初始条件是什么,元素总是会被渲染,并且只是简单地基于 CSS 的 display 属性进行切换。

所以,v-if 适用于在运行时很少改变条件,不需要频繁切换条件的场景;v-show 则适用于需要非常频繁切换条件的场景。

# 1.2、 v-for 遍历必须为 item 添加 key,且避免同时使用 v-if

(1)v-for 遍历必须为 item 添加 key

在列表数据进行遍历渲染时,需要为每一项 item 设置唯一 key 值,方便 Vue.js 内部机制精准找到该条列表数据。当 state 更新时,新的状态值和旧的状态值对比,较快地定位到 diff 。

(2)v-for 遍历避免同时使用 v-if

v-for 比 v-if 优先级高,如果每一次都需要遍历整个数组,将会影响速度,尤其是当之需要渲染很小一部分的时候,必要情况下应该替换成 computed 属性。

推荐

<ul>
  <li
    v-for="user in activeUsers"
    :key="user.id">
    {{ user.name }}
  </li>
</ul>
computed: {
  activeUsers: function () {
    return this.users.filter(function (user) {
	 return user.isActive
    })
  }
}


# 1.3、长列表性能优化

Vue 会通过 Object.defineProperty 对数据进行劫持,来实现视图响应数据的变化,然而有些时候我们的组件就是纯粹的数据展示,不会有任何改变,我们就不需要 Vue 来劫持我们的数据,在大量数据展示的情况下,这能够很明显的减少组件初始化的时间,那如何禁止 Vue 劫持我们的数据呢?可以通过 Object.freeze或者使用Object.preventExtensions方法来冻结一个对象,一旦被冻结的对象就再也不能被修改了。

Object.freeze 和 Object.preventExtensions 介绍 (opens new window)

export default {
  data: () => ({
    users: {},
  }),
  async created() {
    const users = await axios.get("/api/users");
    this.users = Object.freeze(users);
  },
};

# 1.4、 事件的销毁

Vue 组件销毁时,会自动清理它与其它实例的连接,解绑它的全部指令及事件监听器,但是仅限于组件本身的事件。 如果在 js 内使用 addEventListener 等方式是不会自动销毁的,我们需要在组件销毁时手动移除这些事件的监听,以免造成内存泄露,如:

created() {
  addEventListener('click', this.click, false);
  //定时器
  timer=setInternal(()=>{
      ....
  },1000)
},
beforeDestroy() {
  removeEventListener('click', this.click, false);
  clearInternal(timer);//记得清理定时器
}

# 1.5、 图片资源懒加载

对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载。这样对于页面加载性能上会有很大的提升,也提高了用户体验。图片资源懒加载实现原理请见 这里 (opens new window)

大多数我们在项目中使用 Vue 的 vue-lazyload 插件:

vue-lazyload 地址 (opens new window)

import Vue from "vue";
import App from "./App.vue";
import VueLazyload from "vue-lazyload";

Vue.use(VueLazyload);

// or with options
Vue.use(VueLazyload, {
  preLoad: 1.3,
  error: "dist/error.png",
  loading: "dist/loading.gif",
  attempt: 1
});

new Vue({
  el: "body",
  components: {
    App
  }
});
//使用
<ul>
  <li v-for="img in list">
    <img v-lazy="img.src" >
  </li>
</ul>

# 1.6、 路由懒加载

Vue 是单页面应用,可能会有很多的路由引入 ,这样使用 webpcak 打包后的文件很大,当进入首页时,加载的资源过多,页面会出现白屏的情况,不利于用户体验。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就更加高效了。这样会大大提高首屏显示的速度,但是可能其他的页面的速度就会降下来。

const Foo = () => import("./Foo.vue");
const router = new VueRouter({
  routes: [{ path: "/foo", component: Foo }],
});

# 1.7、 第三方插件的按需引入

我们在项目中经常会需要引入第三方插件,如果我们直接引入整个插件,会导致项目的体积太大,我们可以借助 plugin-syntax-dynamic-import ,然后可以只引入需要的组件,以达到减小项目体积的目的。此处以引入 ant-designiview 为例

(1)安装

npm install --save-dev @babel/plugin-syntax-dynamic-import

(2)babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          browsers: ["last 3 versions", "ie>=9"],
        },
        useBuiltIns: "entry",
        debug: false,
      },
    ],
    "@vue/babel-preset-jsx",
  ],
  plugins: [
    "@babel/plugin-transform-runtime",
    "@babel/plugin-syntax-dynamic-import",
    "transform-vue-jsx",
    [
      "import",
      {
        libraryName: "ant-design-vue",
        libraryDirectory: "es",
        style: "css",
      },
      "ant-design-vue",
    ],
    [
      "import",
      {
        libraryName: "iview",
        libraryDirectory: "src/components",
      },
    ],
  ],
};

(3)组件中使用

import { Button } from "iview";

# 1.8、 使用虚拟列表

如果你的应用存在非常长或者无限滚动的列表,那么需要采用   窗口化   的技术来优化性能,只需要渲染少部分区域的内容,减少重新渲染组件和创建 dom 节点的时间。 你可以参考以下开源项目 vue-virtual-scroll-list (opens new window)  和  vue-virtual-scroller (opens new window) 来优化这种无限列表的场景的。

虚拟列表实现原理参考:浅说虚拟列表的实现原理 (opens new window)

# 1.9、 时间分片渲染大量数据

众所周知,页面的卡顿是由于同时渲染大量 DOM 所引起的,所以我们考虑将渲染过程分批进行。常见的分批渲染使用setTimeout,requestAnimationFrameDocumentFragment

<ul id="container"></ul>

(1) 使用 setTimeout 分片

//需要插入的容器
let ul = document.getElementById("container");
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total / once;
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal, curIndex) {
  if (curTotal <= 0) {
    return false;
  }
  //每页多少条
  let pageCount = Math.min(curTotal, once);
  setTimeout(() => {
    for (let i = 0; i < pageCount; i++) {
      let li = document.createElement("li");
      li.innerText = curIndex + i + " : " + ~~(Math.random() * total);
      ul.appendChild(li);
    }
    loop(curTotal - pageCount, curIndex + pageCount);
  }, 0);
}
loop(total, index);

使用 setTimeout 你会发现明显的闪屏现象,其主要原因如下:

  • setTimeout 的执行时间并不是确定的。在 JS 中,setTimeout 任务被放进事件队列中,只有主线程执行完才会去检查事件队列中的任务是否需要执行,因此 setTimeout 的实际执行时间可能会比其设定的时间晚一些。
  • 刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的刷新频率可能会不同,而 setTimeout 只能设置一个固定时间间隔,这个时间不一定和屏幕的刷新时间相同。

(2) 使用 requestAnimationFrame

与 setTimeout 相比,requestAnimationFrame 最大的优势是由系统来决定回调函数的执行时机。如果屏幕刷新率是 60Hz,那么回调函数就每 16.7ms 被执行一次,如果刷新率是 75Hz,那么这个时间间隔就变成了 1000/75=13.3ms,换句话说就是,requestAnimationFrame 的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象

//需要插入的容器
let ul = document.getElementById("container");
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total / once;
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal, curIndex) {
  if (curTotal <= 0) {
    return false;
  }
  //每页多少条
  let pageCount = Math.min(curTotal, once);
  window.requestAnimationFrame(function() {
    for (let i = 0; i < pageCount; i++) {
      let li = document.createElement("li");
      li.innerText = curIndex + i + " : " + ~~(Math.random() * total);
      ul.appendChild(li);
    }
    loop(curTotal - pageCount, curIndex + pageCount);
  });
}
loop(total, index);

此例参考自:「中高级前端」高性能渲染十万条数据(时间分片) (opens new window)

(3) 使用 DocumentFragment

从 MDN 的说明中,我们得知 DocumentFragments 是 DOM 节点,但并不是 DOM 树的一部分,可以认为是存在内存中的,所以将子元素插入到文档片段时不会引起页面回流。

//需要插入的容器
let ul = document.getElementById("container");
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total / once;
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal, curIndex) {
  if (curTotal <= 0) {
    return false;
  }
  //每页多少条
  let pageCount = Math.min(curTotal, once);
  window.requestAnimationFrame(function() {
    let fragment = document.createDocumentFragment();
    for (let i = 0; i < pageCount; i++) {
      let li = document.createElement("li");
      li.innerText = curIndex + i + " : " + ~~(Math.random() * total);
      fragment.appendChild(li);
    }
    ul.appendChild(fragment);
    loop(curTotal - pageCount, curIndex + pageCount);
  });
}
loop(total, index);

# 1.10、服务端渲染 SSR or 预渲染

服务端渲染是指 Vue 在客户端将标签渲染成的整个 html 片段的工作在服务端完成,服务端形成的 html 片段直接返回给客户端这个过程就叫做服务端渲染。

(1)服务端渲染的优点:

  • 更好的 SEO: 因为 SPA 页面的内容是通过 Ajax 获取,而搜索引擎爬取工具并不会等待 Ajax 异步完成后再抓取页面内容,所以在 SPA 中是抓取不到页面通过 Ajax 获取到的内容;而 SSR 是直接由服务端返回已经渲染好的页面(数据已经包含在页面中),所以搜索引擎爬取工具可以抓取渲染好的页面;
  • 更快的内容到达时间(首屏加载更快): SPA 会等待所有 Vue 编译后的 js 文件都下载完成后,才开始进行页面的渲染,文件下载等需要一定的时间等,所以首屏渲染需要一定的时间;SSR 直接由服务端渲染好页面直接返回显示,无需等待下载 js 文件及再去渲染等,所以 SSR 有更快的内容到达时间;

(2)服务端渲染的缺点:

  • 更多的开发条件限制: 例如服务端渲染只支持 beforCreate 和 created 两个钩子函数,这会导致一些外部扩展库需要特殊处理,才能在服务端渲染应用程序中运行;并且与可以部署在任何静态文件服务器上的完全静态单页面应用程序 SPA 不同,服务端渲染应用程序,需要处于 Node.js server 运行环境
  • 更多的服务器负载:在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源,因此如果你预料在高流量环境下使用,请准备相应的服务器负载,并明智地采用缓存策略。

# 1.11、 处理大量数据使用 Web Woker

Web Worker 通常应用于哪些方面呢?

  • 处理密集型数学计算
  • 大数据集排序
  • 数据处理(压缩、音频分析、图像处理等)
  • 高流量网络通信

(1)、Work 创建及终止

实例化

//1. 实例化woker,
var w1 = new Worker("http://some.url.1/mycoolworker.js");
  • 这个 URL 应该指向一个 JavaScript 文件的位置(而不是一个 HTML 页面!),这个文件将被加载到一个 Worker 中。然后浏览器启动一个独立的线程,让这个文件在这个线程中作为独立的程序运行。

  • 如果浏览器中有两个或多个页面(或同一页上的多个 tab !)试图从同一个文件 URL 创建 Worker,那么最终得到的实际上是完全独立的 Worker

(2)、数据收发

Worker 之间以及它们和主程序之间,不会共享任何作用域或资源,那会把所有多线程编程的噩梦带到前端领域,而是通过一个基本的事件消息机制相互联系`;

//2. 数据的获取(主线程发送消息给woker)
w1.addEventListener("message", function(evt) {
  // evt.data
});

//数据的发送(主线程收到woker的信息)
w1.postMessage("something cool to say");

// mydemo_woker.js
//在worker内部,监听主线程发给自己的信息
addEventListener("message", function(evt) {
  // evt.data
});
//发送数据给主线程
postMessage("a really cool reply");

注意,专用 Worker 和创建它的程序之间是一对一的关系。

通常由主页面应用程序创建 Worker,但若是需要的话,Worker 也可以实例化它自己的子 Worker,称为 subworker。有时候,把这样的细节委托给一个“主”Worker,由它来创建其他 Worker 处理部分任务,这样很有用。不幸的是,到写作本书时为 止,Chrome 还不支持 subworker,不过 Firefox 支持。

(3)、Work 的终止

//主页面手动终止
w1.terminate();

突然终止 Worker 线程不会给它任何机会完成它的工作或者清理任何资源。这就类似于通过关闭浏览器标签页来关闭页面。

(4)、Work 运行环境及外部脚本加载

Work 不可以访问主程序的任何资源,不能访问 dom 或者其他资源,但是可以执行网络操 作(Ajax、WebSockets)以及设定定时 器。还 有,Worker 可以访问几个重要的全局变量和功能的本地复 本,包括 navigator 、location 、JSON 和 applicationCache

可以通过 importScripts(..) 向 Worker 加载额外的 JavaScript 脚本:

// 在Worker内部
importScripts("foo.js", "bar.js");

这些脚本加载是同步的。也就是说,importScripts(..) 调用会阻塞余下 Worker 的执行,直到文件加载和执行完成。

# 1.12、 减少重绘和回流

重绘就是在不影响排版的情况下对这个元素重新绘制的过程。例如改变一个元素的背景颜色、字体颜色等。

回流:当 render tree 中的一部分(或全部)因为元素的规模尺寸,布局,隐藏等改变而需要重新构建。

常见的触发重绘和回流的情况:

  • 添加、删除元素(回流+重绘)
  • 隐藏元素,display:none(回流+重绘),visibility:hidden(只重绘,不回流)
  • 移动元素,比如改变 top、left(jquery 的 animate 方法就是改变 top、left 不一定会影响回流),或者移动元素到另外 1 个父元素中。(重绘+回流)
  • 对 style 的操作(对不同的属性操作,影响不一样)(color、background-color)=>(重绘) (padding、margin)=>(回流)
  • 浏览器大小改变 resize、font-size(重绘+回流)
  • transform/opacity (不会触发生重绘、回流)
  • 最复杂的一种:获取某些属性,引发回流 很多浏览器会对回流做优化,他会等到足够数量的变化发生,在做一次批处理回流。 但是除了 render 树的直接变化。 当获取一些属性时,浏览器为了获得正确的值也会触发回流。
    • offsetTop, offsetLeft, offsetWidth, offsetHeight
    • scrollTop/Left/Width/Height
    • clientTop/Left/Width/Height
    • width,height
    • 调用了 getComputedStyle(), 或者 IE 的 currentStyle

TIP

减少重绘和回流方案:

  • 避免逐项更改样式。最好一次性更改 style 属性,或者将样式列表定义为 class 并一次性更改 class 属性。
  • 避免循环操作 DOM。创建一个 documentFragment 或 div,在它上面应用所有 DOM 操作,最后再把它添加到 window.document。
  • 避免多次读取 offsetLeft 等属性。无法避免则将它们缓存到变量。
  • 将复杂的元素绝对定位或固定定位,使它脱离文档流。否则回流代价十分高
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • CSS 选择符从右往左匹配查找,避免 DOM 深度过深
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
  • 使用 translate 替代 top
  • 把 DOM 离线后修改,比如:先把 DOM 给 display:none (有一次 Reflow),然后你修改 100 次,然后再把它显示出来
  • 使用 flexbox 替代老的布局模型

# 1.13、 函数防抖和节流

防抖(debounce)

所谓防抖,就是指触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。

function debounce(func, wait) {
  let timeout;
  return function() {
    let context = this;
    let args = arguments;
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
}

节流(throttle)

所谓节流,就是指连续触发事件但是在 n 秒中只执行一次函数

function throttle(func, wait) {
  let timeout;
  return function() {
    let context = this;
    let args = arguments;
    if (!timeout) {
      timeout = setTimeout(() => {
        timeout = null;
        func.apply(context, args);
      }, wait);
    }
  };
}

# 1.14、SystemJS 动态加载 js

systemjs (opens new window)可以结合 vue 的动态组件实现,vue 组件动态化,本地代码最小化。

<script src="./system.js"> </script>
<script>
SystemJS.import("onejs").then(function(m) {
  Vue.component("component-name",m.default)
});
</script>

# 1.15、 页面预加载 preload

详情请见:页面预加载 preload;

# 1.16、 合理的使用 async 和 defer

# 二、前端性能清单

# 目录

  1. HTML
  2. CSS
  3. Fonts
  4. Images
  5. JavaScript
  6. Server (梳理中)
  7. JS Frameworks (梳理中)

# 概述

性能是一个很大的主题,但它并不总是一个“后端”或“管理(admin)”所要考虑的主题:它也是一个前端需要考虑的。作为前端开发人员,前端性能清单是你在项目中应该检查或者至少需要注意的性能要点的详尽列表。

# 如何使用?

对于每个规则,将有一个段落解释为什么此规则很重要以及如何解决它。有关更深入的信息,可相应找到可指向的 🛠 工具,📖 文章或 📹 媒体的链接,以便梳理。

前端性能清单中的所有项目都是获得最高性能得分的基本要素,但是你可以找到一些指标来帮助你最终确定一些规则的优先顺序。以下有 3 个级别的优先级:

  • low[low] 表示该项目的优先级较低,对项目有影响。
  • medium[medium] 表示该项目具有中等优先级并对项目产生影响,开发时需要处理这些项目。
  • high[high] 表示该项目具有高优先级并对项目产生影响,开发时必须要处理这些项目,不然性能将大打折扣。

# 性能测试工具

以下是一些您可以用来测试或监控您的网站或应用程序的工具:

# 参考


# HTML

html

  • [ ] 压缩 HTML: medium HTML 代码压缩,将注释、空格和新行从生产文件中删除。

为什么:

删除所有不必要的空格、注释和中断行将减少 HTML 的大小,加快网站的页面加载时间,并显著减少用户的下载时间。

怎么做:

大多数框架都有插件用来压缩网页的体积。你可以使用一组可以自动完成工作的 NPM 模块。

为什么:

注释对用户来说是没有用的,应该从生产环境文件中删除。可能需要保留注释的一种情况是:保留远端代码库(keep the origin for a library)。

怎么做:

大多数情况下,可以使用 HTML minify 插件删除注释。

  • [ ] 删除不必要的属性: lowtype="text/javascript" or type="text/css" 这样的属性应该被移除。
<!-- Before HTML5 -->
<script type="text/javascript">
  // Javascript code
</script>

<!-- Today -->
<script>
  // Javascript code
</script>

为什么

类型属性不是必需的,因为 HTML5 把 text/css 和 text/javascript 作为默认值。没用的代码应在网站或应用程序中删除,因为它们会使网页体积增大。

怎么做:

确保所有`标记都没有任何 type 属性。

  • 📖 The Script Tag | CSS-Tricks (opens new window)

  • [ ] 在 JavaScript 引用之前引用 CSS 标记: high 确保在使用 JavaScript 代码之前加载 CSS。

    <!-- 不推荐 -->
    <script src="jquery.js"></script>
    <script src="foo.js"></script>
    <link rel="stylesheet" href="foo.css" />
    
    <!-- 推荐 -->
    <link rel="stylesheet" href="foo.css" />
    <script src="jquery.js"></script>
    <script src="foo.js"></script>
    

    为什么:

    在引用 JavaScript 之前引用 CSS 可以实现更好地并行下载,从而加快浏览器的渲染速度。

    怎么做:

    确保<head>中的<link/><style/>始终位于<script/>之前。

  • [ ] 最小化 iframe 的数量: high 仅在没有任何其他技术可行性时才使用 iframe。尽量避免使用 iframe。

  • [ ] DNS 预取: high 一次 DNS 查询时间大概在 60-120ms 之间或者更长,提前解析网页中可能的网络连接域名

    <link rel="dns-prefetch" href="http://example.com/" />
    

⬆ 返回顶部

# CSS

css

⬆ 返回顶部

# 字体

fonts

⬆ 返回顶部

# 图片

images

  • [ ] 使用矢量图像 VS 栅格/位图: medium 可以的话,推荐使用矢量图像而不是位图图像。

    为什么:

    矢量图像(SVG)往往比图像小,具有响应性和完美缩放功能。而且这些图像可以通过 CSS 进行动画和修改操作。

⬆ 返回顶部

# JavaScript

javascript

  • [ ] 不内嵌 JavaScript: medium (仅对网站有效) 避免在body中间嵌入多个 JavaScript 代码,将 JavaScript 代码重新集中到外部文件中,放在<head>或页面末尾(</body>之前)。

    为什么:

    将 JavaScript 嵌入代码直接放在<body>中可能会降低页面速度,因为它在构建 DOM 时会加载。最好的选择是使用asyncdefer的外部文件来避免阻塞 DOM 渲染。另一种选择是在<head>中放置一些脚本。大多数时候是需要在 DOM 进入主处理之前加载的分析代码或小脚本。

    怎么做:

    确保使用 async 或 defer 加载所有 script 文件,并准确地在<head>中加载代码。

  • [ ] 非阻塞 JavaScript: high 使用 defer 属性或使用 async 来异步加载 JavaScript 文件。

    <!-- Defer Attribute -->
    <script defer src="foo.js">
    
    <!-- Async Attribute -->
    <script async src="foo.js">
    

    为什么:

    JavaScript 阻止 HTML 文档的正常解析,因此当解析器到达<script>标记时(特别是在<head>内),它会停止解析并且执行脚本。如果您的脚本位于页面顶部,则强烈建议添加asyncdefer,但如果在</body>标记之前加载,没有太大影响。但是,使用这些属性来避免性能问题是一种很好的做法。

    怎么做:

    添加async(如果脚本不依赖于其他脚本)或defer(如果脚本依赖或依赖于异步脚本)作为 script 脚本标记的属性。 如果有小脚本,可以在异步脚本上方使用内联脚本。

  • [ ] 优化和更新的 JS 库: medium 项目中使用的所有 JavaScript 库都是有用到的 (推荐使用原生 JS 的简单功能)并更新到最新版本

    为什么:

    大多数情况下,新版本都带有优化和安全性修复,所以应该使用最优化的代码来优化项目。确保不存在过时插件。

    怎么做:

    如果项目使用 NPM 管理依赖包,npm-check (opens new window)是一个非常有用的库来升级/更新你的库。

  • [ ] 使用 tree shaking 技术减少 js 大小: high 通过构建工具分析 JavaScript 代码并移除生产环境中用不到的 js 模块或方法

    * 📖 [
    

    Reduce JavaScript Payloads with Tree Shaking](https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking/)

  • [ ] 使用 code splitting 分包加载 js: high 通过分包加载,减少首次加载所需时间

    怎么做:

    • Vendor splitting 根据库文件拆分模块,例如 React 或 lodash 单独打包成一个文件
    • Entry point splitting 根据入口拆分模块,例如通过多页应用入口或者单页应用路由进行拆分
    • Dynamic splitting 根据动态加载拆分模块,使用动态加载语法 import() ,实现模块按需加载

⬆ 返回顶部

# Server

server-side

  • [ ] 最小化 HTTP 请求: high 始终确保所请求的每个文件对网站或应用程序至关重要,尽可能减少 http 请求。
  • [ ] 使用 CDN 提供静态文件: medium 使用 CDN 可以更快地在全球范围内获取到你的静态文件。
  • [ ] 提供来自相同协议的文件: high 避免网站使用 HTTPS 同时使用 HTTP 来提供相同源地址的文件。

  • [ ] 提供可访问的文件: high 避免请求无法访问的文件(404)。

  • [ ] 正确设置 HTTP 缓存标头: high 合理设置 HTTP 缓存标头来减少 http 请求次数。

  • [ ] 启用 GZIP 压缩 high使用压缩方法(如 Gzip 或 Brotli)来减小 JavaScript 文件的大小。使用较小尺寸的文件,用户可以更快地下载资源,从而提高性能。

  • [ ] 分域存放资源: medium 由于浏览器同一域名并行下载数有限,利用多域名主机存放静态资源,增加并行下载数,缩短资源加载时间

  • [ ] 减少页面重定向 high

⬆ 返回顶部


# 性能与前端框架

# Angular

# React

# WordPress

# 文章
# 插件推荐

# Translations

The Front-End Performance Checklist wants to also be available in other languages! Don't hesitate to submit your contribution!

# 三(pre)、Webpack 优化前分析

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  • 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
  • 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译
  • 确定入口:根据配置中的 entry 找出所有的入口文件
  • 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  • 完成模块编译:在经过上一步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统在;
  1. 开始打包,我们需要获取所有的依赖模块(开始编译及确定入口) 搜索所有的依赖项,这需要占用一定的时间,即搜索时间,那么我们就确定了:

我们需要优化的第一个时间就是搜索时间(Webpack 优化构建速度-缩小文件搜索范围)

  1. 解析所有的依赖模块(解析成浏览器可运行的代码) webpack 根据我们配置的 loader 解析相应的文件。日常开发中我们需要使用 loader 对 js ,css ,图片,字体等文件做转换操作,并且转换的文件数据量也是非常大。由于 js 单线程的特性使得这些转换操作不能并发处理文件,而是需要一个个文件进行处理。

我们需要优化的第二个时间就是解析时间,(Webpack 优化构建速度-减少编译次数及并发打包)

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

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

我们需要优化的第三个时间就是压缩时间。(优化输出质量-压缩文件体积)

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

我们需要优化的第四个时间就是二次打包时间。(缓存)

# 三、Webpack 优化构建速度

# 3.1、 缩小文件搜索范围

搜索过程优化包括:

TIP

  • 优化 resolve.modules 配置
  • 优化 resolve.noParse 配置
  • 优化 resolve.extensions 配置
  • 优化 resolve.noParse 配置
  • 优化 优化 Loader 配置
  1. 优化 resolve.modules 配置

    resolve.modules 用于配置 Webpack 去哪些目录下寻找第三方模块。resolve.modules 的默认值是[node modules],含义是先去当前目录的/node modules 目录下去找我们想找的模块,如果没找到,就去上一级目录../node modules 中找,再没有就去../ .. /node modules 中找,以此类推,这和 Node.js 的模块寻找机制很相似。当安装的第三方模块都放在项目根目录的./node modules 目录下时,就没有必要按照默认的方式去一层层地寻找,可以指明存放第三方模块的绝对路径,以减少寻找。

优化后的配置

resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
modules: [path.resolve(__dirname,'node_modules')]
},
  • 设置 resolve.mainFields:['main'],设置尽量少的值可以减少入口文件的搜索步骤.

    第三方模块为了适应不同的使用环境,会定义多个入口文件,mainFields 定义使用第三方模块的哪个入口文件,由于大多数第三方模块都使用 main 字段描述入口文件的位置,所以可以设置单独一个 main 值,减少搜索

  1. 优化 resolve.alias 配置

对庞大的第三方模块设置 resolve.alias, 使 webpack 直接使用库的 min 文件,避免库内解析。

 alias: {
  '@': resolve('src'),
},
// 通过以上的配置,引用src底下的common.js文件,就可以直接这么写
import common from '@/common.js';

这样会影响 Tree-Shaking,适合对整体性比较强的库使用,如果是像 lodash 这类工具类的比较分散的库,比较适合 Tree-Shaking,避免使用这种方式。

  1. 优化 resolve.extensions

合理配置 resolve.extensions,减少文件查找 默认值:extensions:['.js', '.json'],当导入语句没带文件后缀时,Webpack 会根据 extensions 定义的后缀列表进行文件查找,所以:

  • 列表值尽量少
  • 频率高的文件类型的后缀写在前面
  • 源码中的导入语句尽可能的写上文件后缀,如 require(./data)要写成 require(./data.json)
  1. module.noParse字段告诉 Webpack 不必解析哪些文件,可以用来排除对非模块化库文件的解析.

noParse 配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析和处理,这 样做的好处是能提高构建性能。原因是一些库如 jQuery、ChartJS 庞大又没有采用模块化标准,让 Webpack 去解析这些文件既耗时又没有意义。 noParse 是可选的配置项,类型需要是 RegExp 、[RegExp]、function 中的一种。例如,若想要忽略 jQuery 、ChartJS ,则优化配置如下:

// 使用正则表达式
noParse: /jquerylchartjs/;
// 使用函数,从 Webpack3.0.0开始支持
noParse: (content) => {
  // 返回true或false
  return /jquery|chartjs/.test(content);
};
  1. 优化 loader 配置,通过 test、exclude、include 缩小搜索范围;
  • (1)优化正则匹配
  • (2)通过 cacheDirectory 选项开启缓存
  • (3)通过 include、exclude 来减少被处理的文件
{
  // 1、如果项目源码中只有js文件,就不要写成/\.jsx?$/,以提升正则表达式的性能
  test: /\.js$/,
  // 2、babel-loader支持缓存转换出的结果,通过cacheDirectory选项开启
  loader: 'babel-loader?cacheDirectory',
  // 3、只对项目根目录下的src 目录中的文件采用 babel-loader
  include: [resolve('src')]
},


# 3.2、使用 DllPlugin 减少基础模块编译次数

DllPlugin 动态链接库插件,其原理是把网页依赖的基础模块抽离出来打包到dll文件中,当需要导入的模块存在于某个dll中时,这个模块不再被打包,而是去 dll 中获取。 为什么会提升构建速度呢? 原因在于 dll 中大多包含的是常用的第三方模块,如 react、react-dom,所以只要这些模块版本不升级,就只需被编译一次。我认为这样做和配置 resolve.alias 和 module.noParse 的效果有异曲同工的效果。

详情 demo 请见:lucky_vue_template (opens new window)

  1. 使用 DllPlugin 配置一个 webpack.dll.config.js 来构建 dll 文件:
var path = require("path");
var webpack = require("webpack");
var CleanWebpackPlugin = require("clean-webpack-plugin");

var vendor = ["vue", "vuex", "vue-router"];
var vendordev = ["vue/dist/vue.esm.js", "vuex", "vue-router"]; //集成开发版本vue
var config = require("./config/config.js");
module.exports = {
  //你想要打包的模块数组
  entry: {
    vendor: vendor,
    vendordev: vendordev,
  },
  output: {
    path: path.join(__dirname, config.dllRoot),
    filename: "[name].dll.js",
    library: "[name]_library",
    //vendor.dll.js 中暴露出的全局变量
    //主要是给DllPlugin中的name 使用
    //故这里需要和webpack.DllPlugin 中的 'name :[name]_libray 保持一致
  },
  plugins: [
    new CleanWebpackPlugin(),
    new webpack.DllPlugin({
      path: path.join(__dirname, config.dllRoot, "[name]-manifest.json"),
      name: "[name]_library",
      context: __dirname,
    }),
  ],
};

需要注意 DllPlugin 的参数中 name 值必须和 output.library 值保持一致,并且生成的 manifest 文件中会引用 output.library 值。

  1. 在主 config 文件里使用 DllReferencePlugin 插件引入 xxx-manifest.json 文件:
[
  new webpack.DllReferencePlugin({
    context: __dirname,
    manifest: require(config.dllPath(true, true)),
  }),

  new HtmlWebpackTagsPlugin({ tags: ["/dll/vendor.dll.js"], append: false }),

  new CopyWebpackPlugin([
    {
      from: path.join(__dirname, config.dllPath(true, false)),
      to: path.join(__dirname, config.dllVendorTarget),
    },
  ]),
];

# 3.3、使用 externals 减少基础模块编译次数

我们在使用的 js 库如 vue 或者 react 等的时候,webpack 会将它们一起打包,react 和 react-dom 文件就好几百 k,全部打包成一个文件,可想而知,这个文件会很大,用户在首次打开时就往往会出现白屏等待时间过长的问题,这时,我们就需要将这类文件抽离出来。

externals: {
        "react": "React",
        "react-dom": "ReactDOM"
    },

这里我们会用到 externals,它和 plugins 是平级。左侧 key 表示依赖,右侧 value 表示文件中使用的对象。比如在 react 开发中,我们常常会这样在文件头部写 import React from 'react',这里大家可以和上面对号入座下。

这里我们就需要对这个文件进行单独的引入使用了,在 index.html 中添加如下代码

<script src="./node_modules/react/umd/react.xxx.js"></script>
<script src="./node_modules/react-dom/umd/react-dom.xxx.js"></script>

不过,我们在项目上线的时候不可能会带有 node_modules,所以我们就需要使用一个 copy 插件将 react 和 react-dom 文件复制出来

 new CopyWebpackPlugin([ // from是要copy的文件,to是生成出来的文件
            { from: "node_modules/react/umd/react.xxx.js", to: "js/react.min.js" },
            { from: "node_modules/react-dom/umd/react-dom.xxx.js", to: "js/react-dom.min.js" }
            { from: "public/favicon.ico", to: "favicon.ico" }
        ])

# 3.4、使用 多进程解析和处理文件(thread-loader)

  1. webpack4 及以后使用 thread-loader 进行多进程解析

把这个 loader 放置在其他 loader 之前, 放置在这个 loader 之后的 loader 就会在一个单独的 worker【worker pool】 池里运行,一个 worker 就是一个 nodeJS 进程【node.js proces】,每个单独进程处理时间上限为600ms,各个进程的数据交换也会限制在这个时间内。

thread-loader 使用起来也非常简单,只要把 thread-loader 放置在其他 loader 之前, 那 thread-loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行

thread-loader 的一些限制

  • 这些 loader 不能产生新的文件。
  • 这些 loader 不能使用定制的 loader API(也就是说,通过插件)。
  • 这些 loader 无法获取 webpack 的选项设置。
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        // 创建一个 js worker 池
        use: ["thread-loader", "babel-loader"],
      },
      {
        test: /\.s?css$/,
        exclude: /node_modules/,
        // 创建一个 css worker 池
        use: [
          "style-loader",
          "thread-loader",
          {
            loader: "css-loader",
            options: {
              modules: true,
              localIdentName: "[name]__[local]--[hash:base64:5]",
              importLoaders: 1,
            },
          },
          "postcss-loader",
        ],
      },
      // ...
    ],
    // ...
  },
  // ...
};

注意:thread-loader 放在了 style-loader 之后,这是因为 thread-loader 没法存取文件也没法获取 webpack 的选项设置。

  1. webpack4 之前使用 happypack 进行多进程解析

由于有大量文件需要解析和处理,所以构建是文件读写和计算密集型的操作,特别是当文件数量变多后,Webpack 构建慢的问题会显得更为严重。运行在 Node.之上的 Webpack 是单线程模型的,也就是说 Webpack 需要一个一个地处理任务,不能同时处理多个任务。Happy Pack ( https://github.com/amireh/happypack )就能让 Webpack 做到这一点,它将任务分解给多个子进程去并发执行,子进程处理完后再将结果发送给主进

配置如下

  • HappyPack 插件安装: $ npm i -D happypack

  • webpack.base.conf.js 文件对 module.rules 进行配置

    module: {
     rules: [
      {
        test: /\.js$/,
        // 将对.js 文件的处理转交给 id 为 babel 的HappyPack实例
          use:['happypack/loader?id=babel'],
          include: [resolve('src'), resolve('test'),
            resolve('node_modules/webpack-dev-server/client')],
        // 排除第三方插件
          exclude:path.resolve(__dirname,'node_modules'),
        },
        {
          test: /\.vue$/,
          use: ['happypack/loader?id=vue'],
        },
      ]
    },
  • (3)webpack.prod.conf.js 文件进行配置
const HappyPack = require("happypack");
// 构造出共享进程池,在进程池中包含5个子进程
const HappyPackThreadPool = HappyPack.ThreadPool({ size: 5 });
plugins: [
  new HappyPack({
    // 用唯一的标识符id,来代表当前的HappyPack是用来处理一类特定的文件
    id: "vue",
    loaders: [
      {
        loader: "vue-loader",
        options: vueLoaderConfig,
      },
    ],
    threadPool: HappyPackThreadPool,
  }),

  new HappyPack({
    // 用唯一的标识符id,来代表当前的HappyPack是用来处理一类特定的文件
    id: "babel",
    // 如何处理.js文件,用法和Loader配置中一样
    loaders: ["babel-loader?cacheDirectory"],
    threadPool: HappyPackThreadPool,
  }),
];

# 3.5、合理利用缓存(缩短连续构建时间,增加初始构建时间)

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

如果你的项目生产版本每次都必须进行初始构建的话,缓存会增加构建时间,减慢你的速度。如果不是,那它们就会大大缩减你二次构建的时间。

  1. cache-loader

cache-loader 和 thread-loader 一样,使用起来也很简单,仅仅需要在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘里,显著提升二次构建速度。

module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: ["cache-loader", ...loaders],
        include: path.resolve("src"),
      },
    ],
  },
};

⚠️ 请注意,保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader。

  1. HardSourceWebpackPlugin
  • 第一次构建将花费正常的时间
  • 第二次构建将显着加快(大概提升 90%的构建速度)

HardSourceWebpackPlugin 建议不要和 cache-loader 一起使用,初始化的时候会产生创建缓存等开销,如果两个一起使用,相当于对很多同样的内容做了两次缓存。

const HardSourceWebpackPlugin = require("hard-source-webpack-plugin");
const clientWebpackConfig = {
  // ...
  plugins: [
    new HardSourceWebpackPlugin({
      // cacheDirectory是在高速缓存写入。默认情况下,将缓存存储在node_modules下的目录中
      // 'node_modules/.cache/hard-source/[confighash]'
      cacheDirectory: path.join(
        __dirname,
        "./lib/.cache/hard-source/[confighash]"
      ),
      // configHash在启动webpack实例时转换webpack配置,
      // 并用于cacheDirectory为不同的webpack配置构建不同的缓存
      configHash: function(webpackConfig) {
        // node-object-hash on npm can be used to build this.
        return require("node-object-hash")({ sort: false }).hash(webpackConfig);
      },
      // 当加载器、插件、其他构建时脚本或其他动态依赖项发生更改时,
      // hard-source需要替换缓存以确保输出正确。
      // environmentHash被用来确定这一点。如果散列与先前的构建不同,则将使用新的缓存
      environmentHash: {
        root: process.cwd(),
        directories: [],
        files: ["package-lock.json", "yarn.lock"],
      },
      // An object. 控制来源
      info: {
        // 'none' or 'test'.
        mode: "none",
        // 'debug', 'log', 'info', 'warn', or 'error'.
        level: "debug",
      },
      // Clean up large, old caches automatically.
      cachePrune: {
        // Caches younger than `maxAge` are not considered for deletion. They must
        // be at least this (default: 2 days) old in milliseconds.
        maxAge: 2 * 24 * 60 * 60 * 1000,
        // All caches together must be larger than `sizeThreshold` before any
        // caches will be deleted. Together they must be at least this
        // (default: 50 MB) big in bytes.
        sizeThreshold: 50 * 1024 * 1024,
      },
    }),
    new HardSourceWebpackPlugin.ExcludeModulePlugin([
      {
        test: /.*\.DS_Store/,
      },
    ]),
  ],
};

cache-loaderhard-source-webpack-plugin的性能对比:

# 3.6、 使用 terser 开启多进程压缩 JS 文件

  1. webpack4+使用terser-webpack-plugin (opens new window)

webpack4 中 webpack.optimize.UglifyJsPlugin 已被废弃。

也不推荐使用 ParallelUglifyPlugin,项目基本处于没人维护的阶段,issue 没人处理,pr 没人合并。

webpack4 默认内置使用 terser-webpack-plugin 插件压缩优化代码,而该插件使用 terser 来缩小 JavaScript

  • terser 是什么?

所谓 terser,官方给出的定义是:

用于 ES6+ 的 JavaScript 解析器、mangler/compressor(压缩器)工具包。

  • 为什么 webpack 选择 terser?

不再维护 uglify-es ,并且 uglify-js 不支持 ES6 +。

terser 是 uglify-es 的一个分支,主要保留了与 uglify-esuglify-js@3 的 API 和 CLI 兼容性。

使用多进程并行运行来提高构建速度。并发运行的默认数量为 os.cpus().length - 1 。其他详情配置请见terser-webpack-plugin (opens new window)

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: true,
      }),
    ],
  },
};
  1. webpack3 使用 ParallelUglifyPlugin

由于压缩 JavaScript 代码时,需要先将代码解析成用 Object 抽象表示的 AST 语法树,再去应用各种规则分析和处理 AST ,所以导致这个过程的计算量巨大,耗时非常多。当 Webpack 有多个 JavaScript 文件需要输出和压缩时,原本会使用 UglifyJS 去一个一个压缩再输出,但是 ParallelUglifyPlugin 会开启多个子进程,将对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过 UglifyJS 去压缩代码,但是变成了并行执行。所以 ParallelUglify Plugin 能更快地完成对多个文件的压缩工作。

1)ParallelUglifyPlugin插件安装:
     $ npm i -D webpack-parallel-uglify-plugin
(2)webpack.prod.conf.js 文件进行配置
    const ParallelUglifyPlugin =require('webpack-parallel-uglify-plugin');
    plugins: [
    new ParallelUglifyPlugin({
      cacheDir: '.cache/',
      uglifyJs:{
        compress: {
          warnings: false
        },
        sourceMap: true
      }
     }),
    ]


# 3.7、使用自动刷新

借助自动化的手段,在监听到本地源码文件发生变化时,自动重新构建出可运行的代码后再控制浏览器刷新。Webpack 将这些功能都内置了,并且提供了多种方案供我们选择。

Webpack 可以使用两种方式开启监听:

  1. 启动 webpack 时加上--watch 参数;
  2. 在配置文件中设置 watch:true

Webpack 配置 官方文档点击这里 (opens new window)

module.exports = {
  watch: true,
  watchOptions: {
    ignored: /node_modules/,
    aggregateTimeout: 300, //文件变动后多久发起构建,越大越好
    poll: 1000, //每秒询问次数,越小越好
  },
};

相关优化措施:

  • (1)配置忽略一些不监听的一些文件,如:node_modules。
  • (2)watchOptions.aggregateTirneout 的值越大性能越好,因为这能降低重新构建的频率。
  • (3)watchOptions.poll 的值越小越好,因为这能降低检查的频率。

Vue Cli 可配置如下:

devServer: {
  watchOptions: {
    // 不监听的文件或文件夹,支持正则匹配
    ignored: /node_modules/,
    // 监听到变化后等300ms再去执行动作
    aggregateTimeout: 300,
    // 默认每秒询问1000次
    poll: 1000
  }
},


# 3.8、开启模块热替换

模块热替换不刷新整个网页而只重新编译发生变化的模块,并用新模块替换老模块,所以预览反应更快,等待时间更少,同时不刷新页面能保留当前网页的运行状态。原理也是向每一个 chunk 中注入代理客户端来连接 DevServer 和网页。开启方式:

  • webpack-dev-server --hot
  • 使用 HotModuleReplacementPlugin,比较麻烦

开启后如果修改子模块就可以实现局部刷新,但如果修改的是根JS文件,会整页刷新,原因在于,子模块更新时,事件一层层向上传递,直到某层的文件接收了当前变化的模块,然后执行回调函数。如果一层层向外抛直到最外层都没有文件接收,就会刷新整页。 使用 NamedModulesPlugin 可以使控制台打印出被替换的模块的名称而非数字ID,另外同 webpack 监听,忽略node_modules目录的文件可以提升性能

devServer: {
  hot: true,
},
plugins: [
  new webpack.HotModuleReplacementPlugin(),
// 显示被替换模块的名称
  new webpack.NamedModulesPlugin(), // HMR shows correct file names
]


# 四、优化输出质量-压缩文件体积

# 4.1、区分环境--减小生产环境代码体积

代码运行环境分为开发环境和生产环境,代码需要根据不同环境做不同的操作,许多第三方库中也有大量的根据开发环境判断的 if else 代码,构建也需要根据不同环境输出不同的代码,所以需要一套机制可以在源码中区分环境,区分环境之后可以使输出的生产环境的代码体积减小。

环境配置:

//生产环境
mode: "pruduction";
//测试环境
mode: "development";

代码使用:

if (process.env.NODE_ENV === "production") {
  console.log("你在生产环境");
  doSth();
} else {
  console.log("你在开发环境");
  doSthElse();
}

# 4.2、压缩代码-JS、ES、CSS

  1. webpack4+: 压缩 js

如果你使用的是 webpack v5 或以上版本,你不需要安装这个插件。webpack v5 自带最新的 terser-webpack-plugin。如果使用 webpack v4,则必须安装 terser-webpack-plugin v4 的版本。

webpack5 已经默认开启代码压缩, 使用TerserWebpackPlugin (opens new window)

const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};
  1. webpack3: 压缩 ES6:第三方 UglifyJS 插件

随着越来越多的浏览器支持直接执行 ES6 代码,应尽可能的运行原生 ES6,这样比起转换后的 ES5 代码,代码量更少,且 ES6 代码性能更好。直接运行 ES6 代码时,也需要代码压缩,第三方的uglify-webpack-plugin提供了压缩 ES6 代码的功能。

另外要防止 babel-loader 转换 ES6 代码,要在.babelrc 中去掉 babel-preset-env,因为正是 babel-preset-env 负责把 ES6 转换为 ES5。

npm i -D uglify-webpack-plugin@beta //要使用最新版本的插件
//webpack.config.json
const UglifyESPlugin = require('uglify-webpack-plugin');
//...
plugins:[
    new UglifyESPlugin({
        uglifyOptions: {  //比UglifyJS多嵌套一层
            compress: {
                warnings: false,
                drop_console: true,
                collapse_vars: true,
                reduce_vars: true
            },
            output: {
                beautify: false,
                comments: false
            }
        }
    })
]

# 4.3、无用 css 移除及 css 压缩

snano 基于 PostCSS,不仅是删掉空格,还能理解代码含义,例如把color:#ff0000 转换成 color:red,css-loader 内置了 cssnano,只需要使用 css-loader?minimize 就可以开启 cssnano 压缩。

  1. 使用css-minimizer-webpack-plugin (opens new window)压缩 css
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = {
  module: {
    loaders: [
      {
        test: /.s?css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
      },
    ],
  },
  optimization: {
    minimize: true,
    minimizer: [
      // 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释
      // `...`,
      new CssMinimizerPlugin(),
    ],
  },
};
  1. 使用purgecss-webpack-plugin (opens new window) 移除无用 css 代码
const path = require("path");
const glob = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const PurgeCSSPlugin = require("purgecss-webpack-plugin");

const PATHS = {
  src: path.join(__dirname, "src"),
};

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.join(__dirname, "dist"),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: "styles",
          test: /\.css$/,
          chunks: "all",
          enforce: true,
        },
      },
    },
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
    }),
    new PurgeCSSPlugin({
      paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
    }),
  ],
};

# 4.4、压缩图片

在 vue 项目中除了可以在 webpack.base.conf.js 中 url-loader 中设置 limit 大小来对图片处理,对小于 limit 的图片转化为 base64 格式,其余的不做操作。所以对有些较大的图片资源,在请求资源的时候,加载会很慢,我们可以用 image-webpack-loader 来压缩图片:

  • (1)首先,安装 image-webpack-loader :
npm install image-webpack-loader --save-dev

  • (2) 然后,在 webpack.base.conf.js 中进行配置:
{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000,
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

# 4.5、开启 gzip 压缩

gzip 是 GNUzip 的缩写,最早用于 UNIX 系统的文件压缩。HTTP 协议上的 gzip 编码是一种用来改进 web 应用程序性能的技术,web 服务器和客户端(浏览器)必须共同支持 gzip。目前主流的浏览器,Chrome,firefox,IE 等都支持该协议。常见的服务器如 Apache,Nginx,IIS 同样支持,gzip 压缩效率非常高,通常可以达到 70% 的压缩率,也就是说,如果你的网页有 30K,压缩之后就变成了 9K 左右

以下我们以服务端使用我们熟悉的 express 为例,开启 gzip 非常简单,相关步骤如下:

  • 安装
npm install compression --save
  • 添加代码逻辑:
var compression = require("compression");
var app = express();
app.use(compression());
  • 重启服务,观察网络面板里面的 response header,如果看到如下红圈里的字段则表明 gzip 开启成功

# 4.6、使用 Tree Shaking 剔除 JS 死代码

ree Shaking 可以剔除用不上的死代码,它依赖 ES6 的 import、export 的模块化语法,最先在 Rollup 中出现,Webpack 2.0 将其引入。适合用于 Lodash、utils.js 等工具类较分散的文件。它正常工作的前提是代码必须采用ES6的模块化语法,因为ES6模块化语法是静态的(在导入、导出语句中的路径必须是静态字符串,且不能放入其他代码块中。如果采用了 ES5 中的模块化,例如 module.export = {...}、require( x+y )、if (x) { require( './util' ) },则 Webpack 无法分析出可以剔除哪些代码。

启用 Tree Shaking:

  • 修改.babelrc 以保留 ES6 模块化语句:
{
  "presets": [
    [
      "env",
      { "module": false } //关闭Babel的模块转换功能,保留ES6模块化语法
    ]
  ]
}
  • 启动 webpack 时带上 --display-used-exports 可以在 shell 打印出关于代码剔除的提示

  • 使用 UglifyJSPlugin,或者启动时使用--optimize-minimize

  • 在使用第三方库时,需要配置 resolve.mainFields: ['jsnext:main', 'main'] 以指明解析第三方库代码时,采用 ES6 模块化的代码入口

# 五、优化输出质量--加速网络请求

# 5.1、提取公共代码

如果项目中没有去将每个页面的第三方库和公共模块提取出来,则项目会存在以下问题:

  • 相同的资源被重复加载,浪费用户的流量和服务器的成本。
  • 每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验。

所以我们需要将多个页面的公共代码抽离成单独的文件,来优化以上问题 。Webpack4 内置了 optimization.splitChunks

提取公共代码方法

    1. 将公共代码放置 common 目录
    1. 安装不同入口文件抽取公共代码
    1. 抽取不同 ui 框架
    1. 抽取 webpack 运行资源

webpac 运行资源主要是指:在浏览器运行时,webpack 用来连接模块化的应用程序的所有代码。runtime 包含:在模块交互时,连接模块所需的加载和解析逻辑。包括浏览器中的已加载模块的连接,以及懒加载模块的执行逻辑。

详细配置请见:多页面打包-公共资源抽取实例 (opens new window)


/**
 * 多入口文件代码分割策略
 */
exports.splitChunks={
  optimization: {
    splitChunks: {
      chunks: "all",
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: "-",
      name: true,
      cacheGroups: {
        "vendors-admin": {
          test: /ant-design-vue|@ant-design/, // 抽离ant-design-vue和ant-design
          chunks: "async",//因为采用的是动态加载ui库,所以不使用initial,不使用all是因为可能会导入其他没使用的ant组件代码
          name: "vendors-admin"
        },
        "vendors-index": {
          test: /iview/, // 抽离iview
          chunks: "async",
          name: "vendors-index"
        },
        common: {
          chunks: "initial",//提取admin和index的入口的公共库,通常是vue/vuex/vue-router
          name: "common",
          minChunks: 2,
          maxInitialRequests: 5,
          minSize: 0
        }
      }
    },
    runtimeChunk: {
      name: "manifest"
    }
  }

配置说明

  • minSize(默认是 30000):形成一个新代码块最小的体积
  • minChunks(默认是 1):在分割之前,这个代码块最小应该被引用的次数(译注:保证代码块复用性,默认配置的策略是不需要多次引用也可以被分割)
  • maxInitialRequests(默认是 3):一个入口最大的并行请求数
  • maxAsyncRequests(默认是 5):按需加载时候最大的并行请求数。
  • chunks (默认是 async) :initial、async 和 all
  • test: 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String 和 Function
  • name(打包的 chunks 的名字):字符串或者函数(函数可以根据条件自定义名字)
  • priority :缓存组打包的先后优先级。

打包之后分析:

# 5.2、代码分割及按需加载代码

通过 vue 写的单页应用时,可能会有很多的路由引入。当打包构建的时候,javascript 包会变得非常大,影响加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就更加高效了。这样会大大提高首屏显示的速度,但是可能其他的页面的速度就会降下来

const Foo = () => import(/* webpackChunkName:"Foo" */ "./Foo.vue");
const router = new VueRouter({
  routes: [{ path: "/foo", component: Foo }],
});

对于常用的 iview/ant-design 等 ui 库需要做到组件的按需加载: (常常配合@babel/plugin-syntax-dynamic-import 使用)

//babel.config.js
module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          browsers: ["last 3 versions", "ie>=9"],
        },
        useBuiltIns: "entry",
        debug: false,
      },
    ],
  ],
  plugins: [
    "@babel/plugin-transform-runtime",
    "@babel/plugin-syntax-dynamic-import",
    "@babel/plugin-proposal-object-rest-spread",
    "transform-vue-jsx",
    [
      "import",
      {
        libraryName: "iview",
        libraryDirectory: "src/components",
      },
    ],
  ],
};

# 5.3、提取 css 为一个文件

上面讲述了将 Js 代码做分离,但是这里建议将 css 抽取为一个文件,是为了减少浏览器对服务的请求。众所周知浏览器并发数量通常为 4-6 个。 抽取 css 通常有两种插件方式。

extract-text-webpack-plugin 对 webpack4.0+支持不太友好,如需要配置,请见Vue 单/多入口脚手架 (opens new window)

mini-css-extract-plugin (opens new window)extract-text-webpack-plugin 相比:

  • 异步加载
  • 没有重复的编译(性能)
  • 更容易使用
  • 特别针对 CSS 开发

注意: 开发过程中,使用style-loader速度比每次提取样式都要快。但是在生产中,您应该将样式提取到单独的文件中,以避免样式加载到HTML之后加载样式时出现的错误,从而避免在Web中加载样式错误。

此处简单介绍 mini-css-extract-plugin 的使用方式

npm install -D mini-css-extract-plugin
// webpack.config.js
var MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  // 其它选项...
  module: {
    rules: [
      // ... 忽略其它规则
      {
        test: /\.css$/,
        use: [
          //测试环境不压缩css,只有生产环境压缩
          process.env.NODE_ENV !== "production"
            ? "style-loader"
            : MiniCssExtractPlugin.loader,
          "css-loader",
        ],
      },
    ],
  },
  plugins: [
    // ... 忽略 vue-loader 插件
    new MiniCssExtractPlugin({
      filename: "style.css",
    }),
  ],
};

# 5.4、使用 CDN 加速静态资源加载

1. CDN 缓存介绍 由于 CDN 会为资源开启长时间的缓存,例如用户从 CDN 上获取了 index.html,即使之后替换了 CDN 上的 index.html,用户那边仍会在使用之前的版本直到缓存时间过期。业界做法:

  • HTML 文件:放在自己的服务器上且关闭缓存,不接入CDN;
  • 静态的 JS、CSS、图片等资源:开启 CDN 和缓存,同时文件名带上由内容计算出的 Hash 值,这样只要内容变化 hash 就会变化,文件名就会变化,就会被重新下载而不论缓存时间多长。

TIP

另外,HTTP1.x 版本的协议下,浏览器会对于向同一域名并行发起的请求数限制在4~8个。那么把所有静态资源放在同一域名下的 CDN 服务上就会遇到这种限制,所以可以把他们分散放在不同的 CDN 服务上,例如 JS 文件放在 js.cdn.com 下,将 CSS 文件放在 css.cdn.com 下等。这样又会带来一个新的问题:增加了域名解析时间,这个可以通过 dns-prefetch 来解决 <link rel='dns-prefetch' href='//js.cdn.com'>来缩减域名解析的时间。

2. 构建需要满足的条件

  • 静态资源导入的 URL 要变成指向 CDN 服务的绝对路径的 URL
  • 静态资源的文件名需要带上根据内容计算出的 Hash 值
  • 不同类型资源放在不同域名的 CDN 上

3. 配置参考

const ExtractTextPlugin = require('extract-text-webpack-plugin');
const {WebPlugin} = require('web-webpack-plugin');
//...
output:{
 filename: '[name]_[chunkhash:8].js',
 path: path.resolve(__dirname, 'dist'),
 publicPatch: '//js.cdn.com/id/', //指定存放JS文件的CDN地址
},
module:{
 rules:[{
     test: /\.css/,
     use: ExtractTextPlugin.extract({
         use: ['css-loader?minimize'],
         publicPatch: '//img.cdn.com/id/', //指定css文件中导入的图片等资源存放的cdn地址
     }),
 },{
    test: /\.png/,
    use: ['file-loader?name=[name]_[hash:8].[ext]'], //为输出的PNG文件名加上Hash值
 }]
},
plugins:[
  new WebPlugin({
     template: './template.html',
     filename: 'index.html',
     stylePublicPath: '//css.cdn.com/id/', //指定存放CSS文件的CDN地址
  }),
 new ExtractTextPlugin({
     filename:`[name]_[contenthash:8].css`, //为输出的CSS文件加上Hash
 })
]


4. 构建注意事项

  • 设置了 Js/css 资源放在 cdn 上,会存在资源相对路径无法访问问题

比如说:在 vue 中使用相对路径../css/infor.css,在编译完成之后,生产环境访问时时无法使用//css.cdn.com/id/css/infor.css访问到的。 此时解决办法:

TIP

使用set-webpack-public-path plugin for webpack,解决相对路径问题。详细文档请见:这里 set-webpack-public-path plugin for webpack (opens new window)

# 5.5、添加浏览器缓存

你应该知道的缓存 (opens new window)

缓存主要分为:强缓存和协商缓存

  • 强缓存:主要是通过 http 请求头中的Cache-ControlExpire两个字段控制。Expire 是 HTTP1.0 标准下的字段,在这里我们可以忽略。我们重点来讨论的 Cache-Control 这个字段。

一般,我们会设置 Cache-Control 的值为“public, max-age=xxx”,表示在 xxx 秒内再次访问该资源,均使用本地的缓存,不再向服务器发起请求。

  • 协商缓存:HTTP 的条件方法可以高效的实现再验证。HTTP 允许缓存向原始服务器发送一个条件 GET,请求服务器只有在文档与缓存中现有的副本不同时,才回送对象主体,对于缓存在验证来说最有用的 2 个首部时

    • If-Modified-Since: <date>:

    如果从指定日期之后,文档被修改了,就执行请求的方法。可以与 Last-Modfied 服务器响应首部配合使用,只有在内容修改后与已缓存版本有所不同的时候才去获取内容

    • If-None-Match:<tags>:

    服务器可以为文档提供特殊的标签(ETag),而不是将其与最近修改日期向匹配,这些标签就像序列号一样。如果已缓存标签与服务器文档中的标签有所不同,If-None-Match 首部就会执行所请求的方法

在更新版本之后,如何让用户第一时间使用最新的资源文件呢?在更新版本的时候,顺便把静态资源的路径改了,这样,就相当于第一次访问这些资源,就不会存在缓存的问题了。

webpack 使用 Hash 方式,

entry:{
    main: path.join(__dirname,'./main.js'),
    vendor: ['react', 'antd']
},
output:{
    path:path.join(__dirname,'./dist'),
    publicPath: '/dist/',
    filname: 'bundle.[chunkhash].js'
}

webpack 给我们提供了三种哈希值计算方式,分别是hash、chunkhash和contenthash。那么这三者有什么区别呢?

  • hash:跟整个项目的构建相关,构建生成的文件 hash 值都是一样的,只要项目里有文件更改,整个项目构建的 hash 值都会更改。
  • chunkhash:根据不同的入口文件(Entry)进行依赖文件解析、构建对应的 chunk,生成对应的 hash 值。
  • contenthash:由文件内容产生的 hash 值,内容不同产生的 contenthash 值也不一样。

TIP

在实际在项目中,我们一般会把项目中的 css 都抽离出对应的 css 文件来加以引用。如果我们使用 chunkhash,当我们改了 css 代码之后,会发现 css 文件 hash 值改变的同时,js 文件的 hash 值也会改变。这时候,contenthash 就派上用场了。

结合上面的各种优化方案,浏览器缓存方案为:

  1. 提取公共代码(vue/react/jquery 等)或者使用 external/dllplugin 方式,进行抽取,减少基础模块编译次数,从而减少浏览器访问次数(静态资源不变,浏览器使用缓存读取基础模块);
  2. css 文件和 Js 文件采用 contenthash/chunkhash,减少文件 hash 值变动
  3. 设置 Cache-Control,控制缓存时间

# 5.6、 页面预加载 preload

详情请见:页面预加载 preload;

# 六、优化结果分析

# 6.1、优化 SourceMap

我们在项目进行打包后,会将开发中的多个文件代码打包到一个文件中,并且经过压缩,去掉多余的空格,且 babel 编译化后,最终会用于线上环境,那么这样处理后的代码和源代码会有很大的差别,当有 bug 的时候,我们只能定位到压缩处理后的代码位置,无法定位到开发环境中的代码,对于开发不好调式,因此 sourceMap 出现了,它就是为了解决不好调式代码问题的

官方文档 (opens new window)

SourceMap 的可选值如下:

devtool 构建速度 重新构建速度 生产环境 品质(quality)
(none) +++ +++ yes 打包后的代码
eval +++ +++ no 生成后的代码
cheap-eval-source-map + ++ no 转换过的代码(仅限行)
cheap-module-eval-source-map o ++ no 原始源代码(仅限行)
eval-source-map -- + no 原始源代码
cheap-source-map + o yes 转换过的代码(仅限行)
cheap-module-source-map o - yes 原始源代码(仅限行)
inline-cheap-source-map + o no 转换过的代码(仅限行)
inline-cheap-module-source-map o - no 原始源代码(仅限行)
source-map -- -- yes 原始源代码
inline-source-map -- -- no 原始源代码
hidden-source-map -- -- yes 原始源代码
nosources-source-map -- -- yes 无源代码内容

TIP

+++ 非常快速, ++ 快速, + 比较快, o 中等, - 比较慢, --

对于开发环境

以下选项非常适合开发环境:

eval - 每个模块都使用 eval() 执行,并且都有 //@ sourceURL。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。

eval-source-map - 每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。

cheap-eval-source-map - 类似 eval-source-map,每个模块使用 eval() 执行。这是 "cheap(低开销)" 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像 eval devtool。

cheap-module-eval-source-map - 类似 cheap-eval-source-map,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。

对于生产环境

这些选项通常用于生产环境中:

(none)(省略 devtool 选项) - 不生成 source map。这是一个不错的选择。

source-map - 整个 source map 作为一个单独的文件生成。它为 bundle 添加了一个引用注释,以便开发工具知道在哪里可以找到它。

WARNING

你应该将你的服务器配置为,不允许普通用户访问 source map 文件!

hidden-source-map - 与 source-map 相同,但不会为 bundle 添加引用注释。如果你只想 source map 映射那些源自错误报告的错误堆栈跟踪信息,但不想为浏览器开发工具暴露你的 source map,这个选项会很有用。

WARNING

你不应将 source map 文件部署到 web 服务器。而是只将其用于错误报告工具。

nosources-source-map - 创建的 source map 不包含 sourcesContent(源代码内容)。它可以用来映射客户端上的堆栈跟踪,而无须暴露所有的源代码。你可以将 source map 文件部署到 web 服务器。

WARNING

这仍然会暴露反编译后的文件名和结构,但它不会暴露原始代码。

TIP

在使用 terser-webpack-plugin 时,你必须提供 sourceMap:true 选项来启用 source map 支持。

# 6.2、使用 webpack-bundle-analyzer

配置文件配置

if (config.build.bundleAnalyzerReport) {
  var BundleAnalyzerPlugin =   require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
  webpackConfig.plugins.push(new BundleAnalyzerPlugin());
}
执行 $ npm run build --report 后生成分析报告如下:

# 6.3 使用 Chrome Performance 查找性能瓶颈

使用 Chrome 开发者工具分析页面性能的步骤如下。

# 参考

【未经作者允许禁止转载】 Last Updated: 2/4/2024, 6:06:40 AM