【万字】webpack loader 概念,手写,原理一条龙唠明白

一. 前言

本次讲解以 webapck 5.75.0 为基础进行演示,将从loader的 相关概念,分类,使用方式,手写练习,执行原理 五个章节为大家展开讲解。老规矩,相关案例代码已全部上传至 Git ,欢迎自取,不嫌麻烦的话欢迎点个star。接下来闲言少叙,大家坐稳扶好,我们发车!

二. 相关概念

本章节将通过 定义,基础结构 的讲解让大家对loader有基本的认知。

2.1 定义

我们之前用大白话聊过loader的作用:loader是一个翻译,把webpack不能直接处理的资源,翻译成能直接处理的。究其本质loader到底是什么?这里有一段 官方 的定义:

loader 本质上是导出为函数的 JavaScript 模块。loader runner 会调用此函数,然后将上一个 loader 产生的结果或者资源文件传入进去。函数中的 this 作为上下文会被 webpack 填充,并且 loader runner 中包含一些实用的方法,比如可以使 loader 调用方式变为异步,或者获取 query 参数。

到这里就非常清楚了,loader的本质就是函数模块,既然是函数,我们关注这个函数的入参,出参,功能,即可。接下来我们搭建webpack的基础环境,给大家展示最基本的loader结构。

2.2 基础结构

一个loader的基础结构如下所,其中 mapmeta 是可选参。

/**
 *
 * @param {string|Buffer} content 源文件的内容
 * @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
 * @param {any} [meta] meta 数据,可以是任何内容
 */
function webpackLoader(content, map, meta) {// 你的 webpack loader 代码
}

module.exports = webpackLoader 

为了更直观的演示,这里我们搭建好自己的webpack环境。

  • 步骤1: 安装 webpackwebpack-cli

yarn add webpack webpack-cli -D

  • 步骤2: 调整项目结构
├─ src
│├─ loaders
││ └─ loader1.js
│└─ index.js
├─ webpack.config.js
├─ .gitignore
├─ package.json
└─ README.md 
  • 步骤3: 配置 webpack.config.js
const path = require('path')

// 相对路径转绝对路径
const resolvePath = _path => path.resolve(__dirname, _path)

module.exports = {entry: resolvePath('./src/index.js'),output: {path: resolvePath('./dist'),clean: true},module:{rules:[{test: /\.js$/,loader: resolvePath('./src/loaders/loader1.js')}]},mode: 'development'
} 
  • 步骤4: 初始化 loader1.js 中的内容
const loader1 = function (content, map, meta) {console.log('loader1',content)return content
}

module.exports= loader1 
  • 步骤5: 初始化 src/index.js 中的内容
console.log('Hello Cengfan!') 

环境搭建完成,打开终端运行webpack看看控制台会输出什么内容。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第1张图片

可以看到 src/index.js 中的内容被原封不动地打印出来了,这个就是参数 content 的内容。它被处理后,会输出交给下一个loader继续调用。 如此,一个最基础的loader结构就展示出来了,接下来我们看看loader的分类。

三. 分类

本章节将按照 执行顺序,示例 跟大家讲解loader的分类。

3.1 执行顺序

loader在执行顺序上分为以下4类,仅需有印象即可,后续会进行大量的演示。

  • pre:前置loader* normal:普通loader* inline:内联loader* post:后置loaderloader的执行顺序遵循以下原则:

  • 默认的执行优先级为 pre,normal,inline,post* 相同优先级的loader执行顺序为 从右往左,从下往上了解loader的基本分类后,我们来看看它的示例。

3.2 示例

接下来更改项目中的文件,用以演示loader的默认执行顺序。

3.2.1 默认执行顺序

  • 步骤1: 调整项目结构,新增 loader2.js, loader3.js
├─ src
│├─ loaders
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
│└─ index.js
├─ webpack.config.js
├─ .gitignore
├─ package.json
└─ README.md 
  • 步骤2: 初始化 loader2.js 的内容
const loader2 = function (content, map, meta) {console.log('loader2')return content}module.exports= loader2 
  • 步骤3: 初始化 loader3.js 的内容
const loader3 = function (content, map, meta) {console.log('loader3')return content}module.exports= loader3 
  • 步骤4: 更改 webpack.config.js 中module的内容
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: resolvePath('./src/loaders/loader1.js')},{test: /\.js$/,loader: resolvePath('./src/loaders/loader2.js')},{test: /\.js$/,loader: resolvePath('./src/loaders/loader3.js')}]},
} 

完成后终端执行webpack,查看输出结果。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第2张图片

