阅读视图

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

从原理到实践:JavaScript中的this指向,一篇就够了

从原理到实践:JavaScript中的this指向,一篇就够了

前言

最近在复习JavaScript的this指向问题,写了几个小例子来加深理解。很多同学觉得this难,其实是因为this是在函数执行时确定的,而不是定义时。这个特性导致了this指向的“善变”。

今天,就让我通过这几个代码例子,带你由浅入深掌握JavaScript中的this指向。


第一章:基础概念 - this的默认绑定

1.1 全局环境下的this

在浏览器全局环境中,this指向window对象:

var name = "windowName"; // 全局变量
var func1 = function() {
  console.log('func1');
}

console.log(this.name); // "windowName"
console.log(window.name); // "windowName"

核心知识点:

  • 全局作用域下,this === window
  • var声明的全局变量会自动挂载到window对象上

第二章:谁调用我,我就指向谁

2.1 对象方法调用

var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  }
}

a.func1(); // "Cherry"

关键理解: 这里的this指向了对象a。因为func1是由a调用的。

2.2 经典面试题 - 定时器中的this

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里会报错!
    }, 3000)
  }
}

a.func2(); // TypeError: this.func1 is not a function

为什么报错?

定时器的回调函数是由定时器内部调用的,此时this指向了window对象。而window对象上并没有func1方法(虽然有全局变量func1,但这里调用的是对象方法)。

验证一下:

var a = {
  // ... 同上
  func2: function() {
    setTimeout(function() {
      console.log(this); // window
    }, 3000)
  }
}

第三章:解决this丢失的三种方案

3.1 方案一:保存this(that = this)

// 3.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    var that = this; // 保存外层的this
    setTimeout(function() {
      that.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

原理: 利用闭包的特性,内部函数可以访问外部函数的变量。that保存了正确的this引用。

3.2 方案二:bind绑定

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); 
    }.bind(a), 3000) // bind永久绑定this为a
  }
}

a.func2(); // 3秒后输出 "Cherry" ✅

重要区别:

  • bind()不会立即执行,返回一个新函数,永久绑定this
  • call()/apply()立即执行函数,临时绑定this
// 对比演示
a.func1.call(a); // 立即执行
const boundFunc = a.func1.bind(a); // 返回绑定后的函数,不执行
boundFunc(); // 执行时this已经绑定为a

3.3 方案三:箭头函数

// 4.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(() => {
      console.log(this); // a对象 ✅
      this.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

箭头函数的特点:

  • 没有自己的this
  • 继承定义时所在作用域的this
  • this是静态的,不会改变

第四章:深入理解箭头函数

4.1 箭头函数没有自己的this

// 5.html 中的例子
const func = () => {
  console.log(this); // window(在浏览器环境)
}

func(); // window

4.2 箭头函数不能作为构造函数

const func = () => {
  console.log(this);
}

new func(); // TypeError: func is not a constructor ❌

为什么? 箭头函数没有自己的this,也没有prototype属性,无法进行实例化。

4.3 关于arguments对象

const func = () => {
  console.log(arguments); // ReferenceError ❌
}

// 箭头函数也没有自己的arguments对象
// 但可以这样获取参数
const func2 = (...args) => {
  console.log(args); // [1, 2, 3] ✅
}

func2(1, 2, 3);

第五章:综合实践 - 分析一段复杂代码

让我们来分析1.html中的代码,它包含了多个知识点的综合运用:

var name = "windowName";
var func1 = function() {
  console.log('func1');
}
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里原本有问题
      return function() {
        console.log('hahaha');
      }
    }.call(a), 3000) // ⚠️ 注意:这里用了call
  }
}

这里有个坑:

setTimeout的第一个参数应该是函数,但.call(a)立即执行这个函数,并把返回值作为第一个参数传给setTimeout。这里返回的是undefined,相当于:

// 实际执行效果
setTimeout(undefined, 3000)

正确写法:

// 使用bind(不会立即执行)
setTimeout(function() {
  this.func1();
}.bind(a), 3000)

// 或者使用箭头函数
setTimeout(() => {
  this.func1(); // 这里的this继承自func2
}, 3000)

第六章:面试题精选

6.1 经典组合题

var name = 'window';

var obj = {
  name: 'obj',
  fn1: function() {
    console.log(this.name);
  },
  fn2: () => {
    console.log(this.name);
  },
  fn3: function() {
    return function() {
      console.log(this.name);
    }
  },
  fn4: function() {
    return () => {
      console.log(this.name);
    }
  }
}

obj.fn1();      // 'obj' - 对象方法调用
obj.fn2();      // 'window' - 箭头函数,this指向外层window
obj.fn3()();    // 'window' - 独立函数调用
obj.fn4()();    // 'obj' - 箭头函数,this继承自fn4的this

6.2 优先级问题

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

const obj1 = { name: 'obj1', foo };
const obj2 = { name: 'obj2' };

obj1.foo();                 // 'obj1'
obj1.foo.call(obj2);       // 'obj2' - call优先于隐式绑定
const bound = foo.bind(obj1);
bound.call(obj2);          // 'obj1' - bind绑定后,call无法改变

this绑定优先级:

  1. new绑定(最高)
  2. call/apply/bind显式绑定
  3. 对象方法调用(隐式绑定)
  4. 默认绑定(独立函数调用,最低)

总结

this的指向规律其实很简单,记住这几点:

  1. 函数被调用时才能确定this指向
  2. 普通函数:谁调用我,我指向谁
  3. 箭头函数:我在哪里定义,this就跟谁一样
  4. 可以通过bind永久绑定this,call/apply临时绑定this

理解了这些,JavaScript的this问题就迎刃而解了。


练习题

// 尝试分析下面的输出
const obj = {
  name: 'obj',
  say: function() {
    setTimeout(() => {
      console.log(this.name);
    }, 100);
  }
}

obj.say(); // 输出什么?

const say = obj.say;
say(); // 输出什么?

答案和解析欢迎在评论区讨论!


如果你觉得这篇文章对你有帮助,请点赞👍收藏⭐,让更多的小伙伴看到!我们下期再见!

从删除节点到快慢指针:一篇写给初学者的链表操作指南

从删除节点到快慢指针:一篇写给初学者的链表操作指南

前言

链表,这个数据结构对很多前端同学来说就像一道坎。明明 JavaScript 里到处都是对象引用,为什么到了链表这里就理不清了?

今天这篇文章,我会带你从最基础的链表删除开始,一步一步深入到快慢指针。每一行代码我都会解释为什么这么写,每一个变量我都会说清楚它的作用。相信我,看完这篇文章,链表不再是你的痛点。

什么是链表?一个最简单的比喻

想象一下寻宝游戏:每一张纸条上写着一个宝藏的名字,还有下一张纸条的位置。你拿到第一张纸条,看完宝藏,顺着地址找到第二张纸条...这就是链表。

// 链表中的一个节点
class ListNode {
    constructor(val) {
        this.val = val      // 当前节点的值(宝藏的名字)
        this.next = null   // 指向下一个节点的引用(下一张纸条的位置)
    }
}

第一部分:删除节点 - 为什么要引入哨兵节点?

场景:删除链表中值为 val 的节点

我们先从最简单的需求开始:给定一个链表,删除其中第一个值为 val 的节点。

初版代码:问题在哪里?
function remove(head, val) {
    // 问题1:头节点特殊处理
    if (head && head.val === val) {
        return head.next  // 直接返回第二个节点作为新头节点
    }

    // 问题2:这里的逻辑和上面不统一
    let cur = head
    while (cur.next) {
        if (cur.next.val === val) {
            cur.next = cur.next.next  // 跳过目标节点
            break
        }
        cur = cur.next
    }
    return head
}

这段代码有什么问题?

问题1:头节点需要特殊处理 为什么?因为删除节点的通用逻辑是:找到要删除节点的前一个节点,让它的 next 指向要删除节点的下一个节点。

但头节点没有前一个节点!所以我们必须单独处理。

问题2:逻辑不统一 删除头节点:return head.next 删除其他节点:cur.next = cur.next.next 两种写法,两个思维路径,容易出错。

问题3:尾节点的隐患 虽然这个例子没体现,但如果要删除尾节点,我们的代码也有问题。尾节点的 nextnull,但我们的删除逻辑依然适用,只是要小心别出现 null.next

引入哨兵节点:一个革命性的改进
function remove(head, val) {
    // 创建一个哨兵节点,值是多少不重要,0只是占位
    const dummy = new ListNode(0)
    // 哨兵节点指向头节点
    dummy.next = head
    
    // 现在,dummy 成为了头节点的前驱节点
    let cur = dummy
    
    // 遍历链表
    while (cur.next) {
        if (cur.next.val === val) {
            // 删除 cur.next 节点
            cur.next = cur.next.next
            break
        }
        cur = cur.next
    }
    
    // 返回真正的头节点(dummy.next 可能是原来的头,也可能是新头)
    return dummy.next
}

哨兵节点做了什么?

  1. 给头节点找了个"前驱":现在所有节点都有前驱节点了
  2. 统一了删除逻辑:删除任何节点都是 cur.next = cur.next.next
  3. 简化了返回值:永远返回 dummy.next,不需要判断头节点是否被删

为什么叫"哨兵"? 就像军队的哨兵站在营区门口一样,这个节点站在链表的最前面,帮我们处理边界情况。它不存储有效数据,但它让我们的代码更安全。

内存视角:删除节点时发生了什么?

很多初学者会问:cur.next = cur.next.next 之后,被删除的节点去哪里了?

答案是:没有人引用它了,它会被 JavaScript 的垃圾回收机制回收

删除前:
dummy → node1 → node2(node_to_delete) → node3 → null
                ↑
               cur

执行 cur.next = cur.next.next:

dummy → node1 ──────→ node3 → null
                ↘
                 node2(没有引用指向它了,等待垃圾回收)

第二部分:反转链表 - 哨兵节点的妙用

如果说删除节点是哨兵节点的"被动防御",那反转链表就是它的"主动进攻"。

理解头插法:像打牌一样反转链表

function reverseList(head) {
    // dummy 节点将作为新链表的头哨兵
    const dummy = new ListNode(0)
    // cur 指向当前要处理的节点,从原链表头开始
    let cur = head
    
    while (cur) {
        // 第一步:保存下一个节点
        // 为什么要保存?因为一旦改变了 cur.next 的指向,我们就找不到下一个节点了
        const nextNode = cur.next
        
        // 第二步:头插法的核心 - 把当前节点插入到 dummy 和 dummy.next 之间
        // 这一步让当前节点指向已反转部分的头部
        cur.next = dummy.next
        
        // 第三步:更新 dummy.next,让它指向最新的头节点
        dummy.next = cur
        
        // 第四步:移动到下一个节点
        cur = nextNode
    }
    
    return dummy.next
}

详细拆解每一步:

假设链表是:1 → 2 → 3 → null

初始状态:

dummy → null
cur = 123null

处理节点1:

保存 next = 2
1.next = dummy.next = null    // 1 → null
dummy.next = 1                // dummy → 1 → null
cur = 2                      // 移动到下一个节点

处理节点2:

保存 next = 3
2.next = dummy.next = 1       // 21 → null
dummy.next = 2               // dummy → 21 → null
cur = 3                      // 移动到下一个节点

处理节点3:

保存 next = null
3.next = dummy.next = 2       // 321 → null
dummy.next = 3               // dummy → 321 → null
cur = null                   // 循环结束

为什么这种方法好?

  1. 原地反转:不需要额外创建新的节点
  2. 思路清晰:每一轮都是在做同一件事 - 把当前节点"插"到最前面
  3. 哨兵节点锚定:dummy.next 始终指向最新的头节点

第三部分:检测环形链表 - 快慢指针入门

场景:如何判断一个链表里有环?

想象一下操场跑步的场景:

  • 如果是直线跑道,跑得快的人永远在前面,先到终点
  • 如果是环形跑道,跑得快的人会从后面追上跑得慢的人

这就是快慢指针的核心思想。

function hasCycle(head) {
    // 如果链表为空或只有一个节点,肯定没有环
    if (!head || !head.next) return false
    
    // 两个指针起点相同
    let slow = head
    let fast = head
    
    // 快指针每次走两步,所以必须保证 fast 和 fast.next 都存在
    while (fast && fast.next) {
        slow = slow.next        // 慢指针走1步
        fast = fast.next.next  // 快指针走2步
        
        // 如果两个指针相遇了,说明有环
        // 注意:这里比较的是引用地址,不是值
        if (slow === fast) {
            return true
        }
    }
    
    // 快指针到达了终点,说明没有环
    return false
}

为什么快指针每次走2步,慢指针走1步?

这是数学上的最优解。你可以理解为:

  • 如果快指针走3步,可能会"跳过"慢指针(在环中擦肩而过)
  • 如果快指针走1步,那就和慢指针永远在一起了
  • 走2步是最稳妥的,只要有环,快指针一定会在某圈追上慢指针

为什么返回 false 的条件是 fast 或 fast.next 为 null?

因为快指针走得快,如果链表没有环,它一定会先到达链表的末尾。而链表末尾的特征就是:

  • fast === null(链表长度为偶数,fast 直接走到了末尾)
  • fast.next === null(链表长度为奇数,fast 走到了最后一个节点)

一个常见的困惑:

问:如果链表很长,环很小,快指针会不会在环里转很多圈才能追上慢指针?

答:是的,但这不是问题。当慢指针进入环时,快指针已经在环里了。它们的速度差是1步/次,所以最多转一圈就会被追上。时间复杂度仍然是 O(n)。

第四部分:删除倒数第N个节点 - 快慢指针 + 哨兵节点

现在我们有了两个武器:

  1. 哨兵节点 - 处理边界情况
  2. 快慢指针 - 一次遍历定位

让我们把它们结合起来,解决一个经典问题。

问题分析

删除倒数第n个节点,最直观的思路是:

  1. 先遍历一遍,拿到链表长度 L
  2. 那么倒数第n个节点就是正数第 L-n+1 个节点
  3. 再遍历一遍,找到它的前驱节点,删除它

但我们可以做得更好:一次遍历搞定!

function removeNthFromEnd(head, n) {
    // 1. 创建哨兵节点,统一处理逻辑
    const dummy = new ListNode(0)
    dummy.next = head
    
    // 2. 快慢指针都从哨兵节点开始
    let fast = dummy
    let slow = dummy
    
    // 3. 快指针先走n步
    //   这样快指针和慢指针之间就保持了n个节点的距离
    for (let i = 0; i < n; i++) {
        fast = fast.next
    }
    
    // 4. 快慢指针一起走
    //   当快指针到达最后一个节点时,慢指针刚好在倒数第n个节点的前一个位置
    while (fast.next) {
        fast = fast.next
        slow = slow.next
    }
    
    // 5. 删除倒数第n个节点
    //   slow.next 就是要删除的节点
    slow.next = slow.next.next
    
    // 6. 返回真正的头节点
    return dummy.next
}

为什么这个解法是优雅的?

关键理解1:为什么快指针先走n步?

因为我们要删除倒数第n个节点。倒数第n个节点到链表的末尾的距离是n-1(不算尾节点的next)。

当快慢指针一起走时,快指针到达末尾(null的前一个)时,慢指针和快指针的距离保持不变(n步)。此时,慢指针指向的就是倒数第n个节点的前一个节点

关键理解2:为什么用dummy?

考虑一个极端情况:链表只有一个节点,要删除倒数第1个节点(也就是它自己)。

如果没有dummy:

  • slow和fast都指向head
  • 快指针先走1步:fast = fast.next = null
  • 进入while循环:fast.next 会报错,因为 null.next 不存在

有了dummy:

  • slow和fast都指向dummy
  • 快指针先走1步:fast = dummy.next = head
  • 再走1步:fast = head.next = null
  • 进入while循环:fast.next?fast是null,报错?

为什么是 while(fast.next) 而不是 while(fast)

因为我们想要的是:当fast是最后一个节点时停止。这样slow刚好指向倒数第n个节点的前驱。

如果写成 while(fast),fast会一直走到null,slow就会指向倒数第n个节点本身,而不是它的前驱。

总结:这些技巧的本质是什么?

哨兵节点的本质:用空间换逻辑的简洁性。我们多创建了一个节点,但换来的是:

  • 不需要if-else处理特殊情况
  • 代码更易读,更易维护
  • 边界条件自动化解

快慢指针的本质:用速度差来定位位置。就像两个人跑步,我们通过控制他们的速度差,让慢的人在我们想要的位置停下来。

组合技巧的本质:1+1 > 2。哨兵节点让快慢指针更安全,快慢指针让哨兵节点发挥更大作用。

写在最后

链表操作看似花样繁多,但核心技巧就那么几个。当你理解了:

  • 为什么需要哨兵节点(边界处理)
  • 为什么用头插法(原地反转)
  • 为什么快慢指针能相遇(数学原理)

你就掌握了链表的"内功心法"。剩下的就是多看、多写、多思考。

如果你读到这里,相信链表已经不再是你的痛点。但如果还有困惑,不妨收藏这篇文章,动手敲一遍代码。编程是动手的艺术,只有自己亲手写过,才能真正理解。


本文代码已通过基础测试,但若你发现任何问题或有更好的建议,欢迎在评论区指出。让我们一起写出更好的代码!

写给 JavaScript 初学者的 React 完全指南

写给 JavaScript 初学者的 React 完全指南

引言:从零开始,我们一起学 React

如果你是 JavaScript 初学者,觉得 React 很难懂,别担心!我曾经也是从零开始学习的。今天,我用最简单的方式,结合我自己写的代码,带你一步步理解 React 的核心概念。保证每个概念都有代码示例,而且都是我自己学习时写的真实代码!

第一章:什么是 JSX?比 HTML 还简单的语法

1.1 JSX 就像在 JavaScript 里写 HTML

想象一下,你可以在 JavaScript 文件中直接写 HTML,这就是 JSX!

// 看看我写的代码,是不是很像 HTML?
const element = <h2>JSX 是React 中用于描述用户界面的语法扩展</h2>

简单理解:JSX 就是在 JavaScript 里面写 HTML 标签。

1.2 为什么要用 JSX?一个对比你就懂了

传统方式(很复杂):
// 不用 JSX,创建元素需要这样:
const element = document.createElement('h2')
element.textContent = 'Hello World'
document.body.appendChild(element)
使用 JSX(很简单):
// 使用 JSX,直接写:
const element = <h2>Hello World</h2>

看到区别了吗?JSX 让创建页面元素变得跟写 HTML 一样简单!

1.3 JSX 的几个重要规则

规则1:最外层只能有一个标签
// ❌ 错误写法(多个并列标签)
function Wrong() {
  return (
    <h1>标题</h1>
    <p>内容</p>
  )
}

// ✅ 正确写法(用一个标签包裹起来)
function Right() {
  return (
    <div>  {/* 这个 div 就像一个大盒子 */}
      <h1>标题</h1>
      <p>内容</p>
    </div>
  )
}
规则2:用 className 代替 class
// ❌ 错误写法
<div class="title">Hello</div>

// ✅ 正确写法
<div className="title">Hello</div>

为什么:因为 class 在 JavaScript 中是定义类的关键字,为了避免冲突,React 用 className

规则3:JavaScript 表达式要放在 {} 里
const name = '小明'

// 在 JSX 中使用 JavaScript 变量
const element = <h1>你好,{name}</h1>
// 渲染结果:<h1>你好,小明</h1>

第二章:React 组件 - 就像搭积木一样简单

2.1 什么是组件?就是一个函数

在 React 中,组件就是一个返回 JSX 的函数

// 定义一个最简单的组件
function Welcome() {
  return <h1>欢迎学习 React!</h1>
}

// 使用这个组件(就像使用 HTML 标签一样)
function App() {
  return (
    <div>
      <Welcome />
    </div>
  )
}

2.2 为什么要用组件?看看我的掘金首页例子

我模仿掘金首页写了一个例子,用组件化的思想来构建:

// 我把一个完整的页面拆成了几个小部分:

// 1. 头部组件 - 专门负责显示网站标题
function JuejinHeader() {
  return (
    <header>
      <h1>掘金首页</h1>
    </header>
  )
}

// 2. 文章列表组件 - 专门负责显示文章
function Articles() {
  return (
    <div>
      <h2>文章列表</h2>
      <p>React 入门教程</p>
      <p>JavaScript 基础</p>
    </div>
  )
}

// 3. 签到组件 - 专门负责签到功能
function Checkin() {
  return (
    <div>
      <h2>每日签到</h2>
      <button>点击签到</button>
    </div>
  )
}

// 4. 主组件 - 把所有小组件组合起来
function App() {
  return (
    <div>
      {/* 使用头部组件 */}
      <JuejinHeader />
      
      <main>
        {/* 使用文章列表组件 */}
        <Articles />
        
        <aside>
          {/* 使用签到组件 */}
          <Checkin />
        </aside>
      </main>
    </div>
  )
}

组件化的好处

  1. 可复用Checkin 组件可以在其他页面使用
  2. 好维护:修改签到功能时,只需要改 Checkin 组件
  3. 分工明确:不同人可以负责不同组件

2.3 组件命名的约定

在 React 中,组件名必须以大写字母开头

// ✅ 正确:大写字母开头
function MyComponent() {
  return <div>Hello</div>
}

// ❌ 错误:小写字母开头(会被认为是 HTML 标签)
function mycomponent() {
  return <div>Hello</div>
}

第三章:useState - React 的"记忆"功能

3.1 为什么需要状态?

