babel plugin結合vuepress分析元件的使用情況並生成文件

小豬仔發表於2020-01-06

背景

最近團隊打算將公用元件庫進行遷移,需要分析元件庫在專案中的使用情況,由於我們是分模組部署,那麼就需要知道每一個模組使用該元件的情況。在生產環境中分析的話,我們就要想到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,並且呼叫了ab方法。那麼我們再來看以下ast的解析結果

import的解析結果

方法的呼叫結果

從上述圖中,我們可以從ImportDeclaration以及CallExpression中著手進行分析


那麼我們就可以進入開發階段了

  • 首先基於團隊提供的腳手架(關於腳手架,此文不做論述)新增一個命令scc build:docs,新增build-docs.js檔案新增babel.config.js檔案以及component-analysis-in-use.js檔案,在plugin中引入我們寫的外掛component-analysis-in-use.js,目前Babel已經支援引入路徑的方式,本次也將採用改方式引入外掛。設定好lib名字,以便分分析(本次要分析兩個包)

babel plugin結合vuepress分析元件的使用情況並生成文件

  • 其次,我們分析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的官方文件

上述將文件生成好之後,我們就需要藉助vuepress來幫助我們構建靜態頁面。gitlabgithub都支援pages,這裡我們用到了gitlab pages

component-in-use-docs的專案目錄如下

babel plugin結合vuepress分析元件的使用情況並生成文件

  • 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

複製程式碼

最後

附上文件效果圖

babel plugin結合vuepress分析元件的使用情況並生成文件

好啦,上述就是我在工作中的一個小結,只是方法思路上的總結,並沒有涉及太多的知識,本人是小白,如有不對的地方,歡迎評論區評論,一起學習交流。

相關文章