Vue 中沒有閉包陷阱,但為此付出了什么

今天聊一個非常有爭議的話題。
在社區里,部分 Vue 使用者,會常常因為 React 中存在閉包陷阱,而認為 Vue 是一個更加優秀的框架。個別極端的 Vue 使用者,還會因此而貶低 React,認為閉包陷阱是 React 的一個設計缺陷。
那么,這真的是 React 的設計缺陷嗎?
Vue 中沒有閉包陷阱,那它是否也為此付出了什么代價呢?
我們一點點來分析一下
1.前置知識
我們來思考一個場景。在一個單獨的模塊 A.js 中定義一個變量。
// A.js
let a = 20然后在模塊 B.js 中,我們想要訪問這個變量 a,并且能夠修改這個變量 a 的值,應該怎么辦呢?
// B.js
import A from './A.js'
// 如何訪問模塊 A 中的變量 a我們發現無法直接訪問,因此,我們通常的做法是在模塊 A 中,導出一個專門用于訪問 變量 a 的函數,和一個專門用于修改 變量 a 的函數。
// A.js
let a = 20
export function getA() {
return a
}
export function setA(value) {
a = value
}然后我們在模塊 B 中,就可以調用 getA 函數來訪問變量 a,也可以調用 setA 函數來修改變量 a 的值。
// B.js
import { getA, setA } from './A.js'
const value = getA()
console.log(value)
setA(30)
// 此時 value 的值會變成 30 嗎?此時,我們就遇到一個經典問題:當我調用了 setA 修改了 A 的值之后,上面代碼中的 value 的值會發生變化嗎?
正確答案是:不會。
這就有意思了,為什么 value 的值不會發生變化呢?
這是因為我們通過 getA 函數訪問的是變量 a 的值,而不是變量 a 的引用。因此,如果想要得到新的值,我們還需要重新調用一次 getA 函數。
// B.js
import { getA, setA } from './A.js'
const value = getA()
console.log(value)
setA(30)
// 此時得到最新值
const value2 = getA()
console.log(value2) // 30那我們能不能不通過調用 getA 函數,就能夠直接訪問到變量 a 的值呢?
答案是不行。
現在,我們對這種傳統的方式進行兩種思路的調整。
第一種是稍作修改,模仿成 React 語法的樣子。
// A.js
let a = 20
// 充當了 get 的角色
function useState() {
return [a, setA]
}
function setA(value) {
a = value
}// B.js
import { useState } from './A.js'
const [a, setA] = useState()
console.log(a)
setA(30)
console.log(a) // 20我們會發現,這個情況,就跟 React 中,我們修改了 state 值之后,無法直接訪問到最新的 state 值一樣了。
所示我經常說,無法獲取到最新值,不是 React 的設計缺陷,而是 JS 語言特性他就是如此。
第二種,我們可以通過重新定義 a 的類型,來避免使用 getA 才能訪問新值。
重新修改 A.js 模塊,代碼如下所示:
// A.js
let a = {
value: 20
}
// 充當 get 的角色
export function ref() {
return a
}// B.js
import { ref } from './A.js'
const a = ref()
console.log(a.value)
// 充當 set 的角色
a.value = 30
// 此時通過 .value 訪問到最新值
console.log(a.value) // 30此時,由于我們拿到的直接是一個引用類型,因此,我們可以通過 .value 的方式,做了一個訪問的動作,從而得到最新的值。
此時,我們就可以發現,雖然上面的代碼演變,一直都是框架無關的,但是,我們只需要稍作調整,就可以幾乎完全一致的分別還原 React 與 Vue 的語法。
2.Vue 付出的代價是什么?
接下來,我們要思考的是,當我們通過調整變量的類型結構,把基礎類型包裝成引用類型之后,Vue 為此付出了什么代價?
首先一個很明顯的代價就是:語義不一致。
在 Vue 中,當我們使用 ref 定義一個響應式狀態時,認為這個狀態應該是一個基礎類型。但是實際上,我們拿到的是一個引用類型。
通過 ref 傳入的基礎類型必須包裹到一個引用類型中,才能讓能力變得正常。
因此,我們必須使用 .value 的方式來訪問最新值。
不少開發者會覺得這種方式不夠優雅。
所以,在某個階段,Vue 團隊也曾經試圖解決這個問題,并提出了如下這種方案。
let count = $ref(0)
// 直接訪問,無需 .value
console.log(count)
function increment() {
count++
}這種方式是通過在編譯時,自動添加 .value 的方式來訪問最新值。但是最終由于要解決的問題更多,還是放棄掉了這種方案,ref 也被擴展到可以傳入對象,并被官方團隊作為推薦使用。
其次,由于語義的不一致,.value 的使用,在 template、watch、深層監聽、 組件傳參 等問題中,用法也不一樣,比較混亂。
如下所示:
const x = ref(0)
const y = ref(0)
// 不用 .value
watch(x, (newX) => {
console.log(`x is ${newX}`)
})
// 使用 .value
watch(
() => x.value + y.value,
(sum) => {
console.log(`sum of x + y is: ${sum}`)
}
)
// 不用 .value 與 使用 .value 混用
watch([x, () => y.value], ([newX, newY]) => {
console.log(`x is ${newX} and y is ${newY}`)
})假如你是一名 Vue 新玩家,看到這樣的使用場景,你會不會感覺有點懵?
于是,在使用 Vue 的時候,有的同學老有一種我使用的這個值,到底有沒有被監聽到、還有沒有響應性的心理負擔存在。
事實上,為了與 React 在底層的實現保持差異,Solidjs 在語法上也付出了與 Vue 類似的代價。
如下所示,是一個 Solidjs 的案例。
const CountingComponent = () => {
const [count, setCount] = createSignal(0);
const interval = setInterval(
() => setCount(count => count + 1),
1000
);
onCleanup(() => clearInterval(interval));
return <div>Count value is {count()}</div>;
};這里我們要非常關注的是,狀態 count 返回的不是一個值,而是一個函數。他雖然不需要通過 .value 去獲取最新值,但是他需要通過返回一個函數,并通過調用該函數的方式,才能得到最新值。
所以他使用的時候就變成這個樣子了。
<div>Count value is {count()}</div>但是與此同時,他的 set 方法中回調函數的參數,又不是一個函數,而是一個狀態值,所以寫法就與外面的 count() 不一致。
setCount(count => count + 1)
// or
setCount(count() + 1)也正因為如此,Vue 語法不一致、狀態易丟失響應性的坑,Solidjs 一個也避免不了。特別是在組件傳 props 時,迷惑性很強。也是采用了一堆語法糖來修修補補。
這就是代價。
所以當你覺得 React 的閉包陷阱,是一個設計缺陷的時候,不妨也想想 Vue 和 Solidjs 為了不出現閉包陷阱,都付出了什么樣的代價,也許你會有不一樣的答案。
我的觀點是,并不存在誰的設計理念更先進,這只是在沒有完美方案之下的權衡而已。
3.React 是如何思考的?
實際上,在 React 中,也有通過訪問引用類型的方式,直接獲取值的語法,這就是 useRef()。
const count = useRef(0)
// 通過 .current 訪問最新值
count.current但是區別就是,我們使用 useRef 定義的值,不具備響應性,他只是一個普通的 JS 變量,不與組件狀態綁定。那為什么 React 要這樣做呢?
React 基于一個很重要的原則:使用 useState 定義的值,只與組件狀態綁定,而使用 useRef 定義的值,則僅參與邏輯運算,不與組件狀態綁定,更新時也不影響組件的重新渲染。
而狀態值的更新,會引發組件重新執行,此時 useState 就會自然得到一次執行機會,從而獲取到最新值。
因此,在理想情況下,如果使用者能夠正確分清楚:哪些是狀態值,哪些是邏輯值,就能極大的避免需要獲取最新值的場景出現。
但是麻煩的地方就在于,一部分 React 開發者由于自學的緣故,所以并沒有意識到應該去區分狀態的屬性問題。于是就有一種情況出現,他們在項目中,會瘋狂濫用 useState,定義任何變量都是 useState。
這種情況之下,閉包陷阱就非常容易出現。
4.React 中更麻煩的情況
前面我們提到了要區分狀態值和邏輯值,但是這個時候,會存在一個更麻煩的情況,那就是,在少部分情況下,有一個狀態,他他既是狀態值,又是邏輯值,事情就麻煩了。
這就會非常容易導致閉包陷阱的產生。就如這個案例的 increment 變量,他既是狀態值,又是邏輯值。
面對這種情況,我們通過將該狀態值一分為二的方式來解決,分別定義一個狀態值,一個邏輯值。如下所示:

import { useState, useEffect, useRef } from 'react';
import Button from 'components/ui/button';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const incrementRef = useRef(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + incrementRef.current);
}, 1000);
return () => {
clearInterval(id);
};
}, []);
function incrementHandler() {
setIncrement(i => i + 1);
incrementRef.current += 1;
}
function decrementHandler() {
setIncrement(i => i - 1);
incrementRef.current -= 1;
}
function resetHandler() {
setCount(0);
}
return (
<div className='p-4'>
<div className='flex items-center justify-between'>
<div className='text-2xl font-bold font-din'>
Counter: {count}
</div>
<Button onClick={resetHandler}>Reset</Button>
</div>
<hr />
<div className='flex items-center gap-2'>
Every second, increment by:
<Button disabled={increment === 0} onClick={decrementHandler}>–</Button>
<span className='text-lg font-din'>{increment}</span>
<Button onClick={incrementHandler}>+</Button>
</div>
</div>
);
}我們希望他以邏輯值的身份參與到 useEffect 的回調函數中,而不是以狀態值的身份去添加到依賴項中。
因此,在過往的解決方案中,我們為了繞開閉包陷阱,但是又不想把 increment 作為依賴項,我們就會把這個變量一分為二,分別定義一個狀態值,一個邏輯值。
// 狀態值驅動 UI 變化
const [increment, setIncrement] = useState(1);
// 邏輯值參與 useEffect 的回調函數邏輯運算
const incrementRef = useRef(1);然后在更新時,保證狀態值與邏輯值的同步更新。
setIncrement(i => i + 1);
incrementRef.current += 1;這樣,我們就可以保證在 useEffect 的回調函數中,使用的 increment 值始終是最新的值,又不用把 increment 作為依賴項。
5.總結
很顯然,在如何訪問到最新值上面,Vue 和 React 做了不一樣的選擇。但是,也并不是沒有付出任何代價。Vue 在語法上付出了語義不一致的代價,React 在邏輯上付出了需要區分狀態值和邏輯值的代價。
兩種方案都不完美,這只是一種根據實際情況做出的選擇,而不存在誰一定比誰更好,誰一定就是最優解的說法。
對于我個人而言,我更傾向于 React 的選擇。這是因為,隨著我們對 React 的理解越來越深,我可以通過提高自己個人開發能力的方式合理的區分狀態值與邏輯值,從而避免閉包陷阱的產生。
但是 Vue/solidjs 語義不一致的問題,卻永遠都會存在。
如果是你,你會更傾向于哪種方案呢?


























