LiveView 的生命周期:mount、handle_event 和 Socket 到底怎么运转

前言

先说一个我自己刚上手 LiveView 时的真实感受:

它看起来像在写页面,实际是在写一个服务端进程。

这句话如果没转过来,后面会非常容易写出一堆"能跑,但是味儿不对"的代码。

我第一次写 LiveView 的时候,脑子里还是 React 那套模型:

  • 页面初始化就是组件挂载
  • 点击按钮就是前端事件
  • URL 变化就是路由状态
  • props 传进来,state 自己维护

结果一上手就踩坑。

我在 mount/3 里打日志,发现它竟然跑了两次;我在 mount/3 里订阅 PubSub,结果调试时一脸懵;我把各种参数解析、数据库查询、事件处理全塞进同一个地方,代码很快就开始发臭。

后来我才意识到,LiveView 的生命周期不是"前端组件生命周期"的 Elixir 版。

它真正的心智模型是:

一次 LiveView 页面,先经历普通 HTTP 渲染,再升级成一个有状态的服务端进程。浏览器负责发事件,服务端进程负责改状态,LiveView 负责把 DOM diff 推回浏览器。

这篇就把这条线掰开讲清楚。

1. 先把整条生命周期跑一遍

如果只记一句话,我建议记这个:

LiveView 不是一上来就是 WebSocket,它先是一次普通 HTTP 请求。

一个通过 router 挂载的 LiveView,典型流程大概是这样:

text 复制代码
浏览器发起 HTTP GET
        |
        v
mount/3                 # 第一次,静态渲染阶段
        |
        v
handle_params/3         # 如果这个 LiveView 挂在 router 上
        |
        v
render/1                # 返回一份普通 HTML
        |
        v
浏览器加载页面,LiveSocket 建立连接
        |
        v
mount/3                 # 第二次,connected 阶段
        |
        v
handle_params/3
        |
        v
render/1                # 之后通过 WebSocket 推 diff
        |
        +--> handle_event/3   # 客户端 phx-click/phx-submit/phx-change
        |
        +--> handle_info/2    # 服务端消息、PubSub、定时器
        |
        +--> handle_params/3  # live patch 导致 URL 参数变化

注意这里有几个重点:

  1. mount/3 通常会跑两次。
  2. handle_params/3mount/3 后面跑,也会因为 live patch 再跑。
  3. handle_event/3 处理的是浏览器发来的事件。
  4. handle_info/2 处理的是服务端进程收到的消息。
  5. 每次你改了 socket.assigns,LiveView 都会重新 render,然后把变化推给浏览器。

所以 LiveView 的生命周期,本质上不是"页面生命周期",而是:

HTTP 初始渲染 + WebSocket 连接后的服务端进程事件循环。

这个理解一旦建立,很多 API 就突然合理了。

2. socket.assigns:别再把它当 props 了

很多前端同学看 LiveView 第一眼,会自然把 assigns 理解成 props。

这个类比能帮你入门,但不能一直这么想。

在 React 里,props 是父组件传给子组件的数据;state 是浏览器内存里的组件状态。

在 LiveView 里,socket.assigns 是服务端 LiveView 进程维护的一份状态 map。

举个最小例子:

elixir 复制代码
defmodule DemoWeb.CounterLive do
  use DemoWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("inc", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"""
    <div>
      <p>count: <%= @count %></p>
      <button phx-click="inc">+1</button>
    </div>
    """
  end
end

这里的 @count 来自 socket.assigns.count

按钮点击以后,浏览器不是自己把 count + 1,而是发一个 "inc" 事件到服务端。服务端在 handle_event/3 里更新 socket.assigns,然后 LiveView 重新渲染,把 diff 推回浏览器。

所以我现在更愿意这么理解:

socket.assigns 是这张页面在服务端的状态快照。

它不是数据库,不是 session,也不是前端缓存。它的生命周期跟当前 LiveView 连接绑定。

这点很关键,因为它会直接影响你怎么写代码。

一个我踩过的坑:把 assigns 当成"万能仓库"

我一开始很容易把所有东西都塞进 assigns:

elixir 复制代码
assign(socket,
  current_user: user,
  posts: posts,
  all_tags: all_tags,
  permissions: permissions,
  raw_payload: payload,
  debug_meta: meta
)

看起来省事,实际上后面会很难受。

LiveView 的 diff 很聪明,但不是魔法。你塞进 assigns 的东西越杂,模板依赖越乱,后面追踪"到底哪个状态导致页面变了"就越痛苦。

我的经验是:

  • 模板真的要用的,放 assigns
  • 事件处理临时要用的,尽量局部变量解决
  • 大对象、原始 payload、调试信息,不要顺手塞进去
  • 列表很大时,优先考虑后面会讲到的 streams

一句话:

assigns 要像页面状态,不要像垃圾桶。

3. mount/3:初始化状态,但别乱做副作用

mount/3 是 LiveView 的入口。

它有三个参数:

elixir 复制代码
def mount(params, session, socket) do
  {:ok, socket}
end

这三个参数分工很清楚:

  • params:URL 里的公开参数,用户可以改,不可信
  • session:服务端放进 session 的私有数据,常用来拿当前用户信息
  • socket:当前 LiveView 的 socket,里面会放 assigns

举个例子:一个用户详情页。

elixir 复制代码
defmodule DemoWeb.ProfileLive do
  use DemoWeb, :live_view

  alias Demo.Accounts

  def mount(%{"id" => id}, %{"user_token" => token}, socket) do
    current_user = Accounts.get_user_by_session_token(token)
    profile = Accounts.get_profile!(id)

    {:ok,
     socket
     |> assign(:current_user, current_user)
     |> assign(:profile, profile)}
  end
end

这段能说明 mount/3 适合做什么:

  • 初始化页面必须的数据
  • 根据 session 恢复当前用户
  • 给模板准备第一屏需要的 assigns

但这里有一个 LiveView 新手必踩坑:

mount/3 通常会跑两次。

第一次是静态 HTTP 渲染,第二次是 WebSocket 连接建立后的有状态渲染。

你可以用 connected?/1 判断当前 socket 是否已经连上:

elixir 复制代码
def mount(_params, _session, socket) do
  IO.inspect(connected?(socket), label: "connected?")

  {:ok, assign(socket, count: 0)}
end

第一次通常是:

text 复制代码
connected?: false

第二次是:

text 复制代码
connected?: true

错误写法:在 mount/3 里无脑做副作用

比如你写一个实时订单页,想订阅订单状态变化:

elixir 复制代码
def mount(%{"id" => id}, _session, socket) do
  Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}")

  {:ok, assign(socket, order_id: id)}
end

这段代码的问题不是"完全不能跑",而是心智不对。

静态渲染阶段的进程很快就结束了,你在这个阶段做订阅没有意义。更麻烦的是,如果这里换成外部 API 调用、发消息、写日志、打点、创建任务,就可能出现重复执行或无意义执行。

正确写法一般是:

elixir 复制代码
def mount(%{"id" => id}, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}")
  end

  order = Orders.get_order!(id)

  {:ok,
   socket
   |> assign(:order_id, id)
   |> assign(:order, order)}
end

我的判断标准很简单:

  • 第一屏必须要有的数据,可以在 mount/3 里取
  • PubSub 订阅、定时器、只对 WebSocket 连接有意义的事情,放进 connected?(socket) 判断里
  • 会产生外部副作用的操作,不要随手放 mount/3

比如定时器就是典型例子:

elixir 复制代码
def mount(_params, _session, socket) do
  if connected?(socket) do
    :timer.send_interval(1_000, self(), :tick)
  end

  {:ok, assign(socket, now: DateTime.utc_now())}
end

def handle_info(:tick, socket) do
  {:noreply, assign(socket, now: DateTime.utc_now())}
end

这就是 connected?/1 最常见的价值:

区分"静态 HTML 首屏"与"真正活起来的 LiveView 进程"。

4. handle_params/3:URL 状态归它管,别塞给 mount/3

handle_params/3 是我觉得最容易被低估的回调。

它会在 mount/3 之后调用,并且当你用 live patch 改变当前 LiveView 的 URL 参数时,它还会再次调用。

它的签名是:

elixir 复制代码
def handle_params(params, uri, socket) do
  {:noreply, socket}
end

适合它处理的东西有:

  • 搜索关键词
  • 分页页码
  • tab
  • 排序字段
  • 筛选条件
  • 当前详情 ID

也就是一句话:

凡是应该体现在 URL 里的页面状态,优先考虑放到 handle_params/3

举个例子:文章列表页支持搜索和分页。

elixir 复制代码
defmodule DemoWeb.PostIndexLive do
  use DemoWeb, :live_view

  alias Demo.Blog

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page_title, "Posts")
     |> assign(:posts, [])
     |> assign(:filters, %{})}
  end

  def handle_params(params, _uri, socket) do
    filters = %{
      "q" => Map.get(params, "q", ""),
      "page" => Map.get(params, "page", "1")
    }

    posts = Blog.list_posts(filters)

    {:noreply,
     socket
     |> assign(:filters, filters)
     |> assign(:posts, posts)}
  end
end

模板里可以这样触发 patch:

elixir 复制代码
def render(assigns) do
  ~H"""
  <div>
    <.link patch={~p"/posts?q=elixir&page=1"}>Elixir</.link>
    <.link patch={~p"/posts?q=liveview&page=1"}>LiveView</.link>

    <ul>
      <li :for={post <- @posts}>
        <%= post.title %>
      </li>
    </ul>
  </div>
  """
end

点击链接以后,不是整页刷新,而是在同一个 LiveView 里触发 URL 参数变化,然后进入 handle_params/3

我以前的错误:把 URL 参数只在 mount/3 里处理

错误写法大概是这样:

elixir 复制代码
def mount(params, _session, socket) do
  posts = Blog.list_posts(params)

  {:ok, assign(socket, posts: posts)}
end

这在首次进入页面时没问题。

但如果后面你做了 <.link patch={...}>push_patch/2,你会发现 URL 变了,页面状态却没有按预期更新。

因为 mount/3 不是给每次 URL 参数变化准备的。这个职责应该交给 handle_params/3

我的经验是:

  • mount/3 管"这个 LiveView 活起来前,需要准备什么基础状态"
  • handle_params/3 管"当前 URL 对页面状态有什么影响"
  • handle_event/3 管"用户操作导致了什么变化"
  • handle_info/2 管"服务端消息导致了什么变化"

这四个边界分清楚,LiveView 代码会清爽很多。

5. handle_event/3:处理客户端事件,但参数永远别信

handle_event/3 处理的是客户端通过 phx- 绑定发来的事件。

比如按钮:

elixir 复制代码
def render(assigns) do
  ~H"""
  <button phx-click="inc">+1</button>
  """
end

def handle_event("inc", _params, socket) do
  {:noreply, update(socket, :count, &(&1 + 1))}
end

再比如带参数:

elixir 复制代码
def render(assigns) do
  ~H"""
  <button phx-click="delete" phx-value-id={@post.id}>
    删除
  </button>
  """
end

def handle_event("delete", %{"id" => id}, socket) do
  Blog.delete_post!(id)

  {:noreply, put_flash(socket, :info, "删除成功")}
end

这里有个非常重要的安全点:

handle_event/3 收到的 params 来自客户端,不可信。

别因为它是 LiveView,就以为不用做权限校验了。

错误写法:

elixir 复制代码
def handle_event("delete", %{"id" => id}, socket) do
  Blog.delete_post!(id)
  {:noreply, socket}
end

这个问题很明显:用户可以在浏览器里伪造事件参数。你不能只看按钮上渲染了哪个 ID,就默认这个 ID 一定合法。

更稳一点的写法:

elixir 复制代码
def handle_event("delete", %{"id" => id}, socket) do
  user = socket.assigns.current_user
  post = Blog.get_post!(id)

  if Blog.can_delete?(user, post) do
    {:ok, _post} = Blog.delete_post(post)

    {:noreply,
     socket
     |> put_flash(:info, "删除成功")
     |> push_patch(to: ~p"/posts")}
  else
    {:noreply, put_flash(socket, :error, "没有权限删除这篇文章")}
  end
end

LiveView 省掉的是前后端状态同步成本,不是安全校验。

这个坑我觉得必须反复说:

LiveView 让你少写 API,不代表用户输入突然可信了。

handle_event/3 的返回值怎么理解

最常见返回值是:

elixir 复制代码
{:noreply, socket}

意思不是"不回复浏览器页面更新",而是"不额外回复一个事件结果"。只要你更新了 socket assigns,LiveView 仍然会重新 render 并把 diff 推给客户端。

比如:

elixir 复制代码
def handle_event("toggle", _params, socket) do
  {:noreply, update(socket, :open?, &(!&1))}
end

这段会更新页面。

我刚开始看到 :noreply 时还愣过一下:不 reply 那页面怎么变?

后来才明白,LiveView 的页面更新不靠你手动 response,它靠 socket 状态变化后的 render/diff 流程。

6. handle_info/2:真正体现 BEAM 味道的地方

如果说 handle_event/3 是浏览器驱动,那 handle_info/2 就是服务端驱动。

它处理的是发给 LiveView 进程的普通 Elixir 消息。

这就是 LiveView 很不一样的地方:

你的页面是一个进程,所以它可以接收消息。

举个定时刷新例子:

elixir 复制代码
def mount(_params, _session, socket) do
  if connected?(socket) do
    Process.send_after(self(), :refresh, 5_000)
  end

  {:ok, assign(socket, stats: load_stats())}
end

def handle_info(:refresh, socket) do
  Process.send_after(self(), :refresh, 5_000)

  {:noreply, assign(socket, stats: load_stats())}
end

再举个 PubSub 的例子。比如聊天室里,有人发了一条新消息。

