webpack v4 开始新增了一个 sideEffects 特性,通过给 package.json 加入 sideEffects 声明该 包/模块 是否包含 sideEffects(副作用),从而可以为 tree-shaking 提供更大的优化空间。

基于我们对 side effect 的常规理解,我们可以认为,只要我们确定当前包里的模块不包含副作用,然后将发布到 npm 里的包标注为 sideEffects: false ,我们就能为使用方提供更好的打包体验。原理是 webpack 能将标记为 side-effects-free 的包由 import {a} from xx 转换为 import {a} from 'xx/a',从而自动修剪掉不必要的 import,作用同 babel-plugin-import

Tree Shaking 与副作用

Tree-Shaking在前端界由rollup首先提出并实现,后续webpack在2.x版本也借助于UglifyJS实现了。自那以后,在各类讨论优化打包的文章中,都能看到Tree-Shaking的身影。

Tree-Shaking的原理

ES6的模块引入是静态分析的,故而可以在编译时正确判断到底加载了什么代码。

分析程序流,判断哪些变量未被使用、引用,进而删除此代码。

很好,原理非常完美,那为什么有时候我们项目里面多余的的代码又删不掉呢?

先说原因:都是副作用的锅!

副作用

了解过函数式编程的同学对副作用这词肯定不陌生。它大致可以理解成:一个函数会、或者可能会对函数外部变量产生影响的行为。

举个例子,比如这个函数:

function go (url) {
  window.location.href = url
}

这个函数修改了全局变量location,甚至还让浏览器发生了跳转,这就是一个有副作用的函数。

// componetns.js
export class Person {
  constructor ({ name }) {
    this.className = 'Person'
    this.name = name
  }
  getName () {
    return this.name
  }
}
export class Apple {
  constructor ({ model }) {
    this.className = 'Apple'
    this.model = model
  }
  getModel () {
    return this.model
  }
}
// main.js
import { Apple } from './components'

const appleModel = new Apple({
  model: 'IphoneX'
}).getModel()

console.log(appleModel)

很显然这个Person类是无用的代码

而为什么有时候别的工具,比如rollup在线repl尝试了下tree-shaking,也确实删掉了无用的代码

而使用webpack打包工具却不能进行有效的代码消除呢?

答案是:babel编译 + webpack打包

在这边贴一个链接,是有关于详细介绍babel编译 + webpack打包是怎么让你无效的代码消除不掉的。你的Tree-Shaking并没什么卵用

如果不想看文章的话,这边直接简单说一下原理:babel编译会使得Person类被封装成了一个IIFE(立即执行函数),然后返回一个构造函数,在这边就产生了一个副作用。

这边有个Issues,IIFE 中的类声明被视为副作用

当我在 IIFE 中声明一个类,但没有使用类时,它不会被 UglifyJS 剥离,因为它被认为是副作用。

var V6Engine = (function () {
    function V6Engine() {
    }
    V6Engine.prototype.toString = function () {
        return 'V6';
    };
    return V6Engine;
}());

编译时收到这样的警告:WARN: Side effects in initialization of unused variable V6Engine [./dist/car.bundle.js:74,4]

下面给出的回复:Uglify 没做执行程序流分析。它并不会因为你注意到的副作用而删除代码。你要是想弄个完善一点的摇树,去隔壁rollup呗!

issue中总结下几点关键信息:

函数的参数若是引用类型,对于它属性的操作,都是有可能会产生副作用的。因为首先它是引用类型,对它属性的任何修改其实都是改变了函数外部的数据。其次获取或修改它的属性,会触发getter或者setter,而gettersetter是不透明的,有可能会产生副作用。

uglify没有完善的程序流分析。它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。

rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。

但这已经是很久之前的版本问题,现在的webpack tree shaking已经做了很多的优化,足够的程序流分析进行tree shaking

webpack 的 tree shaking 的作用是可以将未被使用的 exported member 标记为 unused 同时在将其 re-export 的模块中不再 export。说起来很拗口,看代码:

// a.js
export function a() {}
// b.js
export function b(){}
// package/index.js
import a from './a'
import b from './b'
export { a, b }
// app.js
import {a} from 'package'
console.log(a)

当我们以 app.js 为 entry 时,经过摇树后的代码会变成这样:

// a.js
export function a() {}
// b.js 不再导出 function b(){}
function b() {}
// package/index.js 不再导出 b 模块
import a from './a'
import './b'
export { a }
// app.js
import {a} from 'package'
console.log(a)

配合 webpack 的 scope hoistinguglify 之后,b 模块的痕迹会被完全抹杀掉。

但是如果 b 模块中添加了一些副作用,比如一个简单的 log:

// b.js
export function b(v) { reutrn v }
console.log(b(1))
webpack 之后会发现 b 模块内容变成了:

// b.js
console.log(function (v){return v}(1))

虽然 b 模块的导出是被忽略了,但是副作用代码被保留下来了。

由于目前 transformer 转换后可能引入的各种奇怪操作引发的副作用,很多时候我们会发现就算有了 tree shaking 我们的 bundle size 还是没有明显的减小。

而通常我们期望的是 b 模块既然不被使用了,其中所有的代码应该不被引入才对。

这个时候 sideEffects 的作用就显现出来了:如果我们引入的 包/模块 被标记为 sideEffects: false 了,那么不管它是否真的有副作用,只要它没有被引用到,整个 模块/包 都会被完整的移除。

以 mobx-react-devtool 为例,我们通常这样去用:

import DevTools from 'mobx-react-devtools';

class MyApp extends React.Component {
  render() {
    return (
      <div>
        ...
        { process.env.NODE_ENV === 'production' ? null : <DevTools /> }
      </div>
    );
  }
}

这是一个很常见的按需导入场景,然而在没有 sideEffects: false 配置时,即便 NODE_ENV 设为 production ,打包后的代码里依然会包含 mobx-react-devtools 包,虽然我们没使用过其导出成员,但是 mobx-react-devtools 还是会被 import,因为里面“可能”会有副作用。

但当我们加上 sideEffects false 之后,tree shaking 就能安全的把它从 bundle 里完整的移除掉了。

sideEffects 的使用场景

上面也说到,通常我们发布到 npm 上的包很难保证其是否包含副作用(可能是代码的锅可能是 transformer 的锅),但是我们基本能确保这个包是否会对包以外的对象产生影响,比如是否修改了 window 上的属性,是否复写了原生对象方法等。如果我们能保证这一点,其实我们就能知道整个包是否能设置 sideEffects: false了,至于是不是真的有副作用则并不重要,这对于 webpack 而言都是可以接受的。

这也就能解释为什么能给 vue 这个本身充满副作用的包加上 sideEffects: false 了。

所以其实 webpack 里的 sideEffects: false 的意思并不是我这个模块真的没有副作用,而只是为了在摇树时告诉 webpack:我这个包在设计的时候就是期望没有副作用的,即使他打完包后是有副作用的。

性能优化 - JS-CSS代码压缩

  • Terser是一个JavaScript的解释(Parser)、Mangler(绞肉机)/Compressor(压缩机)的工具集;
  • 早期我们会使用 uglify-js来压缩、丑化我们的JavaScript代码,但是目前已经不再维护,并且不支持ES6+的语法;
  • Terser是从 uglify-es fork 过来的,并且保留它原来的大部分API以及适配 uglify-es和uglify-js@3等;

webpack-terser

JavaScript 代码压缩

Webpack 提供了terser-webpack-plugin 插件进行代码优化和压缩。

在production模式下,默认就是使用TerserPlugin来处理代码。

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

module.exports = {
  // 配置其他Webpack选项...

  optimization: {
    minimizer: [new TerserPlugin()],
  },
};

CSS 代码压缩

除了JavaScript代码,CSS代码也可以通过Webpack进行压缩。使用css-minimizer-webpack-plugin 进行压缩CSS代码。

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  // 配置其他Webpack选项...

  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
      // 可以继续添加其他压缩插件...
    ],
  },
};

webpack实现Tree Shaking

tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。

Webpack 实现 Tree Shaking

在现代的前端开发中,代码体积优化是一个关键的议题。Tree Shaking 是一种用于消除未引用代码的优化技术,它可以帮助我们剔除项目中未使用的 JavaScript 模块,从而减小打包后的文件体积。Webpack 提供了内置的支持,使得 Tree Shaking 在项目中变得非常容易实现。

开启 ES 模块化

首先,确保你的 JavaScript 代码采用了 ES 模块化的方式,因为Webpack 的 Tree Shaking 功能仅对 ES 模块有效。你可以在项目中使用 importexport 语法来定义模块。

