跳到內容
關於我 數位花園

react

7 篇文章擁有標籤:“react”

[CSS] Tailwind 主題色的切換

必備知識:CSSHTMLJSReact

Tailwind 提供了深色模式(Dark Mode),可以自行設定深色模式時的樣式,只要在 CSS class 前面加上 dark: 關鍵字:

<div class="text-black dark:text-white">
{/* ... */}
</div>

假如我們要做「主題色切換」的功能,dark: 這個用法也許可行,但也只限於兩個主題色的切換,另一個缺點就是到處都要加上 dark: 的切換樣式,這聽起來不是一個好的做法。

想跳過本文廢話、直接看最終實作結果的同學,請走傳送門

在 Tailwind config 的自定義主題色盤

Section titled “在 Tailwind config 的自定義主題色盤”

在 Tailwind 的 config 當中,其中一個客制化的選項,就是可以自定義顔色的 class,例如:

module.exports = {
theme: {
colors: {
primary: "#0d6efd",
secondary: "#6c757d",
danger: "#dc3545",
warning: "#ffc107",
},
},
};

PS. 以上的名稱及色碼取自 Bootstrap 的主題色盤

若我們有兩個主題色,config 就必須寫成這樣:

module.exports = {
theme: {
colors: {
"theme-1-primary": "#0d6efd",
"theme-1-secondary": "#6c757d",
"theme-1-danger": "#dc3545",
"theme-1-warning": "#ffc107",
"theme-2-primary": "#0d6efd",
"theme-2-secondary": "#6c757d",
"theme-2-danger": "#dc3545",
"theme-2-warning": "#ffc107",
},
},
};

如此一來,就要在 HTML 上去抽換不同主題的 class:

function Button() {
const buttonTheme = isTheme1 ? 'bg-theme-1-primary' : 'bg-theme-2-primary';
return <button className={buttonTheme}>;
}

到處都要有這樣的判斷,看來也不是明智的做法。此外,若我們有四、五個主題要切換,就不是三元運算可以簡單解決的事。

CSS 提供了變數的功能,可分爲區域變數與全域變數。

定義 CSS 區域變數,下面這個 --main-bg-color 只能在這個 scope({} 刮號)裡面使用:

element {
--main-bg-color: brown;
}

比較常見使用 CSS 變數的方式,是使用 CSS 全域變數,建立一個稱作 :root 的 pseudo-class,裡面定義的變數在整個 HTML document 底下都可以使用(其實就是全域變數了):

:root {
--main-bg-color: brown;
}

使用 CSS 變數時,用 var() 這個函式,將要使用的變數作爲參數傳進去:

element {
background-color: var(--main-bg-color);
}

我們結合 Tailwind 及 CSS 變數的特性,就可以達到主題色盤抽換的目的,我們將方才 config 中寫死的色碼,用 CSS 變數取代:

module.exports = {
theme: {
colors: {
'color-one': 'var(--color-one)',
'color-two': 'var(--color-two)',
'color-three': 'var(--color-three)',
'color-four': 'var(--color-four)',
'color-five': 'var(--color-five)',
},
}
}

設定好 Tailwind config 之後,接著就是定義各主題色盤變數的時候了

const theme_ayanami = `
:root {
--color-one: #1d446c;
--color-two: #f1f1f1;
--color-three: #571a1a;
--color-four: #000000;
--color-five: #525252;
}
`;
const theme_ikari = `
:root {
--color-one: #3f6d4e;
--color-two: #8bd450;
--color-three: #1d1a2f;
--color-four: #965fd4;
--color-five: #734f9a;
}
`;

然後將變數包在 <style> 後,安插在 <head> 裡面:

function App() {
const [currentTheme, setCurrentTheme] = useState("ayanami");
const getThemeVariables = (_theme) => {
switch (_theme) {
case "ayanami":
return theme_ayanami;
break;
case "ikari":
return theme_ikari;
break;
}
};
useEffect(() => {
if (!document.getElementById("customThemeId")) {
const head = document.head;
const newStyleElement = document.createElement("style");
head.appendChild(newStyleElement);
newStyleElement.id = "customThemeId";
newStyleElement.innerHTML = getThemeVariables(currentTheme);
} else {
const styleElement = document.getElementById("customThemeId");
if (styleElement) {
// 更新 CSS 全域變數
styleElement.innerHTML = getThemeVariables(currentTheme);
}
}
}, [currentTheme]);
return (
<div>
<button onClick={() => setCurrentTheme("ayanami")}>凌波零</button>
<button onClick={() => setCurrentTheme("ikari")}>碇真嗣</button>
</div>
);
}

在這裡定義了兩個主題,分別是「零號機」與「初號機」的主題色,此後,就可以在 HTML 上使用了,例如:

<div class="bg-color-one"></div>

藉由抽換 <style> 的内容,將新切換的主題變數複寫進去,便可以主題切換了!

從瀏覽器的 Dev Tools 中,可以看到我們只抽換了 CSS 的全域變數,元件的部分都沒有異動

同場加映!「初音未來」跟「涼宮春日」的主題色!😘

若想看整個範例的結果,請前往這裡

範例原始碼,請參考這裡

我們利用 CSS 全域變數的特性,並搭配 Tailwind 的客制化顔色設定,實現了主題色切換的目的。優點非常顯著,我們在切換主題的時候,不用動用任何一行原本的 HTML、不用改動任何的 CSS class,也不用在元件裡面寫切換主題的邏輯,就可以快速達成主題切換的效果。

再來是 CSS 全域變數的部分,各主題的變數其實就是一個字串,上面的範例直接放在 JS 裡面,因此若遇到要更新主題顔色、或新增新主題色時,仍必須重新打包(build)一次前端的專案。若要避免重新打包,就要看 CI/CD 的架構策略,將這些字串移到別的地方,然而這不在我目前擅長的領域,也非本次討論的重點,就交由各位同學去發揮吧!

以上,Tailwind 主題切換分享到這裡,祝各位開發愉快 😎

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 做比較

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

最後附上範例連結