0%

前端模块化开发概述

随着前端功能的日趋复杂,模块化开发变得尤为重要,在大型项目中,模块化的开发方式不仅能有效复用代码,还能针对性的修改模块代码而不影响项目的稳定运行,还能借助同样功能的第三方模块,提高开发效率。

模块化开发作为一种主流开发思想,开发项目就像搭积木一样,每一个模块就是一个个独立的积木而已。但是在代码中会相对复杂一些,模块之间的相互引用导致了模块并非完全独立于其他模块的。

本文主要就前端模块化开发的演变过程,重点说明js的模块化规范:浏览器端使用的ES Modules和Node环境使用的CommonJS规范。以及阐述如何通过基于模块化开发构建web应用。

前端模块化开发的演变过程

早期的模块化开发没有一个合理的规范,最开始的模块化就是把整个js文件拆分成若干个文件,可以理解为“文件划分”阶段。

比如某个项目下的a.jsb.js等等,在使用的时候简单的按照执行顺序引入:

1
2
<script src="a.js"></script>
<script src="b.js"></script>

这样的方式除了按照功能划分了代码,并不是真正意义上的模块,代码之间仍然存在命名冲突,模块变量可以随意更改等问题。

总的来说,只是简单的做了文件划分的存在明显的缺陷:

  • 模块之间相互污染全局作用域
  • 命名冲突
  • 模块之间的引用关系无法管理

除了方便根据文件名找到对应功能的代码,貌似没有别的好处。

为了尽量解决作用域污染和重名问题,我们使用命名空间来对变量进行空间限定,比如模块a.js

1
2
3
4
5
6
7
8
9
10
var moduleA = {
prop: {},
name: '',
method1: function(){
console.log('method1 runing...')
},
method2: function(){
console.log('method2 runing...')
}
}

这样,我们在使用模块a的使用就不用担心内部成员被污染的情况,但是,模块内部的成员任然会被外部直接访问和修改到,没有做到私有化。

下一阶段可以称为立即执行阶段,即使用IIFE对模块代码进行闭包处理,这样,我们只需要导出我们希望导出的内容,而外部就不能再访问未暴露的变量,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function($){
var name = 'modeule a';
var props = {};
function method1(){
console.log('method1 runing...')
}
function method2(){
console.log('method2 runing...')
}
// 通过绑定到全局作用局来导出模块
window.moduleA = {
props,
method1,
method2
}
})(jQuery)

立即执行函数能解决变量私有化的问题,大部分情况下已经解决了模块化开发的需求,但是这种写法也是多种多样的,对于别人的代码和模块,引用和修改起来还是十分不便,这样就需要一种规范化出现,让开发者们都遵循和使用这些规范进行开发,也就能解决上述问题。

模块化规范最主要的就是解决:模块引用和模块导出的问题。

其中,commonJS规范是出现较早的规范,目前也是被Node所使用的模块化规范。

CommonJS规范有以下约定:

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过module.exports导出成员
  • 通过require函数载入其他模块导出的成员

CommonJS规范的模块加载是同步执行的,也就是模块加载完成后再执行代码,对于Node来说,主要应用于服务器开发,代码存储在本地,同步加载也比较快,所以执行起来没什么效率问题,但在浏览器端就不行了,如果执行一段代码需要不断地从服务器加载同步模块,会导致浏览器执行效率低下,因此,浏览器采用AMD这种异步加载模块的规范来实现模块化开发。AMD是”Asynchronous Module Definition”的缩写,意思就是”异步模块定义”。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD模块也是用require函数导入模块,但需要使用回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义一个模块
define('module1', ['jquery', './modules2'], function($, modules2){
return {
start: function(){
$('#start').animate({ margin: '280px' });
modules2();
}
}
})

// 加载模块,并在回调函数中使用模块
require([module1 jquery], function(module1, $){
module1.start();
});

目前,主要有两个Javascript库实现了AMD规范:require.jscurl.jsseajs。但使用起来还是很复杂,且模块较多时,模块js文件加载请求变得频繁,所以,AMD规范也只是浏览器端模块规范化的一步。