从打印的结果来看loader的执行顺序符合 从下往上,从右往左 的原则。接下来我们更改 rule 对象的 enforece 属性来更改loader的执行顺序。

3.2.2 更改执行顺序

更改 webpack.config.js 的内容

module.exports = {// ...module:{rules:[{test: /\.js$/,loader: resolvePath('./src/loaders/loader1.js'),enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: resolvePath('./src/loaders/loader2.js'),},{test: /\.js$/,loader: resolvePath('./src/loaders/loader3.js'),enforce: 'post'}]},
} 

运行webpack查看结果。loader的打印结果从原先的 loader3,loader2,loader1 变成了倒序输出。输出优先级与loader中 enforce 的属性值相关,优先级为 pre,normal,post loader

【万字】webpack loader 概念,手写,原理一条龙唠明白_第3张图片

内联的 inline loader 不在webpack的配置文件中配置,它仅在业务代码中配置且使用不多,所以不展开讲解。到这里我们已经清楚了loader的基础结构和执行顺序,接下来我们看看loader的使用方式。

四. 使用方式

loader在使用方式上分为 同步 loader,异步 loader,raw loader,pitch loader 这4类,在正式开始讲解前我们先对webpack的配置文件进行优化工作。

在当前配置文件中,所有自定义loader的使用都需要用 resolvePath 这个方法指定loader的使用路径。这里我们可以修改配置文件,新增 resolveLoader 属性,让webpack默认寻找自定义的loader文件夹,之后直接使用loader的名称即可。

module.exports = {// ...module:{rules:[{test: /\.js$/,loader: 'loader1',enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',enforce: 'post'}]},resolveLoader:{modules:[// 默认在 node_modules 与 src/loaders 的目录下寻找loader'node_modules',resolvePath('./src/loaders')]},
} 

运行webpack查看结果,loader内容正常输出。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第4张图片

优化工作完毕,接下来我们正式进入loader使用方式的学习。

4.1 同步loader

同步loader顾名思义在整个loader的执行流程中为同步执行。它的使用非常简单,我们用 loader1 举例,修改它的内容。

const loader1 = function (content, map, meta) {console.log('loader1')/* param1:error 是否有错误param2:content 处理后的内容param3:source-map 信息可继续传递 source-mapparam4:meta 给下一个 loader 传递的参数*/this.callback(null, content, map, meta)
}

module.exports = loader1 

使用 this.callback 方法替代原先的 return 语句,运行webpack查看结果。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第5张图片

同步loader的使用仅仅是替换了 return 语句,使用起来非常简单,接下来我们看看异步loader的使用。

4.2 异步loader

异步loader并不是让渡当前loader的执行权力,给下一个loader先执行。而是卡住当前的执行进程,方便你在异步的时间里去进行一些额外的操作。待这些操作完成后,任务进程交给下一个loader。 接下来我们演示异步loader,为了演示方便,先去除配置文件中的 enforce 配置。

module.exports = {// ...module:{rules:[{test: /\.js$/,loader: 'loader1',// enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',// enforce: 'post'}]},// ...
} 

修改 loader2 的内容,之后运行webpack查看输出结果。

const loader2 = function (content, map, meta) {console.log('loader2')// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。const callback = this.async()setTimeout(() => {console.log('async loader2')// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, meta)},500)
}

module.exports = loader2 
【万字】webpack loader 概念,手写,原理一条龙唠明白_第6张图片

可以看到这里先输出了 loader3,loader2 ,500ms后输出了 async loader2,loader1 。这说明在异步loader执行完成之前,是不会执行下一个loader的这里的异步loader执行机制可以用 async 中遇到 await 就暂停运行,等待 await 返回结果后才运行后续的代码去类比理解。

下面我们进行异步loader错误的使用演示,在异步中使用同步loader输出。

  • 步骤1: 修改 loader3 的内容
const loader3 = function (content, map, meta) {console.log('loader3')setTimeout(() => {console.log('async loader3')this.callback(null, content, map, meta)},500)
}

module.exports = loader3 
  • 步骤2:loader2 的异步中打印content,运行webpack查看输出结果
