从include到require - 论node require设计

由于“工作”需要,在上周的某个月黑风高的夜晚,“新建”了一个vue项目。当然了,像我这种不做死就会死星人,自然“一不小心”就把webpack升级到了3.x。

然后惊喜的发现var Vue = require('Vue');竟然不工作了???然后再发现var MyComponent = require('./MyComponent');竟然也不工作了???

然后就是一路的Google,WHY??? 回头换来一个说法竟然是为了“全方位”的兼容import/export,从babel xxx版本开始,require必须后面跟个.default才能导入export的结果。

我擦,我这是瞬间要炸毛的节奏啊。以牺牲require用户体验的方式,强制“退休”require。你们真的“赢了”。

本来吧,es标准委员会推出esModule标准的时候,就想吐嘈这完全是个开历史倒车的标准。但是本着“和谐共处 x项原则”。你推你的import,我用我的require,各走阳光道。但是这次的事件真的是着实恶心到我了。

本来想写篇文章喷import,但是想想作为一个老年人,还是火气还是不要这么大,与其互喷,还不如写点文章,来聊聊require是如何走到今天的吧。相信各位看官看完,自然对import是进车还是倒车就有了自己的看法了。

1. 从c/c++ include说起

在“上古时代”,文件系统啥的都不存在,自然也不存在什么include/import/require的了。在那个年代,一切的“函数”都被编译好了,安安静静的躺在寻址空间的某个角落。

当我们需要某个“函数”的时候,我们只需要说:“啊,我知道一个人叫张三,长的人高马大,一米六八。”就可以了。

换成c的代码就是:

void printf(char * fmt, ...);

这就是所谓的“声明”。很多刚学c的同学很是难以理解,声明和定义区别在哪里。这个其实蛮正常的啦,就像是生活在现代社会的人,难以很多“传统”习俗都是蛮难理解的。(就像是为什么要喝热水,手工狗脸)

但是人嘛,都是懒得,程序员那是尤其的懒。一遍一遍的写累是一方面,更是容易出错。我相信眼尖的同学已经发现问题了:为什么fmt的类型是char *而不是const char *?因为:我就是故意的啊。。。

眼睛更尖同学应该还会注意到printf的返回值,对的,printf应该返回int,而不是void。来源于那个年代奇怪的信仰。如果不是对着手册抄的话,我估计现在多数人都会弄错。

那这个错的离谱的代码能跑吗?答案是“能”!这个明显的不合理啊,这简直不是自欺欺人么???显然,我们的“祖先们”也发现了这个问题。在原始朴素的想法驱动下,那自然就是把一部分重复的东西提取出来,include自然也就出现了。

#include "那些我懒得重复写的声明.h"

显然的,这种朴素的include和现代的包管理有着天壤之别。inlcude赋予程序员灵活性的同时,也带来了一堆苦恼。相信下面代码是个c/c++的程序员都写过:

#ifndef __MY_PROJECT_MY_WIDGET_H__
#define __MY_PROJECT_MY_WIDGET_H__

...

#endif//__MY_PROJECT_MY_WIDGET_H__

在历史上,头文件里是只有函数声明的,在c语言里你对同一个函数声明1次或者声明100次,编译器完全都是“无动于衷”,自然也不会有防重复导入的功能了。甚至不少c/c++的代码还依赖于这种重复导入的功能。

再一个典型的例子就是include的顺序问题,相信写过c++的人,对include hell啥的都是不陌生的。

include只是一个萌芽,问题和坑几乎漫山遍野,程序猿从来都是解决一个问题时创造两个问题生物。做包管理肯定也不例外。include虽然带来了漫山遍野的问题,但include解决了当年的核心诉求。我们在这里就不细数include的问题了,不公正也没必要。

2. 从c/c++ include到python import

从c/c++ include到python或者java的import可以说是编程历史上的一个重大进步。个人没有花时间去追溯到底是python借鉴了java,还是java借鉴了python,也或许它们共同的受到了另一个语言的启发。在这里之所以以python为例,是因为es的import是以python import为原型设计的。

但不论如何,import出现解决了大量的问题。

例如你不需要在写一个独立的头文件,因为本质上来说,头文件完全是可以通过源文件生成的。

再例如就是你不再需要担心文件之间的互相引用关系,也不需要再担心文件的引用顺序,文件的防重复引用。

甚至python也许有意也许无意下解决了一个c/c++的痛点,就是名字空间问题:假如村子里有两个叫王麻子的怎么办?

在c/c++年代,大家普遍的使用前缀来解决这个问题:既然村子里有两个王麻子,这个事情好办,一个取名成村头的王麻子,另一个取名为村尾的王麻子问题就解决了。

