Description
简单聊一聊 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
函数在被创建时形成词法作用域, 可以访问到_x
和x
, 当然这两个变量目前相等 - 调用
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
变量, 第一段代码中的message
在value
修改值之后其保存的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
, 使其内部的value
和value
变量始终保持一致
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>
);
}
函数式组件:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
可以看到, 使用函数式组件可以得到正确的 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, 虽然很长, 但是讲的是非常细致, 当然啃下来还是不容易的...
参考
- https://www.youtube.com/watch?v=eTDnfS2_WE4
- https://codesandbox.io/s/eager-dew-r3cl8
- https://overreacted.io/a-complete-guide-to-useeffect/
- https://overreacted.io/how-are-function-components-different-from-classes/
- https://codesandbox.io/s/pjqnl16lm7
- https://dmitripavlutin.com/react-hooks-stale-closures/
- https://segmentfault.com/q/1010000021222813