手把手帶你 10 分鐘手擼一個簡易的 Markdown 編輯器

前言

最近我在項目中需要實現一個 「markdown 編輯器」 的需求,並且是以React框架爲開發基礎的,類似掘金這樣的:

img

我的第一想法肯定是能用優秀的開源就一定用開源的,畢竟不能老是重複造輪子。於是我在我的前端羣裏問了很多羣友,他們都給了甩過來一堆開源的 markdown 編輯器項目,但我一看全是基於Vue使用的,不符合我的預期,逛了一下github,也沒看到我滿意的項目,所以就想自己實現一個啦

需要實現的功能

我們自己實現的話,看看需要支持哪些功能,因爲做一個初版的簡易編輯器,所以功能實現得不會太多,但絕對夠用:

這裏先放上我最終實現好了的效果圖:

最終效果圖

我也將本文的代碼放在了 Github 倉庫 (opens new window)[1] 上了,歡迎各位點個 ⭐️ 「star」 支持一下

同時,我也給大家提供了一個在線體驗的地址 (opens new window)[2],因爲做的比較倉促,歡迎大家給我提意見和 pr

具體實現

具體的實現也是按照我們上述列出來的功能的順序來一一實現的

說明:本文通過循序漸進的方式講解,所以重複代碼可能有點多。並且每一部分的註釋是專門用於講解該部分的代碼的,所以在看每一部分功能代碼時,只需要看註釋部分就好~

一、佈局

import React, {  } from 'react'


export default function MarkdownEdit() {


    return (
        <div class>
            <textarea class />
            <div class />
        </div>
    )
}

css 樣式我就不一一列舉了,整體就是左邊是**「編輯區」**,右邊是**「展示區」**,具體樣式如下:

佈局圖

二、markdown 語法解析

接下來就需要思考如何將 「「編輯區」」 輸入的markdown語法解析成html標籤並最終渲染在 「「展示區」」

查找了一下目前比較優秀的markdown解析的開源庫,常用的有三個,分別是MarkedShowdownmarkdown-it ,並借鑑了一下其它大佬的想法,瞭解了一下這三個庫的優缺點,對比如下:

81kH4z

剛開始我選擇了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 class>
            <textarea 
                class 
                onChange={(e) => parse(e.target.value)} // 編輯區內容每次修改就更新變量htmlString的值
            />
            <div 
                class 
                dangerouslySetInnerHTML={{ __html: htmlString }} // 將html字符串解析成真正的html標籤
            />
        </div>
    )
}

對於將 「html 字符串」 轉化爲 「真正的 html 標籤」 的操作,我們藉助了 React 提供的dangerouslySetInnerHTML屬性,詳細的使用可以看 React 官方文檔 (opens new window)[3]

此時一個簡單的markdown語法解析功能就實現了,來看看效果

markdown 語法解析效果展示圖

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

img

我們可以打印解析出來的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)[4],上面有很多很多的 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 class>
            <textarea 
                class 
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                class
                id="write"  // 新增write的ID名 
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

再來看看加入樣式後的渲染結果圖

帶樣式的 markdown 渲染效果圖

四、代碼塊高亮

markdown 語法的解析已經完成了,並且也有對應的樣式了,但是代碼塊好像還沒有高亮樣式

這塊兒我們自己來從 0 到 1 的實現是不可能的,可以用現成的開源庫 highlight.js,highlight.js 官方文檔 (opens new window)[5],這個庫能幫你做的就是檢測**「代碼塊標籤元素」**,併爲其加上特定的類名。這裏放上這個庫的 API 文檔 (opens new window)[6]

highlight.js 默認是檢測它所支持的所有語言的語法的,我們就不需要關心了,並且其提供了很多的代碼高亮主題,我們可以在官網進行預覽,如下圖所示:

更大的好消息來了!markdown-it已經將highlight.js集成進去了,直接設定一些配置即可,並且我們需要先將該庫下載下來。具體的可以看 markdown-it 中文官網 - 高亮語法配置 (opens new window)[7]

