React框架快速實現(xiàn)簡易的Markdown編輯器
前言
最近我在項目中需要實現(xiàn)一個 markdown編輯器 的需求,并且是以React框架為開發(fā)基礎的,類似掘金這樣的:

我的第一想法肯定是能用優(yōu)秀的開源就一定用開源的,畢竟不能老是重復造輪子。于是我在我的前端群里問了很多群友,他們都給了甩過來一堆開源的markdown編輯器項目,但我一看全是基于Vue使用的,不符合我的預期,逛了一下github,也沒看到我滿意的項目,所以就想自己實現(xiàn)一個啦
需要實現(xiàn)的功能
我們自己實現(xiàn)的話,看看需要支持哪些功能,因為做一個初版的簡易編輯器,所以功能實現(xiàn)得不會太多,但絕對夠用:
- markdown語法解析,并實時渲染
- markdown主題css樣式
- 代碼塊高亮展示
- 「編輯區(qū)」和「展示區(qū)」的頁面同步滾動
- 編輯器工具欄中工具的實現(xiàn)
這里先放上我最終實現(xiàn)好了的效果圖:

同時,我也給大家提供了一個在線體驗的地址 (opens new window),因為做的比較倉促,歡迎大家給我提意見和pr
具體實現(xiàn)
具體的實現(xiàn)也是按照我們上述列出來的功能的順序來一一實現(xiàn)的
說明:本文通過循序漸進的方式講解,所以重復代碼可能有點多。并且每一部分的注釋是專門用于講解該部分的代碼的,所以在看每一部分功能代碼時,只需要看注釋部分就好~
一、布局
import React, { } from 'react'
export default function MarkdownEdit() {
return (
<div className="markdownEditConainer">
<textarea className="edit" />
<div className="show" />
</div>
)
}
css樣式我就不一一列舉了,整體就是左邊是編輯區(qū),右邊是展示區(qū),具體樣式如下:

二、markdown語法解析
接下來就需要思考如何將 「編輯區(qū)」 輸入的markdown語法解析成html標簽并最終渲染在 「展示區(qū)」
查找了一下目前比較優(yōu)秀的markdown解析的開源庫,常用的有三個,分別是Marked、Showdown、markdown-it ,并借鑒了一下其它大佬的想法,了解了一下這三個庫的優(yōu)缺點,對比如下:
| 庫名 | 優(yōu)點 | 缺點 |
|---|---|---|
| Marked | 性能好,正則解析(中文支持比較好) | 擴展性較差 |
| Showdown | 擴展性好、正則解析(中文支持好) | 性能較差 |
| markdown-it | 擴展性好、性能較好 | 逐字符解析(中文支持不好) |
剛開始我選擇了showdown這個庫,因為這個庫使用起來特別方便,而且官方已經(jīng)在庫中提供了很多擴展功能,只需要配置一些字段即可。
但是后來我又分析了一波,還是選用了markdown-it,因為之后可能需要做更多的語法擴展,showdown的官方文檔寫的比較生硬,而且markdown-it使用的人也多,生態(tài)比較好,雖然其官方?jīng)]有支持很多擴展的語法,但是已經(jīng)有很多基于makrdown-it的功能擴展插件了,最重要的是markdown-it的官方文檔寫得好?。ǘ矣兄形奈臋n)!
接下來寫一下markdown語法解析的代碼吧(其中步驟1、2、3表示的是markdown-it庫的用法)
import React, { useState } from 'react'
// 1. 引入markdown-it庫
import markdownIt from 'markdown-it'
// 2. 生成實例對象
const md = new markdownIt()
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('') // 存儲解析后的html字符串
// 3. 解析markdown語法
const parse = (text: string) => setHtmlString(md.render(text));
return (
<div className="markdownEditConainer">
<textarea
className="edit"
onChange={(e) => parse(e.target.value)} // 編輯區(qū)內(nèi)容每次修改就更新變量htmlString的值
/>
<div
className="show"
dangerouslySetInnerHTML={{ __html: htmlString }} // 將html字符串解析成真正的html標簽
/>
</div>
)
}
對于將 html字符串 轉化為 真正的html標簽 的操作,我們借助了React提供的dangerouslySetInnerHTML屬性,詳細的使用可以看React 官方文檔(opens new window)
此時一個簡單的markdown語法解析功能就實現(xiàn)了,來看看效果

