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

4,208 阅读5分钟

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

写在开头

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

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

image.png

预备知识

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

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

image.png

有时候我们想让控制台干净、整洁一点应该怎么做呢?你可以配置一下 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 文件的源码内容:

image.png

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

image.png

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 命令重启服务,查看页面:

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

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