author: Sophie DeBenedetto author_link: https://github.com/sophiedebenedetto categories: general tags: [‘live view’] date: 2019-12-29 layout: post title: LiveView Design Patterns - LiveComponent and the Single Responsibility Principle excerpt: >

It’s easy to end up with an overly complex LiveView that houses lots of business rules and responsibilities. We can use Phoenix.LiveComponent to build a LiveView feature that is clean, maintainable and adherent to the Single Responsibility Principle.

LiveView 设计模式 - LiveComponent 和单一责任原则

LiveView 可能会变得混乱

随着 LiveView 成为一项更加成熟的技术,我们自然会发现自己使用它来支持越来越多的复杂功能。如果我们不小心,可能会导致 “控制器臃肿综合症” — live view 中塞满了复杂的业务逻辑和不同的职责,就像经典的 “Rails 肥胖控制器”。

我们如何才能在遵守 SRP 等通用设计原则的同时,编写出易于推理和维护的实时视图呢?

实现这一目标的一种方法是利用 Phoenix.LiveComponent 行为。

Phoenix.LiveComponent 简介

组件是使用 Phoenix.LiveComponent 行为的模块。该行为提供在 LiveView 中对状态、标记和事件进行分类的机制。—文档

组件通过对 Phoenix.LiveView.live_component/3 的调用在 live view 父进程内运行。由于它们与父组件 live view 共享一个进程,因此两者之间的通信非常简单(稍后再谈)。

组件可以是无状态或有状态的。无状态的组件除了渲染一个特定的 leex 模板之外,不会做更多的事情,而有状态的组件实现了一个 handle_event/3 函数,允许我们更新组件自己的状态。这使得组件成为从过于复杂的实时视图中剥离责任的好方法。

让我们来看看我们如何使用组件来重构现有应用程序中的一些复杂的 LiveView 代码。

应用程序

假设我们有一个应用程序,它使用像 RabbitMQ 这样的消息代理在系统之间发布和消费消息。我们的应用程序将这些消息持久化在 DB 中,并为用户提供一个 UI,以列出和搜索这些持久化的消息。

live view messages index

我们使用 LiveView 来实现搜索功能、分页功能,并维护当前显示哪些消息的状态。我们的 live view 模块响应搜索表单事件,并维护搜索表单的状态,处理搜索表单的提交, 渲染各种搜索和分页参数的模板。

代码

我们的 live view 的简化版本看起来像这样:

  1. defmodule RailwayUiWeb.MessageLive.Index do
  2. def render(assigns) do
  3. Phoenix.View.render(RailwayUiWeb.MessageView, "index.html", assigns)
  4. end
  5. def mount(_session, socket) do
  6. socket =
  7. socket
  8. |> assign(:page, 1)
  9. |> assign(:search, %Search{query: nil, value: nil})
  10. |> assign(:messages, load_messages())
  11. {:ok, socket}
  12. end
  13. def handle_params(
  14. %{"page" => page_num, "search" => %{"query" => query, "value" => value}},
  15. _uri,
  16. %{assigns: %{search: search}} = socket
  17. ) do
  18. socket =
  19. socket
  20. |> assign(:page, page_num)
  21. |> assign(:search, Search.update(query, value))
  22. |> assign(:messages, messages_search(query, value, page_num))
  23. {:noreply, socket}
  24. end
  25. def handle_params(
  26. %{"page" => page_num},
  27. _uri,
  28. %{assigns: %{state: state}} = socket
  29. ) do
  30. socket =
  31. socket
  32. |> assign(:page, page_num)
  33. |> assign(:messages, messages_page(page_num))
  34. {:noreply, socket}
  35. end
  36. def handle_params(
  37. %{"search" => %{"query" => query, "value" => value}},
  38. _,
  39. %{assigns: %{search: search}} = socket
  40. ) do
  41. socket =
  42. socket
  43. |> assign(:search, %Search{query: query, value: value})
  44. |> assign(:messages, messages_search(query, value))
  45. {:noreply, socket}
  46. end
  47. def handle_params(_params, _, socket) do
  48. {:noreply, socket}
  49. end
  50. def handle_info("search", params, socket) do
  51. {:noreply,
  52. live_redirect(socket,
  53. to: Routes.live_path(socket, __MODULE__, params)
  54. )}
  55. end
  56. def handle_event(
  57. "search_form_change",
  58. %{"_target" => ["search", "value"], "search" => %{"value" => value}},
  59. %{assigns: %{search: search}} = socket
  60. ) do
  61. {:noreply, assign(socket, :search, %Search{query: search.query, value: value})}
  62. end
  63. def handle_event(
  64. "search_form_change",
  65. %{"_target" => ["search", "query"], "search" => %{"query" => query}},
  66. %{assigns: %{search: search}} = socket
  67. ) do
  68. {:noreply, assign(socket, :search, %Search{query: query, value: search.value})}
  69. end
  70. def handle_event(
  71. "search_form_change",
  72. %{"_target" => ["search", "query"], "search" => %{"value" => _value}},
  73. socket
  74. ) do
  75. {:noreply, socket}
  76. end
  77. end

