前端面试题手册

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

前端阅读 05月27日 20:04

Garfish 的生命周期钩子有哪些?provider 函数和 show/hide 怎么用?

Garfish 子应用的生命周期围绕 provider 函数展开,核心钩子按执行顺序为:bootstrap → mount → update(可选) → unmount,另有 show/hide 用于缓存场景。与 qiankun 的最大区别在于:Garfish 子应用必须导出 provider 函数而非直接导出生命周期函数。核心钩子及执行顺序| 钩子 | 触发时机 | 调用次数 | 作用 ||------|----------|----------|------|| bootstrap | 子应用首次加载 | 仅 1 次 | 初始化配置、注入依赖 || mount | 子应用渲染到容器 | 每次激活 | 挂载 DOM、启动渲染 || unmount | 子应用从页面移除 | 每次离开 | 清理 DOM、事件、定时器 || update | 父应用传递 props 变更(可选) | 按需 | 响应属性更新 || show | 缓存子应用重新显示(可选) | 按需 | 恢复运行状态 || hide | 缓存子应用被隐藏(可选) | 按需 | 暂停但不销毁 |执行顺序:首次加载:provider() → bootstrap → mount路由切换离开:unmount(非缓存)或 hide(缓存模式)路由切换回来:mount(非缓存)或 show(缓存模式,跳过 bootstrap)属性变更:update彻底销毁:unmountprovider 函数:Garfish 生命周期的入口Garfish 子应用必须导出一个 provider 函数,它的返回值才是真正的生命周期对象:// 子应用入口export function provider({ basename, dom, ...props }) { return { bootstrap() { console.log('[sub-app] bootstrap, basename:', basename); return Promise.resolve(); }, mount({ basename, dom }) { const container = dom.querySelector('#app'); ReactDOM.render(<App basename={basename} />, container); return Promise.resolve(); }, unmount({ dom }) { const container = dom.querySelector('#app'); ReactDOM.unmountComponentAtNode(container); return Promise.resolve(); }, update({ ...newProps }) { // 响应主应用传入的属性变更 return Promise.resolve(); }, };}关键点:provider 接收主应用传入的 props(如 basename、dom 容器),在 mount/unmount 中通过参数获取运行时上下文,而非闭包变量。show/hide:缓存模式下的生命周期当主应用配置 sandbox.cache = true 时,子应用不会被销毁,而是通过 show/hide 控制显隐:export function provider() { let app = null; return { // ...bootstrap, mount, unmount 省略 show() { // 恢复定时器、重新订阅事件、恢复动画 console.log('[sub-app] show: 恢复运行状态'); return Promise.resolve(); }, hide() { // 暂停定时器、取消事件订阅、暂停动画(不销毁 DOM) console.log('[sub-app] hide: 暂停运行状态'); return Promise.resolve(); }, };}缓存模式下 show/hide 与 mount/unmount 互斥:激活走 show(不走 mount),离开走 hide(不走 unmount)。完整生命周期流程图首次加载: 下载子应用 JS → 执行沙箱隔离 → 调用 provider() → bootstrap() → mount()路由切换(非缓存): 旧子应用 unmount() → 新子应用 mount()路由切换(缓存模式): 旧子应用 hide() → 新子应用 mount() 或 show()属性更新: 主应用 setProps() → 子应用 update()彻底销毁: unmount() → 清理沙箱 → 释放内存插件级生命周期钩子除子应用生命周期外,Garfish 还提供主应用侧的插件钩子,用于拦截加载过程:Garfish.run({ plugins: [ () => ({ beforeLoad(appInfo) { console.log('即将加载:', appInfo.name); return appInfo; }, afterLoad(appInfo) { console.log('加载完成:', appInfo.name); }, beforeMount(appInfo) { console.log('即将挂载:', appInfo.name); }, afterMount(appInfo) { console.log('挂载完成:', appInfo.name); }, beforeUnmount(appInfo) { console.log('即将卸载:', appInfo.name); }, afterUnmount(appInfo) { console.log('卸载完成:', appInfo.name); }, }), ],});这些钩子在主应用侧执行,可用于日志采集、性能监控、权限校验等横切逻辑。与 qiankun 生命周期的对比| 对比项 | Garfish | qiankun ||--------|---------|----------|| 导出方式 | provider 函数返回生命周期对象 | 直接导出 bootstrap/mount/unmount || 缓存钩子 | show/hide | 无(需自行实现) || 插件钩子 | beforeLoad/afterLoad 等 6 个 | 框架级 beforeLoad/afterMount 等 || 参数传递 | provider(props) + mount(props) | mount(props) || 沙箱集成 | 生命周期与沙箱强绑定 | 沙箱独立于生命周期 |常见踩坑与解决方案1. mount 中拿不到容器 DOMmount 回调中的 dom 参数是 Garfish 创建的容器,需要在 dom 内查找挂载点:mount({ dom }) { // 错误:document.getElementById('app') // 正确:在 Garfish 提供的 dom 内查找 const container = dom.querySelector('#sub-app-root'); ReactDOM.render(<App />, container);}2. unmount 后仍然有内存泄漏定时器和全局事件监听不会随 DOM 移除而自动清理:let timer = null;let resizeHandler = null;mount({ dom }) { timer = setInterval(sendHeartbeat, 5000); resizeHandler = () => recalculateLayout(); window.addEventListener('resize', resizeHandler); // ...},unmount() { clearInterval(timer); window.removeEventListener('resize', resizeHandler); timer = null; resizeHandler = null;}3. 缓存模式下 show/hide 未实现导致状态异常如果开启缓存但只实现了 mount/unmount,子应用在 hide 后定时器仍在运行、事件仍在监听,切回时可能出现重复绑定。必须配套实现 show/hide。追问Q: Garfish 为什么选择 provider 函数模式,而不是像 qiankun 那样直接导出生命周期?provider 模式有两个优势:一是每次加载都可以通过 provider 重新创建生命周期实例,避免单例模式下多次挂载的状态污染;二是 provider 在执行时可以拿到主应用传入的 props(如 basename、dom),在闭包中天然拥有运行时上下文,不需要在 mount 中额外合并参数。Q: 如果子应用不实现 unmount 会怎样?子应用的 DOM 不会从容器中移除,事件监听器和定时器继续运行,路由切换后旧应用的副作用仍在执行,会导致内存泄漏、事件重复触发、UI 叠加渲染等问题。Garfish 不会强制校验 unmount 的实现,这是开发者的责任。Q: bootstrap 和 mount 的区别是什么,能不能把初始化逻辑都放在 mount 里?bootstrap 只执行一次,mount 每次激活都会执行。如果把初始化逻辑(如加载配置、注册全局插件)放在 mount 里,每次路由切回都会重复执行,既浪费性能又可能导致重复注册。正确的做法是:一次性初始化放 bootstrap,每次挂载都需要的渲染逻辑放 mount。
前端阅读 05月27日 20:03

Garfish 的错误处理和降级机制是如何工作的?

Garfish 通过沙箱自动降级、生命周期错误捕获、资源加载容错三层机制保证微前端稳定性。沙箱降级是核心。默认启用基于 Proxy 的 VM 沙箱,代理 window 对象隔离子应用全局变量,支持多实例并行。浏览器不支持 Proxy 时自动降级为快照沙箱——挂载前保存 window 全量快照,卸载后恢复原状,只支持单实例切换。降级过程对业务透明,框架内部自动判断。生命周期层面,Garfish 在 beforeLoad、afterLoad、beforeMount、afterMount、beforeUnmount、afterUnmount 六个阶段提供钩子,任一阶段异常均触发全局 error 事件。主应用统一监听即可捕获所有子应用错误:Garfish.router.on('error', (error) => { reportError(error); // 上报监控 showErrorPage(); // 降级 UI});资源加载容错:Garfish 用 fetch 拉取子应用 JS/CSS,网络失败触发 afterLoad 错误回调。跨域动态脚本(JSONP 等)被转成 fetch 请求,后端未配 CORS 会报跨域错误。可用 excludeAssetFilter 放行特定脚本,但放行的资源会逃逸沙箱执行,副作用难以追踪。追问Proxy 沙箱和快照沙箱的核心区别?Proxy 沙箱代理 window 读写,每个子应用有独立代理对象,支持多实例并行。快照沙箱在挂载时浅拷贝 window,卸载时还原,只能串行切换。Proxy 隔离更彻底但不兼容 IE;快照兼容性好但全局对象可能被意外修改。子应用崩溃会拖垮主应用吗?JS 错误被沙箱隔离,不会直接污染主应用状态。但 DOM 副作用可能逃逸——子应用在 document 上绑的事件监听器、插入的全局样式,卸载后不会自动清理。规范做法是在 unmount 钩子中手动移除副作用,或用 Garfish 的 DOM 沙箱自动收集和清理。沙箱里 sourcemap 行号为什么对不上?Garfish 通过 eval + sourceURL 执行子应用代码,sourceURL 改变了错误堆栈中的文件标识,导致行号偏移,sourcemap 还原指向错误位置。需要用 Garfish 提供的行号修正工具对齐偏移量。excludeAssetFilter 放行的脚本出了问题怎么排查?放行 = 脱离沙箱,脚本里的全局变量写入、事件绑定都不受管控。排查思路:在放行脚本的入口和出口打日志对比 window 差异;生产环境尽量不放行,让后端加 CORS 头保持资源在沙箱内加载。实际踩坑中,放行 JSONP 脚本导致全局变量污染另一个子应用的案例很常见。
前端阅读 05月27日 20:03

Garfish 的路由管理系统如何工作,如何实现主子应用的路由协同?

Garfish 的路由管理系统是微前端架构中最关键的基础设施之一——主应用需要知道何时加载/卸载子应用,子应用需要知道自己的路由空间在哪,两者必须无缝协同才能实现"像单页应用一样"的用户体验。本文将从路由劫持原理、basename 自动计算、路由分发机制、主子路由同步四个层面,拆解 Garfish 路由系统的完整工作流程。路由劫持:一切从拦截浏览器路由开始Garfish 在执行 Garfish.run() 时,会立即对浏览器的路由行为进行劫持。具体做法是重写 window.history.pushState 和 window.history.replaceState,同时监听 popstate 和 hashchange 事件。// Garfish.run() 执行后,路由劫持自动生效Garfish.run({ basename: '/', domGetter: '#subApp', apps: [ { name: 'react-app', activeWhen: '/react', entry: 'http://localhost:3000', }, { name: 'vue-app', activeWhen: '/vue', entry: 'http://localhost:8080/index.js', }, ],});劫持的目的有两个:感知路由变化:每次 URL 变化时,Garfish 都能第一时间捕获到新的路径。接管路由控制权:根据新的路径判断应该激活哪个子应用、销毁哪个子应用,而不是让浏览器默认行为接管。这意味着在 Garfish 运行之后,所有路由跳转都经过 Garfish 的路由管理层,主应用不再直接操控浏览器路由,而是通过 Garfish 间接操控。路由匹配与子应用分发当路由劫持捕获到 URL 变化后,Garfish 进入路由匹配阶段。核心逻辑是遍历 apps 配置,用每个子应用的 activeWhen 规则与当前路径做匹配:// activeWhen 支持字符串、正则、函数三种形式{ name: 'react-app', activeWhen: '/react', // 字符串前缀匹配}{ name: 'admin-app', activeWhen: /^\/admin/, // 正则匹配}{ name: 'special-app', activeWhen: (path) => path.startsWith('/special'), // 函数匹配}匹配成功后,Garfish 执行以下流程:检查子应用状态:如果子应用已加载且当前激活,则仅更新子应用路由;如果未加载,则触发子应用加载。加载子应用资源:根据 entry 配置(HTML 入口或 JS 入口)请求子应用资源,创建沙箱环境,执行子应用代码。调用 render 生命周期:将 dom、basename 等信息通过 provider 的 render 函数传递给子应用。卸载非活跃子应用:对不再匹配的子应用调用 destroy 生命周期,清理 DOM 和事件监听。关键细节:不要使用根路径 / 作为 activeWhen,否则该子应用在任何路径下都会被激活,导致其他子应用永远无法加载。basename 自动计算:路由隔离的核心机制basename 是 Garfish 实现路由隔离的关键。子应用的 basename 计算公式为:子应用 basename = 主应用 basename + activeWhen例如,主应用 basename 为 /,子应用 activeWhen 为 /react,则子应用收到的 basename 为 /react。如果主应用 basename 改为 /portal,子应用的 basename 自动变为 /portal/react。// 子应用 provider 配置export const provider = () => ({ render({ dom, basename }) { // basename = 主应用 basename + activeWhen // 必须将 basename 设置为子应用路由的 base path ReactDOM.render( <BrowserRouter basename={basename}> <App /> </BrowserRouter>, dom ? dom.querySelector('#root') : document.querySelector('#root') ); }, destroy({ dom }) { ReactDOM.unmountComponentAtNode( dom ? dom.querySelector('#root') : document.querySelector('#root') ); },});如果子应用不使用 basename,会出现两个严重问题:路由冲突:子应用的路由 /home 会和主应用的 /home 冲突。路由丢失:子应用内部跳转时,路径不会带上 /react 前缀,导致刷新页面后 Garfish 无法匹配到子应用。主子应用路由同步Garfish 的路由同步要解决的核心问题是:子应用既需要能独立运行(开发阶段直接启动),又需要能嵌入主应用运行(生产环境)。Garfish 通过以下机制实现两种模式的平滑切换:1. 路由跳转方式// 方式一:使用 Garfish.router(推荐)Garfish.router.push('/react/dashboard');// 自动带上全局 basename,跳转到正确的完整路径// 方式二:使用子应用框架路由// 需要手动添加 basename 前缀history.push(`${basename}/dashboard`);Garfish.router.push() 会自动拼接全局 basename 作为路径前缀,确保跳转目标正确。而使用框架自带路由跳转时,必须手动添加 basename,否则路径会缺少前缀。2. autoRefreshApp 控制Garfish.run({ autoRefreshApp: true, // 默认 true // ...});autoRefreshApp: true(默认):路由变化时自动刷新子应用视图,子应用内部路由跳转完全正常。autoRefreshApp: false:路由变化时不自动刷新子应用,子应用子路由只能通过 Garfish.router 跳转,但子应用一级路由仍可使用框架路由。3. 路由守卫Garfish 提供 beforeEach 和 afterEach 钩子,用于在路由变化时执行拦截逻辑:Garfish.router.beforeEach((to, from, next) => { // to: 目标路由信息 // from: 来源路由信息 // next: 继续路由跳转(必须调用) if (to.path.startsWith('/admin') && !isAuthenticated()) { // 重定向到登录页 next('/login'); } else { next(); }});Garfish.router.afterEach((to, from) => { // 路由跳转完成后的逻辑 trackPageView(to.path);});路由模式与限制| 特性 | 支持情况 | 说明 ||------|----------|------|| 主应用 History 模式 | 完全支持 | 推荐使用 || 主应用 Hash 模式 | 不支持 | Garfish 路由系统仅支持主应用 History 路由 || 子应用 History 模式 | 支持 | 需正确配置 basename || 子应用 Hash 模式 | 支持 | 子应用内部可使用 Hash 路由 |常见问题与排错子应用路由跳转后页面白屏原因:子应用未使用 basename 配置路由基础路径。跳转后的路径缺少 activeWhen 前缀,Garfish 匹配不到子应用,触发卸载。解决:在子应用 provider.render 中将 basename 传递给路由组件。主应用 basename 变更后子应用路由异常原因:部分子应用硬编码了路径前缀,而不是使用动态传入的 basename。解决:确保所有子应用路由均基于 provider.render 接收的 basename 动态构建。子应用内部路由跳转不生效原因:autoRefreshApp 设为 false,但子应用使用了框架路由跳转而非 Garfish.router。解决:将 autoRefreshApp 设为 true,或改用 Garfish.router.push() 进行跳转。多个子应用同时激活原因:某个子应用的 activeWhen 配置为 / 或过于宽泛的正则,导致路径匹配到多个子应用。解决:确保每个子应用的 activeWhen 互斥,不要使用根路径作为激活条件。最佳实践总结主应用必须使用 History 路由模式,Hash 模式不被 Garfish 路由系统支持。子应用必须使用 basename,且从 provider.render 参数中动态获取,不要硬编码。跨应用跳转统一使用 Garfish.router.push(),避免手动拼接路径出错。activeWhen 规则保持互斥,禁止使用根路径 /,避免多个子应用同时匹配。autoRefreshApp 保持默认 true,除非有明确的性能优化需求。路由守卫用于权限控制,复杂的业务逻辑放在应用内部,守卫层只做拦截和重定向。Garfish 的路由系统通过劫持、匹配、隔离、同步四层机制,解决了微前端架构中最棘手的路由协同问题。理解这套机制,才能在实际项目中避免路由冲突、白屏、状态丢失等常见坑。
前端阅读 05月27日 20:01

什么是 Garfish 微前端框架,它的核心特点和应用场景是什么?

Garfish 是字节跳动开源的微前端框架,主要解决大型前端应用在跨团队协作、技术栈多样化和独立部署方面的痛点。与 qiankun 等方案相比,Garfish 在沙箱隔离和依赖共享上有独特设计,适合对隔离性和性能有更高要求的企业级场景。Garfish 的核心架构Garfish 的整体架构由以下核心模块组成:Loader(加载器):负责子应用资源的获取和解析,支持异步加载和预加载策略Sandbox(沙箱):隔离子应用的 JavaScript 执行环境,防止全局变量污染Router(路由):管理子应用的路由注册、匹配和切换Store(状态管理):提供跨应用的通信机制下面通过一个最小接入示例说明 Garfish 的基本用法:import Garfish from 'garfish';Garfish.run({ basename: '/', domGetter: '#sub-app', apps: [ { name: 'vue-app', entry: 'http://localhost:8080', activeWhen: '/vue', }, { name: 'react-app', entry: 'http://localhost:3000', activeWhen: '/react', }, ],});主应用只需配置子应用的名称、入口地址和激活路由,Garfish 会自动处理加载、挂载和卸载。六大核心特点详解1. 沙箱隔离Garfish 提供两种沙箱模式:快照沙箱(Snapshot Sandbox):在子应用挂载前快照 window 对象,卸载后恢复。适用于不支持 Proxy 的浏览器,但无法处理动态添加的全局变量。VM 沙箱(Proxy Sandbox):基于 ES6 Proxy 实现,为每个子应用创建一个代理 window 对象,真正实现了全局变量的隔离。这是 Garfish 推荐的方式。// VM 沙箱原理示意const proxyWindow = new Proxy(window, { get(target, key) { // 优先从子应用自己的状态中读取 return ownState[key] ?? target[key]; }, set(target, key, value) { ownState[key] = value; // 写入子应用独立状态 return true; },});VM 沙箱的优势在于多个子应用可以同时运行而互不干扰,这也是 Garfish 支持多实例的基础。2. 依赖共享Garfish 支持子应用之间共享公共依赖(如 React、Vue、Lodash 等),避免重复加载同一库的多个副本:Garfish.run({ apps: [ { name: 'app1', entry: 'http://localhost:8081', props: { react: require('react'), // 共享 React 实例 }, }, ],});依赖共享能显著降低整体包体积和加载时间,对于同时运行多个 React 子应用的场景尤为明显。3. 预加载策略Garfish 内置智能预加载机制,会在浏览器空闲时提前获取子应用资源:自动记录用户访问习惯,为高频使用的子应用增加预加载权重支持 prefetch 配置项,可自定义预加载行为预加载的资源包括 HTML、JS、CSS 等子应用入口依赖Garfish.run({ prefetch: true, // 开启预加载 // 也可传入函数自定义预加载逻辑 // prefetch: (apps) => apps.filter(app => app.name === 'high-priority-app'),});4. 框架无关Garfish 对子应用的技术栈没有限制,React、Vue、Angular、Svelte 等均可接入。子应用只需导出固定的生命周期钩子:// 子应用需要导出的生命周期export function provider({ dom, basename, props }) { return { mount() { /* 挂载逻辑 */ }, unmount() { /* 卸载逻辑 */ }, update() { /* 可选,接收父应用传参更新 */ }, };}5. 多实例支持不同于部分微前端方案只允许单个子应用运行,Garfish 支持在同一页面中同时运行多个子应用实例:// 同一页面同时挂载两个子应用Garfish.run({ domGetter: '#container', apps: [ { name: 'sidebar', activeWhen: '/', entry: '...' }, { name: 'main-content', activeWhen: '/', entry: '...' }, ],});这在后台管理系统等需要布局嵌套的场景中非常实用。6. 样式隔离Garfish 提供多种样式隔离方案:CSS Scoped:为子应用的样式自动添加作用域前缀Shadow DOM:利用浏览器原生的 Shadow DOM 实现完全隔离CSS Modules:配合构建工具使用,从源头避免样式冲突Garfish vs Qiankun:如何选择?| 对比维度 | Garfish | Qiankun ||---------|---------|---------|| 出品方 | 字节跳动 | 蚂蚁集团 || 沙箱方案 | 快照 + VM 双模式 | 快照 + Proxy 双模式 || 依赖共享 | 原生支持 | 需额外配置 || 多实例 | 原生支持 | 有限支持 || 预加载 | 内置智能预加载 | 需手动配置 || 社区规模 | ~2.9k Stars | ~16k Stars || 文档完善度 | 中等 | 较完善 || 适用场景 | 隔离性要求高、需多实例 | 通用场景、快速接入 |选择建议:选 Garfish:项目需要强隔离、多实例共存、依赖共享,或已在字节生态内选 Qiankun:追求社区支持、开箱即用,或团队微前端经验较少典型应用场景企业级后台管理系统多个业务团队各自维护独立子应用(权限管理、数据分析、运营工具等),通过 Garfish 统一接入主框架,实现独立开发、独立部署。电商平台活动页、商品详情、购物车等模块由不同团队负责,使用 Garfish 的预加载和依赖共享优化首屏性能。大型 SaaS 产品不同功能模块(CRM、BI、工单系统)采用不同技术栈,Garfish 的框架无关特性允许各模块选择最合适的技术方案。接入注意事项子应用改造:需要导出 provider 生命周期函数,并在打包配置中设置 libraryTarget: 'umd'跨域配置:子应用需配置 CORS 头,允许主应用跨域获取资源环境变量:子应用中访问 window 时需注意沙箱代理,避免直接操作导致泄漏公共路径:子应用的静态资源路径需正确配置 publicPath,防止资源加载失败总结Garfish 作为字节跳动出品的微前端框架,在沙箱隔离、依赖共享和多实例方面有独到优势。如果你的项目对应用隔离有较高要求,或者需要在一个页面中同时运行多个子应用,Garfish 是值得考虑的方案。但在社区生态和文档完善度上,它目前仍落后于 qiankun,团队在选型时需要权衡技术优势与社区支持之间的取舍。
前端阅读 05月27日 18:25

Lottie 支持哪些动画类型和效果?

Lottie 到底支持哪些动画类型和效果?Lottie 是 Airbnb 开源的跨平台动画渲染库,能将 After Effects 导出的 JSON 文件在 Web、iOS、Android 等平台上高效播放。但 Lottie 并不支持 AE 的所有特性——了解它的能力边界,才能在设计和开发中少走弯路。基础变换动画基础变换是 Lottie 支持最完整的动画类型,涵盖图层的四个核心属性:位置(Position):控制图层在画布上的 X/Y/Z 坐标,支持多关键帧和贝塞尔曲线插值。这是最常用的动画属性,从元素位移到弹跳效果都依赖它。缩放(Scale):控制图层的 X/Y/Z 缩放比例,100 表示原始尺寸。可以实现放大、缩小、拉伸等效果。旋转(Rotation):控制图层绕锚点旋转的角度,支持 0-360 度及更多圈数。常用于图标旋转、指针转动等场景。不透明度(Opacity):控制图层的透明度,0 为完全透明,100 为完全不透明。淡入淡出是最典型的应用。这四个属性是 Lottie 动画的基石,所有平台均完整支持,且性能开销极小。形状动画Lottie 对矢量形状的动画支持非常丰富:路径变形(Path Animation):通过关键帧插值路径的顶点(v)、入贝塞尔手柄(i)、出贝塞尔手柄(o),实现形状之间的平滑变形。这是 Lottie 最强大的动画能力之一,液体变形、图标切换等效果都靠它实现。圆角矩形(Rectangle):支持尺寸和圆角半径的动画,圆角从 0 变到正值可以实现从方到圆的过渡。椭圆(Ellipse):支持尺寸动画,可以实现圆形呼吸、椭圆拉伸等效果。星形和多边形(Polystar):支持点数、外径、内径等属性的动画,可以实现星形旋转展开等效果。修剪路径(Trim Path):这是经常被忽略但非常实用的功能——可以控制路径的起止百分比,实现描边绘制、进度条、环形加载等效果。填充与描边动画颜色是 Lottie 动画的重要组成部分:填充颜色(Fill):对形状的内部颜色做动画,支持纯色和关键帧颜色过渡。比如按钮从灰色变绿色表示启用状态。描边颜色(Stroke):对形状的边框颜色做动画,同时支持描边宽度的动画。渐变填充(Gradient Fill):支持线性和径向渐变,可以动画化渐变的色标位置和颜色值,实现流光、极光等视觉效果。渐变描边(Gradient Stroke):与渐变填充类似,应用于描边。不透明度动画:填充和描边各自支持独立的不透明度属性,可以实现颜色的淡入淡出。遮罩与蒙版遮罩是 Lottie 中实现复杂视觉效果的常用手段:遮罩路径(Mask Path):用一条路径定义可见区域,支持 Add、Subtract、Intersect、Difference 等多种混合模式。通过动画化遮罩路径,可以实现擦除、揭幕、窗口移动等效果。遮罩不透明度(Mask Opacity):控制遮罩本身的透明度,实现渐变遮罩效果。遮罩扩展(Mask Expansion):扩展或收缩遮罩的边缘,常配合其他属性做平滑过渡。注意:Lottie 支持 Alpha 遮罩和 Alpha 反转遮罩,但不支持 Luma 遮罩。轨道遮罩(Track Matte)轨道遮罩是将一个图层的 Alpha 通道作为另一个图层的遮罩:Alpha Matte:用上方图层的 Alpha 通道遮罩下方图层,实现文字内嵌图片、文字揭示动画等效果。Alpha Inverted Matte:反转 Alpha 通道的遮罩效果。轨道遮罩在文字动画和图标动画中应用非常广泛,主流平台均支持,但部分浏览器在 Canvas 渲染模式下可能有兼容性问题。父子层级(Parenting)Lottie 支持图层之间的父子关系——子图层继承父图层的所有变换属性。这意味着:移动父图层时,子图层会跟随移动。旋转父图层时,子图层会围绕父图层的锚点旋转。一个图层只能有一个父图层,但一个父图层可以有多个子图层。父子层级是构建复杂动画结构的基础,比如人物骨骼动画、机械联动效果都依赖它。预合成(Precomposition)预合成相当于"动画中的动画"——将多个图层打包成一个独立的合成,然后在主合成中作为一个图层使用。Lottie 完整支持预合成,这意味着:可以将复杂的动画逻辑封装在预合成中,保持主时间轴清晰。预合成可以嵌套使用,支持多层级的合成结构。预合成内部的时间线独立于主时间线,可以通过时间重映射控制播放。文本动画Lottie 对文本的支持有一定限制,但基本功能可用:文本内容:支持静态文本显示,包括字体、字号、对齐方式、行高、字间距等属性。文本动画:可以通过逐字(Per-character)动画实现打字机效果、文字逐字飞入等。文本颜色:支持填充颜色的动画。需要注意:Lottie 对文本动画的支持不如 After Effects 原生丰富,复杂的文本动画器(如 Range Selector)支持有限。中文字体需要确保客户端已安装对应字体,或使用图片替代方案。3D 变换Lottie 支持基本的 3D 变换属性:X 轴旋转(Rotation X):绕 X 轴旋转,实现前后翻转效果。Y 轴旋转(Rotation Y):绕 Y 轴旋转,实现左右翻转效果。Z 轴旋转(Rotation Z):即普通 2D 旋转。3D 位置:支持 Z 轴位置属性。但要注意,Lottie 的 3D 是伪 3D——它不包含真正的 3D 渲染管线,没有光影、透视网格等效果。3D 变换本质上是对 2D 图层做仿射变换的模拟。时间重映射(Time Remapping)时间重映射允许你对预合成的播放时间做动画控制:可以加速、减速、倒放预合成内的动画。可以实现动画暂停后再继续播放的效果。配合表达式可以实现循环播放(但表达式支持有限,建议用关键帧实现)。这是制作交互式动画的关键功能——比如用户点击时,动画从特定帧开始播放。缓动函数(Easing)Lottie 完整支持 After Effects 的关键帧缓动:贝塞尔缓动:通过入贝塞尔手柄(i)和出贝塞尔手柄(o)定义自定义缓动曲线,对应 CSS 的 cubic-bezier()。线性(Linear):匀速变化。定格(Hold):关键帧之间不做插值,直接跳变,实现逐帧动画效果。空间贝塞尔(Spatial Bezier):控制位置关键帧在空间中的运动路径曲线。缓动函数对动画的质感影响极大——同样的关键帧,不同的缓动曲线会产生完全不同的视觉感受。Lottie 不支持的重要特性了解不支持的特性同样重要,避免在设计阶段做无用功:After Effects 表达式(Expressions):Lottie 不支持 AE 表达式。wiggle()、loopOut() 等表达式在导出后不会生效,必须转化为关键帧。效果菜单中的滤镜:高斯模糊、阴影、发光等 AE 内置效果不支持。如需模糊效果,可在设计时直接制作模糊状态的图层。混合模式(Blend Modes):除正常模式外,叠加、正片叠底等混合模式不支持。Luma 遮罩:仅支持 Alpha 遮罩,不支持基于亮度的遮罩。3D 图层和摄像机:支持 3D 变换属性,但不支持 3D 图层、摄像机和灯光。视频图层和音频图层:Lottie 只处理矢量动画,不支持嵌入视频和音频。部分文本动画器:Range Selector 等高级文本动画功能支持有限。dotLottie 新特性(2025-2026)Lottie 生态正在快速发展,dotLottie 格式带来了新能力:状态机(State Machines):在动画文件中定义交互逻辑,无需编写代码即可实现点击切换、悬停响应等交互。支持 Web、iOS、Android 原生 SDK。主题变量(Theming):通过变量替换动画中的颜色和文本,同一动画可适配不同品牌主题。多动画打包:一个 dotLottie 文件可包含多个动画,减少网络请求。AI 动画生成:Lottie Creator 已集成 AI 工具,可直接生成 Lottie 动画。实际开发建议在实际项目中使用 Lottie 动画,有几个关键点值得注意:渲染器选择:Web 端 lottie-web 提供三种渲染器——SVG 兼容性最好,Canvas 性能更优,HTML 适合简单动画。复杂动画建议优先 SVG,动画元素超过 100 个时考虑 Canvas。性能优化:避免单个动画文件过大,超过 100KB 的 JSON 文件应考虑拆分。减少图层数量和关键帧密度,善用预合成复用动画。设计协作:设计师应在 AE 中全程使用 Lottie 支持的特性,用 Bodymovin 插件边做边预览,避免最终导出时才发现特性不支持。LottieFiles 提供的在线预览工具可以快速验证动画兼容性。交互控制:通过 lottie-web 的 API 可以控制动画的播放、暂停、跳帧、速度和方向。结合 State Machines 可以实现更复杂的交互逻辑,减少前端编码量。Lottie 的核心价值在于让设计师和开发者各司其职——设计师在 AE 中创作动画,开发者只需加载 JSON 文件即可还原,零偏差的动画交付大幅提升了协作效率。掌握 Lottie 支持和不支持的特性清单,才能在设计和开发之间建立清晰的契约。
前端阅读 05月27日 18:25

Lottie 动画开发常见问题有哪些?

Lottie 动画看着简单,实际开发中踩坑不少——动画白屏、列表卡顿、iOS 上好好的到 Android 就变样、颜色怎么都改不对。下面按实际遇到频率从高到低,把每个问题的根因和解决办法讲清楚。动画不显示或渲染异常白屏是最常见的问题,三个原因挨个排查:JSON 文件问题:路径写错或文件损坏。打开浏览器 Network 面板看请求状态码,200 说明文件拿到了,4xx 就是路径问题容器没设尺寸:Lottie 需要一个有明确宽高的 DOM 容器,width: 0 的 div 不会报错但什么都看不到AE 不支持的特性:3D 图层、合并路径、部分表达式(wiggle 最典型)导出后会丢失或异常。用 bodymovin 插件的预览功能逐段检查,wiggle 表达式必须烘焙成关键帧再导出animation.addEventListener('data_failed', () => { container.innerHTML = '<img src="fallback.png" alt="fallback">';});加载失败必须有降级,留白是最差的体验。性能卡顿与内存泄漏SVG 渲染画质好但帧率不稳,Canvas 渲染器在移动端和列表场景下表现更稳定。关键数据:带 mask 或 matte 的动画会额外创建 2-3 个 bitmap,放在 RecyclerView 里会直接触发内存抖动——列表场景要么去掉遮罩,要么别用 Lottie。lottie.loadAnimation({ renderer: 'canvas', rendererSettings: { clearCanvas: false, progressiveLoad: true }});离屏动画必须暂停。用 IntersectionObserver 做可见性控制,不可见时 pause(),回到视口再 play()。低端设备直接降级成静态图,比卡顿强一百倍。内存泄漏是另一个高频坑:组件卸载时没调 destroy()、事件监听没移除,这两个都做了才不会泄漏。useEffect(() => { const anim = lottie.loadAnimation({ container: ref.current, renderer: 'svg', loop: true, autoplay: true, path: 'anim.json' }); return () => { anim.destroy(); anim = null; };}, []);跨平台渲染不一致同一份 JSON 在 Web/iOS/Android 上效果可能不同。常见差异:| 问题 | 原因 | 解决 ||------|------|------|| 渐变填充丢失 | 中文 AE 环境下字段名不匹配 | 切英文语言环境导出 || 字体渲染差异 | 各端字体引擎不同 | 转轮廓后导出 || 缓动曲线偏差 | 浮点精度实现不同 | 简化缓动,避免极值 |设计阶段就对照 Lottie 官方 Supported Features 列表确认,比开发完再返工成本低得多。iOS 要求 9.0+,Android 需要开启 vectorDrawables.useSupportLibrary 并用 lottie-android 6.0.0 以上版本。资源加载优化与动态颜色大体积 JSON 加载慢的解决方案:LottieFiles 在线优化器压缩文件、CDN 分发、Service Worker 缓存二次加载。小程序里全屏动画模糊的问题,需要按设备像素比缩放 canvas 尺寸。动态改颜色用 setColorFilter,按图层 keypath 指定替换,比直接改 JSON 数据靠谱。颜色格式必须是 RGBA 数组(0-1 范围),十六进制不行。animation.setColorFilter([{ keypath: 'icon_layer', color: 'rgba(255,80,0,1)' }]);// 十六进制转 Lottie 颜色数组function hexToLottieColor(hex) { return [parseInt(hex.slice(1,3),16)/255, parseInt(hex.slice(3,5),16)/255, parseInt(hex.slice(5,7),16)/255, 1];}循环播放别只设 loop: true,用 loopComplete 事件控制次数、complete 事件手动重播,灵活得多。路径动画闪烁的问题,确保每个关键帧的锚点数量和走向一致就行。
前端阅读 05月27日 18:25

Lottie 动画库的工作原理是什么?

Lottie 是 Airbnb 开源的动画渲染库,核心思路是把 After Effects 动画导出为 JSON,然后在客户端用原生绘图 API 实时渲染——不走 GIF、不走视频,走的是矢量绘制。设计师在 AE 里做好动画,装一个 Bodymovin 插件点导出,拿到的 JSON 文件直接丢进项目,几行代码就能播动画。JSON 里存的是图层数据、关键帧、路径信息,Lottie 库负责解析这些数据,在 iOS 上用 Core Animation 渲染,Android 上用 Canvas,Web 上用 SVG 或 Canvas。所以同一份动画文件,三端表现一致。具体来说,渲染过程分三步:先解析 JSON 构建数据模型(Android 上叫 LottieComposition),然后根据当前播放时间用插值算法算出每一帧各属性的中间值,最后把图层按顺序叠加绘制到画布上——类似 PS 的图层叠加原理,底层依赖 Canvas 的 save/restore 机制。相比 GIF 和视频,Lottie 的优势很明显:文件体积小得多(一个复杂动画可能就几 KB 的 JSON,而同等效果的 GIF 通常要几百 KB),矢量绘制支持无限缩放不失真,而且可以通过代码控制播放进度、速度、暂停,甚至动态修改颜色和文字内容——这些 GIF 做不到。但 Lottie 不是万能的。它只支持 AE 中的一部分特性,像粒子效果、3D 摄像机、某些混合模式就不支持。动画越复杂,JSON 文件越大,低端设备上解析也会变慢。另外 Lottie 的版本兼容性也是个坑——不同平台的 Lottie 库版本对 JSON 特性的支持程度不一样,设计师在 AE 里用了一个遮罩效果,iOS 能正常渲染,Android 可能就显示异常。追问Lottie 和 GIF/MP4 动画有什么区别?| 对比项 | Lottie | GIF | MP4 ||--------|--------|-----|-----|| 文件体积 | 极小(KB 级) | 大(百 KB~MB) | 较大 || 缩放 | 矢量,无损 | 位图,模糊 | 位图,模糊 || 交互控制 | 代码控制播放/暂停/速度 | 仅播放 | 播放/暂停 || 动态修改 | 支持改颜色/文字 | 不支持 | 不支持 || 透明背景 | 原生支持 | 需要处理 | 不方便 || 渲染方式 | 原生矢量绘制 | 位图逐帧 | 硬件解码 |Bodymovin 导出的 JSON 文件结构是什么样的?大致分三层:最外层是动画元信息(版本号、宽高、帧率、inPoint/outPoint 标记起止帧),中间是图层列表(每个图层对应 AE 中的一个图层,包含变换属性、遮罩、效果等),最内层是关键帧数据(每个可动画属性在不同时间点的值,用贝塞尔曲线描述缓动)。渲染时 Lottie 从外到内逐层解析,用插值算法算出当前帧的属性值,再绘制到画布上。实际项目里用过吗?遇到过什么坑?踩过两个典型的坑。一是跨平台渲染不一致——设计稿在 LottieFiles 预览看着正常,跑在低端 Android 机上遮罩就出问题了,后来发现是那个 Android Lottie 版本不支持该遮罩类型,降级 AE 效果才解决。二是 JSON 文件体积——有个动画导出来 200KB+,首屏加载就卡了一下,后来用 Lottie 的缓存策略预加载才缓解。所以实际项目中一定要在目标设备上测试渲染效果,别只在预览页看。Lottie 的动态属性修改怎么用?Lottie 支持在运行时修改动画中的颜色、文字、图片等属性,这个功能叫 Dynamic Properties。Android 上通过 addValueCallback 指定某个图层的某个属性在每一帧的值,iOS 上用 LOTValueDelegate。常见场景是换肤——同一套动画,深色模式下调色板一换就行,不用导出两份 JSON。但要注意,动态修改只对 Lottie 支持的属性类型有效,自定义效果和表达式是改不了的。Lottie 性能优化有哪些手段?几个实用的:开启硬件加速(Android 上 setLayerType)、用 LottieComposition 缓存避免重复解析、控制帧率(没必要 60fps 的动画降到 30fps 能省一半开销)、预加载关键动画。另外 Lottie 现在也支持 dotLottie 格式,是 JSON 的 zip 压缩版本,体积更小加载更快。如果动画包含图片资源,dotLottie 还能把图片一起打包,避免资源管理混乱。
前端阅读 05月27日 18:23

Lottie 动画与其他动画技术相比有哪些区别和优势?

为什么前端团队越来越倾向用 Lottie?先说一个现实问题:一个 3 秒的加载动画,GIF 做出来可能 2MB,PNG 序列帧可能 5MB,而 Lottie 的 JSON 文件往往只要 20KB。这不是压缩算法的魔法,而是根本性的技术路线差异——Lottie 存储的是动画指令而非像素帧。Lottie 由 Airbnb 开源,它的核心思路是:设计师在 After Effects 中完成动画创作,通过 Bodymovin 插件导出为 JSON,客户端用 Lottie 库解析 JSON 并实时渲染。这意味着动画从"图片播放"变成了"代码执行",带来了后续一系列优势。Lottie 与 GIF:代际差距GIF 是 1987 年诞生的格式,它的动画原理就是快速翻页——把一帧帧位图拼在一起循环播放。这决定了它的所有短板:体积大:一个 3 秒循环的 loading 动画,GIF 通常在 500KB~2MB 之间,同样效果 Lottie 只要 10~50KB画质差:GIF 只支持 256 色,透明通道只有 1 位(要么全透明要么不透明),边缘永远有锯齿不可控:没法暂停、没法调速、没法倒放,只能傻循环Lottie 是矢量渲染,无限缩放不失真,支持完整的 Alpha 通道。文件存储的是路径和关键帧数据,不是像素。唯一 GIF 还有存在价值的场景是表情包和邮件内嵌——因为 Lottie 需要 JavaScript 运行时,邮件客户端不支持。Lottie 与 PNG 序列帧:内存杀手PNG 序列帧是游戏开发中的传统方案:把每一帧导出为一张 PNG,然后按时间线依次切换。效果可控,但代价极高:一个 60 帧、分辨率为 1080p 的动画,序列帧总大小轻松超过 10MB所有帧图片需要一次性加载到内存,内存峰值极高,移动端尤其敏感缩放失真,适配多分辨率需要导出多套素材Lottie 只需要一份 JSON 文件,渲染时实时计算矢量路径,内存占用是序列帧方案的零头。如果动画需要适配不同分辨率,Lottie 天然支持,序列帧则需要 1x/2x/3x 三套资源。Lottie 与视频(MP4/WebM):交互性的分水岭视频在"展示型"场景下表现不错——流式加载、硬件解码、画质可以很高。但视频和 Lottie 的本质区别在于:视频是预渲染的:播放什么就是什么,运行时无法改颜色、改文字、改任何元素控制能力有限:虽然能暂停和 seek,但没法动态响应业务逻辑透明通道支持差:WebM 虽然支持透明,但兼容性堪忧;MP4 不支持透明实际案例:一个活动的 loading 动画需要根据不同城市显示不同文案。用视频得为每个城市导出一个文件,用 Lottie 只需运行时替换文本图层即可。这就是声明式动画和预渲染视频的核心差距。Lottie 与 CSS 动画:分工不同CSS 动画做简单交互——hover 渐变、弹跳、淡入淡出——非常顺手,性能也极好(只操作 transform 和 opacity 时走 GPU 合成层)。但它的上限很明显:复杂路径动画写不动:贝塞尔曲线变形、形状补间、粒子效果,CSS 基本无能为力跨端一致性差:同样的 animation,Chrome 和 Safari 的渲染细节可能有差异设计师无法直接参与:动画参数全靠开发者手写,和设计稿之间有二次转译损耗Lottie 把动画的创作权还给设计师,开发者只需要 lottie.loadAnimation() 一行调用。两者不是替代关系,而是分工:CSS 处理 UI 微交互,Lottie 处理视觉级动画。Lottie 与 Canvas / SVG 动画:维护成本的天平Canvas 动画和 SVG 动画都能实现高复杂度的效果,但开发成本完全不同:Canvas:需要手写 requestAnimationFrame 循环,管理渲染管线,手动处理 DPI 适配和性能优化。几秒的动画可能需要数百行代码,后期维护是噩梦SVG 动画:SMIL 或 CSS 驱动,Web 端可用,但跨平台支持差(Android 对 SVG 动画支持有限),复杂动画的 SVG 代码可读性极低Lottie 在 Web 端底层可以选择 SVG 或 Canvas 渲染器,但上层 API 完全一致。开发者不需要关心渲染细节,只管加载 JSON。维护成本从"维护动画代码"降级为"替换一个 JSON 文件"。Lottie 与原生动画:性能的天花板iOS 的 Core Animation 和 Android 的 Animator 是各自平台上的性能天花板——直接操作图层的 GPU 渲染管线,没有中间层。Lottie 在渲染性能上确实不如原生动画:Lottie 的 JSON 解析和图层树构建有额外开销复杂动画帧率可能比原生低 5~10fps大型动画(数百个图层)在低端机上可能出现掉帧但原生动画的开发成本极高:一个复杂动画需要分别给 iOS 和 Android 写两套实现,设计师的效果只能靠开发者手动还原。Lottie 牺牲一点极限性能,换来了跨平台一致性和 10 倍的开发效率提升。在绝大多数业务场景下,这点性能差距用户根本感知不到。Lottie 与 Three.js / WebGL:不同维度Three.js 和 WebGL 做 3D 动画和复杂视觉特效,这是 Lottie 完全不涉及的领域。Lottie 专注 2D 矢量动画,Three.js/WebGL 专注 3D 和 GPU 着色器效果。两者不存在选择困难,需求类型天然不同。Lottie 与 FLIP 动画:不同用途FLIP(First-Last-Invert-Play)是一种布局过渡动画技巧,用于实现元素位置变化的丝滑过渡——比如列表重排、卡片展开。它和 Lottie 解决的是完全不同的问题:Lottie 是预定义的独立动画,FLIP 是运行时计算的过渡动画。两者经常配合使用。怎么选?一张表说清楚| 技术 | 文件体积 | 渲染质量 | 交互能力 | 跨平台 | 开发效率 | 典型场景 ||------|---------|---------|---------|--------|---------|---------|| Lottie | 极小 | 矢量无损 | 强 | 好 | 高 | 品牌动画、icon 动效、loading || GIF | 大 | 位图有损 | 无 | 好 | 低 | 表情包、邮件内嵌 || PNG 序列帧 | 最大 | 位图 | 弱 | 好 | 低 | 游戏帧动画 || 视频 | 中 | 高保真 | 弱 | 好 | 中 | 产品演示、引导视频 || CSS 动画 | 代码量 | 好 | 中 | 一般 | 高 | UI 微交互、hover 效果 || Canvas 动画 | 代码量 | 位图 | 强 | 好 | 低 | 数据可视化、游戏 || SVG 动画 | 较小 | 矢量无损 | 中 | 差 | 中 | Web 端简单动画 || 原生动画 | 代码量 | 最优 | 强 | 差 | 低 | 性能敏感的核心交互动画 |选择的核心逻辑:如果动画是设计师创作的视觉内容(icon 动效、品牌动画、loading),选 Lottie;如果是开发者实现的交互反馈(hover、过渡),选 CSS;如果是 3D 或着色器特效,选 Three.js/WebGL;如果追求极限性能,选原生动画。其他技术基本只在特定场景下作为补充方案。
前端阅读 05月27日 18:22

Lottie 动画相比 GIF 和视频有哪些性能优势?

Lottie 为什么比 GIF 和视频更轻量?Lottie 动画基于矢量描述——本质是一份 JSON 文件,记录了路径、关键帧和图层信息,而不是逐帧存储像素。一个 5 秒的加载动画,Lottie 文件通常在 10-50 KB,而同样效果的 GIF 轻松超过 500 KB,视频编码后也在 200 KB 以上。这种差距的根本原因在于信息表达方式不同:Lottie 只描述"怎么画",GIF 和视频则记录"每帧长什么样"。实际项目中,Lottie 的体积优势在复杂动画上更明显。Airbnb 开源 Lottie 时公布的案例显示,一个 7 秒的品牌动画导出为 GIF 约 1.2 MB,而 Lottie JSON 仅 28 KB,压缩比超过 40 倍。对于移动端来说,这意味着更小的安装包和更快的资源下载。渲染性能差异从何而来?Lottie 使用平台的原生绘图 API 渲染:iOS 上走 Core Animation,Android 上走 Canvas,Web 上走 SVG 或 Canvas。这意味着动画帧由 GPU 直接绘制,和系统 UI 共享硬件加速通道。GIF 则完全不同。GIF 需要先解码每一帧的像素数据,再提交给显示系统。解码过程消耗 CPU,且 GIF 格式本身不支持硬件加速。在低端 Android 设备上,同时播放两三个 GIF 就能明显感受到帧率下降。PNG 序列帧的渲染路径和 GIF 类似,但内存压力更大——每帧都是一张完整图片,30 帧 1080p 的动画就需要同时持有 30 张位图。视频虽然依赖硬件解码器,但解码后的帧缓冲同样占用内存,而且视频解码的初始化延迟比 Lottie 高出几个数量级。内存占用:为什么 Lottie 更省?内存占用的差异源于数据存储方式。Lottie 在内存中只保存动画的描述数据(路径节点、关键帧参数),渲染时按需实时计算当前帧的画面。一个典型 Lottie 动画的运行时内存开销在 1-5 MB。GIF 和 PNG 序列帧则需要预先解码并缓存帧数据。一张 1080×1920 的 RGBA 位图占约 8 MB 内存,30 帧动画如果全量缓存就需要 240 MB。实际播放器通常会做帧缓存优化,但即使只缓存 3-5 帧,内存占用也远高于 Lottie。视频的内存模型介于两者之间,硬件解码器会维护自己的帧缓冲区,通常占用 10-30 MB,但加上解码器本身的上下文开销,整体并不比 Lottie 优越。交互控制能力对比这是 Lottie 区别于其他方案的关键优势之一。Lottie 提供了完整的播放控制 API:播放控制:play、pause、resume、stop进度控制:setProgress(0.0~1.0),可以精确跳转到任意帧速度调节:setSpeed(),支持倍速和反向播放循环模式:支持单次、循环、往返等模式事件监听:可以监听动画开始、结束、取消、重复等事件GIF 只能循环播放,无法暂停、调速或跳帧。PNG 序列帧需要自己实现帧管理器才能获得类似控制能力。视频虽然有基本播放控制,但进度跳转不够精确,且无法在运行时修改动画内容。Lottie 还支持运行时动态修改动画属性——改变颜色、替换文字、隐藏图层,这些是 GIF 和视频完全做不到的。比如一个加载动画,可以根据主题色动态切换旋转圆环的颜色,而不需要为每种主题准备一份动画文件。响应式与分辨率适配Lottie 动画是矢量图形,任意缩放都不失真。同一份 JSON 文件,在 320px 宽的手机和 2560px 的桌面显示器上都能清晰显示,不需要准备 @2x、@3x 等多套资源。GIF、PNG 序列帧和视频都是位图格式,在高分辨率屏幕上放大就会模糊。要适配不同 DPI,就得导出多个版本,进一步增加包体积。对于需要全屏展示的动画效果,这个矛盾尤其突出。加载速度的实际差距Lottie 的加载流程是:下载 JSON → 解析 → 渲染。JSON 文件小,下载快;解析是轻量的文本操作;渲染走原生 API,首帧出现很快。在实际项目中,一个 50 KB 的 Lottie 动画从发起请求到首帧展示,通常在 100-200 ms 内完成(本地缓存场景下更快)。GIF 需要下载完整文件后才能开始解码播放,且文件体积大导致下载时间长。PNG 序列帧更慢,需要下载每一帧图片。视频虽然支持流媒体加载,但解码器初始化本身就有 200-500 ms 的冷启动延迟,不适合做轻量级的 UI 动画。性能优化的实战建议在实际项目中使用 Lottie,有几个优化点值得关注:缓存策略:对已加载的 LottieComposition 做内存缓存,避免重复解析同一份 JSON。Android 上 Lottie 默认提供了 LottieCache,iOS 上可以自己用 NSCache 实现。列表场景控制:在 RecyclerView 或 UITableView 中,务必在视图回收时暂停动画、复用时重新播放。同时在列表中避免同时播放超过 3 个 Lottie 动画,否则低端设备的帧率会明显下降。动画设计约束:和设计师协商,控制遮罩(Mask)和蒙版(Matte)的使用数量——这两个特性在 Lottie 渲染时需要额外的离屏绘制 pass,是性能瓶颈的常见来源。一个动画中超过 3 个遮罩层就要考虑简化。按需加载:对于非首屏的动画,使用 Lottie 的 lazy loading 特性,等视图可见时再触发加载,而不是页面初始化时全部加载。硬件加速:确保动画所在的 View 开启了硬件加速。在 Android 上,可以在 View 层级通过 setLayerType 确认;在 Web 上,确保 SVG 渲染模式没有被强制降级。Lottie 在 UI 动画场景下的性能优势是明确的:更小的文件体积、更低的内存占用、更快的加载速度和更灵活的交互控制。但也要注意它不是万能的——对于复杂的粒子效果、3D 变换或摄影级画面,视频仍然是更合适的选择。选型时根据具体场景权衡,才能发挥各方案的最大价值。
前端阅读 05月27日 18:16

MobX 性能优化的最佳实践有哪些?

MobX 本身已经做了大量性能优化——细粒度依赖追踪、自动批处理、computed 缓存,大多数场景下开箱即用就够了。真正需要手动优化的,集中在三件事上:computed 被滥用或用错了、observable 追踪了不该追踪的东西、组件粒度太粗导致重渲染范围过大。核心思路:减少追踪范围(只让真正会变的状态变 observable)、减少计算次数(用 computed 缓存派生值)、减少渲染范围(拆小组件、延迟间接引用)。追问computed 和普通 getter 有什么区别?什么时候该用 computed?computed 会缓存结果,只在依赖的 observable 变化时重新计算;普通 getter 每次访问都执行。当你需要从 observable 数据派生新值时用 computed——过滤列表、拼接字符串、计算汇总。一个值如果会被多处读取,computed 的缓存收益更大。注意:computed 里不能有副作用(发请求、改状态),它必须是纯函数,否则缓存一致性无法保证。observable 的深度怎么选?什么时候用 shallow?默认 observable 会递归把对象所有层级都变成响应式,适合嵌套深、内部属性需要单独追踪的场景。observable.shallowObject 只让第一层变成响应式,内部对象保持原样。实际项目中,列表数据用 shallow 就够了——你通常关心的是"列表变了"而不是"某个用户的名字变了"。只有确实需要追踪深层属性变化时才用深度 observable。对于不会变的配置项(API 地址、超时时间),压根不要加 observable,纯常量没必要追踪。action 里还需要包 runInAction 吗?不需要。action 本身就会批量处理里面的状态变更,在 action 内再套 runInAction 是多余的。runInAction 的真正用途是异步回调中修改状态——await 之后的赋值已经不在 action 作用域内,必须用 runInAction 包起来。@actionasync fetchData() { this.loading = true; const data = await api.getData(); // 这里已经不在 action 作用域了 runInAction(() => { this.data = data; this.loading = false; });}observer 组件拆多细合适?看组件里读了几种不同的 observable。一个组件同时读 user.name、settings.theme、data.list,任何一个变化都会触发整个组件重渲染。拆成三个小组件,各自只读自己关心的数据,交叉触发就消失了。判断标准:observable 依赖越集中越好。一块 UI 只依赖 store 的一小块数据,就值得单独抽成 observer 组件。如果整个页面只读一个 observable,拆不拆无所谓。另外,不读 observable 的组件(纯展示的 Header、Footer)不要加 observer,加了反而增加追踪开销。autorun、reaction、when 怎么选?autorun:立即执行一次,之后依赖变化就重新执行。适合日志、同步等"每次变了都要做某事"的场景。reaction:只追踪数据表达式,数据变了才执行副作用回调,默认不立即执行。比 autorun 更可控,优先用 reaction。when:条件满足时执行一次就自动销毁。适合"等数据到了再做某事"的一次性逻辑,比在 autorun 里写 if 判断更清晰。三者的返回值都是 dispose 函数,组件卸载时一定要调用,否则内存泄漏。数组操作有什么性能坑?避免整体重新赋值(this.items = [...this.items, item]),MobX 会对整个数组重新建立追踪。用 push、splice 等变异方法直接操作,MobX 只追踪变化的部分。批量替换用 replace(newArray),比重新赋值高效,MobX 内部会做差异更新而不是重建整个 observable 结构。怎么排查 MobX 的性能问题?用 trace() 定位是哪个 computed 或 reaction 导致了多余计算。在组件 render 里调用 trace(true),控制台会输出完整的依赖链和触发原因。用 MobX DevTools 观察每次状态变更触发了哪些 reaction,找到重渲染次数异常的组件。如果某个 computed 计算太频繁,检查它的依赖范围是不是比预期的大——可能是间接引用了一个大对象,MobX 会追踪这个对象上所有被读取的属性。用 computed 预处理数据,把 map/filter 的结果缓存起来,避免在 observer 组件的 render 里直接遍历大列表。写段代码// makeAutoObservable 一键搞定 observable/computed/action 标记class Store { items = []; filter = 'all'; constructor() { makeAutoObservable(this); } get filteredItems() { return this.filter === 'all' ? this.items : this.items.filter(i => i.active); } setFilter(f) { this.filter = f; }}
前端阅读 05月27日 18:16

MobX 组件不更新、异步报错怎么办?常见坑和解决方案

MobX 的响应式机制看起来很简单——observable 包数据、observer 包组件、action 改状态,三件套一配就完事了。但实际项目里踩坑的地方不少:组件明明包了 observer 却不更新、异步操作改了状态没反应、computed 值死活不刷新。这篇文章把最常遇到的坑按出现频率排了一遍,每个坑讲清楚为什么掉进去以及怎么爬出来。组件包了 observer 却不更新这是用 MobX 最容易遇到的问题,没有之一。现象很明确:数据变了,组件纹丝不动。原因通常就那么几个。没有真正访问 observable 属性。 observer 只追踪 render 过程中实际读取的 observable 属性。如果你在 render 外面把值解构出来,MobX 根本不知道这个组件依赖那个属性:// 不会更新——render 里没有直接访问 observableconst { count } = store;return <div>{count}</div>;// 会更新——render 过程中访问了 store.countreturn <div>{store.count}</div>;用了普通对象而非 observable。 这看起来很低级,但在项目里经常出现——某个同事新加了一个对象,忘了用 observable 包一下,组件读它当然不会更新。MobX 6 没开 action 就改状态。 MobX 6 默认 enforceActions: 'observed',意味着所有 observable 状态的修改必须在 action 里进行。在 action 外直接 this.count++ 会报错。如果你为了省事关了这个检查(configure({ enforceActions: 'never' })),表面上看不出问题,但 MobX 内部的批量更新机制会失效,导致多次修改触发多次渲染。class Store { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count++; // makeAutoObservable 自动把方法变成 action }}用 makeAutoObservable 就不用手动标注 action 了,它会自动推断。render 里创建了新的引用类型。 每次 render 都 new 一个对象或数组,即使内容一样引用也不同,React 做 shallow compare 会认为 props 变了,触发不必要的子组件重渲染。更隐蔽的问题是,如果你把这个新对象传给另一个 observer 组件,MobX 会误以为依赖变了。异步操作改了状态不生效@action 只能同步地修改状态。一旦遇到 await,action 的边界就断了——await 之后的代码不在 action 作用域内,MobX 会在控制台疯狂报警告。三种解决方案,推荐程度从高到低:用 flow(最推荐)。 flow 是 MobX 专门为异步设计的,用 generator 函数写,每个 yield 之间的代码自动包裹在 action 里,心智负担最小:class Store { data = null; loading = false; constructor() { makeAutoObservable(this); } fetchData = flow(function* () { this.loading = true; try { const res = yield fetch('/api/data'); this.data = yield res.json(); } catch (e) { console.error(e); } finally { this.loading = false; } });}用 runInAction。 如果不想用 generator,在 await 之后手动把状态修改包进 runInAction:async fetchData() { this.loading = true; try { const res = await fetch('/api/data'); runInAction(() => { this.data = res.json(); }); } finally { runInAction(() => { this.loading = false; }); }}注意 runInAction 需要从 mobx 导入,而且每次修改状态都要包一次,容易漏。用 action 包裹整个 async 函数再配合 runInAction。 这是上面两种的混合,不推荐,代码更啰嗦。computed 值不更新或更新不对computed 有两个大坑:一个是在里面搞副作用,一个是依赖追踪丢了。computed 里搞副作用。 computed 本质是一个缓存计算值,MobX 期望它是纯函数——给同样的输入返回同样的输出,不做任何额外的事。如果你在 computed 里调接口、打日志、改其他状态,MobX 的缓存策略会乱套:// 大错特错get filteredList() { console.log(this.items.length); // 副作用 fetch('/api/track', { body: this.query }); // 副作用 return this.items.filter(i => i.active);}// 正确——纯计算get filteredList() { return this.items.filter(i => i.active);}需要副作用的场景用 autorun 或 reaction,别用 computed。依赖追踪丢了。 MobX 的响应式只在属性被实际读取时才建立追踪。常见写法是解构了 observable 再用,或者条件分支里读了一个属性但返回值没用到:// 不会追踪 this.dataget bad() { const data = this.data; // 读了但没用 return this.items.length;}// 会正确追踪两个依赖get good() { return this.data.length + this.items.length;}内存泄漏:reaction 没清理autorun、reaction、when 这些函数调用后都会返回一个 disposer,组件卸载时必须调用。忘了清理的话,组件都销毁了,reaction 还在跑,轻则内存泄漏,重则操作已卸载组件的 DOM 报错。React 项目里用 useEffect 的 cleanup 来处理:useEffect(() => { const dispose = autorun(() => { console.log(store.count); }); return () => dispose();}, []);when 也要清理——虽然 when 会在条件满足后自动清理,但组件卸载时条件可能还没满足,reaction 还在等。性能问题:渲染太频繁MobX 的 observer 做得已经很精细了——只有组件实际读取的 observable 变了才会重渲染。但还是会遇到性能问题,常见原因:单个组件读太多 observable。 一个大组件读了 store 里的十几个属性,其中任何一个变了都会重渲染整个组件。解法是拆组件——每个小组件只读自己关心的那几个属性:// 一个大组件读了很多数据,任何一个变了都重渲染const Dashboard = observer(() => ( <div> <p>{store.user.name}</p> <p>{store.settings.theme}</p> <p>{store.stats.count}</p> </div>));// 拆成小组件,各管各的const UserName = observer(() => <p>{store.user.name}</p>);const Theme = observer(() => <p>{store.settings.theme}</p>);const Count = observer(() => <p>{store.stats.count}</p>);列表渲染没做细化。 如果列表组件整体用 observer 包裹,修改一条数据的某个字段会导致整个列表重渲染。给每条数据单独包一个 observer 组件,MobX 就能做到只更新变化的那一条。装饰器配置问题装饰器 @observable、@action、@computed 需要 Babel 或 TypeScript 的装饰器插件支持,配置稍微不对就报错。而且 ECMAScript 装饰器提案改了好几版,Babel 的 legacy: true 对应的是旧版提案,和 TypeScript 的 experimentalDecorators 也不是完全一回事。MobX 6 开始官方推荐 makeAutoObservable / makeObservable,不再需要装饰器:class Store { count = 0; list = []; constructor() { makeAutoObservable(this); } // 普通方法自动变成 action increment() { this.count++; } // getter 自动变成 computed get double() { return this.count * 2; }}makeAutoObservable 能推断大多数场景,但有个限制:子类继承时需要手动在子类构造函数里再调一次。makeObservable 更灵活但需要手动标注每个属性。数组操作踩坑MobX 6 默认用 Proxy 实现 observable,数组操作基本和原生数组行为一致,push、splice、map、filter 都能正常触发更新。但有几个细节:直接赋值替换数组。 在 MobX 6 的 Proxy 模式下直接赋值是可以的(this.items = newArray),但如果你在用旧版 MobX 或者关了 Proxy(useProxies: 'never'),需要用 replace():@actionreplaceItems(newItems) { this.items.replace(newItems); // 旧版 MobX 安全写法}传给非 MobX 库时要转原生。 有些第三方库(如 Lodash 的某些方法、antd 的 Table dataSource)对 observable 数组的兼容性不好,传之前用 .slice() 或 toJS() 转成普通数组:import { toJS } from 'mobx';lodashChain(toJS(this.items));Array.isArray 在旧版返回 false。 Proxy 模式下没问题,但旧版 observable 数组不是真数组,Array.isArray(observable([1,2,3])) 返回 false。用 isObservableArray 检测或 .slice() 转换。循环依赖导致无限更新两个 store 的 computed 互相依赖对方的数据,改一个触发另一个重算,另一个重算又触发第一个重算,死循环。MobX 会检测到循环依赖并抛出错误,但有时候循环不是那么明显——比如 A 的 reaction 修改了 B 的数据,B 的 reaction 又修改了 A 的数据,这种间接循环更难定位。解法是理清数据流方向,让依赖关系变成单向的。如果两个 store 确实需要共享数据,抽出一个更高层的 store 来管理共享状态,让两个子 store 都依赖父 store 而不是互相依赖。调试手段MobX 提供了几个调试工具,按实用程度排序:trace():放在 computed 或 observer 的 render 里,控制台会打印这个计算/渲染依赖了哪些 observable,以及是否在某个 observable 变化时重新计算。trace(true) 会在 debugger 断点停下,方便逐步排查。spy():全局监听所有 MobX 事件(action 执行、observable 修改、computed 重算、reaction 触发),适合定位"到底是什么触发了这次渲染":import { spy } from 'mobx';spy((event) => { if (event.type === 'action') { console.log('Action:', event.name); }});getDependencyTree / getObserverTree:程序化地获取依赖关系树,可以判断某个 computed 依赖了哪些 observable,或者某个 observable 被哪些 observer 观察。MobX DevTools:浏览器扩展,可视化展示 observable 树和依赖关系。功能不如 Redux DevTools 丰富,但对于排查响应式链路断裂够用了。TypeScript 类型问题makeObservable 的泛型参数容易写错。MobX 6 要求传入类型参数来推断 this 的类型:class Store { count: number = 0; constructor() { makeObservable<Store>(this, { count: observable, increment: action, }); } increment() { this.count++; }}如果用 makeAutoObservable,大多数情况不需要手动传泛型,TypeScript 能自动推断。但 makeAutoObservable 不支持子类,子类需要在构造函数里手动调 makeObservable 并列出所有需要观测的属性。另一个常见问题是 observable 的类型推断——observable({ list: [] }) 里的 list 会被推断为 never[] 而不是 any[],需要手动标注类型:class Store { list: Item[] = []; constructor() { makeAutoObservable(this); }}
前端阅读 05月27日 18:12

MobX autorun、reaction 和 when 有什么区别?

三个都是 MobX 的 reaction 工具,区别在于追踪粒度和执行策略:autorun 自动追踪所有依赖并立即执行,reaction 手动指定追踪数据并延迟执行,when 只在条件满足时执行一次就自动清理。autorun 最"懒人"——写一个函数,里面用到的 observable 变了它就重跑,创建时还会先跑一次。适合同步状态到 localStorage、更新 document.title 这类"有依赖就响应"的场景。缺点是容易多追踪,函数里不小心读了个不相关的 observable,它也会跟着重跑。reaction 把"追踪什么"和"做什么"拆成了两个函数,第一个函数返回值变了才触发第二个。默认不会立即执行(除非设 fireImmediately: true),而且第二个函数里读的 observable 不会被追踪。适合需要精确控制触发条件的情况,比如只监听 userId 变化去加载用户数据,而不想因为 user 对象其他字段变化而重复请求。when 是一次性的——条件函数返回 true 时执行效果函数,然后自动 dispose。适合等待初始化完成、等待数据加载这类"到了就执行,执行完就拉倒"的逻辑。如果用 autorun 或 reaction 模拟这个行为,你得手动判断条件再 dispose,容易忘。追问reaction 的 fireImmediately 和 autorun 有什么区别?fireImmediately 让 effect 函数在创建时执行一次,但追踪范围仍然是第一个函数指定的,不会追踪 effect 函数里的 observable。autorun 则是把整个函数里的 observable 都追踪了。所以 fireImmediately 只是改了执行时机,没改追踪逻辑。项目里 reaction 忘记 dispose 会怎样?和 useEffect 忘记清理一样——组件卸载后 reaction 还在跑,继续占用内存,observable 变了还会触发回调,可能操作已卸载的组件状态,导致内存泄漏甚至报错。autorun 和 when 同理,都必须在组件卸载时调用返回的 disposer。when 的条件一直不满足怎么办?when 会一直监听,永不执行 effect。可以配合 setTimeout 手动调用 disposer 来设超时,或者用 when 返回的 Promise(MobX 6+)配合 Promise.race 做超时控制:await when(() => store.loaded);// 或者带超时await Promise.race([ when(() => store.loaded), delay(5000).then(() => { throw new Error('timeout') })]);autorun 里访问数组长度和访问数组元素,追踪行为有区别吗?有。store.items.length 只追踪 length,store.items[0] 追踪具体下标,store.items.map(...) 追踪整个数组。用 reaction 可以避免这个问题——在 data 函数里只返回你需要的数据。写段代码// autorun: 自动追踪,立即执行autorun(() => { document.title = `${store.count} items`;});// reaction: 精确追踪,延迟执行reaction( () => store.userId, (id, prevId) => { loadProfile(id); });// when: 条件满足后执行一次when( () => store.initialized, () => { startApp(); });
前端阅读 05月27日 18:11

MobX 中 makeObservable、makeAutoObservable 和装饰器有什么区别?

三者的核心区别在于声明方式:makeObservable 需要显式标注每个成员的类型,makeAutoObservable 自动推断成员类型,装饰器用 @ 语法标记但需要编译器支持。MobX 6 之后官方推荐函数式 API(makeObservable / makeAutoObservable),装饰器变为可选项。传统装饰器(legacy decorators)永远不会成为 JS 标准的一部分,MobX 7 将移除对它们的支持。如果你还在用 @observable 写法,迁移计划该提上日程了。makeObservable:精确控制每个属性class TodoStore { todos = []; loading = false; constructor() { makeObservable(this, { todos: observable.shallow, // 浅层观察,数组引用变才触发 loading: observable, unfinishedCount: computed, addTodo: action, fetchTodos: flow // flow 处理 async/await }); } get unfinishedCount() { return this.todos.filter(t => !t.done).length; } addTodo(text) { this.todos.push({ text, done: false }); } *fetchTodos() { this.loading = true; try { const res = yield fetch("/api/todos"); this.todos = yield res.json(); } finally { this.loading = false; } }}makeObservable 最大的价值是精细控制。observable.shallow 只观察引用变化,数组内部对象的改动不会触发响应——这在列表渲染场景下能避免大量不必要的 re-render。observable.ref 只观察赋值,不做深度转换,适合存不可变数据。flow 专门标注 generator 函数处理异步流程,自动管理 pending/error 状态。缺点也明显:每个属性都要手动标注,漏写一个就丢响应性,而且这类 bug 不会报错,只是默默不更新。makeAutoObservable:自动推断,省心省力class TodoStore { todos = []; loading = false; constructor() { makeAutoObservable(this); } get unfinishedCount() { return this.todos.filter(t => !t.done).length; } addTodo(text) { this.todos.push({ text, done: false }); }}推断规则很直接:字段 → observable,getter → computed,方法 → action。一个 makeAutoObservable(this) 就完事。如果某个成员不想被自动推断,可以覆盖:constructor() { makeAutoObservable(this, { todos: observable.shallow, // 覆盖:用浅层观察 helper: false, // 排除:不使其可观察 fetchTodos: flow // 覆盖:generator 用 flow });}以 _ 开头的属性默认不会被自动推断,这是 MobX 的约定。如果你有内部辅助字段不想暴露为响应式,加个下划线前缀就行。注意:makeAutoObservable 不能用在有超类的类上。子类继承时会报错,因为自动推断无法正确处理继承链上的属性。这种场景必须用 makeObservable。装饰器:语法糖,有前提条件class TodoStore { @observable todos = []; @observable loading = false; @computed get unfinishedCount() { return this.todos.filter(t => !t.done).length; } @action addTodo(text) { this.todos.push({ text, done: false }); }}装饰器写法最直观,属性和类型标注在一起,读起来很清晰。但有两个前提条件经常被忽略:必须配置编译器。TypeScript 需要在 tsconfig.json 中启用 experimentalDecorators,Babel 需要 @babel/plugin-proposal-decorators。没配对就报错,配错了行为也可能不一致。传统装饰器 vs 标准装饰器。MobX 6 同时支持两种,但行为不同。传统装饰器(legacy)用 @observable x = value,标准装饰器(Stage 3)用 @observable accessor x = value。2023 年之后 TC39 确定的标准写法是后者,传统写法已被废弃。另外,用了装饰器不代表可以省掉 makeObservable。MobX 6 中,即使类上写了 @observable,构造函数里还是得调用 makeObservable(this),否则装饰器不生效。这一点很多人踩坑。怎么选?| 场景 | 推荐 | 原因 ||------|------|------|| 新项目,没有装饰器依赖 | makeAutoObservable | 最少代码,自动推断 || 需要浅层观察或排除某些属性 | makeAutoObservable + 覆盖 | 覆盖写法比全手动省事 || 有继承关系的 Store | makeObservable | makeAutoObservable 不支持继承 || 需要 observable.shallow / observable.ref | makeObservable | 精细控制每个属性 || 项目已有装饰器配置,团队习惯 | 装饰器 + makeObservable(this) | 不用为了迁移而迁移 |一句话:默认用 makeAutoObservable,碰到继承或需要精细控制时换 makeObservable,装饰器只在已有项目依赖时继续用。追问makeAutoObservable 和 makeObservable 可以混用吗?不行。一个类里只能选一个。但 makeAutoObservable 的第二个参数本身就是覆盖写法,本质上就是 makeAutoObservable + 部分手动标注的混合体。装饰器写的老项目怎么迁移到 makeAutoObservable?分两步:先把 @observable / @computed / @action 标注转为 makeObservable(this, {...}) 的写法,确认行为一致后,再考虑能否简化为 makeAutoObservable。迁移过程中最容易漏的是 makeObservable(this) 这个调用——老代码用了装饰器但忘记在构造函数里调用它,迁移时同样容易忘。observable.shallow 和 observable 有什么区别?observable 会递归地把对象内部所有嵌套属性都变成可观察的,observable.shallow 只观察第一层引用。对于数组,observable.shallow 只在数组引用变化时触发响应,数组内部元素的属性变化不会触发。列表渲染场景用 observable.shallow 能显著减少不必要的更新。为什么 makeAutoObservable 不支持继承?因为自动推断在遍历 this 上的所有属性时,无法区分哪些是从父类继承的、哪些是子类自己的。父类可能已经对自己的属性做了 makeAutoObservable,子类再调一次就会重复处理。所以 MobX 直接禁止了这种用法,有继承需求的必须用 makeObservable 显式标注。写段代码// 实际项目中常见的模式:// 基类用 makeObservable,子类也用 makeObservable + overrideclass BaseStore { loading = false; constructor() { makeObservable(this, { loading: observable, }); }}class TodoStore extends BaseStore { todos = []; constructor() { super(); makeObservable(this, { loading: override, // 继承的属性用 override todos: observable.shallow, addTodo: action.bound, // 自动绑定 this }); } addTodo(text) { this.todos.push({ text, done: false }); }}
前端阅读 05月27日 18:11

MobX 6 有哪些主要变化和新特性?

MobX 6 最核心的变化有三个:强制 action 修改状态、引入 makeObservable/makeAutoObservable 替代装饰器、Proxy 成为底层实现。装饰器仍然支持但不再是推荐方式,配合 mobx-undecorate 工具可以一键迁移旧代码。追问makeObservable 和 makeAutoObservable 有什么区别?makeObservable 需要你手动标注每个成员的类型(observable、computed、action),适合需要精细控制的场景。makeAutoObservable 自动推断:getter 标记为 computed,方法标记为 action,其余字段标记为 observable。但 makeAutoObservable 不能用于子类,也不能标注被忽略的字段——这种时候用 makeObservable。class Store { count = 0; constructor() { // 二选一 makeObservable(this, { count: observable, doubled: computed, increment: action }); // 或者 makeAutoObservable(this); } get doubled() { return this.count * 2; } increment() { this.count++; }}为什么 MobX 6 强制要求在 action 中修改状态?MobX 5 可以在 action 外部直接修改 observable,这导致状态变更难以追踪,调试时无法定位是哪段代码改了数据。MobX 6 默认 enforceActions: "always",所有状态修改必须在 action 内进行,这样每次状态变更都有明确的调用栈,DevTools 也能清晰展示变更来源。如果迁移时不想立刻改,可以临时配置 enforceActions: "never" 回退到旧行为。装饰器为什么不再是推荐方式?TC39 装饰器提案经历了多次语法变更,Legacy Decorators(Babel experimentalDecorators)一直不是标准。MobX 6 选择拥抱标准:用 makeObservable 在 constructor 中声明式标注成员类型,这在任何 JS 环境下都能运行,不需要 Babel 插件或 TypeScript 实验性配置。如果你仍想用装饰器,MobX 6 也支持,但需要在 constructor 里补一句 makeObservable(this) 才能生效。MobX 5 升级到 6 的迁移步骤是什么?先升级到 MobX 5 的最新小版本,解决所有废弃警告安装 MobX 6,运行 npx mobx-undecorate 自动迁移代码TypeScript 项目设置 useDefineForClassFields: true;Babel 项目设置 ["@babel/plugin-proposal-class-properties", { "loose": false }]每个有 MobX 成员的类,在 constructor 中调用 makeObservable(this) 或 makeAutoObservable(this)用 configure({ enforceActions: "always" }) 启用严格模式替换已移除的 API:decorate() 用 makeObservable 替代,isObservableObject 用 isObservable 替代Proxy 在 MobX 6 中扮演什么角色?MobX 5 默认也用 Proxy,但可以降级到 getter/setter 实现。MobX 6 将 Proxy 作为唯一的响应式实现(IE 和旧版 React Native 除外,需配置 useProxies: "never")。Proxy 的好处是能拦截更多操作(如动态添加属性),Observable 对象的行为更接近普通对象,不需要额外的 API 来处理属性增删。写段代码import { makeAutoObservable } from "mobx";class TodoStore { todos = []; constructor() { makeAutoObservable(this); } get pending() { return this.todos.filter(t => !t.done); } addTodo(text) { this.todos.push({ text, done: false }); } toggle(id) { this.todos[id].done = !this.todos[id].done; }}
前端阅读 05月27日 18:05

OffscreenCanvas 如何在 Web Worker 中进行渲染?

OffscreenCanvas 提供了一个可以脱离屏幕渲染的 Canvas 对象,使得 Canvas 绘图操作能够在 Web Worker 线程中执行,将复杂的图形计算从主线程剥离,避免阻塞用户交互和页面渲染。这个 API 在处理大型动画、图像处理、3D 渲染等场景下能够带来显著的性能提升。为什么需要 OffscreenCanvas浏览器的主线程同时负责 JavaScript 执行、DOM 操作、样式计算、布局和绘制。当 Canvas 上执行复杂渲染时,计算任务会占用主线程时间片,导致页面卡顿、事件响应延迟。OffscreenCanvas 的核心思路是将 Canvas 渲染与 DOM 完全解耦:主线程只负责 DOM 更新,Worker 线程负责 Canvas 绘制,两者并发运行,互不阻塞。具体来说,传统的 Canvas 渲染流水线中,JavaScript 绘制调用和浏览器合成帧是串行执行的;使用 OffscreenCanvas 后,Worker 中的绘制通过 commit() 直接将缓冲区提交给 Display Compositor,跳过了非合成器动画的冗长流水线,走最短渲染路径。核心概念OffscreenCanvas 的两种创建方式方式一:从 DOM Canvas 转移控制权通过 canvas.transferControlToOffscreen() 将页面上已有的 <canvas> 元素的控制权转移为 OffscreenCanvas 对象,然后发送给 Worker。转移后,主线程不能再对该 Canvas 调用 getContext() 等绘制方法。方式二:在 Worker 中直接创建使用 new OffscreenCanvas(width, height) 在 Worker 中直接创建一个独立的 OffscreenCanvas,不与任何 DOM 元素关联。这种方式适用于不需要直接显示、只做离屏计算(如图像处理生成 ImageBitmap)的场景。控制权转移的不可逆性transferControlToOffscreen() 只能对一个 Canvas 元素调用一次。调用后,Canvas 的绘制控制权完全交给 OffscreenCanvas,主线程的 Canvas 上下文失效。如果需要恢复,只能销毁并重新创建 Canvas 元素。支持的渲染上下文OffscreenCanvas 支持以下上下文类型:2d:Canvas 2D 渲染上下文,支持大部分标准 2D APIwebgl / webgl2:WebGL 渲染上下文,支持 3D 渲染bitmaprenderer:ImageBitmap 渲染上下文,用于显示 ImageBitmap需要注意,某些依赖 DOM 的 API 在 Worker 中不可用,如 toDataURL()、toBlob()。替代方案是使用 transferToImageBitmap() 生成 ImageBitmap,再传回主线程处理。基本使用主线程:转移 Canvas 到 Worker// 主线程const canvas = document.getElementById('myCanvas');// 将 Canvas 控制权转移为 OffscreenCanvasconst offscreen = canvas.transferControlToOffscreen();// 创建 Workerconst worker = new Worker('canvas-worker.js');// 通过 Transferable 传输,零拷贝worker.postMessage({ canvas: offscreen }, [offscreen]);postMessage 的第二个参数是 Transferable 列表。OffscreenCanvas 是 Transferable 对象,传输时不进行结构化克隆,而是直接转移所有权,性能开销极低。Worker 线程:接收并绘制// canvas-worker.jslet canvas, ctx;self.onmessage = function(e) { if (e.data.canvas) { canvas = e.data.canvas; ctx = canvas.getContext('2d'); render(); }};function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#4a90d9'; ctx.fillRect(50, 50, 100, 100); requestAnimationFrame(render);}Worker 中的 requestAnimationFrame 与主线程的行为一致,会在每个渲染帧回调绘制函数。三种渲染提交方式OffscreenCanvas 有三种将绘制结果呈现到屏幕的方式,适用场景和性能特征各不相同。方式一:自动提交(push 模式)当 OffscreenCanvas 从 DOM Canvas 通过 transferControlToOffscreen() 创建时,Worker 中每帧绘制完毕后,浏览器会在下一个合成帧自动将内容推送到对应的 DOM Canvas 上显示。这是最简单的使用方式,上面的基本示例就是这种模式。方式二:commit() 手动提交对于 WebGL 上下文,可以调用 gl.commit() 手动将当前帧提交给 Display Compositor。这种方式走最短渲染路径,直接将缓冲区发送给合成器,性能最优。但 commit() 是同步调用,Worker 会阻塞直到帧显示完成。// webgl-worker.jsself.onmessage = function(e) { const canvas = e.data.canvas; const gl = canvas.getContext('webgl'); function render() { gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.clear(gl.COLOR_BUFFER_BIT); // ... 绘制操作 gl.commit(); // 手动提交帧 requestAnimationFrame(render); } render();};方式三:transferToImageBitmap() 零拷贝传输调用 offscreen.transferToImageBitmap() 会将当前 OffscreenCanvas 的绘制内容生成一个 ImageBitmap 对象,同时清空原 Canvas 的缓冲区。ImageBitmap 是 Transferable 对象,可以零拷贝传回主线程,通过 ImageBitmapRenderingContext 显示。// Worker 中const bitmap = offscreen.transferToImageBitmap();self.postMessage({ type: 'frame', bitmap }, [bitmap]);// 主线程中const displayCanvas = document.getElementById('display');const bitmapCtx = displayCanvas.getContext('bitmaprenderer');worker.onmessage = function(e) { if (e.data.type === 'frame') { bitmapCtx.transferFromImageBitmap(e.data.bitmap); }};这种方式的优势在于可以精确控制帧同步时机,确保 Canvas 内容与 DOM 更新同步。但 transferToImageBitmap() 调用后原 Canvas 缓冲区被清空,需要重新绘制才能继续使用。三种方式对比| 维度 | 自动提交 | commit() | transferToImageBitmap() ||------|---------|----------|------------------------|| 同步性 | 异步,与 DOM 更新不同步 | 同步阻塞 Worker | 同步,可精确控制时机 || 性能 | 较好 | 最优,最短渲染路径 | 好,零拷贝传输 || 实现复杂度 | 最低 | 中等 | 较高,需主线程配合 || 适用场景 | 大部分动画场景 | H5 游戏、高性能渲染 | 需要帧同步的场景 |实际应用场景复杂粒子动画粒子动画需要每帧更新大量粒子的位置和绘制,计算密集。将粒子逻辑移到 Worker 后,主线程保持流畅响应。// 主线程const canvas = document.getElementById('canvas');const offscreen = canvas.transferControlToOffscreen();const worker = new Worker('particle-worker.js');worker.postMessage({ canvas: offscreen, width: canvas.width, height: canvas.height}, [offscreen]);// 窗口大小变化时通知 Workerwindow.addEventListener('resize', () => { worker.postMessage({ type: 'resize', width: canvas.width, height: canvas.height });});// particle-worker.jslet canvas, ctx, particles = [];self.onmessage = function(e) { if (e.data.canvas) { canvas = e.data.canvas; ctx = canvas.getContext('2d'); initParticles(e.data.width, e.data.height); render(); } if (e.data.type === 'resize') { canvas.width = e.data.width; canvas.height = e.data.height; initParticles(e.data.width, e.data.height); }};function initParticles(w, h) { particles = []; for (let i = 0; i < 2000; i++) { particles.push({ x: Math.random() * w, y: Math.random() * h, vx: (Math.random() - 0.5) * 2, vy: (Math.random() - 0.5) * 2, size: Math.random() * 3 + 1 }); }}function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 批量绘制:合并为一个路径,一次 fill ctx.beginPath(); for (const p of particles) { p.x += p.vx; p.y += p.vy; if (p.x < 0 || p.x > canvas.width) p.vx *= -1; if (p.y < 0 || p.y > canvas.height) p.vy *= -1; ctx.moveTo(p.x + p.size, p.y); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); } ctx.fillStyle = 'rgba(100, 150, 255, 0.7)'; ctx.fill(); requestAnimationFrame(render);}图像处理图像的像素级操作(灰度化、滤镜、卷积等)是典型的计算密集型任务。在 Worker 中处理可以避免处理期间页面完全冻结。// 主线程const canvas = document.getElementById('canvas');const offscreen = canvas.transferControlToOffscreen();const worker = new Worker('image-worker.js');// 注意:ImageBitmap 是 Transferable,可以传给 Workerasync function processImage(imageUrl) { const response = await fetch(imageUrl); const blob = await response.blob(); const bitmap = await createImageBitmap(blob); worker.postMessage({ canvas: offscreen, bitmap: bitmap, filter: 'grayscale' }, [offscreen, bitmap]);}processImage('/path/to/image.jpg');// image-worker.jsself.onmessage = function(e) { const canvas = e.data.canvas; const ctx = canvas.getContext('2d'); const bitmap = e.data.bitmap; canvas.width = bitmap.width; canvas.height = bitmap.height; ctx.drawImage(bitmap, 0, 0); bitmap.close(); // 释放 ImageBitmap 资源 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; // 灰度化处理 for (let i = 0; i < data.length; i += 4) { const avg = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114; data[i] = avg; // R data[i + 1] = avg; // G data[i + 2] = avg; // B // data[i + 3] 保持不变(Alpha) } ctx.putImageData(imageData, 0, 0);};这里有一个关键细节:原始的 Image 对象不能通过 postMessage 传递给 Worker(它不是 Transferable 也不可结构化克隆)。正确做法是用 createImageBitmap() 将图片转为 ImageBitmap,它是 Transferable 对象,可以零拷贝传输。WebGL 3D 渲染Three.js 等框架在渲染复杂 3D 场景时,可以将整个渲染循环放到 Worker 中,主线程只处理 UI 交互。// 主线程const canvas = document.getElementById('glCanvas');const offscreen = canvas.transferControlToOffscreen();const worker = new Worker('webgl-worker.js');worker.postMessage({ canvas: offscreen }, [offscreen]);// 转发用户交互给 Workercanvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); worker.postMessage({ type: 'mousemove', x: e.clientX - rect.left, y: e.clientY - rect.top });});// webgl-worker.jslet gl, canvas;let mouseX = 0, mouseY = 0;self.onmessage = function(e) { if (e.data.canvas) { canvas = e.data.canvas; gl = canvas.getContext('webgl2'); initScene(); render(); } if (e.data.type === 'mousemove') { mouseX = e.data.x; mouseY = e.data.y; }};function initScene() { // WebGL 初始化:编译着色器、创建缓冲区等 gl.clearColor(0.0, 0.0, 0.0, 1.0);}function render() { gl.clear(gl.COLOR_BUFFER_BIT); // ... 基于 mouseX/mouseY 更新相机或场景 requestAnimationFrame(render);}主线程与 Worker 的通信OffscreenCanvas 本身解决了渲染问题,但交互事件(鼠标、键盘、触摸)仍然只能在主线程捕获。需要通过 postMessage 将事件数据传递给 Worker。事件转发模式// 主线程:转发交互事件canvas.addEventListener('click', (e) => { worker.postMessage({ type: 'click', x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });});// Worker:响应交互self.onmessage = function(e) { if (e.data.type === 'click') { handleClick(e.data.x, e.data.y); }};双向通信:Worker 通知主线程Worker 也可以向主线程发送消息,例如报告渲染状态、返回处理结果。// Workerself.postMessage({ type: 'renderComplete', fps: currentFPS });// 主线程worker.onmessage = function(e) { if (e.data.type === 'renderComplete') { console.log('渲染完成,FPS:', e.data.fps); }};注意事项与常见陷阱Canvas 控制权只能转移一次// 错误:对同一个 Canvas 多次调用const offscreen1 = canvas.transferControlToOffscreen();const offscreen2 = canvas.transferControlToOffscreen(); // 抛出 InvalidStateError// 正确:只调用一次,将 OffscreenCanvas 发给一个 Workerconst offscreen = canvas.transferControlToOffscreen();worker.postMessage({ canvas: offscreen }, [offscreen]);getContext 顺序不可逆在主线程中,transferControlToOffscreen() 必须在 getContext() 之前调用。如果已经获取了上下文,再调用转移方法会抛出异常。// 错误:先获取上下文再转移const ctx = canvas.getContext('2d');const offscreen = canvas.transferControlToOffscreen(); // 抛出异常// 正确:先转移再在 Worker 中获取上下文const offscreen = canvas.transferControlToOffscreen();worker.postMessage({ canvas: offscreen }, [offscreen]);// Worker 中:ctx = canvas.getContext('2d')Worker 中不可用的 APIWorker 没有 DOM 环境,以下 Canvas 相关 API 不可用:toDataURL():无法在 Worker 中序列化为 Data URLtoBlob():无法在 Worker 中生成 BlobcreateImageBitmap(img) 中传入 HTMLImageElement:Worker 中不存在 Image 元素替代方案是使用 transferToImageBitmap() 获取 ImageBitmap,传回主线程后用 canvas.toDataURL() 处理。requestAnimationFrame 的行为差异在 Worker 中,requestAnimationFrame 的回调时机由浏览器的渲染调度决定。当页面处于后台标签页时,回调频率会降低甚至暂停,这与主线程的 requestAnimationFrame 行为一致。如果需要后台持续渲染(如视频处理),应使用 setTimeout 或 setInterval 替代。浏览器兼容性截至当前,OffscreenCanvas 的浏览器支持情况:| 浏览器 | 最低支持版本 ||--------|-------------|| Chrome | 69+ || Edge | 79+ || Firefox | 105+ || Safari | 16.4+ || Opera | 64+ |全局兼容率约 95%,主流浏览器均已支持。Safari 16.4 最初仅支持 2D 上下文,WebGL 支持在后续版本补齐。对于需要兼容旧浏览器的项目,应做特性检测和降级:if (typeof OffscreenCanvas === 'function' && 'transferControlToOffscreen' in HTMLCanvasElement.prototype) { // 使用 OffscreenCanvas const offscreen = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: offscreen }, [offscreen]);} else { // 降级:在主线程渲染 renderOnMainThread(canvas);}性能优化策略批量绘制减少调用次数每次调用 fill()、stroke() 都会触发一次绘制指令提交。将多个图形合并到一个路径中,只调用一次 fill(),可以显著减少 GPU 指令开销。// 低效:每个粒子单独绘制for (const p of particles) { ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();}// 高效:合并为一个路径,一次 fillctx.beginPath();for (const p of particles) { ctx.moveTo(p.x + p.size, p.y); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);}ctx.fill();使用 ImageBitmap 替代 Image 元素createImageBitmap() 返回的 ImageBitmap 对象已解码就绪,绘制时无需再次解码,比 drawImage(img, ...) 更快。且 ImageBitmap 是 Transferable,可以零拷贝跨线程传输。const response = await fetch('texture.png');const blob = await response.blob();const bitmap = await createImageBitmap(blob);// 在 Worker 中直接绘制,无需解码ctx.drawImage(bitmap, 0, 0);// 使用完毕后释放bitmap.close();控制渲染频率并非所有场景都需要 60fps 渲染。对于不需要流畅动画的场景(如图表绘制),可以通过节流降低渲染频率,减少 CPU 和 GPU 开销。const TARGET_FPS = 30;const FRAME_INTERVAL = 1000 / TARGET_FPS;let lastRenderTime = 0;function render(timestamp) { if (timestamp - lastRenderTime >= FRAME_INTERVAL) { // 执行渲染 ctx.clearRect(0, 0, canvas.width, canvas.height); // ... 绘制逻辑 lastRenderTime = timestamp; } requestAnimationFrame(render);}及时释放资源Worker 中的 Canvas 和 ImageBitmap 会占用 GPU 内存。不再使用时需要主动释放:// 释放 ImageBitmapbitmap.close();// Worker 终止时,浏览器会自动回收资源// 但主动清理是好习惯self.close();何时使用 OffscreenCanvasOffscreenCanvas 并非所有场景都适用。以下判断标准可以参考:适合使用的场景:Canvas 动画帧率低于 30fps,且主线程同时需要处理用户交互图像处理耗时超过 16ms(一帧的时间预算)3D 渲染场景复杂,GPU 指令准备时间长页面有多个 Canvas 需要并发渲染不需要使用的场景:简单的静态绘制或低频更新Canvas 操作本身很快(< 5ms),瓶颈不在这里需要频繁调用 toDataURL() 等 Worker 不支持的 API需要兼容不支持 OffscreenCanvas 的旧浏览器且降级成本太高引入 OffscreenCanvas 会增加代码复杂度(Worker 通信、事件转发、调试困难),在性能瓶颈不在 Canvas 时不应盲目使用。
前端阅读 05月27日 18:05

Expo应用中如何管理权限?有哪些最佳实践?

Expo 权限管理机制Expo 应用中,权限管理是调用相机、定位、通知等敏感能力的前提。从 Expo SDK 43 起,统一的 expo-permissions 包已被弃用,改为各模块自带的权限方法。理解这套机制并正确处理权限流转,是避免 App Store 审核被拒、提升用户体验的关键。权限请求的基本流程每个需要权限的 Expo 模块都提供了两个核心方法:getPermissionsAsync() —— 查询当前权限状态,不会弹窗requestPermissionsAsync() —— 向用户请求权限,会弹出系统授权对话框返回值是一个包含以下字段的对象:{ status: 'granted' | 'denied' | 'undetermined' | 'limited', granted: boolean, // status === 'granted' 的快捷判断 canAskAgain: boolean, // 用户拒绝后是否还能再次弹出 expires: 'never' | number}其中 limited 是 iOS 14+ 的"有限访问"状态,用户只授权了部分照片或联系人。常用权限模块及用法相机与麦克风权限import { Camera } from 'expo-camera';// 请求相机权限const { status, granted } = await Camera.requestCameraPermissionsAsync();// 请求麦克风权限(视频录制场景)const { status: audioStatus } = await Camera.requestMicrophonePermissionsAsync();// 仅查询权限状态,不弹窗const { status: currentStatus } = await Camera.getCameraPermissionsAsync();位置权限位置权限区分前台和后台,这是一个容易踩坑的点:import * as Location from 'expo-location';// 前台定位const { status } = await Location.requestForegroundPermissionsAsync();// 后台定位(需要额外配置,审核也更严格)const { status: bgStatus } = await Location.requestBackgroundPermissionsAsync();// 获取当前位置if (status === 'granted') { const location = await Location.getCurrentPositionAsync({});}后台位置权限在 iOS 上需要在 Info.plist 中添加 UIBackgroundModes,且 Apple 审核时会要求你说明为什么前台定位不够用。通知权限import * as Notifications from 'expo-notifications';const { status } = await Notifications.requestPermissionsAsync();// 配置通知的前台展示行为Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false, }),});媒体库权限import * as MediaLibrary from 'expo-media-library';const { status } = await MediaLibrary.requestPermissionsAsync();// Android 13+ 支持细粒度媒体权限const { status: granularStatus } = await MediaLibrary.requestPermissionsAsync({ granularPermissions: true,});// 保存图片到相册const asset = await MediaLibrary.createAssetAsync(localUri);Android 13 引入了 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO 等细粒度权限,替代了旧的 READ_EXTERNAL_STORAGE。通过 granularPermissions 选项可以让 Expo 自动处理这个差异。联系人权限import * as Contacts from 'expo-contacts';const { status } = await Contacts.requestPermissionsAsync();if (status === 'granted') { const { data } = await Contacts.getContactsAsync({ fields: [Contacts.Fields.PhoneNumbers], });}日历权限import * as Calendar from 'expo-calendar';const { status } = await Calendar.requestCalendarPermissionsAsync();if (status === 'granted') { const calendars = await Calendar.getCalendarsAsync(); const eventId = await Calendar.createEventAsync(calendarId, { title: '会议', startDate: new Date(), endDate: new Date(Date.now() + 3600000), });}平台配置权限代码写对了还不够,还需要在配置文件中声明。这一步如果遗漏,iOS 上会直接崩溃,Android 上则权限永远无法授予。iOS 配置在 app.json(或 app.config.js)中通过 infoPlist 声明权限用途描述,这是 App Store 审核的必填项:{ "expo": { "ios": { "infoPlist": { "NSCameraUsageDescription": "需要相机权限来扫描二维码", "NSLocationWhenInUseUsageDescription": "需要位置权限来推荐附近门店", "NSMicrophoneUsageDescription": "需要麦克风权限来录制语音消息" } } }}描述文案应当具体说明用途,写"需要此权限以提供功能"这类泛泛的描述可能导致审核被拒。Android 配置Android 权限通过 permissions 数组声明,同时支持 blockedPermissions 来排除被第三方库自动引入但不使用的权限:{ "expo": { "android": { "permissions": [ "CAMERA", "ACCESS_FINE_LOCATION", "RECORD_AUDIO" ], "blockedPermissions": [ "android.permission.READ_EXTERNAL_STORAGE" ] } }}blockedPermissions 非常实用——某些 Expo 库会自动注入权限声明,如果你不使用相关功能,可以通过这个字段移除它们,避免 Play Store 因"权限与功能不匹配"而拒审。Config Plugins 处理库权限某些 Expo 库通过 Config Plugin 自动注入权限配置,无需手动声明:{ "expo": { "plugins": [ [ "expo-image-picker", { "photosPermission": "需要访问相册以选择图片", "cameraPermission": "需要相机以拍摄照片" } ] ] }}使用 Config Plugin 时,权限描述写在插件配置中而非 infoPlist 里,两者不要重复声明。权限请求的最佳实践按需请求,不要提前索取用户一打开 App 就弹出三四个权限请求,体验非常差。应该在用户真正需要使用某个功能时再请求对应权限:function CameraScreen() { const [hasPermission, setHasPermission] = useState<boolean | null>(null); useEffect(() => { (async () => { const { status } = await Camera.requestCameraPermissionsAsync(); setHasPermission(status === 'granted'); })(); }, []); if (hasPermission === null) { return <Text>正在检查权限...</Text>; } if (hasPermission === false) { return <NoPermissionFallback />; } return <CameraView />;}区分"首次拒绝"和"永久拒绝"这是权限处理中最容易被忽略的逻辑。用户首次拒绝后 canAskAgain 为 true,你还可以再次请求;但如果用户选择了"不再询问",canAskAgain 变为 false,此时只能引导用户去系统设置手动开启:import { Linking, Alert } from 'react-native';async function requestPermissionWithRationale() { const { status, canAskAgain } = await Camera.requestCameraPermissionsAsync(); if (status === 'granted') return true; if (canAskAgain) { // 用户点了"拒绝",但还可以再问 Alert.alert('需要相机权限', '扫码功能需要相机权限才能使用', [ { text: '取消', style: 'cancel' }, { text: '重新授权', onPress: () => requestPermissionWithRationale() }, ]); } else { // 用户选了"不再询问",只能去设置页 Alert.alert('权限被拒绝', '请在系统设置中手动开启相机权限', [ { text: '取消', style: 'cancel' }, { text: '去设置', onPress: () => Linking.openSettings() }, ]); } return false;}Linking.openSettings() 是 React Native 提供的 API,会直接跳转到当前应用的系统设置页,iOS 和 Android 都支持。封装权限 Hook在多个组件中复用权限逻辑时,封装成自定义 Hook 可以避免重复代码:import { useState, useEffect } from 'react';type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'limited';function usePermission( getPermission: () => Promise<{ status: PermissionStatus; canAskAgain: boolean }>) { const [status, setStatus] = useState<PermissionStatus>('undetermined'); const [canAskAgain, setCanAskAgain] = useState(true); useEffect(() => { getPermission().then(({ status, canAskAgain }) => { setStatus(status); setCanAskAgain(canAskAgain); }); }, [getPermission]); return { status, canAskAgain };}// 使用const { status: cameraStatus } = usePermission( () => Camera.getCameraPermissionsAsync());Expo 部分模块也内置了 usePermissions Hook,比如 MediaLibrary.usePermissions(),优先使用官方提供的。平台差异与注意事项iOS权限用途描述(Usage Description)为必填项,缺失会导致崩溃用户可以在"设置"中随时修改权限状态,App 从后台返回前台时应重新检查后台定位、后台音频等权限需要在 UIBackgroundModes 中额外声明某些权限(如通讯录)在 iOS 14+ 支持 limited 状态,需要专门处理Android运行时权限从 Android 6.0 开始,之前的版本在安装时自动授予Android 13+ 对媒体权限做了细分(READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO)可以多次弹出权限请求,但用户体验上应遵循"解释后请求"的原则某些权限(如 SYSTEM_ALERT_WINDOW、WRITE_SETTINGS)需要跳转到特殊设置页,不能通过标准 API 请求通用注意权限声明的变更需要重新构建原生代码,不能通过 OTA 更新生效开发阶段测试权限拒绝场景时,可能需要卸载重装 App 来重置权限状态Expo Go 中部分权限行为与独立构建不同,正式测试应使用 Development Build常见问题排查权限请求没有弹窗可能的原因:配置文件中未声明对应权限(iOS 会崩溃,Android 静默失败),或用户已永久拒绝且 canAskAgain 为 false。检查 app.json 中的权限声明和 canAskAgain 状态。iOS 权限描述审核被拒Apple 要求描述必须具体说明权限用途。不要写"用于提供更好的体验",而要写"用于拍摄头像照片"或"用于扫描二维码"。后台定位权限审核被拒Apple 对后台定位审核很严格。需要在提交时说明为什么前台定位不满足需求,同时在 App 中提供明显的定位使用指示(如蓝色状态栏)。Android 上出现未声明的权限某些第三方库会在 Manifest 中自动合并权限。使用 blockedPermissions 来排除不需要的权限,或者在 android/app/src/main/AndroidManifest.xml 中用 tools:node="remove" 移除。权限状态不一致App 从后台返回时,用户可能已经在系统设置中修改了权限。应在 AppState 的 change 事件中重新检查权限状态:import { AppState } from 'react-native';useEffect(() => { const subscription = AppState.addEventListener('change', (nextState) => { if (nextState === 'active') { // 重新检查权限状态 Camera.getCameraPermissionsAsync().then(({ status }) => { setHasPermission(status === 'granted'); }); } }); return () => subscription.remove();}, []);安全设计原则权限管理不仅是技术问题,也关系到用户信任和应用合规:最小权限原则:只请求功能所必需的权限,不要为了"将来可能用到"而提前索取透明说明:每次请求权限时,向用户解释为什么需要这个权限,特别是首次请求前优雅降级:权限被拒绝时提供替代方案,而不是让功能完全不可用状态同步:监听权限变化,及时更新 UI 状态,避免出现功能已不可用但界面未更新的情况
前端阅读 05月27日 18:04

如何优化Expo应用的性能?有哪些常见的性能问题?

组件渲染优化React Native 中最常见性能问题就是不必要渲染。通过 React.memo、useMemo 和 useCallback 三个核心 API 可以有效控制渲染范围。// React.memo:对 props 做浅比较,避免父组件更新时子组件跟随重渲染const ListItem = React.memo<{ item: Item }>(({ item }) => { return <Text>{item.title}</Text>;});// useMemo:缓存计算结果,避免每次渲染重复执行昂贵运算function SortedList({ items }: { items: Item[] }) { const sorted = useMemo( () => [...items].sort((a, b) => a.priority - b.priority), [items] ); return <FlatList data={sorted} renderItem={({ item }) => <ListItem item={item} />} />;}// useCallback:稳定函数引用,避免因函数重建导致子组件重渲染function Parent() { const [count, setCount] = useState(0); const handlePress = useCallback(() => { setCount((c) => c + 1); }, []); return <Child onPress={handlePress} />;}需要注意:memo 和 useMemo 不是越多越好。对于 props 简单或渲染成本低的组件,浅比较本身的开销可能反而更高。建议先用 React DevTools Profiler 定位瓶颈,再针对性优化。列表渲染优化FlashList 替代 FlatListFlatList 是 React Native 内置的虚拟化列表组件,但在长列表场景下性能不够理想。Shopify 开源的 FlashList 提供了约 10 倍的列表渲染性能提升,已成为 2026 年的推荐选择。import { FlashList } from '@shopify/flash-list';<FlashList data={items} renderItem={({ item }) => <ListItem item={item} />} estimatedItemSize={64} // 必填:提供预估行高,用于滚动条计算 keyExtractor={(item) => item.id}/>如果仍使用 FlatList某些场景下 FlatList 仍有其适用性,关键优化属性如下:<FlatList data={items} renderItem={({ item }) => <ListItem item={item} />} keyExtractor={(item) => item.id} removeClippedSubviews={true} // 移除屏幕外原生视图,降低内存 maxToRenderPerBatch={10} // 每批渲染数量,越小越不容易卡顿 windowSize={10} // 渲染窗口倍数,默认 21 initialNumToRender={10} // 首屏渲染数量 getItemLayout={(data, index) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, })}/>getItemLayout 是最容易被忽略但效果最显著的优化项。当列表项高度固定时,提供该属性可以让 FlatList 跳过异步布局计算,直接定位滚动位置,滚动性能提升明显。启用新架构(New Architecture)新架构是 React Native 近年来最重要的架构升级,包含 Fabric(新渲染系统)、TurboModules(新原生模块系统)和 JSI(JavaScript Interface)三个核心组件。Expo SDK 50+ 已默认支持。性能提升数据:冷启动速度提升约 40%渲染速度提升约 35%内存占用降低约 25%JS 调用原生方法的延迟降低约 40 倍截至 2026 年初,超过 83% 的 EAS Build 项目已使用新架构。// app.json 中启用新架构{ "expo": { "newArchEnabled": true }}迁移前建议使用 npx expo-doctor 检查第三方库兼容性,逐步升级避免一次性大改造带来的风险。Hermes 引擎优化Hermes 是 React Native 0.70+ 的默认 JavaScript 引擎,相比旧版 JSC 有显著性能优势:启动速度提升约 40%(预编译字节码)内存占用降低约 30%包体积减小约 40%// app.json 确认 Hermes 已启用(0.70+ 默认开启){ "expo": { "jsEngine": "hermes" }}Expo SDK 55+ 集成了 Hermes V1,进一步改善了 GC 表现和调试体验。如果项目仍在使用旧版 SDK,升级到 SDK 55+ 是最低成本的启动优化方案。图片优化expo-image 是 Expo 官方推荐的高性能图片组件,内置内存和磁盘缓存、占位符、渐变过渡等功能,相比 React Native 内置 Image 组件优势明显。import { Image } from 'expo-image';<Image source={{ uri: 'https://example.com/photo.webp' }} style={{ width: 200, height: 200 }} cachePolicy="memory-disk" // 内存 + 磁盘二级缓存 contentFit="cover" transition={200} // 淡入动画时长 placeholder={blurhash} // 可选:加载前显示模糊占位/>关键优化点:| 策略 | 效果 | 实施难度 ||------|------|---------|| 使用 WebP 格式 | 带宽减少约 70% | 低 || 启用缓存策略 | 重复加载耗时接近 0 | 低 || 按需加载尺寸 | 避免加载 4K 图显示缩略图 | 低 || 懒加载列表图片 | 减少首屏请求数 | 中 |网络请求优化缓存与去重使用 TanStack Query(原 React Query)可以统一管理请求缓存、去重和状态,减少约 80% 的冗余 API 调用。import { useQuery } from '@tanstack/react-query';function UserProfile({ userId }: { userId: string }) { const { data, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), staleTime: 5 * 60 * 1000, // 5 分钟内视为新鲜数据 gcTime: 10 * 60 * 1000, // 10 分钟后清除缓存(原 cacheTime) }); if (isLoading) return <LoadingSpinner />; if (error) return <ErrorMessage error={error} />; return <Text>{data.name}</Text>;}其他网络优化手段请求批处理:将多个独立请求合并为一个批量接口,减少网络往返响应压缩:服务端启用 Gzip/Brotli,响应体积减少 60%–80%断点续传:大文件下载支持恢复,避免因网络波动从头开始动画优化React Native Reanimated 是高性能动画的标准方案,其 worklet 机制将动画计算从 JS 线程转移到 UI 线程,彻底消除动画卡顿。import Animated, { useSharedValue, useAnimatedStyle, withTiming, withSpring,} from 'react-native-reanimated';function FadeInView({ children }: { children: React.ReactNode }) { const opacity = useSharedValue(0); const translateY = useSharedValue(20); const animatedStyle = useAnimatedStyle(() => ({ opacity: withTiming(opacity.value, { duration: 500 }), transform: [{ translateY: withSpring(translateY.value) }], })); useEffect(() => { opacity.value = 1; translateY.value = 0; }, []); return <Animated.View style={animatedStyle}>{children}</Animated.View>;}核心原则:凡是影响视觉流畅度的动画(拖拽、滑动、转场),都应该使用 Reanimated 的 worklet 跑在 UI 线程,而非通过 JS 线程的 Animated 驱动。导航性能使用 @react-navigation/native-stack 替代 JS 实现的 stack 导航器。native-stack 直接使用 iOS 的 UINavigationController 和 Android 的 Fragment 过渡动画,导航过程中完全不需要 JS 线程参与,页面切换延迟从数十毫秒降至接近原生水平。import { createNativeStackNavigator } from '@react-navigation/native-stack';const Stack = createNativeStackNavigator();function App() { return ( <NavigationContainer> <Stack.Navigator> <Stack.Screen name="Home" component={HomeScreen} /> <Stack.Screen name="Detail" component={DetailScreen} /> </Stack.Navigator> </NavigationContainer> );}内存管理内存泄漏是 React Native 应用性能劣化的常见原因,主要集中在三个场景:未清理的订阅、未清除的定时器、未取消的网络请求。// 取消网络请求useEffect(() => { const controller = new AbortController(); fetchUser(userId, { signal: controller.signal }); return () => controller.abort();}, [userId]);// 清理定时器useEffect(() => { const timer = setInterval(() => syncData(), 30000); return () => clearInterval(timer);}, []);// 清理事件订阅useEffect(() => { const subscription = EventEmitter.addListener('onUpdate', handleUpdate); return () => subscription.remove();}, []);对于需要延迟执行的繁重任务,使用 InteractionManager 等待交互完成后再执行:import { InteractionManager } from 'react-native';useEffect(() => { const task = InteractionManager.runAfterInteractions(() => { // 用户交互完成后再执行耗时操作 processHeavyData(); }); return () => task.cancel();}, []);内存检测工具:开发环境:Flipper + React DevToolsiOS:Xcode Instruments(Allocations / Leaks)Android:Android Studio Profiler生产环境:Sentry 性能监控Bundle 优化减少应用包体积直接影响下载转化率和启动速度。// 移除生产环境 console 输出// babel.config.jsmodule.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], env: { production: { plugins: ['transform-remove-console'], }, }, };};其他 Bundle 优化策略:Metro tree-shaking:确保未使用的导出被移除(Expo SDK 50+ 默认启用)Hermes 字节码预编译:构建时将 JS 编译为字节码,跳过运行时解析审查依赖体积:使用 npx react-native-bundle-visualizer 分析各模块占比,移除或替换体积过大的包按需加载原生模块:Expo 的模块自动链接机制会引入所有已安装的原生模块,定期清理 package.json 中未使用的依赖性能监控与分析工具| 工具 | 用途 | 适用阶段 ||------|------|---------|| React DevTools Profiler | 分析组件渲染次数和耗时 | 开发 || Flipper | 网络监控、布局检查、内存分析 | 开发 || Expo DevTools (SDK 52+) | 实时性能检查、Bundle 分析、加载追踪 | 开发 || Hermes Sampling Profiler | JS 函数级耗时分析 | 开发 || Sentry | 生产环境性能监控和错误追踪 | 生产 || Firebase Performance | 生产环境启动时间、网络延迟监控 | 生产 |// Expo SDK 52+ 内置性能检查// 在开发菜单中启用 "Performance Monitor" 即可查看:// - FPS 和帧时间// - JS 线程和 UI 线程的 CPU 占用// - 内存使用趋势常见性能问题与解决方案列表滚动卡顿使用 FlashList 替代 FlatList(首选方案)如果必须用 FlatList,提供 getItemLayout 并启用 removeClippedSubviews列表项组件用 React.memo 包裹,确保 renderItem 函数引用稳定应用启动慢确认已启用新架构和 Hermes 引擎移除启动阶段不必要的同步初始化代码使用 InteractionManager.runAfterInteractions 延迟非关键任务生产环境移除 console.log(babel 插件)图片加载慢使用 expo-image 并开启 cachePolicy="memory-disk"服务端提供 WebP 格式和多种尺寸列表中的图片使用懒加载内存泄漏useEffect 返回清理函数,取消网络请求、定时器和订阅使用 InteractionManager 延迟非关键任务定期使用 Xcode Instruments 或 Android Profiler 检查内存趋势导航切换延迟使用 native-stack 替代 JS stack 导航器减少导航页面中的 useEffect 同步操作页面组件使用 React.memo 避免路由变化时无关组件重渲染性能优化参考指标建立可量化的性能目标,避免凭感觉优化:| 指标 | 目标值(中端 Android) | 目标值(iPhone 13+) ||------|----------------------|---------------------|| 冷启动时间 | = 58 | >= 59 || 交互响应延迟 | < 100ms | < 50ms || JS 堆内存 | < 180MB | < 150MB || 安装包体积 | < 30MB | < 30MB |优化前先测量,优化后对比验证。没有量化数据的优化只是在猜测。
前端阅读 05月27日 18:00

如何将 Prometheus 与 Grafana 集成?有哪些最佳实践和常见坑点?

Prometheus 与 Grafana 集成的架构原理Prometheus 负责时序数据的采集、存储和告警,Grafana 负责数据的可视化呈现。两者通过 HTTP API 交互:Grafana 作为客户端向 Prometheus 发起 PromQL 查询请求,Prometheus 返回时间序列数据,Grafana 再将数据渲染为图表。理解这个数据流是做好集成的前提。核心数据链路:应用暴露 /metrics 接口 → Prometheus 通过 pull 模型定时抓取 → 数据存入 TSDB → Grafana 通过 PromQL 查询 → 仪表盘可视化 + 告警。集成配置详解添加 Prometheus 数据源在 Grafana 中进入 Configuration → Data Sources → Add data source,选择 Prometheus 类型,填写以下关键配置:{ "name": "Prometheus", "type": "prometheus", "url": "http://prometheus:9090", "access": "proxy", "isDefault": true, "jsonData": { "httpMethod": "POST", "timeInterval": "15s", "customQueryParameters": "" }}关键参数说明:httpMethod: 推荐设为 POST,对于大范围查询性能更好timeInterval: 与 Prometheus 的 scrape_interval 保持一致,避免数据对齐问题access: 生产环境建议用 proxy 模式,由 Grafana 后端代理请求,避免暴露 Prometheus 地址也可以通过 provisioning 配置文件自动注册数据源:apiVersion: 1datasources: - name: Prometheus type: prometheus url: http://prometheus:9090 access: proxy isDefault: true jsonData: httpMethod: POST timeInterval: 15s editable: true验证数据源连通性添加完成后,点击 Save & Test,Grafana 会发送一个查询请求验证连通性。如果报错,排查以下常见问题:网络不通:检查 Prometheus 是否可达(curl http://prometheus:9090/api/v1/status/config)跨域问题:proxy 模式下由 Grafana 后端代理,不存在跨域;direct 模式下需浏览器直连,需配置 CORS认证问题:如果 Prometheus 启用了 basic auth 或 TLS,需要在数据源配置中补充凭证常用 PromQL 查询示例CPU 使用率100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)内存使用率(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100磁盘使用率(1 - (node_filesystem_avail_bytes{fstype!="tmpfs"} / node_filesystem_size_bytes)) * 100网络流量rate(container_network_receive_bytes_total[5m])Kubernetes Pod 重启次数sum by (namespace, pod) (increase(kube_pod_container_status_restarts_total[1h]))HTTP 请求错误率(5xx 占比)sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100变量与模板配置变量是构建可复用仪表盘的核心能力,避免为每个实例、命名空间重复创建面板。常用变量定义| 变量名 | 类型 | Query | 说明 ||--------|------|-------|------|| instance | Query | labelvalues(up, instance) | 选择监控实例 || namespace | Query | labelvalues(kubepodinfo, namespace) | 选择 K8s 命名空间 || interval | Interval | 30s,1m,5m,15m,1h | 控制查询步长 || datasource | Datasource | Prometheus | 支持多数据源切换 |在面板查询中使用变量语法:# 按 instance 变量过滤rate(node_cpu_seconds_total{instance="$instance", mode!="idle"}[5m])# 按 namespace 变量过滤sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="$namespace"}[5m]))$_rateinterval 的使用Grafana 7.2+ 推荐使用 $_rateinterval 替代手动指定区间:rate(node_cpu_seconds_total{mode="idle"}[$__rate_interval]) * 100$_rateinterval 会自动计算为 max(scrapeinterval * 4, dashboardrefresh_interval),确保 rate 函数始终有足够的数据点,避免断图。仪表盘设计与组织仪表盘分层生产环境建议采用三层仪表盘架构:概览层(Overview):展示系统全局健康状态,使用 Stat 面板 + 红黄绿阈值,一眼发现问题服务层(Service):按服务/应用维度展开,包含请求量、延迟分布、错误率等 SLI 指标实例层(Instance):下钻到具体实例,展示 CPU、内存、磁盘 IO、网络等资源详情面板类型选择| 场景 | 推荐面板类型 | 说明 ||------|-------------|------|| 时间序列趋势 | Time Series | 默认首选,支持多条线叠加 || 当前值/状态 | Stat | 显示最新值,配合阈值变色 || 百分位分布 | Heatmap | 适合延迟分布可视化 || 排行/Top N | Bar Chart | 展示资源占用 Top 排名 || 表格数据 | Table | 适合多维指标对比 |告警面板配置在面板下方添加 Alert 区域,设置 Evaluate every(评估频率)和 For(持续时间),避免瞬时抖动触发告警。Recording Rules 优化查询性能当仪表盘中存在耗时较长的聚合查询时,Recording Rules 可以预先计算并存储结果,大幅降低查询延迟。groups: - name: cpu_rules interval: 30s rules: - record: job:cpu_usage:rate5m expr: 100 - (avg by (job) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) - name: http_rules interval: 30s rules: - record: job:http_error_rate:rate5m expr: sum by (job) (rate(http_requests_total{status=~"5.."}[5m])) / sum by (job) (rate(http_requests_total[5m])) * 100在 Grafana 中直接查询预计算指标:job:cpu_usage:rate5m{job="my-service"}Recording Rules 命名规范建议:level:metric:operations,例如 job:cpu_usage:rate5m,便于识别层级和计算逻辑。告警配置与集成Grafana Alerting vs Prometheus Alertmanager两者可以独立使用,也可以组合:Grafana Alerting:配置简单,直接在仪表盘上设置,支持 Unified Alerting 统一管理多数据源告警,适合简单场景Prometheus Alertmanager:功能更强大,支持告警分组(groupby)、抑制(inhibitrules)、静默(silences)、路由(routes),适合大规模告警管理Prometheus 告警规则示例groups: - name: node_alerts rules: - alert: HighCpuUsage expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85 for: 5m labels: severity: warning annotations: summary: "High CPU usage on {{ $labels.instance }}" description: "CPU usage is {{ $value }}% (threshold: 85%)" - alert: DiskSpaceLow expr: (1 - (node_filesystem_avail_bytes{fstype!="tmpfs"} / node_filesystem_size_bytes)) * 100 > 90 for: 10m labels: severity: critical annotations: summary: "Disk space low on {{ $labels.instance }}" description: "Disk usage is {{ $value }}% (threshold: 90%)"Alertmanager 路由配置route: group_by: [alertname, cluster] group_wait: 30s group_interval: 5m repeat_interval: 4h receiver: default-slack routes: - match: severity: critical receiver: pagerduty-critical repeat_interval: 1h - match: severity: warning receiver: default-slackreceivers: - name: default-slack slack_configs: - channel: #monitoring send_resolved: true - name: pagerduty-critical pagerduty_configs: - service_key: <your-key>Grafana 告警通知渠道Grafana 支持多种通知渠道:邮件、Slack、Webhook、钉钉、企业微信、PagerDuty 等。在 Alerting → Contact Points 中配置,并在 Notification Policies 中设置路由规则。高可用与生产级部署Prometheus 高可用方案单点 Prometheus 存在单点故障风险,生产环境常见两种高可用方案:多实例并行:部署两个以上相同配置的 Prometheus 实例,各自独立抓取和存储数据,Grafana 配置多个数据源并用 Load Balance 模式查询远程写入(Remote Write):Prometheus 将数据远程写入 Thanos / Cortex / Mimir 等长期存储后端,Grafana 从统一存储查询Grafana 高可用Grafana 本身无状态,多个实例共享同一个数据库(MySQL/PostgreSQL)即可实现高可用。注意关闭告警的 HA 降级(设置 ha_peer_name)。数据保留策略# prometheus.ymlglobal: scrape_interval: 15s evaluation_interval: 15s# 启动参数--storage.tsdb.retention.time=30d--storage.tsdb.retention.size=50GB短期数据保留在 Prometheus 本地 TSDB,长期数据通过 Remote Write 归档到对象存储。导入社区仪表盘Grafana 官方维护了大量开源仪表盘模板:访问 grafana.com/grafana/dashboards 搜索常用 ID:Node Exporter Full(1860)、Kubernetes 集群监控(7249)、Spring Boot Statistics(12900)在 Grafana 中通过 Dashboards → Import → 输入 ID 即可导入导入后需要根据实际环境调整变量和查询,避免指标名称不匹配导致面板无数据。常见坑点与排障rate 函数区间过短:scrapeinterval 为 15s 时,rate(xxx[1m]) 可能因数据点不足出现断图,建议区间至少为 4 倍 scrapeinterval 或使用 $_rateinterval时区不一致:Prometheus 使用 UTC,Grafana 默认跟随浏览器时区,告警时间判断需注意转换标签冲突:不同 job 采集的相同指标可能标签不一致,导致查询结果缺失,建议统一标签规范大范围查询超时:查询 30 天以上数据时容易超时,应使用 Recording Rules 预聚合,或配置 Prometheus 的 --query.timeout 参数Dashboard JSON 版本不兼容:Grafana 大版本升级后,旧仪表盘 JSON 格式可能变化,升级前做好备份
前端阅读 05月27日 17:34

Rspack 的缓存机制是如何工作的?

Rspack 的缓存机制是提升构建性能的核心手段。Rspack 目前支持内存缓存(Memory Cache)和持久化缓存(Persistent Cache)两种类型,配合快照策略、构建依赖追踪、可移植缓存等机制,能够在开发调试和生产构建中显著缩短耗时。内存缓存内存缓存是 Rspack 最基础的缓存形式,将模块编译结果和依赖图保存在内存中,使得增量构建和 HMR 能够快速响应。在开发模式下,Rspack 默认启用内存缓存:module.exports = { cache: true}也可以显式指定类型:module.exports = { cache: { type: 'memory' }}内存缓存的特点是速度极快,但进程退出后缓存即丢失。对于日常开发中的热更新场景,内存缓存已经足够,这也是 Rspack 在 HMR 性能上表现优异的原因之一。在生产模式下,cache 默认为 false,即不启用任何缓存。持久化缓存持久化缓存将构建结果写入磁盘,使得下次启动时可以直接复用上一次的编译产物,而无需重新执行模块解析和代码转换。这对于大型项目的冷启动场景尤为关键。基本配置const path = require('path')module.exports = { cache: { type: 'persistent' }}启用后,Rspack 默认将缓存存储在 node_modules/.cache/rspack 目录下。你也可以自定义存储位置:module.exports = { cache: { type: 'persistent', storage: { type: 'filesystem', directory: path.resolve(__dirname, '.cache/rspack') } }}构建依赖(buildDependencies)buildDependencies 用于声明哪些文件的变更应当导致缓存失效。Rspack 会计算这些文件的哈希值,当哈希值发生变化时,持久化缓存自动失效。module.exports = { cache: { type: 'persistent', buildDependencies: [__filename, path.join(__dirname, 'tsconfig.json')] }}需要注意的是,与 webpack 不同,Rspack 默认不预设任何构建依赖项。如果你希望配置文件修改后缓存能正确失效,必须将配置文件路径加入 buildDependencies。缓存版本(version)version 字段用于隔离不同配置的缓存。不同 version 值的缓存互不干扰,Rspack 会为每个版本生成独立的缓存目录。module.exports = { cache: { type: 'persistent', version: '1.0.0' }}当项目配置发生重大变更(如升级 loader 版本、修改 babel 配置等)时,更新 version 可以避免旧缓存导致的构建错误。需要注意的是,不要在配置不同的构建之间共享相同的 version 和 storage.directory,否则可能命中错误的缓存。缓存清理(maxGenerations)Rspack 通过 maxGenerations 控制缓存的存活周期。默认值为 1,意味着如果某条缓存在下一次编译中没有被使用,就会被清理。增大该值可以让缓存存活更多轮次:module.exports = { cache: { type: 'persistent', maxGenerations: 5 }}此外,Rspack 在启动时会自动清理超过 7 天未被访问的缓存目录,无需手动维护。快照策略(snapshot)快照策略决定了 Rspack 如何判断文件是否在上次构建后被修改,直接影响缓存验证的效率。managedPathsmanagedPaths 用于指定由包管理器管理的目录(默认包含 node_modules)。对这些路径下的文件,Rspack 通过 package.json 中的 version 字段判断是否变更,而不是逐文件计算哈希,从而大幅加速缓存验证。module.exports = { cache: { type: 'persistent', snapshot: { managedPaths: [/node_modules/] } }}immutablePathsimmutablePaths 用于指定内容不会变更的路径。一旦路径被标记为不可变,Rspack 在热重启时会跳过对这些文件的检查,直接认为缓存有效:module.exports = { cache: { type: 'persistent', snapshot: { immutablePaths: [path.join(__dirname, 'dist')] } }}unmanagedPathsunmanagedPaths 用于排除不应被 managedPaths 规则覆盖的路径。如果你的 node_modules 中有通过 git submodule 等方式管理的包,可以将其加入此列表,让 Rspack 对这些文件采用更精确的哈希验证。可移植缓存(portable)可移植缓存是 Rspack 为 CI/CD 场景设计的能力。启用后,缓存序列化时会将绝对路径转换为相对路径,使得缓存可以在不同机器和操作系统之间共享。module.exports = { cache: { type: 'persistent', portable: true }}典型的应用场景是在 CI 环境中:先将缓存构建并上传为 artifact,后续的构建任务直接下载复用,无需每次从零开始编译。Windows、Linux、macOS 之间可以共享同一份缓存,不需要为每个平台维护独立的缓存副本。需要注意的是,可移植模式下,项目目录外的文件会被转换为相对路径,在新环境中如果这些文件不存在,可能触发额外的重新编译。只读缓存(readonly)只读模式适用于 CI 场景中使用预热缓存的构建任务。启用后,Rspack 只从磁盘读取缓存,不会写入新数据:module.exports = { cache: { type: 'persistent', readonly: Boolean(process.env.CI) }}这在多构建任务共享同一份缓存 artifact 时特别有用,可以避免并发写入导致的缓存损坏。从 webpack 迁移缓存配置如果你从 webpack 迁移到 Rspack,缓存配置需要做以下调整:缓存类型:将 cache.type: 'filesystem' 改为 cache.type: 'persistent'构建依赖:将 buildDependencies 从对象格式 { config: [__filename] } 改为数组格式 [__filename]缓存版本:将 webpack 的 cache.name 和 cache.version 合并为 Rspack 的 cache.version快照配置:将顶层的 snapshot 配置移入 cache.snapshot// webpack 配置module.exports = { cache: { type: 'filesystem', buildDependencies: { config: [__filename] }, name: 'my-app', version: '1.0' }}// Rspack 配置module.exports = { cache: { type: 'persistent', buildDependencies: [__filename], version: 'my-app-1.0' }}实际使用建议开发环境:默认启用内存缓存即可满足需求。如果项目较大导致冷启动慢,可以同时开启持久化缓存。生产构建:生产模式下缓存默认关闭。对于频繁构建的场景(如预发布环境),可以开启持久化缓存并将 buildDependencies 配置完整,确保配置变更时缓存正确失效。CI/CD 环境:结合 portable: true 和 readonly: true,将构建产物缓存作为 pipeline artifact 上传和复用,可以显著减少 CI 构建时间。根据社区反馈,命中缓存后构建速度可以提升 2-3 倍。缓存失效排查:如果遇到缓存未命中或构建结果异常,首先检查 buildDependencies 是否完整,其次确认 version 是否需要更新。Rspack 在 cache.profile: true 开启时会输出缓存统计信息,有助于定位问题。
前端阅读 05月27日 17:34

Qwik 的 SSR 和 CSR 是如何协同工作的?

传统 SSR 框架在服务端渲染 HTML 后,客户端还需要重新下载和执行 JavaScript 来"水合"页面,恢复事件绑定和状态。这个过程随着应用规模增长,开销越来越大。Qwik 提出了完全不同的方案——恢复性(Resumability),让 SSR 产出的 HTML 自带全部状态和事件信息,客户端无需水合即可直接交互。Qwik 的 SSR 渲染流程Qwik 在服务器端执行组件渲染时,不仅生成 HTML 结构,还会将组件状态、事件处理器引用、组件层级关系等全部序列化到 HTML 中。最终返回给浏览器的 HTML 包含了完整的应用快照。具体流程如下:执行组件渲染:Qwik 在 Node.js 环境中执行组件函数,生成虚拟 DOM 并渲染为 HTML 字符串收集状态和事件:渲染过程中,Qwik 收集所有 useSignal、useStore 等响应式状态的当前值,以及所有 onClick$ 等 $ 后缀事件处理器的引用序列化到 HTML:将状态数据以 JSON 格式注入到 HTML 末尾的 <script type="qwik/json"> 标签中,事件绑定信息以 HTML 属性形式嵌入对应 DOM 节点发送完整 HTML:服务器将包含状态和事件元数据的 HTML 响应发送给浏览器export const App = component$(() => { const count = useSignal(0); return ( <div> <p>Count: {count.value}</p> <button onClick$={() => count.value++}> Increment </button> </div> );});服务端渲染后,count.value 的值 0 会被序列化到 HTML 中,onClick$ 处理器会被 Qwik Optimizer 编译为一个独立的 chunk 文件,HTML 中只保留该 chunk 的引用路径。浏览器首次加载时不需要下载这段逻辑代码。这样做的直接好处是:首屏 HTML 包含完整可交互内容,搜索引擎可以直接抓取;客户端零 JavaScript 启动成本,FCP 和 TTI 几乎同时达成。客户端恢复:零水合的交互激活浏览器收到 HTML 后,Qwik 的客户端工作方式与传统框架完全不同。传统框架需要下载整个应用的 JavaScript,重新执行组件渲染函数,逐个绑定事件监听器——这就是水合过程。Qwik 跳过了这整个步骤。qwikloader 全局事件代理Qwik 在 HTML 中注入了一个约 1KB 的 qwikloader 脚本,它做一件事:在 document 上监听所有 DOM 事件。当用户点击按钮时,事件冒泡到 document,qwikloader 根据事件目标元素上的 HTML 属性找到对应的事件处理器 chunk 路径,然后动态加载并执行该 chunk。<!-- 服务端渲染后的 HTML 片段 --><button on:click="./app_component_ClickHandler.js#default"> Increment</button><script type="qwik/json">{"state":{"count":"0"},"refs":{}}</script>这意味着:页面加载时不下载任何组件代码和事件处理器代码,只在用户真正交互时按需加载对应的代码块。状态反序列化当事件处理器 chunk 加载后,Qwik 从 <script type="qwik/json"> 中反序列化状态,将 count 恢复为响应式的 useSignal 对象。组件函数不需要重新执行,状态直接恢复到服务端渲染时的快照。细粒度代码分割Qwik Optimizer 编译器在构建阶段自动进行细粒度代码分割:组件级分割:每个 component$() 包裹的组件生成独立 chunk事件处理器级分割:每个 onClick$、onChange$ 等 $ 后缀回调生成独立 chunk状态更新逻辑分割:涉及状态变更的逻辑单独提取$ 后缀是 Qwik 的核心语法约定,它告诉编译器"这个函数的闭包需要被提取为独立模块"。这就是为什么 Qwik 要求事件处理器使用 onClick$ 而非 onClick——编译器需要显式知道哪些函数边界可以被分割。Qwik City 的 SSR 能力Qwik City 是 Qwik 的全栈框架,提供了路由、数据获取和服务端操作能力。路由数据加载routeLoader$ 在服务端执行数据获取,结果随 HTML 一起序列化发送:import { component$ } from '@builder.io/qwik';import { routeLoader$ } from '@builder.io/qwik-city';export const useProductData = routeLoader$(async ({ params, env }) => { const response = await fetch(`https://api.example.com/products/${params.id}`); return response.json();});export default component$(() => { const product = useProductData(); return ( <div> <h1>{product.value.name}</h1> <p>{product.value.description}</p> </div> );});routeLoader$ 的 $ 后缀同样是分割标记——数据获取逻辑在服务端执行,结果序列化后客户端直接使用,不需要重复请求。服务端操作action$ 处理表单提交等写操作,同样在服务端执行:import { action$ } from '@builder.io/qwik-city';export const useAddToCart = action$(async (data, { requestEvent }) => { const session = requestEvent.sharedMap.get('session'); // 服务端执行业务逻辑 return { success: true };});流式 SSRQwik City 支持流式 SSR,服务器可以在渲染完成前就开始向客户端发送 HTML 片段。对于数据量较大的页面,用户可以更快看到首屏内容,而不是等待整个页面渲染完毕才收到第一个字节。与传统 SSR 框架的关键区别| 维度 | Qwik | Next.js (React) | Nuxt (Vue) ||------|------|-----------------|------------|| 客户端激活方式 | 恢复性,无需水合 | 水合,重新执行组件 | 水合,重新执行组件 || 首屏 JS 体积 | 接近零(仅 qwikloader ~1KB) | 较大(框架运行时 + 组件代码) | 中等(框架运行时 + 组件代码) || 代码分割粒度 | 编译器自动按函数级分割 | 需手动配置 dynamic import | 需手动配置或依赖约定路由 || TTI 表现 | FCP 与 TTI 几乎一致 | TTI 明显滞后于 FCP | TTI 滞后于 FCP || 状态传递 | 自动序列化到 HTML | 需手动处理服务端状态注入 | 需手动处理或依赖框架约定 |核心差异在于水合成本。Next.js 的一个典型 SSR 页面,即便 HTML 已经包含完整内容,客户端仍需下载 React 运行时(约 40KB gzipped)和所有页面组件代码来执行水合。Qwik 完全跳过这一步,首次加载仅需 qwikloader 的 ~1KB。实际性能表现Qwik 官方基准测试中,一个中等复杂度页面的性能指标:FCP(首次内容绘制):约 0.3sTTI(可交互时间):约 0.9s首次加载 JS 体积:约 1KB(qwikloader)作为对比,相同页面在 Next.js 下的典型数据:FCP:约 0.5sTTI:约 2.5s(需要等待水合完成)首次加载 JS 体积:约 80-150KB(React 运行时 + 组件代码)大众点评 M 站在 2026 年初完成基于 Qwik.js 的重构,生产环境验证了恢复性方案在大型电商场景下的性能收益——首屏加载速度提升约 40%,TTI 从 3.2s 降至 1.1s。最佳实践服务端数据获取优先优先使用 routeLoader$ 在服务器获取数据,避免客户端额外请求。数据随 HTML 一起序列化,客户端直接使用。合理使用客户端任务仅在必须访问浏览器 API 时使用 useVisibleTask$,如需要 window、document 或 Web API 的场景。不要用它来获取可以 SSR 的数据。利用 useResource$ 处理异步数据useResource$ 适合需要客户端动态获取数据的场景,它返回的 Resource 对象可以追踪加载状态(pending / resolved / rejected),便于在 UI 中展示 loading 态:export default component$(() => { const searchData = useResource$(async ({ cleanup }) => { const controller = new AbortController(); cleanup(() => controller.abort()); const res = await fetch(`/api/search?q=keyword`, { signal: controller.signal }); return res.json(); }); return ( <Resource value={searchData} onPending={() => <p>加载中...</p>} onResolved={(data) => <div>{data.result}</div>} /> );});混合渲染策略静态内容(如博客文章、产品描述)使用 SSR 确保搜索引擎可抓取;动态交互(如购物车、搜索建议)依赖 Qwik 的按需加载机制,只在用户交互时加载对应逻辑。Qwik 的恢复性架构让 SSR 和 CSR 不再是二选一的取舍,而是一个连续光谱上的不同策略。服务端负责渲染和数据获取,客户端负责交互和按需加载,两者通过序列化机制无缝衔接,开发者不需要手动处理状态同步和水合逻辑。