阅读视图
JavaScript性能与优化:手写实现关键优化技术
new Array() 与 Array.from() 的差异与陷阱
Vue 3 做 todos , ref 能看懂,computed 终于也懂了
从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?
从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?
大家好,今天带大家深入聊聊 React 的核心灵魂——JSX。我们会结合真实代码示例,一步步拆解 JSX 的本质、组件化开发、状态管理,以及那些容易踩坑的地方。
React 为什么这么火?因为它把前端开发从“拼 HTML + CSS + JS”的手工活,变成了“搭积木式”的组件化工程。JSX 就是那把神奇的“胶水”,让 JavaScript 里直接写 HTML-like 代码成为可能。
JSX 是什么?XML in JS 的魔法
想象一下,你在 JavaScript 代码里直接写 HTML 标签,这听起来多酷?这就是 JSX(JavaScript XML)的核心。它不是字符串,也不是真正的 HTML,而是一种语法扩展,看起来像 XML,但最终会被编译成纯 JavaScript。
为什么需要 JSX?传统前端开发,HTML、CSS、JS 三分离,逻辑和视图混在一起时很容易乱套。React 说:不,我们把一切都放进 JS 里!这样,UI 描述和逻辑紧密耦合,代码更易维护。
来看个简单对比:
-
不使用 JSX(纯 createElement):
const element = createElement('h2', null, 'JSX 是 React 中用于描述用户界面的语法扩展'); -
使用 JSX(语法糖):
const element = <h2>JSX 是 React 中用于描述用户界面的语法扩展</h2>;
明显后者更直观、可读性更高!JSX 本质上是 React.createElement 的语法糖,Babel 会帮我们编译成后者。
注:Babel 是一个开源的 JavaScript 编译器,更准确地说,是一个 转译器。它的主要作用是:把现代 JavaScript 代码(ES2015+,也就是 ES6 及更高版本)转换成向后兼容的旧版 JavaScript 代码,让这些代码能在老浏览器或旧环境中正常运行。
底层逻辑:JSX 被 Babel 转译后,生成 Virtual DOM 对象树。React 用这个虚拟树对比真实 DOM,只更新变化部分,这就是 React 高性能的秘密——Diff 算法 + 批量更新。
React vs Vue:为什么 React 更“激进”?
Vue 和 React 都是现代前端框架的代表,都支持响应式、数据绑定和组件化。但 React 更纯粹、更激进。
- Vue:模板、脚本、样式三分离(单文件组件 .vue),上手友好,双向绑定 v-model 超级方便。适合快速原型开发。
- React:一切皆 JS!JSX 把模板塞进 JS,单向数据流(props down, events up),逻辑更明确,但学习曲线陡峭。
React 的激进在于:它不提供“开箱即用”的模板语法,而是让你用完整的 JavaScript 能力构建 UI。你可以用 if、map、变量等原生 JS 控制渲染,而 Vue 模板需要指令(如 v-if、v-for)。
为什么很多人说 React 入门门槛高?因为它强制你思考“组件树”和“状态流”,而不是靠模板魔法。但一旦上手,你会发现它在大型项目中更可控、更灵活。Facebook、Netflix 都在用 React,就是因为组件化让代码像乐高积木一样可复用。
组件化开发:从 DOM 树到组件树
传统前端靠 DOM 操作,审查元素是层层 div。React 说:不,我们用组件树代替 DOM 树!
组件是 React 的基本单位,每个组件是一个函数(现代 React 推荐函数组件),返回 JSX 描述 UI。
来看一个模拟掘金首页的例子:
function JuejinHeader() {
return (
<header>
<h1>掘金的首页</h1>
</header>
);
}
const Articles = () => <main>Articles</main>;
function App() {
return (
<div>
<JuejinHeader />
<main>
<Articles />
<aside>{/* 侧边栏组件 */}</aside>
</main>
</div>
);
}
这里,App 是根组件,组合了子组件。就像包工头分工:Header 负责头部,Articles 负责文章列表。页面就是这些组件搭起来的!
这张图的核心就是:把复杂 UI 拆分成组件树,每个组件专注自己的事,通过组合构建整个页面。
关键点:组件复用
- 你会注意到 FancyText 出现了两次(一个直接在 App 下,一个在 InspirationGenerator 下)。
- 这就是在强调:同一个组件可以被多个父组件多次渲染和复用!这正是 React 组件化开发的强大之处——写一次,到处用,像乐高积木一样组合。
为什么函数做组件? 因为函数纯净、无副作用,能完美封装 UI + 逻辑 + 状态。类组件(旧方式)有 this 绑定问题,函数组件 + Hooks 解决了这一切。
底层逻辑:React 渲染时,会递归调用每个组件的 render 函数,最终生成一棵完整的 Virtual DOM 树。也就是说每个组件渲染生成 Virtual DOM 片段,React 合并成一棵大树。更新时,只重渲染变化的组件子树。
useState:让函数组件拥有“记忆”
组件需要交互?就需要状态!useState 是 Hooks 的入门王牌。
import { useState } from 'react';
function App() {
const [name, setName] = useState('vue'); // 初始值 'vue'
const [todos, setTodos] = useState([
{ id: 1, title: '学习 React', done: false },
{ id: 2, title: '学习 Node', done: false }
]);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const toggleLogin = () => setIsLoggedIn(!isLoggedIn);
setTimeout(() => setName('react'), 3000); // 3秒后自动更新
return (
<>
<h1>Hello <span className="title">{name}</span></h1>
{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>
</>
);
}
useState 返回 [状态值, 更新函数]。调用更新函数会触发重渲染,React 记住最新状态。
函数组件 + Hooks,代码更简洁、复用性更强。常见 Hooks:
- useState:管理状态
- useEffect:处理副作用(数据获取、订阅等)
- useContext、useReducer、useRef 等
易错提醒:
setState 是异步的!多个 setState 可能批处理,不要直接依赖旧值。
// 错:可能加1多次只加1
setCount(count + 1);
// 对:函数式更新
setCount(prev => prev + 1);
-
在同一个事件(如 onClick)里,React 不会立即更新状态,而是把所有 setCount 调用收集到一个队列里。
-
所有 setCount 执行时,看到的 count 都是当前渲染的“快照值” (这里是 0)。
-
等事件结束,React 一次性处理队列:两次都是 “0 + 1 = 1”,最后覆盖成同一个值 1,只重渲染一次。
对象状态更新不会自动合并,用展开运算符:
错的例子:直接替换对象,会丢失属性
假设初始状态是一个对象:
const [person, setPerson] = useState({
name: 'Alice',
age: 30,
city: 'Beijing'
});
如果你想只改 age:
// 错!直接传新对象
setPerson({ age: 35 });
结果:新状态变成 { age: 35 },name 和 city 全没了!因为 React 直接用你传的对象替换了整个状态。
正确的做法:用展开运算符(...prev)手动合并
jsx
// 对!函数式更新 + 展开运算符
setPerson(prev => ({ ...prev, age: 35 }));
这里发生了什么?
- prev 是当前最新的状态对象({ name: 'Alice', age: 30, city: 'Beijing' })
- { ...prev }:用 ES6 展开运算符把 prev 的所有属性复制到一个新对象里 → { name: 'Alice', age: 30, city: 'Beijing' }
- age: 35:覆盖 age 属性
- 最终返回新对象:{ name: 'Alice', age: 35, city: 'Beijing' }
完美!只改了 age,其他属性保留了。
JSX 常见坑与最佳实践
JSX 强大,但也有陷阱:
-
class → className:class 是 JS 关键字,必须用 className。
<div className="title">错误会报错!</div> -
最外层必须单根元素:
JSX 的 return 必须返回一个元素(或 null),不能直接返回多个并列元素。
错的:
return (
<h1>标题</h1>
<p>段落</p> // 报错!Adjacent JSX elements must be wrapped...
);
因为 JSX 最终转译成 React.createElement 调用,而函数返回值只能是一个表达式。
正确做法:用 Fragment <> </> 包裹(不渲染多余 DOM)
return (
<> {/* 短语法 */}
<h1>标题</h1>
<p>段落</p>
</>
);
// 或
return (
<React.Fragment>
<h1>标题</h1>
<p>段落</p>
</React.Fragment>
);
3. 表达式用 {}:插值、条件、三元、map 都用大括号。
jsx {condition ? <A /> : <B />}
-
key 必加:列表渲染 map 时,加唯一 key,帮助 React 高效 Diff。
{todos.map(todo => <li key={todo.id}>...</li>)}缺 key 会警告,性能差。
-
事件用 camelCase:onClick,不是 onclick。
-
自闭合标签:单标签必须闭合,如
<img />。
根组件挂载:从 main.jsx 看 React 启动
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<App />
);
1. 这段代码在干啥?一步步拆解
-
document.getElementById('root') :找到 HTML 文件里的挂载点。通常 index.html 有个
,React 会把整个应用塞进去,接管这个 div 里的所有内容。 -
createRoot(...) :创建 React 的“根”(Root)。它返回一个 Root 对象,这个对象负责管理整个组件树和 DOM 更新。
-
.render() :告诉 React:“嘿,从现在开始渲染 App 组件吧!”React 会从 App 开始递归渲染组件树,生成 Virtual DOM,最终 commit 到真实 DOM。
整个过程:创建根 → 初始渲染 → 接管 DOM。应用启动后,React 就完全掌控了 #root 里的 UI。
总结:为什么选择 React 和 JSX?
JSX 让 React 成为“全栈 JS”的代表:逻辑、视图、状态全在 JS 里。组件化让你像建筑师一样设计页面,useState 等 Hooks 让函数组件强大无比。
相比 Vue,React 更适合大型、复杂应用(生态丰富,TypeScript 支持一流)。但 Vue 上手更快,适合中小项目。
学 React,不是学语法,而是学“声明式编程”和“组件思维”。掌握 JSX,你就掌握了 React 的半壁江山。
一文带你掌握 JSONP:从 Script 标签到手写实现
一、JSONP 是什么?用来做什么?
JSONP(JSON with Padding)诞生于 CORS 尚未普及的年代,是前端解决 “跨域 GET 请求” 的鼻祖级方案。核心思想:
利用
<script>标签没有同源限制的特性,让服务器把数据“包”成一段 JavaScript 函数调用返回,浏览器执行后即可拿到数据。
- 只能发 GET
- 兼容 IE6+
- 无需任何浏览器插件或 CORS 配置
在现代前端,JSONP 已逐渐被 CORS 取代,但仍在 老旧系统、第三方统计脚本、CDN 回调 等场景活跃,同时也是 面试常考题。
二、Script 标签及其属性回顾
| 属性 | 作用 | 对 JSONP 的影响 |
|---|---|---|
src |
发起 GET 请求加载外部 JS | 核心字段,承载接口地址 + 查询参数 |
async |
异步加载,不保证执行顺序 | 默认行为,JSONP 无需顺序 |
defer |
异步但 DOM 后再执行 | 一般不用,防止延迟 |
crossorigin |
开启 CORS 错误详情 | JSONP 不需要,否则报错 |
onload / onerror
|
监听加载成功/失败 | 可用来做 超时/异常 处理 |
关键特性:
-
<script src="xxx">不受同源限制 - 下载完成后立即在全局作用域执行
- 不会把响应文本暴露给 JS,只能靠“执行后的副作用”拿数据
三、Callback 是怎么传递与执行的?
① 传递:前端 → 后端
- 前端生成全局唯一函数名(如
jsonp_1710000000000) - 把函数名作为 GET 查询参数拼到 script 的 src:
https://api.example.com/jsonp?callback=jsonp_1710000000000&id=123 - 在 window 上挂同名函数:
window[jsonp_1710000000000] = function (data) { /* 处理数据 */ };
② 执行:后端 → 浏览器
- 服务器读取
req.query.callback(即jsonp_1710000000000) - 把数据包进该函数名,返回一段可执行 JS:
响应体:Content-Type: text/javascriptjsonp_1710000000000({"name": "jsonp-demo"}); - 浏览器下载完后立即在全局作用域执行上述代码 →
函数被调用,参数即为数据,副作用完成。
③ 清理:前端自己
执行完立即 delete window[jsonp_1710000000000] 并移除 <script>,防止堆积。
四、手写一个简洁版 JSONP(含超时 + 错误)
function jsonp(url, data = {}, timeout = 7000) {
return new Promise((resolve, reject) => {
const cb = `jp_${Date.now()}`;
const script = document.createElement('script');
const timer = setTimeout(() => cleanup(reject('timeout')), timeout);
window[cb] = (data) => cleanup(resolve(data));
function cleanup(fn) {
clearTimeout(timer);
script.remove();
delete window[cb];
fn();
}
script.onerror = () => cleanup(reject('script error'));
script.src = `${url}${url.includes('?') ? '&' : '?'}callback=${cb}&${new URLSearchParams(data)}`;
document.head.appendChild(script);
});
}
/* 使用 */
jsonp('https://api.example.com/jsonp', { id: 123 })
.then(console.log) // { id: '123', name: 'jsonp-demo' }
.catch(console.error);
五、常见问题与坑
| 问题 | 原因 | 解决 |
|---|---|---|
| 返回纯 JSON 报语法错 |
<script> 期望 JS 而非 JSON |
服务器务必返回 callback(JSON);
|
| 无法捕捉 HTTP 状态码 |
<script> 只有 onload/onerror
|
靠 onerror + 超时做模糊失败处理 |
| 只能 GET |
<script> 天生 GET |
换 CORS 或代理 |
| 回调名冲突 | 全局变量重名 | 使用时间戳+随机数唯一化 |
六、今天还用 JSONP 吗?
- 新项目:优先 CORS,简单、标准、支持所有 HTTP 方法
- 老系统/统计脚本/CDN:JSONP 仍活跃,零配置跨域不可替代
- 面试:手写 JSONP 是高频手写题,考察 Promise + Script 加载 + 全局回调 综合功底
七、一句话总结
JSONP = <script> 无同源限制 + 服务器包成 JS 函数调用 + 全局回调收数据
“下载即执行,执行即回调”——掌握它,跨域历史就懂了一半!
# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心
深入理解JavaScript原型系统核心
📖 目录
🎯 核心概念
四大基本原则
-
原则一:每个对象都有构造函数(constructor)
- 指向构建该对象或实例的函数
-
原则二:只有函数对象才有prototype属性
- 非函数对象没有prototype属性
- 实例只有
__proto__属性 - 两者指向同一个对象(函数的原型对象)
-
原则三:Function函数是所有函数的构造函数
- 包括它自己
- 代码中声明的所有函数都是Function的实例
-
原则四:Object也是函数
- 所以Object也是Function函数的实例
实例,函数,对象,原型对象,构造函数,关系总览图
🔍 非函数对象分类
- 实例对象,
const person = new Foo(),person就是实例对象 - 普通对象(
{}或new Object()) - 内置非函数对象实例
🔄 显式原型与隐式原型
对象分类
-
函数对象:拥有
prototype属性 -
非函数对象:只有
__proto__属性
相同点
- 都指向同一个原型对象
📝 示例代码
function Person(){}
const person = new Person();
console.log("Person.prototype指向:", Person.prototype)
console.log("person.__proto__指向", person.__proto__)
🖼️ 执行结果
🎯 构造函数的指向
默认情况
function Person(){}
const person = new Person();
console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: Person]
执行结果
修改原型对象后
function Person(){}
const person = new Person();
Person.prototype = new foo(); // 修改原型对象
console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: foo]
执行结果
📊 核心原理说明
解释
Person.prototype被当作函数foo的实例,继承了foo函数(此篇不展开继承详解)
总结规律
- 每个原型对象或实例都有
.constructor属性 - 实例通过原型链查找constructor
- 原型对象默认指向自身的函数(如果不是其他函数的实例)
查找过程示例
// Person.prototype被当作实例时
Person.prototype.__proto__ → foo.prototype → foo()
🖼️ 可视化关系图
三者关系图
🔬 代码验证
function Person(){}
// 创建新的原型对象
Person.prototype = {
name: "杨",
age: "18",
histype: "sleep"
}
// 添加方法
Person.prototype.print = function(){
console.log("你好我是原型对象");
}
// 创建实例
const person01 = new Person();
const person02 = new Person();
// 验证指向
console.log("Person.prototype指向:", Person.prototype)
console.log("person01.__proto__指向", person01.__proto__)
console.log("person02.__proto__指向", person02.__proto__)
console.log("Person.prototype.constructor指向", Person.prototype.constructor)
执行结果
⚠️ 特别说明
关键细节
创建新对象时,Person.prototype.constructor指向Object,因为Person.prototype成了Object的实例。
对比情况
-
创建新对象时:
Person.prototype.constructor→Object -
未创建新对象时:
Person.prototype.constructor→Person
示意图
Function和Object
小故事
从前有个力大无穷的大力神,能举起任何东西,有一天,小A在路上和这个大力神相遇了。
大力神:小子,我可是力大无穷的大力神,我能举起任何东西,你信不信?
小A:呦呦呦,还大力神,你说你能举起任何东西,那你能把你自己抬起来吗?
...
-
Function是所有函数的加工厂,你在代码声明的所有函数都是Function的实例,包括Function函数本身,Object也是函数,所以它也是Functiod的实例
-
Function就是这样的大力神,而且是可以把自己抬起来的大力神,这听起来比较扯,但是这就是事实,请看VCR:
function Person (){}
const person01 = new Person();
console.log("Function.__proto__指向",Function.__proto__)//Function.__proto__指向 [Function (anonymous)] Object
console.log("Function.prototype指向",Function.prototype)//Function.prototype指向 [Function (anonymous)] Object
console.log("Function.__proto__ == Function.prototype???",Function.__proto__ == Function.prototype)
//Function.__proto__ == Function.prototype??? true
Object 在 JavaScript 中扮演三重角色:
-
构造函数:用于创建对象
-
命名空间:提供一系列静态方法用于对象操作
-
原型终点:Object.prototype 是所有原型链的终点,在往上没有了,值==null
请看VCR:
function Person (){};
const persoon01 = new Person();
const obj = {};//通过对象字面量{}创建obj实例
const obj1 = new Object();//通过构造函数new Object()创建obj1实例
const obj2 = Object.create(Object.prototype);//通过委托创建,或者叫原型创建,来创建obj2实例
console.log("Person.prototype.__proto__指向",Person.prototype.__proto__);
//Person.prototype.__proto__指向 [Object: null prototype] {}
console.log("Function.prototype.__proto__指向",Function.prototype.__proto__)
//Function.prototype.__proto__指向 [Object: null prototype] {}
console.log("通过对象字面量{}创建的obj实例,obj.__proto__指向",obj.__proto__);
//通过对象字面量{}创建的obj实例,obj.__proto__指向 [Object: null prototype] {}
console.log("通过构造函数new Object()创建obj1实例,指向",obj1.__proto__);
//通过构造函数new Object()创建obj1实例,指向 [Object: null prototype] {}
console.log("通过委托创建,或者叫原型创建,来创建obj2实例,指向",obj2.__proto__);
//通过委托创建,或者叫原型创建,来创建obj2实例,指向 [Object: null prototype] {}
Function和Object的关系
- 相互依赖的循环引用
-
Object 是 Function 的实例(构造函数层面)
-
Function 是 Object 的子类(原型继承层面)
-
这是 JavaScript 的自举(Bootstrap)机制
-
根据关系总览图,我们可以看到,Function和Object,它们两形成了一个闭环,将所有的函数和对象都包裹在这个闭环里
📋 JavaScript 原型系统核心概念表
| 概念 | 描述 | 示例 | 特殊说明 |
|---|---|---|---|
| prototype | 函数特有,指向原型对象 | Person.prototype |
只有函数对象才有此属性 |
| proto | 所有对象都有,指向构造函数的原型 | person.__proto__ |
实际应使用 Object.getPrototypeOf()
|
| constructor | 指向创建该对象的构造函数 | Person.prototype.constructor |
可被修改,查找时沿原型链进行 |
| 原型链查找 | 通过 __proto__ 逐级向上查找 |
person.__proto__.__proto__ |
终点为 null
|
| Function | 所有函数的构造函数 | Function.prototype |
Function.__proto__ === Function.prototype |
| Object | 所有对象的基类 | Object.prototype |
原型链终点,Object.prototype.__proto__ === null
|
🔍 补充说明
prototype 补充
- 函数的
prototype属性默认包含constructor属性指向函数自身 - 用于实现基于原型的继承
proto 补充
- 现在更推荐使用
Object.getPrototypeOf(obj)和Object.setPrototypeOf(obj, proto) -
__proto__是访问器属性,不是数据属性
constructor 补充
-
constructor属性可以通过原型链查找 - 示例:
person.constructor === Person(实际查找的是person.__proto__.constructor)
原型链查找补充
- 当访问对象属性时,如果对象自身没有,会沿着原型链向上查找
- 直到找到该属性或到达原型链终点
null
Function 补充
- 是所有函数的构造函数,包括内置构造函数(Object、Array等)和自定义函数
- 自身也是函数,所以
Function.__proto__ === Function.prototype
Object 补充
-
Object.prototype是所有原型链的最终原型对象 - 通过
Object.create(null)可以创建没有原型的"纯净对象"
💡 记忆口诀
- 函数看prototype,实例看__proto__
- constructor找根源,原型链上寻答案
- Object是终点,Function是关键
结语:
看完这篇文章,你应该可以读懂上面的关系总览图了,望学习愉快!!!
从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单
从原生 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 完整实现
<!-- 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 |
五、细节知识点
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 代码不再是「怎么操作页面」,而是「数据怎么变化。
这才是现代前端开发的正确姿势!
90%前端都踩过的JS内存黑洞:从《你不知道的JavaScript》解锁底层逻辑与避坑指南
从后端拼模板到 Vue 响应式:前端界面的三次进化
从后端拼模板到 Vue 响应式:一场前端界面的进化史
当开始学习前端开发时,很多人都会遇到一个共同的困惑:
为什么有的项目让后端直接返回 HTML?
为什么后来大家都开始使用 fetch 拉取 JSON?
而现在又流行 Vue 的响应式界面,几乎不再手动操作 DOM?
这些不同的方式看似杂乱,其实背后隐藏着一条非常清晰的技术发展路径。后端渲染 → 前端渲染 → 响应式渲染它们不是独立出现的,而是前端能力逐步增强、分工越来越明确后的必然产物。
1. 🌱 第一阶段:后端拼模板 —— “厨师把菜做好端到你桌上”
让我们从最初的 Node.js 服务器代码说起。
const http = require("http");// Node.js 内置模块,用于创建 HTTP 服务器或客户端
const url = require("url");// 用于解析 URL
const users = [
{ id: 1, name: '张三', email: '123@qq.com' },
{ id: 2, name: '李四', email: '1232@qq.com'},
{ id: 3, name: '王五', email: '121@qq.com' }
];
// 将 `users` 数组转换为 HTML 表格字符串
function generateUserHTML(users){
const userRows = users.map(user => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
`).join('');
return `
<html>
<body>
<h1>Users</h1>
<table>
<tbody>${userRows}</tbody>
</table>
</body>
</html>
`;
}
// 创建一个 HTTP 服务器,传入请求处理函数
const server = http.createServer((req, res) => {
if(req.url === '/' || req.url === '/users'){
res.setHeader('Content-Type', 'text/html;charset=utf-8');
res.end(generateUserHTML(users));
}
});
// 服务器监听本地 1314 端口。
server.listen(1314);
访问1314端口,得到的结果:
这段代码非常典型,体现了早期 Web 的模式:
- 用户访问
/users - 后端读取数据
- 后端拼出 HTML
- 后端把完整页面返回给浏览器
你可以把它理解成:
用户去餐馆点菜 → 厨房(后端)把菜做好 → 端到你桌上(浏览器)
整个过程中,浏览器不参与任何加工,它只是“展示已经做好的菜”。
🔍 后端拼模板的特点
| 特点 | 说明 |
|---|---|
| 后端掌控视图 | HTML 是后端生成的 |
| 数据和页面耦合在一起 | 改数据就要改 HTML 结构 |
| 刷新页面获取新数据 | 无法局部更新 |
| 用户体验一般 | 交互不够流畅 |
这种方式在早期 Web 非常普遍,就是典型的 MVC:
- M(Model): 数据
- V(View): HTML 模板
- C(Controller): 拼 HTML,返回给浏览器
“后端拼模板”就像饭店里:
- 厨师(后端)把所有食材(数据)做成菜(HTML)
- 顾客(浏览器)只能被动接受
这当然能吃饱,但吃得不灵活。
为了吃一个小菜,还要大厨重新做一桌菜!
这就导致页面每个小变化都得刷新整个页面。
2. 🌿 第二阶段:前后端分离 —— “厨师只给食材,顾客自己配菜”
随着前端能力提升,人们发现:
让后端拼页面太麻烦了。
于是产生了 前后端分离。
🔸 后端从“做菜”变成“送食材”(只返回 JSON)
{
"users": [
{ "id": 1, "name": "张三", "email": "123@qq.com" },
{ "id": 2, "name": "李四", "email": "1232@qq.com" },
{ "id": 3, "name": "王五", "email": "121@qq.com" }
]
}
JSON Server 会把它变成一个 API:
GET http://localhost:3000/users
访问该端口得到:
访问时返回纯数据,而不再返回 HTML。
🔸 前端浏览器接管“配菜”(JS 渲染 DOM)
<script>
fetch('http://localhost:3000/users')// 使用浏览器内置的 `fetch`() API 发起 HTTP 请求。
.then(res => res.json())// 解析响应为 JSON
// 渲染数据到页面
.then(data => {
const tbody = document.querySelector('tbody');
tbody.innerHTML = data.map(user => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
`).join('');
});
</script>
浏览器自己:
- 发送 fetch 请求
- 拿到 JSON
- 用 JS 拼 HTML
- 填入页面
这就好比:
- 后端: 不做菜,只把干净的食材准备好(纯 JSON)
- 前端: 自己按照 UI 要求把菜炒出来(DOM 操作)
- 双方分工明确,互不干扰
这就是现代 Web 最主流的模式 —— 前后端分离。
🚧 但问题来了:DOM 编程太痛苦了
你看这段代码:
tbody.innerHTML = data.map(user => `
<tr>...</tr>
`).join('');
是不是很像在手工组装乐高积木?
DOM 操作会遇到几个痛点:
- 代码又臭又长
- 更新数据要重新操作 DOM
- 状态多了之后难以维护
- 页面结构和业务逻辑混在一起
前端工程师开始苦恼:
有没有一种方式,让页面自动根据数据变化?
于是,Vue、React、Angular 出现了。
3. 🌳 第三阶段:Vue 响应式数据驱动 —— “只要食材变化,餐盘自动变化”
Vue 的核心理念:
ref 响应式数据,将数据包装成响应式对象
界面由 {{}} v-for 进行数据驱动
专注于业务,数据的变化而不是 DOM
这是前端的终极模式 —— 响应式渲染。
🔥 Vue 的思想
Vue 做了三件事:
- 把变量变成“会被追踪的数据”(ref / reactive)
- 把 HTML 变成“模板”(用 {{ }}、v-for)
- 让数据变化自动修改 DOM
你只需要像写伪代码一样描述业务:
<script setup>
import {
ref,
onMounted // 挂载之后
} from 'vue'
const users = ref([]);
// 在挂载后获取数据
onMounted(() =>{
fetch('http://localhost:3000/users')
.then(res => res.json())
.then(data => {
users.value = data;
})
})
</script>
而页面模板:
<tr v-for="u in users" :key="u.id">
<td>{{ u.id }}</td>
<td>{{ u.name }}</td>
<td>{{ u.email }}</td>
</tr>
得到的结果为:
你不再需要:
- querySelector
- innerHTML
- DOM 操作
Vue 会自己完成这些工作。
如果 传统 DOM:
你要把所有食材手动摆到盘子里。
那么Vue:
你只需要放食材到盘子里(修改数据),
餐盘的摆盘会自动变化(界面自动更新)。
比如你修改了数组:
users.value.push({ id: 4, name: "新用户", email: "xxx@qq.com" });
页面会自动新增一行。
你删除:
users.value.splice(1, 1);
页面自动少一行。
你完全不用动 DOM。
4. 🌲 三个阶段的对比
| 阶段 | 数据从哪里来? | 谁渲染界面? | 技术特征 |
|---|---|---|---|
| 1. 后端渲染(server.js) | 后端 | 后端拼 HTML | 模板字符串、MVC |
| 2. 前端渲染(index.html + db.json) | API / JSON | 前端 JS DOM | Fetch、innerHTML |
| 3. Vue 响应式渲染 | API / JSON | Vue 自动渲染 | ref、{{}}、v-for |
本质是渲染责任的迁移:
后端渲染 → 前端手动渲染 → 前端自动渲染
最终目标只有一个:
让开发者把时间花在业务逻辑,而不是重复性 DOM 操作上。
5. 🍁 为什么现代开发必须用前后端分离 + Vue?
最后,让我们用一句最通俗的话总结:
后端拼页面像“饭店厨师包办一切”,效率低。
前端手动拼 DOM 像“自己做饭”,累到爆。
Vue 像“智能厨房”,你只需要准备食材(数据)。
Vue 的三大优势
1)极大减少开发成本
业务逻辑变简单:
users.value = newUsers;
就够了,UI 自动更新。
2)更适合大型项目
- 组件化
- 模块化
- 状态集中管理
- 可维护性高
3)用户体验更好
- 页面不刷新
- 更新局部
- 响应迅速
6. 🌏 文章总结:从“厨房”看前端的进化历史
最终,我们回到开头的类比:
| 阶段 | 类比 |
|---|---|
| 第一阶段:后端拼模板 | 厨房(后端)做好所有菜,直接端给你 |
| 第二阶段:前端渲染 | 厨房只提供食材,你自己炒 |
| 第三阶段:Vue 响应式 | 智能厨房:只要食材变,菜自动做好 |
前端技术每一次进化,都围绕同一个核心目标:
让开发者更轻松,让用户体验更好。
而你上传的代码正好构成了一个完美的演示链路:
从最原始的后端拼模板,到 fetch DOM 渲染,再到 Vue 响应式渲染。
理解了这三步,你就理解了整个现代前端技术的发展脉络。
彻底讲透浏览器的事件循环,吊打面试官
第一层:幼儿园阶段 —— 为什么要有 Event Loop?
首先要明白一个铁律:JavaScript 在浏览器中是单线程的。
想象一下:你是一家餐厅唯一的厨师(主线程)。
-
客人点了一份炒饭(同步代码),你马上炒。
-
客人点了一份需要炖3小时的汤(耗时任务,如网络请求、定时器)。
如果你只有这一个线程,还要死等汤炖好才能炒下一个菜,那餐厅早就倒闭了(页面卡死)。
所以,浏览器给你配了几个服务员(Web APIs,如定时器模块、网络模块)。
-
厨师(主线程) :只负责炒菜(执行 JS 代码)。
-
服务员(Web APIs) :负责看火炖汤(计时、HTTP请求)。汤好了,服务员把“汤好了”这个纸条贴在厨房的**任务板(队列)**上。
-
Event Loop(事件循环) :就是厨师的一个习惯——炒完手里的菜,就去看看任务板上有没有新纸条。如果有,拿下来处理。
总结:Event Loop 是单线程 JS 实现异步非阻塞的核心机制。
第二层:小学阶段 —— 宏任务与微任务的分类
任务板上的纸条分两种,优先级不同。面试官最爱问这个分类。
1. 宏任务(Macrotask / Task)
这就像是新的客人进店。每次处理完一个宏任务,厨师可能需要休息一下(浏览器渲染页面),然后再接下一个。
-
常见的:
-
script(整体代码 script 标签) -
setTimeout/setInterval -
setImmediate(Node.js/IE 环境) -
UI 渲染 / I/O
-
postMessage
2. 微任务(Microtask)
这就像是当前客人的临时加单。客人说:“我要加个荷包蛋”。厨师必须在服务下一个客人之前,先把这个客人的加单做完。不能让当前客人等着你去服务别人。
-
常见的:
-
Promise.then/.catch/.finally -
process.nextTick(Node.js,优先级最高) -
MutationObserver(监听 DOM 变化) -
queueMicrotask
第三层:中学阶段 —— 完整的执行流程(必背)
这是大多数面试题的解题公式。请背诵以下流程:
-
执行同步代码(这其实是第一个宏任务)。
-
同步代码执行完毕,Call Stack(调用栈)清空。
-
检查微任务队列:
-
如果有,依次执行所有微任务,直到队列清空。
-
注意:如果在执行微任务时又产生了新的微任务,会插队到队尾,本轮必须全部执行完,绝不留到下一轮。
-
尝试渲染 UI(浏览器会根据屏幕刷新率决定是否需要渲染,通常 16ms 一次)。
-
取出下一个宏任务执行。
-
回到第 1 步,循环往复。
口诀:同步主线程 -> 清空微任务 -> (尝试渲染) -> 下一个宏任务
第四层:大学阶段 —— 常见坑点实战(初级面试题)
这时候我们来看代码,这里有两个经典坑。
坑点 1:Promise 的构造函数是同步的
面试官常考:
new Promise((resolve) => {
console.log(1); // 同步执行!
resolve();
}).then(() => {
console.log(2); // 微任务
});
console.log(3);
JavaScriptCopy
解析:Promise 构造函数里的代码会立即执行。只有 .then 里面的才是微任务。 输出: 1 -> 3 -> 2
坑点 2:async/await 的阻塞
async function async1() {
console.log('A');
await async2(); // 关键点
console.log('B');
}
async function async2() {
console.log('C');
}
async1();
console.log('D');
JavaScriptCopy
解析:
-
async1开始,打印A。 -
执行
async2,打印C。 -
关键:遇到
await,浏览器会把await后面的代码(console.log('B'))放到微任务队列里,然后跳出async1函数,继续执行外部的同步代码。 -
打印
D。 -
同步结束,清空微任务,打印
B。 输出:A->C->D->B
第五层:博士阶段 —— 深入进阶(吊打面试官专用)
1. 为什么要有微任务?(设计哲学)
你可能知道微任务比宏任务快,但为什么? 本质原因:为了确保在下次渲染之前,更新应用的状态。 如果微任务是宏任务,那么 数据更新 -> 宏任务队列 -> 渲染 -> 宏任务执行 。这会导致页面先渲染一次旧数据,然后再执行逻辑更新,导致闪屏。 微任务保证了: 数据更新 -> 微任务(更新更多状态) -> 渲染 。所有的状态变更都在同一帧内完成。
2. 微任务的死循环(炸掉浏览器)
因为微任务必须清空才能进入下一个阶段。
function loop() {
Promise.resolve().then(loop);
}
loop();
JavaScriptCopy
后果:这会阻塞主线程!浏览器页面会卡死(点击无反应),且永远不会进行 UI 渲染。 对比:如果是 setTimeout(loop, 0) 无限递归,虽然 CPU 占用高,但浏览器依然可以响应点击,依然可以渲染页面。因为宏任务之间会给浏览器“喘息”的机会。
3. 页面渲染的时机(DOM 更新是异步的吗?)
这是一个巨大的误区。JS 修改 DOM 是同步的(内存里的 DOM 树立刻变了),但视觉上的渲染是异步的。
document.body.style.background = 'red';
document.body.style.background = 'blue';
document.body.style.background = 'black';
JavaScriptCopy
浏览器很聪明,它不会画红、画蓝、再画黑。它会等 JS 执行完,发现最后是黑色,直接画黑色。
必杀技问题:如何在宏任务执行前强制渲染? 如果你想让用户看到红色,然后再变黑,普通的 setTimeout(..., 0) 是不稳定的。 标准做法是使用 requestAnimationFrame 或者 强制回流(Reflow) (比如读取 offsetHeight )。
4. 真正的深坑:事件冒泡中的微任务顺序
这是极少数人知道的细节。
场景:父子元素都绑定点击事件。
// HTML: <div id="outer"><div id="inner">Click me</div></div>
const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');
function onClick() {
console.log('click');
Promise.resolve().then(() => console.log('promise'));
}
outer.addEventListener('click', onClick);
inner.addEventListener('click', onClick);
JavaScriptCopy
情况 A:用户点击屏幕
-
触发 inner 点击 -> 打印
click-> 微任务入队。 -
栈空了! (在冒泡到 outer 之前,当前回调结束了)。
-
检查微任务 -> 打印
promise。 -
冒泡到 outer -> 打印
click-> 微任务入队。 -
回调结束 -> 检查微任务 -> 打印
promise。 结果:click->promise->click->promise
情况 B:JS 代码触发 inner.click()
-
inner.click()这是一个同步函数! -
触发 inner 回调 -> 打印
click-> 微任务入队。 -
栈没空! (因为
inner.click()还在栈底等着冒泡结束)。 -
不能执行微任务。
-
冒泡到 outer -> 打印
click-> 微任务入队。 -
inner.click()执行完毕,栈空。 -
清空微任务(此时队列里有两个 promise)。 结果:
click->click->promise->promise
面试杀招:指出用户交互触发和程序触发在 Event Loop 中的堆栈状态不同,导致微任务执行时机不同。
第六层:上帝视角 —— 浏览器的一帧(The Frame)
要理解 React 为什么要搞 Concurrent Mode,首先要看懂**“一帧”**里到底发生了什么。
大多数屏幕是 60Hz,意味着浏览器只有 16.6ms 的时间来完成这一帧的所有工作。如果超过这个时间,页面就会掉帧(卡顿)。
完整的一帧流程(标准管线):
-
Input Events: 处理阻塞的输入事件(Touch, Wheel)。
-
JS (Macro/Micro) : 执行定时器、JS 逻辑。这里是性能瓶颈的高发区。
-
Begin Frame: 每一帧开始的信号。
-
requestAnimationFrame (rAF) : 关键点。这是 JS 在渲染前最后修改 DOM 的机会。
-
Layout (重排) : 计算元素位置(盒模型)。
-
Paint (重绘) : 填充像素。
-
Idle Period (空闲时间) : 如果上面所有事情做完还没到 16.6ms,剩下的时间就是 Idle。
关键冲突: Event Loop 的微任务(Microtasks)是在 JS 执行完立刻执行的。如果微任务队列太长,或者 JS 宏任务太久,直接把 16.6ms 撑爆了,浏览器就没机会去执行 Layout 和 Paint。 结果就是:页面卡死。
第七层:React 18 Concurrent Mode —— 时间切片(Time Slicing)
React 15(Stack Reconciler)是递归更新,一旦开始 diff 一棵大树,必须一口气做完。如果这棵树需要 100ms 计算,那这 100ms 内主线程被锁死,用户输入无响应。
React 18(Fiber 架构)引入了 可中断渲染。
1. 核心原理:把“一口气”变成“喘口气”
React 把巨大的更新任务切分成一个个小的 Fiber 节点(Unit of Work) 。
-
旧模式:JS 执行 100ms -> 渲染。 (卡顿)
-
新模式 (Concurrent) :
-
执行 5ms 任务。
-
问浏览器:“还有时间吗?有高优先级任务(如用户点击)插队吗?”
-
有插队 -> 暂停当前 React 更新,把主线程还给浏览器去处理点击/渲染。
-
没插队 -> 继续下一个 5ms。
2. 实现手段:如何“暂停”和“恢复”?(MessageChannel 的妙用)
React 必须要找一个宏任务来把控制权交还给浏览器。
-
为什么不用
setTimeout(fn, 0)? -
因为这货有 4ms 的最小延迟(由于 HTML 标准遗留问题,嵌套层级深了会强制 4ms)。对于追求极致的 React 来说,4ms 太浪费了。
-
为什么不用
Microtask? -
死穴:微任务会在页面渲染前全部清空。如果你用微任务递归,主线程还是会被锁死,根本不会把控制权交给 UI 渲染。
-
最终选择:
MessageChannel -
React Scheduler 内部创建了一个
MessageChannel。 -
当需要“让出主线程”时,React 调用
port.postMessage(null)。 -
这会产生一个宏任务。
-
因为是宏任务,浏览器有机会在两个任务之间插入 UI 渲染 和 响应用户输入。
-
且
MessageChannel的延迟极低(接近 0ms),优于setTimeout。
简化的 React Scheduler 伪代码:
let isMessageLoopRunning = false;
const channel = new MessageChannel();
const port = channel.port2;
// 这是一个宏任务回调
channel.port1.onmessage = function() {
const currentTime = performance.now();
let hasTimeRemaining = true;
// 执行任务,直到时间片用完(默认 5ms)
while (workQueue.length > 0 && hasTimeRemaining) {
performWork();
// 检查是否超时(比如超过了 5ms)
if (performance.now() - currentTime > 5) {
hasTimeRemaining = false;
}
}
if (workQueue.length > 0) {
// 如果还有活没干完,但时间片到了,
// 继续发消息,把剩下的活放到下一个宏任务里
port.postMessage(null);
} else {
isMessageLoopRunning = false;
}
};
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null); // 触发宏任务
}
}
JavaScriptCopy
第八层:Vue 3 的策略对比 —— 为什么 Vue 不需要 Fiber?
这是一个极好的对比视角。
-
React:走的是“全量推导”路线。组件更新时,默认不知道哪里变了,需要遍历树。为了不卡顿,只能用 Event Loop 切片。
-
Vue:走的是“精确依赖”路线。响应式系统(Proxy)精确知道是哪个组件变了。更新粒度很细,通常不需要像 React 那样长时间的计算。
Vue 的 Event Loop 应用: nextTick Vue 依然大量使用了 Event Loop,主要是为了批量更新(Batching) 。
count.value = 1;
count.value = 2;
count.value = 3;
JavaScriptCopy
Vue 检测到数据变化,不会渲染 3 次。它会开启一个队列,把 Watcher 推进去。然后通过 Promise.then (微任务) 或 MutationObserver 在本轮代码执行完后,一次性 flush 队列。
应用场景:当你修改了数据,想立刻获取更新后的 DOM 高度。
msg.value = 'Hello';
console.log(div.offsetHeight); // 还是旧高度!因为 DOM 更新在微任务里
await nextTick(); // 等待微任务执行完
console.log(div.offsetHeight); // 新高度
JavaScriptCopy
第九层:实战中的“精细化调度”
除了框架内部,我们在写复杂业务代码时,如何利用 Event Loop 管线进行优化?
1. requestAnimationFrame (rAF) 做动画
-
错误做法:
setTimeout做动画。 -
原因:
setTimeout也是宏任务,但它的执行时机和屏幕刷新(VSync)不同步。可能会导致一帧里执行了两次 JS,或者掉帧。 -
正确做法:
rAF。 -
它保证回调函数严格在下一次 Paint 之前执行。
-
浏览器会自动优化:如果页面切到后台,rAF 会暂停,省电。
2. requestIdleCallback 做低优先级分析
-
场景:发送埋点数据、预加载资源、大数据的后台计算。
-
原理:告诉浏览器,“等我不忙了(帧末尾有剩余时间)再执行这个”。
-
注意:React 没直接用这个 API,因为它的兼容性和触发频率不稳定,React 自己实现了一套类似的(也就是上面说的 MessageChannel 机制)。
3. 大数据列表渲染(时间切片实战)
假设后端给你返回了 10 万条数据,你要渲染到页面上。
-
直接渲染:
ul.innerHTML = list-> 页面卡死 5 秒。 -
微任务渲染:用 Promise 包裹 -> 依然卡死!因为微任务也会阻塞渲染。
-
宏任务分批(时间切片) :
function renderList(list) {
if (list.length === 0) return;
// 每次取 20 条
const chunk = list.slice(0, 20);
const remaining = list.slice(20);
// 渲染这 20 条
renderChunk(chunk);
// 关键:用 setTimeout 把剩下的放到下一帧(或之后的宏任务)去处理
// 这样浏览器就有机会在中间进行 UI 渲染,用户能看到列表慢慢变长,而不是卡死
setTimeout(() => {
renderList(remaining);
}, 0);
}
JavaScriptCopy
-
进阶:使用
requestAnimationFrame替代setTimeout,虽然 rAF 主要是为动画服务的,但在处理 DOM 批量插入时,配合DocumentFragment往往比 setTimeout 更流畅,因为它紧贴渲染管线。
第十层:未来的标准 —— scheduler.postTask
浏览器厂商发现大家都在自己搞调度(React 有 Scheduler,Vue 有 nextTick),于是 Chrome 推出了原生的 Scheduler API。
这允许你直接指定任务的优先级,而不需要玩 setTimeout 或 MessageChannel 的黑魔法。
// 只有 Chrome 目前支持较好
scheduler.postTask(doImportantWork, { priority: 'user-blocking' }); // 高优
scheduler.postTask(doAnalytics, { priority: 'background' }); // 低优
JavaScriptCopy
总结:如何回答“实际应用场景”
如果面试官问到这里,你可以这样收网:
-
管线视角:先说明 JS 执行、微任务、渲染、宏任务的流水线关系。
-
React 案例:重点描述 React 18 如何利用 宏任务 (
MessageChannel) 实现时间切片,从而打断长任务,让出主线程给 UI 渲染。 -
对比 Vue:解释 Vue 利用 微任务 (
Promise) 实现异步批量更新,避免重复计算。 -
业务落地:
-
高性能动画:必用
requestAnimationFrame保持与帧率同步。 -
海量数据渲染:手动分片,利用
setTimeout或rAF分批插入 DOM,避免白屏卡顿。 -
后台计算/埋点:利用
requestIdleCallback在浏览器空闲时处理。
终极回答策略:从机制到架构的四维阐述
1. 核心定性(不仅是单线程)
“Event Loop 是浏览器用来协调 JS 执行、DOM 渲染、用户交互 以及 网络请求 的核心调度机制。它解决了 JS 单线程无法处理高并发异步任务的问题,实现了非阻塞 I/O。”
2. 标准流程(精确到微毫秒的执行顺序)
“标准的流程是:执行栈为空 -> 清空微任务队列(Microtasks) -> 尝试进行 UI 渲染 -> 取出一个宏任务(Macrotask)执行。 这里的关键点是:微任务拥有最高优先级插队权,必须全部清空才能进入下一阶段;而UI 渲染穿插在微任务之后、宏任务之前,通常由浏览器的刷新率(60Hz)决定是否执行。”
3. 进阶:与渲染管线的结合(展示物理层面的理解)
“在性能优化中,我们要关注**‘一帧’(16.6ms)**的生命周期。 如果微任务队列太长,或者宏任务执行太久,都会阻塞浏览器的 Layout 和 Paint,导致掉帧。
4. 降维打击:框架原理与调度实战(这是加分项!)
“深刻理解 Event Loop 是理解现代框架源码的基石:
速记核心关键词
如果面试紧张,脑子里只要记住这 4 个关键词,就能串联起整个知识网:
-
单线程 (起点)
-
微任务清空 (Promise, Vue 原理)
-
渲染管线 (16ms, 动画流畅度)
-
宏任务切片 (React Fiber, 大数据分片)
从美团全栈化看 AI 冲击:前端转全栈,是自救还是必然 🤔🤔🤔
我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于
Tiptap的富文本编辑器、NestJs后端服务、AI集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了Tiptap的深度定制、性能优化和协作功能的实现等核心难点。
如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。
据 大厂日报 称,美团履约团队近期正在推行"全栈化"转型。据悉,终端组的部分前端同学在 11 月末左右转到了后端组做全栈(前后端代码一起写),主要是 agent 相关项目。内部打听了一下,团子目前全栈开发还相对靠谱,上线把控比较严格。
这一消息在技术圈引起了广泛关注,也反映了 AI 时代下前端工程师向全栈转型的必然趋势。但更重要的是,我们需要深入思考:AI 到底给前端带来了什么冲击?为什么前端转全栈成为了必然选择?
最近,前端圈里不断有"前端已死"的话语流出。有人说 AI 工具会替代前端开发,有人说低代码平台会让前端失业,还有人说前端工程师的价值正在快速下降。这些声音虽然有些极端,但确实反映了 AI 时代前端面临的真实挑战。
一、AI 对前端的冲击:挑战与机遇并存
1. 代码生成能力的冲击
冲击点:
- 低复杂度页面生成:AI 工具(如 Claude Code、Cursor)已经能够快速生成常见的 UI 组件、页面布局
- 重复性工作被替代:表单、列表、详情页等标准化页面,AI 生成效率远超人工
- 学习门槛降低:新手借助 AI 也能快速产出基础代码,前端"入门红利"消失
影响: 传统前端开发中,大量时间花在"写页面"上。AI 的出现,让这部分工作变得极其高效,甚至可以说,只会写页面的前端工程师,价值正在快速下降。这也正是"前端已死"论调的主要依据之一。
2. 业务逻辑前移的冲击
冲击点:
- AI Agent 项目激增:如美团案例中的 agent 相关项目,需要前后端一体化开发
- 实时交互需求:AI 应用的流式响应、实时对话,要求前后端紧密配合
- 数据流转复杂化:AI 模型调用、数据处理、状态管理,都需要全栈视角
影响: 纯前端工程师在 AI 项目中往往只能负责 UI 层,无法深入业务逻辑。而 AI 项目的核心价值在于业务逻辑和数据处理,这恰恰是后端能力。
3. 技术栈边界的模糊
冲击点:
- 前后端一体化趋势:Next.js、Remix 等全栈框架兴起,前后端代码同仓库
- Serverless 架构:边缘函数、API 路由,前端开发者需要理解后端逻辑
- AI 服务集成:调用 AI API、处理流式数据、管理状态,都需要后端知识
影响: 前端和后端的边界正在消失。只会前端的前端工程师,在 AI 时代会发现自己"够不着"核心业务。
4. 职业发展的天花板
冲击点:
- 技术深度要求:AI 项目需要理解数据流、算法逻辑、系统架构
- 业务理解能力:全栈开发者能更好地理解业务全貌,做出技术决策
- 团队协作效率:全栈开发者减少前后端沟通成本,提升交付效率
影响: 在 AI 时代,只会前端的前端工程师,职业天花板明显。而全栈开发者能够:
- 独立负责完整功能模块
- 深入理解业务逻辑
- 在技术决策中发挥更大作用
二、为什么前端转全栈是必然选择?
1. AI 项目的本质需求
正如美团案例所示,AI 项目(特别是 Agent 项目)的特点:
- 前后端代码一起写:业务逻辑复杂,需要前后端协同
- 数据流处理:AI 模型的输入输出、流式响应处理
- 状态管理复杂:对话状态、上下文管理、错误处理
这些需求,纯前端工程师无法独立完成,必须掌握后端能力。
2. 技术发展的趋势
- 全栈框架普及:Next.js、Remix、SvelteKit 等,都在推动全栈开发
- 边缘计算兴起:Cloudflare Workers、Vercel Edge Functions,前端需要写后端逻辑
- 微前端 + 微服务:前后端一体化部署,降低系统复杂度
3. 市场需求的转变
- 招聘要求变化:越来越多的岗位要求"全栈能力"
- 项目交付效率:全栈开发者能独立交付功能,减少沟通成本
- 技术决策能力:全栈开发者能更好地评估技术方案
三、后端技术栈的选择:Node.js、Python、Go
对于前端转全栈,后端技术栈的选择至关重要。不同技术栈有不同优势,需要根据项目需求选择。
1. Node.js + Nest.js:前端转全栈的最佳起点
优势:
- 零语言切换:JavaScript/TypeScript 前后端通用
- 生态统一:npm 包前后端共享,工具链一致
- 学习成本低:利用现有技能,快速上手
- AI 集成友好:LangChain.js、OpenAI SDK 等完善支持
适用场景:
- Web 应用后端
- 实时应用(WebSocket、SSE)
- 微服务架构
- AI Agent 项目(如美团案例)
学习路径:
- Node.js 基础(事件循环、模块系统)
- Nest.js 框架(模块化、依赖注入)
- 数据库集成(TypeORM、Prisma)
- AI 服务集成(OpenAI、流式处理)
2. Python + FastAPI:AI 项目的首选
优势:
- AI 生态最完善:OpenAI、LangChain、LlamaIndex 等原生支持
- 数据科学能力:NumPy、Pandas 等数据处理库
- 快速开发:语法简洁,开发效率高
- 模型部署:TensorFlow、PyTorch 等模型框架
适用场景:
- AI/ML 项目
- 数据分析后端
- 科学计算服务
- Agent 项目(需要复杂 AI 逻辑)
学习路径:
- Python 基础(语法、数据结构)
- FastAPI 框架(异步、类型提示)
- AI 库集成(OpenAI、LangChain)
- 数据处理(Pandas、NumPy)
3. Go:高性能场景的选择
优势:
- 性能优秀:编译型语言,执行效率高
- 并发能力强:Goroutine 并发模型
- 部署简单:单文件部署,资源占用少
- 云原生友好:Docker、Kubernetes 生态完善
适用场景:
- 高并发服务
- 微服务架构
- 云原生应用
- 性能敏感场景
学习路径:
- Go 基础(语法、并发模型)
- Web 框架(Gin、Echo)
- 数据库操作(GORM)
- 微服务开发
4. 技术栈选择建议
对于前端转全栈的开发者:
-
首选 Node.js:如果目标是快速转全栈,Node.js 是最佳选择
- 学习成本最低
- 前后端代码复用
- 适合大多数 Web 应用
-
考虑 Python:如果专注 AI 项目
- AI 生态最完善
- 适合复杂 AI 逻辑
- 数据科学能力
-
学习 Go:如果追求性能
- 高并发场景
- 微服务架构
- 云原生应用
建议:
- 第一阶段:选择 Node.js,快速转全栈
- 第二阶段:根据项目需求,学习 Python 或 Go
- 长期目标:掌握多种技术栈,根据场景选择
四、总结
AI 时代的到来,给前端带来了深刻冲击:
- 代码生成能力:低复杂度页面生成被 AI 替代
- 业务逻辑前移:AI 项目需要前后端一体化
- 技术边界模糊:前后端边界正在消失
- 职业天花板:只会前端的前端工程师,发展受限
前端转全栈,是 AI 时代的必然选择。
对于技术栈选择:
- Node.js:前端转全栈的最佳起点,学习成本低
- Python:AI 项目的首选,生态完善
- Go:高性能场景的选择,云原生友好
正如美团的全栈化实践所示,全栈开发还相对靠谱,关键在于:
- 选择合适的技术栈
- 建立严格的开发流程
- 持续学习和实践
对于前端开发者来说,AI 时代既是挑战,也是机遇。转全栈,不仅能应对 AI 冲击,更能打开职业发展的新空间。那些"前端已死"的声音,其实是在提醒我们:只有不断进化,才能在这个时代立足。
iOS 电量监控与优化完整方案
隐形追踪者:当你删除了 Cookies,谁还在看着你?——揭秘浏览器指纹
韭菜们是否经历过这样的诡异时刻:你在某个购物网站搜索了一双球鞋,仅仅过了一分钟,当你打开新闻网站或社交媒体时,那双球鞋的广告就出现在了显眼的位置。
通常,我们会把这归咎于 Cookies。于是,聪明的韭菜打开了“无痕模式”,或者彻底清除了浏览器的缓存和 Cookies,认为这样就能隐身于互联网。
然而,广告依然如影随形。
这是因为,由于 “浏览器指纹”(Browser Fingerprinting) 技术的存在,你实际上一直在“裸奔”。
什么是浏览器指纹?
在现实生活中,指纹是我们独一无二的生理特征。而在互联网世界中,浏览器指纹是指当你访问一个网站时,你的浏览器不仅会请求网页内容,还会无意中暴露一系列关于你设备的软硬件配置信息。
这些信息单独看起来都很普通,比如:
- 你的操作系统(Windows, macOS, Android...)
- 屏幕分辨率(1920x1080...)
- 浏览器版本(Chrome 120...)
- 安装的字体列表
- 时区和语言设置
- 显卡型号和电池状态
神奇之处在于组合: 当把这几十甚至上百个特征组合在一起时,它们就形成了一个极高精度的“特征值”。据研究,对于绝大多数互联网用户来说,这个组合是全球唯一的
它是如何工作的?
为了生成这个指纹,追踪者使用了一些非常巧妙的技术:
1. Canvas 指纹(画布指纹)
这是最著名的指纹技术。网站会命令你的浏览器在后台偷偷绘制一张复杂的隐藏图片(包含文字和图形)。
由于不同的操作系统、显卡驱动、字体渲染引擎处理图像的方式有微小的像素级差异,每台电脑画出来的图在哈希值上是完全不同的。
2. AudioContext 指纹(音频指纹)
原理类似 Canvas。网站会让浏览器生成一段人耳听不到的音频信号。不同的声卡和音频驱动处理信号的方式不同,生成的数字指纹也就不同
3. 字体枚举
你安装了 Photoshop?或者安装了一套冷门的编程字体?网站可以通过脚本检测你系统里安装了哪些字体。安装的字体越独特,你的指纹辨识度就越高
为什么它比 Cookies 更可怕?
| 特性 | Cookies (传统的追踪) | 浏览器指纹 (新型追踪) |
|---|---|---|
| 存储位置 | 你的电脑硬盘里 | 不需要存储,实时计算 |
| 用户控制 | 你可以随时一键删除 | 你无法删除,它是你设备的属性 |
| 隐身模式 | 无效(隐身模式不读旧Cookies) | 依然有效(隐身模式下设备配置不变) |
| 持久性 | 易丢失 | 极难改变,甚至跨浏览器追踪 |
这就好比:
- Cookies 就像是进门时发给你的一张胸牌,你把它扔了,保安就不认识你了
- 浏览器指纹 就像是保安记住了你的身高、长相、穿衣风格和走路姿势。这和你戴不戴胸牌没有任何关系
主要用途
浏览器指纹技术在现代网络中有多种用途,主要可以分为追踪识别和安全防护两大类:
追踪与用户画像
- 跨网站追踪用户:广告网络会在不同站点嵌入脚本,通过指纹标记“同一访客”,进而在B站推送你在A站浏览过的商品或内容,实现“精准广告”。
- 绘制用户画像:即使未登录,只要指纹相同,网站就能合并浏览记录、点击路径、停留时长等数据,推测兴趣偏好、消费水平,再反向优化推荐算法。
- “无Cookie” 追踪:指纹在无痕/隐私模式下依旧存在,且无法像Cookie那样一键清空,因此被视为更顽固的追踪手段。
反欺诈与风控
- 账号安全:银行、支付、社交平台把指纹作为“设备信任度”指标。若登录指纹突然大变(新系统、虚拟机、海外设备),可触发二次验证或冻结交易。
- 薅羊毛/作弊识别:投票、抽奖、优惠券领取页面用指纹判断“是否同一设备反复参与”,防止批量注册、刷单。
- 广告反欺诈:验证广告点击是否来自真实浏览器,而非自动化脚本或虚假流量农场。
多账号管理
- 跨境电商/社媒运营:卖家或营销人员需要在一台电脑同时登录几十个Amazon、eBay、Facebook、TikTok账号。若用普通浏览器,平台会因指纹相同判定“关联店铺”并封号。指纹浏览器可为每个账号伪造独立的设备环境(分辨率、字体、Canvas、WebGL、MAC地址、IP等),实现“物理级隔离”。
- 数据抓取与测试:爬虫或自动化测试脚本通过切换指纹模拟不同真实用户,降低被目标站点封锁的概率。
合规与隐私保护
- 反指纹追踪:隐私插件或“高级指纹保护”功能会故意把Canvas、音频、WebGL结果做随机噪声,或统一返回常见值,削弱指纹的唯一性,减少被跨站跟踪。
JavaScript call、apply、bind 方法解析
JavaScript call、apply、bind 方法解析
在 JavaScript 中,call、apply、bind 都是用来**this** 改变函数执行时 指向 的核心方法,它们的核心目标一致,但使用方式、执行时机和传参形式有明显区别。
const dog = {
name: "旺财",
sayName() {
console.log(this.name);
},
eat(food) {
console.log(`${this.name} 在吃${food}`);
},
eats(food1, food2) {
console.log(`${this.name} 在吃${food1}和${food2}`);
},
};
const cat = {
name: "咪咪",
};
// call 会立即执行函数,并且改变 this 指向
dog.sayName.call(cat); // 输出 '咪咪'
dog.eat.call(cat, "🐟"); // 输出 '咪咪 在吃🐟'
dog.sayName.apply(cat); // 输出 '咪咪'
dog.eats.call(cat, "🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'
dog.eats.apply(cat, ["🐟", "🐔"]); // 输出 '咪咪 在吃🐟和🐔'
const boundEats = dog.eats.bind(cat);
boundEats("🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'
一、核心共性
三者的核心作用:this 手动指定函数执行时的 指向,突破函数默认的 this 绑定规则(比如对象方法的 this 原本指向对象本身,通过这三个方法可以强制指向其他对象)。
以示例中的 dog.sayName() 为例,默认执行时 this 指向 dog,但通过 call/apply/bind 可以让 this 指向 cat,从而输出 咪咪 而非 旺财。
二、逐个解析
1. call
-
执行时机:立即执行 函数
-
传参方式:第一个参数是
this要指向的目标对象,后续参数逐个单独传递(逗号分隔) -
语法:
函数.call(thisArg, arg1, arg2, ...)
示例解析:
// this 指向 cat,无额外参数,立即执行 sayName
dog.sayName.call(cat); // 输出 '咪咪'
// this 指向 cat,额外参数 '🐟' 逐个传递,立即执行 eat
dog.eat.call(cat, "🐟"); // 输出 '咪咪 在吃🐟'
// 多参数场景:参数逐个传递,立即执行 eats
dog.eats.call(cat, "🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'
2. apply
-
执行时机:立即执行 函数(和
call一致) -
传参方式:第一个参数是
this要指向的目标对象,后续参数必须放在一个数组(或类数组)中传递 -
语法:
函数.apply(thisArg, [arg1, arg2, ...])
示例解析:
// 无额外参数,数组可以为空(或不传),立即执行 sayName
dog.sayName.apply(cat); // 输出 '咪咪'
// 多参数场景:参数放在数组中传递,立即执行 eats
dog.eats.apply(cat, ["🐟", "🐔"]); // 输出 '咪咪 在吃🐟和🐔'
注意:apply 适合参数数量不固定、或参数已存在于数组中的场景(比如 Math.max.apply(null, [1,2,3]) 求数组最大值)。
3. bind
-
执行时机:不立即执行 函数,而是返回一个绑定了新 this 指向的新函数,后续需要手动调用这个新函数才会执行
-
传参方式:第一个参数是
this要指向的目标对象,后续参数可以提前绑定(柯里化),也可以在调用新函数时补充 -
语法:
const 新函数 = 函数.bind(thisArg, arg1, arg2, ...); 新函数(剩余参数);
示例解析:
// 第一步:bind 不执行,仅绑定 this 为 cat,返回新函数 boundEats(原变量名 boundSayName 已修改)
const boundEats = dog.eats.bind(cat);
// 第二步:手动调用新函数,传递参数 '🐟' 和 '🐔',此时才执行 eats
boundEats("🐟", "🐔"); // 输出 '咪咪 在吃🐟和🐔'
进阶用法:
// 提前绑定部分参数(柯里化),this 仍指向 cat
const boundEatWithFish = dog.eats.bind(cat, "🐟");
// 调用时补充剩余参数,同样输出目标结果
boundEatWithFish("🐔"); // 输出 '咪咪 在吃🐟和🐔'
三、核心区别总结
| 特性 | call | apply | bind |
|---|---|---|---|
| 执行时机 | 立即执行 | 立即执行 | 不立即执行,返回新函数 |
| 传参形式 | 逐个传递(逗号分隔) | 数组/类数组传递 | 可提前绑定,也可调用时传 |
| 返回值 | 函数执行结果 | 函数执行结果 | 绑定 this 后的新函数 |
四、常见使用场景
-
call:适用于参数数量明确、需要立即执行的场景(比如继承:
Parent.call(this, arg1)); -
apply:适用于参数是数组/类数组的场景(比如求数组最大值:
Math.max.apply(null, arr)); -
bind:适用于需要延迟执行、或需要重复使用绑定 this 后的函数的场景(比如事件回调、定时器:
btn.onclick = fn.bind(obj))。
五、补充注意点
-
如果第一个参数传
null/undefined,在非严格模式下,this会指向全局对象(浏览器中是window,Node 中是global);严格模式下this为null/undefined。 -
bind返回的新函数不能通过call/apply再次修改this指向(bind的绑定是永久的)。
彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析
彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析
在面试中,「手写 new 的实现」和「arguments 到底是个啥」几乎是中高级前端的必考题。
今天我们不背答案,而是把它们彻底拆开,看看 JavaScript 引擎在底层到底做了什么。
一、new 运算符到底干了哪四件事?
当你写下这行代码时:
const p =new Person('柯基', 18);
JavaScript 引擎默默为你做了 4 件大事:
- 创建一个全新的空对象
{} - 把这个空对象的
__proto__指向构造函数的prototype - 让构造函数的
this指向这个新对象,并执行构造函数(传入参数) - 自动返回这个对象(除非构造函数显式返回了一个对象)
这就是传说中的“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)的三大特征
- 有
length属性 - 可以用数字索引访问
arguments[0]、arguments[1]... - 不是真正的数组,没有
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 | 已废弃,严格模式下报错 |
几个细节知识点
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] // 原地被修改了!
归根结底:这利用了类数组能借用数组方法的特性
所以这行代码一箭三雕:
- 取出构造函数
- 把 arguments 变成真正的剩余参数数组
- 不需要写 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 底层机制的肩膀上。
requestAnimationFrame 与 JS 事件循环:宏任务执行顺序分析
一、先理清核心概念
在讲解执行顺序前,先明确几个关键概念:
-
宏任务(Macrotask) :常见的有
setTimeout、setInterval、I/O 操作、script 整体代码、UI 渲染(注意:渲染是独立阶段,不是宏任务,但和 rAF 强相关)。 -
微任务(Microtask) :
Promise.then/catch/finally、queueMicrotask、MutationObserver等,会在宏任务执行完后、渲染 / 下一个宏任务前立即执行。 - requestAnimationFrame:不属于宏任务 / 微任务,是浏览器专门为动画设计的 API,会在浏览器重绘(渲染)之前执行,执行时机在微任务之后、宏任务之前(下一轮)。
二、事件循环的执行流程
一个完整的事件循环周期执行顺序:
1. 执行当前宏任务(如 script 主代码)
2. 执行所有微任务(微任务队列清空)
3. 执行 requestAnimationFrame 回调
4. 浏览器进行 UI 渲染(重绘/回流)
5. 取出下一个宏任务执行,重复上述流程
三、代码分析
代码执行优先级:同步代码 > 微任务 > rAF(当前帧) > 普通宏任务(setTimeout) > rAF(下一帧) > 后续普通宏任务。
场景 1:基础顺序(script + 微任务 + rAF + 宏任务)
// 1. 同步代码(属于第一个宏任务:script 整体)
console.log('同步代码执行');
// 微任务
Promise.resolve().then(() => {
console.log('微任务执行');
});
// requestAnimationFrame
requestAnimationFrame(() => {
console.log('requestAnimationFrame 执行');
});
// 宏任务(setTimeout 是宏任务)
setTimeout(() => {
console.log('setTimeout 宏任务执行');
}, 0);
// 执行结果顺序大部分情况下是这样的:
// 同步代码执行
// 微任务执行
// requestAnimationFrame 执行
// setTimeout 宏任务执行
代码解释1:
- 第一步:执行同步代码,打印「同步代码执行」;
- 第二步:微任务队列有
Promise.then,执行并打印「微任务执行」; - 第三步:浏览器准备渲染前,执行 rAF 回调,打印「requestAnimationFrame 执行」;
- 第四步:浏览器完成渲染后,取出下一个宏任务(setTimeout)执行,打印「setTimeout 宏任务执行」。
代码解释2:
-
正常浏览器环境(60Hz 屏幕,无阻塞) :输出顺序是按上方写的先后顺序执行的:
同步代码执行 微任务执行 requestAnimationFrame 执行 setTimeout 宏任务执行原因:浏览器每 16.7ms 刷新一次,
requestAnimationFrame会在下一次重绘前执行,而setTimeout即使设为 0,也会有 4ms 左右的最小延迟(浏览器限制),所以requestAnimationFrame先执行。 -
极端情况(主线程阻塞 / 浏览器刷新延迟) :可能出现顺序互换:
同步代码执行 微任务执行 setTimeout 宏任务执行 requestAnimationFrame 执行原因:如果主线程处理完微任务后,
requestAnimationFrame的回调还没到执行时机(比如浏览器还没到重绘节点),但setTimeout的最小延迟已到,就会先执行setTimeout。
总结
-
固定顺序:同步代码 → 微任务,这两步是绝对固定的,不受任何因素影响。
-
不固定顺序:
requestAnimationFrame和setTimeout的执行先后不绝对,前者优先级更高但依赖渲染时机,后者受最小延迟限制,多数场景下前者先执行,但不能当作 “绝对结论”。 -
核心原则:
requestAnimationFrame属于 “渲染相关回调”,优先级高于普通宏任务(如setTimeout),但并非 ECMAScript 标准定义的 “微任务 / 宏任务” 范畴,而是浏览器的扩展机制,因此执行时机存在微小不确定性。
场景 2:嵌套场景(rAF 内嵌套微任务 / 宏任务)
console.log('同步代码');
// 第一个 rAF
requestAnimationFrame(() => {
console.log('rAF 1 执行');
// rAF 内的微任务
Promise.resolve().then(() => {
console.log('rAF 1 内的微任务');
});
// rAF 内的宏任务
setTimeout(() => {
console.log('rAF 1 内的 setTimeout');
}, 0);
// rAF 内嵌套 rAF
requestAnimationFrame(() => {
console.log('rAF 2 执行');
});
});
// 外层微任务
Promise.resolve().then(() => {
console.log('外层微任务');
});
// 外层宏任务
setTimeout(() => {
console.log('外层 setTimeout');
}, 0);
// 执行结果顺序:
// 同步代码
// 外层微任务
// rAF 1 执行
// rAF 1 内的微任务
// 外层 setTimeout
// (浏览器下一次渲染前)
// rAF 2 执行
// rAF 1 内的 setTimeout
代码解释:
- 先执行同步代码 → 外层微任务;
- 执行 rAF 1 → 立即执行 rAF 1 内的微任务(微任务会在当前阶段清空);
- 浏览器渲染后,执行下一轮宏任务:外层 setTimeout;
- 下一次事件循环的渲染阶段,执行嵌套的 rAF 2;
- 最后执行 rAF 1 内的 setTimeout(下下轮宏任务)。
场景 3:rAF 与多个宏任务对比
// 宏任务1:setTimeout 0
setTimeout(() => {
console.log('setTimeout 1');
}, 0);
// rAF
requestAnimationFrame(() => {
console.log('rAF 执行');
});
// 宏任务2:setTimeout 0
setTimeout(() => {
console.log('setTimeout 2');
}, 0);
// 执行结果顺序:
// rAF 执行
// setTimeout 1
// setTimeout 2
结论:即使多个宏任务排在前面,rAF 依然会在「微任务后、渲染前」优先执行,然后才执行所有待处理的宏任务。
四、实际应用
rAF 的这个执行特性,常用来做高性能动画(比如 DOM 动画),因为它能保证在渲染前执行,避免「布局抖动」:
// 用 rAF 实现平滑移动动画
const box = document.getElementById('box');
let left = 0;
function moveBox() {
left += 1;
box.style.left = `${left}px`;
// 动画未结束则继续调用 rAF
if (left < 300) {
requestAnimationFrame(moveBox);
}
}
// 启动动画
requestAnimationFrame(moveBox);
这个代码的优势:rAF 会和浏览器的刷新频率(通常 60Hz,每 16.7ms 一次)同步,不会像 setTimeout 那样可能出现丢帧,因为 setTimeout 是宏任务,执行时机不固定,可能错过渲染时机。
总结
- 核心执行顺序:同步代码 → 所有微任务 → requestAnimationFrame → 浏览器渲染 → 下一轮宏任务(setTimeout/setInterval 等)。
- rAF 本质:不属于宏 / 微任务,是浏览器渲染阶段的「专属回调」,优先级高于下一轮宏任务。
- 实战价值:rAF 适合做 UI 动画,能保证动画流畅;宏任务(setTimeout)适合非渲染相关的异步操作,避免阻塞渲染。
相比传统的计时器防抖与节流
实战代码:rAF 实现节流(最常用)
rAF 做节流的核心优势:和浏览器渲染同步,不会出现「执行次数超过渲染帧」的无效执行,尤其适合 resize、scroll、mousemove 这类和 UI 相关的高频事件。
基础版 rAF 节流
function rafThrottle(callback) {
let isPending = false; // 标记是否已有待执行的回调
return function(...args) {
if (isPending) return; // 已有待执行任务,直接返回
isPending = true;
// 绑定 this 指向,传递参数
const context = this;
requestAnimationFrame(() => {
callback.apply(context, args); // 执行回调
isPending = false; // 执行完成后重置标记
});
};
}
// 测试:监听滚动事件
window.addEventListener('scroll', rafThrottle(function(e) {
console.log('滚动节流执行', window.scrollY);
}));
代码解释:
-
isPending标记是否有 rAF 回调待执行,避免同一帧内多次触发; - 每次触发事件时,若没有待执行任务,就通过 rAF 注册回调;
- rAF 会在下一次渲染前执行回调,执行完后重置标记,确保每帧只执行一次。
对比传统 setTimeout 节流
// 传统 setTimeout 节流(对比用)
function timeoutThrottle(callback, delay = 16.7) {
let timer = null;
return function(...args) {
if (timer) return;
timer = setTimeout(() => {
callback.apply(this, args);
timer = null;
}, delay);
};
}
rAF 节流的优势:
- 执行时机和浏览器渲染帧完全同步,不会出现「回调执行了但渲染没跟上」的无效操作;
- 无需手动设置延迟(如 16.7ms),自动适配浏览器刷新率(60Hz/144Hz 都能兼容)。
实战代码:rAF 实现防抖
rAF 实现防抖需要结合「延迟 + 取消 rAF」的逻辑,核心是「触发事件后,只保留最后一次 rAF 回调」。
function rafDebounce(callback) {
let rafId = null; // 保存 rAF 的 ID,用于取消
return function(...args) {
const context = this;
// 若已有待执行的 rAF,先取消
if (rafId) {
cancelAnimationFrame(rafId);
}
// 重新注册 rAF,延迟到下一帧执行
rafId = requestAnimationFrame(() => {
callback.apply(context, args);
rafId = null; // 执行后清空 ID
});
};
}
// 测试:监听输入框输入
const input = document.getElementById('input');
input.addEventListener('input', rafDebounce(function(e) {
console.log('输入防抖执行', e.target.value);
}));
代码解释:
- 每次触发事件时,先通过
cancelAnimationFrame取消上一次未执行的 rAF 回调; - 重新注册新的 rAF 回调,确保只有「最后一次触发」的回调会执行;
- 防抖的延迟本质是「一帧的时间(16.7ms)」,若需要更长延迟,可结合
setTimeout。
带自定义延迟的 rAF 防抖
function rafDebounceWithDelay(callback, delay = 300) {
let rafId = null;
let timer = null;
return function(...args) {
const context = this;
// 取消之前的定时器和 rAF
if (timer) clearTimeout(timer);
if (rafId) cancelAnimationFrame(rafId);
// 先延迟,再用 rAF 执行(保证渲染前执行)
timer = setTimeout(() => {
rafId = requestAnimationFrame(() => {
callback.apply(context, args);
rafId = null;
timer = null;
});
}, delay);
};
}
四、适用场景 vs 不适用场景
| 场景 | 是否适合用 rAF 做防抖 / 节流 | 原因 |
|---|---|---|
| scroll/resize 事件 | ✅ 非常适合 | 和 UI 渲染强相关,rAF 保证每帧只执行一次 |
| mousemove/mouseover 事件 | ✅ 适合 | 高频触发,rAF 减少无效执行,提升性能 |
| 输入框 input/change 事件 | ✅ 适合(防抖) | 保证输入完成后,在渲染前执行回调(如搜索联想) |
| 网络请求(如按钮点击提交) | ❌ 不适合 | 网络请求和 UI 渲染无关,用传统 setTimeout 防抖更合适 |
| 后端数据处理(无 UI 交互) | ❌ 不适合 | rAF 是浏览器 API,Node.js 环境不支持,且无渲染需求 |
总结
- rAF 适合做防抖 / 节流,尤其在「和 UI 交互相关的高频事件」(scroll/resize/mousemove)场景下,性能优于传统 setTimeout;
-
rAF 节流:核心是「每帧只执行一次」,利用
isPending标记避免重复执行; - rAF 防抖:核心是「取消上一次 rAF,保留最后一次」,可结合 setTimeout 实现自定义延迟;
- 非 UI 相关的防抖 / 节流(如网络请求),优先用传统 setTimeout,避免依赖浏览器渲染机制。