头图

:::info
你可能听说过,vuepress提供在markdown-it中渲染vue组件的功能。在这一节里,我们将实现markdown-it渲染vue组件,与vuepress不同的是,我们将能够在实时编辑器里使用。
:::

vuepress里的实现

在语法之间的转换工作上,webpack 的 loader 可是很擅长的。所以,vuepress 自定义了一个 markdownLoader 来将 Markdown 转成 Vue,再通过 vue-loader 得到最终的 HTML。

通过这段描述可以知道,vuepress实际上是在打包时对markdown中的vue组件进行解析,对于线上的项目,必然不可能随时打包,这个方案遂作罢。

天马行空

vue提供了一个很有意思的api:createApp,每个项目都会用到它,负责将根节点转化为应用实例,并挂载到body上面,你能在用vite新创建的vue项目的main.js文件里找到它。

import App from './App.vue'
const app = createApp(App)
app.mount('#app')

如果我们在markdown-it中开启html的解析,就可以获取自定义组件的源码,在其身上添加一个id并记录它的相关信息,等到文本被解析为html代码时再通过createApp创建正确的组件再将它通过id挂载到页面上对应的位置,这样我们就实现了markdown-it渲染自定义vue组件的功能。
听上去很酷不是吗?让我们来动手实现一下吧!

脚踏实地

显然,我们要编写一个markdown-it的插件,对应的render规则是html_block。但在此之前,有一些准备工作要做。

识别vue组件

vue组件的命名规则有帕斯卡写法(如 <Button/>)和连字符(如 <button-1/>),vuepress重写了markdown-it的解析规则,在 HTML_SEQUENCES 这个正则数组里添加了两个元素:

// PascalCase Components
[/^<[A-Z]/, />/, true],
// custom elements with hyphens
[/^<\w+\-/, />/, true],

然而在实际应用时,我发现即使原生的html_block规则也能匹配上述的两种写法,答案就在HTML_SEQUENCES的第七个规则:

[new RegExp(HTML_OPEN_CLOSE_TAG_RE.source + '\\s*$'), /^$/, false],

这个规则确保了vue组件能被正常解析,可以在html_block的render获取到。

  • 使用 markdown-it 解析 markdown 代码(读 vuepress 三)
  • github源码

    生命周期

    通常情况下,我们只需要使用markdown-it提供的两个渲染函数renderrender_inline就能实现大部分需求了。但是为了能够在实时编辑器中使用,我们需要不断重复清理挂载两个操作。
    清理是在下一次渲染前对前一次操作创建的应用执行unmount操作取消挂载,挂载则是在html被渲染到页面上后执行mount操作挂载,如果我们能够通过生命周期去管理代码的执行顺序,将会大大便利我们的功能实现。
    生命周期的创意来源于prismjs,如果你看过我之前的文章应该会有印象,它是一个代码块的渲染包,prismjs通过一系列hook划分出了不同的生命周期。

  • before-sanity-check
  • before-highlight
  • before-insert
  • after-highlight
  • complete

markdown-it和prismjs有很多相似之处,它们都是将一段html字符串渲染到页面上,因此我们能够借鉴生命周期的实现。

// 部分代码省略只留下核心代码
const md = new MarkdownIt(defaultConfig)
const env = {} // mdit全局的信息对象
const hooks_env = {} // hooks_env 的全局信息对象
const hooks = useHook()
env.hooks = hooks // 挂载到env上方便插件访问
function useHook() {
    const all = {}
    const add = function (name, callback) {
        all[name] = all[name] || []
        all[name].push(callback)
    }
    const run = function (name, env) {
        let callbacks = all[name]
        if (!callbacks || !callbacks.length) {
            return
        }
        all[name] = callbacks.filter((callback, idx) => {
            if (!callback) {
                return false
            }
            const flag = callback(env)
            return !flag
        })
    }
    return {
        all,
        add,
        run,
    }
}
  • 我们定义了hooks,all存储一个对象,key表示生命周期,value是一个数组,表示该生命周期需要依次调用的函数
  • run触发指定生命周期,且当函数主动返回真值时取消注册,表示是一次性函数
  • add为指定生命周期添加一个执行函数

现在为渲染函数定义几个基本的生命周期

const getOnceRes = str => {
    hooks_env.code = str
    hooks.run('before_check', hooks_env)
    if (str === undefined || str === null) return
    hooks.run('before_render', hooks_env)
    output.value = md.render(str, env)
    hooks_env.rendered_code = output.value
    hooks.run('after_render', hooks_env)
    return output.value
}
watch(output, () => {
    nextTick(() => {
        hooks.run('complete', hooks_env)
    })
})

