跳至主要内容

JavaScript 變數

· 閱讀時間約 8 分鐘

本篇彙整了 JavaScript 變數的相關知識,包含變數宣告、變數型態、變數賦值、變數作用域、閉包等等。

JavaScript 是動態型別語言

JavaScript 是「動態型別語言」或稱「弱型別語言」。

代表說他不需要在宣告變數時指定變數的型別,JS runtime 會藉由儲存的值來判斷他的型別。

let myName = '123'
// 用雙引號包裹的話,JS runtime 可以識別這是一個字串

其他「強型別語言」,如 Java,就需要在宣告變數時就指定型別,否則會出現編譯錯誤。

int myName = 123
// 需要指定變數型別為 int

變數資料型態

Primitive type 原始型態 (純值)

變數的記憶體位置裡存的是值本身。

  1. null

    • 已經宣告但不存在
    • 比 undefined 適合作為一個變數的初始值
  2. undefined

    • 未宣告
  3. string 字串

  4. number 數字

  5. boolean 布林值

  6. Symbol (ES6)

    • 在建造中,還沒有被全部的瀏覽器支援

Reference type 參考型態

變數的記憶體位置裡存的是記憶體位置參照而不是值本身。 例如:object, function, array。

型態查詢

  1. typeof

    背後有對應的表格在轉換,例如:

    typeof [] -> Object

    typeof function(){} -> function

    typeof null -> Object (這是一個 bug)

    typeof 常見用途: 用來檢查變數有沒有宣告,避免沒宣告會報錯的情況

    const a = 10
    if (typeof a !== 'undefined') {
    // 如果對一個沒宣告的變數 typeof 會是 undefined
    if (a !== 'undefined') {
    // 沒宣告,會報錯
    console.log(a)
    }
    }

    由上面可發現,typeof 有時不是很直覺,沒辦法得知真實型態,例如沒辦法用 typeof 來得知一個變數是不是 array。

    可以改用 Array.isArray([]),或者較準確的方法為以下...

  2. Object.prototype.toString.call(要檢查的變數)

    -> [Object 輸入的變數的型態]

變數宣告

var 是 ES6 以前變數宣告唯一的方法,ES6 開始才出現 let 跟 const。

以下比較差別:

  • scope 變數的生存範圍

    var 為 function scope,let 與 const 為 block scope

  • hoisting 行為不同

    • var 在 hoisting 時會將變數給定一個記憶體空間並預設為 undefined,賦值前取用不會報錯,只會得到 undefined。

    • let, const 也會有 hositing ,也會在記憶體中被設定好,但直到賦值前之前你都不能取用,JS engine 會把他擋下來不讓你取用,這段期間稱為 TDZ (Temporal Dead Zone)。

  • 重複宣告

    var 可以被重複宣告,let 與 const 則不行。

    var c = 123
    var c = 456
    console.log(c) // 456

    let d = 123
    let d = 456
    // caught SyntaxError: Identifier 'd' has already been declared

    const e = 123
    const e = 456
    // caught SyntaxError: Identifier 'd' has already been declared
  • const 宣告時一定要賦值。不可以重新賦值,但可以更改內容。其餘兩者沒有此限制

    const a = 123
    a = 456
    // caught SyntaxError: Identifier 'a' has already been declared

    const b = { test: 123 }
    b.test = 456
    console.log(b) // {test: 456}

變數賦值:primitive type 與 reference type 賦值的行為差異比較

承上述,在 primitive type 當中,賦值存的是值,但 Object 存的是記憶體位置。

因此在 primitive type 中:

const a = 10 // 先把 a 設定為 10
const b = a // 把 b 設定為 10
b = 20 // 把 b 改為 20

但對 Object 來說,如果我今天宣告一個變數 obj:

/*    
開一個記憶體位置
0x01 : { number: 10 }
將這個記憶體位置存到 obj
obj: 0x01
*/
var obj = { number: 10 }

/*
把 obj 存的記憶體位置也存到 obj2 中
obj2: 0x01
*/
var obj2 = obj

/*
這邊要注意的是,當我現在又對 obj2 **賦值**,他就會跟原本 obj 記憶體位置斷開連結。
底層作的事情其實是再去開一個新的記憶體位置,如: 0x02,來存 20
0x20: { number: 20 }
然後再把 obj2 的裡面存這個新的記憶體位置
obj2: 0x20
*/
obj2 = { number: 20 }

/*
但這情況代表的就不同上述了,他指的是去存取 obj2 記憶體位置當中的 number,把他改成 20
所以 obj2 存的記憶體位置還是同一塊,而不會區開一塊新的指過來
*/
obj2.number = 20

由上例子可知,對於 object 來說,賦值背後作的事情其實是會先開一個新的記憶體位置來存值,再把 object 的值設為新的記憶體位置。

變數的可變性 (mutable) 與不可變性 (immutable)

primitive type 的變數為 immutable,代表我們沒有能力去改變他的內容,即便我用函式去改變他的內容,也只會回傳改變後的結果,如果把原本的變數印出來還是會跟原本一樣,這就是 immutable 的特性。

不同的是,Object 可以是 mutable ,我們有辦法改變他的內容,表示改變內容後的結果可能是改到原本變數的內容,或者沒有改到,這兩種都是「允許」發生的,因此在操作時要特別去注意文件回傳的是什麼,有無改變到原本的 Object。

變數比較:== and === 的差異

==: 不同型態比較時,背後有一方會先去轉換型態再檢查

===: 不會轉換型態,因此型態不同就是不同

注意事項
  1. 物件比較要注意,用 === 時,記憶體位置一樣才是一樣,而不是去比較實際上的 key/value pair 是否相等
  2. NaN 的型態是 number,NaN 比較時要注意,他不會相等於任何東西,包括自己。 可以用 isNaN(變數) 來檢視是否為 NaN
  3. 如果不是完全了解 == 轉換規則的情況下,永遠用 === 會最保險。

詳細比較結果可以查看 JavaScript comparison table

變數的生存範圍:Scope 作用域

ES5

  1. 只有 function 能夠產生一個作用域
  2. function 內找不到變數會往 scope chain 上一層找 (如,全域) 找; 如果有找到,即便變數名稱一樣(一樣是合法的),就不會向外找。這種不在自己作用域中,也不是被當成參數傳進來的變數,就可以稱作 free variable,可以翻做自由變數
  3. 如果沒有用 var 宣告變數,直接賦值給一個位宣告的變數,這個變數會被宣告成全域變數

ES6

  1. 多了 let, const 兩種宣告變數的方式
  2. 這兩種變數宣告的方式是以 Block {} 來產生作用域 (if, function …都會產生新的作用域)

Scope chain and Closure

Scope chain

在宣告時就定義好了,依據他在程式碼脈絡中實體位置 (Lexical environment),每個變數的外部參照 (outer lexical environment) 會被決定好,因為通常會很多層,這樣層層疊疊決定變數能夠在那邊被存取的東西就是 scope chain。

這個外部參照的存取範圍與他在那邊被呼叫無關,這樣的作用域就叫做靜態作用域 (static scope)

在其他程式語言當中也可能是設計成動態作用域 (dynamic scope),scope 就會是在呼叫時才被決定的

閉包 (Closure)

MDN 說明:

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function.

=> closure 就是 function 與他的 lexical environment 的結合體。 換句話說,function 與他的 lexical environment 會形成 closure。

function 實際在程式碼脈絡中處在的實體位置 (lexical environment),決定他與其他程式碼之間的關係。

也就會影響到 function 內部的變數他可以使用哪些外部的變數, 為何要有 closure ? 為了要確保你永遠能存取到該變數外部參照的 scope chain 當中的變數。

在一個 function 中 return 一個 function,可以形成 closure,但這邊要注意,不一定要 return function 才會形成 closure,其實所有的 function 都是 closure。

在下面的例子當中 var func = outer(),執行完之後 outer EC 就不在了,照理來說,在下面再次呼叫 func(),會取不到 a。

但實際上取得到,因為 closure 的特性,在 var func = outer() 執行的時候,他不但回傳了 inner,也回傳的他的 lexical environment,也就是說 closure 被 return 了。

function outer() {
var a = 10
function inner() {
a++
console.log(a)
}
return inner
}
var func = outer()
func()

Reference

Deploy with Cloud Run

· 閱讀時間約 3 分鐘

This article introduces how we can deploy a service (take NextJS as an example) to Google Cloud Run using Docker and GCP Artifact Registry.

Create a project on GCP

First, we need to create a project on GCP.

Create an Artifact registry

After creating the project, navigate to ‘Artifact registry’ and click ‘+ CREATE REPOSITORY’ to create a repository for out application.

Fill out proper info., like so:

  1. set the repository name
  2. set region to ‘asia-east1(Taiwan)’

Cloud Run deployment with GitLab CICD

· 閱讀時間約 3 分鐘

This article introduces how to setup a GitLab CICD pipeline to deploy a service to Google Cloud Run.

Create a Service account with proper roles for CI runner

  1. In order to add and update the role of a service account, we need to ensure our account possess permission resourcemanager.projects.setIamPolicy before starting.

  2. Go to IAM and admin > Service accounts and click CREATE SERVICE ACCOUNT

Electron.js

· 閱讀時間約 6 分鐘

Electron.js 是一個讓你可以用網頁技術來寫跨平台桌面應用程式的 javascript 套件。

如何為不同作業系統平台開發桌面應用程式?


一般在 windows 當中可能會透過撰寫 C# 搭配 Windows form,在 MacOS 中可能會需要寫 swift 或 objective-c。

Electron.js 是什麼,為何要用他?


2013 年左右 GitHub 團隊在開發 Atom 時候,因為也想要利用網頁技術去開發這個編輯器,當時市面上沒有比較好的解決方案,因此他們就決定自己開發這個工具,並命名為 Atom-shell,而後來改名為 Electron

Electron 將 Chromium 及 Node.js embed 在一起作為 runtime,使得我們可以利用前端技術來開發桌面應用程式,當然也可以使用你最喜歡的框架來開發

因為網頁具有跨平台的特性,目前是能支援到三個平台,windows, macOS, Linux(可以 output 這三種平台的安裝檔)

因此對一個 commercial 的產品來說,公司如果已經有個網頁,這時出現了需要這個網頁的 windows 及 MacOS 或應用程式版本的話,就會需要找到能夠寫這些語言的人,除此之外也會耗費不少時間在這個轉換上。這時候像是 Electron 這樣的套件就有他的市場出現了。

Electron.js 優缺點比較


優點:

第一個顯著的好處就是跨平台,在系統要從網頁版本轉換為桌面應用程式時,公司可以剩下聘請 Native app 工程師的預算以及時間。

第二個是寫起來的體驗蠻接近在寫網頁的,對於已經對網頁技術有一點概念的開發人員,要上手 Electron 算是幾乎沒有學習成本

第三是因為跟網頁技術結合,他也可以算是共享前端的 community 社群,有龐大的社群支持。

缺點:

因為 Electron 會將 node.js 和 chromium bundle 在一起,因此他的應用程式大小會比用 native 語言及框架來寫還要更大上不少,即便只有最基礎的設定,Electron app 的安裝黨也有大約會有 100 MB 以上。

另外也因為他試圖用同一份 code 在不同 OS 上,Electron 對不同 OS 上有去作一些優化處理,使得他會比 Native OS 專一的 app 對於系統資源的利用效率還要來得比較沒那麼好。

在 Electron 程式碼當中其實會需要花時間去處理 OS 專一的功能,有人就覺得到頭來開發上可能更耗費時間

所以 Electron 其實帶來很多便利之餘也有許多不那麼好的地方

如果今天是單看已經有網頁版,要轉換到桌面應用程式的情況,或者要開發的系統比較小型,那 Electron 可能就會是個不錯的選擇

誰有在用 Electron.js?


以下是一些比較常見的 ,以 Electron 開發的 app,除了一開始有提到的 vscode,還有像是 messenger, twitch, 還有我們常用他來畫 ui flow 的 figma

Multi-process architecture of chromium and Electron


瀏覽器的架構可以是單一 process 或多 process 的設計

在架構方面,Electron 繼承了 Chromium multi-process 的架構

在瀏覽器架構上,早期多為 single process,就類似一個人作公司裡面所有的業務

因為以前的瀏覽器功能較為單純,所一一個 process 的設計其實就足夠應付需求的狀況。

隨著網頁技術演進,瀏覽器已經變成不只需要管畫面的呈現,他也需要管更多次要的功能,例如多頁面或分頁的狀況或是載入第三方 extension 等等。使用單一 process 的架構可能會使得某一個功能壞掉或是很慢的時候,會連帶影響到其他的功能,也因此衍生出了後來比較主流的多 process 瀏覽器架構。 就類似公司開始分職位,讓個個職位的人去負責專一的業務

所以在多 process 架構底下,當瀏覽器打開的時候,會產生一個 process 開始跑,啟動的 process 可以要求作業系統建立新的 process 來處理其他任務,process 之間有獨立的記憶體空間,可以透過 inter-process communication (IPC) 來交流。以 chrome 來說 他讓每個分頁都擁有獨自的 process, 如果一個 tab 遭受到資安方面的攻擊,也相對比較不會影響到其他的分頁,也就是安全性比較高。

Process modal in Electron


剛才提到在 Electron 當中也是採用 multi-process 的架構,在寫 Electron 的時候,開發人員會需要關注到的是 process modal 上面的三個部分, main process, renderer process 及 preload script

main process:

對應到的就是主 process,他是 Electron 的進入點,主要管三個部分:

  1. Window management:在 main process 會去由產生  BrowserWindow 的 instance   來建立 application window ,這個 window 就是 app 內容以外的視窗本身。可以自訂許多參數去產生這個視窗,例如寬高,表提列、工具列或是背景的顯示等等。
  2. Application lifecycle: Electron 也提供在 main process 去引入 app 這個 api ,讓你可以監聽不同 app 生命週期階段,然後寫一些比較客製化的行為
  3. Native APIs: 最後, 在 main process 還可以使用 Electron 提供的 api 去存取作業系統的功能,例如 menus, dialogs, and tray icons

Renderer process:

顧名思義就是管網頁內容的部分,可以有好多個,每一個都是 Chromium 的實體,在建立應用程式視窗的時候可以以 html 為進入點來去將網頁內容本身載入到視窗,

如果需要在 main process 及 renderer process 之間溝通,Electron 也提供了一些 api 讓我們可去作 IPC,但因為安全考量,在比較後來版的的 Electron 以經預設禁止我們在 renderer process 取用 node.js 或 Electron module,因為操作通常是涉及一些權限很大的 api 的取用與操作,例如控制或存取你硬體的資訊。

preload script:

跟網頁內容會在不同的 context 中運行,也可以去存取 node.js 跟 Electron 提供的存取系統相關的 api ,這兩個特性下使得他可以作為 IPC 的媒介。

在創建視窗的時候可以設定 preload script 的路徑,他就會在 load 網頁內容之前先執行這個 script

React useEffect hook

· 閱讀時間約 7 分鐘

useEffect 是 React 提供的一個 hook,讓我們可以在每次 render 、畫面更新之後,都去執行 Effects 的邏輯。

Effects: react-specific side effect,一段用來跟 Component 外在系統同步的邏輯

何時需要 Effect?

通常一個 component 會具備以下兩種邏輯:

  1. Render 畫面用的:接收 props, state 計算出 JSX、也就是跟畫面渲染直接相關的邏輯。
  2. handle event 用的:綁定在 JSX 上面的,由事件驅動的邏輯。跟畫面渲染無直接相關。

當這些還不夠完成我們需要的功能時,例如說我們想要在 ChatReoom component 出現在畫面上時,去跟後端建立連線,這是跟畫面渲染無直接相關也不是事件驅動的 side effect,這些就可以用 useEffect 來執行。所以,那些因為 render 本身帶來的 side effect 就適合用 useEffect 來操作。

使用 useEffect

  1. 定義 Effect 邏輯
  2. 定義 dependency,即我們希望在哪些狀態變動的情況下重新執行 Effect,空的話代表只會在 component mount 執行一次 Effect 而已。
  3. 如果有需要可以 return 我們希望在下次 dependency 更新驅動 Effect 執行之前,要先執行的一段 function(cleanup function)
useEffect(() => {
// 1. Effects
return () => {} // 3. cleanup function
}, [deps]) // 2. dependency

useEffect cleanup function

實務開發中,有一種常見的情形會使用到 useEffect cleanup function,就是當我們利用 useEffect 綁定 event handler 時,如下:

const component1 = () => {
useEffect(() => {
const handleScroll = (e) => {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [])
}

在 component mount 時我們綁定了一個 scroll event handler handleScroll,因此,在這個 component 離開畫面之後,我們也應該要移除這個 handleScroll 的綁定。

這是因為如果不移除,會導致元件的實例仍然保留在記憶體中,無法被垃圾回收。這可能導致記憶體洩漏,使 React app 佔用更多記憶體。而另一方面,畫面上已經沒有此 component,但這個 event handler 持續在作用,這可能會導致預料之外的行為。

也許不需要 useEffect 的場景

  1. 拿來計算在 render 就可以計算出來的值:在狀態或參數更新後去計算另一個狀態

    function Form() {
    const [firstName, setFirstName] = useState('Taylor')
    const [lastName, setLastName] = useState('Swift')

    // 🔴 Avoid: redundant state and unnecessary Effect
    const [fullName, setFullName] = useState('')
    useEffect(() => {
    setFullName(firstName + ' ' + lastName)
    }, [firstName, lastName])

    // good: 直接計算得到就可以了,re-render 到最後本來就會拿到 firstName, lastName 最新的值
    const fullName = firstName + ' ' + lastName
    }

    如果是比較昂貴的計算,且不需要在每次 render 完都重新計算結果,就可以用 useMemo cache 住計算結果,並在 dependency 去傳入這個計算依賴的狀態,代表我們只需要在這些依賴狀態有變化的時候才要重新計算,其他時候就用舊的值即可。

    import { useMemo, useState } from 'react'

    function TodoList({ todos, filter }) {
    const [newTodo, setNewTodo] = useState('')
    // ✅ Does not re-run getFilteredTodos() unless todos or filter change
    const visibleTodos = useMemo(
    () => getFilteredTodos(todos, filter),
    [todos, filter]
    )
    // ...
    }
  2. 在狀態或參數改變之後重置 component 的狀態

    如果想在傳入的 userId 改變之後就把 comment 重置,可能會寫 useEffect

    export default function ProfilePage({ userId }) {
    const [comment, setComment] = useState('')

    // 🔴 Avoid: Resetting state on prop change in an Effect
    useEffect(() => {
    setComment('')
    }, [userId])
    // ...
    }

    但其實可以利用 react 提供的 key 屬性,把 userId 傳入。一般來說在同樣的位置 component 會被當成是同一個,內部狀態也會被保留,但藉由這個不同的 key React 就知道這跟之前的 component 是不同的,不會讓他們共用 state。(React 會重畫 DOM,並把元件與子元件都重置)

    export default function ProfilePage({ userId }) {
    return <Profile userId={userId} key={userId} />
    }

    function Profile({ userId }) {
    // ✅ This and any other state below will reset on key change automatically
    const [comment, setComment] = useState('')
    // ...
    }
  3. 拿來處理應該是事件驅動的計算

    如果 Effect 是跟事件相關的,可以直接寫在 event handler,這樣可以更簡潔,也少一層 useEffect 理解的成本。

    例如:我要在東西放進購物車時去跳提醒

    function ProductPage({ product, addToCart }) {
    // 🔴 Avoid: Event-specific logic inside an Effect
    useEffect(() => {
    if (product.isInCart) {
    showNotification(`Added ${product.name} to the shopping cart!`)
    }
    }, [product])

    function handleBuyClick() {
    addToCart(product)
    }

    function handleCheckoutClick() {
    addToCart(product)
    navigateTo('/checkout')
    }
    // ...
    }

    直接在 event handler 寫跳提醒的邏輯,因為這時候你其實已經知道東西被加進購物車了。如果是上面寫在 useEffect 程式再更複雜一點,又沒有寫註解的話,其他改到這支程式的人可能會需要花多一點時間去理解這段在做什麼。

    function ProductPage({ product, addToCart }) {
    // ✅ Good: Event-specific logic is called from event handlers
    function buyProduct() {
    addToCart(product)
    showNotification(`Added ${product.name} to the shopping cart!`)
    }

    function handleBuyClick() {
    buyProduct()
    }

    function handleCheckoutClick() {
    buyProduct()
    navigateTo('/checkout')
    }
    // ...
    }
  4. useEffect 進行連鎖計算

    這可能會導致 component 被 re-render 好幾次,造成不必要的效能支出。如同下方例子:

    setCard → render → setGoldCardCount → render → setRound → render → setIsGameOver → render

    function Game() {
    const [card, setCard] = useState(null);
    const [goldCardCount, setGoldCardCount] = useState(0);
    const [round, setRound] = useState(1);
    const [isGameOver, setIsGameOver] = useState(false);

    // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
    useEffect(() => {
    if (card !== null && card.gold) {
    setGoldCardCount(c => c + 1);
    }
    }, [card]);

    useEffect(() => {
    if (goldCardCount > 3) {
    setRound(r => r + 1)
    setGoldCardCount(0);
    }
    }, [goldCardCount]);

    useEffect(() => {
    if (round > 5) {
    setIsGameOver(true);
    }
    }, [round]);

    useEffect(() => {
    alert('Good game!');
    }, [isGameOver]);

    function handlePlaceCard(nextCard) {
    if (isGameOver) {
    throw Error('Game already ended.');
    } else {
    setCard(nextCard);
    }
    }

    // ...

    改善的作法是可以將判斷的邏輯都放到一個 function 裡面,或是直接在 render 計算。而且因為 batching 特性,這些 setState 會一 次去更新,也減少了不必要的 re-render

    function Game() {
    const [card, setCard] = useState(null)
    const [goldCardCount, setGoldCardCount] = useState(0)
    const [round, setRound] = useState(1)

    // ✅ Calculate what you can during rendering
    const isGameOver = round > 5

    function handlePlaceCard(nextCard) {
    if (isGameOver) {
    throw Error('Game already ended.')
    }

    // ✅ Calculate all the next state in the event handler
    setCard(nextCard)
    if (nextCard.gold) {
    if (goldCardCount <= 3) {
    setGoldCardCount(goldCardCount + 1)
    } else {
    setGoldCardCount(0)
    setRound(round + 1)
    if (round === 5) {
    alert('Good game!')
    }
    }
    }
    }
    }

    // ...

注意事項

  • useEffect 不是專們用來模擬 class component 生命週期的 hook,因為他會在每次 dependency 內容更新後都被執行,意義上比較像是讓 component 去跟外在環境同步,而不是讓我們在 component 生命週期某階段(例如 mount)作某些事情。

  • React 18 為了模擬 component unmount 後再次 mount 的行為,會 mount component 兩次。這是為了確保 component 夠穩健,兩次都會得到相同結果、component 不會壞掉。(如果壞掉的話,也許要檢查是不是需要使用 cleanup function,或者其他地方有 bug。) 所以使用 strict-mode 且處在開發環境時,useEffect 的 Effect 也會被執行兩次,如果真的不需要可以用 useRef 判斷是否跑過一次。

    const component1 = () => {
    const isFirstRender = React.useRef(true)

    React.useEffect(() => {
    if (isFirstRender.current) {
    isFirstRender.current = false
    return
    }
    // do whatever you want...
    }, [])
    return ...
    }

useLayoutEffect 與 useEffect 的差異

他跟 useEffect 長得很像,只差在 Effect 執行時間點。

useEffect 會在畫面更新之後執行,而 useLayoutEffect 會在畫面更新之前執行。

Reference

React docs

React State Update

· 閱讀時間約 3 分鐘

此篇主要探討 React 的狀態(state)更新。 包括 React 如何知道 state 是否改變、不同資料型態的 state 更新方式及需要注意的細節。

什麼是 state ?

在 React 中,state 是 component 內部管理的狀態,會隨著 component 生命週期演進而改變。

為什麼需要更新 state ?

在 React 中,state 的更新會觸發 component 的 re-render,來達成畫面的更新。

React 如何知道 state 是否改變?

在討論如何以正確得方式更新不同資料型態的 state 前,我們要先知道,React 透過 Object.is 來判斷 state 是否改變,表示他是依據記憶體位置是否改變來判斷,記憶體位置若相同,則不會觸發 re-render。這麼做的原因是為了追求更好的效能,而我們只要開發者能確保永遠以 immutable 方式去更新 state,React 就可以幫我們免不必要的 re-render。

當我們說使用 immutable 方式更新 state 時,指的是,將所有資料視為 immutable 資料去更新。

不同資料型態的 state 更新方式

React 官方建議我們應該永遠都應該使用 immutable 方式去更新 state,因此在非 primitive type 的 state 更新上,我們要特別注意。 JS 中,變數又分為 primitive type 和 reference type,前者是 immutable,後者是 mutable。以下分別探討兩者的更新方式。

Primitive type 的更新

因為 primitive type 本身是 immutable,可直接用傳入新值的方式告知 React state 改變,即可正確觸發 re-render。

const [count, setCount] = useState(0)

setState(1)

Reference type 的更新

Reference type 的資料,如:Object, Array,是 mutable 的,代表即使值被改變,其記憶體位置可能不變,React 將無法辨識變化,造成不會 re-render。因此需確保傳入 setState 的新 state 是一份 deep copy

1. Object 的更新

  • 非 nested object

    • Object.assign
    const originalObj = { name: 'lix' }
    const clonedObj = Object.assign({}, originalObj)
    console.log(clonedObj === originalObj) //false
    • Spread operator ...
    const originalObj = { name: 'lix' }
    const clonedObj = { ...originalObj }
    console.log(clonedObj === originalObj) //false
  • nested object

    • 利用 recursive 自己實作一個 deep copy function

    • Json.stringify / Json.parse

      • 轉換時可能需要注意屬性出現非預期結果
    • Lodash 這樣的套件提供的現成的 cloneDeep 方法

      • 缺點:僅僅這個 function 就要多 17 kb 左右
    • immer 有 useImmer 讓我們可以使用 mutable 語法撰寫 immutable 程式碼

    • built-in 方法 structuredClone

      • 優點:多種瀏覽器、node.js、bun 都支援,包括 nested object and array 都可以安心使用
      • 缺點:仍有一些不支援的資料類型要注意,如: function, DOM node 等等

2. Array 的更新

使用會回傳新的 Array 的內建 function,如:map, filter, slice, concat。 避免使用會修改原 Array 的內建 function,如:push, pop, shift, unshift, splice。

  • 錯誤作法: 直接 mutate 原 Array,即使使用 setState,但記憶體位置不變,React 無法得知內容物是否變化,所以不會有 re-render。

    const [amount, setAmount] = useState([1, 2, 3])
    setAmount(amount.push(4))
  • 正確作法: 使用會回傳新的 Array 的內建 function,搭配 setState 註冊狀態更新,正確觸發 re-render。

    const [amount, setAmount] = useState([1, 2, 3])
    setAmount(amount.map(i => i * 2))
    setAmount(prev => prev.map(i => i * 2))

以 immutable 方式更新 state 的其他優點

避免副作用: 使用 mutable 方式更新可能導致 side effect,代表修改資料時可能會影響到其他部分的程式碼,導致錯誤或難以預測的行為。

Reference

React docs: Updating Objects in State 為什麼 React 中的 state 必須是 immutable?

React Virtual DOM and Reconciliation

· 閱讀時間約 4 分鐘

React 利用 Virtual DOM 與 Reconciliation 來優化 DOM 的更新,這篇文章會介紹 Virtual DOM 與 Reconciliation 的原理與實作。 了解 Virtual DOM 與 Reconciliation 是什麼之前,要先了解 react element 與 react component 的概念。

React element


當我們寫一個 React component 的時候,React 在背後會把 JSX 會被轉成 React.createElement(...) function call ,這個 function 會 return 一個 plain object 形式的資料。這也就是為何每次建立 component 都一定要 import React。

例如:現在有個 App component

const App = (props) => {
const { name } = props
return <div id={name}>hi</div>
}

實際上會轉換成以下

const App = (props) => {
const { name } = props
return React.createElement(
'div',
{
id: name,
},
'hi'
)
}

如果呼叫的話 console.log(App()) 會得到這樣的 object,這個 object 就是 react element。

img

React.createElement

  1. 第一個參數是 element 類型
  2. 第二是 function component 的參數
  3. 第三是 children

React component


他是一個會 return element tree 的 class (class component) or function (function component)。

因此如果依照上面的例子,這 App 就是一個 react (function) component。

如果寫 <App/> react 會在背後幫我們...

  • function component: 傳入參數呼叫 function component
  • class component: 建立 instance, 呼叫 render function

Virtual DOM


由上面可知,React 運作時會將 nested JSX 轉換成 object 形式的 react elements tree,也稱之為 virtual DOM。

Reconciliation


初始時,這個 virtual DOM 直接跟真實的 DOM 同步就好。但當有任何 react elements 改變了呢?

React 不會直接去修改真實 DOM,因為修改 DOM 的效能成本是很大的,他會透過新舊 virtual DOM 之間的差異來判斷是不是要真的有需要改變,才 commit 到真正的 DOM,這個比對同步的過程就稱為 Reconciliation。

實際上 React 使用了 diffing algorithm 來優化這整個同步的過程。為了找出最少步數的操作去同步, diffing algorithm 有兩項假設:

  1. 不同類型的 element 會產生完全不同的 virtual DOM

    • 若不同類型(例如,同個位置從  <h1> 換成了 <h2><Component1/> 換成了 <Component2/>): React 會把 virtual DOM 重產

    • 若相同: 例如只是更新的參數,那就會只更新那個參數,不重產

  2. 使用者應該要在經常動態變動的 elements list 中給定 key,且這個 key 要是不可重複的值

    例如以下範例,我們必須給定每個 li 一個 key 值。通常如果這個資料你可以確定他之後都不會變動,直接用 index即可。

    const renderListData = () => {
    const data = ['one', 'two']
    return (
    <ul>
    {data.map((item, index) => {
    return <li key={index}>{item}</li>
    })}
    </ul>
    )
    }

    但更多時候,這個 data 可能會被動態改變,例如可能會在 'one' 之前被加上一個 'zero' ,那麼 key 的變化會是如下:

    item 'one' / key 0
    item 'two' / key 1

    == insert 'zero' =>

    item 'zero' / key 0
    item 'one' / key 1
    item 'two' / key 2

    可以看到,原本的 element 的 key 都跟原本不一樣了,但他們都還在畫面上,順序也沒有變,應該不需重新 render。 如果這種情況,我們不要用 index 作為 key,改定義一個固定的唯一值給每個 element,這樣 react 在 reconciliation 時就會知道 onetwo 是沒有改變的,不用重新 render,只要插入 zero 即可。

    const renderListData = () => {
    const data = ['one', 'two']
    return (
    <ul>
    {data.map((item) => {
    // 假設 item 不會重複,改用 item 作為 key
    return <li key={item}>{item}</li>
    })}
    </ul>
    )
    }

Reference


How Does React Actually Work? React.js Deep Dive #1

Preserving and Resetting State

React useState hook

· 閱讀時間約 3 分鐘

React function component 是 JS function


因為 function component 其實就是 function ,他的 execution context 執行完之後就會離開 call stack 被清除。這就是為何, react function component 的一個狀態,如果用一般的變數宣告,在 render 之間是無法保存的。

useState hook


有上述前提後,就可以理解為何我們需要,React useState hook 了:因為我們希望在 render 之間保持狀態。

(如果是希望在 render 之間保持一個跟渲染畫面無關的狀態,可以是用另一個 hook: useRef

function component 當中定義的 react useState 與一般 function 的變數不同,並不會隨著 return 而消失,而是被 React 紀錄起來了,像是放在 React 為你準備的倉庫上的架子。

setState 詳細觸發畫面更新流程

  1. 透過 setState 告訴 react 去改變 state 值
  2. react 去架子上把 state 的值改了
  3. react 再次呼叫 component(這個行為稱之為 render)
  4. 基於新的 state, props, 等等,去計算得到下一個畫面的 JSX (snapshot)
  5. 回傳新的畫面

所以在執行到 setState 的當下其實只是向 React 註冊了這個 state 需要被改變的請求,而 react 收到後,才會觸發 rerender 拿新的 state 計算下一個畫面,並不是直接就去更新畫面。這也就是為何 setState 是非同步的原因,因為更新後的值在下一個 render 才會拿到。

範例:結果會是 count 1,因為第一個 setter 執行完之後不會馬上把 count 變成 1,執行到下面那段時,count 還是 0

const [count, setCount] = React.useState(0)

const handleAdd = () => {
setCount(1)
setCount(count + 1) // 這時候 count 實際上還是 0
}

setState 兩種形式


直接取代值的形式

const [count, setCount] = React.useState(0)

const handleAdd = () => {
setCount(1)
// or
setCount(count + 1)
}

傳 callback 形式

這樣代表告訴 react 要用前一次的 state 來更新 state。react 會把這樣的更新放進 queue,依次執行。

因此如果需要連續使用同樣的 setState(如下範例),可以改傳 callback 形式。

const [count, setCount] = React.useState(0)

const handleAdd = () => {
setCount(1) // 等同 setCount((count) => count + 1), count 為 0
setCount((count) => count + 1) // count 為 1
}

setState batching


React 會在所有 setState 請求結束後才更新畫面,這現象叫做 batching

這是在 react 18 優化的,之前的版本如果 event handler 裡面 set 不同 state,他會 render 不只一次

react 有這樣非同步更新 state 的原因是:

  1. 不希望產生過多 re-render,這可以讓 react app 跑得更快
  2. 確保可以在所有 event handler 都執行完才更新的話,就可以確保不會拿到更新到一半的值

Reference


React 官方教學文件

前端程式非同步流程控制

· 閱讀時間約 3 分鐘

非同步問題


網頁的世界,因為涉及網路連線、檔案讀取、排程、資料庫連線(也是使用到網路連線),執行網頁程式就會需要處理非同步問題。

const delayedAdd = (n1, n2, delayTime) => {
window.setTimeout(() => {
return n1 + n2
}, delayTime)
}

const test = () => {
const result = delayedAdd(3, 4, 2000)
console.log(result)
}

在這個例子當中,因為程式不可能因為非同步的程式碼而阻塞後面的程式執行,非同步的程式會在所有同步程式碼都執行完畢後,最少等待指定時間才會被執行。

所以在這地方 console.log(result) 會拿到 還沒等待時的值,也就是 undefined

進幾年解決的方案: 回呼函式 Callbacks、Promises 物件、Async/Await 非同步流程控制

1. Callback function

一個純函式

const delayedAdd = (n1, n2, delayTime, callback) => {
window.setTimeout(() => {
callback(n1 + n2)
}, delayTime)
}

const test = () => {
delayedAdd(3, 4, 2000, (result) => {
console.log(result)
})
}

2. Promise

官方提供方式,可以解決 callback function 的作法會產生 callback hell 閱讀與理解上的問題。

const delayedAdd = (n1, n2, delayTime) => {
const p = new Promise(() => {
window.setTimeout(() => {
resolve(n1 + n2)
}, delayTime)
})
return p
}

const test = () => {
const promise = delayedAdd(3, 4, 2000)
promise.then((result) => console.log(result))
}
  1. 第一步是建立 Promise 物件
  2. 傳入的 function ,瀏覽器或 node,會塞給你兩個參數,分別是 resolve, reject
  3. 工作完成後(在這裡子當中就是等兩秒這件事),可以呼叫 resolve 把結果透過參數傳遞進去
  4. 透過 .then 可以讓我們從 promise 裡面拿到 resolve 的值。如果是在非同步執行完後呼叫 reject,則是對應到 .catch 最常見的就是錯誤處理

Promise.all 等待所有 Promise 都結束後返回結果的 array

const test = () => {
const promise1 = delayedAdd(3, 4, 2000);
const promise2 = delayedAdd(1, 2, 3000);

Promise.all([promise1, promise2]).then((results) => {
const answer = results.reduce((total, value) => return total * value);
console.log(answer);
})
};

3. Promise 搭配以 async-await 簡化

const test = async () => {
const result1 = await delayedAdd(3, 4, 2000)
const result2 = await delayedAdd(1, 2, 2000)
const answer = result1 * result2
console.log(answer)
}
提示

Async/Await: 與 Promise 搭配使用的語法糖。進一步提升了 Promise 寫法的簡潔性,讓非同步程式碼看起來像是同步執行,但需要注意 async function 就如同他的名字一樣,會等待 await 後面的 Promise 結束後才會執行下面的程式碼,有時候不適合使用。

  • 使用 await 必需將外層 function 加上 async
  • async function 人如其名,遇到 await 會進行等待,以下面的例子來說,就會等待兩秒才印出 Hello
const delayedAdd = (n1, n2, delayTime) => {
return new Promise((resolve, reject) => {
window.setTimeout(() => {
resolve(n1 + n2)
}, delayTime)
})
}

const test = async () => {
const result = await delayedAdd(3, 4, 2000)
console.log('Hello')
console.log(result)
}

Reference


回呼函式 Callbacks、Promises 物件、Async/Await 非同步流程控制 - 彭彭直播 at 2019/04/07

Promise

· 閱讀時間約 2 分鐘

Promise 是個建構函式,讓我們可以定義一段非同步行為,在成功或失敗情況下要執行的程式碼,並提供一個易於閱讀、使用及維護的介面來處理前端的非同步行為。

關於為何出現 Promise 及我們為何需要他,可以參考 前端程式非同步流程控制

常見操作


  1. new Promise() 得到實體物件後,可以調用方法

    const p = new Promise()

    p.then() // 用於操作成功的情況,resolve (fulfilled) 會調用
    p.catch() // 用於操作失敗的情況,reject 會調用
    p.finally() // 無論狀態是成功或失敗都會執行當中的 callback
  2. new Promise 時通常會傳入一個 callback,代兩個參數,resolve 及 reject

    new Promise((resolve, reject) => {
    // 在這的東西會立刻被執行
    resolve() // 設定為 resole (fulfilled)
    reject() // 設定為 reject
    })
    • new Promise 裡面的 callback 是會立刻執行的,而 resolve 則是在 .then() 呼叫到才會回傳值,.catch() 則是去拿 reject 的值
  3. Promise 狀態有三種,一定會處在其一。依據調用 resolve or reject 來決定變為 Fulfilled or Rejected 。如果都沒有調用,就會是 pending

  4. .then() 可以串聯使用,第二個 .then() 開始會拿到上一個 .then() callback 的回傳值

  5. .catch 通常會放在最後,當任何 .then() 出錯都會直接跳到最後,不會再繼續執行後續的 .then()

Practical use case


在後端 API 還沒有產出之前,但 schema 已經有的狀態下。就可以利用 Promise 來模擬 API,因為比起你在前端寫死 Response 結構,使用 promise ,不只能模擬回傳的結構,也能保留非同步特性。

export const getData = (data, successRate = 0.98, maxLatencyMs = 1000) => {
const mockResponse = {
status: 200,
error: null,
message: null,
object: {
id: 1,
name: 'BbokAri',
type: 'chick',
},
errorDetail: null,
data,
}

return new Promise((resolve, reject) => {
const successRoll = Math.random()

const latency = Math.floor(Math.random() * (maxLatencyMs + 1))
// [0, maxLatencyMs]

if (successRoll <= successRate) {
setTimeout(() => resolve(mockResponse), latency)
} else {
setTimeout(() => reject('API failed to return data'), latency)
}
})
}

以上為一個範例,我們可以依照後端 schema 事先產生 mockResponse,同時模擬一個每次都在不同時間內回傳及有一定失敗機率的 API call。

Reference


JavaScript Promises: Practical Use Cases and Examples