react组件库开发,框架搭建(yarn+lerna+rollup+storybook)

写在最前面

研究了很久,到最后才发现自己错了,对lerna的理解有问题,但是还好错的不是很彻底。

如果你是想单纯做一个类似于antd的组件库,那么,不要用此套方案,这套方案是类似于babel的,比如你要单独使用babel某个插件需要安装@babel/xxxxx,这样,但是组件库大部分使用场景不会是用一个组件安装一个组件,肯定是直接安装一个组件库然后引用其中某些模块,所以这个方案完全与想法背道而驰。

正确思路是研究rolluptree shaking
rollup好处:

  1. 支持导出ES模块的包。
  2. 它支持程序流分析,能更加正确的判断项目本身的代码是否有副作用。我们只要通过rollup打出两份文件,一份umd版,一份ES模块版,它们的路径分别设为main,module的值。这样就能方便使用者进行tree-shaking。

不过我们组还是采用了这种方式,直接将组件库的源代码和各个项目代码用lerna+yarn workspace统一管理。至于怎么打包组件库,在文章最后面~

一、基础介绍

1、yarn workspace

官网介绍

简单翻译一下:
yarn workspace允许将项目分包管理,只用在根目录执行一次yarn install即可安装子包中的依赖。并且允许一个子包依赖另外一个子包。

优点:

  1. 工作区中的项目可以相互(单向)依赖,保持使用最新的代码,只会影响workspace中的内容,不会影响整个项目,比yarn link的机制更好。
  2. 所有子包中的依赖都安装在一起,可以更好的优化空间。
  3. 每个项目独立一个yarn.lock,这意味着更少的冲突,代码检查也更简单。

缺点/局限性:

  1. 开发和打包后的结构会有所不同,workspace中的依赖会被提升到更高的位置,如果这里出现问题了,可以用nohoist尝试修复
  2. 由于有的包要依赖老包打包新的包,所以如果子包中的依赖项版本与workspace中的版本不一样,这个依赖会从npm安装打包而不是从本地的文件系统中打包。
  3. 发包时如果用到了新版本的依赖,但是没有在package.json中声明的话,使用发布版本的用户会出现问题,因为他们不会更新依赖项。这个问题目前没有办法警告。
  4. 目前不支持嵌套工作区,也不支持跨工作区引用。

以上,翻译如有问题,评论区提出

2、lerna

开源js分包管理工具,子包依赖管理,分包发布。

3、rollup & rollup vs webpack

模块打包,库打包用rollup
application打包用webpack

4、storybook

Storybook 是一个用于 UI 开发的工具。 它通过隔离组件使开发更快、更容易。 这允许您一次处理一个组件。 您可以开发整个 UI,而无需启动复杂的开发堆栈、将某些数据强加到您的数据库中或浏览您的应用程序。(google机翻)
总之storybook提供了一个可视化开发组件的解决方案,可以很方便的调试样式、参数,还能开发的时候顺手写了文档,还挺方便。

二、项目搭建流程

全局安装lerna,yarn,rollup

npm i lerna yarn rollup -g

新建文件夹,作为项目名,以下用demo作为项目名

// 进入文件夹
cd demo
// 初始化npm,设置对应字段
npm init
// 初始化lerna仓库
lerna init
// 打开yarn workspaces
yarn config set workspaces-experimental true

在packages.json中添加:

  "private": true,
  "workspaces": ["packages/*"],

安装打包工具rollup,由于是添加到workspaces根的package.json中,所以需要加-W配置

yarn add rollup -D -W

我们是用ts开发的,所以要安装ts和ts-node

yarn add typescript ts-node -D -W

ts配置:

tsc --init
// tsconfig.json
{
  "extends": "./tsconfig.extend.json",
  "compilerOptions": {
    "module": "commonjs"
  }
}
// tsconfig.rollup.json
{
  "extends": "./tsconfig.extend.json",
  "compilerOptions": {
    "module": "esnext",
    "declaration": true
  }
}
// tsconfig.extend.json
{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "baseUrl": ".",
    "downlevelIteration": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "forceConsistentCasingInFileNames": true,
    "jsx": "react",
    "resolveJsonModule": true,
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "noUnusedLocals": false,
    "sourceMap": true,
    "suppressImplicitAnyIndexErrors": true,
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "es2015.collection",
      "es2015.iterable",
      "es2015.promise",
      "es5"
    ]
  },
  "include": ["**/*.ts", "**/*.tsx", "*.ts"],
  "exclude": [
    "node_modules",
    "dist"
  ]
}

