【翻译】第四章 使用渲染属性(render props)进行高级配置。

334 阅读8分钟

在上一章,我们讨论了可以把元素作为属性传递来解决代码的灵活性问题、组件的配置问题。也讨论了如何为作为属性来传递的元素配置默认属性。但是把元素作为属性这一模式,无论其多强大,但依旧无法为我们处理所有问题。如果一个通过属性接收其他组件的组件需要以某种明确而非神奇的方式影响它们的属性或向它们传递一些状态,那么将元素作为属性以及 cloneElement 函数在这里就没什么帮助了。

这一章会为大家介绍渲染属性模式(render props)。大家将会学到:

  • 什么是渲染属性模式(render props,它处理了什么元素作为属性(elements as props)模式不能处理的问题。
  • 如何向渲染属性分享状态化逻辑,以及渲染属性的子组件长什么样。
  • 为什么我们现在不使用这一模式了,我们现在有钩子函数。
  • 渲染属性在分享逻辑模式时仍然有用,即使是在钩子函数里。

问题

这是我们在上一章实现的Button组件。

const Button = ({ appearance, size, icon }) => {
    // create default props
    const defaultIconProps = {
        size: size === 'large' ? 'large' : 'medium',
        color: appearance === 'primary' ? 'white' : 'black',
    };
    const newProps = {
        ...defaultIconProps,
        // make sure that props that are coming from the icon override
        default if they exist
        ...icon.props,
    };
    // clone the icon and assign new props to it
    const clonedIcon = React.cloneElement(icon, newProps);
    
    return (
        <button className={`button ${appearance}`}>
            Submit {clonedIcon}
        </button>
    );
};

这个按钮组件接收一个icon元素,并为其设置了默认尺寸和颜色。

尽管这个模式可以很好的处理一些简单的场景,但无法用于复杂的场景。如果我想引入一些状态到Button组件,并在组件内消费该状态,该怎么办?比如,当有鼠标在Button组件在滑动时,要用一个状态来控制icon组件。在Button组件内实现该状态是很容易的:

const Button = ({ ... }) => {
    const [isHovered, setIsHovered] = useState();
  
    return <button onMouseOver={() => setIsHovered(true)} />
}

但是之后呢,如何把这个状态分享给icon?

使用该模式的另一个问题是,我们已经为当作元素传递的icon设立了预设前提。我们希望icon组件有size和color属性。但是,如果我们使用了没有这两个属性的样式库,我们该怎么办。我们为icon设置的默认属性逻辑将无法运行。

用于渲染元素的渲染属性。

幸运的是,正如之前所说,我们通过React有无数种方法可以解决这个问题。在这个案例中,与其讲一个元素作为属性传递进去,我们可以讲一个渲染函数作为属性传递进去。一个渲染属性,本质上就是一个返回元素的函数罢了。这个函数和组件几乎上是一样的。它们两者的区别在于,你无法直接调用一个组件,而是由React替你调用;但是渲染函数则是任由你掌控了。

在这个案例中,我们可以通过渲染属性来解决这个问题:

// instead of "icon" that expects an Element
// we're receiving a function that returns an Element
const Button = ({ renderIcon }) => {
  // and then just calling this function where the icon should be rendered
    return <button>Submit {renderIcon()}</button>;
};

我们接收一个renderIcon函数,并在它要渲染的地方调用它。之后,回到调用方,我们不再直接传递icon元素,而是传递一个会返回icon元素的渲染函数:

<Button renderIcon={() => <HomeIcon />} />

之后,我们依据实际需求配置icon的属性即可:

// red icon
<Button renderIcon={() => <HomeIcon color="red" />} />

// large icon
<Button renderIcon={() => <HomeIcon size="large" />} />

那么,调用这个函数的要点是什么?第一个要点是icon的属性。相比于老旧的通过cloneElement函数来拷贝元素,我们可以直接把属性对象传递给这个函数:

const Button = ({ appearance, size, renderIcon }) => {
    // create default props as before
    const defaultIconProps = {
        size: size === 'large' ? 'large' : 'medium',
        color: appearance === 'primary' ? 'white' : 'black',
    }
    
    // and just pass them to the function
    return (
        <button>Submit {renderIcon(defaultIconProps)}</button> 
    )
};

之后,在icon组件的视角,我们可以接收这个属性对象并展开它:

<Button renderIcon={(props) => <HomeIcon {...props}/>} />

当然,我们也可以重写一些属性:

<Button
    renderIcon={(props) => (
        <HomeIcon {...props} size="large" color="red" />
    )}
/>

通过这个方式也可以重写一些属性:

<Button
    renderIcon={(props) => {
        <HomeIcon
            fontSize={props.size}
            style={{ color: props.color }}
        />
    }}
/>

代码示例: advanced-react.com/examples/04…

一切都是明确清晰的,不存在通过某种隐藏的魔法来覆盖任何东西的情况。数据的流动虽然有点绕,但却是可见且可追踪的。

image.png

如此一来,把状态共享也不再是难题。我们只要把状态合并入要传递的属性对象即可。

const Button = ({ appearance, size, renderIcon }) => {
     const [isHovered, setIsHovered] = useState(false);
     
     const iconParams = {
         size: size === 'large' ? 'large' : 'medium',
         color: appearance === 'primary' ? 'white' : 'black',
         // add state here - it's just an object after all
         isHovered,
     }

    return <button ...>Submit {renderIcon(iconParams)}</button>
}

或者,我们可以把状态当作渲染函数的第二个参数:

const Button = ({ appearance, size, renderIcon }) => {
     const [isHovered, setIsHovered] = useState(false);
     
     const iconParams = {
         size: size === 'large' ? 'large' : 'medium',
         color: appearance === 'primary' ? 'white' : 'black',
         // add state here - it's just an object after all
         isHovered,
     }

    return <button ...>Submit {renderIcon(iconParams, { isHovered })}</button>
}

之后,我们来的icon的视角:

const icon = (props, state) => state.isHovered ? <HomeIconHovered {...props} /> : <HomeIcon {...props}/>

<Button renderIcon={icon} />

或者,通过className来区分:

const icon = (props, state) => <HomeIcon className={state.isHovered ? 'hovered': ''} {...props}/>

<Button renderIcon={icon} />

代码示例: advanced-react.com/examples/04…

共享有状态逻辑:将子组件作为渲染属性

渲染属性模式的另一个用处是共享有状态逻辑,并且这常常和“子组件作为属性”模式混用。正如前面的章节提到的,当“子组件”被类HTML的嵌套语法调用时,“子组件”本质上还是一个属性

<Parent>
    <Child />
</Parent>

// excatly the same as above
<Parent children={<Child />}/>

所以,我们也可以把子组件通过函数来传递。我们甚至不需要render、renderSomething这些命名:

// make it a function
<Parent children={() => <Child />} />

在Parent组件的视角,你可以像调用任何其他渲染属性一样调用它:

const Parent = ({ children }) => {
// it's just a function that returns an element, just call it here
    return children()
}

当然,也可以使用嵌套语法写:

<Parent>{() => <Children />}</Parent>

代码示例: advanced-react.com/examples/04…

为什么这个模式是有用的?想象一下,你在实现一个“尺寸调整探测器” 组件。这个组件用于捕捉浏览器窗口尺寸的变化:

const ResizeDetector = () => {
    const [width, setWidth] = useState();
    
    useEffect(() => {
        const listener = () => {
            const width = window.innerWidth;
            setWdith(width)
        }
        window.addEventlistener("resize", listener)
    }, [])
    return ...
}

但是,这意味着任何想要调用该组件的父组件,需要为此维护一个状态:

const Layout = () =>{
     const [windowWidth, setWindowWidth] = useState(0);
     
     return (
         <>
             <ResizeDetector onWindowWidth={setWindowWidth} />
             {windowWidth > 600 ? (
                 <WideLayout />
             ): (
                 <NarrowLayout />
             )}
         </>
     )
}

这样的代码结构是有一些混乱的。

我们可以这样:让ResizeDetector组件的children属性接收一个以width为入参的渲染函数:

const ResizeDetector = ({ children }) => {
    const [width, setWidth] = useState();
    
    // same code as before
    
    // pass width to children
    return children(width)
}

之后,任何需要窗口宽度的组件,只要直接调用ResizeDetector即可,无需引入多余的状态:

const Layout = () => {
    return (
        <ResizeDetector>
            {(windowWidth) => {
                // no more state! Get it directly from the resizer
                return windowWidth > 600 ? (
                    <WideLayout />
                ): (
                    <NarrowLayout />
                )
            }}
        </ResizeDetector>
    );
}

代码示例: advanced-react.com/examples/04…

在实际场景中,我们还要考虑重新渲染问题:每一次窗口宽度变化,都会触发状态更新。所以我们需要在探测器内部计算布局,或者节流这一更新。但是,分享状态的逻辑还是一样的。

当然,在现在的代码中,我们也很少使用这个模式了,因为...

钩子函数取代了渲染属性

任何最近两年使用React写代码的人,看到上文的代码都会想:”哎呀妈呀,你说的这些没意义。用钩子函数来实现共享状态逻辑来实现不就行了,何必搞得这么复杂?“

是的,这是对的。钩子函数在99%的场景,都可以替代渲染属性模式。我们可以用钩子函数重写刚刚的代码:

const useResizeDetector = () => {
    const [width, setWidth] = useState();
    
    useEffect() => {
        const listener = () => {
            const width = ... // get window wdith here
            setWidth(wdith);
        }
        window.addEventListener("resize", listener);
        // the rest of the code
    }, [])
    
    return width;
}

