写点什么

源码 | 浅谈 Webpack 原理,以及 loader 和 plugin 实现。

用户头像
梁龙先森
关注
发布于: 2020 年 11 月 30 日
源码 | 浅谈Webpack原理,以及loader和plugin实现。

一、初识Webpack

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

1、能做什么?

webpack 通过 loader 可以支持各种语言和预处理器编写模块(通过loader处理非JavaScript模块,并在bundle中引入依赖),模块可以通过以下方式来表达它们的依赖关系:

1. ES2015 import 语句
2. CommonJS require() 语句
3. AMD define 和 require 语句
4. css/sass/less 文件中的@import语句
5. 样式(url(...))和HTML文件(<img src=...>)中的图片链接
2、安装使用

文章是根据webpack 5.6.0版本,webpack-cli 3.1.0版本。

npm install webpack webpack-cli --save-dev
3、浅析webpack启动过程源码
  1. 命令行运行以上命令,npm会让命令行工具进入node_modules/.bin目录查找是否存在webpack.sh或者webpack.cmd文件,如果存在,就执行,不存在就抛出错误。

  2. webpack.cmd执行会判断当前目录是否存在node执行程序,如果存在则使用当前node进程执行,否则使用全局node执行文件:node_modules/webpack/bin/webpack.js。



webpack启动过程代码如下:

// webpack/bin/webpack.js
const cli = {
    name"webpack-cli",
    package"webpack-cli",
    binName"webpack-cli",
    installed: isInstalled("webpack-cli"), // 是否安装webpack-cli
    url"https://github.com/webpack/webpack-cli"
};
if(!cli.installed){ // 未安装webpack-cli
...
process.exitCode = 1 // 用于判断是否正常执行,正常返回:0
// 未安装webpack-cli,询问是否安装webpack-cli
questionInterface.question(question, answer => {
....
// 流程:选择安装webpack-cli
process.exitCode = 0;
// 执行安装命令
runCommand(packageManager, installOptions.concat(cli.package))
.then(()=>{
// 执行webpack-cli/bin/cli.js方法
runCli(cli);
})
.catch(err=>{
process.exitCode = 1;
})
})
}else{
runCli(cli)
}

webpack-cli启动执行过程:

// webpack-cli/bin/cli.js
const [, , ...rawArgs] = process.argv;
// 判断是否安装webpack
if (packageExists('webpack')) {
// 执行CLI脚本
runCLI(rawArgs);
}else{
//未安装: 提示安装 && runCLI()
...
}
// webpack-cli/lib/bootstrap.js
const { core } = require('./utils/cli-flags');
const runCLI = async(cliArgs)=>{
/**
* core: 命令行接口默认声明配置项
* cliArgs:用户输入的命令行参数
* parsedArgs:{opts:{命令行参数匹配出的配置项},unknownArgs:[未匹配到的命令行参数]}
* 例子:例子: webpack index.js -o output.js --> {opts:{color: true, outputPath:'oiutput.js'},unknownArgs:[]}
*/
const parsedArgs = argParser(core, cliArgs, true, process.title);
/**
* 判断是否为webpack-cli提供的不需要编译的命令:是:直接去执行对应命令
* 如:webpack serve|info|init...
* 若未安装,则询问是否安装
*/
const commandIsUsed = isCommandUsed(cliArgs);
if(commandIsUsed) return;
// 处理默认的webpack entry CLI参数:
// webpack-cli --entry ./index.js' 替换成 'webpack-cli ./index.js'
let entry;
// 从未匹配到的命令行参数中解析出:entry 入口文件
if (parsedArgs.unknownArgs.length > 0) {
entry = [];
parsedArgs.unknownArgs = parsedArgs.unknownArgs.filter((item) => {
if (item.startsWith('-')) {
return true;
}
entry.push(item);
return false;
});
}
// 创建webpack-cli实例,执行编译构建
const cli = new WebpackCLI();
await cli.run(parsedArgsOpts, core);
}
// webpack-cli/lib/webpack-cli.js
class WebpackCLI{
createCompiler(options, callback) {
let compiler;
try {
compiler = webpack(options, callback);
} catch (error) {
this.handleError(error);
process.exit(2);
}
return compiler;
}
async run(args){
/**
* 命令行参数与配置项合并生产配置项
* 0配置,配置优先,下一组将覆盖现有配置
*/
await this.runOptionGroups(args);
// webpack编译配置
let options = this.compilerConfiguration;
// 输出参数配置项
let outputOptions = this.outputConfiguration;
const isRawOutput = typeof outputOptions.json === 'undefined';
// 未配置json:插件数组第一坑位植入插件:WebpackCLIPlugin,用于构建流程展示进度
if (isRawOutput){
const webpackCLIPlugin = new WebpackCLIPlugin({
progress: outputOptions.progress,
});
...
addPlugin(webpackCLIPlugin);
}
const callback = (error, stats) =>{
// 获取输出的构建信息
const foundStats = compiler.compilers
? { children: compiler.compilers.map(getStatsOptionsFromCompiler) }
: getStatsOptionsFromCompiler(compiler);
...
if (outputOptions.json === true){
// 按照json格式在命令行窗口输出构建信息
}else if (typeof outputOptions.json === 'string'){
// 创建json文件输出构建信息
}else{
// 按照普通logger输出构建信息
}
}
// 执行webpack/webpack.js 开始编译,返回compiler实例
compiler = this.createCompiler(options, callback);
return Promise.resolve();
}
}
4、调试node_modules下的三方库
// 1. 进入包目录
cd ~/projects/node_modules/node-redis
/**
* 2. 创建全局包链接
* 包文件夹中的npm link将在全局文件夹{prefix}/lib/node_modules/<package>中创建一个符号链接,
* 该符号链接到执行npm link命令的包。它还将把包中的任何bin链接到{prefix}/bin/{name}。请注意,
* npm link使用全局前缀(其值请参见npm prefix-g)
*/
npm link          
// 3. 转到程序目录          
cd ~/projects/node-bloggy   
// 4. 链接安装程序包[package.json中的name]
npm link redis 