setTimeout(() => {console.log('async loader2', content)// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, meta)},500) 
【万字】webpack loader 概念,手写,原理一条龙唠明白_第7张图片

可以看到 loader2 先于 async loader3 输出,由于执行过程未等待,content 也没有传入 loader2 中,所以打印值为 undefined。

这个就是异步loader的使用,接下来看看raw loader的使用。

4.3 raw loader

raw loader 一般用于处理 Buffer 数据流的文件。在处理图片,字体图标等经常会使用它,这里为了演示方便,我们用它处理js。

  • 步骤1: src/loaders 下新增 raw-loader.js 初始化文件
const rawLoader = function(content) {console.log(content)return content
}

rawLoader.raw = true

module.exports = rawLoader 
  • 步骤2: 修改webpack配置文件,仅用于测试raw loader
module.exports = {// ...module:{/* rules:[{test: /\.js$/,loader: 'loader1',// enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',// enforce: 'post'}] */rules:[{test: /\.js$/,loader: 'raw-loader',}]},// ...
} 

运行webpack查看结果。

这就是 raw-loader 接下来我们看看 pitch-loader

4.4 pitch loader

loader模块中导出函数的 pitch 属性指向的函数就叫 pitch loader。它的使用场景是 当前loader依赖上个loader的输出结果,且该结果为js而非webpack处理后的资源。 此时loader的逻辑处理更适合放在pitch loader。记住它的使用场景,下一章节我们手写 style-loader 时会进行详细讲解。

本章节将通过 基础结构,执行顺序,熔断机制,函数入参 这些小节,跟大家详细讲解 pitch loader

4.4.1 基础结构

它的基础结构如下,参数均为可选参。

/** * @remainingRequest 剩余请求 * @precedingRequest 前置请求 * @data 数据对象 
*/ 
function (remainingRequest, precedingRequest, data) { // code
}; 

4.4.2 执行顺序

loader的执行顺序是 从右往左,从下往上 ,但pitch loader的执行顺序正好相反,它是 从左往右,从上往下 。接下来我们分步写好pitch loader来观察它的执行顺序。

  • 步骤1: 修改 loader1 的内容
const loader1 = function (content, map, meta) {console.log('loader1')/* param1:error 是否有错误param2:content 处理后的内容param3:source-map 信息可继续传递 source-mapparam4:meta 给下一个 loader 传递的参数*/this.callback(null, content, map, meta)
}

const pitch1 = function() {console.log('pitch loader1')
}

module.exports = loader1
module.exports.pitch = pitch1 
  • 步骤2: 修改 loader2 的内容
const loader2 = function (content, map, meta) {console.log('loader2')// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。const callback = this.async()setTimeout(() => {console.log('async loader2')// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, meta)},500)
}

const pitch2 = function() {console.log('pitch loader2')
}

module.exports = loader2
module.exports.pitch = pitch2 
  • 步骤3: 修改 loader3 的内容
const loader3 = function (content, map, meta) {console.log('loader3')this.callback(null, content, map, meta)
}

const pitch3 = function(remainingRequest, precedingRequest, data) {console.log('pitch loader3')
}
module.exports = loader3
module.exports.pitch = pitch3 
  • 步骤4: 修改 webpack.config.js 的 rule 对象,将之前演示 raw-loader 的代码注释
module.exports = {// ...module:{rules:[{test: /\.js$/,loader: 'loader1',// enforce: 'pre'},{// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',},{test: /\.js$/,loader: 'loader3',// enforce: 'post'}]/* rules:[{test: /\.js$/,loader: 'raw-loader',}] */},// ...
} 

运行webpack,查看输出结果。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第8张图片

这下明白了吧,pitch loader 会先于 normal loader 执行,下图即为它们的执行顺序。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第9张图片

4.4.3 熔断机制

上述都是pitch loader无返回值时的执行顺序,如果在整个执行链中,某个pitch loader有返回值,执行顺序又会发生改变。我们修改 loader2 中的pitch loader之后运行webpack查看输出结果。

const pitch2 = function() {console.log('pitch loader2')return 'cengfan'
} 
【万字】webpack loader 概念,手写,原理一条龙唠明白_第10张图片

执行到 loader2 时由于 pitch loader2 有返回值,导致后面所有的loader都不再执行,转而回到上一个loader的 normal loader。执行顺序如下图,这就是loader执行的熔断机制

【万字】webpack loader 概念,手写,原理一条龙唠明白_第11张图片

演示完毕,接下来为了其他演示效果正常,这里我们去掉loader2中的return语句。

4.4.4 函数入参

接下来我们处理 loader1,loader2,loader3 加上对应的输出语句和pitch loader的入参,以此进一步了解pitch loader。

  • 步骤1: 修改 loader1 的内容
const loader1 = function (content, map, meta) {console.log('loader1',content, map, meta)/* param1:error 是否有错误param2:content 处理后的内容param3:source-map 信息可继续传递 source-mapparam4:meta 给下一个 loader 传递的参数*/this.callback(null, content, map, meta)
}

const pitch1 = function(remainingRequest, precedingRequest, data) {console.log('pitch loader1')console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}

module.exports = loader1
module.exports.pitch = pitch1 
  • 步骤2: 修改 loader2 的内容
const loader2 = function (content, map, meta) {console.log('loader2')// this.async 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。const callback = this.async()setTimeout(() => {console.log('async loader2')// 调用 callback 后,才会执行下一个 loadercallback(null, content, map, this.data.value)},500)
}

const pitch2 = function(remainingRequest, precedingRequest, data) {data.value = 999console.log('pitch loader2')console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}

module.exports = loader2
module.exports.pitch = pitch2 
  • 步骤3: 修改 loader3 的内容
const loader3 = function (content, map, meta) {console.log('loader3')this.callback(null, content, map, meta)
}

const pitch3 = function(remainingRequest, precedingRequest, data) {console.log('pitch loader3')console.log('remainingRequest:',remainingRequest, 'precedingRequest:',precedingRequest, 'data:',data)
}
module.exports = loader3
module.exports.pitch = pitch3 

执行webpack查看输出结果。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第12张图片

这里的输出结果分两步分讲解,首先是 remainingRequest:剩余请求;precedingRequest:前置请求,然后是 data

由于输出内容太长且不直观,我把每个loader remainingRequest,precedingRequest 的输出结果汇总成了下表。帮助大家理解这两个参数的含义。

remainingRequest precedingRequest
pitch loader1 index.js;loader2.js;loader3.js
pitch loader2 index.js;loader3.js loader1.js
pitch loader3 index.js loader1.js;loader2.js

你会发现这哥俩的含义属实是顾名思义了,就是告诉你当前的pitch loader中有哪些未执行的请求,和已经执行的请求。

前两个参数聊完后我们看看data,它可用于捕获并共享前面的信息。注意图中标红的地方,我们在 loader2pitch loader 中添加了 data.value 属性。并在其异步调用的 callback 中将data作为参数传入以供loader1后续使用。之后我们在 loader1 中进行了data的输出。

这就是它捕获共享的作用。到这里我们已经讲解完pitch loader的3个参数了。稍后我们在手写练习中通过 style loaderpitch loader 的使用场景进行详细的解析。

五. 手写练习

在对loader有个大概的了解后,我们来手写 clean-log-loader,banner-loader,babel-loader,style-loader 这几个loader。

5.1 clean-log-loader

本loader用于清除webpack打包时检测到的所有console语句。接下来我们分步实现它。

  • 步骤1: 调整src结构
├─ src
│├─ loaders
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│└─ index.js 
  • 步骤2: 初始化 clean-log-loader 的内容
const cleanLogLoader = function(content) {// 使用正则将 content 文件中所有的 console 语句替换成空return content.replace(/console\.log\(.*\);?/g,'')
}

module.exports = cleanLogLoader 
  • 步骤3: 修改 webpack.config.js
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'}]/* rules: [{test: /\.js$/,loader: 'loader1',// enforce: 'pre'}, {// 无enforce属性,默认为 normal loadertest: /\.js$/,loader: 'loader2',}, {test: /\.js$/,loader: 'loader3',// enforce: 'post'}] *//* rules:[{test: /\.js$/,loader: 'raw-loader',}] */},// ...
} 

这里我们注释之前使用的loader。执行webpack查看打包结果。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第13张图片

可以看到 src/index.js 中的 console.log(‘Hello Cengfan!’) 语句在打包后被清除,如果我们不引用 clean-log-loader,它会被重新打包。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第14张图片

如此,一个基础的 clean-log-loader 便完成了,那这个loader还能整点活儿出来吗?是可以的,比如我们在开发中,希望保留一些关键的输出信息区别于一般的console。此时我们可以这样修改自己的loader。

  • 步骤1: 修改 clean-log-loader 的内容
const cleanLogLoader = function(content) {// 使用正则将 content 文件中所有不带 '@' 的 console 语句替换成空return content.replace(/console\.log\([^@]*\);?/g,'')
}

module.exports = cleanLogLoader 
  • 步骤2: 修改 src/index.js 的内容
console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!') 

执行webpack,查看输出结果,可以看到只有带 ‘@’ 符号的 console 语句被保留了下来。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第15张图片

这里想区别于其他console的符号你可以自定义,只要把 clean-log-loader 中 replace 的正则匹配修改成你想用的符号即可。

好了到这里我们的 clean-log-loader 就正式写完了,接下来我们开始写下一个loader。

5.2 banner-loader

本loader用于给代码添加注释信息,如作者姓名等。接下来我们分步实现它。

  • 步骤1: 调整 src 结构
├─ src
│├─ loaders
││ ├─ banner-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│└─ index.js 
  • 步骤2: 初始化 /banner-loader/schema.json 的内容,本 json 文件用于验证从 webpack.config.js 获取的 options 配置是否合法。 type,properties 用于定义接收的 options 参数类型。additionalProperties 定义是否能追加参数,如果为false,增加参数会报错。
{"type":"object","properties":{"author":{"type": "string"},"age":{"type": "number"}},"additionalProperties" : false
} 
  • 步骤3: 初始化 /banner-loader/index.js 的内容
const schema = require('./schema.json')

const bannerLoader = function (content) {// 使用引入的 schema 验证获取的 options,options 在 webpack.config.js 中使用本 loader 时传递const options = this.getOptions(schema)const prefix = `/** Author: ${options.author}* age: ${options.age}*/`;return prefix + content
}

module.exports = bannerLoader 
  • 步骤4: 修改 webpack.config.js
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'},{test:/\.js$/,loader:'banner-loader',options:{author:'Cengfan',age:18}}]// ...},// ...
} 

运行webpack查看结果。需要添加的自定义信息已被打包。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第16张图片

如果我们在配置文件中给 options 新增字段,由于 schema.json 中的 additionalProperties 属性为 false,在打包时会报错。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第17张图片

这就是我们的 banner-loader,接下来我们开始写下一个loader。

5.3 babel-loader

本loader用于将 ES next 转换为 ES5babel-loader 之前的文章已经列举过它的详细作用,这里我们通过引入官方的预设,分步手写一个 babel-loader

  • 步骤1: 由于我们需要babel的核心库和智能预设,因此要安装对应依赖

yarn add @babel/core @babel/preset-env -D

  • 步骤2: 调整 src 结构
├─ src
│├─ loaders
││ ├─ babel-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ banner-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│└─ index.js 
  • 步骤3: 初始化 /babel-loader/schema.json 的内容
{"type":"object","properties":{"presets":{"type": "array"}},"additionalProperties" : true
} 
  • 步骤4: 初始化 /babel-loader/index.js 的内容,对babel.transform()方法不了解的话,欢迎去 Babel官网 查询
const babel = require('@babel/core')
const schema = require('./schema.json')

const babelLoader = function (content) {const callback = this.async()const options = this.getOptions(schema)/* 使用 babel 编译代码param1: code 代码内容param2: options 对应的预设param3: callback 回调函数,其中 result 返回值为 { code, map, ast } 对象*/babel.transform(content, options, function (err, result) {// result 的返回值为 { code, map, ast },这里直接获取解析后的 code 传入给异步 loader 执行即可if (err) callback(err)else callback(null, result.code)})
}

module.exports = babelLoader 
  • 步骤5: 修改 src/index.js 的内容
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'},{test:/\.js$/,loader:'banner-loader',options:{author:'Cengfan',age:18,}},{test:/\.js$/,loader:'babel-loader',options:{presets: ['@babel/preset-env']}}]// ...},// ...
} 
  • 步骤6: 修改 src/index.js 的内容
const add = (...args) => args.reduce((prev,curr) => prev,curr,0)

console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!') 

全部操作完成,执行webpack查看打包结果。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第18张图片

可以看到这里用ES6声明的语法全部变成了ES5。如果不调用我们自己的babel loader打包后的结果会变成这样。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第19张图片

这就是我们的 babel-loader,接下来我们开始写下一个loader。

5.4 style-loader

本loader会动态创建 style 标签,将处理好的样式插入到 head 标签中。全体注意!本loader会充分体现 pitch loader 的使用场景。接下来我们分步实现它。

  • 步骤1: 由于需要借助页面显示样式; style-loader 无法处理引入的其他资源(如图片等)的原因,我们需要安装对应的依赖

yarn add html-webpack-plugin css-loader -D

  • 步骤2: 调整 src 结构,新增 assets,css,style-loader,index.html
├─ src
│├─ assets
││ ├─ img
││ │├─ mk3.jpg
││ │└─ mk5.jpg
││ │└─ mk6.jpg
│├─ css
││ └─ index.css
│├─ loaders
││ ├─ babel-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ banner-loader
││ │├─ index.js
││ │└─ schema.json
││ ├─ clean-log-loader
││ │└─ index.js
││ ├─ style-loader
││ │└─ index.js
││ ├─ loader1.js
││ ├─ loader2.js
││ ├─ loader3.js
││ └─ raw-loader.js
│├─ index.html
│└─ index.js 
  • 步骤3: 初始化 index.html 的内容


Webpack-loader-plugin

Webpack-loader-plugin

  • 步骤4: 初始化 index.css 的内容
h2{background: #4285f4;color: #fff;
}

.picture div{width: 300px;height: 300px;float: left;margin: 0 5px;
}

.picture div:first-child {background: url(../assets/img/mk3.jpg) no-repeat center /contain;
}

.picture div:nth-child(2) {background: url(../assets/img/mk5.jpg) no-repeat center /contain;
}

.picture div:last-child {background: url(../assets/img/mk6.jpg) no-repeat center /contain;
} 
  • 步骤5: 修改 index.js 的内容
import './css/index.css'

const add = (...args) => args.reduce((prev,curr) => prev,curr,0)

console.log('Hello Cengfan!')
console.log('@','Hello Cengfan!') 
  • 步骤6: 初始化 style-loader 的内容
const styleLoader = function(content) {// 创建 style 标签,将 css-loader 处理后的内容插入到 html 中const script = `const styleEl = document.createElement('style')styleEl.innerHTML = ${JSON.stringify(content)}document.head.appendChild(styleEl)`return script
}

module.exports = styleLoader 
  • 步骤7: 修改 webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
// ...
module.exports = {// ...module: {rules:[{test:/\.js$/,loader:'clean-log-loader'},{test:/\.js$/,loader:'banner-loader',options:{author:'Cengfan',age:18,}},{test:/\.js$/,loader:'babel-loader',options:{presets: ['@babel/preset-env']}},{test:/\.css$/,use: ['style-loader', 'css-loader'],}]// ...},// ...plugins: [new HtmlWebpackPlugin({template: resolvePath('./src/index.html'),})],// ...
} 

配置完毕,接下来运行查看打包后的html样式是否生效。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第20张图片

全不生效,诶就是玩儿!这到底是怎么回事呢?F12查看生成的style标签。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第21张图片

可以发现插入到style标签的内容也就是 style loader 接收的 content 参数,并不是我们预想的直接被 css-loader 处理好的样式内容,而是一段js脚本css-loader 会将css文件处理成commonJs模块放入js中,这样插入的内容当然不会生效。

如何解决遇到的问题?可以看到这段js脚本作为模块最后默认暴露了出来。我们需要引用这段脚本并执行它。现在摆在面前的有两种选择。

1.在style loader的 normal 阶段实现能执行js的逻辑,并获取 css loader 返回的样式内容;2.将style loader的逻辑放在 pitch 阶段,通过 pitch loader 函数的 remainingRequest 参数,获取 css loader 的相对路径,把它作为模块引入style loader中,然后让webpack通过 import 语句递归执行引入模块的运算结果。最后输出样式内容。显然,最大程度利用webpack自身特点帮我们处理事务是最优解。style loader官方 也是在 pitch 阶段处理所有逻辑。因此我们的 style loader 需要这样改进一下。

const styleLoader = function(content){}
const styleLoaderPitch = function(remainingRequest) {/* 将绝对路径:C:\Front End\projects\webpack-loader-plugin\node_modules\css-loader\dist\cjs.js!C:\Front End\projects\webpack-loader-plugin\src\css\index.css转换为相对路径:../../node_modules/css-loader/dist/cjs.js!./index.css*/const resolvePath = remainingRequest.split('!').map(absolutePath => {// 通过本 loader 所在的上下文环境和绝对路径,返回一个相对路径return this.utils.contextify(this.context,absolutePath)}).join('!')// 创建 style 标签,将 css-loader 处理后的内容插入到 html 中// '!!' 在 inline loader内跳过 pre,normal,post loader的执行,这里跳过引入的 css loader 后续阶段的自动执行const script = `import style from '!!${resolvePath}'const styleEl = document.createElement('style')styleEl.innerHTML = style document.head.appendChild(styleEl)`// 熔断后续 loader 的执行return script
}
module.exports = styleLoader
module.exports.pitch = styleLoaderPitch 

改进后运行webpack,查看打包结果。此时我们的样式全部正常显示了。还记得之前说过的 pitch loader 的使用场景吗?当前loader依赖上个loader的输出结果,且该结果为js而非webpack处理后的资源。 结合这个案例这下明白了吧。

【万字】webpack loader 概念,手写,原理一条龙唠明白_第22张图片

这就是我们的 style-loader,到这里手写loader的练习已经全部完成,相信你已经对loader有了全面的认知,接下来我们深入它的内核,了解它的原理。

六. 执行原理

本章我将按照 loader的执行流程,执行顺序,异步处理,this 这几个常见的问题跟大家进行讲解,接下来我们开始执行流程的讲解。

6.1 loader的执行流程

webpack的运行流程较为复杂不展开讲解,这里我们专注于loader部分。loader大致的执行流程为以下4个步骤。

  • 获取 webpack 默认的 loader 配置* loaderResover 解析 loader 的路径* rule.modules 创建 RuleSet 规则集* loader runner 运行匹配的 loader大白话翻译一下:获取默认配置,解析loader路径,创建规则集合,运行这个集合。 通俗易懂,简洁明了。展开的过程如下。

1.webpack 的 Compiler 对象会将用户配置 webpack.config.js 和它自己的默认配置合并。2.webpack根据配置创建 ContextModuleFactoryNormalModuleFactory 两个类,这两个类都是工厂函数,会生成对应的 ContextModuleNormalModule 的实例。工厂类的作用如下表。| ContextModuleFactory | NormalModuleFactory |
| — | — |
| 它会解析请求的目录,为每个文件生成请求,并依据传递来的 regExp 进行过滤。最后匹配成功的依赖关系将被传入NormalModuleFactory(生成依赖) | 从入口点开始,此模块会分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块实例(依据依赖生成模块实例) |

3.NormalModule 实例创建后,通过 build 方法构建模块。在构建中首先就要使用 loader runner 调用loader编译模块内容。4.输出 loader 编译后的内容,进入后续编译流程。好了这就是loader大概的执行流程,接下来我们讲解它的执行顺序。

6.2 loader的执行顺序

前文讲解过loader的执行顺序。 normal loader 从下往上,从右往左;pitch loader 与之相反。优先级权重为 pre,normal,inline,post。熔断时 pitch loader 返回上一个loader的 normal loader

这里搞清楚3个问题,loader的执行顺序也就清晰了。

1.loader的执行栈是怎么来的?2.loader runner如何运行这个执行栈?3.熔断时发生了什么?接下来我们分小节逐一解答这个问题。

6.2.1 loader的执行栈

loader的执行栈生成分两步,loader分类,生成执行栈。下面我们分步讲解。

loader分类的主要作用是将webpack获取到的loader按 pre,normal,post 分类,其中 webpack4webpack5 在逻辑上有所不同。

webpack4 中通过 this.ruleSet.exec 传入源码模块的路径,返回的 result 即为获取的loader,因此我们在 webpack.config.jsrule 对象设置的 enforce 属性也可以被获取到。获取该属性后,对loader进行分类。

for (const r of result) {if (r.type === "use") {if (r.enforce === "post" && !noPrePostAutoLoaders) {useLoadersPost.push(r.value);} else if (r.enforce === "pre" &&!noPreAutoLoaders &&!noPrePostAutoLoaders) {useLoadersPre.push(r.value);} else if (!r.enforce &&!noAutoLoaders &&!noPrePostAutoLoaders) {useLoaders.push(r.value);}} // ...
} 

webpack5 中通过 resolveRequestArray 方法对 loader 进行分类。

this.resolveRequestArray(contextInfo,this.context,useLoadersPost,loaderResolver,resolveContext,(err, result) => {postLoaders = result;continueCallback(err);}
);
this.resolveRequestArray(contextInfo,this.context,useLoaders,loaderResolver,resolveContext,(err, result) => {normalLoaders = result;continueCallback(err);}
);
this.resolveRequestArray(contextInfo,this.context,useLoadersPre,loaderResolver,resolveContext,(err, result) => {preLoaders = result;continueCallback(err);}
); 

当loader归类完成后,就需要把它们组装起来,同样 webpack4webpack5 在这里表现也不一样。

webpack4 中使用 neo-async 并行解析loader数组。

asyncLib.parallel([this.resolveRequestArray.bind(this,contextInfo,this.context,useLoadersPost,loaderResolver),this.resolveRequestArray.bind(this,contextInfo,this.context,useLoaders,loaderResolver),this.resolveRequestArray.bind(this,contextInfo,this.context,useLoadersPre,loaderResolver)],(err, results) => {// ...}
); 

最终执行栈如下:

/* results[0]: post loaderloaders: inline loaderresults[1]: normal loaderresults[2]: pre loader
*/
loaders = results[0].concat(loaders, results[1], results[2]); 

webpack5 通过调用 continueCallback 方法,将匹配到的 loaderpost,normal,pre 的顺序推入 loader 执行栈。

let postLoaders, normalLoaders, preLoaders;

const continueCallback = needCalls(3, err => {if (err) {return callback(err);}// 默认赋值为 post loaderconst allLoaders = postLoaders;if (matchResourceData === undefined) {for (const loader of loaders) allLoaders.push(loader);for (const loader of normalLoaders) allLoaders.push(loader);} else {for (const loader of normalLoaders) allLoaders.push(loader);for (const loader of loaders) allLoaders.push(loader);}for (const loader of preLoaders) allLoaders.push(loader);// ...
}); 

到这一步,loader执行栈就彻底形成了,可以发现无论是webpack4还是5,组装的执行栈顺序都是 post,inline,normal,pre 这与之前所说的loader执行顺序正好相反。别慌,因为真实的loader执行顺序其实是反向的。具体我们看下一小节的内容 loader runner 的运行

6.2.2 loader runner的运行

loader runner 用于运行 loader。它运行 loader 时对应的 pitch,normal 2个阶段,分别对应 loader runner 中的 iteratePitchingLoaders,iterateNormalLoaders 2个方法。

iteratePitchingLoaders 会递归执行,同时记录 loader 的 pitch 状态,与当前的 loaderIndex。当它达到最大值(loader执行栈的长度)时,即所有loader的pitch loader已经执行完毕后,开始处理实际的module。此时调用 processResource 方法处理模块资源(添加当前模块为依赖,读取模块内容)。然后 loaderIndex–,并递归执行 iterateNormalLoaders

// abort after last loader
if(loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback); 
// iterate
if(currentLoaderObject.pitchExecuted) {loaderContext.loaderIndex++;return iteratePitchingLoaders(options, loaderContext, callback);
} 
loaderContext.loaderIndex--;
iterateNormalLoaders(options, loaderContext, args, callback); 

这套流程走下来就完成了loader在 pitch,normal 阶段的执行顺序。

6.2.3 熔断原理

来,全体目光向这段代码看齐。要记得在 loader runner 中,当 loaderIndex 达不到 loader 本身的长度时(有 pitch loader 提前 return 发生了熔断)时, processResource 这个方法是不会触发的,这就导致 addDependency 这个方法也不会触发,因此不会将该模块资源添加进依赖,无法读取模块的内容。继而熔断后续操作。

if(loaderContext.loaderIndex >= loaderContext.loaders.length) return processResource(options, loaderContext, callback); 

好了到这里 loader 的执行顺序就彻底讲解完毕了,下面进入异步处理的讲解。

6.3 loader的异步处理

无论是 loaderpitch 还是 normal 阶段,最终是在 loader runnerrunSyncOrAsync 方法中执行。

在loader中调用 this.async 时,实际是将 loaderContext 上的 async 属性赋值为一个函数。isSync 变量默认为 true,当 loader 中使用 this.async 时,它被置为 false,并返回一个 innerCallback 作为异步回调完成的通知。

context.async = function async() {if(isDone) {if(reportedError) return; // ignorethrow new Error("async(): The callback was already called.");}isSync = false;return innerCallback;
} 

isSynctrue 时,会在 loader function 执行完毕后同步回调 callback 继续 loader runner 的执行流程。

if(isSync) {isDone = true;if(result === undefined)return callback();if(result && typeof result === "object" && typeof result.then === "function") {return result.then(function(r) {callback(null, r);}, callback);}return callback(null, result);
} 

到这里loader的同步,异步原理就彻底讲解完毕了,下面我们讲讲loader中的this。

6.3 loader中的this

webpack官方对于loader的定义中有这样一段,函数中的 this 作为上下文会被 webpack 填充。那这个this到底是什么呢?是webpack的实例吗,其实不是,这个this是 loader runner 中的 loaderContext,我们熟悉的 async,callback 等都来自于这个对象。

// prepare loader objects
loaders = loaders.map(createLoaderObject);

loaderContext.context = contextDirectory;
loaderContext.loaderIndex = 0;
loaderContext.loaders = loaders;
loaderContext.resourcePath = resourcePath;
loaderContext.resourceQuery = resourceQuery;
loaderContext.resourceFragment = resourceFragment;
loaderContext.async = null;
loaderContext.callback = null;
// ... 

好了,到这里loader关键部分的原理已经全部讲解完毕了,如有疏漏欢迎在评论区补充。

最后

最近找到一个VUE的文档,它将VUE的各个知识点进行了总结,整理成了《Vue 开发必须知道的36个技巧》。内容比较详实,对各个知识点的讲解也十分到位。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

你可能感兴趣的:(webpack,javascript,前端)