欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

论ES6模块系统的静态解析 博客分类: JS javascripte6module

程序员文章站 2024-03-15 09:55:41
...
本文是Dave Herman的《Static module resolution》一文的编译。Dave Herman是TC39的成员,ES6 module系统的champion。【ES6 spec太大了,所以分成许多可相对独立的特性集合,分别交给一个或几个主导人负责,TC39委员会则会定期开会进行审阅和讨论。主导人就称之为champion。】


在纯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版本增加很高的成本。