近期公司有个多页面的网站需要开发,选择用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。
├── 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 实际上就是一个默认导出的函数
// 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); // 具体模版替换逻辑就在这个函数里面
}
// 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);
};
下面是核心编译函数
// 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下载使用,命令行中运行npm install include-template-loader -D
,在webpack配置中添加本loader在html-loader之前执行就ok了。