阅读视图

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

Event Loop 教你高效 “划水”:JS 单线程的“摸鱼”指南

前言

各位前端打工人,有没有过这种经历:明明写了 setTimeout(() => console.log('摸鱼')),结果同步代码还没跑完,摸鱼计划就被打断?其实 JS 单线程就像一个只能专注干一件事的打工人,而 Event Loop 就是它的 “高效摸鱼手册”—— 既能按时完成核心工作,又能把耗时任务 “挂起摸鱼”,今天咱们就一起好好聊聊这份手册!

一、先搞懂:JS 打工人为啥不能 “硬卷”?(进程线程的底层逻辑)

要想摸鱼,得先知道 “工作台” 的规矩:

  • 进程:好比公司的独立部门 —— 比如浏览器开个新标签页,就是开了个新部门,每个部门都有自己的办公资源(电脑、文件)。

  • 线程:部门里真正干活的打工人 —— 浏览器部门里就有三个核心员工:

    1. 渲染线程(负责画页面,比如给按钮上色、排版文字);
    2. JS 引擎线程(咱们的主角,负责跑代码);
    3. HTTP 请求线程(负责发接口,比如向服务器要数据)。

但这里有个 “办公室规定”:JS 引擎线程和渲染线程是 “互斥同事” ——JS 能修改 DOM(比如把按钮改成红色),要是它俩同时干活,页面就会出现 “排版错乱”(比如按钮画到一半被改成红色),所以必须 “你歇我干”。

更关键的是:JS 引擎线程是个 “独生子” (V8 引擎默认只开一个线程)。这就意味着:如果 JS 遇到一个耗时 10 秒的计算任务(比如统计 100 万条数据),它就会一直死磕这个任务,导致渲染线程没法干活,页面直接卡成 “PPT”—— 这就是 “硬卷” 的下场!

所以 JS 打工人的生存法则是:能摸鱼就不硬卷,耗时任务先 “挂起”,等核心工作做完再处理—— 这就是 “异步摸鱼” 的核心逻辑。

二、Event Loop:摸鱼任务的 “优先级排序”

JS 里的 “摸鱼任务”(异步任务) 分两类,就像公司里的 “紧急任务”“常规任务”,得按顺序处理,不能乱摸鱼:

  • 微任务:紧急摸鱼任务(优先级高)—— 比如 Promise.then()async/await 后续代码、process.nextTick()(Node 环境),相当于 “老板临时交代的小任务,必须在下班前做完”;
  • 宏任务:常规摸鱼任务(优先级低)—— 比如 setTimeoutsetInterval、ajax 请求、I/O 操作、UI 渲染,相当于 “下周要交的报告,先放一放”;
  • 还有个特殊角色:同步任务—— 核心工作(比如写代码、算结果),必须优先做完,相当于 “当天要交的核心 KPI”。

Event Loop 就是这套摸鱼规则的 “监督者”,它的工作流程就像打工人的一天,记好这 4,摸鱼不翻车:

  1. 先清核心 KPI:先把当天的同步任务 (核心工作) 全部做完,遇到异步任务 (摸鱼任务),就按类型扔进 “微任务队列” (紧急摸鱼) 和 “宏任务队列” (常规摸鱼)
  2. 再处理紧急摸鱼:核心 KPI 做完后,把 “微任务队列” 里的所有任务一次性清完(比如老板临时交代的 3 个小任务,必须连续做完,不能中途打断);
  3. 中场休息(渲染页面) :紧急摸鱼任务处理完,浏览器会进行 “页面渲染”(比如更新 DOM、刷新页面),相当于打工人喝杯咖啡歇一歇;
  4. 开启下一轮摸鱼:从 “宏任务队列” 里拿一个任务执行,然后重复 1-3 步,直到所有任务做完。

三、实战摸鱼:用代码例子验证规则

光说不练假把式,咱们用真实代码模拟 JS 打工人的 “摸鱼一天”,看看 Event Loop 是怎么安排任务的!

例子 1:setTimeout为啥 “跑不赢” 同步代码?

先看这串经典代码:

let a = 1;
setTimeout(() => {
    a = 2
}, 1000)
console.log(a);

分析摸鱼过程

  • 同步代码(属于宏任务)先跑:let a=1 → 执行console.log(a),此时a还是 1;
  • setTimeout是宏任务,被扔进 “宏任务队列” 排队;
  • 同步跑完后,微任务队列为空,直接执行下一个宏任务(也就是 1 秒后的a=2)。

所以结果是:先输出 1,1 秒后a才变成 2

image.png

例子 2:Promise.then的 “VIP 特权”

我们看一道经典面试题:

console.log(1);
new Promise((resolve) => {
    console.log(2);
    resolve();
})
.then(() => {
    console.log(3);
    setTimeout(() => {
        console.log(4);
    }, 0)
})
setTimeout(() => {
    console.log(5);
    setTimeout(() => {
        console.log(6);
    }, 0)
}, 0)
console.log(7);

是不是已经头皮发麻了?根本不清楚打印顺序是啥,但是这道面试题我们必须拿下!

