来自DigitalOcean的React 组件库设计哲学

哲学

在维护这样一个库时,我试图理解通过界面提供的微妙激励。如果一个开发者可以拉动一个杠杆,它最终会被拉动。有时开发人员是团队中最资深的人。有时候是刚从新兵训练营出来的人。有成千上万的代码行,一个任务要完成,太多的上下文需要知道正确的事情。此问题产生于大型团队动力学,并且存在于每个组织中。如果杠杆存在,它就会被拉动。

但责任并不完全落在看到杠杆的开发商身上。负担也必须落在提供这种服务的开发商身上。好的设计会导致图书馆用户陷入成功的陷阱。计划这个结果需要耐心的考虑,我不急。一般来说,这份文件中的所有内容归结起来就是我要最大限度地利用以下内容:

  1. 将一个设计转换成 UI 代码应该很容易。道具应该直观地映射到设计图形或其他系统文档。组件在不应用重写的情况下看起来应该是正确的。
  2. 在大多数情况下,组件应该充当使用它的父组件的不透明框。它不应该泄露有关其内部结构的详细信息或允许注入任意的代码/样式。数据进入,标记出来。
  3. 显而易见的事情,简单的事情,和正确的事情应该在大多数时候重叠。在时间压力下的开发人员通常会寻求最简单的解决方案。理想情况下,最简单的解决方案是显而易见的。最明显的应该是我希望开发人员首先要做的事情。
  4. 做错事至少应该是不舒服的,在最坏的情况下是不可能的。必要时允许逃生舱口,但要让他们感觉不舒服。开发人员应该想,“我应该打开一个问题,这样我就不必再做一次。”

也就是说,这份文件中的规则没有一条是硬性和快速的。他们有他们的权衡,这通常归结为我更喜欢设计系统的一致性而不是风格的灵活性。读书的时候记住这一点。

最后,我认为这里采取的权衡不一定适用(但也许适用呢?)一个通用的、开源的组件库,因为动机是不同的。这些库应该足够灵活,以便公司 A 可以使用它们,而不是看起来像公司 B。在我的例子中,Walrus 只是需要看起来像我的公司,我不希望组件库能够避免看起来像我的公司。

当所有人都拥有它的时候,就没人拥有它了

在我看来,必须有人拥有组件库。如果没有所有者,组件库将累积一次性的“我只需要这一件事”类型更改,这种更改在分组时不会反映设计系统的整体视图。至少有一个开发人员的工作描述必须是组件库的维护。

例如,假设一个产品工程师收到一些他们必须实现的新设计。它可能包含组件的一个变体,这个变体可能在设计系统中,但是还没有在组件库中实现。这种不完整性是一个大问题,因为产品工程师必须对他们不负责维护的组件库做一些事情。如果没有专门的所有者,实现的解决方案通常是“简单的事情”(通常是“错误的事情”) :

  1. 仅仅更新组件以获得期望的结果,而不需要更新其他内容。将来会减慢开发人员的速度,因为他们必须经常检查组件内部,以理解接口中存在的各种一次性支持。
  2. 不要更新组件并用样式化组件或其他方式包装它。创建碎片,因为这些更改很少返回到组件库中。
  3. 经常不考虑已经解决的库中可能已经存在的边界问题。

这些类型的溶液往往是复合的。对组件的更改现在需要额外的注意,因为现有的重写可能会成为破坏可视化更改的位置,所以更容易不去触摸它。一直这样下去。随着越来越多的重写的应用,风格的变化变得越来越危险,以安全适用。如果您目前在一家没有任何人拥有组件库的公司工作,我相信您会感受到这种痛苦。

如果组件库糟糕,则某人需要丢掉他们的工作; 否则,它可能会很糟糕。

简洁地表示设计系统变体的组件接口更容易使用

在浏览设计文档时,我试图看看是否能够“可视化”所有的变化,就好像它们是 N 维空间中的轴,每个维度都与单个属性相关联。

design doc 1

了解哪些视觉差异是独立运作的,哪些不是,这一点很重要。例如,按钮的类型和禁用的道具是独立的(正交?)彼此的关系。设计师(希望)不会建议“不能禁用辅助按钮”

