普通视图

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

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

作者 不会js
2025年12月12日 11:35

从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单

大家好,今天用一个最经典的 Todos 应用,来带大家彻底搞清楚:

「为什么我们不再手动操作 DOM?Vue 到底替我们做了什么?」

很多初学者看完 Vue 文档后,会觉得「好像很简单啊」,但真正自己写的时候,又会不自觉地回到原来的命令式写法:

document.getElementById('app').innerHTML = xxx

这篇文章将通过一个逐步演进的过程,让你从「机械式 DOM 操作」进化到「数据驱动」的现代 Vue3 开发思维,彻底领悟响应式编程的魅力。

一、原生 JS 写 Todos:痛并痛苦着

先来看看传统写法(很多人还在这么写):

<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) return;
    app.innerHTML = todo; // 只能显示最后一个!
  })
</script>

这代码能跑,但问题一大堆:

  • 只能显示一条任务(innerHTML 被覆盖)
  • 要实现多条任务、删除、完成状态……需要写几百行 DOM 操作
  • 一旦需求变动,改起来就是灾难

这就是典型的命令式编程:我们的大脑一直在想「我要先找到哪个元素,然后怎么改它」。

而 Vue 的核心思想是:别管 DOM,你只管数据就行。

二、Vue3 + Composition API 完整实现

03998dfb2be956b19c909a672ec27e78.jpg

<!-- App.vue -->
<script setup>
import { ref, computed } from 'vue'

// 1. 响应式数据(重点!)
const title = ref('') // 输入框内容
const todos = ref([
  { id: 1, title: '吃饭', done: false },
  { id: 2, title: '睡觉', done: true }
])

// 2. 计算属性:统计未完成任务数量(带缓存!)
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 3. 添加任务
const addTodo = () => {
  if (!title.value.trim()) return
  
  todos.value.push({
    id: Date.now(), // 推荐用时间戳,比 Math.random() 更可靠
    title: title.value.trim(),
    done: false
  })
  title.value = '' // 清空输入框
}

// 4. 高级技巧:全选/全不选(computed 的 getter + setter)
const allDone = computed({
  get() {
    if (todos.value.length === 0) return false
    return todos.value.every(todo => todo.done)
  },
  set(value) {
    todos.value.forEach(todo => {
      todo.done = value
    })
  }
})
</script>

<template>
  <div class="todos">
    <h2>我的任务清单</h2>
    
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="今天要做什么?按回车添加"
      class="input"
    />

    <!-- 任务列表 -->
    <ul v-if="todos.length" class="todo-list">
      <li v-for="todo in todos" :key="todo.id" class="todo-item">
        <input type="checkbox" v-model="todo.done">
        <span :class="{ done: todo.done }">{{ todo.title }}</span>
      </li>
    </ul>
    
    <div v-else class="empty">
      🎉 暂无任务,休息一下吧~
    </div>

    <!-- 统计 + 全选 -->
    <div class="footer">
      <label>
        <input type="checkbox" v-model="allDone">
        全选
      </label>
      <span>未完成:{{ active }} / 总数:{{ todos.length }}</span>
    </div>
  </div>
</template>

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

三、核心知识点深度拆解(建议反复看)

1. ref() 是如何做到响应式的?

const title = ref('')

这句话背后发生了什么?

  • Vue 在内部为 title 创建了一个响应式对象
  • 真正的数据存在 title.value 中
  • 当你读取 title.value 时,Vue 会记录「当前组件依赖了这个数据」
  • 当你修改 title.value 时,Vue 知道「哪些组件需要重新渲染」,自动更新 DOM

这就叫「依赖收集 + 自动更新」,你完全不用管 DOM!

2. 为什么 computed 比普通函数香?

// 普通函数写法(每次都会计算!)
const activeCount = () => todos.value.filter(...).length

// computed 写法(只有依赖变化才重新计算)
const active = computed(() => todos.value.filter(...).length)

性能差异巨大!当你有 1000 条任务时,普通函数会在每次渲染都执行 1000 次过滤,而 computed 可能只执行一次。

3. computed 的 getter + setter 神技(90%的人不知道)

const allDone = computed({
  get() {
    // 如果todos为空,返回false
    if (todos.value.length === 0) return false;
    // 如果所有todo都完成,返回true
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    // 设置所有todo的done状态
    todos.value.forEach(todo => {
      todo.done = value;
    });
  }
})

这才是真正的「双向计算属性」!点击全选框时,v-model 会自动调用 setter,把所有任务的 done 状态同步修改。

4. v-for 一定要写 :key!不然会出大问题

<li v-for="todo in todos" :key="todo.id">

不写 key 的后果:

  • Vue 无法准确判断哪条数据变了,会导致整张列表重绘
  • 输入框焦点丢失、动画错乱、状态错位

推荐 key 使用:

id: Date.now() + Math.random() // 更稳妥
// 或使用 uuid 库

5. v-model 本质是 :value + @input 的语法糖

Vue 的双向绑定(v-model) = 数据 → 视图 的绑定 + 视图 → 数据的绑定

它让「数据」和「表单元素的值」始终保持同步,你改数据,界面自动更新;你改输入框,数据也自动更新。

<input v-model="title">
<!-- 等价于 -->
<input :value="title" @input="title = $event.target.value">

拆解一下:

方向 对应指令 作用
数据 → 视图 :value="msg" 把 msg 的值渲染到 input 上
视图 → 数据 @input="msg = $event.target.value" 用户输入时,把值重新赋值给 msg

而 @keydown.enter 是 Vue 提供的键位修饰符,超级好用:

@keydown.enter="addTodo"
@keydown.ctrl.enter="addTodo"
@click.prevent="submit" <!-- 阻止默认行为 -->

四、常见坑位避雷指南(血泪经验)

场景 错误写法 正确写法 说明
添加任务后输入框不清空 没重置 title.value title.value = '' v-model 是双向绑定,必须手动清空
全选状态不同步 用普通变量控制 用 computed({get,set}) 普通变量无法响应所有任务的变化
key 使用 index :key="index" :key="todo.id" index 会导致状态错乱
id 使用 Math.random() id: Math.random() id: Date.now() 可能重复,尤其快速添加时
computed 忘记 .value return todos.filter(...) return todos.value.filter(...) script setup 中 ref 要加 .value

五、细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

computed 是如何做到「又快又省」的?

一句话结论:
computed 只有在它的「依赖」真正发生变化时,才会重新计算一次,其他所有时间直接返回缓存结果。

这才是它比普通方法快 10~100 倍的根本原因!

一、最直观的对比实验
<script setup>
import { ref, computed } from 'vue'

const a = ref(1)
const b = ref(10)

// 场景1:普通方法(每次渲染都重新算)
const sum1 = () => {
  console.log('普通方法被调用了') 
  return a.value + b.value
}

// 场景2:computed(只有依赖变了才算)
const sum2 = computed(() => {
  console.log('computed 被调用了')
  return a.value + b.value
})
</script>

<template>
  <p>普通方法:{{ sum1() }}</p>
  <p>computed:{{ sum2 }}</p>
  <button @click="a++">a + 1</button>
  <button @click="b++">b + 1</button>
</template>

你会看到:

操作 普通方法打印几次 computed 打印几次
页面首次渲染 1 次 1 次
点击 a++ 再次打印 再次打印
点击 b++ 再次打印 再次打印
页面任意地方触发渲染(比如父组件更新) 又打印! 不打印!(直接用缓存)

这就是「缓存」带来的性能飞跃!

Vue 内部到底是怎么实现这个缓存的?(底层逻辑)

Vue 用了一个经典的「脏检查 + 依赖收集」机制(Vue3 用 Proxy 更优雅,但原理一致):

步骤 发生了什么
1. 创建 computed Vue 创建一个「计算属性对象」,里面有个 value(缓存值)和 dirty(是否脏)标志」
2. 第一次读取 computed 执行计算函数 → 同时收集所有用到的响应式数据(a、b、todos.length 等)作为依赖
3. 把依赖和这个 computed 关联起来 a.effect.deps.push(computed)
4. 依赖变化时 Vue 把这个 computed 的 dirty 标志设为 true(表示缓存失效了)
5. 下一次读取时 发现 dirty = true → 重新执行计算函数 → 更新缓存 → dirty = false
6. 之后再读取 dirty = false → 直接返回缓存值,不执行函数

图解:

首次读取 computed
     ↓
执行计算函数 → 依赖收集(记录依赖了 a 和 b)
     ↓
把结果缓存起来,dirty = false

a.value = 999(依赖变化)
     ↓
Vue 自动把所有依赖了 a 的 computed 的 dirty 设为 true

下次读取 computed
     ↓
发现 dirty = true → 重新计算 → 更新缓存 → dirty = false
哪些情况会打破缓存?(常见坑)
情况 是否重新计算 说明
依赖的 ref/reactive 变了 正常触发
依赖的普通变量(let num = 1) 不是响应式的!永远只算一次(大坑!)
依赖了 props props 也是响应式的
依赖了 store.state(Pinia/Vuex) store 是响应式的
依赖了 route.params $route 是响应式的(Vue Router 注入)
依赖了 window.innerWidth 不是响应式!要配合 watchEffectScope 手动处理
实战避雷清单
错误写法 正确写法 后果
computed(() => Date.now()) 改成普通方法或用 ref(new Date()) + watch 每一次读取都重新计算,缓存失效
computed(() => Math.random()) 同上 永远不缓存,性能灾难
computed(() => props.list.length) 完全正确 推荐写法
computed(() => JSON.parse(JSON.stringify(todos.value))) 不要这么做,深拷贝太重 浪费性能
六、一句话记住

computed 的高性能秘诀只有 8 个字:
「依赖不变,绝不重新计算」

现在你再也不用担心「用 computed 会不会影响性能」了,反而应该大胆用!
因为它比你手写任何缓存逻辑都要聪明、都要快!

六、总结:从「操作 DOM」到「操作数据」的思维跃迁

传统 JS 思维 Vue 响应式思维
先找元素 → 再改 innerHTML 只改数据 → Vue 自动更新 DOM
手动 addEventListener 用 v-model / @event 声明式绑定
手动计算未完成数量 用 computed 自动计算 + 缓存
全选要遍历 DOM 用 computed setter 一行搞定

当你真正理解了「数据驱动视图」后,你会发现:

写 Vue 代码不再是「怎么操作页面」,而是「数据怎么变化。

这才是现代前端开发的正确姿势!

昨天以前首页

彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析

作者 不会js
2025年12月9日 23:36

彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析

在面试中,「手写 new 的实现」和「arguments 到底是个啥」几乎是中高级前端的必考题。
今天我们不背答案,而是把它们彻底拆开,看看 JavaScript 引擎在底层到底做了什么。

f7afe1f6c5914b92044e39cfb1e0cf81.jpg

一、new 运算符到底干了哪四件事?

当你写下这行代码时:

const p =new Person('柯基', 18);

JavaScript 引擎默默为你做了 4 件大事:

  1. 创建一个全新的空对象 {}
  2. 把这个空对象的 __proto__ 指向构造函数的 prototype
  3. 让构造函数的 this 指向这个新对象,并执行构造函数(传入参数)
  4. 自动返回这个对象(除非构造函数显式返回了一个对象)

这就是传说中的“new 的四步走”。

很多人背得滚瓜烂熟,但真正问他为什么 __proto__ 要指向 prototype?为什么不能直接 obj.prototype = Constructor.prototype?就懵了。

关键提醒(易错点!)

// 错误写法!千万别这样写!
obj.prototype = Constructor.prototype;

// 正确写法
obj.__proto__ = Constructor.prototype;

因为 prototype 是构造函数才有的属性,实例对象根本没有 prototype
所有对象都有 __proto__(非标准,已被 [[Prototype]] 内部槽替代,现代浏览器用 Object.getPrototypeOf),它是用来查找原型链的。

