Phoenix LiveView 1.0-rc 发布了!

发布于 2024 年 5 月 8 日,作者 Chris McCord


LiveView 1.0.0-rc.0 发布了!

这个 1.0 版本里程碑是在第一个 LiveView 提交代码之后近六年时间里发布的。

为什么要使用 LiveView

我开始开发 LiveView 是为了解决一个问题。我想创建一个动态的服务器端渲染应用程序,而无需编写 JavaScript。我厌倦了它带来的不可避免的复杂性。

想想实时表单验证、更新购物车中的商品数量或实时流式更新。为什么在传统堆栈中解决这些问题需要费力?我们编写 HTTP 胶水或 GraphQL 架构和解析器,然后我们弄清楚哪些验证逻辑需要共享或复制。从那里继续下去 - 我们如何将本地化信息传递给客户端?我们需要哪些数据序列化器?如何将 WebSockets 和 IPC 连接回我们的代码?我们的 js 包是否太大?我想现在是时候开始调整 Webpack 或 Parcel 的设置了。等等,Vite 现在是一个东西?或者我猜 Bun 配置是我们想要的?我们都经历过这种痛苦。

想法是,如果我们完全消除这些问题怎么办?HTTP 可以消失,服务器可以处理所有渲染和动态更新问题。这感觉像是很重的做法,但我了解 Elixir 和 Phoenix 非常适合它。

六年后的今天,这种编程模型仍然感觉像作弊。一切都超级快。负载很小。延迟是同类最佳。您不仅编写更少的代码,而且在编写功能时只需考虑更少的事情。

实时基础解锁超级能力

当您将实时双向基础作为一项常规事项提供给每个用户和 UI 时,就会发生有趣的事情。您突然有了超级能力。您几乎没有注意到。摆脱传统全栈开发的繁琐问题,让您专注于交付功能。而使用 Elixir,您开始交付其他平台甚至无法想象的功能。

想要将 实时服务器日志发送到开发环境中的 js 控制台?没问题!

如何支持生产环境中的热代码升级,浏览器可以随时自动重新渲染 CSS 样式表、图像或模板,而不会丢失状态或断开连接?当然可以!

或者,也许您有一个在全球范围内部署的应用程序,您可以在集群中进行工作并将结果实时聚合回 UI。您相信整个 LiveView,包括模板标记和 RPC 调用,只有 350 行代码

这些是 LiveView 支持的应用程序类型。发布这些功能感觉很棒,但我们花了很长时间才做到这一点,原因很充分。为了使这种编程模型真正出色,有很多问题需要解决。

起源

从概念上讲,我真正想要的是像我们在 React 中做的事情一样 - 更改一些状态,我们的模板会自动重新渲染,并且 UI 会更新。但不是让一小部分 UI 在客户端运行,如果我们将其在服务器上运行会怎样?LiveView 看起来像这样

defmodule ThermoLive do
  def render(assigns) do
    ~H"""
    <div id="thermostat">
      <p>Temperature: <%= @thermostat.temperature %></p>
      <p>Mode: <%= @thermostat.mode %></p>
      <button phx-click="inc">+</button>
      <button phx-click="dec">-</button>
    </div>
    """
  end

  def mount(%{"id" => id}, _session, socket) do
    thermostat = ThermoControl.get_thermostat!(id)
    :ok = ThermoControl.subscribe(thermostat)
    {:ok, assign(socket, thermostat: thermstat)}
  end

  def handle_info({ThermoControl, %ThermoStat{} = new_thermo}, _, socket) do
    {:noreply, assign(socket, thermostat: new_thermo)}
  end

  def handle_event("inc", _, socket) do
    thermostat = ThermoControl.inc(socket.assigns.thermostat)
    {:noreply, assign(socket, thermostat: thermostat)}
  end
end

就像 React 一样,我们有一个渲染函数,以及在 LiveView 挂载时设置初始状态的东西。当状态改变时,我们使用新的状态调用 render,UI 会更新。

phx-click 这样的交互,在 +- 按钮上,可以作为 RPC 从客户端发送到服务器,服务器可以使用新的页面 HTML 进行响应。这些客户端/服务器消息使用 Phoenix Channels,它们可以 扩展到每台服务器数百万个连接

