React閉包陷阱

React閉包陷阱

React HooksReact 16.8引入的一個新特性,其出現讓React的函數組件也能夠擁有狀態和生命週期方法,其優勢在於可以讓我們在不編寫類組件的情況下,更細粒度地複用狀態邏輯和副作用代碼,但是同時也帶來了額外的心智負擔,閉包陷阱就是其中之一。

閉包

React閉包陷阱的名字就可以看出來,我們的問題與閉包引起的,那麼閉包就是我們必須要探討的問題了。函數和對其詞法環境lexical environment的引用捆綁在一起構成閉包,也就是說,閉包可以讓你從內部函數訪問外部函數作用域。在JavaScript,函數在每次創建時生成閉包。在本質上,閉包是將函數內部和函數外部連接起來的橋樑。通常來說,一段程序代碼中所用到的名字並不總是有效或可用的,而限定這個名字的可用性的代碼範圍就是這個名字的作用域scope,當一個方法或成員被聲明,他就擁有當前的執行上下文context環境,在有具體值的context中,表達式是可見也都能夠被引用,如果一個變量或者其他表達式不在當前的作用域,則將無法使用。作用域也可以根據代碼層次分層,以便子作用域可以訪問父作用域,通常是指沿着鏈式的作用域鏈查找,而不能從父作用域引用子作用域中的變量和引用。

爲了定義一個閉包,首先需要一個函數來套一個匿名函數。閉包是需要使用局部變量的,定義使用全局變量就失去了使用閉包的意義,最外層定義的函數可實現局部作用域從而定義局部變量,函數外部無法直接訪問內部定義的變量。從下邊這個例子中我們可以看到定義在函數內部的name變量並沒有被銷燬,我們仍然可以在外部使用函數訪問這個局部變量,使用閉包,可以把局部變量駐留在內存中,從而避免使用全局變量,因爲全局變量污染會導致應用程序不可預測性,每個模塊都可調用必將引來災難。

const Student = () => {
    const name = "Ming";
    const sayMyName = function(){ // `sayMyName`作爲內部函數,有權訪問父級函數作用域`Student`中的變量
        console.log(name);
    }
    console.dir(sayMyName); // ... `[[Scopes]]: Scopes[2] 0: Closure (student) {name: "Ming"} 1: Global` ...
    return sayMyName; // `return`是爲了讓外部能訪問閉包,掛載到`window`對象實際效果是一樣的
}
const stu = Student(); 
stu(); // `Ming`

實際開發中使用閉包的場景有非常多,例如我們常常使用的回調函數。回調函數就是一個典型的閉包,回調函數可以訪問父級函數作用域中的變量,而不需要將變量作爲參數傳遞到回調函數中,這樣就可以減少參數的傳遞,提高代碼的可讀性。在下邊這個例子中,我們可以看到local這個變量是局部的變量,setTimeout進行調用的詞法作用域是全局的作用域,理論上是無法訪問local這個局部變量的,但是我們採用了閉包的方式創建了一個能夠訪問內部局部變量的函數,所以這個變量的值能夠被正常打印。如果我們類似於第二個setTimeout直接將參數傳遞也是可以的,但是如果我們在這裏封裝了很多邏輯,那麼這個參數傳遞就變得比較複雜了,根據實際情況用閉包可能會更合適一些。

const cb = () => {
  const local = 1;
  return () => {
    console.log(local);
  };
}

setTimeout(cb(), 1000); // 1
setTimeout(console.log, 2000, 2); // 2

我們可以再看一個例子,我們在寫Node時可能會遇到一個場景,在調用其他第三方服務接口的時候會會被限制頻率,比如對於該接口1s最多請求3次,此時我們通常有兩種解決方案,一種方案是在請求的時候就限制發起請求的頻率,直接在發起的時候就控制好,被限頻的請求需要排隊,另一種方案是不限制發起請求的頻率,而是採用一種基於重試的機制,當請求的結果是被限頻的時候,我們就延遲一段時間再次發起請求,可以用指數退避算法等方式來控制重試時間,實際上以太網在擁堵的時候就採用了這種方法,每次發生碰撞後,設備會根據指數退避算法來計算等待時間,等待時間會逐漸增加,從而降低了設備再次發生碰撞的概率。

在這裏我們需要關注第二種方案中如何進行重試,我們在發起請求的時候通常會攜帶比較多的信息,比如urltokenbody等數據進行查詢,如果我們需要進行重試,那麼肯定需要找個地方把這些數據存儲下來以備下次發起請求,那麼在何處存儲這些變量呢,當然我們可以在global/window中構造一個全局的對象來存儲,但是之前也提到過了全局變量污染會導致應用程序不可預測性,所以在這裏我們更希望用閉包來進行存儲。在下邊這個例子中我們就使用了閉包來存儲了請求時的一些信息,並且在重試時保證了這些信息是最初定義時的信息,這樣就不需要污染全局變量,而且需要對於業務調用來說,我們可以再包裝一側requestWithLimit,當內部的請求正常完整之後纔會Resolve Promise,將這部分重試機制封裝到內部會更加易用。

const requestFactory = (url, token) => {
  return function request(){ // 假設這個函數會發起請求並且返回結果
    return { url, token };
  }
}

const req1 = requestFactory("url1", "token1");
console.log(req1()); // 發起請求 `{url: 'url1', token: 'token1'}`
console.log(req1()); // 重試請求 `{url: 'url1', token: 'token1'}`
const req2 = requestFactory("url2", "token2");
console.log(req2()); // 發起請求 `{url: 'url2', token: 'token2'}`
console.log(req2()); // 重試請求 `{url: 'url2', token: 'token2'}`

Js是靜態作用域,但是this對象卻是個例外,this的指向問題就類似於動態作用域,其並不關心函數和作用域是如何聲明以及在何處聲明的,只關心是從何處調用的,this的指向在函數定義的時候是確定不了的,只有函數執行的時候才能確定this到底指向誰,當然實際上this的最終指向的是那個調用的對象。this的設計主要是爲了能夠在函數體內部獲得當前的運行環境context,因爲在Js的內存設計中Function是獨立的一個堆地址空間,不和Object直接相關,所以才需要綁定一個運行環境。

前邊提到了詞法作用域是在定義時就確定了,所以詞法作用域也可以稱爲靜態作用域。那麼我們可以看下下邊的例子,這個例子是不是很像我們的React Hooks來定義的組件。運行這個例子之後,我們可以看到雖然對於這個函數執行起來看起來都是是完全一樣的,但是最後打印的時候得到的值是得到了之前作用域中的值。我們現在需要關注的是fn這個函數,我們我們說的定義時確定詞法作用域這句話具體指的是這個函數被聲明並定義的時候確定詞法作用域,或者說是在生成函數地址的時候確定詞法作用域。其實但從這個例子看起來好像沒什麼問題,本來就是應該這個樣子的,那麼爲什麼要舉這個例子呢,其實在這裏想表達的意思是,如果我們在寫代碼的時候不小心保持了之前的fn函數地址,那麼雖然我們希望得到的index5,但是實際拿到的index卻是1,這其實就是所謂的閉包陷阱了,我們在下邊探討React的時候也可以通過這個例子理解React的視圖模型。

const collect = [];

const View = (props) => {
  const index = props.index;

  const fn = () => {
    console.log(index);
  }

  collect.push(fn);

  return index;
}

for(let i=0; i<5; ++i){
  View({index: i + 1});
}

collect.forEach(fn => fn()); // 1 2 3 4 5

閉包陷阱

說到這陷阱,不由得想起來一句話,出門出門就上當,噹噹噹當不一樣,平時開發的時候可以說是一不小心就上當掉入了陷阱。那麼我們這個陷阱是完全由閉包引起的嗎,那肯定不是,這只是Js的語言特性而已,那麼這個陷阱是完全由React引起的嗎,當然也不是,所以接下來我們就要來看看爲什麼需要閉包和React結合會引發這個陷阱。

首先我們要考慮下React渲染視圖的機制,我們可以想一下,React是沒有模版的,類似於Vuetemplate這部分,那麼也就是說React是很難去拿到我們希望渲染的視圖,就更不用談去做分析了。那麼在Hooks中應該如何拿到視圖再去更新DOM結構呢,很明顯我們實際上只需要將這個Hooks執行一遍即可,無論你定義了多少分支多少條件,我只要執行一遍最後取得返回值不就可以拿到視圖了嘛。同時也是因爲React渲染視圖非常的靈活,從而不得不這樣搞,Vue不那麼靈活但是因爲模版的存在可以做更多的優化,這實際上還是個取捨問題。不過這不是我們討論的重點,既然我們瞭解到了React的渲染機制,而且在上邊我們舉了一個函數多次運行的示例,那麼在這裏我們舉一個組件多次執行的示例,

// https://codesandbox.io/s/react-closure-trap-jl9jos?file=/src/multi-count.tsx
import React, { useState } from "react";

const collect: (() => number)[] = [];

export const MultiCount: React.FC = () => {
  const [count, setCount] = useState(0);

  const click = () => {
    setCount(count + 1);
  };

  collect.push(() => count);

  const logCollect = () => {
    collect.forEach((fn) => console.log(fn()));
  };

  return (
    <div>
      <div>{count}</div>
      <button onClick={click}>count++</button>
      <button onClick={logCollect}>log {">>"} collect</button>
    </div>
  );
};

我們首先點擊三次count++這個按鈕,此時我們的視圖上的內容是3,但是此時我們點擊log >> count這個按鈕的時候,發現在控制檯打印的內容是0 1 2 3,這其實就是跟前邊的例子一樣,因爲閉包+函數的多次執行造成的問題,因爲實際上Hooks實際上無非就是個函數,React通過內置的use爲函數賦予了特殊的意義,使得其能夠訪問Fiber從而做到數據與節點相互綁定,那麼既然是一個函數,並且在setState的時候還會重新執行,那麼在重新執行的時候,點擊按鈕之前的add函數地址與點擊按鈕之後的add函數地址是不同的,因爲這個函數實際上是被重新定義了一遍,只不過名字相同而已,從而其生成的靜態作用域是不同的,那麼這樣便可能會造成所謂的閉包陷阱。

其實關於閉包陷阱的問題,大部分都是由於依賴更新不及時導致的,例如useEffectuseCallback的依賴定義的不合適,導致函數內部保持了對上一次組件刷新時定義的作用域,從而導致了問題。例如下邊這個例子,我們的useEffect綁定的事件依賴是count,但是我們在點擊count++的時候,實際上useEffect要執行的函數並沒有更新,所以其內部的函數依然保持了上一次的作用域,從而導致了問題。

// https://codesandbox.io/s/react-closure-trap-jl9jos?file=/src/bind-event.tsx
import { useEffect, useRef, useState } from "react";

export const BindEventCount: React.FC = () => {
  const ref1 = useRef<HTMLButtonElement>(null);
  const [count, setCount] = useState(0);

  const add = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    const el = ref1.current;
    const handler = () => console.log(count);
    el?.addEventListener("click", handler);
    return () => {
      el?.removeEventListener("click", handler);
    };
  }, []);

  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
        <button ref={ref1}>log count 1</button>
      </div>
    </div>
  );
};

當我們多次點擊count++按鈕之後,再去點擊log count 1按鈕,發現控制檯輸出的內容還是0,這就是因爲我們的useEffect保持了舊的函數作用域,而那個函數作用的count0,那麼打印的值當然就是0,同樣的useCallback也會出現類似的問題,解決這個問題的一個簡單的辦法就是在依賴數組中加入count變量,當count發生變化的時候,就會重新執行useEffect,從而更新函數作用域。那麼問題來了,這樣就能解決所有問題嗎,顯然是不能的,副作用依賴可能會造成非常長的函數依賴,可能會導致整個項目變得越來越難以維護,關於事件綁定的探討可以研究下前邊 Hooks與事件綁定 這篇文章。

那麼有沒有什麼好辦法解決這個問題,那麼我們就需要老朋友useRef了,useRef是解決閉包問題的萬金油,其能存儲一個不變的引用值。設想一下我們只是因爲讀取了舊的作用域中的內容而導致了問題,如果我們能夠得到一個對象使得其無論更新了幾次作用域,我們都能夠保持對同一個對象的引用,那麼更新之後直接取得這個值不就可以解決這個問題了嘛。在React中我們就可以藉助useRef來做到這點,通過保持對象的引用來解決上述的問題。

// https://codesandbox.io/s/react-closure-trap-jl9jos?file=/src/use-ref.tsx
import { useEffect, useRef, useState } from "react";

export const RefCount: React.FC = () => {
  const ref1 = useRef<HTMLButtonElement>(null);
  const [count, setCount] = useState(0);
  const refCount = useRef<number>(count);

  const add = () => {
    setCount(count + 1);
  };

  refCount.current = count;
  useEffect(() => {
    const el = ref1.current;
    const handler = () => console.log(refCount.current);
    el?.addEventListener("click", handler);
    return () => {
      el?.removeEventListener("click", handler);
    };
  }, []);

  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
        <button ref={ref1}>log count 1</button>
      </div>
    </div>
  );
};

同樣的,當我們多次點擊count++按鈕之後,再去點擊log count 1按鈕,發現控制檯輸出的內容就是最新的count值了而不是跟上邊的例子一樣一直保持0,這就是通過在Hooks中保持了同一個對象的引用而實現的。通過useRef我們就可以封裝自定義Hooks來完成相關的實現,例如有必要的話可以實現一個useRefState,將stateref一併返回,按需取用。再比如下邊這個ahooks實現的useMemoizedFn,第一個ref保證永遠是同一個引用,也就是說返回的函數永遠指向同一個函數地址,第二個ref用來保存當前傳入的函數,這樣發生re-render的時候每次創建新的函數我們都將其更新,也就是說我們即將調用的永遠都是最新的那個函數。由此通過兩個ref我們就可以保證兩點,第一點是無論發生多少次re-render,我們返回的都是同一個函數地址,第二點是無論發生了多少次re-render,我們即將調用的函數都是最新的。

type noop = (this: any, ...args: any[]) => any;

type PickFunction<T extends noop> = (
  this: ThisParameterType<T>,
  ...args: Parameters<T>
) => ReturnType<T>;

function useMemoizedFn<T extends noop>(fn: T) {
  const fnRef = useRef<T>(fn);

  // why not write `fnRef.current = fn`?
  // https://github.com/alibaba/hooks/issues/728
  fnRef.current = useMemo(() => fn, [fn]);

  const memoizedFn = useRef<PickFunction<T>>();
  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }

  return memoizedFn.current as T;
}

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://juejin.cn/post/6844904193044512782
https://juejin.cn/post/7119839372593070094
http://www.ferecord.com/react-hooks-closure-traps-problem.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章