阅读视图

发现新文章,点击刷新页面。

生产环境Sourcemap策略:从苹果事故看前端构建安全架构设计

如果你对 React 源码解析感兴趣,欢迎访问我的微信公众号- 【前端小卒】

在我的博客中,你可以找到:

🔍 完整的 React 源码解析电子书 - 从基础概念到高级实现,全面覆盖 React 19 的核心机制 📖 系统化的学习路径 - 按照 React 的执行流程,循序渐进地深入每个模块 💡 实战案例分析 - 结合真实场景,理解 React 设计思想和最佳实践 🚀 最新技术动态 - 持续更新 React 新特性和性能优化技巧

wechat.png

就在这个月,2025年11月3日,苹果上线的全新 apps.apple.com 上演了一场P0级的“源码裸奔”事故。使用 Svelte开发的应用,竟然忘记了在生产环境中移除 Sourcemap 配置

这意味着什么!

全球任何一个人可以打开浏览器的DevTools,就能够看到并且获取苹果未经压缩、带注释、结构清晰、功能完备的前端源码

G44vIqfWYAAq6Ab.jpeg

为何一个生产代码,会如此“裸奔”?而这个答案直指一个被错误配置的文件——Sourcemap

尽管作为一名业务前端,在各种场所自嘲:前端业务代码没有价值,远离业务核心。但这不并等于前端代码可以裸奔,将可能存在的密钥、业务逻辑、后端API地址赤裸裸的放在公众面前。

本文将从这起最新的“泄码”事件出发,深入Sourcemap的底层原理,彻底搞懂这个 Sourcemap

什么是 Sourcemap?为什么需要它?

在现代前端工作流中,我们写的 src/index.js 并不能直接在浏览器运行。它需要经过一系列处理:

  1. 转换(Transpiling): Babel 将 ES-Next/TS/JSX -> 目标浏览器支持的 ES 版本。
  2. 打包(Bundling): Webpack 或 Vite 将上百个模块文件合并成少数几个文件。
  3. 压缩(Minifying): Terser 或 UglifyJS 将代码中的空格、换行、注释全部删除,并将变量名替换为 a, b, c 等短字符。

通过这样处理,我们得到了更小的文件体积、更少的HTTP请求以及相对代码安全(提高了源码的阅读门槛)

但这带来了巨大的“痛点”:

当我们线上代码出现问题报错时,在监控平台收到的错误异常信息往往是这样的:

TypeError: Cannot read properties of undefined (reading 'a')
    at e.t.a (app.chunk.8efda.min.js:1:1053)

这个信息毫无意义。你完全不知道 app.chunk.8efda.min.js 文件的第1行第1053列,对应的是源代码中哪一行代码,往往只能凭直觉+连蒙带猜。

Sourcemap 就是来解决这个问题的。

它是一个独立的 .map json 文件,精确地维护着“源码”和“生产代码“之间的映射关系

当浏览器(或Sentry等异常监控平台)拿到报错信息时,它会查找对应的 .map 文件,然后定位到真正的错误位置:

TypeError: Cannot read properties of undefined (reading 'user')
    at fetchUser (src/components/user.ts:58:12)

解剖 .map 文件

Sourcemap 的核心就是一个 JSON 文件。让我们来看一个它最简单的结构:

JSON

{
  "version": 3,
  "file": "bundle.min.js",
  "sourceRoot": "",
  "sources": ["src/index.js", "src/utils.js"],
  "names": ["add", "MAGIC_NUMBER", "subtract"],
  "sourcesContent": [
    "import { add } from './utils.js';\n\nconsole.log(add(5, MAGIC_NUMBER));",
    "export const MAGIC_NUMBER = 7;\n\nexport const add = (a, b) => a + b;\nexport const subtract = (a, b) => a - b;"
  ],
  "mappings": "AAAA,SAASA,IAAI,QAAQ,cACpB,QAAQC,cAAc,EACrBC,OAAO,CAACC,GAAG,CAACH,GAAG,CAAC,CAAC,EAAEC,YAAW,CAAC,CAAC"
}

我们来逐个解析这些字段:

  • version: 版本号,目前最新且通用的是版本3。

  • file: 压缩后的文件名(即 bundle.min.js 是由 mappings 映射生成的)。

  • sourceRoot: 源码的根目录(可选)。

  • sources: 一个数组,包含了所有源码文件的路径。

  • names: 一个数组,包含了源码中所有被混淆替换掉的变量名和属性名。

  • sourcesContent: [高危字段] 一个数组,包含了所有源码的原文内容。如果这个字段存在,意味着 .map 文件本身就包含了所有源码,泄露风险极大!


mappings 字段

mappings 字段是 Sourcemap 的核心,也是它最为复杂的部分。我们看到的一堆像乱码长串字符AAAA,SAASA,IAAI... 到底是什么?

这不是乱码,而是一种超高效率的编码——Base64 VLQ (Variable-length quantity) ,这存储了源码和压缩码之间所有位置映射关系。

为什么用它?

如果用 JSON 存储每个位置的映射 [{ genLine: 1, genCol: 5, srcFile: 0, srcLine: 2, srcCol: 10 }, ...],那么这个 .map 文件会比你的 js 文件本体还大几倍。而 VLQ 编码就是为了极致的压缩体积。

mappings 字符串通过两种符号来组织:

  • 分号 (;): 代表“换行”。每个分号对应压缩后文件(bundle.min.js)的一行
  • 逗号 (,): 代表“位置”。在同一行中,用逗号分隔多个映射点(Segments)。

例如 AAAA,CAAC;CACC 的意思是:

  • AAAACAAC 两个映射点在压缩文件的第1行。
  • CACC 映射点在压缩文件的第2行。

Base64 VLQ 编码

AAAACAAC 这样的每个“映射点”(Segment)到底代表什么?

它通常包含4到5个数字:

  1. 压缩后代码的列号
  2. sources 数组中文件索引
  3. 源码的行号
  4. 源码的列号
  5. (可选)names 数组中的变量名索引

而 Base64 VLQ 是一种“可变长”的编码方式,想象一种特殊的编码,用来表示数字。表示 1 只需要1个字符 C,表示 5 只需要1个字符 K,但表示 1000 可能需要3个字符。它对“小数字”的编码极其高效。

这是 VLQ 编码能做到极致压缩的最关键一点:它存储的不是绝对位置,而是“相对前一个位置的偏移量(Delta)”

举个例子:

  • 第一个映射点 AAAA 解码后是 [0, 0, 0, 0](代表:压缩后0列,第0个文件,第0行,第0列)。
  • 假设第二个映射点 CAAC 解码后是 [0, 0, 1, 0]

代表它映射到源码的 (1, 0) 位置。它代表的是偏移量

  • 压缩后列号:0 (相对上一个映射点 AAAA 的0列,偏移 +0)
  • 文件索引:0 (相对上一个的0,偏移 +0)
  • 源码行号:1 (相对上一个的0行,偏移 +1)
  • 源码列号:0 (相对上一个的0列,偏移 +0)

所以 CAAC 真正映射的位置是源码的 (0+1, 0+0),即第1行第0列。

因为源码和压缩码在大多数情况下都是顺序的,所以两次映射之间的“偏移量”通常都非常小(比如 +1, +0),这些小数字用 VLQ 编码后,只需要一个字符。这就是 mappings 字符串能如此之短的秘密。

如何在 Webpack 与 Vite 中玩转 Sourcemap

上面讲解了原理,现在我们来进行实战,在业务中我们必须区分 developmentproduction。而关于sourcemap的分类,这里我们以webapck为例子。尽管webpack有二十多种,但大体上可以分为以下几种:

“sourcemap”

在每次构建中,都会生成以下的文件产物树

dist/
 ├─ app.3cf7.js
 └─ app.3cf7.js.map      

app.3bf4.js尾部注释

//# sourceMappingURL=app.3cf7.js.map

.map 数据样例(只留关键列)

{
  "version": 3,
  "file": "app.3cf7.js",
  "sources": [
    "webpack://my-app/src/utils/add.js",
    "webpack://my-app/src/pages/Pay.vue"
  ],
  "sourcesContent": [      // ① 完整源码嵌在这里
    "export const add = (a, b) => a + b;",
    "<template>...</template>\n<script>..."
  ],
  "names": ["add", "submit"],
  "mappings": "AAAA,SAASA,IAAG,SAASC,...",
  "sourceRoot": ""
}

如果我们在浏览器中打开devtools就能够看到以下的日志:

Sources ▸ webpack:// ▸ src/pages/Pay.vue

sourcemap能够提供列级的精度映射,是 Sentry、Bugsnag 还原真实源码 的最低要求,但是其体积最大,如果将 .map文件发到cdn上,那就GG了。

hidden-source-map

hidden-source-map在在每次构建中,都会生成以下的文件产物树,而map内容也和soucemap的完全一致。

dist/
 ├─ app.3cf7.js            // **没有** sourceMappingURL 注释
 └─ app.3cf7.js.map        // 文件照样生成,只是浏览器不知道

其在浏览器中报错堆栈停留在压缩码位置,而用户也在Sources面板看不到 webpack://树,这意味着浏览器“找不到”这个 .map 文件,用户无法用它来反解源码。

at e (app.3cf7.js:1:1340)

在日常开发中,往往通过ci工具把 .map 私有上传到 Sentry,这样不存在泄漏源码的风险,监控又能够提供完整的错误路径。

cheap-module-source-map

在每次构建中,都会生成以下的文件产物树

dist/
 ├─ app.3cf7.js
 └─ app.3cf7.js.map

对于map文件中,其不会存在列消息

"mappings": "AAAA,CAAC,CAAC,CAAC..."  // 只记录「行→行」映射
**没有列信息**                       // VLQ 第 1、4 段永远为 0

所以当业务报错时,DevTools只能断到 第 58 行,无法断到 第 58 行第 12 列

at submitOrder (Pay.vue:58)   // 看不到 :58:12

这种类型的sourcemap在开发环境够用且 rebuild快,如果在测试环境想省磁盘 / 加速 CI 也可选它。

eval-cheap-module-source-map 

在每次构建中,都会生成以下的文件产物

**没有 .map 文件**
所有代码包裹成:
eval(
  "////# sourceURL=webpack://my-app/src/pages/Pay.vue?3f20\n" +
  "export default {\n  name: \"Pay\"\n}"
)

浏览器 Sources,我们也可以双击点击源码,但是只能断行。

webpack:// ▸ src/pages/Pay.vue?3f20   // 虚拟文件

eval-cheap-module-source-map能够做到 本地 HMR 秒级重编(磁盘 0 写入),但是其最好在生产禁,因为CSP策略可能 block eval,同时无实体文件无法收集到监控平台。


inline-source-map

在每次构建中,都会生成以下的文件产物

dist/
 └─ app.3cf7.js   // 只有一个文件

在app.3cf7.js的尾部追加

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjoz...

事实上,其整个 .map JSON(含 sourcesContent)被 base64 内联 直接导致js文件体积暴增30-50%。

所以其应用场景比较局限:往往写文件库比如loadsh时,一个文件走天下,而多入口业务项目千万别用,每个 chunk 都会生成一份内联map文件,体积直接爆炸。

nosources-source-map

产物示例

{
  "version": 3,
  "sources": ["src/Pay.vue"],
  "sourcesContent": [null],     // ① 关键:只有路径,没有内容
  "names": ["submit"],
  "mappings": "AAAA,SAASA..."
}

DevTools 效果

Sources ▸ webpack:// ▸ src/Pay.vue     // 树在,文件能点开
**内容是空白的** —— 提示 “Source content is not available”

这个几乎是官方UI 框架(vue.global.prod.js、react.production.min.js)标配,用户可看到「目录结构」方便调试,却看不到具体实现

如果我们要把包发到CDN,希望 Sentry 仍能还原列号,那么使用这个就行即可。


####. eval 产物

eval("function add(a,b){return a+b}\n//# sourceURL=webpack:///./src/add.js")

当业务报错时,报错栈只能看到 压缩后变量名。而这个能提供最快 rebuild,在超大型 monorepo 本地开发阶段图个极致 rebuild 速度,往往会选它,但在生产环境中,其都无法满足条件。

生产环境的安全与最佳实践

现在我们回到开头苹果(2025年11月)的“泄密”事件。他们犯了什么错?

他们很可能在生产环境配置了 build.sourcemap: true (如果是Vite/Rollup) 或 devtool: 'source-map' (如果是Webpack),并且忘记了移除 sourceMappingURL 注释,同时将生成的 .map 文件和 .js 文件一起部署到了公网服务器上也忘记了用 Nginx 拦截

看上去,他们几乎踩了我们能想到的每一个“雷”,如果中间任何一个环节稍微严谨点,都不会出现这个问题。

这就是最严重的安全红线:绝对不要将 .map 文件部署到公网用户可以访问到的地方!

“但是,我白屏了就是需要定位问题,怎么办!”

那么我们就要用户无法访问 Sourcemap,但我们自己可以。

在 CI/CD 流程中,使用 hidden-source-map (Webpack) 或 build.sourcemap: true + 移除插件 (Vite) 来构建。这会生成 .map 文件,但浏览器不会加载它。

同时不要将 .map 文件部署到你的CDN或Web服务器。而是将这些 .map 文件上传到私有的错误监控平台,例如 Sentry、FrontJS 或自建的内网服务器。

而对于开源库/组件库 而言 nosources-source-map是最优选,其部署简单,只暴露目录结构和变量名,不暴露源码逻辑,方便他人调试。

这个流程完美地实现了“鱼和熊掌兼得”:在对源码绝对安全的掌控下,还能拥有完整的线上调试能力。

前端高频面试题之Vue(高级篇)

1、说一下 Vue.js 的响应式原理

1.1 Vue2 响应式原理

核心原理就是通过 Object.defineProperty 对对象属性进行劫持,重新定义对象的 gettersetter,在 getter 取值时收集依赖,在 setter 修改值时触发依赖更新,更新页面。

Vue2 对数组和对象做了两种不同方式的处理。

监听对象变化:

针对对象来说,Vue 会循环遍历对象的每一个属性,用 defineReactive 重新定义 gettersetter


function defineReactive(target,key,value){
    observer(value);
    Object.defineProperty(target,key,{ ¸v
        get(){
            // ... 收集依赖逻辑
            return value;
        },
        set(newValue){
            if (value !== newValue) {
                value = newValue;
                observer(newValue) // 把新设置的值包装成响应式
            }
            // ...触发依赖更新逻辑
        }
    })
}
function observer(data) {
    if(typeof data !== 'object'){
        return data
    }
    for(let key in data){
        defineReactive(data,key,data[key]);
    }
}

监听数组变化:

我们都知道,数组其实也是对象,同样可以用 Object.defineProperty 劫持数组的每一项,但如果数组有100万项,那就要调用 Object.defineProperty 一百万次,这样的话性能太低了。鉴于平时我们操作数组大都是采用数组提供的原生方法,于是 Vue 对数组重写原型链,在调用7个能改变自身的原生方法(pushpopshiftunshiftsplicesortreverse)时,通知页面进行刷新,具体实现过程如下:

// 先拿到数组的原型
const oldArrayProtoMethods = Array.prototype
// 用Object.create创建一个以oldArrayProtoMethods为原型的对象
const arrayMethods = Object.create(oldArrayProtoMethods)
const methods = [
    'push',
    'pop',
    'shift',
    'unshift',
    'sort',
    'reverse',
    'splice'
]
methods.forEach(method => {
    // 给arrayMethods定义7个方法
    arrayMethods[method] = function (...args){
        // 先找到数组对应的原生方法进行调用
        const result = oldArrayProtoMethods[method].apply(this, args)
        // 声明inserted,用来保存数组新增的数据
        let inserted
        // __ob__是Observer类实例的一个属性,data中的每个对象都是一个Observer类的实例
        const ob = this.__ob__
        switch(method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
            default:
                break
        }
        // 比如有新增的数据,新增数据也要被定义为响应式
        if(inserted) ob.observeArray(inserted)
        // 通知页面更新
        ob.dep.notify()
        return result
    }
})

Object.defineProperty的缺点:

  1. 无法监听新增属性和删除属性的变化,需要通过 $set$delete 实现。
  2. 监测数组的索引性能太低,故而直接通过数组索引改值无法触发响应式。
  3. 初始化时需要一次性递归调用,性能较差。

1.2 Vue3 的响应式改进

Vue3 采用 Proxy + Reflect 配合实现响应式。能解决上述 Object.defineProperty 的所有缺陷,唯一缺点就是兼容性没有 Object.defineProperty 好。

let handler = {
  get(target, key) {
    if (typeof target[key] === "object") {
      return new Proxy(target[key], handler);
    }
    return Reflect.get(target, key);
  },
  set(target, key, value) {
    let oldValue = target[key];
    if (oldValue !== value) {
      return Reflect.set(target, key, value);
    }
    return true;
  },
};
let proxy = new Proxy(obj, handler);

2、介绍一下 Vue 中的 diff 算法?

Vue 的 diff 算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式 + 双指针的方式进行比较。

比较过程:

  1. 先比较是否是相同节点。
  2. 相同节点比较属性,并复用老节点。
  3. 比较儿子节点,考虑老节点和新节点儿子的情况。
  4. 优化比较:头头、尾尾、头尾、尾头。
  5. 比对查找进行复用。

Vue3 在这个比较过程的基础上增加了最长递增子序列实现diff算法。

  • 找出不需要移动的现有节点。
  • 只对需要移动的节点进行操作。
  • 最小化 DOM 操作次数。

3、Vue 的模板编译原理是什么?

Vue 中的模板编译就是把我们写的 template 转换为渲染函数(render function) 的过程,它主要经历3个步骤:

  1. 解析(Parse):将 template 模板转换成 ast 抽象语法树。
  2. 优化(Optimize):对静态节点做静态标记,减少 diff 过程中的比对。
  3. 生成(Generate):重新生成代码,将 ast 抽象语法数转化成可执行的渲染函数代码。

3.1 解析阶段

<div id="app">
  <p>{{ message }}</p>
</div>
  • 用 HTML 解析器将模板解析为 AST。
  • AST中用 js 对象描述模板,里面包含了元素类型、属性、子节点等信息。
  • 解析指令(v-for、v-if)和事件(@click)、插值表达式{{}}等 vue 语法。

3.2 优化阶段

  • 遍历上一步生成的 ast,标记静态节点,比如用 v-once 的节点,以及没有用到响应式数据的节点。
  • 标记静态根节点,避免不必要的渲染。

3.3 代码生成阶段

vue2 解析结果:

function render() {
  with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('p', [_v(_s(message))])])
  }
}
  • _c: 是 createElement 的别名,用于创建 VNode。
  • _v: 创建文本 VNode。
  • _s: 是 toString 的别名,用于将值转换为字符串。

vue3 解析结果:

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", { id: "app" }, [
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}
  • _openBlock: 开启一个"block"区域,用于收集动态子节点。
  • _createElementBlock: 创建一个块级虚拟 DOM 节点。
  • _createElementVNode: 创建一个普通虚拟 DOM 节点。
  • _toDisplayString: 将响应式数据 _ctx.message 转换为显示字符串,或者处理 null/undefined 等值,确保它们能正确渲染为空白字符串。

vue2在线编译:template-explorer.vuejs.org/

vue3在线编译:v2.template-explorer.vuejs.org/

运行时+编译(runtime-compiler) vs 仅运行时(runtime-only):

  1. 完整版(运行时+编译):
    • 包含编译模块,可以写 template 模版。
    • 体积较大(~30kb)。
  2. 仅运行时版本
    • 需要在打包时使用 vue-loader 进行编译。
    • 体积较小(~20kb)。

平时开发项目推荐使用仅运行时(runtime-only)版本。

编译后的特点:

  1. 虚拟DOM:渲染函数生成的是虚拟DOM节点(VNode)。
  2. 响应式绑定:渲染函数中的变量会自动建立依赖关系。
  3. 性能优化:通过静态节点标记减少不必要的更新。

4、v-show 和 v-if 的原理

简单来说,v-if 内部是通过一个三元表达式来实现的,而 v-show 则是通过控制 DOM 元素的 display 属性来实现的。

v-if 源码:

function genIfConditions (
    conditions: ASTIfConditions,
    state: CodegenState,
    altGen?: Function,
    altEmpty?: string
    ): string {
    if (!conditions.length) {
        return altEmpty || '_e()'
    }
    const condition = conditions.shift()
    if (condition.exp) {   // 如果有表达式
        return `(${condition.exp})?${ // 将表达式作为条件拼接成元素
        genTernaryExp(condition.block)
        }:${
        genIfConditions(conditions, state, altGen, altEmpty)
        }`
    } else {
        return `${genTernaryExp(condition.block)}` // 没有表达式直接生成元素 像v-else
    }

    // v-if with v-once should generate code like (a)?_m(0):_m(1)
    function genTernaryExp (el) {
        return altGen
        ? altGen(el, state)
        : el.once
            ? genOnce(el, state)
            : genElement(el, state)
    }
}

v-show 源码:

{
    bind (el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
    const originalDisplay = el.__vOriginalDisplay =
        el.style.display === 'none' ? '' : el.style.display // 获取原始显示值
        el.style.display = value ? originalDisplay : 'none' // 根据属性控制显示或者隐藏
    }  
} 

5、v-if 和 v-for 哪个优先级更高?为什么?

  • vue2 中 v-for 的优先级比 v-if 高,它们作用于一个节点上会导致先循环后对每一项进行判断,浪费性能。
  • vue3 中 v-if 的优先级比 v-for 高,这就会导致 v-if 中的条件无法访问 v-for 作用域名中定义的变量别名。
<li v-for="item in arr" v-if="item.visible">
  {{ item}}
</li>

以上代码在 vue3 的编译结果如下:

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createCommentVNode as _createCommentVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_ctx.item.visible)
    ? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(_ctx.arr, (item) => {
        return (_openBlock(), _createElementBlock("li", null, _toDisplayString(item), 1 /* TEXT */))
      }), 256 /* UNKEYED_FRAGMENT */))
    : _createCommentVNode("v-if", true)
}

可以看出 vue3 在编译时会先判断 v-if,然后再走 v-for 的循环,所以在 v-if 中自然就无法访问 v-for 作用域名中定义的变量别名。

这样的写法在 vue3 中会抛出一个警告⚠️,[Vue warn]: Property "item" was accessed during render but is not defined on instance,导致渲染失败。

以上代码在 vue2 还不能直接编译,因为 vue2 的组件需要一个根节点,所以我们在外层加一个 div

<div>
  <li v-for="item in arr" v-if="item.visible">
    {{ item}}
  </li>
</div>

其编译结果如下:

function render() {
  with(this) {
    return _c('div', _l((arr), function (item) {
      return (item.visible) ? _c('li', [_v("\n    " + _s(item) + "\n  ")]) :
        _e()
    }), 0)
  }
}

很明显是先循环 arr,然后每一项再用 item.visible 去判断的,也印证了在 vue2 中, v-for 的优先级高于 v-if

所以不管是 vue2 还是 vue3,都不推荐同时使用 v-ifv-for,更好的方案是采用计算属性,或者在外层再包裹一个容器元素,将 v-if 作用在容器元素上。

6、nextTick 的原理是什么?

6.1 Vue2 的 nextTick:

  • 首选微任务:
    • Promise.resolve().then(flushCallbacks):最常见,使用 Promise 创建微任务。
    • MutationObserver:如果 Promise 不可用,创建一个文本节点,修改其内容触发 MutationObserver 的观察器回调。
  • 回退宏任务:
    • setImmediate:如果环境支持 setImmediate,比如 node 环境,则会优先使用 setImmediate 。
    • setTimeout(flushCallbacks, 0):最后使用定时器。

这里体现了优雅降级的思想。

6.2 Vue3 的 nextTick:

  • 由于 Vue3 不再考虑 promise 的兼容性,所以 nextTick 的实现原理就是 promise.then 方法。

7、Vue.set 方法是如何实现的?

Vue2的实现:在 Vue 2 中,Vue.set 的实现主要位于 src/core/observer/index.js 中:

export function set (target: Array | Object, key: any, val: any): any {
    // 1.如果是数组 Vue.set(array,1,100); 调用我们重写的splice方法 (这样可以更新视图)
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
    }
    // 2.如果是对象本身的属性,则直接添加即可
    if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
    }
    const ob = (target: any).__ob__
    // 3.如果是响应式的也不需要将其定义成响应式属性
    if (!ob) {
        target[key] = val
        return val
    }
    // 4.将属性定义成响应式的
    defineReactive(ob.value, key, val)
    // 5.通知视图更新
    ob.dep.notify()
    return val
}

Vue3 中 set 方法已经被移除,因为 proxy 天然弥补 vue2 响应式的缺陷。

8、Vue.use 是干什么的?原理是什么?

Vue.use 是用来使用插件的,我们可以在插件中扩展全局组件、指令、原型方法等。

Vue.use 源码:

Vue.use = function (plugin: Function | Object) {
    // 插件不能重复的加载
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    if (installedPlugins.indexOf(plugin) > -1) {
        return this
    }
    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)  // install方法的第一个参数是Vue的构造函数,其他参数是Vue.use中除了第一个参数的其他参数
    if (typeof plugin.install === 'function') { // 调用插件的install方法
        plugin.install.apply(plugin, args)  Vue.install = function(Vue,args){}
    } else if (typeof plugin === 'function') { // 插件本身是一个函数,直接让函数执行
        plugin.apply(null, args) 
    }
    installedPlugins.push(plugin) // 缓存插件
    return this
}

9、介绍下 Vue 中的 mixin,Vue3 为何不再推荐使用它?

mixin 是 Vue 2 中一种复用组件逻辑的方式,允许将可复用的配置(data、methods、computed、lifecycle hooks 等)抽离成一个对象,然后通过 mixins: [] 合并到组件中。支持全局注入和局部注入。

  • 作用:抽离公共的业务逻辑
  • 原理:类似“对象的继承”,当组件初始化时会调用 mergeOptions 方法进行合并,采用策略模式针对不同的属性进行合并。如果混入的数据和本身组件中的数据冲突,会采用“就近原则”以组件的数据为准。

mixin 的优点:

  • 复用逻辑(如表单验证、权限判断)。
  • 全局注入(如日志、埋点)。
  • 减少重复代码。

mixin 中有很多缺陷:

  • 命名冲突问题:mixin 中的变量、函数名可能会与组件中的重名。
  • 依赖问题:
  • 数据来源问题:

vue3 不再推荐使用它的理由如下:

问题 说明
1. 隐式依赖 & 数据来源不明确 组件行为来自多个 mixin,难以追踪 data、methods 是从哪里来的。
2. 命名冲突 多个 mixin 可能定义同名 data、methods,合并规则复杂(同名 methods 后者覆盖前者,data 合并为对象,同名 Key 后者覆盖前者)。
3. 调试和维护困难 父组件无法知道子组件内部有哪些 mixin 注入的属性,排查 bug 和调试困难。
4. 不利于 Tree-shaking 打包时难以移除未使用的 mixin 代码。
5. 与 Composition API 理念冲突 Mixin 是“横切关注点”,而 Composition API 强调显式、可组合的逻辑。

Vue 3 推荐替代方案:Composition API + 可复用函数(Composables)。

特性 Mixin Composables
数据来源明确 隐式 显式(import)
是否有命名冲突问题
逻辑封装 全局污染 按需引入
Tree-shaking 支持
TypeScript 支持

对于全局混入(Global Mixin),Vue3 虽然提供了 app.mixin(),但不推荐,推荐使用:

  1. app.config.globalProperties
  2. app.provide 在顶层提供数据,组件通过 inject 方法消费数据。

10、介绍下 Vue.js 中的函数式组件、异步组件和递归组件

10.1 函数式组件(Functional Components)

函数式组件是一种轻量级、无状态的组件形式。它们很像纯函数:接收 props,返回 虚拟 DOM(vnode)。函数式组件在渲染过程中不会创建组件实例 (也就是说,没有 this),也不会触发常规的组件生命周期钩子。没有响应式系统、生命周期和实例的开销,函数式组件自然在渲染上更加高效和快速。

总而言之,函数式组件有无状态无this无生命周期性能更高等特点。

使用场景:适合简单、静态的 UI 元素,如列表项或包装组件。

在 Vue 2 中,通过 functional: true 声明;在 Vue 3 中,函数式组件更简单,直接返回渲染函数。

Vue2 示例:

<template functional>
  <div>{{ props.msg }}</div>
</template>

或者 js 形式:

export default {
  functional: true,
  props: ['msg'],
  render(h, { props }) {
    return h('div', props.msg);
  }
};

Vue3 示例:

<script setup>
import { h } from 'vue';

const FunctionalComp = (props) => h('div', props.msg);
</script>

<template>
  <FunctionalComp msg="Hello Functional" />
</template>

10.2 异步组件(Async Components)

异步组件是一种懒加载(Lazy Loading)机制,用于按需加载组件代码,优化初始加载时间和性能。Vue 会动态导入组件,只有在使用时才下载和渲染,常用于路由或大型组件。

特点:

  • 通过 import() 动态加载,返回 Promise。
  • 支持加载中(loading)、错误(error)和超时(timeout)处理。
  • 在 Vue 3 中,使用 defineAsyncComponent 更规范,支持与 <Suspense> 结合(Vue 3 独有,用于统一处理异步)。

Vue2 示例:

<script>
export default {
  components: {
    AsyncComp: () => import('./AsyncComp.vue')
  }
};
</script>

<template>
  <AsyncComp />
</template>

Vue3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';

const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'));
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncComp />
    </template>
    <template #fallback>加载中...</template>
  </Suspense>
</template>

10.3 递归组件(Recursive Components)

递归组件是指组件内部调用自身,用于处理树形或嵌套数据结构,如菜单、树视图或评论回复。Vue 支持组件自引用,但需注意避免无限循环(通过条件终止递归)。

