Vue3 + Vuepress2 编写自定义组件示例
背景🧐
- 在使用
Vuepress2
搭建自己组件文档时,希望跟Element UI文档一样可以很方便的进行书写并展示 - 网上找了很多都是
vuepress1.x
的方案,在vuepress2.x
并不适用,于是对其进行了改写
示例效果🤩
- 只需要书写一次代码,就可以实现以上效果
vue
::: demo 简介
```vue
<button>{{ name }}</button>
<script>
import { ref, defineComponent } from 'vue';
export default defineComponent({
setup() {
const name = ref('按钮');
return {
name,
};
},
});
</script>
```
:::
思路分析⭐️
- 展示代码块是一个组件,我们首先需要编写好一个demo组件进行代码块的展示
- 通过自定义容器来编写demo代码
- 拓展markdown的渲染方式,通过修改
markdown-it
实例,将输入的代码块可以输出符合Vue Template
语法的代码块
直接开干👊
第一步
-
根据Vuepress2官方文档构建应用,构建完成后先
run
一次项目后,得到项目结构docs ├── .vuepress │ ├── .cache │ └── .temp └── README.md
-
先写一个用于展示代码块的组件,
.vuePress
下新建一个components
目录,并创建demoBlock.vue
,此处为了更好的阅读体验,省略样式
vue
// demoBlock.vue
<template>
<div class="block">
<div class="demo-content">
<!-- 插入组件 -->
<slot name="demo"></slot>
</div>
<div class="meta" :style="{ height: `${metaHeight}px` }">
<div class="description" ref="description">
<!-- 插入描述信息 -->
<slot name="description"></slot>
</div>
<div class="code-content" ref="codeContent">
<!-- 插入代码块 -->
<slot name="source"></slot>
</div>
</div>
<div class="demo-block-control" @click="clicks">
<span>显示代码</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const description = ref(null);
const codeContent = ref(null);
const meta = ref(null);
const metaHeight = ref(0);
const clicks = () => {
if (metaHeight.value > 0) {
metaHeight.value = 0;
} else {
metaHeight.value = description.value?.clientHeight + codeContent.value?.clientHeight + 30;
}
};
</script>
- 由于vuePress2是不会默认将
components
目录下的组件进行全局注册,为了方便使用,我们需要手动把组件进行全局注册,具体参考注册vue组件, 在.vuepress
文件下创建client.js
js
// client.js
import { defineClientConfig } from '@vuepress/client';
import demoBlock from './components/demoBlock.vue';
export default defineClientConfig({
enhance({ app, router, siteData }) {
app.component('demoBlock', demoBlock);
},
});
docs
├── .vuepress
│ ├── .cache
│ ├── .temp
│ ├── client.js
│ └── components
│ └── demoBlock.vue
└── README.md
第二步
-
接下来我们利用自定义容器在
README.md
中编写我们的组件代码vue// README.md ::: demo 简介 ```vue <div>demo</div> ``` :::
-
由于目前markdown还不认识我们的语法,因此我们需要自定义一个vuepress插件来处理该语法
-
vuepress2提供的extendsMarkdown钩子可以添加额外的 markdown-it 插件、应用额外的自定义功能的特性,创建一个自定义的插件
⚠️注意:vuepress2中extendMarkdown
已改名为extendsMarkdown
-
创建
md-loader
目录,创建index.js
作为插件的入口,创建containers.js
作为解析器js// index.js import demoBlockContainers from './common/containers.js'; export const markdownContainers = (options) => { return { name: 'markdown-containers', extendsMarkdown: (md) => { md.use(demoBlockContainers); md.linkify.set({ fuzzyEmail: false }); }, }; };
arduinodocs ├── .vuepress │ ├── .cache │ ├── .temp │ ├── client.js │ ├── components │ │ └── demoBlock.vue │ ├── config.js │ └── md-loader │ ├── common │ │ ├── containers.js │ └── index.js └── README.md
markdown-it-container
是一个插件,它允许您创建用于渲染自定义容器块的特定语法。通过该插件,您可以轻松地为 Markdown 添加额外的功能,如代码块、警告框、注释等,并可以自定义这些容器块的样式和渲染方式。markdown-it-container
有两个参数- name - 容器名称(必选)
- options :
- validate - 可选, 在开始标记后验证尾部的函数,成功时应该返回true。
- render - 可选, 用于打开/关闭令牌的渲染函数。
- ...todo
- marker - 可选 (
:
), 用于分隔符的字符
js// containers.js import mdContainer from 'markdown-it-container'; export default (md) => { md.use(mdContainer, 'demo', { validate(params) { return params.trim().match(/^demo\s*(.*)$/); }, render(tokens, idx) { //渲染器函数 const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/); if (tokens[idx].nesting === 1) { const description = m && m.length > 1 ? m[1] : ''; // fence为 const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : ''; return `<demo-block> <template v-slot:demo>demo</template> ${description ? `<template v-slot:description><div>${description}</div></template>` : ''} <template v-slot:source> `; } return `</template></demo-block>`; }, }); };
⚠️注意:vuepress2的slot插槽必须放在template上,否则无法生效
-
创建配置文件
config.js
进行相应的配置jsimport { defineUserConfig } from 'vuepress'; import { markdownContainers } from './md-loader'; export default defineUserConfig({ lang: 'zh-CN', title: 'LUI', description: '这是我的第一个 VuePress 站点', plugins: [ markdownContainers(), ], });
👏到这里为止,我们已经完成了前两步,看下效果!👏
第三步
我们已经有了雏形,接下来只需要把插槽demo的地方替换成我们需要展示的组件即可。
要渲染代码片段,关注以下两点:
- 如何渲染: 在 Vue 中,可以使用一个普通的 JavaScript 对象来定义组件。把代码片段转化成一个对象,之后在父元素中注册一下即可
- 组件的位置: 修改
containers.js
,<!--pre-render-demo:${content}:pre-render-demo-->
是自定义的占位符,在处理组件展示时,我们可以通过该占位符知道需要把组件插入到页面哪个位置
- 修改
containers.js
js
// containers.js
import mdContainer from 'markdown-it-container';
export default (md) => {
//将markdown-it-container插件加载到当前的解析器实例中
md.use(mdContainer, 'demo', {
validate(params) {
//函数在开始标记后验证尾部,成功时返回true
return params.trim().match(/^demo\s*(.*)$/);
},
render(tokens, idx) {
//渲染器函数
const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
if (tokens[idx].nesting === 1) {
const description = m && m.length > 1 ? m[1] : '';
const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : '';
return `<demo-block>
<template v-slot:demo><!--pre-render-demo:${content}:pre-render-demo--></template>
${description ? `<template v-slot:description><div>${description}</div></template>` : ''}
<template v-slot:source>
`;
}
return `</template></demo-block>`;
},
});
};
- 修改
index.js
文件- src 为
markdown source
- env是markdown解析出来的Vue SFC Blocks,在mdit-vue/plugin-sfc有相应的赘述(与vuepress1不同,vuepress会在src处返回html及dataBlockString)
- src 为
js
// index.js
import demoBlockContainers from './common/containers.js';
import renderDemoBlock from './common/render';
export const markdownContainers = (options) => {
return {
name: 'markdown-containers',
extendsMarkdown: (md) => {
const { render } = md;
const render$1 = render.bind(md);
md.render = (src, env) => {
let result = render$1(src, env);
const { template, script } = renderDemoBlock(result);
return template;
};
md.use(demoBlockContainers);
md.linkify.set({ fuzzyEmail: false });
},
};
};
- 每一个 Markdown 文件,首先都会编译为 HTML ,然后转换为一个 Vue 单文件组件 (SFC),一个典型的单文件组件包括三块:template,script 与 style
render.js
主要就是把占位符中的内容分别提取出template, script, style(这里暂不处理)
js
// render.js
import { stripScript, stripStyle, stripTemplate, genInlineComponentText } from './util.js';
export default function (content) {
if (!content) {
return content;
}
const startTag = '<!--pre-render-demo:';
const startTagLen = startTag.length;
const endTag = ':pre-render-demo-->';
const endTagLen = endTag.length;
let componenetsString = ''; // 组件引用代码
let templateArr = []; // 模板输出内容
let styleArr = []; // 样式输出内容
let id = 0; // demo 的 id
let start = 0; // 字符串开始位置
let commentStart = content.indexOf(startTag);
let commentEnd = content.indexOf(endTag, commentStart + startTagLen);
while (commentStart !== -1 && commentEnd !== -1) {
templateArr.push(content.slice(start, commentStart));
const commentContent = content.slice(commentStart + startTagLen, commentEnd); // 获取到模板内容
const html = stripTemplate(commentContent); // 获取到模板的html内容
const script = stripScript(commentContent, toString(id)); // 获取到模板的script内容,id必须时string
// const style = stripStyle(commentContent); // 暂时不处理style内容
const demoComponentContent = genInlineComponentText(html, script, toString(id)); // 示例组件代码内容
const demoComponentName = `render-demo-${id}`; // 示例代码组件名称
templateArr.push(`<${demoComponentName} />`);
// styleArr.push(style);
componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`;
// // 重新计算下一次的位置
id++;
start = commentEnd + endTagLen;
commentStart = content.indexOf(startTag, start);
commentEnd = content.indexOf(endTag, commentStart + startTagLen);
}
// 仅允许在 demo 不存在时,才可以在 Markdown 中写 script 标签
let pageScript = '';
if (componenetsString) {
pageScript = `<script>
import * as Vue from 'vue'
export default {
name: 'component-doc',
components: {
${componenetsString}
}
}
</script>`;
} else if (content.indexOf('<script>') === 0) {
start = content.indexOf('</script>') + '</script>'.length;
pageScript = content.slice(0, start);
}
// 合并 style 内容
let styleString = '';
if (styleArr && styleArr.length > 0) {
styleString = `<style>${styleArr.join('')}</style>`;
} else {
styleString = `<style></style>`;
}
templateArr.push(content.slice(start));
return {
template: templateArr.join(''),
script: pageScript,
style: styleString,
};
}
- 参考Element官方处理方式进行修改。
- 代码片段转换成组件,代码片段的 script 原本就是导出对象。把 template 转换成 render 函数,在vue3中已经遗弃使用
vue-template-compiler
,取而代之的是@vue/compiler-sfc,将Vue单文件组件(sfc)编译成JavaScript,即可执行的render函数,再将 script 与 render 函数合并,这样就把代码片段转换成组件。
js
// util.js
import { compileTemplate, compileScript, parse } from '@vue/compiler-sfc';
const ScriptSetupPattern = /<(script)(?:.* \bsetup\b)?[^>]*>([\s\S]+)<\/\1>/;
function stripScript(content, id) {
const result = content.match(ScriptSetupPattern);
const source = result && result[0] ? result[0].trim() : '';
if (source) {
const { descriptor } = parse(source);
const { content: scriptContent } = compileScript(descriptor, {
refSugar: true,
id,
});
return scriptContent;
}
return source;
}
function stripStyle(content) {
const result = content.match(/<(style)\s*>([\s\S]+)<\/\1>/);
return result && result[2] ? result[2].trim() : '';
}
// 编写例子时不一定有 template。所以采取的方案是剔除其他的内容
function stripTemplate(content) {
content = content.trim();
if (!content) {
return content;
}
return content.replace(/<(script|style)[\s\S]+<\/\1>/g, '').trim();
}
function pad(source) {
return source
.split(/\r?\n/)
.map((line) => ` ${line}`)
.join('\n');
}
function genInlineComponentText(template, script, id) {
const finalOptions = {
id: `inline-component-${id}`,
source: `${template}`,
filename: `inline-component-${id}`,
// compiler: TemplateCompiler,
compilerOptions: {
mode: 'function',
},
};
const compiledComponent = compileTemplate(finalOptions);
// tips
if (compiledComponent.tips && compiledComponent.tips.length) {
compiledComponent.tips.forEach((tip) => {
console.warn(tip);
});
}
// errors
if (compiledComponent.errors && compiledComponent.errors.length) {
console.error(
`\n Error compiling template:\n${pad(compiledComponent.source)}\n` + compiledComponent.errors.map((e) => ` - ${e}`).join('\n') + '\n'
);
}
let demoComponentContent = `
${compiledComponent.code.replace('return function render', 'function render')}
`;
script = script.trim();
if (script) {
script = script
.replace(/export\s+default/, 'const democomponentExport =')
.replace(/import ({.*}) from 'vue'/g, (s, s1) => `const ${s1} = Vue`)
.replace(/const ({ defineComponent as _defineComponent }) = Vue/g, 'const { defineComponent: _defineComponent } = Vue');
} else {
script = 'const democomponentExport = {}';
}
demoComponentContent = `(function() {
${demoComponentContent}
${script}
return {
render,
...democomponentExport
}
})()`;
return demoComponentContent;
}
export { stripScript, stripStyle, stripTemplate, genInlineComponentText };
处理完template、script后,我们回到index.js
,如何把该组件渲染到页面上呢,我们需要手动修改markdown it的渲染内容。
- 再次修改
index.js
- 通过env返回的Vue SFC Blocks,手动修改template和script
js
import demoBlockContainers from './common/containers.js';
import renderDemoBlock from './common/render';
import { createSfcRegexp, TAG_NAME_TEMPLATE } from '@mdit-vue/plugin-sfc';
const sfcRegexp = createSfcRegexp({ customBlocks: [TAG_NAME_TEMPLATE] });
export const markdownContainers = (options) => {
return {
name: 'markdown-containers',
extendsMarkdown: (md) => {
const { render } = md;
const render$1 = render.bind(md);
md.render = (src, env) => {
let result = render$1(src, env);
const { template, script } = renderDemoBlock(result);
const templateSfcBlock = `<template>${template}</template>`.match(sfcRegexp)?.groups;
const scriptSfcBlock = script?.match(sfcRegexp)?.groups;
env.sfcBlocks.template = templateSfcBlock || null;
env.sfcBlocks.template = templateSfcBlock || null;
env.sfcBlocks.script = scriptSfcBlock;
return template;
};
md.use(demoBlockContainers);
md.linkify.set({ fuzzyEmail: false });
},
};
};
最后目录结构
arduino
docs
├── .vuepress
│ ├── .cache
│ ├── .temp
│ ├── client.js
│ ├── components
│ │ └── demoBlock.vue
│ ├── config.js
│ └── md-loader
│ ├── common
│ │ ├── containers.js
│ │ ├── render.js
│ │ └── util.js
│ └── index.js
└── README.md
重新运行项目,看到以下效果就大功告成了!🤌
写在最后👇
以上案例有两个明显缺陷,会在下一篇文章解决。
- 以上案例使用自定义插件是需要每次都手动在
client.js
手动引入为全局组件才能使用。 - 不支持JSX/TSX组件