Skip to content

搞英语 → 看世界

翻译英文优质信息和名人推特

Menu
  • 首页
  • 作者列表
  • 独立博客
  • 专业媒体
  • 名人推特
  • 邮件列表
  • 关于本站
Menu

使用 Phoenix Channels 构建可嵌入组件

Posted on 2025-12-26

当你销售商品时,用户每次离开你的网站都可能错失一笔销售机会。客户一直要求我们允许用户无需离开品牌网站即可购票。他们很喜欢这项服务,但同时也希望保持品牌形象和一致性。

需求看似简单:在任何网页上放置一个小部件,显示一些活动,让用户完成结账。但随着我们深入研究,技术难题开始接踵而至。如何在页面刷新后保持购物车状态?当购买发生在其他网站的域名上时,如何追踪营销归因?以及在本质上属于第三方的情况下,如何安全地处理支付?

本文简要介绍了我们尝试了多种方法,遇到了重重阻碍,最终选择 Phoenix Channels 作为解决方案的过程。

商业问题

我们的平台负责喜剧演出和活动的票务。大多数客户都有自己的网站来宣传演出,但当有人想购票时,他们会被重定向到我们的结账流程。如果您是从谷歌或社交媒体平台跳转过来的,这不会造成任何问题;但如果您是从其他网站跳转过来的,并且已经在浏览活动信息,那么这种重定向就会造成不便。

数据非常清晰地表明:每当用户离开原网站时,购物车放弃率就会飙升。人们点击“购买门票”,跳转到我们的域名,然后被其他事情分散了注意力,最终没有完成购买。我们的客户希望找到一种方法,让买家在整个购买流程中都留在他们自己的网站上。

因此,我们着手开发一个可嵌入的组件,以处理完整的购买流程。浏览活动、添加到购物车、输入客户信息、使用 Stripe 付款、获取确认信息,所有操作均无需离开活动页面。

我们需要解决的问题

除了基本的结账流程之外,我们还有很多技术要求,这使得这项工作变得棘手。

首先,我们需要追踪营销参数。当用户访问 URL 中包含 utm_campaign 或 fbclid 的页面时,这些值需要跟随用户完成整个结账流程,以便我们能够正确地将销售归因于该参数。推荐码和其他追踪标识符也需要遵循同样的规则。

其次,我们需要进行位置检测。不同地区的货币和税收规则各不相同。我们需要确定用户来自哪里,并应用正确的定价策略。

第三,购物车持久性。如果有人将门票添加到购物车,然后刷新页面,购物车里的门票应该仍然存在。理想情况下,如果他们第二天回来,情况也应该如此。

第四,转化跟踪。当订单完成时,我们需要触发 Meta Pixel 事件、Google Tag 转化事件和 Reddit Pixel 跟踪。这些都是营销归因分析中常用的工具。

最后,所有这些功能都必须能在任何网站上正常运行,无论它们使用何种技术栈。无论是 WordPress、Squarespace、自定义 Rails 应用还是静态 HTML,这个小部件都必须能够正常工作。

我们尝试过的方法

使用 REST 的 Web 组件

我们的第一反应显而易见:构建一个 Web 组件,通过 REST 调用我们的 API 来完成所有操作。添加到购物车?发送 POST 请求。更新数量?发送 PUT 请求。结账?发送更多请求。

实际上,几年前我们用Stencil构建过一个基于这种模式的早期实现。我们很快就做出了一个可运行的原型。问题在于状态管理。为了保持 Web 组件 UI 状态的同步,每次状态发生变化时都需要重新获取数据,这使得组件变成了一个巨大的 HTTP 客户端,包含大量的样板代码和错误处理代码。这本身并没有什么问题,但 Elixir 开发的核​​心原则之一是减少样板代码,并优先使用消息传递而非 RPC,因此我们很快就放弃了这种方法。

更大的问题在于,我们基本上是用 JavaScript 重建了整个结账流程。所有的价格格式化逻辑、折扣计算、基于产品配置的条件显示规则,这些我们之前在 Elixir 中都已经完美实现了。在客户端重复编写感觉很不妥。客户端代码本身并不复杂,但 Web 组件渲染的内容和我们网站渲染的内容存在大量重复。

iframes

