在網頁切換時要想實現音樂播放器的不間斷播放,常見的有這麼幾種解決方案:SPA,pajax,iframe。
以上這幾種我都沒用過,所以今天只介紹我自己在專案中嘗試的一種解決方案,用 Turbolinks。這種方案可用於傳統的非前後端分離的網站。
Demo
專案地址:GitHub
音樂和圖片來自 hey-Audio,感謝!
歌曲是從網易雲音樂 mp3 地址轉換所得。歌曲資訊僅供交流欣賞。網易雲音樂僅供試聽,不可用作商用。
Notes
Turbolinks
先來了理解一下 Turbolinks 技術,它是 Rails 專案中自帶的一項技術,但應該也可以用於其它後端語言中,因為它就是一個 JavaScript 的庫嘛 (但後端也有一點相應的邏輯,後面會講到)。
一句話概括它的作用,就是接管所有的 <a>
標籤 (除非顯式宣告不需要) 的點選事件,點選後,將預設的 html 請求變成 ajax 請求。實際上將一個傳統網站變成了接近 SPA,但又基本不改變你原來的寫法,極低的侵入性。
那好處是什麼呢,我們來進行一些對比。
在沒有 Turbolinks 的情況下,我們在頁面 A 中點選一個 <a>
連結 (假設是頁面 B),瀏覽器將發起一個常規的 html 請求,從伺服器獲取頁面 B 的 html 程式碼,然後解析這個 html 程式碼中包含的 JavaScript/CSS/Assets 的檔案,下載,並解析 JavaScript 程式碼並執行,顯示頁面 B。
一般來說,頁面 A 和 B 的 html head 程式碼是相同的,意味著包含的 JavaScript/CSS 也是一樣的,這些檔案在頁面 A 載入時已經下載過了,並被瀏覽器所快取,所以載入頁面 B 時,重新下載可能耗時不會太長,但重新解析 JavaScript 也會消耗一些時間 (有待 profile)。
但是,即使重新下載和解析 JavaScript/CSS 的時間不會太長,但還有一個問題,頁面 A 的記憶體狀態無法保持到頁面 B。當頁面 B 替換頁面 A 時,頁面 A 在記憶體中的資料會被完全清空。舉個例子,在頁面 A 的 JavaScript 程式碼中,我儲存了一個全域性變數 window.globalA = `haha`
,當頁面 B 載入時,這個值就被清除了。
或許你覺得我們可以用 cookie 或 localStorage 將頁面 A 的一些狀態傳遞到頁面 B,在大部分情況下是可以的,但對於我們想實現的音訊的不間斷播放,是不行的。
而 Turoblinks 可以解決上面三個問題。當點選 <a>
連結時,它將發起一個 ajax 請求,從伺服器獲取 html 程式碼,然後解析它的 head 部分,如果 head 和當前頁面相同,那麼:
- 不會重新下載 JavaScript/CSS/Assets
- 不會重新解析 JavaScript/CSS
- 不會清除當前頁面的記憶體
(不完全精確,但大體邏輯是這樣的。)
來看兩張截圖。
第一張截圖是在沒有使用 Turoblinks 的情況下,每次點選連結,都會重新下載各種資源。
第二張截圖是在使用了 Turbolinks 的情況下,點選鏈拉後,不會重新下載各種已經下載過的資源。
在 Rails 專案中 Turbolinks 是預設使用的,如果你不想用它,就把它從 application.js 中去掉。
// app/assets/javascripts/application.js
//= require rails-ujs
//= require activestorage
//= require turbolinks // ---> remove this line
//= require_tree .
複製程式碼
實現
首先我用 Rails + React + webpacker + react-rails 實現一個常規的服務端渲染的非前後端分離的網站。如果你好奇怎麼實現的,可以看這個連結 – 在 Rails 中使用 React 並實現 SSR 的一種實踐。當然,你也可以不用 React,我這裡只闡述一種思想。
如最開頭的截圖動畫所示,這個網站有兩個頁面,一個頁面是歌曲列表,一個頁面是單首歌曲的詳情頁,在這兩個頁面的底部都有一個音樂播放器。點選任何一首歌曲的 play 按鈕,將播放這首歌,且頁面間切換時,我期望播放器不會出現任何間斷。
實現極其簡單,因為 Turbolinks 可以幫助我們在切換頁面時保持記憶體狀態,因此我們只需要把 audio element 和當前狀態儲存到 window 全域性物件中即可,跳轉到新頁面後再讀取出來,that`s all。
AudioPlayer 的程式碼如下:
componentDidMount() {
if (!window._globalAudioEl) {
window._globalAudioEl = document.createElement(`audio`)
}
this.audioElement = window._globalAudioEl
this.audioElement.addEventListener(`canplay`, this.onCanPlay)
this.audioElement.addEventListener(`play`, this.onPlay)
this.audioElement.addEventListener(`pause`, this.onPause)
window.addEventListener(`play-audio`, this.playAudioEventHandler)
// recover state
if (window._globalAudioState) {
this.setState(window._globalAudioState, () => {
this.state.playing && this._setInterval()
})
}
}
componentWillUnmount() {
// save current state to window before it is unmounted
window._globalAudioState = this.state
window.removeEventListener(`play-audio`, this.playAudioEventHandler)
this._clearInterval()
this.audioElement.removeEventListener(`canplay`, this.onCanPlay)
this.audioElement.removeEventListener(`play`, this.onPlay)
this.audioElement.removeEventListener(`pause`, this.onPause)
// don`t pause it
// this.audioElement.pause()
this.audioElement = null
}
複製程式碼
特殊情況
因為 Turbolinks 只能處理 <a>
連結,而且 Turbolinks 將 click event listener 繫結在最頂層的 window 物件上,而不是直接在 <a>
上,所以以下幾種情況 Turbolinks 無法處理:
- 如果在
<a>
的 click handler 中執行了event.stopPropagation()
,阻止了事件冒泡,Turbolinks 將無法接收到此事件 - 通過表單 Form 發起的 GET 請求
- 通過表單 Form 發起的 POST 請求
我們得想辦法解決它們。
先來看第一種情況,在呼叫 event.stopPropagation()
阻止了事件冒泡後,請求將變回預設的 html 請求,我們可以呼叫 event.preventDefault()
阻止預設操作,並呼叫 Turbolinks.visit(url)
API 手動發出 ajax 請求。
程式碼:
songClick1 = (e) => {
// will stop event propagate to window, so turbolinks can`t handle this link
e.stopPropagation()
}
songClick2 = (e) => {
e.stopPropagation()
e.preventDefault()
window.Turbolinks.visit(e.target.getAttribute(`href`) + `?click`)
}
<span>This link execute event.stopPropagation() :</span>
<a href={`/songs/${firstSong.id}`}
onClick={this.songClick1}>
{firstSong.title}
</a>
<span>Resolution :</span>
<a href={`/songs/${firstSong.id}`}
onClick={this.songClick2}>
{firstSong.title}
</a>
複製程式碼
效果對比:
這裡面有一個細節,event.stopPropagation()
導致 Turoblinks 失效,你會覺得是理所當然的事情,但實際它阻止的是 React 合成事件 (SytheticEvent) 的冒泡,而不是原生事件 (NativeEvent) 的冒泡,而 Turbolinks 處理的是原生事件。如果你想了解原因,可以看這篇文章 – 從 Dropdown 的 React 實現中學習到的。
第二種情況,通過表單 Form 發起的 GET 請求,此例中我們建立了一個搜尋表單,通過 title 搜尋相應的歌曲。
有兩種解決辦法:
- 像上面一樣,在 Form 的 onSubmit 事件中,呼叫
event.preventDefault()
阻止預設提交,然後呼叫Turbolinks.visit(url)
API 手動傳送 ajax 請求。 - 將 GET 型別的 Form 轉換成
<a>
連結
示例程式碼:
queryUrl = () => {
return `/songs?q=${this.state.queryStr}`
}
submitQuery = (e) => {
e.preventDefault()
window.Turbolinks.visit(this.queryUrl())
}
render() {
...
<p>Speical Case 2 - Get Form</p>
<span>Origin Get Form :</span>
<form action=`/songs`>
<input type=`text` name=`q` placeholder=`song title`></input>
<input type=`submit` value=`search`></input>
</form>
<span>Resolution 1 : use window.Turoblinks.visit API</span>
<form action=`/songs` onSubmit={this.submitQuery}>
<input type=`text`
placeholder=`song title`
value={this.state.queryStr}
onChange={e=>this.setState({queryStr:e.target.value})}></input>
<input type=`submit` value=`search`></input>
</form>
<span>Resolution 2 : convert form to link</span>
<div>
<input type=`text`
placeholder=`song title`
value={this.state.queryStr}
onChange={e=>this.setState({queryStr:e.target.value})}></input>
<a href={this.queryUrl()}>search</a>
</div>
...
}
複製程式碼
效果對比:
再來看第三種情況,通過表單 Form 發起的 POST 請求,此例中我們建立了一個修改歌曲 title 的表單。解決辦法異常簡單,但背後的原理卻值得一說。
先來看解決辦法,只有一行程式碼,在 form 的屬性中加上 data-remote
(或者 date-remote={true}
)。整體程式碼如下:
<span>Resolution: data-remote Form</span>
<form action={`/songs/${firstSong.id}`} data-remote method=`post`>
<input type="hidden" name="_method" value="put"></input>
<input type=`text`
name=`title`
placeholder=`new song title`
defaultValue={firstSong.title}>
</input>
<input type=`submit` value=`update`></input>
</form>
複製程式碼
來看一下效果對比:
為什麼加了個 data-remote
屬性就 ok 了呢,是不是很神奇的感覺。這裡還有另外一個 JavaScript 庫起了作用,rails-ujs,也是 rails 預設啟用的。這個庫有挺多功能的,在這裡的作用是,將所有 data-remote
屬性為 true 的 form 表單的請求轉換成 ajax 請求 (xhr 即表示請求為 ajax),但注意,此時 Turbolinks 並沒有參與,還記得前面說的嗎,它只管 <a>
連結,其它的一概不關心。
但是,這僅僅解決了第一步,一般來說,POST 請求的結果都是一個 302 響應,瀏覽器要從響應頭中取出目標地址並訪問這個新的地址。
這裡服務端的程式碼:
# songs_controller.rb
def update
@song = Song.find params[:id]
@song.update(title: params[:title])
redirect_to @song
end
複製程式碼
這裡常規的 POST 請求的響應:
如果我們的 ajax 請求得到的也是這種響應,那麼跳轉無法自動實現。所幸的是,Rails 很智慧,它如果檢測到 POST 請求是 ajax 發起的,且 Turbolinks 啟用,在 redirect_to @song
中,它將不再返回 302,而是返回一段 JavaScript 程式碼:
Turbolinks.clearCache()
Turbolinks.visit("http://localhost:3000/songs/21", {"action":"replace"})
複製程式碼
ajax 請求得到這個響應後,因為響應型別是 text/javascript,因此它會執行這段 JavaScript 程式碼,而這段 JavaScript 呼叫了 Turoblinks 的 visit API,而這個 API 我們前面已經用過數次了,它實際呼叫 HTML5 history API 實現無重新整理的跳轉。
這也是前面所說 Turbolinks 雖然是一個 JavaScript 庫,但也是需要後端的一些配合的。