普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月7日首页

从零手写JavaScript继承函数:一场关于"家族传承"的编程之旅

2026年2月7日 17:20

从零手写JavaScript继承函数:一场关于"家族传承"的编程之旅

引言:JavaScript的"与众不同"

在JavaScript的世界里,继承不是简单的复制粘贴,而是一场关于"原型链"的奇妙冒险。想象一下:别的语言继承就像领养孩子,直接给一套新房子和新衣服;而JavaScript的继承更像是家族传承——孩子不仅有自己的家,还能随时去祖辈家里串门拿东西!

今天,就让我们一起揭开JavaScript继承的神秘面纱,亲手打造一个属于自己的"家族传承"系统。

一、原型链继承:直截了当的"家族企业"

让我们先来看看JavaScript中最"朴实"的继承方式。

一个动物王国的故事

假设我们有一个Animal(动物)家族:

function Animal(name, age) {
    this.name = name;   // 名字
    this.age = age;     // 年龄
}
Animal.prototype.species = '动物';  // 所有动物都有的物种属性

现在,Cat(猫)家族想要继承Animal家族的优良传统。最简单的做法是什么?

方法一:直接"认祖归宗"

function Cat(name, age, color) {
    // 先把Animal家族的基本功学过来
    Animal.call(this, name, age);
    this.color = color;  // 猫特有的毛色
}

// 关键一步:成为Animal家族的"亲传弟子"
Cat.prototype = new Animal();
// 但别忘了改个名,不然别人还以为你是Animal
Cat.prototype.constructor = Cat;

const garfield = new Cat('加菲猫', 2, '黄色');
console.log(garfield.species);  // ✅ 输出:动物(成功继承了物种!)

这里发生了什么?

  • Cat.prototype = new Animal():相当于Cat家族把Animal请来当顾问
  • 现在所有Cat都可以通过"顾问"访问Animal家族的资源

但这种做法有个大问题...

场景想象:你想请Animal当顾问,结果人家拖家带口、把全部家当都搬来了!new Animal()创建了一个完整的Animal实例,但我们需要的仅仅是Animal的"知识库"(原型),而不是它的全部身家。

三大痛点

  1. 浪费内存:Animal实例可能很大,但Cat只需要它的原型
  2. 参数尴尬new Animal()时需要参数,但作为原型时不知道传什么
  3. 效率低下:每次继承都要创建一个可能永远用不着的实例

二、走捷径的诱惑:直接"共享家谱"

有人可能想:"既然只是要原型,那直接共享不就行了?"

// 看似聪明的偷懒方法
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

危险!这是个陷阱!

// 猫家族想给自己加个技能
Cat.prototype.eatFish = function() {
    console.log('我爱吃鱼!');
};

// 但意外发生了...
const dog = new Animal('旺财', 3);
dog.eatFish();  // 😱 输出:我爱吃鱼!(狗怎么爱吃鱼了?!)

问题所在

  • Cat.prototypeAnimal.prototype指向同一个对象
  • 给Cat添加方法,Animal也会"被学会"
  • 就像两个部门共用同一个印章,一方修改,另一方遭殃

三、终极方案:聪明的"中间人"策略

我们需要一个既能继承知识,又不造成混乱的方法。这就是我们的"空函数中介"模式——一个聪明的"传话筒"。

手写extends函数:打造完美的家族传承

function extend(Parent, Child) {
    // 1. 请一个"中间人"(空函数F)
    // 它就像家族间的专业翻译,只传话,不添乱
    var F = function() {};
    
    // 2. 让中间人学习Parent的知识库
    F.prototype = Parent.prototype;
    
    // 3. 让Child拜中间人为师
    Child.prototype = new F();
    
    // 4. 给Child正名:你姓Child,不是Parent
    Child.prototype.constructor = Child;
}

来看看这个精妙的传承系统如何工作

// 使用我们的extend函数
function Cat(name, age, color) {
    // 继承Animal的"个人能力"
    Animal.apply(this, [name, age]);
    this.color = color;  // 猫的独有特征
}

// 启动传承仪式!
extend(Animal, Cat);

// 猫家族发展自己的特色
Cat.prototype.purr = function() {
    console.log('喵呜~发出呼噜声');
};

// 见证奇迹的时刻
const kitty = new Cat('小橘', 1, '橘色');
console.log(kitty.species);  // ✅ "动物"(继承了Animal的物种)
kitty.purr();               // ✅ "喵呜~发出呼噜声"(猫的独有技能)

const bird = new Animal('小鸟', 0.5);
console.log(bird.purr);     // ✅ undefined(完全没影响到Animal!)

