在线客服

前端集成脚手架之组件文档预览与编译(一)

adminadmin 报建百科 2024-04-24 105 11
前端集成脚手架之组件文档预览与编译(一)

前言

好久没静下来记记近期学到的一些前端集成脚手架的技术知识点了, 上一篇文章 从零实现属于自己的前端脚手架,简单地介绍了利用命令行交互快速创建项目模版的实现;这一篇打算记一下组件文档如何开发,再不记就该忘光了。

组件文档

在前端开发领域,组件库是一种提高开发效率和项目质量的重要手段。一个优秀的组件库可以为开发者提供丰富的功能,降低实现复杂交互和视觉效果的难度。相应的,组件文档是描述组件如何使用的关键,作为码农,肯定看过一些库的文档写得很烂而吐槽吧(有些公司内部组件,组件文档就只有README.md,没有组件示例,新接触的开发者还得去看下源码实现才知道如何调用0.0)。废话过多了...,接下来准备讲讲我瞎捣鼓的组件文档方案(以Vue开发的组件库作为示例)~

组件文档预览效果

以下是对应的README.md文档

# 组件1的使用文档

如何使用组件

<!-- 下面这段代码是组件示例能够展现的关键,下文将讲解如何实现 -->
::: vue examples/component1/demo/index.vue :::


### 功能介绍

### 使用方法

### 使用示例

### 参数

#### props

| 参数 | 说明 | 类型 | 必选 | 可选值 | 默认值 |
| ----   | ----  | ----  |  ----  | ---- | ----  |
| ----   | ----  | ----  |  ----  | ---- | ----  |


#### 事件
| 事件名称 | 说明 | 回调参数 |
| ---- | ---- | ---- |
| ----  | ---- | -   |

想实现上述效果,要实现的内容分为两部分:

  1. 解析markdown内容,在浏览器加载
  2. 将示例组件内嵌到markdown内容的指定位置

解析markdown内容

作为一个前端切图仔,这部分内容只能站在巨人的肩膀上开发(缝合怪哈哈)。

需要用到的库包括:

  • markdown-it 解析md语法,转义成html语法
  • markdown-it-container 解析::: ::: 语法(相当于你可以在md中编写你定义md语法)
  • highlight.js 代码高亮

如何将示例组件内嵌到md中

既然我们使用markdown-it库解析md语法,将其转为dom元素,那我们可以想一想,将示例组件也转为dom元素,插到解析完之后的md内容中。

编写一个webpackLoader,以下是思路:

  1. 解析md文件,将其转为vue-loader能识别的内容,也就是md文件转vue文件,后续需要用到这个文件,暂记为main.vue(将解析后的md内容,用<tempalte></tempalte>包裹)
  2. 解析::: vue examples/component1/demo/index.vue :::语法,将vue 后面路径对应的示例组件注册到main.vue中,这样就可以显示示例组件的预览啦。
  3. 最后再用普通vue项目加载根文件的方式加载main.vue,就能实现组件文档的预览啦(编译也是一样的)

markdown-loader的实现

webpack loader本质是一个函数,用来处理输入的参数,输出想要的数据。按照上面的思路,函数的逻辑大致如下:

// loader-utils 用于获取loader的配置参数,schema-utils用于校验loader的配置参数
const { getOptions } = require("loader-utils");
const { validate } = require("schema-utils");

const schema = {
  type: "object",
  properties: {
    framework: {
      type: "string",
    },
    options: {
      type: "object",
    },
  },
};

module.export = function() {
  const options = getOptions(this);
  validate(schema, options);
  return new MdParser({
    source,
    options
  }).parse();
}

我们要实现MdParser类,需要考虑以下几个问题:

  1. 通过配置参数,可以将md内容处理成vuereact...等文件(支持多语言嘛,虽然我们现在只考虑vue)
  2. md中代码块的高亮配置
  3. md中示例组件的插入
  4. md中示例组件代码的展示

MdParser的实现

class MdParser {
    constructor({ source, options = {} }) {
        // 原文件信息
        this.source = source;
        // 选项处理
        this.options = Object.assign({ domId: "component-docs" }, options, {
          markdownOption: {
            // 默认的md解析配置(只做了高亮代码块)
            ...DEFAULT_MARKDOWN_OPTIONS,
            // 自定义md解析配置
            ...options?.markdownOption,
          },
        });
        
        // 记录MD插槽路径,也就是示例组件的路径
        this.filePaths = [];
        
        // 初始化MD解析器
        this.markdown = new MarkdownIt(this.options.markdownOption);
        // 初始化MD解析器插件
        MdParser.useMarkdownItContainer.apply(this);
    }
}