接下来我们尝试了 iframe 方法。只需将现有的结账页面嵌入到宿主页面的 iframe 中即可。这样,我们就可以完全复用 LiveView 的所有代码,无需任何修改。

跨域通信是第一个问题。要让父页面和 iframe 互相通信,需要调用大量的 postMessage 函数,并仔细检查其来源。但真正的难题在于第三方 cookie。

现代浏览器正在严厉打击第三方 Cookie。Safari 默认已经屏蔽了 Cookie,Chrome 正在逐步淘汰它们,Firefox 也做出了限制。我们的会话管理依赖于 Cookie,结果突然间,一半的用户在结账流程中无法保持登录状态。尽管概念验证 (PoC) 成功,但 iframe 方案从一开始就失败了。追踪 JavaScript 技术栈并了解浏览器和库的最新动态,实在是不值得投入精力。

LiveView 内置于 Web 组件中

这个方案看起来很有前景。Phoenix LiveView 能提供我们想要的实时响应能力。如果我们能把 LiveView 挂载到 Web 组件的 Shadow DOM 里呢?

我们花了大量时间尝试解决这个问题。问题在于 LiveView 的 DOM 修补机制与 Shadow DOM 的封装机制存在根本冲突。LiveView 需要拥有它所管理的 DOM 树,而 Shadow DOM 创建了一个修补算法无法正确跨越的边界。事件委托机制因此失效。变形 DOM 的更新无法正确穿透 Shadow DOM 的边界。

我们查看了 Phoenix 的源代码,浏览了论坛和 Discord 上的讨论。大家的共识很明确:这不是一个受支持的模式,而且可能永远也不会受支持,即使像live_portal这样的库正在努力解决这个问题。

其他图书馆

我们还评估了许多其他方案。Hologram 看起来很有意思,但将 Web 组件的 socket 连接到 Hologram 似乎不太稳定且具有侵入性,尤其考虑到 Hologram 还处于早期阶段,其 API 很可能会发生变化。LiveVue 和 LiveSvelte的设计初衷就是为了让它们在 LiveView 中使用,而不是反过来。

他们都没能给我们提供我们需要的东西。

为什么凤凰频道奏效

当我们不再尝试嵌入 LiveView,而是开始思考我们真正需要从服务器获得什么时,突破就出现了。

我们不需要完整的 LiveView 抽象层。我们需要的是实时通信和状态管理。我们需要服务器能够在状态发生变化时推送更新。而我们已经有了这样的基础设施:Phoenix Channels。

通道提供持久的 WebSocket 连接,内置主题订阅、消息传递和在线状态跟踪功能。我们在平台的其他功能中广泛使用通道。为什么不将其用于小部件呢?

关键在于意识到我们可以通过通道发送渲染后的 HTML 而不是 JSON。这乍听起来似乎与预期相反。难道不应该是先发送数据,然后让客户端渲染吗?但请听我解释。

通过在服务器端渲染 HTML,我们可以重用所有现有的 Elixir 代码。支持多种货币和语言的价格格式?已经编写好了。根据品牌设置而变化的显示逻辑?只需调用一个函数即可。针对已售罄活动和仍在售活动的条件渲染?使用我们在其他地方使用的相同模板。

如果我们发送 JSON 数据,就得用 JavaScript 重复编写所有这些代码,而且还得保持两种实现方式的同步。这或许符合某种深奥的关注点分离原则,但感觉既笨拙又没必要。而且,本质上我们用的还是同一个应用。

WebSocket 的另一个优点是速度快。一旦连接建立,每次交互都不需要支付 TCP 握手的费用。头部信息几乎被精简到极致。对于像购物车这样在一个会话中可能进行数十次更新的场景,这种开销的减少非常显著。通道端对调用的典型响应如下所示:

  @impl 真的  
  定义 handle_in ( "event" , %{ “类型” => "前往结账" }, 插座) 做  
    购物车ID = socket.assigns [ : cart_id ]​  
  
    如果 购物车ID 做  
      { :好的, 购物车} = CartContext.get_cart ( cart_id )​​  
      {插座, 回复} = prepare_checkout_view (套接字, cart_id , 购物车.region_id ) # 完整 HTML  
      { :回复, { :好的, 回复}, 插座}  
    别的  
      { :回复, { :错误, %{原因: “没有可用的购物车” }}, 插座}  
    结尾  
  结尾  

