一、背景
随着vivo悟空活动中台活动组件越来越多,活动中台开发的小伙伴们愈发的感知到我们缺少一个可以沉淀通用能力,提升代码复用性的组件库。在这个目标基础之上诞生了acitivity-components,但是随着组件的抽离增多,在和上下游沟通时,发现公共组件对于运营、产品、测试同学来说都是黑盒,只有开发自己知道沉淀了哪些能力,业务上哪些模块进行了抽取。同时,在对外赋能时,也缺少了一个平台呈现我们抽离的组件,基于此目标,开发小伙伴们开始构思插件管理平台的开发计划。
二、平台架构
2.1 技术选型
在平台开发之初经过小伙伴们一起沟通确定了Midway+Vue+MySQL技术栈,并完成对应的架构梳理,在做Node层框架选型时,我们选择了Midway做为Node层的开发框架,原因主要有以下几点:
2.2 架构拆解
首先我们来看一下插件管理平台的架构图:
通过对平台整体架构图的梳理,构建了整个平台开发的基本思路:
三、重点技术详解
在平台的整体搭建开发过程中,梳理了以下技术点内容,进行重点介绍。
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组件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": "
CommonDialog
\n
组件介绍
\n
\n
通用基础弹框
\n
\n
属性-Attributes
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
参数
|
说明
|
类型
|
默认值
|
必须
|
sync
|
maskZIndex
|
弹框的z-index层级
|
Number
|
1000
|
否
|
否
|
bgStyle
|
背景样式
|
Object
|
-
|
否
|
否
|
closeBtnPos
|
关闭按钮的位置
|
String
|
top-right
|
否
|
否
|
showCloseBtn
|
是否展示关闭按钮
|
Boolean
|
true
|
否
|
否
|
v-model
|
是否展示弹框
|
Boolean
|
-
|
否
|
否
|
\n
事件-Events
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
事件名
|
说明
|
参数
|
input
|
-
|
|
close
|
弹框关闭事件
|
|
\n
插槽-Slots
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
名称
|
说明
|
scope
|
content
|
default
|
弹框内容
|
|
-
|
\n",
-
"fullPath": "/F/我的项目/公共组件/activity-components/doc/base-components/CommonDialog/index.md",
-
"path": "doc/base-components/CommonDialog/index.md",
-
"cname": "通用基础弹框"
-
}, {
-
"name": "ConfirmDialog",
-
"md": "
ConfirmDialog
\n
组件介绍
\n
\n
确认弹框
\n
\n
属性-Attributes
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
参数
|
说明
|
类型
|
默认值
|
必须
|
sync
|
bgStyle
|
背景样式
|
Object
|
-
|
否
|
否
|
maskZIndex
|
弹框层级
|
Number
|
1000
|
否
|
否
|
v-model
|
弹框展示状态
|
Boolean
|
-
|
否
|
否
|
title
|
弹框标题文案
|
String
|
-
|
否
|
否
|
titleColor
|
标题颜色
|
String
|
-
|
否
|
否
|
leftTitle
|
左按钮文案
|
String
|
取消
|
否
|
否
|
rightTitle
|
右按钮文案
|
String
|
确定
|
否
|
否
|
leftBtnStyle
|
左按钮样式
|
Object
|
-
|
否
|
否
|
rightBtnStyle
|
右按钮样式
|
Object
|
-
|
否
|
否
|
\n
事件-Events
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
事件名
|
说明
|
参数
|
cancel
|
左按钮点击触发
|
|
confirm
|
右按钮点击触发
|
|
close
|
弹框关闭事件
|
|
input
|
-
|
|
\n",
-
"fullPath": "/F/我的项目/公共组件/activity-components/doc/wap-components/ConfirmDialog/index.md",
-
"path": "doc/wap-components/ConfirmDialog/index.md",
-
"cname": "确认弹框"
-
}, {
-
"name": "WinnerList",
-
"md": "
WinnerList
\n
组件介绍
\n
\n
中奖列表
\n
\n
属性-Attributes
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
参数
|
说明
|
类型
|
默认值
|
必须
|
sync
|
item
|
-
|
|
-
|
是
|
否
|
prodHost
|
-
|
String
|
-
|
是
|
否
|
prizeTypeOptions
|
-
|
Array
|
-
|
否
|
否
|
isOrder
|
-
|
Boolean
|
true
|
否
|
否
|
listUrl
|
-
|
String
|
/wukongcfg/config/activity/reward/got/list
|
否
|
否
|
exportUrl
|
-
|
String
|
/wukongcfg/config/activity/reward/export
|
否
|
否
|
\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;
-
}
-
}
-
}
通过这段代码可以发现 :
这段代码可以明显感受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": "****-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 archive
-
targz.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.js
-
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'
-
...
形式2:(wk-base-ui为例)package.json中无main指定入口,根目录下入口文件为index.js:
-
// @wukong/wk-base-ui/index.js
-
export { 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得到抽象语法树ast
-
const 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
阅读(531) | 评论(0) | 转发(0) |