目前最佳的模块化规范就是:浏览器端的ES Modules和Node端的CommonJS规范,这两个规范都是内置的规范,不存在执行环境问题,需要注意的是ES Moduleses6才推出的模块化规范,所以早期的大部分浏览器都不支持ES Modules规范,这就需要我们注意在使用ES Moduels规范开发时的兼容问题。

浏览器端的模块化规范:ES Modules

上文提到的CAD规范只是为了能实现浏览器端的模块化开发而做的“妥协”方法,执行效率上仍然是个问题。最终,在ES6,我们等到了原生模块化的支持 : ES Modules — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。

导出模块成员

使用export关键字在最外层对变量进行导出即可。

1
2
3
4
5
6
export const name = 'jinx'
export function add(...nums) {
let total = 0;
nums.forEach(n => total += n);
return total;
}

export只能写在最外层,而不能写到执行语句中。

还有一种简单的方法是在最后统一导出成员:

1
export {name, add}

为避免重名问题,我们还可以把

导入模块和成员

你想在模块外面使用一些功能,那你就需要导入他们才能使用。最简单的就像下面这样的:

1
import { name, draw, reportArea, reportPerimeter } from '/js-examples/modules/basic-modules/modules/square.js';

使用 import 语句,然后你被花括号包围的用逗号分隔的你想导入的功能列表,然后是关键字from,然后是模块文件的路径。模块文件的路径是相对于站点根目录的相对路径,对于我们的basic-modules 应该是 /js-examples/modules/basic-modules。当然,我们写的路径有一点不同—我们使用点语法意味 “当前路径”,跟随着包含我们想要找的文件的路径。这比每次都要写下整个相对路径要好得多,因为它更短,使得URL 可移植。

值得注意的是,模块导出的值并不是复制了一份,而是导出了引用而已,虽然外部引用无法修改内部模块的成员变量,但模块内部导出成员发生变化后,导出的成员也会随之改变。

以下是一些注意事项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 不能省略扩展名
// import { name } from './module'
import { name } from './modules.js'

// 不能自动识别目录下的index.js
// import { name } from './modules'
import { name } from './modules/index.js'

// 使用相对路径时不能省略./,否则会被识别为第三方模块
// import { name } from 'modules.js'
import { name } from './modules.js'

// 加载模块但不提取数据
import './modules.js'

// 导出成员比较多时,可以将模块成员全部导出
import * as mod from './modules.js'

// 动态导入模块:不能在导入语句中编写变量和嵌套在语句中
import('./modeles.js').then(mod => {
console.log(mod.name)
})

// 导入一般成员和默认成员
import { name, age, default as del } from '.modules.js'

浏览器环境使用

在已经支持了es modules规范的浏览器,我们可以直接使用modules类型的<script>进行模块文件导入:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title></title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
</head>
<body>
<script type="module" src="./modules.js"></script>
</body>
</html>

在支持的浏览器上打开这个html文件是可以正常执行模块中的代码的,但不支持es modules的浏览器会忽略这种模块文件。

为了在不支持的浏览器中也能使用模块语法,我们可以借助第三方编译工具,比如polyfill,模块代码会被转换为低版本的js代码,并返回给浏览器执行。

比如我们使用browser-es-module-loader这个模块来兼容低版本浏览器,安装:

1
npm install browser-es-module-loader

在浏览器端,我们可以通过unpkg.com拼接模块名就可以获取到最新的js文件:比如这个模块就拼接为https://unpkg.com/browser-es-module-loader。我们引入html文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title></title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
</head>
<body>
<script src="https://unpkg.com/promise-polyfill@8.2.0/dist/polyfill.min.js"></script>
<script src="https://unpkg.com/browse/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<script src="https://unpkg.com/browse/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
<script type="module">
import { name } from './modules.js'
console.log(name)
</script>
</body>
</html>

如果存在资源请求限制,最好是把js文件下载到本地再引用。

