使用 Lerna + Yarn + Rollup 搭建 monorepo 脚手架

2,445 阅读4分钟

本地开启 Yarn

Node.js >= 16.10

$ corepack enable

Node.js < 16.10

$ npm i -g corepack
$ corepack enable

corepack 是一个试验性质的工具,它主要用来帮助用户更好的管理你所使用的包管理工具的版本,在 Node 16.10 版本之后,它内置在 Node 中,之前的版本需要进行全局安装

初始化 Lerna

在目标文件夹中运行

$ yarn dlx lerna init --independent

初始化项目

在目标文件夹中运行

$ yarn init -2

因为本项目不打算使用 Plug'n'Play 来管理依赖,因此还需要再调整下 package.jsonyarnrc.yml 文件中的内容

// package.json
{
  "version": "0.0.0",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "packageManager": "yarn@3.2.0",
  "name": "lerna-yarn-rollup-boilerplate",
  "devDependencies": {
    "lerna": "^4.0.0"
  }
}
# yarnrc.yml
nodeLinker: node-modules

yarnPath: .yarn/releases/yarn-3.2.0.cjs

gitignore 文件中的内容可以参考这里

安装 Yarn 插件

$ yarn plugin import workspace-tools

调整 Lerna 配置

// lerna.json
{
  "useWorkspaces": true,
  "npmClient": "yarn",
  "packages": ["packages/*"],
  "version": "independent"
}

安装和配置 Husky(可跳过)

$ yarn add husky -D
$ yarn husky install

package.json 中添加脚本

{
  ...
+  "scripts": {
+    "postinstall": "husky install"
+  },
  ...
}

添加一个 hook

$ yarn husky add .husky/pre-commit "yarn test"

安装 commitlint

$ yarn add -D @commitlint/{config-conventional,cli}

配置使用 conventional config

$ echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

添加 husky 的 hook

$ cat <<EEE > .husky/commit-msg
#!/bin/sh
. "\$(dirname "\$0")/_/husky.sh"

yarn commitlint --edit "\${1}" 
EEE

确保 hook 可执行

$ chmod a+x .husky/commit-msg

添加子项目

用于展示组件的子项目 react app

$ yarn lerna create @lyrb/react-app
$ yarn create vite ./packages/react-app --template react-ts

按钮组件子项目 ui-button

$ yarn lerna create @lyrb/ui-button
$ yarn workspace @lyrb/ui-button add react -D

调整 ui-button 项目中的目录结构和文件内容如下:

packages/ui-button
├── src
│   ├── index.ts
│   └── Button.tsx
├── package.json
└── README.md
// ui-button/src/Button.tsx

import type { PropsWithChildren } from "react";

interface Props {}

const Button = (props: PropsWithChildren<Props>) => {
  return <button>{props.children}</button>;
};

export default Button;
// ui-button/src/index.ts

export { default as Button } from "./Button";

工具库子项目 ui-utils

$ yarn lerna create @lyrb/ui-utils

调整 ui-utils 项目中的目录结构和文件内容如下:

packages/ui-utils
├── src
│   └── index.ts
├── package.json
└── README.md
// ui-utils/src/index.ts

export const sayHelloTo = (name: string) => {
  console.log(`hello ${name}`);
};

安装 TypeScript

$ yarn add typescript -D
$ yarn workspace @lyrb/ui-utils add typescript -D 
$ yarn workspace @lyrb/ui-button add typescript -D

初始化

$ yarn tsc --init
$ yarn workspace @lyrb/ui-button tsc --init
$ yarn workspace @lyrb/ui-utils tsc --init

tsconfig.json 文件中的配置可以参考这里

我们用根目录下的 tsconfig.json 作为基础配置,调整 ui-buttonui-utils 项目中的 tsconfig.json 内容为:

{
  "extends": "../../tsconfig.json"
}

检验一下是否生效

$ yarn workspace @lyrb/ui-button tsc

运行后会得到如下报错:

src/Button.tsx:6:10 - error TS17004: Cannot use JSX unless the '--jsx' flag is provided.

我们调整下 ui-buttontsconfig.json 的内容

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "jsx": "preserve"
  }
}

再次执行上面的命令,发现之前的报错信息已经没有了,且 ui-button 项目中生成了 ./index.js./src/Button.jsx 这2个文件,说明我们的配置已经生效了 ✌️

安装 ESLint

