status
type
tags
category
slug
summary
date
finished_date
icon
password
前言
React + TypeScript 项目里有以下一段简单的代码,
React.FC
和 React.useEffect
同样都引用了 React,为什么前者没有 import React from 'react'
也不会报错,但后者就会出现 “'React' refers to a UMD global, but the current file is a module. Consider adding an import instead” 呢?tsconfig.json
文件中配置一行 "allowUmdGlobalAccess": true
即可。- 但这个配置是不被推荐的,指明依赖可以提高代码可读性
上面出现的 UMD 是什么呢?那就需要聊聊 JavaScript 的模块化发展历史了~
JS 模块简史
<script> 标签:万物起源
JS 诞生之初的代码量很少,最开始都是直接写到 HTML 的
<script>
标签里,比如:随着业务进一步复杂,前端能做的事情越来越多,代码量飞速增长,开发者们开始把 JS 写到独立的文件中,与 HTML 文件解耦,比如:
问题来了 —— JS 最初不支持任何模块系统,也没有封闭作用域的概念的,所以上面两个 JS 文件里申明的变量都会存在于全局作用域中。不同的开发者维护不同的 JS 文件,很难保证不和其它文件冲突。全局变量污染成为开发者的噩梦。
- 难道总要排查其它 script 里是否存在一样的变量?
为了解决全局变量污染的问题,开发者开始使用命名空间的方法,比如:
现在隐隐约约有模块化的概念了,这样在一定程度上是解决了命名冲突的问题。
新的问题来了 ——
b.js
模块的开发者可以很方便的通过 app.moduleA.name
来取到模块 中的名字,但是也可以通过 app.moduleA.name = 'xxxxx'
来任意改掉模块 中的名字,而这件事情,模块 却毫不知情,这显然是不被允许的。- 如何防止他人修改了自己的变量?
于是人们利用「 闭包 」的特性,通过立即调用表达式 (IIFE, Immediately Invoked Function Expression),来解决上面这一问题。
CommonJS:服务端革命
2009 年 1 月,Mozilla 旗下的工程师们制订了一套 JS 模块化的标准规范,并取名为 ServerJS。这是最早用于服务端的 JS,旨在为配合自动化测试等工作而提供模块导入功能。并在同年 8 月,这个项目被改名为 CommonJS。它的规范如下:
- 通过
exports
向外暴露一个模块,它只能是一个object
对象,相关 API 作为对象的属性
- 外部使用
require(
dependency
)
函数来引入其他依赖模块 - 如果被
require
引入的模块中也包含外部依赖,则依次加载这些依赖 - 如果引入模块失败,那么
require
抛出一个异常
2009 年 11 月,欧洲 JSConf 开发者大会上,美国工程师 Ryan Dahl 基于 Google 的 Chromium V8 引擎实现的 NodeJS 惊艳亮相,解决了传统的 Web 服务器架构在处理 I/O 密集型任务时的性能瓶颈。
JS 的编译和运行通常都在引擎中
- NodeJS → Chrome V8
- Edge → Chakra
- Safari → JSCore
- Firefox → SpiderMonkey
CommonJS 第一个大规模应用便是在 NodeJS 里,这就给很多人造成了误解,以为 CommonJS 是 NodeJS 提出来的。实际上 CommonJS 是一个规范,NodeJS 模块化只是它的一个实现。 NodeJS 能以一种比较成熟的姿态出现,离不开 CommonJS 规范的影响,它们二者可以说是互相成就。
NodeJS 后来花了数年时间从 CommonJS 迁移到 ESM(下文会提到)。
2013 年 5 月,NodeJS 包管理器 npm 的作者 Isaac Z. Schlueter 宣布 NodeJS 已经废弃了CommonJS,NodeJS 核心开发者应避免使用它。
- Schlueter, Isaac Z: Forget CommonJS. It's dead. We are server side JavaScript.
AMD:激进派产生
此时 CommonJS 采用同步的动态方式加载模块,这意味着当需要加载一个模块时,程序会停止执行直到该模块加载完成。浏览器更倾向于异步加载模块,以保持页面的流畅性。同步加载会导致页面被阻塞,影响用户体验,所以迟迟不能推广到浏览器上。
因此社区意识到,要想在浏览器环境中也能顺利使用 CommonJS,势必重新制订新的标准规范。但新的规范怎么制订,成为了激烈争论的焦点,分歧和冲突由此诞生.
James Burke 在 2009 年 9 月开发出了 RequireJS,但始终得不到 CommonJS 社区主流认可,于是在同年年底宣布离开 CommonJS 社区,自立门户。
2011 年 2 月,由 Kris Zyp 起草的 Async Module Definition (AMD) 标准规范正式发布。它的规范内容如下:
- 通过
define(
moduleName?, [dependencies]?, moduleDefinition
)
定义一个模块 - 对于依赖的模块,AMD推崇依赖前置,提前执行
- 也就是说,在
define
里的依赖模块,会一开始就被下载并执行
- AMD 也采用 require加载模块,但它有两个参数
require(
[module], callback
)
CMD:中间派出现
由于 AMD 的提前加载的问题,被很多开发者担心会有性能问题而吐槽。如果一个模块依赖了十个其他模块,那么在本模块的代码执行之前,要先把其他十个模块的代码都执行一遍,不管这些模块是不是马上会被用到。这个性能消耗是不容忽视的。
2011 年 4 月,阿里巴巴集团的前端工程师玉伯,在给 RequireJS 不断提出建议却被拒绝之后,写了一个新的模块加载器 SeaJS,并在推广过程中产出模块定义的规范化 CMD (Common Module Definition)
CMD 规范的主要内容与 AMD 大致相同,不过保留了 CommonJS 中最重要的延迟加载、就近依赖声明特性,具体的文档参考:
UMD:兼容并济
Universal Module Definition,通过对 CommonJs、CMD、AMD 进一步处理,它没有自己专有的规范,而是集结多个规范于一身,使代码在多个不同模块规范的项目中运行。
它作出了如下内容的规定:
- 优先判断是否存在
exports
方法,存在则采用 CommonJS 方式加载模块
- 其次判断是否存在
define
方法,存在则采用 AMD 方式加载模块
- 最后判断
global
对象上是否定义了所需依赖,存在则使用,反之抛出异常
ES Module:官方钦定
⼤家一定都听说过 ES6、ES7、ES2015、ES2016…等等,那么 ES 到底是什么?和 JS 有什么关系?
Ecma International 是一个致力于信息标准化的国际性行业组织。ES 是 ECMAScript 的缩写。JS 是基于 ECMAScript 标准做的实现。
严格来说,ES6 是指 2015 年 6 月发布的 ES2015 标准, 但是很多⼈在谈及 ES6 的时候,都会把 ES2016、ES2017 等标准的内容也带进去。所以在谈论 ECMAScript 标准的时候,用年份更好⼀些,但是纠结这个没多大意义。而 ESNext 是⼀个泛指,它永远指向下⼀个版本,即最新版本。
在 ES6 标准中,首次引入
import
和 export
两个 JS 关键字,并提供了被称为 ES Module 的模块化方案。- ESM 模块导入和导出是静态的,即 JS 引擎可以在代码执行前分析模块之间的依赖关系,并做出相应的优化。
在 JS 出生的第 21 个年头里,它终于迎来了属于自己的模块化方案。但由于历史上的先行者已经占据了优势地位,所以 ES Module 迟迟没有完全替换上文提到的几种方案,甚至连浏览器本身都没有立即作出支持。
- 2017 年 9 月上旬,Chrome 61.0 版本发布,首次在浏览器端原生支持了 ES Module。
- 2017 年 9 月中旬,NodeJS 迅速跟随,发布了 8.5.0,以支持原生模块化,这一特性被称之为 ECMAScript Modules (MJS)。
不过随着 Babel、Webpack 等工具兴起,前端开发者已经不再关心以上几种方式的兼容问题,而是习惯写哪种就写哪种,最后由工具统一转译成浏览器支持的方式。
工作原理
当使用 ESM 模式时,一般需要指定一个入口文件,然后从这个文件开始,浏览器或 Node.js 就能通过其中的
import
语句,查找其他代码,并构建一个依赖关系图。总结
那么到最初的那个问题(以及补充一些 TypeScript Compiler / TSC 的相关知识)
React.FC 为什么不会报错
TypeScript 还提供了另一种封装对象的方式 ——
namespace
关键字。- 虽然 TS 支持命名空间,但这并不是封装代码的首选方式。
- 同名的命名空间会被自动合并,难以静态分析。
- 配合
declare
关键字声明,其会被编译器视为全局类型。
React.useEffect 却报错了
因为大部分的编译器执行步骤大概是:
- 把程序解析为 AST(抽象语法树)
- 把 AST 编译成字节码
- 运行时计算字节码
而 TS 的特殊之处在于它不直接编译成字节码,而是编译成 JS 代码,然后再在浏览器等环境中运行得到的 JS 代码。
TSC 把 TS 编译成 JS 时,不会考虑类型。即在这个过程中,上面图片第 1~2 步中使用程序的类型,第 3 步不使用。所以程序中的类型对程序生成的输出产物没有任何影响,它只被用于类型检查这一步。
- 那么
FC
作为一个类型,它通过 TS 的检查后,并不会被写入转化生成的 JS 代码。
- 而
useEffect
作为一个函数,它会被写进最终的产物。它调用了全局变量 React,却又没有显式 import,于是报错。