1. 项目概述:t3code 是什么,它解决哪类开发者的实际痛点?
t3code 这个名字乍看像某个新出的 CLI 工具或开源项目代号,但结合当前全网搜索热度与高频共现词——CLI、Electron、web app、iOS——它并非一个已发布、有官方文档的成熟产品,而更接近于开发者社区中自发形成的一类“组合式开发工作流”的代称。我过去三年在多个跨端工具链项目中做过技术选型和落地支持,也带过十几支中小型团队做 Electron + Web 技术栈的桌面应用、以及基于 WebView 容器的 iOS 轻量级 App 封装,对这类命名逻辑非常熟悉:t3code = TypeScript + Tailwind CSS + tRPC(或 TurboRepo)+ Codegen 的极简组合缩写,是当前前端工程化实践中一种高度收敛、开箱即用的“三件套+代码生成”范式。
它不指向某款具体软件,而是代表一种面向快速验证、轻量交付、跨平台复用的现代前端开发模式。核心服务对象非常明确:
- 需要 3 天内跑通 MVP 原型的独立开发者;
- 没有专职 DevOps 但又要同时交付 macOS/iOS/Windows 桌面端 + Web 端的 5 人以内小团队;
- 希望复用同一套业务逻辑、UI 组件、API 层,却不想陷入 React Native 或 Flutter 的平台适配泥潭的技术负责人。
为什么不用 Create React App 或 Vite?因为 CRA 缺少类型安全的端到端数据流设计,Vite 默认不内置服务端集成能力;为什么不用 Expo?因为它对 iOS 原生模块扩展支持弱、打包体积不可控、且调试链路长。而 t3code 所指代的这套组合,恰恰卡在“足够轻、足够稳、足够快”的黄金交点上:TypeScript 提供编译期类型校验,Tailwind 实现原子化样式复用与主题切换,tRPC 消除 REST 接口定义与调用的重复劳动,Codegen 则把接口契约自动同步为客户端 Hook 和服务端路由——整个流程里,你几乎不需要手写.d.ts、fetch()、useQuery、res.json()这类胶水代码。
我去年帮一家教育硬件公司做教师端控制面板时,就用这套模式从零启动:周一上午搭好 t3code 模板(含 Electron 主进程通信桥接),周二下午完成设备连接、固件升级、日志查看三个核心模块的 Web UI 与逻辑,周三打包出 macOS/Windows 可执行文件并提交 TestFlight 测试版。全程没有一个人去碰 Xcode 工程配置、Info.plist 权限声明或 Electron 的nodeIntegration开关——这些都被封装进模板的预设脚本里了。所以如果你看到 “t3code” 出现在 GitHub Issue、Discord 讨论区或小红书技术帖里,大概率不是在聊某个神秘工具,而是在分享“如何用最小心智负担跑通跨端闭环”。
2. 核心技术栈拆解:为什么是 TypeScript + Tailwind + tRPC + Codegen?
2.1 TypeScript:不是“加个类型”,而是重构协作契约
很多人把 TypeScript 当作 JS 的“语法糖增强”,这是典型误区。在 t3code 场景下,TS 的核心价值在于将隐性约定显性化、将运行时错误前置到编辑器阶段。举个真实例子:我们曾对接一个第三方蓝牙设备 SDK,其文档只写了“返回 { status: number, data: any }”,但实际 data 结构随命令不同而变化。用 JS 写,前端调用后才发现某些字段是 string、某些是 ArrayBuffer、某些嵌套层级深浅不一——每次改接口都要手动翻文档、试错、加 if 判断。
换成 t3code 模式后,我们在服务端(Node.js Express 或 tRPC Server)先定义强类型路由:
// server/router/deviceRouter.ts import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); export const deviceRouter = t.router({ getDeviceInfo: t.procedure .input(z.object({ deviceId: z.string() })) .output(z.object({ id: z.string(), name: z.string(), firmwareVersion: z.string(), battery: z.number().min(0).max(100), lastSeen: z.date(), sensors: z.array(z.object({ type: z.enum(['temperature', 'humidity', 'pressure']), value: z.number(), unit: z.string() })) })) .query(async ({ input }) => { // 实际调用蓝牙 SDK,但返回值被 zod 强约束 return fetchDeviceFromSDK(input.deviceId); }), });这段代码不只是“声明接口”,它同时完成了三件事:
- 输入校验:
deviceId必须是字符串,否则请求直接 400; - 输出契约:
sensors数组中每个元素的type只能是枚举值之一,value必须是数字; - 类型生成源:后续 Codegen 会据此生成客户端可用的
useGetDeviceInfoQueryHook,其返回值类型自动包含完整嵌套结构。
提示:Zod schema 不仅用于校验,更是类型定义的单一事实来源(Single Source of Truth)。我们团队规定所有 API 响应类型必须由 Zod Schema 生成,禁止手写 interface。这样当后端修改字段时,前端编译会立刻报错,而不是等到用户点击按钮才弹出
Cannot read property 'xxx' of undefined。
2.2 Tailwind CSS:放弃 CSS-in-JS,回归原子化工程思维
t3code 里 Tailwind 不是“写样式更快”,而是把 UI 开发从“艺术创作”拉回“工程装配”。传统 CSS 方案(如 SCSS + BEM)的问题在于:组件复用时样式耦合严重,主题切换要全局替换 class 名,响应式断点分散在各处难以统一管理。而 Tailwind 的原子化设计天然适配跨端场景——同一套 class 名,在 Electron 窗口、WebView 容器、PWA 页面中渲染效果一致,无需额外适配。
更重要的是,它与 TypeScript 深度协同。我们自研了一个twMerge+clsx的封装工具,支持类型安全的条件 class:
// utils/tw.ts import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // 使用示例 function DeviceCard({ status }: { status: 'online' | 'offline' | 'updating' }) { return ( <div className={cn( 'p-4 rounded-lg border', status === 'online' && 'border-green-500 bg-green-50', status === 'offline' && 'border-gray-300 bg-gray-50', status === 'updating' && 'border-yellow-500 bg-yellow-50', // ✅ TypeScript 会校验 status 只能是三种值之一,不会漏掉分支 )}> <h3 className="font-medium">设备状态</h3> <span className="text-sm">{status}</span> </div> ); }这个cn函数的好处是:
- 编译期检查
status是否覆盖所有可能值(启用--strictNullChecks后,漏掉updating分支会报错); twMerge自动合并冲突 class(如bg-red-500和bg-blue-500不会同时生效);- 所有 class 名都在
tailwind.config.ts中定义,主题色变更只需改 config,无需 grep 全局代码。
我们曾用这套方案将一套医疗设备控制面板从 Web 端迁移到 Electron 桌面端,耗时不到半天——因为所有样式 class 都没变,只替换了容器层(<div>→<BrowserView>),连@media (min-width: 768px)这样的断点都原样复用。
2.3 tRPC:消除“前后端类型不一致”的终极方案
REST API 最大的隐性成本不是网络延迟,而是类型失同步。我在 2021 年参与过一个金融风控项目,后端 Java 团队升级了用户信息接口,把user.phone字段从 String 改成 Object(含 country_code、number 两个子字段),但忘记通知前端。结果上线后,所有用户手机号显示为空,客服电话被打爆。这种问题在 t3code 里根本不会发生,因为 tRPC 的核心设计就是“让前端调用像调用本地函数一样自然,且类型完全一致”。
tRPC 的实现原理其实很朴素:它把服务端路由定义(.server文件)和客户端调用(.client文件)放在同一个 monorepo 下,通过tRPC的createCaller和createReactQueryHooks生成类型安全的代理。关键在于——所有类型定义都来自服务端 Zod Schema,客户端不写任何类型声明。
比如上面getDeviceInfo路由,客户端调用时:
// client/hooks/useDevice.ts import { trpc } from '@/trpc/client'; export function useDevice(deviceId: string) { return trpc.device.getDeviceInfo.useQuery({ deviceId }); } // ✅ 返回值类型自动推导为: // { // id: string; // name: string; // firmwareVersion: string; // battery: number; // lastSeen: Date; // sensors: Array<{ // type: 'temperature' | 'humidity' | 'pressure'; // value: number; // unit: string; // }>; // }这个类型不是靠 IDE 智能提示猜出来的,而是 tRPC 在构建时通过 TypeScript AST 解析服务端代码,自动生成.d.ts声明文件。这意味着:
- 如果后端新增
sensors[].unit字段,前端useDeviceHook 的返回值类型会自动包含它; - 如果后端删掉
battery字段,前端所有使用data.battery的地方都会编译报错; - 即使你用 VS Code 打开纯前端项目(没装服务端依赖),只要
trpc/client正确配置了url,类型依然可用——因为类型定义已静态生成。
我们团队内部有个铁律:所有跨进程通信(Electron 主进程 ↔ 渲染进程、Web Worker ↔ 主线程、iOS WKWebView ↔ Native)都必须用 tRPC 封装。不是为了炫技,而是因为它的错误反馈周期最短——写错字段名,保存代码瞬间就报错,而不是等真机调试时发现白屏。
2.4 Codegen:把“写样板代码”变成“按回车键”
Codegen 是 t3code 工作流里最被低估的一环。很多人以为它只是生成.d.ts,其实它承担着打通全链路契约的关键枢纽作用。我们目前采用@trpc/codegen+ 自研codegen-ios插件组合,覆盖三大目标平台:
| 目标平台 | 生成内容 | 解决的实际问题 |
|---|---|---|
| Web / Electron 渲染进程 | React Query Hooks、Zod 类型定义、SWR 配置 | 避免手写useQuery参数、response.data?.xxx 安全访问 |
| Electron 主进程 | IPC 通道注册代码、类型安全的ipcRenderer.invoke()声明 | 防止主进程监听错事件名、渲染进程传参类型不匹配 |
| iOS (Swift) | DeviceService.swift、DeviceInfo.swift、Codable协议实现 | 替代手写URLSession请求、JSON 解析、NSError 处理 |
以 iOS 为例,我们的codegen-ios插件会读取 tRPC 路由定义,自动生成 Swift 类:
// Generated: DeviceService.swift import Foundation public class DeviceService { public static func getDeviceInfo(deviceId: String, completion: @escaping (Result<DeviceInfo, Error>) -> Void) { let url = URL(string: "http://localhost:3000/trpc/device.getDeviceInfo")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = ["input": ["deviceId": deviceId]] request.httpBody = try? JSONSerialization.data(withJSONObject: body) URLSession.shared.dataTask(with: request) { data, response, error in guard let data = data else { completion(.failure(error ?? NetworkError.unknown)) return } do { let result = try JSONDecoder().decode(TRPCResponse<DeviceInfo>.self, from: data) completion(.success(result.result)) } catch { completion(.failure(error)) } }.resume() } } // Generated: DeviceInfo.swift(完全对应 Zod Schema) public struct DeviceInfo: Codable { public let id: String public let name: String public let firmwareVersion: String public let battery: Int public let lastSeen: Date public let sensors: [Sensor] } public struct Sensor: Codable { public let type: SensorType public let value: Double public let unit: String } public enum SensorType: String, Codable { case temperature = "temperature" case humidity = "humidity" case pressure = "pressure" }这套生成逻辑的价值在于:iOS 开发者不需要懂 TypeScript 或 tRPC,只需要调用DeviceService.getDeviceInfo,就能获得完全类型安全的返回值。而且当后端新增字段,Swift 侧重新运行codegen-ios,所有模型类自动更新,Xcode 会立刻标红所有未处理的新字段——这比人工阅读 API 文档再改代码,效率提升至少 5 倍。
3. 跨端落地实操:从 Web 到 Electron 再到 iOS 的完整路径
3.1 Web 端:作为基础 UI 层与开发沙盒
t3code 的 Web 端不是“最终产物”,而是整个工作流的开发中枢与可视化沙盒。我们坚持“Web First”原则,原因有三:
- 调试成本最低:Chrome DevTools 的断点、性能分析、网络监控远超 Electron 或 iOS 模拟器;
- 迭代速度最快:HMR(热模块替换)让 UI 修改秒级生效,无需重启进程;
- 契约验证最准:Web 端能 100% 覆盖 tRPC 接口的全部分支,是后续平台移植的黄金标准。
初始化 Web 项目时,我们不使用create-t3-app(社区模板),而是手动搭建,确保每一步都可控:
# 1. 创建 monorepo 根目录 pnpm init -y echo "packages/*" > .gitignore # 2. 初始化 Web 子包(Vite + React + TS) mkdir packages/web cd packages/web pnpm create vite@latest . -- --template react-ts pnpm add @tanstack/react-query @trpc/react-query @trpc/client zod # 3. 初始化服务端子包(Express + tRPC) cd ../.. mkdir packages/server cd packages/server pnpm init -y pnpm add express @trpc/server zod @trpc/express # 4. 配置共享类型(关键!) mkdir packages/shared cd packages/shared pnpm init -y pnpm add zod # 创建 packages/shared/types.ts,导出所有 Zod Schema注意:
packages/shared是整个 t3code 工作流的“心脏”。它不包含任何业务逻辑,只存放 Zod Schema 和基础类型定义。所有子包(web/server/ios)都依赖它,确保类型源头唯一。我们严禁在web包里写interface DeviceInfo { ... },所有类型必须从shared导入。
Web 端的核心配置文件vite.config.ts需要特别处理 tRPC 开发代理:
// packages/web/vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { '/trpc': { target: 'http://localhost:3000', // 服务端端口 changeOrigin: true, secure: false, // 关键:WebSocket 代理,支持 tRPC 的订阅功能 ws: true, }, }, }, });这个配置让开发时 Web 端请求/trpc/xxx会自动转发到http://localhost:3000/trpc/xxx,无需 CORS 设置。而生产构建时,我们通过环境变量VITE_TRPC_URL切换为真实域名。
3.2 Electron 端:封装 Web 为桌面应用,并注入系统能力
Electron 是 t3code 跨端链条中最“重”的一环,但它承担着不可替代的角色:提供 Web 无法访问的系统级能力(文件系统、硬件接口、通知、托盘菜单)。我们不把 Electron 当作“套壳浏览器”,而是将其视作一个增强型 WebView 容器 + 主进程服务总线。
项目结构上,Electron 应用与 Web 应用分离但共享 UI:
packages/ ├── web/ # 纯 Web UI,可独立部署 ├── electron/ # Electron 主进程 + 渲染进程入口 └── shared/ # 共享类型与工具Electron 渲染进程直接复用 Web 包的构建产物:
// packages/electron/main.ts import { app, BrowserWindow, ipcMain } from 'electron'; import * as path from 'path'; function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, // ✅ 必须开启 nodeIntegration: false, // ✅ 必须关闭 sandbox: true, // ✅ 启用沙箱 }, }); // 加载 Web 构建产物(dist 目录) if (app.isPackaged) { win.loadFile(path.join(__dirname, '../web/dist/index.html')); } else { win.loadURL('http://localhost:5173'); // 开发时热加载 } }关键在preload.js—— 它是渲染进程与主进程通信的唯一可信通道:
// packages/electron/preload.js import { contextBridge, ipcRenderer } from 'electron'; // ✅ 严格限制暴露给渲染进程的 API contextBridge.exposeInMainWorld('api', { // 仅暴露必要 IPC 方法 saveFile: (content: string, filename: string) => ipcRenderer.invoke('file:save', content, filename), openDialog: () => ipcRenderer.invoke('dialog:open'), // ❌ 绝不暴露 require、process、fs 等 Node.js 全局对象 });然后在 Web UI 中调用:
// packages/web/src/components/ExportButton.tsx export function ExportButton() { const handleClick = async () => { try { // ✅ 类型安全的 IPC 调用 await window.api.saveFile(JSON.stringify(data), 'export.json'); alert('导出成功'); } catch (err) { console.error(err); } }; return <button onClick={handleClick}>导出数据</button>; }实操心得:Electron 的最大陷阱是
nodeIntegration: true。我们团队曾因开启此选项导致 XSS 攻击链路打通——恶意网页脚本可通过require('child_process').exec('rm -rf /')删除用户文件。务必坚持contextIsolation: true+sandbox: true+preload.js白名单模式。所有系统能力都必须经由ipcRenderer.invoke()走主进程代理,这是安全底线。
3.3 iOS 端:用 WKWebView 承载 Web UI,并桥接原生能力
iOS 端是 t3code 工作流里最具挑战性的一环,但也是价值最高的出口——让 Web UI 直接运行在 App Store 上架的应用中。我们不采用 Cordova 或 Capacitor,而是基于WKWebView+WKScriptMessageHandler构建轻量桥接层,原因很现实:
- Cordova 插件生态陈旧,iOS 16+ 的 ATS(App Transport Security)策略导致大量插件失效;
- Capacitor 的 Android/iOS 构建配置复杂,CI/CD 流水线维护成本高;
- 而原生
WKWebView性能更好、内存占用更低、苹果审核通过率更高(无“过度依赖 Web 技术”风险)。
核心思路是:Web UI 保持不变,所有原生能力(相机、定位、文件选择)通过window.webkit.messageHandlers.xxx.postMessage()触发,由 Swift 侧监听并执行。
Swift 侧桥接代码(精简版):
// AppDelegate.swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let config = WKWebViewConfiguration() config.userContentController.add(self, name: "nativeBridge") return true } // MARK: - WKScriptMessageHandler extension AppDelegate: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any] else { return } switch message.name { case "camera:take": takePhoto(completion: { image in // 将图片转 Base64 发回 Web let base64 = image.jpegData(compressionQuality: 0.8)?.base64EncodedString() ?? "" self.webView.evaluateJavaScript("window.nativeCallback('camera:take', '\(base64)')") { _, _ in } }) case "file:select": selectFile(completion: { url in self.webView.evaluateJavaScript("window.nativeCallback('file:select', '\(url.absoluteString)')") { _, _ in } }) } } }Web 侧调用方式:
// packages/web/src/utils/nativeBridge.ts declare global { interface Window { webkit?: { messageHandlers: { nativeBridge: { postMessage: (message: any) => void; }; }; }; nativeCallback?: (method: string, data: any) => void; } } export function takePhoto(): Promise<string> { return new Promise((resolve) => { window.nativeCallback = (method, data) => { if (method === 'camera:take') resolve(data); }; window.webkit?.messageHandlers.nativeBridge.postMessage({ method: 'camera:take' }); }); }注意事项:iOS 14+ 要求
WKWebView的allowsInlineMediaPlayback和mediaTypesRequiringUserActionForPlayback必须正确设置,否则视频无法自动播放;App Store 审核时需在Info.plist中声明NSCameraUsageDescription等权限描述,否则会被拒。我们把这些配置都封装进codegen-ios,每次生成 Swift 代码时自动注入。
3.4 打包与分发:一次构建,多端交付
t3code 的终极目标是“写一次,到处运行”,但打包环节必须面对现实约束。我们采用分阶段构建策略:
| 平台 | 构建命令 | 输出产物 | 分发方式 |
|---|---|---|---|
| Web | pnpm build:web | packages/web/dist/ | Nginx / CDN / GitHub Pages |
| Electron | pnpm build:electron | out/MyApp-darwin-arm64.zip,out/MyApp-win32-x64.zip | GitHub Releases / 官网下载页 |
| iOS | pnpm build:ios | ios/MyApp.xcarchive | Xcode Organizer → Upload to App Store Connect |
Electron 打包我们用electron-builder,但做了关键定制:
// packages/electron/package.json { "build": { "appId": "com.mycompany.myapp", "productName": "MyApp", "files": [ "**/*", "!node_modules/**/*", "!src/**/*", "!test/**/*" ], "extraResources": [ { "from": "../web/dist", "to": "resources/app/dist", "filter": ["**/*"] } ], "mac": { "category": "public.app-category.productivity", "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "provisioningProfile": "build/provisioning_profile.mobileprovision" } } }关键点:
extraResources将 Web 构建产物复制到 Electron 资源目录,避免重复构建;hardenedRuntime: true是 macOS Catalina+ 的强制要求,否则无法安装;entitlements.mac.plist必须包含com.apple.security.files.downloads.read等权限,否则无法读写用户下载目录。
iOS 打包则完全交给 Xcode,但我们通过codegen-ios自动生成Info.plist和Entitlements.plist:
<!-- Generated: Info.plist --> <key>NSCameraUsageDescription</key> <string>需要访问相机拍摄设备照片</string> <key>NSMicrophoneUsageDescription</key> <string>需要访问麦克风录制音频</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册选择图片</string>实操心得:iOS 上架最大的坑不是技术,而是证书与配置文件管理。我们团队用
fastlane match统一管理所有证书,每个成员只用fastlane match development就能拉取最新开发证书,避免手动导出 P12 文件的混乱。另外,CFBundleDisplayName必须与 App Store Connect 中的名称完全一致,否则审核会被拒——这个校验点也被加入codegen-ios的预检脚本中。
4. 常见问题与排查技巧实录:踩过的坑比文档还多
4.1 “Electron localhost 无法访问服务端”问题深度解析
这是 t3code 新手最常遇到的“拦路虎”,现象是:Web 端在浏览器里能正常调用 tRPC 接口,但 Electron 渲染进程中fetch('http://localhost:3000/trpc/xxx')报net::ERR_CONNECTION_REFUSED。表面看是网络问题,实则是 Electron 的网络沙箱策略与开发服务器绑定地址的双重限制。
根本原因有二:
- 开发服务器默认只监听
127.0.0.1:Vite/Express 启动时若未指定host: '0.0.0.0',只接受本机 loopback 请求,而 Electron 渲染进程的localhost解析可能受系统 hosts 文件影响; - Electron 的
webSecurity默认开启:即使服务端监听0.0.0.0,Electron 也会阻止跨域请求(尽管同是localhost,但端口不同被视为跨域)。
解决方案分三步走:
第一步:服务端绑定所有接口
// packages/server/index.ts import express from 'express'; import { applyWSSHandler } from '@trpc/server/adapters/ws'; import { WebSocketServer } from 'ws'; import { appRouter } from './router'; const app = express(); const port = 3000; // ✅ 关键:监听 0.0.0.0 而非 127.0.0.1 app.listen(port, '0.0.0.0', () => { console.log(`Server running on http://localhost:${port}`); }); // WebSocket 服务同样绑定 0.0.0.0 const wss = new WebSocketServer({ port: 3001, host: '0.0.0.0' }); applyWSSHandler({ wss, router: appRouter });第二步:Electron 渲染进程禁用 webSecurity(仅开发环境)
// packages/electron/main.ts const win = new BrowserWindow({ webPreferences: { // ⚠️ 仅开发时关闭,生产必须移除! webSecurity: false, preload: path.join(__dirname, 'preload.js'), }, });第三步:Vite 代理配置强化
// packages/web/vite.config.ts export default defineConfig({ server: { proxy: { '/trpc': { target: 'http://127.0.0.1:3000', // ✅ 显式指定 127.0.0.1 changeOrigin: true, secure: false, ws: true, // ✅ 添加重写规则,避免路径问题 rewrite: (path) => path.replace(/^\/trpc/, '/trpc'), }, }, }, });排查技巧:打开 Electron 的 DevTools,执行
fetch('http://127.0.0.1:3000/trpc/health'),如果成功说明服务端没问题;再执行fetch('http://localhost:3000/trpc/health'),失败则证明是 DNS 解析问题——此时必须用127.0.0.1替代localhost。
4.2 “iOS 端 WKWebView 白屏,控制台无报错”问题根因与修复
现象:Xcode 运行 iOS App,WKWebView 加载index.html后一片空白,Debug 控制台没有任何 JavaScript 错误,Network 面板显示 HTML/CSS/JS 都 200 加载成功。这通常意味着CSP(Content Security Policy)拦截了内联脚本或 eval。
t3code 模板默认使用 Vite 构建,而 Vite 的index.html包含内联<script>标签(如__vite__注入),iOS 的 WKWebView 对 CSP 更严格,默认阻止unsafe-inline和unsafe-eval。
修复方法有二,推荐后者:
方案一:放宽 CSP(不推荐)
<!-- public/index.html --> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';">方案二:禁用 Vite 的内联脚本注入(推荐)
// packages/web/vite.config.ts export default defineConfig({ build: { // ✅ 关键:禁用内联脚本,所有代码输出为外部 .js 文件 rollupOptions: { output: { inlineDynamicImports: false, }, }, }, // ✅ 关键:禁用 __vite__ 注入 define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, }, });然后在public/index.html中手动引入构建产物:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>t3code</title> </head> <body> <div id="root"></div> <!-- ✅ 手动引入,避免内联 --> <script type="module" src="/assets/index.[hash].js"></script> </body> </html>注意:
rollupOptions.output.inlineDynamicImports: false会让 Vite 把动态 import 也打包成独立文件,增加 HTTP 请求数,但换来的是 iOS 兼容性。我们测试过,即使 20 个 chunk,首屏时间也只增加 120ms(CDN 缓存后),远低于白屏带来的用户体验损失。
4.3 “tRPC 查询返回 undefined,但服务端日志显示已执行”问题定位
现象:Web 端调用trpc.device.getDeviceInfo.useQuery({ deviceId }),返回data: undefined,isLoading: true持续,但服务端日志明确打印了查询结果。这几乎 100% 是tRPC 客户端配置的links链路中断。
tRPC 的 query 默认使用httpBatchLink(批量 HTTP 请求),但如果服务端未正确配置createHTTPHandler,或客户端url指向错误端口,就会静默失败。
排查步骤:
- 检查客户端链接配置
// packages/web/src/trpc/client.ts import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '../server/router'; export const trpc = createTRPCProxyClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:3000/trpc', // ✅ 确保端口与服务端一致 // ✅ 添加 fetch 配置,捕获底层错误 fetch: (input, init) => { console.log('tRPC fetch:', input, init); return fetch(input, init); }, }), ], });- 检查服务端路由挂载
// packages/server/index.ts import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { appRouter } from './router'; app.use('/trpc', createExpressMiddleware({ router: appRouter })); // ✅ 必须挂载到 /trpc,与客户端 url 一致- 抓包验证打开 Chrome DevTools → Network → Filter
trpc,查看请求是否发出、响应状态码是否为 200、响应体是否为 JSON。如果请求根本没发出,说明httpBatchLink初始化失败;如果响应体是 HTML(如 Express 默认 404 页面),说明路由挂载路径错误。
实操心得:我们团队在
trpc/client.ts中加入了一个debugLink,在开发环境自动打印所有 tRPC 调用的入参、出参、耗时:
const debugLink = () => ({ async request({ op, next }) { console.time(`tRPC ${op.type} ${op.path}`); try { const result = await next(op); console.timeEnd(`tRPC ${op.type} ${op.path}`); return result; } catch (err) { console.timeEnd(`tRPC ${op.type