根目录新建rollup.build.ts,编写打包流程,需要安装引入的模块,其中,yargs-parser要安装对应的ts types @types/yargs-parserfs-extra需要安装@types/fs-extra

import chalk from 'chalk' // 控制台输出彩色文案
import execa from 'execa' // 开启子进程执行命令,https://www.npmjs.com/package/execa
import fse from 'fs-extra' // file system的扩展方法,此处用它同步读取json
import globby from 'globby' // 增强版本glob,此处用它同步匹配文件名
import path from 'path' //路径工具
import { InputOptions, OutputOptions, rollup } from 'rollup'
import commonjs from 'rollup-plugin-commonjs'
import scss from 'rollup-plugin-scss'
import nodeResolve from 'rollup-plugin-node-resolve'
import typescript from 'rollup-plugin-typescript2'
import ts from 'typescript'
import yargs from 'yargs-parser'
import lernaJson from './lerna.json'

interface IOpt extends InputOptions {
  output: OutputOptions[]
}

// 命令要做什么,all则编译所有包,changed则编译发生改变的包,默认为all
const argv = yargs(process.argv)
const type: 'all' | 'changed' | undefined = argv.type

export class Run {
  /**
   * 流程函数
   * @param ohterPkgPaths 其他包,可用来排除
   * @param external 排除不打包到dish里面的包
   */
  public async build(ohterPkgPaths: string[] = [], external: string[] = []) {
    const pkgPaths: string[] = this.getPkgPaths(lernaJson.packages)

    // rollup配置列表
    const rollupConfigList = [...pkgPaths, ...ohterPkgPaths].map(
      (pPath) => {
        const pkg = fse.readJsonSync(pPath)
        const libRoot = path.join(pPath, '..')
        const isTsx = fse.existsSync(path.join(libRoot, 'src/index.tsx'))
        return {
          input: path.join(libRoot, isTsx ? 'src/index.tsx' : 'src/index.ts'),
          plugins: [
            scss(), // 我们这里用scoped scss来写样式,所以打包使用scss预处理样式
            nodeResolve({
              extensions: ['.js', '.jsx', '.ts', '.tsx'],
            }),
            typescript({
              check: false,
              tsconfigOverride: {
                compilerOptions: {
                  baseUrl: libRoot,
                  outDir: path.join(libRoot, 'dist'),
                  allowSyntheticDefaultImports: true,
                },
                include: [path.join(libRoot, 'src')],
              },
              typescript: ts,
              tsconfig: path.join(__dirname, 'tsconfig.json'),
            }),
            commonjs({
              include: path.join(__dirname, 'node_modules/**'),
            }),
          ],
          external: [
            ...Object.keys(pkg.dependencies || {}),
            ...(pkg.external || []),
            ...external,
          ],
          output: [
            {
              file: path.join(libRoot, pkg.main),
              format: 'cjs',
              exports: 'named',
              globals: {
                react: 'React',
              },
            },
            {
              file: path.join(libRoot, pkg.module),
              format: 'esm',
              exports: 'named',
              globals: {
                react: 'React',
              },
            },
          ],
        } as IOpt
      }
    )

    for (const opt of rollupConfigList) {
      console.log(chalk.hex('#009dff')('building: ') + opt.input)

      // 打包
      const bundle = await rollup({
        input: opt.input,
        plugins: opt.plugins,
        external: opt.external,
      })

      // 输出
      for (const out of opt.output) {
        // await bundle.generate(outOpt)
        await bundle.write(out)
        console.log(chalk.hex('#3fda00')('output: ') + out.file)
      }
    }
  }

  /**
   * 打印找到发生改变的包的日志
   * @param changes 发生改变的pkg
   */
  private logFindChanged(
    changes: Array<{ name: string; location: string; version: string }>
  ) {
    const logInfo = chalk
      .hex('#009dff')
      .bold('find changed: ' + (changes.length === 0 ? 'nothing changed' : ''))
    console.log(logInfo)

    changes.map((item) => {
      console.log(item.name)
    })
  }

  /**
   * 获得需要编译的包的package
   * @param lernaPkg lerna.json中的packages
   */
  private getPkgPaths(lernaPkg: string[]) {
    const lernaPkgPaths = lernaPkg.map((p) =>
      path.join(__dirname, p, 'package.json').replace(/\\/g, '/')
    )
    if (type === 'changed') {
      const changes = this.getChangedPkgPaths()
      // 如果发生改变,输出日志
      this.logFindChanged(changes)
      return changes.map((p) => path.join(p.location, 'package.json'))
    }
    return globby.sync(lernaPkgPaths)
  }

  /**
   * 获得发生改变的包
   */
  private getChangedPkgPaths(): Array<{
    name: string
    location: string
    version: string
  }> {
    const { stdout } = execa.sync('lerna changed --json')
    const matchPkgStr = stdout.replace(/[\r\n]/g, '').match(/{.+?}/g)
    return (matchPkgStr || []).map((item) => {
      return JSON.parse(item)
    })
  }
}

const run = new Run()

run.build()

package.json中添加打包和发布命令, 如果需要发布到私人npm仓库,则需要在release中添加 --registry (私人npm仓库地址),如发布至npmjs.org则不需要

"scripts": {
  "build:changed": "ts-node rollup.build.ts --type changed",
  "build:all": "ts-node rollup.build.ts --type all",
  "release": "lerna publish --no-push --registry (私人npm地址)",
  "updated": "lerna updated"
},

可选:使用husky做本地的git hooks,结合lint-staged做代码lint:

yarn add husky lint-staged -D -W

然后在package.json中添加配置:

  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "packages/**/*.scss": "stylelint --fix"
  }

三、开发,打包,发布

1、开发准备

其实做完上面的内容,就可以开发js工具库发布了,但是我们这次的目的是做组件库,组件库少不了视觉调试和文档,我采用的是storybook,其他这种类似框架还有umidumi,可以自行尝试。
storybook无需安装,直接使用npx

// 项目根目录
npx sb init

由于storybook是根据你项目目前的开发环境语言等来检测如何初始化的,但是我们目前还没有进入开发,所以,询问是否手动选择项目环境时,输入y,选择react_project即可。
等待初始化完毕...
项目根目录会生成.storybookstories两个文件夹,前者是配置文件的文件夹,后者是模板文件夹。
修改.storybook/main.js,删除stories文件夹。

// .storybook/main.js
module.exports = {
  "stories": [
    "../packages/**/example/*.stories.@(js|jsx|ts|tsx)",
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ]
}

2、开发

我们以一个Button组件进行演示

// 全局安装react(因为其他包也会用到)
yarn add react @types/react react-dom @types/react-dom node-sass -D -W
lerna create @demo/Button

这里我们使用scoped scss做为style格式,storybook的webpack可能用的不是webpack5,所以我们要使用老版本的node-sasssass-loader,并且需要配置sass-loader

yarn add [email protected] [email protected] -D -W
// .storybook/main.js
const path = require('path');

module.exports = {
  "stories": [
    "../packages/**/example/*.stories.@(js|jsx|ts|tsx)",
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
  ],
  webpackFinal: async (config, { configType }) => {
    // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
    // You can change the configuration based on that.
    // 'PRODUCTION' is used when building the static version of storybook.

    // Make whatever fine-grained changes you need
    config.module.rules.push({
      test: /\.scss$/,
      use: ['style-loader', 'css-loader', 'sass-loader'],
      include: path.resolve(__dirname, '../'),
    });

    // Return the altered config
    return config;
  },
}

/packages/Button中进行组件开发,由于我们是组件,删除/lib,新建/src/example,代码如下,这里我们直接用storybook的官方示例了

// /src/index.tsx
import React from 'react';
import './index.scoped.scss';

interface ButtonProps {
  /**
   * Is this the principal call to action on the page?
   */
  primary?: boolean;
  /**
   * What background color to use
   */
  backgroundColor?: string;
  /**
   * How large should the button be?
   */
  size?: 'small' | 'medium' | 'large';
  /**
   * Button contents
   */
  label: string;
  /**
   * Optional click handler
   */
  onClick?: () => void;
}

/**
 * Primary UI component for user interaction
 */
export const Button = ({
  primary = false,
  size = 'medium',
  backgroundColor,
  label,
  ...props
}: ButtonProps) => {
  const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
  return (
    
  );
};
// /src/index.scoped.scss
.storybook-button {
  font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  font-weight: 700;
  border: 0;
  border-radius: 3em;
  cursor: pointer;
  display: inline-block;
  line-height: 1;
}
.storybook-button--primary {
  color: white;
  background-color: #1ea7fd;
}
.storybook-button--secondary {
  color: #333;
  background-color: transparent;
  box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
  font-size: 12px;
  padding: 10px 16px;
}
.storybook-button--medium {
  font-size: 14px;
  padding: 11px 20px;
}
.storybook-button--large {
  font-size: 16px;
  padding: 12px 24px;
}
// /example/index.stories.tsx 这个就是.storybook/main.js中配置的模板演示文件的位置
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';

import { Button } from '../src/index';

export default {
  title: 'Example/Button',
  component: Button,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
} as ComponentMeta;

const Template: ComponentStory = (args) => 

这时控制台输入yarn storybook,就能启动看到storybook的网页了,如下。

storybook调试页面

3、打包

需要修改包的package.json文件

  "main": "dist/bundle.cjs.js",
  "module": "dist/bundle.ems.js",
  "types": "dist/index.d.ts",

注意,子包的依赖也要在package.json中引入,进入子包目录,用yarn add的方式添加依赖。如果是根存在的公共依赖,不会安装,如果是独特的依赖,会单独引入(由于使用了yarn workspaces,会在根目录的node_modules统一安装)
依赖add完毕之后,在项目根目录运行yarn build:all或者yarn build:changed
就会发现子包根目录出现dist文件夹,这里面就是打包出来的文件。

4、发布

发布之前需要先上传git,无论是公司git还是github或者第三方其他git,只要有版本管理即可。
运行yarn release选择特性即可。

四、其他

eslint代码格式规范

init最后询问是否执行npm i,选否,因为我们是用yarn来管理的,自己手动执行yarn install就行了

npm i eslint -g
eslint --init

配置参考:(直接拿了之前脚手架的来用)

module.exports = {
  env: {
    browser: true,
    es2021: true,
  },
  globals: {
    JSX: true,
  },
  extends: [
    'airbnb-typescript',
    'airbnb/hooks',
    'plugin:@typescript-eslint/recommended',
    'prettier',
    'plugin:prettier/recommended',
  ],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
      tsx: true,
      modules: true,
    },
    ecmaVersion: 2020,
    sourceType: 'module',
    project: './tsconfig.json',
  },
  plugins: ['react', '@typescript-eslint'],
  settings: {
    'import/resolver': {
      alias: {
        map: [['@', './src/']],
      },
      node: {
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
      typescript: {
        project: './tsconfig.json',
      },
    },
  },
  rules: {
    'react/jsx-filename-extension': [
      2,
      {
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
    ],
    'import/extensions': [
      'error',
      'ignorePackages',
      {
        js: 'never',
        ts: 'never',
        jsx: 'never',
        tsx: 'never',
      },
    ],
    'max-len': [0],
    'react/jsx-one-expression-per-line': 0,
    'react/state-in-constructor': 0,
    'react/self-closing-comp': 0,
    'react/prefer-stateless-function': 0,
    'react/static-property-placement': 0,
    'max-classes-per-file': 0,
    'react/sort-comp': 0,
    'jsx-a11y/no-noninteractive-element-interactions': 0,
    'jsx-a11y/click-events-have-key-events': 0,
    'jsx-a11y/control-has-associated-label': 0,
    'jsx-a11y/anchor-has-content': 0,
    'react/no-unused-state': 0,
    'jsx-a11y/anchor-is-valid': 0,
    'no-plusplus': 0,
    'jsx-a11y/no-static-element-interactions': 0,
    'jsx-a11y/alt-text': 0,
    'class-methods-use-this': 0,
    'import/prefer-default-export': 0,
    'no-console': 0,
    'react/jsx-props-no-spreading': 0,
    'no-param-reassign': 0,
    'no-shadow': 0,
    'jsx-a11y/media-has-caption': 0,
    'import/no-unresolved': [2, { ignore: ['react', 'react-dom'] }],
    semi: ['error', 'never'],
    'prettier/prettier': ['error', { semi: false, singleQuote: true }],
    '@typescript-eslint/no-empty-function': 'off',
  },
};

使用以上配置,需要安装一些插件:

yarn add eslint-config-airbnb-typescript eslint-config-prettier prettier eslint-plugin-prettier eslint-import-resolver-alias eslint-import-resolver-typescript babel-plugin-import -D -W

五、最终解决方案

直接lerna create @demo/basic-component,创建组件库子包,创建src文件夹,在src下创建各组件的文件夹,如Button,将上面的Button子包中的src移至Button文件夹,src下新建index.tsx,内容如下

import { Button } from './Button'

export { Button }

修改package.jsonButton子包的package.json,添加依赖,添加main``module``types。上传,build,release,发布成功。

注意storybook的模板也要移过去

测试:
新建项目,npm i @demo/basic-component,引入组件。

image.png

image.png

成功!

你可能感兴趣的:(react组件库开发,框架搭建(yarn+lerna+rollup+storybook))