在Vue中使用marked.js解析Markdown,生成目錄,執行程式碼示例

baymaxsjj發表於2020-11-01

個人部落格原文地址雲墨白的部落格https://www.yunmobai.cn/blog/10

前言

對於我來說一個部落格系統就是用來總結自己所學得知識的。寫寫文章,鞏固技術,寫文章我就採用了mavon-editor,在後臺將寫好的Markdown文章儲存到資料庫裡,前臺在獲取Markdown文章將其解析成html程式碼然後渲染。所以寫文章不用愁了,那如何解析Markdown呢!我前前後後用了mavon-editor(包太大),vue-marked(功能少)等等外掛來實現!結果不滿足預期。所以不如使用marked.js直接解析呢!包小效率高,於是就對marked.js進行封裝,實現了目錄,執行程式碼塊,圖片檢視等功能!已經能滿足了基本的需求。
完成功能:markdown解析,防止惡意程式碼注入,生成目錄,檢視圖片,可以執行js程式碼用來展示Demo
原始碼:gitee.com/baymaxsjj/by-vue-blog/bl...

marked.js

一個功能齊全的markdown解析器和編譯器,用JavaScript編寫。 專為速度而設計。marked.js官網

  • 快速構建
  • 用於解析markdown的低階編譯器,無需長時間快取或阻塞
  • 非常輕量,同時實現支援的falses和規格的所有降價功能
  • 支援瀏覽器,伺服器或命令列介面(CLI)

安裝

npm install marked --save
//在vue元件中匯入
import marked  from 'marked' 

使用

//markdownString:要解析的markdown,必須為字串
//options:marked.js的配置
//callback:回撥函式。I如果 options 引數沒有定義,它就是第二個引數。
marked(markdownString [,options] [,callback])

基本配置

 marked.setOptions({
      renderer: rendererMD,
      gfm: true,//預設為true。 允許 Git Hub標準的markdown.
      tables: true,//預設為true。 允許支援表格語法。該選項要求 gfm 為true。
      breaks: false,//預設為false。 允許回車換行。該選項要求 gfm 為true。
      pedantic: false,//預設為false。 儘可能地相容 markdown.pl的晦澀部分。不糾正原始模型任何的不良行為和錯誤。
      sanitize: false,//對輸出進行過濾(清理)
      smartLists: true,
      smartypants: false//使用更為時髦的標點,比如在引用語法中加入破折號。
  });

實現目錄

實現目錄功能,網上又很多的寫法!像我這樣的小白也看不懂,程式碼都好長。我實現的過程肯有些投機取巧了。實現的過程也很簡單,沒有正則表達,沒有複雜的程式碼也就幾行程式碼吧!

實現原理

看看下面這張圖,觀察一下標題和目錄有哪些相同之處。

其實從上往下看沒有什麼不同,從左往右看也就是出現了縮排。

所以我的目錄實現原理,將所以的標題提取出來,然後根據其標題大小進行縮排。
1600852591724.png

自定義渲染方式

知道思路後,改如何實現呢!通過marked.js文件,我們可以重寫renderer(渲染),

 let rendererMD = new marked.Renderer();
 let that=this
 /*
 重寫標題
 text:標題文字
 level:標籤
 */
 rendererMD.heading = function(text, level, raw) {
     //儲存這篇文章的最大標籤 
     if(level<that.maxTitle){
         that.maxTitle=level
     }
     anchor+=1
     /* 
     toc:陣列用於儲存標題,
     id:標題id,用於點選目錄滾動到改標題
     tag:記錄屬於那個標籤(h1……h6)
     test:標籤內容
     */
     that.toc.push(
         {
             'id':anchor,
             'tag':level,
             'text':text
         }
     )
     return `<h${level} id="toc-nav${anchor}">${text}</h${level}>`;
 };
//重寫a標籤,在新標籤開啟
 rendererMD.link = function(href,title,text){
     return '<a href="'+href+'" title="'+text+'" target="_blank">'+text+'</a>';
}
//更多規則到marked.js官網檢視
 <ul >
     <li v-for="item of toc" :key="item.id"  @click="toTarget(item.id)" :style="{'padding-left':item.tag-maxTitle+'em'}" v-html="item.text">
    </li>
