Bootstrap

前端组件化工程实践

作者:实时辅助团队 徐小夕

背景

随着企业系统的日趋复杂和需求的多样化,技术团队不得不考虑系统的容错和扩展性,对性能要求也越来越高,毕竟用户体验至上是个亘古不变的真理。我们在为客户提供服务的过程中,也在持续积累最佳的行业技术经验来保证更好的用户体验和系统性能。当然这些落地的前提始终离不开 研发效能 。

摘要

此篇作为我们对前端组件系统的阶段性复盘,后续会围绕 如何提高中后台系统研发效能 这一主题持续探索和精进。本文将围绕如下大纲进行分享:

  • 组件库的划分和常用的几种打包方式

  • 组件库常用优化点支持按需导入和打包(定制 babel-plugin-import)如何优雅的使用 Tree shaking组件库的 package.json 配置总结

  • 组件库代码检测和提交规范

  • 组件多包管理方案

  • 组件库多环境发包实践

  • 后期规划

组件库的划分和常用的几种打包方式

组件库的分类模式有很多种, 我们常用的 antd(以业务场景划分), element(以功能模块划分)都有各自独立的分类方法。由于我们前端项目大多使用 React 开发, 设计体系和业务场景和 antd 比较吻合, 所以我们组件系统采用类似 antd 的分类方式:

  • 基础型

  • 布局型

  • 导航型

  • 数据录入

  • 数据展示

  • 反馈

  • 其他业务类型

对组件系统进行分类我觉得主要有以下两点好处:

  • 技术侧:更好的解耦业务关系,提高封装性以及组件定位

  • 设计侧:保证UI的一致性,可溯源,提高用户使用效率

随着业务复杂度的增高和业务系统的增多,我们不得不进一步考虑系统之间的共性和复用性,我们需要基于业务组件库进行组合和再抽象,形成所谓的 可复用区块:

区块的设计一方面可以抹平不同系统中相同设计模块因为开发人员不同而产生的差异, 另一方面也能提高相似模块的开发效率, 但是会牺牲一定的设计多样性, 所以在区块封装时需要做一定的取舍。

最终我们业务系统的开发流程会变成如下的形式:

至于这两年比较流行 规则引擎可视化引擎,如果业务系统做到一定的抽象和解耦,也是可以应用到具体项目中的,这块后续会持续探索。

对于组件的打包模式,我们想必都很熟悉, 常用的三种模式如下:

  • Cjs(CommonJS,采用require导入,无法提前分析依赖以及 Tree-Shaking)

  • Esm(es module,支持静态分析以及 Tree-Shaking)

  • Umd(兼容 CJS 和 AMD,支持前端在浏览器中以script的方式引入)

目前主流的组件打包方式是前两种, 我们可以使用 webpack 或者 rollup来做组件库的打包,网上也有很多开箱即用的打包工具比如 father.js, 所以这里就不一一介绍了。

组件库常用优化点

1.支持按需导入和打包

随着组件库的完善和丰富,我们需要考虑组件在项目中的打包体积,也就是要做到按需打包。因为我们的业务组件库是基于 dumi 定制的,打包依赖 father, 所以这里我以 father 为例来实现按需导入和打包。

father支持rollup和babel两种打包方式,要想实现按需导入和打包, 我们可以在 .fatherrc.ts 文件中做如下配置:

export default {
  cjs: { type: 'babel', lazy: true },
  esm: { type: 'babel' },
  umd: { file: 'you conponent name' },
  extractCSS: true,
  // ...其他配置
};

这样我们打包的时候就可以生成 es 和 lib 的包了。当然这只是第一步,要想在项目里实现按需导入和打包,我们还需要对项目进行配置,这里可以采用 babel-plugin-import 来实现:

// webpack config
export default {
  extraBabelPlugins: [
    [
      'import',
      {
        libraryName: 'your component name',
        libraryDirectory: 'es',
        style: true
      },
    ],
  ],
};

是不是很熟悉?没错,和 antd 的按需导入方式一样。但是以上配置是按照 babel-plugin-import 插件默认的导入方式来导入组件路径的,以上配置后我们在使用如下方式导入组件时:

import { Button } from 'antd'

实际上会转换成以下方式:

var _button = require('antd/es/button');
require('antd/es/button/style');

如果上面的配置style的值变成“css”,则通过 babel-plugin-import 处理后的导入方式如下:

var _button = require('antd/es/button');
require('antd/es/button/style/css');

这样的话对我们组件库的开发者来说丧失了很大的灵活度, 因为我们不得不按照它的方式设计组件的目录结构。

好在 babel-plugin-import 插件提供了自定义导入路径的方式,也就是给 style 属性传一个函数。

export default {
  extraBabelPlugins: [
    [
      'import',
      {
        libraryName: 'your component name',
        libraryDirectory: 'es',
        style: (name: string, file: any) => {
          // 自定义css导入路径
          return `${name}/style`;
        }
    ],
  ],
};

同样的对于组件的 js 代码来说,我们也需要自定义导入目录,这样更有利于对我们的业务组件进行分层管理。比如我们希望组件库中不经提供 UI组件, 还有自定义的公共 hooks,工具类库(纯js工具类库当然也可以单独作为类库打包),所以我们的组件目录结构可能长这样:

  • Components

  • Hooks

  • Utils

看过 babel-plugin-import 源码就不难发现我们可以通过如下方式来实现上述的需求:

export default {
  extraBabelPlugins: [
    [
      'import',
      {
        libraryName: 'your component name',
        libraryDirectory: 'es',
        style: (name: string, file: any) => {
          // 自定义css导入路径
          return `${name}/style`;
        },
        camel2DashComponentName: false, // 禁止转写组件名
        customName: (name: string) => {
          if (name.indexOf('use') === 0) {
            return `xxx/es/hooks/${name}`;
          }
          // ...其他过滤条件的处理
          
          // 默认改写的js导入路径
          return `xxx/es/${name}`;
        },
    ],
  ],
};

值得注意的是,在项目中我们如果引入了纯js的模块,比如 hooks 里的模块,启动项目就会发现类似如下的错误:

xxx/hooks/xxx/style not found

原因是 babel-plugin-import 在转写导入方式的时候会同时转写 js 和 css, 所以要想只转写 js, 我们需要在 style 的函数中进行单独配置,这里上一段 babel-plugin-import 的源码,方便我们更好的理解:

通过源码我们就可以发现解决方案了, 只要让 style 附值的函数返回 false,我们就可以阻止 babel-plugin-import 手动添加 css,所以最终的配置方案如下:

export default {
  extraBabelPlugins: [
    [
      'import',
      {
        libraryName: 'your component name',
        libraryDirectory: 'es',
        style: (name: string, file: any) => {
          /** 过滤一些不需要按需导入less文件的组件 */
          const reg = /\/use|\/RIcon|\/umi|\/utils/;
          if (reg.test(name)) {
            return false;
          }

          return `${name}/style`;
        },
        camel2DashComponentName: false,
        customName: (name: string) => {
          if (name.indexOf('use') === 0) {
            return `xxx/es/hooks/${name}`;
          }
          // 其他自定义过滤逻辑
          
          return `xxx/es/${name}`;
        },
      },
    ],
  ],
};

2.如何优雅的使用 Tree Shaking

sideEffects 本质是让 webpack(4以上版本) 去除 tree shaking 带来副作用的代码。具体内容如下:

  • tree shaking 基于 ES6 模块机制,我们引用不同的文件需要严格遵循 ES6 的模块规范

  • webpack 在编译阶段会 去除 那些 只读不写 或者不会被执行的代码。

sideEffects 支持两种写法,一种是 false,一种是数组。

  • false 告诉 webpack 这个 npm 包里的所有文件代码都是没有副作用的

  • 数组 则表示告诉 webpack 我这个 npm 包里指定文件代码是没有副作用的

webpack 在编译就会去读取这个 sideEffects 字段,如果有的话,它就会将所有引用这个包的副作用代码或者自身具有副作用的业务代码给去除掉。

一般情况下,我们直接在自己的组件库 package.json 里加上 ,webpack 在读取到 es 入口的时候,没被引用到的文件就不会被引入了。

当然我们也可以在项目中使用 sideEffects,配置方式稍有不同,我们需要在 webpack 的module/rules/babel-loader 中配置即可。

3.组件库的 package.json 配置总结

对于组件库的 package.json 配置,我们主要关注如下两点:

  • peerDependencies

  • gitHooks

peerDependencies 字面意思是预安装依赖,也就是安装组件库前必须先安装的依赖。之所以介绍这个配置项是因为在项目开发中遇到了一些坑。

为了使 Hook 正常工作,应用代码中的 react 依赖以及 react-dom 的 package 内部使用的 react 依赖,必须解析为同一个模块。如果这些 react 依赖解析为两个不同的导出对象,就会出现报错。

可以避免核心依赖库被重复下载,从而避免不同库依赖版本的问题,在组件库的 package.json 中我们可以将诸如 react,react-dom,antd 等放到 peerDependencies 中以保证公共库的一致性。配置如下:

"peerDependencies": {
    "umi": "^3.5.20",
    "antd": "^4.9.3",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }

另一个需要关注的是 gitHooks 配置项, 该配置项并不是 package 默认的, 而是尤大大在自己开发的包 中自定义的。它简化了原生 git hooks 的处理逻辑,使得我们可以在 gitHooks 配置项中对 git 生命周期的不同阶段进行处理,如:

  • pre-commit 预提交的检测

  • commit-msg 提交信息的校验

这块会在下面的内容中详细介绍。

组件库代码检测和提交规范

为了让组件库更健壮,我们需要制定一致的代码编写规范,市面上也有很多成熟的代码检测工具,比如 eslint 。由于我们项目是基于 umi 构建的,所以可以用 来做代码校验。具体配置流程如下:

module.exports = {
  extends: [require.resolve('@umijs/fabric/dist/eslint')],
  globals: {
    APP_TYPE: true,
    page: true,
  },
  rules: {
    extends: 'eslint:recommended',
    'no-console': 1, // 禁用console
    // 你的规则
  },
};
// package.json
{
    "scripts": {
        "lint:js": "eslint --cache --ext .ts,.tsx --format=pretty ./src",
    }
    "gitHooks": {
        "pre-commit": "npm run lint:js"
    }
}

这样,我们在 commit 之后就会自动验测代码,如果校验不通过则中断提交。

另一个规范就是提交规范。为了让团队每个成员的分支提交更规范和可溯源,我们可以约定 commit log 的格式, 目前业界有很多规范,这里我们采用 。这里我写了一个 node 脚本来校验,如下:

// commitlint.js
const yParser = require('yargs-parser');
const chalk = require('chalk');


// 截取命令行参数
const args = yParser(process.argv.slice(2));
const option = args._[0];


const judeCommitResult = () => {
  // 提取commit信息
  const msgPath = process.env.GIT_PARAMS || process.env.HUSKY_GIT_PARAMS;
  const msg = require('fs').readFileSync(msgPath, 'utf-8').trim();
  const commitRE =
    /^(((\ud83c[\udf00-\udfff])|(\ud83d[\udc00-\ude4f\ude80-\udeff])|[\u2600-\u2B55]) )?(revert: )?(feat|fix|docs|UI|refactor|⚡perf|workflow|build|CI|typos|chore|tests|types|wip|release|dep|locale)(\(.+\))?: .{1,50}/;


  if (!commitRE.test(msg)) {
    console.error(
        `  ${chalk.bgRed.white(' ERROR ')} ${chalk.red(`提交日志不符合规范`)}\n\n${chalk.red(
          `  合法的提交日志格式如下(emoji 和 模块可选填):\n\n`,
        )}    
    ${chalk.green(`💥 feat(模块): 添加了个很棒的功能`)}
    ${chalk.green(`🐛 fix(模块): 修复了一些 bug`)}
    ${chalk.green(`📝 docs(模块): 更新了一下文档`)}
    ${chalk.green(`🌷 UI(模块): 修改/优化了一下样式`)}
    ${chalk.green(`🔨 refactor(模块): 代码重构`)}
    ${chalk.green(`🏰 chore(模块): 对脚手架做了些更改`)}
    ${chalk.green(`🌐 locale(模块): 为国际化做了微小的贡献`)}
      );


    process.exit(1);
  }
};


switch (option) {
  case 'verify-commit':
    // eslint-disable-next-line global-require
    judeCommitResult();
    break;


  default:
    if (args.h || args.help) {
      const details = `
        Commands:
          ${chalk.cyan('verify-commit')}    检查 commit 提交的信息
        More:
        ${chalk.red(`See xxx\n`)}  
        `.trim();
      console.log(details);
    }
    break;
}

最后在 package.json 里配置:

"gitHooks": {
    "pre-commit": "npm run lint:js",
    "commit-msg": "node ./commitlint.js verify-commit"
  },

这样配置之后,如果你提交了一个不符合规范的信息,将会在命令行报如下错误提示:

组件库多包管理方案

拆分和抽象是个永恒的话题。随着企业业务的扩张, 我们需要设计一些相对独立的业务场景,这意味着新的设计规范接踵而来,我们可能需要多套组件方案,诸如:

  • 业务组件库

  • 区块组件库

  • js类库

  • 可视化组件库

  • xxx插件

传统的做法是采用 Multirepo 来管理, 也就是多个依赖包独立进行 git 管理。这种方式的缺点就是每个库改动后,需要发布到线上,然后重新安装才能使用,并且依赖关系越复杂就越难以维护。

至于 Monorepo 方案(所有依赖库完全放入一个项目工程)这里就不考虑了。为了更好的解决以上的问题我们决定采用 lerna 多包管理的方案。它作用是把多个项目拆分为多个 packages 放入一个 git 仓库进行管理。目录结构如下:

lerna-xxx/
  package.json
  packages/
    package-A/
      package.json
    package-B/
      package.json
    package-C/
      package.json

至于 lerna 的具体使用方式这里就不一一介绍了,官网有详细的使用文档,这里上一张我们的组件管理模式图:

组件库多环境发包实践

另一个需要考虑问题的是组件的发布环境问题。如果我们对组件库做了大的调整或者新功能的添加,为了更稳定的对外提供服务,我们需要建立不同的发布环境:

  • 测试包

  • 正式包

等等,这里我们就需要用到 npm 的 tag。这里简单介绍一下如何发布测试版:

"version": "1.0.0-beta.x"
npm publish --tag=beta
npm install xxx@beta.x

后期规划

解决企业研发效能是个永恒的话题。目前针对我们已有的业务发展规模,我们设计了如下的研发方向:

后续会持续的实践和探索新的可能性,利用技术增强人的智能。

欢迎加入:BOSS直聘搜“循环智能”,招聘专家会第一时间与您沟通〜