type Props = {
  type?: 'primary' | 'secondary' | 'tertiary';
  disabled?: boolean;
  icon?: Icon;

  /* ... */
}

export Button: React.FC<Props> = (props) => {
 /* ... */
};

相比之下,相互依存的差异应该合并为一个单独的支柱ーー而这个单独的支柱应该作为它自己的维度运作。例如,可选地具有标签的 TextInput,其中标签也可选地具有工具提示。

design doc 2

界面有两个道具 label 和 labelTooltip 是没有意义的,因为没有标签,工具提示就不会显示。它们应该合并成一个单独的支柱,以满足这一需求:

// ❌ Does not indicate that `labelTooltip` depends on `label`!
type Props = {
  label?: string;
  labelTooltip?: string;

  /* ... */
}

// ✅ `tooltip` cannot exist without `text`!
type Props = {
  label?: string | { text: string; tooltip?: string };
}

export TextInput: React.FC<Props> = (props) => {
  const label = props.label ? normalizeLabel(props.label) : null;

 /* ... */
};

这种类型让人想起我一直以来最喜欢的编程主义之一: “使非法状态无法表示”。如果我们假设设计系统代表所有可能的“合法的视觉状态”,那么道具不应该允许非法的视觉状态。

有些人可能会争辩说,“如果标签不存在,那么组件就不会显示工具提示,表示仍然有效。”此外,还可以添加一个运行时检查来强制执行不变式。

但为什么要等到运行时呢?为什么要等待另一个开发人员感到困惑呢?这种懒惰将正确性的责任推给了开发人员(以及之后的每个开发人员)。根据类型检查器,< TextInput labelTooltip = “ !”/> 是完全有效的。在这段代码中有一个隐含的规则,其中工具提示不能没有标签而存在,类型{ text: string; tooltip 清楚地说明了这一点?校对: string }。

在最极端的情况下,组件可能需要在单个键上切换的道具接口,这与在 action.type 上切换的 Redux reduce 没有什么不同。在这种情况下,更有意义的做法是创建几个不同的组件(可能使用通用的内部基础组件)。

组件可能不应该定位它们自己

考虑下面的图片。组件 C 呈现组件 A 和 B。在 A 和 B 之间是一个空格。问题是: 谁来声明这个间距?

margins 1

如果结果作为保证金权利属于 A,考虑这个结果。也就是说,间距是 A 的内部。这样做的问题是,A 的默认表示包括保证金权利。

考虑到这一点,下面的图片中应该发生什么?我们有 E,它表示 A 在 D 的旁边,但是没有间距。

margins 2

如果 A 有一个内部边距-right,我们将不得不重写它为0。它有效地将样式规则撤消回默认浏览器值。采取这样的步骤感觉像是一种密码的味道。

const StyledA = style(A)`
  margin-right: 0;
`;

避免这个问题的一般方法是说组件不应该将边距(即间距)应用到它自身的外部。因此,这个问题的正确答案是 C 总是声明间距。对于这种说法,我还没有找到一个像样的反例。

组件通常应占用给定的所有水平空间

大多数情况下,一个组件应该占用父组件给它的所有宽度。也就是说,大多数组件的默认状态是占用它们所在部件的整个宽度。当一个组件没有占据整个页面的宽度时,通常是因为它呈现在一个容器(flex/grid/space/etc)中,而容器正在进行约束。

width

应用此规则可以使响应页面更容易实现,因为几乎所有媒体查询 CSS 都可以存在于容器组件(flex/grid/space/etc)中(它们应该存在的位置)。

组件可能不应该公开 className 或样式道具

ClassName 和 style 破坏了组件的风格封装。这些属性允许人们随心所欲地应用任意的风格。当设计系统规范已经出现在实现中时,这种方法可能不是您想要的。

在理想的场景中,父组件应该将子组件视为一个不透明的框,其中包含要拉动的非常特定的杠杆(因为所有的杠杆都会被拉动)。某人不应该能够(或者需要)“深入”某个组件来从根本上任意地改变表示。

如果我们必须为自定义样式重写提供一个逃逸舱口,最好将它们公开为 UNSAFE _ className 和 UNSAFE _ style

