跳到內容
關於我 數位花園

前端

8 篇文章擁有標籤:“前端”

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 現場

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

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

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

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

也充分表現出他的心情

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

html ruby 標籤與亞洲語系標音

因為想要在 markdown 上面寫日文,希望在漢字上面標上假名,所以來研究一下如何實作

查了一下,發現了平常很少用到的 <ruby> 標籤

根據 MDN 的文件:

HTML <ruby> 元素的意思是旁註標記。旁註標記用於標示東亞文字的發音。

包在 <ruby> 標籤內的字,就可以加上標音,以日文為例:

とある<ruby><rb></rb><rt></rt><rb></rb><rt>じゅつ</rt></ruby><ruby><rb>禁書目録</rb><rt>インデックス</rt></ruby>
とある<ruby><rb></rb><rt></rt><rb></rb><rt>がく</rt></ruby><ruby><rb>超電磁砲</rb><rt>レールガン</rt></ruby>

渲染出來的結果:


とあるじゅつ禁書目録インデックス

とあるがく超電磁砲レールガン


<ruby> 裡面包含兩種元素:<rb><rt>

rb 是 ruby base 的意思,也就是要被標音的文字,像是日文漢字或中文字

rt 是 ruby text,也就是標音文字,像是平假名、片假名、注音等

一個 <rb>,後面接的 <rt> 就會標註在前面的 <rb> 上面

當然,中文的注音也可以:

<ruby><rb></rb><rt>ㄅㄧㄢˋ</rt><rb></rb><rt>ㄊㄞˋ</rt></ruby>
<ruby><rb></rb><rt>ㄐ一ㄢˋ</rt><rb></rb><rt>ㄆㄢˊ</rt><rb></rb><rt>ㄗㄨㄟˇ</rt><rb></rb><rb></rb><rt>ㄆㄠˋ</rt></ruby>

結果:


ㄅㄧㄢ ˋㄊㄞ ˋ

ㄐ一ㄢ ˋㄆㄢ ˊㄗㄨㄟ ˇㄆㄠ ˋ


也來試試韓文:1

<ruby><rb></rb><rt>저우쯔위</rt><rb>子瑜</rb><rt>조쯔위</rt></ruby>

結果:


저우쯔위子瑜조쯔위


我們可以把整段要標音的文字都放在 <ruby> 裡面,也可以以詞彙為單位分段:

<ruby><rb></rb><rt></rt><rb></rb><rt>ばく</rt></ruby>
<ruby><rb></rb><rt>もく</rt><rb></rb><rt></rt></ruby>
<ruby><rb></rb><rt>ろく</rt></ruby>
カイジ

結果:


ばく もく ろく カイジ


基本上沒有特別規定,如何整理依個人喜好而異

Hugo 支援直接在 markdown 寫 html,所以上面的那些範例都可以正常地被渲染出來。但問題是寫法不友善,也不好閱讀

然後在網路上找到一位日本人 Spiegel 分享了一段關於 ruby 的 shortcode 之後,就新增進我的 shortcode 裡面:

layouts/shortcodes/ruby.html

<ruby><rb>{{ .Inner }}</rb><rp>(</rp><rt>{{ index .Params 0 }}</rt><rp>)</rp></ruby>

原本我們要這樣寫:

<ruby><rb>再起不能</rb><rt>リタイヤ</rt></ruby>

往後可以這樣寫:

{{</*ruby "リタイヤ">}}再起不能{{</ruby */>}}

結果:


再起不能(リタイヤ)


只需要寫最外層的 <ruby> ,而不用寫裡面的 <rb><rt> 了~

但用這個 shortcode 的寫法,就變成以一個 <ruby> 為標音單位了:

とある{{</*ruby "ま">}}魔{{</>}}{{<ruby "じゅつ">}}術{{</ruby>}}の{{<ruby "インデックス">}}禁書目録{{</ruby */>}}

結果:


とある()(じゅつ)禁書目録(インデックス)


科技始終來自於惰性啊 😎

在 google 的時候,其實不太好搜尋,一方面資料本來就少,另一方面,跟 ruby 語言同名的關係,非常容易查到 ruby 語言的相關資訊 😂

標音功能在我網頁開發近兩年的時間裡(菜逼八),完全沒有用到過,多國語言就算有日文,八成不會有標音吧。如果我是在出版社或教育界工作的話,那還比較有可能呢

  1. 我不懂韓文,所以這是參考子瑜的維基百科,若有標音斷句錯誤再麻煩糾正我,謝謝 🙏

關於 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>;
}

最後附上範例連結