普通视图

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

Trae Genimi3跟着官网学实时通信 Socket.io框架

2025年12月5日 02:30

3b74cd598a4f9c785f0fa84024044a3d.jpg

图片.pngsocket.io/zh-CN/docs/…

第1次对话:AI满意度⭐️⭐️

AI提示词:学习socket.io 请你按照这个文档 socket.io/docs/v4/tut… 创建一个中文网站,循序渐进的学习。

图片.png

图片.png

出现这个基于Node.js的Expressv5.2框架的聊天室,有点弱了,用户进入没有提示和昵称, 我的对AI表现的满意度是 ⭐️⭐️⭐️🈳🈳,可以给半⭐️给 版本比较新。

我需要的是一个学习Socekt.io的网站

AI给我一个Socekt.io开发的项目。

第2次对话:AI满意度⭐️⭐️⭐️⭐️

我需要的是一个 HTML网页文档来学习socket.io,并不是要用socket.io生成项目。使用工具pnpm,文档左侧应该有清晰的导航列表。如果涉及代码应该彩色高亮。

图片.png

第3次对话:AI满意度⭐️⭐️⭐️⭐️

对用 socket.on( ) 或者io 自定义的通道 事件、 关键字 都用emoji表情代替 ,同时 .getElementById 都用queryselector代替。并且列举出socket.io常用功能清单 仅对这个文件,尽可能用直观的中文+emoji

图片.png

图片.png

AI文档最终成品文档

1. 简介

Socket.IO 是一个基于事件的库,用于在浏览器和服务器之间实现实时双向低延迟的通信。

核心特性:

  • HTTP 长轮询作为回退机制(保证连接稳定性)
  • 自动重连
  • 数据包缓冲
  • 广播机制

2. 环境搭建 (pnpm)

首先,我们需要初始化一个新的 Node.js 项目。这里我们使用 pnpm 作为包管理工具。

初始化项目

打开终端,创建一个新目录并进入:

mkdir socket-chat-example
cd socket-chat-example

初始化 package.jsonpnpm init

安装依赖

在根目录下创建 package.json 文件,确保依赖项使用的是最新版本(如下所示):

{
  "dependencies": {
    "express": "^5.2.1",
    "socket.io": "^4.8.1"
  }
}

我们需要安装 Express 框架和 Socket.IO 库:

pnpm init

3. 创建服务器

创建 index.js 文件写入以下代码。我们需要设置一个基本的 Express 服务器,并将 Socket.IO 附加到它上面。

// index.js 文件代码
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');

// 1. 创建 Express 实例
const app = express();
// 2. 创建 HTTP 服务器 (将 app 作为处理器)
const server = createServer(app);
// 3. 初始化 Socket.IO (传入 server)
const io = new Server(server);

// 路由:返回 HTML 文件
app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'index.html'));
});

// 监听连接事件 🔌
io.on('connection', (socket) => {
  console.log('👤 一个用户已连接');
  
  // 监听断开连接 ❌
  socket.on('disconnect', () => {
    console.log('👋 用户已断开连接');
  });
});

// 启动服务器
server.listen(3000, () => {
  console.log('🚀 服务器运行在 http://localhost:3000');
});

4. 创建客户端

创建 index.html 文件。这是用户看到的聊天界面。

<!DOCTYPE html>
<html>
<head>
    <title>Socket.IO 聊天室</title>
    <style>
        /* 简单的样式 */
        body { margin: 0; padding-bottom: 3rem; font-family: sans-serif; }
        #form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); }
        #input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; }
        #input:focus { outline: none; }
        #form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; }
        #messages { list-style-type: none; margin: 0; padding: 0; }
        #messages > li { padding: 0.5rem 1rem; }
        #messages > li:nth-child(odd) { background: #efefef; }
    </style>
</head>
<body>
    <ul id="messages"></ul>
    <form id="form" action="">
      <input id="input" autocomplete="off" /><button>发送</button>
    </form>
</body>
</html>

5. 集成 Socket.IO

index.html</body> 标签之前,引入 Socket.IO 客户端脚本并初始化。

<!-- 自动提供的 Socket.IO 客户端库 -->
<script src="/socket.io/socket.io.js"></script>
<script>
  // 初始化连接 🔗
  const socket = io();
</script>

这里的 io() 默认会尝试连接当前主机。

6. 发送与接收消息

我们需要监听表单提交,将消息发送给服务器。注意这里使用了 querySelector 和 Emoji 事件名。

客户端发送 (Emit)

index.html 的 script 标签中添加:

// 使用 querySelector 获取元素 🔍
const form = document.querySelector('#form');
const input = document.querySelector('#input');

form.addEventListener('submit', (e) => {
  e.preventDefault(); // 阻止表单默认提交刷新页面
  if (input.value) {
    // 向服务器发送 '💬 聊天消息' 事件
    socket.emit('💬 聊天消息', input.value);
    input.value = ''; // 清空输入框
  }
});

服务器接收

index.js 中,我们需要监听这个 Emoji 事件:

io.on('connection', (socket) => {
  // 监听 '💬 聊天消息' 事件
  socket.on('💬 聊天消息', (msg) => {
    console.log('📩 收到消息: ' + msg);
  });
});

7. 广播消息

现在的代码只能在服务器端打印消息。我们需要把消息广播给所有连接的客户端。

服务器端广播

修改 index.js

io.on('connection', (socket) => {
  socket.on('💬 聊天消息', (msg) => {
    // io.emit 会发送给所有连接的客户端,包括发送者自己 📢
    io.emit('💬 聊天消息', msg);
  });
});

客户端显示消息

index.html 中,监听服务器发回的事件:

const messages = document.querySelector('#messages');

// 监听 '💬 聊天消息' 事件
socket.on('💬 聊天消息', (msg) => {
  const item = document.createElement('li');
  item.textContent = msg;
  messages.appendChild(item);
  window.scrollTo(0, document.body.scrollHeight); // 滚动到底部
});

8. Socket.IO 常用功能清单

图片.png

9. 总结

恭喜! 你已经完成了一个基本的实时聊天应用。

关键 API 回顾:

  • pnpm add socket.io - 安装服务端库。
  • const io = new Server(server) - 服务端初始化。
  • const socket = io() - 客户端初始化。
  • socket.emit('💬 消息', data) - 发送消息。
  • socket.on('💬 消息', callback) - 接收消息。
  • io.emit('💬 消息', data) - 广播给所有人。

附录:

注:差点点了自动翻译网页,官网其实有中文选项,但存在切换BUG(失效)和翻译不完整等问题。 你看教程这里又是英文了。 那么跟着

图片.png

终于找到你Gemini-3-Pro(200K),得取消 Auto Mode 并且不能用 Trae Solo模式才有 fab9b7c0a111a741fb37e9de10cef21a.png

JavaScript 拷贝全解析:从浅拷贝到深拷贝的完整指南

作者 1024肥宅
2025年12月4日 23:37

引言

在Javascript开发中,数据拷贝是我们每天都会遇到的基础操作。然而,这个看似简单的概念背后隐藏着许多陷阱和细节。错误的数据拷贝可能导致难以调试的bug、内存泄漏甚至程序崩溃。

你是否曾遇到过:

  • 修改一个对象后,另一个"独立"的对象也被意外修改?
  • 尝试复制包含函数、Date对象或循环引用的数据结构时失败?
  • 在处理大型数据集时,拷贝导致性能急剧下降?

本文将从基础概念出发,深入探讨JavaScript中的各种拷贝技术,提供完整的实现方案,并帮助你根据不同的场景选择最合适的拷贝策略。

一、理解JavaScript的数据类型

在深入拷贝之前,我们需要先理解JavaScript的数据类型,因为不同类型的数据在拷贝时有根本性的区别。

1.1 基本类型(Primitive Types)

JavaScript有7种基本数据类型:

// 基本类型 - 按值存储,拷贝时直接复制值
const str = 'Hello';      // String
const num = 42;           // Number
const bool = true;        // Boolean
const nullValue = null;   // Null
const undefinedValue;      // Undefined
const sym = Symbol('id'); // Symbol(ES6)
const bigInt = 123n;      // BigInt(ES2020)
1.2 引用类型(Reference Types)
// 引用类型 - 按引用存储,拷贝时复制引用
const obj = { name: 'John' }; // Object
const arr = [1, 2, 3];        // Array
const func = () => {};        // Function
const date = new Date();      // Date
const regex = /pattern/gi;    // RegExp
const map = new Map();        // Map
const set = new Set();        // Set
1.3 内存模型图解
// 基本类型 - 栈内存存储
let a = 10; // 栈: a = 10
let b = a;  // 栈: b = 10 (值的拷贝)
b = 20;     // 栈: b = 20, a 保持不变

// 引用类型 - 堆内存存储
let obj1 = { x: 10 }; // 栈: obj1 -> 堆地址1 {x: 10}
let obj2 = obj1;      // 栈: obj2 -> 同一个堆地址1
obj2.x = 20;          // 堆地址1: {x: 20}, obj1.x 也变为 20

理解这个区别是掌握拷贝技术的基础。接下来,我们开始探讨具体的拷贝方法。

二、浅拷贝(Shallow Copy)

浅拷贝创建一个新对象,复制原始对象的所有属性值到新对象。如果属性值是基本类型,则复制值; 如果是引用类型,则复制引用。

2.1 对象浅拷贝方法
方法1: 展开运算符(Spread Operator) - ES6+
const original = {
  name: 'John',
  age: 30,
  hobbies: ['reading', 'gaming'],
  address: {
    city: 'Shang Hai',
    zip: '120001'
  }
};

const shallowCopy = { ...original };

// 测试
console.log(shallowCopy === original); // false - 是新对象
console.log(shallowCopy.hobbies === original.hobbies); // true - 引用相同
console.log(shallowCopy.address === original.address); // true - 引用相同

// 修改嵌套对象会影响原对象
shallowCopy.hobbies.push("coding");
console.log(original.hobbies); // [ 'reading', 'gaming', 'coding' ] - 被影响
方法2: Object.assign() - ES6
const shallowCopy2 = Object.assign({}, original);

// Object.assign 可以合并多个对象
const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { c: 3 };
const merged = Object.assign({}, obj1, obj2, obj3);
console.log(merged); // { a: 1, b: 2, c: 3 }
方法3: 手动实现浅拷贝
function shallowCopy(obj) {
  // 处理 null 和 undefined
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 处理数组
  if (Array.isArray(obj)) {
    return [...obj];
  }

  // 处理对象
  const copy = {};
  for (const key in obj) {
    // 只拷贝对象自身的属性(不包括原型链上的属性)
    if (obj.hasOwnProperty(key)) {
      copy[key] = obj[key];
    }
  }

  return copy;
}

// 测试
const testObj = { a: 1, b: { c: 2 } };
const copied = shallowCopy(testObj);
console.log(copied.b === testObj.b); // true - 浅拷贝
方法4: 使用 Object.create() (原型链拷贝)
// 这种方法会保持原型链
function shallowCopyWithPrototype(obj) {
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 创建一个新对象,继承原对象的原型
  const copy = Object.create(Object.getPrototypeOf(obj));

  // 拷贝自有属性
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
    Object.defineProperty(copy, prop, descriptor);
  });

  return copy;
}

// 测试
const protoObj = { inherited: "from prototype" };
const objWithProto = Object.create(protoObj);
objWithProto.own = "own prototype";

const copiedWithProto = shallowCopyWithPrototype(objWithProto);
console.log(copiedWithProto.inherited); // from prototype - 继承原型
console.log(copiedWithProto.own); // own prototype
2.2 数组浅拷贝方法
方法1: 展开运算符
const originalArray = [1, 2, 3, { x: 4 }];
const shallowArray = [...originalArray];

// 修改基本类型不会影响原数组
shallowArray[0] = 100;
console.log(originalArray[0]); // 1 - 不受影响

// 修改引用类型会影响原数组
shallowArray[3].x = 400;
console.log(originalArray[3].x); // 400 - 受影响!
方法2: slice()方法
const shallowArray2 = originalArray.slice();
// 效果与展开运算符相同
方法3: concat()方法
const shallowArray3 = originalArray.concat();
// 效果与展开运算符相同
方法4: Array.from() - ES6
const shallowArray4 = Array.from(originalArray);
方法5: 手动实现数组浅拷贝
function shallowCopyArray(arr) {
  if (!Array.isArray(arr)) {
    throw new TypeError("Excepted an array");
  }

  const copy = new Array(arr.length);
  for (let i = 0; i < arr.length; i++) {
    copy[i] = arr[i];
  }
  return copy;
}
2.3 浅拷贝的局限性

浅拷贝的主要问题是:

  1. 嵌套对象问题: 只拷贝一层,嵌套的对象仍然是共享的
  2. 对象共享问题: 修改浅拷贝对象的引用类型属性会影响原对象
  3. 特殊对象的引用共享: 对于Date、RegExp等特殊对象, 浅拷贝只复制引用, 不会创建新实例

三、深拷贝(Deep Copy)

深拷贝会创建一个完全独立的新对象, 递归复制所有嵌套的对象和数组, 使新对象与原对象完全分离。

3.1 使用 JSON 方法(最简单但有限制)
const deepCopyWithJSON = JSON.parse(JSON.stringify(original));

// 测试
const obj = {
  name: "John",
  date: new Date(),
  func: () => console.log("hello"),
  undef: undefined,
  symbol: Symbol("id"),
  infinity: Infinity,
  nan: NaN,
  regex: /pattern/gi,
  set: new Set([1, 2, 3]),
  map: new Map([["key", "value"]]),
  nested: { a: 1 },
};

const jsonCopy = JSON.parse(JSON.stringify(obj));
console.log(jsonCopy);
// 输出:
// {
//   name: 'John',
//   date: '2025-12-04T14:13:18.238Z', // Date 变成了字符串
//   func 不存在 // 函数被省略
//   undef 不存在 // undefined 被省略
//   symbol 不存在 // Symbol 被省略
//   infinity: null, // Infinity 变成了 null
//   nan: null, // NaN 变成了 null
//   regex: {}, // RegExp 变成了空对象
//   set: {}, // Set 变成了空对象
//   map: {}, // Map 变成了空对象
//   nested: { a: 1 }
// }

JSON方法的限制:

  • 无法拷贝函数
  • 无法拷贝undefined
  • 无法拷贝Symbol
  • 无法拷贝循环引用
  • 特殊对象(Date、RegExp、Set、Map等)会被错误处理
  • 会忽略原型链
3.2 递归实现深拷贝

基础递归实现

function deepClone(obj, hash = new WeakMap()) {
  // 处理基本类型和 null/undefined
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 处理 Date 对象
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }

  // 处理正则表达式
  if (obj instanceof RegExp) {
    return new RegExp(obj.source, obj.flags);
  }

  // 处理数组
  if (Array.isArray(obj)) {
    return obj.map((item) => deepClone(item, hash));
  }

  // 处理普通对象 - 检查循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }

  const clone = {};
  hash.set(obj, clone);

  // 拷贝所有属性, 包括不可枚举属性(可选)
  const props = Object.getOwnPropertyNames(obj);
  for (const prop of props) {
    clone[prop] = deepClone(obj[prop], hash);
  }

  return clone;
}

// 测试基础功能
const testObj = {
  name: "Test",
  date: new Date(),
  regex: /test/gi,
  arr: [1, 2, { nested: true }],
  nested: {
    level1: {
      level2: "deep",
    },
  },
};

const cloned = deepClone(testObj);
console.log(cloned.date instanceof Date); // true
console.log(cloned.regex instanceof RegExp); // true
console.log(cloned.arr[2] === testObj.arr[2]); // false - 深拷贝成功

支持更多数据类型的完整实现

function deepCloneComplete(target, map = new WeakMap()) {
  // 基本类型直接返回
  if (target === null || typeof target !== "object") {
    return target;
  }

  // 检查循环引用
  if (map.has(target)) {
    return map.get(target);
  }

  // 克隆特殊对象类型
  // Date
  if (target instanceof Date) {
    return new Date(target);
  }

  // RegExp
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // Map
  if (target instanceof Map) {
    const clone = new Map();
    map.set(target, clone);
    target.forEach((value, key) => {
      clone.set(deepCloneComplete(key, map), deepCloneComplete(value, map));
    });
    return clone;
  }

  // Set
  if (target instanceof Set) {
    const clone = new Set();
    map.set(target, clone);
    target.forEach((value) => {
      clone.add(deepCloneComplete(value, map));
    });
    return clone;
  }

  // ArrayBuffer
  if (target instanceof ArrayBuffer) {
    return target.slice(0);
  }

  // TypedArrat (Int8Array, Uint8Array, etc.)
  if (ArrayBuffer.isView(target)) {
    return new target.constructor(target);
  }

  // Array
  if (Array.isArray(target)) {
    const arrClone = [];
    map.set(target, arrClone);
    for (let i = 0; i < target.length; i++) {
      arrClone[i] = deepCloneComplete(target[i], map);
    }
    return arrClone;
  }

  // 普通对象
  const objClone = Object.create(Object.getPrototypeOf(target));
  map.set(target, objClone);

  // 获取所有属性(包括 Symbol)
  const allKeys = Reflect.ownKeys(target);

  for (const key of allKeys) {
    const descriptor = Object.getOwnPropertyDescriptor(target, key);

    if (descriptor) {
      if (descriptor.hasOwnProperty("value")) {
        // 数据属性
        objClone[key] = deepCloneComplete(target[key], map);
      } else {
        // 访问器属性 (getter/setter)
        Object.defineProperty(objClone, key, descriptor);
      }
    }
  }

  return objClone;
}

// 测试完整功能
const complexObj = {
  string: "hello",
  number: 42,
  boolean: true,
  null: null,
  undefined: undefined,
  symbol: Symbol("test"),
  date: new Date(),
  regex: /test/gi,
  array: [1, 2, { nested: true }],
  map: new Map([["key", { value: "map value" }]]),
  set: new Set([1, 2, 3]),
  buffer: new ArrayBuffer(8),
  uintArray: new Uint8Array([1, 2, 3]),
  object: {
    nested: {
      deeply: "nested value",
    },
  },
  get computed() {
    return this.string.toUpperCase();
  },
  method() {
    return this.string;
  },
};

// 添加循环引用
complexObj.self = complexObj;
complexObj.circular = { parent: complexObj };

const completeClone = deepCloneComplete(complexObj);

console.log(completeClone.date instanceof Date); // true
console.log(completeClone.regex instanceof RegExp); // true
console.log(completeClone.map instanceof Map); // true
console.log(completeClone.uintArray instanceof Uint8Array); // true
console.log(completeClone.computed); // 'HELLO'
console.log(completeClone.method()); // 'hello'
console.log(completeClone.self === completeClone); // true - 循环引用正确处理
console.log(completeClone.circular.parent === completeClone); // true
3.3 使用 structuredClone API(现代浏览器)

HTML5规范引入了structuredClone()方法, 提供了一种标准化的深拷贝方法。

// 浏览器环境中的使用
const original = {
    name: 'John',
    date: new Date(),
    array: [1, 2, 3],
    nested: { value: 'test' }
};

try {
    const cloned = structuredClone(original);
    console.log(cloned.date instanceof Date); // true
    console.log(cloned.nested === original.nested); // false
} catch (err) {
    console.log('structuredClone not supported:', err);
}

// Node.js 中的使用(v17+)
if (typeof structuredClone === 'function') {
    const cloned = structuredClone(original);
}

// structuredClone 支持的数据类型:
// - 基本类型(除 Symbol)
// - Boolean、Number、String 对象
// - Date
// - RegExp
// - ArrayBuffer、TypedArray
// - Map、Set
// - Array、Object
// - 循环引用

// 不支持:
// - 函数
// - DOM 节点
// - Error 对象
// - 原型链
3.4 使用第三方库

对于生产环境, 使用成熟的第三方库通常是更好的选择: Lodash_.cloneDeep

// 使用 Lodash
const _ = require('lodash');

const obj = {
    date: new Date(),
    regex: /test/gi,
    func: () => console.log('hi'),
    nested: { a: 1 }
};

const cloned = _.cloneDeep(obj);
console.log(cloned.date instanceof Date); // true
console.log(cloned.regex instanceof RegExp); // true
console.log(typeof cloned.func); // 'function' - 函数被保留

自己实现类似LodashcloneDeep

function cloneDeep(value, stack = new Map()) {
    // 基本类型直接返回
    if (value === null || typeof value !== 'object') {
        return value;
    }
    
    // 检查循环引用
    if (stack.has(value)) {
        return stack.get(value);
    }
    
    let clone;
    
    // 处理特殊对象
    if (value instanceof Date) {
        clone = new Date(value.getTime());
        stack.set(value, clone);
        return clone;
    }
    
    if (value instanceof RegExp) {
        clone = new RegExp(value.source, value.flags);
        stack.set(value, clone);
        return clone;
    }
    
    if (value instanceof Map) {
        clone = new Map();
        stack.set(value, clone);
        value.forEach((val, key) => {
            clone.set(cloneDeep(key, stack), cloneDeep(val, stack));
        });
        return clone;
    }
    
    if (value instanceof Set) {
        clone = new Set();
        stack.set(value, clone);
        value.forEach(val => {
            clone.add(cloneDeep(val, stack));
        });
        return clone;
    }
    
    if (Array.isArray(value)) {
        clone = [];
        stack.set(value, clone);
        for (let i = 0; i < value.length; i++) {
            clone[i] = cloneDeep(value[i], stack);
        }
        return clone;
    }
    
    // 处理普通对象
    clone = Object.create(Object.getPrototypeOf(value));
    stack.set(value, clone);
    
    // 拷贝所有属性
    for (const key in value) {
        if (value.hasOwnProperty(key)) {
            clone[key] = cloneDeep(value[key], stack);
        }
    }
    
    return clone;
}

四、特殊场景和边缘情况

4.1 循环引用处理

循环引用是深拷贝中最棘手的问题之一, 处理不当会导致无限递归和栈溢出

// 循环引用示例
const circularObj = { name: 'Circular' };
circularObj.self = circularObj;
circularObj.ref = { parent: circularObj };

// 处理循环引用的深拷贝实现
function cloneDeepWithCircular(obj, cache = new WeakMap()) {
    // 非对象直接返回
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }
    
    // 检查缓存中是否已有该对象的拷贝
    if (cache.has(obj)) {
        return cache.get(obj);
    }
    
    // 根据对象类型创建相应的空结构
    let clone;
    if (obj instanceof Date) {
        clone = new Date(obj);
    } else if (obj instanceof RegExp) {
        clone = new RegExp(obj.source, obj.flags);
    } else if (obj instanceof Map) {
        clone = new Map();
    } else if (obj instanceof Set) {
        clone = new Set();
    } else if (Array.isArray(obj)) {
        clone = [];
    } else {
        clone = Object.create(Object.getPrototypeOf(obj));
    }
    
    // 将空结构存入缓存(在递归前存入,防止无限递归)
    cache.set(obj, clone);
    
    // 递归拷贝
    if (obj instanceof Map) {
        obj.forEach((value, key) => {
            clone.set(
                cloneDeepWithCircular(key, cache),
                cloneDeepWithCircular(value, cache)
            );
        });
    } else if (obj instanceof Set) {
        obj.forEach(value => {
            clone.add(cloneDeepWithCircular(value, cache));
        });
    } else if (Array.isArray(obj)) {
        for (let i = 0; i < obj.length; i++) {
            clone[i] = cloneDeepWithCircular(obj[i], cache);
        }
    } else {
        for (const key in obj) {
            if (obj.hasOwnProperty(key)) {
                clone[key] = cloneDeepWithCircular(obj[key], cache);
            }
        }
    }
    
    return clone;
}

// 测试循环引用
const testCircular = { a: 1 };
testCircular.b = testCircular;
testCircular.c = { ref: testCircular };

const clonedCircular = cloneDeepWithCircular(testCircular);
console.log(clonedCircular.b === clonedCircular); // true
console.log(clonedCircular.c.ref === clonedCircular); // true
console.log(clonedCircular !== testCircular); // true - 不是同一个对象
4.2 函数拷贝

函数拷贝是一个有争议的话题,因为函数可能依赖于闭包中的外部变量。

// 函数拷贝的几种方法
function cloneFunction(func) {
    // 方法1:使用 eval(不推荐,安全问题)
    const funcString = func.toString();
    
    // 方法2:使用 Function 构造函数
    const clonedFunc = new Function('return ' + funcString)();
    
    // 拷贝函数属性
    Object.getOwnPropertyNames(func).forEach(prop => {
        if (prop !== 'length' && prop !== 'name' && prop !== 'prototype') {
            Object.defineProperty(clonedFunc, prop, 
                Object.getOwnPropertyDescriptor(func, prop));
        }
    });
    
    // 拷贝原型
    clonedFunc.prototype = func.prototype;
    
    return clonedFunc;
}

// 实际使用中,通常不拷贝函数,而是保留引用
function cloneDeepWithFunction(obj, cache = new Map()) {
    if (typeof obj === 'function') {
        return obj; // 直接返回函数引用
    }
    
    // ... 其他类型的处理
}

// 测试
const objWithFunc = {
    name: 'Test',
    sayHello: function() {
        console.log(`Hello, ${this.name}`);
    },
    arrowFunc: () => console.log('Arrow')
};

const clonedWithFunc = cloneDeepWithFunction(objWithFunc);
clonedWithFunc.name = 'Cloned';
clonedWithFunc.sayHello(); // Hello, Cloned
4.3 DOM元素拷贝

DOM元素有特殊的拷贝需求:

function cloneDOMElement(element, deep = true) {
    // 使用 cloneNode 方法
    const cloned = element.cloneNode(deep);
    
    // 处理事件监听器
    // 注意:cloneNode 不会拷贝事件监听器
    
    // 处理数据属性
    if (element.dataset) {
        Object.assign(cloned.dataset, element.dataset);
    }
    
    // 处理自定义属性
    const attributes = element.attributes;
    for (let i = 0; i < attributes.length; i++) {
        const attr = attributes[i];
        if (attr.name.startsWith('data-') || attr.name.startsWith('aria-')) {
            cloned.setAttribute(attr.name, attr.value);
        }
    }
    
    return cloned;
}

// 使用示例
// const originalDiv = document.getElementById('original');
// const clonedDiv = cloneDOMElement(originalDiv, true);
// document.body.appendChild(clonedDiv);
4.4 性能优化技巧

深拷贝可能成为性能瓶颈,特别是处理大型对象时。

