Phoenix LiveView 简介

这是我在 20200516 Elixir Meetup 线上活动 里介绍 Phoenix LiveView 所用的讲稿。 Slide PDF 版本: 202005 Elixir Meetup - Phoenix LiveView

Demo

刚才陈天老师为我们分享了当 Elixir 有不足的时候,该如何引入其他的语言(Rust)进行补足。而我的分享可能刚好相反,是通过 Elixir 的强处,来实现一些其他语言难以实现的效果。希望能让大家体会到 Elixir 的优美和高效。

今天的主角是 Phoenix LiveView。 Phoenix LiveView 是由 Phoenix 官方团队在 2019 年 3 月正式推出的后端渲染框架。它所要解决的问题是:让开发者们用最小的成本,开发出具有实时交互体验的 Web 应用。那什么是「具有丰富的实时交互体验的 Web 应用」呢?让我们先来看两个 Demo:

数独

第一个 Demo 是一个数独解题器:用户可以在这个 9x9 的表单中填入数字,点击 "Start" 进行解题,并能实时地看到解题算法的每一步更新。更厉害的是,用户可以通过滑块实时地控制解题算法更新的间隔。而这些功能,都是用 Elixir 通过 Phoenix LiveView 加上一些简单的 HTML 和 CSS 实现的。

screencast.gif

Twitter

这个 Demo 是一个 Twitter 克隆。从这个录屏中,我们可以看到,在一个客户端中创建的新推文,被实时地推送到了另外的客户端上。这些功能,就是通过 Phoenix LiveView 在 15 分钟内迅速实现的(不包括前端样式部分)。

Build a real-time Twitter clone in 15 minutes

twitter.gif

Agenda

看过了这两个 Demo,相信大家都能体会到 Phoenix LiveView 的强大。那就让我们正式进入今天的主题吧!

What's in this talk

今天的分享主要包含三个部分:

  1. LiveView 要解决什么问题?通过回答这个问题,我们可以明确 LiveView 的定位,理解它背后的设计逻辑。
  2. LiveView 把这个问题解决了吗?通过这个问题,我们可以知道 LiveView 的优势与劣势,帮助我们决定是否要学习、使用 LiveView。
  3. LiveView 是怎么解决这个问题的?最后,我们会一起看看 LiveView 背后的实现机制,更好地理解这个强大的框架。
  1. LiveView 要解决什么问题?
  2. LiveView 把这个问题解决了吗?
  3. LiveView 是怎么解决这个问题的?

What's not in this talk

另外,这次分享并不会介绍如何使用 Phoenix LiveView。大家可以参考官方和社区提供的教程。

怎么使用 Phoenix LiveView

LiveView 要解决什么问题?

LiveView 希望解决的问题其实正是 Phoenix 框架本身在开发之初所要解决的问题:实时的用户交互体验。

实时的用户交互体验

  1. 在 Web 技术刚出现时的 Web 1.0 时代,通过 HTTP 请求网页,显示静态的文字、图片内容,已经能够满足用户的需求。
  2. 而在 Web 2.0 时代,GMail 等使用 AJAX 技术的应用不断涌现。这些应用在静态页面的基础上,动态地请求服务器的数据,及时地将数据变动更新到用户端。
  3. 现在,JavaScript 及其框架不断更新迭代,用户对数据实时性、界面交互性的要求越来越高。提供一个实时的、高度交互的用户体验成为了大部分 Web 应用的基本要求。
    • 在搜索时,我们不再满足于一个空荡荡的搜索框,还希望这个搜索框能够补全要搜索的内容。
    • 在填写表单时,我们不再满足于在提交表单之后再收到错误提示,而希望在输入时就得到对应的反馈。
    • 在聊天时,我们谁也不会通过刷新页面来获取新的消息,而是希望新消息实时地出现在屏幕上。

