服务端面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月31日 16:17

Vite 构建慢该从哪些地方优化?

Vite 性能优化先别急着堆配置,先判断慢在哪里:冷启动、页面首次加载、HMR,还是生产构建。Vite 开发环境用原生 ESM 按需转换,生产构建走 Rollup,两条链路的瓶颈不一样。把 Webpack 时代的“全量调参”直接搬过来,常见结果是配置变复杂,速度没明显提升。先定位瓶颈开发阶段优先看依赖预构建是否反复执行、插件 transform 是否过重、浏览器请求是否太碎。生产阶段重点看 source map、压缩器、大依赖和 chunk 策略。可以先用调试日志和产物分析确认问题,而不是凭感觉改配置。pnpm vite --debugpnpm build依赖预构建怎么配optimizeDeps 主要影响 dev server。对导入链复杂、CommonJS 包或请求特别碎的依赖,可以显式 include;对本地源码包、需要保留模块结构的包,再考虑 exclude。盲目排除大依赖会让浏览器请求暴增,启动看似快了,页面反而更慢。export default defineConfig({ optimizeDeps: { include: ['lodash-es', 'axios'], exclude: ['@my-org/source-lib'] }})构建优化看分包和压缩manualChunks 不适合一刀切。稳定的大依赖可以单独拆,编辑器、图表、富文本这类低频模块适合动态导入。压缩默认用 esbuild 通常够快;只有确实需要更细压缩规则时,再换 terser。export default defineConfig({ build: { sourcemap: false, minify: 'esbuild', rollupOptions: { output: { manualChunks: { react: ['react', 'react-dom'] } } } }})插件也要按环境启用。图片压缩、可视化分析、Markdown 转换这类插件如果每次开发都跑,很容易拖慢 HMR。静态资源方面,assetsInlineLimit 不是越大越好,大图内联会撑大 JS 或 CSS,影响缓存。追问为什么 Vite 开发快,构建仍然可能慢?开发环境不做全量打包,只按浏览器请求转换模块,所以反馈很快。生产构建仍要完整分析依赖图、tree-shaking、分包和压缩,大项目自然会慢。这里的取舍是开发体验和产物质量不能用同一套机制解决。常见坑是误以为 dev 快就代表 build 一定快。manualChunks 应该怎么拆?优先按访问频率和缓存收益拆,而不是按库名机械拆。React、Vue 这类稳定核心依赖适合单独 chunk,低频页面依赖更适合懒加载。拆太细会增加请求调度成本,拆太粗会拖慢首屏。边界是首屏必须用的代码不要为了分包好看强行拆远。要不要把大依赖放 CDN?CDN 外部化能减小包体,也可能利用公共缓存。代价是运行时依赖外部服务,版本、可用性和内网访问都要兜底。官网或活动页可以考虑,后台系统和离线部署通常不适合。常见坑是本地构建正常,线上 CDN 被拦截导致白屏。source map 关掉是不是一定更好?关闭 source map 能缩短构建并减少产物体积,但线上排错会变难。更合理的是普通生产包关闭,灰度或错误监控单独上传 source map。取舍点是速度、源码安全和问题定位效率。不要把 source map 直接公开到静态目录。HMR 慢时先查什么?先查改动是否牵连全局入口、统一导出文件或巨大的状态模块。再查插件是否每次 transform 都做同步 IO 或全量扫描。Vite 本身 HMR 通常很快,慢多半来自项目结构和插件副作用。monorepo 里还要明确 watch 范围。
服务端阅读 05月31日 16:17

Vite 如何集成 Vue、React 和 Svelte?插件配置怎么选?

Vite 和框架集成的核心,不是把脚手架命令背下来,而是理解插件负责什么。Vite 自己处理开发服务器、依赖预构建、静态资源和 Rollup 构建;Vue、React、Svelte 这些框架特有的语法转换、热更新和编译选项,则交给对应插件。这样拆开看,配置就不容易乱。新项目优先用官方模板创建项目时最稳妥的方式是使用官方模板。模板会装好框架插件、入口文件和基础 TypeScript 配置,适合从零开始。npm create vite@latest my-vue-app -- --template vue-tsnpm create vite@latest my-react-app -- --template react-tsnpm create vite@latest my-svelte-app -- --template svelte-ts如果是老项目迁移,不建议一次性照搬模板。更安全的做法是先把构建入口跑通,再逐步迁移别名、环境变量、CSS 预处理器和测试配置。取舍点在于速度和稳定性:新项目追求开箱即用,迁移项目更需要可回滚。Vue 怎么接入Vue 项目主要使用 @vitejs/plugin-vue,它负责处理单文件组件、模板编译和 HMR。如果项目使用 JSX 或 TSX,再额外加 @vitejs/plugin-vue-jsx。import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'import vueJsx from '@vitejs/plugin-vue-jsx'export default defineConfig({ plugins: [vue(), vueJsx()], resolve: { alias: { '@': '/src' } }})Vue 的边界是插件只负责编译,不替你决定状态管理、路由和组件库。比如 Element Plus、Pinia、Vue Router 都要按各自方式接入。踩坑点是路径别名只配 Vite 不够,TypeScript 还要在 tsconfig.json 里配 paths,否则编辑器会报找不到模块。React 怎么接入React 官方插件通常选 @vitejs/plugin-react。它支持 Fast Refresh,也会处理 JSX 转换和 Babel 能力。如果团队追求更快的编译速度,可以评估 @vitejs/plugin-react-swc,但 Babel 插件生态依赖重的项目要谨慎。import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()], server: { port: 5173 }, build: { sourcemap: true }})这里的取舍很实际:Babel 版兼容性更稳,SWC 版速度更好。大多数业务项目先用 Babel 版足够,只有在冷启动或 HMR 明显慢时再切换。React 17 以前的项目还要确认 JSX runtime 设置,避免升级 Vite 后出现 JSX 编译行为不一致。Svelte 和 Solid 怎么接入Svelte 使用 @sveltejs/vite-plugin-svelte,Solid 使用 vite-plugin-solid。这类框架的编译器参与度更高,所以不要随便混用插件顺序。一般把框架插件放在 plugins 数组前面,再放检查、压缩、分析等辅助插件。import { defineConfig } from 'vite'import { svelte } from '@sveltejs/vite-plugin-svelte'export default defineConfig({ plugins: [svelte()], css: { preprocessorOptions: { scss: { additionalData: '@use "src/styles/vars" as *;' } } }})通用配置别忘了配两处路径别名、环境变量、CSS 预处理器是所有框架都会遇到的配置。Vite 负责运行时和构建解析,TypeScript 负责编辑器和类型检查,两边经常都要配。{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } }}多框架仓库怎么处理如果一个 monorepo 里同时有 Vue、React 和组件库,建议每个应用保留自己的 vite.config.ts,公共配置抽成函数复用。不要为了“统一”写一个巨大配置文件,然后用一堆条件判断区分框架。那样短期看少了重复,长期会让插件顺序、别名和构建输出互相牵连。公共层可以放端口约定、alias 生成、环境变量校验和构建分析插件,框架插件仍然留在各应用里。边界清楚以后,某个 React 应用切到 SWC,不会影响 Vue 应用的编译行为。测试配置也要一起考虑。Vitest、Playwright 或 Jest 不一定自动读取 Vite 的 alias 和 env,迁移时经常出现应用能跑、测试失败的情况。稳妥做法是把路径解析和环境变量校验抽成小工具,应用、测试和构建共用同一份约定。追问Vite 集成框架时,插件顺序重要吗?重要,但不用过度紧张。框架插件通常应该靠前,因为它们要先处理 Vue SFC、JSX 或 Svelte 文件。后面的插件再做检查、分析、压缩或自定义转换。踩坑点是多个插件都改同一种文件时,顺序会影响最终代码,遇到奇怪编译问题先简化 plugins 数组。Vue 和 React 项目的 Vite 配置差异大吗?基础能力差不多,差异主要在框架插件。Vue 插件处理模板和 SFC,React 插件处理 JSX、Fast Refresh 和 Babel/SWC 转换。取舍不在 Vite,而在框架生态:Vue 更强调模板编译约定,React 更依赖 JSX 和函数组件习惯。通用配置如 alias、proxy、env、build 基本可以复用。老 Webpack 项目迁到 Vite 要注意什么?不要一开始就追求配置完全等价。Webpack loader、DefinePlugin、动态 require、别名和静态资源规则,都可能需要重写成 Vite/Rollup 的方式。边界是能用浏览器原生 ESM 的代码迁移最顺,依赖 CommonJS 魔法写法的项目会更麻烦。建议先迁入口和开发服务器,再处理构建产物差异。React 插件选 Babel 还是 SWC?Babel 版生态兼容性更好,适合依赖 Babel 插件、宏或老项目的团队。SWC 版速度更快,适合配置简单、项目较大的场景。取舍是性能换生态,不能只看 benchmark。切换前最好跑完整测试和构建,因为一些非标准语法转换在两边表现可能不同。为什么 alias 配了 Vite,编辑器还是报错?因为 Vite 的 resolve.alias 只影响开发服务器和构建。TypeScript、ESLint、测试框架都有自己的解析逻辑,编辑器通常看的是 tsconfig.json。边界是运行能成功不代表类型系统能识别。解决方式是同步配置 compilerOptions.paths,测试工具也要按需补 alias 映射。
服务端阅读 05月31日 16:17

Vite 环境变量怎么用?为什么只有 VITE_ 前缀能进客户端?

Vite 的环境变量分两类:一类给构建工具和服务端配置用,一类会被注入到浏览器代码里。很多线上事故都出在这里:以为 .env 里的变量只是本地配置,结果把密钥用 VITE_ 开头写进了前端包。记住一句话,凡是能通过 import.meta.env 在客户端读到的值,都应该被当成公开信息。.env 文件怎么加载Vite 会按当前 mode 读取环境变量。常见文件有 .env、.env.local、.env.development、.env.production、.env.[mode].local。本地覆盖文件通常不要提交,因为它经常放个人端口、测试地址或临时开关。# .envVITE_APP_TITLE=Admin ConsoleVITE_API_BASE=/api# .env.stagingVITE_API_BASE=https://staging-api.example.com# .env.local,不提交LOCAL_PROXY_TARGET=http://127.0.0.1:7001客户端代码只能访问以 VITE_ 开头的变量,这是 Vite 的安全边界。这个边界不是为了加密,而是为了避免你无意把 DB_PASSWORD、JWT_SECRET 这类服务端变量打进 JS。变量会在构建时被静态替换,所以生产包里的值不会随着服务器环境自动变化。const apiBase = import.meta.env.VITE_API_BASEconst isDev = import.meta.env.DEVconst mode = import.meta.env.MODE内置变量有哪些import.meta.env.MODE 表示当前模式,默认开发是 development,构建是 production。DEV 和 PROD 是布尔值,适合控制调试面板、mock 逻辑和埋点开关。BASE_URL 来自 base 配置,部署到子目录时很有用。SSR 表示代码是否运行在服务端渲染环境,写同构逻辑时要特别注意。vite.config 里怎么读取在配置文件里不能直接依赖客户端的 import.meta.env。需要用 loadEnv(mode, root, prefix) 主动加载,第三个参数决定读取哪些前缀。这里有个取舍:传空字符串可以读到所有变量,方便配置代理;但也更容易误用敏感信息,所以只在配置层使用,不要再塞回客户端。import { defineConfig, loadEnv } from 'vite'export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { server: { proxy: { '/api': { target: env.LOCAL_PROXY_TARGET, changeOrigin: true } } } }})TypeScript 项目还应该补类型,否则变量名写错只能到运行时才发现。/// <reference types="vite/client" />interface ImportMetaEnv { readonly VITE_APP_TITLE: string readonly VITE_API_BASE: string}interface ImportMeta { readonly env: ImportMetaEnv}常见配置边界环境变量还经常和代理、部署路径、CDN 地址混在一起。接口代理通常只属于开发服务器配置,应该写在 server.proxy,不要让业务代码感知本地代理目标。部署子路径则应该优先配置 base,再通过 import.meta.env.BASE_URL 拼静态资源路径。这样区分以后,开发、预发、生产不会因为一个变量名承担太多含义而互相影响。还有一个容易忽略的点:.env 文件变更后通常要重启 Vite dev server。因为配置和环境变量是在启动阶段加载的,不是每次模块热更新都重新读取。如果确实需要运行时切换配置,可以让后端在 HTML 里注入 window.__APP_CONFIG__,或者让前端启动后先请求一个公开配置接口。这样会多一次维护成本,但能避免每个环境都重新打包。边界仍然一样,接口返回的内容也是公开的,不能放密钥。追问为什么生产环境改了服务器变量,前端页面没变化?因为 Vite 的客户端环境变量是在构建时替换的,不是浏览器运行时再去服务器读。你改了容器或机器上的环境变量,但没有重新 build,打出来的 JS 还是旧值。边界是:构建期配置用 Vite env,运行期配置要走接口、HTML 注入或独立配置文件。多租户系统尤其要注意,不能把租户域名这类运行时信息硬编码进包里。VITE_ 前缀是不是安全机制?它更像防误伤机制,不是安全机制。加了 VITE_ 的值会进入客户端包,任何人都能在源码或网络资源里看到。取舍是公开配置可以用它,比如页面标题、接口基础路径、Sentry DSN;真正的密钥必须放服务端。踩坑最多的是把第三方 secret 写成 VITE_XXX_SECRET,这等于主动泄露。mode 和 NODE_ENV 是一回事吗?不是。mode 决定 Vite 加载哪组 .env.[mode] 文件,NODE_ENV 更多影响依赖库的生产/开发分支。你可以用 vite build --mode staging 生成预发包,但它仍然是生产构建。边界是不要用 MODE !== 'production' 判断是否压缩或是否启用调试,应该优先看 import.meta.env.DEV 和 PROD。环境变量应该怎么做类型和默认值?类型文件只能告诉 TypeScript “这个变量应该存在”,不能保证运行时真的有值。关键变量最好在启动或构建阶段做校验,缺了就抛错。取舍是简单项目可以直接读,企业项目建议封装一层 env.ts,集中处理默认值、布尔转换和错误提示。布尔值也要小心,.env 读出来都是字符串,"false" 在 JS 里仍然是真值。框架项目里环境变量放在哪里更合适?纯前端 Vite 应用可以放在项目根目录的 .env 系列文件里。SSR 框架或 BFF 项目要区分客户端变量和服务端变量,不能为了省事全部加 VITE_。边界是:浏览器需要知道的才进 VITE_,服务器专用的只在服务端读取。CI/CD 里通常提交 .env.example,真实值由流水线或部署平台注入。
服务端阅读 05月31日 15:55