同時在目錄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 class>
            <textarea 
                class 
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                class
                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 class>
            <textarea 
                class 
                ref={edit}
                onScroll={(e) => handleScroll(1, e)}
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                class
                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 class>
            <textarea 
                class 
                ref={edit}
                onScroll={(e) => handleScroll(1, e)}
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                class
                id="write"
                ref={show}
                onScroll={(e) => handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

這樣就解決了上述的 bug 了,同步滾動也算很不錯得實現了,現在的效果就跟文章開頭展示的圖片裏效果一樣了

「bug2:」

這裏還存在一個很小的問題,也不算是 bug,應該算是設計上的思路問題,那就是兩個區域其實還沒完完全全實現同步滾動。先來看看原先的設計思想

編輯區和展示區的可視高度是一樣的,但一般編輯區的內容經過 markdown 渲染後,總的滾動高度是會高於編輯區總的滾動高度的,所以我們無法僅憑scrollTopscrollHeight使得兩個區域同步滾動,比較晦澀,用具體的數據來看一下

hixuTQ

假設我們現在滾動編輯區到最底部,那麼此時「編輯區」的 scrollTop 應爲 scrollHeight - clientHeight = 500 - 300 = 200,按照我們原本計算滾動比例的方式得出 scale = scrollTop / scrollHeight = 200 / 500 = 0.4,那麼「展示區」同步滾動後,scrollTop = scale * scrollHeight = 0.4 * 600 = 240 < 600 - 300 = 300。但事實就是編輯區滾動到最底部了,而展示區還沒有,顯然不是我們要的效果

換一種思路,我們在計算滾動比例時,應計算的是當前的 scrollTopscrollTop最大值的比例,這樣就能實現同步滾動了,仍然用剛纔那個例子來看:此時編輯區滾動到最底部,那麼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 class>
            <textarea 
                class 
                ref={edit}
                onScroll={(e) => handleScroll(1, e)}
                onChange={(e) => parse(e.target.value)} 
            />
            <div 
                class
                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 class>
            <button onClick={addBlod}>加粗</button>   {/* 假設一個加粗的按鈕 */}
            <textarea 
                class 
                ref={edit}
                onScroll={(e) => handleScroll(1, e)}
                onChange={(e) => setValue(e.target.value)}   // 直接修改value的值,useEffect會同步渲染展示區的內容
                value={value}
            />
            <div 
                class
                id="write"
                ref={show}
                onScroll={(e) => handleScroll(2, e)}
                dangerouslySetInnerHTML={{ __html: htmlString }}
            />
        </div>
    )
}

藉助這樣的思路,就可以完成其它各種工具的實現了。

在我已經發布的 markdown-editor-reactjs (opens new window)[8] 中,已經完成了其它工具的實現,想要看代碼的可以去源碼裏看

七、補充

爲了保證包的體積足夠小,我將**「第三方依賴庫」**、**「markdown 主題」**、**「代碼高亮主題」**都通過外鏈的形式導入了

八、最後

一個簡易版的 markdown 編輯器就實現了,大家可以手動嘗試實現一下。後續我也會繼續發一些教程,對這個編輯器的功能進行擴展

我將代碼都上傳到了 Github 倉庫 (opens new window)[9](希望大家點個⭐️ 「star」),後續擴展一下功能,並作爲一個完整的組件發佈到 npm 給大家使用,希望大家多多支持~(其實我已經悄悄發佈,但因功能還不是太完善,就不先拿出來給大家使用了,這裏簡單放個 npm 包的地址 (opens new window)[10])

參考資料

[1] Github 倉庫 (opens new window): https://github.com/zero2one3/markdown-editor-reactjs

[2] 在線體驗的地址 (opens new window): http://lpyexplore.gitee.io/taobao_staticweb/markdown-editor-reactjs/

[3] React 官方文檔 (opens new window): https://zh-hans.reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml

[4] Typora Theme (opens new window): https://theme.typora.io/

[5] highlight.js 官方文檔 (opens new window): https://highlightjs.org/

[6] API 文檔 (opens new window): https://highlightjs.readthedocs.io/en/latest/api.html#highlightauto-code-languagesubset

[7] markdown-it 中文官網 - 高亮語法配置 (opens new window): https://markdown-it.docschina.org/# 用法示例

[8] markdown-editor-reactjs (opens new window): https://github.com/zero2one3/markdown-editor-reactjs

[9] Github 倉庫 (opens new window): https://github.com/zero2one3/markdown-editor-reactjs

[10] npm 包的地址 (opens new window): https://www.npmjs.com/package/markdown-editor-reactjs

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ds1YQIwf9gjtpEcrntLM_w