分析 element UI 源码( 二 ),组件库的可交互代码文档是如何生成的 ?

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集合了四种不同语言的菜单栏列表信息,每一个菜单对应不同的 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 方法做了以下操作:

  1. 判断 page.path 是否与 /changelog相等, /changelogzh-CN数组中的第一个对象的 path 值;
  2. 若相等,则调用 load 方法,动态添加所有非组件路由;若不等,则调用 loadDocs 方法,动态添加组件路由;
  3. load 方法返回 LOAD_MAP[lang](path) 使路由中的 component 指向路径是 ./pages/zh-CN/${name}.vue的页面;loadDocs 方法返回 LOAD_MAP[lang](path) 使路由中的 component 指向路径是 ./docs/zh-CN${path}.md的 md 文档。
  4. 在项目打包构建时,webpack使用两个 loader 将 md 文档编译成 vue 格式的字符串,再将 vue 格式字符串编译成 js 字符串。

分析到这里我们大概就清楚了,组件库中既可以预览不同组件的使用效果又可以显示代码的功能主要是通过 docs 文件下的 md 文档加载而来的。

仔细对比 md 文档的预览效果,和真实的项目页面还是有很大差距,它是如何转化的呢?

抽象语法树AST

学习 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 继续处理了。

参考资料

Element的markdown-loader源码解析

ElementUI 组件库 md-loader 的解析和优化

elementui源码解析markdown处理

相关推荐
一只在学习的瓶子1 小时前
【大模型 AI 学习】大模型 AI 部署硬件配置方案(本地硬件配置 | 在线GPU)
深度学习·阿里云·ai
HyperAI超神经2 小时前
Meta 首个多模态大模型一键启动!首个多针刺绣数据集上线,含超 30k 张图片
大数据·人工智能·深度学习·机器学习·语言模型·大模型·数据集
Eric.Lee20212 小时前
数据集-目标检测系列- 螃蟹 检测数据集 crab >> DataBall
python·深度学习·算法·目标检测·计算机视觉·数据集·螃蟹检测
DogDaoDao3 小时前
【预备理论知识——2】深度学习:线性代数概述
人工智能·深度学习·线性代数
牛哥带你学代码3 小时前
交叠型双重差分法
人工智能·深度学习·机器学习
zqx_73 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
深度学习实战训练营4 小时前
基于keras的停车场车位识别
人工智能·深度学习·keras
菜就多练_08285 小时前
《深度学习》OpenCV 摄像头OCR 过程及案例解析
人工智能·深度学习·opencv·ocr
没有余地 EliasJie5 小时前
Windows Ubuntu下搭建深度学习Pytorch训练框架与转换环境TensorRT
pytorch·windows·深度学习·ubuntu·pycharm·conda·tensorflow
技术无疆6 小时前
【Python】Streamlit:为数据科学与机器学习打造的简易应用框架
开发语言·人工智能·python·深度学习·神经网络·机器学习·数据挖掘