写点什么

【营】在开局,提升【豹】发力 - vivo 活动插件管理平台

  • 2022 年 2 月 22 日
  • 本文字数:11773 字

    阅读完需:约 39 分钟

一、背景


随着 vivo 悟空活动中台活动组件越来越多,活动中台开发的小伙伴们愈发的感知到我们缺少一个可以沉淀通用能力,提升代码复用性的组件库。在这个目标基础之上诞生了 acitivity-components,但是随着组件的抽离增多,在和上下游沟通时,发现公共组件对于运营、产品、测试同学来说都是黑盒,只有开发自己知道沉淀了哪些能力,业务上哪些模块进行了抽取。同时,在对外赋能时,也缺少了一个平台呈现我们抽离的组件,基于此目标,开发小伙伴们开始构思插件管理平台的开发计划。


二、平台架构


2.1 技术选型


在平台开发之初经过小伙伴们一起沟通确定了 Midway+Vue+MySQL 技术栈,并完成对应的架构梳理,在做 Node 层框架选型时,我们选择了 Midway 做为 Node 层的开发框架,原因主要有以下几点:


  • 基于 egg.js -- Midway 可以很好的兼容 egg.js 的插件生态。


  • 依赖注入(loC)-- loC 全名叫做控制反转(Inversion of Control,缩写为 loC),是面向对象的一种设计模式,可以用来降低代码之间的耦合度,实现了高内聚,弱耦合的架构目标。


  • 更好的 Typescript 支持 -- 因为 Midway 使用的 ts 开发,所以在项目开发过程中,我们可以直接使用 ts,利用 ts 的静态类型检查、装饰器等能力,提升我们的代码健壮性和开发效率。


2.2 架构拆解


首先我们来看一下插件管理平台的架构图:



通过对平台整体架构图的梳理,构建了整个平台开发的基本思路:


  • 组件抽离 -- 从建站的组件在往下一层,抽取更基础的组件内容,并集中托管至 activity-components。


  • md 生成 -- 所有的组件都需要对外输出,activity-components 内需要做一层编译操作,每个组件需要自动化生成对应的 md 文档。


  • gitlab hooks -- 如何保证 server 端对 activity-components 的变更都能及时响应,保证组件都是最新的,此处使用了 gitlab 集成中的 push events 监听组件的 push 操作。


  • npm 远程加载 -- 平台需要具备远程拉取 npm 包能力,并解压缩对应的包,将 activity-components 源文件获取到。


  • Vue 全家桶使用 -- 平台 web 端引入 Vue 全家桶,利用动态路由对各个组件进行匹配。


  • 单组件预览 -- 抽离的组件底层存在对建站能力的依赖,此处需要将建站的编辑页进行拆解,集成建站底层能力,完成对 activity-components 的组件预览。


  • 文件服务 -- 具备公共组件策划文档的上传能力,方便运营和产品对公共组件的接入。


三、重点技术详解


在平台的整体搭建开发过程中,梳理了以下技术点内容,进行重点介绍。


3.1 组件抽离


首先可以看一下 activity-components 组件库 package.json 内容:


{  "name": "@wukong/activity-components",  "version": "1.0.6",  "description": "活动公共组件库",  "scripts": {     "map": "node ./tool/map-components.js",      "doc": "node ./tool/create-doc.js",      "prepublish": "npm run map && npm run doc"    }  }
复制代码


通过 scripts 里面配置的指令,可以看到,在组件做 publish 操作时,我们利用了 npm 的 pre 事件钩子,完成组件自身的第一层编译操作,map-components 主要用于实现对组件的文件目录进行遍历,导出所有的组件内容。


文件目录结构如下:


|-src|--base-components|---CommonDialog|---***|--wap-components|---ConfirmDialog|---***|--web-components|---WinnerList|---***|-tool|--create-doc.js|--map-components.js
复制代码


map-components 主要实现对文件目录的遍历操作;


// 深度遍历目录const deepMapDir = (rootPath, name, cb) => {  const list = fse.readdirSync(rootPath)  list.forEach((targetPath) => {    const fullPath = path.join(rootPath, targetPath)    // 解析文件夹    const stat = fse.lstatSync(fullPath)    if (stat.isDirectory()) {      // 如果是文件夹,则继续向下遍历      deepMapDir(fullPath, targetPath, cb)    } else if (targetPath === 'index.vue') {      // 如果是文件      if (typeof cb === 'function') {        cb(rootPath, path.relative('./src', fullPath), name)      }    }  })}*********// 拼接文件内容const file = `${components.map(c => `export { default as ${c.name} } from './${c.path}'`).join('\n')}`// 文件输出try {  fse.outputFile(path.join(__dirname, '..', pkgJson.main), file)} catch (e) {  console.log(e)}
复制代码


在做文件遍历时,我们采用了递归函数,保证我们对当前的文件目录做到彻底遍历,将所有的组件全部找出,通过这段代码,可以看到,定义的组件需要有一个 index.vue 组件作为检索的入口文件,找寻到这个组件之后,我们就会停止向下寻找,并将当前的组件目录解析出来,具体流程如下图。



导出文件内容如下:


export { default as CommonDialog } from './base-components/CommonDialog/index.vue'export { default as Login } from './base-components/Login/index.vue'export { default as ScrollReach } from './base-components/ScrollReach/index.vue'export { default as Test } from './base-components/Test/index.vue'***
复制代码


通过上述一系列操作,统一对外的目录文件生成,组件抽离只需要正常往组件库添加即可。


3.2 Markdown 文件自动化生成


生成了组件目录之后,对应的组件说明文档该如何生成呢,此处我们引用同中心另一位同事冯镝同学开发的vue-doc (opens new window),完成对应 Vue 组件 md 文档自动化生成,首先来看一下定义的 doc 指令。


 "doc": "node ./tool/create-doc.js" ***  create-doc.js  const { singleVueDocSync } = require('@vivo/vue-doc') const outputPath = path.join(__dirname, '..', 'doc', mdPath) singleVueDocSync(path.join(fullPath, 'index.vue'), {    outputType: 'md',    outputPath  })
复制代码


通过 Vue-doc 暴露的 singleVueDocSync 方法,在 server 端根目录下会新建一个 doc 文件夹,这里面会根据组件的目录结构生成对应的组件 md 文档,此时 doc 的目录结构为:


|-doc|--base-components|---CommonDialog|----index.md|---***|--wap-components|---ConfirmDialog|----index.md|---***|--web-components|---WinnerList|----index.md|---***|-src|--**
复制代码


通过这个文件目录可以看到,根据组件库的目录结构,在 doc 文件夹下生成同样目录结构的 md 文件,至此每个组件的 md 文档都已经生成了,但是只到这一步是不够的。


我们还需要将当前的 md 文档进行整合,通过一个 json 文件表述出来,因为插件管理平台是需要解析到这个 json 文件并将其做为返回内容至 web 端,完成前端页面的渲染,基于此目标我们写了以下代码:


const cheerio = require('cheerio')const marked = require('marked')const info = {  timestamp: Date.now(),  list: []}***let cname = cheerio.load(marked(fse.readFileSync(outputPath).toString()))  info.list.push({    name,    md: marked(fse.readFileSync(outputPath).toString()),    fullPath: convertPath(outputPath),    path: convertPath(path.join('doc', mdPath)),    cname: cname('p').text() }) *** // 生成对应的组件数据文件fse.writeJsonSync(path.resolve('./doc/data.json'), info, {  spaces: 2})
复制代码


这里引入两个比较重要的库一个是 cheerio,一个是 marked 。cheerio 是 jquery 核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对 DOM 进行操作的地方,marked 主要是将 md 文档转换为 html 的文档格式,完成上述代码编写之后,我们在 doc 目录下生成一个 data.json 文件,具体内容如下:


