解决html-webpack-plugin和html-loader冲突问题

衡子安
2023-12-01

近期公司有个多页面的网站需要开发,选择用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了。

 类似资料: