前端工程化(一)从零开始搭建组件库

3,207 阅读14分钟

本文主要讲在搭建一个组件库过程中,从包工头对组件库进行项目管理,在脚手架对组件库进行项目构建,最后工具对项目规范的约束。同时也是作者对开发组件项目库中遇到的一些问题进行总结和常用工具的分享。

Lerna + Yarn 管理项目

盖大楼之前,我们先把地基打好,先构建好我们的项目工程。

对于一款组件库,我们把每个组件作为一个单独的package进行管理。对于维护过度的package的时候,都会遇到一个难题:这些package是单独用一个仓库维护即多项目多仓库的形式,我们也称该模式为Multirepo,还是使用一个项目仓库Repo中管理多个模块/包Package,我们称之为Monorepo

目前有不少大型开源项目采用了这种方式,如Taro,Babel,React, Meteor, Ember, Angular, Jest, Umijs, Vue等。几乎我们熟知的仓库,都无一例外的采用了monorepo的方式,可以看到这些项目的第一级目录的内容以脚手架为主,主要内容都在 packages目录中、分多个 package 进行管理。

目录结构如下:

├── packages
|   ├── pkg1
|   |   ├── package.json
|   ├── pkg2
|   |   ├── package.json
├── package.json

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/898bd74eb8a5428683aec83a866a3682~tplv-k3u1fbpfcp-zoom-1.image

我们来讲讲两者的优缺点

Multirepo:

  • 优点
    • 各模块管理自由度较高,可自行选择构建工具,依赖管理,单元测试等配套设施。
    • 各模块体积一般不会太大。
  • 缺点
    • 仓库分散不好找,当很多时,更加困难,分支管理混乱。
    • 版本更新繁琐,如果公共模块的版本发生了变化,需要对所有的模块进行依赖的更新。
    • CHANGELOG 梳理移除折腾,无法很好的自动关联各个模块的变动联系,基本靠口口相传。

Monorepo:

  • 优点
    • 一个残酷维护多个模块,不用到处找仓库。
    • 方便版本管理和依赖管理,模块之间的引用,调试都非常方便,配合相应工具,可以一条命令g搞掂。
    • 方便统一生产CHANGELOG,配合提交规范,可以在发布时自动生产CHANGELOG。
  • 缺点
    • 统一构建工具,对构建工具提出了更高要求,要能构建各种相关模块。
    • 仓库体积会变大。

基于两者的优缺点,结合组件库的特点:

  • 每个包之间是有相关依赖的。
  • 统一的构建工具,统一发版。
  • 对版本的说明要求较高

所以我们推荐采用Monorepo对组件库进行管理,目前最常见的Monorepo解决方案是LernaYarn的workspaces特性,基于Lerna和Yarn workspace的monorepo工作流。但是Lerna和Yarn在功能上游较多的重叠,我们采用Yarn官方推荐的做法,用Yarn来处理依赖问题,用Lerna来处理发布问题

安装Yarn

npm install -g yarn

安装Lerna

yarn install lerna -D

初始化Lerna项目

<!-- 进入项目文件夹 -->
cd project
<!-- 初始化 -->
lerna init

初始化后是这样的,lerna.json为Lerna的管理配置文件

prokect /
  packages /
  package.json
  lerna.json

然后采用Lerna的固定模式,并对packages/components下的所有组件进行包的管理

固定模式,通过lerna.json的版本进行版本管理。当你执行lerna publish命令时, 如果距离上次发布只修改了一个模块,将会更新对应模块的版本到新的版本号,然后你可以只发布修改的库。这种模式也是Babel使用的方式。如果你希望所有的版本一起变更, 可以更新minor版本号,这样会导致所有的模块都更新版本。

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

先把主体目录补全一下,目前到这一步,我们的项目仓库就是这个样子啦

project /
  packages /
    common /  公共库
    components / 组件库
      componentA /  组件A
        index.jsx
        index.scss
      componentB /  组件B
        index.jsx
        index.scss
    main.js 入口文件
  package.json
  lerna.json          lerna配置文件
  rollup.config.js    rollup配置文件
  babel.config.js   babel配置文件

Rollup 构建项目

地基打好了,作为打工人,脚手架怎么可以缺失呢,下面我们开始把脚手架构建起来

业内比较常用的组件库打包工具是Webpack,并且Webpack由于自身的使用场景,会更加适合UI组件库的项目的打包工作。但由于自身项目的原因,这里我采用了Rollup进行打包。

Rollup偏向应用于js库,webpack偏向应用于前端工程,UI库;如果你的应用场景中只是js代码,希望做ES转换,模块解析,可以使用Rollup。如果你的场景中涉及到css、html,涉及到复杂的代码拆分合并,建议使用webpack。

