阅读视图

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

React DevTools 组件名乱码?揭秘从开发到生产的代码变形记

React DevTools 组件名乱码?聊聊代码压缩这件事

线上打开 React DevTools,打开一看,组件树全是 C0$rpv 这种不可读的字符。

开发环境明明好好的,叫 NavigationProviderDialogPortal,怎么到线上就全变了?

image.png

问题出在哪

简单说:生产构建时,代码被压缩了,函数名被改成了短字符。

React DevTools 依赖函数名来显示组件名。函数名变了,显示的自然也就变了。

开发环境 vs 生产环境

开发环境

function NavigationProvider({ children }) {
    return <Provider>{children}</Provider>;
}

React DevTools 显示:NavigationProvider

生产环境(压缩后):

function pv({children:e}){return jsx(Provider,{children:e})}

React DevTools 显示:pv

从开发到生产:代码都经历了什么

要理解为什么会被压缩,先要知道我们写的代码是怎么变成用户访问的线上代码的。

开发模式:原汁原味

用脚手架(Create React App、Vite)开发时,启动的是开发服务器

npm run dev
# 或
npm start

这时候:

  • 代码实时编译,但不压缩
  • 保留完整的变量名、函数名
  • 包含 Source Maps(方便调试)
  • 有热更新(Hot Module Replacement)

浏览器加载的代码长这样:

// http://localhost:3000/src/App.jsx
function NavigationProvider({ children }) {
    const [location, setLocation] = useState('/');
    return <Provider>{children}</Provider>;
}

清清楚楚,React DevTools 自然能读到 NavigationProvider

生产构建:全面优化

准备部署上线时,要执行构建命令:

npm run build

这一步会调用打包工具(Webpack、Vite、Rollup),做一系列优化:

graph TD
    A[源代码<br/>多个 .jsx 文件] --> B[编译<br/>JSX → JS]
    B --> C[打包<br/>合并成少数文件]
    C --> D[Tree Shaking<br/>删除未使用代码]
    D --> E[压缩<br/>Minification]
    E --> F[生产代码<br/>dist/main.abc123.js]
1. 编译(Transpilation)

Babel 把 JSX 和现代 JS 语法转成浏览器能理解的代码。

// 源代码
<Provider>{children}</Provider>

// 编译后
React.createElement(Provider, null, children)
2. 打包(Bundling)

把几十上百个文件合并成几个文件。

开发环境:
├── App.jsx
├── components/
│   ├── Navigation.jsx
│   ├── Header.jsx
│   └── Footer.jsx
└── utils/
    └── helpers.js

生产环境:
└── dist/
    └── main.abc123.js  ← 全部合并
3. Tree Shaking

删除没用到的代码。

// utils.js
export function usedFunction() { /* ... */ }
export function unusedFunction() { /* ... */ }  // 这个没被 import

// 打包后:unusedFunction 被删除
4. 压缩(Minification)

这就是导致组件名乱码的关键步骤。

压缩器(Terser、esbuild)把代码体积压到最小:

  • 删除空格、换行、注释
  • 缩短变量名和函数名
  • 简化代码逻辑
// 压缩前
function NavigationProvider({ children }) {
    const [location, setLocation] = useState('/');
    return <Provider>{children}</Provider>;
}

// 压缩后
function pv({children:e}){const[t,n]=useState("/");return jsx(Provider,null,e)}

为什么生产环境要这么做

一个字:快。

用户访问网站时:

  1. 浏览器从服务器下载 JS 文件
  2. 解析代码
  3. 执行代码

如果不压缩

  • 一个中型 React 应用,原始代码可能 2-3 MB
  • 在 3G 网络下,下载要 20-30 秒
  • 用户看到白屏,早跑了

压缩后

  • 代码体积降到 500-800 KB
  • Gzip 压缩后可能只有 200 KB
  • 下载时间缩短到 3-5 秒

体积差异这么大,主要因为:

  • 空格和换行:原始代码为了可读性,大量使用缩进和换行(占 20-30%)
  • 变量名和函数名NavigationProviderpv 这种压缩(占 30-40%)
  • 注释:开发时的注释在生产环境完全删除(占 5-10%)
  • 未使用的代码:Tree Shaking 删除(占 10-20%)

所以,压缩是生产环境的必备步骤,不是可选项。

压缩在哪个阶段

在 Webpack 或 Vite 的配置中,压缩是最后一步:

Webpack 配置(简化版):

// webpack.config.js
module.exports = {
    mode: 'production',  // 自动启用压缩

    optimization: {
        minimize: true,  // 开启压缩
        minimizer: [
            new TerserPlugin(),  // 使用 Terser 压缩
        ],
    },
};

Vite 配置(简化版):

// vite.config.js
export default {
    build: {
        minify: 'terser',  // 使用 Terser 压缩(默认是 esbuild)
    },
};

当你执行 npm run build,打包工具会:

  1. 编译所有源文件
  2. 合并成几个大文件
  3. 最后调用压缩器处理
  4. 输出到 dist/ 目录

压缩是构建流程的最后一步,产出的就是上线的代码。

开发和生产的环境区别

特性 开发环境 生产环境
命令 npm run dev npm run build
代码压缩
变量名 NavigationProvider pv
文件体积 2-3 MB 500-800 KB
Source Maps ✅ 完整 ❌ 或隐藏
调试体验 轻松 困难
加载速度 慢(本地不care) 快(关键指标)

现在明白了:开发时你写的清晰代码,到用户那里已经面目全非

而组件名乱码,就是这个"面目全非"的副作用。

为什么要压缩函数名

理解了背景,再看具体的压缩逻辑就清楚了。

减小文件体积,加快加载速度。

压缩器做的事:

  1. 删除空格和换行
  2. 缩短变量名userNameu
  3. 缩短函数名NavigationProviderpv
  4. 简化代码结构

看个真实例子:

压缩前(15 KB):

function NavigationProvider({ children }) {
    const [location, setLocation] = useState('/');

    const navigate = (path) => {
        setLocation(path);
    };

    return (
        <Context.Provider value={{ location, navigate }}>
            {children}
        </Context.Provider>
    );
}

压缩后(4 KB):

function pv({children:e}){const[t,n]=useState("/");return jsx(Context.Provider,{value:{location:t,navigate:r=>n(r)},children:e})}

体积直接砍掉 70%。

其中 NavigationProviderpv 就省了 17 个字符。整个项目几百个函数,加起来就是几十 KB 的差异。

压缩过程详解

压缩不是简单的文本替换,而是经过多个阶段的代码转换。

完整的构建流程

graph LR
    A[源代码 JSX] --> B[Babel 转译]
    B --> C[打包合并]
    C --> D[Terser 压缩]
    D --> E[生产代码]
  1. Babel 转译:把 JSX 转成标准 JavaScript
  2. 打包合并:多个文件合成一个文件
  3. Terser 压缩:真正的压缩发生在这一步
  4. 输出:最终的生产代码

重点看第三步,压缩器内部其实也分好几个阶段。

Terser 的压缩阶段

阶段 1:解析(Parse)

把代码转成抽象语法树(AST)。

// 源代码
function NavigationProvider({ children }) {
    return <Provider>{children}</Provider>;
}

// 转成 AST(简化版)
{
    type: "FunctionDeclaration",
    id: { type: "Identifier", name: "NavigationProvider" },
    params: [
        {
            type: "ObjectPattern",
            properties: [{ key: "children", value: "children" }]
        }
    ],
    body: {
        type: "ReturnStatement",
        argument: { type: "JSXElement", ... }
    }
}

AST 就是代码的树状结构表示,方便后续分析和修改。

阶段 2:分析(Analyze)

2.1 作用域分析

找出哪些变量名可以改,哪些不能改。

function NavigationProvider({ children }) {
    const location = useState('/');  // 局部变量,可以改
    return <Provider>{children}</Provider>;
}

window.NavigationProvider = NavigationProvider;  // 全局引用,不能改

规则:

  • 可以改:函数内部的局部变量、函数名(如果没被外部引用)
  • 不能改:全局变量、对象属性名、被 eval() 使用的变量

2.2 引用计数

统计每个标识符出现了多少次,决定是否值得压缩。

function NavigationProvider({ children }) {  // NavigationProvider 出现 1 次
    const location = useState('/');          // location 出现 1 次
    const currentLocation = location;        // location 出现 2 次
    return currentLocation;                  // currentLocation 出现 1 次
}

如果一个变量只用了 1 次,改成短名称可能不划算(比如 locationa 只省 7 个字符)。

2.3 依赖分析

找出变量之间的依赖关系,避免命名冲突。

function outer() {
    const a = 1;
    function inner() {
        const b = 2;  // b 可以重命名为 a,因为不在同一作用域
        return b;
    }
    return a;
}
阶段 3:转换(Transform)

这是真正做压缩的阶段,分多个步骤。

3.1 删除死代码(Dead Code Elimination)

// 压缩前
function NavigationProvider({ children }) {
    const DEBUG = false;
    if (DEBUG) {
        console.log('debug');  // 这段永远不会执行
    }
    return <Provider>{children}</Provider>;
}

// 压缩后
function NavigationProvider({ children }) {
    return <Provider>{children}</Provider>;
}

3.2 常量折叠(Constant Folding)

// 压缩前
const MAX_COUNT = 10;
const DOUBLED = MAX_COUNT * 2;
if (count > DOUBLED) { /* ... */ }

// 压缩后
if (count > 20) { /* ... */ }

直接算出结果,减少运行时计算。

3.3 表达式简化

// 压缩前
if (isActive === true) { /* ... */ }

// 压缩后
if (isActive) { /* ... */ }

3.4 变量名压缩(Mangle)

这是我们关心的重点。

压缩器遍历 AST,按照一定规则替换标识符:

// 压缩前
function NavigationProvider({ children }) {
    const [location, setLocation] = useState('/');
    const navigate = (path) => setLocation(path);
    return <Provider value={{ location, navigate }}>{children}</Provider>;
}

// 第一轮:函数名
function pv({ children }) {  // NavigationProvider → pv
    const [location, setLocation] = useState('/');
    const navigate = (path) => setLocation(path);
    return <Provider value={{ location, navigate }}>{children}</Provider>;
}

// 第二轮:参数名
function pv({ children: e }) {  // children → e
    const [location, setLocation] = useState('/');
    const navigate = (path) => setLocation(path);
    return <Provider value={{ location, navigate }}>{e}</Provider>;
}

// 第三轮:局部变量
function pv({ children: e }) {
    const [t, n] = useState('/');  // location → t, setLocation → n
    const r = (o) => n(o);         // navigate → r, path → o
    return <Provider value={{ location: t, navigate: r }}>{e}</Provider>;
}

注意:对象属性名不会改location:navigate: 保持原样),因为这些是对外的接口。

3.5 作用域提升(Scope Hoisting)

把多个模块合并到一个作用域,减少闭包和函数调用。

// 压缩前(两个文件)
// utils.js
export function formatDate(date) { return date.toString(); }

// app.js
import { formatDate } from './utils';
console.log(formatDate(new Date()));

// 压缩后(合并)
function a(b) { return b.toString(); }
console.log(a(new Date()));
阶段 4:生成(Generate)

把修改后的 AST 转回代码字符串。

// AST 转回代码
{
    type: "FunctionDeclaration",
    id: { name: "pv" },  // 已被修改
    params: [{ name: "e" }],
    body: { ... }
}

// 生成代码
function pv(e){const[t,n]=useState("/");return jsx(Provider,{value:{location:t,navigate:r=>n(r)},children:e})}

同时删除所有空格、换行、注释。

命名规则详解

压缩器分配短名称时的优先级:

1. 单字母(52 个)
a, b, c, ..., z
A, B, C, ..., Z

最常用的标识符会优先分配这些。

2. 美元符号和下划线(104 个)
$a, $b, ..., $Z
_a, _b, ..., _Z

单字母用完就加前缀。

3. 两个字母(2704 个)
aa, ab, ac, ..., ZZ

再不够就用两个字母组合。

4. 特殊组合
$, _, $$, $_, ...

然后是各种特殊字符组合。

所以你看到的 pv$rJt 都是按照这个顺序分配的。

真实例子对比

拿一段完整的代码看看每个阶段的变化:

原始代码(150 行,5 KB):

import React, { useState, useContext } from 'react';

const NavigationContext = React.createContext(null);

export function NavigationProvider({ children }) {
    const [currentLocation, setCurrentLocation] = useState('/');

    const navigate = (newPath) => {
        if (newPath !== currentLocation) {
            setCurrentLocation(newPath);
            window.history.pushState({}, '', newPath);
        }
    };

    const value = {
        location: currentLocation,
        navigate: navigate
    };

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

经过 Babel(转 JSX):

import React, { useState, useContext } from 'react';

const NavigationContext = React.createContext(null);

export function NavigationProvider({ children }) {
    const [currentLocation, setCurrentLocation] = useState('/');

    const navigate = (newPath) => {
        if (newPath !== currentLocation) {
            setCurrentLocation(newPath);
            window.history.pushState({}, '', newPath);
        }
    };

    const value = {
        location: currentLocation,
        navigate: navigate
    };

    return React.createElement(
        NavigationContext.Provider,
        { value: value },
        children
    );
}

经过 Terser 分析(内部记录):

作用域分析:
- NavigationContext: 全局导出,不能改
- NavigationProvider: 全局导出,不能改
- children: 函数参数,可以改 → e
- currentLocation: 局部变量,可以改 → t
- setCurrentLocation: 局部变量,可以改 → n
- navigate: 局部变量,可以改 → r
- newPath: 函数参数,可以改 → o
- value: 局部变量,可以改 → a(但会被内联优化掉)

经过 Terser 压缩(最终产物,1.5 KB):

import{useState as t}from"react";const n=React.createContext(null);export function NavigationProvider({children:e}){const[r,o]=t("/"),c=i=>{i!==r&&(o(i),window.history.pushState({},"",i))};return React.createElement(n.Provider,{value:{location:r,navigate:c}},e)}

体积对比

  • 原始代码:5 KB
  • Babel 转译后:5.2 KB(JSX 转换略有增加)
  • Terser 压缩后:1.5 KB(减少 70%)

关键变化:

  1. 删除所有空格和换行:5KB → 4KB
  2. 变量名压缩:4KB → 2KB
  3. 表达式简化和内联:2KB → 1.5KB

为什么这么激进

现代 Web 应用动辄几百个组件,上千个函数。

如果不压缩函数名和变量名:

  • 平均每个函数名 15 字符
  • 1000 个函数 = 15KB 仅用于命名
  • 加上变量名,总共可能 50KB+

50KB 在 3G 网络下,多加载 3-5 秒。

所以压缩器默认非常激进,能压缩的全压缩。

背后的原理

函数名是怎么被替换的

压缩器内部维护一个映射表:

原始名称 → 压缩后名称
NavigationProvider → pv
LocationProvider → $r
DialogPortal → Jt
Link → vv

规则很简单:

  1. 按出现顺序分配短名称
  2. 优先用单字母(a-z, A-Z)
  3. 单字母用完就用两个字母(aa, ab, ...)
  4. 加上特殊字符($, _)增加组合数

所以你会看到 a$rpvJt 这种看起来毫无规律的名称。

React DevTools 怎么知道组件名

React DevTools 获取组件名的逻辑:

graph LR
    A[读取组件] --> B{有 displayName?}
    B -->|有| C[显示 displayName]
    B -->|没有| D{有函数名?}
    D -->|有| E[显示函数名]
    D -->|没有| F[显示 Anonymous]

优先级:displayName > 函数名 > "Anonymous"

开发环境:

  • 函数名完整保留 → DevTools 读到 NavigationProvider

生产环境:

  • 函数名被压缩成 pv → DevTools 只能读到 pv
  • 没设置 displayName → 没有备用方案

所以就出现了开头那张图的情况。

为什么不是所有组件都乱码

你可能注意到,有些组件名还是正常的,比如 LinkMenuDialog

原因有三种:

1. 组件设置了 displayName

const MyComponent = () => <div>Hello</div>;
MyComponent.displayName = 'MyComponent';

压缩器不会改 displayName(它是字符串,不是标识符)。

2. 组件来自第三方库

import { Dialog } from '@mui/material';

第三方库通常会设置 displayName,或者配置了保留函数名的构建选项。

3. 名称太短,碰巧没变

function Link() { /* ... */ }

如果原本就叫 Link,压缩后可能还是 Link(4 个字符,不一定值得压缩)。

但这纯靠运气,不能依赖。

怎么解决

核心思路就两个方向:

1. 告诉压缩器:别改函数名

Webpack/Vite 配置中,设置 Terser 选项:

terserOptions: {
    keep_fnames: true  // 保留函数名
}

代价:包体积增加 5-10%。

2. 手动给组件加 displayName

export const NavigationProvider = ({ children }) => {
    return <Provider>{children}</Provider>;
};

NavigationProvider.displayName = 'Navigation.Provider';

好处:精确控制,体积影响小。 代价:要手动维护。

该不该解决

内部系统/管理后台

  • 调试需求高,体积不敏感
  • 建议保留函数名,调试体验直接拉满

面向用户的产品

  • 体积影响加载速度,调试主要在开发环境
  • 不用管,或者只给核心组件加 displayName

开源组件库

  • 用户调试需要清晰的组件名
  • 建议加 displayName,或构建时保留函数名

大多数情况,这不是个必须解决的问题。线上调试本来就该在 staging 环境(保留函数名),生产环境靠日志和监控。

相关资料

  1. Terser 文档 - 了解 keep_fnames 等配置
  2. React 官方:为什么我的组件叫 _c - React 文档的解释

搞清楚原理就好办了。遇到这种情况,知道是代码压缩导致的,而不是 React 或 DevTools 的 bug。

要不要解决,看具体需求。大部分时候,不用管。

TDesign UniApp 组件库来了

1. 背景

跨端开发一直是前端领域的重要部分,旨在实现一套代码在多个平台运行。国内使用 uniapp 框架人数较多,一直有外部声音想要 uniapp 版本的 TDesign,如 TDesign Miniprogram 下的众多 issue

转存失败,建议直接上传图片文件

原生小程序和 uniapp 有差异,有人在 uniapp 项目里用了原生小程序组件,需要魔改内部组件代码。

基于以上需求,写了 TDesign UniApp 项目。支持:

  • 🌈 暗色模式
  • 🌈 自定义主题
  • 🌍 国际化
  • 🚀 API 对齐官方
  • 🚀 类型提示
  • ...

欢迎使用,欢迎 star,欢迎反馈!

2. 预览

扫码查看 ↓

(注:其他平台同样支持,仅因平台审核等原因未能上架预览,不影响组件库正常使用。)

3. 快速开始

3.1. 安装

  1. NPM 方式
npm i tdesign-uniapp
  1. UNI_MODULES 方式

已上传插件到 DCloud 插件市场,请打开插件详情页并点击使用 HBuilderX 导入插件

3.2. 引入并使用

  1. main.ts 中引入样式文件
import 'tdesign-uniapp/common/style/theme/index.css';
  1. 在文件中使用
<template>
  <t-loading />
</template>

<script lang="ts" setup>
import TLoading from 'tdesign-uniapp/loading/loading.vue';
</script>

3.3. 自动导入

pages.json 配置 easycom,可实现自动导入。

  1. CLI 模式

使用 CLI 模式,即使用 node_modules 下的 tdesign-uniapp 时,配置如下。

{
  "easycom": {
    "custom": {
      "^t-(.*)": "tdesign-uniapp/$1/$1.vue"
    }
  }
}
  1. UNI_MODULES 模式

使用 uni_modules 下的 tdesign-uniapp 时,配置如下。

{
  "easycom": {
    "custom": {
      "^t-(.*)": "@/uni_modules/tdesign-uniapp/components/$1/$1.vue"
    }
  }
}

3.4. 平台兼容性

平台 Vue2 Vue3 H5 Android iOS App-nvue 微信小程序 QQ小程序
支持情况 ⚠️
平台 支付宝小程序 抖音小程序 百度小程序 快手小程序 小红书小程序 京东小程序
支持情况

4. 浅思考

有几点是做之前要想清楚的。

4.1. 为什么不做转换工具

  1. 工具转出来的可读性差,可维护性差
  2. 转换工具无法做到100%,总有些语法需要手动转换。这意味着一定会有人工介入
  3. 维护转换工具成本比维护组件库高好几倍,且写出来的还不一定就能完全满足
  4. 业务真正要用的是组件库,真正关心的也是组件库

4.2. 与 tdesign-miniprogram 版本关系

tdesign-uniapp 有独立的版本,并不与 tdesign-miniprogram 的版本相同。这是因为转换后的产物很有可能有自己的 feature/bug,处理需要发版,必然导致版本分叉。

多个 tdesign-uniapp 版本会对应一个 tdesign-miniprogram 版本,会尽量提供 miniprogram 最新版本的转换产物。

4.3. API 设计

API 一定要与官方一致,这是最不能妥协的,包括 propsevents、事件参数,参数类型、插槽、CSS变量。

这样做的好处是,开发者没有额外心智负担,同时限制开发人员的胡乱发挥,以及减少开发者的决策成本。

API 尽量与小程序对齐,而不是 mobile-vue/mobile-react,因为 uniapp 语法主要是小程序的语法。

4.4. 可维护性

  • 用统一的语法
  • 不使用编译后的、混淆后的变量

5. 转化过程

5.1. 核心转换逻辑

之前写过 Press UI,整体思路差不多。就是将小程序的 wxml/wxss/js/json 转成 uniapp 的 Vue,四个文件合成一个文件。以及将小程序的语法进行转化,以下是核心部分:

  1. uniComponent 包裹,内部有一些公共处理
  2. properties => props
  3. setData => data 正常赋值
  4. 生命周期改造
  5. 事件改造
  6. props 文件改造,from: value: ([^{]+),to: default: $1

其他部分,如 externalClassesrelations,以及组件库特有的受控属性、命令调用等都需要进行额外的处理。

5.2. 事件参数

tdesign-miniprogram 中的事件参数,在 tdesign-uniapp 中都被去掉了 detail 一层。以 Picker 组件为例,在 tdesign-miniprogram 中,这样获取参数

onPickerChange(e) {
  console.log(e.detail.value);
}

tdesign-uniapp 中,需要去掉 .detail,即

onPickerChange(e) {
  console.log(e.value);
}

这样做是为了简化使用。tdesign-uniapp 中所有组件都采用了这种方式。

6. 细节

6.1. 命令调用

tdesign-uniapp 中支持命令调用的组件有

  • ActionSheet
  • Dialog
  • Message
  • Toast

TDesign UniApp 下,命令调用的核心思路是数据转化,就是把所有 props 都声明成 data,比如 visible => dataVisible,这样组件自身才能既能从方法(methods)中得到值,又能从 props 中得到值。要改的地方包括

  1. data 中初始化
  2. watch 中监听
  3. setData 收口,设置的时候都加上特殊开头

每个组件具体实现不同。

  • Message 嵌套了一层 message-itemmessage-item 没有 props,都是 setData 直接给的 data,所以根本不需要转换。
    • 这是另一种解决思路了,用嵌套子组件,而不是转换数据。子组件一嵌套,且数据全部不走 props,而是调用子组件内部方法。
    • 展示时, setMessage(组件调用、命令调用都走) => addMessage ( => showMessageItem) 或者 updateMessage
    • Message 中的 setMessage/addMessage/showMessageItem 都是指的内部的 message-item,是循环的 messageList,而不是页面级别的 t-message
  • Dialog、ActionSheet 需要转换
    • 调用 setData,将属性(包含 visible: true)传进去,同时将 instance_onConfirm 设置为 promiseresolve
  • Toast 没有组件调用,只有命令式,无需数据转换。
    • 调用 instance.show,内部还是 setData

6.2. 受控属性

存在受控属性的非表单组件有

  • 反馈类:ActionSheet、DropdownItem、Guide
  • 展示类:CheckTag、Collapse、Image-viewer
  • 导航类:Indexes、Sidebar、Steps、Tabbar、Tabs

TDesign UniApp 中受控属性的处理,和小程序版本差不多。是将其转成 data 开头的内部属性,初始化的时候,会判断受控和非受控值。同时触发事件的时候也要判断当前是否存在受控属性,非受控的时候直接改变内部值并抛出事件,受控的时候只抛出事件。以及,props 中受控属性的默认值需是 nullundefined

不同的是,小程序受控属性,可以使用 this.setData({ [value]: this.defaultValue }),也就是 data 中声明了一个和 properties 名称一样的变量,Vue 中不可以,会报错 'set' on proxy: trap returned falsish for property 'value'

总结下来,受控属性要处理的:

  1. watch 中监听
  2. created 中初始化
  3. methods 中新增 _trigger,作为抛出事件的收口

6.3. 三方库

tdesign-miniprogram 执行 npm run build,在 miniprogram_dist/node_modules 目录下 拿到 dayjstinycolor2 的产物,复制到 tdesign-uniappnpm 目录下,用啥拿啥 。

一次性工作,一般不会改。

6.4. input 受控

H5 下,uni-app 封装了 input,且不支持受控。

Input 限制中文字符在 uni-app 实现的话,解决方案是先设置一次,然后在 nextTick 中再设置一次。

参考:ask.dcloud.net.cn/article/397…

其他方案:

  1. 可以动态创建 input 元素,不用 uni-app 包裹的,缺点是更新属性麻烦。
  2. 动态计算 maxlength,用浏览器原生属性约束,缺点是实现稍复杂、代码量稍多。

6.5. externalClass

uni-app 下,externalClasses 是不生效的。

参考:

所以 styleIsolation: apply-shared 不够用,以只能改成 styleIsolation: shared,这样开发者才能在任意使用的地方覆盖组件样式。

可以改下 packages/site/node_modules/@dcloudio/uni-mp-compiler/dist/transforms/transformComponent.js,把 isComponentProp 方法,将 t-class 排除,就能解决,但是官方不会推出。

6.6. scoped

tdesign-uniapp 必须加 scoped,否则一个自定义组件加了 styleIsolation: shared,同一页面下其他没加此属性的自定义组件也会生效,只要 class 相同!

6.7. t-class

统一用 tClass,而不是 class

转存失败,建议直接上传图片文件

6.8. distanceTop

Drawer 顶部过高,是因为子组件 popup 中使用的 --td-popup-distance-top 变量为 0,这个变量由 distanceTop 生成,distanceTop 又是由 using-custom-navbar 这个 mixin 生成。

distanceTopuni.getMenuButtonBoundingClientRect 计算生成,H5 和 App 下没有这个API,可以直接传入 customNavbarHeight,这个值由业务自行计算得到。

目前使用到 using-custom-navbar 这个 mixin 的组件有

  • Overlay,基础,使用到它的也会引用
    • Popup
    • Picker
    • ActionSheet
    • Calendar
    • Dialog
    • Drawer
    • Guide
    • Toast
  • Fab
  • ImageViewer

6.9. page-scroll

APP-PLUS 下,动态监听 onPageScroll 不生效,需要业务自己在页面中监听,下面给出最佳实践之一。

// 页面 Vue 文件下,引入组件库提供的监听方法
// 该方法内部会通过 event-bus,传递参数给对应的组件
import { handlePageScroll } from 'tdesign-uniapp/mixins/page-scroll';

export default {
  onPageScroll(e) {
    handlePageScroll(e);
  },
}

目前使用到 page-scroll 这个 mixin 的组件有

  1. Sticky
  2. Indexes
  3. Tabs(引入了 Sticky)

示例页面有

  • Fab
  • PullDownRefresh

6.10. getCustomNavbarHeight 报错

Cannot read properties of null (reading 'parentElement')
转存失败,建议直接上传图片文件

这种就是 mounted 之后没延时,没获取到对应元素。

6.11. site 工程中的 alias

tdesign-uniapp 在 H5 下使用 vite.config 中的 alias,不使用 workspace,可解决修改组件后必须重启才能生效。

小程序下,这种方式需要进一步改造,只能引用同一个子工程,即不能跨 src,解决方案就是监听组件变动,同步复制到 site 工程下。

6.12. watch

小程序的 observersvuewatch 逻辑并不完全相同,小程序下,如果 prop 接收外部传入的实参与该 prop 的默认值不相等时,会导致 observer 被立即调用一次,Vue 而不是。

imagecalcSize 中就用到了。

6.13. auto-import

开发了 auto-import-resolver 插件,但是发现微信小程序下编译有问题,H5 下正常,推测是 uniapp 自己的问题。

转存失败,建议直接上传图片文件

可以使用 easycom 模式。

⚠️ 注意,easycom 不支持 TIcon 这种大驼峰,只能是 t-icon,这种中划线形式。

6.14. visible

下面几个组件在关闭时,需要父组件中设置 visiblefalse,否则无法再次开启。也就是 visible 只能是受控的。可以给 visible 属性增加 v-model 语法糖。

  • drawer
  • cascader
  • calendar
  • date-time-picker
  • color-picker

7. 支付宝小程序

7.1. styleIsolation

支付宝小程序只支持在 json 文件中配置 styleIsolation,参考文档

uni-app 会静态分析组件中的 styleIsolation 配置,放到组件对应的 json 文件中。源码地址:packages/uni-mp-vite/src/plugins/entry.ts

正则表达式如下:

const styleIsolationRE = [
  /defineOptions\s*[\s\S]*?styleIsolation\s*:\s*['"](isolated|apply-shared|shared)['"]/,
  /export\s+default\s+[\s\S]*?styleIsolation\s*:\s*['|"](isolated|apply-shared|shared)['|"]/,
]

所以,不能用 uniComponent 在运行时添加,只能在 Vue 中显式声明。

7.2. background

Stepper 中需显式声明 background 和 padding。

转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

Search 中同样问题。

转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

7.3. disable-scroll

滚动穿透问题,uniapp 有通用方案 @touchmove.stop.prevent="noop",支付宝下无效,需要设置 disable-scroll。参考文档

转存失败,建议直接上传图片文件

⚠️ 注意,设置 disable-scrolltrue 后,所有子元素的滚动都不能冒泡了,即便子元素设置的 disable-scrollfalse,所以也尽可能减少 disable-scroll 属性的覆盖范围。

7.4. :deep 编译问题

避免 less 中两个 :deep 嵌套,其中一个不会被转化。

转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

7.5. scroll-view

微信小程序 scroll-view,宽度 100%。支付宝小程序不是,需手动设置,不设置的话,撑不开。

转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

8. 抖音小程序

8.1. virtualHost

遇到一个点击事件不能传递的问题,排查下来以为是不能用 uniComponent 包裹,猜测其内部会静态检测 js 文件。后面发现是不能使用 virtualHost: true,不止 button 组件,其他组件也不一样。

8.2. 样式穿透

抖音小程序原生的话,可以用 externalClasses 来进行样式覆盖,但是前面提到过 uni-app 不支持。

它也不支持标签选择器,加上刚说的不能用 virtualHost: true,所以它的样式穿透是最麻烦的。

解决方案是,根据具体情况,对 class/t-class/style/custom-style 这些属性区分平台处理,比如

  • DropdownItem 组件中,btn 用了 class/t-class 区分,radio-group/checkbox-group 用了 custom-style
  • AvatarGroup 组件中,avatar 用了 setStylechildren 获取),因为 avatar 是外部定义的,无法用 custom-style
  • 涉及到伪类的只能用 class,不能用 custom-style

8.3. 父子关系

抖音小程序给两个组件绑定父子关系也是最复杂的,其他小程序及H5可以通过 provide/inject 来收集 parent,抖音小程序中找不到(下面部分截图是放的 PressUI 组件库的)。

转存失败,建议直接上传图片文件

这里想到一个办法是递归调用 $parent,找最近的一个和目标组件名称相同的 parent。比如 picker-item 中就找组件名称为 TPicker 最近的父组件。

但是,抖音小程序子孙组件的 $parent 竟然就是页面,页面的所有 $children 都是拉平的。基于此,想到的办法是从上往下遍历这个拉平的 $children,找距离子组件最近的一个父组件。

转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

但是,页面的 $children 并不是"父子父子父子.."这样顺序排列的,而是"父父父子子子...",导致 $children 收集有问题,要么多于实际,要么为空。

转存失败,建议直接上传图片文件

想到的办法是父子组件之间传递一个 relationKey,这个值是唯一的,找 $parent 时就不会找错了。

function findNearListParent(children = [], name) {
  let temp;
  for (const item of children) {
    const parentRelationKey = item.$props?.relationKey;
    const thisRelationKey = this.$props?.relationKey;
    if (item.$options.name === name && parentRelationKey === thisRelationKey) {
      temp = item;
    }
    if (item === this && temp) {
      return temp;
    }
  }

  return temp;
}

上面的 relationKey 应该永远从业务传入。内部组件,不管父子,都只接受 props,不自己生产,减少复杂度。这样的话,不管用 slot<x><x-item></x> 还是用一个 <x>,都能保证 relationKey 同一个,且不论空还是不空,都是相等的。

此外,还有这种游离在依赖树之外的 vm 实例,也拿不到 provide 的值。

转存失败,建议直接上传图片文件

这种主要发生在 Popup 组件内部的父子关系,比如 dropdown-menu 组件中的 radio-group/radiocascader 组件 tab 模式的 tabs/tab-panel

这种问题的一个解决方案是在使用它们的地方手动关联。

8.4. 生命周期

Vue 中父子组件生命周期正常的执行顺序是:父组件先创建,然后子组件创建;子组件先挂载,然后父组件挂载,即“父beforeCreate-> 父create -> 子beforeCreate-> 子created -> 子mounted -> 父mounted”。

抖音小程序并不遵循这样的规律。

转存失败,建议直接上传图片文件

这个问题会导致父子组件的初始化数据出问题,之前在父组件 mounted 中执行的初始逻辑,都会因为还没收集完 children,而失败。

转存失败,建议直接上传图片文件

解决办法有两种,可用延时,也可用回调。回调更安全,延时可能跟机器性能有关。回调就是在子组件 mounted 的时候调用父组件的数据初始化方法。

9. 其他

9.1. 最简单的

button 不是最简单的,loading/icon 才是最简单的,它们是 button 的子元素。

9.2. 组件归类

转存失败,建议直接上传图片文件

导航类

  • Navbar、Tabbar、Sidebar、Indexes 分别是上下左右四个方向的导航,固定
  • Drawer、BackTop 都是可隐藏的,点击某处或滑动到某处时才显示
  • Tabs 是业务中最常用的导航类组件,Steps 比 Tabs 更苛刻,有顺序,这两都以 s 结尾

反馈类

  • Overlay、Popup、Loading 基础
  • Message、Toast、Dialog、NoticeBar 是一类,Message 上+动态,Toast 中间,Dialog 中间,更重,NoticeBar 上+固定
  • DropdownMenu、ActionSheet 一个从上往下显示,一个从下往上
  • SwipeCell,PulldownRefresh 一个向左滑,一个向下滑
  • Guide 特殊,全局,其他的都是局部

输入类

  • Input、Textarea、Search,文字输入
  • Radio、Checkbox、Switch,点击选择
  • Stepper、Slider,数字选择(输入)一个是点击,一个是滑动
  • Picker,Cascader、TreeSelect,滑动选择
  • Calendar、DatetimePicker,特殊场景
  • ColorPicker,特殊场景
  • Rate,特殊场景
  • Upload,特殊场景

9.3. 野蛮生长

只有流量大的、用户多的APP,才可能有小程序。国内小程序生态百花齐放,没有两个是完全一样的。每一种小程序框架、文档、运营平台、开发者工具、审核等都需要不少的工作量、不少的人力。看得出来中国互联网过去几年发展的可以。

9.4. 图标

转存失败,建议直接上传图片文件

上面是几个小程序开发者工具的图标

  • 微信/qq、支付宝、百度(BAT)
  • 抖音、快手、小红书(分享社区)
  • 京东

有意思的是,大家想的都差不多

  1. 体现连接
    • 抖音,平面
    • 京东,立体
    • 快手,横向
    • 百度,中间
  2. 代码符号
    • 支付宝
    • 小红书
    • 微信(结合了自己的 logo)
  3. 产品 logo 变形
    • QQ
    • 微信

9.5. wxComponent

tdesign-miniprogramwxComponent 类的作用:

  1. 属性,处理受控属性,增加 default* 属性的默认值,增加 style/customStyle 属性,增加 aria* 相关属性
  2. externalClasses,增加 class
  3. 方法,增加 _trigger,兼容受控情况下的抛出事件,非生命周期函数挂载在 methods 对象上
  4. 生命周期函数放到 lifetimes

9.6. uni-app

src/core/runtime/mp/polyfill/index.js

uni-app 中运行时对 vant-weapppolyfill 核心逻辑

9.7. data

只要不在模板中使用data 不用提前声明,created 中动态声明即可

created() {
  this.xxx = 'xxx';
}

9.8. Slider 组件细节

前置变量:

  • initLeft = boxLeft - halfblock
  • initRight = boxRight - halfblock
  • maxRange = boxRight - boxLeft - blockSize - 6 ( 6 是边框)

capsule 模式下:

  1. 左边滑块滑动,offset = blockSize + 3currentLeft = clientLeft - initLeft - offset,就是 clientLeft - boxLeft - halfBlock - 3
  2. 右边滑动滑动,offset = - 3currentIRight = -(clientRight - initRight - offset),就是 boxRight - clientRight - halfBlock - 3

假设 boxLeft = 0boxRight = 100, halfBlock = 10,

  • 左就是 clientLeft - 13,左边最小是 13
  • 右就是 87 - clientRight,右边最大是 87
  • maxRange 就是 74
转存失败,建议直接上传图片文件

图中分别是左、右、边框。

10. 反馈

有任何问题,建议通过 Github issues 反馈或扫码加入用户微信群。

11. 总结

TDesign is an artwork.

向 TDesign 的开发者致敬🫡。

后续规划是

  1. 同步 TDesign Miniprogram 改动,尽量在小程序版本发布后的一周内,同步改动到 uniapp 版本上
  2. 兼容调试更多平台
  3. 模板工程等

注,本文发布于非工作时间。

typescript 方法前面加* 是什么意思

在 TypeScript 中,方法前面加 * 表示这是一个 生成器函数(Generator Function)

基本语法

function* sendMessageStream() {
    // 函数体
    yield "消息1";
    yield "消息2";
    yield "消息3";
}

生成器函数的特点

1. 使用 yield 关键字

function* messageGenerator() {
    yield "Hello";
    yield "World";
    yield "!";
}

const generator = messageGenerator();
console.log(generator.next()); // { value: "Hello", done: false }
console.log(generator.next()); // { value: "World", done: false }
console.log(generator.next()); // { value: "!", done: false }
console.log(generator.next()); // { value: undefined, done: true }

2. 可暂停和恢复执行

function* countGenerator() {
    let count = 0;
    while (count < 3) {
        yield count++;
    }
}

const counter = countGenerator();
console.log(counter.next().value); // 0
console.log(counter.next().value); // 1
console.log(counter.next().value); // 2
console.log(counter.next().value); // undefined

实际应用场景

1. 消息流处理(如 sendMessageStream

function* sendMessageStream(messages: string[]) {
    for (const message of messages) {
        // 可以在这里添加处理逻辑
        const processedMessage = `[${new Date().toISOString()}] ${message}`;
        yield processedMessage;
        
        // 模拟一些延迟或条件
        if (message === "stop") {
            return; // 结束生成器
        }
    }
}

// 使用示例
const messages = ["消息1", "消息2", "stop", "消息3"];
const stream = sendMessageStream(messages);

let result = stream.next();
while (!result.done) {
    console.log(result.value);
    result = stream.next();
}

2. 无限序列

function* idGenerator() {
    let id = 1;
    while (true) {
        yield id++;
    }
}

const idGen = idGenerator();
console.log(idGen.next().value); // 1
console.log(idGen.next().value); // 2
console.log(idGen.next().value); // 3
// 可以无限继续...

3. 状态机

function* trafficLight() {
    while (true) {
        yield "red";
        yield "yellow";
        yield "green";
    }
}

const light = trafficLight();
console.log(light.next().value); // "red"
console.log(light.next().value); // "yellow"
console.log(light.next().value); // "green"
console.log(light.next().value); // "red"

与异步生成器的区别

普通生成器

function* syncGenerator() {
    yield 1;
    yield 2;
    yield 3;
}

异步生成器(ES2018+)

async function* asyncSendMessageStream() {
    yield await fetchMessage1();
    yield await fetchMessage2();
    yield await fetchMessage3();
}

// 使用 for await...of
async function processMessages() {
    for await (const message of asyncSendMessageStream()) {
        console.log(message);
    }
}

在类中的使用

class MessageService {
    private messages: string[] = [];
    
    *sendMessageStream(): Generator<string> {
        for (const message of this.messages) {
            yield `Processed: ${message}`;
        }
    }
    
    // 异步版本
    async *sendAsyncMessageStream(): AsyncGenerator<string> {
        for (const message of this.messages) {
            // 模拟异步操作
            const processed = await this.processMessageAsync(message);
            yield processed;
        }
    }
    
    private async processMessageAsync(message: string): Promise<string> {
        return `Async: ${message}`;
    }
}

实际用例:分页数据流

function* paginatedDataStream(pageSize: number = 10) {
    let page = 0;
    let hasMore = true;
    
    while (hasMore) {
        // 模拟 API 调用获取分页数据
        const data = fetchPageData(page, pageSize);
        
        if (data.length === 0) {
            hasMore = false;
            return; // 没有更多数据
        }
        
        yield data;
        page++;
        
        // 可以添加条件来停止
        if (page > 100) { // 安全限制
            hasMore = false;
        }
    }
}

// 模拟分页数据获取
function fetchPageData(page: number, pageSize: number): any[] {
    // 返回模拟数据
    return Array.from({ length: pageSize }, (_, i) => ({
        id: page * pageSize + i,
        content: `Item ${page * pageSize + i}`
    }));
}

总结

方法前加 * 表示生成器函数:

  • 可暂停执行:使用 yield 暂停函数执行并返回值
  • 保持状态:函数调用之间的状态会被保留
  • 惰性求值:值在需要时才生成
  • 迭代协议:遵循迭代器协议,可与 for...of 循环配合使用

对于 sendMessageStream 这样的方法,很可能是用于:

  • 逐步发送消息
  • 处理消息流
  • 实现某种状态机或序列生成
  • 分批次处理数据

告别span嵌套地狱:CSS Highlights API重新定义语法高亮

CSS Highlights API:不用 DOM 操作也能实现语法高亮

做代码编辑器或者技术博客的时候,语法高亮是个绕不开的需求。传统方案是给每个关键字、字符串、注释包一层 <span> 标签,然后加上不同的 class。

问题是,几十行代码下来,DOM 树就被塞满了几百个 span 节点。浏览器渲染起来慢,内存占用也高,还容易出性能问题。

最近研究了 CSS Highlights API,发现这玩意儿挺有意思的:不操作 DOM,直接用 Range 标记文本位置,性能提升明显。来看看到底怎么回事。

文章底部有代码示例,可以直接复制下来运行测试对比不同方案的区别。

image.png

传统方案的问题

先看看我们常用的方法:

// 传统方案:为每个 token 包裹 span
function highlightCode(code) {
    const tokens = tokenize(code);
    let html = '';

    for (const token of tokens) {
        html += `<span class="token-${token.type}">${token.value}</span>`;
    }

    element.innerHTML = html;
}

这样做,一段 50 行的代码,轻松产生 200-300 个 DOM 节点。想想也是,每个关键字、每个字符串、每个数字都是一个节点,能不多吗。

DOM 节点多了带来几个问题:

  1. 渲染慢:浏览器要构建整个 DOM 树,计算每个节点的样式和布局
  2. 内存占用高:每个节点都要占内存,几百个节点就是几百份数据
  3. 更新麻烦:代码一改,整个 innerHTML 重新生成,所有节点重建

特别是在代码编辑器场景下,用户每输入一个字符,就要重新生成一遍所有节点,卡顿在所难免。

CSS Highlights API 的思路

CSS Highlights API 换了个思路:不修改 DOM 结构,只标记文本位置

核心原理说穿了挺简单:

graph LR
    A[纯文本 TextNode] --> B[词法分析 Tokenize]
    B --> C[创建 Range 对象]
    C --> D[按类型分组]
    D --> E[注册到 CSS.highlights]
    E --> F[浏览器直接渲染高亮]

整个过程不创建新的 DOM 节点,文本始终是一个完整的 text node。

具体来说:

  1. 保持纯文本:代码放在一个 text node 里,不拆分
  2. 用 Range 标记:Range 对象只是标记"第 10 个字符到第 18 个字符"这样的位置信息
  3. CSS 负责渲染:用 ::highlight() 伪元素定义样式,浏览器直接渲染

实现细节

1. 定义高亮样式

/* 定义不同 token 类型的样式 */
::highlight(keyword) {
    color: #569cd6;
    font-weight: bold;
}

::highlight(string) {
    color: #ce9178;
}

::highlight(comment) {
    color: #6a9955;
    font-style: italic;
}

这里用 ::highlight() 伪元素,括号里的名称对应后面注册时的 key。

2. 词法分析

function tokenize(code) {
    const tokens = [];

    // 定义匹配规则(顺序很重要)
    const patterns = [
        { type: 'comment', regex: /\/\/[^\n]*/g },
        { type: 'string', regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g },
        { type: 'keyword', regex: /\b(function|const|let|var|if|else|return)\b/g },
        { type: 'number', regex: /\b\d+\.?\d*\b/g },
        // ... 其他规则
    ];

    for (const { type, regex } of patterns) {
        let match;
        while ((match = regex.exec(code)) !== null) {
            tokens.push({
                type,
                start: match.index,
                end: match.index + match[0].length,
                value: match[0]
            });
        }
    }

    return tokens;
}

这里要注意:

  • comment 和 string 放最前面:确保注释和字符串内部的内容不会被其他规则匹配
  • 去重处理:多个规则可能匹配同一段文本,要去掉重叠的 token

3. 创建 Range 并注册

function applyHighlights(element, code) {
    // 检查浏览器支持
    if (!CSS.highlights) {
        return; // 降级到传统方案
    }

    // 设置纯文本(只有一个 text node)
    element.textContent = code;
    const textNode = element.firstChild;

    const tokens = tokenize(code);

    // 按类型分组
    const tokensByType = new Map();
    for (const token of tokens) {
        if (!tokensByType.has(token.type)) {
            tokensByType.set(token.type, []);
        }

        // 创建 Range 标记位置
        const range = new Range();
        range.setStart(textNode, token.start);
        range.setEnd(textNode, token.end);
        tokensByType.get(token.type).push(range);
    }

    // 注册到 CSS.highlights
    for (const [type, ranges] of tokensByType) {
        const highlight = new Highlight(...ranges);
        CSS.highlights.set(type, highlight);
    }
}

关键点:

  • Range 只是标记:不修改 DOM,只是告诉浏览器"这段文本需要高亮"
  • 按类型注册:同一类型的 token(比如所有关键字)共享一个 Highlight 对象
  • CSS 自动匹配:注册的名称(如 keyword)会匹配 CSS 中的 ::highlight(keyword)

性能对比

实测数据(50 行代码,约 150 个 token):

指标 CSS Highlights API 传统 DOM 方案 提升
DOM 节点数 1 个 300+ 个 99.7%
首次渲染时间 0.8ms 2.5ms 68%
内存占用 ~70%
重新渲染 ~60%

优势明显:

  1. 节点数大幅减少:从几百个节点降到 1 个
  2. 渲染更快:浏览器不用构建复杂的 DOM 树
  3. 内存占用低:Range 对象比 DOM 节点轻量得多
  4. 更新高效:修改代码只需重新创建 Range,不用重建 DOM

需要注意的点

1. 浏览器兼容性

if (!CSS.highlights) {
    // 降级到传统方案
    applyTraditionalHighlight(element, code);
    return;
}

当前支持情况:

  • Chrome/Edge 105+
  • Firefox 140+
  • Safari 17.2+

不支持的浏览器需要 fallback 方案。

2. 词法分析的顺序

const patterns = [
    { type: 'comment', regex: /\/\/[^\n]*/g },    // 必须在最前面
    { type: 'string', regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g },  // 也要优先
    { type: 'keyword', regex: /\b(function|const)\b/g },
    // ... 后续规则
];

为什么 comment 和 string 要放前面?

因为注释里可能包含关键字,字符串里可能包含数字。如果关键字规则先匹配,注释和字符串内部就会被错误高亮。

3. 去重逻辑

// 按位置排序
tokens.sort((a, b) => a.start - b.start);

// 去除重叠的 token
const filteredTokens = [];
let lastEnd = 0;

for (const token of tokens) {
    if (token.start >= lastEnd) {
        filteredTokens.push(token);
        lastEnd = token.end;
    }
}

这样可以确保:

  • 注释中的冒号不会被 operator 规则重复匹配
  • 字符串中的关键字不会被单独高亮

4. Range 不会自动更新

// 代码改变后,需要重新创建 Range
function updateCode(newCode) {
    CSS.highlights.clear();  // 清除旧的
    applyHighlights(element, newCode);  // 重新应用
}

Range 对象只是快照,不会跟随文本变化自动更新。代码编辑器场景需要监听输入事件,及时重新生成。

适用场景

适合用 CSS Highlights API 的场景

  1. 代码展示:技术博客、文档站、代码分享平台
  2. 只读编辑器:查看器、diff 工具、代码审查工具
  3. 性能敏感:大文件预览、移动端展示

不太适合的场景

  1. 复杂编辑器:需要光标定位、选区管理、行号对齐等功能
  2. 老浏览器支持:IE、老版 Safari 不支持,需要完善的 fallback
  3. 极致性能要求:虚拟滚动、增量渲染的场景可能需要更定制化的方案

和 Prism.js、Highlight.js 的区别

常见的高亮库都是基于 DOM 的:

特性 CSS Highlights API Prism.js / Highlight.js
DOM 节点数 1 个 几百个
渲染性能
语言支持 需自己实现 内置几十种语言
主题系统 CSS 自定义 预设主题
插件生态 丰富
学习成本

简单总结:

  • 语言支持少、要求性能:用 CSS Highlights API
  • 需要开箱即用、多语言支持:用 Prism.js / Highlight.js
  • 极致性能 + 定制化:自己基于 CSS Highlights API 封装

相关文档

官方标准文档

  1. CSS Custom Highlight API - MDN - API 使用指南
  2. Highlight API Specification - W3C 规范草案

技术文章

  1. High Performance Syntax Highlighting - 性能对比和实现细节

浏览器兼容性

  1. Can I Use - CSS Custom Highlight - 浏览器支持情况

完整 Demo

下面是一个完整的对比演示,左侧展示 CSS Highlights API 方案,右侧展示传统 DOM 方案,可以直接看到性能差异:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS Highlights API - 语法高亮演示</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 2rem;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        h1 {
            color: white;
            text-align: center;
            margin-bottom: 1rem;
            font-size: 2.5rem;
            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
        }

        .info {
            background: rgba(255, 255, 255, 0.95);
            padding: 1rem;
            border-radius: 8px;
            margin-bottom: 2rem;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }

        .info h2 {
            color: #667eea;
            margin-bottom: 0.5rem;
        }

        .info p {
            color: #4a5568;
            line-height: 1.6;
        }

        .demo-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 2rem;
            margin-bottom: 2rem;
        }

        @media (max-width: 768px) {
            .demo-grid {
                grid-template-columns: 1fr;
            }
        }

        .demo-section {
            background: white;
            border-radius: 12px;
            overflow: hidden;
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
        }

        .demo-header {
            background: #2d3748;
            color: white;
            padding: 1rem;
            font-weight: bold;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .demo-header .badge {
            background: #48bb78;
            padding: 0.25rem 0.75rem;
            border-radius: 12px;
            font-size: 0.875rem;
        }

        .demo-header .badge-warning {
            background: #f59e0b;
        }

        .code-container {
            background: #1e1e1e;
            padding: 1.5rem;
            overflow-x: auto;
            min-height: 400px;
        }

        .code-block {
            font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
            font-size: 14px;
            line-height: 1.6;
            color: #d4d4d4;
            white-space: pre;
        }

        /* CSS Highlights API Styles */
        ::highlight(keyword) {
            color: #569cd6;
            font-weight: bold;
        }

        ::highlight(string) {
            color: #ce9178;
        }

        ::highlight(comment) {
            color: #6a9955;
            font-style: italic;
        }

        ::highlight(function) {
            color: #dcdcaa;
        }

        ::highlight(number) {
            color: #b5cea8;
        }

        ::highlight(operator) {
            color: #d4d4d4;
        }

        ::highlight(punctuation) {
            color: #d4d4d4;
        }

        ::highlight(identifier) {
            color: #9cdcfe;
        }

        /* Traditional span-based highlighting */
        .token-keyword {
            color: #569cd6;
            font-weight: bold;
        }

        .token-string {
            color: #ce9178;
        }

        .token-comment {
            color: #6a9955;
            font-style: italic;
        }

        .token-function {
            color: #dcdcaa;
        }

        .token-number {
            color: #b5cea8;
        }

        .token-operator {
            color: #d4d4d4;
        }

        .token-punctuation {
            color: #d4d4d4;
        }

        .token-identifier {
            color: #9cdcfe;
        }

        .stats {
            background: rgba(255, 255, 255, 0.95);
            padding: 1.5rem;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }

        .stats h3 {
            color: #667eea;
            margin-bottom: 1rem;
        }

        .stats-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 1rem;
        }

        .stat-item {
            background: #f7fafc;
            padding: 1rem;
            border-radius: 6px;
            border-left: 4px solid #667eea;
        }

        .stat-label {
            color: #718096;
            font-size: 0.875rem;
            margin-bottom: 0.25rem;
        }

        .stat-value {
            color: #2d3748;
            font-size: 1.5rem;
            font-weight: bold;
        }

        .warning {
            background: #fef3c7;
            border-left: 4px solid #f59e0b;
            padding: 1rem;
            border-radius: 4px;
            margin-top: 1rem;
        }

        .warning p {
            color: #92400e;
        }

        .hidden {
            display: none;
        }

        .controls {
            background: rgba(255, 255, 255, 0.95);
            padding: 1rem;
            border-radius: 8px;
            margin-bottom: 2rem;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            text-align: center;
        }

        button {
            background: #667eea;
            color: white;
            border: none;
            padding: 0.75rem 1.5rem;
            border-radius: 6px;
            font-size: 1rem;
            cursor: pointer;
            margin: 0 0.5rem;
            transition: background 0.3s;
        }

        button:hover {
            background: #5568d3;
        }

        button:active {
            transform: translateY(1px);
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🎨 CSS Highlights API 演示</h1>

        <div class="info">
            <h2>关于此演示</h2>
            <p>
                此演示对比了两种语法高亮方法:现代的 <strong>CSS Highlights API</strong>(左侧)与传统的
                <strong>基于 DOM 的高亮</strong>(右侧)。CSS Highlights API 提供高性能的语法高亮,
                无需操作 DOM,将文本保持在单个文本节点中,以获得最佳渲染性能。
            </p>
        </div>

        <div class="controls">
            <button type="button" onclick="remeasure()">🔄 重新测量性能</button>
            <button type="button" onclick="changeCode()">🎲 切换代码示例</button>
        </div>

        <div class="demo-grid">
            <div class="demo-section">
                <div class="demo-header">
                    <span>CSS Highlights API</span>
                    <span class="badge">现代方案</span>
                </div>
                <div class="code-container">
                    <pre class="code-block" id="highlights-demo"></pre>
                </div>
            </div>

            <div class="demo-section">
                <div class="demo-header">
                    <span>传统 DOM Span 方案</span>
                    <span class="badge badge-warning">传统方案</span>
                </div>
                <div class="code-container">
                    <pre class="code-block" id="traditional-demo"></pre>
                </div>
            </div>
        </div>

        <div class="stats">
            <h3>📊 性能对比</h3>
            <div class="stats-grid">
                <div class="stat-item">
                    <div class="stat-label">CSS Highlights - DOM 节点数</div>
                    <div class="stat-value" id="highlights-nodes">-</div>
                </div>
                <div class="stat-item">
                    <div class="stat-label">传统方案 - DOM 节点数</div>
                    <div class="stat-value" id="traditional-nodes">-</div>
                </div>
                <div class="stat-item">
                    <div class="stat-label">CSS Highlights - 渲染时间</div>
                    <div class="stat-value" id="highlights-time">-</div>
                </div>
                <div class="stat-item">
                    <div class="stat-label">传统方案 - 渲染时间</div>
                    <div class="stat-value" id="traditional-time">-</div>
                </div>
                <div class="stat-item">
                    <div class="stat-label">性能提升</div>
                    <div class="stat-value" id="improvement">-</div>
                </div>
                <div class="stat-item">
                    <div class="stat-label">内存节省</div>
                    <div class="stat-value" id="memory-saved">-</div>
                </div>
            </div>
            <div class="warning hidden" id="browser-warning">
                <p><strong>⚠️ 浏览器兼容性:</strong>您的浏览器不支持 CSS Highlights API。只有传统的高亮方法能正常工作。</p>
            </div>
        </div>
    </div>

    <script>
        // 代码示例
        const codeSamples = [
            `// JavaScript 示例
function fibonacci(n) {
    // 计算斐波那契数
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(10);
console.log("结果:", result);

// 数组操作
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(x => x * 2);`,

            `// React 组件
function UserProfile({ name, age }) {
    const [isActive, setActive] = useState(false);

    // 处理点击事件
    const handleClick = () => {
        setActive(!isActive);
        console.log("状态已改变");
    };

    return (
        <div className="profile">
            <h1>{name}</h1>
            <p>年龄: {age}</p>
        </div>
    );
}`,

            `// TypeScript 接口
interface User {
    id: number;
    name: string;
    email: string;
}

function getUserData(userId: number): Promise<User> {
    // 获取用户数据
    return fetch(\`/api/users/\${userId}\`)
        .then(response => response.json())
        .catch(error => {
            console.error("错误:", error);
            throw error;
        });
}`
        ];

        let currentCodeIndex = 0;

        /**
         * 词法分析器 - 将代码文本解析成 token 列表
         * @param {string} code - 要分析的源代码
         * @returns {Array} - token 数组,每个 token 包含 type, start, end, value
         */
        function tokenize(code) {
            const tokens = [];

            // 定义匹配规则,顺序很重要:
            // 1. comment 和 string 放在最前面,确保它们内部的内容不会被其他规则匹配
            // 2. 后续规则按照优先级排列
            const patterns = [
                { type: 'comment', regex: /\/\/[^\n]*/g },                    // 单行注释
                { type: 'string', regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g },   // 字符串(支持单引号、双引号、模板字符串)
                { type: 'keyword', regex: /\b(function|const|let|var|if|else|return|class|interface|async|await|import|export|from|new|typeof|instanceof)\b/g }, // JavaScript 关键字
                { type: 'number', regex: /\b\d+\.?\d*\b/g },                 // 数字(整数和小数)
                { type: 'function', regex: /\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\()/g }, // 函数名(后面跟着左括号)
                { type: 'operator', regex: /[+\-*/%=<>!&|^~?:]/g },          // 运算符
                { type: 'punctuation', regex: /[{}[\]();,\.]/g },            // 标点符号
            ];

            // 遍历所有规则,收集匹配的 token
            for (const { type, regex } of patterns) {
                let match;
                while ((match = regex.exec(code)) !== null) {
                    tokens.push({
                        type,
                        start: match.index,
                        end: match.index + match[0].length,
                        value: match[0]
                    });
                }
            }

            // 按起始位置排序
            tokens.sort((a, b) => a.start - b.start);

            // 去除重叠的 token(保留先匹配到的)
            // 例如:注释中的冒号不应该被 operator 规则再次匹配
            const filteredTokens = [];
            let lastEnd = 0;

            for (const token of tokens) {
                if (token.start >= lastEnd) {
                    filteredTokens.push(token);
                    lastEnd = token.end;
                }
            }

            return filteredTokens;
        }

        /**
         * 应用 CSS Highlights API 进行语法高亮
         * 核心思想:不创建额外的 DOM 节点,通过 Range 对象标记文本位置
         * @param {HTMLElement} element - 目标元素
         * @param {string} code - 源代码
         * @returns {Object} - 包含性能数据和清理函数
         */
        function applyHighlights(element, code) {
            // 检查浏览器是否支持 CSS Highlights API
            if (!CSS.highlights) {
                document.getElementById('browser-warning').classList.remove('hidden');
                return { time: 0, nodes: 0, cleanup: () => {} };
            }

            const startTime = performance.now();

            // 清除之前的所有高亮
            CSS.highlights.clear();

            // 将代码设置为纯文本内容(只有一个 text node)
            element.textContent = code;

            const textNode = element.firstChild;
            if (!textNode) return { time: 0, nodes: 0, cleanup: () => {} };

            // 获取所有 token
            const tokens = tokenize(code);

            // 按 token 类型分组,每种类型对应一个 Highlight 对象
            const tokensByType = new Map();
            for (const token of tokens) {
                if (!tokensByType.has(token.type)) {
                    tokensByType.set(token.type, []);
                }

                // 创建 Range 对象标记 token 在文本中的位置
                // 关键:Range 只是标记位置,不修改 DOM 结构
                const range = new Range();
                range.setStart(textNode, token.start);
                range.setEnd(textNode, token.end);
                tokensByType.get(token.type).push(range);
            }

            // 为每种 token 类型注册 Highlight
            // CSS 中的 ::highlight(keyword)、::highlight(string) 等会匹配这里注册的名称
            for (const [type, ranges] of tokensByType) {
                const highlight = new Highlight(...ranges);
                CSS.highlights.set(type, highlight);
            }

            const endTime = performance.now();

            return {
                time: (endTime - startTime).toFixed(2),
                nodes: countNodes(element),
                cleanup: () => CSS.highlights.clear()
            };
        }

        /**
         * 应用传统的基于 DOM 的语法高亮
         * 传统方法:为每个 token 创建一个 <span> 元素
         * @param {HTMLElement} element - 目标元素
         * @param {string} code - 源代码
         * @returns {Object} - 包含性能数据
         */
        function applyTraditional(element, code) {
            const startTime = performance.now();

            const tokens = tokenize(code);
            let html = '';
            let lastIndex = 0;

            // 遍历所有 token,构建 HTML 字符串
            for (const token of tokens) {
                // 添加 token 之前的普通文本
                html += escapeHtml(code.substring(lastIndex, token.start));

                // 为 token 包裹 span 标签,添加对应的 class
                // 缺点:每个 token 都创建一个 DOM 节点,大量代码会产生数百个节点
                html += `<span class="token-${token.type}">${escapeHtml(token.value)}</span>`;
                lastIndex = token.end;
            }

            // 添加最后的剩余文本
            html += escapeHtml(code.substring(lastIndex));

            // 将 HTML 字符串插入 DOM(触发浏览器解析和渲染)
            element.innerHTML = html;

            const endTime = performance.now();

            return {
                time: (endTime - startTime).toFixed(2),
                nodes: countNodes(element)
            };
        }

        /**
         * HTML 转义函数 - 防止 XSS 攻击
         */
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        /**
         * 统计 DOM 节点数量
         * 用于对比两种方法的内存占用差异
         */
        function countNodes(element) {
            let count = 0;
            const walker = document.createTreeWalker(element, NodeFilter.SHOW_ALL);
            while (walker.nextNode()) count++;
            return count;
        }

        /**
         * 更新性能统计数据显示
         */
        function updateStats(highlightsResult, traditionalResult) {
            document.getElementById('highlights-nodes').textContent = highlightsResult.nodes;
            document.getElementById('traditional-nodes').textContent = traditionalResult.nodes;
            document.getElementById('highlights-time').textContent = highlightsResult.time + ' ms';
            document.getElementById('traditional-time').textContent = traditionalResult.time + ' ms';

            // 计算性能提升百分比
            const improvement = ((traditionalResult.time - highlightsResult.time) / traditionalResult.time * 100).toFixed(1);
            document.getElementById('improvement').textContent = improvement + '%';

            // 计算内存节省百分比
            const memorySaved = ((traditionalResult.nodes - highlightsResult.nodes) / traditionalResult.nodes * 100).toFixed(1);
            document.getElementById('memory-saved').textContent = memorySaved + '%';
        }

        /**
         * 渲染代码并应用两种高亮方法
         */
        function renderCode() {
            const code = codeSamples[currentCodeIndex];
            const highlightsElement = document.getElementById('highlights-demo');
            const traditionalElement = document.getElementById('traditional-demo');

            // 分别应用两种方法,对比性能差异
            const highlightsResult = applyHighlights(highlightsElement, code);
            const traditionalResult = applyTraditional(traditionalElement, code);

            updateStats(highlightsResult, traditionalResult);
        }

        /**
         * 切换代码示例
         */
        function changeCode() {
            currentCodeIndex = (currentCodeIndex + 1) % codeSamples.length;
            renderCode();
        }

        /**
         * 重新测量性能
         */
        function remeasure() {
            renderCode();
        }

        // 页面加载完成后初始化
        window.addEventListener('DOMContentLoaded', renderCode);
    </script>
</body>
</html>

kotlin-4

太棒了!现在让我们进入 Kotlin 最激动人心的部分——协程现代异步编程。这将彻底改变你处理并发和异步任务的方式。


Kotlin 协程:重新定义异步编程

二十二、协程基础:轻量级线程

协程是 Kotlin 用于异步编程的解决方案,比线程更轻量、更高效。

import kotlinx.coroutines.*

fun main() {
    println("主线程开始: ${Thread.currentThread().name}")
    
    // 启动一个协程
    GlobalScope.launch {
        println("协程开始: ${Thread.currentThread().name}")
        delay(1000L) // 非阻塞延迟
        println("协程结束: ${Thread.currentThread().name}")
    }
    
    println("主线程继续执行: ${Thread.currentThread().name}")
    Thread.sleep(2000L) // 阻塞主线程,等待协程完成
    println("主线程结束")
}

// 输出:
// 主线程开始: main
// 主线程继续执行: main
// 协程开始: DefaultDispatcher-worker-1
// 协程结束: DefaultDispatcher-worker-1
// 主线程结束

关键概念:

  • launch: 启动一个不返回结果的协程
  • delay(): 非阻塞的挂起函数
  • 协程运行在后台线程池,不会阻塞主线程

二十三、挂起函数:异步操作的基石

挂起函数是协程的核心,用 suspend关键字标记。

import kotlinx.coroutines.*

suspend fun fetchUserData(userId: Int): String {
    delay(1000L) // 模拟网络请求
    return "用户 $userId 的数据"
}

suspend fun fetchUserPosts(userId: Int): String {
    delay(1500L) // 模拟数据库查询
    return "用户 $userId 的帖子"
}

fun main() = runBlocking {
    val time = measureTimeMillis {
        val userData = fetchUserData(1)
        val userPosts = fetchUserPosts(1)
        println("$userData, $userPosts")
    }
    println("顺序执行耗时: ${time}ms")
}

二十四、异步并发:同时执行多个任务

使用 async和 await实现并发操作。

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

fun main() = runBlocking {
    val time = measureTimeMillis {
        // 同时启动两个异步任务
        val userDataDeferred = async { fetchUserData(1) }
        val userPostsDeferred = async { fetchUserPosts(1) }
        
        // 等待两个任务都完成
        val userData = userDataDeferred.await()
        val userPosts = userPostsDeferred.await()
        
        println("$userData, $userPosts")
    }
    println("并发执行耗时: ${time}ms") // 时间接近最长任务的时间
}

// 输出:
// 用户 1 的数据, 用户 1 的帖子
// 并发执行耗时: 1506ms (而不是 1000 + 1500 = 2500ms)

二十五、协程上下文与调度器

控制协程在哪个线程上运行。

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 在不同调度器上启动协程
    launch { // 继承父协程的上下文
        println("默认调度器: ${Thread.currentThread().name}")
    }
    
    launch(Dispatchers.Unconfined) { // 不受限制,在第一个挂起点之前运行在调用者线程
        println("不受限制: ${Thread.currentThread().name}")
    }
    
    launch(Dispatchers.Default) { // CPU 密集型任务
        println("默认调度器: ${Thread.currentThread().name}")
    }
    
    launch(Dispatchers.IO) { // I/O 密集型任务
        println("IO 调度器: ${Thread.currentThread().name}")
    }
    
    launch(newSingleThreadContext("MyThread")) { // 专用线程
        println("专用线程: ${Thread.currentThread().name}")
    }
}

二十六、结构化并发:避免资源泄漏

使用 coroutineScope实现结构化并发。

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 结构化并发:所有子协程完成后,父协程才完成
    val result = coroutineScope {
        val userData = async { fetchUserData(1) }
        val userPosts = async { fetchUserPosts(1) }
        
        "结果: ${userData.await()} + ${userPosts.await()}"
    }
    println(result)
}

// 处理异常的结构化并发
suspend fun fetchDataSafely(): Result<String> = coroutineScope {
    try {
        val data = async { 
            delay(500L)
            if (System.currentTimeMillis() % 2 == 0L) {
                throw RuntimeException("模拟错误")
            }
            "获取的数据"
        }.await()
        Result.success(data)
    } catch (e: Exception) {
        Result.failure(e)
    }
}

二十七、流(Flow):异步数据流

Flow 是 Kotlin 的响应式流处理,类似 RxJava。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

// 创建流
fun simpleFlow(): Flow<Int> = flow {
    println("流开始")
    for (i in 1..3) {
        delay(100L) // 模拟异步工作
        emit(i) // 发射值
    }
}

// 流的中间操作
fun processNumbers(): Flow<String> = flow {
    for (i in 1..5) {
        emit(i)
    }
}.filter { it % 2 == 0 } // 过滤偶数
 .map { "数字: $it" }    // 转换
 .onEach { println("处理: $it") } // 副作用

fun main() = runBlocking {
    // 收集流
    simpleFlow().collect { value -> 
        println("收集到: $value") 
    }
    
    println("--- 带中间操作的流 ---")
    processNumbers().collect { println("最终结果: $it") }
}

二十八、通道(Channel):协程间通信

Channel 用于协程之间的通信。

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<String>()
    
    // 生产者协程
    launch {
        val users = listOf("Alice", "Bob", "Charlie")
        for (user in users) {
            channel.send(user) // 发送数据
            delay(100L)
        }
        channel.close() // 关闭通道
    }
    
    // 消费者协程
    launch {
        for (user in channel) { // 接收数据
            println("收到用户: $user")
        }
        println("通道已关闭")
    }
}

二十九、实战:构建完整的异步应用

让我们构建一个完整的用户信息获取系统。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.system.measureTimeMillis

// 数据类
data class User(val id: Int, val name: String, val email: String)
data class Post(val id: Int, val userId: Int, val title: String, val content: String)
data class UserProfile(val user: User, val posts: List<Post>, val friends: List<User>)

// 模拟远程数据源
object UserRepository {
    suspend fun getUser(id: Int): User {
        delay(500L) // 模拟网络延迟
        return User(id, "用户$id", "user$id@example.com")
    }
    
    suspend fun getUserPosts(userId: Int): List<Post> {
        delay(300L)
        return listOf(
            Post(1, userId, "标题1", "内容1"),
            Post(2, userId, "标题2", "内容2")
        )
    }
    
    suspend fun getUserFriends(userId: Int): List<User> {
        delay(400L)
        return listOf(
            User(userId + 1, "朋友1", "friend1@example.com"),
            User(userId + 2, "朋友2", "friend2@example.com")
        )
    }
}

// 业务逻辑层
class UserService {
    suspend fun getUserProfile(userId: Int): UserProfile = coroutineScope {
        val userDeferred = async { UserRepository.getUser(userId) }
        val postsDeferred = async { UserRepository.getUserPosts(userId) }
        val friendsDeferred = async { UserRepository.getUserFriends(userId) }
        
        UserProfile(
            user = userDeferred.await(),
            posts = postsDeferred.await(),
            friends = friendsDeferred.await()
        )
    }
    
    // 使用 Flow 实现实时更新
    fun getUserProfileStream(userId: Int): Flow<UserProfile> = flow {
        while (true) {
            val profile = getUserProfile(userId)
            emit(profile)
            delay(5000L) // 每5秒更新一次
        }
    }
}

// UI 层(模拟)
fun main() = runBlocking {
    val userService = UserService()
    
    println("=== 获取用户资料 ===")
    val time = measureTimeMillis {
        val profile = userService.getUserProfile(1)
        println("用户: ${profile.user.name}")
        println("帖子数量: ${profile.posts.size}")
        println("好友数量: ${profile.friends.size}")
    }
    println("耗时: ${time}ms")
    
    println("\n=== 实时数据流 ===")
    // 模拟实时数据流(只收集3次)
    userService.getUserProfileStream(1)
        .take(3) // 只取3个值
        .collect { profile ->
            println("实时更新: ${profile.user.name} - 帖子: ${profile.posts.size}")
        }
}

三十、异常处理与超时控制

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 1. 基本的异常处理
    val job = launch {
        try {
            delay(1000L)
            throw RuntimeException("测试异常")
        } catch (e: Exception) {
            println("捕获异常: ${e.message}")
        }
    }
    job.join()
    
    // 2. 协程异常处理器
    val handler = CoroutineExceptionHandler { _, exception ->
        println("协程异常处理器捕获: ${exception.message}")
    }
    
    val job2 = launch(handler) {
        throw RuntimeException("被处理器捕获的异常")
    }
    job2.join()
    
    // 3. 超时控制
    try {
        withTimeout(1300L) {
            repeat(1000) { i ->
                println("任务 $i")
                delay(500L)
            }
        }
    } catch (e: TimeoutCancellationException) {
        println("任务超时")
    }
    
    // 4. 超时返回默认值
    val result = withTimeoutOrNull(1300L) {
        delay(1000L)
        "成功结果"
    } ?: "超时默认值"
    
    println("结果: $result")
}

三十一、高级模式:生产者-消费者模式

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun CoroutineScope.produceUsers() = produce {
    var id = 1
    while (true) {
        send(User(id, "用户$id", "user$id@example.com"))
        delay(200L)
        id++
        if (id > 5) break // 只生产5个用户
    }
}

fun CoroutineScope.processUser(userChannel: ReceiveChannel<User>) = produce {
    for (user in userChannel) {
        delay(100L) // 模拟处理时间
        send("已处理: ${user.name}")
    }
}

fun main() = runBlocking {
    val userProducer = produceUsers()
    val processor = processUser(userProducer)
    
    // 消费处理结果
    for (result in processor) {
        println(result)
    }
    
    println("处理完成")
}

下一步学习方向

你现在已经掌握了 Kotlin 协程的核心概念!接下来可以探索:

  1. Flow 高级操作combinezipflatMapLatest等操作符
  2. 状态管理:使用 StateFlow和 SharedFlow
  3. 测试协程:使用 TestCoroutineDispatcher和 runTest
  4. Android 上的协程:与 ViewModel、Lifecycle 集成
  5. 服务端协程:Ktor 框架的使用

协程是 Kotlin 生态中最强大的特性之一,它将彻底改变你处理异步编程的方式。尝试在实际项目中使用这些模式,你会发现代码变得更加简洁和易于维护!

kotlin-3

太棒了!现在让我们进入 Kotlin 最精彩的部分——面向对象编程和高级特性。这一部分将彻底改变你对编程的认知。


Kotlin 面向对象编程:重新定义代码组织方式

十四、类与对象:简洁的开始

Kotlin 中的类定义极其简洁,告别了 Java 的样板代码。

// 最简单的类定义
class Person

// 带主构造函数的类
class Person constructor(firstName: String) { /*...*/ }

// 主构造函数可以省略 constructor 关键字
class Person(firstName: String) {
    // 属性直接在类体中声明
    var firstName: String = firstName
    var lastName: String = ""
    var age: Int = 0
}

// 更简洁的写法:在主构造函数中直接声明属性
class Person(
    val firstName: String,  // 只读属性
    var lastName: String,   // 可变属性
    var age: Int = 0        // 带默认值的参数
) {
    // 类体
}

使用类:

val person = Person("张", "三", 25)
println(person.firstName)  // 输出:张
person.age = 26            // 可以修改
// person.firstName = "李" // 错误!firstName 是 val

十五、数据类(Data Class):自动生成样板代码

这是 Kotlin 的"魔法"特性之一,专门用于存储数据。

// 数据类定义 - 一行代码搞定!
data class User(
    val id: Int,
    val name: String,
    val email: String
)

// 自动获得以下功能:
val user1 = User(1, "Alice", "alice@example.com")
val user2 = User(1, "Alice", "alice@example.com")

// 1. 自动实现 toString()
println(user1) // 输出:User(id=1, name=Alice, email=alice@example.com)

// 2. 自动实现 equals() 和 hashCode()
println(user1 == user2) // 输出:true(比较内容,不是引用)

// 3. 自动实现 copy() 函数
val user3 = user1.copy(name = "Alice Smith")
println(user3) // 输出:User(id=1, name=Alice Smith, email=alice@example.com)

// 4. 自动实现 componentN() 函数(用于解构)
val (id, name, email) = user1
println("ID: $id, Name: $name") // 输出:ID: 1, Name: Alice

对比 Java: 在 Java 中需要手动编写 getter/setter、equals()、hashCode()、toString() 等大量样板代码。

十六、对象表达式与对象声明:替代匿名类和单例

  1. 对象表达式(匿名对象)

    // 类似 Java 的匿名内部类,但更强大
    interface ClickListener {
        fun onClick()
    }
    
    val button = object : ClickListener {
        override fun onClick() {
            println("Button clicked!")
        }
    }
    
    button.onClick()
    
  2. 对象声明(单例模式)

    // 使用 object 关键字创建单例
    object DatabaseManager {
        private var connectionCount = 0
    
        fun connect() {
            connectionCount++
            println("Connected! Total connections: $connectionCount")
        }
    
        fun getConnectionCount(): Int = connectionCount
    }
    
    // 直接通过类名调用,不需要实例化
    DatabaseManager.connect() // 输出:Connected! Total connections: 1
    DatabaseManager.connect() // 输出:Connected! Total connections: 2
    

十七、伴生对象(Companion Object):替代静态成员

Kotlin 没有 static关键字,使用伴生对象实现类似功能。

class MyClass {
    companion object {
        const val MAX_COUNT = 100  // 类似静态常量
        
        fun create(): MyClass {    // 类似静态工厂方法
            return MyClass()
        }
        
        @JvmStatic  // 如果需要 Java 互操作
        fun staticMethod() {
            println("This can be called from Java as static method")
        }
    }
    
    fun instanceMethod() {
        println("This is an instance method")
    }
}

// 使用方式
println(MyClass.MAX_COUNT)  // 直接通过类名访问
val obj = MyClass.create()   // 直接通过类名调用

十八、密封类(Sealed Class):受限的类层次结构

密封类用于表示受限的类继承结构,在 when表达式中特别有用。

// 定义密封类
sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val message: String) : Result<Nothing>()
    object Loading : Result<Nothing>()
}

// 使用密封类 - when 表达式会检查是否覆盖所有情况
fun handleResult(result: Result<String>) {
    when (result) {
        is Result.Success -> println("Success: ${result.data}")
        is Result.Error -> println("Error: ${result.message}")
        Result.Loading -> println("Loading...")
        // 不需要 else 分支,因为所有情况都已覆盖
    }
}

val successResult = Result.Success("Data loaded")
val errorResult = Result.Error("Network error")

handleResult(successResult) // 输出:Success: Data loaded
handleResult(errorResult)   // 输出:Error: Network error

十九、扩展函数实战:为现有类添加超能力

让我们看一些实用的扩展函数例子:

// 为 String 类添加扩展
fun String.isEmail(): Boolean {
    return contains("@") && contains(".")
}

fun String.capitalizeWords(): String {
    return split(" ").joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } }
}

// 为 List 添加扩展
fun <T> List<T>.second(): T = this[1]
fun <T> List<T>.randomOrNull(): T? = if (isEmpty()) null else this.random()

// 使用扩展函数
println("test@example.com".isEmail())        // 输出:true
println("hello world".capitalizeWords())      // 输出:Hello World

val list = listOf("first", "second", "third")
println(list.second())                       // 输出:second
println(emptyList<String>().randomOrNull())  // 输出:null

二十、属性委托:强大的属性管理

属性委托是 Kotlin 的高级特性,可以简化代码。

  1. lazy 委托:延迟初始化

    class ExpensiveObject {
        init {
            println("正在创建昂贵的对象...")
        }
    }
    
    class MyClass {
        // 只有第一次访问时才会创建
        val expensiveObject: ExpensiveObject by lazy {
            println("第一次访问,开始初始化")
            ExpensiveObject()
        }
    }
    
    val myClass = MyClass()
    println("对象已创建,但昂贵对象还未初始化")
    println(myClass.expensiveObject)  // 这里才会真正初始化
    println(myClass.expensiveObject)  // 直接使用已初始化的对象
    
  2. observable 委托:属性变化监听

    import kotlin.properties.Delegates
    
    class User {
        var name: String by Delegates.observable("未命名") { property, oldValue, newValue ->
            println("属性 ${property.name} 从 '$oldValue' 变为 '$newValue'")
        }
    }
    
    val user = User()
    user.name = "Alice"  // 输出:属性 name 从 '未命名' 变为 'Alice'
    user.name = "Bob"    // 输出:属性 name 从 'Alice' 变为 'Bob'
    

二十一、空安全深度探索:安全调用链

当处理复杂对象时,安全调用操作符特别有用。

data class Address(val street: String?, val city: String)
data class Company(val name: String, val address: Address?)
data class Employee(val name: String, val company: Company?)

// 复杂的嵌套对象
val employee: Employee? = Employee(
    "Alice", 
    Company("Tech Corp", Address("Main St", "Beijing"))
)

// 传统方式(繁琐)
val city1 = if (employee != null) {
    if (employee.company != null) {
        if (employee.company.address != null) {
            employee.company.address.city
        } else null
    } else null
} else null

// Kotlin 安全调用链(优雅!)
val city2 = employee?.company?.address?.city

println(city2) // 输出:Beijing

// 如果中间有任何环节为 null,整个表达式返回 null
val employee2: Employee? = Employee("Bob", null)
val city3 = employee2?.company?.address?.city
println(city3) // 输出:null

实战项目:构建一个简单的任务管理系统

// 定义数据模型
sealed class TaskPriority {
    object Low : TaskPriority()
    object Normal : TaskPriority()
    object High : TaskPriority()
}

data class Task(
    val id: Int,
    val title: String,
    val description: String,
    val priority: TaskPriority,
    val isCompleted: Boolean = false
) {
    // 扩展函数:获取优先级颜色
    fun getPriorityColor(): String = when (priority) {
        TaskPriority.Low -> "绿色"
        TaskPriority.Normal -> "黄色"
        TaskPriority.High -> "红色"
    }
}

// 任务管理器(单例)
object TaskManager {
    private val tasks = mutableListOf<Task>()
    private var nextId = 1
    
    fun addTask(title: String, description: String, priority: TaskPriority): Task {
        val task = Task(nextId++, title, description, priority)
        tasks.add(task)
        return task
    }
    
    fun completeTask(taskId: Int): Boolean {
        val task = tasks.find { it.id == taskId }
        return if (task != null) {
            tasks.replaceAll { if (it.id == taskId) it.copy(isCompleted = true) else it }
            true
        } else {
            false
        }
    }
    
    fun getHighPriorityTasks(): List<Task> {
        return tasks.filter { it.priority is TaskPriority.High && !it.isCompleted }
    }
    
    fun getTasksByPriority(priority: TaskPriority): List<Task> {
        return tasks.filter { it.priority == priority }
    }
}

// 使用任务管理系统
fun main() {
    // 添加任务
    val task1 = TaskManager.addTask("学习 Kotlin", "掌握 Kotlin 高级特性", TaskPriority.High)
    val task2 = TaskManager.addTask("写博客", "分享 Kotlin 学习心得", TaskPriority.Normal)
    
    println("高优先级任务:")
    TaskManager.getHighPriorityTasks().forEach { task ->
        println("${task.title} - 优先级:${task.getPriorityColor()}")
    }
    
    // 完成任务
    TaskManager.completeTask(task1.id)
    println("任务1完成状态:${TaskManager.getHighPriorityTasks().isEmpty()}")
}

下一步学习方向

你现在已经掌握了 Kotlin 的核心高级特性!接下来可以探索:

  • 协程(Coroutines) :异步编程的现代解决方案
  • 类型安全的构建器(Type-safe builders) :创建 DSL
  • 反射(Reflection) :运行时检查代码结构
  • 多平台项目(KMP) :用 Kotlin 开发 iOS、Web、桌面应用

Kotlin 的世界非常广阔,这些高级特性将让你写出更加优雅、安全、高效的代码。尝试用这些特性重构你之前的项目,你会发现编程可以如此愉快!

kotlin-2

太棒了!我们继续深入,探索 Kotlin 更核心、更强大的特性。这一部分将让你真正体会到 Kotlin 相比 Java 的优雅和高效。


Kotlin 基础进阶:解锁现代编程的威力

八、数据类型:不只是“一切皆对象”

Kotlin 对数据类型做了精心设计,区分了可空与否,并且所有类型都是对象,没有 Java 中的基本类型和包装类型之分。

  1. 基本类型

    • 数值类型ByteShortIntLongFloatDouble
    • 其他类型Char(字符), Boolean(布尔值), String(字符串)
    val number: Int = 100 // 整数
    val pi: Double = 3.14 // 双精度浮点数
    val isKotlinFun: Boolean = true // 布尔值
    val letter: Char = 'A' // 字符
    val text: String = "Hello" // 字符串
    
  2. 智能类型转换

    Kotlin 编译器非常智能,一旦进行了类型检查,在后续作用域内会自动进行类型转换。

    fun printLength(obj: Any) { // Any 是 Kotlin 中所有类的基类,类似 Java 的 Object
        if (obj is String) { // 类型检查
            // 在这个分支内,obj 被自动转换为 String 类型
            println(obj.length) // 可以直接调用 String 的方法
        }
    
        // 或者使用 `!is` 进行否定判断
        if (obj !is String) return
        println(obj.length) // 这里 obj 也是 String 类型
    }
    

    对比 Java: 在 Java 中需要显式强制转换:if (obj instanceof String) { String str = (String) obj; }

九、集合:功能强大的数据容器

Kotlin 的集合分为可变和不可变两种,这是非常重要的设计理念。

  1. List(列表)

    // 不可变列表 - 只能读取,不能修改
    val readOnlyList: List<String> = listOf("Apple", "Banana", "Orange")
    println(readOnlyList[0]) // 输出:Apple
    // readOnlyList.add("Grape") // 错误!不可变列表不能添加元素
    
    // 可变列表 - 可以修改
    val mutableList: MutableList<String> = mutableListOf("Apple", "Banana")
    mutableList.add("Grape") // 正确!
    mutableList[0] = "Apricot" // 修改元素
    
  2. Set(集合)和 Map(映射)

    // Set - 不重复元素的集合
    val set = setOf(1, 2, 3, 2, 1) // 结果: [1, 2, 3]
    
    // Map - 键值对
    val map = mapOf("name" to "Kotlin", "version" to "1.9")
    println(map["name"]) // 输出:Kotlin
    

十、循环:更优雅的遍历方式

  1. for 循环

    val fruits = listOf("Apple", "Banana", "Orange")
    
    // 遍历集合元素
    for (fruit in fruits) {
        println(fruit)
    }
    
    // 遍历索引和值
    for ((index, fruit) in fruits.withIndex()) {
        println("$index: $fruit")
    }
    
    // 遍历数字范围
    for (i in 1..5) { // 包含 1 和 5
        println(i)
    }
    
    for (i in 1 until 5) { // 不包含 5
        println(i)
    }
    
    for (i in 5 downTo 1 step 2) { // 从5到1,步长为2
        println(i) // 输出:5, 3, 1
    }
    
  2. while 和 do-while(与 Java 相同)

    var x = 10
    while (x > 0) {
        println(x)
        x--
    }
    

十一、函数进阶:默认参数、命名参数、扩展函数

  1. 默认参数:为函数参数提供默认值

    fun greet(name: String, greeting: String = "Hello") {
        println("$greeting, $name!")
    }
    
    greet("Alice") // 输出:Hello, Alice!
    greet("Bob", "Hi") // 输出:Hi, Bob!
    
  2. 命名参数:调用时指定参数名,提高可读性

    fun createUser(name: String, age: Int, isAdmin: Boolean = false) {
        println("Name: $name, Age: $age, Admin: $isAdmin")
    }
    
    // 可以跳过有默认值的参数,随意调整顺序
    createUser(age = 25, name = "Charlie", isAdmin = true)
    
  3. 扩展函数:Kotlin 的"杀手级特性",可以为现有类添加新功能

    // 为 String 类添加一个扩展函数
    fun String.addExcitement(): String {
        return this + "!!!"
    }
    
    println("Kotlin is fun".addExcitement()) // 输出:Kotlin is fun!!!
    
    // 为 List 添加一个扩展函数
    fun <T> List<T>.secondOrNull(): T? {
        return if (this.size >= 2) this[1] else null
    }
    
    val list = listOf("first", "second", "third")
    println(list.secondOrNull()) // 输出:second
    

十二、Lambda 表达式和高阶函数

这是函数式编程的核心,让代码极其简洁。

  1. Lambda 表达式基础

    // 一个简单的 Lambda:接收两个 Int 参数,返回它们的和
    val sum: (Int, Int) -> Int = { a, b -> a + b }
    println(sum(3, 4)) // 输出:7
    
    // 更常见的写法:直接传递给函数
    val numbers = listOf(1, 2, 3, 4, 5)
    
    // 过滤出偶数
    val evens = numbers.filter { it % 2 == 0 } // [2, 4]
    
    // 将每个元素乘以2
    val doubled = numbers.map { it * 2 } // [2, 4, 6, 8, 10]
    
    // 排序
    val sorted = numbers.sortedByDescending { it } // [5, 4, 3, 2, 1]
    
  2. 高阶函数:接收函数作为参数或返回函数的函数

    // 接收一个函数作为参数
    fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
        return operation(a, b)
    }
    
    // 使用 Lambda 调用
    val result1 = calculate(10, 5) { x, y -> x + y } // 15
    val result2 = calculate(10, 5) { x, y -> x * y } // 50
    
    // 返回一个函数
    fun createMultiplier(factor: Int): (Int) -> Int {
        return { number -> number * factor }
    }
    
    val double = createMultiplier(2)
    val triple = createMultiplier(3)
    
    println(double(5)) // 输出:10
    println(triple(5)) // 输出:15
    

十三、异常处理:try-catch 也是表达式

Kotlin 的 try-catch也可以返回值,这让错误处理更加函数式。

fun parseNumber(str: String): Int? {
    return try {
        str.toInt() // 如果转换成功,返回数字
    } catch (e: NumberFormatException) {
        null // 如果转换失败,返回 null
    }
}

println(parseNumber("123")) // 输出:123
println(parseNumber("abc")) // 输出:null

实战演练:综合运用

让我们用学到的知识解决一个实际问题:处理学生成绩列表。

data class Student(val name: String, val score: Int)

fun main() {
    val students = listOf(
        Student("Alice", 85),
        Student("Bob", 92),
        Student("Charlie", 78),
        Student("Diana", 95),
        Student("Eve", 88)
    )
    
    // 找出成绩90分以上的学生名字
    val topStudents = students
        .filter { it.score > 90 }
        .map { it.name }
    println("Top students: $topStudents") // [Bob, Diana]
    
    // 计算平均分
    val averageScore = students.map { it.score }.average()
    println("Average score: $averageScore")
    
    // 按成绩降序排列
    val sortedByScore = students.sortedByDescending { it.score }
    sortedByScore.forEach { println("${it.name}: ${it.score}") }
    
    // 使用扩展函数添加实用功能
    fun List<Student>.getNames(): List<String> = this.map { it.name }
    
    val allNames = students.getNames()
    println("All names: $allNames")
}

下一步学习路径

现在你已经掌握了 Kotlin 的核心语法!接下来可以探索:

  • 面向对象编程:类、对象、继承、接口、数据类、密封类
  • 协程:Kotlin 的轻量级线程,用于异步编程
  • DSL(领域特定语言) :用 Kotlin 创建优雅的领域特定语言
  • 与 Java 互操作:在现有 Java 项目中引入 Kotlin

Kotlin 的学习曲线非常平滑,这些基础概念将为你后续的学习打下坚实的基础。尝试用 Kotlin 重写你之前用 Java 写过的小程序,你会立即感受到它的魅力!

kotlin-1

好的,没问题!我们来写一篇更系统、更清晰、更适合零基础入门的 Kotlin 基础语法篇。

我将采用  “概念讲解 + 代码示例 + 与Java对比” 的方式,并辅以实用技巧和总结,力求让你一篇文章就掌握 Kotlin 语法的精髓。


Kotlin 基础语法入门:从零开始,写好每一行代码

相比于转载的文章,本教程将提供更清晰的逻辑脉络和更实用的知识提炼。

一、 Kotlin 初印象:为什么是它?

在深入语法之前,先了解 Kotlin 的核心优势:

  • 简洁实用:大大减少样板代码(如 getter/setter、数据类型声明等)。
  • 空安全:从语言层面杜绝了空指针异常(NullPointerException)的困扰。
  • 100% 兼容 Java:可以在项目中与 Java 代码无缝调用,平滑迁移。
  • 现代语言特性:支持函数式编程、扩展函数、协程等。

现在,让我们开始语法之旅。

二、 程序起点:main 函数

任何程序的执行都有一个入口,在 Kotlin 中,它极其简洁。

fun main() {
    println("Hello, Kotlin World!")
}
  • fun:关键字,用于声明一个函数。
  • main:函数名,是程序的入口点。
  • println:一个内置函数,用于在控制台输出一行内容。

对比 Java:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, Java World!");
    }
}

Kotlin 的写法明显更简洁,不需要类包装。

三、 变量声明:val 和 var 的艺术

Kotlin 使用两个关键字来声明变量,这是基础中的基础。

  1. val(value 的缩写) :用于声明只读变量,相当于 Java 中的 final变量。一旦赋值,不可更改。

    val name: String = "Kotlin"
    // name = "Java" // 错误!编译不通过,因为 name 是只读的
    
  2. var(variable 的缩写) :用于声明可变变量

    var age: Int = 5
    age = 6 // 正确!可以重新赋值
    

高级技巧:类型推断

Kotlin 编译器很智能,如果你在声明时直接赋值,可以省略类型声明(: String和 : Int)。

val language = "Kotlin" // 编译器自动推断 language 为 String 类型
var count = 10 // 编译器自动推断 count 为 Int 类型

📌 最佳实践建议:

优先使用 val,除非这个变量确实需要被改变。这能让你的代码更安全、更符合函数式编程的理念。

四、 函数定义:fun 的关键作用

使用 fun关键字来定义函数。基本语法如下:

// 1. 基本函数:无返回值
fun greet(name: String) {
    println("Hello, $name!")
}

// 2. 有返回值的函数
fun sum(a: Int, b: Int): Int { // 返回值类型在参数列表后声明
    return a + b
}

// 3. 单表达式函数(语法糖!)
// 当函数体只有一行表达式时,可以简化写法
fun sumSimple(a: Int, b: Int): Int = a + b
// 甚至,类型推断也能用在这里
fun sumEvenSimpler(a: Int, b: Int) = a + b

调用函数:

greet("World") // 输出:Hello, World!
val result = sum(5, 3)
println(result) // 输出:8

五、 字符串模板:让拼接成为历史

这是 Kotlin 非常方便的特性,用于在字符串中直接嵌入变量或表达式。

  • 使用 $变量名

    val name = "Alice"
    println("Hello, $name") // 输出:Hello, Alice
    
  • 使用 ${表达式} (表达式复杂时必需):

    val a = 10
    val b = 20
    println("The sum is ${a + b}") // 输出:The sum is 30
    println("The text length is ${name.length}") // 输出:The text length is 5
    

对比 Java 的字符串拼接: "Hello, " + name,Kotlin 的方式更直观、更安全。

六、 条件控制:if 和 when

  1. if表达式

    在 Kotlin 中,if不仅可以做分支控制,还可以返回一个值

    val max = if (a > b) {
        println("a is larger")
        a // 最后一行作为返回值
    } else {
        println("b is larger or equal")
        b
    }
    println("Max is $max")
    

    对比 Java: Java 的 if是语句,不返回值,需要借助三元运算符 ? :。Kotlin 用 if统一了两者。

  2. when表达式

    功能类似 Java 的 switch,但强大得多。

    val grade = "A"
    when (grade) {
        "A" -> println("Excellent")
        "B", "C" -> println("Good") // 可以合并多个条件
        in "D".."F" -> println("Need improvement") // 可以判断范围
        else -> println("Invalid grade") // 类似 default
    }
    
    // when 也可以返回值
    val description = when (grade) {
        "A" -> "Excellent"
        else -> "Other grades"
    }
    println(description)
    

七、 空安全:Kotlin 的王牌特性

这是 Kotlin 解决 NPE 的核心设计。

  1. 可空类型:在类型后面加一个问号 ?,表示这个变量可以为 null

    var nullableName: String? = null // 允许为 null
    var nonNullName: String = "Kotlin" // 不允许为 null
    // nonNullName = null // 错误!编译不通过
    
  2. 安全调用操作符 ?. :如果对象不为空,则调用方法或属性;否则返回 null

    val length: Int? = nullableName?.length // 如果 nullableName 为 null,则 length 也为 null,不会抛出 NPE
    
  3. Elvis 操作符 ?: :提供一个默认值。

    val safeLength: Int = nullableName?.length ?: 0 // 如果左边为 null,则返回 0
    

总结与学习建议

特性 关键字/符号 核心优点 对比 Java
变量声明 valvar 不可变优先,类型推断 减少 final,代码更简洁
函数定义 fun 单表达式函数简化 无需类包装,语法更轻量
字符串 $${} 嵌入变量,无需拼接 比 +更直观安全
条件控制 ifwhen 是表达式,可返回值 功能比 switch更强大
空安全 ??.?: 编译期防止 NPE 从语言层面解决痛点

下一步学习建议:

掌握了这些基础语法后,你就可以编写简单的 Kotlin 程序了。接下来可以探索:

  • 数据类型:数值类型、字符、布尔值、数组和集合。
  • 循环for和 while循环。
  • 面向对象:类、继承、接口。
  • Lambda 表达式与高阶函数:Kotlin 函数式编程的核心。

希望这篇为你量身定制的教程能让你有一个完美的开始!Kotlin 是一门令人愉悦的语言,祝你学习愉快!

Next.js 不只是前端框架!我们用它搭了个发布中枢,让跨团队协作效率翻倍

头图

企业发布流程割裂、协同复杂?我们用 Next.js 全栈+BFF(Backend For Frontend,前端后端中间层,用于聚合三方系统接口,适配前端需求),串联 GitLab、Tapd、钉钉和 MongoDB,把发布流程自动化、一站式可观测落地。本文分享架构实战与关键代码,助力中大型前端团队搭建自己的“发布中枢”。

你将收获

  • 企业级发布自动化的一线架构与流程
  • Next.js 全栈+BFF最佳实践
  • 三方平台集成(GitLab/Tapd/钉钉)与踩坑经验
  • 关键模型、权限中间件、自动化脚本代码
  • 发布流程可观测与质量保障的实拍指标

背景与目标

企业内部需求、开发、测试、发布流程分散:

  • Tapd 管需求,GitLab 负责代码和CI,钉钉审批沟通,环境分散
  • 信息孤岛、人工对齐,链路不可追溯,权限混乱
  • 发布效率和质量难保障

目标:

  • 聚合需求/代码/MR/流水线/发布/通知,一站式视图
  • 自动化三方联动,减少人工链路
  • 全程日志审计,所有操作可追溯
  • 统一鉴权、角色权限管理,安全可控

技术选型亮点

为什么选择 Next.js 全栈 + BFF?

相比传统"前端 + 后端 API"架构,选择 Next.js 全栈是因为:

  • 减少跨域问题:发布平台需频繁跨系统交互(GitLab/Tapd),Next.js 的 API Route 可直接作为 BFF(Backend For Frontend,前端后端中间层,用于聚合三方系统接口,适配前端需求) 层,前后端同域部署,避免 CORS 配置
  • 性能提升:同构渲染(SSR/SSG)提升页面首屏响应速度 30%+,特别是发布状态页面需要实时数据展示
  • 统一技术栈:TypeScript 全栈覆盖,减少上下文切换,降低维护成本
  • 安全可控:三方 API 密钥仅存服务端,避免前端暴露

为什么选择 MongoDB?

相比 MySQL 等关系型数据库:

  • 灵活 Schema:发布日志结构多变(不同系统字段不同),MongoDB 的文档模型减少 70% 的表结构变更成本
  • 查询性能:复合索引(如 {projectId: 1, createAt: -1})保障高频查询(发布记录查询、操作日志检索)在千万级数据下仍保持毫秒级响应
  • 扩展性好:日志归档场景,冷热数据分离,MongoDB 分片更易实现

服务层整合策略

三方 SDK 统一限流、重试、熔断,业务解耦:

  • 统一错误模型:所有服务层抛出标准错误,API 层统一转换为前端可理解的响应格式
  • 集中治理:限流器(p-limit)、重试策略(指数退避)、熔断器(失败率阈值)统一配置,避免重复代码

钉钉/IM 通知

时效强、闭环好,重要事件双通道(钉钉 + 邮件),确保关键发布事件及时触达责任人

架构总览

前端页面、API路由、服务层、数据层和运维脚本协作,串联三方系统,自动化发布流程。

平台主流程

平台主流程

流程说明:从 Tapd 需求创建 → GitLab 分支开发 → MR 合并 → CI/CD 流水线 → 发布计划创建 → 产线发布 → 钉钉通知,形成完整闭环。每个环节的操作日志自动落库,支持全程回溯。

技术架构

技术架构图

架构要点

前端 + API Route(BFF):Next.js 全栈同仓部署,API Route 作为三方系统统一网关,密钥仅存服务端,统一响应格式 {code, message, data}

服务层:限流(GitLab 5r/s,Tapd 3r/s)、熔断(连续 5 次超时切换 Redis 缓存)、指数退避重试(1s→2s→4s),日志埋点全链路追踪。三方服务集成示例:以 GitLab 为例,前端页面 → API Route(权限校验)→ GitLab Service(MR查询/流水线拉取/合并)→ MongoDB(操作日志自动落库),确保所有流程可追溯。同样策略适用于 Tapd 和钉钉。

MongoDB:复合索引保障查询 < 50ms,事务确保关键操作原子性,冷热分离优化存储成本。

基础设施:Docker + PM2 容器化,Nginx 静态缓存 + 负载均衡。

数据流向:前端 → BFF(权限)→ 服务层(限流/熔断)→ 三方系统 → MongoDB(持久化),异常时降级至缓存。

确保 99.9% SLA、高安全性、全链路可观测。


业务与数据建模

核心对象如下:

模型 作用说明
发布计划 批次/需求/环境/状态(发布计划:一次发布任务的完整定义;发布批次:同一计划下的多个发布批次)
发布说明 变更说明/回滚线索
项目/环境 仓库/分支/环境策略
需求/合并 Tapd对接/合并策略
人员/角色 企业组织/权限管理
操作/合并日志 审计/回溯

发布状态流转图

状态流转说明:发布计划状态流转:待发布 → 发布中 → 已发布。每个状态变更都会触发操作日志记录,并推送钉钉通知给相关责任人。

业务模块拆解

发布计划管理

说明

  • 发布计划:一次发布任务的完整定义,包含目标环境、发布时间、关联需求等
  • 发布批次:同一发布计划下的多个发布批次,用于聚合多个需求并生成发布文档

核心功能

  • 创建/维护发布计划与发布批次、设置发布时间
  • 对接CI/CD(CI:持续集成,代码合并后自动构建/测试;CD:持续交付/部署,自动化发布到环境),触发/回滚,钉钉自动通知
  • 需求聚合成批次、生成发布文档、依赖校验、门禁策略
  • 分支/流水线/Code Owners(按目录/文件指定审核所有者,MR 需其审核)策略
  • 发布变更集、执行人、回滚记录

场景化案例:发布批次审批发版闭环

  • 组批:将需求 A/B/C 加入同一发布批次,系统自动生成发布文档(变更项、影响范围、回滚预案)
  • 审批:责任人(业务/技术负责人或 Code Owners)线上审批,通过后自动触发生产发布流水线(或生成 Tag)
  • 发布:CI/CD 执行生产发布,产出版本号与链接;若监控异常,自动阻断并回滚,上报钉钉告警
  • 归档:发布结果、发布文档、审批记录与执行人落库,便于审计与追溯

发布管理

需求管理

  • Tapd故事同步、MR绑定与一致性校验
  • 需求锁,合并冲突防控
  • 分支/流水线/codeowner策略

场景化案例:需求锁如何避免冲突?

当开发 A 在 Tapd 标记需求"开发中"时,系统自动锁定对应 Git 分支,开发 B 试图提交 MR 时会收到"需求已被锁定,请联系 A 确认"的钉钉提醒。这样避免了多人同时修改同一需求导致的代码冲突。

需求管理

人员与角色

  • 角色功能映射、页面权限绑定
  • 钉钉unionId对企业组织,自动审批派发

场景化案例:审批派发与越权拦截闭环

  • 研发提交发布批次后,平台按角色矩阵自动分配审批人(产品/技术负责人),并推送钉钉待办;
  • 审批通过 → 自动触发生产流水线;拒绝/超时 → 批次状态更新并推送原因;
  • 无权限用户尝试触发发布 → 中间件路由门禁 + API 二次校验即时拦截,记录操作日志并通知管理员;
  • 审批/执行全链路留痕,可按人/项目/时间检索。

人员管理

权限鉴权与中间件

  • 所有受保护路由自动校验Token,钉钉扫码支持;API层二次验权,敏感操作需二次确认。

场景化案例:Token 过期与敏感操作防护

  • 用户访问受保护路由,middleware 校验失败后重定向至登录页,并保留 redirect;
  • 登录回调(带 dingCode)解析身份,注入会话并跳回 redirect;
  • 触发发布等接口时,API 层二次校验 Token/角色;无权限则返回 403,记录操作日志;
  • 对“切生产/回滚”等高危操作,强制二次确认与审批链,并推送钉钉通知。

中间件关键代码:

export async function middleware(req: NextRequest) {
  const { pathname, searchParams, search } = req.nextUrl;
  const host = req.headers.get('host');

  // 钉钉扫码登录回调处理
  const dingCode = searchParams.get('code');
  if (dingCode) {
    const baseUrl = buildBaseUrl(host);
    const redirectUrl = `${baseUrl}/dashboard`;
    return NextResponse.redirect(
      new URL(`/dingUserInfo?dingCode=${dingCode}&redirect=${redirectUrl}`, req.url)
    );
  }

  // 公开路径直接放行
  if (isPublicPath(pathname)) {
    return NextResponse.next();
  }

  // 受保护路径需要验证Token
  const verifiedToken = await verifyAuth(req).catch(() => null);
  if (!verifiedToken) {
    const baseUrl = buildBaseUrl(host);
    const redirectUrl = `${baseUrl}${pathname}${search}`;
    const loginUrl = `${LOGIN_URL}?redirect=${encodeURIComponent(redirectUrl)}`;
    return NextResponse.redirect(new URL(loginUrl));
  }

  return NextResponse.next();
}

自动化脚本与运维

脚本名 用途
build_data.mjs 构建产物、数据生成
start.js 启动服务
schedule.js 定时任务、数据同步
onlineNotice.js 钉钉群机器人通知
nginx.mjs Nginx反向代理配置

运维建议:

  • 构建产物/运行镜像分离
  • Nginx缓存静态资源
  • 非幂等危险操作需灰度和二次确认

前端页面结构

  • Next.js自动路由,Ant Design+自研组件
  • 页面→业务组件→原子组件,稳定API
  • Minimal状态设计,数据接口拉取为主
Dashboard
├─ 发布管理
├─ 需求管理
├─ 人员管理
├─ 角色管理
├─ 脑图管理
└─ 操作日志

前端页面结构示例

落地流程 Step-by-Step

  1. 明确系统范围和最小闭环(MR→CI→发布→通知→审计)
  2. Next.js初始化、API Route做BFF、服务层可复用
  3. 路由中间件做权限守卫,API强制Token/角色校验
  4. 接入GitLab、Tapd、钉钉三方服务
  5. 发布管理先上线,补充故事、人员、角色,操作日志可视化
  6. 响应统一、日志埋点,指标化(准备时长、回溯时效、失败率)
  7. 事件化解耦三方系统,逐步微前端/GraphQL聚合

可观测与质量保障

  • API和服务响应结构化,异常可追溯:统一响应格式 {code, message, data},异常时自动记录堆栈、请求参数、响应时间
  • 日志埋点,操作日志表,关键链路自动记录:所有三方 API 调用(GitLab/Tapd/钉钉)自动记录入参、出参、时延、错误码
  • 合并/发布记录可视化,问题定位分钟级:操作时间线组件展示完整链路(需求创建 → MR 合并 → 发布触发 → 钉钉通知),支持按时间、人员、项目过滤
  • 钉钉自动推送,回滚记录闭环:发布成功/失败/回滚时自动推送钉钉群消息,包含变更集、执行人、回滚线索

操作时间线

时间线说明:操作时间线可视化展示发布全链路,每个节点包含操作人、时间戳、操作类型、关联对象(如 MR ID、发布批次号),点击可查看详细信息。支持按项目、人员、时间范围过滤,快速定位问题。

踩坑清单&最佳实践

问题 案例场景 解决方案 效果数据
三方API限流/波动 GitLab API 突发限流(QPS=10)导致发布计划批量失败 使用 p-limit 控制并发(限 5 个请求/秒)+ 指数退避重试(3 次重试,间隔 1s→2s→4s)+ 熔断器(失败率 > 50% 时切换缓存) 失败率从 15% 降至 0.3%
长耗时任务与API Route 批量同步 100+ 项目分支信息,API Route 超时(60秒) 任务拆分为 10 个批次,前端轮询状态(每 2 秒),后台任务异步执行 超时率从 40% 降至 0%
MongoDB查询退化 操作日志表千万级数据,按项目+时间范围查询耗时 5 秒+ 建立复合索引 {projectId: 1, createAt: -1},控制单文档体积 < 16MB 查询耗时降至 50ms 以内
身份映射不一致 企业组织、GitLab 用户、钉钉用户的唯一标识不统一,导致权限校验失败 建立用户映射表({orgId, gitlabId, dingtalkUnionId}),关键操作(如发布、回滚)强制校验三方身份一致性 权限校验失败率从 8% 降至 0.1%
权限仅依赖前端 前端绕过权限检查直接调用 API,导致越权操作 API 层强制 Token/角色校验,敏感操作(发布、回滚)二次确认 + 审批链 越权操作拦截率 100%
日志缺口/排查难 发布失败时无法回溯具体操作步骤,排查耗时 2 小时+ 统一日志规范(操作类型、操作人、时间戳、关联对象),操作时间线可视化 + 检索面板(支持按人/项目/时间过滤) 排查时间从 2 小时缩短至 15 分钟

强烈建议:三方服务层全部加限流、重试、熔断,关键操作二次确认和日志落库!这是保障平台稳定性的基石。

关键代码片段

1. 通用MongoDB模型

async getList(query?: T, sortFields?: Sort, pageIndex = 1, pageSize = 20) {
  const db = await this.init();
  let ret = db.find(query || {});
  if (pageIndex && pageSize) {
    if (sortFields) ret = ret.sort(sortFields);
    ret = ret.skip(pageSize * (pageIndex - 1)).limit(pageSize);
  }
  return ret.toArray();
}

2. GitLab服务层创建MR

export const syncPostNewMR = async (params: any) => {
  const { token, project_name, id: projectId, source_branch, target_branch } = params;

  // 获取项目主分支(如果未指定target_branch)
  const project = await syncProjectsSearch({ id: projectId });
  const defaultBranch = target_branch || project[0].default_branch;

  // 调用GitLab API创建MR
  const gitlabApi = `https://gitlab.example.com/api/v4/projects/${projectId}/merge_requests`;
  const response = await axios.post(
    gitlabApi,
    {
      source_branch,
      target_branch: defaultBranch,
      title: params.title || `Merge ${source_branch} to ${defaultBranch}`,
    },
    {
      headers: { 'Private-Token': process.env.GITLAB_PRIVATE_TOKEN },
      timeout: 10000, // 10秒超时
    }
  );

  if (response.status !== 201) {
    throw new Error(`创建MR失败:${response.data.message || '未知错误'}`);
  }

  // 操作日志自动落库
  try {
    const info: any = await analysisToken(token);
    addLogs(2, 6, info.personName, [project_name], info.personId);
  } catch (err) {
    console.error('日志记录失败', err);
  }

  return response.data;
};

3. JWT签发与校验

安全提示:生产环境中,secretkey 必须通过环境变量(如 process.env.JWT_SECRET)注入,避免硬编码;并显式指定算法(如 algorithm: 'HS256')。

import jwt from 'jsonwebtoken';

// ⚠️ 生产安全:密钥仅通过环境变量注入,避免硬编码;未配置直接报错
if (!process.env.JWT_SECRET) { throw new Error('JWT_SECRET is required'); }
const secretkey = process.env.JWT_SECRET as string;

/**
 * 签发 JWT Token
 * - 使用 HS256 对称签名算法
 * - 设置 1 小时过期时间(expiresIn=3600s)
 * - 建议改进:校验密钥长度≥32字节;添加 issuer/audience/subject 声明防止错域串用
 */
export const sign = (data = {}) => {
  return jwt.sign(data, secretkey, {
    expiresIn: 60 * 60,
    algorithm: 'HS256', // 显式指定算法,避免算法混淆攻击
  });
};

/**
 * 校验 JWT Token
 * - 启用 algorithms=['HS256'] 算法白名单
 * - 建议改进:
 *   1. try/catch 区分 TokenExpiredError/NotBeforeError/Invalid,分流 401/等待/重登
 *   2. 添加 clockTolerance: 30 处理机器时间偏移
 *   3. 校验 issuer/audience/subject 声明
 *   4. 传输层用 HttpOnly+Secure+SameSite=strict Cookie 携带 Token
 */
export const verify = async (token: string) => {
  return jwt.verify(token, secretkey, {
    algorithms: ['HS256'],
  });
};

效果实拍与指标

📈 效率提升

指标 优化前 优化后 提升幅度
发布协作耗时 8h(跨系统沟通 + 手工对齐 + 等待审批) 5.6h(自动联动 + 一站式视图 + 即时通知) 30%
问题定位时间 2h(多系统手动查询拼凑) 15min(时间线可视化一键回溯) 8倍
信息同步时效 2h 0.5h 75%

🛡️ 质量与稳定性

  • 发布阻塞率下降 98%:限流/熔断机制上线后,三方 API 故障导致的发布中断从 15% 降至 0.3%
  • 100% 操作可追溯:所有发布操作(创建/触发/回滚)自动记录,审计链路完整无断点
  • 关键节点卡点前置:需求锁定 + 分支校验 + Code Owners 审批,减少 42% 的线上回滚

🔍 运维可观测性

  • 排障效率提升 60%:结构化日志 + 操作时间线让问题根因定位从 30min 缩短至 12min
  • 全链路可视化:GitLab Pipeline + Tapd 需求 + 钉钉通知聚合展示,故障溯源一键直达

发布效率指标钉钉机器人通知示例钉钉机器人通知示例

适用场景推荐

  • 中大型有自研需求的前端团队
  • 需要聚合多系统(GitLab/Tapd/钉钉)的企业平台
  • 对发布自动化、审计、协作有强需求的团队

下一步计划

  • 引入事件总线和GraphQL聚合
  • 指标化平台效益,辅助组织度量和持续改进
  • 打通Jenkins等CI/CD工具,实现前后端自动联动
  • 持续优化脚本化运维和安全策略

总结

Next.js全栈+BFF结合服务层三方集成,以发布主线串联各业务,保障自动化、审计、权限和可观测性,是企业级发布平台高效协作与治理的最佳实践。

希望本文对你的企业前端平台落地有所帮助,欢迎点赞收藏、评论交流你的实践和问题!

作者:洞窝-重阳

react 切片 和 优先级调度

关键点在于,这个决策并 不是 在 workLoop (工作循环) 内部 自己判断的。 workLoop 只是一个“埋头干活的工人”,而真正的“项目经理”是 Scheduler(调度器)Lane(车道) 模型 的组合。 下面我们来拆解一下 React 是如何区分这两种情况的。

两种不同的“触发机制”

我们不应该把 workLoop 想象成一个需要猜测当前处境的函数,而应该理解为有两条完全不同的路径,都会最终导向“干活”这一步。

路径一:时间片用完(优雅地暂停)这是并发渲染中的“常规路径”。

  1. 准备工作 :调度器有一个低优先级的任务。它通过 requestIdleCallback 向浏览器申请“绿灯”(空闲时间)。
  2. 开始工作 :浏览器回应:“你现在有 4 毫秒的空闲时间。” 于是调度器启动 workLoopConcurrent()
  3. 执行工作 : while 循环开始执行: while (workInProgress !== null && !shouldYield())
    • performUnitOfWork 被调用,处理一个 Fiber 节点。
    • shouldYield() 被调用。它 唯一的职责 就是检查这 4 毫秒的“最后期限”是否已到。它对任务优先级一无所知。
  4. 暂停工作 :假设处理了 20 个 Fiber 节点后,4 毫秒用完了。 shouldYield() 现在返回 true
  5. 做出决策 : while 循环的条件 !shouldYield() 变为 false 。循环 优雅地终止 。
  6. 最终结果 : workLoopConcurrent 函数执行完毕。 workInProgress 指针完好无损,依然指向第 21 个待处理的 Fiber 节点。调度器注意到工作还没完成,于是它会预约下一次的 requestIdleCallback ,以便将来继续。 在这个场景里,循环是由其 内部 的 shouldYield() 时间检查来停止的。这是一个有计划的、合作式的暂停。

路径二:高优先级任务插队,强制中断这是确保 UI 响应的“紧急路径”。

  1. 当前状态 : workLoopConcurrent() 正在愉快地执行一个低优先级的更新(比如我们上面的例子)。
  2. “入侵者”出现 :用户点击了一个按钮。事件处理器触发,调用了 setState
  3. 发出信号 : scheduleUpdateOnFiber 函数被调用。它获得了一个 高优先级的车道 (例如 SyncLane )。它把这个 SyncLane 添加到 Fiber 根节点的 pendingLanes (待处理车道)上。
  4. “项目经理”被唤醒 :调度器收到了这个变更通知。它立刻检查所有任务的优先级,发现:
    • 当前正在运行的车道 ( workInProgressRootRenderLanes ):是低优先级的(例如 TransitionLane )。
    • 新加入的待处理车道 ( pendingLanes ):包含一个高优先级的 SyncLane 。
  5. 做出决策 :调度器当前运行的 workLoop 之外,在更高的层级 做出了关键判断。它看到 SyncLane 的优先级高于 TransitionLane 。它决定 必须中断 当前的工作。
  6. 执行中断 :
    • 它不会礼貌地等待 shouldYield() 。
    • 它直接调用 prepareFreshStack() 函数。
    • prepareFreshStack 丢弃 了旧的、只做了一半的 workInProgress 树,并为这个高优先级的 SyncLane 更新创建了一个 全新的 workInProgress 树。
    • 然后,它会立刻开始一个 新的 工作循环(通常是 workLoopSync ,这个循环会完全 忽略 shouldYield ,因为它必须一次性执行完毕)。

React 选择 workLoopSync 还是 workLoopConcurrent 的核心逻辑

const shouldTimeSlice =
  (!forceSync &&
    !includesBlockingLane(lanes) &&
    !includesExpiredLane(root, lanes)) ||
  (enableSiblingPrerendering && checkIfRootIsPrerendering(root, lanes));

决策关键:shouldTimeSlice 变量

  • 位置:react-reconciler/ReactFiberWorkLoop.js
  • 作用:决定使用同步渲染(workLoopSync)还是并发渲染(workLoopConcurrent

1. 执行 workLoopSync(同步渲染)的情况

当 shouldTimeSlice = false 时,满足以下任一条件:

  • 条件 1forceSync = true

    • 由 ReactDOM.flushSync() 强制触发的更新,必须立即同步执行。
  • 条件 2includesBlockingLane(lanes) = true

    • 更新属于高优先级 “阻塞车道”(如 用户输入、点击等 交互事件),需立即响应。
  • 条件 3includesExpiredLane(root, lanes) = true

    • 低优先级任务因被反复打断而 “过期”,强制同步执行避免 “饥饿”。

2. 执行 workLoopConcurrent(并发渲染)的情况

当 shouldTimeSlice = true 时,满足以下所有条件:

  • 非 flushSync 触发的更新;
  • 不属于高优先级阻塞车道;
  • 未过期的低优先级任务;
  • 典型场景:startTransition 包裹的更新、网络请求后的 UI 更新等。

总结记忆表

渲染模式 触发场景 核心特点
workLoopSync flushSync / 用户交互 / 过期任务 同步、不可中断、高优先级
workLoopConcurrent startTransition / 异步数据更新 / 非紧急任务 可中断、低优先级、时间分片

React 事件优先级判断机制总结

核心决策者:getEventPriority 函数

  • 位置:react-dom-bindings/src/events/ReactDOMEventListener.js
  • 作用:根据事件类型分配优先级,决定后续渲染模式(同步 / 并发)

三大优先级分类及对应事件

优先级类型 对应事件示例 特点 渲染模式
DiscreteEventPriority(离散事件优先级) click、keydown、input、focus、submit 用户直接、单次交互,需即时反馈 同步渲染(workLoopSync)
ContinuousEventPriority(连续事件优先级) scroll、mousemove、touchmove、drag 高频连续触发,避免阻塞主线程 并发渲染(可中断)
DefaultEventPriority(默认事件优先级) load、error、未明确分类事件 异步或非用户直接交互事件 并发渲染(可中断)

关键流程

  1. 事件捕获:所有浏览器事件先被 React 统一监听器捕获(而非直接触发用户写的回调)。
  2. 优先级分配getEventPriority 通过 switch 语句判断事件类型,分配对应优先级。
  3. 调度渲染:高优先级(离散事件)走同步渲染,低优先级(连续 / 默认事件)走并发渲染。

记忆要点

  • VIP 事件(点击、输入等)→ 高优先级 → 同步渲染(不卡顿)。
  • 高频事件(滚动、鼠标移动等)→ 中优先级 → 可中断渲染(保流畅)。
  • 其他事件 → 低优先级 → 并发渲染(不阻塞)。

这个机制从源头区分任务轻重,是 React 并发模式流畅运行的核心基础。

image.png

JavaScript 内存管理是如何工作的?

1. 开场白:看不见的房间管家

写 JS 的你我,几乎从不手动 malloc/free,但这不代表内存管理“不存在”。
JavaScript 引擎就像一位房间管家

  • 你买东西(创建变量)→ 管家帮你找空位;
  • 你用完扔一边(解除引用)→ 管家扫进垃圾桶;
  • 你若一直占着茅坑不拉屎(内存泄漏)→ 管家也无可奈何。

理解管家的工作方式,才能让应用不爆内存、不卡顿、不 OOM


2. 两张图看懂“东西”放哪儿

存储区域 放什么 特点 生命周期
Stack 原始值(numberbooleanundefinednullstringsymbolbigint 固定大小、后进先出、超快 函数 return 即自动弹栈
Heap 引用值(ObjectArrayFunctionDateRegExp…) 动态大小、可按引用共享、稍慢 靠垃圾回收器(GC)稍后清理

代码示例

let age  = 18;        // 栈
let user = {age};     // 栈里只存指针,实际对象在堆

3. 垃圾回收三部曲

现代引擎默认采用**“标记-清除”(Mark-and-Sweep)算法,配合“分代回收”**优化。

3.1 标记阶段(Mark)

从一组**根(Roots)**出发:

  • 当前调用栈里的局部变量;
  • 全局对象(windowglobalThis);
  • 被 Chrome DevTools 断点 Hold 住的变量…

把所有能访问到的对象打上 “在用” 标签,其余都是 “垃圾”

3.2 清除阶段(Sweep)

线性扫堆,把没标签的对象直接释放;
内存空隙由空闲链表按页压缩整理,避免碎片化。

3.3 分代优化(Generational GC)

V8 把堆再细分为:

  • 新生代(1~8 M):短命对象,采用 Scavenge(复制回收),频率高、停顿短;
  • 老生代:多次幸存的大对象、长寿命对象,采用 Mark-Sweep + Mark-Compact,频率低、停顿长。

实测:95% 的对象 20ms 内就死,分代后 GC 吞吐量提升 5~10 倍。


4. 四种常见内存泄漏与排查清单

泄漏场景 代码味道 修复要点
1. 意外全局变量 function f(){ leaky="oops" } 'use strict' + ESLint no-undef
2. 忘记清理定时器 setInterval(()=>{…},1000) const t=setInterval(...); 卸载时 clearInterval(t)
3. 闭包捕“大”不放 return ()=>console.log('hi') 却捕获了 new Array(1e6) 只把必要变量闭进去,或手动 largeData=null
4. 游离 DOM + 监听器 removeChild(node) 后仍保留 node 引用 node.remove(); node=null; 同时 off() 监听器

5. 开发者必备:Chrome DevTools 三板斧

  1. Performance Monitor
    实时折线图:JS heap size、DOM 节点、事件监听器数量。
  2. Heap Snapshot
    对比两次快照,按 “Retained Size” 排序,轻松找出“大胃王”。
  3. Allocation Timeline
    录制 30 秒用户操作,蓝色竖线表示新生代分配,峰值即潜在泄漏点。

6. 进阶:WeakMap / WeakRef 让 GC 更聪明

  • WeakMap 只保存弱引用,键对象被回收时,映射条目自动消失;
  • WeakRef 允许你观察对象是否已被 GC,而不阻止其被回收;
  • 适合缓存、DOM 元数据映射,不增加额外可达路径

7. 一句话总结

JavaScript 帮你扫地,但别把垃圾藏到床底——
理解栈/堆、标记-清除、分代模型,善用 DevTools,远离四种泄漏,
你的页面才能常驻 60 FPS、不爆 256 M 低端机


8. 延伸阅读

前端代码规范体系建设与团队落地实践

一、为什么需要前端代码规范?

在现代前端开发中,代码规范是团队协作的基石。随着项目规模扩大和团队成员增多,统一的代码规范能够带来显著的收益:

核心价值

  • 提升代码可读性:一致的代码风格让团队成员能够快速理解他人代码

  • 降低维护成本:规范的代码结构便于后续维护和功能迭代

  • 减少低级错误:静态检查工具能够捕捉潜在的代码问题

  • 提高开发效率:自动化的格式化和检查节省手动调整时间

  • 促进团队协作:统一的规范减少代码合并冲突和沟通成本

二、代码规范设计体系

1. 基础代码规范(技术核心层)

JavaScript/TypeScript 规范 (ESLint + Prettier)

// .eslintrc.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react/recommended',
    'plugin:prettier/recommended'
  ],
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint', 'react', 'react-hooks'],
  rules: {
    // React Hooks 规则
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    
    // 代码风格
    'indent': ['error', 2],
    'quotes': ['error', 'single'],
    'semi': ['error', 'always'],
    
    // 生产环境限制
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    
    // TypeScript 规则
    '@typescript-eslint/explicit-function-return-type': 'off',
    '@typescript-eslint/no-unused-vars': ['error', { 
      argsIgnorePattern: '^_' 
    }]
  },
  settings: {
    react: {
      version: 'detect'
    }
  }
};

Prettier 格式化配置

{
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "bracketSpacing": true,
  "jsxSingleQuote": false,
  "arrowParens": "always",
  "endOfLine": "lf"
}

2. 样式规范(视觉统一层)

// stylelint.config.js
module.exports = {
  extends: [
    'stylelint-config-standard',
    'stylelint-config-recess-order',
    'stylelint-config-prettier'
  ],
  rules: {
    'selector-class-pattern': '^[a-z][a-zA-Z0-9]*$',
    'no-descending-specificity': null,
    'declaration-block-trailing-semicolon': 'always'
  }
};

3. 项目结构规范(组织架构层)

src/
├── components/          # 公共组件
│   ├── common/         # 通用基础组件
│   └── business/       # 业务组件
├── pages/              # 页面组件
├── hooks/              # 自定义 Hooks
├── utils/              # 工具函数
├── services/           # API 服务
├── store/              # 状态管理
├── styles/             # 全局样式
├── types/              # 类型定义
└── assets/             # 静态资源

三、规范实施技术方案

1. Git Hooks 自动化守护

依赖安装

npm install husky lint-staged --save-dev

package.json 配置

{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
    "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
    "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
    "type-check": "tsc --noEmit"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ],
    "*.{json,css,scss,md}": [
      "prettier --write",
      "git add"
    ]
  }
}

Commit Message 规范

// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert']
    ],
    'subject-case': [2, 'always', 'sentence-case']
  }
};

2. 编辑器统一配置

# .editorconfig
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

四、团队协作与规范落地策略

1. 规范文档化体系

创建 CODEGUIDE.md 文档,包含:

📋 核心原则

  • 一致性:团队代码风格统一

  • 可读性:代码清晰易懂

  • 可维护性:便于后续修改扩展

  • 安全性:避免常见安全问题

💻 代码风格规范

JavaScript/TypeScript

  • 缩进:2个空格

  • 引号:优先单引号

  • 分号:语句结尾必须

  • 命名:

    • 变量/函数:camelCase

    • 常量:UPPER_SNAKE_CASE

    • 组件:PascalCase

React 组件规范

  • 文件组织:一个组件一个文件

  • Props 命名:语义化,避免缩写

  • State 管理:合理使用 useState/useReducer

  • 副作用:使用 useEffect 管理

📦 项目结构标准

src/
├── components/ # 组件目录
├── pages/      # 页面目录  
├── hooks/      # 自定义 Hooks
├── utils/      # 工具函数
└── ...

🤝 Git 提交规范

<type>(<scope>): <subject>

<body>

<footer>

提交类型说明:

  • feat: 新功能

  • fix: 修复bug

  • docs: 文档更新

  • style: 代码格式调整

  • refactor: 代码重构

  • perf: 性能优化

  • test: 测试相关

  • chore: 构建工具变动

2. 新成员入职引导

环境配置清单

# 1. 基础环境
- [ ] Node.js (指定版本)
- [ ] 包管理器 (npm/yarn/pnpm)

# 2. 项目初始化  
- [ ] git clone <项目地址>
- [ ] cd <项目目录>
- [ ] npm install

# 3. 开发环境设置
- [ ] npm run setup
- [ ] 配置 IDE 插件

规范培训重点

  • 代码提交流程

  • 代码审查标准

  • 例外处理机制

五、面试回答策略

问题1:如何设计前端代码规范?

专业回答:

"我设计前端代码规范时采用分层架构思维,从技术实现到团队文化全方位考虑:

第一层:基础代码规范

  • 代码风格:ESLint + Prettier 统一格式

  • 最佳实践:组件设计原则、状态管理规范

  • 质量保障:TypeScript 类型检查、单元测试

第二层:工具链配置

  • 静态分析:ESLint 检查代码质量

  • 格式化:Prettier 自动格式化

  • 提交规范:Commitlint 强制规范提交

第三层:执行机制

  • Git Hooks:Husky + lint-staged 自动化检查

  • CI/CD 集成:流水线代码质量门禁

  • 文档化:详细的指南和示例代码

设计原则:

  • 渐进式:新项目严格,老项目逐步引入

  • 自动化:工具自动检查和修复

  • 可配置:合理的例外处理机制

  • 团队参与:共同制定提高接受度"

问题2:如何确保团队成员遵守代码规范?

专业回答:

"确保规范执行需要技术手段和管理策略结合:

技术层 - 自动化强制

  • Git Hooks 守护:pre-commit 自动修复,pre-push 全量检查

  • 提交信息规范:commitlint 确保符合约定式提交

  • IDE 实时提示:开发时实时发现问题

流程层 - 制度保障

  • 代码审查:规范遵守作为 PR 必要审查项

  • CI/CD 检查:流水线设置质量门禁

管理层 - 文化建设

  • 新人引导:专门的规范培训

  • 定期回顾:讨论执行情况和改进建议

  • 规范进化:根据反馈适时调整

关键技术实现:

json

{
  "*.{js,jsx,ts,tsx}": [
    "eslint --fix",
    "prettier --write", 
    "git add"
  ]
}

通过这种多层次体系,既保证规范执行,又不影响开发效率,最终形成团队自发的规范意识。"

问题3:遇到团队成员不遵守规范怎么办?

应对策略:

"面对这种情况,我采取循序渐进的策略:

1. 理解原因

  • 私下了解具体原因

  • 区分是工具使用问题还是规范理解问题

2. 技术辅助

  • 提供详细工具使用指南

  • 帮助配置开发环境

  • 对复杂规范提供具体示例

3. 渐进式要求

  • 历史代码:新修改部分遵守规范

  • 特殊情况:Code Review 讨论后有条件豁免

4. 正向激励

  • 代码审查中给予正面反馈

  • 将规范遵守作为能力评估参考

  • 分享规范带来的实际收益案例

5. 制度保障

  • 通过代码审查流程强制要求

  • 作为团队协作基本要求强调

关键点: 规范的目的是提高团队效率,不是惩罚。通过理解、引导、支持,帮助团队成员从被动遵守转变为主动认同。"

通过这套完整的代码规范体系,团队可以建立统一的开发标准,提升代码质量和协作效率,为项目的长期健康发展奠定坚实基础。

Vite 库模式输出 ESM 格式时的依赖处理方案

🧩 环境信息

{
  "vite": "5.4.15",
  "vue": "2.7.16"
}

📖 背景说明

当前项目是一个前端框架型宿主项目,主要负责:

  • 管理功能菜单与用户模块;

  • 通过 iframe 嵌入多个子前端项目

  • 在“工作台”页面中动态加载来自其他前端项目的小组件(Widget)

为了便于这些小组件的动态加载与复用,我们使用了 Vite 的库模式(Library Mode) 来进行打包。


⚙️ 初始配置

最早的 widget.vite.config.js 如下:

return {
  build: {
    lib: {
      entry: {
        customReport: 'widgets/custom_report/index.js'
      },
      fileName: (_, entryName) => `${entryName}.js`,
      formats: ['es']
    },
    rollupOptions: {
      output: {
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[name].[ext]',
        manualChunks: id => {
          if (id.includes('node_modules')) {
            for (let chunk of chunks) {
              if (id.includes(`node_modules/${chunk}`)) return chunk
            }
          }
        }
      }
    }
  }
}

起初并没有将 vue 从打包中排除,因为看似一切正常,直到后来出现了严重问题👇


💥 问题分析:Vue 多实例导致渲染死循环

在某个小组件中使用函数式组件渲染 VNode 数组时,页面发生死循环渲染

<template>
  <VNode :data="bottomInst" />
</template>

<script>
export default {
  components: {
    VNode: {
      functional: true,
      render: (h, ctx) =>
        isFunction(ctx.props.data) ? ctx.props.data() : ctx.props.data
    }
  }
}
</script>
  • 页面会不断触发 render(),导致浏览器卡死。

  • 后续测试发现是因为宿主项目与子组件中使用的 Vue 实例不一致

换句话说,小组件自己又打包进了一份 vue,与宿主项目的 Vue 实例“冲突”了。


🧠 回到根源:Vue 外部化问题

Vite 官方文档明确建议:

当你的库要被宿主项目引用时,应使用 build.lib外部化处理依赖,例如 vuereact

官方示例:

export default defineConfig({
  build: {
    lib: {
      entry: {
        'my-lib': resolve(__dirname, 'lib/main.js'),
        secondary: resolve(__dirname, 'lib/secondary.js')
      },
      name: 'MyLib'
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

⚠️ 实际遇到的问题

当配置为多入口时:

  • 默认打包格式会变为 ['es', 'cjs']

  • 如果强制设置 formats: ['umd'],Rollup 会报错;

  • 而在 ESM 模式下globals 配置不会生效(即不会转成全局变量访问)。

因此,当外部化 vue 后,代码仍然保留:

import vue from 'vue'

浏览器在执行时会报错:

Uncaught TypeError: Failed to resolve module specifier "vue".

因为浏览器根本不知道 "vue" 这个导入路径应该从哪里加载。


✅ 正确的解决方案

方法一:使用 HTML Import Map(推荐,前提是宿主环境支持)

如果不需要兼容 Chrome 89 以下版本,可以在宿主项目的 HTML 中声明:

<script type="importmap">
{
  "imports": {
    "vue": "https://example.com/vue.js"
  }
}
</script>

这样浏览器在解析到:

import Vue from 'vue'

时,会自动从 /vue.js 加载,而不是去找 node_modules

✅ 优点:

  • 符合原生 ES Module 机制;

  • 适合现代浏览器;

  • 简洁直观。

❌ 缺点:

  • Chrome 89 以下(含 IE)不支持 importmap

  • 需要在宿主 HTML 中维护映射。


方法二:使用 Rollup 的 output.paths 配置

如果你希望在构建阶段就将路径转换成一个可访问的 URL,可使用:

return {
  build: {
    lib: {
      entry: {
        customReport: 'widgets/custom_report/index.js'
      },
      fileName: (_, entryName) => `${entryName}.js`,
      formats: ['es']
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        paths: {
          vue: '/dist/assets/vue.js'
        }
      }
    }
  }
}

打包后:

// ✅ 自动替换成可访问路径
import vue from '/dist/assets/vue.js'

宿主项目只需确保 /dist/assets/vue.js 存在(即宿主与子项目共享同一份 Vue 文件)。

✅ 优点:

  • 不依赖 importmap;

  • 可控制精确路径;

  • 浏览器 100% 可识别。

❌ 缺点:

  • 宿主项目需要在相同路径下提供该文件;

  • 一旦路径变更,需要同步更新。


🧾 总结对比

方案 关键配置 优点 缺点 适用场景
方法一 ImportMap <script type="importmap"> 原生 ESM 支持、配置简单 浏览器兼容性要求高 现代浏览器环境
方法二 Rollup paths rollupOptions.output.paths 无需 importmap,构建期解决 路径需宿主一致 自定义部署结构、多版本环境

🚀 实践建议

  1. 统一宿主与子项目的 Vue 实例来源
    → 不要在子组件中重复打包 vue

  2. 打包时外部化 Vue

    external: ['vue']
    
  3. 根据浏览器兼容性选择方案

    • 支持现代浏览器 → ✅ ImportMap;

    • 需要兼容旧环境 → ✅ Rollup paths

el-input数字类型禁止+-符号输入

使用 el-input 的数字类型输入时,会默认开放-+e符号输入,且无法通过onInput进行拦截。

<el-input
    v-model="input"
    type="number"
    placeholder="请输入内容"
    @input="onInput"
></el-input>
function onInput (value) {
    const val = value.toString().replace(/[^\d/.]/g, '');
    input.value = val
}

以上方法无法正确响应,原因有二:

  1. number类型将 -+e 识别为数字符号,不做限制,且符号的输入不会精确触发input响应;
  2. 首次接收到 -+e 输入后,返回剔除掉的文本回显,无法正确显示,原生input会内部转义,导致无法即显。

方案

可监听原生input方法,即可规避问题1;回显赋值时,同时更改原生inputvalue值,即可。 需要注意的是,该方案依赖Event中的属性data,对于兼容性要求高的场景,需谨慎处理。

<el-input
    v-model="input"
    type="number"
    placeholder="请输入内容"
    @input.native="onInput"
></el-input>
function onInput (e) {
    const target = e.target;
    const value = target.value;
  
    if( [ '-', '+', 'e' ].includes(e.data) ) {
        target.value = offset.value[key] + '';
    }
    const val = value.toString().replace(/[^\d/.]/g, '');
    input.value = val
}

浏览器之内置四大多线程API

一、为什么 Web Worker 能实现 “多线程”?

浏览器的 JS 引擎(如 V8)本身是单线程的,但浏览器是多进程 / 多线程架构。Web Worker 的本质是 浏览器为 JS 提供的 “额外线程池” ,核心原理:

  1. 线程隔离:Worker 线程与主线程是完全隔离的内存空间(不共享堆内存),通过 “消息队列” 通信(数据传输基于结构化克隆算法,深拷贝)。
  2. 阻塞无关性:Worker 线程的阻塞(如死循环)不会影响主线程,但会导致自身无法响应消息。
  3. 资源限制:浏览器对 Worker 数量有上限(通常同源下不超过 20 个),且单个 Worker 占用的内存有限制(避免滥用系统资源)。

关键区别:与 Node.js 的 child_process 不同,Web Worker 无法共享内存(需通过 SharedArrayBuffer 实现有限共享,见下文),且受浏览器安全策略(如跨域限制)约束。

总结: webwork是通过js的方式唤起浏览器的内置api使用,辅助前端计算的一种方式,就像fetch、ajaix那样唤起浏览器的接口查询一样。

多线程四大API

Web Worker 是前端 “多线程” 的基础规范,但浏览器针对不同场景设计了 4 种独立的 Worker 类型—— 它们共享 “线程隔离” 的核心思想(避免阻塞主线程),但定位、能力、使用场景完全不同,均为 W3C 标准定义的原生 API(无需第三方库),底层由浏览器独立实现(无依赖关系)。

浏览器 4 种核心 Worker 类型对比表

Worker 类型 核心定位 底层依赖 典型场景
Web Worker 主线程的 “计算助手”,处理耗时计算 浏览器线程池 大数据排序、加密解密、复杂算法计算
Shared Worker 多页面共享的 “后台协调者”,跨页面通信 浏览器共享线程 多标签页登录状态同步、跨页数据协同
Service Worker 页面离线的 “代理服务器”,拦截请求 + 缓存 浏览器后台线程 离线应用、请求拦截优化、浏览器推送通知
Worklet 渲染管线的 “实时处理器”,介入渲染流程 浏览器渲染线程 CSS 物理动画、音频降噪、Canvas 渲染优化

二、分类

1. Web Worker(计算型:解决主线程阻塞)

核心能力

  • 独立于主线程的 “计算线程”,仅与创建它的主线程通信(一对一);
  • 生命周期与页面绑定(页面关闭则 Worker 销毁);
  • 不阻塞 UI,专门处理耗时计算(避免页面卡顿)。

示例:10 万条数据排序

// 主线程(main.js):发起计算请求,接收结果
if (window.Worker) {
  // 1. 创建 Worker 实例
  const sortWorker = new Worker('sort-worker.js');
  
  // 2. 生成 10 万条随机大数据(模拟复杂计算场景)
  const bigData = Array.from({ length: 100000 }, () => Math.random() * 100000);
  
  // 3. 发送数据给 Worker,触发计算
  sortWorker.postMessage(bigData);
  console.log('主线程:已发送数据,等待排序结果...');
  
  // 4. 接收 Worker 返回的计算结果
  sortWorker.onmessage = (e) => {
    console.log('主线程:排序完成,前 10 条数据:', e.data.slice(0, 10));
    sortWorker.terminate(); // 计算完成,销毁 Worker(避免内存泄漏)
  };
  
  // 5. 监听 Worker 错误(如代码报错)
  sortWorker.onerror = (err) => {
    console.error(`Worker 错误:${err.message}(行号:${err.lineno})`);
    sortWorker.terminate();
  };
} else {
  console.error('当前浏览器不支持 Web Worker');
}
// Worker 线程(sort-worker.js):执行耗时计算
self.onmessage = (e) => {
  const data = e.data;
  console.log('Worker 线程:开始排序 10 万条数据...');
  
  // 耗时计算(示例用内置排序,实际可替换为快排、归并等复杂算法)
  const sortedData = data.sort((a, b) => a - b);
  
  // 向主线程返回结果
  self.postMessage(sortedData);
  self.close(); // Worker 主动关闭,释放资源
};

运行效果

主线程可正常处理用户交互(点击、滚动),排序在后台线程执行,页面无卡顿;计算完成后自动销毁 Worker,无内存泄漏。

2. Shared Worker(协同型:多页面数据同步)

核心能力

  • 同源多页面可共享同一个 Worker 实例(突破 “单页面私有” 限制);
  • 通过 port(通信端口)实现多页面与 Worker 的双向通信;
  • 适合跨页面状态同步(无需重复请求接口)。

示例:多标签页登录状态同步

// 主线程 - 页面 A(login.html):登录后同步状态
if (window.SharedWorker) {
  // 1. 创建 Shared Worker 实例
  const sharedWorker = new SharedWorker('sync-worker.js');
  // 2. 激活通信端口(必须调用 start())
  sharedWorker.port.start();
  
  // 3. 模拟登录按钮点击,发送登录状态给 Worker
  document.getElementById('login-btn').addEventListener('click', () => {
    const userInfo = { username: 'admin', isLogin: true };
    sharedWorker.port.postMessage({ type: 'LOGIN', data: userInfo });
    console.log('页面 A:已发送登录状态');
  });
  
  // 4. 接收 Worker 广播的消息(如其他页面同步的状态)
  sharedWorker.port.onmessage = (e) => {
    console.log('页面 A 收到消息:', e.data);
  };
}
// 主线程 - 页面 B(index.html):实时获取登录状态
if (window.SharedWorker) {
  const sharedWorker = new SharedWorker('sync-worker.js');
  sharedWorker.port.start();
  
  // 1. 向 Worker 请求当前登录状态
  sharedWorker.port.postMessage({ type: 'QUERY_STATUS' });
  
  // 2. 接收状态(页面 A 登录后,页面 B 实时更新)
  sharedWorker.port.onmessage = (e) => {
    if (e.data.type === 'LOGIN_STATUS') {
      document.getElementById('status').textContent = e.data.isLogin 
        ? `已登录:${e.data.username}` 
        : '未登录';
    }
  };
}
// Shared Worker 线程(sync-worker.js):维护全局状态,广播消息
const connections = []; // 存储所有连接的页面端口
let globalState = { isLogin: false, username: '' }; // 全局共享状态
// 监听页面连接(新页面打开时触发)
self.onconnect = (e) => {
  const port = e.ports[0];
  connections.push(port); // 记录新连接的页面
  port.start();
  
  // 处理页面发送的消息
  port.onmessage = (msg) => {
    switch (msg.data.type) {
      case 'LOGIN':
        // 更新全局状态
        globalState = msg.data.data;
        // 广播给所有连接的页面(同步状态到页面 A、B...)
        connections.forEach(p => p.postMessage({ 
          type: 'LOGIN_STATUS', 
          ...globalState 
        }));
        break;
      case 'QUERY_STATUS':
        // 单独响应某个页面的状态查询
        port.postMessage({ type: 'LOGIN_STATUS', ...globalState });
        break;
    }
  };
  
  // 页面关闭时,移除端口(避免内存泄漏)
  port.onclose = () => {
    const index = connections.indexOf(port);
    if (index !== -1) connections.splice(index, 1);
  };
};

运行效果

页面 A 点击 “登录” 后,页面 B 无需刷新,实时显示 “已登录:admin”;多标签页共享同一登录状态,无需重复调用登录接口。

3. Service Worker(网络型:离线缓存 + 请求拦截)

核心能力

  • 完全独立于页面,运行在浏览器后台(页面关闭后仍可活动);
  • 拦截所有网络请求,可自定义缓存策略(实现离线访问);
  • 生命周期包含 “安装→激活→运行”,需手动管理缓存版本。

示例:离线缓存静态资源 + API 请求

// 主线程(main.js):注册 Service Worker,触发离线逻辑
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      // 1. 注册 Service Worker(脚本需在根目录,确保作用域覆盖所有页面)
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service Worker 注册成功,作用域:', registration.scope);
      
      // 2. 测试离线请求(首次联网缓存,后续断网可访问)
      fetch('/api/data').then(res => res.json()).then(data => {
        console.log('API 请求结果:', data);
      });
    } catch (err) {
      console.error('Service Worker 注册失败:', err);
    }
  });
}
// Service Worker 线程(sw.js):缓存+请求拦截逻辑
const CACHE_NAME = 'offline-cache-v1'; // 缓存版本(更新时修改版本号)
// 需要缓存的资源列表(静态资源+API接口)
const CACHE_ASSETS = [
  '/', 
  '/index.html',
  '/styles.css',
  '/api/data' // 需缓存的 API 接口
];
// 1. 安装阶段:缓存静态资源(仅首次注册/版本更新时触发)
self.addEventListener('install', (event) => {
  // 等待缓存完成后,再进入激活阶段
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CACHE_ASSETS)) // 批量缓存资源
      .then(() => self.skipWaiting()) // 跳过等待,立即激活新 Worker(替换旧版本)
  );
});
// 2. 激活阶段:清理旧缓存(避免缓存膨胀)
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      // 删除非当前版本的缓存
      return Promise.all(
        cacheNames.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))
      );
    }).then(() => self.clients.claim()) // 强制所有打开的页面使用新 Worker
  );
});
// 3. 拦截请求:优先从缓存返回,无缓存则请求网络
self.addEventListener('fetch', (event) => {
  // 仅拦截同源的 GET 请求(避免跨域资源和非幂等请求)
  if (event.request.method === 'GET' && event.request.mode === 'same-origin') {
    event.respondWith(
      caches.match(event.request)
        .then(cachedResponse => {
          // 缓存命中:直接返回缓存资源
          if (cachedResponse) {
            console.log('从缓存返回:', event.request.url);
            return cachedResponse;
          }
          // 缓存未命中:发起网络请求,并缓存新结果
          return fetch(event.request).then(networkResponse => {
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, networkResponse.clone()); // 缓存新请求
            });
            return networkResponse;
          });
        })
    );
  }
});

运行效果

  • 首次联网时,自动缓存 index.html、styles.css 和 /api/data;
  • 断网后刷新页面,仍能正常加载页面和 API 数据(从缓存读取);
  • 缓存版本更新时,自动清理旧缓存,避免资源冗余。

4. Worklet(实时型:介入渲染流程)

核心能力

  • 嵌入浏览器渲染管线(如 CSS 引擎线程、音频线程),低延迟(<1ms);
  • 直接干预渲染过程(动画、绘制、音频处理),弥补 Web Worker 通信延迟的缺陷;
  • 细分类型:CSSWorklet(动画)、AudioWorklet(音频)、PaintWorklet(绘制)。

示例:CSSWorklet 实现物理弹性动画

// 主线程(main.js):注册 Worklet,关联动画
if ('CSSWorklet' in window) {
  try {
    // 注册自定义 Worklet(加载动画逻辑脚本)
    await CSSWorklet.addModule('bounce-worklet.js');
    console.log('CSSWorklet 注册成功');
  } catch (err) {
    console.error('CSSWorklet 注册失败:', err);
  }
}
// HTML 结构:动画元素
/*
<div class="box">弹性动画方块</div>
*/
// CSS 样式:绑定 Worklet 动画
/*
.box {
  width: 100px;
  height: 100px;
  background: red;
  /* 使用 Worklet 定义的动画(名称与 Worklet 中注册一致) */
  animation: bounce 2s infinite;
}
/* 定义动画进度(from→to 对应 Worklet 中的 0→1) */
@keyframes bounce {
  from { transform: translateY(0); }
  to { transform: translateY(300px); }
}
*/
// CSSWorklet 线程(bounce-worklet.js):自定义动画逻辑
class BounceWorklet {
  // 每一帧的计算(嵌入渲染管线,实时执行)
  process(inputs, outputs, parameters) {
    const [t] = inputs; // 动画进度(0~1,from→to)
    const [output] = outputs; // 输出结果(最终的 CSS 样式值)
    
    // 物理弹性公式(模拟重力+反弹效果,非匀速动画)
    const bounce = Math.sin(t * Math.PI) * Math.exp(-t * 0.5);
    // 输出 transform 样式(控制方块位置)
    output.value = `translateY(${300 * (1 - bounce)}px)`;
  }
}
// 注册 Worklet 动画(名称需与 CSS 中的 @keyframes 名称一致)
registerAnimator('bounce', BounceWorklet);

运行效果

红色方块以 “物理弹性轨迹” 上下运动(类似小球落地反弹),动画流畅无卡顿;Worklet 运行在渲染线程,延迟极低,避免 Web Worker 通信导致的动画掉帧。

三、混合使用:多 Worker 协同解决复杂场景

场景需求:离线数据分析 Dashboard

需要同时满足 4 个核心需求:

  1. 离线访问(断网后仍可查看历史数据);
  1. 多标签页同步分析结果(无需重复解析);
  1. 后台解析 10 万条 CSV 数据(不阻塞页面);
  1. 实时渲染流畅图表(动画无掉帧)。

完整实现代码(整合 4 种 Worker)

// 主线程(dashboard.js):整合所有 Worker,协调流程
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      // 1. 第一步:注册 Service Worker(离线缓存)
      const swRegistration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service Worker 注册成功');
      // 2. 第二步:创建 Shared Worker(多页面同步)
      const sharedWorker = new SharedWorker('sync-worker.js');
      sharedWorker.port.start();
      // 3. 第三步:创建 Web Worker(CSV 数据解析)
      const csvWorker = new Worker('csv-worker.js');
      // 4. 第四步:注册 CSSWorklet(图表动画优化)
      if ('CSSWorklet' in window) {
        await CSSWorklet.addModule('chart-worklet.js');
        console.log('CSSWorklet 注册成功');
      }
      // 5. 页面元素:文件上传、图表容器、状态文本
      const fileInput = document.getElementById('file-upload');
      const chartContainer = document.getElementById('chart-container');
      const statusText = document.getElementById('status');
      // 6. 监听文件上传:触发 CSV 解析
      fileInput.addEventListener('change', (e) => {
        const file = e.target.files[0];
        if (file && file.name.endsWith('.csv')) {
          statusText.textContent = '正在解析 CSV 文件...';
          csvWorker.postMessage(file); // 发送文件给 Web Worker
        } else {
          statusText.textContent = '请上传 CSV 格式文件!';
        }
      });
      // 7. 接收 Web Worker 解析结果:渲染+同步+缓存
      csvWorker.onmessage = (e) => {
        const analysisResult = e.data; // 解析结果(数据数组+统计信息)
        
        // 7.1 渲染图表(用 CSSWorklet 优化动画)
        renderChart(analysisResult, chartContainer);
        
        // 7.2 同步结果到其他标签页(通过 Shared Worker)
        sharedWorker.port.postMessage({
          type: 'ANALYSIS_RESULT',
          data: analysisResult
        });
        
        // 7.3 缓存结果到 Service Worker(支持离线访问)
        if (navigator.serviceWorker.controller) {
          navigator.serviceWorker.controller.postMessage({
            type: 'CACHE_RESULT',
            key: 'last-csv-analysis',
            data: analysisResult
          });
        }
        
        // 7.4 更新状态文本
        statusText.textContent = `解析完成:共 ${analysisResult.totalCount} 条</doubaocanvas>

四、关于self

// 1. 来源:Worker 线程的内置全局对象,浏览器自动注入,无需定义
// 2. 作用:等同于主线程的 window,是 Worker 线程访问自身能力的入口
// 3. 隔离性:不同 Worker 的 self 是独立实例,互不干扰(如 Web Worker 的 self ≠ Service Worker 的 self)
// 4. 核心能力:接收/发送消息(onmessage/postMessage)、管理生命周期(close)、调用 Worker 专属 API
Worker 中 self 的简单代码示例
// Worker 线程(calc-worker.js)
// self 指向当前 Web Worker 实例,仅用于计算相关逻辑
self.onmessage = (e) => {
  const { num1, num2 } = e.data;
  const sum = num1 + num2; // 简单计算:两数相加
  self.postMessage(sum); // 用 self 向主线程返回结果
  self.close(); // 用 self 主动关闭 Worker,释放资源
};
// 主线程(main.js)- 配合使用
const calcWorker = new Worker('calc-worker.js');
calcWorker.postMessage({ num1: 10, num2: 20 }); // 发数据给 Worker
calcWorker.onmessage = (e) => {
  console.log('计算结果:', e.data); // 输出 30
};
注意:
// 注意 1:Worker 中不能用 window,必须用 self(因 Worker 无窗口概念)
// 注意 2self 无法访问 DOM(如 document、body),仅能处理非 UI 逻辑
// 注意 3:简单场景可省略 self(如 onmessage = () => {} 等同于 self.onmessage = () => {}),但复杂场景建议保留,增强可读性

五、生产环境使用的平衡点

1. 错误处理与健壮性

  • Worker 内部错误不会冒泡到主线程,需单独监听:

    // 主线程
    worker.onerror = (err) => {
      console.error(`Worker错误:${err.message}`);
      worker.terminate(); // 出错后销毁Worker,避免内存泄漏
    };
    
    // Worker线程
    self.onerror = (err) => {
      self.postMessage({ type: 'ERROR', message: err.message });
      self.close(); // 主动关闭
    };
    
  • 网络错误:Worker 脚本加载失败(404)时,主线程会触发 error 事件,需捕获并降级处理。

2. 内存管理与资源回收

  • 避免创建过多 Worker:每个 Worker 都是独立线程,占用内存和 CPU,建议通过 “Worker 池” 复用(如用 p-queue 管理 Worker 实例)。
  • 及时销毁无用 Worker:worker.terminate()(主线程主动销毁,立即终止)或 self.close()(Worker 主动关闭,清理后终止)。
  • 警惕内存泄漏:Worker 中若持有 setInterval、未关闭的 fetch 请求等,即使调用 terminate 也可能导致内存泄漏,需先清理资源。

3. 跨域与安全策略

  • Worker 脚本必须与主线程同源(协议、域名、端口一致),若需加载跨域脚本,需通过 importScripts 加载且服务器允许跨域(Access-Control-Allow-Origin)。
  • 禁止访问 file:// 协议下的脚本(浏览器安全限制),本地开发需用 http-server 启动服务。
  • 敏感操作限制:Worker 中无法使用 localStorage(部分浏览器支持,但规范不推荐),建议用 IndexedDB 存储大量数据(异步 API,不阻塞)。

4. 兼容性处理与降级方案

  • 浏览器支持:IE 完全不支持,Edge 12+、Chrome 4+、Firefox 3.5+ 支持基本特性,SharedArrayBuffer 和 SharedWorker 兼容性较差。

  • 降级逻辑:

    if (window.Worker) {
      // 使用Worker
    } else {
      // 降级到主线程执行(给用户提示“当前浏览器可能卡顿”)
      heavyTask();
    }
    

六、性能陷阱

  1. 过度使用 Worker 导致性能反降小数据计算(如几毫秒可完成的操作)用 Worker 反而会增加通信开销(序列化 + 消息传递),建议仅对 执行时间 > 50ms 的任务使用 Worker。

  2. 频繁通信导致主线程阻塞若 Worker 与主线程高频次 postMessage(如每秒 hundreds 次),序列化数据会占用主线程资源,导致卡顿。解决方案:

    • 批量发送数据(累计一定量后再通信);
    • 用 SharedArrayBuffer 减少序列化开销。
  3. Worker 中滥用同步 APIWorker 虽然不阻塞 UI,但内部的同步操作(如 XMLHttpRequest 的同步请求、大量同步循环)会阻塞自身线程,导致无法响应消息。建议优先使用异步 API(fetchsetImmediate)。

  4. 忽略线程优先级浏览器会给 Worker 分配较低的线程优先级,若需 “近实时” 处理(如游戏帧计算),可能出现延迟。此时可考虑 requestIdleCallback 结合 Worker,利用主线程空闲时间处理。

Token无感刷新全流程(Vue + Axios + Node.js(Express))

页面基本流程

  1. 登录成功后,后端返回 Access Token 和 Refresh Token,前端存储两者及各自有效期。
  2. 每次发起业务请求前,前端判断 Access Token 是否即将过期。
  3. 若即将过期,先调用 “刷新 Token 接口”,用有效的 Refresh Token 换取新的 Access Token。
  4. 用新的 Access Token 发起原业务请求,用户全程无感知。
  5. 若 Refresh Token 也过期,才会引导用户重新登录。

一、技术栈与核心约定

  • 前端:Vue 3(适配 Vue 2,只需微调语法)+ Axios(统一请求拦截)

  • 后端:Node.js + Express + JWT(生成 Token)+ Redis(存储 Refresh Token,可选但推荐)

  • Token 规则:

    • Access Token:短期有效(1 小时),用于业务请求身份验证
    • Refresh Token:长期有效(7 天),仅用于刷新 Access Token
    • 状态码:401 = Access Token 过期 / 无效;403 = Refresh Token 过期 / 无效

二、前端实现(核心代码)

1. 初始化 Axios 实例(api/index.js)

封装请求 / 响应拦截器,处理 Token 携带、刷新和重试逻辑

import axios from 'axios';
import { ElMessage } from 'element-plus'; // 按需引入UI组件库提示(可选)

// 1. 创建Axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量配置后端地址
  timeout: 5000, // 请求超时时间
});

// 2. Token存取工具函数(安全存储建议用HttpOnly Cookie,此处用localStorage演示)
const TokenKey = {
  ACCESS: 'access_token',
  REFRESH: 'refresh_token',
};

// 获取Token
const getAccessToken = () => localStorage.getItem(TokenKey.ACCESS);
const getRefreshToken = () => localStorage.getItem(TokenKey.REFRESH);
// 存储新Token
const setTokens = (accessToken, refreshToken) => {
  localStorage.setItem(TokenKey.ACCESS, accessToken);
  localStorage.setItem(TokenKey.REFRESH, refreshToken);
};
// 清除Token(退出登录用)
const removeTokens = () => {
  localStorage.removeItem(TokenKey.ACCESS);
  localStorage.removeItem(TokenKey.REFRESH);
};

// 3. 刷新状态管理(防止并发请求重复刷新Token)
let isRefreshing = false; // 是否正在刷新Token
let requestQueue = []; // 等待刷新完成的请求队列

// 4. 请求拦截器:自动给所有请求添加Access Token
service.interceptors.request.use(
  (config) => {
    const token = getAccessToken();
    if (token) {
      // 规范格式:Bearer + 空格 + Token(后端需对应解析)
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 5. 响应拦截器:处理Token过期逻辑
service.interceptors.response.use(
  (response) => response.data, // 直接返回响应体,简化业务层调用
  async (error) => {
    const { response, config } = error;
    const originalRequest = config; // 原始失败请求

    // 仅处理401状态码(Access Token过期/无效),且排除刷新Token本身的请求
    if (response?.status === 401 && originalRequest.url !== '/auth/refresh') {
      // 避免重复刷新:正在刷新时,将请求加入队列
      if (isRefreshing) {
        return new Promise((resolve) => {
          requestQueue.push(() => {
            // 刷新成功后,用新Token重试原始请求
            originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`;
            resolve(service(originalRequest));
          });
        });
      }

      originalRequest._retry = true; // 标记该请求已进入重试流程
      isRefreshing = true; // 开启刷新状态

      try {
        // 调用后端刷新接口,用Refresh Token换取新Token
        const refreshToken = getRefreshToken();
        if (!refreshToken) {
          throw new Error('Refresh Token不存在');
        }

        const refreshRes = await service.post('/auth/refresh', {
          refreshToken, // 传给后端的Refresh Token
        });

        // 存储新Token
        const { accessToken, refreshToken: newRefreshToken } = refreshRes;
        setTokens(accessToken, newRefreshToken);

        // 重试队列中所有等待的请求
        requestQueue.forEach((callback) => callback());
        requestQueue = []; // 清空队列

        // 重试当前失败的请求
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return service(originalRequest);
      } catch (refreshError) {
        // 刷新失败(Refresh Token过期/无效),强制跳转登录页
        removeTokens(); // 清除本地无效Token
        ElMessage.error('登录已过期,请重新登录');
        window.location.href = '/login'; // 跳转到登录页
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false; // 关闭刷新状态
      }
    }

    // 非401错误(如网络错误、业务错误),直接抛出
    ElMessage.error(error.message || '请求失败');
    return Promise.reject(error);
  }
);

export default service;

2. 登录与业务请求示例(api/user.js)

import service from './index';

// 登录:获取初始双Token
export const login = (username, password) => {
  return service.post('/auth/login', { username, password });
};

// 业务请求示例(无需手动处理Token)
export const getUserInfo = () => {
  return service.get('/user/info');
};

// 退出登录:清除Token
export const logout = () => {
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  window.location.href = '/login';
};

3. 登录页面使用示例(Login.vue)

<template>
  <div>
    <input v-model="username" placeholder="用户名" />
    <input v-model="password" type="password" placeholder="密码" />
    <button @click="handleLogin">登录</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { login } from '@/api/user';
import { ElMessage } from 'element-plus';

const username = ref('');
const password = ref('');

const handleLogin = async () => {
  try {
    // 调用登录接口,后端返回accessToken和refreshToken
    const res = await login(username.value, password.value);
    // 存储Token(实际已在api拦截器中处理,此处简化)
    localStorage.setItem('access_token', res.accessToken);
    localStorage.setItem('refresh_token', res.refreshToken);
    ElMessage.success('登录成功');
    window.location.href = '/home'; // 跳转到首页
  } catch (error) {
    ElMessage.error('登录失败,请检查账号密码');
  }
};
</script>

三、后端实现(Node.js + Express)

1. 依赖安装

npm install express jsonwebtoken redis cors dotenv // 核心依赖

2. 核心配置(config.js)

require('dotenv').config();

module.exports = {
  // JWT密钥(生产环境需用环境变量,避免硬编码)
  JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key-321',
  // Token有效期
  ACCESS_TOKEN_EXPIRES: '1h', // 1小时
  REFRESH_TOKEN_EXPIRES: '7d', // 7天
  // Redis配置(存储Refresh Token,防止重复使用)
  REDIS: {
    host: 'localhost',
    port: 6379,
    db: 0,
  },
};

3. JWT 工具函数(utils/jwt.js)

javascript

const jwt = require('jsonwebtoken');
const config = require('../config');

// 生成Token
const generateToken = (payload, expiresIn) => {
  return jwt.sign(payload, config.JWT_SECRET, { expiresIn });
};

// 验证Token
const verifyToken = (token) => {
  try {
    return jwt.verify(token, config.JWT_SECRET);
  } catch (error) {
    throw new Error('Token无效或已过期');
  }
};

module.exports = { generateToken, verifyToken };

4. Redis 工具函数(utils/redis.js)

const redis = require('redis');
const config = require('../config');

// 创建Redis客户端
const client = redis.createClient({
  host: config.REDIS.host,
  port: config.REDIS.port,
  db: config.REDIS.db,
});

// 连接Redis
client.connect().catch((err) => console.error('Redis连接失败:', err));

// 存储Refresh Token(key: userId, value: refreshToken)
const setRefreshToken = async (userId, refreshToken) => {
  // 有效期与Refresh Token一致(7天)
  await client.setEx(`refresh_token:${userId}`, 60 * 60 * 24 * 7, refreshToken);
};

// 获取Refresh Token
const getRefreshToken = async (userId) => {
  return await client.get(`refresh_token:${userId}`);
};

// 删除Refresh Token(退出登录时)
const deleteRefreshToken = async (userId) => {
  await client.del(`refresh_token:${userId}`);
};

module.exports = { setRefreshToken, getRefreshToken, deleteRefreshToken };

5. 核心接口实现(routes/auth.js)

const express = require('express');
const router = express.Router();
const { generateToken, verifyToken } = require('../utils/jwt');
const { setRefreshToken, getRefreshToken, deleteRefreshToken } = require('../utils/redis');
const config = require('../config');

// 模拟用户数据库(实际替换为MySQL/MongoDB)
const mockUsers = [
  { id: 1, username: 'admin', password: '123456' },
];

// 1. 登录接口:生成双Token
router.post('/login', (req, res) => {
  const { username, password } = req.body;
  // 验证账号密码
  const user = mockUsers.find(
    (u) => u.username === username && u.password === password
  );

  if (!user) {
    return res.status(400).json({ message: '账号或密码错误' });
  }

  // 生成双Token(payload中存储用户唯一标识,避免敏感信息)
  const accessToken = generateToken({ userId: user.id }, config.ACCESS_TOKEN_EXPIRES);
  const refreshToken = generateToken({ userId: user.id }, config.REFRESH_TOKEN_EXPIRES);

  // 存储Refresh Token到Redis(用于后续验证)
  setRefreshToken(user.id, refreshToken);

  // 返回双Token给前端
  res.json({
    code: 200,
    message: '登录成功',
    data: { accessToken, refreshToken },
  });
});

// 2. 刷新Token接口:用有效Refresh Token换取新双Token
router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    return res.status(403).json({ message: 'Refresh Token不能为空' });
  }

  try {
    // 1. 验证Refresh Token有效性
    const payload = verifyToken(refreshToken);
    const { userId } = payload;

    // 2. 验证Redis中存储的Refresh Token是否一致(防止伪造)
    const storedRefreshToken = await getRefreshToken(userId);
    if (storedRefreshToken !== refreshToken) {
      return res.status(403).json({ message: 'Refresh Token无效' });
    }

    // 3. 生成新的双Token
    const newAccessToken = generateToken({ userId }, config.ACCESS_TOKEN_EXPIRES);
    const newRefreshToken = generateToken({ userId }, config.REFRESH_TOKEN_EXPIRES);

    // 4. 更新Redis中的Refresh Token(滑动过期,增强安全性)
    await setRefreshToken(userId, newRefreshToken);

    // 5. 返回新Token
    res.json({
      code: 200,
      data: { accessToken: newAccessToken, refreshToken: newRefreshToken },
    });
  } catch (error) {
    return res.status(403).json({ message: 'Refresh Token已过期,请重新登录' });
  }
});

// 3. 退出登录接口:删除Redis中的Refresh Token
router.post('/logout', async (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(400).json({ message: 'Token不能为空' });
  }

  try {
    const payload = verifyToken(token);
    await deleteRefreshToken(payload.userId);
    res.json({ code: 200, message: '退出登录成功' });
  } catch (error) {
    res.status(400).json({ message: '退出登录失败' });
  }
});

module.exports = router;

6. 后端入口文件(app.js)

const express = require('express');
const cors = require('cors');
const authRouter = require('./routes/auth');

const app = express();
const port = 3001;

// 跨域配置(生产环境需限制origin)
app.use(cors());
// 解析JSON请求体
app.use(express.json());

// 挂载路由
app.use('/api/auth', authRouter);

// 启动服务
app.listen(port, () => {
  console.log(`后端服务启动成功:http://localhost:${port}`);
});

四、关键注意事项(生产环境必看)

  1. 安全存储 Token

    • 不推荐用 localStorage 存储(易受 XSS 攻击),优先用 HttpOnly Cookie 存储 Refresh Token,前端无法读取,避免窃取。
    • Access Token 可存在内存(如 Vuex/Pinia),页面刷新后通过 Cookie 获取 Refresh Token 重新刷新。
  2. 防止重复刷新

    • isRefreshing状态和requestQueue队列,避免多个并发请求同时触发刷新接口,导致 Token 冲突。
  3. Redis 的必要性

    • 存储 Refresh Token 到 Redis,支持 “强制登出”“单点登录” 功能(如修改密码后,删除 Redis 中的旧 Refresh Token,强制用户重新登录)。
  4. HTTPS 协议

    • 生产环境必须启用 HTTPS,防止 Token 在传输过程中被中间人窃取。
  5. Token 有效期合理设置

    • Access Token:15 分钟~2 小时(越短越安全)。
    • Refresh Token:7~30 天(平衡安全性和用户体验)。

五、完整流程梳理

  1. 用户登录 → 后端验证账号密码 → 返回 Access Token 和 Refresh Token → 前端存储。
  2. 前端发起业务请求 → 拦截器自动携带 Access Token → 后端验证有效 → 返回业务数据。
  3. 若 Access Token 过期 → 后端返回 401 → 前端拦截器调用刷新接口。
  4. 刷新接口验证 Refresh Token 有效 → 返回新双 Token → 前端更新存储,重试原始请求。
  5. 若 Refresh Token 过期 → 前端清除 Token,跳转登录页。

在Next.js中实现页面级别KeepAlive

在Next.js中实现页面级别KeepAlive

前提:

  1. 基于App Router的路由模式

  2. 使用客户端组件(服务端组件没测试过)

很简单, 只需要三个文件

组件名称 作用
KeepAliveProvider.tsx 用于包裹最外层layout, 处理缓存组件和非缓存组件的渲染
KeepAliveContext.tsx 无需解释
KeepAlive.tsx 用于包裹App Router的页面组件page.tsx

KeepAliveProvider.tsx

'use client'

import { KeepAliveContext } from '@/components/KeepAlive/KeepAliveContext'
import { usePathname } from 'next/navigation'
import React, { useState } from 'react'

interface KeepAliveProviderProps {
  children: React.ReactNode
}

// 最大缓存数量
const MAX_CACHE_SIZE = 5

export const KeepAliveProvider: React.FC<KeepAliveProviderProps> = ({ children }) => {
  const pathname = usePathname()
  const [componentList, setComponentList] = useState<Map<string, React.ReactNode>>(new Map())

  // 更新缓存组件列表的方法
  const updateComponentList = (path: string, component: React.ReactNode) => {
    setComponentList((prev) => {
      const newMap = new Map(prev)
      newMap.set(path, component)

      // 如果超过最大缓存数量,再删除最旧的
      if (newMap.size > MAX_CACHE_SIZE) {
        const oldestKey = newMap.keys().next().value

        // 如果存在最旧的key,则删除
        if (oldestKey !== undefined) {
          newMap.delete(oldestKey)
        }
      }

      return newMap
    })
  }

  return (
    <KeepAliveContext.Provider
      value={{
        componentList,
        updateComponentList,
        activePath: pathname,
      }}
    >
      {[...componentList.entries()].map(([key, node]) => (
        <div
          key={key}
          style={{
            display: key === pathname ? 'block' : 'none',
            height: '100%',
          }}
        >
          {node}
        </div>
      ))}

      <div className="h-full">{!componentList.get(pathname) && children}</div>
    </KeepAliveContext.Provider>
  )
}

KeepAliveContext.tsx

// 创建context
import React, { createContext } from 'react'

interface IKeepAliveContext {
  // 缓存的组件列表
  componentList: Map<string, React.ReactNode>
  // 更新缓存组件列表的方法
  updateComponentList: (path: string, component: React.ReactNode) => void
  // 当前激活的路径
  activePath?: string
}

export const KeepAliveContext = createContext<IKeepAliveContext>({
  componentList: new Map<string, React.ReactNode>(),
  updateComponentList: () => {},
})

KeepAlive.tsx

'use client'

import { usePathname } from 'next/navigation'
import React, { useContext, useEffect } from 'react'
import { KeepAliveContext } from './KeepAliveContext'

export const KeepAlive = ({ children }: { children: React.ReactNode }) => {
  const { updateComponentList } = useContext(KeepAliveContext)
  const pathname = usePathname()

  // 每当 children 变化时,更新缓存的组件列表
  useEffect(() => {
    updateComponentList(pathname, children)
  }, [children])

  // 该组件本身不渲染任何内容
  return null
}

用法

  1. 先包裹最外层layout (项目根目录的layout.tsx, 并非组件的layout.tsx)
// layout.tsx
import { KeepAliveProvider } from '@/components/KeepAlive/KeepAliveProvider'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <KeepAliveProvider>{children}</KeepAliveProvider>
      </body>
    </html>
  )
}
  1. 在需要缓存的页面组件中使用KeepAlive包裹
// page.tsx
import { KeepAlive } from '@/components/KeepAlive/KeepAlive'

export default function Page() {
  return (
    <KeepAlive>
      <div>这是一个需要缓存的页面内容</div>
    </KeepAlive>
  )
}
  1. 测试结果, 切换页面时, 该页面状态会被保留, 如果没保留, 请检查是否正确包裹, KeepAlive是否包裹正确

如果还是异常, 请忽略该文章, 就当没看过...

Vue.js 源码解读:从 new Vue() 到 DOM 更新的完整追踪

1. 示例与调试环境

示例代码(Vue@2.6.14)

<body>
  <div id="app">
    <h1>组件化机制</h1>
    <p>{{msg}}</p>
    <comp />
  </div>
  <script src="../../dist/vue.js"></script>
  <script>
    Vue.component('comp', {
      template: '<div>I am a component</div>'
    })

    const app = new Vue({
      el: '#app',
      data:{
        msg: 'hello vue'
      }
    })

  </script>
</body>

源码调试环境搭建

  1. 获取Vue.js源码,v2.6.14

  2. 安装项目依赖

    npm install(安装phantom.js时可终止)

  3. 安装构建工具rollup

    npm i -g rollup

  4. 配置构建脚本

    修改package.json中的dev脚本,添加--sourcemap,在开发模式下生成sourcemap

    "scripts": {
        "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
    }
    
  5. 构建Vue.js

    运行行开发命令npm run dev,构建完成后,会在dist目录下生成带源码映射的vue.js文件

  6. 创建测试页面并引入构建好的vue.js

  7. 调试Vue.js源码

在浏览器中打开测试页面,然后打开开发者工具进行调试

2. 从构建配置寻找源码入口

2.1. 构建命令分析

"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
  • -c scripts/config.js=> 指定Rollup配置文件路径
  • --environment TARGET:web-full-dev=> 设置环境变量TARGETweb-full-dev

2.2. 查看Rollup配置文件

  1. 打开scripts/config.js

  2. 找到web-full-dev相关配置

    const builds = {
      ...
      // Runtime+compiler development build (Browser)
      'web-full-dev': {
        entry: resolve('web/entry-runtime-with-compiler.js'),
        dest: resolve('dist/vue.js'),
        format: 'umd',
        env: 'development',
        alias: { he: './entity-decoder' },
        banner
      },
      ...
    }
    

2.3. 定位入口文件

从配置中可以看出入口文件为:

entry: resolve('web/entry-runtime-with-compiler.js')

  1. 先看resolve函数的定义:

    const aliases = require('./alias')
    const resolve = p => {
      // 将传入的web/entry-runtime-with-compiler.js通过/分割成数组
      // 然后取第一个元素设置为base,即web
      const base = p.split('/')[0]
      if (aliases[base]) {
        return path.resolve(aliases[base], p.slice(base.length + 1))
      } else {
        return path.resolve(__dirname, '../', p)
      }
    }
    

2. 接着来到scripts/alias.js

module.exports = {
  ...
  web: resolve('src/platforms/web'),// 找到web对应的真实路径    
  ...
}

3. 于是便得到入口文件的真实路径

src/platforms/web/entry-runtime-with-compiler.js

2.4. 通过追踪入口文件依赖关系找到核心入口

💡 主线不要乱,粗略地看细节,先理清调用链

打开代码文件后,先把方法都折叠起来,了解大致结构

🚂 src/platforms/web/entry-runtime-with-compiler.js

⬇️ import Vue from './runtime/index'

🚂 src/platforms/web/runtime/index.js

⬇️ import Vue from 'core/index'

🚂 /src/core/index.js

⬇️ import Vue from './instance/index'

🚂 src/core/instance/index.js

➡️ function Vue () { ... } 找到Vue构造函数

3. Vue的渐进式构建

Vue并非一次性定义完成,而是通过多个模块逐步增强功能

3.1. 原型方法混入

🚂 src/core/instance/index.js

先定义一个空函数,只是定义,还没执行

function Vue (options) {
  ...
  this._init(options)// 初始化入口
}

这里只是一个空函数,还没有原型方法:

按顺序混入原型上的方法和属性

initMixin(Vue) ➡️ 添加_init方法

stateMixin(Vue) ➡️ 添加 $data``$props属性 & $set``$delete``$watch方法

eventsMixin(Vue) ➡️ 添加 $on``$once``$off``$emit方法

lifecycleMixin(Vue) ➡️ 添加 _update``$forceUpdate``$destroy方法

renderMixin(Vue)

  • 添加 $nextTick方法
  • installRenderHelpers(Vue.prototype)
    • 为Vue实例添加渲染相关的工具方法(_s _l _v等,注意,此时还没有添加_c)
    • 🚂src/core/instance/render-helpers/index.js

导出基础Vue

此时只有原型方法

3.2. 静态方法添加

🚂 src/core/index.js

import Vue from './instance/index'// 导入上一步导出的基础Vue类
initGlobalAPI(Vue);// 全局静态API初始化入口

🚂 src/core/global-api/index.js

  • 初始化全局配置对象Vue.config

  • 添加内部使用的工具函数

    Vue.util = {
      warn,
      extend,
      mergeOptions,
      defineReactive
    }
    
  • 添加Vue.set Vue.delete Vue.nextTick

  • 初始化选项对象

    Vue.options = Object.create(null)

  • 初始化组件、指令、过滤器的存储结构

    // ['component', 'directive', 'filter']
    ASSET_TYPES.forEach(type => {
      Vue.options[type + 's'] = Object.create(null)
    })
    
  • 内置组件 <keep-alive>

  • 初始化全局API

    initUse(Vue)=>Vue.use

    initMixin(Vue)=>Vue.mixin

    initExtend(Vue)=>Vue.extend

  • 资源注册

    initAssetRegisters(Vue)

最后,导出完整的Vue类:

3.3. 平台适配完善

🚂src/platforms/web/runtime/index.js

导入上一步导出的Vue类

  • 添加web平台特定配置

    Vue.config.mustUseProp = mustUseProp
    Vue.config.isReservedTag = isReservedTag
    Vue.config.isReservedAttr = isReservedAttr
    Vue.config.getTagNamespace = getTagNamespace
    Vue.config.isUnknownElement = isUnknownElement
    
  • 安装web平台相关指令和组件

    // model,show
    extend(Vue.options.directives, platformDirectives)  
    // Transition,TransitionGroup
    extend(Vue.options.components, platformComponents)  
    
  • 安装 __patch__ 函数

  • 安装 $mount方法

  • 导出Web平台适配后的Vue

3.4. 编译器集成

🚂 src/platforms/web/entry-runtime-with-compiler.js

导入上一步平台适配后的Vue

  • 缓存原始的$mount方法 const mount = Vue.prototype.$mount

  • 重写$mount方法,支持模板编译

    const mount = Vue.prototype.$mount  // 缓存原始方法
    
    Vue.prototype.$mount = function (el, hydrating) {
      // 处理模板编译逻辑
      if (!options.render) {
        let template = options.template
        if (!template && el) {
          template = getOuterHTML(el)  // 将 DOM 转为模板字符串
        }
    
        if (template) {
          // 编译模板得到 render 函数
          const { render, staticRenderFns } = compileToFunctions(template, {
            delimiters: options.delimiters,
            comments: options.comments
          }, this)
          options.render = render
        }
      }
    
      // 调用原始 mount 方法
      return mount.call(this, el, hydrating)
    }
    

最终导出给用户使用的Vue

4. 从new Vue()到挂载

4.1. 全局组件注册

Vue.component('comp', {  // 🚂 调用initGlobalAPI中定义的Vue.component
  template: '<div>I am a component</div>'
})

4.1.1. 执行过程

  • 调用initGlobalAPI中定义的Vue.component方法
  • 将组件配置存入Vue.options.components
  • ⚠️ 此时只是注册定义,尚未创建组件构造函数

4.1.2. 相关代码

🚂src/core/global-api/assets.js

// src/core/global-api/assets.js
function initAssetRegisters(Vue) {
  ASSET_TYPES.forEach(type => {
    Vue[type] = function(id, definition) {
      if (!definition) {
        return this.options[type + 's'][id]
      }
      // 注册组件
      if (type === 'component' && isPlainObject(definition)) {
        definition.name = definition.name || id
        definition = this.options._base.extend(definition)
      }
      this.options[type + 's'][id] = definition
      return definition
    }
  })
}

执行完后

4.2. Vue实例化入口

const app = new Vue({
  el: '#app',
  data: {
    msg: 'hello vue'
  }
})

当执行new Vue(options)时,Vue内部会立即调用_init

🚂core/instance/index.js

const app = new Vue({
  el: '#app',
  data: {
    msg: 'hello vue'
  }
})

4.3. _init方法

❗初始化流程开始

🚂src/core/instance/init.js

4.3.1. 选项合并

目的: 将用户传入的实例配置与Vue构造函数自身的全局配置(🌰全局组件/混入/指令/...)进行合并,生成最终的实例属性vm.$options

vm.$options = mergeOptions(
  resolveConstructorOptions(vm.constructor),// 解析Vue全局配置
  options || {},// 用户实例配置
  vm// 当前Vue实例
)

合并后: vm.$options包含了所有可用于当前实例的选项

4.3.2. 核心功能初始化

此阶段按特定顺序初始化实例的核心功能模块,确保后续模块能访问先初始化的内容

  1. initLifecycle(vm)
    • 初始化组件实例的生命周期相关属性
    • 建立父子组件关系链($parent $children ...)等
  1. initEvents(vm)
    • 初始化事件系统
    • 处理父组件传递的自定义事件(vm.$listeners),为后续的$on $emit等方法提供支持
  1. initRender(vm)⚠️ 关键步骤
    • 初始化与渲染相关的属性和方法
    • 挂载vm._createElement vm._c
    initRender(vm) {
      // 供内部模板编译生成的render函数使用
      vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
    
      // 供用户手写render函数时使用
      vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
    }
    
    // 两者的区别
    // vm._c:会对子节点进行简单的规范化处理,因为模板编译时已经处理过大部分情况
    // vm.$createElement:会进行更全面的规范化处理,确保用户编写的VNode结构正确
    

    ⚠️ 此时安装了_c

4.3.3. 调用beforeCreate钩子

callHook(vm, 'beforeCreate')

此时状态:事件、渲染函数已就绪,但数据响应式尚未建立,即无法访问到data、computed等数据

4.3.4. 初始化inject

initInjections(vm)

解析并建立inject选项,使得当前实例可以注入来自祖先组件提供的数据

4.3.5. 初始化状态

initState(vm) ❗️响应式系统核心

目的: 建立Vue的响应式数据系统

初始化顺序:props => methods => data => computed => watch

取出options选项,按照以上顺序,如果存在,则初始化之

为什么是按这个顺序

确保后初始化的数据能访问先初始化的数据

  1. props - 接收父组件传递的数据(最先,供其他选项使用)
  2. methods - 定义方法,供data中方法调用
  3. data - 整个响应式系统的起点,将数据对象转为响应式,建立getter/setter => observe(data)
  4. computed - 定义计算属性,创建惰性求值的Watcher
  5. watch - 监听器,依赖前面所有数据,为每个处理函数创建watcher => createWatcher

4.3.6. 初始化provide

initProvide(vm)

解析provide选项,为后代组件提供数据

4.3.7. 调用created钩子

callHook(vm, 'created')

此时状态:数据响应式系统已完全建立,实例已准备就绪,但尚未开始DOM的挂载和渲染

4.3.8. 自动挂载判断

if (vm.$options.el) {// 如果设置了el选项,直接挂载
  vm.$mount(vm.$options.el)// 启动挂载流程
}

4.4. 渲染&挂载阶段

4.4.1. $mount的扩展

4.4.1.1. 回到入口文件

🚂src/platforms/web/entry-runtime-with-compiler.js

可以看到

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function () {
  ...
  return mount.call(this, el, hydrating)
}

这里做了什么? => 扩展$mount方法

  1. 缓存原始$mount
  2. 重定定义$mount => 扩展
  3. 调用原始$mount,执行默认挂载

为什么这样设计?

功能分离:编译逻辑与挂载逻辑解耦

向后兼容:确保核心功能不受破坏

灵活配置:运行时版本可以直接使用原始方法

4.4.1.2. 模板处理链与优先级
Vue.prototype.$mount = function () {
  // 1.处理el
  el = el && query(el)

  const options = this.$options

  // 2. 模板处理链: el -> template -> render 
  if (!options.render) {
    let template = options.template
    if (template) {
      // 处理各种template(字符串/DOM元素/...)
    } else if (el) {
      // 没有template,从el获取模版 (🌰 id="app"的div内容)
      template = getOuterHTML(el)
    }

    // 3.如果存在template,则编译它,获取render函数
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        // 编译选项...
      }, this)
      // 4.将结果保存到选项中,供后续使用
      options.render = render
      options.staticRenderFns = staticRenderFns

    }
  }
  // 5.调用原始$mount 执行默认挂载
  return mount.call(this, el, hydrating)
}

优先级说明:

  1. render函数(最高)-> 直接使用
  2. template选项 -> 编译为render函数
  3. el选项(最低)-> 提取对应DOM的HTML并编译

4.4.2. 模板编译过程

4.4.2.1. 编译流程概览

➡️ compileToFunctions 🚂 src/platforms/web/compiler/index.js

const { compile, compileToFunctions } = createCompiler(baseOptions)

➡️ createCompiler 🚂 src/compiler/index.js

4.4.2.2. 核心编译函数
import { createCompilerCreator } from './create-compiler'

export const createCompiler = createCompilerCreator(function baseCompile(
  template: string,     // 要编译的模版字符串
  options: CompilerOptions  // 编译选项,可以配置一些编译时的行为
): CompiledResult {
  // 1.解析  template=>AST
  const ast = parse(template.trim(), options)
  
  // 2.优化  标记静态节点
  if (options.optimize !== false) {
    optimize(ast, options) 
  }
  
  // 3.生成render函数  AST=>render函数
  const code = generate(ast, options)
  
  // 返回一个CompiledResult对象,包含:
  // ast 生成的抽象语法树,可以用于服务端渲染或其他分析
  // render 主渲染函数的代码字符串
  // staticRenderFns 一个数组,包含静态子树的渲染函数代码字符串,结果恒定,缓存
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})
4.4.2.3. 编译结果示例
function render() {
  with(this) {
    return _c('div', 
      { attrs: { "id": "app" } },
      [
        _c('h1', [_v("组件化机制")]),
        _c('p', [_v(_s(msg))]),
        _c('comp')
      ], 
      1 // 规范化类型标识
    )
  }
}

工具函数说明:

  • _c:createElement,创建VNode
  • _v:createTextVNode,创建文本VNode
  • _s:toString,值转换为字符串

4.4.3. 运行时挂载:mountComponent

4.4.3.1. 调用原始$mount方法

🚂 src/platforms/web/runtime/index.js

Vue.prototype.$mount = function (el, hydrating) {
  el = el && inBrowser ? query(el) : undefined
  // 调用核心挂载方法, Vue实例真正开始挂载的地方
  return mountComponent(this, el, hydrating)
}
4.4.3.2. 核心挂载逻辑

🚂 src/core/instance/lifecycle.js

 function mountComponent (vm, el, hydrating) {
  // 1.保存DOM引用
  vm.$el = el

  // 2.正式挂载前,触发beforeMount钩子
  callHook(vm, 'beforeMount')

  // 3.定义组件更新函数 updateComponent(❗️核心)
  const updateComponent = () => {
    // 首先执行_render => vdom
    vm._update(vm._render(), hydrating)
  }

  // 4.创建Watcher实例,触发首次渲染(响应式系统的关键连接点)
  // 对于子组件,它们的mounted钩子会在其自身的vnode被插入到父DOM树时才触发
  new Watcher(vm, updateComponent, noop, ...)
  
  // 5.触发mounted钩子,仅根实例
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }

  // 6.返回实例本身,支持链式调用
  return vm 
}

关于updateComponent的两个关键步骤

  1. vm._render()

调用实例的_render方法,执行定义的render函数或模板编译得到的render函数,返回一个vnode

  1. vm._update(vm._render(), hydrating)

调用实例的_update方法,接收_render产生的vnode,进行patch算法,将vnode转为真实DOM并挂载或更新到页面

4.4.3.3. 挂载执行时序
mountComponent()
    ↓
new Watcher(vm, updateComponent)  // 创建渲染Watcher
    ↓  
Watcher.get() → pushTarget(this)  // 开始依赖收集updateComponent()  // 执行更新函数
    ↓
vm._render()  // 生成VNode树
    ↓
vm._update()  // DOM更新
    ↓
vm.__patch__()  // 创建/更新真实DOMpopTarget()  // 结束依赖收集callHook(vm, 'mounted')  // 挂载完成

4.4.4. 渲染Watcher

4.4.4.1. Watcher的核心职责
  1. 解析表达式:处理计算属性和监听器
  2. 收集依赖:建立视图与数据的关联
  3. 触发回调:数据变化时执行更新
4.4.4.2. 首次渲染的执行流程
4.4.4.2.1. 触发时机

mountComponent时创建Watcher时,Watcher.get()会同步执行,立即触发首次渲染

function mountComponent (vm, el, hydrating) 
  ...
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  ...
}
4.4.4.2.2. 执行流程

🚂 src/core/observer/watcher.js

// 💡 Watcher构造函数中的关键逻辑
constructor(vm, expOrFn, ...){
  this.vm = vm    // 保存组件引用

  // 如果是render watcher,记录到vm上
  if (isRenderWatcher) {
    vm._watcher = this// 便于实例管理和调试
  }
    ...

  // 处理getter 保存更新函数
  if (typeof expOrFn === 'function') {
    // 如果传入的参数2=>expOrFn是函数,则表示它是组件的更新函数
    // 即updateComponent
    this.getter = expOrFn
  }else{
    ...
  }

  this.value = this.get()// 立即执行首次渲染
}

// 💡 依赖收集核心
get() {
  // 设置当前Watcher实例为全局的依赖收集目标
  pushTarget(this)  // 即Dep.target = this(当前Watcher),在后续的属性访问中,相关的Dep就知道要收集哪个Watcher
  let value
  const vm = this.vm
  try{
    // 触发getter,即updateComponent
    value = this.getter.call(vm, vm)    
  } finally {
    popTarget()    // 恢复上一个 Watcher
    this.cleanupDeps() // 清理依赖
  }
  return value
}

4.4.5. 虚拟DOM的创建与处理

4.4.5.1. 虚拟DOM的生成

核心方法: _render() 执行render函数并返回对应的vnode

🚂 src/core/instance/render.js

Vue.prototype._render = function (): VNode {
  // 保存当前Vue实例的引用
  const vm: Component = this  
  // 从实例配置中获取render方法和父组件的vnode(父组件创建的,代表当前组件的占位符vnode)
  const { render, _parentVnode } = vm.$options  

  // 设置父vnode,根实例为null
  vm.$vnode = _parentVnode
  
  // 🔥 核心调用:执行渲染函数,生成当前组件的 VNode 树
  let vnode
  try {
    vnode = render.call(vm._renderProxy, vm.$createElement)   
  } ...
  

  // 设置父级关系,将当前VNode的parent指向_parentVnode,完善vnode树结构
  vnode.parent = _parentVnode 

  // 返回最终生成的虚拟DOM节点
  return vnode  
}

关键参数:

  • vm._renderProxy执行上下文,开发环境有代理检查,生产环境即vm
  • vm.$createElement
    • 创建vnode的函数
    • initRender()时已经挂载到实例上的
4.4.5.2. createElement的设计

设计模式:外层包装+内层实现

🚂 src/core/vdom/create-element.js

  1. 外层:参数标准化和重载处理
export function createElement(context, tag, data, children, ...) {
  // 参数重载:支持多种调用方式
  if (Array.isArray(data) || isPrimitive(data)) {
    // h('div', ['hello']) → h('div', undefined, ['hello'])
    normalizationType = children
    children = data
    data = undefined
  }
  
  // 调用核心实现
  return _createElement(context, tag, data, children, normalizationType)
}

2. 内层:vnode创建核心逻辑

// 真正返回vnode的函数
export function _createElement (context, tag, data, children, normalizationType) {

  // 1. 子节点标准化
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  }

  // 2. 根据tag类型创建对应DOM
  let vnode, ns
  if (typeof tag === 'string') {  
    // 处理字符串标签(HTML 元素、组件、...)
    if (config.isReservedTag(tag)) {
      // 平台内置元素 div/p/...
      vnode = new VNode(config.parsePlatformTagName(tag), data, children,undefined, undefined, context)
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 已注册的组件,创建组件占位符vnode
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 未知标签或命名空间元素
      vnode = new VNode(tag, data, children,undefined, undefined, context)
    }
  } else {
    // 组件选项/构造函数 
    vnode = createComponent(tag, data, context, children)
  }

  return vnode
}
4.4.5.3. createComponent:组件占位符的创建

在执行_createElement的过程中,当识别到一个组件时(已注册的组件名或直接传入的组件选项/构造函数),会调用createComponent创建组件占位符vnode。

❗️注意:createComponent只负责创建一个代表该组件的占位符vnode,而不是立即实例化组件本身。

4.4.5.3.1. 设计思想

延迟渲染&性能优化

  • 懒加载:组件占位符vnode只包含元数据(构造函数/Props/...)。真正的组件实例化、渲染还有挂载被延迟到后续的patch阶段才执行
  • 性能优化: 这种组件级懒渲染方式带了巨大的性能优势,🌰 对于v-if="false"的组件,Vue在首次渲染的时候只需创建一个轻量的占位符vnode,完全跳过内部复杂的实例化、渲染和DOM操作流程
  • 支持异步组件
4.4.5.3.2. 主要逻辑

🚂 src/core/vdom/create-component.js

export function createComponent(Ctor, data, context, children, tag) {

  // 确保Ctor是构造函数
  const baseCtor = context.$options._base // 获取Vue构造函数
  if (isObject(Ctor)) {
    // 如果传入的是组件选项对象,则转换为构造函数
    Ctor = baseCtor.extend(Ctor)
  }

  ...

  // 将组件的生命周期逻辑(init, prepatch等)以钩子函数的形式挂载到 vnode.data.hook上
  // 在后续的 patch过程中,当处理到这个组件 VNode 时,会调用相应的钩子(如 init)来真正创建组件实例
  installComponentHooks(data)
  
  // 创建组件占位符vnode
  const vnode = new VNode(...)
  
  return vnode
}
4.4.5.3.3. 组件占位符vnode结构
{
  // 基本 VNode 属性
  tag: 'vue-component-1-comp', // Vue 内部生成的唯一标识符
  data: {
    hook: { // 🔥 核心!存放组件生命周期的钩子函数,将在 patch 阶段被触发
      init: function(vnode) { ... },    // 初始化组件实例
      prepatch: function(oldVnode, vnode) { ... }, // 更新前
      insert: function(vnode) { ... }, // 插入 DOM 后
      destroy: function(vnode) { ... }  // 销毁时
    }
  },
  children: undefined, // ❗ 注意:子节点(元素内容)不在这里
  elm: null, // 对应的真实 DOM 元素(此时尚未创建)

  // 组件特有属性
  componentOptions: { // 🔥 组件的“配置包”,包含了实例化所需的一切信息
    Ctor: function VueComponent(options) { ... }, // 组件构造函数
    propsData: { msg: 'hello' }, // 从 data 中提取出的 props 值
    listeners: { 'custom-event': handler }, // 父组件监听的事件
    tag: 'comp', // 原始标签名
    children: [/* 插槽内容的 VNode 数组 */] // ❗ 子节点(插槽内容)在这里
  },
  componentInstance: undefined // ❗ 关键:此时组件实例还不存在!
}

关键点说明:

  1. componentOptions对象: 一个信息聚合包,包含了创建和配置组件实例所需要的全部数据,方便在patch阶段一次性使用
  2. children的存放位置: 对于组件vnode,其模板内的子内容(即默认插槽内容)不是作为vnode的直接子节点(vnode.children),而是放在vnode.componentOptions.children
4.4.5.4. vnode的创建顺序

深度优先

执行render函数时的调用栈推进过程:

调用栈深度:
anonymous() 开始
  ↓
准备执行 _c('div', ...)
  ↓ 需要先计算子节点数组
计算第一个数组元素:_c('h1', ...)
  ↓ 需要先计算h1的子节点
计算 _v("组件化机制")  ← 最先执行完成!
  ↓
_c('h1', ...) 执行完成
  ↓
计算第二个数组元素:_c('comp')
  ↓
_c('comp') 执行完成  
  ↓
现在所有子节点就绪,执行 _c('div', ...)
  ↓
anonymous() 返回结果
4.4.5.5. 生成的vnode的大致结构:
{
  tag: 'div',
  data: { attrs: { id: 'app' } },
  children: [
    {
      tag: 'h1',
      children: [
        { tag: undefined, text: '组件化机制', isComment: false }
      ],
      elm: undefined,
      context: vm
    },
    { tag: undefined, text: ' ', isComment: false },
    {
      tag: 'p',
      children: [
        { tag: undefined, text: "hello vue", isComment: false }
      ],
      elm: undefined,
      context: vm
    },
    { tag: undefined, text: ' ', isComment: false },
    {
      tag: 'vue-component-1-comp',
      componentOptions: {
        Ctor: CompConstructor,
        propsData: undefined,
        tag: 'comp'
      },
      data: {
        hook: { /* 组件生命周期钩子 */ }
      },
      elm: undefined,
      context: vm
    }
  ],
  elm: undefined,
  context: vm,
  parent: undefined
}

4.4.6. Patch过程:从虚拟DOM到真实DOM

4.4.6.1. Patch的触发

_update中,会根据是否首次渲染,选择不同的patch策略

🚂 src/core/instance/lifecycle.js

Vue.prototype._update = function (vnode, hydrating) {
  const vm = this
  const prevVnode = vm._vnode// 之前渲染的vnode
  
  if (!prevVnode) {
    // 首次渲染
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)
  } else {
    // 更新渲染(diff算法)
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}
4.4.6.2. Patch函数的创建

🚂 src/platforms/web/runtime/patch.js

// 工厂模式,创建平台专用的 patch 函数
export const patch: Function = createPatchFunction({ nodeOps, modules })

createPatchFunction一个工厂函数

  • 输入:平台特有的节点操作和属性操作
    • nodeOps:封装了平台对应的各种原生DOM的基础操作方法
    • modules:处理属性、样式、事件等平台相关逻辑
  • 输出:一个平台专有的patch函数并返回

💡 核心算法复用,平台特性隔离

4.4.6.3. Patch核心流程

🚂 src/core/vdom/patch.js

主要处理三个场景:

  1. 新节点不存在 => 销毁旧节点(组件被条件渲染移除 v-if="false")

    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
    
  2. 旧节点不存在 => 全新挂载(动态组件首次渲染/服务端渲染激活)

    if (isUndef(oldVnode)) {
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)  // 直接创建新DOM树
    }
    
  3. 新旧都存在 => 精细化更新

    在首次渲染根节点时,走的就是这里

    // 检查是否是真实DOM元素(首次挂载时 oldVnode 是真实的 div#app)
    const isRealElement = isDef(oldVnode.nodeType)
    
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 可复用的虚拟节点 → 精细化diff
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 真实DOM或不可复用 → 替换操作
      if (isRealElement) {// 根组件挂载
        // 首次挂载,标准化为vnode
        oldVnode = emptyNodeAt(oldVnode)
      }
    
      const oldElm = oldVnode.elm   // 获取宿主元素
      const parentElm = nodeOps.parentNode(oldElm)  // 获取宿主元素父元素 🌰body
    
    
      // 关键:创建新DOM树,将它追加到parentElm里面,oldElm旁边
      createElm(
        vnode,                    // 新虚拟节点
        insertedVnodeQueue,       // 插入回调队列
        parentElm,                // 父容器(body)
        nodeOps.nextSibling(oldElm) // 参考位置:旧节点的下一个兄弟节点前
      )
    
      // 销毁旧节点
      if (isDef(parentElm)) {
        removeVnodes(parentElm, [oldVnode], 0, 0)
      }
    }
    
4.4.6.4. createElm() :DOM创建的核心

从虚拟DOM到真实DOM的转换器

4.4.6.4.1. 核心逻辑
function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested) {
  // 优先尝试作为组件创建
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return// 如果是组件,创建成功直接返回,不是组件则继续普通元素创建流程
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag

  // 按元素类型分派处理
  if (isDef(tag)) {
    // 1. 元素节点:创建对应HTML标签
    vnode.elm = vnode.ns 
      ? nodeOps.createElementNS(vnode.ns, tag)  // 命名空间元素
      : nodeOps.createElement(tag, vnode)       // 普通元素
    
    // 递归创建子节点(深度优先)
    createChildren(vnode, children, insertedVnodeQueue)
    
    // 处理属性、样式、事件等
    if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    
    // 插入到DOM
    insert(parentElm, vnode.elm, refElm)
    
  } else if (isTrue(vnode.isComment)) {
    // 2. 注释节点
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
    
  } else {
    // 3. 文本节点
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}
4.4.6.4.2. createChildren

递归创建子节点,深度优先遍历

function createChildren(vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; ++i) {
      // 对每个子节点递归调用 createElm
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true)
    }
  } else if (isPrimitive(vnode.text)) {
    // 文本内容直接创建文本节点
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}
4.4.6.4.3. 当前示例的执行顺序
createElm(div#app)
    ↓
createChildren → 遍历5个子节点
    ↓
createElm(h1) → 创建h1元素 → createChildren → 创建文本节点"组件化机制"
    ↓
createElm(文本节点 "\n    ")
    ↓
createElm(p) → 创建p元素 → createChildren → 创建动态文本节点
    ↓
createElm(文本节点 "\n    ")  
    ↓
createElm(comp组件) → 触发组件创建逻辑
4.4.6.5. createComponent()组件创建的特殊处理

createElm中,会调用createComponent,将vnode优先尝试作为组件创建

function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    // 检查是否有init钩子(组件标识,只有组件节点的data属性中会有hook属性)
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      // 执行组件初始化钩子
      i(vnode, false /* hydrating */)
    }
    
    // 如果组件实例创建成功 => 调用组件的init钩子后,vnode中才会有组件实例,所以可以用vnode.componentInstance来判断组件实例是否创建成功
    if (isDef(vnode.componentInstance)) {
      // 初始化组件DOM
      initComponent(vnode, insertedVnodeQueue)
      // 插入到父容器
      insert(parentElm, vnode.elm, refElm)
      return true  // 返回true,表示已作为组件处理
    }
  }
  return false
}
4.4.6.5.1. 组件init钩子的作用

🚂 src/core/vdom/create-component.js

init(vnode, hydrating) {
  if (vnode.componentInstance && vnode.data.keepAlive) {
    // keep-alive组件复用逻辑
  } else {
    // 创建组件实例
    const child = vnode.componentInstance = createComponentInstanceForVnode(
      vnode,
      activeInstance  // 当前激活的组件实例
    )
    
    // 子组件递归挂载(重要!)
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  }
}
4.4.6.5.2. 组件从render到patch的完整过程
1. 渲染阶段 (_render)
  父组件 _render()
      ↓
  遇到 <comp></comp>,调用 _createElement('comp', ...)
      ↓
  识别为组件,调用 createComponent(...)
      ↓
  返回一个轻量的【组件占位符 VNode】(包含 Ctor, props, hooks 等元数据)
      ↓
  父组件 VNode 树生成完毕

2. 补丁阶段 (patch)
  patch 过程遍历到组件占位符 VNode
      ↓
  调用 vnode.data.hook.init(vnode) 钩子
      ↓
  在 init 钩子中:const child = vnode.componentInstance = new Ctor(...)
      ↓
  子组件开始自己的生命周期:child.$mount() -> _init() -> _render() -> patch()
      ↓
  此时才真正创建子组件的实例和 DOM 树
4.4.6.6. Diff算法核心
4.4.6.6.1. patchVnode() 中的Diff入口

在Patch的过程中,当判断到新旧vnode可复用时,会调用patchVnode()方法,进行精细化更新

核心逻辑: 首先进行树级别比较

三种情况:

  1. 新vnode不存在 => 删
  2. 旧vnode不存在 => 增
  3. 都存在 => diff

具体规则:

  1. 新老节点均有children,则对子节点diff,调用updateChildern
  2. 新节点有children而老节点没有,先清空老节点文本,再新增子节点
  3. 新节点没有children而老节点有,移除该节点的所有子节点
  4. 新老节点都没有children,文本替换
// 获取双方子元素
const oldCh = oldVnode.children
const ch = vnode.children

if (isUndef(vnode.text)) { 
  // 非文本节点
  if (isDef(oldCh) && isDef(ch)) {  
    // 新旧节点都有子节点
    // 触发updateChildren进行对比
    if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  } else if (isDef(ch)) { 
    // 只有新节点有子节点
    // 如果旧节点有文本则清空
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
    // 添加新节点
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  } else if (isDef(oldCh)) {  
    // 只有旧节点有子节点,直接移除
    removeVnodes(oldCh, 0, oldCh.length - 1) 
  } else if (isDef(oldVnode.text)) {  
    // 旧节点有文本内容,直接清空
    nodeOps.setTextContent(elm, '')
  }
} else if (oldVnode.text !== vnode.text) {
  // 文本节点,直接更新文本
  nodeOps.setTextContent(elm, vnode.text)
}

if (isDef(data)) {
  // 触发postpatch钩子
  if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
4.4.6.6.2. updateChildren() :Diff算法的核心实现
4.4.6.6.2.1. 核心逻辑:

双指针&同层比较

新老两组VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢,oldStartIdx > oldEndIdxnewStartIdx > newEndIdx时结束循环

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  // 初始化指针
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  // 双端比较循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      // 旧开始节点为空,跳过
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isUndef(oldEndVnode)) {
      // 旧结束节点为空,跳过
      oldEndVnode = oldCh[--oldEndIdx]
    } 
    // 1. 头头相同 - 直接 patch
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } 
    // 2. 尾尾相同 - 直接 patch
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } 
    // 3. 头尾相同 - patch 后移动到末尾
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      // 将节点移动到旧列表末尾之后
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } 
    // 4. 尾头相同 - patch 后移动到开头
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      // 将节点移动到旧列表开头之前
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } 
    // 5. 四种情况都不匹配 - 使用 key 映射查找
    else {
      // 创建旧节点的 key 到 index 的映射
      if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      
      // 根据新节点的 key 查找在旧列表中的位置
      idxInOld = isDef(newStartVnode.key) 
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      
      if (isUndef(idxInOld)) {
        // 新节点 - 创建并插入
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
      } else {
        // 找到可复用的节点
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined // 标记为已处理
          // 移动到当前位置
          nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 相同 key 但不同元素 - 创建新节点
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }

  // 处理剩余节点
  if (oldStartIdx > oldEndIdx) {
    // 旧节点遍历完 - 添加剩余的新节点
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    // 新节点遍历完 - 移除剩余的旧节点
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}
4.4.6.6.2.2. Diff算法的四种比较策略
  1. 头头

  1. 尾尾

  1. 头尾(节点移动)

  1. 尾头(节点移动)

4.4.6.6.2.3. key的重要性

有key的情况:

// 旧列表
[{key: 'a', text: 'A'}, {key: 'b', text: 'B'}, {key: 'c', text: 'C'}]

// 新列表  
[{key: 'c', text: 'C'}, {key: 'a', text: 'A'}, {key: 'b', text: 'B'}]

// Diff 过程:通过 key 快速定位,只需移动节点,无需重新创建

无key的情况:

// 旧列表
[{text: 'A'}, {text: 'B'}, {text: 'C'}]

// 新列表
[{text: 'C'}, {text: 'A'}, {text: 'B'}]  

// Diff 过程:只能按位置比较,误判为不同节点,导致不必要的重新创建

key是Vue中用于优化diff算法的特殊属性,核心作用是为vnode提供一个唯一标识,帮助Vue更高效地识别哪些节点可以复用,从而最小化DOM操作,提升性能。

从源码层面来看:

首先key是用来判断是否相同节点的第一条件,只有当两个vnode的key和tag都相同时,vue才会认为它们是可复用节点,继而进行精细化的属性更新,否则,Vue会直接销毁并重新创建节点。

其次,在diff算法的核心,updateChildren函数中,当前面的头头、尾尾、头尾、尾头,四种快捷对比都失败后,Vue会为旧的节点数组创建一个key到index的映射表,通过这个映射表,Vue可以直接用新节点的key快速判断是否有可复用节点,然后通过移动DOM来完成更新。没有key的情况下,Vue只能按索引顺序对比。这在对列表进行排序、过滤等非末尾增删操作时,会误判为是节点内容发生了更改,而非节点位置发生了移动。结果就是导致大量不必要的DOM操作和组件重新渲染,而非高效的移动。

因此,在实际开发中,尤其是包含状态的组件列表或复杂DOM结构的v-for循环中,必须为每一项提供一个唯一且稳定的key(通常为id)。

5. 关键流程回顾

new Vue() → _init() → $mount() → mountComponent() → new Watcher()
    ↓
Watcher.get() → updateComponent() → _render() → createElement()
    ↓
生成 VNode 树 → _update() → patch() → createElm()
    ↓
递归处理组件 → 创建真实 DOM → 触发 mounted 钩子

JavaScript 异步编程深度解析(上):单线程、事件循环与异步的本质

引言:为什么 JavaScript 需要异步?

当你在浏览器中点击一个按钮,页面立即响应;当你滚动网页,内容流畅滑动;当你发起一个网络请求,界面却不会卡死——这一切的背后,都离不开 JavaScript 的异步机制。然而,初学者常常困惑于这样一个事实:JavaScript 是单线程语言,却能同时处理多个任务。这看似矛盾的现象,正是理解现代 Web 开发的关键。

本文将从 JavaScript 的单线程本质出发,深入剖析异步代码的执行逻辑,并为下篇介绍 Promise 奠定基础。


一、JavaScript 的单线程模型:简单即是强大

1.1 什么是单线程?

“线程”是操作系统执行代码的最小单位。大多数编程语言(如 Java、C++)支持多线程,可以并行执行多个任务。但 JavaScript 从诞生之初就选择了单线程模型——整个程序只有一个主线程负责执行所有代码。

console.log(1);
setTimeout(() => {
    console.log(2);
}, 3000);
console.log(3);

这段代码的输出顺序是:1 → 3 → 2
为什么不是 1 → 2 → 3?因为 setTimeout异步操作,而 console.log同步操作

1.2 为何选择单线程?

JavaScript 最初设计用于操作 DOM(文档对象模型)。如果允许多线程,就可能出现两个线程同时修改同一个 DOM 节点的情况,导致不可预测的冲突。为了避免这种复杂性,JavaScript 采用单线程,确保任何时刻只有一个任务在执行

此外,单线程也让语言更简单、易学,特别适合前端开发场景。


二、同步 vs 异步:执行顺序的奥秘

2.1 同步代码:按顺序执行

同步代码严格按照书写顺序执行,每行代码必须等待前一行完成才能运行:

console.log(1); // 立即执行
console.log(3); // 紧接着执行

这类操作通常耗时极短(毫秒级),包括变量声明、数学计算、DOM 查询等。

2.2 异步代码:延迟执行

异步操作则不同——它们不会阻塞主线程。常见的异步任务包括:

  • 定时器(setTimeout, setInterval
  • 网络请求(fetch, XMLHttpRequest
  • 文件读写(Node.js 中的 fs.readFile
  • 用户事件(点击、滚动)

当 JS 引擎遇到异步代码时,它会:

  1. 立即注册该任务
  2. 将其放入“任务队列”(Task Queue)
  3. 继续执行后续同步代码
  4. 等所有同步代码执行完毕后,再从队列中取出异步任务执行

这就是为什么 setTimeout 中的 console.log(2) 总是在最后打印。


三、事件循环(Event Loop):异步的调度中枢

虽然 JavaScript 是单线程,但它通过事件循环机制实现了“伪并行”。

3.1 执行栈与任务队列

  • 调用栈(Call Stack) :存放当前正在执行的函数。
  • 任务队列(Task Queue) :存放待执行的异步回调。

执行流程如下:

  1. 同步代码压入调用栈,逐行执行。
  2. 遇到 setTimeout,将其回调函数放入任务队列。
  3. 同步代码执行完毕,调用栈清空。
  4. 事件循环检查任务队列,将回调压入调用栈执行。

注意:即使 setTimeout 的延迟时间为 0,其回调也不会立即执行,必须等同步代码全部完成。

3.2 实际案例分析

<script>
console.log(1);
setTimeout(() => console.log(2), 0);
console.log(3);
</script>

输出:1 → 3 → 2
尽管延迟为 0,console.log(2) 仍被推迟到同步代码之后。


四、异步带来的问题:控制流混乱

虽然异步机制避免了页面卡死,但也带来了新的挑战——代码执行顺序与编写顺序不一致

考虑以下场景:

  1. 先打印 “开始”
  2. 读取一个大文件(耗时 2 秒)
  3. 打印 “结束”

若用传统异步写法:

console.log("开始");
fs.readFile('a.txt', 'utf-8', (err, data) => {
    console.log(data);
});
console.log("结束");

输出将是:

开始
结束
[文件内容]

“结束”在文件读取完成前就打印了!这显然不符合业务逻辑。我们真正想要的是:等待异步任务完成后再执行后续操作

这就是“如何将异步变成同步”的核心问题。


五、迈向解决方案:Promise 的诞生背景

在 ES6 之前,开发者只能通过**回调函数(Callback)**处理异步:

fs.readFile('a.txt', 'utf-8', (err, data) => {
    if (!err) {
        fs.readFile('b.txt', 'utf-8', (err2, data2) => {
            // 回调地狱(Callback Hell)
        });
    }
});

这种方式容易导致代码嵌套过深、难以维护。

为了解决这一问题,ES6 引入了 Promise——一种更优雅的异步流程控制工具。它允许我们将异步操作“包装”起来,并通过 .then() 链式调用,实现看似同步的代码结构

“Promise 是 ES6 提供的异步变同步的高级工具类。”


结语:理解异步,是掌握 JavaScript 的第一步

JavaScript 的单线程与异步机制,既是它的限制,也是它的优势。通过事件循环,它在保持简单性的同时,实现了高效的非阻塞 I/O。

然而,异步也带来了控制流的复杂性。如何让代码“按我们想要的顺序执行”?答案就在 Promise 中。

在下篇《JavaScript 异步编程深度解析(下):Promise 与现代异步实践》中,我们将深入探讨 Promise 的工作原理、链式调用、错误处理,并结合 fetch 和文件读取等真实案例,展示如何用 Promise 写出清晰、可靠的异步代码。

❌