小夥伴遇到這個問題說不想幹前端了——一次Chrome翻譯造成的玄學bug

突然有一個海外用戶反饋問題,說有一個頁面點擊新增按鈕就白屏。對方不會說中文,所以全程英文交流,用上了我摳腳的6級啞巴英語,溝通過程稍微麻煩一點。一開始聽到白屏,心裏還是毫無波動的,這種問題呢,無非就是某個接口數據返回不太科學,然後前端沒有容錯。只需要看見報錯信息必然可以秒解決

前排提示,現在掘金髮文的時候有違禁詞會發不出去。所以花了半小時發文章,反覆使用二分法排除定位違禁詞語,能發出去說明前面內容沒問題,然後再加一點內容繼續試。我還在發文章的時候,就看見200瀏覽了,給200個小夥伴道歉,那時候還在試敏感詞中,文章內容不完整,現在已經好了,可以回頭看了。例如"報錯"有時候需要改成"錯誤"才能過、頁面不能有emoji、"jiechi"也是違禁的(以後叫hijack吧)。這裏強烈建議,給出違禁詞清單、或者監測到違禁詞的時候彈出來提示一下

讓用戶打開控制檯

先讓用戶刷新再復現一遍,保持一直打開console的狀態下操作。再手把手截圖指導,如何打開console切到哪個面板,再讓對方截圖,結果是這樣的報錯:

這個就突然讓我有點懵逼了,竟然不是 cannot read property xx of undefined 這種報錯。細看一下,是react源碼的報錯:dispatch後setstate、觸發批量更新、執行調度。估計是中途有其他操作把dom節點改了,react瞬間懵逼。即使知道大概是這樣,但怎麼排查呢?那就先直接來撈接口數據,放本地跑一下看看能不能復現吧

引導用戶發response過來

經過一番摳腳英語交流和步驟截圖,終於讓用戶把相關接口的返回數據都發過來了。拿到了數據,那就到我表演了。我本地開始跑dev,再把這些接口全部代理到剛剛拿到的數據上

結果,居然正常運行,一切問題都沒有發生

接着我嘗試看看對方的錄屏,結果發現也沒什麼錯誤操作,唯獨就是點一下按鈕,就報錯了,而且還是同樣的react源碼內部的報錯,接口都正常。最後,決定讓用戶掃我電腦的碼,在我電腦登錄賬號

在我電腦登上了別人的號,開始一頓操作,來到同樣的頁面,點一下按鈕,結果又正常,什麼都沒有發生…小朋友,你是否有很多問號

遠程桌面

實在沒辦法了,我直接視頻通話打過去並要求屏幕分享。打通了,開始全程口語交流,摳腳的英語口語水平只能慢慢的講,估計對方勉強聽得懂吧。我重複了之前的操作,果然又出現了,來到同樣的頁面,點了按鈕,馬上報錯了。還是一樣的問題

於是開始打斷點,隨便操作了幾下,居然自己好了!??

後面刷新頁面,全都自然好了…

心累,暫時不管那麼多了,沒事就好了吧,事情就此爲止。

“looks fine for now. Thank you so much!”

事情再次出現

過了幾天,在愉快地寫需求的時候,突然被機器人拉羣,還是同樣的人,還是同樣的問題,只是不同的頁面鏈接了。先別急着動手,捋一下思路:

  • react源碼錯誤,必然是有react之外的原生dom操作
  • 確認過代碼,沒有任何其他原生dom操作
  • 對方在控制檯做了dom操作?不可能,無技術背景
  • 那隻能是瀏覽器插件、中間人注入(基本不可能優先級調最低)、翻譯
  • 忘了上次打斷點的事情吧,不能投機取巧

上次的經驗告訴我,直接遠程控制是最好的方法。於是馬上連上了遠程控制。檢查了一下瀏覽器插件,沒有什麼插件有影響——瀏覽器插件pass。確認一下是否翻譯,問了對方說有沒有開了翻譯,對方說沒有(遠程桌面看不見彈出菜單的,所以需要人家告訴我)

ok,人家說沒有翻譯,那我就假設這是實話。既然問題發生的根本原因就是有react之外的原生dom操作,那就是dom節點數很有可能不一樣。於是我在控制檯輸入了一下$$('*'),發現對方電腦上是2400個節點。在我電腦上輸一下,只有2000個節點。讓同事幫忙看看,一樣也是2000個節點。於是我決定對比一下第一個不一樣的節點是怎樣的,在對方的電腦控制檯上輸了一段簡單的腳本:

$$('*').reduce((acc, { tagName }) => `${acc}${tagName},`, '')

我:“could you please copy the txt and send me”

於是我拿到了用戶整個頁面所有的標籤字符串集合,在我打開的頁面的控制檯下,和我的對比一下:

var arr = otherHtml.split(',')
$$('*').findIndex(({ tagName }, i) => tagName !== arr[i])

發現index爲103,找到第103個節點,發現是一個link標籤,引入了translate.googleapis.com下的一個css,而且html這個標籤多了一個叫做translated-ltr的class。顧名思義,翻譯實錘了

於是,再繼續展開主內容,發現對方的頁面上多了很多font標籤!!

果然,還是開了翻譯,只是人家“覺得沒有開”。其實,很有可能是之前設置了一律翻譯,所以後面就一直不用管,所有的網站都會自動翻譯。接着讓用戶按照我的要求,將翻譯關掉。最後,多次重複的操作,問題也沒有出現了

其實,估計之前大家都是腳手架一把刷,並沒有注意到html的lang的值,而且我們這個系統都是英文的。於是出現了一個所有的內容都是英文的“中文”頁面,到了海外Chrome翻譯的邏輯就是,這是“中文”頁面,需要自動翻譯,然後就“英文翻譯成英文”,視覺上無變化,實際上dom節點已經多了很多font

<html lang="zh-cn">

爲什麼上次打斷點就沒事

於是我還是想看看爲什麼上次打斷點就沒事了,打開維基百科試一下,在開啓了翻譯的條件下打斷點會發生什麼。打開source面板,勾選了load事件

自動翻譯也開啓


刷新頁面,發現一進來的時候,一切安好,html標籤是這樣

<html class="client-js" lang="en" dir="ltr">

點了兩下下一步的時候,html標籤發生了變化,核心特徵:有translated-ltr類

<html class="client-js translated-ltr ve-not-available" lang="zh-CN" dir="ltr">

再看看element面板,很多font包裹


實際上這就是一個頁面load成功後,Chrome的翻譯功能去拉css和js回來、修改頁面內容的過程。覆盤一下上次能解決問題的斷點操作:

  • 我在報錯的發生前最後一個接口的返回打了斷點,勾選了error事件的斷點
  • 頁面進來,有一個cors報錯,error卡住。此時已經有請求出去了,斷點卡一下爭取到了時間(你看起來是pending,實際上response已經到你家門口了)
  • 再點下一步,前面的數據秒出,一瞬間又卡了,因爲最後一個接口也回來了
  • 此時還沒到拉翻譯資源的時候,但頁面已經展示完整。我點一下按鈕,成功越過翻譯導致的頁面元素錯亂。這是一個創建按鈕,創建成功了後面就是用戶自己操作了
  • 因爲創建是頻率稍微低一些的行爲,所以幾天內再無收到反饋
  • 出現問題通常是setstate後刪掉某個元素,那個元素追溯不到報錯了。這裏點了按鈕的確是會刪掉按鈕並切換頁面內容

看看react具體怎樣纔會報錯

繼續來作死,一起看看怎麼樣才能把react玩壞


const { useState, useLayoutEffect } = React;
export default function App() {
  useLayoutEffect(() => {
    const font = document.createElement("font");
    const app = document.querySelector(".App");
    // 製造font包裹的效果,模擬翻譯的效果,破壞原有結構
    while (app.firstChild) {
      font.appendChild(app.firstChild);
    }
    app.appendChild(font);
    setTimeout(() => {
    // set個state看看
      setShow(false);
    }, 1000);
  }, []);
  const [show, setShow] = useState(true);
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      {show && (
        <>
          123123
          <h2>Start editing to see some magic happen!</h2>
        </>
      )}
    </div>
  );
}

預期效果出現了:

其實也不需要手動改,你只需要右鍵開啓翻譯爲中文就可以復現了。問題根源在於react提前把parentNode存起來了,所以操作的時候找不到子節點

解決方法

錯誤邊界組件

利用react的兩個生命週期來感知翻譯錯誤,然後展示兜底ui,提示用戶關掉翻譯。並給出操作文檔鏈接。使用的時候只需要用TranslateErrorBoundary包一下組件即可

class TranslateErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { translateError: false };
  }

  static getDerivedStateFromError() {
    if (document.documentElement.classList.contains("translated-ltr")) {
      return { translateError: true };
    }
  }

  componentDidCatch(e, info) {
  // 上報翻譯錯誤
    report(e, info);
  }

  render() {
    if (this.state.translateError) {
      return (
        <>
          <strong>
            translate error! you' d better to turn your google-translate off and reload. see
          </strong>
          <a
            target="_blank"
            rel="noopener noreferrer"
            href="文檔鏈接"
          >
            文檔
          </a>
        </>
      );
    }
    return this.props.children;
  }
}

// usage
<TranslateErrorBoundary>
    <Cpn />
</TranslateErrorBoundary>

不要讓一塊可刪改的react元素最外層存在文本節點

話不多說,看🌰

<div className="App">
  <h1>Hello CodeSandbox</h1>
  {show && (
    <>
      123123
      <h2>Start editing to see some magic happen!</h2>
    </>
  )}
</div>

這一塊,有最外層的123123文本節點,所以翻譯了會報錯:

{show && (
  <>
    123123
    <h2>Start editing to see some magic happen!</h2>
  </>
)}

爲什麼呢?先看看翻譯後結果,發現原本想刪的節點是"123123",而他父節點卻再也找不到它了

{show && (
  <>
    <font><font>123123</font></font>
    <h2><font><font>Start editing to see some magic happen!</font></font></h2>
  </>
)}

改正措施: 加上span標籤,不要讓123123裸露

{show && (
  <>
    <span>123123</span>
    <h2>Start editing to see some magic happen!</h2>
  </>
  )
}
// 翻譯後

{show && (
  <>
    <span><font><font>123123</font></font></span>
    <h2><font><font>Start editing to see some magic happen!</font></font></h2>
  </>
  )
}

因爲最外層的是span,所以即使加了font,也是在span內部加了,刪除元素的時候找的是span,都不會出問題

再看一個🌰

  <div>
    {label !== undefined ? (
      <div>
        {label}
      </div>
    ) : null}
    {children}
  </div>

這裏的話,label就是純文本。經過上面的例子,相信大家都知道{label}那裏要套一個span了。但是這還是有風險:如果這個組件對外部使用,外部靠children傳進來,意味着children的內容是多變的,比如傳一個字符串進來,setstate後是一個其他節點,那麼問題再次出現

錯誤條件再次重複一遍:一塊可刪改的react元素最外層存在文本節點。此時children是一塊元素,而且是可變的,最外層就是children這個對象的最外層所有節點,其中存在一個文本節點是字符串,因此滿足出錯條件

例如children是文本節點textNode1,那麼正常情況下setstate後如果children發生變化,刪掉textNode1的方式就是textNode1ParentNode.removeChild(textNode1)。如果翻譯了,文本節點包了兩層font,那麼textNode1再也不是textNode1ParentNode的子節點了。此外,即使把外層div換成span、section、article同理,都會出錯

推論:不要在任何元素下直接裸露可變文本節點

代碼都是自己寫的,像props.children這種那麼靈活的,尤其是要注意一下,如果是可能有文本節點的最好包一個span,確認沒有的就可以不用包,防止外國用戶翻譯後源碼出錯。其實可以寫一個工具,掃一下ast,發現有裸露文本節點的自動包一層span

要不,提個issue問問react那邊可不可以不把parent節點先存起來,刪元素的時候直接node.parentNode.removeChild?

總結

  • 使用數據驅動視圖的框架如react、vue,如果遇到源碼錯誤,考慮一下是不是有原生dom操作打亂了
  • 如果確認不是原生dom操作導致,考慮一下瀏覽器插件、翻譯
  • 確實需要在react、vue中使用原生操作,需要考慮到這個隱患
  • 國際化的業務,如果出現這種問題,建議首先從瀏覽器翻譯開始排查
  • 不要讓一塊可刪改的react元素最外層存在文本節點,確認會有可變文本節點,需要套一層span

純寫需求寫業務無聊?那一起搞事情鴨。關注公衆號《不一樣的前端》,以不一樣的視角學習前端,快速成長,一起把玩最新的技術、探索各種黑科技

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章