美文网首页React
react组件库开发,框架搭建(yarn+lerna+rollu

react组件库开发,框架搭建(yarn+lerna+rollu

作者: 无心之水 | 来源:发表于2021-07-20 10:40 被阅读0次

    写在最前面

    研究了很久,到最后才发现自己错了,对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<any>(
          (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 node-sass@4.14.1 sass-loader@8.0.2 -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 (
        <button
          type="button"
          className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
          style={{ backgroundColor }}
          {...props}
        >
          {label}
        </button>
      );
    };
    
    // /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<typeof Button>;
    
    const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
    
    export const Primary = Template.bind({});
    Primary.args = {
      primary: true,
      label: 'Button',
    };
    
    export const Secondary = Template.bind({});
    Secondary.args = {
      label: 'Button',
    };
    
    export const Large = Template.bind({});
    Large.args = {
      size: 'large',
      label: 'Button',
    };
    
    export const Small = Template.bind({});
    Small.args = {
      size: 'small',
      label: 'Button',
    };
    

    这时控制台输入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+rollu

        本文链接:https://www.haomeiwen.com/subject/xzoudltx.html