Skip to content

Tech upgrade

赵鹏程(珵之) edited this page Jan 17, 2024 · 38 revisions

技术升级指引

前言

Next 组件库经过长期发展,已经有了很重的历史包袱,技术架构、用户体验已经落后于开源生态,急需从用户开发者角度出发,进行一波技术升级,为后续的发展铺平道路。

本次升级在 1.x 的基础上进行技术性改造,不涉及组件功能的调整,完全向前兼容。计划发布 1.27.x 版本,并停止 minor 位的升级。目前已经完成项目基本结构、工程链路及部分组件的改造,发布了 1.27.2 版本,接下来需要结合社区力量,将剩余组件逐步升级上来。

PR 收获

  1. 对组件库本身
    1. 类型完善,更易用
    2. 代码清晰,更容易维护
    3. 文档更友好
    4. 发布后更稳定
  2. 对贡献者
    1. 开源锻炼
    2. 根据贡献度,纳入 fusion contributors 清单

升级原则

  • 向前完全兼容性
  • TS 类型明确
  • 文档清晰完备
  • 测试用例稳定有效

待改造组件列表

https://github.com/alibaba-fusion/next/issues?q=is:open+is:issue+label:%22Technical+Upgrade%22+no:assignee

升级指引

升级点

  1. 目录优化(已完成)

    • 将每个组件相关的文档、测试用例、源码都放在同一个目录下维护,以便快速地寻找相关代码
  2. TS 化

    • 全面使用 TS 语言编写,一方面在老代码类型补全过程中发现潜在问题,另一方面完善组件库类型提示,解决以前手动维护类型带来的缺失、错误类型和额外成本,再者通过静态类型检查提升组件库稳定性
  3. 文档优化

    • 一方面对存量文档进行文案优化和 Demo 更新,另一方面通过 TSDoc 维护组件 api,减少手动维护 api 带来的问题。
  4. 测试工具升级,用例优化

    • 一方面解决现存测试工具稳定性、兼容性问题,另一方面对存量用例进行回归改造,保证用例书写规范、目的清晰、验证有效

TS 化

1. 重命名

# 通过脚本将 components/date-picker 目录内所有的 js 文件重命名为 ts 文件
# 脚本根据文件内是否有 jsx 语法来确定文件后缀为 .tsx 还是 .ts
npm run tool:rename2ts date-picker

# 执行一次 commit,保证 git 能够正确追踪文件历史
git commit -m 'refactor(DatePicker): rename to ts'

2. 类型补全与修正

  • 组件的类型集中在 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 导出的其它类型
    • 查看示例
  • 涉及其它组件的 API 直接引用即可,不要保留副本

3. 类型校验

# 仅检查 date-picker 目录内的 TS 类型
npm run check:types date-picker
# 非本组件目录内的检查错误可以先忽略

文档优化

1. 描述文档优化

针对 components/*/__docs__/{index.md,index.en-us.md} 文档内容进行优化调整,可从语义准确性、错别字、歧义、排版等角度对文案进行酌情调整

2. 组件 API 声明方式改造

由原先在组件 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

Button API 示例

组件 API TSDoc 参考示例

TSDoc 参考:https://tsdoc.org/

测试工具升级,用例优化

组件库真实运行环境是在浏览器中的,走 jsdom 的方式模拟有很多兼容性和稳定性问题,测试过程与实际表现不符,给用例编写带来了很大的成本,阻塞 CI/CD 流程却不能保证组件库稳定性。故而切换为纯浏览器环境的测试工具。

考虑到切换成本与体验,经考虑采用 cypress 社区版本作为组件库的测试框架,其同样采用 Mocha bdd syntax,并且有很优秀的异步支持,很容易以简洁的代码写出有效的用例,而不需要关心太多延迟、动画、渲染时机等异步问题。

Accessibility 用例改造参考

普通用例改造参考

用例改造要点

  • 移除旧的测试工具代码
  • 使用 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 API 使用参考

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

  1. 渲染
it('xxx', () => {
    cy.mount(<Button>按钮</Button>);
    // ...some assertions
});
  1. 修改 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>)
});
  1. 检测元素是否存在
it('xxx', () => {
    cy.mount(<Button>按钮</Button>)
    // cy.get 始终从根节点开始寻找元素
    cy.get('.next-btn');
});
  1. 在指定元素内查找子节点
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');
});
  1. 断言元素属性
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');
});
  1. 断言元素数量
it('xxx', () => {
    cy.mount((
      <div>
      	<Button>按钮 1</Button>
      	<Button>按钮 2</Button>
    	</div>
    ))
    cy.get('.next-btn').should('have.length', 2);
});
  1. 触发事件
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')
    });
});
  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);

TSDoc 参考示例

/**
 * @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;
}