面试题手册

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

服务端阅读 05月27日 18:30

MobX 中的中间件和拦截器如何使用?

MobX 生态中有两套不同的拦截与中间件机制:MobX 核心库的 intercept/observe,以及 MobX-State-Tree(MST)的 addMiddleware/onAction。面试中混淆两者是常见的扣分点。下面分别讲解它们的用法、区别和典型场景。核心库:intercept 和 observeintercept 和 observe 是 MobX 核心库提供的底层 API,直接作用于 observable 对象的属性变更。intercept:变更前拦截intercept(target, propertyName?, interceptor) 在变更作用于 observable 之前被调用,可以对变更进行修改、放行或取消。import { observable, intercept } from 'mobx';const store = observable({ count: 0, items: []});// 拦截 count 属性的变化const disposer = intercept(store, 'count', (change) => { // 1. 修改变更:不允许负数 if (change.newValue < 0) { change.newValue = 0; } // 2. 取消变更:超过上限直接返回 null if (change.newValue > 100) { return null; } // 3. 放行变更:返回 change 对象 return change;});store.count = 5; // 正常设置,count 变为 5store.count = -1; // 被修改,count 变为 0store.count = 200; // 被取消,count 保持不变disposer(); // 移除拦截器拦截器的返回值决定了变更的命运:返回 change 对象:放行变更修改 change 后返回:修改后放行(常用于数据规范化)返回 null:取消变更,对象不被修改抛出异常:阻止变更并向上传播错误拦截数组和 Mapintercept 也可以作用于 observable 数组和 Map,此时不需要指定属性名:import { observable, intercept } from 'mobx';const items = observable([1, 2, 3]);intercept(items, (change) => { if (change.type === 'add' && typeof change.newValue !== 'number') { throw new Error('只允许添加数字'); } return change;});const map = observable(new Map());intercept(map, (change) => { if (change.name === 'secret') { return null; // 禁止设置 secret 键 } return change;});observe:变更后观察observe(target, propertyName?, listener) 在变更已经生效之后被调用,适合做副作用处理(如日志、同步到外部系统)。import { observable, observe } from 'mobx';const store = observable({ count: 0 });const disposer = observe(store, 'count', (change) => { console.log(`count: ${change.oldValue} -> ${change.newValue}`);});store.count = 5; // 输出: count: 0 -> 5disposer();观察数组时的 change 对象包含 added、removed、index 等字段:const items = observable([1, 2, 3]);observe(items, (change) => { if (change.type === 'splice') { console.log('添加:', change.added, '移除:', change.removed); }});items.push(4); // 添加: [4] 移除: []不指定属性名时,可以观察对象所有属性的变化:observe(store, (change) => { console.log(`${change.name}: ${change.oldValue} -> ${change.newValue}`);});intercept 与 observe 的关键区别| 对比项 | intercept | observe ||--------|-----------|---------|| 触发时机 | 变更生效前 | 变更生效后 || 能否修改变更 | 可以 | 不可以 || 能否取消变更 | 可以(返回 null) | 不可以 || 典型用途 | 数据验证、格式化、权限控制 | 日志记录、副作用同步 |注意事项MobX 官方文档明确指出,intercept 和 observe 是底层工具,在实际项目中应谨慎使用。原因如下:observe 不遵循事务原则,在 action 中间可能触发多次两者都不支持深层级对象变化的监听滥用 intercept 容易创建难以调试的隐式数据流优先使用 reaction、autorun 或 when 来替代 observe;将数据验证逻辑放在 action 内部而不是 intercept 中。MobX-State-Tree:中间件体系MobX-State-Tree(MST)在 MobX 核心之上构建了更完善的中间件系统,通过 addMiddleware 和 onAction 提供 action 级别的拦截能力。addMiddleware:拦截 actionaddMiddleware 可以拦截子树上的任何 action 调用,并能修改参数、中止执行或替换返回值。import { addMiddleware, flow } from 'mobx-state-tree';const disposer = addMiddleware(store, (call, next) => { // call 包含: name, args, type, context, tree 等 console.log(`[Action] ${call.name} 被调用,参数:`, call.args); // 前置逻辑:验证参数 if (call.name === 'removeItem' && call.args[0] < 0) { return next({ ...call, args: [0] }); // 修改参数后传递 } // 调用 next 继续执行链 const result = next(call); // 后置逻辑:记录结果 console.log(`[Action] ${call.name} 完成,结果:`, result); return result;});中间件处理函数必须调用 next(call) 让 action 继续执行,或者通过返回值中止 action。不调用 next 会导致 action 被静默取消。onAction:监听 actiononAction 是一个内置的只读中间件,只能监听 action 的调用,不能拦截或修改。import { onAction } from 'mobx-state-tree';const disposer = onAction(store, (call) => { console.log(`Action ${call.name} 被调用,参数:`, call.args);});onAction 的参数以可序列化格式传递,适合用于:调试日志操作录制与重放(配合 applyAction)远程同步onAction 与 addMiddleware 的区别| 对比项 | addMiddleware | onAction ||--------|---------------|----------|| 能否拦截 action | 可以 | 不可以 || 能否修改参数 | 可以(克隆后修改) | 不可以 || 能否中止执行 | 可以(不调用 next) | 不可以 || 参数格式 | 原始参数 | 可序列化格式 || 典型用途 | 验证、权限控制、错误处理 | 日志、录制、调试 |中间件链的执行顺序多个中间件可以附加到同一个节点上,执行顺序遵循"由内到外"原则:同一对象上,先注册的中间件先执行子节点的中间件先于父节点的中间件执行每个中间件必须调用 next(call) 才能将控制权传递给下一个实战:典型应用场景数据验证与格式化用 intercept 在数据写入前进行校验和规范化:import { observable, intercept } from 'mobx';const form = observable({ email: '', age: 0});intercept(form, 'email', (change) => { if (change.newValue && !change.newValue.includes('@')) { return null; // 不写入无效邮箱 } return change;});intercept(form, 'age', (change) => { change.newValue = Math.max(0, Math.floor(change.newValue)); return change;});撤销/重做(Undo/Redo)利用 observe 记录变更历史,实现撤销和重做功能:import { observable, observe, action, makeAutoObservable } from 'mobx';class UndoManager { past = []; future = []; constructor(target) { this.target = target; makeAutoObservable(this); // 监听目标对象的属性变化 Object.keys(target).forEach((key) => { observe(target, key, (change) => { this.past.push({ key, oldValue: change.oldValue, newValue: change.newValue }); this.future = []; }); }); } @action undo() { if (this.past.length === 0) return; const entry = this.past.pop(); this.future.push(entry); this.target[entry.key] = entry.oldValue; } @action redo() { if (this.future.length === 0) return; const entry = this.future.pop(); this.past.push(entry); this.target[entry.key] = entry.newValue; } get canUndo() { return this.past.length > 0; } get canRedo() { return this.future.length > 0; }}MST 中间件:统一的错误处理在 MST 中用 addMiddleware 为所有 action 统一添加错误处理:import { addMiddleware } from 'mobx-state-tree';addMiddleware(store, (call, next) => { try { const result = next(call); // 异步 action 需要特殊处理 if (result && typeof result.then === 'function') { return result.catch((error) => { console.error(`[Error] ${call.name} 失败:`, error); store.setError(error.message); throw error; }); } return result; } catch (error) { console.error(`[Error] ${call.name} 失败:`, error); store.setError(error.message); throw error; }});MST 中间件:性能监控import { addMiddleware } from 'mobx-state-tree';const metrics = {};addMiddleware(store, (call, next) => { const start = performance.now(); const result = next(call); const duration = performance.now() - start; if (!metrics[call.name]) { metrics[call.name] = { count: 0, totalTime: 0, maxTime: 0 }; } const m = metrics[call.name]; m.count++; m.totalTime += duration; m.maxTime = Math.max(m.maxTime, duration); if (duration > 100) { console.warn(`[性能] ${call.name} 耗时 ${duration.toFixed(2)}ms`); } return result;});操作录制与重放利用 onAction 的可序列化特性,录制操作并在其他实例上重放:import { onAction, applyAction } from 'mobx-state-tree';// 录制端const recordedActions = [];onAction(sourceStore, (call) => { recordedActions.push(call);});// 重放端recordedActions.forEach((action) => { applyAction(targetStore, action);});这种模式在协作编辑、时间旅行调试和测试中非常有用。面试高频问题intercept 和 observe 的区别是什么?intercept 在变更生效前触发,可以修改或取消变更;observe 在变更生效后触发,只能被动接收。前者适合数据校验和格式化,后者适合日志和副作用同步。为什么 MobX 官方建议慎用 intercept 和 observe?因为 observe 不遵循事务原则,可能在一个 action 中间多次触发;两者都不支持深层级监听;滥用容易创建隐式的、难以调试的数据流。官方推荐使用 reaction、autorun 或 when 替代。MST 的 addMiddleware 和 onAction 有什么区别?addMiddleware 可以拦截、修改和中止 action,而 onAction 只能监听不能拦截。onAction 的参数以可序列化格式传递,适合录制和重放场景。MST 中间件链的执行顺序是什么?同一对象上先注册的中间件先执行,子节点中间件先于父节点中间件执行。每个中间件必须调用 next(call) 才能将控制权传递给下一个。如何选择使用哪种机制?需要拦截 observable 属性级别的变更:用核心库 intercept需要监听属性变更做副作用:优先用 reaction,其次 observe需要拦截 MST action 级别的调用:用 addMiddleware只需监听 MST action 调用:用 onAction需要数据验证:放在 action 逻辑内部,而非 intercept 中
服务端阅读 05月27日 18:30

Kubernetes ConfigMap 和 Secret 有什么区别?

