summary
type
category
tags
slug
status
date
finished_date
icon
password

引言

React + TypeScript 项目里有以下一段简单的代码,React.FCReact.useEffect 同样都引用了 React,为什么前者没有 import React from 'react' 也不会报错,但后者就会出现 “'React' refers to a UMD global, but the current file is a module. Consider adding an import instead” 呢?
notion image
如果我不想引入 React,又不想让它报错的解决方案是什么?在 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它的规范如下:
  1. 通过 exports 向外暴露一个模块,它只能是一个 object 对象,相关 API 作为对象的属性
    1. 外部使用 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(下文会提到)
    notion image
    2013 年 5 月,NodeJS 包管理器 npm 的作者 Isaac Z. Schlueter 宣布 NodeJS 已经废弃了CommonJS,NodeJS 核心开发者应避免使用它。

    AMD:激进派产生

    此时 CommonJS 采用同步的动态方式加载模块,这意味着当需要加载一个模块时,程序会停止执行直到该模块加载完成。浏览器更倾向于异步加载模块,以保持页面的流畅性。同步加载会导致页面被阻塞,影响用户体验,所以迟迟不能推广到浏览器上。
    因此社区意识到,要想在浏览器环境中也能顺利使用 CommonJS,势必重新制订新的标准规范。但新的规范怎么制订,成为了激烈争论的焦点,分歧和冲突由此诞生.
    James Burke 在 2009 年 9 月开发出了 RequireJS,但始终得不到 CommonJS 社区主流认可,于是在同年年底宣布离开 CommonJS 社区,自立门户。
    2011 年 2 月,由 Kris Zyp 起草的 Async Module Definition (AMD) 标准规范正式发布。它的规范内容如下:
    1. 通过 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 进一步处理,它没有自己专有的规范,而是集结多个规范于一身,使代码在多个不同模块规范的项目中运行。
      它作出了如下内容的规定:
      1. 优先判断是否存在 exports 方法,存在则采用 CommonJS 方式加载模块
      1. 其次判断是否存在 define 方法,存在则采用 AMD 方式加载模块
      1. 最后判断 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 等工具兴起,前端开发者已经不再关心以上几种方式的兼容问题,而是习惯写哪种就写哪种,最后由工具统一转译成浏览器支持的方式。

      总结

      那么到最初的那个问题(以及补充一些 TypeScript Compiler / TSC 的相关知识)

      React.FC 为什么不会报错

      TypeScript 还提供了另一种封装代码的方式 —— namespace 关键字。
      • 虽然 TS 支持命名空间,但这并不是封装代码的首选方式
      • 同名的命名空间会被自动合并,难以静态分析。
      • 配合 declare 关键字声明,其会被编译器视为全局类型。
      node_modules/@types/react
      node_modules/@types/react

      React.useEffect 却报错了

      因为大部分的编译器执行步骤大概是:
      1. 把程序解析为 AST(抽象语法树)
      1. 把 AST 编译成字节码
      1. 运行时计算字节码
      而 TS 的特殊之处在于它不直接编译成字节码,而是编译成 JS 代码,然后再在浏览器等环境中运行得到的 JS 代码
      notion image
      TSC 把 TS 编译成 JS 时,不会考虑类型。即在这个过程中,上面图片第 1~2 步中使用程序的类型,第 3 步不使用。所以程序中的类型对程序生成的输出产物没有任何影响,它只被用于类型检查这一步
      • 那么 FC 作为一个类型,它通过 TS 的检查后,并不会被写入转化生成的 JS 代码。
      • useEffect 作为一个函数,它会被写进最终的产物。它调用了全局变量 React,却又没有显式 import,于是报错。
        • node_modules/react
          node_modules/react

      参考材料

       
      Rylan
      Rylan
      Just be a rock