跳出工程化看工程化

```js Array.prototype._map = function(cb) { var ret = [];

for (var i = 0; i < this.length; i++) { ret.push(cb(this[i], i, this)); }

return ret; };

arr = [1, 2, 3]; arr._map(function(x) { return x * 2; }); // [2, 4, 6] ```

理解了这个,你就知道为什么多数前端项目的入口文件的第一行一般都是 import '@babel/polyfill';。当然这其实是一个很不好的模式,因为各个浏览器或者说各个端的 JS 引擎对 JS 方法的支持情况已经有很详细的统计了,可以参考 Can I use,所以一股脑的引入全量的 @babel/polyfill 实际上是很浪费性能的举措(虽然绝大多数垫片方法不会执行,但是 JS 引擎依然需要去编译完整的垫片代码)。鉴于此,babel 在 7.x 推出了 usebuiltins 选项可以根据 target 决定引入哪些垫片。还有 Polyfill.io 则更进一步,会根据用户请求头的信息动态的返回垫片内容,不浪费一丁点性能,这无疑也给了工程化过程中对垫片的处理一个新的思路。

  1. 模块化

向下兼容更多的是通过编译将源码转换成了能在更多环境下运行的代码,而模块化的步骤则是通过编译将存在依赖关系的代码聚合到一起。聚合的方式有很多种,这个话题会在下面打包部分具体讨论,而依赖关系的确定无非是对 AST 中和模块定义相关的节点进行解析。常见的和模块相关的节点主要有 ES6 模块 importexport,CommonJS 模块 requiremodule,AMD 模块 define。所以在模块化的过程中,只需要遍历这些节点即可,将导入方法和导出方法替换为目标代码模块化方案的实现即可。当然为了合并后的代码有更广泛的适用性,一般模块化工具都会自己实现一套模块化方案来组织代码。比如 webpack 就中意 CommonJS 方案,所以它在对代码进行模块化处理的时候,会做两步工作: 1. 替换导入和导出节点为自己的实现;2. 在整个模块外包裹一层匿名函数。其中第一步像极了~~爱情~~向下兼容时提到的 helper 函数,而第二步则借助闭包巧妙的避免了作用域污染等问题。

```js // ./answer.js export default 42;

// 0. AST { ExportDefaultDeclaration: { NumericLiteral: { value: 42; } } }

// 1. ExportDefaultDeclaration -> AssignmentExpression (left = heleper 函数) webpack_exports['default'] = 42;

// 2. 闭包并注入 helper 函数 { './answer.js': function(module, webpackexports, webpackrequire) { webpackexports['default'] = 42; }, ...othermodules } ```

webpack 对于导出节点的处理可以参考 HarmonyDetectionParserPlugin,闭包的处理可以参考 FunctionModuleTemplatePlugin。对 webpack 的 helper 函数,我们更多的时候习惯称其 runtime 函数,或者 webpackBootstrap,有兴趣的同学可以参考我的另一篇文章 详解 webpackBootstrap

  1. 优化

优化实际上是一个很大的主题,这里不想展开。更符合一个 FEer 知觉的说法,应该是代码压缩。相对常见的优化点,比如将方法签名的形参缩短(e.g. function func(some_meaningful_param){} -> function func(x){}),或是简化冗余逻辑(e.g. if( 2 > 1 || other_conditions){} -> if(1){})。进行这些转换还是离不开编译,而一般的压缩也是通过对抽象出来的 AST 应用各种优化规则,从而安全又准确的完成代码压缩的工作。举个简单的例子:

```js function square(number) { return number * number; }

const number = 42; ```

我们要压缩 square() 方法的形参,只需要 vistorFunctionDeclaration 内部将所有 Identifier.name === 'number'Identifier 替换掉即可。这样在 FunctionDeclaration 外部即使有与 square() 方法形参相同的变量定义,也不会被误替换。

ast-003

打包

打包其实是工程化发展的一个缩影,也见证了前端模块化的发展历程。从早些时候的 Grunt,到后来的 Gulp,再到后来的 BrowserifywebpackRollupParcelMetro,可谓是百家争鸣。每个工具都有带来自己的理念,但万变不离其宗的就是上面编译部分提到的模块化支持。而这些工具对模块化的实现,也大致可以分为以下三种方式:

  1. 合并文件流

早期的 Grunt 和 Gulp 都是通过合并文件的思路来实现打包的。这就要求源代码有自己的模块化实现,比如像 jQuery 那样使用 $ 作为命名空间;或是将模块化方案定义在全局可访问的地方,比如在全局定义 define()require() 方法的 AMD 标准,然后所有的源码,遵循这一标准,使用 AMD 的模块化方案编写代码。当然如果你的源码足够简单,你可以不需要任何模块化方案,直接合并文件,只要你能保证没有变量冲突即可。

