本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
作者:开膛手 eason
背景
前段时间入职了新公司后,做一些内部前端基建的工作,其中一个工作就是优化现有的 frontend-common 公共组件库。之前的组件库一直是以源码依赖的形式存在,即各个项目通过 git submodule 的方式将该仓库引入到各个项目中,作为一个目录,然后打包的时候将 frontend-common 的源码以及项目本身的代码一起打包到产物中。公共组件的运行依赖于宿主,要求引入 frontend-common 的项目 (宿主) 本身要安装依赖的包,否则无法运行,例如公共组件依赖 element 这个库,所以引入公共组件的项目也要求要安装 element 才可以运行。
分析
当前这种使用方式以及实际的落地方式上存在一些问题,这里简单罗列下
分支管理不规范(每个引用 frontend-common 的子项目都单独维护了一个分支,没有合入到主分支,导致各自的差异越来越大)
代码风格不统一(不同的开发的编辑器配置不一样,导致大家提交上来的代码五花八门)
组件没有文档和预览(写公共组件的开发实现之后就没有花更多时间在文档和预览上,导致其他开发要使用组件的时候有上手成本,而且不方便熟悉这些公共组件的功能和使用)
没有提交规范(因历史原因,不少提交的 commit message 上面都是随便写,没有什么规范,也不方便根据 commit 信息判断改动的内容)
无法保证改动不影响之前的一些功能(即无法保证能向下兼容,改动需要更多靠人工的方式来验证功能是否不影响之类的)
使用 submodule 的方式引入的方式不是很优雅(个人偏向于用 npm 包的方式,或者用 monorepo 的方式)
优化思路
根据上面存在的一些问题我们有针对性的做出一些调整和策略
分支管理规范,先让团队成员把各自的分支合并,如果只是单独自己项目用的组件,就迁移到自己项目的代码仓库中维护,不写在公共组件中。后续都从主分支拉新的分支进行开发,本地调试可以用自己的分支拉取代码调试,开发完之后合并到测试分支,线上环境和预发布环境必须用指定的分支来拉取公共组件库的代码。
用 eslint + prettier + husky + lint-stage 来保证代码风格统一
接入 storybook,用于做组件预览和文档的功能
增加 commitlint commitizen 等工具,用于命令式生成 commit,保证 commit 信息的规范
增加单元测试,新增一个组件要写单元测试,后续修改之后要保证之前的单元测试都运行通过才可以合并代码
因为内部基建的原因,暂时还没有搭建内部的 npm 源,monorepo 的方式改动也比较大,暂时不做调整
改造步骤
仓库初始化 npm
因为原先是作为当成一个组件来使用,所以 frontend-common 这个代码仓库里面是没有 package.json node_module 等配置,我们为了接入的规范肯定要增加包来处理这些,所以第一步要初始化 npm
npm init直接按提示输入即可,这里就不再赘述
增加代码规范的包
eslint + prettier + lint-stage + husky + 对应的 eslint 包 根据自己项目的实际情况增加对应的包,比如笔者这个仓库是用 vue2 的,就用 vue 相关的 eslint 包 这里笔者列一下自己安装的包和创建的配置文件
新增包和命令
在 package.json 中新增对应的包和命令、配置
"scripts": { ... "lint-staged": "lint-staged", "prepare": " husky install",},"lint-staged": { "*.{js,ts,vue,jsx,tsx}": [ "eslint --cache --fix" ]},"devDependencies": { ... "@commitlint/cli": "^17.6.5", "@commitlint/config-conventional": "^17.6.5", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^8.0.3", "husky": "^8.0.0", "lint-staged": "^13.2.2", "prettier": "^2.4.1",}新增配置文件
.eslintrc.js (eslint 的配置文件)
.eslintignore(eslint 的忽略配置文件)
.prettier.js (prettier 的配置文件)
commitlint.config.js (commitlint 的配置文件)
commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
};eslintrc.js
// .eslintrc.jsmodule.exports = { root: true, // 指定代码的运行环境 env: { browser: true, node: true, es6: true }, plugins: ['vue', 'prettier'], extends: [ // 继承 vue 的标准特性 'plugin:vue/essential', 'eslint:recommended', // 避免与 prettier 冲突 'plugin:prettier/recommended' ], parserOptions: { // 定义ESLint的解析器 parser: '@babel/eslint-parser', sourceType: 'module' }};处理 husky
配置完成之后记得npm i,这时候会添加. husky 目录,给这个目录添加文件 commit-msg
#!/usr/bin/env sh. "$(dirname -- "$0")/_/husky.sh"npx commitlint -e $GIT_PARAMSpre-commit
#!/usr/bin/env sh. "$(dirname -- "$0")/_/husky.sh"npm run lint-staged参考目录如下:
运行
这样配置完后续正常 commit 就会触发 eslint 和 commitlint,保证提交的代码和 commit 的规范。如果报错要修复完问题才可以正常提交,而且代码都会进行格式化,保证每个人提交的风格都一致。其他的不展开赘述。
接入 storybook
初始化 storybook
在原先的项目中执行命令初始化 storybook 的相关配置和依赖
npx -p @storybook/cli sb init --type vue选择 webpack5 和安装依赖
自动运行 storybook
打开浏览器,我们可以看到 storybook 的界面
来走读一下创建出来的 storybook demo 文件,我们以Button.stories.js这个文件为例
import MyButton from './Button.vue';// More on how to set up stories at: https://storybook.js.org/docs/vue/writing-stories/introductionexport default { title: 'Example/Button', component: MyButton, tags: ['autodocs'], render: (args, { argTypes }) => ({ props: Object.keys(argTypes), components: { MyButton }, template: '<my-button @onClick="onClick" v-bind="$props" />', }), argTypes: { backgroundColor: { control: 'color' }, size: { control: { type: 'select' }, options: ['small', 'medium', 'large'], }, },};// More on writing stories with args: https://storybook.js.org/docs/vue/writing-stories/argsexport const Primary = { args: { primary: true, label: 'Button', },};export const Secondary = { args: { label: 'Button', },};export const Large = { args: { size: 'large', label: 'Button', },};export const Small = { args: { size: 'small', label: 'Button', },};走读下 default 这个配置
title: 该故事在 Storybook 应用的侧边栏中的名称。路径式的名称表示故事的层级结构。在这个例子中,"Example" 是一个文件夹,"Button" 是这个文件夹下的一个故事。component: 这是你想要展示的组件,Storybook 将使用它来自动生成文档页(如果你启用了这个功能)。tags: 这是一个标签数组,你可以添加任何你喜欢的标签来帮助你组织和查找你的故事。render: 这是一个函数,返回一个 Vue 组件的配置对象,用于定义如何渲染故事。在这个例子中,所有的 args 和 argTypes 都被传递给MyButton组件,你可以在 Storybook 的 UI 中调整它们的值。argTypes: 这个对象定义了每个 arg 的控件和其他配置。在这个例子中,backgroundColor的控件是一个颜色选择器,size的控件是一个下拉列表,选项包括'small'、'medium' 和'large'。control: 用于指定参数控件的类型,例如:'color'、'select'、'range' 等。options: 用于指定'select' 类型控件的选项。
新建 story
新建一个 story,用于编写我们自己的组件的 story,如下,这个是我们新创建的 stories 文件,我们引入自己的 vue2 组件
先照猫画虎写一个配置
import CommonNoFound from "../../../components/commonPage/FcommonNoFound/index.vue";export default { title: "components/FcommonNoFound", component: CommonNoFound, tags: ["autodocs"],};export const NoFound = {};看下效果
这是因为我们的组件里面用了 vue-i18n,用 $t 然后 storybook 识别不到,这里我们就需要解决这个 vue-i18n 的问题
解决 vue-i18n
我们需要在.storybook/preview.js中设置 vue-i18n 相关的配置 看下原先的文件
/** @type { import('@storybook/vue').Preview } */const preview = { parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, },};export default preview;我们在这个文件的基础上增加 vue-i18n 的配置 要预先安装好vue vue-i18n,然后同 i18n 初始化一致实例化 i18n 实例然后设置到 storybook 中 看下代码
import Vue from "vue";import VueI18n from "vue-i18n";import en from "../lang/en_us";import zh from "../lang/zh_cn";Vue.use(VueI18n);const i18n = new VueI18n({ locale: "en", messages: { zh: { language: "简体中文", ...zh, }, en: { language: "English", ...en, }, },});/** @type { import('@storybook/vue').Preview } */const preview = { parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, }, decorators: [ (Story) => ({ components: { Story }, template: '<story v-bind="$props" />', i18n, }), ],};export default preview;在这个例子中,decorators 数组中的函数接收一个 Story 参数,这个参数表示当前的故事组件。然后,我们创建了一个模板,这个模板包含一个 story 组件,并且使用 v-bind 来绑定故事的属性。最后,我们在 components 对象中指定了 Story 组件。
这样,你的故事组件就会接收到 i18n 实例,并且会正确地被渲染。
解决环境变量问题
vue 代码里面会有环境变量,但是在 storybook 的环境中这个环境变量是没有的,所以我们需要手动设置这个环境变量,保证我们的代码可以正常运行 这时候我们需要一个包,我们安装 dotEnv 这个包
npm i dotenv --save-dev然后我们新建一个.env的文件,在这个文件中我们设置我们需要的环境变量,例如我的这个
VUE_APP_WEB_ENV=dev然后就是在我们的 storybook 的 mainjs 或者 preview.js 配置中引入 dotEnv 的配置即可
require("dotenv").config();
// 其他配置解决请求代理问题
我们在常见的 vue 项目中,本地开发会经常用 proxy 的配置来解决跨域问题,转发接口,当我们的组件中依赖了接口的话,这时候我们可以同样模拟一下这个 proxy 的过程 我们需要安装 proxy 的包
npm install http-proxy-middleware --save-dev然后我们在. storybook 目录下新建一个middleware.js,然后同我们的 webpack 配置一样补充我们需要的 proxy,这里补充下笔者接手的这个项目的配置
const { createProxyMiddleware } = require("http-proxy-middleware");module.exports = function expressMiddleware(router) { let env = process.env.VUE_APP_WEB_ENV || "dev"; const comonApi = require(`../config/api/${env}.js`); if (comonApi && Object.keys(comonApi).length) { Object.keys(comonApi).forEach((e) => { const apiBase = comonApi[e].apiBase; const apiRoot = comonApi[e].apiRoot; if (apiBase && apiRoot) { router.use( apiBase, createProxyMiddleware({ target: apiRoot, changeOrigin: true, pathRewrite: { [`^${apiBase}`]: "", }, }) ); } }); }};引入组件库
组件会依赖一些 UI 库的组件,比如笔者用到的 element ui,在 storybook 中需要引入这些 element 的组件,这里我们在. storybook/preview.js 中引入 element,参考如下
import ElementUI from "element-ui";import "element-ui/lib/theme-chalk/index.css";Vue.use(ElementUI);解决样式问题
引入组件会有一些样式,所以我们也需要处理下引入的 css,类似 webpack 一样增加对应的 loader,我们安装对应的 loader
npm install --save-dev sass-loader style-loader css-loader然后在.storybook/main.js文件中补充对应的 webpack 配置
const config = {
webpackFinal: async (config, { configType }) => {
// 处理 SCSS 文件
config.module.rules.push({
test: /\.scss$/,
use: ["style-loader", "css-loader", "sass-loader"],
});
return config;
},
}接入 commitizen
组件库之前的各种 commit 都是五花八门,这里为了规范 commit 信息,然后方便后面生成 changelog,我们这里需要一个命令式的 commit 提交工具,笔者选择了用commitizen,先安装好这个包
npm install --save-dev commitizen cz-conventional-changelog然后在我们的 package.json 中增加对应的 script 或者配置
"scripts": { "commit": "git-cz", ...},"config": { "commitizen": { "path": "cz-conventional-changelog" }},配合我们上面的 husky,我们可以在. husky 目录下新增 prepare-commit-msg 文件,然后补充下面的命令,在我们 git 的生命周期中触发 commitzen
#!/usr/bin/env sh. "$(dirname -- "$0")/_/husky.sh"exec < /dev/tty && npx cz --hook || true因为我们都是用 commitzen 生成的 commit 信息,上面原先. husky/commit-msg 可以考虑移除掉了,笔者只保留了两个
我们正常运行git add git commit就会触发下面这个,然后根据实际情况填写内容
全部填写完成之后就会生成对应的 commit 记录
生成 changelog(可忽略)
下面的自动升级版本的命令会自动生成 changelog,实际接入中可以不用看这一部分 changelog 就是根据我们的 commit 生成变更的日志,尝试效果的话我们需要引入新的包
npm install --save-dev conventional-changelog-cli在 package.json 中增加一个生成 changelog 的脚本,通过这个命令我们可以手动生成 changelog
{ "scripts": { "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s" }}版本升级
命令升级
我们需要在准备发版的时候,更新 package.json 中的版本号,生成 changelog 文件,提交更改和创建标签,这里我们需要用到第三方的工具包,这里用了standard-version
npm install --save-dev standard-version增加脚本
"scripts": { "release": "standard-version"}当我们准备发新版本的时候,就跑一下这个命令npm run release,这时候就会帮我们自动增加一个 commit 做上面说的事情,比如这样的 commit
因为standard-version这个包内置了生成 changelog 的包,所以我们不需要额外引入上面部分提到的conventional-changelog-cli
跳过检测
因为我们通过上面命令会自动提交一个 commit,但是我们的 commit 会触发我们的 eslint、commintlint 等,就像上面截图的那种命令式的 commit 界面,我们其实不需要再次手动输入,只需要 release 自己生成即可,所以这里我们要做的是跳过prepare-commit-msg这个阶段,思路是增加一个环境变量,然后跳过这个命令
.husky/prepare-commit-msg调整为
#!/usr/bin/env sh. "$(dirname -- "$0")/_/husky.sh"if [ "$HUSKY_SKIP_HOOKS" = "1" ]; then echo "Skipping prepare-commit-msg hook" exit 0fi exec < /dev/tty && npx cz --hook || truerelease 命令设置参数
"release": "HUSKY_SKIP_HOOKS=1 standard-version"兼容多平台
上面的设置参数在 mac 下是可以的,但是在 windows 下不行,为了兼容命令,这里我们需要增加cross-env安装这个包
npm i cross-env --save-dev更新 release 命令
"release": "cross-env HUSKY_SKIP_HOOKS=1 standard-version"接入单元测试
单元测试的作用
组件库会被多个项目引用,每个项目的情况不一样,可能需要根据本身项目的需求对组件进行修改或者增加一些改动,原则上改动都是要向下兼容的,每次组件库更新理论上引用的项目都要跟着更新,验证下改动是否没问题,但是考虑到每次都要让各个项目来验证这种成本比较高,所以引入单元测试,组件的创建人在写完组件之后,顺便根据自己的场景补充好单元测试。下一个修改的人如果要修改这个组件,修改完成之后,需要保证原先的单元测试都跑通过才可以,另外需要补充单元测试。
编写单元测试
我们在编写好 vue 组件之后,如果要对当前这个组件编写单元测试,可以在组件当前的目录(初定是和组件放在同一个目录下)创建对应的一个 xx.spec.js文件,然后在文件中编写对应的单元测试,可以参考项目中已有的单元测试文件,如下。
import { shallowMount } from "@vue/test-utils";import CommonNoFound from "./index";import { i18n, localVue } from "../../../jest.setup";describe("FcommonNoFound.vue", () => { it("can find list text", () => { const wrapper = shallowMount(CommonNoFound, { localVue, i18n }); expect(wrapper.text()).toContain("Lost..."); }); it("can find pageNotFound text", () => { const wrapper = shallowMount(CommonNoFound, { localVue, i18n }); expect(wrapper.text()).toContain("页面已飞到太空外"); });});运行与调试单元测试
我们在package.json中增加一个命令,用于运行单元测试
{ "scripts": { "test": "jest" }}运行单个单测文件,可以单独验证单测文件是否运行通过,可以在命令后面补充对应的单测文件路径
npm run test components/commonPage/FcommonNoFound/commonNoFound.spec.js运行结果,可以看到哪些通过哪些不通过,如果不通过会有报错信息,根据报错信息调整单测
全量运行,结果展示同上
npm run test单元测试卡点
有了单元测试之后,我们需要在每次提交合并的时候保证所有的单元测试都跑通过,否则就不给合并代码,相当于对每次合码都做一次卡点,减少一些改动无法向下兼容,导致引用组件的项目出现问题。
可以考虑使用自动化测试在每次 PR 或者 MR 的时候做运行所有的单元测试,检查测试覆盖率之类的,可以参考笔者之前的这篇文章
如果无法做自动化测试的话,可以考虑每次 PR 或者 MR 的时候要求提交人补充本地运行所有单元测试的结果,这里就可以通过配置一些 MR 或者 PR 提交的模板,要求代码提交人按这种格式来提交,补充好单元测试的截图之类的
合并代码策略
指定分支合并到对应的分支,例如合并到 release 或者 master 分支,这时候会有预置的模板,按照模板补充说明然后提交 PR 进行审核 以下是笔者的搞的一个合码的模板,要求提交人按这种格式去填写
组件预览部署
在上面的步骤中我们已经接入了 storybook,可以在本地预览,如果我们要单独把 storybook 单独部署一个到一个站点,其他开发可以直接打开去看
增加构建命令
在 package.json 中增加命令,构建出 storybook 的产物
"scripts": { "build-dev": "storybook build -o dist",}项目部署
配合运维,绑定好分支,然后当指定分支有 merge 或者 Push 的时候,触发构建,这个根据自己团队的情况去部署即可。 笔者部署完的大概样子如下:
总结
当前这版优化对现有的组件库做了一次大的调整,本身不涉及具体组件的改动,只是规范和优化整个流程,方便前端开发接入和使用等,但是还存在不少的优化空间,比如以 submodule 接入的方式,笔者觉得不是很好,还是偏向于用 npm 包的方式,但是由于内部还没有搞自建的 npm 源,加上不少项目都已经在用 submodule 的方式了,所以暂时不做这种处理。