文档:npm-link Symlink a package folder

二、Webpack的本质

webpack可以理解为一种基于事件流的编程范例,一系列的插件运行,而实现这个插件机制的是Tapable。

1、Tapable是什么?

Tapable公开了许多Hook类,可用于为插件创建钩子。

Tapable是一个类似于Node.js的EventEmitter的库,主要是控制钩子函数的发布与订阅,控制着Webpack的插件系统。



安装使用:

npm install --save tapable



Node.js事件机制简单例子:

// nodejs中的事件机制
const EventEmitter = require('events');
const emitter = new EventEmitter();
// 监听事件
emitter.on('start',()=>{
console.log('start')
})
// 触发事件
emitter.emit('start')
2、Tapable Hooks类型

Tapable暴露的为插件提供挂载的Hook类,如下:

const {    
SyncHook, // 同步钩子
SyncBailHook, // 同步熔断钩子
SyncWaterfallHook, // 同步流水钩子
SyncLoopHook, // 同步循环钩子
AsyncParallelHook, // 异步并发钩子
AsyncParallelBailHook, // 异步并发熔断钩子
AsyncSeriesHook, // 异步串行钩子
AsyncSeriesBailHook, // 串行串行熔断钩子
AsyncSeriesWaterfallHook // 异步串行流水钩子
} = require("tapable");

这些Hook可以按以下进行分类:

Hook:所有钩子的后缀
Waterfall:同步方法,但是它会传值给下一个函数
Bail:熔断:当函数有任何返回值,就会在当前执行函数停止
Loop:监听函数返回true表示继续循环,返回undefined表示循环结束
Sync:同步方法
AsyncSeries:异步串行钩子
AsyncParallel:异步并行执行钩子
3、模拟webpack的Compiler与plugin

相关代码如下:

//tapable使用:模拟webpack的Compiler.js
const {SyncHook} = require("tapable");
module.exports = class Compiler{
constructor(){
this.hooks = {
// 1. 注册同步钩子
init:new SyncHook(['start']),
}
}
run(){
// 3. 触发钩子函数
this.hooks.init.call()
}
}
//模拟 plugin.js
class Plugin{
constructor(){}
apply(compiler){
// 2. 插件内监听钩子函数
compiler.hooks.init.tap('start',()=>{
console.log('compiler start')
})
}
}
// 模拟webpack.js
const options = {
plugins:[new Plugin()]
}
const compiler = new Compiler()
for(const plugin of options.plugins){
if(typeof plugin==='function'){
plugin.call(compiler,compiler)
}else{
plugin.apply(compiler)
}
}
compiler.run()

三、浅析webpack源码