后来的 Rollup 也是文件合并的思路,不过在合并的过程中会对变量命名进行检查,如果后合并的模块内有和之前模块冲突的变量命名,则自动进行重命名的工作。对于不太复杂的项目,特别是 NPM 包这种,通过 Rollup 简单的合并后不会引入额外的模块化 helper 函数,而且 Rollup 带来的 tree shaking 方案也会剔除掉未被引用的代码。很大程度上可以减小打包后文件的体积,我想这也是为什么 React、Redux 这些主流的库,依旧在使用 Rollup 就行构建的原因。

  1. CommonJS 标准

CommonJS 标准似乎是目前的主流方案,而 webpack、Parcel 是这种模块化方案的代表。他们在打包代码时会遵照上面编译过程中提到的流程,首选将导入和导出节点转换为自身实现的 helper 函数,接着借助闭包实现作用域隔离并注入 helper 函数。将所有模块打包成 {[id]: ƒ} 形式储存,然后在入口处定义 helper(即 runtime) 函数。这个过程看似复杂,但却最大程度的避免了全局污染以及第三方对源码的入侵。在不做模块拆分的情况下,webpack 打包完的代码执行完后不会在全局定义和暴露任何变量和方法,所有源码中的变量和方法都在各自的闭包作用域内,我想这就是其最大的优势之一吧。

当然有些同学可能会有疑问,webpack 的 output.libraryTarget 选项明明是支持 amdumd 等一众配置的,为什么说他是 CommonJS 标准的模块化实现呢?其实你仔细看其构建产物就会发现,不同的 output.libraryTarget 只是决定了其打包产物是一个自执行时函数,还是将自执行函数作为 factory 方法交给对应的模块化方案去调用。其自执行函数内部本质还是一个基于 CommonJS 标准的 helper 函数和对应的以 {[id]: ƒ} 形式储存的模块代码。

  1. AMD 标准

本来我以为 AMD 的标准已经渐渐淡出主流视野了,直到开始接触 React Native 的构建,才发现其官方御用的构建工具 Metro 的输出是基于 AMD 标准的模块化方案。其在编译实现模块化的过程中与上面基于 CommonJS 标准的构建工具大同小异,无非是在编译阶段,将所有导入聚合到一个集合内部,然后将所有对导入的引入作为入参传入当前模块即可。这样做的好处是,模块化后的模块(文件)有着类似的结构(形如 define('id', ['moduleA'], (moduleA) => { ... })),并且可以直接合并。考虑到 RN 的 JS 运行时,是在 native 内部启动的 JS 引擎,外部并没有访问的机会,即使将 definerequire 这些方法暴露,也并没有太大风险。而且基于 AMD 的模块方案,让我们可以有更灵活的拆包方案,自由的按需拆包。

```js // full bundle define('a', [], () => {}); define('b', [], () => {}); define('c', [], () => {}); define('d', [], () => {});

// ↓↓↓↓

// split.bundle.1 define('a', [], () => {}); define('b', [], () => {});

// split.bundle.2 define('c', [], () => {}); define('d', [], () => {}); ```

  1. 未来可期

未来的工程化打包是什么样子的?我猜,未来可能不需要打包了。为什么这么说?首先我们考虑为什么要打包,第一 JS 引擎确实没有已实现的模块化方案;第二大型项目动辄 1K - 2K 个模块依赖(因为有打包有模块化方案,前端也会采用分层的架构设计,由此带来逻辑的清晰,但副作用是引用分散),如果没有打包在浏览器端进行引用是难以想象的。但是这两个问题都在慢慢改变,Chrome 已经从 61 开始支持 <script type="module"> 标签,而借助 CDN 及 HTTP2,多文件并发请求的效率远远大于单个大文件的请求效率。也正是基于此,诞生了类似 pika 的基于 ES6 模块化方案的模块管理平台,也许今后你的代码对 Redux 的引用,就直接变成了 import * as Redux from 'https://cdn.pika.dev/redux/v4';,更多内容可以参考其官网的 blog 《A Future Without Webpack》。你仔细看那个引用方式,是不是和 ry(Ryan Dahl) 新写的 Deno 也有异曲同工之妙。

执行

最后聊一下代码执行,我认为这是最重要,也是最不重要的部分。基于前面的工作,在这部分只要保证工程化处理后的资源(不仅仅是 JS 代码)能被正确加载就可以了,至于运行代码的 JS 引擎是 V8、JavaScriptCore,还是最新的 QuickJSHermes,万变不离其宗的只有 ECMAScript Language Specification

在浏览器端包括 PC 和手机,我们通常直接把资源丢到 CDN 上去,通过绝对地址引用;在移动端,我们通常将 JS 与资源文件一同打包,由 native 去下载和创建运行时;在桌面端,我们通常直接使用文件系统引用打包后的文件和资源,如此而已。

下篇:未完待续(~~还没想好~~)

参考文献

所有的引用都可以在正文中找到链接,这里就不汇总了。

-->
loading...
loading...

最新评论

    还没有人评论...

Powered by Fun & Rainsho. Copyright © 2017. [Manage]

www.rainsho.cc. All rights reserved.