对 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 中的内容进行改写,如下:
// 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 中进行合法配置:
{ loader: 'loader1', options: { name: 'this is a name!' } } 复制代码
在 webpack.config.js 中进行非法配置:
{ loader: 'loader1', options: { name: false } } 复制代码
实现自定义 loader —— vueLoader
功能描述
针对 .vue 文件中的 <template> 、<script>、<style>
三部分进行拆分,并且重组到一个 .html
文件中.
webapck.config.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
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
状态来指明异步处理的结果。
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
const CopyWebpackPlugin = require('./plugins/CopyWebpackPlugin'); module.exports = { mode:'none', plugins: [ new CopyWebpackPlugin({ from: './public', to: 'dist', ignores: ['notCopy.txt'] }) ] }; 复制代码
CopyWebpackPlugin.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;