参考 Element Plus Demo 和 Vue SFC Playground 自定义预览组件
主流的组件库都有这样的预览功能,比如上图所示的Element Plus - Button。在预览之外,还有代码展示,Playground 等功能
Element Plus 的交互已经做的足够好,但从我个人写博客角度来看,还有以下诉求:
- 源码可以通过配置决定是否常驻展示
- playground 要能快速加载,像 Element Plus 的在我的网络环境下就要加载很久
- playground 的预置环境是可以自定义的,比如通过 CDN 加载一些需要的库
- ...
以上个性化需求都需要针对性开发,因此可以参考 Element Plus 和@vue/repl 的代码,实现一个更符合个人需求的预览组件
约定使用规范
在实现预览组件前,先要约定如何使用
这里我们参照element-plus/docs/en-US/component/button.md的格式,其中预览组件的核心代码如下:
通过:::demo
开头,:::
结尾。中间包裹一个示例代码文件的相对路径,:::demo
之后也可以补充一些描述
实现思路
从使用规范那里,我们可以得出“产出预览组件代码片段”是利用了 vitepress 的Markdown 扩展实现的
因此从 Element Plus 的 vitepress 配置文件入手:element-plus/docs/.vitepress/config/index.mts,看到配置了如下代码:
import { mdPlugin } from './plugins'
const config = {
markdown: {
config: (md) => mdPlugin(md),
},
};
查看element-plus/docs/.vitepress/config/plugins.ts,找到 Demo 的相关代码:
import mdContainer from 'markdown-it-container'
import createDemoContainer from '../plugins/demo'
export const mdPlugin = (md) => {
md.use(mdContainer, 'demo', createDemoContainer(md))
}
为 markdown-it 的 markdown 解析器创建块级自定义容器的插件
使用方式 md.use(require('markdown-it-container'), name [, options]);
name 容器名称,必填
options
validate 可选,“打开标记”后验证尾部的函数,成功则为
true
比如 Element Plus 的
!!params.trim().match(/^demo\s*(.*)$/)
,:::
就是打开标记,该验证函数就是验证是否符合::::demo
开头+零个或多个空格+零个或多个任意字符直至一行的结尾render 可选,类比 Vue 的 render
marker 可选,分隔符,默认就是
:
通过以上代码,我们便知道怎么自定义容器。来看看 Element Plus 自定义的 Demo 容器:element-plus/docs/.vitepress/plugins/demo.ts
重点分析render
函数
return {
render(tokens, idx) {
const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/)
if (tokens[idx].nesting === 1 /* means the tag is opening */) {
const description = m && m.length > 1 ? m[1] : ''
const sourceFileToken = tokens[idx + 2]
let source = ''
const sourceFile = sourceFileToken.children?.[0].content ?? ''
if (sourceFileToken.type === 'inline') {
source = fs.readFileSync(path.resolve(docRoot, 'examples', `${sourceFile}.vue`), 'utf-8')
}
if (!source) throw new Error(`Incorrect source file: ${sourceFile}`)
return `<Demo source="${encodeURIComponent(
md.render(`\`\`\` vue\n${source}\`\`\``)
)}" path="${sourceFile}" raw-source="${encodeURIComponent(
source
)}" description="${encodeURIComponent(md.render(description))}">
<template #source><ep-${sourceFile.replaceAll('/', '-')}/></template>`
} else {
return '</Demo>\n'
}
},
}
先搞清楚tokens
和idx
,整个 markdown 文件会被解析成tokens
数组,而idx
就是命中该插件的 token 索引。以约定使用规范中的内容做测试,在render
中打印tokens[idx]
时,会得到如下输出:
输出结果
// idx 115
// tokens[idx]
Token {
type: 'container_demo_open',
tag: 'div',
attrs: null,
map: [ 111, 115 ],
nesting: 1,
level: 0,
children: null,
content: '',
markup: ':::',
info: 'demo Use `disabled` attribute to determine whether a button is disabled. It accepts a `Boolean` value.',
meta: null,
block: true,
hidden: false
}
// idx 119
// tokens[idx]
Token {
type: 'container_demo_close',
tag: 'div',
attrs: null,
map: null,
nesting: -1,
level: 0,
children: null,
content: '',
markup: ':::',
info: '',
meta: null,
block: true,
hidden: false
}
可以看出,开始和结束标记会触发render
函数,那其中的内容,便可以通过idx + N
来得到,分别输出tokens[idx + 1]
、tokens[idx + 2]
、tokens[idx + 3]
、
输出结果
// tokens[idx + 1]
Token {
type: 'paragraph_open',
tag: 'p',
nesting: 1,
level: 1,
children: null,
content: '',
markup: '',
info: '',
}
// tokens[idx + 2]
Token {
type: 'inline',
tag: '',
nesting: 0,
level: 2,
children: [
Token {
type: 'text',
tag: '',
nesting: 0,
level: 0,
children: null,
content: 'button/disabled',
markup: '',
info: '',
}
],
content: 'button/disabled',
markup: '',
info: '',
}
// tokens[idx + 3]
Token {
type: 'paragraph_close',
tag: 'p',
nesting: -1,
level: 1,
children: null,
content: '',
markup: '',
info: '',
}
可以看到很多信息,但是以目的为导向,我们只需要解析tokens[idx + 2]
的数据即可
回头再看 Element Plus 的代码,就比较清晰了。接着就是看下<Demo></Demo>
组件了。文件路径为:element-plus/docs/.vitepress/vitepress/components/vp-demo.vue
迁移改造 vp-demp.vue
安装 @vueuse/core、@element-plus
替换 locale 相关代码
去掉 useSourceCode 的引入,主要是通过 props.path 得出 github url,对文档库别人反馈有用,对个人博客用处不大,后续按需实现
更新 usePlayground,跳转至Vue SFC Playground
jsfunction utoa(data) { return btoa(unescape(encodeURIComponent(data))) } const MAIN_FILE_NAME = 'App.vue' const IMPORT_MAP_FILE_NAME = 'import-map.json' export const usePlayground = (source) => { const code = decodeURIComponent(source) const originCode = { [MAIN_FILE_NAME]: code, // [IMPORT_MAP_FILE_NAME]: `{ // "imports": { // "vue": "https://play.vuejs.org/vue.runtime.esm-browser.js", // "vue/server-renderer": "https://play.vuejs.org/server-renderer.esm-browser.js", // "dayjs": "https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js" // } // }`, } const link = `https://play.vuejs.org/#${utoa(JSON.stringify(originCode))}` return { link, } }
Vue SFC Playground 是基于@vue/repl 二次封装,考虑到高频场景就是 传输若干文件内容 + 一些
import
语句,为了省事,我们直接使用了它。如果后续还有更多个性化需求,比如要注入其它文件import map
不满足的,就可以使用@vue/repl Repl.vue的previewOptions
选项,其中headHTML
、customCode
都有妙用替换 CSS 变量,文件里有一些
--bg-color
、--el-text-color-secondary
这类变量,可以替换成 vitepress 主题的变量,更适配主题去 Element Plus,将相关组件(含 Icon)都移除换成原生的,减少依赖
实现 vite 插件,在构建时将 Demo 组件的 import 语句注入对应 markdown 文件
PS
有考虑过在 markdown 中写:::demo
和<Demo>
的成本,一开始认为基本一样,无非是多写了些import
组件的语句,但好处是可以简洁明了的给<Demo>
组件传参,灵活性更高。但并非如此,看看plugins/markdown/demo.js
中对<Demo>
组件注入的属性就知道多麻烦了
在render
函数中,有这样一段代码:<ep-${sourceFile.replaceAll('/', '-')}/>
。是根据sourceFile
名称生成的组件名,分析代码会发现,它是和每个 Demo 组件一一对应的。但是主 markdown 文件并没有引入注册这些组件。因此需要在 vite 构建工具里实现提前注入 import 语句
PPS
一开始我考虑的是在 <Demo>
组件中,通过props.path
得到组件名,然后利用 defineAsyncComponent
、动态import
、<component />
组件 实现异步加载。但这种方式仅限于开发环境(即有本地服务器的情况下)。打包时由于是动态引入,path
还没传入,相关组件均不会被打包,导致打包后 examples 下 的组件均不能展示
Element Plus 实现了一个 MarkdownTransform 的 vite 插件:docs/.vitepress/config/vite.ts - MarkdownTransform,弊端是生成的import
语句引入路径是写死的,这就要求文档和 examples 的路径一开始就约定好,不能变动
由于博客文档的路径多样,也为了适配更多项目。我将引入路径作为 frontmatter 配置项,并提供默认值
完整实现如下:
PPPS
在插件的transform
钩子中,一开始我没有去遍历页面同名文件夹下的文件而是通过正则从 markdown 内容中提取,这种方式无法区分 markdown 文件中注释的、示例的 demo 块。还是 Element Plus 的好点,直接引入该页面同名文件夹下的所有文件,倒逼你先去创建 Demo 文件
PPPPS
Element Plus 解析:::demo
后的内容作为 description,我觉得有些浪费空间了。description 完全可以以 markdown 形式写在 demo 块前。而:::demo
后的空间我作为<Demo>
组件的额外传参用。比如::::demo [is-hidden-ops is-show-raw-source-permanently]
---
relativeExamples: ../../
---
:::demo [is-show-raw-source-permanently is-hidden-ops]
vue-repl-playground/test
::: <!-- 占位内容,避免markdown解析问题 -->
import path from 'path'
import mdContainer from 'markdown-it-container'
import createDemoContainer from './plugins/markdown/demo.js'
import appendDemoImportsToMd from './plugins/vite/append-imports-to-markdown.js'
export default {
markdown: {
config: (md) => md.use(mdContainer, 'demo', createDemoContainer(md)),
},
vite: {
plugins: [{ examplesRoot: path.resolve('examples') }],
},
}
import path from 'path'
import fs from 'fs'
export default function createDemoContainer(md) {
return {
validate(params) {
return !!params.trim().match(/^demo\s*(.*)$/)
},
render(tokens, idx) {
// 这里只能获取到 token 字符串信息,已知examples路径,后续拼接的字符串都需要提供
const m = tokens[idx].info.trim().match(/^demo\s*\[(.*?)\].*$/)
if (tokens[idx].nesting === 1 /* means the tag is opening */) {
const otherProps = m && m.length > 1 ? m[1] : ''
const sourceFileToken = tokens[idx + 2]
let source = ''
const sourceFile = sourceFileToken.children?.[0].content ?? ''
if (sourceFileToken.type === 'inline') {
source = fs.readFileSync(path.resolve('examples', `${sourceFile}.vue`), 'utf-8')
}
if (!source) throw new Error(`Incorrect source file: ${sourceFile}`)
return `<Demo source="${encodeURIComponent(
md.render(`\`\`\` vue\n${source}\`\`\``)
)}" path="${sourceFile}" raw-source="${encodeURIComponent(source)}"${
otherProps ? ` ${otherProps}` : ''
}>
<template #source><exp-${sourceFile.replaceAll('/', '-')}/></template>`
} else {
return '</Demo>\n'
}
},
}
}
import path from 'path'
import fs from 'fs'
import { camelize } from '@vue/shared'
export default function appendImportsToMarkdown(options = {}) {
const { examplesRoot } = options
const relativeExamples = './' // 文档相对examples文件夹的位置,可通过frontmatter自定义
return {
name: 'append-imports-to-markdown',
enforce: 'pre',
transform(content, filename) {
if (!filename.endsWith('.md')) return
const pageId = path.basename(filename, '.md') // 当前文档名称
const pageDemoRoot = path.resolve(examplesRoot, pageId)
if (!fs.existsSync(pageDemoRoot)) return // 如果examples下没有当前文档的同名目录,返回
const customRelativeExamples = '' // TODO: 后续从content的frontmatter中解析出来
const files = fs.readdirSync(pageDemoRoot)
const imports = []
for (const item of files) {
if (!/\.vue$/.test(item)) continue
const file = item.replace(/\.vue$/, '')
const name = camelize(`Exp-${pageId}-${file}`)
imports.push(
`import ${name} from '${
customRelativeExamples || relativeExamples
}examples/${pageId}/${item}'`
)
}
return (content += `\n<script setup>\n
${imports.join('\n')}
</script>\n
`)
},
}
}
最终成果
默认只展示效果,可以自行展开/收起代码
当想让代码和效果同时静态展示时,设置is-show-raw-source-permanently
为true
。此时还可以将is-hidden-ops
设置成true
不展示操作按钮让代码和效果更紧凑
<template>
<div>{{ msg }}</div>
<hr />
<div>world</div>
</template>
<script setup>
import { ref } from 'vue'
const msg = 'hello'
</script>