分享一款超好用的JavaScript 打包压缩工具


Posted in Javascript onApril 26, 2020

背景

平时大家在开发 Js 项目的时候,可能已经离不开 webpack 等打包工具了。而 webpack 打包速度大概就是“能用“的水平。大概去年开始,我就开始在构想,如果能写一个极速的打包工具,功能未必需要很强,可能对小项目非常有用。去年我用 C++ 写完 parser 之后,便没什么动力写下去了。但是最近发现有这个想法的不止我一个,Figma 的 CTO 业余之际写了一个打包器 https://github.com/evanw/esbuild ,可以说完完全全实现了我想象中的需求,不过他是用 Go 语言实现的。我看到这个项目时心里一想,这不是我去年就想做的事吗,这 push 我赶紧把打包压缩部分完成。

代码

Github 地址: https://github.com/vincentdchan/jetpack.js

优化思路

并行 Parsing

毫无疑问,每一个 js 文件的 parsing 可以在不同线程完成,这就需要支持并行的语言。由于 parsing 的结果是 AST,所以需要可以共享内存的语言(排除通过 messeage parsing 实现多线程的语言)。满足以上两个要求的语言不多。 Evan 选择了 Go,我选择了 C++。

减少遍历次数

要想速度快,就要减少 AST 的遍历次数。最好就是只遍历一次来生成代码,在 Parsing 构建 AST 的时候就收集足够的信息。但是这也意味着只能做比较浅层次的优化,不能做深层次的压缩(死代码消除,tree shaking 都做不了)。

架构

由上述思路我总结出了以下打包的架构:

  1. 并行 parse 文件
  2. 作用域提升、生成框架代码、重命名变量
  3. 并行生成代码
  4. 合并输出文件

流程图如下:

分享一款超好用的JavaScript 打包压缩工具

打包压缩原理

本章节主要讲如何“最简单“地压缩 Js 代码。本章节假设读者对编译原理有一定了解,知道什么是 AST。如果不懂请直接跳到下文「性能」章节。

字面量替换

字面替换最简单。规则有一下几个:

  • undefined 替换为 void 0
  • true 替换为 !0 , false 替换为 !1

:warning: 注意:在 ES 中,undefined 是标识符(Identifier),而不是关键字,也就是说你可以定义一个叫 undefined 的变量,所以这个时候不能简单地替换为 void 0

常量折叠

计算简单的运算:

var two = 1 + 1;
var foobar = 'foo' + 'bar';

转换成

var two = 2;
var foobar = 'foobar';

:warning: 注意:这里要注意实现的平台和 js 的差异,比如在 C++ 里面大整数相加可能会溢出,而在 Js 会自动转换成 bigint. 加法问题就如此,其他运算符问题更多。如果要完整实现常量折叠,可能要部分实现 js 引擎。

变量别名

别名就是要给变量重新赋予比较短的变量名。从字母一直排上去,abcd,一个字母用完了用两个字母。实现起来也很简单,用一个计数器,一直加上去就可。最后每个变量分配一个数字,把这个数字映射到相应的英文字母上,有点像 36 进制转换成字母的面试题。不过这里有一点值得注意的是,变量名第一个字母不能是数字,第二个字母开始可以是数字,要考虑到这一点,才能尽可能“压榨”变量名。

为了尽可能地“压榨”变量名,同一级的作用域里面的变量名是可以使用相同的变量名。到下一级的时候,对子作用域进行合并。

举个例子:

function Mother() {
	var e = 'capture'; // d 不能使用跟子作用域同样的变量名,不然子作用域无法捕获这个变量
	function A(a, b, c, d) {
 console.log(e);
	}
	
	function B(a, b, c) { // B 跟 A 函数同级,分配同样的变量名
	 // ...
	}
}

上述例子中,A 和 B 都没有子作用域了,变量名从 0 开始分配。到给 Mother 下 e 分配变量名时,找到子作用域最大的计数器。分配最多的子作用域 A 分配了 4 个,所以 B 计数器从 5 开始分配,所以给 e 分配了5,所以 e 就得到了这个名字。

