Feb 1, 2021

如何通过 mangling 属性压缩代码体积[译]

Mangling Property


什么是 Mangling

假设你有下面这段代码

class Human {
  constructor(chewAmount) {
    this.chewAmount = 3;
  }
  eat() {
    for (let amount = 3; amount < this.chewAmount; amount++) {
      this.chew();
    }
  }
  chew() {}
}

function getHumanEating() {
  const lihau = new Human();
  return lihau.eat();
}

原始大小为 268 字节

如果使用 Terser 的默认配置来压缩这段代码会输出如下

class Human {
  constructor(chewAmount) {
    this.chewAmount = 3;
  }
  eat() {
    for (let i = 3; i < this.chewAmount; i++) {
      this.chew();
    }
  }
  chew() {}
}
function getHumanEating() {
  return new Human().eat();
}

压缩至 207 字节(77.2%)

Terser 一般也会将空格进行压缩,这里为了方便阅读维持空格

即使代码中的变量名被改变你的代码的执行情况依然和之前一致,而这种将变量名进行重命名的压缩方式叫做 Mangle

Terser 提供了一些 相关的配置项 来控制是否使用 mangle 来压缩 class name、function name、property name 以及一些全局变量

同时可以控制不对一些保留字使用 mangle 压缩

如果上述代码使用 es 标准编写,我们只通过 import 引用的方式而不是全局的引用,那么类名就无关紧要

// Terser option: { mangle: { module: true } }
class H {
  constructor(chewAmount) {
    this.chewAmount = 3;
  }
  eat() {
    for (let i = 3; i < this.chewAmount; i++) {
      this.chew();
    }
  }
  chew() {}
}
function e() {
  return new H().eat();
}
export { H as Human, e as getHumanEating };

186 Bytes (69.4%)

还可以进一步压缩吗?代码的 chewAmount 属性名占用了 20 个字符,几乎占用了代码的百分之十,如果把这个属性名变为一个字符的变量,会得到更小体积的代码

class H {
  constructor(t) {
    this.c = 3;
  }
  a() {
    for (let t = 3; t < this.c; t++) this.s();
  }
  s() {}
}
function e() {
  return new H().a();
}
export { H as Human, e as getHumanEating };

107 Bytes (39.9%)

代码体积得到了很大的压缩,我们不应该把我们的属性或者方法名改更短,这会将代码可读性完全破坏

为什么 Terser 默认没有做这件事?

开启 mangling 需要一个前提条件,所以在 Terser 文档 里面完全开启这个选项是一个非常危险的行为

很简单,如果你的代码中的属性或是方法完全是内部调用那么借助 mangling 压缩没有任何问题,但是如果你的代码的属性或者方法被外部不可预见地方的调用,那么压缩后的代码将会失效且会导致报错

如果你是一个库的作者,或者你写了一个提供给别人使用的模块,如果你压缩库/模块本身,你所有的方法名和对象的属性名会被改变,所以你所有的 api 都会失效

// filename: source.js
export function doSomething({ paramA, paramB }) {
  return { sum: paramA + paramB };
}
export class Car {
  constructor({ model }) {
    this.model = model;
  }
  drive() {}
}
// filename: source.min.js
export function doSomething({ o: t, t: o }) {
  return { m: t + o };
}
export class Car {
  constructor({ s: t }) {
    this.s = t;
  }
  i() {}
}

当你的用户调用 doSomething({paramA: 1, paramB: 2}) 或者 car.drive() 都会失效

同样的方式如果你引入了其他第三方库或者模块,这些代码也都会被压缩

// filename: source.js
import { doSomething } from "some-library";

doSomething({ paramA: 1, paramB: 2 });
// filename: source.min.js
import { doSomething as r } from "some-library";

r({ m: 1, o: 2 });

同样的 Terser 配置每次并不会保持同样的输出,意思是 paramA 并不会每次都会编译为 m

总的来说,如果你的代码的属性或者方法名被外部依赖调用或者作为入参的的对象的属性使用 mangle 的方式来压缩属性会破坏你的代码

如果没有下面这些 case 使用 mangle 作为默认压缩你的代码的方式是很安全的,并且跨文件的属性或者方法名都会被一致的压缩

  • 没有引入或者导出任何依赖
  • 没有读写全局作用域读取任何属性
// filename: source.js
class CarA {
  drive() {}
}
class CarB {
  drive() {}
}
const car = Math.random() > 0.5 ? new CarA() : new CarB();
car.drive();

foo({ drive: "bar" });
// filename: source.min.js
class s {
  s() {}
}
class e {
  s() {}
}
const a = Math.random() > 0.5 ? new s() : new e();
a.s(), foo({ s: "bar" });

如果你使用了 drive 这个属性或者方法名,整个文件的 drive 会被压缩为同一个更短的字段

在上面的例子里,drive 同时是类的方法名和函数入参对象的属性名,完全不同的两处使用都被压缩成了相同的名称 s

从全局作用域中读写属性

从经验来说,如果你在全局作用上读写了属性名,这个属性会被 mangle 强制的压缩

当然需要注意的是要保证安全的情况下,默认选项是关闭的(false),你可以自行控制风险开启这个开关

从内置对象上面访问属性或者方法

Terser 内置了一份避免被 mangle 压缩的白名单,如下

  • DOM properties: window.location, document.createElement
  • Methods of built-in objects: Array.from, Object.defineProperty

这份列表可以在 domprops.jsfind_builtins 中查看

这个配置的控制通过 builtins 选项,可以根据风险自行设置为 true 来强制压缩内置对象的属性

访问未声明变量的属性或者方法

当前代码未定义的变量可能会在全局或者外部定义,所以这些变量的属性或者方法也不会被 mangle 压缩

同样可以通过配置 undeclared 属性为 true 来开启强制压缩

为 使用 rollup 或 webpack 打包的代码配置 mangle

如果你的项目使用了 terser-webpack-plugin 或者 rollup-plugin-terser,可以安全的使用 mangle 来压缩属性吗

经验来讲,如果你的打包器输出一个文件以上的时候不行

这意味着影响任何进行代码拆分的打包设置(code-splitting)

由于 terser 在打包的代码分割成多个文件后来运行,所以跨文件的属性和方法名不会被压缩成一致的名称,所以这是不安全的

如何负责且安全的压缩属性

有上述如此多的限制条件,如何使用 mangle 负责且安全的压缩属性呢

terser 的 mangle 并不是极端的压缩全部或者什么事都不做,它内置了一些配置可以自由的控制压缩的内容从而保证代码的安全

私有属性

下面的例子里,Car 这个 class 中 driveTo 是公共的方法(对外暴露),可以使用 mangle 把其他私有方法压缩掉

// filename: source.js
class Car {
  driveTo({ destination }) {
    this.destination = destination;
    this.calculateRoute();
    this.startDriving();
  }
  calculateRoute() {
    this.planRoute(this.currentLocation, this.destination);
  }
  startDriving() {}
  planRoute() {}
}

我们要对 this.currentLocation, this.destination, this.calculateRoute, this.startDriving, this.planRoute 进行压缩而保持 this.driveTo 不变

有以下方式

  1. 除了对我们配置 reserved 选项之外的所有方法和属性进行压缩
// filename: terser_options.js
const terserOptions = {
  mangle: {
    properties: {
      reserved: ["driveTo"],
    },
  },
};
  1. 使用正则表达式来匹配出我们需要压缩的属性或者方法名
// filename: terser_options.js
const terserOptions = {
  mangle: {
    properties: {
      regex: /^(destination|calculateRoute|currentLocation|startDriving|planRoute)$/,
    },
  },
};

有一份针对私有属性进行命名的非官方规范,通常如果一个变量以下划线 _ 开头一般会被看做是私有的

// filename: source.js
class Car {
  driveTo({ destination }) {
    this._destination = destination;
    this._calculateRoute();
    this._startDriving();
  }
  _calculateRoute() {
    this._planRoute(this._currentLocation, this._destination);
  }
  _startDriving() {}
  _planRoute() {}
}

这样我们使用正则表达式就方便的多了

// filename: terser_options.js
const terserOptions = {
  mangle: {
    properties: {
      regex: /^_/,
    },
  },
};

压缩整个过程中保持属性被压缩的结果一致(同一次构建)

如果你想要对属性_calculateRoute 始终压缩成同一个名字(多次打包),nameCache可以做到这一点

nameCache 是 terser 的内部状态,控制使用 mangle 压缩过程中的序列化和反序列化

const fs = require("fs").promises;
const terser = require("terser");
const nameCache = {};
await terser.minify(code, {
  nameCache,
});

// serialise and store `nameCache`
await fs.writeFile("nameCache.json", JSON.stringify(nameCache), "utf-8");

// deserialise and seed Terser
const nameCache = JSON.parse(await fs.readFile("nameCache.json", "utf-8"));
await terser.minify(code, {
  nameCache,
});

在不同的构建中保持对属性压缩保持一致

如果你有多个独立的项目,如何保证 mangle 可以在这些项目运行保持一致呢

如果你管理的属性和方法名属于私有的,那不必担心有任何副作用。不同的项目应该彼此解耦内聚不依赖内部的属性和方法

那么要针对的就是公共的 API 方法和属性,因为它涉及公共的方法,所以需要有一些准备工作

这种情况下,我建议维护一个用来压缩的名称映射表,然后使用 babel-plugin-transform-rename-properties对它进行重命名

这份映射表是手动编辑的公共属性和方法的名称列表,并且仅在公共 API 发生更改时才需要更新,就像是你的项目说明文档,项目发生改变的时候你要保持文档的更新

// filename: babel.config.js
const nameMapping = {
  driveTo: "d", // rename all `.driveTo` to `.d`
};

return {
  plugins: [
    [
      "babel-plugin-transform-rename-properties",
      {
        rename: nameMapping,
      },
    ],
  ],
};

其他

webpack & rollup

目前整篇文章都在介绍 Terser 和 Terser 的配置,尚未提及如何在使用 webpack 或者 rollup 打包的项目中使用

webpack 用户可以使用 terser-webpack-plugin 插件

// filename: webpack.config.js
const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          mangle: {
            properties: {
              regex: /^_/,
            },
          },
        },
      }),
    ],
  },
};

rollup 用户可以使用 rollup-plugin-terser 插件

// filename: rollup.config.js
import { terser } from "rollup-plugin-terser";
rollup({
  plugins: [
    terser({
      mangle: {
        properties: {
          regex: /^_/,
        },
      },
    }),
  ],
});

Preact 中的一个奇怪现象

The rabbit hole of how to mangle property names starts with investigating the Preact Suspense bug, but that would be a story for another time.

Preact 是一个和 react 具有相同 api 只有 3k 体积的轻量库

使用 mangle 压缩属性是保持库体积很小的很重要的一个方法

Without mangling With mangling
10.7 KB minified 9.7 Kb minified (reduced ~10%)
4.2 KB minified + gzipped 3.9 KB minified + gzipped (reduced ~5%)

下面罗列一些 preact 构建过程的差异

  • preact/core
  • preact/compat - a compat layer on top of preact to provide all React API
  • preact/debug - a layer on top of preact/core that provides a better debugging experience
  • preact/devtools - the bridge between preact/core and the devtools extension.

在不同的构建过程中统一的使用 mangle 压缩属性使用了babel-plugin-transform-rename-properties,名字映射表在mangle.json

在 preact 中使用 babel-plugin-transform-rename-properties 的 pr 在 https://github.com/preactjs/preact/pull/2548

preact 中压缩私有属性使用了microbundle,这是用来从 mangle.json 或者 package.json 中的 mangle 属性中读取压缩的选项,具体可以查看Mangling Properties for microbundle.

最后

我们已经介绍了什么是 mangle,以及使用 mangle 的一些注意事项

充分了解了这些警告之后,我们研究了可用于利用属性压缩来减少我们的代码体积进行性能优化

相关链接

原文

👾

Published on Feb 1, 2021