Phoenix LiveView 至今仍然经常被压扁成两种只说到一半的口号。顺着一种说法,它成了“从此不再需要 JavaScript 的框架”;顺着另一种说法,它又像是在用 websocket 偷偷把单页应用塞回浏览器,只是换了一套名字。Chris McCord 在 ElixirConf 2022 的这场演讲之所以有价值,正在于它把这两种扁平说法都拨开了。视频真正展示出来的是一条更窄也更耐用的判断:Phoenix 与 LiveView 最顺手的形态,是把 UI 边界写成服务器一侧的组件契约,同时让浏览器专注于渲染、patch,以及一层被明确收束过的客户端交互。[1]
当前文档把这条线索写得很清楚。LiveView 的 welcome 指南把 LiveView 描述成一个接收事件、更新状态、再把变化以 diff 形式渲染出去的进程;第一次页面输出仍然来自普通的 server-rendered HTML,随后才建立持久连接。[2] 组件文档则继续说明,attr/3 与 slot/3 会把 function component 的输入边界显式声明出来,并在编译阶段执行校验,还能承接 phx-*、aria-*、data-* 这些全局 HTML attributes。[3] Phoenix.LiveView.JS 文档又把最后一层补齐:客户端命令是对 DOM patch 友好的,因此浏览器端的操作可以跨过后续 server patch 留存,而不用长成另一套和服务器抢解释权的应用模型。[4]
顺着演讲与文档合在一起看,LiveView 真正押注的,并非“把客户端删掉”。更准确的说法是:不要再让客户端默认承担第二份 UI 契约。[1][2][3][4][5][6] Phoenix 想让组件把自己接收什么写清楚,让 generators 输出一套可以复用的接口级 building blocks,再把浏览器运行时压在足够小的范围里,使 server re-render 始终留在唯一的权威位置。这条工程判断,比“不要 JavaScript”的贴纸说法扎实得多。
配图说明:题图使用 Chris McCord 的真实 ElixirConf 讲者肖像。这张图合适,因为本文围绕的是维护者本人对 Phoenix 与 LiveView 设计方向的解释,重心落在系统边界,而不在装饰性的浏览器截图或泛化的代码编辑器照片。[7]
大约从 9:00 开始,组件不再只是 snippets,而开始像接口一样工作
视频里第一段真正决定理解方向的内容,出现在 McCord 讲到 function components 的 declarative attributes 与 slots 时。[1] 他展示的重点并不只是更好看的语法,而是一种能同时被 compiler 与 editor 理解的组件调用方式。演讲里那段 table 风格的 component,既可以声明 required attributes,也可以通过 slots 接收任意 markup,还能在 runtime 之前就把调用错误暴露出来。[1] 这里真正变化的,并非模板长相,而是边界开始被机器看见。
组件文档把这一层写得非常明确。attr/3 允许组件声明自己接收哪些 attributes、哪些是 required,并在调用方破坏契约时给出 compile-time warnings。[3] slot/3 则把同样的机制带到 HEEx content blocks 上,包括 required inner blocks 与 slot 的结构限制。[3] 一旦这套机制成立,component call 就不再只是“某个你最好记得怎么用的 helper”,而更像一个接口,即便它最后输出的仍然是 HTML。
其中最值得停下来看的,是 global attributes 这一块。McCord 特别强调,调用方应该能够把 accessibility attributes、phx-click 一类的绑定继续传进去,而并非让每个组件作者都手工把整个 HTML 世界重写一遍。[1] 文档把同一件事写成更明确的约束:声明 :global 属性之后,组件就可以承接标准 HTML attributes 与默认的 phx-、aria-、data- 前缀,同时把剩余接口继续维持为显式状态。[3] 这并非一个小便利,而是 Phoenix 用来避免两头失衡的办法:既不把组件 API 做得僵死,也不把整片 markup 放回自由生长的泥地里。
也正因为这样,视频一直反复提到 compile-time niceties。问题不在 warnings 好不好看,而在于一旦 markup call 的边界被编译器看见,LiveView 在团队里的可维护性就会明显上升。原本藏在 helper、view conventions 或片段复制里的暗含契约,会被抬到台面上来。能被编译器检查的组件契约,本身就已经接近 design system 的骨架了,即便现场还没有人把这个词说出口。
大约从 20:07 开始,McCord 把 LiveView 重新讲成“去掉翻译层”,而并非“否认浏览器存在”
演讲中段有一串话,几乎把 LiveView 与普通前端堆栈之间最核心的差异说透了。McCord 把那种常见路径摆出来:服务器定义契约,序列化出去,浏览器再重建 client-side models、push 行为与各种数据层;随后他把 LiveView 放到另一条更直接的事件与渲染回路里。[1] 他真正反对的是重复。如果服务器已经握着权威状态迁移,为什么大量常规产品工作还要再维护一份 JSON serializers、GraphQL schemas、client stores 与手搓 websocket choreography,只为把同一件事再描述一次?
welcome 指南把这套哲学写成了操作模型。LiveView 是接收事件、更新 immutable state、再把相关 HTML 片段以 diffs 形式推出去的进程。[2] 页面第一次输出仍然通过普通 HTTP 完成,这让 first paint 与索引更自然;之后持久连接才让后续更新变轻。[2] 这条路线与经典 SPA 的平衡点并不一样。浏览器仍然活跃,但它默认不再承担那种长期持有业务状态、为每个交互维持第二份应用模型的职责。
也正是在这里,“不要 JavaScript”这层说法开始失效。LiveView 没有删除客户端,它只是缩小了团队必须先搭一套客户端应用架构才能开工的场景范围。McCord 把 LiveView 对 HTTP 栈的影响,类比成 utility CSS 对样式组织方式的影响时,真正的重点在于它砍掉了一层命名与翻译。[1] 许多日常产品页面里,服务器和浏览器之间不再需要持续互译两套模型;主事件回路留在一处,浏览器则擅长承接 patch 与即时交互。
这条边界也解释了为什么 LiveView 一旦被强行当成厚客户端框架来模仿,优势就会迅速变钝。它真正省下来的,是那份重复描述状态与界面的工程税。如果团队又顺着旧习惯把平行的客户端模型重新搭回去,LiveView 最有辨识度的好处就会先被自己抵消掉。
大约从 25:00 开始,core_components.ex 把 Phoenix 更强的一层野心露了出来:它想交付的是可迁移的接口,而不只是默认 markup
McCord 讲 generators 那一段,很容易被看成一个样式层故事,因为屏幕上的 Tailwind 很显眼。[1] 更值得注意的那一层,其实在样式之下。Phoenix 1.7 的 generators 开始输出一组接口级的 reusable components,例如 header、simple_form、input 与 modal,并把它们收进 core_components.ex,这样 scaffolded code 依赖的就不再是一大片一次性 markup,而是一组稳定的 component calls。[1][5] 官方 release post 说得更直白:这些 generators 依赖一套 core UI components,团队可以替换具体函数实现,而不用丢掉 generator 本身带来的价值。[5]
这一点很关键。很多框架的 generated UI,一旦团队换掉样式体系,就会立刻变成一次性代码。McCord 给出的方向正好相反。如果 generators 输出的是连贯的 component calls,而并非铺开的 page fragments,那么 Phoenix 在项目进入真正开发阶段之后仍然继续有用。[1] 把底层实现从 Tailwind 换成 Bulma、Bootstrap,或者换成团队自己的 house style,外层接口照样成立。[1][5] Nimble 对 Phoenix 1.7 的总结,把这层概括成 controller 与 LiveView 之间统一的 HTML rendering approach,本质上也是在说:Phoenix 试图把同一应用内部的模板方言漂移压缩掉。[6]
顺着这里往下看,component contract 的价值就从“优雅”变成了“可操作”。一旦 call site 可以长期稳定,团队要修改样式栈、无障碍默认值或 markup conventions 时,就能在单一的 component layer 里完成,不用回头逐页拆 generator 输出。框架交付的,已经不只是一个默认外观,而是一条可替换的接口缝。
放在这个层面上,core_components.ex 并不只是 starter-kit 糖衣。它更像是 Phoenix 对长期杠杆位置的一次明确下注。真正的杠杆,在于让 UI surface 保持足够 declarative,使 generators、团队代码与社区组件都能落在同一套接口上。只要这件事成立,“generated code”就不再天然等于“项目一开始就要扔掉的代码”。
大约从 39:00 开始,受限客户端这一层被讲明白了:connection states 与 JS commands 都活在 patch-safe 规则里
视频后段的 connection component,是浏览器边界最容易被看见的一段。[1] McCord 展示了一个带有 disconnected、connected 与 loading slots 的 component,并指出客户端一侧真正做的事情很薄:客户端判断当前处于哪种连接状态,component 只负责渲染对应 slot 的内容。[1] 这几乎就是 LiveView 更大架构的一张缩略图。浏览器当然有工作,但那份工作被收进了服务器拥有的 component contract 之内。
Phoenix.LiveView.JS 文档把这条边界写得更清楚。JS commands 可以添加或删除 classes、设置 attributes、隐藏或显示元素、dispatch events,也可以把 richer events 推回服务器;但这些操作都对 DOM patch 友好,因此它们能跨过后续 patch 保持效果,而不会悄悄长成另一台与服务器争夺解释权的状态机。[4] 文档甚至直接写到了 optimistic composition:你可以先 push event,再立刻在客户端隐藏 modal,同时仍然保留 server-interacting commands 的顺序保证。[4] 这是一层被约束过的 JS 运行时,并非一场对 JavaScript 的意识形态战争。
这也回应了怀疑者最常提出的那个问题。既然浏览器仍然负责 interaction polish、loading states 与直接的 DOM effects,那么 LiveView 到底简化了什么?答案在于 Phoenix 把这些客户端行为压进了 patch-safe commands 与 declarative component seams 里,而没有把它们提升成另一套独立的应用架构。[1][4] 客户端没有消失,但团队也不再需要为每个普通页面、modal 或 form flow 都再写一遍产品级的第二程序。
把整支视频收回来,最值得留下的判断是这一条:LiveView 最适合被理解成一份经过严格分工的责任分配。组件通过 attr 与 slot 在服务器一侧声明接口;LiveViews 把 state 与 event handling 留在同一条 process-oriented loop 里;generators 输出可迁移的 component seams;浏览器运行时则有意识地保持很小、对 patch 友好,并把优势集中在浏览器真正擅长的那部分工作上。[1][2][3][4][5][6] 这套架构没有试图抹掉客户端,它真正想做的,是阻止客户端出于惯性变成第二个 truth source。
来源
- ElixirConf,《ElixirConf 2022 - Chris McCord - Phoenix + LiveView Updates》,YouTube 视频,发布于 2022 年 9 月 8 日。
- Phoenix LiveView 文档,《Welcome》——把 LiveViews 定义为接收事件、更新状态、通过持久连接输出 diffs 的 server-rendered HTML 进程。
- Phoenix LiveView 文档,《
Phoenix.Component》——attr/3、slot/3、compile-time validations,以及phx-*、aria-*、data-*这类 global attributes。 - Phoenix LiveView 文档,《
Phoenix.LiveView.JS》——对 DOM patch 友好的 client commands、push 选项、loading states 与 optimistic client behavior。 - Phoenix Framework Blog,《Phoenix 1.7.0 released: Built-in Tailwind, Verified Routes, LiveView Streams, and core component-based generators》。
- Nimble,《Phoenix 1.7: A Major Step for the Phoenix Framework》——对 unified HTML rendering,以及 controller 与 LiveView 组件调用方式趋同的总结。
- ElixirConf 讲者页面资源——本文所用 Chris McCord 肖像图的来源文件。