Webpack 核心原理(打包)
打包:bundle 打包器:bundler
首先我们有下面的代码例子 (下面的代码都是在project_1文件夹内)
// a.js
import b from './b.js'
const a = {
value: 'a',
getB: () => b.value + ' from a.js'
}
export default a
// b.js
import a from './a.js'
const b = {
value: 'b',
getA: () => a.value + ' from b.js'
}
export default b
// index.js
import a from './a.js'
import b from './b.js'
console.log(a.getB())
console.log(b.getA())
在index.html
中引入index.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="module" src="index.js"></script>
</body>
</html>
然后使用http-server project_1
去启动它们,会看到
虽然能成功输出,但是伴随着请求过多的问题
如何解决请求过多的问题呢?
如果把所有文件都打包成一个文件,是不是就能解决了呢
打包
在介绍打包之前,需要先了解@babel/core
是怎么把import/export
转换成函数的
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'
// 设置根目录
const projectRoot = resolve(__dirname, 'project_1')
// 类型声明
type DepRelation = { [key: string]: { deps: string[], code: string } }
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = {}
// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
console.log(depRelation)
console.log('done')
function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
if (Object.keys(depRelation).includes(key)) {
// 注意,重复依赖不一定是循环依赖
return
}
// 获取文件内容,将内容放至 depRelation
const code = readFileSync(filepath).toString()
const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})
// 初始化 depRelation[key]
depRelation[key] = { deps: [], code: es5Code }
// 将代码转为 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依赖,将内容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然后转为项目路径
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依赖写进 depRelation
depRelation[key].deps.push(depProjectPath)
collectCodeAndDeps(depAbsolutePath)
}
}
})
}
// 获取文件相对于根目录的相对路径
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
}
然后使用 node -r ts-node/register 文件路径 来运行, 如果需要调试,可以加一个选项 --inspect-brk,再打开 Chrome 开发者工具,点击 Node 图标即可调试
然后可以看到
{
'index.js': {
deps: [ 'a.js', 'b.js' ],
code: '"use strict";\n' +
'\n' +
'var _a = _interopRequireDefault(require("./a.js"));\n' +
'\n' +
'var _b = _interopRequireDefault(require("./b.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'\n' +
'console.log(_a["default"].getB());\n' +
'console.log(_b["default"].getA());'
},
'a.js': {
deps: [ 'b.js' ],
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports["default"] = void 0;\n' +
'\n' +
'var _b = _interopRequireDefault(require("./b.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'\n' +
'var a = {\n' +
" value: 'a',\n" +
' getB: function getB() {\n' +
` return _b["default"].value + ' from a.js';\n` +
' }\n' +
'};\n' +
'var _default = a;\n' +
'exports["default"] = _default;'
},
'b.js': {
deps: [ 'a.js' ],
code: '"use strict";\n' +
'\n' +
'Object.defineProperty(exports, "__esModule", {\n' +
' value: true\n' +
'});\n' +
'exports["default"] = void 0;\n' +
'\n' +
'var _a = _interopRequireDefault(require("./a.js"));\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'\n' +
'var b = {\n' +
" value: 'b',\n" +
' getA: function getA() {\n' +
` return _a["default"].value + ' from b.js';\n` +
' }\n' +
'};\n' +
'var _default = b;\n' +
'exports["default"] = _default;'
}
}
done
我再简化一下就会得到
"use strict";
Object.defineProperty(exports, "__esModule", {value: true});
exports["default"] = void 0;
var _b = _interopRequireDefault(require("./b.js"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { "default": obj };
}
var a = {
value: 'a',
getB: function getB() {
return _b["default"].value + ' from a.js';
}
};
var _default = a;
exports["default"] = _default;
逐行解释
Object.defineProperty(exports, "__esModule", {value: true});
给当前模块添加_esModule:true属性,主要是和CommonJS模块区分
exports["default"] = void 0;
1. void 0 等价于undefined
2. 这句代码主要是为了情况exports['default']的值
3. 暂时没有找到为什么需要清空
var _b = _interopRequireDefault(require("./b.js"));
这句其实就是import b from './b.js'
下面解释一下 _interopRequireDefault
1. _下划线前缀主要是为了避免与其它变量重名
2. 该函数的主要意图是给模块添加了'default'
3. 为什么需要加default,CommonJS模块并没有默认导出,加上方便兼容
4. 内部实现: return obj && obj.__esModule ? obj : { "default": obj };
5. _interop开头的函数大多数都是为了兼容旧代码
export default a 变成了
var _default = a;
exports["default"] = _default;
稍稍简化就变成 exports["default"] = a
如果你还有
const x = 'X'; export {x} 则会变成
var x = 'X'; export.x = x
暂时没有看出中间变量_default的作用
总结
import关键字会变成require函数,export关键字会变成exports对象
本质就是ESModule
语法变成了CommonJS
规则
手动实现一个打包器
接下来我们手动实现一个打包器(bundler)
首先你的目录结构是
index.js作为打包入口文件,把所有需要打包的js文件引入到index.js文件即可,dist.js是打包后的文件
// bundeler.js
import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { writeFileSync, readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'
// 设置根目录
const projectRoot = resolve(__dirname, 'project')
// 类型声明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = [] // 数组!
// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
// 得到打包的文件 dist.js
writeFileSync('./project/dist.js', generateCode())
console.log('done')
function generateCode() {
let code = ''
code += 'var depRelation = [' + depRelation.map(item => {
const { key, deps, code } = item
return `{
key: ${JSON.stringify(key)},
deps: ${JSON.stringify(deps)},
code: function(require, module, exports){
${code}
}
}`
}).join(',') + '];\n'
code += 'var modules = {};\n'
code += `execute(depRelation[0].key)\n`
code += `
function execute(key) {
if (modules[key]) { return modules[key] }
var item = depRelation.find(i => i.key === key)
if (!item) { throw new Error(\`\${item} is not found\`) }
var pathToKey = (path) => {
var dirname = key.substring(0, key.lastIndexOf('/') + 1)
var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
return projectPath
}
var require = (path) => {
return execute(pathToKey(path))
}
modules[key] = { __esModule: true }
var module = { exports: modules[key] }
item.code(require, module, module.exports)
return modules[key]
}
`
return code
}
function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
if (depRelation.find(i => i.key === key)) {
// 注意,重复依赖不一定是循环依赖
return
}
// 获取文件内容,将内容放至 depRelation
const code = readFileSync(filepath).toString()
const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})
// 初始化 depRelation[key]
const item = { key, deps: [], code: es5Code }
depRelation.push(item)
// 将代码转为 AST
const ast = parse(code, { sourceType: 'module' })
// 分析文件依赖,将内容放至 depRelation
traverse(ast, {
enter: path => {
if (path.node.type === 'ImportDeclaration') {
// path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
// 然后转为项目路径
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依赖写进 depRelation
item.deps.push(depProjectPath)
collectCodeAndDeps(depAbsolutePath)
}
}
})
}
// 获取文件相对于根目录的相对路径
function getProjectPath(path: string) {
return relative(projectRoot, path).replace(/\\/g, '/')
可执行 node -r ts-node/register bundler 进行打包
最终会得到一个打包后的文件dist.js
, 可以使用node dist运行,也可以在html里面引入运行
其原理就是拼凑字符串,然后writeFileSync
写入文件
虽然实现了把文件都打包到一个文件里面,但是还存在如下问题
- 生成的代码中有多个重复的 _interopXXX 函数
- 只能引入和运行 JS 文件
- 只能理解 import,无法理解 require
- 不支持插件
- 不支持配置入口文件和 dist 文件名