兩邊確實正在同步更新,但是…看起來好像哪里不太對!其實是沒問題的,被解析好的 html字符串 每個標簽都被附帶上了特定的類名,只是現(xiàn)在我們引入任何的樣式文件,例如下圖

我們可以打印解析出來的html字符串看看是什么樣的
<h1 id="">大標題</h1> <blockquote> <p>本文來自公眾號:前端印象</p> </blockquote> <pre><code class="js language-js">let name = '零一' </code></pre>
三、markdown主題樣式
接下來我們可以去網(wǎng)上找一些markdown的主題樣式css文件,例如我用一個最簡單Github主題的markdown樣式。另外我還是很推薦Typora Theme (opens new window),上面有很多很多的markdown主題
因為我這個樣式主題是有一個前綴id write(Typora上的大部分主題前綴也是#write),所以我們給展示區(qū)的標簽加上該類id,并引入樣式文件
import React, { useState } from 'react'
import './theme/github-theme.css' // 引入github的markdown主題樣式
import markdownIt from 'markdown-it'
const md = new markdownIt()
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('')
const parse = (text: string) => setHtmlString(md.render(text));
return (
<div className="markdownEditConainer">
<textarea
className="edit"
onChange={(e) => parse(e.target.value)}
/>
<div
className="show"
id="write" // 新增write的ID名
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
再來看看加入樣式后的渲染結果圖

四、代碼塊高亮
markdown語法的解析已經(jīng)完成了,并且也有對應的樣式了,但是代碼塊好像還沒有高亮樣式
這塊兒我們自己來從0到1的實現(xiàn)是不可能的,可以用現(xiàn)成的開源庫 highlight.js,highlight.js 官方文檔 (opens new window),這個庫能幫你做的就是檢測代碼塊標簽元素,并為其加上特定的類名。這里放上這個庫的API文檔(opens new window)
highlight.js 默認是檢測它所支持的所有語言的語法的,我們就不需要關心了,并且其提供了很多的代碼高亮主題,我們可以在官網(wǎng)進行預覽,如下圖所示:

更大的好消息來了!markdown-it已經(jīng)將highlight.js集成進去了,直接設定一些配置即可,并且我們需要先將該庫下載下來。具體的可以看markdown-it中文官網(wǎng) - 高亮語法配置(opens new window)
同時在目錄highlight.js/styles/下有很多很多的主題,可以自行導入
接下來就來實現(xiàn)一下代碼高亮的功能吧
import React, { useState, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'
import hljs from 'highlight.js' // 引入highlight.js庫
import 'highlight.js/styles/github.css' // 引入github風格的代碼高亮樣式
const md = new markdownIt({
// 設置代碼高亮的配置
highlight: function (code, language) {
if (language && hljs.getLanguage(language)) {
try {
return `<pre><code class="hljs language-${language}">` +
hljs.highlight(code, { language }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>';
}
})
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('')
const parse = (text: string) => setHtmlString(md.render(text));
return (
<div className="markdownEditConainer">
<textarea
className="edit"
onChange={(e) => parse(e.target.value)}
/>
<div
className="show"
id="write"
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
來看一下代碼高亮的效果圖:

五、同步滾動
markdown編輯器還有一個重要的功能就是在我們滾動一個區(qū)域的內(nèi)容時,另一塊區(qū)域也跟著同步的滾動,這樣才方便查看
接下來我們來實現(xiàn)一下,我會將我實現(xiàn)時踩的坑也一并列出來,讓大家也印象深刻點,免得以后也犯同樣的錯誤
剛開始主要實現(xiàn)思路就是當滾動其中一塊區(qū)域時,計算滾動比例(scrollTop / scrollHeight),然后使另一塊區(qū)域當前的滾動距離占總滾動高度的比例等于該滾動比例
import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
const md = new markdownIt({
highlight: function (code, language) {
if (language && hljs.getLanguage(language)) {
try {
return `<pre><code class="hljs language-${language}">` +
hljs.highlight(code, { language }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>';
}
})
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('')
const edit = useRef(null) // 編輯區(qū)元素
const show = useRef(null) // 展示區(qū)元素
const parse = (text: string) => setHtmlString(md.render(text));
// 處理區(qū)域的滾動事件
const handleScroll = (block: number, event) => {
let { scrollHeight, scrollTop } = event.target
let scale = scrollTop / scrollHeight // 滾動比例
// 當前滾動的是編輯區(qū)
if(block === 1) {
// 改變展示區(qū)的滾動距離
let { scrollHeight } = show.current
show.current.scrollTop = scrollHeight * scale
} else if(block === 2) { // 當前滾動的是展示區(qū)
// 改變編輯區(qū)的滾動距離
let { scrollHeight } = edit.current
edit.current.scrollTop = scrollHeight * scale
}
}
return (
<div className="markdownEditConainer">
<textarea
className="edit"
ref={edit}
onScroll={(e) => handleScroll(1, e)}
onChange={(e) => parse(e.target.value)}
/>
<div
className="show"
id="write"
ref={show}
onScroll={(e) => handleScroll(2, e)}
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
這是我做的時候的第一版,確實是實現(xiàn)了兩塊區(qū)域的同步滾動,但是存在兩個bug,來看看是哪兩個
bug1:
這是一個很致命的bug,先埋個伏筆,先來看效果:

同步滾動的效果實現(xiàn)了,但能很明顯得看到,當我手動滾動完以后停止了任何操作,但是兩個區(qū)域仍然在不停的滾動,這是為什么呢?
排查了一下代碼,發(fā)現(xiàn) handleScroll 這個方法會無限觸發(fā),假設當我們手動滾動一次編輯區(qū)后會觸發(fā)其 scroll方法,即會調用 handleScroll 方法,然后會去改變「展示區(qū)」的滾動距離,此時又會觸發(fā)展示區(qū)的 scroll方法,即調用 handleScroll 方法,然后會去改變「編輯區(qū)」的滾動距離 … 就這樣一直循環(huán)往復,才會出現(xiàn)圖中的bug
后來我想了個比較簡單的解決辦法,就是用一個變量記住你當前手動觸發(fā)的是哪個區(qū)域的滾動,這樣就可以在 handleScroll 方法里區(qū)分此次滾動是被動觸發(fā)的還是主動觸發(fā)的了
import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
const md = new markdownIt({
highlight: function (code, language) {
if (language && hljs.getLanguage(language)) {
try {
return `<pre><code class="hljs language-${language}">` +
hljs.highlight(code, { language }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>';
}
})
let scrolling: 0 | 1 | 2 = 0 // 0: none; 1: 編輯區(qū)主動觸發(fā)滾動; 2: 展示區(qū)主動觸發(fā)滾動
let scrollTimer; // 結束滾動的定時器
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('')
const edit = useRef(null)
const show = useRef(null)
const parse = (text: string) => setHtmlString(md.render(text));
const handleScroll = (block: number, event) => {
let { scrollHeight, scrollTop } = event.target
let scale = scrollTop / scrollHeight
if(block === 1) {
if(scrolling === 0) scrolling = 1; // 記錄主動觸發(fā)滾動的區(qū)域
if(scrolling === 2) return; // 當前是「展示區(qū)」主動觸發(fā)的滾動,因此不需要再驅動展示區(qū)去滾動
driveScroll(scale, showRef.current) // 驅動「展示區(qū)」的滾動
} else if(block === 2) {
if(scrolling === 0) scrolling = 2;
if(scrolling === 1) return; // 當前是「編輯區(qū)」主動觸發(fā)的滾動,因此不需要再驅動編輯區(qū)去滾動
driveScroll(scale, editRef.current)
}
}
// 驅動一個元素進行滾動
const driveScroll = (scale: number, el: HTMLElement) => {
let { scrollHeight } = el
el.scrollTop = scrollHeight * scale
if(scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
scrolling = 0 // 在滾動結束后,將scrolling設為0,表示滾動結束
clearTimeout(scrollTimer)
}, 200)
}
return (
<div className="markdownEditConainer">
<textarea
className="edit"
ref={edit}
onScroll={(e) => handleScroll(1, e)}
onChange={(e) => parse(e.target.value)}
/>
<div
className="show"
id="write"
ref={show}
onScroll={(e) => handleScroll(2, e)}
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
這樣就解決了上述的bug了,同步滾動也算很不錯得實現(xiàn)了,現(xiàn)在的效果就跟文章開頭展示的圖片里效果一樣了
bug2:
這里還存在一個很小的問題,也不算是bug,應該算是設計上的思路問題,那就是兩個區(qū)域其實還沒完完全全實現(xiàn)同步滾動。先來看看原先的設計思想

編輯區(qū)和展示區(qū)的可視高度是一樣的,但一般編輯區(qū)的內(nèi)容經(jīng)過markdown渲染后,總的滾動高度是會高于編輯區(qū)總的滾動高度的,所以我們無法僅憑scrollTop和scrollHeight使得兩個區(qū)域同步滾動,比較晦澀,用具體的數(shù)據(jù)來看一下
| 屬性 | 編輯區(qū) | 展示區(qū) |
|---|---|---|
| clientHeight | 300 | 300 |
| scrollHeight | 500 | 600 |
假設我們現(xiàn)在滾動編輯區(qū)到最底部,那么此時
「編輯區(qū)」的 scrollTop 應為 scrollHeight - clientHeight = 500 - 300 = 200,按照我們原本計算滾動比例的方式得出 scale = scrollTop / scrollHeight = 200 / 500 = 0.4
「展示區(qū)」同步滾動后,scrollTop = scale * scrollHeight = 0.4 * 600 = 240 < 600 - 300 = 300。但事實就是編輯區(qū)滾動到最底部了,而展示區(qū)還沒有,顯然不是我們要的效果
換一種思路,我們在計算滾動比例時,應計算的是當前的 scrollTop 占 scrollTop最大值的比例,這樣就能實現(xiàn)同步滾動了,仍然用剛才那個例子來看: 此時編輯區(qū)滾動到最底部,那么scale應為 scrollTop / (scrollHeight - clientHeight) = 200 / (500 - 300) = 100%,表示編輯區(qū)滾動到最底部了,那么在展示區(qū)同步滾動時,他的 scrollTop 就變成了 scale * (scrollHeight - clientHeight) = 100% * (600 - 300) = 300,此時的展示區(qū)也同步滾動到了最底部,這樣就實現(xiàn)了真正的同步滾動了
來看一下改進后的代碼
import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
const md = new markdownIt({
highlight: function (code, language) {
if (language && hljs.getLanguage(language)) {
try {
return `<pre><code class="hljs language-${language}">` +
hljs.highlight(code, { language }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>';
}
})
let scrolling: 0 | 1 | 2 = 0
let scrollTimer;
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('')
const edit = useRef(null)
const show = useRef(null)
const parse = (text: string) => setHtmlString(md.render(text));
const handleScroll = (block: number, event) => {
let { scrollHeight, scrollTop, clientHeight } = event.target
let scale = scrollTop / (scrollHeight - clientHeight) // 改進后的計算滾動比例的方法
if(block === 1) {
if(scrolling === 0) scrolling = 1;
if(scrolling === 2) return;
driveScroll(scale, showRef.current)
} else if(block === 2) {
if(scrolling === 0) scrolling = 2;
if(scrolling === 1) return;
driveScroll(scale, editRef.current)
}
}
// 驅動一個元素進行滾動
const driveScroll = (scale: number, el: HTMLElement) => {
let { scrollHeight, clientHeight } = el
el.scrollTop = (scrollHeight - clientHeight) * scale // scrollTop的同比例滾動
if(scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
scrolling = 0
clearTimeout(scrollTimer)
}, 200)
}
return (
<div className="markdownEditConainer">
<textarea
className="edit"
ref={edit}
onScroll={(e) => handleScroll(1, e)}
onChange={(e) => parse(e.target.value)}
/>
<div
className="show"
id="write"
ref={show}
onScroll={(e) => handleScroll(2, e)}
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
兩個bug都已經(jīng)解決了,同步滾動的功能也算完美實現(xiàn)啦。但對于同步滾動這個功能,其實有兩種概念,一種是兩個區(qū)域在滾動高度上保持同步滾動;另一種就是右側的展示區(qū)域對應左側的編輯區(qū)的內(nèi)容進行滾動。我們現(xiàn)在實現(xiàn)的是前者,后者可以后續(xù)作為新功能實現(xiàn)一下~
六、工具欄
最后我們就再實現(xiàn)一下編輯器的工具欄部分的工具(加粗、斜體、有序列表等等),因為這幾個工具的實現(xiàn)思路都一致,我們就拿 「加粗」 這個工具舉例子,其余的就可以模仿著寫出來了
加粗工具的實現(xiàn)思路:
光標是否選中文字?
- 是。將選中文字的兩側加上**
- 否。在光標所在處添加文字**加粗文字**
動圖效果演示:

import React, { useState, useRef, useEffect } from 'react'
import markdownIt from 'markdown-it'
import './theme/github-theme.css'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
const md = new markdownIt({
highlight: function (code, language) {
if (language && hljs.getLanguage(language)) {
try {
return `<pre><code class="hljs language-${language}">` +
hljs.highlight(code, { language }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(code) + '</code></pre>';
}
})
let scrolling: 0 | 1 | 2 = 0
let scrollTimer;
export default function MarkdownEdit() {
const [htmlString, setHtmlString] = useState('')
const [value, setValue] = useState('') // 編輯區(qū)的文字內(nèi)容
const edit = useRef(null)
const show = useRef(null)
const handleScroll = (block: number, event) => {
let { scrollHeight, scrollTop, clientHeight } = event.target
let scale = scrollTop / (scrollHeight - clientHeight)
if(block === 1) {
if(scrolling === 0) scrolling = 1;
if(scrolling === 2) return;
driveScroll(scale, showRef.current)
} else if(block === 2) {
if(scrolling === 0) scrolling = 2;
if(scrolling === 1) return;
driveScroll(scale, editRef.current)
}
}
// 驅動一個元素進行滾動
const driveScroll = (scale: number, el: HTMLElement) => {
let { scrollHeight, clientHeight } = el
el.scrollTop = (scrollHeight - clientHeight) * scale
if(scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
scrolling = 0
clearTimeout(scrollTimer)
}, 200)
}
// 加粗工具
const addBlod = () => {
// 獲取編輯區(qū)光標的位置。未選中文字時:selectionStart === selectionEnd ;選中文字時:selectionStart < selectionEnd
let { selectionStart, selectionEnd } = edit.current
let newValue = selectionStart === selectionEnd
? value.slice(0, start) + '**加粗文字**' + value.slice(end)
: value.slice(0, start) + '**' + value.slice(start, end) + '**' + value.slice(end)
setValue(newValue)
}
useEffect(() => {
// 編輯區(qū)內(nèi)容改變,更新value的值,并同步渲染
setHtmlString(md.render(value))
}, [value])
return (
<div className="markdownEditConainer">
<button onClick={addBlod}>加粗</button> {/* 假設一個加粗的按鈕 */}
<textarea
className="edit"
ref={edit}
onScroll={(e) => handleScroll(1, e)}
onChange={(e) => setValue(e.target.value)} // 直接修改value的值,useEffect會同步渲染展示區(qū)的內(nèi)容
value={value}
/>
<div
className="show"
id="write"
ref={show}
onScroll={(e) => handleScroll(2, e)}
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
借助這樣的思路,就可以完成其它各種工具的實現(xiàn)了。
七、補充
為了保證包的體積足夠小,我將第三方依賴庫、markdown主題、代碼高亮主題都通過外鏈的形式導入了
八、最后
一個簡易版的markdown編輯器就實現(xiàn)了,大家可以手動嘗試實現(xiàn)一下。后續(xù)我也會繼續(xù)發(fā)一些教程,對這個編輯器的功能進行擴展
點擊下載源碼,后續(xù)擴展一下功能,并作為一個完整的組件發(fā)布到npm給大家使用,希望大家多多支持~(其實我已經(jīng)悄悄發(fā)布,但因功能還不是太完善,就不先拿出來給大家使用了,這里簡單放個npm包的地址 (opens new window))
相關文章
詳解webpack2+node+react+babel實現(xiàn)熱加載(hmr)
這篇文章主要介紹了詳解webpack2+node+react+babel實現(xiàn)熱加載(hmr) ,非常具有實用價值,需要的朋友可以參考下2017-08-08
使用react render props實現(xiàn)倒計時的示例代碼
這篇文章主要介紹了使用react render props實現(xiàn)倒計時的示例代碼,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-12-12
React?中使用?react-i18next?國際化的過程(react-i18next?的基本用法)
i18next?是一款強大的國際化框架,react-i18next?是基于?i18next?適用于?React?的框架,本文介紹了?react-i18next?的基本用法,如果更特殊的需求,文章開頭的官方地址可以找到答案2023-01-01
React中memo useCallback useMemo方法作用及使用場景
這篇文章主要為大家介紹了React中三個hooks方法memo useCallback useMemo的作用及使用場景示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助2023-03-03