1、webpack
/*
* webpack/lib/webpack.js
*/
const createCompiler = rawOptions => {
// 获取默认的webpack参数,rawOptions转换为compiler完整的配置项:填补上默认项
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
// 根据当前工作目录创建编译实例
const compiler = new Compiler(options.context);
compiler.options = options;
/**
* NodeEnvironmentPlugin
* 往compiler挂载:可以缓存的输入文件系统|输出文件系统|监视文件系统;infrastructureLogger日志logger
* 目的:为打包输出文件准备
*/
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
// 执行在配置文件配置的所有插件
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
/**
* 应用webpack配置项默认值
* module|output|loader|performance|optimization...
* */
applyWebpackOptionsDefaults(options);
// 环境准备之后,执行插件(插件通过Hook tap监听事件)
compiler.hooks.environment.call();
// 环境安装完成之后,执行插件
compiler.hooks.afterEnvironment.call();
/**
* WebpackOptionsApply:开启默认的所有插件
* plugin事件流总线:根据options选项的配置,设置compile的相应插件
* 使用插件:require('Plugin'), new Plugin().applay(compiler)
*/
new WebpackOptionsApply().process(options, compiler);
// 执行初始化钩子函数
compiler.hooks.initialize.call();
return compiler;
};
const webpack = (options,callback)=>{
const create = ()=>{
// 验证配置项参数是否正确
validateSchema(webpackOptionsSchema, options);
let compiler;
let watch = false;
/** @type {WatchOptions|WatchOptions[]} */
let watchOptions;
if (Array.isArray(options)) {
/** @type {MultiCompiler} */
compiler = createMultiCompiler(options);
watch = options.some(options => options.watch);
watchOptions = options.map(options => options.watchOptions || {});
} else {
/** @type {Compiler} */
// 介绍的重点创建创建compiler
compiler = createCompiler(options);
watch = options.watch;
watchOptions = options.watchOptions || {};
}
return { compiler, watch, watchOptions };
}
if(callback){
...
const { compiler, watch, watchOptions } = create();
if (watch) {
compiler.watch(watchOptions, callback);
} else {
// 开始编译
compiler.run((err, stats) => {
compiler.close(err2 => {
callback(err || err2, stats);
});
});
}
return compiler;
...
}
}
module.exports = webpack;
2、WebpackOptionsApply

将所有的配置options参数转换成webpack内部插件,使用默认插件列表,如:

output.library -> LibraryTemplatePlugin
externals -> ExternalsPlugin
devtool -> EvalDevtoolModulePlugin,SourceMapDevToolPlugin
AMDPLUGIN,CommonJSPlugin

相关代码如下:

