Headless UI组件——(也许是你没见过的组件设计模式)【译文】

1,205 阅读7分钟

分离原则:将策略与机制分离;将接口与引擎分离。——Eric S. Raymond

Headless UI组件是通过不提供UI来提供最大程度的视图层灵活性的组件。(连UI都没有了,视图层的灵活性当然有了。)“等等,你是在提倡一个没有UI的UI组件吗?”

是的,这正是我在这篇文章所提倡的。

抛硬币组件

假设你的产品经理给你提了一个需求,要求实现一个抛硬币模拟器,要求抛到正面和反面的概率是一半一半。你给产品估的工时是两年左右,然后你就开始干活了。

const CoinFlip = () =>
  Math.random() < 0.5 ? <div>正面</div> : <div>反面</div>;

显然,实现一个抛硬币模拟器的难度比你预估的要低不少,产品经理对你说:“真不戳!那你可以让抛硬币的结果用图片来呈现吗?” 当然可以!

const CoinFlip = () =>
  Math.random() < 0.5 ? (
    <div>
      <img src="/heads.svg" alt="正面" />
    </div>
  ) : (
    <div>
      <img src="/tails.svg" alt="反面" />
    </div>
  );

很快,他们就把你的抛硬币组件上到了营销活动,好让大家看看你新开发的功能有多酷。“我们想把它放进博客页,但是为了SEO之类的东西,我们需要你把告诉用户结果是正面还是反面的标签(label)加回来。” 噢天啊……那看来我们还需要一个标记去识别出营销活动页?

const CoinFlip = (
  // 默认设置为false以免影响到营销页之外的地方
  // 目前的用法
  { showLabels = false }
) =>
  Math.random() < 0.5 ? (
    <div>
      <img src="/heads.svg" alt="正面" />

      {/* 在营销活动页启用标签(label) */}
      {showLabels && <span>正面</span>}
    </div>
  ) : (
    <div>
      <img src="/tails.svg" alt="反面" />

      {/* 在营销活动页启用标签(label) */}
      {showLabels && <span>反面</span>}
    </div>
  );

不久后,产品追加了一个新的需求。“我们想要一个「再抛一次」的按钮,但只在站内展示。”于是代码开始变丑了,我简直无法直视Kent C. Dodds(一个前端大佬)。

const flip = () => ({
  flipResults: Math.random(),
});

class CoinFlip extends React.Component {
  static defaultProps = {
    showLabels: false,
    // 别去改变`showLabels`的定义了,做个人吧。
    showButton: false,
  };

  state = flip();

  handleClick = () => {
    this.setState(flip);
  };

  render() {
    return (
      // Use fragments so people take me seriously.
      // ↑不知道该怎么翻译这句,有知道的评论区戳我一下
      <>
        {this.state.showButton && (
          <button onClick={this.handleClick}>Reflip</button>
        )}
        {this.state.flipResults < 0.5 ? (
          <div>
            <img src="/heads.svg" alt="正面" />
            {showLabels && <span>正面</span>}
          </div>
        ) : (
          <div>
            <img src="/tails.svg" alt="反面" />
            {showLabels && <span>反面</span>}
          </div>
        )}
      </>
    );
  }
}

这时一个同事向你伸出了手。“嗨,你的抛硬币组件写得真棒!我们刚被派了个活,要实现一个抛骰子组件,所以我们想复用你的代码!”

新的需求:

  1. 点击骰子可以再抛一次。
  2. 要在营销活动页和站内都能用。
  3. 和抛硬币组件完全不同的UI。
  4. 不同的点数有不一样的概率。

那么你现在有两种选择,一是跟你的同事说不行,二是把抛骰子的功能强加进抛硬币组件里,并看着它原有的代码结构被它所承担的复杂业务给压垮。

走进Headless组件

Headless UI组件将逻辑和行为从它的视觉表现(视图)分离开来。当一个组件的逻辑足够复杂,并且这些逻辑与视觉表现并不耦合时,这种模式是十分有效的。 我们可以用函数子组件或render prop的方式去实现抛硬币组件的Headless化:

const flip = () => ({
  flipResults: Math.random(),
});

class CoinFlip extends React.Component {
  state = flip();

  handleClick = () => {
    this.setState(flip);
  };

  render() {
    return this.props.children({
      rerun: this.handleClick,
      isHeads: this.state.flipResults < 0.5,
    });
  }
}

这个组件不会渲染出任何东西,所以我们可以称这个组件是headless组件,它实现了状态提升,并期望消费者(也就是调用组件的地方)去实现UI的渲染。那么回顾我们之前的工作,现在站内的抛硬币组件(产品要求要有再抛一次按钮)可以这样用:

<CoinFlip>
  {({ rerun, isHeads }) => (
    <>
      <button onClick={rerun}>再抛一次</button>
      {isHeads ? (
        <div>
          <img src="/heads.svg" alt="正面" />
        </div>
      ) : (
        <div>
          <img src="/tails.svg" alt="反面" />
        </div>
      )}
    </>
  )}
</CoinFlip>

而营销页的抛硬币组件(产品要求要有个label显示结果)可以这样用:

<CoinFlip>
  {({ isHeads }) => (
    <>
      {isHeads ? (
        <div>
          <img src="/heads.svg" alt="正面" />
          <span>正面</span>
        </div>
      ) : (
        <div>
          <img src="/tails.svg" alt="反面" />
          <span>反面</span>
        </div>
      )}
    </>
  )}
