经验分享-如何从 0 到 1 开发一个 ElementUI 官网
本文参考了 Element UI 的 md-loader 源码,为 Element UI 团队表示深深的敬意。
以下示例是层层递进的,从简单到复杂的顺序进行叙述。另外只有部分代码片段,如果要运行:
- 使用
vue-cli3初始化一个标准工程vue create demo-md - 新建或者修改按照下面对应的示例文件即可
一、工程需要有解析 Markdown 文件的能力
demo-md-1
演示一个 Vue 工程如何解析一个基本的 Markdown 文件
Demo1.vue
<template>
<div class="demo">
<p class="description">
直接引入 MarkdownIt 插件,调用其函数 md.render 渲染字符串。
</p>
<div class="content" v-html="mdResult"></div>
</div>
</template>
<script>
import MarkdownIt from 'markdown-it'
export default {
data () {
return {
mdResult: ''
}
},
mounted () {
const md = new MarkdownIt()
this.mdResult = md.render(`
# demo1
## 二级标题
### 三级标题
1. 有序列表项1
1. 有序列表项2
* 无序列表项1
* 无序列表项2
* 无序列表项3
`)
}
}
</script>
demo-md-2
演示一个 Vue 工程如何加载外部 .md 文件解析并显示到页面上
Demo2.vue
<template>
<div class="demo">
<p class="description">
将外部 MD 文件通过 require 形式引入,必须增加 loader 来处理
</p>
<div class="content" v-html="mdResult"></div>
</div>
</template>
<script>
export default {
data () {
return {
mdResult: ''
}
},
mounted () {
// OR
// import demo2 from './demo2.md'
// this.mdResult = demo2
this.mdResult = require('./demo2.md').default
}
}
</script>
demo2.md
# demo2
## 二级标题
### 三级标题
1. 有序列表项1
1. 有序列表项2
* 无序列表项1
* 无序列表项2
* 无序列表项3
build/md-loader/index.js
const MarkdownIt = require('markdown-it')
const md = new MarkdownIt()
module.exports = function (source) {
return md.render(source)
}
vue.config.js
注意: 一定要结合 raw-loader 使用,详细查看文档。
const path = require('path')
// vue.config.js
module.exports = {
// options...
configureWebpack: {
module: {
rules: [
{
test: /\.md$/,
use: [
// https://webpack.docschina.org/loaders/raw-loader/
'raw-loader',
{
loader: path.resolve(__dirname, './build/md-loader/index.js')
}
]
}
]
}
}
}
demo-md-3
演示如何高亮显示代码块
Demo3.vue
<template>
<div class="demo">
<p class="description">
MD 文件中有代码块,需要高亮显示,引入插件 highlight.js
</p>
<div class="content" v-html="mdResult"></div>
</div>
</template>
<script>
export default {
data () {
return {
mdResult: ''
}
},
mounted () {
this.mdResult = require('./demo3.md').default
}
}
</script>
demo3.md
# demo3
这是一个基本的示例说明,使用到了 `highlight.js` 插件
## 菜单配置说明
```js
// 注释模块
export default [{
id: 1,
name: '张三'
}];
```
main.js
import 'highlight.js/styles/tomorrow.css'
build/md-loader/index.js
const hljs = require('highlight.js') // https://highlightjs.org/
const md = require('markdown-it')({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(lang, str, true).value +
'</code></pre>'
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
})
module.exports = function (source) {
return md.render(source)
}
vue.config.js
注意: 一定要结合 raw-loader 使用,详细查看文档。
const path = require('path')
// vue.config.js
module.exports = {
// options...
configureWebpack: {
module: {
rules: [
{
test: /\.md$/,
use: [
// https://webpack.docschina.org/loaders/raw-loader/
'raw-loader',
{
loader: path.resolve(__dirname, './build/md-loader/index.js')
}
]
}
]
}
}
}
二、工程需要有能独立访问 Markdown 文件页面的能力
demo-md-4
要能让 vue-router 有能力直接跳转显示一个 Markdown 文件,必须将整个 Markdown 文件转换为一个 Vue 独立组件
router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
path: '/',
redirect: 'demo4'
},
{
path: '/demo4',
name: 'demo4',
component: () => import('./demo4.md')
},
{
path: '*',
redirect: '/'
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
demo4.md
# demo4
## npm 安装
推荐使用 npm 的方式安装,它能更好地和 webpack 打包工具配合使用。
## 示例演示
### Button
```html
<el-button>默认按钮</el-button>
<el-button type="primary">主要按钮</el-button>
```
build/md-loader/index.js
const hljs = require('highlight.js') // https://highlightjs.org/
const md = require('markdown-it')({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(lang, str, true).value +
'</code></pre>'
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
})
module.exports = function (source) {
return `
<template>
<div class="content">${md.render(source)}</div>
</template>
<script>
export default {
name: 'demo'
}
</script>
`
}
vue.config.js
注意: 不再需要 raw-loader 插件了,因为 .md 文件已经被解析为 vue 组件了,可以通过路由显示,或者作为 components 组件使用
const path = require('path')
// vue.config.js
module.exports = {
// options...
configureWebpack: {
module: {
rules: [
{
test: /\.md$/,
use: [
// https://github.com/vuejs/vue-loader
'vue-loader',
// https://webpack.docschina.org/loaders/raw-loader/
// 'raw-loader',
{
loader: path.resolve(__dirname, './build/md-loader/index.js')
}
]
}
]
}
}
}
.eslintignore
由于 .md 文件变成了 Vue 组件,所以 Eslint 默认会对其进行代码检查,但是 MD 里面的写法肯定不如标准的 Vue 规范,所以直接忽略掉。
node_modules
*.md
三、工程需要有直接预览显示 Markdown 文件中代码块的能力
demo-md-5
要直接预览显示 Markdown 文件中的代码块,只需要2步:
- 将 Markdown 文件通过
vue-loader转换为一个 Vue Component - 将 Markdown 文件中的代码块,也转换为一个 Vue Component,作为整个页面的子组件
关键点:
- 如何写
markdown-it插件,标记 MD 文件中要运行的代码块?- 参考作者已有的示例自己摸索:https://github.com/markdown-it/markdown-it-emoji
- 作者打死都不坑写一篇关于插件如何开发的文档(看下这个ISSUE能笑死):https://github.com/markdown-it/markdown-it/issues/10
- 我们这里没有自己写了,使用了一个已有的插件:markdown-it-container
- 如何在插件中解析代码块,变成一个
vue Component,然后组织内容变成一个整体 vue 组件?- 参考本示例中
build/md-loader的写法 - 参考了 Vue 源码,其核心代码:https://github.com/vuejs/vue-loader/blob/423b8341ab368c2117931e909e2da9af74503635/lib/loaders/templateLoader.js#L46
- 参考本示例中
demo5.md
注意: 下面示例代码块的写法。
# demo5
## 示例1
::: demo 示例1的描述信息
```html
<el-button>默认按钮</el-button>
```
:::
## 示例2
::: demo 示例2的描述信息
```html
<template>
<el-select v-model="value" placeholder="请选择">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</template>
<script>
export default {
data() {
return {
options: [{
value: '选项1',
label: '黄金糕'
}, {
value: '选项2',
label: '双皮奶'
}, {
value: '选项3',
label: '蚵仔煎'
}, {
value: '选项4',
label: '龙须面'
}, {
value: '选项5',
label: '北京烤鸭'
}],
value: ''
}
}
}
</script>
```
:::
router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
path: '/',
redirect: 'demo5'
},
{
path: '/demo5',
name: 'demo5',
component: () => import('./demo5.md')
},
{
path: '*',
redirect: '/'
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import 'highlight.js/styles/tomorrow.css'
import 'element-ui/lib/theme-chalk/index.css'
import ElementUI from 'element-ui'
import DemoBlock from './DemoBlock.vue'
Vue.config.productionTip = false
Vue.use(ElementUI)
Vue.component('demo-block', DemoBlock)
new Vue({
router,
render: h => h(App)
}).$mount('#app')
DemoBlock.vue
这个组件在 md-loader 中有所使用,所以先初始化。
<template>
<div>
<section class="description" v-if="$slots.default">
<slot></slot>
</section>
<section class="source">
<slot name="source"></slot>
</section>
</div>
</template>
<script>
export default {
name: 'demo-block'
}
</script>
build/md-loader/index.js
const hljs = require('highlight.js') // https://highlightjs.org/
const mdContainer = require('markdown-it-container')
const parser = require('./parser')
const md = require('markdown-it')({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(lang, str, true).value +
'</code></pre>'
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'
}
}).use(mdContainer, 'demo', {
// https://github.com/markdown-it/markdown-it-container#example
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>` : ''}
<!--demo: ${content}:demo-->
`
}
return '</demo-block>'
}
})
module.exports = function (source) {
return parser(md, source)
}
build/md-loader/parser.js
以下内容大部分出自
Element UI源码
const {
stripScript,
stripStyle,
stripTemplate,
genInlineComponentText
} = require('./util')
module.exports = function (md, source) {
const content = md.render(source)
const startTag = '<!--demo:'
const startTagLen = startTag.length
const endTag = ':demo-->'
const endTagLen = endTag.length
let componenetsString = ''
let id = 0 // demo 的 id
let output = [] // 输出的内容
let start = 0 // 字符串开始位置
let styles = []
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)
const style = stripStyle(commentContent)
styles.push(style)
let demoComponentContent = genInlineComponentText(html, script)
const demoComponentName = `demo${id}`
output.push(`<template slot="source"><${demoComponentName} /></template>`)
componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`
// 重新计算下一次的位置
id++
start = commentEnd + endTagLen
commentStart = content.indexOf(startTag, start)
commentEnd = content.indexOf(endTag, commentStart + startTagLen)
}
let pageScript = ''
if (componenetsString) {
pageScript = `<script>
export default {
name: 'demo',
components: {
${componenetsString}
}
}
</script>`
} else if (content.indexOf('<script>') === 0) {
start = content.indexOf('</script>') + '</script>'.length
pageScript = content.slice(0, start)
}
output.push(content.slice(start))
return `
<template>
<div class="content">
${output.join('')}
</div>
</template>
${pageScript}
`
}
build/md-loader/util.js
以下内容大部分出自
Element UI源码
const { compileTemplate } = require('@vue/component-compiler-utils')
const compiler = require('vue-template-compiler')
function stripScript (content) {
const result = content.match(/<(script)>([\s\S]+)<\/\1>/)
return result && result[2] ? result[2].trim() : ''
}
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) {
// https://github.com/vuejs/vue-loader/blob/423b8341ab368c2117931e909e2da9af74503635/lib/loaders/templateLoader.js#L46
const finalOptions = {
source: `<div>${template}</div>`,
filename: 'inline-component',
compiler
}
const compiled = compileTemplate(finalOptions)
// tips
if (compiled.tips && compiled.tips.length) {
compiled.tips.forEach(tip => {
console.warn(tip)
})
}
// errors
if (compiled.errors && compiled.errors.length) {
console.error(
`\n Error compiling template:\n${pad(compiled.source)}\n
${compiled.errors.map(e => ` - ${e}`).join('\n')}
`
)
}
let demoComponentContent = `
${compiled.code}
`
script = script.trim()
if (script) {
script = script.replace(/export\s+default/, 'const democomponentExport =')
} else {
script = 'const democomponentExport = {}'
}
demoComponentContent = `(function() {
${demoComponentContent}
${script}
return {
render,
staticRenderFns,
...democomponentExport
}
})()`
return demoComponentContent
}
module.exports = {
stripScript,
stripStyle,
stripTemplate,
genInlineComponentText
}
以上代码块中,democomponentExport 这个对象比较重要,如果没有的话,嵌在 .md 文件中的代码片段,如果有 <script> 脚本片段无法解析的。
vue.config.js
const path = require('path')
// vue.config.js
module.exports = {
// options...
configureWebpack: {
module: {
rules: [
{
test: /\.md$/,
use: [
// https://github.com/vuejs/vue-loader
'vue-loader',
// https://webpack.docschina.org/loaders/raw-loader/
// 'raw-loader',
{
loader: path.resolve(__dirname, './build/md-loader/index.js')
}
]
}
]
}
}
}
(全文完)