当然了,如果你要把一个镇子的人都拉下来一起写代码,王麻子们自然就不能这么取名字了,自然就出现了里岙村村头王麻子外山村村尾王麻子

这个看起来貌似没什么的问题,当随着越来越多的程序员参与开发后,问题越来越爆炸。最后发展到什么样子呢?我们来看一个gtk的函数命名:

void gtk_menu_tool_button_set_arrow_tooltip_markup(...);

这个不是我特意挑特意选的,而是我随手打开gtk的手册挑了一个,这个函数命名足足有45个字符。这显然写起来不是那么舒服。

但即使是这样,名字冲突还是层出不穷。最典型的就是c/c++里面那些让人崩溃的“基本拓展类型”,比如啥DWORD啊,UINT32啊,到处都在定义,还都不太一致。一出错完全不知道引了哪个定义。

我为什么说我不知道python到底是有意还是无意解决了这个问题呢?

在python的import语法中,当你导入一个包时,包里面的东西是不会展开到你的名字空间的。而是会有一个独立的名字,就好像:

import 中国.浙江省.温州市.永嘉县.乌牛镇.河口埭村

print(河口逮村.王麻子)

这种封闭的结构充分的避免了名字冲突。而且显得非常的自然。

但是很可惜的是,es在继承python的import时,却捡了芝麻丢了西瓜,丢掉了这个重要特性。关于这方面,我们在后面的文章继续的探讨。

3. 从c/c++ include到ruby require

ruby,几乎和python一样,出生在那个计算机语言大爆炸的年代。自然也面临了这种挑战。ruby采用了和python并不完全相同的require去解决相似的历史问题。

就像是import解决了头文件和源代码文件分离,解决了循环引用和重复引用。require也解决了同样的问题。

但要说require和import最大的区别的话,那就是require从出生的那天起,其实就是一个函数,而import却是一个指令。换句话来说,你可以这么写代码:

def my_func
  require('some_module')
  do_something
end

但是你不能这么写代码

def my_func():
    import some_module
    some_module.do_something()

这也是require和include的最大区别。

这个区别看上去是细微的,但是实际上却造成非常的微妙:

import可以在代码执行前扫描依赖关系,如果某个依赖不存在的话,解释器有机会在不执行一行代码的前提下就直接报错停止。(虽然实际上python并没有这么做)。相似的,代码打包器也完全可以在不执行代码的前提下为代码打包,语法提示器完全可以在不执行代码的前提下进行语法提示。。。

听起来非常的美妙,但是同样的,import静态性自然有静态性的缺点:

plugin_name = 'img_' + ext
require(plugin_name)   # 非常的OK,因为require本来就是个函数,传个字符串啥的分分钟的
plugin_name = 'img_' + ext
import plugin_name     # 囧,加载了一个模块叫plugin_name

还有另一个缺陷也无法忽视,就是加载时间,随着现在软件项目越来越大,启动时一次性加载到内存即漫长又没有必要,因此即使是python,现实实现中也是遇到一个import执行一个,并不是一次性先全部检查加载。(但即使是这样,python的启动速度还是非常的慢,核心的原因是import的语法设计会引导使用者总是先把所有依赖都写在文件头部)

而require则完全是相反的路子,依赖完全是动态加载的。就像是事物的两面,require解决了静态依赖的痛点,但同时动态化也使得ruby的语法提示变得困难。打包器也不得不以来于一个独立的Gemfile来描述依赖管理。

顺便值得一提的是,ruby require作为一个函数,同时还获得了一些意外的好处,就是require函数是可以被覆写的,因此诞生了一个伟大的项目,叫rubygems。虽然在ruby之前perl已经做了cpan,但是不得不说中央式的包仓库还是被ruby发扬光大的。但这其实也不算是require的优点,因为在import上也完全可以做。

4. node.js的抉择

动态/静态就像是光暗的两面,也是这次js require/import之争的核心。从某种角度来说,我个人认为python import的设计在总体上是优于ruby require的。其中最大的点就是,ruby require采用了和c/c++类似的设计:当你require一个文件的时候,这个文件的所有名字就会全部暴露在当前的名字空间下。

当node.js项目开始的时候,js是一个完全完全没有包模型的语言,所有的代码都被运行在一个叫window的变量的全局名字空间下。要在js里加载另一个js完全是一个魔法,我相信熟悉前端的老同学们下面代码应当都不陌生:

// 谜之js加载远程脚本
var script = document.createElement('script');
script.onload = function() {
    console.log('script loaded');
}
script.src = 'https://mywebsite.com/myscript.js';
document.head.appendChild(script);

所以当node.js开始的时候,所面临的第一个问题就是:如果让一个js能加载另一个js?

问题很复杂,但这个抉择却是半点都不复杂,node.js是2009年由Ryan Dahl单独开始的,这和node的竞争对手go来说,形成了极其鲜明的对比。go语言背后有强大的Google作为支撑,而node几乎是白手起家。这几乎注定了在这方面成本是致命的衡量标准。

实现一个require并不困难,只需要在V8引擎中注入一个全局变量既可。但是如果要实现一个import的话,却需要修改V8引擎的词法分析、语法分析、几乎牵扯到了解释器的方方面面。从这个角度上来说,node采用require是一个完全不意外的选择。

但是同样是require,Ryan却从ruby里充分的吸取了历史教训:node require会返回一个变量而不是修改本地名字空间。

这个小小的改动一方面降低了成本,另一方面解决了两个重大问题:

  1. node解决了ruby名字冲突问题,拥有了和python类似的名字隔离方案

  2. node解决了ruby插件加载后难以接收插件根对象的问题

另外node require还做了一个极具前瞻性的事情,就是把依赖安装到了项目目录。这个看似简单的改动,成功的绕开了python/ruby作为后端开发所面临的dependencies hell。相信用python的同学一定知道那个该死的virtualenv,或者ruby的rvm。连go语言这种后发的语言都掉入了GOHOME的坑里。

node的require不是没有缺点,而是基于实用主义的最佳实践。甚至说node require是现存语言最成功的设计都不为过。

5. ECMAScript标准化委员会的选择

那么node require设计是否就是完美的设计呢?

勿需质疑,肯定不是。因为完美只存在于童话世界。软件工程就是妥协和权衡的集中营。完美的设计,今天不存在,今后也不会存在。

为什么es标准化委员会选择重新造个轮子?

当然,我们可以推行些阴谋论:因为require的方案是廉价的,import的方案是昂贵的。强势玩家推行一个困难的方案有利于逼迫弱势玩家出局。

但咱今天不是来聊阴谋论的,咱来聊一聊“官方说法”:require方案不利于网络预加载,加载大量的文件会导致网页加载速度低下。

就像是我上面说的,动态方案的最大缺陷就在于无法被简单的静态分析。当浏览器加载页面如果需要反复的暂停,等待网络请求,的确会极大的影响用户体验。这个从表明上来说貌似是一个非常“合理”的理由。我们暂且认为这个理由是合理的。但es标准化委员会在import的设计中犯的低级错误简直无法忍受。

es的import基本上来说就是以python的import为蓝本设计的,反正你就是横着看像,竖着看也像。但偏偏却和python的不同。最大的不同在哪?就是default。

在python中,一切都是导出的,但在es中,你可以用export关键字选择导出什么。这是一个现代化设计的趋势,并没有任何问题。

但在es中,import xxx from 'xxx',却并不是导入xxx的所有东西,而是等价于import { default as xxx } from xxx。es import的默认语法并不是导入所有的东西,而是导入了一个叫default的东西并重命名。

这是一个非常不可思议的设计!对比一下下面的js/python代码:

// *错误代码*  这两行代码导入了同样的东西
import 天安门 from '北京市' // 相当与  导入`北京市.default`并取名为`天安门`
import 长安街 from '北京市' // 相当与  导入`北京市.default`并取名为`长安街`

import { 天安门 } from '北京市' // 这是正确导入
import * as 北京市 from '北京市' // 这句等价于python: import 北京市
# 这两行代码导入了不同的东西
from 北京市 import 天安门 # 相当与  导入`北京市.天安门`
from 北京市 import 长安街 # 相当与  导入`北京市.长安街`

在浏览器的环境下,我们希望网络流量越小越好,我们肯定是不希望在一个包里塞一些乱七八糟没用的东西,然后让浏览器下载到用户的电脑的。但这并不是重点,重点是这个奇怪的行为完完全全彻彻底底的破坏了名字的封装性。考虑一下下面的代码:

import { 王麻子 } from '张家村'
import { 王麻子 } from '李家屯'

WTF????

那我怎么办?这么写吗?

import { 王麻子 as 张家村的王麻子 } from '张家村'
import { 王麻子 as 李家屯的王麻子 } from '李家屯'

不知道大家怎么觉得,我反正是觉得蛋隐隐约约的疼。

我相信这个时候es的拥护者会告诉我说,你可以这么写代码啊?

import * as 张家村 from '张家村'
import * as 李家屯 from '李家屯'

OK,我们来回顾一下Babel的人是怎么指责require的吧?

Babel采用一种“摇晃大树”的方法来进行js模块的裁剪。在node require中,用户会把整个模块require进来,我们没法知道一个模块中哪些部分是需要打包的,哪些部分是不需要打包的,只能把整个模块打包进来。

你这么做和require有什么区别?除了写的更长更折腾之外?