浏览器支持曾经是一个令人担忧的问题,但自 2011 年以来,WebSocket 在所有主流浏览器上的兼容性一直很稳定。这并非一项实验性技术。

建筑

以下是各个部分如何组合在一起的。

在客户端,我们有一个名为product-list-widget Web 组件。您只需将其添加到任何带有脚本标签和自定义元素的页面上,并将组件 ID 作为属性传递给它,剩下的工作就交给它来处理了。

我们曾纠结于使用Lit还是Stencil来构建 Web 组件。几年前,我们在最初的基于 REST 的实现中使用了 Stencil,但这次我们选择了 Lit。Stencil 更侧重于构建设计系统和组件库,它拥有自己的编译器和构建工具链。而对于我们这种只有一个功能单一的组件的用例来说,Lit 更轻量级的实现方式更合适。你只需要继承一个基类即可。

组件挂载时,会与我们的 Phoenix 应用程序建立 WebSocket 连接,并加入该组件特有的通道。服务器上的通道处理程序会查找组件配置,获取相关产品,渲染初始 HTML,然后将其发送回服务器。

从这里开始,所有用户交互都通过这个通道进行。点击“添加到购物车”?组件发送一条消息,服务器更新购物车,渲染新的状态,并返回 HTML 代码。结账过程也遵循同样的模式。服务器处理业务逻辑,并返回下一个要显示的视图。

网站托管(任何域名)  
    └── 产品列表小部件(Web 组件)  
          └── 凤凰频道连接  
                └── 产品列表频道  
                      └── 结账服务(与主站点共享)  
                            └── 我们所有现有的 Elixir 代码  

Web 组件提供了封装机制,因此我们的样式不会泄露到宿主页面,反之亦然。Shadow DOM 将所有内容都控制在内。

处理参数

我们遇到的首要问题之一是跟踪参数。当用户访问包含营销UTM参数的URL页面时,我们需要捕获这些参数,并将它们与发生的任何购买行为关联起来。

解决方案很简单。小部件初始化时,会读取当前 URL 并提取我们关心的所有参数,例如:UTM 广告系列、来源、媒介;Facebook 点击 ID;来自我们联盟计划的推荐码;以及用于享受特价的场地访问码。

这些信息会在加入频道时发送。在服务器端,我们会将它们存储在套接字分配中,并在创建购物车时将它们写入购物车上下文。

常量 跟踪参数 = {  
  utm_campaign : url.searchParams.get ( 'utm_campaign' ),  
  utm_source : url.searchParams.get ( 'utm_source' ),  
  fbclid : url.searchParams.get ( 'fbclid' ),  
  customer_referral_code : url.searchParams.get ( 'customer_referral_code' ),  
  参考: url.searchParams.get ( 'ref' )  
};  
  
channel.join ( "product_list : 1 " , { tracking_params : 跟踪参数 });  

当订单最终完成时,所有这些参数都存在于购物车上下文中,准备好供我们的分析管道处理。

位置检测

货币和税收规则取决于买家所在地。我们通过从套接字连接中获取 IP 地址并将其输入地理定位服务来处理此问题。

通道加入处理程序会从套接字中获取 IP 地址,查找位置,并使用该位置信息来确定默认货币和地区。如果我们检测到用户位于加拿大,则他们会看到加元;如果用户位于美国,则会看到美元。

这个过程在首次连接时自动完成,用户无需从下拉菜单或其他任何方式选择国家/地区。我们会自动识别并显示相关的价格信息。

购物车持久性

LocalStorage负责客户端购物车数据的持久化。当我们在服务器端创建购物车时,会返回一个购物车 ID。组件会将该 ID 存储在 LocalStorage 中,并以组件 ID 作为键进行存储。

下次页面加载时,该组件会检查是否存在购物车 ID,并在加入频道时将其发送。服务器会验证购物车是否存在且仍然可用,然后恢复之前的状态。如果购物车已过期或已完成购买,则重新开始。

私人的 getStoredCartId () : 细绳 | 无效的 {  
  返回 localStorage.getItem ( ` widget_cart _ $ { this.widgetId } ` ) ;  
}  
  
私人的 storeCartId ( cartId : 细绳) : 空白 {  
  localStorage.setItem ( ` widget_cart _ $ { this.widgetId } ` ,​ cartId );  
}  

这样就完全绕过了第三方 Cookie 的问题LocalStorage按来源进行分区,但小部件的 JavaScript 代码在宿主页面的上下文中运行,因此它可以访问该页面的存储空间。

使用 Idiomorph 进行 DOM 差异比较

这里有一个对用户体验影响巨大的细节。当服务器返回新的 HTML 时,我们不会直接用 innerHTML 将其写入 DOM。那样做会导致输入框焦点重置、滚动位置丢失,整体体验非常卡顿。

我们使用Idiomorph库,它能进行智能的 DOM 差异比较和变形。你只需提供当前的 DOM 和你想要的新 HTML,它就能计算出将两者转换所需的最小更改。未更改的元素保持不变。焦点会留在你正在输入的内容上。滚动位置也会被保留。

进口 { 异形体 } 从 'idiomorph' ;  
  
私人的 updateContent ( html : 细绳) {  
  Idiomorph.morph ( this.contentContainer ,​​​​ html , {  
    变形样式: 'innerHTML'  
  });  
}  

这样我们就能在不使用 LiveView 的情况下实现类似 LiveView 的响应式效果。当你更新购物车数量时,页面其余部分会保持在原来的位置。你不会丢失当前页面,也不需要重新聚焦到该字段上。整个过程非常流畅。

支付集成

Stripe Elements 负责处理支付界面。客户填写完信息并点击“继续”后,我们会在服务器端创建一个 PaymentIntent,并将客户端密钥以及显示支付表单的指令发送回服务器。

该组件将 Stripe Elements 挂载到 Shadow DOM 中的一个容器中。用户输入银行卡信息后,我们通过 Stripe 确认付款,确认成功后,我们会通过该渠道发送消息以完成订单。

免费订单完全跳过 Stripe 环节。如果购物车总额为零(可能是使用了 100% 折扣码),我们会直接提交订单,无需进行支付处理。

转化跟踪

要让转化像素正确触发,我们费了一番功夫。在我们的主站上,我们使用 LiveView 的push_event/3将转化数据发送到 JavaScript 钩子,该钩子会触发各种跟踪像素。但在组件上下文中,我们没有 LiveView 钩子。

解决方案是将转化事件数据包含在订单成功响应中。订单完成后,服务器会查找该帐户配置了哪些分析元素,为每个元素构建事件有效负载,并将其包含在响应中。

 { :好的, 转换事件} = CheckoutService.get_order_conversion_data ( order_id )​​  
  
{ :回复, { :好的, %{  
  html : render_success_view (订单数据),  
  看法: “成功” ,  
  转换事件: 转换事件  
}}, 插座}  

在客户端,我们遍历这些事件,并使用相应的全局函数触发它们。 fbq用于 Meta, gtag用于 Google, rdt用于 Reddit。

如果 (响应.转换事件? .长度) {  
  设置超时() => {  
    this.fireConversionEvents ( response.conversion_events ) ;​​​  
  }, 2000年);  
}  

两秒的延迟与我们在主站上的做法一致。这可以让页面有时间稳定下来,然后再开始向第三方跟踪服务发出网络请求。

这里需要注意一点:宿主页面必须加载这些跟踪库。如果宿主页面没有包含 Facebook Pixel 脚本,我们的fbq调用就会静默失败。我们会向控制台记录一条警告,但程序不会崩溃。

减少重复工作

我们最担心的事情之一就是维护两个并行的实现。主站点的结账流程和插件结账流程虽然功能相同,但实现方式略有不同。

我们通过将共享逻辑提取到 CheckoutService 模块中解决了这个问题。购物车验证、客户添加、Stripe 设置、订单完成,所有核心结账操作都集中在一个地方,并由 LiveView 代码和渠道处理程序调用。

 defmodule AmplifyWeb.CheckoutService 做  
  定义 complete_cart_with_validation ( cart_id )  
  定义 setup_stripe_payment ( cart_id , 货币代码)  
  定义 add_customer_to_cart ( cart_id , customer_params , 选项)  
  定义 validate_customer_params ( params )  
  定义 get_order_conversion_data ( order_id )  
结尾  