注意,如果你的浏览器还不支持Promise还需要再安装一个编译Promise的模块,因为上面编译模块代码的第三方模块内部使用的是Promise语法进行回调。这里我们使用了promise-polyfii模块。

另外一个问题,我们发现在支持es modules规范的浏览器,调用了两次模块代码,这是因为<script type="module">执行了一次,而es-module-loader又执行了一次,我们知道,这个模块是为了在不支持模块规范的浏览器中使用的,支持的就不需要使用了,所以我们使用一个nomodule的属性来标识:

1
2
3
<script nomodule src="https://unpkg.com/promise-polyfill@8.2.0/dist/polyfill.min.js"></script>
<script nomodule src="https://unpkg.com/browse/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<script nomodule src="https://unpkg.com/browse/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>

但这样的写法在生成环境是极不推荐的,生产环境的代码应该是编译后的代码,而且我们可以借助babel这样的工具自动完成,不需要那么麻烦。

在Node环境中使用es modules

保证node版本大于8.5。

在node中执行es modules的语法,需要做两个准备:

  • .js扩展名为.mjs
  • 使用node启动模块时带上--experimental-modules命令,目前来说,这仍是实验中的特性,所以不要在生成环境中使用。

更改文件扩展名之后,我们就可以在node中使用es modules语法了:

1
2
3
import fs from 'fs'

fs.writeFileSync('./foo.txt', 'es module writing')

执行:

1
node --experimental-modules index.mjs

值得注意的是:

1
2
3
4
5
6
// 可以使用{}导出内置模块的成员
import { writeFileSync } from 'fs'
// 但第三方模块不行,因为第三方模块都是默认导出,这个{}并不是解构的语法,
// 而内置模块能导出仅仅是做了兼容,将模块内部每个成员都进行了导出
// import { camelCase } from lodash
writeFileSync('./foo.txt', 'es module writing2')

此外,Node的内置成员也不能再访问:

1
2
3
4
5
6
7
8
9
10
// 模块加载函数
console.log(require)
// 模块对象
console.log(module)
// 导出对象别名
console.log(exports)
// 当前文件绝对路径
console.log(__filename)
// 当前文件夹所在目录
console.log(__dirname)

但这些信息都可以从import函数的成员中转换得到:

1
2
3
4
5
6
7
8
import { fileURLToPath } from 'url'
import { dirname } from 'path'

const url = import.meta.url;
const __filename = fileURLToPath(url)
console.log(__filename)
const __dirname = dirname(__filename)
console.log(__dirname)

最新版本的支持:

切换node版本到12.10以上,

1
nvm use 12.10.0

每次都加上--experimental-modules比较麻烦,我们在package.js中增加一个type字段:

1
2
3
{
type: "module"
}

这样,node默认执行环境就是es modules语法,而且文件扩展名不再需要使用.mjs,但是,如果你需要使用CommonJS语法,需要将扩展名修改为.cjs

使用babel兼容js版本问题

为了兼容浏览器使用es modules规范,我们一般使用babel进行js版本编译,这样,我们就可以放心使用最新特性的js代码,也就包括了这些模块化开发机制。

安装:

1
npm install @babel/node @balel/core @babel/preset-env --dev

使用babel-node命令运行js即可:

1
yarn babel-node ./index.js --presets=@babel/preset-env

为了能正常将模板代码编译为正常的js版本,需要使用--presets=@babel/preset-env指令,这是因为babel是插件化开发的,如果我们未设置插件,某些功能,比如模块代码编译,就无法正常工作。而@babel/preset-env相当于一个插件集模块,其中就包含了模块解析的插件。

为了更方便的调用,我们可以使用babel的配置文件.babelrc来设置presets项:

1
2
3
{
"presets": ["@babel/preset-env"]
}

当然,这里引用@babel/preset-env插件集只是为了编译模块代码,我们还可以手动指定插件,比如编译模块代码的插件:

1
npm install @babel/plugin-transform-modules-commonjs --dev

然后配置文件使用plugins项导入:

1
2
3
4
5
{
"plugins": [
"@babel/plugin-transform-modules-commonjs"
]
}

参考

谢谢你请我吃糖!