在状态下保持搜索表单的选择查询和输入值的表示,可以让我们确保选择正确的搜索查询单选按钮,并允许我们更新搜索表单输入字段的占位符文本。

保持搜索表单的状态,还可以保证用户通过一组查询参数直接导航到 /consumed_messages 路径,不仅可以看到正确弹出的消息,还可以看到正确配置的搜索表单。

live component search form query params

问题

很明显,我们需要维护搜索表单的状态,但上面的 LiveView 代码太长,难以维护和推理。它管理搜索表单的状态,实现了一组 handle_params/3 回调来执行搜索查询和分页,并在状态中维护了一组消息。这是一个很大的工作,它违反了单一责任原则。简单地说,我们的 live view 做了太多的工作。

让我们把搜索表单的状态维护重构成有自己状态的组件吧!

解决方法: 搜索表单组件

我们的搜索表单组件将从父级 live view 中获取其初始搜索表单状态。这将确保用户可以直接导航到像 /consumed_messages?search[query]=uuid&search[value]=0af71c6a-aeec-431f-83d0-ae779358b055 这样的路由,并从 params 中看到正确配置的搜索表单。

但是,我们的搜索组件会继续保持搜索表单状态独立于父体,只有在表单提交时才会将消息转发到 live view 中。

这样一来,我们就可以将搜索表单变化事件的处理及其对搜索表单状态的后续影响移出 live view 。这将使我们在减少责任的情况下获得一个更干净的 live view。

定义组件

从 LiveView 设置初始化状态

我们先定义我们的组件 RailwayUiWeb.MessageLive.SearchComponent,并从父级 live view 中的状态进行初始搜索渲染。

  1. defmodule RailwayUiWeb.MessageLive.SearchComponent do
  2. use Phoenix.LiveComponent
  3. def render(assigns) do
  4. Phoenix.View.render(RailwayUiWeb.MessageView, "search_component.html", assigns)
  5. end
  6. end

在这一点上,我们的组件很简单。它使用 Phoenix.LiveComponent 行为并实现了 render/1 函数。这个函数渲染我们的 search_component.html.leex 模板(我们稍后会看一下),通过父 live view 调用 live_component/3 时建立的 assigns

现在我们来看看这个调用。在父 live view 的模板中,我们调用。

  1. <%= live_component @socket, RailwayUiWeb.MessageLive.SearchComponent, search: @search, id: :search %>

这里有两件重要的事情需要指出。首先,需要注意的是,我们传递了 :id 属性并将其设置为 :search 原子的值。通过设置 :id 属性,组件变得有状态。如果没有这个属性,我们就无法实现 handle_event/2 回调。

其次,我们用 @search 值填充组件的 assigns。此时组件的 assigns 是这样的。

  1. %{search: search}

而来自父 live view 的 socket.assigns 的搜索结构将在组件自己的模板中作为 @search

这使得我们可以利用父 live view 中的 handle_params/3 回调来建立搜索状态,然后将该搜索状态传递到组件中。让我们来仔细看看这是如何工作的。

  1. 用户访问 /consumed_messages?search[query]=uuid&search[value]=0af71c6a-aeec-431f-83d0-ae779358b055
  2. MessageLive.Index live view 的 handle_params/3 函数被调用:
  1. def handle_params(
  2. %{"search" => %{"query" => query, "value" => value}},
  3. _,
  4. %{assigns: %{search: search}} = socket
  5. ) do
  6. socket =
  7. socket
  8. |> assign(:search, %Search{query: query, value: value})
  9. |> assign(:messages, messages_search(query, value))
  10. {:noreply, socket}
  11. end
  1. MessageLive.Index live view 使用 @search 声明渲染模板
  2. MessageLive.Index 的模板调用 live_component/3, 传递 @search 声明
  3. MessageLive.SearchComponent 根据 @search 声明正确呈现搜索表单,以反映任何选定的搜索查询类型和输入。

现在让我们来看看组件的模板,以便了解它是如何利用搜索表单状态中的信息进行适当渲染的。

构建搜索表单模板

搜索组件的模板使用 @search 赋值的 query 和 value 属性,以确保选择正确的单选按钮,并确保搜索表单输入正确地填充一个值(如果存在)。

  1. <!-- styling removed for brevity -->
  2. <form>
  3. <div>
  4. <div>
  5. <input name="search[query]" value="uuid" type="radio" <%= if @search.query == "uuid", do: "checked" %>>
  6. <label class="form-check-label">message UUID</label>
  7. </div>
  8. <div>
  9. <input name="search[query]" value="correlation_id" type="radio" <%= if @search.query == "correlation_id", do: "checked" %>>
  10. <label class="form-check-label">correlation ID</label>
  11. </div>
  12. <div>
  13. <input name="search[query]" value="message_type" type="radio" <%= if @search.query == "message_type", do: "checked" %>>
  14. <label class="form-check-label">message type</label>
  15. </div>
  16. </div>
  17. <div>
  18. <input name="search[value]" value="<%= @search.value %>" type="text" placeholder="<%= "search by #{@search.query}" %>">
  19. </div>
  20. <button type="submit" class="btn btn-primary">Submit</button>
  21. </form>

这里有些事需要注意:

  • if 条件,如下面的条件,负责确保选择正确的单选按钮。
  1. if @search.query == "message_type", do: "checked"
  • 搜索表单的输入字段的 value 是由 @search 赋值的 value 属性填充的。

现在我们已经看到了我们的组件是如何呈现它的初始搜索表单状态的,让我们来看看我们的组件将如何处理搜索表单事件。

处理表格变化事件

我们需要更新组件的 socket.assigns 来反映两种情况下搜索表单状态的变化。

  • 用户选择一个给定的搜索查询(”消息UUID”、”相关ID”、”消息类型”)。
  • 用户在搜索表格输入栏中输入一个值。

我们将在表单中添加一个 phx-change 事件来捕捉这些交互,并在组件中定义相应的 handle_event/3 回调。

  1. <form phx-change="search_form_change">
  2. ...
  3. </form>

我们将添加下面的 handle_event/3 回调

  1. defmodule RailwayUiWeb.MessageLive.SearchComponent do
  2. ...
  3. # update search state when user inputs a search value
  4. def handle_event(
  5. "search_form_change",
  6. %{"_target" => ["search", "value"], "search" => %{"value" => value}},
  7. %{assigns: %{search: search}} = socket
  8. ) do
  9. {:noreply, assign(socket, :search, %Search{query: search.query, value: value})}
  10. end
  11. # update search state when user selects a query type radio button
  12. def handle_event(
  13. "search_form_change",
  14. %{"_target" => ["search", "query"], "search" => %{"query" => query}},
  15. %{assigns: %{search: search}} = socket
  16. ) do
  17. {:noreply, assign(socket, :search, %Search{query: query, value: search.value})}
  18. end
  19. end

