藉助 Turbolinks 實現不間斷的網頁音樂播放器

YouKnowNothing發表於2019-02-19

在網頁切換時要想實現音樂播放器的不間斷播放,常見的有這麼幾種解決方案:SPA,pajax,iframe。

以上這幾種我都沒用過,所以今天只介紹我自己在專案中嘗試的一種解決方案,用 Turbolinks。這種方案可用於傳統的非前後端分離的網站。

Demo

藉助 Turbolinks 實現不間斷的網頁音樂播放器

專案地址: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 和當前頁面相同,那麼:

  1. 不會重新下載 JavaScript/CSS/Assets
  2. 不會重新解析 JavaScript/CSS
  3. 不會清除當前頁面的記憶體

(不完全精確,但大體邏輯是這樣的。)

來看兩張截圖。

第一張截圖是在沒有使用 Turoblinks 的情況下,每次點選連結,都會重新下載各種資源。

藉助 Turbolinks 實現不間斷的網頁音樂播放器

第二張截圖是在使用了 Turbolinks 的情況下,點選鏈拉後,不會重新下載各種已經下載過的資源。

藉助 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 無法處理:

  1. 如果在 <a> 的 click handler 中執行了 event.stopPropagation(),阻止了事件冒泡,Turbolinks 將無法接收到此事件
  2. 通過表單 Form 發起的 GET 請求
  3. 通過表單 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>
複製程式碼

效果對比:

藉助 Turbolinks 實現不間斷的網頁音樂播放器

這裡面有一個細節,event.stopPropagation() 導致 Turoblinks 失效,你會覺得是理所當然的事情,但實際它阻止的是 React 合成事件 (SytheticEvent) 的冒泡,而不是原生事件 (NativeEvent) 的冒泡,而 Turbolinks 處理的是原生事件。如果你想了解原因,可以看這篇文章 - 從 Dropdown 的 React 實現中學習到的

第二種情況,通過表單 Form 發起的 GET 請求,此例中我們建立了一個搜尋表單,通過 title 搜尋相應的歌曲。

有兩種解決辦法:

  1. 像上面一樣,在 Form 的 onSubmit 事件中,呼叫 event.preventDefault() 阻止預設提交,然後呼叫 Turbolinks.visit(url) API 手動傳送 ajax 請求。
  2. 將 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>
  ...
}
複製程式碼

效果對比:

藉助 Turbolinks 實現不間斷的網頁音樂播放器

再來看第三種情況,通過表單 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>
複製程式碼

來看一下效果對比:

藉助 Turbolinks 實現不間斷的網頁音樂播放器

為什麼加了個 data-remote 屬性就 ok 了呢,是不是很神奇的感覺。這裡還有另外一個 JavaScript 庫起了作用,rails-ujs,也是 rails 預設啟用的。這個庫有挺多功能的,在這裡的作用是,將所有 data-remote 屬性為 true 的 form 表單的請求轉換成 ajax 請求 (xhr 即表示請求為 ajax),但注意,此時 Turbolinks 並沒有參與,還記得前面說的嗎,它只管 <a> 連結,其它的一概不關心。

藉助 Turbolinks 實現不間斷的網頁音樂播放器

但是,這僅僅解決了第一步,一般來說,POST 請求的結果都是一個 302 響應,瀏覽器要從響應頭中取出目標地址並訪問這個新的地址。

這裡服務端的程式碼:

# songs_controller.rb
def update
  @song = Song.find params[:id]
  @song.update(title: params[:title])
  redirect_to @song
end
複製程式碼

這裡常規的 POST 請求的響應:

藉助 Turbolinks 實現不間斷的網頁音樂播放器

如果我們的 ajax 請求得到的也是這種響應,那麼跳轉無法自動實現。所幸的是,Rails 很智慧,它如果檢測到 POST 請求是 ajax 發起的,且 Turbolinks 啟用,在 redirect_to @song 中,它將不再返回 302,而是返回一段 JavaScript 程式碼:

Turbolinks.clearCache()
Turbolinks.visit("http://localhost:3000/songs/21", {"action":"replace"})
複製程式碼

藉助 Turbolinks 實現不間斷的網頁音樂播放器

藉助 Turbolinks 實現不間斷的網頁音樂播放器

ajax 請求得到這個響應後,因為響應型別是 text/javascript,因此它會執行這段 JavaScript 程式碼,而這段 JavaScript 呼叫了 Turoblinks 的 visit API,而這個 API 我們前面已經用過數次了,它實際呼叫 HTML5 history API 實現無重新整理的跳轉。

這也是前面所說 Turbolinks 雖然是一個 JavaScript 庫,但也是需要後端的一些配合的。

相關文章