:::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 三)
生命周期
通常情况下,我们只需要使用markdown-it提供的两个渲染函数
render
和render_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的
uuid
和tag
。 - getCop 通过标签名获取组件
- mountApp 通过上面记录的
copDis
对组件进行挂载。并将生成的应用记录在mountedApp
,返回真值,表示调用后就丢弃 - unMountApp 通过上面记录的
mountedApp
对应用进行卸载,返回真值,表示调用后就丢弃 - 在
md.renderer.rules.html_block
获取tag进行合法性判断,并为其注册complete
和before_check
生命周期
至此我们就实现了所有的功能!
写在最后
为了实现更多的扩展性和可能,我可能会探索如果在md编写slot和prop,并正确解析的方法。请持续关注我!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。