</ul>
為什麼儲存最大標題

通過也是渲染成功後,如果沒有沒有最大標題,假如文章只有h6標題,那麼目錄還是會縮排6個字元,不好看,這樣做的目的就是為了保證所有的標題都是從最大的以下開始縮排。縮排利用的padding-left。item.tag-maxTitle也好理解:

//最大標題從h1開始            //最大標題從h4開始
h1->0em                    h4->0em
h2->1em                    h5->1em
……                        ……

上面可以看出最大標籤始終為0em,其它標籤都是相對最大標籤的偏移。

執行程式碼

像我的部落格,就可以執行一些程式碼示例來展示,主要原理就是通過Components 定義一個執行程式碼的標籤。通過marked.js 解析程式碼塊,將特點語言的程式碼塊提取出來(我這裡就是將demo 標記的語言提取出來),然後拼接成自定義的標籤。

 rendererMD.code = function (code, language) {
                 // 提取language標識為 demo 的程式碼塊重寫
                     if (language === 'demo') {
                         DEMO_UID+=2
                        // 頁面中可能會有很多的示例,這裡增加ID標識
                        const id = 'demo-mobai-template-' + (DEMO_UID)
                        // 將程式碼內容儲存在template標籤中
                        const template = `<template type="text/demo" id="${id}">${code}</template>`
                        // 將template和自定義標籤通過ID關聯
                        const sandbox = `<demo-mobai template="${id}"></demo-mobai>`
                        // 返回新的HTML
                        return template + sandbox
                    }
}

上面解決了標籤問題,接下來就是解析標籤,以下的大部分程式碼參考自水墨寒,我修改了部分程式碼,主要解析了兩個問題,
一是,預設的執行程式碼會有一個樣式,我通過id號區分要顯示和不要顯示的,
二是,在我用的時候發現不能引入線上的js,只能執行程式碼塊中的程式碼,這樣就不太好了,比如我要用一些框架,比我來說我的這篇文章,vue 音樂播放器,就能執行線上的vue 框架和element ui,這個問題是由於引入的js程式碼後執行,所以不能解析,我的解決辦法就是通過Promise當js載入成功後,resolve();新增到Promise陣列中,通過Promise.all(Promise陣列)當所有js 都載入成功後在將程式碼塊中的程式碼新增到Shadow DOM中。

 let arr=[]
        // 4. 拼合所有Script
        for(let i=0;i<scripts.length;i++){
            // 全域性替換document為新的$shadowDocument
            if(scripts[i].src){
                // 建立
                const $sc = document.createElement('script')
                $sc.setAttribute("type", "text/javascript");
                $sc.setAttribute('src', scripts[i].src);
                this.shadow.appendChild($sc)
                arr.push(
                    //通過Promise來解決,所有js都載入成功後,在將程式碼新增到Shadow DOM
                    new Promise(function(resolve,reject){
                    //js 載入完成回執行
                    $sc.onload = function() {
                        console.log($sc)
                        resolve();
                        };
                    })
                )
                this.shadow.getElementById('demo-run').removeChild(scripts[i])
                continue
            }

            $globalDefines.innerHTML += `{
                ${scripts[i].textContent.replace(/(document)\.(getElementById|querySelector|querySelectorAll|getElementsByClassName|getElementsByName|getElementsByTagName)/gm, '$shadowDocument.$2').replace(/\r\n?/gm, '')}
            }`
            // 移除舊節點
            this.shadow.getElementById('demo-run').removeChild(scripts[i])
        }
        $globalDefines.innerHTML += `})();`

        Promise.all(arr).then(()=>{
            console.log('js載入成功');
            this.shadow.appendChild($globalDefines)
        })

Web Components 標準非常重要的一個特性是,它使開發者能夠將HTML頁面的功能封裝為 custom elements(自定義標籤),而往常開發者不得不寫一大堆冗長、深層巢狀的標籤來實現同樣的頁面功能

