一文学会webpack-loader | Markdown转React组件

在一个组件开发团队或其他团队中,通常使用markdown写文档,现在有这么一个需求,需要在项目中将markdown转为React组件,可以直接当做React组件直接import进来并使用,这个组件可以展示markdown中所有的内容,同时在markdown中可以import进来其他组件和JavaScript,而且可以随意插入JSX并使用引入的组件。 不光在组件文档中可以使用,当业务扔给你一个markdown文档时,你可以快速将其引入到项目中。

初始化

首先新建空目录,笔者这里使用yarnyarn init来初始化node项目,新建index.js作为初始入口,webpack-loader的本质是一个函数,传入原始文本,返回的是经过处理后的文本:

javascript 复制代码
module.exports = function(source, map) {
  return source;
}

接下来我们新建一个example目录,在这里使用一个React项目来调试我们的loader,同样在example里运行yarn init,然后运行yarn add webpack-cli webpack -D安装webpack。因为我们要跑的是React项目,需要一个HTML作为入口,所以还要安装html-webpack-plugin,这样在每次打包时webpack会自动根据template指向的HTML模版创建一个index.html文件,并插入一个script标签指向入口文件,所以我们在public中新建一个HTML,其中放一个idrootdiv标签作为React应用的根节点:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

新建webpack.config.js作为webpack的配置文件

javascript 复制代码
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development", // 开发模式
  entry: "./src/main.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./public/index.html", //* 配置index映射路径
    }),
  ],
};

然后我们需要引入React,执行yarn add react react-dom,新建src/main.js,在这里写下React应用的入口

javascript 复制代码
import React from 'react';
import ReactDOM from 'react-dom';

const App = () => {
  return (
    <>
      hello
    </>
  )
}
ReactDOM.render(<App/>, document.getElementById('root'));

webpack本身并不具备解析新版本JavaScriptJSX的能力,所以我们需要安装一些babel库,执行yarn add babel-loader @babel/preset-env @babel/preset-react,然后在webpack.config.js中配置:

javascript 复制代码
module: {
  rules: [
    {
      test: /\.(js|jsx)$/, //* 配置解析的文件后缀名
      exclude: /(node_modules)/, //* 不做处理的文件夹
      use: [
        //* 应用的解析模块,可以是一个数组,里面的值可以为模块名字符串,模块对象
        {
          loader: "babel-loader", //* 使用 babel-loader进行编译 */
          options: {
            //* 视具体来定,可以是一个字符串或者对象,值会传递到loader里面,为loader选项 */
            presets: ["@babel/preset-env", "@babel/preset-react"], //* 选择使用的编译器
          },
        },
      ],
    }
  ],
},

module中使用loader的顺序是从下到上、从右到左的,所以遇到后缀为jsjsx的文件,首先会经过@babel/preset-react进行处理,将JSX转为普通JS后就会进入@babel/preset-env处理,将ES6转为ES5

接下来在package.jsonscripts中加上启动webpack命令

json 复制代码
"scripts": {
  "dev": "webpack --config ./webpack.config.js"
},

执行yarn dev即可打包到dist目录

创建loader

src目录下新建md目录来存放Markdown文件,新建hello.md

markdown 复制代码
# test

hello world

main.js中直接引入hello.md,这时候去打包就会报错: 提示我们需要一个loader去处理hello.md这个文件,所以我们在webpack.config.js配置一下新loader,现在我们完整的module属性是这样的:

javascript 复制代码
module: {
  rules: [
    {
      test: /\.(js|jsx)$/, //* 配置解析的文件后缀名
      exclude: /(node_modules)/, //* 不做处理的文件夹
      use: [
        //* 应用的解析模块,可以是一个数组,里面的值可以为模块名字符串,模块对象
        {
          loader: "babel-loader", //* 使用 babel-loader进行编译 */
          options: {
            //* 视具体来定,可以是一个字符串或者对象,值会传递到loader里面,为loader选项 */
            presets: ["@babel/preset-env", "@babel/preset-react"], //* 选择使用的编译器
          },
        },
      ],
    },
    {
      test: /\.md$/,
      exclude: /node_modules/,
      use: [
        {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env", "@babel/preset-react"],
          },
        },
        {
          //这里写 loader 的路径
          loader: path.resolve(
            __dirname,
            "../index.js"
          ),
        },
      ],
    },
  ],
},