我们上面举的王麻子的例子某种程度上来说还不够“致命”。更加致命的是:在现代工程方法里,如果一个方法所在的上下文是清晰的,那么它不应该重复清晰了的部分。什么意思呢?就是下面这个意思。

var c = sha1.init(); // 而不是 sha1.sha1_init()
sha1.update(c, 'hello'); // 而不是 sha1.sha1_update(...)
sha1.final(c); // 而不是 sha1.sha1_final()

有了上面这个例子,我们再来看一下下面的代码

import { init, update, final } from sha1

// 这中间有3个屏幕的代码
// balabala

var c = init(); // 我init了啥?
update(c, 'hello'); // update???哪来的方法?谁的方法?
final(c); // final what???

如果这个时候你还有个模块叫aes要导入的话,我觉得你真的是骂娘的心情都有了。因为aes也有3个函数叫init, update, final。

OK,那怎么写?我能想到的唯一写法只能是这样:

import { init as sha1_init,
         update as sha1_update,
         final as sha1_final } from sha1
import { init as aes_init,
         update as aes_update,
         final as aes_final } from aes
// ...

当然,这不是es标准化委员会的唯一失误。

node.js的开发人员2017年2月在blog说:

由于import和现行require的水货不容,经过权衡了大量的方案后,我们只能为新的es模块开启了全新的扩展名*.mjs,我们把这个新的文件扩展名热切的称之为麦克杰克逊脚本(Michael Jackson Script)。

文章过去了1年半了,至今node.js仍然没法完美的融合两套系统。推行一套无法和现行事实标准兼容的新标准,无疑是一个搬石头砸脚的行为。

然而前面两个问题虽然严重,却不是最严重的,最严重的问题是:

标准委员会的工作应当是致力于社区的融合,esModule却导致了严重的社区分裂。Babel做法更是进一步的挑起战火。

6. import真的完成了es赋予的使命吗?

其实说到这里,我想这到底是进车还是倒车,大家心里都有自己的看法了。但我还想最后谈谈,import真的完成当初的使命了吗?

明显是没完成。从各方面来说都是彻头彻尾的没完成。

从标准化进度来说:2015年6月的标准,到现在只有chrome和safari实现了。后端js更是深陷泥潭。

从出发点(优化加载速度)来说:脚本即使不执行,也需要一个一个从网上下下来才能知道到底哪个脚本依赖了哪个脚本。

但这些都不重要,重要的是:import并行加载完全就是个伪需求。

做过前端的都知道一个东西,叫:atlas。atlas是啥?其实就是一个大拼图:由于网络请求零碎图标会导致大量的网络请求,而导致页面加载速度下降。有经验的前端程序员会把大量的小图标打包在一起,放在一张大图上。然后用css的background属性“扣”出图片的一个部分。

使用atlas能够极大的加快网页的加载速度。其实webpack完全充当了类似的角色:把一堆零碎的js文件打包压缩在一起。

如果去掉webpack的,让浏览器直接import,完全就是不切实际的做法。一个网页通常只有上百个图标。但一个lodash都有631个文件。如果按照100ms的延迟、10个线程并发、启用tcp fast open、禁用https,在这么极端的情况下仍然需要6.4秒。这仅仅是一个lodash。未经压缩的jQuery有多少文件,未压缩的d3有多少文件?一个页面到时候10分钟加载够吗?

所以浏览器到底需要什么?需要的只是一个简单的异步require,一个能让前面提到的那个魔鬼的加载script方法滚蛋就好了。

7. 以后会怎么样?

虽然我很想吐嘈import,也一直在吐嘈import。但是esModule已经不可回避的成为了js的标准。就像是阿斗终将接替刘备的位置。

但是其实事情不会有那么悲观,就像是糟糕的C++标准化委员会催生了Qt。import之后必定会有“民间组织”为import擦屁股。

但今天我们能怎么办?

如果你是标准化的忠实信徒,那么尽可能避开import和require结合的雷区:

  1. 不要使用export default,这样能减少和require混用的兼容性问题

  2. 尽可能的只导出构造函数(或者说类),这样能绕开很多名字空间问题

如果你是想我一样的实用化主义者,那么:

  1. 安心的使用require,不要去碰import

  2. 用插件去hack webpack,让代码看起来更合理

  3. 无视webpack“大佬们”的“正确代码指南”

  4. 前端项目的寿命一般都很短,不需要考虑明天。真有那么一天,sed或者awk都是好东西。

  5. 最后:当一个吃瓜群众,等标准委员会的同志们最后达成一致,或等撑不下去了的玩家退场。

本文遵守 CC-BY-NC-4.0 许可协议。

Creative Commons License

欢迎转载,转载需注明出处,且禁止用于商业目的。

上篇如何制作一个"好用"的音量控制滑槽
下篇游戏本,也许真的不太适合用来办公