所以变量别名就是从 AST 的叶子开始向上构造,一直分配到根结点把所有作用域都分配完为止。

小技巧

这里 esbuild 采用了比较聪明的技巧。它统计了所有变量的引用次数,然后进行排序,引用次数最多的变量分配到的名字就是尽量短的,这样也可以减少编译出来 js 的体积。我在写 jetpack 打包的时候,也借鉴了这种做法。

模块合并

模块合并的办法有很多。webpack 采用的是用 function 把每个函数包起来,放到了一个长长的数组里面,然后实现了自己的 require,esbuild 也采用了类似的方法。

Rollup.js 实现的方法则是作用域提升(Scope hoisting),把模块都放到根作用域。这里我采用的方法也是作用域提升。

假设有 a.js 文件:

export function A() {
 console.log('a');
}

然后有 main.js 文件:

import { A as ExternalA } from './a';

function A() {
 console.log('local A');
}

export function main() {
 A() + ExternalA();
}

使用 jetpack 打包完的结果:

// a.js
function A() {
 console.log('a');
}

// main.js
function A_0() {
 console.log('local A');
}

function main() {
 A_0() + A();
}

export { main };

难点在于作用域合并。实际上在 ES modules 里面不同 modules 之间引用是一个图结构。

C++ 的优化

除了策略上的优化,C++ 还提供了诸多基础数据结构/内存方面的优化。

shared_ptr

AST 的结点全部使用 shared_ptr,有人可能认为这是一个很大的开销。但是早期的时候我实现过一个裸指针版本(不释放内存),并没有测出有明显差距。

使用 shared ptr 很重要一个原因是,一个子树可能被其他类拥有(打包模块,Scope,ES Module 管理器)。这个时候如果用 unique ptr 的话就会 gg。只能说 GC 大法好。

对于 C++ 这种没有 GC 的语言有一个毛病就是:析构 AST 非常耗时。AST 够大的话能耗上十几 ms(这个时间跟 gc 比有何优势?),所以因此我也能想出了一个办法: 不释放内存 ……。

最后说一句: GC 大法好

robin hood hashing

由于打包器中大量使用哈希表,所以提高哈希表速度尤其重要,这里我使用了 robin hood hashing

参见: https://martin.ankerl.com/2019/04/01/hashmap-benchmarks-01-overview/

在 hash 方面我有一个设想,就是像 Lua 一样,对于短字符,在字符串创建的时候把 hash 记下来,这样在多次使用哈希表的时候可以节省 hash 的时间(但是要求字符串是 immutable 的)。为此我专门写了个 String 类,最后的结果是总体速度慢了 2-3x,测出来是 immutable 字符串拼接耗时太多,最后放弃了这个方案。

jemalloc

Parsing 过程中需要大量分配 node,大家都知道很明显 C++ 的 new 并不够快。经过测试在 macOS 下使用 jemalloc 会让 parsing 速度提升 1 倍。使 用系统 malloc 会导致 parsing 速度比 Go 慢 1x 左右,慢在 new 。

当然了,内存池我也试过的,测出来速度基本和 jemalloc 一样,所以就直接用 jemalloc 了。

性能

分享一款超好用的JavaScript 打包压缩工具

总结

写编译器需要快速大量产生 node 结点,大量树和图的结构,这一方面的运算 C++ 并没有什么优势可言。

不得不承认,使用 C++ 你要思考很多东西,做很多很多额外的工作,才能获得比 Go 还快的速度(什么都不想做出来只会比 Go 还慢)。另一方面使用 C++ 会让你额外考虑很多和业务无关的东西,大大降低开发速度,而对于打包器这个场景 C++ 在这一块本身不能提供很大优势。

到此这篇关于写一个飞快的 JavaScript 打包压缩工具的文章就介绍到这了,更多相关JavaScript 打包压缩工具内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Javascript 相关文章推荐
鼠标经过的文本框textbox变色
May 21 Javascript
Javascript读取cookie函数代码
Oct 16 Javascript
JQuery分别取得每行最后一列和最后一行的示例代码
Aug 18 Javascript
使用canvas实现仿新浪微博头像截取上传功能
Sep 02 Javascript
JS+CSS实现仿支付宝菜单选中效果代码
Sep 25 Javascript
react native带索引的城市列表组件的实例代码
Aug 08 Javascript
JavaScript原生实现观察者模式的示例
Dec 15 Javascript
JS扩展String.prototype.format字符串拼接的功能
Mar 09 Javascript
vue动态路由配置及路由传参的方式
May 23 Javascript
jQuery实现鼠标移入显示蒙版效果
Jan 11 jQuery
原生JavaScript实现五子棋游戏
Nov 09 Javascript
React 高阶组件HOC用法归纳
Jun 13 Javascript
微信小程序自定义navigationBar顶部导航栏适配所有机型(附完整案例)
Apr 26 #Javascript
javascript 使用sleep函数的常见方法详解
Apr 26 #Javascript
基于JavaScript实现十五拼图代码实例
Apr 26 #Javascript
小程序自定义导航栏兼容适配所有机型(附完整案例)
Apr 26 #Javascript
vue 使用 vue-pdf 实现pdf在线预览的示例代码
Apr 26 #Javascript
javascript设计模式 ? 访问者模式原理与用法实例分析
Apr 26 #Javascript
详解关于Vue单元测试的几个坑
Apr 26 #Javascript
You might like
PHP中遍历stdclass object的实现代码
2011/06/09 PHP
PHP编程风格规范分享
2014/01/15 PHP
PHP弱类型语言中类型判断操作实例详解
2017/08/10 PHP
JavaScript使用技巧精萃[代码非常实用]
2008/11/21 Javascript
js操纵dom生成下拉列表框的方法
2014/02/24 Javascript
jquery插件star-rating.js实现星级评分特效
2015/04/15 Javascript
浅谈javascript中的call、apply、bind
2016/03/06 Javascript
JS随机洗牌算法之数组随机排序
2016/03/23 Javascript
深入理解React中es6创建组件this的方法
2016/08/29 Javascript
强大的 Angular 表单验证功能详细介绍
2017/05/23 Javascript
基于vue-ssr服务端渲染入门详解
2018/01/08 Javascript
Vuejs 单文件组件实例详解
2018/02/09 Javascript
[原创]jQuery实现合并/追加数组并去除重复项的方法
2018/04/11 jQuery
微信JS-SDK实现微信会员卡功能(给用户微信卡包里发送会员卡)
2019/07/25 Javascript
python3.0 字典key排序
2008/12/24 Python
python删除列表中重复记录的方法
2015/04/28 Python
Python实现求最大公约数及判断素数的方法
2015/05/26 Python
python获取元素在数组中索引号的方法
2015/07/15 Python
python实现汉诺塔递归算法经典案例
2021/03/01 Python
关于Python中异常(Exception)的汇总
2017/01/18 Python
神经网络(BP)算法Python实现及应用
2018/04/16 Python
Python从单元素字典中获取key和value的实例
2018/12/31 Python
python实发邮件实例详解
2019/11/11 Python
python实现在一个画布上画多个子图
2020/01/19 Python
Python如何省略括号方法详解
2020/03/21 Python
微软俄罗斯官方网站:Microsoft俄罗斯
2016/09/18 全球购物
澳大利亚礼品篮网站:Macarthur Baskets
2019/10/14 全球购物
请写出char *p与"零值"比较的if语句
2014/09/24 面试题
门诊手术室工作制度
2014/01/30 职场文书
卫生标语大全
2014/06/21 职场文书
个人工作表现自我评价
2015/03/06 职场文书
党员观看《筑梦中国》心得体会
2016/01/18 职场文书
2016年“11.11”光棍节活动总结
2016/04/05 职场文书
入党转正申请自我鉴定
2019/06/25 职场文书
vue组件的路由高亮问题解决方法
2021/05/11 Vue.js
Vue实现跑马灯样式文字横向滚动
2021/11/23 Vue.js