上面的 typescript 我们采取的策略是每个子包都会独立去安装这个依赖,这样的好处是即使在子项目的根目录下依旧可以运行 tsc 命令

$ cd ./packages/ui-button
$ yarn tsc

但是麻烦的是,我们需要给每个子项目都安装这个依赖,如果子项目总数过多,维护起来就很繁琐。

Yarn 提供了一个可以共享脚本的功能,我们可以借助它来实现只在项目根目录中安装对应依赖,在各个子项目中共享使用这个依赖

首先在根项目中安装 eslint

$ yarn add eslint -D

初始化 eslint

$ yarn create @eslint/config

根据提示选择合适的选项后会在根项目中安装额外的依赖,安装完毕后,此时在 IDE 中会看到对应的错误提示

image.png

我们再确认下命令是否生效

在根项目的 package.json 文件中添加用来被共享的脚本

{
  ...
  "scripts": {
    "postinstall": "husky install",
+    "g:eslint": "cd $INIT_CWD && eslint"
  },
  ...
}

ui-utils 项目 package.json 文件中添加脚本

{
  "scripts": {
    "test": "echo \"Error: run tests from root\" && exit 1",
+    "eslint": "yarn g:eslint src/**/*"
  },
}

执行命令

$ yarn workspace @lyrb/ui-utils run eslint

此时我们可以看到控制台会输出对应的错误信息

/.../packages/ui-utils/src/index.ts
  1:1  error    Prefer default export         import/prefer-default-export
  2:3  warning  Unexpected console statement  no-console

安装 Prettier

$ yarn add prettier -D

创建配置文件和需要忽略的配置文件

$ echo {}> .prettierrc.json
$ echo coverage\\n.yarn > .prettierignore

在根项目的 package.json 文件中添加脚本

{
  "scripts": {
    "postinstall": "husky install",
    "g:eslint": "cd $INIT_CWD && eslint",
+    "prettier": "prettier --write ."
  },
}

执行脚本

$ yarn run prettier

配合 ESLint 达成 1 + 1 > 2 的效果

添加 eslint-config-prettiereslint-plugin-prettier

$ yarn add eslint-config-prettier eslint-plugin-prettier -D

调整 .eslintrc.js 中的配置

module.exports = {
  ...
  extends: [
    ...,
+    'plugin:prettier/recommended',
  ],
  ...
}

安装 Stylelint

$ yarn add stylelint stylelint-config-standard -D

创建配置文件 .stylelintrc.js

{
  "extends": "stylelint-config-standard",
  "rules": {},
}

配合 Prettier 达成 1 + 1 > 2 的效果

添加 stylelint-config-prettierstylelint-prettier)

$ yarn add stylelint-config-prettier stylelint-prettier -D

调整 .stylelintrc.js 中的配置

module.exports = {
  ...
  extends: [
    ...,
+    'plugin:prettier/recommended',
  ],
  ...
}

在根项目的 package.json 文件中添加脚本

{
  ...
  "scripts": {
    ...,
+    "g:stylelint": "cd $INIT_CWD && stylelint"
  },
  ...
}

执行命令验证一下

$ yarn run g:stylelint "**/*.css"

packages/react-app/src/App.css
  1:1   ✖  Expected class selector to be kebab-case  selector-class-pattern
  5:1   ✖  Expected class selector to be kebab-case  selector-class-pattern
 11:3   ✖  Expected class selector to be kebab-case  selector-class-pattern
 16:1   ✖  Expected class selector to be kebab-case  selector-class-pattern
 27:1   ✖  Expected class selector to be kebab-case  selector-class-pattern
 31:12  ✖  Expected keyframe name to be kebab-case   keyframes-name-pattern

packages/react-app/src/index.css
 3:63  ✖  Unexpected quotes around "Roboto"     font-family-name-quotes
 3:73  ✖  Unexpected quotes around "Oxygen"     font-family-name-quotes
 3:83  ✖  Unexpected quotes around "Ubuntu"     font-family-name-quotes
 4:5   ✖  Unexpected quotes around "Cantarell"  font-family-name-quotes

安装 lintstaged

通过 Mrm 完成快速安装和初始化配置

$ yarn dlx mrm lint-staged

命令执行完后,我们需要调整下默认的配置

# ./.husky/pre-commit

...

- npx lint-staged
+ yarn lint-staged
// ./package.json