上面说过module中使用loader的顺序是从下到上、从右到左,所以当webpack遇到.md后缀的文件,就会先使用下面用路径引入的loader,处理完后先后使用@babel/preset-reactbabel/preset-env处理,当然现在我们的自己的loader没有返回React形式的文本,所以当@babel/preset-react进行处理时会报错,我们更改一下index.js

javascript 复制代码
module.exports = function(source, map) {
  const reactCmp = `
    import React from 'react'
    const App = () => (<>test</>);
    export default App;
  `
  return reactCmp;
}

// main.js 
const App = () => {
  return (
    <>
      <Hello/>
    </>
  )
}

重新打包,打开dist/index.html

改造Markdown

接下来我们就在index.js中对source进行处理就行。

如果你不想让你的项目里类组件和函数组件混用,可以在配置loader的时候传入参数控制将Markdown转为函数组件还是类组件。在options配置后,在webpack处理模块的构建过程中时会创建loader的上下文信息,这个对象包含了当前文件的路径、文件内容等信息,同时也包括了配置选项,在执行loader函数时会在这个上下文中执行(call、apply),所以在loader的函数中可以用this.query来获得参数。

javascript 复制代码
// webpack.config.js
{
  //这里写 loader 的路径
  loader: ...,
  options: {
    isFunctionComponent: true,
  },
},

// index.js
module.exports = function (source, map) {
  const options = this.query;
  const { isFunctionComponent = true } = options;
  const reactCmp = isFunctionComponent
    ? `
  import React from 'react'
  const App = () => (<>test</>);
  export default App;
`
    : `
  import React, { Component } from 'react';
  class App extends Component{
    render() {
      return (<>test</>)
    }
  }
  export default App;
`;
  return reactCmp;
};

接下来把Markdown转为HTML插入到render中,我们借助marked库来实现,在根目录yarn add marked

javascript 复制代码
const marked = require("marked");

...
const htmlText = marked.parse(source)
const reactCmp = isFunctionComponent
    ? `
  import React from 'react'
  const App = () => (<>${htmlText}</>);
  export default App;
` 。。。

marked.parse会把Markdown文本进行词法分析,识别出每种语法结构,然后会进行语法分析,marked 会根据词法分析得到的标记,按照 Markdown 语法的规则进行解析,将这些标记转换为对应的 HTML 标记。需要注意的是,当碰到<尖括号时,marked会将其认为是HTML语法而不是普通文本,所以不会用<p></p>将其包裹,而是直接输出,所以如果要原样尖括号的话需要自己转义或用代码块包裹。利用这一特性,我们就可以在Markdown中书写JSXmarked会将其原样输出,然后会被@babel/preset-react解析;当然,也可以在Markdown中写HTML,但是因为我们要输出的是React组件,所以要注意HTMLJSX的转换。

我们改写一下hello.md

markdown 复制代码
# test

#### hello world1
---
<h3>123</h3>

213123

---
sad
---

打包一下发现报错:

打印出转换后的HTML,发现---转换后的是<hr>,标签没有闭合,但是在ReactJSX中,所有标签必须严格闭合,我们可以利用XHTML规范来实现这一点,在XHTML规范中,所有标签都必须有对应的结束标签。由Options可知,marked中的xhtml配置已在8.0版本中移除,我们需要安装marked-xhtml yarn add marked-xhtml

javascript 复制代码
const { markedXhtml } = require("marked-xhtml");
...

marked.use(markedXhtml());
const htmlText = marked.parse(source);

这样即可将标签闭合。

我们再改写一下hello.md

markdown 复制代码
# test

#### hello world1
---

<h3>{123}</h3>
{345}

打包后的页面如下: 可以看到本应显示的大括号被React识别成了JavaScript表达式,所以需要对{}进行转义,但是对其中的JSXHTML却不需要转义,所以我们使用markedrenderer进行处理,renderer可以拦截marked中的所有标签处理的过程,有点类似现在正在做的loader 举个例子:

javascript 复制代码
function codeReplace(code, infoString) {
  console.log("code, infoString: ", code, infoString);
  // 对code进行处理
  return `
    <pre>
      <code>
        const b = 1;
      </code>
    </pre>
  `;
}
renderer.code = codeReplace
marked.use({ renderer });

这样配置以后所有对code的处理都会经过codeReplace函数,返回的字符串就是处理完后的。每个标签的renderer传入的参数有区别,具体如下:

可以看到每个标签对应的函数参数都不一样,如果要根据不同renderer methods去处理文本的话那工作量就大了,我们需要处理的是经过marked处理过后的字符串,笔者一开始试着用marked的extensions来处理,如: renderertoken参数的raw的定义:

raw A string containing all of the text that this token consumes from the source. (一个包含此标记从源文本中提取的所有文本的字符串.)

raw是会把原markdown的标签里的换行符都给包括进来,笔者经过验证后发现不好用这个方式来自定义处理文本,最好还是处理marked已经处理后的文本(HTML)

marked里包含一个Renderer原型,里面包含所有标签的处理方法,也就是说如果我们上面的renderer.code如果没有赋值的话,他使用的就是Renderer原型里的code方法,这个方法会返回markdown转换后的HTML,我们直接处理HTML就可以了

我们只需要重写renderer上所有的转换方法,类似我们之前提到过的AOP思想:在JavaScript中使用AOP编程思想监听HTTP请求

javascript 复制代码
const { marked } = require("marked");

function replaceString(str) {
  if (typeof str !== "string") return str;
  return str?.replace(/[\{]/g, "&#123;")?.replace(/[\}]/g, "&#125;");
}

function rewrite(fn) {
  return function() {
    const afterString = fn.apply(renderer, [...arguments])
    console.log('afterString: ', afterString);
    return replaceString(afterString);
  }
}
// 块元素
renderer.code = rewrite(renderer.code)
renderer.blockquote = rewrite(renderer.blockquote);
renderer.heading = rewrite(renderer.heading);
//renderer.html = renderer.html;
renderer.hr = rewrite(renderer.hr);
renderer.list = rewrite(renderer.list);
renderer.listitem = rewrite(renderer.listitem);
renderer.checkbox = rewrite(renderer.checkbox);
//renderer.paragraph = rewrite(renderer.paragraph);
renderer.table = rewrite(renderer.table);
renderer.tablerow = rewrite(renderer.tablerow);
renderer.tablecell = rewrite(renderer.tablecell);

// 行元素
renderer.strong = rewrite(renderer.strong);
renderer.em = rewrite(renderer.em);
renderer.codespan = rewrite(renderer.codespan);
renderer.br = rewrite(renderer.br);
renderer.del = rewrite(renderer.del);
renderer.link = rewrite(renderer.link);
renderer.image = rewrite(renderer.image);
renderer.text = rewrite(renderer.text);

marked.use({ renderer });
marked.use(markedXhtml());

这样改造后所有标签的{}都会被转义,而JSXHTML标签)却会被原样保留下来,接下来如果需要转义其他符号就可以在replaceString中添加。 可以看到除了code.html,笔者将code.paragraph也注释掉了,那是因为marked会将markdown的每个段落都给code.paragraph处理并套上p标签,这也是为什么在rewrite函数中打印出来的afterString都有重复的。

利用YAML

既然我们能在markdown中写JSX,那么也应该可以引入其他组件或者JSJSX使用,为了标记引入依赖的部分,我们可以使用 YAML Front Matter

YAML Front Matter 是指位于某些文件开头的 YAML 格式的元数据块。这种格式通常用在静态网站生成器(如 Jekyll、Hugo 等)或者一些特定系统中,用来配置文件的元数据信息。 在一个包含 YAML Front Matter 的文件中,该元数据块被放置在文件的起始部分,位于内容前面,并使用三个连字符 --- (或者三个点号 ...) 来分隔。例如,一个 Markdown 文件的 YAML Front Matter 可能如下所示:

yaml 复制代码
---
title: "Sample Title"
author: "John Doe"
date: "2022-03-27"
tags:
  - example
  - YAML
  - Front Matter
---

在这个例子中,元数据块包含了文章的标题、作者、日期、标签等信息。这些信息可以在静态网站生成过程中用来配置生成的页面内容,也可以在文章中直接使用。

我们使用front-matter库来处理markdown中的yaml部分(attributes)和body部分:yarn add front-matter,然后将attributes直接插入在React组件中,所以在imports部分可以写任意的JS

javascript 复制代码
const fm = require("front-matter");

。。。

const fmParsed = fm(source);
const attributes = fmParsed.attributes;
const htmlText = marked.parse(fmParsed.body);
const reactCmp = isFunctionComponent
    ? `
  import React from 'react'
  ${attributes.imports ? attributes.imports : ""}
  const App = () => (<>${htmlText}</>);
  export default App;
` 。。。

我们写一个例子:

jsx 复制代码
// Button.jsx
import React from 'react';

function Button({ onClick, children, className, disabled, type, style }) {
  return (
    <button
      onClick={onClick}
      className={className}
      disabled={disabled}
      type={type}
      style={style}
      >
      {children}
    </button>
  );
}
export default Button;

// util.js
export default function(){
  alert('test');
}

// hello.md
---
  imports: |
import Button from '../components/Button.jsx';

import test from '../utils/util.js'
---

  # Hello, World

Heres a component rendered inline:{}

<Button onClick={test}>test</Button>

YAML Front Matter的作用就是用来保存文件信息的,我们可以在yaml中保存此markdown的信息,如authortimeversion等,别人在下次引入此markdown时就能直接使用这些信息,所以我们得将除了imports之外的变量导出。

markdown 复制代码
---
imports: |
  import Button from '../components/Button.jsx';

  import test from '../utils/util.js'
author: plutoLam
time: 2023/12/03
version: 1.0.3
---

# Hello, World
jsx 复制代码
import React from 'react';
import ReactDOM from 'react-dom';
import Hello, {author, time, version} from './md/hello.md'

const App = () => {
  return (
    <>
      <Hello/>
      <p>author:{author}</p>
      <p>time:{time}</p>
      <p>version:{version}</p>
    </>
  )
}
ReactDOM.render(<App/>, document.getElementById('root'));

loader中遍历attributes即可

jsx 复制代码
let exportStr = "";
  for (const key in attributes) {
    if (key === "imports") continue;
    exportStr += `export const ${key} = ${JSON.stringify(attributes[key])};`;
}
const processed = isFunctionComponent
    ? `
    import React from 'react'
    ${parsed.attributes.imports ? parsed.attributes.imports : ""}
    ${extraExports(extra)}
    const Markdown = () => (<>${html}</>);
    export default Markdown;
  ` 。。。

代码高亮

笔者这里使用prismjs库来实现代码高亮,官方文档:prismjs.com/ 因为我们要改变代码块codeclss名,所以我们不能使用marked自带的renderer.code来处理代码块了:

javascript 复制代码
function replaceString(str) {
  if (typeof str !== "string") return str;
  return str
    ?.replace(/[\{]/g, "&#123;")
    ?.replace(/[\}]/g, "&#125;")
    .replace(/class="/g, 'className="');
}

function codeReplace(code, lang) {
  console.log("code, lang: ", code, lang);
  let langClss = '',html = code
  try {
    loadLanguages([lang]);
    html = Prism.highlight(code, Prism.languages[lang], lang);
    langClss = `language-${lang}`
  } catch (error) {
    console.log(error)
  }

  return replaceString(`
    <pre>
      <code class="${langClss}">
        ${html}
      </code>
    </pre>
  `);
}
renderer.code = rewrite(codeReplace);

所以我们使用codeReplace函数来处理,按照prismjs官方文档中在Node的使用方法,使用highlight方法实现对应lang语言的高亮,返回的是加了classHTML,如果传入的语言不符合规范,prismjs就会报错,所以用try-catch捕获这个错误 看一个例子:

markdown 复制代码
 ```js
 const b = 1;
 ```.

转换后的HTML为:<span class="token keyword">const</span> b <span class="token operator">=</span> <span class="token number">1</span><span class="token punctuation">;</span> 因为prismjs每种语言使用的都是一样的class名,所以我们需要在code标签上标记语言对应的class 可以看到返回的是HTML,但是我们的React组件的class名用的是ClassName,所以我们需要在replaceString加上替换 现在我们只是在React组件中加上了类名,但是没有样式,所以我们在example目录下也安装prismjs,然后在main.js全局引入import "prismjs/themes/prism.css";;为了解析css,还得安装两个loader:yarn add css-loader style-loader,在webpack.config.js配置:

javascript 复制代码
{
  test: /\.css$/, // 匹配以 .css 结尾的文件
  use: ['style-loader', 'css-loader'], // 使用 'style-loader' 和 'css-loader' 来处理 CSS 文件
  }

总结

至此,我们已经完成了这个loader的基本功能,笔者更多的是提供一个思路,如果你的项目有其他需求,可以参照此教程去修改。 代码已发布到github,点个star吧~github.com/plutoLam/md... 感谢你看到最后,欢迎点赞、收藏、转发、关注~

相关推荐
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅9 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊9 小时前
jwt介绍
前端
爱敲代码的小鱼9 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax