探索useEffect最佳实践

javascript/jquery

浏览数:22

2020-5-27

AD:资源代下载服务

我们用ReactHooks也有一年时间了,到了需要用它更好地表达这个阶段。

我做了几个例子,希望对大家有些帮助。

1. 显示当前时间

这个例子没有什么特别,但覆盖了80%的真实场景。

const App = () => {
  const [time, setTime] = useState(new Date());
  useEffect(() => {
    const timer = setInterval(() => {
      setTime(new Date());
    }, 1000);
    return () => clearInterval(timer);
  }, []);
  return (
    <div>{formatTime(time)}</div>
  )
};

在codepen查看

涉及到的点有:

  • 使用 useState 定义状态
  • 使用 useEffect 定义副作用

    • 这个Effect无依赖
    • 在effect中可同时定义了 初始化清理 操作。
      这是useEffect相对于生命周期函数 componentDidMountcomponentWillUnmount的好处:让初始化和清理这一对操作在一起。

和React无关,这个例子还用到String#padStart,用来格式化时间,让数字显示保持两位。你能想到的最简实现是什么? 我想到一个:

('0' + v).substr(-2);

2. effect和依赖

这个示例探索effect 执行清理时机

点击 Add 可以添加一项;
点击 Remove 可以移除一项。

打开console可以看相应的日志,其中 Item中相关的代码如下:

const Item = ({ task, onDelete }) => { 
  useEffect(() => {
    console.log('enter', task.id);
    return () => {
      console.log('leave', task.id)
    };
  }, [task]);
  ...

在codepen查看

可以看到 添加 时,会执行 enter移除 时会执行 leave

如果熟悉 componentDidMountcomponentWillUnmount,就会很自然地把 useEffect 和这两个生命周期等同起来,毕竟两者现象一致,特别是当依赖设置为空[]时,再加上Hooks是后来才出现的,就会想当然地认为useEffect(..., []) 是上面两个生命周期的语法糖。

不过这种看法是不对的, 它会影响我们使用useEffect去有效表达,因为我们还在使用生命周期去考虑,而不是从effect的角度去考虑。

使用Effect表达一个重要的点是:这个Effect依赖什么? 比如Effect中使用了task,所以就诚实地告诉React,依赖是[task],而不要一股脑地都写成[]

3. effect和closure

我对上一个示例做了些改动,主要是想表达:每次渲染,Effect都有独立的版本

CodePen

相关代码如下

const Item = ({ task, onDelete, onFinish }) => { 
  const stamp = Date.now();
  useEffect(() => {
    console.log('enter', task.id, stamp);
    return () => {
      console.log('leave', task.id, stamp);
    };
  }, [task]);

Item是一个组件,其实是个function
useEffect传递的也是一个function,所以effect也是一个function

每次渲染时,都会执行Item函数,然后调用useEffect构造一个新的Effect函数。得益于js中function具有closure特性,它们都拥有 独立的数据

添加3个Item, console输出:

enter 1 1582464544868
enter 2 1582464545132
enter 3 1582464545358

其中1,2,3是Item的序号,后面的是渲染时的时间戳,后面会用到。

点击中间的finish按扭,输出:

leave 2 1582464545132
enter 2 1582464703578

我们看到leave时,引用的是刚才渲染版本的stamp,然后马上enter,引用的是新版本的stamp。

再点一下中间的finish 按扭。

输出:

leave 2 1582464703578   // 上一个版本的stamp
enter 2 1582464921857   // 当前版本的stamp

注意到更新时, 会执行leave和enter,它们来自不同版本的Effect

const Item = ({ task, onDelete, onFinish }) => { 
  const stamp = Date.now();
  useEffect(() => { // 每次渲染都是新的function
    console.log('enter', task.id, stamp); 
    return () => {
      console.log('leave', task.id, stamp);  
    };
  }, [task]);

4. think effects

我们再看一个示例,来体会刚才的点。

例子中有两个按扭:

其关键代码为:

class CounterA extends React.Component {
  state = {
    times: 0
  };
  
  handleAdd = () => {
    this.setState({ times: this.state.times + 1 });
  }
  
  handleLog = () => {
    setTimeout(() => {
      console.log(`times: ${this.state.times}`);
    }, 3000);
  }
}

依次点击Add Log Add Log Add Log

会输出:

times: 3
times: 3
times: 3

而使用hooks实现

const CounterB = () => {
  const [times, setTimes] = useState(0);
  const handleAdd = () => {
    setTimes(times + 1);
  };
  const handleLog = () => {
    setTimeout(() => {
      console.log(`times: ${times}`);
    }, 3000);
  };

依次点击Add Log Add Log Add Log会输出:

times: 1
times: 2
times: 3

这有点类似于JS经典迷题:

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 100);
}

CodePen示例

5 useRef

如何让function版本的组件拥有class版本的效果呢? 可以使用useRef

CodePen示例

const Counter = () => {
  const [times, setTimes] = useState(0);
  const timesRef = useRef(times); // 一次组件生命周期只拥有一个实例
  useEffect(() => {
    timesRef.current = times; // 使用effect更新ref的数据
  }); // 这里也可以加上times依赖
  
  const handleAdd = () => {
    setTimes(times + 1);
  };
  const handleLog = () => {
    setTimeout(() => {
      console.log(`times: ${timesRef.current}`);
    }, 3000);
  };

有了上面的基础,我们就可以解释实际编程中会碰到的关于Effect的坑,并探索最佳实践。

6 失效的计数器

让我们又从计数器开始:

const Counter = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      console.log(count);
      setCount(count + 1);
    }, 1000);
  }, []);
  
  return (
    <div>{{count}}</div>
  );
};

很遗憾 这个计数器不能正常工作,如果理解了刚才的点,那么原因显而易见。

因为setInterval中拿到的count,总是自己的那个版本的count,即0。 // << — 理解这点是个关键。

CodePen示例

7. useEffect的依赖

那如何解决呢?有一种方法,只是针对当前示例不合理,但对于真实的场景会很合适。即管理好useEffect的依赖

编写Effect时需考虑其依赖,就像编写函数时需仔细考虑其签名(名称、参数返回值)。

const Counter = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, [count]);  // 诚实地告诉useEffect依赖,就没问题啦!

CodePen示例

8. get last state

上述实现虽然现象正常,但每次执行effect时,都会重新初始化计数器,这一点不大合理,那有什么解决方法呢? setCount其实可以传递一个函数,在里面可以拿到上一次的state。

const Counter = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      //setCount(count + 1);
      setCount(last => last + 1);  // 可拿到上次state
    }, 1000);
  }, []);

CodePen示例

9. muti deps

继续刚才的示例,添加一个step状态,用于控制每次增加的值。

这时候callback方式就不适用了,因为它每次只能拿到一个状态值。不过useEffect可拥有多个依赖。

const App = () => {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(last => last + step);
    }, 1000);
    return () => clearTimeout(timer);
  }, [count, step]);  // 多个依赖

CodePen示例

10. useReducer

上一示例的实现方式并不优雅,每次都会安装新的计数器(setInterval),那如何取得多个上一次的状态值呢?

可以使用useReducer

const App = () => {
  // React保证dispatch在生命周期内唯一
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
  const { count } = state;

  useEffect(() => {
    setInterval(() => {
      dispatch({ type: 'tick' });  
    }, 1000)
  }, []);
  
  const handleChange = e => {
    const step = +e.target.value || 0;
    dispatch({ type: 'step', payload: step });
  };

在reducer中,我们就可以拿到所需要的state:

function reducer(state, { type, payload }) {
  if (type === 'tick') {
    state = { ...state, count: state.count + state.step };
  }
  if (type === 'step') {
    state = { ...state, step: payload }
  }
  return state
}

CodeOpen示例

11. inner reducer

可以将reducer放在组件内部,这里有点神奇:

const App = () => {
  const [step, setState] = useState(1);
  // 虽然每次reducer都是新的版本,
  // 但是React能让其正确工作!!
  const reducer = (count, { type }) => {
    if (type === 'tick') {
      return count + step;
    }
    return count;
  };
  
  const [count, dispatch] = useReducer(reducer, 0);

  useEffect(() => {
    setInterval(() => {
      dispatch({ type: 'tick' });  
    }, 1000)
  }, []);
  
  const handleChange = e => {
    const step = +e.target.value || 0;
    setState(step);
  };

CodePen示例

12. load data

接下来几个示例探索load data的最佳实践

const App = () => {
  const [data, setData] = useState(null);
  useEffect(() => {
    const load = async() => {
      const res = await loadData({ id: 123 });
      setData(res.data);
    };
    load();
  }, []);

最简单的实现,我们日常的业务实现多数是这种。

CodePen示例

上述实现不考虑依赖,如果取数依赖了props,则变化后并不会重新请求数据,
作为业务实现合理,如果是可复用组件,则需要考虑更新问题。

方式是仔细考虑effect的依赖,在几次实践后,就会真正地think effects的方式去创建组件。

const Item = ({ id }) => {
  const [data, setData] = useState(null);
  useEffect(() => {
    const load = async() => {
      const res = await loadData({ id });
      setData(res.data);
    };
    load();
  }, [id]);  // 添加id作为依赖。

13 重用函数

如果要重用loadData怎么办呢?

const Item = ({ id }) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // 需要重用这个load
  const load = async () => {
    setLoading(true);
    const stamp = Date.now();
    const res = await loadData({ id, stamp });
    setLoading(false);
    setData(res.data);
  };
  
  useEffect(() => {
    load();
  }, [id]); // 依赖是id? 
  
  const handleReload = () => {
    load();
  };

CodePen示例

14 function as deps

上例useEffect中,依赖id合理吗?
我觉得实现虽然没问题。可是表达上不够好。

load虽然现在在旁边,但也有可能在较远的地方,那effect就不大可能知道load函数到底依赖什么。

effect实际上依赖的是load这个函数,但根据我们前面的分析,每次渲染都有不同的load, 所以不能直接用当作依赖。

这时候,useCallback就派上用场了。

const Item = ({ id }) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  const load = useCallback(async () => {
    setLoading(true);
    const stamp = Date.now();
    const res = await loadData({ id, stamp });
    setLoading(false);
    setData(res.data);
  }, [id]);  // callback知道自己依赖什么
  
  useEffect(() => {
    load();
  }, [load]);  // function as deps
  
  const handleReload = () => {
    load();
  };

CodePen示例

作者:bencode