精讀《Records & Tuples for React》

黃子毅發表於2022-01-04

繼前一篇 精讀《Records & Tuples 提案》,已經有人在思考這個提案可以幫助 React 解決哪些問題了,比如這篇 Records & Tuples for React,就提到了許多 React 痛點可以被解決。

其實我比較擔憂瀏覽器是否能將 Records & Tuples 效能優化得足夠好,這將是它能否大規模應用,或者說我們是否放心把問題交給它解決的最關鍵因素。本文基於瀏覽器可以完美優化其效能的前提,一切看起來都挺美好,我們不妨基於這個假設,看看 Records & Tuples 提案能解決哪些問題吧!

概述

Records & Tuples Proposal 提案在上一篇精讀已經介紹過了,不熟悉可以先去看一下提案語法。

保證不可變性

雖然現在 React 也能用 Immutable 思想開發,但大部分情況無法保證安全性,比如:

const Hello = ({ profile }) => {
  // prop mutation: throws TypeError
  profile.name = 'Sebastien updated';
  return <p>Hello {profile.name}</p>;
};

function App() {
  const [profile, setProfile] = React.useState(#{
    name: 'Sebastien',
  });
  // state mutation: throws TypeError
  profile.name = 'Sebastien updated';
  return <Hello profile={profile} />;
}

歸根結底,我們不會總使用 freeze 來凍結物件,大部分情況下需要人為保證引用不被修改,其中的潛在風險依然存在。但使用 Record 表示狀態,無論 TS 還是 JS 都會報錯,立刻阻止問題擴散。

部分代替 useMemo

比如下面的例子,為了保障 apiFilters 引用不變,需要對其 useMemo:

const apiFilters = useMemo(
  () => ({ userFilter, companyFilter }),
  [userFilter, companyFilter],
);
const { apiData, loading } = useApiData(apiFilters);

但 Record 模式不需要 memo,因為 js 引擎會幫你做類似的事情:

const {apiData,loading} = useApiData(#{ userFilter, companyFilter })

用在 useEffect

這段寫的很囉嗦,其實和代替 useMemo 差不多,即:

const apiFilters = #{ userFilter, companyFilter };

useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);

你可以把 apiFilters 當做一個引用穩定的原始物件看待,如果它確實變化了,那一定是值改變了,所以才會引發取數。如果把上面的 # 號去掉,每次元件重新整理都會取數,而實際上都是多餘的。

用在 props 屬性

可以更方便定義不可變 props 了,而不需要提前 useMemo:

<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;

將取數結果轉化為 Record

這個目前還真做不到,除非用效能非常差的 JSON.stringifydeepEqual,用法如下:

const fetchUserAndCompany = async () => {
  const response = await fetch(
    `https://myBackend.com/userAndCompany`,
  );
  return JSON.parseImmutable(await response.text());
};

即利用 Record 提案的 JSON.parseImmutable 將後端返回值也轉化為 Record,這樣即便重新查詢,但如果返回結果完全不變,也不會導致重渲染,或者區域性變化也只會導致區域性重渲染,而目前我們只能放任這種情況下全量重渲染。

然而這對瀏覽器實現 Record 的新能優化提出了非常嚴苛的要求,因為假設後端返回的資料有幾十 MB,我們不知道這種內建 API 會導致多少的額外開銷。

假設瀏覽器使用非常 Magic 的辦法做到了幾乎零開銷,那麼我們應該在任何時候都用 JSON.parseImmutable 解析而不是 JSON.parse

生成查詢引數

也是利用了 parseImmutable 方法,讓前端可以精確傳送請求,而不是每次 qs.parse 生成一個新引用就發一次請求:

// This is a non-performant, but working solution.
// Lib authors should provide a method such as qs.parseRecord(search)
const parseQueryStringAsRecord = (search) => {
  const queryStringObject = qs.parse(search);
  // Note: the Record(obj) conversion function is not recursive
  // There's a recursive conversion method here:
  // https://tc39.es/proposal-record-tuple/cookbook/index.html
  return JSON.parseImmutable(
    JSON.stringify(queryStringObject),
  );
};

const useQueryStringRecord = () => {
  const { search } = useLocation();
  return useMemo(() => parseQueryStringAsRecord(search), [
    search,
  ]);
};

還提到一個有趣的點,即到時候配套工具庫可能提供類似 qs.parseRecord(search) 的方法把 JSON.parseImmutable 包裝掉,也就是這些生態庫想要 “無縫” 接入 Record 提案其實需要做一些 API 改造。

避免迴圈產生的新引用

即便原始物件引用不變,但我們寫幾行程式碼隨便 .filter 一下引用就變了,而且無論返回結果是否變化,引用都一定會改變:

const AllUsers = [
  { id: 1, name: 'Sebastien' },
  { id: 2, name: 'John' },
];

const Parent = () => {
  const userIdsToHide = useUserIdsToHide();
  const users = AllUsers.filter(
    (user) => !userIdsToHide.includes(user.id),
  );
  return <UserList users={users} />;
};

const UserList = React.memo(({ users }) => (
  <ul>
    {users.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
));

要避免這個問題就必須 useMemo,但在 Record 提案下不需要:

const AllUsers = #[
  #{ id: 1, name: 'Sebastien' },
  #{ id: 2, name: 'John' },
];

const filteredUsers = AllUsers.filter(() => true);
AllUsers === filteredUsers;
// true

作為 React key

這個想法更有趣,如果 Record 提案保證了引用嚴格不可變,那我們完全可以拿 item 本身作為 key,而不需要任何其他手段,這樣維護成本會大大降低。

const list = #[
  #{ country: 'FR', localPhoneNumber: '111111' },
  #{ country: 'FR', localPhoneNumber: '222222' },
  #{ country: 'US', localPhoneNumber: '111111' },
];
<>
  {list.map((item) => (
    <Item key={item} item={item} />
  ))}
</>

當然這依然建立在瀏覽器非常高效實現 Record 的前提,假設瀏覽器採用 deepEqual 作為初稿實現這個規範,那麼上面這坨程式碼可能導致本來不卡的頁面直接崩潰退出。

TS 支援

也許到時候 ts 會支援如下方式定義不可變變數:

const UsersPageContent = ({
  usersFilters,
}: {
  usersFilters: #{nameFilter: string, ageFilter: string}
}) => {
  const [users, setUsers] = useState([]);
  // poor-man's fetch
  useEffect(() => {
    fetchUsers(usersFilters).then(setUsers);
  }, [usersFilters]);
  return <Users users={users} />;
};

那我們就可以真的保證 usersFilters 是不可變的了。因為在目前階段,編譯時 ts 是完全無法保障變數引用是否會變化。

優化 css-in-js

採用 Record 與普通 object 作為 css 屬性,對 css-in-js 的區別是什麼?

const Component = () => (
  <div
    css={#{
      backgroundColor: 'hotpink',
    }}
  >
    This has a hotpink background.
  </div>
);

由於 css-in-js 框架對新的引用會生成新 className,所以如果不主動保障引用不可變,會導致渲染時 className 一直變化,不僅影響除錯也影響效能,而 Record 可以避免這個擔憂。

精讀

總結下來,其實 Record 提案並不是解決之前無法解決的問題,而是用更簡潔的原生語法解決了複雜邏輯才能解決的問題。這帶來的優勢主要在於 “不容易寫出問題程式碼了”,或者讓 Immutable 在 js 語言的上手成本更低了。

現在看下來這個規範有個嚴重擔憂點就是效能,而 stage2 並沒有對瀏覽器實現效能提出要求,而是給了一些建議,並在 stage4 之前給出具體效能優化建議方案。

其中還是提到了一些具體做法,包括快速判斷真假,即對資料結構操作時的優化。

快速判真可以採用類似 hash-cons 快速判斷結構相等,可能是將一些關鍵判斷資訊存在 hash 表中,進而不需要真的對結構進行遞迴判斷。

快速判假可以通過維護雜湊表快速判斷,或者我覺得也可以用上資料結構一些經典演算法,比如布隆過濾器,就是用在高效快速判否場景的。

Record 降低了哪些心智負擔

其實如果應用開發都是 hello world 複雜度,那其實 React 也可以很好的契合 immutable,比如我們給 React 元件傳遞的 props 都是 boolean、string 或 number:

<ExpensiveChild userName="nick" age={18} isAdmin />;

比如上面的例子,完全不用關心引用會變化,因為我們用的原始型別本身引用就不可能變化,比如 18 不可能突變成 19,如果子元件真的想要 19,那一定只能建立一個新的,總之就是沒辦法改變我們傳遞的原始型別。

如果我們永遠在這種環境下開發,那 React 結合 immutable 會非常美妙。但好景不長,我們總是要面對物件、陣列的場景,然而這些型別在 js 語法裡不屬於原始型別,我們瞭解到還有 “引用” 這樣一種說法,兩個值不一樣物件可能是 === 全等的。

可以認為,Record 就是把這個顧慮從語法層面消除了,即 #{ a: 1 } 也可以看作像 1819 一樣的數字,不可能有人改變它,所以從語法層面你就會像對 19 這個數字一樣放心 #{ a: 1 } 不會被改變。

當然這個提案面臨的最大問題就是 “如何將擁有子結構的型別看作原始型別”,也許 JS 引擎將它看作一種特別的字串更貼合其原理,但難點是這又違背了整個語言體系對子結構的預設認知,Box 裝箱語法尤其彆扭。

總結

看了這篇文章的暢想,React 與 Records & Tulpes 結合的一定會很好,但前提是瀏覽器對其效能優化必須與 “引用對比” 大致相同才可以,這也是較為少見,對效能要求如此苛刻的特性,因為如果沒有效能的加持,其便捷性將毫無意義。

討論地址是:精讀《Records & Tuples for React》· Issue #385 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章