为何要抄md-loader
最近打算重写自己组件库的官网,在写展示组件部分的时候遇到了一个问题,所有组件的功能展示都在一个.vue文件里面写的话,会很麻烦。如果只用简单的md就可以转成需要的页面并且有代码高亮、demo展示框和页面样式,那该多好。
转换逻辑
事先修改webpack配置
module: {
rules: [
//.....
{
test: /\.md$/,
use: [
{
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
loader: path.resolve(__dirname, './md-loader/index.js')
}
]
},
]
},
Element md-loader 目录
目录 | 大体功能 |
---|---|
index.js | 入口文件 |
config.js | markdown-it的配置文件 |
containers.js | render添加自定义输出配置 |
fence | 修改fence渲染策略 |
util | 一些处理解析md数据的函数 |
md-loader需要完成的功能
先看看demo
//demo.md
## Table 表格
用于展示多条结构类似的数据,可对数据进行排序、筛选、对比或其他自定义操作。
### 基础表格
基础的表格展示用法。
:::demo 当`el-table`元素中注入`data`对象数组后,在`el-table-column`中用`prop`属性来对应对象中的键名即可填入数据,用`label`属性来定义表格的列名。可以使用`width`属性来定义列宽。
```html
<template>
<el-table
:data="tableData"
style="width: 100%">
<el-table-column
prop="date"
label="日期"
width="180">
</el-table-column>
<el-table-column
prop="name"
label="姓名"
width="180">
</el-table-column>
<el-table-column
prop="address"
label="地址">
</el-table-column>
</el-table>
</template>
<script>
export default {
data() {
return {
tableData: [{
date: '2016-05-02',
name: '王小虎',
address: '上海市普陀区金沙江路 1518 弄'
}
}
}
}
</script>
:::
(```)
- 一套code两个用途,一个是作为示例展示,一个是作为示例代码,全部放进
demo-block
组件里面,也就是只写一套代码就够了
demo-block
就包含三样东西,标题、示例组件、示例代码。
- 锚点
好了,下面就是一步步搞懂element的md-loader是如何做到这些的
准备工作
首先需要安装这两个依赖
yarn add markdown-it-chain markdown-it-anchor -D
config.js对markdown-it
做配置
//config.js
const Config = require('markdown-it-chain');
const anchorPlugin = require('markdown-it-anchor');//给页眉添加锚点
const config = new Config();
config
.options.html(true).end()
.plugin('anchor').use(anchorPlugin, [
{
level: 2,
slugify: slugify,
permalink: true,
permalinkBefore: true
}
]).end()
const md = config.toMd();
module.exports = md;
用markdown-it-anchor
给页眉添加锚点
markdown-it-chain
的链式配置参考文档
现在在index.js
里面引入config.js
const md = require('./config');
module.exports = function(source) {
const content = md.render(source) //得到来自.md的解析出来的数据,请记住这个content
//....
}
既然是包装成.vue,那么最终输出的肯定是和平常见到的一模一样吧
//大概是这样
<template>
</template>
<script>
export default {
}
</script>
于是修改index.js
module.exports = function(source) {
const content = md.render(source) //得到来自.md的解析出来的数据
//....
let script = `
<script>
export default {
name: 'component-doc',
}
</script>`
//script标签
//输出这个template和script合并的字符串
return `
<template>
<section class="content element-doc"> //template
</section>
</template>`
${pageScript};
}
现在需要考虑一个问题,一个md里面有很多类似的demo,最终输出的肯定是一个vue对象,那么这些demo就必须包装成一个个组件。
使用渲染函数创建组件
很明显这里不能用模板创建组件,于是需要用到渲染函数的形式。
需要用到的插件
名字 | 大体功能 |
---|---|
vue-template-compiler | template转为render函数(作为配置项) |
component-compiler-utils | 编译Vue单文件组件的工具 |
(需要知道的)vue-loader 会借助 component-compiler-utils 的工具编译Vue单文件组件
现在在util.js
里面引入他们
const { compileTemplate } = require('@vue/component-compiler-utils');
const compiler = require('vue-template-compiler');
在源码里面这个功能在util.js
里面的genInlineComponentText
函数完成。(这个函数挺复杂和冗长的,只能拆分说明)
function genInlineComponentText(template, script) {}
首先这个函数接受两个参数 template
和script
,他们由各自对应的处理函数处理的,
数据来源便是一开始就解析出来的content
function stripScript(content) {
const result = content.match(/<(script)>([\s\S]+)<\/\1>/);
return result && result[2] ? result[2].trim() : '';
} //只输出带有script标签的
function stripTemplate(content) {
content = content.trim();
if (!content) return content;
return content.replace(/<(script|style)[\s\S]+<\/\1>/g, '').trim();
}//过滤清空掉script和style,输出自然是template
再来请点开上面table里面工具的github文档说明,对应里面的options配置,反正照着文档来就行
const options = {
source: `<div>${template}</div>`,
filename: 'inline-component',
compiler // 这个compiler即是vue-template-compiler
}
利用上面引入的compileTemplate
编译
const compiled = compileTemplate(options)
如果有则抛出编译过程中的报错和警告
if (compiled.tips && compiled.tips.length) {
compiled.tips.forEach(tip => {
console.warn(tip);
});
}
// errors
if (compiled.errors && compiled.errors.length) {
console.error(
//.....
);
}
最后拿到编译后的code
let demoComponentContent = `${compiled.code}`
现在处理script
script = script.trim() //去掉两边空格
if (script) {
script = script.replace(/export\s+default/, 'const democomponentExport =')
} else {
script = 'const democomponentExport = {}';
}
这部分是把字符串里面的export default
替换为const democomponentExport =
便于后面的解构赋值
最后return出去
demoComponentContent = `(function() {
${demoComponentContent} // 跑了一遍
${script}
return {
render, //下面有说
staticRenderFns, //这里不需要关注这个
...democomponentExport //解构出上面的对象的属性
}
})()`
return demoComponentContent;
上面的render
实际上在函数里面的${demoComponentContent}
跑了一遍的时候就已经赋值了
可以看看这里demo编译出来compiled.code
的结果
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h //可以把下面return的_c当做是$createElement
return _c(
"div", // 一个 HTML 标签名
[//.....] // 子节点数组 ,实际上也是由由$createElement构建而成
)
}
var staticRenderFns = []
render._withStripped = true
那么现在就很明了了,重新看回之前的index.js
let script = ` <script> export default {
name: 'component-doc',
components: {
'demo-components':(function() {
var render = function() {
//.....
return {
render,
staticRenderFns,
...democomponentExport
}
})()
}
}
</script>
`
关于render._withStripped = true
如果未定义,则不会被get
所拦截,这意味着在访问不存在的值后不会抛错。
这里使用了@component-compiler-utils
,所以是自动加上的,可以不用关心。
回顾一下上面做了什么
- 用
markdown-it
解析.md里面的数据 - 抽离
template
和script
,通过插件编译成render Functioon
,利用他创建组件。
现在的问题是一个content
里面包含着所有的demo的数据,如何分辨并对每个demo做以上的操作,而且components
里面的组件名字是不能重名的,如何解决这一点。
给每个Demo打上‘标记’
首先需要下载依赖
yarn add markdown-it-container -D
直接看源码
文档示例
element源码
const mdContainer = require('markdown-it-container');
module.exports = 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] : '';
const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : '';
return `<demo-block>
${description ? `<div>${md.render(description)}</div>` : ''}
<!--element-demo: ${content}:element-demo-->
`;
}
return '</demo-block>';
}
})
};
需要了解的,下面的是一个块级容器,用(:::符号包裹),这个插件可以自定义这个块级所渲染的东西
::: demo 我是description(1)
*demo*(2)
:::
tokens
是一个数组,里面是块级容器里面所有md代码的code,按照一定规则分割,例如
tokens[idx].type === 'fence'
是块级内容
-
块级容器默认返回的是
:::
符号包裹的内容,也就是说即使是写在同一行的我是description
默认是不会在content
里面的,这里render
所返回的就是插在content
返回值里面的,至于是在包裹内容的前还是后,取决于页初还是页尾,也就是tokens[idx].nesting
是否等于1,这一点在文档的Examples可以知道。 -
于是现在这段代码的功能就很明显了,页初添加
<demo-block>
,页尾添加</ demo-block>
,组成一个<demo-block />
组件,<demo-block/>
是一个全局注册的组件,是示例展示用的,后面会提到。
在这个demo-block里面就是三样东西
${description} //(1) description即为demo的开头说明,请返回demo.md查看
<!--element-demo: ${content}:element-demo--> //(2) 展示组件 前面和后面的便是标记,在index.js会根据这个标记找到这段内容
${content} // (3) 展示的代码 这个东西会在fence.js文件里面做渲染覆盖,修改它的标签内容
根据标记找到并拼装组件
const startTag = '<!--element-demo:';
const startTagLen = startTag.length;
const endTag = ':element-demo-->';
const endTagLen = endTag.length;
let componenetsString = '';
let id = 0; // demo 的 id
let output = []; // 输出的内容
let start = 0; // 字符串开始位置
let commentStart = content.indexOf(startTag);
let commentEnd = content.indexOf(endTag, commentStart + startTagLen);
while (commentStart !== -1 && commentEnd !== -1) {
output.push(content.slice(start, commentStart));
const commentContent = content.slice(commentStart + startTagLen, commentEnd);
const html = stripTemplate(commentContent);
const script = stripScript(commentContent);
let demoComponentContent = genInlineComponentText(html, script);
const demoComponentName = `element-demo${id}`;
output.push(`<template slot="source"><${demoComponentName} /></template>`);
componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`;
//这里start会是前一个标记结尾的地方,常规说来就是前一个demo的代码展示的开头
id++; //id用于拼接名字
start = commentEnd + endTagLen;
commentStart = content.indexOf(startTag, start);
commentEnd = content.indexOf(endTag, commentStart + startTagLen);
}
output.push(content.slice(start)) //把后面余下的拼接上去
源码这一段有点多,首先需要搞懂各个变量的作用
-
demoComponentName
与id
,命名每个demo组件名字用的,id会在while每一次循环+1,这样子组件从第一个到最后一个都不会重名。 -
output
是一个数组,最终会拼接放入输出vue文件的template
里面,也就是这一页的HTML。 从上面代码可以看到,对output
进行操作的只有三个地方,while
循环开头会把description
推进去,然后是推入展示组件
字符串,
output.push(`<template slot="source"><${demoComponentName} /></template>`)
这里面的demoComponentName
会在最终输出的类vue对象字符串里面进行局部注册,也就是最上面提到的。
为什么最后有个output.push(content.slice(start))
let content = `
Description1 //description
Component1 // 展示组件
componentCode1//展示代码
Description2
Component2
componentCode2
`
content
就是上面这样的结构,那么output
实际上就是经历了以下过程
1·第一次循环
output.push(Description1)
output.push(Component1)
2.第二次循环
output.push(componentCode1)
output.push(Description2)
output.push(Component2)
3.循环结束
也就是说循环结束后,componentCode2
是没有推入output
的,而componentCode2
包含 </ demo-block>
,这样子在最后拼接的时候,HTML结构是有问题的。
demoComponentContent
前面有讲过是返回的render Function
,componenetsString
的结构类似下面的代码
`componentName1:(renderFn1)(),componentName2:(renderFn2)()`
最终在script
的代码就是
script = `<script>
export default {
name: 'component-doc',
components: {
component1:(function() {*render1* })(),
component2:(function() {*render2* })(),
component3:(function() {*render3* })(),
}
}
</script>`;
然后index.js
里就可以返回这个啦,
return `
<template>
<section class="content element-doc">
${output.join('')}
</section>
</template>
${script}
`;
最后输出的大致代码就是
<h3>我是demo1</h3>
<template slot="source"><demo1/></template>
<template slot="highlight"><pre v-pre><code class="html">${md.utils.escapeHtml(content)}</code></pre></template>
//第三个其实就是展示代码,在fence.js里面对其做了修改,以此对应具名插槽。
fence.js
的操作大致就和上面我写的注释是一样的,主要代码如下
if (token.info === 'html' && isInDemoContainer) {
return `<template slot="highlight"><pre v-pre><code class="html">${md.utils.escapeHtml(token.content)}</code></pre></template>`;
}
通过覆盖修改默认输出,添加<template slot="highlight">
便于在demo-block
分发内容。
利用具名插槽分发内容
<template slot="source"><${demoComponentName} /></template> //展示组件
打开Element的源码,在example=>components里面可以找到demo-block.vue
,
里面有这样的代码
<div class="source">
<slot name="source"></slot>
</div>
在 template 上使用特殊的 slot 特性,可以将内容从父级传给具名插槽 。
添加锚点
yarn add markdown-it-anchor
使用
const Config = require('markdown-it-chain')
const anchor = require('markdown-it-anchor')
const containers = require('./containers')
const config = new Config()
config
.options.html(true).end()
.plugin('anchor').use(anchor, [
{
permalinkSymbol:'#', //修改默认链接的图案,默认是¶
permalink: true, //是否在标题旁添加链接
permalinkBefore: true,//将链接放在标题的左边
}
]).end()
添加好配置后,需要在mounted
钩子里给链接跳转添加基础路由
renderAnchorHref() {
if (/changelog/g.test(location.href)) return;
const anchors = document.querySelectorAll('h2 a,h3 a,h4 a,h5 a');
const basePath = location.href.split('#').splice(0, 2).join('#');
[].slice.call(anchors).forEach(a => {
const href = a.getAttribute('href');
a.href = basePath + href;
});
},
至此,Element的md-loader
的大部分代码都及功能都看完了,感谢Element团队贡献出的源码,让我获益良多。
希望能对你理解源码有帮助,如果有什么不对的地方,欢迎批评指正。