普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月31日掘金 前端

Vue-从 Vue 2 到 Vue 3:生命周期全图鉴与实战指南

2026年1月31日 12:19

前言

生命周期钩子(Lifecycle Hooks)是 Vue 组件从诞生到销毁的全过程记录。掌握生命周期,不仅能让我们在正确的时间点执行逻辑,更是优化性能、排查内存泄露的关键。

一、 生命周期四大阶段

Vue 的生命周期大体可分为:创建、挂载、更新、销毁


二、 Vue 2 vs Vue 3 生命周期对比图

在 Vue 3 组合式 API 中,生命周期钩子需要从 vue 中导入,且命名上增加了 on 前缀。

阶段 Vue 2 (选项式 API) Vue 3 (组合式 API) 备注
创建 beforeCreate / created setup() Vue 3 中 setup 包含了这两个时期
挂载 beforeMount / mounted onBeforeMount / onMounted 常用:操作 DOM、请求接口
更新 beforeUpdate / updated onBeforeUpdate / onUpdated 响应式数据变化时触发
销毁 beforeDestroy / destroyed onBeforeUnmount / onUnmounted 注意:Vue 3 中命名的变更
缓存 activated / deactivated onActivated / onDeactivated 配合 <keep-alive> 使用

三、 详细解析与实战场景

1. 创建阶段 (Creation)

  • Vue 2 (beforeCreate / created)

    • beforeCreate:组件实例刚在内存中被创建,此时还没有初始化好 datamethods 属性。适合插件开发,注入全局变量。
    • created:实例已创建,响应式数据data、methods 已准备好。
      • 场景:最早可发起异步请求的时机。
  • Vue 3 (setup)

    • 在 Vue 3 中,setup 的执行早于 beforeCreate,它是组合式 API 的入口。

2. 挂载阶段 (Mounting)

  • Vue 2 (beforeMount / mounted)
    • beforeMount:此时已经完成了模板的编译,但是还没有挂载到页面中

    • mounted:此时已经将编译好的模板挂载到了页面指定的容器中,可以访问页面中的dom了

      • 场景:dom已创建,可用于获取接口数据和dom元素、访问子组件

  • Vue 3 (onBeforeMount / onMounted)
    • onBeforeMount:模板编译完成,但尚未渲染到 DOM 树中。

    • onMounted:组件已挂载,可以安全地访问 DOM 元素。

      • 场景:获取接口数据、初始化第三方插件(如 ECharts)、访问子组件。

3. 更新阶段 (Updating)

  • Vue 2 (beforeUpdate / updated)

    • beforeUpdate:数据状态更新之前执行,此时 data 中的状态值是最新的,但是界面上显示的数据还是旧的,因为此时还没有开始重新渲染DOM节点。

      • 场景 :此时view层还未更新,可用于获取更新前各种状态。
    • updated:实例更新完毕之后调用,此时 data 中的状态值和界面上显示的数据,都已经完成了更新,界面已经被重新渲染好了。

  • Vue 3 (onBeforeUpdate / onUpdated)

    • onBeforeUpdate:数据已更新,但 DOM 尚未重新渲染。可用于获取更新前的 DOM 状态。
    • onUpdated:DOM 已完成更新。注意:不要在此钩子中修改状态,否则可能导致死循环。

4. 销毁阶段 (Unmounting / Destruction)

  • Vue 2 (beforeDestroy / destroyed)

    • beforeDestroy:实例销毁之前调用。
      • 场景:清理工作,如 清除定时器 (setInterval)、解绑全局事件监听、取消订阅
    • destroyed:Vue 实例销毁后调用。组件彻底从 DOM 中移除,所有的指令和事件监听都会被解除。
  • Vue 3 (onBeforeUnmount / onUnmounted)

    • onBeforeUnmount:实例销毁之前调用。

    • onUnmounted:组件彻底从 DOM 中移除,所有的指令和事件监听都会被解除。

5. 缓存阶段 (Keep-alive)

如果使用了keep-alive缓存组件会新增两个生命周期函数

  • onActivated:组件进入视野,被重新激活时调用。
  • onDeactivated:组件移出视野,进入缓存状态时调用。

四、 Vue 3 + TypeScript 实战演示

以下是使用 script setup 语法编写的生命周期示例:

<template>
  <div ref="container">
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">增加</button>
  </div>
</template>

<script setup lang="ts">
import { 
  ref, 
  onMounted, 
  onBeforeUpdate, 
  onUpdated, 
  onBeforeUnmount 
} from 'vue'

const count = ref<number>(0)
const container = ref<HTMLElement | null>(null)
let timer: number | null = null

// 挂载阶段
onMounted(() => {
  console.log('Component Mounted. DOM element:', container.value)
  // 模拟一个定时任务
  timer = window.setInterval(() => {
    console.log('Timer running...')
  }, 1000)
})

// 运行阶段
onBeforeUpdate(() => {
  console.log('Data updated, but DOM is not yet re-rendered.')
})

onUpdated(() => {
  console.log('Data updated and DOM re-rendered.')
})

// 销毁阶段
onBeforeUnmount(() => {
  console.log('Cleanup before unmount.')
  if (timer) {
    clearInterval(timer) // 关键:防止内存泄漏
  }
})
</script>

五、 进阶:父子组件生命周期执行顺序

为了清晰起见,我们将顺序拆解为三个主要场景(vue3):

1. 初始挂载阶段

父组件必须等待所有子组件挂载完成后,才能完成自己的挂载逻辑。

  1. setup(开始创建)
  2. onBeforeMount
  3. setup
  4. onBeforeMount
  5. onMounted (子组件渲染完毕,向上通知)
  6. onMounted (父组件接收到信号,宣布整体挂载完毕)

记忆口诀: 父创 -> 子创 -> 子挂 -> 父挂。


2. 更新阶段

当父组件传递给子组件的 props 发生变化时,更新逻辑如下:

  • onBeforeUpdate
  • onBeforeUpdate
  • onUpdated
  • onUpdated

注意: 如果只是父组件自身的私有状态更新,且未影响到子组件,则子组件的更新钩子不会被触发。


3. 销毁阶段

销毁过程同样是“递归”式的,父组件先启动销毁,等子组件销毁完毕后,父组件正式功成身退。

  1. onBeforeUnmount
  2. onBeforeUnmount
  3. unmounted
  4. onUnmounted

六、 Vue 3 + TS 模拟演示

你可以通过以下代码在控制台直接观察执行逻辑。

父组件 Parent.vue

<script setup lang="ts">
import { onMounted, onBeforeMount } from 'vue'
import Child from './Child.vue'

console.log('1. 父 - setup')

onBeforeMount(() => console.log('3. 父 - onBeforeMount'))
onMounted(() => console.log('8. 父 - onMounted'))
</script>

<template>
  <div class="parent">
    <h1>父组件</h1>
    <Child />
  </div>
</template>

子组件 Child.vue

<script setup lang="ts">
import { onMounted, onBeforeMount } from 'vue'

console.log('4. 子 - setup')

onBeforeMount(() => console.log('6. 子 - onBeforeMount'))
onMounted(() => console.log('7. 子 - onMounted'))
</script>

<template>
  <div class="child">子组件内容</div>
</template>

📝 总结与避坑

  1. 接口请求放哪里?

    • 如果子组件的渲染依赖父组件接口返回的数据,请在父组件的 created(Vue 2)或 setup(Vue 3)中请求。
    • 注意:即便你在父组件的 onMounted 发请求,子组件此时也已经渲染完成了。
  2. Refs 访问时机

    • 父组件想通过 ref 访问子组件实例,必须在父组件的 onMounted 之后,因为只有这时子组件才真正挂载完成。
  3. 异步组件

    • 如果子组件是异步组件(如使用 defineAsyncComponent),顺序会发生变化,父组件可能会先执行 onMounted

比黄金暴跌更难过的是 Vue 3.6 构建工具全线换成 Rolldown 了😰

作者 VaJoy
2026年1月31日 11:41

最近在尝试解析 Vue 3.6 的源码,前些天从底层构建系统着手,辛辛苦苦分析了 Vue 是如何通过 Rollup 和 esbuild 来分别构建出生产环境、开发环境产物的,并精心沉淀出了第一篇技术文章。

今天一看天塌了 ———— Vue 3.6 beta5 开始把全线构建工具统一为了 Rolldown,所有构建相关的脚本均被改写,意味着我之前案牍劳形进行的分析、写的文章好像打了水漂。

这简直比黄金暴跌更让我心疼!!(因为我其实也没买)。

官方改动具体见 Commit - build: use rolldown

Rolldown 目前处于 RC 预发布候选版本,所以没预料到 Vue 这么快就将其应用在源码构建上,盲猜其原因为:

  • Rolldown 在 Vite 生态内部已经经历了较长时间的真实项目验证,官方团队对其成熟度相对有信心,得以进一步推进到 Vue 源码仓库中集成使用。
  • Rolldown 是使用 Rust 编写的现代化打包器,据称在性能上会比 Rollup 快 10~30 倍。
  • Rollup 的模块图主要面向「一次性构建」设计,缺乏面向长期运行进程的增量构建能力(这是之前为何要使用 esbuild 作为开发环境构建工具的原因);而 Rolldown 把模块图视为长期驻留的一等公民,从架构层面支持精确失效与增量重建,得以适配开发态的高频变更场景。
  • 开发环境和构建环境统一只使用一套「构建底座」,可以大幅提升构建配置和脚本的一致性,例如不同的需求(例如占位符替换)不再需要配套不同的构建插件,进而大幅降低维护成本。

对此个人很欣赏 Vue 与时俱进的态度,也期待 3.6 正式版本能早日顺利发布。

最后贴下 Rollup 搭配 esbuild 的「老款」构建系统解析文章,还是可以帮助大家了解 Vue 项目底层构建的流程和原理。


《Vue 3.6 beta 4 源码解析 —— 项目结构和构建雏形 Pt.1》

本文基于 Vue 3.6 beta 4 版本。

本文案例源码可在 Github 仓库(Tag 1.1.1) 上获取。

Vue 3.6 的源码并不是一个单体项目,而是一个高度模块化、工程化的系统。在正式深入分析各个核心模块之前,如果不先理解其整体的项目结构与工程形态,很容易在阅读源码时陷入局部实现细节,而缺乏对整体设计与模块协作关系的把握。

作为本专栏的开篇,本文将从工程视角出发,介绍并讨论 Vue 3.6 的项目结构雏形与构建技术选型,并实现一个最基础的工程化方案。

1、项目初始化

我们创建一个名为 vue 的文件夹作为源码项目,执行 pnpm init 进行初始化、生成 package.json 文件。

Vue 源码项目是严格要求使用 pnpm 来作为包管理器的,我们可以在 package.jsonscript 字段添加 preinstall 钩子指令,在使用者通过包管理器安装项目依赖时,能自动检查并确保项目只能使用 pnpm 作为包管理器:

{
  "name": "vue",
  "version": "3.6.0",
  "type": "module",
  "scripts": {
    "preinstall": "npx only-allow pnpm"    // 确保项目只能使用 pnpm 作为包管理器
  },
  "license": "MIT"
}

其中 only-allow 是 pnpm 官方提供的一个检测工具,可以检测当前项目是否使用了指定的包管理器(若不符合条件会报错并强行退出程序)。

preinstall 钩子中,我们通过 npx only-allow pnpm 来确保项目只能使用 pnpm 作为包管理器。

💡 Vue 是基于 Monorepo 架构来维护各模块的,而 pnpm 对 Monorepo 有很好的支持,我们会在后文了解到这一点。

2、Monorepo

2.1 Vue 中的 Monorepo

在业务项目中,我们可以通过 npm install vue 等方式来下载和使用 Vue,不过 Vue 除了这个覆盖完整功能的 npm 包,还提供了多个独立的核心子模块包,每个包都有其特定的功能和作用:

  • @vue/shared:Vue 3 中被各模块共享的工具函数模块。
  • @vue/reactivity:Vue 3 的响应式模块。
  • @vue/compiler-core:Vue 3 的模板编译核心模块。
  • @vue/compiler-dom:Vue 3 的 DOM 编译模块。
  • @vue/runtime-core:Vue 3 的运行时核心模块。
  • ...

例如你可以在业务项目中独立下载 Vue 的响应式模块 @vue/reactivity

npm install @vue/reactivity

并在项目中使用它:

import { reactive } from '@vue/reactivity'

const state = reactive({ msg: 'hi' })

此举仅会下载 @vue/reactivity 模块(及其依赖模块)的代码,而不会下载 Vue 的所有模块。

然而 Vue 并没有给每个核心子模块都独立创建一个 Git 仓库,而是将它们统一放在 Vue 源码项目的 packages 文件夹下进行维护:

vue
├── packages
│   ├── shared
│   ├── reactivity
│   ├── compiler-core
│   ├── compiler-dom
│   ├── runtime-core
│   ├── ...

此类「单仓库多模块」的架构形式,被称为 Monorepo

2.2 以 shared 为例的模块结构

以「共享工具函数模块」shared 为例,其目录结构 packages/shared 非常简练:

vue
├── packages
│   ├── shared
│   │   ├── src           // 存放实际源码
│   │   │   ├── general.ts
│   │   │   ├── makeMap.ts
│   │   │   ├── ...
│   │   │   └── index.ts
│   │   ├── package.json  // npm 包配置
│   │   └── index.ts      // 包入口

src 文件夹

shared/src 文件夹用于存放 shared 模块实际的源码文件,源码文件会按功能维度拆分成多个子模块。

简单起见,我们目前只实现 shared/src/general.tsshared/src/makeMap.ts 两个功能子模块,它们的代码如下:

/** shared/src/makeMap.ts **/

/**
 * 把一个用逗号分隔的字符串(例如 "a,b,c" )预处理成一个“成员判断函数”,
 * 用于在运行时高频地判断某个 key 是否属于某个固定集合。
 * 
 * 示例:
 * const isHTMLTag = makeMap('div,span,p')
 * isHTMLTag('div') // true
 * isHTMLTag('a')   // false
 */

export function makeMap(str: string): (key: string) => boolean {
  const map = Object.create(null)
  for (const key of str.split(',')) map[key] = 1
  return val => val in map
}
/** shared/src/general.ts **/

import { makeMap } from './makeMap'

/** 空对象 */
export const EMPTY_OBJ: { readonly [key: string]: any } = {}

/** 空数组 */
export const EMPTY_ARR: readonly never[] = []

/** 空函数 */
export const NOOP = (): void => {}

/** 生成一个方法,用于判断一个属性名是否是保留属性 */
export const isReservedProp: (key: string) => boolean = /*@__PURE__*/ makeMap(
  ',key,ref,ref_for,ref_key,' +
    'onVnodeBeforeMount,onVnodeMounted,' +
    'onVnodeBeforeUpdate,onVnodeUpdated,' +
    'onVnodeBeforeUnmount,onVnodeUnmounted',
)

接着我们在模块的内部出口文件 shared/src/index.ts 中,导出所有功能子模块(目前仅 generalmakeMap)的接口:

/** shared/src/index.ts **/

export * from './general'
export * from './makeMap'

后续仅需引用该出口文件,就能通过「一拖多」的形式间接引入 shared 模块的全部功能接口。

待构建的 dist 文件夹

作为一个被多运行时、多构建环境复用的基础工具模块,通常需要提供多种构建格式(例如 ESM、CJS 等),以适配不同的加载方式与运行场景。

对于 shared 模块而言,其职责是为 Vue 各核心包提供最基础、最通用的工具能力,因此需要将 shared/src 下的源码构建为多种产出物,以覆盖以下几类典型使用场景:

  • 开发环境下的 CommonJS 引用

    即通过 require('@vue/shared') 引入 shared 模块,且运行于开发环境(process.env.NODE_ENV === 'development')的场景,需确保所引用的 shared 模块产出物为 CommonJS 版本,且包含用于开发期的调试逻辑(如 warning、assert 等)。

    我们拟定该产出物名为 shared.cjs.js

  • 生产环境下的 CommonJS 引用

    即同样通过 require('@vue/shared') 引入 shared 模块,但运行于生产环境的场景,需确保所引用的 shared 模块产出物为 CommonJS 版本,且剔除了所有用于开发期的调试逻辑(确保最终代码体积最小)。

    我们拟定该产出物名为 shared.cjs.prod.js

  • ESM 引用(Bundler 场景)

    即通过 import 引入 shared 模块的场景,需确保所引用的 shared 模块产出物为 ESM bundler 版本。

    我们拟定该产出物名为 shared.esm-bundler.js

    💡 与 CommonJS 不同,ESM 版本并不会拆分出「开发环境」和「生产环境」两套文件。

    💡 这得益于 ESM 的静态导入特性,Vue 框架使用者在业务侧进行二次构建的过程中,构建工具可在编译期将环境变量替换为常量,并通过 Tree-shaking / Dead Code Elimination 精确移除不可达的环境逻辑,从而保证最终生产代码中不存在任何冗余分支。

在后文我们会通过构建工具,将 shared/src 的源文件构建出适配上述三个场景的产出物,并创建 shared/dist 文件夹来存放这些产物。

另外我们也将为 shared 模块构建其 TypeScript 声明文件 shared/dist/shared.d.ts,用于在 TypeScript 项目中提供类型检查与智能提示。

npm 包入口文件

shared/index.tsshared 模块的包入口文件,在下文将介绍的 shared/package.json 中会通过 main 字段指定该文件为 CommonJS 生态下的传统入口。

其内容为根据当前的运行环境,引入对应的 CommonJS 版本构建产物:

'use strict'

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./dist/shared.cjs.prod.js')
} else {
  module.exports = require('./dist/shared.cjs.js')
}

留意在这类 CommonJS 入口文件中,Vue 都对它们启用了严格模式('use strict'),这是为了确保代码能及时暴露一些潜在的问题(例如使用未声明的变量),并提升代码的质量和可维护性。

💡 严格模式在 ESM 里是默认开启的,因此 ESM 模块里无需标记 'use strict'

npm 包配置文件

shared/package.jsonshared 模块的 npm 包配置文件,其职责不只是提供「npm 发布描述」,还包含了工程化相关的配置信息:

/** shared/package.json **/

{
  "name": "@vue/shared",
  "version": "3.6.0",
  "main": "index.js",                     // 指定 CommonJS 生态下的传统入口,主要用于 不支持 exports 的旧 NodeJS / 工具链
  "module": "dist/shared.esm-bundler.js", // 指定 ESM bundler 生态下的入口
  "types": "dist/shared.d.ts",            // 指定类型声明文件
  "files": [                              // 指定了在发布到 npm 时,哪些文件会被包含在发布包中(避免将 src、测试代码、构建脚本等无关内容发布到 npm)
    "index.js",
    "dist"
  ],
  "exports": {                            // 更为完善的新规范入口配置字段
    ".": {
      "types": "./dist/shared.d.ts",                 // 指定类型声明文件
      "node": {                                      // 传统 NodeJS(CommonJS),开发环境和生产环境对应的入口
        "production": "./dist/shared.cjs.prod.js",
        "development": "./dist/shared.cjs.js",
        "default": "./index.js"
      },
      "module": "./dist/shared.esm-bundler.js",      // ESM bundler 入口(用于部分构建工具识别)
      "import": "./dist/shared.esm-bundler.js",      // ESM bundler 入口(用于 NodeJS 侧识别)
      "require": "./index.js"                        // 传统 CommonJS 默认入口(兜底)
    },
    "./*": "./*"                                     // 指定可访问的子路径映射
  },
  "sideEffects": false          // 声明该包在模块【初始化阶段】不存在副作用,当模块的导出未被使用时,整个模块可被剔除(Tree-shaking)
  }
}

其中 mainmoduletypes 三个字段是为了兼容旧生态所保留的约定式入口,exports 则是更为完善的新规范字段(可以指定更细粒度的入口映射),现代 NodeJS 或构建工具会优先采用 exports 字段指定的入口。

2.3 Monorepo 模块之间的联系

新建 reactivity 模块

为了更好地了解 Monorepo 下各独立模块之间的联系,我们仿照 shared 模块的结构,在 packages 下创建一个名为 reactivity 的响应式模块:

vue
├── packages
│   ├── reactivity        // 新增响应式模块文件夹
│   │   ├── src           // 存放实际源码
│   │   │   ├── reactive.ts
│   │   │   └── index.ts
│   │   ├── package.json  // npm 包配置
│   │   └── index.ts      // 包入口

reactivity 下各文件的内容和 shared 的基本一致。

目前我们仅打算搭建一个项目雏形,因此 reactivity 模块的内容尽量简单化,其中 src/reactive.ts 的代码仅用来模拟 shared 模块接口的引入和导出:

/** src/reactive.ts **/

export * from '@vue/shared'  // 仅用于调试

此时你会看到 IDE 中会标红报错,提示 TypeScript 找不到 @vue/shared 模块:

p1.png

我们可以在 vue 根目录下新增 tsconfig.json 文件,用于配置 TypeScript 的编译选项:

{
  "compilerOptions": {
    "strict": true,
    "rootDir": ".",
    "paths": {                            // IDE 类型检查路径
      "@vue/*": ["./packages/*/src"],     // 把 @vue/xxx 映射到 packages/xxx/src 下
      "*": ["./*"]
    }
  },
  "include": [
    "packages/*/src",
  ]
}

此时 src/reactive.ts 中不再标红报错,IDE 的 TypeScript 类型检查功能已可成功识别到 @vue/shared 模块。

pnpm 的 workspace 协议

如同「2.1 Vue 中的 Monorepo」所提及的,@vue/reactivity 需要被独立发布到 npm 上(供开发者下载),我们需要为其声明对 @vue/shared npm 包的依赖:

/** reactivity/package.json **/

{
  "name": "@vue/reactivity",
  "version": "3.6.0",
  "main": "index.js",
  "module": "dist/reactivity.esm-bundler.js",
  // 略...(和 shared/package.json 基本一致)

  "dependencies": {                 // 补充对 shared 模块的依赖信息
    "@vue/shared": "3.6.0"
  }
}

然而这里所填入的 @vue/shared 版本号 3.6.0 存在一个问题 —— 该远程版本的内容并非我们本地上最新的 packages/shared 下的内容,且每次发布 npm 包之前都需要手动更新该依赖包的版本号,一旦遗漏就会出错。

pnpm 官方提供了一个解决方案,即使用 workspace 协议来替换依赖包的版本号,它表示该依赖必须解析自当前 Monorepo 中声明的 workspace 包,而不会从远程 npm registry 下载:

/** reactivity/package.json **/

  "dependencies": {      
    "@vue/shared": "workspace:*"     // 更替为 workspace 协议
  }

另外,我们还需在 vue 根目录下新增 pnpm-workspace.yaml 文件,用于告知 pnpm「哪些文件夹可以作为独立的 workspace」:

/** pnpm-workspace.yaml **/

packages:
  - "packages/*"  // 把 packages 文件夹里的每一个一级子目录,都当作一个独立的 workspace 包

pnpm 在执行时会扫描 pnpm-workspace.yaml 中所配置的目录,并将其中包含 package.json 的子目录注册为 workspace 成员,后续在解析 workspace:* 时,会从这些 workspace 成员中进行检索和匹配。

在经过这番配置后,通过 pnpm publish 指令来发布 @vue/reactivity 包时,pnpm 会把将要提交到 npm registry 的 manifest 中的 @vue/shared 依赖包版本号,填写为本地对应 workspace 成员 packages.json(即 packages/shared/package.json)中的版本号。

3、构建系统雏形

在理解了 Vue 3.6 的 Monorepo 结构与包之间的依赖关系之后,下一步就是要实现一个基础的构建系统,将分散在 packages/*/src 目录下的源码,构建出预期的各版本并放置在 packages/*/dist 中)。

3.1 技术选型

Vue 使用了 Rollup 作为「生产构建器」,开发态的快速构建则采用的 esbuild

生产构建选择 Rollup 的原因

Rollup 自诞生以来就是为了打包 JavaScript 库而设计的:

  • Tree-shaking 的极致优化 —— Rollup 基于 ES Modules (ESM),它能生成非常「扁平」的输出代码,进而确保冗余代码能被精确裁剪、输出内容足够干净。
  • 作用域提升(Scope Hoisting) —— Rollup 默认会将所有模块提升到同一个作用域内,这不仅进一步减小了代码体积,还能提高执行性能。
  • 多格式输出支持 —— Rollup 的插件系统和配置机制,可以高效地构建出 Vue 的多版本产物:
    • esm-bundler (给 Vite / Webpack 用);
    • esm-browser (给浏览器 <script type="module"> 用);
    • cjs (CommonJS 语法,给 NodeJS 环境用);
    • global (传统的 <script> 引入) 。

最重要的是,Vue 需要被构建为一个「通用的前端框架库」,而不是被构建为一个「Web 应用」,因此需要尽可能地保证输出代码的可阅读性。相比 Webpack 会在每个模块周围包裹大量的 __webpack_require__ 等运行时代码,Rollup 输出的代码更加「原汁原味」、易于使用者阅读和调试。

开发构建选择 esbuild 的原因

虽然 Rollup 产物精美,但在速度上它并不是最快的。

在开发、调试场景中,我们对于构建速度的需求要远高于极致性能的需求,而 esbuild 非常契合这一场景:

  • 极速的增量构建 —— esbuild 采用了基于 Go 语言的编译后端,编译速度非常快,能够在毫秒级完成增量构建,适合频繁改动源码、快速看效果的 watch 开发。
  • 简单的配置 —— esbuild 的配置选项非常简单,无需复杂的插件系统即可实现基本的构建需求。

因此,虽然 esbuild 的输出代码不够完美(例如 Tree-Shaking 没有 Rollup 极致),但非常适用于在开发场景中用于响应迅速的「粗加工」。

💡 读者需注意区分各类构建面向的主体对象 —— Vue 源码项目的「生产构建」是面向 Vue 框架使用者的,因此「生产构建」的产物包括了使用者在其生产环境中使用的产物,也包括了使用者在其开发环境中使用的产物;而 Vue 源码项目的「开发构建」只面向 Vue 源码开发者(与使用者无关)。

3.2 shared 模块的生产构建方案实现

rollup.config.js 配置

Vue 是通过拼接 Rollup 的命令行指令来执行生产构建的,我们可以按照 Rollup 官方文档,先在项目根目录创建一个 rollup.config.js 配置文件,用于告诉 Rollup 在执行指令时「如何打包一个 package」。

以「构建一个 packages/shared/dist/shared.cjs.js 为例」,其配置内容参考如下:

/** rollup.config.js */

import { fileURLToPath } from 'node:url'
import path from 'node:path'
import esbuild from 'rollup-plugin-esbuild'

const __dirname = fileURLToPath(new URL('.', import.meta.url))

const packageConfigs = [{
  input: path.resolve(__dirname, './packages/shared/src/index.ts'),

  plugins: [
    // 插件
    esbuild({       // TypeScript / JS 语法转译
      tsconfig: path.resolve(__dirname, 'tsconfig.json'),
      minify: false,
      target: 'es2016',
    }),
  ],

  output: {
    file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.js'),
    format: "cjs",
  },
  treeshake: {
moduleSideEffects: false,    // 声明模块无副作用,允许 Rollup 安全地进行 Tree-Shaking
},
}];

export default packageConfigs

留意这里我们使用了 rollup-plugin-esbuild 插件,这是因为 Rollup 本身是无法识别 TypeScript 语法的,因此需要利用 esbuild 来充当「语法翻译官」,把 TypeScript 代码转译为 JavaScript 代码。

💡 esbuild 在开发模式中充当了构建器 + bundler + runner 的角色,但在本小节的生产构建中被降级为一个编译器插件。

💡 读者请自行安装 rolluptypescriptrollup-plugin-esbuild 等 npm 依赖包,本系列文章不会提及依赖包的安装。

此时执行 rollup -c 即可构建出 packages/shared/dist/shared.cjs.js 文件。

然而除了 cjs 格式的文件,还要为 shared 模块构建 esm-bundler 格式的文件,这需要往 packageConfigs 数组中 push 多个配置项,我们可以封装一个 createConfig 方法来创建配置项:

/** rollup.config.js */

// 略...

const outputConfigs = {
  'esm-bundler': {
    file: path.resolve(__dirname, './packages/shared/dist/shared.esm-bundler.js'),
    format: 'es',
  },
  'cjs': {
    file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.js'),
    format: 'cjs',
  },
}

const packageFormats = ['esm-bundler', 'cjs']

const packageConfigs = packageFormats.map(format => createConfig(format, outputConfigs[format]))

export default packageConfigs

function createConfig(format, output, plugins = []) {
console.log(`正在创建 ${format} 格式的构建配置...`)

return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [   
esbuild({  
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
}),
...plugins    // 支持扩展自定义插件
],
output,
    treeshake: {
moduleSideEffects: false,
},
}
}

此时执行 rollup -c 可构建出 packages/shared/dist/shared.cjs.jspackages/shared/dist/shared.esm-bundler.js 文件。

利用 NODE_ENVPROD_ONLY 参数指定产出物环境

根据「待构建的 dist 文件夹」小节所罗列的构建产物,我们还需为 shared 模块构建一个 CommonJS 格式的生产环境产物 shared.cjs.prod.js

另外,Vue 属于一个多模块、复杂度较高的项目,会有「按需构建」的需求,用于节省构建的等待时间。例如开发者会希望通过执行不同的 Rollup 的指令来满足如下三种场景:

  • 构建所有生产环境和开发环境的产物;
  • 只构建生产环境的产物;
  • 只构建开发环境的产物。

我们可以通过 Rollup 执行指令中的 environment 参数来传值处理:

  • 执行 rollup -c --environment NODE_ENV:production 时构建出所有生产环境和开发环境的产物;
  • 执行 rollup -c --environment NODE_ENV:production,PROD_ONLY:true 时只构建生产环境的产物;
  • 执行 rollup -c --environment NODE_ENV:development 时只构建开发环境的产物。

这里我们自定义了两个参数 NODE_ENVPROD_ONLY,分别用于指定「构建目标环境」和「是否只构建生产环境的产物」,我们可以在 rollup.config.js 中通过 process.env 来读取传入的自定义参数值,并做相应的逻辑处理:

/** rollup.config.js */

// 略...
const packageFormats = ['esm-bundler', 'cjs']

// 通过 process.env 获取传入的 PROD_ONLY 参数
const packageConfigs = process.env.PROD_ONLY ? [] : packageFormats.map(format => createConfig(format, outputConfigs[format]))

// 若需要构建生产环境产物,针对 cjs 格式,添加其生产环境的配置项
if (process.env.NODE_ENV === 'production') {    // 通过 process.env 获取传入的 NODE_ENV 参数
  packageFormats.forEach(format => {
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
  })
}

export default packageConfigs

function createConfig(format, output, plugins = []) {
const isProductionBuild = /\.prod\.js$/.test(output.file)
console.log(`正在创建${isProductionBuild ? '生产环境' : '开发环境'} ${format} 格式的构建配置...`)

return {
// 略...
}
}

// 创建生产环境配置项
function createProductionConfig(format) {
  return createConfig(format, {
    ...outputConfigs[format],
    file: path.resolve(__dirname, './packages/shared/dist/shared.cjs.prod.js'),  // 构建 cjs.prod.js 文件
  })
}

此时执行 rollup -c --environment NODE_ENV:production,PROD_ONLY:true,会单独构建出 packages/shared/dist/shared.cjs.prod.js 文件。

开发环境占位符 __DEV__ 及其替换

查看前文所构建出的 shared.cjs.jsshared.cjs.prod.js 文件,会发现它们的内容是完全相同的,读者可能会因此感到困惑。

为了便于区分不同环境产物的内容(而不仅仅是文件名不同),我们修改 packages/shared/src/general.ts 的代码,加上一个自定义的开发环境占位符 __DEV__

/** packages/shared/src/general.ts */

/** 空对象 */
export const EMPTY_OBJ: { readonly [key: string]: any } = __DEV__
  ? Object.freeze({})
  : {}

/** 空数组 */
export const EMPTY_ARR: readonly never[] = __DEV__ ? Object.freeze([]) : []

此举目的是让 Vue 框架使用者在开发环境中,通过 Object.freeze 来避免改动到 Vue 源码内置的空对象和空数组两个常量,在生产环境时则改为使用性能最优的空对象和空数组即可。

IDE 在此时会对 __DEV__ 进行标红,提示「找不到名称__DEV__」,我们可以在 packages 下创建一个 global.d.ts 文件,用于声明这些自定义的变量:

// Global compile-time constants
declare var __DEV__: boolean

接着我们在 rollup.config.js 中,通过 rollup-plugin-esbuild 插件的 define 属性,对 __DEV__ 占位符进行替换:

/** rollup.config.js */

// 略...

function createConfig(format, output, plugins = []) {
const isProductionBuild = /\.prod\.js$/.test(output.file)
  const isBundlerESMBuild = /esm-bundler/.test(format)      // 是否为 ESM Bundler 格式的构建
// 略...

return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [  
...resolveReplace(),
esbuild({  
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
define: resolveDefine(),    // 替换自定义占位符
}),
...plugins
],
// 略...
}

  function resolveDefine() {
    const replacements = {}

    if (isBundlerESMBuild) {
      replacements.__DEV__ = `!!(process.env.NODE_ENV !== 'production')`
    } else {
      replacements.__DEV__ = String(!isProductionBuild)
    }

    return replacements
  }
}

留意在新增的 resolveDefine 方法中,是否处于 ESM Bundler 格式的构建会影响占位符 __DEV__ 的替换内容:

  • ESM Bundler 格式的构建产物会把 __DEV__ 占位符替换为 !!(process.env.NODE_ENV !== 'production'),因为该产物是会交由业务侧去执行二次构建的,由业务侧的构建工具再进一步去替换 process.env.NODE_ENV 即可;
  • 非 ESM Bundler 格式的构建产物属于「最终运行时代码」,不会经历二次构建,因此要把 __DEV__ 占位符根据构建环境替换为明确的 truefalse,esbuild 插件会在构建过程通过 Tree-Shaking 移除未命中的逻辑分支,确保产物简洁且可以直接运行。

然而此时执行 rollup -c --environment NODE_ENV:production 指令,会出现报错:

- [!] (plugin esbuild) Error: Transform failed with 1 error:
- error: Invalid define value (must be an entity name or JS literal): !!(process.env.NODE_ENV !== 'production')

这是因为 esbuild 的 define 对替换内容具有严格的要求,其仅用于将全局标识符替换为静态常量,它要求替换的内容必须是布尔值、数字、字符串,或者是另一个标识符,但不能是 !!(process.env.NODE_ENV !== 'production') 这样的表达式语句。

esbuild 会试图把自定义的 Key 替换成一个合法的 AST 节点,当要替换的值是一个复杂的表达式时,esbuild 解析器无法将其作为一个单一的「值」或「标识符」插入到 AST 中,因此会直接抛出 Invalid define value 错误来阻止构建。

针对此问题,我们可以在 ESM Bundler 场景改为使用 @rollup/plugin-replace 插件来进行占位符替换:

/** rollup.config.js */

import replace from '@rollup/plugin-replace'
// 略...

function createConfig(format, output, plugins = []) {
// 略...

return {
input: path.resolve(__dirname, './packages/shared/src/index.ts'),
plugins: [         
...resolveReplace(),     // 使用 @rollup/plugin-replace 插件
esbuild({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
minify: false,
target: 'es2016',
define: resolveDefine(),
}),
...plugins
],
// 略...
}

function resolveDefine() {
const replacements = {}

if (!isBundlerESMBuild) {
replacements.__DEV__ = String(!isProductionBuild)
}

return replacements
}

function resolveReplace() {
const replacements = {}

    // ESM Bundler 格式的构建产物使用 @rollup/plugin-replace 来替换占位符
if (isBundlerESMBuild) {
Object.assign(replacements, {
__DEV__: `!!(process.env.NODE_ENV !== 'production')`,
})
}

return [replace({ 
      values: replacements, 
      preventAssignment: true  // 若变量出现在赋值号( = )的左边,就不要进行替换。目的是防止把赋值语句当成常量替换(例如 __DEV__ = true)
    })]
}
}

此时执行 rollup -c --environment NODE_ENV:production 指令,构建出的所有 shared 模块产物内容如下:

/** packages/shared/dist/shared.cjs.js */

'use strict';

const EMPTY_OBJ = Object.freeze({}) ;
// 略...
exports.EMPTY_OBJ = EMPTY_OBJ;
// 略...
/** packages/shared/dist/shared.cjs.prod.js */

'use strict';

const EMPTY_OBJ = {};
// 略...
exports.EMPTY_OBJ = EMPTY_OBJ;
// 略...
/** packages/shared/dist/shared.esm-bundler.js */

const EMPTY_OBJ = !!(process.env.NODE_ENV !== "production") ? Object.freeze({}) : {};
// 略...

export { 
  EMPTY_OBJ, 
  // 略... 
}

我们会在下篇文章为构建系统补充 reactivity 等其它模块的构建能力。

3.3 shared 模块的开发构建方案实现

我们已经在前文了解过,Vue 源码项目只通过 esbuild 来负责开发调试时的构建,我们在 Vue 源码项目的根目录创建 ./scripts/dev.js 文件来配置 esbuild:

import esbuild from 'esbuild'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'url'
import { parseArgs } from 'util'

const __dirname = dirname(fileURLToPath(import.meta.url))

const {
    values: { format: rawFormat, prod, },
} = parseArgs({
    options: {
        format: {
            type: 'string',
            short: 'f',
            default: 'cjs',
        },
        prod: {
            type: 'boolean',
            short: 'p',
            default: false,
        },
    },
})

const format = rawFormat || 'cjs'
const outputFormat = format === 'cjs' ? 'cjs' : 'esm'

const pkgBasePath = `../packages/shared`
const outfile = resolve(__dirname, `${pkgBasePath}/dist/shared.${format}.${prod ? `prod.` : ``}js`)

const relativeOutfile = relative(process.cwd(), outfile)


/** @type {Array<import('esbuild').Plugin>} */
const plugins = [
    {
        name: 'log-rebuild',    // 在构建完成时,打印构建完成的文件路径
        setup(build) {
            build.onEnd(() => {
                console.log(`built: ${relativeOutfile}`)
            })
        },
    },
]

const entry = 'index.ts'

esbuild
    .context({
        entryPoints: [resolve(__dirname, `${pkgBasePath}/src/${entry}`)],
        outfile,
        bundle: true,
        sourcemap: true,
        format: outputFormat,
        platform: format === 'cjs' ? 'node' : 'browser',
        plugins,
        define: {
            __DEV__: prod ? `false` : `true`,
        },
    })
    .then(ctx => ctx.watch())    // 监听源码变化并执行实时增量构建

其中我们使用了 NodeJS util 原生模块的 parseArgs 来解析命令行参数,例如:

node scripts/dev.js -f esm-bundler

当执行该指令时,parseArgs 会捕获传入的 f 参数并将其值(esm-bundler)赋给 rawFormat 变量。

最后我们调用了 esbuild 的 context 方法来自定义配置并执行构建。以上述的指令为例,esbuild 会直接构建出 packages/shared/dist/shared.esm-bundler.js 文件,且实时监听 shared 模块的源码变化并执行增量构建。

💡 Vue 源码在开发调试环节,需要通过 esbuild 构建的场景其实不多(在开发后期可以创建一个 vite 项目来配合调试),开发的前期更多还是通过 vitest 对模块源码进行单元测试。我们会在后续的文章中进行了解。

补充:鉴于 Vue 3.6 beta 5 已全线替换构建工具,本文「后续的文章」也只能跟着断更。后续可能以 Vue 官方最新版的项目重新进行源码解析和输出解析文章。共勉。

Wujie微前端

2026年1月30日 23:15

wujie使用

基座应用是“容器”,负责加载和展示子应用。

假设你用 Vue3 作为基座(最常见),配置超级简单。

1. 创建一个普通的 Vue3 项目(如果你没有)

用 Vue CLI 或 Vite 创建:

text

npm create vue@latest
# 或者 vite:npm create vite@latest

选 Vue + TypeScript 或 JS 都行,随便。

2. 安装 Wujie 的 Vue3 包

在你的基座项目里运行:

text

npm i wujie-vue3 -S
3. 在 main.js(或 main.ts)里引入并注册

打开 src/main.js,加上这些代码:

JavaScript

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'  // 如果你有路由

// 引入 WujieVue
import WujieVue from 'wujie-vue3'

const app = createApp(App)

// 注册 WujieVue 插件
app.use(WujieVue)

// 如果有路由
app.use(router)

app.mount('#app')
4. 在页面里使用wujie-vue组件加载子应用

打开一个页面组件,比如 src/views/Home.vue 或 App.vue,写一个容器:

vue

<template>
  <div>
    <h1>这是基座应用</h1>
    
    <!-- 这里放子应用,width 和 height 自己调 -->
    <wujie-vue
      width="100%"
      height="800px"
      name="唯一名字"        <!-- 随便取但要唯一比如 "vue3-app" -->
      url="子应用的地址"     <!-- 子应用的线上地址或本地地址,比如 http://localhost:3000 -->
      :sync="true"            <!-- 可选:路由同步,推荐开 -->
      :fetch="自定义fetch"    <!-- 可选:如果子应用有跨域问题,可以自定义fetch -->
      :props="{ token: '123' }"  <!-- 可选:给子应用传数据 -->
      :beforeLoad="beforeLoad"  <!-- 可选:生命周期钩子 -->
      :beforeMount="beforeMount"
      :afterMount="afterMount"
    ></wujie-vue>
  </div>
</template>

<script setup>
// 可选:生命周期钩子函数
const beforeLoad = () => {
  console.log('子应用开始加载')
}
const beforeMount = () => {
  console.log('子应用即将挂载')
}
const afterMount = () => {
  console.log('子应用挂载完成')
}
</script>

关键参数解释(小白必看):

  • name:子应用的唯一名字,不能重复。
  • url:子应用的入口地址(必须是完整的 http:// 或 https://)。
  • sync:true 表示路由同步(子应用路由变了,浏览器地址栏也会变)。
  • props:可以给子应用传数据,子应用里用 window.$wujie.props 拿。
5. 预加载(推荐,加速切换)

在基座的入口文件(比如 App.vue 的 mounted)里预加载:

JavaScript

import { preloadApp } from 'wujie-vue3'

preloadApp({
  name: '唯一名字',
  url: '子应用地址'
})
6. 启动基座

text

npm run dev

打开页面,你就会看到子应用嵌进来了!

第三步:子应用怎么配置(最友好的是零改造!)

子应用就是你原来的普通项目(Vue、React、Vite 等)。

超级好消息:用“保活模式”或“重建模式”,子应用完全不用改代码!

推荐方式:保活模式(keep-alive,最丝滑,无白屏)

在基座的 组件上加一个属性:

vue

<wujie-vue
  ...
  alive="true"   <!-- 就是这一行!开启保活 -->
></wujie-vue>

完了!子应用零改造,切换回来不会重新加载,超级快。

如果你想用单例模式(多个地方共用同一个实例,需要改造一点)

只在子应用的入口文件(main.js 或 index.js)里包一层判断:

Vue3 示例(vite 项目) : 在 main.js 最外面包起来:

JavaScript

let instance

if (window.__POWERED_BY_WUJIE__) {
  // 被 Wujie 加载时的生命周期
  window.__WUJIE_MOUNT = () => {
    instance = createApp(App).use(router).mount('#app')
  }
  window.__WUJIE_UNMOUNT = () => {
    instance.unmount()
  }
  // 如果是 Vite 构建的,需要主动触发
  window.__WUJIE.mount()
} else {
  // 独立运行时
  createApp(App).use(router).mount('#app')
}

其他框架(React、Vue2)文档里都有示例,基本就是把 mount 和 unmount 挂到 window 上。

强烈建议小白先用保活模式,完全不用改子应用。

单例模式VS保活模式

保活模式

什么是保活模式?想象子应用是一个“玩具房子”;

  • 正常情况,你离开房间(切换页面),房子就被拆掉(卸载),回来需要重新搭(重新加载,有白屏)
  • 保活模式,房子一直留在原地不拆(保持活着),你回来直接玩(零白屏,状态完全保留,比如输入框的内容还在)
  • 优点:
    • 配置最简单
    • 零改造子应用
    • 切换最快、无白屏
  • 缺点: 如果基座多个地方加载同一个子应用,状态不共享(每个是独立的“房子”)
  • 适合场景:导航菜单切换子应用,Tab页切换,大多数项目都先用这个

单例模式

什么是单例模式? 全局只有一个房子!不管你在几个房间放几个门(多个容器加载同一个子应用),开门进门的都是同一个房子(共享同一个实例和状态)。 怎么配置: * 基座不用特殊配置(可以结合alive=true一起使用) * 关键: 子应用需要改造代码(手动控制挂载和卸载) 子应用改造示例(Vue3+Vite项目为例,在main.js或main.ts最外面包一层):

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

let instance = null  // 全局只有一个 instance

function mount() {
  if (!instance) {
    instance = createApp(App)
    instance.use(router)
    instance.mount('#app')  // 挂载到容器
  }
}

function unmount() {
  if (instance) {
    instance.unmount()
    instance = null  // 可选:销毁实例
  }
}

// 被 Wujie 加载时(微前端模式)
if (window.__POWERED_BY_WUJIE__) {
  // 挂到 window 上,Wujie 会自动调用
  window.__WUJIE_MOUNT = () => {
    mount()
  }
  window.__WUJIE_UNMOUNT = () => {
    unmount()
  }
  
  // Vite 项目需要主动触发一次(有些构建工具需要)
  window.__WUJIE.mount?.()
} else {
  // 独立运行时(直接 npm run dev)
  createApp(App).use(router).mount('#app')
}
  • 单例模式特点:
    • 全局只有一个子应用实例
    • 多个地方加载同一个url的子应用,共享状态(比如登录用户信息全局共享)
    • 切换时可以不重建(如果结合保活)
    • 需要改在子应用(__WUJIE_MOUNT和__WUJIE_UNMOUNT)
  • 优点:
    • 状态全局共享(适合需要单点登录、共享用户数据等)
    • 内存占用更少(只有一个实例)
  • 缺点:
    • 需要改造子应用代码
    • 如果不结合保活,切换可能有轻微重建
  • 适合场景: 子应用需要在基座多个位置出现,但状态一致(比如全局共享的侧边栏,头部组件)
  • 最强组合:单例模式+保活模式一起用!

第四步:常见问题注意事项(小白必看)

  1. 跨域问题:子应用的地址必须和基座同域,或者子应用服务器开 CORS(允许跨域)。本地开发时,两个项目用不同端口,就要开代理或用 nginx。
  2. 子应用地址:本地开发时,子应用先单独跑起来(npm run dev),拿到 http://localhost:xxxx,然后填到基座的 url。
  3. 样式隔离:Wujie 天生隔离好,子应用样式不会污染基座。
  4. 通信:基座传数据用 props;子应用发事件用 bus.emit(事件,数据);基座监听bus.emit('事件名', 数据);基座监听 bus.on('事件名', handler)。

Wujie微前端的通信方案(基座 ↔ 子应用、子应用 ↔ 子应用)

Wujie 的通信设计非常简单和强大,它内置了一个全局事件总线(EventBus) ,所有子应用和基座都共享同一个 bus 对象。这意味着:

  • 基座 → 子应用:单向传值最常用的是 props
  • 子应用 → 基座:子应用通过 bus.$emit 发事件,基座监听
  • 子应用 ↔ 子应用:直接通过同一个 bus 互相发事件和监听(不需要经过基座中转!)
  • 另外还有一些辅助方式(如插件共享状态)

下面我一步一步慢慢讲,每种方案都配代码示例(基于 Vue3 基座 + Vue3/React 子应用通用)。

方案1:基座 → 子应用(推荐:props 单向传值)

这是最简单、最常用的方式。基座把数据作为 props 传给子应用,子应用实时接收(数据变化会自动同步)。

基座配置( 组件上加 props)

vue

<template>
  <wujie-vue
    name="sub-app1"
    url="http://localhost:3001"
    :props="subAppProps"
  ></wujie-vue>
</template>

<script setup>
import { ref } from 'vue'

const subAppProps = ref({
  token: 'abc123',
  userName: '小明',
  count: 0,
  // 甚至可以传函数!
  jumpTo: (path) => {
    console.log('子应用调用了基座的函数,跳转到', path)
    // 这里可以控制基座路由
  }
})

// 基座修改数据,子应用会自动收到更新
const updateCount = () => {
  subAppProps.value.count += 1
}
</script>

子应用接收(在任何地方都可以拿到)

JavaScript

// 子应用里(Vue/React 都一样)
if (window.__POWERED_BY_WUJIE__) {
  // 被 Wujie 加载时
  const props = window.$wujie?.props || {}
  console.log('收到基座传来的数据:', props)
  // props.token, props.userName, props.count, props.jumpTo()

  // 如果想响应式(Vue3 示例)
  import { watch } from 'vue'
  watch(() => window.$wujie?.props, (newProps) => {
    console.log('props 更新了:', newProps)
  }, { deep: true })
}

优点:实时同步、类型安全、支持传函数(子应用可以调用基座方法) 缺点:单向(子应用不能直接改基座的数据)

方案2:子应用 → 基座、子应用 → 子应用(全局 EventBus)

Wujie 内置了一个全局 bus,所有子应用和基座共享同一个实例。子应用之间可以直接通信,不需要基座中转。

基座监听事件(在任意组件里)

vue

<script setup>
import { onMounted, onUnmounted } from 'vue'
import { bus } from 'wujie-vue3'  // 基座直接导入

onMounted(() => {
  // 监听所有子应用发的事件
  bus.$on('click-btn', (data) => {
    console.log('基座收到事件,数据是:', data)
    // 这里可以更新基座状态、跳转路由等
  })

  // 监听特定子应用(可选)
  bus.$on('sub-app1-event', (data) => { ... })
})

onUnmounted(() => {
  bus.$off('click-btn')  // 记得销毁,防止内存泄漏
})
</script>

子应用发事件(在子应用任意位置)

JavaScript

// 子应用里(不需要额外安装,直接用 window.$wujie.bus)
const sendEvent = () => {
  window.$wujie?.bus.$emit('click-btn', {
    msg: '来自子应用1的数据',
    value: 999
  })
}

// 子应用2 也可以直接监听子应用1的事件!
window.$wujie?.bus.$on('click-btn', (data) => {
  console.log('子应用2 收到子应用1的事件:', data)
})

关键点

  • 所有子应用共享同一个 bus,所以子应用A emit的事件,子应用B可以直接emit 的事件,子应用B 可以直接 on 收到。
  • 事件名建议加前缀避免冲突,比如 'sub-app1-click'。
  • 支持一次性监听:bus.$once('event', handler)
  • 销毁监听:$off

优点:双向、实时、子应用间直接通信、非常灵活 缺点:需要手动管理事件名和销毁监听

方案3:基座 → 所有子应用广播(结合 props + bus)

如果你想基座主动推送数据给所有子应用:

  • 用 bus.$emit 发事件,所有子应用监听同一个事件。
  • 或者用插件(见下面)共享状态。
方案4:共享状态(插件方式,适合复杂状态管理)

如果你的项目状态很多,推荐用 Wujie 的插件系统共享 Pinia/Vuex/Redux 等。

官方推荐一个插件:wujie-polyfill,可以共享状态管理库。

示例:共享 Pinia(Vue)

  1. 基座和所有子应用都安装同一个 Pinia store。
  2. 用插件让它们共享实例(具体代码看官方插件文档)。

或者更简单:用 window 共享一个全局对象(不推荐,容易污染)。

总结对比(小白一看就懂)
场景 推荐方案 方向 是否实时 代码复杂度 备注
基座 → 子应用(传数据/函数) props 单向 最常用
子应用 → 基座 bus.emit+emit + on 双向
子应用 → 子应用 bus.emit+emit + on 双向 直接通信,超方便
全局共享复杂状态 插件(Pinia等) 双向 项目大时用

wujie源码

wujie源码主要包含以下几部分:

  • wujie-core
  • wujie-vue2/wujie-vue3
  • wujie-react
  • wujie-polyfill

wujie-core

  • 入口文件:index.ts
// 导出核心 API
export { startApp, preloadApp, destroyApp } from './start'  // 启动、预加载、销毁
export { bus } from './bus'  // 通信总线
export { Wujie } from './wujie'  // 主类

// 全局配置
export function setupApp(options) { ... }

// 预加载
export async function preloadApp(options: PreloadAppOptions) {
  // 提前下载资源,缓存 HTML/JS/CSS
}

// 启动应用
export async function startApp(options: StartAppOptions) {
  // 创建沙箱、加载资源、执行代码
}
* startApp主要是创建沙箱-》fetch资源=〉执行JS=》挂载DOM
* Wujie有两种沙箱模式
    * 默认:Proxy沙箱(快,推荐):wujie用Proxy代理window对象,让子应用以为自己在操作全局window,但其实操作的是“假window”
    * iframe沙箱(最强隔离,但慢)
    
class WujieSandbox {
active = true;  // 沙箱是否激活
proxyWindow: WindowProxy;  // 代理的 window

constructor(name) {
 // 创建假 window(其实是空对象)
 const fakeWindow = Object.create(null);

 // 记录修改的变量(为了销毁时恢复)
 const modifiedMap = new Map();
 const reversedMap = new Map();  // 恢复用

 this.proxyWindow = new Proxy(fakeWindow, {
   get(target, key) {
     // 1. 先从子应用自己的变量拿
     if (key in target) return target[key];
     // 2. 否则从真实 window 拿(基座共享)
     return window[key];
   },
   set(target, key, value) {
     if (this.active) {
       // 激活时:子应用修改变量,只改假 window
       modifiedMap.set(key, value);
       target[key] = value;
     } else {
       // 不激活时:直接改真实 window(共享)
       window[key] = value;
     }
     return true;
   },
   // delete、has 等也类似处理
 });

 // 避免 with 语句(历史问题,已优化)
 // 用 with 包裹代码时特殊处理
}

// 销毁沙箱:恢复基座 window
destroy() {
 this.active = false;
 // 把子应用改的变量恢复原样
 modifiedMap.forEach((value, key) => {
   window[key] = reversedMap.get(key) || originalValue;
 });
}
}
  • 怎么解决全局变量冲突?Proxy拦截get/set,只让子应用改自己的;这个比single-spa的snapshot快,比乾坤的Proxy更完善

框架适配包

  • wujie-vue3(vue组件怎么实现的?)其实就是一个wrapper,核心还是core的startApp

总结

Wujie的目标:让子应用(Vue/React等)像嵌入iframe一样隔离强,性能好,无白屏 整体流程:

  1. 加载子应用HTML:用importHTML函数fetch子应用的index.html,解析它,提取所有的

  2. 解析HTML:用preocessTp把HTML拆成template,scripts,styles

  3. 创建沙箱:new 一个Wujie的类实例。它会:

    • 创建一个隐藏的iframe(JS执行环境)
    • 用Shadow DOM(影子根)隔离CSS/DOM
    • 用Proxy代理window/document/location(JS隔离,最牛的地方)
    • 如果浏览器太老,降级用纯iframe
  4. 执行资源:加载CSS(内联或替换路径),执行JS(内联eval或动态script),支持插件修改代码。

  5. 挂载:把解析后的template塞到Shadow DOM或iframe,执行mount生命周期。

  6. 通信: 左右子应用和基座共享一个bus(EventBus),emit/emit/on发事件。

  7. 保活/销毁:支持保活模式(不销毁DOM),销毁时恢复变量,清除事件。

  8. 插件系统:所有步骤可以插插件(比如改CSS路径,排除JS)

为什么强

* JS隔离:Proxy代理window,子应用改变量只改变自己的“假window”
* CSS隔离:Shdow DOM
* 样式/JS路径自动修正
* 通信简单:全局Bus
* 性能好: 预加载,保活,无白屏

wujie-core 中所有重要函数/类罗列(按重要度排序)

从源码文件看,核心是这些(没有一个“startApp”函数,可能是框架包里调的,core里是底层):

  1. bus(全局 EventBus 实例,index.ts)—— 通信核心
  2. EventBus 类(event.ts)—— on/on/emit/$off 等通信方法
  3. importHTML(entry.ts)—— 加载和解析子应用HTML,最重要加载函数
  4. processTpl(template.ts)—— 解析HTML提取script/style
  5. class Wujie(sandbox.ts)—— 沙箱主类,constructor/mount/destroy 等
  6. proxyGenerator / localGenerator(proxy.ts)—— 创建Proxy代理 window/document/location
  7. getPlugins / defaultPlugin(plugin.ts)—— 插件系统
  8. 其他辅助:processCssLoader、getEmbedHTML 等(加载CSS)

Web文件下载 : 从PDF预览Bug到Hook架构演进

作者 im_AMBER
2026年1月30日 22:54

大家好,我是 AY。

在 Web 开发中,下载功能看似简单,却隐藏着浏览器行为差异与跨域安全限制的陷阱。

今天,我原本只想做一个导出不同文件格式的功能,却遇到了一个bug:生成Word或MD文件时,Chrome浏览器都会正常弹出下载框,但导出PDF文件时却不行——PDF会直接在当前页面预览,看起来明明是要下载PDF,结果却直接进入了预览模式,而且我原本打开的页面还被这个预览页面覆盖了。

一、为什么 PDF 会“不请自来”地预览?

1.浏览器的 MIME 类型策略

浏览器如何处理一个 URL,取决于服务器返回的 MIME 类型(Multipurpose Internet Mail Extensions)

  • Word/MD 文件:由于 Chrome 等浏览器没有内置渲染引擎,它会识别为“不可直接读取的内容”,从而触发下载。
  • PDF 文件:现代浏览器均内置了功能强大的 PDF 渲染器。当它接收到 application/pdf 类型时,默认行为是 “当前窗口导航(Navigation)”

2. 被忽略的 download 属性

我们通常尝试通过 <a download> 标签强制下载,但它受到 同源策略(Same-Origin Policy) 的严格限制:

  • 同源请求download 属性正常工作,强制下载。
  • 跨域请求:如果资源来自不同的域名/端口,浏览器出于安全考虑会 无视 download 属性,将其降级为一个普通链接,导致 PDF 直接在当前页打开。

二、Blob 对象与 Object URL

为了绕过跨域下载限制并防止原页面丢失,最稳健的方案是利用 Blob (Binary Large Object)

1. 内存中的“影子文件”

通过 fetch 请求将远程文件拉取到内存中转换为 Blob,我们可以利用 URL.createObjectURL(blob) 生成一个临时的 blob: 协议链接。

MDN 定义 - URL.createObjectURL():

该方法创建一个 DOMString。该 URL 的生命周期与其创建时的 document 绑定。

2. 为什么 Blob 能解决问题?

  • 伪装同源:生成的 blob:// 链接与当前页面拥有相同的 Origin,这使得 download 属性 100% 被浏览器尊重。
  • 生命周期管理:虽然 URL 与 DOM 树绑定,但它仅仅是指向内存的指针。通过手动创建 a 标签并设置 target="_blank" 或触发 .click(),我们可以精确控制它是静默下载还是新窗口预览。

三、架构升级:自定义 Hook 的解耦艺术

在复杂的业务逻辑中,我原本将文件获取、Blob 转换、动态创建 DOM 节点等代码堆积在 index.tsx 中会导致维护灾难。

1. 逻辑抽离的必要性

  • 关注点分离:UI 组件只负责“展示”,而下载的繁琐逻辑应该交给专门的逻辑单元。
  • 复用性:自定义 Hook 可以让下载逻辑在全站不同页面间自由导入。

2. 最佳实践代码实现

我们将这一过程封装为 useDownload Hook,实现一处定义,随处调用:

import { useState } from 'react';

/**
 * 自定义下载 Hook
 * 封装了从获取流到触发 DOM 点击的全过程
 */
export const useDownload = () => {
  const [loading, setLoading] = useState(false);

  const handleDownload = async (fileUrl: string, fileName: string) => {
    setLoading(true);
    try {
      // 1. 获取资源并转化为二进制 Blob
      const response = await fetch(fileUrl);
      const blob = await response.blob();

      // 2. 生成内存 URL
      const url = window.URL.createObjectURL(blob);

      // 3. 动态注入 a 标签触发下载
      const link = document.createElement('a');
      link.href = url;
      link.download = fileName; // 此时同源,download 属性生效
      document.body.appendChild(link);
      link.click();

      // 4. 清理现场
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url); // 必须释放内存,防止溢出
    } catch (e) {
      console.error("下载失败", e);
    } finally {
      setLoading(false);
    }
  };

  return { handleDownload, loading };
};

🌟 总结

一个 PDF 跳转的小 Bug,我补充了一些Web API 的学习,本质上是浏览器安全策略与渲染机制的综合体现。

  1. 明确边界:知道 download 属性何时失效,比盲目调试代码更重要。
  2. 生命周期意识:在使用 createObjectURL 时,必须养成配套使用 revokeObjectURL 的习惯。
  3. 架构思维:即便是一个“很小的 Bug”,也值得通过 自定义 Hook 进行架构级的封装,从而实现从“业务实现”到“工程设计”的跨越。

 参考文献:

: The Anchor element - HTML | MDN:锚元素 - HTML(超文本标记语言) | MDN

reportlab.com/docs/report…

使用 Effect 进行同步 – React 中文文档: The Anchor element - HTML | MDN

URL:createObjectURL() 静态方法 - Web API | MDN

Document - Web API | MDN

File - Web API | MDN

Blob - Web API | MDN

MediaSource - Web API | MDN

使用自定义 Hook 复用逻辑 – React 中文文档

浏览器的同源策略 - 安全 | MDN

Vue 3 Composition API 完全指南

2026年1月30日 22:39

Vue 3 Composition API 完全指南

Vue 3 的 Composition API 是一个重大的改进,它提供了更好的代码组织和复用性。

什么是 Composition API?

Composition API 是一种基于函数的 API,允许我们按功能组织代码,而不是按选项。

基本用法

setup() 函数

import { ref, reactive, computed } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const user = reactive({ name: '张三' })

    const doubleCount = computed(() => count.value * 2)

    function increment() {
      count.value++
    }

    return { count, user, doubleCount, increment }
  }
}

<script setup> 语法糖

Vue 3.2 引入了 <script setup>,让代码更简洁:

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const doubleCount = computed(() => count.value * 2)

function increment() {
  count.value++
}
</script>

<template>
  <div>{{ count }} × 2 = {{ doubleCount }}</div>
  <button @click="increment">+1</button>
</template>

核心 API

1. ref 和 reactive

  • ref():用于基本类型
  • reactive():用于对象
const count = ref(0)         // 基本类型
const state = reactive({    // 对象
  name: 'Vue',
  version: 3
})

2. computed

计算属性,具有缓存特性:

const firstName = ref('张')
const lastName = ref('三')

const fullName = computed({
  get() {
    return firstName.value + lastName.value
  },
  set(value) {
    [firstName.value, lastName.value] = value.split('')
  }
})

3. watch 和 watchEffect

  • watch:监听特定源
  • watchEffect:自动追踪依赖
const count = ref(0)

// watch:指定监听源
watch(count, (newVal, oldVal) => {
  console.log(`count 变化: ${oldVal}${newVal}`)
})

// watchEffect:自动追踪
watchEffect(() => {
  console.log(`当前 count: ${count.value}`)
})

生命周期

import { onMounted, onUnmounted } from 'vue'

onMounted(() => {
  console.log('组件已挂载')
})

onUnmounted(() => {
  console.log('组件已卸载')
})

自定义 Composables

这是 Composition API 最强大的特性:

// useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initialValue }

  return { count, increment, decrement, reset }
}

// 使用
import { useCounter } from './useCounter'

const { count, increment, decrement, reset } = useCounter(10)

优势总结

更好的代码组织:按功能逻辑分组 ✅ 更简洁的类型推断:更好的 TypeScript 支持 ✅ 更强的复用性:通过 composables 共享逻辑 ✅ 更灵活的树摇:未使用的代码可以被优化掉

总结

Composition API 是 Vue 3 的未来,它让代码:

  • 更清晰
  • 更易维护
  • 更易测试
  • 更易复用

如果你还在使用 Options API,是时候尝试 Composition API 了!

NPM 脚本避坑指南:如何优雅地区分 postinstall 的“开发”与“安装”环境?

作者 donecoding
2026年1月30日 22:28

前言

你是否遇到过这样的尴尬:给包写了一个 postinstall 钩子去做自动构建(如编译 C++ 模块或生成协议文件),结果自己本地开发跑 pnpm install 时,它也跟着在那编译半天,甚至因为环境问题报错卡死?

区分“宿主开发”与“依赖安装”环境是每个包作者的必修课。本文将从环境变量到路径特征进行全方位拆解,带你由浅入深寻找最优解。

🚀 如果你现在正急着解决这个问题:
请直接跳转到 方案三:特征路径扫描法。这是目前在独立包、Monorepo 以及跨平台场景下鲁棒性最强、最通用的解决方案。


方案一:基础路径比对法(INIT_CWD)

这是最直观的方案,利用 npm/pnpm 注入的环境变量 INIT_CWD

javascript

// scripts/postinstall.js
const path = require('path');

if (process.env.INIT_CWD && path.resolve(process.env.INIT_CWD) === path.resolve(process.cwd())) {
  console.log('宿主开发环境,跳过脚本');
  process.exit(0);
}

请谨慎使用此类代码。

  • 原理解析INIT_CWD 是你敲下安装命令时的路径,process.cwd() 是脚本执行时的路径。在最简单的独立包开发中,这两个路径通常是一致的。

  • 局限性

    • Monorepo 杀手:在 Monorepo 架构中,你在根目录运行 pnpm iINIT_CWD 指向项目根目录,但子包脚本执行时 cwd 指向 packages/xxx,两者永远不相等,导致拦截失效。
    • 软链接风险:在某些 OS 或特定包管理器下,路径的大小写或物理链接解析可能不一致,导致比对失败。

方案二:配置标志位法(npm_config_*)

通过包管理器注入的环境变量来判断当前的安装行为。

javascript

if (process.env.npm_config_global) {
  // 只有全局安装(-g)时执行
}

请谨慎使用此类代码。

  • 原理解析:利用包管理器在执行脚本时注入的 npm_config_ 系列变量。

  • 局限性

    • 粒度太粗:它能区分“全局”还是“本地”,但无法区分“本地开发源码”还是“作为他人项目的项目依赖”。
    • 兼容性碎片化:npm、yarn、pnpm 对这些变量的注入规则在 2026 年依然存在细微差异。

方案三:特征路径扫描法(全场最佳)

这是目前健壮性最高、也是社区最推崇的方案。它的逻辑非常纯粹:检查当前脚本执行的物理路径中是否包含 node_modules

javascript

const path = require('path');
const cwd = process.cwd();

// 核心逻辑:检查路径片段中是否包含标准的 node_modules 目录名
const isDependency = cwd.split(path.sep).includes('node_modules');

if (!isDependency) {
    console.log("🚀 检测到处于源码开发环境(独立包或 Monorepo),拦截 postinstall");
    process.exit(0);
}

// 只有被别人 add 之后,你的包才会出现在 node_modules 里
console.log("📦 正在作为依赖安装,执行初始化逻辑...");

请谨慎使用此类代码。

  • 为什么它既支持独立包又支持 Monorepo?

    • 独立包开发:你的路径是 /User/dev/my-pkg,不含 node_modules。拦截成功。
    • Monorepo 开发:你的子包路径是 /User/dev/repo/packages/my-pkg,依然不含 node_modules。拦截成功。
    • 别人安装时:无论对方怎么配置,你的包一定会被放置在对方项目的某个 node_modules 目录下。路径必含该关键字。逻辑触发。
  • 优势:完美规避了 INIT_CWD 在大仓中的路径偏移问题。


方案四:2026 现代包管理器配置法(pnpm 视角)

如果你和你的用户群体主要使用 pnpm v10+ ,除了代码层面的拦截,还可以利用 pnpm 的安全机制。

pnpm v10 默认会拦截未知的构建脚本。作为开发者,你可以在项目的 .npmrc 中通过 only-built-dependencies 显式控制。但为了给用户提供“开箱即用”的体验,在脚本内部通过 方案三 进行静默拦截依然是最佳实践。


总结:我该选哪种?

根据 2026 年的开发标准,建议在你的 postinstall.js 中使用以下终极兼容代码:

javascript

const path = require('path');

function shouldSkip() {
  const initCwd = process.env.INIT_CWD;
  const cwd = process.cwd();

  // 1. 尝试 INIT_CWD 比对(覆盖 90% 简单场景)
  if (initCwd && path.resolve(initCwd) === path.resolve(cwd)) return true;

  // 2. 特征路径扫描(覆盖 Monorepo 和 符号链接场景)
  // 只要路径里没出现 node_modules,就判定为是在自己家开发
  if (!cwd.split(path.sep).includes('node_modules')) return true;

  return false;
}

if (shouldSkip()) {
  process.exit(0);
}

// ... 执行真正的逻辑

请谨慎使用此类代码。

写在最后

一个优秀的 NPM 包不仅要有强大的功能,还要有“不打扰”的自修养。通过简单的几行环境判断,你可以让你的包在开发阶段轻量如初,而在生产安装时稳如泰山。

如果你觉得这篇文章解决了你的燃眉之急,欢迎点赞收藏!

文本编码转换器核心JS实现

作者 滕青山
2026年1月30日 22:18

工具网址和截图

在线工具网址:see-tool.com/encoding-co…

工具截图: 在这里插入图片描述

文本编码转换器功能核心实现解析

本文将深入探讨文本编码转换器(Text Encoding Converter)的核心 JavaScript 实现逻辑。该工具旨在实现普通文本与多种编码格式(如十六进制、二进制、Base64、Unicode 等)之间的相互转换。

1. 核心转换机制

整个工具的转换逻辑基于一个统一的入口函数 convert,它根据输入和输出格式,通过查找表(Lookup Table)调用相应的转换函数。

核心的字节处理依赖于浏览器原生的 TextEncoderTextDecoder API,这确保了对 UTF-8 的正确处理。

// 字符串转字节数组
const encoder = new TextEncoder();
const bytes = encoder.encode(text);

// 字节数组转字符串
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(new Uint8Array(bytes));

2. 格式转换实现细节

2.1 进制转换 (Hex, Binary, Octal, Decimal)

对于二进制、八进制、十六进制等数字格式,核心思路是将文本转换为字节数组,然后利用 Number.prototype.toString(radix) 将每个字节转换为对应的进制字符串。

以**Hex(十六进制)**为例:

textToHex: function(text, delimiter, prefix, uppercase) {
    const encoder = new TextEncoder();
    const bytes = encoder.encode(text);
    let hex = Array.from(bytes).map(b => {
        // 每个字节转16进制,并补齐2位
        let h = b.toString(16).padStart(2, '0');
        if (uppercase) h = h.toUpperCase();
        return prefix + h;
    });
    return hex.join(delimiter);
}

反向转换则是移除前缀和分隔符后,使用 parseInt(chunk, 16) 还原字节。

2.2 Base64 编码

JavaScript 原生的 btoaatob 函数只能处理 ASCII 字符。为了支持中文等 Unicode 字符,我们需要先对字符串进行编码处理。

文本转 Base64 的健壮实现:

textToBase64: function(text) {
    try {
        // 方法1: 使用 TextEncoder 获取字节,构造二进制字符串
        const encoder = new TextEncoder();
        const bytes = encoder.encode(text);
        let binary = '';
        bytes.forEach(byte => binary += String.fromCharCode(byte));
        return btoa(binary);
    } catch (e) {
        // 方法2: 降级方案,使用 encodeURIComponent 处理
        return btoa(unescape(encodeURIComponent(text)));
    }
}

Base64 转文本

base64ToText: function(base64) {
    const binary = atob(base64.trim());
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
    }
    const decoder = new TextDecoder('utf-8');
    return decoder.decode(bytes);
}

2.3 Unicode 转义与码点

处理 Unicode 转义(如 \u4E2D)时,关键在于正确处理代理对(Surrogate Pairs)。对于超出基本多文种平面(BMP, U+0000 到 U+FFFF)的字符(例如 Emoji),JavaScript 的字符串长度为 2。

我们使用 codePointAt(0) 来获取完整的码点值:

textToUnicodeEscape: function(text, delimiter, uppercase) {
    let result = [];
    for (let char of text) {
        let code = char.codePointAt(0);
        // 如果码点超过 0xFFFF,说明是代理对,JS 会将其视为两个字符
        if (code > 0xFFFF) {
            // 手动计算代理对(虽然 ES6 for-of 循环会自动正确迭代字符)
            const high = Math.floor((code - 0x10000) / 0x400) + 0xD800;
            const low = (code - 0x10000) % 0x400 + 0xDC00;
            // ... 转换为 \uXXXX\uXXXX 格式
            let h1 = high.toString(16).padStart(4, '0');
            let h2 = low.toString(16).padStart(4, '0');
            result.push('\\u' + h1);
            result.push('\\u' + h2);
        } else {
            // ... 普通字符转换为 \uXXXX
            let h = code.toString(16).padStart(4, '0');
            result.push('\\u' + h);
        }
    }
    return result.join(delimiter);
}

注意:使用 for...of 循环可以正确遍历字符串中的 Emoji 等宽字符,而普通的 for(let i=0;...) 则会把它们拆分成两个。

2.4 Punycode 转换

Punycode 是国际化域名(IDN)使用的编码。本项目采用了一个巧妙的利用浏览器原生 API 的方法,避免引入庞大的第三方库:

punycode: {
    encode: function(input) {
        try {
            // 利用 URL API 自动进行 Punycode 编码
            const url = new URL('http://' + input);
            return url.hostname.replace(/^xn--/, '');
        } catch (e) {
            // 降级处理...
        }
    },
    decode: function(input) {
        // 利用 URL API 自动解析
        const testUrl = 'http://' + input;
        const url = new URL(testUrl);
        return url.hostname;
    }
}

这是一个非常轻量且高效的实现方式。

2.5 HTML 实体

HTML 实体的转换相对直接,主要将字符转换为其对应的十进制或十六进制引用:

textToHtmlDecimal: function(text, delimiter) {
    let result = [];
    for (let char of text) {
        let code = char.codePointAt(0);
        result.push('&#' + code + ';');
    }
    return result.join(delimiter);
}

3. 字符详情分析

工具还提供了一个 getCharacterInfo 函数,用于分析单个字符的详细信息。它不仅返回字符本身,还计算其 Unicode 码点、UTF-8 字节序列等。

function getCharacterInfo(char) {
    const codePoint = char.codePointAt(0);
    const encoder = new TextEncoder();
    const utf8Bytes = encoder.encode(char);
    
    return {
        char: char,
        codePoint: codePoint, // 数字形式
        hex: codePoint.toString(16).toUpperCase(), // Hex 形式
        utf8: Array.from(utf8Bytes) // UTF-8 字节序列
              .map(b => b.toString(16).toUpperCase().padStart(2, '0'))
              .join(' ')
    };
}

总结

本项目的文本编码转换器通过充分利用 TextEncoder/TextDecoderURL API 以及 ES6+ 的字符串处理特性(如 codePointAtfor...of),以原生 JavaScript 实现了高效、轻量的多格式转换,无需依赖任何重型第三方库。

🌟 JavaScript `Symbol` 全面学习笔记(ES6+)

2026年1月30日 21:36

🌟 JavaScript Symbol 全面学习笔记(ES6+)

一句话定义
Symbol 是 ES6 引入的第七种原始数据类型(Primitive Type),用于创建唯一、不可变、匿名的标识符,主要解决对象属性名冲突问题,并支持元编程能力。


一、基础认知:它是什么?

维度 说明
数据类型 原始类型(typeof Symbol() === 'symbol') ✅ 与 string/number/boolean/null/undefined/bigint 并列(共 7 种) ❌ 不是对象(new Symbol() 报错)
创建方式 Symbol([description]) —— 函数调用,非构造函数description(可选):仅用于调试显示的字符串描述,不影响唯一性 • 每次调用 Symbol() 都返回全新且不相等的值
唯一性保证 Symbol() !== Symbol()(即使 description 相同) ✅ Symbol('a') !== Symbol('a') —— 描述相同 ≠ 值相同
const s1 = Symbol('id');
const s2 = Symbol('id');
console.log(s1 === s2); // false ✅
console.log(s1.toString()); // "Symbol(id)"
console.log(s2.toString()); // "Symbol(id)"

二、核心特性与用途

✅ 1. 作为对象的私有/隐藏属性键(Key)

  • 解决多人协作中对象属性名冲突问题(如 user.id vs user.id 被覆盖)
  • Symbol不会被常规遍历方法枚举,实现“弱私有”语义
遍历方式 是否访问 Symbol 键 说明
for...in 仅遍历可枚举的字符串键
Object.keys() 同上
Object.getOwnPropertyNames() 仅返回字符串键(含不可枚举)
JSON.stringify() 忽略 Symbol 键
Object.getOwnPropertySymbols() 唯一标准方法获取所有 Symbol 键
Reflect.ownKeys() 返回所有键(字符串 + Symbol)
const secret = Symbol('password');
const user = {
  name: '张三',
  email: 'zhang@example.com',
  [secret]: '123456' // 隐藏密钥
};

// ✅ 安全访问
console.log(user[secret]); // "123456"

// ❌ 不会被意外覆盖或暴露
user.secret = 'hacked'; // 新增字符串键,不影响 Symbol 键

// 🔍 查看 Symbol 键(需主动调用)
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(password)]
console.log(Reflect.ownKeys(user)); // ['name', 'email', Symbol(password)]

✅ 2. 全局注册表:Symbol.for()Symbol.keyFor()

当需要跨模块共享同一个 Symbol 时使用(避免重复创建):

方法 作用 示例
Symbol.for(key) 全局 Symbol 注册表中查找/创建 Symbol ✅ 同 key → 同 Symbol Symbol.for('shared') === Symbol.for('shared') // true
Symbol.keyFor(sym) 返回该 Symbol 在注册表中的 key(仅对 for() 创建的生效) Symbol.keyFor(Symbol.for('shared')) // 'shared'

⚠️ 注意:Symbol('a')Symbol.for('a') 完全不同

console.log(Symbol('a') === Symbol.for('a')); // false
console.log(Symbol.for('a') === Symbol.for('a')); // true

✅ 3. 内置 Symbol(Well-known Symbols)—— 实现协议接口

ES6 定义了一系列以 Symbol. 开头的预设 Symbol 值,用于自定义对象行为(鸭子类型协议):

Symbol 作用 触发场景 示例简写
Symbol.iterator 定义对象的默认迭代器 for...of, [...obj], Array.from() obj[Symbol.iterator] = function*(){...}
Symbol.toStringTag 自定义 Object.prototype.toString.call(obj) 输出 Object.prototype.toString.call(obj) obj[Symbol.toStringTag] = 'MyClass'"[object MyClass]"
Symbol.hasInstance 自定义 instanceof 行为 obj instanceof Constructor class MyClass { static [Symbol.hasInstance](x) { return x?.custom === true; } }
Symbol.isConcatSpreadable 控制 Array.prototype.concat() 是否展开 [].concat(arr) arr[Symbol.isConcatSpreadable] = false
Symbol.toPrimitive 定义对象转原始值逻辑(+, ==, String() 等) obj + 1, String(obj) obj[Symbol.toPrimitive] = (hint) => hint === 'number' ? 42 : 'foo'
Symbol.unscopables 指定 with 语句中屏蔽的属性(已废弃但需了解) with(obj){ prop } obj[Symbol.unscopables] = { prop: true }

💡 这些是 JS 元编程(Metaprogramming) 的基石,让对象能“参与语言级协议”。


三、重要注意事项与常见误区

误区 正确理解 为什么重要
Symbol 是“私有属性” ⚠️ 不是真正私有: • 可通过 Object.getOwnPropertySymbols() 获取 • 可通过 Reflect.ownKeys() 获取 • JSON.stringify() 会忽略,但 console.log() 仍可见 避免误以为 Symbol=private,实际是“不易被意外访问”,非安全隔离
Symbol 可以隐式转换为字符串 Symbol 不能隐式转换'' + Symbol() → TypeError String(Symbol()) ✅ 显式转换 防止静默错误,强制开发者显式处理
❌ 所有 Symbol 都是全局唯一的 ⚠️ Symbol.for() 创建的是全局注册的 SymbolSymbol() 才是绝对唯一 混淆二者会导致预期外的相等性(如误以为 Symbol('a') === Symbol.for('a')
❌ Symbol 键在 Object.assign() 中会被忽略 会被复制Object.assign({}, obj) 会复制 Symbol 键 JSON.stringify() 不同,需注意深拷贝兼容性
Symbol 可以作为 Map / WeakMap 的键 完全支持,且是理想选择: map.set(Symbol(), value) —— 无冲突、无泄漏风险 WeakMap + Symbol 是实现真正私有状态的经典组合
// ✅ WeakMap + Symbol 实现“真私有”
const privateData = new WeakMap();
class User {
  constructor(name) {
    privateData.set(this, { name }); // 存储私有数据
  }
  getName() {
    return privateData.get(this)?.name;
  }
}

四、与其他数据类型的对比速查表

特性 Symbol String Number Object
类型 Primitive Primitive Primitive Reference
唯一性 ✅ 每次调用新值 ❌ 字符串值相等即相等 ❌ 数值相等即相等 ❌ 引用不同即不等
可枚举性 ❌ 不被 for...in / keys() 枚举 ✅(自身可枚举属性)
可作为对象键 ✅(自动转字符串) ✅(自动转字符串)
可序列化(JSON) ❌(被忽略) ✅(仅可枚举自有属性)
可隐式转换 ❌(TypeError) ✅(+str, str + '' ✅(toString()/valueOf()

五、最佳实践建议

  1. 命名冲突防护

    const MY_LIB_PREFIX = Symbol('my-lib');
    const config = { [MY_LIB_PREFIX]: { debug: true } };
    
  2. 常量定义(替代字符串常量)

    export const STATUS = {
      PENDING: Symbol('PENDING'),
      FULFILLED: Symbol('FULFILLED'),
      REJECTED: Symbol('REJECTED')
    };
    // ✅ 比字符串更安全:STATUS.PENDING !== 'PENDING'
    
  3. 配合 WeakMap 实现私有状态(见上文)。

  4. 慎用 Symbol.for() :仅在明确需要跨模块共享 Symbol 时使用,避免污染全局注册表。

  5. 调试技巧

    • 使用 Symbol.description 获取描述(s.description === 'id'
    • console.log(s) 显示 Symbol(id),便于识别

六、延伸思考:为什么需要 Symbol?

问题 传统方案缺陷 Symbol 解决方案
对象属性名冲突 多人协作时 user.id 可能被覆盖 user[Symbol('id')] 确保唯一
需要“隐藏”配置项 _ 前缀(约定俗成,不强制) Symbol 键天然不被常规遍历发现
自定义对象行为(迭代、转换等) 无法干预语言内置操作 内置 Symbol 协议提供标准钩子
Map 键需唯一且无哈希碰撞 字符串键可能重复,对象键会转字符串 Symbol 作为键既唯一又高效

本质Symbol 是 JS 为元编程安全扩展而设计的底层原语,是语言演进的关键一步。


📌 附:快速测试代码(可直接运行)

// 验证唯一性
console.log(Symbol() === Symbol()); // false
console.log(Symbol('a') === Symbol('a')); // false

// 验证不可枚举性
const obj = { str: 'hello', [Symbol('sym')]: 'world' };
console.log(Object.keys(obj)); // ['str']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(sym)]

// 验证全局注册
console.log(Symbol.for('test') === Symbol.for('test')); // true
console.log(Symbol.for('test') === Symbol('test')); // false

 补充知识点

为什么必须写 [secret]?—— 破解你心中的核心困惑

你代码中这两行是理解 Symbol 的分水岭:

const secret = Symbol('password');     // ✅ 创建一个 Symbol 实例,存入变量 secret

// ❌ 错误:{ secret: '123' } → 创建字符串键 'secret'
const user1 = { secret: '123' };

// ✅ 正确:{ [secret]: '123' } → 使用变量 secret 的值(即 Symbol)作为键
const user2 = { [secret]: '123' };

🔍 深度解析:方括号 [ ] 是唯一“开门钥匙”

表格

写法 JS 如何解析 实际创建的键 是否访问到'123'
{ secret: '123' } 将 secret 视为字面量标识符 → 自动转为字符串 'secret' 字符串 'secret' user1.secret ✅ → '123' user1[secret] ❌ → undefined
{ [secret]: '123' } 方括号触发计算属性名(Computed Property Name)  → 先求值 secret 变量 → 得到 Symbol('password') Symbol('password') user2[secret] ✅ → '123' user2.secret ❌ → undefined(无字符串键 'secret'

💡 关键结论

  • . 和 { key: } 中的 key 永远只认字符串字面量
  • [ ] 是 JS 中唯一能将变量、表达式、Symbol 动态注入为属性名的语法
  • 因此: [secret] 不是“一种写法”,而是使用 Symbol 作键的强制语法要求。

🧩 类比助记(来自代码的场景)

想象 secret 是一把定制指纹锁的模具

  • { secret: ... } → 相当于在门上贴了张纸条,写着“secret”二字(任何人都能看见、修改);
  • { [secret]: ... } → 把模具按在门上,生成一个独一无二的物理锁孔,只有同一模具(即同一个 secret 变量)才能打开。

所以:没有 [ ],就没有 Symbol 键;没有 Symbol 键,就没有命名隔离与安全防护。

别再让用户等哭!React 懒加载:让组件 “躺平” 到要用时再干活

2026年1月30日 21:13

前言

之前咱们聊了普通网页的图片懒加载 —— 让图片 “藏” 在屏幕外摸鱼,滚到眼前再开工。那 React 项目里的组件和图片呢?总不能一打开页面就把所有组件的代码都拽过来吧?

想象下:你打开购物App页面,首页就加载了 “订单页”“个人中心” 的代码,结果用户根本没点进去 —— 这不是纯纯的浪费流量和性能吗?React 的懒加载,就是给组件和图片发 “摸鱼许可证”没出现在视野里,就别加载,躺平到要用时再干活

如果不了解懒加载的,我的上篇文章在这:

救命!我那加载慢到离谱的图片,终于被懒加载 “救活” 了

一、React 里的 “组件懒加载”:React.lazy + 动态导入

我们先创建一个Demo组件,并看这行代码:

// 别直接import Demo,改成动态导入 
const Demo = lazy(() => import('./Demo'));

这行代码的魔力在于:

  • 原本import Demo from './Demo'会在页面初始化时就加载 Demo 组件的代码
  • React.lazy+import()包裹后,Demo 的代码会被单独打包成一个小文件,只有当 Demo 组件要被渲染(比如滚到可视区域)时,这个小文件才会被下载、加载。

搭配Suspense(不然组件加载时会 “崩”)

不过光用lazy还不够 —— 组件加载需要时间,加载过程中页面会报错。得用Suspense当 “占位符”:

import { lazy, Suspense } from 'react';
const Demo = lazy(() => import('./Demo'));

export default function App() {
    return (
        <div>
            {/* 一堆内容... */}
            <Suspense fallback={<div>组件加载中...</div>}>
                <Demo />
            </Suspense>
        </div>
    );
}

fallback里写的就是组件加载时的 “Loading 占位”,比如转圈圈、提示文字,用户体验会更友好。

二、React 里的 “图片 / 内容懒加载”:react-lazyload 或 自定义组件

组件懒加载管的是 “代码包”,图片 / 内容懒加载管的是 “可视区域内才渲染内容”。我常用的有两种方案:

方案 1:直接用react-lazyload库(现成的轮子)

仓库地址: www.npmjs.com/package/rea…

下载方法:npm i react-lazyload

导入的LazyLoad就是这个库,直接包在图片外面:

import LazyLoad from 'react-lazyload';

// 在App.jsx里用:
<LazyLoad placeholder={<div>图片加载中...</div>} offset={300}>
    <img src="https://xxx.png" alt="" />
</LazyLoad>
  • placeholder:图片加载前的占位内容;
  • offset={300}提前 300px 开始加载图片(用户还没滚到,图片已经在偷偷加载了,体验更丝滑)。

完整代码(为了更好展现效果,我放了50个p标签):

import LazyLoad from 'react-lazyload';
import { lazy } from 'react'
// import Demo from './Demo'
import MyLazyLoad from './MyLazyLoad'

// 组件没有出现在可视区域时,组件代码都不会被加载,被import('./Demo')包裹的模块会单独打包
const Demo = lazy(() => import('./Demo'));

export default function App() {
    return (
        <div>
            {/* <Demo></Demo> */}
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <p>xxx</p>
            <MyLazyLoad placeholder={<div>loading...</div>} width='100px' onContentVisible={() => console.log('onContentVisible')} onClose={() => console.log('onClose')}>
                {/* <img src="https://inews.gtimg.com/om_bt/OG4Cnt2SgXAuTj-Vv77ASGszUj1BwOhUXtBCplSlBfQmAAA/641" alt="" /> */}
                <Demo></Demo>
            </MyLazyLoad>
            <LazyLoad placeholder={<div>loading...</div>} offset={300}>
                <img src="https://inews.gtimg.com/om_bt/Os3eJ8u3SgB3Kd-zrRRhgfR5hUvdwcVPKUTNO6O7sZfUwAA/641" alt="" />
            </LazyLoad>
        </div>
    )
}

可以看到最开始的界面显示的都是loading

image.png

我们慢慢往下滑:

image.png

滑到Demo照片

image.png

方案 2:自己写一个懒加载组件(比如我自己写的MyLazyLoad.jsx

我写的MyLazyLoad组件,核心就是用IntersectionObserver实现 “可视区域检测”,逻辑和之前普通网页的懒加载是通的:

// MyLazyLoad.jsx核心代码
import { useState, useRef, useEffect } from 'react';

export default function MyLazyLoad(props) {
    const { placeholder, children, offset, onContentVisible } = props;
    const [visible, setVisible] = useState(false);
    const containerRef = useRef(null);
    const elementObserver = useRef();

    // 初始化IntersectionObserver
    useEffect(() => {
        const options = {
            threshold: 0,
            rootMargin: typeof offset === 'number' ? `${offset}px` : '0px'
        };
        // 监听元素是否进入可视区域
        elementObserver.current = new IntersectionObserver(lazyLoadHandler, options);
        const node = containerRef.current;
        elementObserver.current.observe(node);

        // 卸载时停止监听
        return () => {
            elementObserver.current.unobserve(node);
        };
    }, []);

    // 元素进入可视区域后的处理
    function lazyLoadHandler(entries) {
        const [entry] = entries;
        if (entry.isIntersecting) {
            setVisible(true); // 显示真实内容
            onContentVisible?.(); // 触发“内容可见”的回调
            elementObserver.current.unobserve(containerRef.current); // 停止监听
        }
    }

    // 没进入可视区域就显示占位,进入了就显示真实内容
    return (
        <div ref={containerRef}>
            {visible ? children : placeholder}
        </div>
    );
}

用的时候和react-lazyload差不多,把要懒加载的内容包进去:

<MyLazyLoad placeholder={<div>加载中...</div>} offset={300}>
    <img src="https://xxx.png" alt="" />
    {/* 甚至可以包组件:<Demo /> */}
</MyLazyLoad>

三、“组件懒加载”+“内容懒加载”:双剑合璧

把前面的技术结合起来,就是 React 项目里的 “终极懒加载”

import { lazy, Suspense } from 'react';
import MyLazyLoad from './MyLazyLoad';

// 组件代码懒加载
const Demo = lazy(() => import('./Demo'));
export default function App() {
    return (
        <div>
            {/* 一堆内容... */}
            {/* 组件代码+组件内容 都懒加载 */}
            <Suspense fallback={<div>组件包加载中...</div>}>
                <MyLazyLoad placeholder={<div>组件内容加载中...</div>}>
                    <Demo />
                </MyLazyLoad>
            </Suspense>
        </div>
    );
}
  • 第一步:用户滑到 Demo 区域前Demo的代码包不会下载
  • 第二步:代码包下载完成后,MyLazyLoad会等 Demo 进入可视区域,再渲染 Demo 的内容;
  • 全程都有占位提示,用户不会看到 “空白” 或 “报错”

四、总结

  • React 懒加载分两类React.lazy+Suspense实现组件代码按需下载react-lazyload/ 自定义组件实现内容按需渲染
  • 核心价值:减少初始化资源开销,提升页面加载和渲染性能。

结语

普通网页的懒加载是让图片 “摸鱼”React 的懒加载则是给组件和代码都发了 “摸鱼许可证”—— 不用一开场就全员到岗,该躺平时躺平,该干活时再发力。

就好比经营一家店,不用把所有商品都堆在门口 (初始化加载所有代码),而是把暂时没人要的商品放进仓库 (单独打包组件),等顾客问到了再取出来,摆上货架(渲染内容)—— 既省了门口的空间 (页面加载性能),又不会让顾客等太久 (优化用户体验)

用 TypeScript 定义数据契约,用 Zustand 实现业务流转

2026年1月30日 20:49

📖 引言:为什么我们需要状态管理?

在 React 的世界里,我们经常面临一个核心问题:跨组件通信

当你在组件 A 中登录了用户,如何让组件 B(比如导航栏)立刻知道“用户已登录”并显示用户名?如果数据只存在组件内部(useState),它就是“私有的”。我们需要一个全局可访问、响应式更新的“公共钱包”。

这就是状态管理器(如 Redux, Zustand)的用武之地。在本文中,我们将以 Zustand 为核心,结合 TypeScript,深入剖析那些看似简单却极易踩坑的细节。

我们将通过构建一个待办事项(Todo)应用用户登录系统来串联所有知识点。


第一模块:基石篇——TypeScript 类型定义的艺术

在开始管理状态之前,我们必须先定义好数据的“形状”。这就像盖房子前先画图纸。

1.1 接口(Interface)的定义与复用

我们首先定义应用中最核心的两种数据:Todo(待办事项)和 User(用户)。

// 定义一个 Todo 的结构
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// 定义一个 User 的结构
export interface User {
  id: number;
  username: string;
  password: string;
}

💡 核心解析:

  • interface:它不是类(Class),它只是一个“契约”。它告诉编译器:“任何标为 Todo 的东西,必须有 id(数字)、title(字符串)和 completed(布尔值)”。
  • 类型安全:如果你试图给 title 赋值一个数字,TypeScript 会在编译阶段报错,而不是等运行时崩溃。

1.2 状态机思维:定义 Store 的结构

在 Zustand 中,Store 不仅仅存数据,还存修改数据的方法。我们需要定义一个“状态机”接口。

// 定义 TodoState:包含数据和行为
export interface TodoState {
  todos: Todo[]; // 数据:待办事项列表
  addTodo: (text: string) => void; // 行为:添加方法
  toggleTodo: (id: number) => void; // 行为:切换完成状态
  removeTodo: (id: number) => void; // 行为:删除方法
}

⚠️ 易错点 1:忘记定义函数类型
很多新手只定义数据(todos: Todo[]),却忘了定义函数。这会导致在组件中调用 addTodo 时,TypeScript 提示“找不到该属性”。在 Zustand 中,State 是“数据+逻辑”的集合体。

1.3 复杂状态的陷阱:嵌套与引用

看下面这个 UserState 的定义:

interface UserState {
  isLoggin: boolean; // 拼写错误陷阱!
  login: (user: { username: string; password: string }) => void;
  logout: () => void;
  user: User | null; // 关键点:可为空
}

⚠️ 易错点 2:可为空(Null)的处理
注意 user: User | null

  • 场景:应用刚启动时,用户还没登录。此时 userundefinednull
  • 后果:如果你只定义 user: User,那么你必须在初始化时提供一个完整的 User 对象(比如 user: {id: 0, username: '', password: ''})。这不仅麻烦,还可能导致逻辑错误(你以为用户登录了,其实只是默认值)。
  • 最佳实践:对于“可能不存在”的数据,永远加上 | null| undefined

❓ 答疑解惑环节 1

Q: 为什么要专门写一个 interface TodoState,直接在 create 里写不行吗?
A: 可以,但不推荐。 分离接口(Type)和实现(Logic)是大型项目的最佳实践。

  1. 可读性:看接口一眼就知道这个模块有哪些数据和方法。
  2. 复用性:如果另一个 Store 需要引用 Todo 的数据结构,可以直接 extends TodoState
  3. 维护性:当逻辑变得复杂时,分离类型能让代码不那么臃肿。

Q: User | nullUser | undefined 有什么区别?
A: 在 JavaScript 运行时,nullundefined 通常被视为“无值”。但在 TypeScript 语义上:

  • null 通常表示“有意的空值”(比如用户注销了,我特意把 user 设为 null)。
  • undefined 通常表示“未初始化”。
    在 Zustand 初始化时,两者效果一样。建议团队统一风格,通常推荐用 null 表示“无”。

第二模块:进阶篇——Zustand 的核心机制与持久化

定义好类型后,我们来创建 Store。Zustand 的核心理念是极简

2.1 创建 Store:Set 与 Get 的哲学

以计数器为例:

export const useCountStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}))

💡 核心解析:

  • create<CounterState> :泛型注入,让 IDE 能自动提示 state.count
  • set 函数:这是 Zustand 的心脏。你不能直接修改 state(如 state.count++),你必须通过 set 告诉 Zustand “我要一个新的 state”
  • 函数式更新set((state) => ...)。当你需要基于旧状态计算新状态时(如加减),必须用函数形式,以防止闭包陷阱。

2.2 异步与中间件:让数据“过夜”

看下面的用户登录 Store:

import { persist } from 'zustand/middleware';

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLoggin: false,
      user: null,
      login: (user) => set({ isLoggin: true, user: { ...user, id: 1 } }),
      logout: () => set({ isLoggin: false, user: null })
    }),
    { name: 'user' } // 持久化的 key
  )
)

⚠️ 易错点 3:中间件的执行顺序
注意 persist 的写法。它是包裹在 create 的参数外面的。

  • 逻辑:Zustand 支持中间件(Middleware)。persist 是一个中间件,它监听所有的 set 操作,并自动将数据存入 localStorage
  • 坑点:如果不加 persist,刷新页面后,Store 会重置为初始值(0 或 null)。加了 persist 后,数据就像饼干一样“持久”保存了。
  • 配置{ name: 'user' } 对应浏览器 LocalStorage 里的 Key 名字。

2.3 状态更新的“不可变性”(Immutability)

TodoStateaddTodo 中:

addTodo: (text) => set((state) => ({ 
  todos: [...state.todos, { id: Date.now(), title: text, completed: false }] 
})),

⚠️ 易错点 4:直接修改数组
错误写法

const newTodos = state.todos;
newTodos.push({id: 1, title: text, completed: false});
set({ todos: newTodos }); // 大错特错!这修改了原始引用

后果:React 的更新机制依赖于“引用变化”。如果你直接 pushstate.todos 的内存地址没变,React 会认为数据没变,导致页面不刷新!
正确做法:使用扩展运算符 ... 创建一个新数组。

❓ 答疑解惑环节 2

Q: 为什么 login 函数里要写 { ...user, id: 1 }
A: 这是为了数据净化

  1. 场景:用户在登录表单输入 usernamepassword,这些数据传给 login
  2. 问题:表单数据可能没有 id 字段。
  3. 解决:在存入 Store 之前,利用对象扩展语法,给它加上一个 id: 1。这样保证存入的 user 符合 User 接口的定义(必须有 id)。这是一种防御性编程。

Q: persist 中间件会导致性能问题吗?
A: 在大多数场景下不会

  • persist 默认是在 set 之后异步写入 LocalStorage 的,不会阻塞主线程。
  • 注意:不要把太大(MB 级)的数据放进去,LocalStorage 读写较慢,且有容量限制(通常 5-10MB)。

第三模块:实战篇——React 组件的连接与渲染

有了 Store,现在看组件如何使用。

3.1 订阅与解构:最小化重渲染

App.tsx

function App() {
  // 1. 从 Store 中“取出”需要的变量和函数
  const { count, increment, decrement, reset } = useCountStore();
  
  return (
    <div className="card">
      {/* 2. 绑定事件 */}
      <button onClick={increment}> count is {count} </button>
      <button onClick={decrement}> -1 </button>
      <button onClick={reset}> Reset </button>
    </div>
  )
}

💡 核心解析:

  • useCountStore:这是一个 Hook。它让组件订阅了 Store 的变化。
  • 解构赋值:我们只取了 countincrement 等。Zustand 智能地做到了**“选择器(Selector)”机制。如果 Store 里还有其他数据(比如 todos)变了,但 count 没变,这个 App 组件不会重新渲染**。这是 Zustand 比 Redux 性能好的关键点之一。

3.2 事件处理与状态同步

⚠️ 易错点 5:异步操作中的状态滞后
假设你在 onClick 里连续调用 increment() 三次:

// 错误预期
onClick={() => {
  increment(); // 假设 count 从 0 变 1
  increment(); // 期望基于 1 变 2
  increment(); // 期望基于 2 变 3
}}

实际结果:可能只加了 1。

  • 原因set 是异步的。上面的代码在一次事件循环中连续触发了三次 set,它们可能都基于最初的 state(0)进行计算。
  • 解决:如果必须连续修改,应该在一次 set 里完成:
set((state) => ({ count: state.count + 3 }))

3.3 表单与状态的双向绑定

虽然你的代码中没有复杂的表单,但在 Todo 应用中,通常会有:

// 伪代码
<input 
  value={newTitle} 
  onChange={(e) => setNewTitle(e.target.value)} 
/>
<button onClick={() => addTodo(newTitle)}>Add</button>

这里 newTitle 是组件的本地状态(useState),而 todos 是全局状态。区分“本地 UI 状态”和“全局业务状态”是架构设计的关键。

❓ 答疑解惑环节 3

Q: 为什么在组件里不需要 useEffect 来监听 Store 的变化?
A: 因为 Zustand 的 Store Hook(如 useCountStore)内部已经帮你做了这件事。
当你调用 useCountStore() 时,它不仅返回当前值,还注册了一个监听器。一旦 Store 更新,它会强制组件重新执行 render。你只需要像使用普通变量一样使用它即可。

Q: 如果我想在用户登录成功后跳转页面,该在哪写代码?
A: 不要在 Store 里写跳转逻辑(如 window.location.href),这会让 Store 依赖浏览器 API,变得难以测试。
最佳实践

  1. Store 只负责把 isLoggin 改为 true

  2. 在组件中使用 useEffect 监听 isLoggin

    useEffect(() => {
      if (isLoggin) navigate('/home'); // 假设用了 react-router
    }, [isLoggin]);
    

第四模块:牛刀小试

💼 1:Zustand 与 Redux 的本质区别是什么?什么时候该用 Zustand?

参考回答思路:

  • 范式差异:Redux 强调“纯函数”、“Action”、“Reducer”和“单一状态树”,样板代码多,适合超大型复杂项目(如需要时间旅行调试)。Zustand 强调“Hooks 风格”和“中间件”,几乎没有样板代码,更符合现代 React 开发者的直觉。
  • 性能机制:Redux 默认使用 connectuseSelector 需要手动优化(shallowEqual)。Zustand 天然基于选择器(Selector),只有订阅的字段变化才会重渲染,性能开箱即用。
  • 结论:除非项目有极强的调试回溯需求或团队规范强制使用 Redux,否则对于中小型项目,Zustand 是更高效、更简洁的选择。

💼 2:在你的代码中,user: { ...user, id: 1 } 这一行为什么要用扩展运算符?直接传 user 不行吗?

参考回答思路:
这考察的是不可变性(Immutability)类型安全

  1. 类型补充:传入的 user 参数可能只包含 usernamepassword(来自表单),但 Store 定义的 User 接口要求必须有 id。扩展运算符允许我们在保留原有数据的同时,注入缺失的 id
  2. 引用隔离:直接使用传入的 user 对象可能会导致引用污染。使用 {...user} 创建了一个新对象,保证了 Store 内部状态的纯净,避免外部对象后续修改影响到 Store。

💼 3:如果我们的应用需要做服务端渲染(SSR),你写的 persist 代码会有问题吗?如何解决?

参考回答思路:
会有问题。

  • 原因persist 默认使用浏览器的 localStorage。在服务端(Node.js)环境中,没有 window 对象,也没有 localStorage。如果在 SSR 首屏渲染时直接执行这段代码,服务端会报错 ReferenceError: window is not defined

  • 解决方案

    1. 动态导入:在 SSR 环境下,延迟加载包含 persist 的 Store,或者在 Store 初始化时检测环境。

    2. 自定义 Storage:给 persist 传入一个自定义的 storage 配置。在服务端使用内存存储或 cookie,在客户端使用 localStorage。例如:

      const customStorage = {
        getItem: (name) => {
          // 服务端逻辑:从 cookie 读取
          // 客户端逻辑:从 localStorage 读取
        },
        setItem: (name, value) => {
          // 客户端写入 localStorage
        }
      }
      

🌟 结语

编程不仅仅是写代码,更是逻辑的构建与错误的规避

通过这篇博客,我希望你不仅学会了如何使用 Zustand 和 TypeScript,更重要的是理解了 “状态是唯一的真相源(Source of Truth)” 这一理念。

记住:优秀的代码不是写出来的,是重构出来的。 每一次对“易错点”的规避,都是你编程内功的一次提升。

祝你在前端开发的道路上,代码无 Bug,人生无坑!

2026年了,你还在用传统滚动监听做懒加载?试试这种现代方案

作者 小金鱼Y
2026年1月30日 20:30

前言

当我们进入某个包含大量图片的网站时,网页内的图片却迟迟加载不出来,给我们带来了极差的用户体验,而懒加载技术,正是解决这类问题的有效手段之一。

传统的滚动监听懒加载方式在过去发挥了重要作用,但随着技术的不断革新,2026年的今天,我们有了更为现代、高效的方案可供选择。接下来,就让我们深入探究一番。

什么是懒加载 ?

懒加载(Lazy Loading)是前端开发中延迟加载非关键资源的优化技术,通过仅在资源进入用户可视区域时加载,显著减少初始请求数、降低内存占用,提升页面加载速度和用户体验。

核心原理:按需加载的本质

懒加载的核心逻辑基于视窗检测和条件触发,分为三步:

  1. 资源暂存:将真实的资源地址(如图片的链接)存储在HTML5的自定义属性(如data-src)中,而非直接赋值给src,避免初次加载。
  2. 视窗检测:通过监听滚动事件或者使用浏览器API(如Intersection Observer),判断资源是否进入用户的可视区域。
  3. 动态加载:当资源进入视窗内,将data-src的值赋值给src,触发浏览器加载真实资源。

其本质就是将“一次性全量加载”转化为“按需增量加载”,减少初始页面的资源请求和渲染压力。

实现方法

1. 原生JavaScript实现 - 传统滚动监听

定义核心变量

image.png

  • 作用:获取当前浏览器窗口的可视高度(单位:像素),用于判断图片是否进入用户视野。

懒加载核心函数

image.png

  • getBoundingClientRect(): 返回元素的位置信息(top/bottom/left/right等),是判断元素是否在视口内的核心依据。
  • 自定义属性data-origin 存储图片的真实URL(避免初始加载),lazyload 标记图片为“待加载状态”

初始化与滚动监听

image.png

  • 初始化执行:页面加载完成后立即调用 lazyLoad(),确保首屏图片正常显示。
  • 滚动监听:当用户滚动页面时,持续判断后续图片是否进入视口,但直接监听 scroll 会导致事件触发过于频繁(每秒可能触发数十次),因此需要节流函数优化。

节流函数 - throttle()

image.png

  • 核心作用:限制 lazyLoad 的执行频率(示例中为每1000毫秒执行一次),避免滚动时频繁触发函数导致性能浪费。
  • 闭包特性preTime 变量被闭包保存,每次滚动时都会对比“当前时间”与“上次执行时间”,确保在规定间隔内只执行一次

完整代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        img {
            display: block;
            height: 200px;
            margin-bottom: 20px;
        }
    </style>
</head>

<body>
    <img src="" lazyload="true" data-origin="图片地址" alt="">
    <!-- 多张图片省略 -->
    <script>
        // 可视区域的高度
        const viewHeight = window.innerHeight

        function lazyLoad() {
            const imgs = document.querySelectorAll('img[lazyload]')
            // 判断哪些 img 在可视区域内
            imgs.forEach(item => {
                // item是否在可视区域内
                const rect = item.getBoundingClientRect() 
                if (rect.top < viewHeight && rect.bottom >= 0) {
                    item.src = item.dataset.origin
                    item.removeAttribute('lazyload')
                }
            })
        }
        
        lazyLoad()

        window.addEventListener('scroll', throttle(lazyLoad, 1000))//第二个参数必须是一个函数体

        //节流-在规定的时间内只触发一次
        function throttle(fn, await) {
            let preTime = Date.now()
            return () => {
                const nowTime = Date.now()
                if (nowTime - preTime >= await) {
                    fn()
                    preTime = nowTime
                }
            }
        }
    </script>
</body>
</html>

2. 现代浏览器方案 - Intersection Observer API

Intersection Observer基于现代浏览器原生 API 的图片懒加载方案,相比传统的滚动监听+节流,它更高效、更简洁,核心逻辑是由浏览器自动监听图片是否进入视口,无需手动处理滚动事件。

创建观察器实例

image.png

  • item.isIntersecting : 布尔值,直接表示元素是否进入视口(无需手动计算位置)。
  • item.target : 当前被观察的 DOM 元素(即图片)。
  • io.unobserve(item.target) : 停止对已加载图片的观察,避免不必要的资源消耗。

批量观察待加载图片

image.png

将页面中所有标记为 lazyload 的图片注册到观察器中,浏览器会自动监听它们的视口可见性变化。

完整代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        img {
            display: block;
            height: 200px;
            margin-bottom: 20px;
        }
    </style>
</head>

<body>
    <img src="" lazyload="true" data-origin="图片地址" alt="">
    <!-- 多张图片省略 -->

    <script>
        const io = new IntersectionObserver(
            (entries) => {
                entries.forEach(item => {
                    if (item.isIntersecting) {
                        item.target.src = item.target.dataset.origin
                        item.target.removeAttribute('lazyload')
                        io.unobserve(item.target)
                    }
                })
            }
        )

        const imgs = document.querySelectorAll('img[lazyload]')
        imgs.forEach(img => {
                io.observe(img)
            }
        )
    </script>
</body>

</html>

优点:性能最优:浏览器异步处理,不阻塞主线程。代码极简:无需手动计算位置或处理滚动;自动停止观察:加载后立即解除监听,节省资源。

缺点:兼容性有限:IE 浏览器不支持(需降级方案)

总结

懒加载技术作为优化网页性能的有效手段,在提升用户体验方面发挥着重要作用。从原理上看,它按需加载图片,避免资源浪费,显著减少页面初始加载时间。

原生JavaScript方案凭借基础DOM操作与事件监听,具备良好兼容性与灵活性,虽需手动处理细节,但能精准把控加载逻辑;

Intersection Observer则更为简洁高效,自动监听元素可见性,大幅降低开发工作量,且性能表现出色。

在实际应用中,开发者可依据项目需求、浏览器兼容性等因素灵活选择。随着互联网对性能要求持续提升,懒加载技术将不断演进,为打造更流畅、高效的网页体验贡献力量 ✨。

市场行情收藏列表联动的状态管理

作者 Naomi_
2026年1月30日 19:52

一、架构设计与技术选型

1.1 Markets 模块核心架构

自定义 Hook: useCollect(核心状态管理)
// apps/my-page/hooks/use-collect.ts
export const useCollect = () => {
  const [collectInfo, setCollectInfo] = useState<CollectInfo>();
  const [spotCollectState, setSpotCollectState] = useState<string[]>([]);
  const [contractCollectState, setContractCollectState] = useState<string[]>([]);
  const [pendingUnfavorites, setPendingUnfavorites] = useState<Set<string>>(new Set());

  // 初始化收藏数据
  const initCollectInfo = async () => {
    const res = await getCollectInfo();
    if (res.ret_code === 0) {
      setCollectInfo(res.result.preferences);
      setSpotCollectState(res.result.preferences.spotBookSymbolSequence?.split(',').filter(Boolean) || []);
      setContractCollectState(res.result.preferences.bookSymbolSequence?.split(',').filter(Boolean) || []);
    }
  };

  // 刷新收藏数据(切换 Tab 时调用)
  const refreshCollectInfo = async () => {
    await initCollectInfo();
    setPendingUnfavorites(new Set());  // 清空待移除列表
  };

  // 点击收藏/取消收藏
  const clickCollect = async (data: MarketData, marketType: 'spot' | 'contract') => {
    if (!data?.tradingPair) return;

    const normalizedPair = normalizeTradingPair(data.tradingPair);  // 移除斜杠
    const currentList = marketType === 'spot' ? spotCollectState : contractCollectState;
    const isCurrentlyCollected = currentList.includes(normalizedPair);

    // 计算新的收藏列表
    const newList = isCurrentlyCollected
      ? currentList.filter(item => item !== normalizedPair)
      : [...currentList, normalizedPair];

    // 乐观更新本地状态(立即反馈用户)
    if (marketType === 'spot') {
      setSpotCollectState(newList);
    } else {
      setContractCollectState(newList);
    }

    // 异步同步到服务端
    await updateCollectInfo({
      upsert_keys: {
        spotBookSymbolSequence: marketType === 'spot' ? newList.join(',') : spotCollectState.join(','),
        bookSymbolSequence: marketType === 'contract' ? newList.join(',') : contractCollectState.join(',')
      }
    });

    // 埋点记录
    track?.('MyPageClick', {
      section_type: 'Markets',
      button_name: isCurrentlyCollected ? 'Remove_from_Favorites' : 'Add_to_Favorites',
      page_type: 'favorites_candidate',
      page_name: marketType
    });
  };

  return {
    collectInfo,
    spotCollectState,
    contractCollectState,
    pendingUnfavorites,
    initCollectInfo,
    refreshCollectInfo,
    clickCollect,
    setPendingUnfavorites
  };
};
  • 初始化的时候调用API得到collectInfo,设置CollectState的初始状态
  • 用户添加收藏的时候,调用接口,再更新本地状态和collectInfo
  • 显示UI的时候,优先显示本地状态,备用collectInfo
  • 延迟同步机制:通过 pendingUnfavorites 实现"不立即消失"需求
  • 状态分离:Spot 和 Contract 独立管理,避免数据混淆

1.2 市场数据共享(PC 端使用 Context)

// apps/my-page/contexts/MarketDataContext.tsx
export const MarketDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
  const marketData = useMarket({
    needSpot: true,
    needContract: true,
    defaultType: 'spot',
    sortRule: [
      { label: 'hot', tab: 'hot' },
      { label: 'newListing', tab: 'newListing' },
      // ... 其他排行榜
    ]
  });

  return (
    <MarketDataContext.Provider value={marketData}>
      {children}
    </MarketDataContext.Provider>
  );
};

// 子组件使用
const { spot, contract, tabKey } = useMarketData();

优势:

  • 不在顶层使用useMarket hook是因为顶层的父组件会根据市场种类(spot或衍生品)和tab的state的变化而重新渲染,如果继续使用hook会导致重复发送请求.使用context就只是在Provider里请求一次
  • 解决 Tab 切换时 Spot/Contract 数据错乱问题:

现象:

  • 在 Hot tab 可以正确显示 Spot 数据
  • 切换到 Favorites tab 后,显示的是 Contract 数据
  • 再切回 Hot tab,数据又变了

根因分析:

// ❌ 错误实现:每个 Tab 独立调用 useMarket
const HotTab = () => {
  const { spot } = useMarket();  // 独立实例
  return <div>{spot.ranking.hot}</div>;
};

const FavoritesTab = () => {
  const { contract } = useMarket();  // 另一个独立实例
  return <div>{contract.spotSymbolList}</div>;
};

解决方案就是上述的useContext数据共享

1.3 交易对格式标准化

// 移除斜杠统一格式
const normalizeTradingPair = (pair: string) => pair?.replace(/\//g, '') || '';

// 示例
'BTC/USDT' => 'BTCUSDT'  // 存储格式
'BTCUSDT' => 'BTCUSDT'   // 已标准化格式

原因:

  • 现货市场返回 BTC/USDT,合约市场返回 BTCUSDT
  • 需要统一格式才能正确比较和存储

二、核心功能实现

2.1 Favorites 空状态默认推荐hot榜单前 6 条数据

组件结构:

// apps/my-page/containers/newMyPage/Markets/NoFavorites/index.tsx
const NoFavorites = ({ marketType, onAddToFavorites }) => {
  const { spot, contract } = useMarketData();
  const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());

  // 获取 Hot 榜单前 6 条数据
  const hotData = useMemo(() => {
    const data = marketType === 'spot' ? spot : contract;
    return data?.ranking?.hot?.data?.slice(0, 6) || [];
  }, [spot, contract, marketType]);

  // 默认全选
  useEffect(() => {
    const allIds = hotData.map(item => normalizeTradingPair(item.symbolName));
    setSelectedItems(new Set(allIds));
  }, [hotData]);

  // 添加到收藏
  const handleAddToFavorites = async () => {
    const selectedArray = Array.from(selectedItems);
    await onAddToFavorites(selectedArray);

    // 埋点
    track?.('MyPageClick', {
      page_type: 'favorites_candidate',
      page_name: marketType,
      button_name: 'Add_to_Favorites'
    });
  };

  return (
    <div className={styles.noFavorites}>
      <div className={styles.grid}>
        {hotData.map(item => (
          <NoFavoritesItem
            key={item.symbol}
            data={item}
            selected={selectedItems.has(normalizeTradingPair(item.symbolName))}
            onToggle={(id) => {
              const newSet = new Set(selectedItems);
              newSet.has(id) ? newSet.delete(id) : newSet.add(id);
              setSelectedItems(newSet);
            }}
          />
        ))}
      </div>
      <Button onClick={handleAddToFavorites}>
        Add to Favorites ({selectedItems.size})
      </Button>
    </div>
  );
};

Grid 布局样式:

// NoFavorites/index.module.less
.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin-bottom: 24px;

  @media (max-width: 768px) {
    grid-template-columns: 1fr;  // H5 单列
    gap: 12px;
  }
}

2.2 星标交互实现

MarketDataTable 组件:

// /Markets/MarketDataTable/index.tsx
const MarketDataTable = ({ marketType, tabValue }) => {
  const { spot, contract } = useMarketData();
  const { spotCollectState, contractCollectState, clickCollect } = useCollect();

  // 获取当前市场的收藏列表
  const collectState = marketType === 'spot' ? spotCollectState : contractCollectState;

  // 过滤数据:favorites tab 只显示收藏项
  const filteredList = useMemo(() => {
    const data = marketType === 'spot' ? spot : contract;
    const rawData = tabValue === 'favorites'
      ? (marketType === 'spot' ? data?.spotSymbolList : data?.contractSymbolList)
      : data?.ranking?.[tabValue]?.data;

    return rawData?.map(item => ({
      id: item.symbol,
      tradingPair: item.symbolName,
      price: item.price,
      change24h: Number(item.change),
      volume24h: item.turnover24hUSD,
      icon: marketType === 'spot' ? item.icon : item?.lightIcon,
      link: item.link,
      isCollected: collectState.includes(normalizeTradingPair(item.symbolName))  // 收藏状态
    }));
  }, [spot, contract, tabValue, marketType, collectState]);

  // 分页处理
  const paginatedData = useMemo(() => {
    return filteredList?.slice((currentPage - 1) * 6, currentPage * 6) || [];
  }, [filteredList, currentPage]);

  return (
    <div className={styles.tableWrapper}>
      <table>
        <thead>
          <tr>
            <th>Trading Pair</th>
            <th>Price</th>
            <th>24h Change</th>
            <th>24h Volume</th>
            <th>Action</th>
          </tr>
        </thead>
        <tbody>
          {paginatedData.map(row => (
            <tr key={row.id}>
              <td>
                <div className={styles.pairCell}>
                  {/* 星标图标 */}
                  <Tooltip title={row.isCollected ? 'Remove from Favorites' : 'Add to Favorites'}>
                    <div
                      className={styles.starIcon}
                      onClick={() => clickCollect(row, marketType)}
                    >
                      {row.isCollected ? <StarFilled /> : <StarOutlined />}
                    </div>
                  </Tooltip>
                  <img src={row.icon} alt={row.tradingPair} />
                  <span>{row.tradingPair}</span>
                </div>
              </td>
              <td>{row.price}</td>
              <td className={row.change24h >= 0 ? styles.positive : styles.negative}>
                {row.change24h}%
              </td>
              <td>{row.volume24h}</td>
              <td>
                <Button href={row.link}>Trade</Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* 分页器 */}
      <Pagination
        current={currentPage}
        total={filteredList?.length || 0}
        pageSize={6}
        onChange={setCurrentPage}
      />
    </div>
  );
};

星标样式:

.starIcon {
  cursor: pointer;
  font-size: 16px;
  margin-right: 8px;

  &:hover {
    transform: scale(1.2);
    transition: transform 0.2s;
  }

  // 黄色星标(已收藏)
  :global(.anticon-star) {
    color: #FFD700;
  }

  // 灰色星标(未收藏)
  :global(.anticon-star-outlined) {
    color: #8C8C8C;
  }
}

2.3 API 接口实现

// /api/favorites.ts 

// 获取收藏列表
export const getCollectInfo = () => {
  return api.get('/member/preferences');
};

// 更新收藏列表
export const updateCollectInfo = (data: {
  upsert_keys: {
    deriatives: string;      // "BTC,ETH,SOL"
    spotBookSymbolSequence: string;  // "BTCUSDT,ETHUSDT,SOLUSDT"
  }
}) => {
  return api.post('/member/pref-setting', data);
};

2.4 新手任务奖励集成

自定义 Hook: useNewbieTaskRewards

// /hooks/useNewbieTaskRewards.ts
export const useNewbieTaskRewards = (taskType: 'kyc' | 'deposit') => {
  const [taskData, setTaskData] = useState<TaskData | null>(null);
  const tagId = taskType === 'kyc' ? 'Gifts_Kyc' : 'Gifts_deposit';

  useEffect(() => {
    const fetchTask = async () => {//...获取接口数据,得到新手任务的奖励金额,时效等};

    fetchTask();
  }, [tagId]);

  return taskData;
};

倒计时组件:

//components/CountdownTimer/index.tsx
export const CountdownTimer: React.FC<{ seconds: number }> = memo(function CountdownTimer({ seconds }) {
  const [timeLeft, setTimeLeft] = useState(seconds);

  useEffect(() => {
    if (timeLeft <= 0) return;

    const timer = setInterval(() => {
      setTimeLeft(prev => Math.max(0, prev - 1));
    }, 1000);

    return () => clearInterval(timer);
  }, [timeLeft]);

  const days = Math.floor(timeLeft / 86400);
  const hours = Math.floor((timeLeft % 86400) / 3600);
  const minutes = Math.floor((timeLeft % 3600) / 60);

  return (
    <span className={styles.countdown}>
      {days}d {hours}h {minutes}m remaining
    </span>
  );
});

2.5 KYC 状态细化

自定义 Hook: useKycStatus

// /hooks/useKycStatus.ts
export enum KycSubTaskStatus {
  UNVERIFIED_ALL = '3',      // 未认证-全部
  PASSED = '2',              // 已通过
  REVIEWING = '1',           // 审核中
 //..其他KYC状态(部分未认证、拒绝可重试等等)
}

export const useKycStatus = () => {
  const [status, setStatus] = useState<KycSubTaskStatus | null>(null);
  const [kycLevel, setKycLevel] = useState<string>('');

  useEffect(() => {
    const fetchKycStatus = async () => {
      //获取用户的KYC状态
    };

    fetchKycStatus();
  }, []);

  // 状态文案映射
  const statusTextMap = {
    [KycSubTaskStatus.REVIEWING]: 'Your KYC is being reviewed',
    [KycSubTaskStatus.UNVERIFIED_ALL]: 'Verify your identity',
    [KycSubTaskStatus.PASSED]: 'KYC verified'
    //...
  };

  return {
    status,
    kycLevel,
    statusText: statusTextMap[status] || '',
    isReviewing: status === KycSubTaskStatus.REVIEWING
  };
};

2.6 AB 实验控制

// /Guides/index.tsx
const Guides = () => {
  const { verified, isDeposit } = useNewMypageContext();
  const { status: kycStatus } = useKycStatus();

  // AB 实验分组
  const { abResult: showKycState } = useSensors({
    param_name: 'showKycState',
    default_value: 1,  // 0=对照组,1=实验组
    value_type: 'Number'
  });

  const { abResult: showRewards } = useSensors({
    param_name: 'showRewards',
    default_value: 1,  // 0=不显示奖励,1=显示奖励
    value_type: 'Number'
  });

  // 未 KYC 状态
  if (!verified) {
    if (showKycState === 1 && kycStatus !== KycSubTaskStatus.UNVERIFIED_ALL) {
      // 实验组:显示详细 KYC 状态
      return <EnhancedKycGuide status={kycStatus} showRewards={showRewards === 1} />;
    } else {
      // 对照组:显示原有 UI
      return <LegacyKycGuide />;
    }
  }

  // 已 KYC 但未充值
  if (verified && !isDeposit) {
    return showRewards === 1 ? (
      <DepositGuideWithRewards />
    ) : (
      <LegacyDepositGuide />
    );
  }

  return null;
};

三、埋点

// Favorites 区域曝光
useEffect(() => {
  if (tabValue === 'favorites') {
    track?.('PageView', {
      section_type: 'Markets',
      page_type: 'favorites',
    });
  }
}, [tabValue, collectState]);

// 点击添加/删除收藏
const handleClickStar = (item) => {
  track?.('PageClick', {
    section_type: 'Markets',
    button_name: item.isCollected ? 'Remove_from_Favorites' : 'Add_to_Favorites',
  });
};

四、响应式设计与 H5 适配

4.1 PC 端实现

// PCMyPage/Markets/index.tsx
const Markets = () => {
  return (
    <MarketDataProvider>
      <div className={styles.marketsContainer}>
        <SegmentedControl value={value} onChange={setValue} options={marketOptions} />
        <Tabs value={tabValue} onChange={setTabValue} tabs={tabsList} />
        {isEmpty ? (
          <NoFavorites marketType={value} onAddToFavorites={handleAdd} />
        ) : (
          <MarketDataTable marketType={value} tabValue={tabValue} />
        )}
      </div>
    </MarketDataProvider>
  );
};

4.2 H5 端适配

// /H5MyPage/Markets/index.tsx
const H5Markets = () => {
  // H5 端不使用 Context,直接调用 Hook
  const marketData = useMarket({ /* ... */ });
  const { spotCollectState, contractCollectState } = useCollect();

  return (
    <div className={styles.h5MarketsContainer}>
      {/* 单列布局 */}
      <MobileSegmentedControl value={value} onChange={setValue} />
      <MobileTabs value={tabValue} onChange={setTabValue} />
      {isEmpty ? (
        <NoFavorites marketType={value} />  {/* Grid 自动变为单列 */}
      ) : (
        <MobileMarketDataTable marketType={value} tabValue={tabValue} />
      )}
    </div>
  );
};

H5 样式适配:

// H5MyPage/Markets/index.module.less
.h5MarketsContainer {
  padding: 12px;

  .grid {
    display: grid;
    grid-template-columns: 1fr;  // 单列
    gap: 12px;
  }

  .table {
    font-size: 14px;  // PC 端 16px

    td {
      padding: 8px;  // PC 端 12px
    }
  }
}

五、技术难点与解决方案

问题2: Icon 在测试环境(U1)无法显示

现象:

  • 生产环境图片正常显示
  • U1 测试环境图片返回 404

根因分析:

// ❌ 错误:硬编码 CDN 域名
const iconUrl = `https://cdn.exchange.com/images/${item.icon}`;

解决方案:

// ✅ 正确:环境变量动态配置
const getCdnUrl = () => {
  const envMap = {
    'unify-test-1': 'https://cdn-test.exchange.com',
    'gtd-test-1': 'https://cdn-gtd-test.exchange.com',
    'production': 'https://cdn.exchange.com'
  };
  return envMap[process.env.PIPELINE_ENV] || envMap.production;
};

const iconUrl = `${getCdnUrl()}/images/${item.icon}`;

问题3: 收藏立即消失影响用户体验

需求:

  • 用户点击取消收藏后,不要立即从列表中移除
  • 等下次切换 Tab 或刷新页面时再同步

实现方案:

const [pendingUnfavorites, setPendingUnfavorites] = useState<Set<string>>(new Set());

// 点击取消收藏时
const handleUnfavorite = (id: string) => {
  // 1. 添加到待移除列表(用于视觉反馈)
  setPendingUnfavorites(prev => new Set(prev).add(id));

  // 2. 调用 API(异步)
  await clickCollect(item, marketType);

  // 3. 不立即从 collectState 中移除
};

// 渲染时过滤待移除项(视觉上消失)
const displayList = filteredList.filter(item => 
  !pendingUnfavorites.has(normalizeTradingPair(item.tradingPair))
);

// 切换 Tab 时刷新数据
useEffect(() => {
  if (tabValue === 'favorites') {
    refreshCollectInfo();  // 同步服务端数据
    setPendingUnfavorites(new Set());  // 清空待移除列表
  }
}, [tabValue]);

问题4: 新手任务过期判断不准确

现象:

  • 服务端未及时清理过期任务
  • 前端显示已过期任务的奖励金额

解决方案:

// ✅ 双重判断:服务端返回 + 前端计算
const isTaskValid = useMemo(() => {
  if (!taskData) return false;
  if (taskData.remain_sec <= 0) return false;

  // 额外校验:注册后 >7 天自动过期
  const registrationTime = userInfo.created_at;
  const daysSinceRegistration = (Date.now() - registrationTime) / (1000 * 60 * 60 * 24);
  return daysSinceRegistration <= 7;
}, [taskData, userInfo]);

// 过期回退到原有 UI
return isTaskValid ? (
  <GuideWithRewards taskData={taskData} />
) : (
  <LegacyGuide />
);

R (Result) - 项目成果

一、功能交付成果

Markets 收藏功能(已全量上线)

  • Favorites 标签页(空状态 + 收藏列表)
  • 星标交互覆盖 5 个排行榜(Hot/NewListing/Gainers/Losers/Turnover)
  • PC + H5 双端完整适配
  • 现货和衍生品独立管理
  • 分页功能(每页 6 个,超过显示分页器)
  • 延迟刷新机制(切换 Tab 时同步)

新手引导迭代(AB 实验进行中)

  • KYC 状态从 2 种扩展到 6 种(覆盖全流程)
  • 新手任务奖励展示(KYC + 入金双任务)
  • 倒计时组件(实时更新剩余时间)
  • 动态视频引导(WebP 格式,自动播放)
  • AB 实验控制逻辑(useSensors 动态分组)

数据埋点体系

  • Markets 模块:3 个关键节点(曝光、添加、删除)
  • Guides 模块:6 种 KYC 状态埋点 + AB 分组标记
  • My Assets 模块:补充资产详情点击埋点

二、技术亮点

2.1 状态管理优化

成果:

  • API 调用次数减少 40%(通过 Context 共享数据)
  • 操作响应时间 < 100ms(乐观更新策略)
  • 状态同步准确率 100%(延迟刷新机制)

技术方案:

useCollect() → 本地状态立即更新 → 异步同步服务端 → 切换 Tab 时刷新

2.2 代码复用率提升

成果:

  • PC 和 H5 共享 70% 组件代码
  • 自定义 Hook 封装 5 个核心逻辑
  • 代码行数减少 30%(相比初版设计)

关键 Hooks:

  • useCollect - 收藏状态管理(220 行)
  • useKycStatus - KYC 状态管理
  • useNewbieTaskRewards - 新手任务奖励管理
  • useMarketData - 市场数据共享
  • useSensors - AB 实验控制

🔥Vue 采用双链表重构响应式,你竟然还不知道?

作者 Mose前端
2026年1月30日 19:23

前言

今天咱们来聊聊 Vue 响应式系统——这个让你写代码时“数据自动变视图,视图自动变数据”的神奇魔法,vue3发布后,网上很多分析Vue3响应式源码,基于weakMap -> map -> set的这种数据节后实现响应式,,其实早就偷偷用双链表重构了响应式核心,玩出了新花样,今天就带大家扒一扒这份源码,新手也能看懂,老鸟也能补漏~~

先抛个灵魂拷问:为啥Vue3要放着好好的Set依赖收集方案不用,非要折腾双链表?答案很简单——Set方案太“笨”,性能拉胯还麻烦!Vue3早期的响应式依赖收集,用的是Set来存储副作用函数,看似简洁,但删除依赖时要遍历整个Set查找目标,更新的时候更是“大水漫灌”,明明只改了一个变量,却要触发一堆无关的更新,CPU都要哭了。

而双链表,就是Vue3给响应式“开的外挂”——轻量、高效、能精准定位,完美解决了Set收集的痛点。就像给依赖关系装了个“导航仪”,找依赖、更依赖、删依赖,一步到位,再也不用像Set那样瞎遍历浪费性能。话不多说,直接上源码,边看边吐槽~

源码阅读

如果还不知到源码阅读技巧,初始准备,可以看下博主上一篇文章# 90% 的人读不懂 Vue 源码,是因为没做这些准备

首先,我们创建一个demo文件index.html,通过断点方式来阅读源码,可以更清晰的看到代码执行过程中数据流的变化

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue Effect Test</title>

    <!-- 引入你本地 clone 的 vue.global.js -->
    <script src="../../../../vue/dist/vue.global.js"></script>
</head>

<body>
    <div id="text"></div>

    <button id="btn">count++</button>

    <script>
        const { reactive, ref, effect } = Vue;

        // 创建响应式数据
        const state = reactive({
            count1: 1,
            count2: 2
        });

        // 绑定 effect:依赖收集 + 自动更新
        effect(() => {
            // 建立一个链表关系 
            document.getElementById('text').innerText =
                `当前 count 值:${state.count1}`;
        });
        document.getElementById('btn').onclick = () => {
            state.count1++;
        };
    </script>
</body>
</html>

依赖收集track函数

阅读响应式代码,我们需要找到vue响应式模块reactivity模块下reactive.ts文件,然后找到reactive函数,可以看到响应式对象是通过 createReactiveObject函数创建出来的。

image.png

当我们深入到 createReactiveObject 函数内部,这个函数接收五个参数。通过逐行调试(debugger)执行其代码逻辑可以清晰看到,函数最终会返回一个 Proxy 对象 —— 这正是 Vue 响应式系统的核心实现基础。如此看来,大名鼎鼎的 Proxy 核心原理,拆解开来其实也并不复杂。

image.png

由上图可见,baseHandlers其实本质就是MutableReactiveHandler,作为createReactiveObject函数的第三个参数传递进去,可以看到 MutableReactiveHandler 中有 get, set, deleteProperty, has, ownKeys 等方法,get方法继承自 BaseReactiveHandler image.png

查看get取值操作,可以看到get操作主要做的是track依赖收集,收集完毕后把count1初始值返回,供页面展示使用(忽略判断,我们只关心主线逻辑)

image.png

get 函数内部,有一个核心操作是 track(依赖追踪)。深入到 track 函数的实现逻辑中可以发现,Vue 是通过一套层级化的数据结构来组织响应式依赖收集的:最外层是 WeakMap,其下嵌套 Map,最终指向具体的 Dep 实例。我们进到track函数内部, 首先会查找当前targetMap中是否有target对应的map, 没有我们会创建一个map, 然后我们会查找当前count1是否有对应的Dep实例,如果不存在则创建一个Dep对象

image.png

我们看核心操作dep.track, 这是我们依赖收集的核心函数; 首先会通过Link类创建一个link节点, 其中 activeSub是当前激活的副作用函数,this是当前的dep对象, 这也是为啥说link是连接depsub的桥梁, 初始时候我们不做指针后移,但是访问count2是要进行链表新增操作的

image.png

接下来执行addSub, 我们看看addSub中到底做了什么? 其实就是更新sub对应的前后sub指针,由于我们当前只有一个副作用,所以prevSubundefined,最后当前 link 挂到它所属的 dep 上,作为该 dep 订阅者链表的入口,这样我们就完成了count1的收集,count2类似的阅读思路

image.png

依赖触发trigger函数

我们点击按钮,修改count1的值,此时我们想要做的是通知副作用函数的执行,从而让页面视图更新,那vue是怎么触发更新的呢?

image.png

其中run方法内部核心执行dep.trigger方法

image.pngdep.trigger方法内部,在finally尾部执行endBatch方法

image.png trigger函数如下

image.png

判断是否肮,很显然我们更新了数据,需要执行副作用,所以执行this.runIfDirty

image.png

this.runIfDirty内部调用this.run方法

image.png

run方法中返回 this.fn()函数 image.png

其中fn就是我们的副作用函数,找到执行它就ok啦

image.png 这样我们就完成了副作用的执行

总结

最后,双链表的结构图大致可以抽象为

image.png

好了,今天的源码拆解就到这里。

原创不易,如果对你有帮助,点个小赞、关注~

type-challenges(ts类型体操): 11 - 元组转换为对象

作者 fxss
2026年1月30日 23:20

11 - 元组转换为对象

by sinoon (@sinoon) #简单 #object-keys

题目

将一个元组类型转换为对象类型,这个对象类型的键/值和元组中的元素对应。

例如:

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const

type result = TupleToObject<typeof tuple> // expected { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}

在 Github 上查看:tsch.js.org/11/zh-CN

代码

/* _____________ 你的代码 _____________ */

type TupleToObject<T extends readonly PropertyKey[]> = {
  [P in T[number]]: P
}

关键解释:

  • type PropertyKey = string | number | symbol
  • T extends readonly PropertyKey[] 用于限制 T 必须是一个只读的属性键元组。
  • [P in T[number]] 用于遍历元组中的每个元素,将其作为对象的键。
  • P 是元组中的元素类型,通过 T[number] 来获取。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

readonly

  • 核心作用:标记后,目标(属性 / 数组 / 元组)只能在初始化阶段赋值(比如接口实例化、类构造函数、变量声明时),后续任何修改运算都会被 TS 编译器拦截报错;
  • 运行时特性:readonly 仅做编译时检查,不会生成任何额外 JS 代码,也无法真正阻止运行时的修改(比如通过类型断言绕开的话,运行时仍能改);
  • const 的区别:const 是变量层面的不可重新赋值(但变量指向的对象 / 数组内部属性仍可改),readonly 是属性 / 类型层面的不可修改(变量本身可重新赋值,除非变量也用 const)。

常用使用场景:

  1. 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
  readonly id: number; // 只读属性:只能初始化赋值,后续不可改
  name: string; // 普通属性:可修改
}

// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };

// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
  1. 作用于类的属性: 类中使用 readonly 标记属性,只能在声明时构造函数中赋值,后续无法修改
class Person {
  readonly id: number; // 只读属性
  name: string;

  // 构造函数中给 readonly 属性赋值(唯一合法的后续赋值方式)
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  updateInfo() {
    this.id = 100; // ❌ 报错:id 是只读属性
    this.name = "王五"; // ✅ 合法
  }
}

const person = new Person(1, "赵六");
person.id = 2; // ❌ 报错:只读属性不可修改
  1. 作用于数组 / 元组(只读数组): readonly 可标记数组为 “只读数组”,禁止修改数组元素、调用 push/pop 等修改方法
// 方式1:使用 readonly 修饰数组类型
const arr1: readonly number[] = [1, 2, 3];
arr1.push(4); // ❌ 报错:readonly 数组不存在 push 方法
arr1[0] = 10; // ❌ 报错:无法修改只读数组的元素

// 方式2:使用 ReadonlyArray<T> 类型(等价于 readonly T[])
const arr2: ReadonlyArray<string> = ["a", "b"];
arr2.pop(); // ❌ 报错

// 作用于元组(只读元组)
type Point = readonly [number, number];
const point: Point = [10, 20];
point[0] = 30; // ❌ 报错:只读元组元素不可修改
  1. 结合 keyof + in 批量创建只读类型(映射类型)
interface Product {
  name: string;
  price: number;
  stock: number;
}

// 批量创建只读版本的 Product(TS 内置的 Readonly<T> 就是这么实现的)
type ReadonlyProduct = {
  readonly [K in keyof Product]: Product[K];
};

const product: ReadonlyProduct = { name: "手机", price: 2999, stock: 100 };
product.price = 3999; // ❌ 报错:price 是只读属性

// TS 内置了 Readonly<T>,可直接使用(无需手动写映射类型)
const product2: Readonly<Product> = { name: "电脑", price: 5999, stock: 50 };
product2.stock = 60; // ❌ 报错
  1. 只读索引签名:如果类型使用索引签名,也可以标记为 readonly,禁止通过索引修改属性
// 只读索引签名:只能读取,不能修改
type ReadonlyDict = {
  readonly [key: string]: number;
};

const dict: ReadonlyDict = { a: 1, b: 2 };
dict["a"] = 3; // ❌ 报错:索引签名是只读的
console.log(dict["b"]); // ✅ 合法:仅读取

in

in 运算符用于遍历联合类型中的每个成员,将其转换为映射类型的属性名。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = 'title' | 'description'

type TodoPreview = {
  [P in TodoKeys]: Todo[P]
}
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
// }

T[number]

T[number] 索引访问类型 用于 从数组类型 / 元组类型中提取所有元素的类型,最终得到一个联合类型。

  1. 普通数组类型
// 定义普通数组类型
type StringArr = string[];
type NumberArr = number[];
type BoolArr = boolean[];

// T[number] 提取元素类型
type Str = StringArr[number]; // 结果:string
type Num = NumberArr[number]; // 结果:number
type Bool = BoolArr[number]; // 结果:boolean

// 等价于直接注解类型
let s: Str = "hello"; // 等同于 let s: string
let n: Num = 123;    // 等同于 let n: number
let b: Bool = true;  // 等同于 let b: boolean
  1. 元组类型
// 定义一个多类型的元组类型
type Tuple = [123, "TS", true, null];

// T[number] 提取所有元素的联合类型
type TupleUnion = Tuple[number]; // 结果:123 | "TS" | true | null

// 变量注解:可以是联合类型中的任意一种
let val: TupleUnion;
val = 123;    // 合法
val = "TS";   // 合法
val = true;   // 合法
val = null;   // 合法
val = false;  // ❌ 报错:不在联合类型中
  1. 字面量元组
// 字面量元组:元素是数字/字符串字面量
type StatusTuple = [200, 404, 500];
type EnvTuple = ["dev", "test", "prod"];

// 转字面量联合类型(开发中常用的枚举式类型)
type Status = StatusTuple[number]; // 结果:200 | 404 | 500
type Env = EnvTuple[number];       // 结果:"dev" | "test" | "prod"

// 严格限制变量值,避免手写错误
let code: Status = 200; // 合法
code = 404;             // 合法
code = 403;             // ❌ 报错:403 不在 200|404|500 中

let env: Env = "dev";   // 合法
env = "prod";           // 合法
env = "production";     // ❌ 报错:不在联合类型中
  1. as const + 数组 + T[number]

同时拥有数组的可遍历性 + 联合类型的严格类型约束。

// 步骤1:用 as const 断言数组为「只读字面量元组」
// 作用:让 TS 保留每个元素的字面量类型,且把数组转为只读元组(不可修改)
const EnvArr = ["dev", "test", "prod"] as const;
const StatusArr = [200, 404, 500] as const;

// 步骤2:用 typeof 获取数组的类型(只读字面量元组类型)
// 补充:typeof 是 TS 关键字,用于「从变量中提取其类型」
type EnvTuple = typeof EnvArr; // 类型:readonly ["dev", "test", "prod"]
type StatusTuple = typeof StatusArr; // 类型:readonly [200, 404, 500]

// 步骤3:用 T[number] 转成字面量联合类型
type Env = EnvTuple[number]; // 结果:"dev" | "test" | "prod"
type Status = StatusTuple[number]; // 结果:200 | 404 | 500

// 简化写法(开发中常用,省略中间元组类型)
type EnvSimplify = typeof EnvArr[number];
type StatusSimplify = typeof StatusArr[number];
  1. 泛型中使用 T[number]
// 泛型 T 约束为「只读数组」(兼容 as const 断言的数组)
function getUnionType<T extends readonly any[]>(arr: T): T[number] {
  return arr[Math.floor(Math.random() * arr.length)];
}

// 传入 as const 断言的数组,返回值自动推导为字面量联合类型
const res1 = getUnionType(["dev", "test", "prod"] as const); // res1 类型:"dev" | "test" | "prod"
const res2 = getUnionType([1, 2, 3] as const); // res2 类型:1 | 2 | 3

// 传入普通数组,返回值推导为基础类型
const res3 = getUnionType([1, 2, 3]); // res3 类型:number
  1. 支持嵌套数组 / 元组
const NestedArr = [[1, "a"], [2, "b"]] as const;
type NestedUnion = typeof NestedArr[number]; // 结果:readonly [1, "a"] | readonly [2, "b"]
type DeepUnion = typeof NestedArr[number][number]; // 结果:1 | "a" | 2 | "b"

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
const tupleNumber = [1, 2, 3, 4] as const
const sym1 = Symbol(1)
const sym2 = Symbol(2)
const tupleSymbol = [sym1, sym2] as const
const tupleMix = [1, '2', 3, '4', sym1] as const

type cases = [
  Expect<Equal<TupleToObject<typeof tuple>, { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y' }>>,
  Expect<Equal<TupleToObject<typeof tupleNumber>, { 1: 1, 2: 2, 3: 3, 4: 4 }>>,
  Expect<Equal<TupleToObject<typeof tupleSymbol>, { [sym1]: typeof sym1, [sym2]: typeof sym2 }>>,
  Expect<Equal<TupleToObject<typeof tupleMix>, { 1: 1, '2': '2', 3: 3, '4': '4', [sym1]: typeof sym1 }>>,
]

// @ts-expect-error
type error = TupleToObject<[[1, 2], {}]>

相关链接

分享你的解答:tsch.js.org/11/answer/z… 查看解答:tsch.js.org/11/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

type-challenges(ts类型体操): 10 - 元组转合集

作者 fxss
2026年1月30日 23:13

10 - 元组转合集

by Anthony Fu (@antfu) #中等 #infer #tuple #union

题目

实现泛型TupleToUnion<T>,它返回元组所有值的合集。

例如

type Arr = ['1', '2', '3']

type Test = TupleToUnion<Arr> // expected to be '1' | '2' | '3'

在 Github 上查看:tsch.js.org/10/zh-CN

代码

/* _____________ 你的代码 _____________ */

type TupleToUnion<T> = T extends [infer F, ...infer R] ? F | TupleToUnion<R> : never

关键解释:

  • T extends [infer F, ...infer R] 用于判断元组是否为空。
  • F | TupleToUnion<R> 用于递归处理元组的剩余部分。
  • never 用于处理空元组的情况。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

infer

infer 是 TypeScript 在条件类型中提供的关键字,用于声明一个 待推导的类型变量(类似给类型起一个临时名字),只能在 extends 子句中使用。它的核心作用是:从已有类型中提取 / 推导我们需要的部分,而无需手动硬编码类型。

infer 必须配合条件类型使用,语法结构如下:

// 基础结构:推导 T 的类型为 U,若能推导则返回 U,否则返回 never
type InferType<T> = T extends infer U ? U : never;

type Example = InferType<string>; // Example 类型为 string
type Example2 = InferType<number[]>; // Example2 类型为 number[]

高频使用场景:

1. 提取函数的返回值类型
// 定义类型工具:提取函数的返回值类型
type GetReturnType<Fn> = Fn extends (...args: any[]) => infer R ? R : never;

// 测试用函数
const add = (a: number, b: number): number => a + b;
const getUser = () => ({ name: "张三", age: 20 });

// 使用类型工具
type AddReturn = GetReturnType<typeof add>; // AddReturn 类型为 number
type UserReturn = GetReturnType<typeof getUser>; // UserReturn 类型为 { name: string; age: number }
2. 提取数组的元素类型
// 定义类型工具:提取数组元素类型
type GetArrayItem<T> = T extends (infer Item)[] ? Item : never;

// 测试
type NumberArray = GetArrayItem<number[]>; // NumberArray 类型为 number
type StringArray = GetArrayItem<string[]>; // StringArray 类型为 string
type MixedArray = GetArrayItem<[string, number]>; // MixedArray 类型为 string | number
3. 提取 Promise 的泛型参数类型
// 定义类型工具:提取 Promise 的泛型类型
type GetPromiseValue<T> = T extends Promise<infer Value> ? Value : never;

// 测试
type PromiseString = GetPromiseValue<Promise<string>>; // PromiseString 类型为 string
type PromiseUser = GetPromiseValue<Promise<{ id: number }>>; // PromiseUser 类型为 { id: number }
4. 提取函数的参数类型
// 定义类型工具:提取函数参数类型
type GetFunctionParams<Fn> = Fn extends (...args: infer Params) => any ? Params : never;

// 测试
const fn = (name: string, age: number): void => {};
type FnParams = GetFunctionParams<typeof fn>; // FnParams 类型为 [string, number]

// 进一步:提取第一个参数的类型
type FirstParam = GetFunctionParams<typeof fn>[0]; // FirstParam 类型为 string

|

| 运算符用于表示联合类型,即一个值可以是多个类型中的任意一个。

  1. 变量的联合类型注解
// 变量 a 可以是字符串 OR 数字
let a: string | number;

// 合法赋值(符合任意一种类型)
a = "TS";
a = 123;

// 非法赋值(不属于联合类型中的任何一种),TS 直接报错
a = true; // ❌ 类型 'boolean' 不能赋值给类型 'string | number'
  1. 函数参数的联合类型
// 函数接收 string 或 number 类型的参数
function printValue(val: string | number) {
  console.log(val);
}

// 合法调用
printValue("hello");
printValue(666);

// 非法调用,TS 报错
printValue(null); // ❌
  1. 数组的联合类型(注意两种写法的区别)
// 写法1:(A | B)[] —— 数组的「每个元素」可以是 A 或 B(混合数组)
let arr1: (string | number)[] = [1, "2", 3, "4"]; // 合法

// 写法2:A[] | B[] —— 「整个数组」要么全是 A 类型,要么全是 B 类型(纯数组)
let arr2: string[] | number[] = [1, 2, 3]; // 合法(全数字)
arr2 = ["1", "2", "3"]; // 合法(全字符串)
arr2 = [1, "2"]; // ❌ 报错:混合类型不符合要求

当使用联合类型的时候,访问某一个子类型的专属属性 / 方法时,需要进行类型守卫,可用的方法有 typeofinswitchinstanceof

  1. typeof
function getLength(val: string | number) {
  // 类型窄化:判断 val 是 string 类型
  if (typeof val === "string") {
    // 此分支中,TS 确定 val 是 string,可安全使用 length
    return val.length;
  } else {
    // 此分支中,TS 确定 val 是 number,执行数字相关逻辑
    return val.toString().length;
  }
}

console.log(getLength("TS")); // 2
console.log(getLength(1234)); // 4
  1. in
function printUserInfo(user: { name: string } | { age: number }) {
  // 类型窄化:判断 user 是否有 name 属性(即是否是 { name: string } 类型)
  if ("name" in user) {
    console.log(`Name: ${user.name}`);
  } else {
    // 此分支中,TS 确定 user 是 { age: number } 类型
    console.log(`Age: ${user.age}`);
  }
}
  1. switch
interface User {
  type: "user";
  name: string;
  age: number;
}
interface Admin {
  type: "admin";
  name: string;
  permission: string[];
}
// 联合类型:可以是 User 或 Admin
type Person = User | Admin;
function printPerson(p: Person) {
  switch (p.type) {
    case "user":
      console.log(p.age); // 确定是 User
      break;
    case "admin":
      console.log(p.permission); // 确定是 Admin
      break;
  }
}
  1. instanceof
// 定义两个类
class Dog {
  bark() { console.log("汪汪"); }
}
class Cat {
  meow() { console.log("喵喵"); }
}

// 联合类型:Dog 或 Cat 实例
type Animal = Dog | Cat;

// instanceof 类型守卫(针对类实例)
function animalCall(animal: Animal) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

animalCall(new Dog()); // 汪汪
animalCall(new Cat()); // 喵喵

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<TupleToUnion<[123, '456', true]>, 123 | '456' | true>>,
  Expect<Equal<TupleToUnion<[123]>, 123>>,
]

相关链接

分享你的解答:tsch.js.org/10/answer/z… 查看解答:tsch.js.org/10/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

为网页注入灵魂:Live2D Widget看板娘,打造会动的互动伙伴!

作者 修己xj
2026年1月30日 23:01

厌倦了静态网页的冰冷与单调?Live2D Widget 能将一个生动、可爱的看板娘轻松带入你的网站。只需一行代码,这个由 TypeScript 驱动的开源项目即可为博客、个人主页或任何网页赋予灵动的生命。她不仅会眨眼、转头,还能与访客进行简单的互动,瞬间提升网站的趣味性与亲和力。无论是技术极客追求的可定制性,还是普通用户向往的轻松集成,Live2D Widget 都能满足。跟随本文,一分钟唤醒你的网页,让数字世界多一位温暖陪伴。

🎯项目介绍

  • • 页面互动:在网页中添加 Live2D 看板娘
  • • 易于集成: 核心代码由 TypeScript 编写,易于集成,只需一行代码,即可为网站添加看板娘
  • • 轻量级设计:除Live2D核心库外无额外依赖,加载迅速
  • • 高度可定制:支持多种配置选项,完美适配你的网站风格

github地址:github.com/stevenjoezh…

官网地址:www.live2d.com/en

该项目目前在github上已有 10.4k ⭐️ star

⚡快速开始:一分钟集成

对于大多数用户来说,集成过程简单得令人惊喜:

<!-- 只需在页面中添加这行代码 -->
<script src="https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js"></script>

我的博客页面 xiuji008.github.io/ 已经集成了,家人们可以移步过去看看效果,以下是一些效果图

🎖️进阶

如果你是小白,我们通过上边介绍的那行代码就已经把看板娘集成进来了。但是如果你想让看板娘更适合你的网站,你可以通过进一步的配置及开发来完成,感兴趣的家人们可以自行研究。

相关技术博文:www.fghrsh.net/post/123.ht…

📝 总结:让网页活起来

Live2D Widget不仅仅是一个技术项目,它代表了网页交互的新可能。在这个数字化的时代,一个灵动的看板娘能够:

  • • 提升用户体验和停留时间
  • • 增加网站的特色和记忆点
  • • 展示技术实力和创意精神

无论你是技术爱好者、博主还是网站开发者,Live2D Widget都能为你的项目增添独特的魅力。立即尝试,让你的网站拥有一个随时陪伴访客的可爱伙伴吧!

让技术更有温度,让网页更有生命!🚀

大模型发展史-01

2026年1月30日 23:01

前言

2017年,一篇论文悄然发表,题为《Attention Is All You Need》。

当时没人预料到,这篇论文中提出的 Transformer 架构,会在短短几年内彻底改变人工智能的格局。

五年后的2022年11月30日,ChatGPT 发布。五天内,用户突破100万。两个月内,用户突破1亿。

这是互联网历史上增长最快的应用,也是人工智能发展史上的重要里程碑。

从默默无闻到席卷全球,大语言模型经历了怎样的进化之路?让我们一起回顾这段激动人心的技术演进史。


1. 什么是 Transformer

Transformer 是一种完全基于注意力机制的神经网络架构,于2017年由 Google 团队提出。

核心创新

特点 说明
Self-Attention 自注意力机制,捕捉长距离依赖
并行计算 可并行训练,大幅提升效率
可扩展性 为后续大模型奠定基础

核心思想

// Transformer 的核心:Self-Attention
class Transformer {
  attention(Q, K, V) {
    // Q (Query)、K (Key)、V (Value)
    const scores = Q @ K.T / Math.sqrt(d_k);  // 计算注意力分数
    const weights = softmax(scores);           // 归一化
    return weights @ V;                        // 加权求和
  }
}

重要术语

术语 解释
预训练 用大量无标注数据训练基础模型
微调 针对特定任务用小数据集优化模型
RLHF 人类反馈强化学习,对齐人类偏好
少样本学习 只需几个例子就能学会新任务

2. 案例

案例 1:GPT 系列的进化之路

让我们看看 GPT 系列是如何一步步进化的:

代际 发布时间 参数量 能力突破
GPT-1 2018.06 117M 预训练范式
GPT-2 2019.02 1.5B 零样本生成
GPT-3 2020.05 175B 少样本学习
GPT-3.5 2022.11 未知 对话能力
GPT-4 2023.03 ~1.7T 多模态+推理
GPT-4o 2024.05 未知 原生多模态

关键突破:GPT-3 的少样本学习

const prompt = `
翻译以下句子成中文:
Example 1: Hello world -> 你好世界
Example 2: How are you -> 你好吗
Input: Good morning -> ?
`;
// GPT-3: 早上好
// 没有专门训练,就能学会翻译任务

案例 2:ChatGPT 的 AI iPhone 时刻

发布时间:2022年11月30日

突破性改进

训练流程:
1. 预训练(学习知识)
   ↓
2. 有监督微调(学习指令)
   ↓
3. 奖奖模型(学习人类偏好)
   ↓
4. 强化学习(优化输出)

成果

  • 对话能力大幅提升
  • 指令遵循能力强
  • 多轮对话流畅
  • 5天用户破100万

案例 3:2023年百花齐放

闭源模型三强鼎立

模型 公司 核心优势
GPT-4 OpenAI 多模态、推理能力强
Claude 3 Anthropic 超长上下文(200K)
Gemini Google 原生多模态

开源模型快速追赶

模型 组织 参数 特点
Llama 3 Meta 8B/70B 性能强劲
Qwen 阿里云 7B/14B/72B 中文优秀
Mistral Mistral AI 7B 效率之王

中国大模型崛起

模型 公司 特色
文心一言 百度 知识图谱增强
通义千问 阿里云 开源友好
讯飞星火 科大讯飞 语音能力强
DeepSeek 幻方量化 性价比高

案例 4:2024年的三大趋势

趋势1:开源模型追平闭源

2024年初:Llama 2 70B  GPT-3.5
2024年中:Llama 3 70B 接近 GPT-4
2024年底:Qwen 2.5、DeepSeek V3 追平闭源

趋势2:多模态成为标配

  • GPT-4o:原生多模态
  • Claude 3.5:强大的视觉能力
  • Gemini:从一开始就是多模态

趋势3:智能体技术成熟

// Agent 能力的进化
2022:简单对话
2023:工具调用
2024:
  ├── 复杂任务规划
  ├── 多智能体协作
  ├── 自主学习和改进
  └── 真正的"AI 员工"

总结

  1. 规模即质量——更大的模型通常表现更好
  2. 数据是关键——高质量训练数据至关重要
  3. 架构创新——Transformer 是核心突破
  4. 开源加速——开源模型推动技术普及

什么是大语言模型-00

2026年1月30日 22:56

前言

你有没有想过,当你问 ChatGPT 一个问题时,它是如何"思考"并给出回答的?

今天天气怎么样?——抱歉,我无法获取实时天气信息。 请用 JavaScript 写一个快速排序——几秒钟内,代码就出现在屏幕上。

同样是 AI,为什么能写代码却不能查天气?大语言模型的"知识"从哪里来?它是真的"理解"我们的话吗?

这些问题,正是我们探索大语言模型(Large Language Model,LLM)世界的起点。


1. 什么是大语言模型

大语言模型(LLM) 是一种经过海量文本数据训练的深度学习模型,能够理解和生成人类语言。

关键特征

特征 说明 例子
大规模训练 使用 TB 级文本数据 GPT-4 训练了约 1 万亿 tokens
深度神经网络 数十亿到数万亿参数 GPT-3 有 1750 亿参数
通用能力 不需要专门训练就能完成多种任务 翻译、写作、编程、推理

通俗理解

想象一下:

  • 你阅读了互联网上几乎所有的文本
  • 你记住了其中的模式、规律和知识
  • 当有人问你问题时,你能根据记忆生成回答

这就是大语言模型做的事情!

核心工作原理

LLM 的本质是一个文字接龙机器

输入: "今天天气"
LLM 预测下一个词可能是:
- "真好"    (概率 30%)
- "很热"    (概率 25%)
- "怎么样"  (概率 20%)

训练流程

┌─────────────────────────────────────────┐
│            LLM 训练流程                   │
├─────────────────────────────────────────┤
│                                         │
│  1. 数据收集                             │
│     ├── 网页文本                         │
│     ├── 书籍文章                         │
│     └── 代码库                           │
│                                         │
│  2. 预训练                               │
│     ├── 学习语言模式                     │
│     ├── 学习世界知识                     │
│     └── 学习逻辑推理                     │
│                                         │
│  3. 微调                                 │
│     ├── 对齐人类偏好                     │
│     ├── 遵循指令                         │
│     └── 安全性训练                       │
│                                         │
└─────────────────────────────────────────┘

四大核心能力

1. 语言理解

  • 理解文本含义
  • 识别情感倾向
  • 提取关键信息

2. 语言生成

  • 写文章、写代码
  • 创意写作
  • 总结提炼

3. 逻辑推理

  • 数学计算
  • 逻辑推理
  • 问题解决

4. 少样本学习

  • 看几个例子就能学会新任务
  • 不需要重新训练

2. 案例

案例 1:少样本学习的神奇之处

让我们看看 LLM 如何通过几个例子学会新任务:

const prompt = `
例子1:
输入:苹果
输出:水果

例子2:
输入:胡萝卜
输出:蔬菜

输入:香蕉
输出:?
`;
// LLM 能推断:香蕉 → 水果

image.png

解析:无需重新训练,只需几个示例,LLM 就能理解分类规律并应用到新问题。

案例 2:代码生成能力

输入:"请用 JavaScript 写一个快速排序"

LLM 输出

function quickSort(arr) {
  if (arr.length <= 1) {
    return arr;
  }
  const pivot = arr[Math.floor(arr.length / 2)];
  const left = arr.filter(x => x < pivot);
  const middle = arr.filter(x => x === pivot);
  const right = arr.filter(x => x > pivot);
  return [...quickSort(left), ...middle, ...quickSort(right)];
}

解析:LLM 从训练数据中学会了编程模式和算法逻辑,能够生成可运行的代码。

案例 3:发现 LLM 的局限性

测试 1:实时信息

用户: "今天天气怎么样?"
LLM: "抱歉,我无法获取实时天气信息。"

测试 2:精确计算

用户: "12345 × 67890 = ?"
LLM: "大约是 83,000,000 左右"
实际: 838,102,050

测试 3:知识截止

用户: "谁赢得了2024年奥运会?"
LLM: "抱歉,我的知识截止到2023年..."

解析:这些测试揭示了 LLM 的三大局限——知识截止、幻觉问题、无法访问实时信息。

案例 4:实际项目中的调用

在本项目的后端代码中,LLM 调用是这样实现的:

async chat(request: {
  question: string;    // 用户的问题
  model: string;       // 使用的模型(如 qwen-plus)
  apiKey: string;      // API 密钥
}) {
  // 调用阿里云百炼的 LLM
  const response = await axios.post(
    'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
    {
      model: request.model,
      messages: [{ role: 'user', content: request.question }]
    }
  );

  return response.data.choices[0].message.content;
}

解析:通过 HTTP API 调用,将用户问题发送给 LLM,获取生成的回复。


总结

  1. LLM 是文字接龙机器——核心原理是预测下一个词
  2. LLM 有强大但有限的能力——理解、生成、推理、学习都很强,但并非万能
  3. LLM 的知识来自训练数据——它学习的是模式和规律,而非简单记忆
  4. LLM 会犯错——幻觉、知识截止、计算不精确是常见问题
昨天 — 2026年1月30日掘金 前端

Flutter最佳实践:Sliver族网络刷新组件NCustomScrollView

作者 SoaringHeart
2026年1月30日 21:07

一、需求来源

最近需要实现嵌套和吸顶Header滚动下的下拉刷新及上拉加载。最终实现基于 CustomScrollView 的刷新视图组件。

simulator_screenshot_5C4883E4-F919-4FFD-BE3D-97E0BCD5C40D.png

二、使用示例

Widget buildBodyNew() {
  return NCustomScrollView<String>(
    onRequest: (bool isRefresh, int page, int pageSize, pres) async {
      final length = isRefresh ? 0 : pres.length;
      final list = List<String>.generate(pageSize, (i) => "item${length + i}");
      DLog.d([isRefresh, list.length]);
      return list;
    },
    headerSliverBuilder: (context, bool innerBoxIsScrolled) {
      return [
        buildPersistentHeader(),
      ];
    },
    itemBuilder: (_, i, e) {
      return ListTile(
        title: Text('Item $i'),
      );
    },
  );
}

三、源码

//
//  NCustomScrollView.dart
//  projects
//
//  Created by shang on 2026/1/28 14:41.
//  Copyright © 2026/1/28 shang. All rights reserved.
//

import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_placeholder.dart';
import 'package:flutter_templet_project/basicWidget/n_sliver_decorated.dart';
import 'package:flutter_templet_project/basicWidget/refresh/easy_refresh_mixin.dart';
import 'package:flutter_templet_project/basicWidget/refresh/n_refresh_view.dart';

/// 基于 CustomScrollView 的下拉刷新,上拉加载更多的滚动列表
class NCustomScrollView<T> extends StatefulWidget {
  const NCustomScrollView({
    super.key,
    this.title,
    this.placeholder = const NPlaceholder(),
    this.contentDecoration = const BoxDecoration(),
    this.contentPadding = const EdgeInsets.all(0),
    required this.onRequest,
    required this.headerSliverBuilder,
    required this.itemBuilder,
    this.separatorBuilder,
    this.headerBuilder,
    this.footerBuilder,
    this.builder,
  });

  final String? title;

  final Widget? placeholder;

  final Decoration contentDecoration;

  final EdgeInsets contentPadding;

  /// 请求方法
  final RequestListCallback<T> onRequest;

  /// 列表表头
  final NestedScrollViewHeaderSliversBuilder? headerSliverBuilder;

  /// ListView 的 itemBuilder
  final ValueIndexedWidgetBuilder<T> itemBuilder;

  final IndexedWidgetBuilder? separatorBuilder;

  /// 列表表头
  final List<Widget> Function(int count)? headerBuilder;

  /// 列表表尾
  final List<Widget> Function(int count)? footerBuilder;

  final Widget Function(List<T> items)? builder;

  @override
  State<NCustomScrollView<T>> createState() => _NCustomScrollViewState<T>();
}

class _NCustomScrollViewState<T> extends State<NCustomScrollView<T>>
    with AutomaticKeepAliveClientMixin, EasyRefreshMixin<NCustomScrollView<T>, T> {
  @override
  bool get wantKeepAlive => true;

  final scrollController = ScrollController();

  @override
  late RequestListCallback<T> onRequest = widget.onRequest;

  @override
  List<T> items = <T>[];

  @override
  void didUpdateWidget(covariant NCustomScrollView<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.title != oldWidget.title ||
        widget.placeholder != oldWidget.placeholder ||
        widget.contentDecoration != oldWidget.contentDecoration ||
        widget.contentPadding != oldWidget.contentPadding ||
        widget.onRequest != oldWidget.onRequest ||
        widget.itemBuilder != oldWidget.itemBuilder ||
        widget.separatorBuilder != oldWidget.separatorBuilder) {
      setState(() {});
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (items.isEmpty) {
      return GestureDetector(onTap: onRefresh, child: Center(child: widget.placeholder));
    }

    final child = EasyRefresh.builder(
      controller: refreshController,
      onRefresh: onRefresh,
      onLoad: onLoad,
      childBuilder: (_, physics) {
        return CustomScrollView(
          physics: physics,
          slivers: [
            ...(widget.headerBuilder?.call(items.length) ?? []),
            buildContent(),
            ...(widget.footerBuilder?.call(items.length) ?? []),
          ],
        );
      },
    );
    if (widget.headerSliverBuilder == null) {
      return child;
    }

    return NestedScrollView(
      headerSliverBuilder: widget.headerSliverBuilder!,
      body: child,
    );
  }

  Widget buildContent() {
    if (items.isEmpty) {
      return SliverToBoxAdapter(child: widget.placeholder);
    }

    return NSliverDecorated(
      decoration: widget.contentDecoration,
      sliver: SliverPadding(
        padding: widget.contentPadding,
        sliver: widget.builder?.call(items) ?? buildSliverList(),
      ),
    );
  }

  Widget buildSliverList() {
    return SliverList.separated(
      itemBuilder: (_, i) => widget.itemBuilder(context, i, items[i]),
      separatorBuilder: (_, i) => widget.separatorBuilder?.call(context, i) ?? const SizedBox(),
      itemCount: items.length,
    );
  }
}

源码:EasyRefreshMixin.dart

//
//  EasyRefreshMixin.dart
//  projects
//
//  Created by shang on 2026/1/28 14:37.
//  Copyright © 2026/1/28 shang. All rights reserved.
//

import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/refresh/n_refresh_view.dart';

/// EasyRefresh刷新 mixin
mixin EasyRefreshMixin<W extends StatefulWidget, T> on State<W> {
  late final refreshController = EasyRefreshController(
    controlFinishRefresh: true,
    controlFinishLoad: true,
  );


  /// 请求方式
  late RequestListCallback<T> _onRequest;
  RequestListCallback<T> get onRequest => _onRequest;
  set onRequest(RequestListCallback<T> value) {
    _onRequest = value;
  }

  // 数据列表
  List<T> _items = [];
  List<T> get items => _items;
  set items(List<T> value) {
    _items = value;
  }

  int page = 1;
  final int pageSize = 20;
  var indicator = IndicatorResult.success;

  @override
  void dispose() {
    refreshController.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // DLog.d([widget.title, widget.key, hashCode]);
      if (items.isEmpty) {
        onRefresh();
      }
    });
  }

  Future<void> onRefresh() async {
    try {
      page = 1;
      final list = await onRequest(true, page, pageSize, <T>[]);
      items.replaceRange(0, items.length, list);
      page++;

      final noMore = list.length < pageSize;
      if (noMore) {
        indicator = IndicatorResult.noMore;
      }
      refreshController.finishRefresh();
      refreshController.resetFooter();
    } catch (e) {
      refreshController.finishRefresh(IndicatorResult.fail);
    }
    setState(() {});
  }

  Future<void> onLoad() async {
    if (indicator == IndicatorResult.noMore) {
      refreshController.finishLoad();
      return;
    }

    try {
      final start = (items.length - pageSize).clamp(0, pageSize);
      final prePages = items.sublist(start);
      final list = await onRequest(false, page, pageSize, prePages);
      items.addAll(list);
      page++;

      final noMore = list.length < pageSize;
      if (noMore) {
        indicator = IndicatorResult.noMore;
      }
      refreshController.finishLoad(indicator);
    } catch (e) {
      refreshController.finishLoad(IndicatorResult.fail);
    }
    setState(() {});
  }
}

最后、总结

1、当页面比较复杂,需要吸顶或者嵌套滚动时就必须使用 Sliver 相关组件,否则会有滚动行文冲突。

2、NCustomScrollView 支持顶部吸顶组件自定义;底部列表头,列表尾设置,支持sliver 设置 Decoration。

3、支持下拉刷新,上拉加载更多,代码极简,使用方便。

4、刷新逻辑封装在 EasyRefreshMixin 混入里,方便多组件可共用。

github

❌
❌