// math.js
export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}

Webpack 配置

在 Webpack 的配置文件中,确保以下几点设置,以启用 Tree Shaking:

mode 设置为 'production',Webpack 会自动启用相关的优化,包括 Tree Shaking。

JS实现Tree Shaking

webpack实现Tree Shaking采用了两种不同的方案:

  • usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的;
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用;

使用usedExports实现Tree Sharking

配置模式为production

module.exports = {
  mode: 'production',
  // ...其他配置
};

配置optimization里面的usedExports

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
 mode: 'development',
 optimization: {
   usedExports: true,
 },
};

使用sideEffect实现Tree Sharking

在package.json中设置sideEffects的值:

  • 如果我们将sideEffects设置为false,就是告知webpack可以安全的删除未用到的exports;

  • 如果有一些我们希望保留,可以设置为数组;

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

Webpack 中的 sideEffects

解释 tree shaking 和 sideEffects

sideEffectsusedExports(更多被认为是 tree shaking)是两种不同的优化方式。

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 去检测语句中的副作用。它是一个 JavaScript 任务而且没有像 sideEffects 一样简单直接。而且它不能跳转子树/依赖由于细则中说副作用需要被评估。尽管导出函数能运作如常,但 React 框架的高阶函数(HOC)在这种情况下是会出问题的。

CSS实现TreeShaking

CSS的Tree Shaking需要借助于一些其他的插件;

在早期的时候,我们会使用PurifyCss插件来完成CSS的tree shaking,但是目前该库已经不再维护了(最新更新也是在4年前 了);

目前我们可以使用另外一个库来完成CSS的Tree Shaking:PurgeCSS,也是一个帮助我们删除未使用的CSS的工具;

Webpack对文件压缩

什么是HTTP压缩

HTTP压缩是一种内置在 服务器 和 客户端 之间的,以改进传输速度和带宽利用率的方式;
HTTP压缩的流程什么呢?
第一步:HTTP数据在服务器发送前就已经被压缩了;(可以在webpack中完成)
第二步:兼容的浏览器在向服务器发送请求时,会告知服务器自己支持哪些压缩格式;
第三步:服务器在浏览器支持的压缩格式下,直接返回对应的压缩后的文件,并且在响应头中告知浏览器;

目前的流行压缩格式

目前的压缩格式非常的多:
compress – UNIX的“compress”程序的方法(历史性原因,不推荐大多数应用使用,应该使用gzip或deflate);
deflate – 基于deflate算法(定义于RFC 1951)的压缩,使用zlib数据格式封装;
gzip – GNU zip格式(定义于RFC 1952),是目前使用比较广泛的压缩算法;
br – 一种新的开源压缩算法,专为HTTP内容的编码而设计;

Webpack配置文件压缩

webpack中相当于是实现了HTTP压缩的第一步操作,我们可以使用CompressionPlugin。

第一步,安装CompressionPlugin:

npm install compression-webpack-plugin -D

第二步,使用CompressionPlugin即可

module.exports = {
  plugins: [
    new CompressionPlugin({
      test: /\.js(\?.*)?$/i,
    }),
  ],
};

试了试把真实项目从 Vite 迁移到 Rspack,Build 速度从 125 秒缩短到了 17 秒,开发中刷新页面的速度也提升了 64 %,不过 HMR 时间比 Vite 慢多了。

如果开发过程中触发 HMR 比较多,而刷新页面比较少,Vite 还是有开发体验的优势。如果是复杂项目,刷新页面更常用,那 Rspack 的开发体验反而会更好。

对比

前端构建的工具实在是太多了,rolldown、rollup、Rspack、Vite……

先提前插个眼

前言

先来说说为什么要优化?当然如果你的项目很小,构建很快,其实不需要特别关注性能方面的问题。

但是随着项目涉及到的页面越来越多,功能和业务代码也会越来越多,相应的 webpack 的构建时间也会越来越久,这个时候我们就不得不考虑性能优化的事情了。

webpack 的性能优化较多,我们考虑从两方面入手:
优化一:打包后的结果,上线时的性能优化。(比如分包处理、减小包体积、CDN服务器等)
优化二:优化打包速度,开发或者构建时优化打包速度。(比如 excludecache-loader等)

因为这个上线时的性能是直接影响到用户使用体验的,而构建时间与我们的日常开发是密切相关,当我们本地开发启动 devServer 或者 build 的时候,如果时间过长,会大大降低我们的工作效率。