在此次重构之前,我们把相同的购物车完成逻辑分别写在了三个不同的地方。随着时间的推移,每个地方都存在一些细微的差别。将它们合并到一个服务中,就减少了大约一百行重复代码,并让我们拥有了一个统一的实现,只需维护一个规范的版本即可。

转化事件构建就是一个很好的例子。 CartComplete实时视图和组件通道都需要使用相同的数据触发相同的跟踪像素。现在,它们都调用同一个get_order_conversion_data/1函数,并获取相同的事件。

我们没有尝试共享的是模板渲染。由于该组件通过通道返回原始 HTML,因此它使用字符串插值模板。而主站点则使用 HEEx 模板,并具备 LiveView 的所有编译时特性。这些本质上是不同的渲染上下文,试图对它们进行抽象反而得不偿失。DRY 原则并非总是最佳选择,有时一些重复代码也是可以接受的。

单体仓库结构

我们将 Lit 小部件的代码与 Phoenix 应用放在同一个代码仓库中。该小部件位于assets/widget/目录下,拥有自己的package.json 、TypeScript 配置和构建脚本。起初,这并非一个显而易见的选择,因为我们本可以创建一个单独的 npm 包并独立发布。

单体仓库方案最终胜出有几个原因。首先,组件和服务器紧密耦合。当我们更改通道发送数据的方式时,通常也需要更新组件处理数据的方式。将两者放在同一个仓库中意味着我们可以在一次提交中原子地完成这些更改。无需版本协调,也无需担心已部署的组件是否与已部署的服务器匹配。

其次,是共享类型。我们从 Elixir 结构体生成 TypeScript 接口,用于定义频道消息格式。将所有内容集中在一起意味着类型会自动保持同步。当我们向响应中添加字段时,TypeScript 编译器会立即告知我们所有需要更新的地方。

第三,更简化的持续集成。一个代码仓库对应一条流水线。我们运行 Elixir 测试、构建组件,然后一起部署。如果组件构建失败,整个部署也会失败。这样就不会出现因为有人忘记更新版本号而导致组件损坏的情况。

构建过程很简单。我们有一个 mix 任务,它会调用 npm 来构建小部件,然后将输出复制到priv/static/widget/ 。小部件的 JS 和 CSS 文件会作为静态资源提供。当用户引入我们的 script 标签时,他们会从提供其他静态文件的同一个 CDN 拉取资源。

放大/  
├── lib/ # Elixir 代码  
│ └── amplify_web/  
│ └── 频道/  
│ └── product_list_channel.ex  
├── 资产/  
│ ├── js/ # 主应用程序 JavaScript  
│ └── widget/ # 小部件包  
│ ├── src/  
│ │ └── product-list.ts  
│ ├── package.json  
│ └── tsconfig.json  
└── priv/static/widget/ # 已构建的小部件输出  

这也有助于提升客户用户体验,他们只需将脚本标签粘贴到 Squarespace 网站即可。我们自行托管小部件并将其保存在单体仓库中,从而能够完全掌控用户体验。

我们学到了什么

先从你已有的基础组件入手。我们之前浪费了不少时间,试图把 LiveView 硬塞进它原本不适用的场景。其实 Phoenix Channels 一直都在那里,久经考验,随时可用。有时候,答案并非新的库或巧妙的变通方案,而是你多年来一直在使用的那些看似平淡无奇的基础架构。

通过 WebSocket 进行服务器端渲染的 HTML 被低估了。传统观点认为应该发送 JSON 数据并在客户端渲染。但这只有在服务器端没有渲染逻辑的情况下才有意义。我们之前用 Elixir 编写了多年的代码来处理价格格式化、折扣计算和条件显示逻辑等各种特殊情况。发送 HTML 意味着我们可以重用所有这些代码。这个组件免费获得了经过生产环境测试的渲染功能。

对于任何动态 UI 来说,DOM 差异库都是必备的。我们差点就直接使用原始的 innerHTML 更新了。直到第一次看到用户因为整个容器重新渲染而丢失表单填写位置时,我们才意识到需要 Idiomorph。集成过程大概花了两个小时。用户体验的提升立竿见影,显而易见。如果你需要动态更新 DOM 内容,那就一定要使用变形库。这不是可选项。

