前言
從問題到方案,bingo。今日前端早讀課文章由58@莫日根授權,公號:58本地服務FE分享。
@莫日根,58本地服務高級前端工程師,一個活潑開朗的內蒙大男孩兒,喜歡各種球類活動。
正文從這開始~~
背景
在到家 APP 的內容後台中,需要使用到富文本編輯器。而在初期的內容發布活動中,對富文本編輯器的要求並不是很高,只需要滿足簡單的排版與編輯功能即可。但隨着內容愈加豐富,排版與編輯的要求也隨之越來越高,於是便產生了這一次富文本編輯器的升級需求。
內容後台早期使用的富文本編輯器為 wangEditor v4,關於 wangEditor 這裡就不多做贅述,只需知道它是一個輕量級富文本編輯器,對國內開發者友好,能夠很快上手使用,非常適合內容後台的應用場景。由於 wangEditor v4 仍舊使用 document.execCommand API,正好藉此機會將之升級到 v5,一個以 slate.js 為內核,具備 L1 能力的富文本編輯器。同時,v5 比 v4 具有更豐富的編輯選項,在功能需求上也能更好的服務於我們的運營同學。
按照一般的開發經驗來說,對於富文本編輯器這一類的第三方工具升級,除了對工具的 API 進行必要的功能開發之外,通常不會有其他的影響。但很不湊巧的是,這一次升級富文本編輯器,暴露了一個長期存在但是十分隱蔽的問題。
二級標題富文本編輯器升級暴露的問題
由於我們內容後台的圖文內容結構比較特殊,一篇內容中可以插入多正文內容,也就是說在圖文編輯的頁面中可能同時會存在多個富文本編輯器。在升級完富文本編輯器之後,在頁面中添加多個正文內容(即插入多個富文本編輯器,一個富文本編輯器對應一個正文內容),此時,如果選中第一個富文本編輯器,則後面創建的富文本編輯器都會從頁面中消失。如下圖所示:
第二段內容的消失
在我們的內容後台頁面中,使用的是 React Hook,也是 React 官方推薦的使用方式。其中正文內容為一個組件,其中維護一個名為 contentList 的數據,用於表示當前具有的內容。在正文內容的層級之下,還有富文本編輯器組件與圖片上傳組件等子組件。富文本編輯器組件在監聽到 change 事件時,會通過回調函數的方式,向父組件,也就是正文內容組件傳遞信息,同時在正文內容組件中對 contentList 數據進行維護。
那麼為什麼使用 v4 版本富文本編輯器時沒有遇到這個問題,而使用 v5 版本時會遇到呢?
經過驗證,在進行上述操作時,v5 版富文本編輯器在焦點變換的時候,不但會觸發 focus/blur 事件,同時還會觸發 change 事件,而我們在組件中並沒有對 focus 和 blur 事件進行監聽,也就是說 change 事件的觸發會導致出現上面的問題。當然,只是觸發富文本編輯器的 change 事件還不夠,另外一個條件是,當頁面存在多個富文本編輯器時,必須要觸發前面的富文本編輯器的 change 事件。
由於 v4 版本富文本編輯器僅僅在對內容進行編輯時才會觸發 change 事件,而運營同學的習慣往往又是正向順序編輯操作,所以這個問題也被掩蓋了起來。
問題的原因是什麼
我們來一步一步分析整個操作流程中,究竟發生了哪些事情。為了方便觀察,在一些關鍵節點添加了控制台的打印信息。
先來執行過程的第一步,點擊「添加正文」按鈕,此時頁面會在正文內容組件中創建一個富文本編輯器子組件。可以看到,頁面在打開後經過了若干次渲染,而正文內容組件則被渲染了 6 次。點擊按鈕後,由於更新了 contentList 數據,正文內容組件又進行了幾次渲染,其中創建第一個富文本編輯器發生在第 7 次渲染過程中。同時,第一個富文本編輯器在創建完成之後觸發了一次 change 事件,並調用了正文內容組件的回調函數,而且渲染次數與創建時的渲染次數是相同的。
生成第一個內容時的渲染過程
接下來創建第二個富文本編輯器。同樣地,更新過 contentList 數據之後,正文內容組件也進行了幾次渲染,而第二個富文本組件的創建則發生在第 11 次渲染過程之中。與創建第一個富文本編輯器一樣,第二個富文本編輯器在創建完畢之後,正文內容組件的回調函數也被調用了一次。
生成第二個內容時的渲染過程
這時,將光標移入第一個富文本編輯器中,觸發它的 change 事件。可以看到,所觸發的正文內容組件回調函數,仍舊是第一個富文本編輯器第一次創建時所在的渲染過程,也就是在第 7 次渲染過程。
觸發第一個富文本編輯器 change 事件時的渲染過程
由於正文內容組件中富文本編輯器的個數是由 contentList 數據來控制的,顯而易見地,在創建一個富文本編輯器後,contentList 中有了一條數據;創建第二個富文本編輯器後,contentList 中有了兩條數據;在觸發第一個富文本編輯器的 change 事件後,contentList 中又只剩下了一條數據,這時頁面中僅存在一個富文本編輯器了。
從上面的表現,再結合我們對 React 的了解,相信很多同學已經猜到問題究竟出在哪裡了。
React 元素渲染的特點
在 React 的官方文檔中,我們可以看到對元素渲染更新的說明:
React 元素是不可變對象。一旦被創建,你就無法更改它的子元素或者屬性。一個元素就像電影的單幀:它代表了某個特定時刻的 UI。
React DOM 會將元素和它的子元素與它們之前的狀態進行比較,並只會進行必要的更新來使 DOM 達到預期的狀態。
綜上所述,React 渲染機制的特點就是:每一幀都擁有獨立的狀態。如何理解這個特點,我們先來看一個 React 的例子:
function Parent() { const [count, setCount] = setState(0); const tick = () => { setCount(count + 1); }; setInterval(tick, 1000); return ( <div> <Child count={count} /> </div> );}function Child(props) { const { count } = props; const handleClick = () => { setTimeout(() => { alert(count); }, 5000); }; return ( <div> <p>{count}</p> <button onClick={handleClick}>alert button</button> </div> );}
這段代碼中,創建了一個名為 Parent 的函數組件和一個名為 Child 的函數組件,其中 Child 組件的 count 屬性由 Parent 組件傳入,初始值為 0,每隔一秒增加 1。點擊 Child 組件中的「alert count」按鈕,將延遲 5 秒彈出 count 的值。實際操作後會發現,彈窗中出現的值,與頁面中展示的 count 值並不相同,而是等於點擊按鈕那一時刻 count 的值。
由於 Child 是函數組件,在每一次渲染時,都會接收一個 props 參數,這個 props 是函數作用域下的變量。當 Child 組件被創建時,執行類似如下的代碼完成一次渲染:
const props_0 = { count: 0 };const handleClick_0 = () => { setTimeout(() => { alert(props_0.count); }, 5000);};return ( <div> <p>{props_0.count}</p> <button onClick={handleClick_0}>alert count</button> </div>);
當 Parent 組件傳入的 count 變為 1,React 會再次調用 Child 函數,執行第二次渲染,這個時候 count 的值是 1:
const props_1 = { count: 1 };const handleClick_1 = () => { setTimeout(() => { alert(props_1.count); }, 5000);};return ( <div> <p>{props_1.count}</p> <button onClick={handleClick_1}>alert count</button> </div>);
由於 props 是 Child 函數作用域下的變量,可以說對於這個函數的每一次調用,都產生了新的 props 變量,它在聲明時被賦予了當前的屬性,他們相互間互不影響。
換一種說法,對於其中任意一個 props ,它的值在聲明階段便已經決定,不會隨着時間發生變化。handleClick 函數也是如此。因此,雖然定時器的回調函數是在未來發生的,但 props.count 的值是在聲明 handleClick 函數時就已經決定好的。
例如,在第 1 秒的時候點擊「alert count」按鈕,此時 props.count 的值為 1,handleClick 函數中的 count 的值也為 1。當時間到達第 6 秒時,props.count 的值變為了 6,此時剛剛執行的定時器回調函數開始執行,閉包中的 count 的值仍舊為 1,頁面彈窗中出現的值就是 1。
如何解決這個問題
回過頭來,再看我們內容後台遇到的這個富文本編輯器的問題,可以直觀的感受到與上面提到的例子如出一轍。查看 wangEditor 的文檔,發現其中有這麼一句話:
使用 vdom 技術(基於 snabbdom.js )做視圖更新,model 和 view 分離,增加穩定性。
由於富文本編輯器為了提升視圖渲染的穩定性,引入了虛擬 DOM 技術,而虛擬 DOM 技術往往是通過對比兩次數據的異同來判斷是否需要更新視圖的,由此可以斷定,正是在 React 中使用了虛擬 DOM 技術,導致了富文本編輯器在頁面多次渲染之後仍保留了創建時的狀態。
要解決上述問題,需要引入 useRef。useRef 通常有兩種作用:一是作為多次渲染之間的紐帶;二是獲取 DOM 元素。這裡我們只需要 useRef 的第一種作用。我們先來看看 useRef 在 React 返回值的類型定義:
interface MutableRefObject<T> { current: T;}
可以看到 useRef 返回值是一個包括屬性 current 類型為范型 的一個object。它與直接在函數組件中定義一個{ current: null }的區別就是:useRef會在所有的render中保持對返回值的唯一引用。因為所有對 ref 的賦值和取值拿到的都是最終的狀態,並不會因為不同的render而存在不同的隔離。也就是說,我們可以把useRef的返回值想象成一個全局變量。
我們來改寫一些上面的代碼:
function Child(props) { const { count } = props; const countRef = useRef(count); const handleClick = () => { setTimeout(() => { alert(countRef.current); }, 5000); }; return ( <div> <p>{count}</p> <button onClick={handleClick}>alert button</button> </div> );}
此時再按照之前的步驟去操作,會發現彈窗展示出的值就是最新的值,與頁面是保持一致的,而並非是渲染隔離的值。
同樣地,在我們的內容後台中使用 useRef,也能確保正文內容組件中 contentList 始終保持最新,在多次觸發 change 事件之後,不會有頁面異常的情況出現了。
總結
對於大多數場景來說,我們可能對 React 元素渲染的特性感知並不明顯,但是當真正遇到問題時,就需要提高對 React 的理解了。
React 元素渲染的特點是每一幀都擁有獨立的狀態,而 useRef 則是 React 提供的一種跨越元素渲染的另一種機制。我們可以將 useRef 的返回值看作是一個組件內部的全局共享變量,它會在組件渲染間共享一個相同的值。
參考資料:
Hook API 索引 - React:https://react.docschina.org/docs/hooks-reference.html#useref
React Hook 最佳實踐:https://react.docschina.org/blog/2020/05/22/react-hooks.html
細說 React 中的 useRef:https://juejin.cn/post/6996171186719686693
關於本文作者:@莫日根原文:https://mp.weixin.qq.com/s/m6U580CdQjAcWNXdA8Aoug
相關閱讀。歡迎自薦投稿,前端早讀課等你來。