Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 339

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 392

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 394
Joyber 的博客

calc(20rpx + env(safe‑area‑inset‑bottom)) 详解

env() 是CSS环境变量函数,safe‑area‑inset‑bottom 代表iOS全面屏底部手势条(小黑条)的避让高度,单位px。

你的写法含义:20rpx固定距离 + 系统给的底部安全区px距离,常用于小程序底部fixed悬浮按钮、底部栏的bottom / padding‑bottom。

一、四个安全区环境变量

env(safe-area-inset-top);    /* 顶部刘海/灵动岛高度 px */
env(safe-area-inset-right);  /* 右边避让,横屏生效 px */
env(safe-area-inset-bottom); /* 底部手势条高度 px */
env(safe-area-inset-left);   /* 左边避让,横屏生效 px */
  • 非全面屏手机:safe‑area‑inset‑bottom = 0px,表达式等价于 calc(20rpx + 0),只会保留你的20rpx基础距离。
  • iPhone全面屏:会自动叠加小黑条的高度(34px等)。

二、关键坑点(高频踩坑)

1️⃣ H5网页必须开启 viewport‑fit=cover

不加,safe‑area‑inset‑bottom 永远等于0,完全失效。

<meta name="viewport" content="width=device‑width,initial‑scale=1,viewport‑fit=cover">
微信小程序不需要写meta,框架默认开启cover模式,直接可用。

2️⃣ 历史兼容:constant()(iOS11.2以前旧版本)

旧版Safari用constant(),新版本用env(),constant写在env上方,做降级兼容。

/* 兼容旧iOS */
padding‑bottom: calc(20rpx + constant(safe‑area‑inset‑bottom));
padding‑bottom: calc(20rpx + env(safe‑area‑inset‑bottom));

3️⃣ calc内部符号两侧必须空格,否则直接失效

✅正确:calc(20rpx + env(safe‑area‑inset‑bottom))
❌错误:calc(20rpx+env(safe‑area‑inset‑bottom)) +两边没空格会解析失败。

4️⃣ 单位混用注意(小程序)

env(safe‑area‑inset‑bottom) 返回px;你写的是rpx。

微信小程序内部会自动做单位换算,calc(rpx + env(xxx)) 是支持直接写的,不需要手动转px,小程序引擎自动处理。
H5不支持rpx单位,H5要统一全部改成px。

5️⃣ env支持回退默认值(第二个参数)

设备不支持安全区变量时,使用回退值:

env(safe‑area‑inset‑bottom, 0px);

示例:

padding‑bottom: calc(20rpx + env(safe‑area‑inset‑bottom,0px));

三、两种业务场景示例(微信小程序)

场景1:底部fixed悬浮按钮,bottom定位

.fixed‑btn{
  position: fixed;
  bottom: calc(20rpx + env(safe‑area‑inset‑bottom));
  right: 30rpx;
}

含义:距离底部基础20rpx,iPhone全面屏再叠加手势条高度。普通安卓/非全面屏,就只有20rpx。

场景2:底部栏增加内边距padding‑bottom(推荐,避免内容被小黑条遮挡)

.footer‑bar{
  padding‑bottom: calc(24rpx + constant(safe‑area‑inset‑bottom));
  padding‑bottom: calc(24rpx + env(safe‑area‑inset‑bottom));
}

四、常见错误现象排查清单

  1. env完全不起作用:

    • H5:检查meta有没有加viewport‑fit=cover
    • 符号+忘记加空格;
    • 顺序写反,env写在constant前面。
  2. iPhone底部距离变得巨大:>

    你写的是基础距离 + safe‑area‑inset‑bottom,如果你本身元素已经靠到屏幕最底边,就不要额外再加基础值,直接写 env(safe‑area‑inset‑bottom),否则会双重叠加,留白过大。

五、H5完整可复制样板

.bottom‑wrap {
  padding‑bottom: calc(16px + constant(safe‑area‑inset‑bottom));
  padding‑bottom: calc(16px + env(safe‑area‑inset‑bottom,0px));
}
小程序直接复制,把px替换成rpx即可。

Ubuntu24.04(桌面版,NetworkManager)

方式1:全局断开再重连(最简单,推荐)

sudo nmcli networking off
sudo nmcli networking on

一行合并写法:

sudo nmcli networking off && sudo nmcli networking on

方式2:只重启单个连接(推荐SSH远程,不会断其他网卡)

  1. 先查看所有连接名称

    nmcli connection show

    示例输出:Wired connection 1、MyWiFi

  2. 断开、重连(替换名字)

    sudo nmcli con down "Wired connection 1"
    sudo nmcli con up "Wired connection 1"

方式3:直接重启NetworkManager服务(彻底重置网络)

sudo systemctl restart NetworkManager

方式4:netplan(服务器版,netplan管理)

sudo netplan apply

方式5:底层ip命令(直接操作网卡设备)

# 查看网卡名
ip addr
# 示例网卡 enp0s3
sudo ip link set enp0s3 down
sudo ip link set enp0s3 up

验证是否通网

ping 8.8.8.8
提示:Ubuntu24桌面默认用NetworkManager,优先用nmcli;服务器不带NM才用netplan。
SSH远程操作,尽量用【方式2】,避免全局断网导致连不上机器。

对应右上角菜单「断开网络 → 再连上」两种场景

场景A:点WiFi开关(关闭WiFi硬件,和右上角总WiFi开关一模一样)

# 第一步:关闭WiFi(相当于GUI点关闭WiFi)
nmcli radio wifi off

# 第二步:打开WiFi,系统会自动重连已保存WiFi
nmcli radio wifi on

一行合并:

nmcli radio wifi off && sleep 1 && nmcli radio wifi on

场景B:只断开当前这个WiFi连接(WiFi网卡不关,只是断开AP,就是点WiFi名称里的断开)

  1. 先查看连接名

    nmcli connection show --active

    拿到你的WiFi连接名称,例如 MyHomeWiFi

    # 断开当前WiFi
    nmcli con down "MyHomeWiFi"
    # 重新激活(自动重连)
    nmcli con up "MyHomeWiFi"

场景C:右上角【全部网络总开关】(有线+WiFi一起关掉,最接近右上角那个总网络开关)

sudo nmcli networking off
sudo nmcli networking on

区分重点

  • nmcli radio wifi off:无线网卡射频关闭,右上角WiFi图标消失,对应GUI的WiFi总开关。
  • nmcli con down "xxx":仅仅断开这个WiFi连接,网卡还开着,只是断开路由器,网卡还在扫描。
  • nmcli networking off:全局网络全部关闭,有线+WiFi一起断,GNOME右上角网络全部灰色。
Ubuntu24.04 GNOME桌面右上角点击网络开关,本质调用的就是 nmcli networking 或者 nmcli radio。
sleep 1 是给NetworkManager一点反应时间,防止太快执行导致不生效。

如果你是有线网线(不是wifi),那么用全局:

sudo nmcli networking off && sleep 1 && sudo nmcli networking on

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>摄像头拍照保存</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;font-family:system-ui;}
body{padding:20px;max-width:800px;margin:0 auto;}
#video{width:100%;border:1px solid #ccc;background:#000;transform:scaleX(-1);/*前置预览镜像*/}
#canvas{display:none;}
#preview{margin:15px 0;max-width:100%;border:1px solid #ccc;}
.btns{margin:15px 0;display:flex;gap:10px;flex-wrap:wrap;}
button{padding:10px 18px;font-size:16px;cursor:pointer;}
</style>
</head>
<body>
<h3>摄像头拍照</h3>
<video id="video" autoplay playsinline></video>
<canvas id="canvas"></canvas>
<div class="btns">
<button id="startBtn">开启摄像头</button>
<button id="stopBtn">关闭摄像头</button>
<button id="takeBtn">拍照</button>
<button id="saveBtn">保存图片</button>
</div>
<img id="preview" alt="预览图">

<script>
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const preview = document.getElementById('preview');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const takeBtn = document.getElementById('takeBtn');
const saveBtn = document.getElementById('saveBtn');

let mediaStream = null;
let imgUrl = '';

// 开启摄像头
startBtn.onclick = async () => {
  try{
    mediaStream = await navigator.mediaDevices.getUserMedia({
      video:{facingMode:'user'}
    });
    video.srcObject = mediaStream;
  }catch(err){
    alert('无法打开摄像头,请授予摄像头权限:' + err.message);
    console.error(err);
  }
};

// 关闭摄像头
stopBtn.onclick = ()=>{
  if(mediaStream){
    mediaStream.getTracks().forEach(track=>track.stop());
    mediaStream = null;
    video.srcObject = null;
  }
}

// 拍照,前置画面镜像翻转
takeBtn.onclick = () => {
  if(!mediaStream){
    alert("请先开启摄像头");
    return;
  }
  if(!video.videoWidth){
    alert("视频还未就绪,请稍等");
    return;
  }
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  // 水平翻转画布,拍出来和预览一样镜像
  ctx.save();
  ctx.scale(-1, 1);
  ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height);
  ctx.restore();
  imgUrl = canvas.toDataURL('image/png');
  preview.src = imgUrl;
};

// 保存下载图片
saveBtn.onclick = () => {
  if(!imgUrl){
    alert('请先拍照');
    return;
  }
  const a = document.createElement('a');
  a.href = imgUrl;
  a.download = `photo_${Date.now()}.png`;
  a.click();
};
</script>
</body>
</html>

摄像头视频流怎么推送到后端,做直播视频流

网页摄像头推流到后端做直播:完整技术方案

浏览器端采集摄像头,向后端推送视频流,主流分两大类:

  1. WebRTC(推荐低延迟直播,实时互动,延迟<500ms)
  2. MediaRecorder + HTTP上传(简单HLS分片直播,延迟高,5~20秒,上手简单)
前置基础:还是用 navigator.mediaDevices.getUserMedia 获取摄像头MediaStream,和前面拍照网页是同一个媒体源。

方案1:MediaRecorder + HTTP分片上传(简易直播,适合入门)

原理:浏览器拿到摄像头流,用MediaRecorder编码成小块视频片段(blob),通过fetch持续POST发给后端;后端接收片段,转封装为HLS,前端用video.js播放。

  • 优点:技术栈简单,不需要复杂的信令服务器;后端用Node/Python/PHP都能写接收接口。
  • 缺点:延迟高,不适合实时互动,适合监控、低要求直播。

前端核心代码片段

let mediaStream;
let mediaRecorder;
const chunkList = [];

// 获取摄像头
mediaStream = await navigator.mediaDevices.getUserMedia({
    video: { facingMode: 'user', width: {ideal:1280}, height:{ideal:720} },
    audio: true // 同时采集音频
});

// 实例化录制器,编码格式
mediaRecorder = new MediaRecorder(mediaStream, {
    mimeType: 'video/webm;codecs=vp9,opus'
});

// 每500ms生成一块视频分片
mediaRecorder.ondataavailable = async (e) => {
    if(e.data.size > 0){
        const formData = new FormData();
        formData.append('videoChunk', e.data);
        // POST上传分片到后端接口
        await fetch('/api/upload-chunk', {
            method: 'POST',
            body: formData
        })
    }
}
mediaRecorder.start(500); // 每500ms触发一次ondataavailable

后端职责:

  1. 接收webm分片
  2. 把分片追加到视频文件,或者交给ffmpeg转码,生成HLS m3u8 + ts切片
  3. 对外提供HLS地址给播放器观看
限制:浏览器编码格式有限,大多输出webm;如果要mp4,浏览器原生MediaRecorder不支持mp4实时流式输出。

方案2:WebRTC(低延迟实时直播,首选)

WebRTC可以直接把摄像头MediaStream实时推流到后端,延迟极低。
两种部署模式:

模式A:SFU 媒体转发服务器(最常用直播方案)

浏览器(推流端) ↔ SFU服务器 ↔ 多个观看浏览器

  • 浏览器采集摄像头流,通过SDP信令,把RTP音视频包发给SFU
  • SFU负责接收、转发视频流给所有观众,不做转码(性能好)
  • 常用开源SFU:Mediasoup、Janus、LiveKit、Pion

流程:

  1. 前端getUserMedia拿到mediaStream
  2. 创建RTCPeerConnection
  3. 将摄像头track添加到peer连接
  4. 通过websocket作为信令通道,交换SDP、ICE候选
  5. 建立P2P/服务器中转,RTP数据包持续推送到后端SFU
// WebRTC极简前端示例
const pc = new RTCPeerConnection({
  iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
});
// 将摄像头流加入peer连接
mediaStream.getTracks().forEach(track=>pc.addTrack(track, mediaStream));
// 创建offer,通过websocket发给后端信令服务器
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发送offer到后端websocket信令
ws.send(JSON.stringify({type:'offer', sdp:offer.sdp}));

模式B:P2P直连

浏览器直接和观看端P2P,后端只做信令转发,不转发媒体。
缺点:不适合多人直播,网络穿透失败率高,生产直播很少用。

方案3:WebRTC 推流到RTMP服务器(传统直播流)

很多后端直播服务是RTMP(Nginx+rtmp-module,SRS),浏览器本身不原生支持RTMP。
两个解决办法:

  1. 前端WebRTC推流 → 后端SRS/Mediasoup接收WebRTC,内部转RTMP,推到CDN。(推荐,SRS原生支持WebRTC转RTMP)
  2. 用wasm在浏览器内把MediaStream编码成H.264,封装RTMP,这个方案前端CPU占用很高,不推荐生产。
SRS 是国内最常用开源直播服务器:支持 WebRTC 推流、转RTMP、转HLS,部署简单。

关键概念区分

  1. MediaStream:摄像头原始未编码音视频帧(就是你拍照网页里的流),只能在浏览器内部使用,不能直接发给后端,必须编码打包。
  2. MediaRecorder:浏览器内置编码器,把MediaStream编码成webm片段。
  3. WebRTC RTP:实时编码,小包传输,低延迟。
  4. RTMP:传统直播协议,浏览器不原生支持,多用于CDN。

选型建议

  • 低延迟互动直播、远程监控、实时画面:WebRTC + SRS / LiveKit
  • 简单监控,允许10秒以上延迟,快速开发:MediaRecorder分片上传 + ffmpeg转HLS
  • 需要对接阿里云/腾讯云直播CDN:前端WebRTC推SRS,SRS转RTMP推CDN

常见坑

  1. 必须HTTPS/localhost,否则getUserMedia无法打开摄像头。
  2. WebRTC需要信令服务器(websocket),媒体流和信令是两条独立通道。
  3. 带宽:1080p30视频推流,至少需要4Mbps上行带宽。
  4. 编码:浏览器支持H.264 / VP8 / VP9,不同浏览器支持不一样。
  5. 高DPI屏幕不影响视频流本身,视频流分辨率由getUserMedia的constraints指定,不是屏幕分辨率。

拓展:和你之前拍照代码的关联

getUserMedia拿到的MediaStream,既可以给video预览、canvas截图,也可以直接喂给MediaRecorder或者RTCPeerConnection做推流,同一个媒体源复用。

Serena MCP 完整使用教程(适配你的 Cocos+Oops TS游戏项目,解决一次性读大量源码耗token问题)

Serena核心作用:符号级代码检索,AI不再一次性读取整个文件/整个仓库;需要哪个函数、哪个类,才单独拉取那一小段代码,大幅削减token消耗,刚好解决你之前Cursor索引Oops框架直接把额度耗尽的痛点。
原理:基于LSP语言服务器,识别TS的类、函数、接口,而不是把文件当纯文本全文读取。
项目地址:https://github.com/oraios/serena

一、前置依赖安装(Windows/Mac通用)

Serena推荐用uv包管理器(比pip简单很多)

  1. 安装 uv

    # Windows(PowerShell)
    powershell -ExecutionPolicy ByPass -c "irm [https://astral.sh/uv/install.ps1](https://astral.sh/uv/install.ps1) | iex"
    
    # Mac
    curl -LsSf [https://astral.sh/uv/install.sh](https://astral.sh/uv/install.sh) | sh

    安装完成重启终端,让环境变量生效。

  2. 安装 Serena-agent

    uv tool install -p 3.13 serena-agent@latest --prerelease=allow
  3. 初始化Serena(下载语言服务依赖,只需要跑一次)

    serena init

    执行完输入 serena --help,能正常输出帮助就代表安装成功。

二、接入到 Trae(推荐,你准备切换的免费AI编辑器)

Trae支持MCP,直接配置项目级MCP服务:

  1. 打开你的Cocos游戏项目(根目录)
  2. 在项目根新建文件夹 .trae,里面新建 mcp.json

    {
      "servers": [
     {
       "name": "serena",
       "transport": "stdio",
       "command": "serena",
       "args": [
         "start-mcp-server",
         "--context=ide",
         "--project",
         "${workspaceFolder}"
       ]
     }
      ]
    }
  3. 重启Trae,进入项目,打开AI聊天面板 → 设置 → MCP,看到serena状态绿色就是成功。

    ${workspaceFolder} 自动读取当前项目根目录,也就是你的Cocos项目根,Serena会自动索引。

接入Cursor的配置(如果你后续还要用Cursor)

在项目根新建 .cursor/mcp.json

{
  "mcpServers": {
    "serena": {
      "command": "serena",
      "args": [
        "start-mcp-server",
        "--context=ide",
        "--project-from-cwd"
      ]
    }
  }
}

保存,重启Cursor。

三、针对你的《弹力幻境》项目,Serena的配置优化(重点)

Serena会读取项目文件,你需要过滤不需要索引的目录,避免它扫描library、缓存、demo,和.traeignore配合:

  1. 在项目根新建 .serenaignore,内容:

    # Cocos编译缓存
    library/
    temp/
    build/
    dist/
    node_modules/
    
    # Oops示例代码,不需要让serena索引demo
    oops/demo/
    oops/example/
    
    # 只保留 code/业务代码 + oops核心源码
    *.log
    .DS_Store
    Serena会读取这个忽略清单,不会去索引缓存文件,只解析code/下面你的TS业务脚本 + oops框架核心代码,极大减少索引体积。

四、使用方式(对话指令,直接复制丢给AI)

激活成功之后,在AI聊天框直接发指令,Serena会自动调用符号检索工具:

使用serena:查找code/下所有弹力相变相关函数,只读取函数签名和实现,不要读取整个文件。
使用serena:查找oops.message的on和off方法,查看定义。
使用serena:找到GameManager类,分析依赖,新增小球对象池逻辑。

✅ Serena能做到:

  • 查找函数、类、接口定义,按需读取片段,不加载完整文件,省大量token
  • 查找函数调用关系,找到哪里调用了某个方法
  • 重命名类/函数,跨文件批量修改符号(TS类型安全)
  • 查看类型继承关系

❌ Serena不做:写完整大段业务代码、运行游戏、调试Cocos引擎物理。它是代码检索工具,用来减少AI读代码时的token开销。

五、使用工作流(适配你的游戏开发)

  1. 打开Trae,加载Cocos项目,serena自动后台索引(首次索引会慢几十秒,后续走缓存很快)
  2. 写代码时,直接让AI调用serena检索code/目录里的类/函数
  3. AI不再@整个项目,只通过serena符号检索按需拉取代码片段
  4. 配合 .traeignore + .serenaignore 双重过滤缓存目录,从根源解决一次性加载海量源码耗尽额度

六、坑点提醒(必看)

  1. Serena依赖TypeScript语言服务,本地需要安装Node+TS,否则TS解析会失败
  2. 第一次索引会扫描项目,一定要配置.serenaignore,否则还是会扫描library、node_modules,索引慢、耗资源
  3. Serena免费版使用LSP后端,足够TS项目使用;JetBrains后端是付费,你不需要
  4. 它不能替代.traeignore,两者搭配:.traeignore控制Trae内置索引,.serenaignore控制Serena符号索引

备选简化方案:不想装Serena,用Repomix

如果你觉得Serena配置略复杂,Repomix是命令行一次性打包精简代码,适合一次性给AI看架构,不用常驻后台。

repomix --include "code/**/*,oops/**/*" --exclude "oops/demo/**/*,library/**/*,temp/**/*,build/**/*,node_modules/**/*" --compress

--compress AST精简,只保留函数签名,砍掉函数内部实现,token大幅压缩。

推荐你当前的组合方案

Trae + Serena MCP + .traeignore + .serenaignore

  • Trae免费SOLO Agent无硬限额
  • Serena按需读取代码片段,避免一次性读大量源码
  • 双重ignore过滤缓存、demo目录,杜绝爆token的问题
  • 适配 Cocos3.8.8 + Oops + TS微信小游戏

Yii2 joinWith left join 附加 on 额外 and 条件

joinWith 默认把 closure 里面的条件放到 WHERE,不是 ON。想要 LEFT JOIN xxx ON a=b AND c=d,必须用 onCondition,不能写 andFilterWhere / andWhere。

关键点

  • joinWith('关联名', false):第二个参数 false,不执行 eager loading 预加载,只做 JOIN SQL;true 会额外做一次 IN 查询。
  • onCondition 里面写的条件,会拼接在 ON 后面,生成 AND xxx=xxx。
  • andWhere / andFilterWhere 会跑到 WHERE 子句,会把 left join 变成等价 inner join,这是高频踩坑。

模型关联定义(必须先定义 relation)

// Order 模型
public function getLog()
{
    /**
     * LEFT JOIN log ON log.order_id = order.id AND log.type = 1
     */
    return $this->hasMany(Log::class, ['order_id' => 'id'])
        ->onCondition(['log.type' => 1]); // 这里的条件直接拼 ON AND
}
如果条件需要动态变量,用数组格式防注入:
->onCondition([
    'log.type' => $typeVal,
    'log.status' => 2
]);

查询使用 joinWith

$query = Order::find()
    ->joinWith([
        'log' => function($q) {
            // ❌ 不要在这里写 andWhere,会进 WHERE
            // ✅ 如果需要再追加 ON 的 AND,继续 onCondition
            $q->onCondition(['log.is_del' => 0]);
        }
    ], false) // false:只JOIN,不贪婪加载
    ->all();

生成 SQL:

SELECT `order`.* FROM `order`
LEFT JOIN `log` ON `log`.`order_id` = `order`.`id` AND `log`.`type`=1 AND `log`.`is_del`=0

这里说一下 joinWith 第二个参数 $eagerLoading(bool)

方法签名:

public function joinWith($with, $eagerLoading = true, $joinType = 'LEFT JOIN')

$eagerLoading = false:只执行 SQL JOIN,不做贪婪(eager)预加载。

$eagerLoading = true(默认)

Order::find()->joinWith('log', true);

会执行2条SQL:

  1. 主查询:SELECT order.* LEFT JOIN log ON ... 拿到主表+关联表连接后的数据集
  2. 额外再跑一条 IN 查询:SELECT * FROM log WHERE order_id IN (1,2,3...),把关联模型数据查出来,给模型的 $order->log 属性赋值。
目的:做贪婪加载,循环遍历 $orders 的时候,访问 $order->log 不会触发数据库查询。
代价:多一次数据库查询。即使你根本不需要读取 $order->log,这条SQL依旧会执行。

$eagerLoading = false

Order::find()->joinWith('log', false);

只执行1条SQL:带LEFT JOIN的主查询。

  • SQL里会做 LEFT JOIN,你可以在select里取log表字段、可以where过滤log表字段;
  • 不会额外执行IN查询,不会填充模型关联属性。
  • 此时你访问 $order->log,会触发延迟加载(lazy load),单独再查一次数据库。

使用场景什么时候写 false

  1. 仅仅为了做表连接,用于过滤、排序、取关联表字段,业务代码不读取 $model->relation

    绝大多数报表、列表查询场景,都用 false,节省一次SQL。
    // 用于 where、orderBy、select取log.*,不需要读取$order->log
    Order::find()
     ->joinWith('log',false)
     ->select('order.*,log.content')
     ->andWhere(['log.type'=>1])
     ->orderBy('log.create_time desc');
  2. 你只是需要 LEFT JOIN xxx ON ... AND ...,不需要拿到关联模型对象。

什么时候用 true(默认)

需要循环遍历结果,并且要读取关联模型属性:

$list = Order::find()->joinWith('log',true)->all();
foreach($list as $item){
    echo $item->log[0]->content; // 不会N+1,全部已经预加载完毕
}

场景2:不想修改模型关联,查询时临时追加 ON AND

模型本身不带 onCondition,查询动态增加 ON 条件

$query = Order::find()
    ->joinWith([
        'log' => function ($q) {
            /** @var $q ActiveQuery */
            // 追加 ON AND 条件,不会污染模型定义
            $q->onCondition([
                'log.type' => 1,
                'log.is_del' => 0
            ]);
        }
    ], false)
    ->all();

场景3:onCondition 需要大于、不等于这类非等于条件

onCondition 支持 yii 的条件数组格式:

$q->onCondition([
    'and',
    ['log.type' => 1],
    ['>', 'log.create_time', '2026-01-01 00:00:00']
]);

输出:ON ... AND log.type=1 AND log.create_time > '2026‑01‑01 00:00:00'

场景4:原生 SQL 习惯,直接手写 join(备选)

如果逻辑复杂,也可以直接 leftJoin,完全手写on:

Order::find()
    ->leftJoin('log', 'log.order_id = `order`.id AND log.type = :t', [':t' => 1])
    ->all();

常见错误对比

❌ 错误写法(条件跑到 WHERE,left join 失效)

->joinWith(['log' => function($q){
    $q->andWhere(['log.type' =>1]); // 条件进 WHERE,不是 ON
}], false);

✅ 正确:onCondition,条件拼接在 ON ... AND xxx

小提示

  1. joinWith(relName, false):只做表连接,不查询关联数据;如果需要同时取出关联模型数据,第二个参数改为 true。
  2. onCondition 是 ActiveQuery 的方法,只作用于 JOIN 的 ON,不会影响 where。
  3. 如果使用 andFilterWhere,同样会跑到 WHERE,绝对不要拿来写 ON 的 and 条件。