首先要掌握兩個知識點,Components 和Shadow DOM詳情參考MDN
這兩個我就不過多說,其實我也不太會,也沒MDN說的細,不過使用的要謹慎,有相容問題,
下面的程式碼才是關鍵,上面已經將特定語言的程式碼快轉化成自定義標籤,通過marked.js 渲染到頁面上了,但並不起作用,因為瀏覽器識別不出改標籤,下面通過Components 定義一個標籤,然後通過Shadow ,以下是我部落格中解析MARKDOWN的一個元件,其中使用到一個vue-dompurify-html用了對MARKDOWN過濾防止惡意程式碼,

<template>
    <div class="marked">
        <div ref="preview" class="write">
    //沒有vue-dompurify-html,可以將v-dompurify-html="html"改成v-html="html"
            <span
                v-if="dompurify"
                v-dompurify-html="html"
            ></span>
            <span
                v-else
                v-html="html"
            ></span>
    //沒有使用element ui 可以將下面刪除
        <el-image 
            v-if="imgView"
            id="imgview"
            style="height:0px"
            :src="url" 
            :preview-src-list="srcList">
            </el-image>
        </div>
        <transition name="slide-fade">
        <div class="toc" v-if="tocNav&&toc.length" v-show="tocIsShow">
            <div class="toc-top a-tag">
                <span class="toc-title">TOC</span>
                <a href="javascript:;" class="toc-close" @click="tocIsShow=false">「 關閉 」</a>
            </div>

            <ul >
                <li v-for="item of toc" :key="item.id"  @click="toTarget(item.id)" :style="{'padding-left':item.tag-maxTitle+'em'}" v-html="item.text">
                </li>
            </ul>
        </div>
        </transition>
        <transition name="slide-fade">
             <div class="toc-tag"  v-if="tocNav &&toc.length" v-show="!tocIsShow" @click="tocIsShow=true"> 
                <i></i>
                <i></i>
                <i></i>
            </div>
        </transition>

    </div>

</template>
<script>

