2024-01-28|閱讀時間 ‧ 約 35 分鐘

[FE] Learn from react-wrap-balancer

此套件主要是使字體擁有更好的閱讀體驗,它會將每一行的文字都有差不多的長度,達成更好的閱讀體驗。
https://react-wrap-balancer.vercel.app
raw-image

使用方式

import Balancer from "react-wrap-balancer";

<h1>
<Balancer>My Title</Balancer>
</h1>;

codesandbox

Text Wrap: balance

此套件如果瀏覽器可以使用到原生的 CSS text-wrap: balance  的話,就會使用 native 的方式,不支援才會透過計算來達成。

這是一種 CSS 原生的文字排版方式,用途一樣是為了讓文字更容易閱讀,身為開發者是無法預測最終呈現的文字是什麼(i18n, 文字的字體等),但是瀏覽器可以,所以透過瀏覽器的實作去計算成最容易閱讀的方式。這實作是需要耗費成本的,所以並不推薦將所有的文字都加上,並且瀏覽器也有限制最多只會影響到 6 行的文字。

h1,
h2,
h3,
h4,
h5,
h6,
blockquote {
text-wrap: balance;
}

另外此種方式與 white-space  是衝突的, balance  會造成換行,所以不應該一起使用。

套件中使用 JS 的 CSS.suports  來檢查是否支援使用此方式。

const isTextWrapBalanceSupported = `(self.CSS&&CSS.supports("text-wrap","balance")?1:2)`

useIsomorphicLayoutEffect

常見的 Server Side 和 Client Side 的 effect 實作方式,如果在 Server Side 的話,因為沒有 DOM 元素,所以如果使用 useLayoutEffect  會沒有實際的作用,因此改使用 useEffect 。

export const IS_SERVER = typeof window === "undefined";

export const useIsomorphicLayoutEffect = IS_SERVER
? React.useEffect
: React.useLayoutEffect;

useIdPollyfill

react 中可以使用 useId  來獲取唯一的 ID 值,不過在舊版並沒有此 hook,所以在套件中另外實作了此 hook 的 pollyfill。

let ID = 0

const genId = () => ++IDlet serverHandoffComplete = falsefunction useIdPolyfill() { const [id, setId] = React.useState(serverHandoffComplete ? genId : undefined) useIsomorphicLayoutEffect(() => { if (id === undefined) { setId(genId()) } serverHandoffComplete = true }, []) if (id === undefined) { return id } return `rwb-${id.toString(32)}`}

其中 genId 使用到了 js 的 closure 的概念,它獲取外部的 ID 變數並進行修改。

在使用時檢查開發者目前的 react 是否有支援 useId 的 hook,若沒有的話則使用實作的 useIdPollyfill 。

export function useId() {

const implementation = React.useMemo((): (() => string | number) => {
if ('useId' in React) return React.useId
return useIdPolyfill
}, [])
return implementation()
}

Polymorphic Component

這是一種由外部決定 Component 要使用什麼 HTML Tag 的方式,並透過 TypeScript 讓開發有更好的體驗,可以知道有哪些屬性可以傳遞。

codesandbox

eact 提供了 ComponentPropsWithoutRef 傳入 generic type 獲取該 Component 有哪些的 props 可以傳遞,聽夠過 Omit 移除已經定義的屬性。

interface BalancerOwnProps<

 ElementType extends React.ElementType = React.ElementType

> extends React.HTMLAttributes<HTMLElement> {

...

}



type BalancerProps<ElementType extends React.ElementType> =

 BalancerOwnProps<ElementType> &

   Omit<React.ComponentPropsWithoutRef<ElementType>, keyof BalancerOwnProps>

props nonce

因為此套件會動態的載入 <script> ,而在 CSP(Content Security Policy) 不允許有在 HTML 內的 <script> Tag,除非有設定 nonce 屬性。

const createScriptElement = (
injected: boolean,
nonce?: string,
suffix: string = ''
) => {
if (suffix) {
suffix = `self.${SYMBOL_NATIVE_KEY}!=1&&${suffix}`
}
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
// Calculate the balance initially for SSR
__html:
(injected
? ''
: `self.${SYMBOL_NATIVE_KEY}=self.${SYMBOL_NATIVE_KEY}||${isTextWrapBalanceSupported};self.${SYMBOL_KEY}=${RELAYOUT_STR};`) +
suffix,
}}
nonce={nonce}
/>
)
}

suppressHydrationWarning 是指產生的內容和在 Server Side 產生的不相同的話不需要提示。

在此 Script 會執行

// self.${SYMBOL_NATIVE_KEY}=self.${SYMBOL_NATIVE_KEY}||${isTextWrapBalanceSupported}
self.__wrap_n = self.__wrap_n || isTextWrapBalanceSupported;
// self.${SYMBOL_KEY}=${RELAYOUT_STR};
self.__wrap_b = RELAYOUT_STR
// suffix: self.${SYMBOL_KEY}("${id}",${ratio})
relayout(id, ratio)

relayout

這是此套件計算方式的 function。

const relayout: RelayoutFn = (id, ratio, wrapper) => {

}

傳入的 id , ratio , wrapper ,其中 wrapper 指的是會包住我們內容的 Element,預設是 span 。

  wrapper =
wrapper || document.querySelector<WrapperElement>(`[data-br="${id}"]`)
const container = wrapper.parentElement

const update = (width: number) => (wrapper.style.maxWidth = width + 'px')

// Reset wrapper width
wrapper.style.maxWidth = ''

這一段若是沒有給 wrapper 的話會透過 querySelector 獲取,並重設 wrapper  element 的 maxWith 。

// Get the initial container size
const width = container.clientWidth
const height = container.clientHeight
// Synchronously do binary search and calculate the layout
let lower: number = width / 2 - 0.25
let upper: number = width + 0.5
let middle: number

if (width) {
// Ensure we don't search widths lower than when the text overflows
update(lower)
lower = Math.max(wrapper.scrollWidth, lower)

while (lower + 1 < upper) {
middle = Math.round((lower + upper) / 2)
update(middle)
if (container.clientHeight === height) {
upper = middle
} else {
lower = middle
}
}

// Update the wrapper width
update(upper * ratio + width * (1 - ratio))
}

這一段比較長,主要是使用 binary search 找到最適合的寬度,若是 lowerupper 差不止 1px 就會執行,如果父元素原本的高度(height)和後來的高度(container.clientHeight) 相同會將 upper 設為 middle ,因為在上方已經將 wrapper 的 maxWith 進行更改,所以可能會造成 wrapper 內的‘文字換行造成父元素的高度也改變,若沒有造成換行的話則將 lower 設為 middle 。

之後在依照我們給的 ratio 計算新的 maxWidth ,如果 ratio 為 1 就會使用 upper 的值,0 則使用原本的值。

 if (!wrapper['__wrap_o']) {
if (typeof ResizeObserver !== 'undefined') {
;(wrapper['__wrap_o'] = new ResizeObserver(() => {
self.__wrap_b(0, +wrapper.dataset.brr, wrapper)
})).observe(container)
} else {
// Silently ignore ResizeObserver for production builds
if (process.env.NODE_ENV === 'development') {
console.warn(
'The browser you are using does not support the ResizeObserver API. ' +
'Please consider add polyfill for this API to avoid potential layout shifts or upgrade your browser. ' +
'Read more: https://github.com/shuding/react-wrap-balancer#browser-support-information'
)
}
}
}

之後若沒有 ResizeObserver 的話,建一個新的去監聽 wrapper 的 size 變化重新去執行。

分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.