monorepo 创建属于你的js/ts库

2,392 阅读12分钟

引言

  • 前端基建工程化"横行",专注于业务代码得不到提升,是不是觉得空虚觉得寂寞觉得冷?

  • 很多人想写个自己的库,但是没有方法,光有技术却得不到施展,总是在构建上遇到阻碍。

  • 每次构建的时候发现流行的库都用上了高级的构建技巧,自己还在用vue-cli的 vue-cli-service 通用打包,看起来不太高大上?(这里没有踩vue-cli的意思,vue-cli 封装了所有类型资源的打包规则,一键打包其实非常方便了,一般的库和组件都不需要二次定制,特别是现在的vite2.0内置的打包配置也足够开箱即用了,非常方便)

这篇文章旨在分享如何创建monorepo/packages 项目管理方式,基于rollup打包ts代码生成dts声明文件,采用自定义脚本一键支持打包,最小化更改(rename)即可挪为已用。目前已经配置好了基础打包和代码引入,如无特殊需求,更名后即可食用(后期可能会写个脚手架, ts-monorepo-starter什么的)。

转载声明

本文以分享为目的的文章,不存在任何利益行为。署名内容来源本文,或结尾声明参考文章链接即可任意使用本文内容,包括转载和复制修改文章任何内容,无需告知我。

yarn workspace

yarn workspace 是一个全新的代码组织方式,允许你使用一行代码yarn install安装处理所有packages的依赖,并便捷开发项目

其实早在yarn 1.0 就支持了workspaces,因为各大仓库都先后去拥抱了workspaces,比如react, babel,现在的vue3.0,后面重构的vite2等,从实践上证明了yarn workspaces的优秀。

使用yarn workspace 的好处

当然,一键yarn install 只是表面上的好处;内在的好处大致以下3点:

  • 你的所有的库相互之间会被SymbolLink到一起,举个例子,vite2.0没重构使用workspace之前,调试的时候需要单独在vite的rootdir下使用 yarn link创建一个全局的 vite Symbollink, 然后在playground里,再yarn link vite ,就可以使用vite binary anywhere,使用了workspace之后,你可以直接在packages下创建一个private: true的调试项目,依赖里写入vite,yarn install就可直接创建SymbolLink非常方便。而且,每个包都是相互link。
  • 每个包安装的依赖都会安装到rootdir的node_modules目录里,这样可以让yarn自动的优化这些包,处理版本问题和依赖问题。
  • 使用一个lockfile即可控制版本,通用的库可以yarn add -W ** 安装的workspace公共区域,私有的库可以yarn workspace project-1 add package -D往packages/project-1项目里安装依赖package。那么公用的库可以保持版本一致,私有的库可以各自安装互不影响,完美。

记得第一次调试vuetify的时候不知道有workspace的概念,更不知道lerna这个东西,doc里安装依赖,组件库里安装一下,简直头都要炸了,现在回想,真特么傻了。 这样的结构方便找代码,依赖清晰,库之间引用清晰,香。

快速创建workspace

packages
|--project1
|  |--package.json
|--project2
|  |--package.json
package.json

首先们创建一个这样的目录,使用yarn init -y快速的创建。

  • rootdir的package.json 里设置 private: true,并设置你的package目录

因为跟目录的package.json 只是用来管理依赖的,所以其他信息已经没有用了,可以随意删除。

{
+ "private": true,
- "name": "rootdir",
  "main": "index.js",
+ "workspaces": [
+   "packages/*"
+  ]
}

worksapces/packages 下存放的就是我们要开发的库,如上的project1project2

就这么简单,当你在命令行敲下yarnyarn install的时候,包之间的依赖就创建成功了。剩下的只需要按

yarn workspace <workspace_name> <command>yarn add <package-name> <command> -W这样就可以安装库或者全局公用的依赖了。

更多的workspace相关的命令行就不在这里赘述了,甚至于你使用lerna也可以,对后面没有任何影响。