{
  ...,
  "lint-staged": {
-    "*.js": "eslint --cache --fix",
-    "*.css": "stylelint --fix"
+    "./packages/**/*.{js,jsx,ts,tsx}": "eslint --fix",
+    "./packages/**/*.css": "stylelint --fix",
+    "*.{md,json}": "prettier --write"
  }
}

安装 Jest

$ yarn add jest babel-jest @types/jest react-test-renderer @types/react-test-renderer -D

初始化

$ yarn jest --init

安装 Babel 相关模块

$ yarn add @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript -D

创建 Babel 配置文件 babel.config.js

module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
    [
      '@babel/preset-react',
      {
        runtime: 'automatic',
      },
    ],
  ],
}

测试 ui-utils 项目

ui-utils 项目中添加 src/index.test.ts 文件

import { sayHelloTo } from './index'

describe('test sayHelloTo function', () => {
  it('should console log Hello Kevin', () => {
    const logSpy = jest.spyOn(console, 'log')

    sayHelloTo('Kevin')

    expect(logSpy).toHaveBeenCalledWith('Hello Kevin')
  })
})

运行脚本查看测试结果

$ yarn run test
  console.log
    Hello Kevin

      at console.<anonymous> (node_modules/jest-mock/build/index.js:836:25)

 PASS  packages/ui-utils/src/index.test.ts
  test sayHelloTo function
    ✓ should console log Hello Kevin (47 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |                   
 index.ts |     100 |      100 |     100 |     100 |                   
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.555 s, estimated 1 s
Ran all test suites.

测试 ui-button 项目

首先改造下 ui-button 项目的源码

安装 clsx

$ yarn workspace @lyrb/ui-button add clsx arn 

添加 style.css 文件到 ui-button 项目的 src 目录下

.btn {
  padding: 12px 24px;
  border: 1px solid grey;
  appearance: none;
}

.btn-primary {
  background: blue;
}

修改 Button.tsx 文件

import type { PropsWithChildren } from 'react'
+ import clsx from 'clsx'
+ import './style.css'

interface Props {
  type: 'primary' | 'default'
}

const Button = (props: PropsWithChildren<Props>) => {
  const { type } = props

+  const classNames = clsx('btn', {
+    'btn-primary': type === 'primary',
+  })

-  return <button type="button">{props.children}</button>
+  return ( 
+    <button className={classNames} type="button">
+      {props.children}
+    </button>
+  )
}

export default Button

添加测试文件

// ui-button/src/__tests__/button.test.tsx

import renderer from 'react-test-renderer'
import Button from '../Button'

describe('test button component', () => {
  it('render correctly', () => {
    const component = renderer.create(<Button type="primary">123</Button>)

    expect(component).toMatchSnapshot()
  })
})

因为我们在 Button.tsx 文件中引入了 styles.css 文件,因此要简单改造下 Jest 的配置文件,具体原因可以参考这里

在项目根目录添加 __mocks__/styleMock.js 文件

module.exports = {}

修改 jest.config.ts 内容

export default {
   ...,
+  moduleNameMapper: {
+    '\\.(css|less|sass|scss)$': '<rootDir>/__mocks__/styleMock.js',
+  },
   ...
}

运行脚本查看测试结果

$ yarn run test                           
 PASS  packages/ui-utils/src/index.test.ts
  ● Console

    console.log
      Hello Kevin

      at console.<anonymous> (node_modules/jest-mock/build/index.js:836:25)

 PASS  packages/ui-button/src/__tests__/button.test.tsx
 › 1 snapshot written.
---------------|---------|----------|---------|---------|-------------------
File           | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------|---------|----------|---------|---------|-------------------
All files      |     100 |      100 |     100 |     100 |                   
 ui-button/src |     100 |      100 |     100 |     100 |                   
  Button.tsx   |     100 |      100 |     100 |     100 |                   
 ui-utils/src  |     100 |      100 |     100 |     100 |                   
  index.ts     |     100 |      100 |     100 |     100 |                   
---------------|---------|----------|---------|---------|-------------------

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 written, 1 total
Time:        0.645 s, estimated 1 s
Ran all test suites.

可以在 ui-button/src/__tests__/__snapshots__ 文件夹中找到刚创建的 button.test.tsx.snap 文件

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`test button component render correctly 1`] = `
<button
  className="btn btn-primary"
  type="button"
>
  123
</button>
`;