摸鱼步骤拆解

  1. 常规摸鱼(宏任务)开跑

    • 先执行console.log(1) → 输出1
    • 遇到new PromisePromise 构造函数里的代码是同步的,执行console.log(2) → 输出2,然后resolve()
    • then是微任务,扔进 “微任务队列”;
    • 遇到外层setTimeout:宏任务,扔进 “宏任务队列”;
    • 最后执行console.log(7) → 输出7
  2. 紧急摸鱼(微任务)接棒

    • 微任务队列里只有then的回调,执行它:console.log(3) → 输出3
    • 回调里的setTimeout(4)是宏任务,扔进 “宏任务队列”。
  3. 宏任务队列开跑(下一轮摸鱼)

    • 先拿第一个宏任务(外层setTimeout):执行console.log(5) → 输出5
    • 里面的setTimeout(6)扔进宏任务队列;
    • 再拿下一个宏任务(then里的setTimeout(4)):执行console.log(4) → 输出4
    • 最后拿setTimeout(6):执行console.log(6) → 输出6

最终输出顺序1 → 2 → 7 → 3 → 5 → 4 → 6

image.png

上图更清晰:

image.png

例子 3:async/await 是 “优雅摸鱼” 的语法糖

async/await 本质是 Promise 的语法糖,相当于给摸鱼任务加了 “自动排队” 功能,先搞懂它的用法

console.log('script start');
async function async1() {
    await async2()
    console.log('async1 end');
}
async function async2() {
    console.log('async2 end');
}
async1();

关键规则

  • async函数本身相当于 “返回 Promise 的函数”;
  • await fn()的本质是:await后面的代码,塞进了fn()返回的 Promise 的then里(也就是微任务队列)

拿这段代码分析:

  1. 同步执行console.log('script start') → 输出;

  2. 执行async1()

    • 进入async1,遇到await async2() → 先执行async2()(同步),输出async2 end
    • await把后续的console.log('async1 end')扔进微任务队列
  3. 继续执行同步代码

image.png

OK既然知道了原理我们就实战摸鱼

// 模拟耗时任务:向服务器要数据(宏任务)
function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log('常规摸鱼:发接口请求(耗时 1 秒)');
            resolve('接口返回数据:用户列表');
        }, 1000);
    });
}
// 核心工作函数(async 标记为异步函数)
async function work() {
    console.log('核心工作:开始处理用户数据');
    // await 相当于“等待摸鱼任务完成,再继续核心工作”
    const data = await fetchData();
    // 这行代码会被扔进微任务队列,相当于“紧急摸鱼后的收尾工作”
    console.log(`核心工作:使用${data}完成报表`);
}
// 执行核心工作
work();
// 其他同步任务
console.log('核心工作:处理其他紧急事务');

摸鱼流程拆解:

  1. 执行同步任务:

    • 调用 work() 函数,打印 核心工作:开始处理用户数据
    • 遇到 await fetchData(),先执行 fetchData(),里面的 setTimeout 被扔进 “宏任务队列”(常规摸鱼);
    • await 会暂停 work 函数,跳出去执行其他同步任务,打印 核心工作:处理其他紧急事务 → 同步任务完成。
  2. 微任务队列为空,直接进入中场休息。

  3. 处理宏任务队列(常规摸鱼):

    • 1 秒后,执行 setTimeout 回调,打印 常规摸鱼:发接口请求(耗时 1 秒)Promise resolve 后,await 后面的代码被扔进 “微任务队列”。
  4. 再次处理微任务队列:

    • 执行 console.log(核心工作:使用 ${data} 完成报表) → 核心工作收尾。

image.png

这里的关键是:await 后面的代码会被自动塞进微任务队列,相当于 “摸鱼结束后,优先处理收尾工作”,不用手动写 then 回调,摸鱼更优雅!

大家可以复制代码去运行一下,时间延迟照片体现不出来~~

四、摸鱼避坑:这些误区千万别踩

  1. 误区 1:setTimeout 延迟时间是 “准确时间”

错! setTimeout(() => {}, 1000) 不是 “1 秒后立即执行”,而是 “1 秒后把任务扔进宏任务队列”,得等同步任务和微任务全部完成后才会执行。如果前面的任务耗时 2 秒,那摸鱼就得等 2 秒后才开始。

  1. 误区 2:Promise 构造函数里的代码是异步的

错! new Promise((resolve) => { 同步代码 }) 里的代码是同步执行的,只有 thencatch 回调才是微任务(异步)。比如下面的代码,会先打印 同步代码,再打印 微任务

new Promise((resolve) => {
    console.log('同步代码');
    resolve();
})
.then(() => {
    console.log('微任务')
});

image.png 3. 误区 3:async 函数返回值是 “原始数据”

错! async 函数默认返回一个 Promise 对象,哪怕你写 async function fn() { return 1; },调用 fn() 得到的也是 Promise { 1 },需要用 await 或 then 才能拿到值。

五、总结:Event Loop 摸鱼口诀(记熟直接用)

