2020 年,開啓現代庫的基建學習 —— 從項目演進看前端工程化發展

在我的課程 前端開發核心知識進階 的結束語:《大話社區和一個程序員的自我修養》中,我提到了西班牙語裏,有一個很特別的的詞語叫做 “Sobremesa”。它專指「吃完飯後,大家在飯桌上意猶未盡交談的那段短暫而美好時光」。因此在課程最後一節,我不再去講解“很乾很硬”的知識點,相反地,我講述瞭如何保持社區禮儀,積極融入開源世界,並重點突出如何成爲一名開源社區的貢獻者。

這篇文章繼續開源和工程化探索,我想重點來和大家聊一下「現代庫和項目編寫」的話題,相信技術和思維上,對你會有啓發。

庫,不僅是能用

國慶長假已過,2019 年進入最後一個季度,前端技術和解決方案每時每刻在確立着新的格局。「如何寫好一個現代化的開源庫」——這個話題始終很值得討論。當然,這對於初級開發者也許並不簡單。比如,我們要思考:

  • 開源證書如何選擇
  • 庫文檔如何編寫,才能做到讓使用者快速上手
  • TODO 和 CHANGELOG 需要遵循哪些規範,有什麼講究
  • 如何完成一個流暢 0 error, 0 warning 的構建流程
  • 如何確定編譯範圍和實施流程
  • 如何設計合理的模塊化方案
  • 如何打包輸出結果,以適配多種環境
  • 如何設計自動規範化鏈路
  • 如何保證版本規範和 commit 規範
  • 如何進行測試
  • 如何引入可持續集成
  • 如何引入工具使用和配置的最佳實踐
  • 如何設計 APIs 等

這其中的任何一個點都能牽引出前端語言規範和設計、工程化基建等相關知識。比如,讓我們來思考構建和打包過程,如果我是一個庫開發者,我的預期將會是:

  • 我要用 ES Next 優雅地寫庫代碼,因此要通過 Babel 或者 Bublé 進行轉義
  • 我的庫產出結果要能夠運行在瀏覽器和 Node 環境中,我會有自定義的兼容性要求
  • 我的庫產出結果要支持 AMD 或者 CMD 等模塊化方案。因此,對於不同環境,採用的模塊化方案也不同
  • 我的庫產出結果要能夠和 Webpack, Rollup, Gulp 等工具無縫配合

根據這些預期,因此我就要糾結:「到底用 Rollup 對庫進行打包還是 Webpack 進行打包」,「如何真正意義上實現 Tree shaking」,「如何選擇並比較不同的工具」,「如何合理地使用 Babel,如何使用插件」等話題。

所有這些問題,在我們先前的文章:2020 年如何寫一個現代的 JavaScript 庫中,已經有了較爲詳細的講解,我的 課程 中也有更多更細緻的知識和實戰案例。

「寫庫的庫」,設計是一門藝術

不管是從零開始,開發一個應用項目還是開源庫,基建工作都至關重要。接下來,我將會從 Jslib-base 的演進來討論項目的組織和基建設計。

大概在半年多前,我們寫了一個 Jslib-base,旨在從多方面快速幫大家搭建一個標準的 JavaScript 庫。

Jslib-base 最好用的 JavaScript 第三方庫腳手架,賦能 JavaScript 第三方庫開源,讓開發一個 JavaScript 庫更簡單,更專業

沒錯,這是一個“爲了寫庫而寫的庫”。Jslib-base(下簡稱 Jslib) 早期的方式較爲原始,它集成了各種最佳實踐的模版。作爲一個庫開發者,首先需要在 Github 中對項目進行 fork,再通過 Jslib 內置的 npm script 進行自定義的初始化操作。這個初始化過程包括但不限於:

  • 基於模版變量的庫項目名稱替換
  • 基於模版替換的 JaScript/TypeScript 腳手架沙盒環境替換
  • 基於模版的雙語(中英文)README.md,TODO.md,CHANGELOG.md,Doc 等初始化

以重命名項目名爲例:

"scripts": {
    "rename": "node rename.js",
    // ...
  },
  

對應的腳本核心代碼爲(有刪減):

const path = require('path');
const cdkit = require('cdkit');

function getFullPath (filename) {
    return path.join(__dirname, filename)
}

const map = [
    getFullPath('package.json'),
    getFullPath('README.md'),
    getFullPath('config/rollup.js'),
    getFullPath('test/browser/index.html'),
    getFullPath('demo/demo-global.html'),
];

const config = [
    {
        root: '.',
        rules: [
            {
                test: function (pathname) {
                    return map.some(function (u) {
                        return pathname.indexOf(u) > -1;
                    });
                },
                replace: [
                    {
                        from,
                        to,
                    }
                ]
            }
        ]
    },
];

cdkit.run('replace', config);

先前的設計方式基本滿足了庫開發者的初始化需求,通過 fork 項目的方式,可以獲得融合最佳實踐的腳手架代碼集成,接着通過運行 npm 腳本完成腳手架代碼的自定義需求。

我認爲,Jslib 初版的真正意義在於「明確最佳實踐」。比如,我們在論證了:「庫開發使用 Rollup,其他場景(比如應用開發)使用 Webpack」。具體內容可見:2020 年如何寫一個現代的 JavaScript 庫。同時,Jslib 的編譯打包流程也都採用最新的 Babel 版本進行(對於閱讀源碼的讀者來說,這裏面尤其需要注意 Babel 6 到 Babel 7 的核心差異)。同時爲了最大限度考慮兼容性,我們使用了較低版本的 Rollup,當然使用者完全可以自定義配置,整體基建和設計流程如下圖:

image.png

更多細節這裏不再展開,歡迎讀者與我們討論。

請讀者思考:上述內容都是社區上以及我們探索的“最佳實踐”,但是從 Jslib 初版使用方式上來說,我是不完全滿意的,首先:

  • Git fork + clone 的操作成本較高,也相對“野生”
  • 模版 + npm 腳本方式,使得初始化庫腳手架過程較爲“怪異”,這樣造成的後果是出現冗餘代碼
  • 模版 + npm 腳本方式,依賴大量運行時文件操作,不夠黑盒,也不夠簡潔優雅
  • 定製化需求仍有較大提升空間

針對於這些弊端,我給出的解決方案是命令行 + Monorepo 化改造。於是開始了一輪改版,事實上,Jslib 的這次改造是所用現代化工程項目的升級縮影,請讀者繼續閱讀。

命令行技術已經非常簡單

在 NodeJS 發展成熟的今天,命令行編寫已經非常常見了,相關知識社區上介紹也不少,實際上命令行編寫也確實非常簡單,我不在過多介紹。總體來看,新版本的 Jslib 使用方式如下圖:

gif3.gif

image.png

當鍵入簡單命令後,我們就得到了一個完整的庫腳手架運行時:它包括了最佳實踐打包,Babel 配置,測試用例運行,demo 演示和 doc 等,所有的必備環境都已經集成完畢,且可直接運行。甚至包含了庫的 Github banner 內容。沙盒如下圖:

image.png

剩下的只需要使用者直接上手寫代碼了!

當使用者在項目初始化完畢並愉快地進行庫開發後,如果需要更新某些內容,或者替換初始化部分內容,Jslib 提供:jslib update 的命令行能力,它依賴文件拷貝,主要實現了:

  • 模板文件合併
  • json 文件合併
  • 內容替換
  • 刪除文件
  • 升級依賴

等能力。

當然,這並不是我想重點介紹的內容,我打算重點聊一下 Monorepo 及其他技術的應用落地。

現代項目組織的思考

現代項目組織管理代碼的方式主要分爲兩種:

  • Multirepo
  • Monorepo

顧名思義,Multirepo 就是將應用按照模塊分別在不同的倉庫中進行管理;而 Monorepo 就是將應用中所有的模塊一股腦全部放在同一個項目中,這樣一來,所有應用不需要單獨發包、測試,所有代碼都在一個項目中管理,一同部署上線,共享構建以及配置腳本等核心流程,同時在開發階段能夠更早地復現 bug,暴露問題。