/*
* 这里只介绍下入口文件插件的注册使用
*/
//WebpackOptionsApply.js
class WebpackOptionsApply extends OptionsApply{
...
process(options, compiler){
// 入口文件插件注册
new EntryOptionPlugin().apply(compiler);
// 调用入口文件插件注册的钩子函数
compiler.hooks.entryOption.call(options.context, options.entry);
}
}
// EntryOptionPlugin.js
class EntryOptionPlugin{
apply(compiler){
// 监听 entry 配置项处理完成的钩子函数
compiler.hooks.entryOption.tap("EntryOptionPlugin",(context,entry)=>{
EntryOptionPlugin.applyEntryOption(compiler, context, entry);
return true;
})
}
static applyEntryOption(compiler, context, entry) {
if (typeof entry === "function") {
// options的entry支持function类型:(entry:()=>"src/index.js")
const DynamicEntryPlugin = require("./DynamicEntryPlugin");
new DynamicEntryPlugin(context, entry).apply(compiler);
} else {
// EntryPlugin:用于处理EntryDependency的创建
const EntryPlugin = require("./EntryPlugin");
for (const name of Object.keys(entry)) {
const desc = entry[name];
// 入口文件默认配置项处理
const options = EntryOptionPlugin.entryDescriptionToOptions(
compiler,
name,
desc
);
for (const entry of desc.import) {
// 调用入口插件
new EntryPlugin(context, entry, options).apply(compiler);
}
}
}
}
static entryDescriptionToOptions(compiler, name, desc) {
/** @type {EntryOptions} */
const options = {
name,
filename: desc.filename,
runtime: desc.runtime,
dependOn: desc.dependOn,
chunkLoading: desc.chunkLoading,
wasmLoading: desc.wasmLoading,
library: desc.library
};
if (desc.chunkLoading) {
const EnableChunkLoadingPlugin = require("./javascript/EnableChunkLoadingPlugin");
EnableChunkLoadingPlugin.checkEnabled(compiler, desc.chunkLoading);
}
if (desc.wasmLoading) {
const EnableWasmLoadingPlugin = require("./wasm/EnableWasmLoadingPlugin");
EnableWasmLoadingPlugin.checkEnabled(compiler, desc.wasmLoading);
}
if (desc.library) {
const EnableLibraryPlugin = require("./library/EnableLibraryPlugin");
EnableLibraryPlugin.checkEnabled(compiler, desc.library.type);
}
return options;
}
}
// EntryPlugin.js
class EntryPlugin{
apply(compiler) {
// 监听编译(compilation)创建之后,执行插件的调用
compiler.hooks.compilation.tap(
"EntryPlugin",
(compilation, { normalModuleFactory }) => {
// 编译器compilation设置依赖
compilation.dependencyFactories.set(
EntryDependency,
// 模块工厂(用于模块的构建,我们说的js文件等等)
normalModuleFactory
);
}
);
// 注册compiler的make钩子函数,
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options);
// 执行编译器compilation的addEntry方法加载入口文件
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
}
}
3、Compiler和Compilation
// Compiler.js
class Compiler{
constructor(context) {
this.hooks = Object.freeze({
// 流程相关钩子
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
beforeCompile:new AsyncSeriesHook(["params"]),
afterCompile: new AsyncSeriesHook(["compilation"]),
make: new AsyncParallelHook(["compilation"]),
emit: new AsyncSeriesHook(["compilation"]),
afterEmit: new AsyncSeriesHook(["compilation"]),
// 监听相关钩子
watchRun: new AsyncSeriesHook(["compiler"]),
watchClose: new SyncHook([]),
// 其他相关钩子可参考官方文档
....
})
}
run(callback){
...
// 整个打包编译执行完成的回调
const finalCallback = (err, stats)=>{...}
// 编译完毕的回调
const onCompiled = (err, compilation)=>{...}
const run = () => {
// 调用编译打包前的钩子函数
this.hooks.beforeRun.callAsync(this, err => {
if (err) return finalCallback(err);
// 调用运行中钩子函数
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
// 读取Records文件
this.readRecords(err => {
if (err) return finalCallback(err);
// 开始编译
this.compile(onCompiled);
});
});
});
};
if (this.idle) {
this.cache.endIdle(err => {
if (err) return finalCallback(err);
this.idle = false;
run();
});
} else {
run();
}
}
// 编译
compile(callback) {
// 创建编译器的参数
const params = this.newCompilationParams();
// 调用开始编译前钩子函数
this.hooks.beforeCompile.callAsync(params, err => {
// 调用开始编译钩子函数
this.hooks.compile.call(params);
// 创建编译器实例
const compilation = this.newCompilation(params);
// 调用make钩子函数
this.hooks.make.callAsync(compilation, err => {
...
// 调用finishMake钩子函数
this.hooks.finishMake.callAsync(compilation, err => {
// 编译结束
compilation.finish(err=>{
...
// 编译(compilation)停止接收新模块时触发
compilation.seal(err => {
...
this.hooks.afterCompile.callAsync(compilation, err => {
...
return callback(null, compilation);
})
})
})
})
})
}
}
newCompilationParams() {
const params = {
// 模块工厂,用于模块的构建,通常说的js文件等
normalModuleFactory: this.createNormalModuleFactory(),
// 上下文工厂,用于整个工程的文件路径加载
contextModuleFactory: this.createContextModuleFactory()
};
return params;
}
}
4、Module分类



NormalModule模块说明:

  1. 使用loader-runner运行loaders

  2. 通过Parser解析(内部是acron)

  3. ParserPlugins添加依赖

四、loader

1、loader是什么?
  1. 概念

loader只是一个导出为函数的JavaScript模块

// 同步loader
module.exports = function(content,map,meta){
return someSyncOperation(content)
}
// 异步loader:使用 this.async 来获取 callback 函数
module.exports = function(content,map,meta){
var callback = this.async()
someAsyncOperation(content, function(err, result) {
if (err) return callback(err);
callback(null, result, map, meta);
});
}
  1. 多loader的执行顺序

module.exports = {
module:{
rules:[
{
test:/\.less$/,
use:[
'style-loader',
'css-loader',
'less-loader'
]
}
]
}
}

多个loader是串行执行,顺序从后到前执行,这是因为Webpack采用的是Compose的函数组合方式:

compose = (f,g)=>(...args)=>f(g(...args))
2、开发个raw-loader
// scr/raw-loader.js
module.exports = function(source){
const json = JSON.stringify(source)
// 为了安全,处理ES6模板字符串问题
.replace(/\u2028/g,'\\u2028')
.replace(/\u2029/g,'\\u2029')
return `export default ${json}`
}
3、loader-runner调试工具

loader不同于plugin,可以独立于webpack进行开发调试。

  1. 安装

npm install loader-runner --save-dev
  1. 使用例子

