前言
最近我在專案中需要實作一個 markdown編輯器 的需求,并且是以React框架為開發基礎的,類似掘金這樣的:

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

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

二、markdown語法決議
接下來就需要思考如何將 「編輯區」 輸入的markdown語法決議成html標簽并最終渲染在 「展示區」
查找了一下目前比較優秀的markdown決議的開源庫,常用的有三個,分別是Marked、Showdown、markdown-it ,并借鑒了一下其它大佬的想法,了解了一下這三個庫的優缺點,對比如下:
| 庫名 | 優點 | 缺點 |
|---|---|---|
| Marked | 性能好,正則決議(中文支持比較好) | 擴展性較差 |
| Showdown | 擴展性好、正則決議(中文支持好) | 性能較差 |
| markdown-it | 擴展性好、性能較好 | 逐字符決議(中文支持不好) |
剛開始我選擇了showdown這個庫,因為這個庫使用起來特別方便,而且官方已經在庫中提供了很多擴展功能,只需要配置一些欄位即可,但是后來我又分析了一波,還是選用了markdown-it,因為之后可能需要做更多的語法擴展,showdown的官方檔案寫的比較生硬,而且markdown-it使用的人也多,生態比較好,雖然其官方沒有支持很多擴展的語法,但是已經有很多基于makrdown-it的功能擴展插件了,最重要的是markdown-it的官方檔案寫得好啊(而且有中文檔案)!
接下來寫一下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)} // 編輯區內容每次修改就更新變數htmlString的值
/>
<div
className="show"
dangerouslySetInnerHTML={{ __html: htmlString }} // 將html字串決議成真正的html標簽
/>
</div>
)
}
對于將 html字串 轉化為 真正的html標簽 的操作,我們借助了React提供的dangerouslySetInnerHTML屬性,詳細的使用可以看React 官方檔案(opens new window)
此時一個簡單的markdown語法決議功能就實作了,來看看效果

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

我們可以列印決議出來的html字串看看是什么樣的
<h1 id="">大標題</h1>
<blockquote>
<p>本文來自公眾號:前端印象</p>
</blockquote>
<pre><code class="js language-js">let name = '零一'
</code></pre>
三、markdown主題樣式
接下來我們可以去網上找一些markdown的主題樣式css檔案,例如我用一個最簡單Github主題的markdown樣式,另外我還是很推薦Typora Theme (opens new window),上面有很多很多的markdown主題
因為我這個樣式主題是有一個前綴id write(Typora上的大部分主題前綴也是#write),所以我們給展示區的標簽加上該類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語法的決議已經完成了,并且也有對應的樣式了,但是代碼塊好像還沒有高亮樣式
這塊兒我們自己來從0到1的實作是不可能的,可以用現成的開源庫 highlight.js,highlight.js 官方檔案 (opens new window),這個庫能幫你做的就是檢測代碼塊標簽元素,并為其加上特定的類名,這里放上這個庫的API檔案(opens new window)
highlight.js 默認是檢測它所支持的所有語言的語法的,我們就不需要關心了,并且其提供了很多的代碼高亮主題,我們可以在官網進行預覽,如下圖所示:

更大的好訊息來了!markdown-it已經將highlight.js集成進去了,直接設定一些配置即可,并且我們需要先將該庫下載下來,具體的可以看markdown-it中文官網 - 高亮語法配置(opens new window)
同時在目錄highlight.js/styles/下有很多很多的主題,可以自行匯入
接下來就來實作一下代碼高亮的功能吧
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編輯器還有一個重要的功能就是在我們滾動一個區域的內容時,另一塊區域也跟著同步的滾動,這樣才方便查看
接下來我們來實作一下,我會將我實作時踩的坑也一并列出來,讓大家也印象深刻點,免得以后也犯同樣的錯誤
剛開始主要實作思路就是當滾動其中一塊區域時,計算滾動比例(scrollTop / scrollHeight),然后使另一塊區域當前的滾動距離占總滾動高度的比例等于該滾動比例
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) // 編輯區元素
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) {
// 改變展示區的滾動距離
let { scrollHeight } = show.current
show.current.scrollTop = scrollHeight * scale
} else if(block === 2) { // 當前滾動的是展示區
// 改變編輯區的滾動距離
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>
)
}
這是我做的時候的第一版,確實是實作了兩塊區域的同步滾動,但是存在兩個bug,來看看是哪兩個
bug1:
這是一個很致命的bug,先埋個伏筆,先來看效果:

同步滾動的效果實作了,但能很明顯得看到,當我手動滾動完以后停止了任何操作,但是兩個區域仍然在不停的滾動,這是為什么呢?
排查了一下代碼,發現 handleScroll 這個方法會無限觸發,假設當我們手動滾動一次編輯區后會觸發其 scroll方法,即會呼叫 handleScroll 方法,然后會去改變「展示區」的滾動距離,此時又會觸發展示區的 scroll方法,即呼叫 handleScroll 方法,然后會去改變「編輯區」的滾動距離 … 就這樣一直回圈往復,才會出現圖中的bug
后來我想了個比較簡單的解決辦法,就是用一個變數記住你當前手動觸發的是哪個區域的滾動,這樣就可以在 handleScroll 方法里區分此次滾動是被動觸發的還是主動觸發的了
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: 編輯區主動觸發滾動; 2: 展示區主動觸發滾動
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; // 記錄主動觸發滾動的區域
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 } = 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了,同步滾動也算很不錯得實作了,現在的效果就跟文章開頭展示的圖片里效果一樣了
bug2:
這里還存在一個很小的問題,也不算是bug,應該算是設計上的思路問題,那就是兩個區域其實還沒完完全全實作同步滾動,先來看看原先的設計思想

編輯區和展示區的可視高度是一樣的,但一般編輯區的內容經過markdown渲染后,總的滾動高度是會高于編輯區總的滾動高度的,所以我們無法僅憑scrollTop和scrollHeight使得兩個區域同步滾動,比較晦澀,用具體的資料來看一下
| 屬性 | 編輯區 | 展示區 |
|---|---|---|
| clientHeight | 300 | 300 |
| scrollHeight | 500 | 600 |
假設我們現在滾動編輯區到最底部,那么此時「編輯區」的 scrollTop 應為 scrollHeight - clientHeight = 500 - 300 = 200,按照我們原本計算滾動比例的方式得出 scale = scrollTop / scrollHeight = 200 / 500 = 0.4,那么「展示區」同步滾動后,scrollTop = scale * scrollHeight = 0.4 * 600 = 240 < 600 - 300 = 300,但事實就是編輯區滾動到最底部了,而展示區還沒有,顯然不是我們要的效果
換一種思路,我們在計算滾動比例時,應計算的是當前的 scrollTop 占 scrollTop最大值的比例,這樣就能實作同步滾動了,仍然用剛才那個例子來看: 此時編輯區滾動到最底部,那么scale應為 scrollTop / (scrollHeight - clientHeight) = 200 / (500 - 300) = 100%,表示編輯區滾動到最底部了,那么在展示區同步滾動時,他的 scrollTop 就變成了 scale * (scrollHeight - clientHeight) = 100% * (600 - 300) = 300,此時的展示區也同步滾動到了最底部,這樣就實作了真正的同步滾動了
來看一下改進后的代碼
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都已經解決了,同步滾動的功能也算完美實作啦,但對于同步滾動這個功能,其實有兩種概念,一種是兩個區域在滾動高度上保持同步滾動;另一種就是右側的展示區域對應左側的編輯區的內容進行滾動,我們現在實作的是前者,后者可以后續作為新功能實作一下~
六、工具列
最后我們就再實作一下編輯器的工具列部分的工具(加粗、斜體、有序串列等等),因為這幾個工具的實作思路都一致,我們就拿 「加粗」 這個工具舉例子,其余的就可以模仿著寫出來了
加粗工具的實作思路:
- 游標是否選中文字?
- 是,將選中文字的兩側加上
** - 否,在游標所在處添加文字
**加粗文字**
- 是,將選中文字的兩側加上
動圖效果演示:

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('') // 編輯區的文字內容
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 = () => {
// 獲取編輯區游標的位置,未選中文字時: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(() => {
// 編輯區內容改變,更新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會同步渲染展示區的內容
value={value}
/>
<div
className="show"
id="write"
ref={show}
onScroll={(e) => handleScroll(2, e)}
dangerouslySetInnerHTML={{ __html: htmlString }}
/>
</div>
)
}
借助這樣的思路,就可以完成其它各種工具的實作了,
在我已經發布的markdown-editor-reactjs (opens new window)中,已經完成了其它工具的實作,想要看代碼的可以去原始碼里看
七、補充
為了保證包的體積足夠小,我將第三方依賴庫、markdown主題、代碼高亮主題都通過外鏈的形式匯入了
八、最后
一個簡易版的markdown編輯器就實作了,大家可以手動嘗試實作一下,后續我也會繼續發一些教程,對這個編輯器的功能進行擴展
我將代碼都上傳到了 Github倉庫 (opens new window)(希望大家點個?? star),后續擴展一下功能,并作為一個完整的組件發布到npm給大家使用,希望大家多多支持~(其實我已經悄悄發布,但因功能還不是太完善,就不先拿出來給大家使用了,這里簡單放個npm包的地址 (opens new window))
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/286338.html
標籤:其他
下一篇:一小時入門vue組件(建議收藏)