我们可以看一些具体的例子演示。 (Demo chrismccord/phoenix_live_view_example in Chrome)

  1. Weather

    这是 Phoenix LiveView 官方提供的一些样例。

    在主页里我们可以看到 LiveView 最基本的应用:天气查询。

    这里对天气信息的更新全是在 Elixir 里实现的,我们不需要写一行 JavaScript 代码。

  2. Search

    这是另一个样例:词典搜索。

    用户在输入时,LiveView 会实时地给出单词的补全。在查询时,LiveView 会请求第三方词典 API,获得单词的解释之后,再将其渲染到客户端。

    (Demo)

    值得注意的是,在请求第三方客户端时,LiveView 会显示「Searching」,让用户得到适当的反馈。这对于一些后端比较耗时的操作来说,是很有必要的。

  3. Form

    第二个例子是一个实时表单。

    当用户在表单里填入信息时,LiveView 会实时地给出错误提示。这样一来,用户就不必等到提交表单之后才知道自己的 Email 格式不正确,或者已经被人使用了等错误,避免了重复提交表单的繁琐。

  1. 搜索框自动补全
  2. 表单实时验证
  3. 实时聊天

已有的解决方案

但是,现有的解决方案都不尽如人意。为了实现复杂的实时交互体验,开发团队需要投入大量的时间成本、人力成本开发、维护前后端的应用。交互体验与开发成本之间,似乎构成了一种「鱼和熊掌不可得兼」的对立关系。我们可以先对已有的解决方案进行简单地对比。

单页应用(Single Page Application)

首先,当下最火热的解决方案就是单页应用框架。

它们能够实现最复杂的页面交互效果、逻辑。用户甚至可以在下载了应用之后,离线使用。最典型的例子是 Google Docs 和 Figma 这类协作创造工具。(Google Docs 并不能离线使用,Figma 离线时可使用的功能也是受限的)

但是,实现这些交互的代价可能是巨大的。前后端的分离意味着开发者需要使用两套开发语言、框架,开发效率较低。同时维护前端应用和后端 API 也带来了极高的维护成本。

另外,这个方案对搜索引擎的爬虫并不友好,为了搜索引擎优化(SEO)可能需要服务端渲染(SSR)等额外的技术开销。

  • 应用
    • Google Docs
    • Figma
  • 优势
    • 可以实现最为复杂的交互、动画效果
    • 可以离线使用
  • 劣势
    • 开发效率低下
    • 对 SEO 不友好

AJAX 应用(服务端 API + jQuery)

另外,我们也有比较传统的解决方案:在 Ruby on Rails, Phoenix 这些服务端渲染框架的基础上,在 HTML 页面渲染之后,通过 jQuery 等 JavaScript Library 发起 AJAX 请求,接收到 JSON 返回之后更新用户界面,达到实时交互的效果。

这种方案的部署、维护成本较低,初期的代码复杂度比较低,开发效率较高。但是当应用对实时性、交互性的要求逐渐增加,前端代码的维护成本也是极高的,这也是 React, Vue.js 这些前端框架火热的原因。另外,用较低的代码复杂度换来的也只是少许的实时交互效果,一些复杂的需求(比如,服务端向客户端实时推送数据)就很难实现。而应用页面之间的切换往往还是依赖一次完整的页面请求,相比 API 请求会传输更多的数据,加载速度更慢,用户体验差。

  • 例子
    • Ruby on Rails
    • Phoenix (without LiveView)
  • 应用
    • Gmail
  • 优势
    • 初期代码复杂度相对较低,开发效率较高
    • 对 SEO 友好
  • 劣势
    • 实时性弱,数据更新往往需要通过刷新页面来获取
    • 请求速度慢(每次刷新都重新获取数据)

介于两者之间的解决方案

当然,在单页应用和 AJAX 应用之间也已经有了许多不同的解决方案。

像 Basecamp 就在 Rails 的基础上开发了 Turbolinks 技术,通过传输 HTML partials 在 AJAX 应用的基础上实现了类似单页应用的效果。

但是,这类已有的解决方案要么存在着性能问题,不适合大规模使用,要么就是在开发效率和实时性之间做了取舍。鱼和熊掌难以兼得。

  • 例子
    • Turbolinks
    • Stimulus
  • 应用
    • Basecamp
  • 开发效率与实时性均在单页应用和 AJAX 应用之间

更快速的开发+更实时的交互

我们可以用这么一张图总结。

  • 横坐标代表的是应用交互体验的实时性,从左到右逐渐加强。
  • 纵坐标代表的是应用的开发成本,从下到上逐渐增加。

已有的解决方案分布在从左下到右上的对角线上。而 Phoenix LiveView 的目标在右下角。

也就是说,如何快速地、低成本地开发出带有实时交互体验的 Web 应用,并且满足生产环境的性能、SEO 需求,就是 Phoenix 和 Phoenix LiveView 希望解决的问题。

Sorry, your browser does not support SVG.

LiveView 把问题解决了吗?

讨论完了 LiveView 的目标,我们现在可以看看在发布了一年之后,LiveView 把这些目标完成得怎么样,具备了哪些优势,还存在哪些劣势。

LiveView 的优势

Phoenix LiveView 因为站在 Erlang OTP, Elixir, Phoenix 这些巨人们的肩膀上,在出生伊始,就具备了其他解决方案无法比拟的四大优势:

  1. 满足大部分实时交互需求
  2. 高效的开发体验
  3. 极致的性能
  4. SEO 友好

满足大部分实时交互需求

首先,LiveView 目前已经能够支持大部分 Web Application 中需要实时交互的场景。

除了满足基本的 CRUD 操作更新、服务端推送数据更新之外, LiveView 还提供了 JavaScript Hook functions 满足更加定制化的需求(比如:无限滚动加载、DOM 元素更新时的动画效果、与 JavaScript 第三方库的交互 等等)

CRUD 操作的实时更新
Twitter Timeline
服务端主动推送数据更新至客户端
Phoenix Live Dashboard
提供 JavaScript Hook functions 进行定制
Infinite scroll

高效的开发体验

其次,LiveView 应用的开发是极其高效的,之前展示的所有例子,核心代码都不超过 100 行。

这里展示的是「天气查询」的 LiveView component 代码,开发者只需要负责实现核心业务的部分:

  1. render: HTML template
  2. LiveView component callbacks
    1. mount: component 的初始化
    2. handle_event: 用户事件的处理逻辑
  3. weather: 后端业务逻辑

而我们作为开发者不用考虑该如何设计后端 API 的请求路径、返回数据结构,如何追踪数据的变化,如何通知客户端数据的更新,如何在前端接收 API 返回、解析、更新 DOM 结构等非核心业务逻辑, LiveView 帮我们打理好了这一切,并且比我们大多数人设计得更好。

这也正是 LiveView 为什么能如此显著地提高开发效率的原因: 开发者只用关心核心业务逻辑

def render(assigns) do
  ~L"""
  <div>
    <form phx-submit="set-location">
      <input name="location" placeholder="Location" value="<%= @location %>"/>
      <%= @weather %>
    </form>
  </div>
  """
end

def mount(_params, _session, socket) do
  {:ok, assign(socket, location: "Shanghai", weather: weather("Shanghai"))}
end

def handle_event("set-location", %{"location" => location}, socket) do
  {:noreply, put_location(socket, location)}
end

defp put_location(socket, location) do
  assign(socket, location: location, weather: weather(location))
end

defp weather(local) do
  {:ok, {{_, 200, _}, _, body}} =
    :httpc.request(:get, {~c"http://wttr.in/#{URI.encode(local)}?format=1", []}, [], [])

  IO.iodata_to_binary(body)
end

极致的性能

除去高效的开发体验之外,LiveView 另外一个出彩的地方就是它出色的性能。

正如刚才提到的,LiveView 帮我们实现了对状态的管理、变化追踪、同步,它在这些功能的实现上都是极其高效的:

  1. LiveView 在渲染模版时会区分出静态的模版内容和可能动态变化的内容;静态的内容不会随着数据变化或者用户的操作而变化,因此 LiveView 只会在初次渲染时将静态内容传输到客户端;之后的数据更新都只包含动态变化的部分
  2. 而且在传输动态数据的更新时,LiveView 只会传输需要更新的数据,前端再结合已有的静态模版,更新 DOM 结构。
  3. 最后,LiveView 目前使用 JSON 与前端通信,之后计划增加对 Erlang Term Format 这种数据格式的支持,提高服务端的负载能力,并且进一步减少更新时的数据包大小。