特点:

  • 组件需有名称(name 选项),才能自引用。
  • 常结合 v-for 和 props 传递数据。

Vue 2 示例:

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'Tree',  // 必须有 name
  props: ['tree']
};
</script>

Vue 3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';  // 可选:异步加载避免循环

const Tree = defineAsyncComponent(() => import('./Tree.vue'));  // 自引用
defineProps(['tree']);
</script>

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

11、Vue.js 中的 vue-loader 是什么?

Vue-loader 是一个专为 Vue.js 设计的 Webpack loader(加载器),其主要作用是将 Vue 的单文件组件(Single-File Components,简称 SFC,即 .vue 文件)转换为可执行的 JavaScript 模块。 它允许开发者以一种结构化的方式编写组件,将模板(template)、脚本(script)和样式(style)封装在同一个文件中,便于管理和维护。

核心功能:

  • 解析 SFC 文件:Vue-loader 会自动处理 .vue 文件中的三个部分:
    • template 部分:编译为 Vue 的渲染函数(render function)。
    • script 部分:提取为组件的 JavaScript 逻辑,支持 ES 模块和 TypeScript。
    • style部分:处理 CSS,支持预处理器(如 Sass、Less)并可选地应用 scoped(作用域样式)或 CSS Modules。
  • 热重载(Hot Module Replacement,HMR):在开发模式下,支持组件的热更新,无需刷新页面即可看到变化,提高开发效率。
  • 自定义块(Custom Blocks):支持扩展,如添加 <docs> 或其他自定义标签,用于文档生成或其他工具集成。
  • 预处理器支持:无缝集成 Babel、PostCSS 等工具链。

12、Vue.extend 方法的作用?

Vue.extend方法可以作为基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

Vue2 示例:

var dialog = Vue.extend({
  template: "<div>{{hello}} {{world}}</div>",
  data: function () {
    return {
      hello: "hello",
      world: "world",
    };
  },
});
// 创建 dialog 实例,并手动挂载到一个元素上。
new dialog().$mount("#app");

注意:在 Vue.extend 中的 data 必须是一个函数,要不然会报错。

Vue3 示例:

Vue3 中不在使用 Vue.extend 方法,而是采用render方法进行手动渲染。

<!-- Modal.vue -->
<template>
  <div class="modal">这是一个弹窗</div>
</template>

<script>
export default {
  name: 'Modal',
}
</script>
<template>
  <div id="box"></div>
</template>

<script setup>
import { h, render, createApp, onMounted } from 'vue'
import Modal from './Modal.vue'

onMounted(() => {
  render(h(Modal), document.getElementById('box'));
})
</script>

13、keep-alive 的原理

<keep-alive> 是 Vue.js 的内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例,避免重复渲染和状态丢失,提高性能。它是一个抽象组件(abstract: true),不会渲染到 DOM,也不会出现在组件树中,而是通过插槽(slots)和自定义 render 函数实现缓存逻辑。

核心实现机制:

  1. 抽象组件与 Render 函数:
  • <keep-alive> 通过 render 函数处理包裹的内容(通常是动态组件,如 <component> 或 v-if 切换的组件)。
  • 在 render 中,它从插槽获取子组件的 VNode(虚拟节点)。如果子组件有 key(推荐使用),则用 key 作为缓存标识;否则用组件的 tag 或 cid(组件 ID)。
  • 如果组件已缓存,直接返回缓存的 VNode(设置 vnode.componentInstance.keepAlive = true 以标记缓存状态);否则,渲染新实例并存入缓存。
  1. 缓存存储:
  • 内部使用一个对象(this.cache)作为缓存 Map,以 key 为键,值为 VNode 对象(包含组件实例)。
  • 当组件首次渲染时,存入缓存;切换回时,从缓存取出,避免重新创建实例和执行 mounted 钩子。
  1. LRU 缓存算法:
  • 支持 max 属性设置最大缓存数量(Vue 2.5+)。
  • 使用 Least Recently Used(最近最少使用)算法:当缓存超出 max 时,删除最久未访问的组件(通过 this.keys 数组跟踪访问顺序)。
  • 访问组件时,将其 key 移到数组末尾(最近使用);超出时,删除数组开头的 key,并销毁对应实例(调用 $destroy)。
  1. 过滤规则(include/exclude):
  • 通过 include(白名单)和 exclude(黑名单)属性决定哪些组件缓存,支持字符串、正则、数组或函数。
  • 在 created 钩子中,监听这些 prop 的变化,并调用 pruneCache 更新缓存(移除不匹配的组件)。
  1. 生命周期钩子:
  • 缓存组件不会触发 destroyed/unmounted,而是使用 activated(激活时)和 deactivated(失活时)钩子。
  • 这允许开发者在切换时管理状态(如暂停定时器),而非完全销毁。

注意事项:

  • 只缓存一个直接子组件(插槽内容),不支持多个。
  • Vue 3 中原理类似,但优化了 VNode 处理和 Composition API 支持。
  • 潜在问题:缓存过多导致内存占用;需手动清理资源(如在 deactivated 中停止监听)。

14、Vue.js 中使用了哪些设计模式?

1. 观察者模式 (Observer Pattern)

  • 描述:Vue 的响应式系统使用观察者模式,通过 Proxy (Vue 3) 或 Object.defineProperty (Vue 2) 拦截对象属性的 get/set 操作。当数据变化时,通知订阅者(Watcher)更新视图。
  • 应用:在 reactive() 函数中,返回 Proxy 对象,get 陷阱用于依赖收集 (track),set 陷阱用于触发更新 (trigger)。
  • 优势:实现了细粒度的变更检测,避免全局重渲染。

2. 发布-订阅模式 (Publish-Subscribe Pattern)

  • 描述:Vue 的事件系统和响应式通知机制采用 Pub-Sub 模式。数据变化时发布事件,订阅者(如组件渲染函数)接收并响应。
  • 应用:在响应式系统中,trigger() 函数检索订阅者效果并调用它们;事件 API 如 emitemit 和 on 也基于此。
  • 优势:解耦了数据生产者和消费者,支持异步更新。

3. 代理模式 (Proxy Pattern)

描述:Vue 3 的响应式系统直接使用 ES6 Proxy 作为代理层,拦截对象操作,实现透明的响应式。 应用:reactive() 返回 Proxy 对象,代理目标对象的访问和修改。 优势:比 Vue 2 的 defineProperty 更强大,支持数组和 Map/Set 等类型。

4. 策略模式 (Strategy Pattern)

  • 描述:Vue 的虚拟 DOM diff 算法使用不同策略(如 key-based diff 或简单 patch)来优化更新。
  • 应用:在渲染过程中,根据节点类型选择 diff 策略。
  • 优势:最小化 DOM 操作,提高渲染效率。

5. 单例模式(Singleton Pattern)

  • 描述:整个程序中有且仅有一个实例。
  • 应用:vuex 的 store 和插件。
  • 优势:全局唯一、节约资源、便于管理。

6. 工厂模式(Factory Pattern)

  • 描述:提供了一种创建对象的方式,使得创建对象的过程与使用对象的过程分离。
  • 应用:Vue2 中的组件创建,传入参数给 createComponentInstance 就可以创建实例。
  • 优势:解藕,易于维护。

15、Vue.js 应用中常见的内存泄漏来源有哪些?

  1. 未清理的事件监听器、定时器、动画
<script setup>
import { onMounted, onUnmounted } from 'vue';

let timer = null;
let controller = null;
let raf = null;

onMounted(() => {
  // 定时器
  timer = setInterval(() => {}, 1000);
  // 动画
  raf = requestAnimationFrame(() => {});
  // 事件监听
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  clearInterval(timer);
  cancelAnimationFrame(this.raf);
  window.removeEventListener('resize', handleResize);
});
</script>
  1. 未移除的第三方库实例
<script setup>
import { onMounted, onUnmounted } from 'vue';

let chart = null;

onMounted(() => {
  chart = echarts.init(this.$refs.chart);
});

onUnmounted(() => {
  chart?.dispose();
});
</script>
  1. 事件总线(Event Bus)未解绑

vue2 用可以用 new Vue 全局创建一个事件总线实例,或者在组件中直接使用 this.$onthis.$emitthis.$off

vue3 则需要借助第三库,比如 mitt 来实现事件总线。

<script setup>
import { onMounted, onUnmounted } from 'vue';
import mitt from 'mitt';

// 创建事件总线实例
const emitter = mitt();

onMounted(() => {
  emitter.on('update', this.handler);
});

onUnmounted(() => {
  emitter.off('update', this.handler);
});
</script>

顺便提一下, vue3 为啥去掉 $on、$emit、$off 这些 API,主要有以下原因:

  1. 设计理念的调整

Vue 3 更加注重组件间通信的明确性和可维护性。$on 这类事件 API 本质上是一种 "发布 - 订阅" 模式,容易导致组件间关系模糊(多个组件可能监听同一个事件,难以追踪事件来源和流向)。Vue 3 推荐使用更明确的通信方式,如:

  • 父子组件通过 props 和 emit 通信
  • 跨组件通信使用 provide/inject 或 Pinia/Vuex 等状态管理库
  • 复杂场景可使用专门的事件总线库(如 mitt
  1. 与 Composition API 的适配

Vue 3 主推的 Composition API 强调逻辑的封装和复用,而 $on 基于选项式 API 的实例方法,与 Composition API 的函数式思维不太契合。移除后,开发者可以更自然地使用响应式变量或第三方事件库来实现类似功能。

  1. 减少潜在问题
  • $on 容易导致内存泄漏(忘记解绑事件)
  • 事件名称可能冲突(全局事件总线尤其明显)
  • 不利于 TypeScript 类型推断,难以实现类型安全

4. 未清理的 Watcher

Vue 本身不会泄漏内存,泄漏几乎都来自开发者未清理的副作用。养成“创建即清理”的习惯,使用 beforeDestroy 或者 onUnmounted 集中清理,在使用 keep-alive 的组件中,视情况在 deactivated 钩子中清理资源。

16、Vue.js 中的性能优化手段有哪些?

16.1 数据相关

  • Vue2 中数据层级不易过深,合理设置响应式数据;
  • Vue2 非响应式数据可以通过 Object.freeze()方法冻结属性;
  • 合理使用 computed,利用其缓存能力提高性能。

16.2 组件相关

  • 控制组件粒度(Vue 采用组件级更新);
  • 合适场景可使用函数式组件(函数式组件开销低);
  • 采用异步组件(借助构建工具的分包的能力,减少主包体积);
  • 在组件卸载或者非激活状态及时清除定时器、DOM事件、事件总线、三方库的实例等。
  • v-on 按需监听、使用动态 watch 和及时销毁 watch。

16.3 渲染相关

  • 合理设置 key 属性;
  • v-show 和 v-if 的选取;
  • 使用防抖、节流、分页、虚拟滚动、时间分片等策略;
  • 合理使用 keep-alive 、v-once、v-memo 进行逻辑优化。

结语

以上是整理的 Vue 高级的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vuex 和 Vue-router 相关面试题。

从面条代码到抽象能力:一个小表单场景里的前端成长四阶段

在日常业务开发里,有一类场景出现频率极高:

  • 页面里有一个子表单组件(用 ref 引用)
  • 提交前要让子表单先做一轮校验
  • 校验通过后,从当前组件的数据里组装一个 payload 发给后端

看起来再普通不过,比如下面这样一段代码:

if (this.reviewContent?.length) {
  // 先让子表单组件校验
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调接口……
  • 有内容就校验;
  • 校验不过就提示;
  • 校验通过就拼一个 payload 去提交。

很多前端的“表单生涯”,就是从这种线性、直接、带一点点面条味的代码开始的。

有意思的是:
同样一个需求,不同水平的前端,会写出完全不同层次的代码。
从这个小例子出发,我们刚好可以串一条:初级 → 中级 → 高级 → 架构 的成长路径。


一、初级前端:能把流程串起来,就是胜利 🎯

典型写法

还是这段最“朴素”的代码:

if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调用接口,比如:api.submit(payload)

逻辑完全按脑子里的流程来:

  1. 有自评内容吗?(this.reviewContent?.length
  2. 有的话就调用子表单的 validate()
  3. 校验没过就弹一条错误消息
  4. 校验通过,拼一个请求体对象
  5. 调接口

这个阶段的特点

  • ✅ 优点:

    • 写起来非常快;
    • 读起来也很直接——业务同学都能看懂。
  • ❌ 问题:

    • 强耦合在当前组件

      • 假设 $refs.reviewForm 一定存在;
      • 假设 validate() 返回 { ok, message }
      • 假设消息提示必须在这里做。
    • 逻辑、UI 提示、payload 结构全部糊在一起;

    • 若别的页面也要搞“先校验子表单再组装 payload”,往往是复制粘贴一份再改改字段。

初级阶段的核心目标其实只有一个: “我能把功能做出来。”
想到哪写到哪,是完全正常的。


二、中级前端:开始对“重复”和“耦合”过敏 🧩

当你写了第三、第四个类似的提交流程后,会开始皱眉头:

  • “怎么又是先 validate 再拼对象?”
  • “为什么到处都有同样的错误提示逻辑?”
  • “这要是修改字段名,不得全项目搜索一遍?”

这时候,就会自然走向第一步抽象:提取工具函数

第一步:按“模块”抽函数

比如我们为这块自评表单单独写一个工具文件:

// utils/reviewForm.js

// 校验自评表单
export function validateReview(vm) {
  if (!vm.reviewContent?.length) {
    // 没填内容,视为不需要校验
    return { ok: true };
  }

  const form = vm.$refs.reviewForm;
  if (!form || typeof form.validate !== 'function') {
    // 看业务需求,这里也可以认为是异常
    return { ok: true };
  }

  const res = form.validate();

  if (!res || res.ok === false) {
    const msg = res?.message || '自评信息校验未通过';
    vm.$message.error(msg);
    return { ok: false, message: msg };
  }

  return { ok: true };
}

// 构建自评 payload
export function buildReviewPayload(vm) {
  return {
    reviewContent: vm.reviewContent,
    attachmentList: vm.attachmentList,
  };
}

组件里就可以这样用:

import { validateReview, buildReviewPayload } from '@/utils/reviewForm';

async onSubmit() {
  const { ok } = validateReview(this);
  if (!ok) return;

  const payload = buildReviewPayload(this);
  await api.submit(payload);
}

中级前端的思维变化

  • 不再满足于“能跑”,开始追求“以后好改一点”;
  • 能意识到:可以把公共逻辑抽成函数,避免重复粘贴;
  • 但抽象粒度通常还是按业务模块划分
    reviewFormUtilsbaseInfoUtilspriceUtils……

封装有了,代码好了不少,但还停留在“每个业务模块一套”的阶段:
每加一个新表单,又是一组新的 validateXxx + buildXxxPayload


三、高级前端:从“封装”走向“抽象模式” 🧠

再往前走一步,你会开始问更有意思的问题:

  • 这些校验 + payload 的套路,本质上是不是一样的?
  • 哪些是“流程”,哪些是“策略/细节”?
  • 我能不能写一份通用逻辑,让所有表单都用?

1)提炼通用流程:getPayload

观察会发现,每个表单提交流程几乎都是:

  1. 根据某个 ref 拿到子表单组件;
  2. 调用 validate() 做校验;
  3. 如果失败 → 提示并中断;
  4. 如果成功 → 根据当前组件数据构造 payload。

于是可以写出一个只关心流程的函数:

// utils/getPayload.js

/**
 * @param {Vue} vm - 当前组件实例
 * @param {Function} buildPayload - (vm) => payload 对象
 * @param {String} refName - 子表单的 ref 名
 */
export function getPayload(vm, buildPayload, refName) {
  const form = vm.$refs?.[refName];

  // 没有这个表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    // 支持 Promise 风格的 validate
    if (maybe && typeof maybe.then === 'function') {
      return maybe.then(handle).catch((e) => {
        const msg = e?.message || '校验异常';
        vm.$message.error(msg);
        return { ok: false, payload: null, message: msg };
      });
    }

    // 同步返回
    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件使用示例:

import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => ({
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    }),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

此时,getPayload

  • 不再关心 payload 结构;
  • 不再关心具体业务,只负责:
    “找到表单 → 校验 → 错误处理 → 调用参数构造 payload”

2)这里已经有设计模式的影子

  • getPayload 像是一个小号的 模板方法模式(Template Method):

    • 固定了流程:校验 → 错误处理 → 构建 payload
    • 把“payload 怎么构”这一段留给调用方(作为“模板中的可变步骤”)。
  • buildPayload 实际上也符合 策略模式(Strategy)的思路:

    • 同一个处理流程,根据不同策略函数(buildXxxPayload),构建不同业务数据。

高级前端的特点是:

  • 不只是“会封装”,而是会从逻辑里识别出模式
  • 能把“稳定的部分”和“易变的部分”拆开,分别对待;
  • 会刻意让代码有可扩展点,而不是为了当前需求把东西都焊死。

四、前端架构:把“表单校验 + payload”升级成一种通用能力 🏗️

再往上一个段位,前端架构考虑的不只是“这段代码写得好不好看”,而是:

“这种模式在整个项目范围内,要怎么用、怎么演进?”

如果全站有 N 个子表单,每个都要:

  • ref + validate
  • 再拼各自的 payload

那么架构会更倾向于做一件事:

把这种模式正式命名、抽象成一类‘能力’,然后由所有页面共同复用。

1)用配置表达“表单 → payload”的映射关系

先把“这个 ref 对应什么 payload”抽成配置:

// config/formPayloadMap.js

export const formPayloadMap = {
  // 自评表单
  reviewForm(vm) {
    return {
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    };
  },

  // 基础信息表单
  baseForm(vm) {
    return {
      baseInfo: vm.baseInfo,
      projectId: vm.projectId,
    };
  },

  // 报价表单
  priceForm(vm) {
    return {
      priceList: vm.priceList,
    };
  },

  // ……
};

2)getPayload:只需要传 refName

现在改造 getPayload,变成只需要 vm + refName

// utils/getPayload.js
import { formPayloadMap } from '@/config/formPayloadMap';

export function getPayload(vm, refName) {
  const buildPayload = formPayloadMap[refName];

  if (!buildPayload) {
    console.warn(`[getPayload] 未配置 ref "${refName}" 对应的 payload 构造函数`);
    return { ok: false, payload: null, message: `未配置 ${refName} 映射` };
  }

  const form = vm.$refs?.[refName];

  // 没有表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    if (maybe && typeof maybe.then === 'function') {
      return maybe
        .then(handle)
        .catch((e) => {
          const msg = e?.message || '校验异常';
          vm.$message.error(msg);
          return { ok: false, payload: null, message: msg };
        });
    }

    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件里的使用体验就变成:

import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(this, 'reviewForm');
  if (!res.ok) return;

  await api.submit(res.payload);
}

调用方只需要关心:

  • “我这块用的是哪个 ref 的表单?”

至于:

  • 要不要校验;
  • 校验怎么提示;
  • payload 字段怎么组装;

全部由统一机制 + 配置来处理。

3)架构视角下,多了几件事要考虑

在这个阶段,思考重心变成了:

  • 一致性

    • 表单校验行为统一;
    • 错误提示统一;
    • payload 结构的调整有统一入口。
  • 可配置 / 可扩展

    • 新增表单只需要:

      1. 约定好 ref 名;
      2. formPayloadMap 里加一个构造函数;
    • 对核心流程无侵入。

  • 领域化

    • 不再把子表单当成“某个页面的实现细节”,
      而是当成领域里的一个“实体”:
      reviewFormbaseFormpriceForm……
    • 每个实体都有“校验 + 构造请求体”的统一接口。
  • 长期演进成本

    • 将来如果:

      • 改用新的 UI 表单库;
      • 数据结构升级;
      • Vue2 升 Vue3;
    • ——绝大多数改动都可以限制在小范围内完成。


五、同一个需求,不同段位的差别到底是什么?

用一句话概括每个阶段的心智模式:

  • 初级前端:

    “我能把这个流程串起来,让它跑起来。”

  • 中级前端:

    “这里有重复,我抽一个函数出来,大家都用。”

  • 高级前端:

    “这是一种模式。
    哪些是稳定流程?哪些是可变策略?
    我怎么设计抽象,让一份逻辑服务多个业务?”

  • 前端架构:

    “这不仅是个工具函数,而是一类‘通用能力’。
    我要用机制 + 配置,把它变成整个项目的基础设施。”

而最初那段看起来有点“面条味”的代码,其实只是起点。

当你开始嫌弃这类代码,开始思考“能不能抽象、能不能通用”的那一刻,你就已经在从“写代码的人”,向“设计代码的人”迈进了。


如果你现在项目里正好到处都是:

const res = this.$refs.xxx.validate();
// if (!res.ok) ...
// const payload = { ... }

不妨找一个最典型的提交流程,从:

直接写 → 提取函数 → 通用流程 + 策略 → 配置化

这四步路径里,选你觉得当前团队能接受的一步先落地。
技术成长很多时候不是换框架、追新库,而是搞定这种“看起来很小,但无处不在”的模式。


六、如果业务继续变复杂:往“建造者风格”进化 🧱

前面那套 getPayload + formPayloadMap,足以覆盖大部分常规业务表单场景:

  • 每个表单的 payload 结构相对固定;
  • 只要从 vm 上摘几个字段拼一下就行。

但真实项目有时候会长成这样:

  • 某些字段是「勾选了某个开关才需要拼进去」
  • 某些片段要按不同的业务类型组合(比如:普通流程 / 加急流程 / 审批流)
  • 有时还需要先异步拿一部分数据,再参与构建 payload

这时候,简单的:

formPayloadMap[refName](vm) {
  return { ... };
}

就可能开始变得又长又丑了:里面充满 if / switch / 三元运算符。

这个时候,就可以考虑往**“建造者风格(Builder-style)”**上进化:
把一个“大 payload”拆成多个可组合的构建步骤。

1)一个简化版 PayloadBuilder 示例

先来个最小可用的版本:

// builders/PayloadBuilder.js
export class PayloadBuilder {
  constructor(vm) {
    this.vm = vm;
    this.data = {};
  }

  withReview() {
    if (this.vm.reviewContent?.length) {
      this.data.reviewContent = this.vm.reviewContent;
    }
    return this;
  }

  withAttachments() {
    if (Array.isArray(this.vm.attachmentList)) {
      this.data.attachmentList = this.vm.attachmentList;
    }
    return this;
  }

  withBaseInfo() {
    if (this.vm.baseInfo) {
      this.data.baseInfo = this.vm.baseInfo;
    }
    return this;
  }

  // 你可以继续加更多 withXxx 模块…

  build() {
    return this.data;
  }
}

使用方式(举个场景):

import { PayloadBuilder } from '@/builders/PayloadBuilder';
import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => new PayloadBuilder(vm)
      .withReview()
      .withAttachments()
      .withBaseInfo()
      .build(),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

这里发生了几件事:

  • getPayload 仍然负责:
    找到 ref → 校验 → 错误处理

  • 具体 payload 构建过程交给 PayloadBuilder

    • 每个 withXxx() 负责一个独立模块;
    • build() 返回最终对象。

好处是:

  • 可以非常自然地按业务组合:

    • 某些场景只要 .withReview().withAttachments()
    • 另一些场景再 .withBaseInfo().withSomethingElse()
  • 单个 withXxx 内部逻辑变复杂也不怕,不会把一个函数搞成 200 行 if-else

2)什么时候才值得用 Builder 风格?

简单粗暴的判断:

  • 值得上 Builder 的情况

    • payload 真的是**「很多块拼起来」**的;
    • 不同业务场景需要「选择性启用某些块」;
    • 每块内部逻辑都可能变得很复杂(多条件、多分支、甚至异步)。
  • 没必要上 Builder 的情况

    • 只是把 3~5 个字段丢进对象里;
    • 变动很少,大部分字段都是 1:1 映射;
    • 没有复杂的组合逻辑。

换句话说:

Builder 风格的价值,在于把一个复杂构建过程拆成多个可组合的小模块
如果你的业务没有复杂到这个程度,
现在这套 formPayloadMap[refName](vm) + 少量 if,其实已经刚刚好。

3)和前面几级抽象的关系

可以把这几层理解成「渐进增强」:

  • Lv.3 高级前端:

    getPayload(vm, buildPayload, refName)

    • 通用流程 + 策略函数,已经很好用了。
  • Lv.4/架构阶段:

    配一个 formPayloadMap[refName] = buildPayload

    • 把“谁负责构建什么”集中管理。
  • Builder 风格:

    在单个 buildPayload(vm) 的实现内部,如果逻辑变复杂,再引入 PayloadBuilder

    • 是对某一个领域的构建细节做进一步拆分,而不是推翻整个体系。

也就是说,Builder 不是替代前面的抽象,而是为「某个复杂 payload」加的一层“精细化构建工具”。


七、这条路还能怎么走?一些扩展方向思路 🚀

最后顺带聊几个可以继续进化的方向,你可以根据项目实际情况慢慢加,不用一口吃胖子。

1)配合 TypeScript 做强类型约束

当前的写法都是 JS 靠自觉:

  • formPayloadMap 的 key/返回结构完全靠约定;
  • getPayload 的返回 { ok, payload } 也没有类型提示。

如果用 TS,可以做几件事:

  • formPayloadMap 建立一个统一的类型 Map:

    • 比如:type FormPayloadMap = { reviewForm: ReviewPayload; baseForm: BasePayload; ... }
  • getPayload 根据 refName 返回不同的 payload 类型(泛型 + 索引类型);

  • PayloadBuilder 每个 withXxx() 加上返回类型约束,防止漏字段/写错字段名。

好处是:一旦后端改了字段,TS 能第一时间把相关代码全标红,你就不需要靠“全局搜索 + 祈祷”。

2)统一成“多表单聚合”的流程

很多真实页面不是只有一个子表单,而是:

顶层页面 → N 个子块(基础信息、自评、报价、附件……)
最后统一点一个「提交」,要校验所有子块,组合所有 payload。

在现有基础上,可以设计一个“多表单聚合器”,伪代码例如:

async function collectAllPayloads(vm, configList) {
  const allPayload = {};

  for (const cfg of configList) {
    const { refName, mountPoint } = cfg;
    const res = await getPayload(vm, refName);
    if (!res.ok) return { ok: false };

    // mountPoint 决定这块 payload 挂在最终对象的哪里
    allPayload[mountPoint] = res.payload;
  }

  return { ok: true, payload: allPayload };
}

调用时:

const res = await collectAllPayloads(this, [
  { refName: 'baseForm', mountPoint: 'baseInfo' },
  { refName: 'reviewForm', mountPoint: 'review' },
  { refName: 'priceForm', mountPoint: 'price' },
]);

if (!res.ok) return;
await api.submit(res.payload);

这时:

  • getPayload单个表单的能力
  • collectAllPayloads多表单聚合的能力
  • 再配合 Builder,你就有了一条从“字段级 → 模块级 → 表单级 → 全页级”的构建链路。

3)把“校验 + 构建”做成可插拔中间件

现在,getPayload 里,校验逻辑顺序是写死的:

校验 → 错误提示 → 构建 payload

如果业务越来越复杂,可以考虑做成类似“中间件管线”的模式,比如:

const pipeline = [
  validateFormStep,
  extraAsyncCheckStep,
  normalizeDataStep,
  buildPayloadStep,
];

runPipeline(vm, refName, pipeline);

每个 step 接收 context(比如 { vm, form, payload }),按顺序处理。
这样你可以:

  • 在某些表单插入额外风控校验;
  • 在某些表单前面加数据归一化逻辑;
  • 保持主流程一致但允许个性化“插片”。

这就已经非常接近「前端领域里自己的 mini-framework」了。

4)跨框架 / 跨项目复用

你现在的设计,其实已经很容易跨栈:

  • ref 概念:React 可以用 useRef + forwardRef 来模拟;
  • validate:绝大多数表单库都有类似 API;
  • payload 构造:和框架无关,本身就是纯函数/Builder。

如果你有多个项目(Vue2/Vue3/React 混合),理论上可以:

  • 把“构建规则”和“校验规则”抽到一个独立 npm 包;
  • 每个项目只写一层很薄的“外壳”,适配自己的 ref/组件体系。

这就是从“一个项目里的抽象”,升级成“多项目共享的领域包”。


你现在这整套,从最开始那段:

if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}
const payload = { ... };

一路演进到:

  • 通用 getPayload
  • 配置化 formPayloadMap
  • 按需引入 PayloadBuilder 做复杂构建
  • 再往外是多表单聚合、类型约束、流水线、跨项目复用

# vue2 使用 cesium 展示 TLE 星历数据

vue2 使用 cesium 展示 TLE 星历数据

为啥突然写这么一篇文章呢,是因为我现在对 cesium 也是了解一些,在做一个项目的时候用到了,发现里面的东西还很多,稍微说一下。

环境准备

1. cesium包

项目中需要引入 cesium 包,我不是使用 npm 安装的,我是直接在官网下载的 cesium 包。下载完成后把包放进项目中使用的。

在这里插入图片描述

然后引入的话,只需要在 index.html 中引入一下就可以了。

<head> 标签里面放:

<link rel="stylesheet" href="./Cesium/Widgets/widgets.css">

<body> 标签最后面放:

<script type="text/javascript" src="./Cesium/Cesium.js"></script>

然后就完事了。cesium 也算引入成功了。

如果不放心的话,可以运行起 vue 项目,在控制台输入命令查看一下引入的 Cesium 版本:

console.log(Cesium.VERSION);

在这里插入图片描述

2. satellite.js

这个的话我就是使用 npm 安装的了,我安装的 5.0 版本以上的,最好是5这个版本往上,不要装4,API可能会有差异。

在这里插入图片描述

3. 其他

其他的话就是 moment,用来转换时间啥的,这个直接安装就行,想用就用,不想用就用 Date() 转,都可以。

Cesium 开发