import marked  from 'marked' 
import hljs   from '@/utils/highlight.min.js' 
import { Notification } from 'element-ui';
let rendererMD = new marked.Renderer();
const TAG_NAME = 'demo-mobai'
let Deom=true;
try {
  // 此處是可能產生例外的語句
    customElements.define(TAG_NAME, class DemoSandbox extends HTMLElement {
    constructor() {
        super()
        // 使用影子DOM
        this.shadow = this.attachShadow({
        mode: 'open'
        })
        // 獲取關聯的程式碼塊模板的ID
        const templateId = this.getAttribute('template')
        const $template = document.getElementById(templateId)
        if (!templateId) {
        return
        }
        // 獲取程式碼塊內容
        const template = $template.innerHTML
        console.log(templateId)
        let id=parseInt(templateId.split('demo-mobai-template-')[1]);
        console.log(id%2==0)
        if(id%2==0){
              // 用獲取到的程式碼塊來填充影子DOM的HTML
            let code=marked('```html  \n'+template+'\n```', {
                sanitize: false,
                highlight: function (code) {
                        return hljs.highlightAuto(code).value;
                },
            })
            this.shadow.innerHTML =`
            <style>
                :host {
                    display:block;
                    width:100%;
                    padding: 0;
                    border: 1px solid #f0f0f0;
                    color: #414240;
                    font-size: 1rem;
                    position: relative;
                    margin: 10px 0;
                    min-height: 36px;
                }
                :host:before {
                    content: " ";
                    position: absolute;
                    -webkit-border-radis: 50%;
                    border-radius: 50%;
                    background: #ff6058;
                    width: 12px;
                    height: 12px;
                    left: 15px;
                    margin-top: 10px;
                    -webkit-box-shadow: 20px 0 #ffbd2b, 40px 0 #3cef57;
                    box-shadow: 20px 0 #ffbd2b, 40px 0 #3cef57;
                    z-index: 2;
                }
                :host:after {
                    content: "demo";
                    position: absolute;
                    top:0px;
                    left: 50%;
                    z-index: 2;
                    color:var(--main-6);
                    font-weight:bold;
                    transform: translateX(-50%);
                    font-size: 20px;
                    line-height:32px
                }
                * {
                    box-sizing: border-box;
                }

                #demo-run {
                    padding:20px;
                    background-color:white;
                    border-top: 32px solid #ecf5ff;
                    border-radius: 6px;
                    overflow-x: auto;
                    overflow-y: hidden;
                    position:relative;
                }
                #demo-code {
                    padding:20px;
                    border-top: 1px solid #eaeefb;
                    font-size: 85%;
                    font-family: "Operator Mono SSm A","Operator Mono SSm B","Operator Mono","Source Code Pro",Menlo,Consolas,Monaco,monospace;
                    line-height: 1.4;
                    background-color:#fefefe; 
                }
                #demo-code code{
                    display: block;
                    overflow-x: auto;
                }
                #demo-open {
                    width:100%;
                    -webkit-appearance: none;
                    border:none;
                    border-top: 1px solid #eaeefb;
                    text-align:center;
                    padding: 10px 20px;
                    font-size: 14px;
                    cursor: pointer;
                    outline: 0;
                    transition: background-color .3s;
                    color: var(--main-6);
                    background-color:#fff
                }
                #demo-open:hover,
                #demo-open:active {
                    background-color: var(--main-9);
                }
            </style>
            <div id="demo-run">${template}</div>
            <div id="demo-code" hidden>${code}</div>
            <button id="demo-open">檢視原始碼</button>
            <style>
            .hljs{display:block;overflow-x:auto}.hljs-comment,.hljs-meta{color:#969896}.hljs-emphasis,.hljs-quote,.hljs-string,.hljs-strong,.hljs-template-variable,.hljs-variable{color:#df5000}.hljs-keyword,.hljs-selector-tag,.hljs-type{color:#a71d5d}.hljs-attribute,.hljs-bullet,.hljs-literal,.hljs-number,.hljs-symbol{color:#0086b3}.hljs-name,.hljs-section{color:#63a35c}.hljs-tag{color:#333}.hljs-attr,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-selector-pseudo,.hljs-title{color:#795da3}.hljs-addition{color:#55a532;background-color:#eaffea}.hljs-deletion{color:#bd2c00;background-color:#ffecec}.hljs-link{text-decoration:underline}.hljs-comment,.hljs-quote{color:#998}.hljs-keyword,.hljs-selector-tag,.hljs-subst{font-weight:700}.hljs-literal,.hljs-number,.hljs-tag .hljs-attr,.hljs-template-variable,.hljs-variable{color:teal}.hljs-doctag,.hljs-string{color:#d14}.hljs-section,.hljs-selector-id,.hljs-title{color:#900;font-weight:700}.hljs-subst{font-weight:400}.hljs-class .hljs-title,.hljs-type{color:#458;font-weight:700}.hljs-link,.hljs-regexp{color:#009926}.hljs-bullet,.hljs-symbol{color:#990073}.hljs-built_in,.hljs-builtin-name{color:#0086b3}.hljs-deletion{background:#fdd}.hljs-addition{background:#dfd}.hljs-emphasis{font-style:italic}
            </style>
            `
            const co= this.shadow.getElementById("demo-code")
            this.shadow.getElementById("demo-open").addEventListener(
            "click", (function() {
                co.hasAttribute("hidden") ? co.removeAttribute("hidden") : co.setAttribute("hidden", "")
            }));
        }else {
             this.shadow.innerHTML =`
            <div id="demo-run">${template}</div>
            `
        }


        // 移除掉關聯的template節點
            // 移除掉關聯的template節點
        $template.parentNode.removeChild($template)
        // 處理 script
        // 1. 查詢影子DOM中剛才填充的script節點
        const scripts = Array.from(this.shadow.querySelectorAll('script'))
        console.log(scripts)
        // 2. 建立一個用來儲存影子DOM根節點的Script
        const $globalDefines = document.createElement('script')
        // 3. 建立一個自執行函式,將程式碼包裹起來
        $globalDefines.innerHTML = `(function(){
        const $component = document.querySelector('${TAG_NAME}[template="${templateId}"]');
        const $shadowDocument = $component.shadowRoot;
        `
        let arr=[]
        // 4. 拼合所有Script
        for(let i=0;i<scripts.length;i++){
            // 全域性替換document為新的$shadowDocument
            if(scripts[i].src){
                // 建立
                const $sc = document.createElement('script')
                $sc.setAttribute("type", "text/javascript");
                $sc.setAttribute('src', scripts[i].src);
                this.shadow.appendChild($sc)
                arr.push(
                    //通過Promise來解決,所有js都載入成功後,在將程式碼新增到Shadow DOM
                    new Promise(function(resolve,reject){
                    //js 載入完成回執行
                    $sc.onload = function() {
                        console.log($sc)
                        resolve();
                        };
                    })
                )
                this.shadow.getElementById('demo-run').removeChild(scripts[i])
                continue
            }

            $globalDefines.innerHTML += `{
                ${scripts[i].textContent.replace(/(document)\.(getElementById|querySelector|querySelectorAll|getElementsByClassName|getElementsByName|getElementsByTagName)/gm, '$shadowDocument.$2').replace(/\r\n?/gm, '')}
            }`
            // 移除舊節點
            this.shadow.getElementById('demo-run').removeChild(scripts[i])
        }
        $globalDefines.innerHTML += `})();`

        Promise.all(arr).then(()=>{
            console.log('js載入成功');
            this.shadow.appendChild($globalDefines)
        })
    }
})
} catch(error) {
    Deom=false
    Notification.error({
        title: '瀏覽器不支援該功能',
        message: '請使用最新瀏覽器',
    })
}

