hacker0limbo/my-blog

简单聊一聊 hooks 与闭包

hacker0limbo opened this issue · 0 comments

简单聊一聊 hooks 与闭包

变量引用

关于这方面问题不做深究, 可以看做是指针

let x = 0
let y = x
let z = `hello ${x}`

x = 1
console.log(y) // 0
console.log(z) // hello 0

闭包

  • 函数在创建的时候就会生成一个词法环境, 在运行的时候同样会创建另一个新的词法环境, 两个词法环境可能不同. 分析时需要理清楚
  • 闭包中的变量引用. 词法环境中如存在引用, 需要分析该引用在后面的函数(闭包)调用中是否被修改.

这里重点谈谈第二点, 关于闭包里的变量引用问题.

例子 1:

function outer() {
  let x = 0
  function inner() {
    let _x = x
    function log() {
      console.log({ x, _x })
    }

    return log
  }

  function change() {
    x += 1
  }

  return [inner, change]
}

let [inner, change] = outer()

let log = inner()
log() // { x: 0, _x: 0 }
change()

log() // { x: 1, _x: 0 } 

分析:

  • 首先调用outer()函数创建了一个闭包, 闭包中的变量为x = 0, inner函数和change在被创建的过程中形成词法作用域, 可以访问到该变量
  • 调用inner()函数, 此时在当前的词法作用域下创建一个新的闭包, 该闭包中创建了一个新的变量_x, 当然还存在之前引用的x变量. log函数在被创建时形成词法作用域, 可以访问到_xx, 当然这两个变量目前相等
  • 调用change()函数, 该函数在第一次声明的词法作用域下修改了x变量, 使其为1
  • 调用log()函数, 注意, 由于闭包的特性, log()函数可以访问到x_x, 但由于change()修改了最顶层的词法作用域里的x, 这里读取的x也为1. 不过, 由于这里的_x只在inner()函数调用(也就是声明log函数)的时候声明一次, 且指向 x = 1, 即使change()修改了x对其并无任何影响. 因此这时的log()函数调用的词法作用域为x = 1, _x = 0

例子 2:
该例子取自于 react-hooks-stale-closures 这篇文章. 虽然我个人觉得这篇文章的作者其实没写到点子上...

有如下两段代码

function createIncrement(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    const message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  
  return increment;
}

const inc = createIncrement(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3

log();             // "Current value is 1"
function createIncrementFixed(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    return function logValue() {
      const message = `Current value is ${value}`;
      console.log(message);
    };
  }
  
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3

log();             // "Current value is 3"

这里就不细展开了, 具体的分析可以看 这个问题下的答案, 主要需要注意这几点:

  • value 在最顶层的词法作用域, 确实是不断在变化的
  • log() 函数在被调用的时候, 确实拿到的value值是最新的值, 但是第一段代码与第二段代码的区别在于message变量, 第一段代码中的messagevalue修改值之后其保存的value仍旧是原始的值(也就是 1)(此 value 非彼 value), 而第二段代的message并不是作为log函数的定义时的闭包变量而存在, 而是作为自己的作用域内的变量. 因此在调用log()函数的时候, 才会声明message, 这时候再去查找value, 得到的当然是最新值

对于第二段代码, 这么改效果也是一样的:

function createIncrementFixed(i) {
  let message;
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    message = `Current value is ${value}`;
    return function logValue() {
      console.log(message);
    };
  }
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 1
inc();             // 2
inc();             // 3
log();             // "Current value is 3"

分析: 每次调用inc()的时候不仅修改顶层词法作用域里的value, 也在不断重写message, 使其内部的valuevalue变量始终保持一致

hooks

从计时器开始

上一篇已经讲到, 可以使用闭包来模拟类的行为, 还是以计数器为例, 假定有如下代码:

const Counter = () => {
  let value = 0

  const render = () => {
    setTimeout(() => {
      console.log(value)
    }, 2000)
  }

  const inc = () => {
    value += 1 
    render()
  }

  return { render, inc }
}

const c = Counter()

c.inc() // 3
c.inc() // 3
c.inc() // 3

可以看到结果, 2 秒以后输出值全为 3. 然而往往需求可能是, 每次打印出来的值应该是顺序的, 比如在这个例子里面希望 2 秒后依次打印 1, 2, 3

可以这么做:

const Counter = () => {
  let value = 0

  const render = () => {
    let v = value
    setTimeout(() => {
      console.log(v)
    }, 2000)
  }

  const inc = () => {
    value += 1 
    render()
  }

  return { render, inc }
}

const c = Counter()

c.inc() // 1
c.inc() // 2
c.inc() // 3

这里我们使用v这个临时变量存取当前(上一次)的value的值, 这样使得setTimeout这个闭包内部可以在 2 秒后, "正确"读取到当前属于自己的值. 而对于第一段代码, 由于inc()不断调用修改了顶层词法作用域里的value变量, setTimeout读取到的value的值永远都是最新的(因为 value 不断被更新...)

react

引子

先来看一个场景:
Dan 在他的 博客 里面有这样一个场景实例: 有一个类似 twitter 的页面, 你想要 follow 某个用户, 但是点击 follow 这个动作是一个异步请求可能需要时间, 在点击了 follow 这个操作之后, 立马切换到另一个用户的页面, 几秒钟之后客户端收到响应, 显示你 follow 了"这个"用户

具体的 live demo 点击这里查看

然而, 使用函数式组件写法, 和 class 组件写法带来的效果是很不同的, 两种写法的组件和效果分别如下:

class 组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

bug

函数式组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

fix

可以看到, 使用函数式组件可以得到正确的 follow, 延时请求所请求的 user 是之前点击的 Follow 按钮的 user, 而非像 class 组件一样发送了错误的 user 请求

下面会进行分析, 当然也推荐直接看 Dan 的 博客

分析

Dan 在另外一篇 文章 中总结的相当好:

  • 函数式组件在每一次渲染都有它自己的…所有, 你可以想象成每次 render 的时候都形成了一次快照, 保存了所有下面的东西, 每一份快照都是不同且独立的. 即
    • 每一次渲染都有自己的 props 和 state
    • 每一次渲染都有自己的事件处理函数
    • 每一次渲染都有自己的 useEffect()
  • class 组件之所以有时候"不太对"的原因是, React 修改了 class 中的 this.state 使其指向永远最新状态

例子

有如下代码: live demo

function App() {
  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>
  );
}

