Headless UI组件

1,908 阅读5分钟

原文:www.merrickchristensen.com/articles/he…

"策略同机制分离,接口同引擎分离。"— Eric S. Raymond

Headless ui组件是一种新的ui组件开发模式,组件本身不提供ui上的实现,从而让使用者能够自由定制ui样式。“且慢,无ui的ui组件,你知道自己在说什么吗?”

没错,虽然反直觉,但这正是我们所倡导的。

Coin Flip Component

假设你要实现一个抛硬币的功能,需求是这样的:实现一个类似硬币翻转的效果,翻转结束后,硬币正面朝上和反面朝上的概率是对半开。你和产品说,这需求有点复杂,给我半年的时间调研一下,然后你开始写demo

const CoinFlip = () =>
  Math.random() < 0.5 ? <div>Heads</div> : <div>Tails</div>; 

太简单了,然后你拉了个会,拿着ppt就上去了。产品说不错,功能是有了,你把样式优化一下。对你来说问题不大。

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

没多久,他们希望能在营销页上线你这个功能。他们打算投放到博客推文里,希望你的组件能够对SEO友好。于是你撸起袖子继续开干。

const CoinFlip = (
  // 设定默认值为false,以免对之前的应用造成破坏。
  // current usage.
  { showLabels = false }
) =>
  Math.random() < 0.5 ? (
    <div>
      <img src="/heads.svg" alt="Heads" />

      {/* 增加label标签,用于营销页 */}
      {showLabels && <span>Heads</span>}
    </div>
  ) : (
    <div>
      <img src="/tails.svg" alt="Tails" />

      {/* 增加label标签,用于营销页 */}
      {showLabels && <span>Tails</span>}
    </div>
  );

然后又来了一个需求,加一个重来的按钮,并且这个按钮只在应用程序当中添加。组件开始变得丑陋了起来

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

class CoinFlip extends React.Component {
  static defaultProps = {
    showLabels: false,
    // We don't repurpose `showLabels`, we aren't animals, after all.
    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="Heads" />
            {showLabels && <span>Heads</span>}
          </div>
        ) : (
          <div>
            <img src="/tails.svg" alt="Tails" />
            {showLabels && <span>Tails</span>}
          </div>
        )}
      </>
    );
  }
}

然后有一天你同事找到你:“好兄弟,你的掷硬币功能碉堡了。我们有个新需求叫投骰子,可以复用你的代码么?“你拆分了一下新需求:

  1. 需要重来按钮
  2. 需要同时用于应用程序和营销页
  3. 和你的组件有着完全不一样的ui
  4. 有着不一样的随机概率
    现在你面临两种选择,要不就是拒绝你的同事,要不就是改造你的组件,把投骰子功能赛到掷硬币组件当中,看着这个组件变得臃肿难以维护。

使用Headless组件

Headless ui组件将自身的ui和行为分离出来。当一个组件的行为足够复杂,并且逻辑与视觉表现可以解耦时,这种模式非常有效。CoinFlip组件实现Headless的方式可以是让具体的ui实现作为一个子组件或者是renderProp传入,就像下面这样:

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}>Reflip</button>
      {isHeads ? (
        <div>
          <img src="/heads.svg" alt="Heads" />
        </div>
      ) : (
        <div>
          <img src="/tails.svg" alt="Tails" />
        </div>
      )}
    </>
  )}
</CoinFlip>

然后是我们的营销页,它可能长这样:

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

完美,我们对逻辑和状态进行了很好的抽象,剥离了ui,这样我们就可以随意的定制我们的ui了。我知道你可能在想什么...

你是不是傻,不就是一个renderProp么,有必要绕来绕去么?

这个例子当中,恰好我们是利用renderProp去实现它。在react当中,我们当然也可以用HOC来实现。稍微扩散一下思维,我们甚至可以将其实现为MVC当中的View和Controller,或者MVVM中的ViewModel和View。(注:将组件内部的逻辑状态封装,具体的渲染和事件绑定交由渲染框架,单独的开发适配层,组件甚至可以做到跨平台)这里的核心思想是分离组件的机制和表现。

回到投骰子组件

这种分离的好处是,我们很容易扩展我们的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组件,我们只需要更新CoinFlip组件的代码,而不需要去修改下级消费者的代码。

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

同样的,同事也可以通过复用Probability组件来实现他们的逻辑

const RollDice = ({ children }) => (
  // 六面骰子
  <Probability threshold={1 / 6}>
    {({ rerun, result }) => (
      <div>
        {/* 这里可以实现一些自定义事件 */}
        <span onMouseOver={rerun}>Roll the dice!</span>
        {/* 完全不同的ui实现 */}
        {result ? (
          <div>Big winner!</div>
        ) : (
          <div>You win some, you lose most.</div>
        )}
      </div>
    )}
  </Probability>
);

优雅,非常优雅。

分离原则-Unix设计哲学

这是一个广受业界认可的共识,并且经久不衰。Unix设计哲学基础第四条:

"策略同机制分离,接口同引擎分离。"— Eric S. Raymond

我想引用这一部分,并且用界面替代策略这个词。

因为界面和机制是按照不同的时间尺度变化的,界面的变化要远远快于机制。GUI工具包的观感时尚来去匆匆,而光栅操作和组合确实永恒的。
所以,把界面同机制揉成一团有两个负面影响:一来会使界面变得死板,难以适应用户需求的改变,二来也意味着任何界面的改变都极有可能动摇机制。
相反,将两者剥离,就有可能在探索新界面的时候不足以打破机制。另外,我们也可以更容易为机制写出较好的测试(因为界面太短命,不值得花太多精力在这上面)。

我喜欢这里的深刻见解,这也给我们带来思考,哪些情况下headless ui设计模式是非常有价值的。

  1. 这个组件的寿命有多久?抛开界面表现,背后的机制是否值得我们刻意保留?这个机制我们是否会用在另外一个外观和风格完全不同的项目当中。
  2. 我们的界面外观多久更新一次,同样的功能,我们会有多少不同的外观界面?

将机制和政策分离,是有成本的。我们需要平衡好分离带来的收益和成本。我认为这是过去许多MV*模式容易犯错的地方,就是死守这个原则,一切都以这种方式去分离。回到现实,很多机制和政策都是深度耦合的,分离的好处可能不足以覆盖所带来的成本。

本文由博客一文多发平台 OpenWrite 发布!