同步任务先干完,微任务队列清干净;

渲染页面歇一歇,宏任务来轮着干;

await 后藏微任务,Promise 构造是同步;

Event Loop 掌节奏,摸鱼工作两不误!

结语

其实 JS 单线程的 “摸鱼哲学”,本质是 “优先级管理”—— 核心工作优先做,耗时任务排队做,既不耽误事,又不浪费时间。掌握了 Event Loop,你不仅能看懂 JS 异步代码的执行顺序,还能写出更高效的代码,就像打工人掌握了摸鱼技巧,工作效率翻倍,摸鱼也不心慌!

前端面试题-JavaScript高级篇

以下为JavaScript高级篇面试考察点总结,具体知识点不会太详细,主要梳理面试核心考察点,为面试做准备。高级JavaScript工程师的面试不再局限于API的使用或孤立的知识点,而是聚焦于对语言、引擎、生态以及软件工程思想的综合理解与掌控能力。

一、 V8引擎工作原理与垃圾回收 (GC)

理解JavaScript的执行环境是高级优化的前提。

V8引擎核心流程

  1. 解析 (Parsing) : V8将JavaScript源代码解析成抽象语法树 (AST)

  2. 解释 (Interpretation) : Ignition (V8的解释器) 将AST转换成字节码并执行。同时,Ignition会收集分析信息,用于后续的优化。

  3. 编译 (Compilation) : 对于被频繁执行的代码(热点代码),TurboFan (V8的优化编译器) 会介入,利用分析信息将字节码编译成高度优化的机器码,以提升执行效率。这个过程被称为JIT (Just-In-Time) 编译。如果优化的假设失败(如函数参数类型改变),会进行去优化 (Deoptimization) ,回退到字节码执行。

垃圾回收 (Garbage Collection)

V8采用分代回收 (Generational Collection)的策略,将堆内存分为新生代 (New Generation)老生代 (Old Generation)

新生代 (Scavenger算法)

  • 空间小,存活对象少。采用Scavenger算法,将空间一分为二(From-Space 和 To-Space)。
  • 回收时,将From-Space中的存活对象复制到To-Space,然后清空From-Space。最后,From-Space和To-Space角色互换。
  • 对象若经历多轮回收仍存活,则被**晋升 (Promotion)**到老生代。

老生代

  • 空间大,存活对象多。采用标记-清除算法。

  • 标记阶段: 从根对象(如全局对象)开始,遍历所有可达对象并打上标记。

  • 清除阶段: 清除非标记对象所占用的内存。

  • 整理阶段 : 为解决内存碎片化问题,在清除后,会将所有存活对象向一端移动,形成连续的内存空间。

代码示例 (导致内存泄漏的场景):

高级开发者需要能够识别并解释内存泄漏。闭包引用了已分离的DOM节点是典型案例。

function createLeakingElement() {
  const container = document.getElementById('container');
  const detachedElement = document.createElement('div');
  detachedElement.textContent = 'This is a potentially leaking element.';
  container.appendChild(detachedElement);

  // 关键:一个外部可访问的函数,通过闭包持有了对 detachedElement 的引用
  const leakingClosure = function() {
    // 即使 detachedElement 从DOM树中移除,只要 leakingClosure 存在,
    // detachedElement 就不会被GC回收。
    console.log(detachedElement.textContent);
  };

  // 从DOM中移除元素
  container.removeChild(detachedElement);

  // 返回这个闭包
  return leakingClosure;
}

// globalLeaker 现在持有了对 detachedElement 的间接引用
// 即使它在DOM中已不可见,它依然存在于内存中
window.globalLeaker = createLeakingElement();

// 只要 window.globalLeaker 不被设为 null,这块内存就永远无法被回收

二、 事件循环 (Event Loop)

高级面试会深入到Node.js环境,考察对Event Loop各阶段的理解。

  • 浏览器 vs. Node.js: 两者模型相似,但Node.js的事件循环有更明确的阶段划分。

  • Node.js 事件循环的六个阶段:

    1. timers: 执行 setTimeout()setInterval() 的回调。
    2. pending callbacks: 执行上一轮循环中延迟到本轮执行的I/O回调。
    3. idle, prepare: 仅内部使用。
    4. poll: 核心阶段。检索新的I/O事件;执行与I/O相关的回调。如果队列不为空,会遍历执行;如果为空,会在此阻塞等待,直到有新的I/O事件或到达 timers 设定的阈值。
    5. check: 执行 setImmediate() 的回调。
    6. close callbacks: 执行如 socket.on('close', ...) 的回调。
  • process.nextTick() 与微任务 (Micro-task) :

    • process.nextTick() 有自己独立的队列,其优先级高于所有微任务。
    • 在一个阶段执行完毕后,事件循环会立即清空 nextTick 队列,然后才清空微任务队列,之后才进入下一个阶段。

代码示例 (Node.js环境下):

const fs = require('fs');

console.log('1. Script Start');

// Timers 阶段
setTimeout(() => {
  console.log('7. setTimeout');
}, 0);

// Check 阶段
setImmediate(() => {
  console.log('8. setImmediate');
});

// Micro-task
Promise.resolve().then(() => {
  console.log('5. Promise.then');
});

// process.nextTick 队列 (最高优先级)
process.nextTick(() => {
  console.log('4. process.nextTick');
});

// I/O 操作,其回调将在 Poll 阶段执行
fs.readFile(__filename, () => {
  console.log('6. I/O (readFile) callback');

  // I/O回调内部的调度
  setTimeout(() => console.log('11. I/O -> setTimeout'), 0);
  setImmediate(() => console.log('9. I/O -> setImmediate'));
  process.nextTick(() => console.log('10. I/O -> nextTick'));
});

console.log('2. Script End');
console.log('3. Poll phase may start here...');

// 理论输出顺序:
// 1. Script Start
// 2. Script End
// 3. Poll phase may start here...
// 4. process.nextTick
// 5. Promise.then
// 6. I/O (readFile) callback
// 10. I/O -> nextTick
// 9. I/O -> setImmediate
// 7. setTimeout
// 8. setImmediate
// 11. I/O -> setTimeout
// (注意:9, 7, 8, 11 的确切顺序可能因I/O耗时和系统调度而有细微变化,但基本规律如此)

三、 高级性能优化

Tree Shaking (摇树优化)

原理

  • 依赖ES Modules (import/export) 的静态结构,在编译时分析代码,移除未被实际引用的“死代码”(dead-code)。

实践

  • Webpack, Rollup等现代打包工具在生产模式下默认开启。开发者需保证代码遵循ESM规范,并避免有副作用的模块导入。

Code Splitting (代码分割)

目的

  • 将巨大的单体bundle分割成多个小块(chunks),按需加载,以减小首屏加载体积,提升用户体验。

策略

  1. 按路由分割: 每个页面或路由对应一个chunk。

  2. 按组件分割: 对于非首屏、或需要交互才出现的大型组件(如弹窗、图表)进行懒加载。

  3. 公共库分离 (Vendor Splitting) : 将不常变动的第三方库(如React, Lodash)打包成独立的vendor chunk,利用浏览器缓存。

利用浏览器渲染路径

  • 关键渲染路径 : 优化CSS加载(内联关键CSS)、减少阻塞渲染的脚本、使用 async/defer

  • 硬件加速: 尽量使用 transformopacity 属性进行动画,它们能被提升到单独的合成层(Compositor Layer),由GPU处理,避免触发重排(Reflow)和重绘(Repaint)。

代码示例 (React中的代码分割)

import React, { Suspense, lazy } from 'react';

// 使用 React.lazy 和动态 import() 来实现组件的懒加载
const HeavyComponent = lazy(() => import('./components/HeavyComponent'));
const AnotherLazyComponent = lazy(() => import('./components/AnotherLazyComponent'));

function App() {
  const [showHeavy, setShowHeavy] = React.useState(false);

  return (
    <div>
      <h1>My App</h1>
      <button onClick={() => setShowHeavy(true)}>Load Heavy Component</button>

      {/* 
        Suspense 组件用于在懒加载组件下载和解析期间,显示一个fallback UI。
        只有当 showHeavy 为 true 时,浏览器才会去请求 HeavyComponent.js。
      */}
      <Suspense fallback={<div>Loading...</div>}>
        {showHeavy && <HeavyComponent />}
        
        {/* 假设这是另一个需要懒加载的组件 */}
        {/* <AnotherLazyComponent /> */}
      </Suspense>
    </div>
  );
}

四、 内存管理与诊断

内存泄漏的常见原因

  1. 意外的全局变量: 未经声明的变量被赋值,成为全局对象的属性。

  2. 遗忘的定时器或回调: setInterval 未被清除,其回调函数及其闭包环境无法被回收。

  3. 分离的DOM节点引用: 如第一节的代码示例。

  4. 闭包的滥用: 闭包会使其外部函数的作用域持续存在,如果作用域中包含大量数据,则可能造成内存占用过高。

诊断工具 (Chrome DevTools)

  • Performance Monitor: 实时监控CPU使用率、JS堆大小、DOM节点数等。

  • Memory Tab:

    • Heap Snapshot (堆快照) : 拍摄堆内存的快照,用于分析对象分布、查找分离的DOM树、定位内存泄漏。
    • Allocation Instrumentation on Timeline: 记录内存分配的时间线,用于定位是哪个函数或操作导致了频繁的内存分配或内存激增。

代码示例 (遗忘的定时器):

class PulsingDot {
  constructor() {
    this.size = 0;
    this.isGrowing = true;

    // 定时器通过闭包持有了对 this (PulsingDot实例) 的引用
    this.intervalId = setInterval(() => {
      if (this.isGrowing) {
        this.size += 1;
        if (this.size >= 10) this.isGrowing = false;
      } else {
        this.size -= 1;
        if (this.size <= 0) this.isGrowing = true;
      }
    }, 100);
  }

  // 必须提供一个销毁方法来清除定时器
  destroy() {
    clearInterval(this.intervalId);
    console.log('PulsingDot destroyed and interval cleared.');
  }
}

let dot = new PulsingDot();

// 假设在某个时间点,我们不再需要这个 dot 实例
dot = null;

// 问题:虽然 dot 变量被设为 null,但 PulsingDot 实例无法被回收,
// 因为 setInterval 的回调函数仍然持有对它的引用,定时器还在不停地运行。
// 正确做法:在销毁对象前,调用 dot.destroy()。

五、 软件设计模式

高级开发者应能将设计模式思想融入日常编码,以构建可维护、可扩展的系统。

  • 单例模式 (Singleton) : 确保一个类只有一个实例,并提供一个全局访问点。

  • 观察者模式 (Observer / Pub/Sub) : 定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知并自动更新。

  • 工厂模式 (Factory) : 定义一个用于创建对象的接口,让子类决定实例化哪一个类。

  • 装饰器模式 (Decorator) : 动态地给一个对象添加一些额外的职责。

  • 代理模式 (Proxy) : 为其他对象提供一种代理以控制对这个对象的访问。

代码示例 (观察者模式/发布-订阅):

class EventBus {
  constructor() {
    this.listeners = {};
  }

  // 订阅
  on(eventName, callback) {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    this.listeners[eventName].push(callback);
  }

  // 取消订阅
  off(eventName, callback) {
    if (!this.listeners[eventName]) return;
    this.listeners[eventName] = this.listeners[eventName].filter(
      listener => listener !== callback
    );
  }

  // 发布
  emit(eventName, ...args) {
    if (!this.listeners[eventName]) return;
    this.listeners[eventName].forEach(listener => {
      try {
        listener(...args);
      } catch (e) {
        console.error(`Error in listener for event "${eventName}":`, e);
      }
    });
  }
}

// --- 使用场景 ---
const bus = new EventBus();

function onUserLogin(userData) {
  console.log('Analytics Service: User logged in', userData.name);
}

function updateNavbar(userData) {
  console.log('UI Service: Updating navbar for', userData.name);
}

bus.on('user:login', onUserLogin);
bus.on('user:login', updateNavbar);

// 某处登录成功后...
bus.emit('user:login', { id: 1, name: 'Mickey' });

// 用户退出时,可以取消订阅
// bus.off('user:login', onUserLogin);

六、 模块化与工程化

模块化方案演进

从IIFE、CommonJS (require/module.exports)、AMD (define/require) 到 ES Modules (import/export) 。高级开发者需理解它们的差异及适用场景。

构建工具

Webpack

一个强大的、高度可配置的模块打包器。核心概念:Entry, Output, Loaders (转换非JS模块), Plugins (执行更广泛的任务,如打包优化、资源管理), Mode

Vite

新一代前端构建工具。利用浏览器原生ESM支持,在开发环境下实现极速的冷启动和热更新 (HMR)。生产环境则使用Rollup进行打包。

Monorepo

在单一代码仓库中管理多个项目/包的策略。工具:Lerna, Nx, Turborepo。

代码示例 (一个基础的 webpack.config.js):

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 模式:'development' 或 'production'
  mode: 'development',

  // 入口文件
  entry: './src/index.js',

  // 输出配置
  output: {
    filename: 'bundle.[contenthash].js', // contenthash 用于缓存优化
    path: path.resolve(__dirname, 'dist'),
    clean: true, // 在生成文件之前清空 output 目录
  },

  // 模块处理规则
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          // 使用 babel-loader 来转换 ES6+ 语法
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react']
          }
        }
      },
      {
        test: /.css$/,
        // loader 的执行顺序是从右到左
        use: ['style-loader', 'css-loader']
      }
    ]
  },

  // 插件配置
  plugins: [
    // 自动生成一个 HTML 文件,并注入打包后的 JS
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ],

  // 开发服务器配置
  devServer: {
    static: './dist',
    hot: true,
  },
  
  // Source Map 配置,用于调试
  devtool: 'eval-source-map',
};

七、 Web安全

XSS (Cross-Site Scripting)

攻击者将恶意脚本注入到网页中,其他用户浏览时执行。