手撕一个完美版 new

function myNew(Constructor, ...args) {
    // 1. 创建一个空对象
    const obj = Object.create(Constructor.prototype);
    
    // 2 & 3. 执行构造函数,绑定 this,并传入参数
    const result = Constructor.apply(obj, args);
    
    // 4. 如果构造函数返回的是对象,则返回它,否则返回我们创建的 obj
    return result instanceof Object ? result : obj;
}

为什么这里用 Object.create(Constructor.prototype) 而不是 new Object() + 设置 __proto__

因为 Object.create(proto) 是最纯粹、最推荐的建立原型链的方式,比手动操作 __proto__ 更现代、更安全。

验证一下

function Dog(name, age) {
    this.name = name;
    this.age = age;
}
Dog.prototype.bark = function() {
    console.log(`${this.name} 汪汪汪!`);
};

const dog1 = new Dog('小黑', 2);
const dog2 = myNew(Dog, '大黄', 3);

dog1.bark(); // 小黑 汪汪汪!
dog2.bark(); // 大黄 汪汪汪!
console.log(dog2 instanceof Dog); // true
console.log(Object.getPrototypeOf(dog2) === Dog.prototype); // true

完美复刻!

二、arguments 是个什么鬼?

你可能写过无数次函数,却不知道 arguments 到底是个啥玩意儿。

function add(a, b, c) {
    console.log(arguments); 
    // Arguments(5) [1, 2, 3, 4, 5, callee: ƒ, Symbol(Symbol.iterator): ƒ]
}
add(1,2,3,4,5);

打印出来长得像数组,但其实不是!

类数组(Array-like)的三大特征

  1. length 属性
  2. 可以用数字索引访问 arguments[0]、arguments[1]...
  3. 不是真正的数组,没有 map、reduce、forEach 等方法

经典面试题:怎么把 arguments 变成真数组?

5 种方式,从老到新:

function test() {
    // 方式1:Array.prototype.slice.call(arguments)
    const arr1 = Array.prototype.slice.call(arguments);
    
    // 方式2:[...arguments] 展开运算符(最优雅)
    const arr2 = [...arguments];
    
    // 方式3:Array.from(arguments)
    const arr3 = Array.from(arguments);
    
    // 方式4:用 for 循环 push(性能最好,但写法古老)
    const arr4 = [];
    for(let i = 0; i < arguments.length; i++) {
        arr4.push(arguments[i]);
    }
    
    // 方式5:Function.prototype.apply 魔术(了解即可)
    const arr5 = Array.prototype.concat.apply([], arguments);
}

推荐顺序:[...arguments] > Array.from() > 手写 for 循环

arguments 和箭头函数的恩怨情仇(超级易错!)

const fn = () => {
    console.log(arguments); // ReferenceError!
};
fn(1,2,3);

箭头函数没有自己的 arguments!它会往上层作用域找。

这是因为箭头函数没有 [[Call]] 内部方法,所以也没有 arguments 对象。

arguments.callee 已经死了

以前可以这样写递归:

// 老黄历(严格模式下报错,已废弃)
function factorial(n) {
    if (n <= 1) return 1;
    return n * arguments.callee(n - 1);
}

现在请用命名函数表达式:

const factorial = function self(n) {
    if (n <= 1) return 1;
    return n * self(n - 1);
};

三、把所有知识点串起来:实现一个支持任意参数的 sum 函数

function sum() {
    // 方案1:用 reduce(推荐)
    return [...arguments].reduce((pre, cur) => pre + cur, 0);
    
    // 方案2:经典 for 循环(性能最好)
    // let total = 0;
    // for(let i = 0; i < arguments.length; i++) {
    //     total += arguments[i];
    // }
    // return total;
}

console.log(sum(1,2,3,4,5)); // 15
console.log(sum(10, 20));    // 30
console.log(sum());          // 0

四、总结:new 和 arguments 的灵魂考点

考点 正确答案 & 易错点提醒
new 做了哪几件事? 4 步:创建对象 → 链接原型 → 绑定 this → 返回对象
obj.proto 指向谁? Constructor.prototype(不是 Constructor 本身!)
手写 new 推荐方式 Object.create(Constructor.prototype) + apply
arguments 是数组吗? 不是!是类数组对象
如何转真数组? [...arguments] 最优雅
箭头函数有 arguments 吗? 没有!会抛错
arguments.callee 已废弃,严格模式下报错

fc962ce0cd306c49bc54248e80437e81.jpg

几个细节知识点

1.arguments 到底是什么类型的数据?

通过Object.prototype.toString.call 打印出 [object Arguments]

arguments 是一个 真正的普通对象(plain object),而不是数组! 它的内部类([[Class]])是 "Arguments",这是一个 ECMAScript 规范里专门为函数参数创建的特殊内置对象

为什么它长得像数组?

因为 JS 引擎在创建 arguments 对象时,特意给它加了这些“伪装属性”:

JavaScript

arguments.length = 参数个数
arguments[0], arguments[1]... = 对应的实参
arguments[Symbol.iterator] = Array.prototype[Symbol.iterator]  // 所以可以 for...of

这就是传说中的“类数组(array-like object)”。

2.apply 不仅可以接受数组,还可以接受类数组,底层逻辑是什么?

apply 的第二个参数只要求是一个 “Array-like 对象” 或 “类数组对象”,甚至可以是任何有 length 和数字索引的对象!

JavaScript

// 官方接受的类型统称为:arguments object 或 array-like object
func.apply(thisArg, argArray)
能传什么?疯狂测试!

JavaScript

function sum() {
    return [...arguments].reduce((a,b)=>a+b);
}

// 这些全都可以被 apply 正确处理!
sum.apply(null, [1,2,3,4,5]);                    // 真数组
sum.apply(null, arguments);                     // arguments 对象
sum.apply(null, {0:1, 1:2, 2:3, length: 3});     // 自定义类数组对象
sum.apply(null, "abc");                         // 字符串!也是类数组
sum.apply(null, new Set([1,2,3]));              // 不行!Set 没有 length 和索引
sum.apply(null, {length: 5});                    // 得到 [undefined×5]

所以只要满足:

  • 有 length 属性(可转为非负整数)
  • 有 0, 1, 2... 这些数字属性

就能被 apply 正确展开!

3.[].shift.call(arguments) 到底是什么鬼?为什么能取到构造函数?

这行代码堪称“手写 new 的经典黑魔法”:

JavaScript

function myNew() {
    var Constructor = [].shift.call(arguments);
    // 现在 Constructor 就是 Person,arguments 变成了剩余参数
}
myNew(Person, '张三', 18);
一步步拆解:

JavaScript

[].shift           // Array.prototype.shift 方法
.call(arguments)   // 把 arguments 当作 this 调用 shift

shift 的作用:删除并返回数组的第一个元素

因为 arguments 是类数组,所以 Array.prototype.shift 能作用于它!

执行过程:

JavaScript

// 初始
arguments = [Person函数, '张三', 18]

// [].shift.call(arguments) 执行后:
返回 Person 函数
arguments 变成 ['张三', 18]   // 原地被修改了!

归根结底:这利用了类数组能借用数组方法的特性

所以这行代码一箭三雕:

  1. 取出构造函数
  2. 把 arguments 变成真正的剩余参数数组
  3. 不需要写 arguments[0], arguments.slice(1) 这种丑代码

最后送你一份面试加分答案模板

面试官:请手写实现 new 运算符

function myNew(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;
}

面试官:那 arguments 呢?

// 快速转换为真数组
const realArray = [...arguments];
// 或者
const realArray = Array.from(arguments);

一句「Object.create 是建立原型链最纯粹的方式」就能让面试官眼前一亮。

搞懂了 new 和 arguments,你就已经站在了 JavaScript 底层机制的肩膀上。

❌
❌