Vue3 + Vuepress2 编写自定义组件示例

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>

```

:::

思路分析⭐️

  1. 展示代码块是一个组件,我们首先需要编写好一个demo组件进行代码块的展示
  2. 通过自定义容器来编写demo代码
  3. 拓展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 });
    		},
    	};
    };
    arduino 复制代码
    docs
    ├── .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 - 可选 (:), 用于分隔符的字符

    参考markddown-it-container配置

    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进行相应的配置

    js 复制代码
    import { defineUserConfig } from 'vuepress';
    import { markdownContainers } from './md-loader';
    
    export default defineUserConfig({
    	lang: 'zh-CN',
    	title: 'LUI',
    	description: '这是我的第一个 VuePress 站点',
    	plugins: [
    		markdownContainers(),
    	],
    });

    👏到这里为止,我们已经完成了前两步,看下效果!👏

第三步

我们已经有了雏形,接下来只需要把插槽demo的地方替换成我们需要展示的组件即可。

要渲染代码片段,关注以下两点:

  1. 如何渲染: 在 Vue 中,可以使用一个普通的 JavaScript 对象来定义组件。把代码片段转化成一个对象,之后在父元素中注册一下即可
  2. 组件的位置: 修改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)
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

重新运行项目,看到以下效果就大功告成了!🤌

写在最后👇

以上案例有两个明显缺陷,会在下一篇文章解决。

  1. 以上案例使用自定义插件是需要每次都手动在client.js手动引入为全局组件才能使用。
  2. 不支持JSX/TSX组件

参考📝

如何优雅的使用Vuepress编写组件示例

谈谈 Element 文档中的 Markdown 解析

vuepress-plugin-demoblock-plus

相关推荐
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch7 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光7 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   7 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   7 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web8 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery