本文作者:Mr.Luo ,貝聊前端經理,作者部落格 mrluo.life 。
從今年(2017年)年初起,我們團隊開始引入「Vue.js」開發移動端的產品。在某個專案的測試過程中,測試妹子跟我們反饋了一個奇怪的bug:在一個播放音樂的頁面中,有一個地方同步顯示音樂的當前播放位置;音樂開始播放後,這個地方的內容會不斷改變,但是滾動頁面後,內容卻不再變化,看起來像是某個環節被阻塞了。
這個問題只在我們iOS的客戶端內出現,在微信和Safari內卻毫無問題,這讓我們一度懷疑是受到客戶端某些程式碼的影響。但仔細排查過後,發現問題並沒有這麼簡單。
iOS中的WebView
iOS中的WebView有兩種:UIWebView和WKWebView。
WKWebView是從iOS 8開始提供的,除了帶來了更好的效能與更少的記憶體佔用外,它還改良了在UIWebView裡面的一些不好的體驗,比如scroll事件的觸發。在UIWebView內,只會在滾動完全停止後才會觸發scroll事件;而在WKWebView內,則是在滾動過程中不斷觸發。
然而,WKWebView並非向下相容UIWebView,更換成本不小,所以仍然有相當一部分的APP還在使用UIWebView,例如我們貝聊的APP,以及新浪微博。
即便如此,我們讓iOS組的同事臨時用一個WKWebView開啟問題頁來測試,卻是很簡單的事情。實測結果是:在WKWebView內不會有阻塞問題發生。
Demo
為了更好地重現這個問題,我們做了一個demo頁,關鍵程式碼如下:
<template>
<div>
<audio ref="player" :src="audioURL" @timeupdate="updateTime" controls></audio>
<div class="current-time">{{ time }}</div>
</div>
</template>
<script>
export default {
data() {
return {
audioURL: require(`./music.mp3`),
time: ``
};
},
methods: {
updateTime() {
this.time = this.$refs.player.currentTime;
document.title = this.time;
}
}
};
</script>
複製程式碼
頁面功能非常簡單,播放音樂的時候,通過timeupdate事件去更新資料欄位「time」的值,從而把當前播放位置不斷地更新到介面上。同時,也把「time」的值更新到頁面標題,這樣做的目的是檢查「time」的賦值是否成功。
用新浪微博APP開啟此頁,執行效果如下:
可以看到,滾動頁面結束後,頁面內的數字不再更新,但是標題還在繼續變化。這說明了timeupdate事件是在不斷觸發的,「time」欄位的值也是在不斷更新,但是資料變化後更新到介面(重新整理DOM)的過程被阻塞了。
被阻塞的其實是…
恰巧,我們在出現bug的產品頁中發現了另一個現象:出現阻塞問題後,頁面中呼叫客戶端的功能也被阻塞了。這又讓我們懷疑是客戶端的鍋,但後來發現並不是。我們把客戶端的功能呼叫都封裝成了Promise,在除錯過程中,我們發現該Promise例項既無法進入then的流程,也沒有進入catch的流程。
我們開始懷疑被阻塞的是Promise,於是就在demo中增加兩個按鈕「Button1」和「Button2」:
<template>
<div>
<audio ref="player" :src="audioURL" @timeupdate="updateTime" controls></audio>
<div class="current-time">{{ time }}</div>
<input type="button" value="Button1" @click="click1" />
<input type="button" value="Button2" @click="click2" />
</div>
</template>
<script>
export default {
data() {
return {
audioURL: require(`./music.mp3`),
time: ``
};
},
methods: {
click1() { alert(`click1`); },
click2() {
Promise.resolve().then(() => {
alert(`click2`);
});
},
updateTime() {
this.time = this.$refs.player.currentTime;
document.title = this.time;
}
}
};
</script>
複製程式碼
就如料想的那樣,點選播放音樂並滾動頁面後,點選「Button1」彈出了「click 1」,但是點選「Button2」卻沒有任何響應。這證明了被阻塞的確實就是Promise了。
罪魁禍首竟然是…
找到了問題,就去搜尋引擎找答案,但竟然搜到了「Vue.js」的原始碼。在本地開啟該檔案,也確實有這片程式碼:
從這裡的註釋可以發現,「Vue.js」的開發團隊也知道Promise在UIWebView下的阻塞問題,並進行了修復,但為什麼在demo頁中仍然有問題呢?
排查bug很重要的一點就是儘量減少重現問題所需的程式碼和依賴。於是,我用「Vue-CLI」初始化一個新專案,並把demo頁放到此專案中。此時再用新浪微博開啟頁面進行同樣的操作,並沒有出現阻塞的問題。
然後,把專案中用到的「SASS」、「postcss-px2rem」、「Vuex」和「babel-polyfill」依次安裝,並在每次安裝後都重新開啟demo頁進行操作。最後發現,裝完「babel-polyfill」之後問題就重現了。
babel-polyfill
iOS 8以上的Safari和WebView都已經支援Promise,但是實測發現,「babel-polyfill」會用自己的Promise覆蓋原生的Promise!檢視「babel-polyfill」所依賴的「corejs」的程式碼可以發現,它對Promise的特性檢查比較嚴格:
由於iOS下的Promise並沒有完全支援這些特性,所以「corejs」用自己的Promise把原生的Promise覆蓋了。而且,看起來「Vue.js」對阻塞問題的修復對「corejs」的Promise無效。
解決方案
解決方案有三個:
- 不要安裝「babel-polyfill」,但這樣會造成舊版本瀏覽器下無法執行「Vuex」。
- 把UIWebView更換為WKWebView,但這不是短期內可以完成的事情。
- 載入「babel-polyfill」後,把瀏覽器的Promise重置回原生的Promise。
考慮到那些額外的特性在實際開發中基本用不上,方案3反而是一種比較好的臨時解決方案。
先調整「babel-polyfill」的引入方式,把它的程式碼檔案通過其他方式傳到伺服器上。然後修改專案入口檔案,也就是根目錄下的「index.html」:
<script>
var _Promise;
// 檢查是否iOS9+(iOS9+才支援Symbol)
var useNativePromise = typeof Promise === `function` &&
/^(iPhone|iPad|iPod)/.test(navigator.platform) &&
typeof Symbol === `function`;
if (useNativePromise) { _Promise = Promise; }
</script>
<script src="//s2.imgbeiliao.com/assets/js/lib/babel-polyfill/6.23.0/polyfill.min.js"></script>
<script>
if (_Promise) { Promise = _Promise; }
</script>
複製程式碼
上面程式碼的流程就是:檢查到是iOS>=9時,就把原生Promise儲存下來,待「babel-polyfill」載入執行完之後,再把儲存下來的Promise覆蓋回去。那iOS<9的怎麼辦呢?測試妹子很不容易找到了一臺iOS 8的iPhone來測試,結論是不會出現阻塞問題,所以iOS<9可以不用管了。
既然「babel-polyfill」已通過script標籤引入,那就可以刪除對它的依賴了:
npm uninstall babel-polyfill --save
複製程式碼
然後修改「/build/webpack.base.conf.js」,移除「babel-polyfill」的打包入口:
entry: {
// app: [`babel-polyfill`, `./src/main.js`]
app: [`./src/main.js`]
}
複製程式碼
這種臨時的解決方案其實並不優雅,讓客戶端儘快更換為WKWebView才是正道。
後記
最近蘋果釋出了iOS 11。在iOS 11的WebView中,Promise已經是完全體,可以通過「corejs」的特性檢查,所以不會再有這個阻塞的問題。