componentDidMount 和 useEffect 是完全一样的吗

304 阅读6分钟

文章翻译来自 Dan Abramov(React 库的核心团队成员之一) overreacted.io/a-complete-… 文章采用意译,融合了我自己的理解,欢迎大家留言讨论。先说总结:

  • 每一次渲染都有自己的 prop\state\事件处理\effect\清理函数。在单次渲染的范围内,props 和 state 保持不变,也就是事件处理、effect 和清理函数拿到的 props 和 state 保持不变。使用 Ref 可以打破这一范式,让事件处理、effect 和清理函数拿到最新值。
  • 先进行UI渲染,再执行 effect 的上一次的清理函数,最后执行 effect 函数。
  • componentDidMount 和 useEffect(fn, []) 是不一样的,或者说 useEffect(fn, []) 和 componentDidMount 是不一样的。在 单次渲染范围内,useEffect(fn, []) 总是拿到最初的值,而不是最新的值。因为 this.state.count 则永远指向最新的值。
  • effect 应该理解成同步,而不是按照生命周期去理解。如果按照 class 生命周期去理解 effect 会让我们更加迷糊,应该建立 effect 同步的 思维去思考如何使用 effect.

每一次的渲染都有自己的 Props and State

function Counter() {
  const [count, setCount] = useState(0); 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

为什么我们点击一次,页面的会显示 count 的次数呢?count 仅仅是一个数字,不是 数据绑定、watcher、proxy 或者其他什么语法。

// 第一次渲染,函数 Counter 第一次执行
function Counter() {
  const count = 0; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}
 
// 点击一次后,Counter 再次执行
function Counter() {
  const count = 1; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}
 
// 再点击一次后,Counter 再次执行
function Counter() {
  const count = 2; // Returned by useState()
  // ...
  <p>You clicked {count} times</p>
  // ...
}

也就是每一次函数 Counter 执行都能 “看见” 内部的 state count。尽管 count 可能在不同的渲染之间发生变化,但在对于每次渲染来说都是常量。

每一次的渲染都有自己的事件处理函数

function Counter() {
  const [count, setCount] = useState(0);
 
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

对于这个例子,当我们先点击按钮 Click me,让 count 到增加5,然后点击按钮 Show alert,之后继续点击增加 count 到 8, 3秒后 alert 的值是多少呢?

下意识会觉得弹出最新值8,其实不然,会弹出触发定时器 setTimeout 时的 count 的值,会弹出 5。

每次调用我们的函数时,count 值对于该特定调用是 恒定的。也就是——函数会被调用多次(每次渲染都会调用一次),但每次调用时,函数内部的 count 值都是恒定的,并且设定为特定的值(即该次渲染的状态值)。

下面的例子可以更好的理解 “函数每次调用,函数内部的值是恒定的”

function sayHi(person) {
  setTimeout(() => {
    alert('Hello, ' + person.name);
  }, 3000);
}
 
let someone = {name: 'Dan'};
sayHi(someone);
 
someone = {name: 'Yuzhi'};
sayHi(someone);
 
someone = {name: 'Dominic'};
sayHi(someone);

会以此 alert DanYuzhiDominic

注意: someone 每次是重新赋值了:someone = {name: 'Yuzhi'};不是修改 someone的属性:someone.name = 'Yuzhi'

image.png

这个例子中,setTimeout 使用了外层函数 sayHi 的变量 person,这正是 闭包 的概念。对于 setTimeout 函数来说,外部变量是 恒定的

每一次渲染都有自己的副作用 effect

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

概念上,可以将 effect 想象成渲染结果的一部分。心智模型是这样的:跟事件处理一样 effect 函数属于每一次特定的渲染。

这段代码的执行逻辑如下:

// During first render
function Counter() {
  // ...
  useEffect(
    // Effect function from first render
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
  // ...
}
 
// After a click, our function is called again
function Counter() {
  // ...
  useEffect(
    // Effect function from second render
    () => {
      document.title = `You clicked ${1} times`;
    }
  );
  // ...
}
 
// After another click, our function is called again
function Counter() {
  // ...
  useEffect(
    // Effect function from third render
    () => {
      document.title = `You clicked ${2} times`;
    }
  );
  // ..
}

渲染过程更细致的表述:

  • React: state 现在是0,给我一下 UI
  • 组件
    • 这是渲染结果:<p>You clicked 0 times</p>
    • 记得执行副作用 effect: () => { document.title = 'You clicked 0 times' }
  • React:当然可以,浏览器,帮忙更新一下UI,我对 DOM 进行了一些修改。
  • 浏览器:好的,我把这些渲染到屏幕上。
  • React:OK,我现在要执行组件的副作用 effect 函数了。执行() => { document.title = 'You clicked 0 times' }

接下来我们看下当点击按钮后会发生什么:

  • 组件: React, 帮我把 state 设为 1
  • React:给我一下 UI,现在 state 是 1
  • 组件
    • 这是渲染结果:<p>You clicked 1 times</p>
    • 记得执行副作用 effect: () => { document.title = 'You clicked 1 times' }
  • React:当然可以,浏览器,帮忙更新一下UI,我对 DOM 进行了一些修改。
  • 浏览器:好的,我把这些渲染到屏幕上。
  • React:OK,我现在要执行组件的副作用 effect 函数了。执行() => { document.title = 'You clicked 1 times' }

每一次渲染有自己的一切

看下这段代码,并思考一下会输出什么呢。

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

这是一个比较直观的例子,我们可以看到控制台输出一系列日志,每一个输出的日志都属于一个特定的渲染,每次渲染都有自己的 count 值,你可以自己试一下:codesandbox.io/s/lyx20m1ol

timeout_counter.gif

使用 class 实现,则是 codesandbox.io/s/kkymzwjqz…

componentDidUpdate() {
  setTimeout(() => {
    console.log(`You clicked ${this.state.count} times`);
  }, 3000);
}

可以看到使用 class 实现,则会输出最新的 count 的值。

timeout_counter_refs.gif

React 中的 Hooks 主要依赖闭包的特性,并且每次渲染都是函数执行。但是 class 方式修改 count,setTimeout 中 count 的引用会指向最新的 this.state.count的值。我们可以利用 闭包的特性来解决这个问题:codesandbox.io/s/w7vjo0705…

componentDidUpdate() {
  const count = this.state.count;
  setTimeout(() => {
    console.log(`You clicked ${count} times`);
  }, 3000);
}

逆流而上(使用 Ref)

正因为上面所说的,每一次渲染所具有的 props 和 state 的值都是确定的,所以以下代码是等价的:

function Example(props) {
  useEffect(() => {
    setTimeout(() => {
      console.log(props.counter);
    }, 1000);
  });
  // ...
}

function Example(props) {
  const counter = props.counter;
  useEffect(() => {
    setTimeout(() => {
      console.log(counter);
    }, 1000);
  });
  // ...
}

也就是不管在组件内部“早期”读取 props 还是 state,这都无关紧要。它们不会改变!在单次渲染的范围内,props 和 state 保持不变。(解构 props 会使这一点更加明显。)

如果从一个过去的渲染中从函数读取未来的 props 或 state 时(也就是拿到 state 的最新值),这是 逆流而上 的,打破了“每一次渲染都有自己的确定一切(保持不变)”的范式。我们可以使用 Ref 来实现,Ref 跟 React 本身在类组件中重新分配 this.state 的方式一样,可以随时修改 latestCount.current,并获取到最新值:

function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);
 
  useEffect(() => {
    // Set the mutable latest value
    latestCount.current = count;
    setTimeout(() => {
      // Read the mutable latest value
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });

timeout_counter_refs.gif

清理函数(cleanup)怎么理解呢

正如文档中解释的那样,一些 effect 可能会返回一个清理函数。其目的是在某些情况下“撤销”effect,比如订阅。

考虑以下代码:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

假设在第一次渲染时,props 是 {id: 10},第二次渲染时是 {id: 20}。你可能会认为是这样:

  1. React 清理 {id: 10} 的 effect。
  2. React 为 {id: 20} 渲染 UI。
  3. React 为 {id: 20} 运行 effect。

(事实并非如此。)

根据这种思维模型,你可能认为清理“看到”旧的 props,因为它在重新渲染之前运行,而新的 effect “看到”新的 props,因为它在重新渲染之后运行。这是直接从类生命周期中得出的思维模型,但在这里并不准确。为什么呢?

React 只会在让浏览器绘制后运行 effect。这样做会使你的应用程序更快,因为大多数 effect 不需要阻塞屏幕更新。Effect 的清理也是延迟的。上一个 effect 会在重新渲染带有新 props 后进行清理:

  1. React 为 {id: 20} 渲染 UI。
  2. 浏览器绘制。我们在屏幕上看到 {id: 20} 的 UI。
  3. React 清理 {id: 10} 的 effect。
  4. React 运行 {id: 20} 的 effect。

你可能会想:但是如果清理上一个 effect 是在 props 变成 {id: 20} 后运行,它怎么还能“看到”旧的 {id: 10} props 呢?

我们之前讨论过这个问题... 🤔

是的,每一次渲染都有自己的一切。

Effect 的清理不会读取“最新”的 props,无论这意味着什么。它读取的是它定义时所属渲染的 props:

// 第一次渲染,props 是 {id: 10}
function Example() {
  // ...
  useEffect(
    // 第一次渲染的 effect
    () => {
      ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
      // 第一次渲染的 effect 的清理
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
      };
    }
  );
  // ...
}
 
// 下一次渲染,props 是 {id: 20}
function Example() {
  // ...
  useEffect(
    // 第二次渲染的 effect
    () => {
      ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
      // 第二次渲染的 effect 的清理
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
      };
    }
  );
  // ...
}

但无论如何,第一次渲染的 effect 清理所“看到”的 props 永远是 {id: 10}

这正是为什么 React 能在绘制之后处理 effects —— 并默认使你的应用程序更快。如果我们的代码需要它们,旧的 props 会一直存在。

同步,而非生命周期

React hook 的特点是初始渲染和更新是统一的,React 根据我们当前的 props 和 state 同步 DOM。渲染时没有“挂载”或“更新”的区别。我们应该以类似的方式看待 effect。useEffect 会根据我们的 props 和 state 来同步 React 之外的内容。