</CoinFlip>

太棒了!我们彻底地把逻辑从渲染中抽离了出来!这种做法会让我们在写UI时非常自由!等等,我知道你在想什么……

你这%^&#!这不就是一个render prop吗?!

没错,这个headless组件刚好是用了render prop的方式来实现。同样,headless化也可以用HOC来实现。它甚至能用MVC中的V和C或是MVVM中的V和VM来实现。关键在于将(抛硬币的)机制与视觉呈现(也就是渲染)分离开来。

那么,抛骰子组件呢?

刚才我们提到的“分离”的好处就在于,我们可以非常轻松地拓展我们的headless抛硬币组件去实现同事提出的“抛骰子”组件的特性。(这里的特性指的是可以让组件的使用者去指定抛到正反面的概率。)

const run = () => ({
  random: Math.random(),
});

class Probability extends React.Component {
  state = run();

  handleClick = () => {
    this.setState(run);
  };

  render() {
    return this.props.children({
      rerun: this.handleClick,

      // 通过暴露出去的threshold属性,我们可以支持调用方去自定义不同结果的概率。
      result: this.state.random < this.props.threshold,
    });
  }
}

有了这个headless组件(指<Probability />),我们可以在不改变调用方代码的情况下去替换掉原有的抛硬币组件的内部逻辑。(这里就体现出了抛硬币组件headless化的好处。)

const CoinFlip = ({ children }) => (
  <Probability threshold={0.5}>
    {({ rerun, result }) =>
      children({
        isHeads: result,
        rerun,
      })
    }
  </Probability>
);

现在我们的好同事就可以通过复用<Probability />组件来实现他的抛骰子组件了。

const RollDice = ({ children }) => (
  // Six Sided Dice
  <Probability threshold={1 / 6}>
    {({ rerun, result }) => (
      <div>
        {/* 当然它也可以用别的事件来调用抛骰子 */}
        <span onMouseOver={rerun}>抛骰子!</span>
        {/* 可以任意地实现不同的UI! */}
        {result ? (
          <div>你是大赢家!</div>
        ) : (
          <div>你输了。</div>
        )}
      </div>
    )}
  </Probability>
);

这代码看着很不错吧?

分离原则——Unix哲学

这是对一种源远流长的基本原则的表达。Unix哲学基础第四条:

分离原则:将策略与机制分离;将接口与引擎分离。——Eric S. Raymond

(首尾呼应了属于是)

我想摘录其中的一部分并将其中的“策略(policy)”改为“界面(interface)”

界面和机制发生变化的频率往往是不同的,界面的变化要比机制快得多。流行的视觉效果可能会如流水般来来去去,但它背后的逻辑机制却可能是永恒不变的。

因此,界面和机制耦合会带来两个坏处:界面会变得死板并难以响应用户频繁变更的需求;这也意味着尝试更改界面很可能会破坏原有的机制。

从另一个方面来说,将两者分离会让我们有可能在不改变机制的情况下就去尝试新的界面。同样我们也可以更轻松地对机制编写测试的代码。(界面变化的实在是太频繁了,我们通常无法确定对它编写测试代码是不是划得来。)

我喜欢这伟大的见解!这让我们对什么时候该把组件headless化有了一定的理解。

  1. 这个组件的生命周期(指在业务中的生命周期)有多长?值得我们从界面中剥离出它的机制吗?抽离出来的机制有机会在一个长得完全不一样的组件中被用上吗?
  2. 组件的界面变化得有多频繁?通用的机制会被应用到多个不同的界面上吗?

headless化是有一定成本的,你需要确保headless的收益能大于这么干的成本。我认为这在很大程度上是过去很多MV*模式出错的地方,它们从最底层开始就让一切都以分离原则(机制和策略分离)运行,但在实际的业务中机制和策略往往是强耦合的,或者说分离(headless)的收益并不足以cover成本。(也就是ROI小1了。)

开源Headless组件 & 重要参考

一个由我的朋友Kent C. Dodds在Paypal写的一个叫downshift的项目,可以作为headless组件的标杆。其实也正是downshift这一项目激发了这篇文章的灵感。在不提供任何UI的情况下,downshift提供了accessible的很复杂的自动完成/下拉菜单/选择框等组件。可以点这里看看.

我真诚地希望会有更多像downshift这样的项目随着时间的推移涌现出来。我已经不记得有多少次我想用一个UI库但是却发现它并不支持主题化(themeable)和定制化(skinnable)而作罢了。Headless组件通过要求接入方自己写UI,规避了这一问题。

在一个各种UI或各种design都是headless化的世界里,你可以高度定制你的界面,并且拥有一个优秀的开源UI库提供的健壮性(durability)和可访问性(accessibility)。你只需要花时间去实现真正独一无二的那part工作——你的项目/应用所独有的界面和体验。

我也可以接着讲关于headless给从国际化(i18n)到E2E集成测试所带来的好处,但我还是建议你们自己去试试。

译者的话

这是我对翻译文章的第一次尝试,如有词不达意的地方,欢迎在评论区里大声说出来,感谢各位的观看~ 原文链接: www.merrickchristensen.com/articles/he…