这些回调为我们确保了两件事。

  • 当用户选择一个新的搜索查询类型选项时,正确的单选按钮被标记为 “选定”。
  • 搜索表单输入的 placeholder 属性被正确更新,以反映所选的查询类型。
  1. <input name="search[value]" value="<%= @search.value %>" type="text" placeholder="<%= "search by #{@search.query}" %>">

处理表格提交

现在,我们表单组件的状态已经可以根据用户的交互正确更新了,我们来谈谈用户提交表单时需要发生的事情。

我们正在设计的功能需要我们在用户提交搜索表单时,在浏览器的 URL 栏中填充查询参数。这样用户就可以共享某个搜索结果与链接。

为了达到这个目的,我们可以使用 live_redirect/2 函数。这将利用浏览器的 pushState API 来改变页面导航,而不需要实际发送一个 web 请求。取而代之的是,我们的 live view 的 handle_params/3 回调函数将被调用,允许我们通过搜索适当的消息和更新 live view socket 的状态来响应。

但是等一下! 很遗憾,由于 Phoenix.LiveComponent 行为没有实现 handle_params/3 函数,所以 live_redirect/2 函数在组件内部无法使用。但幸运的是,父 live view 和组件共享一个进程。这意味着从组件内部调用 self() 会返回一个 PID,这个 PID 与父 live view 进程 是相同的。因此,在我们的组件中,我们可以 send 一个消息到 self(),并在父 live view 中处理该消息。

我们将利用这个功能,让我们的组件通过向父 live view 发送消息来处理提交事件中的搜索,指示该 live view 执行实时重定向。

我们首先在组件模板中为搜索表单添加一个 phx-submit 绑定事件:

  1. <form phx-submit="search" phx-change="search_form_change">
  2. ...
  3. </form>

然后我们需要为 "search" 事件实现一个 handle_event/3 函数

  1. defmodule RailwayUiWeb.MessageLive.SearchComponent do
  2. ...
  3. def handle_event("search", params, socket) do
  4. send self(), {:search, params}
  5. {:noreply, socket}
  6. end
  7. end

我们函数中最重要的一部分是这一行:

  1. send self(), {:search, params}

在这里,我们将发送一个消息 {:search, params} ,让父级 live view 可以响应。

最后,我们将在父 live view 中实现一个 handle_info/2 回调,它将负责用搜索表单中的 params 执行实时重定向:

  1. defmodule RailwayUiWeb.MessageLive.Index do
  2. ...
  3. def handle_info({:search, params}, socket) do
  4. {:noreply,
  5. live_redirect(socket,
  6. to: Routes.live_path(socket, __MODULE__, params)
  7. )}
  8. end
  9. end

这将反过来导致 live view 的 handle_params/3 回调被调用,从而正确更新 live view 的状态。

  1. defmodule RailwayUiWeb.MessageLive.Index do
  2. ...
  3. def handle_params(
  4. %{"search" => %{"query" => query, "value" => value}},
  5. _,
  6. %{assigns: %{search: search}} = socket
  7. ) do
  8. socket =
  9. socket
  10. |> assign(:search, %Search{query: query, value: value})
  11. |> assign(:messages, messages_search(query, value))
  12. {:noreply, socket}
  13. end
  14. end

结语

作为这次重构的结果,我们有了一个更干净的 live view 模块,更遵守单一责任原则。我们的 live view 可以专注于给定一组 params 的正确状态的设置。同时,维护搜索表单的状态和适当地呈现搜索表单属性所需的逻辑可以放在一个专门的组件中。

当我们发现自己无法在组件中使用 live_redirect/2 时,我们确实遇到了一个障碍。然而,由于组件和 live view 共享一个流程,我们发现很容易在两者之间实现通信。

不过,这种方法还是不能让我们建立一个完全不知道搜索表单状态的 live view 。为了让用户直接导航到带有查询参数的路线,我们的父级 live view 确实设置了搜索表单的初始状态,并将其传递到组件中。不管这个缺点如何,在这里组件已经达到让我们能够编写和维护一个更纤细的 live view。

要想了解 LiveView 提供的其他一些状态、标记和事件处理隔离选项,请查看文档