-
Notifications
You must be signed in to change notification settings - Fork 592
Tech upgrade
Next 组件库经过长期发展,已经有了很重的历史包袱,技术架构、用户体验已经落后于开源生态,急需从用户
、开发者
角度出发,进行一波技术升级,为后续的发展铺平道路。
本次升级在 1.x 的基础上进行技术性改造,不涉及组件功能的调整,完全向前兼容。计划发布 1.27.x
版本,并停止 minor 位的升级。目前已经完成项目基本结构、工程链路及部分组件的改造,发布了 1.27.2
版本,接下来需要结合社区力量,将剩余组件逐步升级上来。
- 对组件库本身
- 类型完善,更易用
- 代码清晰,更容易维护
- 文档更友好
- 发布后更稳定
- 对贡献者
- 开源锻炼
- 根据贡献度,纳入 fusion contributors 清单
- 向前完全兼容性
- TS 类型明确
- 文档清晰完备
- 测试用例稳定有效
-
目录优化(已完成)
- 将每个组件相关的文档、测试用例、源码都放在同一个目录下维护,以便快速地寻找相关代码
-
- 全面使用 TS 语言编写,一方面在老代码类型补全过程中发现潜在问题,另一方面完善组件库类型提示,解决以前手动维护类型带来的缺失、错误类型和额外成本,再者通过静态类型检查提升组件库稳定性
-
- 一方面对存量文档进行文案优化和 Demo 更新,另一方面通过 TSDoc 维护组件 api,减少手动维护 api 带来的问题。
-
- 一方面解决现存测试工具稳定性、兼容性问题,另一方面对存量用例进行回归改造,保证用例书写规范、目的清晰、验证有效
# 通过脚本将 components/date-picker 目录内所有的 js 文件重命名为 ts 文件
# 脚本根据文件内是否有 jsx 语法来确定文件后缀为 .tsx 还是 .ts
npm run tool:rename2ts date-picker
# 执行一次 commit,保证 git 能够正确追踪文件历史
git commit -m 'refactor(DatePicker): rename to ts'
- 组件的类型集中在 types.ts 文件内管理
- import 不允许携带文件后缀,如:import '../../style.js'
- 组件入口文件
components/date-picker/index.ts
导出规范- default 导出组件本身,如:
export default config(DatePicker)
- named 导出组件 Props 类型定义
{组件名大驼峰}Props
,如:export type { DatePickerProps }
- named 导出原先
components/*/index.d.ts
导出的其它类型 - 查看示例
- default 导出组件本身,如:
- 涉及其它组件的 API 直接引用即可,不要保留副本
# 仅检查 date-picker 目录内的 TS 类型
npm run check:types date-picker
# 非本组件目录内的检查错误可以先忽略
针对 components/*/__docs__/{index.md,index.en-us.md}
文档内容进行优化调整,可从语义准确性、错别字、歧义、排版等角度对文案进行酌情调整
由原先在组件 static propTypes
内通过 JSDoc 描述 api 的方式,改造为在组件 Props 类型中通过 TSDoc 形式描述 api 所有信息,最后在构建时解析生成到 index.md 内对应 #API 区域
这里添加了几个自定义的 TSDoc Tag 来补充说明哪些类型及哪些属性生成文档时的细节
For 类型定义
TSDoc 标签 | 定义 | 类型 | 必填 | 格式示例 |
---|---|---|---|---|
@api | 声明该类型需要生成文档 | block | 是 | @api Button |
@order | 声明该类型在文档中的顺序 | block | 否 | @order 0 |
For 属性定义
TSDoc 标签 | 定义 | 类型 | 必填 | 格式示例 |
---|---|---|---|---|
@en | 声明该属性的英文文档 | block | 未声明了 @skip 的必填 | @en En description |
@skip | 声明该属性不需要生成文档 | modifier | 否 | @skip |
@version | 声明该属性开始支持的版本 | block | 否 | @version 1.27.x |
@param | 描述该函数属性的参数,中英文用 - 分隔 | block | 否 | @param name - 名称 - username |
@returns | 描述该函数属性的返回值,中英文用 - 分隔 | block | 否 | @returns 是否为空数组 - is empty array |
其他说明
- 参考
__docs__/index.md
文档内 API 章节,根据其内 api 的描述在对应 Props 类型中添加 TSDoc - 原先未在 index.md 中声明的属性,添加 @skip 标签
- 英文文案优先参考
__docs__/index.en-us.md
内的 - 过长属性描述使用 @remarks 标签承载,属性主要描述要求简短直接
- TSDoc 内 HTML 特殊字符需要使用
\
转义
执行 API 生成脚本检验
npm run api date-picker
TSDoc 参考:https://tsdoc.org/
组件库真实运行环境是在浏览器中的,走 jsdom 的方式模拟有很多兼容性和稳定性问题,测试过程与实际表现不符,给用例编写带来了很大的成本,阻塞 CI/CD 流程却不能保证组件库稳定性。故而切换为纯浏览器环境的测试工具。
考虑到切换成本与体验,经考虑采用 cypress
社区版本作为组件库的测试框架,其同样采用 Mocha bdd syntax,并且有很优秀的异步支持,很容易以简洁的代码写出有效的用例,而不需要关心太多延迟、动画、渲染时机等异步问题。
- 移除旧的测试工具代码
- 使用 Cypress 工具提供的 api 执行渲染、查询、断言、事件模拟等操作
- 对存量用例进行 Review,修正补全无效或错误的用例
- a11y-spec.tsx 用例使用工具类
util/__tests__/a11y/validate
,参考button a11y-spec.tsx
- 保证用例通过测试、ts 类型校验、eslint 校验
# 在 headed 浏览器里执行测试
npm run test:head
# 在 headless 浏览器里执行指定组件的测试
npm run test button
# 在 headless 浏览器里执行所有组件测试
npm run test
# 校验组件 ts 类型、eslint、stylelint
npm run check button
Cypress 查询:https://docs.cypress.io/api/commands/get
Cypress 断言:https://docs.cypress.io/api/commands/should
Cypress 事件交互:https://docs.cypress.io/guides/core-concepts/interacting-with-elements
- 渲染
it('xxx', () => {
cy.mount(<Button>按钮</Button>);
// ...some assertions
});
- 修改 props
import React from 'react';
import { MountReturn } from 'cypress/react';
// 某些情况需要测试组件 props 变化时的行为是否符合预期,则需要保留组件实例的情况下调整 props
it('xxx', () => {
cy.mount(<Button type="primary">重要按钮</Button>).as('btn');
// do some assertions for primary btn...
// change props with same component instance
cy.get<MountReturn>('@btn').then(({component, rerender}) => {
return rerender(React.cloneElement(component, {type: 'secondary'}))
})
// do some assertions for new props...
// 若不保留组件实例调整 props,直接重新 cy.mount 即可
// cy.mount(<Button type="secondary"></Button>)
});
- 检测元素是否存在
it('xxx', () => {
cy.mount(<Button>按钮</Button>)
// cy.get 始终从根节点开始寻找元素
cy.get('.next-btn');
});
- 在指定元素内查找子节点
it('xxx', () => {
cy.mount((
<div>
<div className="box1">
<Button>按钮 1</Button>
<Button>按钮 2</Button>
</div>
<div className="box2"></div>
</div>
));
// find 只会从前面获取到的元素内寻找
cy.get('.box1').find('.next-btn');
});
- 断言元素属性
it('xxx', () => {
cy.mount(<Button disabled>按钮</Button>)
cy.get('.next-btn').should('have.attr', 'disabled');
cy.get('.next-btn').should('not.have.attr', 'href');
cy.get('.next-btn').should('have.attr', 'name', 'value');
});
- 断言元素数量
it('xxx', () => {
cy.mount((
<div>
<Button>按钮 1</Button>
<Button>按钮 2</Button>
</div>
))
cy.get('.next-btn').should('have.length', 2);
});
- 触发事件
it('xxx', () => {
const onClick = cy.spy();
cy.mount(<Button onClick={onClick}>按钮</Button>)
cy.get('.next-btn').click(1);
// cy.get('.next-btn').trigger('click');
cy.wrap(onClick).should('be.calledOnce');
cy.then(() => {
onClick('1');
cy.wrap(onClick).should('have.callCount', 2);
cy.wrap(onClick.getCall(1)).should('be.calledWith', '1')
});
});
- 延迟执行
it('xx', () => {
// 常用于代码中有 setTimeout 情况
// 开始计时
cy.clock();
// ... do something
// ...
// 500ms 后结束计时
cy.tick(500);
// ... do something
// ...
})
参考:components/button/index.tsx
import React from 'react';
import ConfigProvider from '../config-provider';
// src/radio/types.ts 内集中放置 radio 相关所有类型
import type { RadioProps, RadioState, RadioShape, RadioGroupProps } from './types';
// 子组件 Group
import Group from './group';
class Radio extends React.Component<RadioProps, RadioState> {
static Group = Group;
// ...
render() {}
};
// 导出组件 Props,以及其他原先在 types/radio/index.d.ts 里导出的类型,例如 RadioShape
export type { RadioProps, RadioGroupProps, RadioShape };
// default 导出组件实现
export default ConfigProvider.config(Radio);
/**
* @api Button
* @order 0
*/
export interface ButtonProps {
/**
* 按钮的类型
* @en Typeo of button
* @defaultValue 'normal'
* @version 1.2.x
*/
type?: 'primary' | 'secondary' | 'normal';
/**
* 点击按钮的回调
* @en Callback of click event
* @example
* (event) => {}
*/
onClick?: React.MouseEventHandler;
/**
* xxx 事件回调
* @en Callback of xxx event
* @param arg1 - 参数 1 - Arg1
* @param arg2 - 参数 2 - Arg2
* @returns 是否 xxx - isxxx
*/
onXXX?: (arg1: boolean, arg2: string) => boolean;
/**
* @deprecated use xxx insteaded
* @skip
*/
shape?: string;
}