为什么这个方案如此优雅?

三层隔离保护

  1. 第一层:Cat有自己的原型对象
  2. 第二层:通过中间人F访问Animal的原型
  3. 第三层:对Cat原型的修改完全不影响Animal

内存关系图

kitty(猫实例)
    ↓ "我可以找我的家族要东西"
Cat.prototype(猫家族知识库)
    ↓ "我学自中间人F"
F.prototype(= Animal.prototype)
    ↓ "我来自Animal家族"
Animal.prototype(动物家族知识库)
    ↓ "我是所有对象的起点"
Object.prototype

四、完整实战:打造动物世界的继承体系

让我们把理论变成实战代码:

// 增强版extend:更智能的传承系统
function extend(Child, Parent) {
    // 1. 请专业中间人(开销极小)
    var F = function() {};
    
    // 2. 中间人学习Parent的全部知识
    F.prototype = Parent.prototype;
    
    // 3. Child拜师学艺
    Child.prototype = new F();
    Child.prototype.constructor = Child;
    
    // 4. 给Child一个"家谱"(可选但很贴心)
    Child.uber = Parent.prototype;
    
    // 5. 现代JavaScript的额外支持
    if (Object.setPrototypeOf) {
        Object.setPrototypeOf(Child.prototype, Parent.prototype);
    }
}

// 动物家族基类
function Animal(name, age) {
    this.name = name;
    this.age = age;
}
Animal.prototype.breathe = function() {
    return '我在呼吸新鲜空气';
};

// 猫家族
function Cat(name, age, color) {
    // 先学Animal的"生存技能"
    Animal.call(this, name, age);
    this.color = color;
}

// 启动传承
extend(Cat, Animal);

// 猫家族的独门绝技
Cat.prototype.climbTree = function() {
    return '我能爬上最高的树!';
};

// 看看成果
const tom = new Cat('汤姆', 3, '蓝灰色');
console.log(tom.breathe());    // ✅ "我在呼吸新鲜空气"
console.log(tom.climbTree());  // ✅ "我能爬上最高的树!"
console.log(tom.color);        // ✅ "蓝灰色"

五、现代JavaScript:语法糖背后的真相

ES6给了我们更优雅的写法:

class Animal {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    breathe() {
        return '我在呼吸新鲜空气';
    }
}

class Cat extends Animal {
    constructor(name, age, color) {
        super(name, age);  // 这行相当于 Animal.call(this, name, age)
        this.color = color;
    }
    
    climbTree() {
        return '我能爬上最高的树!';
    }
}

重要提醒class只是"语法糖",底层依然是我们的原型继承。理解原型,才能真正掌握JavaScript的继承精髓。

总结:继承的智慧

通过这次探索,我们学到了:

  1. 原型实例化继承 → 简单粗暴但笨重(请整个家族当顾问)
  2. 直接原型继承 → 危险捷径(共用家谱,一损俱损)
  3. 空函数中介模式 → 优雅方案(专业中间人,隔离又高效)

编程就像家族传承

  • 好的继承应该像家训传承:后代学习前辈的智慧,但有自己的发展
  • 坏的继承就像财产纠纷:边界不清,互相影响
  • 我们的extend函数就像是找到了完美的家族信托方案

进阶思考

如果你要继续优化这个extend函数,你会添加哪些功能?

  1. 多重继承:像继承多个家族的优秀基因?
  2. 方法混入:像选择性学习不同师父的绝招?
  3. 静态方法继承:连家族的传统仪式也一起继承?

动手挑战:尝试实现一个支持多重继承的extend函数,让一个类可以同时继承多个父类的特性。把你的代码分享到评论区,看看谁的实现最优雅!

记住:在JavaScript的世界里,理解原型链就像掌握家族的秘密通道。通过这些通道,你可以在不破坏原有结构的前提下,构建出强大而灵活的代码"家族"。现在,你也是掌握这个秘密的开发者了!

深入浅出:手写 new 操作符,彻底理解 JavaScript 的实例化过程

2026年2月7日 17:09

深入浅出:手写 new 操作符,彻底理解 JavaScript 的实例化过程

引言

在 JavaScript 中,new 操作符是我们创建对象实例最常用的方式之一。但你真的了解 new 背后发生了什么吗?今天我们就来深入探讨一下 new 的奥秘,并亲手实现一个自己的 new 函数。

在解释手写new函数的之前,我们先解释一些知识点方便我们后面理解手写new的过程

一、构造函数被实例化的完整过程

什么是构造函数?

