ES Module / Babel
Module
在ES6之前,社区制定了一些模块加载方案,最主要的有CommonJS和AMD两种。前者用于服务器,后者用于浏览器。
ES6在语言规格的层面上实现了模块功能,而且实现得相当简单,完全可以取代现有的CommonJS和AMD规范,成为浏览器和服务器通用的模块解决方案。
stage1
文件划分方式,约定每一个 js 文件就是一个独立的模块
1 |
|
缺点:
- 污染全局作用域
- 命名冲突
- 无法管理模块间的依赖关系
- 模块成员可以在外部直接修改
stage2
命名空间,减少命名冲突
每个文件中,通过一个命名对象来包裹所有的变量和方法
1 |
|
缺点:
- 污染全局作用域
- 无法管理模块间的依赖关系
- 模块成员可以在外部直接修改
stage3
IIFE(立即执行函数)
1 |
|
通过闭包的方式,模块内部的变量只能被模块内部修改,外部无法修改。
通过 IIFE 的传参,我们可以传递一些依赖包给模块使用。
模块化规范
CommonJS(NodeJS)
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过 module.exports 导出成员
- 通过 require 函数加载成员
以同步的方式加载模块,如果放在浏览器中,那么会出现大量的文件请求,有时候并不是立马就需要的文件也会被加载,从而造成性能问题,不适合在浏览器中使用。
AMD(Asyncronous module definition)
异步的模块定义
1 |
|
CMD规范(类似 CommonJS)
ES Modules(浏览器)
ES6 模块化
1 |
|
特性:
- ES6的模块自动采用严格模式,不管有没有在模块头部加上"use strict"。
- 每个 ES Module 都运行在私有作用域中
- ESM 是通过 CORS(跨域) 的方式去请求外部的 JS 资源的。(原 script 标签默认支持跨域)
- ESM 的 script 标签会延迟执行脚本(类似标签的 defer 属性),等待网页渲染完成后,再执行。
1
2
3
4
5
6
7
8
9// 会比有 type="module" 的script 先执行
<script>
alert('hello')
</script>
// 延迟执行
<script type="module"> // 等同于加了 defer 属性
console.log(1)
</script>
严格模式主要有以下限制:
- 变量必须声明后再使用。
- 函数的参数不能有同名属性,否则报错。
- 不能使用with语句。
- 不能对只读属性赋值,否则报错。
- 不能使用前缀0表示八进制数,否则报错。
- 不能删除不可删除的属性,否则报错。
- 不能删除变量(delete prop),会报错,只能删除属性(delete global[prop])。
- eval不会在其外层作用域引入变量。
- eval和arguments不能被重新赋值。
- arguments不会自动反映函数参数的变化。
- 不能使用arguments.callee。
- 不能使用arguments.caller。
- 禁止this指向全局对象。
- 不能使用fn.caller和fn.arguments获取函数调用的堆栈。
- 增加了保留字(比如protected、static和interface)。
export & import
模块功能主要由两个命令构成:export和import。
export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。
如果希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。
1 |
|
如果在一个模块中先输入后输出同一个模块,import语句可以与export语句写在一起。
1 |
|
另外,ES7有一个提案(https://github.com/leebyron/ecmascript-more-export-from),简化先输入后输出的写法,拿掉了输出时的大括号。
1 |
|
import语句会执行所加载的模块
1 |
|
模块整体加载
1 |
|
module 命令
module命令可以取代import语句,达到整体输入模块的作用。
1 |
|
动态加载模块
返回的是 Promise
1 |
|
ES6模块加载的实质
ES6模块加载的机制与CommonJS模块完全不同。
CommonJS模块输出的是一个值的拷贝,而ES6模块输出的是 值的引用
。
1 |
|
注意加上 type=”module”
1 |
|
ES Module in NodeJS
如何在 Nodejs 中使用 ES module!
有两个步骤:
.js
后缀都改为.mjs
- 执行脚本时,加上
node --experimental-modules main.mjs
1 |
|
注意事项:
- 在 node 环境中,我们可以使用 ES Module 导入 CommonJS 导出的成员
1 |
|
- 在 node 环境中,CommonJS 中
不能
导入 ES Module 导出的成员
1 |
|
CommonJS 与 ES Module 差异
1 |
|
1 |
|
高版本 NodeJS 已支持 ESM (Version > 12.10.0)
不需要再写 .mjs
结尾了,直接 .js
1 |
|
加上 type module 配置之后,如果我们还想用 CommonJS
规范,发现会报错了。因为我们已经使用了 ESM。
如果继续使用 CommonJS
规范,那么就需要将 .js
改为 .cjs
。
ESM in NodeJS (babel 兼容方案)
yarn add @babel/node @babel/preset-env –save-dev
1 |
|
要注意的是,babel/core
并不会转换我们的代码,转换代码的工作交给 babel 内置的插件去实现的。也就是说,我们需要一个插件,去转换 ES6 中的一个新特性。
而 @bable/preset-env
是插件的集合,所以不需要每个插件都安装一遍。
1 |
|
结合 babel 配置文件使用
1 |
|
既然 @babel/preset-env
是插件的集合,而我不想要那么多,只是想处理 ESM
怎么办呢?
我们卸载掉 @babel/preset-env
,安装 @babel/plugin-transform-modules-commonjs
1 |
|
babel
编译器
主要用于将ES6版本的JS代码转换为ES5等向后兼容的JS代码,从而使代码可以运行在低版本浏览器或其他环境中。
npm install –save-dev babel-core babel-preset-es2015 babel-preset-latest –registry=https://registry.npm.taobao.org
配置文件 .babelrc
(或者 babel.config.js || .babelrc.js)
1 |
|
安装 babel-cli
,命令行编译工具,依赖 babel/core
1 |
|
查看 babel 版本(npx 是新版 NodeJS 附带的命令)
1 |
|
到这里,我们就可以使用 babel 来编译js 文件了。创建一个 babel.js 文件,写入一些 es6 的语法。
1 |
|
打开命令行工具:
1 |
|
转化后的结果:
1 |
|
Polyfill
Babel默认只转换新的JS语法(syntax),而不转换新的API(Promise、Map)。如果只是做语法转换,Promise 是不会被降级的,在低版本的浏览器中可能无法使用。
引入 Polyfill,为当前环境提供一个“垫片”。所谓“垫片”,是指垫平不同浏览器之间差异的东西。polyfill提供了全局的ES6对象及通过修改原型链Array.prototype等来补充对实例的实现。
babel 版本
Babel版本主要是Babel 6和Babel 7这两个版本。
提到Babel版本的时候,通常指的是@babel/core这个Babel核心包的版本。
Babel 7的npm包都存放在babel域下,即在安装npm包的时候,我们安装的是名称以@babel/开头的npm包,如@babel/cli、@babel/core等。而在Babel 6中,我们安装的包名是babel-cli、babel-core等以babel-开头的npm包。其实它们本质上是一样的,都是Babel官方提供的cli命令行工具和core核心包。在平时开发和学习的过程中,碰到@babel/和babel-时应该认识到它俩是作用相同、内容接近的包,只是版本不一样而已。
babel 配置文件
指定编译的规则。
1 |
|
1 |
|
推荐使用后缀名是js的配置文件来进行配置,因为可以使用该文件做一些逻辑处理,适用性更强。
插件 & 预设
plugin代表插件,preset代表预设。通常每个插件或预设都是一个npm包。
Babel的插件实在太多了,假如只配置插件数组,那我们前端工程要把ES2015、ES2016、ES2017……下的所有插件都写到配置项里,这样的Babel配置文件会非常臃肿。
preset预设就是帮我们解决这个问题的。预设是一组Babel插件的集合,通俗的说法就是插件包,例如babel-preset-es2015就是所有处理ES2015的二十多个Babel插件的集合。这样我们就不用写一大堆插件配置项了,只需要用一个预设代替就可以。另外,预设也可以是插件和其他预设的集合。
Babel官方已经针对常用的环境做了如下这些preset包。
1)@babel/preset-env.
2)@babel/preset-react.
3)@babel/preset-typescript.
4)@babel/preset-stage-0.
5)@babel/preset-stage-1.
插件与预设的简称
如果插件的npm包名称的前缀为babel-plugin-,则可以省略其前缀。
如果npm包名称的前缀带有npm作用域@,如@org/babel-plugin-xxx,则短名称可以写成@org/xxx。
1 |
|
预设的短名称规则与插件的类似,预设npm包名称的前缀为babel-preset-或作用域@xxx/babel-preset-xxx的可以省略掉babel-preset-。
插件和预设执行顺序
plugins插件数组和presets预设数组是有顺序要求的。如果两个插件或预设都要处理同一个代码片段,那么会根据插件和预设的顺序来执行。规则如下:
1)插件比预设先执行。
2)插件执行顺序是插件数组元素从前向后依次执行。
3)预设执行顺序是预设数组元素从后向前依次执行。
预设的选择
babel-preset-latest,在Babel 6时期,是所有年代preset的集合,在Babel 6最后一个版本中,它是babel-preset-es2015、babel-preset-es2016、babel-preset-es2017的集合。
@babel/preset-env包含了babel-preset-latest的功能,并对其进行了增强
,现在@babel/preset-env完全可以替代babel-preset-latest。
Babel 6 中是babel-preset-env,Babel 7版本开始,@babel/preset-env。
1 |
|
总结起来,Babel官方提供的预设,我们实际会用到的其实就只有四个。
1)@babel/preset-env.
2)@babel/preset-flow.
3)@babel/preset-react.
4)@babel/preset-typescript.
插件的选择
Babel 7 官方有九十多个插件,不过其中大多数都已经整合在@babel/preset-env和@babel/preset-react等预设里了,我们在开发的时候直接使用预设就可以。
目前比较常用的插件只有@babel/plugin-transform-runtime。
browserslist
在 package.json 中经常遇到这个配置项:
1 |
|
上面配置的含义是,该项目工程的目标环境是市场份额大于1%的浏览器并且不考虑IE 8及以下的IE浏览器。
browserslist叫作目标环境配置表,除了写在package.json文件里,也可以单独写在工程目录下的.browserslistrc文件里。
我们写一个含有箭头函数的 js 文件,如果我们在browserslist里指定目标环境是Chrome 60浏览器,再来看一下转换结果:
1 |
|
我们发现转换后的代码仍然是箭头函数,因为Chrome 60浏览器已经实现了箭头函数语法,所以不会转换成ES5的函数定义语法。
@babel/runtime
我们写个 js 文件,里面包含 es6 的 class 语法:
1 |
|
然后执行 babel 命令,查看转换后的结果 npx babel babel.js -o compiled.js
1 |
|
可以看到,转换后的代码上部增加了好几个函数声明,这些函数是Babel转码时注入的,我们称之为辅助函数。@babel/preset-env在做语法转换的时候,注入了这些函数声明,以便语法转换后使用。
但这样做存在一个问题。在我们正常地进行前端工程开发的时候,少则有几十个JS文件,多则有上千个。如果每个文件里都使用了class类语法,那么会导致每个转换后的代码上部都会注入这些相同的函数声明。这会导致我们用构建工具打包出来的包体积非常大。
那么应该怎么办呢?一个思路就是,我们把这些函数声明都放在一个npm包里,需要使用的时候直接从这个包里引入我们的文件。这样即使有上千个文件,也会从相同的包里引入这些函数。使用Webpack这一类的构建工具进行打包时,我们只需要引入一次npm包里的函数,这样就做到了复用,减小了包的体积。
@babel/runtime
就是上面说的这个npm包,@babel/runtime把所有语法转换会用到的辅助函数都集中在了一起。
1 |
|
然后到node_modules目录下看一下这个包的结构,找到 _classCallCheck、_defineProperties与_createClass这三个辅助函数的位置,我们直接引入即可。
这时,我们就可以替换转换后自动创建的方法,而是使用 @babel/runtime/helpers 中已有的方法。
不过,这么多辅助函数要一个个记住并手动引入,是很难做到的。这时Babel插件 @babel/plugin-transform-runtime
就可以用来帮我们解决这个问题。
@babel/plugin-transform-runtime
辅助函数的自动引入。
@babel/plugin-transform-runtime有三大作用,其中之一就是自动移除语法转换后内联的辅助函数(inline Babel helpers),而是使用@babel/runtime/helpers里的辅助函数来替代,这样就减少了我们手动引入的麻烦。
1 |
|
1 |
|
还是上面的例子,我们再看看转换后的结果,会发现插件已经自动帮我们处理了。
1 |
|
实际进行前端开发时,我们除了安装@babel/runtime包,基本也会安装@babel/plugin-transform-runtime这个Babel插件。
Babel 原理与插件开发
Babel的转码过程主要由三个阶段组成:解析(parse)、转换(transform)和生成(generate)。这三个阶段分别由@babel/parser、@babel/core和@babel/generator来完成。
- 解析阶段
该阶段由Babel读取源码并生成抽象语法树(AST),该阶段由两部分组成:词法分析与语法分析。
词法分析会将字符串形式的代码转换成tokens流,语法分析会将tokens流转换成AST。
1 |
|
AST
1 |
|
- 转换阶段
完成了解析工作,生成了AST,AST是一个树状的JSON结构。接下来就可以通过Babel插件对该树状结构执行修改操作,修改完成后就得到了新的AST。
- 生成阶段
通过转换阶段的工作,我们得到了新的AST。在生成阶段,我们对AST的树状JSON结构进行还原操作,生成新的JS代码,通常这就是我们需要的ES5代码。
Babel 插件开发
babel-handbook
https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md
开发Babel转码插件的重点是在第二阶段(转换阶段),在这一阶段我们要从AST上找出需要转换的节点,改成我们需要的形式,最后在生成阶段把AST变回JS代码。
插件模板结构:
1 |
|
假设我们有这样一段代码:
1 |
|
现在我想将 dog 变成喵咪。
1 |
|
转换后:
1 |
|
我们编写Babel插件的主要工作就是修改visitor
对象,该对象是遍历AST各个节点的方法。
在上面的插件里,要把变量名dog修改为cat,于是我们修改了visitor.Identifier方法,那我们如何知道要修改的是Identifier方法呢?
Babel原理里讲过Babel转码的三个阶段:解析阶段、转换阶段和生成阶段,我们编写的Babel插件实际上是在执行第二个阶段(转换阶段)的工作,该工作需要前一个阶段解析工作先完成。在解析阶段,我们得到了转码前代码的AST树状结构信息,该AST上会有Identifier等节点信息,我们编写插件的时候参考该AST的信息即可。
接着看Identifier方法,可以看到它有两个参数path和state,visitor中的每个方法都接收这两个参数,path代表路径。最后我们判断path上节点信息name是不是dog,是的话把它修改为cat即可。
同理,我们也可以修改 const
、value
:
1 |
|
插件传参
给插件传递参数,注意第一个成员变成了数组,数组的第二项是参数
1 |
|
在插件中,我们可以通过 state.opts
获取到参数:
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!