性能优化 - 代码分离

代码分离(Code Splitting)是 webpack 一个非常重要的特性:

它主要的目的是将代码分离到不同的 bundle 中,之后我们可以按需加载,或者并行加载这些文件;
比如默认情况下,所有的 JavaScript 代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页的加载速度;
代码分离可以分出更小的 bundle ,以及控制资源加载优先级,提供代码的加载性能;

Webpack中常用的代码分离有三种:

  • 入口起点:使用entry配置手动分离代码
  • 防止重复:使用Entry Dependencies或者SplitChunksPlugin去重和分离代码;
  • 动态导入:通过模块的内联函数调用来分离代码;

入口起点优化-Entry Dependencies(入口依赖)

当项目拥有多个入口点(entry points)时,可能会遇到一些重复依赖的问题。某些模块可能在多个入口点中被引用,导致这些模块被重复打包,增加了最终输出文件的体积。
dependon-shared模块解决重复依赖

module.exports = {
  entry: {
    page1: {
      import: './src/page1.js',
      dependOn: 'shared',
    },
    page2: {
      import: './src/page2.js',
      dependOn: 'shared',
    },
    shared: './src/shared.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: __dirname + '/dist',
  },
}; 

动态导入(dynamic import)

动态导入是一种在Webpack中实现按需加载(Lazy Loading)的技术,允许在运行时异步加载模块,而不是在应用初始化时就把所有模块打包到一个大文件中。可以提高应用的初始加载速度,并且减小了初始包的体积。

const path = require('path');

module.exports = {
  entry: {
    main: './src/index.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [
      // 添加你的Loader规则
    ],
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
};

在上述配置中,通过 optimization.splitChunks 进行代码分割,它的 chunks: 'all' 选项表示对所有模块进行代码分割。

然后,在代码中使用 import() 函数进行动态导入:

// 在需要的地方使用动态导入
const loadModule = () => import('./Module');

loadModule().then(module => {
  // 使用加载的模块
});

Webpack会将使用 import() 函数引入的模块进行代码分割,生成单独的文件。
在运行时,这些文件会在需要的时候异步加载。

自定义分包-SplitChunks

分包(code splitting)是一种优化策略,它允许将代码分割成小块,使得应用在加载时能够更快地显示内容。

Webpack提供了多种分包的模式,其中一种是使用SplitChunksPlugin插件来实现的,这个模式叫做splitChunks

module.exports = {
  // ...其他配置
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 30000, // 模块的最小体积
      minChunks: 1, // 模块的最小被引用次数
      maxAsyncRequests: 5, // 按需加载时的最大并行请求数
      maxInitialRequests: 3, // 入口点的最大并行请求数
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

webpack-split-chunks-plugin

性能优化-CDN

CDN称之为内容分发网络(Content Delivery Network或Content Distribution Network,缩写:CDN), 它是指通过相互连接的网络系统,利用最靠近每个用户的服务器; 更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户; 来提供高性能、可扩展性及低成本的网络内容传递给用户;

在开发中,我们使用CDN主要是两种方式:

  • 打包的所有静态资源,放到CDN服务器, 用户所有资源都是通过CDN服务器加载的;
  • 一些第三方资源放到CDN服务器上;

使用CDN(Content Delivery Network,内容分发网络)是一种非常有效的性能优化策略,特别是在Webpack中。CDN可以加速网站的加载速度,减轻服务器负担,并提高用户体验。以下是如何在Webpack中配置和使用CDN的方法:

将第三方库引入CDN

将你的项目中用到的第三方库(例如React、Vue、jQuery等)引入CDN。可以选择在HTML文件中直接引入CDN链接:

<script src="https://cdn.jsdelivr.net/npm/react@版本号/dist/react.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@版本号/dist/react-dom.min.js"></script>

在Webpack中配置externals

在Webpack的配置中使用externals字段,告诉Webpack哪些模块是外部引入的,不需要打包。

module.exports = {
  // ...其他配置
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
};

然后在HTML文件中通过script标签引入CDN:

<script src="https://cdn.jsdelivr.net/npm/react@版本号/dist/react.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@版本号/dist/react-dom.min.js"></script>

配置CDN的publicPath

在Webpack的output字段中配置publicPath,指定在引入资源时使用的URL前缀,通常设置为CDN的地址:

module.exports = {
  // ...其他配置
  output: {
    // ...其他output配置
    publicPath: 'https://cdn.example.com/',
  },
};

这样在Webpack构建时,所有的资源引用路径都会加上CDN的地址前缀。

性能优化-提取css文件

将CSS文件从JavaScript打包文件中提取出来是一种常见的性能优化策略。这样做的好处是可以减小JavaScript文件的体积,加快页面加载速度,并且使浏览器能够并行下载CSS和JavaScript文件,提高加载性能。在Webpack中,你可以使用mini-css-extract-plugin插件来实现CSS文件的提取。

配置Webpack

在Webpack配置文件中引入mini-css-extract-plugin插件,然后配置module.rules来处理CSS文件。

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  // ...其他配置

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          // 可以加入其他的CSS处理loader,比如postcss-loader和sass-loader
        ],
      },
    ],
  },

  plugins: [
    new MiniCssExtractPlugin({
      filename: 'styles.css', // 提取出的CSS文件的文件名
    }),
  ],
};