rollup

作为打包工具,目前最流行的依旧是webpack 和 rollup。webpack作为老牌打包工具,从早玩到晚,那肯定腻了,我采用的是rollup,一是因为rollup配置非常简单,二是rollup打包下来gzip后总能比webpack小那么一丢丢,也是很神奇。另外一个不算原因的原因是此处的playground采用尤雨溪新开发的vite用来起调试服务,那就和vite使用相同的打包工具就是(vite重构后已经使用esbuild处理代码,使用了rollup-like的api,兼容一些rollup插件,我特么好家伙,这是要抢webpack的饭碗,也要抢rollup的筷子)。

此次配置要达到的目的

  • 支持所有库一键打包各自需要支持的平台(umd, esm, iiff, cjs...)
  • 支持单个库监听修改并编译文件
  • 支持生成dts文件,支持生成ts type doc
  • 支持打包脚本无痛转移,今后只需要复制代码就可以用

不会详细介绍的内容: eslint、prettier代码格式化,单元测试,ts type check,与流程内无关的命令行等,保证需要这部分内容的同学能快速获取,其它内容全网已经有很多教程就不赘述了。

接下来是根据需要,一步一步的介绍如果编写打包和调试脚本。

step-1 库的结构

在rootdir里,我们的workspaces里写的是匿名写法,workspaces: ["packages/*"],那么,packages下的文件名就是我们的库的名称,所以,文件名会十分重要。

