phoenix-thinking
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePhoenix Thinking
Phoenix 思考
Mental shifts for Phoenix applications. These insights challenge typical web framework patterns.
针对Phoenix应用的思维转变。这些见解挑战了典型的Web框架模式。
The Iron Law
铁律
NO DATABASE QUERIES IN MOUNTmount/3 is called TWICE (HTTP request + WebSocket connection). Queries in mount = duplicate queries.
elixir
def mount(_params, _session, socket) do
# NO database queries here! Called twice.
{:ok, assign(socket, posts: [], loading: true)}
end
def handle_params(params, _uri, socket) do
# Database queries here - once per navigation
posts = Blog.list_posts(socket.assigns.scope)
{:noreply, assign(socket, posts: posts, loading: false)}
endmount/3 = setup only (empty assigns, subscriptions, defaults)
handle_params/3 = data loading (all database queries, URL-driven state)
No exceptions: Don't query "just this one small thing" in mount. Don't "optimize later". LiveView lifecycle is non-negotiable.
NO DATABASE QUERIES IN MOUNTmount/3会被调用两次(HTTP请求 + WebSocket连接)。在mount中执行查询会导致重复查询。
elixir
def mount(_params, _session, socket) do
# 此处禁止数据库查询!会被调用两次。
{:ok, assign(socket, posts: [], loading: true)}
end
def handle_params(params, _uri, socket) do
# 数据库查询放在这里——每次导航执行一次
posts = Blog.list_posts(socket.assigns.scope)
{:noreply, assign(socket, posts: posts, loading: false)}
endmount/3 = 仅用于初始化(空赋值、订阅、默认设置)
handle_params/3 = 数据加载(所有数据库查询、URL驱动的状态)
无例外情况: 不要在mount中查询“哪怕这一件小事”。不要“以后再优化”。LiveView的生命周期是不可协商的。
Scopes: Security-First Pattern (Phoenix 1.8+)
作用域:安全优先模式(Phoenix 1.8+)
Scopes address OWASP #1 vulnerability: Broken Access Control. Authorization context is threaded automatically—no more forgetting to scope queries.
elixir
def list_posts(%Scope{user: user}) do
Post |> where(user_id: ^user.id) |> Repo.all()
end作用域解决了OWASP排名第一的漏洞:访问控制失效。授权上下文会自动传递——再也不会忘记对查询添加作用域。
elixir
def list_posts(%Scope{user: user}) do
Post |> where(user_id: ^user.id) |> Repo.all()
endPubSub Topics Must Be Scoped
PubSub主题必须添加作用域
elixir
def subscribe(%Scope{organization: org}) do
Phoenix.PubSub.subscribe(@pubsub, "posts:org:#{org.id}")
endUnscoped topics = data leaks between tenants.
elixir
def subscribe(%Scope{organization: org}) do
Phoenix.PubSub.subscribe(@pubsub, "posts:org:#{org.id}")
end未添加作用域的主题会导致租户之间的数据泄露。
External Polling: GenServer, Not LiveView
外部轮询:使用GenServer,而非LiveView
Bad: Every connected user makes API calls (multiplied by users).
Good: Single GenServer polls, broadcasts to all via PubSub.
错误做法: 每个已连接用户都发起API调用(调用次数随用户数量倍增)。
正确做法: 单个GenServer进行轮询,通过PubSub向所有用户广播结果。
Components Receive Data, LiveViews Own Data
组件接收数据,LiveView拥有数据
- Functional components: Display-only, no internal state
- LiveComponents: Own state, handle own events
- LiveViews: Full page, owns URL, top-level state
- 函数式组件: 仅用于展示,无内部状态
- LiveComponents: 拥有自身状态,处理自身事件
- LiveViews: 完整页面,拥有URL,顶级状态
Async Data Loading
异步数据加载
Use for data that can load after mount:
assign_async/3elixir
def mount(_params, _session, socket) do
{:ok, assign_async(socket, :user, fn -> {:ok, %{user: fetch_user()}} end)}
end使用加载mount之后可获取的数据:
assign_async/3elixir
def mount(_params, _session, socket) do
{:ok, assign_async(socket, :user, fn -> {:ok, %{user: fetch_user()}} end)}
endGotchas from Core Team
核心团队指出的陷阱
LiveView terminate/2 Requires trap_exit
LiveView的terminate/2需要捕获退出信号
terminate/2Fix: Use a separate GenServer that monitors the LiveView process via , then handle messages to run cleanup.
Process.monitor/1:DOWNterminate/2修复方案: 使用单独的GenServer,通过监控LiveView进程,然后处理消息来执行清理操作。
Process.monitor/1:DOWNstart_async Duplicate Names: Later Wins
start_async重复名称:后调用的会覆盖
Calling with the same name while a task is in-flight: the later one wins, the previous task's result is ignored.
start_asyncFix: Call first if you want to abort the previous task.
cancel_async/3在任务执行过程中使用相同名称调用:后调用的会生效,之前任务的结果会被忽略。
start_async修复方案: 如果要中止之前的任务,先调用。
cancel_async/3Channel Intercept Socket State is Stale
Channel拦截中的Socket状态已过期
The socket in intercept is a snapshot from subscription time, not current state.
handle_outWhy: Socket is copied into fastlane lookup at subscription time for performance.
Fix: Use separate topics per role, or fetch current state explicitly.
handle_out原因: 为了性能,socket会在订阅时被复制到快速通道查找表中。
修复方案: 为每个角色使用单独的主题,或显式获取当前状态。
CSS Class Precedence is Stylesheet Order
CSS类优先级由样式表顺序决定
When merging classes on components, precedence is determined by stylesheet order, not HTML order. If appears later in the compiled CSS than , it wins regardless of HTML order.
btn-primarybg-red-500Fix: Use variant props instead of class merging.
在组件上合并类时,优先级由样式表顺序决定,而非HTML顺序。如果在编译后的CSS中比靠后,无论HTML顺序如何,它都会生效。
btn-primarybg-red-500修复方案: 使用变体属性而非类合并。
Upload Content-Type Can't Be Trusted
Upload的Content-Type不可信
The in is user-provided. Always validate actual file contents (magic bytes) and rewrite filename/extension.
:content_type%Plug.Upload{}%Plug.Upload{}:content_typeRead Body Before Plug.Parsers for Webhooks
针对Webhooks,在Plug.Parsers之前读取请求体
To verify webhook signatures, you need the raw body. But Plug.Parsers consumes it.
elixir
{:ok, body, conn} = Plug.Conn.read_body(conn)
verify_signature!(conn, body)
%{conn | body_params: JSON.decode!(body)}Don't use —it keeps the entire body in memory for ALL requests.
preserve_req_body: true要验证Webhook签名,你需要原始请求体。但Plug.Parsers会消耗它。
elixir
{:ok, body, conn} = Plug.Conn.read_body(conn)
verify_signature!(conn, body)
%{conn | body_params: JSON.decode!(body)}不要使用——它会将整个请求体保留在内存中,影响所有请求。
preserve_req_body: trueRed Flags - STOP and Reconsider
危险信号——立即停止并重新考虑
- Database query in mount/3
- Unscoped PubSub topics in multi-tenant app
- LiveView polling external APIs directly
- Using terminate/2 for cleanup (won't fire without trap_exit)
- Calling start_async with same name without cancel_async first
- Relying on socket.assigns in Channel intercepts (stale!)
- CSS class merging for component customization (use variants)
- Trusting for security
%Plug.Upload{}.content_type
Any of these? Re-read The Iron Law and the Gotchas section.
- 在mount/3中执行数据库查询
- 多租户应用中使用未添加作用域的PubSub主题
- LiveView直接轮询外部API
- 使用terminate/2执行清理操作(不捕获退出信号则不会触发)
- 未先调用cancel_async就使用相同名称调用start_async
- 在Channel拦截中依赖socket.assigns(已过期!)
- 使用CSS类合并进行组件定制(使用变体)
- 信任以保障安全
%Plug.Upload{}.content_type
出现以上任何一种情况?重新阅读《铁律》和《陷阱》部分。