创建一个自定义的 Webpack Loader 可以让你在 Webpack 构建过程中预处理文件。Loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript,或者将内联图像转换为 data URL。Loader 甚至允许你直接在 JavaScript 模块中 import
CSS 文件!
下面将创建一个名为 markdown-plus-loader
的 Loader,它能够:
- 将 Markdown 文件转换为 HTML。
- 提取 Markdown 文件中的 Frontmatter (YAML 格式的元数据)。
- 支持通过选项配置 Markdown 解析器的行为。
- 支持集成
markdown-it
插件 (例如用于代码高亮、TOC 生成等)。 - (可选高级功能) 识别 Markdown 中的自定义组件占位符,并将其信息导出,以便在运行时动态加载或渲染这些组件。
- 最终输出一个 JavaScript 模块,该模块导出 HTML 内容、Frontmatter 数据以及其他处理过的信息。
Webpack Loader 基础
什么是 Loader?
Loader 是导出为一个函数的 Node.js 模块。这个函数在 Webpack 解析模块请求时被调用。Loader 的 this
上下文由 Webpack 填充了一些有用的方法和属性,允许 Loader 访问配置、发出文件、报告错误等。
Loader 的类型
- 同步 Loader : 可以简单地
return
一个值或调用this.callback(null, result, sourceMap?, meta?)
。 - 异步 Loader : 必须调用
this.async()
来告诉 Webpack Loader 将异步执行,并在完成后调用this.callback(...)
。
Loader API (this
上下文)
在 Loader 函数内部,this
指向一个 "loader context" 对象,包含以下常用属性和方法:
this.version
: Loader API 版本。this.context
: 当前处理文件的目录。this.resourcePath
: 当前处理文件的完整路径。this.resourceQuery
: 文件的查询参数 (例如file.js?query
)。this.getOptions()
: 获取传递给 Loader 的选项 (推荐使用loader-utils
的getOptions
)。this.cacheable(flag = true)
: 标记 Loader 的输出是否可缓存。默认是可缓存的。this.async()
: 返回this.callback
,用于异步操作。this.callback(err, content, sourceMap?, meta?)
: 用于返回多个结果(内容、Source Map、抽象语法树 AST 等)。this.emitFile(name, content, sourceMap)
: 生成一个文件。this.emitWarning(warning)
: 发出一个警告。this.emitError(error)
: 发出一个错误。this.addDependency(file)
: 添加文件依赖,当此文件变化时,会重新执行 Loader。this.addContextDependency(directory)
: 添加目录依赖。this.fs
: Webpack 使用的输入文件系统。
Pitching Loader
Loader 还可以有一个 pitch
方法。pitch
方法会在所有 Loader 的实际执行(从右到左,从下到上)之前,按相反顺序(从左到右,从上到下)执行。如果任何 pitch
方法返回了一个值(不是 undefined
),那么它右边的 Loader (包括它自己的正常执行方法) 将被跳过。
项目结构
我们将只创建一个 Loader 文件,但会讨论如何组织相关工具。
go
my-webpack-loaders/
├── markdown-plus-loader.js // 我们的自定义 Loader
├── package.json
└── (未来可能有 utils/, schemas/ 等)
1. 初始化项目和安装依赖
bash
mkdir my-webpack-loaders
cd my-webpack-loaders
npm init -y
安装必要的依赖:
loader-utils
: 提供获取选项等工具函数。schema-utils
: 用于验证 Loader 选项。front-matter
: 用于解析 YAML Frontmatter。markdown-it
: 一个强大且可扩展的 Markdown 解析器。- (可选)
highlight.js
或prismjs
(如果要做复杂代码高亮)。 - (可选) 各种
markdown-it
插件。
bash
npm install loader-utils schema-utils front-matter markdown-it --save-dev
# 示例安装 markdown-it 插件
npm install markdown-it-anchor markdown-it-table-of-contents markdown-it-emoji --save-dev
# 如果需要代码高亮
npm install highlight.js --save-dev
2. 创建 markdown-plus-loader.js
这是我们 Loader 的核心代码。
js
// my-webpack-loaders/markdown-plus-loader.js
"use strict";
const { getOptions } = require('loader-utils');
const { validate } = require('schema-utils');
const fm = require('front-matter');
const MarkdownIt = require('markdown-it');
const hljs = require('highlight.js'); // 用于代码高亮
// Loader 选项的 JSON Schema,用于验证
const schema = {
type: 'object',
properties: {
html: { type: 'boolean', description: '在源码中启用 HTML 标签' },
xhtmlOut: { type: 'boolean', description: '使用 XHTML 兼容输出 (<br /> 代替 <br>)' },
breaks: { type: 'boolean', description: '转换段落里的 \n 到 <br>' },
langPrefix: { type: 'string', description: '给代码块添加的 CSS 语言前缀' },
linkify: { type: 'boolean', description: '自动转换链接类文本为链接' },
typographer: { type: 'boolean', description: '启用一些替换和美化标点' },
quotes: { type: 'string', description: '双引号/单引号替换对,例如 '""'''' },
markdownItOptions: { type: 'object', description: '直接传递给 MarkdownIt 构造函数的额外选项' },
plugins: {
type: 'array',
description: '要使用的 markdown-it 插件列表及其选项',
items: {
anyOf: [
{ type: 'string', description: '插件名称或路径' },
{
type: 'object',
properties: {
plugin: { description: '插件本身或其名称/路径' }, // 可以是函数或模块名
options: { type: 'array', description: '传递给插件的选项数组' }
},
required: ['plugin']
}
]
}
},
customBlockSyntax: {
type: 'object',
description: '自定义块级组件的解析规则',
properties: {
enabled: { type: 'boolean' },
// 例如: @[ComponentName](./path/to/component.js){"prop1":"value"}
regex: { type: 'string', description: '用于匹配自定义块的正则表达式字符串' },
placeholderTag: { type: 'string', description: '替换自定义块的 HTML 占位符标签' }
},
additionalProperties: false
},
wrapperClass: {
type: 'string',
description: '包裹最终 HTML 输出的 div 的 CSS 类名',
default: 'markdown-body'
},
exportFormat: {
type: 'string',
enum: ['module', 'html', 'vue-sfc'],
description: '输出格式: "module" (JS 对象), "html" (纯 HTML 字符串), "vue-sfc" (Vue 单文件组件)',
default: 'module'
},
vueSfcOptions: {
type: 'object',
description: '当 exportFormat 为 "vue-sfc" 时的选项',
properties: {
style: { type: 'string', description: 'SFC 中的 <style> 内容' },
scriptSetup: { type: 'boolean', description: '是否使用 <script setup>' },
componentName: { type: 'string', description: 'Vue 组件名', default: 'MarkdownComponent' }
},
additionalProperties: false
}
},
additionalProperties: true // 允许 markdown-it 本身的选项直接在顶层传递
};
/**
* MarkdownPlusLoader 主函数
* @param {string} source Markdown 源文件内容
*/
module.exports = function MarkdownPlusLoader(source) {
// 0. 获取并验证选项
// -------------------------------------------------------------------------
const options = getOptions(this) || {};
validate(schema, options, {
name: 'Markdown Plus Loader',
baseDataPath: 'options',
});
// 1. 将 Loader 标记为可缓存
// -------------------------------------------------------------------------
// 如果 Loader 的输入和依赖没有改变,Webpack 可以重用缓存的输出。
// 这是默认行为,但明确指出是个好习惯。
if (this.cacheable) {
this.cacheable(true);
}
// 2. 解析 Frontmatter
// -------------------------------------------------------------------------
let frontmatter = {};
let content = source; // Markdown 正文内容
try {
if (fm.test(source)) {
const parsed = fm(source);
frontmatter = parsed.attributes;
content = parsed.body;
// 如果 frontmatter 中有文件依赖,可以添加到 Webpack
// 例如: if (frontmatter.template) this.addDependency(path.resolve(this.context, frontmatter.template));
}
} catch (e) {
this.emitError(new Error(`Error parsing frontmatter: ${e.message}`));
// 即使 frontmatter 解析失败,也尝试处理剩余内容
}
// 3. 初始化 Markdown-it
// -------------------------------------------------------------------------
// 合并直接传递给 markdown-it 的选项和我们 schema 中定义的特定选项
const mdOptions = {
html: options.html !== undefined ? options.html : true,
xhtmlOut: options.xhtmlOut !== undefined ? options.xhtmlOut : false,
breaks: options.breaks !== undefined ? options.breaks : false,
langPrefix: options.langPrefix !== undefined ? options.langPrefix : 'language-',
linkify: options.linkify !== undefined ? options.linkify : true,
typographer: options.typographer !== undefined ? options.typographer : true,
quotes: options.quotes !== undefined ? options.quotes : '""''',
highlight: (str, lang) => { // 默认的代码高亮逻辑
if (lang && hljs.getLanguage(lang)) {
try {
const highlightedCode = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
// 返回符合 markdown-it 期望的 HTML 结构
return `<pre class="hljs ${options.langPrefix || 'language-'}${lang}"><code>${highlightedCode}</code></pre>`;
} catch (__) {
// 高亮失败,回退到普通代码块
}
}
// 无语言或高亮失败,仅进行 HTML 转义
const escapedCode = mdInstance.utils.escapeHtml(str);
return `<pre class="hljs"><code>${escapedCode}</code></pre>`;
},
...(options.markdownItOptions || {}), // 用户自定义的 markdown-it 原始选项
};
const mdInstance = new MarkdownIt(mdOptions);
// 4. 加载和应用 Markdown-it 插件
// -------------------------------------------------------------------------
if (options.plugins && Array.isArray(options.plugins)) {
options.plugins.forEach(pluginConfig => {
try {
if (typeof pluginConfig === 'string') {
// 简单插件名,尝试 require 和 use
// 注意:直接在 loader 中 require 用户指定的字符串模块名可能存在安全风险
// 更好的做法是让用户传递插件函数本身或预定义一组支持的插件
const plugin = require(pluginConfig); // 需要确保这些插件已安装
mdInstance.use(plugin);
} else if (pluginConfig && typeof pluginConfig.plugin !== 'undefined') {
let pluginToUse = pluginConfig.plugin;
if (typeof pluginToUse === 'string') {
pluginToUse = require(pluginToUse); // 同上,需要确保已安装
}
const pluginOpts = pluginConfig.options || [];
mdInstance.use(pluginToUse, ...pluginOpts);
} else {
this.emitWarning(`Invalid plugin configuration: ${JSON.stringify(pluginConfig)}`);
}
} catch (e) {
this.emitError(new Error(`Failed to load or use markdown-it plugin "${JSON.stringify(pluginConfig)}": ${e.message}`));
}
});
}
// 5. (高级) 处理自定义块级组件语法 (示例性实现)
// -------------------------------------------------------------------------
// 例如,我们想解析 Markdown 中的 `@Component[./path/to/Comp.vue]{"prop1": "val1"}` 这样的语法
// 并将其转换为 HTML 占位符,同时提取组件信息。
const customComponents = [];
if (options.customBlockSyntax && options.customBlockSyntax.enabled) {
const {
regex = "@([A-Za-z0-9_\-]+)\[([^\]]+)\](?:\{([^\}]+)\})?", // 默认正则: @CompName[./path]{propsJson}
placeholderTag = "div"
} = options.customBlockSyntax;
const customBlockRegex = new RegExp(regex, 'gm');
let match;
// 注意:直接在 Markdown 内容上用正则替换,可能会干扰 Markdown 解析。
// 更健壮的方法是编写一个 markdown-it 插件来处理这种自定义语法。
// 这里为了演示 loader 的能力,我们做一个简化的预处理。
// 这种预处理可能会破坏代码块或复杂 Markdown 结构中的相同模式。
// 警告:以下替换逻辑非常简化,生产环境需要更复杂的解析或 markdown-it 插件
let tempContent = content; // 在临时内容上操作
let componentIndex = 0;
while((match = customBlockRegex.exec(tempContent)) !== null) {
const fullMatch = match[0];
const componentName = match[1];
const componentPath = match[2];
const propsString = match[3];
let props = {};
if (propsString) {
try {
props = JSON.parse(propsString);
} catch (e) {
this.emitWarning(`Invalid JSON props for custom component ${componentName}: ${propsString}`);
}
}
const componentId = `md-plus-comp-${componentIndex++}`;
customComponents.push({
id: componentId,
name: componentName,
path: componentPath, // Loader 需要解析这个路径
props: props,
});
// 用占位符替换原始内容
const placeholder = `<${placeholderTag} data-md-component-id="${componentId}" data-md-component-name="${componentName}"></${placeholderTag}>`;
// 替换时要小心,避免在已替换的内容上再次匹配
// 这是一个非常棘手的问题,简单的 replaceAll 可能不工作或有 bug
// content = content.replace(fullMatch, placeholder); // 简化处理
}
// 如果使用上述方法,应该在 mdInstance.render() 之前修改 `content`
// 但由于其复杂性和潜在问题,这里仅收集信息,不直接修改 `content`
// 而是让用户在运行时根据 `customComponents` 数据自行处理 HTML 中的占位符或结构
}
// 6. 转换 Markdown 到 HTML
// -------------------------------------------------------------------------
let htmlOutput = "";
try {
htmlOutput = mdInstance.render(content);
} catch (e) {
this.emitError(new Error(`Error rendering Markdown to HTML: ${e.message}`));
htmlOutput = `<p>Error rendering Markdown: ${e.message}</p>`; // 提供回退内容
}
// 7. 包裹 HTML (如果配置了 wrapperClass)
// -------------------------------------------------------------------------
if (options.wrapperClass) {
htmlOutput = `<div class="${options.wrapperClass}">${htmlOutput}</div>`;
}
// 8. 构建输出模块
// -------------------------------------------------------------------------
let resultModuleString;
switch (options.exportFormat) {
case 'html':
resultModuleString = htmlOutput; // 直接返回 HTML 字符串 (可能不是JS模块)
// 如果直接返回 HTML,Webpack 可能不知道如何处理它,除非下一个 loader 是 html-loader
// 通常 loader 返回的是 JS 代码
// 为了让它作为 JS 模块,我们应该导出它
resultModuleString = `export default ${JSON.stringify(htmlOutput)};`;
break;
case 'vue-sfc':
const vueOptions = options.vueSfcOptions || {};
const componentName = vueOptions.componentName || 'MarkdownComponent';
const scriptSetup = vueOptions.scriptSetup || false;
const styleContent = vueOptions.style || '';
let scriptBlock = `<script>\nexport default {\n name: '${componentName}',\n props: {\n frontmatter: { type: Object, default: () => (${JSON.stringify(frontmatter)}) }\n }\n};\n</script>`;
if (scriptSetup) {
scriptBlock = `<script setup>\nconst props = defineProps({\n frontmatter: { type: Object, default: () => (${JSON.stringify(frontmatter)}) }\n});\n</script>`;
}
resultModuleString = `
<template>
<div class="vue-markdown-plus-wrapper">
${htmlOutput}
</div>
</template>
${scriptBlock}
${styleContent ? `<style scoped>\n${styleContent}\n</style>` : ''}
`;
// 当输出 Vue SFC 时,这个 loader 应该在 vue-loader 之前运行
// e.g., { test: /.md$/, use: ['vue-loader', 'markdown-plus-loader'] }
// 或者,更常见的是,markdown-plus-loader 的输出被 vue-loader 处理
// Webpack 会根据文件扩展名或 loader 链来决定如何处理。
// 如果这个 loader 的输出是 .vue 文件内容,那么 webpack 规则需要匹配 .md 然后让 vue-loader 处理其输出。
break;
case 'module':
default:
// 默认导出 JS 模块,包含 HTML, frontmatter, 和自定义组件信息
resultModuleString = `
// Generated by MarkdownPlusLoader
export const attributes = ${JSON.stringify(frontmatter)};
export const html = ${JSON.stringify(htmlOutput)};
export const source = ${JSON.stringify(source)}; // 原始 Markdown 内容
export const customComponents = ${JSON.stringify(customComponents)};
// 你可以添加一个辅助函数来渲染,或者让用户自己处理
// export function renderToDOM(elementId) {
// const el = document.getElementById(elementId);
// if (el) el.innerHTML = html;
// // 更多关于自定义组件的逻辑...
// }
export default {
attributes,
html,
source,
customComponents
};
`;
break;
}
// 9. 返回结果
// -------------------------------------------------------------------------
// 使用 this.callback 可以返回多个值,例如 Source Map 或 AST (meta)
// this.callback(err, content, sourceMap?, meta?);
// 如果没有错误,第一个参数是 null。
// 如果要生成 Source Map,需要确保 sourceMap 参数是符合规范的 SourceMap 对象。
// 对于这个 Loader,从 Markdown 到 HTML 再到 JS 字符串的 Source Map 比较复杂,
// 此处我们暂不生成复杂的 Source Map,简单地将输出映射到原始文件。
// 如果你的转换是逐行的,可以考虑使用 `source-map` 库来生成。
// 此处我们不传递 source map,因为转换比较复杂,简单的 source map 可能意义不大。
// 如果需要,可以研究如何为这种多阶段转换(MD -> HTML -> JS String)生成有意义的 source map。
this.callback(null, resultModuleString, null /* sourceMap */, null /* meta */);
// 当调用了 this.callback 后,应该返回 undefined,告知 Webpack 操作已完成。
return;
};
// (可选) Pitching Loader
// -----------------------------------------------------------------------------
// module.exports.pitch = function(remainingRequest, precedingRequest, data) {
// // remainingRequest: 剩下需要处理的 loader 链和资源路径
// // precedingRequest: 已经经过的 loader 链
// // data: 一个可以在 pitch 和 normal 阶段共享的对象
//
// // 例如,如果满足某些条件,可以直接返回结果,跳过后续 loader (包括此 loader 的 normal 执行)
// // if (someCondition) {
// // return `module.exports = "pitched result";`;
// // }
// };
// 导出原始 loader 函数,使其能够处理二进制数据 (如果需要)
// module.exports.raw = true; // 默认是 false,source 是 UTF-8 字符串
// 对于 Markdown,通常作为字符串处理即可。
代码讲解 (markdown-plus-loader.js
):
-
依赖引入:
loader-utils
: 用于getOptions
来安全地获取 Webpack 配置中传递给 loader 的选项。schema-utils
: 用于validate
来校验这些选项是否符合预定义的schema
。front-matter
: 一个小巧的库,用于从字符串中提取和解析 YAML frontmatter。markdown-it
: Markdown 解析库。highlight.js
: 用于代码语法高亮。
-
schema
定义:- 这是一个 JSON Schema 对象,描述了 loader 接受的各种选项、它们的类型、默认值和描述。
validate(schema, options, { name, baseDataPath })
会在选项不符合 schema 时抛出易于理解的错误。- 我们定义了如
html
,xhtmlOut
,breaks
(直接对应markdown-it
选项),以及更复杂的plugins
,customBlockSyntax
,exportFormat
等。
-
MarkdownPlusLoader
函数:-
这是 loader 的主函数,接收
source
(文件原始内容) 作为参数。 -
this.cacheable(true)
: 声明此 loader 的输出是可缓存的。如果输入文件和它的依赖没有变化,Webpack 可以重用上次的构建结果。 -
Frontmatter 解析:
- 使用
fm.test(source)
检查是否存在 frontmatter。 fm(source)
解析出attributes
(元数据对象) 和body
(Markdown 正文)。- 包含基本的错误处理。
- 使用
-
MarkdownIt
初始化:- 创建一个
MarkdownIt
实例。 - 将用户通过 loader 选项传入的配置 (如
html
,linkify
,typographer
,markdownItOptions
) 应用到MarkdownIt
实例。 - 代码高亮 : 提供了一个
highlight
函数,使用highlight.js
。如果代码块指定了语言并且highlight.js
支持,则进行高亮处理。否则,进行 HTML 转义。
- 创建一个
-
插件加载:
- 遍历
options.plugins
数组。 - 每个插件配置可以是字符串 (插件名) 或对象 (
{ plugin: 'name'/'fn', options: [] }
)。 - 使用
require()
动态加载插件。注意 : 在生产环境中,动态require
用户提供的字符串可能存在安全和打包问题。更安全的方式是让用户直接传递插件函数,或者维护一个受信任的插件列表。 - 包含错误处理,防止插件加载或使用失败导致整个构建中断。
- 遍历
-
(高级) 自定义块级组件语法 (示例) :
- 这是一个概念性的实现,展示了如何通过正则表达式预处理 Markdown 内容以查找自定义标记 (例如
@ComponentName[./path]{propsJson}
)。 - 它将提取组件名、路径和属性,并将这些信息存储在
customComponents
数组中。 - 重要 : 直接用正则表达式修改 Markdown 内容非常容易出错,尤其是在处理嵌套结构或代码块时。一个更健壮的解决方案是编写一个
markdown-it
插件来识别和处理这种自定义语法,将其转换为特定的 HTML 标记或 AST 节点。这里的实现主要是为了展示 loader 可以进行的复杂文本转换。
- 这是一个概念性的实现,展示了如何通过正则表达式预处理 Markdown 内容以查找自定义标记 (例如
-
Markdown 到 HTML:
- 调用
mdInstance.render(content)
将 Markdown 正文转换为 HTML 字符串。
- 调用
-
包裹 HTML:
- 如果
options.wrapperClass
被设置,则用一个带指定类名的<div>
包裹生成的 HTML。
- 如果
-
构建输出模块:
-
根据
options.exportFormat
的值,决定最终输出的 JavaScript 模块字符串的格式:'html'
: 导出一个包含 HTML 字符串的默认成员。'vue-sfc'
: 生成一个完整的 Vue 单文件组件 (SFC) 字符串,包含<template>
,<script>
, 和可选的<style>
。这使得.md
文件可以直接被vue-loader
处理。'module'
(默认): 导出一个包含attributes
(frontmatter),html
(HTML 字符串),source
(原始 Markdown), 和customComponents
信息的对象。
-
-
this.callback
:- 使用
this.callback(null, resultModuleString, sourceMap, meta)
返回处理结果。 - 第一个参数是错误对象 (成功时为
null
)。 - 第二个参数是转换后的内容 (JavaScript 代码字符串)。
- 第三个参数是 Source Map 对象 (此处为
null
,因为生成准确的 Source Map 较为复杂)。 - 第四个参数是
meta
,可以是 AST 或其他需要在 loader 间传递的信息。 - 调用
this.callback
后,函数应返回undefined
。
- 使用
-
-
Pitching Loader (注释中) :
- 简单提及了
pitch
方法,但未在此 loader 中实现。pitch
方法用于更高级的 loader 控制流。
- 简单提及了
-
raw
属性 (注释中) :module.exports.raw = true
可以让 loader接收原始的Buffer
而不是 UTF-8 字符串。对于文本文件如 Markdown,通常不需要。
3. 在 Webpack 配置中使用 Loader
要在项目中使用这个自定义 loader,你需要在 webpack.config.js
中配置它。
假设你的 markdown-plus-loader.js
文件位于项目根目录下的 my-webpack-loaders/
文件夹中。
js
// webpack.config.js
const path = require('path');
module.exports = {
// ... 其他 Webpack 配置 (mode, entry, output, etc.)
module: {
rules: [
{
test: /.md$/, // 匹配所有 .md 文件
use: [
// 如果 exportFormat 是 'vue-sfc',你可能需要 vue-loader 在此之后处理
// {
// loader: 'vue-loader',
// options: { /* vue-loader options */ }
// },
{
loader: path.resolve(__dirname, 'my-webpack-loaders/markdown-plus-loader.js'),
options: {
// --- 基本 Markdown-it 选项 ---
html: true, // 允许 Markdown 中的 HTML 标签
linkify: true, // 自动转换 URL 为链接
typographer: true, // 启用智能标点和替换
breaks: false, // 不将段落中的 \n 转换成 <br>
// --- 代码高亮 ---
langPrefix: 'hljs-', // highlight.js 使用的 CSS 类名前缀
// --- Markdown-it 原始选项 ---
markdownItOptions: {
// langPrefix: 'code-highlight language-' // 也可以在这里覆盖
},
// --- 插件配置 ---
plugins: [
'markdown-it-emoji', // 简单插件名 (需 npm install)
{
plugin: require('markdown-it-anchor'), // 直接传递插件函数
options: [ // 传递给 anchor 插件的选项
{
level: [1, 2, 3, 4],
permalink: true,
permalinkBefore: true,
permalinkSymbol: '#'
}
]
},
{
plugin: 'markdown-it-table-of-contents', // 插件名
options: [
{
includeLevel: [2, 3],
containerClass: 'table-of-contents'
}
]
}
],
// --- 自定义块级组件 (示例性) ---
// customBlockSyntax: {
// enabled: true,
// regex: "@([A-Za-z0-9_\-]+)\[([^\]]+)\](?:\{([^\}]+)\})?",
// placeholderTag: "section"
// },
// --- 输出包裹类 ---
wrapperClass: 'markdown-container prose', // 例如使用 Tailwind Typography
// --- 输出格式 ---
exportFormat: 'module', // 'module', 'html', or 'vue-sfc'
// --- Vue SFC 特定选项 (如果 exportFormat: 'vue-sfc') ---
// vueSfcOptions: {
// style: `
// .vue-markdown-plus-wrapper { padding: 1rem; }
// .markdown-container h1 { color: blue; }
// `,
// scriptSetup: true,
// componentName: 'MyMarkdownDoc'
// }
}
}
]
},
// ... 其他 rules (例如处理 JS, CSS, Vue 文件等)
// 如果 markdown-plus-loader 输出 Vue SFC 字符串,确保 vue-loader 能处理它
// {
// test: /.vue$/,
// loader: 'vue-loader'
// }
]
},
resolveLoader: { // 帮助 Webpack 找到你的本地 loader
modules: ['node_modules', path.resolve(__dirname, 'my-webpack-loaders')]
}
// 或者在 loader 路径中使用 path.resolve() 如上例所示,就不一定需要 resolveLoader
};
使用说明:
test: /.md$/
: 这条规则告诉 Webpack 对所有以.md
结尾的文件使用此 loader。loader: path.resolve(...)
: 指定了 loader 文件的路径。如果你的 loader 发布到了 npm,这里可以直接写 loader 的名字 (例如'markdown-plus-loader'
)。options
: 一个对象,包含了传递给markdown-plus-loader
的配置。这些选项会通过this.getOptions()
在 loader 内部获取。resolveLoader
: (可选) 如果你的 loader 不在node_modules
中,这个配置可以帮助 Webpack 找到它。或者直接在loader
属性中使用绝对路径或path.resolve
。- Loader 顺序 : Webpack 中的 loader 是从右到左(或从下到上)应用的。如果
markdown-plus-loader
的输出需要被另一个 loader (如vue-loader
) 处理,确保它们的顺序正确。
4. 如何在代码中导入和使用 .md
文件
如果 exportFormat
设置为 'module'
(默认):
js
// src/myComponent.js
import markdownData from './my-document.md';
// markdownData 将会是:
// {
// attributes: { title: 'Hello World', date: '2024-05-30', ... },
// html: '<div class="markdown-container prose"><h1>Hello World</h1>...</div>',
// source: '# Hello World...',
// customComponents: [ /* ... */ ]
// }
console.log(markdownData.attributes.title);
function renderMarkdown() {
const app = document.getElementById('app');
if (app) {
app.innerHTML = markdownData.html;
// (高级) 处理自定义组件
// markdownData.customComponents.forEach(comp => {
// const placeholder = app.querySelector(`[data-md-component-id="${comp.id}"]`);
// if (placeholder) {
// // 动态导入组件并渲染到 placeholder
// import(/* webpackChunkName: `md-comp-${comp.name}` */ comp.path)
// .then(module => {
// const Component = module.default || module;
// // 假设 Component 是一个可以 new 的类或一个函数
// // new Component({ target: placeholder, props: comp.props }); // Svelte 风格
// // ReactDOM.render(<Component {...comp.props} />, placeholder); // React 风格
// });
// }
// });
}
}
renderMarkdown();
如果 exportFormat
设置为 'vue-sfc'
,并且你正确配置了 vue-loader
来处理 .md
文件 (或者 markdown-plus-loader
的输出):
js
<!-- src/App.vue -->
<template>
<div>
<h1>My Application</h1>
<MyMarkdownDocument />
<!-- 或者 -->
<MarkdownFromFile msg="Hello from prop!" />
</div>
</template>
<script>
import MyMarkdownDocument from './my-document.md'; // 这会是一个 Vue 组件
export default {
name: 'App',
components: {
MyMarkdownDocument,
// 如果在 markdown-plus-loader 中配置了 vueSfcOptions.componentName
// 它导出的组件名会是那个,但 import 时你仍可以随意命名
}
}
</script>
5. 测试 Loader (概念)
测试 Webpack loader 通常涉及:
-
单元测试: 对于 loader 内部的纯逻辑函数(如果有的话),可以像测试普通 JavaScript 函数一样。
-
集成测试: 这是关键。
- 使用
webpack
Node API 以编程方式运行一个包含此 loader 的最小 Webpack 构建。 - 准备一个或多个示例输入文件 (例如,一个
.md
文件)。 - 断言构建的输出(通常是转换后的 JavaScript 模块内容)是否符合预期。
- 可以使用
memfs
库来在内存中模拟文件系统,避免磁盘 I/O,使测试更快。 jest
或mocha
等测试框架可以用来组织和运行测试。
- 使用
示例测试文件结构 (使用 Jest):
scala
my-webpack-loaders/
├── markdown-plus-loader.js
├── __tests__/
│ ├── fixtures/ // 测试用的 .md 文件
│ │ └── basic.md
│ │ └── with-frontmatter.md
│ └── markdown-plus-loader.test.js // 测试脚本
├── package.json
└── webpack.config.test.js // (可选) 用于测试的 Webpack 配置
markdown-plus-loader.test.js
(伪代码):
js
// __tests__/markdown-plus-loader.test.js
const webpack = require('webpack');
const path = require('path');
const { Volume } = require('memfs'); // 或者使用 Node.js 内置的 fs
// 辅助函数来运行 webpack
function compile(fixture, options = {}) {
const compiler = webpack({
mode: 'development',
context: __dirname,
entry: `./fixtures/${fixture}`,
output: {
path: path.resolve(__dirname, 'dist'), // 内存中的 dist
filename: 'bundle.js',
libraryTarget: 'commonjs2' // 方便获取模块导出
},
module: {
rules: [{
test: /.md$/,
use: {
loader: path.resolve(__dirname, '../markdown-plus-loader.js'),
options
}
}]
}
});
compiler.outputFileSystem = new Volume(); // 使用内存文件系统
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) return reject(err);
if (stats.hasErrors()) return reject(new Error(stats.toJson().errors.join('\n')));
const output = compiler.outputFileSystem.readFileSync(path.join(stats.compilation.outputOptions.path, 'bundle.js'), 'utf-8');
// 因为是 commonjs2,需要 eval 来获取模块
const m = { exports: {} };
// eslint-disable-next-line no-eval
eval(output)(m, m.exports, require); // 模拟 Node.js 模块环境
resolve(m.exports.default || m.exports); // 取决于你的 loader 如何导出
});
});
}
describe('MarkdownPlusLoader', () => {
it('should convert basic markdown to HTML module', async () => {
const output = await compile('basic.md', { exportFormat: 'module' });
expect(output.html).toContain('<h1>Basic Markdown</h1>');
expect(output.attributes).toEqual({});
});
it('should extract frontmatter', async () => {
const output = await compile('with-frontmatter.md', { exportFormat: 'module' });
expect(output.attributes.title).toBe('Test Document');
expect(output.html).toContain('<h2>Content Here</h2>');
});
// ... 更多测试用例,覆盖不同选项和插件
});
总结
这个 markdown-plus-loader
提供了相当多的功能,并且代码量(包括注释和 schema)也比较可观。它展示了:
- 如何使用
loader-utils
和schema-utils
处理和验证选项。 - 如何集成第三方库 (
front-matter
,markdown-it
,highlight.js
)。 - 如何配置和使用
markdown-it
插件。 - 如何根据选项输出不同格式的 JavaScript 模块 (包括 Vue SFC)。
- 基本的错误处理和警告发出。
- 如何使 loader 可缓存。
- 一个高级功能的初步设想 (自定义组件占位符)。
编写健壮的 loader (尤其是涉及复杂解析和转换的) 需要仔细考虑边缘情况、性能和错误处理。