ElementUI源码系列九 - 搭建element-ui官方文档之md文件渲染到页面、demo-block组件的实现

3,628 阅读5分钟

「这是我参与2022首次更文挑战的第11天,活动详情查看:2022首次更文挑战」。

写在开头

上一篇文章 ElementUI源码系列八 - 搭建element-ui官方文档之项目的基本框架 我们已经搭建好了项目的基本结构,本章,我们就围绕标题 "md文件渲染到页面""demo-block组件的实现" 这两个知识点来进行学习。

下面我画了一张大致的流程图,可以先浏览浏览或者最后回头来看看:

image.png

预备知识

启动服务时去掉多余的打印信息

我们执行 npm run demo 启动我们的项目时,总是会看到控制台会输出一大堆东西。

image.png

有时候我们想让控制台干净、整洁一点应该怎么做呢?你可以配置一下 webpackstats 对象:

// 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 文件的源码内容:

image.png

页面上,我们点击 button 的路由,也终于不会报错了。

image.png

markdown转化成html

既然我们能拿到 .md 文件的源码内容,那么,接下来我们就能正式开始对文件内的 markdown 进行转换了。

转换过程中我们会用到以下这些依赖:

  • markdown-it:是一个辅助解析 markdown 的库,能将 markdown 字符串转换成 HTML 字符串,也就是可以完成从 # 大标题<h1>大标题</h1> 的转换。在线体验
  • markdown-it-chain:是一个以链式调用的形式来辅助解析 markdown 的库,markdown-itmarkdown-it-chain 的关系就好比 webpackwebpack-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 命令重启服务,查看页面:

image.png

可以发现,我们原先写的 .md 文件内容已经能正常显示在页面上了。

而上面关于 markdown-it 等相关依赖的使用,寥寥数行代码,你不要觉得难,这都是它们的基本语法而且,看它们的文档对着写就行啦。初次看见可能比较蒙,你可以慢慢去 console 每次代码执行后的结果,会更好的理解。

我们再来修改 examples/docs/zh-CN/button.md 文件,添加我们自定义的块容器:

## Button 按钮
常用的操作按钮。

:::demo 橙某人

:::

然后查看页面:

image.png

可以发现,我们自定义的块容器会被转化成 <demo-block /> 标签了,但是,控制台会有报错,当然,这也正常,因为这个组件我们还没具体实现。

image.png

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 /> 标签也已经被解析了。

image.png

组件具体细节实现

完成组件的引入后,接下来我们就要来完成 <demo-block /> 组件里面的细节部分了。我们先来观察一下 element-ui 线上文档的这块组件的样子。

image.png

组件主要分成三个内容区,对应我们上面代码中留的三个 <slot /> 标签,接下来,我们只要把相应的 "东西" 丢到里面就完成了。

我们先来修改一下 examples/docs/zh-CN/button.md 的内容:

image.png

(这里只能提供截图了,要辛苦小伙伴自己手敲了,因为写的是 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}
  `;
}

上面代码都做了详细注释,并且小编给你画了一张图参考,如果你还不懂的话,欢迎在底下评论留言哦。

image.png

我们再创建 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" /> 标签还没有内容。

image.png

还有,从图中可以看到 <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');

再次查看页面,可以看到标签都能正常被识别了。

image.png

接下来,我们需要来继续完善 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>

我们给组件添加一点逻辑处理,查看页面:

image.png

从图中可以看到,每个 <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

查看页面,可以看到,代码块的展示也正常,和描述信息分离出来了,这样子就大功告成了。

image.png

写到这里,核心功能就写完啦,<demo-block /> 组件还剩下一些小功能,你可以自己再完善完善,像收起/展开、代码高亮的功能,应该都不算太难,本章节就不再继续啰嗦下去啦。

B4D94C17.gif

往期内容




至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。