// 性能优化的深拷贝
function fastDeepClone(obj, cache = new WeakMap()) {
    // 快速路径:基本类型
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }
    
    // 检查缓存
    if (cache.has(obj)) {
        return cache.get(obj);
    }
    
    let clone;
    
    // 使用构造函数快速创建对象
    const Ctor = obj.constructor;
    
    switch (Ctor) {
        case Date:
            clone = new Date(obj);
            break;
        case RegExp:
            clone = new RegExp(obj);
            break;
        case Map:
            clone = new Map();
            cache.set(obj, clone);
            obj.forEach((value, key) => {
                clone.set(fastDeepClone(key, cache), fastDeepClone(value, cache));
            });
            return clone;
        case Set:
            clone = new Set();
            cache.set(obj, clone);
            obj.forEach(value => {
                clone.add(fastDeepClone(value, cache));
            });
            return clone;
        case Array:
            clone = new Array(obj.length);
            cache.set(obj, clone);
            for (let i = 0; i < obj.length; i++) {
                clone[i] = fastDeepClone(obj[i], cache);
            }
            return clone;
        default:
            // 普通对象
            clone = Object.create(Object.getPrototypeOf(obj));
            cache.set(obj, clone);
    }
    
    // 快速属性拷贝
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
        const key = keys[i];
        clone[key] = fastDeepClone(obj[key], cache);
    }
    
    return clone;
}

// 性能对比
const largeObj = {};
for (let i = 0; i < 10000; i++) {
    largeObj[`key${i}`] = {
        nested: { value: i },
        array: new Array(10).fill(i)
    };
}

console.time('JSON 深拷贝');
JSON.parse(JSON.stringify(largeObj));
console.timeEnd('JSON 深拷贝');

console.time('递归深拷贝');
fastDeepClone(largeObj);
console.timeEnd('递归深拷贝');

五、实践应用和最佳实践

5.1 何时使用浅拷贝

适合浅拷贝的场景:

  1. 简单数据结构: 对象只有一层,没有嵌套
  2. 性能敏感: 需要快速拷贝,不关心嵌套对象的独立性
  3. 不可变数据: 数据不会被修改,或修改时创建新对象
  4. 配置对象: 只需要修改顶层配置
// 浅拷贝适用场景
const config = {
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    headers: {
        'Content-Type': 'application/json'
    }
};

// 只需要修改顶层配置时
const devConfig = { ...config, apiUrl: 'https://dev-api.example.com' };
// headers 对象仍然是共享的,但这通常是可以接受的
5.2 何时使用深拷贝

适合深拷贝的场景:

  1. 复杂嵌套结构: 对象有多层嵌套,需要完全独立
  2. 状态管理:ReduxVuex中修改状态时
  3. 不可变更新: 函数式编程中创建新状态
  4. 数据隔离: 防止原始数据被意外修改
  5. 缓存数据: 保存数据快照
// 深拷贝适用场景 - Redux reducer
function todoReducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            // 需要深拷贝来创建新状态
            return {
                ...state,
                todos: [
                    ...state.todos,
                    {
                        id: action.id,
                        text: action.text,
                        completed: false
                    }
                ]
            };
        case 'TOGGLE_TODO':
            // 深度更新嵌套对象
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.id
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
        default:
            return state;
    }
}
5.3 性能优化策略
  1. 按需拷贝: 只拷贝需要修改的部分
  2. 结构共享: 使用不可变数据结构库(如Immutable.js)
  3. 增量更新: 只更新变化的部分
  4. 缓存结果: 对于相同输入返回缓存的结果
// 按需拷贝示例
function updateUser(user, updates) {
    // 只深拷贝需要修改的部分
    const updatedUser = { ...user };
    
    if (updates.address) {
        updatedUser.address = { ...user.address, ...updates.address };
    }
    
    if (updates.preferences) {
        updatedUser.preferences = { ...user.preferences, ...updates.preferences };
    }
    
    // 其他属性直接浅拷贝
    Object.keys(updates).forEach(key => {
        if (key !== 'address' && key !== 'preferences') {
            updatedUser[key] = updates[key];
        }
    });
    
    return updatedUser;
}

// 使用代理实现惰性拷贝
function createLazyCopy(original) {
    const changes = new Map();
    const handler = {
        get(target, prop) {
            // 如果该属性有修改,返回修改后的值
            if (changes.has(prop)) {
                return changes.get(prop);
            }
            
            // 否则返回原始值
            const value = target[prop];
            
            // 如果是对象,则返回代理
            if (value && typeof value === 'object') {
                return new Proxy(value, handler);
            }
            
            return value;
        },
        set(target, prop, value) {
            // 记录修改
            changes.set(prop, value);
            return true;
        }
    };
    
    return new Proxy(original, handler);
}
5.4 安全性考虑
  1. 避免原型污染: 确保不会拷贝__proto__等特殊属性
  2. 防止恶意对象: 处理具有getter的执行可能引发副作用的对象
  3. 内存安全: 避免拷贝会导致内存泄漏的大型对象
// 安全的深拷贝实现
function safeDeepClone(obj, options = {}) {
    const {
        maxDepth = 100,
        maxSize = 10000,
        allowFunctions = false,
        allowSymbols = true
    } = options;
    
    let size = 0;
    
    function clone(current, depth, cache) {
        // 检查深度限制
        if (depth > maxDepth) {
            throw new Error('Maximum depth exceeded');
        }
        
        // 检查大小限制
        if (size > maxSize) {
            throw new Error('Maximum size exceeded');
        }
        
        // 基本类型处理
        if (current === null || typeof current !== 'object') {
            // 检查 Symbol
            if (typeof current === 'symbol' && !allowSymbols) {
                throw new Error('Symbols are not allowed');
            }
            return current;
        }
        
        // 检查函数
        if (typeof current === 'function') {
            if (!allowFunctions) {
                throw new Error('Functions are not allowed');
            }
            return current;
        }
        
        // 检查缓存(防止循环引用)
        if (cache.has(current)) {
            return cache.get(current);
        }
        
        // 根据类型创建空对象
        let clone;
        const Ctor = current.constructor;
        
        switch (Ctor) {
            case Date:
                clone = new Date(current);
                break;
            case RegExp:
                clone = new RegExp(current);
                break;
            case Map:
                clone = new Map();
                break;
            case Set:
                clone = new Set();
                break;
            case Array:
                clone = [];
                break;
            default:
                // 普通对象 - 避免原型污染
                clone = Object.create(null);
        }
        
        // 存入缓存
        cache.set(current, clone);
        
        // 递归拷贝
        if (current instanceof Map) {
            current.forEach((value, key) => {
                size++;
                clone.set(
                    clone(key, depth + 1, cache),
                    clone(value, depth + 1, cache)
                );
            });
        } else if (current instanceof Set) {
            current.forEach(value => {
                size++;
                clone.add(clone(value, depth + 1, cache));
            });
        } else if (Array.isArray(current)) {
            for (let i = 0; i < current.length; i++) {
                size++;
                clone[i] = clone(current[i], depth + 1, cache);
            }
        } else {
            for (const key in current) {
                // 避免拷贝原型链上的属性
                if (current.hasOwnProperty(key)) {
                    // 避免特殊属性
                    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
                        continue;
                    }
                    
                    size++;
                    clone[key] = clone(current[key], depth + 1, cache);
                }
            }
        }
        
        return clone;
    }
    
    return clone(obj, 0, new WeakMap());
}

六、现在JavaScript中的拷贝模式

6.1 不可变数据模式
// 使用 Object.freeze 实现浅不可变
const immutableConfig = Object.freeze({
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    headers: Object.freeze({
        'Content-Type': 'application/json'
    })
});

// 深度冻结
function deepFreeze(obj) {
    Object.freeze(obj);
    
    Object.getOwnPropertyNames(obj).forEach(prop => {
        const value = obj[prop];
        
        if (value && typeof value === 'object' && !Object.isFrozen(value)) {
            deepFreeze(value);
        }
    });
    
    return obj;
}

// 使用 Proxy 实现不可变接口
function createImmutable(obj) {
    const handler = {
        get(target, prop) {
            const value = target[prop];
            
            // 如果是对象,返回代理
            if (value && typeof value === 'object') {
                return createImmutable(value);
            }
            
            return value;
        },
        set() {
            throw new Error('Cannot modify immutable object');
        },
        deleteProperty() {
            throw new Error('Cannot delete property from immutable object');
        }
    };
    
    return new Proxy(obj, handler);
}
6.2 结构共享(Persistent Data Structures)
// 简化的结构共享实现
class PersistentMap {
    constructor(data = {}) {
        this.data = data;
        this.version = 0;
    }
    
    set(key, value) {
        // 创建新版本,共享未修改的数据
        const newData = { ...this.data };
        newData[key] = value;
        
        const newMap = new PersistentMap(newData);
        newMap.version = this.version + 1;
        
        return newMap;
    }
    
    get(key) {
        return this.data[key];
    }
    
    // 比较两个版本是否相等
    equals(other) {
        if (this === other) return true;
        if (this.version !== other.version) return false;
        
        // 深度比较(简化版)
        return JSON.stringify(this.data) === JSON.stringify(other.data);
    }
}

// 使用示例
const map1 = new PersistentMap({ a: 1, b: 2 });
const map2 = map1.set('c', 3);
const map3 = map2.set('b', 20);

console.log(map1.get('b')); // 2
console.log(map3.get('b')); // 20
console.log(map1.data === map2.data); // false
6.3 使用现代API
// 使用 Object.groupBy 和 Map (ES2024)
const users = [
    { id: 1, name: 'Alice', group: 'admin' },
    { id: 2, name: 'Bob', group: 'user' },
    { id: 3, name: 'Charlie', group: 'admin' }
];

// 分组并创建不可变结构
const grouped = Object.groupBy(users, user => user.group);

// 转换为不可变 Map
const immutableGroups = new Map(Object.entries(grouped));

// 深度冻结
function deepFreezeMap(map) {
    map.forEach(value => {
        if (value && typeof value === 'object') {
            deepFreeze(value);
        }
    });
    Object.freeze(map);
}

deepFreezeMap(immutableGroups);

// 创建新版本
const updatedGroups = new Map(immutableGroups);
updatedGroups.set('moderator', [{ id: 4, name: 'David', group: 'moderator' }]);

七、总结与最佳实践建议

7.1 拷贝方法选择指南
场景 推荐方法 理由
简单对象,无嵌套 浅拷贝({...obj}) 快速、简单、高效
配置对象,少量嵌套 浅拷贝+手动处理嵌套 平衡性能和正确性
复杂嵌套对象 深拷贝(递归或sutrcturedClone) 确保完全独立
包含特殊类型(Date、RegExp) 自定义深拷贝或Lodash 正确处理特殊对象
性能关键路径 按需拷贝+结构共享 最大化性能
不可变数据 深拷贝+Object.freeze 确保数据不可变
生产环境 Lodash的_.cloneDeep 成熟、稳定、功能全
7.2 黄金法则
  1. 明确需求: 先确定是否需要深拷贝,很多时候浅拷贝就足够了
  2. 测试边界情况: 总是测试循环引用、特殊对象和大型数据结构
  3. 考虑性能: 对于频繁操作的数据,考虑使用不可变数据结构
  4. 保持简洁: 避免过度复杂的拷贝逻辑,必要时使用成熟的库
  5. 安全性第一: 处理用户输入时要特别小心,避免原型污染和其他安全问题
7.3 未来趋势
  1. 结构化克隆 API: structuredClone()将成为深拷贝的标准方式
  2. Records和Tuples: ES提案,提供原生不可变数据结构
  3. 更快的拷贝算法: WebAssembly和新的 JavaScript 引擎优化
  4. 编译时优化: 通过静态分析优化拷贝操作
7.4 最终建议代码
// 生产环境推荐的拷贝工具函数
class CloneUtils {
    // 简单的深拷贝(适合大多数场景)
    static deepClone(obj) {
        // 优先使用原生 API
        if (typeof structuredClone === 'function') {
            try {
                return structuredClone(obj);
            } catch (e) {
                // 如果失败,回退到其他方法
            }
        }
        
        // 回退到 JSON 方法(有限制)
        try {
            return JSON.parse(JSON.stringify(obj));
        } catch (e) {
            // 如果 JSON 方法失败,使用自定义实现
            return this.customDeepClone(obj);
        }
    }
    
    // 自定义深拷贝实现
    static customDeepClone(obj, cache = new WeakMap()) {
        // 基础类型和函数
        if (obj === null || typeof obj !== 'object') {
            return obj;
        }
        
        // 检查缓存
        if (cache.has(obj)) {
            return cache.get(obj);
        }
        
        // 处理特殊对象
        if (obj instanceof Date) {
            const cloned = new Date(obj);
            cache.set(obj, cloned);
            return cloned;
        }
        
        if (obj instanceof RegExp) {
            const cloned = new RegExp(obj);
            cache.set(obj, cloned);
            return cloned;
        }
        
        // 创建空对象/数组
        const cloned = Array.isArray(obj) ? [] : {};
        cache.set(obj, cloned);
        
        // 递归拷贝属性
        for (const key in obj) {
            if (obj.hasOwnProperty(key)) {
                cloned[key] = this.customDeepClone(obj[key], cache);
            }
        }
        
        return cloned;
    }
    
    // 安全的浅拷贝(防止原型污染)
    static safeShallowClone(obj) {
        if (obj === null || typeof obj !== 'object') {
            return obj;
        }
        
        const cloned = Array.isArray(obj) ? [] : {};
        
        for (const key in obj) {
            if (obj.hasOwnProperty(key) && 
                key !== '__proto__' && 
                key !== 'constructor' && 
                key !== 'prototype') {
                cloned[key] = obj[key];
            }
        }
        
        return cloned;
    }
    
    // 性能优化的拷贝(只拷贝需要修改的部分)
    static smartClone(original, modifications) {
        const result = { ...original };
        
        for (const key in modifications) {
            if (modifications.hasOwnProperty(key)) {
                const originalValue = original[key];
                const modifiedValue = modifications[key];
                
                if (originalValue && typeof originalValue === 'object' &&
                    modifiedValue && typeof modifiedValue === 'object' &&
                    !Array.isArray(originalValue) && !Array.isArray(modifiedValue)) {
                    // 递归处理嵌套对象
                    result[key] = this.smartClone(originalValue, modifiedValue);
                } else {
                    result[key] = modifiedValue;
                }
            }
        }
        
        return result;
    }
}

// 使用示例
const data = {
    user: {
        name: 'John',
        settings: {
            theme: 'dark',
            notifications: true
        }
    },
    items: [1, 2, 3]
};

// 简单深拷贝
const cloned1 = CloneUtils.deepClone(data);

// 智能拷贝(只修改部分)
const cloned2 = CloneUtils.smartClone(data, {
    user: {
        settings: {
            theme: 'light'
        }
    }
});

console.log(cloned2.user.settings.theme); // 'light'
console.log(cloned2.user.settings.notifications); // true(保持原值)
console.log(cloned2.items === data.items); // true(未修改的部分共享引用)

结语

JavaScript拷贝是一个看似简单实则复杂的话题。通过本文的学习,你应该能够:

  1. 理解浅拷贝和深拷贝的根本区别
  2. 根据不同的场景选择合适的拷贝策略
  3. 实现各种拷贝方法,处理边界情况
  4. 优化拷贝性能,避免常见陷阱 记住,没有一种拷贝方法是适用于所有场景的万能解决方案。最好的方法是理解每种技术的优缺点,根据具体需求做出明智的选择。

在实际开发中,当面临拷贝需求时,先问自己几个问题:

  • 我真的需要完全独立的数据吗?
  • 数据结构有多复杂?
  • 性能要求有多高?
  • 是否有特殊类型的对象需要处理?

通过回答这些问题,你将能够选择最合适的拷贝策略,写出更健壮、更高效的代码。

深入学习资源:

昨天 — 2025年12月4日技术

基于deepseek实现的ai问答小程序

2025年12月4日 19:02

功能预览

截屏2025-12-04 18.38.30.png

截屏2025-12-04 18.38.45.png

截屏2025-12-04 18.38.55.png

截屏2025-12-04 18.39.52.png

截屏2025-12-04 18.39.59.png

截屏2025-12-04 18.40.36.png

截屏2025-12-04 18.41.41.png

lovegif_1764845391382.gif

✨ 核心特性

🚀 流式数据处理

  • 由于微信小程序这里对SSE的流式处理不支持,只有chunks的模式,故采用H5的方式来实现。
  • SSE(Server-Sent Events)流式接收:实现了完整的流式数据处理机制,在收到第一块数据时立即渲染
  • 分块解析与缓冲:支持多行 SSE 事件的正确解析,处理不完整的数据块
  • 实时渲染反馈:使用 requestAnimationFrame 优化渲染性能,避免频繁 DOM 更新导致的卡顿

💬 对话管理

  • 消息追踪系统:自动生成唯一消息 ID,维护消息状态(pending/success/error)
  • 会话隔离:支持多会话并行,可创建、切换、管理历史会话
  • 消息快照存储:保存用户消息与 AI 回复的完整上下文

🎨 交互优化

  • 思考中加载动画:AI 回复延迟时的优雅 UX——流式数据到达时即刻消失
  • 自动滚动定位:智能滚动到最新消息,支持指定消息定位,避免内容变化导致的滚动位置偏移
  • 消息操作面板:支持复制、点赞、重新生成等交互功能

🎯 多模型支持

  • 模型切换器:支持在对话过程中动态选择不同 AI 模型
    • 快速问答模式(deepseek-chat)
    • 深度思考模式(deepseek-reasoner)

📝 Markdown 渲染

  • 完整的 Markdown 支持:包括代码块、表格、列表、引用等
  • 代码高亮:使用 highlight.js 实现多语言代码着色
  • HTML 安全渲染:配置化处理,支持链接自动新窗口打开

🎨 设计系统

  • 现代渐变设计:渐变色(紫→紫→粉)贯穿整个应用
  • 响应式布局:基于 CSS 变量的深浅主题支持
  • 动画细节:平滑的消息滑入、加载转圈、脉冲效果等

🏗️ 项目结构

src/
├── components/              # Vue 组件库
│   ├── MessageItem.vue      # 单条消息渲染组件(含 Markdown 解析、操作面板)
│   ├── InputArea.vue        # 输入框及工具栏(含模型选择、录音、新建会话)
│   ├── HistroySessions.vue  # 历史会话管理弹窗
│   └── HelloWorld.vue       # 示例组件
├── utils/
│   ├── type.ts              # TypeScript 类型定义(ChatMessage、Session 等)
│   ├── request.ts           # HTTP 请求封装(get/post)
│   ├── streamRequest.ts     # SSE 流式请求实现
│   └── markdown.ts          # Markdown 渲染引擎配置
├── assets/                  # 静态资源
│   ├── regenerate-icon.svg  # 重新生成按钮图标
│   ├── copy-icon.svg        # 复制按钮图标
│   ├── like-icon.svg        # 点赞按钮图标
│   ├── thinking-icon.svg    # 思考中加载动画
│   └── logo1.png            # AI 角色头像
├── App.vue                  # 主应用组件(核心业务逻辑)
├── main.ts                  # 应用入口
└── style.scss               # 全局样式与设计系统变量

🔧 技术栈

技术 版本 用途
Vue 3.5.13 前端框架
TypeScript 5.8 类型安全
Vite 6.3.5 构建工具
Markdown-it 14.1.0 Markdown 渲染
highlight.js 内置 代码高亮
SCSS 1.94.2 样式预处理
Element Plus 2.10.4 UI 组件库(可选)
socket.io-client 4.8.1 WebSocket 支持(预留)

📥 快速开始

前置要求

  • Node.js >= 16
  • npm 或 yarn

安装依赖

npm install

开发服务器

npm run dev

访问 http://localhost:5173

生产构建

npm run build

构建输出到 dist/ 目录

预览构建结果

npm run preview

🎯 核心业务流程

对话流程

用户输入 → 发送消息
    ↓
创建 User Message (pending)
显示在消息列表 → 自动滚动到底部
    ↓
调用 streamFetch(POST /api/ai/chat)
Assistant Message 创建 (pending)
    ↓
SSE 数据流开始接收
    ├─ 第一块数据到达 → status 变为 success(思考中 icon 消失)
    ├─ 持续接收 → 实时渲染 Markdown 内容
    └─ 滚动到最新消息
    ↓
流结束 (onDone) → 最终更新消息状态
    ↓
用户可进行操作:复制、点赞、重新生成

会话管理流程

新建会话 → POST /api/ai/sessions
    ↓
获取 session.id → 用于后续对话上下文关联
    ↓
切换历史会话 → GET /api/ai/sessions/{id}/messages
    ↓
加载历史消息 → 等待 DOM 稳定 → 智能滚动到底部

🔐 API 接口约定

对话接口

POST /api/ai/chat
Body: {
  messages: Array<{ role: string; content: string }>,
  sessionId?: string | number,
  stream: true,
  model: string  // "deepseek-chat" | "deepseek-reasoner"
}
Response: 流式 SSE
  data: 文本块
  [可选] event: 事件类型
  [可选] id: 事件 ID

会话接口

POST /api/ai/sessions
Body: { title: string; summary?: string }
Response: { session: { id: string | number; ... } }

GET /api/ai/sessions/{id}/messages
Response: { messages: Array<HistoryMessage> }
  HistoryMessage = {
    role: "assistant" | "user",
    content: string,
    created_at?: string
  }

💡 亮点分析

1. 高性能流式渲染

  • 使用 requestAnimationFrame 进行 Markdown 渲染节流,避免频繁重排/重绘
  • SSE 流数据的分块处理和缓冲机制确保即便数据包不完整也能正确解析
  • 流式更新时的自动滚动采用两帧 rAF 等待,确保 CSS 动画和布局稳定

2. 响应式适配

  • 使用rem单位做响应式适配,目前支持各种大小屏幕的设备,兼容PC端的良好显示。
关键代码

// 用于设置rem单位的字体大小(动态)
function setRem() {
    const baseWidth = 375; // 设计稿宽度
    const minFontSize = 6; // 最小字体
    const maxFontSize = 16; // 最大字体(PC屏或大屏限制)
    const html = document.documentElement;
    const width = html.clientWidth;

    let fontSize = (width / baseWidth) * 12;

    if (fontSize < minFontSize) fontSize = minFontSize;
    if (fontSize > maxFontSize) fontSize = maxFontSize;

    html.style.fontSize = fontSize + 'px';
}
setRem();
window.addEventListener('resize', setRem);


## 🔄 后续优化方向

*   [ ] 语音输入完整实现(当前预留)
*   [ ] 消息搜索与过滤
*   [ ] 导出对话记录(PDF、Markdown)
*   [ ] 数据加密传输
*   [ ] 更多 AI 模型集成

## 📄 相关文件说明

| 文件                 | 说明                               |
| ------------------ | -------------------------------- |
| `streamRequest.ts` | SSE 流式数据解析的核心实现,支持多行 data 字段     |
| `App.vue`          | 主业务逻辑,包含对话流程、会话管理、自动滚动优化         |
| `MessageItem.vue`  | 消息渲染与交互,思考中加载动画、Markdown 解析、操作按钮 |
| `InputArea.vue`    | 输入框、模型切换、会话操作                    |
| `markdown.ts`      | Markdown-it 配置,代码高亮、链接处理         |
| `style.scss`       | 全局设计系统,渐变、主题、动画                  |


深入执行上下文:JavaScript 中 this 的底层绑定机制

作者 xhxxx
2025年12月4日 18:29

深入理解 JavaScript 中的 this:设计初衷、绑定规则与常见陷阱

在 JavaScript 开发中,this 是一个既基础又容易让人困惑的概念。它看似简单,却因绑定规则依赖于函数调用方式而非声明位置,常常导致意料之外的行为。本文将从 this 的本质出发,结合语言设计背景、执行机制以及实际代码示例,系统性地梳理 this 的行为规律,并为后续讨论“绑定丢失”问题预留空间。


从自由变量说起:为什么需要 this

在深入 this 之前,不妨先回顾一个更基础的概念:自由变量(free variable)

考虑如下代码:

var name = "全局";

function greet() {
  console.log("你好," + name);
}

greet(); // 你好,全局

函数 greet 内部使用了变量 name,但它并未在函数内部声明。这个 name 就是一个自由变量。JavaScript 引擎会沿着词法作用域链(Lexical Scope Chain) 向外查找,最终在全局作用域中找到 name 的定义。

这种机制是静态的——变量的查找路径在代码书写时就已确定,与函数如何被调用无关。这也是 JavaScript 中绝大多数变量访问的行为模式。

然而,面向对象编程带来了一个新需求:同一个函数可能属于多个对象,希望在运行时动态地知道“当前是哪个对象在调用我”

例如:

var person1 = { name: "Alice", sayHi: greet };
var person2 = { name: "Bob",   sayHi: greet };

person1.sayHi(); // 期望输出:你好,Alice
person2.sayHi(); // 期望输出:你好,Bob

如果 greet 依然依赖词法作用域中的 name,它永远只能访问到全局的 "全局",而无法感知调用者是谁。词法作用域在此失效了

于是,JavaScript 引入了 this —— 一个不依赖词法作用域、而由调用方式决定的特殊关键字。它让函数能够在运行时动态获取“调用上下文”,从而实现对所属对象的自引用。

换句话说:自由变量靠“写在哪”决定值,this 靠“怎么调”决定值

正是这种设计,使得 this 成为了 JavaScript 执行模型中一个独特而关键的存在——它打破了静态作用域的规则,引入了动态上下文的能力,但也因此带来了理解上的挑战。

this 是什么?

在 JavaScript 中,this 是一个运行时绑定的上下文对象引用。它不是一个变量,而是一个关键字,其值在函数被调用时动态确定,取决于函数是如何被调用的,而不是在哪里定义的。

这与 JavaScript 中其他变量(如自由变量)的查找机制截然不同——后者遵循词法作用域(Lexical Scope) ,由函数声明的位置决定;而 this 则完全由调用方式决定,属于动态作用域的一种体现。


this 的设计初衷

JavaScript 最初被设计为一种轻量级脚本语言,用于在浏览器中操作 DOM。为了支持面向对象编程(OOP),即使在没有 class 的早期版本中,也需要一种机制让函数能够访问所属对象的属性和方法。

于是,this 被引入:当一个函数作为对象的方法被调用时,this 自动指向该对象。这样,开发者就可以在方法内部通过 this.xxx 访问对象自身的数据。

然而,由于 JavaScript 函数是一等公民(first-class citizens),可以被赋值、传递、独立调用,这就导致同一个函数在不同调用场景下 this 指向可能完全不同——这种灵活性也带来了复杂性。


varlet 声明对全局对象的影响

这一点与 this 的默认绑定密切相关:

  • 使用 var 在全局作用域声明的变量,会自动挂载到全局对象上(如 window.myVar = ...)。
  • 使用 letconst 声明的变量则不会挂载到全局对象
var a = 1;
let b = 2;

console.log(window.a); // 1
console.log(window.b); // undefined

因此,在非严格模式下,若 this 指向 window,通过 this.a 可以访问到 var a,但无法访问 let b。这也解释了为什么在某些代码中 this.xxx 能“神奇地”访问到全局变量——其实是访问了挂载在 window 上的属性。

使用var声明挂载变量到window对象上并不是一件好的事情,他会污染全局环境


、JavaScript 执行机制与 this 的“例外性”

JavaScript 引擎在执行代码前会经历编译阶段(包括词法分析、作用域构建等)。变量和函数的作用域链在编译阶段就已确定,这就是词法作用域的基础。

然而,this 是一个例外:它的值无法在编译阶段确定,必须等到运行时根据调用栈和调用方式动态计算。这意味着:

  • 即使两个完全相同的函数体,只要调用方式不同,this 就可能指向完全不同的对象。
  • this 与作用域链无关,它属于执行上下文(Execution Context) 的一部分,而非词法环境。

2e1b50313261ecf6e1348b4e76311bff.png


this 指向的几种典型情况

根据调用方式,this 的绑定可分为以下几类:

1. 作为对象的方法调用

var myObj = {
    name:"极客时间",
    showThis:function(){
        console.log(this);//this->myObj
    }
}
    myObj.showThis();

作为对象的方法调用时,它指向调用该函数的对象

2. 作为普通函数调用

function print() {
  console.log(this); // 非严格模式:window;严格模式:undefined
}
print();

作为普通函数调用时,它指向全局对象window(非严格模式)/undefined(严格模式)

3. 构造函数调用

function CreateObj(){
            // var tempObj = {};
            //CreateObj.call(tempObj);
            //tempObj.__proto__ = CreateObj.prototype;
            //return tempObj;
            console.log(this);
            this.name="极客时间";

        }
        var myObj = new CreateObj();
        console.log(myObj);

作为构造函数调用时,它指向当前的构造函数的实例化对象

4. 使用 call / apply 绑定this

 let bar ={
            myName:"极客邦",
            test:1
        }
        function foo(){
            this.myName="极客时间";
        }
        // 接受指定this为第一个参数,并运行
        foo.call(bar);// this 被指定为bar 
        // 和call 一样
        foo.apply(bar);// this 被指定为bar 
        console.log(bar);

call和apply都能够改变this的指向,他们接受指定this为第一个参数,我的理解:你可以认为指定一个对象来调用这个函数。值得注意的是,在这段代码中二者似乎是等价的,但实际上二者在参数上有差异,在这里就不深入讨论

5. 事件处理函数中的 this

在 DOM 事件监听器中,this 默认指向触发事件的元素

<a href="#" id="link">点击我</a>
    <script>
        document.getElementById("link").addEventListener("click",function(){
            console.log(this);
        })

image.png触发事件后,可以看到,this指向的是当前触发改事件的DOM元素


、this的绑定规则

默认绑定

一般存在于最常用的函数调用类型;独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则

考虑这样一段代码:


function foo(){
console.log(this.a)
}
var a =2;
foo();//2

我们能看到,当我们调用foo()函数时,this.a被解析成了全局变量a。这是为什么?是的,这就是我们说的默认绑定,this指向全局对象,当然这必须是在非严格模式下,严格模式下则会绑定到undefined。

一句话总结默认绑定:非严格模式下,当函数独立调用时,this自动绑定到全局对象上

⚠️ 这种设计其实暴露了早期 JavaScript 的一个“历史包袱”:在非严格模式下,意外的全局 this绑定可能导致隐式创建全局变量,污染全局命名空间。

隐式绑定

当函数调用的位置存在上下文对象,或者说该函数被调用时被某个对象“拥有”或“包含”,隐式绑定会把函数调用中的this绑定到这个上下文对象

考虑这样一段代码:

function foo ()
{
console.log(this.a);
}
var obj ={
a:2,
foo:foo// 实际上是对foo的引用

}
obj.foo()//2

我们能看到 foo()函数调用时,能够访问到obj的内部属性a,这是因为它由obj调用,所以它被obj所包含。
值得注意的是,对象引用链中只有上一层或者说最后一层在调用位置起作用


function foo ()
{
console.log(this.a);
}
var obj2 ={
a:42,
foo:foo
}
var obj1 ={
a:2,
obj2:obj2
}
obj1.obj2.foo();//42

因为最后调用foo的是obj2,所以 foo 的 this.a 指向 obj 2中的 a

显式绑定

JS中绝大多数函数以及你自己创建的函数,都可以使用call()和apply()方法,你可以使用他们来直接指定this的绑定对象,因此我们称为显示绑定

考虑这样一段代码

function foo ()
{
console.log(this.a);
}
var obj ={
a:2
}
foo.call(obj);//2

按照前面的理解:foo()在全局中被调用,那么this应该默认被绑定到全局,但是这里却能够访问到obj中的a,这就是 call() 的作用 -->我们可以在调用foo时强制把它的this绑定到obj上

从this绑定的角度出发,call()和apply()是一样的,都用来强制绑定this到指定对象,他们的区别体现在其他参数上,我们这里不考虑

如果你传入了一个原始值(字符串,布尔值,数字)来当作this的绑定对象,这个值会被转换为它的对象形式(new String(),new Boolean(),new Number())。这个过程被称为“装箱”

new绑定

使用new来调用foo()时,我们会构造一个新对象并把它绑定到foo()调用中的this上。我们称为new绑定

首先我们需要重新定义JS中的构造函数-->构造函数只是一些使用new操作符时被调用的普通函数。
所以实际上,并不存在所谓的构造函数,只有对于函数的构造调用
接下来考虑这样一段代码:

function foo (a)
{
  this.a = a;
}
var bar =new foo(2);
console.log(bar.a);//2

当我们使用new时,就会自动执行这样一些操作:

  1. 创建一个新对象{}
  2. 新对象被执行与[[prototype]]连接
  3. 将函数调用的this绑定到该对象
  4. 如果没有返回其他对象,则自动返回这个新对象

所以上述代码的实际底层是:

function foo (a)
{
 var bar ={};
 bar.__proto__ = foo().prototype
 foo.call(bar);
 return bar;
  this.a = a;
}

绑定丢失

隐式丢失

一个常见的绑定问题就是被隐式绑定的函数会丢失绑定对象,会重新应用为默认绑定,从而使得this绑定到全局会undefined

function foo(){
console.log(this.a)
}
var obj = {
  a:2,
  foo:foo
  
};


var bar = obj.foo;//函数别名
var a = "global";
bar();//global

这里导致绑定丢失的原因是bar实际上也是对foo的引用,而当bar()调用时,它其实是一个函数的独立调用,所以执行了默认绑定

再看另外一种情况,在传入回调函数时:

function foo(){
console.log(this.a)
}
function doFoo(fn)
{
    fn();
}
var obj = {
  a:2,
  foo:foo
  
};

// 把方法赋值给一个变量 —— 绑定丢失!
var bar = obj.foo;//函数别名
var a = "global";
doFoo(obj.foo);//global

这样同样导致了绑定丢失,那么造成这种情况的原因是?
foo()函数的执行实际上转交由doFoo来执行了,而在它的执行上下文中没有a这个变量,所以沿着作用域链查找到全局中的a

这样的绑定丢失的核心是:回调函数的执行权被移交到了其他函数手中

即使是显示绑定也无法避免绑定丢失

function foo() {
  console.log("this.a =", this.a); // 期望this指向obj,输出2
}

var obj = { a: 2 };

// 定义一个接收回调的函数
function doCallback(callback) {
  callback(); // 这里执行回调,call的绑定丢失
}

// 用call显式绑定foo的this到obj,作为回调传递
doCallback(function() {
  foo.call(obj); // 看似绑定了obj
});

// 改造:故意制造绑定丢失(更直观)
function doLostBind(callback) {
  // 模拟实际场景中对回调的二次调用,绑定丢失
  const temp = callback;
  temp(); // 执行时丢失原call绑定
}

// 传递用call绑定的函数,最终绑定丢失
doLostBind(foo.call.bind(foo, obj)); // 非严格模式下输出this.a = undefined(指向window)

doLostBind(foo.call.bind(foo, obj))这种写法看似传入时做了绑定,但其实这只是生成了一个“准备绑定的函数”,并没有真正的执行绑定逻辑


怎么解决绑定丢失的问题?

  • 硬绑定
function foo(){
console.log(this.a)
}
var obj = {
  a:2,
};

var bar = function () {
    foo.call(obj);
    
}
bar();//2
setTimeout(bar,2);//2

bar.call(window);//2
  1. 在bar函数的内部,我们把foo的this强制绑定在了obj上,无论之后怎么调用bar,他都会手动在obj上调用foo
  2. 硬绑定不可能再修改它的this,我们想要通过bar.call(window); 修改绑定对象,但无论你怎么修改,最后都会执行 foo.call(obj);把this重新绑定到obj上

由于硬绑定是一种很常用的模式,所以ES5提供了它的内置方法bind(),用法如下

function foo(temp){
console.log(this.a,temp);
return this.a+ temp
}
var obj = {
  a:2,
};


var bar = foo.bind(obj)
var b = bar(3);//2 3
console.log(b);// 5

bind()会返回一个硬编码的新函数,他会把你指定的参数设置为this的上下文并调用原始函数

结语

this 是 JavaScript 中一个强大但需要谨慎使用的机制。理解它的设计初衷、绑定规则以及与作用域系统的差异,是写出健壮、可维护代码的关键。掌握 this,不仅有助于避免常见 bug,也能更深入地理解 JavaScript 的执行模型。

在下一篇文章中,我们将了解更多关于this的底层

企业级 Vue 3 基础数据管理方案:从混乱到统一

作者 狗弟
2025年12月4日 18:13

作者: 狗弟 发布日期: 2025-12-04
技术栈: Vue 3 + TypeScript + Composition API + Element Plus
阅读时长: 约 15 分钟


📌 引言

在大型企业应用中,基础数据(字典、港口、货币、国家等)的管理往往是一个被忽视但至关重要的领域。一个设计良好的基础数据方案可以:

  • 🚀 减少 70% 以上的重复代码
  • 降低 API 请求次数 80%+
  • 🎯 提升开发效率和代码可维护性
  • 🌍 无缝支持国际化切换

本文将分享我们在航运物流系统中设计和实现的统一基础数据管理方案,涵盖架构设计、性能优化、缓存策略和最佳实践。


🤔 问题背景:野蛮生长的痛点

最初的混乱

在项目初期,每个开发者按自己的方式获取和使用基础数据:

// 🔴 问题代码示例:每个组件各自为政

// 组件 A:直接调用 API
const res = await api.getDictList('ORDER_STATUS')
const statusList = res.data

// 组件 B:使用 hooks 但没有缓存
const { data } = useAllDict('ORDER_STATUS') // 每次调用都请求 API

// 组件 C:在 Vuex 中存储
store.dispatch('loadDictData', 'ORDER_STATUS')
const statusList = store.state.dict.ORDER_STATUS

// 组件 D:硬编码
const statusList = [
  { value: 1, label: '待处理' },
  { value: 2, label: '已完成' },
  // ...
]

这导致了严重的问题

问题 影响
API 请求爆炸 同一个字典在 10 个组件中被请求 10 次
数据不一致 硬编码的数据与后端不同步
国际化困难 中英文切换需要手动处理每个地方
代码重复 格式化、查找 label 的逻辑到处都是
类型缺失 没有 TypeScript 类型,IDE 无法提示

🏗️ 架构设计:统一数据源

核心设计理念

我们采用单一数据源 + 工厂模式的架构:

┌─────────────────────────────────────────────────────────┐
│                    业务组件层                             │
│   ┌─────────┐   ┌─────────┐   ┌─────────┐              │
│   │ 下拉框   │   │ 表格列   │   │ 标签     │              │
│   └────┬────┘   └────┬────┘   └────┬────┘              │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
├──────────────────────▼──────────────────────────────────┤
│              Composables 统一入口                        │
│   ┌─────────────────────────────────────────────────┐   │
│   │  import { useDictType, usePorts } from          │   │
│   │         '~/composables/basicData'               │   │
│   └─────────────────────────────────────────────────┘   │
├─────────────────────────────────────────────────────────┤
│                    模块内部架构                          │
│                                                         │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│   │  hooks   │  │ adapters │  │  cache   │            │
│   │ 业务封装  │  │ 数据适配  │  │ 缓存管理  │            │
│   └────┬─────┘  └────┬─────┘  └────┬─────┘            │
│        │             │             │                    │
│        └─────────────┴─────────────┘                    │
│                      │                                  │
│              ┌───────▼───────┐                          │
│              │     API       │                          │
│              │  统一数据获取   │                          │
│              └───────────────┘                          │
└─────────────────────────────────────────────────────────┘

目录结构

src/composables/basicData/
├── index.ts          # 统一导出入口
├── hooks.ts          # 业务数据 Hooks(港口、船舶、航线等)
├── useDict.ts        # 字典数据 Hooks
├── cache.ts          # 缓存管理(TTL、清理策略)
├── adapters.ts       # 数据适配器(API → 标准格式)
├── api/              # API 封装
│   └── index.ts
└── types/            # TypeScript 类型定义
    └── index.ts

💡 核心实现

1. 工厂函数:统一的 Hook 创建模式

不同类型的基础数据(港口、船舶、货币等)有相同的使用模式,我们用工厂函数消除重复:

/**
 * 创建基础数据 Hook 的工厂函数
 * 所有基础数据 Hook 共享相同的接口和行为
 */
function createBaseDataHook<T extends BaseDataItem, R>(
  fetchFn: () => Promise<R>,
  transformFn: (response: R) => T[],
  cacheConfig: CacheConfig,
): (params?: QueryParams) => BaseDataHookResult<T> {
  
  return (params: QueryParams = {}): BaseDataHookResult<T> => {
    const { useEnglish = false } = params

    // 使用缓存系统
    const { data, loading, error, refresh, clearCache } = useBasicDataCache(
      cacheConfig.key,
      async () => transformFn(await fetchFn()),
      { ttl: cacheConfig.ttl },
    )

    // 根据参数过滤数据
    const filteredData = computed(() => {
      let result = data.value || []
      
      if (params.keyword) {
        result = BaseAdapter.filterByKeyword(result, params.keyword)
      }
      if (params.enabledOnly) {
        result = BaseAdapter.filterByEnabled(result, true)
      }
      
      return result
    })

    // Element Plus 格式的选项
    const options = computed(() => 
      BaseAdapter.toOptions(filteredData.value, useEnglish)
    )

    return {
      data: filteredData,
      loading,
      error,
      options,
      isEmpty: computed(() => filteredData.value.length === 0),
      isReady: computed(() => !loading.value && !error.value),
      refresh,
      search: (keyword) => BaseAdapter.filterByKeyword(data.value, keyword),
      getByCode: (code) => data.value?.find(item => item.code === code),
      clearCache,
    }
  }
}

// 一行代码创建新的基础数据 Hook
export const usePorts = createBaseDataHook(
  queryPortList,
  PortAdapter.transform,
  { key: 'PORTS', ttl: 10 * 60 * 1000 }
)

export const useVessels = createBaseDataHook(
  queryVesselList,
  VesselAdapter.transform,
  { key: 'VESSELS', ttl: 15 * 60 * 1000 }
)

2. 字典数据:专为 UI 组件优化

字典数据是最常用的基础数据类型,我们为其设计了专门的 API:

/**
 * 特定字典类型的组合式函数
 * 提供开箱即用的下拉选项和 label 查询
 */
export function useDictType(dictType: string) {
  const { locale } = useI18n()
  const { data: dictMap, loading, error, refresh } = useAllDictData()

  // 响应式的选项列表,自动根据语言切换
  const options = computed(() => {
    const items = dictMap.value?.[dictType] || []
    return items.map(item => ({
      label: locale.value === 'en' ? item.labelEn : item.label,
      value: item.value,
    }))
  })

  // 根据 code 获取 label,支持国际化
  function getLabel(code: string): string {
    const items = dictMap.value?.[dictType] || []
    const item = items.find(i => i.value === code)
    if (!item) return code
    return locale.value === 'en' ? item.labelEn : item.label
  }

  return {
    options,
    items: computed(() => dictMap.value?.[dictType] || []),
    loading,
    error,
    getLabel,
    getLabels: (codes: string[]) => codes.map(getLabel),
    refresh,
  }
}

3. 智能缓存:TTL + 全局共享

缓存是性能优化的关键,我们实现了带 TTL 的响应式缓存:

/**
 * 带 TTL 的响应式缓存 Hook
 * 支持过期自动刷新、手动清除
 */
export function useBasicDataCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: { ttl: number }
) {
  // 使用 VueUse 的 useStorageAsync 实现持久化
  const cached = useStorageAsync<CacheEntry<T> | null>(
    `basic-data:${key}`,
    null,
    localStorage
  )

  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  // 检查缓存是否过期
  const isExpired = computed(() => {
    if (!cached.value) return true
    return Date.now() - cached.value.timestamp > options.ttl
  })

  // 加载数据(带去重)
  let loadingPromise: Promise<void> | null = null
  
  async function load() {
    if (loadingPromise) return loadingPromise
    
    if (!isExpired.value && cached.value) {
      data.value = cached.value.data
      return
    }

    loading.value = true
    loadingPromise = fetcher()
      .then(result => {
        data.value = result
        cached.value = { data: result, timestamp: Date.now() }
      })
      .catch(err => {
        error.value = err
        // 如果有旧缓存,降级使用
        if (cached.value) {
          data.value = cached.value.data
        }
      })
      .finally(() => {
        loading.value = false
        loadingPromise = null
      })

    return loadingPromise
  }

  // 自动加载
  load()

  return {
    data: computed(() => data.value),
    loading: computed(() => loading.value),
    error: computed(() => error.value),
    refresh: () => {
      cached.value = null
      return load()
    },
    clearCache: () => {
      cached.value = null
      data.value = null
    }
  }
}

🎯 使用示例

场景 1:下拉选择器

<template>
  <el-select v-model="form.status" placeholder="请选择状态">
    <el-option
      v-for="item in statusOptions"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { useDictType } from '~/composables/basicData'

const { options: statusOptions } = useDictType('ORDER_STATUS')
</script>

场景 2:表格列显示 label

<template>
  <el-table :data="tableData">
    <el-table-column prop="code" label="编号" />
    <el-table-column label="状态">
      <template #default="{ row }">
        <el-tag :type="getStatusColor(row.status)">
          {{ getStatusLabel(row.status) }}
        </el-tag>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup lang="ts">
import { useDictType } from '~/composables/basicData'

const { getLabel: getStatusLabel, getColorType: getStatusColor } = 
  useDictType('ORDER_STATUS')
</script>

场景 3:港口选择(带搜索)

<template>
  <el-select
    v-model="selectedPort"
    filterable
    remote
    :remote-method="handleSearch"
    :loading="loading"
    placeholder="搜索港口..."
  >
    <el-option
      v-for="port in portOptions"
      :key="port.value"
      :label="port.label"
      :value="port.value"
    />
  </el-select>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePorts } from '~/composables/basicData'

const keyword = ref('')
const searchParams = computed(() => ({
  keyword: keyword.value,
  enabledOnly: true
}))

const { options: portOptions, loading } = usePorts(searchParams)

function handleSearch(query: string) {
  keyword.value = query
}
</script>

场景 4:获取关联数据

import { usePorts, useCountries } from '~/composables/basicData'

const { getByCode: getPort } = usePorts()
const { getByCode: getCountry } = useCountries()

// 获取港口及其所属国家信息
function getPortWithCountry(portCode: string) {
  const port = getPort(portCode)
  if (!port) return null
  
  const country = port.countryCode ? getCountry(port.countryCode) : null
  
  return {
    ...port,
    countryName: country?.nameCn || '',
    countryNameEn: country?.nameEn || '',
  }
}

⚡ 性能优化效果

Before vs After

指标 优化前 优化后 提升
字典 API 请求次数/页 15-20 次 1 次 95%↓
首屏加载时间 3.2s 1.8s 44%↓
内存占用(字典数据) 分散存储 统一缓存 60%↓
代码行数(基础数据相关) ~2000 行 ~500 行 75%↓

缓存命中率

┌────────────────────────────────────────────────────┐
│                  缓存命中情况                        │
├────────────────────────────────────────────────────┤
│ 字典数据 ████████████████████████████████ 98%       │
│ 港口数据 ██████████████████████████████░░ 92%       │
│ 货币数据 ████████████████████████████████ 99%       │
│ 国家数据 ████████████████████████████████ 99%       │
└────────────────────────────────────────────────────┘

🔧 最佳实践

✅ 推荐做法

// 1. 使用解构获取需要的方法
const { options, getLabel, loading } = useDictType('STATUS')

// 2. 使用 computed 传递动态参数
const params = computed(() => ({ keyword: search.value }))
const { data } = usePorts(params)

// 3. 处理加载状态
<template v-if="loading">加载中...</template>
<template v-else>{{ getLabel(code) }}</template>

// 4. 统一从入口导入
import { useDictType, usePorts } from '~/composables/basicData'

❌ 避免做法

// 1. 不要在循环中调用 Hook
// ❌ 错误
tableData.forEach(row => {
  const { getLabel } = useDictType('STATUS') // 每次循环都创建新实例
  row.statusLabel = getLabel(row.status)
})

// ✅ 正确
const { getLabel } = useDictType('STATUS')
tableData.forEach(row => {
  row.statusLabel = getLabel(row.status)
})

// 2. 不要忽略加载状态
// ❌ 错误
const label = getLabel(code) // 数据可能还未加载

// ✅ 正确
const label = computed(() => loading.value ? '加载中' : getLabel(code))

📦 扩展:添加新的基础数据类型

添加新的基础数据类型非常简单,只需 3 步:

// 1. 定义 API
// api/index.ts
export async function queryNewDataList() {
  return request.get('/api/new-data/list')
}

// 2. 定义适配器
// adapters.ts
export const NewDataAdapter = {
  transform(response: ApiResponse): BaseDataItem[] {
    return response.data.map(item => ({
      code: item.id,
      nameCn: item.name,
      nameEn: item.nameEn,
      enabled: item.status === 1,
    }))
  }
}

// 3. 创建 Hook
// hooks.ts
export const useNewData = createBaseDataHook(
  queryNewDataList,
  NewDataAdapter.transform,
  { key: 'NEW_DATA', ttl: 10 * 60 * 1000 }
)

// 4. 导出
// index.ts
export { useNewData } from './hooks'

🎓 总结

通过这套基础数据管理方案,我们实现了:

  1. 统一入口 - 所有基础数据从 ~/composables/basicData 导入
  2. 自动缓存 - TTL 机制 + 全局共享,避免重复请求
  3. 类型安全 - 完整的 TypeScript 类型定义
  4. 国际化 - 自动根据语言环境切换中英文
  5. 开箱即用 - Element Plus 格式的选项,直接用于组件
  6. 易于扩展 - 工厂模式,添加新类型只需几行代码

这套方案已在我们的航运物流系统中稳定运行,支撑着日均数万次的基础数据查询,希望能给正在处理类似问题的团队一些启发。


📚 相关资源


💬 欢迎在评论区交流讨论,如果觉得有帮助,请点赞收藏~

ScaleSlider 组件实现

作者 Syron
2025年12月4日 18:01

需求分析

功能需求

  • ✅ 支持纵向和横向两种方向
  • ✅ 显示刻度线(主刻度和次刻度)
  • ✅ 实时显示当前值(带单位)
  • ✅ 箭头指向滑块位置
  • ✅ 值显示框跟随滑块移动
  • ✅ 支持鼠标拖拽和点击跳转
  • ✅ 受控和非受控模式
  • ✅ 禁用状态支持

交互需求

  • ✅ 点击轨道跳转到目标位置
  • ✅ 拖拽滑块平滑移动
  • ✅ 值实时更新
  • ✅ 过渡动画(点击时平滑,拖拽时即时)
  • ✅ 悬停效果

视觉需求

css
纵向布局:
  刻度    轨道    箭头+值
  ═══     ║      ← [1.60mm]
  ═══     ●      
  ═══     ║      
  
横向布局:
  刻度   ═══ ══ ═══
  轨道   ●═════
  箭头      ↑
  值      [50%]

设计思路

1. 组件结构设计

初始方案(V1)

typescript
<Container>
  <ValueDisplay />  // 左侧/上方
  <SliderWrap>
    <Scales />
    <Track />
  </SliderWrap>
</Container>

问题:

  • ❌ 值显示位置固定,不跟随滑块
  • ❌ 布局不够灵活

改进方案(V2)

typescript
<Container>
  <ValueDisplay />  // 在另一侧
  <SliderWrap>
    <Scales />
    <Track />
    <ValueWrapper />  // 跟随滑块
  </SliderWrap>
</Container>

问题:

  • ❌ ValueWrapper 嵌套过深
  • ❌ 鼠标事件复杂,容易误触发

最终方案(V3 - CSS Grid)

typescript
<Container> // Grid 布局
  <ScalesContainer />    // 独立区域
  <TrackContainer />     // 独立区域
  <ValueContainer />     // 独立区域
</Container>

优势:

  • ✅ 三个区域完全独立
  • ✅ 鼠标事件精确隔离
  • ✅ 定位清晰简单

2. 布局方案对比

方案 A:Flexbox + Padding

css
.sliderWrap {
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
}

问题:

  • ❌ padding 区域仍会捕获鼠标事件
  • ❌ 定位复杂,容易出错

方案 B:CSS Grid(最终选择)

css
.container {
  display: grid;
  grid-template-columns: auto 8px auto; /* 纵向 */
  grid-template-rows: auto 8px auto;    /* 横向 */
}

优势:

  • ✅ 每个区域独立,互不干扰
  • ✅ 宽度/高度自动计算
  • ✅ 响应式友好

实现过程

阶段 1:基础滑动条(V1)

代码实现

typescript
export function ScaleSlider({ orientation = 'vertical', ...props }) {
  const [value, setValue] = useState(defaultValue)
  const trackRef = useRef<HTMLDivElement>(null)

  const handleMouseDown = (e: React.MouseEvent) => {
    // 计算点击位置
    const rect = trackRef.current.getBoundingClientRect()
    const percent = orientation === 'vertical'
      ? (rect.bottom - e.clientY) / rect.height
      : (e.clientX - rect.left) / rect.width
    
    const newValue = min + percent * (max - min)
    setValue(newValue)
  }

  return (
    <div>
      <div ref={trackRef} onMouseDown={handleMouseDown}>
        <div className="fill" style={{ height: `${percentage}%` }} />
        <div className="thumb" style={{ bottom: `${percentage}%` }} />
      </div>
    </div>
  )
}