坚持“永远不要超越风格”是不完全合理的如果确实有一个逃生舱口,它应该感到可怕的做和容易抓住。我从一个朋友那里偷来的解决方案是给这两个道具都加上 UNSAFE _ 的前缀。

// ❌ Nothing to see here.
const btn1 = <Button className="a b c" />;

// ✅ Feels terrible. Looks gross. Easy to grep.
const btn2 = <Button UNSAFE_className="a b c" />;

ClassName 是类似样式化组件的库注入任意样式的挂钩。用 UNSAFE _ className 替换 className 可以消除使用样式化组件包装内容的“诱惑”。我觉得这是个大胜利。

它还打开门的衬垫规则或其他工具,以防止过度使用覆盖。这个检查对 className 来说是不可能的。

一般情况下,尽量避免从基本元素道具延伸

扩展一个基类型,比如 React.HTMLAttritribute < HTMLButtonElement > 将扩展组件的接口数百个键。根据我的发现,如果我们这样做,我们可能会尝试将它们全部转发到组件(Button)中的某个基本元素(按钮)。那就是:

interface Props extends React.HTMLAttributes<HTMLButtonElement> {
  type?: "primary" | "secondary" | "tertiary";
  disabled?: boolean;
  icon?: Icon;
  /*  */
}

const Button: React.FC<Props> = (props) => {
  const { type, disabled, icon, ...rest } = props;

  /* ... */

  return <button {...rest} />;
};

在构建组件接口时,我希望非常清楚它允许的变体。出于同样的原因,我不想启用从基础扩展,我不想要 className 或样式道具。门可以随意改动。

避免外部数据上的 JSX 传播有时可以防止奇怪的 bug

也就是说,在处理外部数据时,我避免使用任何扩展运算符。是的,我不想让道具从一个部件覆盖到另一个部件。(说句公道话,我觉得这是处理道具的一个很好的一般规则。)利用外部数据的利差有几个缺点:

  1. 目前还不清楚具体的道具是从哪里来的。
type AProps = {
  thing?: string;
  other?: number;
  disabled?: boolean;
  /* ... */
};

const A: React.FC<AProps> = (props) => {
  const [disabled, setDisabled] = React.useState(false);

  /* ... */

  return <B {...props} disabled={props.disabled || disabled} />;
};

type BProps = {
  thing?: string;
  other?: number;
  disabled?: boolean;
  /* ... */
};

const B: React.FC<CProps> = (props) => {
  const disabled = React.useContext(DisabledContext);

  /**
   * Whether `C` is `disabled` depends on whether
   * `disabled` was passed into `A`.
   */
  return <C disabled={disabled} {...props} />;
};
  1. 它使得转发非预期的道具成为可能。 TypeScript 不会捕捉到这一点。
// button.tsx

type Props = {
  children: React.ReactNode;
  onClick(): void;
};

export const Button = (props: Props) => {
  return <button {...props} />;
};
// account.tsx

import { Button } from "./button";

const Account = () => {
  // ...

  const buttonProps = {
    onClick() {
      /* ... */
    },
    style: {
      /* Oops... */
    },
  };

  return <Button {...props}>Save</Button>;
};

我建议,在任何可能的情况下,根据需要解构道具对象和转发键。拆分道具可以删除过多的键,允许设置默认值,并使得抓取代码更加容易。

限制子组件的“传递”道具可能更好

想象一个带有两个按钮的 Modal 组件。这可能是诱人的保持模式通用,并允许按钮可定制与其完整的道具:

type Props = {
  // ...

  primaryButtonProps?: React.ComponentProps<Button>;
  secondaryButtonProps?: React.ComponentProps<Button>;
};

const Modal: React.FC<Props> = (props) => {
  /* ... */
};

我认为这对于一些应用程序通常不使用的基本级别的内部 Modal 组件来说是很好的。不过,除非每个调用站点都有相同的主 ButtonProps blob,否则它可能不一致。此外,对所有 Button 道具的这个显式调用将有关按钮的详细信息泄露到父组件ーー我特别考虑了 Button 是否被禁用。

相反,模态应该有描述不同视觉状态的变化。按钮道具(现在被命名为“ Action”道具)通常应该限制在少数可以在不同实例之间合理变化的事情上。