想象一下,你的网页需要记住一些信息,比如:

  • 用户是否登录
  • 计数器当前的值
  • 待办事项列表

在 React 中,这些"记忆"就是通过 状态(state) 实现的。

3.2 useState 基本用法

让我们一步步理解,看我写的登录状态例子:

// 第一步:导入 useState
import { useState } from 'react'

function App() {
  // 第二步:使用 useState
  // 它返回一个数组,包含两个东西:
  // 1. 当前状态值(isLoggedIn)
  // 2. 修改状态的函数(setIsLoggedIn)
  const [isLoggedIn, setIsLoggedIn] = useState(false)
  // ↑ 初始值设为 false,表示未登录
  
  // 第三步:使用状态
  return (
    <div>
      {/* 根据登录状态显示不同文字 */}
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      
      {/* 点击按钮改变状态 */}
      <button onClick={() => setIsLoggedIn(!isLoggedIn)}>
        {isLoggedIn ? '退出登录' : '登录'}
      </button>
    </div>
  )
}

形象理解

  • isLoggedIn 就像是一个"记忆盒子",里面装着 truefalse
  • setIsLoggedIn 就像是改变盒子内容的"遥控器"
  • 当盒子里的内容改变时,页面会自动更新

3.3 多个状态的管理

在我的代码中,我管理了多个状态:

function App() {
  // 1. 字符串状态 - 名字
  const [name, setName] = useState('vue')
  // name 初始值是 'vue'
  
  // 2. 数组状态 - 待办事项列表
  const [todos, setTodos] = useState([
    { id: 1, title: '学习react', done: false },
    { id: 2, title: '学习vue', done: false }
  ])
  
  // 3. 布尔状态 - 是否登录
  const [isLoggedIn, setIsLoggedIn] = useState(false)
  
  // 每个状态都有自己的"记忆盒子"和"遥控器"
  // 它们之间互不干扰
}

3.4 自动更新的魔法

看看我这个有趣的例子:

function App() {
  const [name, setName] = useState('vue')
  
  // 3秒后自动改变名字
  setTimeout(() => {
    setName('react')  // 从 'vue' 变成 'react'
  }, 3000)
  
  return <h1>Hello {name}</h1>
}

会发生什么

  1. 页面第一次显示:Hello vue
  2. 3秒后,自动变成:Hello react
  3. 不需要手动刷新页面,React 会自动更新!

这就是 React 最神奇的地方:状态改变,UI 自动更新

第四章:条件渲染和列表渲染

4.1 条件渲染 - 像 if-else 一样简单

在我的待办事项代码中,我用了条件渲染:

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, title: '学习react' },
    { id: 2, title: '学习vue' }
  ])
  
  return (
    <div>
      {/* 条件渲染:如果 todos 有内容就显示列表,否则显示提示 */}
      {
        todos.length > 0 ? (
          // 条件成立时显示的内容
          <ul>
            {todos.map(todo => (
              <li key={todo.id}>{todo.title}</li>
            ))}
          </ul>
        ) : (
          // 条件不成立时显示的内容
          <div>暂无待办事项</div>
        )
      }
    </div>
  )
}

简单理解

  • todos.length > 0 ? A : B 就像 if-else 语句
  • 如果 todo 数量 > 0,显示列表(A)
  • 否则,显示"暂无待办事项"(B)

4.2 列表渲染 - 用 map 显示数组内容

列表渲染非常常用,看我的例子:

const todos = [
  { id: 1, title: '学习react' },
  { id: 2, title: '学习vue' },
  { id: 3, title: '学习JavaScript' }
]

function TodoList() {
  return (
    <ul>
      {
        // 用 map 把数组变成 JSX 元素
        todos.map(item => (
          <li key={item.id}>{item.title}</li>
        ))
      }
    </ul>
  )
}

// 渲染结果:
// <ul>
//   <li>学习react</li>
//   <li>学习vue</li>
//   <li>学习JavaScript</li>
// </ul>

重要提示:列表中的每个元素都需要 key 属性

// ✅ 正确:有 key
<li key={item.id}>{item.title}</li>

// ❌ 错误:没有 key(React 会警告)
<li>{item.title}</li>

为什么需要 key

  1. 帮助 React 识别哪些元素变化了
  2. 提高列表更新的性能
  3. 就像给列表项加上身份证号,每个都不一样

第五章:事件处理 - 让页面能交互

5.1 处理点击事件

在我写的登录例子中,我处理了按钮点击:

function LoginButton() {
  const [isLoggedIn, setIsLoggedIn] = useState(false)
  
  // 点击按钮时调用的函数
  const handleClick = () => {
    // 把登录状态取反
    // 如果原来是 true,变成 false;如果原来是 false,变成 true
    setIsLoggedIn(!isLoggedIn)
  }
  
  return (
    <div>
      <p>{isLoggedIn ? '已登录' : '未登录'}</p>
      {/* 注意:onClick 的 C 是大写 */}
      <button onClick={handleClick}>
        {isLoggedIn ? '退出登录' : '登录'}
      </button>
    </div>
  )
}

5.2 事件处理的不同写法

// 写法1:先定义函数,再使用
function handleClick() {
  console.log('按钮被点击了')
}
<button onClick={handleClick}>按钮</button>

// 写法2:使用箭头函数
<button onClick={() => {
  console.log('按钮被点击了')
}}>按钮</button>

// 写法3:箭头函数调用已定义的函数
<button onClick={() => handleClick()}>按钮</button>

第六章:完整代码解析 - 一步步理解

6.1 我的 App.jsx 完整解析

// 第1步:导入需要的功能
import { useState, createElement } from 'react'
import './App.css'

// 第2步:定义 App 组件
function App() {
  // 第3步:定义状态(组件的"记忆")
  const [name, setName] = useState('vue')          // 名字状态
  const [todos, setTodos] = useState([             // 待办事项状态
    { id: 1, title: '学习react', done: false },
    { id: 2, title: '学习vue', done: false }
  ])
  const [isLoggedIn, setIsLoggedIn] = useState(false) // 登录状态
  
  // 第4步:定义事件处理函数
  const toggleLogin = () => {
    // 切换登录状态(true ↔ false)
    setIsLoggedIn(!isLoggedIn)
  }
  
  // 第5步:定义 JSX 元素
  const element = <h2>JSX 是React 中用于描述用户界面的语法扩展</h2>
  const element2 = createElement('h2', null, 'JSX 是React 中用于描述用户界面的语法扩展')
  
  // 第6步:演示状态自动更新(3秒后改变名字)
  setTimeout(() => {
    setName('react')  // 从 'vue' 变成 'react'
  }, 3000)
  
  // 第7步:返回 JSX(页面的内容)
  return(
    <>
      {/* 显示 JSX 元素 */}
      {element}
      {element2}
      
      {/* 显示动态内容 */}
      <h1>Hello <span className="title">{name}</span></h1>
      
      {/* 条件渲染:根据 todos 长度显示不同内容 */}
      {
        todos.length > 0 ? (
          <ul>
            {
              todos.map((todo) => (
                <li key={todo.id}>{todo.title}</li>
              ))
            }
          </ul>
        ) : (<div>暂无待办事项</div>)
      }
      
      {/* 条件渲染:根据登录状态显示不同内容 */}
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      
      {/* 事件处理:点击按钮切换登录状态 */}
      <button onClick={toggleLogin}>
        {isLoggedIn ? '退出登录' : '登录'}
      </button>
    </>
  )
}

// 第8步:导出组件,让其他文件可以使用
export default App

6.2 我的 App2.jsx 完整解析

// 这个文件展示了如何把大页面拆分成小组件

// 组件1:掘金网站的头部
function JuejinHeader() {
  return (
    <div>
      <header>
        <h1>掘金首页</h1>
      </header>
    </div>
  )
}

// 组件2:文章列表
const Articles = () => {
  return (
    <div>
      <h2>文章列表</h2>
    </div>
  )
}

// 组件3:签到功能
const Checkin = () => {
  return (
    <div>
      <h2>签到</h2>
    </div>
  )
}

// 组件4:热门文章
const TopArticles = () => {
  return (
    <div>
      <h2>热门文章</h2>
    </div>
  )
}

// 主组件:把所有小组件组合起来
function App() {
  return (
    <div>
      {/* 使用头部组件 */}
      <JuejinHeader />
      
      {/* 主要内容区域 */}
      <main>
        {/* 使用文章列表组件 */}
        <Articles />
        
        {/* 侧边栏 */}
        <aside>
          {/* 使用签到组件 */}
          <Checkin />
          {/* 使用热门文章组件 */}
          <TopArticles />
        </aside>
      </main>
    </div>
  )
}

// 导出主组件
export default App

第七章:常见问题解答

7.1 我是 JavaScript 新手,能学会 React 吗?

当然可以! React 虽然有一些新概念,但都是建立在 JavaScript 基础上的。我的学习建议:

  1. 先掌握这些 JavaScript 基础

    • 变量和常量(let, const)
    • 函数(function, 箭头函数)
    • 数组和对象
    • 数组的 map 方法
  2. 然后学习 React

    • JSX(就是 HTML 写在 JS 里)
    • 组件(就是返回 JSX 的函数)
    • useState(就是让组件有"记忆")

7.2 为什么我的代码报错了?

根据我的经验,新手常犯的错误:

// 错误1:最外层多个标签
function Wrong() {
  return (
    <h1>标题1</h1>  // ❌ 这里会报错
    <h2>标题2</h2>
  )
}

// 正确:用一个标签包裹
function Right() {
  return (
    <div>  {/* ✅ 用一个 div 包裹所有内容 */}
      <h1>标题1</h1>
      <h2>标题2</h2>
    </div>
  )
}

7.3 如何开始我的第一个 React 项目?

最简单的方式是使用官方工具:

# 在命令行中输入(确保已安装 Node.js)
npx create-react-app my-first-app

# 进入项目文件夹
cd my-first-app

# 启动项目
npm start

然后打开 src/App.js,就可以开始写代码了!

学习路线建议

根据我自己的学习经历,建议按这个顺序学习:

第一阶段:基础(1-2周)

  1. 学会写 JSX(在 JS 中写 HTML)
  2. 理解什么是组件
  3. 掌握 useState 的基本使用

第二阶段:实践(2-3周)

  1. 做一个待办事项应用(就像我代码中的 todos)
  2. 做一个简单的登录/注册界面
  3. 练习条件渲染和列表渲染

第三阶段:进阶(持续学习)

  1. 学习更多 React Hooks
  2. 学习组件间通信
  3. 学习路由、状态管理等

最后的鼓励

我刚开始学习 React 时,也觉得这些概念很抽象。但通过实际写代码,特别是像我的 App.jsxApp2.jsx 这样的练习,我逐渐理解了:

  1. React 并不神秘:它只是提供了一种更高效的方式来构建网页
  2. 组件化思想很重要:把大问题拆成小问题,每个组件解决一个小问题
  3. 状态管理是核心:理解 useState,就理解了 React 的一半

记住我代码中的这个例子,它包含了 React 最核心的概念:

// 状态
const [isLoggedIn, setIsLoggedIn] = useState(false)

// 事件处理
const toggleLogin = () => {
  setIsLoggedIn(!isLoggedIn)
}

// 条件渲染
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}

// 事件绑定
<button onClick={toggleLogin}>切换登录状态</button>

一个函数 + 一些状态 + JSX = 一个 React 组件

从现在开始,动手写代码吧!遇到问题就回头看我的代码示例,或者自己尝试修改我的代码看看效果。学习编程最有效的方法就是:多写,多练,多思考

加油,你一定能学会 React!

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

前言:当我第一次学习前端时,导师让我实现一个Todo应用。我花了2小时写了50行代码,导师看了一眼说:“试试Vue3吧。” 我用30分钟重写了同样的功能,代码减少到20行。那一刻,我明白了什么是真正的数据驱动开发。今天,我想通过这个Todo应用,带你体验这场思维革命。

第一章:传统开发方式的困境

让我们先回顾一下用原生JavaScript实现的Todo应用:

<!-- demo.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>传统Todo应用</title>
</head>
<body>
    <h2 id="app"></h2>
    <input type="text" id="todo-input">
    <script>
        // 传统做法:命令式编程
        const app = document.getElementById('app')
        const todoInput = document.getElementById('todo-input')
        
        // 手动监听事件
        todoInput.addEventListener('change', function(event){
            const todo = event.target.value.trim()
            if(!todo){
                console.log('请输入任务')
                return
            }
            // 手动更新DOM
            app.innerHTML = todo
        })
    </script>
</body>
</html>

🔍 传统方式的三大痛点:

  1. 命令式编程:你需要像指挥官一样告诉浏览器每一步该做什么
  2. DOM操作繁琐:每次数据变化都要手动查找和更新DOM
  3. 关注点错位:80%的代码在处理界面操作,只有20%在处理业务逻辑

这就像每次想改变房间布局,都要亲自搬砖砌墙

第二章:Vue3的数据驱动革命

现在,让我们看看用Vue3实现的完整Todo应用:

<!-- App.vue -->
<template>
  <div>
    <!-- 1. 数据绑定 -->
    <h2>{{title}}</h2>
    
    <!-- 2. 双向数据绑定 -->
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="输入任务后按回车"
    >
    
    <!-- 3. 条件渲染 -->
    <ul v-if="todos.length">
      <!-- 4. 列表渲染 -->
      <li v-for="todo in todos" :key="todo.id">
        <!-- 5. 双向绑定到对象属性 -->
        <input type="checkbox" v-model="todo.done">
        
        <!-- 6. 动态class绑定 -->
        <span :class="{done: todo.done}">{{todo.title}}</span>
      </li>
    </ul>
    
    <!-- 7. v-else指令 -->
    <div v-else>
      暂无任务
    </div>
    
    <!-- 8. 计算属性使用 -->
    <div>
      进度:{{activeTodos}} / {{todos.length}}
    </div>
    
    <!-- 9. 计算属性的getter/setter -->
    全选<input type="checkbox" v-model="allDone">
  </div>
</template>

<script setup>
// 10. Composition API导入
import { ref, computed, watch } from 'vue'

// 11. 响应式数据
const title = ref("Todos任务清单")
const todos = ref([
  {
    id: 1,
    title: '学习vue',
    done: false
  },
  {
    id: 2,
    title: '打王者',
    done: false
  },
    {
    id: 3,
    title: '吃饭',
    done: true
  }
])

// 12. 计算属性
const activeTodos = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 13. 方法定义
const addTodo = () => {
  if(!title.value) return
  
  todos.value.push({
    id: Date.now(),  // 更好的ID生成方式
    title: title.value,
    done: false
  })
  
  title.value = ""
}

// 14. 计算属性的getter/setter
const allDone = computed({
  get() {
    return todos.value.length > 0 && 
           todos.value.every(todo => todo.done)
  },
  set(val) {
    todos.value.forEach(todo => todo.done = val)
  }
})

// 15. 监听器 - 补充知识点
watch(todos, (newTodos) => {
  console.log('任务列表发生变化:', newTodos)
  // 可以在这里实现本地存储
}, { deep: true })

// 16. 生命周期钩子 - 补充知识点
import { onMounted } from 'vue'
onMounted(() => {
  console.log('组件挂载完成')
  // 可以在这里从本地存储读取数据
})
</script>

<style>
.done {
  color: gray;
  text-decoration: line-through;
}

/* 17. 组件样式作用域 - 补充知识点 */
/* 这里的样式只作用于当前组件 */
</style>

第三章:Vue3核心API深度解析

🎯 1. ref - 响应式数据的基石

代码:

const title = ref("Todos任务清单")

补充:

  • ref用于创建响应式引用
  • 访问值需要使用.value
  • 为什么需要.value?因为Vue需要知道哪些数据需要被追踪变化
// ref的内部原理简化版
function ref(initialValue) {
  let value = initialValue
  return {
    get value() {
      // 这里可以收集依赖
      return value
    },
    set value(newValue) {
      value = newValue
      // 这里可以通知更新
    }
  }
}

🎯 2. v-model - 双向绑定的魔法

代码:

<input type="text" v-model="title">

补充: v-model实际上是语法糖,它等于:

<input 
  :value="title"
  @input="title = $event.target.value"
>

对于复选框,v-model的处理有所不同:

<input type="checkbox" v-model="todo.done">
<!-- 等价于 -->
<input 
  type="checkbox" 
  :checked="todo.done"
  @change="todo.done = $event.target.checked"
>

🎯 3. 指令系统详解

v-show vs v-if

<!-- v-if是真正的条件渲染 -->
<div v-if="show">条件渲染</div> <!-- 会从DOM中移除/添加 -->

<!-- v-show只是控制display -->
<div v-show="show">显示控制</div> <!-- 始终在DOM中,只是display切换 -->

动态参数

<!-- 动态指令参数 -->
<a :[attributeName]="url">链接</a>
<button @[eventName]="doSomething">按钮</button>

🎯 4. computed - 智能计算属性

细节

// 计算属性的缓存特性
const expensiveCalculation = computed(() => {
  console.log('重新计算') // 只有依赖变化时才会执行
  return todos.value
    .filter(todo => !todo.done)
    .map(todo => todo.title.toUpperCase())
    .join(', ')
})

// 依赖没有变化时,直接返回缓存值
console.log(expensiveCalculation.value) // 输出并打印"重新计算"
console.log(expensiveCalculation.value) // 直接返回缓存值,不打印

🎯 5. watch - 数据监听器

重要知识点:

// 1. 监听单个ref
watch(title, (newTitle, oldTitle) => {
  console.log(`标题从"${oldTitle}"变为"${newTitle}"`)
})

// 2. 监听多个数据源
watch([title, todos], ([newTitle, newTodos], [oldTitle, oldTodos]) => {
  // 处理变化
})

// 3. 立即执行的watch
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { immediate: true }) // 组件创建时立即执行一次

// 4. 深度监听
watch(todos, (newTodos) => {
  // 可以检测到对象内部属性的变化
}, { deep: true })

🎯 6. 生命周期钩子

完整生命周期:

import { 
  onBeforeMount, 
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onErrorCaptured
} from 'vue'

onBeforeMount(() => {
  console.log('组件挂载前')
})

onMounted(() => {
  console.log('组件已挂载,可以访问DOM')
})

onBeforeUpdate(() => {
  console.log('组件更新前')
})

onUpdated(() => {
  console.log('组件已更新')
})

onBeforeUnmount(() => {
  console.log('组件卸载前')
})

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

onErrorCaptured((error) => {
  console.error('捕获到子组件错误:', error)
})

第四章:Vue3开发模式的优势

🚀 1. 开发效率对比

功能 传统JS代码量 Vue3代码量 效率提升
数据绑定 10-15行 1行 90%
列表渲染 15-20行 3行 85%
事件处理 5-10行 1行 80%
样式绑定 5-10行 1行 80%

🎯 2. 思维模式转变

传统开发思维(怎么做):

1. 找到DOM元素
2. 监听事件
3. 获取数据
4. 操作DOM更新界面

Vue3开发思维(要什么):

1. 定义数据状态
2. 描述UI与数据的关系
3. 修改数据
4. 界面自动更新

💡 3. 性能优化自动化

Vue3自动为你做了这些优化:

// 1. 虚拟DOM减少真实DOM操作
// 2. Diff算法最小化更新
// 3. 响应式系统精确追踪依赖
// 4. 计算属性缓存避免重复计算
// 5. 组件复用减少渲染开销

第五章:实战技巧与最佳实践

📝 1. 代码组织建议

<script setup>
// 1. 导入部分
import { ref, computed, watch, onMounted } from 'vue'

// 2. 响应式数据
const title = ref('')
const todos = ref([])

// 3. 计算属性
const activeCount = computed(() => { /* ... */ })

// 4. 方法定义
const addTodo = () => { /* ... */ }

// 5. 生命周期
onMounted(() => { /* ... */ })

// 6. 监听器
watch(todos, () => { /* ... */ })
</script>

🎨 2. 样式管理技巧

<style scoped>
/* scoped属性让样式只作用于当前组件 */
.todo-item {
  padding: 10px;
}

/* 深度选择器 */
:deep(.child-component) {
  color: red;
}

/* 全局样式 */
:global(.global-class) {
  font-size: 16px;
}
</style>

🔧 3. 调试技巧

// 1. 在模板中调试
<div>{{ debugInfo }}</div>

// 2. 使用Vue Devtools浏览器插件
// 3. 使用console.log增强
watch(todos, (newTodos) => {
  console.log('todos变化:', JSON.stringify(newTodos, null, 2))
}, { deep: true })

结语:从学习者到实践者

通过这个Todo应用,我们看到了Vue3如何将我们从繁琐的DOM操作中解放出来,让我们能更专注于业务逻辑。这种声明式编程的思维方式,不仅让代码更简洁,也让开发更高效。

记住

  1. Vue3不是魔法,但它让开发变得像魔法一样简单
  2. 学习Vue3不仅是学习一个框架,更是学习一种更好的编程思维
  3. 从今天开始,尝试用数据驱动的方式思考问题

下一步建议

  1. 在Vue Playground中多练习
  2. 阅读Vue3官方文档
  3. 尝试实现更复杂的功能(过滤、搜索、排序)
  4. 学习Vue Router和Pinia

📚 资源推荐

希望这篇文章能帮助你更好地理解Vue3的强大之处!如果你有任何问题或想法,欢迎在评论区讨论交流。🌟

一起进步,从今天开始!

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

从零手写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 的实例化过程

深入浅出:手写 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“打字机”效果的

实战解密:我是如何用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流式输出

什么是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
❌