本文只是记录公司屎山代码是如何一点点被修改成未来可期的代码
什么是aicc-components?
aicc-components 是一个集大成者的仓库。服务于aicc的各个项目。
为什么要做渐进式升级?
- 时间跨度长,历史包袱技术债务
-
功能定位不清晰。组件库?业务组件库?工具库?接口库?store库?
-
难以快速响应交互和UI的升级。
- 组件封装水平层次补齐,拓展能力差。魔改和定制开发太多。
当前最迫切的问题是?
- 没有文档,不知道用哪个,不知道怎么用
- 组件实现水平参差不齐,很多hardcode,拓展性不高
- UI和交互的优化,组件无法快速支持,甚至无法支持
明确目标
- 让 aicc-components 好用
- 让 aicc-components 好改
怎么做?
- 搞文档
- 写组件
使用 Storybook 编写和管理文档
什么是Storybook 0x1 基本概念
Storybook 是一个用于UI开发的开源工具,通过隔离组件,使开发更快、更容易。它提供了一套完整的开发流程,你可以不用配置一个复杂的开发环境、不用和数据库交互,也不需要和你的应用程序关联。
Storybook可以帮你记录组件的文档,以便重复使用,并自动对你的组件进行视觉测试来防止出现bug。同时Storybook还有一个插件的生态系统来扩展自身的能力,如微调响应式布局或验证可访问性。支持多种前端框架,甚至目前还在进行试验性的支持服务端渲染组件框架。
选型过程
背景
现阶段的业务组件库aicc-components是基于Element-ui的二次封装,包含请求api文件,配置脚本,以及业务组件;该项目是一个独立的项目,其他项目使用时需要通过git下载到项目中,实现组件使用,但存在以下缺点。
- 可维护性不强,项目无法独立启动。
- 易用性不高,无包含组件示例的在线文档。
目标:
通过加入在线文档的方法提升组件库的维护性及易用性,帮助新人及更多开发成员快速熟悉业务,方便开发。
方案
工具选型
社区中有很多组件文档方案,选择三个比较热门的,社区比较活跃的工具做一个对比
- Storybook
- Docz
- Dumi
Storybook
Storybook 是一个用于UI开发的开源工具,通过隔离组件,使开发更快、更容易。它提供了一套完整的开发流程,你可以不用配置一个复杂的开发环境、不用和数据库交互,也不需要和你的应用程序关联。
Storybook可以帮你记录组件的文档,以便重复使用,并自动对你的组件进行视觉测试来防止出现bug。同时Storybook还有一个插件的生态系统来扩展自身的能力,如微调响应式布局或验证可访问性。支持多种前端框架,甚至目前还在进行试验性的支持服务端渲染组件框架。
Dozc
Docz
是一个高效、零配置的事件记录工具。Docz
基于 MDX
,有许多内置的组件可以帮助你记录你的事情。它同时支持添加插件
Dumi
dumi
是一款为组件开发场景而生的文档工具。其具备开箱即用,将注意力集中在组件开发和文档编写上、基于 TypeScript
类型定义,自动生成组件 API
、移动端组件库编写及多语言支持。
经过调研,整理出如下表格:
框架 | start数量(2022-4-19) | 支持编写的组件库类型 | 文档格式 | 插件系统 | 自动化测试 |
---|---|---|---|---|---|
Storybook | 70.2k | Vue, React, Angular… | .js,.md,.mdx | 支持 | 支持 |
Docz | 22.5k | Vue, React, Angular… | mdx | 支持 | 不支持 |
Dumi | 2.2k | React | mdx | 支持 | 不支持 |
- Dumi首先排除,社区活跃度不高。出自阿里,容易成坑。Docz和Storybook来自社区,拥有活跃的社区。
- 从社区的活跃角度来看Storybook是首选。同时Storybook除了文档的展示之外,还提供了完整的数据调试和UI自动化测试的能力,开箱即用。
- 可按需开发定制的addon,以Storybook为载体,管理和呈现AICC的设计系统。
- 同时Storybook支持多种前端框架,在AICC组验证之后,可以复用到SCRM项目。
综上所述,所以最后选择Storybook。
和设计团队一起落地设计规范
什么是设计系统、原子设计
设计系统的定义是一系列文档元素、组件和区域、设计和前端指南的等完整的标准。有了设计系统,公司内各部门可以轻松地在应用程序的多个实例中重复使用样式和组件,快速构建一个或多个产品,从而简化大规模设计。
A design system is a collection of reusable components, guided by clear standards, that can be assembled together to build any number of applications ——Design better
简单来说,设计系统就是一套完整的标准,提供可复用的组件和模式来管理和简化设计和开发。
业界已经有很多成功的案例,比如像Google的Material Design,Spotify 的 Encore System 抖音的Semi Design等等。这些公司借助他们的设计系统,成功的改变了他们设计和开发产品的方式,通过一组可重用的组件,以及一套指导这些组件使用的规范,可以快速的完成设计和开发工作。
为什么需要设计系统
设计的标准化带来的是更高的效率和更好的质量:
-
可以快速、大规模地创建和复制设计(和开发)工作。
- 设计系统的主要好处是它们能够利用预制的 UI 组件和元素快速复制设计。团队可以继续一遍又一遍地使用相同的元素,极大减少重复开发的工作,同时还能保证输出的一致性。
-
减轻了设计资源的压力,专注于更大、更复杂的问题。
- 由于已经创建了更简单的 UI 元素并且可以重用,因此设计资源可以更少地关注调整视觉外观,而更多地关注更复杂的问题(如信息优先级、工作流优化和数据管理等)。
-
在跨职能团队内部和之间创建了一种统一的语言。
- 统一的语言可以减少因沟通不畅而浪费的设计或开发时间。例如,因为在设计系统中明确定义了某组件或者某交互的形式,大家对此形成默契,减少了多余的争议。
-
保证产品的视觉一致性。
- 设计系统提供了组件、模式和样式的单一来源,并统一了脱节的体验,使它们在视觉上具有凝聚力,并且似乎是同一生态系统的一部分。作为额外的奖励,任何主要的视觉品牌重塑或重新设计都可以通过设计系统进行大规模管理。
-
它可以作为设计师和内容贡献者的教育工具和参考
- 明确编写的使用指南和样式指南可帮助不熟悉 UI 设计或内容创建的个人贡献者入职,并为其他贡献者提供提醒。
什么是原子设计(Atomic Design)
在2013年网页设计师Brad Frost从化学中受到启发提出了原子设计的概念。
化学反应由化学方程式表示,化学方程式通常显示原子元素如何结合在一起形成分子。在上面的例子中,我们看到了氢和氧如何结合在一起形成水分子。
在宇宙中,原子元素结合在一起形成分子。这些分子可以进一步结合形成相对复杂的有机体。反过来宇宙中的所有物质都可以分解为一组有限的原子元素。
- 原子是所有物质的基本组成部分。每种化学元素都有不同的特性,它们不能被进一步分解而不会失去意义。
- 分子是两个或多个原子通过化学键结合在一起的基团。这些原子的组合具有它们自己独特的特性,并且比原子更加有形和可操作。
- 有机体是作为一个单元一起发挥作用的分子的集合体。这些相对复杂的结构可以从单细胞生物一直到人类等极其复杂的生物。
类比Web开发,任何一个网页都是由许许多多相同和不同的HTML元素组成。
同理的,原子设计应运而生。原子设计是一种方法论,由原子、分子、组织、模板和页面共同协作以创造出更有效的用户界面系统的一种设计方法。
就像在化学中一样,原子是我们系统中最小的组成部分。而不是像氧或氢这样的原子,在设计中我们有按钮、输入、标签和其他在我们的设计中使用的小元素。
什么是 Design Token
而今天介绍的Design Token 则属于比原子还小的力度,是设计师可以创建的最小样式值。token映射的是构建和维护设计系统所需的所有值——间距、颜色、排版、对象样式、动画等。用于代替硬编码值,直接集成到我们的组件库和 UI 工具包,以确保所有产品体验的灵活性和统一性。
Design Token 这个概念最初由Salesforce设计系统团队创建。他们发现,如果在现有设计元素之上建立一个新的数据层并从一个地方管理它们,可以使用一个系统将其一致地扩展到所有平台。Salesforce 将 Design Token 运用在公司的 Lightning Design System 中,甚至开发了Theo来帮助他们更快捷地使用Design Token。Salesforce 定义的 Design Tokens 包含:
- spacing
- sizing
- rounded corners
- font (字体) weights
- line heights
- font (字体) families
- border colors
- background colors
- text color
- shadows
- animation durations等
从前端开发的角度来看,Design Token 就是一个变量。它是可存储的,可组织的,集中式的,可传播的。
- 可存储的。在开发人员将Design Token 转换为对应的CSS之前,可以通过JSON或者YAML的文件形式将Token保存在文件中。
- 可组织的。通过合理的方式管理Token, 定义一些规则和优秀的模式。比如将 #2b7de8 保存在名为
blue-base
的token中,我们可以将token与实际组件或者业务加上关联,让token更加语义化。使比如 header-color、primary-color等等。
- 集中式的。Design Token 是设计系统中的基础一环,可以在规范和流程中集中管理。对所有人来说都能很方便的使用。
- 可传播的。Design Token 是设计和开发过程中的一部分。在这个过程中,可以用各种格式进行转换,例如CSS变量,Less变量,亦或者Andriod 或者IOS的变量。这样一来,可以被不同的产品、应用程序和技术使用,并保证可维护性和一致性。
自 Design Token 这个概念诞生出来,很多团队都在研究并实践。不同团队的 Design Token 的设计和分类,以及使用的各类工具都是不尽相同。例如常见的设计工具包括 Photoshop,Sketch,Figma等。Token翻译工具也有很多,Theo,Style Dictionary,Dize,Specify 等。
在2019年七月,W3C社区发起了 Design Tokens Community Group 。目标是提供一套标准,让产品和设计工具可以按照这个标准共享设计系统的设计风格。
在W3C的现阶段的标准中,将 Design Token 定义为一种方法论:
Design tokens are a methodology for expressing design decisions in a platform-agnostic way so that they can be shared across different disciplines, tools, and technologies. They help establish a common vocabulary across organisations.
设计令牌是一种方法论,用于以与平台无关的方式表达设计决策,以便它们可以在不同的学科、工具和技术之间共享。它们有助于建立跨组织的通用词汇。
因为尚处于草案阶段 design-tokens.github.io/community-g… ,就不在此赘述太多文档上的内容,有兴趣的朋友可自行阅读。
说了这么多,相信大家对 Design Token 应该有自己的理解了。已经知道“是什么“,接下来看看”该怎么做“。
创建和交付 Design Token
设计师使用的是 Figma Tokens 插件,它是一款基于 Figma 的插件,相对于 Figma 右侧面板原生自带的样式外,能够实现多层级的 Token 管理,同时插件内容能够与 Figma 设计文件实现实时联动。
使用插件后,设计师可以导出Token的JSON文件,文件内容大致如下:
{
"sizing": {
"10": {
"value": "10",
"type": "sizing"
},
"12": {
"value": "12",
"type": "sizing"
},
"14": {
"value": "14",
"type": "sizing"
},
"16": {
"value": "16",
"type": "sizing"
},
"18": {
"value": "18",
"type": "sizing"
},
"20": {
"value": "20",
"type": "sizing"
},
"22": {
"value": "22",
"type": "sizing"
},
"24": {
"value": "24",
"type": "sizing"
},
"28": {
"value": "28",
"type": "sizing"
},
"32": {
"value": "32",
"type": "sizing"
},
"36": {
"value": "36",
"type": "sizing"
},
"40": {
"value": "40",
"type": "sizing"
},
"48": {
"value": "48",
"type": "sizing"
}
}
}
随后将JSON文件交付给开发人员,开发人员将JSON转换成需要的文件格式,比如Web端可以转换成Scss文件、Less文件或者CSS自定义属性的文件。
使用Style Dictionary 处理Design Token
社区上处理和解析Design Token的工具很多,这有一个可参考的目录。比较受欢迎的有 Style Dictionary、Theo 等。我选择 Style Dictionary,因为社区受关注度更高,更新也比较及时,最近正在计划做4.0版本。
Style Dictionary 是一个构建系统。使用它可以让你一次性定义风格,供任何平台或语言使用。在一个地方可以创建和编辑你的样式,通过一个命令就可以把这些规则导出到你需要的所有地方,iOS、Android、CSS、JS、HTML、Sketch 文件、样式文档,或者任何你能想得到的地方。它可以通过npm作为CLI使用,也可以像普通的Node.js模块一样使用。
简单案例,快速上手
为了帮助开发者快速上手,官方很贴心地提供了一些实例。让我们来看一下简单的实例,从中了解Style Dictionary 到底做了什么事情。随便找个目录,执行以下命令。
$ mkdir MyStyleD
$ cd MyStyleD
$ style-dictionary init basic
这个命令先将仓库中的实例文件复制到本地目录,然后执行 style-dictionary build
,生成构建的产出物。不出意外的话,你会在你的控制台中看到这些输出:
Copying starter files...
Source style dictionary starter files created!
Running `style-dictionary build` for the first time to generate build artifacts.
scss
✔︎ build/scss/_variables.scss
android
✔︎ build/android/font_dimens.xml
✔︎ build/android/colors.xml
compose
✔︎ build/compose/StyleDictionaryColor.kt
✔︎ build/compose/StyleDictionarySize.kt
ios
✔︎ build/ios/StyleDictionaryColor.h
✔︎ build/ios/StyleDictionaryColor.m
✔︎ build/ios/StyleDictionarySize.h
✔︎ build/ios/StyleDictionarySize.m
ios-swift
✔︎ build/ios-swift/StyleDictionary+Class.swift
✔︎ build/ios-swift/StyleDictionary+Enum.swift
✔︎ build/ios-swift/StyleDictionary+Struct.swift
这意味着你已经成功运行了这个实际的例子。回头看看当前你操作的文件目录,它应该长这样:
├── README.md
├── config.json
├── tokens/
│ ├── color/
│ ├── base.json
│ ├── font.json
│ ├── size/
│ ├── font.json
├── build/
│ ├── android/
│ ├── font_dimens.xml
│ ├── colors.xml
│ ├── compose/
│ ├── StyleDictionaryColor.kt
│ ├── StyleDictionarySize.kt
│ ├── scss/
│ ├── _variables.scss
│ ├── ios/
│ ├── StyleDictionaryColor.h
│ ├── StyleDictionaryColor.m
│ ├── StyleDictionarySize.h
│ ├── StyleDictionarySize.m
│ ├── ios-swift/
│ ├── StyleDictionary.swift
│ ├── StyleDictionaryColor.swift
│ ├── StyleDictionarySize.swift
Style Dictionary 由配置驱动,必须包含一份config.json和配置中引用的design token对应的文件。
- config.json:style dictionary 依赖的配置。告诉 style dictionary去哪里找design tokens,以及如何转换和格式化输出文件。
{
"source": ["tokens/**/*.json"],
"platforms": {
"scss": {
"transformGroup": "scss",
"prefix": "sd",
"buildPath": "build/scss/",
"files": [{
"destination": "_variables.scss",
"format": "scss/variables"
}],
"actions": ["copy_assets"]
},
"android": {
"transforms": [
"attribute/cti",
"name/cti/snake",
"color/hex",
"size/remToSp",
"size/remToDp"
],
"buildPath": "build/android/src/main/res/values/",
"files": [{
"destination": "style_dictionary_colors.xml",
"format": "android/colors"
}]
}
}
}
- design tokens: 定义了 design token的一系列JSON或者JavaScript Module文件。会在config.json 中的source属性中使用。
{
"size": {
"font": {
"small": {
"value": "10px"
},
"medium": {
"value": "16px"
},
"large": {
"value": "24px"
},
"xl": {
"value": "34px"
},
"xxl": {
"value": "46px"
},
"base": {
"value": "{size.font.medium.value}",
"attributes": {
"comment": "All about that base"
}
},
"heading": {
"1": {
"value": "{size.font.xxl.value}"
},
"2": {
"value": "{size.font.xl.value}"
}
}
}
}
}
Style Dictionary 的架构
为了更好地理解style dictionary的能力和工作原理,让我们来看看它的架构设计。下面是官方给到的架构图。
一图胜千言,这张图已经很直观地展示其工作原理。
- 第一步:解析配置文件。
- 第二步:找到所有 token 文件。config中的
include
和source
组合决定了搜索查找的范围。
- 第三步:将所有的token文件进行一个深度合并。相同的结构将会覆盖。
-
第四步:遍历config中定义所有platform,执行一下操作:
- 执行 token转换,提取token中的value。这会深度遍历每一个对象,直到找到
value
这个key。 - 解析别名和引用。找到
value
之后,如果其值是别名或者引用,比如"{size.font.base.value}”
,就用转换后的值替换之。 - 根据每一个platform配置中的format格式,输出不同的文件。
- 在输出文件之后,还可以执行自定义的Actions。
- 执行 token转换,提取token中的value。这会深度遍历每一个对象,直到找到
当上述四个步骤都成功执行并结束后,你就能得到你想要的输出产物了。
与设计师协作
设计师可以通过 Figma 等设计工具,以文件的形式将 design token 提供给开发人员。现在,身为开发人员的我们,可以使用 Style Dictionary 对token进行处理,输出我们想要的文件。
我们将所有的tokens文件托管到内部的gitlab仓库中。设计师定义好token之后,将文件上传到仓库,然后告知到对应的工程师。工程师使用基于Node.js开发的脚本,实现token的下载和转换输出。token统一维护在这里
使用 Node API 增强定制能力
style dictionary 提供了比较强大的 Node API,你可以使用这些API实现一些更复杂的能力,满足更多的需求场景,这里是API的文档,感兴趣的朋友可以仔细阅读。接下来我会结合实际案例,向大家演示如何使用Node API。
先从一个简单的例子开始,新建一个JavaScript文件。引入依赖之后,调用extend
传入配置。然后调用返回实例的buildAllPlatforms
方法。
// build.js
const StyleDictionary = require('style-dictionary').extend('config.json');
StyleDictionary.buildAllPlatforms();
extend方法的参数可以是一个文件路径,也可以是个对象。
// build.js
const StyleDictionary = require('style-dictionary').extend({
source: ['properties/**/*.json'],
platforms: {
scss: {
transformGroup: 'scss',
buildPath: 'build/',
files: [{
destination: 'variables.scss',
format: 'scss/variables'
}]
}
// ...
}
});
StyleDictionary.buildAllPlatforms();
你可以多次调用extend和buildAllPlatforms方法,可以用在输出嵌套主题或者多品牌主题等类似的场景。
// build.js
const StyleDictionary = require('style-dictionary');
const brands = [`brand-1`, `brand-2`, `brand-3`];
brands.forEach(brand => {
StyleDictionary.extend({
include: [`tokens/default/**/*.json`],
source: [`tokens/${brand}/**/*.json`],
// ...
}).buildAllPlatforms();
});
假设下面是我们的 token.json。
// token.json
{
"sizing": {
"10": {
"value": "10",
"type": "sizing"
},
"12": {
"value": "12",
"type": "sizing"
},
"14": {
"value": "14",
"type": "sizing"
},
"16": {
"value": "16",
"type": "sizing"
},
"18": {
"value": "18",
"type": "sizing"
},
"20": {
"value": "20",
"type": "sizing"
},
"22": {
"value": "22",
"type": "sizing"
},
"24": {
"value": "24",
"type": "sizing"
},
"28": {
"value": "28",
"type": "sizing"
},
"32": {
"value": "32",
"type": "sizing"
},
"36": {
"value": "36",
"type": "sizing"
},
"40": {
"value": "40",
"type": "sizing"
},
"48": {
"value": "48",
"type": "sizing"
}
}
}
先创建Style Dictionary 需要的 config.json。
// config.json
{
"source": ["./token.json"],
"platforms": {
"scss": {
"transformGroup": "scss",
"buildPath": "build/scss/",
"files": [
{
"destination": "sizing.scss",
"format": "scss/variables"
}
]
}
}
}
然后试着执行 node build.js
。不出意外的话,执行结束之后,会在当前目录中创建 build/sizing.scss
。
// Do not edit directly
// Generated on Thu, 04 Aug 2022 07:08:59 GMT
$sizing-10: 10;
$sizing-12: 12;
$sizing-14: 14;
$sizing-16: 16;
$sizing-18: 18;
$sizing-20: 20;
$sizing-22: 22;
$sizing-24: 24;
$sizing-28: 28;
$sizing-32: 32;
$sizing-36: 36;
$sizing-40: 40;
$sizing-48: 48;
虽然输出了我们想要的文件,但是文件的内容好像和预期不符,每一个 Scss 变量应该带上单位。要想定制这个输出结果,我们需要使用自定义的Transform。
Transform 和 TransformGroup
Style Dictionary 中的Transform是用来修改token的函数。使用 Transform可以对token的name、value 或者 attribute 进行转换,从而实现适配输出不同平台的能力。比如,将pixel转换成point。可以使用内置的Transforms,也可以使用registerTransform
方法注册自定义Transform。
StyleDictionary.registerTransform({
name: 'time/seconds',
type: 'value',
matcher: function(token) {
return token.attributes.category === 'time';
},
transformer: function(token) {
// Note the use of prop.original.value,
// before any transforms are performed, the build system
// clones the original token to the 'original' attribute.
return (parseInt(token.original.value) / 1000).toString() + 's';
}
});
TransformGroup就是一组Transform。有开箱即用的内置的TransformGroup,也可以通过registerTransformGroup
方法注册自定义TransformGroup。
StyleDictionary.registerTransformGroup({
name: 'Swift',
transforms: [
'attribute/cti',
'size/pt',
'name/cti'
]
});
为了给sizing加上单位pixel,我们先注册一个自定义 Transform 和 TransformGroup。
StyleDictionary.registerTransform({
name: 'size/px', //定义transform的名称,作为引用标记
type: 'value', // transform 转换的对象,我们需要转换token对应的值
matcher: token => { // token 匹配函数,返回boolean。
return token.type === 'sizing' && token.value !== 0
},
transformer: token => { // transform函数,接受token,返回想要的内容
return `${token.value}px`
}
})
StyleDictionary.registerTransformGroup({
name: 'custom/scss',
transforms: StyleDictionary.transformGroup['scss'].concat([
'size/px',
'size/percent'
])
})
接着在config.json中使用叫做custom/scss
的 TransformGroup。
{
"source": ["./token.json"],
"platforms": {
"scss": {
"transformGroup": "custom/scss",
"buildPath": "build/scss/",
"files": [
{
"destination": "sizing.scss",
"format": "scss/variables"
}
]
}
}
}
最后检查输出内容。
// Do not edit directly
// Generated on Thu, 04 Aug 2022 07:08:59 GMT
$sizing-10: 10px;
$sizing-12: 12px;
$sizing-14: 14px;
$sizing-16: 16px;
$sizing-18: 18px;
$sizing-20: 20px;
$sizing-22: 22px;
$sizing-24: 24px;
$sizing-28: 28px;
$sizing-32: 32px;
$sizing-36: 36px;
$sizing-40: 40px;
$sizing-48: 48px;
掌握工具的使用,配合自动化脚本,可以让token2css的过程变得非常丝滑流畅。
// 同步最新token,生成文件
yarn parse-token
后续动作
现阶段的几件事情
- 产出linter规范和对应的linter插件,aicc项目复用
- 组件规范,包括设计、开发、发布等,并执行
- 按优先级,该优化的优化,该开发的开发。
-
将研发流程工程化,自动化。
基于PNPM的项目改造
背景
因为历史原因,前端项目的代码按照业务模块,分散在不同的代码仓库中。可以复用的业务代码以npm package的形式共享。每个业务模块都是基于Nuxt.js的SSG,部署方式是将各模块构建产出物上传至服务器上,使用Nginx代理接口和托管HTML等静态资源。
看起来好像是一个很常规的操作。但实际上在各方面都存在一些让人非常不痛快的点,且不说:
- npm包的日常开发调试流程繁琐。
- 共享代码的更新迫使依赖的业务模块必须更新。
- 各项目冗余了构建相关的配置。
当这套流程在技术水平普遍不高的团队中应用时,还会有更多挑战:
- 代码组织没有可参考的标准。什么样的代码必须提升至共享,什么样的代码属于强业务?
- 历史原因和开发人员技术能力问题导致的代码腐败。
- 业务模块使用的Webpack、Vue等基础依赖及其生态版本,没有强的一致性约束,导致版本参差不齐。
- 引入Nuxt.js的SSG想做微前端。效果没达成,反而增加了项目维护的难度。
- 项目间形成了孤岛,无法全局观,限制了开发人员的想象力,一定程度上促进了项目代码的腐败。
其他更多挑战就不一一列举了,总而言之就是很“痛”。
当遇到本地化项目的时候,痛苦更上一层楼。我需要同时维护所有项目的代码版本。在众多项目和它们的分支中来回切换,逐渐迷失了自己。
在针对这些场景和痛点进行了一番技术调研之后,我决定尝试基于PNPM对现有项目进行Monorepo的改造。
Monorepo VS Polyrepo
Nrwl 团队创建了monorepo.tools/向大家解释Monorepo相关的概念和工具。
Monorepo是一个包含了多个独立项目,且项目间有明确的关联关系的仓库。可以看到两个重点:多个独立项目,项目间有明确关系。如果只是将项目放在同一个仓库,彼此之间没有明确关系,那这个不能称之为Monorepo架构;如果仓库只包含多个项目,没有拆分出来的封装和复用的代码,那只是一个大库,只能算是MonoLith架构。
与Monorepo相反的方案可以称之为“Polyrepo”,也是当下标准的开发模式:每个仓库都应一个模块、应用或者项目。彼此通过其他的仓库来共享可复用的代码,每个项目都有自己的构建流程和部署流程。这也是当前团队的开发模式。
Polyrepo模式存在代码共享难,重复代码多,依赖更新烦,配置升级乱等问题。Monorepo模式下只会有一个项目仓库,在文件夹之间共享代码十分简单;项目可以保持同一个版本,减少升级的心智负担;复用同一套配置,升级和维护都很方便。
文章中提到了 Monorepo的工具对比,相对比较全面,感兴趣的朋友可以看看。我选择的是PNPM的方案,因为这是当下改造成本最低的方案。未来可能会再研究 TurboRepo 和 Rush。
基于 PNPM 的 Monorepo
PNPM项目的初衷是节约磁盘空间并提升安装速度。PNPM将所有文件都存储在同一个位置,当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间,可以跨项目地共享同一版本的依赖。
PNPM 内置的 Workspace 能力提供了对 Monorepo 的支持。Workspace 的创建非常简单,包含[pnpm-workspace.yaml](<https://pnpm.io/zh/pnpm-workspace_yaml>)
文件的目录就是一个PNPM的Workspace。它定义了Workspace的根目录,可以从workspace中包含或者排除你选择的目录。
packages:
# all packages in direct subdirs of packages/
- 'packages/*'
# all packages in subdirs of components/
- 'components/**'
# exclude packages that are inside test directories
- '!**/test/**'
保留历史记录,渐进迁移
团队的各个项目都在进行正常的开发迭代,技术改造不能对需求上线造成影响,需要有一个方案可以让技术改造和业务迭代并行。
当下的难点在于:
- Monorepo的改造,必然是要有一个新仓库的,原有项目仓库中的代码和新仓库之间的代码如何时刻保持同步
- 项目的依赖包版本没有对齐,合并的过程一定有统一版本的过程,如何统一项目的依赖。
- 如何打造一套全新的构建流程。
针对难点1我想到的方案是:Git Submodule。Git Submodule 允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。完美解决了代码同步的问题。
在改造阶段,对每个项目的修改主要包含:
- 依赖升级之后的可能存在的兼容性改动
- 编译打包相关的配置改动
这些工作可以单独拉去分支进行改造,通过验证之后合并回主干分支。当所有项目改造完成后,整体的升级也算是顺利完成了。
依赖升级
理想很美好,但是现实很残忍。当 PNPM 和 Git Submodule 配置之后,第一步的依赖升级就挡住前进的道路。各项目依赖的Vue和Webpack版本五花八门。Vue的版本有2.6.x和2.7.x,Webpack的版本有3.x、4.x和5.x。
Webpack
安装完依赖之后,第一次启动便遇到了警告
显然,需要将Webpack生态相关的依赖升级到统一的版本。可以使用pnpm why -r
快速检查指定包的依赖关系,所有项目的依赖扫了一遍,一个个升级实在过于繁琐。pnpm update
根据指定的范围更新软件包的最新版本,配置项--recursive
同允许我们同时在所有子目录中使用运行更新,就像刚才使用pnpm why
时一样。
pnpm --recursive update
# 更新子目录深度为 100 以内的所有包
pnpm --recursive update --depth 100
# 将每个包中的 typescript 更新为最新版本
pnpm --recursive update typescript@latest
在指定更新某些包时,一定要记得带上版本,只有包名是不会更新的。
pnpm update --recursive webpack@^4 css-loader@^3 sass-loader@^10 webpack-merge@^5
Vue 和 Nuxt
更新了Webpack版本后,又遇到了预料之中的Vue packages version mismatch
。检查发现,实际项目中使用的Vue版本都是v2.6.12,v2.7.10的版本来自一些第三方库的依赖。考虑到升级Vue可能带来的影响,决定先保留Vue的版本为v2.6.12。
再次执行 nuxt dev
时,得到了新的错误。
✖ Nuxt Fatal Error
Error:
Vue packages version mismatch:
- vue@2.6.12
- vue-server-renderer@2.7.10
This may cause things to work incorrectly. Make sure to use the same version for both.
使用 pnpm why
查看 vue-server-renderer ,会发现最终的顶级依赖是 nuxt
,我试图将其版本锁定在项目使用的v2.11.0,但是依旧是相同的错误。将原先的 yarn.lock文件和 pnpm-lock.yml进行比对后发现,后者的版本更新到了v2.7.10。从 node_module s中一层层从 nuxt 到 vue-server-renderer 的引用关系如下
nuxt@2.11.0
-> @nuxt/core@2.11.0
-> @nuxt/vue-renderer@2.11.0
->vue-server-renderer@^2.6.11
因为^的关系,vue-server-renderer 会安装到最新的v2.7.10。
为什么老项目本身能够运行? 项目中的 yarn.lock 文件创建的时间很早,锁定的vue-server-renderer是v2.6.12,与vue版本一致,所有可以运行。
看起来需要将Vue的版本锁定为v2.7.10。社区中有不少关于将Vue升级到2.7的踩坑文章,接下来结合项目当前对Vue的使用情况,梳理出一个可行的改造方案。
pnpm update -r vue@^2.7.14 nuxt@^2.15.8
Babel
同样的,使用 update -r
批量更新vue@2.7.10、nuxt@2.15.8,游戏进入到下一关。
/.nuxt/client.js: Unknown option: base.configFile. Check out <http://babeljs.io/docs/usage/options/> for more information about options.
A common cause of this error is the presence of a configuration options object without the corresponding preset name. Example:
Invalid:
`{ presets: [{option: value}] }`
Valid:
`{ presets: [['presetName', {option: value}]] }`
For more detailed information on preset configuration, please see <http://babeljs.io/docs/plugins/#pluginpresets-options>.
这是 babel-core 和将 babel 升级到 v7.x,bable-loader 升级到 v8.x 就能解决这个问题。我直接将它们升级到latest版本。
pnpm update --recursive babel-loader@^8 @babel/core@^7 @babel/preset-env@latest
因为项目中用到了jsx,对应的还需要升级相关的插件,具体细节可以查看这里。
pnpm add -D -r @vue/babel-preset-jsx @vue/babel-helper-vue-jsx-merge-props
现在 nuxt dev可以顺利运行,但是页面上出现了下图中的错误。
考虑到本次升级将nuxt从v2.11.0升级到了v2.15.8,猜测可能是nuxt导致的问题。
Nuxt
通过打断点发现,在加载Layout目录中的组件时,出现了一个 undefined。检查发现是Layout目录中包含了一个microEvent.js
,文件内容是导出了几个工具函数,没有默认导出对象(export default)。
/*
* 定义一个事件系统,用于让微应用和主应用通讯
*/
const microEvents = {}
export const addMicroAppEventListener = (appName, ev, cb) => {
if (!microEvents[appName]) {
microEvents[appName] = {}
}
if (!microEvents[appName][ev]) {
microEvents[appName][ev] = []
}
microEvents[appName][ev].push(cb)
}
export const removeMicroAppEventListener = (appName, ev, cb) => {
if (microEvents[appName] && microEvents[appName][ev]) {
microEvents[appName][ev] = microEvents[appName][ev].filter(it => it !== cb)
}
}
export const dispatchMicroAppEvent = appName => (ev, ...args) => {
if (microEvents[appName] && microEvents[appName][ev]) {
microEvents[appName][ev].forEach(fn => fn.apply(null, args))
}
}
对比了 v2.10.1 和 v2.15.8 的产物后发现,两个版本中,microEvent.js都被解析为 undefined,在2.10.1版本下,nuxt在解析了layouts目录中的文件后,直接构建了一个layouts
对象。
而在2.15.8版本中,则调用了sanitizeComponent 方法。
在sanitizeComponent(Component)
中没有对入参为 undefined 时做兼容处理,导致出现报错。
export function sanitizeComponent (Component) {
// If Component already sanitized
if (Component.options && Component._Ctor === Component) {
return Component
}
if (!Component.options) {
Component = Vue.extend(Component) // fix issue #6
Component._Ctor = Component
} else {
Component._Ctor = Component
Component.extendOptions = Component.options
}
// If no component name defined, set file path as name, (also fixes #5703)
if (!Component.options.name && Component.options.__file) {
Component.options.name = Component.options.__file
}
return Component
}
在nuxtjs的releases记录中找到了对应改动的版本在v2.13.0,这里是提交记录。
行动计划
pnpm的改造成本不算太高,主要的成本体现在项目依赖的升级这件事情上。不过好在版本的跨度没有太大,整个过程较为平滑。但是这么多项目,如果一次性批量升级依赖,风险还是太大了。接下来要做的事情是,在每个项目的迭代中逐步升级依赖,减少升级带来的风险。与此同时进行的是,将项目的构建部署和工具包的发布等工程化相关的流程梳理清楚,收敛为一套标准的流程。所有的工作串起来后,完整的monorepo方案就能顺利落地。
如上所述:那么aicc的代码都被整合在一个代码库中,只不过分为不同的子库去处理,可以单独启动子库跑代码
并且多个子库的代码规范,代码检查都可以统一管理起来
微应用:突破技术封锁,不局限于框架
原来老旧的代码都是vue项目,新进入的前端同学只能被迫使用vue去开发,但是使用微应用后,我们可以以页面为单位,嵌入不同的子应用进去,子应用的技术可以随便选型,可以大大活跃前端代码框架的死板,提升大家学习新技术的积极性,具体嵌入方式可以参考qiankun
总结:
原先的代码库是项目内部使用aicc-components这个npm包,这个包内业务组件+基础组件+一大堆东西,定义完全不清晰,并且后来者也不清楚哪些组件被开发过,导致重复任务大大提升;
然后,从组件化思想出发,选型storybook去改变此包,写组件使用文档,区分开基础组件和业务组件,然后在具体网址上去配置文档网页,便于后续同学开发项目。
组件化时,借鉴的是Arco Design,在查看组件样式时,发现都是样式变量, 从而想到了css原子化,使用到了**Style Dictionary,** 并且于UI同学一起改造,绘制页面时,UI同学也会使用样式变量给予提示。
在aicc的各个系统中,如外呼系统,呼入系统,都是单独的项目,各个项目的代码检查规范,ts,eslint等都不是统一的,所以采用monorepo去整合项目,然后使用qinkun嵌入;并且pnpm正好新出后,使用pnpm来优化node_modules包,在子库引入其它公共工具库时,正好pnpm的workspace功能可以满足;
在微应用嵌入时,正好可以突破框架的封锁,在同一项目不同页面使用不同框架,可以提升开发同学兴趣和满足大体同学都想使用react的心理。
整体架构下来,原来屎山项目,一点点的改成了未来可期,慢慢努力,整个公司代码会越来越好!