同样,如果服务器想要向客户端发送更新,例如另一个用户更改恒温器,客户端可以监听它并以相同的方式替换页面 HTML。我对 phoenix_live_view.js 客户端的第一个幼稚版本看起来像这样。

let main = document.querySelector("[phx-main]")
let channel = new socket.channel("lv")
channel.join().receive("ok", ({html}) => main.innerHTML = html)
channel.on("update", ({html}) => main.innerHTML = html)

window.addEventListener("click", e => {
  let event = e.getAttribute("phx-click")
  if(!event){ return }
  channel.push("event", {event}).receive("ok", ({html}) => main.innerHTML = html)
})

这就是 LiveView 的起源。我们去服务器进行交互,在状态改变时重新渲染整个模板,并将整个页面发送到客户端。然后客户端会替换内部 HTML。

它起作用了,但并不理想。部分状态更改需要重新执行整个模板,并为微不足道的更新发送大量 HTML。

但基本的编程模型正是我的目标。随着 HTTP 从我的关注点中消失,整个全栈考虑层的消失了。

接下来,挑战在于将其变成真正出色的东西。我们不知道我们会在不知不觉中超越了许多 SPA 的用例。

如何优化编程模型

LiveView 的差异引擎用一种机制解决了两个问题。第一个问题是只执行模板中实际从之前渲染改变的动态部分。第二个问题是只发送更新客户端所需的最少数据。

它通过将模板拆分为静态部分和动态部分来解决这两个问题。考虑以下 LiveView 模板

~H"""
<p class={@mode}>Temperature: <%= format_unit(@temperature) %></p>
"""

在编译时,我们将模板转换为这样的结构

%Phoenix.LiveView.Rendered{
  static: ["<p class=\"", \">Temperature:", "</p>"]
  dynamic: fn assigns ->
    [
      if changed?(assigns, :mode), do: assigns.mode,
      if changed?(assigns, :temperature), do: format_unit(assigns.temperature)
    ]
  end
}

我们知道静态部分永远不会改变,因此它们与动态 Elixir 表达式分开。接下来,我们使用基于每个表达式中访问的变量的更改跟踪来编译每个表达式。在渲染时,我们将之前模板的值与新的值进行比较,只有在值改变时才执行模板表达式。

我们无需在更改时将整个模板发送下去,而是可以在 mount 上将所有静态和动态部分发送给客户端。在挂载之后,我们只为每次更新发送动态值的局部差异。

为了了解其工作原理,我们可以想象在上面的模板上 mount 时发送以下负载

{
  s: ["<p class=\"", ">Temperature: ", "</p>"],
  0: "cooling",
  1: "68℉"
}

客户端在 s 键中接收静态值的映射,以及动态值的映射,这些值以它们在静态部分中的索引为键。为了让客户端渲染完整的模板字符串,它只需要将静态列表与动态值进行压缩即可。例如

["<p class=\"", "cooling", "\">Temperature: ", "68℉", "</p>"].join("")
"<p class=\"cooling\">Temperature: 68℉</p>"

客户端拥有一个静态/动态缓存,优化网络更新就变得轻而易举。任何 mount 之后的服务器渲染只需在其已知索引处返回新的动态值。未更改的动态值和静态值将完全忽略。

如果一个 LiveView 运行 assign(socket, :temperature, 70),则会调用 render/1 函数,并且以下负载会通过网络发送

{1: "70℉"}

就是这样!要更新 UI,客户端只需将此对象与其静态/动态缓存合并即可

{                     {
                        s: ["<p class=\"", ">Temperature: ", "</p>"],
                        0: "cooling",
  1: "70F"     =>       1: "70℉"
}                     }

然后,数据在客户端压缩在一起,以生成 UI 的完整 HTML。

当然,innerHTML 更新会消除 UI 状态,并且执行起来很昂贵。因此,与任何客户端框架一样,我们计算最小的 DOM 差异以有效地更新 DOM。事实上,有些人从 React 迁移到 Phoenix LiveView,因为 LiveView 客户端渲染速度比他们的 React 应用程序快

从那时起,优化不断进行。包括指纹、for 循环、树共享等等。您可以在 Dashbit 博客上阅读有关每个优化的全部内容

由于我们有状态的客户端和服务器连接,因此我们自动免费应用这些优化。大多数其他服务器端渲染 HTML 解决方案在每次更新时都会发送整个片段,或者要求用户手动微调更新。

同类最佳的延迟

我们已经了解到 LiveView 负载比最精心编写的 JSON API 或 GraphQL 查询要小,但实际上比这更好。每个 LiveView 都维护与服务器的连接,因此页面导航通过实时导航进行。TLS 握手、当前用户身份验证等只在用户访问的生命周期内发生一次。这允许页面导航通过单个 WebSocket 帧进行,并且为任何客户端操作减少数据库查询次数。结果是客户端的往返次数更少,服务器的工作量更少。与从服务器获取数据或将突变发送到服务器的 SPA 相比,这为最终用户提供了更低的延迟。

维护有状态连接的代价是服务器内存,但它比人们想象的便宜得多。在基本情况下,给定的通道连接会消耗 40kb 的内存。这使 1GB 服务器的理论上限为 ~25,000 个并发 LiveView。当然,您存储的状态越多,消耗的内存就越多,但您只会保留所需的状态。我们还有 stream 原语,用于处理大型集合而不会影响内存。Elixir 和 Erlang VM 是为此而设计的。将有状态系统扩展到数百万个并发用户并非理论上的,我们一直在这样做。以 WhatsApp、Discord 或 我们自己的基准测试 为例。

通过在客户端和服务器上优化编程模型,我们扩展到了更高层次的构建块,这些构建块利用了我们独特的差异引擎。

使用 HEEx 的可重用组件

更改跟踪和最小差异是突破性的功能,但我们的 HTML 模板仍然缺乏可组合性。我们能提供的最好的就是“部分”式模板渲染,其中一个函数可以封装一些部分模板内容。这可以工作,但它组合起来很糟糕,并且与我们编写标记的方式不匹配。幸运的是,来自 Surface 项目 的 Marlus Saraiva 带领开发了一个了解 HTML 的组件系统,并将其贡献回 LiveView 项目。使用 HEEx 组件,我们拥有声明式组件系统、HTML 验证以及组件属性和插槽的编译时检查。

HEEx 组件只是带注释的函数。它们看起来像这样

@doc """
Renders a button.

## Examples

    <.button>Send!</.button>
    <.button phx-click="go">Send!</.button>
"""
attr :type, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)

slot :inner_block, required: true

def button(assigns) do
  ~H"""
  <button
    type={@type}
    class="rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3 text-white"
    {@rest}
  >
    <%= render_slot(@inner_block) %>
  </button>
  """
end

对组件的无效调用,例如 <.button click="bad">,会在编译时产生警告

warning: undefined attribute "click" for component AppWeb.CoreComponents.button/1
  lib/app_web/live/page_live.ex:123: (file)

插槽允许组件接受调用方提供的任意内容。这允许组件由调用方更可扩展,而无需创建大量专门的部分模板来处理每种情况。

HEEx 标记注释

查看浏览器 HTML 然后寻找该 HTML 在代码中的生成位置的日子已经一去不复返了。最终的浏览器标记可以在几个嵌套的组件调用层中渲染。我们如何快速追溯谁渲染了什么?

HEEx 通过 debug_heex_annotations 配置解决了这个问题。设置后,所有渲染的标记都将用函数组件定义的 file:line 以及组件调用方调用的 file:line 进行注释。实际上,您的开发 HTML 在浏览器检查器中看起来像这样

Debug HEEx annotations

它在调用方站点和函数组件定义处都对文档进行注释。如果您发现上面的导航很困难,您可以使用新的 Phoenix.LiveReloader 功能,这些功能可以让您的编辑器在您选择的一组特殊键序列下单击时跳转到元素最近的调用方或定义 file:line。

让我们看看它的实际效果

首先,我们可以看到在点击的同时按住 c 键是如何跳转到调用者文件的:该 <.button> 调用的行位置。接下来,我们看到在点击按钮的同时按住 d 键是如何跳转到函数定义文件:行号的。

