编程知识 cdmana.com

React调用子组件方法与命令式编程误区

8d19cf2cbf295ac3f2fe2010e041d51b.png


本文将阐述以下内容:

  1. 调用DOM元素方法
  2. 调用React子组件方法的两种直接方案
  3. 自省组件结构设计是否合理 -- 探讨声明式编程与命令式编程在React开发中的问题
  4. 调用React子组件方法的最佳方案
  5. 总结


基础需求:调用 DOM元素 的方法

使用原生的JavaScript开发中,页面主动调用某个html元素的方法是 十分常见的操作

比如下面的按钮被点击后,除了增加计数器的值,还会自动让光标出现在输入框里

<body>
<div>
    Counter: <span id="counter">0</span>
</div>
<br/>
<button id="increase-button">Click To Increase Counter and Focus Input</button>
<br/>
<br/>
<label for="input">
    Name:
</label>
<input id="input"/>

</body>
<script>
  const counter = document.querySelector("#counter");
  const input = document.querySelector("#input");

  document.querySelector("#increase-button").addEventListener("click", e => {
    counter.innerText = (parseInt(counter.innerText) + 1).toString();
    input.focus(); // 调用input的focus方法
  });

</script>

1b58c561f51ca1d8fe128e78b6c6fdf1.jpeg


再来看看,使用React怎么实现相同的效果

const Counter = () => {
  const [counter, setCounter] = useState(0);
  const inputRef = useRef(); // 关键 1
  const increaseHandler = () => {
    setCounter(counter + 1);
    inputRef.current.focus(); // 关键 2
  }

  return (
      <div>
        <div>
          Counter: <span>{counter}</span>
        </div>
        <br/>
        <button onClick={increaseHandler}>Click To Increase Counter and Focus Input</button>
        <br/>
        <br/>
        <label htmlFor="input">
          Name:
        </label>
        <input id="input" ref={inputRef}/> // // 关键 3
      </div>
  );
};

export default Counter;

上面这段代码主要三个关键点:

  1. 使用 useRef hook,产生一个 ref
  2. 在 button click handler 里调用 inputRef.current.focus()
  3. 在 input 加一个 ref={inputRef} 的属性


进阶需求:调用React子组件的方法

上面的 input 是基本的DOM元素,所以调用它的方法看起 来逻辑很直观。但是,如果把 input 换成 React 组件,又需要怎么实现呢?

假设有以下一个React组件叫 ColorLight 模拟一个彩灯,每次点击都有更换一次颜色

913c0e4401189ec8b24035b12c1c4b65.gif

我们想把这个 ColorLight 替换掉上面的 input ,使得按钮每次被点击后,除了增加计数器的值,还会自动更改 ColorLight 的颜色

import React, {useRef, useState} from 'react';
import ColorLight from './ColorLight';

const Counter2 = () => {
  const [counter, setCounter] = useState(0);
  const ref = useRef();
  const increaseHandler = () => {
    setCounter(counter + 1);
    ref.current.changeColor(); // 改变 ColorLight 的颜色
  }

  return (
      <div>
        <div>
          Counter: <span>{counter}</span>
        </div>
        <br/>
        <button onClick={increaseHandler}>Click To Increase Counter and Focus Input</button>
        <br/>
        <br/>
        <ColorLight ref={ref}/>  // ref 引用 自定义 React 组件
      </div>
  );
};

export default Counter2;

295f42faa558f57c7cd8b0346c64f757.gif

上面的代码的 关键 —— 如何让 ColorLight 拥有一个 changeColor 方法呢

回顾一下 我们平常 写 React 组件内部的方法 通常是

  1. 自产自销,比如上面的 button 的 increaseHandler
  2. 传递给子组件使用,比如:
const Counter3 = () => {
  const [counter, setCounter] = useState(0);
  const inputRef = useRef();
  const increaseHandler = () => {
    setCounter(counter + 1);
  }

  return (
      <div>
        <div>
          Counter: <span>{counter}</span>
        </div>

        <AnotherChild onIncreaseCounter={increaseHandler} /> //
      </div>
  );
};

export default Counter3;

AnotherChild 这个组件就能通过调用它的 props.onIncreaseCounter() 来增加父组件(即Counter3) 的 count 值


以上这两种用法是React开发中最常用的,但是并不能符合(至少不能直接符合)上文提到的需求。


解决方案一:useImperativeHandle

ColorLight.js

import React, {forwardRef, useCallback, useImperativeHandle, useState} from 'react';

const style = {
  height: "50px",
  width: "50px",
  borderRadius: "50%",
};

function getRandomColor({
  var letters = '0123456789ABCDEF';
  var color = '#';
  for (var i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
}


const ColorLight = (props, ref) => {
  const [color, setColor] = useState("#bbb");
  const setRandomColor = useCallback(() => {
    setColor(getRandomColor())
  }, [setColor])

  // 最关键的代码: 第二个参数返回一个对象 —— 定义 父组件可以调用这个组件 的方法
  useImperativeHandle(ref, () => ({   
    changeColor: setRandomColor
  }), [setRandomColor])

  return (
      <div style={{...style, backgroundColor: color}}
           onClick={setRandomColor}/>
  );
};

export default forwardRef(ColorLight);  // 别忘了 forwardRef

上面代码中最核心的部分是使用了 useImperativeHandle ,其中第二个参数返回了一个对象 —— 定义了 父组件可以调用这个组件 的方法,即 ColorLight 暴露了一个方法叫 changeColor,对应的内部实现就是 setRandomColor

此外,记得调用 forwardRef ,否则父组件无法 ref 到这个组件。



这个方案的优点:

  1. ref={ref} 的写法跟上文获取 DOM元素的引用一样,一致性更好
  2. 使用React原生提供的方法和Hook,代码相对简洁



方案二:Props Callback + useEffect + useRef


Counter.js

import React, {useCallback, useRef, useState} from 'react';
import ColorLight from './ColorLight';
import ColorLight2 from './ColorLight2';

const Counter3 = () => {
  const [counter, setCounter] = useState(0);
  const ref = useRef(); 

  const increaseHandler = () => {
    setCounter(counter + 1);
    ref.current.changeColor();
  }

  // ref current 的 setter 方法,作为 props 传递给 子组件 ColorLight2
  const setChangeColorHandler = useCallback((handler) => {
    ref.current = {
      changeColor: handler
    }
  }, [ref]); // 

  return (
      <div>
        <div>
          Counter: <span>{counter}</span>
        </div>
        <br/>
        <button onClick={increaseHandler}>Click To Increase Counter and Focus Input</button>
        <br/>
        <br/>
        <ColorLight2 setChangeColorHandler={setChangeColorHandler}/> // props 的形式传入 setChangeColorHandler
      </div>
  );
};

export default Counter3;


ColorLight2.js

import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react';

const style = {
  height"50px",
  width"50px",
  borderRadius"50%",
};

function getRandomColor({
  var letters = '0123456789ABCDEF';
  var color = '#';
  for (var i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
}


const ColorLight2 = (props) => {
  const [color, setColor] = useState("#bbb");
  const setRandomColor = useCallback(() => {
    setColor(getRandomColor())
  }, [setColor])


  // 利用 useEffect 在渲染结束后调用 props.setChangeColorHandler 
  useEffect(() => { 
    props.setChangeColorHandler(setRandomColor)
  }, [props.setChangeColorHandler, setRandomColor]);

  return (
      <div style={{...style, backgroundColor: color}}
           onClick={setRandomColor}/>
  );
};

export default ColorLight2;


跟方案一的关键不同点是 —— 我们用自己的逻辑代码给 ref.current 设值:

通过 props 的形似将 setChangeColorHandler 传给子组件 (即 ColorLight2 ), ColorLight2 利用 useEffect 在渲染结束后调用 props.setChangeColorHandler 将内部方法 setRandomColor 传递回父组件


方案二的优点:

  1. 使用了比较常见的 hook (useRef, useEffect) 以及 props 传递回调函数的方式,相比生僻的 useImperativeHandle hook 更容易理解一些

缺点:

  1. 代码量相对多一点,代码逻辑更复杂一些,因此个人不推荐(个人看法,见仁见智)



自省:这种组件结构设计合理吗?

遗憾的是,大多数需要让父组件调用子组件方法的组件结构设计通常是不合理的。

因为这种写法已经接近于 命令式编程 (Imperative Programming)而不是 React推荐的 声明式编程 (Declarative Programming)

命令式编程: 使用代码详细地一步一步地指导程序应该怎么运行

声明式编程:描述程序目标的性质,关注的是目标,而非流程

举个例子:

Material-UI Dialog 对话框组件 是否打开是由 props中的 open 属性来控制的,而不是暴露一个 open() 方法 和 close() 方法来控制的。

前者属于声明式的,后者属于命令式的。

命令式有错吗?

不,命令式编程并没有错,如绝大多数的 C 程序都是命令式的。

只是在React的设计及开发中,推崇的是声明式编程。在 React 组件中,我们应该专注在 state 和props上,而View 是什么样的,我们只需要在 JSX 中声明好它跟 state/props的关系就好了。

回顾一下本文开头的例子:

命令式代码:

  const counter = document.querySelector("#counter");

  document.querySelector("#increase-button").addEventListener("click"e => {
    counter.innerText = (parseInt(counter.innerText) + 1).toString();
  });

按钮的点击回调函数里代码直接操纵了DOM节点,将counter的innerText设置成新值


而React代码

const Counter = () => {
  const [counter, setCounter] = useState(0);
  const increaseHandler = () => {
    setCounter(counter + 1);
  }

  return (
      <div>
        <div>
          Counter: <span>{counter}</span>
        </div>
        <button onClick={increaseHandler}>Click To Increase Counter and Focus Input</button>
      </div>
  );
};

在返回的 JSX 里,代码声明了 span元素里的文本对应的是这个组件里 counter 这个state的值。counter state 变成了什么值,React就帮我们自动在DOM上渲染出什么值 —— 我们无需直接去操纵DOM节点

那在React开发中我们自己的代码符合声明式编程又有什么好处呢?
比如提高代码可读性、可复用性,解耦,方便重构等,另外,我个人认为最重要的是 —— 契合React的设计,享受React设计带来的好处而规避不必要的坑 (when in rome do as the romans do )


扯的有点远了(有机会我专门写一篇文章聊聊React的声明式编程思想)~

有读者会问:“管它React推崇什么,产品经理还在催进度呢!我只想让实现我的业务逻辑!”

莫急!让我们看看,如何让代码既能实现业务逻辑又符合声明式编程。


方案三(推荐):状态提升

Counter.js

const Counter = () => {
  const [counter, setCounter] = useState(0);
  const [color, setColor] = useState("#bbb");
  const increaseHandler = () => {
    setCounter(counter + 1);
    setColor(getRandomColor())
  }

  return (
      <div>
        <div>
          Counter: <span>{counter}</span>
        </div>
        <br/>
        <button onClick={increaseHandler}>Click To Increase Counter and Focus Input</button>
        <br/>
        <br/>
        <ColorLight color={color}/>
      </div>
  );
};

export default Counter;

ColorLight.js

const ColorLight = (props) => {
  const {color} = props;

  return (
      <div style={{...style, backgroundColor: color}}/>
  );
};

export default ColorLight;

这次代码重构的核心是 —— 我们把 ColorLight 的 color state 删掉了,而是接收一个 color props 属性(状态提升)。这样一来 ColorLight 就变成了一个非常简单的纯组件,而如何改变颜色的逻辑被提升到了父组件那里。

这样的设计重构就摆脱了父组件需要调用子组件方法的需求,省却了大量麻烦。


当然,还有其他状态提升的方式,如把状态存放在 Redux 里,这种方案在某些场景也是可行的,这边就不举例了。



总结

当React组件设计不当的时候,会遭遇到调用子组件方法的需求,遇到这种情况,我们应尽力避免写出命令式的代码而是尝试重构组件设计,通过提升状态等方式完成需求。

当然,在特殊情况下(如 deadline将至,组件重构代价高),可以使用 React refs, useImperativeHandle 实现命令式代码完成需求。




如果觉得文章写的不错,对你有帮助有启发,欢迎点赞、喜欢、收藏三连,鼓励我写出更多文章!






参考链接:

https://stackoverflow.com/questions/37949981/call-child-method-from-parent

https://codeburst.io/declarative-vs-imperative-programming-a8a7c93d9ad2

https://reactjs.org/docs/forwarding-refs.html

https://reactjs.org/docs/refs-and-the-dom.html


版权声明
本文为[osc_oz0d1seh]所创,转载请带上原文链接,感谢
https://my.oschina.net/u/4383286/blog/4837685

Scroll to Top