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

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

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 的回調(diào)函數(shù)中,而不是以狀態(tài)值的身份去添加到依賴項(xiàng)中。
因此,在過往的解決方案中,我們?yōu)榱死@開閉包陷阱,但是又不想把 increment 作為依賴項(xiàng),我們就會把這個變量一分為二,分別定義一個狀態(tài)值,一個邏輯值。
// 狀態(tài)值驅(qū)動 UI 變化
const [increment, setIncrement] = useState(1);
// 邏輯值參與 useEffect 的回調(diào)函數(shù)邏輯運(yùn)算
const incrementRef = useRef(1);然后在更新時(shí),保證狀態(tài)值與邏輯值的同步更新。
setIncrement(i => i + 1);
incrementRef.current += 1;這樣,我們就可以保證在 useEffect 的回調(diào)函數(shù)中,使用的 increment 值始終是最新的值,又不用把 increment 作為依賴項(xiàng)。
5.總結(jié)
很顯然,在如何訪問到最新值上面,Vue 和 React 做了不一樣的選擇。但是,也并不是沒有付出任何代價(jià)。Vue 在語法上付出了語義不一致的代價(jià),React 在邏輯上付出了需要區(qū)分狀態(tài)值和邏輯值的代價(jià)。
兩種方案都不完美,這只是一種根據(jù)實(shí)際情況做出的選擇,而不存在誰一定比誰更好,誰一定就是最優(yōu)解的說法。
對于我個人而言,我更傾向于 React 的選擇。這是因?yàn)?,隨著我們對 React 的理解越來越深,我可以通過提高自己個人開發(fā)能力的方式合理的區(qū)分狀態(tài)值與邏輯值,從而避免閉包陷阱的產(chǎn)生。
但是 Vue/solidjs 語義不一致的問題,卻永遠(yuǎn)都會存在。
如果是你,你會更傾向于哪種方案呢?















 
 
 








 
 
 
 