文件名和库名保持一致,与packages/*/package.json 里的name保持一致,这样脚本在打包的时候才能一致的输入正确的 bundle文件,当然,也很推荐带 npm scope 的命名写法,防止发布包的时候冲突又要重新起名,代码两分钟,起名两小时(package.json 里的name 采用 @scope/lib-name这样的写法,如:@rollup/rollup-typescript)

image.png

image.png

为了合理有效的支持全量引入、esm tree-shaking ,src目录下有个index.js/ts 文件导入导出所有,并作为打包的入口文件,那么结构上就没有问题了。

step-2 通用的rollup.config.js

rollup和webpack都一样,保证input,确定output,中间plugin处理代码和资源。

拿到input

首先,我们的库都是放在packages目录下,需要传入打包的目标,就交给环境变量TARGET来处理,于是:

import path from 'path'

const pkgsDir = path.resolve(__dirname, 'packages')
const pkgDir = path.resolve(pkgsDir, process.env.TARGET) // TARGET 我们将在脚本里提供

由于我们的rollup是由脚本启动,类似nodejs 执行 rollup.rollup() 那么,rollup在不知道bsaeUrl的时候,我们需要给rollup传入完整的文件路径,同时,可支持深层次的文件结构:

// 创建一个新的resolve 代替path原生resolve,前面已经处理好pkgDir
const resolve = (filename) => path.resolve(pkgDir, filename)

处理output

按照我们默认的情况,库的入口一般在src/index.ts,当然不排除一个库提供多个工具,如vite,同时提供build和cli脚手架功能,即,入口同时有src/index.ts, src/build.ts, src/cli.ts,那么,我们可以在package.json里用buildOptions来写入各自的差异。如此就可以通过覆盖参数的方式针对性的打包各个库:

// 针对构建,我们采用 cli inline > pkgOptions > defaults 的优先级 处理参数覆盖

const pkgOptions = pkg.buildOptions || {}
const defaultFormats = ['esm', 'umd']
// cli inline 表示你在命令行输入命令时,想手动控制打包行为而指定的命令,可以是打包格式,监听模式,或者dev环境等
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFormats || pkgOptions.formats || defaultFormats

没一个打包格式将对应一个输入输出,那么我们要根据format生成一个完整配置参数的数组,并导出给rollup使用:

const outputConfigs = {
  esm: {
    file: resolve(`dist/${name}.esm.js`),
    format: 'es'
  },
  global: {
    file: resolve(`dist/${name}.global.js`),
    name: camelCase(name),
    format: 'iife'
  }
  // .... 还有cjs umd 等
}

const packageConfigs = packageFormats.map(createConfigWithFormat)

function createConfigWithFormat(format) { return createConfig(format, outputConfigs[format])}

export default packageConfigs

对于umd,iife格式,rollup打包成立即执行函数的需要提供name属性用于挂载在global上,另外,生产环境需要更改output的文件名,以及压缩代码等,这部分差异显而易见且代码简单,就不在这里展示了。 这样,处理input和output就OK了:

//rollup.config.js 需要默认导出一个配置或配置数组,这个配置必须是一个输入对应一个输出
//由于plugins是通用的,我们就创建一个方法处理每一个不同的format

//format即是此次打包格式,output我输出配置,plugins是特殊需要的插件
function createConfig(format, output, plugins = []) {
    //由于output在dev和production环境下输出并不一样,我们将差异化的地方放在外面
    //使createConfig始终关注通用配置的默认行为
    //同样,不同场景下可能会有一些特殊的插件要使用,那么除了同样的插件,这些特殊插件都通过传入的方式添加
    
    const entryFile = pkgOptions.entry || 'src/index.ts'
    return {
        input: resolve(entryFile),
        output,
        plugins: [
            common-plugin1(),
            common-plugin2(),
            ...plugins
        ]
    }
}

step-1 step-2 总结

  • 我们需要知道打包目标(“project-1”)来自环境变量TARGET
  • 我们需要知道打包格式(“esm, cjs, umd...”)来自cli inline, buildOptions,defaults
  • 我们需要知道打包环境dev or productin 来自 env.NODE_ENV,或者知道更多的其他环境变量

step-3 开始编写打包脚本

dev打包

  • cli inline 优先级最高,可以覆盖所有参数,所以我们要先获取命令行里的参数,这里推荐使用minimist库,自动解析并格式化参数

假设我们此次调试的库是shared,打包格式是esm, 命令行为:

yarn dev shared -f esm
# or
yarn dev shared --formats esm
# or
yarn dev --formats esm shared

输出的结果:

// 具名参数arguments 采用 --word 或 -w 方式(--接单词或-接单词缩写 接空格 接value的形式)
// 匿名参数则统一push到 args._ 里
const args = require('minimist')(process.argv.slice(2))
console.log(args) // target { _: [ 'shared' ], f: 'esm' }
  • 如上我们已经可以随心定制命令行了,接下来就是定制rollup的命令行参数:
// -w 表示监听文件变化,-c表示使用config文件,默认rollup.config.js 也可以在value 指定
// --enviroment 可以传递环境变量 比如 NODE_ENV 这种
rollup -wc --enviroment [环境变量]

为了能执行命令行,我们还需要一个简化的命令行库execa

const execa = require('execa')

// 如果你正专注于某一个库的开发,还可以默认指定参数,或者在package.json scripts里指定打包对象
// fuzzyMatchTarget 用于检查当前target是不是在packages目录下存在
const target = args._.length ? fuzzyMatchTarget(args._)[0] : 'split-layout'
const formats = args.formats || args.f
const sourceMap = args.sourcemap || args.s

execa(
  'rollup',
  [
    '-wc',
    '--environment',
    [
      `TARGET:${target}`, // 传递打包对象
      `FORMATS:${formats || 'global'}`, // 传递打包格式

      // sourcemap 是代码打包后对于源代码的索引,方便浏览器报错时查找到错误代码位置
      sourceMap ? 'SOURCE_MAP:true' : '' 
    ].filter(Boolean).join(',')
  ],
  {
    stdio: 'inherit'
  }
)

production打包

  • 生产环境打包原理和上面一样,在主要流程上区别的地方主要是:
  1. 输出dts文件
  2. 同时打包多个库
  3. 生成参数类型文档
  4. gizp文件
  5. 去除开发环境提示
  6. 验证输出文件并check 文件大小
输出dts文件和类型文档
  • rollup-plugin-typescript2打包ts代码并输出dts文件

需要用@rollup/plugin-typescirpt 或 rollup-plugin-typescript2

@rollup/plugin-typescirpt是官方插件,rollup-plugin-typescript2是基于官方插件新增了语法语义报错提示,所以我推荐后者,以便开发时就能提早发现问题。

既如此我们需要忘rollup的配置里加入 typescript插件

typescript插件会有默认配置,但是我们各个库可能有不同的目标,比如浏览器通常编译到es5啊,插件编译到es6啊,各种情况都有,那我们就在这个库下创建一个tsconfig.json 写上自己的需要的配置进行覆盖,同时也支持参数override,value是个对象为tsconfig里的配置,这里我们从简且需求达到,就如下了:

+ import typescript  from 'rollup-plugin-typescript2'

  function createConfig(format, output, plugins = []) {
      return {
          plugins: [
+             typescript({
+                 tsconfig: resovle('tsconfig.json')
+             }),
              ...plugins
          ]
      }
  }
// packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".", //重新指定参照路径
    "declaration": true, // 是否输出声明文件
    "declarationMap": true,
    "outDir": "dist"
  }
}
  • @microsoft/api-extractor将dts文件整合到一起,并生成type doc

默认会使用配置文件api-extractor.json,由于是通用的,这里就不展示了,可以直接去仓库里看

我们使用的是脚本文件,就需要手动调用api-extractor插件了:

const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor')

const extractorConfigPath = path.resolve(pkgDir, 'api-extractor.json')
const extractorConfig = ExtractorConfig.loadFileAndPrepare(
  extractorConfigPath
)
// invoke表示使用自定义准备好的文件,场景就是针对脚本执行时手动传入配置
const extractorResult = Extractor.invoke(extractorConfig, {
  localBuild: true,
  showVerboseMessages: true // 输出更为详细的信息
})

execa是异步执行函数,我们需要等待所有ts文件打包后输出对应的dts文件,才能将使用api-extractor将dts文件整合到一起,如果本地还有types文件夹,build脚本里还有一段代码将types里的dts文件追加到dist下的dts文件里,详情请看仓库里的scripts/build.js文件

至此,打包上的重要步骤就完成了,别的插件可以按自己的需要添加。

gzip和输出文件大小信息

gzip就比较简单了,使用:

const { gzipSync } = require('zlib')

// 直接fs读取文件,gzip猛抽就完事
const file = fs.readFileSync(filePath)
const minSize = (file.length / 1024).toFixed(2) + 'kb'
const gzipped = gzipSync(file)
const gzippedSize = (gzipped.length / 1024).toFixed(2) + 'kb'

总结一下操作

  • 在根目录简单的命令行就可以单独调试某个库或者一键打包
yarn dev project-1 -f esm

yarn build -t

关键文件夹和文件:
tsconfig.json, scripts/dev.js, scripts/build.js, api-extractor.json, rollup.config.js

以上只是通用的packages/projects 目录格式的打包方式,你可以根据自己的需要自定义打包脚本,只要关注点 input, output, target, formats 是清晰的就没有问题

  • 所有实例代码都在 仓库 compose/initial 下,这个分支只会优化bundle代码,不会写入业务代码
  • 后期,这个仓库会参照vscode需求基于原生js写一个支持T字型,或者田字型布局的拖拽库,届时,欢迎捧场

这篇文章参考代码来自vue-next,阅读完本文章之后,相信你有能力掌握vue-next项目的打包和构建流程

【github 仓库地址】

毕业工作两年了,开始一步步沉淀自己,新开的组织和github账号,不再瞎搞,一切重新开始,后面开始写文章了,少摸鱼,欢迎留言讨论和指出错误,同时欢迎大佬现场教学。