Element UI 的组件页如何实现
上篇文章我们重点分析了两个问题, element ui 的自动化生成入口文件, 以及执行 npm run dev
命令后做了什么事,了解组件库的基本目录结构,功能分布。
今天要分析的重点是 element ui 项目启动后,访问 8085 端口,我们看到的组件页面是怎么生成的?这部分代码在哪里?在页面中既可以预览不同组件的使用效果又可以显示代码的功能怎样实现?
上篇文章中提到,examples
是用来存放 Element UI 组件示例的文件夹,npm run dev
启动组件库的本质也就是在启动examples
这个独立的 vue 项目,所以我们将目光锁定在这个独立项目的入口文件: examples / entry.js 上,看从中能得到哪些信息。
js
// entry.js
// ...
import Element from 'main/index.js';
// ...
入口文件中有语句引入了 Element,它来自于main/index.js
,这里的 mian 在webpack.demo.js 的 webpackConfig.resolve.alias 中有配置。
mian 代表的是 src, Element文档示例 也就是从 src/index.js 下引入的。
组件的源码来自于packages文件夹,但是我们之前有看过 packages 中的代码,他们都是一个个组件的源码,最基础的组件构成单元,并不具备页面上呈现的demo演示效果。探索之路再次被中断!如果一个坑跌倒,那么我们尝试在别的坑挣扎挣扎,接下来分析什么内容呢?用户想要看见组件的实现代码,必定先要经过点击顶部导航栏,打开组件库页面,然后在页面的菜单栏中选中组件名,才能定位到该组件代码的位置。
分析 route.config.js 文件,探究路由结构
element ui 源码在本地启动之后,用户看到的页面都来自于examples
这个独立的 vue 项目,顶部导航以及左侧菜单栏都映射在路由配置中,也就是route.config.js
。
registerRoute
在route.config.js
中,我们直接关注到 registerRoute
这个方法,它遍历了 navConfig 中的 key 值生成 route 。navConfig 来自于 ./nav.config
。
js
// route.config.js
import navConfig from './nav.config';
// ...
const registerRoute = (navConfig) => {
let route = [];
Object.keys(navConfig).forEach((lang, index) => {
let navs = navConfig[lang];
route.push({
path: `/${ lang }/component`,
redirect: `/${ lang }/component/installation`,
component: load(lang, 'component'),
children: []
});
// ...
});
// ...
};
nav.config.json 配置菜单栏列表
nav.config
集合了四种不同语言的菜单栏列表信息,每一个菜单对应不同的 path 路径。仔细观察该文件夹下的JSON数据结构,可以发现:
(下文皆以数据结构中的zh-CN
对象为例)
- 数组对象的第一级属性都有 name,对应页面中菜单栏的一级目录
- 数组对象的第一级属性 children / groups 对应页面菜单栏中的二级目录
路由配置的核心 ------ addRoute 做了什么?
js
// route.config.js
// ...
const LOAD_MAP = {
'zh-CN': name => {
return r => require.ensure([], () =>
r(require(`./pages/zh-CN/${name}.vue`)),
'zh-CN');
},
// ...
};
const load = function(lang, path) {
return LOAD_MAP[lang](path);
};
const LOAD_DOCS_MAP = {
'zh-CN': path => {
return r => require.ensure([], () =>
r(require(`./docs/zh-CN${path}.md`)),
'zh-CN');
},
// ...
};
const loadDocs = function(lang, path) {
return LOAD_DOCS_MAP[lang](path);
};
const registerRoute = (navConfig) => {
let route = [];
Object.keys(navConfig).forEach((lang, index) => {
let navs = navConfig[lang];
route.push({
path: `/${ lang }/component`,
redirect: `/${ lang }/component/installation`,
component: load(lang, 'component'),
children: []
});
navs.forEach(nav => {
if (nav.href) return;
if (nav.groups) {
nav.groups.forEach(group => {
group.list.forEach(nav => {
addRoute(nav, lang, index);
});
});
} else if (nav.children) {
nav.children.forEach(nav => {
addRoute(nav, lang, index);
});
} else {
addRoute(nav, lang, index);
}
});
});
function addRoute(page, lang, index) {
const component = page.path === '/changelog'
? load(lang, 'changelog')
: loadDocs(lang, page.path);
let child = {
path: page.path.slice(1),
meta: {
title: page.title || page.name,
description: page.description,
lang
},
name: 'component-' + lang + (page.title || page.name),
component: component.default || component
};
route[index].children.push(child);
}
return route;
};
首先,registerRoute 根据用户选择的国际化语言(中文)添加了非组件页面的路由对应第 33 行代码中的 route.push 操作,此时路由的 component 指向的是 examples/pages/zh-CN/component.vue
;第二步操作 navs.forEach ,遍历zh-CN
数组的每一级数据,进一步调用addRoute 方法。
对以上代码进行抽丝剥茧发现,addRoute 方法做了以下操作:
- 判断 page.path 是否与
/changelog
相等,/changelog
是zh-CN
数组中的第一个对象的 path 值; - 若相等,则调用
load
方法,动态添加所有非组件路由;若不等,则调用loadDocs
方法,动态添加组件路由; load
方法返回LOAD_MAP[lang](path)
使路由中的 component 指向路径是./pages/zh-CN/${name}.vue
的页面;loadDocs
方法返回LOAD_MAP[lang](path)
使路由中的 component 指向路径是./docs/zh-CN${path}.md
的 md 文档。- 在项目打包构建时,webpack使用两个 loader 将 md 文档编译成 vue 格式的字符串,再将 vue 格式字符串编译成 js 字符串。
分析到这里我们大概就清楚了,组件库中既可以预览不同组件的使用效果又可以显示代码的功能主要是通过 docs
文件下的 md 文档
加载而来的。
仔细对比 md 文档的预览效果,和真实的项目页面还是有很大差距,它是如何转化的呢?
抽象语法树AST
在这里拓展一个小知识点,ast
是源代码的抽象语法结构树状表现形式。有了这个工具做辅助,对 md 文档进行词法分析、语法分析,形成源代码的树形结构。遍历这个树形结构,拿到不同结点的值来重新组合加工成另一种规则的语法文档。
解析 md 文档的第一个 loader:md-loader 做了什么
拓展资料:Element的markdown-loader源码解析
ElementUI 组件库 md-loader 的解析和优化
根据 webpack 配置,可以找到 md-loader 位置:build/md-loader/index.js
纵览 md-loader 目录结构:
- index.js: 入口文件
- config.js: markdown-it 的配置文件
- containers.js:render 添加自定义输出配置
- fence: 修改fence渲染策略
- util: 一些处理解析md数据的函数
按照顺序先看看index.js中的代码:
js
const {
stripScript,
stripTemplate,
genInlineComponentText
} = require('./util');
const md = require('./config');
module.exports = function(source) {
const content = md.render(source);
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},`;
// 重新计算下一次的位置
id++;
start = commentEnd + endTagLen;
commentStart = content.indexOf(startTag, start);
commentEnd = content.indexOf(endTag, commentStart + startTagLen);
}
// 仅允许在 demo 不存在时,才可以在 Markdown 中写 script 标签
// todo: 优化这段逻辑
let pageScript = '';
if (componenetsString) {
pageScript = `<script>
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);
}
output.push(content.slice(start));
return `
<template>
<section class="content element-doc">
${output.join('')}
</section>
</template>
${pageScript}
`;
};
md-loader 把 markdown 语法字符串,转化成 Vue 组件字符串,转化的过程可以拆分成三个步骤: markdown 渲染、dome 子组件的处理、构造完整的 vue 组件;
1、markdown 渲染
md 文件内容会渲染生成对应的 HTML,它是通过下面这段代码完成的:
js
const md = require('./config');
module.exports = function(source) {
const content = md.render(source);
}
而 md 对象来源如下:
js
// 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('anchor').use(anchorPlugin, [
{
level: 2,
slugify: slugify,
permalink: true,
permalinkBefore: true
}
]).end()
.plugin('containers').use(containers).end();
const md = config.toMd();
overWriteFenceRule(md);
module.exports = md;
这段代码首先实例化了 config
对象,它依赖于 markdown-it-chain
,通过 webpack chain 的链式调用 API ,配置 markdown-it
插件。md
对象指向的就是 markdown-it
的实例。 md.render
就是把 markdown 字符串渲染生成 HTML。
什么是
markdown-it
? markdown-it是一个用来解析 markdown 的库,解析后得到的结果不是一颗 AST 树 (AST 是一个对象),而是一个数组,markdown-it 称之为 token 流。了解更多
什么是AST
? AST 抽象语法树是编译器或解释器在处理源代码时所使用的一种中间表示形式。AST在编译和代码生成过程中起着关键作用。了解更多
以 alter 为例,我们可以发现 标准的 markdowm 语法并不能解析 :::demo - ::: 它实际上是一个 markdown 的自定义容器,借助于 markdown-it-container
插件,就可以解析这个自定义容器:
js
// build/md-loader/containers.js
//这个插件可以让你支持内容块,识别 markdown的:::
const mdContainer = require('markdown-it-container');
module.exports = md => {
md.use(mdContainer, 'demo', {
validate(params) {
return params.trim().match(/^demo\s*(.*)$/); //*是匹配0次以上
},
render(tokens, idx) {
const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
if (tokens[idx].nesting === 1) {
// :::demo后面的描述文字
const description = m && m.length > 1 ? m[1] : '';
// html的匹配内容
const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : '';
// 把内容放在已经写好的组件 demo-block 中;把 html 的内容放在 !--element-demo 里,便于后面处理抽取
// demo-block组件在 entry.js 里作为全局组件注册过,可以直接使用
return `<demo-block>
${description ? `<div>${md.render(description)}</div>` : ''}
<!--element-demo: ${content}:element-demo-->
`;
}
return '</demo-block>';
}
});
md.use(mdContainer, 'tip');
md.use(mdContainer, 'warning');
};
从代码中可以看出,它会通过正则匹配到 demo 开头后面紧接着的描述字符串以及 code fence
,并生成被<demo-block>
标签包裹的新 HTML 字符串。
此外,code fence
也定义了新的渲染策略
js
// 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) {
//html的加上高亮标签
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);
};
};
对于在 demo
容器内且带有 html
标记的 code fence
,会做一层特殊处理。 以 alter 为例:
html
:::demo Alert 组件提供四种主题,由`type`属性指定,默认值为`info`。
```html
<template>
<el-alert
title="成功提示的文案"
type="success">
</el-alert>
<el-alert
title="消息提示的文案"
type="info">
</el-alert>
<el-alert
title="警告提示的文案"
type="warning">
</el-alert>
<el-alert
title="错误提示的文案"
type="error">
</el-alert>
</template>
```
:::
经过解析后,生成的 HTML 格式大概如下:
html
<demo-block>
<div><p>Alert 组件提供四种主题,由<code>type</code>属性指定,默认值为<code>info</code>。</p>
</div>
<!--element-demo:
<template>
<el-alert
title="成功提示的文案"
type="success">
</el-alert>
<el-alert
title="消息提示的文案"
type="info">
</el-alert>
<el-alert
title="警告提示的文案"
type="warning">
</el-alert>
<el-alert
title="错误提示的文案"
type="error">
</el-alert>
</template>
:element-demo-->
<template slot="highlight"><pre v-pre><code class="html"><template>
<el-alert
title="成功提示的文案"
type="success">
</el-alert>
<el-alert
title="消息提示的文案"
type="info">
</el-alert>
<el-alert
title="警告提示的文案"
type="warning">
</el-alert>
<el-alert
title="错误提示的文案"
type="error">
</el-alert>
</template>
</code></pre></template>
</demo-block>
2、子组件的处理
在组件库中的每一个 demo 示例都会通过 demo-block
组件渲染,它是预先定义好的 Vue 组件。 demo-block
支持多个插槽,其中默认插槽对应了组件的描述部分;source
插槽对应 demo
实现的部分;
html
// examples/components/demo-block.vue
<template>
<div
class="demo-block":class="[blockClass, { 'hover': hovering }]"@mouseenter="hovering = true"@mouseleave="hovering = false">
<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
...
目前,我们生成的html字符串还不能被 demo-block
组件使用,需要进一步处理:
js
//build/md-loader/index.js
module.exports = function(source) {
const content = md.render(source);
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},`;
// 重新计算下一次的位置
id++;
start = commentEnd + endTagLen;
commentStart = content.indexOf(startTag, start);
commentEnd = content.indexOf(endTag, commentStart + startTagLen);
}
// 处理 script
// ...
output.push(content.slice(start))
};
这段代码要做的就是填充demo-lock
组件内部的source
插槽。由于前面生成的html中包含了<!--element-demo:
和 :element-demo-->
,因此就可以找到注释字符串的位置,通过字符串截取的方式来获得注释内外的内容;
对于注释内的内容,会提取其中的模版部分和JS部分,然后构造出一个内联的组件字符串。
output
表示要输出的模版内容:
js
[
`<demo-block>
<div><p>Alert 组件提供四种主题,由<code>type</code>属性指定,默认值为<code>info</code>。</p></div>`,
`<template slot="source"><element-demo0 /></template>`,
`<template slot="highlight"><pre v-pre><code class="html"><template>
<el-alert
title="成功提示的文案"
type="success">
</el-alert>
<el-alert
title="消息提示的文案"
type="info">
</el-alert>
<el-alert
title="警告提示的文案"
type="warning">
</el-alert>
<el-alert
title="错误提示的文案"
type="error">
</el-alert>
</template>
</code></pre></template>
<demo-block>`
]
componenetsString
表示要输出的脚本内容:
js
`"element-demo0": (function() {
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
[
[
_c("el-alert", { attrs: { title: "成功提示的文案", type: "success" } }),
_vm._v(" "),
_c("el-alert", { attrs: { title: "消息提示的文案", type: "info" } }),
_vm._v(" "),
_c("el-alert", { attrs: { title: "警告提示的文案", type: "warning" } }),
_vm._v(" "),
_c("el-alert", { attrs: { title: "错误提示的文案", type: "error" } })
]
],
2
)
}
var staticRenderFns = []
render._withStripped = true
const democomponentExport = {}
return {
render,
staticRenderFns,
...democomponentExport
}
})(),`
通过内联的方式定义了 element-demo0
子组件的实现。
3、构造完整的组件
output
负责组件的模板定义,pageScript
负责组件的脚本定义,最终会通过字符串拼接的方式,返回完整的组件定义。
对于最开始完整的示例而言,经过 md-loader
处理的结果如下:
js
<template>
<section class="content element-doc">
<h2 id="alert-jing-gao"><a class="header-anchor" href="#alert-jing-gao" aria-hidden="true">¶</a> Alert 警告</h2>
<p>用于页面中展示重要的提示信息。</p>
<h3 id="ji-ben-yong-fa"><a class="header-anchor" href="#ji-ben-yong-fa" aria-hidden="true">¶</a> 基本用法</h3>
<p>页面中的非浮层元素,不会自动消失。</p>
<demo-block>
<div><p>Alert 组件提供四种主题,由<code>type</code>属性指定,默认值为<code>info</code>。</p>
</div>
<template slot="source">
<element-demo0/>
</template>
<template slot="highlight"><pre v-pre><code class="html"><template>
<el-alert
title="成功提示的文案"
type="success">
</el-alert>
<el-alert
title="消息提示的文案"
type="info">
</el-alert>
<el-alert
title="警告提示的文案"
type="warning">
</el-alert>
<el-alert
title="错误提示的文案"
type="error">
</el-alert>
</template>
</code></pre>
</template>
</demo-block>
</section>
</template>
<script>
export default {
name: 'component-doc',
components: {
"element-demo0": (function() {
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
[
[
_c("el-alert", { attrs: { title: "成功提示的文案", type: "success" } }),
_vm._v(" "),
_c("el-alert", { attrs: { title: "消息提示的文案", type: "info" } }),
_vm._v(" "),
_c("el-alert", { attrs: { title: "警告提示的文案", type: "warning" } }),
_vm._v(" "),
_c("el-alert", { attrs: { title: "错误提示的文案", type: "error" } })
]
],
2
)
}
var staticRenderFns = []
render._withStripped = true
const democomponentExport = {}
return {
render,
staticRenderFns,
...democomponentExport
}
})(),
}
}
</script>
显然,经过 md-loader
处理后,原来的 markdown 语法的字符串变成了一个 Vue 组件定义的字符串,现在就可以交给 vue-loader 继续处理了。
参考资料