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!

阅读 475发布于 10月10日
推荐阅读
前端杂谈
用户专栏

关于前端的一些积累

85 人关注
44 篇文章
专栏主页
目录