构造函数其实就是一个普通的函数,但当我们使用 new 关键字调用它时,它就变成了一个"构造函数"。

function Person(name, age) {
    this.name = name;
    this.age = age;
}

// 作为普通函数调用
Person('张三', 18);  // this 指向全局对象(浏览器中是 window)

// 作为构造函数调用
const person = new Person('张三', 18);  // this 指向新创建的对象

new 实例化的完整步骤

比喻:想象一下工厂生产产品的过程:

  1. 准备原材料(创建空对象)
  2. 按照设计图纸加工(调用构造函数)
  3. 贴上品牌标签(设置原型链)
  4. 出厂检验(返回对象)

具体来说,new 操作符执行以下4个步骤:

步骤1:创建一个空对象
const obj = {};
步骤2:将新对象的 __proto__ 指向构造函数的 prototype
obj.__proto__ = Constructor.prototype;
步骤3:将构造函数的 this 绑定到这个新对象,并执行构造函数
Constructor.apply(obj, args);
步骤4:如果构造函数返回了一个对象,则返回该对象;否则返回新创建的对象
function Person(name) {
    this.name = name;
    // 如果没有显式返回,默认返回 this
}

function Person2(name) {
    this.name = name;
    return { custom: 'object' };  // 如果返回对象,则替代新创建的对象
}

const p1 = new Person('张三');  // Person {name: "张三"}
const p2 = new Person2('李四'); // {custom: "object"}

二、apply、call 和 bind 的区别

这三个方法都用于改变函数执行时的 this 指向,但使用方式略有不同。

比喻说明

想象你是一家公司的CEO(函数),你需要给员工(对象)下达指令:

  • call:直接告诉某个员工该做什么
  • apply:告诉某个员工该做什么,并给他一袋资料(数组参数)
  • bind:预先告诉员工,将来某个时间点需要做什么

1. call 方法

function introduce(greeting, punctuation) {
    console.log(`${greeting}, 我是${this.name}${punctuation}`);
}

const person = { name: '张三' };

// call 接受参数列表
introduce.call(person, '你好', '!');  // "你好, 我是张三!"

2. apply 方法

// apply 接受参数数组
introduce.apply(person, ['你好', '!']);  // "你好, 我是张三!"

3. bind 方法

// bind 返回一个新函数,而不是立即执行
const boundIntroduce = introduce.bind(person, '你好');
boundIntroduce('!');  // "你好, 我是张三!"

总结对比

方法 立即执行 参数形式 返回值
call 参数列表 函数执行结果
apply 数组 函数执行结果
bind 参数列表 新函数

三、arguments 对象详解

什么是 arguments?

arguments 是函数内部的一个特殊对象,它包含了函数调用时传入的所有参数。

function showArgs() {
    console.log(arguments);
    console.log(arguments.length);
    console.log(arguments[0]);
}

showArgs(1, 2, 3);
// 输出:
// Arguments(3) [1, 2, 3]
// 3
// 1

arguments 的特点

1. 类数组对象(Array-like Object)

arguments 看起来像数组,但不是真正的数组:

function checkArguments() {
    console.log('长度:', arguments.length);
    console.log('可索引:', arguments[0], arguments[1]);
    console.log('是数组吗?', Array.isArray(arguments));  // false
    console.log('类型:', Object.prototype.toString.call(arguments)); // [object Arguments]
}

checkArguments('a', 'b', 'c');
2. 不能使用数组的方法
function tryArrayMethods() {
    // 这些会报错
    // arguments.map(item => item * 2);  // ❌ 错误
    // arguments.reduce((sum, num) => sum + num);  // ❌ 错误
    
    // 但可以这样遍历
    for (let i = 0; i < arguments.length; i++) {
        console.log(arguments[i]);
    }
    
    // 或者用 for...of(ES6+)
    for (const arg of arguments) {
        console.log(arg);
    }
}

如何将 arguments 转为真正的数组?

方法1:Array.from (ES6)
function convertArguments1() {
    const argsArray = Array.from(arguments);
    console.log(Array.isArray(argsArray));  // true
    console.log(argsArray.map(x => x * 2));  // 可以正常使用数组方法
}
方法2:扩展运算符 (ES6)
function convertArguments2(...args) {  // 直接在参数中使用
    console.log(Array.isArray(args));  // true
}

function convertArguments3() {
    const argsArray = [...arguments];
    console.log(Array.isArray(argsArray));  // true
}
方法3:Array.prototype.slice.call (ES5)
function convertArguments4() {
    const argsArray = Array.prototype.slice.call(arguments);
    console.log(Array.isArray(argsArray));  // true
}

arguments 的注意事项

  1. 箭头函数没有 arguments
const arrowFunc = () => {
    console.log(arguments);  // ❌ 报错:arguments is not defined
};

// 箭头函数应该这样获取参数
const arrowFunc2 = (...args) => {
    console.log(args);  // ✅ 正确
};
  1. arguments 和参数变量联动(非严格模式)
function linkedArguments(a, b) {
    console.log('a:', a, 'arguments[0]:', arguments[0]);
    
    a = 'changed';
    console.log('修改后 a:', a, 'arguments[0]:', arguments[0]);
    
    arguments[0] = 'changed again';
    console.log('再次修改后 a:', a, 'arguments[0]:', arguments[0]);
}

linkedArguments('original', 2);
// 输出:
// a: original arguments[0]: original
// 修改后 a: changed arguments[0]: changed
// 再次修改后 a: changed again arguments[0]: changed again

四、开始手写实现 new 操作符

现在,让我们结合以上知识点,一步步实现自己的 new 函数。

基础版本实现

function objectFactory(Constructor, ...args) {
    // 1. 创建一个空对象
    const obj = {};
    
    // 2. 将新对象的原型指向构造函数的原型
    obj.__proto__ = Constructor.prototype;
    
    // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
    Constructor.apply(obj, args);
    
    // 4. 返回新对象
    return obj;
}

增强版本(处理构造函数返回值)

function objectFactory(Constructor, ...args) {
    // 1. 创建新对象,并设置原型链
    const obj = Object.create(Constructor.prototype);
    
    // 2. 执行构造函数,绑定 this
    const result = Constructor.apply(obj, args);
    
    // 3. 判断构造函数返回的是否是对象
    // 如果是对象则返回该对象,否则返回新创建的对象
    return typeof result === 'object' && result !== null ? result : obj;
}

完整实现(兼容 ES5)

function objectFactory() {
    // 1. 获取构造函数(第一个参数)
    const Constructor = [].shift.call(arguments);
    
    // 2. 创建空对象,并继承构造函数的原型
    const obj = Object.create(Constructor.prototype);
    
    // 3. 执行构造函数,将 this 指向新对象
    const result = Constructor.apply(obj, arguments);
    
    // 4. 返回结果
    return typeof result === 'object' ? result : obj;
}

使用示例

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.sayHello = function() {
    console.log(`你好,我是${this.name},今年${this.age}岁`);
};

// 使用原生的 new
const person1 = new Person('张三', 18);
person1.sayHello();  // "你好,我是张三,今年18岁"

// 使用我们手写的 objectFactory
const person2 = objectFactory(Person, '李四', 20);
person2.sayHello();  // "你好,我是李四,今年20岁"

console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Person);  // true
console.log(person1.sayHello === person2.sayHello);  // true(共享原型方法)

处理特殊情况

// 1. 构造函数返回对象的情况
function Car(model) {
    this.model = model;
    return { custom: 'special object' };  // 返回对象
}

const car = objectFactory(Car, 'Tesla');
console.log(car);  // {custom: "special object"},而不是 Car 实例

// 2. 构造函数返回基本类型的情况
function Bike(brand) {
    this.brand = brand;
    return 'not an object';  // 返回基本类型,会被忽略
}

const bike = objectFactory(Bike, 'Giant');
console.log(bike);  // Bike {brand: "Giant"},返回新创建的对象

五、实际应用场景

1. 库或框架中的使用

许多库(如早期的 jQuery)会使用类似的技术来创建对象,避免使用 new 关键字:

// jQuery 风格的初始化
function $(selector) {
    return new jQuery(selector);
}

// 或者
function $(selector) {
    return objectFactory(jQuery, selector);
}

2. 创建对象池

function createObjectPool(Constructor, count) {
    const pool = [];
    
    for (let i = 0; i < count; i++) {
        pool.push(objectFactory(Constructor));
    }
    
    return pool;
}

// 创建 10 个默认的 Person 对象
const personPool = createObjectPool(Person, 10);

3. 实现单例模式

function singleton(Constructor, ...args) {
    let instance = null;
    
    return function() {
        if (!instance) {
            instance = objectFactory(Constructor, ...args);
        }
        return instance;
    };
}

const getSingletonPerson = singleton(Person, '单例', 100);
const p1 = getSingletonPerson();
const p2 = getSingletonPerson();
console.log(p1 === p2);  // true

总结

通过手写 new 操作符,我们深入理解了 JavaScript 对象实例化的过程:

  1. 创建空对象:建立对象的"肉身"
  2. 设置原型链:连接对象的"灵魂"(继承)
  3. 执行构造函数:赋予对象"个性"(属性)
  4. 返回对象:决定最终"出厂"的是什么

理解这些底层机制,不仅可以帮助我们更好地使用 JavaScript,还能在面试中脱颖而出。更重要的是,这种"知其然知其所以然"的学习方式,能够让我们在面对复杂问题时,有能力从底层原理出发,找到最优雅的解决方案。

记住,每个看似简单的 new 背后,都隐藏着 JavaScript 原型链、this 绑定、函数执行等多个核心概念的完美协作。掌握了这些,你就真正理解了 JavaScript 面向对象编程的精髓。


实战解密:我是如何用Vue 3 + Buffer实现AI“打字机”效果的

2026年2月7日 16:57

实战解密:我是如何用Vue 3 + Buffer实现AI“打字机”效果的

从一行代码到一个完整AI聊天应用

最近我在做一个AI聊天应用时,遇到了一个关键问题:如何让AI的回复像真人打字一样,一个字一个字地出现?  经过一番探索,我发现了流式输出 + Buffer的组合方案。今天,我就用我的实际代码,带你彻底搞懂这个技术!

这个应用是做什么的?

想象你有一个 智能聊天机器人🤖:

  1. 你输入一个问题(比如:"讲一个笑话")
  2. 点击"提交"按钮
  3. 机器人开始思考并回复你
  4. 回复可以一个字一个字出现(流式模式),或者一下子全部出现

第一部分:理解 Vue 3 的基础

1.1 什么是响应式数据?

生活例子📺: 想象你家电视的遥控器:

  • 按"音量+" → 电视音量变大
  • 按"频道+" → 电视换台

这里的 响应式 就是:按遥控器(改变数据),电视立即响应(页面更新)。

// 创建响应式数据就像给数据装上"遥控器"
const question = ref('你好');  // 创建一个能"遥控"的数据

// 在模板中显示
<div>{{ question }}</div>  <!-- 显示:你好 -->

// 如果改变数据
question.value = 'Hello';   // 按下"遥控器"

// 页面自动变成
<div>Hello</div>            <!-- 页面自动更新! -->

1.2 ref 是什么?

ref 就是把普通数据包装成一个特殊的盒子📦:

// 普通数据
let name = "小明";  
// 改变时,Vue不知道,页面不会更新

// 响应式数据
const nameRef = ref("小明");
// 实际上变成了:{ value: "小明" }

// 访问时要加 .value
console.log(nameRef.value);  // "小明"

// 改变数据
nameRef.value = "小红";      // Vue 知道数据变了,会更新页面

第二部分:模板语法

2.1 v-model - 双向绑定

双向绑定 就像 同步的记事本📝:

<!-- 创建一个输入框 -->
<input v-model="question" />

<!-- 这相当于做了两件事:
1. 输入框显示 question 的值
2. 你在输入框打字时,自动更新 question 的值
-->

实际效果:

// 你输入"你好"
question.value = "你好";

// 页面显示
<input value="你好" />

// 你再输入"大家好"
// question.value 自动变成 "大家好"

2.2 @click - 事件监听

就像给按钮装上 门铃🔔:

<button @click="askLLM">提交</button>

<!-- 意思是:点击这个按钮时,执行 askLLM 函数 -->

第三部分:核心功能 - 调用 AI

3.1 基本流程(像点外卖)

const askLLM = async () => {
  // 1. 准备问题(像写菜单)
  if (!question.value) {
    console.log('问题不能为空');
    return;
  }
  
  // 2. 显示"思考中..."(像显示"商家接单中")
  content.value = "思考中...";
  
  // 3. 准备外卖信息
  const endpoint = 'https://api.deepseek.com/chat/completions';  // 外卖平台地址
  const headers = {
    'Authorization': `Bearer ${你的API密钥}`,  // 支付凭证
    'Content-Type': 'application/json',        // 说要送JSON格式
  };
  
  // 4. 下订单
  const response = await fetch(endpoint, {
    method: 'POST',      // 点外卖用POST
    headers,             // 告诉商家信息
    body: JSON.stringify({  // 具体订单内容
      model: 'deepseek-chat',
      stream: stream.value,  // 要不要流式(分批送)
      messages: [{
        role: 'user',
        content: question.value
      }]
    })
  });
  
  // 5. 等外卖送到并处理
  // ... 后面详细讲
}

第四部分:流式响应详细解释

4.1 什么是"流式"?

