在Vue中使用marked.js解析Markdown,生成目录,运行代码示例

前言

对于我来说一个博客系统就是用来总结自己所学得知识的。写写文章,巩固技术,写文章我就采用了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 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!