elixir 复制代码
def mount(%{"room_id" => room_id}, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(Demo.PubSub, "room:#{room_id}")
  end

  {:ok,
   socket
   |> assign(:room_id, room_id)
   |> assign(:messages, Chat.list_messages(room_id))}
end

def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
  room_id = socket.assigns.room_id
  user = socket.assigns.current_user

  {:ok, message} = Chat.create_message(room_id, user, body)

  Phoenix.PubSub.broadcast(
    Demo.PubSub,
    "room:#{room_id}",
    {:new_message, message}
  )

  {:noreply, socket}
end

def handle_info({:new_message, message}, socket) do
  {:noreply, update(socket, :messages, fn messages -> messages ++ [message] end)}
end

这段代码很能体现 LiveView 的味道:

  • 用户提交表单,进入 handle_event/3
  • 服务端写数据库
  • 服务端广播消息
  • 所有订阅了这个房间的 LiveView 进程收到消息
  • 每个进程进入自己的 handle_info/2
  • 每个浏览器收到对应的 DOM diff

如果换成传统 SPA,你大概率要写:

  • 发送消息 API
  • WebSocket 订阅客户端逻辑
  • 前端消息状态合并
  • 后端广播逻辑
  • 断线重连和重复消息处理

LiveView 不是说这些复杂性完全消失了,但它把很多东西收回到服务端同一种语言、同一个进程模型里。

这个体验非常不一样。

这里也有坑:别把 handle_info/2 写成万能入口

我见过一种写法,所有事情都先 send(self(), xxx),然后丢给 handle_info/2 做。

少量异步解耦没问题,但如果滥用,很快就会变成"消息面条":

elixir 复制代码
def handle_event("save", params, socket) do
  send(self(), {:save_later, params})
  {:noreply, assign(socket, saving?: true)}
end

def handle_info({:save_later, params}, socket) do
  # 这里又发消息,又改状态,又查数据库
  {:noreply, socket}
end

我的建议是:

  • 用户事件能同步处理清楚,就放 handle_event/3
  • 外部进程、PubSub、定时器、后台任务结果,放 handle_info/2
  • 真正耗时的任务,不要阻塞 LiveView 进程,考虑 assign_async/3start_async/3 或业务层任务进程

LiveView 是进程,但它不是让你把所有业务都塞进页面进程。

7. render/1:你以为它只是模板,其实它吃的是 assigns

render/1 很容易被忽略,因为大家觉得"模板嘛,没什么好讲"。

但 LiveView 里,render/1 的关键点是:

它应该尽量是 assigns 的纯展示结果。

也就是说,复杂业务逻辑不要写在模板里。

错误味道:

elixir 复制代码
def render(assigns) do
  ~H"""
  <div :for={post <- Enum.filter(@posts, &(&1.published))}>
    <%= post.title %>
  </div>
  """
end

更好的做法是提前在回调里准备好:

elixir 复制代码
def handle_params(params, _uri, socket) do
  posts = Blog.list_posts(params)
  published_posts = Enum.filter(posts, & &1.published)

  {:noreply,
   socket
   |> assign(:posts, posts)
   |> assign(:published_posts, published_posts)}
end

模板只负责展示:

elixir 复制代码
def render(assigns) do
  ~H"""
  <div :for={post <- @published_posts}>
    <%= post.title %>
  </div>
  """
end

当然,不是说模板里完全不能写逻辑。简单判断、循环、展示格式化都很正常。

我的边界是:

如果这段逻辑需要单独测试、会查数据库、会影响业务分支,就不要写进 render。

8. 生命周期分工:我的个人口诀

把上面这些合起来,我现在写 LiveView 会先问自己四个问题:

8.1 这是页面第一次起来就要有的吗?

是,就看 mount/3

比如:

  • 当前用户
  • 页面标题
  • 首屏基础数据
  • 默认表单

但如果是订阅、定时器、后台消息,就加 connected?(socket)

8.2 这是 URL 决定的吗?

是,就看 handle_params/3

比如:

  • /posts?page=2
  • /posts?q=liveview
  • /settings?tab=billing
  • /orders/123

只要你希望用户刷新、分享链接、浏览器前进后退还能保留状态,就别只放在 handle_event/3 里。

8.3 这是用户在页面上操作触发的吗?

是,就看 handle_event/3

比如:

  • 点击按钮
  • 提交表单
  • 输入框变化
  • 拖拽、选择、删除

但记住:参数来自客户端,不可信。

8.4 这是服务端主动来的消息吗?

是,就看 handle_info/2

比如:

  • PubSub 广播
  • 定时器 tick
  • 后台任务完成
  • 其他进程发来的消息

这四个问题问完,大部分 LiveView 代码该放哪就很清楚了。

9. 一个完整一点的例子:订单详情页

最后我们把几个回调放到一个场景里。

假设有一个订单详情页:

  • 打开页面时加载订单
  • URL 里可以切 tab:?tab=timeline?tab=payment
  • 点击按钮可以取消订单
  • 其他系统更新订单状态后,当前页面要实时刷新

代码可以这样组织:

下面代码默认你的认证层已经通过 on_mount 或类似方式把 current_user 放进了 assigns,这也是 Phoenix 项目里比较常见的做法。

elixir 复制代码
defmodule DemoWeb.OrderLive.Show do
  use DemoWeb, :live_view

  alias Demo.Orders

  @tabs ~w(summary timeline payment)

  def mount(%{"id" => id}, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}")
    end

    {:ok,
     socket
     |> assign(:order_id, id)
     |> assign(:order, Orders.get_order!(id))
     |> assign(:tab, "summary")}
  end

  def handle_params(params, _uri, socket) do
    tab =
      params
      |> Map.get("tab", "summary")
      |> normalize_tab()

    {:noreply, assign(socket, :tab, tab)}
  end

  def handle_event("cancel", _params, socket) do
    order = socket.assigns.order
    user = socket.assigns.current_user

    case Orders.cancel_order(order, user) do
      {:ok, updated_order} ->
        Phoenix.PubSub.broadcast(
          Demo.PubSub,
          "order:#{updated_order.id}",
          {:order_updated, updated_order}
        )

        {:noreply, put_flash(socket, :info, "订单已取消")}

      {:error, reason} ->
        {:noreply, put_flash(socket, :error, error_message(reason))}
    end
  end

  def handle_info({:order_updated, order}, socket) do
    {:noreply, assign(socket, :order, order)}
  end

  defp normalize_tab(tab) when tab in @tabs, do: tab
  defp normalize_tab(_tab), do: "summary"

  defp error_message(:not_allowed), do: "没有权限取消这个订单"
  defp error_message(:already_finished), do: "订单已完成,不能取消"
  defp error_message(_reason), do: "操作失败,请稍后再试"
end

对应模板大概是:

elixir 复制代码
def render(assigns) do
  ~H"""
  <div>
    <h1>订单 #<%= @order.id %></h1>

    <nav>
      <.link patch={~p"/orders/#{@order_id}?tab=summary"}>概览</.link>
      <.link patch={~p"/orders/#{@order_id}?tab=timeline"}>动态</.link>
      <.link patch={~p"/orders/#{@order_id}?tab=payment"}>支付</.link>
    </nav>

    <section :if={@tab == "summary"}>
      <p>状态:<%= @order.status %></p>
      <button phx-click="cancel">取消订单</button>
    </section>

    <section :if={@tab == "timeline"}>
      <!-- 这里展示订单动态 -->
    </section>

    <section :if={@tab == "payment"}>
      <!-- 这里展示支付信息 -->
    </section>
  </div>
  """
end

这段例子的分工就比较舒服:

  • mount/3:准备订单基础状态,连接后订阅订单主题
  • handle_params/3:处理 URL 里的 tab
  • handle_event/3:处理用户点击取消订单
  • handle_info/2:处理其他地方广播来的订单更新
  • render/1:根据 assigns 展示 UI

我觉得这就是 LiveView 写顺手之后的感觉:

不是到处找"该发哪个 API",而是在问"这个状态变化来自哪里"。

10. 再强调几个实战坑

10.1 mount/3 跑两次,不要大惊小怪

这是设计,不是 bug。

第一次给用户一份普通 HTML,第二次建立 WebSocket 后让页面活起来。你要做的是区分哪些事情应该两次都做,哪些事情只该 connected 后做。

10.2 params 不可信,哪怕它来自 LiveView

mount/3handle_params/3handle_event/3 里的 params 都可能被用户改。

该校验校验,该鉴权鉴权,该查数据库查数据库。

10.3 socket.assigns 不是长期存储

连接断了会重连,进程崩了会重新 mount。

真正重要的数据要落数据库,至少也要有业务层状态来源。assigns 只是当前页面进程的状态。

10.4 不要在回调里堆业务大泥球

LiveView 回调应该负责"接事件、改状态、调业务层",不要把所有业务规则都写进 LiveView。

我的习惯是:

elixir 复制代码
def handle_event("publish", %{"id" => id}, socket) do
  user = socket.assigns.current_user

  case Blog.publish_post(user, id) do
    {:ok, post} ->
      {:noreply, assign(socket, :post, post)}

    {:error, reason} ->
      {:noreply, put_flash(socket, :error, humanize(reason))}
  end
end

业务规则放 Blog.publish_post/2,LiveView 只处理页面状态。

这比在 handle_event/3 里塞几十行权限、状态机、数据库操作要稳得多。

总结

这期我们把 LiveView 的生命周期主线走了一遍。

我自己的核心结论是:

写 LiveView,不要先想"组件怎么更新",要先想"这次状态变化从哪里来"。

来自页面初始化,看 mount/3;来自 URL,看 handle_params/3;来自用户操作,看 handle_event/3;来自服务端消息,看 handle_info/2;最后统一落到 socket.assigns,由 render/diff 推给浏览器。

这个心智模型转过来以后,LiveView 就不再是"不会写 JS 的替代品",而是一套非常清楚的服务端交互模型。

当然,这篇只是把生命周期讲清楚。真正开始写业务后,最容易上头的地方其实是表单:实时校验、错误展示、phx-changephx-submit、Ecto changeset 怎么配合。

相关推荐
yingyima1 小时前
JWT Token 解析与安全实践速查:5 问 5 答直击要害
前端
kyriewen2 小时前
我用 Codex 重写了同事维护三年的代码,他没说谢谢——而是找了领导
前端·javascript·ai编程
OpenTiny社区2 小时前
从零开发 AI 聊天页要两周?试试这款 Vue3 垂直对话组件库 TinyRobot,直接开箱即用
前端·vue.js·github
铁皮饭盒3 小时前
S3已成为文件存储标准,阿里/腾讯/华为云都支持,Bun率先原生支持
前端·javascript·后端
Cobyte3 小时前
22.Vue Vapor 组件 props 的实现
前端·javascript·vue.js
lichenyang4533 小时前
从 has.showToast 看 ASCF 的 API 调用链路
前端
张就是我1065924 小时前
DOMPurify 的一个漏洞:你以为 {} 是空的?
前端
疯狂的魔鬼5 小时前
一套 Schema 驱动四视图:记 useCrudSchemas 的设计与实践
前端·javascript·typescript
风骏时光牛马5 小时前
大模型开发工具高频故障与实操问题汇总代码案例大全
前端