DEFAULT_MARKDOWN_OPTIONS配置如下(高亮代码):

const DEFAULT_MARKDOWN_OPTIONS = {
  html: true,
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return (
          '<pre v-pre class="hljs"><code>' +
          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
          "</code></pre>"
        );
      } catch (err) {
        // ignore
      }
    }
    return ""; // use external default escaping
  },
};
md中示例组件的插入

上面说过,解析::: vue examples/component1/demo/index.vue :::语法,将vue 后面路径对应的示例组件注册到main.vue中,这样就可以预览示例组件。那么useMarkdownItContainer方法就是用来处理示例组件路径的。

const NEW_LINE = "\r\n";
class MdParser {
    // ...
    static useMarkdownItContainer() {
        let { framework = "vue" } = this.options;
        framework = framework.toLocaleLowerCase();
        const mdParserInstance = this;
        this.markdown.use(MarkdownItContainer, framework, {
          validate(params) {
            return params.trim().match(/^vue\s+(.*)$/);
          },
          // tokens参数值是所有解析::: :::之后的内容,idx是对应当前render要处理的是第几个
          render(tokens, idx) {
            const str = tokens[idx].info.trim().match(/\s+(.*):::$/);
            if (tokens[idx].nesting === 1) {
              const pathTemp = str[1].trim();
              // 拿到示例组件的路径
              const filePath = path.resolve("./", pathTemp);
              if (!fs.existsSync(filePath)) {
                console.log("示例组件路径错误:", str[1]);
                return "\n";
              } else {
                // 这里为了兼容可能不同路径下会有文件名相同,直接将路径+文件名定义为后面要注册的组件名称
                /** e.g:
                 * examples/demo1/index.vue
                 * examples/demo2/index.vue
                 * 若直接取文件名当做组件名导入和注册,会报错
                 * 所以最后注册组件时,导入的形式是这样的
                 * import examples_demo1_index from 'xxx'
                 */
                const name = pathTemp.replace(/\//g, "_").split(".")[0];

                // 过滤重名组件,避免重复注册
                if (!that.filePaths.find((item) => item.name === name)) {
                  that.filePaths.push({
                    // 这里多增加转义\,是因为我们输出的是字符串文本,
                    // 只不过内容格式为vue文件格式,没转义的话,最后给到vue-loader,\\会变成\。
                    // 我们要确保输出的路径最终被消费的时候是跟输入进来的一样是\\。
                    path: filePath.replace(/\\/g, "\\\\"),
                    name: switchCompName(name),
                  });
                }
                return `<CodePanel code="${enCodeStr(
                  filePath
                )}"><${name}/> ${NEW_LINE}
                    <template v-slot:code>
                      ${addCode(filePath, that.options.markdownOption)}
                    </template> ${NEW_LINE}
                    </CodePanel> ${NEW_LINE}
                  `;
              }
            } else {
              return "";
            }
          }
        }
    }
}

如果留心的话,会发现useMarkdownItContainer方法返回的字符串中,用到了<CodePanel></CodePanel>enCodeStraddCode,这些是干什么的呢?

CodePanel

这是实现了一个简单的组件展示、代码预览的功能组件,类似elementUI的组件示例(::: ::: 的思路就是来源于element plus的组件文档编译)。 思路很简单:

提供一个插槽,展示示例组件,将示例组件的代码通过props.code传进来,点击复制按钮,拷贝到剪切板上。

<template>
  <div class="panel-container">
    <div class="component-main">
      <slot>示例组件的插槽</slot>
    </div>
    <div class="code-main">
      <div class="options">
        <i class="option-item" title="复制源代吗" @click="copyCode">
          <svg
            preserveAspectRatio="xMidYMid meet"
            viewBox="0 0 24 24"
            width="1.2em"
            height="1.2em"
            data-v-65a7fb6c=""
          >
            <path
              fill="currentColor"
              d="M7 6V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1.001 1.001 0 0 1 3 21l.003-14c0-.552.45-1 1.007-1H7zM5.003 8L5 20h10V8H5.003zM9 6h8v10h2V4H9v2z"
            ></path></svg
        ></i>
        <i class="option-item" title="查看源代吗" @click="switchShowCodePanel">
          <svg
            preserveAspectRatio="xMidYMid meet"
            viewBox="0 0 24 24"
            width="1.2em"
            height="1.2em"
            data-v-65a7fb6c=""
          >
            <path
              fill="currentColor"
              d="m23 12l-7.071 7.071l-1.414-1.414L20.172 12l-5.657-5.657l1.414-1.414L23 12zM3.828 12l5.657 5.657l-1.414 1.414L1 12l7.071-7.071l1.414 1.414L3.828 12z"
            ></path>
          </svg>
        </i>
      </div>
      <div
        ref="transBox"
        :class="{ 'transition-box': true, 'hide-box': isShowCodePanel }"
      >
        <slot name="code"></slot>
        <div class="code-button" @click="isShowCodePanel = false">
          <i style="width: 18px; height: 18px; margin-right: 8px"
            ><svg
              viewBox="0 0 1024 1024"
              xmlns="http://www.w3.org/2000/svg"
              data-v-65a7fb6c=""
            >
              <path
                fill="currentColor"
                d="M512 320 192 704h639.936z"
              ></path></svg
          ></i>
          收起源代码
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "CodePanel",
  props: {
    code: {
      type: String,
      default: " ",
    },
  },
  data() {
    return {
      isShowCodePanel: false,
    };
  },
  methods: {
    copyCode() {
      const input = document.createElement("textarea");
      input.value = decodeURI(this.code);
      console.log(decodeURI(this.code));
      input.style.transform = "translateX(-1000px)";
      document.body.appendChild(input);
      input.select();
      document.execCommand("Copy");
      document.body.removeChild(input);
    },

    switchShowCodePanel() {
      this.isShowCodePanel = !this.isShowCodePanel;
      this.$refs.transBox.style.setProperty("--max-height", 0 + "px");
      this.$nextTick(() => {
        const height = this.$refs.transBox.scrollHeight;
        this.$refs.transBox.style.setProperty("--max-height", height + "px");
      });
    },
  },
};
</script>

<style scoped>
.panel-container .component-main {
  padding: 10px;
  border: 1px solid #ddd;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.panel-container .code-main {
  padding: 10px;
  border: 1px solid #ddd;
  border-top: transparent;
  border-bottom-left-radius: 8px;
  border-bottom-right-radius: 8px;
  overflow: hidden;
}
.options {
  padding: 0 10px;
  text-align: right;
}
.options .option-item {
  cursor: pointer;
  margin: 0 4px;
}
.options .option-item:active {
  color: #409eff;
}
.transition-box {
  transform-origin: center top;
  transition: max-height 0.3s ease;
  will-change: max-height;
  overflow: hidden;
  max-height: 0;
}
.hide-box {
  max-height: var(--max-height);
}
.code-button {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 8px;
  padding-top: 8px;
  cursor: pointer;
  border-top: 1px solid #ddd;
}
.code-button:active {
  color: #409eff;
}
</style>

enCodeStraddCode
  • addCode将示例组件的内容转为代码块
  • enCodeStr将示例组件的内容转为字符串
const addCode = (path, markdownOption) => {
  const fileStr = fs.readFileSync(path).toString();
  const md = new MarkdownIt(markdownOption);
  return md.render(`
  \`\`\`html
  ${fileStr}
  \`\`\`
  `);
};

const enCodeStr = (path) => {
  const fileStr = fs.readFileSync(path).toString();
  return encodeURI(fileStr);
};

到这里,我们已经完成了代码高亮、示例组件的展示与代码内容的复制。接下来就是实现最重要的一部分,将输入的md内容转为vue格式内容。

MD解析方法:parse

上面讲过,md内容解析成vue格式的内容,然后示例组件注册成vue组件,最后输出vue格式的内容。 那我们就需要处理生成template,script,style三块内容。

class MdParser {
    // ...
    // 解析MD
    parse() {
        // 定义结果对象,包括template,script,style三个部分
        let result =
          typeof this.options.process === "function"
            ? this.options.process(this.source)
            : {
                template: this.source,
                script: "",
                style: ""
              };
        // md内容解析
        const html = this.markdown.render(result.template);
        
        // 将packages组件注册为全局组件(怎么方便怎么来),这是为了解决某些组件相互引用又没注册(仁者见仁,这不是必要的)
        定义二维数组,用来存import 和 Vue.component()
        const globalUseComps = [[], []];
        const pwd = process.cwd();
        const compDirPath = path.resolve(pwd, "packages");
        if (fs.statSync(compDirPath).isDirectory) {
          const compDirs = fs.readdirSync(compDirPath);
          compDirs.forEach((item) => {
            const cuttentItem = path.resolve(compDirPath, item, "index.vue");
            if (fs.existsSync(cuttentItem)) {
              const componentName = switchCompName(item);
              globalUseComps[0].push(
                `import ${componentName} from '${cuttentItem.replace(
                  /\\/g,
                  "\\"
                )}';`
              );
              globalUseComps[1].push(
                `Vue.component('${componentName}', ${componentName});`
              );
            }
          });
        } else {
          throw new Error(`没找到${compDirPath}路径`);
        }
        // 默认解析MD之后需要动态生成的script
        const scriptStr = `
            import Vue from 'vue';
            ${globalUseComps[0].join(" ")}
            ${globalUseComps[1].join(" ")}
            import CodePanel from '${path
              .resolve(__dirname, "./code-panel.vue")
              .replace(/\\/g, "\\\\")}';
            ${this.filePaths
              .map((item) => `import ${item.name} from '${item.path}'`)
              .join(";")}
            export default {
              components: {
                CodePanel,
                ${this.filePaths.map((item) => item.name).join(",")}
              }
            }
        `;
        // 预留自定义处理函数
        if (typeof this.options.process === "function") {
          result.script = `<script>
            ${result.script || ""}
            ${scriptStr}
          </script>
          `;
          result.style = `<style>${
            result.style ||
            `
              pre {
                padding: 10px;
                background: #DDD;
              }
            `
          }</style>`;
        } else {
          result = {
            template: this.source,
            script: scriptStr,
            style: `<style>
              pre {
                padding: 10px;
                background: #DDD;
              }
            </style>`,
          };
        }
        // 判断使用的框架
        const { framework = "vue" } = this.options;
        let fileContent = `${framework}暂未实现`;
        switch(framework) {
            case 'vue': 
                vueFile = `
                  <template>
                    // id 是留给文档站点用的,以后有时间再另起一篇讲讲
                    <div id="${this.options.domId || "component-docs"}" class="markdown-body">
                      ${html}
                    </div>
                  </template>
                  ${result.script}
                  ${result.style}
                `;
                break;
            default: 
                break;
        }
        return fileContent;
    }
}

到这里,整个markdown-loader 算是基本完成了,再在组件库项目vue.config.js配置文件加上:

module.exports = {
    pages: {
        index: "src/readme/index.js",
    }
    chainWebpack: (config) => {
        config.entry("index").clear().add(mainPath);
        delete config.entry.app;
        config.module
            .rule("markdown")
            .test(/.md$/)
            .use("vue-loader")
            .loader("vue-loader")
            .end()
            .use("markdown-loader")
            .loader("markdown-loader的路径")
            .options({
                framework: "vue",
            })
            .end();
    };
}
// src/readme/index.js
import Vue from 'vue'
import './style.css'
import 'highlight.js/styles/github.css';
import md from '手动修改要预览的组件readme路径'
new Vue({
  render: h => h(md),
}).$mount('#component-docs')
    

最后预览这一步,目前很麻烦,需要手动修改路径;接下来会在前端集成脚手架之组件文档预览与编译(二)中讲解:

  • 如何高效预览组件
  • 如何结合vue-cli,集成配置封装成命令行指令

结语

摸爬滚打切图仔~ 写得不好不对的地方,请大家批评指正。

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!
代办报建

本公司承接江浙沪报建代办施工许可证。
联系人:张经理,18321657689(微信同号)。

喜欢0发布评论

11条评论

  • 游客 发表于 2个月前

    东方不败还是灭绝师太啊?http://k17g7.shenzsnytl.com/2024/5.html

  • 游客 发表于 2个月前

    支持一下!http://tiqr.cqyiyou.net/test/081284773.html

  • 游客 发表于 2个月前

    好帖子!http://test.cqyiyou.net/test/

  • 彩客网官方软件评价 发表于 1个月前

    这位作者的文笔极其出色,用词精准、贴切,能够形象地传达出他的思想和情感。http://99up.7cotton.com

  • 游客 发表于 2周前

    顶顶更健康!http://www.guangcexing.net/voddetail/TwAhtxSjph.html

  • 游客 发表于 2周前

    支持一下,下面的保持队形!http://www.lw400.com/

发表评论

  • 昵称(必填)
  • 邮箱
  • 网址