Turbolinks5 是用 Coffeescript 編寫.
學(xué)習(xí) Turbolinks5 能夠讓你:
- 從根本上掌握瀏覽器加載網(wǎng)頁時的處理流程
- 掌握 Turbolinks5 的核心原理, 學(xué)會如何模塊化一個 "大" 的前端項目
- 跟著老鳥學(xué)會如何分析源代碼
準備工作
clone 項目:
git clone https://github.com/turbolinks/turbolinks
準備你的編輯器, 推薦 atom 或 sublime text
- 找到 Turbolinks.start() 入口
老鳥提示, 在研究代碼之前, 明確你研究對象的適用范圍非常重要, 大部分時間先看文檔是一個非常有效的熟悉項目架構(gòu)的手段.
- 所以推薦提前閱讀它的 README.
開始
入口非常簡單:
Turbolinks.start = ->
if installTurbolinks()
Turbolinks.controller ?= createController()
Turbolinks.controller.start()
installTurbolinks = ->
window.Turbolinks ?= Turbolinks
moduleIsInstalled()
createController = ->
controller = new Turbolinks.Controller
controller.adapter = new Turbolinks.BrowserAdapter(controller)
controller
moduleIsInstalled = ->
window.Turbolinks is Turbolinks
Turbolinks.start() if moduleIsInstalled()
可以了解到以下信息:
- Turbolinks 會掛載到全局的 window.Turbolinks 對象, 單例( 即全局只有一個 ).
- Turbolinks.controller 是核心, 也是單例的( 全局只有一個 ).
- Turbolinks.controller.start() 是真正的入口.
老鳥提示, 用空間想像力從靜態(tài)的代碼中抽出運行時各個類或組件的關(guān)系, 這是閱讀代碼的精粹. 必要時, 可以動用動態(tài)的 debug 工具進行動態(tài)分析.
controller 在做什么
我們跳入 controller.coffee, 找到 start() 方法:
start: ->
unless @started
addEventListener("click", @clickCaptured, true)
addEventListener("DOMContentLoaded", @pageLoaded, false)
@scrollManager.start()
@startHistory()
@started = true
@enabled = true
暫時不用關(guān)心非骨干的代碼, 我們看到最重要的入口已經(jīng)出現(xiàn):
clickCaptured 函數(shù)掛載到了全局的 click 事件, pageLoaded 函數(shù)掛載到了 DOMContentLoaded 事件
DOMContentLoaded 與 Load 事件區(qū)別在于前者不繼續(xù)等待 css, image 加載完成即觸發(fā), 后者等頁面完全加載后觸發(fā).
從這里, 我們已經(jīng)看到第一個關(guān)鍵實現(xiàn):
如何綁定了 a 元素的事件: addEventListener
這時我們已經(jīng)到了 visit() 這個入口了. 繼續(xù)往下看:
visit() -> @adapter.visitProposedToLocationWithAction -> @controller.startVisitToLocationWithAction
最后來到 controller.coffee 的 startVisitToLocationWithAction:
startVisit: (location, action, properties) ->
@currentVisit?.cancel()
@currentVisit = @createVisit(location, action, properties)
@currentVisit.start()
@notifyApplicationAfterVisitingLocation(location)
我們可以看出以下信息:
- 用戶點擊一個鏈接后, 實際上, Turbolinks5 創(chuàng)建了一個 Visit 的實例, 然后調(diào)用了 .start() 來啟動具體的訪問過程.
這時也可以基本分析出 controller 的作用了:
- controller 是所有相關(guān)類的一個容器, 通過它來關(guān)聯(lián)各個模塊, 但 Visit 是特例, 它每次訪問都產(chǎn)生一個新的實例, 并存儲在 @currentVisit
Visit 真正的訪問
start() -> @adapter.visitStarted(this) -> visit.issueRequest(); visit.changeHistory(); visit.loadCachedSnapshot()
這是真正的處理流程:
發(fā)送 HTTP Request( 異步, 注意 Javascript 里面請求默認都是異步 )
更新瀏覽器歷史( 通過 History API )
加載 cache 頁面
是時候分道揚鑣了.
HttpRequest
http_request.coffee 里面研究一下, HTTP Request 是如何發(fā)送的:
createXHR: ->
@xhr = new XMLHttpRequest
@xhr.open("GET", @url, true)
@xhr.timeout = @constructor.timeout * 1000
@xhr.setRequestHeader("Accept", "text/html, application/xhtml+xml")
@xhr.setRequestHeader("Turbolinks-Referrer", @referrer)
@xhr.onprogress = @requestProgressed
@xhr.onload = @requestLoaded
@xhr.onerror = @requestFailed
@xhr.ontimeout = @requestTimedOut
@xhr.onabort = @requestCanceled
果然不出預(yù)料, 通過 XMLHttpRequest 對象, 發(fā)起了一個異步請求. 設(shè)定了回調(diào). 我們暫時不看異常處理, 直接看 requestLoaded
requestLoaded: =>
@endRequest =>
if 200 <= @xhr.status < 300
@delegate.requestCompletedWithResponse(@xhr.responseText, @xhr.getResponseHeader("Turbolinks-Location"))
else
@failed = true
@delegate.requestFailedWithStatusCode(@xhr.status, @xhr.responseText)
@delegate 是什么鬼? 實際上, delegate 的命名在框架里是非常常見的, 它代表一個代理人, 將請求轉(zhuǎn)給對應(yīng)的接口. 這里明顯就是原來的 Visit 實例. 這樣設(shè)計能夠讓 HttpRequest 對象不依賴于具體的實現(xiàn)類( 比如 visit ), 更為通用.
繼續(xù)分析, 就發(fā)現(xiàn)它最后調(diào)用了
loadResponse: ->
if @response?
@render ->
@cacheSnapshot()
if @request.failed
@controller.render(error: @response, @performScroll)
@adapter.visitRendered?(this)
@fail()
else
@controller.render(snapshot: @response, @performScroll)
@adapter.visitRendered?(this)
@complete()
這就是最終 HttpRequest 之后的動作, 可以看出它調(diào)用了 @controller.render 接口. 先不繼續(xù)往 render 里走. 回到上一個分支點.
老鳥提示, 好的命名能夠極大程度降低閱讀代碼的工作量, 不要一路追到底, 明確了一個接口的含義后, 可以往其他重要的入口分析. 比如 render 就是一個非常清晰的含義, 我們幾乎不分析也能明白它的作用.
loadCachedSnapshot
loadCachedSnapshot: ->
if snapshot = @getCachedSnapshot()
isPreview = @shouldIssueRequest()
@render ->
@cacheSnapshot()
@controller.render({snapshot, isPreview}, @performScroll)
@adapter.visitRendered?(this)
@complete() unless isPreview
非常妙, cache page 最終加載也通過 @controller.render 進行了.
我們最終需要進入最關(guān)鍵的 render 函數(shù)
controller.coffee
render: (options, callback) ->
@view.render(options, callback)
進入 view.coffee
renderSnapshot: (snapshot, callback) ->
Turbolinks.SnapshotRenderer.render(@delegate, callback, @getSnapshot(), Turbolinks.Snapshot.wrap(snapshot))
進入 snapshot_renderer.coffee
render: (callback) ->
if @trackedElementsAreIdentical()
@mergeHead()
@renderView =>
@replaceBody()
@focusFirstAutofocusableElement()
callback()
else
@invalidateView()
我們轉(zhuǎn)了一圈, 最終找到了 render 的實際入口. 我們看到 render 做了以下幾件事:
合并頭
替換 body
一些雜項
繼續(xù)看 mergeHead
mergeHead: ->
@copyNewHeadStylesheetElements()
@copyNewHeadScriptElements()
@removeCurrentHeadProvisionalElements()
@copyNewHeadProvisionalElements()
非常明顯的命名.
我們繼續(xù)從 head_details.coffee 中分析到具體操作:
document.head.appendChild(element)
也就是 mergeHead 也就是同步了頭部信息, 并將其加載起來. 注意這里在明確理解 Javascript 操作 script 標簽元素的作用.( 會自動異步取回 src 屬性中的內(nèi)容并執(zhí)行 )
同理, replaceBody 的操作關(guān)鍵是:
for replaceableElement in @getNewBodyScriptElements()
element = @createScriptElement(replaceableElement)
replaceableElement.parentNode.replaceChild(element, replaceableElement)
非常清晰的命名, 讓我們能夠很快明白這里的邏輯.