前端一文搞懂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 (尤其是涉及复杂解析和转换的) 需要仔细考虑边缘情况、性能和错误处理。

相关推荐
小小小小宇19 分钟前
业务项目中使用自定义eslint插件
前端
babicu12322 分钟前
CSS Day07
java·前端·css
小小小小宇27 分钟前
业务项目使用自定义babel插件
前端
前端码虫1 小时前
JS分支和循环
开发语言·前端·javascript
GISer_Jing1 小时前
MonitorSDK_性能监控(从Web Vital性能指标、PerformanceObserver API和具体代码实现)
开发语言·前端·javascript
余厌厌厌1 小时前
墨香阁小说阅读前端项目
前端
fanged1 小时前
Angularjs-Hello
前端·javascript·angular.js
lichuangcsdn1 小时前
springboot集成websocket给前端推送消息
前端·websocket·网络协议
程序员阿龙1 小时前
基于Web的濒危野生动物保护信息管理系统设计(源码+定制+开发)濒危野生动物监测与保护平台开发 面向公众参与的野生动物保护与预警信息系统
前端·数据可视化·野生动物保护·濒危物种·态环境监测·web系统开发
岸边的风1 小时前
JavaScript篇:JS事件冒泡:别让点击事件‘传染’!
开发语言·前端·javascript