安装 Rollup

$ yarn add rollup -D

根项目 package.json 添加公共脚本

{
  "scripts": {
    ...,
+   "g:build": "cd $INIT_CWD && rollup",
    ...
  },
}

构建 ui-utils 项目

安装 Rollup 插件

$ yarn add @rollup/plugin-typescript @rollup/plugin-babel rollup-plugin-node-externals @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-sizes -D

ui-utils 项目根目录创建 rollup.config.js 文件

import { defineConfig } from 'rollup'
import path from 'path'
import typescript from '@rollup/plugin-typescript'
import babel from '@rollup/plugin-babel'
import externals from 'rollup-plugin-node-externals'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import sizes from 'rollup-plugin-sizes'
import packageJson from './package.json'

/**
 * @type {import('rollup').RollupOptions.plugins}
 */
const publicConfig = {
  input: 'src/index.ts',
  plugins: [
    externals(),
    resolve(),
    commonjs(),
    babel({
      babelHelpers: 'bundled',
    }),
    sizes(),
  ],
}

/**
 * @type {import('rollup').RollupOptions}
 */
const cjsConfig = {
  ...publicConfig,
  output: {
    file: packageJson.main,
    format: 'cjs',
    sourcemap: true,
  },
  plugins: [
    ...publicConfig.plugins,
    typescript({ tsconfig: path.resolve(__dirname, './tsconfig.json') }),
  ],
}

/**
 * @type {import('rollup').RollupOptions}
 */
const esConfig = {
  ...publicConfig,
  output: [
    {
      dir: 'es',
      format: 'es',
      sourcemap: true,
    },
  ],
  plugins: [...publicConfig.plugins, typescript({ tsconfig: './tsconfig.json', outDir: './es' })],
}

export default defineConfig([cjsConfig, esConfig])

调整 ui-utils 项目的 package.json 文件

{
  ...,
-  "main": "lib/ui-utils.js",
+  "main": "lib/index.js",
+  "module": "es/index.js",
+  "typings": "lib/index.d.ts",
  ...,
+  "files": [
+    "lib",
+    "es"
+  ],
  "scripts": {
    ...,
+   "build": "yarn g:build -c"
  },
}

调整 ui-utils 项目的 tsconfig.json 文件

{
  "extends": "../../tsconfig.json",
+ "exclude": ["node_modules", "**/*.test.ts", "/**/*.test.tsx", "./es/**/*", "./lib/**/*"],
+ "compilerOptions": {
+   "declaration": true,
+   "noEmit": true,
+   "outDir": "./"
+ }
}

执行构建命令

$ yarn workspace @lyrb/ui-utils run build

构建 ui-button 项目

由于该项目中引入了 css 文件,因此要额外安装一些插件

$ yarn add rollup-plugin-styles -D

ui-button 项目根目录创建 rollup.config.js 文件

import { defineConfig } from 'rollup'
import path from 'path'
import typescript from '@rollup/plugin-typescript'
import sizes from 'rollup-plugin-sizes'
import styles from 'rollup-plugin-styles'
import resolve from '@rollup/plugin-node-resolve'
import externals from 'rollup-plugin-node-externals'
import commonjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'
import packageJson from './package.json'

/**
 * @type {import('rollup').RollupOptions.plugins}
 */
const publicConfig = {
  input: 'src/index.ts',
  plugins: [
    externals(),
    resolve(),
    commonjs(),
    babel({
      babelHelpers: 'bundled',
    }),
    styles({
      mode: 'extract',
    }),
    sizes(),
  ],
}

/**
 * @type {import('rollup').RollupOptions}
 */
const cjsConfig = {
  ...publicConfig,
  output: {
    file: packageJson.main,
    format: 'cjs',
    sourcemap: true,
    assetFileNames: '[name][extname]',
  },
  plugins: [
    ...publicConfig.plugins,
    typescript({ tsconfig: path.resolve(__dirname, './tsconfig.json') }),
  ],
}

/**
 * @type {import('rollup').RollupOptions}
 */
const esConfig = {
  ...publicConfig,
  preserveModules: true,
  output: [
    {
      dir: 'es',
      format: 'es',
      sourcemap: true,
      assetFileNames: '[name][extname]',
    },
  ],
  plugins: [...publicConfig.plugins, typescript({ tsconfig: './tsconfig.json', outDir: './es' })],
}

export default defineConfig([cjsConfig, esConfig])

调整 ui-button 项目的 package.json 文件

{
  ...,
-  "main": "lib/ui-button.js,
+  "main": "lib/index.js",
+  "module": "es/index.js",
+  "typings": "lib/index.d.ts",
  ...,
+  "files": [
+    "lib",
+    "es"
+  ],
  "scripts": {
    ...,
+   "build": "yarn g:build -c"
  },
}

调整 ui-button 项目的 tsconfig.json 文件

{
  "extends": "../../tsconfig.json",
+ "exclude": ["node_modules", "**/*.test.ts", "/**/*.test.tsx", "./es/**/*", "./lib/**/*"],
  "compilerOptions": {
-   "jsx": "preserve"
+   "jsx": "react-jsx",
+   "declaration": true,
+   "noEmit": true,
+   "outDir": "./"
   }
}

执行构建命令

$ yarn workspace @lyrb/ui-button run build

最后,记得在根项目的 .gitignore.eslintignore 中添加 eslib 目录

关联各个子项目

ui-button 中使用 ui-utils

$ yarn workspace @lyrb/ui-button add @lyrb/ui-utils@1.0.0  

调整 ui-button 项目中的 src/Button.tsx 文件内容

- import { PropsWithChildren } from 'react'
+ import { PropsWithChildren, useCallback } from 'react'
import clsx from 'clsx'
+ import { sayHelloTo } from '@lyrb/ui-utils'
import './style.css'

interface Props {
  type: 'primary' | 'default'
}

const Button = (props: PropsWithChildren<Props>) => {
  const { type } = props

  const classNames = clsx('btn', {
    'btn-primary': type === 'primary',
  })

+ const handleClick = useCallback(() => {
+   sayHelloTo('John')
+ }, [])

  return (
-   <button className={classNames} type="button">
+   <button className={classNames} type="button" onClick={handleClick}>
      {props.children}
    </button>
  )
}

export default Button

调整 ui-button 中的 tsconfig.json

{
  ...,
  "compilerOptions": {
+   "moduleResolution": "node",
    ...
  }
}

react-app 中使用 ui-button

$ yarn workspace @lyrb/react-app add @lyrb/ui-button@1.0.0  

调整 react-app 中的 App.tsx

import { useState } from 'react'
+ import { Button } from '@lyrb/ui-button'
+ import '@lyrb/ui-button/lib/index.css'
import logo from './logo.svg'
import './App.css'

function App() {
  const [count, setCount] = useState(0)

  return (
    <div className="app">
      <header className="app-header">
        <img src={logo} className="app-logo" alt="logo" />
        <p>Hello Vite + React!</p>
        <p>
          <button type="button" onClick={() => setCount((prevCount) => prevCount + 1)}>
            count is: {count}
          </button>
        </p>
+       <p>
+         <Button type="primary">Click Me</Button>
+       </p>
        <p>
          Edit <code>App.tsx</code> and save to test HMR updates.
        </p>
        <p>
          <a
            className="app-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
          {' | '}
          <a
            className="app-link"
            href="https://vitejs.dev/guide/features.html"
            target="_blank"
            rel="noopener noreferrer"
          >
            Vite Docs
          </a>
        </p>
      </header>
    </div>
  )
}

export default App

调整 vite.config.ts,原因看这里

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
+ optimizeDeps: {
+   include: ['react/jsx-runtime'],
+ },
})

构建

安装 yarn.BUILD 插件

$ yarn plugin import https://yarn.build/latest   

构建项目

$ yarn build

构建完毕后,我们可以运行 react-app 项目看看效果

$ yarn workspace @lyrb/react-app dev
# or
$ yarn dlx serve packages/react-app/dist

一切顺利 🎉

image.png

发布

调整 ui-buttonui-utils 项目中的 package.json 文件

{
  ...,
  "publishConfig": {
+   "access": "public",
    ...
  },
  "scripts": {
+   "prepublishOnly": "yarn run build",
    ...
  },
  ...
}

调整 lerna.json 里的配置

{
  "useWorkspaces": true,
  "npmClient": "yarn",
+ "command": {
+   "publish": {
+     "message": "chore(release): publish"
+   }
+ },
  "packages": ["packages/*"],
  "version": "independent"
}

发车 🚀

$ yarn lerna publish

完整的项目在这里

(完)

参考资料