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

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

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

庫,不只是能用

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

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

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

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

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

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

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

無論是從零開始,開發一個應用項目仍是開源庫,基建工做都相當重要。接下來,我將會從 Jslib-base 的演進來討論項目的組織和基建設計。github

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

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

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

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

以重命名項目名爲例:bootstrap

"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!

相關文章
相關標籤/搜索