引入CSS文件

在JavaScript文件或者入口文件中引入CSS文件:

import './styles.css';

或者在HTML文件中使用link标签引入提取出来的CSS文件:

<link rel="stylesheet" href="styles.css">

性能优化-打包文件命名(Hash,ContentHash,ChunkHash)

在Webpack中,打包文件的命名是一个重要的性能优化策略。合适的命名方案可以确保浏览器能够正确地缓存文件,避免不必要的网络请求,提高应用的加载速度。以下是三种常见的打包文件命名方式:Hash、ContentHash 和 ChunkHash。

Hash(哈希)

Hash 是根据文件内容生成的哈希值,当文件内容发生改变时,其对应的 Hash 值也会改变。在Webpack中,可以使用 [hash] 占位符来表示 Hash 值。

output: {
  filename: 'bundle.[hash].js',
}

ContentHash(内容哈希)

ContentHash 是根据文件内容生成的哈希值,但是不同于 Hash 的是,ContentHash 只会受到文件内容的影响,不会受到文件名或路径等其他因素的影响。在Webpack中,可以使用 [contenthash] 占位符来表示 ContentHash 值。

output: {
  filename: 'bundle.[contenthash].js',
}

ChunkHash(块哈希)

ChunkHash 是根据模块内容生成的哈希值,不同模块的内容不同,它们的 ChunkHash 值也会不同。在Webpack中,可以使用 [chunkhash] 占位符来表示 ChunkHash 值。

output: {
  filename: '[name].[chunkhash].js',
}

性能优化-webpack实现Tree Shaking

JavaScript的Tree Shaking:

对JavaScript进行Tree Shaking是源自打包工具rollup(后面我们也会讲的构建工具);

这是因为Tree Shaking依赖于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系);

webpack2正式内置支持了ES2015模块,和检测未使用模块的能力;

在webpack4正式扩展了这个能力,并且通过 package.json的 sideEffects属性作为标记,告知webpack在编译时,哪里文 件可以安全的删除掉;

webpack5中,也提供了对部分CommonJS的tree shaking的支持;

commonjs-tree-shaking

JS实现Tree Shaking

webpack实现Tree Shaking采用了两种不同的方案:

  • usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的;
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用;

CSS进行Tree Shaking

CSS的Tree Shaking需要借助于一些其他的插件;

在早期的时候,我们使用PurifyCss插件来完成CSS的tree shaking,但是目前该库已经不再维护;

目前我们可以使用另外一个库来完成CSS的Tree Shaking:PurgeCSS,也是一个帮助我们删除未使用的CSS的工具: PurgeCss

前言

一般來說在學習寫網頁的時候,最先碰到的會是 HTML 與 CSS,負責把版面刻出來以及美化版面,當基礎打穩之後,會開始學習 JavaScript,試著做出一點互動性的效果。而「互動」除了使用者跟瀏覽器的互動以外,別忘了還有 Client 端跟 Server 端的互動,也就是必須要學會從瀏覽器用 JavaScript 跟後端 Server 拿資料,否則你的網頁資料都只能是寫死的。

這篇的主要預設讀者是網頁前端的初學者,希望能讓本來不太理解怎麼跟 Server 交換資料或是怎麼串 APi 的讀者看完之後,能夠更了解該怎麼跟後端串接。

閱讀更多