markdown-it 实时渲染vue组件

:::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组件的命名规则有帕斯卡写法(如 )和连字符(如 ),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提供的两个渲染函数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,并正确解析的方法。请持续关注我!

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值