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/3 在 mount/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/3、start_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/3、handle_params/3、handle_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-change、phx-submit、Ecto changeset 怎么配合。

相关推荐
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈9 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js
鶴哥只手遮天14 小时前
《Electron 45.2.3 内核解剖》第 4 章 补丁体系:380 个补丁改了什么
前端·electron
FungLeo16 小时前
成为全栈·Vue 管理后台篇·分类树与标签管理:层级数据如何落到管理界面
前端·javascript·vue.js·分类树·naive ui·标签管理·前端数据结构