藜麦计划 · 纯前端的Markdown编辑器(二 · 核心代码讲解)
前言
大家好啊,这里是懂一点项目管理和前端开发的狗子。
最近狗子的人生已经重启完毕,即将开始React开发的新篇章。但说实话,我React开发的技术确实还在基础水平,想检验一下自己的项目开发能力。
于是藜麦计划就诞生了,这是藜麦计划的第一个项目,纯练手项目,不喜勿喷。
上集回顾
上一篇我们主要讲了一下Markdown编辑器项目的需求和基本开发思路。
这一篇主要讲述基础功能的开发和部分逻辑的优化,很多内容的开发会有些重复,我只挑重点讲,因为部分代码都是工作量的问题,没有什么难点。
大纲如下:
-
基础显示
-
列表 - 有序,无序
-
任务列表
....
-
总结
详细代码直接来github:antd-tools
话不多说,我们开始!
正文
基础显示
上一篇其实我们已经将基础项目和关键的几个文件建好了,经过一些小小的优化(css优化)之后呢,页面已经长成这样子了(长成啥样子不重要~~,真的~~!)
export default function MarkdownToHtml() {
const [markdownText, setMarkdownText] = useState(textDemo);
// 分词,解析为tokens
// const tokens = [];
// 根据上一步所得到的tokens,形成抽象语法树
// const ast = {};
const style = {
backgroundImage: `url(${MarkdownBg})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat'
};
return (
<>
<div className="h-full w-full flex backdrop-opacity-10 backdrop-invert rounded" style={style}>
<div style={{ flex: `0 0 40rem`}}>
<MarkdownInput defaultValue={markdownText} onChange={setMarkdownText} />
</div>
<Typography className="w-full p-2 text-left border
border-stone-300 border-solid rounded"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
overflow: 'auto',
}}>
<GenerateHtml node={ast} />
</Typography>
</div>
</>
);
}
可以很明显看到左边的编辑器和右边的显示框已经设置好了宽高,那么最主要还要做两点,基础显示就完成了。
- 左右文字的实时同步
- 测试用例
左右文字的实时同步
这块就是简单的左边改动,右边响应。
在上一篇文章中,我们已经能够拿到markdownText
,只是tokens
和ast
还没有生成,我们假如ast
是一个空对象,直接传入GenerateHtml
组件中,看看ast
需要一个什么样子的类型。
众所周知,ast
是一棵树,那么正常在开发过程中,我们遇到树结构都会想到一个属性children
,加上我们此时的需求是:左右文字同步。
所以我做主,再定义一个content
来显示内容。
再因为整个需求是markdown语法,不同的文字有着不同的类型,所以type
字段肯定逃不掉。
最终,我们的ast的类型就定好了:
// parse.ts
export interface ParseAST {
type: MarkdownElement | typeof ParseNodeRoot;
children: ParseAST[];
content: string;
}
这时候就有小伙伴要问了,MarkdownElement
是什么, ParseNodeRoot
又是什么?
节点类型肯定是一个枚举,不用想哈,肯定是,用js的话就是一个映射对象。
// const.ts
// 根节点单独拿出来
export const ParseNodeRoot = 'root';
// markdown节点类型
export enum MarkdownElement {
// 标题
Heading = 'heading',
}
好了,接下来轮到渲染代码上场了!
// generate-html.tsx
export default function GenerateHtml({ node }: Props) {
if (node.type === ParseNodeRoot) {
return (
<>
{node.children?.map((child, index) => <GenerateHtml key={index} node={child} />)}
</>
);
}
return node.content;
}
是不是很easy啦
然后俺们把index.tsx
中的ast
改一下,改成一个简单的对象就可以看到右侧的显示了:
// 函数MarkdownToHtml
...
const ast: ParseAST = {
type: ParseNodeRoot,
children: [{
type: MarkdownElement.Heading,
content: markdownText
}]
}
...
嘿嘿,出来了!
测试用例
想致富,先修路
敏捷开发嘛,总要了解一下TDD(测试驱动开发),先写用例,再开发实际代码,冲!
我们要做什么呢?
- 标题
- 段落
- 加粗段落
- 斜体
- 删除线
- 行内代码
- 链接
- 列表
- 有序列表
- 无序列表
- 任务列表
- 引用
- 图片
- 分割线
都是基础的功能,话不多说,直接上用例!
const textDemo = `
# 标题
这是**一个**段落,**这是一个加粗字体**,这也是__加粗的方式__
- 无序列表项 1
- 无序列表项 2
- 无序列表项 3
---
这是第*二个*段落,*这是一个斜体*,这也是_斜体_的写法
1. 有序列表项 1
2. 有序列表项 2
3. 有序列表项 3
---
这是第~~三个~~段落,~~这是一个删除线~~
这是第\`四个\`段落,\`const inlineCode = "这是一个行内代码";\`
> 段**落**1
> 段落2
> 段\`落\`3
这是第五个段落,内部有[链接文字,链接到百度](https://www.baidu.com "标题"),没想到吧!
[不带标题的链接文字,链接到百度](https://www.baidu.com)
这是第六个段落

> 这是第二段引用
- [x] 任务列表1已完成
- [ ] 任务~~列表2~~
- [ ] 任务列表31**加粗**
这是第七个段落
`;
标题
很好理解,有多少个 #
就有多少级标题,最少1个,最多6个,同时#
后面必须跟一个空格。
核心正则
/^\s*#{1,6}\s+/
匹配格式:
# H1
## H2
### H3
^
表示行的开始。\s*
匹配0个或多个空白字符(包括空格、制表符、换行符等)。#{1,6}
匹配1到6个#
字符,代表一级到六级的标题。\s+
匹配1个或多个空白字符,这通常是为了匹配标题后面的空格。
解析为Token
在上一篇中我们解析token的文件tokenize.ts
并没有开始写
现在开始解析第一个功能:标题
首先创建主函数tokenize
,并且返回一个数组,这个数组保存着我们一行一行解析的Markdown语法,我们简要称它的类型为Token
。
// tokenize.ts
export function tokenize(markdownText: string) {
// 将获取到的markdown文本按照行来分割
const lines = markdownText.split('\n');
// 用于存储解析出来的token
const tokens: Token[] = [];
// 将markdown文字转为Token
// ...
return tokens;
}
那Token
类型我们该如何定义呢?
我的理解中Token
类型是一个集合体,它可以是标题类型,可以是段落类型,也可以是其他类型。
目前先写标题类型:Heading,它应该有几个基本属性:类型,内容,等级。
它的等级又限定死了1,2,3,4,5,6
// type.d.ts
// 标题等级类型
export type TitleLevel = 1 | 2 | 3 | 4 | 5 | undefined;
// 标题类型
interface HeadingToken {
type: MarkdownElement.Heading;
level: TitleLevel;
content: string;
}
export type Token = HeadingToken;
接下来我们就需要将lines
中每一个数据转为tokens
中的token
咱们用最简单的方案来放入数组,for循环。
for (const line of lines) {
// 先将前后空白符清理
const trimLine = line.trim();
// 判断是否是标题
if (trimLine.match(markdownRegex.heading)) {
// 获取标题的级别
const level = (trimLine.match(markdownRegex.heading)?.[0].trim().length || 6) as TitleLevel;
// 创建标题的 token
tokens.push({
type: MarkdownElement.Heading,
level: level,
content: trimLine.replace(markdownRegex.heading, '').trim(),
});
}
这里咱们暂时不去管标题中带有斜体、内置代码、删除线等混合操作,等我们将段落模块开发完之后再加上。
转为AST
在上面 左右文字的实时同步 时,我们简单的写了一个ParseAST
类型,用于给出最终AST的结构,接下来我们简单的写一下parse
函数.
parse
函数有几点需求:
- 接收
tokens
数组 - 将数组结构转为AST对象
- 返回
ParseAST
类型的对象
// parse.ts
export function parse(tokens: Token[]) {
// 初始化抽象语法树的根节点
const ast: ParseAST = {
type: ParseNodeRoot,
children: [],
content: ''
}
let currentList: ParseAST | null;
tokens.forEach((token) => {
//...
});
return ast;
}
那标题怎么写呢
标题就是把token
里面的数据push进ast的数组即可(暂时)
因为不涉及到标题内部特殊格式的嵌套,所以先暂时直接进ast.children
。
// parse.ts
export function parse(tokens: Token[]) {
// 初始化抽象语法树的根节点
const ast: ParseAST = {
type: ParseNodeRoot,
children: [],
content: ''
}
let currentList: ParseAST | null;
tokens.forEach((token) => {
switch (token.type) {
case MarkdownElement.Heading:
// 标题和段落实际处理一致
ast.children!.push(token);
break;
default:
break;
}
});
return ast;
}
因为是第一个解析为AST的模块,所以我展示全量代码,后续只会展示部分代码,不然以为在水字数。
到此,我们的标题模块就可以进入渲染阶段了,让我们看看渲染出来的结果是什么样子的。
渲染
标题的渲染我们选用Antd的Typography
组件,它的Text
正符合我们的要求。
同时GenerateHtml
组件也使用switch语句来分类渲染
// generate-html.tsx
interface Props {
node: ParseAST;
keyValue?: number | string;
}
/** 生成标题 */
function Heading({ node }: Props) {
return <Title level={node.level}>{node.content}</Title>
}
export default function GenerateHtml({ node }: Props) {
if (node.type === ParseNodeRoot) {
return (
<>
{node.children?.map((child, index) => <GenerateHtml key={index} node={child} />)}
</>
);
}
switch (node.type) {
case MarkdownElement.Heading:
return <Heading node={node} />;
default:
return null;
}
}
周树人:抽离组件是我们良好的习惯
至此,我们终于可以展示效果图了!
段落
苏格拉地漏曾经说过:万物皆可段落
标题其实只是给大家举了一个栗子🌰,其实我们更加应该从段落开始写代码。
我说:标题是段落,列表是段落,表格里面也有段落。
那段落它应该是什么?
段落应该是一个集合,一个普通文字、一个加粗文字,一个斜体.....(不水字数了)
说了这么多废话,不如看一行代码(我真棒!又压上韵了)
(˵¯͒〰¯͒˵)
核心正则
就正则我会列的详细一点,懂的大佬可以只扫一眼,不太了解的大佬们详细看看。
加粗
bold: /\*\*(.*?)\*\*|__(.*?)__/g,
匹配格式:**bold text** 或 __bold text__
这个正则表达式用于匹配两种模式,分别是 **
和 __
包围的文本。我们可以分解这个正则表达式来更详细地解释其含义:
/\*\*(.*?)\*\*|
:这部分匹配由**
包围的文本。\*\*
:匹配两个星号字符**
。(.*?)
:这是一个捕获组,用于捕获**
之间的内容。.
:匹配除换行符之外的任何单个字符。*?
:*
表示前面的元素(即.
)可以重复零次或多次,而?
在这里使*
变为非贪婪模式,即尽可能少地匹配字符。
\*\*
:再次匹配两个星号字符**
。
|
:匹配其左侧或右侧的任何部分。__(.*?)__/
:这部分匹配由__
包围的文本。__
:匹配两个下划线字符__
。(.*?)
:同样是一个捕获组,用于捕获__
之间的内容,其意义与上述相同。__/
:再次匹配两个下划线字符__
。
g
:匹配字符串中的所有可能位置(只说一次)。
斜体
italic: /\*(.*?)\*|_(.*?)_/g,
匹配格式: *italicized text* 或 _bold text_
斜体就是加粗的正则在两边各减少了一个*
或_
,其余的就不重复了。
删除线
delete: /~~(.*?)~~/g,
匹配格式:~~The world is flat.~~
删除线也很好懂,只要知道中间是一个匹配任意字符的非贪婪算法即可。
行内代码块
inlineCode: /`(.*?)`/g,
匹配格式:`code`
行内代码块也是同样的,被`
包裹的文字都是行内的代码块(当然,我们也可以用三个来显示行内代码,这块等我们写完代码块之后再看)
链接文字
link: /\[([^\]]*)\]\(([^)]*)\)/g,
匹配格式:[title](https://www.example.com)
链接文字和图片差不多,只是少了一个!
-
\[ 和 \]
:分别匹配一个左方括号 [ 和一个右方括号 ]。同样,因为在正则表达式中,方括号是特殊字符,所以我们需要使用 \ 对其进行转义。 -
\( 和 \)
:分别匹配一个左圆括号 ( 和一个右圆括号 )。同样的原因,我们需要使用 \ 对其进行转义。 -
([^\]]*)
:这是一个捕获组。它匹配任何不是右方括号的字符([^\]]
表示非右方括号的字符集合),并且*
表示匹配零个或多个这样的字符。 -
\(([^)]*)\)
:这是另一个捕获组,嵌套在第一个捕获组内部。它匹配任何不是右圆括号的字符([^)]
表示非右圆括号的字符集合),并且*
表示匹配零个或多个这样的字符。
解析为Token
段落这些特殊表达式的解析和一行代表一个格式的表达式不一样。
段落可以在大部分表达式内部解析,而且一种段落格式可以在一行中出现多次,根据这两个特性,我认为在解析时,应该将这类格式归为子集,或者属性。
所以我是这么写的
在普通一行(这一行都是文字,没有其他特殊格式)中,我们将行内的特殊格式均加入到行对象中,并且这些格式都是以数组形式存储。
我发现,无论是加粗、斜体、删除线或行内代码,都可以解析为同一个对象格式;
它包括文字类型、不带格式的文字内容和带格式的内容
所以我称它们为InlineToken
interface ExtraTokenBase {
content: string;
match: string;
}
interface BoldToken extends ExtraTokenBase {
type: MarkdownElement.Bold;
}
interface ItalicToken extends ExtraTokenBase {
type: MarkdownElement.Italic;
}
interface InlineCodeToken extends ExtraTokenBase {
type: MarkdownElement.InlineCode;
}
interface DeleteToken extends ExtraTokenBase {
type: MarkdownElement.Delete;
}
export type InlineToken = BoldToken | ItalicToken | InlineCodeToken | DeleteToken;
只要不同的类型的函数经过相同逻辑处理之后,我们就能够得到这些特殊格式的token对象了。
/** 匹配加粗格式 */
function matchBoldText(text: string) {
return matchText(text, markdownRegex.bold, MarkdownElement.Bold);
}
/** 匹配斜体格式 */
function matchItalicText(text: string) {
return matchText(text, markdownRegex.italic, MarkdownElement.Italic);
}
/** 匹配删除线格式 */
function matchDeleteText(text: string) {
return matchText(text, markdownRegex.delete, MarkdownElement.Delete);
}
/** 匹配行内代码格式 */
function matchInlineCodeText(text: string) {
return matchText(text, markdownRegex.inlineCode, MarkdownElement.InlineCode);
}
function matchText(text: string, regex: RegExp, type: InlineToken['type']) {
const list: InlineToken[] = [];
const matchs = text.matchAll(regex);
for (const match of matchs) {
// 0位是完整匹配项,1/2位是内容
// 由于我们使用了|来分隔两个模式,所以只有一个捕获组会有内容
list.push({
type: type,
content: match[1] || match[2],
match: match[0]
});
}
return list;
}
不知道大家有没有发现,或者好奇,Link
去哪了?
我在写的时候,link确实不好处理,它自带了多个属性:href
、alt
和title
。
我需要单独拿出一个类型:LinkToken
interface LinkToken extends ExtraTokenBase {
type: MarkdownElement.Link;
href?: string;
title?: string;
}
InlineToken
也要加上LinkToken
export type InlineToken = BoldToken | ItalicToken | InlineCodeToken | DeleteToken | LinkToken;
处理函数也无法复用,必须单独处理:
/** 匹配行内链接,需要带其他数据,所以单独拿出一个函数 */
function matchLink(text: string) {
const list: InlineToken[] = [];
const matchs = text.matchAll(markdownRegex.link);
for (const match of matchs) {
// href中可能带有标题
const hrefMatch = match[2];
// 单独匹配上标题
const titleMatch = hrefMatch.match(/^([^"]*)\s*"([^"]*)"$/);
list.push({
type: MarkdownElement.Link,
content: match[1],
match: match[0],
href: titleMatch?.[1].trim() ?? hrefMatch.trim(),
title: titleMatch?.[2] ?? ''
});
}
return list;
}
测完没问题之后,给标题也加上对应的属性,由于标题都是加粗,所以除了加粗都要加。
转为AST
我的想法是这样的,所有的一行文字都可以在最终转为段落类型,每个段落类型中又可以拆分成一个数组,这个数组包含了这一行中所有的格式数据。
首先我们将整行转为一个parseAST
对象,并将内容放进数组的0号位:
const result: ParseAST = {
...token,
type,
content: token.content ?? '',
children: [{
type: MarkdownElement.Text,
content: token.content
}]
}
然后我们就可以对每一句话里面的内容进行切割了
function splitToAST(rootChildren: ParseAST[], tokenList: InlineToken[], type: MarkdownElement) {
const result: ParseAST[] = [];
rootChildren.forEach(childText => {
// 已经处理过的数据直接放入数组
if (childText.type !== MarkdownElement.Text) {
result.push(childText);
return;
}
let content = childText.content ?? '';
tokenList.forEach(item => {
// 匹配Text类型的数据
const index = content.indexOf(item.match);
// 未匹配上
if (index === -1) {
return;
}
// 开头未匹配上,不管了,直接进去
if (index > 0) {
result.push({
type: MarkdownElement.Text,
content: content.slice(0, index)
})
}
// 将匹配上的数据放入数组
result.push({
...item,
type,
content: item.content,
})
// 切掉之前匹配上的数据,重新走流程
content = content.slice(index + item.match.length);
});
// 如果最终还有剩余的文字(未匹配上),则直接加进去
if (content) {
result.push({
type: MarkdownElement.Text,
content: content
})
}
});
return result;
}
那好奇的大佬们就会问,如果一行中出现多个类型怎么办?
那还能怎么办呢?
还得是按顺序一次一次来做切割
当然,这种写法会导致性能很一般
/** 解析段落 */
function parseParagraph<T extends ParagraphToken>(token: T, type: MarkdownElement = MarkdownElement.Paragraph) {
const result: ParseAST = {
...token,
type,
content: token.content ?? '',
children: [{
type: MarkdownElement.Text,
content: token.content
}]
}
if (token.inlineCode?.length) {
result.children = splitToAST(result.children!, token.inlineCode, MarkdownElement.InlineCode);
}
if (token.bold?.length) {
result.children = splitToAST(result.children!, token.bold, MarkdownElement.Bold);
}
if (token.delete?.length) {
result.children = splitToAST(result.children!, token.delete, MarkdownElement.Delete);
}
if (token.italic?.length) {
result.children = splitToAST(result.children!, token.italic, MarkdownElement.Italic);
}
if (token.link?.length) {
result.children = splitToAST(result.children!, token.link, MarkdownElement.Link);
}
return result;
}
整体的流程没啥说的,就是将在Tokens
数组中匹配上的数据转为将要渲染的结构对象。
渲染
效果图:
PS:关于斜体不斜的问题,目前我还在查为什么,等第三篇发布了应该就有结果了。
渲染代码:
/** 生成段落 */
function ParagraphContent({ node, keyValue }: Props) {
if (!node.content) {
return null;
}
function getContent(item: ParseAST, index: number) {
const content = item.content;
if (!content) {
return null;
}
if (item.type === MarkdownElement.Text) {
return <span key={index}>{content}</span>
}
if (item.type === MarkdownElement.Link) {
return (
<Link
key={index}
href={item.href}
title={item.title}
target='_blank'>
{content}
</Link>
)
}
return <Text
key={index}
strong={item.type === MarkdownElement.Bold}
italic={item.type === MarkdownElement.Italic}
code={item.type === MarkdownElement.InlineCode}
delete={item.type === MarkdownElement.Delete}>
{content}
</Text>
}
return (
<>
{
node.children!.length > 0
? node.children!.map(getContent)
: <span key={keyValue}>{node.content}</span>
}
</>
);
}
列表
终于把最重要的写完了~
列表呢,不过就是多行的段落罢了,我简单带大家过一遍。
核心正则
无序列表
unorderedList: /^\s*[-+*]\s+/,
- First item
- Second item
^
:匹配输入字符串的开始位置。\s*
:匹配零个或多个空白字符(包括空格、制表符、换页符等等)。[-+*]
:匹配字符集中的任意一个字符,这里是指减号 -、加号 + 或星号 *。\s+
:匹配一个或多个空白字符。
有序列表
orderedList: /^\s*\d+\.\s+/,
匹配格式:
1. First item
2. Second item
\d+
:匹配一个或多个数字字符(0-9)。\.
:匹配小数点 . 字符。注意,在正则表达式中,. 是一个特殊字符,表示匹配任意单个字符,所以要匹配实际的小数点字符,需要使用反斜杠 \ 进行转义。
任务列表
taskList: /^(-|\*) \[( |x)\] (.*)$/,
匹配格式:
- [x] Write the press release
- [ ] Update the website
(-|\*)
:匹配减号 - 或星号 * 中的任意一个。\[ 和 \]
:分别匹配字符 [ 和 ]。由于 [ 和 ] 在正则表达式中是特殊字符,所以需要使用反斜杠 \ 进行转义。( |x)
:匹配一个空格 或字符 x。(.*)
:匹配任意数量的任意字符,并捕获为一个分组。. 表示任意单个字符,* 表示零个或多个前面的字符。
解析为Token
在上面段落类型写完之后,其实最复杂的一部分已经完成了
列表的解析非常简单,它的内容也只需要将前面的标志位切割掉,再将后面的文字以段落的形式解析即可。
转为AST
因为我们是逐行解析,但是列表项又是多行连贯的,所以需要一个 数组的根节点(Array Root Node) 缓存每一行列表项,如果出现不连贯的时候,则清空缓存,表示当前列表结束。
设置一个currentList
对象
let currentList: ParseAST | null;
而它的子节点则为一个段落节点
ok,段落节点写完,一切就是这么简单
case MarkdownElement.ListItem:
// 当遇到列表的时候,需要创建列表的父元素
if (!currentList || !Object.prototype.hasOwnProperty.call(currentList, 'ordered')) {
currentList = {
type: token.isOrdered ? MarkdownElement.OrderedList : MarkdownElement.UnorderedList,
children: [],
ordered: token.isOrdered
};
ast.children?.push(currentList)
}
// 接下来将当前的列表推入到列表数组中
currentList.children?.push(parseParagraph({
...token,
type: MarkdownElement.Paragraph,
}));
break;
并且,你需要在其他的case里面加入
...
currentList = null;
...
关联代码截图:
渲染
因为在Antd中,ol和ul可以被Paragraph
包裹,有特殊样式,所以我们写起来也很简单:
你尽管写标签,其余的交给ParagraphContent
。
/** 生成列表 */
function ListItem({ node }: Props) {
const child = node.children?.map((item, index) => {
return <li key={index}><ParagraphContent keyValue={index + 'key'} node={item} /></li>
});
return (
<>
{
node.ordered
? <ol>{child}</ol>
: <ul>{child}</ul>
}
</>
)
}
/** 任务列表 */
function TaskList({ node }: Props) {
const child = node.children?.map((item, index) => (
<Col key={index} span={24}>
<Checkbox value={node.content} disabled={item.checked} checked={item.checked}>
<ParagraphContent keyValue={index + 'key'} node={item} />
</Checkbox>
</Col>
));
return <div className='mb-3'><Row>{child}</Row></div>
}
引用、图片、分割线
终于到最后哪些不是很重要的东西了,再坚持一下,快看完了。
核心正则
引用
blockquote: /^>\s*.*$/
匹配格式:> blockquote
^
:这个符号表示匹配字符串的开始位置。在正则表达式中,它用来确保后面的模式从字符串的起始位置开始匹配。>
:这个符号直接匹配文本中的>
字符,这是Markdown中块引用的标志。\s*
:\s
是一个元字符,代表任何空白字符,包括空格、制表符、换行符等。星号*
表示前面的元素(在这里是\s
)可以出现零次或多次。因此,\s*
匹配零个或多个连续的空白字符。.*
:点号.
代表除了换行符之外的任何单个字符。星号*
表示前面的元素(在这里是.
)可以出现零次或多次。因此,.*
匹配零个或多个任意字符(直到行尾或遇到换行符)。$
:这个符号表示匹配字符串的结束位置。确保前面的模式匹配到字符串的末尾。
图片
image: /!\[(.*?)\]\((.*?)\)/g
匹配格式:
!\[
:匹配Markdown图片语法开始的![
部分。(.*?)
:这是一个非贪婪的捕获组。.
代表匹配除了换行符以外的任意单个字符,*
表示前面的字符(.
)可以出现0次或多次。问号?
使得这个匹配变得非贪婪(也就是尽可能少地匹配字符)。这个捕获组用于捕获图片的替代文本(alt text)。\]
:匹配Markdown图片语法中的闭合方括号]
。\(
:匹配Markdown图片语法中的开括号(
。在正则表达式中,圆括号()
是特殊字符,用于捕获组,所以需要使用反斜杠\
进行转义。(.*?)
:同样是一个非贪婪的捕获组,用于捕获图片的URL。\)
:匹配Markdown图片语法中的闭括号)
。/g
:这是一个全局修饰符,表示正则表达式会匹配输入字符串中的所有可能的匹配项,而不仅仅是第一个。
分割线
其实他可以支持很多种/^(-{3,}|*{3,}|_{3,})$/
,暂时先只支持---
horizontalRule: /^(-{3,})$/
匹配格式:---
(-{3,})
:-
:匹配字符-
。{3,}
:表示前面的字符(在这里是-
)必须连续出现至少3次。{3,}
是一个量词,表示前面的元素可以出现3次或更多次。
解析为Token
这几个我们简单过一下就行,和前面的内容基本一致
转为AST
渲染
分割线用的Antd的Divider
组件,图片用的Image
组件(很好用哦)。
总结
至此我们所有的基本需求都已经开发完成,剩下的高级需求(表格、代码块)我会再发一篇文章来讲解。
本篇我们看到了Markdown转换过程中基础语法的代码和效果,从0到1完成了简单的Markdown编辑器,Easy!
转载自:https://juejin.cn/post/7352079468506415140