首先我们创建一个 TCesium.js 文件,用来编写 Cesium 相关的代码:

在这里插入图片描述

当然,案例嘛,我文件随便创建了,但是正经开发的话需要做好目录规划。

初始化蓝星(地球)

首先我也不知道为啥把地球叫蓝星,很多人把地球称作蓝星,我也就这样叫吧。

1. 创建 TCesium 类

首先创建一个TCesium 类,在构造函数里面初始化蓝星。我尽可能把注释写的详细一些

export class TCesium {

  dom = null,  // dom节点对象
  scene = null;  // 当前场景
  viewer = null;  // 当前地图对象
  trackEntities = {} // 卫星对象
  totalTime = 24*60*60   // 仿真总时长,默认一天, 单位秒
  timeInterval = 60  // 采样间隔,单位秒
  satelliteDataSource = null  // 卫星数据源

// 接收一个dom,就是用来渲染蓝星的Dom
  constructor(dom) {
    this.dom = dom;  // 把穿进的dom节点赋值给全局
    Cesium.Ion.defaultAccessToken = '这个地方需要填写自己在官网申请的Token值';   // 设置 Token,需要从官网自己申请
    this.viewer = new Cesium.Viewer(dom, {
      homeButton: false,  // 显示主页按钮
      sceneModePicker: true,   // 是否显示切换2D/3D按钮
      baseLayerPicker: false,  // 图层切换按钮
      animation: true,  // 动画,需要开启
      infoBox: false,  // 信息框显示
      selectionIndicator: false, 
      geocoder: false,
      timeline: true,  // 时间轴,要开启的啊
      fullscreenButton: false,
      shouldAnimate: false,
      navigationHelpButton: false,
      terrainProvider: new Cesium.CesiumTerrainProvider({   // 基础地形数据
        url: 'https://www.supermapol.com/realspace/services/3D-stk_terrain/rest/realspace/datas/info/data/path',
        requestVertexNormals: true
      }),
    })
    this.scene = this.viewer.scene;
    this.viewer.scene.postProcessStages.fxaa.enabled = true   // 开启抗锯齿优化
    this.viewer.scene.sun.show = false;   // 不显示太阳
    this.viewer.scene.moon.show = false;   // 不显示月亮
    this.viewer.scene.skyBox.show = false;  // 不显示星空
  }
}

然后蓝星初始化完成了就,我们需要在使用蓝星的组件编写一下基础结构引入一下就可以了。

<template>
  <div class="app">
    <div class="ed-earth-model" id="earthModel" ref="earthModel"></div>
  </div>
</template>
<script>
import { TCesium } from "./TCesium";
export default {
  name: "Home",

  data() {
    return {
      cesium: null
    }
  },

  mounted() {
    this.$nextTick(() => {
      this.initMap();  // 初始化地图
    })
  },

  methods: {
    initMap() {
      if (!this.cesium) {
        this.cesium = new TCesium(this.$refs.earthModel);
      }
    }
  }
};
</script>
<style scoped>
.app {
  position: relative;
  width: 100%;
  height: 100%;
}

.ed-earth-model {
  width: 100%;
  height: 100%;
}
</style>

编写完上面的代码就可以看到蓝星初始化完成了。

在这里插入图片描述

非常好!完美~

2. 初始化数据

我们写一个方法用来初始化数据,加载TLE星历数据的话,我们可以从网上找一些星历数据,或者客户提供星历数据,他们提供的星历数据一般是这个格式的:

在这里插入图片描述

每个卫星的星历包含三行,然后如果有多个的话就往下排,这个星历文件就是简单的 txt 文件。

然后我们需要把这个星历文件转换成JSON对象的形式,就像下面:

{
  "name": "STARLINK-2589",
  "tle1": "1 48371U 21038U   25270.54822457  .00018630  00000+0  12665-2 0  9999",
  "tle2": "2 48371  53.0562   3.9906 0001373 107.0633 253.0506 15.06394038242577"
},

这个可以写个JS函数进行转换,我写了,但是我不想浪费时间贴了,这个不是重点,到时候你们需要的话直接自己写一个就行了。

星历数据自己去找就行,我上面的都不一定准确了,可以去网站上去复制,很多网站可以查看全球登记在册的卫星,都可以复制星历数据,要保证星历数据的准确性哈,出一点问题都不行,星历数据有了的话,然后我们就可以在地球展示轨道了。

但是有一点需要说一下,就是这个星历啊,他是有时效性的,一般时效性就是7天到14天左右,太久了的话不是不能用,而是不准确了,卫星的位置可能天差地别了。

3. 卫星仿真

首先仿真这个就是看卫星在某一时间的运行轨迹嘛,我们写一个方法,然后来处理这个逻辑。

首先仿真时间可以是自定义仿真时间,也可以是使用当前时间开始仿真。

比如根据提供的星历数据,看一下 2025-10-10 12:00:002025-10-10 15:00:00 这三个小时的卫星轨迹。或者是看一下现在时间到未来两个小时内的卫星运行轨迹。

准备数据

我先贴代码,然后一点一点解释哈:

  addTle() {
    let tleData = [   // 星历数据
      {
        "name": "STARLINK-2589",
        "tle1": "1 48371U 21038U   25270.54822457  .00018630  00000+0  12665-2 0  9999",
        "tle2": "2 48371  53.0562   3.9906 0001373 107.0633 253.0506 15.06394038242577"
      }
    ]
    let startTime = "2025-11-11 08:00:00";  // 东八区时间,也就是北京时间
    let endTime = "2025-11-11 12:00:00";  // 东八区时间,也就是北京时间
    this.loadTleFile(tleData, true, startTime, endTime);  // 调用加载TLE数据的函数
  }

这个函数就是准备了一下星历数据,当然我这里写死了,到时候项目肯定后端返回了。

然后创建了startTimeendTime ,表示仿真这个时间段的卫星轨迹。

然后就是去走loadTleFile()函数。

加载TLE数据函数

我先写代码:

  // 加载星历文件,实现卫星轨迹显示
  loadTleFile(tleData, showPath = false, startTime = null, endTime = null) {
    let start = "";
    let stop = "";
    if (startTime && endTime) { // 假设提供了开始时间和结束时间
      start = Cesium.JulianDate.fromDate(this.beijingTimeToDate(startTime));
      stop = Cesium.JulianDate.fromDate(this.beijingTimeToDate(endTime));
    } else {  // 如果没有传时间默认从系统当前时间往后一天的仿真时间
      start = Cesium.JulianDate.fromIso8601(new Date().toISOString());
      stop = Cesium.JulianDate.addSeconds(start, this.totalTime, new Cesium.JulianDate());
    }
    this.totalTime = Cesium.JulianDate.secondsDifference(stop, start);  // 计算开始时间和结束时间的时间差 单位秒
    this.viewer.clock.startTime = start.clone() // 给cesium时间轴设置开始的时间,也就是上边的东八区时间
    this.viewer.clock.stopTime = stop.clone() // 设置cesium时间轴设置结束的时间
    this.viewer.clock.currentTime = start.clone() // 设置cesium时间轴设置当前的时间
    this.viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP // 时间结束了,再继续重复来一遍
    this.viewer.clock.multiplier = 1 // 时间轴倍速,1是正常速度,2是两倍速,0.5是半速
    this.viewer.clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK_MULTIPLIER // 时间轴步长,SYSTEM_CLOCK_MULTIPLIER是根据系统时间步长来更新时间轴,其他选项还有TICK_DEPENDENT和SYSTEM_CLOCK
    this.viewer.timeline.updateFromClock();  // 强制更新时间轴
    this.viewer.timeline.zoomTo(start.clone(), stop.clone());  // 设置时间轴显示区间

    if (!this.satelliteDataSource) {  // 判断卫星数据源是不是空(可以不用,视情况而定,写一下是知道可以这样用)
      this.satelliteDataSource = new Cesium.CustomDataSource('satellite')  // 是空则创建一个数据源
      this.viewer.dataSources.add(this.satelliteDataSource)  // 把数据源添加到viewer,后期卫星全部添加到卫星数据源
    }
    // 分批处理卫星数据,避免一次性处理所有卫星导致页面卡住
    let sss = JSON.parse(JSON.stringify(tleData))
    this._loadSatellitesInBatches(sss, start, stop, 10, showPath) // 每批加载10个卫星
  }

首先loadTleFile()函数接收四个参数,分别是星历数据列表是否显示卫星轨迹线仿真开始北京时间仿真结束北京时间

里面主要做了啥呢?主要就是设置时间轴,让Cesium界面上的时间轴显示成我们设置的仿真时间段

这里有一问题需要特别注意一下哈,那就是时间问题。我们传入的开始时间和结束时间是北京时间,也就是东八区的时间。但是!Cesium时间轴设置的时间需要是UTC时间

UTC 时间(Coordinated Universal Time,协调世界时)是全球通用的标准时间,被世界各地用作时间基准,其核心特点是统一、无时区偏移、基于原子时和地球自转校准。

所以说我们设置时间轴的时候需要把北京时间转换成UTC时间

我们使用了beijingTimeToDate()函数:

      start = Cesium.JulianDate.fromDate(this.beijingTimeToDate(startTime));
      stop = Cesium.JulianDate.fromDate(this.beijingTimeToDate(endTime));

beijingTimeToDate()函数可以把北京时间字符串转成Date类型的对象,然后通过Cesium.JulianDate.fromDate()函数可以转成UTC时间。

  // 北京时间字符串转换为Date对象
  beijingTimeToDate(beijingTimeString) {
    const isoString = beijingTimeString.replace(' ', 'T');
    return new Date(isoString);
  }

解释一下,其实北京时间和UTC时间差了八小时,也就是说北京时间比UTC时间多八小时。比如北京时间2025-11-11 08:00:00 对应的UTC时间就是 2025-11-11 00:00:00

在后面就是设置时间轴了,更新时间轴,这样的话,我们时间轴就设置成功了。

在这里插入图片描述

设置成功就调用了 _loadSatellitesInBatches() 函数分批处理星历数据了。

_loadSatellitesInBatches()函数 分批处理星历数据

贴这个函数完整代码,后面解释:

  // 分批加载卫星数据 
  _loadSatellitesInBatches(satellites, start, stop, batchSize, showPath) {
    const batches = [];
    // 将卫星数据分成多个批次
    for (let i = 0; i < satellites.length; i += batchSize) {
      batches.push(satellites.slice(i, i + batchSize));
    }
    // 递归处理每个批次
    const processBatch = (batchIndex) => {
      if (batchIndex >= batches.length) {
        return; // 所有批次处理完毕
      }
      // 处理当前批次
      const currentBatch = batches[batchIndex];
      currentBatch.forEach(satellite => {
        this._addSatelliteEntity(satellite, start, stop, showPath);
      });
      // 在下一帧处理下一批次,避免阻塞主线程
      requestAnimationFrame(() => {
        processBatch(batchIndex + 1);
      });
    };
    // 开始处理第一批
    processBatch(0);
  }

为啥要分批处理哈,其实单纯案例的话不需要,分批是怕卫星星历很多,比如上百颗卫星,或者是上千颗卫星,最好分批。

因为后面需要对每颗卫星的星历进行处理,获取每颗卫星不同时间段的位置,比如1000颗卫星,每颗卫星取1000个位置,这个遍历时间是有点儿久的,如果不分批的话,Cesium展示的进程会被卡死,页面整个操作不了了,但是案例就一颗卫星,不需要分批,但是做分批处理是没坏处的,有备无患。

_loadSatellitesInBatches()函数接收五个参数,分别是星历列表开始UTC时间结束UTC时间每批个数是否显示轨迹线

这个函数没啥好说的,看看就懂,跟Cesium没啥关系,就是分批加载,一帧加载十颗卫星。

每一帧遍历这一批次的卫星星历,执行一个this._addsatelliteEntity(satellite, start, stop, showPath); 函数,这个函数才是真正的处理星历的函数。

_addSatelliteEntity 处理星历函数

首先还是贴代码:

  // 添加单个卫星实体
  _addSatelliteEntity(satellite, start, stop, showPath) {
    // 创建临时对象存储当前卫星信息,避免修改this对象
    const satelliteInfo = {
      name: satellite.name.trim(),
      tleLine1: satellite.tle1.trim(),
      tleLine2: satellite.tle2.trim(),
      satrec: twoline2satrec(satellite.tle1.trim(), satellite.tle2.trim()),
      leadTime: parseInt(24 * 3600 / satellite.tle2.slice(52, 64))
    };

    // 创建卫星实体
    let cesiumSateEntity = {
      id: satellite.noradId || satellite.name,  // 设置卫星对象的ID
      name: satellite.name, // 设置卫星的名称
      description: satellite.name,  // 设置描述
      // 使用专用函数计算位置,传入卫星信息
      position: this._getSatellitePositionProperty(start, satelliteInfo),
      point: {  // 卫星用点表示
        pixelSize: 10,  // 卫星点十个像素
        color: Cesium.Color.WHITE,  // 卫星点是白色的
      },
      path: {  // 卫星轨迹线设置
        width: 0.5,   // 轨迹线的宽度,单位是像素
        leadTime: satelliteInfo.leadTime,  //  轨迹线 “前瞻时间”,即显示卫星从当前时间开始,未来一段时间内的预测轨迹长度,这里设置的就是卫星运行一圈的时间,也就是显示未来一圈的轨迹
        trailTime: 0,  // 轨迹线 “回溯时间”,即显示卫星从过去一段时间到当前时间的轨迹长度,设置为 0 表示不显示卫星过去的轨迹,只展示未来的预测轨迹
        material: Cesium.Color.YELLOW,  // 线是黄色的
        show: showPath === true ? true : false, // 默认隐藏轨迹
        clampToGround: false  // 轨迹线是否 “贴地”,卫星在天上飞,轨迹就不要贴地了哈
      },
      label: {  // 卫星的label展示配置
        show: true,  // 是否显示
        text: satellite.name,  // 显示的内容
        font: '12px sans-serif',   // 字体设置
        fillColor: Cesium.Color.WHITE,   // 填充颜色白色
        outlineColor: Cesium.Color.BLACK,  // 边框颜色黑色
        outlineWidth: 2,  // 边框宽度2像素
        pixelOffset: new Cesium.Cartesian2(0, 15),  // 偏移量
      }
    };
    // 添加卫星实体
    let satelliteEntity = this.satelliteDataSource.entities.add(new Cesium.Entity(cesiumSateEntity));
    this.trackEntities[satelliteEntity.id] = satelliteEntity;
  }

首先这个函数接收四个参数:单颗卫星星历数据仿真开始UTC时间仿真结束UTC时间是否显示卫星轨迹线

函数第一步是对卫星 TLE 数据的结构化封装:

在这里插入图片描述

因为传进来的satellite其实就是单个卫星数据,就是这个:

在这里插入图片描述

所以satelliteInfo 前三个值不解释了。

然后第三个字段,satrec: twoline2satrec(satellite.tle1.trim(), satellite.tle2.trim()) 是通过 twoline2satrec 方法(通常来自 satellite.js 库)解析 TLE 数据后得到的 卫星轨道模型对象(satrec)satrec 包含了 SGP4 轨道计算所需的所有参数(如半长轴、倾角、历元时间等),是后续调用 propagate 方法计算卫星实时位置(ECI 坐标)的核心输入。

twoline2satrec 方法是satellite.js 库里面的,需要在顶部引入一下:

import { twoline2satrec, propagate, gstime, eciToGeodetic, degreesLong } from 'satellite.js'

这几个都是后面用到的,全写上吧。

第四个字段,leadTime: parseInt(24 * 3600 / satellite.tle2.slice(52, 64)) 用来计算卫星的轨道周期,单位是秒,表示卫星绕地球一周所需的时间。

这个satelliteInfo 对象解释完了,后面可能用到里面的字段,注意知道每个字段的含义就行。

在后面就是创建一个卫星实体cesiumSateEntity,代码注释的很清楚了,再就是把卫星实体对象添加到卫星数据源里面,其中添加到卫星数据源之后会返回这个卫星实体对象。

// 将卫星添加到卫星数据源
let satelliteEntity = this.satelliteDataSource.entities.add(new Cesium.Entity(cesiumSateEntity));

他会返回一个satelliteEntity ,你可以通过修改satelliteEntity 的内容,页面上的卫星状态也会跟着改变,比如颜色、是否显示轨迹、是否可见啥的,也可以获取他的信息,比如经度、纬度啥的。所以后边在对象里面存储了一下:

this.trackEntities[satelliteEntity.id] = satelliteEntity; // 存储卫星实体

主要说的是啥呢!是创建卫星实体对象的时候设置位置调用了一个专用的计算函数:

  // 使用专用函数计算位置,传入卫星信息
  position: this._getSatellitePositionProperty(start, satelliteInfo),

调用的_getSatellitePositionProperty函数才是关键,他用来计算这个卫星在这个仿真时间段的位置信息!

卫星位置计算

还是哈,先贴代码:

  // 为单个卫星计算位置属性
  _getSatellitePositionProperty(start, satelliteInfo) {
    const positionProperty = new Cesium.SampledPositionProperty();  // 位置属性
    const baseTime = Cesium.JulianDate.toDate(start).getTime();  // 开始时间的时间戳
    // 这样可以在保持轨迹连续性的同时大幅提高性能
    const sampleInterval = this.timeInterval; // timeInterval 秒钟一个采样点(秒)
    const totalSamples = Math.ceil(this.totalTime / sampleInterval); // 计算采样点数
    for (let i = 0; i < totalSamples; i++) {  // 遍历每个采样点
      const currentTime = new Date(baseTime + i * sampleInterval * 1000);  // 采样时间
      const sateCoord = propagate(satelliteInfo.satrec, currentTime).position;  // 计算当前时间的位置
      if (!sateCoord?.x || !sateCoord?.y || !sateCoord?.z) {  // 如果计算出的位置为空,则跳过
        continue
      }
      let gsmt = gstime(currentTime)  // 计算当前时间的 GST 时间
      let efgd = eciToGeodetic(sateCoord, gsmt)  //  将 ECI 坐标转换为大地坐标
      let lon = degreesLong(efgd.longitude)  // 将大地坐标转换为度
      let lat = degreesLong(efgd.latitude)  //  将大地坐标转换为度
      let height = efgd.height  //  高度
      let stkWorldPoint = Cesium.Cartesian3.fromDegrees(lon, lat, height * 1000)  // 将大地坐标转换为 STK 世界坐标
      const cesiumTime = Cesium.JulianDate.addSeconds(start, i * sampleInterval, new Cesium.JulianDate());
      positionProperty.addSample(cesiumTime, stkWorldPoint);
    }
    return positionProperty;
  }

这个函数 _getSatellitePositionProperty 是用于在 Cesium 中生成单个卫星的时间序列位置属性(SampledPositionProperty),核心功能是通过轨道计算获取卫星在一段时间内的连续位置,并将其转换为 Cesium 可识别的坐标格式,最终用于绘制卫星轨迹或动态展示卫星运动。

整体逻辑是:函数从指定的起始时间 start 开始,按固定时间间隔采样,计算卫星在每个采样时刻的空间位置,将这些 “时间 - 位置” 对整合为 SampledPositionProperty 对象(Cesium 中用于描述随时间变化的位置的专用属性),供卫星实体绑定轨迹或动态更新位置使用。

对这个函数重点解释一下:

const positionProperty = new Cesium.SampledPositionProperty();  // 位置属性

这行代码是用来初始化位置属性,创建 SampledPositionProperty 实例,用于存储 “时间点 - 位置” 的映射关系,是 Cesium 中描述动态位置的核心对象(支持插值计算,使轨迹平滑)。

const baseTime = Cesium.JulianDate.toDate(start).getTime();  // 开始时间的时间戳

baseTime 是用来做基准时间的。就是仿真开始时间,这里转换成了时间戳。将 Cesium 的 JulianDate 格式起始时间(start)转换为 JavaScript 时间戳(毫秒级),方便后续计算采样时间。

const sampleInterval = this.timeInterval; // timeInterval 秒钟一个采样点(秒)
const totalSamples = Math.ceil(this.totalTime / sampleInterval); // 计算采样点数

上面这两行主要用来设置采样参数sampleInterval是每次采样的时间间隔(单位:秒),控制轨迹的精度(间隔越小,轨迹越精细,但性能消耗越高);totalSamples是根据总时长(this.totalTime)和间隔计算需要的采样点数量,确保覆盖整个时间段。

for (let i = 0; i < totalSamples; i++) { ... }

for循环的话,就是为了遍历每个采样点,计算对应时间的卫星位置。

然后是for循环里面的逻辑:

const currentTime = new Date(baseTime + i * sampleInterval * 1000);  // 采样时间
const sateCoord = propagate(satelliteInfo.satrec, currentTime).position;  // 计算当前时间的位置

currentTime 这个是啥呢,就是获取第 i 个采样点的时间,即:基准时间 + 第 i 个取样点 * 取样间隔时间(s) * 1000,也就是基于起始时间戳,累加 i * 采样间隔(毫秒),得到当前采样点的 UTC 时间(Date 对象)。

sateCoord 是调用 propagate 方法,根据卫星的 satrec 轨道模型和当前时间,计算卫星在惯性坐标系(ECI) 中的位置(x, y, z,单位通常为千米)。

可以理解通过这两行代码,最后获得了这个在某个时间点下,该卫星在惯性坐标系下的位置信息。

继续:

if (!sateCoord?.x || !sateCoord?.y || !sateCoord?.z) {  // 如果计算出的位置为空,则跳过
  continue
}

上面这个判断是为了过滤无效位置,若轨道计算失败(返回 NaN 或 undefined),跳过当前采样点,避免错误数据影响轨迹。

再往下就是:

let gsmt = gstime(currentTime); // 计算当前时间的格林尼治恒星时(GST)
let efgd = eciToGeodetic(sateCoord, gsmt); // ECI 转大地坐标

gstime(currentTime)是用来计算当前时间的格林尼治恒星时(用于 ECI 到地固坐标的转换,消除地球自转影响)。 eciToGeodetic是将惯性系(ECI)坐标转换为大地坐标(经度、纬度、高度,基于 WGS84 椭球),即卫星在地面观测者眼中的位置

let lon = degreesLong(efgd.longitude); // 经度(弧度→度)
let lat = degreesLong(efgd.latitude);  // 纬度(弧度→度)
let height = efgd.height;              // 高度(千米)

上面是将大地坐标单位转换,将经纬度从弧度转换为度(Cesium 常用单位),保留高度值(后续需转换为米)。

再往下是:

let stkWorldPoint = Cesium.Cartesian3.fromDegrees(lon, lat, height * 1000);

讲位置信息转换为 Cesium 世界坐标。Cesium.Cartesian3.fromDegrees作用是将经纬度(度)和高度(米,因此 ×1000 转换千米→米)转换为 Cesium 的地固坐标系(ECEF) 坐标(Cartesian3 对象),即三维世界中的位置。

最后了:

const cesiumTime = Cesium.JulianDate.addSeconds(start, i * sampleInterval, new Cesium.JulianDate());
positionProperty.addSample(cesiumTime, stkWorldPoint);

绑定时间与位置,计算当前采样点对应的 Cesium 时间(JulianDate 格式),将 “时间 - 位置” 对添加到 positionProperty 中,完成一个采样点的记录。

最终返回包含所有采样点的 SampledPositionProperty 对象,可直接绑定到 Cesium 的卫星实体(Entity)上,用于动态展示卫星轨迹和位置

所以这个函数是卫星轨迹可视化的核心逻辑,通过 “时间采样→轨道计算→坐标转换→绑定属性” 的流程,将卫星的轨道参数(TLE)转换为 Cesium 可渲染的动态位置数据。

然后就可以看一下页面效果:

在这里插入图片描述在这里插入图片描述

我们可以看到卫星轨迹线已经加载出来了。

然后我们用第三方软件加载一下这个星历,看一下效果是不是一样的:

在这里插入图片描述

我们可以看到我们绘制的轨迹线和第三方软件绘制的是一样的。这就完成了!

注意

有一点需要注意。

在这里插入图片描述

就是我们看3D的效果,卫星轨迹线为什么转完一圈之后首尾没有闭合啊?感觉像是轨迹线跑偏了一样,他理论上围着地球绕圈,不应该是个正圆吗?为什么还是“丝带”状缠起来了?我看别人做的是正圆。

这是一个很好的问题!

因为我们在_getSatellitePositionProperty函数中,计算卫星采样位置的时候,最后转换成了地固坐标系(ECEF)。而之前那些正圆,可以实现首尾相连的使用的是惯性坐标系(ECI)

看之前我们写的代码可以看出来,我们拿到惯性坐标后转成了地固坐标

那么 惯性坐标 和 地固坐标 的区别是什么?

惯性坐标(ECI)地固坐标都是用来描述卫星、航天器等空间物体位置的两种核心坐标系,核心区别在于是否随地球自转,适用场景也因此不同。

惯性坐标:地面上的固定点(如北京)的坐标会随地球自转而持续变化(每天绕 Z 轴旋转一圈);卫星的坐标变化仅由其自身轨道运动导致(如近地卫星沿椭圆轨道运动)。

地固坐标:地面上的固定点(如北京)的坐标始终不变;卫星的坐标变化是其轨道运动与地球自转的 “叠加效果”(如卫星从西向东运动,同时地球也在自转)。

ECI 是 “宇宙视角”:不随地球转,适合分析卫星的绝对轨道运动; ECEF 是 “地球视角”:随地球转,适合描述物体相对于地面的位置。

那这样的话什么时候使用惯性坐标,什么时候使用地固坐标呢?

惯性坐标适用于:卫星轨道力学分析轨道设计航天器导航(需计算绝对运动)。卫星轨道预测(如用 TLE 计算 ECI 坐标)、星际航行轨道规划、天体力学仿真。

固地坐标适用于地面观测通信链路分析、地图匹配(需描述相对地球表面的位置)。卫星地面覆盖范围计算、地面站与卫星的方位角 / 仰角计算、GPS 等导航系统定位。

获取惯性坐标位置展示

那上面写了固地坐标展示的方式,下面写一下惯性坐标展示的方法,其实很简单,只需要修改_getSatellitePositionProperty函数:

  // 为单个卫星计算位置属性
  _getSatellitePositionProperty(start, satelliteInfo) {
    const positionProperty = new Cesium.SampledPositionProperty();  // 位置属性
    const baseTime = Cesium.JulianDate.toDate(start).getTime();  // 开始时间的时间戳
    // 这样可以在保持轨迹连续性的同时大幅提高性能
    const sampleInterval = this.timeInterval;  // 采样时间间隔(秒)
    const totalSamples = Math.ceil(this.totalTime / sampleInterval); // 计算采样点数
    for (let i = 0; i < totalSamples; i++) {  // 遍历每个采样点
      const currentTime = new Date(baseTime + i * sampleInterval * 1000);  // 采样时间
      const sateCoord = propagate(satelliteInfo.satrec, currentTime).position;  // 计算当前时间的位置
      if (!sateCoord?.x || !sateCoord?.y || !sateCoord?.z) {  // 如果计算出的位置为空,则跳过
        continue
      }
      const cesiumTime = Cesium.JulianDate.addSeconds(start, i * sampleInterval, new Cesium.JulianDate());
      const cesiumPosition = {
        x: sateCoord.x * 1000,
        y: sateCoord.y * 1000,
        z: sateCoord.z * 1000
      }
      positionProperty.addSample(cesiumTime, cesiumPosition);
    }
    return positionProperty;
  }

这就可以了

在这里插入图片描述在这里插入图片描述

这就是首尾相连的正圆了。

再说一点哈

很多人可能觉得这个方式绘制星历太麻烦了,性能还不好,然后使用czml不是更好吗?我想说的是,在实际开发中会遇到各种离谱的事情,让人不得不放弃一些东西,毕竟选择的实现方式还是以实际开发后出结果为准,加油吧各位!希望对大家有用!

好了今天就先到这儿!

一文带你剖析 Promise.then all 实现原理,状态机、发布订阅模式完美实现异步编程

一文带你剖析 Promise 实现原理,状态机、发布订阅模式完美实现异步编程

注:本文代码为仿promise原理简写的代码,非源码。

1. 概述

本文档详细解析了基于 ES6 Class 实现的 Promise,包括其核心原理、状态管理机制、异步处理流程以及各种方法的实现细节。通过深入理解 Promise 的内部实现,帮助你更好地掌握异步编程范式。

2. Promise 核心原理

2.1 状态机设计

Promise 采用状态机模式,具有三种互斥状态:

  • pending: 初始状态,既未完成也未拒绝
  • fulfilled/resolved: 操作成功完成
  • rejected: 操作失败

核心特性:状态一旦改变,就不会再变,这是 Promise 可靠性的基础。

// 状态定义与初始化
constructor(executeFn) {
  this.status = 'pending'      // 初始状态为 pending
  this.value = undefined       // 存储成功结果
  this.reason = undefined      // 存储失败原因
  this.onResolvedCallbacks = [] // 成功回调队列
  this.onRejectedCallbacks = [] // 失败回调队列
  // ...
}

2.2 发布-订阅模式

Promise 内部采用发布-订阅模式处理异步回调:

  • 在 pending 状态时,通过 then/catch 方法注册的回调会被存储在回调队列中
  • 当 Promise 状态改变时,会遍历执行对应队列中的所有回调(constructor中通过resolve或reject函数触发回调)

这解决了异步操作和回调注册的时序问题,确保即使回调在状态变更前注册,也能在状态变更后得到执行。

3. 核心实现详解

3.1 构造函数实现 💖

构造函数接收一个 executeFn 函数,立即执行该函数并传入 resolve 和 reject 两个函数作为参数:

constructor(executeFn) {
  // 初始化状态和数据
  this.status = 'pending'
  this.value = undefined
  this.reason = undefined
  this.onResolvedCallbacks = []
  this.onRejectedCallbacks = []
  
  // 使用箭头函数定义 resolve/reject,确保 this 指向 Promise 实例
  const resolve = (value) => {
    if (this.status === 'pending') {
      this.status = 'fulfilled'
      this.value = value
      // 发布成功事件,执行所有成功回调
      this.onResolvedCallbacks.forEach(fn => fn(this.value))
    }
  }
  
  const reject = (reason) => {
    if (this.status === 'pending') {
      this.status = 'rejected'
      this.reason = reason
      // 发布失败事件,执行所有失败回调
      this.onRejectedCallbacks.forEach(fn => fn(this.reason))
    }
  }
  
  // 捕获 executeFn 执行过程中的异常
  try {
    executeFn(resolve, reject)
  } catch (error) {
    reject(error)
  }
}

3.2 then 方法实现💖

then 方法是 Promise 规范(Promise/A+)的核心,它是 Promise 能够实现异步操作链式调用的关键机制,被誉为 JavaScript 异步编程的革命性突破。其重要性体现在:

  • 彻底解决回调地狱问题:通过链式调用替代嵌套回调,使异步代码可读性大幅提升
  • 统一异步操作接口:为所有异步操作提供了标准化的处理方式
  • 错误传播机制:实现了错误在链式调用中的自动传递,无需每层都添加错误处理
  • 状态隔离与数据传递:保证了异步操作结果的正确传递和状态的严格隔离
  • 异步操作编排:支持复杂异步操作流程的构建和组合

核心要点:

  1. 值穿透机制(参数校验与默认处理)

    • onFulfilledonRejected 不是函数时(初始化为函数),Promise 规范要求实现「值穿透」
    • 成功状态的值穿透:value => value - 确保成功的值能够继续在链中传递
    • 失败状态的值穿透:reason => { throw reason } - 确保错误能够继续在链中传播
  • 这一机制使得可以在链式调用中间跳过某些处理步骤而不中断链式调用

    当 then 方法不传递回调函数时,会使用默认函数将值传递给下一个 then 方法,实现值的穿透。

  1. 链式调用实现(Promise 链)

    • 每次调用 then 方法都返回一个全新的 Promise 实例(promise2
    • 这是链式调用的基础,保证了每个 then 操作都在独立的 Promise 上下文中执行
  • 新 Promise 的状态由回调函数的执行结果决定,实现了状态的隔离和转换
  1. 状态驱动的行为模式---状态机

    • 根据当前 Promise 的状态(pending/fulfilled/rejected)采取不同的处理策略
    • 已完成状态(fulfilled/rejected):立即异步执行对应回调
    • 进行中状态(pending):将回调函数存储到对应队列中等待执行
    • 这体现了状态模式的设计思想,行为随状态而变化
  2. 异步执行保证

    • 使用 setTimeout(fn, 0) 模拟微任务,确保回调函数异步执行
    • 这符合 Promise/A+ 规范要求,保证了回调执行的时序一致性
    • 避免了同步执行可能导致的状态不一致和执行顺序问题
  3. 结果处理与值传递

    • 通过 resolvePromise 辅助函数处理回调返回值,支持:
      • 返回 Promise 对象:等待该 Promise 解决并采用其结果
      • 返回普通值:直接将该值作为下一个 Promise 的成功值
      • 抛出异常:将异常作为下一个 Promise 的失败原因
    • 这一设计实现了值在异步链中的自然传递和转换
then(onFulfilled, onRejected) {
  // 参数校验,支持值穿透.当 `onFulfilled` 或 `onRejected` 不是函数时,使用箭头函数将其转为函数。实现值穿透
  onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value
  onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason }

  // 返回新的 Promise 以支持链式调用
  const promise2 = new MyPromise((resolve, reject) => {
    // 成功状态处理
    if (this.status === 'fulfilled') {
      // 异步执行回调
      setTimeout(() => {
        try {
          // 使用了值穿透,这里才能直接使用onFulfilled(this.value),否则就异常了
          const x = onFulfilled(this.value) 
          // 处理回调返回值
          this.resolvePromise(promise2, x, resolve, reject)
        } catch (error) {
          reject(error)
        }
      }, 0)
    }

    // 失败状态处理
    if (this.status === 'rejected') {
      setTimeout(() => {
        try {
          const x = onRejected(this.reason)
          this.resolvePromise(promise2, x, resolve, reject)
        } catch (error) {
          reject(error)
        }
      }, 0)
    }

    // pending 状态处理 - 存储回调
    // 订阅过程(注册回调)
    if (this.status === 'pending') {
      this.onResolvedCallbacks.push(() => {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      })

      this.onRejectedCallbacks.push(() => {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason)
            this.resolvePromise(promise2, x, resolve, reject)
          } catch (error) {
            reject(error)
          }
        }, 0)
      })
    }
  })

  return promise2
}

3.3 resolvePromise 辅助函数--处理 then 方法回调函数的返回值💖

核心作用与重要性

resolvePromise 是 Promise 实现中最为复杂和核心的辅助函数,它是 Promise/A+ 规范中实现 "Promise Resolution Procedure"(Promise 解决过程)的关键。这个函数负责处理 then 方法回调函数的返回值,并根据返回值类型决定如何处理下一个 Promise 的状态转换,是实现链式调用和值传递的核心机制

参数说明

resolvePromise(promise2, x, resolve, reject)
  • promise2: then 方法返回的新 Promise 实例
  • x: then 方法回调函数的返回值(onFulfilled 或 onRejected 的返回值)
  • resolve: promise2 的 resolve 函数,用于将 promise2 状态改为 fulfilled
  • reject: promise2 的 reject 函数,用于将 promise2 状态改为 rejected

详细要点:

  1. 循环引用检测

    • xpromise2 是同一个对象时,会导致无限循环
    • 通过 if (promise2 === x) 判断避免这种情况,并抛出类型错误
    • 这是 Promise/A+ 规范强制要求的安全机制
  2. 状态凝固保障

    • 使用 called 标志变量确保 resolvereject 只能被调用一次
    • 这符合 Promise 的核心特性:状态一旦改变就不能再变
    • 在所有可能调用 resolve/reject 的地方都检查 called 标志
  3. Promise 类型处理

    • x 是 Promise 实例时,需要等待其状态变化
    • 递归调用 x.then(),将结果继续通过 resolvePromise 处理
    • 确保 Promise 链能够正确等待异步操作完成
  4. thenable 对象兼容

    • "thenable" 对象是指具有 then 方法的对象或函数
    • 通过动态获取 x.then 属性并检查其是否为函数来识别 thenable 对象
    • 使用 then.call(x, ...) 调用 then 方法,确保上下文正确绑定
    • 这是 Promise 能够兼容其他 Promise 实现的关键
  5. 异常处理机制

    • 使用 try-catch 捕获访问 x.then 属性或调用 then 方法时可能发生的异常
    • 任何异常都将导致 promise2 被拒绝,错误作为拒绝原因
    • 异常处理也受到 called 标志的保护,确保只被处理一次
  6. 原始值直接传递

    • x 不是对象或函数时(原始值),直接调用 resolve(x)
    • 这确保了普通值能够正确地传递到下一个 Promise

代码执行流程

resolvePromise 的执行流程体现了 Promise/A+ 规范的严格要求:

  1. 首先检查循环引用 → 2. 初始化 called 标志 → 3. 根据 x 的类型分三种情况处理:
    • Promise 实例 → 等待其状态变化并递归处理
    • 对象/函数 → 检查是否为 thenable 并相应处理
    • 原始值 → 直接 resolve

为什么需要这个复杂的函数?

resolvePromise 函数解决了 JavaScript 异步编程中的几个关键问题:

  • 统一的异步接口:无论返回什么类型的值,都能按照统一规则处理
  • 跨 Promise 实现兼容:通过 thenable 机制支持不同的 Promise 实现
  • 防止状态不一致:通过 called 标志确保状态只变更一次
  • 避免死循环:通过循环引用检测确保程序安全
  • 正确的值传递:在异步链中确保值能够正确地从一个 Promise 传递到下一个
resolvePromise(promise2, x, resolve, reject) {
  // 处理循环引用 1.循环引用检测:当 `x` 与 `promise2` 是同一个对象时,会导致无限循环
  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected for promise'))
  }

  // 防止多次调用 2.状态凝固保障:状态一旦改变就不能再变;确保 `resolve` 或 `reject` 只能被调用一次
  let called = false

  // 处理 x 是 Promise 的情况 3.Promise 类型处理:当 `x` 是 Promise 实例时,需要等待其状态变化。
  if (x instanceof MyPromise) {
    //递归调用 `x.then()`,将结果继续通过 `resolvePromise` 处理
    x.then(
      value => this.resolvePromise(promise2, value, resolve, reject),
      reason => reject(reason)
    )
  }
    
  // 4.thenable 对象兼容:"thenable" 对象是指具有 `then` 方法的对象或函数
  // 处理 x 是对象或函数的情况(可能是 thenable)
  else if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    // 5.异常处理机制:使用 try-catch 捕获访问 `x.then` 属性或调用 then 方法时可能发生的异常
    try {
      // 动态获取 `x.then` 属性并检查其是否为函数来识别 thenable 对象
      const then = x.then
      // 判断是否为 thenable 对象
      if (typeof then === 'function') {
        // 使用 `then.call(x, ...)` 调用 then 方法,确保上下文正确绑定
        // 这是处理 thenable 对象的核心逻辑
        then.call(
          x,  // 确保 then 方法内部的 this 指向 x 对象
          
          // 成功回调函数 - 当 thenable 对象被成功解析时执行
          value => {
            // 状态凝固检查:如果已经调用过 resolve/reject,则直接返回
            if (called) return
            // 标记为已调用,防止重复调用
            called = true
            // 递归调用 resolvePromise 处理 thenable 对象返回的 value
            // 这是实现链式调用和值传递的关键步骤
            // 例如:当 value 本身也是 Promise 或 thenable 时,会继续递归处理
            this.resolvePromise(promise2, value, resolve, reject)
          },
          
          // 失败回调函数 - 当 thenable 对象被拒绝时执行
          reason => {
            // 状态凝固检查
            if (called) return
            // 标记为已调用
            called = true
            // 直接拒绝 promise2,并将错误原因传递下去
            reject(reason)
          }
        )
      } else {
        // 普通对象,直接 resolve
        resolve(x)
      }
    } catch (error) {
      if (called) return
      called = true
      reject(error) // 错误作为拒绝原因
    }
  } else {
    // 原始值,直接 resolve 6.原始值直接传递
    resolve(x)
  }
}

JavaScript中的 call 方法详解

call方法是JavaScript中函数对象的一个内置方法,它允许你调用一个函数并明确指定函数内部的this值。这在Promise实现中非常重要,特别是处理thenable对象时。

基本语法

function.call(thisArg, arg1, arg2, ...)

主要作用

  1. 改变函数内部的this指向
    • 第一个参数thisArg就是函数执行时this的指向
    • 后续参数作为函数的参数传入

为什么需要 call 方法?

在JavaScript中,函数内部的this指向通常由调用方式决定,而不是定义方式call方法提供了一种显式控制this指向的机制,这在很多场景下非常有用,尤其是在处理对象方法借用、回调函数、类继承等情况时。

Promise实现中的应用

在Promise实现中,call方法主要用于处理thenable对象:

then.call(
  x,  // 确保 then 方法内部的 this 指向 x 对象
  value => { /* 成功回调 */ },
  reason => { /* 失败回调 */ }
)

这里为什么要用call?

  • thenable对象是指具有then方法的对象
  • 我们需要调用这个对象的then方法,但同时要确保这个方法内部的this正确指向该对象本身
  • 如果直接写x.then(...),在正常情况下this也会指向x,但使用call方法是更明确、更安全的做法,特别是在某些特殊情况下(如函数被赋值给变量后调用)

简单例子说明

// 定义一个对象
const person = {
  name: 'John',
  greet: function(greeting) {
    console.log(`${greeting}, ${this.name}!`);
  }
};

// 正常调用 - this指向person
person.greet('Hello');  // 输出: "Hello, John!"

// 使用call改变this指向
const anotherPerson = { name: 'Jane' };
person.greet.call(anotherPerson, 'Hi');  // 输出: "Hi, Jane!"

与Promise thenable处理的关系

在Promise的resolvePromise函数中:

  1. 我们检测到一个对象有then方法
  2. 我们需要调用这个then方法,但必须确保它在正确的this上下文中执行
  3. 使用then.call(x, onFulfilled, onRejected)确保了:
    • then方法被调用
    • then方法内部的this指向x对象
    • 我们传入了成功和失败的回调函数

这种方式是Promise/A+规范要求的标准做法,确保了不同Promise实现之间的兼容性和正确的上下文绑定。

4. Promise 扩展方法实现

4.1 错误处理方法

catch 方法

专门用于捕获 Promise 链中的错误,本质是 then 方法的语法糖

catch(onRejected) {
  return this.then(null, onRejected)
}
finally 方法

无论 Promise 成功或失败都会执行的回调,不接收参数也不影响原 Promise 的结果

finally(callback) {
  return this.then(
    value => MyPromise.resolve(callback()).then(() => value),
    reason => MyPromise.resolve(callback()).then(() => { throw reason })
  )
}

4.2 静态方法

resolve 方法

返回一个已成功的 Promise,如果参数本身是 Promise 则直接返回:

static resolve(value) {
  if (value instanceof MyPromise) {
    return value
  }
  return new MyPromise(resolve => resolve(value))
}
reject 方法

返回一个已失败的 Promise:

static reject(reason) {
  return new MyPromise((resolve, reject) => reject(reason))
}
all 方法💖

等待所有 Promise 都成功,返回包含所有结果的数组;任一 Promise 失败则整体失败

static all(promises) {
  return new MyPromise((resolve, reject) => {
    const result = []
    let count = 0
    
    if (!Array.isArray(promises)) {
      return reject(new TypeError('Argument must be an array'))
    }

    if (promises.length === 0) {
      return resolve([])
    }

    promises.forEach((promise, index) => {
      MyPromise.resolve(promise).then(
        value => {
          result[index] = value
          count++
          if (count === promises.length) {
            resolve(result)
          }
        },
        reason => reject(reason)
      )
    })
  })
}
race 方法

返回第一个完成的 Promise 的结果(无论成功或失败)

static race(promises) {
  return new MyPromise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('Argument must be an array'))
    }

    if (promises.length === 0) {
      return
    }

    promises.forEach(promise => {
      MyPromise.resolve(promise).then(
        value => resolve(value),
        reason => reject(reason)
      )
    })
  })
}

5.constructor中的resolve函数和外部的static resolve方法有何区别?

在Promise实现中,constructor中的resolve函数和外部的static resolve方法有以下几个关键区别:

5.1. 定义位置与类型

  • constructor中的resolve:是在Promise构造函数内部定义的局部函数,作为参数传递给executor函数,使用箭头函数定义以确保this指向Promise实例。
  • static resolve方法:是Promise类的静态方法,通过MyPromise.resolve()直接调用,不属于任何实例。

5.2. 主要作用

  • constructor中的resolve

    • 负责将Promise实例的状态从pending转变为fulfilled
    • 设置成功值(value)并触发所有注册的成功回调函数
    • 是Promise内部状态转换的核心机制
  • static resolve方法

    • 作为Promise的工具方法,用于快速创建一个已成功状态的Promise实例
    • 提供Promise包装功能,将任意值转换为Promise

5.3. 调用方式

  • constructor中的resolve

    • 由executor函数内部调用,通常是异步操作完成后
    • 例如:new MyPromise((resolve, reject) => { setTimeout(() => resolve(1), 1000); })
  • static resolve方法

    • 直接通过类名调用,作为工厂方法使用
    • 例如:MyPromise.resolve(1)MyPromise.resolve(promiseInstance)

5.4. 处理逻辑

  • constructor中的resolve

    • 检查当前状态是否为pending,确保状态只能变更一次
    • 更新状态为fulfilled并存储成功值
    • 异步执行所有已注册的成功回调
  • static resolve方法

    • 检查传入值是否已经是Promise实例,如果是则直接返回
    • 否则创建并返回一个新的已成功状态的Promise实例

5.5. 代码实现对比

// constructor中的resolve(局部函数)
const resolve = (value) => {
  if (this.status === 'pending') {
    this.status = 'fulfilled'
    this.value = value
    this.onResolvedCallbacks.forEach(fn => fn(this.value))
  }
}

// static resolve方法(类静态方法)
static resolve(value) {
  if (value instanceof MyPromise) {
    return value
  }
  return new MyPromise(resolve => resolve(value))
}

总结来说,constructor中的resolve是Promise内部状态管理的核心机制,负责状态转换和回调触发;而static resolve则是一个便捷的工具方法,用于创建已解决状态的Promise实例,实现值的Promise化包装。

6. 上述代码优化建议

6.1 微任务模拟改进then 方法中 setTimeout

当前实现使用 setTimeout 模拟异步执行,但实际的 Promise 使用微任务队列。在浏览器环境中,可以使用 MutationObserverMessageChannel 更好地模拟微任务:

// 微任务队列实现
const microTask = (fn) => {
  if (typeof queueMicrotask === 'function') {
    queueMicrotask(fn);
  } else if (typeof MutationObserver === 'function') {
    const observer = new MutationObserver(fn);
    const textNode = document.createTextNode('');
    observer.observe(textNode, { characterData: true });
    textNode.data = '1';
  } else {
    setTimeout(fn, 0);
  }
};

// 然后在 then 方法中使用 microTask 替代 setTimeout

6.2 增强静态方法

可以扩展更多实用的静态方法,如:

// allSettled 方法 - 等待所有 Promise 完成,不关心成功或失败
static allSettled(promises) {
  return new MyPromise(resolve => {
    const results = [];
    let count = 0;
    
    if (!Array.isArray(promises)) {
      return resolve([]);
    }

    if (promises.length === 0) {
      return resolve([]);
    }

    promises.forEach((promise, index) => {
      MyPromise.resolve(promise)
        .then(
          value => {
            results[index] = { status: 'fulfilled', value };
            count++;
            if (count === promises.length) {
              resolve(results);
            }
          },
          reason => {
            results[index] = { status: 'rejected', reason };
            count++;
            if (count === promises.length) {
              resolve(results);
            }
          }
        );
    });
  });
}

// any 方法 - 返回第一个成功的 Promise
static any(promises) {
  return new MyPromise((resolve, reject) => {
    const errors = [];
    let count = 0;
    
    if (!Array.isArray(promises)) {
      return reject(new TypeError('Argument must be an array'));
    }

    if (promises.length === 0) {
      return reject(new AggregateError([], 'All promises were rejected'));
    }

    promises.forEach((promise, index) => {
      MyPromise.resolve(promise)
        .then(resolve)
        .catch(error => {
          errors[index] = error;
          count++;
          if (count === promises.length) {
            reject(new AggregateError(errors, 'All promises were rejected'));
          }
        });
    });
  });
}

7. 输入输出示例

基本用法示例

// 创建并使用 Promise
const promise = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('成功了!');
    // reject('失败了!');
  }, 1000);
});

promise.then(
  value => {
    console.log('成功:', value); // 输出: 成功: 成功了!
    return '链式调用';
  }
).then(
  value => {
    console.log('链式调用结果:', value); // 输出: 链式调用结果: 链式调用
  }
).catch(
  reason => {
    console.log('失败:', reason);
  }
);

静态方法使用示例

// Promise.all 示例
MyPromise.all([
  MyPromise.resolve(1),
  new MyPromise(resolve => setTimeout(() => resolve(2), 100)),
  3
]).then(values => {
  console.log('all结果:', values); // 输出: all结果: [1, 2, 3]
});

// Promise.race 示例
MyPromise.race([
  new MyPromise(resolve => setTimeout(() => resolve(1), 100)),
  new MyPromise((resolve, reject) => setTimeout(() => reject(2), 50))
]).then(
  value => console.log('race成功:', value),
  reason => console.log('race失败:', reason) // 输出: race失败: 2
);

8. 总结

本文详细解析了 Promise 的实现原理,包括状态机设计、发布-订阅模式、异步执行机制以及各种方法的实现细节。通过深入理解这些核心概念,开发者可以更好地掌握异步编程范式,编写更加健壮和高效的异步代码。

Promise 的实现体现了优秀的设计模式应用,特别是状态机和发布-订阅模式的结合,解决了传统回调地狱问题,提供了更加优雅和可维护的异步编程解决方案。

思考1:状态机模式是什么?怎么在Promise中实现

状态机模式的定义

状态机模式是一种行为设计模式,它允许对象在内部状态改变时改变其行为。对象看起来好像修改了它的类,因为它的行为随着状态的变化而变化。在软件设计中,状态机模式特别适用于描述对象在其生命周期内可能经历的状态转换,以及这些状态转换所触发的行为。

经典的设计模式分类中,我们通常不会直接使用"状态机模式"这个术语,而是使用 状态模式(State Pattern) 。状态机是一个更广泛的概念,而状态模式是实现状态机的一种面向对象设计模式。

总结

  • 状态模式(State Pattern) :GoF正式定义的设计模式,属于23种经典设计模式之一。
  • 状态机模式 :更侧重于实现有限状态机概念的模式,在工程实践中的常用称呼。
  • Promise的实现同时符合状态模式的设计原则和状态机的核心概念,所以可以说它使用了状态模式(在设计模式术语中)或状态机模式(在工程实践术语中)。

这就是为什么你可能在不同的资料中看到不同的称呼,但它们描述的是同一个核心设计思想。

Promise中的状态机实现

promise-implementation-docs.md文档中,Promise通过以下方式实现了状态机模式:

1. 状态定义

Promise具有三种互斥的状态:

  • pending: 初始状态,既未完成也未拒绝
  • fulfilled/resolved: 操作成功完成
  • rejected: 操作失败
2. 状态的不可变性

核心特性是:状态一旦改变,就不会再变。这是Promise可靠性的基础,确保了异步操作的结果一旦确定就不会被覆盖。

3. 状态实现代码

在构造函数中初始化状态和相关数据:

constructor(executeFn) {
  // 初始化状态和数据
  this.status = 'pending'      // 初始状态为 pending
  this.value = undefined       // 存储成功结果
  this.reason = undefined      // 存储失败原因
  this.onResolvedCallbacks = [] // 成功回调队列
  this.onRejectedCallbacks = [] // 失败回调队列
  
  // ...
}
4. 状态转换机制

通过resolvereject函数实现状态转换:

const resolve = (value) => {
  if (this.status === 'pending') {
    this.status = 'fulfilled'
    this.value = value
    // 发布成功事件,执行所有成功回调
    this.onResolvedCallbacks.forEach(fn => fn(this.value))
  }
}

const reject = (reason) => {
  if (this.status === 'pending') {
    this.status = 'rejected'
    this.reason = reason
    // 发布失败事件,执行所有失败回调
    this.onRejectedCallbacks.forEach(fn => fn(this.reason))
  }
}

状态机模式在Promise中的核心机制

  1. 状态检查: 在转换状态前,会检查当前状态是否为pending,只有处于pending状态的Promise才能转换为fulfilledrejected状态。

  2. 状态与数据关联: 状态转换时,会同时存储相关的数据(成功值value或失败原因reason)。

  3. 状态驱动行为: 状态的变化会触发相应的行为,例如执行存储在回调队列中的函数。

  4. 互斥性: 三种状态是互斥的,任何时刻Promise只能处于其中一种状态。

  5. 单向转换: 状态转换是单向的,只能从pending转换到fulfilledrejected,一旦完成转换就不能再改变。

这种状态机设计使Promise能够可靠地表示异步操作的最终结果,无论是成功还是失败,并确保异步操作的结果一旦确定就不会被覆盖,为JavaScript异步编程提供了可靠的基础。

思考2:promise中如何实现发布-订阅模式的

MyPromise实现中,发布-订阅模式主要体现在以下几个关键部分:

1. 事件中心(constructor中定义订阅列表)

在Promise构造函数中定义了两个数组作为订阅者列表

// 存储Promise成功状态下需要执行的回调函数数组
this.onResolvedCallbacks = []
// 存储Promise失败状态下需要执行的回调函数数组
this.onRejectedCallbacks = []

这两个数组就是发布-订阅模式中的事件中心,用于存储所有注册的回调函数。

2. 订阅过程(then方法中注册回调)

当Promise处于pending状态时,通过then方法注册的回调会被添加到对应的数组中:

// pending状态处理 - 存储回调
if (this.status === 'pending') {
  this.onResolvedCallbacks.push(() => {
    setTimeout(() => {
      try {
        const x = onFulfilled(this.value)
        this.resolvePromise(promise2, x, resolve, reject)
      } catch (error) {
        reject(error)
      }
    }, 0)
  })

  this.onRejectedCallbacks.push(() => {
    setTimeout(() => {
      try {
        const x = onRejected(this.reason)
        this.resolvePromise(promise2, x, resolve, reject)
      } catch (error) {
        reject(error)
      }
    }, 0)
  })
}

这部分代码实现了订阅功能,将回调函数添加到相应的事件队列中。

3. 发布过程(constructor中通过resolve或reject函数触发回调)

当Promise状态发生变更时(通过resolve或reject函数),会遍历对应的回调数组并执行所有订阅的回调:

const resolve = (value) => {
  if (this.status === 'pending') {
    this.status = 'fulfilled'
    this.value = value
    // 发布成功事件,执行所有注册的成功回调
    this.onResolvedCallbacks.forEach(fn => fn(this.value))
  }
}

const reject = (reason) => {
  if (this.status === 'pending') {
    this.status = 'rejected'
    this.reason = reason
    // 发布失败事件,执行所有注册的失败回调
    this.onRejectedCallbacks.forEach(fn => fn(this.reason))
  }
}

发布-订阅模式的核心体现

  1. 解耦性:Promise的状态变更(发布者)和回调执行(订阅者)完全解耦
  2. 时序无关性:无论回调是在Promise状态变更前还是变更后注册,都能正确执行
  3. 多订阅支持:允许多个回调订阅同一个Promise的状态变更
  4. 异步协调:有效解决了异步操作和回调执行的时序问题

通过这种发布-订阅模式,Promise巧妙地解决了JavaScript异步编程中的回调地狱问题,使代码更加清晰和可维护。

小朋友,你是否有很多❓

解耦性是什么意思❓怎么做到的时序无关性❓怎么支持的多订阅❓异步协调是then方法中的setTimeout,怎么解决的时序问题❓

发布-订阅模式四个核心特性的具体体现:

1. 解耦性

解耦性指的是Promise的状态status变更机制(发布者)和回调函数(onResolvedCallbacks、onRejectedCallbacks)执行(订阅者)完全分离,彼此之间不直接依赖

具体体现:

  • constructor中定义了状态管理和回调数组,这是发布者的核心部分
  • resolvereject函数负责状态变更和发布事件(执行回调)
  • then方法负责注册回调函数(pendding)到订阅列表中
  • 发布者(状态变更逻辑)不需要知道具体有哪些订阅者(回调函数)
  • 订阅者(回调函数)也不需要知道状态何时会变更
2. 时序无关性

时序无关性确保无论回调是在Promise状态变更前还是变更后注册,都能正确执行

具体实现:

// 在then方法中
if (this.status === 'pending') {
  // 状态未变更时:存储回调到队列
  this.onResolvedCallbacks.push(() => { /* 回调逻辑 */ })
} else if (this.status === 'fulfilled') {
  // 状态已变更时:直接执行回调
  setTimeout(() => { /* 回调逻辑 */ }, 0)
}
  • 当Promise已经是fulfilled或rejected状态时,调用then方法会立即异步执行对应回调
  • 当Promise还是pending状态时,回调会被存储在数组中,等待状态变更后执行
  • 这样无论回调注册和状态变更的顺序如何,都能保证回调正确执行
3. 多订阅支持

多订阅支持允许多个回调函数订阅同一个Promise的状态变更。

具体实现:

// 在constructor中使用数组存储回调
this.onResolvedCallbacks = []
this.onRejectedCallbacks = []

// resolve函数中执行所有注册的回调
this.onResolvedCallbacks.forEach(fn => fn(this.value))
  • 使用数组存储回调函数,而不是单一函数
  • 每个Promise实例可以多次调用then方法每次调用都会将回调添加到数组中
  • 状态变更时,所有注册的回调会按添加顺序依次执行
  • 这使得多个独立的操作可以基于同一个Promise的结果进行联动
4. 异步协调

异步协调通过setTimeout确保回调异步执行,解决了异步操作和回调执行的时序问题

具体实现:

// 在then方法的三种状态处理中都使用了setTimeout
setTimeout(() => {
  try {
    const x = onFulfilled(this.value)
    this.resolvePromise(promise2, x, resolve, reject)
  } catch (error) {
    reject(error)
  }
}, 0)
  • 使用setTimeout模拟微任务,确保回调在当前执行栈清空后执行
  • 无论Promise状态是同步还是异步变更,回调总是异步执行
  • 解决了回调地狱问题,提供了清晰的异步操作流程控制
  • 确保即使在同步操作完成后,回调也会按预期顺序异步执行

Ajax 数据请求详解与实战

在现代前端开发中,网页与服务器的数据交互已经成为核心功能之一。
而支撑这一功能的技术之一,正是 Ajax(Asynchronous JavaScript and XML)
今天我们就来系统了解一下 Ajax 的工作原理、请求流程以及一个完整的示例。


一、什么是 Ajax?

Ajax 全称是 异步 JavaScript 和 XML
中文意思是“异步的 JavaScript 与 XML”。

虽然名字里有 XML,但如今开发中我们更多使用 JSON 格式来传输数据。
它最大的特点是:在不刷新页面的情况下与服务器通信,动态更新网页内容。


二、Ajax 的基本工作流程

Ajax 的实现依赖浏览器内置的一个对象:XMLHttpRequest(简称 XHR)。
通过这个对象,我们可以主动发起 HTTP 请求并接收响应。

流程如下:

  1. 创建请求对象

    const xhr = new XMLHttpRequest();
    
  2. 配置请求信息

    xhr.open(method, url, async);
    
    • method:请求方式(如 GETPOST
    • url:目标接口地址
    • async:是否异步(true 为异步,false 为同步)
  3. 发送请求

    xhr.send();
    
  4. 监听请求状态变化

    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4 && xhr.status === 200) {
            const data = JSON.parse(xhr.responseText);
            console.log(data);
        }
    };
    

三、readyState 状态说明

在 Ajax 请求过程中,readyState 表示请求的不同阶段:

状态码 含义 说明
0 初始化 请求未初始化
1 打开 已调用 open(),还未发送
2 发送 已发送请求,接收到响应头
3 接收 正在接收服务器数据
4 完成 请求完成,已接收到全部响应数据

同时要注意:

  • xhr.status 表示 HTTP 响应状态(例如 200 表示成功)。
  • xhr.responseText 是服务器返回的字符串数据。

四、实战示例:请求 GitHub 数据

下面是一个完整的 Ajax 请求示例,用来获取 GitHub 上某组织的成员数据:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ajax 数据请求</title>
</head>
<body>
    <ul id="members"></ul>

    <script>
        // 1. 创建 XMLHttpRequest 对象
        const xhr = new XMLHttpRequest();

        // 2. 打开请求 (异步)
        xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', true);

        // 3. 监听状态变化
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4 && xhr.status === 200) {
                // 4. 解析 JSON 数据
                const data = JSON.parse(xhr.responseText);
                console.log(data);

                // 5. 渲染到页面
                document.getElementById('members').innerHTML =
                    data.map(item => `<li>${item.login}</li>`).join('');
            }
        };

        // 6. 发送请求
        xhr.send();
    </script>
</body>
</html>

执行后,浏览器会在控制台打印出返回的数据,同时在网页中显示成员列表。


五、同步与异步的区别

  • 同步请求(async = false)
    浏览器会等待服务器响应后再执行后续代码,页面会“卡住”。
  • 异步请求(async = true)
    浏览器不会等待,能继续执行后续代码,响应回来后再触发回调函数。
    —— 这正是 Ajax 的核心优势所在。

六、现代替代方案:Fetch 与 Axios

如今,在实际开发中,我们更常用以下方式:

  • Fetch API:更简洁现代的异步请求方式。
fetch('https://api.github.com/orgs/lemoncode/members')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

🧩 七、小结

要点 内容
Ajax 全称 异步 JavaScript 和 XML
核心对象 XMLHttpRequest
关键方法 open()send()onreadystatechange
常用属性 readyStatestatusresponseText
主要用途 实现网页的动态数据加载,不刷新页面即可更新内容

✅ 结语

Ajax 是前端与服务器通信的基础技术之一。
理解其底层原理不仅能帮助你更好地使用 fetch
更能让你彻底理解浏览器异步通信机制的本质。

react性能优化两大策略bailout和eagerState

一、Bailout 策略:智能跳过不必要的渲染

什么是 Bailout 策略?

Bailout 策略是 React 协调(Reconciliation)过程中的核心优化机制。其核心思想是:当一个组件的输出不会发生变化时,完全跳过对该组件及其整个子树的渲染和协调过程

Bailout 的工作原理

React 在组件更新时会进行一系列检查,判断是否满足 Bailout 条件:

jsx

// React 内部的简化 Bailout 检查逻辑
function shouldBailout(currentFiber, newProps) {
  // 1. 检查 props 是否变化
  const oldProps = currentFiber.memoizedProps;
  if (!shallowEqual(oldProps, newProps)) {
    return false;
  }
  
  // 2. 检查是否有待处理的状态更新
  if (currentFiber.updateQueue !== null) {
    return false;
  }
  
  // 3. 检查 context 依赖是否变化
  if (hasContextChanged(currentFiber)) {
    return false;
  }
  
  // 4. 检查组件类型是否变化
  if (currentFiber.type !== newFiberType) {
    return false;
  }
  
  return true; // 满足所有条件,执行 Bailout
}

Bailout 的触发条件

一个组件能够成功 Bailout 必须满足以下所有条件:

  1. Props 没有变化:新旧 props 浅层比较相等
  2. 状态没有更新:组件内部没有使用 useState、useReducer 等触发的状态变更
  3. Context 未变化:组件依赖的 Context 值没有发生变化
  4. 组件类型相同:组件元素类型没有改变

开发者如何利用 Bailout 策略

1. 使用 React.memo

jsx

const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
  // 这个组件包含复杂的计算或渲染逻辑
  const processedData = expensiveCalculation(data);
  
  return (
    <div>
      {processedData.map(item => (
        <div key={item.id}>{item.content}</div>
      ))}
    </div>
  );
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState(initialData);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        计数: {count}
      </button>
      {/* 当 count 变化但 data 不变时,ExpensiveComponent 会 Bailout */}
      <ExpensiveComponent data={data} />
    </div>
  );
}

2. 使用 PureComponent

jsx

class PureComponentExample extends React.PureComponent {
  render() {
    // 只有当 props 发生变化时才会重新渲染
    return <div>{this.props.value}</div>;
  }
}

3. 谨慎传递 Props

jsx

// ❌ 避免:每次渲染都会创建新的对象和函数
function ProblematicParent() {
  const [count, setCount] = useState(0);
  
  return (
    <ChildComponent 
      config={{ type: 'example' }}  // 新对象
      onClick={() => console.log('click')}  // 新函数
    />
  );
}

// ✅ 推荐:使用 useMemo 和 useCallback
function OptimizedParent() {
  const [count, setCount] = useState(0);
  
  const config = useMemo(() => ({ type: 'example' }), []);
  const handleClick = useCallback(() => console.log('click'), []);
  
  return (
    <ChildComponent 
      config={config}
      onClick={handleClick}
    />
  );
}

Bailout 的性能收益

当 Bailout 成功时,React 会:

  • 跳过组件的 render 方法调用
  • 跳过子树的虚拟 DOM 比较
  • 直接复用之前的 DOM 结构
  • 显著减少 JavaScript 执行时间

二、Eager State 策略:提前预测状态更新

什么是 Eager State 策略?

Eager State 是 React 在状态更新时的一种高级优化策略。它尝试在状态更新被正式调度之前,就预测这次更新是否会导致组件输出变化。如果预测结果不变,则直接标记为可跳过更新,避免整个协调过程。

Eager State 的工作原理

jsx

// React 内部 Eager State 的简化逻辑
function dispatchSetState(fiber, queue, action) {
  // 1. 计算新状态
  const newState = calculateNewState(queue, action);
  
  // 2. 急切计算:尝试使用新状态渲染组件
  const oldProps = fiber.memoizedProps;
  const oldState = fiber.memoizedState;
  
  // 创建临时的工作副本进行预测性渲染
  const temporaryFiber = createTemporaryFiber(fiber, newState);
  const nextChildren = renderComponent(temporaryFiber, oldProps, newState);
  
  // 3. 比较预测结果与当前渲染结果
  if (shallowEqual(currentChildren, nextChildren)) {
    // 输出相同,标记为可 Bailout,跳过正式更新
    markUpdateForBailout(fiber);
    return;
  }
  
  // 4. 输出不同,正常调度更新
  scheduleUpdateOnFiber(fiber);
}

Eager State 的触发场景

Eager State 优化通常发生在以下情况:

jsx

function MyComponent() {
  const [value, setValue] = useState('initial');
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    // ✅ 场景1:设置相同的状态值
    setValue('initial'); // React 会进行 Eager 计算,发现状态没变
    
    // ✅ 场景2:连续多次设置状态
    setCount(1);
    setCount(1); // 第二次设置相同值,可能触发 Eager 优化
    
    // ✅ 场景3:基于当前状态计算但结果不变
    setCount(currentCount => {
      const newCount = Math.max(currentCount, 10); // 如果 currentCount >= 10,结果不变
      return newCount;
    });
  };
  
  return <button onClick={handleClick}>点击</button>;
}

Eager State 的优势

  1. 避免不必要的调度:在更新进入 React 调度系统前就被拦截
  2. 减少协调工作:跳过 beginWork 和 completeWork 阶段
  3. 提升响应速度:避免了虚拟 DOM 比较和可能的 DOM 操作

实际应用示例

jsx

function SearchComponent() {
  const [filters, setFilters] = useState({});
  const [searchTerm, setSearchTerm] = useState('');
  
  // 优化过滤器设置:只有真正变化时才更新
  const updateFilter = useCallback((key, value) => {
    setFilters(currentFilters => {
      // 如果新值与旧值相同,React 的 Eager State 可能优化这次更新
      if (currentFilters[key] === value) {
        return currentFilters; // 返回相同引用
      }
      return { ...currentFilters, [key]: value };
    });
  }, []);
  
  // 防抖搜索:避免频繁更新
  const debouncedSearch = useCallback(
    _.debounce((term) => {
      setSearchTerm(term); // 如果 term 没变,Eager State 会优化
    }, 300),
    []
  );
  
  return (
    <div>
      <input 
        onChange={(e) => debouncedSearch(e.target.value)}
        placeholder="搜索..."
      />
      <FilterControls onFilterChange={updateFilter} />
    </div>
  );
}

三、Bailout 与 Eager State 的协同工作

这两种策略在 React 更新流程中协同工作,形成了多层次的优化防护:

更新流程中的优化层次

text

状态更新触发
    ↓
Eager State 策略(第一层防御)
    ├── 预测渲染结果不变 → 直接跳过更新
    ↓
进入调度系统
    ↓
Bailout 策略(第二层防御)
    ├── 检查更新条件不满足 → 跳过组件渲染
    ↓
执行完整渲染和协调

性能优化最佳实践

1. 编写可优化的组件

jsx

// ✅ 可优化的组件结构
function OptimizedUserProfile({ user, settings }) {
  // 使用 useMemo 缓存计算结果
  const userStats = useMemo(() => 
    calculateUserStats(user), 
    [user.id, user.activity]
  );
  
  // 使用 useCallback 缓存事件处理
  const handleSettingsChange = useCallback((newSettings) => {
    // 处理设置变更
  }, []);
  
  return (
    <div>
      <UserHeader user={user} stats={userStats} />
      <SettingsPanel 
        settings={settings} 
        onChange={handleSettingsChange} 
      />
    </div>
  );
}

// 使用 React.memo 包装
export default React.memo(OptimizedUserProfile);

2. 状态设计优化

jsx

function ShoppingCart() {
  // ❌ 不推荐:合并所有状态,导致不必要的更新
  // const [cart, setCart] = useState({ items: [], total: 0, discount: 0 });
  
  // ✅ 推荐:分离状态,减少更新范围
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [discount, setDiscount] = useState(0);
  
  // 只有 items 变化时才重新计算
  const cartSummary = useMemo(() => ({
    itemCount: items.length,
    totalWeight: items.reduce((sum, item) => sum + item.weight, 0)
  }), [items]);
  
  return (
    <div>
      <CartItems items={items} />
      <CartSummary summary={cartSummary} />
      <DiscountSection discount={discount} />
    </div>
  );
}

四、调试和验证优化效果

使用 React DevTools

React DevTools Profiler 可以帮助验证优化效果:

  1. 高亮更新:开启"Highlight updates when components render"
  2. 性能分析:使用 Profiler 记录组件渲染时间
  3. 组件检查:查看组件为什么会渲染

自定义调试 Hook

jsx

function useWhyDidYouUpdate(name, props) {
  const previousProps = useRef();
  
  useEffect(() => {
    if (previousProps.current) {
      const allKeys = Object.keys({ ...previousProps.current, ...props });
      const changes = {};
      
      allKeys.forEach(key => {
        if (previousProps.current[key] !== props[key]) {
          changes[key] = {
            from: previousProps.current[key],
            to: props[key]
          };
        }
      });
      
      if (Object.keys(changes).length > 0) {
        console.log('[why-did-you-update]', name, changes);
      }
    }
    
    previousProps.current = props;
  });
}

// 在组件中使用
function MyComponent(props) {
  useWhyDidYouUpdate('MyComponent', props);
  // ... 组件逻辑
}

总结

React 的 Bailout 和 Eager State 策略共同构成了框架高性能的核心基础。理解这些机制有助于开发者:

  1. 编写更高效的组件:通过合理设计组件结构和状态管理
  2. 避免性能陷阱:识别导致不必要渲染的常见模式
  3. 充分利用框架优化:让 React 的智能优化机制发挥最大作用

Flutter 3.38 版本发布了,看看有哪些新特性

介绍

欢迎回到我们定期发布的季度版本 Flutter 3.38。本次更新旨在通过点简写和 Widget 预览的更新来提升您的开发效率并优化开发者体验。感谢社区的贡献,本次版本共包含来自 145 位贡献者的 825 次提交,其中 37 位是首次贡献者。让我们深入了解一下本次版本更新的内容。

Dot shorthands

编写更简洁的 Dart 代码!我们很高兴地宣布 Dart 的一项新特性——点简写!简写允许您省略 Dart 可以推断的类型,从而减少样板代码。

例如,你可以使用简写.start来代替MainAxisAlignment.start

// 使用简写
Column ( 
  mainAxisAlignment : .start, 
  crossAxisAlignment : .center, 
  children : [ /* ... */ ], 
), 

// 不使用简写
Column ( 
  mainAxisAlignment : MainAxisAlignment.start, 
  crossAxisAlignment : CrossAxisAlignment.center, 
  children : [ /* … */ ], 
),

这同样适用于命名构造函数!你可以.all这样写EdgeInsets.all

填充(
  填充:.全部(8.0),
  子元素:文本('Hello world'),
),

Dart 3.10 和 Flutter 3.38 默认启用此功能。更多信息,请查看dart.dev 上的dot 简写页面。您还可以在Dart 3.10 发布博文中了解更多相关内容(例如 Dart hooks!)。

web

Web 开发配置文件

flutter run命令现在支持 Web 设置配置文件。您可以在web_dev_config.yaml项目根目录的文件中指定主机、端口、证书和标头信息。将该文件检入,以便团队中的每个人都能使用相同的设置进行调试。有关更多信息,请访问“设置 Web 开发配置文件”

Web 开发代理设置

除了现有的命令行参数外,Web 开发配置文件还支持新的代理设置。代理设置允许将对已配置路径的请求转发到另一台服务器。这使得开发能够连接到同一主机上动态端点的 Web 客户端变得更加容易。

有关代理设置的详细信息,请参阅设置 Web 开发配置文件

扩展了对网页热重载的支持

现在,当您在浏览器中打开 Flutter 应用的链接并运行该应用时,状态热重载功能默认启用-d web-server。即使同时连接多个浏览器,此功能也能正常工作。

与之前一样-d chrome,此功能可以使用标志暂时禁用--no-web-experimental-hot-reload。禁用此功能的功能将在未来的版本中移除,因此,如果您在开发流程中遇到问题,请使用 Dart 的Web 热重载问题模板提交错误报告。更多信息,请参阅Web 热重载文档

框架

此版本包含框架中的许多强大的新功能和改进,使开发人员能够对高级 UI、导航和平台交互进行更精细的控制。

现在,开发者在使用 Overlay.of 创建弹出窗口、对话框和其他浮动 UI 元素时拥有了更大的控制权OverlayPortal。现在可以Overlay使用 Overlay.of 在组件树的任何上级组件中渲染子组件OverlayPortal.overlayChildLayoutBuilder#174239),从而更轻松地显示应用范围的通知或其他需要突破父组件布局限制的 UI。底层 Overlay.of 方法也得到了增强,变得更加健壮高效(#174315)。

为了提供更现代化的 Android 导航体验,预测性返回路径过渡效果现已默认启用MaterialApp#173860)。当用户执行返回手势时,当前路径会以动画形式消失,同时用户会看到主屏幕的预览。此外,默认页面过渡效果也已更新,以FadeForwardsPageTransitionsBuilder反映ZoomPageTransitionsBuilder原生应用的行为。

此次版本更新还深化了桌面集成。在 Windows 系统上,开发者现在可以访问已连接显示器的列表,并查询每个显示器的详细属性,例如分辨率、刷新率和物理尺寸(#164460)。这使得创建具有复杂窗口管理功能的应用程序成为可能。

最后,框架本身的容错性也得到了提升。组件生命周期回调中发生的错误(例如 get_errors( didUpdateWidget))现在能够得到更优雅的处理,从而避免在元素树中引发级联故障(#173148)。此外,get_errors() 也正确实现了相等性判断,确保相同的提供程序得到相同的处理,ResizeImage从而使图像缓存和比较更加可预测( #172643)。ResizeImage

在网页上,UI 的优化仍在继续,修复了RSuperellipse当圆角半径大于小部件本身时出现的渲染错误(#172254),现在,这种情况将被处理,以按预期生成药丸形状。

对于国际用户而言,检测浏览器首选语言环境的功能现在更加强大。引擎现在使用标准Intl.LocaleWeb API 来解析浏览器语言,取代了之前手动且较为脆弱的实现方式(#172964)。这一改进带来了更可靠的语言环境检测,并为全球用户提供了更佳的体验。

一个特定于 Android 系统的漏洞(#171973)已修复,该漏洞主要影响配备硬件键盘的三星设备。此前,用户与文本框交互后TextField,Android 输入法编辑器 (IME) 可能会卡在“过期”状态。这会导致 IME 错误地拦截“回车”或“空格”键的按下,从而阻止非文本控件(例如文本框CheckboxRadio按钮)接收到事件。此修复程序确保InputMethodManager在文本连接关闭时正确重置 IME,清除其“过期”状态,并恢复用户可预期的硬件键盘交互。

材料和库比蒂诺更新

Material 和 Cupertino 库持续发展,专注于 API 的一致性和优化的用户体验。此次版本更新带来了重要的 API 迁移、全新的组件功能以及诸多改进,让构建美观实用的用户界面变得更加轻松。

基于已弃用的旧版本MaterialState,本次版本继续推进内部向更统一的版本的迁移。这提供了一种一致且富有表现力的方式来定义控件在不同交互状态(例如按下、悬停或禁用)下WidgetState的外观,并且无需对现有应用进行任何更改。此次迁移已应用于多种控件及其主题,包括IconButton、、和(#173893)。新的 API 还增强了功能和灵活性;例如,现在包含一个属性(#169821),允许以编程方式控制其视觉状态,从而为更自定义和交互式的设计打开了大门。ElevatedButton``Checkbox``SwitchIconButton``statesController

此次版本更新还引入了几个新特性和便捷的 API。Badge.count构造函数现在包含一个maxCount参数(#171054),可以轻松限制显示的数量(例如,显示“99+”而不是“100”)。

为了实现更精细的手势控制,该InkWell小部件现在具有onLongPressUp回调功能(#173221),可用于触发仅在用户抬起手指时才应完成的操作。

Cupertino 库也在不断努力提升其 iOS 兼容性。CupertinoSlidingSegmentedControl新增了一个isMomentary属性(#164262),允许控件在不保持选中状态的情况下触发操作。为了更好地匹配原生 iOS 的行为,该控件CupertinoSheet在完全展开状态下向上拖动时,现在会呈现微妙的“拉伸”效果(#168547)。

最后,本次版本更新包含多项改进,优化了核心组件的行为。亮点包括修复了DropdownMenuFormField表单重置时正确清除文本字段的问题(#174937),以及更新了组件以SegmentedButton改进焦点处理(#173953)并确保其边框能够正确反映组件的状态(#172754)。

解耦 Material and Cupertino

我们一直在进行大量的规划工作,旨在将 Material 和 Cupertino 库与框架解耦。以下列表包含了围绕近期发布的一些设计文档展开的讨论。

改进 flutter/packages 的发布流程,解耦后将包含 Material 和 Cupertino。

颜色和点阵速记

解耦测试

文本

滚动:更稳健、更可预测的条形滚动

此版本带来了一系列修复,使得构建复杂的滚动布局(尤其是使用SliverMainAxisGroup和的布局SliverCrossAxisGroup)更加稳健和可预测。

使用这些组件将多个 Sliver 分组的开发者会发现,手势处理现在更加可靠。现在可以正确计算这些组内 Sliver 的点击和其他指针事件的命中测试,确保用户交互按预期运行(#174265)。

其他几项修复也使得滚动行为更加准确SliverMainAxisGroup。使用固定标题时过度滚动的问题已得到解决(#173349),调用showOnScreen显示小片段现在可以正常工作(#171339),并且内部滚动偏移计算更加精确(#174369)。

对于构建自定义滚动视图的开发者来说,新的SliverGrid.list构造函数(#173925)提供了一种更简洁的方式,可以从简单的子列表创建网格。

此次更新还改进了复杂布局中键盘和方向键用户的焦点导航体验。在具有不同滚动轴的嵌套滚动视图(例如水平轮播图的垂直列表)中,方向性焦点导航现在更加可预测,防止焦点在不同部分之间意外跳转(#172875)。

无障碍设计:为所有用户提供更具包容性的体验

确保所有用户都能访问应用程序是 Flutter 框架的基石。此次版本更新延续了这一承诺,赋予开发者更多程序控制权,改善了国际用户的体验,并优化了核心组件的可访问性。

对于构建复杂应用程序的开发者而言,此版本引入了在 iOS 上默认启用辅助功能的功能WidgetsFlutterBinding.instance.ensureSemantics#174163debugDumpSemanticsTree )。此外,由于新增了文本输入验证结果信息,有助于更快地诊断问题(#174677 ),因此调试辅助功能问题也变得更加容易。

为了增强基于 Sliver 的滚动视图的辅助功能,我们新增了一个SliverSemantics组件(#167300)。与现有组件类似,开发者可以在滚动视图中Semantics使用该组件,为 Sliver 树的各个部分添加特定的语义信息。这对于标注标题、分配语义角色以及为屏幕阅读器添加描述性标签尤为有用,从而为用户提供更易于理解和访问的体验。SliverSemantics``CustomScrollView

最后,核心组件的辅助功能持续改进。CupertinoExpansionTile现在默认情况下可访问(#174480),并且该AutoComplete组件现在会向用户播报搜索结果的状态(#173480)。其他改进,例如增大触摸目标TimePicker#170060),也有助于提供更便捷的开箱即用体验。

iOS

我们很高兴地确认,Flutter 完全支持最新的平台版本:iOS 26、Xcode 26 和 macOS 26,这些版本均于 9 月发布。这确保您可以立即在 Apple 最新的操作系统和工具上开始开发和测试您的应用。

您可能已经注意到,Flutter 最新版本为 iOS 开发者带来了显著的体验提升,解决了用户长期以来的一个痛点:在物理设备上运行 Flutter 应用时,Xcode 应用必须自动启动flutter run。我们引入了一种新的部署方式,使用 Xcode 26 命令行工具 npm run devicectldev 来进行应用的安装、启动和调试。这种方式无需在部署过程中调用 Xcode 应用,大多数情况下完全依赖于命令行 Xcode 构建工具。如果您遇到任何问题,可以使用 npm run dev 禁用此部署方式flutter config --no-enable-lldb-debugging,并请提交 issue告知我们!

此前,此功能依赖于 Xcode 自动化,但在 Xcode 26 中变得不稳定且容易出错,尤其是在连续运行命令时。如果您现在正在为最新的 Apple 版本进行开发,我们强烈建议您将 Flutter 版本更新到 3.38 或更高版本。

UIScene 生命周期迁移

Flutter 3.38 包含了对苹果强制要求的UIScene 生命周期的重要支持。这是继苹果在 WWDC25 上宣布“在 iOS 26 之后的版本中,任何使用最新 SDK 构建的 UIKit 应用都必须使用 UIScene 生命周期,否则将无法启动”之后的一项关键且积极的更新。

为确保您的 iOS Flutter 应用程序在未来的 iOS 版本上保持兼容性并成功启动,需要进行迁移。

迁移 Flutter 应用程序

所有现有的 iOS Flutter 应用都必须迁移到新的生命周期。您可以通过两种方式完成此迁移:

  1. 手动迁移:请按照 Flutter网站上提供的手动迁移说明进行操作。
  2. 自动迁移(实验性功能):启用此实验性功能可自动处理迁移。此功能将在未来的版本中默认启用。运行以下命令:

flutter config --enable-uiscene-migration

成为会员

迁移 Flutter 插件

依赖应用程序生命周期事件的 Flutter 插件必须更新为使用 UIScene 生命周期事件。插件开发者应参考迁移指南。未迁移的插件将在未来的版本中显示警告。

迁移嵌入式 Flutter(可选)

对于将 Flutter 嵌入原生宿主应用程序的项目,迁移是可选的,但强烈建议进行。使用“添加到应用程序迁移指南”采用 Flutter 新的 UIScene API ,可以为您的插件启用场景生命周期事件,从而确保与 Flutter 生态系统的兼容性。

安卓

16KB页面大小兼容性

升级到 Flutter 3.38 是满足Google Play 16 KB 页面大小兼容性要求的必要准备。从2025 年 11 月 1 日起,面向 Android 15 及更高版本的应用必须支持 16 KB 页面。此项变更可确保您的应用在高内存设备上正常运行,并带来性能提升,例如启动速度提升高达 30%。Flutter 3.38 将默认的 Android ndkVersion 更新为 NDK r28,这是原生代码实现 16 KB 页面大小支持的最低要求。

内存修复

Flutter 3.38修复了一个影响所有 Android 平台 Flutter 应用的严重内存泄漏问题。该问题(在 3.29.0 版本中引入)发生在 Activity 在退出时被销毁的情况下,无论是开发者设置中配置的销毁方式,还是由于内存不足而被系统强制终止的 Activity。

Android 依赖项更新

为您的应用找到合适的 Android 依赖项版本组合通常是一项挑战,这些依赖项包括 Gradle、Android Gradle 插件 (AGP)、Kotlin Gradle 插件 (KGP)、Java 等。对于 Flutter 3.38 版本,我们在持续集成 (CI) 环境中测试并确认了以下 Android 依赖项版本组合的兼容性:

  • Java 17:Flutter 3.38 中 Android 开发的最低版本要求。
  • KGP 2.2.20:该工具支持的最高Kotlin Gradle 插件版本。
  • AGP 8.11.1:最新的 Android Gradle 插件版本,与 KGP 2.2.20兼容。
  • Gradle 8.14:此版本与所选的 Java、KGP 和 AGP 版本兼容。请注意,AGP 8.11.1 的最低版本要求为 Gradle 8.13。

为了确保您的应用能够在不同的 Flutter 版本之间无缝运行,我们强烈建议您在构建文件中使用 Flutter SDK 提供的 API 级别变量。此版本配置的值如下:

  • flutter.compileSdkVersion(API 36)
  • flutter.targetSdkVersion(API 36)
  • flutter.minSdkVersion(API 24)或更高

引擎

性能叠加层

性能叠加层已重构,效率更高,在 Skia 和 Impeller 后端上的渲染时间均有所缩短。这意味着您可以以更少的开销获得更准确的性能数据。(#176364

Vulkan 和 OpenGL ES

对 Vulkan 和 OpenGL ES 后端的诸多修复和改进提高了在更广泛设备上的稳定性和性能。这包括更好地处理管线缓存(#176322)、栅栏等待器(#173085)和图像布局过渡(#173884)。

渲染器统一

CanvasKit 和 Skwasm 渲染器的统一工作仍在继续。本次版本更新包含大量重构,以在两者之间共享更多代码,这将带来更一致的用户体验,并加快未来的开发速度(#174588)。

线程合并

iOS 和 Android 系统中已移除选择退出线程合并的功能。欲了解更多信息,请观看精彩的线程合并视频。

开发者工具和集成开发环境

实验性小部件预览 — 更新

Flutter 3.35 引入了 Widget Previews(组件预览),这是一项实验性功能,旨在收集社区的早期反馈。Flutter 3.38 版本对 Widget Previews 进行了重大改进,包括:

  • IDE 集成:我们的 VSCode 和 IntelliJ/Android Studio 插件均已更新,初步支持 Widget 预览。现在,您可以直接在 IDE 中查看预览,从而获得更流畅的开发体验。

按回车键或点击查看完整尺寸的图片

VSCode 中嵌入了小部件预览。

在集成开发环境 (IDE) 中使用时,控件预览环境默认配置为根据当前选定的源文件筛选显示的预览:

按回车键或点击查看完整尺寸的图片

  • 小部件预览环境主题和控件改进:小部件预览环境现在支持浅色和深色模式,以及自定义 IDE 配色方案,以匹配您的开发环境。小部件预览环境中的控件也进行了调整,以减少占用空间,从而为渲染预览腾出更多空间。

按回车键或点击查看完整尺寸的图片

为小部件预览环境提供自定义主题支持。

  • 预览可扩展性:预览注解类不再标记为 final,现在可以扩展以创建自定义预览注解,从而减少常见预览类型的样板代码。

按回车键或点击查看完整尺寸的图片

自定义注解示例BrightnessPreview

  • 多预览支持:新增的MultiPreview基类允许从单个自定义注释创建多个预览变体。

按回车键或点击查看完整尺寸的图片

  • 预览组:类中的新分组参数Preview允许对相关的预览进行分组。

按回车键或点击查看完整尺寸的图片

预览组中多个“亮度”预览的示例。

  • 放宽了对 @Preview 注解参数的限制:现在支持将私有常量作为Preview注解的参数。函数参数(例如 wrapper 和 theme)仍然需要具有公开的、静态可访问的名称。

小部件预览功能目前仍处于实验阶段,您的反馈对塑造其未来至关重要。API 和用户体验尚不稳定,我们会根据您的反馈不断改进。

根据早期反馈,我们计划进行更多改进以提升组件预览体验,包括:

  1. Flutter DevTools Widget Inspector 支持:Widget Inspector 正在更新,以支持在 widget 预览环境中检查预览效果。我们计划将检查器直接嵌入到 widget 预览器中,使其无论您处于何种开发环境都能轻松访问。
  2. IDE中的多项目支持:目前,组件预览器仅支持显示单个项目或Pub工作区内的预览。我们正在积极研究如何支持在IDE会话中包含多个Flutter项目的方案(问题#173550)。
  3. 提升启动性能:我们正在研究提升启动性能的机会,以缩短初始启动时间,包括:
  • 首次运行后启动预编译的组件预览环境
  • 并行化预览检测逻辑以更好地处理大型项目

首先,请查看文档,然后告诉我们您的想法!

重要提示 :已知 Widget Previewer 在执行某些操作后可能会崩溃或停止更新flutter pub get 。如果遇到此问题,请flutter pub get在项目中运行相关命令并重启 IDE。 详情请参阅 #178317 。**

开发者工具更新

Flutter 3.38 修复了 2025 年 DevTools 用户调查中用户指出的一些主要痛点,包括:

网络面板改进

  • 使用户更容易了解面板何时正在记录网络流量。(#9495
  • 修复了复制粘贴网络请求相关的问题。(#9472#9482#9485#8588

Flutter Inspector 修复

  • 修复了选择控件时有时会打开底层框架源代码而不是用户源代码的错误。(#176530
  • 修复了偶尔会导致无法与检查器面板顶部按钮进行交互的错误。(#9327

弃用和重大变更

作为 Flutter 框架现代化和改进工作的一部分,本次版本更新包含了几个重要的弃用项和重大变更。

关键的构建和工具变更可能会影响自定义构建脚本。Flutter versionSDK 根目录下的文件已被移除,取而代之的是位于 [flutter.version.json此处应填写路径] 的新文件bin/cache#172793)。此外,该AssetManifest.json文件不再默认生成(#172594)。

其他显著变化包括:

  • 为了使行为更可预测,包含操作的 SnackBar 将不再自动关闭(#173084)。
  • 构造函数OverlayPortal.targetsRootOverlay已被弃用,取而代之的是更灵活的OverlayPortaloverlayLocation: OverlayChildLocation.rootOverlay)。
  • 的几个属性CupertinoDynamicColor,例如withAlphawithOpacity,现在已被弃用,取而代之的是标准Color方法(#171160)。
  • Flutter 3.38 要求 Android 的最低 Java 版本为 17,与Gradle 8.14(2025 年 7 月发布)的最低要求一致。

有关这些变更和其他变更的更多详细信息和迁移指南,请查看重大变更页面

结尾

Flutter 3.38 改动还是挺多的, 要升级的还得等等看看,避免在生产环境使用。

下一代桌面应用框架 - Tauri 尝鲜

之前我写过一篇文章,介绍了pake这个小玩具,今天我来说一说它的亲爹Tauri


做过桌面应用打包的前端同学,对 Electron 的名声肯定是如雷贯耳。它可以用我们熟悉的 Web 技术构建跨平台的桌面应用,VS Code、Slack、Discord 等众多知名应用都是基于它构建的。然而,Electron 的“缺点”也很明显:打包体积大、内存占用高,特别是在弱网传输的时候,这个毛病影响很大。。

接下来,我来介绍一个正在悄摸摸崛起的挑战者——Tauri。我掐指算过了,它会是下一代桌面应用框架!

Tauri 是什么?

我先简单介绍一下 tauri 框架。 Tauri 是一个为构建更小、更快、更安全的桌面应用程序而设计的框架。它和 Electron 一样,允许你使用任何前端框架(如 React、Vue、Svelte 或原生 HTML/CSS/JS)来构建用户界面。 但它的核心思想与 Electron 有本质区别:

  1. 更小的体积Electron 内置了一个完整的 Chromium 浏览器,所以每个应用都至少要带上 100MB+ 的基础文件。而 Tauri 则使用操作系统自带的 WebView(Windows 上的 Webview2,macOS 上的 WebKit,Linux 上的 WebKitGTK)。这意味着你的应用本身不包含浏览器,体积可以轻松做到 10MB 以下,甚至更小。
  2. 更低的资源占用:由于不自带浏览器,Tauri 应用的内存占用和 CPU 消耗远低于 Electron 应用,运行起来更加轻快。
  3. 后端使用 RustTauri 的后端核心是用 Rust 编写的。Rust 以其高性能、内存安全和并发性著称,这为桌面应用提供了坚实、安全的后端基础。你可以通过简单的 JS API 调用 Rust 代码,处理文件系统、执行 Shell 命令等操作,既安全又高效。
  4. 安全优先Tauri 默认采用“权限最小化”原则。你需要在一个叫 allowlist(允许列表)的配置文件中,明确开启你的应用需要使用的 API(如文件读写、网络请求等),这大大降低了应用的安全风险。 总而言之,Tauri 用“借力”的方式,解决了 Electron 最大的体积和性能问题,同时用 Rust 提供了一个强大而安全的后端,这正是它被称为“下一代”框架的原因。目前Tauri版本已经到了2.0,本文后面用的也是这个版本。

Windows 系统安装 Tauri

接下来我带你在 windows 系统安装一下 tauri 框架,后面有空我再介绍一下在 ubuntu 系统中的安装方法。 在开始之前,Tauri 需要两个核心依赖:Rust 语言环境和 C++ 编译工具链。

1. 安装 Rust

Tauri 的后端是 Rust,所以首先需要安装 Rust。 访问官网:rust-lang.org/tools/insta… 下载并运行 rustup-init.exe,按照提示安装即可。安装完成后,它会提示你重启终端或重新加载环境变量。

2. 安装 Microsoft C++ 生成工具

在 Windows 上编译 Rust 和一些原生 Node.js 模块需要 C++ 构建工具。 访问官网:visualstudio.microsoft.com/zh-hans/vis… 下载“Microsoft C++ 生成工具”。在安装程序中,只用勾选“使用 C++ 的桌面开发”,然后安装即可。

3. 确认环境

打开一个新的终端(CMD 或 PowerShell),输入以下命令,如果都能正常显示版本号,说明环境就绪了。

rustc --version
npm --version

Tauri 初体验:打包一个远程 URL

1. 准备一个前端项目

我直接把一个项目复制过来,把代码管理相关文件夹删除,依赖也可以删除,然后存在一个文件夹里,就用这个项目来试下效果。

  1. 在项目根目录初始化 npm 项目:
    npm init -y
    
    这会生成一个 package.json 文件。

2. 初始化 Tauri

把这个实验项目用vscode打开,然后执行:

# 安装 Tauri CLI 作为开发依赖
npm install -D @tauri-apps/cli@latest 
# 初始化 Tauri 项目
npm run tauri init

执行 init 命令时,它会问你几个问题,比如应用名称、窗口标题等,一路回车使用默认值即可。

这会创建一个 src-tauri 目录,这就是 Tauri 框架的后端部分。里面包含了 Rust 代码和关键的配置文件 tauri.conf.json

3. 尝鲜 Tauri

安装好了之后,咱先尝个鲜,把本地跑起来的应用打包进去呢? 看起来,如果只是打包一个url,这个包会相当的小。如果我把这个前端应用在服务器上用nginx部署,然后再用tauri打包,会不会比较有意思? 是的,这是一个非常有趣的应用场景!你可以创建一个“壳应用”,它本身不包含任何业务逻辑,只是加载一个远程的 Web 地址。这样做的好处是:

  • 体积极小:应用本身只有几 MB,用户下载飞快。
  • 热更新:你只需要更新服务器上的网页,所有用户的桌面应用内容就自动更新了,无需重新分发安装包。 要实现这个,只需在 tauri.conf.json 配置文件中修改 window.url 字段:
// src-tauri/tauri.conf.json
{
  "build": {
      "devUrl": "https://your-nginx-deployed-app.com" // 改成你的网址
      }
  }
}

不过,这种方式的缺点是应用部署必须得配一个nginx,安装过程不够简单,有点拖泥带水。


Tauri 实战:打包本地前端应用

所以,我依然想用tauri打包一个包含所有前端应用内容的本地包,不依赖于url。来吧! 我带你创建一个完整的前端项目并将其打包成一个独立的桌面应用。

1. 修改配置

前面只是尝鲜,只用到devUrl这个配置。接下来将是最关键的一步!你需要根据你的项目结构,修改 src-tauri/tauri.conf.json 文件。我这里给出一个配置文件的模板,里面有详细说明:

{
  "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
  "productName": "Tauri Demo",
  "version": "0.1.0",
  // 这里要注意,会有一些格式要求,按提示改就行
  "identifier": "com.tauri.demo",
  "build": {
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "pm run build",
    // 告诉 Tauri 你的前端静态文件(HTML, JS, CSS)在哪个目录
    "frontendDist": "../dist"
  },
  "app": {
    "windows": [
      {
        "label": "main", // 给窗口一个唯一的 label
        "title": "Tauri Demo",
        "width": 800,
        "height": 600,
        "resizable": true,
        "fullscreen": false
      }
    ],
    "security": {
      "csp": null // 内容安全策略,开发阶段可以设为 null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  },
  "plugins": {} // 插件配置,后面会用到
}

关键点解释

  • frontendDist: 这是 Tauri 2.0 的新字段,替代了旧版的 distDir。它指向你构建完成后的前端资源目录。对于vue项目,直接指向根目录 "../dist" 即可。
  • beforeDevCommand / beforeBuildCommand: 意思是在执行 Tauri的构建之前,会执行这些项目本身的 npm run devnpm run build 脚本。
  • app.windows: 窗口配置现在被放在 app 对象下。给窗口加了一个 label: "main",这在权限配置时会用到。
  • plugins: Tauri 2.0 的核心功能(如文件系统、Shell)都通过插件提供。需要在这里声明使用的插件。

2. 开发与运行

配置完成后,就可以启动开发模式了。直接运行即可:

npm run tauri dev

这会先执行npm run dev,然后编译 Rust 后端,然后直接弹出一个桌面窗口,里面加载的就是你的 index.html 页面。当你修改项目文件并保存时,窗口会自动刷新,这就是热重载。

3. 构建最终安装包

当你对应用满意后,就可以执行构建命令了:

npm run tauri build

下面这张图是执行npm run tauri:build后的效果,它会先执行npm run build,构建本地的打包文件,然后再从dist文件夹下面获取静态文件并打包为最终包。

构建完成后,你可以在 src-tauri/target/release/bundle/ 目录下找到生成的安装包。在 Windows 上,你会看到一个 .msi 安装文件和一个免安装的 .exe 文件。这个 .exe 文件就是我们的最终成果,体积非常小,可以分发给用户直接运行!


总结

Tauri 以其轻量、高性能和高安全性的特性,为前端开发者打开了一扇通往现代桌面应用开发的新大门。虽然生态和社区相比 Electron 还在成长中,但其技术理念的先进性已经吸引了大量关注。如果你正准备开发一款桌面应用,或者对 Electron 的性能和体积感到困扰,不妨来尝一尝 Tauri 这道“新菜”,相信它会给你带来惊喜!


后续,我将探索Tauri框架的IPC通信,给应用增加与后端通信的能力,敬请期待。

能让 GitHub 删除泄露的苹果源码还有 8000 多个相关仓库的 DMCA 是什么?

1. 源码泄露

上周苹果 App Store 前端源码泄露,因其生产环境忘记关闭 Sourcemap,被用户下载了源码,上传到 Github。短短几天就已经 Fork 和 Star 超 5k。

当然现在仓库已经被 ban 了,当你打开仓库的时候,GitHub 会提示:

千万不要以为自己有 fork 就安全了,fork 的仓库也一同被删除了,据说删了 8000 多个相关仓库,足以看到当时大家凑热闹的热情 😂

2. 还想找源码?

如果你还想看看泄露的代码,GitHub 可能已经不好找了,但国内的 Gitee 上依然可以找到。

在 Gitee 搜索 apps.apple.com 可以看到:

如果你想找可运行的版本,可以搜索 test-apps.apple.com-main

不过说是可运行,其实只是本地起个服务静态预览:

如果要从源码运行,因为仓库引用了私有包,相对来说还是难处理的。

关于源码,还可以阅读我的这篇《看了下昨日泄露的苹果 App Store 源码……》

3. 类似事件

不过这次泄露的只是苹果 App Store 的前端源码,对业务并没有什么影响,仓库在几天后才删除。如果泄露的后端代码,那可能就要紧张很多了。

其实这事之前也发生过,2019 年的时候,B 站的后端源码就被泄露了,10 点上传的源码,16 点大量吃瓜群众涌入,17 点就因 DMCA 被 ban 了。这事之后,很多仿 B 站架构的项目如雨后春笋,甚至还掀起了一股学习 Go 语言的热潮 😂

大家在源码中更是挖了不少内容,比如用户名密码被硬编码在代码中,暗箱操作抽奖成功率,代码不规范,发现在代码里画画、写诗、画颜文字、吐槽甚至贴广告,一度导致 B 站股价下跌。

相比之下,苹果这次的后果很轻了,而且源码里也没什么画画、写诗,整体是比较规范有序的。

4. DMCA

说回 DMCA,全称 Digital Millennium Copyright Act,中文翻译为“数字千年版权法”,又称“千禧年数字版权法”,是美国 1998 年颁布的联邦法律,旨在应对数字时代的著作权保护问题。

其主要目的是保护数字内容(如音乐、电影、文字、图像等)的著作权,并为在线服务提供商(如 Google 等)提供了免除因用户上传侵权内容而产生的金钱赔偿责任的安全港规则。这意味着在线服务提供商在收到版权所有者的侵权通知后,若能及时移除侵权内容,则可免受法律追责。

GitHub 有专门的仓库记录 DMCA 的公开通知:https://github.com/github/dmca

从中可以翻到 11 月 7 日的 apple 仓库相关的通知:

TypeScript 5.0+ 重要新特性全面解析

本文将详细介绍 TypeScript 自 5.0 版本以来的重要更新,帮助开发者了解并应用这些新特性来提升开发效率和代码质量。


📅 TypeScript 5.0 (2023年3月)

1. 装饰器(Decorators)正式支持

TypeScript 5.0 正式支持了 ECMAScript Stage 3 的装饰器提案,这是一个里程碑式的更新。

// 类装饰器
function logged(value: any, context: ClassDecoratorContext) {
  return class extends value {
    constructor(...args: any[]) {
      super(...args);
      console.log(`创建了 ${context.name} 的实例`);
    }
  };
}

@logged
class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

// 方法装饰器
function log(target: any, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);
  return function (this: any, ...args: any[]) {
    console.log(`调用方法: ${methodName}`);
    return target.apply(this, args);
  };
}

class Calculator {
  @log
  add(a: number, b: number) {
    return a + b;
  }
}

2. const 类型参数

允许在泛型中使用 const 修饰符,锁定类型为字面量类型。

// 不使用 const
function makeArray<T>(arr: T[]) {
  return arr;
}
const arr1 = makeArray([1, 2, 3]); // number[]

// 使用 const
function makeConstArray<const T>(arr: T[]) {
  return arr;
}
const arr2 = makeConstArray([1, 2, 3]); // readonly [1, 2, 3]

3. 枚举改进

所有枚举成员现在都可以作为联合枚举使用。

enum Status {
  Loading = "loading",
  Success = "success",
  Error = "error"
}

// 可以直接用作类型
function handleStatus(status: Status) {
  switch (status) {
    case Status.Loading:
      return "加载中...";
    case Status.Success:
      return "成功!";
    case Status.Error:
      return "错误!";
  }
}

📅 TypeScript 5.1 (2023年6月)

1. get/set 访问器类型解耦

允许 getter 和 setter 使用不同的类型,提供更灵活的 API 设计。

class Thing {
  #size = 0;

  // getter 返回 number
  get size(): number {
    return this.#size;
  }

  // setter 可以接受 number | string
  set size(value: number | string) {
    this.#size = typeof value === 'string' ? parseInt(value) : value;
  }
}

const thing = new Thing();
thing.size = "42"; // ✅ 可以传字符串
console.log(thing.size); // 42 (number)

2. 未定义返回函数的改进

更好地处理返回 undefined 的函数。

// 之前可能需要显式返回 undefined
function doSomething(): undefined {
  console.log("做点什么");
  return undefined;
}

// 现在可以省略 return
function doSomethingElse(): undefined {
  console.log("做点别的");
  // 不需要显式 return
}

📅 TypeScript 5.2 (2023年8月)

1. using 声明和显式资源管理

支持 ECMAScript 的显式资源管理提案,自动清理资源。

// 实现 Disposable 接口
class FileHandle implements Disposable {
  private file: string;

  constructor(filename: string) {
    this.file = filename;
    console.log(`打开文件: ${filename}`);
  }

  [Symbol.dispose]() {
    console.log(`关闭文件: ${this.file}`);
    // 清理资源
  }

  read() {
    return `读取 ${this.file} 的内容`;
  }
}

// 使用 using 声明
function processFile() {
  using file = new FileHandle("data.txt");
  console.log(file.read());
  // 函数结束时自动调用 [Symbol.dispose]
}

2. 装饰器元数据

增强装饰器功能,支持元数据反射。

function setMetadata(key: string, value: any) {
  return (target: any, context: DecoratorContext) => {
    context.metadata[key] = value;
  };
}

@setMetadata("version", "1.0")
class MyClass {}

📅 TypeScript 5.3 (2023年11月)

1. Import Attributes 支持

支持导入断言的标准化语法。

// 导入 JSON 文件
import data from "./data.json" with { type: "json" };

// 导入 CSS 模块
import styles from "./styles.css" with { type: "css" };

2. switch(true) 收窄

改进 switch(true) 语句中的类型收窄。

function processValue(value: string | number) {
  switch (true) {
    case typeof value === "string":
      // value 被收窄为 string
      return value.toUpperCase();
    case typeof value === "number":
      // value 被收窄为 number
      return value.toFixed(2);
  }
}

📅 TypeScript 5.4 (2024年3月)

1. NoInfer 工具类型

防止类型推断在特定位置发生,提供更精确的类型控制。

function createMap<T>(
  keys: T[],
  defaultValue: NoInfer<T>
): Map<T, T> {
  const map = new Map<T, T>();
  keys.forEach(key => map.set(key, defaultValue));
  return map;
}

// T 只从 keys 推断,不从 defaultValue 推断
const map1 = createMap(["a", "b"], "default"); // Map<string, string>
const map2 = createMap([1, 2], 0); // Map<number, number>

2. Object.groupBy 和 Map.groupBy 支持

支持新的 JavaScript 分组方法。

const people = [
  { name: "Alice", age: 25 },
  { name: "Bob", age: 30 },
  { name: "Charlie", age: 25 }
];

const grouped = Object.groupBy(people, person => person.age);
// { 25: [{name: "Alice", age: 25}, {name: "Charlie", age: 25}], 
//   30: [{name: "Bob", age: 30}] }

📅 TypeScript 5.5 (2024年6月)

1. 推断类型谓词(Inferred Type Predicates)

自动推断函数是否为类型守卫,无需手动编写类型谓词。

// 之前需要显式声明类型谓词
function isString(value: unknown): value is string {
  return typeof value === "string";
}

// 现在 TypeScript 可以自动推断
function isStringAuto(value: unknown) {
  return typeof value === "string";
}

const values: unknown[] = ["hello", 42, "world"];
// TypeScript 能识别 filter 后的类型
const strings = values.filter(isStringAuto); // string[]

2. 常量索引访问的控制流收窄

改进对常量索引访问的类型分析。

function processData(data: { status: "success"; value: number } | { status: "error"; error: string }) {
  const key = "status" as const;
  
  if (data[key] === "success") {
    // data 被正确收窄为 { status: "success"; value: number }
    console.log(data.value);
  } else {
    // data 被正确收窄为 { status: "error"; error: string }
    console.log(data.error);
  }
}

3. 正则表达式语法检查

在字符串字面量中检查正则表达式语法错误。

// ❌ 编译时报错:无效的正则表达式
const invalidRegex = /[/;

// ✅ 正确的正则表达式
const validRegex = /[/;

4. 数组过滤增强

更智能的数组 filter 方法类型推断。

const mixed: (string | number | null)[] = ["hello", 42, null, "world", null];

// 自动推断过滤后的类型
const nonNull = mixed.filter(x => x !== null); // (string | number)[]
const strings = mixed.filter(x => typeof x === "string"); // string[]

📅 TypeScript 5.6 (2024年9月)

1. 可变元组支持

增强的元组类型功能,支持更灵活的元组操作。

// 可变元组类型
type Prefixed<T extends unknown[]> = [string, ...T];

type Example1 = Prefixed<[number, boolean]>; // [string, number, boolean]
type Example2 = Prefixed<[]>; // [string]

📅 TypeScript 5.7 (2024年11月)

1. ES2024 特性集成

完整支持最新的 ECMAScript 2024 特性。

// 支持 Promise.withResolvers
const { promise, resolve, reject } = Promise.withResolvers<number>();

// 支持 Array.fromAsync
const asyncIterable = async function* () {
  yield 1;
  yield 2;
  yield 3;
};
const array = await Array.fromAsync(asyncIterable());

2. 模块解析改进

更好的路径重写和模块解析能力。

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"]
    }
  }
}

📅 TypeScript 5.8 (2025年3月)

1. 返回表达式中的分支类型检查增强

改进对返回语句中条件表达式的类型检查。

function getValue(condition: boolean): string | number {
  // TypeScript 5.8 更精确地推断返回类型
  return condition 
    ? "string value"  // string
    : 42;             // number
}

2. 稳定的 --module node18 标志

为 Node.js 18+ 用户提供稳定的模块系统支持。

// tsconfig.json
{
  "compilerOptions": {
    "module": "node18",
    "moduleResolution": "node18"
  }
}

3. 直接执行支持

简化 TypeScript 代码的直接执行流程,提升开发体验。


🎯 总结

TypeScript 5.x 系列版本带来了众多重要特性:

🔑 核心亮点

  1. 装饰器标准化 - 正式支持 ECMAScript 装饰器
  2. 资源管理 - using 声明自动清理资源
  3. 类型推断增强 - 自动推断类型守卫、更好的控制流分析
  4. 开发体验 - 正则语法检查、更好的错误提示
  5. ECMAScript 对齐 - 紧跟最新 JavaScript 标准

📊 升级建议

  • 从 4.x 升级到 5.x: 强烈推荐,性能和类型安全性大幅提升
  • 渐进式采用: 新特性可以逐步引入,不会破坏现有代码
  • 关注装饰器: 如果使用旧版装饰器,需要迁移到新语法

🔗 参考资源


你最喜欢哪个新特性?欢迎在评论区讨论! 💬

我用 TRAE 翻译了J友的数字滚动组件,从原生到Vue!

最近在做自己的大屏项目,毕竟很多公司还是要求提交自己的作品的。

虽然做的效果一般,但是UI和UE上的体验一定要拉满,给面试官以视觉上的冲击力才是大屏的关键。

在掘金上看到这篇文章写的仿百度数字滚动效果,打算比着葫芦画瓢,复刻一个Vue3版本的数字滚动组件。

正好现在一直在用 TRAE,复刻代码也比较简单。

效果

数字滚动组件的核心部分就是延迟滚动效果,各个数字从前到后延迟滚动会造成一种非常好的视觉效果。

我一开始以为直接用CSS实现就齐活了,后来发现事情不是这么简单的。

  • 每位数字的滚动时间不同,做出前后延迟效果。
  • 数字需要精准的滚动到所需的内容,并且滚动过程需要连贯。
  • 滚动效果上带有一种惯性的感觉。

屏幕录制-2025-11-13-172657.gif

本来想着自己比着来一遍速度也很快,但是低估了内容的难度。

于是乎我打开 TRAE ,将J友的代码拷进去,输入指令:

请帮我将上述代码转换为Vue3的组件代码,我要实现的是一个数字滚动的组件,将每个数字进行分割,最大8位数,不足8位使用0补齐8位。同时每3位增加一个逗号分隔符,每位数字都拥有自己的背景,类似于记分牌的效果。

1,2,3.....

于是乎以下的代码就输出了出来!

实现代码

<div class="num-statistic">
    <div class="num-statistic-content">
        <template v-for="(_digit, index) in numList" :key="index">
            <div class="digit-container" :class="`digit-container${index+1}`" ref="digitListRefs">
                <div class="digit-list">
                    <div v-for="(_item, idx) in 10" :key="idx" class="digit">{{ idx }}</div>
                    <div v-for="(_item, idx) in 10" :key="`dup-${idx}`" class="digit">{{ idx }}</div>
                    <div v-for="(_item, idx) in 10" :key="`dup2-${idx}`" class="digit">{{ idx }}</div>
                </div>
            </div>
            <div v-if="index === 1 || index === 4" class="num-split" :key="`split-${index}`">,</div>
        </template>
    </div>
</div>

Ps: TRAE提醒我,每个需要滚动的数字组件一定要通过 v-for 循环创建,即便你的数字位数是固定的。

因为Vue3中只有通过v-for定义相同的ref才会被归纳到一个数组中。

单纯的写多个divref相同的话,并不是数组,而是一个对象,指向最后一个DOM

const props = defineProps<{
    num: number
}>()
const digitListRefs = ref<HTMLDivElement[]>([])

const numList = computed(() => {
    const numStr = String(props.num).padStart(8, '0');
    return numStr.split('');
});

watch(numList, () => {
    nextTick(() => {
        startAnimate();
    })
}, {
    immediate: true
})

const startAnimate = () => {
    const digits = numList.value;
    digitListRefs.value.forEach((element, i) => {
        if (element && element.querySelector('.digit-list')) {
            const list = element.querySelector('.digit-list') as HTMLElement;
            const targetDigit = parseInt(digits[i], 10);
            const targetY = -(20 + targetDigit) * 50;

            list.style.transform = `translateY(${targetY}px)`;
        }
    });
}

Ps: 这里注意,我设置的数字最大8位数,逻辑上是不足8位以0补全。

完整组件代码

<script setup lang="ts">
import {ref, defineProps, computed, watch, nextTick} from "vue";

const props = defineProps<{
    num: number
}>()

const digitListRefs = ref<HTMLDivElement[]>([])

const numList = computed(() => {
    const numStr = String(props.num).padStart(8, '0');
    return numStr.split('');
});

watch(numList, () => {
    nextTick(() => {
        startAnimate();
    })
}, {
    immediate: true
})

const startAnimate = () => {
    const digits = numList.value;
    digitListRefs.value.forEach((element, i) => {
        if (element && element.querySelector('.digit-list')) {
            const list = element.querySelector('.digit-list') as HTMLElement;
            const targetDigit = parseInt(digits[i], 10);
            const targetY = -(20 + targetDigit) * 50;

            list.style.transform = `translateY(${targetY}px)`;
        }
    });
}
</script>

<template>
    <div class="num-statistic">
        <div class="num-statistic-content">
            <template v-for="(_digit, index) in numList" :key="index">
                <div class="digit-container" :class="`digit-container${index+1}`" ref="digitListRefs">
                    <div class="digit-list">
                        <div v-for="(_item, idx) in 10" :key="idx" class="digit">{{ idx }}</div>
                        <div v-for="(_item, idx) in 10" :key="`dup-${idx}`" class="digit">{{ idx }}</div>
                        <div v-for="(_item, idx) in 10" :key="`dup2-${idx}`" class="digit">{{ idx }}</div>
                    </div>
                </div>
                <div v-if="index === 1 || index === 4" class="num-split" :key="`split-${index}`">,</div>
            </template>
        </div>
    </div>
</template>

<style scoped lang="less">
.num-statistic-content {
    display: flex;
    gap: 12px;
}

.digit-container1,
.digit-container2,
.digit-container3,
.digit-container4,
.digit-container5,
.digit-container6,
.digit-container7,
.digit-container8 {
    width: 36px;
    height: 50px;
    text-align: center;
    line-height: 50px;
    overflow: hidden;
    background-color: #244193;
    font-size: 24px;
    font-weight: bold;
    border-radius: 4px;

    .digit {
        height: 50px;
        display: flex;
        align-items: center;
        justify-content: center;
    }
}

.digit-container1 .digit-list {
    transition: transform 1720ms ease-in-out;
}

.digit-container2 .digit-list {
    transition: transform 1760ms ease-in-out;
}

.digit-container3 .digit-list {
    transition: transform 1800ms ease-in-out;
}

.digit-container4 .digit-list {
    transition: transform 1840ms ease-in-out;
}

.digit-container5 .digit-list {
    transition: transform 1880ms ease-in-out;
}

.digit-container6 .digit-list {
    transition: transform 1920ms ease-in-out;
}

.digit-container7 .digit-list {
    transition: transform 1960ms ease-in-out;
}

.digit-container8 .digit-list {
    transition: transform 2000ms ease-in-out;
}

.num-split {
    width: 20px;
    text-align: center;
    font-size: 24px;
    font-weight: bold;
    line-height: 50px;
}
</style>

最后非常感谢这位兄弟@三个木base的思路及源码,再次感谢!

零代码+三维仿真!实现自然灾害的可视化模拟与精准预警

从“被动应对”到“主动预警”的蜕变

传统防灾预警高度依赖专业人员经验与繁琐的数据分析,既耗时耗力,又因灾害状态瞬息万变易错过最佳预警窗口。

园测信科自研的**【RiskInsight平台】构建【综合风险预警模型】【灾害链动力学模型】两大模块,围绕滑坡泥石流、洪水、森林火灾、边坡崩塌等多个灾种,提供基于真实地理环境的灾害分析,将冰冷的监测数字转换成直观生动的三维仿真场景**,并且可以按照现实情况实时推演,推动防灾减灾工作的普及化和高效化

动图封面

01 支持多灾种可视化的综合评价模型

内置**【滑坡泥石流、溃决洪水、流域洪水、城市洪涝、边坡塌陷、森林火灾】等灾害综合评价模型,**利用GIS空间分析能力,实现实时数据驱动下的模型运行,以及在地图上的可视化模拟,从而降低决策理解门槛。

动图封面

  • 集成**多个综合评价模型,**提供多灾种科学分析支撑。

  • 实时数据驱动专业模型,灾害评估结果更贴近实际。

  • 基于真实三维地理环境直观展示模型分析结果,反映灾害发生概率,降低决策理解门槛

02 基于真实地理环境的动力学模型仿真模拟

内置通用的**【滑坡泥石流、溃决洪水、流域洪水、城市洪涝、边坡塌陷、森林火灾】等灾害链动力学仿真分析模型**,接入真实的地理数据,可实现在三维场景中模拟灾害的演进过程(如溃决洪水的扩散过程);

同时支持调整参数来对比:不同气象条件下房屋、道路、人员等承载体的受灾情况。

动图封面

  • 内置多种灾害的动力学模型,支持实时修改模型参数,快速生成模拟场景。

  • 基于真实DEM数据,可视化模拟灾害演进过程。

  • 可叠加建筑物、道路、人口分布数据,动态统计灾害对关键设施的影响范围。

零代码让精准预警触手可及

灾害风险预警模型是平台的核心,面对不同的地理环境,需要业务专家据实际情况,对模型中的大量参数进行调试以获得最优的监控方案,在以往的调试过程中,需要由防灾专家与开发者多次沟通才能完成,一旦地理环境发生变化,又需要重复上述的调试过程,这样的模式不仅效率低下,更让真正懂防灾业务的人员被隔绝在技术门槛之外!

作为业内首个自然灾害监测预警零代码开发平台,RiskInsight通过直观的图形界面和简单的交互操作,无需编写复杂的代码就可以完成预警模型的参数调整,对开发的依赖大大降低!并且,在地理环境不断演变的情况下,可以及时地调整与验证,生成最优的监控方案。

01 方式一:框选范围分析

动图封面

#该方式支持用户直接在地图上划定分析范围,操作更简便。

02 方式二:上传数据分析

动图封面

#该方式需用户自行制作数据上传,分析结果更精确。

03 运行模型

动图封面

仿真分析案例

● 流域洪水

本模型主要用于模拟流域或区域尺度的地表水与地下水动态过程,尤其在水文循环模拟、洪水预报、干旱评估及气候变化对水资源的影响研究中应用广泛。

动图封面

动图封面

● 滑坡泥石流

本模型是一款专注于泥石流(岩屑流、山洪泥石流)动力学模拟的专业软件工具,主要用于分析泥石流的启动、运动过程、堆积范围及对周边设施的影响,广泛应用于山地灾害风险评估、防灾减灾规划及工程防护设计领域。

动图封面

动图封面

● 森林火灾

本模型是野火行为研究的经典工具之一。其核心目标是通过量化火源热释放、燃料特性、气象条件及地形对火蔓延的影响,预测火线(Firefront)的推进速度与方向,为火灾防控、风险评估及应急决策提供科学依据。

动图封面

动图封面

在后续的文章中,将为大家分享上述模型功能的开发思路,以及在开发过程中所遇到的问题和解决方案,敬请期待~

了解更多,请至Mapmost官网联系客服

用awesome-digital-human-live2d创建属于自己的数字人

1.AWESOME-DIGITAL-HUMAN是什么

AWESOME-DIGITAL-HUMAN是一款国产开源的数字人框架

image.png

2.准备工作

下载源码 git clone github.com/wan-h/aweso… 项目目录如下:

├── config.yaml                  # 全局配置文件
├── agents                       # agent 配置文件目录
└── engines                      # 引擎配置文件目录
    ├── asr                      # 语音识别引擎配置文件目录
    ├── llm                      # 大模型引擎配置文件目录
    └── tts                      # 文字转语音引擎配置目录

容器部署:docker-compose up --build -d 创建一个千问智能体,以备后用 开通腾讯tts服务,以备后用

image.png

打开8880端口,发现项目已经成功启动了,可以修改很多配置,比如人物的模型,背景,声音,可以使用的agent等等

image.png

2.项目修改

修改configs\engines\tts\tencentAPI.yaml文件的相关配置,选择我们喜欢的语音

VERSION: "v0.0.1"
DESC: "接入腾讯服务"
META: {
  official: "",
  configuration: "https://console.cloud.tencent.com/tts",
  tips: "",
  fee: ""
}
PARAMETERS: [
  {
    name: "secret_id",
    description: "tencent secret_id.",
    type: "string",
    required: false,
    choices: [],
    default: "腾讯tts的  secret_id"
  },
  {
    name: "secret_key",
    description: "tencent secret_key.",
    type: "string",
    required: false,
    choices: [],
    default: "腾讯tts的 secret_key"
  },
  {
    name: "voice",
    description: "Voice for TTS.",
    type: "string",
    required: false,
    choices: [],
    default: "爱小璟"
  },
  {
    name: "volume",
    description: "Set volume, default +0%.",
    type: "float",
    required: false,
    range: [-10, 10],
    default: 0.0
  },
  {
    name: "speed",
    description: "Set speed, default +0%.",
    type: "float",
    required: false,
    range: [-2, 6],
    default: 0.0
  }
]

打开config_template.yaml文件,可以发现目前是不支持通义千问的,首先增加对千问的支持

  NAME: "Awesome-Digital-Human"
  VERSION: "v3.0.0"
  LOG_LEVEL: "DEBUG"
SERVER:
  IP: "0.0.0.0"
  PORT: 8880
  WORKSPACE_PATH: "./outputs"
  ENGINES:
    ASR: 
      SUPPORT_LIST: [ "difyAPI.yaml", "cozeAPI.yaml", "tencentAPI.yaml", "funasrStreamingAPI.yaml"]
      DEFAULT: "difyAPI.yaml"
    TTS: 
      SUPPORT_LIST: [ "edgeAPI.yaml", "tencentAPI.yaml", "difyAPI.yaml", "cozeAPI.yaml" ]
      DEFAULT: "tencentAPI.yaml"
    LLM:
      SUPPORT_LIST: []
      DEFAULT: ""
  AGENTS:
    SUPPORT_LIST: [ "repeaterAgent.yaml", "openaiAPI.yaml", "difyAgent.yaml", "fastgptAgent.yaml", "cozeAgent.yaml"]
    DEFAULT: "repeaterAgent.yaml"

在SUPPORT_LIST里增加qwenAgent.yaml 并设置为默认。 在configs agent目录里增加一个qwenAgent.yaml文件

VERSION: "v0.0.1"
DESC: "接入通义千问智能体(DashScope Application)"
META: {
  official: "https://bailian.console.aliyun.com/",
  configuration: "需在百炼平台创建应用并获取 App ID",
  tips: "支持多轮对话、流式输出、自动会话管理。提示词和知识库已在智能体后台配置。",
  fee: "按 DashScope 定价计费"
}
# 暴露给前端的参数选项以及默认值
PARAMETERS: [
  {
    name: "api_key",
    description: "DashScope API Key(通义千问密钥)",
    type: "string",
    required: true,
    choices: [],
    default: "你的sk"
  },
  {
    name: "app_id",
    description: "通义千问智能体 App ID(在百炼平台创建)",
    type: "string",
    required: true,
    choices: [],
    default: "你的appId"
  }
]

在digitalHuman\agent\core下创建qwenAgent.py文件,千问支持保存短期对话和长期对话,短期对话使用session_id或者拼接message列表均可,根据文档,在这两个字段同时存在里阿里会采用message列表的模式,此时session_id字段会失效 我们使用session_id的形式,在用户首次对话时创建session_id并传递给前端,当进行后续对话时再由前端返回,长期记忆根据用户创建memory_id存表,然后在对话中携带参数即可


from ..builder import AGENTS
from ..agentBase import BaseAgent
import os
import json
from urllib.parse import urlencode
from digitalHuman.protocol import *
from digitalHuman.utils import httpxAsyncClient, logger

__all__ = ["QwenAgent"]


@AGENTS.register("Qwen")
class QwenAgent(BaseAgent):
    async def run(
        self,
        input: TextMessage,
        streaming: bool,
        **kwargs
    ):
        try:
            if not streaming:
                raise KeyError("Tongyi Agent only supports streaming mode")

            # 获取 API 参数
            api_key = kwargs.get("api_key") or os.getenv("DASHSCOPE_API_KEY")
            app_id = kwargs.get("app_id") or os.getenv("DASHSCOPE_APP_ID")
            session_id = (kwargs.get("conversation_id") or kwargs.get("session_id") or "").strip()
            memory_id  = kwargs.get("mid") or ""
            messages = kwargs.get("messages", [])

            if not api_key:
                raise ValueError(
                    "Missing 'api_key': please provide it in parameters "
                    "or set DASHSCOPE_API_KEY environment variable."
                )
            if not app_id:
                raise ValueError(
                    "Missing 'app_id': please provide it in parameters "
                    "or set DASHSCOPE_APP_ID environment variable."
                )

            prompt = input.data.strip()

            #模式判断:messages 优先级高于 session_id
            use_custom_messages = bool(messages)

            headers = {
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "X-DashScope-SSE": "enable"
            }

            payload = {
                "input": {},
                "parameters": {
                    "incremental_output": True
                },
                "stream": True
            }

            url = f"https://dashscope.aliyuncs.com/api/v1/apps/{app_id}/completion"
            if memory_id :
                payload["input"]["memory_id"] = memory_id 
            if use_custom_messages:
                #自定义上下文模式
                logger.info("[QwenAgent] Using custom 'messages' mode. session_id will be ignored.")
                for msg in messages:
                    if not isinstance(msg, dict) or "role" not in msg or "content" not in msg:
                        raise ValueError("Each message must be a dict with 'role' and 'content'")
                final_messages = messages.copy()
                if prompt:
                    final_messages.append({"role": "user", "content": prompt})
                payload["input"]["messages"] = final_messages
            else:
                #标准多轮对话模式
                if not prompt:
                    raise ValueError("Prompt is required when not using 'messages'.")
                logger.info(f"[QwenAgent] Using standard mode with session_id: '{session_id or '(new)'}'")
                payload["input"]["prompt"] = prompt
                if session_id:
                    url += "?" + urlencode({"session_id": session_id})
                    payload["input"]["session_id"] = session_id

            logger.info(f"[QwenAgent] Request URL: {url}")
            logger.info(f"[QwenAgent] Payload: {payload}")

            # 发起请求
            async with httpxAsyncClient.stream("POST", url, headers=headers, json=payload) as response:
                logger.info(f"[QwenAgent] Response status: {response.status_code}")
                if response.status_code != 200:
                    error_text = await response.aread()
                    try:
                        err_json = json.loads(error_text)
                        msg = err_json.get("message", error_text.decode())
                    except Exception:
                        msg = error_text.decode()
                    error_msg = f"HTTP {response.status_code}: {msg}"
                    logger.error(f"[QwenAgent] Request failed: {error_msg}")
                    yield eventStreamError(error_msg)
                    return

                got_conversation_id = False
                async for chunk in response.aiter_lines():
                    line = chunk.strip()
                    if not line or line == ":":
                        continue

                    if line.startswith("data:"):
                        data_str = line[5:].strip()
                        if data_str == "[DONE]":
                            break

                        try:
                            data = json.loads(data_str)
                        except json.JSONDecodeError as e:
                            logger.warning(f"[QwenAgent] JSON decode error: {e}, raw: {data_str}")
                            continue

                        logger.debug(f"[QwenAgent] SSE data received: {data}")

                        # 提取 conversation_id(仅在标准模式下)
                        if not got_conversation_id and not use_custom_messages:
                            cid = data.get("output", {}).get("session_id")
                            if cid:
                                if not session_id or cid != session_id:
                                    logger.info(f"[QwenAgent] New conversation_id received: {cid}")
                                    yield eventStreamConversationId(cid)
                                got_conversation_id = True

                        # 提取文本
                        text = data.get("output", {}).get("text", "")
                        if text:
                            yield eventStreamText(text)

            yield eventStreamDone()

        except Exception as e:
            logger.error(f"[QwenAgent] Exception: {e}", exc_info=True)
            yield eventStreamError(str(e))

阿里创建长期记忆体sdk:

    def create_client() -> bailian20231229Client:
        """
        使用凭据初始化账号Client
        @return: Client
        @throws Exception
        """
        # 工程代码建议使用更安全的无AK方式,凭据配置方式请参见:https://help.aliyun.com/document_detail/378659.html。
        credential = CredentialClient()
        config = open_api_models.Config(
            credential=credential
        )
        # Endpoint 请参考 https://api.aliyun.com/product/bailian
        config.endpoint = f'bailian.cn-beijing.aliyuncs.com'
        return bailian20231229Client(config)

    @staticmethod
    async def main_async(
        args: List[str],
    ) -> None:
        client = Sample.create_client()
        create_memory_node_request = bailian_20231229_models.CreateMemoryNodeRequest()
        runtime = util_models.RuntimeOptions()
        headers = {}
        try:
            # 复制代码运行请自行打印 API 的返回值
            await client.create_memory_node_with_options_async('', '', create_memory_node_request, headers, runtime)
        except Exception as error:
            # 此处仅做打印展示,请谨慎对待异常处理,在工程项目中切勿直接忽略异常。

需要注意incremental_output需要修改为True,否则流式输出的情况下数据会有重复。 在流式输出的情况下,如果每次返回都请求tts,会导致断句不自然甚至一字一顿的情况,比如 我 喜欢 吃这样,调整前端相关的逻辑代码,着重修改web\app(products)\sentio\hooks\chat.ts文件,主要修改逻辑就是把每次流式输出推送tts改为拼接完成后,根据标点断句分批推送tts,同时控制文字和语音的分段同时显示,使嘴型,声音和文字更加自然同步。

import { 
    useChatRecordStore, 
    useSentioAgentStore, 
    useSentioTtsStore,
    useSentioBasicStore,
} from "@/lib/store/sentio";
import { useTranslations } from 'next-intl';
import { CHAT_ROLE, EventResponse, STREAMING_EVENT_TYPE } from "@/lib/protocol";
import { Live2dManager } from '@/lib/live2d/live2dManager';
import { base64ToArrayBuffer, ttsTextPreprocess } from '@/lib/func';
import { convertMp3ArrayBufferToWavArrayBuffer } from "@/lib/utils/audio";
import {
    api_tts_infer,
    api_agent_stream,
} from '@/lib/api/server';
import { addToast } from "@heroui/react";
import { SENTIO_RECODER_MIN_TIME, SENTIO_RECODER_MAX_TIME } from "@/lib/constants";

export function useAudioTimer() {
    const t = useTranslations('Products.sentio');
    const startTime = useRef(new Date());
    const toast = (message: string) => {
        addToast({
            title: message,
            color: "warning",
        });
    };
    const startAudioTimer = () => {
        startTime.current = new Date();
    };
    const stopAudioTimer = (): boolean => {
        const duration = new Date().getTime() - startTime.current.getTime();
        if (duration < SENTIO_RECODER_MIN_TIME) {
            toast(`${t('recordingTime')} < ${SENTIO_RECODER_MIN_TIME}`);
        } else if (duration > SENTIO_RECODER_MAX_TIME) {
            toast(`${t('recordingTime')} > ${SENTIO_RECODER_MAX_TIME}`);
        } else {
            return true;
        }
        return false;
    };
    return { startAudioTimer, stopAudioTimer };
}

//获取 mid
function getMidFromUrl(): string | null {
    if (typeof window === 'undefined') return null;
    const params = new URLSearchParams(window.location.search);
    return params.get('mid');
}

export function useChatWithAgent() {
    const [chatting, setChatting] = useState(false);
    const { engine: agentEngine, settings: agentSettings } = useSentioAgentStore();
    const { engine: ttsEngine, settings: ttsSettings } = useSentioTtsStore();
    const { sound } = useSentioBasicStore();

    const { addChatRecord, updateLastRecord } = useChatRecordStore();
    const controller = useRef<AbortController | null>(null);
    const conversationId = useRef<string>("");
    const messageId = useRef<string>("");

    // 原始流式文本缓存(用于断句)
    const fullRawText = useRef<string>("");
    const pendingText = useRef<string>("");
    const lastTextUpdateTime = useRef<number>(0);

    // 已显示的文本(仅包含已 TTS 的内容)
    const displayedContent = useRef<string>("");
    const agentThinkRef = useRef<string>("");

    //TTS 串行队列,保证顺序
    const ttsQueue = useRef<Array<{ text: string }>>([]);
    const isProcessingTts = useRef<boolean>(false);

    //从 URL 获取 mid(只在初始化时读一次)
    const mid = useRef<string | null>(getMidFromUrl());

    const abort = () => {
        setChatting(false);
        Live2dManager.getInstance().stopAudio();
        if (controller.current) {
            controller.current.abort("abort");
            controller.current = null;
        }
        fullRawText.current = "";
        pendingText.current = "";
        displayedContent.current = "";
        lastTextUpdateTime.current = 0;
        agentThinkRef.current = "";
        ttsQueue.current = []; // 清空队列
        isProcessingTts.current = false;
    };

    const updateDisplayedContent = (newSentence: string) => {
        displayedContent.current += newSentence;
        updateLastRecord({
            role: CHAT_ROLE.AI,
            think: agentThinkRef.current,
            content: displayedContent.current
        });
    };

    //串行处理 TTS 队列
    const processNextTtsInQueue = () => {
        if (isProcessingTts.current || ttsQueue.current.length === 0 || !controller.current) {
            return;
        }

        isProcessingTts.current = true;
        const { text } = ttsQueue.current.shift()!;

        const processedText = ttsTextPreprocess(text);
        if (!processedText) {
            updateDisplayedContent(text);
            isProcessingTts.current = false;
            processNextTtsInQueue();
            return;
        }

        api_tts_infer(
            ttsEngine,
            ttsSettings,
            processedText,
            controller.current.signal
        ).then((ttsResult) => {
            if (ttsResult && controller.current) {
                const audioData = base64ToArrayBuffer(ttsResult);
                convertMp3ArrayBufferToWavArrayBuffer(audioData)
                    .then((buffer) => {
                        if (controller.current) {
                            updateDisplayedContent(text);
                            Live2dManager.getInstance().pushAudioQueue(buffer);
                        }
                    })
                    .catch((err) => {
                        console.warn("Audio conversion failed:", err);
                        updateDisplayedContent(text);
                    })
                    .finally(() => {
                        isProcessingTts.current = false;
                        processNextTtsInQueue();
                    });
            } else {
                updateDisplayedContent(text);
                isProcessingTts.current = false;
                processNextTtsInQueue();
            }
        }).catch((err) => {
            console.warn("TTS failed for text:", text, err);
            updateDisplayedContent(text);
            isProcessingTts.current = false;
            processNextTtsInQueue();
        });
    };

    //入队函数(替代原来的 processTextForTTS)
    const enqueueTts = (text: string) => {
        if (!controller.current) return;

        const processedText = ttsTextPreprocess(text);
        if (!processedText) {
            // 无效文本立即显示
            updateDisplayedContent(text);
            return;
        }

        if (!sound) {
            // 无声模式直接显示
            updateDisplayedContent(text);
            return;
        }

        // 有声模式:入队等待串行处理
        ttsQueue.current.push({ text });
        processNextTtsInQueue();
    };

    //按标点断句并及时入队
    const tryProcessPendingText = () => {
        if (!pendingText.current.trim()) return;

        const now = Date.now();
        const timeSinceLastUpdate = now - lastTextUpdateTime.current;
        const charCount = pendingText.current.length;

        // 定义句子结束符(注意:必须包含中文和英文标点)
        const sentenceEndingsRegex = /[。!?!?.;;\n,,、…~]/g;
        const endings = pendingText.current.match(sentenceEndingsRegex) || [];
        const parts = pendingText.current.split(sentenceEndingsRegex);

        const completeSentences: string[] = [];
        let remaining = "";

        // 重组:除最后一个 part 外,其余都可组成完整句子
        for (let i = 0; i < parts.length - 1; i++) {
            let sentence = parts[i];
            if (i < endings.length) {
                sentence += endings[i]; // 把标点加回去
            }
            if (sentence.trim()) {
                completeSentences.push(sentence);
            }
        }

        // 最后一个 part 是未完成的片段(除非原文本以标点结尾)
        const lastPart = parts[parts.length - 1] || "";
        const endsWithPunctuation = sentenceEndingsRegex.test(pendingText.current.slice(-1));
        
        if (endsWithPunctuation) {
            // 如果原文本以标点结尾,则最后一部分也完整
            if (lastPart.trim()) {
                completeSentences.push(lastPart + (endings[endings.length - 1] || ''));
            }
            remaining = "";
        } else {
            // 否则保留为 pending
            remaining = lastPart;
        }

        // 是否强制 flush 剩余内容?
        const shouldFlushRemaining =
            charCount >= 60 ||
            timeSinceLastUpdate > 1500;

        if (shouldFlushRemaining && remaining.trim()) {
            completeSentences.push(remaining);
            remaining = "";
        }

        // 处理所有完整句子
        completeSentences.forEach(sentence => {
            if (sentence.trim()) {
                enqueueTts(sentence);
            }
        });

        // 更新 pendingText
        pendingText.current = remaining;
    };

    const chatWithAgent = (
        message: string, 
        postProcess?: (conversation_id: string, message_id: string, think: string, content: string) => void
    ) => {
        addChatRecord({ role: CHAT_ROLE.HUMAN, think: "", content: message });
        addChatRecord({ role: CHAT_ROLE.AI, think: "", content: "" });

        controller.current = new AbortController();
        setChatting(true);
        fullRawText.current = "";
        pendingText.current = "";
        displayedContent.current = "";
        lastTextUpdateTime.current = 0;
        agentThinkRef.current = "";
        ttsQueue.current = [];
        isProcessingTts.current = false;

        const agentCallback = (response: EventResponse) => {
            const { event, data } = response;
            switch (event) {
                case STREAMING_EVENT_TYPE.CONVERSATION_ID:
                    conversationId.current = data;
                    break;
                case STREAMING_EVENT_TYPE.MESSAGE_ID:
                    messageId.current = data;
                    break;
                case STREAMING_EVENT_TYPE.THINK:
                    agentThinkRef.current += data;
                    updateLastRecord({
                        role: CHAT_ROLE.AI,
                        think: agentThinkRef.current,
                        content: displayedContent.current
                    });
                    break;
                case STREAMING_EVENT_TYPE.TEXT:
                    fullRawText.current += data;
                    pendingText.current += data;
                    lastTextUpdateTime.current = Date.now();

                    if (sound) {
                        tryProcessPendingText();
                    } else {
                        displayedContent.current += data;
                        updateLastRecord({
                            role: CHAT_ROLE.AI,
                            think: agentThinkRef.current,
                            content: displayedContent.current
                        });
                    }
                    break;
                case STREAMING_EVENT_TYPE.ERROR:
                    addToast({ title: data, color: "danger" });
                    break;
                case STREAMING_EVENT_TYPE.TASK:
                case STREAMING_EVENT_TYPE.DONE:
                    if (postProcess) {
                        postProcess(conversationId.current, messageId.current, agentThinkRef.current, fullRawText.current);
                    }

                    // 流结束,处理剩余文本
                    if (sound && pendingText.current.trim()) {
                        enqueueTts(pendingText.current);
                        pendingText.current = "";
                    }

                    setChatting(false);
                    break;
                default:
                    break;
            }
        };

        const agentErrorCallback = (error: Error) => {
            setChatting(false);
        };

        //调用 api_agent_stream 时传入 mid
        api_agent_stream(
            agentEngine,
            agentSettings,
            message,
            conversationId.current,
            controller.current.signal,
            agentCallback,
            agentErrorCallback,
            mid.current
        );
    };

    const chat = (
        message: string,
        postProcess?: (conversation_id: string, message_id: string, think: string, content: string) => void
    ) => {
        abort();
        chatWithAgent(message, postProcess);
    };

    useEffect(() => {
        conversationId.current = "";
        return () => {
            abort();
        };
    }, [agentEngine, agentSettings]);

    return { chat, abort, chatting, conversationId, mid: mid.current };
}

到这里感觉完事大吉了,但是查看长期记忆列表居然没有数据,这是什么情况?经过多次实现发现自动写入记忆片段只有推理模式才能成功,但是开启推理模式等待时间过长,不适合数字人这种需要及时交互的场景,那么有没有解决的方法呢?

image.png 我们可以使用阿里提供的另一个接口,主动创建记忆片段。 修改qwenAgent.py文件,具体逻辑就是在千问返回信息后,异步通过接口判断当前返回的内容是否需要抽取保存到记忆中,如果需要的话通过相关sdk进行保存


from ..builder import AGENTS
from ..agentBase import BaseAgent
import os
import json
import asyncio
import re
from urllib.parse import urlencode
from digitalHuman.protocol import *
from digitalHuman.utils import httpxAsyncClient, logger

# DashScope SDK 导入
try:
    from alibabacloud_bailian20231229.client import Client as bailian20231229Client
    from alibabacloud_credentials.client import Client as CredentialClient
    from alibabacloud_tea_openapi import models as open_api_models
    from alibabacloud_bailian20231229 import models as bailian_20231229_models
    from alibabacloud_tea_util import models as util_models
    from alibabacloud_credentials.models import Config as CredentialConfig
    SDK_AVAILABLE = True
except ImportError:
    logger.warning("DashScope SDK not available, memory writing disabled")
    SDK_AVAILABLE = False

__all__ = ["QwenAgent"]


@AGENTS.register("Qwen")
class QwenAgent(BaseAgent):
    
    def __init__(self, config=None, engine_type=None):
        """初始化 QwenAgent,兼容父类构造函数"""
        super().__init__(config, engine_type)
        self._sdk_client = None
        if SDK_AVAILABLE:
            self._init_sdk_client()
    
    def _init_sdk_client(self):
        """初始化 DashScope SDK 客户端 - 从环境变量读取 AK/SK"""
        try:
            access_key_id = os.getenv("DASHSCOPE_ACCESS_KEY_ID")
            access_key_secret = os.getenv("DASHSCOPE_ACCESS_KEY_SECRET")
            
            if not access_key_id or not access_key_secret:
                logger.error("DASHSCOPE_ACCESS_KEY_ID or DASHSCOPE_ACCESS_KEY_SECRET not set in environment variables")
                return
            
            credential_config = CredentialConfig(
                type='access_key',
                access_key_id=access_key_id,
                access_key_secret=access_key_secret
            )
            credential = CredentialClient(credential_config)
            
            config = open_api_models.Config(credential=credential)
            config.endpoint = 'bailian.cn-beijing.aliyuncs.com'
            self._sdk_client = bailian20231229Client(config)
            logger.info("DashScope SDK client initialized successfully")
        except Exception as e:
            logger.error(f"Failed to initialize SDK client: {e}")
            self._sdk_client = None
    
    #判断是否需要抽取记忆
    def _should_extract_memory(self, text: str) -> bool:
        """轻量判断:是否可能包含可记忆信息"""
        if not text.strip():
            return False
        triggers = [
            "叫", "名字", "姓名",  # 基本信息
            "喜欢", "爱吃", "爱喝", "讨厌", "不吃", "过敏",  # 饮食
            "兴趣", "爱好", "擅长", "迷上", "喜欢玩",  # 兴趣
            "性格", "活泼", "内向", "安静", "调皮",  # 性格
            "年级", "学校", "班级",  # 基本信息
            "进步", "退步", "成绩", "考得", "学习", "作业"  # 学习(趋势)
        ]
        return any(trigger in text for trigger in triggers)
    
    # qwen-max 抽取结构化记忆
    async def _extract_memory_with_qwen_max(self, text: str, api_key: str, child_name: str = "") -> dict:
        prompt = f"""
# 角色
你是一个严格的信息抽取系统,不是对话助手。你的唯一任务是从家长原话中提取结构化信息。

# 规则
1. 禁止任何解释、问候、反问、建议
2. 禁止输出 JSON 以外的任何字符(包括换行、空格、Markdown)
3. 如果没有可提取信息,必须输出:{{}}
4. 字段值必须是简洁的中文短语,不要完整句子

# 字段定义
- basic_info: "姓名,性别,年级"(如"李明,男,三年级")
- diet_preference: "饮食偏好"(如"喜欢吃西瓜")
- interests: "兴趣爱好"(如"喜欢画画")
- personality: "性格特点"(如"性格活泼")
- academic_trend: "学习趋势"(如"数学有进步")

# 家长原话
{text}

# 你的输出(纯 JSON,无任何其他内容):
"""
        
        try:
            resp = await httpxAsyncClient.post(
                "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation",
                headers={"Authorization": f"Bearer {api_key}"},
                json={
                    "model": "qwen-max",
                    "input": {"prompt": prompt.strip()},
                    "parameters": {
                        "max_tokens": 200,
                        "temperature": 0.01,
                        "stop": ["\n", "。", "!", "?", ":"]
                    }
                },
                timeout=10.0
            )
            if resp.status_code == 200:
                data = resp.json()
                raw_text = data.get("output", {}).get("text", "").strip()
                logger.debug(f"[MemoryExtract] Raw output: '{raw_text}'")
                
                # 尝试解析 JSON
                try:
                    return json.loads(raw_text)
                except:
                    # 尝试提取 {...}
                    match = re.search(r'\{[^{}]*\}', raw_text)
                    if match:
                        try:
                            return json.loads(match.group())
                        except:
                            pass
                if raw_text == "{}":
                    return {}
                    
                logger.warning(f"[MemoryExtract] Failed to parse: {raw_text[:100]}")
                
        except Exception as e:
            logger.error(f"[MemoryExtract] Error: {e}", exc_info=True)
        
        return {}

    #使用 SDK 写入 DashScope Memory
    async def _write_memory_node_with_sdk(self, app_id: str, memory_id: str, content: str):
        if not SDK_AVAILABLE or self._sdk_client is None:
            logger.warning("[MemoryWrite] SDK not available, skipping write")
            return
            
        if not app_id or not memory_id or not content.strip():
            logger.warning("[MemoryWrite] Skip empty app_id, memory_id or content")
            return
        
        try:
            logger.info(f"[MemoryWrite] Attempting to write via SDK - app_id: '{app_id}', memory_id: '{memory_id}', content: '{content}'")
            
            create_memory_node_request = bailian_20231229_models.CreateMemoryNodeRequest(
                content=content
            )
            runtime = util_models.RuntimeOptions()
            headers = {}
            space_id = os.getenv("DASHSCOPE_SPACE_ID")
            response = await self._sdk_client.create_memory_node_with_options_async(
                space_id, 
                memory_id, 
                create_memory_node_request, 
                headers, 
                runtime
            )
            
            logger.info(f"[MemoryWrite] SDK SUCCESS - MemoryNode ID: {response.body.memory_node_id}")
            
        except Exception as e:
            logger.error(f"[MemoryWrite] SDK EXCEPTION: {e}")

    #后台记忆更新任务
    async def _background_memory_update(self, user_input: str, app_id: str, memory_id: str, api_key: str):
        await asyncio.sleep(0.5)
        
        child_name = ""
        extracted = await self._extract_memory_with_qwen_max(user_input, api_key, child_name)
        logger.info(f"[Memory] Extracted result: {extracted}")
        
        for key, value in extracted.items():
            if value:
                pure_content = value
                logger.info(f"[Memory] Writing pure content: {pure_content}")
                await self._write_memory_node_with_sdk(app_id, memory_id, pure_content)

    async def run(
        self,
        input: TextMessage,
        streaming: bool,
        **kwargs
    ):
        try:
            if not streaming:
                raise KeyError("Tongyi Agent only supports streaming mode")

            # 获取 API 参数(从环境变量或参数)
            api_key = kwargs.get("api_key") or os.getenv("DASHSCOPE_API_KEY")
            app_id = kwargs.get("app_id") or os.getenv("DASHSCOPE_APP_ID")
            session_id = (kwargs.get("conversation_id") or kwargs.get("session_id") or "").strip()
            memory_id = kwargs.get("mid") or ""
            messages = kwargs.get("messages", [])

            if not api_key:
                raise ValueError(
                    "Missing 'api_key': please provide it in parameters "
                    "or set DASHSCOPE_API_KEY environment variable."
                )
            if not app_id:
                raise ValueError(
                    "Missing 'app_id': please provide it in parameters "
                    "or set DASHSCOPE_APP_ID environment variable."
                )

            prompt = input.data.strip()

            #模式判断:messages 优先级高于 session_id
            use_custom_messages = bool(messages)

            headers = {
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "X-DashScope-SSE": "enable"
            }

            payload = {
                "input": {},
                "parameters": {
                    "incremental_output": True
                },
                "stream": True
            }

            url = f"https://dashscope.aliyuncs.com/api/v1/apps/{app_id}/completion"
            if memory_id:
                payload["input"]["memory_id"] = memory_id 
            if use_custom_messages:
                logger.info("[QwenAgent] Using custom 'messages' mode. session_id will be ignored.")
                for msg in messages:
                    if not isinstance(msg, dict) or "role" not in msg or "content" not in msg:
                        raise ValueError("Each message must be a dict with 'role' and 'content'")
                final_messages = messages.copy()
                if prompt:
                    final_messages.append({"role": "user", "content": prompt})
                payload["input"]["messages"] = final_messages
            else:
                if not prompt:
                    raise ValueError("Prompt is required when not using 'messages'.")
                logger.info(f"[QwenAgent] Using standard mode with session_id: '{session_id or '(new)'}'")
                payload["input"]["prompt"] = prompt
                if session_id:
                    url += "?" + urlencode({"session_id": session_id})
                    payload["input"]["session_id"] = session_id

            logger.info(f"[QwenAgent] Request URL: {url}")
            logger.info(f"[QwenAgent] Payload: {payload}")

            #异步触发记忆抽取
            if memory_id and self._should_extract_memory(prompt):
                logger.info("[Memory] Triggering async memory extraction...")
                asyncio.create_task(
                    self._background_memory_update(prompt, app_id, memory_id, api_key)
                )

            #发起主请求(保持原有逻辑不变)
            async with httpxAsyncClient.stream("POST", url, headers=headers, json=payload) as response:
                logger.info(f"[QwenAgent] Response status: {response.status_code}")
                if response.status_code != 200:
                    error_text = await response.aread()
                    try:
                        err_json = json.loads(error_text)
                        msg = err_json.get("message", error_text.decode())
                    except Exception:
                        msg = error_text.decode()
                    error_msg = f"HTTP {response.status_code}: {msg}"
                    logger.error(f"[QwenAgent] Request failed: {error_msg}")
                    yield eventStreamError(error_msg)
                    return

                got_conversation_id = False
                async for chunk in response.aiter_lines():
                    line = chunk.strip()
                    if not line or line == ":":
                        continue

                    if line.startswith("data:"):
                        data_str = line[5:].strip()
                        if data_str == "[DONE]":
                            break

                        try:
                            data = json.loads(data_str)
                        except json.JSONDecodeError as e:
                            logger.warning(f"[QwenAgent] JSON decode error: {e}, raw: {data_str}")
                            continue

                        logger.debug(f"[QwenAgent] SSE data received: {data}")

                        if not got_conversation_id and not use_custom_messages:
                            cid = data.get("output", {}).get("session_id")
                            if cid:
                                if not session_id or cid != session_id:
                                    logger.info(f"[QwenAgent] New conversation_id received: {cid}")
                                    yield eventStreamConversationId(cid)
                                got_conversation_id = True

                        text = data.get("output", {}).get("text", "")
                        if text:
                            yield eventStreamText(text)

            yield eventStreamDone()

        except Exception as e:
            logger.error(f"[QwenAgent] Exception: {e}", exc_info=True)
            yield eventStreamError(str(e))

这样一个支持语音输出输入,长短期记忆的live2d数字人就完成了

image.png

image.png

Windows搭建MongoDB(5):Mongosh操作数据库常用命令总结

前言

大家好,我是WangHappy,一名主业前端,副业全栈的程序员,在这里我会分享关于前端进阶全栈的常用技术 和 基本入门操作。 如果我的文章能让您有所收获,欢迎一键三连(评论,点赞,关注)。


在前端转型全栈的过程中,我们多数情况下使用 node.js 来开发服务端,而 node.js 里操作MongoDB数据库最常用的库是 mongoose,所以 mongosh 在实际开发中的使用频率并不是很高,多用于测试或查看数据库状态等场景。

本章只讲述 Mongosh 一些常用命令,例如:数据库基本的增、删、改、查(CRUD)和状态信息获取等操作,能够满足我们日常开发使用即可。

1.连接数据库

1.1 本地连接数据库

mongosh --dbpath "path/data"

1.2 远程连接数据库

mongosh "mongodb://<hostname>:<port>"

举例:mongosh "mongodb://192.168.0.0:27017"

1.3 远程连接数据库并使用用户名密码验证

mongosh "mongodb://<username>:<password>@<hostname>:<port>"

举例:mongosh "mongodb://admin:admin123@192.168.0.0:27017"

2.查看数据库状态

2.1 查看当前使用的数据库

db
  • 切换数据库
use <database_name>

举例:use admin

如果切换的数据库名称当前不存在,则会在 insert 插入数据时自动创建

注意:是插入数据时创建,而不是使用 use 切换时创建。

2.2 列出MongoDB实例下所有的数据库

show databases 可以查看目前所有的数据库,也可以使用简写 show dbs

show databases

2.3 列出当前数据库下所有集合

理论上每个数据库下都可以包含一个或多个集合,它有点类似于我们传统关系型数据库中的 的概念。

show collections

2.4 退出 mongosh

使用 exit 可以直接退出mongosh数据库连接,也可以使用 ctrl + c 快捷键来退出

exit

3.数据库的增删改查

3.1 插入数据

使用 insertOne 插入一条数据,使用 insertMany 插入多条数据,代码如下:

// 插入一条数据
db.user.insertOne({ name: "Mike", age: 30 })

// 插入多条数据
db.user.insertMany([{ name: "Marry", age: 25 }, { name: "Kerry", age: 35 }])

3.2 删除数据

使用 deleteOne 删除单个文档,使用 deleteMany 删除多个文档

// 删除单个文档
db.user.deleteOne({ name: "Alice" })

// 删除年龄30岁以下的数据
db.user.deleteMany({ age: { $lt: 30 } })

3.3 更新数据

使用 updateOne 更新单个文档,使用 updateMany 更新多个文档

// 更新单个文档
db.user.updateOne({ name: "Alice" }, { $set: { age: 31 } })

// 更新多个文档
db.user.updateMany({ age: { $lt: 30 } }, { $set: { status: "young" } })

3.4 查询数据

使用 find 查询集合中所有数据

db.user.find()

查询某个条件的文档

// 查询30岁以下的数据
db.user.find({ age: { $gte: 30 } })

3.5 创建索引

为某个字段创建索引

db.user.createIndex({ name: 1 })

3.6 查看当前集合下的所有索引

db.users.getIndexes()

执行上述代码通常会返回两个索引,_idname

_id 索引:是自动创建,用于确保每条数据的唯一性。

name 索引:自定义索引,方便对指定字段的查询操作,例如 name: 1 表示按升序排列,如果改为 name: -1 表示降序。

4.其它命令

命令 作用
version() 查看当前使用 mongosh 的版本
help 查看 mongosh 的帮助信息, 查看某个命令的帮助信息,<name>.help(), 例如:db.help()

写在最后


本章整理了 mongosh 中一些常用命令,方便大家在日常使用中参考。虽然 mongosh 功能强大,但像 聚合查询等更贴近业务场景的功能,我通常会通过 mongoose 来实现。接下来,我将继续编写关于 node.js + mongoose 的使用教程,并与大家分享更多实践经验。

从 useState 到 URLState:前端状态管理的另一种思路

在 React 开发中,useState 是我们最常用的状态管理工具之一。它轻量、直观,能满足大多数组件级状态管理需求。但在某些场景下,比如列表筛选、分页、多页面共享状态时,单纯使用 useState 会遇到状态丢失、刷新页面重置等问题。这时候,URLState 或许是一个更优雅的解决方案。

一、useState 的「痛点」场景

先来看一个常见的业务场景:实现一个带筛选功能的商品列表页,包含「价格区间」「分类」「排序方式」三个筛选条件。用 useState 实现的代码可能是这样的:


import { useState } from 'react';

function ProductList() {
  // 筛选状态
  const [priceRange, setPriceRange] = useState([0, 1000]);
  const [category, setCategory] = useState('all');
  const [sortBy, setSortBy] = useState('price-asc');

  // 筛选逻辑...
  return (
    <div>
      <FilterPanel 
        priceRange={priceRange}
        onPriceRangeChange={setPriceRange}
        category={category}
        onCategoryChange={setCategory}
        sortBy={sortBy}
        onSortByChange={setSortBy}
      />
      <ProductGrid />
    </div>
  );
}

这段代码看似没问题,但存在三个明显痛点:

  • 页面刷新状态丢失:用户筛选后刷新页面,所有筛选条件会重置为初始值,体验极差。

  • 状态无法共享:如果需要在其他页面(比如商品详情页)回退到筛选后的列表,无法携带筛选状态。

  • 无法书签/分享:用户想把筛选后的结果分享给同事,复制链接过去是未筛选的初始状态。

二、什么是 URLState?为什么要用它?

URLState 是将应用状态存储在 URL 查询参数(Query String)中的状态管理方式。比如上面的筛选场景,使用 URLState 后,URL 可能变成这样:


https://example.com/products?price=0-1000&category=electronics&sort=price-asc

这种方式的核心优势在于:

  • 「刷新不丢状态」:URL 是浏览器的持久化载体,刷新页面参数不会消失。

  • 「天然可共享」:复制 URL 即可分享当前状态,支持书签保存。

  • 「跨页传参简单」:不同页面间通过 URL 即可传递状态,无需依赖全局状态库。

  • 「可回溯」:浏览器的前进/后退按钮能直接回溯状态变更历史。

三、实现 URLState:自定义 useURLState Hook

其实 URLState 的实现并不复杂,核心是通过 URLSearchParams 操作查询参数,并结合 React 的状态更新机制。下面我们封装一个通用的 useURLState Hook。

3.1 核心逻辑拆解

  1. 从 URL 中解析初始状态:通过 new URLSearchParams(window.location.search) 获取查询参数。

  2. 定义状态更新函数:修改状态时,同步更新 URL(使用 history.pushState 避免页面刷新)。

  3. 监听 URL 变化:当用户通过前进/后退按钮切换历史记录时,同步更新组件状态。

3.2 完整实现代码


import { useState, useEffect, useCallback } from 'react';

function useURLState(initialState = {}) {
  // 从 URL 解析状态
  const parseURLState = useCallback(() => {
    const searchParams = new URLSearchParams(window.location.search);
    const state = {};
    
    // 遍历初始状态,从 URL 中提取对应参数
    Object.entries(initialState).forEach(([key, defaultValue]) => {
      const value = searchParams.get(key);
      if (value === null) {
        state[key] = defaultValue;
        return;
      }
      
      // 处理不同类型的默认值(数字、布尔、数组等)
      if (typeof defaultValue === 'number') {
        state[key] = Number(value);
      } else if (typeof defaultValue === 'boolean') {
        state[key] = value === 'true';
      } else if (Array.isArray(defaultValue)) {
        state[key] = value.split('-');
      } else {
        state[key] = value;
      }
    });
    
    return state;
  }, [initialState]);

  // 初始化状态:从 URL 解析或使用初始值
  const [state, setState] = useState(parseURLState());

  // 当 URL 变化时(前进/后退),同步更新状态
  useEffect(() => {
    const handlePopState = () => {
      setState(parseURLState());
    };
    window.addEventListener('popstate', handlePopState);
    return () => window.removeEventListener('popstate', handlePopState);
  }, [parseURLState]);

  // 更新状态并同步到 URL
  const setURLState = useCallback((newState) => {
    const searchParams = new URLSearchParams(window.location.search);
    const nextState = { ...state, ...newState };
    
    // 遍历新状态,更新到 searchParams
    Object.entries(nextState).forEach(([key, value]) => {
      if (value === initialState[key]) {
        // 如果值等于初始值,移除该参数(保持 URL 简洁)
        searchParams.delete(key);
      } else {
        // 数组类型用 "-" 拼接
        const paramValue = Array.isArray(value) ? value.join('-') : String(value);
        searchParams.set(key, paramValue);
      }
    });
    
    // 更新 URL(pushState 不会刷新页面)
    const searchString = searchParams.toString();
    const newUrl = searchString ? `${window.location.pathname}?${searchString}` : window.location.pathname;
    window.history.pushState({}, '', newUrl);
    
    // 更新组件状态
    setState(nextState);
  }, [state, initialState]);

  return [state, setURLState];
}

四、实战:用 URLState 重构商品列表页

有了 useURLState,我们可以轻松重构之前的商品列表页,解决 useState 带来的痛点:


import { useURLState } from './useURLState';

function ProductList() {
  // 用 useURLState 替代 useState,初始状态和之前一致
  const [state, setURLState] = useURLState({
    priceRange: [0, 1000], // 数组类型
    category: 'all',       // 字符串类型
    sortBy: 'price-asc'    // 字符串类型
  });

  const { priceRange, category, sortBy } = state;

  // 筛选条件变更时,调用 setURLState 更新
  const handlePriceChange = (newRange) => {
    setURLState({ priceRange: newRange });
  };

  const handleCategoryChange = (newCategory) => {
    setURLState({ category: newCategory });
  };

  const handleSortChange = (newSort) => {
    setURLState({ sortBy: newSort });
  };

  return (
    <div>
      <FilterPanel 
        priceRange={priceRange}
        onPriceRangeChange={handlePriceChange}
        category={category}
        onCategoryChange={handleCategoryChange}
        sortBy={sortBy}
        onSortByChange={handleSortChange}
      />
      <ProductGrid />
    </div>
  );
}

此时,用户筛选商品后,URL 会自动更新为:


https://example.com/products?priceRange=0-2000&category=electronics&sortBy=price-desc

刷新页面、复制链接分享、后退到上一个筛选状态,都能完美生效!

五、URLState 的适用场景与注意事项

5.1 适用场景

  • 列表筛选、分页、排序等「可分享」的状态。

  • 多步骤表单(如注册流程)的进度状态。

  • 单页应用中的「页面级」状态(如标签页切换)。

5.2 注意事项

  • 不要存储敏感信息:URL 参数会暴露在地址栏、浏览器历史、服务器日志中,密码、token 等敏感信息绝对不能用 URLState。

  • 参数不宜过多/过长:浏览器对 URL 长度有上限(通常 2KB-8KB),复杂状态建议用全局状态库(如 Redux)。

  • 处理特殊类型数据:对于对象等复杂类型,需要先序列化(如 JSON.stringify),但会增加 URL 长度,需谨慎使用。

六、总结

useState 是 React 状态管理的基石,但在「状态持久化」「可分享」场景下存在局限。URLState 作为一种轻量级的补充方案,通过 URL 查询参数实现状态的持久化和共享,无需引入复杂的状态管理库,就能解决很多实际业务问题。

当然,URLState 不是银弹,它和 useState、全局状态库是互补关系。在合适的场景选择合适的工具,才是高效开发的关键。

Bun Test 不支持时间快进?我用这招让单元测试提速 8 倍!

假设我们的组件或函数内使用了 setInterval (setTimeout 同理),如果不对其进行任何操作,“傻傻等待”的话,一个单元测试可能就要耗费几秒钟才能完成,甚至还可能超时。

老牌的 jest 或新的 vitest 以及 node:test 原生都支持“时间快进”。但是 bun:test(v1.2.23 2025-10-10)尚未支持 jest/vi.advanceTimersByTime,将报错:

// 快进1秒,触发1次 setInterval 回调
37 |   vi.advanceTimersByTime(1000)
          ^
TypeError: vi.advanceTimersByTime is not a function. (In 'vi.advanceTimersByTime(1000)', 'vi.advanceTimersByTime' is undefined)

难道只能“坐以待毙”?不,我们还有一种workaround。就是通过重写或者说 mock 全局 setInterval / setTimeout

效果一睹为快:

成功将耗时从 2s+ 优化到 300ms+,是其 1/8

解决办法:重写 setInterval

// src\DeepThinkButton\demo\advanced.test.tsx:
import { afterEach, beforeEach } from 'vitest'

// 1. 保存原始的 setInterval
const originalSetInterval = globalThis.setInterval

beforeEach(() => {
  // 在每个测试开始前启用假定时器
  // @ts-expect-error
  globalThis.setInterval = (callback, delay) => {
    // console.log('callback, delay:', { callback, delay })

    if (callback.name === 'deepThinkButtonInterval') {
      // 返回一个原始 ID,并且内部调用原始函数
      return originalSetInterval(() => {
        callback()
      }, 0)
    } else {
      return originalSetInterval(callback, delay)
    }
  }
})

afterEach(() => {
  // 在每个测试结束后恢复真定时器
  globalThis.setInterval = originalSetInterval
})

使用

假设我们有如下待测试代码,测试深度思考功能:

// 逐个字符显示,当完整内容显示后停止
timer = setInterval(function deepThinkButtonInterval() {
  setThinkContent((text) => {
    if (text.length < THINK_CONTENT.length) {
      // 每次输出 N 个字符
      return THINK_CONTENT.slice(0, text.length + 2)
    }

    clearInterval(timer)
    setThinkingStatus('Completed')
    setThinkingStopTime(Date.now())
    return text
  })
}, 20)

执行

❯ bun test src/DeepThinkButton/

正常情况耗时 2s+:

✓ 自定义 icon、按钮尺寸,以及思考内容折叠后效果 [2563.00ms]

第一步修改待测试代码:

- timer = setInterval(() => {
+ timer = setInterval(function deepThinkButtonInterval() {

也就是匿名函数改成具名函数,方便我们针对性 mock。

当我们给单测添加上述 mock 后,耗时减少到 1s+

✓ 自定义 icon、按钮尺寸,以及思考内容折叠后效果 [1344.00ms]

但其实我们还可以加速,即在一个 interval 回调中多次运行函数:

// src\DeepThinkButton\demo\advanced.test.tsx:
return originalSetInterval(() => {
    // 运行 6 次
    callback()
    callback()
    callback()
    callback()
    callback()
    callback()
  }, 0)

可以看到又从 1s+ 优化到了 300ms+ ⚡️ !

✓ 自定义 icon、按钮尺寸,以及思考内容折叠后效果 [328.00ms]

成功将耗时从 2s+2s+ 优化到 300ms+300ms+,是其 1/81/8

重构

目前只有一个 test case 还好,如果多个 case 需要则要避免违背 DRY 原则,所以接下来我们封装成函数。

// tests/utils.ts
import { afterEach, beforeEach } from 'vitest'

export function advanceInterval(
  callbackName: string,
  { ms = 0, batchCalledCount = 1 }: { ms?: number; batchCalledCount?: number } = {},
) {
  // 1. 保存原始的 setInterval
  const originalSetInterval = globalThis.setInterval

  beforeEach(() => {
    // 在每个测试开始前启用假定时器
    // @ts-expect-error
    globalThis.setInterval = (callback, delay) => {
      // 返回一个模拟的ID,或者调用原始函数
      if (callback.name === callbackName) {
        return originalSetInterval(() => {
          for (let i = 0; i < batchCalledCount; i++) {
            callback()
          }
        }, ms)
      } else {
        return originalSetInterval(callback, delay)
      }
    }
  })

  afterEach(() => {
    // 在每个测试结束后恢复真定时器
    globalThis.setInterval = originalSetInterval
  })
}

使用:

// src/DeepThinkButton/demo/advanced.test.tsx
import React from 'react'
import { test, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'

import Demo from './advanced'
import { advanceInterval } from '@/tests/rtl'

+ advanceInterval('deepThinkButtonInterval', { batchCalledCount: 6 })

test('自定义 icon、按钮尺寸,以及思考内容折叠后效果', async () => {
  ...
})

🚀 10 分钟吃透 CSS position 定位!从底层原理到避坑实战,搞定所有布局难题

在前端开发中,布局是核心技能之一,而position属性更是布局的 “灵魂”—— 它决定了元素在页面中的位置关系,是实现复杂布局、悬浮组件、固定导航等效果的关键。很多开发者入门时会混淆absolutefixed,踩坑sticky不生效的问题,本质上是没吃透其底层原理。本文结合 5 个实战案例,从文档流本质出发,全面解析position的 5 种属性用法,帮你彻底掌握这个 CSS 基础核心知识点。

一、先搞懂:文档流是什么?

要理解position,必须先明确 “文档流” 的概念 —— 这是 HTML 元素默认的布局规则,就像现实中排队一样,元素按照代码顺序依次排列。

块级元素(如divp)默认垂直排列,每个元素独占一行,自上而下依次分布;行内元素(如spana)则水平排列,从左到右紧密排布,直到一行放不下才换行。这种 “自上而下、从左到右” 的自然布局方式,就是文档流。

position属性的核心作用,就是打破或遵循这个默认规则,改变元素的定位方式。其中,是否脱离文档流是区分不同定位属性的关键:脱离文档流的元素会 “跳出” 排队队伍,不再占用原来的位置,其他元素会忽略它重新排列;不脱离的元素则仍在队伍中,只是在原位置上进行微调。

二、逐个击破:5 种 position 属性的底层逻辑

1. static:默认定位(无定位)

staticposition的默认值,所有元素在未设置定位时,都遵循static规则 —— 按照文档流正常排列,不受topleftrightbottom属性影响。

核心特点

  • 完全遵循文档流,不脱离
  • 无法通过top/left等属性调整位置
  • 可用于取消已设置的定位(如示例 5 中,5 秒后将absolute改为static

实战示例(示例 5 初始状态):

css

.parent {
  position: absolute; /* 初始定位 */
  left: 100px;
  top: 100px;
}
/* 5秒后取消定位,恢复static默认状态 */
setTimeout(() => {
  oParent.style.position = 'static';
}, 5000);

position改为static后,lefttop失效,元素回到文档流的原始位置。


2. relative:相对定位(不脱离文档流)

relative是 “相对” 于自身在文档流中的原始位置进行定位,这是它最核心的特征。

核心特点

  • 不脱离文档流,原始位置仍被占用(后面元素不会补位)
  • 通过top/left/right/bottom调整位置,参考点是自身默认位置
  • 常作为absolute的 “定位容器”(子绝父相)

实战示例(示例 1):

css

.parent {
  width: 500px;
  height: 500px;
  background: pink;
  position: relative; /* 相对定位 */
  left: 100px;
  top: 100px;
}
.child {
  width: 300px;
  height: 200px;
  background: skyblue;
}

这里.parent会相对于自己的默认位置(左上角)向右移动 100px、向下移动 100px,而它原来的位置仍被占用,.box元素会按照文档流在它后面正常排列,不会向前补位。


3. absolute:绝对定位(完全脱离文档流)

absolute是最常用的定位属性之一,元素会完全脱离文档流,相当于 “跳出” 排队队伍,不再占用任何空间。

核心特点

  • 完全脱离文档流,原始位置被释放(后面元素会补位)
  • 定位参考点是 “最近的已定位祖先元素”(position不为static
  • 若没有已定位祖先,则参考body(浏览器视口)
  • 必须配合top/left等属性使用,否则定位无效

实战示例(示例 2):

css

.parent {
  width: 550px;
  height: 500px;
  background: pink;
  position: relative; /* 定位容器 */
}
.child {
  width: 300px;
  height: 200px;
  background: skyblue;
  position: absolute; /* 绝对定位 */
  right: 100px; /* 相对于.parent右侧偏移100px */
}
.box {
  width: 100px;
  height: 100px;
  background: green;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%); /* 水平垂直居中 */
}

示例中.parent设置relative作为定位容器,.child.boxabsolute定位都以.parent为参考。其中.box通过left:50%+top:50%+transform:translate(-50%,-50%)实现了相对于父容器的完美居中,这是absolute的经典用法。


4. fixed:固定定位(完全脱离文档流)

fixed的核心是 “固定于浏览器视口”,无论页面如何滚动,元素位置始终不变。

核心特点

  • 完全脱离文档流,不占用原始位置
  • 定位参考点是浏览器视口(可视区域)
  • 不受祖先元素定位影响(即使父元素有relative,仍参考视口)
  • 常用来实现固定导航、悬浮按钮、弹窗等

实战示例(示例 3):

css

.child {
  width: 300px;
  height: 200px;
  background: blue;
  position: fixed; /* 固定定位 */
  right: 100px;
  bottom: 100px; /* 相对于视口右下角偏移100px */
}
body {
  height: 2000px; /* 让页面可滚动 */
}

即使页面滚动,.child始终固定在视口右下角 100px 的位置。需要注意的是,fixed元素会跟随浏览器窗口移动,不会被父容器的overflow属性影响(除非父容器有transform属性,这是常见坑点)。


5. sticky:粘性定位(动态切换定位方式)

sticky是 “相对定位” 和 “固定定位” 的结合体,堪称布局神器,常用于导航栏滚动吸顶效果。

核心特点

  • 未达到滚动阈值时,表现为relative(遵循文档流)
  • 达到滚动阈值时,自动切换为fixed(固定于视口)
  • 不脱离文档流,原始位置仍被占用(切换为fixed后也不会让后面元素补位)
  • 必须配合top/left等属性设置阈值,否则无效

实战示例(示例 4):

css

.box {
  width: 100px;
  height: 100px;
  background: green;
  position: sticky; /* 粘性定位 */
  top: 100px; /* 滚动阈值:距离视口顶部100px时固定 */
}
body {
  height: 2000px; /* 可滚动页面 */
}

当页面滚动时,.box在未到达视口顶部 100px 前,会跟随文档流正常滚动;一旦距离顶部小于 100px,就会固定在顶部 100px 的位置,直到滚动到父元素底部,又会恢复relative状态。

三、关键区别:5 种定位属性核心对比

定位属性 是否脱离文档流 定位参考点 核心用途
static 否(默认) 取消定位、默认布局
relative 自身默认位置 微调位置、作为 absolute 容器
absolute 最近已定位祖先 /body 精准定位、弹窗、居中
fixed 浏览器视口 固定导航、悬浮组件
sticky 否(动态切换) 文档流 / 视口 滚动吸顶、粘性导航

四、实战避坑:这些问题一定要注意

  1. absolute无法定位? 检查父元素是否有position: relative/absolute/fixed/sticky,若没有则参考body,可能因父元素未设置高度导致定位异常。
  2. sticky不生效? 确保满足三个条件:设置了top/left等阈值、父元素没有overflow: hidden、元素在文档流中(没有脱离文档流的祖先)。
  3. fixed被父元素 “困住”? 若父元素有transform属性(如transform: translate(0)),fixed会以该父元素为参考,而非视口,需避免这种嵌套。
  4. 脱离文档流的影响? absolutefixed会脱离文档流,可能导致页面布局塌陷,需提前预留空间或用其他元素补位。

五、总结:定位属性的选择逻辑

  1. 只需微调元素位置,不影响其他布局 → relative
  2. 需精准定位,且不占原始空间 → absolute(配合relative容器)
  3. 需固定在视口,不受滚动影响 → fixed
  4. 需滚动吸顶 / 吸底效果 → sticky
  5. 恢复默认布局或取消定位 → static

position属性看似简单,实则是 CSS 布局的底层逻辑体现。掌握它们的核心区别和使用场景,能让你在实现复杂布局时游刃有余。建议结合本文的示例代码,亲自在浏览器中调试,观察元素位置变化,加深对文档流和定位规则的理解。

最后

如果你在使用position时遇到过特殊坑点,或者有更巧妙的用法,欢迎在评论区分享~ 也可以点赞收藏,下次遇到定位问题直接翻这篇指南!

❌