1. 1. 模块化
    1. 1.1. 什么是模块化
    2. 1.2. JS模块化方案
  2. 2. Webpack打包机制
    1. 2.1. 使用webpack打包
      1. 2.1.1. Step1:划分模块,组成模块队列(queue)
      2. 2.1.2. Step2:每个模块import和export改写
      3. 2.1.3. Step3:实现__require__和export逻辑
      4. 2.1.4. Step4:把处理好的模块作为参数传进IIFE
      5. 2.1.5. 异步加载的模块
    2. 2.2. 编写迷你打包程序
      1. 2.2.1. createAsset
      2. 2.2.2. createGraph
      3. 2.2.3. bundle
模块化与Webpack原理

模块化

早期的 JavaScript 往往作为嵌入到 HTML 页面中的用于控制动画与简单的用户交互的脚本语言,我们习惯这样写:

1
2
3
4
5
<!--html-->
<script type="application/javascript">
// module1 code
// module2 code
</script>

所有的嵌入到网页内的 JavaScript 对象都会使用全局的 window 对象来存放未使用 var 定义的变量。这就会导致一个问题,那就是,最后调用的函数或变量取决于我们引入的先后顺序。

随着单页应用与富客户端的流行,不断增长的代码库也急需合理的代码分割与依赖管理的解决方案,这也就是我们在软件工程领域所熟悉的模块化(Modularity)

什么是模块化

简而言之,模块化就是将一个大的功能拆分为多个块,每一个块都是独立的,你不需要去担心污染全局变量,命名冲突什么的。

模块化的好处

  • 封装功能
  • 封闭作用域
  • 可能解决依赖问题
  • 工作效率更高,重构方便
  • 解决命名冲突

JS模块化方案

  • 全局function模式:将不同的功能封装成不同的全局函数
1
2
3
4
5
6
7
function m1 () {
// ...
}

function m2 () {
// ...
}

问题:污染全局命名空间,容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系。

  • namespace模式:简单对象封装,减少了全局变量,解决命名冲突
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let greeting = {
helloInLang: {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
},

sayHello: function (lang) {
return helloInLang[lang]
}
}

greeting.helloInLang.en = 'hello' //能直接修改模块内部的数据
greeting.sayHello('en') // hello

问题:数据不安全(外部可以直接修改模块内部的数据)。

  • IIFE模式:匿名函数自调用(闭包),数据是私有的, 外部只能通过暴露的方法操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// file lib/greeting.js
(function(window) {
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
}
//操作数据的函数
function sayHello() {
//用于暴露有函数
return helloInLang[lang]
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.greeting = { sayHello }
})(window)
1
2
3
4
5
6
7
8
// index.html文件
<script type="text/javascript" src="lib/greeting.js"></script>
<script type="text/javascript">
greeting.sayHello('en')
console.log(greeting.helloInLang) //undefined 不能访问模块内部数据
greeting.helloInLang = 'xxxx' //不是修改的模块内部的helloInLang
greeting.sayHello('en') //没有改变
</script>

问题:无法解决模块依赖问题。

  • IIFE模式增强:引入依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// file lib/greeting.js
(function(window, $) {
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
}
//操作数据的函数
function sayHello() {
//用于暴露有函数
$('body').css('background', 'red')
return helloInLang[lang]
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.greeting = { sayHello }
})(window, jQuery)
1
2
3
4
5
6
7
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="lib/greeting.js"></script>
<script type="text/javascript">
greeting.sayHello('en')
</script>
  • CommonJS:主要是应用在Nodejs服务端,属于动态同步加载
1
2
3
4
5
6
7
8
9
10
11
12
// file lib/greeting.js
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
}

module.exports = {
sayHello: function (lang) {
return helloInLang[lang]
}
}
1
2
3
4
5
// file hello.js
const greeting = require('./lib/greeting.js')

var phrase = greeting.sayHello('en')
document.write(phrase)
  • AMD && CMD:AMD是RequireJS提出的,主要是依赖前置。CMD是SeaJS提出的,主要是就近依赖(只要用到才会导入),两者用法接近。属于异步加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// file lib/greeting.js
define(function() {
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
}

return {
sayHello: function (lang) {
return helloInLang[lang]
}
}
})
1
2
3
4
5
// file hello.js
define(['./lib/greeting'], function(greeting) {
var phrase = greeting.sayHello('en')
document.write(phrase)
})
  • UMD:因为AMD中无法使用CommonJS,所以出来了一个UMD,可在UMD中同时使用AMD和CommonJS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(function (define) {
define(function () {
var helloInLang = 'hello'

return {
sayHello: function (lang) {
return helloInLang[lang]
}
}
})
}(
typeof module === 'object' && module.exports && typeof define !=='function'
? function (factory) { module.exports = factory() }
: define
))
  • ESM(ES Module):ES6新规范,JavaScript终于在语言标准的层面上,实现了模块功能,使得在编译时就能确定模块的依赖关系,以及其输入和输出的变量,属于静态加载(编译时加载)
