一文学会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... 感谢你看到最后,欢迎点赞、收藏、转发、关注~

相关推荐
Tiffany_Ho3 分钟前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
小白学习日记2 小时前
【复习】HTML常用标签<table>
前端·html
丁总学Java2 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele3 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
懒羊羊大王呀3 小时前
CSS——属性值计算
前端·css
DOKE3 小时前
VSCode终端:提升命令行使用体验
前端
xgq3 小时前
使用File System Access API 直接读写本地文件
前端·javascript·面试
用户3157476081353 小时前
前端之路-了解原型和原型链
前端