近期公司有个多页面的网站需要开发,选择用webpack构建项目。
在编写webpack config 的过程中,发现html-webpack-plugin和html-loader有冲突。
如果使用html-loader来处理html模版文件中的url,会导致html-webpack-plugin的ejs模版语法失效。
经过研究发现html-loader会把原始的html模版,编译成一个js模块样式的字符串,导致html-webpack-plugin解析的时候,发现文件已经被编译了,会直接跳过模版语法的检测。
解决方案就是在html-loader执行之前,执行一个自定义的loader来预先编译自定义的模版语法。
下面是我自己用ts开发的一个html模版解析loader, include-template-loader。
目录结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | ├── src │ ├── index.ts │ ├── interface.ts │ ├── schema │ │ └── schema.ts │ └── utils │ ├── options.util.ts │ └── template-parser.util.ts ├── test │ ├── __mock__ │ │ ├── footer.html │ │ ├── header.html │ │ ├── index.html │ │ ├── invalid-params.html │ │ ├── nested.html │ │ ├── sub-sub.html │ │ ├── sub.html │ │ └── without-params.html │ ├── include-htm-loader.spec.ts │ ├── options.util.spec.ts │ └── template-parser.util.spec.ts ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── tsconfig.json |
开始
webpack loader 实际上就是一个默认导出的函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // index.ts import * as webpack from 'webpack'; import {Options} from './interface'; import {validateOptions, mergeOptions} from './utils/options.util'; import {templateParser} from './utils/template-parser.util'; export default function includeHtmlLoader( this: webpack.loader.LoaderContext, source: string ) { const defaultOptions: Options = { sign: ['{{', '}}'], deep: 5 }; const options = mergeOptions(defaultOptions, validateOptions(this)); return templateParser(this, source, options); // 具体模版替换逻辑就在这个函数里面 } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // utils/options.util.ts import {loader} from 'webpack'; import {getOptions} from 'loader-utils'; import {schema} from '../schema/schema'; import {Options} from '../interface'; import validate from 'schema-utils'; /** * 验证传入选项的正确性 * @param {loader.LoaderContext} self * @returns {Options} */ export const validateOptions = ( self: loader.LoaderContext ): Partial<Options> => { const options = getOptions(self); validate(schema as any, options); return options; }; export const mergeOptions = ( defaultOptions: Options, options: Partial<Options> ): Options => { return Object.assign(defaultOptions, options); }; |
下面是核心编译函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | // utils/template-parser.util.ts import {Options, Sign, TemplateRules} from '../interface'; import {loader} from 'webpack'; import {parse, resolve} from 'path'; import {readFileSync} from 'fs'; import {getCurrentRequest} from 'loader-utils'; /** * 获取模板替换的规则 * @param sign 模板标记 */ const getRules = (sign: Sign): TemplateRules => { const [start, end] = sign; return { include: new RegExp( `${start}\\s*@include\\(\\s*['"](.*)['"](?:,\\s*({(?:\\S|\\s)*?}))?\\s*\\)?\\s*${end}`, 'g' ), variable: new RegExp(`${start}\\s*(?!@include)(.*?)\\s*${end}`, 'g') }; }; /** * 模板替换 * @param content 模板内容 * @param templatePath 需要被替换的模板路径 * @param rules 替换规则 * @param deep 递归替换的深度 * @param dependenciesUrl 替换模板的路径集合,用于添加到webpack,实现watch * @returns 替换完成的字符和模板路径集合 */ const templateReplace = ( content: string, templatePath: string, rules: TemplateRules, deep: number, dependenciesUrl = new Set<string>() ): [string, Set<string>] => { // 解析模版的递归函数 const invoke = ( content: string, templatePath: string, rules: TemplateRules, deep: number ): string => { // 取出正则匹配的规则 const {include, variable} = rules; // 模版替换的执行函数 const handle = (_match: string, $1: string, $2 = '{}') => { let templateParams: {[key: string]: any}; // 解析参数,如果参数不是一个json字符串,会抛出异常 try { templateParams = JSON.parse($2); } catch { throw new Error('Parameter format error, unable to parse into JSON'); } // 获取子模版的路径,用于读取模版内容 const {dir} = parse(templatePath); const templateUrl = resolve(dir, $1); // 添加子模版路径到依赖Set dependenciesUrl.add(templateUrl); // 读取子模版内容 let templateContent = readFileSync(templateUrl, 'utf8'); // 替换子模版中的变量 templateContent = templateContent.replace( variable, (_, $1) => templateParams[$1] || '' ); // 如果子模版中嵌入后代模版同时没有超过解析的最大深度,递归调用invoke if (--deep > 0 && include.test(templateContent)) { return invoke(templateContent, templateUrl, rules, deep); } return templateContent; }; const replacedContent = content.replace(include, handle); return replacedContent; }; // 返回一个元组,第一项是替换成功之后的字符串,第二项是所有依赖的子模版的Set return [invoke(content, templatePath, rules, deep), dependenciesUrl]; }; /** * 解析模板 * @param self loader上下文对象 * @param source 原始字符串 * @param options 配置对象 * @returns 返回编译后的字符串 */ export const templateParser = ( self: loader.LoaderContext, source: string, options: Options ) => { const {sign, deep} = options; const rules = getRules(sign); // 通过loader-utils包提供的getCurrentRequest方法,获取当前文件的路径 const basePath = getCurrentRequest(self).split('!').pop() as string; // 调用模版替换方法 const [content, dependenciesUrl] = templateReplace( source, basePath, rules, deep ); // 添加到webpack依赖实现watch dependenciesUrl.forEach(item => { self.addDependency(item); }); // 返回最终处理好的html模版字符串 return content; }; |
以上就是整个loader的完整代码
总结
webpack loader 实现原理很简单,就是一个拥有默认导出函数的模块,入参是上一个loader执行完毕后的字符串。
本项目有完整的单元测试,覆盖率100%,如果在webpack项目中有需要的可以直接通过npm下载使用,命令行中运行