這就是項目代碼在組織上的不同哲學:一種倡導分而治之,一種倡導集中管理。究竟是把雞蛋全部放在同一個籃子裏,還是倡導多元化,這就要根據團隊的風格以及面臨的實際場景進行選型。

Babel 和 React 都是典型的 Monorepo,其 issues 和 pull requests 都集中到唯一的項目中,CHANGELOG 可以簡單地從一份 commits 列表梳理出來。我們參看 React 項目倉庫,從目錄結構即可看出其強烈的 Monorepo 風格:

react-16.2.0/
  packages/
    react/
    react-art/
    react-.../

因此,reactreact-dom 代碼在一起,但它們在 npm 上是兩個不同的庫,也就是說,React 和 ReactDom 只不過在 React 項目中通過 Monorepo 的方式進行管理。至於爲什麼 react 和 react-dom 是兩個包,我把這個問題留給讀者。

Jslib 的 Monorepo 化改造

由上述知識,我們體會到 Monorepo 的優勢:

  • 所有項目擁有一致的 lint,以及構建、測試、發佈流程,核心構建環節保持一致
  • 不同項目之間容易調試、協作
  • 方便處理 issues
  • 容易初始化開發環境
  • 易於發現 bugs

那麼 Jslib 爲什麼適合做 Monorepo,我們又是怎麼做的 Monorepo 呢?

使用者在敲入 jslib new mylib 命令時,我們通過交互式命令行或命令行參數,獲取了開發者的設計意圖,其中包括:

  • 項目名稱
  • 發佈 npm 包名稱
  • 作者 Github 賬戶名稱
  • 使用 JavaScript 還是 TypeScript 構建庫
  • 項目庫使用英語還是漢語作爲文檔等內容語言
  • 使用 npm 還是 yarn 維護項目,或者暫時不自動安裝依賴

針對這些信息,我們初始化出整個項目庫腳手架。初始化過程的本質是根據輸入信息進行模版填充。比如,如果開發者選擇了使用 TypeScript 以及英語環境構建項目,那麼核心流程中在初始化 rolluo.config.js 文件時,我們讀取 rollup.js.tmpl,並將相關信息(比如對 TS 的編譯)填寫到模版中。與此類似的情況還有初始化 .eslintrc.ts.json,package.json,CHANGELOG.en.md,README.en.md,doc.en.md 等。所有這些文件的生成過程都需要可插拔,更理想的是,這些插件是一個獨立的運行時。因此我們可以將每一個腳手架文件(即模版文件)的初始化視作一個獨立的應用,由 cli 這個應用統一指揮調度。同時創建 util 應用,用來提供基本函數庫。換句話說,我們把所有模版應用化,充分利用 Monorepo 優勢,支持獨立發包。

最終項目如下組織:

jslib-base/
  packages/
    changelog/
    cli/
    compiler/
    config/
    demo/
    doc/
    eslint/
    license/
    manager/
    readme/
    rollup/
    root/
    src/
    test/
    todo/
    util/
    ...

相關示意圖:

image.png

對應架構大致如下:

image.png

相關核心代碼如下:

const fs = require('fs');
const path = require('path');
const ora = require('ora');
const spinner = ora();

const root = require('@js-lib/root');
const eslint = require('@js-lib/eslint');
const license = require('@js-lib/license');
const package = require('@js-lib/package');
const readme = require('@js-lib/readme');
const src = require('@js-lib/src');
const demo = require('@js-lib/demo');
const rollup = require('@js-lib/rollup');
const test = require('@js-lib/test');
const manager = require('@js-lib/manager');

function init(cmdPath, option) {
    root.init(cmdPath, option.pathname, option);
    package.init(cmdPath, option.pathname, option);
    license.init(cmdPath, option.pathname, option);
    readme.init(cmdPath, option.pathname, option);
    demo.init(cmdPath, option.pathname, option);
    src.init(cmdPath, option.pathname, option);
    eslint.init(cmdPath, option.pathname, option);
    rollup.init(cmdPath, option.pathname, option);
    test.init(cmdPath, option.pathname, option);
    manager.init(cmdPath, option.pathname, option).then(function() {
        spinner.succeed('Create project successfully');
    });
}

我們調用每一個應用提供的 init 方法,該方法接受項目路徑、用戶通過命令行交互產生的初始化參數、其他參數作爲 init 方法參數,init 方法內核心操作是生成相關的腳手架文件並拷貝到使用者項目目錄中。最後一個 manager.init 是根據用戶的 npm/yarn/none 選項自動安裝依賴,這是一個異步方法,manager.init 異步結束後即表明初始化完成,項目搭建完畢。

當版本開發到一定階段,我們可以依靠 Lerna 發佈命令,進行統一發版。如下圖:

image.png

上面提到的 Learn 就是管理 Monorepo 的一個利器,當然也可以結合 yarn workspace 來打造更順滑的流程。這些工具的使用查閱文檔即可,我們不過多介紹。

總的來說,我們會發現 Jslib 就像 Babel 和 Webpack 一樣,爲了適應複雜的定製需求和頻繁的功能變化,都採取了微內核的架構風格。所謂微內核,是指核心代碼倡導 simple 原則,真正功能都是通過插件擴展實現的。如下圖:

image.png

運行流程圖如下:

image.png

詩和遠方,能學可做的還有更多

不同於早期文章 2020 年如何寫一個現代的 JavaScript 庫 着重介紹編寫庫以及各種配置的最佳實踐,這篇文章到此,我們介紹了項目的設計思路和改造過程。接下來,我們如何做的更多更好,或者作爲開發者,如何持續完善一個庫,又如何分析一個優秀庫的源碼,學到更多的知識呢?比如,我提到 yarn workspace 和 lerna 配合構建流程,那麼如何協調兩者的關係呢?

我暫時不回答這個問題,咱們從更基礎更核心的內容看起。

解析一個庫基建

我以一個「開發 React 組件庫」輪子的場景爲例來繼續這個話題。大家應該很熟悉 ant-design,react-bootstrap 等 React 組件庫相對成熟方案。我的意圖顯然不是教大家如何使用 HoC,render prop 甚至 hooks 模式來實現組件複用,編寫公共輪子,我更想介紹這些輪子項目組織管理以及構建設計的一個更好的思路。

Ant-design 的 components 目錄下存在了 50 個以上文件(沒有細數),各個組件之間必定也存在着相互引用。如果這些組件彼此獨立,具備單獨發版的能力(使用者可以單獨 install XXComponent),同時保留所有組件一起發版的特性,這無疑是一個比較不錯的嘗試。同時作爲這些庫開發者,在調試時,也會享受到更大的便利。一切改造方式都指向了 Monorepo 化,沒錯,這樣的訴求比 Jslib 還要適合 Monorepo。

當然這種更現代化的組織方式早已經被應用了。不過很遺憾,ant-design 並沒有使用這樣的設計,但讀者依然可以在 ant-design 中學習組件的封裝,而在 reach-ui 中學習項目的基建和組織。我認爲 reach-ui 這個相對小衆的開源作品在這方面的設計表現更加出色,如下圖,及標註:

image.png

我們通過代碼來進一步學習,選取 alert 這個組件(目錄 reach-ui/packages/alert/package.json)中,我們看到:

"scripts": {
    "build": "node ../../shared/build-package",
    "lint": "eslint . --max-warnings=0"
},

在其他組件的 package.json 文件中,也會有同樣的內容,這就是“共享構建腳本”。而 build-package 內容很簡單:

const execSync = require("child_process").execSync;
const path = require("path");

let babel = path.resolve(__dirname, "../node_modules/.bin/babel");

const exec = (command, extraEnv) =>
  execSync(command, {
    env: Object.assign({}, process.env, extraEnv),
    stdio: "inherit"
  });

console.log("\nBuilding ES modules ...");
exec(`${babel} src -d es --ignore src/*.test.js --root-mode upward`, {
  MODULE_FORMAT: "esm"
});

console.log("Building CommonJS modules ...");
exec(`${babel} src -d . --ignore src/*.test.js --root-mode upward`, {
  MODULE_FORMAT: "cjs"
});

該庫會導出兩種模塊化方式:esm 和 cjs,以供不同環境的使用。

而項目根目錄中,package.json 有這樣的內容:

"scripts": {
    "build:changed": "lerna run build --parallel --since origin/master",
    "build": "lerna run build --parallel",
    "release": "lerna run test --since origin/master && yarn build:changed && lerna publish --since origin/master",
    "lint": "lerna run lint"
  },

通過 lerna run build 就可以運行所有 packages 內的組件包的 build 命令,達到同時構建所有組件的目的。

在項目根目錄 lerna.json 中,有這樣的內容:

{
  "version": "independent",
  // ...
}

我們看到,version 選用的 independent 模式,這樣模塊發佈新版本時,會逐個詢問需要升級的版本號,基準版本爲自身的 package.json,這樣就使得每一個組件包都能保持獨立的版本號。

這個項目是我觀察過的所有組件庫輪子類項目中,基建做的最好的之一了(我個人主觀認爲,只是我的審美和認知,不代表客觀立場),推薦給大家學習。對 reach-ui 更加細緻的解讀,或更多相關內容(比如完整構建一個 UI 輪子,文檔的自動化建設,組件封裝等知識點),我將會在後續我的課程或文章中進行更新,希望這篇文章可以做到拋磚引玉的作用。

解析一個庫腳本

前面我們分析了 reach-ui 中的 build-package 文件。事實上,npm 腳本在一個項目中起到的作用至關重要。它是一個項目的核心流程。

當從零開始做的項目越來越多時,我們會發現 npm 腳本有一定的共性:也許項目 A 和項目 B 的 lint 腳本類似;項目 B 和項目 C 的 pre-commit 腳本也差不多。這樣的話,有心的開發者可能就會想創造一個自己的“腳本世界”。在啓動項目 D 時候,直接依賴已有的腳本並加入需要自定義的行爲即可。同時,我們把腳本收斂抽象,也方便大家學習、掌握。

比如,我習慣使用 Jest 進行單元測試,那麼 Jest 相關的 npm 腳本可以進行抽象,在新的項目 package.json 中引入:

"scripts": {
    "test": "lucas-script --test",
    // ...

相關腳本 lucas-script 抽象爲(代碼出自 kentcdodds/kcd-scripts,這裏僅供參考):

process.env.BABEL_ENV = 'test'
process.env.NODE_ENV = 'test'

const isCI = require('is-ci')
const {hasPkgProp, parseEnv, hasFile} = require('../utils')

const args = process.argv.slice(2)

const watch =
  !isCI &&
  !parseEnv('SCRIPTS_PRE-COMMIT', false) &&
  !args.includes('--no-watch') &&
  !args.includes('--coverage') &&
  !args.includes('--updateSnapshot')
    ? ['--watch']
    : []

const config =
  !args.includes('--config') &&
  !hasFile('jest.config.js') &&
  !hasPkgProp('jest')
    ? ['--config', JSON.stringify(require('../config/jest.config'))]
    : []

// eslint-disable-next-line jest/no-jest-import
require('jest').run([...config, ...watch, ...args])

這段腳本抽象與項目業務之外,代碼卻相當簡單。它會在當前的測試流程中,賦值相應的環境變量,判斷 Jest 的運行是否需要進行監聽(watch 參數),同時獲取 Jest 配置,並最終運行 Jest。

再比如,使用 travis 進行持續集成,成功結束時的操作可以抽象:

const spawn = require('cross-spawn')
const {
  resolveBin,
  getConcurrentlyArgs,
  hasFile,
  pkg,
  parseEnv,
} = require('../utils')

console.log('installing and running travis-deploy-once')

const deployOnceResults = spawn.sync('npx', ['travis-deploy-once@5'], {
  stdio: 'inherit',
})

if (deployOnceResults.status === 0) {
  runAfterSuccessScripts()
} else {
  console.log(
    'travis-deploy-once exited with a non-zero exit code',
    deployOnceResults.status,
  )
  process.exit(deployOnceResults.status)
}

// eslint-disable-next-line complexity
function runAfterSuccessScripts() {
  const autorelease =
    pkg.version === '0.0.0-semantically-released' &&
    parseEnv('TRAVIS', false) &&
    process.env.TRAVIS_BRANCH === 'master' &&
    !parseEnv('TRAVIS_PULL_REQUEST', false)

  const reportCoverage = hasFile('coverage') && !parseEnv('SKIP_CODECOV', false)

  if (!autorelease && !reportCoverage) {
    console.log(
      'No need to autorelease or report coverage. Skipping travis-after-success script...',
    )
  } else {
    const result = spawn.sync(
      resolveBin('concurrently'),
      getConcurrentlyArgs(
        {
          codecov: reportCoverage
            ? `echo installing codecov && npx -p codecov@3 -c 'echo running codecov && codecov'`
            : null,
          release: autorelease
            ? `echo installing semantic-release && npx -p semantic-release@15 -c 'echo running semantic-release && Unlike react-scripts, kcd-scriptse'`
            : null,
        },
        {killOthers: false},
      ),
      {stdio: 'inherit'},
    )

    process.exit(result.status)
  }
}

這段代碼判斷在持續集成階段結束後,是否需要自動發版或進行測試覆蓋率報告。如果需要,分別使用 semantic-releasecodecov 進行相關操作。

使用起來:

"scripts": {
    "after-release": "lucas-script --release",
    // ...

最後,不管是 react-scripts 還是 lucas-scripts,還是其他各種 xxx-scripts,這些基建工具類腳本都一定會支持使用者自定義配置。但是不同於 Create React App 的 react-scripts 的方案 (具體 Create React App 的方案,有時間我會單獨解析),我認爲腳本的設計更應該開放,xxx-scripts 除了應該 just work,也需要向外暴露出默認配置,以供開發者 overriding。

這一點在 Babel 和 Webpack 插件體系以及 Eslint 的配置上體現的尤爲突出。以 Eslint 配置爲例,一個理想的設計方案是開發者可以在自定義的 .eslintrc 文件中加入:

{"extends": "./node_modules/lucas-scripts/eslint.js"}

這樣一行代碼即可和默認 lint 進行結合。同樣的設計體現在 Babel 配置上,我們只需要:

{"presets": ["lucas-scripts/babel"]}

即可,對應的 Jest 配置:

const {jest: jestConfig} = require('lucas-scripts/config')

module.exports = Object.assign(jestConfig, {
  // your overrides here

  // for test written in Typescript, add:
  transform: {
    '\\.(ts|tsx)$': '<rootDir>/node_modules/ts-jest/preprocessor.js',
  },
})

當然我封裝了更多腳本,以及更多工程化方面相關的 util 函數,感興趣或想進行了解、學習的讀者可以關注我的後續課程。如果你想從基礎做起,進行進階提高,文章開頭處也有我的已上線課程介紹。

總結

這篇文章反覆提到的 Jslib 可以幫助開發者通過簡單的命令,創建出一個庫的運行時 just work 的腳手架和基礎代碼。如果你想寫一個庫,那我建議你考慮使用它來開啓第一步。但我無意“推銷”這個作品,真正重要的是,如果你想了解如何從零設計一個項目,也許可以通過它收穫啓發。

這篇文章我們從一個「創建庫的庫」,聊到現代前端開發的一些最佳實踐,聊到 Monorepo 組織項目,又聊到 npm 腳本構建流程。一個應用項目或一個庫的基建工作涉及到方方面面,本文中很多細節都值得深入分析,後續我們將會產出更多內容,歡迎一起討論學習。

分享交流

我的課程:前端開發核心知識進階

移動端點擊瞭解更多:

移動端點擊瞭解更多《前端開發核心知識進階

Happy coding!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章