前端一文搞懂webpack loader

创建一个自定义的 Webpack Loader 可以让你在 Webpack 构建过程中预处理文件。Loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript,或者将内联图像转换为 data URL。Loader 甚至允许你直接在 JavaScript 模块中 import CSS 文件!

下面将创建一个名为 markdown-plus-loader 的 Loader,它能够:

  1. 将 Markdown 文件转换为 HTML。
  2. 提取 Markdown 文件中的 Frontmatter (YAML 格式的元数据)。
  3. 支持通过选项配置 Markdown 解析器的行为。
  4. 支持集成 markdown-it 插件 (例如用于代码高亮、TOC 生成等)。
  5. (可选高级功能) 识别 Markdown 中的自定义组件占位符,并将其信息导出,以便在运行时动态加载或渲染这些组件。
  6. 最终输出一个 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-utilsgetOptions)。
  • 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.jsprismjs (如果要做复杂代码高亮)。
  • (可选) 各种 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):

  1. 依赖引入:

    • loader-utils: 用于 getOptions 来安全地获取 Webpack 配置中传递给 loader 的选项。
    • schema-utils: 用于 validate 来校验这些选项是否符合预定义的 schema
    • front-matter: 一个小巧的库,用于从字符串中提取和解析 YAML frontmatter。
    • markdown-it: Markdown 解析库。
    • highlight.js: 用于代码语法高亮。
  2. schema 定义:

    • 这是一个 JSON Schema 对象,描述了 loader 接受的各种选项、它们的类型、默认值和描述。
    • validate(schema, options, { name, baseDataPath }) 会在选项不符合 schema 时抛出易于理解的错误。
    • 我们定义了如 html, xhtmlOut, breaks (直接对应 markdown-it 选项),以及更复杂的 plugins, customBlockSyntax, exportFormat 等。
  3. 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 到 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
  4. Pitching Loader (注释中) :

    • 简单提及了 pitch 方法,但未在此 loader 中实现。pitch 方法用于更高级的 loader 控制流。
  5. 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 通常涉及:

  1. 单元测试: 对于 loader 内部的纯逻辑函数(如果有的话),可以像测试普通 JavaScript 函数一样。

  2. 集成测试: 这是关键。

    • 使用 webpack Node API 以编程方式运行一个包含此 loader 的最小 Webpack 构建。
    • 准备一个或多个示例输入文件 (例如,一个 .md 文件)。
    • 断言构建的输出(通常是转换后的 JavaScript 模块内容)是否符合预期。
    • 可以使用 memfs 库来在内存中模拟文件系统,避免磁盘 I/O,使测试更快。
    • jestmocha 等测试框架可以用来组织和运行测试。

示例测试文件结构 (使用 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-utilsschema-utils 处理和验证选项。
  • 如何集成第三方库 (front-matter, markdown-it, highlight.js)。
  • 如何配置和使用 markdown-it 插件。
  • 如何根据选项输出不同格式的 JavaScript 模块 (包括 Vue SFC)。
  • 基本的错误处理和警告发出。
  • 如何使 loader 可缓存。
  • 一个高级功能的初步设想 (自定义组件占位符)。

编写健壮的 loader (尤其是涉及复杂解析和转换的) 需要仔细考虑边缘情况、性能和错误处理。

相关推荐
Larcher6 分钟前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐19 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭31 分钟前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu1 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花1 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋2 小时前
场景模拟:基础路由配置
前端
六月的可乐2 小时前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程