类型

  • 储存型(Stored XSS
  • 反射型(Reflected XSS
  • DOM型(DOM-based XSS)。

防御

  1. 绝不信任任何用户输入

  2. 输出编码/转义: 对用户输入的数据在渲染到页面前进行HTML实体转义。现代框架(如React, Vue)默认进行此操作。

  3. 使用 textContent 代替 innerHTML

  4. Content Security Policy (CSP) : 通过HTTP头,严格限制页面可以加载的资源来源。

CSRF (Cross-Site Request Forgery)

攻击者诱导已登录用户在不知情的情况下,向其已认证的Web应用发送一个伪造的请求(如转账、修改密码)。

防御

  1. Anti-CSRF Token: 服务器为每个用户会话生成一个随机Token,要求所有状态变更的请求(POST, PUT, DELETE)都必须携带此Token。

  2. SameSite Cookie 属性: 将Cookie设置为 StrictLax,可以阻止浏览器在跨站请求中发送Cookie。SameSite=Strict 是最强的防御。

  3. 检查 Referer 头: 验证请求的来源,但此方法可被伪造。

代码示例 (XSS防御):

const userInput = '<img src="invalid" onerror="alert('XSS Attack!')">';

// 错误的方式:直接使用 innerHTML
const vulnerableDiv = document.getElementById('vulnerable');
// vulnerableDiv.innerHTML = userInput; // 这将执行 onerror 中的恶意脚本

// 正确的方式:使用 textContent,浏览器会将其作为纯文本处理
const secureDiv = document.getElementById('secure');
secureDiv.textContent = userInput; // 页面将显示字符串 "<img..." 而非图片

八、 框架原理 (以React为例)

Virtual DOM (VDOM)

  • 一个以JavaScript对象形式存在的、对真实DOM的抽象表示。

  • 工作流:

    graph TD
    A[状态变更] --> B(重新渲染生成新的VDOM)
    B --> C(新旧VDOM进行<br/>Diffing差异比较)
    C --> D(计算出最小化的变更集);
    D --> E(将变更批量更新<br/>到真实DOM);

Reconciliation (协调) 与 Diffing 算法

Diffing策略

  1. Tree Diff: 只对同层级的节点进行比较,跨层级的移动会视为节点的销毁和重建。

  2. Component Diff: 如果组件类型不同,直接销毁旧组件,创建新组件;如果类型相同,则更新其属性。

  3. Element Diff: 对于同层级的一组子节点,通过 key 属性进行优化。key 帮助React识别哪些元素是稳定的、哪些是新增或删除的,从而实现高效的移动和复用,而不是原地销毁重建。

代码示例 (key的重要性)

// 场景:在一个列表的开头插入一个新元素

// --- 不推荐:使用 index 作为 key ---
// 当在开头插入 'grape' 时,列表变为 ['grape', 'apple', 'banana']
// React 看到:
// - key=0 的元素从 'apple' 变为 'grape' (更新)
// - key=1 的元素从 'banana' 变为 'apple' (更新)
// - 新增一个 key=2 的元素 'banana' (新增)
// 这导致了大量不必要的DOM更新。
const BadList = ({ items }) => (
  <ul>{items.map((item, index) => <li key={index}>{item}</li>)}</ul>
);

// --- 推荐:使用稳定且唯一的ID作为 key ---
// 当在开头插入 {id: 3, text: 'grape'} 时
// React 看到:
// - key=1 ('apple') 和 key=2 ('banana') 的元素仍然存在,只需移动位置
// - 新增一个 key=3 的元素 'grape'
// 这只会导致一次新增操作和两次移动操作,效率极高。
const GoodList = ({ items }) => (
  <ul>{items.map(item => <li key={item.id}>{item.text}</li>)}</ul>
);

九、 TypeScript

核心价值

  • 为JavaScript带来静态类型系统
  • 在编译阶段发现潜在错误
  • 提升代码的可维护性、可读性和大型项目的健壮性。

高级类型

  • 泛型 (Generics) : 创建可重用的、类型安全的组件或函数。

  • 条件类型 (Conditional Types) : T extends U ? X : Y,使类型可以根据条件变化。

  • 映射类型 (Mapped Types) : [K in keyof T]: ...,基于一个现有类型创建新类型。

  • 工具类型 (Utility Types) : Partial<T>, Required<T>, Readonly<T>, Pick<T, K>, Omit<T, K> 等,对类型进行转换和操作。

代码示例 (泛型与条件类型)

// 泛型函数:确保输入和输出类型一致
function identity<T>(arg: T): T {
  return arg;
}
let output = identity<string>("myString"); // output 类型为 string

// 泛型接口
interface GenericRepository<T> {
  findById(id: number): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
}
// class UserRepository implements GenericRepository<User> { ... }

// 条件类型:从一个类型中提取特定类型的属性名
type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];

interface Part {
  id: number;
  name: string;
  updatePart(newName: string): void;
  getPartName(): string;
}

// Type 'FunctionNames' will be "updatePart" | "getPartName"
type FunctionNames = FunctionPropertyNames<Part>;

十、 Node.js与服务器端

Node.js不仅是“能在服务器上运行的JavaScript”,更是一个基于事件驱动、非阻塞I/O模型的强大运行时环境。对其核心原理、生态系统及最佳实践的掌握,是构建高性能、高并发网络应用的基础。

1. Node.js 核心模型

事件驱动 & 非阻塞I/O

  • 这是Node.js性能的基石。与传统的每个请求独占一个线程的模型不同,Node.js在单个主线程上运行事件循环。

  • 当遇到I/O操作(如数据库查询、文件读写、网络请求)时,Node.js不会等待其完成,而是将操作和回调函数交给底层系统(如libuv库),然后继续处理事件队列中的其他事件。

  • 当I/O操作完成后,其回调函数会被放回事件队列,等待事件循环的下一次轮询来执行。

适用场景
  • 这种模型使得Node.js极其适合处理大量并发连接的I/O密集型应用,如实时聊天服务、API网关、微服务等。

但它天然不适合CPU密集型任务,因为长时间的计算会阻塞主线程,导致整个应用无响应。

2. Web框架:Express & Koa

虽然Node.js内置了http模块,但直接使用它来构建复杂的Web应用是繁琐且低效的。Web框架提供了路由、中间件、模板引擎集成等高级抽象。

Express

事实上的行业标准,以其稳定、灵活和庞大的社区生态而著称。其核心是中间件(Middleware) 概念——一系列按顺序处理请求的函数。

Koa

由Express原班人马打造,被视为下一代Node.js Web框架。Koa的核心是利用async/await语法,通过洋葱模型 (Onion Model) 来组织中间件,使得异步流程控制更为优雅和直观。

代码示例 (Express中间件与路由)

const express = require('express');
const app = express();

// 1. 应用级中间件:日志记录器
// 这是一个简单的中间件,会记录每个请求的方法、URL和时间戳。
app.use((req, res, next) => {
  const now = new Date().toISOString();
  console.log(`[${now}] ${req.method} ${req.originalUrl}`);
  next(); // 关键:调用 next() 将控制权传递给下一个中间件或路由处理器
});

// 2. 内置中间件:用于解析JSON格式的请求体
app.use(express.json());

// 3. 路由处理器
app.get('/', (req, res) => {
  res.send('Welcome to the homepage!');
});

app.post('/api/users', (req, res) => {
  // req.body 是由 express.json() 中间件处理后得到的
  const newUser = req.body;
  console.log('Creating new user:', newUser);
  // ... 在这里执行数据库插入等操作 ...
  res.status(201).json({ id: Date.now(), ...newUser });
});

// 4. 错误处理中间件 (特殊的4个参数)
// 应该放在所有 app.use() 和路由调用的最后
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

3. Streams (流)

Stream是Node.js中处理流式数据的抽象接口。在处理大文件或大量网络数据时,一次性将所有数据读入内存是低效且危险的(可能导致内存溢出)。Stream允许我们以小块(chunks)的方式、边读取边处理数据。

四种基本类型

  1. Readable: 可供读取数据的流 (如 fs.createReadStream)。

  2. Writable: 可供写入数据的流 (如 fs.createWriteStream, http.ServerResponse)。

  3. Duplex: 既可读又可写的流 (如 net.Socket)。

  4. Transform: 在读写过程中可以修改或转换数据的Duplex流 (如 zlib.createGzip)。

pipe()方法

  • 是连接流的最简单方式,它会自动处理数据从Readable流到Writable流的传输,并能妥善处理背压 (Back-pressure) 问题(即写入速度跟不上读取速度时,自动暂停读取)。

代码示例 (高效的文件服务器):

以下代码使用流高效地提供一个大文件的下载,而无需将整个文件加载到服务器内存中。

const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer((req, res) => {
  // 假设我们要提供一个名为 'large-video.mp4' 的文件下载
  const filePath = path.join(__dirname, 'large-video.mp4');

  // 检查文件是否存在
  fs.stat(filePath, (err, stats) => {
    if (err) {
      res.writeHead(404, { 'Content-Type': 'text/plain' });
      return res.end('File not found.');
    }

    // 设置响应头
    res.writeHead(200, {
      'Content-Type': 'video/mp4',
      'Content-Length': stats.size
    });

    // 创建一个可读流
    const readStream = fs.createReadStream(filePath);

    // 关键:使用 pipe() 将文件流直接导入到HTTP响应流(res)
    // Node.js 会自动处理数据分块、发送以及背压控制。
    // 这是一种极其高效且内存友好的方式。
    readStream.pipe(res);

    // 监听错误事件
    readStream.on('error', (streamErr) => {
      console.error('Stream Error:', streamErr);
      res.end(); // 发生错误时关闭连接
    });
  });
});

server.listen(3000, () => {
  console.log('File server listening on port 3000');
});

4. Child Processes (子进程)

为了解决CPU密集型任务阻塞主线程的问题,Node.js提供了child_process模块,允许创建子进程来执行这些任务。

  • spawn() : 启动一个新进程,以流的方式进行I/O,适合处理大量数据。

  • exec() : 启动一个shell来执行命令,将stdout/stderr缓存起来,在进程结束时通过回调一次性返回。有大小限制,适合执行简单的shell命令。

  • fork() : spawn()的一个特殊变体,专门用于创建新的Node.js进程。父子进程之间会建立一个IPC (Inter-Process Communication) 通道,允许通过.send().on('message', ...)来收发消息。

代码示例 (使用fork处理CPU密集型计算):

假设我们需要进行一个耗时的斐波那契数列计算。

parent.js (主进程)

const { fork } = require('child_process');

console.log('Main process started.');

const child = fork('./child.js'); // 启动子进程

const numberToCompute = 45; // 一个会导致显著计算耗时的数字

// 监听子进程发回的消息
child.on('message', (message) => {
  console.log(`Main process received result from child: Fibonacci(${numberToCompute}) = ${message.result}`);
});

// 向子进程发送任务
console.log(`Main process sending task to child: compute Fibonacci(${numberToCompute}).`);
child.send({ number: numberToCompute });

// 主进程可以继续执行其他任务,不会被计算阻塞
console.log('Main process continues to do other work...');

child.js (子进程)

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

// 监听父进程发来的消息
process.on('message', (message) => {
  console.log('Child process received task from parent:', message);
  const result = fibonacci(message.number);

  // 计算完成后,通过IPC通道将结果发送回父进程
  process.send({ result });

  // 子进程完成任务后可以自行退出
  process.exit();
});

通过这种方式,Node.js应用能够充分利用多核CPU资源,将计算密集型任务 offload 到子进程,同时保持主线程的响应性,以处理高并发的I/O请求。

结语

以上是JS高级篇面试考察点的内容,如有错误欢迎评论区指正。

从爬楼梯到算斐波那契,我终于弄懂了递归和动态规划这俩 "磨人精"

最近在代码界摸爬滚打,总被两个词按在地上摩擦 —— 递归和动态规划。这俩货就像数学题里的 "小明",天天换着花样折磨人,今天就来好好扒一扒它们的底裤。

递归:自己调自己的 "套娃大师"

递归这东西,说白了就是函数自己喊自己的名字。就像小时候问妈妈 "我从哪来的",妈妈说 "你是妈妈生的",再问 "妈妈从哪来的",妈妈说 "外婆生的"... 一直问到祖宗十八代,这就是递归的精髓 —— 找规律 + 找出口

比如算个阶乘,用递归(时间复杂度过高)写出来是这样:

function mul(n){

   if(n === 1){ // 出口:问到祖宗了

       return 1

   }

   return n * mul(n-1) 

}

这代码简洁得像诗,但算个斐波那契数列就露馅了。那个 1,1,2,3,5,8... 的数列,递归写法看着简单:

递归的 "中年危机":重复计算让 CPU 原地冒烟

function fb(n){

   if(n === 1 || n === 2){

       return 1

   }

   return fb(n-1) + fb(n-2)

}

这代码算个 n=10 还行,要是算 n=40,能让你喝杯咖啡回来还没出结果。就像你查快递单号,每次都要从快递员刚取件的时候查起,哪怕昨天刚查过。

给递归装个 "备忘录":记忆化搜索救场

比如爬楼梯问题:一次能爬 1 或 2 阶,到第 n 阶有几种走法?

后来我灵机一动,给递归加了个小本本(数组 f):

const f = []
var climbStairs = function (n) {
    if (n === 1 || n === 2) {
        return n
    }
    if (f[n] === undefined) { // 查小本本,没记过才计算
        f[n] = climbStairs(n - 1) + climbStairs(n - 2)
    }
    return f[n]
};

这招叫 "记忆化搜索"(提效),相当于把算过的结果记在通讯录里,下次直接拨号不用重新查号。

后来才发现,这代码就像给老年机装了智能手机的通讯录 —— 思路对但效率不够。全局数组 f 在多组测试用例下会残留历史数据,而且递归调用本身就有函数栈的开销,n 太大时还是扛不住。

彻底换个活法:动态规划的 "自底向上" 哲学

  • 站在已知的角度,通过已知来定位未知 最后改用纯动态规划找到动态方程)写法,直接逆袭:
var climbStairs = function (n){
    const f = []
    // 先搞定已知的1楼和2楼
    f[1] = 1
    f[2] = 2
    // 从3楼开始往上爬,每步都踩在前人的肩膀上
    for(let i = 3;i<=n;i++){
        f[i] = f[i-1] + f[i-2]
    }
    return f[n]
 }

这思路就像盖楼,从 1 层开始一层层往上盖,每一层的建材都直接用前两层的,根本不用回头看。没有递归的函数调用开销,也没有重复计算,效率直接拉满。

总结:三种写法的生存现状

写法 特点 适合场景
纯递归 代码简洁如诗 理解思路用,n≤30
记忆化搜索 加了缓存的递归 教学演示,n≤1000
动态规划 自底向上迭代 实际开发,n多大都不怕

总结:什么时候该套娃,什么时候该记笔记?

  • 递归适合简单问题或调试时用,写起来爽,但容易重复劳动

  • 动态规划适合复杂问题,虽然前期要多写几行,但跑起来飞快

  • 记住:所有动态规划问题,先建个空数组当小本本准没错

现在终于明白,递归是浪漫的诗人,只顾优雅不管效率; 动态规划是务实的会计,每一笔账都记得清清楚楚。 下次再遇到这俩货,我可不会再被它们忽悠了!

对于原型、原型链和继承的理解

原型和原型链是前端老生常谈的问题,以前常常在各种面试题中看到,我自己也背过不少次,但总是感觉磕磕巴巴的,明显没有真正理解这一概念,最近又要面试,再次看到这个问题,突然有些豁然开朗的感觉,因为前阵子参加
❌