总所周知,现代前端,基本都离不开前端工程化,多多少少都要用上打包工具进行处理,例如webpack、rollup。不过写得再多,也只是针对webpack/rollup的配置工程师,具体打包过程,对咱们来讲倒是个黑盒。前端
不如,咱们来搞点事情?node
在构建以前咱们要梳理一下打包的流程。webpack
咱们的打包工具要有一个打包入口 [entry]
git
咱们的 [entry]
文件会引入依赖,所以须要一个 {graph}
来存储模块图谱,这个模块图谱有3个核心内容:github
filepath
:模块路径,例如 ./src/message.js'
dependencies
:模块里用了哪些依赖code
:这个模块中具体的代码{graph}
会存储全部的模块,所以须要递归遍历每个被依赖了的模块web
根据 {graph}
的依赖路径,用 {graph.code}
构建可运行的代码shell
将代码输出到出口文件,打包完成npm
首先写好咱们须要打包的代码windows
// ./src/index.js
import message from './message.js';
import {word} from './word.js';
console.log(message);
console.log(word);
复制代码
// ./src/message.js
import {word} from './word.js';
const message = `hello ${word}`;
export default message;
复制代码
// ./src/word.js
export const word = 'paraslee';
复制代码
而后在根目录建立bundler.js,设计好具体要用上的功能,前端工程化
// ./bundler.js
// 模块分析能力:分析单个模块,返回分析结果
function moduleAnalyser(filepath) {
return {};
}
// 图谱构建能力:递归模块,调用moduleAnalyser获取每个模块的信息,综合存储成一个模块图谱
function moduleGraph(entry) {
const moduleMuster = moduleAnalyser(entry);
const graph = {};
return graph;
}
// 生成代码能力:经过获得的模块图谱生成可执行代码
function generateCode(entry) {
const graph = moduleGraph(entry);
const code = '';
return code;
}
// 调用bundleCode执行打包操做,获取到可执行代码后,将代码输出到文件里
function bundleCode() {
const code = generateCode('./src/index.js');
}
bundleCode();
复制代码
首先是最底层的功能:moduleAnalyser(模块分析)
由于第一个分析的模块必定是入口文件,因此咱们在写 moduleAnalyser 的具体内容时能够把其余代码先注释掉,单独编写这一块。
function moduleAnalyser(filepath) {}
moduleAnalyser('./src/index.js');
复制代码
首先,咱们须要读取这个文件的信息,经过node提供的 fs
API,咱们能够读取到文件的具体内容
const fs = require('fs');
function moduleAnalyser(filepath) {
const content = fs.readFileSync(filePath, 'utf-8');
}
复制代码
打印content能获得下图的结果
第二步,咱们须要获取这个模块的全部依赖,即 ./message.js
和 ./word.js
。有两种方法能够拿到依赖:1. 手写规则进行字符串匹配;2. 使用babel工具操做
第一种方法实在吃力不讨好,并且容错性低,效率也不高,所以我采用第二种方法
babel有工具能帮咱们把JS代码转换成AST,经过对AST进行遍历,能够直接获取到使用了 import 语句的内容
npm i @babel/parser @babel/traverse
复制代码
...
const parser = require('@babel/parser');
const traverse = require("@babel/traverse").default;
const moduleAnalyser = (filePath) => {
const content = fs.readFileSync(filePath, 'utf-8');
// 经过 [@babel/parser的parse方法] 能将js代码转换成ast
const AST = parser.parse(content, {
sourceType: 'module' // 若是代码使用了esModule,须要配置这一项
});
// [@babel/traverse] 能遍历AST树
traverse(AST, {
// 匹配 ImportDeclaration 类型节点 (import语法)
ImportDeclaration: function(nodePath) {
// 获取模块路径
const relativePath = nodePath.node.source.value;
}
});
}
复制代码
若是咱们在控制台中打印 relativePath
就能输出 ./message.js
和 ./word.js
AST实在是太长♂了,感兴趣的小伙伴能够本身输出AST看看长啥样,我就不放出来了
第三步,获取到依赖信息后,连同代码内容一块儿存储下来
下面是 moduleAnalyser的完整代码,看完后可能会有几个疑惑,我会针对标注处挨个进行解释
npm i @babel/core
复制代码
...
const babel = require('@babel/core');
const moduleAnalyser = (filePath) => {
const content = fs.readFileSync(filePath, 'utf-8');
const AST = parser.parse(content, {
sourceType: 'module'
});
// 存放文件路径 #1
const dirname = path.dirname(filePath);
// 存放依赖信息
const dependencies = {};
traverse(AST, {
ImportDeclaration: function(nodePath) {
const relativePath = nodePath.node.source.value;
// 将相对模块的路径 改成 相对根目录的路径 #2
const absolutePath = path.join(dirname, relativePath);
// replace是为了解决windows系统下的路径问题 #3
dependencies[relativePath] = './' + absolutePath.replace(/\\/g, '/');
}
});
// 用babel将AST编译成可运行的代码 #4
const {code} = babel.transformFromAst(AST, null, {
presets: ["@babel/preset-env"]
})
return {
filePath,
dependencies,
code
}
}
复制代码
#1 为何要获取dirname?
首先入口文件为 ./src/index.js,默认 ./src 为代码的根目录,全部依赖,全部模块文件都在 ./src 下面 (暂时先不考虑node_modules),所以咱们要获取这个根目录信息, dirname === 'src'
#2 为何要把相对模块的路径 改成 相对根目录的路径
在 ./src/index.js 中是这样引入模块的 import message from './message.js'
,relativePath 变量存储的值为 ./message.js
,这对于分析 message.js 文件很是不便,转换成 ./src/message.js
后就能直接经过fs读取这个文件,方便了不少
#3 为何要这样存储依赖信息
经过键值对的存储,既能够保留 将相对模块的路径 ,又能够存放 相对根目录的路径
#4 为何要作代码编译
代码编译能够将esModule转换成commonjs,以后构建代码时,咱们能够编写本身的 require()
方法进行模块化处理.
OK,如今已经理解了 moduleAnalyser 方法,让咱们看看输出结果长啥样
如今已经实现了 模块分析能力,接下来咱们须要递归全部被导入了的模块,将每一个模块的分析结果存储下来做为 grapth
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {
// moduleMuster 存放已经分析过的模块集合, 默认直接加入入口文件的分析结果
const moduleMuster = [moduleAnalyser(entry)];
// cache记录已经被分析过的模块,减小模块的重复分析
const cache = {
[moduleMuster[0].filePath]: 1
};
// 存放真正的graph信息
const graph = {};
// 递归遍历全部的模块
for (let i = 0; i < moduleMuster.length; i++) {
const {filePath} = moduleMuster[i];
if (!graph[filePath]) {
const {dependencies, code} = moduleMuster[i];
graph[filePath] = {
dependencies,
code
};
for (let key in dependencies) {
if (!cache[dependencies[key]]) {
moduleMuster.push(moduleAnalyser(dependencies[key]));
cache[dependencies[key]] = 1;
}
}
}
}
return graph;
}
// 先直接传入enrty信息,获取图谱信息
moduleGraph('./src/index.js');
复制代码
moduleGraph 方法并不难理解,主要内容在递归层面。
输出看看最终生成的图谱 graph
接下来就是重点中的重点,核心中的核心:根据graph生成可执行代码
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}
const generateCode = (entry) => {
// 代码在文件里实际上是一串字符串,浏览器是把字符串转换成AST后再操做执行的,所以这里须要把图谱转换成字符串来使用
const graph = JSON.stringify(moduleGraph(entry));
return ` (function(graph) { // 浏览器没有require方法,须要自行建立 function require(module) { // 代码中引入模块时是使用的相对模块的路径 ex. var _word = require('./word.js') // 但咱们在引入依赖时须要转换成相对根路径的路径 ex. require('./src/word.js') // 将requireInEval传递到闭包中供转换使用 function requireInEval(relativePath) { return require(graph[module].dependencies[relativePath]); } // 子模块的内容存放在exports中,须要建立空对象以便使用。 var exports = {}; // 使用闭包避免模块之间的变量污染 (function(code, require, exports) { // 经过eval执行代码 eval(code); })(graph[module].code, requireInEval, exports) // 将模块所依赖的内容返回给模块 return exports; } // 入口模块需主动引入 require('${entry}'); })(${graph})`;
}
generateCode('./src/index.js');
复制代码
此刻一万匹草泥马在心中狂奔:这破函数到底写的是个啥玩意儿? 给爷看晕了
GGMM不着急,我来一步步说明
首先把字符串化的graph传入闭包函数中以供使用。
而后须要手动导入入口文件模块,即 require('${entry}')
,注意这里要使用引号包裹,确保为字符串
所以咱们的 require 函数此时为
function require(module = './src/index.js') {}
复制代码
根据 graph['./src/index.js']
能获取到 入口文件的分析结果,
function require(module = './src/index.js') {
(function(code) {
// 经过eval执行代码
eval(code);
})(graph[module].code)
}
复制代码
而后咱们看一下此时eval会执行的代码,即入口文件编译后的代码
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _word = require("./word.js");
var message = "hello ".concat(_word.word);
var _default = message;
exports["default"] = _default;
复制代码
在第六行里有一句 var _word = require("./word.js");
,由于做用域链的存在,这里的require会调用最外层的require方法, 可是咱们本身写的require方法接受的是相对根目录的路径,所以须要有一个方法进行转换。
// 此时的require
function require(module = './src/index.js') {
function requireInEval(relativePath = './word.js') {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(code, require, exports) {
eval(code);
})(graph[module].code, requireInEval, exports)
return exports;
}
复制代码
经过 requireInEval 进行路径转换,并传入到闭包当作,根据做用域的特性,eval中执行的 require 为传入的requireInEval 方法。
eval执行时,会把依赖里的数据存放到exports对象中,所以在外面咱们也须要建立一个exports对象接受数据。
最后将exports返回出去
以后就是重复以上步骤的循环调用
如今,打包流程基本已经完成了,generateCode 方法返回的code代码,能够直接放到浏览器中运行。
不过好歹是个打包工具,确定要把打包结果输出出来。
const os = require('os'); // 用来读取系统信息
...
const moduleAnalyser = (filePath) => {...}
const moduleGraph = (entry) => {...}
const generateCode = (entry) => {...}
function bundleCode(entry, output) {
// 获取输出文件的绝对路径
const outPath = path.resolve(__dirname, output);
const iswin = os.platform() === 'win32'; // 是不是windows
const isMac = os.platform() === 'darwin'; // 是不是mac
const code = generateCode(entry);
// 读取输出文件夹
fs.readdir(outPath, function(err, files) {
// 若是没有文件夹就建立文件夹
let hasDir = true;
if (err) {
if (
(iswin && err.errno === -4058)
|| (isMac && err.errno === -2)
) {
fs.mkdir(outPath, {recursive: true}, err => {
if (err) {
throw err;
}
});
hasDir = false;
} else {
throw err;
}
}
// 清空文件夹里的内容
if (hasDir) {
files = fs.readdirSync(outPath);
files.forEach((file) => {
let curPath = outPath + (iswin ? '\\' :"/") + file;
fs.unlinkSync(curPath);
});
}
// 将代码写入文件,并输出
fs.writeFile(`${outPath}/main.js`, code, 'utf8', function(error){
if (error) {
throw error;
}
console.log('打包完成!');
})
})
}
bundleCode('./scr/index.js', 'dist');
复制代码
执行 node bundler.js
看看最终结果吧!
到这里,一个基础的打包工具就完成了!
你能够本身添加 bundler.config.js ,把配置信息添加进去,传入bundler.js,这样看上去更像个完整的打包工具了。
这个打包工具很是简单,很是基础,webpack/rollup由于涉及了海量的功能和优化,其内部实现远比这个复杂N倍,但打包的核心思路大致相同。
这里放上完整的代码: github
若是文中有错误/不足/须要改进/能够优化的地方,但愿能在评论里提出,做者看到后会在第一时间里处理
既然你都看到这里了,为什么不点个赞👍再走,github的星星⭐是对我持续创做最大的支持❤️️
![]()
拜托啦,这对我真的很重要