实现效果

  • ✅ 点击跳转
  • ✅ 基础拖拽
  • ❌ 没有刻度
  • ❌ 没有值显示

阶段 2:添加刻度和值显示(V2)

代码实现

typescript
// 生成刻度
const scales = Array.from({ length: scaleCount + 1 }, (_, i) => ({
  position: (i / scaleCount) * 100,
  isMain: i % 2 === 0,
}))

return (
  <div className={styles.container}>
    <div className={styles.valueDisplay}>
      {formatValue(value)}
    </div>
    <div className={styles.sliderWrap}>
      {/* 刻度 */}
      <div className={styles.scales}>
        {scales.map((scale, i) => (
          <div key={i} className={styles.scale} 
               style={{ bottom: `${scale.position}%` }} />
        ))}
      </div>
      {/* 轨道 */}
      <div ref={trackRef} className={styles.track}>
        <div className={styles.fill} />
        <div className={styles.thumb} />
      </div>
    </div>
  </div>
)

CSS 实现

css
.container {
  display: flex;
  flex-direction: row; /* 纵向 */
  gap: 12px;
}

.scales {
  position: absolute;
  right: calc(100% + 4px);
}

.scale {
  position: absolute;
  width: 6px;
  height: 1px;
  bottom: X%;
}

实现效果

  • ✅ 刻度显示正确
  • ✅ 值显示在左侧
  • ❌ 值不跟随滑块
  • ❌ 没有箭头指示

阶段 3:值显示跟随滑块(V3)

代码改进

typescript
// 将 valueWrapper 移到 sliderWrap 内部
<div className={styles.sliderWrap}>
  <div className={styles.scales}>...</div>
  <div ref={trackRef} className={styles.track}>...</div>
  
  {/* 值显示跟随滑块 */}
  <div className={styles.valueWrapper} 
       style={{ bottom: `calc(${percentage}% - 0.5rem)` }}>
    <svg>箭头</svg>
    <div className={styles.valueDisplay}>
      {formatValue(value)}
    </div>
  </div>
</div>

CSS 定位

css
.sliderWrap {
  position: relative;
  padding-right: 100px; /* 为值显示预留空间 */
}

.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);
  bottom: calc(X% - 0.5rem); /* 跟随滑块 */
}

实现效果

  • ✅ 值跟随滑块位置
  • ✅ 箭头指向滑块
  • ❌ 出现严重 Bug

遇到的问题与解决方案

❌ 问题 1:纵向滑块鼠标事件异常

问题描述

现象:鼠标在纵向滑块附近移动(不点击),滑块也会跟随移动
影响:组件完全无法使用,交互体验极差
触发条件:只在纵向布局出现,横向布局正常

问题排查

Step 1:检查事件绑定

typescript
// ✅ 事件绑定正确
<div ref={trackRef} onMouseDown={handleMouseDown}>

Step 2:检查拖拽逻辑

typescript
useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // ✅ 有拖拽状态判断
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, [])

Step 3:检查 DOM 结构

html
<!-- ❌ 发现问题:valueWrapper 嵌套在 sliderWrap 内 -->
<div class="sliderWrap" style="padding-right: 100px">
  <div class="track"></div>
  <div class="valueWrapper"></div> <!-- 可能捕获事件 -->
</div>

Step 4:检查 CSS 布局

css
/* ❌ 发现问题:padding 导致可交互区域过大 */
.sliderWrap {
  padding-right: 100px; /* 这个区域可能捕获事件 */
}

Step 5:添加调试代码

typescript
const handleMouseDown = (e: React.MouseEvent) => {
  console.log('MouseDown triggered')
  console.log('Target:', e.target)
  console.log('CurrentTarget:', e.currentTarget)
}

// 发现:有时 e.target 不是 track 元素

根本原因分析

scss
问题 1:DOM 嵌套层级过深
Container
└── sliderWrap (padding-right: 100px)
    ├── scales
    ├── track ← 应该只有这里响应
    └── valueWrapper ← 嵌套在内部,可能干扰事件

问题 2:可交互区域不明确
═══  ║                 [值]
     └── track (8px)
     └────────────────────┘
          padding (100px)
         ↑ 这个区域可能误触发

问题 3pointer-events 控制不精确
.sliderWrap { }           // 没有禁用
.track { }                // 响应事件
.valueWrapper { }         // 没有明确禁用

✅ 解决方案演进

方案 1:添加 pointer-events(失败)

css
.sliderWrap {
  pointer-events: none;
}

.track {
  pointer-events: auto;
}

.valueWrapper {
  pointer-events: none;
}

结果:

  • ❌ 仍然有问题
  • ❌ 原因:valueWrapper 仍在 sliderWrap 内部

方案 2:调整 DOM 结构(部分成功)

typescript
<Container>
  <div className={styles.sliderWrap}>
    <Scales />
    <Track />
  </div>
  <div className={styles.valueWrapper}> {/* 移到外部 */}
    ...
  </div>
</Container>

结果:

  • ✅ 减少了误触发
  • ❌ 仍有边缘情况
  • ❌ 定位复杂

方案 3:CSS Grid 重构(最终成功)

核心思路:三个区域完全独立

typescript
<Container> // Grid 布局
  <ScalesContainer />    // 区域 1:刻度(不可交互)
  <TrackContainer />     // 区域 2:轨道(唯一可交互)
  <ValueContainer />     // 区域 3:值显示(不可交互)
</Container>

CSS Grid 配置

css
.container {
  display: grid;
  position: relative;
}

/* 纵向:三列布局 */
.vertical {
  grid-template-columns: auto 8px auto;
  /* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}

/* 横向:三行布局 */
.horizontal {
  grid-template-rows: auto 8px auto;
  /* 刻度(auto) | 轨道(8px) | 值显示(auto) */
}

精确控制交互区域

css
/* ✅ 只有轨道响应鼠标 */
.trackContainer {
  pointer-events: auto;
  width: 8px;  /* 纵向 */
  height: 8px; /* 横向 */
}

/* ❌ 其他区域不响应 */
.scalesContainer,
.valueContainer,
.thumb,
.fill {
  pointer-events: none;
}

视觉对比

scss
修正前(有问题):
┌────────────────────────────────┐
│ sliderWrap (可能误触)           │
│  ┌────┐  ┌──────────┐         │
│  │轨道│  │ 值显示    │         │
│  └────┘  └──────────┘         │
│  8px      padding 100px        │
└────────────────────────────────┘

修正后(正确):
┌──────┬────┬──────────┐
│ 刻度  │轨道│  值显示   │
│(no)  │YES│  (no)    │
│      │8px│          │
└──────┴────┴──────────┘

❌ 问题 2:值显示定位复杂

问题描述

需求:值显示框要跟随滑块位置
难点:同时要保持在固定区域内

解决方案:双层定位

typescript
// 外层容器:跟随滑块位置
<div className={styles.valueContainer} 
     style={{ bottom: `${percentage}%` }}>
  
  // 内层内容:在容器中居中
  <div className={styles.valueContent}>
    <Arrow />
    <ValueDisplay />
  </div>
</div>
css
/* 外层:跟随滑块 */
.valueContainer {
  position: relative;
  bottom: X%; /* 动态值 */
}

/* 内层:居中对齐 */
.valueContent {
  position: absolute;
  bottom: 0;
  transform: translateY(50%); /* 垂直居中 */
}

效果对比

css
单层定位(复杂):
.valueWrapper {
  position: absolute;
  left: calc(100% - 90px);        // 横向固定
  bottom: calc(X% - 0.5rem);      // 纵向跟随
  transform: translateY(50%);     // 居中
}

双层定位(清晰):
.valueContainer {
  bottom: X%;  // 跟随滑块
}
.valueContent {
  transform: translateY(50%);  // 居中
}

❌ 问题 3:拖拽性能优化

问题描述

css
现象:拖拽时有轻微延迟或卡顿
原因:CSS transition 在拖拽时不应该生效

解决方案:动态禁用过渡

typescript
const [isDragging, setIsDragging] = useState(false)

const fillClasses = `${styles.fill} ${
  isDragging ? styles.fillNoDrag : ''
}`
css
.fill {
  transition: height 0.15s ease, width 0.15s ease;
}

/* 拖拽时禁用过渡 */
.fillNoDrag {
  transition: none !important;
}

.container:active .valueContainer {
  transition: none !important;
}

效果对比

ini
点击跳转(平滑):
时间 0ms:    ●  [50%]
时间 75ms:      ●  [60%]  ← 平滑过渡
时间 150ms:        ●  [70%]

拖拽移动(即时):
时间 0ms:    ●  [50%]
时间 1ms:        ●  [70%]  ← 立即跟随

最终架构

1. 组件结构

arduino
ScaleSlider
├── types.ts           // TypeScript 类型定义
├── ScaleSlider.tsx    // 组件主逻辑
├── ScaleSlider.module.css  // 样式文件
└── index.ts           // 导出

2. DOM 结构

html
<div class="container vertical"> <!-- Grid 容器 -->
  
  <!-- 区域 1:刻度(pointer-events: none) -->
  <div class="scalesContainer">
    <div class="scale scaleMain" style="bottom: 0%"></div>
    <div class="scale scaleMinor" style="bottom: 10%"></div>
    <div class="scale scaleMain" style="bottom: 20%"></div>
    ...
  </div>
  
  <!-- 区域 2:轨道(pointer-events: auto) -->
  <div class="trackContainer" onMouseDown={...}>
    <div class="track">
      <div class="fill" style="height: 60%"></div>
      <div class="thumb" style="bottom: 60%"></div>
    </div>
  </div>
  
  <!-- 区域 3:值显示(pointer-events: none) -->
  <div class="valueContainer" style="bottom: 60%">
    <div class="valueContent">
      <svg class="arrow"></svg>
      <div class="valueDisplay">1.60mm</div>
    </div>
  </div>
  
</div>

3. CSS Grid 布局

css
/* 纵向布局 */
.vertical {
  display: grid;
  grid-template-columns: 
    auto    /* 刻度区域(自适应宽度) */
    8px     /* 轨道区域(固定 8px) */
    auto;   /* 值显示区域(自适应宽度) */
  gap: 8px;
}

/* 横向布局 */
.horizontal {
  display: grid;
  grid-template-rows: 
    auto    /* 刻度区域(自适应高度) */
    8px     /* 轨道区域(固定 8px) */
    auto;   /* 值显示区域(自适应高度) */
  gap: 8px;
}

4. 交互区域示意图

scss
纵向滑块:
┌──────────┬────┬──────────┐
│  刻度区   │轨道│  值显示   │
│ (14px)   │8px │ (100px)  │
│          │    │          │
│   ═══    │    │          │
│   ═══    │    │          │
│   ═══    │ ║  │  ← 值    │
│   ═══    │ ●  │          │ ← 跟随滑块
│   ═══    │ ║  │          │
│   ═══    │    │          │
│          │    │          │
│ 不响应   │响应│  不响应   │
└──────────┴────┴──────────┘

技术总结

1. 核心技术要点

✅ CSS Grid 布局

css
优势:
- 区域完全独立
- 自动计算尺寸
- 响应式友好
- 代码简洁

适用场景:
- 需要精确控制区域边界
- 需要独立控制交互行为
- 需要灵活的响应式布局

✅ pointer-events 精确控制

css
核心策略:
1. 容器默认 pointer-events: none
2. 只有交互区域 pointer-events: auto
3. 其他元素明确 pointer-events: none

防止误触发:
- 刻度不响应
- 滑块不响应(通过轨道控制)
- 值显示不响应

✅ 双层定位策略

typescript
外层:控制位置(跟随滑块)
<div style={{ bottom: `${percentage}%` }}>
  内层:控制对齐(居中)
  <div style={{ transform: 'translateY(50%)' }}>
    ...
  </div>
</div>

✅ 受控/非受控模式

typescript
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : internalValue

if (!isControlled) {
  setInternalValue(newValue)
}
onChange?.(newValue) // 总是触发回调

2. 性能优化

动画优化

typescript
// 点击时:使用 CSS transition
<div className={styles.fill} />

// 拖拽时:禁用 transition
<div className={`${styles.fill} ${styles.fillNoDrag}`} />

事件优化

typescript
// 使用 ref 存储拖拽状态,避免闭包问题
const isDraggingRef = useRef(false)

useEffect(() => {
  const handleMouseMove = (e: MouseEvent) => {
    if (isDraggingRef.current) { // 直接读取 ref
      updateValue(e.clientX, e.clientY)
    }
  }
  // ...
}, []) // 空依赖数组

计算优化

typescript
// 预计算刻度位置
const scales = useMemo(() => 
  Array.from({ length: scaleCount + 1 }, (_, i) => ({
    position: (i / scaleCount) * 100,
    isMain: i % 2 === 0,
  })),
  [scaleCount]
)

3. 关键经验教训

❌ 避免的坑

  1. 过度嵌套 DOM
typescript
// ❌ 错误
<div class="wrapper">
  <div class="inner">
    <div class="track"></div>
    <div class="value"></div> <!-- 嵌套过深 -->
  </div>
</div>

// ✅ 正确
<div class="container">
  <div class="track"></div>
  <div class="value"></div> <!-- 扁平化 -->
</div>
  1. 不明确的交互区域
css
/* ❌ 错误 */
.wrapper {
  padding: 100px; /* 大面积可能误触 */
}

/* ✅ 正确 */
.trackContainer {
  width: 8px; /* 精确宽度 */
  pointer-events: auto;
}
  1. 忽视 pointer-events
css
/* ❌ 错误:没有明确禁用 */
.thumb { }

/* ✅ 正确:明确禁用 */
.thumb {
  pointer-events: none;
}

✅ 最佳实践

  1. 用 Grid 代替复杂的 Flex + Position
  2. 精确控制每个元素的 pointer-events
  3. 双层定位处理跟随+居中
  4. 用 ref 管理事件状态,避免闭包
  5. 动态控制过渡动画

4. 可扩展性设计

支持的功能扩展

typescript
// ✅ 自定义刻度渲染
renderScale?: (value: number, isMain: boolean) => ReactNode

// ✅ 自定义值显示
renderValue?: (value: number) => ReactNode

// ✅ 范围滑动条(双滑块)
type?: 'single' | 'range'

// ✅ 垂直文字(纵向布局)
valueOrientation?: 'horizontal' | 'vertical'

// ✅ 触摸支持
onTouchStart, onTouchMove, onTouchEnd

// ✅ 键盘控制
onKeyDown: (e) => {
  if (e.key === 'ArrowUp') setValue(v => v + step)
  if (e.key === 'ArrowDown') setValue(v => v - step)
}

完整示例

基础使用

typescript
<ScaleSlider
  value={layerHeight}
  onChange={setLayerHeight}
  min={0}
  max={3}
  step={0.1}
  unit="mm"
  precision={2}
  orientation="vertical"
  scaleCount={10}
/>

高级配置

typescript
<ScaleSlider
  value={temperature}
  onChange={setTemperature}
  min={-20}
  max={100}
  step={1}
  unit="°C"
  precision={1}
  orientation="horizontal"
  scaleCount={12}
  showValue={true}
  showArrow={true}
  disabled={false}
  size={300}
/>

总结

核心突破

  1. 使用 CSS Grid 解决布局隔离问题

    • 三个区域完全独立
    • 交互区域精确可控
  2. pointer-events 精确控制

    • 只有轨道响应鼠标
    • 消除所有误触发
  3. 双层定位策略

    • 外层跟随滑块
    • 内层居中对齐

技术价值

  • ✅ 可复用的组件架构
  • ✅ 清晰的代码结构
  • ✅ 良好的性能表现
  • ✅ 完善的交互体验
  • ✅ 易于扩展和维护

适用场景

  • ✅ 3D 打印参数调节
  • ✅ 音量/亮度控制
  • ✅ 温度/压力调节
  • ✅ 任何需要精确刻度的滑动条

最终成果:一个生产级的刻度滑动条组件!  🎉

哈希指针,什么是区块链,genesis blcok,most recent block,tamper-evident log,merkle tree,binar

作者 前端涂涂
2025年12月4日 18:28

区块链中最核心的底层概念 系统性总结成一份清晰易懂的知识体系,所有内容都提供简明解释 + 用法 + 举例


✅ 1. 哈希指针(Hash Pointer)

概念: 一种特殊的指针,它不仅包含 指向某个数据的地址,还包含该数据的 哈希值(Hash)

用途: 可用于创建不可篡改的数据结构,例如链表、区块链。

举例: 一个区块记录:

Hash Pointer → 指向上一个区块,同时包含上一个区块内容的 hash

如果有人篡改了上一个区块内容,哈希值就会变,链条就断裂 → 篡改可被检测


✅ 2. 什么是区块链(Blockchain)

概念: 区块链是一种结构:

  • 数据被分成多个 区块(Block)
  • 每个区块包含自己的数据和一个 哈希指针,指向上一个区块
  • 所有区块串联成一个链

特点:

  • 不可篡改(修改一个区块会导致链断裂)
  • 分布式(多个节点都保存账本)
  • 去中心化
  • 可追溯

✅ 3. 创世区块(Genesis Block)

概念: 区块链中的第一个区块。 它没有上一个区块,所以哈希指针通常填空值或 0。

特点:

  • 整条链的起点
  • 硬编码在区块链系统中

✅ 4. 最新区块(Most Recent Block)

概念: 链上最末尾的区块,也叫 HEAD block。 新区块会接在它后面添加。


✅ 5. 可检测篡改日志(Tamper-evident Log)

概念: 一种日志结构,只要有人修改历史记录,就能被检测出来。

区块链本质上就是一个巨型的 tamper-evident log

使用场景:

  • 审计日志
  • 金融系统交易历史
  • 区块链账本

✅ 6. Merkle Tree(默克尔树)

概念: 一种树形哈希结构

  • 叶子节点存数据的 Hash
  • 上层节点是左右子节点哈希拼接后再 hash
  • 树顶产生 根哈希(Root Hash)

用途:

  • 快速证明某条数据是否存在
  • 大数据集的完整性验证
  • 区块链交易数据结构

举例:

   Root Hash
     /  \
    H1  H2
   / \  / \
 D1 D2 D3 D4

✅ 7. 二叉树(Binary Tree)

概念: 每个节点最多只有两个子节点的树结构。

Merkle Tree 是一种特殊的二叉树(只不过子节点是哈希)。


🔥 Merkle Tree vs Binary Tree 区别

维度 Merkle Tree Binary Tree
主要用途 数据完整性验证 泛用数据结构
节点内容 存哈希(Hash) 存任意数据
优势 修改易检测、快速证明 结构简单,操作方便
区块链使用 是核心组成 不一定

✅ 8. Data Block(数据区块)

概念: 叶子节点存放的实际数据(或数据的哈希)。

在区块链中,Data block 即:“某个交易数据”。


✅ 9. 根哈希(Root Hash)

概念: Merkle 树最上层生成的唯一哈希值。

用途:

  • 代表整个数据集
  • 任何数据改动,root hash 就会改变
  • 区块链的 block header 中包含 root hash

✅ 10. 区块头(Block Header)

包含区块的元数据,如:

  • 上一个区块的哈希(hash pointer)
  • Merkle Root(本区块所有交易的摘要)
  • 时间戳
  • 难度值/随机数(如 PoW)

区块头非常小,用于 共识验证


✅ 11. 区块体(Block Body)

包含实际数据,如:

  • 多笔交易列表
  • 附属数据

体积大,不参与共识算法的哈希计算。


🔥 12. Merkle Proof(默克尔证明)

概念: 证明一种数据是否在 Merkle Tree 中,只需提供:

  • 要验证的数据的 hash
  • 若干个兄弟节点(siblings)的哈希构建路径

从而重建 root hash 看是否吻合。

特点:

  • 证明很短(O(logN))
  • 区块链轻节点(Light Client)常用

13. Proof of Membership(成员证明)

概念: 证明“某条数据在集合中”。

Merkle Proof 是一种典型的 membership proof。

例子: 证明交易 tx3 存在于区块中,只需提供 tx3 的 merkle 路径(比如 H4、H12 等)。


14. Proof of Inclusion(包含证明)

这是 membership proof 的另一种叫法。 Meaning:数据被包含在集合中。

在区块链文献中 Inclusion = Membership


15. Proof of Non-Membership(非包含证明)

概念: 证明“某条数据不在集合中”。

Merkle Tree 原生不支持,需要用 Merkle Patricia Trie(MPT)Sorted Merkle Tree 才能证明不包含。

例如: 查询一个地址从未发生过交易 → 需要 non-membership proof。


📦 总结(结构化知识框架)

一页脑图式总结:

Blockchain = Blocks + Hash Pointers + Tamper-evident log
     |
     |-- Genesis Block (first block)
     |-- Most Recent Block (head)
     |
     |-- Block Structure
     |       |-- Block Header (prev hash, merkle root, timestamp)
     |       |-- Block Body (transactions)
     |
     |-- Merkle Tree
             |-- Binary tree storing hashes
             |-- Leaf: data block
             |-- Output: root hash
             |-- Proof:
                    |-- Merkle Proof
                           |-- Proof of Membership (Inclusion)
                           |-- Proof of Non-membership

在这里插入图片描述

树结构、区块结构

下面给你 最清晰、最标准的区块链结构 + Merkle 树结构 ASCII 可视化示意图,适合学习与文档引用。


📦 一、区块链结构(Blockchain Structure)示意图

┌──────────────────────────────────────────┐
              Genesis Block               
   (第一个区块,没有前哈希 Prev Hash)        
                                          
   Block Header:                          
      Prev Hash: 0000000000000000         
      Merkle Root: A1B2C3...              
      Timestamp: T0                       
   Block Body:                            
      [tx1, tx2, tx3...]                  
└──────────────────────────────────────────┘
                      Hash Pointer
                     
┌──────────────────────────────────────────┐
                Block #2                  │
                                          
   Block Header:                          
      Prev Hash: HASH(Genesis Block)      
      Merkle Root: D4E5F6...              
      Timestamp: T1                       
   Block Body:                            
      [tx4, tx5, tx6...]                  
└──────────────────────────────────────────┘
                      Hash Pointer
                     
┌──────────────────────────────────────────┐
                Block #3                  │
                                          
   Block Header:                          
      Prev Hash: HASH(Block #2)           │
      Merkle Root: XYZ789...              
      Timestamp: T2                       
   Block Body:                            
      [tx7, tx8...]                       
└──────────────────────────────────────────┘
                      Hash Pointer
                     
              (Most Recent Block)

👉 关键看点

  • 每个区块通过 Prev Hash 指向上一个区块
  • 区块头(Header)非常小
  • 区块体(Body)存交易
  • 修改任意历史区块会使链断裂 → tamper-evident

🌲 二、Merkle Tree(默克尔树)可视化结构

                        ┌────────────────────┐
                        │      Root Hash      │
                        └───────────┬────────┘
                                    │
               ┌────────────────────┴─────────────────────┐
               │                                          │
        ┌──────┴──────┐                            ┌──────┴──────┐
        │     H12      │                            │     H34      │
        └──────┬──────┘                            └──────┬──────┘
               │                                          │
        ┌──────┴──────┐                            ┌──────┴──────┐
        │   Hash1      │                            │   Hash3      │
        │ (tx1+tx2)    │                            │ (tx3+tx4)    │
        └──────┬──────┘                            └──────┬──────┘
               │                                          │
     ┌─────────┴──────────┐                    ┌─────────┴──────────┐
     │        H1           │                    │        H3           │
     │    Hash(tx1)        │                    │    Hash(tx3)        │
     └─────────────────────┘                    └─────────────────────┘

     ┌─────────┐                                ┌─────────┐
     │   H2     │                                │   H4     │
     │Hash(tx2) │                                │Hash(tx4) │
     └─────────┘                                └─────────┘

👉 结构说明

  • 最底层叶子是 交易哈希(H1, H2, H3, H4)
  • 上一层将相邻两个叶子拼接后 hash(Hash1、Hash3)
  • 最上层再 hash 得到 Merkle Root
  • 任何底层数据变化都会引起 Root Hash 变化

🔥 三、Merkle Proof(证明路径)示意图

以证明 tx3(H3)存在于树中 为例:

要验证的目标: H3 (tx3)

提供的 Merkle Proof:
   - H4 (兄弟节点)
   - H12(另一个分支的 hash)

验证过程结构(构建路径):

                       Root Hash ?
                            ▲
                  ┌─────────┴─────────┐
                  │        H12         │   ← 提供
                  └─────────┬─────────┘
                            │
                  ┌─────────┴─────────┐
                  │      Hash3         │
                  └───────┬───────────┘
                          │
                  ┌───────┴───────┐
                  │   H3H4    │   ← H3 已知 / H4 提供
                  └────────┴────────┘

用户只需提供 log(N) 个哈希,就能重建 root hash。


📘 四、区块结构(Block Structure)内部示意图

┌───────────────────────────── Block ─────────────────────────────┐
│                                                                  │
│   ┌────────────────────── Block Header ───────────────────────┐  │
│   │  Version                                                  │  │
│   │  Prev Block Hash (hash pointer)                           │  │
│   │  Merkle Root (所有交易的摘要)                                │  │
│   │  Timestamp                                                │  │
│   │  Nonce                                                    │  │
│   └───────────────────────────────────────────────────────────┘  │
│                                                                  │
│   ┌────────────────────── Block Body ─────────────────────────┐ │
│   │  Transaction 1                                             │ │
│   │  Transaction 2                                             │ │
│   │  Transaction 3                                             │ │
│   │  ...                                                       │ │
│   └────────────────────────────────────────────────────────────┘ │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

👉 Header 用来做哈希计算(PoW 等) 👉 Body 是实际内容,不参与主链哈希计算


this 不是你想的 this:从作用域迷失到调用栈掌控

作者 有意义
2025年12月4日 17:41

一、自由变量 vs 对象属性:

一段代码揭示 JavaScript 作用域的本质

我们从一段看似简单、却常被用作教学陷阱的 JavaScript 代码开始:

var bar = { 
  myName: "time.geekbang.com",
  printName: function() {
    console.log(myName); // 注意:这里没有 this,也没有声明 myName
  }
}

function foo() {
  let myName = '极客时间';
  return bar.printName;
}

let _printName = foo();
_printName(); // ❌ ReferenceError: myName is not defined

初看之下,这段代码似乎只是想“打印某个名字”。但运行后,控制台会抛出一个错误:

ReferenceError: myName is not defined

image.png

为什么会这样?
这背后涉及 JavaScript 中两个极易混淆的核心概念:词法作用域中的自由变量查找对象属性访问。它们看似相似,实则天差地别。


🔍 myName 到底是谁?

关键问题在于:printName 函数体中的 myName 指的是什么?

  • 不是 this.myName —— 代码中压根没写 this
  • 它也不是局部变量 —— 函数内部没有用 varlet 或 const 声明 myName

于是,JavaScript 引擎启动标识符解析(Identifier Resolution) 机制:沿着词法作用域链(Lexical Scope Chain) 向上查找这个变量。

📌 词法作用域由函数“定义的位置”决定,而非“调用的位置”

查找路径如下:

  1. 在 printName 自身的作用域中查找 → 未找到
  2. 跳转到该函数定义时的外层作用域 → 这里是全局作用域
  3. 在全局作用域中查找名为 myName 的绑定。

然而,在初始版本中,全局作用域确实没有 myName 变量

⚠️ 重要区分:对象属性 ≠ 变量
尽管 bar 对象有一个 myName 属性,但 bar.myName属性访问表达式,而 myName 是一个自由变量(Free Variable)
JavaScript 不会自动将对象属性当作同名变量来解析。

因此,引擎找不到 myName,抛出 ReferenceError


✅ 添加全局变量后:为什么输出 “极客邦”?

现在我们在全局作用域添加一行:

let myName = '极客邦';

完整代码变为:

<script>
var bar = { 
  myName: "time.geekbang.com",
  printName: function() {
    console.log(myName); // 自由变量查找
  }
}

function foo() {
  let myName = '极客时间';
  return bar.printName;
}

let myName = '极客邦'; // ← 全局词法环境中的绑定
let _printName = foo();
_printName(); // 输出:'极客邦'
</script>

此时,printName 执行时:

  • 自身作用域无 myName
  • 沿词法作用域链向上 → 找到全局作用域中的 myName 绑定
  • 成功解析为 '极客邦'

image.png

💡 补充说明:
虽然 let 声明的全局变量不会挂载到 window 对象上(即 window.myName === undefined),但它仍然存在于全局词法环境(Global Lexical Environment)中,对自由变量查找完全可见。
这正是 ES6 引入块级作用域后的设计:作用域查找 ≠ 全局对象属性查找


🎯 如何真正访问 "time.geekbang.com"

我们的目标其实是 bar 对象上的属性值 "time.geekbang.com"。既然 console.log(myName) 打印的是全局变量 '极客邦',那该如何正确访问对象属性?

答案很简单:显式通过对象引用访问

console.log(bar.myName); // ✅ 输出 "time.geekbang.com"

这行代码之所以成功,是因为它直接执行了属性访问操作。只要 bar 在当前作用域可见(这里是全局),就能稳定、可靠地获取其属性。

🗣️ 虽说“直呼其名有点不礼貌”?
其实在编程中,清晰比委婉更重要
显式写出 bar.myName,是对代码可读性和可维护性的最大尊重。


⚠️ 那 this.myName 为什么不行?

第三行代码试图用 this 来访问:

console.log(this.myName); // ❌ 输出 undefined

问题出在 this 的绑定方式 上。

虽然 printNamebar 的方法,但我们是这样调用它的:

let _printName = foo();
_printName(); // 直接调用函数,没有通过 bar

这种调用方式下,this 指向全局对象(如浏览器中的 window)。

但全局变量是用 let 声明的:

let myName = '极客邦';

let 声明的变量不会成为全局对象的属性,所以:

this.myName   // 等价于 window.myName → undefined

🔁 对比实验:
如果改成 var myName = '极客邦',那么 this.myName 会输出 '极客邦' ——
但请注意,这仍然是全局变量不是 bar.myName

因此,this.myName 在这里既不可靠,也不是你真正想访问的值。


🧩 小结:三种访问方式的本质区别

写法 机制 是否依赖作用域 能否访问 bar.myName
myName 自由变量(词法作用域) ❌(除非全局巧合)
bar.myName 对象属性访问 ❌(只需 bar 可见)
this.myName 动态上下文绑定 ❌(除非通过 bar.printName() 调用)

对象属性是数据,变量是绑定;前者靠引用访问,后者靠作用域查找。二者在 JavaScript 中属于完全不同的命名空间。

理解这一点,是避免“我以为它能找到”的关键。下一部分,我们将深入探讨 this 的动态绑定规则——为什么它如此“善变”,又该如何掌控它。

二、this 的真相:

动态上下文如何由调用方式决定?

在第一节中,我们厘清了:

  • 变量查找是静态的(词法作用域)
  • 对象属性 ≠ 变量
  • 自由变量沿定义时的作用域链向上查找

而本节要揭示的是另一个平行但常被混淆的机制:

this 的值与作用域无关,它完全由函数的“调用方式”决定——它是动态的、运行时的上下文引用。

这正是初学者甚至中级开发者频繁踩坑的根源:this 当作“当前对象”或“作用域”的同义词

🎯 this 到底是谁?

在 JavaScript 中,this 是一个既基础又令人困惑的概念。很多开发者误以为 this 和“函数定义的位置”或“当前作用域”有关,但事实恰恰相反:this 的值完全由函数的调用方式决定,与词法作用域无关

本文将通过三个典型场景,带你一步步揭开 this 的真实面目,并建立一套可靠的判断逻辑。


第一部分:普通函数调用 → 默认绑定

function foo() {
    console.log(this);  // 输出 window(在浏览器中)
}
foo();  // 普通函数调用

非严格模式下,当你直接调用一个函数(如 foo()),JavaScript 引擎会将该函数的 this 默认绑定到全局对象

  • 在浏览器环境中,全局对象是 window
  • 在 Node.js 中,则是 global

因此,上述代码会输出 window

image.png

❓ 为什么这看起来“不合理”?
因为我们本能地认为:“这个函数写在全局,那 this 应该代表‘当前上下文’。”
但 JavaScript 的设计哲学是:this 不是静态的,而是动态的——它取决于“怎么调用”,而不是“在哪定义”。

小结

普通函数调用(foo()) → this 指向全局对象
(非严格模式下是 window;严格模式下是 undefined


第二部分:call / apply → 显式绑定

let bar = {
    myName: "极客邦",
    test1: "1"
};

function foo() {
    this.myName = "极客时间";
}

foo.apply(bar);
console.log(bar); // { myName: "极客时间", test1: "1" }

这里我们重新定义了 foo,它的作用是给 this 对象设置 myName 属性。

关键在于这一行:

foo.apply(bar);

Function.prototype.apply(以及 callbind)允许我们显式指定函数执行时的 this
foo.apply(bar) 的含义是:

“调用 foo 函数,并强制让函数内部的 this 指向 bar 对象。”

于是,this.myName = "极客时间" 实际上等价于 bar.myName = "极客时间",成功修改了 bar 的属性。

image.png

小结

foo.call(obj)foo.apply(obj)this 被显式绑定为 obj
这是控制 this 最直接、最可靠的方式之一。


第三部分:对象方法调用 → 隐式绑定

function foo() {
  console.log(this.a);
}

var obj = {
  a: 2,
  foo: foo
};

obj.foo(); // 输出 2
  • 关键:通过 obj.foo() 调用函数

  • 在 JavaScript 中,当一个函数作为对象的方法被调用时,函数内部的 this 会自动绑定到该对象(即 obj)。

  • 所以在 foo 执行时:

    • this 指向 obj
    • this.a 就是 obj.a,即 2
  • 因此输出:2

小结

作为对象的方法调用(obj.method()) → this 指向 obj
但仅限于“点调用”形式,解构或赋值后调用会失效。


理解 this,是迈向 JavaScript 运行时高手的关键一步。下一次当你看到 this 时,别再问“它应该是什么”,而是问: “它是怎么被调用的?” ——答案就在调用栈中。

第四部分:隐式绑定丢失

第三部分讲到了隐式绑定的概念,现在我们来讲讲隐式丢失

var myObj = {
            name : "极客时间",
            showThis: function(){
                this.name = '极客邦';
                console.log(this);
            }
        }
        var foo = myObj.showThis;   // window.foo

⚠️ 隐式绑定为什么会“丢失”?

现在看你的代码:

var foo = myObj.showThis; // 仅获取函数引用,未调用
foo(); // ❌ this 不再是 myObj!

这一步发生了什么?

  1. myObj.showThis 是一个函数引用,它本身只是一个普通的函数值;
  2. 将其赋值给变量 foo 后,foo 和 myObj 彻底断开联系
  3. 当你执行 foo() 时,这是一个普通函数调用(没有通过对象),因此触发的是 默认绑定规则

在非严格模式下:

foo(); // 相当于 window.foo() → this = window

所以,在 showThis 内部:

this.name = '极客邦'; // 实际上是 window.name = '极客邦'
console.log(this);    // 输出 window 对象

🔥 这就是“隐式绑定丢失”:
原本属于对象的方法,一旦被当作普通函数调用,就失去了与原对象的上下文关联,this 回退到全局对象(或 undefined)。

第五部分:new 调用 → new 绑定(最高优先级)

你可能写过这样的代码:

function CreateObj() {
    this.name = "极客时间";
}

var myObj = new CreateObj();
console.log(myObj.name); // "极客时间"

但你是否想过:为什么 this 在这里指向新创建的对象?

这背后是 new 操作符在运行时完成的一系列精密步骤。我们可以通过“手动实现 new”来还原其本质:

✅ 正确的手写 new 实现(教学版)
function myNew(Constructor, ...args) {
    // 1. 创建一个新对象
    const obj = {};

    // 2. 将新对象的 [[Prototype]] 链接到构造函数的 prototype
    obj.__proto__ = Constructor.prototype;

    // 3. 将构造函数内的 this 绑定到这个新对象,并执行构造函数
    const result = Constructor.apply(obj, args);

    // 4. 如果构造函数返回的是引用类型,则返回该值;否则返回新对象
    return (typeof result === 'object' && result !== null) ? result : obj;
}

🧠 new 绑定的核心规则:

当使用 new CreateObj() 时,JavaScript 引擎会按以下顺序执行

  1. 创建一个全新的 空对象
  2. 将该对象的内部 [[Prototype]] 链接到 CreateObj.prototype
  3. 将 CreateObj 函数体内的 this 绑定到这个新对象;
  4. 执行构造函数体**(即 this.name = "极客时间");
  5. 如果构造函数没有显式返回一个对象,则自动返回新创建的对象。

第六部分:DOM 事件监听器中的 this 

考虑这段常见代码:

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link')
  .addEventListener('click', function() {
    console.log(this); // 👉 输出 <a id="link">点击我</a>
  });
</script>

尽管这是一个普通函数作为回调传入,且以普通方式被调用(我们并没有写 handler.call(element)

但它的 this 却神奇地指向了触发事件的 DOM 元素。

🔍 这是怎么发生的?

答案是:浏览器在内部调用你的回调函数时,显式绑定了 this

也就是说,浏览器大致做了这样的事:

// 浏览器内部伪代码
const element = document.getElementById('link');
const handler = function(event) { console.log(this); };

// 当点击发生时:
handler.call(element, event); // ← 显式将 this 设为 element!

因此,虽然你写的是一个“普通函数”,但调用者(浏览器)使用了 .call() ,使得 this 指向事件目标(event target)。

✅ 这不是 JavaScript 引擎的默认行为,而是 DOM API 的设计约定


⚠️ 注意:箭头函数会破坏这一行为!

如果你改用箭头函数:

document.getElementById('link')
  .addEventListener('click', () => {
    console.log(this); // 👉 输出 window(非严格模式)
  });

你会发现 this 变成了全局对象。为什么?

因为:

  • 箭头函数没有自己的 this
  • 它的 this 继承自外层词法作用域(这里是全局作用域);
  • 浏览器即使想通过 .call() 绑定 this,也对箭头函数无效(规范规定箭头函数忽略 this 参数)。

📌 所以:如果你想在事件回调中使用 this 指向元素,必须使用普通函数。

💡 记住:this 的值永远取决于“谁在调用”以及“怎么调用”——即使是浏览器,也在遵守这一原则。

🧩 总结:this 绑定规则优先级(从高到低)

绑定类型 调用形式 this 指向
new 绑定 new Fn() 新创建的实例对象
显式绑定 fn.call(obj)fn.bind(obj) 指定的对象 obj
隐式绑定 obj.method() obj
默认绑定 fn() 全局对象 / undefined
箭头函数 任意 继承外层词法作用域的 this

💡 终极心法
不要问“this 应该是什么”,而要问“它是怎么被调用的?”
答案,永远藏在调用栈中。

第2讲:BTC-密码学原理 北大肖臻老师客堂笔记

作者 前端涂涂
2025年12月4日 17:39

这节课系统讲清了支撑比特币的两大密码学支柱:哈希函数和公钥密码/数字签名,并用这些工具解释“比特币为什么能防伪、防篡改、防冒充”。

一、密码学在比特币中的角色

  • 课程先强调电子货币系统必须解决四个核心问题:如何保密、如何防篡改、如何确认“你就是你”、以及如何防止事后否认自己转过账。密码学正是用来给这些问题提供“工程级”的技术解法的基础设施。
  • 讲课中多次提醒:不要带着“传统银行系统”的思路看比特币,而要意识到在完全开放的网络环境下,只能依赖数学和算法来建立信任,这为后面所有技术细节定下了基调。

例子:如果你在QQ群里转账给别人,没有任何中心机构背书,那么其他人如何确认这笔“转账记录”是真的?这就需要密码学来保证这条记录既不能被别人随便伪造,也不能被你自己事后篡改或否认。

二、哈希函数:区块链的“指纹机”

  • 课程用“给任意数据生成短小指纹”的比喻解释哈希函数:无论原始数据多长,丢进哈希函数得到的值长度固定、看起来随机,却对输入极度敏感,一点点改动都会导致截然不同的输出。
  • 重点强调哈希的三大性质:单向性(从结果几乎推不回输入)、抗碰撞(几乎不可能找到两个不同输入有同样输出)、以及雪崩效应(输入微变输出大变),这些性质保证了“只看指纹就能检查内容是否被篡改”。

例子:

  • 把“转账给张三 10 BTC”文本当作输入,计算出哈希值 H1。哪怕只是偷偷把“10”改成“11”,重新算出的哈希 H2 与 H1 完全不同。于是网络里的节点只要对收到的交易重新算一遍哈希,就能立刻发现数据有没有被动过手脚。

三、哈希在比特币中的具体用法

  • 讲到比特币时,老师把哈希函数变成一个“命名工具”:每笔交易、每个区块都通过哈希得到一个“名字”,这个名字长度固定、便于传播,又很难和别的数据重复,因此非常适合在点对点网络中当作标识符使用。
  • 此外,课程还会提到:区块头里包含上一个区块的哈希,从而形成一条哈希链;区块内部为了一次性概括所有交易,会用到更复杂的结构(默克尔树),让一个哈希值就能代表“这一整个交易集合”。

例子:

  • 某一区块包含 2000 笔交易。通过默克尔树把这 2000 条记录逐层哈希,最终得到一个“总哈希”写在区块头中。以后任何人若想证明“某笔交易确实包含在这个区块内”,只要提供一条从这笔交易到树根的哈希路径即可,验证效率非常高。

四、公钥密码与数字签名:谁在花钱

  • 在解决“谁在花钱”之前,老师先介绍公钥密码体系:每个人有一对密钥,私钥自己保存,公钥可以公开。用私钥做的数学运算(签名),只有对应公钥能验证;反之,没有私钥的人无法伪造同样的结果。
  • 课程以“签名”而不是“加密”来解释比特币的用法:用户用自己的私钥对交易内容签名,所有节点利用公开的公钥来检验这笔交易是不是由该用户发起,从而实现“谁对转账负责”的认证和不可否认性。

例子:

  • 你要把 1 BTC 转给小王。你构造一条交易信息:“从我的某个 UTXO 转出 1 BTC 给小王的地址”,然后用私钥对这条消息做数字签名。全网节点收到后,用你的公钥验证签名。如果签名对得上,说明确实是你发的,而不是别人冒充你乱花你的钱。

五、比特币地址、私钥、公钥的关系

  • 老师会说明:普通用户看到的是“地址”,但底层是从公钥经过一系列哈希和编码得到的结果,这样做既缩短了长度,又通过多次哈希增加了安全性和容错性。
  • 这一设计带来两个效果:一方面,拿到地址无法反推出公钥甚至私钥,保护了用户安全;另一方面,一旦用户用对应私钥签名,网络就能通过地址映射出公钥,再进一步验证签名是否有效,从而把“地址”和“控制权”对应起来。

例子:

  • 可以把地址理解成“银行卡号”,公钥类似“卡的某些公开参数”,私钥则是“只有你知道的 PIN+签名设备”。别人看到你的地址可以给你转账,但无法从地址推回你的私钥;只有你能对“从这个地址花钱”的交易签字。

六、两大工具如何共同保证安全

  • 课程最后把哈希和数字签名串联起来:哈希保证了“账本上写的内容一旦确定,任何微小篡改都会暴露”;数字签名保证了“只有真正所有者才能发起有效的转账”。合在一起,就构成了一个无需中心机构也能维持的“公开、可验证的账本系统”。
  • 进一步讲,比特币通过“哈希链”让区块彼此捆绑,通过“全网节点验证签名”来过滤非法交易,再在后续章节用工作量证明、共识协议等机制解决“谁来写下一个区块”的问题,这一讲则是后面所有机制的密码学地基。

概念总结

这一讲的内容可以按你说的三块来记,既有概念,也有内在结构,还有“为啥这套东西能跑起来”的直觉。

一、记概念:几个核心密码学积木

  • 哈希函数:输入任意长度数据,输出固定长度“指纹”,特点是单向(不能反推原文)、抗碰撞(几乎找不到两个不同输入同哈希)、雪崩效应(改一位结果大变),在比特币里用来给交易、区块、交易集合“起名字”和做完整性校验。
  • 公钥 / 私钥:每个用户有一对密钥,私钥自己保管,公钥可以公开;任何人能用公钥验证“这条消息确实由对应私钥签过名”,但不能从公钥算出私钥。
  • 数字签名:用私钥对“具体的交易内容”做数学运算,生成一段签名;别人拿到交易内容和签名,用公钥一验就知道是不是你签的,既可以防伪造,又让你事后不能否认自己发过这笔交易。
  • 地址:对公钥做多次哈希和编码得到的短字符串,用来当“收款账号”;别人看到地址可以给你打币,但既不能从地址推出私钥,也不能单凭地址花你的钱,必须配合私钥签名才行。

小例子:

  • 你写了一条消息:“给张三 1 BTC”,算出哈希是 H1,后来有人把 1 改成 2,再算一次变成 H2。由于 H1≠H2,任何节点都能一眼知道中间有人动过手脚。
  • 你用私钥对“从地址 A 给地址 B 1 BTC”签名,得到 Sig。全网节点只需拿交易内容 + Sig + 你的公钥,就能验证是否真是你签的,而不是别人伪造的。

二、记关系:交易、签名与哈希链

  • 交易和签名的绑定:一笔比特币交易里,写清楚“花掉的是哪些之前收到的币、要转给谁、转多少”,然后对“这整条消息”做数字签名;因为签名和具体内容强绑定,任何人想改金额、改收款人,都会导致原来的签名立即失效,只能重新让持有私钥的人再签一次。
  • 区块和哈希的绑定:每个区块除了交易,还包含上一个区块头的哈希值,这样一来,如果你改动历史上任何一个区块的数据,对应哈希就变了、后面所有区块里“引用的那个哈希”也会错,从而整条链都露馅,形成所谓“哈希链”。

小例子:

  • A 在第 100 号区块里给 B 转了 1 BTC。有人想把这条记录改成“转 10 BTC”,那么 100 号区块的哈希会变,101 号区块里“前一区块哈希”字段就不对了,必须连带修改 101、102、103……所有后续区块,代价巨大。
  • 一棵默克尔树可以把几千笔交易“压缩”成一个哈希根写入区块头,只要任何一笔交易被改动,重新计算出的树根就不一致,说明区块被动过手脚。

三、记直觉:数学+广播网络=去中心化信任

  • 数学替代人治:传统银行系统里,是银行这个中心机构告诉你“这笔账是真的”;在比特币里,哈希和数字签名保证“只要式子对上,这就是原始持币人亲自授权、且记录未被篡改”,不需要相信某个机构,只需要相信算法的难解性。
  • 广播+多数验证替代单点记账:每笔交易发到全网,节点各自用公钥验证签名、用哈希检查数据一致性;谁来写入下一个区块交给后面的共识和挖矿机制,但所有人都用同一套数学规则来判断“哪条链、哪些交易是有效的”,于是形成一种“公共账本”,任何人都能检验、没人能轻易作假。

直觉例子:

  • 想象一群人围成一圈记账,任何人都可以大声宣布“我给小王 1 BTC”;但只有你能拿出针对这句话的正确数字签名,别人无法伪造你的“声音”,而所有人都用同样的哈希和签名规则来判断这句话是否被篡改、是否真是你说的。
  • 久而久之,这群人按照统一规则选出“本轮记账代表”写下一个区块;但即便如此,他也改不了既有记录,因为一改就会破坏哈希链,被其他人立即识破。

基于uniapp的PDA手持设备红外扫码方案

2025年12月4日 17:24

前言

在本篇文章中,可以学习到基于uniapp平台的移动端app应用,通过uts插件调用安卓sdk

背景

移动端最近有一个需求,需要使用PDA的红外扫码功能来识别一些二维码。识别成功后,再进行一些业务操作。因为公司移动端的技术用的是uniapp,没有找到合适的现成库或工具,于是决定自己造,顺带写了这篇文章。

名词解释

PDA设备

PDA的英文全称叫Personal Digital Assistant,现代意义上的PDA终端手持机是一种集成了数据采集、处理、传输功能的智能终端设备。 如下图就是一个在使用红外扫码功能的PDA手持设备。

UTS插件

uts,全称uni type script,统一、强类型、脚本语言,是uni提供的接入原生api、SDK、插件的DSL。

它会被编译为不同平台的编程语言,如:

  • web平台,编译为JavaScript
  • Android平台,编译为Kotlin
  • iOS平台,编译为Swift(HX 3.6.7+ 版本支持)
  • harmonyOS平台,编译为ArkTS(HX 4.22+ 版本支持)在现有架构下,ArkTS和JS在同一环境下执行,不涉及通讯等问题。

uts插件,指利用uts语法,操作原生的API(包括手机os的api或三方sdk),并封装成一个uni_modules插件,供前端调用。

如果有想要详细了解的同学可以看看官网介绍:Uniapp UTS插件


需求分析

本文最终采用的是PDA厂商SDK方案,使用UTS插件接入SDK,最后封装成业务组件。(不关心方案选型,想直接看PDA厂商SDK接入的同学,可以点击目录【具体实现】跳转)

方案分析

方案 优点 缺点
隐藏input代理
  1. 通用方案,不需要针对不同的PDA设备编写独立代码。

  2. 开发方案熟悉,基本还是基于web开发的思维。
  1. 移动端input聚焦的时候会唤起键盘,关闭键盘方案不够好的情况下会导致页面闪烁。

  2. 需要针对处理不同厂商(甚至是同一厂商不同型号)设备的红外扫码按键监听事件。
PDA系统设置扫码广播
  1. 用户体验好,效果PDA厂商SDK方案没有差别。

  2. 代码量小,开发简单。
  1. 需要针对每一台PDA手持设备进行特殊设置,客户体量大的情况下配置PDA手持设备极度麻烦。

  2. 极度依赖PDA厂商的系统设置。
PDA厂商SDK方案
  1. 用户体验好,完全静默的红外扫码,依靠消息订阅实现。

  2. 不需要引入额外的js库。
  1. 针对不同的PDA品牌,需要额外特殊处理。

  2. 较前两个方案复杂,原生SDK接入不是web开发的舒适区。

隐藏input代理方案

如果要使用隐藏input代理方案,有两个必须要面对的问题:

  • 第一个是input聚焦时会唤起键盘
  • 第二个是需要监听PDA手持设备的红外扫码功能的开始与结束。

开始具体分析这两个问题。

如何避免input聚焦唤起键盘

目前有三种方案可以隐藏唤起键盘或者避免键盘被唤起。隐藏唤起键盘会带来无法避免的页面闪烁问题,所以避免键盘被唤起是最优解。

  1. uniapp提供了hideKeyboard方法用来隐藏键盘,hideKeyboard方法会带来页面闪烁问题。检索到有结合使用setInterval不断隐藏键盘的方案,但是测试结果依旧是会有页面闪烁的情况,不建议使用。如果有同学想了解该方案可以看看uniapp的官网

  2. 使用input组件的属性,来达到聚焦input组件时,不唤起键盘的效果。

    1. inputmode属性,可以完全屏蔽唤起键盘,但是会丢失PDA扫码结果。
    2. readonly属性,app端测试没有效果,无法屏蔽唤起键盘。(有资料说在小程序端有效果,这个我没有做测试,有兴趣的同学可以自己试试。)
  3. 通过原生插件或者uniapp的native.js来修改页面级(activity级别)的键盘唤起模式。(这个方案没有深入尝试,因为既然都需要编写原生相关代码。那使用PDA厂商的SDK,明显获得更好的用户体验,)

如何监听PDA手持设备的红外扫码功能

  1. 监听PDA的键盘事件,需要依赖uniapprender.js来获取addEventListener,监听键盘事件。
  2. 监听PDA手持设备红外扫码功能的广播。(可以询问适配的PDA厂商,有没有开放红外扫码的安卓公共广播。如果没有那就只能针对SDK进行一个二次开发)

隐藏input代理方案是一个不错的通用解决方案。但是隐藏键盘带来的页面闪烁问题,会带给用户较差的体验,所以最后的实现方案没有采用该方案。

PDA系统设置扫码广播解解

不同的PDA手持设备有着不同的设置方式,这里是一个其他同学的方案,想了解该方案的同学可以去看看。

需要针对使用的PDA设备进行独立配置红外扫码功能的系统设置,如果在客户体量大、数量多的情况下,会有大量PDA设置需求。会带来两个问题:

  • 第一,客户大概率是不愿意自己进行PDA设置,那么这个设置过程将消耗己方的大量人力。
  • 第二,不是谁谁来设置这些PDA手持设备,都没有办法保证每一台PDA都一定设置正确。

这个是无法接受的缺点,所以最终也没有采用该方案。

PDA厂商SDK方案

红外扫码作为PDA的原生能力,那么自然会对有需求的开发者提供相关的SDK。我们只需要调用这些SDK,就可以轻松完成红外扫码识别。

步骤拆解:

  1. 接入PDA手持设备的官方SDK。
  2. 在插件中完成,对红外扫码的监听。
  3. 封装相关业务组件,提供给项目使用。

具体实现

当前实现过程使用的是霍尼韦尔PDA设备,接下来的实现过程将以该品牌提供的SDK作为例子。大家在自己开发、接入SDK时,需要向适配的PDA厂商索要红外扫码SDK的相关文档、示例。

如何获取SDK

获取sdk有多种途径,这个同学们可以询问一下公司的采购,或者向上级反映情况。

  • 可以去各个品牌的官网下载。
  • 找购买渠道的渠道商要相关型号品牌的sdk。

了解SDK

大致有两个比较重要类需要我们在编写代码前了解一下:

  • AidcManager这个是扫码模块的管理类。
// 红外扫码模块的管理类
public final class AidcManager extends java.lang.Object {

    /**
     * 这个接口提供了AidcManager创建成功的回调方法。
     */
    public static interface AidcManager.CreatedCallback {
    
        /**
         * 需要我们未来实现的方法,实现红外扫码功能。
         * aidcManager是红外扫码管理实例
         */
        void onCreated(AidcManager aidcManager)
    }

    /* 
     * 创建红外扫码管理器的静态类,需用通过该方法创建AidcManager的实例。
     * context 是安卓引用的上下文对象
     * callback 将在这个类里面实现红外扫码的基本功能。
     *
     * 我们不关心其内部实现
     */
    public static void create(Context context, AidcManager.CreatedCallback callback)
    
    /**
     * 用于创建红外扫码读取器对象
     * 不需要关心其内部实现。
     */
    public BarcodeReader createBarcodeReader()
    
    // 关闭红外扫码服务的连接。
    public void close()

}
  • BarcodeReader这个是红外扫码的读取器类。
// 红外扫码的读取器类
public final class BarcodeReader extends java.lang.Object {

    /**
     * 红外扫码事件的接口,包含红外扫码事件的回调事件
     * 红外扫码的成功响应回调方法
     * 红外扫码的失败响应回调方法
     */
    public static interface BarcodeReader.BarcodeListener extends java.util.EventListener {
    
        // 红外扫码的成功响应回调方法
        void onBarcodeEvent(BarcodeReadEvent event)
        
        // 红外扫码的失败响应回调方法
        void onFailureEvent(BarcodeFailureEvent event)
    }
    
    // 红外扫码读取器 绑定响应事件回调
    public void addBarcodeListener(BarcodeReader.BarcodeListener listener)
    
    // 红外扫码读取器 移除响应事件回调
    public void removeBarcodeListener(BarcodeReader.BarcodeListener listener)
    
    //  红外扫码读取器 开始响应扫码结果的事件
    public void claim()
    
    // 红外扫码读取器 停止响应扫码结果的事件
    public void release()
    
    // 销毁红外扫码读取器
    public void close()
}

创建uts插件

创建uts插件有两种方法:

  • 第一种,是手动创建uts插件的目录结构(不建议,容易有遗漏)。
  • 第二种如下图,依赖HBuilderX。右键uni_modules文件夹,点击新建uni_modules插件。选择第三项uts插件-api插件

uts插件目录

PDA厂商提供的sdk放置在utssdk/app-android/libs文件夹下就可以,uts插件支持以下三种类型文件:

  • jar
  • aar
  • so库

大致的目录结构如下

├─static                          // 静态资源
├─utssdk                          // 插件主要功能的代码目录
│        ├─app-android                 //Android平台目录
│        │        ├─assets                  //Android原生assets资源目录,可选
│        │        ├─libs                    //Android原生库目录,可选
│        │        ├─res                     //Android原生res资源目录,可选
│        │        └─index.uts               //需要我们实现的插件功能入口,必须
│        ├─app-ios                     //iOS平台目录 不在本文的讨论范围内。
│        ├─interface.uts               // 声明插件对外暴露的API,必需
│        └─unierror.uts                // 定义插件对外暴露的错误信息,可选
├─changelog.md                   // 说明文件
├─readme.md                      // 说明文件
└─package.json                    // 插件清单文件,必需

实现uts插件

utssdk/app-android/目录下新建barcode.uts文件,用于实现红外扫码的AidcManager.CreatedCallback接口,完成以下功能:

  • 完成aidcManager实例的绑定。
  • 创建红外扫码读取器实例。
  • 红外扫码读取器实例绑定响应事件。
  • 开启红外扫码读取器实例对红外扫码结果的响应
  • 销毁红外扫扫码管理实例、读取器实例

export class BarcodeManager implements CreatedCallback {
  /** 扫码模块的管理实例 */
  manager: AidcManager | null = null
  /** 扫码的读取器实例 */
  reader: BarcodeReader | null = null
  /** 扫码的监听器实例 */
  listener: BarcodeReader.BarcodeListener | null

  // 绑定红外扫码读取器的监听器实例
  constructor(listener: BarcodeReader.BarcodeListener) {
    this.listener = listener
  }

  /** 扫码模块的初始化完成后,绑定红外扫码监听事件, */
  override onCreated(aidcManager: AidcManager) {
  
    // 绑定红外扫码模块实例
    this.manager = aidcManager

    if (this.manager !== null) {
      // 创建读取器
      this.reader = this.manager?.createBarcodeReader()

      if (this.reader !== null) {
        try {
          // 扫码读取器的一些配置
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODE_128_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_GS1_128_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_QR_CODE_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODE_39_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_DATAMATRIX_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_UPC_A_ENABLE, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_EAN_13_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_AZTEC_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODABAR_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_INTERLEAVED_25_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_PDF_417_ENABLED, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CODE_39_MAXIMUM_LENGTH, 10)
          this.reader?.setProperty(BarcodeReader.PROPERTY_CENTER_DECODE, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_NOTIFICATION_BAD_READ_ENABLED, true)

          this.reader?.setProperty(BarcodeReader.PROPERTY_INTERLEAVED_25_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_INTERLEAVED_25_REDUNDANCY_MODE, 10)
          this.reader?.setProperty(BarcodeReader.PROPERTY_LINEAR_VOID_HANDLING, false)
          this.reader?.setProperty(BarcodeReader.PROPERTY_DATA_PROCESSOR_LAUNCH_BROWSER, false)

          // ocr设置
          const comreg: string =
            '[{"enabled":true,"key":"chanum","regexValue":"[A-Z a-z]{2}\\d{3}","type":"CUSTOMIZED"},' +
            '{"enabled":true,"key":"OCR_CONTENT_REGEX_IP_ADDRESS","type":"EMBEDDED"}]'
          this.reader?.setProperty(BarcodeReader.PROPERTY_OCR_ENABLED, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_OCR_EXCLUSIVE, true)
          this.reader?.setProperty(BarcodeReader.PROPERTY_OCR_CONTENT_REGEX_SEQUENCE, comreg)

          // 按键触发模式
          this.reader?.setProperty(
            BarcodeReader.PROPERTY_TRIGGER_CONTROL_MODE,
            BarcodeReader.TRIGGER_CONTROL_MODE_AUTO_CONTROL
          )
        } catch (e) {
          console.error(`${this} Failed to apply properties`)
        }

        // 注册响应事件
        this.reader?.addBarcodeListener(this.listener)
        // 读取器开启红外扫码结果响应
        this.reader?.claim();
      }
    }
  }

  // 提供红外扫码管理模块的注销方法
  destroyManager() {
    // 注销读取器实例
    if (this.reader != null) {
      // 停止读取器的响应
      this.reader?.release();
      // 注销监听事件
      this.reader?.removeBarcodeListener(this.listener)
      this.listener = null

      // 关闭扫码读取器
      this.reader?.close()
      this.reader = null
    }
    
    // 注销红外扫码管理模块
    if (this.manager != null) {
      // 关闭红外扫码模块连接
      this.manager?.close()
      this.manager = null
    }
  }
}

utssdk/app-android/目录下新建listener.uts文件,实现BarcodeReader.BarcodeListener接口,给读取器实例绑定不同的响应事件。


export class Listener implements BarcodeReader.BarcodeListener {
  onScanBarcode: (barcode: string) => void
  onScanFail: () => void

  // 绑定外部的红外扫码结果响应事件
  constructor(onScanBarcode: (barcode: string) => void, onScanFail: () => void) {
    this.onScanBarcode = onScanBarcode
    this.onScanFail = onScanFail
  }

  // 扫码成功回调事件方法
  override onBarcodeEvent(event: BarcodeReadEvent) {
    const barcode = event.getBarcodeData()
    this.onScanBarcode(barcode)
  }

  // 扫码失败的回调事件方法
  override onFailureEvent(event: BarcodeFailureEvent) {
    this.onScanFail()
  }
}

最后实现入口文件index.uts,这个文件给uniapp组件、页面提供了可调用的方法:

  • initSilenceScan:用于初始化红外扫码对象
  • destroySilenceScan:用于销毁红外扫码对象,释放内存
// 扫码模块实例
let barcodeManager: BarcodeManager | null = null

// 初始化红外扫码相关功能
@UTSJS.keepAlive
export function initSilenceScan ({ onScanBarcode, onScanFail }: InitSilenceScanOpt) {
  // 判断红外扫码模块是否存在,如果存在则不重复创建。
  if (barcodeManager?.manager == null) {
  
    // 创建红外扫码响应事件实例
    const listener = new Listener(onScanBarcode, onScanFail)
    
    // 创建红外扫码功能模块
    const barcodeManager = new BarcodeManager(listener)
    
    // 获取安卓app的上下文对象
    const context = UTSAndroid.getAppContext()
    
    // 调用AidcManager.create抽象方法,创建红外扫码管理实例
    AidcManager.create(context, barcodeManager)
  }
}

export function destroySilenceScan() {

  // 销毁红外扫码模块
  barcodeManager?.destroyManager()
}

封装红外扫码业务组件SilenceScan。后续需要使用红外扫码的页面组件,只需要引入SilenceScan组件,即可丝滑享受红外扫码功能。

<script setup lang="ts">
import * as SilenceScan from '@/uni_modules/silence-scan'
import { onHide, onShow } from '@dcloudio/uni-app'
import { onBeforeMount, onBeforeUnmount, onUnmounted } from 'vue'

/** 对外抛出的ScanCode事件 */
const emit = defineEmits<{ scanCode: [code: string] }>()

/** 扫码成功事件 */
const onSilenceScanCode = (code: string) => {
  emit('scanCode', code)
}

/** 扫码失败事件 */
const onScanFail = () => {
  uni.showToast({ icon: 'none', title: '请检查扫描的条码、二维码' })
}

// 组件所在页面显示,初始化扫码对象
onShow(() => {
  SilenceScan.initSilenceScan({ onScanBarcode: onSilenceScanCode, onScanFail })
})

// 当前组件所在页面隐藏,销毁扫码。
onHide(() => {
  SilenceScan.destroySilenceScan()
})

// 当前组件销毁,销毁扫码对象
onUnmounted(() => {
  SilenceScan.destroySilenceScan()
})
</script>

<template>
  <view />
</template>

<style scoped></style>

最后

文中如有错误或不严谨的地方,请给予指正,十分感谢。

代码地址

红外扫码uts插件 github

做中国人自己的视频编辑UI框架,WebCut正式开源

作者 否子戈
2025年12月4日 17:22

项目地址:github.com/tangshuang/…

朋友们晚上好啊,这段时间我在忙着完成最新的开源项目WebCut。这个项目是我这小半年来唯一的开源新项目,这对比我过去几年的一些开源事迹来说,真的是一段低产荒。不过这是正常的,没有任何人可以长时间的一直发布新项目,这种沉寂,正是因为我把时间和精力都投入在其他事情上,所以其实是好事。之所以要发起和开源这个项目,说起来还是有些背景,下面我会来聊一聊关于这个项目的一些背景,以及过程中在技术上的一些探索。

没有合适的视频编辑UI框架😭

过去半年,我连续发布了多款与视频相关的产品,这些产品或多或少都需要用户参与视频编辑操作,比如给视频添加字幕、对视频进行裁剪、给视频配音等等,这些在视频编辑器中常见的视频处理工具,在Web端其实需求非常巨大,特别是现在AI领域,制作各种个样的视频的需求非常多。而这些需求,可能并不需要在产品中载入一整个视频编辑器,而是只需要几个简单的部件来实现编辑功能。然而回望开源市场,能够支持这种编辑能力的项目少之又少,虽然有一些项目呈现了视频编辑的能力,然而,要么项目太大,一个完整的视频编辑器甩在开发者脸上,要么过于底层,没有UI界面。如果我只是想有一个视频预览,再有轨道和几个配置界面,就没法直接用这些项目。包括我自己在内,每次有与视频相关的功能,都要把之前在另外一个产品中实现的编辑能力移植到新产品中,而且要调的细节也很多。正是这种求而不得的现状,促使我打算自己写一个视频编辑器项目。

初始想法💡:拼积木

我可不是从0开始的,因为我已经开发过很多次视频编辑相关的功能了。我还在Videa项目中完整实现了一个视频编辑器。因此,我的想法是把我之前做过的功能,整理一遍,就可以得到想要的组件或逻辑。有了这个工具包之后,我只需要在将来的新产品中复用这些代码即可。于是我建立了一个独立的npm包,来把所有功能集中放在一起。随着持续的迭代,我发现其实这里面是有规律的。

视频编辑器我们都用过,像剪映一样,有各种功能,细节也很多。但是,当我们把视频编辑的功能放到某个具体的产品中时,我们是不可能直接把整个编辑器给用户的。实际上,我们最终呈现的产品形态,基本上都是剪映的子集,而且是很小很小的子集,可能只是整个剪映1%的功能,最终给到用户操作可能只是非常简单的一次性操作,而页面也很轻量,属于用户即用即走,用完就关再也不会来第二次的那种。正是这种看似功能点很小,但实际上需要为它单独定制,技术上的成本可以用巨大来描述的场景,让我觉得这是一个需要认真对待的点。

我的计划是采用组件化的思想,把一个视频编辑器拆成一个一个的组件,把一个完整的剪映编辑器,拆成一个按钮一个按钮的积木。当我们面对产品需求时,就从这些积木中挑选,然后组合成产品经理所描述的功能,同时,具体这些积木怎么布局,则根据设计稿调整位置,还可以用CSS来覆盖组件内部的样式,达到与设计稿媲美的效果。

上面是我用AI做的一张示意图,大概就是这个意思,把一个编辑器拆的细碎,然后要什么功能就把对应的组件拿来拼凑一下。比如要对视频进行静音裁剪,就只要把预览区和轨道区拿出来,然后自己再增加一些能力上去。这样,开发者在面对各种各样的需求时,就能快速搭建起界面效果,而不需要从头实现界面和视频处理能力。

通过不同的组合方式,配合开发者自己的布局,就可以创建符合需求的视频编辑界面。

没那么容易😥:外简内繁的接口艺术

虽然想法很容易有,但是要做成成品,并且发布为可用的库可没那么轻松。要知道视频编辑器的功能非常多,多到我们无法用简单的文字描述它们。那么,如何才能站在开发者的角度,让这件事变得简单呢?

开发库永远面临着一个矛盾:灵活性和规范性之间的矛盾。灵活性就是暴露又多又细的接口,让开发者可以用不同参数,玩出花活,例如知名项目echarts早期就是这种。这种对于刚上手的开发者而言,无言是一场灾难,他们甚至不知道从哪里开始,但是一旦完全掌握,就相当于拥有了一个武器库,可以为所欲为。而规范性则是强制性规则比较多,只能暴露少量接口,避免开发者破坏这种规范,例如很多前端UI组件库就是这样。这两者之间的矛盾,是每一个库或框架开发者最难平衡的。

在视频编辑器上,我认为有些东西是固定的,几乎每一个需求都会用到,例如视频预览播放器、控制按钮等,但是有些功能是低频功能,例如媒体库、字幕编辑等。因此,对于高频和低频的功能,我用两种态度去处理。

高频的功能,拆的很细,拆到一个按钮为止,例如视频播放器,看上去已经很单一了,但是,我还要把预览屏幕和播放按钮拆开,屏幕和按钮如何布局就可以随意去处理。类似的还有导出按钮、分割片段按钮等等。把这些工具拆分为一个一个最小的单元,由开发者自由布局,这样就可以很好的去适配产品需求。

对于低频功能,则直接导出为大组件,这样就可以在一个组件内完成复杂的逻辑,减少开发这些组件时需要跨组件控制状态的心智成本。比如媒体库,里面包含了媒体的管理、上传、本地化等等,这些逻辑还是很复杂的,如果还是按照细拆思路,那么实现起来就烦得要死。因此,这类工具我都是只暴露为一个大的组件。

同时,为了方便开发者在某些情况下快捷接入,我会设计一些自己看到的不错的编辑器UI,然后用上诉的工具把它们搭出来,这样,开发者如果在产品需求中发现仓库里已经有功能一致的组件,就不需要自己去组合,直接使用对应组件即可。

以模仿剪映为例,开发者只需要接入WebCutEditor这个组件即可:

<script setup>
import { WebCutEditor } from 'webcut';
import 'webcut/esm/style.css';
</script>

<template>
    <WebCutEditor />
</template>

这样就可以得到一个界面接近于剪映的视频编辑器。

数据驱动,视频编辑的DSL

经过我的研究,发现对于单纯的视频编辑而言,编辑器其实只需要两份数据,就可以解决大部分场景下的需求。一份是编辑的素材数据,一份是视频的配置数据。

素材数据

它包含素材文件本身的信息、素材的组织信息、素材的属性信息。

文件信息

我通过opfs将文件存在本地,并且在indexedDB中存储每一个文件的关联信息。包含文件的类型、名称、大小等。在一个域名下,每一个文件的实体(也就是File对象)只需要一份,通过file-md5值作为索引存在opfs中。而一个文件可能会在多处被使用,indexedDB中则是记录这些信息,多个关联信息可同时链接到同一个File。另外,基于indexedDB的特性,还可以实现筛选等能力。

素材组织信息

主要是指当把素材放在视频时间轨道中时,所需要的数据结构。包含轨道列表、素材所对应的文件、素材对应时间点、播放时的一些属性等等信息。这些信息综合起来,我们就知道,在视频的某一个时刻,应该播放什么内容。

素材属性信息

在播放中,素材以什么方式呈现,如文本的样式、视频音频的播放速度、动画、转场等。

配置数据

主要指视频本身的信息,在导出时这些配置可以直接体现出来,例如视频的分辨率、比例、速率等,视频是否要添加某些特殊的内容,例如水印等。

基于素材数据和配置数据,我们基本上可以完整的知道当前这个视频的编辑状态。通过数据来恢复当前的编辑状态,变得可行,这可以抵消用户在浏览器中经常执行“刷新”操作带来的状态丢失。同时,这份数据也可以备份到云端,实现多端的同步(不过需要同时同步File,速度肯定会受影响)。而且由于数据本身是纯序列化的,因此,可以交给AI来进行处理,例如让AI调整一些时间、样式等可基于纯序列化数据完成的功能。这就让我们的编辑器变得有更多的玩法。

发布上线🌏

经过几天的工作,我终于把代码整理完整,经过调试之后,基本可用了,便迫不及待的准备与大家分享。现在,你可以使用这个项目了。

由于底层是由Vue3作为驱动的,因此,在Vue中使用有非常大的优势,具体如下:

npm i webcut

先安装,安装之后,你就可以在Vue中如下使用。

<script setup>
import { WebCutEditor } from 'webcut';
import 'webcut/esm/style.css';
</script>

<template>
    <WebCutEditor />
</template>

或者如果你的项目支持typescript,你可以直接从源码进行引入,这样就不必主动引入css:

<script setup lang="ts">
import { WebCutEditor } from 'webcut/src';
</script>

如果是非Vue的项目,则需要引用webcomponents的构建产物:

import 'webcut/webcomponents';
import 'webcut/webcomponents/style.css';

export default function Some() {
    return <webcut-editor></webcut-editor>;
}

如果是直接在HTML中使用,可以直接引入webcomponents/bundle,这样包含了Vue等依赖,就不需要另外构建。

<script src="https://unpkg.com/webcut/webcomponents/bundle/index.js"></script>
<link rel="stylesheet" href="https://unpkg.com/webcut/webcomponents/bundle/style.css" />

<webcut-editor></webcut-editor>

如果是想自己布局,则需要引入各个很小的组件来自己布局。在这种情况下,你必须引入WebCutProvider组件,将所有的子组件包含在内。

<webcut-provider>
    <webcut-player></webcut-player>
    <webcut-export-button></webcut-export-button>
</webcut-provider>

未来展望

当前,WebCut还是处于很初级的阶段,实现了最核心的能力,我的目标是能够为开发者们提供一切需要的组件,并且不需要复杂的脚本处理就可以获得视频编辑的全部功能。还有很多功能没有实现,在计划中:

  • 历史记录功能,包含撤销和重做功能
  • 内置样式的字体
  • 花字,比内置样式更高级的文本
  • 轨道里的素材吸附能力
  • 视频的轨道分离(音频分离)
  • 音视频的音量调节
  • 单段素材的下载导出
  • 整个视频导出时可以进行分辨率、码率、速率、编码、格式的选择,支持只导出音频

以上这些都是编辑的基本功能。还有一些是从视频编辑定制化的角度思考的能力:

  • 动画(帧)支持
  • 转场过渡效果支持
  • 扩展功能模块,这部分可能会做成收费的,下载模块后,通过一个接口安装一下,就可以支持某些扩展功能
  • AI Agent能力,通过对话来执行视频编辑,降低视频编辑的门槛
  • 视频模板,把一些流行的视频效果片段做成模板,在视频中直接插入后,与现有视频融合为模板中的效果
  • 基于AI来进行短剧创作的能力

要实现这些能力,需要大量的投入,虽然现在AI编程非常火热,但是真正能够完美实现的,其实还比较少,因此,这些工作都需要小伙伴们的支持,如果你对这个项目感兴趣,可以通过DeepWiki来了解项目代码的底层结构,并fork项目后,向我们提PR,让我们一起共建一个属于我们自己的视频编辑UI框架。

最后,你的支持是我前进的动力,动动你的小手,到我们的github上给个start,让我们知道你对此感兴趣。

深入理解 Vue keep-alive:缓存本质、触发条件与生命周期对比

2025年12月4日 17:14

一、先明确核心结论

keep-alive 是 Vue 内置的抽象组件(不渲染真实 DOM),它的核心作用是缓存被包裹的组件实例,缓存的关键数据结构如下:

  • 缓存容器:keep-alive 实例上的 this.cache(一个对象,key 是组件的「缓存标识」,value 是组件实例);
  • 辅助记录:this.keys(一个数组,存储缓存组件的 key,用于实现 max 缓存数量限制);
  • 挂载关系:被缓存的组件实例 → 作为 this.cache 对象的属性值 → 挂在 keep-alive 组件实例上,而非被缓存组件自己的实例上。

二、keep-alive 挂载缓存的完整过程(分步骤拆解)

以 Vue 2 为例(Vue 3 逻辑一致,仅源码实现细节略有差异),核心流程如下:

步骤 1:keep-alive 初始化,创建缓存容器

keep-alive 组件初始化时,会在自身实例上创建两个核心属性,用于存储缓存:

// keep-alive 组件的初始化逻辑(简化版)
export default {
  name: 'keep-alive',
  abstract: true, // 抽象组件,不参与DOM渲染
  props: {
    include: [String, RegExp, Array], // 需缓存的组件
    exclude: [String, RegExp, Array], // 排除缓存的组件
    max: [String, Number] // 最大缓存数量
  },
  created() {
    this.cache = Object.create(null); // 缓存容器:{ key: 组件实例 }
    this.keys = []; // 缓存key列表:[key1, key2...]
  },
  // ...其他生命周期
}
  • this.cache :空对象,后续用来存「缓存标识 → 组件实例」的映射;
  • this.keys :空数组,记录缓存 key 的顺序,用于 LRU 淘汰(超出 max 时删除最久未使用的缓存)。

步骤 2:组件首次渲染,判断是否缓存

当 keep-alive 包裹的组件首次渲染时,keep-alive 的 render 函数会执行核心逻辑:

  1. 获取被包裹组件的**「缓存标识」**(key):
    • 默认 key:组件名 + 组件实例的uid(避免同组件不同实例冲突);
    • 自定义 key:可通过 key 属性指定(如 <keep-alive><component :is="comp" :key="compKey" /></keep-alive>)。
  1. 判断是否符合缓存规则(include / exclude):
    • 若符合:将组件实例存入 this.cache,并把 key 加入 this.keys
    • 若不符合:不缓存,直接渲染组件(和普通组件一样)。

举个例子:

<keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
</keep-alive>

步骤 3:缓存组件实例,挂载到 keep-alive 上

核心逻辑简化如下:

// keep-alive 的 render 函数核心逻辑(简化版)
render() {
  const slot = this.$slots.default;
  const vnode = getFirstComponentChild(slot); // 获取被包裹的第一个组件vnode
  const componentOptions = vnode && vnode.componentOptions;
  
  if (componentOptions) {
    // 1. 生成缓存key(核心:唯一标识组件实例)
    const key = this.getCacheKey(vnode);
    const { cache, keys } = this;

    // 2. 判断是否需要缓存(符合include,不符合exclude)
    if (this.shouldCache(componentOptions)) {
      // 3. 若缓存中已有该组件实例,直接复用
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // 更新key顺序(LRU:把当前key移到最后,标记为最近使用)
        remove(keys, key);
        keys.push(key);
      } else {
        // 4. 首次渲染:将组件vnode(包含实例)存入缓存
        cache[key] = vnode;
        keys.push(key);
        // 5. 超出max时,删除最久未使用的缓存
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // 标记组件为“被缓存”,避免重复初始化
      vnode.data.keepAlive = true;
    }
  }
  return vnode;
}

关键挂载动作cache[key] = vnode → 组件的 vnode(包含 componentInstance 即组件实例)被作为 cache 对象的属性值,挂载到 keep-alive 实例的 this.cache 上。

步骤 4:组件再次渲染,复用缓存实例

当被缓存的组件需要再次渲染时(比如路由切换后返回):

  1. keep-alive this.cache 中根据 key 取出对应的组件实例;
  2. 将缓存的实例赋值给新的 vnode 的 componentInstance
  3. 直接复用该实例渲染,不再执行组件的 created / mounted 等生命周期(而是触发 activated 钩子)。

三、关键细节:为什么缓存不挂在被缓存组件自己的实例上?

  1. 逻辑合理性keep-alive 是「缓存管理者」,应该由管理者统一存储和管理所有被缓存的组件,而非让组件自己存储;
  2. 避免内存泄漏:若缓存挂在被缓存组件实例上,组件实例本身无法被销毁(因为缓存引用了它),而 keep-alive 统一管理可通过 maxexclude 主动清理缓存;
  3. 多实例隔离:多个 keep-alive 组件的缓存是隔离的(比如页面 A 和页面 B 各有一个 keep-alive),每个 keep-alive 实例有自己的 cache,不会互相干扰。

总结:

一、先肯定你的正确认知

  1. ✅ keep-alive 是 Vue 官方内置的抽象组件(不渲染真实 DOM),被包裹的组件实例 / 状态会挂载在keep-alive 实例的 cache 对象上(而非组件自身);
  2. ✅ 缓存的核心内容是组件的 VNode(包含组件实例、DOM 节点描述、数据状态如 data/props/ 输入框值等);
  3. ✅ 组件的缓存触发和「组件失活」强相关(比如路由跳转导致组件被隐藏)。

二、需要修正 / 补充的关键细节

细节 1:“组件经过 keep-alive 时被缓存” → 不是 “经过”,而是 “组件被 keep-alive 包裹且失活时才缓存”

keep-alive 不会 “主动拦截” 组件,而是当被包裹的组件从「激活状态」变为「失活状态」时,才会将其 VNode 存入缓存(而非加载时就缓存)。

  • 激活状态:组件在页面中可见(比如当前路由匹配的 Home 组件);
  • 失活状态:组件被隐藏(比如跳转到 List 路由,Home 组件被 router-view 卸载)。

简单说:keep-alive 是 “挽留” 即将被销毁的组件 —— 默认情况下,组件失活会被销毁,而 keep-alive会把它存入缓存,避免销毁。

细节 2:“只有页面跳转才缓存” → 不完全对,「组件失活」都触发缓存(不止路由跳转)

路由跳转是最常见的 “组件失活” 场景,但不是唯一场景:

  • 场景 1(路由跳转):/home → /list,Home 组件失活 → 被缓存;
  • 场景 2(条件渲染):<keep-alive><component :is="compName" /></keep-alive>,当compName 从 Home 改为 List 时,Home 失活 → 被缓存;
  • 场景 3(v-if 隐藏):<keep-alive><div v-if="show">Home组件</div></keep-alive>,当show 从 true 改为 false 时,Home 失活 → 被缓存。

核心:只要 keep-alive 包裹的组件从 “渲染在页面上” 变为 “不渲染”,且符合 include/exclude 规则,就会被缓存。

细节 3:“只加载页面不跳转,不会缓存” → 准确说:“组件未失活,缓存容器中已有该组件的 VNode,但未触发「缓存复用」”

即使不跳转,只要组件被 keep-alive 包裹并完成首次渲染:

  1. keep-alive cache 已经存入了该组件的 VNode (可以通过前面的代码查到);
  2. 只是因为组件未失活,所以不会触发 activated 钩子,也不会体现出 “缓存效果”(比如输入框输入内容,不跳转的话,内容本来就在,看不出缓存);
  3. 只有当组件失活后再次激活(比如跳转回来),才会从缓存中复用 VNode,此时能看到 “状态保留”(比如输入框内容还在)—— 这才是缓存的 “可见效果”。

举个直观例子:

  • 步骤 1:访问 /home,Home 组件渲染(激活),keep-alive.cache 中已有 Home 的 VNode(但未体现缓存);
  • 步骤 2:在 Home 输入框输入 “123”,跳转到 /list(Home 失活),keep-alive 保留 Home 的 VNode(包含输入框的 “123”);
  • 步骤 3:跳回 /home(Home 激活),keep-alive 复用缓存的 VNode,输入框仍显示 “123”—— 这就是缓存的效果。

如果只停留在步骤 1(不跳转),虽然缓存容器中有 Home 的 VNode,但因为没有 “失活→激活” 的过程,所以看不到缓存的效果,并非 “没有缓存”。

细节 4:缓存的 VNode 包含什么?→ 不止节点 / 属性,还有组件的「完整实例状态」

VNode 是组件的 “虚拟描述”,缓存 VNode 本质是缓存组件实例:

  • 包含 DOM 结构描述(比如 <div class="home">);
  • 包含组件的响应式数据data/computed/props);
  • 包含组件的 DOM 状态(输入框值、滚动条位置、复选框勾选状态);
  • 包含组件的生命周期状态不会再执行 created / mounted ,而是执行 activated)。

三、总结:精准理解 keep-alive 的缓存逻辑

  1. 挂载关系keep-alive 是 “缓存管理者”,被包裹组件的 VNode(含实例 / 状态)挂载在 keep-alive实例的 cache 对象上;
  2. 缓存触发:组件被 keep-alive 包裹 + 组件从「激活→失活」(路由跳转 / 条件隐藏等)→ 存入缓存;
  3. 缓存复用:组件从「失活→激活」→ 从 cache 中取出 VNode 复用(不重新创建实例,保留状态);
  4. 可见效果:只有 “失活→激活” 的过程,才能体现缓存(状态保留),仅加载组件不跳转,缓存存在但无 “可见效果”。

简单记: keep-alive 的核心是 “保活”—— 不让失活的组件销毁,而是存入缓存,下次激活时直接复用,避免重复创建 / 销毁,同时保留组件状态。


keep-alive组件加载生命周期对比

阶段 Vue 2 生命周期 Vue 3 组合式 API 核心特点
首次加载 beforeCreate → created → beforeMount → mounted → activated setup → onBeforeMount → onMounted → onActivated 完整生命周期,最后触发激活钩子
失活缓存 deactivated onDeactivated 仅触发失活钩子,不销毁组件
二次加载 activated onActivated 仅触发激活钩子,跳过创建 / 挂载
缓存销毁 deactivated → beforeDestroy → destroyed onDeactivated → onBeforeUnmount → onUnmounted 先失活,再销毁

深入理解 JavaScript 原型链与继承机制:从 instanceof 到多种继承模式

作者 Yira
2025年12月4日 17:06

深入理解 JavaScript 原型链与继承机制:从 instanceof 到多种继承模式

在 JavaScript 的面向对象编程(OOP)体系中,原型(prototype)和原型链是核心机制。不同于 Java、C++ 等基于类的语言,JavaScript 采用基于原型的继承模型。理解这一机制,不仅能正确使用 instanceof 运算符,还能灵活实现各种继承方式。本文将从原型链本质出发,手写 instanceof,并系统梳理三种经典继承模式。


一、原型与原型链:JavaScript OOP 的基石

每个 JavaScript 对象都有一个内部属性 [[Prototype]](可通过 __proto__ 访问),它指向另一个对象——即该对象的“原型”。当访问一个对象的属性时,若自身没有,引擎会沿着原型链向上查找,直到 null

const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(arr.__proto__.__proto__ === Object.prototype); // true
console.log(arr.__proto__.__proto__.__proto__ === null); // true

这条从实例 → 构造函数的 prototype → 更上层原型 → null 的链条,就是原型链

constructor 属性的作用

每个原型对象默认有一个 constructor 属性,指回其构造函数:

arr.constructor === Array; // true
Array.prototype.constructor === Array; // true

二、手写 instanceof:理解其本质

A instanceof B 的语义是:判断构造函数 B 的 prototype 是否出现在 A 的原型链上

我们可以手动实现:

function isInstanceOf(left, right) {
  let proto = left.__proto__;
  while (proto) {
    if (proto === right.prototype) {
      return true;
    }
    proto = proto.__proto__; // 向上遍历原型链
  }
  return false;
}

验证示例

function Animal() {}
function Dog() {}
Dog.prototype = new Animal();

const dog = new Dog();
console.log(isInstanceOf(dog, Dog));     // true
console.log(isInstanceOf(dog, Animal));  // true(因为 Dog.prototype = new Animal())
console.log(isInstanceOf(dog, Object));  // true(所有对象最终继承自 Object)

✅ 这说明 instanceof基于血缘关系(原型链) 的判断,而非简单的类型匹配。


三、JavaScript 继承的三种经典模式

继承的本质是:子类能访问父类的属性和方法。由于 JS 没有类(ES6 之前),我们通过函数和原型模拟继承。


模式一:构造函数绑定继承(借用构造函数)

通过 callapply 在子类构造函数中调用父类构造函数,实现属性继承

function Animal() {
  this.species = '动物';
}

function Cat(name, color) {
  Animal.call(this); // 借用父类构造函数
  this.name = name;
  this.color = color;
}

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // '动物'
优点:
  • 每个实例拥有独立的父类属性(避免引用共享问题)
  • 可以向父类构造函数传参
缺点:
  • 无法继承父类原型上的方法
  • 父类构造函数每次都会执行,浪费性能(若父类有复杂初始化)

❌ 此模式仅实现“属性继承”,未实现“方法继承”。


模式二:原型链继承(prototype 模式)

将父类的实例赋值给子类的 prototype,使子类原型链指向父类实例。

function Animal() {
  this.species = '动物';
}
Animal.prototype.say = function() { console.log('I am an animal'); };

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 关键:子类原型 = 父类实例
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 修复 constructor 指向

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // '动物'
cat.say(); // 'I am an animal'
优点:
  • 子类可继承父类属性 + 原型方法
缺点:
  • 所有子类实例共享父类实例的属性(若属性是引用类型,会互相影响)
  • 无法向父类构造函数传参(new Animal() 无参数)

✅ 这是真正意义上的“原型链继承”,但存在共享状态风险。


模式三:组合继承(推荐)

结合前两种模式:构造函数继承属性 + 原型链继承方法

function Animal(name) {
  this.species = '动物';
  this.name = name;
}

Animal.prototype.say = function() {
  console.log(`I am ${this.name}`);
};

function Cat(name, color) {
  Animal.call(this, name); // 继承属性(可传参,独立副本)
  this.color = color;
}

// 继承方法
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

const cat1 = new Cat('咪咪', '白色');
const cat2 = new Cat('小黑', '黑色');
优点:
  • 属性独立,方法共享
  • 支持传参
  • 符合 OOP 直觉
缺点:
  • 父类构造函数被调用了两次(一次在 new Animal(),一次在 Animal.call(this)

💡 尽管有小瑕疵,这是 ES5 时代最常用的继承模式。


模式四:寄生组合继承(优化版)

为避免组合继承中父类构造函数重复调用,引入“空中介对象”:

function inheritPrototype(Child, Parent) {
  const prototype = Object.create(Parent.prototype); // 创建空对象,原型指向 Parent.prototype
  prototype.constructor = Child;
  Child.prototype = prototype;
}

function Animal(name) {
  this.name = name;
}
Animal.prototype.say = function() { console.log(this.name); };

function Cat(name, color) {
  Animal.call(this, name);
  this.color = color;
}

inheritPrototype(Cat, Animal); // 关键优化

const cat = new Cat('小花', '橘色');
cat.say(); // '小花'
优点:
  • 只调用一次父类构造函数
  • 原型链完整,instanceof 和 isPrototypeOf 正常工作

✅ 这是 ES5 中最理想的继承方式,也是 class extends 的底层原理之一。


四、错误示范:直接继承 prototype

Cat.prototype = Animal.prototype; // 危险!

这会导致父子类共享同一个原型对象,修改子类原型会影响父类:

Cat.prototype.meow = function() {};
console.log(Animal.prototype.meow); // function() {} ← 父类被污染!

❌ 绝对不要这样做!


五、为什么 instanceof 在大型项目中很重要?

在多人协作或复杂框架中,对象来源多样,类型模糊。instanceof 能可靠判断对象“血缘”:

if (obj instanceof Array) { ... }
if (element instanceof HTMLElement) { ... }

相比 typeof(对数组、null 返回 "object")或 constructor(易被覆盖),instanceof 基于不可篡改的原型链,更安全可靠。


六、总结

继承模式 属性继承 方法继承 传参 属性独立 推荐度
构造函数绑定 ⭐⭐
原型链继承
组合继承 ⭐⭐⭐
寄生组合继承 ⭐⭐⭐⭐

掌握原型链和 instanceof 的本质,是理解 JavaScript OOP 的关键。虽然 ES6 引入了 class 语法糖,但其底层仍是原型机制。深入这些原理,才能写出健壮、可维护的代码。

你可能不知道 react 组件中受控和非受控的秘密!

作者 孟祥_成都
2025年12月4日 17:03

前言

在 React.js 中,涉及到处理表单的所有组件,你都不得不先弄清楚一个问题,什么是表单组件的受控和非受控模式!

以下是我的 headless 组件库新增的 Checkbox 组件(这个组件主要参考了字节的 arco-design、阿里的 ant-design 和国外的 shadcn/ui 源码),网站也有详细的组件教程,欢迎点赞交流。

关于受控和非受控的应用具体案例,可以看我的 headless 组件库的 checkbox

其中两个核心概念是 受控组件(Controlled Components) 和 非受控组件(Uncontrolled Components)。它们决定了表单数据在 React 组件中是如何被管理的。简单来说:

  • 受控组件 完全依赖 React 的 state 来存储和更新表单数据。

  • 非受控组件 则依赖原生 DOM 自身来管理表单数据。

本文将带你理解这两类组件的区别、实现方式,并提供在实际应用中使用它们的最佳实践。

什么是受控组件?

受控组件指由 React state 完全管理 的表单元素(如 input、textarea、select 等)。 也就是说,该表单元素的值完全由 React state 决定,React 成为表单数据的 唯一数据源。

通过使用 state 控制表单,你可以:

  • 更精确地控制用户输入行为

  • 轻松执行校验逻辑

  • 格式化输入等等

我们举个例子:

import React, { useState } from 'react';

function ControlledComponent() {
  const [value, setValue] = useState('');

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    alert('A name was submitted: ' + value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" value={value} onChange={handleChange} />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

export default ControlledComponent;

在这个示例中:

  • value 用来保存输入框的当前值。

  • handleChange 在用户输入时更新该 state。

  • handleSubmit 在提交时使用 state 中的值。

我们小小总结一下:受控组件的核心在于:表单数据完全由 React state 管理。

使用受控组件的优势

一般情况下,复杂g场景都会使用受控模式,因为两个很明显的好处:

  • 更容易与复杂 UI 库集成
  • 更容易实现表单校验

与常见组件库集成

国内主流组件库的表单,基本都是来自于 ant-design 的 Form 组件逻辑, From 组件说白了,就是一个中介者模式,也就是说,Form 组件内部维护了一个收集表单数据的 store,当表单数据修改的时候, From 中的 store 会同步修改,并且刷新视图。

所以这里我们必须要知道什么时候数据被修改了,这就是受控组件的优势,onChange 事件触发,就意味着数据很可能被修改了

更容易实现表单校验

正因为能够显式的收集表单数据的变化(第三方 UI 库),才能做到在提交表单的时候,我们能够有机会来统一校验值是否符合业务要求。

或者在填写表单的时候,就显示错误信息。

举例:

import React, { useState } from 'react';

function ValidatedForm() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const handleChange = (event) => {
    const value = event.target.value;
    setEmail(value);

    if (!value.includes('@')) {
      setError('Invalid email address');
    } else {
      setError('');
    }
  };

  return (
    <div>
      <input type="email" value={email} onChange={handleChange} />
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

上述例子会在用户输入的每一步进行校验,并提供即时反馈。

最后我们可以再来看两个封装一个受控组件的案例,体会其用法:

文本输入框

import React, { useState } from 'react';

function TextInput() {
  const [text, setText] = useState('');

  const handleChange = (event) => {
    setText(event.target.value);
  };

  return (
    <div>
      <label>
        Text:
        <input type="text" value={text} onChange={handleChange} />
      </label>
      <p>Entered Text: {text}</p>
    </div>
  );
}

export default TextInput;

复选框(Checkbox)

import React, { useState } from 'react';

function Checkbox() {
  const [isChecked, setIsChecked] = useState(false);

  const handleChange = (event) => {
    setIsChecked(event.target.checked);
  };

  return (
    <div>
      <label>
        Accept Terms:
        <input type="checkbox" checked={isChecked} onChange={handleChange} />
      </label>
      <p>Checked: {isChecked ? 'Yes' : 'No'}</p>
    </div>
  );
}

export default Checkbox;

什么是非受控组件

非受控组件的状态由 DOM 自己管理,而不是 React state。 这种方式通常依赖 ref 来读取 DOM 中的值。我们来举个例子:

import React, { Component } from 'react';

class UncontrolledComponent extends Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }

  handleSubmit = () => {
    console.log(this.inputRef.current.value);
  }

  render() {
    return (
      <div>
        <input 
          type="text"
          ref={this.inputRef}
        />
        <button onClick={this.handleSubmit}>Submit</button>
      </div>
    );
  }
}

从上面我们可以看到非受控组件的特点:

  1. 内部管理状态

表单的状态存储在 DOM 中,而非 React state。

  1. 直接访问 DOM

通过 ref.current.value 获取值。

  1. 更简单

适用于无需实时校验的场景

什么时候适合非受控组件

简单单表单

适用情况包括:

  • 表单结构简单,字段少

  • 不需要复杂校验或联动逻辑

  • 不希望 state 频繁更新导致 re-render

注意

这里特别注意一个优点,就是不希望 state 频繁更新导致 re-render,这让非受控组件的性能非常高。

国外流行的 React-hook-form 的性能高的原因,就是主要以非受控为主。

其实 ant-design 内部为了避免 setState 每次全量刷新表单造成性能问题,主要的方式是采用了发布订阅模式,把所有表单都订阅到了 store 里,当一个表单发生变化时,只刷新这个表单的 state,从而减少了性能损耗。

组件库如何合并受控和非受控状态

我们的组件库使用了 useMergeValue hook, 它可以用来合并受控和非受控模式.

这个 hook 大家可以复制下来,在写表单组件的时候,非常好用(在各种 ui 库中广泛存在)。首先我们先看一下简单用法:

假设我们在处理一个 radio 组件,radio 组件默认是不被选中的,所以 useMergeValue 的默认值,也就是第一个参数是 false, 然后 value 表示是否是受控模式,如果是,外界就传了 value,代表外界接管了 radio 的 state 变化。当然非受控模式一般也支持传一个 defaultValue,所以 defaultValue 代表了外界采用的是非受控模式:

  const [checked, setChecked] = useMergeValue(false, {
    value: props.checked,
    defaultValue: props.defaultChecked,
  });

那么我们合并受控和非受控的思路是什么呢?

完整代码如下,先别急,后面会有详细解释:

'use client';

import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';

export function useMergeValue<T>(
  defaultStateValue: T,
  props?: {
    defaultValue?: T;
    value?: T;
  },
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
  const { defaultValue, value } = props || {};
  const firstRenderRef = useRef(true);
  const prevPropsValue = usePrevious(props?.value);

  const [stateValue, setStateValue] = useState<T>(
    !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
  );

  // 受控转为非受控的时候,需要做转换处理
  useEffect(() => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      return;
    }
    if (value === undefined && prevPropsValue !== value) {
      setStateValue(value);
    }
  }, [value]);

  const mergedValue = isUndefined(value) ? stateValue : value;

  return [mergedValue, setStateValue, stateValue];
}

我们逐条拆解:

1. 初始化:判断当前是受控还是非受控

const [stateValue, setStateValue] = useState(
  value ?? defaultValue ?? defaultStateValue
);

顺序解释:

  1. 如果用户传了 value → 是受控组件
  2. 否则如果传了 defaultValue → 是非受控组件
  3. 否则 → 使用组件内部默认值 defaultStateValue

👉 这一步决定了初始模式是 Controlled 还是 Uncontrolled。

2. 运行时:保持跟随受控 value 的变化

const mergedValue = isUndefined(value) ? stateValue : value;

意思是:

  • 如果外面传了 value → 受控,永远以外界 value 为准
  • 如果外界没传 value → 非受控,使用内部 state

👉 组件内部不需要关心模式切换,只看 mergedValue 就够了。

3. 特殊处理:当“受控 → 非受控”时,需要同步内部 state

这是最难理解的部分,也是很多 UI 库都踩过的坑:

useEffect(() => {
  if (value === undefined && prevPropsValue !== value) {
    setStateValue(value);
  }
}, [value]);

说明:

  • 如果上一次是受控(有 value)

  • 现在变成非受控(value = undefined)

  • 那么内部 state 应该更新为 value(undefined)

    • 这等价于“清空或重置内部状态”

也就是切换受控和非受控的状态

例如:

<Checkbox checked={true} />   // 受控,永远选中
// 某个时刻用户把 checked 用 undefined 覆盖掉,比如执行了 reset
<Checkbox checked={undefined} />  // 变成非受控

如果不特殊处理,组件内部 state 还是旧的 true,就会产生 UI 错乱。

欢迎加入交流群

基于AI的质量风险管控

作者 百度Geek说
2025年12月4日 16:56

导读

线上问题复盘发现质量保障存在测试召回、有效性及排查止损时效性不足等痛点,根源在于保障对象多样演进、线上问题处置复杂。为此我们构建质量风险管控系统,本文分别从风险管理系统的构建思想&实践、风险感知系统的AI效果提升、风险控制系统的智能化建设等维度展开介绍,整体风险管控系统在构建过程效果、使用效果和质量结果等层面均取得较好效果。未来,AI将更深度参与质量风险管控过程,与人工协同构建更智能化的风险管控体系。

01 背景

在线上问题的复盘中,我们总结出质量保障的三大痛点

(1)问题测试召回/感知能力的完备性不足:测试能力缺失导致问题漏检、监控报警缺失导致问题发现滞后;

(2)问题测试召回/感知能力的有效性不足:测试工具不稳定导致测试结果失真、报警配置不合理导致误报/漏报;

(3)问题排查与止损的时效性不足:线上问题定位能力缺失、定位止损慢、止损链路长,导致影响范围扩大。

究其根本,源于以下挑战:

(1)质量保障对象多样、海量且持续演进:我们面对数以万计至百万级的质量保障对象(如服务模块、词表、业务对象等),每类对象对应不同的质量风险与保障策略。同时,这些对象本身还在不断变化,要求质量保障方案具备动态适应能力——即实现对质量保障对象的完整、动态、高效识别与控制,确保在合适的阶段选用最优的质量保障策略组合,以召回潜在风险。

(2)线上问题处置复杂、动态且高度关联:线上系统面临大量动态风险(如变更、数据波动、流量与资源变动等),这些因素持续冲击系统稳定性。因此,我们亟需构建不依赖人、完备且高效的问题感知机制,并打造体系化、智能化的定位与止损能力,从而快速分析线索、实施干预,降低线上问题带来的损失。

为应对上述挑战,我们构建了质量风险管控系统(RMCS),该系统由三部分组成:风险管理系统(RMS-Risk Manage System)-前置消除风险、风险感知系统(ROS-Risk Observe System)-中期发现问题、风险控制系统(RCS-Risk Control System)-后置控制损失。

02 AI的质量风险管控方案

经过多年发展,伴随着AI的发展强大,质量风险管控经过起步阶段、发展阶段的建设积累,已经发展到关键的转型阶段:基于AI的质量风险管控阶段,我们普遍并深入的使用AI能力来解决质量风险管理全流程的问题,提升质量管控的效果和ROI。

图片

△ 基于AI的质量风险管控整体架构

领域知识:把丰富的知识从各类入口、平台、配置以及人脑转移到标准的软件知识图谱中,以结构化知识和非结构化规范知识进行组织,按需转化为实体和关系,从而构建RMCS的丰富、标准、开放的知识图谱生态,实现海量信息的标准化、共享化存储。

RMCS核心能力

  • RMS Agent (AI风险管理):以 AI 为核心,打造具备 “感知 - 决策 - 执行 - 反思” 能力的智能质量风险管理系统,实现 “应拦尽拦”。RMS以开放策略生态思路,灵活管理 “对象质量能力、质量能力风险处置策略”,实现对不同刻画对象能力现状的刻画,驱动质量能力提升,最终通过风险管理应用平台,实现数据、策略、刻画、闭环等环节的统一产品管理。

  • ROS  Agent(AI报警管理):依托领域知识,打造风险实时观测与降噪能力,实现 “应报尽报”。ROS涵盖知识建设、监控创建、维护、评估、降噪及报警跟进等多个环节,覆盖风险管理(如前置监控完备性建设)与控制(如报警有效性、感知后跟进处置)两个阶段,是问题发现后的主要感知手段。

  • RCS  Agent(AI值班人):融合领域模型与领域知识,打造端到端 AI 值班人,具备自主 / 协同式的智能定位与处置能力,实现 “应快尽快”。RCS围绕问题发生到止损全环节,构建报警分类导诊、排查定位、止损等多个环节的智能化控制能力,实现对问题整体损失预期控制,托管全流程风险控制过程。

03 基于AI的质量风险管控核心能力介绍

3.1 RMS Agent (AI做风险管理)

传统质量建设过程的核心痛点包括质量能力缺失、质量能力退化等反复出现的问题,面对庞大且持续变化的质量主题和持续发展的质量保障能力,需要构建不依赖于人刻画和前置风险识别,风险管理系统RMS就是为了解决这种前置风险而产生的, RMS以知识图谱为基础,对质量保障『主体』上全生命周期『质量保障能力』进行持续的合理性风险评估、分发和处理流程管理,牵引『主体』的『质量保障能力』持续发挥预期价值,达到将风险约束在适宜位置/阶段的目的,最终实现3个根本性转变:

  • 从“人治”到“数治”: 将风险管控从依赖专家个人经验和重复劳动的模式,转变为基于全域数据和AI模型进行系统性、自动化治理的模式。

  • 从“孤立”到“协同”: 打破各业务线、各质量阶段之间的信息壁垒,通过统一的风险语言和协作流程,实现跨域风险的联动防控。

  • 从“被动响应”到“主动预防”: 从事后补救的“救火队”模式,转向事中干预、事前预测的“预警机”模式,将风险尽可能约束在萌芽或早期阶段。

RMS核心关注的四大核心痛点和解决思路:

(1)“经验壁垒”与“人力瓶颈”问题: 风险识别、评估、决策高度依赖少数专家的个人经验,难以规模化、标准化和传承,RMS 将专家经验沉淀为可计算、可复用的知识图谱和AI策略模型,让系统具备“专家级”的风险认知和判断能力。

(2)“信息孤岛”与“认知局限”问题:业务系统、质量数据、保障能力等信息分散在不同部门,缺乏全局视角,RMS 通过构建覆盖“主体-对象-能力”的完备知识图谱,打通数据孤岛,形成统一的、相互关联的风险全景视图。。

(3)“响应滞后”与“漏反复”问题: 传统人工巡检和评审方式,风险发现不及时,处理周期长且可能陷入“发现问题-修复-再次发生”的恶性循环,RMS实现7x24小时的自动化风险扫描与监测,并通过策略闭环确保风险被有效分发和处理,防止复发。

(4)“成本高昂”与“灵活性不足”问题: 为每个业务线定制化搭建风控体系成本高、周期长,业务变化时,风控策略难以快速调整,无法适应敏捷开发和快速迭代的需求,RMS 通过中台化、组件化(拼装、插拔式)的架构,提供通用能力的同时,允许业务方低成本、高效率地自定义风控流程和策略,实现“开箱即用”与“灵活定制”的平衡。

RMS旨在从模式上成本上效果上重塑质量风险管理过程,****打破业务间壁垒,最大化降低业务质量经营成本。****整体方案依托软件知识图谱,以一站式质量经营为导向,构建包括实体对象管理、质量能力管理、风险策略管理、风险观测、风险分发处置等通用能力。标准能力支持业务自主拼装、插拔式使用,实现风险从认知到闭环的全流程管理。支持各种质量角色的参与,协同以达到持续提升质量经营水平的目的。

下面是RMS提供的部分核心能力展示,目前RMS接入实体106万,覆盖实体类型115类,建设能力项394个,累计发现风险16万+,并完成了91.46%的风险闭环,直接支撑业务风险前置挖掘召回和闭环。

image.png 基于多实体关系的大事件运营

image.png

风险智能闭环工作台

3.2 ROS  Agent(AI做报警管理)

监控报警建设核心要解决报警完备性、有效性两个问题,即一旦异常发生时,需覆盖全位置、全指标异常并有效感知,同时对异常引发的多维、重复、关联报警进行降噪,减少报警信号的流转干扰。

为此,ROS重点构建了报警自主生成&运维报警智能降噪能力来解决报警完备性和有效性问题。本文从通用逻辑阐述 AI 监控管理方案。

图片

为达到完备和有效的目标,需重点解决以下四大问题:

(1)如何做到完备的覆盖:构建完备的系统与业务知识,抽象所有监控对象并构建不同监控对象关系,结合监控基础知识与大模型,生成完善的监控覆盖方案,其中需要重点关注业务监控基础知识差异,同时使用影响范围、对象分层等作为输入进行方案构建。

(2)如何做到监控项智能生成:依据监控对象、关系、基础知识、数据 / 业务特征及经验,生成含监控对象、策略、关联参数、通知方式等的多维度复杂监控项参数,这里结合时序模型、大模型来综合判断,最终结合监控平台能力完成监控项的生成;监控生成分为完全自主生成(适用于场景明确、准确度高的场景)与协同式生成(需人工确认,用于初始阶段或准确度不足时),两种方式适合于不同成熟度的场景使用。

(3)如何做到异常智能识别:通过规则、时序模型、大模型、动态阈值等机制,判断数据或用例结果是否为问题,不同的监控平台、监控对象、数据特征、业务特征适合不同类型的异常检测策略。

(4)如何进行智能降噪:分析单个报警 、关联报警、多个报警的异常特征、关系及盯盘情况来综合判断是否需要进行报警通知,并结合风险程度、影响范围、时效性等解决无效打扰、报警淹没等问题,平衡质效。

下面是典型的业务&监控平台提供的能力示例如下,通过上述关键问题的解决,结合底层完备/准确的知识构建和场景化的应用产品,监控召回率保持90%+,报警生成比例78%,部分业务监控降噪比例已达到60%。

image.png

报警生成示例

image.png

切流导致的报警降噪(绿色点不通知)示例

3.3 RCS  Agent(AI值班人)

风险控制系统主要解决报警后跟进及时性、排查准确性与效率问题,通过快速找到有效止损线索并止损缩小影响,将问题损失控制在最小范围,会面临以下几个关键问题:

(1)匹配最优跟进人 / 方案:如何结合问题影响面、跟进代价与时效性,明确 AI 或真人跟进的成本与效果。

(2)提供排查线索与止损预案:如何依据业务经验、变更信息、系统知识、历史故障等,匹配最契合排查链路/工具找到正确的线索并从预案库筛选最优止损方案,实现快速止损。

(3)解决跟进过程信息与人员混乱:针对多角色、多团队参与的线上处置场景,尤其长链路业务信息差问题,需要构建端到端事件管理机制,确保及时找对负责人、同步信息,减少干扰与维护成本。

为了解决上述问题,构建了一套统一的RCS建设方案,可实现基于AI的全方位风险控制能力。

图片

方案中有几个关键部分,展开介绍如下:

(1)问题导诊:报警后快速明确风险影响面、跟进方(AI or 真人),提供智能排查结论,按业务特点构建导诊策略(如影响面、风险对象、业务类别等),实现差异化问题处置通路。

(2)端到端事件管理:搭建事件管理产品,覆盖事件感知、建群、排查、止损、总结、跟踪全生命周期,提供流程管理、信息互通等核心能力,同时完成事件信息的统一中心化存储,实现 MEG 线上事件标准化管理。

(3)AI值班人自主处置(常见于慢损问题):对影响小、暂无需真人介入的问题,AI 通过定位工具调度、对话分析、人员地图等能力,完成初步分析、变更确认、标注等工作,确认是线上问题后再转真人跟进。自主处置AI值班人的目标是自主完成问题处置,所以需要建设完善的定位工具调度、单对单对话、自然语言分析、人员地图能力,并能够实现拟人化的信息确认和自主分析。

(4)AI值班人引导处置(常见于快损问题):快损问题需真人与 AI 协同,AI 以助手身份提供线索推荐、工具推荐、止损操作推荐、事件盯盘等支持,且可动态调整策略(如根据损失预估切换止损方式),触达正确人员快速判断,快损事件的关键目标是快速止损,所以无论是触达效率、有损止损动作选择权衡等均需要以综合损失最小快速止损为目标。

(5)高危事件管控中心:针对业务与系统关联复杂的情况,构建全局管控中心与 MEG 高危事件 AI 值班人,与各业务 AI 值班人协同,实现事件信息、工具、线索互通,避免因信息差延误止损。

通过持续的能力建设和数字化构建,线上问题的智能定位覆盖率和准确率稳步增长,同时为了解决问题损失(等级)和MTTR的耦合关系,构建了基于损失速度分桶的损失控制达标率指标,该达标率同样持续提升至93%。AI值班人开始持续在风险控制过程中发挥作用,AI值班人协助率达到96%,端到端协率完成协助率达到40%。

04 总结&展望

随着RMCS能力的建设,质量结果得到了非常有效的控制(如下图)。

图片

(1)从线上问题数量上看,线上问题总数逐年降低,25年对比22年降低比例超过53%,说明我们具备了将问题前置拦截通过风险呼唤前置解决的能力。

(2)从线上问题等级上看,严重问题数量也在持续降低,说明我们具备了快速问题感知和控制的能力,将高损问题转化为低损问题。

展望

目前质量风险管控已经发展了AI转型的重要时期,已经从使用AI解决工具问题变化为使用面向AI构建知识、产品,AI从辅助人慢慢的开始在更多场景可以替代人,因人的投入限制质量保障工作的限制会逐步被突破,质量风险管控后续也可能会变成人和AI更深度协同分析的局面,AI发挥自我学习、24h oncall、智能化的特长完成绝大部份的风险管控,正式员工发挥知识构建、训练AI并构建符合AI的管控产品,最终协同构建更智能化的风险管控目标。

半年时间使用 Tiptap 开发一个和飞书差不多效果的协同文档 😍😍😍

作者 Moment
2025年12月4日 16:55

写了半年的协同文档终于有点像样了,目前新增数学公式、RAG、续写,划词评论,创建组织和 @ 人等功能。

项目链接

新功能预览

微信图片_20251203164800_7_66.png

微信图片_20251203164911_8_66.png

微信图片_20251203164924_9_66.png

微信图片_20251203165229_10_66.png

微信图片_20251203165403_11_66.png

微信图片_20251203165442_12_66.png

微信图片_20251203165502_13_66.png

记一个前端导出excel受限问题

作者 至简简
2025年12月4日 16:50

问题背景

笔者在处理公司项目时,收到测试同学的反馈,批量导出excel功能只能导出10个excel文件。笔者写了一个测试demo,还原项目中的实际情况

image.png

点击导出全部用户按钮,结果却是这样的

image.png

可以看到,从第19条数据直接跳到50条数据,数据导出不完整。

笔者在网上查了一下,发现同时触发多个 Excel 文件下载,会遇到浏览器下载限制问题:

  • Chrome/Edge: 通常限制 6-10 个并发下载
  • 超过限制: 下载会被阻塞或失败

exportExcelSequentially

为了解决浏览器并发限制,可以防止一次性并发下载,转并发为串行,如下所示

核心实现

// 顺序导出 Excel 文件,避免浏览器下载限制
const exportExcelSequentially = async (files: any[], delayMs = 500) => {
for (let i = 0; i < files.length; i++) {
exportExcel([files[i]]);
if (i < files.length - 1) {
await delay(delayMs);
}
}
};

使用 async 函数,允许在函数内部使用 await 关键字,强迫按照顺序导出 Excel 文件

对比

有问题的代码逻辑

const exportToExcel = async () => {
    setExporting(true);
    setProgress(0);

    for (let i = 0; i < mockUsers.length; i++) {
      const user = mockUsers[i];
      await exportSingleUser(user);
      setProgress(i + 1);
    }

    setExporting(false);
    alert(`导出完成!共导出 ${mockUsers.length} 个 Excel 文件`);
  };

在这里由于是快速并发导出,很容易导致受到浏览器下载限制

优化后的代码

 // 串行导出全部用户,避免浏览器下载限制
  const exportToExcelSequentially = async (delayMs = 500) => {
    setExportingSequential(true);
    setProgress(0);

    for (let i = 0; i < mockUsers.length; i++) {
      const user = mockUsers[i];
      await exportSingleUser(user);
      setProgress(i + 1);
      if (i < mockUsers.length - 1) {
        await delay(delayMs);
      }
    }

    setExportingSequential(false);
    alert(`导出完成!共导出 ${mockUsers.length} 个 Excel 文件`);
  };

利用 delay 方法把并发变成串行同步

uni-app 中配置 UnoCSS

作者 一壶纱
2025年12月4日 16:42

本文档记录在 uni-app 项目中集成 UnoCSS 原子化 CSS 引擎的完整过程。

1. 安装依赖

pnpm add -D unocss @uni-helper/unocss-preset-uni

核心依赖说明:

  • unocss: UnoCSS 核心包
  • @uni-helper/unocss-preset-uni: 专为 uni-app 优化的预设,自动转换 rpx 单位,兼容小程序/App 平台

2. 配置 Vite 插件

vite.config.ts 中引入 UnoCSS 插件:

import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig(async () => {
  const UnoCSS = (await import('unocss/vite')).default; // 动态导入 UnoCSS 插件,兼容 HBuilderX
  return {
    plugins: [
      uni.default(),
      UnoCSS() // 添加 UnoCSS 插件
      // 其他插件...
    ]
  };
});

3. 创建 UnoCSS 配置文件

在项目根目录创建 uno.config.ts

import { presetUni } from '@uni-helper/unocss-preset-uni';
import { defineConfig, presetIcons, transformerDirectives, transformerVariantGroup } from 'unocss';

export default defineConfig({
  presets: [
    presetUni(), // uni-app 预设,必需
    presetIcons({
      scale: 1.2,
      warn: true,
      extraProperties: {
        display: 'inline-block',
        'vertical-align': 'middle'
      }
    })
  ],
  transformers: [
    transformerDirectives(), // 支持 @apply 等指令
    transformerVariantGroup() // 支持变体组简写,如 hover:(bg-gray-400 font-medium)
  ]
});

配置说明:

  • presetUni(): 自动处理 rpx/px 单位转换,支持小程序/App 平台特性
  • presetIcons(): 可选,支持图标集(需额外安装 @iconify-json/* 包)
  • transformerDirectives(): 支持 Tailwind 风格的 @apply 指令
  • transformerVariantGroup(): 简化变体书写,如 hover:(text-red bg-blue)

4. 引入 UnoCSS 样式

方式一:在 main.ts 中引入(推荐 CLI 编译)

import { createSSRApp } from 'vue';
import App from './App.vue';
import 'uno.css'; // 引入 UnoCSS 生成的样式

export function createApp() {
  const app = createSSRApp(App);
  return { app };
}

平台差异说明:

  • H5: 方式一
  • 小程序: 推荐方式一(main.ts 引入)
  • App(Android/iOS):
    • CLI 编译:方式一
    • HBuilderX 编译:方式二,将样式放在 App.vue 中以确保注入到 view 层

5. 使用 UnoCSS

配置完成后即可在模板中使用工具类:

<template>
  <view class="flex justify-center items-center h-screen">
    <view class="bg-blue-500 text-white p-4 rounded-lg"> Hello UnoCSS in uni-app! </view>
  </view>
</template>

6. 常见问题

6.1 样式在 App 端不生效

原因: uni-app 的 App 端分为 service 层和 view 层,main.ts 的引入只在 service 层生效。

解决方案:

  1. import 'uno.css' 移至 App.vue<style>
  2. 或使用内联样式/自定义组件包裹

6.2 HBuilderX 编译报错 "Cannot find module 'uno.css'"

原因: HBuilderX 不支持 virtual:uno.css 虚拟模块。

解决方案:

// 使用普通导入,不要使用 virtual: 前缀
import 'uno.css';

6.3 单位不是 rpx

原因: 未使用 presetUni() 预设。

解决方案: 确保 uno.config.ts 中包含:

presets: [presetUni()];

6.4 动态类名不生效

原因: UnoCSS 静态扫描无法识别动态拼接的类名。

错误示例:

<view :class="`text-${color}-500`"></view>

正确做法:

<view :class="color === 'red' ? 'text-red-500' : 'text-blue-500'"></view>

或使用 safelist 配置:

// uno.config.ts
export default defineConfig({
  safelist: ['text-red-500', 'text-blue-500']
  // ...
});

7. 开发体验优化

7.1 VSCode 插件

安装 UnoCSS 插件获得:

  • 类名自动补全
  • 类名悬停预览
  • 类名颜色高亮

7.2 UnoCSS Inspector

开发模式下访问 http://localhost:5173/__unocss 查看:

  • 当前生成的所有样式
  • 使用的工具类统计
  • 样式文件大小

7.3 配置别名

uno.config.ts 中自定义简写:

export default defineConfig({
  shortcuts: {
    btn: 'px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600',
    card: 'bg-white rounded-lg shadow p-4'
  }
});

使用:

<view class="btn">按钮</view>
<view class="card">卡片</view>

8. 性能优势

相比传统 CSS 方案,UnoCSS 在 uni-app 中的优势:

  1. 按需生成:只生成使用到的样式,最终包体积更小
  2. 零运行时:编译时生成,无运行时开销
  3. 统一规范:团队成员使用相同工具类,样式一致性更好
  4. 跨平台兼容:自动处理不同平台的单位差异(rpx/px)

9. 参考资源

别被 JS 骗了!终极指南:JS 类型转换真相大揭秘

2025年12月3日 16:16

== VS ===

在讲解之前我们先看一个有意思的东西,=====,你说这俩有啥区别吗,不都是一样的用吗? a == 1a === 1难道结果还不一样吗?很多情况下确实是没啥区别,但是你觉得官方会闲的没事打造两个作用一模一样的东西吗?

看个例子:

console.log(1 == '1');
console.log(1 === '1');

结果:

image.png

什么?怎么打印出来的结果不一样?这就是大名鼎鼎的类型转换

前言

在 JavaScript 的世界里,类型转换就像个 “隐形工匠”,时而帮我们简化代码,时而悄悄挖下陷阱。

先来一道面试题:

[] == ![];

如果你回答false,那么可以下一个问题了,因为答案是true。不是这玩意会是true?你别说还真是。这背后,就是 JS 里让人又爱又恨的隐式类型转换—— 它像个魔术师,悄咪咪把变量 “变” 成另一种类型,稍不注意就会 “翻车”~ 带着这个问题,我们开始今天的学习。

一、先搞懂:什么是 “类型转换”?

JS 里变量分两大阵营

  • 原始值:简单纯粹的基础类型(数字、字符串、布尔、undefined、null、Symbol、BigInt)
  • 引用类型:复杂的对象类型(对象{}、数组[]、函数等,本质都是对象)

但有时候运算或者判断需要 “统一类型”,这时候就会触发类型转换

  • 显式转换:你主动喊它变(比如Number(str)String(num)
  • 隐式转换:JS 偷偷帮你变(比如1 == '1'里,字符串'1'被偷偷转成了数字)

二、原始值转原始值 —— 基础类型的 “内部变身”

原始值之间的转换,就像同一个班级里同学换座位,规则简单直接,没啥别的套路,全靠 3 个 “魔法公式”:

1. 转数字:ToNumber(x)(对应显式Number(x)

把任意原始值变成数字:

// 数字→数字:不变
console.log(Number(123));  // 123

// 布尔→数字:true=1,false=0
console.log(Number(true));  // 1
console.log(Number(false)); // 0

// 字符串→数字:纯数字字符串转对应数字,非数字字符串转NaN,空字符串是特例
console.log(Number('456'));   // 456
console.log(Number('hello')); // NaN(非数字字符串直接翻车)
console.log(Number(''));      // 0(空字符串是特例)

// undefined→NaN,null→0(记住这两个特殊规则)
console.log(Number(undefined)); // NaN
console.log(Number(null));      // 0

image.png

2. 转字符串:ToString(x)(对应显式String(x)

把原始值变成字符串,就是 “给值加引号” 的简单操作:

// 数字→字符串:直接套引号
console.log(String(789)); // 输出:"789"

// 布尔→字符串:true→"true",false→"false"
console.log(String(true));  // 输出:"true"
console.log(String(false)); // 输出:"false"

// undefined/null→固定字符串
console.log(String(undefined)); // 输出:"undefined"
console.log(String(null));      // 输出:"null"

image.png

3. 转布尔:ToBoolean(x)(对应显式Boolean(x)

原始值转布尔只有 “两极”6 个假值false,其余全是true

// 假值清单(记死!)
console.log(Boolean(0));   // false
console.log(Boolean(''));  // false
console.log(Boolean(NaN)); // false
console.log(Boolean(undefined)); // false
console.log(Boolean(null));  // false
console.log(Boolean(false)); // false

// 其他全是真值
console.log(Boolean(1));     // true
console.log(Boolean(' '));   // true(空格字符串不是空字符串)
console.log(Boolean('abc')); // true

image.png

具体可看官方网页:es5.github.io/#x9.3

三、引用类型转原始值 ——V8 引擎的 “暗箱操作”

引用类型(对象、数组等)不能直接参与运算或者判断,V8 引擎会自发执行一套 “转换流程”(ToPrimitive方法),把它变成原始值再处理。

关键规则:转换目标决定调用顺序(转数字和转字符串的流程不一样!),最终都要通过valueOf()toString()获取原始值,失败就报错。

1. 引用类型转布尔:最简单的 “一刀切”

所有引用类型转布尔,不管内部是什么内容,全是 true

// 空对象转布尔
console.log(Boolean({})); // true

// 空数组转布尔
console.log(Boolean([])); // true

// 有属性的对象转布尔
console.log(Boolean({ name: '张三' })); // true

// 有元素的数组转布尔
console.log(Boolean([1, 2, 3])); // true

image.png

2. 引用类型转数字:ToNumber(ToPrimitive(x, Number))

转换流程(重点记顺序!):

  1. 先调用引用类型的valueOf()方法,若返回原始值,直接用这个值转数字;
  2. valueOf()返回的还是引用类型(比如对象本身),再调用toString()方法,用返回的字符串转数字;
  3. toString()也没返回原始值,直接报错(一般很少见)。

我用三个案例来深度解析一下:

案例1

// 案例1:数组[]转数字
const arr = [];
// 第一步:arr.valueOf() → 返回数组本身([],还是引用类型)
// 第二步:调用arr.toString() → 空数组toString()返回""(空字符串,原始值)
// 第三步:ToNumber("") → 0
// 最终结果:[]转数字是0
console.log(Number([])); // 0
console.log([] == 0); // true(因为[]转数字0,和右边0相等)

image.png

看完这个例子你是不是觉得很熟悉,没错这不就是我开头抛出的问题吗?

面试题

[] == ![];   // []转布尔→true
[] == !true; // !true→false
[] == false; // false转数字→0
[] == 0;     // []转字符串''
'' == 0;     // ''转数字→0,0==0
0 == 0;      // 类型+值均相等,直接匹配

image.png

现在面试官的这个问题已经难不倒你了!

案例2

// 案例2:数组[1,2]转数字
const arr2 = [1,2];
// arr2.valueOf() → [1,2](引用类型)
// arr2.toString() → "1,2"(字符串)
// ToNumber("1,2") → NaN(非纯数字字符串)
console.log(Number([1,2])); // NaN

image.png

案例3

// 案例3:对象{}转数字
const obj = {};
// obj.valueOf() → {}(引用类型)
// obj.toString() → "[object Object]"(字符串)
// ToNumber("[object Object]") → NaN
console.log(Number({})); // NaN

image.png

3. 引用类型转字符串:ToString(ToPrimitive(x, String))

转换流程(和转数字相反,先调toString()):

  1. 先调用引用类型的toString()方法,若返回原始值,直接用这个值作为最终字符串;
  2. toString()返回引用类型,再调用valueOf()方法,用返回的原始值转字符串;
  3. 若还是没拿到原始值,报错。

我依旧用三个案例来深度解析:

案例1

// 案例 1:数组[1,2]转字符串
const arr3 = [1,2];
// 第一步:arr3.toString() → "1,2"(原始值)
// 直接返回这个字符串,不用走valueOf()
console.log(String([1,2])); // "1,2"
console.log([1,2] + "3"); // "1,23"(转字符串后拼接)

image.png

案例2

// 案例2:对象{}转字符串
const obj2 = {};
// 第一步:obj2.toString() → "[object Object]"(原始值)
// 直接作为结果
console.log(String({})); // "[object Object]"
console.log({} + [1,2]); // "[object Object]1,2"(先转字符串再拼接)

image.png

案例3

// 案例3:自定义对象的转换(更直观)
const user = {
    name: 'henry',
    // 重写toString(),让它返回自定义字符串
    toString() {
        return `我是${this.name}`;
    },
    // 重写valueOf(),看看转字符串时会不会用到(这里不会,因为toString()已经返回原始值)
    valueOf() {
        return { age: 20 };
    }
};
console.log(String(user)); // "我是henry"(直接用toString()的结果)

image.png

四、“魔术现场”:隐式转换什么时候触发?

知道了两大核心转换场景,再看隐式转换的触发时机,就一目了然了!

场景 1:==判断 —— 跨类型比较必触发

==不会直接比较,而是先把两边转成同一类型(优先转数字),再比较。结合前面的转换规则。我举的面试题就是最好的例子。

场景 2:+运算 —— 加法还是拼接,看转换结果

+是 “两面派”,规则如下:

  1. 作为一元运算符(比如+x):直接调用ToNumber(x)(原始值转数字,引用类型按转数字流程来);

  2. 作为二元运算符(a + b):

    • 先把 a 和 b 都转成原始值(ToPrimitive(a)ToPrimitive(b));
    • 只要有一个原始值是字符串,就按字符串拼接来;
    • 否则,两边都转数字做加法。
// 案例 1:一元运算符 +[]
// []转数字是0,所以+a→0
console.log(+[]); // 0

// 案例 2:[1,2,3] + '1'
// [1,2,3]转原始值(按String规则)→"1,2,3"(字符串)
// 有字符串,所以拼接→"1,2,31"
console.log([1,2,3] + '1'); // "1,2,31"

// 案例 3:{} + 1
// {}转原始值(按Number规则)→toString()返回"[object Object]",转数字→NaN
// NaN + 1 → NaN
console.log({} + 1); // NaN

// 案例4:true + 1
// true转数字→1,所以1+1=2(没有字符串,做加法)
console.log(true + 1); // 2

image.png

场景 3:判断语句(if/while)—— 全转布尔

if(xxx)while(xxx)里的条件,会自动把xxx转成布尔值(原始值按ToBoolean,引用类型转布尔全是 true):

// 案例 1:if({}) → 对象转布尔true,执行代码
if({}) {
  console.log('对象转布尔是true'); // 会执行
}

// 案例 2:if([]) → 数组转布尔true,执行代码
if([]) {
  console.log('空数组转布尔也是true'); // 会执行
}

// 案例 3:if('') → 空字符串转布尔false,不执行
if('') {
  console.log('这句话不会输出');
}

image.png

其实还有很多,总的来说可分为两大类:

  • 四则运算+ - * / %
  • 判断语句if while == >= <= > < !=

五、toString () 方法:JS 里的 “格式转换器”

前面我们一直用到了toString () 方法,那么不同类型的返回值是不一样的:

  • 对象 {}:调用 toString() 固定返回 '[object Object]',例如 ({}).toString() → '[object Object]'(加括号避免被解析为代码块)。

  • 数组 []:返回内部元素用逗号拼接的字符串,例如 [1, 'a', true].toString() → '1,a,true',空数组 [].toString() → ''

  • 原始值(数字、布尔、字符串等):直接将值用引号包裹,例如 123.toString() → '123'、true.toString() → 'true'、'hello'.toString() → 'hello'

六、避坑小技巧:别让 “魔术” 坑了你

  1. 优先用===代替=====不会做任何隐式转换,必须 “类型 + 值” 都相等才返回 true,比如1 === '1' → false,避免踩坑;
  2. 引用类型参与运算前,主动显式转换:比如想把数组转数字,直接写Number([1,2].join('')),而不是靠隐式转换;
  3. 记住核心转换规则:引用类型转数字 “先 valueOf 后 toString”,转字符串 “先 toString 后 valueOf”,转布尔全是 true;
  4. 警惕NaN:引用类型转数字很容易得到NaNNaN和任何值比较都是 false(包括NaN === NaN → false)。

结语

总结一下:JS 的类型转换看似复杂,其实核心就是 “原始值转原始值看 3 大规则,引用类型转原始值看 ToPrimitive 流程”。记住这些逻辑,再遇到[] == ![]{} + [1,2]这类 “离谱” 代码,就能轻松拆解开背后的 “魔术套路” 啦!

具体规则可看:es5.github.io/

❌
❌