一文学会webpack-loader | Markdown转React组件
在一个组件开发团队或其他团队中,通常使用markdown
写文档,现在有这么一个需求,需要在项目中将markdown
转为React
组件,可以直接当做React
组件直接import
进来并使用,这个组件可以展示markdown
中所有的内容,同时在markdown
中可以import
进来其他组件和JavaScript
,而且可以随意插入JSX
并使用引入的组件。
不光在组件文档中可以使用,当业务扔给你一个markdown
文档时,你可以快速将其引入到项目中。
初始化
首先新建空目录,笔者这里使用yarn
的yarn init
来初始化node
项目,新建index.js
作为初始入口,webpack-loader
的本质是一个函数,传入原始文本,返回的是经过处理后的文本:
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
,其中放一个id
为root
的div
标签作为React
应用的根节点:
<!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
的配置文件
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
应用的入口
import React from 'react';
import ReactDOM from 'react-dom';
const App = () => {
return (
<>
hello
</>
)
}
ReactDOM.render(<App/>, document.getElementById('root'));
webpack
本身并不具备解析新版本JavaScript
和JSX
的能力,所以我们需要安装一些babel
库,执行yarn add babel-loader @babel/preset-env @babel/preset-react
,然后在webpack.config.js
中配置:
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
的顺序是从下到上、从右到左的,所以遇到后缀为js
或jsx
的文件,首先会经过@babel/preset-react
进行处理,将JSX
转为普通JS
后就会进入@babel/preset-env
处理,将ES6
转为ES5
接下来在package.json
的scripts
中加上启动webpack
命令
"scripts": {
"dev": "webpack --config ./webpack.config.js"
},
执行yarn dev
即可打包到dist
目录
创建loader
在src
目录下新建md
目录来存放Markdown
文件,新建hello.md
# test
hello world
在main.js
中直接引入hello.md
,这时候去打包就会报错:
提示我们需要一个loader去处理
hello.md
这个文件,所以我们在webpack.config.js
配置一下新loader
,现在我们完整的module
属性是这样的:
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-react
和babel/preset-env
处理,当然现在我们的自己的loader
没有返回React
形式的文本,所以当@babel/preset-react
进行处理时会报错,我们更改一下index.js
:
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
来获得参数。
// 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
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
中书写JSX
,marked
会将其原样输出,然后会被@babel/preset-react
解析;当然,也可以在Markdown
中写HTML
,但是因为我们要输出的是React
组件,所以要注意HTML
到JSX
的转换。
我们改写一下hello.md
:
# test
#### hello world1
---
<h3>123</h3>
213123
---
sad
---
打包一下发现报错:
打印出转换后的HTML
,发现---
转换后的是<hr>
,标签没有闭合,但是在React
的JSX
中,所有标签必须严格闭合,我们可以利用XHTML
规范来实现这一点,在XHTML
规范中,所有标签都必须有对应的结束标签。由Options可知,marked中的xhtml
配置已在8.0
版本中移除,我们需要安装marked-xhtml: yarn add marked-xhtml
const { markedXhtml } = require("marked-xhtml");
...
marked.use(markedXhtml());
const htmlText = marked.parse(source);
这样即可将标签闭合。
我们再改写一下hello.md
:
# test
#### hello world1
---
<h3>{123}</h3>
{345}
打包后的页面如下:
可以看到本应显示的大括号被
React
识别成了JavaScript
表达式,所以需要对{
和}
进行转义,但是对其中的JSX
和HTML
却不需要转义,所以我们使用marked的renderer进行处理,renderer
可以拦截marked
中的所有标签处理的过程,有点类似现在正在做的loader
举个例子:
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来处理,如:
renderer
的token
参数的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
就可以了
const { marked } = require("marked");
function replaceString(str) {
if (typeof str !== "string") return str;
return str?.replace(/[\{]/g, "{")?.replace(/[\}]/g, "}");
}
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());
这样改造后所有标签的{
和}
都会被转义,而JSX
(HTML
标签)却会被原样保留下来,接下来如果需要转义其他符号就可以在replaceString
中添加。
可以看到除了code.html
,笔者将code.paragraph
也注释掉了,那是因为marked
会将markdown
的每个段落都给code.paragraph
处理并套上p
标签,这也是为什么在rewrite
函数中打印出来的afterString
都有重复的。
利用YAML
既然我们能在markdown
中写JSX
,那么也应该可以引入其他组件或者JS
供JSX
使用,为了标记引入依赖的部分,我们可以使用 YAML Front Matter
YAML Front Matter 是指位于某些文件开头的 YAML 格式的元数据块。这种格式通常用在静态网站生成器(如 Jekyll、Hugo 等)或者一些特定系统中,用来配置文件的元数据信息。 在一个包含 YAML Front Matter 的文件中,该元数据块被放置在文件的起始部分,位于内容前面,并使用三个连字符 --- (或者三个点号 ...) 来分隔。例如,一个 Markdown 文件的 YAML Front Matter 可能如下所示:
---
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
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;
` 。。。
我们写一个例子:
// 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
的信息,如author
、time
、version
等,别人在下次引入此markdown
时就能直接使用这些信息,所以我们得将除了imports
之外的变量导出。
---
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
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
即可
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/
因为我们要改变代码块code
的clss
名,所以我们不能使用marked
自带的renderer.code
来处理代码块了:
function replaceString(str) {
if (typeof str !== "string") return str;
return str
?.replace(/[\{]/g, "{")
?.replace(/[\}]/g, "}")
.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
语言的高亮,返回的是加了class
的HTML
,如果传入的语言不符合规范,prismjs
就会报错,所以用try-catch
捕获这个错误
看一个例子:
```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
配置:
{
test: /\.css$/, // 匹配以 .css 结尾的文件
use: ['style-loader', 'css-loader'], // 使用 'style-loader' 和 'css-loader' 来处理 CSS 文件
}
总结
至此,我们已经完成了这个loader
的基本功能,笔者更多的是提供一个思路,如果你的项目有其他需求,可以参照此教程去修改。
代码已发布到github
,点个star
吧~github.com/plutoLam/md…
感谢你看到最后,欢迎点赞、收藏、转发、关注~
转载自:https://juejin.cn/post/7349863595730010147