此套件主要是使字體擁有更好的閱讀體驗,它會將每一行的文字都有差不多的長度,達成更好的閱讀體驗。
https://react-wrap-balancer.vercel.app
import Balancer from "react-wrap-balancer";
<h1>
<Balancer>My Title</Balancer>
</h1>;
此套件如果瀏覽器可以使用到原生的 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)`
常見的 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;
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()
}
這是一種由外部決定 Component 要使用什麼 HTML Tag 的方式,並透過 TypeScript 讓開發有更好的體驗,可以知道有哪些屬性可以傳遞。
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>
因為此套件會動態的載入 <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)
這是此套件計算方式的 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 找到最適合的寬度,若是 lower
和 upper
差不止 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 變化重新去執行。