在这里我们定义了几个生命周期

  • before_check 渲染值校验前
  • before_render 渲染前
  • after_render 渲染后
  • complete html被渲染到页面上后

插件编写

import { createApp } from 'vue'
const copDis = {} // 组件名称:组件实例
;(() => {
    const modules = import.meta.glob('@/components/customCmp/*.vue')
    for (let key in modules) {
        const new_key = /\/([\w\d-]+)\.vue$/.exec(key)?.[1]
        if (!new_key) continue
        copDis[new_key.toLowerCase()] = modules[key]
    }
})()

export const customComponentPlugin = md => {
    const getUniqueId = () => {
        return '_' + Math.random().toString(36).slice(2, 7) + Date.now()
    }
    const getApp = (Cop, props) => {
        return createApp(Cop, props)
    }
    const vaildCop = tag => {
        return Object.keys(copDis).some(name => {
            return new RegExp(`^${name}$`, 'i').test(tag)
        })
    }
    const getNewCode = (code, tag, copInfo) => {
        // 校验
        const flag = code.indexOf(tag) !== -1
        if (!flag) return console.log('校验失败', tag)
        // 执行
        const uuid = getUniqueId()
        copInfo.push([uuid, tag])
        // return code.replace(tag, `${tag} id=${uuid}`)
        return `<div id=${uuid}></div>`
    }
    const getCop = tag => {
        return copDis[tag.toLowerCase()]
    }
    const mountApp = (copInfo, mountedApp) => {
        copInfo.forEach(async ([uuid, tag], idx) => {
            const module = await getCop(tag)()
            const cop = markRaw(module.default)
            const app = getApp(cop)
            if (!document.querySelector('#' + uuid)) {
                console.log('mount查找不到id', uuid)
                // copInfo.splice(idx, 1)
                return
            }
            app.mount('#' + uuid)
            mountedApp.push([app])
        })
        return true
    }
    const unMountApp = (copInfo, mountedApp, toMount) => {
        mountedApp.forEach(([app]) => {
            if (!document.body.contains(app._container)) {
                return console.log('unmount查找不到', app._container.id)
            }
            app.unmount()
        })
        toMount = null
        return true
        // copInfo.splice(0, copInfo.length)
        // mountedApp.splice(0, mountedApp.length)
    }

    const defaultR = md.renderer.rules.html_block?.bind(md.renderer.rules) || md.renderer.renderToken.bind(md.renderer)
    md.renderer.rules.html_block = (...args) => {
        const copInfo = []
        const mountedApp = []
        const [tokens, idx, options, env, self] = args
        const { hooks } = env,
            token = tokens[idx]
        if (!hooks || !token) return console.log('token或hooks未给出')
        const tag = /<([\w\d-]+)\/?>/.exec(tokens[idx].content)?.[1]
        if (!vaildCop(tag)) return ''
        const rawCode = defaultR(...args)
        const newCode = getNewCode(rawCode, tag, copInfo)
        const toMount = () => mountApp(copInfo, mountedApp)
        const toUnMount = () => unMountApp(copInfo, mountedApp, toMount)
        hooks.add('complete', toMount)
        hooks.add('before_check', toUnMount)
        return newCode
    }
}

Dont be afraid!这里没有银弹! 我将逐条解析。
这里使用了vite的glob导入,modules可能是这样的

const modules = {
  './dir/foo.js': () => import('./dir/foo.js'),
  './dir/bar.js': () => import('./dir/bar.js')
}
  • 我们希望copDis存储一个键值对,组件名:组件地址,我也是这么做的。键名被设置为小写,用以忽略组件名的大小写
  • getUniqueId用来获取一个唯一的id,你可以看到我在最前面添加了'_',这是为了匹配id的命名规则。
  • getApp通过组件实例获得一个应用。
  • vaildCop 用来检验标签是被记录在copDis,据此判断合法性,否则就输出空字符串。
  • getNewCode 返回一个占位div,后续应用会被挂载到上面。并记录每一个合法tag的uuidtag
  • getCop 通过标签名获取组件
  • mountApp 通过上面记录的copDis对组件进行挂载。并将生成的应用记录在mountedApp,返回真值,表示调用后就丢弃
  • unMountApp 通过上面记录的mountedApp对应用进行卸载,返回真值,表示调用后就丢弃
  • md.renderer.rules.html_block获取tag进行合法性判断,并为其注册completebefore_check生命周期

至此我们就实现了所有的功能!

写在最后

为了实现更多的扩展性和可能,我可能会探索如果在md编写slot和prop,并正确解析的方法。请持续关注我!


张小仙人
12 声望0 粉丝

我是大睡个