type Props = {
  type: "alert" | "info" | "confirm";
  disabled?: boolean;
  primaryActionProps: {
    onClick(): void;
    children: string;
    icon?: Icon;
    /* ... */
  };
  secondaryActionProps?: {
    onClick(): void;
    children: string;
    icon?: Icon;
    /* ... */
  };
};

const Modal: React.FC<Props> = (props) => {
  /* ... */
};

在我看来,这是一个将控制移入 Modal 的改进。将来,我们可能会决定“ info”模式中的次要操作是一个链接类型的组件,而不是一个按钮。在前一种情况下,这是一个相当大的突破性变化。但是,关键在于,有了这个新的接口,这些细节就会降级到 Modal 的内部。

大多数时候,对于相互依赖的组件使用 React 上下文是一个好主意

据我所知,上下文最初的用例是链接依赖组件的数据,而不需要到处进行线程处理。

例如,让我们构建自定义 SelectMenu 和 SelectOption 组件。如果不使用上下文,我们必须将相同的 onSelect 处理程序和选中的布尔值传递给每个选项:

import { SelectMenu, SelectOption } from "some-walrus-lib";

const Thing = () => {
  const [selected, setSelected] = React.useState<string>(null);

  return (
    <SelectMenu>
      <SelectOption
        value="a"
        onSelect={setSelected}
        selected={selected === "a"}
      >
        Option A
      </SelectOption>
      <SelectOption
        value="b"
        onSelect={setSelected}
        selected={selected === "b"}
      >
        Option B
      </SelectOption>
      <SelectOption
        value="c"
        onSelect={setSelected}
        selected={selected === "c"}
      >
        Option C
      </SelectOption>
      <SelectOption
        value="d"
        onSelect={setSelected}
        selected={selected === "d"}
      >
        Option D
      </SelectOption>
    </SelectMenu>
  );
};

通过上下文,我们可以告诉 SelectMenu 当前选择的值,而不必向每个 SelectOption 指出它们是否当前被选中:

import { SelectMenu, SelectOption } from "some-walrus-lib";

const Thing = () => {
  const [selected, setSelected] = React.useState<string>(null);

  return (
    <SelectMenu onSelect={setSelected} selected={selected}>
      <SelectOption value="a">Option A</SelectOption>
      <SelectOption value="b">Option B</SelectOption>
      <SelectOption value="c">Option C</SelectOption>
      <SelectOption value="d">Option D</SelectOption>
    </SelectMenu>
  );
};

还有一件事: 上下文应该保持在库的内部。允许应用程序导入和处理裸上下文会造成脆弱的耦合,在以后的升级中很容易破坏这种耦合。

将逻辑组件分组为单个对象几乎是零成本的方便

如果 SelectMenu 和 SelectOption 组件可以一起导出,那是非常好的,因为它们的上下文必须一起呈现。这种总是在一起的特性是一个数据集群,因此将它们分组到单个对象中。

export const Select = {
  Menu: SelectMenu,
  Option: SelectOption,
  // ...
};

然后我们得出结论:

import { Select } from "some-walrus-lib";

const instance = (
  <Select.Menu onClick={handleClick}>
    <Select.Option value="a">Option A</Select.Option>
    <Select.Option value="b">Option B</Select.Option>
    <Select.Option value="c">Option C</Select.Option>
    <Select.Option value="d">Option D</Select.Option>
  </Select.Menu>
);

对于其他人来说,这种分组几乎是零成本的,它告诉他们,“请一起使用这些。”

避免为浏览器 API 滚动我自己的无头抽象是一个好主意

JavaScript 浏览器 API 的细微差别通常足以让您不必尝试重新发明轮子。依赖于性能良好的无头抽象并去除边缘情况。

在收到一个 bug 报告说它在 Safari 中无法工作之前,我曾经遇到过麻烦,以为“我所需要的只是20行代码”。别像我一样。

只发送带有主要版本颠簸的弃用信息压力要小得多

Walrus 发布到一个内部软件包注册中心,这样它就可以横跨我们的许多前端应用程序。对于产品团队来说,发布重大更改会使更新 Walrus 变得麻烦和阻碍。如果一个应用程序晚了几个版本,但是票据需要最新的版本,那么作为升级的一部分的重大更改可能是一个巨大的阻碍,这取决于重大更改。

