1. 需求背景
组件库要被人家使用,所以一定需要有使用demo和API解析。这两个工作是可以合并在同一份markdown
文件里面的,所以我们需要提取markdown
里面的代码片段,然后渲染成demo就可以,这块我们手写一个loader来处理,具体的逻辑在build/markdown-loader.js
里面。
1.1. 添加loader
我们要转换.md
文件为HTML标签,同时提取里面的代码块,然后对代码块做一些自定义处理,最后返回vue单文件组件模板,传递给vue-loader
去解析。多loader是串联链式调用的,按逆序执行,所以我们在vue.config.js
里面添加下面对.md
的处理👇
config.module
.rule('md')
.test(/\.md/)
.use('vue-loader')
.loader('vue-loader')
.options({
compilerOptions: {
preserveWhitespace: false
}
})
.end()
.use('markdown-loader')
.loader(
require('path').resolve(__dirname, './build/markdown-loader.js')
)
.end();
1.2. loader逻辑
markdown-it
这个库可以把Markdown文档转换成HTML标签,配合markdown-it-container
这个插件可以提取特定标签内内容。在steam-game-ui
里面我们定义:::demo 代码块 :::
里面的代码块需要被渲染成Vue组件。
const md = require('markdown-it')();
const VueTemplateComplier = require('vue-template-compiler');
const { parse, compileTemplate } = require('@vue/component-compiler-utils');
let componentCodeList = [];
md.use(require('markdown-it-container'), 'demo', {
// 验证代码块为【:::demo :::】才进行渲染
validate: function(params) {
return params.trim().match(/^demo\s+(.*)$/);
},
// 代码块渲染
render: function (tokens, idx) {
const token = tokens[index];
const tokenInfo = token.info.trim().match(/^demo\s*(.*)$/);
if (token.nesting === 1) { // 开始标签
// 获取demo的第一行代码描述,即::: demo xxx 中的xxx
const desc = tokenInfo && tokenInfo.length > 1 ? tokenInfo[1] : '';
// 获取demo中需要渲染的内容
const nextIndex = tokens[index + 1];
const content = nextIndex.type === 'fence' ? nextIndex.content : '';
// 将content解析为vue组件基本属性对象
let { template, script, styles } = parse({
source: content,
compiler: VueTemplateComplier,
needMap: false
});
// 将content的内容分别提炼出来并转码,给codepen作为提交表单使用
let rawCodepen = {
html: (template && template.content || '').replace(/^(\/|\n)*/g, ''),
js: (script && script.content || '').replace(/^(\/|\n)*/g, ''),
css: (styles && styles.content || '').replace(/^(\/|\n)*/g, '')
};
let codepen = markdownIt.utils.escapeHtml(JSON.stringify(rawCodepen));
// 将template转为render函数
const { code } = compileTemplate({
source: template.content,
compiler: VueTemplateComplier
});
styleCodeList = styleCodeList.concat(styles);
// 获取script的代码
script = script ? script.content : '';
if (script) {
script = script.replace(
/export\s+default/,
'const exportJavaScript ='
);
} else {
script = 'const exportJavaScript = {} ;';
}
// 将代码解析成vue组件存储,然后在渲染html中引用该组件
const name = `st-demo-${componentCodeList.length}`;
componentCodeList.push(`"${name}":(function () {
${code}
${script}
return {
...exportJavaScript,
render,
staticRenderFns
}
})()`);
// 将需要渲染的示例用code-block组件包裹替换插槽显示示例效果
return `<code-block :codepen="${codepen}">
<div slot="desc">${markdownIt.render(desc)}</div>
<template slot="demo"><${name} /></template>
<div slot="code">`;
}
return ` </div>
</code-block> `;
}
}
});
核心逻辑如上,获取到每一块:::demo 代码块 :::
里面的代码块,然后分别获取到代码块里面的HTML模板、脚本和样式,他们有两个作用,一是传给我们的自定义组件code-block
,在里面我会对代码块做一些样式和功能性的封装;二是组合成codepen
的提交参数,codepen是一个在线代码编辑平台,它可以通过表单提交代码然后跳转演示,同样的平台还有jsfiddle
和jsbin
等。这里面我使用vue-template-compiler
去提取模板变量,但是你也可以用别的方式去提取,比如cheerio👇
const cheerio = require('cheerio');
function fetch (str, tag) {
var $ = cheerio.load(str, { decodeEntities: false });
if (!tag) return str;
return $(tag).html();
}
// ...
let codepen = {
html: fetch(content, 'template'),
js: fetch(content, 'script'),
css: fetch(content, 'style')
};
因为一份文档里面有多个demo,所以我们需要一个componentCodeList
去存储每个代码块,然后最终遍历插入,最终生成下面这个模板并返回
`<template>
<div class="st-doc">
${markdownIt.render(source)}
</div>
</template>
<script>
export default {
name: 'st-doc',
components: {
${componentCodeList.join(',')}
}
}
</script>
<style>
${Array.from(styleCodeList, m => m.content).join('\n')}
</style>`
除了处理demo内的代码块,我们还可以对markdown-it
生成的HTML标签做一些处理,它本身提供一些rule允许你对它进行一些操作,比如给table
标签替换一个类名👇
markdownIt.renderer.rules.table_open = function() {
return '<table class="st-doc__table">';
};
同时你也可以通过fence
自己定义某些标签的渲染规则,比如如果在模板中用到插值{{ xx }}
,因为我们把整个文本返回给vue-loader
处理,如果不对双大括号做处理的话,在显示纯文本的代码的时候,双大括号也会变解析成模板,在父组件内就会报错找不到该变量,所以需要写一个函数替换双大括号👇
markdownIt.renderer.rules.fence = genInlineLabel(markdownIt.renderer.rules.fence);
function genInlineLabel (render) {
return function() {
return render.apply(this, arguments)
.replace('{{', '{ { ')
.replace('}}', ' } }');
};
}
1.3. CodeBlock组件
从上面看到我们把template,js,css
提取出来之后都注入到了CodeBlock
这个组件当中,这个组件主要就是做一些样式上的处理同时添加跳转codepen
的方法👇
goCodepen() {
// https://blog.codepen.io/documentation/api/prefill
const { js, html, css } = this.codepen;
const resourcesTpl =
'<scr' +
'ipt src="//unpkg.com/vue/dist/vue.js"></scr' +
'ipt>' +
'\n<scr' +
`ipt src="//unpkg.com/steam-game-ui@${SteamUI.version}/lib/steam-game-ui.umd.min.js"></scr` +
'ipt>';
let jsTpl = (js || '').replace(/export default/, 'var Main =').trim();
let htmlTpl = `${resourcesTpl}\n<div id="app">\n${html.trim()}\n</div>`;
jsTpl = jsTpl
? jsTpl + "\nvar Ctor = Vue.extend(Main)\nnew Ctor().$mount('#app')"
: "new Vue().$mount('#app')";
let cssTpl = `html { font-size: 100px; } #app{ font-size: 14px; }\n${css ||
''}`;
const data = { js: jsTpl, css: cssTpl, html: htmlTpl };
const form =
document.getElementById('fiddle-form') ||
document.createElement('form');
while (form.firstChild) {
form.removeChild(form.firstChild);
}
form.method = 'POST';
form.action = 'https://codepen.io/pen/define/';
form.target = '_blank';
form.style.display = 'none';
const input = document.createElement('input');
input.setAttribute('name', 'data');
input.setAttribute('type', 'hidden');
input.setAttribute('value', JSON.stringify(data));
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
这里要注意的是,我们需要给codepen
写一段注入的代码,去引用我们当前已发布的最新版本的代码包,因为组件库托管在npm上,所以我这里引用的是unpkg.com
上的代码包,只要你的包在npm上发布了,都会被同步到unpkg.com
中。最后我们在main.js
中全局注册codeBlock
组件Vue.component('code-block', codeBlock)
,这样子就可以在所有地方引用该组件了
1.4. 总结
整个流程是两部分:第一部分把Markdown文件转成HTML标签,第二部分提取:::demo 代码块 :::
代码块里面的内容传给全局的codeBlock
组件,然后把一二部分合成一个组件模板,传给vue-loader
去渲染,在官网中我们只需要在路由里面引用Markdown文件就可以了。具体的代码逻辑在build/markdown-loader.js