跳到內容
關於我 數位花園

JavaScript

12 篇文章擁有標籤:“JavaScript”

使用 NPM 腳本建立 Hugo 文章

  • 基礎知識:NPM, Hugo, JavaScript, shell script
  • 預先安裝 VS Code, NPM CLI, Hugo CLI

使用 Hugo CLI 建立文章對我而言是一項繁瑣的工作。因為我總是使用文章樣板(archetype)建立文章,並將其放置在巢狀階層的資料匣中。例如,在建立本篇文章時,必須在終端機中輸入以下指令:

Terminal window
hugo new --kind develop posts/_developer/create-hugo-post-with-npm-script

問題在於,我總是忘記擁有多少種文章樣本,以及資料匣結構目前長什麼樣子。資料匣結構可能是動態的,可能非常頻繁地調整。此外,我非常喜歡 VSCode 在側邊選單「Explorer」中提供的 NPM SCRIPTS 功能,如下所示的截圖:

npm script in side menu

這個功能,我個人稱之為「點擊執行腳本」,若使用者無法記住或忘記腳本怎麼寫,此時就非常方便。但據我所知,它似乎只支援 Node 套件管理,也就是 NPM。為了將「點擊執行腳本」功能與 Hugo CLI 結合使用,需要使用 NPM 作為中間件(middleware),縱然 Hugo 靜態網頁在任何時候都不需要 NPM 或任何 Node 套件。讓我們開始吧。

首先使用 npm init 初始化 NPM。

然後,在將此腳本添加到您的 package.json 後,讓我們嘗試透過 NPM 啟動 Hugo 本地伺服器:

package.json
"scripts": {
"dev": "hugo serve -D",
}

在終端機中,輸入 npm run dev,或者只需在側邊選單點擊 NPM 腳本來執行:

npm run dev

成功執行 Hugo CLI!✨

因此,NPM 腳本完美地呼叫了 Hugo CLI。然後,讓我們嘗試實現最終目標:建立一篇文章。

首先,我們需要安裝兩個套件:

  1. @inquirer/prompts:利用 JavaScript 腳本在終端機裡,打造容易操作的介面
  2. inquirer-directory:在終端機裡也能夠輕鬆操作資料匣路徑

接下來,在根目錄中建立了一個 JavaScript 檔案 createPost.js,以下是程式碼:

"use strict";
const inquirer = require("inquirer");
const { input, select, Separator, confirm } = require("@inquirer/prompts");
const { execSync } = require("child_process");
const inquirerDirectory = require("inquirer-directory");
const BASE_PATH = "./content";
inquirer.registerPrompt("directory", inquirerDirectory);
const exec = (commands) => {
execSync(commands, { stdio: "inherit", shell: true });
};
/**
* Create post script
*
* @see https://github.com/SBoudrias/Inquirer.js
* @see https://github.com/nicksrandall/inquirer-directory
*/
(async function () {
const archeType = await select({
message: "Select a archetype",
choices: [
{
name: "Basic",
value: "basic",
description: "Basic post",
},
{
name: "Dev",
value: "dev",
description: "Post for developer.",
},
new Separator(),
{
name: "Garden",
value: "garden",
description: "Note for digital garden.",
},
],
});
const title = await input({ message: "Enter your post title" });
const directory = await inquirer.prompt({
type: "directory",
name: "path",
message: "Please choose post directory.",
basePath: BASE_PATH,
});
const answer = await confirm({ message: "Confirm create the post?", default: false });
if (answer) {
exec(`hugo new --kind ${archeType} ${directory.path}/${title}`);
exec(`open ${BASE_PATH}/${directory.path}/${title}/index.md`);
}
})();

在腳本中,我提供了 3 個問題,以及一些操作:

  1. 選擇一個文章樣板。
  2. 輸入文章標題。
  3. 選擇一個目錄。
  4. 確認建立。
  5. 執行 Hugo 文章建立腳本。
  6. 最後,打開我們建立的文件。

完成精心打造的腳本後,然後將其加入到我們的 package.json 中:

package.json
"scripts": {
"create": "node createPost.js"
}

執行 npm run create,以下是執行結果:

npm run create

以上,開發愉快。

[筆記] 以狀態機實作多階段表單

基礎知識: VueTypeScriptXStatevee-validatezod

時常看到「多階段」的表單流程,也就是會將一個本來很長的表單拆分成好幾個部分來填寫,相較冗長的一整頁表單,使用者在填寫時比較沒有心理上的負擔。

跟表單相關的開源套件很多,而我選用以下幾種來處理跟表單相關的任務:

  • 表單狀態管理:這裡因為範例使用 Vue,所以以 vee-validate 管理表單所有 inputselect 等元件的渲染狀態
  • 表單驗證:表單驗證的部分,搭配 vee-validate 官方推薦的 zod 來處理驗證邏輯

以上是表單的部分,再來的重點是,要如何設計這個「多階段」的架構?

若將所階段裡面的欄位都合在一個表單,如下圖:

chart 01

在切換階段的時候決定顯示哪些欄位,然而在切換下一階段時,又要只驗證當下的那幾個欄位,這樣會造成複雜的驗證邏輯。

於是我想到將每個階段獨立成自己的表單,所以所有驗證都不再是局部驗證,而是對表單的所有欄位(例如階段一表單的所有欄位)來做驗證:

chart 02

由於拆分成多個表單,簡化了表單的驗證邏輯,不用再做多餘的判斷(局部欄位驗證)了。每個表單元件的職責,如下:

  1. 欄位狀態管理
  2. 所有欄位驗證

將上述任務委派給表單元件處理之後,剩下待處理的邏輯是:

  1. 顯示現在是何種階段表單元件的狀態
  2. 送出表單資料,執行非同步請求

由於「第一階段」的下一步只能到「第二階段」,而不行到「第三階段」或是「確認階段(Step Confirm)」,因此我想到利用有限狀態機來解決上述的兩個問題,而嘗試使用以實作有限狀態機很有名的 XState 套件來處理這些任務。

因此,職責分配圖如下:

chart 03

前一段大致描述了初步的想法,這裡整理一下職責分配的規劃,分為兩個部分:

  1. 階段流程控制(要顯示哪一階段的表單)
  2. 儲存所有表單資料
  3. 送出資料,執行非同步請求
  4. 非同步請求的等待狀態(loading)、錯誤狀態

以上皆由 XState 實作

  1. 表單欄位狀態管理(由 vee-validate 實作)
  2. 表單欄位驗證(由 zod 實作)
  3. 表單送出(submit)事件與表單資料(由 vee-validate 實作)

表單元件,以第一階段 Form1.vue 舉例,大致如下:

<template>
<div>
<h2 class="formTitle">Choose channels you like</h2>
<form class="form" @submit="onSubmit">
<div>
<input type="checkbox" id="discovery" :value="1" v-model="channels" />
<label class="label" for="discovery">Discovery</label>
</div>
{/* other inputs... */}
<div v-if="errors.channels" class="error">{{ errors.channels }}</div>
<div class="buttonGroup">
<button class="button" type="submit">next step</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import type { Form1Model } from "@/types";
import { toTypedSchema } from "@vee-validate/zod";
import { useForm, useField } from "vee-validate";
import z from "zod";
interface Props {
initialValues: Form1Model;
}
interface Emits {
(event: "next", values: Form1Model): void;
}
const props = withDefaults(defineProps<Props>(), {
class: "",
});
const emits = defineEmits<Emits>();
const validationSchema = toTypedSchema(
z.object({
channels: z.number().array().nonempty("Please choose at least one channel."),
})
);
const { handleSubmit, errors, values } = useForm<Form1Model>({
initialValues: props.initialValues,
validationSchema,
});
const { value: channels } = useField<number[]>("channels");
const onSubmit = handleSubmit((values) => emits("next", values));
</script>

表單的欄位初始值(initial values)由狀態機提供,經由 props 傳進來;然後表單在送出的時候,再發事件出去讓狀態機處理,一個人只負責一件事,可謂「單一職責原則」的精神。

各階段的表單的顯示控制,在外層 MultiStepForm.vue 實作,導入狀態機並決定顯示邏輯。各表單會發出各種事件(下一步、上一步、送出等),然後交付給狀態機來執行。

<template>
<div :class="`h-full w-full ${props.class}`">
<h1 class="title">Multi Step Form Example</h1>
<div class="grid grid-cols-2 gap-x-6">
<div>
<h2 class="subTitle">Form Component</h2>
<div class="p-4 border border-slate-700 rounded-lg">
<Form1
v-if="state.matches('step1')"
@next="send('NEXT_TO_STEP_2', { formValues: $event })"
@prev="send('PREV')"
:initial-values="state.context.form1Values"
/>
<Form2
v-if="state.matches('step2')"
@next="send('NEXT_TO_STEP_3', { formValues: $event })"
@prev="send('PREV')"
:initial-values="state.context.form2Values"
/>
<Form3
v-if="state.matches('step3')"
@next="send('NEXT_TO_STEP_CONFIRM', { formValues: $event })"
@prev="send('PREV')"
:initial-values="state.context.form3Values"
/>
<FormConfirm
v-if="state.matches('stepConfirm')"
@prev="send('PREV')"
@submit="send('SUBMIT')"
:is-submitting="state.matches('stepConfirm.submitting')"
:error="state.context.error"
:machine-context="state.context"
:payload="state.context.payload"
/>
<FormComplete v-if="state.matches('complete')" @restart="send('RESTART')" />
</div>
</div>
<div>
<p class="subTitle">Current Machine Context</p>
<pre class="preBlock">{{ state.context }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMachine } from "@xstate/vue";
import Form1 from "@/components/Form1.vue";
import Form2 from "@/components/Form2.vue";
import Form3 from "@/components/Form3.vue";
import FormConfirm from "@/components/FormConfirm.vue";
import FormComplete from "@/components/FormComplete.vue";
import { multiStepFormMachine } from "@/multiStepFormMachine";
interface Props {
class?: string;
}
interface Emits {
(event: "click"): void;
}
const props = withDefaults(defineProps<Props>(), {
class: "",
});
const emits = defineEmits<Emits>();
const { state, send } = useMachine(multiStepFormMachine);
</script>

接著是狀態機,我將狀態機獨立成一個檔案 multiStepFormMachine.ts,以方便管理:

import { assign, createMachine } from "xstate";
import type { Form1Model, Form2Model, Form3Model, SubmitData } from "./types";
import { FORM_1_INITIAL_VALUES, FORM_2_INITIAL_VALUES, FORM_3_INITIAL_VALUES } from "./default";
import { sendFormData } from "./utils";
type MachineEvent =
| { type: "NEXT_TO_STEP_2"; formValues: Form1Model }
| { type: "NEXT_TO_STEP_3"; formValues: Form2Model }
| { type: "NEXT_TO_STEP_CONFIRM"; formValues: Form3Model }
| { type: "PREV" }
| { type: "SUBMIT" }
| { type: "RESTART" };
export type MachineContext = {
form1Values: Form1Model;
form2Values: Form2Model;
form3Values: Form3Model;
payload: SubmitData | null;
error: string | null;
};
const INITIAL_MACHINE_CONTEXT: MachineContext = {
form1Values: FORM_1_INITIAL_VALUES,
form2Values: FORM_2_INITIAL_VALUES,
form3Values: FORM_3_INITIAL_VALUES,
payload: null,
error: null,
};
type MachineState =
| { context: MachineContext; value: "step1" }
| { context: MachineContext; value: "step2" }
| { context: MachineContext; value: "step3" }
| { context: MachineContext; value: "stepConfirm" }
| { context: MachineContext; value: "stepConfirm.submitting" }
| { context: MachineContext; value: "complete" };
export const multiStepFormMachine = createMachine<MachineContext, MachineEvent, MachineState>(
{
id: "multiStepForm",
initial: "step1",
context: INITIAL_MACHINE_CONTEXT,
states: {
step1: {
on: {
NEXT_TO_STEP_2: {
target: "step2",
actions: assign({
form1Values: (context, event) => event.formValues,
}),
},
},
},
step2: {
on: {
NEXT_TO_STEP_3: {
target: "step3",
actions: assign({
form2Values: (context, event) => event.formValues,
}),
},
PREV: {
target: "step1",
},
},
},
step3: {
on: {
NEXT_TO_STEP_CONFIRM: {
target: "stepConfirm",
actions: assign({
form3Values: (context, event) => event.formValues,
}),
},
PREV: {
target: "step2",
},
},
},
stepConfirm: {
initial: "preSubmit",
states: {
preSubmit: {
entry: assign({
payload: (context, event) => ({
...context.form1Values,
...context.form2Values,
...context.form3Values,
}),
}),
on: {
SUBMIT: {
target: "submitting",
},
},
},
submitting: {
invoke: {
src: "formSubmit",
onDone: {
target: "#multiStepForm.complete",
actions: "resetContext",
},
onError: {
target: "errored",
actions: assign({
error: (context, event) => event.data.error,
}),
},
},
},
errored: {
on: {
SUBMIT: {
target: "submitting",
},
},
},
},
on: {
PREV: {
target: "step3",
},
},
},
complete: {
entry: "resetContext",
on: {
RESTART: {
target: "step1",
},
},
},
},
},
{
actions: {
resetContext: assign(INITIAL_MACHINE_CONTEXT),
},
services: {
formSubmit: async (context, event) => {
if (context.payload) {
return await sendFormData(context.payload);
} else {
return await new Promise((resolve, reject) => reject("Context cannot be null."));
}
},
},
}
);

狀態機的流程操作,可以參考這個可視化頁面:multi-step-form | Stately

以上是狀態機的程式碼,有點冗長還請見諒 🙏

主要職責是:

  1. Form1 會發出 NEXT_TO_STEP_2 事件進到 Form2,或 Form2 發出 PREV 事件回到 Form1,以此類推。
  2. FormConfirm 會發 SUBMIT 事件告訴狀態機要執行非同步請求,將表單的資料送出。
  3. 狀態機的 context 中,存放 Form1 及其他表單元件的欄位資料,以及最後要送出請求的酬載(payload),此外還有送出非同步請求的狀態(loading、error)

以下是最後實作出來的結果,左邊是表單元件,右邊則顯示目前狀態機 context 的狀態,能清楚知道何時會更新 context 的資料

畫面

所有程式碼請參考這裡

以上是多階段表單的一些構想,利用狀態機將表單的職責單一化,也清楚將邏輯切割並分派給各部分處理。

這是第一次真正自己認真寫狀態機,也還在摸索學習的階段,若有任何問題歡迎留言告訴我 😎

感謝收看 🙏

初探多執行緒實作模式

本篇是略讀「Multithreaded JavaScript」這本書第六章:「多執行緒實作模式」(Multithreaded Patterns),整理的一些筆記

本章節介紹了一些多執行緒常見的實作模式,有以下:

  1. 執行緒池(Thread Pool)
  2. 互斥鎖(Mutex)
  3. 環形緩衝(Ring Buffers)
  4. 演員模型(Actor Model)
  • 多執行緒應用程式很常使用的一個實作方式
  • 執行緒池是一個集合,裡面含有同質性(homogeneous)的 worker 執行緒,每個 worker 都可用來處理重負載、複雜運算的工作
  • Node.js 的 libuv 函式庫提供了執行緒池的功能,預設為四個執行緒,可以處理底層 I/O 的一些操作
  • 概念很類似分散式系統
  • 分為兩部分討論:執行緒池的大小(Pool Size)與委派策略(Dispatch Strategies)
  • 一般而言不會動態改變執行緒池的大小
  • 通常在作業系統中,處理器核心跟執行緒並沒有直接的關聯
  • 當執行緒的數量遠超過處理器核心數量時,效能反而會下降
  • 以 Node.js 的 libuv 函式庫為例,裡面包含三個執行緒,分別是:主執行緒、worker 執行緒、垃圾回收執行緒(Garbage Collection Thread)
Node.js
// browser
cores = navigator.hardwareConcurrency;
cores = require("os").cpus().length;
  • 別忘了還有主執行緒,所以是 n + 1
  • 依據不同的目的來決定執行緒的數量
    • 對於挖礦而言,99.9% 的工作在於 worker 上所執行繁重的運算,幾乎沒有 I/O,主執行緒也沒有特別的事情,因此可以開與核心數量相同的 worker 執行緒
    • 對串流影音或轉檔而言,則有大量的 CPU 運算及 I/O 操作,因此必須預留兩個核心給這兩個程序,剩下再分配給 worker 執行緒
  • 如果不確定要如何分配,那麼留一個核心給主執行緒是一個安全的作法

我們將高成本、繁重的運算收集整理起來,然後分配個 worker 執行緒去執行這些任務,這裡介紹三個常見的委派策略:

  • 按照順序指派工作,指派到最後一位,下一次就回到第一位 worker 身上
  • 可以確保每位 worker 都有事做
  • 可能造成每位 worker 的負擔不均
  • HAProxy 稱此委派策略為 roundrobin
  • 就如字面的意思,隨機選取一個 worker 來處理工作
  • 可能造成每位 worker 的負擔不均
  • 將新的任務指派給負載最低的 worker
  • 當有最低負載的 worker 有兩個的時候,隨機選取一個指派
  • HAProxy 稱此委派策略為 leastconn
  • Mutex 全名為 mutually exclusive lock.
  • 互斥鎖是一個存取共享資料的控制機制
  • 此機制確保共享資料在同一時間,只允許一項任務執行
  • 互斥鎖在有人存取共享資料的時候上鎖,並在結束後解鎖
  • 在上鎖及釋放鎖定之間,稱之為「臨界區段」(Critical Section)

串流資料與環形緩衝(Ring Buffers)

Section titled “串流資料與環形緩衝(Ring Buffers)”
  • 環形緩衝是「先進先出」(first-in-first-out, aka FIFO)佇列的典型實作,利用一組「配對指標」指向當下的記憶體位址
  • 當佇列的指標在陣列末位,下一步會移動到陣列首位,形成環形結構的概念
  • 在北美餐廳中常被使用的點餐圓盤(order wheel)則是類比世界中,同樣的概念實踐
  • head 指標:指向下一個寫入佇列的位置
  • tail 指標:指向下一個讀取佇列的位置
  • 佇列長度:我們想要建立多大的佇列,一個陣列長度,head 指標、tail 指標會在上面移動

借用一下書中的示意圖:

ring buffer

  • 當寫入佇列時,head 指標移動的下一個位置
  • 當讀取佇列時,tail 指標移動的下一個位置
  • 當指標位在佇列末位,下一次就會移到首位
  • 因為是環形,所以沒有頭尾之分,因此指標在哪個位置並不重要
  • tail 指標最多只會跟 head 指標在同一個位置,不能超過 head 指標
  • 緩衝區滿載的時候,若要再寫入佇列,則有兩種策略:
    • 覆蓋最舊的佇列:相較於未處理的舊資料,新資料比較重要
    • 拋出異常,且不寫入佇列:資料順序性很重要的時候
  • 必須正確讀出最舊佇列
  • 儲存在環形緩衝裡的每個元素不會做移動,只有加入與移除,適合實作「先進先出」(first-in-first-out, aka FIFO)佇列
  • 「非環形緩衝」若要實作 FIFO,則必須在處理完一個任務之後,移動佇列上所有的元素
  • 「非環形緩衝」較適合實作「後進先出」(last-in-first-out, aka LIFO)佇列
  • JavaScript 的堆疊(stack)就是 LIFO 的實作
  • 動態改變佇列的大小,意味著必須重新賦予記憶體,會影響效能。若要實作動態佇列,鍊表(linked list)則較為適合
  • 演員模型是實踐同步運算的一種程式設計模式
  • 一個 actor 代表一個執行程式碼的容器
  • 在 Erlang 程式語言中,actor 是一級公民,但在 JavaScript 中也可以模擬其實作
  • 每個 actor 皆具有三種功能:執行運算、建立新的 actor、actor 之間的相互傳遞訊息
  • 每個 actor 擁有自己的訊息佇列(message queue),以 FIFO 佇列順序處理每個任務
  • 因為 actor 都無法操作共享記憶體(shared memory),因此避免了多執行緒容易發生的情況:race condition 及 deadlock
  • actor 是單執行緒,一次執行一件事
  • 使用 actor 的系統必須能夠接受延遲或順序不一致的現象
  • 每個 actor 可以擁有一個位址,例如:tcp://127.0.0.1:1234/3 代表著在 1234 port 中,第三個 actor

借用一下書中的示意圖:

actor model

E2E 測試導向的開發流程

某天,我們從設計師那裡拿到了設計圖

於是我們開始切版,跟往常一樣,主要注重在畫面的樣式與互動上,此時並不會考慮到 E2E 測試

若開發期間,很幸運地拿到了 QA 的測試案例(或任何寫測試案例的人),我們是否可以在切版的當下,也將未來寫測試案例的情況也加入考慮?

此外,這裡提到的測試案例是指 E2E 測試(自動化測試),而非人工的整合測試

為了讓寫測試的過程能更順利,依據測試案例上的各種 action,例如:點擊某個 button、在 input 輸入文字,或是從某個 div 元素上獲取文字。在這些計畫做事的 DOM 元件上安插屬性(attribute)作為 querySelector 的查詢指標

若手上沒有測試案例又該如何呢?沒有測試案例,就無法準確知道哪些元素會被查詢,只能用推測的。此時腦中閃過一個想法:

如果制定一些規則,然後按照這個規則去添加屬性,是否就可以維持一定的覆蓋率?

因此我根據自己寫測試的經驗,嘗試制定了一些規則。我將會分成以下幾個部分說明:

  1. 元件取向
  2. 位置取向
  3. 狀態取向
  4. 無形資料取向
  5. 結構取向

以下所有程式,我會以 React 元件來解釋。實際上無論框架,最終都會回到 HTML 元素身上

插入 data-test-page 屬性於最外層的 HTML 元素:data-test-page='<頁面元件名稱>'

有了 data-test-page 屬性,我們可以很快地辨認出這個頁面元件的範圍(我更喜歡稱之為「邊界」)

註:屬性名稱全看個人喜好,這裡展示的是我自己覺得不錯的命名方式 🙃

例如我們有一個 home 元件:

function HomePage() {
return <div data-test-page="home">{/* ... */}</div>;
}

插入 data-test-comp 屬性於最外層的 HTML 元素:data-test-comp='<元件名稱>'

有了 data-test-comp 屬性,我們可以很快地辨認出這個基本元件的範圍

function Button() {
return <div data-test-comp="button">{/* ... */}</div>;
}

元件會在不同地方重複使用(這也是我們要抽成元件的初衷,DRY 原則),因此,data-test-comp 就會出現重複的情況,而且如果在相近的地方有多個重複的元件,就不太好快速辨識目標元件

比如說我們在一個頁面上,用了 5 個 button 元件,而測試案例的動作是:「選取其中一個 button,然後點擊它。」此時,我們必須先找出所有 button 的元素,然後在裡面尋找我們要做點擊的那個 button(比對 button 的文字;或是直接指定第幾個 button 元素 ⋯⋯ 等)

這個過程不困難,但我認為這個過程很煩,而且不斷重複在做這個查找的動作。於是我開始思考,是否有更精確、更快速的方式。如果有一個唯一值(或接近唯一的值,亦即很少重複),這個元素尋找的過程將會簡單許多

因此,像是這樣的情境,我們需要另一個表示功能的特徵1 屬性(這個特徵屬性最好是唯一值)

使用 data-test-feat 屬性,用來辨識這個元件在這裡所提供的功能(或目的)

我認為這個屬性比較偏向是非強制性的。倘若只有在元件上標示屬性,仍然可以拿到我們想要的元件。例如:在導覽列(navbar 元件)上的登入 button 及選單裡(menu 元件)的登入 button,分別包含在不同元件裡面。此外,過度使用可能會造成最後很多重複的屬性,唯一性的特徵消失了,精準打擊(aka 準確查詢)的初衷也會隨之化為泡影。因此,要在這之間取得平衡

所以,data-test-feat 屬性大概有兩種加入方式:

有時候,基於元件的樣式設計(有外層決定寬高、背景色 ⋯⋯ 等),我們會在元件的外面包一層容器元素

function HomePage() {
return (
<>
{/* ... */}
<div data-test-feat="confirmBtn">
<Button />
</div>
<div data-test-feat="cancelBtn">
<Button />
</div>
{/* ... */}
</>
);
}

上面的範例,可以很清楚地知道,在 home 頁面裡,有兩個 button:確認 button 及取消 button

如果在元件外面包一層容器元素不是你的菜,可以透過 props 的方式穿進去元件裡:

若沒有外層的容器元素,透過 props 傳進去是另一個選擇:

function HomePage() {
return (
<>
{/* ... */}
<Button dataTestFeat="confirmBtn" />
<Button dataTestFeat="cancelBtn" />
{/* ... */}
</>
);
}

在 button 元件裡:

function Button({ dataTestFeat, btnText, labelText }) {
return (
<div data-test-comp="button">
<label>
{labelText}
<button data-test-feat={dataTestFeat}>{btnText}</button>
</label>
</div>
);
}

有時候我們想知道某個元件的狀態,假如我們有一個 toggle button 元件:

toggle button

測試案例中,我們想要在點擊 toggle button 之後,確認它會確實從 on 狀態轉變成 off 的狀態

我們當然可以透過辨認 style 的變化得知它的狀態。如果是用 SASS 的話,或許可以很簡單地從 class 上辨認出(端看 class 怎麼設計了)。然而我最近比較常用的是 tailwindcss,此時就會變得不太直覺。以下是我的 toggle button 元件:

function ToggleButton() {
const [isOn, setIsOn] = useState(false);
const handleChange = () => setIsOn((prev) => !prev);
return (
<button
type="button"
className="rounded-full overflow-hidden relative w-16 h-7 shrink-0 text-white font-semibold text-sm uppercase ml-2.5 bg-neutral-500"
onClick={handleChange}
>
{/* here is what the difference by state */}
<div className={`absolute transition left-1.5 top-1/2 -translate-y-1/2 ${isOn && "translate-x-9"}`}>
<div className="rounded-full bg-white w-4 h-4 relative">
<div className="absolute right-full top-1/2 -translate-y-1/2 px-2 whitespace-nowrap">On</div>
<div className="absolute left-full top-1/2 -translate-y-1/2 px-2 whitespace-nowrap">Off</div>
</div>
</div>
</button>
);
}

這個元件裡,translate-x-9 是 on 與 off 狀態上差異的 class。我們仍然可以辨識,但會造成修改樣式時後容易抓不到元素的情況。Utility first 樣式庫的優點,在此時卻成了缺點

像這種情況,建議加入表示狀態的屬性:

function Button({ btnText, labelText }) {
const [isOn, setIsOn] = useState(false);
const handleChange = () => setIsOn((prev) => !prev);
return (
<div data-test-comp="button">
<label>
{labelText}
<button data-test-state={isOn} onClick={handleChange}>
{btnText}
</button>
</label>
</div>
);
}

另一個常見的情境是載入狀態(loading),假如有一個列表與搜尋列元件:

function SearchPage() {
// ...some state here
return (
<div data-test-page="searchPage">
<div data-test-comp="searchBar">
<input value={searchValue} data-test-feat="searchInput" />
<button onClick={handleSearch} data-test-feat="searchBtn">
Search
</button>
</div>
<div data-test-comp="list" data-test-loading={isLoading}>
{/* list data here */}
</div>
</div>
);
}

註:基於可讀性,我直接用 HTML 表示

點擊搜尋 button 之後,頁面就會開始 loading 打 API 拿資料,並在結束後,結束 loading 狀態,並更新資料至底下的列表

假如有一個 card 元件要顯示商品資訊,如在元素上存在 product id 的話,就能快速地找到目標商品。然而商品資訊卡片通常不會顯示 product id,此時就必須將這個值加到屬性當中:

function ProductCard({ productId, ...restProps }) {
return (
<div data-test-comp="productCard" data-test-product-id={productId}>
{/* product card content */}
</div>
);
}

至於命名原則,我認為只要夠語意化就行了

在一些特殊情境下,我們會需要用 div 元素去模擬其他元素

這裡舉個例子,用 div 元素去模擬 table 元素的結構。加入 data-test-el 屬性:data-test-el='<模擬的元素名稱>'

function Table() {
return (
<div data-test-el="table">
<div data-test-el="tbody">{/* ... */}</div>
</div>
);
}

或許會有人不太懂為何要這樣做,但我還是可以簡單說明一下

在開發的時候,遇到某些樣式在與 table 有關的元素上(也就是<table><thead><tbody>⋯⋯ 等),在 Safari 瀏覽器下的顯示會不如預期(Safari 的 bug ⋯⋯),因此必須用 div 元素重建整個 table 的結構,因此在加入屬性之後,可以很快地了解整個 table 的結構,藉此增加可讀性

誠如模擬元素,為了增加可讀性,我們也可以在其他地方加入屬性,可以更快地了解整個元件結構。例如一個 dialog 元件:

function Dialog() {
return (
<div data-test-comp="dialog">
<div data-test-el="dialogHeader">this is dialog header</div>
<div data-test-el="dialogBody">this is dialog body</div>
<div data-test-el="dialogFooter">this is dialog footer</div>
</div>
);
}

加入 data-test-el 屬性之後,可以有效率地一眼看出整個元件的結構(在這裡,dialog 分為 dialog header、dialog body、dialog footer 三個區塊)

開發時,加入屬性將會是一個漫長的過程。在開發的過程中,測試案例並非我們的優先考量。制定規則並遵守它,可以讓我們無腦地加入屬性,並將專注力放在實作樣式外觀、操作互動以及資料上。當開發結束後,撰寫測試案例將會更為順利,並感謝以前的自己

最後,說到底這些規則畢竟只是自己的想法,並非什麼業界標準那麼偉大的東西 😎

開發愉快

  1. Feature,我翻譯成「特徵」

行動裝置上 100vh 的奇怪行爲

最近遇到一個手機瀏覽器上,在某個特定排版會發生高度有誤的問題

進而發現手機瀏覽器的特殊行爲

查了一下資料,發現這個問題已經存在許久,也有不少的解法

大致分為用 JS 及 CSS 去解決

CSS 的解決方式比較簡單明瞭

想要直接看結果,可以走傳送門:JS 解決方案CSS 解決方案

這個頁面是滿版的,所以 container 的部分佔滿整個 document.body

container 裡面,由上而下包含三個區域:header、body、footer

header 與 footer 分別固定在 container 上緣與下緣,body 撐滿剩下的空間,如果内容超過就會有捲軸滾動

layout 的示意圖如下:

layout

外層的 container 寬高分別是 100vw、100vh,在電腦上看起來沒什麼問題,然而在手機上就不是那麼回事:

100vh-issue-1

可以看到 container 高度是 100vh,卻仍然可以 scroll 的現象,照理來說應該要剛好撐滿才是

備註:可以在手機上點開連結,看一下真實情況


因此,若將 container 裡面的東西加進來,就會造成內外都有 scroll 的情況:

100vh-issue-2


看來 CSS 所抓到的 100vh,跟實際上的 window 高度不一樣,window 比較小,所以造成了 scroll

於是,試著把 container 元素的高度與 window 的高度印出來

var containerElement = document.getElementById("container");
var containerHeight = containerElement.clientHeight;
var heightElement = document.getElementById("container-height");
heightElement.innerText = "container clientHeight: " + containerHeight + "px";
var windowHeightEl = document.getElementById("window-height");
windowHeightEl.innerHTML = "window innerHeight: " + window.innerHeight + "px";

在電腦瀏覽器上,這兩個數字會是一樣的,在手機上卻不同:

different-height

查了一下網路上的資料,大概有兩種方式可以解

一個是用 JS,一個是用 CSS

於是我加上一個 resize 事件,監聽 window 的高度變化,並顯示在畫面上:

var containerElement = document.getElementById("container");
var containerHeight = containerElement.clientHeight;
var heightElement = document.getElementById("container-height");
heightElement.innerText = "container clientHeight: " + containerHeight + "px";
function updateWindowHeight() {
var windowHeightEl = document.getElementById("window-height");
windowHeightEl.innerHTML = "window innerHeight: " + window.innerHeight + "px";
}
updateWindowHeight();
window.addEventListener("resize", updateWindowHeight);

在 iOS Chrome 上,可以看出兩者的高度並不一致:

100vh-issue-3-ios-chrome

iOS 的 Safari,除了 container 與 window 高度不同之外,還發現了另一個現象

100vh-issue-3

在最頂的時候往下滑動,window 的高度是動態的

mind-explosion

在 Android Chrome 上,也出現 window 變化的狀況,但還是有點差異

100vh-issue-3-android-chrome

原來是彩蛋啊!!🥚

既然 100vh 與 window 的高度會不一致,而且在 iOS Safari 與 Android Chrome 上還會不斷改變, 那麼就在監聽 windowresize 事件時, 將 container 元素的高度複寫成當下的 window 高度就行了:

(function () {
function updateWindowHeight() {
var currentWindowHeight = window.innerHeight;
var windowHeightEl = document.getElementById("window-height");
windowHeightEl.innerHTML = "window innerHeight: " + currentWindowHeight + "px";
var containerElement = document.getElementById("container");
containerElement.style.height = currentWindowHeight + "px";
var containerHeight = containerElement.clientHeight;
var heightElement = document.getElementById("container-height");
heightElement.innerText = "container clientHeight: " + containerHeight + "px";
}
updateWindowHeight();
window.addEventListener("resize", updateWindowHeight);
})();

100vh-issue-4

我們可以看到,container 高度與 window 高度,始終保持同步,也就避免出現外層 scroll 的情況發生了

100vh-issue-rotate

旋轉螢幕也不是問題~

既然 container 的高度解決了,再將 container 裡面的 header、body、footer 加進來:

100vh-issue-final

一切看來都非常美好 😎

至於 resize 事件是否要加入 debounce 增進效能,那就看個人選擇了

CSS 的方法有分幾種:

.container {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
max-height: -webkit-fill-available; // 加上 fill-available
background: lavender;
}

但如同前綴 -webkit- 的字面意義,在 Android Chrome 上仍然有問題

但 Windows 及 macOS 上的 Chrome 卻沒事

ChromeSafari
macOSOO
WindowsONA
iOSOO
AndroidXNA

將 html 與 body 的高度設為 100%,底下的 container 就放心地用 100%

完全將在 mobile 上有爭議的 vh 單位移除了,是一個簡潔的方式

html,
body {
margin: 0;
padding: 0;
height: 100%; // 用 100%
width: 100%;
}
.container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%; // 這裡也用 100%
background: lavender;
}

所有平台及裝置的瀏覽器都通過了考驗 💯

ChromeSafari
macOSOO
WindowsONA
iOSOO
AndroidONA

有一個人分享了利用新 CSS 單位的解法,也是一個不錯的選擇:

.container {
display: flex;
flex-direction: column;
width: 100%;
height: 100dvh; // 用單位 dvh
background: lavender;
}

關於 dvh 的示意圖如下:

new unit

PS. 本圖來自 Google IO

但這個似乎還很新,看來支援度沒有太好

簡單測試了一下,在 Safari 可以正常運作,但是 Chrome 卻不認識這個單位

ChromeSafari
macOSXO
WindowsXNA
iOSOO
AndroidXNA

支援度可以參考:“dvh” | Can I use… Support tables for HTML5, CSS3, etc

之後想要來研究一下關於 dvh 這一類給 mobile 專用的單位

ChromeSafari
MacOSOO
WindowsONA
iOSXX
AndroidXNA
  • O:代表 100vh 與 window 高度一致
  • X:代表 100vh 與 window 高度不同
  • NA:此平台無此瀏覽器
ChromeSafari
iOSXO
AndroidONA
  • O:代表 window 高度會隨 scroll 變化
  • X:代表 window 高度固定
  • NA:此平台無此瀏覽器
OSChromeSafari
iOS15.15103.0.5060.6315.15
Android11103.0.5060.71NA

這次因為使用了滿版 layout 搭配 100vh,而發現了這個水很深的問題

iOS Safari 在往下滑動時,window 會不斷改變的現象,確實是大開眼界

總結下來,JS 的解法需要去監聽 resize 事件, 此外大概不可避免要搭配 debounce,會需要寫很多的程式碼, 還需要在適當的時機取消訂閱 resize 事件,避免記憶體洩漏問題, 簡單來說為了效能及資源,都要額外再做其他事

CSS 的三種解法都相對簡單,沒有太多的程式碼

但唯有第二個解法(html、body 設為 100%)通過了所有瀏覽器的考驗

第三個解法(新單位 dvh)雖然現在有相容性問題,但未來的可用性令人期待

用 JavaScript 偵測裝置的方位 - 直向及橫向

在 CSS 中,我們可以用 Media Queries 的方式,分別定義橫向與直向的樣式:

.box {
width: 300px;
height: 300px;
display: none;
}
@media (orientation: landscape) {
.landscapeBox {
display: block;
background: lightblue;
}
}
@media (orientation: portrait) {
.portraitBox {
display: block;
background: lightcoral;
}
}

但是,雖然可以隨著直、橫向定義不同的樣式

但卻無法讓元件知道現在的狀態,JS 並不知道目前的裝置方位為何

也就無法將這個值納入元件的 state 去做其他的判斷了

我原本打算使用 react-hook-screen-orientation 這個套件來判斷行動裝置的方位

於是我翻了一下這個套件的原始碼,發現它監聽的事件是 orientationchange,以下是部份原始碼:

window.addEventListener("orientationchange", updateOrientation);

於是,我查了一下 MDN 的文件,發現這個事件即將要捨棄了:

Deprecated: This feature is no longer recommended. Though some browsers might still support it, it may have already been removed from the relevant web standards, may be in the process of being dropped, or may only be kept for compatibility purposes. Avoid using it, and update existing code if possible; see the compatibility table at the bottom of this page to guide your decision. Be aware that this feature may cease to work at any time.

雖說要棄用,但目前這個事件在手機上還是可以正常作用

於是我又在原始碼發現了這一段:

const getOrientation = () => window.screen?.orientation?.type;

在 macOS Safari 的 console 裡輸入 window,發現 window.screen 底下沒有 orientation 這個屬性

所以使用套件,會抓到 undefined 也是很合理的

因此我推測所有使用 Webkit 的瀏覽器一樣沒有 orientation 屬性

測試了四個作業系統(Windows、macOS、iOS、Android)、三種瀏覽器(Chrome、Firefox、Safari)之後,得出以下結論:

ChromeFirefoxSafari
MacOSOOX
WindowsOONA
iOSXXX
AndroidOONA

Windows 與 Android 系統上沒有 Safari,所以就不在討論範圍內

果然不出所料,Webkit 系的瀏覽器,全部都拿不到 orientation 屬性

而 MDN 也列出 orientation 屬性對各瀏覽器的支援度,如下表:

browser compatibility

iOS 上的 Chrome、Firefox 應該也屬於 Safari on iOS 那一類

令人驚訝的是,IE11 居然有支援!

雖然行動裝置上也沒有 IE 可以測試,而 Windows 的 IE 整個打不開 CodeSandbox

所以就不實測了

至於 Safari 的結果就 ⋯⋯ 🤦‍♂️

不知道 Safari 什麼時候會支援啊 ⋯⋯

以下是測試結果中使用的裝置,其作業系統及各瀏覽器版本,有需要可以參考一下:

OSChromeFirefoxSafari
MacOSMonterey 12.3.1101.0.4951.64100.015.14
WindowsWin10 21H2100.0.4896.12787.0NA
iOS15.14.1101.0.4951.58100.115.14
Android11101.0.4951.6191.1.0NA

因此,就算 orientationchange 事件仍然可以使用

但只要有裝置上的 window.screen 物件沒有 orientation 屬性

就沒辦法用此方法來判斷裝置的方位

於是我查了一下看是否有其他替代方案,在 stack overflow 找到了一些解決方案

而用 window.matchMedia("(orientation: portrait)") 去判斷當前 window 的方位,似乎是可行的方案

並加上監聽 change 事件,即可在當 orientation 改變的時候,得到最新的方位

於是,我把它寫成一個 hook,以便之後方便使用:

import { useCallback, useEffect, useState } from "react";
const getPortraitFromMediaQuery = () => window.matchMedia("(orientation: portrait)");
function UseIsPortrait(): boolean {
const initialIsPortrait = getPortraitFromMediaQuery().matches;
const [isPortrait, setIsPortrait] = useState<boolean>(initialIsPortrait);
const updateOrientation = useCallback((event: MediaQueryListEvent) => {
console.log("event emitted!!");
if (event.matches) {
// Portrait mode
setIsPortrait(true);
} else {
// Landscape
setIsPortrait(false);
}
}, []);
useEffect(() => {
const portrait = getPortraitFromMediaQuery();
// start listening event
portrait.addEventListener("change", updateOrientation);
return () => portrait.removeEventListener("change", updateOrientation);
}, [updateOrientation]);
useEffect(() => {
console.log("isPortrait: ", isPortrait);
}, [isPortrait]);
return isPortrait;
}
export default UseIsPortrait;

若將 matchMedia 改寫成:window.matchMedia("(orientation: landscape)")

matches 屬性拿到的布林值就會是相反的,就看比較偏好用哪種方式判斷了

window.screen.orientation 確實是一個不錯的判斷方式

只可惜 WebKit 系的瀏覽器不支援,希望以後可以跟進(對 Safari 的成長速度頗為擔心就是了

window.matchMedia("(orientation: landscape)") 目前看來可以取代的方案

美中不足的是,它只能判斷「直」(portrait)與「橫」(landscape)

而無法像 orientation 屬性可以判斷主要橫向(landscape-primary)、次要橫向(landscape-secondary)、主要直向(portrait-primary)、次要橫向(portrait-secondary)四種不同的值(型別為 string

但其實基本的直、橫向判斷,就足以應付大部分的情況了

以上的程式碼全貌,可以參考以下的 CodeSandbox

可以試著在手機、平板等裝置上打開,看看不同瀏覽器的差異

最下面的有顏色的方塊是用純 CSS 的方式寫的,方便跟元件的 state 做比較

遞迴元件與巢狀列表

工作上遇到一個頁面,是要呈現一個巢狀的 list

每個 list 底下,還會有自己的屬於自己的 list

而每個 list item 都有一個 checkbox 在前面

HTML 會長得像以下結構:

<ul>
<li>
<input type="checkbox" />
<label>1</label>
<ul>
<li>
<input type="checkbox" />
<label>1-1</label>
</li>
<li>
<input type="checkbox" />
<label>1-2</label>
<ul>
<li>
<input type="checkbox" />
<label>1-2-1</label>
</li>
<li>
<input type="checkbox" />
<label>1-2-2</label>
</li>
{/* 繼續往下長.... */}
</ul>
</li>
</ul>
</li>
<li>
<input type="checkbox" />
<label>2</label>
</li>
</ul>

目前只有固定的三層,可以直接寫死三層的 list 結構

但這不是一個靈活的結構,因爲未來某一天若要添加第四層,就得在 HTML 再添加下一層的 <li>

我希望的是用 data-driven 的方式去做渲染

也就是 data 有幾層巢狀,HTML 的部分就會依據 data 長出幾層

巢狀的資料結構,我第一個想到的就是「遞迴」

某些場合我們會使用遞迴函式

函式可以遞迴,也就是在自己裡面呼叫自己,那元件是否也可行呢?

於是我查了一下關於 recursive component 的文章

發現 Vue 與 React 雖然寫法不太一樣,但都有遞迴元件的方式達成不斷巢狀渲染的目的

這裡就用 Vue 來示範

以下分三個方向來討論,分別是:

  • 資料面
  • 顯示面
  • 互動面

首先,先定義一下 data 的型別:

type Item = {
id: string;
isCheck: boolean;
children: Item[]; // <-- 這裡又是 type Item,不斷往下長⋯⋯
};
type List = Item[];

從 children 的型別可以看出,type Item 也是一個遞迴的結構

因此,我們的 data 的結構就會是 type List 的樣子:

var nestedList: List = [
{ id: "1", isCheck: true, children: [] },
{
id: "2",
children: [
{ id: "2-1", isCheck: true, children: [] },
{
id: "2-2",
isCheck: true,
children: [{ id: "2-2-1", isCheck: true, children: [] }],
},
],
isCheck: true,
},
];

我們在這裡只寫三層的 list 結構就好,三層若可以正常顯示,四層、五層、⋯⋯、n 層也不會有問題

接下來就是 HTML 的部分,我們定義一個元件,就叫做RecursiveList吧:

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" />
<label>{{ item.id }}</label>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "RecursiveList",
props: {
list: {
type: Array,
default: () => [],
},
},
};
</script>

這裡可以渲染第一層的 item 了,因此我們著手進行第二層以下

如果每個 <li> 有 children,就要再跑一次 v-for 去渲染

遞迴函式呼叫自己,就是爲了避免寫重複的邏輯、做重複的工作,遞迴元件也是同樣的道理

因此,我們在元件裡建立遞迴的結構:

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" v-bind:checked="item.isCheck" />
<label>{{ item.id }}</label>
{/* ⬇️ 這裡使用遞迴 ⬇️ */}
<RecursiveList v-bind:list="item.children" v-if="item.children" />
</li>
</ul>
</div>
</template>
<script>
// ...
</script>

從此,無論是第三層、第四層、……、第 n 層,都可以渲染出來了~

nested template

到這裡爲止,僅只是顯示方面,別忘了每一個 item 裡面還有一個 checkbox

接下來,我們要為這個 checkbox 增加互動行爲

我們做出的巢狀的 <ul><li> 結構之後,每個 item 的 checkbox 互動行爲又是如何呢?

這裡有兩個條件要符合:

  1. 當任一 checkbox 被 check/uncheck 時,該 checkbox 底下的所有 checkbox 都要被 check/uncheck
  2. 每個 checkbox 的下一層有任一 checkbox 為 check 狀態,它自身就要被 check

當 checkbox 點擊的當下,以這個 list item 的元件為主體(以下簡稱當事人),分為兩個方向:

  1. 向下的操作:改變所有子層(及子層的子層 ⋯⋯ 等) checkbox 的值
  2. 向上的檢查:確認父層是否要變動

首先,我們來做第一個條件的互動:

當事人若爲 check,底下所有子孫都要 check,也就是全選;反之,則全不選

所以我們新增一個 handleChildren 的方法

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" v-bind:checked="item.isCheck" v-on:change="handleChildren($event, item)" />
<label>{{ item.id }}</label>
<RecursiveList v-bind:list="item.children" v-if="item.children" />
</li>
</ul>
</div>
</template>
<script>
export default {
// ...
methods: {
handleChildren(event, item) {
const newValue = event.target.checked;
function changeAll(item, value) {
item.children.forEach((child) => {
child.isCheck = value;
changeAll(child, value);
});
}
changeAll(item, newValue);
item.isCheck = newValue; // 更新自身的值
},
},
};
</script>

handleChildren 方法裡面,changeAll 這個函式也是做遞迴呼叫

handleChildren 傳進來的 item 底下所有的 children 都掃過一遍

最後也別忘了更新自己本身的值

完成了向下的操作,接下來看向上的檢查

當事人觸發 change 事件後,若為 check,則父層不用做任何檢查

但若為 uncheck,他的父層(parent)要判定其子層,也就是當事人的平輩層(sibling)是否都沒有 check,若都沒有,就要 uncheck

這個父層若 uncheck 了,那又要執行一次這個父層的父層(aka 當事人的祖父層、grandparent)的檢查 ⋯⋯

也就是會一路往上每一層都做檢查,直到最上層

在當事人的視野裡,我們只從 props 接收到 list,也就是父層的 children 這個陣列

壓根不知道自己的父層是誰

於是,他必須通知自己的父層,也就是遞迴元件的上一層去做 children 的檢查

此時,Vue 的 $emit 就派上用場了

還記得前些年,前高雄市長韓國瑜的口號嗎?

沒錯,那就是「貨出去,人進來,高雄發大財」

來改編一下這句話:

event 出去,props 進來,前端發大財

woz-in-us-festival

利用 $emit 的特性,來通知上層:

你底下的其中一個子層 uncheck 了,請檢查自己該不該繼續 check

於是,新增一個 handleParent 方法

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input
type="checkbox"
v-bind:checked="item.isCheck"
v-on:change="handleChildren($event, item), handleParent()"
/>
<label>{{ item.id }}</label>
<RecursiveList
v-bind:list="item.children"
v-if="item.children"
v-on:emit-parent="handleParent(item)"
/>
</li>
</ul>
</div>
</template>
<script>
export default {
// ...
methods: {
// ...
handleChildren() {
// ...
},
handleParent(item) {
this.$emit("emit-parent");
if (!item) return; // 沒有帶參數,表示是當事人,故不檢查
const childrenHasSomeChecked = item.children.some((item) => item.isCheck);
item.isCheck = childrenHasSomeChecked;
},
},
};
</script>

有別於一般 $emit 的寫法,這個 $emit 不帶任何 payload 出去,只是發送 emit-parent 事件

遞迴到上一層之後,emit-parent 事件觸發後執行的函式,依然是 handleParent 方法

此外,還從參數帶了父層的 item 進來,以便做 check 的檢查

至此就大功告成了


當事人父層的父層檢查、父層祖父層檢查,也都是同樣的邏輯

重複的工作,就交給遞迴去做了

emit-parent 事件會一路 $emit 上去

在最上層,也就是第一次出現 RecursiveList 的 App 元件裡面

handleEmitTop 方法去接 emit-parent 事件,並用 console.log 紀錄下來:

<template>
<div id="app">
<RecursiveList v-bind:list="nestedList" v-on:emit-parent="handleEmitTop($event)" />
</div>
</template>
<script>
import RecursiveList from "./components/RecursiveList.vue";
export default {
name: "App",
components: {
RecursiveList,
},
data() {
return {
nestedList: [
// ...
],
};
},
methods: {
handleEmitTop() {
console.log("do nothing");
},
},
};
</script>

果然會跟預想的一樣,每次的點擊,無論哪個 checkbox

每次都會一路 $emit 到最上層,然後印出 do nothing

但最上層沒有 checkbox 了,所以無需檢查,因此不用做任何事

上面的所有程式碼,請參考以下 CodeSandbox:

這次是因工作上的需要,而研究了關於遞迴的寫法

雖然這個不確定會巢狀幾層的 data 結構,會造成後端定義 model 的困擾而沒有採用

但也是一個不錯的嘗試

希望未來能有機會用到這種簡潔的寫法

Vue 因為框架的特性,直接對 data 的操作比較簡單,而無需在意是哪一層、哪一個 children

雖然過程中還挺手忙腳亂的,不知道哪一層是哪一層,然後誤打誤撞的寫出可以動的東西

it works

然而其實只需要專注在當事人身上就好了

話說回來,隨意修改 data 而不透過 props 去傳遞新的 data ,確實不是個好作法,但也確實能達到目的

若換作是 React,想必寫法會很不一樣

如果你好奇:文中那張照片、站在沙灘高舉雙手的人是誰?

他是蘋果電腦的共同創辦人:史蒂夫·沃茲尼克(Steve Wozniak)

那是 1982 年沃茲尼克所舉辦的音樂節 US Festival 現場

在他的自傳中提到自己夢想著籌辦音樂節

雖然音樂節圓滿舉辦,但不僅沒賺錢,反而虧損

可是他本人還是很開心,非常享受音樂節的每一刻

這張照片的肢體語言令我印象深刻

也充分表現出他的心情

就算沒有發大財,每天還是要過得開心

初探 C# 的 Array 與 List

C# 的 List 與 Array 有很多類似之處

在看 LINQ 介紹的時候,出現了兩個 LINQ 函式:ToList()ToArray()

看到這兩個詞之後我不禁好奇,List 與 Array 又有何不同呢?

C# 中的 Collections 資料型別有很多種,包含:

  • ArrayList
  • List
  • SortedList
  • Dictionary
  • Hashtable
  • Stack
  • Queue

而想討論的是 Array、List 與 ArrayList 這三種

Array 的定義如下:

An array is the data structure that stores a fixed number of literal values (elements) of the same data type. Array elements are stored contiguously in the memory.

由定義我們知道,C# 使用連續的記憶體空間存放 Array,因此在初始化的當下,就決定了這個 Array 的長度

我們可以替換 Array 裡面成員的值,但無法新增或是刪除裡面的成員

因此,若要增加陣列的長度,就要再建立一個更大長度的新陣列,然後將就原陣列的所有成員 copy 過去,再將原陣列刪除

宣告一個 Array,可以這樣寫:

int[] numbers;
string[] colors;

或在宣告的時候直接初始化:

int[] numbers = new int[3] { 1, 2, 3 };
string[] colors = new string[3] { "red", "green", "blue" };

若用 var 初始化陣列,則可以省去陣列長度,讓編譯器從成員去自行推斷陣列的長度:

var numbers = new int[] { 1, 2, 3 };
var colors = new string[] { "red", "green", "blue" };

更簡短的寫法:

int[] numbers = { 1, 2, 3 };
string[] colors = { "red", "green", "blue" };

在初始化的時候,必須指定 Array 的長度

此外,成員的數量也要與長度相同,不然就會報錯

ArrayList 的定義如下:

In C#, the ArrayList is a non-generic collection of objects whose size increases dynamically. It is the same as Array except that its size increases dynamically.

因為 ArrayList 在宣告的時候,並不用定義其內部成員的型別,意即每個成員可以是不一樣的型別

List 跟 ArrayList 都不使用連續的記憶體來儲存資料,因此它的長度是動態的,會隨著成員的數量而增減

宣告一個 ArrayList 有兩種方法:

ArrayList list = new ArrayList();
var list = new ArrayList();

可以用 Add() 方法,隨時加入新的成員至 ArrayList:

var list = new ArrayList();
list.Add(1);
list.Add("hello");
list.Add(false);
list.Add(null);

或用物件初始子(object initializer syntax)的寫法:

var list = new ArrayList()
{
1, "hello", false, null
};

由此可見,ArrayList 中的成員不一定是同型別的,這跟 JavaScript 的陣列很相似

ArrayList 提供一些方法,來操作內部的成員,剛才範例中的 Add() 就是其中之一

除此之外,還有 AddRange()Insert()Remove()Sort()IndexOf⋯⋯ 等

比較常見的屬性有:CapacityCount

Count 很好理解,也就是 ArrayList 的成員數,相當於 JavaScript 的 Array.prototype.length

Capacity 則代表這個 ArrayList 可以裝載多少成員,這其實跟記憶體的配置容量有關

List 的定義如下:

The List<T> is a collection of strongly typed objects that can be accessed by index and having methods for sorting, searching, and modifying list. It is the generic version of the ArrayList that comes under System.Collections.Generic namespace.

List 其實是泛型版本的 ArrayList,在 List 裡面的每個成員,其型別都要符合傳入的 T 泛用型別

List 來自於 System.Collections.Generic namespace,而 ArrayList 來自於 System.Collections

宣告、初始化、對成員的操作方法,基本上都跟 ArrayList 一樣

List 同 ArrayList 有 Count 這個屬性,表成員的數量

List 有些方法與 ArrayList 一樣,詳細的範例可以參考 TutorialsTeacher 網站

List 與 Array 將資料儲存至記憶體的方式不太一樣,在這篇文章分析了兩者的相異之處

以 List 而言,在建立一個 List 的時候,會先定義長度為 4 的陣列

當裡面的 item 數量大於 4 時,則再創建一個新的陣列,其長度為原本的兩倍(也就是 8),並將舊的陣列從記憶體中釋放(dereference, garbage collection 機制)

再下一次遇到 item 數量超越陣列長度的時候,就再做一次(長度增為 16)

可以從下面的範例印證:

可以看到,Capacity 確實是依照兩倍的方式增長

最後面,用 TrimExcess() 這個方法將 Capacity 多餘未使用的記憶體移除,達成 Count 跟 Capacity 相等。然後呼叫 Clear() 清空所有成員,Count 歸零,Capacity 則維持原狀

ArrayArrayListList
記憶體使用靜態且連續的記憶體使用動態且不連續的記憶體使用動態且不連續的記憶體
成員同型別資料異型別資料同型別資料
命名空間System.ArraySystem.CollectionsSystem.Collections.Generic
成員修改只能夠變動成員,無法增減可增減成員可增減成員

研究過 Array 及 List 之後,才知道最重要的差異就在於記憶體使用

然而,我在專案裡幾乎只有看到 List<T>,因為我們處理的資料大部分都是集合物件,也大量使用 LINQ 函式來處理資料

因此相較於 Array,List 的靈活度也就顯而易見了

遞迴函式與夢境

遞迴函式是一個很有趣的東西

也常在一些特殊的資料結構上會使用到

而遞迴最主要的構成因素就是在函式裡面呼叫自己,大概長得像是下面的結構:

function recursion(param) {
recursion(param);
}
// initial call
recursion(initialParam);

但若像上面的結構,不斷地呼叫自己,沒有終止的機制,就會像無窮迴圈一樣,讓電腦記憶體爆掉

因此,遞迴函式裡面必須有一個刹車的機制

流程控制:繼續走?還是回頭?

Section titled “流程控制:繼續走?還是回頭?”

在流程控制(control flow)上,可以分成兩條路:

  • 符合終止條件:停止
  • 不符合終止條件:呼叫自己

流程圖大概如下:

graph LR
A(Initial call) --> B{Continue?}
B -->|No| C(Finish)
B -->|Yes| D(Call again)
D --> E{Continue?}
E -->|No| F(Finish)
E -->|Yes| H(Call again)
H --> I(Go on...)

我很喜歡用一個比喻去詮釋遞迴的概念,那就是:全面啟動

電影中的主角柯伯如何進入夢境的?

沒錯,睡著就行了!

而在夢中的他,有兩個選擇:

  1. 醒來,回到現實世界
  2. 再睡著一次,進入「夢中夢」,也就是第二層夢境

但不幸的是,隊員的其中一人在出任務的時候受傷而進入昏迷狀態,柯伯必須去下一層救他

原本計畫順利的話,應該就此打道回府才是

然而面對這樣情況,他也無法執行了

任務達成的條件尚未成立

於是他說:「我們必須前往更深處」

go deeper

他只好再睡著一次,去下一層夢境了

然後他終於找回隊員了

該怎麼回到現實世界呢?

沒錯!就是 kick!

would be a kick

在理解遞迴的同時,也需要先了解這兩個關鍵字作為基礎知識

一個是執行環境(execution context),另一個則是堆疊(stack)

這裡就直接用英文稱呼,比較直覺一點

在 JavaScript 的世界裡,其實有兩種 execution context:

  • 全域執行環境(Global Execution Context)
  • 函式執行環境(Function Execution Context)

而我們這裡討論的,主要是函式執行環境,而所有函式執行環境的最外面,即是所謂的全域執行環境

context 就是記住當下的時空背景:當下的有哪些變數、其值又是什麼

每一次的函式呼叫,就會創造一個新的 context,以便記憶此次呼叫的環境狀態

創造出來 context 則會存放在稱為 stack 的地方

新的 context 建立了之後,就丟進去這個 stack,

因此,stack 裡的順序就會是:舊的在下,新的在上

下一次要去拿 context 的時候,就會先拿到比較新的 context

大致了解 context 與 stack 的概念之後,我們來看一個簡單的範例:階乘的計算

同時,我們把全面啟動的故事也放在手邊(到底是有多愛全面啟動

階乘的數學定義如下:

$$ n! = n \times (n - 1) \times (n - 2) \times \ldots \times 3 \times 2 \times 1 $$

我們要拿指定的初始值 n 去做階乘計算

function factorial(n) {
// 終止條件
if (n === 0) {
return 1;
} else {
return n * factorial(n - 1); // 呼叫自己
}
}
factorial(4); // result: 24
factorial(2); // result: 2
factorial(0); // result: 1

終止條件是,當 n = 0 時,return 1

因為階乘的定義上:

$$ 0! = 1 $$

假設我們的初始值 n = 3,於是我們執行第一次的呼叫:

factorial(3);

柯伯坐在飛機頭等艙舒適的座椅上睡著了,進入了夢境

第一次的呼叫,建立的一個新的 context,裡面有我們帶進去的 n = 3

所以我們可以這樣表示:

Context { n: 3 }

並且將這個 context 丟進 stack 中儲存,可以這樣表示:

Stack = [
{ n: 3 }
];

然而我們的 n 沒有符合終止條件,於是,進行了第二次的呼叫 factorial

柯伯為了解任務,再次睡著進入了夢中夢

此時,我們帶入 factorial 的參數是 n - 1,所以是 3 - 1 = 2,帶入 2

第二次的呼叫,再次建立一個屬於它的 context:

Context { n: 2 }

於是 stack 變成:

Stack = [
{ n: 2 },
{ n: 3 }
];

我們又回到了函式的第二行,再次判斷終止與否,但仍未滿足條件,於是進行第三次的呼叫:

柯伯達成任務了,不料,隊員昏迷不醒,於是他躍身進入下一層夢境

然後再重複了一次遞迴

OK,這次 n = 0,所以要 return 1 回去,至此,不再遞迴下去

柯伯找回遺失的隊員要打道回府了

return 代表結束這個函式,並回傳值

但別忘了,我們現在在第幾層夢境?

柯伯說:「我是誰?我為什麼在這裡?」

啊,我是指在哪一個 context 裡?

最後那一個對吧,也就是 stack 最上面那個:Context: { n: 0 }

所以當我們 return 之後,回到上一次層是:Context: { n: 1 } 這個 context,而不是現實世界(aka Global 的 context)

柯伯醒來一次,回到上一層夢境

然後我們看到上一層這樣寫:return n * factorial(n - 1)

於是,1 跟上一層的 n 相乘之後,又繼續 return 更上一層,一連串的 return

柯伯一行人,藉由一連串的 kick,一路回到現實世界

整個過程像是這樣:

factorial(3);
3 * factorial(2);
2 * factorial(1);
1 * factorial(0);
return 1;
return 1 * 1;
return 2 * 1;
return 3 * 2;

於是最後我們得到結果就是:

$$ 3! = 6 $$

我們總共呼叫了 factorial 這個函式四次,稱之為遞迴的深度(depth)

如果我們想知道每一次呼叫時的 depth,可以這樣寫:

function factorial(n, depth) {
console.log("depth: ", depth); // 現在的遞迴深度
console.log("context: n =", n); // 當前的 n 值,也就是目前的 context
if (n === 0) {
return 1;
} else {
return n * factorial(n - 1, depth + 1);
}
}
factorial(2, 1); // 第一次呼叫,所以深度從 1 開始

這樣就可以看到每一個 context 及對應的 depth 了:

depth: 1
context: n = 2
depth: 2
context: n = 1
depth: 3
context: n = 0

於是柯伯轉動手上的陀螺,以避免迷失自我

而這裡的 console.log 則成為各個 context 裡的指標(電影裡稱之為「圖騰」totem)

spinner

但柯伯的陀螺只能判斷是在夢境(aka Function Execution Context)或是現實(aka Global Execution Context

還是不太方便呢(重點錯

由上述的範例可知,每一次的呼叫,就會創造一個 context 並存到 stack 之中

所以遞迴的深度越深,context 也會越多,電腦需要花更多的記憶體去存放這個龐大的 stack

到達極限的時候,就會造成「堆疊溢位」(stack overflow)

遞迴函式自有它簡潔優雅的優點,但優點是用吃重記憶體這個缺點換來的

就像我們以前會說:「Chrome 瀏覽器的速度好快啊!」但它是吃記憶體的大怪獸啊

基於效能的考量,就會衍生出迭代(Iterate)與遞迴(Recursion)的優缺點比較了

有機會再聊這塊

其實一直想要寫關於遞迴的文章

但又不知道從何寫起

而每當在用遞迴函式的時候,總想到全面啟動這部電影

為了寫這篇,我還重新看了一次電影呢 🙈

參考網路上很多關於遞迴的範例

其他常見的遞迴範例還有,像是費式數列、巴斯卡三角、巢狀資料結構的處理……等

他們都寫得很好,我也把連結列在參考資料

或許我並不是想要寫遞迴,而是全面啟動吧 🙃

關於 React 傳送門 (Portal)

React portal 是一個神奇的魔法。 官網的描述是這樣:

Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.

簡單來說,它可以跨越 JSX 結構層級,依附在指定的地方。

以下是這個 portal 的 API 及所需參數:

ReactDOM.createPortal(child, container);

第一個參數 child:可以是 dom 元素、字串或是 fragment。(官網稱之為 renderable React child

第二個參數 container:要依附的目標(dom 元素)

最常使用的時機,就是在使用 modal(dialog)元件的時候,因為它要凌駕所有的 dom 元素之上,才能覆蓋它們。 或父層元件擁有 overflow: hiddenz-index css 屬性,但是子元件想要不受父元件屬性限制的時候。

首先,我們將會有三個元件,分別是:Target、Parent、Child。 Child 元件是 Parent 元件的子元件,而 Target 元件則是我們要利用 React Portal 依附的目標元件。

Child 元件:設定寬、高皆為 400px

src/components/Child.jsx

export default function Child() {
const STYLE = { width: 400, height: 400, background: "lightgreen" };
return <div style={STYLE}>Child element</div>;
}

接著是 Parent 元件:設定寬、高為 300px,還有 overflow: hidden。 並將 Target 元件的參照,傳進 createPortal 的第二個參數裡,也就是依附對象。

src/components/Parent.jsx

import React from "react";
import ReactDOM from "react-dom";
import Child from "./Child";
function Parent(props) {
const { forwardRef, targetEl } = props; // 將 target element 傳進來,供 createPortal 使用
const STYLE = { width: 300, height: 300, background: "lightblue", overflow: "hidden" };
return (
<div ref={forwardRef} style={STYLE} onClick={handleClick}>
Parent element
{targetEl && ReactDOM.createPortal(<Child />, targetEl)}
</div>
);
}
export default React.forwardRef((props, ref) => <Parent forwardRef={ref} {...props} />);

另外,為了要取得該元件的元素參照,所以使用了 forwardRef

最後是 Target 元件:

src/components/Target.jsx

import React from "react";
function Target(props) {
const { forwardRef } = props;
const STYLE = { width: 500, height: 500, background: "lightgrey" };
return (
<div ref={forwardRef} style={STYLE}>
Target element
</div>
);
}
export default React.forwardRef((props, ref) => <Target forwardRef={ref} {...props} />);

在 App 元件引用 Target、Parent 元件:

src/App.jsx

import { useState, useRef, useEffect } from "react";
import Target from "./components/Target";
import Parent from "./components/Parent";
function App() {
const targetElRef = useRef();
const parentElRef = useRef();
const [currentTargetEl, setCurrentTargetEl] = useState(null);
const STYLE = { display: "flex" };
useEffect(() => {
if (targetElRef.current) setCurrentTargetEl(targetElRef.current);
}, []);
return (
<div>
<div style={STYLE}>
<Target forwardRef={targetElRef} />
<Parent forwardRef={parentElRef} targetEl={currentTargetEl} />
</div>
</div>
);
}
export default App;

特別注意的是,這裡在 useEffect 裡, 當 targetElRef 拿到 Target 元件的 dom 節點的時候, 更新 currentTargetEl 的值, 以便讓 Parent 元件能夠拿到 Target 元件的節點參照。 (由於 ref 的改變不會觸發 re-render,因此必須用 state 儲存起來)

讓我們來看一下結果:

從 developer tool 可以看出,Child 元件包覆在 Target 元件裡面,而不是如 JSX 的結構包覆在 Parent 元件裡。

利用 React Portal,Child 元件成功地跨越世界線逃脫 Parent 元件的掌控啦~

就跟奇異博士一樣,從砂輪機的火花圈中走出來

dr.strange

Portal 與事件冒泡(Event Bubbling)

Section titled “Portal 與事件冒泡(Event Bubbling)”

React Portal 傳送門,將 element 傳送到我們想要的地方。 結果就是,dom 結構(DOM tree)改變了,但 JSX 的結構(React tree)卻不變。

也因此,Parent 元件這個 scope 裡面的所有東西(例如:props、function⋯⋯ 等),Child 元件仍然可以拿到。

所以「事件冒泡」當然也包含在內,我們在 Parent 元件裡,新增一個函式:handleClick

src/components/Parent.jsx

// ...
const handleClick = () => {
console.log("parent click!!");
};
//...

並在根節點,當 onClick 事件觸發的時候呼叫它:

// ...
return (
<div ref={forwardRef} style={STYLE} onClick={handleClick}>
Parent element
{targetEl && ReactDOM.createPortal(<Child />, targetEl)}
</div>
);

然後我們會發現,無論是滑鼠點擊 Parent 元件,還是 Child 元件,都會印出 parent click!!! 然而點擊 Target 元件則毫無反應。

因此我們可以證明,Child 元件跑去寄宿在 Target 元件底下, 但同時又享有 Parent 元件資產的使用權(functionprops 等)

這不就是拿美國護照、用台灣健保的概念嗎??(喂

以上就是 React Portal 的簡單介紹~

上面的 demo code,請點選這個連結

我們下次見囉~ 👋

React-Beautiful-DnD 簡單示範

React-Beautiful-DnD是一個很有人氣的 DnD 套件,除了基本的 Drag & Drop 功能之外,還有優雅的拖曳動畫、鍵盤拖曳支援等,使用起來也很容易入手。

React-Beautiful-DnD由三種要素組成,分別為:<DragDropContext /><Draggable /><Droppable />

下圖為官方的示意圖:

Structure

開發 React 的人對於<DragDropContext />應該不難理解, 看到context這個關鍵字就能領略箇中含義, <DragDropContext />所定義的是要實作拖拉功能的範圍

定義拖曳元件,將所要拖曳的元件包在<Draggable />裡面,結構如下:

import { Draggable } from "react-beautiful-dnd";
<Draggable draggableId={taskId} index={taskIndex}>
{(provided, snapshot) => {
const { draggableProps, dragHandleProps, innerRef } = provided;
const { isDragging } = snapshot;
return (
<div ref={innerRef} {...draggableProps} {...dragHandleProps} data-is-dragging={isDragging}>
<h4>I can DRAG! ✋</h4>
</div>
);
}}
</Draggable>;

要拖曳的元件不能直接作為children直接包在<Draggable />中間, 必須是一個函式(官方的教學影片說裡面是放一個renderProps),並在return的時候返回我們要拖拉的元件, 而函式的參數中,有<Draggable />的兩個物件:providedsnapshot

provided物件中包含draggablePropsdragHandlePropsinnerRef

  • innerRef:用來綁定拖曳的 DOM 元素
  • draggableProps:提供拖曳元件的props
  • dragHandleProps:則可另外定義拖拉的範圍,像是綁定一個 Icon 做這個拖曳元件的拖拉

snapshot物件中,我目前使用到的只有isDragging,在拖曳的狀態下,可以用來做樣式上的變換

定義放置的元件,如同<Draggable />一樣的結構,只是是加上放置的屬性,結構如下:

import { Droppable } from "react-beautiful-dnd";
<Droppable droppableId={columnId} type="task">
{(provided, snapshot) => {
const { droppableProps, innerRef, placeholder } = provided;
const { isDraggingOver } = snapshot;
return (
<div ref={innerRef} {...droppableProps} data-is-over={isDraggingOver}>
<h2>Drop on ME!! 🙌</h2>
{placeholder}
</div>
);
}}
</Droppable>;

provided物件中包含droppablePropsplaceholderinnerRef

  • innerRef:用來綁定可放置的 DOM 元素
  • droppableProps提供放置元件的props
  • placeholder就如其名,是一個佔位元素,在拖曳(但尚未 drop)的時候,提供一個放置的空間

snapshot物件中,我目前使用到的只有isDraggingOver,在被拖曳元件 hover 的狀態下,用來做樣式上的變換

最後附上範例連結

React-DnD 簡單示範

basic concept

Image Reference

HTML5 提供原生的HTML5 Drag & Drop API(以下簡稱 HTML5 DnD)實現 DOM 元素拖曳的功能。但是由於在 React 的生態底下,並不適合操作實體 DOM,因此 React-DnD 居於中間角色,作為雙方溝通的橋樑。對於 DOM 方監控 HTML5 DnD,對 React 方做 component 及 state 的處理。


監控 HTML5 DnD 事件,其中包含三個要素,Backends、拖曳項目(Item)、監視器(Monitors)

  • HTML5: 支援 HTML5 DnD 事件
  • Touch: 支援行動裝置的觸控螢幕拖曳
  • Test: 支援 DnD 的互動測試
  • 拖曳元件的身分識別
  • 定義拖曳元素可以放置在哪裡
  • 其中攜帶拖曳元件的相關資訊,提供給放置元素進行放置動作的資料操作
  • 提供 React 元件(component)來自於(實體)DOM 端的 DnD 事件
  • 監視器會將收集到來自於 DOM 的事件注入至context

Collectors 收集 React 所需要資訊,包括收集函式(Collector Functions)、拖曳元件(Drag Sources)、放置(目標)元件(Drop Target)

  • 收集函式將監視器(monitor)得到的資訊注入 React 元件的props當中
  • 綁定可拖曳的元件(component)
  • 攜帶拖曳元件的相關資訊,提供給放置元件
  • 定義可接受放置的拖曳元件

定義拖曳(來源)元件(Drag Sources)及放置(目標)元件(Drop Target)的範圍

import Backend from "react-dnd-html5-backend";
import { DndProvider } from "react-dnd";
export default class YourApp {
render() {
return <DndProvider backend={Backend}>/* Your Drag-and-Drop Application */</DndProvider>;
}
}

拖曳(來源)元件(Drag Sources)

Section titled “拖曳(來源)元件(Drag Sources)”

綁定拖曳的元件、定義拖曳的行為

import { useDrag } from "react-dnd";
function DraggableComponent(props) {
const [collectedDragProps, drag, preview] = useDrag({
// 必填欄位
item: { type: "task", id: 1 },
// 選填欄位
begin: (monitor) => {}, // 開始拖曳的時候要做什麼事
end: (item, monitor) => {},
isDragging: (monitor) => {}, // return isDragging 的條件
canDrag: (monitor) => {}, // return canDrag 的條件
collect: (monitor) => ({
canDrag: Boolean(monitor.canDrag()), // 將上述的屬性值收集起來
isDragging: Boolean(monitor.isDragging()),
didDrop: Boolean(monitor.didDrop()),
}),
});
const { canDrag, isDragging, didDrop } = collectedDragProps; // 所有收集起來的值會 collectedDragProps 裡面
return <div ref={drag}>...</div>;
}

綁定放置的元件、定義放置的行為

import { useDrop } from "react-dnd";
function myDropTarget(props) {
const [collectedDropProps, drop] = useDrop({
// 必填欄位
accept: "task", // 接受元件的類別,可以是一個陣列,放置多個接受的類別, 例如:['task', 'story']
// 選填欄位
drop: (item, monitor) => {}, // 拖曳元件放開的時候要做的事
hover: (item, monitor) => {}, // 拖曳元件hover的時候要做的事
canDrop: (monitor) => {}, // 定義是否可以拖曳的條件,return一個布林值
collect: (monitor) => ({
isOver: Boolean(monitor.isOver()), // 將上述的屬性值收集起來
canDrop: Boolean(monitor.canDrop()),
}),
});
const { isOver, canDrop } = collectedDropProps; // 所有收集起來的值會 collectedDropProps 裡面
return <div ref={drop}>Drop Target</div>;
}

最後附上範例連結