对于跨域状态管理,LocalStorage 比 cookie 更胜一筹。iframe方案因第三方 cookie 的限制而失效。LocalStorage 则不存在这个问题,因为组件运行在宿主页面的上下文中。其缺点是购物车现在是按设备而非按用户会话管理的,但对于我们的用例来说,这完全可以接受。毕竟,大多数购票操作都是在单个会话中完成的。

当客户端和服务器紧密耦合时,单体仓库(Monorepo)可以简化一切。我们曾短暂考虑过将组件发布为一个独立的 npm 包。但那样一来,协调工作量将会非常巨大。每次频道消息格式的更改都需要在不同仓库之间同步版本。将所有内容放在一起意味着可以实现原子提交和单一部署流水线。这种简洁性值得我们付出稍大一些的仓库体积。

总结

Phoenix Channels 为我们构建真正可用的嵌入式组件提供了所需的基础。持久化的 WebSocket 连接能够优雅地处理实时更新。发送渲染后的 HTML 使我们能够重用现有的 Elixir 代码库。Idiomorph 则保证了 DOM 更新的流畅性。

如果你正在使用 Elixir 和 Phoenix 构建类似的功能,不妨考虑一下 Channels 是否比直接嵌入 LiveView 更合适。Web Component + Channel + 服务器端渲染 HTML 的这种模式非常强大,而且可以避免我们在其他方法中遇到的许多复杂性。

该组件现已在客户网站的不同平台和技术栈上投入生产环境运行。只需添加一个脚本标签和自定义元素,即可实现完整的结账流程,无需将用户重定向到其他页面。以下是最终组件的样式:

小部件

原文: https://zarar.dev/building-embeddable-widgets-with-phoenix-channels/

本站文章系自动翻译,站长会周期检查,如果有不当内容,请点此留言,非常感谢。
  • Abhinav
  • Abigail Pain
  • Adam Fortuna
  • Alberto Gallego
  • Alex Wlchan
  • Alin Panaitiu
  • Anil Dash
  • Answer.AI
  • Arne Bahlo
  • Ben Carlson
  • Ben Kuhn
  • Bert Hubert
  • Big Technology
  • Bits about Money
  • Brandon Skerritt
  • Brian Krebs
  • ByteByteGo
  • Chip Huyen
  • Chips and Cheese
  • Christopher Butler
  • Colin Percival
  • Cool Infographics
  • Dan Sinker
  • David Walsh
  • Dmitry Dolzhenko
  • Dustin Curtis
  • eighty twenty
  • Elad Gil
  • Ellie Huxtable
  • Ethan Dalool
  • Ethan Marcotte
  • Exponential View
  • FAIL Blog
  • Founder Weekly
  • Geoffrey Huntley
  • Geoffrey Litt
  • Greg Mankiw
  • HeardThat Blog
  • Henrique Dias
  • Herman Martinus
  • Hypercritical
  • IEEE Spectrum
  • Investment Talk
  • Jaz
  • Jeff Geerling
  • Jonas Hietala
  • Josh Comeau
  • Lenny Rachitsky
  • Li Haoyi
  • Liz Danzico
  • Lou Plummer
  • Luke Wroblewski
  • Maggie Appleton
  • Matt Baer
  • Matt Stoller
  • Matthias Endler
  • Mert Bulan
  • Mind Matters
  • Mostly metrics
  • Naval Ravikant
  • News Letter
  • NextDraft
  • Non_Interactive
  • Not Boring
  • One Useful Thing
  • Phil Eaton
  • PostHog
  • Product Market Fit
  • Readwise
  • ReedyBear
  • Robert Heaton
  • Rohit Patel
  • Ruben Schade
  • Sage Economics
  • Sam Altman
  • Sam Rose
  • selfh.st
  • Shtetl-Optimized
  • Simon schreibt
  • Slashdot
  • Slava Akhmechet
  • Small Good Things
  • Steph Ango
  • Stephen Wolfram
  • Steve Blank
  • Taylor Troesh
  • Telegram Blog
  • The Macro Compass
  • The Pomp Letter
  • thesephist
  • Thinking Deep & Wide
  • Tim Kellogg
  • Understanding AI
  • Wes Kao
  • 英文媒体
  • 英文推特
  • 英文独立博客
©2026 搞英语 → 看世界 | Design: Newspaperly WordPress Theme