渲染属性和无头组件
到目前为止,我们写的每个组件都有样子:都会渲染出你能看见的东西。**无头组件**则不同,它没有自己的样式 UI。它存在的唯一目的是提供行为,然后渲染你传给它的任何 children。这听起来有点抽象,但仔细想想就会发现,很多界面组件本质上都是同一种行为穿着不同的衣服:收藏星标、下拉菜单、暗色模式开关、还有"显示更多"的展开区域,它们都是一个布尔值加一个切换函数。一个无头的 Toggle 组件把这个逻辑写一次,然后每个具体的组件就可以用不同的标记组合在它周围。
本章先跟着课程把 Toggle 做成一个复合组件家族,通过上下文协调,然后会遇到一堵墙,那就是本章第二个主要思想的由来:渲染属性,这是一种模式,用来向组件的调用者暴露组件内部的状态。
一个无头的 Toggle 家族
Toggle 拥有一个布尔值,通过一个作用域化的 ToggleContext provider 把它和一个 toggle 函数提供出去。家族中的其他成员消费这个上下文,每一块都挂在 Toggle 上作为一个属性(Toggle.Button = ToggleButton),这是复合组件中的点语法:
<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 函数,每当状态改变时从副作用里调用它:
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.On 和 Toggle.Off 会挂载和卸载它们的 children。React 不是在改一个元素的类,而是移除一个元素再插入另一个,CSS 没办法为一个已经不存在的元素加过渡。调用者需要的是 on 这个状态本身,这样一个始终存在的元素才能改变自己的类名。但状态被困在 Toggle 里面。
逃脱的办法是 JavaScript 里一个熟悉的手段:回调反转控制。当你调用 addEventListener("click", callback) 时,你提供函数,但浏览器来调用它,浏览器决定它接收什么(事件对象)。组件可以提供同样的交易。你传给它一个函数,组件会用它的内部状态来调用这个函数,然后渲染函数返回的东西:
function ToggleDisplay({ children }) {
const { on } = useContext(ToggleContext)
return children(on)
}
Toggle.Display = ToggleDisplay<Toggle.Display>
{on => <div className={`box ${on ? 'filled' : ''}`} />}
</Toggle.Display>这次 Toggle.Display 的 children 不是元素,而是一个函数。Toggle.Display 调用它,把 on 传进去,返回它得到的 JSX。现在 div 是一个始终存在的元素,每次切换时只是改类名,过渡播放了。这就是渲染属性模式:一个属性,它的值是一个函数,组件调用这个函数来决定要渲染什么。有些 API 用过一个字面意思上叫 render 的属性,这就是这个模式名字的来源;把函数作为 children 传进去是坚持下来的形式。
当你需要实际的真假值在你这边时,你就把一个函数作为 children 传给组件;组件用这个值来调用你的函数,你的函数返回什么,什么就出现在页面上。
接下来:自定义 Hook 实战,把同样的逻辑移到函数里,组件消失。

