提高前端工程構建效率-buildflow

1 原由

考拉有不少node工程,其中客戶端代碼client/和基於egg的服務端server/混合在一塊兒。因爲歷史遺留問題,大部分client下都會有多套構建腳本。好比我負責的工程就包含:
1. client/pc(webpack2)
2. client/wap(webpack2)
3. client/wap-vue(webpack4)
4. ssrClient(vue-cli)html

居然有四套構建 (キ`゚Д゚´) 再加上對應的npm install,以及服務端的server/也須要install,最終致使構建時間會很長vue

爲了解決這個問題,提高構建效率,我作了@kaola/buildflow,經過多線程並行構建,按需構建(緩存npm installnpm run build的構建結果),大幅提高了構建速度node

本文主要介紹了buildflow的解決方案、實現思路,以及一些有趣的技術細節webpack

2 原理和實現

↓流程示意圖↓git

buildflow流程示意圖.png

2.1 多線程執行任務

將構建的各類命令合理分配到不一樣線程中,將整體打包效率最大化es6

2.1.1 設計易於維護和理解的api

建立一個可執行任務github

flow.exec('install wap packages', {
    cmd: 'npm i --prefix=./client/wap',
    cwd: './'
})
複製代碼
Copy

如何串行執行web

flow.exec('install wap packages', { cmd: 'npm i --prefix=./client/wap' })
    .exec('build wap packages', { cmd: 'npm run build --prefix=./client/wap' })
複製代碼
Copy

如何多線程並行執行vue-cli

flow.fork([
    flow.exec(...), // 任務1
    flow.exec(...), // 任務2
])
複製代碼
Copy

並行任務之間能夠進行嵌套npm

flow.fork([
    flow.fork([
        flow.exec(...), // 任務1
        flow.exec(...), // 任務2
    ])
        .exec(...), // 任務3,

    flow.exec(...), // 任務4
        .exec(...), // 任務5
])
複製代碼
Copy

如上僞代碼執行順序,就是1/2/4同時開始執行,當1/2執行結束後3再開始執行,當4執行結束後5開始執行

2.1.2 最大線程數

在運行時,若是不限制線程數,可能會併發執行大量任務,致使機器卡頓,反而會拖累總體構建速度。通常在構建機上執行還好(幾十個cpu內核起步),但在本地執行時容易出這種問題

所以,使用線程池來控制最大線程數,數量由cpu虛擬內核數來決定更加合理

// CPU邏輯內核,決定最大併發任務數量
const cpus = require('os').cpus();
const cpuNumber = cpus.length;
複製代碼
Copy

一個例子,部分代碼以下

完整版見:

Edit cpu-pool

const CpuPool = require("./cpu-pool");
const sleep = require("./sleep");

const pool = new CpuPool();

// 一個任務
async function exec() {
  await pool.occupy(); // 佔用1個cpu邏輯內核
  await sleep(1000 * 10 * Math.random()); // 休眠0-10s,模擬執行效果
  await pool.release(); // 釋放佔用
}

let count = 0,
  max = 20;
while (count <= max) {
  exec();
  count++;
}

/*
假設 cpu 虛擬內核爲 12
並行執行20個任務
並行執行12個任務(執行時間0-10s),剩餘8個任務隊列中等待
先執行完畢的任務會釋放cpu,隊列的等待的任務進入執行
*/
複製代碼
Copy

2.1.3 執行

執行使用node的child_process模塊

const spawn = require('child_process').spawn;
function run(first, params, options = {}) {
    return new Promise((resolve) => {
        const p = spawn(first, params, options);
        let stdout = '', stderr = '', output = '';
        p.stdout.on('data', (data) => {
            const str = data.toString();
            stdout += str;
            output += str;
        });
        p.stderr.on('data', (data) => {
            const str = data.toString();
            stderr += str;
            output += str;
        });
        p.on('close', (code) => {
            resolve({ stdout, stderr, output, code });
        });
    });
}

// 執行
run('npm', ['run', 'build'], { cwd: './client/wap' })
    .then({ stdout } => stdout);
複製代碼
Copy

2.1.3 日誌

使用debug包對日誌進行分級,如:error、warn、info、debug

經過設置process.env.DEBUG參數,能夠選擇輸出的日誌類型

2.1.4 某次構建耗時過久

在webpack的一次構建中,若是耗時太長,能夠考慮拆分構建的頁面:

  1. 除了各類優化措施以外,webpack還推薦使用 parallel-webpackcache-loader 來對單次構建使用多線程能力,以及在不一樣線程中共享緩存(我還沒用過┭┮﹏┭┮)
  2. 也能夠只構建變動的頁面,buildflow的緩存能力能夠將構建結果與上一次構建的結果合併

2.2 緩存構建結果

測試環境可能會頻繁構建。若是隻修改了服務端代碼,卻仍然要總體構建,顯然是比較浪費的

所以,思路就是將前一次構建的結果進行緩存,並在下一次構建時,只對變更的部分執行構建命令,最後將兩次的構建結果合併

2.2.1 如何判斷哪些變更,並執行對應的日誌

將上一次構建的 commitIdHEADgit diff 操做。我使用了 simple-git

const { root, joinRoot } = require('../lib/path');
const git = require('simple-git/promise')(rootPath);

async function getChangedFiles(lastCommitId, currCommitId = 'HEAD') { // 獲取指定commit id之間變動的文件,默認對比HEAD
    assert.ok(typeof lastCommitId === 'string');
    const changeFiles = (await git.diffSummary([`${lastCommitId}...${currCommitId}`]))
        .files.map(f => f.file)
        .filter(p => p)
        .map(filepath => joinRoot(filepath));
    return changeFiles;
}
複製代碼
Copy

肯定修改的文件後,將文件路徑與npm run build的路徑進行比對,若是文件在該路徑下,則執行對應的構建操做。

2.2.2 如何緩存

緩存放在什麼地方?

最先在網易的時候,跟運維商量以後,決定將緩存文件放在構建機的特定目錄,貌似會進行按期清理

而如今由於構建機有不少臺,這種方案顯然再也不適用,改成上傳到oss遠程存儲的方式存放緩存數據。
下載解壓+壓縮上傳一個20M+的一個壓縮包,大概總共須要10~15s,這個要算到使用緩存功能的時間成本中

另外須要考慮到:版本管理、按期清理、安全問題。
尤爲是安全方面須要特別留意,oss bucket不能讓無關的用戶訪問到,Bucket ACL須要設置爲私有(上傳,下載均須要AK鑑權)。
每次構建結束,將緩存的內容tar+gzip壓縮後,上傳到oss私有桶,oss中的路徑包含:工程名、分支、時間,便於維護和按期清理,到下一次構建開始時,先嚐試將緩存的構建結果拉倒本地進行解壓

2.2.3 流

爲了更快速的壓縮&上傳、下載&解壓文件,須要用到stream流來輔助操做

好比下載&解壓文件,你須要先下載,而後再解壓。而若是使用流,就能作到一邊下載,一邊解壓,效率提高了不少

我使用了 compressingpump,再結合 oss node客戶端 進行的流操做,文件被壓縮爲*.tgz格式

2.2.4 中間件

爲了應用緩存能力,我使用了中間件的設計思路。上面提到 flow.exec 會生成一個安裝或構件任務,那麼中間件就會加載到這一任務中,在這個任務開始前和結束後執行。當有多箇中間件同時生效時,使用koa洋蔥模型嵌套執行

中間件的實現方式很簡單,基本原理以下

sandbox:
Edit middleware onion

// 第一個中間件
const mid1 = async (next, props = {}) => {
  console.log(`mid1 before, name: ${props.name}`);
  await next();
  console.log(`mid1 after, name: ${props.name}`);
};
// 另外一箇中間件
const mid2 = async (next, props = {}) => {
  console.log(`mid2 before, name: ${props.name}`);
  await next();
  console.log(`mid2 after, name: ${props.name}`);
};
// 任務函數
const exec = async (props = {}) => {
  console.log(`run exec, name: ${props.name}`);
};

// 添加了中間件的任務函數
const execNew = async (props = {}) => {
    const ms = [
        mid1, // 將中間件放到數組的前兩位,執行時會符合koa洋蔥模型
        mid2,
        async(next, props = {}) => { // 封裝後的任務函數
            await exec(props);
        },
    ];
    const next = async() => {
        const m = ms.shift(); // 每次執行next,都會從頭部導出數組的一項
        await m(next, { name });
    };
    await next(); // 開始執行
};

/*

執行結果:
> mid1 before, name: tian
> mid2 before, name: tian
> run exec, name: tian
> mid2 after, name: tian
> mid1 after, name: tian

*/
複製代碼
Copy

2.2.5 api如何設計

oss相關參數很好處理

flow
    .cache({
        // oss緩存參數
        oss: {...}
    })
});
複製代碼
Copy

中間件的定義,以及哪些exec任務須要加載中間件

方法一:全局中間件

// 全局應用中間件(具體節點也能夠單獨設置,會合並)
//  - 只針對: flow.exec、flow.check
//  - 寫在開頭,能夠應用到全局
flow.use({
    'git-change': { // 中間件名稱
        // 篩選須要應用中間件的節點,名字以 build 開頭的任務
        test: ({ name }) => /^\s*build\s*$/.test(name),
        // 傳入中間件的參數
        option: ({ cwd = '' }) => {
            return { watch: cwd }; // watch 目錄的代碼修改後,須要從新構建,不然使用緩存
        }
    }
});
複製代碼
Copy

方法二:針對某個任務使用中間件

flow.exec('build wap', {
    cmd: 'npm run build --prefix=./client/wap',
    use: [ 'git-change?watch=./client/wap' ] //
});
複製代碼
Copy

2.2.6 緩存install結果

npm i操做也會消耗必定的時間,使用緩存後也有必定提高空間

具體策略是將package.jsonpackage-lock.json文件 md5 轉換爲字符串,並與對應的node_modules/一塊兒緩存。
在新一次構建中,若是新計算的md5值未發生修改,則直接使用上一次安裝的node_modules/

md5操做使用了crypto

2.3 其餘功能

2.3.1 clean

刪除文件/目錄,通常用於在開始構建前,剔除不參與構建(緩存)的內容

flow.clean([
    './compressed',
    './server/app/public'
])
複製代碼
Copy

2.3.2 check

對打包結果進行校驗,提早避免一部分問題,好比es6語法、xss風險(目前支持了art-template模板)等

flow // 構建結果檢查
    .check('check bundle file', {
        es5check: { // 檢查1 - 指定目錄不該該包含es6的語法
            path: require.resolve('@kaola/buildflow.check.es6'), // 外部導入
            pattern: ['./server/app/public/**/*.js'], // 須要檢查的列表
            notCheckEval: false, // 是否檢查eval語法中的代碼
        },
        xss: {/* ... */} // 檢查2 - 模板中是否存在xss風險
    });
複製代碼
Copy

2.3.3 compress

輸出構建結果到指定目錄,使用 globby

compress
    // 將哪些數據輸出
    .from({
        cwd: './',
        include: ['@(dist)', 'server/*'],
        exclude: []
    })
    // 輸出到的目錄
    .to('./compressed');
複製代碼
Copy

2.4 API解析

如何將易於閱讀的api,解析爲適合工程運行的數據結構?

假設用戶輸入的api以下

flow.node('total') // 僅用於打日誌,用於分割和標記時間
    .clean(['./server/app/public']) // 刪除目錄
    .fork([ // 啓用併發
        flow.exec('install wap', { cwd: 'npm i --prefix=./client/wap' }) // 任務1
            .exec('build wap', { cwd: 'npm run build --prefix=./client/wap' }), // 任務2(依賴任務1)

        flow.exec('install server', { cwd: 'npm i --prefix=./server' }), // 任務3,與任務1併發執行
    ])
    .check('check es6 bundle', { // 對構建結果進行校驗
        checkES6Bundle: {
            path: require.resolve('@kaola/buildflow.check.es6'), // 引用外部模塊
            pattern: ['./server/app/public/**/*.js'], // 須要檢查的文件
        }
    })
複製代碼
Copy

爲了便於程序運行,api最終會被解析爲一個數組以下:

[
  { "id": 0, "name": "total", "type": "node", "parent": [] },
  {
    "id": 1, "name": "clean", "type": "clean", "parent": [0],
    "dirs": ["./server/app/public"]
  },
  {
    "id": 2, "name": "install wap", "type": "exec", "parent": [1],
    "use": [],
    "exec": { "cmd": "npm i", "options": { "cwd": "./client/wap" } }
  },
  {
    "id": 3, "name": "build wap", "type": "exec", "parent": [2],
    "use": [],
    "exec": { "cmd": "npm run build", "options": { "cwd": "./client/wap" } }
  },
  {
    "id": 4, "name": "install server", "type": "exec", "parent": [1],
    "use": [],
    "exec": { "cmd": "npm i", "options": { "cwd": "./server" } }
  },
  {
    "id":5, "name":"check es6 bundle", "type":"check", "parent":[3, 4],
    "rules":[
      {
        "path":"./node_modules/@kaola/buildflow.check.es6/src/index.js",
        "pattern":["./server/app/public/**/*.js"],
        "type":"es5check"
      }
    ]
  }
]
複製代碼
Copy

除了表示併發的flow以外,其他的API(node,clean,exec,check)都會被解析爲數組中的一項,而且每一種類型都會有對應的處理函數(onNode,onClean,onExec,onCheck)負責執行

參數id,parent用來標記各個節點之間的關係,其中parent爲數組,由於一個節點可能同時依賴多個節點,好比最後的check節點,它依賴於並行執行的2個任務(任務二、任務3)

真正執行的順序是這樣的,先取數組的第一個節點(id爲0),執行第一個節點(onNode函數)完畢後,將執行完畢的節點的id放到一個數組中(如 dealIds 爲 [0]),再檢查有哪些節點的parent已經執行完畢(包含在dealIds中),此時獲得id爲1的節點clean,繼續執行onClean,執行完畢後將對的id放到dealIds中(此時爲[0, 1]),繼續檢查parent包含[0, 1]的節點,由此依次執行下去,直到所有節點執行完畢爲止

3 總結

  1. 多線程併發構建,適用於一個工程中有多個與構建相關命令的場景
  2. 緩存功能,主要解決了修改不多一部分代碼,卻要全量構建的問題。但依賴於構建腳本自己,可以支持針對改動部分的代碼單獨進行構建
  3. 上文還分享了開發過程當中的一些知識點和設計思路,但願對你們有必定啓發意義
相關文章
相關標籤/搜索