比喻🎬:

  • 非流式:等电影全部下载完(5GB)才能看
  • 流式:下载一点(10MB)就能开始看,边下载边看

在这个应用中:

  • 非流式:等AI全部生成完文字,一次性显示
  • 流式:AI生成一个字就显示一个字

4.2 流式响应代码详解(逐步讲解)

if (stream.value) {  // 如果用户选了流式模式
  // 第一步:清空上次的回答
  content.value = "";  // 清空显示区域
  
  // 第二步:创建"水管"和"水龙头"
  const reader = response.body?.getReader();  
  // reader 就像水龙头,可以控制水流
  
  const decoder = new TextDecoder();
  // decoder 就像净水器,把脏水(二进制)变成干净水(文字)
  
  let done = false;  // 记录水是否流完了
  let buffer = '';   // 临时水桶,装不完整的水
  
  // 第三步:开始接水(循环读取)
  while (!done) {  // 只要水没流完就一直接
    
    // 接一瓢水(读一块数据)
    const { value, done: doneReading } = await reader?.read();
    // value: 接到的水(二进制数据)
    // doneReading: 这一瓢接完了吗?
    
    done = doneReading;  // 更新是否流完的状态
    
    // 第四步:处理接到的水
    // 把这次的水和上次没处理完的水合在一起
    const chunkValue = buffer + decoder.decode(value);
    buffer = '';  // 清空临时水桶
    
    console.log("收到数据:", chunkValue);
    // 数据格式类似:
    // data: {"delta": {"content": "你"}}
    // data: {"delta": {"content": "好"}}
    // data: [DONE]
    
    // 第五步:把一大块水分成一行一行
    const lines = chunkValue.split('\n')  // 按换行分割
      .filter(line => line.startsWith('data: '));  // 只保留以"data: "开头的行
    
    // 第六步:处理每一行水
    for (const line of lines) {
      const incoming = line.slice(6);  // 去掉开头的"data: "
      // 现在 incoming = '{"delta": {"content": "你"}}'
      
      // 如果是结束标志
      if (incoming === '[DONE]') {
        done = true;  // 停止接水
        break;        // 跳出循环
      }
      
      try {
        // 第七步:解析JSON(把水变成能喝的东西)
        const data = JSON.parse(incoming);
        // data = { delta: { content: "你" } }
        
        const delta = data.choices[0].delta.content;
        // delta = "你"
        
        if (delta) {
          // 第八步:显示出来
          content.value += delta;  // 把"你"加到显示内容里
          // 第一次:content = "你"
          // 第二次:content = "你好"
          // 第三次:content = "你好世"
          // ... 直到完成
        }
      } catch (error) {
        // 如果JSON解析失败(比如收到了不完整的JSON)
        buffer += `data: ${incoming}`;  // 存起来等下一瓢水
      }
    }
  }
}

4.3 为什么需要 buffer

情景模拟: 假设AI要回复"你好世界",但网络传输时可能这样:

第一次收到data: {"delta": {"content": "你 (JSON不完整,少了右括号)

第二次收到好世界"}}

如果直接解析第一次的数据:

JSON.parse('{"delta": {"content": "你');  // 报错!JSON不完整

所以我们需要:

  1. 第一次:buffer = 'data: {"delta": {"content": "你'
  2. 第二次:buffer + 新数据 = 'data: {"delta": {"content": "你好世界"}}'
  3. 现在可以正确解析了

完整工作流程演示

让我用具体的执行过程展示这个系统的精妙:

javascript

// 用户输入:"你好"
// 服务器响应流开始...

// 第1次循环:
收到数据: data: {"delta": {"content": "你"}}\n
分割成行: ['data: {"delta": {"content": "你"}}']
解析成功!→ 显示:"你"

// 第2次循环:
收到数据: data: {"delta": {"content": "好
分割成行: ['data: {"delta": {"content": "好']
JSON解析失败!→ 存入buffer: 'data: {"delta": {"content": "好'

// 第3次循环:
收到数据: "}}\n
当前数据: buffer + 新数据 = 'data: {"delta": {"content": "好"}}'
分割成行: ['data: {"delta": {"content": "好"}}']
解析成功!→ 显示:"你好"

// 第4次循环:
收到数据: data: [DONE]\n
检测到[DONE] → 结束循环

第五部分:完整交互流程

你打开页面
    ↓
看到输入框:[讲一个笑话]
    ↓
点击"提交"
    ↓
Vue调用 askLLM() 函数
    ↓
显示"思考中..."
    ↓
发送请求到DeepSeek
    ↓
AI开始思考
    ↓
【流式模式】
    ↓
收到第一个字:"有"
    ↓
页面显示:有
    ↓
收到第二个字:"个"
    ↓
页面显示:有个
    ↓
收到第三个字:"人"
    ↓
页面显示:有个人
    ↓
...(持续)
    ↓
收到"[DONE]"
    ↓
显示完整:有个人去面试...

第六部分:关键概念总结

概念 比喻 作用
ref() 遥控器📱 让数据变化时页面自动更新
v-model 双向镜子🪞 输入框和数据的双向同步
@click 门铃🔔 点击时执行函数
fetch() 外卖小哥🚴 发送网络请求
getReader() 水龙头🚰 读取流式数据
TextDecoder() 翻译官👨‍💼 把二进制变成文字
JSON.parse() 拆包裹📦 把JSON字符串变成对象

给初学者的建议

  1. 先理解整体:不要一开始就陷入细节
  2. 分块学习
    • 先学会 Vue 基础(ref, v-model)
    • 再学网络请求(fetch)
    • 最后学流式处理
  3. 动手实践:修改代码看看效果
    • stream.value 改成 false 看看区别
    • console.log 里看数据变化
  4. 遇到问题:用 console.log() 打印每一步的结果

这个代码虽然看起来复杂,但每个部分都有明确的作用。就像搭积木一样,每块积木(函数)都有特定的功能,组合起来就实现了强大的AI聊天功能!😊

附录:完整的Vue 3 AI流式输出代码

App.vue 完整代码

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

const question = ref('讲一个光头强和一个白富美之间的故事,20字');
const stream = ref(true);
const content = ref("");

const askLLM = async () => {
  if (!question.value) {
    console.log('question is empty');
    return;
  }
  
  content.value = "思考中...";
  
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json',
  };
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{
        role: 'user',
        content: question.value
      }]
    })
  });
  
  if (stream.value) {
    content.value = "";
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';
    
    while (!done) {
      const { value, done: doneReading } = await reader?.read();
      console.log(value, doneReading);
      done = doneReading;
      
      const chunkValue = buffer + decoder.decode(value);
      console.log(chunkValue);
      buffer = '';
      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data: '));
      
      for (const line of lines) {
        const incoming = line.slice(6);
        if (incoming === '[DONE]') {
          done = true;
          break;
        }
        
        try {
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) {
            content.value += delta;
          }
        } catch (error) {
          buffer += `data: ${incoming}`;
        }
      }
    }
  } else {
    const data = await response.json();
    console.log(data);
    content.value = data.choices[0].message.content;
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
   
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream"/>
        <div>{{content}}</div>
      </div>
    </div>
  </div>  
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>

为什么ChatGPT能"打字"给你看?从Buffer理解AI流式输出

2026年2月7日 16:18

什么是Buffer?

Buffer(缓冲区)是计算机内存中用于临时存储数据的一块区域。想象一下你正在用杯子接水龙头的水:水龙头直接流到杯子里,如果水流太快,杯子可能会溢出。但如果你在中间放一个水壶(缓冲区),水先流到水壶里,再从水壶倒到杯子里,整个过程就更加可控了。

在JavaScript中,Buffer就是那个"水壶"——它帮助我们在处理二进制数据(如图片、音频、网络传输等)时更加高效和可控。

为什么需要Buffer?

1. 文本 vs 二进制

计算机中一切数据最终都以二进制形式存储,但我们在编程时通常处理的是文本(字符串)。当需要处理非文本数据时,就需要Buffer。

生活比喻:就像快递运输,文本数据就像明信片,内容直接可见;二进制数据就像密封的包裹,你需要专门的工具(Buffer)来查看和处理里面的内容。

2. 效率问题

直接操作二进制数据比操作字符串更高效,特别是在处理大量数据时。

HTML5中的Buffer操作

1. TextEncoder 和 TextDecoder

这是HTML5提供的编码/解码工具:

// 编码:将字符串转换为二进制数据
const encoder = new TextEncoder();
const myBuffer = encoder.encode('你好 HTML5');
console.log(myBuffer); // Uint8Array(10) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, ...]

// 解码:将二进制数据转换回字符串
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"

注意:中文字符通常占用3个字节,英文字符占用1个字节,空格也是1个字节。

2. ArrayBuffer - 原始的二进制缓冲区

// 创建一个12字节的缓冲区(就像申请一块12格的内存空间)
const buffer = new ArrayBuffer(12);

// 但ArrayBuffer本身不能直接操作,需要视图(View)来读写

3. 视图(TypedArray)- 操作缓冲区的"眼镜"

ArrayBuffer就像一块空白画布,而TypedArray就是不同颜色的画笔:

const buffer = new ArrayBuffer(16); // 16字节的缓冲区

// 不同的视图类型,用不同的方式"看待"同一块内存
const uint8View = new Uint8Array(buffer);   // 视为8位无符号整数(0-255)
const uint16View = new Uint16Array(buffer); // 视为16位无符号整数
const int32View = new Int32Array(buffer);   // 视为32位有符号整数

// 使用Uint8Array视图操作数据
const view = new Uint8Array(buffer);
const encoder = new TextEncoder();
const data = encoder.encode('Hello');

for(let i = 0; i < data.length; i++) {
    view[i] = data[i]; // 将数据复制到缓冲区
}

实际应用场景

1. 流式数据处理(AI响应示例)

// 模拟AI流式输出
async function simulateAIStreaming() {
    const responses = ["思考", "中", "请", "稍", "候"];
    const buffer = new ArrayBuffer(100);
    const view = new Uint8Array(buffer);
    const decoder = new TextDecoder();
    
    let position = 0;
    
    for (const word of responses) {
        // 模拟网络延迟
        await new Promise(resolve => setTimeout(resolve, 500));
        
        // 将每个词编码并添加到缓冲区
        const encoded = new TextEncoder().encode(word);
        for (let i = 0; i < encoded.length; i++) {
            view[position++] = encoded[i];
        }
        
        // 实时解码已接收的部分
        const receivedSoFar = decoder.decode(view.slice(0, position));
        console.log(`已接收: ${receivedSoFar}`);
    }
}

// 这就是streaming:true的效果——边生成边显示

2. 文件处理

// 读取图片文件并获取其二进制数据
fileInput.addEventListener('change', async (event) => {
    const file = event.target.files[0];
    const buffer = await file.arrayBuffer(); // 获取文件的二进制数据
    
    // 现在可以操作这个buffer
    const view = new Uint8Array(buffer);
    console.log(`文件大小: ${buffer.byteLength} 字节`);
    console.log(`前10个字节: ${view.slice(0, 10)}`);
});

关键概念对比

概念 比喻 作用
ArrayBuffer 空白的内存空间 分配一块原始二进制内存
TypedArray 有刻度的量杯 以特定格式(如整数、浮点数)读取/写入数据
DataView 多功能测量工具 更灵活地读写不同格式的数据
TextEncoder 打包机 将文本打包成二进制
TextDecoder 拆包机 将二进制解包成文本

常见TypedArray类型

// 不同"眼镜"看同一数据的不同效果
const buffer = new ArrayBuffer(16);
const data = [1, 2, 3, 4];

// 使用Uint8Array:每个数字占1字节
const uint8 = new Uint8Array(buffer);
uint8.set(data);
console.log(uint8); // [1, 2, 3, 4, 0, 0, ...]

// 使用Uint16Array:每个数字占2字节
const uint16 = new Uint16Array(buffer);
console.log(uint16); // [513, 1027, 0, 0, ...] 
// 为什么是513?因为1+2*256=513(小端序存储)

性能优化技巧

  1. 复用Buffer:避免频繁创建和销毁Buffer
  2. 批量操作:使用set()方法而不是循环赋值
  3. 适当大小:不要分配过大的Buffer,会浪费内存
// 优化示例:批量操作
const source = new Uint8Array([1, 2, 3, 4, 5]);
const targetBuffer = new ArrayBuffer(10);
const targetView = new Uint8Array(targetBuffer);

// 好:批量复制
targetView.set(source);

// 不好:逐个复制
for (let i = 0; i < source.length; i++) {
    targetView[i] = source[i];
}

总结

Buffer是JavaScript处理二进制数据的核心工具,特别是在:

  • 网络通信(流式传输)
  • 文件操作(图片、音频处理)
  • 加密算法
  • 与WebGL、Web Audio等API交互

记住这个流程: 文本 → TextEncoder → 二进制 → ArrayBuffer → TypedArray操作 → TextDecoder → 文本

就像快递系统:商品(数据)被包装(编码)→ 运输(二进制传输)→ 拆包(解码)→ 使用。

掌握Buffer操作,你就打开了JavaScript处理二进制世界的大门!


延伸学习

  1. Blob对象:文件相关的二进制操作
  2. Streams API:更高级的流式数据处理
  3. WebSocket.binaryType:网络通信中的二进制传输
  4. Canvas图像数据处理:getImageData()返回的就是Uint8ClampedArray
❌
❌