我们把ReszieDetector组件的逻辑提取成一个钩子,这个钩子可以到处使用:

const Layout = () => {
    const windowWidth = useResizeDetector();
    
    return windowWidth > 600 ? (
        <WideLayout />
    ): (
        <NarrowLayout />
    )
}

代码示例: advanced-react.com/examples/04…

如此一来,代码量下去了,代码可读性也上来了。

既然如此,为什么还要学习这个模式?以下是要学习的原因:

  • 渲染属性模式在提升代码的可配置性和灵活性上,依然富有生命力。
  • 这个模式在老项目中依旧存在。不少老开源库依然使用这一模式。
  • 这个模式在特定场景依然适用:当你想要共享的逻辑和状态依赖于一个DOM元素

最后一种用例的一个非常常见的示例是跟踪某个区域内的滚动情况:

const ScrollDetector = ({ children }) => {
    const [scroll, setScroll] = useState();
    
    return (
        <div
            onScroll={(e) => setScroll(e.currentTarget.scrollTop)}
        >
            {children}
        </div>
    );
};

这个场景和之前的场景一样:你想把一些状态共享给其他组件。使用props会使代码看起来混乱。但是,抽象到一个钩子也很麻烦:因为你需要挂在div元素上的onScroll事件来获取状态,而不是直接从window获取。因此,你需要引入一个Ref并传递这个Ref(在第九章会讲到)。或者,使用渲染属性模式即可:

const ScrollDetector = ({ children }) => {
    const [scroll, setScroll] = useState();
    
    return (
        <div
            onScroll={(e) => setScroll(e.currentTarget.scrollTop)}
        >
            {children}
        </div>
    )
}

然后,依据scroll值的大小,来决定内容的展示:

consst Layout = () => {
    return (
        <ScrollDetector>
            {(scroll) => {
                return <>{scroll > 30 ? <SomeBlock /> : null }</>
            }}
        </ScrollDetector>
    )
}

代码示例: advanced-react.com/examples/04…

知识概要

希望这篇文章能够让你理解这个模式。希望能记住本章节这些要点:

  • 如果一个组件使用了元素作为属性,并且希望这些元素可以接受属性或者状态,你需要把这些元素转换为渲染属性:
const Button = ({ renderIcon }) => {
    const [someState, seSomeState] = useState();
    const someProps = {...};
    reutrn <button>Submit {renderIcon(someProps, someState)}</button>;
};

<Button renderIcon={(props, state) => <IconComponent {...props} someProps={state}/>} />
  • 子组件属性也可以通过嵌套语法,适用于渲染属性模式
const Parent = ({ children }) => {
    return children(someDate);
}
  • 在处理共享状态逻辑的问题时,使用渲染属性模式可以高效的解决这类问题。而且不需要进行父子组件传值。
  • 在99%的情况下,使用钩子函数就能处理这类问题。
  • 渲染属性模式在处理共享状态逻辑时,还是有用的,比如,一些状态要关联到DOm元素时。