背景
最近團隊打算將公用元件庫進行遷移,需要分析元件庫在專案中的使用情況,由於我們是分模組部署,那麼就需要知道每一個模組使用該元件的情況。在生產環境中分析的話,我們就要想到babel
強大的ast
。並且需要利用gitlab ci
生成文件。
技術方案梳理
- 首先,我們需要準備一個專門存放文件的倉庫
component-in-use-doc
- 其次,寫一個Babel 外掛來分析元件的使用情況
- 最後,將動態生成的.md檔案,利用vuepress生成文件(此處需要用到gitlab ci,提交程式碼的同時會自動觸發ci構建部署)
知識儲備
babel plugin
說到babel, 我們首先會想到抽象語法樹,也就是常說的ast
,附ast地址。我們寫入以下程式碼觀察以下ast
是如何分析我們的程式碼的。
import a from 'seller-service';
a.b();
複製程式碼
我需要分析的就是引用到seller-service
中的a
,並且呼叫了a
的b
方法。那麼我們再來看以下ast
的解析結果
從上述圖中,我們可以從ImportDeclaration
以及CallExpression
中著手進行分析
那麼我們就可以進入開發階段了
- 首先基於團隊提供的腳手架(關於腳手架,此文不做論述)新增一個命令
scc build:docs
,新增build-docs.js
檔案新增babel.config.js
檔案以及component-analysis-in-use.js
檔案,在plugin中引入我們寫的外掛component-analysis-in-use.js
,目前Babel
已經支援引入路徑的方式,本次也將採用改方式引入外掛。設定好lib名字,以便分分析(本次要分析兩個包)
- 其次,我們分析
visitor
也就是觀察者模式中的ImportDeclaration
方法和CallExpression
方法。具體如果寫一個babel
外掛,本次不做分析,本次只分析如何利用觀察者模式中的這兩個階段。詳情請參考文章如何編寫自己的babel plugin外掛
先上程式碼。
const { writeFile } = require('../../utils/command');
const { cwd } = require('../../utils/path-helper');
function ComponentAnalysisInUsePlugin(babel) {
const componentInUse = {
'seller-service': {},
'seller-common': {}
};
return {
name: 'component-analysis-in-use',
visitor: {
ImportDeclaration(path, _ref = { opts: {} }) {
const specifiers = path.node.specifiers;
const source = path.node.source;
// 只有libraryName滿足才會轉碼
if (_ref.opts.sellerServiceLib == source.value && (!babel.types.isImportDefaultSpecifier(specifiers[0]))) { //_ref.opts是傳進來的引數
specifiers.map((specifier) => {// 遍歷specifier獲取local.name
componentInUse[source.value][specifier.local.name] = {};
})
} else if (_ref.opts.sellerCommonLib === source.value && (!babel.types.isImportDefaultSpecifier(specifiers[0]))) {
specifiers.map((specifier) => {// 遍歷specifier獲取local.name
if (!['Utils', 'config', 'constants', 'core', 'directives', 'filters', 'mixins', 'utils', 'locale'].includes(specifier.local.name)) {
componentInUse[source.value][specifier.local.name] = specifier.local.name;
}
})
}
},
CallExpression(path, options) {
const node = path.node;
const callee = node.callee;
if (callee.object && Object.keys(componentInUse['seller-service']).includes(callee.object.name)) {
componentInUse['seller-service'][callee.object.name][callee.property.name] = callee.property.name;
}
}
},
post(state) {
writeFile(cwd(`../../docs/componentInUse.md`), JSON.stringify(componentInUse));
}
}
}
module.exports = ComponentAnalysisInUsePlugin;
複製程式碼
我們需要一個物件componentInUse
儲存seller-common
以及seller-service
的分析結果。此處存在一個問題,我們無法監聽到babel
解析結束事件(暫時沒有找到,如有知道的同學可以評論區告訴我呀,^_^),因為我在post階段會將本次的解析結果寫入檔案,在需要生成文件的地方,再讀取檔案進行分析。
ImportDeclaration
將會對import
進行分析,根據我們在babel.config.js
檔案中配置的libraryName
來過濾出seller-common
以及seller-service
。將分析結果儲存到componentInUse
物件中。CallExpression
將會分析方法的呼叫情況,這裡我們需要獲取到呼叫到了seller-service
類中的哪些方法,將結果儲存到componentInUse
物件中。- 分析結果大致如下所示
{seller-service: {OrderListTypes: {…}, orderService: {…}, OrderByCreateDateDesc: {…}, OrderByShipByDateAsc: {…}, transactionService: {…}, …}
seller-common: {InfiniteScroll: "InfiniteScroll", OrderPaymentInfo: "OrderPaymentInfo", UserViewItem: "UserViewItem", Divider: "Divider", AddressSelect: "AddressSelect", …}
}
複製程式碼
將babel
外掛的分析方法寫好之後,我們需要拉取專案。由於我們是每個業務模組都單獨一個倉庫管理,所以如果要去遍歷每一個group
下有哪些project
,gitlab也給我們提供了api
,我們在build-docs.js
檔案中拉取專案,並且根據babel
的分析結果生成.md檔案。先上程式碼
const { execCommand, readFile, writeFile } = require('../utils/command');
const { cwd } = require('../utils/path-helper');
const fetch = require('node-fetch');
const fs = require('fs');
// 拉取releaseUrl和tagUrl,後面會將XXXX進行替換
const releaseUrl = `https://git.garena.com/api/v4/projects/XXXXX/repository/branches/release`;
const tagUrl = `https://git.garena.com/api/v4/projects/XXXXX/repository/tags`;
const axios = require('axios');
const token = 'XXXXX';(此處不便提供token,只做方法展示)
// 排除不需要分析的模組
const exceptReposReg = new RegExp('seller-center-root|seller-center-vendor|seller-center-cli|root');
const diff = async () => {
// 先刪除component-in-use-doc專案,再重新拉取最新的程式碼
await execCommand('rm -rf component-in-use-doc', { cwd: cwd('../') })();
await execCommand('git clone --single-branch --branch master ssh://gitlab@git.garena.com:2222/shopee/seller-fe/tech/component-in-use-doc.git', { cwd: cwd('../') })();
// 將每次分析的模組拉取到本地,儲存到modules檔案中進行分析
await execCommand('rm -rf modules', { cwd: cwd('../component-in-use-doc') })();
await execCommand('mkdir modules', { cwd: cwd('../component-in-use-doc') })();
await execCommand('rm -rf componentInUse', { cwd: cwd(`../component-in-use-doc/docs`)})();
await execCommand('mkdir componentInUse', { cwd: cwd(`../component-in-use-doc/docs`)})();
// 呼叫介面,https://git.garena.com/api/v4/groups/2443,拉取該groups下所有的project,並且過濾掉不需要分析的模組
const { data } = await axios.get('https://git.garena.com/api/v4/groups/2443', { headers: { 'PRIVATE-TOKEN': token }});
const repos = data.projects.filter(item => !exceptReposReg.test(item.ssh_url_to_repo));
for (const v of repos) {
const releaseUrls = releaseUrl.replace(/XXXXX/, v.id);
const tagsurl = tagUrl.replace(/XXXXX/, v.id);
const releaseRaw = await fetch(releaseUrls, {
headers: {
"PRIVATE-TOKEN": token
}
});
const tagRaw = await fetch(tagsurl, {
headers: {
"PRIVATE-TOKEN": token
}
});
const tagRes = await tagRaw.json();
const releaseRes = await releaseRaw.json();
const lastTagCommitId = tagRes[0] && tagRes[0].commit.id;
const currentCommitId = releaseRes.commit && releaseRes.commit.id;
if (currentCommitId && lastTagCommitId) {
await execCommand(`rm -rf ${v.name}`, { cwd: cwd('../component-in-use-doc/modules') })();
await execCommand(`git clone --single-branch --branch release ${v.ssh_url_to_repo}`, { cwd: cwd('../component-in-use-doc/modules') })();
await execCommand('scc init -b', { cwd: cwd(`../component-in-use-doc/modules/${v.name}`) })();
await execCommand('cid=sg env=test scc build', { cwd: cwd(`../component-in-use-doc/modules/${v.name}`) })();
const componentInUse = await readFile(cwd(`../component-in-use-doc/docs/componentInUse.md`));
let str = '';
for (const libName in JSON.parse(`${componentInUse}`)) {
if (JSON.parse(`${componentInUse}`).hasOwnProperty(libName)) {
const element = JSON.parse(`${componentInUse}`)[libName];
str += '\n**`'+ libName + '`**\n';
for (const component of Object.keys(element)) {
str += `- ${component}\n`;
if (typeof element[component] === 'object') {
const methodObj = element[component];
for (const methodName of Object.keys(methodObj)) {
str += ` - ${methodName}\n`;
}
}
}
}
}
fs.stat(cwd(`../component-in-use-doc/docs/componentInUse/${tagRes[0] && tagRes[0].name}`), async (err, stats) => {
if (err) {
await execCommand(`mkdir ${tagRes[0] && tagRes[0].name}`, { cwd: cwd(`../component-in-use-doc/docs/componentInUse`) })();
}
await execCommand(`mkdir ${v.name}`, { cwd: cwd(`../component-in-use-doc/docs/componentInUse/${tagRes[0] && tagRes[0].name}`) })();
await writeFile(cwd(`../component-in-use-doc/docs/componentInUse/${tagRes[0] && tagRes[0].name}/${v.name}/index.md`), `${str}`);
})
}
}
}
diff();
複製程式碼
vuepress
上述將文件生成好之後,我們就需要藉助vuepress
來幫助我們構建靜態頁面。gitlab
和github
都支援pages
,這裡我們用到了gitlab pages
。
component-in-use-docs
的專案目錄如下
- docs目錄是存放文件的檔案
- modules是存放要分析的模組將不會上傳到
gitlabb
上, .vuepress
檔案用來存放vuepress
配置檔案
那我們先來看一下config.js檔案都如何配置,上程式碼
var fs = require("fs");
var path = require("path");
var rootpath = path.dirname(__dirname);
// 側邊欄
var sidebar = [];
/**
* string比較工具類
*/
var str = {
contains: function(string, substr, isIgnoreCase) {
if (isIgnoreCase) {
string = string.toLowerCase();
substr = substr.toLowerCase();
}
var startChar = substr.substring(0, 1);
var strLen = substr.length;
for (var j = 0; j < string.length - strLen + 1; j++) {
if (string.charAt(j) == startChar) {
//如果匹配起始字元,開始查詢
if (string.substring(j, j + strLen) == substr) {
//如果從j開始的字元與str匹配,那ok
return true;
}
}
}
return false;
}
};
/**
* 檔案助手: 主要用於讀取當前檔案下的所有目錄和檔案
*/
var filehelper = {
getAllFiles: function(rpath) {
let filenames = [];
fs.readdirSync(rpath).forEach(file => {
fullpath = rpath + '/' + file;
var fileinfo = fs.statSync(fullpath);
// 過濾 .DS_Store
if (fileinfo.isFile() && !str.contains(file, "DS_Store", true)) {
if (file === "README.md" || file === "readme.md") {
file = '';
} else {
file = file.replace(".md", '');
}
filenames.push(file);
}
});
filenames.sort();
return filenames;
},
getAllDirs: function getAllDirs(mypath = ".") {
var items = fs.readdirSync(mypath);
// console.log(mypath, items);
let result = [];
// 遍歷當前目錄中所有資料夾
items.map(item => {
let temp = path.join(mypath, item);
// 過濾無關的資料夾
if (fs.statSync(temp).isDirectory() && !item.startsWith(".") && !str.contains(item, "DS_Store", true)) {
let path = mypath + '/' + item + '/';
result.push(path);
result = result.concat(getAllDirs(temp));
diffDirname(path)
}
});
return result;
}
};
// nav的連結路徑
var navLinks = [];
// 導航欄
var nav = getNav();
function genSideBar() {
var allDirs = filehelper.getAllDirs(rootpath);
allDirs.forEach(item => {
var dirname = item.replace(rootpath, '');
navLinks.push(dirname);
});
}
/**
* 查詢parent形成side bar樹
* @param {*} dirname
*/
function diffDirname(path) {
var ss = path.toString().split('/');
var name = ss[ss.length - 2];
var parent = path.replace(name + '/', '');
var parentNameSs = parent.toString().split('/');
var parentName = parentNameSs[parentNameSs.length - 2];
if (name !== 'componentInUse') {
if (filehelper.getAllFiles(path).length >= 1) {
var parentDir = sidebar.find(item => item.title === parentName);
if (!parentDir) {
sidebar.push({
title: parentName,
children: [
{
title: name,
path: path.replace(rootpath, '')
}
]
})
} else {
var index = sidebar.findIndex(item => item.title === parentName);
sidebar[index]['children'].push({
title: name,
path: path.replace(rootpath, '')
})
}
}
}
}
/**
* 先生成所有nav檔案連結;
* @param filepaths
* @returns {Array}
*/
function genNavLink(filepaths) {
genSideBar();
var navLinks = [];
filepaths.forEach(p => {
var ss = p.toString().split('/');
var name = ss[ss.length - 2];
var parent = p.replace(name + '/', '');
navLinks.push({
text: name,
link: p,
items: [],
parent: parent
});
});
return navLinks;
}
/**
* 自定義排序資料夾
* @param a
* @param b
* @returns {number}
*/
function sortDir(a, b) {
let al = a.parent.toString().split('/').length;
let bl = b.parent.toString().split('/').length;
if (al > bl) {
return -1;
}
if (al === bl) {
return 0;
}
if (al < bl) {
return 1;
}
}
/**
* 生成最終的 nav配置資訊
* @param navLinks
* @returns {Array}
*/
function getNav() {
let nnavs = genNavLink(navLinks);
nnavs.sort(sortDir);
var iniMap = {};
var result = [];
var delMap = {};
nnavs.forEach(l => {
iniMap[l.link] = l;
});
nnavs.forEach(l => {
var parentLink = l.parent;
if (parentLink !== '/') {
iniMap[parentLink].items.push(l);
delMap[l.link] = l;
}
});
for (var k in iniMap) {
if (delMap[k] != null) {
delete iniMap[k];
continue;
}
result.push(iniMap[k]);
}
return result;
}
/**
* Vuepress 最終需要的配置資訊, 修改其他資訊在此處配置
*/
var config = {
base: '/seller-fe/tech/seller-component-in-use-doc/',
title: "Component in use analysis tool",
description: "Analysis seller-common component use in seller-center",
lang: "zh-CN",
dest: 'public',
head: [["link", { rel: "icon", href: "/logo.png" }]],
markdown: {
// markdown-it-anchor 的選項
anchor: { permalink: false },
// markdown-it-toc 的選項
toc: { includeLevel: [1, 2, 3] },
},
themeConfig: {
sidebar: {
'/': [
{
title: 'componentInUse',
path: '/',
children: sidebar
}
]
},
nav: nav,
sidebarDepth: 3
}
};
module.exports = config;
複製程式碼
gitlab-ci
參考文章
對於gitlab-ci,我需要在兩個專案中進行配置:seller-center-root
以及component-in-use-docs
這兩個專案的用途分別是:
seller-center-root
是遍歷每一個模組,並利用我們腳手架去對每一個模組進行build
分析生成文件,並觸發component-in-use-docs
。component-in-use-docs
需要將生成的.md檔案生成文件。這個時候需要開啟runner
配置如下:
seller-center-root
image: 這裡需要配上自己的映象
pages:
cache:
key: ${CI_COMMIT_REF_NAME} # CI_COMMIT_REF_NAME for ^9.0, CI_BUILD_REF_NAME for 8
paths:
- node_modules/
stage: deploy
script:
- scc init:docs
- scc build:docs
- cd .. && cd seller-component-in-use-doc
- git config --global user.name "componentInUse"
- git config --global user.email "componentInUse@shopee.com"
- git add . && git commit -m "update docs"
- git push origin master
artifacts:
paths:
- public
only:
- release
複製程式碼
component-in-use-docs
image: 這裡需要配上自己的映象
pages:
cache:
key: ${CI_COMMIT_REF_NAME} # CI_COMMIT_REF_NAME for ^9.0, CI_BUILD_REF_NAME for 8
paths:
- node_modules/
stage: deploy
script:
- yarn install
- yarn build:docs
artifacts:
paths:
- public
only:
- master
複製程式碼
最後
附上文件效果圖
好啦,上述就是我在工作中的一個小結,只是方法思路上的總結,並沒有涉及太多的知識,本人是小白,如有不對的地方,歡迎評論區評論,一起學習交流。