MobX 中 observable、computed 和 action 该怎么分工?

observable、computed 和 action 是 MobX 里最容易混在一起的三个词,但分工其实很清楚:observable 放状态,computed 放由状态推导出的值,action 放修改状态的过程。一个常见判断是,如果它需要被 UI 观察,就用 observable;如果它能由已有状态算出来,就别再存一份;如果它会改变状态,就让它进入 action 边界。import { makeAutoObservable, runInAction } from "mobx";class TodoStore { todos = [] as { title: string; done: boolean }[]; filter: "all" | "done" = "all"; constructor() { makeAutoObservable(this); } get visibleTodos() { return this.filter === "done" ? this.todos.filter(t => t.done) : this.todos; } add(title: string) { this.todos.push({ title, done: false }); }}上面 todos 和 filter 是 observable,visibleTodos 是 computed,add 是 action。使用 makeAutoObservable 时 MobX 会自动推断:字段变成 observable,getter 变成 computed,方法变成 action。它很省事,但不是没有边界;复杂 store 里仍然可以显式配置某些字段不追踪,避免把临时对象、第三方实例也变成 observable。这三个概念放到一起看,还能避免一个常见误区:不要为了“方便更新”把所有派生值都存成 observable。比如 totalPrice、selectedCount、isValid 这类值大多能从已有状态计算出来,存两份反而会带来同步问题。computed 的价值就在这里,它让数据源保持单一,同时又避免每次渲染都重复计算。makeAutoObservable 虽然好用,但在大型 store 里不要完全无脑依赖推断。某些字段可能只是缓存第三方实例,某些方法可能不应该自动绑定,某些深层对象也可能需要 observable.ref 这类更浅的追踪方式。选择默认推断还是显式 annotation,本质是开发效率和可控性的取舍。初期可以先自动推断,等 store 稳定后再把关键边界写清楚。测试时也能看出三者分工是否合理。observable 负责准备初始状态,action 负责触发业务行为,computed 负责断言结果是否正确。如果测试必须手动改很多中间值,往往说明 store 把派生值也当状态保存了,或者 action 颗粒度切得太碎。还有一个判断技巧:看这段代码是在回答“现在是什么”,还是“接下来做什么”。回答“现在是什么”的通常是 observable 或 computed,回答“接下来做什么”的通常是 action。比如 isSubmitDisabled 是当前表单状态的派生结论,应放 computed;submitForm 会校验、请求并写入结果,应放 action。边界清楚后,组件就不需要知道太多状态更新细节,只负责展示和触发行为。追问observable 是不是越多越好?不是,observable 应该只包会影响界面的业务状态。把所有对象都做成 observable 会增加理解成本,也可能让外部库实例被代理后行为变怪。取舍在于便利和边界:页面状态、表单值、接口数据适合观察,DOM 节点、WebSocket 实例、不可变配置通常不适合。踩坑点是把服务类、路由对象也塞进 store,后面调试时很难分清哪些变化真的应该触发 UI。computed 和普通函数有什么区别?computed 有缓存,并且会被 MobX 追踪依赖;普通函数每次调用都会重新执行。对于列表过滤、金额汇总、权限派生这类由 observable 算出的值,computed 通常更合适。边界是 computed 不适合接收经常变化的临时参数,也不应该包含副作用。实际项目里如果一个 getter 里顺手改了 loading 或发了请求,后续很容易出现循环触发。action 只是为了规范代码吗?action 不只是风格约束,它还提供状态修改的事务边界。一个 action 里连续改多个 observable,MobX 会等 action 结束后再通知 reaction,避免 UI 看到中间状态。取舍是你需要把“读”和“写”分清楚,不能在任何地方随手改 store。开启严格模式后,没放进 action 的修改会报错,这在团队协作时反而是好事。异步 action 应该怎么写才安全?async 方法可以作为 action 入口,但 await 之后的代码已经进入新的异步片段。为了让后续赋值仍然处在 action 边界内,可以用 runInAction 包住结果写入。这个写法比直接赋值多一点样板,但能避免严格模式问题,也能让状态变更集中。踩坑点是接口失败时只改了 error,忘记把 loading 改回 false,页面就会一直转圈。async loadTodos() { this.loading = true; try { const todos = await api.listTodos(); runInAction(() => { this.todos = todos; }); } finally { runInAction(() => { this.loading = false; }); }}三者在 React 组件里怎么配合?组件里应该尽量读取 observable 和 computed,而不是把派生逻辑散落在 JSX 中。按钮点击、表单提交这类事件则调用 action,让修改路径可追踪。取舍是 store 会稍微“厚”一点,但组件会更薄,测试和复用更容易。边界是不要把所有 UI 细节都塞进全局 store,弹窗开关、输入框临时草稿可以留在局部组件状态里。掌握这三者后,MobX 代码会变得很直观:状态放一处,派生值只计算不保存,修改都走明确的方法。真正要避免的不是 API 用错,而是把可观察状态、计算逻辑和副作用混在同一段代码里。
服务端阅读 05月31日 15:55

React 里 MobX observer 为什么能自动更新组件?

MobX 的 observer 不是简单地给组件加一个订阅开关,它会在组件渲染时记录“这次 render 到底读了哪些 observable”。之后只有这些被读过的状态变化,组件才会重新渲染。也就是说,observer 的关键不是“组件用了 store”,而是“组件在渲染期间访问了 store 的哪个字段”。在 React 项目里,函数组件通常使用 mobx-react-lite,类组件才会用到 mobx-react。MobX 6 以后更推荐 makeAutoObservable,少写装饰器,也更适合 TypeScript 和现代构建环境。import { makeAutoObservable } from "mobx";import { observer } from "mobx-react-lite";class CounterStore { count = 0; name = "MobX"; constructor() { makeAutoObservable(this); } inc() { this.count += 1; }}const store = new CounterStore();export const Counter = observer(() => { return <button onClick={() => store.inc()}>{store.count}</button>;});上面组件只读取了 store.count,所以 store.name 改变不会让它重渲染。这个粒度比“整个 store 变化就刷新”要细很多,也是 MobX 在复杂表单、局部状态很多的页面里比较省心的原因。实际接入时,还要想清楚 store 从哪里来。小 demo 里直接 const store = new CounterStore() 没问题,但真实应用通常会用 React Context 注入 store,避免测试、SSR 或多实例页面互相污染。尤其是 Next.js 这类服务端渲染场景,全局单例可能把 A 用户的状态带到 B 用户请求里,这是很隐蔽的边界问题。另一个容易忽略的点是 React 18 的 StrictMode。开发环境下某些渲染和 effect 会被重复调用,用来暴露副作用问题,很多人会误判为 observer 重复更新。判断时要区分“React 开发模式故意重复执行”和“MobX 依赖真的变化”。如果 action 里混入请求、埋点或一次性初始化逻辑,最好把这些副作用放到明确的生命周期或事件里,不要依赖 render 触发。还有一个实用经验:不要过早把 store 解构成一堆局部变量再传来传去。const { count } = store 在某些位置只是拿到了当前值,后续组件读取的就不是 observable getter。保留 store.count 的读取路径,或者在 observer 子组件内再读取,通常更符合 MobX 的追踪模型。如果使用 Context 注入 store,也不要在 Provider 的 render 里反复 new Store()。每次创建新实例都会让依赖关系重建,旧组件里的 reaction 也可能来不及按预期清理。通常可以用 useState(() => new Store()) 或模块级工厂保证实例生命周期稳定。这个细节不显眼,但在多标签页、弹窗复用和测试隔离时很容易变成偶发 bug。追问observer 应该包在父组件还是子组件上?更推荐把 observer 放在真正读取 observable 的叶子组件上,而不是一股脑包住最外层页面。这样依赖会更小,某个字段变化时只刷新用到它的那块 UI。取舍是组件数量会多一些,但性能边界更清晰,排查“为什么这里更新了”也更容易。踩坑点是父组件先把 observable 解构成普通值再传下去,子组件即使包了 observer,也可能失去追踪效果。为什么有时数据变了,observer 组件却不更新?最常见原因是渲染期间没有直接读取 observable,例如在组件外提前把值存成普通变量。MobX 只能追踪 reaction 执行时发生的读取,追踪不到已经脱离 observable 的快照。另一个边界是对象本身没有被 makeAutoObservable、observable 或对应 annotation 处理,普通对象当然不会触发更新。实际项目里还要检查状态修改是否发生在 action 中,开启 enforceActions 后,违规修改会直接暴露问题。observer 和 React.memo 需要一起用吗?多数情况下不需要,observer 本身已经对 observable 依赖做了细粒度判断,也会处理一部分 props 变化带来的重复渲染。React.memo 更适合纯展示组件,用普通 props 驱动且没有读取 MobX 状态。两者强行叠加不一定出错,但容易让团队误以为“性能优化越多越好”。真正需要权衡的是组件边界:把读取状态的组件拆小,通常比到处加 memo 更稳定。在 render 里创建新对象会影响 observer 吗?会影响,但影响点通常不是 MobX 追踪,而是 React 子组件的 props 比较。比如每次 render 都创建 { color: 'red' } 或新的回调函数,传给普通子组件时可能导致子组件跟着刷新。这个坑在 MobX 页面里更隐蔽,因为你会以为是 observable 更新太频繁,其实是 React 引用变化。固定样式对象、用 computed 产出派生数据,或者把子组件也拆成 observer,都是可选方案。异步请求里修改状态,observer 会怎样更新?异步本身不会破坏 observer,关键是每次修改 observable 时是否在 action 边界内。async 函数里 await 之后已经离开原来的同步 action,因此后续赋值最好放进 runInAction 或拆成单独 action。这样做的取舍是代码多几行,但状态变化会更可追踪,也能避免严格模式报错。多个字段一起更新时,action 还能合并通知,减少组件中间态闪烁。async load() { this.loading = true; const data = await api.getList(); runInAction(() => { this.items = data; this.loading = false; });}observer 用得好,核心不是“所有组件都包一下”,而是让组件在正确的位置读取正确的 observable。状态读取越靠近展示位置,MobX 的自动追踪越准确,页面也越不容易出现莫名其妙的刷新。
服务端阅读 05月31日 15:55

MobX 异步操作为什么要用 runInAction 或 flow?

MobX 处理异步的核心问题只有一个:await 之后,代码已经离开了原来那个 action 的同步执行栈。如果项目开启了 enforceActions,这时直接改 observable 可能报错,调试也会变乱。所以 MobX 异步写法通常有两条路:普通 async/await 搭配 runInAction,或者使用 flow 让 generator 的每一步自动包进 action。async/await 的常见写法简单请求用 runInAction 最直观。请求前设置 loading;请求回来之后再改 data、error、loading,就放到 runInAction 里。这样状态变更集中,组件只看到清晰的开始、成功或失败、结束。class UserStore { user = null; loading = false; error = null; constructor() { makeAutoObservable(this, {}, { autoBind: true }); } async fetchUser(id) { this.loading = true; this.error = null; try { const user = await api.getUser(id); runInAction(() => { this.user = user; }); } catch (e) { runInAction(() => { this.error = e.message; }); } finally { runInAction(() => { this.loading = false; }); } }}flow 适合更长的流程flow 用 generator 写异步,yield 后面的状态修改仍由 MobX 管理。它适合串行步骤多、需要取消任务、错误分支复杂的场景。代价是语法不如 async/await 普及,TypeScript 类型也要多处理一点,团队不熟时会增加理解成本。fetchUser = flow(function* (id) { this.loading = true; try { this.user = yield api.getUser(id); } catch (e) { this.error = e.message; } finally { this.loading = false; }});并行请求和重试都要考虑“最后一次结果才有效”。否则慢请求可能覆盖快请求,页面显示旧数据。常用做法是记录 requestId,或使用 AbortController 取消旧请求。追问为什么 @action async 函数里 await 后还要 runInAction?@action 包住的是函数开始执行的同步部分,不会自动把所有未来的 Promise 回调都包住。await 后恢复执行时,MobX 已经不能保证这段修改仍在 action 中。runInAction 的取舍是多写一点样板代码,换来严格模式下可预测的状态修改。坑在于本地没开 enforceActions 时看不出问题,线上团队规范一收紧就开始报错。runInAction 和 flow 应该选哪个?短流程、团队熟悉 async/await 时,用 runInAction 更自然。流程很长、每一步都要改状态,或者希望使用 MobX 自带的取消能力时,flow 更顺手。边界不是哪个更高级,而是代码是否还能一眼看出状态变化发生在哪里。大型团队里最好统一风格,否则同一个项目两套写法会增加维护成本。loading 状态为什么容易写错?最常见的坑是并发请求共用一个 boolean。第一个请求还没结束,第二个请求开始;第二个先结束把 loading 设 false,页面就误以为全部结束了。可以用 requestId 保证只处理最后一次请求,或用 pendingCount 表示还有几个请求未完成。取舍是代码稍复杂,但能避免旧请求覆盖新结果和 loading 闪烁。const id = ++this.requestId;const data = await api.search(keyword);runInAction(() => { if (id === this.requestId) this.results = data;});异步错误应该放在 store 里还是组件里?业务错误通常放 store 里,因为多个组件可能都要根据它展示状态或禁用按钮。只影响某个弹窗的一次性错误,可以留在组件本地,没必要污染全局 store。边界是错误是否属于业务状态:比如“用户未登录”是业务状态,“当前弹窗输入为空”更像 UI 状态。踩坑点是把所有 error 都塞进全局数组,最后用户看到的提示和真实操作对不上。组件卸载时异步任务要不要取消?如果请求结果只会写入全局 store,组件卸载不一定必须取消,但要防止过期结果覆盖新状态。若结果只服务当前组件,卸载时取消更稳,尤其是搜索框、详情页切换和轮询。flow 返回的任务可以 cancel,fetch 可以用 AbortController。边界在于:取消不是为了让 MobX 安全,而是为了避免无意义请求和过期数据写回。
服务端阅读 05月31日 15:55

大型前端应用中 MobX Store 应该如何拆分和协作?

大型应用里用 MobX,关键不是“建一个 store 然后全局导出”,而是把状态边界划清楚。页面越多,store 越容易变成杂物间:登录信息、列表缓存、弹窗状态、错误提示、持久化逻辑全塞一起,短期写得快,后期任何改动都像拆炸弹。比较稳的做法是用 RootStore 管组合关系,业务 Store 管自己的数据和动作,跨模块协作只通过少数明确入口发生。推荐的基本结构RootStore 不应该写太多业务逻辑,它更像装配层。UserStore、ProductStore、CartStore 等模块各自维护领域状态,构造时拿到 root 或必要依赖。也方便测试时替换依赖。class RootStore { userStore: UserStore; cartStore: CartStore; constructor(api: ApiClient) { this.userStore = new UserStore(api.user); this.cartStore = new CartStore(api.cart, this.userStore); makeAutoObservable(this, {}, { autoBind: true }); }}class CartStore { items: CartItem[] = []; constructor(private api: CartApi, private userStore: UserStore) { makeAutoObservable(this, {}, { autoBind: true }); } get total() { return this.items.reduce((s, i) => s + i.price * i.count, 0); }}哪些状态该进 MobX长期共享、会被多个页面读取、需要派生计算的状态适合进 MobX,比如用户、权限、购物车、筛选条件、实体缓存。只属于一个组件的输入框临时值,不必为了“统一管理”强行进全局 store。取舍点在生命周期和共享范围:短命状态适合本地,长期状态适合 store。持久化也不要一把梭。登录 token、主题、语言可以落 localStorage;大量实体数据更适合接口缓存。autorun 自动保存很方便,但要防抖、过滤敏感字段,并在 store 销毁时清理 disposer。追问单一 RootStore 和多个业务 Store 怎么取舍?小项目用一个 store 没问题,文件少、调试直接。大型应用更适合多个业务 Store,因为团队可以按领域并行维护,测试也更容易隔离。代价是跨 store 依赖会变复杂,所以 RootStore 要控制装配关系,不能让每个 store 随意 import 另一个单例。边界是:当一个文件开始同时处理三个以上业务域时,就该拆了。Store 之间通信能不能用事件总线?能用,但一般不推荐作为首选。事件总线让依赖看起来松,实际调试时很难知道某个状态是谁改的。更稳的方式是构造函数注入依赖,或者由上层 action 编排多个 store。事件总线适合埋点、全局通知这类不要求强业务一致性的场景,别拿它处理订单和权限这种核心流程。computed 越多性能越好吗?不是,computed 适合表达由 observable 推导出的稳定结果,并且只有被观察时才有缓存价值。把所有函数都改成 computed 会让依赖关系变难读,还可能因为返回新数组导致下游重复渲染。大型列表里常见做法是把筛选条件和原始数据分开,computed 只做必要派生。边界是:它应该没有副作用,也不应该偷偷发请求或修改状态。get visibleProducts() { return this.products.filter(p => p.category === this.category);}大型应用如何避免 Store 变成上帝对象?先按业务能力拆,而不是按 observable、action、computed 这种技术类型拆。比如 user、order、payment 这样的领域边界,比“actions 文件夹里塞一切”更容易维护。每个 store 暴露少量动作,内部字段能私有就私有,组件不要绕过 action 到处改。踩坑点是为了省事把 root 传给所有 store,最后每个 store 都能访问全世界。MobX Store 怎么测试才不脆?优先测试业务动作和派生结果,不要测试 MobX 内部实现。API 依赖用 fake client 注入,异步 action 等待结束后断言 observable 状态即可。组件测试只验证 UI 是否响应 store 状态变化,没必要把真实 RootStore 全挂上。边界在于:如果一个测试需要同时准备五个 store 才能跑,通常说明业务逻辑拆分还不够清楚。
服务端阅读 05月31日 15:55

Jest 中 describe、test 和 it 该怎么分工?

describe 负责分组,test 和 it 负责定义具体用例;test 与 it 在 Jest 里是同一个能力,只是表达风格不同。写测试时不要把 describe 当成必填包装,也不要为了层级好看一层套一层。好的组织方式是让读者从外层看到模块或场景,从内层看到行为,再从断言里确认结果。简单说,describe 管上下文,test/it 管一个可以独立失败的事实。很多测试文件难维护,不是因为 Jest API 难,而是命名和分组把问题藏起来了。比如 describe('utils') 下面放几十个用例,失败时只能知道 utils 坏了,却不知道是格式化、校验还是边界值坏了。反过来,如果每个分支都新开一层 describe,文件会像迷宫,读者要上下跳着看 beforeEach。好的组织方式应该让失败标题连起来就是一句清楚的话。describe('Calculator.add', () => { test('adds two positive numbers', () => { expect(add(2, 3)).toBe(5); }); it('handles negative numbers', () => { expect(add(-2, 3)).toBe(1); });});追问test 和 it 到底有没有功能差异?没有,it 是 test 的别名,超时参数、异步测试、skip、only 等能力都一样。区别主要是可读性:it('returns empty array') 读起来像行为描述,test('returns empty array') 更直白。团队里最好统一一种风格,否则同一个文件里混着写会显得很乱。边界是 BDD 风格明显的组件行为测试可以用 it,工具函数或数据结构测试用 test 也很自然。取舍不是谁更高级,而是谁能让失败信息更像人话。describe 应该嵌套几层才合适?通常一到两层就够了,外层放模块名或组件名,内层放方法、状态或场景。三层以上会让测试标题很长,失败输出也不容易快速定位。嵌套太深还容易把共享变量塞进外层,最后不同用例之间互相影响。更稳的做法是把复杂场景拆成多个文件,或者用清晰的测试数据工厂代替深层 describe。如果一个 describe 下面只有一个测试,也要警惕它是不是只是为了凑结构。beforeEach 放在 describe 里有什么坑?beforeEach 适合做重复但便宜的初始化,比如创建 store、清空 mock、挂载轻量组件。不要在里面做慢请求、真实数据库连接或复杂全局配置,否则每个用例都会付一次成本。更隐蔽的坑是外层 beforeEach 和内层 beforeEach 叠加后,读者很难知道一个用例运行前到底发生了什么。只要初始化逻辑超过几行,就应该考虑抽成 setup(),让每个测试自己显式调用。这个取舍会让测试多写一行,但换来的是用例输入更清楚。一个 test 里可以写多个 expect 吗?可以,但多个断言应该服务于同一个行为。比如“登录成功后写入 token、更新用户信息、关闭 loading”属于一个完整结果,可以放在同一个用例里。若断言覆盖了多个原因不同的分支,失败时就很难判断到底是哪段逻辑坏了。取舍标准是:如果其中一个断言失败后,其他断言仍然代表独立需求,就拆成多个测试。另一个边界是异步流程,多个 expect 之间如果依赖执行顺序,就要确保前面的 await 已经真正结束。skip、only 和 todo 应该怎么用?test.only 和 describe.only 只适合本地临时调试,不能进入主分支。skip 可以用于记录暂时无法运行的用例,但要写清楚原因,否则它会变成永久漏测。todo 适合先标记测试计划,尤其是修 bug 前先列出缺失场景。团队踩坑最多的是忘删 .only,建议开启 eslint-plugin-jest 的 no-focused-tests 和 no-disabled-tests 规则。边界是迁移期的大量失败用例,可以短期 skip,但要配 issue 或过期时间,否则它们很快没人敢动。写段配置// eslint.config.jsmodule.exports = { plugins: ['jest'], extends: ['plugin:jest/recommended'], rules: { 'jest/no-focused-tests': 'error', 'jest/no-disabled-tests': 'warn', 'jest/expect-expect': 'error' }};组织 Jest 测试时,目标不是把层级写得像目录树,而是让失败信息能直接说明哪个行为坏了。describe 少而准,test/it 小而独立,再配合必要的 lint 约束,测试文件会比单纯追求“格式统一”更好维护。代码评审时也可以顺手看测试标题:如果只读标题看不懂行为,后面的人排查失败时也一样看不懂。
服务端阅读 05月31日 15:55

Jest 测试跑得太慢时该从哪些地方优化?

Jest 测试变慢时,先不要急着把所有用例都改成 mock。更稳的做法是先量出慢在哪里,再从运行范围、测试环境、并发、转换缓存和外部依赖几个点逐个处理。通常收益最大的是三件事:只跑相关测试、把不需要 DOM 的用例放到 node 环境、把网络和计时器这类不稳定依赖隔离掉。CI 上还要控制 worker 数量,因为机器核数看起来很多,不代表同时跑满就最快,I/O、转译和内存都会抢资源。优化前最好先固定基线:记录完整测试耗时、最慢的测试文件、是否开启 coverage、是否每次都重新转译。很多团队感觉“Jest 越来越慢”,实际是新增了 jsdom 用例、覆盖率范围过大、mock 泄漏导致重试,或者 CI 容器内存不足。把这些因素拆开之后,优化才不会变成凭感觉调参数。// jest.config.jsmodule.exports = { testEnvironment: 'node', maxWorkers: process.env.CI ? '50%' : '75%', testTimeout: 5000, cacheDirectory: '<rootDir>/.jest-cache', collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.stories.{js,jsx,ts,tsx}', '!src/**/*.d.ts' ]};追问为什么先用 --runInBand 或 --detectOpenHandles 排查,而不是直接加 maxWorkers?maxWorkers 只能调度并发,不能解决测试本身卡住的问题。遇到数据库连接没关闭、定时器没清理、Promise 没 await 时,并发越高日志越乱,定位反而更慢。--runInBand 能把问题压成单进程复现,--detectOpenHandles 可以暴露遗留句柄。代价是这两个参数会明显拖慢执行速度,所以适合排查,不适合长期放进默认 CI 命令。边界也要注意:如果问题只在并发下出现,单进程可能复现不了,这时可以先降低 worker 数量,再逐步缩小到具体文件。testEnvironment 选 node 还是 jsdom,有什么取舍?纯函数、Node 服务端逻辑、数据转换这类测试应该优先用 node,启动快、内存少,也少了 DOM 模拟层带来的噪音。组件测试、依赖 document、window、布局事件的用例才需要 jsdom。踩坑点是有些工具库会偷偷读取浏览器全局对象,如果全局配置成 node,这些用例会突然失败。比较稳的做法是默认 node,只在需要 DOM 的测试文件顶部用 /** @jest-environment jsdom */ 单独声明。这样做的取舍是配置会分散一些,但换来的是大部分非 UI 测试能保持轻量。覆盖率收集为什么会拖慢 Jest?覆盖率需要对代码插桩,转译和文件扫描都会增加开销,尤其是 TypeScript 项目和大仓库更明显。日常本地开发可以不默认打开 coverage,只在提交前或 CI 的独立阶段运行。collectCoverageFrom 要排除声明文件、story、mock、生成代码,否则数字看起来完整,实际是在统计无意义文件。边界是核心库、支付、权限这类高风险模块仍然应该保留覆盖率门禁,不能为了速度完全取消。如果覆盖率阶段太慢,可以把单元测试和 coverage 拆成两个 CI job,让开发先拿到基础测试反馈。Mock 外部依赖会不会让测试失真?会,所以 mock 要用在边界上,而不是把所有内部逻辑都替换掉。API、时间、随机数、文件系统、第三方 SDK 适合 mock,因为它们慢且不稳定;业务分支和状态变更如果也全 mock,测试就只是在验证 mock 写得对。一个常见坑是 mock 没有在 afterEach 里恢复,导致后面的用例继承了错误状态。可以配合 jest.clearAllMocks() 或 jest.restoreAllMocks(),让用例之间保持隔离。取舍上,少量集成测试仍然要保留真实调用链,只把网络层替换掉,这样才能发现模块之间的契约问题。watch、onlyChanged 和 CI 命令应该怎么分开?本地开发追求反馈快,jest --watch 或 jest --onlyChanged 很合适,因为它们只跑和改动相关的测试。CI 追求确定性,应该跑完整测试,并把 worker、coverage、缓存目录固定下来。不要把 test.only、describe.only 当成选择性运行方案,它们很容易被误提交。团队里可以加 ESLint 规则或 pre-commit 检查禁止 .only,这比事后排查漏测便宜得多。还有一个边界是单体仓库:只跑 changed 可能漏掉跨包依赖,CI 最好结合依赖图或至少在合并前跑一次全量。写段代码{ "scripts": { "test": "jest --watch", "test:changed": "jest --onlyChanged", "test:ci": "jest --ci --coverage --maxWorkers=50%", "test:debug": "jest --runInBand --detectOpenHandles" }}Jest 性能优化的关键不是把命令堆满,而是给不同场景配不同命令。本地要快,CI 要稳,排查要可复现。只要把环境、并发、覆盖率和 mock 边界分清,大多数“测试越来越慢”的问题都能被压回可控范围。真正需要重写测试时,也应该先从最慢、最不稳定、最依赖外部资源的文件开始,而不是把整个测试目录推倒重来。
服务端阅读 05月31日 11:08

Jest 如何测试异常处理并正确使用 toThrow 和 rejects?

异常测试不是为了证明代码“会报错”,而是确认它在错误输入、依赖失败和边界条件下报出正确的错,并且调用方能按预期处理。Jest 里同步异常主要用 toThrow,Promise 拒绝主要用 rejects,回调错误则要显式等待测试结束。最常见的误区是把函数先执行了,再把结果交给 expect,这样异常会在断言前就抛出。同步异常怎么测?toThrow 接收的是一个函数包装,而不是函数调用结果。可以匹配错误类型、完整消息、部分字符串或正则。function divide(a, b) { if (b === 0) throw new RangeError('Division by zero') return a / b}test('除数为 0 时抛出 RangeError', () => { expect(() => divide(10, 0)).toThrow(RangeError) expect(() => divide(10, 0)).toThrow(/zero/)})边界是不要过度依赖完整错误文案。文案经常为了用户体验调整,测试也会跟着碎。更稳定的断言是错误类型、错误 code,或关键短语。Promise 拒绝怎么测?异步函数返回 Promise 时,用 await expect(promise).rejects…。不要忘记 await 或 return,否则测试可能在 Promise 拒绝前就结束,形成假通过。async function fetchUser(api) { const res = await api.get('/user') if (!res.ok) throw new Error('API Error') return res.data}test('接口失败时 rejects', async () => { const api = { get: jest.fn().mockResolvedValue({ ok: false }) } await expect(fetchUser(api)).rejects.toThrow('API Error')})如果你需要检查错误对象上的多个字段,可以用 try/catch,但要加 expect.assertions,避免没有抛错时测试仍然通过。test('保留错误 code', async () => { expect.assertions(2) try { await readConfig('/missing') } catch (error) { expect(error).toBeInstanceOf(Error) expect(error.code).toBe('ENOENT') }})回调错误和框架错误怎么测?Node 风格回调可以用 done,但必须保证错误路径和成功路径都能结束测试。React 错误边界、日志上报这类场景还要临时 mock console.error,并在测试后恢复。test('callback 返回错误', done => { loadFile('bad.txt', err => { expect(err).toBeInstanceOf(Error) expect(err.message).toContain('bad') done() })})追问toThrow 为什么必须包一层函数?因为 Jest 需要自己调用这段代码,才能捕获抛出的异常。写成 expect(divide(1, 0)).toThrow() 时,异常已经在 expect 执行前抛出,断言根本没机会运行。这个边界只针对同步异常;异步函数即使内部 throw,也会变成 rejected Promise。踩坑是把同步和异步写法混用,导致测试报错位置看起来很奇怪。rejects.toThrow 和 try/catch 怎么取舍?rejects.toThrow 简洁,适合只关心错误类型或消息的场景。try/catch 更啰嗦,但适合检查多个字段,比如 code、status、details。取舍标准是断言复杂度:一两个断言用 rejects,多字段检查用 try/catch。坑是 try/catch 里忘记 expect.assertions,当函数没有抛错时测试也可能悄悄通过。错误消息应该精确匹配吗?一般不建议完整精确匹配,除非这个消息本身就是公开 API。内部错误文案经常调整,精确匹配会让测试过于脆弱。更好的选择是匹配错误类型、错误码或关键关键词。边界是表单校验、CLI 输出、SDK 对外错误这类场景,用户依赖文案时就应该精确测试。如何测试“没有抛错”?同步函数可以写 expect(() => fn()).not.toThrow(),异步函数可以写 await expect(fn()).resolves.toEqual(…)。但不要滥用“没有抛错”作为唯一断言,因为函数可能什么也没做也能通过。取舍是它适合覆盖边界输入不崩溃,更关键的业务结果仍要单独断言。踩坑是只测 not.toThrow,漏掉返回值或副作用错误。Mock 抛错时要注意什么?同步依赖用 mockImplementation(() => { throw error }),异步依赖用 mockRejectedValue(error),不要混着用。错误对象最好带上真实业务会用到的字段,比如 code 或 response.status。边界是有些库抛出的不是 Error,而是普通对象,测试要和真实库行为一致。踩坑是 mock 得太理想,生产里的错误结构不同,catch 分支读取字段时再次报错。
服务端阅读 05月31日 11:08

Jest 如何测试 TypeScript 项目并配置 ts-jest?

Jest 测 TypeScript 项目时,先要分清两个问题:测试运行时怎么把 TS 转成 JS,以及类型错误由谁负责检查。ts-jest 可以在 Jest 运行时编译 TypeScript,配置直观,适合希望测试和 tsconfig 保持一致的项目。另一个常见选择是 Babel 或 SWC 转译,它们更快,但通常不做完整类型检查。ts-jest 基础配置怎么写?先安装 Jest、类型声明和 ts-jest。Node 项目一般使用 node 环境,前端组件或 DOM 工具才需要 jsdom。npm i -D jest ts-jest @types/jest typescriptmodule.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/?(*.)+(spec|test).ts'], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts']}tsconfig.json 里要包含 Jest 类型,否则 describe、test、expect 可能被编辑器标红。{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "types": ["jest", "node"] }}如果项目本身是 ESM,配置会更敏感,需要使用 preset: 'ts-jest/presets/default-esm',并处理 extensionsToTreatAsEsm。这是 ts-jest 里最常见的坑之一。类型安全的测试怎么写?测试不应该为了通过而到处 as any。TypeScript 的价值在于让测试数据、Mock 和返回值也遵守业务类型。export interface User { id: number; name: string }export function formatUser(user: User): string { return `${user.id}:${user.name}`}test('格式化用户信息', () => { const user: User = { id: 1, name: 'Ada' } expect(formatUser(user)).toBe('1:Ada')})Mock 函数可以用 jest.MockedFunction 或 jest.mocked,避免 mockResolvedValue 的类型丢失。import { fetchUser } from './api'jest.mock('./api')const mockedFetchUser = jest.mocked(fetchUser)test('加载用户', async () => { mockedFetchUser.mockResolvedValue({ id: 1, name: 'Ada' }) await expect(loadUserName(1)).resolves.toBe('Ada')})要不要让 Jest 做类型检查?ts-jest 可以诊断类型错误,但大型项目里会拖慢测试。很多团队会把 tsc --noEmit 放到 CI 的单独步骤,让 Jest 专注行为测试。这样失败信息更清晰:类型错归类型检查,逻辑错归单元测试。追问ts-jest、babel-jest 和 swc-jest 怎么选?ts-jest 最贴近 TypeScript 编译器,路径、装饰器和部分 tsconfig 行为更容易对齐。Babel 或 SWC 通常更快,适合大型前端项目,但它们主要是转译,不负责完整类型检查。取舍是准确性和速度:配置复杂、依赖 TS 编译特性的项目优先 ts-jest;追求测试速度并已有独立 tsc --noEmit 的项目可以选 SWC。踩坑是以为 Jest 通过就代表类型没问题,实际上转译型方案可能放过类型错误。路径别名为什么在测试里经常失效?TypeScript 的 paths 只告诉编译器怎么解析,不会自动教 Jest 解析模块。Jest 需要单独配置 moduleNameMapper,或者用 pathsToModuleNameMapper 从 tsconfig 生成。边界是 monorepo 里 rootDir、baseUrl 和包边界更复杂,不能简单复制单包配置。常见坑是源码能编译,测试却报 Cannot find module '@/xxx'。ESM 项目配置 Jest 有什么坑?ESM 下 type: module、tsconfig 的 module、Jest preset 和导入扩展名必须互相匹配。很多错误不是业务代码错,而是 CJS/ESM 混用导致模块加载失败。取舍是如果项目没有强 ESM 需求,测试环境保持 CJS 会省事很多;如果库要发布 ESM,就应该尽早把测试跑在接近发布格式的环境里。踩坑是 mock ESM 模块方式和 CJS 不同,旧的 jest.mock 习惯可能失效。TypeScript 测试里什么时候可以用 as any?as any 可以用于刻意构造非法输入,测试运行时防御逻辑,例如后端收到脏数据。除此之外应尽量避免,因为它会绕开类型系统,让测试数据变得不可信。取舍是:为了测边界可以局部使用,但要用注释说明这是故意破坏类型。踩坑是为了省事大量 as any,最后测试覆盖了一个现实中根本不会出现的类型形状。类型测试和行为测试要分开吗?通常要分开。Jest 擅长测运行时行为,类型层面的断言可以用 tsd、expect-type 或 tsc --noEmit。边界是工具库、SDK、泛型函数这类类型就是产品能力的代码,应该补类型测试。普通业务项目则不必把所有类型都放进 Jest,否则会让测试意图变模糊。
服务端阅读 05月31日 11:08

Jest 如何测试 fs 文件系统和 I/O 操作?

测试文件系统代码时,最重要的问题不是“能不能读写文件”,而是“你的业务逻辑在文件存在、缺失、权限不足、内容损坏时是否处理正确”。Jest 可以 mock fs,也可以配合临时目录做接近真实的集成测试。两种方式都该会用,因为纯 Mock 快但容易脱离真实行为,真实 I/O 准但慢且需要清理。什么时候 Mock fs?如果函数只是包装读取、解析和错误处理,mock fs 很合适。它能让测试不依赖本机路径,也不会污染项目目录。const fs = require('fs')jest.mock('fs')function readConfig(path) { const raw = fs.readFileSync(path, 'utf8') return JSON.parse(raw)}test('读取并解析配置文件', () => { fs.readFileSync.mockReturnValue('{"port":3000}') expect(readConfig('/app/config.json')).toEqual({ port: 3000 }) expect(fs.readFileSync).toHaveBeenCalledWith('/app/config.json', 'utf8')})边界是:mock 出来的 fs 不会模拟所有 Node 行为,比如真实错误对象的 code、路径分隔符、权限差异、符号链接。只靠 mock,可能会漏掉生产环境里的路径问题。异步 fs.promises 怎么测?现代 Node 项目更常用 fs/promises。这时要 mock 对应模块,并用 mockResolvedValue 或 mockRejectedValue 表达成功和失败。jest.mock('fs/promises', () => ({ readFile: jest.fn(), writeFile: jest.fn(), mkdir: jest.fn()}))const fs = require('fs/promises')test('文件不存在时返回默认配置', async () => { fs.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) await expect(loadConfig('/no-file.json')).resolves.toEqual({})})这里的坑是不要只 mock happy path。I/O 最容易出问题的地方恰恰是 ENOENT、EACCES、JSON 格式错误、磁盘写入中断和目录不存在。什么时候用真实临时文件?涉及路径拼接、目录创建、文件遍历、编码、换行符或原子写入时,最好用临时目录跑一层集成测试。const os = require('os')const path = require('path')const fs = require('fs/promises')test('写入后可以再次读取', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'jest-fs-')) const file = path.join(dir, 'data.txt') await saveText(file, 'hello') await expect(fs.readFile(file, 'utf8')).resolves.toBe('hello') await fs.rm(dir, { recursive: true, force: true })})清理必须放在 afterEach 或 try/finally 中,否则 CI 上一次失败会影响下一次运行。追问Mock fs 和使用临时目录怎么取舍?Mock fs 速度快、断言清晰,适合覆盖业务分支和错误处理。临时目录更接近真实环境,适合验证路径、编码、目录递归和跨平台行为。取舍标准是测试目标:想验证“函数是否调用了 fs”用 Mock,想验证“文件结果是否真的正确”用临时目录。踩坑是把所有测试都写成真实 I/O,最后测试套件变慢且偶发失败。如何测试文件不存在和权限不足?不要只断言抛错,要断言你的代码对错误的处理策略。文件不存在可能返回默认值,也可能提示用户创建配置;权限不足通常应该向上抛出更明确的错误。边界是不同系统的错误消息不稳定,不要用完整 message 做强匹配。更稳的是匹配 error.code,例如 ENOENT 或 EACCES。为什么手动 mock fs 容易失真?Node 的 fs API 细节很多,同步、异步、callback、promise 版本行为并不完全一样。手动 mock 只覆盖你想到的分支,没想到的地方会变成测试盲区。取舍是:小函数手动 mock 足够,大量文件操作可以考虑 memfs 这类内存文件系统。坑是 memfs 也不是完整操作系统,权限和符号链接仍可能与真实环境不同。文件遍历测试要注意什么边界?目录遍历要测空目录、嵌套目录、隐藏文件、扩展名过滤和排序稳定性。跨平台时还要避免硬编码 /,应该使用 path.join 或 path.resolve。取舍是测试里是否固定排序:如果业务需要稳定输出,就应该排序并测试;如果不需要,断言可以用集合方式。踩坑是 macOS 本地文件顺序和 Linux CI 不一致,导致测试偶发失败。写入文件时如何避免测试污染?每个测试都应创建独立临时目录,不要共用项目里的 fixtures/output。清理逻辑要放在 finally 或 afterEach,即使断言失败也能删除文件。边界是调试失败时你可能想保留文件,可以通过环境变量控制是否清理。不要在测试里写固定路径,尤其不要写用户主目录或仓库根目录。
服务端阅读 05月31日 11:08

Jest 如何测试 Redux 的 Action、Reducer 和 Selector?

Redux 测试最好按代码职责拆开:action creator 测返回的 action,reducer 测状态如何变化,selector 测派生数据,异步 thunk 或 RTK Query 再测副作用边界。不要把所有东西都塞进一个 React 组件测试里,否则失败时很难判断是 UI、store、接口 Mock,还是 reducer 写错了。Jest 的价值在于让这些纯逻辑可以被快速、稳定地验证。从 reducer 开始最划算reducer 通常是纯函数,输入旧 state 和 action,输出新 state。它不需要 DOM,也不需要 mock store,是 Redux 里最适合单元测试的部分。const initialState = { count: 0 }function counterReducer(state = initialState, action) { switch (action.type) { case 'counter/increment': return { ...state, count: state.count + 1 } case 'counter/add': return { ...state, count: state.count + action.payload } default: return state }}test('处理 add action', () => { expect(counterReducer({ count: 2 }, { type: 'counter/add', payload: 3 })) .toEqual({ count: 5 })})这里要特别测 default 分支和不可变更新。一个常见坑是 reducer 直接修改原对象,简单断言结果可能看不出来,但会影响 React-Redux 的引用比较。可以在测试里冻结输入对象,或者使用 Redux Toolkit 的 Immer,让写法和不可变语义保持一致。action 和 selector 怎么测?action creator 如果只是返回对象,测试应保持克制;过度测试会让代码变得像快照翻译。selector 更值得测,因为它经常藏着筛选、排序、权限判断和空值兼容。export const addTodo = text => ({ type: 'todos/add', payload: text })export const selectDoneTodos = state => state.todos.items.filter(x => x.done)test('创建 addTodo action', () => { expect(addTodo('写测试')).toEqual({ type: 'todos/add', payload: '写测试' })})test('筛选已完成 todos', () => { const state = { todos: { items: [{ done: true }, { done: false }] } } expect(selectDoneTodos(state)).toHaveLength(1)})selector 的边界包括空数组、缺字段、排序稳定性和 memoized selector 的引用复用。尤其是 Reselect,如果 selector 每次都返回新数组,组件会无意义重渲染。异步逻辑如何隔离?传统 thunk 可以 mock API,再断言 dispatch 顺序。Redux Toolkit 的 createAsyncThunk 更推荐测 fulfilled/rejected 对 reducer 的影响,少测内部实现。test('fetchUser 成功后派发 success', async () => { const api = { getUser: jest.fn().mockResolvedValue({ id: 1 }) } const dispatch = jest.fn() await fetchUser(1, api)(dispatch) expect(dispatch).toHaveBeenCalledWith({ type: 'user/success', payload: { id: 1 } })})追问为什么 reducer 测试比 action 测试更重要?reducer 承担业务规则,状态加减、列表合并、错误回滚都在这里发生。action creator 很多时候只是对象工厂,测太细会带来重复断言。取舍是:复杂 action payload 需要测试,简单 action 可以靠 reducer 或集成测试覆盖。踩坑是只测 action 不测 reducer,最后 action 发出去了但状态根本没变。selector 应该测返回值还是 memoization?普通 selector 测返回值即可,重点是不同 state 下结果是否符合业务预期。使用 Reselect 时,可以补一两个引用稳定性的测试,确认相同输入不会重新生成对象。边界是不要把 memoization 细节测得过死,否则换实现时测试会阻碍重构。真正有性能风险的 selector,例如大列表过滤和权限树计算,才值得额外测缓存行为。mock store 和真实 store 怎么选?mock store 适合验证 thunk 派发了哪些 action,速度快,也容易断言顺序。真实 store 更适合验证 reducer、middleware 和组件一起工作后的最终 UI。取舍点是你关心“过程”还是“结果”:过程用 mock store,结果用真实 store。坑在于 redux-mock-store 不会真的更新 state,拿它测组件状态变化会得到误导性结果。Redux Toolkit 的 slice 还需要单独测 action 吗?大多数情况下不需要单独测自动生成的 action creator,因为它们由库保证。更有价值的是测试 slice reducer 对 pending、fulfilled、rejected 的处理,以及 payload 边界。边界是如果你写了 prepare 回调,它包含格式化、生成 id 或清洗输入,就应该单测。否则测试越多越像在测试 Redux Toolkit 本身。React-Redux 组件测试要 mock useSelector 吗?可以 mock,但不建议作为默认方案。mock useSelector 很快,却会绕过 Provider、store shape 和订阅更新,容易让测试与真实运行环境脱节。更稳的方式是创建一个测试 store,用 Provider 包住组件,然后从用户视角断言页面。只有组件很小、依赖 state 很简单,并且你明确只想隔离 UI 分支时,mock hook 才是合理取舍。
服务端阅读 05月31日 11:08

Jest 如何配合 Vue Test Utils 测试 Vue 组件?

Vue 组件测试的核心不是把组件内部每一行都测一遍,而是验证它对外表现是否稳定:传入 props 后渲染什么,用户点击后发生什么,是否 emit 正确事件,依赖插件或异步更新时是否能按预期收尾。Jest 负责断言、Mock 和运行环境,@vue/test-utils 负责把 Vue 组件挂载成可操作的 wrapper。实际项目里,测试越贴近用户行为,后期重构越不容易被测试绑住。基础配置怎么写?Vue 3 项目常见组合是 Jest、@vue/test-utils 和 @vue/vue3-jest。如果还在 Vue 2,需要换成对应版本的 vue-jest,这是最容易踩的版本坑。npm i -D jest @vue/test-utils @vue/vue3-jest babel-jest jest-environment-jsdommodule.exports = { testEnvironment: 'jsdom', moduleFileExtensions: ['js', 'json', 'vue'], transform: { '^.+\\.vue$': '@vue/vue3-jest', '^.+\\.js$': 'babel-jest' }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }}这里的边界是:Jest 跑在 jsdom 中,不是真浏览器。它适合测组件逻辑和 DOM 结果,不适合验证真实布局、滚动位置、CSS 动画或浏览器兼容性;这些应交给 E2E 或视觉回归测试。一个组件应该测什么?以计数器为例,优先测用户能感知的结果,而不是直接断言 wrapper.vm.count。这样即使以后把 Options API 改成 Composition API,只要 UI 行为不变,测试仍然有效。import { mount } from '@vue/test-utils'import Counter from '@/components/Counter.vue'test('点击按钮后展示新的计数并派发事件', async () => { const wrapper = mount(Counter, { props: { initialCount: 1 } }) await wrapper.get('button').trigger('click') expect(wrapper.text()).toContain('Count: 2') expect(wrapper.emitted('change')?.[0]).toEqual([2])})trigger 和 setProps 后通常要 await,因为 Vue DOM 更新是异步的。漏掉 await 时,测试可能本地偶尔通过、CI 偶尔失败,这类 flaky case 很难排查。追问mount 和 shallowMount 应该怎么取舍?mount 会渲染子组件,适合验证父子组合后的真实行为,例如表单组件和校验提示是否一起工作。shallowMount 会把子组件替换成 stub,速度更快,也能让测试只关注当前组件。取舍点在于测试目标:如果失败原因应该定位到当前组件,用 shallowMount;如果用户路径依赖子组件交互,用 mount。踩坑是过度使用 shallowMount 会把插槽、provide/inject 或子组件事件遮掉,导致测试通过但页面真实不可用。为什么不建议大量断言 wrapper.vm?wrapper.vm 能拿到组件实例,测 computed 或方法很方便,但它会把测试绑定到实现细节。组件重构后,用户看到的页面没变,测试却因为内部变量名变化而失败,这就是维护成本。边界是复杂计算逻辑如果没有抽成纯函数,又确实需要覆盖,可以少量使用 wrapper.vm。更稳的做法是通过文本、属性、事件和可访问角色来断言外部行为。异步接口和插件依赖怎么测?接口请求通常用 jest.fn() 或 MSW Mock,不要让单元测试真的访问网络。Vue Router、Pinia、i18n 这类插件可以通过 global.plugins 注入,也可以只 Mock 当前组件用到的最小能力。取舍在于真实性和速度:插件全量接入更像真实页面,但配置重、失败链路长;局部 Mock 更快,但容易漏掉集成问题。常见坑是异步 Promise 已经 resolve,但 Vue 还没完成 DOM 更新,需要配合 flushPromises() 和 await nextTick()。测 props、slots 和 emitted 时重点是什么?props 要测边界值,例如空字符串、缺省值、禁用状态,而不只是正常值。slots 要确认组件是否把外部内容放在正确位置,尤其是弹窗、表格列和布局组件。emitted 不只看有没有触发,还要看 payload 是否稳定,因为父组件往往依赖这个契约。踩坑是事件名大小写和 Vue 版本差异,模板里写法和测试里读取的事件名要保持一致。什么时候该改用组件集成测试或 E2E?当测试需要覆盖路由跳转、真实接口拦截、浏览器焦点、拖拽、文件上传时,单靠 Jest 会越来越别扭。Jest 的优势是快、反馈短,适合把组件的输入输出守住。E2E 更慢但更真实,适合覆盖关键业务链路。好的边界是:组件内部状态交给 Jest,跨页面和浏览器能力交给 Playwright 或 Cypress。
服务端阅读 05月31日 11:08

Tauri 前端和 Rust 后端如何进行 IPC 通信?

Tauri 的 IPC 通信,本质上是在 WebView 前端和 Rust 后端之间建立一条受控通道。前端不能直接调用系统 API,而是通过 invoke 请求 Rust 命令;Rust 也可以通过事件把状态推回前端。这个模型比“前端拿到完整 Node 能力”更收敛,但也要求你认真设计命令边界、数据结构和错误处理。前端调用 Rust 命令Tauri 2 中常用 @tauri-apps/api/core 的 invoke:import { invoke } from '@tauri-apps/api/core';const result = await invoke<string>('greet', { name: 'World' });console.log(result);Rust 端用 #[tauri::command] 标记函数,并注册到 handler:#[tauri::command]fn greet(name: String) -> String { format!("Hello, {name}!")}fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![greet]) .run(tauri::generate_context!()) .expect("error while running tauri application");}参数通过 JSON 序列化传递,所以前端对象字段名要和 Rust 参数匹配。简单类型可以直接传,复杂对象建议定义结构体,避免一堆散参数让接口变脆。复杂数据怎么传Rust 结构体需要实现 Serialize 和 Deserialize:use serde::{Deserialize, Serialize};#[derive(Debug, Deserialize)]struct SaveNoteInput { title: String, body: String,}#[derive(Debug, Serialize)]struct SaveNoteOutput { id: String, saved: bool,}#[tauri::command]fn save_note(input: SaveNoteInput) -> Result<SaveNoteOutput, String> { if input.title.trim().is_empty() { return Err("title is required".into()); } Ok(SaveNoteOutput { id: "note-1".into(), saved: true })}前端调用时保持同样的数据形状:await invoke('save_note', { input: { title: 'todo', body: 'ship desktop app' }});Rust 主动通知前端短请求用 invoke 足够,长任务进度更适合事件。比如 Rust 处理文件时持续推送进度:use tauri::{Emitter, Window};#[tauri::command]async fn import_files(window: Window) -> Result<(), String> { for progress in [10, 40, 70, 100] { window.emit("import-progress", progress).map_err(|e| e.to_string())?; } Ok(())}前端监听后记得取消订阅:import { listen } from '@tauri-apps/api/event';const unlisten = await listen<number>('import-progress', (event) => { console.log(event.payload);});// 组件卸载时调用unlisten();错误处理不要只返回字符串示例里常用 Result<T, String>,但生产项目可以定义更稳定的错误格式。这样前端可以根据错误码做提示,而不是解析中文错误消息。取舍是 Rust 代码稍微多一点,但后续国际化、埋点和自动化测试都会更稳。异步命令和状态管理耗时任务应该写成 async command,并避免阻塞主线程。Rust 侧可以把 CPU 密集任务放到专门线程,I/O 任务用 async 处理,前端则用 loading、取消按钮和事件进度展示状态。一个常见模式是 invoke 启动任务,返回任务 ID,再通过事件接收进度:#[tauri::command]async fn start_export(window: tauri::Window) -> Result<String, String> { let task_id = "export-1".to_string(); window.emit("export:progress", 1).map_err(|e| e.to_string())?; Ok(task_id)}前端不要把 IPC 结果直接散落到多个组件里。React 可以放到 hook,Vue 可以放到 composable,Svelte 可以放到 store。这样错误提示、重试、取消订阅都能集中处理。代价是抽象层略多,但当命令数量超过十几个时,会明显减少“这个事件到底谁在听”的混乱。前端类型也要跟着维护为了避免命令参数改了前端还不知道,团队可以给 IPC 单独维护类型声明:type SaveNoteInput = { title: string; body: string };type SaveNoteOutput = { id: string; saved: boolean };这不能替代 Rust 校验,但能减少调用方传错字段。更进一步可以从 Rust 结构生成 TypeScript 类型,不过会增加构建链路复杂度。小项目手写类型足够,大项目再考虑生成方案。关键是把 IPC 当成接口,而不是随手调用的内部函数。命令命名和版本兼容IPC 命令名一旦被前端使用,就像内部 API 一样需要稳定。建议用动词加业务名,例如 notes_save、settings_load、export_start,不要用 handle、do_work 这种含糊名字。参数结构升级时尽量向后兼容,新增字段用 Option 或默认值处理。桌面应用存在旧版本用户,自动更新也可能失败,所以不能假设所有前端和 Rust 永远同步发布。追问invoke 和事件应该怎么选?一次性请求用 invoke,比如读取配置、保存表单、获取应用版本。持续状态用事件,比如下载进度、文件扫描、后台任务日志。边界是:如果前端需要等待一个明确结果,invoke 更简单;如果 Rust 需要多次推送,事件更自然。常见坑是用循环 invoke 轮询进度,既浪费资源,也让取消逻辑变复杂。IPC 传大文件合适吗?不合适。IPC 适合传结构化数据,不适合把几十 MB 的文件内容塞进 JSON。更好的做法是前端选择文件路径,Rust 侧读取和处理,只把进度、摘要或结果路径传回前端。取舍在于前端不能随意拿到所有原始内容,但性能和内存会稳定很多。大文件直接传 IPC,开发机可能没问题,用户机器上就可能卡死。参数校验应该放前端还是 Rust?两边都要做,但 Rust 侧必须做。前端校验是为了体验,Rust 校验是为了安全和数据一致性。边界很清楚:任何来自 WebView 的参数都不能默认可信,即使这个页面是你自己写的。踩坑点是把前端 TypeScript 类型当成运行时保证,实际上用户或注入脚本仍可能传入异常数据。多窗口通信怎么处理?如果只是 Rust 通知某个窗口,用对应 Window emit 即可;如果要广播,可以通过 app handle 发事件。多窗口应用要注意事件名隔离,避免设置窗口收到编辑窗口的业务事件。取舍是全局事件方便,但长期会变成“谁都能听、谁都能发”的隐式依赖。建议事件名带业务前缀,例如 settings:changed、import:progress。IPC 会不会成为性能瓶颈?普通表单和配置读写不会,瓶颈通常来自高频调用和大 payload。比如拖动滑块每 10ms 调一次 Rust 命令,就会让 UI 和后端都很难受。可以用 debounce、批量提交或把计算放到前端完成。IPC 是边界,不是函数调用的廉价替代品,设计时要把跨边界次数当作成本。
服务端阅读 05月31日 11:08

Tauri 权限系统和安全机制是如何工作的?

Tauri 的安全模型可以用一句话概括:前端默认不可信,系统能力默认不给,所有越过 WebView 边界的操作都要显式授权。它不像传统网页只能访问浏览器沙箱,也不像一些桌面框架默认把 Node 能力暴露给页面。Tauri 把系统调用放在 Rust 侧,通过权限、命令、CSP、作用域和插件能力组合控制风险。做得好,应用可以很轻;做得粗糙,前端 XSS 也可能变成读文件、开进程这种桌面级事故。权限系统的核心逻辑Tauri 2 更推荐用 capability 文件描述窗口能使用哪些能力。比如只允许主窗口读取用户选择目录下的文件,而不是开放整个文件系统:{ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-capability", "windows": ["main"], "permissions": [ "core:default", "dialog:default", { "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/Documents/*.txt" }] } ]}旧版项目常见的是 allowlist,例如只打开 readFile、writeFile,并限制 scope。无论版本如何,原则都一样:不要为了省事写 all: true,更不要把 $HOME/** 当作默认配置。CSP 负责限制前端能加载什么CSP 不是摆设,它能降低 XSS 后继续扩大攻击面的概率。一个保守配置可以从下面开始:{ "app": { "security": { "csp": "default-src 'self'; img-src 'self' asset: https:; style-src 'self' 'unsafe-inline'; script-src 'self'" } }}这里的取舍很现实:前端框架和样式库有时需要 inline style,完全禁掉会影响功能;但脚本最好保持 script-src 'self',不要随手加 unsafe-eval。如果确实要加载远程图片或接口,也应该精确到域名,而不是放开所有来源。IPC 边界同样需要校验很多人以为“权限没开就安全”,但自定义 #[tauri::command] 也可能绕开插件权限。比如前端传一个路径给 Rust,Rust 直接读文件,那权限系统并不会自动替你判断业务边界。Rust 端要自己做白名单、路径规范化和参数校验:use std::path::{Path, PathBuf};#[tauri::command]fn read_note(base: String, name: String) -> Result<String, String> { if name.contains("..") || name.contains('/') { return Err("invalid file name".into()); } let path = Path::new(&base).join(name); std::fs::read_to_string(path).map_err(|e| e.to_string())}这段代码仍然只是示意,生产中还要校验 base 是否来自可信配置,而不是任由前端传入。插件和 Shell 是高风险区域Shell、文件系统、剪贴板、自动更新、深链协议都很有用,但权限边界也更敏感。尤其是 Shell,不要允许前端拼接任意命令:{ "permissions": [ { "identifier": "shell:allow-open", "allow": [{ "url": "https://example.com/*" }] } ]}如果业务需要执行系统命令,优先把命令封装在 Rust 侧,并限制参数枚举。前端只传业务意图,不传完整命令行。生产项目里的权限收口流程比较稳的做法是先按功能列权限,而不是先写配置。比如“导入文件”需要 dialog 和只读文件权限,“导出报告”需要保存路径写入权限,“打开官网”只需要受限 URL 的 open 权限。列完后再把权限拆到 capability,确保不同窗口只拿到自己需要的能力。发布前可以做一次反向检查:删除某个权限后,是否只有对应功能失效,而不是整个应用都依赖它。自动更新和签名也属于安全链路。更新包如果没有签名校验,攻击者一旦控制下载链路,权限配置再严格也没有意义。桌面应用还要小心日志,很多团队会把完整路径、用户输入甚至 token 打进日志里,最后通过“导出诊断信息”泄露出去。权限系统管不了这些业务习惯,只能靠代码审查和脱敏规则兜底。依赖和插件要单独审Tauri 插件提升开发效率,但插件本质上也是系统能力入口。引入插件前要看它暴露了哪些命令、默认权限是否过宽、是否还在维护。前端依赖同样不能放松,XSS、原型污染、富文本渲染漏洞都可能让攻击者触发已授权能力。一个实用边界是:凡是能碰文件、进程、网络代理、凭据和自动更新的依赖,都按高风险依赖处理,而不是普通 UI 包。追问Tauri 默认安全就代表不用管 XSS 吗?不是。Tauri 限制的是系统能力暴露方式,XSS 仍然可能读取页面状态、调用已授权 API,甚至借助你的自定义命令做危险操作。边界在于:权限越小,XSS 之后能做的事越少。踩坑点是很多团队只审 Rust 代码,不审前端富文本、Markdown 渲染和远程内容注入,这些才是桌面应用里最常见的入口。capabilities 和旧版 allowlist 有什么取舍?capabilities 更细,可以按窗口、平台和插件声明能力,适合 Tauri 2 的长期维护。旧版 allowlist 更直观,但粒度和组织方式不如 capability 清晰。迁移时不要机械替换字段名,要重新梳理每个窗口到底需要什么能力。否则配置看起来升级了,实际权限仍然过大。文件系统 scope 应该怎么设?优先使用应用目录、用户选择的目录或明确的业务目录,不要开放整个家目录。比如笔记应用可以限定 $APPDATA 和用户导入目录,导出时再通过保存对话框拿到目标路径。取舍是用户第一次操作可能多一步授权,但换来的是事故半径更小。常见坑是开发阶段为了方便放开权限,发布前忘了收紧。远程页面能不能直接放进 Tauri WebView?可以加载,但安全风险明显更高,因为你无法完全控制远程脚本。除非业务就是浏览器壳,否则不建议让远程页面拥有 Tauri API 能力。更稳的做法是本地前端加载远端数据,把系统能力留给可信页面。边界是远程内容可以显示,但不要让它直接调用高权限命令。自定义 Rust 命令怎么做安全审计?先列出命令能碰到的系统资源:文件、网络、进程、凭据、窗口或剪贴板。再看参数是否来自前端、是否做了类型和范围校验、错误信息是否泄露敏感路径。最后检查命令是否能被任意窗口调用,必要时按窗口隔离能力。很多漏洞不是 Rust 不安全,而是业务命令太相信前端传来的参数。
服务端阅读 05月31日 11:08

Tauri 中如何集成 React、Vue 或 Svelte?

Tauri 集成 React、Vue 或 Svelte 的关键,不是把前端框架“塞进”桌面壳里,而是让前端构建工具、Tauri 开发服务器和 Rust 后端的边界对齐。Tauri 负责窗口、系统 API、打包和权限;React、Vue、Svelte 仍然按普通 Web 项目开发。真正容易出问题的地方,通常是 dev server 端口、构建产物目录、前端路由、Tauri API 版本和安全权限没有配套。推荐的集成方式新项目优先用官方脚手架,它会自动生成 src-tauri、前端目录和基础配置:npm create tauri-app@latestcd my-tauri-appnpm installnpm run tauri dev选择框架时,React、Vue、Svelte 都可以直接配合 Vite。已有前端项目也能接入 Tauri,但要先确认项目能静态构建,因为桌面应用最终加载的是本地资源,而不是一个长期运行的 Node 服务。React 项目怎么配置React + Vite 的常见配置如下,重点是固定端口并忽略 src-tauri,否则 Rust 文件变化可能触发前端重复刷新:import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';export default defineConfig({ plugins: [react()], clearScreen: false, server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } }});Tauri 2 的配置通常在 src-tauri/tauri.conf.json 里声明开发地址和构建产物:{ "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", "frontendDist": "../dist", "devUrl": "http://localhost:1420" }}如果使用旧版 Tauri,字段可能是 distDir 和 devPath。这类版本差异是迁移时最常见的坑,不要照抄配置后直接怀疑框架不兼容。Vue 和 Svelte 有什么不同Vue 主要替换 Vite 插件:npm install vue @vitejs/plugin-vue -Dimport vue from '@vitejs/plugin-vue';export default defineConfig({ plugins: [vue()] });Svelte 则使用 @sveltejs/vite-plugin-svelte。三者和 Tauri 的交互方式没有本质区别,区别只在前端编译阶段。Tauri 不关心你写 JSX、模板还是 .svelte 文件,它只关心 devUrl 是否可访问、构建产物是否存在、前端是否按权限调用系统能力。前端如何调用 Tauri 能力安装前端 API 后,可以用 invoke 调用 Rust 命令:npm install @tauri-apps/apiimport { invoke } from '@tauri-apps/api/core';const message = await invoke<string>('greet', { name: 'React' });Rust 端需要显式注册命令:#[tauri::command]fn greet(name: String) -> String { format!("Hello, {name}")}fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![greet]) .run(tauri::generate_context!()) .expect("failed to run app");}生产构建前还要检查什么桌面应用和网页最大的区别,是用户打开的是一个固定版本的本地包。构建前建议先单独跑一次前端构建,再跑 Tauri 构建:npm run buildnpm run tauri build如果 dist 目录不存在,优先检查前端构建脚本,而不是 Tauri 配置。生产环境还要确认 base 路径,Vite 默认通常没问题,但如果你配置过 CDN 路径或子目录部署,桌面端可能会找不到静态资源。另一个容易忽略的点是环境变量,浏览器端只能拿到构建时注入的变量,不能像 Node 服务一样运行时读取 .env。需要根据桌面端、Web 端分别维护配置时,可以用 TAURI_ENV_PLATFORM 或自己的构建脚本区分。系统能力不要混进前端组件React、Vue、Svelte 组件里可以直接调用 Tauri API,但大型项目最好封装一层 services。比如文件选择、配置读写、日志导出都放到独立模块,组件只关心业务结果。这样做的好处是将来要 Mock、迁移插件或调整权限时,不需要到处改按钮事件。代价是多写一层薄封装,但桌面应用生命周期长,这点成本通常值得。追问前端路由应该用 history 还是 hash?桌面端更稳妥的是 hash 路由,因为生产环境加载的是本地文件,刷新或深链进入某个 history 路径时,WebView 可能找不到对应资源。React Router 的 HashRouter、Vue Router 的 createWebHashHistory 都能减少这类问题。取舍在于 URL 不够漂亮,但桌面应用里地址栏通常不可见,这个代价很小。如果坚持 history 模式,需要自己处理 fallback,否则开发环境正常、打包后白屏很常见。能不能直接把 Next.js 放进 Tauri?可以,但不建议把 SSR 当作默认方案。Tauri 更适合加载静态前端,Next.js 需要配置静态导出,避免依赖运行时服务端。边界是:如果你的页面强依赖服务端渲染、API Routes 或 Node 运行时,迁移成本会明显升高。实际项目里更常见的做法是把桌面端改成 Vite React,把需要的接口能力放到 Rust 或远端服务里。Tauri API 调不通一般怎么排查?先确认使用的 API 包和 Tauri 版本匹配,Tauri 1 和 Tauri 2 的 import 路径不同。再检查命令名是否注册到 generate_handler!,参数名是否和前端传入对象一致。还有一个坑是权限:文件、Shell、Dialog 等能力不是随便能用,必须在 capabilities 或配置中声明。不要一上来改 Rust 逻辑,先看浏览器控制台和终端日志,通常错误信息已经说明是权限还是命令不存在。多框架项目怎么选择 React、Vue 或 Svelte?如果团队已有 React 组件库,React 的迁移成本最低;Vue 适合已有 Vue 后台或中台体系的团队;Svelte 包体更轻,但生态和团队熟悉度要评估。Tauri 本身不会因为某个框架变快很多,主要性能差异来自前端渲染方式和资源体积。真正的取舍是长期维护成本,而不是示例项目跑起来的速度。桌面应用还要考虑自动更新、文件权限和系统集成,这些往往比选哪个前端框架更影响交付。
服务端阅读 05月31日 02:05

Tauri 应用性能慢时应该先优化哪里?

Tauri 应用性能优化不要一开始就改 Rust 编译参数。更靠谱的顺序是先量化问题:是启动慢、窗口白屏久、包体积大、IPC 调用频繁,还是内存越跑越高。Tauri 的优势是外壳轻,但最终体验仍然由前端资源、WebView 行为、Rust 命令和系统 API 调用共同决定。先定位性能瓶颈启动阶段可以记录前端首屏时间、Rust setup 耗时和第一次 command 返回时间。运行阶段用 DevTools 看长任务、内存快照和网络请求,用 Rust profiling 看 CPU 热点。没有数据时改 Cargo.toml 很容易变成心理安慰:包小了一点,但用户真正卡住的是首屏加载了太多 JS。const t0 = performance.now();await invoke('load_project');console.log('load_project cost', performance.now() - t0);包体积怎么减?前端先检查依赖和静态资源,尤其是图标库、编辑器、图表库和大图片。能按路由拆分就不要首屏全量加载,能用系统字体就别塞一堆字体文件。Rust release profile 可以继续压缩,但要接受构建时间变长的代价。[profile.release]opt-level = "z"lto = truecodegen-units = 1strip = truepanic = "abort"这个配置适合追求体积的客户端,但调试崩溃会更麻烦。若应用包含大量计算,opt-level = 3 可能比 z 更合适,所以不要把“体积最小”当成唯一目标。IPC 为什么会拖慢应用?每次 invoke 都要序列化参数、跨进程通信、执行 Rust 逻辑、再把结果序列化回来。少量调用没问题,问题出在循环里一条条查数据、传大 JSON、或者用轮询模拟事件。能批量就批量,能缓存就缓存,能用事件推送就别每 200ms 问一次。#[tauri::command]async fn load_items(ids: Vec<String>) -> Result<Vec<Item>, String> { // 一次查完,避免前端循环 invoke query_items(ids).await.map_err(|e| e.to_string())}启动速度怎么优化?Rust setup 里不要做大文件扫描、网络请求或数据库迁移,至少不要阻塞窗口出现。前端首屏也要克制,先渲染可交互骨架,再加载编辑器、图表、AI SDK 这类重模块。边界是安全检查和必要配置必须在启动前完成,但可以把耗时任务移到后台,并用事件通知 UI。const Editor = lazy(() => import('./Editor'));内存和事件监听怎么处理?Tauri 应用常驻桌面,内存泄漏比网页更容易被用户感知。窗口事件、全局快捷键、文件监听和自定义事件都要在组件卸载时释放。Rust 侧少做无意义 clone,大对象用 Arc 或数据库分页读取,别把整套工程文件一次性塞进内存。数据缓存要放在哪一层?性能优化里缓存很有用,但缓存位置要按数据类型选。短期 UI 状态放前端 store,跨窗口共享或需要落盘的数据放 Rust 侧或数据库,临时大文件放 cache 目录。不要为了少一次 IPC 就把所有数据复制到前端,也不要为了“原生更快”把每个按钮状态都交给 Rust 管。缓存还要有失效策略,尤其是项目文件、用户配置和远程数据混在一起时。优化完成后要保留基准数据,否则下一次依赖升级可能把问题带回来。至少把关键指标写进发布检查,而不是只靠个人感觉。追问应该先优化前端还是 Rust?多数 Tauri 应用先看前端,因为首屏 JS、DOM 数量和大依赖更容易拖慢用户体感。Rust 侧当然也会慢,尤其是文件扫描、压缩、加密和数据库查询,但它通常更容易通过 profiling 找到热点。取舍是先修用户能感知的路径,再处理构建参数和底层算法。IPC 返回大对象有什么坑?大对象会带来序列化成本,还可能让 WebView 一次性分配很多内存。更稳的做法是分页、流式事件、临时文件或只返回摘要,用户点开时再加载详情。边界是小配置、小状态没必要过度设计,但日志、图片、二进制和大列表不适合直接塞进 JSON。Web Worker 在 Tauri 里还有必要吗?有必要,因为 WebView 的主线程仍然会被计算密集型 JS 卡住。图表布局、文本 diff、压缩预处理可以放 Worker,系统级重活则考虑 Rust command。踩坑点是 Worker 不能直接访问所有前端上下文,和 Tauri API 的交互要设计清楚。release profile 会不会影响稳定性?一般不会直接影响业务逻辑,但会影响调试、构建耗时和崩溃信息。strip 后符号少,线上 crash 排查更难;LTO 会让 CI 构建更慢。性能敏感应用要准备两套配置:日常 release 便于排查,正式分发再开更激进的压缩。怎么判断优化真的有效?至少记录优化前后的启动时间、首屏时间、包体积、关键 command 耗时和内存曲线。只看一次本机结果不够,Windows 低配机和 Linux 不同桌面环境经常表现不一样。真正可靠的优化,是指标变好且用户路径没有被破坏,而不是某个配置看起来更高级。
服务端阅读 05月31日 02:05

Tauri 插件系统是如何把 Rust 能力暴露给前端的?

Tauri 插件系统的核心作用,是把一组 Rust 能力、前端 API、权限声明和初始化逻辑封装成可复用模块。普通 command 适合项目内一次性能力,插件适合跨项目复用,或者需要在应用启动时注册状态、事件、菜单、后台任务的能力。真正要理解插件,重点不是“怎么写一个函数”,而是它如何在 Rust 侧注册命令,再通过 JavaScript 包提供稳定入口。插件由哪几部分组成?一个完整插件通常包含 Rust crate、前端 npm 包、权限配置和示例文档。Rust 侧负责执行系统能力,比如读写文件、调用原生库、管理状态;前端侧只暴露类型友好的函数,不应该让业务代码到处手写 invoke 字符串。这样做的取舍是工程结构变重,但多人项目里更容易控制边界和升级。pnpm tauri plugin init my-plugin# 或在 Rust 工具链里使用对应的插件脚手架cargo install tauri-cliRust 侧怎样注册命令?插件命令仍然是 Tauri command,只是注册在插件命名空间下。下面的例子把一个简单的 greet 暴露出去,真实项目里可以在 setup 里初始化数据库连接、缓存目录或后台 worker。错误类型建议转成字符串或可序列化结构,别把复杂 Rust error 原样丢给前端。use tauri::{plugin::TauriPlugin, Manager, Runtime};#[tauri::command]async fn greet(name: String) -> Result<String, String> { if name.trim().is_empty() { return Err("name cannot be empty".into()); } Ok(format!("hello, {name}"))}pub fn init<R: Runtime>() -> TauriPlugin<R> { tauri::plugin::Builder::new("my-plugin") .invoke_handler(tauri::generate_handler![greet]) .setup(|app, _api| { let _cache = app.path().app_cache_dir()?; Ok(()) }) .build()}应用里注册插件时要放在 builder 链上,顺序一般不敏感,但依赖某些状态的插件要保证状态先初始化。fn main() { tauri::Builder::default() .plugin(my_plugin::init()) .run(tauri::generate_context!()) .expect("error while running tauri app");}前端 API 为什么要单独封装?前端封装可以隐藏 plugin:my-plugin|greet 这种字符串协议,也能把参数和返回值写成 TypeScript 类型。业务代码只依赖 greet(name),以后插件内部从 command 改成事件、缓存或批处理,都不必全局替换。import { invoke } from '@tauri-apps/api/core';export function greet(name: string): Promise<string> { return invoke('plugin:my-plugin|greet', { name });}权限和配置放在哪里?Tauri v2 更强调 capability,插件如果涉及文件、shell、网络或系统通知,必须让应用显式声明权限。这个设计牺牲了一点上手速度,但能避免插件安装后默认拿到过大的系统能力。踩坑最多的是开发阶段 all allow,发布前忘了收紧,导致审核或安全检查不过。插件如何处理状态和事件?插件不只适合“一问一答”的 command,也适合维护后台状态。比如文件监听、下载进度、设备连接状态,可以在 Rust 侧运行任务,再通过事件推给前端。这里的边界是不要把插件写成万能全局服务,状态越多,生命周期越难管,窗口关闭、应用退出和权限变化都要处理。事件名也要当成 API 设计,最好带上插件前缀,避免和业务事件撞名。配置项也要保持向后兼容,插件升级时不要突然删除字段。更稳的做法是给默认值,并在初始化阶段做版本迁移。追问什么时候该写插件,而不是普通 command?如果能力只在一个应用里用,普通 command 更轻,目录结构也简单。只要它开始被多个项目复用,或者需要配套 npm API、权限、初始化和文档,就值得拆成插件。取舍在于维护成本:插件要考虑版本兼容和发布流程,不能像业务 command 那样随手改签名。插件能直接访问前端状态吗?不能把前端状态当成 Rust 里的全局变量来用,插件和 WebView 之间仍然要靠 invoke、事件或状态管理通信。真正需要共享的数据,应放在 Rust managed state、数据库或前端 store 中,并明确谁是数据源。边界是实时 UI 状态不适合塞进插件,系统能力和持久化任务才适合放到 Rust 侧。插件错误应该怎么返回给前端?不要只返回 String 然后让前端猜含义,稍复杂的插件可以定义 { code, message } 这样的可序列化错误。这样 UI 可以区分权限不足、参数错误、系统调用失败和用户取消。踩坑点是 Rust error 很丰富,但跨 IPC 后只能传可序列化数据,错误设计太随意会让前端无法做可靠提示。官方插件和自研插件怎么选?文件、对话框、通知、剪贴板这类通用能力优先用官方插件,因为权限模型和跨平台细节有人维护。自研插件适合业务协议、公司内部 SDK、特殊硬件或性能敏感的原生能力。取舍是官方插件稳定但不一定覆盖细节,自研插件灵活但要自己承担测试矩阵。插件发布到 npm 和 crates.io 要注意什么?前端包和 Rust crate 的版本最好同步,否则用户会遇到 JS API 已升级、Rust 插件没升级的错位问题。发布前要固定最低 Tauri 版本,并写清楚需要哪些 capability 权限。最容易踩坑的是只测示例项目,不测从空项目安装后的真实路径和打包结果。
服务端阅读 05月31日 02:05

Tauri 应用从开发到打包要走哪些流程?

Tauri 应用的构建流程可以理解成两条线并行:前端先产出静态资源,Rust 再把 WebView、命令、权限和这些资源一起编进桌面应用。开发时它连着本地 dev server,打包时它读取前端构建目录;很多构建失败不是 Tauri 本身的问题,而是这两条线的路径、命令或系统依赖没有对齐。构建前要确认哪些环境?最少需要 Node.js、Rust stable、系统 WebView 依赖和 Tauri CLI。macOS 还要有 Xcode Command Line Tools;Windows 建议使用 MSVC 工具链;Linux 常见坑是缺 WebKitGTK、AppIndicator 或 OpenSSL 开发包。版本不要混着猜,先把命令固定到项目脚本里,CI 和本机都用同一套入口。rustup update stablepnpm installpnpm tauri devpnpm tauri buildTauri v2 的配置重点是 beforeDevCommand、beforeBuildCommand、devUrl 和 frontendDist。开发模式读取 devUrl,生产包读取 frontendDist,如果前端框架输出目录从 dist 改成了 build,这里不改就会出现白屏。{ "productName": "DeskTool", "version": "1.0.0", "identifier": "com.example.desktool", "build": { "beforeDevCommand": "pnpm dev", "beforeBuildCommand": "pnpm build", "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, "bundle": { "active": true, "targets": ["dmg", "msi", "deb"], "icon": ["icons/icon.icns", "icons/icon.ico"] }}开发模式和生产打包有什么区别?tauri dev 会启动前端开发服务器,然后编译 Rust 外壳,窗口里加载的是本地 URL,所以热更新快,但它不能代表最终安装包表现。tauri build 会先执行前端生产构建,再把静态文件打进应用资源目录,随后生成平台安装包。上线前至少要在生产包里走一遍登录、文件读写、自动更新、深链和权限弹窗,因为这些问题在 dev server 下经常被掩盖。跨平台打包应该怎么安排?Tauri 不是“一台机器打所有平台”最省心的方案。macOS 签名和 notarization 最好在 macOS runner 上做,Windows MSI/NSIS 放 Windows runner,Linux AppImage/deb 放 Ubuntu runner。交叉编译可以用 Rust target 解决一部分二进制问题,但安装包、系统依赖和签名链仍然强依赖目标平台。strategy: matrix: os: [macos-latest, windows-latest, ubuntu-22.04]steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: dtolnay/rust-toolchain@stable - run: pnpm install --frozen-lockfile - run: pnpm tauri build发布前还要检查哪些细节?打包不是最后一个命令跑完就结束,真正麻烦的是安装后的表现。建议准备一张发布检查表:应用图标是否在三个平台都正常显示,配置文件是否写到用户目录,自动更新地址是否可访问,首次启动是否触发不必要的安全弹窗。前端静态资源也要用生产包验证,特别是字体、wasm、worker 和懒加载 chunk。版本号需要同时关注前端 package、Tauri 配置和更新服务的 manifest。只改其中一个,用户看到的版本、安装包文件名和自动更新判断可能会不一致。这个问题平时不显眼,一到灰度发布就会拖慢排查。还有一个容易忽略的点是资源路径。桌面包里的资源不再处于网站根目录,前端代码里写死 /assets/a.png、运行时再请求本地开发端口,都会在用户机器上失败。发布前最好断网打开安装包,确认核心页面不依赖开发环境。如果应用带自动更新,还要检查更新包签名和下载地址。这个环节最好在测试环境完整跑一次。追问为什么本地 dev 能跑,build 后却白屏?最常见原因是 frontendDist 指错了,或者前端用了只在开发服务器存在的路径。生产包里没有 Vite/Next 的 dev server,静态资源必须能从相对路径加载。取舍上,前端路由尽量使用 hash 或正确的 base 配置,少依赖服务端 fallback;踩坑点是 CSS、字体和懒加载 chunk 的绝对路径经常漏测。macOS 签名和 notarization 可以最后再补吗?内部测试包可以先不签名,但面向用户分发时不能拖到最后一天。签名会影响 entitlements、自动更新、网络权限和系统拦截提示,后补时容易发现包结构或权限模型要改。边界是企业内部分发和公开下载要求不同;公开下载建议尽早把 Developer ID、hardened runtime 和 notarization 放进 CI。Windows 上 MSI 和 NSIS 应该选哪个?MSI 更适合企业环境和集中管理,NSIS 的安装体验更灵活,也更容易做自定义页面。两者不是性能差异,而是分发场景不同。踩坑点是升级策略、安装路径和签名证书要提前定,否则用户机器上可能同时残留多个版本。包体积应该在什么时候优化?等功能稳定后再极限压缩更稳,因为 opt-level = "z"、LTO、strip 会拉长构建时间,也可能让调试信息变少。前期先控制前端依赖和图片资源,收益通常比折腾 Rust profile 更直接。边界是工具类应用用户更在意启动速度和下载体积,后台常驻应用则还要关注内存和更新包大小。CI 构建失败先查哪里?先看系统依赖和缓存,不要一上来怀疑业务代码。Linux runner 缺 WebKitGTK、Windows runner 没装正确 MSVC、macOS 证书没导入,是最常见三类问题。建议把 rustc -V、node -v、pnpm -v 和 Tauri 配置打印出来,排查会比盯着最后一行报错快很多。