安装依赖

安装依赖

yarn add -W -D rollup rollup-plugin-terser rollup-plugin-progress  @rollup/plugin-alias  @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-replace @rollup/plugin-url

安装Babel相关依赖

yarn add -W -D @rollup/plugin-babel @babel/core @babel/preset-env @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread

安装样式处理相关依赖

yarn add -W -D rollup-plugin-postcss postcss-plugin-pxtoviewport postcss-url autoprefixer rollup-plugin-postcss-inject-to-css

介绍下常用的插件

常用:

插件说明
rollup-plugin-terser帮助我们在打包过程中实现代码压缩
rollup-plugin-progress输出Rollup打包的进度
@rollup/plugin-alias提供了为模块起别名的功能,用过webpack的小伙伴应该对这个功能非常熟悉
@rollup/plugin-commonjs帮助我们支持CommonJS模块
@rollup/plugin-json帮助我们把.json转换成ES6模块
@rollup/plugin-node-resolve帮助我们在node_modules中找到并捆绑第三方依赖项
@rollup/plugin-replace捆绑文件时替换文件中的字符串
@rollup/plugin-url内联导入文件作为数据URI,或将其复制到输出目录

Babel相关:

插件说明
@rollup/plugin-babel帮助我们对ES6等语法进行编译转换成ES5
@babel/preset-env一个智能预设,可让您使用最新的JavaScript,而无需微观管理目标环境所需的语法转换(以及可选的浏览器polyfill),JavaScript包更小
@babel/plugin-proposal-decorators允许使用修饰器的jsx语法
@babel/plugin-proposal-class-properties用于编译类组件
@babel/plugin-proposal-object-rest-spread用于使用spread操作符

处理样式相关:

插件说明
rollup-plugin-postcss用于处理sass/less/stylus
postcss-plugin-pxtoviewport把px换成为vw单位
postcss-url处理样式url()图片,复制到输出目录或者转成Base64等操作
autoprefixer自动给样式属性添加-webkit-等前缀用于兼容浏览器
rollup-plugin-postcss-inject-to-css把postcssinject模式下的样式引用从内联转换成外联

Rollup的配置就不详细说明了,具体的看代码就好了:

// rollup.config.js

import NodePath from 'path'
import autoprefixer from 'autoprefixer'
import url from 'postcss-url'
import px2vw from 'postcss-plugin-pxtoviewport'
import RollupJson from '@rollup/plugin-json'
import RollupAlias from '@rollup/plugin-alias'
import RollupUrl from '@rollup/plugin-url'
import RollupBabel from '@rollup/plugin-babel'
import RollPostcss from 'rollup-plugin-postcss'
import RollPostcssInject2Css from 'rollup-plugin-postcss-inject-to-css'
import RollProgress from 'rollup-plugin-progress'
import RollupCommonjs from '@rollup/plugin-commonjs'
import RollupNodeResolve from '@rollup/plugin-node-resolve'

// babel 配置
const rollBabelConfig = {
  babelHelpers: 'runtime'
}
// postcss 配置
const rollPostcssConfig = {
  inject: true,
  plugins: [
    url({
      url: 'inline',
      maxSize: 1000000  // 所有图片转成base64
    }),
    px2vw({
      viewportWidth: 750, // 视窗的宽度,对应的是我们设计稿的宽度,一般是750
      unitPrecision: 3, // 指定`px`转换为视窗单位值的小数位数(很多时候无法整除)
      viewportUnit: 'vw', // 指定需要转换成的视窗单位,建议使用vw
      selectorBlackList: ['.ignore', '.hairlines'], // 指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名
      minPixelValue: 1 // 小于或等于`1px`不转换为视窗单位,你也可以设置为你想要的值
    }),
    autoprefixer({
      remove: false
    })
  ],
  extensions: ['.css', 'scss']
}

export default {
  input: './packages/main.js',
  output: [{
    format: 'es', // 打包成es模块
    dir: './lib', // 输出到 lib 文件夹
    preserveModules: true,  // 保留模块,尽可能多的输出chunk
    preserveModulesRoot: 'packages'
  }],
  external:  [
    'react',
    'react-dom'
  ],
  plugins: [
    RollupAlias({
      entries: [{ find: '@', replacement: NodePath.join(__dirname, 'packages') }]
    }),
    RollupBabel(rollBabelConfig),
    RollPostcss(rollPostcssConfig),
    RollupNodeResolve({
      customResolveOptions: {
        moduleDirectory: 'node_modules'
      },
      rootDir: NodePath.join(__dirname, '.'),
      browser: true
    }),
    RollupCommonjs({
      include: /\/node_modules\//
    }),
    RollupJson(),
    RollupUrl(),
    RollProgress(),
    RollPostcssInject2Css({
      exclude: /\/node_modules\//
    })
  ]
}

Babel配置

// babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env', {
        modules: false,
      }
    ]
  ],
  plugins: [
    [
      '@babel/plugin-proposal-decorators', {
        legacy: true,
      }
    ],
    '@babel/plugin-proposal-class-properties',
    '@babel/plugin-proposal-object-rest-spread',
    '@babel/plugin-transform-runtime'
  ]
}

优化打包

文件压缩

Rollup会统计引入的代码并排除掉那些没有被用到的。这使您可以在现有工具和模块的基础上构建,而无需添加额外的依赖项或膨胀项目的大小。

除了Rollup自身的摇树优化外,还需要对打包的代码进行压缩,以便最小化代码体积。这里我们采用rollup-plugin-terser这款插件。

安装

yarn add -D -W rollup-plugin-terser

使用插件

import { terser } from "rollup-plugin-terser"
export default {
  ...其他配置
  plugins: [
    ...其他插件配置
    terser()
  ]
}

不过一般在项目工程中,webpack都用uglifyjs-webpack-plugin或者terser-webpack-plugin进行代码的压缩,所以其实组件库压不压缩问题不大。不压缩的话,开发者可以通过node_modules在阅读源码调试时可能会舒服很多。

按需引入

一款组件库往往会有比较多的组件,随着组件越来越多,整个组件库打包后的体积也会越来越大,这是非常致命的,组件库使用者在绝大部分情况下都只会引入个别模块。所以组件按需加载十分必要。目前antd-uielement-ui等组件库都提供了按需加载的功能

组件按需引入

要实现组件的按需引入比较简单,得益于Rollupouput.preserveModules属性,可以在打包时,把模块经过编译进行保留并输出到ouput目录上。

如下面一个例子:

// 源码:
project /
  packages /
    components / 组件库
      componentA /  组件A
        index.jsx
        index.scss
      componentB /  组件B
        index.jsx
        index.scss
    main.js 入口文件

main.js

// 入口文件 main.js 导出组件
export { default as componentA } from './components/componentA'
export { default as componentB } from './components/componentB'
  • 在不使用preserveModules的的导出效果
lib /
  main.js

会把所有组件都打包到一个文件内

  • 使用preserveModules的的导出效果
lib /
  components / 组件库
    componentA /  组件A
      index.js
    componentB /  组件B
      index.js
  main.js 入口文件

在使用上其实都是

import { ComponentA, ComponentB } from 'xxx'

但是如果使用了preserveModules后,我们就可以单独引入组件模块了

import ComponentA from 'xxx/components/componentA

组件按需引入的问题解决了,但是在实际开发项目工程中,如果每个组件都要写这么长的路径,非常麻烦啊。

这里我推荐一个webpack的插件webpack-plugin-import,他可以避免开发过程中开发者需要手动引入组件样式,通过工程的方式自动引入。

注意,这里指的是开发项目去引入组件的过程,不是组件库项目

安装

yarn add -D -W webpack-plugin-import

使用

// babel.config.js
plugins: [
  [
    'import',
    {
      libraryName: 'xxx',
      camel2DashComponentName: false, // 是否需要驼峰转短线
      customName (name) {
        return `xxx/components/${name}`
      }
    }
  ]
]

效果

// 源码的引入方式:
import { ComponentA, ComponentB } from 'xxx'
// 经过babel插件转换后:
import ComponentA from 'xxx/components/componentA
import ComponentA from 'xxx/components/componentA

来到这里,组件的按需引入终于大功告成了。

样式文件的按需引入

Rollup-plugin-postcss插件中有两种样式的打包输出模式

  • extract 打包捆绑出一个入口css文件
  • inject 讲样式通过js模块动态把组件样式插入到<style>标签内

样式文件进行模块拆分的话,我们一般使用Rollup-plugin-postcss插件中的options.inject属性,及把组件引入的样式进行内联插入。

// componentA index.js
import 'A.css.js'
// componentA A.css.js
// 以es6模块的形式导出样式,会把样式插入到<head>中的<style>标签里面
export default injectStyle('.classA {height: 20px} .classA__text {font-size: 16px}')

但是使用inject会带来一个问题,就是<style>标签的权重比较大,在组件使用者去使用组件的时候,往往需要设置比较大的样式权重去覆盖样式。

为了解决这个问题,这里可以使用rollup-plugin-postcss-inject-to-css插件,把样式内联引入变成外联引入。

使用rollup-plugin-postcss-inject-to-css插件后:

// componentA index.js
import 'A.css'
// componentA A.css
.classA {height: 20px} .classA__text {font-size: 16px}

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c29151231b194ec7a058ec54785993c9~tplv-k3u1fbpfcp-zoom-1.image

约束规范

组件库的管理和构建功能都有了,你以为等着开发组件就完事了吗?NoNoNo... 作为打工人,去造一个房子,处理有包工头-管理脚手架-构建工具外,还需要很多打工仔-开发同学。多人共建的一个项目中,必须得有一个规范去进行约束,而规范除了书面上的阐述外,还需要一些自动化的、强制性的约束。

项目常用的约束规范有 eslint进行代码的格式和书写规范、stylelint进行样式的属性规范。但是总有打工仔是是喜欢偷懒的,也经常会有一些由于编辑器配置不当导致eslint没有生效等等等这些操作。还有一些更过分的,每次文件保存都会自动根据自己编辑器的配置爱好进行文件自动格式化o(╥﹏╥)o。这时候一提交到git仓库,完了完了...别的人等着一堆冲突需要解决吧。。。

所以我们需要在代码提交前对每个打工仔的代码进行一遍验证,防止乱提交。

husky Git提交钩子

这里我们使用的是husky来给GIT提交添加钩子执行一些我们需要做的验证,并执行一些脚本去验证代码是否有问题是否规范。

安装

npm install husky -D

使用

// package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "npm test",
      "pre-push": "npm test",
      "...": "..."
    }
  }
}

当在使用git commit进行提交时,就会在commit/push前,就会先跑一下npm test进行提交前的测试。

lint-staged

lint-staged 是一个在git暂存文件上运行linters的工具。一般可以搭配刚刚提到的husky进行git commit前的代码校验。

安装

npm install lint-staged -D

使用

// packages.json
{
  "lint-staged": {
    "**/*.(js|jsx)": [
      "prettier --config .prettierrc --write",
      "eslint --fix",
      "git add"
    ],
    "**/*.scss": [
      "prettier --config .prettierrc --write",
      "stylelint --syntax=scss --fix",
      "git add"
    ],
    "**/*.css": [
      "prettier --config .prettierrc --write",
      "stylelint --fix",
      "git add"
    ]
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  }
}

这里我的配置是 在git commit之前,先对目录下的代码进行eslintstylelint的校验,以及使用prettier进行代码的统一格式化。保证我们提交到GIT仓库的都是风格一致的。

commitlint 提交信息校验

在有了Husky赋能之后,我们有能力在Git的钩子里做一些事情,首先不得不提的是代码的提交规范和规范的校验,优雅的提交,方便团队协作和快速定位问题。

一般GIT比较常见的提交格式规范是:<type>: <subject>

常用的type类别

  • upd:更新某功能(不是 feat, 不是 fix)
  • feat:新功能(feature)
  • fix:修补bug
  • docs:文档(documentation)
  • style: 格式(不影响代码运行的变动)
  • refactor:重构(即不是新增功能,也不是修改bug的代码变动)
  • test:增加测试
  • chore:构建过程或辅助工具的变动

安装

npm i -D commitlint @commitlint/cli @commitlint/config-conventional"

使用

// packages.json
{
  ...
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
}

当然每次提交都手动输入可能有些烦,所以我们可以用 git cz

Commitizen 和 CHANGELOG

Commitizen安装

npm install -g commitizen

安装changelog,生成changelog的工具

npm install -g conventional-changelog conventional-changelog-cli

检验是否安装成功:

npm ls -g -depth=0

运行下面命令,使其支持Angular的Commit message格式:

commitizen init cz-conventional-changelog --save --save-exact

进入到项目目录,执行以下命令生成CHANGELOG.md文件:

conventional-changelog -p angular -i CHANGELOG.md -s

使用Commitizen后,以后但凡需要用到git commit的时候直接改成git cz,然后就会出现选项进行commit message的填写,最终生成符合格式的提交信息了。

结语

本文章主要梳理了我从零开发一款组件库遇到的问题踩过的坑总结下来并实际使用的工具和方法,希望能给大家带来帮助。

做一个团队基建,从找包工头去管理项目,到搭脚手架去方便搭建,再到规范打工仔的施工作业。都是一步步踩坑走来。除了上面提到的这些比较常用的工具和方法外,对于前端工程化/自动化还有很多探索。包括CI/CD对提交进行更多的验证等等。

参考文献:

  1. Lerna+yarn项目的最佳实践 [blog.csdn.net/i10630226/a…]
  2. lerna的基础使用 [www.jianshu.com/p/8b7e60253…]
  3. git cz Commitzen使用方法 [segmentfault.com/a/119000002…]
  4. Rollup中文文档 [segmentfault.com/a/119000002…]
  5. GitHook 工具 - huksy配置 [blog.csdn.net/huangpb123/…]