本文是Dave Herman的《Static module resolution》一文的编译。Dave Herman是TC39的成员,ES6 module系统的champion。【ES6 spec太大了,所以分成许多可相对独立的特性集合,分别交给一个或几个主导人负责,TC39委员会则会定期开会进行审阅和讨论。主导人就称之为champion。】
在纯JS环境下已经有多种模块系统。比如CommonJS。所谓纯JS系统,就是不依赖其他机制如预处理之类的。纯JS系统中的模块都是一个个对象。客户代码导入模块所导出的定义,实际上是查找module对象上的属性:
ES6模块系统则相反,模块不是对象,而是声明式的代码集合。从模块导入定义也是声明式的:
这个import是在编译时resolve的——即在脚本开始执行之前。事实上,各个模块之间的依赖关系图所涉及的所有imports和exports都是在执行之前resolve好了。当然,我们也有lazy loading或按需加载的需求,即在运行时才进行模块加载。对此,ES6也有异步的模块动态加载API。不过本文只讨论声明式模块依赖关系图的解析。
NodeJS的作者认为我们应该走渐进的、改良式的道路,认为ES6的模块系统应该更接近今天已经存在着的模块系统。我也相当赞同“pave the cowpaths”哲学(遵循事实标准),并常以此立论,但是必须注意到,现有JS模块系统的作者们从未有过从语言层面做修改的可能性,而我们现在却有机会改变JS,选择在纯动态系统中没可能走的道路,包括:
快速查找
静态import(无论是通过import还是如m.foo的引用)可以编译为如同简单的变量引用一样。在动态模块系统中,像m.foo这样的显式用引(dereference)会得到一个对象引用,通常需要PIC才能优化(Polymorphic Inline Caching,多态内联缓存,JavaScript引擎在执行时动态修改JIT代码的高级优化技术)。如果是复制到局部变量,相对来说会较容易进行优化。但是对于静态模块来说,总是早期绑定,也就是始终和变量引用一样高效。这使得模块化的程序能运行更快,避免了因为模块化导致额外性能成本。
早期变量检查
依我的经验,在脚本执行前若能对变量引用——包括imports和exports——进行检查,非常有助于确保程序顶层的基础结构是健全的。JavaScript基本上是静态作用域的,因此可以进行静态作用域检查,这也是唯一可以做的检查。James Burke认为这只是shallow type checking(浅类型检查,而不是强类型检查),不够有用。但我在其他语言的经验表明正相反——这超级有用!变量检查是一个最佳平衡点,你可以写出富有表达力的动态程序,同时又能捕捉到那些真的很常见的错误。如Anton Kovalyov指出的,报告未绑定的变量是JSHint的最常用特性,如果不必借助额外的lint工具就能捕捉这些bug那就再好不过了。
循环依赖
允许模块间循环依赖是非常重要的。现实情况是编程中可能出现相互间的递归调用——有时你甚至都没注意到。如果你将程序拆分模块后,由于不能处理循环依赖结果系统挂了,那最简单的workaround就是继续把所有东西都堆到一个大模块中。这肯定有问题。无论如何,模块系统不应该阻止程序员拆分程序,不应该挫伤程序员模块化的积极性。
不是说动态系统就不可能支持循环依赖,但是我觉得在那些提案中看起来都像是事后补丁。ES6的静态模块系统则仔细的考虑了循环依赖问题。声明式的模块让你可以在执行任何代码前预初始化更多的模块结构,这样如果引用尚未赋值的export,能得到更好的错误信息。例如,一个let绑定会扔出异常——如果你在它被赋值之前就引用它的话——你可以得到清晰的错误信息。而一个动态模块对象上的属性如果还未赋值就被引用,得到的是undefined,最终错误可能发生在客户代码中,必须跟踪这个错误直到源头——这比异常要难调试太多了。
兼容未来的macro特性
我非常期待JavaScript未来能让程序员可以发展他们自己的定制语法扩展,而不必等待TC39。今天,人们自个儿写编译器来弄新语法。但是这个极难,而且你不能在同一个源文件里使用不同编译器提供的不同语法特性。
有了macro,你就可以实现,比如说一个新的cond语法,来取代连续的? :条件分支,并可以通过库的方式共享之:
cond这个macro会在程序运行前进行预处理,将这段代码转换为连续的条件分支。而纯动态模块是无法实现预处理的:
兼容未来的类型系统
在悲剧的ES4时代我就加入了TC39,当时委员会在搞一个可选的类型系统。这系统基础不全最终废弃。其中一个重要缺失就是模块系统,通过模块系统可以将代码划定边界并说“这部分需要类型检查”。否则你永远不知道是否有更多后续代码会影响类型检查。
为什么要有类型系统?一个原因是:JS很快且越来越快,但是也更难准确预测性能。通过类似LLJS的试验性系统,我在Mozilla的团队使用带有类型的JS方言进行预编译,生成相当独特的为当前JIT优化的JS代码。如果你可以直接用带类型系统的JS方言写出高性能核心,现代编译器可以做得更好而不用如此曲折。
通过声明性的解析,你可以导入和导出带有类型信息的定义,并可进行编译时检查。动态导入不可能进行静态检查。
跨语言的模块性
一些人不care或者不想要像macro或类型这样的特性。但是JavaScript必须适应许多不同的程序员的各种不同的开发实践和需求。其中一种方式是让人们使用他们自己的语言,并编译为JavaScript。所以即使未来的ECMAScript标准没有macro和类型,若你可以使用静态类型或带有macro的JS方言并编译为浏览器可执行的JS,也是相当好的。实际上人们已经这样干了,比如用Closure compiler的类型检查、Roy语言、ClojureScript等。静态模块系统可以更一致更直接的兼容更多的语言。
成本和收益
以上是一些我看到的声明性模块解析的收益。Isaac Schlueter(NodeJS的作者)说import语法无甚意义。这是不公正和错误的。它是有意义的。我也不认为声明性的import语法会给ES6和未来的JS版本增加很高的成本。
在纯JS环境下已经有多种模块系统。比如CommonJS。所谓纯JS系统,就是不依赖其他机制如预处理之类的。纯JS系统中的模块都是一个个对象。客户代码导入模块所导出的定义,实际上是查找module对象上的属性:
- var { stat, exists, readFile } = require('fs');
ES6模块系统则相反,模块不是对象,而是声明式的代码集合。从模块导入定义也是声明式的:
- import { stat, exists, readFile } from 'fs';
这个import是在编译时resolve的——即在脚本开始执行之前。事实上,各个模块之间的依赖关系图所涉及的所有imports和exports都是在执行之前resolve好了。当然,我们也有lazy loading或按需加载的需求,即在运行时才进行模块加载。对此,ES6也有异步的模块动态加载API。不过本文只讨论声明式模块依赖关系图的解析。
NodeJS的作者认为我们应该走渐进的、改良式的道路,认为ES6的模块系统应该更接近今天已经存在着的模块系统。我也相当赞同“pave the cowpaths”哲学(遵循事实标准),并常以此立论,但是必须注意到,现有JS模块系统的作者们从未有过从语言层面做修改的可能性,而我们现在却有机会改变JS,选择在纯动态系统中没可能走的道路,包括:
快速查找
静态import(无论是通过import还是如m.foo的引用)可以编译为如同简单的变量引用一样。在动态模块系统中,像m.foo这样的显式用引(dereference)会得到一个对象引用,通常需要PIC才能优化(Polymorphic Inline Caching,多态内联缓存,JavaScript引擎在执行时动态修改JIT代码的高级优化技术)。如果是复制到局部变量,相对来说会较容易进行优化。但是对于静态模块来说,总是早期绑定,也就是始终和变量引用一样高效。这使得模块化的程序能运行更快,避免了因为模块化导致额外性能成本。
早期变量检查
依我的经验,在脚本执行前若能对变量引用——包括imports和exports——进行检查,非常有助于确保程序顶层的基础结构是健全的。JavaScript基本上是静态作用域的,因此可以进行静态作用域检查,这也是唯一可以做的检查。James Burke认为这只是shallow type checking(浅类型检查,而不是强类型检查),不够有用。但我在其他语言的经验表明正相反——这超级有用!变量检查是一个最佳平衡点,你可以写出富有表达力的动态程序,同时又能捕捉到那些真的很常见的错误。如Anton Kovalyov指出的,报告未绑定的变量是JSHint的最常用特性,如果不必借助额外的lint工具就能捕捉这些bug那就再好不过了。
循环依赖
允许模块间循环依赖是非常重要的。现实情况是编程中可能出现相互间的递归调用——有时你甚至都没注意到。如果你将程序拆分模块后,由于不能处理循环依赖结果系统挂了,那最简单的workaround就是继续把所有东西都堆到一个大模块中。这肯定有问题。无论如何,模块系统不应该阻止程序员拆分程序,不应该挫伤程序员模块化的积极性。
不是说动态系统就不可能支持循环依赖,但是我觉得在那些提案中看起来都像是事后补丁。ES6的静态模块系统则仔细的考虑了循环依赖问题。声明式的模块让你可以在执行任何代码前预初始化更多的模块结构,这样如果引用尚未赋值的export,能得到更好的错误信息。例如,一个let绑定会扔出异常——如果你在它被赋值之前就引用它的话——你可以得到清晰的错误信息。而一个动态模块对象上的属性如果还未赋值就被引用,得到的是undefined,最终错误可能发生在客户代码中,必须跟踪这个错误直到源头——这比异常要难调试太多了。
兼容未来的macro特性
我非常期待JavaScript未来能让程序员可以发展他们自己的定制语法扩展,而不必等待TC39。今天,人们自个儿写编译器来弄新语法。但是这个极难,而且你不能在同一个源文件里使用不同编译器提供的不同语法特性。
有了macro,你就可以实现,比如说一个新的cond语法,来取代连续的? :条件分支,并可以通过库的方式共享之:
- import cond from 'cond.js';
- ...
- var type = cond {
- case (x === null): "null",
- case Array.isArray(x): "array",
- case (typeof x === "object"): "object",
- default: typeof x
- };
cond这个macro会在程序运行前进行预处理,将这段代码转换为连续的条件分支。而纯动态模块是无法实现预处理的:
- var cond = require('cond.js');
- ...
- // impossible to preprocess because we haven't evaluated the require!
- var type = cond { /* etc */ };
兼容未来的类型系统
在悲剧的ES4时代我就加入了TC39,当时委员会在搞一个可选的类型系统。这系统基础不全最终废弃。其中一个重要缺失就是模块系统,通过模块系统可以将代码划定边界并说“这部分需要类型检查”。否则你永远不知道是否有更多后续代码会影响类型检查。
为什么要有类型系统?一个原因是:JS很快且越来越快,但是也更难准确预测性能。通过类似LLJS的试验性系统,我在Mozilla的团队使用带有类型的JS方言进行预编译,生成相当独特的为当前JIT优化的JS代码。如果你可以直接用带类型系统的JS方言写出高性能核心,现代编译器可以做得更好而不用如此曲折。
通过声明性的解析,你可以导入和导出带有类型信息的定义,并可进行编译时检查。动态导入不可能进行静态检查。
跨语言的模块性
一些人不care或者不想要像macro或类型这样的特性。但是JavaScript必须适应许多不同的程序员的各种不同的开发实践和需求。其中一种方式是让人们使用他们自己的语言,并编译为JavaScript。所以即使未来的ECMAScript标准没有macro和类型,若你可以使用静态类型或带有macro的JS方言并编译为浏览器可执行的JS,也是相当好的。实际上人们已经这样干了,比如用Closure compiler的类型检查、Roy语言、ClojureScript等。静态模块系统可以更一致更直接的兼容更多的语言。
成本和收益
以上是一些我看到的声明性模块解析的收益。Isaac Schlueter(NodeJS的作者)说import语法无甚意义。这是不公正和错误的。它是有意义的。我也不认为声明性的import语法会给ES6和未来的JS版本增加很高的成本。