而在可扩展方面,因为有着 Erlang OTP, Elixir, Phoenix 这些得天独厚的优势,无论是纵向扩展还是横向扩展都没有问题。

  • 纵向扩展方面,Phoenix Channel 在一台 40 核 128GB 内存的服务器上可以支撑 2000000 的 WebSocket 连接服务。
  • 横向扩展方面,Erlang OTP 可以连接多台机器组成集群。
  1. diff 机制高效,且仍有优化空间
    1. 区分静态内容与动态内容
    2. 只传输变化的数据 diff
    3. JSON -> Erlang Term Format (ETF)
  2. 纵向扩展有 Phoenix Channel 做保障

    (The Road to 2 Million Websocket Connections)

  3. 横向扩展有 Erlang OTP 做保障

SEO 友好

最后,因为 LiveView 本身就运行在一个 Phoenix 应用中,给爬虫渲染 HTML 这个原本并不复杂的任务根本不在话下。

LiveView 会在请求开始的首次渲染时返回完整的 HTML response。这样,无论是浏览器还是爬虫,都能正常地拿到要渲染的数据。

之后,LiveView 才会通过 JavaScript 调用 WebSocket 启动 LiveView 生命周期,保证浏览器里数据的正常更新。

因此,使用 LiveView 并不会对 SEO 有任何影响,因为这本就是一个服务端渲染的页面。

  1. 首次渲染默认返回完整的 HTML response
  2. HTML 渲染结束之后再建立 WebSocket 连接启动 LiveView 生命周期

LiveView 的劣势

说完了 LiveView 的优势,LiveView 当然也有着它的缺陷与劣势。这些劣势主要都是源于 LiveView 对 WebSocket 的依赖。

LiveView 需要 WebSocket 才能正常接收数据更新,因此即使用户把页面缓存之后,在离线状态下也是无法正常使用的。

而如果网络的延迟很高,对 LiveView 应用的用户体验也会有很明显的影响。如果需要实现 CSS 动画或者多人游戏这类对低延迟、高帧率有要求的功能时,LiveView 也显然不是合适的选择。

在此,我必须要为 LiveView 稍作开脱。毕竟,又有多少应用、框架能够实现这个受到物理世界因素限制的需求呢?大部分应用也只是通过缓存、过场动画等用户体验上的改善,让用户体会不到网络延迟的存在,而这些改善用 LiveView 也同样能够实现。而且,LiveView 团队已经在开发相关的功能尝试解决这些问题:

  1. 在断线状态对客户端更新进行缓存,连线之后上传更新。
  2. 提供开发环境下的延迟模拟功能,供开发者调试。
  1. 无法在离线状态下正常使用
    • 复杂的本地应用(例:Figma)
  2. 用户体验容易受到延迟影响
    • 游戏
    • 动画效果

LiveView 是如何实现的?

那么,如此强大的 LiveView 是如何实现的呢?

  1. 首先,浏览器通过 Phoenix Channel 与服务器建立 WebSocket 连接。之后会在 phx-* 对应的事件发生后发送 WebSocket 请求至后端服务器。
  2. 后端服务器的 Phoenix LiveView Channel GenServer 接收到请求之后,会调用对应的 LiveView module 中的 handle_event/3 callback。
  3. 我们在应用中定义的 LiveView module 对数据状态进行更新,以 tuple 返回值的形式交还给 LiveView Channel GenServer。
  4. 数据状态更新完成之后,LiveView.Channel 调用 LiveView.Diff 生成 diff 数据。
  5. LiveView Channel GenServer 推送 diff/redirect/render 更新到客户端。
  6. 客户端接收数据之后,通过 morphdom 这个 JavaScript library 对 DOM 结构进行更新,并且调用对应的 JavaScript hooks。

这是 LiveView 底层实现细节的一个简单介绍,具体的细节大家可以阅读 phoenix_live_view/channel.ex 中的源码。

flow.png