export default {
    name: 'MyMarked',
    props: {
        initialValue: {
            // 初始化內容
            type: String,
            default: ''
        },
        markedOptions: {
            type: Object,
            default: () => ({})
        },
        copyCode: {// 複製程式碼
            type: Boolean,
            default: true
        },
        dompurify:{
            type:Boolean,
            default:true
        },
        copyBtnText: {// 複製程式碼按鈕文字
            type: String,
            default: '複製程式碼'
        },
        imgView:{
            type: Boolean,
            default: true
        },
        tocNav:{
            type: Boolean,
            default: false
        },
    },
    data() {
        return {
            html: '',
            previewImgModal: false,
            previewImgSrc: '',
            previewImgMode: '',
            toc:[],
            tocIsShow:document.body.clientWidth>600?true:false,
            maxTitle:6,
            url:'https://iconfont.alicdn.com/t/43f13cdf-39c8-4053-affd-b2d3e75b1e0e.png',
            srcList: [
                'https://iconfont.alicdn.com/t/43f13cdf-39c8-4053-affd-b2d3e75b1e0e.png',
                'https://iconfont.alicdn.com/t/9d79fc67-6f0d-4af2-90e7-ce50ef4404b7.png'
            ]
        };
    },
    mounted() {
        this.translateMarkdown();
    },
    methods: {
        translateMarkdown() {
            let that=this
            let DEMO_UID = 0
            let SHOW_UID=0
            rendererMD.code = function (code, language) {
                 // 提取language標識為 demo 的程式碼塊重寫
                 if(Deom){
                     if (language === 'demo') {
                         DEMO_UID+=2
                        // 頁面中可能會有很多的示例,這裡增加ID標識
                        const id = 'demo-mobai-template-' + (DEMO_UID)
                        // 將程式碼內容儲存在template標籤中
                        const template = `<template type="text/demo" id="${id}">${code}</template>`
                        // 將template和自定義標籤通過ID關聯
                        const sandbox = `<demo-mobai template="${id}"></demo-mobai>`
                        // 返回新的HTML
                        return template + sandbox
                    }
                    if(language === 'show'){
                         // 頁面中可能會有很多的示例,這裡增加ID標識
                        const id = 'demo-mobai-template-' + (++SHOW_UID)
                        // 將程式碼內容儲存在template標籤中
                        const template = `<template type="text/demo" id="${id}">${code}</template>`
                        // 將template和自定義標籤通過ID關聯
                        const sandbox = `<demo-mobai template="${id}"></demo-mobai>`
                        // 返回新的HTML
                        return template + sandbox
                    }
                 }else{
                      if (language === 'demo') {
                          language='html';
                      }
                 }

                 // 其他標識的程式碼塊依然使用程式碼高亮顯示
                 return `<div class="code-block"><span class="code-language">${language}</span><span class="copy-code el-icon-files">${that.copyBtnText}</span><pre rel="${language}"><code class="hljs ${language}">${hljs.highlightAuto(code).value}</code></pre></div>`
            }
            rendererMD.link = function(href,title,text){
                return '<a href="'+href+'" title="'+text+'" target="_blank">'+text+'</a>';
            }
            let anchor=0;
            if(that.tocNav){
                rendererMD.heading = function(text, level, raw) {
                    // const anchor = tocify.add(text, level);
                    if(level<that.maxTitle){
                        that.maxTitle=level
                    }
                    anchor+=1
                    that.toc.push(
                        {
                            'id':anchor,
                            'tag':level,
                            'text':text
                        }
                    )
                    return `<h${level} id="toc-nav${anchor}">${text}</h${level}>`;
                };
            }
            // customElements.define(TAG_NAME, Demobox)
            let html = marked(this.initialValue, {
                sanitize: false,
                renderer: rendererMD,

                ...this.markedOptions
            })
            this.html = html;
            // this.addCopyListener();
            if(this.imgView){
                this.addImageClickListener();
            }
        },
        addImageClickListener() {// 監聽檢視大圖
            const {imgs = []} = this;
            if (imgs.length > 0) {
                for (let i = 0, len = imgs.length; i < len; i++) {
                    imgs[i].onclick = null;
                }
            }
            setTimeout(() => {
                this.imgs = this.$refs.preview.querySelectorAll('img');
                for (let i = 0, len = this.imgs.length; i < len; i++) {
                    this.imgs[i].onclick = () => {
                        const src = this.imgs[i].getAttribute('src');
                        this.srcList[1]=src
                        this.url=src
                            setTimeout(() => {
                            document.getElementById("imgview").click()
                            },5)
                    };
                }
            }, 1000);
        },
        toTarget(target){
            target='#toc-nav'+target
            let toElement = document.querySelector(target);
            toElement.scrollIntoView({
                behavior: 'smooth',
                block: 'center',
                inline: 'nearest'
            })
        },
    },
    watch: {
        initialValue() {
            this.translateMarkdown();
        }
    },
    destroyed () {
        window.removeEventListener('scroll', this.scroll, false)
    },
};
</script>
<style lang="stylus" scoped>
@import '~@/assets/style/marked.css'
.marked
    display: flex;
    flex-flow: row nowrap;
    position: relative;
    align-items: flex-start;
    .write
        flex: 1 1 auto;
        width: 1%;
        overflow: hidden;
    .toc
        width: 220px;
        margin-left: 20px;
        border-left: 1px solid #efefee
        position: sticky;
        top: 100px;
        flex-shrink: 0;
        padding-left 10px
        .toc-top
            display: flex;
            justify-content: space-between;
            align-items center
            padding 10px 0
            .toc-title
                font-size 18px
                &:before
                    content '#'
                    color var(--main-6)
                    padding-right 3px
            .toc-close
                font-size 14px;
                color #989898
                cursor pointer

        li
            display table
            margin-bottom: 10px;
            line-height: 1em;
            text-align: left;
            font-size: 14px;
            color: #8599ad;
            transition: .2s;
            cursor pointer
            &:hover
                color var(--main-6)
                text-decoration: underline;
            &:before
                content '- '
        .acitve
            color var(--main-6)
    .toc-tag
        width 40px
        height 40px
        position fixed
        right 20px
        bottom 85px
        z-index 999
        background #585d5d
        display flex
        align-items: center;
        justify-content: center;
        flex-flow: column;
        transition all .3s
        cursor pointer
        &:hover
            background-image: linear-gradient(to right, #8EC5FC,#9FACE6)
            i:nth-child(1)
                transform translateX(2px)
            i:nth-child(3)
                transform translateX(-2px)
        i
            display: block;
            width: 24px;
            height: 2px;
            background-color: hsla(0,0%,100%,.75);
            margin: 3px 0;
            transition: all .2s ease-in-out;
.slide-fade-enter-active {
  transition: all .3s ease;
}
.slide-fade-leave-active {
  transition: all .3s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.slide-fade-enter, .slide-fade-leave-to
/* .slide-fade-leave-active for below version 2.1.8 */ {
  transform: translateX(10px);
  opacity: 0;
}
</style>
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章