1. 前景
在日益复杂和多元的Web业务背景下,前端工程化这个概念经常被提及。“说说你对Web工程化的理解?” 相信很多初学者在面试时会经常遇到,而大多数人脑子会直接浮现出 Webpack,认为工程化就是 Webpack 做的那些事情儿,当然也不能说不对,准确说 Webpack 只是工程化背景下产生的工具。
工程化的目的是高性能、是稳定性、是可用性、是可维护性、是高效协同,只要是以这几个角度为目标所做的操作,都可称为工程化的一部分。工程化其实是软件工程中的一种思想。当下的前端工程化可以分为四个方面:模块化、组件化、规范化、自动化
。
1.2. 什么是模块化
模块化其实是指解决一个复杂问题时,自顶向下逐层把系统划分成若干模块
的过程,每个模块完成一个特定的子功能(单一职责),所有的模块按某种方法组装起来,成为一个整体,从而完成整个系统所要求的功能。
1.3. 为什么需要模块化
早期在网页这个东西刚出现的时候,页面、样式都很简单,极少有交互以及设计元素,一个页面也不会依赖很多文件,逻辑代码非常少,就是静态页面那种,那个时候的前端叫网页设计。
随着 Web 技术的发展,各种交互以及新技术等使网页变得越来越丰富,逐渐我们前端工程师登上了舞台,同时也使得我们前端同学的代码量急速上涨、复杂度在逐步增高,越来越多的业务逻辑和交互都放在 Web 层实现,代码一多,各种命名冲突、代码冗余、文件间依赖变大等等一系列的问题就出来了,甚至导致后期难以维护。
在这些问题上,其他如 java、php 等后端语言中早已有了很多实践经验,那就是模块化,因为小的、组织良好的代码远比庞大的代码更易理解和维护,于是前端也开启了模块化历程。
2. 概述
声明:以下是《Webpack实战:入门、进阶与调优》的笔记。
历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。
其他语言都有这项功能,比如 Ruby 的require、Python 的import,甚至就连 CSS 都有@import,但是 JavaScript 任何这方面的支持都没有,这对开发大型的、复杂的项目形成了巨大障碍。
在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。
还有一种UMD模块,严格来说,UMD并不能说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境(当时ES6 Module还未被提出)。
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
3. CommonJS
3.1 导出
导出是一个模块向外暴露自身的唯一方式。在CommonJS中,通过module.exports
可以导出模块中的内容,如:
// 写法一
module.exports = {
name: 'calculater',
add: function (a, b) { return a + b; }
}
// 写法二
exports.name = 'calculater';
exports.add = function (a, b) {
return a + b;
};
CommonJS模块内部会有一个module
对象用于存放当前模块的信息,可以理解成在每个模块的最开始定义了以下对象:
var module = {...};
//模块自身逻辑
module.exports = {...};
module.exports
用来指定该模块要对外暴露哪些内容,module.exports
默认是空对象,所以CommonJS模块在没有导出的情况下,默认导出空对象。
最后要注意导出语句不代表模块的末尾,在module.exports
后面的代码依旧会照常执行。在实际使用中,为了提高可读性,不建议采取上面的写法,而是应该将module.exports
语句放在模块的末尾。
3.2 导入
在CommonJS中使用require
进行模块导入。如:
// calculator.js
module.exports = {
add: function (a, b) { return a + b; }
};
// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log(sum); // 5
当我们require
—个模块时会有两种情况:
require
的模块是第一次被加载。这时会首先执行该模块,然后导出内容。require
的模块曾被加载过。这时该模块的代码不会再次执行,而是直接导出上次执行后得到的结果。
4. ES6 Module
在ES6 Module中不管开头是否有"use strict",都会采用严格模式。
4.1 导出
在ES6 Module中使用export
命令来导出模块。export
有两种形式:
- 命名导出
- 默认导出
一个模块可以有多个命名导出。它有两种不同的写法:
//写法1
export const name = 'calculator';
export const add = function (a, b) { return a + b; };
//写法2
const name = 'calculator';
const add = function (a, b) { return a + b; };
export { name, add };
与命名导出不同,模块的默认导出只能有一个。如:
export default {
name: 'calculator',
add: function (a, b) { return a + b; }
}
我们可以将export default
理解为对外输出了一个名为default的变量,因此不需要像命名导出一样进行变量声明,直接导出值即可。
//导出字符串
export default 'This is calculator.js';
// 导出 class
export default class {...}
//导出匿名函数
export default function () {...}
4.2 导入
ES6 Module中使用import
语法导入模块。首先我们来看如何加载带有命名导出的模块,请看下面的例子:
// calculator.js
const name = 'calculator';
const add = function (a, b) { return a + b; };
export { name, add };
// index.js
import { name, add } from './calculator.js';
add(2, 3);
加载带有命名导出的模块时,import
后面要跟一对大括号来将导入的变量名包裹起来,并且这些变量名需要与导出的变量名完全一致。导入变量的效果相当于在当前作用域下声明了这些变量(name和add),并且不可对其进行更改,也就是所有导入的变量都是只读的。
接下来处理默认导出,请看下面这个例子:
// calculator.js
export default {
name: 'calculator',
add: function (a, b) { return a + b; }
};
// index.js
import myCalculator from './calculator.js';
calculator.add(2, 3);
对于默认导出来说,import
后面直接跟变量名,并且这个名字可以自由指定,它指代了calculator.js中默认导出的值。从原理上可以这样去理解:
import { default as myCalculator } from './calculator.js';
最后看一个两种导入方式混合起来的例子:
// index.js
import React, { Component } from 'react';
这里的React对应的是该模块的默认导出
,而Component则是其命名导出中的一个变量
。注意:这里的React必须写在大括号前面,而不能顺序颠倒,否则会提示语法错误。
5. CommonJS 与 ES6 Module 的区别
5.1 动态与静态
CommonJS与ES6 Module最本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态的”。在这里“动态”的含义是,模块依赖关系的建立发生在代码运行阶段;而“静态”则是模块依赖关系的建立发生在代码编译阶段。
- require的模块路径可以动态指定,支持传入一个表达式,我们甚至可以通过if语句判断是否加载某个模块。
- ES6 Module的导入、导出语句都是声明式的,它不支持导入的路径是一个表达式,并且导入、导出语句必须位于模块的顶层作用域(比如不能放在if语句中)。
5.2 值拷贝与动态映射
在导入一个模块时,对于CommonJS来说获取的是一份导出值的拷贝;而在ES6 Module中则是值的动态映射,并且这个映射是只读的。
CommonJS:
// calculator.js
var count = 0;
module.exports = {
count: count,
add: function (a, b) {
count += 1;
return a + b;
}
}
// index.js
var count = require('./calculator.js').count;
var add = require('./calculator.js').add;
console.log(count); // 0 (这里的count是对 calculator.js 中 count 值的拷贝)
add(2, 3);
console.log(count); // 0 (calculator.js中变量值的改变不会对这里的拷贝值造成影响)
count += 1;
console.log(count); // 1 (拷贝的值可以更改)
ES6 Module:
// calculator.js
let count = 0;
const add = function (a, b) {
count += 1;
return a + b;
};
export { count, add };
// index.js
import { count, add } from './calculator.js';
console.log(count); // 0 (对 calculator.js 中 count 值的映射)
add(2, 3);
console.log(count); // 1(实时反映 calculator.js 中 count 值的变化)
// count += 1; // 不可更改,会抛出 SyntaxError: "count" is read-only
5.3 循环依赖
5.3.1 CommonJS
// foo.js
const bar = require('./bar.js');
console.log('value of bar:', bar);
module.exports = 'This is foo.js';
// bar.js
const foo = require('./foo.js');
console.log('value of foo: ', foo);
module.exports = 'This is bar.js';
// index.js
require('./foo.j s');
理想状态下我们希望二者都能导入正确的值,并在控制台上输出。
value of foo: This is foo.js
value of bar: This is bar.js
而当我们运行上面的代码时,实际输出却是:
value of foo: {}
value of bar: This is bar.js
为什么foo的值会是一个空对象呢?让我们从头梳理一下代码的实际执行顺序。
- index.js导入了foo.js,此时开始执行foo.js中的代码。
- foo.js的第1句导入了bar.js,这时foo.js不会继续向下执行,而是进入了bar.js内部。
- 在bar.js中又对foo.js进行了require,这里产生了循环依赖。需要注意的是,执行权并不会再交回foo.js,而是直接取其导出值,也就是module.exports。但由于foo.js未执行完毕,导出值在这时为默认的空对象,因此当bar.js执行到打印语句时,我们看到控制台中的value of foo就是一个空对象。
- bar.js执行完毕,将执行权交回foo.js。
- foo.js从require语句继续向下执行,在控制台打印出value of bar(这个值是正确的),整个流程结束。
由上面可以看出,尽管循环依赖的模块均被执行了,但模块导入的值并不是我们想要的。因此在CmnmonJS中,若遇到循环依赖我们没有办法得到预想中的结果。
5.3.2 ES6 Module
// foo.js
import bar from './bar.js';
console.log('value of bar:', bar);
export default 'This is foo.js';
// bar.js
import foo from './foo.js';
console.log('value of foo:', foo);
export default 'This is bar.js';
// index.js
import foo from './foo.js';
执行结果如下:
value of foo: undefined
foo.js:3 value of bar: This is bar.js
很遗憾,在bar.js中同样无法得到foo.js正确的导出值,只不过和CommonJS默认导出一个空对象不同,这里获取到的是undefined。
5.3.3 ES6 Module 解决循环依赖
//index.js
import foo from './foo.js';
foo('index.js');
// foo.js
import bar from './bar.js';
function foo(invoker) {
console.log(invoker + 'invokes foo.js');
bar('foo.js');
}
export default foo;
//bar.js
import foo from './foo.js';
let invoked = false;
function bar(invoker) {
if (!invoked) {
invoked = true;
console.log(invoker + 'invokes bar.js');
foo('bar.js');
}
}
export default bar;
上面代码的执行结果如下:
index.js invokes foo.js
foo.js invokes bar.js
bar.js invokes foo.js
可以看到,foo.js和bar.js这一对循环依赖的模块均获取到了正确的导出值。下面让我们分析一下代码的执行过程。
- index.js作为入口导入了foo.js,此时开始执行foo.js中的代码。
- 从foo.js导入了bar.js,执行权交给bar.js。
- 在bar.js中一直执行到其结束,完成bar函数的定义。注意,此时由于foo.js还没执行完,foo的值现在仍然是undefined。
- 执行权回到foo.js继续执行直到其结束,完成foo函数的定义。由于ES6 Module动态映射的特性,此时在bar.js中foo的值已经从undefined成为了我们定义的函数,这是与CommonJS在解决循环依赖时的本质区别,CommonJS中导入的是值的拷贝,不会随着被夹在模块中原有值的变化而变化。
- 执行权回到index.js并调用foo函数,此时会依次执行foo → bar → foo,并在控制台打出正确的值。
由上面的例子可以看出,ES6 Module的特性使其可以更好地支持循环依赖,只是需要由开发者来保证当导入的值被使用时已经设置好正确的导出值。
6. 其他类型模块
6.1 非模块化文件
非模块化文件指的是并不遵循任何一种模块标准的文件。如果你维护的是一个几年前的项目,那么极有可能里面会有非模块化文件,最常见的就是在script标签中引入的jQuery及其各种插件。
如何使用Webpack打包这类文件呢?其实只要直接引入即可,如:
import './jquery.min.js';
这句代码会直接执行jquery.min.js,一般来说jQuery这类库都是将其接口绑定在全局,因此无论是从script标签引入,还是使用Webpack打包的方式引入,其最终效果是一样的。
6.2 npm 模块
与Java、C++、Python等语言相比,JavaScript是一个缺乏标准库的语言。当开发者需要解决URL处理、日期解析这类很常见的问题时,很多时候只能自己动手来封装工具接口。而npm提供了这样一种方式,可以让开发者在其平台上找到由他人所开发和发布的库,并安装到项目中,来快速地解决问题,这就是npm作为包管理器为开发者带来的便捷。
在使用时,加载一个npm模块的方式很简单,只需要引入包的名字即可。
// index.js
import _ from 'lodash';
当Webpack在打包时解析到这条语句,就会自动去node_modules
中寻找名为lodash
的模块了,而不需要我们写出从源文件index.js
到node_modules
中lodash
的路径。
现在我们知道,在导入一个npm模块时,只要写明它的名字就可以了。那么在实际打包的过程中具体加载的是npm模块中哪个js文件呢?
每一个npm模块都有一个入口。当我们加载一个模块时,实际上就是加载该模块的入口文件。这个入口被维护在模块内部package.json文件的main字段中。