「这是我参与2022首次更文挑战的第11天,活动详情查看:2022首次更文挑战」。
写在开头
上一篇文章 ElementUI源码系列八 - 搭建element-ui官方文档之项目的基本框架 我们已经搭建好了项目的基本结构,本章,我们就围绕标题 "md文件渲染到页面" 和 "demo-block组件的实现" 这两个知识点来进行学习。
下面我画了一张大致的流程图,可以先浏览浏览或者最后回头来看看:
预备知识
启动服务时去掉多余的打印信息
我们执行 npm run demo
启动我们的项目时,总是会看到控制台会输出一大堆东西。
有时候我们想让控制台干净、整洁一点应该怎么做呢?你可以配置一下 webpack
的 stats 对象:
// webpack.demo.js
module.exports = {
...,
devServer: {
host: 'localhost',
port: 8082,
publicPath: '/',
hot: true,
stats: 'minimal' // 只在发生错误或有新的编译时输出
},
...
}
errors-only
:只在发生错误时输出。minimal
:只在发生错误或有新的编译时输出。none
:没有输出。normal
:标准输出。verbose
:全部输出。
md文件渲染到页面
当我们要把 .md
文件内容转化到页面上展示,其实本质也就是 markdown
转换成 HTML
的过程。而假设我们的项目有工程化支持,并且打包器为 webpack
,相信很多小伙伴第一反应会想到用 loader
来完成这项任务。
因为 webpack
默认只认识 .js
和 .json
文件,对于其他类型的文件,webpack
一般都会先交由相关的 loader
去处理,最终再返回给 webpack
处理,所以 loader
就相当于成了其他资源文件的"翻译官"。
那么,下面我们就来看看如何通过 loader
来实现 .md
文件的转化的。
配置自定义loader
我们先修改 webpack.demo.js
配置文件:
// build/webpack.demo.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
...
module: {
rules: [
...,
{
test: /\.md$/,
use: [
// 处理 .vue 文件
{
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
// 自定义一个 loader, 通过 loader 先把 .md 文件转成 .vue 文件形式
{
loader: path.resolve(__dirname, './md-loader/index.js')
}
]
}
]
},
...
}
在配置文件中我们添加了一个专门处理 .md
文件的 loader
,需要注意的是,这里我们使用自定义形式的 loader
,如何自己编写一个 loader
上面 "预备知识" 有写,这里就不多说啦。
我们再来创建 build/md-loader/index.js
文件:
module.exports = function(source) {
console.log(source); // .md 文件源码
return `
<template>
<section class="content element-doc">
橙某人
</section>
</template>
`;
}
创建完后,我们重新执行 npm run demo
命令重启服务。
从下方截图中可以看到,在自定义的 loader
中,我们能获取到 .md
文件的源码内容:
页面上,我们点击 button
的路由,也终于不会报错了。
markdown转化成html
既然我们能拿到 .md
文件的源码内容,那么,接下来我们就能正式开始对文件内的 markdown
进行转换了。
转换过程中我们会用到以下这些依赖:
- markdown-it:是一个辅助解析
markdown
的库,能将markdown
字符串转换成HTML
字符串,也就是可以完成从# 大标题
到<h1>大标题</h1>
的转换。在线体验 - markdown-it-chain:是一个以链式调用的形式来辅助解析
markdown
的库,markdown-it
与markdown-it-chain
的关系就好比webpack
和webpack-chain
是一样的。 - markdown-it-container:创建块级自定义
markdown
"容器"插件,使用这个插件,你可以自定义创建像这样的块容器。
这就有点像创建自定义::: 容器名称 内容 :::
markdown
语法一样的感觉,当然,它是要经过插件本身的编译。
我们先安装这些依赖:
npm intsall markdown-it@8.4.1 markdown-it-chain@1.3.0 markdown-it-container@2.0.0 -D
接着继续来看 build/md-loader/index.js
文件:
const md = require('./config');
module.exports = function(source) {
const content = md.render(source); // 调用 render 方法把 markdown 转换成 html
// 展示转换后的 html
return `
<template>
<section class="content element-doc">
${content}
</section>
</template>
`;
}
然后新建 build/md-loader/config.js
文件:
const Config = require('markdown-it-chain');
const containers = require('./containers');
const config = new Config(); // 实例化
config
.options.html(true).end() // 可以解析 HTML 标签
.plugin('containers').use(containers).end(); // 创建自定义 markdown 容器
const md = config.toMd();
module.exports = md;
再新建 build/md-loader/container.js
文件:
const mdContainer = require('markdown-it-container');
module.exports = md => {
md.use(mdContainer, 'demo', {
validate(params) {
// 匹配 markdown 中含有 :::demo ::: 形式的块容器
return params.trim().match(/^demo\s*(.*)$/);
},
render(tokens, idx) {
// tokens 是通过解析 markdown 后输出的一个 数组对象, 具体形式可以自行打印查看
const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
if (tokens[idx].nesting === 1) {
/**
* description: 会获取到块容器中的 "描述信息" 的内容
* :::demo 描述信息
*
* :::
*
* content: 会获取到块容器中的 "html" 的内容
* :::demo 描述信息
* ```html
*
* ```
* :::
* 注意content被我们用 <!--element-demo: :element-demo--> 标签包裹起来了,
* 这主要是为我们后面操作html其中的内容提供一个标识作用
*/
const description = m && m.length > 1 ? m[1] : '';
const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : '';
// 把 :::demo ... ::: 替换成 <demo-block> ... </demo-block> 标签
return `<demo-block>
${description ? `<div>${md.render(description)}</div>` : ''}
<!--element-demo: ${content}:element-demo-->
`;
}
return '</demo-block>';
}
});
};
做完这些后,我们执行 npm run demo
命令重启服务,查看页面:
可以发现,我们原先写的 .md
文件内容已经能正常显示在页面上了。
而上面关于 markdown-it
等相关依赖的使用,寥寥数行代码,你不要觉得难,这都是它们的基本语法而且,看它们的文档对着写就行啦。初次看见可能比较蒙,你可以慢慢去 console
每次代码执行后的结果,会更好的理解。
我们再来修改 examples/docs/zh-CN/button.md
文件,添加我们自定义的块容器:
## Button 按钮
常用的操作按钮。
:::demo 橙某人
:::
然后查看页面:
可以发现,我们自定义的块容器会被转化成 <demo-block />
标签了,但是,控制台会有报错,当然,这也正常,因为这个组件我们还没具体实现。
demo-block组件的实现
组件引入
接下来,我们就来实现一下 <demo-block />
这个组件。
首先,我们先创建这个组件 examples/components/demo-block.vue
:
<template>
<div class="demo-block">
<!-- 效果展示 -->
<div class="source">
<slot name="source"></slot>
</div>
<div class="meta" ref="meta">
<!-- 描述展示 -->
<div class="description" v-if="$slots.default">
<slot></slot>
</div>
<!-- 代码展示 -->
<div class="highlight">
<slot name="highlight"></slot>
</div>
</div>
</div>
</template>
<style lang="scss">
// https://github.com/ElemeFE/element/blob/dev/examples/components/demo-block.vue
</style>
为节约文章长度,样式小编就不放上去啦,可以直接上官方源码拷贝过来就行,样式不是我们的重点。
然后我们在入口文件 entry.js
中引入这个组件:
import Vue from 'vue';
import entry from './app.vue';
import VueRouter from 'vue-router';
import routes from './route.config';
import MainHeader from './components/header.vue';
import demoBlock from './components/demo-block.vue';
Vue.use(VueRouter);
Vue.component('main-header', MainHeader);
Vue.component('demo-block', demoBlock);
const router = new VueRouter({
mode: 'hash',
base: __dirname,
routes
});
new Vue({
...entry,
router
}).$mount('#app');
查看浏览器控制,这下你的页面就没有报错了并且你可以看到 <demo-block />
标签也已经被解析了。
组件具体细节实现
完成组件的引入后,接下来我们就要来完成 <demo-block />
组件里面的细节部分了。我们先来观察一下 element-ui 线上文档的这块组件的样子。
组件主要分成三个内容区,对应我们上面代码中留的三个 <slot />
标签,接下来,我们只要把相应的 "东西" 丢到里面就完成了。
我们先来修改一下 examples/docs/zh-CN/button.md
的内容:
(这里只能提供截图了,要辛苦小伙伴自己手敲了,因为写的是 markdown
会被文章本身识别,效果不太好。)
在上面,我们已经把 .md
文件中的 markdown
源码转化成 html
代码了,接下来,我们来看看怎么把这些 html
划分出去,放到我们对应的 <slot />
中去展示。
我们直接上代码,里面注有详细的解释,修改 build/md-loader/index.js
文件:
const {stripScript, stripTemplate, genInlineComponentText} = require('./util');
const md = require('./config');
module.exports = function(source) {
const content = md.render(source); // 把 markdown 转换成 HTML
// 一个 .md 文件转换后, 里面肯定会有很多个 <demo-block /> 我们需要把它们都逐个找出来做一些处理, 因此需要定义一些索引
const startTag = '<!--element-demo:';
const startTagLen = startTag.length;
const endTag = ':element-demo-->';
const endTagLen = endTag.length;
let componenetsString = ''; // 以 'element-demoX': render() 形式存在的字符串
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); // 获取到 <!--element-demo: :element-demo--> 标签包裹的内容
const html = stripTemplate(commentContent); // 获取 <template></template> 标签的内容
const script = stripScript(commentContent); // 获取 <script></script>标签的内容
let demoComponentContent = genInlineComponentText(html, script); // 把 html 和 script 的内容变成vue中的一个 render 函数
const demoComponentName = `element-demo${id}`;
output.push(`<template slot="source"><${demoComponentName} /></template>`); // 把每块 <demo-block />组件 变成 <element-demoX /> 组件
componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`;
// 重新计算下一个 :::demo ::: 位置
id++;
start = commentEnd + endTagLen;
commentStart = content.indexOf(startTag, start);
commentEnd = content.indexOf(endTag, commentStart + startTagLen);
}
let pageScript = ''; // 执行组件注册
if (componenetsString) {
// 如果存在 <demo-block />组件, 则把 'element-demoX': render() 形式的组件全部进行注册
pageScript = `<script>
export default {
name: 'component-doc',
components: {
${componenetsString}
}
}
</script>`;
}
output.push(content.slice(start));
return `
<template>
<section class="content element-doc">
${output.join('')}
</section>
</template>
${pageScript}
`;
}
上面代码都做了详细注释,并且小编给你画了一张图参考,如果你还不懂的话,欢迎在底下评论留言哦。
我们再创建 build/md-loader/util.js
文件:
const { compileTemplate } = require('@vue/component-compiler-utils');
const compiler = require('vue-template-compiler');
// 获取 <script /> 标签片段
function stripScript(content) {
const result = content.match(/<(script)>([\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');
}
// 将 <template /> 和 <script /> 代码片段转化成 Vue 中的 render() 函数
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', // TODO:这里有待调整
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') +
'\n'
);
}
let demoComponentContent = `
${compiled.code}
`;
// todo: 这里采用了硬编码有待改进
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,
stripTemplate,
genInlineComponentText
};
做完这些后,我们执行 npm run demo
命令重启服务。
查看页面,对比上面刚引入时的截图,页面有东西展示了,而且可以发现 <slot name="source" />
和 <slot>
标签内部已经被替换成对应内容了,不过,现在 <slot name="highlight" />
标签还没有内容。
还有,从图中可以看到 <el-button />
标签还没被解析,我们先来搞定这个问题。来把我们之前自己做的 ElementUI
引入来先,在入口文件 entry.js
中引入:
import Vue from 'vue';
import entry from './app.vue';
import VueRouter from 'vue-router';
import routes from './route.config';
import MainHeader from './components/header.vue';
import demoBlock from './components/demo-block.vue';
// 引入自己的element-ui
import Element from '../lib/element-ui.common.js';
import '../lib/theme-chalk/index.css';
Vue.use(Element);
Vue.use(VueRouter);
Vue.component('main-header', MainHeader);
Vue.component('demo-block', demoBlock);
const router = new VueRouter({
mode: 'hash',
base: __dirname,
routes
});
new Vue({
...entry,
router
}).$mount('#app');
再次查看页面,可以看到标签都能正常被识别了。
接下来,我们需要来继续完善 examples/components/demo-block.vue
组件的内容:
<script>
export default {
data() {
return {
isExpanded: false,
}
},
computed: {
codeArea() {
return this.$el.getElementsByClassName('meta')[0];
},
codeAreaHeight() {
if (this.$el.getElementsByClassName('description').length > 0) {
return this.$el.getElementsByClassName('description')[0].clientHeight +
this.$el.getElementsByClassName('highlight')[0].clientHeight + 20;
}
return this.$el.getElementsByClassName('highlight')[0].clientHeight;
}
},
watch: {
isExpanded(val) {
this.codeArea.style.height = val ? `${ this.codeAreaHeight + 1 }px` : '0';
}
},
mounted() {
this.isExpanded = true;
}
}
</script>
我们给组件添加一点逻辑处理,查看页面:
从图中可以看到,每个 <demo-block />
标签的描述信息已经可以展示了。
但是,代码的展示好像和我们在 element-ui
官方看到的不一样,这是为什么呢?
不着急,我们接着来看,回到 build/md-loader/config.js
文件中:
const Config = require('markdown-it-chain');
const anchorPlugin = require('markdown-it-anchor');
const slugify = require('transliteration').slugify;
const containers = require('./containers');
const overWriteFenceRule = require('./fence'); // 引入新文件
const config = new Config();
config
.options.html(true).end()
.plugin('containers').use(containers).end();
const md = config.toMd();
overWriteFenceRule(md); // 覆盖默认的 md 转 html 的渲染策略
module.exports = md;
再新建 build/md-loader/fence.js
文件:
// 覆盖默认的 fence 渲染策略
module.exports = md => {
const defaultRender = md.renderer.rules.fence;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx];
// 判断该 fence 是否在 :::demo 内
const prevToken = tokens[idx - 1];
const isInDemoContainer = prevToken && prevToken.nesting === 1 && prevToken.info.trim().match(/^demo\s*(.*)$/);
if (token.info === 'html' && isInDemoContainer) {
// 添加插槽名: highlight
return `<template slot="highlight"><pre v-pre><code class="html">${md.utils.escapeHtml(token.content)}</code></pre></template>`;
}
return defaultRender(tokens, idx, options, env, self);
};
};
fence.js
文件主要目的就是在编写 html
代码中间包裹一个 <template slot="highlight"><pre v-pre><code class="html"></code></pre></template>
标签,这样就能将特定内容放入到 <slot name="highlight" />
插槽标签中了。
做完这些后,我们再来重启一些服务: npm run demo
。
查看页面,可以看到,代码块的展示也正常,和描述信息分离出来了,这样子就大功告成了。
写到这里,核心功能就写完啦,<demo-block />
组件还剩下一些小功能,你可以自己再完善完善,像收起/展开、代码高亮的功能,应该都不算太难,本章节就不再继续啰嗦下去啦。
往期内容
- ElementUI源码系列一 - 从零搭建项目架构,项目准备、项目打包、项目测试流程
- ElementUI源码系列二 - 引入scss,用gulp把scss转成css并补全、压缩,用cp-cli移动目录、文件
- ElementUI源码系列三 - 学习gen-cssfile.js文件之自动创建组件的.scss文件与生成index.scss文件内容
- ElementUI源码系列四 - 学习new.js文件之自动创建组件目录结构与生成components.json文件内容
- ElementUI源码系列五 - 学习build-entry.js文件之自动生成总入口文件index.js内容
- ElementUI源码系列六 - 小结
- ElementUI源码系列七 - 组件按需引入
- ElementUI源码系列八 - 搭建element-ui官方文档之项目的基本框架
- ElementUI源码系列九 - 搭建element-ui官方文档之md文件渲染到页面、demo-block组件的实现
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。