前言
本文主要针对 webpack 中的 laoder 和 plugins 进行学习,不涉及如何使用和配置 webpack,因为这些基础在官方文档中已经很明确了,重点在于如何去实现属于自定义的 laoder 和 plugins。那么在开始前,先简单的介绍下什么叫构建工具。
构建工具
在 web 应用程序中,除了 HTML 文件之外,往往还需要用的很多的其他静态资源加以修饰,比如在 HTML 中使用的 图片、css 样式、js文件等等,但是浏览器并不能识别所有的文件资源,并正确的加载。
因此,开发者需要对不同的文件资源进行对应的处理操作,目的是为了能够正确的加载和使用对应的文件资源。比如:
- 图片除了常用的一些格式能被正常加载和显示之外,一些特殊的格式就无法直接使用;
- css 样式我们可能会使用 less / scss / css in js 等方式去使用;
- js 文件中可能使用了比较新的语法,如 ES6 、ES7 以至于更新的特性,需要对应的编译工具去做转换等等。
由于需要针对不同的文件资源做不同的处理,并且还要考这些用于处理文件资源工具的维护问题,因此就诞生了构建工具。
构建工具就是包含了处理大多数以上提及到问题的解决方案,意味着原本我们需要不同的小工具去处理不同的文件内容,但是现在只需要关注构建工具本身如何使用即可。
webpack
webpack 是什么?
webpack 是众多构建工具中的一种,它也是一个用于现代 JavaScript 应用程序的 静态模块打包工具。
当 webpack 处理应用程序时,它会在内部从 一个 或 多个 入口点去构建一个 依赖图,然后将项目中所需的每一个模块组合成一个或多个 bundles ,它们均为 静态资源,用于展示你的内容。
其中涉及到的 chunk 和 bundles 的概念,可以根据下图来辅助理解:
- 根据引入的各种文件资源,形成对应的 依赖图 ,其中包含了要处理的 代码块 chunk
- 将 代码块 chunk 进行对应的处理,也称之为打包,输出之后就得到了需要的 bundles
五大核心
mode
- 可选值:
development
,production
,none
- 设置
mode
参数,可以启用 webpack 内置在相应环境下的默认优化 - mode 参数默认值为
production
entry
入口起点(entry point) 指示 webpack 应该使用哪个文件作为入口模块,用来作为构建其内部依赖图,可以拥有多个入口文件。
output
output 负责告诉 webpack 需要在哪里输出它所创建的 bundle,以及怎么去命名这些文件.
- 默认输出目录:
./dist
- 默认主要输出文件名: ./dist/main.js
- 其他生成文件默认放在
./dist
下
loader
webpack 只能理解 JavaScript 和 JSON 文件,开箱的 webapck 没办法识别其他文件类型。loader 就能够把这些文件类型转换成 weback 能识别的资源,并将它们转换为有效 模块,以便于在应用程序中去使用,同时也会被添加到依赖图中。
plugin
loader 用于转换某些类型的模块,而 plugin 则可以用于执行包括 loader 在内的、范围更广的任务。比如:打包优化,资源管理,注入环境变量等。
- 可以通过 require 引入对应plugin 插件 ,并在选项配置
plugins
的数组中 实例化调用 new PluginName(opt) - 可以自定义 webpack 插件实现具体场景的需求
loader
在 webpack 中 loader 是什么?
loader 本质就是一个函数,这个函数会接收三个参数:
content
:对应模块的内容map
:对应模块的 sourcemapmeta
:对应模块的元数据
loader 执行顺序
通常 loader 的书写结构决定了对执行顺序的描述:
- 左右结构 ------> 执行顺序为 从右往左
- 上下结构 ------> 执行顺序为 从下往上
为了更清晰和直观,下面列出了一个在 webpack 配置中和样式相关的常见配置:
js
module: {
rules: [
{
test: /\.css$/,
// 左右结构
use: ['style-loader', 'css-loader'],
// 或
// 上下结构
use: [
'style-loader',
'css-loader'
]
}
]
}
无论是 左右结构 还是 上下结构 ,都可以统一理解为 从后往前 的顺序去执行.
自定义 loader
- 新建 loader1.js 和 loader2.js 作为自定义 loader ,注意除了向外暴露的函数方法以外,还给这个函数对象上添加了一个 pitch 方法,内容具体如下:
pitch 方法执行顺序和 loader 是相反的,也就是说 pitch 是 从前往后 的顺序去执行.
js
// loader1.js
module.exports = function(content, map, meta) {
console.log('loader1 ...');
return content;
}
module.exports.pitch = function (){
console.log('loader1 pitch...');
}
// loader2.js
module.exports = function(content, map, meta) {
console.log('loader2 ...');
return content;
}
module.exports.pitch = function (){
console.log('loader2 pitch...');
}
- 并在 webpack.config.js 中进行配置,内容如下:
js
// webpack.config.js
const { resolve } = require('path');
module.exports = {
mode: 'production',
module: {
rules: [
{
test: /\.js$/,
use: [
resolve(__dirname, 'loaders/loader1.js'),
resolve(__dirname, 'loaders/loader2.js'),
]
},
]
}
}
- 为了简化每次引入自定义 loader 时,都要写完整路径,如:
resolve(__dirname, 'loaders/xxx.js)
,因此可以通过配置 resolveLoader 选项统一指定 loader 要查找的路径,具体如下:
js
// webpack.config.js
const { resolve } = require('path');
module.exports = {
mode: 'production',
module: {
rules: [
{
test: /\.js$/,
use: [
'loader1',
'loader2',
]
},
]
},
resolveLoader: {
modules: [
resolve(__dirname, 'loaders'),
'node_modules'
],
}
}
- 当在编辑器终端输入 webpack 指令进行打包时,控制台输出结果如下:
loader 的同步和异步
同步 loader
在 自定义 loader 中书写 loader 的方式就属于同步 loader ,当然还有另一种写法,那就是通过调用 this.callback() 方法,可以将上述 自定义 loader 中的写法进行改写,具体如下:
this.callback(error, content, map, meta) ,其中 error 表示错误内容,当没有错误时,可将其执行为 null . 使用这样的方式,就不需要在显式的进行 return.
js
// loader1.js
module.exports = function(content, map, meta) {
console.log('loader1 ...');
this.callback(null, content, map, meta);
}
module.exports.pitch = function (){
console.log('loader1 pitch...');
}
// loader2.js
module.exports = function(content, map, meta) {
console.log('loader1 ...');
this.callback(null, content, map, meta);
}
module.exports.pitch = function (){
console.log('loader1 pitch...');
}
异步 loader
异步 loader 需要通过 const callBack = this.async();
方法进行指定,然后通过调用 callBack() 方法表明异步执行完成.
可以将 loader2.js 变为异步 loader,改造内容和运行结果如下:
js
// loader2.js
module.exports = function(content, map, meta) {
console.log('loader2 ...');
const callback = this.async();
setTimeout(()=>{
callback(null,content, map, meta);
},1000);
}
module.exports.pitch = function (){
console.log('loader2 pitch...');
}
PS: 当执行到 loader2 时,会先等待 1s 左右,然后在执行 loader1 . 同时 compiled successfully 的时间明显比之前更多.
对 loader 中的 options 进行合法校验
为什么需要校验合法性?
向外提供了 options 配置是为了让自定义 loader 具有更高的灵活性和可配置性,但是这样的灵活性如果没有得到约束,那么 options 配置可能就变得没有意义。试想一下,外部使用时传递了一堆 loader 中根本用不到的配置,除了让配置看起来更复杂之外,也会让 loader 内部的各种判断逻辑进行无用的执行。基于以上种种原因,对 options 的合法校验显得尤为重要,只有在校验通过之后再去执行 loader 中的其他处理程序。
获取 loader 中的 options 配置
要对 options 进行合法校验,首先就得获取 options,获取方式有 2 种:
- 通过
const options = this.getOptions()
的方式获取 - 通过调用
loader-utils
库中的getOptions(this)
方法获取
校验合法性
可以通过 schema-utils
库中的 validate()
方法进行校验.
通过一个例子进行直观的理解,首先在 webpack.config.js 中修改配置,也就是给 loader1 传入 options 配置,然后对 loader1.js 中的内容进行改写,如下:
js
// webpack.config.js
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'loader1',
options: {
name: 'this is a name!'
}
},
'loader2',
]
},
]
}
// loader1.js
const { validate } = require('schema-utils');
// schema 意为模式,定义校验规则
const loader1_schema = {
type: "object",
properties: {
name: {
type: 'string',
},
},
// additionalProperties 代表是否可以追加属性
additionalProperties: true
};
module.exports = function (content, map, meta) {
console.log('loader1 ...');
// 获取 options
const options = this.getOptions();
console.log('loader1 options = ',options);
// 校验 options 是否合法
validate(loader1_schema, options,{
name: 'loader1',
baseDataPath: 'options',
});
this.callback(null, content, map, meta);
}
module.exports.pitch = function () {
console.log('loader1 pitch...');
}
在 webpack.config.js 中进行合法配置:
js
{
loader: 'loader1',
options: {
name: 'this is a name!'
}
}
在 webpack.config.js 中进行非法配置:
js
{
loader: 'loader1',
options: {
name: false
}
}
实现自定义 loader ------ vueLoader
功能描述
针对 .vue 文件中的 <template> 、<script>、<style>
三部分进行拆分,并且重组到一个 .html
文件中.
webapck.config.js
js
const { resolve } = require('path');
module.exports = {
mode: 'production',
module: {
rules: [
{
test: /\.vue$/,
use: {
loader: 'vueLoader',
options: {
template: {
path: resolve(__dirname, 'src/index.html'),
fileName: 'app',
},
name: 'app',
title: 'Home Page',
reset: true
}
}
},
]
},
resolveLoader: {
modules: [
resolve(__dirname, 'loaders'),
'node_modules'
],
}
}
vueLoader.js
js
const { validate } = require('schema-utils');
const fs = require('fs');
const { resolve } = require('path');
const vueLoader_schema = {
type: "object",
properties: {
template: {
type: 'object',
properties: {
path: { type: 'string' },
fileName: { type: 'string' }
},
additionalProperties: false
},
name: {
type: 'string',
},
title: {
type: 'string',
},
reset: {
type: 'boolean',
}
},
additionalProperties: false
};
module.exports = function (content, map, meta) {
const options = this.getOptions();
const regExp = {
template: /<template>([\s\S]+)<\/template>/,
script: /<script>([\s\S]+)<\/script>/,
style: /<style.+>([\s\S]+)<\/style>/,
};
validate(vueLoader_schema, options, {
name: 'vueLoader',
baseDataPath: 'options',
});
let template = '';
let script = '';
let style = '';
if (content.match(regExp.template)) {
template = RegExp.$1;
}
if (content.match(regExp.script)) {
let match = RegExp.$1;
let name = match.match(/name:(.+),?/)[1].replace(/("|')+/g,'');
script = match.replace(/export default/, `const ${name} = `);
}
if (content.match(regExp.style)) {
style = RegExp.$1;
}
let { path, fileName } = options.template;
fileName = fileName || path.substring(path.lastIndexOf('\\') + 1, path.lastIndexOf('.html'));
fs.readFile(path, 'utf8', function (error, data) {
if (error) {
console.log(error);
return false;
}
const innerRegExp = {
headEnd: /<\/head>/,
bodyEnd: /<\/body>/,
};
content = data
.replace(innerRegExp.headEnd, (match, p1, index, origin) => {
let resetCss = "";
if (options.reset) {
resetCss = fs.readFileSync(resolve(__dirname, 'css/reset.css'), 'utf-8')
}
let rs = `<style>${resetCss} ${style}</style></head>`;
return rs;
})
.replace(innerRegExp.bodyEnd, (match, p1, index, origin) => {
let rs = `${template}<script>${script}</script></body>`;
return rs;
});
if (options.title) {
content = content.replace(/<title>([\s\S]+)<\/title>/, () => {
return `<title>${options.title}</title>`
});
}
fs.writeFile(`dist/${fileName}.html`, content, 'utf8', function (error) {
if (error) {
console.log(error);
return false;
}
console.log('Write successfully!!!');
});
});
return "";
}
plugins
在 webpack 中 plugin 是什么?
webpack 中的 plugin 由以下组成:
- 一个 JavaScript 命名函数 或 JavaScript 类
- 在插件函数的 prototype 上定义一个
apply()
方法 - 指定一个绑定到 命名函数 自身的 事件钩子
- 处理 webpack 内部实例的特定数据
- 功能完成后调用 webpack 提供的回调
下面是一个 plugin 的基本结构:
apply
中的tap()
方法来绑定同步操作,但有些 plugin 需要进行是异步操作,这时候可以使用tapAsync()
或tapPromise()
这两个异步方法来绑定。当使用tapAsync
方式时,回调参数会多一个callback
用于指明异步处理是否结束;当时用tapPromise
方式时,要在其内部返回一个Promise
对象,通过改变Promise
状态来指明异步处理的结果。
js
class TestWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tap('TestWebpackPlugin', (compilation) => {
console.log('tap callBack ...');
// 返回 true 以输出 output 结果,否则返回 false
return true;
});
compiler.hooks.emit.tapAsync('TestWebpackPlugin', (compilation, callback) => {
setTimeout(() => {
console.log('tapAsync callBack ...');
callback();
}, 2000);
});
compiler.hooks.emit.tapPromise('TestWebpackPlugin', (compilation) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('tapPromise callBack ...');
resolve();
}, 1000);
});
});
}
}
module.exports = TestWebpackPlugin;
// 输出顺序:
// 1. tap callBack ...
// 2. tapAsync callBack ...(等待前面的 tap 执行完毕,2s 后输出)
// 3. tapPromise callBack ...(等待前面的 tapAsync 执行完毕,1s 后输出)
plugin 中的执行顺序
从上面的例子中,可以看出其执行顺序为:
- 不同 hooks 的执行时机可以参考 生命周期钩子函数,执行时机决定了执行顺序
- 同一个 plugin 中的同一个 hooks 中注册的回调,会按串行顺序执行,即便其中包含了 异步操作
对 plugin 中的 options 进行合法校验
这一点和 loader
中的校验一样,都需要使用 schema-utils
中的 validate()
方法进行校验。和 loader
中不一样的就是,plugin
中的 options
不需要通过 this.getOptions()
的方式获取,因为 plugin
是一个 class 或者是 构造函数 ,因此可以直接在 constructor
中直接进行获取。
实现自定义 plugin ------ CopyWebpackPlugin
功能描述
把 指定目录 下的所有文件复制到 目标目录,支持忽略某些文件.
webpack.config.js
js
const CopyWebpackPlugin = require('./plugins/CopyWebpackPlugin');
module.exports = {
mode:'none',
plugins: [
new CopyWebpackPlugin({
from: './public',
to: 'dist',
ignores: ['notCopy.txt']
})
]
};
CopyWebpackPlugin.js
js
const { validate } = require('schema-utils');
const { join, resolve, isAbsolute, basename } = require('path');
const { promisify } = require('util');
const fs = require('fs');
const webapck = require('webpack');
const { RawSource } = webapck.sources;
const readdir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);
const schema = {
type: 'object',
properties: {
from: {
type: 'string',
},
to: {
type: 'string',
},
ignores: {
type: 'array',
},
},
additionalProperties: false,
}
class CopyWebpackPlugin {
constructor(options = {}) {
this.options = options;
// 校验 options 合法性
validate(schema, options);
}
apply(compiler) {
compiler.hooks.emit.tapAsync('CopyWebpackPlugin', async (compilation, callback) => {
let { from, to = '.', ignores = [] } = this.options;
// 运行指令的目录
let dir = process.cwd() || compilation.options.context;
// 判断传入的路径是否为绝对路径
from = isAbsolute(from) ? from : resolve(dir, from);
// 1. 获取 form 目录下所以文件或文件夹名称
let dirFiles = await readdir(from, 'utf-8');
// 2. 通过 ignores 进行过滤文件或文件夹名称
dirFiles = dirFiles.filter(name => !ignores.includes(name));
// 3. 读取 form 目录下所有文件
const files = await Promise.all(dirFiles.map(async (name) => {
const fullPath = join(from, name);
const data = await readFile(fullPath);
const filename = join(to, basename(fullPath));
return {
data,// 文件内容数据
filename,// 文件名
};
}));
// 4. 生成 webpack 格式的资源
const assets = files.map(file => {
const source = new RawSource(file.data);
return {
source,
filename: file.filename,
};
});
// 5. 添加到 compilation 中,向外输出
assets.forEach((asset) => {
compilation.emitAsset(asset.filename, asset.source);
});
// 6. 通过 callback 指明当前处理完成
callback();
});
}
}
module.exports = CopyWebpackPlugin;