前言
学习webpack打包流程,跟着 大崔哥 的视频敲了一遍,结合 瓶子君 的一篇讲解教程,记点笔记,以便自己后续查看。
模块
模块是一组有特定功能的代码。它封装了实现细节,公开了公共API,并与其他模块结合以构建更大的应用程序; 模块化,就是为了实现更高级别的抽象,它将一类或者多种实现封装到一个模块里,使用者不必考虑模块内是怎样的依赖关系,仅仅调用它暴露出来的API即可。
依赖
模块之间的依赖关系,就是多个模块结合来构建出更大应用程序的相互引用关系;
简单的说,我们常常封装的工具函数是一个小模块,多个函数组成一个库,函数之间又可能相互调用,这种调用关系就是依赖关系,这个函数、库就是一个模块;
CJS/AMD/UMD/ESM
-
CJS(CommonJS):旨在用于服务器端 JavaScript 的同步定义,Node 的模块系统实际上基于 CJS; 但 CommonJS 是以同步方式导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大,但在浏览器端,如果在 UI 加载的过程中需要花费很多时间来等待脚本加载完成,这会造成用户体验的很大问题。 鉴于网络的原因, CommonJS 为后端 JavaScript 制定的规范并不完全适合与前端的应用场景,下面来介绍 JavaScript 前端的规范。
-
AMD(异步模块定义):被定义为用于浏览器中模块的异步模型,RequireJS 是 AMD 最受欢迎的实现;
-
UMD(通用模块定义):它本质上一段 JavaScript 代码,放置在库的顶部,可让任何加载程序、任何环境加载它们;
-
ES2015(ES6):定义了异步导入和导出模块的语义,会编译成 require/exports 来执行的,这也是我们现今最常用的模块定义;
CJS导出的是模块的拷贝,不会影响;ESM导出的是模块的引用,会影响;
打包器
将多个JS模块组合到一个可以在浏览器中运行的文件中的工具。 (模块化)打包器可以管理多个依赖项,将每个依赖项模块化,让每个依赖项能够在正确的时间、正确的地点被正确的引用。 (捆绑)并且可以减少http文件请求,只请求一个打包后的JS文件即可; 所以,模块化与捆绑是打包器需要实现的两个最主要功能。
项目目录
-src
- - mes //二级文件夹
- - - message.js //依赖
- - entry.js //入口文件
- - hello.js //依赖
- - name.js //依赖
- index.js //打包文件
- minipack.config.js //配置文件
entry.js
//入口文件
import message from "./mes/message.js";
import { name } from "./name.js";
message();
console.log("----name-----: ", name);
message.js
// 依赖项
import {hello} from '../hello.js'
import {name} from '../name.js'
export default function message() {
console.log(`${hello} ${name}!`)
}
hello.js
// 依赖项
export const hello = 'hello'
name.js
// 依赖项
export const name = 'bottle'
minipack.config.js
const path = require("path");
module.exports = {
entry: "./src/entry.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "./dist")
}
};
安装需要的包:
npm install @babel/core @babel/parser @babel/preset-env @babel/traverse --save-dev
- @babel/core babel 集成包核心库,包含(parse/traverse/types/transfromFromAst...)
- @babel/parser 解析文件内容为AST
- @babel/traverse 遍历AST
- @babel/preset-env 根据配置的浏览器的列表,自动加载当前浏览器所需要的插件,然后对ES语法做转换
打包流程:
- 解析入口文件,生成AST树,遍历所有依赖项,收集依赖;
- 递归解析所有的依赖项,生成依赖关系图;
- 使用依赖关系图,生成一个可以在浏览器运行的JS文件;
- 输出到指定文件夹下;
解析入口文件,生成AST树,遍历依赖项
//引入fs模块
const fs = require("fs");
// const babelParser = require("@babel/parser");
// const traverse = require("@babel/traverse").default;
const { parse, traverse, transformFromAst } = require("@babel/core"); //核心插件,是babel的整合
//获取配置文件
const config = require("./minipack.config");
//入口
const { entry } = config;
//出口
const { output } = config;
/**
* @description 解析文件内容及其依赖,
* {
* deps:文件依赖的模块,
* code:文件解析内容
* }
* @param filename 文件路径
*/
function createAsset(filename) {
//1:读取文件内容
const content = fs.readFileSync(filename, "utf-8");
//2:解析代码,生成AST(抽象语法树)https://astexplorer.net/
//sourceType:指示代码应解析的模式有三种参数,
const ast = parse(content, {
//script/module(使用ES6的import和require时)/unambiguous(parse去自己检测)
sourceType: "module"
});
//3:遍历 AST 找到依赖关系
//存放 ast 中解析出的所有依赖
const deps = [];
traverse(ast, {
//// 遍历所有的 import 模块,并将相对路径放入 deps
ImportDeclaration: ({ node }) => {
// console.log("node", node.source.value);
deps.push(node.source.value);
}
});
//4:把 AST 转成浏览器可运行的代码
//通过babel/core的 transformFromAst 方法转换 ast 成浏览器可以运行的东西
const { code } = transformFromAst(ast, null, {
// 该`presets`选项是一组规则,告诉`babel`如何传输我们的代码.
// 我们用`babel-preset-env``将我们的代码转换为浏览器可以运行的东西
presets: ["@babel/env"]
});
// console.log("code:******************", code);
// fs.writeFileSync("./dist/bundle.js", code);
return {
deps,
code
};
}
递归解析依赖项,生成依赖关系
此时要考虑到可能出现重复打包(多处依赖同一个文件)的问题,所以解析依赖项时,要找到一个唯一标识,去除重复模块,可以参考webpack的打包结果,是以文件名为唯一标识;
/**
* @description
* 从入口文件开始,获取整个依赖图
* @param entry 入口文件
*/
function createGraph(entry) {
//从入口文件开始,解析每一个依赖资源,并将其依次放入队列
const mainAsset = createAsset(entry);
//队列
const queue = {
[entry]: mainAsset
};
/**
* @description 递归遍历,获取所有依赖
* @param filename 文件名
* @param asset 入口文件
*/
function recursionDep(filename, asset) {
//跟踪所有依赖文件(模块唯一标识符)
asset.mapping = {};
// 模块 import 为相对路径,转成当前绝对路径
//第一次参数是 entry.js,所以 absolutePath 是它的依赖相对于 entry.js
const dirname = path.dirname(filename);
// console.log("filename:", filename, "dirname", dirname);
//循环依赖数组里面的相对路径,拼接上当前递归文件路径,等于它依赖的绝对路径,
//因为源头entry路径确定了,依次都确定了
asset.deps.forEach(relativePath => {
// 获取绝对路径,以便于 createAsset 读取文件
//!!教程此处没有做windows适配
const absolutePath = path.join(dirname, relativePath).replace(/\\/g, "/");
// console.log("relativePath:", relativePath, "absolutePath:", absolutePath);
//与之前的 asset 关联
asset.mapping[relativePath] = absolutePath;
// console.log("mapping:", asset.mapping);
//检测依赖文件,没有加入到依赖图中,才让其加入,避免模块重复打包
if (!queue[absolutePath]) {
//获取依赖模块内容
const childAsset = createAsset(absolutePath);
//将 childAsset 依赖关系放入 queue 以便于 for 继续解析依赖资源的依赖
//直到所有依赖解析完成,就这构成了一个从入口文件开始的依赖图
queue[absolutePath] = childAsset;
//如果子的依赖关系 deps 数组还有依赖其他的,那就继续递归
if (childAsset.deps.length > 0) {
recursionDep(absolutePath, childAsset);
}
}
});
}
//遍历 queue 队列,获取每一个 asset 及其依赖模块并将其加入到队列
for (const filename in queue) {
const asset = queue[filename];
recursionDep(filename, asset);
}
//返回依赖图
return queue;
}
使用依赖图,返回一个可以在浏览器运行的 JavaScript 文件
这里可以看 大崔哥 视频,讲的比较容易理解,但他是用ejs模板解析去做的;
/**
* @description 打包(使用依赖图,返回一个可以在浏览器运行的包)
* 所以返回一个立即执行函数(function(){})(params)
* 这个函数只接收一个参数,包含依赖图中的所有信息
*
* 遍历图 graph,将每个 mod 以'key:value'方式加入到 modules
* key(filename),模块的唯一标识符,value 为一个数组,包含:
* function(require, module, exports){ ${mod.code} }
* ${JSON.stringify(mod.mapping)}
*
* 其中:function(require, module, exports){ ${mod.code} }
* 使用函数包装每一个模块的代码 mode.code,防止 mode.code 污染全局变量或其它模块
* 并且模块转化后运行在 common.js 系统,它们期望有 require, module, exports 可用
*
* 其中:${JSON.stringify(mod.mapping)} 是模块间的依赖关系,当依赖被 require 时调用
* 例如:{ './message.js': 'src\message.js' }
*
* @param graph 依赖图
*/
function bundle(graph) {
let modules = "";
for (const filename in graph) {
const mod = graph[filename]; //每一个模块
modules += `'${filename}':[
function(require,module,exports){
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
}
// 注意:modules 是一组 `key: value,`,所以我们将它放入 {} 中
// 实现 立即执行函数
// 首先实现一个 require 函数,require('${entry}') 执行入口文件,entry 为入口文件绝对路径,也为模块唯一标识符
// require 函数接受一个moduleId(filename 绝对路径) 并在其中查找它模块我们之前构建的对象.
// 通过解构 const [fn, mapping] = modules[id] 来获得我们的函数包装器和 mappings 对象.
// 由于一般情况下 require 都是 require 相对路径,而不是moduleId(filename 绝对路径),所以 fn 函数需要将 require 相对路径转换成 require 绝对路径,即 localRequire
// 注意:不同的模块 moduleId(filename 绝对路径)时唯一的,但相对路径可能存在相同的情况
//
// 将 module.exports 传入到 fn 中,将依赖模块内容暴露处理,当 require 某一依赖模块时,就可以直接通过 module.exports 将结果返回
const result = `
(function(modules){
function require(moduleId){
const [fn,mapping] = modules[moduleId];
//获取map映射的值,即 key-value 的value
function localRequire(name){
return require(mapping[name])
}
const module = {exports:{}};
fn(localRequire,module,module.exports)
return module.exports;
}
require('${entry}')
})({${modules}})
`;
return result;
}
输出到指定目录
/**
* @description 输出打包
* @param path 路径
* @param result 内容
*/
function writeFile(path, result) {
//写入 ./dist/bundle.js
fs.writeFile(path, result, err => {
if (err) throw err;
console.log("文件已被保存");
});
}
// 获取依赖图
const graph = createGraph(entry);
// console.log("graph:", graph);
//打包
const result = bundle(graph);
//console.log("result", result);
//输出
let { path: outPath, filename: outFilename } = output;
fs.access(`${outPath}/${outFilename}`, err => {
if (!err) {
writeFile(`${outPath}/${outFilename}`, result);
} else {
fs.mkdir(outPath, { recursive: true }, err => {
if (err) throw err;
writeFile(`${outPath}/${outFilename}`, result);
});
}
});
BUG
- 瓶子君 createGraph方法的方法解析absolute时,windows下会出现反斜杠,后续mapping的key值错误,本文已做了适配;
- 2.大崔哥 视频教程的mainjs中引入foo方法不正确,导致打包时按照exports.default方式,引入bundlejs时,浏览器报错;