相反,我们遵循 Ember-ish 版本控制模型,其中主要版本颠覆了不赞成使用 API/道具/助手等。它们还包括一个或多个代码解码器来修复95% 以上的弃用。

通过这种方式,团队可以立即升级 Walrus,只需忍受一个响亮的控制台,直到他们准备好做这项工作。

代码解码器使我更快(在我变得擅长代码解码器之后)

对于我所处的特定情况,编写代码使我们能够在所有代码库之间快速发布重大的、全面的变化。

包含弃用的 Walrus 版本通常有一个代码来修复该弃用。运行代码解码通常意味着团队在将有问题的代码从应用程序中删除之前甚至不会看到弃用消息。

等幂代码的压力要小得多

我确保代码是幂等的。由开发人员在同一模块上多次运行的代码解码器的结果与开发人员运行一次的结果相同。这个附加要求是为了当代码运行多次时不会引入错误。

多次? 为什么要多次运行它们? 考虑下面的场景:

  1. 代码解码器运行,结果合并到主分支中。
  2. 另一个早于代码解码器的请求在代码库中引入了一个新的、现在无效的模块。
  3. 请求被合并
  4. Merge 将回归引入到主分支中。
  5. 我们重新运行代码来修复问题。

如果代码不是幂等的,就有可能引入回归!

假设 Walrus 公开了一个颜色对象,我们决定在所有颜色后加 Base (无论出于什么原因)。因此,color. blue 和 color. red 分别变成 color. blue Base 和 color. redBase。如果代码只添加了后缀,那么如果执行多次,我们可能很快就会得到 Colors.bluBaseBase。当然,这无法编译,但是返回并修复它是一个痛苦的过程。

当代码幂等时,它们可以避免这种麻烦。

自动升级的工具节省了我数周的工作

我已经编写了一些工具,可以突破 Walrus,运行所有新的代码解码器,并在所有应用程序中打开一个请求。如果测试通过,则可以快速合并升级。产品工程师有时甚至不需要知道发生了颠簸。

这项工作节省了我和其他人数百个小时的单调乏味,而且正是我们想要自动化的东西。

静态分析是王道

更多工具!了解开发人员如何使用组件库对于决定我们首先需要处理什么是必要的。我们的团队已经建立了工具来分析组织中的所有前端应用程序并回答基本问题。

这些工具的工作原理是这样的:

  1. 克隆所有 React 前端应用程序
  2. 对每个应用程序的每个源模块进行 Glob。
  3. 将每个文件解析为一个 AST 并通过查询 AST 收集数据。
  4. 将来自所有应用程序的数据合并到一个 blob 中。
  5. 分析斑点以获得洞察力

我们可以检入查询,以回答诸如 Walrus 组件 X 被样式化组件包装了多少次这样的问题?哪些 CSS 属性更改得最多?某些组件是否只与其他组件一起导入?等等(您可以提出任意的问题,但我们在这里讨论的是组件库。)想象一下,通过成千上万行代码来猜测这些问题的答案!你必须依靠工具。

此外,通过 REST 端点使这些查询可用使得设置仪表板和度量变得容易。(甚至还有更多的工具来开 JIRA 的罚单。)很高兴看到描述向右移动的技术债务的图表。

可视化回归测试比单元测试更有价值

使用 Jest 进行图像快照测试是非常有价值的。遍历组件的所有变体和状态,在每个步骤中获取图像快照,并将它们与组件的最新版本进行比较。该套件是可配置的,因此表示中的任何差异都会触发故障。你以为会有什么不同吗?没有吗?我想你需要修理一些东西。

设置可视化回归测试很痛苦(但绝对值得)

拍摄图像快照所涉及的测试工具非常复杂。它要求在计算机上运行快照测试的操作系统与在 CI 上运行的操作系统相同。如果在 CI 上运行 Linux 映像(可能是这样) ,则必须在 MacBook 上的 Docker 容器中运行快照测试。这是因为在 CI 上运行测试会失败,因为 Linux 使用的字体平滑与 MacOS 不同。问题解决了吗?有人给我发邮件吗!此外,它们的运行速度可能非常慢,而且意外的失败可能会使调试变得乏味。

猜你喜欢

转载自juejin.im/post/7124582453649342494