Skip to content
This page has been auto-translated and may contain errors.View in English

渲染属性和无头组件

到目前为止,我们写的每个组件都有样子:都会渲染出你能看见的东西。**无头组件**则不同,它没有自己的样式 UI。它存在的唯一目的是提供行为,然后渲染你传给它的任何 children。这听起来有点抽象,但仔细想想就会发现,很多界面组件本质上都是同一种行为穿着不同的衣服:收藏星标、下拉菜单、暗色模式开关、还有"显示更多"的展开区域,它们都是一个布尔值加一个切换函数。一个无头的 Toggle 组件把这个逻辑写一次,然后每个具体的组件就可以用不同的标记组合在它周围。

本章先跟着课程把 Toggle 做成一个复合组件家族,通过上下文协调,然后会遇到一堵墙,那就是本章第二个主要思想的由来:渲染属性,这是一种模式,用来向组件的调用者暴露组件内部的状态。

一个无头的 Toggle 家族

Toggle 拥有一个布尔值,通过一个作用域化的 ToggleContext provider 把它和一个 toggle 函数提供出去。家族中的其他成员消费这个上下文,每一块都挂在 Toggle 上作为一个属性(Toggle.Button = ToggleButton),这是复合组件中的点语法:

jsx
<Toggle>
  <Toggle.Button>
    <Star />
  </Toggle.Button>
  <Toggle.On>状态是开启的</Toggle.On>
  <Toggle.Off>状态是关闭的</Toggle.Off>
</Toggle>
  • Toggle.Button 在一个 <button type="button"> 里渲染它的 children,点击时会翻转状态。children 不需要自己处理点击事件;点击 button 内部的任何东西都会冒泡到包装器,这靠事件冒泡来完成,通过 React 的合成事件系统而不是 button 本身上的监听器。一个可点击的 <div> 对鼠标用户来说行为相同,但会失去键盘激活和屏幕阅读器的提示,这些内容在无障碍设计章节有介绍。
  • Toggle.On 在状态为真时渲染它的 children,否则返回 null。Toggle.Off 反过来。

星标图标、菜单标记、标签,这些都来自调用者。这就是这个组件在完全不同的界面上都能重用的原因:课程把同一个 Toggle 用在星标下面和菜单下面,菜单内部用自己的元素包装 Toggle 的元素,所以使用 <Menu> 的人永远看不到内部机制。

让外部能够监听

真实的星标按钮不仅会重新渲染,还要告诉服务器有人点击了它。所以无头组件也需要自己的事件监听属性,形状和你在children 和组合章节写的自己组件的 onClick 一样。Toggle 接受一个 onToggle 函数,每当状态改变时从副作用里调用它:

jsx
function Toggle({ children, onToggle = () => {} }) {
  const [on, setOn] = useState(false)
  const toggle = () => setOn(prevOn => !prevOn)
  const firstRender = useRef(true)

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false
    } else {
      onToggle()
    }
  }, [on])

  // provider 和 children 在下面渲染
}

这里有两个细小但关键的细节。这个 ref 保护副作用在第一次运行时的行为:副作用会在初始渲染之后也触发,如果宣布一个没有人执行过的"toggle"是个 bug,所以 ref 用来跟踪是不是还在第一次渲染,而不是用状态那样会触发重新渲染的方式。还有 = () => {} 这个默认值是一个空操作函数,这样调用者如果不关心这个事件,组件调用 onToggle() 时也不会崩溃。

依赖数组里只有 on。加上 onToggle(这是 exhaustive-deps 要求的),会在调用者每次传一个新的内联函数时都重新运行副作用,所以这是 lint 规则和实际意图不一致的地方。ref 防守里还有一个细节在开发环境里出现。StrictMode 在挂载时会运行每个副作用两次,这会在第一次运行时用掉 ref,让回调在第二次运行。生产环境不会这样做,更持久的修复办法是从变化本身来推导行为,比较 on 和之前的值,而不是从"这是不是第一次渲染"来推导。

渲染属性:把内部交到外部

现在这个模式遇到了一堵墙。给一个盒子加上 CSS 背景颜色的过渡,在 Toggle.On 里渲染填充版本,在 Toggle.Off 里渲染空心版本,过渡永远不会播放。原因是:Toggle.OnToggle.Off 会挂载和卸载它们的 children。React 不是在改一个元素的类,而是移除一个元素再插入另一个,CSS 没办法为一个已经不存在的元素加过渡。调用者需要的是 on 这个状态本身,这样一个始终存在的元素才能改变自己的类名。但状态被困在 Toggle 里面。

逃脱的办法是 JavaScript 里一个熟悉的手段:回调反转控制。当你调用 addEventListener("click", callback) 时,你提供函数,但浏览器来调用它,浏览器决定它接收什么(事件对象)。组件可以提供同样的交易。你传给它一个函数,组件会用它的内部状态来调用这个函数,然后渲染函数返回的东西:

jsx
function ToggleDisplay({ children }) {
  const { on } = useContext(ToggleContext)
  return children(on)
}

Toggle.Display = ToggleDisplay
jsx
<Toggle.Display>
  {on => <div className={`box ${on ? 'filled' : ''}`} />}
</Toggle.Display>

这次 Toggle.Display 的 children 不是元素,而是一个函数。Toggle.Display 调用它,把 on 传进去,返回它得到的 JSX。现在 div 是一个始终存在的元素,每次切换时只是改类名,过渡播放了。这就是渲染属性模式:一个属性,它的值是一个函数,组件调用这个函数来决定要渲染什么。有些 API 用过一个字面意思上叫 render 的属性,这就是这个模式名字的来源;把函数作为 children 传进去是坚持下来的形式。

渲染属性解决的是组件和调用者之间的状态共享;自定义 Hook 解决的是普通函数之间的状态共享,Hook 出现以后生态把大多数渲染属性 API 迁移过去了。一个 useToggle() Hook 返回 ontoggle,没有额外组件,树里没有嵌套,也没有函数式 children 的间接性,所以曾经提供 <Downshift> 风格渲染属性的库现在提供 useSelect 风格的 Hook。

在提供方必须是组件的地方,渲染属性仍然有其位置:当暴露的值和一个特定的渲染元素绑定时(测量、定位),或者当一个库想在边界组件里把控什么东西会被渲染,一个捕获渲染错误或持有加载状态并决定显示什么内容的边界组件。见到这个模式就认出来,当组件边界本身重要时就用它,当不重要时就倾向用 Hook。

关于 Toggle.Display 的一个结构上的说明:在渲染时调用 children(on) 意味着每次 on 切换,Toggle.Display 都会重新调用这个函数并重新渲染它返回的值。这就是目的,也是成本;渲染属性是一种订阅,函数体里的所有东西都会随着它订阅的状态重新渲染。把函数体保持小,当重的子树不依赖这个值时就把它推到函数外面。

Juno没有脸的行为 无头组件是那种做什么事但自己不显示什么的组件:一个 Toggle 记录开关状态,你来提供它包装什么。它的助手在状态开或关时渲染你的内容。

当你需要实际的真假值在你这边时,你就把一个函数作为 children 传给组件;组件用这个值来调用你的函数,你的函数返回什么,什么就出现在页面上。

Juno没有脸的行为 把无头组件做成一个在作用域化上下文上的复合家族:一个拥有状态的父组件,一个通过包装点击处理来翻转状态的 Button 部分,一个有条件地渲染 children 的 On 和 Off 部分。用像 onToggle 这样的事件属性来暴露变化,默认为空操作,从一个第一次渲染 ref 防守的副作用里触发。

当调用者需要原始状态时,给他们一个 Display 部分,它把 children 作为函数来调用并传入状态,这就是渲染属性模式。

Juno没有脸的行为 无头组件把行为和标记分开,这样一个逻辑可以为视觉上毫不相关的组件服务;像 On 和 Off 这样的条件渲染部分会重新挂载它们的 children,所以任何需要连续性的东西,包括 CSS 过渡,都需要通过渲染属性来暴露状态。

把渲染属性当作用 JSX 进行的控制反转和一个有重新渲染成本的订阅来看待,除非组件边界本身有意义,否则更倾向于用自定义 Hook 做同样的事。

接下来:自定义 Hook 实战,把同样的逻辑移到函数里,组件消失。