Turbolinks5 是用 Coffeescript 编写.
学习 Turbolinks5 能够让你:
- 从根本上掌握浏览器加载网页时的处理流程
- 掌握 Turbolinks5 的核心原理, 学会如何模块化一个 "大" 的前端项目
- 跟着老鸟学会如何分析源代码
准备工作
clone 项目:
git clone https://github.com/turbolinks/turbolinks
准备你的编辑器, 推荐 atom 或 sublime text
- 找到 Turbolinks.start() 入口
老鸟提示, 在研究代码之前, 明确你研究对象的适用范围非常重要, 大部分时间先看文档是一个非常有效的熟悉项目架构的手段.
- 所以推荐提前阅读它的 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() 是真正的入口.
老鸟提示, 用空间想像力从静态的代码中抽出运行时各个类或组件的关系, 这是阅读代码的精粹. 必要时, 可以动用动态的 debug 工具进行动态分析.
controller 在做什么
我们跳入 controller.coffee, 找到 start() 方法:
start: ->
unless @started
addEventListener("click", @clickCaptured, true)
addEventListener("DOMContentLoaded", @pageLoaded, false)
@scrollManager.start()
@startHistory()
@started = true
@enabled = true
暂时不用关心非骨干的代码, 我们看到最重要的入口已经出现:
clickCaptured 函数挂载到了全局的 click 事件, pageLoaded 函数挂载到了 DOMContentLoaded 事件
DOMContentLoaded 与 Load 事件区别在于前者不继续等待 css, image 加载完成即触发, 后者等页面完全加载后触发.
从这里, 我们已经看到第一个关键实现:
如何绑定了 a 元素的事件: addEventListener
这时我们已经到了 visit() 这个入口了. 继续往下看:
visit() -> @adapter.visitProposedToLocationWithAction -> @controller.startVisitToLocationWithAction
最后来到 controller.coffee 的 startVisitToLocationWithAction:
startVisit: (location, action, properties) ->
@currentVisit?.cancel()
@currentVisit = @createVisit(location, action, properties)
@currentVisit.start()
@notifyApplicationAfterVisitingLocation(location)
我们可以看出以下信息:
- 用户点击一个链接后, 实际上, Turbolinks5 创建了一个 Visit 的实例, 然后调用了 .start() 来启动具体的访问过程.
这时也可以基本分析出 controller 的作用了:
- controller 是所有相关类的一个容器, 通过它来关联各个模块, 但 Visit 是特例, 它每次访问都产生一个新的实例, 并存储在 @currentVisit
Visit 真正的访问
start() -> @adapter.visitStarted(this) -> visit.issueRequest(); visit.changeHistory(); visit.loadCachedSnapshot()
这是真正的处理流程:
发送 HTTP Request( 异步, 注意 Javascript 里面请求默认都是异步 )
更新浏览器历史( 通过 History API )
加载 cache 页面
是时候分道扬镳了.
HttpRequest
http_request.coffee 里面研究一下, HTTP Request 是如何发送的:
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
果然不出预料, 通过 XMLHttpRequest 对象, 发起了一个异步请求. 设定了回调. 我们暂时不看异常处理, 直接看 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 的命名在框架里是非常常见的, 它代表一个代理人, 将请求转给对应的接口. 这里明显就是原来的 Visit 实例. 这样设计能够让 HttpRequest 对象不依赖于具体的实现类( 比如 visit ), 更为通用.
继续分析, 就发现它最后调用了
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 之后的动作, 可以看出它调用了 @controller.render 接口. 先不继续往 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 进行了.
我们最终需要进入最关键的 render 函数
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()
我们转了一圈, 最终找到了 render 的实际入口. 我们看到 render 做了以下几件事:
合并头
替换 body
一些杂项
继续看 mergeHead
mergeHead: ->
@copyNewHeadStylesheetElements()
@copyNewHeadScriptElements()
@removeCurrentHeadProvisionalElements()
@copyNewHeadProvisionalElements()
非常明显的命名.
我们继续从 head_details.coffee 中分析到具体操作:
document.head.appendChild(element)
也就是 mergeHead 也就是同步了头部信息, 并将其加载起来. 注意这里在明确理解 Javascript 操作 script 标签元素的作用.( 会自动异步取回 src 属性中的内容并执行 )
同理, replaceBody 的操作关键是:
for replaceableElement in @getNewBodyScriptElements()
element = @createScriptElement(replaceableElement)
replaceableElement.parentNode.replaceChild(element, replaceableElement)
非常清晰的命名, 让我们能够很快明白这里的逻辑.