Kubernetes 中 ConfigMap 和 Secret 都用于将配置与容器镜像解耦,但它们在数据性质、存储方式和安全机制上有本质区别。理解两者的差异并正确使用,是管理 K8s 应用配置的基本功,也是面试高频考点。ConfigMap 和 Secret 的核心区别是什么ConfigMap 存储非敏感的配置数据,比如应用端口号、日志级别、功能开关;Secret 专门存储密码、证书、Token 等敏感信息。这是最根本的划分原则——如果你犹豫某个值该放哪里,问自己一个问题:泄露后会不会出安全事故?会就放 Secret。两者在 API 层面的差异:编码方式:ConfigMap 的 data 字段存明文,Secret 的 data 字段存 Base64 编码。注意 Base64 只是编码不是加密,etcd 中 Secret 默认仍是明文存储,必须启用 EncryptionConfiguration 才能真正加密。访问控制:Secret 有更严格的 RBAC 建议策略,K8s 审计日志可以单独追踪 Secret 的访问记录。etcd 存储:开启加密后,Secret 在 etcd 中以密文保存;ConfigMap 始终明文。大小限制:两者单条都限制 1 MiB,超限需要拆分或引入外部配置中心。一个容易忽略的点:Secret 挂载到 Pod 后,kubelet 会将其写入 tmpfs(内存文件系统),Pod 删除后数据随之消失;而 ConfigMap 挂载的文件默认写入磁盘。怎样创建 ConfigMap实际项目中很少用命令行一条条创建,更多是通过 YAML 声明式管理,配合 GitOps 流程。以下覆盖常见的创建方式。从字面值创建,适合少量键值对:kubectl create configmap app-config \ --from-literal=LOG_LEVEL=info \ --from-literal=MAX_RETRIES=3从文件创建,适合将整个配置文件注入:kubectl create configmap nginx-config \ --from-file=nginx.conf=./nginx.conf从 YAML 声明创建,这是推荐的做法,便于版本管理和审计:apiVersion: v1kind: ConfigMapmetadata: name: app-configdata: LOG_LEVEL: "info" MAX_RETRIES: "3" application.yml: | server: port: 8080 spring: datasource: url: jdbc:mysql://mysql-svc:3306/mydb注意 ConfigMap 的 data 下所有值都是字符串类型。如果你写了 PORT: 8080,实际存储的是 "8080",这在某些语言框架中可能引发类型解析问题。怎样在 Pod 中使用 ConfigMapConfigMap 有三种使用方式,选择哪种取决于配置的变更频率和消费方式。作为环境变量——适合少量、启动时确定的配置:spec: containers: - name: app image: my-app:latest env: - name: LOG_LEVEL valueFrom: configMapKeyRef: name: app-config key: LOG_LEVEL也可以一次性注入所有键值对:envFrom:- configMapRef: name: app-config注意 envFrom 会把 ConfigMap 的所有键注入环境变量,如果键名冲突会被后面的覆盖,要确认命名规范一致。挂载为卷——适合配置文件场景,如 Nginx 配置、Spring 的 application.yml:spec: containers: - name: app volumeMounts: - name: config-volume mountPath: /etc/app/config readOnly: true volumes: - name: config-volume configMap: name: app-config挂载为卷有一个重要特性:ConfigMap 更新后,挂载的文件会在几分钟内自动刷新(kubelet 的同步周期默认 60 秒 + 随机延迟)。但应用本身需要有能力感知文件变化并热加载,否则还是要滚动重启 Pod。作为命令行参数——在容器启动命令中引用环境变量:spec: containers: - name: app image: my-app:latest command: ["./app"] args: ["--log-level=$(LOG_LEVEL)"] env: - name: LOG_LEVEL valueFrom: configMapKeyRef: name: app-config key: LOG_LEVEL这种方式本质上还是环境变量,只是被 command/args 引用了。怎样创建 SecretSecret 的创建方式与 ConfigMap 类似,但有一个关键区别:data 字段的值必须 Base64 编码。apiVersion: v1kind: Secretmetadata: name: db-credentialstype: Opaquedata: username: YWRtaW4= # echo -n 'admin' | base64 password: c2VjcmV0MTIz # echo -n 'secret123' | base64如果你不想手动编码,可以用 stringData 字段,K8s 会自动帮你转 Base64:apiVersion: v1kind: Secretmetadata: name: db-credentialstype: OpaquestringData: username: admin password: secret123stringData 在写入 etcd 后会被转为 data 字段的 Base64 编码形式,所以通过 kubectl get -o yaml 看到的仍然是编码后的值。创建 TLS 类型的 Secret,用于 Ingress 或 Pod 的 HTTPS 配置:kubectl create secret tls tls-cert \ --cert=./tls.crt \ --key=./tls.key创建镜像拉取凭据:kubectl create secret docker-registry regcred \ --docker-server=registry.example.com \ --docker-username=user \ --docker-password=pass怎样在 Pod 中使用 Secret作为环境变量——最简单但要注意安全隐患:spec: containers: - name: app env: - name: DB_USERNAME valueFrom: secretKeyRef: name: db-credentials key: username环境变量方式的缺点:应用日志或调试输出可能意外打印敏感值;子进程会继承所有环境变量。如果对安全性要求高,优先用卷挂载。挂载为卷——推荐方式,Secret 以文件形式存在 tmpfs 中:spec: containers: - name: app volumeMounts: - name: secret-volume mountPath: /etc/secrets readOnly: true volumes: - name: secret-volume secret: secretName: db-credentials挂载方式的好处是应用可以按需读取,不会意外泄露到环境变量或日志中。imagePullSecrets——用于拉取私有镜像仓库:spec: imagePullSecrets: - name: regcred containers: - name: app image: registry.example.com/my-app:latestConfigMap 和 Secret 的更新机制有什么坑这是一个面试常考、实战也常踩的要点。ConfigMap/Secret 更新后,Pod 不会自动重启。对于 Deployment 管理的 Pod,你需要在模板中引用 ConfigMap/Secret 的某个标注(比如通过 subPath 或 env 注入资源版本号),触发滚动更新。否则新配置只会通过卷挂载的方式在 Pod 内部刷新文件内容。环境变量方式更加局限——容器的环境变量在启动时就固定了,ConfigMap 更新后,已经运行的 Pod 的环境变量不会变,必须重建 Pod 才能生效。还有一个容易忽略的坑:如果你用了 subPath 挂载 ConfigMap 或 Secret 的某个键,该文件不会随着 ConfigMap/Secret 更新而自动刷新,因为它脱离了符号链接的更新机制。immutable 不可变配置有什么用Kubernetes 1.21 起,ConfigMap 和 Secret 支持 immutable: true 字段:apiVersion: v1kind: ConfigMapmetadata: name: app-configimmutable: truedata: LOG_LEVEL: "info"标记为不可变后,无法修改 data 字段,只能删除重建。好处有两点:性能提升:kubelet 不需要 watch 不可变 ConfigMap/Secret,减轻了 API Server 和 kubelet 的负担。集群中 ConfigMap/Secret 数量多时效果明显。安全性:防止配置被意外或恶意篡改。生产环境中,如果配置在发布后确实不会变更,建议加上 immutable: true。Secret 的安全加固方案有哪些Base64 编码不等于加密,这是一个必须强调的事实。以下是生产环境中需要落实的安全措施。启用 etcd 加密——配置 EncryptionConfiguration,让 Secret 在 etcd 中以密文存储:apiVersion: apiserver.config.k8s.io/v1kind: EncryptionConfigurationresources: - resources: - secrets providers: - aescbc: keys: - name: key1 secret: <base64-encoded-32-byte-key> - identity: {}配置 RBAC 最小权限——只授权必要的 Secret 访问:apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: secret-readerrules:- apiGroups: [""] resources: ["secrets"] resourceNames: ["db-credentials"] verbs: ["get"]使用外部密钥管理系统——对于安全等级高的场景,引入 HashiCorp Vault、AWS Secrets Manager 或 Sealed Secrets 等:Vault Agent Injector:通过 Mutating Admission Webhook 自动将 Vault 中的密钥注入 PodExternal Secrets Operator:将外部密钥同步为 K8s SecretSealed Secrets:加密后可以安全地存储在 Git 中启用审计日志——追踪谁在什么时候访问了哪些 Secret:apiVersion: audit.k8s.io/v1kind: Policyrules:- level: RequestResponse resources: - group: "" resources: ["secrets"]定期轮换密钥——建立轮换机制,避免长期使用同一密钥。可以结合 CI/CD 流程在部署时自动更新 Secret。面试中如何回答 ConfigMap 和 Secret 的相关问题面试官问这个问题,通常不只是要你背概念,而是在考察你对 K8s 配置管理的整体理解。一个合格的回答应该涵盖以下层次:先说清楚本质区别:非敏感 vs 敏感,明文 vs Base64 编码,不同的安全机制再说实际使用:创建方式、三种消费方式(环境变量/卷挂载/命令行参数)及各自的适用场景然后说踩坑经验:更新机制的限制、环境变量不热更新、subPath 不自动刷新最后说安全加固:etcd 加密、RBAC 最小权限、外部密钥管理、审计日志如果你只答了第一层,面试官会认为你对 K8s 的理解停留在表面。能把第四层讲清楚,才能体现出生产环境的实战经验。
服务端阅读 05月27日 18:30

Koa 错误处理怎么写?从洋葱模型到完整方案

Koa 的错误处理和其他框架有什么不同?Koa 的错误处理设计跟 Express 有本质区别。Express 用中间件参数签名来区分普通中间件和错误处理中间件——四个参数 (err, req, res, next) 才是错误处理中间件。Koa 走了另一条路:它借助 async/await,让 try-catch 自然地包裹整个下游中间件链,配合洋葱模型实现错误冒泡。这意味着你只需要在洋葱模型的最外层放一个 try-catch,就能捕获所有内层抛出的错误。理解这一点,是写好 Koa 错误处理的前提。Koa 错误传播的原理是什么?Koa 的洋葱模型中,每个中间件都有机会在 await next() 之后执行逻辑。如果某个内层中间件抛出错误,这个错误会沿着调用栈向上冒泡,直到被某一层的 try-catch 捕获,或者到达框架顶层。关键细节:Koa 框架顶层有兜底逻辑。如果一个错误始终没被任何中间件捕获,Koa 会尝试返回 500,并触发 app.on('error') 事件。但如果响应头已经发送(ctx.headerSent 为 true),Koa 无法再修改状态码和响应体,只能把错误抛给 Node.js 的 unhandledRejection。这是实际开发中容易踩的坑——在流式响应场景中尤其要注意。如何用 ctx.throw 抛出标准 HTTP 错误?ctx.throw 是 Koa 提供的快捷方法,用于抛出带 HTTP 状态码的错误:app.use(async (ctx) => { if (!ctx.query.token) { ctx.throw(401, 'Token is required'); } ctx.body = 'Success';});ctx.throw 的第一个参数是状态码,第二个参数是错误消息。它内部会创建一个 HttpError 对象并抛出,这个对象携带 status、message 等属性,方便外层中间件统一处理。需要注意的是,ctx.throw 只支持 HTTP 标准状态码对应的错误。如果你需要携带自定义的业务错误码(比如 INVALID_PARAM),应该用自定义错误类代替 ctx.throw。怎么写错误处理中间件?错误处理中间件必须放在所有业务中间件之前,也就是洋葱模型的最外层。只有这样,内层所有中间件的错误才能被捕获:async function errorHandler(ctx, next) { try { await next(); } catch (err) { ctx.status = err.status || 500; if (ctx.app.env === 'development') { ctx.body = { error: err.message, stack: err.stack, code: err.code }; } else { ctx.body = { error: 'Internal Server Error', code: 'INTERNAL_ERROR' }; } ctx.app.emit('error', err, ctx); }}app.use(errorHandler);这段代码做了三件事:设置状态码、构建响应体、触发错误事件。开发环境返回堆栈信息方便调试,生产环境隐藏细节防止信息泄露。ctx.app.emit('error', err, ctx) 把错误转发给全局监听器,用于日志记录和监控上报。常见误区:有人把错误处理中间件放在路由中间件之后,这样它就无法捕获路由中抛出的错误——因为洋葱模型中,后注册的中间件在内层,内层的 try-catch 捕获不到外层已经抛出的错误。如何设计自定义错误类?ctx.throw 只能抛出 HTTP 标准错误,实际项目中往往需要更丰富的错误信息。自定义错误类可以携带业务错误码、错误详情等字段:class AppError extends Error { constructor(status, message, code) { super(message); this.status = status; this.code = code; this.name = 'AppError'; }}class NotFoundError extends AppError { constructor(message = 'Resource not found') { super(404, message, 'NOT_FOUND'); this.name = 'NotFoundError'; }}class ValidationError extends AppError { constructor(message = 'Validation failed') { super(400, message, 'VALIDATION_ERROR'); this.name = 'ValidationError'; }}class AuthError extends AppError { constructor(message = 'Authentication required') { super(401, message, 'AUTH_ERROR'); this.name = 'AuthError'; }}使用时直接抛出,错误处理中间件会自动识别 status 和 code:app.use(async (ctx) => { const user = await findUser(ctx.params.id); if (!user) { throw new NotFoundError('User not found'); } if (!user.isActive) { throw new AuthError('User account is deactivated'); } ctx.body = user;});设计自定义错误类时,建议让所有业务错误继承同一个基类 AppError,这样错误处理中间件可以通过 instanceof 判断错误类型,做差异化处理。全局错误事件怎么用?app.on('error') 是 Koa 的全局错误事件监听器。所有未被中间件完全处理的错误,以及中间件中手动 ctx.app.emit('error', err, ctx) 触发的错误,都会到达这里:app.on('error', (err, ctx) => { console.error(`[${new Date().toISOString()}] ${ctx.method} ${ctx.url}`); console.error(`Status: ${err.status || 500}, Code: ${err.code || 'UNKNOWN'}`); console.error(`Message: ${err.message}`); // 上报监控系统 monitoringService.report(err, ctx); // 严重错误发送告警 if (err.status >= 500) { alertService.send(err, ctx); }});全局错误事件的职责是日志记录、监控上报、告警通知。不要在这里修改 ctx 的响应——因为到了这一步,响应可能已经发出去了。响应格式化是错误处理中间件的事,全局监听只管记录。还有一个容易忽略的点:如果错误处理中间件捕获了错误并正常响应了客户端,但没有调用 ctx.app.emit('error'),这个错误就不会到达全局监听器。这意味着你需要做一个选择——哪些错误需要全局记录。通常建议:所有 500 及以上的错误都应该 emit 到全局,4xx 的客户端错误可以视情况决定。404 怎么处理?Koa 不会自动返回 404。如果一个请求没有匹配到任何路由,也没有任何中间件设置响应体,Koa 默认返回 404 状态码和 Not Found 纯文本。但在实际项目中,你通常需要返回统一格式的 JSON 响应:// 放在所有路由之后app.use(async (ctx) => { ctx.status = 404; ctx.body = { error: 'Not Found', code: 'NOT_FOUND', path: ctx.url };});这个中间件的原理是:如果前面的路由中间件已经处理了请求(设置了 ctx.body),Koa 不会再执行后续中间件。只有请求穿透了所有路由,才会落到这个兜底中间件。更优雅的做法是判断 ctx.status === 404 && !ctx.body,避免覆盖其他中间件故意设置的 404 响应。异步错误在 Koa 中怎么处理?Koa 基于 async/await,能自动捕获 async 函数中抛出的同步错误。但有些场景需要额外注意:// 直接 await — 错误会正常冒泡app.use(async (ctx) => { const data = await fetchData(); ctx.body = data;});// 未 await 的 Promise — 错误不会被捕获app.use(async (ctx) => { fetchData().then(data => { // 危险!如果 fetchData reject,错误不会冒泡 ctx.body = data; });});第二条规则:永远不要在 Koa 中间件里写 .then() 而不 await。未 await 的 Promise 如果 reject,错误会被吞掉,不会冒泡到错误处理中间件,也不会触发全局错误事件。这是 Node.js 中 unhandledRejection 的常见来源。对于第三方回调风格的异步操作,用 Promise 包装后再 await:const { promisify } = require('util');const readFile = promisify(fs.readFile);app.use(async (ctx) => { try { const content = await readFile(ctx.query.path, 'utf8'); ctx.body = content; } catch (err) { if (err.code === 'ENOENT') { throw new NotFoundError('File not found'); } throw err; }});数据库和第三方服务的错误怎么统一处理?数据库驱动抛出的错误通常有特定的错误码,需要转换成 HTTP 友好的格式。在错误处理中间件中针对不同错误类型做转换:app.use(async (ctx, next) => { try { await next(); } catch (err) { // PostgreSQL 唯一约束冲突 if (err.code === '23505') { ctx.throw(409, 'Resource already exists'); } // PostgreSQL 外键约束冲突 if (err.code === '23503') { ctx.throw(400, 'Invalid reference'); } // MongoDB 重复键 if (err.code === 11000) { ctx.throw(409, 'Duplicate key error'); } // JWT 过期 if (err.name === 'TokenExpiredError') { ctx.throw(401, 'Token expired'); } // 请求超时 if (err.code === 'ECONNABORTED' || err.code === 'ETIMEDOUT') { ctx.throw(504, 'Request timeout'); } throw err; }});这种做法把底层错误码翻译成 HTTP 语义,对客户端更友好。但要注意,这些转换逻辑不应该无限膨胀——如果某个数据库的错误码特别多,应该封装成专门的错误转换函数。一个完整的错误处理方案长什么样?把上面的各个部分组合起来,得到一个可用的完整方案:const Koa = require('koa');const app = new Koa();// 自定义错误类class AppError extends Error { constructor(status, message, code) { super(message); this.status = status; this.code = code; this.name = 'AppError'; }}class NotFoundError extends AppError { constructor(message = 'Resource not found') { super(404, message, 'NOT_FOUND'); }}class ValidationError extends AppError { constructor(message = 'Validation failed') { super(400, message, 'VALIDATION_ERROR'); }}// 错误处理中间件 — 放在最前面app.use(async (ctx, next) => { try { await next(); // 兜底 404 if (ctx.status === 404 && !ctx.body) { ctx.body = { error: 'Not Found', code: 'NOT_FOUND', path: ctx.url }; } } catch (err) { ctx.status = err.status || 500; const response = { error: err.message, code: err.code || 'INTERNAL_ERROR', timestamp: new Date().toISOString() }; if (app.env === 'development') { response.stack = err.stack; } ctx.body = response; ctx.app.emit('error', err, ctx); }});// 全局错误事件app.on('error', (err, ctx) => { console.error(`[${new Date().toISOString()}] ${ctx.method} ${ctx.url} - ${err.status || 500}`); if (err.status >= 500) { monitoringService.report(err, ctx); }});// 业务路由app.use(async (ctx) => { if (ctx.path === '/users/:id') { const user = await findUser(ctx.params.id); if (!user) throw new NotFoundError('User not found'); ctx.body = user; } ctx.body = { message: 'OK' };});app.listen(3000);这套方案覆盖了自定义错误类、错误处理中间件、全局事件监听、404 兜底、开发/生产环境差异化响应。把它作为项目模板,根据实际需求增减即可。写 Koa 错误处理,核心就是三件事:把错误处理中间件放在最前面,用自定义错误类统一错误格式,在全局事件中做好日志和监控。搞清洋葱模型中错误的传播方向,其他问题都好解决。
服务端阅读 05月27日 18:29

Koa 中 Context 对象 ctx 有哪些核心属性和用法?

Koa 的 Context 对象是什么?Koa 的 Context 对象(即 ctx)是 Koa 框架中最核心的概念之一。它将 Node.js 原生的 request 和 response 对象封装到一个统一的对象中,并通过代理机制让开发者可以直接在 ctx 上访问请求和响应的属性,不必反复切换 req/res。理解 ctx,本质上就是理解 Koa 的设计哲学——用更少的代码完成更多的事情。ctx 的代理机制是怎么工作的?很多开发者只知道 ctx.query 能拿到查询参数,但并不清楚它为什么能直接用。实际上,ctx 上许多属性并不是自己定义的,而是通过 Object.defineProperty 代理到 ctx.request 和 ctx.response 上的。具体来说,当你访问 ctx.query 时,实际执行的是 ctx.request.query;当你设置 ctx.body 时,实际设置的是 ctx.response.body。这种代理机制的好处是减少了代码嵌套层级,让中间件的写法更加扁平。需要注意的一点是,并非所有 request/response 上的属性都被代理了。对于没有被代理的属性,你仍然需要通过 ctx.request.xxx 或 ctx.response.xxx 来访问。请求相关属性有哪些?ctx 提供了两组请求属性的访问方式:便捷访问和完整访问。便捷访问(直接通过 ctx):ctx.url — 请求路径,包含查询字符串ctx.method — 请求方法(GET、POST 等)ctx.header — 请求头对象ctx.query — 解析后的查询字符串对象,例如 /api?name=koa 会得到 { name: 'koa' }ctx.path — 请求路径,不包含查询字符串ctx.host — 请求的主机名完整访问(通过 ctx.request):ctx.request.querystring — 原始查询字符串(未解析),例如 name=koactx.request.search — 包含 ? 的原始查询字符串ctx.request.type — 请求的 Content-Typectx.request.accept — 客户端接受的内容类型ctx.request.ip — 客户端 IP 地址实际开发中,ctx.query 和 ctx.method 是使用频率最高的两个请求属性。获取请求体数据(ctx.request.body)则需要额外引入 koa-bodyparser 中间件,Koa 本身不内置 body 解析功能。app.use(async (ctx) => { // 获取查询参数 const { page, size } = ctx.query; // 获取请求方法和路径 console.log(ctx.method, ctx.path); // 获取客户端 IP const ip = ctx.request.ip;});响应相关属性有哪些?和请求类似,响应也有便捷访问和完整访问两种方式。便捷访问(直接通过 ctx):ctx.body — 响应体,支持字符串、Buffer、Stream、Object(自动序列化为 JSON)ctx.status — HTTP 状态码ctx.type — 响应的 Content-Typectx.redirect(url) — 重定向到指定 URL完整访问(通过 ctx.response):ctx.response.header — 响应头对象ctx.response.length — 响应 Content-Lengthctx.response.lastModified — Last-Modified 时间戳ctx.response.etag — ETag 值设置 ctx.body 时有一些细节值得注意:如果 body 是一个对象,Koa 会自动设置 Content-Type 为 application/json;如果 body 是字符串,则默认为 text/plain。你也可以通过 ctx.type 手动覆盖。app.use(async (ctx) => { // 返回 JSON ctx.body = { code: 0, data: { list: [] } }; // 返回 HTML ctx.type = 'html'; ctx.body = '<h1>Hello</h1>'; // 设置状态码后重定向 ctx.status = 302; ctx.redirect('/login');});ctx.state 有什么用?ctx.state 是 Koa 官方推荐的命名空间,用于在中间件之间传递数据。它的设计初衷是避免在 ctx 上随意挂载属性导致命名冲突。// 认证中间件app.use(async (ctx, next) => { const token = ctx.header.authorization; if (token) { ctx.state.user = verifyToken(token); // 将用户信息挂到 state 上 } await next();});// 业务中间件app.use(async (ctx) => { const user = ctx.state.user; // 从 state 取出用户信息 ctx.body = { name: user.name };});这个模式在实际项目中非常常见。除了用户信息,你还可以用它存储请求 ID、权限标识、分页参数等中间件间需要共享的数据。ctx.cookies 怎么操作?Koa 内置了 Cookie 操作能力,不需要额外安装中间件。ctx.cookies 提供了 get 和 set 两个方法。app.use(async (ctx) => { // 读取 Cookie const sessionId = ctx.cookies.get('sid'); // 设置 Cookie ctx.cookies.set('sid', 'abc123', { maxAge: 86400000, // 有效期 1 天,单位毫秒 httpOnly: true, // 禁止 JS 访问,防止 XSS secure: true, // 仅 HTTPS 传输 sameSite: 'lax', // 防止 CSRF });});设置 Cookie 时,httpOnly 和 sameSite 是两个安全相关的选项,生产环境中建议始终配置。maxAge 比 expires 更常用,因为它指定的是相对时间,不受时区影响。ctx.throw 和 ctx.assert 怎么用?Koa 提供了两种错误处理方式:ctx.throw() 和 ctx.assert()。ctx.throw() 用于主动抛出 HTTP 错误:app.use(async (ctx) => { const user = await findUser(ctx.query.id); if (!user) { ctx.throw(404, '用户不存在'); }});ctx.assert() 是 ctx.throw() 的断言封装,条件为 false 时抛出错误:app.use(async (ctx) => { ctx.assert(ctx.query.id, 400, '缺少用户 ID'); ctx.assert(ctx.state.user, 401, '未登录');});两种方式抛出的错误都会被 Koa 的错误事件捕获,你可以在 app.on('error') 中统一处理日志记录和监控上报。相比之下,ctx.assert() 的写法更简洁,适合做参数校验。ctx.app 是什么?ctx.app 是当前 Koa 应用实例的引用。通过它可以访问应用级别的配置和回调,比如 ctx.app.env 获取运行环境、ctx.app.proxy 判断是否信任代理头等。日常开发中用得不多,但在编写通用中间件时偶尔需要。ctx.req 和 ctx.res 与 ctx.request 和 ctx.response 有什么区别?这是初学者容易混淆的一对概念:ctx.req / ctx.res — Node.js 原生的 http 模块请求和响应对象,功能原始,不经过 Koa 封装ctx.request / ctx.response — Koa 封装后的对象,提供了更友好的 API除非你需要操作一些 Koa 没有封装的底层功能(比如 ctx.res.writeHead()),否则应始终优先使用 ctx.request 和 ctx.response。直接操作 ctx.res 可能会绕过 Koa 的中间件机制,导致响应处理逻辑失效。实际项目中的 ctx 使用模式了解了各个属性之后,更重要的是知道在实际项目中如何组织 ctx 的使用。以下是一个典型的中间件链中 ctx 的流转过程:const Koa = require('koa');const app = new Koa();// 请求日志中间件app.use(async (ctx, next) => { const start = Date.now(); await next(); const duration = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${duration}ms`);});// 认证中间件app.use(async (ctx, next) => { const token = ctx.header.authorization; ctx.assert(token, 401, '缺少认证信息'); ctx.state.user = verifyToken(token); await next();});// 业务路由app.use(async (ctx) => { const { page = 1, size = 10 } = ctx.query; const list = await getList(ctx.state.user.id, page, size); ctx.status = 200; ctx.body = { code: 0, data: { list, total: list.length } };});app.listen(3000);这个例子展示了 ctx 在整个请求生命周期中的角色:从日志中间件读取 method 和 url,到认证中间件校验 header 和写入 state,再到业务层读取 query 和设置 body,ctx 始终是贯穿所有中间件的数据枢纽。
服务端阅读 05月27日 18:26

Logstash 有哪些常用的插件,如何安装和管理插件?

Logstash 的强大之处在于它的插件体系——输入、过滤、输出三大类插件覆盖了从数据采集到写入目标的全链路。面试中经常问到"Logstash 有哪些常用插件""怎么安装和管理插件",下面结合实际使用场景梳理清楚。Input 插件:数据从哪里来?Input 插件决定 Logstash 从哪个数据源读取数据。选对 Input 插件是搭建 Pipeline 的第一步。file —— 读日志文件file 是最基础的 Input 插件,行为类似 tail -f,持续读取文件新增内容:input { file { path => "/var/log/nginx/access.log" start_position => "beginning" sincedb_path => "/var/lib/logstash/sincedb" }}几个关键参数:start_position:首次读取时从文件开头(beginning)还是末尾(end)开始,默认 endsincedb_path:记录已读取位置的文件路径,重启后从断点续读;设为 /dev/null 则每次从头读mode:默认 tail 模式持续追踪,设为 read 则读完即退出beats —— 接收 Beats 数据Beats 是 Elastic 官方的轻量采集器家族(Filebeat、Metricbeat 等),beats 插件是 Logstash 与 Beats 配合的标准方式:input { beats { port => 5044 }}生产环境中,Beats 负责在各服务器上采集数据,再统一发送到 Logstash 做集中处理,这是 ELK 架构中最常见的组合。kafka —— 从 Kafka 消费消息当数据量大、需要缓冲或多个消费者协同工作时,Kafka 是首选的中间层:input { kafka { bootstrap_servers => "kafka-broker1:9092,kafka-broker2:9092" topics => ["app-logs", "business-events"] group_id => "logstash-consumer" consumer_threads => 4 }}其他常用 Input 插件| 插件 | 典型场景 ||------|---------|| jdbc | 定时从关系型数据库拉取增量数据 || http | 对外暴露 HTTP 接口,接收外部系统推送的数据 || tcp / udp | 接收网络协议数据,常用于收集 syslog || syslog | 专门解析 syslog 格式日志 || redis | 从 Redis List 或 Channel 读取数据 || elasticsearch | 从 ES 中查询数据做二次处理 || s3 | 从 AWS S3 桶读取归档日志 |Filter 插件:数据怎么加工?Filter 插件负责把非结构化的原始数据转换成结构化、可搜索的字段。这是 Logstash 最核心的能力。grok —— 解析非结构化日志grok 是使用频率最高的 Filter 插件,通过正则表达式模式把文本拆解成字段:filter { grok { match => { "message" => "%{IP:client_ip} %{WORD:method} %{URIPATH:request_uri} %{NUMBER:response_code:int} %{NUMBER:bytes:int}" } tag_on_failure => ["_grokparsefailure"] }}关键点:Logstash 内置了大量命名模式(如 IP、HOSTNAME、COMBINEDAPACHELOG),优先使用内置模式tag_on_failure:匹配失败时打上标签,方便后续排查未解析的日志性能瓶颈:grok 基于正则,匹配复杂模式时 CPU 开销大,大规模数据场景下可考虑用 dissect 替代mutate —— 字段操作mutate 用于对字段进行增删改查,是日常配置中使用最频繁的 Filter 之一:filter { mutate { rename => { "old_field" => "new_field" } remove_field => ["unused_field"] convert => { "response_code" => "integer" "latency" => "float" } gsub => [ "message", "\", "/" ] }}date —— 时间戳解析日志中的时间格式五花八门,date 插件负责将字符串时间解析为 Logstash 事件的时间戳:filter { date { match => ["log_time", "yyyy-MM-dd HH:mm:ss", "ISO8601"] target => "@timestamp" timezone => "Asia/Shanghai" }}注意:如果不确定时间格式,可以传多个模式数组,date 插件会依次尝试匹配。其他常用 Filter 插件| 插件 | 作用 ||------|------|| json | 解析 JSON 字符串为字段 || csv | 按分隔符拆分 CSV 格式数据 || geoip | 根据 IP 查询地理位置 || useragent | 解析浏览器 User-Agent || ruby | 用 Ruby 代码实现复杂逻辑(性能敏感场景慎用) || aggregate | 跨事件聚合,如关联同一个请求的多条日志 || dissect | 类似 grok 但基于固定分隔符,性能更好 || drop | 直接丢弃不需要的事件 || fingerprint | 给事件生成唯一标识 |Output 插件:数据送到哪里去?elasticsearch —— 写入 Elasticsearch这是最常用的 Output 插件,生产环境中几乎必用:output { elasticsearch { hosts => ["http://es-node1:9200", "http://es-node2:9200"] index => "app-logs-%{+YYYY.MM.dd}" template => "/etc/logstash/templates/es-template.json" action => "create" retry_on_conflict => 3 }}关键参数:index:支持按时间动态生成索引名,%{+YYYY.MM.dd} 按天分索引action:index(默认,覆盖写入)或 create(仅当文档不存在时写入,防止重复)retry_on_conflict:版本冲突时重试次数kafka —— 写入 Kafkaoutput { kafka { bootstrap_servers => "kafka-broker1:9092" topic_id => "processed-logs" codec => json compression_type => "snappy" }}其他常用 Output 插件| 插件 | 场景 ||------|------|| file | 写入本地文件 || http | 推送到外部 HTTP API || redis | 写入 Redis || stdout | 控制台输出,调试时常用 || email | 触发告警邮件 || s3 | 归档到 AWS S3 || mongodb | 写入 MongoDB |Codec 插件:数据的编解码Codec 插件常被忽略,但它影响着数据的序列化方式。常用的是 json 和 multiline:input { file { path => "/var/log/app.log" codec => multiline { pattern => "^%{TIMESTAMP_ISO8601}" negate => true what => "previous" } }}multiline 用于把 Java 堆栈信息等多行日志合并为一条事件,pattern 匹配新日志行的开头,what => "previous" 表示不匹配的行归入上一条事件。插件安装和管理Logstash 通过 bin/logstash-plugin 命令管理插件生命周期:查看已安装插件# 列出所有插件bin/logstash-plugin list# 带版本号bin/logstash-plugin list --verbose# 按分组查看bin/logstash-plugin list --group input# 模糊搜索bin/logstash-plugin list '*kafka*'安装插件# 从 RubyGems 安装bin/logstash-plugin install logstash-output-s3# 指定版本bin/logstash-plugin install logstash-output-s3 --version 10.0.0# 从本地 gem 文件安装bin/logstash-plugin install /path/to/logstash-output-custom-1.0.0.gem更新和卸载# 更新全部插件bin/logstash-plugin update# 更新指定插件bin/logstash-plugin update logstash-output-s3# 卸载插件(Logstash 7.x+ 中部分插件为集成插件,不可卸载)bin/logstash-plugin uninstall logstash-output-s3离线安装生产环境通常无法访问外网,需要制作离线安装包:# 在有网络的机器上生成离线包bin/logstash-plugin prepare-offline-pack logstash-output-s3# 在目标机器上安装bin/logstash-plugin install file:///path/to/logstash-offline-plugins.zip插件配置的实战经验条件判断让 Pipeline 更高效不同类型的日志走不同的 Filter 逻辑,避免无关插件浪费算力:filter { if [type] == "nginx-access" { grok { match => { "message" => "%{COMBINEDAPACHELOG}" } } geoip { source => "clientip" } } else if [type] == "app-error" { grok { match => { "message" => "%{TIMESTAMP_ISO8601:timestamp} %{LOGLEVEL:level} %{GREEDYDATA:msg}" } } if [level] == "ERROR" { mutate { add_field => { "alert" => "true" } } } }}grok 解析失败的兜底处理grok 匹配失败是线上常见问题,必须处理:filter { grok { match => { "message" => "%{PATTERN:field}" } tag_on_failure => ["_grokparsefailure"] } if "_grokparsefailure" in [tags] { mutate { add_field => { "parse_error" => "true" } } }}性能优化要点优先用 dissect 替代 grok:当日志格式固定时,dissect 基于分隔符拆分,性能比 grok 高出一个量级减少 mutate 调用次数:合并多个 mutate 操作到一个块内,减少事件反复处理慎用 ruby 插件:Ruby 代码执行效率远低于内置插件,只在无法用其他插件实现时才用合理配置 pipeline.workers 和 pipeline.batch.size:workers 数量通常设为 CPU 核心数,batch.size 根据事件大小调整(默认 125,大事件可适当调小)关注慢 Filter:在 logstash.yml 中开启 config.debug 和慢日志,定位瓶颈插件自定义插件开发当内置插件无法满足需求时(比如对接公司内部系统),就需要开发自定义插件。创建插件骨架gem install logstash-plugin-generatorlogstash-plugin generate --type input --name myinput生成的目录结构:logstash-input-myinput/├── lib/│ └── logstash/│ └── inputs/│ └── myinput.rb├── spec/│ └── inputs/│ └── myinput_spec.rb├── Gemfile├── logstash-input-myinput.gemspec└── README.md核心实现一个 Input 插件至少实现 register 和 run 两个方法:class LogStash::Inputs::Myinput < LogStash::Inputs::Base config_name "myinput" config :host, :validate => :string, :default => "0.0.0.0" config :port, :validate => :number, :required => true def register # 初始化资源,只在启动时调用一次 @server = TCPServer.new(@host, @port) end def run(queue) # 持续运行,产生事件后推入 queue loop do client = @server.accept while line = client.gets event = LogStash::Event.new("message" => line.chomp) decorate(event) queue << event end client.close end end def stop # 优雅关闭 @server.close if @server endend构建与安装gem build logstash-input-myinput.gemspecbin/logstash-plugin install logstash-input-myinput-1.0.0.gem编写测试require "logstash/devutils/rspec/spec_helper"require "logstash/inputs/myinput"describe LogStash::Inputs::Myinput do let(:config) { { "port" => 9999 } } it "registers without error" do input = described_class.new(config) expect { input.register }.not_to raise_error endend插件版本管理的注意事项集成插件不可卸载:Logstash 7.x 之后,Kafka、Beats 等常用插件被合并为集成插件(logstash-integration-kafka),无法通过 uninstall 移除锁定版本:在 Gemfile 中指定版本避免升级引入兼容问题:gem "logstash-output-s3", "~> 10.0"升级策略:bin/logstash-plugin update 默认只升级小版本和补丁版本,不会跨大版本升级,降低破坏性变更风险查看插件版本:bin/logstash-plugin list --verbose | grep 插件名掌握 Logstash 插件体系的关键在于理解 Input-Filter-Output 的数据流模型,以及每个插件在链路中的定位。日常使用中,grok 和 mutate 是最需要熟练掌握的 Filter 插件,elasticsearch output 是最核心的输出插件,而插件管理命令则保证你能灵活扩展和维护 Pipeline。
服务端阅读 05月27日 18:26

MariaDB 如何进行索引优化?有哪些索引类型和优化策略?

MariaDB 有哪些索引类型?各自的适用场景是什么?MariaDB 支持多种索引类型,理解它们的区别是做优化决策的基础。B-Tree 索引是 MariaDB 的默认索引结构,绝大多数场景下使用的都是它。B-Tree 采用平衡多路搜索树结构,叶子节点通过双向链表连接,天然支持等值查询、范围查询和排序操作。当你执行 WHERE id BETWEEN 100 AND 200 或 ORDER BY created_at DESC 时,B-Tree 可以高效地利用索引的有序性完成扫描,而不需要额外的文件排序(filesort)。哈希索引仅支持等值匹配查询,不支持范围查询和排序。它的查询时间复杂度接近 O(1),在精确查找场景下比 B-Tree 更快。MariaDB 中哈希索引主要用于 MEMORY 存储引擎的表,InnoDB 的自适应哈希索引(Adaptive Hash Index)是引擎内部自动维护的,不需要手动创建。面试中如果被问到"哈希索引为什么不能用于范围查询",核心原因是哈希值之间没有大小关系,无法利用有序性做区间扫描。全文索引(FULLTEXT)专门用于文本内容的模糊搜索,底层基于倒排索引实现。相比于 LIKE '%关键词%' 会导致全表扫描,全文索引可以快速定位包含目标词的记录。MariaDB 支持 MATCH ... AGAINST 语法,提供自然语言模式和布尔模式两种查询方式。需要注意的是,全文索引对中文分词的支持有限,通常需要借助 ngram 解析器或 Mroonga 引擎来处理中文场景。空间索引(SPATIAL)用于地理空间数据类型的索引,底层基于 R-Tree 结构。适合处理点、线、多边形等 GIS 数据的空间关系查询,比如"查找某坐标 5 公里范围内的门店"。空间索引仅支持 InnoDB 和 MyISAM 引擎,且索引列必须声明为 NOT NULL。聚簇索引不是一个独立的索引类型,而是 InnoDB 的数据组织方式。InnoDB 的主键索引就是聚簇索引——叶子节点直接存储完整的行数据,而非主键索引(二级索引)的叶子节点存储的是主键值。这意味着通过二级索引查找数据时,需要先查到主键值,再回表查询完整行数据,这个过程叫做"回表"。理解聚簇索引和回表机制,是掌握覆盖索引优化前提。创建索引时应该遵循哪些设计原则?索引不是越多越好。每多一个索引,INSERT/UPDATE/DELETE 就多一份维护成本,同时占用额外的磁盘空间。设计索引时需要把握几个关键原则。优先对高选择性列建索引。 选择性指的是列中不同值的数量与总行数的比值。选择性越高,索引过滤效果越好。例如用户表的 email 列选择性接近 1,几乎每条记录值都不同,索引过滤效率极高;而性别列只有两三个值,选择性极低,索引对查询的帮助微乎其微,优化器大概率会选择全表扫描。一个经验阈值是:当某值占比超过全表的 20% 时,优化器通常放弃使用索引。复合索引要遵循最左前缀原则。 对于索引 idx_abc(a, b, c),查询条件用到了 a、(a,b)、(a,b,c) 都能命中索引,但只用 b 或 c 则无法使用。实际设计中,应该把等值查询的列放在前面,范围查询的列放在后面。例如 WHERE status = 1 AND created_at > '2024-01-01',应建索引 (status, created_at) 而非 (created_at, status),因为等值过滤在前可以大幅缩小范围查询的扫描区间。利用覆盖索引减少回表。 如果查询需要的所有列都包含在索引中,InnoDB 直接从索引返回数据,无需回表读取行记录。例如 SELECT id, name FROM users WHERE name = 'John',如果 name 列上有索引且索引包含了 id(InnoDB 二级索引自动包含主键),这就是一次覆盖索引扫描。在 EXPLAIN 输出中,覆盖索引的 Extra 列会显示 Using index。避免在索引列上使用函数或表达式。 WHERE YEAR(created_at) = 2024 会导致索引失效,因为 MariaDB 需要对每一行计算函数值后才能比较。正确的写法是 WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01',这样优化器可以利用 B-Tree 的有序性做范围扫描。如何使用 EXPLAIN 分析查询的索引使用情况?EXPLAIN 是索引优化的核心工具,它展示优化器为查询选择的执行计划。EXPLAIN SELECT u.name, o.totalFROM users uJOIN orders o ON u.id = o.user_idWHERE u.status = 1 AND o.created_at > '2024-01-01';重点关注以下几个字段:type:访问类型,从好到差依次为 system > const > eq_ref > ref > range > index > ALL。出现 ALL 意味着全表扫描,需要重点优化。ref 表示使用索引等值匹配,range 表示索引范围扫描。key:实际使用的索引名称。如果显示 NULL,说明没有可用索引。rows:预估扫描行数。这个值越小越好,但它是基于统计信息的估算值,不一定精确。Extra:额外信息。Using index 表示覆盖索引,Using filesort 表示需要额外排序,Using temporary 表示使用了临时表,后两者通常意味着性能瓶颈。一个实用的工作流是:先跑 EXPLAIN 看执行计划,发现 type 为 ALL 或 rows 过大时,针对性地添加或调整索引,再反复验证。什么是索引失效?哪些常见写法会导致索引失效?索引失效指的是查询条件虽然涉及了索引列,但优化器最终选择不使用索引而做全表扫描。以下几种写法是常见的索引失效陷阱。对索引列使用函数或运算: WHERE name LIKE '%John'(左模糊)、WHERE YEAR(date_col) = 2024、WHERE id + 1 = 100,这些写法破坏了 B-Tree 的有序性,优化器无法利用索引定位。隐式类型转换: 当列是 VARCHAR 类型,查询条件写成 WHERE phone = 13800001111(数字类型),MariaDB 会将列值转为数字再做比较,这相当于对列施加了隐式函数,导致索引失效。正确写法是 WHERE phone = '13800001111'。OR 条件连接不同索引列: WHERE name = 'John' OR age = 25,如果 name 和 age 各有独立索引,MariaDB 在某些情况下可以使用 Index Merge 优化,但效果往往不如预期,不如改写为 UNION ALL 两个子查询。NOT IN、NOT EXISTS、!=、: 这些否定条件可能导致索引失效,尤其是结果集占比较大时。但并非绝对——如果否定条件过滤性很强(排除的值很少),优化器仍可能选择索引。索引列 IS NULL: 在 MariaDB 中,B-Tree 索引是包含 NULL 值的,WHERE col IS NULL 可以使用索引。这一点与 Oracle 等数据库不同,面试中注意区分。如何通过覆盖索引和索引下推提升查询性能?覆盖索引在前文已经提到,核心思路是让查询所需的所有列都在索引中,从而避免回表。实际应用中,可以通过 SELECT 指定列或将常用查询列加入复合索引来实现。-- 订单列表查询,只需要 id、status、created_atSELECT id, status, created_at FROM orders WHERE user_id = 100;-- 建立覆盖索引 (user_id, status, created_at)-- InnoDB 二级索引自动包含主键 id,因此这三列 + id 都在索引中ALTER TABLE orders ADD INDEX idx_user_status_created(user_id, status, created_at);索引下推(Index Condition Pushdown,ICP) 是 MariaDB 5.6+ 引入的优化。传统流程中,二级索引查到主键后必须回表才能判断 WHERE 中的其他条件;启用 ICP 后,存储引擎在索引扫描阶段就根据 WHERE 条件过滤,减少回表次数。-- 假设有索引 (last_name, first_name)SELECT * FROM users WHERE last_name = 'Smith' AND first_name LIKE '%ohn';-- 没有 ICP:先通过 last_name 索引查到所有 Smith 的主键,逐个回表再过滤 first_name-- 有 ICP:在索引扫描时直接对 first_name 做 LIKE 判断,不满足的跳过,减少回表在 EXPLAIN 的 Extra 列中,ICP 会显示 Using index condition。ICP 的适用条件是:查询使用了复合索引,且 WHERE 中有索引前列的等值条件加上后续列的条件过滤。如何监控和维护索引的健康状态?索引不是建完就一劳永逸的,随着数据增删改,索引可能出现碎片化、统计信息过期等问题,需要定期维护。-- 查看表的索引信息SHOW INDEX FROM users;-- 查看索引统计信息SELECT INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, CARDINALITYFROM information_schema.STATISTICSWHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'users';-- 更新表的统计信息(不锁表)ANALYZE TABLE users;-- 重建表以消除碎片(会锁表)OPTIMIZE TABLE users;识别无用索引: 可以通过 sys.schema_unused_indexes 视图(MariaDB 10.6+)或开启 userstat 插件来追踪索引使用情况。长期未使用的索引应该清理,减少写入开销。监控索引碎片: 频繁的增删改会导致索引页出现空洞,降低索引扫描效率。OPTIMIZE TABLE 会重建表和索引,消除碎片,但操作期间会锁表,建议在低峰期执行。对于大表,可以考虑使用 ALTER TABLE ... ENGINE=InnoDB 的方式在线重建。统计信息维护: 优化器依赖统计信息(cardinality、rows 等)来选择执行计划。如果统计信息严重失真,可能导致优化器选错索引。定期执行 ANALYZE TABLE 可以刷新统计信息,且在 MariaDB 10.4+ 中该操作是在线进行的,不会阻塞读写。MariaDB 索引优化有哪些常见的实战案例?案例一:分页查询优化深分页是典型的性能杀手。SELECT * FROM orders ORDER BY id LIMIT 100000, 10 需要先扫描 100010 行再丢弃前 100000 行。-- 方案一:游标分页(推荐)-- 前端记录上一页最后一条的 id,下一页查询时带上SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;-- 方案二:延迟关联-- 先通过子查询在索引上定位 id,再回表取数据SELECT o.* FROM orders oJOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 10) tON o.id = t.id;案例二:多条件组合查询SELECT * FROM products WHERE category_id = 5 AND status = 1 AND price BETWEEN 100 AND 500 ORDER BY sales_count DESC LIMIT 20;索引设计:(category_id, status, sales_count)。前两列做等值过滤缩小范围,第三列利用索引有序性避免 filesort。price 列的范围查询放在最后处理。案例三:大表 JOIN 优化SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.idWHERE o.status = 2 AND u.region = 'CN';确保 JOIN 条件列(user_id、id)有索引,同时 orders 表在 status 上建索引、users 表在 region 上建索引,使驱动表的过滤结果尽可能小,减少循环 JOIN 的次数。掌握以上索引类型、设计原则、分析工具和实战技巧,基本能应对 MariaDB 索引优化的大部分面试问题和线上场景。核心思路始终是:用 EXPLAIN 验证,让索引覆盖查询,减少回表和全表扫描。
前端阅读 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:24

MariaDB 的 JSON 函数怎么用?有哪些常见坑?

MariaDB 从 10.2 开始提供了一套 JSON 函数,能直接在 SQL 里创建、查询、修改和校验 JSON 数据。JSON 列本质是 LONGTEXT 加 CHECK 约束,不是 MySQL 那种二进制格式,这一点在迁移时容易踩坑。追问JSONEXTRACT、JSONVALUE、JSON_QUERY 有什么区别?三个都是取值,但返回类型不同:| 函数 | 返回值 | 示例 ||------|--------|------|| JSONEXTRACT | 原始 JSON 片段(带引号) | "John" || JSONVALUE | 标量值(去引号) | John || JSON_QUERY | 对象或数组 | {"city":"NY"} |日常取字符串值用 -> 操作符(JSONEXTRACT 的语法糖),取标量用 JSONVALUE,取嵌套对象用 JSON_QUERY。JSONSET、JSONINSERT、JSON_REPLACE 有什么区别?JSON_SET:存在则更新,不存在则插入——万能选手JSON_INSERT:只在路径不存在时插入,已有值不动JSON_REPLACE:只在路径已存在时替换,没找到就跳过记住一句话:不确定用 SET,只想加新字段用 INSERT,只想改旧字段用 REPLACE。JSON 列怎么加索引?JSON 列不能直接建普通索引。两种方式:生成列 + 索引(推荐):ALTER TABLE products ADD COLUMN brand VARCHAR(50) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.brand'))) STORED, ADD INDEX idx_brand(brand);函数索引(MariaDB 10.3+):CREATE INDEX idx_brand ON products((CAST(attributes->'$.brand' AS CHAR(50))));MariaDB 的 JSON 和 MySQL 的 JSON 有什么区别?这是迁移时最容易翻车的地方:| 对比项 | MariaDB | MySQL ||--------|---------|-------|| 存储格式 | 原文 LONGTEXT | 二进制 JSON || JSON 类型 | LONGTEXT 的别名 | 独立数据类型 || 自动校验 | 需要 CHECK 约束 | 内置校验 || 部分更新 | 不支持 | 支持二进制增量更新 |MariaDB 存原文的好处是可以直接用文本函数处理,坏处是每次修改整个字段重写,大 JSON 字段更新性能差。用 JSON 列存数据有什么坑?没有 schema 约束:同列不同行结构可以完全不同,查出来才知道长什么样,排查问题靠蒙查询性能:每次取值都要解析 JSON,高频查询字段务必抽成普通列加索引更新代价:改一个字段整个 JSON 重写,大文档更新慢CHECK 约束别忘了加:CREATE TABLE products ( id INT PRIMARY KEY, attrs JSON, CONSTRAINT chk_json CHECK (JSON_VALID(attrs)));写段代码-- 建表 + 插入 + 查询一条龙CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100), attrs JSON CHECK (JSON_VALID(attrs)));INSERT INTO products (name, attrs) VALUES ('Laptop', '{"brand":"Dell","ram":"16GB"}');-- 查品牌、改内存SELECT name, attrs->'$.brand' AS brand FROM products;UPDATE products SET attrs = JSON_SET(attrs, '$.ram', '32GB') WHERE id = 1;
服务端阅读 05月27日 18:24

Logstash 性能怎么调?从瓶颈定位到参数优化的实战方案

Logstash 吞吐量上不去,CPU 打满却处理不完日志,这类问题在生产环境里太常见了。很多团队第一反应是加机器,但多数情况下调对参数就能让现有资源发挥出两三倍的吞吐。这篇文章从实际踩坑经验出发,讲清楚 Logstash 性能瓶颈怎么定位、各参数怎么调、调了之后有什么效果。读完你会知道:什么时候该调 pipeline.workers,什么时候该加 Kafka 缓冲,G1GC 到底有没有用,以及那些看起来合理但实际拖慢速度的配置。先定位瓶颈再动手调优最忌讳盲目改参数。动手之前,先用 Logstash 自带的监控 API 看清楚瓶颈在哪:curl -s localhost:9600/_node/stats/pipelines | jq '.pipelines.main'重点关注这几个指标:| 指标 | 含义 | 健康范围 ||------|------|----------|| events.in | 每秒摄入事件数 | 接近输入源速率 || events.out | 每秒输出事件数 | 与 events.in 基本持平 || events.filtered | 过滤后事件数 | 合理的过滤率 || pipeline.workers 活跃数 | 当前工作线程 | 等于配置值 || queue.type | 队列类型 | memory 或 persisted |如果 events.in 远大于 events.out,说明处理速度跟不上摄入速度,瓶颈在 filter 或 output。如果 CPU 使用率低但吞吐上不去,问题可能出在 I/O 等待或网络延迟上。JVM 调优:堆内存和 GC 怎么配Logstash 跑在 JVM 上,内存配置直接影响性能。在 config/jvm.options 里调整:-Xms4g-Xmx4g-XX:+UseG1GC堆内存设置原则:Xms 和 Xmx 设成一样的值。动态扩缩容会触发 Full GC,导致处理暂停,日志管道会短暂卡顿堆内存不要超过物理内存的 50%。Logstash 本身还需要堆外内存做缓冲区和网络 I/O,堆太大会让操作系统可用内存不足,反而触发 swap大多数场景 4-8GB 就够了。超过 8GB 不一定更好——堆越大,GC 扫描的时间越长,G1GC 在 4-8GB 区间表现最好G1GC 是否值得开? 实测下来,G1GC 相比默认的 Parallel GC,在堆内存 4GB 以上时 Full GC 暂停时间从秒级降到百毫秒级。但如果堆只有 2GB,G1GC 的分区管理开销反而可能让吞吐量下降 5%-10%。所以:4GB 以上开 G1GC,2GB 以下用默认的就行。一个容易踩的坑:如果日志里有大量 Grok 解析失败,会产生异常对象堆积在堆里。这时候调大堆只是延缓问题,根本办法是修 Grok 模式或用 if 条件跳过不需要解析的日志。Pipeline 参数:workers、batch size、delay 怎么平衡这三个参数互相影响,单独调一个往往看不到效果。pipeline.workers# logstash.ymlpipeline.workers: 4这是处理事件的线程数。默认值是 CPU 核心数,但有个前提:你的 filter 和 output 插件必须是线程安全的。大多数官方插件没问题,但自定义插件需要确认。实际调法:先设成 CPU 核心数跑基准测试,然后分别试 核心数/2 和 核心数*2,看哪个 EPS 最高。经验上,filter 重(大量 Grok 正则)的场景设成核心数就行,filter 轻但 output 重(往 ES 写入)的场景可以适当加倍。pipeline.batch.sizepipeline.batch.size: 125每个 worker 一次拿多少事件来处理。默认 125 是个保守值。增大 batch size 能减少事件调度开销,提高吞吐量:高吞吐场景(日志量 > 10万/分钟):调到 500-1000低延迟场景(实时告警):保持 125 或更小batch size 不是越大越好。过大的 batch 会导致单个批次处理时间变长,增加事件从进入到输出端的端到端延迟。而且如果 filter 里有 Grok 失败的情况,大 batch 会让重试开销放大。pipeline.batch.delaypipeline.batch.delay: 50worker 等待多久凑够一个 batch 再开始处理,单位毫秒。默认 50ms。这个参数的意义是:当事件流入速度不够快时,等一等能凑满 batch,减少处理次数。事件流入速度很快:delay 可以降到 10-20ms,减少等待事件流入速度慢但实时性要求高:降到 5ms 甚至 1ms事件流入速度慢且不要求实时:保持 50ms,省 CPU三者联动经验:高吞吐场景用 workers=核心数, batch.size=500, batch.delay=10;低延迟场景用 workers=4, batch.size=50, batch.delay=5。改一个参数时保持其他两个不变,观察 EPS 变化,找到拐点。Filter 优化:减少无用功Filter 是 Logstash 最容易成为瓶颈的环节,尤其是 Grok。Grok 是性能杀手Grok 底层是正则表达式,每条日志都要跑一遍模式匹配。优化 Grok 的方法:用 if 条件跳过不需要 Grok 的日志:filter { if [type] == "nginx_access" { grok { match => { "message" => "%{COMBINEDAPACHELOG}" } } }}这看起来简单,但实际效果可能比调参数还明显。一条不需要 Grok 的日志跳过正则匹配,省下的是毫秒级的 CPU 时间。自定义模式比组合内置模式快:内置的 COMBINEDAPACHELOG 实际上是多个小模式拼接的,每次匹配都要逐个尝试。写成一条自定义模式能减少匹配次数:# 自定义模式文件NGINX_ACCESS %{IPORHOST:clientip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:timestamp}\] "%{WORD:method} %{URIPATHPARAM:request} HTTP/%{NUMBER:httpversion}" %{NUMBER:response} %{NUMBER:bytes}把最常匹配的模式放前面:Grok 是按顺序尝试匹配的,最可能的模式放第一个能最快命中。用 dissect 替代简单格式的 Grok:如果日志格式是固定的分隔符(如管道符、逗号分隔),dissect 插件用分隔符切分,比正则匹配快 5-10 倍:filter { dissect { mapping => { "message" => "%{ip} | %{user} | %{action}" } }}其他 Filter 优化mutate 的 remove_field:尽早删掉不需要的字段,减少后续处理的数据量用 drop 过滤器丢弃无用事件:在 filter 链最前面丢弃,比处理完再丢省很多资源避免重复解析:如果上游已经做过 JSON 解析,不要再用 json filter 再解析一遍Output 优化:往 ES 写数据的讲究ES output 是最常见的瓶颈之一。批量写入参数output { elasticsearch { hosts => ["http://es-cluster:9200"] http_compression => true }}注意:旧版本的 flush_size 和 idle_flush_time 参数已经在 7.x 之后废弃,改由 pipeline 的 batch size 和 batch delay 统一控制。如果你还在用这两个参数,升级后删掉,否则会有告警。http_compression => true 这个一定要开。压缩后网络传输量减少 60%-80%,对跨机房写入场景效果尤其明显,CPU 开销可以忽略。连接池调优如果 ES 集群有多个节点,Logstash 会自动轮询写入。但默认连接池大小可能不够,高并发场景下可以在 ES output 里显式配置:output { elasticsearch { hosts => ["http://es-node1:9200", "http://es-node2:9200", "http://es-node3:9200"] http_compression => true # 新版本支持的批量操作参数 action => "index" }}持久队列:防数据丢失的最后防线# logstash.ymlqueue.type: persistedpath.queue: /data/logstash/queuequeue.page_capacity: 250mbqueue.max_events: 0queue.max_bytes: 4gb持久队列把事件写到磁盘,Logstash 重启或崩溃时不丢数据。代价是吞吐量下降 10%-20%,因为每次事件要写磁盘。什么场景该开持久队列:数据不能丢(金融日志、审计日志)下游 ES 不稳定,偶尔写入失败Logstash 重启频繁什么场景可以不开:日志允许少量丢失(纯分析用途的访问日志)下游写入非常稳定对吞吐量有极致要求架构层面:加缓冲和水平扩展单机调优总有上限。当一台 Logstash 处理不过来,架构上的调整比继续压单机更有效。加 Kafka 缓冲Filebeat → Kafka → Logstash → ElasticsearchKafka 在中间起两个作用:一是缓冲突发流量,Logstash 处理不过来时 Kafka 先存着;二是解耦,上游采集和下游处理互不影响。Kafka 场景下的 Logstash 配置要点:input { kafka { bootstrap_servers => "kafka1:9092,kafka2:9092" topics => ["nginx-logs"] group_id => "logstash-nginx" consumer_threads => 4 auto_offset_reset => "earliest" }}consumer_threads 建议设成 Kafka 分区数。如果分区数是 12,设 12 个 consumer 线程能充分利用并行消费。多实例水平扩展起多个 Logstash 实例,用负载均衡或 Kafka consumer group 分流:如果输入源是 Kafka:每个 Logstash 实例配相同的 group_id,Kafka 自动分配分区给不同实例如果输入源是 Beats:在 Beats 和 Logstash 之间加一层 Nginx 或 HAProxy 做 TCP 负载均衡用 Beats 替代 Logstash 做采集Filebeat、Metricbeat 比 Logstash 轻量得多,资源占用大约是 Logstash 的 1/10。架构上让 Beats 做采集、Logstash 做处理,比让 Logstash 又采集又处理高效得多。怎么验证优化效果每次只改一个参数,跑一轮基准测试对比。用 Logstash 自带的 generator 输入插件做压测:input { generator { lines => ["192.168.1.1 - - [10/Oct/2023:13:55:36 +0000] \"GET /api/users HTTP/1.1\" 200 2326"] count => 1000000 }}filter { grok { match => { "message" => "%{COMBINEDAPACHELOG}" } }}output { stdout { codec => dots }}跑完后看输出的时间,算出 EPS(每秒处理事件数)。把这个值作为基准,改一个参数再跑,对比变化。生产环境监控关键指标:events.in 和 events.out 的差值(积压量)、JVM 堆使用率、GC 频率和耗时。如果堆使用率持续超过 75% 或 Full GC 频率超过每分钟一次,说明要么堆太小,要么 filter 有内存泄漏。调优没有一劳永逸的方案。日志格式变了、流量模式变了、ES 集群扩容了,都可能让之前的调优配置不再最优。养成定期看监控、定期跑基准测试的习惯,比任何单次调优都重要。
前端阅读 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

Logstash 有哪些常用过滤器?Grok 和 Mutate 怎么用?

Grok 过滤器:把非结构化日志变成结构化数据Grok 是 Logstash 中使用频率最高的过滤器,核心能力是将一行纯文本日志拆解成有名字段的 JSON。它的底层原理是基于正则表达式的模式匹配,但 Elastic 已经预置了大量常用模式,日常使用不需要手写正则。基本匹配最典型的场景是解析 Apache/Nginx 访问日志。COMBINEDAPACHELOG 是内置模式,一条配置就能提取 clientip、response_code、bytes 等十几个字段:filter { grok { match => { "message" => "%{COMBINEDAPACHELOG}" } }}匹配成功后,原来 message 字段中的一整行日志会被拆成 clientip、ident、auth、timestamp、verb、request、httpversion、response、bytes 等独立字段,后续过滤器和输出插件都能直接引用。多模式备选实际生产中,日志格式往往不止一种。Grok 支持传入一个模式数组,按顺序依次尝试匹配,命中的第一个生效:filter { grok { match => { "message" => [ "%{COMBINEDAPACHELOG}", "%{COMMONAPACHELOG}", "%{SYSLOGBASE} %{GREEDYDATA:message}" ] } }}这种方式比写一个超长的"万能正则"更易维护,哪条模式匹配了也更容易排查。自定义模式当内置模式无法满足需求时,可以在外部文件中定义自己的模式。模式文件的语法是 PATTERN_NAME regex,一行一个:filter { grok { patterns_dir => ["/etc/logstash/patterns"] match => { "message" => "%{MYAPP_LOG:myapp_field}" } }}对应的 /etc/logstash/patterns/myapp 文件内容示例:MYAPP_LOG \[%{TIMESTAMP_ISO8601:timestamp}\] \[%{LOGLEVEL:level}\] %{GREEDYDATA:msg}Grok 匹配失败怎么办Grok 匹配失败时,Logstash 会自动给事件打上 _grokparsefailure 标签。在生产环境中,应该用条件判断捕获这些失败事件,避免脏数据静默进入 Elasticsearch:filter { grok { match => { "message" => "%{COMBINEDAPACHELOG}" } } if "_grokparsefailure" in [tags] { mutate { add_field => { "parse_error" => "grok failed for message" } } }}也可以借助 Kibana 自带的 Grok Debugger 工具调试模式,避免反复重启 Logstash。Mutate 过滤器:字段级别的加工工具Mutate 是"万能修理工",几乎所有的字段增删改操作都能靠它完成。它不关心数据来源,只对已有字段做变换。重命名字段从 Beats 或其他输入源拿到的字段名不符合规范时,用 rename 统一命名:filter { mutate { rename => { "client_ip" => "source_ip" } }}类型转换Grok 解析出来的字段默认都是字符串类型,想做数值聚合或范围查询,必须先转换类型:filter { mutate { convert => { "response" => "integer" "request_time" => "float" } }}这一步经常被忽略,导致 Elasticsearch 中所有字段都是 keyword 类型,数值范围查询直接失效。删除无用字段每个事件默认携带 message、@version、host 等字段。如果已经用 Grok 把 message 拆成了独立字段,原始 message 留着只会浪费存储:filter { mutate { remove_field => ["message", "@version", "host"] }}替换和追加字段replace 会覆盖已有字段或新建字段,add_field 则是在原有字段基础上追加:filter { mutate { replace => { "log_source" => "production-nginx" } add_field => { "environment" => "prod" "pipeline" => "nginx-access" } }}gsub:正则替换字段内容Mutate 自带 gsub 操作,可以对字段值做正则替换,不需要动用 Ruby 过滤器:filter { mutate { gsub => [ "request", "\\?.+$", "" ] }}这会把 /api/users?page=1&size=10 替换为 /api/users,去掉查询参数部分,便于按路径做聚合统计。大小写转换与分割filter { mutate { uppercase => ["log_level"] split => { "tags" => "," } }}uppercase 将字段值转为大写,split 按指定分隔符将字符串拆成数组。这两个操作在数据规范化场景中很常用。Mutate 各操作的执行顺序Mutate 内部有固定的操作执行顺序,与你在配置中写的顺序无关:rename → copy → gsub → uppercase/lowercase → strip → replace → join → split → merge → coerce → convert → add_field → remove_field。如果 rename 在 convert 之后才生效,可能让类型转换的目标字段名对不上。遇到这类问题时,可以拆成两个 mutate 块来控制顺序:filter { mutate { rename => { "resp_code" => "response" } } mutate { convert => { "response" => "integer" } }}Date 过滤器:统一时间戳格式Logstash 用 @timestamp 作为事件的时间基准,但原始日志中的时间格式千差万别。Date 过滤器的作用就是把各种格式的时间字符串解析成 Logstash 内部的 ISO8601 时间对象。解析多种时间格式filter { date { match => [ "timestamp", "dd/MMM/yyyy:HH:mm:ss Z", "yyyy-MM-dd HH:mm:ss", "ISO8601" ] }}match 的第二个参数是格式数组,Joda-Time 格式和 ISO8601 关键字都可以混用。解析成功后,@timestamp 自动更新。指定时区和目标字段filter { date { match => ["log_time", "yyyy-MM-dd HH:mm:ss"] timezone => "Asia/Shanghai" target => "parsed_time" }}如果不指定 target,默认写入 @timestamp。如果只想保留解析结果但不动 @timestamp,就指定一个自定义的 target 字段。GeoIP 过滤器:IP 地址转地理位置GeoIP 根据 IP 地址查询 MaxMind 数据库,自动补充城市、国家、经纬度等地理信息,是做访问地图可视化的前提。filter { geoip { source => "client_ip" target => "geo" fields => ["city_name", "country_name", "location"] }}source 指定待查询的 IP 字段,fields 限制只输出需要的地理字段,避免写入过多无用数据。注意 Logstash 默认内置了 GeoLite2 数据库,但如果需要更精确的数据,需要手动下载并指定 database 路径。JSON 过滤器:解析嵌套 JSON 日志现代应用的日志越来越倾向于直接输出 JSON 格式,JSON 过滤器可以把它展开成 Logstash 的事件字段:filter { json { source => "message" target => "parsed" }}如果指定了 target,解析结果会放在 target 字段下形成嵌套结构;不指定则直接铺平到顶层。生产中建议指定 target,避免字段名冲突。解析后通常配合 mutate 删除原始 message 字段:filter { json { source => "message" target => "log" } mutate { remove_field => ["message"] }}Useragent 过滤器:解析浏览器信息从 HTTP 请求的 User-Agent 头中提取浏览器名称、版本、操作系统等信息:filter { useragent { source => "agent" target => "ua" }}通常跟在 Grok 解析 Apache 日志之后使用,agent 字段就是 Grok 从日志中提取出来的 User-Agent 字符串。CSV 过滤器:处理表格数据CSV 过滤器用于解析逗号(或其他分隔符)分隔的文本数据:filter { csv { separator => "," columns => ["name", "age", "city"] autodetect_column_types => true }}columns 指定每列的字段名,autodetectcolumntypes 让 Logstash 自动识别数值类型。如果 CSV 首行是表头,也可以省略 columns 让它自动读取。Ruby 过滤器:处理复杂逻辑当内置过滤器无法满足需求时,Ruby 过滤器提供了完全的编程能力:filter { ruby { code => ' status = event.get("response").to_i if status >= 400 event.tag("error") event.set("error_level", status >= 500 ? "server_error" : "client_error") end ' }}Ruby 过滤器灵活但性能开销大,Grok 能搞定的事情不要用 Ruby。实际项目中,Ruby 过滤器多用于多字段联合计算、条件标签打标等场景。Drop 过滤器:丢弃不需要的事件Drop 过滤器直接丢弃整个事件,不会传到输出阶段。常见用法是过滤掉调试日志或特定来源的噪声数据:filter { if [log_level] == "DEBUG" or [message] =~ /^health check/ { drop { } }}使用时注意加上条件判断,否则会丢掉所有事件。过滤器的组合与顺序实际项目中,过滤器总是组合使用的。一个典型的 Nginx 访问日志处理管线:filter { # 第一步:用 Grok 把日志拆成字段 grok { match => { "message" => "%{COMBINEDAPACHELOG}" } } # 第二步:转换数值类型 mutate { convert => { "response" => "integer" } convert => { "bytes" => "integer" } remove_field => ["message"] } # 第三步:解析时间戳 date { match => ["timestamp", "dd/MMM/yyyy:HH:mm:ss Z"] } # 第四步:补充地理位置 geoip { source => "clientip" } # 第五步:解析 User-Agent useragent { source => "agent" target => "ua" }}过滤器的执行顺序就是配置中的书写顺序。一般遵循"先解析、再转换、后丰富"的原则:Grok 在最前面把原始文本拆开,Mutate 紧跟其后做类型转换和字段清理,Date/GeoIP/Useragent 等根据已有字段做信息补充。日常排错建议Grok 匹配失败:检查 Kibana Grok Debugger,确认模式与日志格式一致。生产环境务必处理 _grokparsefailure 标签事件。Mutate 顺序问题:记住 Mutate 内部操作有固定执行顺序,不受配置书写位置影响。遇到 rename 和 convert 冲突时,拆成两个 mutate 块。Date 时区偏移:@timestamp 默认是 UTC 时间,查询时注意时区换算。如果业务强依赖本地时间,在 date 过滤器中指定 timezone。GeoIP 数据库过旧:Logstash 内置的 GeoLite2 不会自动更新,地理信息不准确时需要手动下载最新数据库。性能瓶颈:Grok 是 CPU 密集型操作,复杂模式会导致吞吐量下降。可以考虑用 dissect 过滤器替代简单格式的 Grok 匹配,dissect 基于分隔符定位,性能更好。
前端阅读 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

MQTT 主题通配符有哪些?怎么用才不出错?

MQTT 主题通配符是订阅机制中绕不过去的核心概念。当你只需要监听某一类设备的数据,而不是逐个订阅每一个具体主题时,通配符就能派上用场。MQTT 规范定义了两种通配符:单级通配符 + 和多级通配符 #,它们各自的匹配规则和使用限制完全不同,混用或用错都会导致订阅失败或收到意料之外的消息。单级通配符 +单级通配符用 + 表示,它的作用很明确:只匹配主题层级中的一个层级,且该层级不能为空。所谓"一个层级",指的是两个 / 之间的一段内容。比如主题 home/livingroom/temperature 包含三个层级:home、livingroom、temperature。如果你订阅 home/+/temperature,那么第二层级无论是什么值都会被匹配到——home/bedroom/temperature、home/kitchen/temperature 都能命中,但 home/temperature 不行(缺少一个层级),home/livingroom/kitchen/temperature 也不行(多了一个层级)。几个需要特别注意的点:+ 可以在同一个主题过滤器中出现多次。比如 sensor/+/data/+ 是合法的,它会匹配 sensor/001/data/temperature 和 sensor/002/data/humidity,但不匹配 sensor/001/data(层级不够)或 sensor/001/data/temperature/value(层级超出)。+ 不能匹配空层级。订阅 home/+/temperature 不会匹配 home//temperature,因为中间那个层级是空的,而 + 要求至少有一个字符。+ 必须占据整个层级。home/room+/temperature 是无效写法,它不会匹配 home/room1/temperature——+ 不是一个正则里的"一个任意字符",而是代表整个层级的通配。多级通配符多级通配符用 # 表示,它的匹配范围比 + 大得多:匹配当前层级及其后面的所有层级,包括零个层级。比如订阅 home/#,以下主题全部能命中:home/livingroom、home/livingroom/temperature、home/bedroom/humidity/value。甚至 home/ 本身也能匹配(# 匹配了零个层级,但注意 home 不行,因为 # 前面必须有 / 或者 # 本身就是整个过滤器的第一个字符)。# 的使用限制比 + 更严格:必须放在主题过滤器的最后。home/#/temperature 是无效的,Broker 会直接拒绝这种订阅。这是 MQTT 规范的硬性要求,没有例外。每个主题过滤器只能出现一次。home/#/# 这种写法同样无效。前面必须有 / 分隔(除非 # 是整个过滤器的唯一内容,即单独订阅 #,这会匹配所有主题)。比如 home# 是无效的,home/# 才是正确的。单独订阅 # 是一个特殊用法——它匹配 Broker 上的所有主题。这在调试阶段偶尔有用,但在生产环境中极度危险,不仅会造成性能问题,还可能收到大量不相关的消息。+ 和 # 的组合使用两种通配符可以在同一个主题过滤器中组合使用,只要遵守各自的规则。最常见的组合模式是先用 + 固定某个层级,再用 # 捕获剩余部分:订阅:home/+/sensors/#匹配: home/livingroom/sensors/temperature ✓ home/livingroom/sensors/temperature/value ✓ home/bedroom/sensors/humidity ✓不匹配: home/sensors/temperature ✗(+不能匹配空层级) home/livingroom/sensors ✗(# 前缺少 /)订阅:sensor/+/#匹配: sensor/001/data ✓ sensor/001/data/temperature ✓ sensor/002/data/humidity/value ✓不匹配: sensor ✗(+至少需要一个层级) office/001/data ✗(第一层级不匹配)组合使用时,最容易犯的错误是在 # 后面继续加内容。记住一条原则:过滤器里出现了 #,它后面就不能再有任何东西。通配符只能用于订阅,不能用于发布这是一个初学者常踩的坑。MQTT 规范明确规定:通配符只适用于订阅(SUBSCRIBE)操作,发布(PUBLISH)时必须指定完整的确切主题。这意味着你不能往 home/+/temperature 发布消息,Broker 不会帮你把消息分发到 home/livingroom/temperature 和 home/bedroom/temperature。发布时,客户端必须明确指定目标主题,比如 home/livingroom/temperature。这个设计是合理的:如果允许通配符发布,消息的路由方向就变得不可预测,整个发布-订阅模型的确定性会被破坏。通配符匹配的实际示例理解规则是一回事,在实际场景中正确使用是另一回事。下面用几个常见场景来演示:场景一:监控所有温度传感器假设你的传感器主题结构为 sensors/{device_id}/temperature,你想接收所有设备的温度读数,订阅 sensors/+/temperature 即可。每台设备发布消息到自己的确切主题(如 sensors/001/temperature),而你的客户端只需要一条订阅规则就能全部收到。场景二:监控某个楼层所有设备主题结构为 building/floor1/{device_type}/{device_id},你只关心一楼的全部数据,那就订阅 building/floor1/#。不管后面有多少层级、是什么设备类型,一楼的所有消息都会推送到你的客户端。场景三:订阅特定设备类型的状态主题结构为 device/{device_id}/status,你想监控所有设备的在线/离线状态。订阅 device/+/status,每台设备状态变化都能收到。场景四:订阅所有告警告警消息散布在多个层级中:alert/critical/overheat、alert/warning/low_battery、alert/info/maintenance。订阅 alert/# 一条规则覆盖全部告警类型。场景五:分层指标收集系统指标主题可能是 system/{service}/metrics/{metric_name}/{instance}。如果你想收集某个服务下所有实例的所有指标,可以订阅 system/payment/metrics/#。如果你需要所有服务的同一个指标名,则用 system/+/metrics/cpu_usage/#。通配符使用中的常见错误在实际开发中,以下错误反复出现:错误一:把 + 当成正则的 .+ 不是正则表达式里的 .,它匹配的是一整个层级,而不是一个字符。home/room+/temperature 不会匹配 home/room1/temperature,这是无效的订阅格式。错误二:把 # 放在中间home/#/temperature 看起来像"home 下面任意层级后面跟 temperature",但 MQTT 不支持这种用法。# 只能在最后,没有例外。如果你需要这种模式,只能通过精确订阅多个主题来弥补。错误三:期望 + 匹配空层级home/+/temperature 不会匹配 home//temperature。空层级在实际业务中很少出现,但如果你手动拼接主题字符串时不小心产生了连续的 /,就会触发这个问题,而且很难排查。错误四:在生产环境订阅 #单独订阅 # 会收到 Broker 上的所有消息。调试时可能方便,但生产环境下这会给 Broker 和客户端同时带来不必要的负担,还可能暴露不该看到的数据。错误五:忘记 # 前面的 /home# 是无效的。如果你的主题层级超过一层,# 前面必须有 /。只有当 # 是整个过滤器的唯一字符时才不需要前面的 /。通配符对性能的影响通配符订阅不是免费的。Broker 收到每一条消息后,都需要遍历所有订阅进行主题匹配。精确订阅的匹配是 O(1) 的哈希查找,而通配符订阅需要逐个做模式匹配,复杂度至少是 O(n),n 是通配符订阅的数量。实际影响取决于 Broker 的实现和你的订阅规模。EMQX、Mosquitto 等主流 Broker 都对通配符匹配做了优化(比如用 Trie 树),在几千个订阅的场景下,性能差异通常可以忽略。但当订阅数达到十万甚至百万级别时,通配符匹配的开销就会显现。几个性能方面的建议:能用精确订阅就不用通配符。如果你只需要三台设备的数据,分别订阅三个主题比 sensor/+/temperature 更高效。通配符越具体越好。building/floor1/# 比 building/# 的匹配范围小,Broker 的过滤效率也更高。主题层级不要太深。5 层以内的主题结构在匹配性能上不会有问题,超过 10 层的场景需要评估。避免大量客户端同时订阅宽泛通配符。1000 个客户端都订阅 # 比它们各自订阅不同的精确主题对 Broker 的压力大得多。通配符与安全控制通配符订阅天然和权限控制存在张力。一条 # 订阅就能绕过主题层级的隔离,所以 ACL(访问控制列表)的配置必须考虑通配符场景。主流 Broker 都支持基于主题模式的 ACL 规则。比如 EMQX 可以配置"允许客户端订阅 sensors/+/temperature,但拒绝 sensors/#",从而限制客户端只能读取温度数据,不能读取湿度、压力等其他传感器数据。安全配置的核心原则是最小权限:只授予客户端完成其工作所需的最小订阅范围。如果某个客户端只需要一楼的数据,就只给它 building/floor1/# 的权限,而不是 building/#。另外,通配符订阅可能带来信息泄露风险。假设你的主题结构是 tenant/{tenant_id}/data,如果某个客户端订阅了 tenant/+/data,它就能收到所有租户的数据。在多租户系统中,这个问题尤其严重,ACL 必须严格限制跨租户的通配符订阅。代码示例Python(paho-mqtt)import paho.mqtt.client as mqttdef on_connect(client, userdata, flags, reason_code, properties): print(f"Connected: {reason_code}") # 订阅所有设备的温度数据(单级通配符) client.subscribe("sensors/+/temperature") # 订阅卧室的所有数据(多级通配符) client.subscribe("home/bedroom/#") # 组合使用:所有服务的指标数据 client.subscribe("system/+/metrics/#")def on_message(client, userdata, msg): print(f"Topic: {msg.topic}, Payload: {msg.payload.decode()}")client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)client.on_connect = on_connectclient.on_message = on_messageclient.connect("broker.example.com", 1883, 60)client.loop_forever()JavaScript(MQTT.js)const mqtt = require('mqtt');const client = mqtt.connect('mqtt://broker.example.com:1883');client.on('connect', () => { console.log('Connected'); // 单级通配符:所有设备的温度 client.subscribe('sensors/+/temperature'); // 多级通配符:卧室所有数据 client.subscribe('home/bedroom/#'); // 组合通配符:所有服务的指标 client.subscribe('system/+/metrics/#');});client.on('message', (topic, message) => { console.log(`Topic: ${topic}, Payload: ${message.toString()}`);});Java(Eclipse Paho)import org.eclipse.paho.mqttv5.client.MqttClient;import org.eclipse.paho.mqttv5.client.MqttConnectionOptions;import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;MqttClient client = new MqttClient( "tcp://broker.example.com:1883", "java-client-" + System.currentTimeMillis(), new MemoryPersistence());client.setCallback(new MqttCallback() { @Override public void messageArrived(String topic, MqttMessage message) { System.out.println("Topic: " + topic + ", Payload: " + new String(message.getPayload())); } // ... 其他回调方法省略});MqttConnectionOptions opts = new MqttConnectionOptions();opts.setAutomaticReconnect(true);client.connect(opts);// 单级通配符client.subscribe("sensors/+/temperature", 1);// 多级通配符client.subscribe("home/bedroom/#", 1);// 组合通配符client.subscribe("system/+/metrics/#", 1);主题设计建议通配符好不好用,很大程度上取决于你的主题结构设计。一个设计良好的主题结构能让通配符发挥最大价值,而一个糟糕的主题结构会让通配符变得鸡肋甚至无法使用。保持层级语义一致。 主题的每一层都应该有明确的含义。building/floor1/room2/temperature 这种结构中,每一层都是具体的分类维度,通配符可以精确地切入任何维度。如果主题层级含义混乱(比如 data/temperature/floor1/001),通配符就很难发挥筛选作用。层级不要太深。 3-5 层是最佳范围。层级过深会增加匹配开销,也让订阅规则变得难以阅读和维护。避免在层级中使用特殊字符。 主题层级中不要包含 +、#、/ 这些保留字符,也不要使用空格和通配符本身。MQTT 规范虽然没有禁止在主题内容中使用这些字符,但它们会干扰通配符的匹配逻辑。统一命名风格。 全部用小写、用下划线或连字符连接单词,不要混用 camelCase 和 snake_case。sensor/temperature/living_room 比 sensor/Temperature/LivingRoom 更不容易出错。为通配符预留扩展空间。 设计时考虑未来可能新增的层级。比如当前主题是 device/{id}/status,未来可能需要按区域分组,那不如一开始就设计成 region/{region}/device/{id}/status,这样 region/east/# 这样的订阅就有意义了。MQTT 主题通配符本质上是一种模式匹配机制,+ 匹配单层级,# 匹配多层级,两者都只能用于订阅。理解它们的匹配规则和使用限制是正确使用 MQTT 的前提,而良好的主题结构设计则决定了通配符在实际项目中能发挥多大的价值。
前端阅读 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; }}