这篇文章想重点来和大家聊一下「现代库编写」的话题,相信技术和思维上,对你会有启发。
根据这些预期,因此我就要纠结:「到底用 Rollup 对库进行打包还是 Webpack 进行打包」,「如何真正意义上实现 Tree shaking」,「如何选择并比较不同的工具」,「如何合理地使用 Babel,如何使用插件」等话题。
Jslib-base 最好用的 JavaScript 第三方库脚手架,赋能 JavaScript 第三方库开源,让开发一个 JavaScript 库更简单,更专业
"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,当然使用者完全可以自定义配置,整体基建和设计流程如下图:
更多细节这里不再展开,欢迎读者与我们讨论。
请读者思考:上述内容都是社区上以及我们探索的“最佳实践”,但是从 Jslib 初版使用方式上来说,我是不完全满意的,首先:
Git fork + clone 的操作成本较高,也相对“野生”
模版 + npm 脚本方式,使得初始化库脚手架过程较为“怪异”,这样造成的后果是出现冗余代码
模版 + npm 脚本方式,依赖大量运行时文件操作,不够黑盒,也不够简洁优雅
定制化需求仍有较大提升空间
在 NodeJS 发展成熟的今天,命令行编写已经非常常见了,相关知识社区上介绍也不少,实际上命令行编写也确实非常简单,我不在过多介绍。总体来看,新版本的 Jslib 使用方式如下图:
当键入简单命令后,我们就得到了一个完整的库脚手架运行时:它包括了最佳实践打包,Babel 配置,测试用例运行,demo 演示和 doc 等,所有的必备环境都已经集成完毕,且可直接运行。甚至包含了库的 Github banner 内容。沙盒如下图:
剩下的只需要使用者直接上手写代码了!
jslib update
的命令行能力,它依赖文件拷贝,主要实现了:
当然,这并不是我想重点介绍的内容,我打算重点聊一下 Monorepo 及其他技术的应用落地。
react-16.2.0/
packages/
react/
react-art/
react-.../
因此,react 和 react-dom 代码在一起,但它们在 npm 上是两个不同的库,也就是说,React 和 ReactDom 只不过在 React 项目中通过 Monorepo 的方式进行管理。至于为什么 react 和 react-dom 是两个包,我把这个问题留给读者。
所有项目拥有一致的 lint,以及构建、测试、发布流程,核心构建环节保持一致
不同项目之间容易调试、协作
方便处理 issues
容易初始化开发环境
易于发现 bugs
使用者在敲入 jslib new mylib
命令时,我们通过交互式命令行或命令行参数,获取了开发者的设计意图,其中包括:
针对这些信息,我们初始化出整个项目库脚手架。初始化过程的本质是根据输入信息进行模版填充。比如,如果开发者选择了使用 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/
...
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');
});
}
我暂时不回答这个问题,咱们从更基础更核心的内容看起。
"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("
Building 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",
// ...
}
这个项目是我观察过的所有组件库轮子类项目中,基建做的最好的之一了(我个人主观认为,只是我的审美和认知,不代表客观立场),推荐给大家学习。对 reach-ui 更加细致的解读,或更多相关内容(比如完整构建一个 UI 轮子,文档的自动化建设,组件封装等知识点),我将会在后续我的课程或文章中进行更新,希望这篇文章可以做到抛砖引玉的作用。
"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-release 和 codecov 进行相关操作。
使用起来:
"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。
.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)$': '/node_modules/ts-jest/preprocessor.js',
},
})
这篇文章反复提到的 Jslib 可以帮助开发者通过简单的命令,创建出一个库的运行时 just work 的脚手架和基础代码。如果你想写一个库,那我建议你考虑使用它来开启第一步。但我无意“推销”这个作品,真正重要的是,如果你想了解如何从零设计一个项目,也许可以通过它收获启发。
这篇文章我们从一个「创建库的库」,聊到现代前端开发的一些最佳实践,聊到 Monorepo 组织项目,又聊到 npm 脚本构建流程。一个应用项目或一个库的基建工作涉及到方方面面,本文中很多细节都值得深入分析,后续我们将会产出更多内容,欢迎一起讨论学习。
同时,欢迎大家扫码了解我的专栏《前端开发核心知识进阶》