// test-loader.js 调试 raw-loader.js
import { runLoaders } from "loader-runner";
const fs = require('fs')
const path = require('path')
runLoaders({
// 资源的绝对路径(可以增加查询字符串,如?query)
resource:'./test.txt',
// loader的绝对路径可以增加查询字符串)
loaders:[path.resolve(__dirname,'./loader/raw-loader.js')],
// 基础上下文之外的额外loader上下文
context:{
minimize:true
},
// 读取资源函数
readResource:fs.readFile.bind(fs)
},function(err,result){
err?console.log(err):console.log(result)
})
// 运行测试
node test-loader.js

loader中如何进行异常处理,使用缓存,产生一个文件,详细可以查看:loader api 官方文档

五、plugin

插件没有像loader那样的独立运行环境,只能在webpack里面运行。

1、基本结构
class Plugin{ // 插件名称
constructor(options){
// 通过构造函数获取传递参数
this.options = options;
}
apply(compiler){ // 插件上的apply方法
// 监听钩子触发事件
compiler.hooks.done.tap('Plugin',(stats)=>{ // done钩子触发,stats作为参数
// 插件处理逻辑
})
}
}
module.exports = Plugin;
2、插件的使用
module.exports = {
entry:'./index.js',
output:{
path:'./dist',
filename:'[name].js'
},
plugins:[
new Plugin({配置参数})
]
}
3、CorsPlugin跨域插件

用于处理网页域名和要载入的静态文件存放的站点域名不一致时候的跨域问题。

module.exports = class CorsPlugin {
constructor ({ publicPath, crossorigin, integrity }) {
/*
* crossorigin:跨域属性
* 值:anonymous || '' :对此元素的CORS请求将不设置凭据标志
* 值:use-credentials :对此元素的CORS请求将设置凭证标志;这意味着请求将提供凭据
*/
this.crossorigin = crossorigin
// 请求完整性,供CDN 的静态文件使用,检查文身否为原版,防止使用的CDN资源被劫持篡改
this.integrity = integrity
this.publicPath = publicPath
}
apply (compiler) {
const ID = `vue-cli-cors-plugin`
compiler.hooks.compilation.tap(ID, compilation => {
/*
* Standard Subresource Integrity缩写
* 用于解析,操作,序列化,生成和验证Subresource Integrity哈希值。
*/
const ssri = require('ssri')
// 计算文件的integrity值
const computeHash = url => {
const filename = url.replace(this.publicPath, '')
const asset = compilation.assets[filename]
if (asset) {
const src = asset.source()
const integrity = ssri.fromData(src, {
algorithms: ['sha384']
})
return integrity.toString()
}
}
compilation.hooks.htmlWebpackPluginAlterAssetTags.tap(ID, data => {
const tags = [...data.head, ...data.body]
if (this.crossorigin != null) {
// script || link 标签设置允许跨域
tags.forEach(tag => {
if (tag.tagName === 'script' || tag.tagName === 'link') {
tag.attributes.crossorigin = this.crossorigin
}
})
}
if (this.integrity) {
// 校验文件是否为原版
tags.forEach(tag => {
if (tag.tagName === 'script') {
const hash = computeHash(tag.attributes.src)
if (hash) {
tag.attributes.integrity = hash
}
} else if (tag.tagName === 'link' && tag.attributes.rel === 'stylesheet') {
const hash = computeHash(tag.attributes.href)
if (hash) {
tag.attributes.integrity = hash
}
}
})
// when using SRI, Chrome somehow cannot reuse
// the preloaded resource, and causes the files to be downloaded twice.
// this is a Chrome bug (https://bugs.chromium.org/p/chromium/issues/detail?id=677022)
// for now we disable preload if SRI is used.
data.head = data.head.filter(tag => {
return !(
tag.tagName === 'link' &&
tag.attributes.rel === 'preload'
)
})
}
})
compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tap(ID, data => {
data.html = data.html.replace(/\scrossorigin=""/g, ' crossorigin')
})
})
}
}

六、总结

文章有点长,梳理了webpack原理,loader,以及plugin实现。读源码过程,存在不准确的地方,欢迎留言指正。



客官点个关注呗!

后续将努力推出系列文章:Low Code Development Platform(低代码平台相关实现)



--END--



作者:梁龙先森 WX:newBlob



原创作品,抄袭必究!



发布于: 2020 年 11 月 30 日阅读数: 1706
用户头像

梁龙先森

关注

寒江孤影,江湖故人,相逢何必曾相识。 2018.03.17 加入

1月的计划是:重学JS,点个关注,一起学习。

评论

发布
暂无评论
源码 | 浅谈Webpack原理,以及loader和plugin实现。