一开始先点击Show alert按钮, 然后立马点击 3 次 Click me按钮, 3 秒过后浏览器打印出来的结果为打印出"You clicked on: 0"

更多次实验以后会发现, Show alert 只会显示在点击触发前那一刻所对应的 count 的值, 比如目前 count 为 5, 点击 Show alert之后立马再点击几次Click me, 3 秒过后浏览器打印的结果为 5

这是由于闭包的原因, 每次setTimeout()读取到的 count 的是当前 render状态下的值, 即使后面对count进行了改变, setTimeout()中的 count不受影响, 永远是当前 render 下的 count 的值, 而非最新的 count 的值

那如果想要得到最新的值呢?

最简单, 可以使用useRef()来存取最新的值, 那么代码可以改成如下:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0)

  countRef.current = count

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + countRef.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

这时候重复上述过程, 可以发现 3 秒之后能得到当前的最新值.

关于 useEffect
实际上 useEffect 也是一个函数, 和 handleAlertClick类似, 也可以实现类似需求: 即在组件 mount 的时候根据初始 state 只发送一个异步请求, 用户在等待请求的过程中对该 state 重新进行了设置 那么该请求中所涉及到的 state 应该是在 mount 时候的初始值, 也就是 initial state, 代码如下:

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }, [])

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

在页面初次渲染之后便开始发送请求, 此时点击几次Click me按钮, 3 秒之后会显示You clicked on: 0

当然, 如果想要实现在 mount 时发送请求携带的 state 是最新的用户操作过后的数据, 那么还是一样可以使用useRef()来存取最新的 state, 代码改成如下:

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);

  countRef.current = count;

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + countRef.current);
    }, 3000);
  }, [])

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

这样再点击Clik me之后, state 发生了修改, 那么之前发送的请求中的 state 也就是修改过后的最新的 state.

这里需要注意, 第一段代码中useEffect()虽然为空, 但是 eslint 会提示需要加上 count, 但是加上了并不是我们想要的效果, 代码会变成这样:

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }, [count])

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

那么最后的结果为, 每次点击Click me, 触发 count 的更新, 随之也触发useEffect()这个函数的调用, 那么请求也会每次被发送, 当然由于闭包, 3 秒之后会依次显示所对应的 count 的值, 比如点击了 3 次, 那么 3 秒过后依次打印 1, 2, 3

总结: 需要分析清楚你想要的是什么, 是需要最新的 state/props(可以用 ref), 还是想要每次 render 下所对应的自己的 state/props

另外, 非常推荐 Dan 的 A Complete Guide to useEffect, 虽然很长, 但是讲的是非常细致, 当然啃下来还是不容易的...

参考