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!

相关文章
相关标签/搜索