1-15节主要讲webpack的使用,当然,建议结合《webpack学完这些就够了》一起学习。
从本节开始,专攻webpack原理,只有深入原理,才能学到webpack设计的精髓,从而将技术点运用到实际项目中。
可以点击上方专栏订阅哦。
以下是本节正文:
高频面试点
)面试可以按照上面的答,然后面试官会问每个步骤的细节,所以每个步骤怎么实现的,需要了解清楚,这就意味着需要总结下面写的代码,这块很重要哦~
按照上面的步骤,一步步实现webpack,重要步骤均写了注释,可以打这个代码去
debugger
有些许bug,但是不影响了解webpack的工作流
webpack.config.js
配置文件const path = require('path');
const RunPlugin = require('./myPlugins/run-plugin');
const DonePlugin = require('./myPlugins/done-plugin');
const EmitPlugin = require('./myPlugins/emmit-plugin');
module.exports = {
mode: 'production',
devtool: false,
context: process.cwd(), // 上下文,如果想改变根目录,可以改这边。默认值就是当前命令执行的时候所在的目录(不是webpack.config.js的目录,是执行时的目录)
entry: {
entry1: './src/entry1.js',
entry2: './src/entry2.js'
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js'
},
resolve: {
extensions: ['.js', '.jsx', '.json']
},
module: {
rules: [
{
test: /\.js$/,
use: [
path.resolve(__dirname, 'loaders', 'logger1-loader.js'),
path.resolve(__dirname, 'loaders', 'logger2-loader.js')
]
}
]
},
plugins: [
new RunPlugin(),
new DonePlugin(),
new EmitPlugin()
]
}
然后开始手写webpack
webpack.run.js
文件,该文件主要用于手动跑webpack
compiler
,这个compiler
有一个run方法,调用该方法可以启动编译compiler
与complication
的区别见下文const webpack = require('./myWebpack/myWebpack');
const webpackOptions = require('./webpack.config');
// compiler代表整个编译过程
const compiler = webpack(webpackOptions);
// 调用run方法,可以启动编译
compiler.run((err, stats) => {
console.log(err)
console.log(stats.toJson())
})
myWebpack
const Compiler = require('./Compiler');
function webpack(options){
// 1. 初始化文件,从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
// console.log(process.argv); // 获取命令参数,参数与webpack.config.js同名的时候,参数会覆盖配置文件,也就是说参数的优先级更高
let shellOptions = process.argv.slice(2).reduce((shellConfig, item) => {
let [key, value] = item.split('=')
shellConfig[key.slice(2)] = value;
return shellConfig;
}, {})
let finalConfig = {...options, ...shellOptions};
// console.log(finalConfig)
// 2. 用上一步得到的参数初始化Compiler对象
let compiler = new Compiler(finalConfig);
// 3. 加载所有配置的插件
let { plugins } = finalConfig;
for (let plugin of plugins) {
plugin.apply(compiler);
// 注册所有插件的事件,是通过tapable的tap来注册的,然后就是等待着合适的时机去触发事件,也就是调用tapable的call函数
// 由于触发的时机是固定的,所以不同时机触发的插件,在注册的时候谁先谁后都无所谓,那么webpack的plugins中写的谁先谁后其实都无所谓。但是如果多个插件是统一时机触发的,那么就是谁先注册谁就先触发。
}
// 4. 执行compiler对象的run方法开始编译,调用run在外面,具体的run方法在Compiler类中
// 最后,需要将compiler对象返回
return compiler;
}
module.exports = webpack;
Compiler
类,该类主要是run
和compiler
方法。let { SyncHook } = require('tapable');
let Complication = require('./complication');
let fs = require('fs');
class Complier{
constructor(options){
this.options = options;
this.hooks = {// 类似run的钩子有四五十个,下面几个是比较典型的
run: new SyncHook(), // 开始启动编译
emit: new SyncHook(['assets']), // 会在将要写入文件的时候触发
done: new SyncHook(), // 会在完成编译的时候触发 全部完成
}
}
run(callback){ // 开始编译
// 4. 执行compiler对象的run方法开始编译
/* 下面都是编译过程 */
// 首先是触发run钩子
this.hooks.run.call();
// 5. 根据配置中的entry找到入口文件
// 开启编译
this.compile(callback);
// 监听入口文件的变化, 如果文件变化了,重新再开始编译
Object.values(this.options.entry).forEach(entry => { // 考虑到多入口
fs.watchFile(entry, () => this.compile(callback));
})
// 最后是触发done钩子
this.hooks.done.call();
/* 上面都是编译过程 */
callback(null, {
toJson(){
return {
files: [], // 产出哪些文件
assets: [], // 生成哪些资源
chunk: [], // 生成哪些代码块
module: [], // 模块信息
entries: [] // 入口信息
}
}
})
}
compile(callback){
let complication = new Complication(this.options, this.hooks);
complication.make(callback);
}
}
module.exports = Complier;
compier
的时候,会调用Complication
类,该类实现如下:const path = require('path');
const fs = require('fs');
const types = require('babel-types');
const parser = require('@babel/parser'); // 编译
const traverse = require("@babel/traverse").default; // 转换
const generator = require('@babel/generator').default; // 生成 新源码
const baseDir = toUnitPath(process.cwd()); // process.cwd()表示当前文件所在绝对路径,toUnitPath(路径)将路径分隔符统一转成正斜杠/
class Complication{ // 一次编译会有一个complication,会存放所有入口
constructor(options, hooks){
this.options = options;
this.hooks = hooks;
// 下面这些 webpack4中都是数组 但是webpack5中都换成了set,优化了下,防止重复的资源编译,当然数组也可以做到防止重复资源编译
this.entries = []; // 存放所有的入口
this.modules = []; // 存放所有的模块
this.chunks = []; // 存放所有的代码块
this.assets = {}; // 对象,key为文件名,value为文件编译后的源码,存放所有的产出的资源, this.assets就是文件输出列表
this.files = []; // 存放所有的产出的文件
}
make(callback){
// 5. 根据配置中的entry找出入口文件
let entry = {};
if (typeof this.options.entry === 'string') {
entry.main = this.options.entry;
} else {
entry = this.options.entry;
}
// entry = {entry1: "./src/entry1.js", entry2: './src/entry2.js'}
for (let entryName in entry) {
// 获取入口文件的绝对路径,这里的this.options.context默认是process.cwd(),这个默认值在这边没做处理
let entryFilePath = toUnitPath(path.join(this.options.context, entry[entryName]))
// 6.从入口文件出发,调用所有配置的Loader对模块进行编译,返回一个入口模块
let entryModule = this.buildModule(entryName, entryFilePath);
// // 把入口module也放到this.modules中去
// this.modules.push(entryModule);
// 8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的Chunk
let chunk = {
name: entryName,
entryModule,
modules: this.modules.filter(item => item.name === entryName)
};
this.entries.push(chunk); // 代码块会放到entries和chunks中
this.chunks.push(chunk); // 代码块会放到entries和chunks中
// 9.再把每个Chunk转换成一个单独的文件加入到输出列表
this.chunks.forEach(chunk => {
let filename = this.options.output.filename.replace('[name]', chunk.name);
// 这个this.assets就是文件输出列表, key为文件名,value为chunk的源码
this.assets[filename] = getSource(chunk); // assets中key为文件名,value为chunk的源码
})
// 10.在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
this.files = Object.keys(this.assets);
this.hooks.emit.call(this.assets)
for(let fileName in this.assets){
let filePath = path.join(this.options.output.path, fileName);
fs.writeFileSync(filePath, this.assets[fileName], 'utf8');
}
// 最后调用callback,返回一个对象,对象内有toJSON方法
callback(null, {
toJson: () => {
return {
entries: this.entries,
chunks: this.chunks,
modules: this.modules,
files: this.files,
assets: this.assets
}
}
});
}
}
buildModule(name, modulePath){ // 参数第一个是代码块的名称,第二个是模块的绝对路径
// 6.从入口文件出发,调用所有配置的Loader对模块进行编译,返回一个入口模块
// 6.1 读取模块文件内容
let sourceCode = fs.readFileSync(modulePath, 'utf-8');
let rules = this.options.module.rules;
let loaders = []; // 寻找匹配的loader
for (let i = 0; i < rules.length; i++) {
if (rules[i].test.test(modulePath)) { // 如果当前文件路径能够匹配上loader的正则的,那么就调用这个loader去处理
loaders = [...loaders, ...rules[i].use]
}
}
// 用loader进行转换,从右往左,从下往上,在这里就是数组从右往左
for (let i = loaders.length - 1; i>= 0; i--) {
let loader = loaders[i];
sourceCode = require(loader)(sourceCode)
}
// 7. 再找出该模块依赖的模块,再递归这个步骤,知道所有入口依赖的文件都经过了这个步骤的处理,得到入口与模块之间的依赖关系
let moduleId = './' + path.posix.relative(baseDir, modulePath); // 当前模块的id
let module = {
id: moduleId, // 模块id,也就是相对于项目根目录的相对路径
dependencies: [], // 模块依赖
name // 模块名称
}
let ast = parser.parse(sourceCode, {sourceType: 'module'}); // 生成语法树
traverse(ast, {
// CallExpression这个节点代表方法调用
CallExpression: ({ node }) => {
if (node.callee.name === 'require') {
let moduleName = node.arguments[0].value; // 获取require函数的参数 './title',也就是模块的相对路径
let dirname = path.posix.dirname(modulePath); // 获取模块的所在目录(title文件的父文件夹) path.posix相当于把路径都转成/,不论是什么系统,都是正斜杠,如果不用posix的话,linux是正斜杠,windows是反斜杠
let depModulePath = path.posix.join(dirname, moduleName); // 模块的绝对路径,但是可能没有后缀,
let extensions = this.options.resolve.extensions; // 如果options中没有配置resolve,需要做判断,这边就暂时不写了
depModulePath = tryExtensions(depModulePath, extensions); // 生成依赖的模块绝对路径,已经包含了扩展名了
// 找到引用的模块的id,引用的模块的id的特点是:相对于根目录的路径
let depModuleId = './' + path.posix.relative(baseDir, depModulePath);
node.arguments = [types.stringLiteral(depModulePath)]; // 这个就是参数这个节点要变,因为原来是require('./title'),现在要变成require('./src/title.js'),这个types.stringLiteral就是用来修改参数的
let alreadyImportedModuleIds = this.modules.map(item => item.id); // 遍历出已有的modules的moduleId
// 把依赖模块的绝对路径放到依赖数组里面
// if (!alreadyImportedModuleIds.includes(dependency.depModuleId)) { // 如果不存在,才放进this.modules数组,这样防止已经编译过的模块重复放到this.modules中
module.dependencies.push({depModuleId, depModulePath});
// }
}
}
})
let { code } = generator(ast);
console.log(code, toUnitPath(this.options.context));
module._source = code.replace(toUnitPath(this.options.context), '.');// 模块源代码指向语法树转换后的新生村的源代码
// 7. 再找出该模块依赖的模块,再递归这个步骤,知道所有入口依赖的文件都经过了这个步骤的处理,得到入口与模块之间的依赖关系 这时候需要开始递归了
module.dependencies.forEach(dependency => {
let depModule = this.modules.find(item => item.id === dependency.depModuleId);
// 判断模块是否已经被编译过了,如果编译过了直接push,如果没有编译过,那么就先编译,编译完了再push
if (depModule) {
this.modules.push({...depModule, name}); // 重新改下名字
} else {
let dependencyModule = this.buildModule(name, dependency.depModulePath); // 这个name为啥是一样的??????
this.modules.push(dependencyModule);
}
})
return module;
}
}
function tryExtensions(modulePath, extensions){
extensions.unshift('');// 为什么要加一个空串,因为有可能用户写的路径是带后缀的,所以路径跟空串结合就是路径,如果不加空串,用户如果路径带了后缀,那判断就是title.js.js title.js.jsx title.js.json
for(let i = 0; i < extensions.length; i++){
let filePath = modulePath + extensions[i];
if (fs.existsSync(filePath)) {
return filePath;
}
}
throw new Error('Module not found')
}
function toUnitPath(modulePath){
return modulePath.replace(/\\/g, '/');
}
function getSource(chunk){
return `
(() => {
var modules = ({
${chunk.modules.map(module => `
"${module.id}":(module,exports,require)=>{
${module._source}
}
`).join(',')
}
});
var cache = {};
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = cache[moduleId] = {
exports: {}
};
modules[moduleId](module, module.exports, require);
return module.exports;
}
var exports = {};
(() => {
${chunk.entryModule._source}
})();
})()
;
`
}
module.exports = Complication;
webpack编译过程中有两个最重要的对象
- Compiler 生产产品的工厂,代表整个编译过程
- Compilation 代表一个生产过程,代表依次编译
代码解释说明
const fs = require("fs");
const path = require("path");
// 代表具体的一次编译,代表一次生产过程
class Complication{
build(){
console.log('编译一次')
}
}
// 代表整个编译
class Compiler{
run(){
this.compile(); // 开始编译
fs.watchFile(path.resolve(__dirname, './test.js'), () => {
this.compile(); // 监听文件变化,一旦变化,我就开始编译
})
}
compile(){
let complication = new Complication();
complication.build();
}
}
const compiler = new Compiler()
compiler.run()
webpack插件,主要是调用apply方法,apply方法是webpack会去调用的。
apply方法参数为compiler。
emit-plugin
class EmitPlugin{
apply(compiler){
compiler.hooks.emit.tap('emit', (assets) => {
assets['assets.md'] = Object.keys(assets).join('\n');
console.log('这是发射文件之前触发')
})
}
}
module.exports = EmitPlugin;
path.join('a', 'b', 'c')
在windows结果是a\b\c
,在linux中是a/b/c
path.posix.join('a', 'b', 'c')
不管在哪个操作系统下,返回的都是a/b/c
(都是linux的结果, 一般我们都希望用这个)
path.win32.join('a', 'b', 'c')
不管在哪个操作系统下,返回的都是a\b\c
(都是windows的结果)
process.cwd()
表示当前文件所在绝对路径,路径分隔符是反斜杠\
,一般来说我们需要手动转成linux下的正斜杠/
,可以用正则replace
替换
slash
这个库,可以自动将反斜杠转成正斜杠