【翻译】第七章 高阶组件

121 阅读9分钟

接下来,我们要讨论的是高阶组件。在钩子函数出现前,高阶组件是用于共享状态和上下文的最佳模式。这个模式在今天也是常用的,特别是老的库。所以,也许在现在的代码中使用该模式也许不是最合适的,但是理解其工作原理还是必要的。

那么让我们从头开始,并在这个过程中学习吧:

  • 什么是高阶组件模式
  • 我们要如何使用高阶组件来提升回调函数和React生命周期事件?
  • 传递数据给高阶组件的不同方式
  • 如何创建可拦截DOM和键盘事件的可复用组件。

什么是高阶组件

根据 React 文档所述,高阶组件是一种用于复用组件逻辑的高级技术,该技术常用于处理横切关注点(跨多个模块的通用逻辑问题)。

在英语中,高阶组件是把组件作为其中一个参数,执行特定逻辑,然后依据参数组件返回另一个组件的函数。

它最简单的变体(什么都不做的那种)如下:

// accept a Component as an argument
const withSomeLogic = (Component) => {
    // do something
    
    // return a component that renders the component from the argument
    return (props) => <Component {...props} />
}

这段代码的核心在于其返回函数 - 它是一个组件,正如其他组件一样。

之后,如果要使用它,是这么使用:

// just a button
const Button = ({ onClick }) => (
    <button oncClick={onClick}>Button</button>
)

// same button, but with enchanced functionality
const ButtonWithSomeLogic = withSomeLogic(Button);

你将你的按钮(Button)组件传递给这个函数,然后它会返回一个新的按钮组件,这个新组件包含了在高阶组件中定义的任何逻辑。之后,这个按钮就可以像其他任何按钮一样被使用了:

const SomePage = () => {
    return (
        <>
            <Button />
            <ButtonWithSomeLogic />
        </>
    )
}

人们常常会用这个模式来为组件注入一些属性。比如说,我们可以实现一个withTheming组件来提取当前浏览器的明暗模式,然后再把这个模式传递给theme属性。这是代码:

const withTheme = (Component) => {
    // isDark will come from something like context
    const theme = isDark ? 'dark' : 'light';
    
    // making sure that we pass all props to the component back
    // and also inject the new one: theme
    return (props) => <Component {...props} theme={theme} />
}

而现在,如果我们将它应用到我们的按钮上,那么这个按钮就能使用 theme 这个属性了:

const Button = ({ theme }) => {
    // theme prop here will come from withTheme HOH below
    return <button theme={theme} ...>Button</button>
}

const ButonWithTheme = withTheme(Button);

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

在进入钩子函数时代之前,高阶组件被广泛地用于访问上下文和数据订阅。Redux的connect函数和React Router的withRouter函数都是高阶函数。

如你所见,高阶函数是比较复杂的,因此难于编写和理解。所以当钩子函数被推广后,很多人转向了钩子函数。

有了钩子函数后,我们可以更简单地实现刚才的功能:

const Button = () => {
    // we see immediatle where the theme is coming from
    const { theme } = useTheme();
    
    return <button appearance={theme} ...>Button</button>
}

这种写法如此明晰,以至于从头读到尾就可以明白这个过程发生了什么,大大的简化了调试和开发的流程。

尽管钩子函数在很多场景取代了高阶函数,高阶函数在当下还是很有用的。特别是在提升回调函数性能,处理React生命周期,拦截DOM和键盘事件时,高阶组件还是很有用的。

让我看看高阶组件在这些场景是如何发挥作用的。

提升回调函数

设想一下,你需要针对某些回调发送某种高级日志记录。例如,当你点击一个按钮时,你想要发送一些带有相关数据的日志记录事件。使用钩子(hooks)的话你会怎么做呢?你很可能会有一个带有 onClick 回调的按钮(Button)组件:

const Button = ({ onClick, children }) => {
    return <button onClick={onClick}>{children}</button>
}

而在消费者的视角,你可以在onClick函数内调用高级日志的钩子:

const SomePage = () => {
    const log = useLoggingSystem();
    
    const onClick = () => {
        log('Button was clicked');
    };
    return <Button onClick={onClick}>Click here</Button>;
};

如果你只想触发一两个事件的话,这样做没问题。但要是你希望无论何时点击按钮,整个应用程序中都能持续触发日志记录事件,那该怎么办呢?我们或许可以将其融入按钮(Button)组件本身当中:

const Button = ({ onClick }) => {
    const log = useLoggingSystem();
    
    const onButtonClick = () => {
        log('Button was clicked');
        onClick();
    };
    
    return <button onClick={onButtonClick}>Click me</button>;
  };

但这之后呢?为了更好地打日志,你还需要传送一些数据:

const Button = ({ onClick, loggingData }) => {
    const onButtonClick = () => {
        log('Button was clicked', loggingData);
        onClick();
    }
    
    return <button onClick={onButtonClick}>Click me</button>
}

如果你想把这个打日志的功能扩展到其他组件,该怎么办?人们在app上可不仅仅是点击Button。如果我想对ListItem组件做同样的事情,该怎么办?把同样的逻辑拷贝过去吗?

const ListItem = ({ onClick, loggingData }) => {
    const onListItemClick = () => {
        log('List item was clicked', loggingData);
        onClick();
    }
    
    return <button onClick={onButtonClick}>Click me</button>
}

太多的复制粘贴操作了,容易出错,而且可能会有人忘记按照我的要求更改某些内容。

从本质上讲,我想要的是将 “某个东西触发了 onClick 回调 —— 发送一些日志记录事件” 这样的逻辑封装在某个地方,然后可以在我想要的任何组件中复用它,而无需以任何方式更改那些组件的代码。

而这个效果,钩子函数是无法实现的,而高阶组件可以实现。

与其四处拷贝刚刚的逻辑,我们可以生成一个withLoggingOnClick函数:

  • 以组件为参数
  • 拦截这个组件的onClick回掉
  • 把数据发送出去
  • 返回带有完好无损的 onClick 回调的组件以供后续使用。

代码会是这样的:

// just a function that accepts Component as an argument
export const withLoggingOnClick = (Component) => {
    return (props) => {
        const onClick = () => {
            console.log('Log on click something');
            
            props.onClick();
        }
        
        return <Component {...props} onClick={onClick} />;
    }
}

现在,我可以把这个函数用于任何组件。比如:

export const ButtowWithLoggingOnClick =
    withLoggingOnClick(SimpleBUtton);

又或者:

export const ListItemWithLoggingOnClick =
    withLoggingOnClick(ListItem);

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

为高阶组件添加数据

现在,我们可以为打日志函数添加外部数据。我们知道高阶组件本质上是一个函数,所以添加外部数据并不难。我们只要为这个函数添加另一个参数:

export const withLogginOnClickWithParams = (
    Component,
    // adding some params as a second argument to the fucntion
    params,
) => {
    return (props) => {
        const onClick = () => {
            console.log('Log on click', params.text);
            props.onClick();
        }
        
        return <Component {...props} onClick={onClick} />;
    }
}

现在,当我们用一个高阶组件来包裹按钮时,我们可以传递我们想要打印的文本:

const ButtonWithLoggingOnClickWithParams = 
    withLogginOnClickWithParams(simpleButton, {
        text: 'button component',
    })

而在消费者的视角,我们把这个按钮组件当作普通的按钮组件即可,不用担心传递打印文本的事情:

const Page = () => {
    return (
        <ButtonWithLoggingOnClickWithParams
            onClick={onClickCallback}
        >
            Click me
        </ButtonWithLoggingOnClickWithParams>
    )
}

但是,如果我想自定义传递打印文本,该怎么办?

这也是很容易解决的:与其把要打印的文本当作高阶组件函数的参数传递,我们可以把它当做返回结果的函数的属性。这是代码:

export const withLogginOnClickWithParams = (Component) => {
    // it will be in the props here, just extract it
    return ({ logText, ...props }) => {
        const onClick = () => {
            // and then just use it here
            console.log('Log on click: ', logText);
            props.onClick();
        }
        
        return <Component {...props} onClick={onClick} />;
    }
}

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

提升React生命周期事件

我们不仅仅可以使用高阶组件处理事件和回调函数。记住,我们在处理的都是组件,所以我们可以使用React提供的能力来处理它们。比如,我们可以在一个组件被挂载时,打印相关日志:

export const withLoggingOnMount = (Component) => {
    return (props) => {
        // no more overriding onClick
        // use normal useEffect - it's just a component!
        useEffect(() => {
            console.log('log on mount');
        }, []);
        
        // and pass back props intact
        return <Component {...props} />
    }
}

甚至可以读取属性(props),并且当某个属性发生变化时,在重新渲染时发送它们:

export const withLoggingOnReRender = (Component) => {
    return ({ id, ...props }) => {
        // fire logging every time "id" prop changes
        useEffect(() => {
            console.log('log on mount');
        }, [id]);
        
        // and pass back props intact
        return <Component {...props} />
    }
}

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

拦截DOM事件

高阶组件的另一个使用场景,就是拦截DOM事件和键盘事件。想象一下,你需要为你的应用实现快捷键功能。当特定的按钮被按下时,你想实现类似打开对话框的功能。你也许会为window添加一个事件监听器:

useEffect(() => {

     const keyPressListener = (event) => {
     // do stuff
     };
     
     window.addEventListener('keypress', keyPressListener);
     
     return () =>
         window.removeEventListener(
             
         );'keypress',
             keyPressListener,
}, []);

然后,你的应用程序有不同的部分,比如模态对话框、下拉菜单、抽屉式菜单等等,当这些对话框打开时,你会希望阻止那个全局监听器起作用。如果只有一个对话框,你可以手动地给对话框本身添加 onKeyPress 事件处理函数,并且在那里针对该事件执行 event.stopPropagation() 操作。 这样就能阻止事件继续向上传播,避免触发全局监听器的相关逻辑了,保证在该对话框处于打开状态时,其内部操作不受全局监听器干扰。

export const Modal = ({ onClose }) => {
    const onKeyPress = (event) => event.stopPropagation();
    
    return (
        <div onKeyPress={onKeyPress}>...// dialog code</div>
    );
};

但这个场景和onClick的打印日志很像 - 如果你要对多个组件实现这个逻辑,该怎么办?把这段代码四处复制吗?

我们可以使用一个高阶组件来解决这个问题:

export const withSuppressKeyPress = (Component) => {
     return (props) => {
         const onKeyPress = (event) => {
             event.stopPropagation();
         };
         return (
             <div onKeyPress={onKeyPress}>
                 <Component {...props} />
             </div>
         );
     };
};

然后,我们就可以把任何组件传递进去了:

const ModalWithSuppressedKeyPress =
    withSuppressKeyPress(Modal);
const DropdownWithSuppressedKeyPress =
    withSuppressKeyPress(Dropdown);
// etc

现在,当这个模态框处于打开且获取焦点的状态时,任何按键按下事件都会沿着元素层级向上冒泡,直至到达我们那个包裹着模态框的 withSuppressKeyPress 中的 div 元素,然后就会在那里停止传播。这样一来,任务就完成了,而且实现模态框组件的开发人员甚至都无需知晓或关心这件事。

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

知识概要

  • 高阶组件就是一个接受组件作为参数,并返回一个新组件的函数。这个新组件会渲染作为参数传入的那个组件。
  • 我们可以将属性(props)或者额外的逻辑注入到被高阶组件包裹的组件当中。以下是示例代码:

// accept a Component as an argument
const withSomeLogic = (Component) => {
    // inject some logic here
    // return a component that renders the component from the
    argument
    // inject some prop to it
    return (props) => {
        // or inject some logic here
        // can use React hooks here, it's just a component
        return <Component {...props} some="data" />;
    };
};
  • 我们可以通过函数的参数或者属性(props)的方式,向高阶组件传递额外的数据。