Reactjs 問世十年後的開發體驗 一袋米扛幾樓的難度有多少

me
林彥成
2024-04-06 | 11 min.
文章目錄
  1. 1. 資料層: Flux 架構、Redux
    1. 1.1. Store: Rudux
    2. 1.2. Store: Zustand
  2. 2. 業務邏輯層: React Hooks
    1. 2.1. API Hooks: SWR、React Query
  3. 3. 展示層: JSX、CSS-in-JS
    1. 3.1. Reconciliation: Virtual DOM、Fiber Tree
    2. 3.2. 元件分類與週期: React Class Component、Functional Component
    3. 3.3. 元件開發: Storybookjs

Reactjs 從 2013 年問世後至今滿 10 年,提供前端開發領域元件設計的典範基礎,逐步帶動並完善了開發的生態系統。

最初的 React 就是一個單純的函式,提供 props 並進行內部的狀態運算後回傳一個計算後的 DOM 元素呈現在網頁上。

DOM = React(props, state)

回顧過去的十年,Reactjs 的發展從 Clean Architecture 角度來看主要圍繞以下三個層面:

  • 資料層:單向資料流
  • 業務邏輯層:Hooks
  • 展示層:JSX、CSS-in-JS


圖片來源: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

在 2024 年 Reactjs 的開發體驗將會是什麼樣子呢?

React 近十年的發展下來,在 2022 React 18 版本釋出後停滯了接近兩年,下一個主要版本 19 會是著重在新的渲染引擎,從官方的 Github 可可以看出接下來的 React 18.3 主要是為了接下來的 React 19 版做準備,,部落格可以看出 React 19 將減少開發者撰寫多餘的 hook,最終的目的是簡化開發流程,降低開發人員的學習成本,提供更簡潔的開發體驗與更高的開發效率。

相較於同樣有著包袱的隔壁棚 Angular 每次升級幾乎都是 Breaking Change,React 從最初的簡單函式庫在經歷了多次版本迭代,繼續背著承重的歷史包袱帶著大家繼續前行。

相對於 React、Angular,較沒有人員和程式碼包袱的 Vue 和 Svelte 也都在 React 和 Angular 的肩膀上提供了不錯的解決方案,至於最新的 React Server Components 要讓 React 一袋米扛幾樓? Next.js 和 RedwoodJS 正在嘗試著給出好的答案。

資料層: Flux 架構、Redux

Reactjs 提出單向資料流(Unidirectional Data Flow)的概念,強調資料的不可變性(Immutability)

Flux 架構提供資料管理方案,小編早期接觸過 AltRedux 兩種,Flux 用單向資料流取代傳統的 MVC,這意資料從單一入口 (動作) 進入,然後透過狀態管理器 (Store) 向外流動,最後到達畫面 (view),畫面又可以透過呼叫其他動作來回應使用者輸入,重新開始資料流的流程。

早期的 Redux 有個問題就是會產生很多的 Code Boilerplate,開發過程中會很常需要複製貼上許多類似架構的程式碼,官方後來還推出 Redux Toolkits 來協助寫法簡化,本來要維護三個地方,現在變成只要維護一份配置檔就好了。

Store: Rudux

為什麼需要 Redux? 使用與否的差異在哪?

直接開始一個情境,我們想像一個頁面中有三個元件:

  1. 元件一: 登入按鈕區塊,登入後顯示 hello, XXX (XXX 為學生名稱)
  2. 元件二: 顯示登入後撈回的各科成績資料
  3. 元件三: 更改學生姓名區塊

那麼有或沒有 Redux 的情況下,要怎麼實作這樣的頁面呢?關鍵需要解決的問題就是元件之間溝通的問題:

  • 沒有 Redux 時: 使用 Container 元件來管理狀態,把以上三個子元件都放在容器裡,並在容器中寫幾個 callback function 當作 props 傳進子元件中,讓子元件可以在改變狀態時,把改變的狀態及時回傳到容器裡,這就是官方文件中寫的 Lift State Up


Lift State up: https://react.dev/learn/passing-data-deeply-with-context

  • 有 Redux 後: 最大的改變就是,Lift State Up to Store,把狀態統一管理避免 Prop drilling 維持 Single Source of Truth,所有的來源都是來自於一個可被預測的地方,開發工具因此可以做到時空旅行,讓狀態停在任意想要的時間點


Prop drilling: https://react.dev/learn/passing-data-deeply-with-context

使用 Redux ToolKits 後按照功能搭配分類,以剛剛 Clean Architecture 角度來說就是針對 Entities 分類,小編覺得這種配置會比較適合大型專案,舉例來說依照不同的腳色功能就會分成

  • typeOne
  • typeTwo
  • typeThree

用常見的拍賣網站來說就像是買家、普通賣家、商城賣家,而每個功能所需要的 action、components、containers、reducers 都會放在一起,所以在開發時,每個工程師都可以在獨立的資料夾中完成該次的任務。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
src
└── features
├── typeOne
│ ├── TypeOne.js
│ ├── TypeOne.styles.scss
│ └── typeOneSlice.js
├── typeTwo
│ ├── TypeTwo.js
│ ├── TypeTwo.styles.scss
│ └── typeTwoSlice.js
└── typeThree
├── TypeThree.js
├── TypeThree.styles.scss
└── typeThreeSlice.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createSlice } from "@reduxjs/toolkit";

const initialState = { value: 0 };

const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
},
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

以程式碼的架構面來說,使用 Redux 是一個重大的決定,從寫法上看來幾乎是穿透了專案所有地方,從 GUI 上的 Action 需要透過特殊的方式 Dispatch 一直到打 API 的 middleware 一直到 Global 的 Store,所以也才演變出 Container 和 Component 的概念,還是盡量讓元件是可以高度重用的設計。

Store: Zustand

隨著時間的演進,最近有越來越多管理狀態的工具,以概念上來說粗分為三種流派 Atom、Store、Proxy,如果以最傳統的 Store 概念來說,Zustand 是小編目前用過最簡單的,最基本的 store 只要短短幾行就完成配置。

1
2
3
4
5
6
import { create } from "zustand";

const useStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}));

程式碼架構上來說,因為是使用 hook 的關係,所以剩下只需要在元件中透過 hook 的概念將 Store 掛載進去元件進行使用即可

1
2
3
4
5
6
7
8
9
function Counter() {
const { count, inc } = useStore();
return (
<div>
<span>{count}</span>
<button onClick={inc}>one up</button>
</div>
);
}

在專案架構上 Zustand 除了 hook 以外也提供了不同的架構方式,不使用 hook 而透過直接當模組使用的方式也讓元件的呈現能更為簡潔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const useDogStore = create(() => ({ paw: true, snout: true, fur: true }));

// Getting non-reactive fresh state
const paw = useDogStore.getState().paw;
// Listening to all changes, fires synchronously on every change
const unsub1 = useDogStore.subscribe(console.log);
// Updating state, will trigger listeners
useDogStore.setState({ paw: false });
// Unsubscribe listeners
unsub1();

// You can of course use the hook as you always would
function Component() {
const paw = useDogStore((state) => state.paw);
}

業務邏輯層: React Hooks

React Hooks 提供了一種更簡潔、更靈活的方式來管理狀態和副作用,使開發人員能夠更專注於業務邏輯的實現

React Hooks 的出現是 Reactjs 發展史上的重要里程碑,讓副作用處理和加值功能能透過 hook 的方式整合進 functional component,相對 class 的處理能提供更簡單的寫法,像 redux 的 useSelector 就取代 connect 和 mapStateToProps。

React Hooks 的概念可以想像成是在登山背包上,透過外掛系統將需要的功能或需要的材料掛載到背包上,透過 hooks 將邏輯處理的函式和處理後的資料掛載到元件上。


圖片來源: https://www.zhihu.com/tardis/zm/art/456354561?source_id=1003

React Hooks 由剛剛的說明來看就屬於外掛子系統的一個概念,所以在撰寫上也有要注意的規範

  1. 只能在 function 中的最上層使用 hooks,不要在迴圈中或是判斷裡面執行,外掛系統必須跟著元件在產生的時候一起掛載上去,就像是登山背包出門發前就要將裝備準備齊全一樣
  2. 只能在 React Function 裡面使用 hooks 不能在一般的 JavaScript Function 中使用,因為 hooks 是專屬於 React 元件的外掛系統,就是像都已經出門登山了外掛的東西在半山腰突然修改一樣
  3. 不要動態的修改 hooks 的內容,外掛系統也是一個系統不要邊使用邊改變
1
2
3
4
5
6
7
8
9
10
// 1. 只能掛載在最上層,且一開始就使用,而不是當作半路上的選配
function ChatInput() {
return <Button useData={useDataWithLogging} />; // 🔴 Bad: don't pass Hooks as props
}

// 3. 不要動態的修改 hooks 的內容,外掛系統也是一個系統不要邊使用邊改變
function ChatInput() {
const useDataWithLogging = withLogging(useData); // 🔴 Bad: don't write higher order Hooks
const data = useDataWithLogging();
}

API Hooks: SWR、React Query

舉個例子來說與後端 API 介面整合的部分,早期的寫法小編大多是透過定義 API Service 來進行處理。

在 Fetch 尚未普及時,Axios API 提供了一個不錯的解決方案,打 API 的時候,可能常常不只一個後端,不同後端也會需要有兩個不同的 token,放置的位置可能也不一樣,成功回覆的狀態碼也不同,失敗的情況也不大相同。所以如果當多個地方都需要同時打 A、B 兩個不同的 API 時,也代表多個地方都要進行類似的預/後處理。

axios 透過產生 instance 的方式,幫我們統一處理了 request 發出前,接收 response 後,統一的預處理、後處理的問題。這樣只要在不同的地方使用 A、B instance 去進行 API 的串接即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const A = axios.create();

// Add a request interceptor
A.interceptors.request.use(
function (config) {
// Do something before request is sent
return config;
},
function (error) {
// Do something with request error
return Promise.reject(error);
}
);

// Add a response interceptor
A.interceptors.response.use(
function (response) {
// Do something with response data
return response;
},
function (error) {
// Do something with response error
return Promise.reject(error);
}
);

A.get("/api/path");

SWR 與 React Query 則提供了 hook 的解決方案,對於 API 資料的處理寫法上簡化了不少。

1
2
3
4
5
6
7
8
9
10
11
12
import useSWR from "swr";
import axios from "axios";

const fetcher = (url) => axios.get(url).then((res) => res.data);

function Profile() {
const { data, error, isLoading } = useSWR("/api/user", fetcher);

if (error) return <div>failed to load</div>;
if (isLoading) return <div>loading...</div>;
return <div>hello {data.name}!</div>;
}

早期 API services 的設計在 hook 則透過客製化不同的 fetcher 來進行轉換,fetcher 依舊可以維持原來的作法,只是在元件中我們不再透過 useEffectuseState 來處理資料。

展示層: JSX、CSS-in-JS

Reactjs 使用 JSX 語法來描述 UI 界面,使其更易於理解和維護。

DOM = React(props, state)

元件中的 JSX 會透過 createElement 這個 function 被轉換成 React Element。

Reconciliation: Virtual DOM、Fiber Tree

React Element 是一個物件,包含 type 及 properties,type 用來區分是 component instance 或是 DOM node,Element 在 React 中又分成兩種

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 1. Element (type 為 DOM node)
{
type: 'button',
props: {
className: 'button button-blue',
children: {
type: 'b',
props: {
children: 'OK!'
}
}
}
}

<button class='button button-blue'>
<b>
OK!
</b>
</button>

// 2. Component Element (type 為 instance)
const DeleteAccount = () => ({
type: SubmitForm,
props: {
children: [{
type: 'p',
props: {
children: 'Are you sure?'
}
}, {
type: Button,
props: {
color: 'blue',
children: 'Cancel'
}
}]
});

const DeleteAccount = () => (
<SubmitForm>
<p>Are you sure?</p>
<Button color='blue'>Cancel</Button>
</SubmitForm>
);

當我們知道底層的定義之後,React 透過 Virtual DOM 減少了高成本的 DOM tree 操作,可以參考 state of the art algorithms,React 效能好就歸功於寫的程式碼並不會操作 DOM,而是會有以下特性:

  • 改變的狀態其實是操作我們常聽到的 Virtual DOM 或是 Fiber Tree
  • 狀態對 UI 影響是非同步,如果狀態在一個循環內 A -> B -> C -> A 這樣最後 DOM 就不會變化
  • 相同 type,如果 attributes 改變則會偵測並變化 attribute
  • Reconciliation (fiber) 將複雜度減少到 O(n)

React 也會這樣的資料結構中,處理狀態對於元件的改變,這個結構就是我們常聽的 Virtual DOM,有篇開箱文寫的很棒:

https://pomb.us/build-your-own-react/

在這篇文章中可以看成是一棵 left-chlid right-sibling tree:

  • 每一個 Node 有指向 Parent 的參考
  • 過程先看有沒有 Child,再看有沒有 Sibling,都沒有才回到 Parent
  • 整棵樹走完才 render 結果到實際的 DOM 上

Reconciliation 就是一個演算法,找出哪些樹節點哪些需要變化,當我們呼叫 render() 的時候,React 會做一個 Top-Down 的 Reconciliation。

過程中會不停地去問你的 type 是什麼? 如果我們定義了一個 Component Elements X 且 type 是 Y,那 React 就會去問什麼是 Y,直到問到最基礎的組成為止。

Reconciliation 實作上會符合以下假設:

  • 不同 type,在 react 會產生不同的樹,不會去偵測而是直接取代
  • 偵測一個 list 的改變會透過 keys 來增加效能
1
2
3
4
5
6
7
8
9
10
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

元件分類與週期: React Class Component、Functional Component

實際上元件中週期有哪些? 執行順序如何? 就像人有生老病死,元件從出現到消失主要也分三部分

Mount -> Updating -> Unmounting

React 要寫出一個元件,有 Class-based 或是 Functional 兩種方式,早期的 React Class Component 開發參雜了各種複雜的元件週期,也許是因為開發初期,所以將週期都拆解得很詳細,並且提供可以客製的型態,光是理解這些就讓開發的入門門檻提高不少。

  1. Mount: 已經出現在瀏覽器的 DOM 上
    • constructor()
    • static getDerivedStateFromProps()
    • render()
    • componentDidMount()
  2. Updating: Props 或 State 變化後引發的元件更新
    • getDerivedStateFromProps()
    • shouldComponentUpdate()
    • render()
    • getSnapshotBeforeUpdate()
    • componentDidUpdate()
  3. Unmounting: 從瀏覽器的 DOM 中移除
    • componentWillUnmount()

另外元件從 Props 或 State 變化後引發改變的過程,主要分為兩大階段 Render 和 Commit,會先進行 Render 的計算後才會真的 Commit 結果到真正的 DOM 上面,Commit 前會有個 Pre-commit。

  1. Render: 在這個階段 React 能自行暫停、取消、重新這個過程
  2. Pre-commit: 文件上有出現但甚少使用的功能,有一個週期是 getSnapshotBeforeUpdate(),可以看成是一個做決定前的再次狀態確認
  3. Commit: 套用改變到瀏覽器的 DOM 上,而這是肉眼可見,也是我們較常操作的週期
    1. componentDidMount(): Mount 成功,出現在 DOM 上面
    2. componentDidUpdate(): Props 或 State 改變
    3. componentWillUnmount(): 從 DOM 上移除

React 經過十年後,現在常見的 Functional Component 已經不再需要處理複雜的元件週期,取而代之的是理解關鍵元件週期並透過 hook 處理,當元件 props 或是 state 改變後,React 會透過 render 的演算法來決定最終要更新到 DOM 上面的改變,這個過程就是剛剛提到的 reconciliation。

雖然說透過 hook 能大量簡化元件本身的規則,但元件還是有一些基礎原則需要遵守一些原則來設計:

  1. 冪等 (Idempotent): 這部分跟 API 設計一樣,React Funtional Component 本質上也還是一個 Function,所以將元件設計為 Pure Function 且保證每次的 Input 也就是 State 和 Props 不改的情況下,每次都要 Render 出同樣的結果
  2. 不在 Render 的地方處理 Side Effect: 元件本身可以看成一個 Declarative 的語言,理論上我們想要出現什麼就是什麼,React 只優化狀態改變後的 “下個版本” 該顯示什麼該怎麼改變畫面,會出現副作用讓畫面不同的地方通常只剩使用者操作事件或是後端 API 的資料不符合 Idempotent,當 Render 元件這件事變成純函式,React 也可以更容易的去優化
  3. 不去修改非元件內的資料: Props 跟 state 都是 immutable 也就是不要造成額外副作用的意思
  4. 元件用 JSX 的方式執行: 雖然 Functional Component 可以當成 Function 直接執行 HelloComponent(),但你會發現 React 開發者工具就無法處理 AKA React 並沒有辦法好好處理,建議方式還是 <HelloComponent /> 讓 React 正常執行上面提到的 createElement 產生正確的 Virtual Dom
  5. 元件裡面不要再有元件: 會讓元件檔案變大也很難做完全隔離的單元測試,這種寫法大概只能存在於重構到一半的程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Bad: 元件只用 JSX 的方式執行
function ComponentOne() {

return (
<div>
<ComponentOne></ComponentOne>
{() => {return <>ComponentTwo</>;}()}
</div>
);
}

function ComponentOne() {
const ComponentTwo = () => {
return <>ComponentTwo</>;
};

return (
<div>
<ComponentOne></ComponentOne>
{ComponentTwo()}
</div>
);
}

// Bad: 元件裡面不要再有元件
function ComponentOne() {
const ComponentTwo = () => {
return <>ComponentTwo</>;
};

return (
<div>
<ComponentOne></ComponentOne>
<ComponentTwo></ComponentTwo>
</div>
);
}

// Good
const ComponentTwo = () => {
return <>ComponentTwo</>;
};
function ComponentOne() {
return (
<div>
<ComponentOne></ComponentOne>
<ComponentTwo></ComponentTwo>
</div>
);
}

元件開發: Storybookjs

此外 Storybook 也搭著這波元件的概念,發展出了 Component-based 的架構和開發方式,這樣的概念其實就是大家熟悉的原子化設計,

  1. 獨立開發每個元件,並為其不同變體撰寫故事 (測試案例)
  2. 將小型元件組合在一起以實現更複雜的功能
  3. 通過組合複合元件來組裝頁面
  4. 整合資料和業務邏輯將頁面整合到專案


圖片來源: https://bradfrost.com/blog/post/atomic-web-design/

底下的程式碼示範了一個最簡單的故事書設定,透過以下的設定我們就能夠輕鬆的獲得一個完全隔離的環境來進行元件開發,即使多人合作的專案也不容易互相衝突,設計出來的元件也會更容易符合單一責任原則。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import type { Meta, StoryObj } from "@storybook/react";

import { Histogram } from "./Histogram";

const meta: Meta<typeof Histogram> = {
component: Histogram,
};

export default meta;
type Story = StoryObj<typeof Histogram>;

export const Default: Story = {
args: {
dataType: "latency",
showHistogramLabels: true,
histogramAccentColor: "#1EA7FD",
label: "Latency distribution",
},
};

由於 component-based 的概念興起元件開發成為顯學,CSS-in-JS 提供了將樣式寫在元件中解決方案,也讓 CSS 需要從寫程式語言的角度去進行架構設計。

開發上 CSS-in-JS 的流行,當網頁三本柱 HTML、CSS、JavaScript 能夠完整的寫在同一個檔案時,也使 Reactjs 的 UI 開發更加靈活和一致,不僅降低了維護難度也加速了開發速度減少檔案切換,同時也減少 class 命名錯誤等等問題,讓元件成為真正高內聚的元件。

常見的 library 像是 css module、vanilla-extract、styled-components、styled-jsx (Next.js)、StyleX (Facebook) 都非常好上手,更完整的將元件模組化並增加可重用性。


喜歡這篇文章,請幫忙拍拍手喔 🤣


share