{  "timestamp": 1628846618611,  "list": [    {      "name": "CommonDialog",      "md": "<h1 id=\"commondialog\">CommonDialog</h1>\n<h3 id=\"组件介绍\">组件介绍</h3>\n<blockquote>\n<p>通用基础弹框</p>\n</blockquote>\n<h2 id=\"属性-attributes\">属性-Attributes</h2>\n<table>\n<thead>\n<tr>\n<th align=\"center\">参数</th>\n<th align=\"center\">说明</th>\n<th align=\"center\">类型</th>\n<th align=\"center\">默认值</th>\n<th align=\"center\">必须</th>\n<th align=\"center\">sync</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">maskZIndex</td>\n<td align=\"center\">弹框的z-index层级</td>\n<td align=\"center\">Number</td>\n<td align=\"center\">1000</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">bgStyle</td>\n<td align=\"center\">背景样式</td>\n<td align=\"center\">Object</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">closeBtnPos</td>\n<td align=\"center\">关闭按钮的位置</td>\n<td align=\"center\">String</td>\n<td align=\"center\">top-right</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">showCloseBtn</td>\n<td align=\"center\">是否展示关闭按钮</td>\n<td align=\"center\">Boolean</td>\n<td align=\"center\">true</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">v-model</td>\n<td align=\"center\">是否展示弹框</td>\n<td align=\"center\">Boolean</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n</tbody></table>\n<h2 id=\"事件-events\">事件-Events</h2>\n<table>\n<thead>\n<tr>\n<th align=\"center\">事件名</th>\n<th align=\"center\">说明</th>\n<th align=\"center\">参数</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">input</td>\n<td align=\"center\">-</td>\n<td align=\"center\"></td>\n</tr>\n<tr>\n<td align=\"center\">close</td>\n<td align=\"center\">弹框关闭事件</td>\n<td align=\"center\"></td>\n</tr>\n</tbody></table>\n<h2 id=\"插槽-slots\">插槽-Slots</h2>\n<table>\n<thead>\n<tr>\n<th align=\"center\">名称</th>\n<th align=\"center\">说明</th>\n<th align=\"center\">scope</th>\n<th align=\"center\">content</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">default</td>\n<td align=\"center\">弹框内容</td>\n<td align=\"center\"></td>\n<td align=\"center\">-</td>\n</tr>\n</tbody></table>\n",      "fullPath": "/F/我的项目/公共组件/activity-components/doc/base-components/CommonDialog/index.md",      "path": "doc/base-components/CommonDialog/index.md",      "cname": "通用基础弹框"    }, {      "name": "ConfirmDialog",      "md": "<h1 id=\"confirmdialog\">ConfirmDialog</h1>\n<h3 id=\"组件介绍\">组件介绍</h3>\n<blockquote>\n<p>确认弹框</p>\n</blockquote>\n<h2 id=\"属性-attributes\">属性-Attributes</h2>\n<table>\n<thead>\n<tr>\n<th align=\"center\">参数</th>\n<th align=\"center\">说明</th>\n<th align=\"center\">类型</th>\n<th align=\"center\">默认值</th>\n<th align=\"center\">必须</th>\n<th align=\"center\">sync</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">bgStyle</td>\n<td align=\"center\">背景样式</td>\n<td align=\"center\">Object</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">maskZIndex</td>\n<td align=\"center\">弹框层级</td>\n<td align=\"center\">Number</td>\n<td align=\"center\">1000</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">v-model</td>\n<td align=\"center\">弹框展示状态</td>\n<td align=\"center\">Boolean</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">title</td>\n<td align=\"center\">弹框标题文案</td>\n<td align=\"center\">String</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">titleColor</td>\n<td align=\"center\">标题颜色</td>\n<td align=\"center\">String</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">leftTitle</td>\n<td align=\"center\">左按钮文案</td>\n<td align=\"center\">String</td>\n<td align=\"center\">取消</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">rightTitle</td>\n<td align=\"center\">右按钮文案</td>\n<td align=\"center\">String</td>\n<td align=\"center\">确定</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">leftBtnStyle</td>\n<td align=\"center\">左按钮样式</td>\n<td align=\"center\">Object</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">rightBtnStyle</td>\n<td align=\"center\">右按钮样式</td>\n<td align=\"center\">Object</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n</tbody></table>\n<h2 id=\"事件-events\">事件-Events</h2>\n<table>\n<thead>\n<tr>\n<th align=\"center\">事件名</th>\n<th align=\"center\">说明</th>\n<th align=\"center\">参数</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">cancel</td>\n<td align=\"center\">左按钮点击触发</td>\n<td align=\"center\"></td>\n</tr>\n<tr>\n<td align=\"center\">confirm</td>\n<td align=\"center\">右按钮点击触发</td>\n<td align=\"center\"></td>\n</tr>\n<tr>\n<td align=\"center\">close</td>\n<td align=\"center\">弹框关闭事件</td>\n<td align=\"center\"></td>\n</tr>\n<tr>\n<td align=\"center\">input</td>\n<td align=\"center\">-</td>\n<td align=\"center\"></td>\n</tr>\n</tbody></table>\n",      "fullPath": "/F/我的项目/公共组件/activity-components/doc/wap-components/ConfirmDialog/index.md",      "path": "doc/wap-components/ConfirmDialog/index.md",      "cname": "确认弹框"    }, {      "name": "WinnerList",      "md": "<h1 id=\"winnerlist\">WinnerList</h1>\n<h3 id=\"组件介绍\">组件介绍</h3>\n<blockquote>\n<p>中奖列表</p>\n</blockquote>\n<h2 id=\"属性-attributes\">属性-Attributes</h2>\n<table>\n<thead>\n<tr>\n<th align=\"center\">参数</th>\n<th align=\"center\">说明</th>\n<th align=\"center\">类型</th>\n<th align=\"center\">默认值</th>\n<th align=\"center\">必须</th>\n<th align=\"center\">sync</th>\n</tr>\n</thead>\n<tbody><tr>\n<td align=\"center\">item</td>\n<td align=\"center\">-</td>\n<td align=\"center\"></td>\n<td align=\"center\">-</td>\n<td align=\"center\">是</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">prodHost</td>\n<td align=\"center\">-</td>\n<td align=\"center\">String</td>\n<td align=\"center\">-</td>\n<td align=\"center\">是</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">prizeTypeOptions</td>\n<td align=\"center\">-</td>\n<td align=\"center\">Array</td>\n<td align=\"center\">-</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">isOrder</td>\n<td align=\"center\">-</td>\n<td align=\"center\">Boolean</td>\n<td align=\"center\">true</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">listUrl</td>\n<td align=\"center\">-</td>\n<td align=\"center\">String</td>\n<td align=\"center\">/wukongcfg/config/activity/reward/got/list</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n<tr>\n<td align=\"center\">exportUrl</td>\n<td align=\"center\">-</td>\n<td align=\"center\">String</td>\n<td align=\"center\">/wukongcfg/config/activity/reward/export</td>\n<td align=\"center\">否</td>\n<td align=\"center\">否</td>\n</tr>\n</tbody></table>\n",      "fullPath": "/F/我的项目/公共组件/activity-components/doc/web-components/WinnerList/index.md",      "path": "doc/web-components/WinnerList/index.md",      "cname": "中奖列表"    }] }
复制代码


至此我们就在 activity-components 侧完成了对组件的 md 文档自动化生成。



通过这张图我们可以清晰的抓取到底层组件中的关键信息,例如:组件支持的属性和事件。


3.3 gitlab hooks


在平台开发的过程中,组件每次做 gitlab 提交时,平台是无法感知组件库的代码发生了变化,于是我们开始调研,在对 gitlab 的 api 进行搜索时,发现 gitlab 已经提供了集成的解决方案。



在完成对应 url 和 secret Token 配置之后,点击 save changes 会生成如下图所示内容:



此时已经完成基本的 push events 配置,接下来需要在插件管理平台 server 端完成对应的接口开发。


@provide()@controller('/api/gitlab')export class GitlabController {  @inject()  ctx: Context;  @post('/push')  async push(): Promise<void> {    try {      const event = this.ctx.headers['x-gitlab-event'];      const token = this.ctx.headers['x-gitlab-token'];      // 判断token是否正确      if (token === this.ctx.app.config.gitlab.token) {        switch (event) {          case 'Push Hook':            // do something            const name = 'activity-components';            const npmInfo = await this.ctx.service.activity.getNpmInfo(`@wukong/${name}`);            await this.ctx.service.activity.getPkg(name, npmInfo.data.latest.version);            break;        }      }      this.ctx.body = {        code: ErrorCode.success,        success: true,        msg: Message.success      } as IRes;    } catch (e) {      this.ctx.body = {        code: ErrorCode.fail,        success: false,        msg: e.toString()      } as IRes;    }  }}
复制代码


通过这段代码可以发现 :


  • 首先我们使用了 @controller 声明这个类为控制器类,同时使用了 @post 定义了请求方式;


  • 通过 @inject()去容器中取出对应的实例注入到当前属性中;


  • 通过 @provide()定义当前的对象需要被绑定到对应容器中。


这段代码可以明显感受 loC 机制给开发带来的便利性,当我们想要使用某个实例时,容器会自动将对象实例化交给用户,使得我们的代码具备很好的解耦性。


上述代码中还做了 request 解析,当请求头里的 gitlab-token 为定义的 activity-components,就会继续往后执行后续逻辑,这里 ctx.headers 写法其实就是引用了 koa 的 context.request.headers 的简写,通过 token 验证,保证了只有组件库代码提交时才会触发。


3.4 npm 包远程拉取+解压缩


当完成 gitlab hooks 监听后,如何实现从 npm 私服拉取对应的组件库,并将里面的内容解析出来呢,此处通过查阅 npm 私服的指令,发现可以通过 npm view [<@scope>/][@] [[.]...]来查询当前的私服托管的 npm 包具体信息,基于此,我们在本地的终端检索了下 @wukong/activity-components 包的信息,得到如下信息:


npm view @wukong/activity-components*** {   host: 'wk-site-npm-test.vivo.xyz',   pathname: '/@wukong%2factivity-components',   path: '/@wukong%2factivity-components',   ***   dist:{     "integrity": "sha512-aaJssqDQfSmwQ1Gonp5FnNvD6TBXZWqsSns3zAncmN97+G9i0QId28KnGWtGe9JugXxhC54AwoT88O2HYCYuHg==",    "shasum": "ff09a0554d66e837697f896c37688662327e4105",    "tarball": "http://wk-****-npm-test.vivo.xyz/@wukong%2factivity-components/-/activity-components-1.0.0.tgz"   },   ***}
复制代码


分析 npm view 的返回信息,抓到 npm 包的源地址:dist.tarball:[http://****.xyz/@wukongactivity-components-1.0.0.tgz],通过这个地址可以直接将对应的 tgz 源文件下载到本地。


但是这个地址并不能彻底解决掉问题,因为随着公共组件库的不断迭代,npm 包的版本是在不断变化的,如何才能获取到 npm 包的版本呢?带着这个问题,我们去了 npm 私服 network 抓到一个接口:[http://****.xyz/-/verdaccio/sidebar/@wukong/activity-components];通过查询这个接口的返回,得到了以下信息:



接口返回可以看到 latest.version 返回了最新的版本信息,通过这两个接口,就可以在 Node 层直接下载到最新的组件库,接下来看下插件管理平台侧的代码:


service/activity.ts***// 包存放的根目录,所有的插件加载后统一放在这里const rootPath = path.resolve('temp');  /**   * 获取某个插件的最新版本   * @param {string} fullName 插件全名(带前缀:@wukong/wk-api)   */  async getNpmInfo(fullName) {    const { registry } = this.ctx.service.activity;    // 远程获取@wukong/activity-components的最新版本信息    const npmInfo = await this.ctx.curl(`${registry}/-/verdaccio/sidebar/${fullName}`, {      dataType: 'json',    });
if (npmInfo.status !== 200) { throw new Error(`[error]: 获取${fullName}版本信息失败`); } return npmInfo; } /** * 远程下载npm包 * @param {string} name 插件名(不带前缀:activity-components) * @param {string} tgzName `${name}-${version}.tgz`; * @param {string} tgzPath path.join(rootPath, name, tgzName); */async download(name,tgzName,tgzPath){ const pkgName = `@wukong/${name}`; const pathname = path.join(rootPath, name); // 远程下载文件 const response = await this.ctx.curl(`${this.registry}/${pkgName}/-/${tgzName}`); if (response.status !== 200) { throw new Error(`download ${tgzName}加载失败`); } // 确定文件夹是否存在 fse.existsSync(pathname); // 清空文件夹 fse.emptyDirSync(pathname); await new Promise((resolve, reject) => { const stream = fse.createWriteStream(tgzPath); stream.write(response.data, (err) => { err ? reject(err) : resolve(); }); });}
复制代码


getNpmInfo 方法主要是获取组件的版本信息,download 主要是组件的下载操作,最后完成对应的流文件注入,这两个方法执行完毕之后,我们会生成以下的目录结构:


|-server|--src|---app|----controller|----***|--temp|---activity-components|----activity-compoponents-1.0.6.tgz
复制代码


在 temp 文件下获取到了组件库的压缩包,但是到这一步是不够的,我们需要解压缩这个压缩包,并且要获取到对应的源码。带着这个问题,找到一个 targz 的 npm 包,首先看下官方给的 demo:


var targz = require('targz');// decompress files from tar.gz archivetargz.decompress({    src: 'path_to_compressed file',    dest: 'path_to_extract'}, function(err){    if(err) {        console.log(err);    } else {        console.log("Done!");    }});
复制代码


官方暴露的 decomporess 方法即可完成 targz 包的解压缩,得到对应的组件库源代码,对于压缩包,我们使用 fs 的 remove 方法移除即可:


|-server|--src|---app|----controller|----***|--temp|---activity-components|----doc|----src|----tool|----****
复制代码


到这一步我们就完成了整体的 npm 包拉取和解压缩操作,获取到了组件库的源代码,此时我们需要读取到源代码中 doc 通过 3.2 步骤生成的 json 文件,并将 json 内容返回给 web 侧。


3.5 ast 转译


背景:在对建站平台的基础组件库 wk-base-ui 引入时,由于组件库的 index.js 不是自动生成的,里面会出现冗余代码以及注释的情况,这样会导致插件管理平台根据入口文件无法精准的获取到所有的组件地址,为了解决这个问题,我们使用 @babel/parser、@babel/traverse 解析 wk-base-ui 组件库的入口文件。


思路:找到组件库 npm 包入口文件,根据入口文件中的 export 语句,找到组件库中每个 Vue 组件的路径,并置换成相对 npm 包根目录的地址。


组件库的一般组织形式:


形式 1:(activity-components 为例)


package.json 中有 main 指定入口:


// @wukong/activity-components/package.json{    "name": "@wukong/activity-components",    "description": "活动公共组件库",    "version": "1.0.6",    "main": "src/main.js", // main 指定npm包入口    ...}
复制代码


入口文件:


// src/main.jsexport { default as CommonDialog } from './base-components/CommonDialog/index.vue'export { default as Login } from './base-components/Login/index.vue'export { default as ScrollReach } from './base-components/ScrollReach/index.vue'...
复制代码


形式 2:(wk-base-ui 为例)package.json 中无 main 指定入口,根目录下入口文件为 index.js:


// @wukong/wk-base-ui/index.jsexport { default as inputSlider } from './base/InputSlider.vue'export { default as inputNumber } from './base/InputNumber.vue'export { default as inputText } from './base/InputText.vue'/*export { default as colorGroup } from './base/colorGroup.vue'*/...
复制代码


以上两种形式最终都指向形如 export {default as xxx } from './xxx/../xxx.vue'的文件。为了从入口 js 文件中准确找到 export 组件名和文件路径,这里使用利用 @babel/parser 和 @babel/traverse 来解析,如下:


// documnet.ts
// 通过@babel/parser解析入口js文件内容exportData得到抽象语法树astconst ast = parse(exportData, { sourceType: 'module',});
const pathList: any[] = [];
// 通过@babel/traverse遍历ast,得到每条export语句中的组件名name和对应的vue文件路径traverse(ast, { ExportSpecifier: { enter(path, state) { console.log('start processing ExportSpecifier!'); // do something pathList.push({ path: path.parent.source.value, // 组件导出路径 eg: from './xxx/../xxx.vue' 这里的./xxx/../xxx.vue name: path.node.exported.name, // 组件导出名 eg: export { default as xxx} 这里的xxx }); }, exit(path, state) { console.log('end processing ExportSpecifier!'); // do something }, },});
复制代码


这里最终得到的 pathList 如下:


[  { name: "inputSlider", path: "./base/InputSlider.vue" },  { name: "inputNumber", path: "./base/InputNumber.vue" },  { name: "inputText", path: "./base/InputText.vue" },  ...]
复制代码


后续再遍历 pathList 数组,利用 @vivo/vue-doc 的 singleVueDocSync 解析出每个组件的 md 文档,完成对组件库的文档解析工作。代码示例如下:


pathList.forEach((item) => {  const vuePath = path.join(jsDirname, item.path); // 输入路径  const mdPath = path.join(outputDir, item.path).replace(/\.vue$/, '.md');// 输出路径  try {    singleVueDocSync(vuePath, {      outputType: 'md', // 输入类型 md      outputPath: mdPath, // 输出路径    });    // ...省略  } catch (error) {    // todo 如果遇到@vivo/vue-doc处理不了的组件,暂时跳过。(或者生成一个空的md文件)  }});
复制代码


最终效果如下图,在项目目录下生成 doc 文件夹:



至此,完成了解析组件库并生成对应 md 文档的全部流程。


最后我们可以看一下平台实现的效果:



四、小结


4.1 思考过程


在做建站的组件开发过程中,首先构思公共组件库,解决开发之间组件沉淀的问题,随着组件增多,发现产品和运营对于沉淀的公共组件也有诉求,对于此开始了插件管理平台的架构设计,解决公共组件对产品的黑盒问题,同时也可以很好的赋能悟空活动中台生态,对于别的业务方也可以快速的接入 vivo 悟空活动中台组件,提升自身的开发效率。


4.2 现状和未来计划


目前一共抽离了公共组件 26 个,累计覆盖建站组件超过 12 个,接入的业务方 2 个,累计提升人效大于 20 人天。但是还是不够的,后续我们需要完成组件的自动化测试,继续丰富组件库,增加动效区域,更好的赋能上下游。


作者:vivo 互联网前端团队-Fang Liangliang

发布于: 2 小时前阅读数: 4
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
【营】在开局,提升【豹】发力 - vivo活动插件管理平台