1
2
3
4
5
6
7
8
9
10
// file lib/greeting.js
const helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
}

export const sayHello = (lang) => {
return helloInLang[lang]
}
1
2
3
4
// file hello.js
import { sayHello } from './lib/greeting'

sayHello('en')

问题:目前浏览器和Nodejs的支持程度都并不理想,需要使用额外的工具如Babel编译成ES5,才可以在浏览器和Nodejs中运行。

Webpack打包机制

由于模块化的原因,我们不得不处理不同模块的依赖关系。随着项目越来越庞大,这种关系会变得越来越难以维护。为了方便开发和维护,我们就会使用到打包工具webpack。

webpack可以根据模块的依赖关系进行静态分析,然后将这些模块按照指定的规则生成对应的静态资源。那么,它究竟是如何处理这些依赖关系的呢

使用webpack打包

新建一个简单的项目,目录下包含这样几个文件:

1
2
3
4
5
src
├── big.js
├── helloWorld.js
├── index.js
└── lazy.js
1
2
3
4
5
6
7
8
9
10
11
12
// index.js
import helloWorld from './helloWorld'

const node = document.createElement("div")
node.innerHTML = helloWorld + 'loading...'

import(/* webpackChunkName: "async" */ './lazy')
.then(({ default: lazy }) => {
node.innerHTML = helloWorld + lazy
})

document.body.appendChild(node)
1
2
3
4
5
6
// helloWorld.js
import big from './big'

const helloWorld = big('hello world!')

export default helloWorld
1
2
3
4
5
6
// lazy.js
import big from './big'

const lazy = big("lazy loaded!")

export default lazy
1
2
3
4
// big.js
export default (val) => {
return val && val.toUpperCase()
}

Step1:划分模块,组成模块队列(queue)

从以上代码中我们可以观察到每个模块之间的引用关系:

1
2
3
4
5
6
7
8
9
10
11
12
* src/index.js (ESM)
# ./helloWorld
# (async) ./lazy
- src/helloWorld.js
- (async) src/lazy.js
* src/helloWorld.js (ESM)
# ./big
- src/big.js
* src/big.js
* src/lazy.js (ESM)
# ./big
- src/big.js

使用webpack打包后的结果:

我们可以观察一下打包生成的main.js里的内容,这里的代码比我们的源代码多了许多乱七八糟的东西,但是我们可以将它简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/******/ (function(modules) { // webpackBootstrap
/*中间部分删减*/
/******/ })
/************************************************************************/
/******/ ({

/***/ "./src/big.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/*中间部分删减*/
/***/ }),

/***/ "./src/index.js":
/***/ (function(module, __webpack_exports__, __webpack_require__) {
/*中间部分删减*/
/***/ })

/******/ });

简化后我们可以发现,main.js里的内容本质上是一个立即执行函数,这个函数的参数便是我们模块代码(队列):

1
2
3
4
5
6
7
(function (modules) {
// 模块串联逻辑
})({
// 所有的模块对象
[moduleId]: function () { /* code... */ }
// 更多`模块-id键值`对
})

Step2:每个模块import和export改写

由于在webpack中配置了concatenateModules: true(作用域提升),index.jshelloWorld.js模块合并成了一个文件。查看模块-id键值中的function内容可以发现,webpack对模块内的import与export进行了改写:

1
2
3
4
5
6
// big.js
(function (__require__, exports) {
exports.default = (val) => {
return val && val.toUpperCase()
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
// index.js + helloWorld.js
(function (__require__, exports) {
const big = __require__('./src/big.js')
const helloWorld = big.default('hello world')
const node = document.createElement("div")
node.innerHTML = helloWorld + 'loading...'
// 先看普通的import
// import(/* webpackChunkName: "async" */ './lazy')
// .then(({ default: lazy }) => {
// node.innerHTML = helloWorld + lazy
// })
document.body.appendChild(node)
})
1
2
3
4
5
6
// lazy.js
(function (__require__, exports) {
const big = __require__('./src/big.js')
const lazy = big.default('lazy loaded!')
exports.default = lazy
})

Step3:实现__require__和export逻辑

我们知道import需要具备以下功能:

  • 执行目标模块代码
  • 导出目标模块的export内容给外部使用

因此__require__export需要具备以上功能,使代码能在单文件中相互引用并执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
!(function (modules) {
function __require__ (id) {
// 设置一个缓存,有的话直接返回
if (cache[id]) return cache[id].exports

var module = {
exports: {}
}
// 1.执行当前模块的内容,这个modules[id]就是我们之前对每个模块封装后的代码
modules[id](__require__, module.exports, module)
cache[id] = module
// 2.导出当前模块的export内容给外部使用
return module.exports
}
})({
// [moduleId]: function () {}
})

Step4:把处理好的模块作为参数传进IIFE

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
26
27
28
29
30
31
32
33
!(function (modules) {
function __require__ (id) {
// 设置一个缓存,有的话直接返回
if (cache[id]) return cache[id].exports

var module = {
exports: {}
}
// 1.执行当前模块的内容,这个modules[id]就是我们之前对每个模块封装后的代码
modules[id](__require__, module.exports, module)
cache[id] = module
// 2.导出当前模块的export内容给外部使用
return module.exports
}
})({
'./src/big.js': (function (__require__, exports) {
exports.default = (val) => {
return val && val.toUpperCase()
}
}),
'./src/index.js': (function (__require__, exports) {
const big = __require__('./src/big.js')
const helloWorld = big.default('hello world')
const node = document.createElement("div")
node.innerHTML = helloWorld + 'loading...'
// 先看普通的import
// import(/* webpackChunkName: "async" */ './lazy')
// .then(({ default: lazy }) => {
// node.innerHTML = helloWorld + lazy
// })
document.body.appendChild(node)
})
})

异步加载的模块

1
2
3
4
5
6
__require__.loadChunk('async') // 请求lazy.js的内容
.then(__require__.bind(null, './src/lazy.js')) // 请求完毕后将模块 require 进去
.then(function (_ref) { // 执行代码内容
var lazy = _ref.default
node.innerHTML = helloWorld + lazy
})
1
2
3
4
5
6
7
8
9
10
11
// 请求部分,loadChunk的实现
let chunkResolves = {}

__require__.loadChunk = function (chunkId) {
return new Promise (resolve => {
chunkResolves[chunkId] = resolve
let srcipt = document.createElement('script')
script.src = 'src/' + chunkId + '.js'
document.head.appendChild(script)
})
}
1
2
3
4
5
6
7
8
// 得到callback()数据包
window.webpackJsonpCallback ('async', {
'./src/lazy.js': (function (__require__, exports) {
const big = __require__('./src/big.js')
const lazy = big.default('lazy loaded!')
exports.default = lazy
})
})
1
2
3
4
5
6
7
8
// 每个模块下载(promise)完成对应的resolve
let chunkResolves = {}
window.webpackJsonpCallback = function (chunkId, newModules) {
for (const id in newModules) {
modules[id] = newModules[id]
chunkResolves[chunkId]()
}
}

编写迷你打包程序

了解了webpack的打包步骤,我们可以模拟以上过程编写出完整的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 打包程序所需要的主要模块功能

// 解析模块
function createAsset (filename) {}
// 生成依赖图
function createGraph (entry) {}
// 打包
function bundle (graph) {}

const graph = createGraph('./src/index.js')
const result = bundle(graph)

console.log(result)

所需依赖工具:

  • @babel/parser:js解析器,将文本代码转化成AST(语法树)
  • @babel/traverse:遍历AST寻找依赖关系
  • @babel/core的transformFromAst:将AST代码转化成浏览器所能识别的代码(ES5)

createAsset

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
26
27
28
29
function createAsset (filename) {
// 读取文件
const content = fs.readFileSync(filename, 'utf-8')
// 我们通过 babylon 这个 javascript 解析器来理解 import 进来的字符串
const ast = parser.parse(content, {
sourceType: 'module'
})
// 该模块所依赖的模块的相对路径
const dependencies = []
// import声明
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value)
}
})
// 递增设置模块ID
const id = ID++
// AST -> ES5
const { code } = transformFromAst(ast, null, {
presets: ['@babel/env'],
})

return {
id,
filename,
dependencies,
code
}
}

createGraph

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function createGraph (entry) {
// 从第一个文件开始,首先解析index文件
const mainAsset = createAsset(entry)
// 定义一个依赖队列,一开始的时候只有入口文件
const queue = [mainAsset]
// 遍历 queue,广度优
for (const asset of queue) {
asset.mapping = {}

const dirname = path.dirname(asset.filename)

// 遍历依赖数组,解析每一个依赖模块
asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath) + '.js'
// 解析
const child = createAsset(absolutePath)
// 子模块`路径-id`map
asset.mapping[relativePath] = child.id
queue.push(child)
})
}
return queue
}

bundle

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
26
27
28
29
30
31
32
function bundle (graph) {
let modules = ''
graph.forEach(mod => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)}
],`
})
const result = `
(function (modules) {
function require(id) {
const [fn, mapping] = modules[id]
function localRequire (name) {
return require(mapping[name])
}

const module = { exports: {} }

fn(localRequire, module, module.exports)

return module.exports
}

require(0)
})({
${modules}
})
`
return result
}

项目地址:mini-pack

参考文章: