我負責的預約前台是用 Next.js 寫的,而不少會員是從店家的 LINE 官方帳號點進來,所以一開始就接了 LIFF,讓會員可以直接用 LINE 登入。之後我們又把登入整合到另一個網域的 SSO SPA,結果 LINE 的內建瀏覽器跟跨網域導向一碰在一起,就開始出現各種怪問題:卡在載入中、登入無限迴圈、跳出看不懂的錯誤視窗,而且很多都只在 Android 或只在 iPhone 才會發生。
這篇文章主要就是把這段時間在 LINE 上踩到的坑整理在一起,每個都附上症狀、原因跟解法。如果你也在做會從 LINE 裡打開的網站,希望能幫你少繞一點路。
症狀: 會員用 iPhone 的 Safari 打開網站,按下「登入」後會跳到 LINE App,接著又回到 Safari,但畫面上還是顯示「登入」按鈕,再按一次又是一樣的循環。
原因: 在外部瀏覽器走 LIFF 的 OAuth 時,iOS 會把登入交給 LINE App 處理。而 LINE 把控制權交回 Safari 時,是開一個新分頁,不是回到原本的分頁。但 OAuth 流程需要的資料(例如存在原分頁的 code verifier)在新分頁裡都拿不到,整條登入流程就斷了。
我試過幾種繞法,包括強制走外部瀏覽器的 OAuth、自己組 OAuth 網址加上不要開 App 的參數,都沒有用。
解法: 最後的決定是,只有「真的在 LINE App 裡」才走 LIFF 登入,外部瀏覽器一律改走簡訊登入。判斷的部分我沒有自己寫 UA 規則,而是直接用 LINE 官方的 @liff/is-in-client,它跟 liff.isInClient() 是同一套邏輯,而且不用先 liff.init():
import { isInClient } from '@liff/is-in-client';
export const isLiffInAppBrowser = (): boolean => {
// 它會同步讀 navigator 跟 sessionStorage,SSR 時要擋掉
if (typeof window === 'undefined') return false;
return isInClient();
};
另外順帶一提,LIFF 登入時的 redirectUri 要帶會員原本所在的頁面。我們曾經在某個情況下把 redirectUri 設成首頁,結果會員從 LINE 登入回來就掉回首頁,原本在看的分店頁面不見了。
liff.init() 會重新載入目前的網址症狀: 會員在 LINE 裡登入完,導回我們的 callback 頁,結果跳出登入失敗。看 API 紀錄,用一次性 code 換 token 的請求被送了兩次,第二次拿到 400。
原因: 我們每個頁面載入時都會跑 liff.init(),而 liff.init() 在 LINE 的內建瀏覽器裡可能會重新導向到目前的網址。如果剛好是在 callback 頁,就等於帶著同一個一次性 code 重新載入,code 就被兌換了兩次。
解法: 兩層都要做:
liff.init()。 這一頁只負責拿 code 換 token,根本不需要 LIFF。後面這個其實不只 LINE 需要,只要是一次性的 code(OAuth、magic link 之類的),都應該預設頁面可能會被重新載入。
liff.init() 還沒完成就改網址,Android 會不導向症狀: 頁面要導向到登入中心時,畫面會先蓋上一層「載入中...」。但在部分 Android 的 LINE 裡,這層畫面會永遠蓋著,網址完全沒變。
原因: LINE 自己的文件就有提醒,在同一個分頁裡 liff.init() 還在跑的時候就去改 location,有些 Android 的 LIFF WebView 會就這樣不導向。而我們的導向邏輯跟 liff.init() 是各自在不同的元件裡跑,剛好就賽跑了。
解法: 導向前先等 liff.init() 完成。但這裡有個陷阱:liff.init() 本身沒有 timeout,如果它卡住(例如 SDK 載不下來),無限期等待只是把「卡在載入中」從一個地方搬到另一個地方。所以我的做法是最多等 5 秒,時間到了就照樣導向:
const LIFF_INIT_WAIT_MS = 5_000;
useEffect(() => {
// 在 LIFF 裡,先等 liff.init() 完成再碰 location
if (isLiffMode && !liffReady && !waitElapsed) {
const timer = setTimeout(() => setWaitElapsed(true), LIFF_INIT_WAIT_MS);
return () => clearTimeout(timer);
}
startRedirect();
}, [isLiffMode, liffReady, waitElapsed]);
症狀: 就算已經等 liff.init() 完成了,有些 Android 的 LINE 還是會卡住。會員在登入中心按了取消後,回到 callback 頁也會停在一片空白。
原因: 有些 Android 的 LIFF 瀏覽器會靜靜地吞掉同一個分頁裡的跨網域 location.assign() 或 location.replace()。不會拋錯,分頁也還活著,就是不會跳過去。
解法: 這個我沒辦法從根本解決,只能「確認有沒有跳過去,沒有就再試一次」:
const REDIRECT_RETRY_MS = 2_000;
export const redirectWithRetry = (url: string, onStuck?: () => void) => {
window.location.assign(url);
// 如果 2 秒後還在這一頁,就再導向一次
setTimeout(() => {
window.location.assign(url);
// 重試也沒用的話,交給呼叫端顯示手動重試的按鈕
if (onStuck) setTimeout(onStuck, REDIRECT_RETRY_MS);
}, REDIRECT_RETRY_MS);
};
頁面真的導向成功的話,後面的 setTimeout 自然就不會執行,所以不用額外判斷。而 onStuck 是最後一道防線:如果畫面上有蓋一層「載入中」,至少要給會員一個按鈕可以自己重試,而不是永遠卡著。
init() 完成才能用症狀: 預約完成後,我們會用 liff.sendMessages() 幫會員在聊天室發一則預約摘要。但 Sentry 上一直有這個地方丟出來的錯誤。
原因: 程式裡用 liff.isLoggedIn() 來判斷能不能發訊息,但 liff 物件一 import 就存在,不代表它已經 init 過了。在 init 完成之前,或是根本沒有 init(例如會員是用外部瀏覽器開的),這些 API 就不能用。
解法:
liff 物件存不存在來判斷,而是自己記一個「init 完成」的狀態,只有在它為真的時候才呼叫其他 LIFF API。sendMessages() 這種「有做很好,沒做也不影響主流程」的功能,失敗時就記一筆 log 就好,不要讓它中斷預約流程。上面這些坑,花最多時間的其實不是修,而是找原因。整理幾個我覺得值得記下來的:
1. 不要對使用者 alert() 原始錯誤。 有一陣子會員在 LINE 裡會看到「TypeError: Load failed」、「Network Error」這種彈窗。一開始我以為是 LINE 導向時機的問題,試過在導向前延遲幾秒,但都沒用。最後在實機上查到,是 liff.init() 還在打 API 的時候,頁面就導向走了,被中斷的請求丟出例外,而程式裡剛好有一行 alert(e)。但其實 liff.init() 失敗時本來就會退回簡訊登入,那個彈窗對會員一點幫助都沒有,拿掉就好了。
2. 檢查自己的監控設定。 卡在載入中的問題,Sentry 上一筆錯誤都沒有。查了才發現 Sentry 的 ignoreErrors 把 Network Error、Load failed 這類錯誤全部忽略了,而且是每個環境都忽略,包括 staging。為了減少雜訊而忽略的錯誤,剛好就是我在找的那個。後來改成只有正式環境才忽略,其他環境全部送上來。
3. 有些錯誤是別人的。 Sentry 上有一類 window.webkit.messageHandlers 是 undefined 的 TypeError,查下來是第三方腳本(很可能是 GTM 裡設定的標籤)在 Instagram、Facebook 的內建瀏覽器裡,誤以為自己在原生 App 的 WebView,於是在 pagehide 時去呼叫不存在的原生橋接。發生時使用者已經離開頁面了,不影響體驗,而且也不是我們的程式碼,所以直接在 Sentry 過濾掉。
4. 實機上沒辦法開 DevTools,就把 console 放進頁面。 在非正式環境載入 vConsole 或 eruda,就能直接在 LINE 裡看 log,這在 LINE 的內建瀏覽器裡幾乎是必備的。
如果你的網站也會從 LINE 裡打開,可以對照這幾點:
@liff/is-in-client
redirectUri 要帶會員原本的頁面liff.init(),而且同一個 code 只兌換一次location 之前先等 liff.init() 完成,但要加等待上限alert() 原始錯誤,也要確認監控工具沒有把你要找的錯誤忽略掉LINE 的內建瀏覽器很多行為都跟一般瀏覽器不一樣,而且 Android 跟 iPhone 又各自不同。對我來說最大的收穫是:會在 LINE 裡用的功能,一定要在真的 LINE 裡、用兩種手機都測過一次。
參考資料: