普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月2日技术

⏰前端周刊第 451 期(2026年1月25日-1月31日)

2026年2月2日 14:06

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

banner-raw.png


💬 推荐语

本期主题偏向“平台新能力落地 + 工程工具链升级”。Web 开发部分重点关注 HTML Invoker Commands 在主流浏览器达成 baseline 支持,以及 Chrome Canary 的文本缩放试验;工具链方面则有 Yarn 6 预览、Rolldown 1.0 RC 与面向“前端考古”的 ReliCSS。无障碍栏目从 AI 驱动的诉讼风险谈到如何更主动地把可访问性做进流程,并补上一条关于原生 dialog 是否需要“强制焦点陷阱”的实践纠偏。最后在 WebGPU 与图形方向,既有流体模拟与文字溶解特效的完整拆解,也有 mrdoob 用 Three.js 复刻 1996 年《Quake》的硬核项目。CSS 侧补齐 Reset、层叠上下文、纯 CSS 手风琴、::search-text 等新伪元素与断点设计思路;JavaScript/TypeScript 则围绕 2026 框架生态趋势、TanStack Start 的并发更新策略与 async/await 的工程化写法。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

♿️ 无障碍访问

✨ 演示/特效

🎨 CSS

💡 JavaScript

🧷 TypeScript

当前前端领域的新能力和工具链的升级,带来了更简化的开发流程和更高效的工程实践。例如,HTML Invoker Commands 在浏览器中的 baseline 支持减少了样板 JS 代码,Yarn 6 的预览版则进一步提升了工作流兼容性。然而,快速落地时,如何在复杂项目中高效整合这些新技术仍然是团队面临的一大挑战,尤其是在跨平台与多工具链协调时。借助 RollCode 低代码平台私有化部署自定义组件静态页面发布(SSG + SEO),可以帮助开发者更轻松地管理和落地这些工程化工具。

前端向架构突围系列 - 编译原理 [6 - 4]:模板编译与JSX 转换的编译艺术

2026年2月2日 13:49

写在前面

很多开发者认为前端框架是纯粹的“运行时(Runtime)”库。 其实不然。现代前端框架的竞争,早已从运行时卷到了编译时(Compile-time)

  • Vue 的模板看起来像 HTML,但浏览器根本不认识 v-for。它是通过编译器把模板变成了高效的 JavaScript 渲染函数。
  • React 的 JSX 看起来像 XML,但它其实是 React.createElement 的语法糖。而最新的 React Compiler 更是试图通过编译手段自动解决性能问题。

作为架构师,理解这套编译逻辑,你才能明白为什么 Vue 3 比 Vue 2 快,也能理解 React 团队为什么要搞个编译器。

unnamed (1).jpg


一、 Vue 的编译哲学:静态分析的艺术

Vue 的核心设计哲学是 “显式优于隐式” 的模板语法。 正因为模板的结构是固定的(不像 JSX 那样可以是任意 JS 逻辑),Vue 的编译器可以在编译阶段就知道哪些节点是静态的(永远不变),哪些是动态的(可能变)。

这是一场关于 AST 的情报战

1.1 编译流水线

Vue 的编译过程包含三个核心步骤:

  1. Parse (解析):<template> 字符串解析成 Vue AST(不是 JS AST,是描述 HTML 结构的树)。
  2. Transform (转换): 遍历 Vue AST,应用各种指令转换(如 v-if, v-model)和编译时优化
  3. Generate (生成): 把优化后的 Vue AST 生成为 JavaScript 代码(即 render 函数)。

1.2 魔法的核心:PatchFlags 与 Block Tree

Vue 3 性能起飞的秘密就在 Transform 阶段。

看看这段代码:

<div>
  <span>我是静态的</span>
  <span>{{ msg }}</span>
</div>

Vue 2 的做法: 每次更新,都要对比整个 DOM 树,即使第一个 <span> 根本不可能变。 Vue 3 的做法(编译后): 编译器在 AST 上给第二个 <span> 打了个标记(PatchFlag)。

// 伪代码:Vue 3 编译后的 render 函数
export function render(_ctx) {
  return (
    openBlock(),
    createBlock('div', null, [
      createVNode('span', null, '我是静态的'), // 静态节点
      createVNode('span', null, _ctx.msg, 1 /* TEXT */) // 动态节点,标记为 1
    ])
  )
}

架构洞察: 运行时看到这个 1,就知道:“我只需要对比这个节点的文本内容,其他的属性、类名、子节点都不用管。” 这就是 Compile-time Optimization(编译时优化) 赋能 Runtime Performance(运行时性能) 的典范。


二、 React 的编译哲学:JSX 的极简与自由

React 选择了另一条路:All in JavaScript。 JSX 不是模板,它就是 JS 表达式。这意味着 React 拥有极高的灵活性,但也付出了代价——编译器很难通过静态分析来优化它

2.1 JSX 的本质:Babel 插件

React 的编译过程相对简单,通常不需要自己写 Parser,而是借助于 Babel@babel/preset-react 会把 JSX 语法转化为普通的 JS 函数调用。

源代码:

const element = <div className="foo">Hello</div>;

编译后 (React 17+ Automatic Runtime):

import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx("div", { className: "foo", children: "Hello" });

2.2 自由的代价

因为 JSX 太灵活了(你可以在 if 里写 return <div />,也可以用 map 生成组件),编译器很难像 Vue 那样预判“这块 DOM 永远不会变”。 因此,React 长期依赖运行时的 Diff 算法(Fiber 架构)来解决性能问题,或者强迫开发者手动写 useMemouseCallback


三、 变局:React Compiler (React Forget)

React 团队意识到,手动优化(useMemo)太反人类了。于是,他们在 2024 年推出了 React Compiler

这标志着 React 也开始向“重编译”方向转型。

3.1 它的工作原理

React Compiler 也是一个 Babel 插件。它通过 AST控制流图 (Control Flow Graph, CFG) 分析你的代码,自动计算依赖关系。

源代码:

function Component({ heading, body }) {
  return <div>
    <h1>{heading}</h1>
    <p>{body}</p>
  </div>;
}

编译后(概念版): 编译器发现 headingbody 没变时,整个 JSX 都不需要重新创建。它自动帮你把组件内部的代码用类似 useMemo 的逻辑包裹起来,但粒度更细,细到具体的表达式。

架构意义: 这填补了 React 相比于 Vue/Solid 在细粒度更新上的短板,完全由编译器代劳,开发者无需感知。


四、 跨框架的共识:编译时的崛起

从 Vue 的 PatchFlags,到 React Compiler,再到 Svelte(干掉 Virtual DOM)和 SolidJS(预编译 DOM 模板),前端框架的演进趋势非常清晰:

把运行时的负担,转移到编译时去。

4.1 为什么?

  1. 用户体验: 编译时慢一点(开发者构建慢),换来的是用户运行时快很多。
  2. 代码体积: 编译器可以分析出没用到的特性(Tree Shaking),打包出来的代码更小。

4.2 架构师的视角

当你选型框架时,不要只看语法(JSX vs Template),要看它的编译策略

  • 如果你的项目是重交互、高性能仪表盘,Vue 3 或 Solid 这种基于静态分析优化的框架可能更有优势。
  • 如果你的项目逻辑极其复杂、动态性极强(低代码平台),React 的灵活性依然是王者。

结语:掌握魔法的钥匙

至此,《编译流程》 圆满结束。

我们从最底层的 AST 原理(第一篇),进阶到 Babel 插件实战(第二篇),掌握了 ESLint 与 Codemod 的治理能力(第三篇),最后看透了 现代框架 的编译魔法。

现在,代码在你眼中不再是黑盒。你看到的不是字符,而是,是,是可被重塑的逻辑

自动驾驶标注数据分片上传

作者 yiranlater
2026年2月2日 11:50

自动驾驶标注平台:标注结果分片上传指南

一、标注结果特点

自动驾驶标注结果通常包含:

  • 3D点云标注:.pcd + JSON标注框
  • 图像标注:图片 + 2D框/分割mask
  • 时序标注:多帧关联的轨迹数据
  • 元数据:标注者信息、审核状态等

文件特点:JSON文件较大(几MB到几十MB),需要可靠上传

二、简化实现方案

1. 标注结果上传组件

// AnnotationUploader.js
class AnnotationUploader {
  constructor() {
    this.chunkSize = 2 * 1024 * 1024; // 2MB每片
  }

  // 核心方法:上传标注结果
  async uploadAnnotation(annotationData, taskId) {
    // 1. 将标注数据转为Blob
    const blob = new Blob(
      [JSON.stringify(annotationData)], 
      { type: 'application/json' }
    );
    
    // 2. 生成唯一标识
    const fileId = `annotation_${taskId}_${Date.now()}`;
    
    // 3. 分片上传
    return await this.uploadWithChunks(blob, fileId);
  }

  // 分片上传逻辑
  async uploadWithChunks(blob, fileId) {
    const totalChunks = Math.ceil(blob.size / this.chunkSize);
    
    // 检查已上传的分片(断点续传)
    const uploaded = await this.getUploadedChunks(fileId);
    
    for (let i = 0; i < totalChunks; i++) {
      if (uploaded.includes(i)) {
        console.log(`分片 ${i} 已上传,跳过`);
        continue;
      }

      // 切片
      const start = i * this.chunkSize;
      const end = Math.min(start + this.chunkSize, blob.size);
      const chunk = blob.slice(start, end);

      // 上传分片
      await this.uploadChunk(chunk, {
        fileId,
        chunkIndex: i,
        totalChunks
      });

      // 更新进度
      this.onProgress?.(i + 1, totalChunks);
    }

    // 通知服务器合并
    return await this.mergeFile(fileId, totalChunks);
  }

  // 上传单个分片
  async uploadChunk(chunk, meta) {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('fileId', meta.fileId);
    formData.append('chunkIndex', meta.chunkIndex);
    formData.append('totalChunks', meta.totalChunks);

    const response = await fetch('/api/annotation/upload-chunk', {
      method: 'POST',
      body: formData,
      headers: {
        'Authorization': `Bearer ${this.getToken()}`
      }
    });

    if (!response.ok) {
      throw new Error(`分片${meta.chunkIndex}上传失败`);
    }

    return response.json();
  }

  // 查询已上传的分片(断点续传关键)
  async getUploadedChunks(fileId) {
    try {
      const response = await fetch(
        `/api/annotation/upload-status?fileId=${fileId}`,
        {
          headers: {
            'Authorization': `Bearer ${this.getToken()}`
          }
        }
      );
      const data = await response.json();
      return data.uploadedChunks || [];
    } catch (error) {
      return []; // 首次上传
    }
  }

  // 合并文件
  async mergeFile(fileId, totalChunks) {
    const response = await fetch('/api/annotation/merge', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.getToken()}`
      },
      body: JSON.stringify({ fileId, totalChunks })
    });

    return response.json();
  }

  getToken() {
    return localStorage.getItem('token');
  }
}

2. 在标注编辑器中使用

// AnnotationEditor.vue
export default {
  data() {
    return {
      annotations: {
        taskId: '12345',
        frames: [
          {
            frameId: 0,
            objects: [
              {
                id: 'obj_1',
                type: 'car',
                position: { x: 10, y: 20, z: 0 },
                rotation: { x: 0, y: 0, z: 1.57 },
                size: { width: 4.5, length: 2, height: 1.8 }
              }
            ]
          }
        ],
        metadata: {
          annotator: 'user_001',
          timestamp: Date.now()
        }
      },
      uploadProgress: 0,
      isUploading: false
    }
  },

  methods: {
    // 保存标注结果
    async saveAnnotations() {
      this.isUploading = true;
      
      const uploader = new AnnotationUploader();
      
      // 设置进度回调
      uploader.onProgress = (current, total) => {
        this.uploadProgress = Math.round((current / total) * 100);
      };

      try {
        const result = await uploader.uploadAnnotation(
          this.annotations,
          this.annotations.taskId
        );
        
        this.$message.success('标注结果保存成功');
        console.log('文件路径:', result.filePath);
        
      } catch (error) {
        this.$message.error('保存失败: ' + error.message);
        // 可以重试
      } finally {
        this.isUploading = false;
      }
    },

    // 自动保存(每5分钟)
    setupAutoSave() {
      setInterval(() => {
        if (!this.isUploading) {
          this.saveAnnotations();
        }
      }, 5 * 60 * 1000);
    }
  },

  mounted() {
    this.setupAutoSave();
  }
}

3. 后端接口(Python Flask示例)

from flask import Flask, request, jsonify
import os
import json

app = Flask(__name__)
TEMP_DIR = './temp_chunks'
UPLOAD_DIR = './annotations'

# 接收分片
@app.route('/api/annotation/upload-chunk', methods=['POST'])
def upload_chunk():
    chunk = request.files['chunk']
    file_id = request.form['fileId']
    chunk_index = request.form['chunkIndex']
    
    # 创建临时目录
    chunk_dir = os.path.join(TEMP_DIR, file_id)
    os.makedirs(chunk_dir, exist_ok=True)
    
    # 保存分片
    chunk_path = os.path.join(chunk_dir, f'chunk_{chunk_index}')
    chunk.save(chunk_path)
    
    return jsonify({'success': True, 'chunkIndex': chunk_index})

# 查询上传状态
@app.route('/api/annotation/upload-status', methods=['GET'])
def upload_status():
    file_id = request.args.get('fileId')
    chunk_dir = os.path.join(TEMP_DIR, file_id)
    
    if not os.path.exists(chunk_dir):
        return jsonify({'uploadedChunks': []})
    
    # 获取已上传的分片
    chunks = [
        int(f.split('_')[1]) 
        for f in os.listdir(chunk_dir) 
        if f.startswith('chunk_')
    ]
    
    return jsonify({'uploadedChunks': sorted(chunks)})

# 合并文件
@app.route('/api/annotation/merge', methods=['POST'])
def merge_file():
    data = request.json
    file_id = data['fileId']
    total_chunks = data['totalChunks']
    
    chunk_dir = os.path.join(TEMP_DIR, file_id)
    output_path = os.path.join(UPLOAD_DIR, f'{file_id}.json')
    
    # 合并分片
    with open(output_path, 'wb') as output_file:
        for i in range(total_chunks):
            chunk_path = os.path.join(chunk_dir, f'chunk_{i}')
            with open(chunk_path, 'rb') as chunk_file:
                output_file.write(chunk_file.read())
    
    # 清理临时文件
    import shutil
    shutil.rmtree(chunk_dir)
    
    # 解析并保存到数据库
    with open(output_path, 'r') as f:
        annotation_data = json.load(f)
        save_to_database(annotation_data)  # 保存到数据库
    
    return jsonify({
        'success': True,
        'filePath': output_path,
        'taskId': annotation_data.get('taskId')
    })

def save_to_database(annotation_data):
    # 保存到数据库的逻辑
    pass

三、UI组件示例

<template>
  <div class="annotation-save">
    <!-- 保存按钮 -->
    <el-button 
      type="primary" 
      @click="saveAnnotations"
      :loading="isUploading"
    >
      {{ isUploading ? '保存中...' : '保存标注' }}
    </el-button>

    <!-- 进度条 -->
    <el-progress 
      v-if="isUploading"
      :percentage="uploadProgress"
      :status="uploadProgress === 100 ? 'success' : ''"
    />

    <!-- 断点续传提示 -->
    <el-alert
      v-if="hasUnfinishedUpload"
      title="检测到未完成的上传"
      type="warning"
      :closable="false"
    >
      <el-button size="small" @click="resumeUpload">
        继续上传
      </el-button>
    </el-alert>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isUploading: false,
      uploadProgress: 0,
      hasUnfinishedUpload: false
    }
  },

  async mounted() {
    // 检查是否有未完成的上传
    this.checkUnfinishedUpload();
  },

  methods: {
    async checkUnfinishedUpload() {
      const fileId = `annotation_${this.taskId}_*`;
      // 检查localStorage或服务器
      // ...
    },

    async resumeUpload() {
      // 继续之前的上传
      await this.saveAnnotations();
    }
  }
}
</script>

四、关键优化点

1. 压缩标注数据

// 上传前压缩
import pako from 'pako';

const compressed = pako.gzip(JSON.stringify(annotationData));
const blob = new Blob([compressed], { type: 'application/gzip' });

2. 增量保存

// 只保存变更的帧
const changedFrames = this.annotations.frames.filter(f => f.modified);
await uploader.uploadAnnotation({ 
  taskId: this.taskId,
  frames: changedFrames,
  isIncremental: true 
});

3. 离线缓存

// 网络断开时保存到IndexedDB
if (!navigator.onLine) {
  await saveToIndexedDB(this.annotations);
  this.$message.info('已离线保存,联网后自动上传');
}

五、完整流程

标注编辑器
    ↓
点击保存按钮
    ↓
生成标注JSON → 转为Blob → 计算fileId
    ↓
检查服务器已上传分片(断点续传)
    ↓
分片上传(跳过已上传的)
    ↓
所有分片完成 → 通知服务器合并
    ↓
服务器合并 → 保存到数据库 → 返回成功
    ↓
前端显示成功提示

这样就实现了针对标注结果的可靠上传方案!

Vue-从内置指令到自定义指令实战

2026年2月2日 12:04

前言

在 Vue 的开发世界里,“指令(Directives)”是连接模板与底层 DOM 的桥梁。除了官方提供的强大内置指令外,Vue 还允许我们根据业务需求自定义指令。本文将带你一次性梳理 Vue 指令体系,并手把手实现一个高频实用的“一键复制”指令。

一、 Vue 内置指令全家桶

在深入自定义指令之前,我们先复习一下这些每天都在用的“老朋友”。内置指令以 v- 开头,是 Vue 预设的特殊属性。

指令 作用描述 核心要点
v-bind 响应式地更新 HTML 属性 简写为 :,如 :src:class
v-on 绑定事件监听器 简写为 @,如 @click
v-model 在表单及组件上创建双向绑定 它是 v-bindv-on 的语法糖
v-if / v-else 根据条件渲染/销毁元素 真正的条件渲染(销毁与重建)
v-show 根据条件切换元素的显示 基于 CSS 的 display: none 切换
v-for 基于源数据多次渲染元素 建议必须绑定唯一的 :key
v-html 更新元素的 innerHTML 注意:易导致 XSS 攻击,慎用
v-once 只渲染元素和组件一次 随后的重新渲染将跳过该部分,用于优化性能

二、 自定义指令:像 v-model 一样强大

1. 核心概念

自定义指令主要用于提高代码复用性。当你发现自己在多个组件中都在操作同一个 DOM 逻辑时,就该考虑将其封装为指令了。

2. 生命周期(钩子函数)

Vue 3 重构了指令钩子,使其与组件生命周期完美对齐:

Vue 3 钩子 Vue 2 对应 执行时机
beforeMount bind 指令第一次绑定到元素时调用
mounted inserted 绑定元素插入父节点时调用
beforeUpdate update 元素所在组件 VNode 更新前
updated componentUpdated 组件及子组件全部更新后调用
unmounted unbind 指令与元素解绑且元素已卸载

3. 钩子函数参数

指令对象的钩子函数中都带有如下参数:

  • el: 绑定的真实 DOM。

  • binding: 对象,包含

    • name:指令名,不包括 v- 前缀。
    • value:指令的绑定值,例如:v-my-directive="1 + 1" 中,绑定值为 2
    • oldValue:指令绑定的前一个值,仅在 ``update/beforeUpdate 和 componentUpdated/updated` 钩子中可用。无论值是否改变都可用。
    • expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1"
    • arg:传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo"
    • modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }
  • vnodeVue 编译生成的虚拟节点

  • oldVnode:上一个虚拟节点,仅在 update/beforeUpdate 和 componentUpdated/updated 钩子中可用


三、 实战:实现“一键复制”指令 v-copy

1. 指令逻辑实现 (/libs/directives/copy.ts)

import { Directive, DirectiveBinding } from 'vue';

export const copyDirective: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    el.style.cursor = 'copy';
    
    // 绑定点击事件
    el.addEventListener('click', () => {
      const textToCopy = binding.value;
      
      if (!textToCopy) {
        console.warn('v-copy: 无复制内容');
        return;
      }

      // 现代浏览器 API
      if (navigator.clipboard && window.isSecureContext) {
        navigator.clipboard.writeText(String(textToCopy))
          .then(() => alert('复制成功!'))
          .catch(() => alert('复制失败'));
      } else {
        // 兼容降级方案
        const textarea = document.createElement('textarea');
        textarea.value = String(textToCopy);
        textarea.style.position = 'fixed';
        textarea.style.left = '-9999px';
        document.body.appendChild(textarea);
        textarea.select();
        try {
          document.execCommand('copy');
          alert('复制成功!');
        } catch (err) {
          console.error('复制失败', err);
        }
        document.body.removeChild(textarea);
      }
    });
  }
};

2. 全局注册与使用

注册 (main.ts):

import { createApp } from 'vue';
import App from './App.vue';
import { copyDirective } from './libs/directives/copy';

const app = createApp(App);
app.directive('copy', copyDirective); // 全局注册
app.mount('#app');

使用:

<template>
  <button v-copy="'这是要复制的内容'">点击复制</button>
</template>

四、 总结

  1. 内置指令覆盖了 90% 的开发场景,应熟练掌握其简写与区别(如 v-if vs v-show)。

  2. 自定义指令是操作 DOM 的最后防线,通过 mountedupdated 钩子可以实现极其灵活的逻辑。

  3. 注意规范:在 Vue 3 + TS 环境下,务必为指令和参数标记类型,以确保代码的健壮性。

微信通话时,是如何判断“当前/对方网络不佳”的?以及我们自己怎么实现?

2026年2月2日 11:40

前阵子跟客户微信语音聊需求,说着说着突然没声了,屏幕立马弹出“对方网络不佳”的提示,或者自己这边提示"当前网络不佳",反复切WiFi、开流量都没用,最后只能换电话沟通。其实这件事我想了很久了,还是打算今天拿来好好唠唠,顺便也给自己涨涨姿势,看看到底是神不可及的技术!!还是最最最简单的网络延迟方法。

为什么需要“网络不佳”提示

在微信通话这种实时音视频场景里,用户对流畅有非常低的容忍度,一旦出现断续的声音、口型不同步、画面卡顿或通话直接掉线,用户就会迅速认为服务不可靠并中断通话或投诉。因此在界面上及时、准确地提示“当前/对方网络不佳”不仅是对用户体验的尊重,也是减少误判、引导用户采取补救措施(切换到语音、关视频、切换网络或靠近路由器)的关键。具体场景包括:地铁或电梯等移动过程中发生的小区切换导致丢包与抖动;多人群聊或屏幕共享时上行带宽被耗尽导致画面质量急剧下降等,自适应码流和重传策略提供触发条件,并提升用户对恢复机制的信任感——这些都是设计“网络不佳”提示的直接动因。

image.png

微信是如何做到的?(猜测)

从技术上看,“网络好不好”并不是一个主观判断,而是一组持续可观测、可量化的网络与媒体质量信号。在实时音视频(RTC)系统中,最基础的一层是网络层指标:丢包率(Packet Loss)反映数据在传输路径上的可靠性;抖动(Jitter)描述包到达时间的不稳定性,直接决定是否需要更大的播放缓冲;RTT(Round-Trip Time)则刻画端到端时延和链路拥塞程度。在其之上是媒体层指标:码率(Bitrate)是否能稳定达到目标值、帧率(FPS)是否持续下降、关键帧是否频繁请求;再往上是体验层的综合指标,如 MOS(Mean Opinion Score) ,通过对丢包、时延、抖动、音频 PLC 触发次数、视频卡顿时长等信号加权估算“用户主观感受”。这些指标的共同点在于:它们都来自客户端和传输层的实时统计

在微信以及主流 RTC 平台(WebRTC、Agora、Zoom、腾讯云 TRTC 等)的实现中,通常不会依赖单一指标来下结论,而是采用多信号融合 + 时间窗口判断的方式。典型做法包括:在信令层和媒体层同时采集统计数据(冗余信令),避免单一路径或单一模块失效;通过 上/下行探测包(Probe Packet) 或带宽估计算法(如基于延迟梯度、丢包反馈的 BWE)持续判断链路可用带宽;在弱网或移动场景下启用 多通路/备份链路(如 Wi-Fi + 蜂窝网络的快速切换或并行探测);在播放端使用 自适应缓冲区(Adaptive Jitter Buffer) ,根据抖动动态调整缓冲深度,以在“低延迟”和“不卡顿”之间取平衡。一旦检测到多个关键指标在一定时间窗口内持续恶化(例如丢包率超过阈值、RTT 快速上升、码率被迫下探),系统就会触发体验等级下降,并映射为“当前/对方网络不佳”的用户提示。

这种思路在公开资料中也有佐证。WebRTC 官方文档和 RFC 中详细描述了基于 RTCP 统计的带宽估计与拥塞控制模型;腾讯、字节、阿里等厂商在公开专利中多次提到 多维网络质量评估、弱网对抗与体验分级提示机制;学术与工业界关于 MOS 预测的技术文献也表明,将底层网络指标映射为用户可理解的体验标签,是大规模 RTC 系统的通用做法。

如何决策

在产品层面,“网络不佳”不是技术结论展示,而是不干扰用户体验,核心目标只有一个:在不打扰用户的前提下,帮他理解当前通话异常的原因。因此微信这类产品在设计上通常遵循以下取舍。

网络指标是实时波动的,但提示不能实时波动。
实际策略通常是:时间窗口 + 连续恶化判定,例如在 2~5 秒内持续丢包升高、RTT 上扬、码率被迫下探,才认为是“稳定性问题”,否则只是短暂抖动,直接忽略。
这也是为什么你在地铁刚进隧道那一瞬间,微信往往不会立刻弹“网络不佳”。 当然了哈~~ 也不排除微信确实没及时检测到,哈哈哈

技术方案

如果把“网络不佳”当成一个完整的技术功能来看,它并不是某个 if 判断,而是一条很清晰的过程:数据采集 → 指标聚合 → 质量评分 → 防抖与阈值 → 展示或策略处理

一、数据采集(Data Collection)

第一步解决的不是判断,而是你到底能看到什么。在 RTC 客户端里,采集通常来自三层:

  • 网络层:RTT、丢包率、抖动、发送/接收速率、重传次数
  • 传输/协议层:RTCP 统计、NACK/PLI/FIR 次数、拥塞窗口变化
  • 媒体层:编码码率、实际渲染帧率、卡顿时长、音频 PLC 触发次数

注意:这些数据不是按事件上报,而是以固定周期(如 200ms / 500ms / 1s)持续采样,形成时间序列。

二、指标聚合(Aggregation)

原始指标是噪声极大的,不能直接用。现实情况下我们系统一定要收集:

  • 滑动时间窗(如最近 3s / 5s)
  • 计算均值、P95、变化斜率
  • 标记异常峰值(Spike)而不是立刻判坏

举个栗子:
一次 200ms 的 RTT 飙升,可能是 GC、系统调度或基站抖动;
RTT 连续 5 秒单调上升 + 丢包同步增加,才是链路拥塞的信号。

其实这个操作就是把瞬时的网络状态,转换成一个网络趋势,方便判断是否要提示用户!

三、质量评分(Quality Scoring)

接下来不是直接出网络好/网络坏,而是要有体验层映射。常见方式如下:

  • 规则加权
    score = w1*丢包 + w2*RTT + w3*卡顿 + w4*帧率下降
  • 分档映射
    优 / 良 / 可接受 / 差(对应 MOS 区间)

四、提示以及处理

这里的提示我们必须做防抖,不能反复频繁提示用户!

进入阈值:评分连续低于 X,持续 ≥ T 秒 退出阈值:评分连续高于 Y(Y > X),持续 ≥ T′ 秒 状态锁定:同一状态不重复触发提示

然后就是处理了, UI 层:展示「当前 / 对方网络不佳」。 要做的处理:

-   自动降码率 / 降分辨率
-   关闭视频保音频
-   切备用链路 / 重连
  • 统计层:上报埋点,用于后续策略优化

也就是说, “网络不佳”往往是系统已经做了很多努力之后的结果告知 ,而不是直接哇啦哇啦告诉用户,你踏马网废了。

整体流程示意


flowchart LR

A[原始数据采集<br/>RTT / 丢包 / 帧率] --> B[时间窗口聚合<br/>均值 / 趋势]

B --> C[质量评分<br/>MOS / 等级]

C --> D[防抖 & 阈值判断<br/>状态机]

D --> E[UI 提示<br/>网络不佳]

D --> F[自适应策略<br/>降码率/切链路]


我们如何实现呢?(ReactNative)

前文拆解的这套网络检测逻辑,并非微信独有的技术壁垒,在工程实践中,我们完全可以自己完成一套方案。下面直接用React Native结合WebRTC的实操举例,别眨眼,我要写代码了。(可以眨眼)

技术选型与依赖

在RN项目中,基于WebRTC做数据采集是最稳妥的选择,第一步先安装核心依赖:

yarn add react-native-webrtc

这个库自带的getStats方法,是网络质量判断的核心入口,里面包含了所有关键数据维度:

  • RTT(往返延迟)
  • packetsLost / packetsSent(丢包数/发送数)
  • jitter(抖动)
  • bitrate(码率,通过bytesSent差分计算得出)
  • frameRate(帧率,部分平台支持)

这里要明确一个核心认知:无需刻意计算网络状态,重点是精准读取传输过程中的原生统计数据。

image.png

数据采集(定时 + 时间序列)

const statsBuffer: StatSample[] = [];

setInterval(async () => {
  const stats = await pc.getStats();
  const parsed = parseStats(stats);

  statsBuffer.push({
    rtt: parsed.rtt,
    packetLoss: parsed.packetLoss,
    jitter: parsed.jitter,
    bitrate: parsed.bitrate,
    ts: Date.now(),
  });

  // 只保留最近5秒的数据
  prune(statsBuffer, 5000);
}, 1000);

这里有两个至关重要的细节:切勿依赖单次数据快照,必须保留时间维度的连续数据。缺少这两点,后续的防抖处理和趋势判断都会沦为空谈。

指标聚合 + 质量评分(可解释优先)

function calcQuality(samples: StatSample[]) {
  const avgLoss = mean(samples.map(s => s.packetLoss));
  const avgRtt = mean(samples.map(s => s.rtt));
  const avgJitter = mean(samples.map(s => s.jitter));

  let score = 100;

  if (avgLoss > 0.05) score -= 30;
  if (avgRtt > 300) score -= 30;
  if (avgJitter > 50) score -= 20;

  return score;
}

这种规则加权的评分方式,在真实工程场景中应用极广。核心原因很简单:可调优、可回滚、可追溯,出现问题时能快速定位到具体异常指标。

阈值 + 防抖(用状态机思路,别堆if判断)

let badSince: number | null = null;
let state: 'GOOD' | 'BAD' = 'GOOD';

function updateState(score: number) {
  const now = Date.now();

  if (score < 60) {
    if (!badSince) badSince = now;
    if (now - badSince > 3000 && state !== 'BAD') {
      state = 'BAD';
      showNetworkBad();
    }
  } else {
    badSince = null;
    if (state === 'BAD' && score > 75) {
      state = 'GOOD';
      hideNetworkBad();
    }
  }
}

这段逻辑的核心要点很明确:评分低于60分时触发预警判定,持续3秒无改善才切换至异常状态;恢复时需评分超过75分才回切正常状态。这一步的设计直接决定提示功能的专业性,有效避免频繁误报影响用户体验。

举例方便所以使用打分制,也可以其他的

然后UI展示轻提示

{state === 'BAD' && (
  <View style={styles.badNetwork}>
    <Text>醒醒!!你踏马网废了</Text>
  </View>
)}

采用轻量提示设计,不弹窗、不弹出 Toast、不抢占用户操作焦点,仅安静告知用户:当前网络存在异常,非设备故障或操作问题。

image.png

自动降级处理,这点很重要

在真实项目中,网络异常提示绝非仅展示一句文案,更重要的是触发对应的自适应应对策略:

例如当异常状态持续5秒:

  • 自动降低视频码率
  • 下调视频分辨率或帧率

当异常状态持续10秒:

  • 提示用户关闭视频,优先保障音频通话通畅

当状态恢复正常时:

  • 缓慢提升码率,避免一次性拉满导致再次卡顿

这里有个核心工程原则务必记牢:恢复要慢,降级要快。

总结

从技术角度来看,判断通话网络好坏,其实就是三件事:持续采集指标、观察趋势、连续判定。瞬时波动不算数,只有连续多秒丢包、抖动高、延迟大,才真正算网络不佳。再配合降码率、先保音频、延迟提示的策略,就能在用户几乎感觉不到的情况下保证体验。核心逻辑很朴素,但工程上最难的是防抖、聚合和兜底

Vue-深度解析“组件”与“插件”的区别与底层实现

2026年2月2日 11:38

前言

在 Vue 的生态系统中,“组件(Component)”和“插件(Plugin)”是构建应用的两大基石。虽然它们都承载着逻辑复用的使命,但在设计模式、注册方式和职责边界上却截然不同。本文将带你从底层原理出发,理清二者的核心差异。

一、 核心概念对比

1. 组件 (Component)

组件是 Vue 应用的最小构建单元,通常是一个 .vue 后缀的文件。

  • 本质:可复用的 UI 实例。
  • 职责:封装 HTML 结构、CSS 样式和 TS 交互逻辑。

2. 插件 (Plugin)

插件是用于扩展 Vue 全局功能的工具库。

  • 本质:一个包含 install 方法的对象或函数。
  • 职责:为 Vue 添加全局方法、全局指令、全局组件或注入全局属性(如 vue-routerpinia)。

二、 关键区别总结

特性 组件 (Component) 插件 (Plugin)
功能范围 局部的 UI 渲染与交互 全局的功能扩展
代码形式 .vue 文件(SFC)或渲染函数 暴露 install 方法的 JS/TS 对象
注册方式 app.component() 或局部引入 app.use()
使用场景 按钮、弹窗、列表等 UI 单元 路由管理、状态管理、全局水印指令等

三、 编写形式

1. 编写一个组件

组件的编写我们非常熟悉,通常使用 DefineComponent<script setup>

<template>
  <button class="my-btn"><slot /></button>
</template>

<script setup lang="ts">
// 组件内部逻辑
</script>

2. 编写一个插件 (Vue 3 写法)

在 Vue 3 中,插件的 install 方法第一个参数变为 app (应用实例) ,而不再是 Vue 构造函数。

// myPlugin.ts
import type { App, Plugin } from 'vue';

export const MyPlugin: Plugin = {
  install(app: App, options: any) {
    // 1. 添加全局方法或属性 (通过 config.globalProperties)
    app.config.globalProperties.$myGlobalMethod = () => {
      console.log('执行全局方法');
    };

    // 2. 注册全局指令
    app.directive('my-highlight', {
      mounted(el: HTMLElement, binding) {
        el.style.backgroundColor = binding.value || 'yellow';
      }
    });

    // 3. 全局混入 (慎用)
    app.mixin({
      created() {
        // console.log('插件注入的生命周期');
      }
    });

    // 4. 注册全局组件
    // app.component('GlobalComp', MyComponent);

    // 5. 提供全局数据 (Provide / Inject)
    app.provide('plugin-config', options);
  }
};

四、 注册方式的演进

1. 组件注册

  • 全局注册app.component('MyBtn', MyButton)
  • 局部注册:在父组件中直接 import导入。

2. 插件注册

在 Vue 3 中,使用应用实例的 use 方法。

// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { MyPlugin } from './plugins/myPlugin';

const app = createApp(App);

// 安装插件,可以传入可选配置
app.use(MyPlugin, {
  debug: true
});

app.mount('#app');

五、 总结与注意事项

  1. Vue 3 的变化:Vue 3 移除了 Vue.prototype,改为使用 app.config.globalProperties 来挂载全局方法。

  2. 职责分离:如果你的代码是为了在页面上显示一段内容,请写成组件;如果你是为了给所有的组件提供某种“超能力”(如统一处理错误、多语言支持),请写成插件

  3. 插件的 install 机制app.use 内部会自动调用插件的 install 方法。如果插件本身就是一个函数,它也会被直接当做 install 函数执行。

Rspress 2.0 发布:面向体验与 AI 的全新升级

作者 WebInfra
2026年2月2日 11:33

本文作者为 Rstack 团队 - SoonIter

我们很高兴地宣布 Rspress 2.0 的正式发布!

Rspress 是基于 Rsbuild 的静态站点生成器,专为开发者打造的文档站工具。自 2023 年正式发布以来,Rspress 1.x 累计迭代 144 个版本,共有 125 位贡献者 参与项目开发。越来越多的开发者选择 Rspress,借助其高效的编译性能、约定式路由和组件库预览等功能,搭建美观可靠的文档站点。

基于社区的反馈和建议,Rspress 2.0 在 主题美观度AI-native文档开发体验与 Rslib 一起使用 等方面更进一步。

为什么是 Rspress 2.0

Rspress 1.x 已经解决了文档站框架编译性能的问题,但仍存在一些问题影响着作为一个文档开发工具的核心体验。2.0 版本将不止于对编译性能的追求,也聚焦于文档站体验的其他方面:

  • 主题样式:一套更美观的默认主题,并提供了多种 自定义主题 方式,解决了 1.x 在主题定制上缺乏稳定 API 的问题。

  • AI-native:文档不仅服务于人类读者,也需要被 Agent 更好地理解和使用。Rspress 现在内置了 llms.txt 生成和从 SSG 衍生出的 SSG-MD 功能,生成高质量的 Markdown 渲染内容供 Agent 读取。

  • 按需编译,瞬间启动:默认启用 lazyCompilation,配合链接 hover 时对资源的 preload 功能,仅在访问特定路由时构建所需文件,实现无论项目规模多大,dev 也可瞬间启动

  • Shiki 代码高亮:默认集成 Shiki,在构建时完成语法高亮,支持主题切换、transformer 扩展,比如 @rspress/plugin-twoslash,带来更丰富的代码块展示效果。

  • 文档开发体验:优化 _nav.json_meta.json 等文件的 HMR 并新增 json schema 用于 IDE 内的代码提示;默认开启死链检查功能;新增文件代码块语法,支持引用外部文件;@rspress/plugin-preview@rspress/plugin-playground 支持同时使用等。

  • Rslib 集成:现在可以在使用 create-rslib 创建组件库项目时,选择 Rspress 作为文档工具,快速搭建组件文档站点。

这是一次对现有架构的全面升级,下面将介绍 Rspress 2.0 和它的 全新主题、高质量 llms.txt 生成、集成 Shiki、按需编译等重要功能。

2.0 新特性

全新主题

2.0 默认主题迎来了一次系统性升级,它由团队设计师 @Zovn Wei 整体设计,在视觉效果和阅读体验上都有较大幅度提升,并且每个组件均可独立替换,拥有很高的可定制性。

主题定制

按照定制化程度从低到高,有 CSS 变量、BEM 类名、ESM 重导出覆盖、组件 eject 四种自定义主题方式。

  • CSS 变量:新主题暴露了更多 CSS 变量,覆盖主题色、代码块、首页等样式。你可以在 CSS 变量 页面交互式地预览和调整所有 CSS 变量,找到满意的配置后直接复制到项目中使用。
:root {
  /* 自定义主题色 */
  --rp-c-brand: #3451b2;
  --rp-c-brand-dark: #2e4599;
  /* 自定义代码块样式 */
  --rp-code-block-bg: #1e1e1e;
}
  • BEM 类名:内置组件现在均采用 BEM 命名规范。这是一个十分 Old School 的选择,但也是我们深思熟虑的决定。用户可以通过 CSS 选择器精准调整样式,HTML 结构更加清晰;同时与 Rspress 用户自身使用的 CSS 框架解耦,用户可以任意选择 CSS 框架(TailwindLessSass 等),比如使用 Tailwind V4 或 V3 而不用担心版本,也不用担心与 Rspress 内置 CSS 产生冲突。
/* BEM 命名规范 */
.rp-[component-name]__[element-name]--[modifier-name] {
}

/* 根据 BEM 类名轻松覆盖组件样式 */
.rp-nav__title {
  height: 32px;
}
.rp-nav-menu__item--active {
  color: purple;
}
  • ESM 重导出覆盖:如果 CSS 上的修改无法满足定制需求,可以通过 JS 进行更深度的定制。在 theme/index.tsx 中利用 ESM 重导出,可以覆盖任意一个 Rspress 的内置组件。
import { Layout as BasicLayout } from '@rspress/core/theme-original';

const Layout = () => <BasicLayout beforeNavTitle={<div>some content</div>} />;

export { Layout }; //[!code highlight]
export * from '@rspress/core/theme-original'; //[!code highlight]
  • 组件 eject:你可以使用全新的 rspress eject [component] 命令,这个命令会将指定组件的源代码复制到 theme/components/ 目录下,你可以自由修改这些代码,甚至直接交给 AI 修改,来实现深度定制。
# 将 DocFooter 组件导出到 theme 目录
rspress eject DocFooter

导航栏、侧边栏 tag

Rspress 2.0 实现了 Tag 组件,现在可以使用 frontmatter 中的 tag 属性,在侧边栏或导航栏进行 UI 标注。

---
tag: new, experimental # 会在 H1 和 Sidebar 进行显示
---

import { Tag } from '@rspress/core/theme';

# Tag

## Common tags <Tag tag="new" /> {/* 会在右侧 outline 进行显示 */}
Tag 组件在侧边栏中的显示效果

内置多语言支持

在 1.x 版本中,Rspress 仅内置了英文文本,如果使用其他语言例如 zh,必须对所有的文本都进行配置,使用起来较为繁琐。现在 2.0 主题内置了 zh、en、ja、ko、ru 等多种语言的翻译文本,系统会根据语言配置自动进行 "Tree Shaking",仅打包你使用到的文本及语言,未内置的语言会兜底到 en 文本。你也可以通过 i18nSource 配置项扩展或覆盖翻译文本。

Rspress 未来会支持更多内置语言,如果你有兴趣,请参考 这位贡献者的 Pull Request

llms.txt 支持

Rspress 现在将 llms.txt 生成能力集成到 core 中,并实现了全新的 SSG-MD(Static Site Generation to Markdown,静态站点 Markdown 生成)能力。

在基于 React 动态渲染的前端框架中,往往存在静态信息难以提取的问题,Rspress 也面临同样的挑战。Rspress 允许用户通过 MDX 片段、React 组件、Hooks 以及 TSX 路由等动态特性来增强文档表现力。但这些动态内容在转换为 Markdown 文本时会面临以下问题:

  • 直接将 MDX 输入给 AI 会包含大量代码语法噪音,并丢失 React 组件内容
  • 将 HTML 转为 Markdown 往往效果不佳,信息质量难以保证

为了解决这个问题,Rspress 2.0 引入了 SSG-MD 特性。这是一个全新的功能,它类似于 静态站点生成(SSG),但不同之处在于它将你的页面渲染为 Markdown 文件,而非 HTML 文件,并生成 llms.txt 及 llms-full.txt 相关文件。

相比于将 HTML 转化为 Markdown 等传统方式,SSG-MD 在渲染期间拥有更优质的信息源,比如 React 虚拟 DOM,从而保证更高的静态信息质量和灵活性。

启用方式非常简单:

import { defineConfig } from '@rspress/core';

export default defineConfig({
  llms: true,
});

构建后将生成如下结构:

doc_build
├── llms.txt
├── llms-full.txt
├── guide
│   └── start
│       └── introduction.md
└── ...

若想定制自定义组件的渲染内容,可通过环境变量控制:

export function Tab({ label }: { label: string }) {
  if (import.meta.env.SSG_MD) {
    // SSG-MD 模式下输出纯文本描述
    return <>{`**Tab: ${label}**`}</>;
  }
  // 正常渲染交互式组件
  return <div className="tab">{label}</div>;
}

这样既保证了文档的交互体验,也能帮助 AI 理解组件的语义信息。

详见 SSG-MD 使用指南

Shiki 编译时代码块高亮

Rspress 2.0 默认使用 Shiki 进行代码高亮。相比 1.x 的 prism 运行时高亮方案,Shiki 在编译时完成高亮处理。

  1. 支持多种主题样式,比如在 CSS 变量 页面可以交互式地切换和预览不同的 Shiki 主题。
  2. 同时 Shiki 也允许使用自定义的 transformer 进行扩展来丰富写作,例如 twoslash 等。
  3. 按需引入编程语言,不增加运行时开销和包体积。
  4. 基于 TextMate 语法实现与 VS Code 一致的准确语法高亮。

下面是一些 Shiki transformer 的示例,直观感受一下 Shiki 带来的文档创造力:

使用 @rspress/plugin-twoslash

const hi = 'Hello';
const msg = `${hi}, world`;
//    ^?

使用 transformerNotationFocus

console.log('Not focused');
console.log('Focused'); // [!code focus]
console.log('Not focused');

详见 代码块

构建性能提升

Rspress 2.0 底层由 Rsbuild 和 Rspack 2.0 预览版本驱动,同时默认开启了 按需编译持久化缓存

按需编译

默认开启 dev.lazyCompilation,只有当你访问某个页面时,该页面才会被编译,大幅提升了开发启动速度,甚至实现毫秒级的冷启动。Rspress 同时实现了路由的 preload 策略,当鼠标悬停在链接上时会预先加载目标路由页面,搭配 lazyCompilation 实现无损的开发体验。

持久化缓存

2.0 同时默认开启了 持久化缓存,在热启动中复用上次编译的结果,提升 30%-60% 的构建速度。这意味着在首次运行 rspress devrspress build 后,后续启动速度都会明显提升。

文档开发体验

默认开启死链检查

Rspress 2.0 默认开启死链检查功能。在构建过程中,会自动检测文档中的无效链接,帮助你及时发现和修复。

import { defineConfig } from '@rspress/core';

export default defineConfig({
  markdown: {
    link: {
      checkDeadLinks: true, // 默认开启,可通过 false 关闭
    },
  },
});

详见 链接

文件代码块

你可以使用 file="./path/to/file" 属性来引用外部文件作为代码块的内容,将示例代码放在单独的文件中维护。

```ts file="./_demo.ts"

```
```tsx file="<root>/src/components/Button.tsx"

```

详见 文件代码块

preview 更灵活的 meta 用法

@rspress/plugin-preview 现在基于 meta 属性使用,更加灵活,也可以配合文件代码块。

下面是一个使用 iframe 预览代码块的示例:

```tsx preview="iframe-follow" file="./_demo.ts"

```

它将会渲染为:

20260202-104132.jpeg

并且 @rspress/plugin-playground 现在支持和 plugin-preview 一起使用,通过 meta 属性切换即可,例如 ```tsx playground

支持若干配置文件的 HMR

基于 Rsbuild 重新设计的 虚拟模块插件,现在支持 i18n.json_nav.json_meta.json、文件代码块以及 @rspress/plugin-preview 中 iframe 相关的 HMR。修改这些配置文件后,页面会自动热更新,无需手动刷新。

Rslib & Rspress

在使用 create-rslib 创建项目时,你现在可以选择 Rspress 工具。这让你能够在开发组件库的同时,快速搭建配套的文档站点,用于编写组件的使用说明、展示 API 参考,或实时预览组件效果。

执行 npm create rslib@latest 并选中 Rspress,会生成下方的文件结构:

模版中内置了 rsbuild-plugin-workspace-dev 插件,可在启动 Rspress 开发服务器的同时自动运行 Rslib 的 watch 命令。

直接运行 npm run doc 启动 Rspress 的开发服务器对 Rslib 组件库进行预览:

{
  "scripts": {
    "dev": "rslib build --watch",
    "doc": "rspress dev" // 执行该命令
  }
}

更多 Rspress 官方插件

Rspress 2.0 新增了多个官方插件:


其他 Breaking changes

从 Rspress 1.x 迁移

如果你是 1.x 项目的用户,我们准备了一份详尽的迁移文档,帮助你从 1.x 升级到 2.0。

你可以直接使用页面中的 "复制 Markdown" 功能,将其输入给你常用的编码 agent(如 Claude Code 等)来完成迁移。

请参考 迁移指南

移除 mdxRs 配置

我们注意到很大一部分 1.x 用户为了使用 Shiki、组件库预览功能和自定义 remark/rehype 插件,而主动关闭 mdxRs,并且在开启按需编译和持久化缓存后,即使使用 JS 版本的 mdx 解析器,性能优化效果已经非常显著。

为了换取更好的扩展性和维护性,我们决定在 Markdown/MDX 编译流程中不再使用 Rust 版本的 MDX 解析器(@rspress/mdx-rs)。这使得 Rspress 能够更好地集成 Shiki 等 JavaScript 生态的工具。

Node.js 与上游依赖版本要求

Rspress 2.0 要求 Node.js 版本 20+,React 版本 18+。

依赖 允许范围 默认版本 说明
react ^18.0.0 || ^19.0.0 19 不再支持 React 17,如项目已安装则使用项目版本
react-dom ^18.0.0 || ^19.0.0 19 与 react 版本保持一致
react-router-dom ^6.0.0 || ^7.0.0 7 如项目已安装则使用项目版本
unified ^11.0.0 11 自定义 remark/rehype 插件需兼容

包名及导入路径变更

Rspress 将 rspress@rspress/runtime@rspress/shared@rspress/theme-default 都整合进了 @rspress/core 中,项目和插件现在均只需安装一个 @rspress/core 包即可。

{
  "dependencies": {
-   "rspress": "1.x"
-   "@rspress/shared": "1.x"
+   "@rspress/core": "^2.0.0"
  }
}
- import { defineConfig } from 'rspress/config';
+ import { defineConfig } from '@rspress/core';
- import { useDark } from 'rspress/runtime'
- import { PackageManagerTabs } from 'rspress/theme';
+ import { useDark } from '@rspress/core/runtime'
+ import { PackageManagerTabs } from '@rspress/core/theme';

如果你开发了 Rspress 插件,请将插件的 peerDependencies 从 rspress 变更为 @rspress/core

{
  "peerDependencies": {
    "@rspress/core": "^2.0.0"
  }
}

下一步

Rspress 2.0 的发布只是一个新的起点。本次发布后,Rspress 将持续迭代:

  • 推进生态集成:与 Rslib、Rstest 更深度地结合,提供前端项目和组件库项目的一体化开发体验。
  • 探索 AI 与文档更深度集成:如智能问答、自动摘要等;完善 SSG-MD 使其稳定并更加易用。

感谢所有为 Rspress 做出贡献的开发者和用户!如果你在使用过程中遇到问题或有任何建议,欢迎在 GitHub Issues 中反馈。

立即使用或升级到 Rspress 2.0,体验全新的文档开发之旅!

npm create rspress@latest

一次 Agent Skill 的实战体验,以及 MCP 和 Skill 的区别

作者 Sailing
2026年2月2日 11:29

本周通过一个小需求尝试了下 Agent Skill,效果还不错。

比如你要做一个网站,以前没装技能的时候,AI 生成的代码又是那个熟悉的:

蓝紫渐变色 + 千篇一律的布局 + 明显的 AI 审美(不同的模型,产生的结果不同)

而通过 Agent Skill 的形式,可以提前配置好:

  • 配色体系
  • 字体
  • 布局风格

当然,rules 和 prompt 也能做到这一点。
但 Agent Skill 的优势在于:把 Prompt 打包成一个文件夹,让 AI 按需读取和使用

虽然本质上没啥区别,都是 prompt,但 Skill 的形态更工程化、更灵活。

image.png

MCP 和 Skill:经常一起出现,但不是一回事

现在用 AI Agent 工具(Claude Code、Cursor)时,经常会遇到两个概念:

  • MCP
  • Skill

我觉得有必要区分清楚:两者各有侧重,是互补关系,而不是替代关系。

Anthropic 官方的说法:

MCP connects Claude to external services and data sources.
Skills provide procedural knowledge—instructions for how to complete specific tasks or workflows.

翻成一句话就是:

MCP 让 AI 能拿到数据,Skill 教 AI 怎么处理数据。

MCP 在做什么?

MCP 的三个核心组成:

  • Tools(工具)
  • Resources(资源)
  • Prompts(提示)

LLM(大语言模型)本身并不执行函数,在 Agent 架构中,通常是由规划层(Planner / System Prompt) 决定“要做什么”。

Function Calling 负责在推理过程中,表达模型想要调用某个工具的意图。MCP 构建在 Function Calling 之上,进一步规范工具的描述方式、发现机制与调用协议。可以理解为:

  • 规划层:决定做什么
  • Function Calling:表达要调用哪个工具
  • MCP:规范这个工具从哪里来、如何被发现、如何被调用

MCP 更关注的是:AI 与外部世界的连接能力

同时需要注意:

MCP 本身并不提供推理能力,
它解决的是连接与通信的标准化问题。
是否正确使用工具、如何组合工具,仍然取决于模型能力与上层 Agent 设计。

Skill 在做什么?

Skill 可以以文件夹(Prompt 资产)形式存在,里面包含:

  • 指令
  • 脚本
  • 资源

但 Skill 的价值并不在于“文件夹本身”,而在于:

这些 Prompt 资产能够被 Agent 识别、发现、加载和组合使用。

在架构层级上:Skill 是「提示 / 知识层」、MCP 是「集成层」

Skill 通常分三层加载:

  1. 元数据(始终加载)
  2. 核心指令(按需加载)
  3. 支持文件(按需加载)

它解决的是:如何把经验、规范、做事方法沉淀下来并复用

同时需要明确:

从能力本质上看,Skill 并不是新的模型能力,
而是对 Prompt 的工程化封装与组织升级
提升的是稳定性与可维护性,而不是智能本身的跃迁。

什么时候用 MCP?什么时候用 Skill?

  • 用 MCP
    • 获取外部数据
    • 调接口
    • 操作系统、文件、数据库
  • 用 Skill
    • 内部规范
    • 标准化实践经验
    • 固定工作方式
    • 代码风格 / 设计风格约束

网上也有人提到 Skill 可以用于指定工作流程,这块我还没有深入实践,后面有时间会再尝试。

一个对照式实战示例:同一个需求,不同方式怎么做?

假设现在有一个需求:从接口获取用户数据,并生成一个用户列表页面。

只用 Prompt

你可能会这样写:

请从接口 api.xxx.com/users 获取用户数据,并使用 Vue3 生成一个简洁风格的用户列表页面。

特点:

  • 每次都要重复写
  • 输出风格不稳定

因此,这种方式更适用于一次性需求。

使用 Skill 固化“页面生成方式”

创建一个 Skill,例如:

/frontend-ui-skill
  ├── metadata.json
  ├── instructions.md
  ├── style-guide.md

instructions.md:

所有页面使用:

  • 浅色背景
  • 中性色配色
  • 卡片式布局
  • Vue3 + Composition API

之后你只需要说:

生成用户列表页面

AI 会自动套用该 Skill 的规则。适用于前端规范化输出的场景。

使用 MCP 获取真实数据

通过 MCP 暴露一个工具:

getUsers()

你对 AI 说:

调用 getUsers

AI 会通过 MCP 获取接口数据。

MCP + Skill 组合

流程:

  1. MCP:调用 getUsers()
  2. Skill:规定页面结构和风格
  3. AI:生成页面代码

你只需要说:“生成用户列表页面”,背后完成了:

  • 拿数据
  • 套规范
  • 产出代码

一个更大的共性

不管是 MCP、Prompt 还是 Skill,本质目标都一致:

降低模型幻觉,提高稳定性,提高效率。

但也必须明确:

MCP、Prompt、Skill 都无法从根本上消除模型幻觉,
它们能做的是:降低出错概率、提高一致性、减少不确定性。

因此,完全脱离人工审核的流程化自动生成,在工程上仍然是不可靠的

它们更合理的定位是:

放大工程师能力的工具,而不是替代工程师。

Vue-实例从 createApp 到真实 DOM 的挂载全历程

2026年2月2日 11:28

前言

无论是 Vue 2 的 new Vue() 还是 Vue 3 的 createApp(),将组件配置转化为页面上可见的真实 DOM,中间经历了一系列复杂的转换。理解这一过程,不仅能帮我们更好地掌握生命周期,更是理解响应式原理的基础。

一、 挂载过程总览

Vue 实例的挂载过程,本质上是将组件配置转化为虚拟 DOM,最终映射为真实 DOM,并建立响应式双向绑定的过程。


二、 核心挂载步骤详解

1. 初始化阶段 (Initialization)

在 Vue 3 中,通过 createApp 开始。

  • 创建实例:根据传入的根组件配置创建一个应用上下文(vue实例),接着进行数据初始化。

  • 初始化数据:这是最关键的一步。Vue 会依次初始化 Props、Methods、Setup (Vue 3)、Mixins、Data、Computed

    • 校验:Vue 会校验 propsdata 中的变量名是否重复。
    • 响应式绑定:Vue 3 使用 Proxy(Vue 2 使用 Object.defineProperty)对数据进行劫持,建立依赖收集机制。

2. 模板编译阶段 (Template Compile)

这一步将“肉眼可见”的 HTML 模板转化为机器高效执行的 JavaScript 代码。

  • 解析 (Parser) :将 template 字符串解析为 抽象语法树 (AST)
  • 转换 (Transformer) :对 AST 进行静态提升、补丁标记(Patch Flags)等优化。
  • 生成 (Generator) :将 AST 转换成 render 渲染函数 字符串。

3. 生成虚拟 DOM (VNode)

  • Vue 调用生成的 render 函数。
  • render 函数根据Template执行后会返回一个 虚拟 DOM 树 (Virtual DOM) 。它是对真实 DOM 的一种轻量级 JavaScript 对象描述。

4. 挂载与 Patch (Mounting & Patching)

  • 调用 Mount:执行组件的挂载方法。
  • 渲染真实 DOM:渲染器(Renderer)遍历虚拟 DOM 树,递归创建真实的 HTML 元素。
  • 更新页面:将生成的真实 DOM 插入到指定的容器(如 #app)中,替换掉原本的内容。

5. 完成挂载

  • 一旦真实 DOM 渲染完毕,Vue 会触发 mounted(组合式 API 为 onMounted)生命周期钩子,此时开发者可以安全地访问 DOM 节点。

三、 Vue 3 挂载示例

在 Vue 3 项目中,挂载通常发生在 main.ts

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 1. 创建应用实例
const app = createApp(App)

// 2. 挂载到指定 DOM 容器
// 挂载过程中会执行编译、数据拦截、生成 VNode 并渲染
app.mount('#app')

四、 总结

  1. AST 与 VNode 的区别

    • AST:是对 HTML 语法的描述,用于代码编译阶段。
    • VNode:是对 DOM 节点的描述,用于运行时渲染和 Diff 算法。
  2. 双向绑定的建立时机:在 data 初始化阶段,Vue 就已经通过响应式 API 拦截了数据。当 render 函数读取数据时,会自动触发依赖收集。

  3. 重新挂载:如果响应式数据发生变化,Vue 不会重新走一遍完整的挂载过程,而是通过 Diff 算法 对比新旧 VNode,仅更新发生变化的真实 DOM 部分。

在 Cloudflare 平台上构建垂直微前端

作者 程序猿DD
2026年2月2日 11:23

想象一下,你正在开发一个大型Web应用。营销团队想要用Astro构建他们的页面以获得最佳的SEO效果,而产品团队却坚持要用React来构建功能丰富的后台管理系统。更糟糕的是,每次发布新版本时,十几个团队的代码都需要一起打包、一起测试、一起上线——只要其中一个团队引入了一个bug,整个发布就要回滚。这种"一荣俱荣、一损俱损"的耦合方式,是不是让你感到无比头疼?

或者,你的公司刚刚收购了一个创业公司,他们的产品是用Vue写的,而你们的主站是用React写的。你想把他们的功能整合进来,但又不希望把两个完全不同的代码库强行混在一起。

这些都是现代Web开发中真实存在的难题。传统的微前端架构通常是"水平"的——同一个页面上的不同组件来自不同的服务。但如果有一种方式,能让每个团队完全独立地开发、部署和维护自己的功能模块,而用户却感觉在使用一个无缝的、统一的应用呢?

这就是垂直微前端(Vertical Microfrontends)要解决的问题。现在,Cloudflare推出了一款全新的Worker模板,让这种架构变得前所未有的简单。

什么是垂直微前端?

垂直微前端是一种架构模式,单个独立团队拥有应用程序功能的完整切片,从用户界面一直到底层的CI/CD流水线。这些切片通过域名上的路径来定义,你可以将各个独立的Worker与特定路径关联起来:

/      = 营销网站
/docs  = 文档
/blog  = 博客
/dash  = 仪表盘

我们还可以进一步细化,在更细粒度的子路径上关联不同的Worker。比如在仪表盘中,你可能通过各种功能或产品来划分URL路径的深度(例如 /dash/product-a),在两个产品之间导航可能意味着两个完全不同的代码库。

现在有了垂直微前端,我们还可以这样设计:

/dash/product-a  = WorkerA
/dash/product-b  = WorkerB

上面的每个路径都是独立的前端项目,它们之间没有任何共享代码。product-aproduct-b 路由映射到分别部署的前端应用,它们有自己的框架、库、CI/CD流水线,由各自的团队定义和拥有。

你可以端到端地拥有自己的代码。但现在我们需要找到一种方法将这些独立的项目缝合在一起,更重要的是,让它们感觉像是一个统一的体验。

Cloudflare自己也在经历这个痛点,因为仪表盘有许多独立的团队负责各自的产品。团队必须面对一个事实:在他们控制范围之外所做的更改会影响用户对其产品的体验。

在内部,我们现在对自己的仪表盘也采用了类似的策略。当用户从核心仪表盘导航到我们的ZeroTrust产品时,实际上它们是两个完全独立的项目,用户只是通过路径 /:accountId/one 被路由到那个项目。

视觉上的统一体验

将这些独立项目缝合在一起,让它们感觉像一个统一的体验,并没有你想象的那么困难:只需要几行CSS魔法。我们绝对不希望发生的事情是将我们的实现细节和内部决策泄露给用户。如果我们无法让这个用户体验感觉像一个统一的前端,那我们就对用户犯下了严重的错误。

要实现这种巧妙的手法,让我们先了解一下视图过渡和文档预加载是如何发挥作用的。

视图过渡

当我们想要在两个不同页面之间无缝导航,同时让最终用户感觉流畅时,视图过渡非常有用。在页面上定义特定的DOM元素,让它们一直保留到下一页可见,并定义任何变化的处理方式,这成为了多页应用的强大缝合工具。

然而,在某些情况下,让各个垂直微前端感觉不同也是完全可以接受的。比如我们的营销网站、文档和仪表盘,它们各自都有独特的定义。用户不会期望这三者在导航时都感觉统一。但是……如果你决定在单个体验中引入垂直切片(例如 /dash/product-a/dash/product-b),那么用户绝对不应该知道它们底层是两个不同的仓库/Worker/项目。

好了,说得够多了——让我们开始动手吧。我说过让两个独立的项目对用户来说感觉像是一个是低成本的,如果你还没有听说过CSS视图过渡,那么接下来我要让你大开眼界了。

如果我告诉你,你可以在单页应用(SPA)或多页应用(MPA)的不同视图之间创建动画过渡,让它们感觉像是一个整体?在添加任何视图过渡之前,如果我们导航属于两个不同Worker的页面,中间加载状态会是浏览器中的白色空白屏幕,持续几百毫秒,直到下一页开始渲染。页面不会感觉统一,当然也不会像单页应用。

如果希望元素保留,而不是看到白色空白页,我们可以通过定义CSS视图过渡来实现。通过下面的代码,我们告诉当前文档页面,当视图过渡事件即将发生时,将nav DOM元素保留在屏幕上,如果现有页面和目标页面之间存在任何外观差异,我们将使用ease-in-out过渡来动画展示。

突然之间,两个不同的Worker感觉就像一个了。

@supports (view-transition-name: none) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.3s;
    animation-timing-function: ease-in-out;
  }
  nav { view-transition-name: navigation; }
}

预加载

在两个页面之间过渡让它"看起来"无缝——我们还希望它"感觉"像客户端SPA一样即时。虽然目前Firefox和Safari不支持Speculation Rules,但Chrome/Edge/Opera确实支持这个较新的API。Speculation Rules API旨在提高未来导航的性能,特别是对于文档URL,让多页应用感觉更像单页应用。

分解成代码,我们需要定义一个特定格式的脚本规则,告诉支持的浏览器如何预取与我们Web应用程序连接的其他垂直切片——可能通过某些共享导航链接。

<script type="speculationrules">
  {
    "prefetch": [
      {
        "urls": ["https://product-a.com", "https://product-b.com"],
        "requires": ["anonymous-client-ip-when-cross-origin"],
        "referrer_policy": "no-referrer"
      }
    ]
  }
</script>

有了这些,我们的应用程序会预取其他微前端并将它们保留在内存缓存中,所以如果我们导航到那些页面,会感觉几乎是即时的。

对于明显可区分的垂直切片(营销、文档、仪表盘),你可能不需要这样做,因为用户在它们之间导航时会预期有轻微的加载。然而,当垂直切片定义在特定可见体验内时(例如在仪表盘页面中),强烈建议使用。

通过视图过渡和推测规则,我们能够将完全不同的代码仓库联系在一起,感觉就像它们来自单页应用一样。如果你问我,这太神奇了。

零配置请求路由

现在我们需要一种机制来托管多个应用程序,以及一种在请求流入时将它们缝合在一起的方法。定义一个Cloudflare Worker作为"路由器",允许在边缘的单个逻辑点处理网络请求,然后将它们转发给负责该URL路径的垂直微前端。而且我们可以将单个域名映射到该路由器Worker,其余的就"正常工作"了。

服务绑定

如果你还没有探索过Cloudflare Worker服务绑定,那么值得花点时间了解一下。

服务绑定允许一个Worker调用另一个Worker,而无需经过公开可访问的URL。服务绑定允许Worker A调用Worker B上的方法,或将请求从Worker A转发到Worker。进一步分解,路由器Worker可以调用已定义的每个垂直微前端Worker(例如营销、文档、仪表盘),假设它们都是Cloudflare Workers。

这为什么重要?这正是将这些垂直切片"缝合"在一起的机制。我们将在下一节深入探讨请求路由如何处理流量分割。但要定义这些微前端中的每一个,我们需要更新路由器Worker的wrangler定义,这样它就知道允许调用哪些前端。

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "router",
  "main": "./src/router.js",
  "services": [
    {
      "binding": "HOME",
      "service": "worker_marketing"
    },
    {
      "binding": "DOCS",
      "service": "worker_docs"
    },
    {
      "binding": "DASH",
      "service": "worker_dash"
    }
  ]
}

上面的示例定义在我们的路由器Worker中,然后告诉我们被允许向三个独立的额外Worker(营销、文档和仪表盘)发出请求。授予权限就这么简单,但让我们深入研究一些更复杂的逻辑,包括请求路由和HTML重写网络响应。

请求路由

了解了在需要时可以调用的各种其他Worker之后,现在我们需要一些逻辑来确定何时将网络请求定向到哪里。由于路由器Worker被分配到我们的自定义域名,所有传入的请求首先在网络边缘到达它。然后它确定哪个Worker应该处理请求,并管理结果响应。

第一步是将URL路径映射到关联的Worker。当收到某个请求URL时,我们需要知道它需要被转发到哪里。我们通过定义规则来实现这一点。虽然我们支持通配符路由、动态路径和参数约束,但我们将专注于基础——字面路径前缀——因为它更清楚地说明了要点。

在这个例子中,我们有三个微前端:

/      = 营销
/docs  = 文档
/dash  = 仪表盘

上面的每个路径都需要映射到一个实际的Worker(参见上面章节中的wrangler服务定义)。对于我们的路由器Worker,我们定义一个额外的变量,包含以下数据,这样我们就知道哪些路径应该映射到哪些服务绑定。现在我们知道当请求进来时应该将用户路由到哪里!定义一个名为ROUTES的wrangler变量,内容如下:

{
  "routes": [
    {"binding": "HOME", "path": "/"},
    {"binding": "DOCS", "path": "/docs"},
    {"binding": "DASH", "path": "/dash"}
  ]
}

让我们设想一个用户访问我们网站的路径 /docs/installation。在底层,发生的情况是请求首先到达我们的路由器Worker,它负责了解什么URL路径映射到哪个独立的Worker。它理解 /docs 路径前缀映射到我们的 DOCS 服务绑定,参照我们的wrangler文件指向我们的 worker_docs 项目。我们的路由器Worker知道 /docs 被定义为垂直微前端路由,从路径中移除 /docs 前缀,将请求转发给我们的 worker_docs Worker来处理请求,然后最终返回我们得到的任何响应。

为什么要删除 /docs 路径呢?这是一个实现细节的选择,目的是当Worker通过路由器Worker访问时,它可以清理URL来处理请求,就像它是从路由器Worker外部调用的一样。像任何Cloudflare Worker一样,我们的 worker_docs 服务可能有自己的独立URL可以访问。我们决定希望该服务URL继续独立工作。当它附加到我们的新路由器Worker时,它会自动处理移除前缀,这样服务就可以从自己定义的URL或通过我们的路由器Worker访问……任何地方都可以,无所谓。

HTMLRewriter

用URL路径分割我们的各种前端服务(例如 /docs/dash)让我们很容易转发请求,但当我们的响应包含不知道它被通过路径组件反向代理的HTML时……嗯,这就会出问题。

假设我们的文档网站在响应中有一个图片标签 <img src="./logo.png" />。如果我们的用户正在访问页面 https://website.com/docs/,那么加载 logo.png 文件可能会失败,因为我们的 /docs 路径只是由我们的路由器Worker人为定义的。

只有当我们的服务通过路由器Worker访问时,我们才需要对一些绝对路径进行HTML重写,这样我们返回的浏览器响应才能引用有效的资源。实际上发生的是,当请求通过我们的路由器Worker时,我们将请求传递给正确的服务绑定,并从中接收响应。在将其传回客户端之前,我们有机会重写DOM——所以在看到绝对路径的地方,我们继续用代理路径预先填充它。以前我们的HTML返回的图片标签是 <img src="./logo.png" />,现在我们修改为在返回客户端浏览器之前 <img src="./docs/logo.png" />

让我们回到CSS视图过渡和文档预加载的魔法。我们当然可以把那段代码手动放到我们的项目中并让它工作,但这个路由器Worker也会使用HTMLRewriter自动为我们处理这些逻辑。

在你的路由器Worker ROUTES 变量中,如果你在根级别设置 smoothTransitionstrue,那么CSS过渡视图代码会自动添加。此外,如果你在路由中设置 preload 键为 true,那么该路由的推测规则脚本代码也会自动添加。

下面是两者结合使用的示例:

{
  "smoothTransitions": true,
  "routes": [
    {"binding": "APP1", "path": "/app1", "preload": true},
    {"binding": "APP2", "path": "/app2", "preload": true}
  ]
}

开始使用

你今天就可以开始使用垂直微前端模板构建了。

访问Cloudflare仪表盘的链接,或者进入"Workers & Pages"并点击"创建应用程序"按钮开始。从那里,点击"选择模板"然后"创建微前端",你就可以开始配置你的设置了。

更多使用指南,可以点击查看文档 ,如果您对各种云原生架构的内容感兴趣,也可以关注我的博客:程序猿DD,第一时间获得干货更新。

Vue-路由懒加载与组件懒加载

2026年2月2日 11:14

前言

在构建大型单页应用(SPA)时,JavaScript 包体积(Bundle Size)往往会随着业务增长而膨胀,导致首屏加载缓慢、白屏时间长。懒加载(Lazy Loading) 是解决这一问题的核心方案。其本质是将代码分割成多个小的 chunk,仅在需要时才从服务器下载。

一、 路由懒加载:按需拆分页面

1. 为什么需要路由懒加载?

如果不使用懒加载,所有路由对应的组件都会被打包进同一个 app.js 中。用户访问首页时,浏览器不得不下载整个应用的逻辑,造成严重的性能浪费。

2. 实现方式:ES import()

利用动态导入语法,打包工具(如 Vite 或 Webpack)会自动进行 代码分割(Code Splitting)

// router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';

// 静态导入:会随着主包一起加载,适合首页
import Home from '@/views/Home.vue';

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // 动态导入:只有访问 /about 路径时,浏览器才会请求该组件对应的 JS 文件
    component: () => import('@/views/About.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

二、 组件懒加载:细粒度控制

有些组件(如弹窗、复杂的图表、第三方重型库)并不需要在页面初次渲染时立即存在。

1. Vue 3 的 defineAsyncComponent

在 Vue 3 中,异步组件必须使用 defineAsyncComponent 进行显式声明。

示例

<template>
  <div>
    <h1>主页面</h1>
    <button @click="showChart = true">加载并显示报表</button>
    
    <AsyncChart v-if="showChart" />
  </div>
</template>

<script setup lang="ts">
import { ref, defineAsyncComponent } from 'vue';

const showChart = ref<boolean>(false);

// 显式定义异步组件
const AsyncChart = defineAsyncComponent(() =>
  import('@/components/BigChart.vue')
);

// 高级配置(可选):带加载状态
const AsyncComponentWithConfig = defineAsyncComponent({
  loader: () => import('./components/MyComponent.vue'),
  loadingComponent: LoadingComponent, // 加载过程中显示的组件
  errorComponent: ErrorComponent,     // 加载失败时显示的组件
  delay: 200,                         // 展示加载组件前的延迟时间
  timeout: 3000                       // 超时时间
});
</script>

2. Vue 2 中直接使用import函数声明异步组件

export default {
  components: {
    // 定义一个异步组件
    'MyLazyComponent': () => import('./components/MyLazyComponent.vue')
  }
}

三、 底层原理与分包策略

1. 打包工具的配合

当你使用 import() 时:

  • Vite/Rollup:会自动将该组件及其依赖提取到一个独立的 .js 文件中。

  • Webpack:会生成一个 chunk,你可以通过“魔法注释”自定义 chunk 的名称:

    const About = () => import(/* webpackChunkName: "about-group" */ './About.vue')
    

四、 总结

  1. 首屏优化:建议首页(Home)使用静态导入,而其他非核心路径、非首屏展示的弹窗/插件全部使用懒加载。

  2. 用户体验:使用异步组件时,建议配合 loadingComponent,避免加载过程中组件区域出现突兀的空白 。

微信小程序中的 UnoCSS:哪些能用,哪些一用就报错([ WXSS 文件编译错误 ] ./app.wxss(7997:2599): unexpected `\

2026年2月2日 11:13

前言

最近我自己搭了一套 移动端 uni-app + Vue3 + TypeScript + UnoCSS + uview-plus 的基础框架,目标是一套代码,多端运行,可以同时部署到 H5、微信小程序、App

H5 环境 下,一切都非常美好: UnoCSS 写起来顺手又优雅,原子类随便用,开发体验直接拉满。

但当我把同一套代码 打包运行到微信小程序 时,问题立刻出现了—— 项目甚至还没跑起来,微信小程序 IDE 就直接报错,堪称“当场劝退”。

[ WXSS 文件编译错误 ]
./app.wxss(7997:2599): unexpected `\` at pos 170113
(env: Windows, mp, 1.06.2504060; lib: 3.14.0)

一开始我以为是配置问题、打包问题,甚至怀疑过 uni-app 或 uview-plus。 直到深入排查之后才发现,真正的“元凶”其实是 UnoCSS 的部分写法在微信小程序(WXSS)中并不被支持

随后我只能回过头来,严格按照微信小程序可接受的 UnoCSS 写法,对样式进行了重新梳理和重构。 在经历了一轮“删中括号、改写法、禁用部分特性”的过程之后,项目才终于顺利在微信小程序中运行起来。

也正是这次踩坑,让我对 UnoCSS 在微信小程序中的可用边界 有了非常清晰的认识,这篇文章也正是对这次经历的总结。

微信小程序报错

一、 微信小程序 不支持 的 UnoCSS 写法(雷区)

🚫 1️⃣ 任意值(死亡率 100%)

w-[100px]
m-[20px]
px-[10rpx]
bg-[#fff]

你以为你在写 CSS, WXSS 看到的是:

.w-\[100px\] { ... }

WXSS:

你是不是往我这塞正则?

🚫 2️⃣ 变体写法(WXSS 完全不认识)

hover:bg-red-500
md:w-100
dark:bg-black

WXSS 的内心活动:

hover?md? 你是不是把我当浏览器了?

🚫 3️⃣ 分数 / 高级数学

WXSS:

我只会 px,不会高数。

🚫 4️⃣ 自定义颜色 / 特殊字符

bg-[#fff]
text-[rgb(0,0,0)]

只要出现:

[ ]  :  #  %

WXSS 直接拒绝交流

二、那到底哪些 UnoCSS 能在小程序里活下来?

好消息是: 不是 UnoCSS 全灭,只是被迫“回到朴素年代”。

✅ 微信小程序 支持 的 UnoCSS 写法(安全区)

✅ 1️⃣ 布局类(WXSS 认识)

flex
block
inline-block
hidden

flex-row
flex-col

items-start
items-center
items-end

justify-start
justify-center
justify-end
justify-between

✅ 2️⃣ 尺寸类(纯数字,别加戏)

w-10
w-50
w-100
h-40
h-100

w-100 = width: 100px。不支持 rpx,不支持中括号。

✅ 3️⃣ 间距类(老实人专用)

m-0
m-10
m-20

mt-10
mb-10
ml-10
mr-10

p-10
p-20

px-20
py-10

✅ 4️⃣ 文字类(岁月静好)

text-12
text-14
text-16

font-normal
font-medium
font-bold

text-left
text-center
text-right

✅ 5️⃣ 颜色类(只用官方色)

text-black
text-white
text-gray-500
text-blue-500

bg-white
bg-gray-100
bg-blue-500

✅ 6️⃣ 边框 & 圆角(还能接受)

border
border-2

border-gray-200
border-gray-300

rounded
rounded-md
rounded-full

✅ 7️⃣ 定位类(简单就好)

relative
absolute
fixed

top-0
left-0
right-0
bottom-0

top-10
left-20

三、为什么会这样?(简单说,不废话)

1️⃣ WXSS 不是 CSS

微信小程序用的是 WXSS, 本质是 CSS 的阉割版:

不支持复杂选择器 不支持转义字符 不支持任意值

WXSS 只吃“看起来像早期 CSS”的东西

2️⃣ UnoCSS 的强项,刚好是 WXSS 的雷点

你写:

w-[100px]

UnoCSS 会生成:

.w-\[100px\] {
  width: 100px;
}

在 Web 里: ✅ 合法 ✅ 优雅

在 WXSS 里: ❌ 非法 ❌ 编译失败

四、保命配置(强烈建议)

// uno.config.ts
presetUno({
  arbitrary: false, // 禁止 w-[xx] 这种自杀式写法
})

“你再写中括号,我就当你没写。”

五、一句话生存法则(请背)

微信小程序 + UnoCSS:

不写中括号

不写冒号

不写花里胡哨

否则你会收获:

unexpected \

一看就懂的 Haskell 教程 - 基本数据类型

作者 Anita_Sun
2026年2月2日 10:52

前言:Haskell 数据类型的核心设计思想

Haskell 作为纯函数式、强静态类型、惰性求值的编程语言,其数据类型设计并非单纯定义数据形态,而是深度围绕函数式编程的核心本质展开,与命令式语言(JS/Python/Java)的设计逻辑形成根本区别。其核心设计思想可总结为四大核心原则,所有数据类型的定义、操作、扩展均严格遵循此原则,既保证函数式的纯性与抽象性,又兼顾工程实践的实用性与高效性:

  1. 纯度优先,不可变性贯穿:所有数据类型均为不可变,无任何可直接修改的底层结构,所有操作(运算、拼接、修改等)均生成新值而非修改原值,从根源避免状态突变,契合纯函数“无副作用、输出仅由输入决定”的核心要求;
  2. 惰性求值深度适配:数据类型设计贴合惰性求值机制(仅在需要时计算值,未使用的部分不分配内存、不执行计算),天然支持无限数据结构(如无限列表、无限字符串),兼顾内存效率与表达灵活性,体现“关注结果而非执行步骤”的声明式思维;
  3. 类型安全与通用性平衡:依托强静态类型实现编译期类型检查,杜绝运行时类型错误;通过类型类(Type Class) 实现“操作与具体类型解耦”,让同一套操作逻辑适配多种类型(如+可作用于所有数值类型,take可作用于列表与字符串),避免冗余代码,体现“通用抽象”的设计哲学;
  4. 简洁与表达力统一,按需分层设计:核心数据类型(标量、字符串、列表、元组)语义清晰、无冗余设计,覆盖所有基础开发场景;同时按“场景/性能/精度”分层设计(如Int/Integer、String/Text),既简化基础编码,又能精准适配不同工程需求,支持自定义类型扩展,契合“极简且强大”的函数式设计理念。

标量类型

标量类型是 Haskell 最基础的数据类型,代表单一、不可拆分的原子值,其设计核心是「语义精准、操作通用、贴合惰性与不可变性」,为复杂数据结构(字符串、列表、元组)提供基础支撑,同时体现函数式“极简抽象”的设计特点,所有标量值一旦定义即不可修改,所有运算均生成新值。

数值标量类型(Int/Integer/Float/Double/Rational/Complex)

设计目的

解决“数值表达与精准计算”的基础需求,覆盖整数、浮点、分数、复数等所有数值运算场景,同时兼顾精度、性能、通用性三大维度,适配纯函数式无副作用、惰性求值的特性,从设计上避免命令式语言中常见的“数值溢出、浮点误差、类型混乱”等问题。

设计思想

  • 按「精度/性能/业务场景」分层设计:针对不同数值需求(普通整数、大数计算、简单浮点、高精度浮点、精准分数、复数运算)拆分出专属类型,而非设计单一“数值类型”,既保证语义清晰,又避免“一刀切”导致的精度浪费或性能损耗;
  • 不可变性贯穿始终:所有数值类型的值一旦定义永久不可修改,任何数值运算(+/*/sqrt等)均生成新值,而非修改原值,无任何状态突变;
  • 类型类统一通用操作:通过Num/Integral/Floating/Fractional等类型类,将数值操作(加减乘除、开方、取模等)抽象出来,让同一套操作逻辑适配所有对应数值类型,实现通用抽象,减少重复编码。

体现的函数式特点

  • 体现「无副作用」:数值不可变,运算不修改任何原始值,操作输出仅由输入决定,无状态依赖,契合纯函数要求;
  • 体现「通用抽象」:类型类解耦“操作”与“具体数值类型”,无需为每个类型单独定义运算函数,实现跨类型代码复用;
  • 体现「类型安全」:强静态类型限制让数值类型错误在编译期暴露(如Int与String无法相加、整数与浮点数的显式转换要求),杜绝运行时类型错误;
  • 体现「按需设计」:分层设计让不同类型精准适配不同场景,既保证普通场景的性能,又满足特殊场景的精度要求,不牺牲工程实用性。

各数值类型详细解析

整数类型:Int & Integer
类型 核心特性 设计目的 适用场景
Int 固定精度,范围依赖系统(32位系统±2¹⁵-1,64位系统±9223372036854775807),运算高效,底层对应机器原生整型 适配“普通整数计算”场景,兼顾运算性能,贴合机器底层实现,减少运算开销 普通整数计算、循环计数、性能敏感场景
Integer 任意精度(无溢出、精度无上限),运算效率略低于Int,底层为大整数实现 解决“大数计算”痛点,避免命令式语言中整数溢出问题,保证计算精准性 大数计算、金融/密码学/数学运算等无溢出需求

设计思想补充:二者分工明确,避免“单一整数类型”的弊端——既不因为追求无限精度牺牲普通场景的运算性能(Int),也不因为追求性能放弃大数场景的精准性(Integer),体现 Haskell“按需设计、平衡取舍”的核心思想,且均遵循不可变性,运算均生成新值。

-- Int:普通整数,64位系统下高效计算(生成新值,无状态修改)
a :: Int = 100
b :: Int = a * 2  -- 200,纯函数式运算,原a值保持不变
-- Integer:大数无溢出,支持无限位(惰性求值适配,仅在需要时计算)
c :: Integer = 10^100  -- 1后面100个0,无溢出,生成新值
d :: Integer = c * 2   -- 正常计算,无精度丢失,原c值保持不变
浮点类型:Float & Double
类型 核心特性 设计目的 适用场景
Float 单精度浮点数,32位存储,精度约6-7位有效数字,内存占用小 适配“简单浮点计算”场景,节省内存,兼顾基础浮点运算需求 简单浮点计算、内存敏感的嵌入式/轻量场景
Double 双精度浮点数,64位存储,精度约15-17位有效数字,工程默认推荐,精度更高 适配“高精度浮点计算”场景(如科学计算、工程运算),减少浮点误差,满足绝大多数复杂需求 常规浮点计算、科学计算、数据分析

设计思想补充:与整数类型一致,按“精度/内存”分层设计,严格遵循不可变性——浮点运算均生成新值,即使存在浮点误差(如0.1+0.2≠0.3),也是浮点类型的底层存储特性,而非 Haskell 设计缺陷;同时通过Floating/Fractional类型类,让sqrt/log//等函数适配两种浮点类型,实现通用抽象。

-- Float:单精度,精度有限(生成新值,不修改原始定义)
f :: Float = 0.123456789  -- 实际存储为0.12345679(精度截断,非修改操作)
-- Double:双精度,精度更高,满足绝大多数工程需求
g :: Double = 0.1234567890123456789  -- 保留更多有效数字
h :: Double = sqrt 2.0  -- 1.41421356237,平方根计算,生成新值无副作用
有理数:Rational
  • 核心特性:精准表示分数,无任何浮点误差,底层为Integer/Integer(分子/分母)结构,自动约分,不可变;
  • 导入方式:需显式导入Data.Ratio模块,定义语法为分子 % 分母
  • 设计目的:解决浮点类型的“精度丢失”痛点,适配金融计算、分数运算、精准数值对比等对精度要求极高的场景,体现函数式“精准抽象”的思想——用最贴合业务场景的数据类型表达逻辑,而非勉强使用通用类型;
  • 体现的函数式特点:不可变性让分数运算无状态突变,自动约分保证数据语义清晰,类型类让其可复用数值类型的所有运算逻辑(+/*/>等)。
import Data.Ratio  -- 必须显式导入,Prelude不默认包含

-- 定义有理数:1/3、2/5,自动约分(生成新值,不可修改)
r1 :: Rational = 1 % 3
r2 :: Rational = 2 % 5
r3 :: Rational = r1 + r2  -- 11 % 15(精准计算,无浮点误差,生成新值)
r4 :: Rational = 4 % 6    -- 自动约分为2 % 3(语义精准,避免数据冗余)
r5 :: Rational = r4 * 3   -- 2 % 1(自动简化,等价于整数2
复数:Complex
  • 核心特性:表示复数a + bi,实部和虚部可指定为任意数值类型(Float/Double/Rational),不可变,支持所有复数专属运算(共轭、模长、辐角等),贴合组合式抽象设计;
  • 导入方式:需显式导入Data.Complex模块,定义语法为实部 :+ 虚部(冒号+加号为复数构造符);
  • 设计目的:适配科学计算、工程运算(如信号处理、复分析、量子计算)等需要复数的场景,通过“实部+虚部”的极简组合设计,实现复数的精准表达与运算,体现函数式“组合式抽象”的核心思想——复杂数据结构由简单基础类型组合而成,无需单独设计复杂底层;
  • 体现的函数式特点:不可变性让复数运算无状态突变,所有复数操作均生成新复数;组合式设计贴合函数式“拆分-组合”的思维,实部和虚部可灵活选择数值类型,适配不同精度需求;同时通过Num/Floating类型类,复用数值类型的运算逻辑(如+/*/sqrt),实现通用抽象,减少冗余代码。
import Data.Complex  -- 必须显式导入,Prelude不默认包含

-- 定义复数:实部+虚部,可指定不同数值类型(组合式设计,灵活适配)
c1 :: Complex Double = 1 :+ 2    -- 双精度复数:1+2i(适配高精度场景)
c2 :: Complex Float = 3 :+ (-4)  -- 单精度复数:3-4i(适配内存敏感场景)
c3 :: Complex Rational = 1%2 :+ 3%4  -- 有理数复数:1/2 + 3/4i(适配精准场景)

-- 复数运算:所有操作均生成新复数,不修改原复数(不可变性)
c4 :: Complex Double = c1 + (2 :+ 3)  -- (1+2)+(2+3)i = 3+5i(生成新值)
c5 :: Complex Double = c1 * (3 :+ (-4))  -- (1*3 - 2*4) + (1*(-4)+2*3)i = 11+2i(精准运算)

-- 复数专属操作(贴合科学计算需求,语义清晰)
c1Conj :: Complex Double = conjugate c1  -- 共轭复数:1 :+ (-2)(生成新值)
c1Mag :: Double = magnitude c1           -- 模长:sqrt(1²+2²) = 2.23607(复用浮点运算)
c1Arg :: Double = phase c1              -- 辐角:arctan(2/1) ≈ 1.10715 弧度

布尔类型:Bool

布尔类型是 Haskell 中表示逻辑判断的基础标量类型,设计极简且语义单一,完全贴合函数式“语义精准、类型安全”的核心要求,为条件分支、守卫表达式、逻辑运算提供基础支撑。

  • 核心值:仅True(真)和False(假)两个常量值,不可变,无任何其他冗余值;

  • 设计目的:精准表示逻辑判断结果,杜绝命令式语言中“非布尔值当作布尔值使用”(如JS中0视为false、非0视为true)的类型混乱,保证类型安全,同时为逻辑运算提供明确的语义支撑;

  • 设计思想:极简设计(仅两个常量值),逻辑操作与类型强绑定,避免语义模糊;不可变性让逻辑运算无状态突变,所有逻辑操作均生成新布尔值;贴合类型安全要求,仅布尔值可参与逻辑运算,杜绝类型错误;

  • 体现的函数式特点:

    • 体现「无副作用」:TrueFalse为不可变常量,任何逻辑运算(&&/||/not)均生成新布尔值,不修改任何原始值,输出仅由输入决定;
    • 体现「类型安全」:强静态类型限制,仅布尔值可参与逻辑运算,非布尔值(如Int、String)无法参与逻辑操作,编译期即可暴露类型错误;
    • 体现「声明式适配」:布尔值作为条件判断的结果,让开发者无需关注“如何判断逻辑真假”,只需聚焦“逻辑是否成立”,贴合声明式编程“关注结果而非步骤”的思维;
    • 体现「极简抽象」:仅负责逻辑判断的单一语义,无冗余功能,契合函数式“极简且强大”的设计理念。
-- 基础逻辑运算(所有操作均生成新值,无副作用,不可变性)
b1 :: Bool = True && False  -- False(逻辑与,生成新布尔值)
b2 :: Bool = True || False  -- True(逻辑或,生成新布尔值)
b3 :: Bool = not b1         -- True(逻辑非,生成新布尔值)

-- 比较运算返回Bool(类型安全,仅同类型可比较,语义清晰)
b4 :: Bool = 5 > 3          -- True(整数比较,返回Bool)
b5 :: Bool = "abc" == "abd" -- False(字符串比较,返回Bool)
b6 :: Bool = 'a' == 'A'     -- False(字符比较,返回Bool)

-- 编译报错示例(类型安全,杜绝逻辑混乱)
-- b7 = 5 && True  -- 报错:Int与Bool无法进行逻辑运算
-- b8 = "true" || False  -- 报错:String与Bool无法进行逻辑运算

设计思想补充:布尔类型的“极简性”是其核心优势——命令式语言中“非布尔值当作布尔值”的设计,容易导致逻辑混乱(如1 && 2的语义模糊),而 Haskell 中布尔类型的单一语义的设计,让逻辑运算更清晰、更安全,同时不可变性让逻辑操作可预测、可测试,契合纯函数式编程的核心要求。

字符类型:Char

Char类型是表示单个字符的标量类型,是字符串(String/Text)的基础组成单元,设计核心是“通用适配、语义精准、不可变”,支持所有Unicode字符,贴合多语言开发需求,同时为字符串的组合式设计提供基础。

  • 核心特性:表示单个Unicode字符,用单引号' '包裹,支持所有Unicode编码(包括中文、日文、特殊符号、 emoji 等),不可变,底层存储为Unicode编码值;

  • 设计目的:精准表示单个字符,为字符串(字符的聚合序列)提供基础单元,支持多语言字符,打破语言编码限制,体现函数式“组合式抽象”的思想——复杂的字符串由简单的Char类型组合而成;

  • 设计思想:基于Unicode编码设计,覆盖所有语言的字符,实现多语言通用适配;不可变性让字符操作(如转大小写、编码转换)均生成新字符,无副作用;语义单一(仅表示单个字符),无冗余功能,贴合极简设计理念;

  • 体现的函数式特点:

    • 体现「无副作用」:Char值不可变,任何字符转换操作(如转大写、转编码)均生成新Char值,不修改原始字符,输出仅由输入决定;
    • 体现「通用抽象」:Unicode编码的支持让Char类型可适配所有多语言场景,无需为不同语言单独设计字符类型,实现通用适配;
    • 体现「组合式编程」:Char作为基础单元,通过聚合组合形成字符串(String/Text),贴合函数式“拆分-组合”的核心思维,为字符串的复用与扩展提供基础;
    • 体现「类型安全」:Char类型与其他标量类型(如Int、Bool)严格区分,不可直接混用(如'a' + 1编译报错),保证类型安全。
import Data.Char (toUpper, toLower, ord, chr)  -- 字符操作与编码转换函数

-- 普通ASCII字符、Unicode字符(中文、特殊符号、emoji)(多语言通用适配)
c1 :: Char = 'a'         -- ASCII字符
c2 :: Char = '中'        -- 中文Unicode字符
c3 :: Char = 'π'         -- 希腊字母
c4 :: Char = '😂'        -- emoji字符(Unicode编码支持)

-- 字符转换操作(生成新字符,不修改原字符,无副作用)
c5 :: Char = toUpper c1  -- 'A'(转大写,生成新Char)
c6 :: Char = toLower 'B' -- 'b'(转小写,生成新Char)

-- Unicode编码转换(Char与Int的映射,体现底层编码特性)
c7 :: Int = ord c1       -- 97'a'的Unicode编码值,生成新Int)
c8 :: Char = chr 98      -- 'b'(通过Unicode编码值获取字符,生成新Char)
c9 :: Int = ord '中'     -- 20013'中'的Unicode编码值)

设计思想补充:Char类型的Unicode支持,是其“通用适配”的核心体现——不同于部分语言中“字符仅支持ASCII编码”的设计,Haskell 的Char类型从根源上支持多语言,无需额外的编码转换操作(如中文无需GBK/UTF-8切换),简化多语言开发;同时不可变性让字符操作更安全、可预测,贴合纯函数式的无副作用要求,为后续字符串的不可变设计奠定基础。

字符串类型

字符串类型是 Char 类型的聚合序列,用于表示文本信息,Haskell 中核心字符串类型为 StringText,二者按“便捷性/效率”分层设计,统一遵循“不可变性、组合式抽象、惰性适配(仅String)”的核心原则,覆盖“快速测试、短文本”与“生产环境、长文本”所有场景,同时贴合函数式“复用抽象、按需设计”的思想,本章节将二者统一整合解析,明确二者的设计差异、互补性与核心操作。

字符串类型的整体设计思想:以 Char 为基础单元,通过聚合组合形成字符串,遵循不可变性(所有操作生成新字符串),按“场景分层”设计(String 侧重便捷复用,Text 侧重高效性能),支持通用操作与显式转换,兼顾便捷性与工程实用性,同时体现函数式“组合式抽象、通用复用、无副作用”的核心特点。

基础字符串类型:String

String 是 Haskell 原生默认字符串类型,本质是 [Char](Char 类型的单链表),设计核心是“便捷性、复用性”,无需额外导入模块,可直接复用列表的所有操作逻辑,适合短文本、快速测试场景,贴合惰性求值机制,但存在效率短板。

(1)核心特性

  • 本质:String ≡ [Char](Char 的单链表),双引号" "包裹,与列表完全等价(如"hello" ≡ ['h','e','l','l','o']);
  • 便捷性:原生支持,无需导入任何模块,可直接使用;
  • 惰性适配:单链表结构天然贴合惰性求值机制,支持无限字符串(如['a'..]生成无限字符序列),按需计算,节省内存;
  • 不可变性:本质是不可变列表,所有字符串操作(拼接、截取、替换等)均生成新字符串,不修改原始字符串,无副作用;
  • 复用性:可直接复用列表的所有操作逻辑(如take/drop/++/map等),无需为字符串单独设计基础操作;
  • 局限性:单链表结构导致内存冗余、操作效率低——每个 Char 需额外存储指针(指向后续字符),内存占用高;拼接(++)、随机访问、分割等操作均为 O(n) 时间复杂度,大字符串、高频操作场景下效率极低,无法适配生产环境需求。

(2)设计目的

提供基础、便捷的文本表达方式,复用列表的操作逻辑,简化编码与快速测试流程,降低入门门槛,同时贴合惰性求值机制,支持无限字符串场景(如简单的字符序列生成),体现函数式“复用抽象、简洁便捷”的设计思想——无需为字符串单独设计基础操作,直接复用列表的成熟抽象,减少冗余代码。

(3)设计思想

  • 极简复用:将字符串设计为“Char 的单链表”,而非独立的复杂类型,直接复用列表的所有操作(拼接、截取、遍历等),最大化减少代码冗余,体现“复用抽象”的核心思想;
  • 惰性适配:单链表结构与 Haskell 惰性求值机制深度契合,支持“按需生成字符”,无需提前分配所有内存,适合无限字符串、短文本场景,兼顾内存效率;
  • 不可变性贯穿:继承列表的不可变性,所有字符串操作均生成新字符串,无状态突变,契合纯函数“无副作用”的要求;
  • 便捷优先,效率让步:优先保证编码便捷性与入门友好性,适合快速测试、短文本处理,对于效率要求不高的场景,无需引入复杂的高效类型(Text),体现“按需设计”的灵活性。

(4)体现的函数式特点

  • 体现「无副作用」:不可变性让所有字符串操作均生成新值,不修改原始字符串,操作输出仅由输入决定,无状态依赖,便于推理与测试;
  • 体现「复用抽象」:直接复用列表的操作逻辑,无需为字符串单独设计基础操作,实现“一套抽象,多场景复用”,减少冗余代码,契合函数式“通用抽象”的思想;
  • 体现「惰性求值优势」:单链表结构支持惰性生成,无限字符串的原生支持让复杂字符序列(如无限重复的字符流)变得简洁直观,无需手动控制内存分配;
  • 体现「组合式编程」:以 Char 为基础单元,通过列表的聚合组合形成字符串,贴合函数式“拆分-组合”的核心思维,可通过基础操作的组合实现复杂文本处理。

(5)核心操作

String 无专属操作,所有操作均复用列表的操作逻辑,核心覆盖“创建、拼接、截取、遍历、转换”等场景,所有操作均遵循不可变性,生成新字符串。

-- 1. 字符串创建:双引号、列表两种写法等价(体现[String]本质)
s1 :: String = "hello"       -- 双引号写法(推荐,简洁)
s2 :: [Char] = ['h','e','l','l','o']  -- 列表写法,与s1完全等价
s3 :: Bool = s1 == s2        -- True,两种写法语义一致
s4 :: String = "你好,Haskell"  -- 支持Unicode中文,无需额外编码

-- 2. 拼接操作:复用列表++,生成新字符串(不可变性)
s5 :: String = s1 ++ " world"  -- "hello world",原s1保持不变
s6 :: String = "a" ++ "b" ++ "c"  -- "abc",多次拼接均生成新值

-- 3. 截取操作:复用take/drop,生成新字符串
s7 :: String = take 3 s1      -- "hel",截取前3个字符
s8 :: String = drop 2 s1      -- "llo",丢弃前2个字符
s9 :: String = take 5 "你好世界"  -- "你好世界",按Char计数(Unicode兼容)

-- 4. 遍历/转换操作:复用map/filter,生成新字符串
-- 所有字符转大写(map遍历每个Char,生成新String)
s10 :: String = map toUpper s1  -- "HELLO"
-- 过滤非字母字符(保留字母,生成新String)
s11 :: String = filter isAlpha "hello123!@#"  -- "hello"

-- 5. 惰性无限字符串(贴合惰性求值,按需计算)
infStr1 :: String = ['a'..]    -- 无限字符序列:"abcdef..."(仅在需要时生成)
infStr2 :: String = cycle "ab" -- 无限重复"ab""abababab..."
testInf :: String = take 10 infStr2  -- "ababababab"(仅生成前10个字符,无内存浪费)

-- 6. 长度/空判断:复用length/null,语义清晰
s12 :: Int = length s1        -- 5,字符串长度(Char计数)
s13 :: Bool = null ""         -- True,判断空字符串
s14 :: Bool = null s1         -- False

设计思想补充:String 的“复用列表操作”是其核心设计亮点——Haskell 没有为字符串单独设计一套基础操作,而是借助“String ≡ [Char]”的等价性,直接复用列表的成熟操作,既减少了语言设计的冗余,又降低了开发者的学习成本(掌握列表操作即可掌握 String 基础操作),完美体现函数式“通用抽象、复用优先”的设计思想。但其单链表结构的短板,也决定了它仅适合短文本、快速测试场景,无法适配生产环境的长文本、高频操作需求,这也是 Text 类型的设计初衷。

高效字符串类型:Text

Text 类型是 Haskell 为解决 String(单链表)效率低下的问题而设计的高效字符串类型,与 String 同属“字符聚合序列”,但底层结构优化为“连续字节数组”(UTF-8/UTF-16 编码),设计核心是“在保留函数式不可变性、通用性的基础上,提升字符串操作效率”,适配生产环境的长文本、高频操作场景(如拼接、分割、匹配),与 String 互补,共同覆盖所有文本处理需求。

(1)核心特性

  • 底层结构:摒弃 String 的单链表,采用连续字节数组(默认 UTF-8 编码,可切换为 UTF-16),无指针冗余,内存占用低;
  • 高效性:拼接、分割、匹配、随机访问等操作均为高效实现(时间复杂度远低于 String 的 O(n)),适合长文本、高频操作;
  • 不可变性:严格遵循函数式不可变性,所有 Text 操作均生成新 Text 值,不修改原始值,无副作用;
  • 编码安全:严格支持 UTF-8/UTF-16 编码,避免 String 中可能出现的编码混乱问题(如无效 Unicode 字符),保证多语言适配的安全性;
  • 互补性:支持与 String 的显式转换(pack/unpack),可在需要时切换为 String 复用列表操作,或切换为 Text 提升效率;
  • 专属操作:提供字符串专属的高效操作(如splitOn/replace/isInfixOf等),无需复用列表操作,进一步提升效率与表达力;
  • 局限性:需额外导入模块(Data.Text),不支持惰性求值(无法生成无限 Text 序列),入门门槛略高于 String。

(2)设计目的

解决 String 单链表结构导致的“内存冗余、操作低效”痛点,在不违背函数式核心原则(不可变性、类型安全)的前提下,优化底层结构,提升字符串操作效率,适配生产环境的长文本、高频操作场景(如文件 IO、网络传输、文本解析),同时提供编码安全保障,与 String 形成互补,兼顾便捷性与工程实用性,体现函数式“实用主义”的设计补充——抽象不脱离工程实践,简洁与效率并重。

(3)设计思想

  • 底层结构优化:以“连续字节数组”替代单链表,减少指针冗余,提升内存利用率与操作效率,针对性解决 String 的工程痛点;
  • 兼容函数式核心:严格遵循不可变性,所有操作均生成新值,无副作用,契合纯函数要求;同时通过类型类实现通用操作,保证与 String、Char 的兼容性;
  • 编码安全优先:严格支持 UTF-8/UTF-16 编码,自动校验 Unicode 有效性,避免编码混乱,适配多语言生产级场景;
  • 渐进式替换:支持与 String 的显式转换,既保留 String 的便捷性(复用列表操作),又能在需要时切换为 Text 提升效率,无需彻底替换原有代码,降低迁移成本;
  • 专属操作设计:针对字符串高频场景(分割、替换、匹配),设计专属高效操作,无需复用列表操作(避免列表操作的效率损耗),提升表达力与效率;
  • 效率优先,便捷让步:优先保证生产级场景的效率与安全性,适当牺牲“原生支持、惰性适配”等便捷性,与 String 形成“便捷-效率”的分层互补。

(4)体现的函数式特点

  • 体现「无副作用」:不可变性让所有 Text 操作均生成新值,不修改原始 Text,操作输出仅由输入决定,无状态突变,便于并行计算与测试;
  • 体现「实用主义抽象」:在不违背函数式核心原则的前提下,优化底层结构,适配工程实践需求,避免“为了抽象而抽象”,兼顾抽象性与实用性;
  • 体现「通用适配与互补」:支持 UTF-8 编码,适配多语言场景;与 String 的显式转换,实现“便捷-效率”的互补,覆盖所有文本场景;类型类让其可复用部分通用操作(如length/take),实现跨类型复用;
  • 体现「组合式编程」:以 Char 为基础单元,通过连续字节数组聚合形成 Text,贴合函数式“拆分-组合”的核心思维,同时专属操作可通过组合实现复杂文本处理;
  • 体现「类型安全」:与 String、Char 严格区分,不可直接混合操作(需显式转换),避免类型混乱,编译期即可暴露编码错误与类型错误。

(5)String vs Text 核心对比

String 与 Text 均为 Haskell 字符串类型,设计思路差异化明显、特性互补,共同覆盖所有文本处理场景,其对比本质体现了 Haskell“按需设计、平衡取舍”的核心思想——不追求单一通用类型,而是根据场景设计合适的类型,兼顾便捷性与效率。

特性 String([Char]) Text 互补性体现
底层结构 Char 单链表,每个 Char 带指针 连续字节数组(UTF-8/UTF-16 编码) String 侧重便捷复用,Text 侧重效率,适配不同场景;
内存占用 高(指针冗余多,内存浪费严重) 低(连续存储,无指针冗余) 短文本用 String 省麻烦,长文本用 Text 省内存;
操作效率 低(拼接/分割/访问均为 O(n) 复杂度) 高(专属高效实现,复杂度远低于 String) 快速测试用 String,生产环境用 Text;
编码支持 原生 Unicode(Char 为 Unicode),无编码校验 严格 UTF-8/UTF-16,编码安全校验 简单场景用 String,多语言生产场景用 Text;
导入方式 原生支持,无需导入任何模块 需导入 Data.Text(核心)、Data.Text.Encoding(编码) 入门/测试用 String,进阶/生产用 Text;
惰性适配 支持惰性求值,可生成无限字符串 不支持惰性求值,仅支持有限 Text 序列 无限序列用 String,有限长文本用 Text;
操作方式 复用列表操作,无专属操作 专属高效操作,支持部分列表操作复用 复杂列表操作复用 String,高频文本操作用水 Text;
适用场景 短文本、快速测试、无限字符串、列表操作复用场景 生产环境、长文本、高频操作、编码安全、多语言场景 二者互补,按需选择,通过转换实现协同;

(6)Text 核心操作

Text 提供专属的字符串操作函数,命名与列表/String 操作相似,但效率更高,核心覆盖“创建、拼接、截取、查找、替换、分割、转换”等生产级场景,所有操作均遵循不可变性,生成新 Text 值;推荐使用“限定导入”(qualified Data.Text as T),避免与 Prelude 函数(如length/take)重名。

-- 必须导入相关模块(核心操作+字符转换,避免与Prelude函数重名)
import qualified Data.Text as T  -- 限定导入,所有Text操作前缀为T.
import Data.Text (Text)
import Data.Char (toUpper)

-- 1. Text创建:专属函数,避免与String混淆(不可变,生成新值)
t1 :: Text = T.pack "hello"  -- String -> Text(最常用,显式转换)
t2 :: Text = T.fromStrict "你好,Haskell"  -- 从严格字节数组创建(高效)
t3 :: Text = T.singleton 'a' -- 单个Char创建Text(等价于T.pack "'a'",更高效)
t4 :: Text = T.empty         -- 空Text(等价于T.pack "",语义更清晰)

-- 2. 拼接操作:专属T.append,效率远高于String的++(不可变性)
t5 :: Text = T.append t1 " world"  -- "hello world",原t1保持不变
t6 :: Text = t1 `T.append` " haskell"  -- 中缀写法,与前缀写法等价
t7 :: Text = T.concat [t1, " ", t2]  -- 多Text拼接,比多次append更高效

-- 3. 截取操作:专属高效实现,复杂度低于String(生成新Text)
t8 :: Text = T.take 3 t1      -- "hel",截取前3个字符(按Char计数,Unicode兼容)
t9 :: Text = T.drop 2 t1      -- "llo",丢弃前2个字符
t10 :: Text = T.takeEnd 2 t1  -- "lo",截取后2个字符(String无原生该操作,需手动组合)
t11 :: Text = T.slice 1 4 t1  -- "ell",从索引1开始(含),到索引4结束(不含)

-- 4. 遍历/转换操作:专属映射,高效且贴合函数式思维
-- 所有字符转大写(T.map遍历每个Char,生成新Text,比map+pack更高效)
t12 :: Text = T.map toUpper t1  -- "HELLO"
-- 过滤非字母字符(保留字母,生成新Text)
t13 :: Text = T.filter T.isAlpha t1  -- "hello"(T.isAlpha为Text专属字符判断)

-- 5. 查找操作:专属高效实现,支持前缀、后缀、子串查找(返回Bool/索引)
t14 :: Bool = T.isPrefixOf "he" t1  -- True,判断t1是否以"he"为前缀
t15 :: Bool = T.isSuffixOf "lo" t1  -- True,判断t1是否以"lo"为后缀
t16 :: Bool = T.isInfixOf "el" t1   -- True,判断t1是否包含"el"子串
t17 :: Maybe Int = T.findIndex (== 'l') t1  -- Just 2,查找第一个'l'的索引(无则返回Nothing)

-- 6. 替换操作:专属T.replace,高效替换子串(生成新Text,不可变)
t18 :: Text = T.replace "l" "x" t1  -- "hexxo",将所有'l'替换为'x'
t19 :: Text = T.replace "hello" "hi" t1  -- "hi",替换整个Text内容

-- 7. 分割操作:专属T.splitOn,高频生产级操作(效率远高于String手动分割)
t20 :: [Text] = T.splitOn "," t2  -- ["你好", "Haskell"],按逗号分割
t21 :: [Text] = T.split (== 'l') t1  -- ["he", "", "o"],按字符分割(空串保留)
t22 :: [Text] = T.words t1  -- ["hello"],按空白字符分割(自动过滤多余空格)
t23 :: [Text] = T.lines "hello\nworld"  -- ["hello", "world"],按换行符分割

-- 8. 长度/空判断:专属操作,语义清晰且高效
t24 :: Int = T.length t1  -- 5,Text长度(按Char计数,Unicode兼容)
t25 :: Bool = T.null t4  -- True,判断空Text
t26 :: Bool = T.all T.isAlpha t1  -- True,判断所有字符均为字母

-- 9. String与Text显式转换(互补性体现,按需切换)
sFromText :: String = T.unpack t1  -- Text -> String,复用列表操作时使用
tFromStr :: Text = T.pack "test"   -- String -> Text,需要高效操作时使用

-- 10. 其他高频生产级操作(贴合工程需求,String无原生支持)
-- 去除前后空白字符(常用于用户输入处理)
t27 :: Text = T.strip "  hello haskell  "  -- "hello haskell"
t28 :: Text = T.stripStart "  hello"       -- "hello"(仅去除开头空白)
t29 :: Text = T.stripEnd "hello  "         -- "hello"(仅去除结尾空白)

-- 重复操作(生成新Text,不可变)
t30 :: Text = T.replicate 3 t1  -- "hellohellohello"

-- 字符统计(统计指定字符出现次数)
t31 :: Int = T.count "l" t1  -- 2,统计'l'在t1中出现的次数

String 与 Text 的显式转换

String 与 Text 作为 Haskell 核心字符串类型,二者的显式转换是实现“便捷与效率兼顾”的关键,转换过程严格遵循不可变性(转换后生成新值,不修改原始 String/Text),贴合函数式通用抽象思想,所有转换均需通过专属函数实现(禁止隐式转换,保证类型安全)。

(1)转换核心函数

  • T.pack :: String -> Text:将 String 转换为 Text,用于“需要高效操作、编码安全”的场景(如生产级文本处理),转换过程会自动校验 Unicode 有效性(避免无效字符);
  • T.unpack :: Text -> String:将 Text 转换为 String,用于“需要复用列表操作、生成无限序列”的场景(如快速测试、复杂列表组合操作);
  • 辅助转换:T.fromStrict/T.toStrict(与严格字节数组转换,进一步提升效率)、T.fromLazy/T.toLazy(与惰性 Text 转换,适配惰性场景),适合更精细的性能优化。

(2)转换场景示例

import qualified Data.Text as T
import Data.List (intercalate)  -- 列表拼接(String复用列表操作)

-- 场景1:String(便捷列表操作)→ Text(高效拼接/分割)
-- 步骤:1. 用String复用列表intercalate操作拼接字符串;2. 转换为Text做高效分割
strList :: [String] = ["apple", "banana", "orange"]
strConcat :: String = intercalate "," strList  -- "apple,banana,orange"(复用列表操作)
textSplit :: [Text] = T.splitOn "," (T.pack strConcat)  -- 高效分割,["apple","banana","orange"]

-- 场景2:Text(高效处理)→ String(惰性无限序列)
-- 步骤:1. Text高效处理短文本;2. 转换为String生成无限重复序列
textShort :: Text = T.pack "ab"
strInf :: String = cycle (T.unpack textShort)  -- 无限重复"ab""abababab..."(惰性适配)
testInf2 :: String = take 10 strInf  -- "ababababab"(按需计算,无内存浪费)

-- 场景3:多类型协同(Text高效替换 + String列表过滤)
textOrigin :: Text = T.pack "hello123world456"
-- Text高效替换数字为空字符串
textFilterNum :: Text = T.replace "123" "" $ T.replace "456" "" textOrigin  -- "helloworld"
-- 转换为String,复用列表filter过滤非字母(演示协同,实际可直接用T.filter)
strFilterAlpha :: String = filter (`elem` ['a'..'z']) (T.unpack textFilterNum)  -- "helloworld"

-- 注意:禁止隐式转换(编译报错示例)
-- errorExample :: Text = "hello"  -- 报错:String无法隐式转换为Text
-- errorExample2 :: String = T.pack "hello"  -- 报错:Text无法隐式转换为String

(3)转换设计思想与函数式特点

  • 设计思想:显式转换而非隐式转换,保证类型安全(避免 String 与 Text 混用导致的类型错误);转换函数轻量化,不引入额外副作用,贴合不可变性原则;通过转换实现二者优势互补,既保留 String 的便捷性与惰性适配,又发挥 Text 的高效性与编码安全性,体现 Haskell“按需设计、灵活协同”的核心思想;
  • 体现的函数式特点: 体现「无副作用」:转换过程生成新值(新 String/新 Text),不修改原始值,转换输出仅由输入决定,无状态依赖;
  • 体现「通用抽象与组合式编程」:转换函数作为“桥梁”,实现两种字符串类型的协同,让不同场景的操作可组合实现(如列表操作+高效分割),贴合函数式“拆分-组合”的核心思维;
  • 体现「类型安全」:显式转换强制开发者区分 String 与 Text,避免隐式转换导致的类型混乱,编译期即可暴露转换相关的类型错误;
  • 体现「实用主义抽象」:转换操作不追求“过度抽象”,而是聚焦工程实践需求,通过简单的函数实现两种类型的切换,兼顾抽象性与实用性,让开发者可按需选择最优方案。

Flutter-实现Tabs吸顶的PageView效果

作者 鹏多多
2026年2月2日 10:51

1. 效果预览

在 Flutter 开发中,创建具有吸顶 Tabs 的 PageView 效果可以极大地提升用户界面的交互性和用户体验。今天,我们就通过一段具体的代码来深入了解如何实现这一功能。效果预览如下:

预览图

2. 结构分析

我们从整体上看这段代码,它定义了一个名为CeilingTabsPageView的有状态组件。这个组件的作用就是构建出一个带有吸顶 Tabs 的页面,用户可以通过滑动 PageView 在不同的页面内容间切换。

  1. 引入必要的库
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';

代码开头引入了两个重要的库。

  • package:flutter/material.dart:Flutter 的核心 UI库,它提供了各种构建用户界面的基本组件和工具,比如我们后续会用到的Container、Row、Text等。

  • package:extended_nested_scroll_view/extended_nested_scroll_view.dart:为我们实现吸顶效果提供了关键支持,ExtendedNestedScrollView这个特殊的组件就来自于它。

  1. 定义CeilingTabsPageView组件
class CeilingTabsPageView extends StatefulWidget {
  const CeilingTabsPageView({Key? key}) : super(key: key);

  @override
  State<CeilingTabsPageView> createState() => CeilingTabsPageViewState();
}

这里定义了CeilingTabsPageView组件,它是一个有状态的组件。有状态组件意味着它在运行过程中可以根据用户操作或者其他事件改变自身状态。而createState方法返回了CeilingTabsPageViewState实例,这个实例负责管理组件的状态和构建具体的 UI。

  1. CeilingTabsPageViewState类的详细解析
class CeilingTabsPageViewState extends State<CeilingTabsPageView> {
/// 控制器
late PageController _pageController;

int pageIndex = 0;

 /// 字体样式
TextStyle myTextStyle = const TextStyle(
      color: Colors.white, fontWeight: FontWeight.w600, fontSize: 20);

  /// 生命周期
  @override
  void initState() {
    super.initState();
    _pageController = PageController(initialPage: pageIndex);
  }

 /// 页面滑动回调
  void handlePageChange(int index) {
    setState(() {
      pageIndex = index;
    });
  }
 
/// Tabs点击
  void handleTabClick(int index) {
    setState(() {
      pageIndex = index;
      _pageController.jumpToPage(index); // 直接跳转至指定页面
    });
  }
  • 生命周期方法:initState方法在组件首次插入到 Widget 树时调用,在这里我们只是简单地调用了父类的initState方法,暂时没有额外的初始化操作,但它为我们后续可能需要的初始化工作提供了位置。
  • 状态变量:pageIndex用于记录当前 PageView 显示的页面索引,初始值为 0,表示默认显示第一个页面。
  • 控制器:_pageController是PageView的控制器
  • 字体样式定义:myTextStyle定义了一种字体样式,包括白色字体颜色、中等加粗的字重和 20 的字体大小,后续在多个文本组件中会使用到这个样式。
  • 页面滑动回调函数:当 PageView 发生滑动时,handlePageChange函数会被调用。它通过setState方法来更新pageIndex的值,setState方法会触发组件的重新构建,从而确保 UI 能够反映出页面索引的变化。
  • Tabs点击:点击Tabs的回调函数
  1. 构建 UI 的核心方法
@override
Widget build(BuildContext context) {
  /// 最大宽度
  double maxW = MediaQuery.of(context).size.width;

  /// 最大高度
  double maxH = MediaQuery.of(context).size.height;

  return SizedBox(
    width: maxW,
    height: maxH,
    child: ExtendedNestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return [
            SliverToBoxAdapter(
                child: Column(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [bannerWidget(maxW), tabsWidget(maxW)]))
          ];
        },
        // 需要固定吸顶的高度
        pinnedHeaderSliverHeightBuilder: () {
          return 40;
        },
        onlyOneScrollInBody: true,
        body: SizedBox(
          width: maxW,
          height: maxH,
          child: pageViewWidget(maxW, maxH),
        )),
  );
}
  • 获取屏幕尺寸:通过MediaQuery.of(context).size.width和MediaQuery.of(context).size.height获取当前设备屏幕的宽度maxW和高度maxH,这两个值对于构建适配不同屏幕尺寸的 UI 非常重要。
  • 使用ExtendedNestedScrollView:这是实现吸顶效果的关键组件。
  • headerSliverBuilder:这个回调函数用于构建顶部的内容。它返回一个包含SliverToBoxAdapter的列表,SliverToBoxAdapter又包含了一个Column,Column中依次排列着bannerWidget和tabsWidget。这就定义了顶部的布局结构,先显示一个 Banner,再显示 Tabs。
  • pinnedHeaderSliverHeightBuilder:这个回调函数指定了需要固定吸顶的高度为 40。也就是说,tabsWidget部分会在用户滚动页面时固定在顶部,不会随着页面内容一起滚动。
  • onlyOneScrollInBody:设置为true表示在页面主体部分只允许一个滚动行为,避免了滚动冲突。 body:这里设置页面的主体内容为pageViewWidget,也就是我们的 PageView 部分。
  1. 各个部件的构建方法

bannerWidget

Widget bannerWidget(double maxW) {
  return Container(
      width: maxW,
      height: 200,
      alignment: Alignment.center,
      color: Colors.red.shade300,
      child: Text('Banner', style: myTextStyle));
}

这个方法构建了一个Container作为 Banner。它的宽度为屏幕宽度maxW,高度为 200,背景颜色为浅红色(Colors.red.shade300),并且在容器中心显示了 “Banner” 字样,使用之前定义好的myTextStyle字体样式。

tabsWidget

Widget tabsWidget(double maxW) {
  return Container(
    width: maxW,
    height: 40,
    color: Colors.blue.shade400,
    child: Row(
      children: [
        Expanded(
            child: GestureDetector(
              onTap: () {
                handleTabClick(0);
              },
              child: Container(
                alignment: Alignment.center,
                child: Text('Tab 1', style: myTextStyle),
              ),
            ),
          ),
          Expanded(
            child: GestureDetector(
              onTap: () {
                handleTabClick(1);
              },
              child: Container(
                alignment: Alignment.center,
                child: Text('Tab 2', style: myTextStyle),
              ),
            ),
          )
      ],
    ),
  );
}

tabsWidget构建了 Tabs 部分。同样是一个宽度为屏幕宽度maxW、高度为 40 的Container,背景颜色为浅蓝色(Colors.blue.shade400)。在这个容器内部,通过Row布局将空间分为两部分,每部分都包含一个Expanded包裹的Container,分别显示 “Tab 1” 和 “Tab 2”,同样使用myTextStyle字体样式。Expanded组件的作用是让两个 Tab 平分容器的宽度。并且添加了GestureDetector来处理点击事件。

pageViewWidget

Widget pageViewWidget(double maxW, double maxH) {
  return SingleChildScrollView(
      primary: true,
      physics: const BouncingScrollPhysics(),
      child: SizedBox(
        width: maxW,
        height: maxH,
        child: PageView(
        controller: _pageController,
            onPageChanged: (index) {
              setState(() {
                pageIndex = index;
              });
            },
          children: [
            Container(
                width: maxW,
                height: 1000,
                color: Colors.amberAccent,
                alignment: Alignment.topCenter,
                child: Text('Page1', style: myTextStyle)),
            Container(
                width: maxW,
                height: 1000,
                color: Colors.deepPurpleAccent,
                alignment: Alignment.topCenter,
                child: Text('Page2', style: myTextStyle))
          ],
        ),
      ));
}

pageViewWidget构建了 PageView。它被包裹在SingleChildScrollView中,设置primary为true表示这是主要的滚动视图,physics设置为BouncingScrollPhysics以实现类似于 iOS 的弹性滚动效果。在SizedBox内部是一个PageView,包含两个页面,每个页面都是一个宽度为屏幕宽度maxW、高度为 1000 的Container,分别显示 “Page1” 和 “Page2”,背景颜色也各不相同,同样使用myTextStyle字体样式。并且把控制器绑定上,添加了onPageChanged回调事件。

3. 完整代码

  • main.dart
const CeilingTabsPageView()
  • ceilingTabsPageView.dart
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';

/// 吸顶Tabs的PageView
class CeilingTabsPageView extends StatefulWidget {
  const CeilingTabsPageView({Key? key}) : super(key: key);

  @override
  State<CeilingTabsPageView> createState() => CeilingTabsPageViewState();
}

class CeilingTabsPageViewState extends State<CeilingTabsPageView> {
  late PageController _pageController;

  int pageIndex = 0;

  /// 字体样式
  TextStyle myTextStyle = const TextStyle(
      color: Colors.white, fontWeight: FontWeight.w600, fontSize: 20);

  /// 生命周期
  @override
  void initState() {
    super.initState();
    _pageController = PageController(initialPage: pageIndex);
  }

  /// 页面滑动回调
  void handlePageChange(int index) {
    setState(() {
      pageIndex = index;
    });
  }

  /// Tabs点击
  void handleTabClick(int index) {
    setState(() {
      pageIndex = index;
      _pageController.jumpToPage(index); // 直接跳转至指定页面
    });
  }

  /// 构建UI
  @override
  Widget build(BuildContext context) {
    /// 最大宽度
    double maxW = MediaQuery.of(context).size.width;

    /// 最大高度
    double maxH = MediaQuery.of(context).size.height;

    return SizedBox(
      width: maxW,
      height: maxH,
      child: ExtendedNestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return [
              SliverToBoxAdapter(
                  child: Column(
                      crossAxisAlignment: CrossAxisAlignment.center,
                      children: [bannerWidget(maxW), tabsWidget(maxW)]))
            ];
          },
          // 需要固定吸顶的高度
          pinnedHeaderSliverHeightBuilder: () {
            return 40;
          },
          onlyOneScrollInBody: true,
          body: SizedBox(
            width: maxW,
            height: maxH,
            child: pageViewWidget(maxW, maxH),
          )),
    );
  }

  /// Banner部件
  Widget bannerWidget(double maxW) {
    return Container(
        width: maxW,
        height: 200,
        alignment: Alignment.center,
        color: Colors.red.shade300,
        child: Text('Banner', style: myTextStyle));
  }

  /// Tabs部件
  Widget tabsWidget(double maxW) {
    return Container(
      width: maxW,
      height: 40,
      color: Colors.blue.shade400,
      child: Row(
        children: [
          Expanded(
            child: GestureDetector(
              onTap: () {
                handleTabClick(0);
              },
              child: Container(
                alignment: Alignment.center,
                child: Text('Tab 1', style: myTextStyle),
              ),
            ),
          ),
          Expanded(
            child: GestureDetector(
              onTap: () {
                handleTabClick(1);
              },
              child: Container(
                alignment: Alignment.center,
                child: Text('Tab 2', style: myTextStyle),
              ),
            ),
          )
        ],
      ),
    );
  }

  /// pageView部件
  Widget pageViewWidget(double maxW, double maxH) {
    return SingleChildScrollView(
        primary: true,
        physics: const BouncingScrollPhysics(),
        child: SizedBox(
          width: maxW,
          height: maxH,
          child: PageView(
            controller: _pageController,
            onPageChanged: (index) {
              setState(() {
                pageIndex = index;
              });
            },
            children: [
              Container(
                  width: maxW,
                  height: 1000,
                  color: Colors.amberAccent,
                  alignment: Alignment.topCenter,
                  child: Text('Page1', style: myTextStyle)),
              Container(
                  width: maxW,
                  height: 1000,
                  color: Colors.deepPurpleAccent,
                  alignment: Alignment.topCenter,
                  child: Text('Page2', style: myTextStyle))
            ],
          ),
        ));
  }
}

4. 总结

通过这段代码,我们成功地在 Flutter 中实现了一个具有吸顶 Tabs 的 PageView 效果。从引入必要的库,到定义组件和管理状态,再到构建具体的 UI 部件,每一步都紧密配合。ExtendedNestedScrollView组件的使用是实现吸顶效果的核心,而各个部件的合理布局和样式设置则让整个页面看起来更加美观和易于交互。

希望这篇文章能帮助你理解并在自己的 Flutter 项目中运用类似的功能。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

uniapp 手机端 与浏览器端 图片url 地址解析不一致

作者 lemon_yyds
2026年2月2日 10:41

项目描述

用uniapp 进行开发的时候,在浏览器调试,一个图片展示
主要是接口返回了图片的部分路径 ,/static/home.jpg 这样的格式,
前端进行baseurl + 'xxxx/xxx/xxxx.jpg' 拼接

浏览器加载的时候,图片会无法加载

image.png

UniApp 或者一般的移动端/H5 开发中,将相对路径手动拼接上 http:// 开头,主要会引发以下几个严重问题:

1. 混合内容拦截 (Mixed Content) - 最主要的问题

这是目前最常见且最致命的问题,尤其是在 iOS 和 微信小程序 中。

  • 现象:图片在开发工具中能显示,但在手机真机上不显示,或者请求直接失败。

  • 原因

    • 现在的 App(包括微信、浏览器)为了安全,默认都强制要求使用 HTTPS 协议。
    • 如果你的页面是通过 HTTPS 加载的(比如 https://yourdomain.com),而你图片的地址是 HTTP 开头的,浏览器或 WebView 会认为这是不安全的内容,从而自动拦截该图片的加载13。
    • 特别注意:iOS 系统(特别是 iOS 18+)对 WebKit 的安全策略收紧,会自动将 HTTP 图片资源重定向为 HTTPS 访问,如果目标服务器不支持 HTTPS,图片就会加载失败。

2. 域名解析错误 (拼接逻辑 Bug)

你提到“拼接”,这通常意味着代码逻辑。如果逻辑处理不当,很容易产生错误的 URL。

  • 双协议头:如果后端返回的“相对地址”其实已经包含了 http://,而你的代码没做判断又强行拼接了一次,就会变成 http://http://xxx,导致域名解析失败(如你之前遇到的 ERR_NAME_NOT_RESOLVED)28。
  • 缺少主机名:有时候“相对地址”是指 /static/img/xxx.png,如果你只拼接了 http:// 而忘了拼接具体的域名(如 http://yourdomain.com/static/...),请求会指向你当前页面的域名,导致 404。

3. 平台兼容性差异

  • H5 端:浏览器对 HTTP 图片的拦截越来越严格,很多现代浏览器(Chrome, Safari)会直接屏蔽。
  • App 端 (Android) :虽然 Android 系统允许加载 HTTP,但如果你的 targetSdkVersion 较高,或者应用配置了网络安全策略(Network Security Config),也会禁止明文流量(Cleartext Traffic)4。
  • 小程序端:微信小程序要求全站 HTTPS,任何 HTTP 资源在真机上都会被拦截,必须配置下载域名或使用 HTTPS。

最佳实践建议

  1. 统一使用 HTTPS

    • 不要手动拼接 http://,直接让后端返回完整的 https:// 开头的 URL。
    • 或者在前端配置 CDN 地址时,直接使用 https://cdn.yourdomain.com
  2. 使用协议相对路径 (慎用)

    • 如果你必须手动拼接,可以使用 //yourdomain.com/static/xxx.png。这样浏览器会自动继承当前页面的协议(HTTPS 或 HTTP)。
    • 注意:在 App 端(file:// 协议)使用这种写法可能会有问题,建议仅在 H5 端使用。
  3. 后端统一处理

    • 最好的方案是让后端在返回图片路径时,直接带上完整的、正确的协议头(HTTPS),前端只负责展示,不要负责拼接协议。

总结:  尽量避免在代码里硬编码 http://,这不仅有安全风险,还会导致真机兼容性问题。请尽量使用 HTTPS 或者让后端返回完整路径。

全栈开发者的谎言:什么都会 = 什么都不精?

作者 ErpanOmer
2026年2月2日 10:33

Full-stack-web-developer.png

上周面了一个自称5年全栈的兄弟🤔。

简历漂亮得像报菜名:精通 Vue/React,熟悉 Node.js/Go,玩过 K8s,能画原型图,甚至还写过两个 Flutter App。 我只问了一个问题:如果不使用任何框架,Node.js 的 HTTP 模块是如何处理高并发下的内存积压的?

他愣了三秒,支支吾吾说:厄...一般我们都用 NestJS,框架处理好了吧?😖

那一刻,我看到了无数前端人的缩影:我们拼命想成为无所不能的全栈大神,最后却活成了什么都懂一点、什么都搞不定的API 缝合怪。


全栈的本质

你要知道,全栈工程师(Full Stack Engineer)这个词,最开始是谁捧红的? 是硅谷的创业公司。

为什么?因为没钱。 他们招不起一个前端专家 + 一个后端专家 + 一个运维专家。他们需要一个性价比极高的耗材,一个人把这三个坑都填了。

于是,招聘 JD 画风突变:

25K,招全栈。要求精通 React、Node.js、MySQL、Docker、AWS...

你看似拿了比纯前端高 20% 的工资,干的却是 3 个人的活。你的大脑需要在 CSS 的 z-index 和 MySQL 的 Transaction Isolation Level 之间疯狂切换。

结果是什么? 你的认知被彻底击穿。

你以为你的认知,什么场景都能用。 但在真正的技术攻坚战里,什么都不是。🥱


所谓的全栈,大多是全沾

我见过太多这种虚假全栈的代码了,简直是灾难现场。

他们写后端,思维还是前端那一套:

  • 数据库设计:没有范式概念,一张表 50 个字段,全是 JSON 字符串。
  • 错误处理try-catch 包住整个 API,报错全返 200 OK,msg 里写 bug。
  • 并发安全:在 for 循环里 await 查库,完全不懂什么是连接池耗尽。

让我们看一段典型的前端思维写后端的死代码:

// 典型的假全栈代码
// 以为用了 async/await 就是后端大神了
router.post('/buy', async (req, res) => {
    // 1. 先查库存(没有锁,并发一来直接超卖)
    const stock = await db.query(`SELECT count FROM products WHERE id=${req.body.id}`);
    
    if (stock > 0) {
        // 2. 扣库存(中间如果服务挂了,数据不一致)
        await db.query(`UPDATE products SET count = count - 1 WHERE id=${req.body.id}`);
        // 3. 创建订单
        await db.query(`INSERT INTO orders ...`);
        return res.json({ success: true });
    }
});

这种代码,稍微有点后端经验的人看了都会心肌梗塞。但在全栈眼里:跑通了啊,没报错啊!

什么都会 = 什么都不精。 你以为你拓宽了广度,其实你牺牲了深度。在裁员潮来临时,公司是会留一个能解决复杂内存泄漏的 Node 专家,还是留一个既能写页面又能写增删改查,但稍微上点量就崩服务的万金油

在我们国内,答案是极其残酷的。


T 型人才的骗局

很多人反驳:我要做 T 型人才,一专多能!

理想很丰满,现实是绝大多数人做成了 一型人才 —— 横向铺得无限开,纵向深度为零。

  • 学了 Docker,只会 docker run,不懂 Cgroup 原理。
  • 学了 React,只会 useEffect,不懂 Fiber 调度。
  • 学了 Rust,只会写 Hello World,借用检查器都过不去。
  • 学了 SQLite, 只会增删改查,不懂什么叫锁,什么叫性能优化

这种简历驱动型学习产生的知识,极其脆弱。 一旦遇到深水区的 Bug,你的全栈光环瞬间破碎,只能去 AI Chat 复制粘贴,然后祈祷奇迹发生。

真正的全栈 是你能从前端的一个点击事件(Click),一路追踪到内核的系统调用(Syscall),这中间的每一层你都可控。 如果你做不到,那你充其量只是一个全栈水货。😥


请你先成为单栈战神

人的精力是有限的。在 35 岁危机到来之前,请功利一点,聚焦一点。

如果你是前端: 别急着去学 Go,别急着去搞 K8s。 先把浏览器渲染原理吃透,把 V8 垃圾回收搞懂,把图形学(WebGL/Canvas)啃下来。 当你在一个领域钻得足够深,深到能解决 99% 人解决不了的问题时,你才有资格去谈横向扩展。

这时候的扩展,不是为了凑简历,而是为了解决问题。

  • 学 Node.js,是因为前端构建工具跑得太慢,你需要深入 OS 层优化 I/O。
  • 学 Rust,是因为 JS 在计算密集型任务上拉胯,你需要 WASM 来救场。

这才是全栈的正确打开方式:降维打击。


别再用全栈来标榜自己了。 在这个分工日益精细化的时代,专家永远比杂家值钱。

专注你的赛道,把它做到极致。 那才是你不可被替代的根本。

大家怎么看🤔

Suggestion (3).gif

RxJS 和 Interceptor 又是什么?

作者 前端付豪
2026年2月2日 10:33

RxJS 是一个组织异步逻辑的库,可简化异步逻辑和回调的编写

image.png

Nest 的 interceptor 集成了 RxJS,可以用它来处理响应

新建项目看看

nest new interceptor-rxjs -p npm

新建

nest g interceptor aaa --flat --no-spec

记录接口时间


import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class AaaInterceptor implements NestInterceptor {

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => console.log(`Using... ${Date.now() - now}ms`)),
      );
  }
}

使用

image.png

访问http://localhost:3000/

image.png

这是 interceptor 最基本使用

使用下 RxJS operator

map

nest g interceptor map-test --flat --no-spec

使用 map operator 来对 controller 返回的数据做一些修改

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class MapTestInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(map(data => {
      return {
        code: 200,
        message: 'success',
        data
      }
    }))
  }
}

controller 中使用

image.png

image.png

tap

nest g interceptor tap-test --flat --no-spec

使用 tap operator 来添加一些日志、缓存等逻辑

import { AppService } from './app.service';
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';

@Injectable()
export class TapTestInterceptor implements NestInterceptor {
  constructor(private appService: AppService) {}

  private readonly logger = new Logger(TapTestInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(tap((data) => {
      
      // 这里是更新缓存的操作,这里模拟下
      this.appService.getHello();

      this.logger.log(`log log log`, data);
    }))
  }
}

使用

image.png

image.png

catchError

controller 里很可能会抛出错误,这些错误会被 exception filter 处理,返回不同的响应,在那之前,可以在 interceptor 先处理下

nest g interceptor catch-error-test --flat --no-spec

更新下

import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { catchError, Observable, throwError } from 'rxjs';

@Injectable()
export class CatchErrorTestInterceptor implements NestInterceptor {
  private readonly logger = new Logger(CatchErrorTestInterceptor.name)

  intercept (context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(catchError(err => {
      this.logger.error(err.message, err.stack)
      return throwError(() => err)
    }))
  }
}

使用

image.png

image.png

错误打印

image.png

还有一次错误打印

image.png

一次是在 interceptor 里打印的,一次是 exception filter 打印

timeout

接口如果长时间没返回,要给用户一个接口超时的响应,可以用 timeout operator

nest g interceptor timeout --flat --no-spec

更新

import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common';
import { catchError, Observable, throwError, timeout, TimeoutError } from 'rxjs';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(3000),
      catchError(err => {
        if(err instanceof TimeoutError) {
          console.log(err);
          return throwError(() => new RequestTimeoutException());
        }
        return throwError(() => err);
      })
    )
  }
}

会在 3s 没收到消息的时候抛一个 TimeoutError。

然后用 catchError 处理,如果是 TimeoutError,就返回 RequestTimeoutException,这个有内置的 exception filter 会处理成对应的响应格式。

其余错误就直接 throw Error 抛出去

使用

image.png

image.png

此处处理

image.png

可以换一个试试

image.png

image.png

再试试 全局的 interceptor

image.png

这种是手动 new 的,没法注入依赖

但很多情况下我们是需要全局 interceptor 的,而且还用到一些 provider,怎么办呢?

nest 提供了一个 token,用这个 token 在 AppModule 里声明的 interceptor,Nest 会把它作为全局 interceptor

image.png

在这个 interceptor 里注入了 appService

image.png

image.png

可以看到全局 interceptor 生效了,而且这个 hello world 就是注入的 appService 返回的

image.png

选择器与优先级:为啥我的样式不生效?(附个人踩坑分享)

作者 VixenAhri
2026年2月2日 10:21

选择器与优先级:为啥我的样式不生效?(附个人踩坑分享)

刚开始写CSS时,我经常被“样式不生效”困扰,明明代码逻辑没问题,页面却毫无反应,排查许久才发现根源。总结下来,90%的问题集中在两点:要么选择器未精准匹配目标元素,要么优先级被其他样式覆盖。今天就把我整理的选择器与优先级干货、踩坑经验分享给大家,帮大家少走弯路,快速排查样式问题。

一、基础选择器:入门必备4种,避坑优先

/* 通配符:匹配所有元素,优先级最低 */
* { margin: 0; padding: 0; }

/* 元素选择器:直接匹配指定HTML标签,简洁高效 */
p { color: #333; }
div { font-size: 16px; }

/* 类选择器:可复用性强,多个元素可共用一个类 */
.btn { padding: 8px 16px; }
.btn-primary { background: blue; }

/* ID 选择器:页面唯一,一个ID仅能使用一次 */
#header { height: 60px; }

/* 属性选择器:灵活匹配,按元素属性定位 */
[type="text"] { border: 1px solid #ccc; }
[href^="https"] { color: green; }   /* 以 https 开头,适配外部链接 */
[href$=".pdf"] { color: red; }      /* 以 .pdf 结尾,适配下载链接 */
[class*="btn"] { cursor: pointer; } /* 包含 btn 字符串,匹配所有按钮类元素 */
[data-state="active"] { font-weight: bold; } /* 自定义属性,便捷控制元素状态 */

属性选择器小技巧:分享一个实用的符号记忆方法,掌握这几个就能满足日常开发——^ 匹配属性值开头、$ 匹配属性值结尾、* 匹配属性值包含指定字符、~ 匹配空格分隔的单词、| 匹配连字符前缀。我平时做表单状态控制、链接类型区分时,经常用到这些,亲测实用且不易踩坑。

二、组合选择器:精准定位,告别无效匹配(个人踩坑总结)

/* 后代选择器:匹配父元素下所有层级的目标子元素 */
.card p { margin: 0 0 8px; }

/* 子选择器:仅匹配父元素的直接子元素,不包含孙级及以下 */
.menu > li { display: inline-block; }

/* 相邻兄弟选择器:匹配目标元素相邻的第一个同层级兄弟元素 */
h2 + p { margin-top: 0; }

/* 通用兄弟选择器:匹配目标元素后面所有同层级兄弟元素 */
h2 ~ p { color: #666; }

/* 多选择器:逗号分隔,一次性给多个元素设置相同样式,提升效率 */
h1, h2, h3 { font-weight: 600; }

后代 vs 子选择器:我曾混淆的关键点:刚开始使用时,我经常混淆这两种选择器,踩过几次坑后才彻底分清。div p 会匹配div下所有层级的p标签,无论嵌套多深;div > p 仅匹配div的直接子元素p,孙级及以下的p标签不会被选中。现在我会根据层级需求选择:层级较深时用后代选择器,对结构层级有严格要求时用子选择器,避免多余匹配浪费性能。

三、伪类选择器:控制元素状态,提升交互体验(日常开发高频使用)

/* 链接伪类:顺序固定为LVHA(link→visited→hover→active),否则易出bug */
a:link { color: blue; }    /* 未访问链接,初始状态 */
a:visited { color: purple; } /* 已访问链接,区分状态 */
a:hover { color: darkblue; } /* 鼠标悬浮状态,提升交互反馈 */
a:active { color: red; }   /* 鼠标点击瞬间,激活状态 */

/* 结构伪类:按元素位置定位,无需额外添加类名 */
li:first-child { font-weight: bold; } /* 父元素第一个子元素,突出重点 */
li:last-child { border-bottom: none; } /* 父元素最后一个子元素,清除多余边框 */
li:nth-child(2) { background: #f5f5f5; } /* 父元素第2个子元素,精准定位 */
li:nth-child(odd) { background: #fff; } /* 奇数位元素,实现隔行变色 */
li:nth-child(3n) { color: blue; } /* 3的倍数位元素,批量设置样式 */
li:nth-child(3n+1) { color: red; } /* 3的倍数+1位元素,灵活搭配使用 */
li:nth-of-type(2) { }  /* 同类型元素中的第2个,仅匹配指定标签类型 */

/* 表单伪类:掌控表单各种状态,优化用户体验 */
input:focus { outline: 2px solid blue; } /* 获得焦点,高亮提示 */
input:disabled { opacity: 0.5; } /* 禁用状态,视觉上区分,避免误导用户 */
input:checked { accent-color: blue; } /* 复选/单选选中状态,颜色区分更清晰 */
input:placeholder-shown { color: #999; } /* 未输入时,占位符浅灰色更协调 */

/* 其他常用伪类:虽不常用,但关键时刻能高效解决问题 */
:empty { display: none; } /* 空元素直接隐藏,避免占位尴尬 */
:target { scroll-margin-top: 60px; }  /* 锚点跳转,适配导航栏不遮挡内容 */
:not(.active) { opacity: 0.7; } /* 排除.active类元素,批量设置非激活状态 */

nth-child 公式:个人常用简化版本:核心语法是 an + b,n从0开始递增,无需死记硬背,我整理了几个日常高频使用的版本,直接套用即可——2n 对应偶数位、2n+1 对应奇数位、-n+3 对应前3个元素、3n 对应3的倍数位,高效又省心。

新手踩坑点:我曾栽过的关键错误:重点提醒大家,nth-child是按父元素所有子元素计数,并非仅统计同一种类型的元素!比如父元素包含p、span、p三个子元素,div p:nth-child(2) 会失效(因为父元素第2个子元素是span),此时换成 nth-of-type(2) 即可,它会仅统计p标签类型的元素。

四、伪元素选择器:装饰元素神器,无需额外添加DOM(个人常用技巧)

/* ::before / ::after 注意事项:必须添加content属性,否则无法显示 */
.quote::before {
  content: """";
  font-size: 2em;
  color: #ccc;
}
.quote::after {
  content: """ ";
}

/* 首行、首字母伪元素:优化文本排版,提升页面格调 */
p::first-line { font-weight: bold; } /* 文本第一行,突出重点内容 */
p::first-letter { font-size: 2em; float: left; margin-right: 4px; } /* 首字母放大,优化排版 */

/* 选中文本伪元素:自定义选中文本样式,替代浏览器默认样式 */
::selection {
  background: #b3d4fc;
  color: #000;
}

/* 占位符伪元素:统一占位符颜色,保持页面风格一致 */
input::placeholder { color: #999; }

伪元素关键提醒:个人总结3点核心:掌握这3点,基本不会踩坑!1. CSS3规范中,伪元素使用双冒号 ::,单冒号是老版本兼容写法,建议使用双冒号,可清晰区分伪类与伪元素;2. ::before::after 会生成一个行内元素,必须添加 content 属性(哪怕是空值 content: ""),否则会默认隐藏,我曾因漏写content浪费了半小时排查;3. 伪元素无法被鼠标选中,适合用于页面装饰,实用性极强。

五、优先级计算:样式生效的核心(个人踩坑最多的部分)

样式能否生效,核心取决于优先级高低!刚开始我始终搞不懂优先级逻辑,写的样式经常被覆盖,后来总结出一套简单易懂的计算方法:按 (a, b, c, d) 四位数值计算,高位优先对比,无需累加求和,高位数值越大,样式优先级越高,越容易生效。分享我日常记忆的规则:

  • a:行内样式(style属性),优先级最高,我平时尽量不用,避免后续维护困难
  • b:ID选择器(#xxx),优先级仅次于行内样式,我很少用ID编写样式,因其优先级过高,后续难以覆盖
  • c:类选择器(.xxx)、属性选择器、伪类选择器,优先级居中,是我日常开发中使用最多的类型
  • d:元素选择器、伪元素选择器,优先级较低,适合编写全局基础样式
  • 通配符选择器(*):优先级最低,数值为(0,0,0,0),可被任意其他选择器覆盖,适合用于全局样式初始化
* { }           /* 0,0,0,0 → 优先级最低,可被任意选择器覆盖 */
p { }           /* 0,0,0,1 → 元素选择器,基础优先级 */
.btn { }        /* 0,0,1,0 → 类选择器,优先级高于元素选择器 */
#header { }     /* 0,1,0,0 → ID选择器,优先级大幅提升 */
p.btn { }       /* 0,0,1,1 → 元素+类选择器,优先级叠加 */
#header .nav a { }  /* 0,1,2,1 → ID+2个类+元素选择器,优先级极高 */
style="color: red;"     /* 1,0,0,0 → 行内样式,优先级最高 */

特殊情况:!important(急救工具,谨慎使用)

在样式属性后添加 !important,可强制覆盖所有优先级(包括行内样式),相当于“紧急急救”功能。我刚接触时,曾过度依赖它,几乎所有样式都添加了!important,导致后续维护时,样式冲突难以排查,陷入崩溃。在此提醒大家,尽量谨慎使用!

/* 强制生效,除非其他样式也添加!important且优先级更高 */
.btn { color: red !important; }

!important 个人常用场景:结合日常开发经验,我仅在两种场景下使用!important:1. 覆盖第三方组件的行内样式(无法修改组件源码时,临时急救);2. 全局紧急样式(如网站维护提示、错误弹窗等,需强制显示);常规业务样式坚决不使用,避免埋下维护隐患。

同优先级规则:个人总结“后发制人”

若两个选择器的 (a,b,c,d) 数值完全一致,遵循“后发制人”原则——后编写的样式会覆盖先编写的样式;若引入多个CSS文件,后引入文件中同优先级的样式,会覆盖先引入文件中的样式。我曾因忽略编写顺序,修改许久样式仍不生效,这个坑大家一定要记牢。

优先级可视化示例:个人排查问题的参考

给大家分享一个可视化示例,同一个p标签被多个选择器命中,可直观看到最终生效的样式,我平时排查优先级问题时,经常用这种思路,新手可直接参考:

/* 1. 元素选择器(0,0,0,1)→ 优先级最低,被覆盖 */
p { color: red; }
/* 2. 类选择器(0,0,1,0)→ 优先级高于元素选择器,临时生效 */
.text { color: green; }
/* 3. ID选择器(0,1,0,0)→ 优先级高于类选择器,覆盖生效 */
#content { color: blue; }
/* 4. 行内样式(1,0,0,0)→ 优先级最高,最终生效 */
<p id="content" class="text" style="color: black;">测试文本</p>

优先级记忆口诀:个人整理,简单好记

分享一个我自己整理的记忆口诀,避免记混:行内第一,ID第二,类第三,元素第四;逐位比较,高位优先;同优先级,后写生效;!important 破一切,慎用为上!多念几遍就能记住,我现在日常开发仍会用到。

六、选择器最佳实践:避坑+高效(个人日常开发习惯)

/* 1. 尽量避免使用ID选择器,类选择器可复用性更强,更易维护 */
/* 不推荐:ID唯一不可复用,优先级过高,后续难以覆盖(个人踩坑经验) */
#btn-submit { padding: 8px 16px; }
/* 推荐:类选择器,可多元素共用,优先级适中,便于维护(当前常用写法) */
.btn-submit { padding: 8px 16px; }

/* 2. 避免编写过长选择器,层级超过3层,会增加维护成本和性能消耗 */
/* 不推荐:层级过深,可读性差,修改不便(早期冗余写法) */
.card .body .title .text { color: #333; }
/* 推荐:扁平化写法,使用规范类名,一目了然(当前简洁写法) */
.card__title-text { color: #333; }

/* 3. 采用BEM、OOCSS等类名规范,减少嵌套,提升协作效率 */
/* BEM规范:块(block)__元素(element)--修饰符(modifier),语义清晰 */
/* 我现在与团队协作时,均使用这种规范,避免类名混淆,提升开发效率 */
.card { /* 块 */ }
.card__title { /* 块内元素 */ }
.card--active { /* 块的修饰状态 */ }

/* 4. 巧用 :not() 伪类实现排除效果,减少多余样式编写 */
/* 不推荐:先添加样式再删除,冗余繁琐(早期笨写法) */
li { border-bottom: 1px solid #eee; }
li:last-child { border-bottom: none; }
/* 推荐:直接排除目标元素,简洁高效(当前优化写法) */
li:not(:last-child) { border-bottom: 1px solid #eee; }

/* 5. 用属性选择器替代多余类名,更灵活地控制元素状态 */
/* 不推荐:添加多个类名,冗余且不便维护(早期写法) */
.btn-loading { pointer-events: none; opacity: 0.7; }
/* 推荐:使用自定义属性,一句话控制所有对应状态(当前常用写法) */
[data-loading="true"] { pointer-events: none; opacity: 0.7; }

/* 6. 选择器性能优化:遵循从右向左匹配原则,精准定位元素 */
/* 不推荐:通配符+后代选择器,会遍历所有元素,性能较差(个人踩过性能坑) */
* .btn { color: #333; }
/* 推荐:直接定位目标元素,匹配速度更快,性能更优(当前优化写法) */
.btn { color: #333; }

七、CSS3+ 新增实用选择器:现代开发必备,提升效率(个人近期常用)

主流浏览器均支持这些新增选择器,我近期才熟练运用,掌握后能大幅提升开发效率,减少冗余代码,分享我日常的使用场景:

/* 1. :has() 父选择器(CSS4重磅更新,实现父元素随子元素状态变化) */
/* 场景:子元素.active激活时,给父元素.nav添加边框,无需JS控制 */
/* 我以前均用JS实现该效果,现在用:has()可直接省掉多余JS代码 */
.nav:has(.active) { border-left: 3px solid blue; }
/* 场景:输入框有内容时,父容器自动变色,提升用户体验 */
.form-group:has(input:not(:placeholder-shown)) { border-color: green; }

/* 2. :is() 选择器:简化多选择器编写,避免重复冗余 */
/* 不推荐:多个选择器重复编写,冗余繁琐(早期写法) */
h1 a, h2 a, h3 a, h4 a { color: blue; text-decoration: none; }
/* 推荐:用:is()一键简化,效果与上面一致,更简洁(当前常用写法) */
:is(h1, h2, h3, h4) a { color: blue; text-decoration: none; }

/* 3. :where() 选择器:简化写法的同时,保持低优先级,便于后续覆盖 */
/* 场景:编写通用样式,确保业务样式可轻松覆盖,无需添加!important */
/* 我现在编写通用组件样式时,均使用该选择器,降低后续维护成本 */
:where(.btn, .link, .tag) { color: #333; margin: 4px; }
/* 业务样式可直接覆盖,无任何压力 */
.btn-primary { color: #fff; background: blue; }

八、浏览器调试技巧:样式不生效?快速排查方法(个人万能技巧)

样式不生效时,无需慌乱,浏览器开发者工具是排查问题的关键。刚开始我不会使用调试工具,排查问题全靠猜测,效率极低,后来摸索出一套简单步骤,分享给大家,跟着操作就能快速找到问题:

  1. 打开浏览器(Chrome、Firefox均可),按F12调出开发者工具(记住快捷键,可大幅提升效率);
  2. 切换到「Elements」面板,点击左上角的选择器图标,选中样式失效的元素;
  3. 查看右侧「Styles」面板:被划掉的样式,说明被其他样式覆盖,鼠标悬停可查看覆盖它的样式;
  4. 切换到「Computed」面板,可查看元素最终生效的样式,同时能定位到该样式所在的CSS文件及具体行数;
  5. 可在「Styles」面板临时修改样式、添加!important测试,确认问题后再修改代码,高效且不易出错,我平时排查优先级问题,全靠这一步。

九、选择器速查表:新手必备,随用随查(个人整理)

选择器类型 示例 优先级 (a,b,c,d) 个人日常用途
通配符选择器 * {} (0,0,0,0) 全局样式初始化,清除元素默认边距(必备)
元素选择器 p {} (0,0,0,1) 设置全局标签默认样式,简洁直接
类选择器 .btn {} (0,0,1,0) 日常开发使用最多,可复用性强,便于维护
ID选择器 #header {} (0,1,0,0) 极少使用,仅用于页面唯一元素(如导航栏)
行内样式 style="" (1,0,0,0) 仅用于临时调试,不用于常规开发
伪类选择器 :hover {} (0,0,1,0) 控制元素交互状态(悬浮、选中),高频使用
伪元素选择器 ::before {} (0,0,0,1) 页面装饰,无需额外添加DOM,提升开发效率
属性选择器 [data-active="true"] {} (0,0,1,0) 控制元素状态、区分链接类型,灵活便捷

十、总结:个人开发心得分享

其实CSS选择器并不难,我从一开始频繁踩坑,到现在能熟练运用,核心就是掌握两点:精准匹配目标元素、合理控制样式优先级。选择器主要分为基础、组合、伪类、伪元素四类,再结合CSS3+新增的:has()、:is()、:where(),完全能满足日常开发需求。

优先级只需记住 (a,b,c,d) 四位计算规则即可:行内样式 > ID选择器 > 类/属性/伪类选择器 > 元素/伪元素选择器,同优先级遵循“后发制人”,!important 仅作为急救工具,切勿滥用(个人踩过深刻的维护坑)。

分享我的日常开发习惯:尽量编写扁平化样式、减少ID选择器的使用、多用类选择器、遵循规范的类名命名方式,配合浏览器调试工具,能快速排查大部分样式问题,提升开发效率,同时减少踩坑。

补充说明:关于:has() 选择器的高级用法(如复杂父元素匹配、多条件筛选),后续我会单独整理分享,感兴趣的可以关注。另外,文中整理的选择器速查表,大家可以保存下来,随用随查,非常便捷。

@empjs/valtio - 让你像写 Vue 响应式一样写 React 状态

作者 KenXu
2026年2月2日 10:18

如果你正在经历: Zustand 替代了 Redux 的繁琐,但还是觉得不够"直觉"?从 Vue 转 React 后,怀念 data.count++ 这种自然的写法? @empjs/valtio 可能是你的答案。它让 React 状态管理回归"改变数据就自动更新"的本能,同时把常用功能(撤销/重做、计算属性、本地存储)从 4~5 个安装步骤简化为 1 行配置


📖 目录

  1. 三分钟理解:Redux → Zustand → Valtio 的演变
  2. 核心体验:一个计数器的三种写法
  3. 为什么需要增强版?原版 Valtio 的"最后一公里"
  4. 实战对比:同一个功能,代码量差多少?
  5. 读写铁律:snap 和 store 不能混用
  6. 内建武器库:17 个方法全解析
  7. 全局 vs 局部:什么时候用哪个?
  8. 微前端场景:像传普通 props 一样传 store
  9. 避坑指南:5 个新手常犯错误
  10. 快速决策表:30 秒选对方案

1. 三分钟理解

React 状态管理的三代演变

想象你在管理一家奶茶店的库存:

Redux(第一代)—— 严格的仓库管理制度

// 每次改库存都要填表、盖章、走流程
dispatch({ type: 'UPDATE_MILK_TEA', payload: { count: 10 } })
// 优点:流程清晰,适合大团队协作
// 缺点:改个数字要写三个文件(action、reducer、connect)

Zustand(第二代)—— 简化的库存本子

// 把表格简化成一个记账本
const useStore = create(set => ({
  count: 0,
  increase: () => set(state => ({ count: state.count + 1 }))
}))
// 优点:只要一个文件,API 很少
// 缺点:还是要定义"动作函数",不能直接改数字

Valtio(第三代)—— 像改普通变量一样

// 就像在白板上直接擦掉旧数字写新数字
const state = proxy({ count: 0 })
state.count++  // 就这么简单!UI 自动更新
// 优点:最接近 Vue 的 reactive,零学习成本
// 缺点:太"自由"了,缺少统一管理

为什么 Vue 开发者会爱上它?

如果你熟悉 Vue 3 的 reactive,那么 Valtio 的 proxy 几乎是同一个概念:

<!-- Vue 3 -->
<script setup>
const state = reactive({ count: 0 })
state.count++  // 直接改,视图自动更新
</script>
// Valtio (React)
const state = proxy({ count: 0 })
state.count++  // 同样直接改,组件自动重渲染

两者底层都用了 JavaScript 的 Proxy 机制来追踪变化,这就是为什么 Valtio 常被称为"React 世界的 Vue 响应式"。


2. 核心体验

同一个计数器,三种库的写法

Redux Toolkit(约 20 行)

// store.ts
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: { increment: state => { state.count += 1 } }
})

// 组件
import { useSelector, useDispatch } from 'react-redux'
function Counter() {
  const count = useSelector(state => state.counter.count)
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(increment())}>
    {count}
  </button>
}

Zustand(约 12 行)

import { create } from 'zustand'
const useStore = create(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 }))
}))

function Counter() {
  const { count, increment } = useStore()
  return <button onClick={increment}>{count}</button>
}

@empjs/valtio(约 7 行)

import { createStore } from '@empjs/valtio'
const store = createStore({ count: 0 })

function Counter() {
  const snap = store.useSnapshot()
  return <button onClick={() => store.set('count', snap.count + 1)}>
    {snap.count}
  </button>
}

量化对比

指标 Redux Toolkit Zustand @empjs/valtio
代码行数 ~20 行 ~12 行 ~7 行
需要定义"动作" 是(reducer) 是(函数)
TypeScript 类型推断 需手写 RootState 需手写泛型 自动推导
添加撤销/重做 装 redux-undo 自己实现 1 行配置

3. 为什么需要增强版?

原版 Valtio 的优势与边界

原版 Valtio 只有两个核心 API:

  • proxy(data) — 创建响应式对象
  • useSnapshot(state) — 在组件里读取数据

这种极简主义是优点也是局限:

优点: 学习成本几乎为零,写起来最自然
局限: 生产环境需要的"工程化能力"都要自己加

原版缺失的"最后一公里"

缺失的能力 原版方案 @empjs/valtio 方案
统一写入口 自己封装 set/update 函数 createStore 内建 17 个方法
撤销/重做 valtio-history 并手动接入 createStore({ history }) 一行开启
计算属性 derive-valtio 并配置 createStore({ derive }) 自动计算
本地存储 自己写 localStorage 逻辑 store.persist('key') 一行搞定
多实例隔离 useRef(proxy(...)) + 手动清理 useStore(init) 自动管理生命周期

典型痛点场景

场景 1:想加个"撤销"功能

// 原版:需要安装新包 + 多处改造
npm install valtio-history
import { proxyWithHistory } from 'valtio-history'
const state = proxyWithHistory({ count: 0 })
// 然后在组件里手动调 state.value.count、state.undo()

// 增强版:配置一下就行
const store = createStore({ count: 0 }, { history: true })
snap.undo()  // 直接用

场景 2:表单需要"全名"自动拼接

// 原版:需要装 derive-valtio + 单独维护派生逻辑
npm install derive-valtio
import { derive } from 'derive-valtio'
const derived = derive({ fullName: get => ... })

// 增强版:写在配置里
const store = createStore(
  { firstName: '', lastName: '' },
  {
    derive: (get, p) => ({
      fullName: `${get(p).firstName} ${get(p).lastName}`.trim()
    })
  }
)

4. 实战对比

案例:用户信息表单(带撤销、计算属性、持久化)

原版 Valtio(约 35 行)

// ① 安装依赖
// npm install valtio valtio-history derive-valtio

// ② 创建状态
import { proxy } from 'valtio'
import { proxyWithHistory } from 'valtio-history'
const state = proxyWithHistory({ firstName: '', lastName: '' })

// ③ 手写派生逻辑
import { derive } from 'derive-valtio'
const derived = derive({
  fullName: get => 
    `${get(state.value).firstName} ${get(state.value).lastName}`.trim()
})

// ④ 手写持久化
import { useEffect } from 'react'
useEffect(() => {
  const saved = localStorage.getItem('user')
  if (saved) Object.assign(state.value, JSON.parse(saved))
  const unsub = subscribe(state, () => 
    localStorage.setItem('user', JSON.stringify(state.value))
  )
  return unsub
}, [])

// ⑤ 组件使用
function Form() {
  const snap = useSnapshot(state)
  const derivedSnap = useSnapshot(derived)
  return (
    <>
      <input value={snap.value.firstName} 
             onChange={e => state.value.firstName = e.target.value} />
      <p>全名:{derivedSnap.fullName}</p>
      <button onClick={() => snap.undo()}>撤销</button>
    </>
  )
}

@empjs/valtio(约 18 行)

// ① 一次性配置
import { createStore } from '@empjs/valtio'

const store = createStore(
  { firstName: '', lastName: '' },
  {
    history: { limit: 50 },  // 撤销功能
    derive: (get, p) => ({    // 计算属性
      fullName: `${get(p).firstName} ${get(p).lastName}`.trim()
    })
  }
)
store.persist('user-form')   // 持久化

// ② 直接用
function Form() {
  const snap = store.useSnapshot()
  const derived = store.derived.useSnapshot()
  return (
    <>
      <input value={snap.value.firstName}
             onChange={e => store.value.firstName = e.target.value} />
      <p>全名:{derived.fullName}</p>
      <button onClick={() => snap.undo()}>撤销</button>
    </>
  )
}

代码量对比

维度 原版 增强版 减少
总行数 ~35 行 ~18 行 48%
需要安装的包 3 个 1 个 66%
接入步骤 5 步 1 步 80%

5. 读写铁律

记住一句话:读用 snap,写用 store。 这不是代码风格,是响应式系统的硬性要求。

为什么有这个规则?

Valtio 的响应式依赖 React 的 useSnapshot 来收集"谁用了哪些字段"。直接读 store.xxx 不会触发这个收集机制,组件就不会在数据变化时重新渲染。

// ❌ 错误:读 store 不会触发重渲染
function Bad() {
  store.useSnapshot()  // 虽然调了 hook,但没用返回值
  return <span>{store.count}</span>  // 读的是 proxy,不是 snap
  // 结果:count 变了,页面不更新
}

// ✅ 正确:读 snap,写 store
function Good() {
  const snap = store.useSnapshot()
  return (
    <>
      <span>{snap.count}</span>  {/* 读 snap */}
      <button onClick={() => store.set('count', snap.count + 1)}>  {/* 写 store */}
        +1
      </button>
    </>
  )
}

带历史功能时的规则

如果开启了 history 配置,状态会被包在 .value 里:

操作 写法
读当前值 snap.value.firstName
写入新值 store.value.firstName = 'Alice'
撤销 snap.undo()
重做 snap.redo()

用类型保证不犯错

import { type EmpStore } from '@empjs/valtio'

const initialState = { count: 0, name: '' }
type State = typeof initialState

// 子组件只依赖这个类型,TypeScript 会强制你用 store 的方法
export type Store = EmpStore<State>

function ChildComponent({ store }: { store: Store }) {
  const snap = store.useSnapshot()
  // snap.count = 1  // ❌ TypeScript 报错:snap 是只读的
  store.set('count', 1)  // ✅ 必须通过 store 的方法
}

6. 内建武器库

createStore / useStore 返回的对象有 17 个方法,分四大类:

📖 读取类(3 个)

方法 用途 示例
useSnapshot() 组件内读取数据(Hook) const snap = store.useSnapshot()
getSnapshot() 非组件场景读取(如回调) console.log(store.getSnapshot())
toJSON() 序列化为纯对象 const data = store.toJSON()

✏️ 写入类(6 个)

方法 用途 示例
set(key, value) 改单个字段 store.set('count', 10)
update(partial) 批量改多个字段 store.update({ count: 10, name: 'Alice' })
setNested(path, value) 改深层路径 store.setNested('user.address.city', '北京')
delete(key) 删除某个字段 store.delete('tempData')
reset(state?) 重置为初始状态 store.reset()
fromJSON(json) 从对象恢复状态 store.fromJSON(savedData)

👂 订阅类(3 个)

方法 用途 示例
subscribe(fn) 监听所有变化 store.subscribe(() => console.log('变了'))
subscribeKey(key, fn) 只监听某个字段 store.subscribeKey('count', val => ...)
subscribeKeys(keys, fn) 监听多个字段 store.subscribeKeys(['a', 'b'], ...)

🔧 工具类(5 个)

方法 用途 示例
ref(value) 标记为非响应式(如 DOM) store.set('dom', store.ref(divElement))
batch(fn) 批量更新,只触发一次渲染 store.batch(() => { ... })
clone() 深拷贝当前状态 const copy = store.clone()
persist(key) 开启 localStorage 持久化 store.persist('my-data')
debug() 在控制台打印每次变更 store.debug()

7. 全局 vs 局部

什么时候用 createStore(全局单例)?

特征: 数据需要跨组件共享,整个应用生命周期内只有一份

典型场景:

  • 当前登录用户信息
  • 主题配置(深色/浅色模式)
  • 全局加载状态
  • 购物车数据
// 在单独文件里创建
import { createStore } from '@empjs/valtio'

export const themeStore = createStore({
  mode: 'light',
  primaryColor: '#1890ff'
})

// 任何组件都可以用
function Header() {
  const snap = themeStore.useSnapshot()
  return <div style={{ background: snap.primaryColor }}>...</div>
}

什么时候用 useStore(每实例独立)?

特征: 每个组件实例需要自己的独立状态,互不干扰

典型场景:

  • 表单(页面上可能有多个表单)
  • 代码编辑器(每个 Tab 一个编辑器)
  • 画板工具(多画布场景)
  • 计数器组件(同页面多个实例)
import { useStore } from '@empjs/valtio'

function FormBlock({ initialLabel }: { initialLabel: string }) {
  // 每个 <FormBlock> 实例都有自己的 store
  const [snap, store] = useStore({ count: 0, label: initialLabel })
  
  return (
    <div>
      <p>{snap.label}: {snap.count}</p>
      <button onClick={() => store.set('count', snap.count + 1)}>
        +1
      </button>
      <button onClick={() => store.reset()}>重置</button>
    </div>
  )
}

// 两个实例,状态完全隔离
<FormBlock initialLabel="表单 A" />
<FormBlock initialLabel="表单 B" />

惰性初始化(适合昂贵计算)

// 传函数而不是对象,只有第一次渲染时才执行
const [snap, store] = useStore(() => ({
  data: expensiveComputation(),  // 只在组件挂载时算一次
  timestamp: Date.now()
}))

8. 微前端场景

传统方案的问题

方案 问题
全局单例 子应用和主应用版本不一致就炸,构建顺序有依赖
事件总线 类型弱,调试困难,边界不清晰
postMessage 只能传序列化数据,丢失类型和方法

@empjs/valtio 的方案:当普通 prop 传

核心思想: store 本身就是一个普通对象,可以像任何 React props 一样传递

// ===== 共享类型定义(放在独立的 npm 包里) =====
import { type EmpStore } from '@empjs/valtio'

export const initialState = { count: 0, name: 'shared', loading: false }
export type State = typeof initialState
export type SharedStore = EmpStore<State>

// ===== 主应用(主机) =====
import { useStore } from '@empjs/valtio'
import { initialState, type SharedStore } from '@my-company/shared-types'
import RemoteChild from 'remote-app/Child'  // Module Federation

function Host() {
  const [snap, store] = useStore<State>(initialState)
  
  return (
    <div>
      <h1>主应用</h1>
      <p>主应用的计数:{snap.count}</p>
      
      {/* 像普通 prop 一样传给子应用 */}
      <RemoteChild store={store} />
    </div>
  )
}

// ===== 子应用(独立构建,独立部署) =====
import { type SharedStore } from '@my-company/shared-types'

function RemoteChild({ store }: { store: SharedStore }) {
  const snap = store.useSnapshot()
  
  return (
    <div>
      <h2>子应用</h2>
      <p>看到的主应用数据:{snap.count}</p>
      <button onClick={() => store.set('count', snap.count + 1)}>
        子应用也能改
      </button>
    </div>
  )
}

为什么这样好?

优势 说明
零耦合 子应用不需要知道主应用的运行时,只依赖类型定义
类型安全 TypeScript 全程保护,改了类型定义,双方都能感知
调试简单 就是普通的 props,React DevTools 直接能看
版本独立 主应用升级不影响子应用,子应用可以独立发版

状态层次示意图

主应用
├── 全局 store(createStore)
│   ├── 用户信息
│   └── 主题配置
│       └── 通过 props 传给子应用 ──┐
│                                   ↓
└── 子应用 A                     接收 store
    ├── 使用主应用的 store(共享状态)
    └── 自己的 store(useStore)
        ├── 表单数据(局部)
        └── 编辑器状态(局部)

9. 避坑指南

❌ 错误 1:读 store 而不是 snap

// 错误
function Bad() {
  store.useSnapshot()  // 虽然调了,但没用返回值
  return <span>{store.count}</span>  // 不会触发重渲染
}

// 正确
function Good() {
  const snap = store.useSnapshot()
  return <span>{snap.count}</span>
}

为什么错: Valtio 的响应式机制依赖 useSnapshot 的返回值来追踪"谁读了哪些字段",直接读 store 不会被追踪。

❌ 错误 2:键名和方法重名

// 错误:键名叫 set,和 store.set() 冲突
const store = createStore({
  set: new Set(),  // ❌ 冲突!
  update: 123      // ❌ 也冲突!
})

// 正确:换个名字
const store = createStore({
  tagSet: new Set(),  // ✅
  version: 123        // ✅
})

为什么错: store.set / store.update 是内建方法,用同名键会被覆盖。

❌ 错误 3:传非 proxy 对象给 useSnapshot

// 错误
const plainObj = { count: 0 }
const snap = useSnapshot(plainObj)  // ❌ 报错

// 正确
const store = createStore({ count: 0 })
const snap = store.useSnapshot()  // ✅

报错信息: "Please use proxy object"

❌ 错误 4:在 derive 里写副作用

// 错误
const store = createStore(
  { a: 1, b: 2 },
  {
    derive: (get, p) => {
      console.log('计算中')  // ❌ 副作用
      fetch('/api')          // ❌ 异步请求
      return { sum: get(p).a + get(p).b }
    }
  }
)

// 正确
const store = createStore(
  { a: 1, b: 2 },
  {
    derive: (get, p) => ({
      sum: get(p).a + get(p).b  // ✅ 纯计算
    })
  }
)

为什么错: derive 会被频繁调用(每次依赖变化都调),副作用会重复执行且难以控制。

❌ 错误 5:忘记 .value(开启历史功能时)

// 开启历史后,状态被包在 .value 里
const store = createStore({ count: 0 }, { history: true })

// 错误
const snap = store.useSnapshot()
console.log(snap.count)  // ❌ undefined

// 正确
const snap = store.useSnapshot()
console.log(snap.value.count)  // ✅
store.value.count++             // ✅ 写入也要加 .value

10. 快速决策表

你的场景 推荐方案 示例代码
全局配置(主题/语言/用户) createStore const themeStore = createStore({ mode: 'light' })
多个独立表单 useStore const [snap, store] = useStore({ name: '' })
需要撤销/重做 createStore + history createStore(init, { history: { limit: 50 } })
需要计算属性(如全名) createStore + derive createStore(init, { derive: (get, p) => ({ ... }) })
需要本地持久化 任意 store + .persist() store.persist('my-data-key')
微前端:主应用 → 子应用 主应用 useStore,props 传入 <RemoteChild store={store} />
微前端:子应用内部 子应用自行 useStore 与主应用完全隔离
批量更新避免多次渲染 store.batch() store.batch(() => { store.set(...); store.set(...) })
表单多次改值卡顿 store.batch() 输入框 onChange 里包一层 batch

总结:三句话记住 @empjs/valtio

  1. 像 Vue 一样写 React 状态 —— state.count++ 就能自动更新 UI
  2. 从 4~5 步简化到 1 步 —— 历史、计算属性、持久化都是一行配置
  3. 微前端友好 —— store 当普通 props 传,无需全局单例和事件总线

参考资料:

❌
❌