这是一个非常简单的提高生活质量的功能。一旦你尝试过它,它就会成为你工作流程中的一个关键部分。

交互式上传

几年前,LiveView 解决了文件上传问题。一件本来应该很简单的事情,历史上却变得不必要地困难。我们想要一个单一的抽象,用于直接上传到云端和直接上传到服务器这两种用例。

只需几行服务器代码,你就可以拥有带有拖放、文件进度、选择修剪、文件预览等等功能的文件上传。

最近,我们定义了一个 UploadWriter 行为。它让你可以访问原始上传流,因为该流正被客户端分块。这让你可以做一些事情,比如 将上传流传输到不同的服务器 或者 在上传时转码视频

由于上传发生在现有的 LiveView 连接上,因此反映上传进度或高级文件操作 变得非常容易实现

流和异步

在上传之后,我们发布了一个流的原语,用于高效地处理大型集合,而无需将这些集合保留在服务器内存中。我们还引入了 assign_asyncstart_async 原语,这使得处理异步操作和渲染异步结果变得轻而易举。

例如,假设你有一个昂贵的操作,它调用外部服务。结果可能存在延迟或不稳定,或者两者兼而有之。你的 LiveView 可以使用 assign_async/2 将此操作卸载到一个新的进程中,并使用 <.async_result> 使用每个加载、成功或失败状态来渲染结果。

def render(assigns) do
  ~H"""
  <.async_result :let={org} assign={@org}>
    <:loading>Loading organization <.spinner /></:loading>
    <:failed :let={_failure}>there was an error loading the organization</:failed>
    <%= org.name %>
  </.async_result>
  """
end

def mount(%{"slug" => slug}, _, socket) do
  {:ok, assign_async(:org, fn -> {:ok, %{org: fetch_org(slug)}} end)}
end

现在,你不必担心异步任务会崩溃 UI,也不必仔细监视异步操作,同时使用一堆条件语句来更新模板,你拥有一个用于执行工作和渲染结果的单一抽象。一旦 LiveView 断开连接,异步进程就会被清理,确保不会浪费资源到不再存在的 UI 上。

在这里,我们还可以看到插槽在 <:loading><:failed> 插槽中的作用,这些插槽是 <.async_result> 函数组件的。插槽允许调用者使用他们自己的动态内容扩展组件,包括他们自己的标记和函数组件调用。

LiveView 走向主流

LiveView 和 .NET Blazor 几乎同时开始。我喜欢认为这两个项目都推动了这种编程模型的采用。

自从开始以来,这种模型已在 Go、Rust、Java、PHP、JavaScript、Ruby 和 Haskell 社区中以各种方式得到采用。我相信还有其他我还没有听说过的社区。

大多数社区不提供 LiveView 的声明式模型。相反,开发人员需要标注单个元素是如何更新和删除的,这会导致应用程序变得脆弱,类似于在引入 React 和其他声明式框架之前客户端应用程序的情况。大多数社区也缺乏 LiveView 开发人员免费获得的优化功能。除非开发人员手动对其进行微调,否则在每次事件发生时都会发送大型负载。

React 本身非常喜欢将 React 放到服务器上的想法,他们发布了自己的 React Server Components 来解决与 LiveView 类似的目标的交叉部分。在 RSC 的情况下,推送实时事件留给了外部手段。

React 就像大多数社区一样,选择了不同的权衡,因为他们别无选择。大多数平台都不适合状态化、双向通信层,因此大多数社区都跳过了这一层。Elixir 和 Erlang VM 真正让这种编程模型大放异彩。而我们只是简要地讨论了我们内置的全局分布式集群和 PubSub。平台中真正存在着非凡的功能,触手可及。

下一步

我们鼓励大家在应用程序中尝试 1.0-rc 并报告任何问题或错误。查看 变更日志 以了解重大更改,从而将你现有的应用程序更新到最新版本。在 RC 阶段之后,我们将继续努力实现我们的问题跟踪器中所述的协同 JavaScript 挂钩、Web 组件集成、导航防护等功能。

特别感谢

如果没有 Phoenix 团队的帮助,特别是 Steffen Deusch,他在这几个月里解决了无数的 LiveView 问题,我们不可能到达这里。

快乐编码!

–Chris