阅读视图

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

JavaScript 内存泄漏与性能优化:V8引擎深度解析

当我们的应用变慢,甚至崩溃时,这可能并不是代码逻辑问题,而是内存和性能问题。理解V8引擎的工作原理,掌握性能分析和优化技巧,是现代JavaScript开发者必备的核心能力。

前言:从一次真实的内存泄漏说起

class User {
    constructor(name) {
        this.name = name;
        this.element = document.createElement('div');
        this.element.textContent = `用户: ${name}`;
        // 将DOM元素存储在类实例中
        this.element.onclick = () => this.handleClick();
        document.body.appendChild(this.element);
    }
    handleClick() {
        console.log(`${this.name} 被点击了`);
    }
    // 缺少清理方法!
}

// 使用
const users = [];
for (let i = 0; i < 1000; i++) {
    users.push(new User(`用户${i}`));
}

上述代码存在几个问题:

  1. 即使删除users数组,User实例也不会被垃圾回收:因为DOM元素和事件监听器仍然保持引用
  2. 内存使用会持续增长,直到页面崩溃

这个简单的例子展示了 JavaScript 内存管理的复杂性,本篇文章将深入讲解其背后的原理。

JavaScript内存管理基础

内存的生命周期:分配 → 使用 → 释放

1. 内存分配

// 原始类型:直接分配在栈内存
let number = 42;           // 数字
let string = 'hello';      // 字符串
let boolean = true;        // 布尔值
let nullValue = null;      // null
let undefinedValue;        // undefined
let symbol = Symbol('id'); // Symbol
let bigInt = 123n;         // BigInt

// 引用类型:分配在堆内存,栈中存储引用地址
let array = [1, 2, 3];     // 数组
let object = { a: 1 };     // 对象
let functionRef = () => {}; // 函数
let date = new Date();     // Date对象

2. 内存使用

function processData(data) {
  // 创建局部变量
  const processed = data.map(item => item * 2);

  // 创建闭包
  const counter = (() => {
    let count = 0;
    return () => ++count;
  })();

  // 使用内存
  console.log('处理数据:', processed);
  console.log('计数:', counter());

  // 内存引用关系
  const refExample = {
    data: processed,
    counter: counter,
    self: null // 自引用
  };
  refExample.self = refExample; // 循环引用

  return refExample;
}

3. 内存释放(垃圾回收)

function createMemory() {
  const largeArray = new Array(1000000).fill('x');
  return () => largeArray[0]; // 闭包保持引用
}

let memoryHolder = createMemory(); // 创建闭包并保持引用

// 手动释放引用
memoryHolder = null;

垃圾回收算法

1. 引用计数(Reference Counting)


class ReferenceCountingExample {
  constructor() {
    this.refCount = 0;
  }

  addReference() {
    this.refCount++;
    console.log(`引用计数增加: ${this.refCount}`);
  }

  removeReference() {
    this.refCount--;
    console.log(`引用计数减少: ${this.refCount}`);
    if (this.refCount === 0) {
      console.log('没有引用,可以回收内存');
      this.cleanup();
    }
  }

  cleanup() {
    console.log('执行清理操作');
  }
}

引用计数算法的问题:当A和B相互引用时,即使外部不再引用A和B,引用计数也不为0,无法回收。

2. 标记清除(Mark-and-Sweep)

class MarkAndSweepDemo {
  constructor() {
    this.marked = false;
    this.children = [];
  }

  // 模拟标记阶段
  mark() {
    if (this.marked) return;

    this.marked = true;
    console.log(`标记对象: ${this.name || '匿名对象'}`);

    // 递归标记所有引用的对象
    this.children.forEach(child => child.mark());
  }

  // 模拟清除阶段
  static sweep(objects) {
    const survivors = [];

    objects.forEach(obj => {
      if (obj.marked) {
        obj.marked = false; // 重置标记
        survivors.push(obj);
      } else {
        console.log(`回收对象: ${obj.name || '匿名对象'}`);
        obj.cleanup();
      }
    });

    return survivors;
  }

  cleanup() {
    console.log('清理对象资源');
  }
}

内存泄漏的常见模式

意外的全局变量

示例1:忘记声明变量

function createGlobalVariable() {
  // 错误:忘记写 var/let/const
  globalLeak = '这是一个全局变量'; // 实际上:window.globalLeak = ...
  console.log('创建了全局变量:', globalLeak);
}

示例2:this指向全局

function accidentalGlobalThis() {
  // 在非严格模式下,this指向window
  this.leakedProperty = '意外添加到window';
  console.log('this指向:', this === window);
}

示例3:事件监听器的this问题

const button = document.createElement('button');
button.textContent = '点击我';

button.addEventListener('click', function() {
  // 这里的this指向button元素
  this.clicked = true; // 正确:添加到DOM元素
  window.leakedFromEvent = '来自事件的泄漏'; // 错误:添加到window
});

解决方案

1. 使用严格模式
'use strict';
2. 使用let/const
function safeFunction() {
  const localVar = '局部变量';
  let anotherLocal = '另一个局部变量';
}
3. 使用模块作用域
(function() {
  var moduleScoped = '模块作用域变量';
})();
4. 使用类字段
class SafeClass {
  // 类字段自动绑定到实例
  leaked = '不会泄漏到全局';

  constructor() {
    this.instanceProperty = '实例属性';
  }

  method() {
    const localVar = '局部变量';
  }
}

遗忘的定时器和回调

示例1:未清理的定时器

class TimerLeak {
  constructor(name) {
    this.name = name;
    this.data = new Array(10000).fill('timer data');

    // 启动定时器但忘记清理
    this.intervalId = setInterval(() => {
      console.log(`${this.name} 定时器运行中...`);
      this.processData();
    }, 1000);
  }

  processData() {
    // 模拟数据处理
    return this.data.map(item => item.toUpperCase());
  }

  // 缺少清理方法!
}

示例2:未移除的事件监听器

class EventListenerLeak {
  constructor(elementId) {
    this.element = document.getElementById(elementId) ||
      document.createElement('div');
    this.data = new Array(5000).fill('event data');

    // 添加事件监听器
    this.handleClick = this.handleClick.bind(this);
    this.element.addEventListener('click', this.handleClick);

    // 添加多个监听器
    this.element.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
    window.addEventListener('resize', this.handleResize.bind(this));
  }

  handleClick() {
    console.log('元素被点击');
    this.processData();
  }

  handleMouseEnter() {
    console.log('鼠标进入');
  }

  handleResize() {
    console.log('窗口大小改变');
  }

  processData() {
    return this.data.slice();
  }

  // 忘记在销毁时移除监听器
}

示例3:Promise和回调地狱

class PromiseLeak {
  constructor() {
    this.data = new Array(10000).fill('promise data');
    this.pendingPromises = [];
  }

  startRequests() {
    for (let i = 0; i < 10; i++) {
      const promise = this.makeRequest(i)
        .then(response => {
          console.log(`请求 ${i} 完成`);
          this.processResponse(response);
        })
        .catch(error => {
          console.error(`请求 ${i} 失败:`, error);
        });

      this.pendingPromises.push(promise);
    }
  }

  makeRequest(id) {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({ id, data: this.data });
      }, Math.random() * 3000);
    });
  }

  processResponse(response) {
    // 处理响应
    return response;
  }

  // 忘记清理pendingPromises数组
}

解决方案

1. 正确的定时器管理
class SafeTimer {
  constructor(name) {
    this.name = name;
    this.data = new Array(1000).fill('safe data');
    this.intervals = new Set();
    this.timeouts = new Set();
  }

  startInterval(interval = 1000) {
    const id = setInterval(() => {
      console.log(`${this.name} 安全运行`);
    }, interval);

    this.intervals.add(id);
    return id;
  }

  startTimeout(delay = 2000) {
    const id = setTimeout(() => {
      console.log(`${this.name} 超时执行`);
      this.timeouts.delete(id);
    }, delay);

    this.timeouts.add(id);
    return id;
  }

  cleanup() {
    console.log(`清理 ${this.name}`);

    // 清理所有定时器
    this.intervals.forEach(id => clearInterval(id));
    this.timeouts.forEach(id => clearTimeout(id));

    this.intervals.clear();
    this.timeouts.clear();

    // 清理数据
    this.data.length = 0;
  }
}
2. 使用WeakRef和FinalizationRegistry
class WeakTimerManager {
  constructor() {
    this.timers = new Map(); // 保存定时器ID
    this.registry = new FinalizationRegistry((id) => {
      console.log(`对象被垃圾回收,清理定时器 ${id}`);
      clearInterval(id);
    });
  }

  register(object, callback, interval) {
    const weakRef = new WeakRef(object);
    const id = setInterval(() => {
      const obj = weakRef.deref();
      if (obj) {
        callback.call(obj);
      } else {
        console.log('对象已被回收,停止定时器');
        clearInterval(id);
      }
    }, interval);

    this.timers.set(object, id);
    this.registry.register(object, id, object);

    return id;
  }

  unregister(object) {
    const id = this.timers.get(object);
    if (id) {
      clearInterval(id);
      this.timers.delete(object);
      this.registry.unregister(object);
    }
  }
}
3. 事件监听器的正确管理
class SafeEventListener {
  constructor(element) {
    this.element = element;
    this.handlers = new Map(); // 存储事件处理函数
  }

  add(event, handler, options) {
    const boundHandler = handler.bind(this);
    this.element.addEventListener(event, boundHandler, options);

    // 保存引用以便清理
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event).push({ handler, boundHandler });

    return boundHandler;
  }

  remove(event, handler) {
    const handlers = this.handlers.get(event);
    if (handlers) {
      const index = handlers.findIndex(h => h.handler === handler);
      if (index !== -1) {
        const { boundHandler } = handlers[index];
        this.element.removeEventListener(event, boundHandler);
        handlers.splice(index, 1);
      }
    }
  }

  removeAll() {
    this.handlers.forEach((handlers, event) => {
      handlers.forEach(({ boundHandler }) => {
        this.element.removeEventListener(event, boundHandler);
      });
    });
    this.handlers.clear();
  }
}
4. 使用AbortController取消异步操作
class SafeAsyncOperations {
  constructor() {
    this.controllers = new Map();
  }

  async fetchWithTimeout(url, timeout = 5000) {
    const controller = new AbortController();
    const abortId = setTimeout(() => controller.abort(), timeout);

    try {
      const response = await fetch(url, {
        signal: controller.signal
      });
      clearTimeout(abortId);
      return response.json();
    } catch (error) {
      clearTimeout(abortId);
      if (error.name === 'AbortError') {
        console.log('请求被取消');
      }
      throw error;
    }
  }

  startPolling(url, interval = 30000) {
    const controller = new AbortController();
    const poll = async () => {
      if (controller.signal.aborted) return;

      try {
        const data = await this.fetchWithTimeout(url, 10000);
        console.log('轮询数据:', data);
      } catch (error) {
        console.error('轮询失败:', error);
      }

      if (!controller.signal.aborted) {
        setTimeout(poll, interval);
      }
    };

    poll();
    return controller;
  }
}
5. 使用清理回调模式
function withCleanup(callback) {
  const cleanups = [];

  const cleanup = () => {
    cleanups.forEach(fn => {
      try {
        fn();
      } catch (error) {
        console.error('清理错误:', error);
      }
    });
    cleanups.length = 0;
  };

  const api = {
    addTimeout(fn, delay) {
      const id = setTimeout(fn, delay);
      cleanups.push(() => clearTimeout(id));
      return id;
    },

    addInterval(fn, interval) {
      const id = setInterval(fn, interval);
      cleanups.push(() => clearInterval(id));
      return id;
    },

    addEventListener(element, event, handler, options) {
      element.addEventListener(event, handler, options);
      cleanups.push(() => element.removeEventListener(event, handler, options));
    },

    cleanup
  };

  try {
    callback(api);
  } catch (error) {
    cleanup();
    throw error;
  }

  return cleanup;
}

DOM 引用和闭包

示例1:DOM引用泄漏

class DOMMemoryLeak {
  constructor() {
    // 保存DOM引用
    this.elementRefs = [];
    this.dataStore = new Array(10000).fill('DOM data');
  }

  createElements(count = 100) {
    for (let i = 0; i < count; i++) {
      const div = document.createElement('div');
      div.className = 'leaky-element';
      div.textContent = `元素 ${i}: ${this.dataStore[i]}`;

      // 保存DOM引用
      this.elementRefs.push(div);

      // 添加到页面
      document.body.appendChild(div);
    }
  }

  removeElements() {
    // 从DOM移除,但引用仍然存在
    this.elementRefs.forEach(el => {
      if (el.parentNode) {
        el.parentNode.removeChild(el);
      }
    });

    // 忘记清理数组引用
    console.log('元素已从DOM移除,但引用仍保存在内存中');
  }
}

示例2:闭包保持外部引用

function createClosureLeak() {
  const largeData = new Array(100000).fill('闭包数据');
  let eventHandler;

  return {
    setup(element) {
      // 闭包保持对largeData的引用
      eventHandler = () => {
        console.log('数据大小:', largeData.length);
        // 即使不再需要,largeData也无法被回收
      };

      element.addEventListener('click', eventHandler);
    },

    teardown(element) {
      if (eventHandler) {
        element.removeEventListener('click', eventHandler);
        // 但是eventHandler闭包仍然引用largeData
      }
    }
  };
}

示例3:缓存的不当使用

class CacheLeak {
  constructor() {
    this.cache = new Map();
  }

  getData(key) {
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    // 模拟获取数据
    const data = {
      id: key,
      content: new Array(10000).fill('缓存数据').join(''),
      timestamp: Date.now()
    };

    this.cache.set(key, data);

    // 问题:缓存永远增长,从不清理
    return data;
  }

  // 忘记实现缓存清理策略
}

解决方案

1. 使用WeakMap和WeakSet
class SafeDOMManager {
  constructor() {
    // WeakMap保持对DOM元素的弱引用
    this.elementData = new WeakMap();
    this.elementListeners = new WeakMap();
  }

  registerElement(element, data) {
    this.elementData.set(element, data);

    const handleClick = () => {
      const elementData = this.elementData.get(element);
      console.log('点击元素:', elementData);
    };

    element.addEventListener('click', handleClick);

    // 保存监听器以便清理
    this.elementListeners.set(element, {
      click: handleClick
    });
  }

  unregisterElement(element) {
    const listeners = this.elementListeners.get(element);
    if (listeners) {
      element.removeEventListener('click', listeners.click);
      this.elementListeners.delete(element);
    }
    this.elementData.delete(element);
  }
}
2. 使用WeakRef和FinalizationRegistry清理DOM引用
class DOMReferenceManager {
  constructor() {
    this.registry = new FinalizationRegistry((element) => {
      console.log('DOM元素被垃圾回收,清理相关资源');
      // 清理与元素关联的资源
    });

    this.weakRefs = new Set();
  }

  trackElement(element, data) {
    const weakRef = new WeakRef(element);
    this.weakRefs.add(weakRef);

    this.registry.register(element, {
      element: element,
      data: data
    }, weakRef);

    return weakRef;
  }
}
3. 避免闭包保持不必要引用
function createSafeClosure() {
  // 需要保持的数据
  const essentialData = {
    config: { maxSize: 100 },
    state: { count: 0 }
  };

  // 不需要保持的大数据
  let temporaryData = new Array(100000).fill('临时数据');

  const processTemporaryData = () => {
    // 处理临时数据
    const result = temporaryData.map(item => item.toUpperCase());

    // 处理后立即释放引用
    temporaryData = null;

    return result;
  };

  return {
    process: processTemporaryData,

    updateConfig(newConfig) {
      Object.assign(essentialData.config, newConfig);
    },

    getState() {
      return { ...essentialData.state };
    }
  };
}

V8引擎优化策略

隐藏类(Hidden Classes)

隐藏类是V8内部优化对象访问的机制,相同结构的对象共享同一个隐藏类:

function createOptimizedObject() {
  const obj = {};
  obj.a = 1;  // 创建隐藏类 C0
  obj.b = 2;  // 创建隐藏类 C1
  obj.c = 3;  // 创建隐藏类 C2
  return obj;
}

内联缓存(Inline Caching)

内联缓存是V8优化属性访问的重要机制,通过缓存对象的隐藏类和属性位置来加速访问:

单态(Monomorphic):总是访问同一类型的对象

function monomorphicAccess(objects) {
  let sum = 0;
  for (const obj of objects) {
    sum += obj.value; // 总是访问相同隐藏类的对象
  }
  return sum;
}
const monomorphicObjects = [];
class TypeA { constructor(v) { this.value = v; } }
for (let i = 0; i < 10000; i++) {
  monomorphicObjects.push(new TypeA(i));
}

多态(Polymorphic):访问少量不同类型的对象

function polymorphicAccess(objects) {
  let sum = 0;
  for (const obj of objects) {
    sum += obj.value; // 访问2-4种隐藏类的对象
  }
  return sum;
}
const polymorphicObjects = [];
class TypeA { constructor(v) { this.value = v; } }
class TypeB { constructor(v) { this.value = v; } }
for (let i = 0; i < 10000; i++) {
  polymorphicObjects.push(i % 2 === 0 ? new TypeA(i) : new TypeB(i));
}

超态(Megamorphic):访问多种类型的对象

function megamorphicAccess(objects) {
  let sum = 0;
  for (const obj of objects) {
    sum += obj.value; // 访问超过4种隐藏类的对象
  }
  return sum;
}
const megamorphicObjects = [];
class TypeA { constructor(v) { this.value = v; } }
class TypeB { constructor(v) { this.value = v; } }
class TypeC { constructor(v) { this.value = v; } }
class TypeD { constructor(v) { this.value = v; } }
class TypeE { constructor(v) { this.value = v; } }
const types = [TypeA, TypeB, TypeC, TypeD, TypeE];
for (let i = 0; i < 10000; i++) {
  const Type = types[i % 5];
  megamorphicObjects.push(new Type(i));
}

内存管理黄金法则

  • 及时释放不再需要的引用
  • 避免创建不必要的全局变量
  • 小心处理闭包和回调
  • 使用弱引用管理缓存
  • 定期检查和清理内存

结语

性能优化是一个持续的过程,而不是一次性的任务。最好的性能优化是在问题发生之前预防它。理解V8引擎的工作原理,掌握正确的工具使用方法,建立完善的监控体系,这样才能构建出高性能、高可用的Web应用。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript 函数式编程核心概念

函数式编程不是一种新的语法,而是一种思考方式。它让我们用更简洁、更可预测、更可测试的方式编写代码。理解这些概念,将彻底改变我们编写 JavaScript 的方式。

前言:从命令式到声明式的转变

命令式编程:关注"怎么做"

const numbers = [1, 2, 3, 4, 5];
const doubled = [];

for (let i = 0; i < numbers.length; i++) {
    doubled.push(numbers[i] * 2);
}
console.log(doubled); // [2, 4, 6, 8, 10]

函数式编程:关注"做什么"

const numbers2 = [1, 2, 3, 4, 5];
const doubled2 = numbers2.map(n => n * 2);
console.log(doubled2); // [2, 4, 6, 8, 10]

立即调用函数表达式(IIFE)

IIFE 的基本概念

IIFE(Immediately Invoked Function Expression)是定义后立即执行的函数表达式。

IIFE的基本语法

语法1:括号包裹整个调用

(function() {
    console.log('括号包裹整个调用');
}());

语法2:括号包裹函数,然后调用

(function() {
    console.log('括号包裹函数,然后调用');
})();

注:以上两种写法只是两种不同的风格,它们在功能上完全等价,没有实质性区别。

语法对比:函数声明 vs 函数表达式 vs IIFE

1. 函数声明 - 不会立即执行

function greet() {
    console.log('Hello!');
}
greet(); // 需要显式调用

2. 函数表达式 - 也不会立即执行

const greetExpr = function() {
    console.log('Hello from expression!');
};
greetExpr(); // 需要显式调用

3. IIFE - 定义后立即执行

(function() {
    console.log('Hello from IIFE!'); // 立即执行
})();

IIFE操作符

在 JavaScript 中,以 function 开头的语句会被解析为函数声明,而函数声明不能直接跟 () 执行,因此出现了操作符。添加这些操作符后,JavaScript 引擎会将 function... 解析为函数表达式,这样就可以立即执行了。

逻辑非运算符!

const result = !function () {
  console.log('逻辑非');
}();
console.log(result);  // true

上述代码会将立即执行函数的返回值进行取反,由于上述函数没有明确返回值,故默认返回 undefined!undefined 结果为 true,因此 result 的值为 true

一元加运算符+

const result = +function () {
  console.log('一元加');
}();
console.log(result);  // NaN 

上述代码立即执行函数的返回值转换为数字,由于上述函数没有明确返回值,故默认返回 undefinedundefined 转为为数字结果为 NaN,因此 result 的值为 NaN

void 运算符

const result = void function () {
  console.log('void');
}();
console.log(result);  // undefined

上述代码中,立即执行函数的返回值永远为 undefined ,这是 void 关键字的特性使然。

IIFE 的实际应用

应用1:创建私有作用域

使用IIFE可以创建模块作用域,模块作用域内变量在IIFE外部无法直接访问,即为私有作用域。在IIFE内部,我们可以提供公共方法,去访问这些私有作用域:

(function () {
  // 这些变量在IIFE外部无法访问
  var privateVar = '我是私有的';
  var secret = 42;

  // 提供公共方法访问私有变量
  myModule = {
    getSecret: function () {
      return secret;
    },
    publicMethod: function () {
      console.log('公共方法可以访问私有变量:', privateVar);
    }
  };
})();

console.log(myModule.getSecret()); // 42
myModule.publicMethod(); // "公共方法可以访问私有变量: 我是私有的"
console.log(privateVar); // ReferenceError: privateVar is not defined
console.log(secret); // ReferenceError: secret is not defined

应用2:避免变量冲突

假设有多个第三方库,它们都使用了同一个变量,如 jQuery 和 Prototype.js ,它们都用了 $ 符号,直接使用 $ 符号就会冲突。这种情况下,我们就可以采用 IIFE 的方式,将 $ 保护起来:

(function($) {
    // 在这个作用域内,$就是jQuery
    $(document).ready(function() {
        console.log('jQuery准备好了');
    });
})(jQuery); // 传入jQuery对象

// Prototype.js的$不受影响

IIFE 的现代替代方案

方案1:ES Module(最佳方案)

// module.js
const privateVar = '私有变量';
export const publicVar = '公共变量';
export function publicMethod() {
  return privateVar;
}

// main.js
import { publicVar, publicMethod } from './module.js';

方案2:块级作用域 + 闭包

{
  const privateData = '块级私有数据';
  let counter = 0;

  counterModule = {
    increment: () => ++counter,
    getValue: () => counter
  };
}

console.log(counterModule.increment()); // 1
console.log(counterModule.getValue());  // 1
// console.log(privateData); // ReferenceError
// console.log(counter); // ReferenceError

方案3:类与私有字段

class SecureModule {
  #secret = '绝密信息';
  #counter = 0;

  getSecret() {
    return this.#secret;
  }

  increment() {
    return ++this.#counter;
  }
}

const module = new SecureModule();
console.log(module.getSecret()); // "绝密信息"
console.log(module.increment()); // 1
// console.log(module.#secret); // SyntaxError

纯函数(Pure Functions)

什么是纯函数?

纯函数是函数式编程的基石,它具有两个核心特征:

  • 相同的输入,总是得到相同的输出
  • 没有副作用

纯函数 vs 非纯函数

纯函数示例

function add(a, b) {
  return a + b;
}

非纯函数示例

let counter = 0;
function increment() {
  counter++; // 修改外部状态
  return counter;
}

纯函数的优势

优势1:可预测性

纯函数中对于相同的输入,总是得到相同的输出,因此其结果是可以预测的:

const calculatePrice = (price, taxRate) => price * (1 + taxRate);
console.log('价格计算: $100 * 1.1 =', calculatePrice(100, 0.1)); // 110
console.log('再次计算: $100 * 1.1 =', calculatePrice(100, 0.1)); // 110(总是相同)

优势2:易于测试

function testCalculatePrice() {
  const result = calculatePrice(100, 0.1);
  const expected = 110;
  console.log(`测试 ${result === expected ? '通过' : '失败'}: ${result} === ${expected}`);
}
testCalculatePrice();

优势3:引用透明性

const price1 = calculatePrice(100, 0.1);
const price2 = calculatePrice(100, 0.1);
console.log('price1 === price2:', price1 === price2); // true
console.log('可以直接替换:', calculatePrice(100, 0.1) === 110); // true

优势4:可缓存性

function square(x) {
  console.log(`计算 ${x} 的平方`);
  return x * x;
}

// 缓存包装器
function memoize(fn) {
  const cache = {};
  return function (x) {
    if (cache[x] !== undefined) {
      console.log(`从缓存获取 ${x} 的平方`);
      return cache[x];
    }
    const result = fn(x);
    cache[x] = result;
    return result;
  };
}

// 使用缓存
const memoizedSquare = memoize(square);

console.log(memoizedSquare(5)); // 第一次计算
console.log(memoizedSquare(5)); // 从缓存获取

常见的副作用及其解决方案

副作用类型1:修改输入参数

const impureAddToArray = (array, item) => {
  array.push(item); // 副作用:修改输入参数
  return array;
};
解法方案:返回新数组,不修改原数组
const pureAddToArray = (array, item) => {
  return [...array, item]; // 返回新数组,不修改原数组
};

副作用类型2:修改外部变量

let globalCount = 0;
const impureIncrement = () => {
  globalCount++; // 副作用:修改全局状态
  return globalCount;
};
解决方案:返回新值
const pureIncrement = (count) => {
  return count + 1; // 不修改外部状态
};

副作用类型3:I/O操作

const impureFetchData = (url) => {
  // 副作用:网络请求
  fetch(url)
    .then(response => response.json())
    .then(data => console.log('数据:', data));
};
解决方案:返回一个函数,延迟执行副作用
const pureFetchData = (url) => {
  // 返回一个函数,延迟执行副作用
  return () => {
    return fetch(url)
      .then(response => response.json());
  };
};

副作用类型4:异常和错误

const impureParseJSON = (str) => {
  return JSON.parse(str); // 可能抛出异常
};
解决方案:异常捕获
const pureParseJSON = (str) => {
  try {
    return { success: true, data: JSON.parse(str) };
  } catch (error) {
    return { success: false, error: error.message };
  }
};

高阶函数(Higher-Order Functions)

什么是高阶函数?

高阶函数是指能够接受函数作为参数,或者返回函数作为结果的函数:

接受函数作为参数

const greet = (name, formatter) => {
  return formatter(name);
};
const shout = (name) => `${name.toUpperCase()}!`;
const whisper = (name) => `psst... ${name}...`;
console.log(greet('zhangsan', shout));   // "ZHANGSAN!"
console.log(greet('lisi', whisper));   // "psst... lisi..."

返回函数作为结果

const multiplier = (factor) => {
  return (number) => number * factor;
};
const double = multiplier(2);
const triple = multiplier(3);
console.log('double(5):', double(5)); // 10
console.log('triple(5):', triple(5)); // 15

同时接受和返回函数

const compose = (f, g) => {
  return (x) => f(g(x));
};
const addOne = (x) => x + 1;
const square = (x) => x * x;
const addOneThenSquare = compose(square, addOne);
console.log('addOneThenSquare(2):', addOneThenSquare(2));

柯里化(Currying)

什么是柯里化?

柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

// 原始函数(多参数)
const addThreeNumbers = (a, b, c) => a + b + c;
console.log('原始函数:', addThreeNumbers(1, 2, 3)); // 6

// 柯里化版本
const curriedAdd = (a) => {
  return (b) => {
    return (c) => {
      return a + b + c;
    };
  };
};

console.log('柯里化版本:', curriedAdd(1)(2)(3)); // 6

柯里化的优势

1. 参数复用

const addFive = curriedAdd(5);
console.log('addFive(10)(15):', addFive(10)(15)); // 30

2. 延迟计算

const multiply = (a) => (b) => a * b;
const double = multiply(2);
const triple = multiply(3);

console.log('double(10):', double(10)); // 20
console.log('triple(10):', triple(10)); // 30

3. 函数组合

const greet = (greeting) => (name) => `${greeting}, ${name}!`;
const sayHello = greet('Hello');
const sayHi = greet('Hi');

console.log(sayHello('zhangsan')); // "Hello, zhangsan!"
console.log(sayHi('lisi'));      // "Hi, lisi!"

手动实现柯里化

const manualCurry = (fn) => {
  const arity = fn.length; // 函数期望的参数个数

  const curried = (...args) => {
    if (args.length >= arity) {
      return fn(...args);
    } else {
      return (...moreArgs) => {
        return curried(...args, ...moreArgs);
      };
    }
  };
  return curried;
};

函数组合(Function Composition)

什么是函数组合?

函数组合是将多个函数组合成一个新函数的过程,新函数的输出作为下一个函数的输入。

手动组合

const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 手动组合:从右向左
const addOneThenDoubleThenSquare = (x) => {
  const afterAddOne = addOne(x);
  const afterDouble = double(afterAddOne);
  const afterSquare = square(afterDouble);
  return afterSquare;
};
console.log('手动组合:', addOneThenDoubleThenSquare(2)); // ((2+1)*2)^2 = 36

组合函数

const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 组合函数
const compose = (...fns) => (x) => 
  fns.reduceRight((acc, fn) => fn(acc), x);

// 从右向左组合:square(double(addOne(x)))
const addOneThenDoubleThenSquare = compose(square, double, addOne);

console.log('函数组合:', addOneThenDoubleThenSquare(2));

管道函数

const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;

// 管道函数
const pipe = (...fns) => {
  return (initialValue) => {
    return fns.reduce((value, fn) => fn(value), initialValue);
  };
};

// 从左向右组合:addOne → double → square
const addOneThenDoubleThenSquarePipe = pipe(addOne, double, square);

console.log('管道组合:', addOneThenDoubleThenSquarePipe(2));

Pointfree 风格编程

Pointfree 风格(无参数风格)是一种编程风格,函数定义不显式提及它所操作的数据参数。

非 Pointfree 风格示例

const nonPointfree = (users) => {
  return users
    .filter(user => user.age >= 18)
    .map(user => user.name)
    .map(name => name.toUpperCase());
};

Pointfree 风格

const isAdult = user => user.age >= 18;
const getName = user => user.name;
const toUpperCase = str => str.toUpperCase();

const getAdultUserNames = (users) => {
  return users
    .filter(isAdult)
    .map(getName)
    .map(toUpperCase);
};

现代 JavaScript 中的函数式特性

1. 箭头函数

const add = (a, b) => a + b;

2. 解构与剩余参数

const processArgs = (first, second, ...rest) => {
  console.log('前两个:', first, second);
  console.log('其余:', rest);
};

3. 默认参数

const greet = (name, greeting = 'Hello') => `${greeting}, ${name}!`;

4. 数组和对象的扩展运算

const combine = (...arrays) => [].concat(...arrays);
const merge = (...objects) => Object.assign({}, ...objects);

5. Promise 和 async/await

const asyncPipe = (...fns) => async (initial) => {
  return fns.reduce(async (value, fn) => {
    const resolvedValue = await value;
    return fn(resolvedValue);
  }, Promise.resolve(initial));
};

6. 新的数组方法

const numbers = [1, 2, 3, 4, 5];
const flatMapped = numbers.flatMap(x => [x, x * 2]);

7. 可选链和空值合并

const safeGet = (obj, path) => {
  return path.split('.').reduce(
    (acc, key) => acc?.[key] ?? null,
    obj
  );
};

结语

函数式编程提供了一套强大的工具和思维方式,通过掌握这些核心概念,我们能够编写出更简洁、更可维护、更可测试的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

CommonJS vs ES Module:现代JavaScript模块化对决

在当今的JavaScript生态中,CommonJS和ES Module是两个最重要的模块系统。理解它们的差异和适用场景,是每个前端开发者必须掌握的核心技能。

前言:为什么需要对比两大模块系统?

我们先来看看同一个功能,在两大模块中的不同写法:

// CommonJS版本
const { add } = require('./math.cjs');
console.log(add(2, 3));

// ES Module版本
import { add } from './math.mjs';
console.log(add(2, 3));

这两种写法看似相似,实则天差地别!在实际开发中,我们应该选择哪一种?为什么?何时使用?这些问题的答案决定了项目的架构质量。

核心差异全景对比

语法差异对比表

特性 CommonJS ES Module
导出语法 module.exports = value
exports.name = value
export const name = value
export default value
导入语法 const module = require(path) import module from 'path'
默认导出 module.exports = value export default value
命名导出 exports.name = value export const name = value
导入重命名 const { name: newName } = require() import { name as newName }
导入所有 const module = require() import * as module
条件导入 支持 不支持(需用动态导入)
动态导入 原生支持(运行时) import()(返回Promise)

导入/导出对比

导出方式对比

以以下代码为例:

const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
const PI = 3.14159;

CommonJS导出

方式1:单个导出
module.exports.add = add;
module.exports.multiply = multiply;
方式2:对象导出
module.exports = {
    add,
    multiply,
    PI
};

ES Module导出

默认导出(只能有一个)
export default function calculate() {
    return '计算器';
}
分别导出
exports {
    add,
    multiply,
    PI
};

导入方式对比

CommonJS导入

默认导入
const math = require('./math.cjs');
const { add, PI } = require('./math.cjs');
const addFunc = require('./math.cjs').add;
条件导入(运行时)
let mathModule;
if (process.env.NODE_ENV === 'production') {
    mathModule = require('./math-prod.cjs');
} else {
    mathModule = require('./math-dev.cjs');
}

ES Module导入

默认导入
import math from './math.mjs'; // 默认导入
import { add, PI } from './math.mjs'; // 命名导入
import { add as sum } from './math.mjs'; // 重命名导入
import * as mathUtils from './math.mjs'; // 命名空间导入
动态导入(返回Promise)
if (condition) {
    const math = await import('./math.mjs');
}

底层机制解析

加载时机对比

CommonJS:运行时加载

const cjsMath = require('./math.cjs'); // 同步加载,阻塞执行
console.log('CJS加载后执行');

ES Module:编译时静态解析

// import语句在代码执行前就被解析
import('./math.mjs').then(esmMath => {
    console.log('ESM加载后执行');
});

核心差异

  1. CommonJS会阻塞代码执行
  2. ES Module不会阻塞后续代码

值传递方式对比

CommonJS 值传递

// counter.cjs
let count = 0;
function increment() {
    count++;
    console.log('CJS内部count:', count);
}
module.exports = { count, increment };

// app-cjs.cjs
const counter = require('./counter.cjs');
console.log('初始count:', counter.count); // 0
counter.increment(); // CJS内部count: 1
console.log('外部count:', counter.count); // 0 !!! 值没有变
counter.count = 10; // 修改导出值
console.log('修改后外部count:', counter.count); // 10
counter.increment(); // CJS内部count: 2
console.log('再次检查外部count:', counter.count); // 10

ES Module 值传递

// counter.mjs
export let count = 0;
export function increment() {
    count++;
    console.log('ESM内部count:', count);
}

// app-esm.mjs
import { count, increment } from './counter.mjs';
console.log('初始count:', count); // 0
increment(); // ESM内部count: 1
console.log('外部count:', count); // 1 !!! 值同步更新
// count = 10; // 错误!不能直接修改导入的值

核心差异

  1. CommonJS:值拷贝,修改不影响原模块
  2. ES Module:实时绑定,修改同步更新
  3. CommonJS可以在导入侧修改导出值(但不会影响原模块)
  4. ES Module导入的值是只读的(在严格模式下)

循环依赖处理对比

循环依赖:模块A依赖模块B,模块B依赖模块A。

  1. CommonJS:遇到 require 时立即执行模块,可能拿到不完全的模块
  2. ES Module:先建立导入导出关系,再执行代码,访问未初始化变量得到 undefined
  3. 两种方式都能处理循环依赖,但行为不同

性能与优化对比

Tree Shaking能力对比

CommonJS(难以Tree Shaking)

// math-cjs.cjs
exports.add = (a, b) => a + b;           // ✓ 被使用
exports.multiply = (a, b) => a * b;      // ✗ 未使用
exports.divide = (a, b) => a / b;        // ✗ 未使用
exports.PI = 3.14159;                    // ✓ 被使用

// 使用方
const { add, PI } = require('./math-cjs.cjs');
console.log(add(2, 3), PI);

// 打包结果:通常全部包含,因为难以静态分析

ES Module(支持Tree Shaking)

// math-esm.mjs
export const add = (a, b) => a + b;           // ✓ 被使用
export const multiply = (a, b) => a * b;      // ✗ 未使用
export const divide = (a, b) => a / b;        // ✗ 未使用
export const PI = 3.14159;                    // ✓ 被使用

// 使用方
import { add, PI } from './math-esm.mjs';
console.log(add(2, 3), PI);

// 打包结果:只包含add和PI,multiply和divide被移除

关键差异:

  1. ES Module:静态结构,编译时可分析
  2. CommonJS:动态结构,运行时才能确定
  3. 现代工具可部分分析CommonJS,但效果有限

优化建议:

  1. 库开发优先使用ES Module
  2. 使用lodash-es而不是lodash
  3. 配置package.json的sideEffects字段

内存使用对比

CommonJS的内存行为

const memoryTestCJS = () => {
    const modules = [];
    
    // 多次加载同一模块
    for (let i = 0; i < 1000; i++) {
        // 清除缓存,强制重新加载
        delete require.cache[require.resolve('./memory-module.cjs')];
        const module = require('./memory-module.cjs');
        modules.push(module);
    }
    
    console.log('CJS模块实例数:', modules.length);
    // 每个require.cache都会创建一个新的模块实例
};

ES Module的内存行为

const memoryTestESM = async () => {
    const modules = [];
    
    // ES Module有模块映射缓存
    for (let i = 0; i < 1000; i++) {
        // 相同URL会返回缓存的模块
        const module = await import('./memory-module.mjs');
        modules.push(module);
    }
    
    console.log('ESM模块实例数:', modules.length);
    // 相同URL只加载一次,共享实例
};

关键差异:

  1. CommonJS:require.cache可管理,可清除
  2. ES Module:模块映射不可变,不可清除
  3. ES Module更节省内存,但灵活性较低

模块系统的未来

趋势1:ES Module成为标准

  • Node.js正在逐步转向ES Module优先
  • 浏览器原生支持不断完善

趋势2:导入映射(Import Maps)标准化

  • 控制模块解析,减少构建工具依赖

趋势3:模块联邦(Module Federation)

  • 微前端架构,跨应用共享模块
  • Webpack 5+ 原生支持

趋势4:WebAssembly模块集成

  • 与JavaScript模块无缝协作

趋势5:边缘计算优化

  • CDN级别的模块分发和缓存

趋势6:TypeScript与模块深度集成

  • 类型安全的模块导入导出

结语

CommonJS和ES Module代表了JavaScript模块化的两个时代。CommonJS以其实用性成为过去十年的主流,而ES Module以其标准化、静态分析和现代特性代表着未来。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript 模块化演进:从 IIFE 到 ES Module 的完整历程

从早期的脚本标签加载,到现代的 ES Module,JavaScript 模块化走过了一条漫长而精彩的道路。理解这段历史,不仅能帮我们写出更好的代码,还能深入理解现代构建工具的工作原理。

前言:为什么需要模块化?

在模块化出现之前,我们通常会这样写代码:

var globalData = '我是全局变量';

function utility1() {
    // 可能修改全局变量
    globalData = '被修改了';
}

function utility2() {
    // 依赖utility1
    utility1();
    console.log(globalData);
}

这种写法在小型项目中尚可,但在大型项目中会带来严重的问题:

  1. 全局命名空间污染
  2. 依赖关系不明确
  3. 难以维护和测试
  4. 无法按需加载

模块化的演进历程

原始时期:Script 标签与全局命名空间

<!-- 1995-2009:简单的脚本加载 -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="main.js"></script>

在这种脚本加载中,存在以下问题:

  1. 所有脚本共享全局作用域
  2. 脚本中的变量可能被其他脚本覆盖
  3. 依赖关系必须手动管理
  4. 如果utils.js依赖jquery.js,必须确保加载顺序

命名空间模式(Namespace Pattern)

// 2005年左右:使用对象作为命名空间
var MYAPP = MYAPP || {}; // 防止重复定义

MYAPP.utils = {
    trim: function(str) {
        return str.replace(/^\s+|\s+$/g, '');
    },
    formatDate: function(date) {
        // ...
    }
};

MYAPP.models = {
    User: function(name) {
        this.name = name;
    }
};

命名空间的优点

  1. 减少了全局变量数量
  2. 有一定的组织结构

命名空间的缺点

  1. 所有数据仍然是公开的
  2. 无法实现私有成员
  3. 依赖关系依然不明确

IIFE 模式(立即执行函数)

// 2008年左右:使用闭包实现模块化
var Module = (function() {
    // 私有变量
    var privateVar = '我是私有的';
    
    // 私有函数
    function privateMethod() {
        console.log(privateVar);
    }
    
    // 公有API
    return {
        publicMethod: function() {
            privateMethod();
            return '公共方法';
        },
        publicVar: '公共变量'
    };
})();

// 使用模块
Module.publicMethod(); // 可以访问
// Module.privateVar; // 报错:undefined
// Module.privateMethod(); // 报错:不是函数

IIFE 模式的优点

  1. 实现了真正的私有成员
  2. 避免了全局污染
  3. 代码更加安全

IIFE 模式的缺点

  1. 依赖管理仍需手动处理
  2. 无法实现按需加载
  3. 模块定义分散

IIFE 依赖注入

var Module = (function($, _) {
    // 使用依赖
    function init() {
        $('#app').hide();
        _.each([1, 2, 3], console.log);
    }
    
    return {
        init: init
    };
})(jQuery, _); // 依赖作为参数传入

IIFE 依赖注入的优点

  1. 依赖关系明确
  2. 可以替换依赖的实现

IIFE 依赖注入的缺点

  1. 依赖需要提前加载
  2. 依赖顺序必须正确

模块加载器的出现

随着 Web 应用越来越复杂,社区开始探索更先进的模块系统:

2009年:CommonJS(服务器端)

var fs = require('fs');
var _ = require('lodash');

exports.myFunction = function() {
    // ...
};

2011年:AMD(浏览器端,异步加载)

define(['jquery', 'lodash'], function($, _) {
    return {
        init: function() {
            // 使用$和_
        }
    };
});

2014年:UMD(通用模块定义:兼容CommonJS和AMD)

(function(root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = factory(require('jquery'));
    } else {
        // 浏览器全局变量
        root.myModule = factory(root.jQuery);
    }
}(this, function($) {
    // 模块代码
    return {
        // ...
    };
}));

2015年:ES Module(现代标准)

export const PI = 3.14159;
export function add(a, b) {
    return a + b;
}
export function multiply(a, b) {
    return a * b;
}

四大模块系统对比

特性 CommonJS AMD UMD ES Module
环境 服务器端(Node.js) 浏览器端 通用 现代浏览器/Node.js
加载方式 同步加载 异步加载 根据环境决定 静态/动态加载
语法 require() / exports define() / require() 条件判断多种语法 import / export
时机 运行时 运行时 运行时 编译时(静态)
Tree Shaking 不支持 不支持 不支持 支持
静态分析 困难 困难 困难 容易

Tree Shaking 深度解析

什么是 Tree Shaking?

Tree Shaking 是一种通过静态分析从代码中移除未使用代码(死代码)的技术。这个名字源于摇动树木,让枯叶(未使用的代码)掉落。

export function add(a, b) {
    return a + b;
}

export function multiply(a, b) {
    return a * b;
}

export function unusedFunction() {
    console.log('这个函数从未被使用');
    return '无用';
}

// app.js
import { add } from './math.js';

console.log(add(2, 3)); // 只用到了add

// 打包后,multiply和unusedFunction可以被移除

Tree Shaking 的实现条件

1. 使用 ES Module 语法

// ✅ 使用 ES Module 语法可以被Tree Shaking
export function used() { return '使用'; }
export function unused() { return '未使用'; }

// ❌ CommonJS 难以Tree Shaking
module.exports = {
    used: function() { return '使用'; },
    unused: function() { return '未使用'; }
};

2. 无副作用/纯函数(Pure Function)

// ✅ 纯函数,可以安全移除
export const PI = 3.14159;
export function square(x) { return x * x; }

// ⚠️ 有副作用,需要小心处理
export function logMessage(msg) {
    console.log(msg); // 副作用:控制台输出
    return msg;
}

3. 静态导入

// ✅ 静态导入可以被分析
import { add } from './math.js';

// ❌ 动态导入难以分析
const moduleName = './math.js';
import(moduleName).then(module => {
    // 运行时才知道使用什么
});

4. 模块级别的分析

// ✅ 整个模块可以被分析
export { add, multiply } from './math.js';

5. 使用工具标记

// package.json 中的 sideEffects 字段
{
    "name": "my-package",
    "sideEffects": false, // 整个包都无副作用
    "sideEffects": [      // 或指定有副作用的文件
        "*.css",
        "src/polyfills.js"
    ]
}

Tree Shaking 原理详解

// math-complex.js
export function add(a, b) {           // 被使用
    return a + b;
}

export function multiply(a, b) {      // 被使用
    return a * b;
}

export function divide(a, b) {        // 未被使用
    return a / b;
}

export const PI = 3.14159;            // 被使用

export const UNUSED_CONST = '未使用'; // 未被使用

// 副作用代码
export function init() {              // 有副作用,但未被调用
    console.log('初始化');
    window.MATH = { version: '1.0' };
}

// 默认导出(可能有副作用)
export default function() {
    console.log('默认导出');
}

// app.js
import { add, PI } from './math-complex.js';
import mathDefault from './math-complex.js';

console.log(add(2, 3), PI);
mathDefault();

以上述代码为例,Tree Shaking 步骤流程如下:

  1. 源代码分析

  2. 构建依赖图

    app.js
        ├── 导入: add (来自math-complex.js)
        ├── 导入: PI (来自math-complex.js)
        └── 导入: default (来自math-complex.js)
        
        math-complex.js
        ├── 导出: add ✓ (被使用)
        ├── 导出: multiply ✗ (未使用)
        ├── 导出: divide ✗ (未使用)
        ├── 导出: PI ✓ (被使用)
        ├── 导出: UNUSED_CONST ✗ (未使用)
        ├── 导出: init ✗ (未使用,但有副作用)
        └── 导出: default ✓ (被使用)
    
  3. 标记活跃代码:从入口开始,标记所有可达的代码

  4. 消除死代码:移除未被标记的代码

  5. 最终打包结果包含:

    • add 函数
    • PI 常量
    • 默认导出函数
    • 不包含:multiply, divide, UNUSED_CONST, init

注:默认导出即使内部有console.log,但因为被调用了,所以需要保留。 init函数有副作用但未被调用,理论上可以移除,但需要小心。

模块化的未来

模块联邦(Module Federation)

Webpack 5 引入的模块联邦,允许在多个独立构建的应用间共享模块:

// app1/webpack.config.js - 提供者(Host)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app1',
            filename: 'remoteEntry.js', // 远程入口文件
            exposes: {
                './Button': './src/components/Button.jsx',
                './utils': './src/utils/index.js'
            },
            shared: {
                react: { singleton: true },
                'react-dom': { singleton: true }
            }
        })
    ]
};

// app2/webpack.config.js - 消费者(Remote)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app2',
            remotes: {
                app1: 'app1@http://localhost:3001/remoteEntry.js'
            },
            shared: {
                react: { singleton: true },
                'react-dom': { singleton: true }
            }
        })
    ]
};

// app2/src/App.js - 使用远程模块
// 从app1动态导入模块
const RemoteButton = () => import('app1/Button');

导入映射(Import Maps)

<!-- 导入映射:控制模块的解析 -->
<script type="importmap">
{
    "imports": {
        "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js",
        "lodash": "/node_modules/lodash-es/lodash.js",
        "components/": "/src/components/"
    },
    "scopes": {
        "/src/": {
            "utils": "/src/utils/index.js"
        }
    }
}
</script>

<script type="module">
    // 现在可以这样导入
    import { createApp } from 'vue';
    import { debounce } from 'lodash';
    import Button from 'components/Button.js';
    
    // 不需要写完整路径
    import { formatDate } from 'utils';
</script>

结语

JavaScript 模块化的演进历程是一部精彩的技术发展史。从最初的全局变量污染,到现在的 ES Module 原生支持,我们见证了前端工程化从无到有、从简单到复杂的过程。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript Proxy与Reflect

在 JavaScript 中如何拦截对象的读取、修改、删除操作?Vue3的响应式系统如何实现?本文将深入讲解 JavaScript 的元编程世界,探索 Proxy 和 Reflect 的奥秘。

前言:从Vue3的响应式说起

// Vue3的响应式数据实现原理
const reactive = (target) => {
    return new Proxy(target, {
        get(target, key, receiver) {
            track(target, key); // 依赖收集
            return Reflect.get(target, key, receiver);
        },
        set(target, key, value, receiver) {
            const result = Reflect.set(target, key, value, receiver);
            trigger(target, key); // 触发更新
            return result;
        }
    });
};

Vue3的响应式数据实现原理的背后,就是 Proxy 和 Reflect 。理解它们,我们就能真正掌握 JavaScript 元编程的能力。

理解Proxy与Reflect

什么是Proxy(代理)?

Proxy 对象用于创建一个对象的代理,从而实现对基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

Proxy工作流程示意图


┌─────────┐   操作请求   ┌─────────┐   转发操作    ┌─────────┐
│ 客户端   │───────────→ │  Proxy  │───────────→ │ 目标对象 │
│         │             │  代理    │             │         │
│         │←─────────── │         │←─────────── │         │
└─────────┘   响应结果   └─────────┘   实际结果    └─────────┘
                ↓                           ↓
         ┌─────────────┐            ┌─────────────┐
         │ 拦截和自定义  │            │ 原始行为     │
         │   (陷阱)   │            │             │
         └─────────────┘            └─────────────┘

什么是Reflect(反射)?

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 Proxy 的陷阱方法一一对应。

为什么需要Reflect?

  1. 统一的操作API
  2. 更好的错误处理(返回布尔值而非抛出异常)
  3. 与Proxy陷阱方法一一对应

代理基础:创建第一个代理

// 目标对象
const target = {
  name: '张三',
  age: 30
};

// 处理器对象(包含陷阱方法)
const handler = {
  // 拦截属性读取
  get(target, property, receiver) {

    // 添加自定义逻辑
    if (property === 'age') {
      return `${target[property]}岁`;
    }

    // 默认行为:使用Reflect
    return Reflect.get(target, property, receiver);
  },

  // 拦截属性设置
  set(target, property, value, receiver) {
    // 添加验证逻辑
    if (property === 'age' && (value < 0 || value > 150)) {
      console.warn('年龄必须在0-150之间');
      return false; // 返回false表示设置失败
    }

    // 默认行为:使用Reflect
    return Reflect.set(target, property, value, receiver);
  }
};

// 创建代理
const proxy = new Proxy(target, handler);

console.log(proxy.name);  // 张三
console.log(proxy.age);   // 30岁
console.log(target.age); // 30

13种可拦截的陷阱方法

完整陷阱方法概览

const completeHandler = {
  // 1. 属性访问拦截
  get(target, prop, receiver) {
    console.log(`get: ${prop}`);
    return Reflect.get(target, prop, receiver);
  },

  // 2. 属性赋值拦截
  set(target, prop, value, receiver) {
    console.log(`set: ${prop} = ${value}`);
    return Reflect.set(target, prop, value, receiver);
  },

  // 3. in操作符拦截
  has(target, prop) {
    console.log(`has: ${prop}`);
    return Reflect.has(target, prop);
  },

  // 4. delete操作符拦截
  deleteProperty(target, prop) {
    console.log(`delete: ${prop}`);
    return Reflect.deleteProperty(target, prop);
  },

  // 5. 构造函数调用拦截
  construct(target, args, newTarget) {
    console.log(`construct with args:`, args);
    return Reflect.construct(target, args, newTarget);
  },

  // 6. 函数调用拦截
  apply(target, thisArg, args) {
    console.log(`apply: thisArg=`, thisArg, 'args=', args);
    return Reflect.apply(target, thisArg, args);
  },

  // 7. Object.getOwnPropertyDescriptor拦截
  getOwnPropertyDescriptor(target, prop) {
    console.log(`getOwnPropertyDescriptor: ${prop}`);
    return Reflect.getOwnPropertyDescriptor(target, prop);
  },

  // 8. Object.defineProperty拦截
  defineProperty(target, prop, descriptor) {
    console.log(`defineProperty: ${prop}`, descriptor);
    return Reflect.defineProperty(target, prop, descriptor);
  },

  // 9. 原型访问拦截
  getPrototypeOf(target) {
    console.log('getPrototypeOf');
    return Reflect.getPrototypeOf(target);
  },

  // 10. 原型设置拦截
  setPrototypeOf(target, prototype) {
    console.log(`setPrototypeOf:`, prototype);
    return Reflect.setPrototypeOf(target, prototype);
  },

  // 11. 可扩展性判断拦截
  isExtensible(target) {
    console.log('isExtensible');
    return Reflect.isExtensible(target);
  },

  // 12. 防止扩展拦截
  preventExtensions(target) {
    console.log('preventExtensions');
    return Reflect.preventExtensions(target);
  },

  // 13. 自身属性枚举拦截
  ownKeys(target) {
    console.log('ownKeys');
    return Reflect.ownKeys(target);
  }
};

陷阱方法详细解析

1. get陷阱:属性访问拦截

get() 会在获取属性值的操作中被调用:

const getHandler = {
  get(target, property, receiver) {
    // 处理不存在的属性
    if (!(property in target)) {
      throw new Error(`属性不存在: ${property}`);
    }

    // 处理私有属性(约定以_开头的属性为私有)
    if (property.startsWith('_')) {
      throw new Error(`不能访问私有属性: ${property}`);
    }
    return Reflect.get(target, property, receiver);
  }
};
  • 参数说明:
    1. target:目标对象
    2. property:引用的目标对象上的属性
    3. receiver:代理对象
  • 返回值说明:返回值无限制

2. set陷阱:属性赋值拦截

set() 会在设置属性值的操作中被调用:

const setHandler = {
  set(target, property, value, receiver) {
    // 只读属性检查(约定以$开头的属性为只读)
    if (property.startsWith('$')) {
      throw new Error(`属性不可修改: ${property}`);
    }
    // 执行设置
    return Reflect.set(target, property, value, receiver);
  }
};
  • 参数说明:
    1. target:目标对象
    2. property:引用的目标对象上的属性
    3. value:要给属性设置的值
    4. receiver:代理对象
  • 返回值说明:返回 true 表示成功;返回false表示失败。

3. has陷阱:in操作符拦截

has() 会在 in 操作符中被调用:

const hasHandler = {
  has(target, property) {
    // 隐藏某些属性(不在in操作中暴露)
    const hiddenProperties = ['password', 'token', '_internal'];
    if (hiddenProperties.includes(property)) {
      console.log(`隐藏属性: ${property}`);
      return false;
    }

    // 虚拟属性(不存在但返回true)
    const virtualProperties = ['isAdmin', 'hasAccess'];
    if (virtualProperties.includes(property)) {
      console.log(`虚拟属性: ${property}`);
      return true;
    }

    return Reflect.has(target, property);
  }
};
  • 参数说明:
    1. target:目标对象
    2. value:要给属性设置的值
  • 返回值说明:返回布尔值表示属性是否存在,返回非布尔型会被转成布尔型。

注:Object.keys() 不受 has() 影响,仍会返回所有属性。

4. apply陷阱:函数调用拦截

apply() 会在调用函数时被调用:

const applyHandler = {
  apply(target, thisArg, argumentsList) {
    // 参数验证
    if (target.name === 'calculate') {
      if (argumentsList.length < 2) {
        throw new Error('calculate函数需要至少2个参数');
      }
      if (!argumentsList.every(arg => typeof arg === 'number')) {
        throw new Error('calculate函数的所有参数必须是数字');
      }
    }
    return Reflect.apply(target, thisArg, argumentsList);
  }
};
  • 参数说明:
    1. target:目标对象
    2. thisArg:调用函数时的this参数
    3. argumentsList:调用函数时的参数列表
  • 返回值说明:返回值无限制

5. construct陷阱:构造函数拦截

construct() 会在调用函数时被调用:

const constructHandler = {
  construct(target, argumentsList, newTarget) {
    // 单例模式:确保只有一个实例
    if (target._instance) {
      console.log('返回现有实例');
      return target._instance;
    }

    // 创建实例
    const instance = Reflect.construct(target, argumentsList, newTarget);

    // 为单例保存实例
    if (target.name === 'Singleton') {
      target._instance = instance;
    }

    // 为实例添加额外属性
    instance.createdAt = new Date();
    instance._id = Math.random().toString(36).substr(2, 9);

    console.log('创建新实例,ID:', instance._id);
    return instance;
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. argumentsList:传给目标构造函数的参数列表
    3. newTarget:最初被调用的构造函数
  • 返回值说明:必须返回一个对象

6. ownKeys陷阱:影响Object.keys()等遍历

ownKeys() 会在Object.keys()及类似方法中被调用:

const ownKeysHandler = {
  ownKeys(target) {
    // 过滤掉以_开头的私有属性
    const keys = Reflect.ownKeys(target);
    return keys.filter(key => {
      if (typeof key === 'string') {
        return !key.startsWith('_');
      }
      return true;
    });
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回一个包含字符串或符号的可枚举对象

7. getOwnPropertyDescriptor陷阱:影响Object.getOwnPropertyDescriptor()

getOwnPropertyDescriptor() 会在Object.getOwnPropertyDescriptor()方法中被调用:

const getOwnPropertyDescriptorHandler = {
  getOwnPropertyDescriptor(target, prop) {
    // 隐藏私有属性的描述符
    if (prop.startsWith('_')) {
      return undefined;
    }
    return Reflect.getOwnPropertyDescriptor(target, prop);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. prop:引用的目标对象上的字符串属性
  • 返回值说明:必须返回对象,或在属性不存在时返回 undefined

8. defineProperty陷阱:拦截Object.defineProperty()

defineProperty() 会在Object.defineProperty()方法中被调用:

const definePropertyHandler = {
  defineProperty(target, prop, descriptor) {
    // 防止修改只读属性
    if (prop === 'id' && target.id) {
      console.warn('id属性是只读的');
      return false;
    }
    return Reflect.defineProperty(target, prop, descriptor);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. prop:引用的目标对象上的字符串属性
    3. descriptor:包含可选的enumrable、configurable、value、get 和 set 等定义的对象
  • 返回值说明:必须返回布尔值,表示属性是否成功定义

9. deleteProperty陷阱:拦截delete操作

deleteProperty() 会在 delete 操作中被调用:

const deletePropertyHandler = {
  deleteProperty(target, prop) {
    // 防止删除重要属性
    const protectedProps = ['id', 'createdAt'];
    if (protectedProps.includes(prop)) {
      console.warn(`不能删除受保护的属性: ${prop}`);
      return false;
    }
    return Reflect.deleteProperty(target, prop);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. property:引用的目标对象上的属性
  • 返回值说明:必须返回布尔值,表示属性是否被成功删除

10. preventExtensions陷阱:拦截Object.preventExtensions()

preventExtensions() 会在 Object.preventExtensions() 方法中被调用:

const preventExtensionsHandler = {
  preventExtensions(target) {
    // 在阻止扩展前添加标记
    target._frozenAt = new Date();
    return Reflect.preventExtensions(target);
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回布尔值,表示 target 是否已经不可扩展

11. getPrototypeOf陷阱:拦截Object.getPrototypeOf()

getPrototypeOf() 会在 Object.getPrototypeOf() 方法中被调用:

const getPrototypeOfHandler = {
  getPrototypeOf(target) {
    return Reflect.getPrototypeOf(target);
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回对象或null

12. setPrototypeOf陷阱:拦截Object.setPrototypeOf()

setPrototypeOf() 会在 Object.setPrototypeOf() 方法中被调用:

const setPrototypeOfHandler = {
  setPrototypeOf(target, prototype) {
    return Reflect.setPrototypeOf(target, prototype);
  }
};
  • 参数说明:
    1. target:目标构造函数
    2. property:引用的目标对象上的属性
  • 返回值说明:必须返回布尔值,表示原型赋值是否成功

13. isExtensible陷阱:拦截Object.isExtensible()

isExtensible() 会在 Object.isExtensible() 方法中被调用:

const isExtensibleHandler = {
  isExtensible(targete) {
    return Reflect.isExtensible(targete);
  }
};
  • 参数说明:
    1. target:目标构造函数
  • 返回值说明:必须返回布尔值,表示 target 是否可扩展

可撤销代理

什么是可撤销代理?

可撤销代理(Revocable Proxy)是一种特殊的代理,它提供了一个 revoke() 方法,调用该方法后,代理将不再可用,所有对代理的操作都会抛出 TypeError,即:会中断代理对象和目标对象之间的联系。

const target = { name: 'zhangsan' };
const handler = {
  get() {
    return 'foo';
  }
}
// 创建可撤销代理
const { proxy, revoke } = Proxy.revocable(target, handler);

console.log(proxy.name); // 'zhangsan'

// 撤销代理
revoke();

// 代理已失效
try {
  console.log(proxy.name); // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch (error) {
  console.log('错误:', error.message);
}

实现响应式数据(Vue3原理)

// 简易版Vue3响应式系统
class ReactiveSystem {
  constructor() {
    this.targetMap = new WeakMap(); // 存储依赖关系
    this.effectStack = [];          // 当前正在执行的effect
    this.batchQueue = new Set();    // 批量更新队列
    this.isBatching = false;        // 是否处于批量更新模式
  }

  // 核心:创建响应式对象
  reactive(target) {
    return new Proxy(target, {
      get(target, key, receiver) {
        // 获取原始值
        const result = Reflect.get(target, key, receiver);

        // 依赖收集
        track(target, key);

        // 深度响应式(如果值是对象,继续代理)
        if (result && typeof result === 'object') {
          return reactive(result);
        }

        return result;
      },

      set(target, key, value, receiver) {
        // 获取旧值
        const oldValue = target[key];

        // 设置新值
        const result = Reflect.set(target, key, value, receiver);

        // 触发更新(值确实改变时才触发)
        if (oldValue !== value) {
          trigger(target, key);
        }

        return result;
      },

      // 处理删除操作
      deleteProperty(target, key) {
        const hasKey = key in target;
        const result = Reflect.deleteProperty(target, key);

        if (hasKey && result) {
          trigger(target, key);
        }

        return result;
      }
    });
  }

  // 依赖收集
  track(target, key) {
    if (this.effectStack.length === 0) return;

    let depsMap = this.targetMap.get(target);
    if (!depsMap) {
      depsMap = new Map();
      this.targetMap.set(target, depsMap);
    }

    let dep = depsMap.get(key);
    if (!dep) {
      dep = new Set();
      depsMap.set(key, dep);
    }

    // 收集当前正在执行的effect
    const activeEffect = this.effectStack[this.effectStack.length - 1];
    if (activeEffect) {
      dep.add(activeEffect);
      // effect也需要知道哪些依赖收集了它
      activeEffect.deps.push(dep);
    }
  }

  // 触发更新
  trigger(target, key) {
    const depsMap = this.targetMap.get(target);
    if (!depsMap) return;

    const dep = depsMap.get(key);
    if (dep) {
      // 如果是批量更新模式,加入队列
      if (this.isBatching) {
        dep.forEach(effect => this.batchQueue.add(effect));
      } else {
        // 立即执行所有依赖的effect
        dep.forEach(effect => {
          if (effect !== this.effectStack[this.effectStack.length - 1]) {
            effect.run();
          }
        });
      }
    }
  }

  // 创建effect(副作用)
  effect(fn) {
    const effect = new ReactiveEffect(fn, this);
    effect.run();

    // 返回停止函数
    const runner = effect.run.bind(effect);
    runner.effect = effect;
    runner.stop = () => effect.stop();

    return runner;
  }

  // 开始批量更新
  batchStart() {
    this.isBatching = true;
  }

  // 结束批量更新
  batchEnd() {
    this.isBatching = false;

    // 执行队列中的所有effect
    this.batchQueue.forEach(effect => {
      if (effect !== this.effectStack[this.effectStack.length - 1]) {
        effect.run();
      }
    });

    this.batchQueue.clear();
  }

  // computed计算属性
  computed(getter) {
    let value;
    let dirty = true;

    const runner = this.effect(() => {
      value = getter();
      dirty = false;
    });

    return {
      get value() {
        if (dirty) {
          runner();
        }
        return value;
      }
    };
  }

  // watch侦听器
  watch(source, callback, options = {}) {
    let getter;

    if (typeof source === 'function') {
      getter = source;
    } else {
      getter = () => this.traverse(source);
    }

    let oldValue;
    const job = () => {
      const newValue = runner();
      callback(newValue, oldValue);
      oldValue = newValue;
    };

    const runner = this.effect(getter, {
      lazy: true,
      scheduler: job
    });

    oldValue = runner();

    if (options.immediate) {
      job();
    }
  }

  // 深度遍历对象(用于watch)
  traverse(value, seen = new Set()) {
    if (typeof value !== 'object' || value === null || seen.has(value)) {
      return value;
    }

    seen.add(value);

    for (const key in value) {
      this.traverse(value[key], seen);
    }

    return value;
  }
}

// 响应式effect类
class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn;
    this.scheduler = scheduler;
    this.deps = [];
    this.active = true;
  }

  run() {
    if (!this.active) return;

    try {
      this.scheduler.effectStack.push(this);
      return this.fn();
    } finally {
      this.scheduler.effectStack.pop();
    }
  }

  stop() {
    if (this.active) {
      // 从所有依赖中移除自己
      this.deps.forEach(dep => dep.delete(this));
      this.deps.length = 0;
      this.active = false;
    }
  }
}

实现不可变数据(immer.js原理)

// 简易版immer实现
class Immer {
  constructor(baseState) {
    this.baseState = baseState;
    this.draft = this.createDraft(baseState);
    this.isModified = false;
  }

  // 创建草稿(代理)
  createDraft(base) {
    // 如果不是对象,直接返回
    if (typeof base !== 'object' || base === null) {
      return base;
    }

    // 处理数组
    if (Array.isArray(base)) {
      return this.createArrayDraft(base);
    }

    // 处理对象
    return this.createObjectDraft(base);
  }

  // 创建对象草稿
  createObjectDraft(base) {
    const draft = Array.isArray(base) ? [] : {};

    // 复制所有属性
    for (const key in base) {
      if (base.hasOwnProperty(key)) {
        draft[key] = this.createDraft(base[key]);
      }
    }

    // 创建代理
    return new Proxy(draft, {
      get: (target, prop, receiver) => {
        // 特殊属性处理
        if (prop === '__immer_draft__') return true;
        if (prop === '__immer_original__') return base;

        return Reflect.get(target, prop, receiver);
      },

      set: (target, prop, value, receiver) => {
        this.isModified = true;

        // 如果新值是普通值,直接设置
        if (typeof value !== 'object' || value === null) {
          return Reflect.set(target, prop, value, receiver);
        }

        // 如果新值是草稿,获取其最终状态
        if (value.__immer_draft__) {
          value = this.finalize(value);
        }

        // 如果新值是对象,创建新的草稿
        const newDraft = this.createDraft(value);
        return Reflect.set(target, prop, newDraft, receiver);
      },

      deleteProperty: (target, prop) => {
        this.isModified = true;
        return Reflect.deleteProperty(target, prop);
      }
    });
  }

  // 创建数组草稿(需要特殊处理)
  createArrayDraft(base) {
    const draft = this.createObjectDraft(base);

    // 代理数组方法
    const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

    arrayMethods.forEach(method => {
      const originalMethod = Array.prototype[method];

      draft[method] = function (...args) {
        this.isModified = true;
        return originalMethod.apply(this, args);
      }.bind({ isModified: false }); // 这里简化处理
    });

    return draft;
  }

  // 获取草稿
  getDraft() {
    return this.draft;
  }

  // 完成修改,生成新状态
  produce(producer) {
    // 执行生产者函数
    producer(this.draft);

    // 如果没有修改,返回原状态
    if (!this.isModified) {
      return this.baseState;
    }

    // 最终化草稿,生成新状态
    return this.finalize(this.draft);
  }

  // 最终化草稿(将草稿转换为普通对象)
  finalize(draft) {
    // 如果不是草稿,直接返回
    if (!draft || !draft.__immer_draft__) {
      return draft;
    }

    const original = draft.__immer_original__;
    const result = Array.isArray(original) ? [] : {};
    let hasChanges = false;

    // 收集所有键(包括原对象和新对象)
    const allKeys = new Set([
      ...Object.keys(original || {}),
      ...Object.keys(draft)
    ]);

    for (const key of allKeys) {
      const originalValue = original ? original[key] : undefined;
      const draftValue = draft[key];

      // 如果属性被删除
      if (!(key in draft)) {
        hasChanges = true;
        continue;
      }

      // 如果值是草稿,递归最终化
      if (draftValue && draftValue.__immer_draft__) {
        const finalized = this.finalize(draftValue);
        if (finalized !== originalValue) {
          hasChanges = true;
          result[key] = finalized;
        } else {
          result[key] = originalValue;
        }
      }
      // 如果值被修改
      else if (draftValue !== originalValue) {
        hasChanges = true;
        result[key] = draftValue;
      }
      // 值未修改
      else {
        result[key] = originalValue;
      }
    }

    return hasChanges ? result : original;
  }
}

// 简化API
function produce(baseState, producer) {
  const immer = new Immer(baseState);
  return immer.produce(producer);
}

结语

Proxy和Reflect开启了JavaScript元编程的新篇章,它们不仅是框架开发的利器,也是理解现代JavaScript运行时特性的关键。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript数据结构深度解析:栈、队列与树的实现与应用

当函数调用层层嵌套,JavaScript 引擎如何管理这些调用?当事件循环处理异步任务时,背后的数据结构是什么?本文将深入探讨栈、队列和树在前端开发中的核心应用。

前言:从函数调用说起

// 一个简单的函数调用栈示例
function funcA() {
    console.log('进入函数A');
    funcB();
    console.log('离开函数A');
}

function funcB() {
    console.log('进入函数B');
    funcC();
    console.log('离开函数B');
}

function funcC() {
    console.log('进入函数C');
    console.log('离开函数C');
}

funcA();

这段代码的输出顺序是什么?进入函数A 进入函数B 进入函数C 离开函数C 离开函数B 离开函数A

这种"先进后出"的执行顺序,正是的典型特征。

栈(Stack)的实现与应用

栈的基本概念

是一种后进先出(LIFO)的数据结构,就像一摞盘子,我们只能从最上面取放盘子。

栈的操作示意图

      push(3)      push(5)      pop() → 5
┌───┐    →    ┌───┐    →    ┌───┐    →    ┌───┐
│   │         │ 3 │         │ 5 │         │ 3 │
├───┤         ├───┤         ├───┤         ├───┤
│   │         │   │         │ 3 │         │   │
└───┘         └───┘         └───┘         └───┘
 空栈          栈底:3        栈顶:5        栈底:3

用数组实现栈

class ArrayStack {
  constructor(capacity = 10) {
    this.items = new Array(capacity);  // 底层数组
    this.top = -1;                     // 栈顶指针
    this.capacity = capacity;          // 栈容量
  }

  // 入栈操作
  push(element) {
    if (this.isFull()) {
      throw new Error('栈已满');
    }
    this.top++;
    this.items[this.top] = element;
    return this;
  }

  // 出栈操作
  pop() {
    if (this.isEmpty()) {
      throw new Error('栈为空');
    }
    const element = this.items[this.top];
    this.items[this.top] = undefined;  // 清理引用
    this.top--;
    return element;
  }

  // 查看栈顶元素(不弹出)
  peek() {
    if (this.isEmpty()) {
      return null;
    }
    return this.items[this.top];
  }

  // 判断栈是否为空
  isEmpty() {
    return this.top === -1;
  }

  // 判断栈是否已满
  isFull() {
    return this.top === this.capacity - 1;
  }

  // 获取栈大小
  get size() {
    return this.top + 1;
  }

  // 清空栈
  clear() {
    this.items = new Array(this.capacity);
    this.top = -1;
  }
}

用链表实现栈

class ListNode {
  constructor(value, next = null) {
    this.value = value;
    this.next = next;
  }
}

class LinkedListStack {
  constructor() {
    this.top = null;    // 栈顶节点
    this.length = 0;    // 栈长度
  }

  // 入栈操作
  push(element) {
    // 创建新节点,指向原来的栈顶
    const newNode = new ListNode(element, this.top);
    this.top = newNode;
    this.length++;
    return this;
  }

  // 出栈操作
  pop() {
    if (this.isEmpty()) {
      throw new Error('栈为空');
    }
    const value = this.top.value;
    this.top = this.top.next;  // 移动栈顶指针
    this.length--;
    return value;
  }

  peek() {
    if (this.isEmpty()) {
      return null;
    }
    return this.top.value;
  }

  isEmpty() {
    return this.top === null;
  }

  get size() {
    return this.length;
  }

  clear() {
    this.top = null;
    this.length = 0;
  }
}

数组栈 vs 链表栈

  • 数组栈:内存连续,CPU缓存友好,访问速度快
  • 链表栈:动态扩容,无容量限制,但内存不连续
  • 实际选择:JavaScript 数组经过V8引擎优化,通常数组栈性能更好

栈的实际应用

应用1:函数调用栈模拟

class CallStackSimulator {
  constructor() {
    this.callStack = new ArrayStack(50);
    this.currentDepth = 0;
    this.maxDepth = 0;
  }

  // 函数调用
  callFunction(funcName) {
    const frame = {
      funcName,
      timestamp: Date.now(),
      localVars: {}
    };

    this.callStack.push(frame);
    this.currentDepth++;
    this.maxDepth = Math.max(this.maxDepth, this.currentDepth);

    console.log(`调用: ${funcName} (深度: ${this.currentDepth})`);
    this.printStack();

    return frame;
  }

  // 函数返回
  returnFromFunction() {
    if (this.callStack.isEmpty()) {
      console.log('调用栈已空');
      return null;
    }

    const frame = this.callStack.pop();
    const duration = Date.now() - frame.timestamp;
    this.currentDepth--;

    console.log(`返回: ${frame.funcName} (耗时: ${duration}ms)`);
    console.log(`剩余深度: ${this.currentDepth}`);

    return frame;
  }
}

队列(Queue)与双端队列(Deque)

队列(Queue)的基本概念

队列 是一种先进先出(FIFO)的数据结构,就像排队买票,先来的人先服务。

队列的操作示意图

      enqueue(3)    enqueue(5)    dequeue() → 3
┌───┬───┐  →  ┌───┬───┐  →  ┌───┬───┐  →  ┌───┬───┐
│   │   │     │ 3 │   │     │ 3 │ 5 │     │   │ 5 │
└───┴───┘     └───┴───┘     └───┴───┘     └───┴───┘
front rear   front↑rear    front rear↑     ↑front rear

双端队列(Deque)的基本概念

双端队列 允许在两端进行插入和删除操作,结合了栈和队列的特性。

双端队列的实现

class Deque {
  constructor(capacity = 10) {
    this.items = new Array(capacity);
    this.capacity = capacity;
    this.front = 0;
    this.rear = 0;
    this.count = 0;
  }

  // 前端添加
  addFront(element) {
    if (this.isFull()) {
      this._resize();
    }

    this.front = (this.front - 1 + this.capacity) % this.capacity;
    this.items[this.front] = element;
    this.count++;

    console.log(`前端添加: ${element}, front: ${this.front}`);
    return this;
  }

  // 后端添加
  addRear(element) {
    if (this.isFull()) {
      this._resize();
    }

    this.items[this.rear] = element;
    this.rear = (this.rear + 1) % this.capacity;
    this.count++;

    console.log(`后端添加: ${element}, rear: ${this.rear}`);
    return this;
  }

  // 前端删除
  removeFront() {
    if (this.isEmpty()) {
      throw new Error('队列为空');
    }

    const element = this.items[this.front];
    this.items[this.front] = undefined;
    this.front = (this.front + 1) % this.capacity;
    this.count--;

    console.log(`前端删除: ${element}, front: ${this.front}`);
    return element;
  }

  // 后端删除
  removeRear() {
    if (this.isEmpty()) {
      throw new Error('队列为空');
    }

    this.rear = (this.rear - 1 + this.capacity) % this.capacity;
    const element = this.items[this.rear];
    this.items[this.rear] = undefined;
    this.count--;

    console.log(`后端删除: ${element}, rear: ${this.rear}`);
    return element;
  }

  // 查看前端
  peekFront() {
    if (this.isEmpty()) {
      return null;
    }
    return this.items[this.front];
  }

  // 查看后端
  peekRear() {
    if (this.isEmpty()) {
      return null;
    }
    const index = (this.rear - 1 + this.capacity) % this.capacity;
    return this.items[index];
  }

  // 扩容
  _resize() {
    const newCapacity = this.capacity * 2;
    const newItems = new Array(newCapacity);

    // 复制元素到新数组
    for (let i = 0; i < this.count; i++) {
      const index = (this.front + i) % this.capacity;
      newItems[i] = this.items[index];
    }

    this.items = newItems;
    this.front = 0;
    this.rear = this.count;
    this.capacity = newCapacity;

    console.log(`队列扩容: ${this.capacity / 2}${newCapacity}`);
  }

  isEmpty() {
    return this.count === 0;
  }

  isFull() {
    return this.count === this.capacity;
  }

  get size() {
    return this.count;
  }
}

队列的实际应用

应用1:任务调度器

class TaskScheduler {
  constructor() {
    this.taskQueue = new CircularQueue(100);  // 任务队列
    this.currentTask = null;
    this.taskId = 0;
    this.isProcessing = false;
  }

  // 创建任务
  createTask(name, duration, priority = 0) {
    return {
      id: ++this.taskId,
      name,
      duration,      // 任务执行时间(毫秒)
      priority,      // 优先级(数值越小优先级越高)
      status: 'pending',
      createdAt: Date.now(),
      startedAt: null,
      completedAt: null
    };
  }

  // 添加任务到队列
  addTask(task) {
    this.taskQueue.enqueue(task);
    console.log(`任务添加: ${task.name} (ID: ${task.id})`);
    this.printQueueStatus();

    // 如果没有正在处理的任务,开始处理
    if (!this.isProcessing) {
      this.processNextTask();
    }
  }

  // 处理下一个任务
  async processNextTask() {
    if (this.taskQueue.isEmpty()) {
      this.isProcessing = false;
      console.log('所有任务处理完成!');
      return;
    }

    this.isProcessing = true;
    this.currentTask = this.taskQueue.dequeue();
    this.currentTask.status = 'processing';
    this.currentTask.startedAt = Date.now();

    console.log(`\n开始处理任务: ${this.currentTask.name}`);
    console.log(`任务ID: ${this.currentTask.id}, 预计耗时: ${this.currentTask.duration}ms`);
    this.printQueueStatus();

    // 模拟任务执行
    await this.executeTask(this.currentTask);

    // 任务完成
    this.currentTask.completedAt = Date.now();
    this.currentTask.status = 'completed';
    const totalTime = this.currentTask.completedAt - this.currentTask.startedAt;

    console.log(`任务完成: ${this.currentTask.name}`);
    console.log(`实际耗时: ${totalTime}ms`);

    // 处理下一个任务
    setTimeout(() => {
      this.processNextTask();
    }, 0);
  }

  // 模拟任务执行
  executeTask(task) {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve();
      }, task.duration);
    });
  }
}

树(Tree)的实现与应用

树的基本概念

是一种分层的数据结构,由节点和边组成。每个节点有零个或多个子节点,没有父节点的节点称为根节点;没有子节点的节点称为叶子节点

二叉树结构示意图

        A (根节点)
       / \
      B   C
     / \   \
    D   E   F
   /
  G

术语解释:

  • 根节点: A
  • 叶子节点: E, F, G
  • 深度: 节点G的深度是3
  • 高度: 树的高度是3
  • 子树: B、D、E、G构成一个子树

二叉树的实现

class TreeNode {
  constructor(value) {
    this.value = value;    // 节点值
    this.left = null;      // 左子节点
    this.right = null;     // 右子节点
    this.parent = null;    // 父节点(可选)
  }

  // 判断是否为叶子节点
  isLeaf() {
    return this.left === null && this.right === null;
  }

  // 判断是否有左子节点
  hasLeft() {
    return this.left !== null;
  }

  // 判断是否有右子节点
  hasRight() {
    return this.right !== null;
  }

  // 获取高度
  getHeight() {
    const leftHeight = this.left ? this.left.getHeight() : 0;
    const rightHeight = this.right ? this.right.getHeight() : 0;
    return Math.max(leftHeight, rightHeight) + 1;
  }
}

二叉树遍历算法

class BinaryTree {
  constructor() {
    this.root = null;  // 根节点
  }

  // 前序遍历:根 → 左 → 右
  preorderTraversal(callback) {
    const result = [];
    this._preorder(this.root, callback, result);
    return result;
  }

  _preorder(node, callback, result) {
    if (node === null) return;

    // 访问当前节点
    if (callback) callback(node);
    result.push(node.value);

    // 遍历左子树
    this._preorder(node.left, callback, result);

    // 遍历右子树
    this._preorder(node.right, callback, result);
  }

  // 中序遍历:左 → 根 → 右(对二叉搜索树会得到有序序列)
  inorderTraversal(callback) {
    const result = [];
    this._inorder(this.root, callback, result);
    return result;
  }

  _inorder(node, callback, result) {
    if (node === null) return;

    // 遍历左子树
    this._inorder(node.left, callback, result);

    // 访问当前节点
    if (callback) callback(node);
    result.push(node.value);

    // 遍历右子树
    this._inorder(node.right, callback, result);
  }

  // 后序遍历:左 → 右 → 根
  postorderTraversal(callback) {
    const result = [];
    this._postorder(this.root, callback, result);
    return result;
  }

  _postorder(node, callback, result) {
    if (node === null) return;

    // 遍历左子树
    this._postorder(node.left, callback, result);

    // 遍历右子树
    this._postorder(node.right, callback, result);

    // 访问当前节点
    if (callback) callback(node);
    result.push(node.value);
  }

  // 层序遍历:按层级从上到下,从左到右
  levelOrderTraversal(callback) {
    if (this.root === null) return [];

    const result = [];
    const queue = new CircularQueue(100);
    queue.enqueue(this.root);

    while (!queue.isEmpty()) {
      const node = queue.dequeue();

      // 访问当前节点
      if (callback) callback(node);
      result.push(node.value);

      // 将子节点加入队列
      if (node.left) {
        queue.enqueue(node.left);
      }
      if (node.right) {
        queue.enqueue(node.right);
      }
    }

    return result;
  }

  // 深度优先搜索(DFS)
  dfs(targetValue) {
    return this._dfs(this.root, targetValue);
  }

  _dfs(node, targetValue) {
    if (node === null) return null;

    // 检查当前节点
    if (node.value === targetValue) {
      return node;
    }

    // 搜索左子树
    const leftResult = this._dfs(node.left, targetValue);
    if (leftResult !== null) {
      return leftResult;
    }

    // 搜索右子树
    return this._dfs(node.right, targetValue);
  }

  // 广度优先搜索(BFS)
  bfs(targetValue) {
    if (this.root === null) return null;

    const queue = new CircularQueue(100);
    queue.enqueue(this.root);

    while (!queue.isEmpty()) {
      const node = queue.dequeue();

      // 检查当前节点
      if (node.value === targetValue) {
        return node;
      }

      // 将子节点加入队列
      if (node.left) {
        queue.enqueue(node.left);
      }
      if (node.right) {
        queue.enqueue(node.right);
      }
    }
    return null;
  }
}

数据结构选择的关键因素

访问模式

  • 随机访问:数组(O(1))
  • 顺序访问:链表、栈、队列
  • 层级访问:树

操作频率

  • 频繁插入/删除开头:链表
  • 频繁插入/删除两端:双端队列
  • 频繁随机访问:数组

内存考虑

  • 内存连续:数组(缓存友好)
  • 内存分散:链表(无扩容成本)
  • 动态大小:链表、树

结语

本文讲解了栈、队列和树的实现与应用,理解这些数据结构,不仅能帮助我们在面试中表现出色,更能让我们在实际开发中写出更高效、更可靠的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript链表与双向链表实现:理解数组与链表的差异

数组在 JavaScript 中如此方便,为什么我们还需要链表呢?当 V8 引擎处理数组时,链表又在哪些场景下更有优势?本篇文章将深入探讨数据结构的核心差异。

前言:从一道面试题说起

// 面试题:如何高效地从大型数据集合中频繁插入和删除元素?
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// 场景1:在数组开头插入元素(性能如何?)
data.unshift(0); // 需要移动所有元素!

// 场景2:在数组中间删除元素(性能如何?)
data.splice(5, 1); // 需要移动一半的元素!

// 场景3:只需要顺序访问数据
for (let i = 0; i < data.length; i++) {
    console.log(data[i]); // 数组很快!
}

那么问题来了:有没有一种数据结构,插入和删除快,顺序访问也快?当然有,答案就是:链表! 数组和链表是编程中最基础的两种数据结构,理解它们的差异能帮助我们在不同场景下做出最优选择。

理解数组与链表的本质差异

内存结构对比

我们先通过一个简图,观察一下数组和链表在内存中的存储方式:

数组的内存结构(连续存储)

┌─────┬─────┬─────┬─────┬─────┐
│  0  │  1  │  2  │  3  │  4  │ ← 索引
├─────┼─────┼─────┼─────┼─────┤
│  10 │  20 │  30 │  40 │  50 │ ← 值
└─────┴─────┴─────┴─────┴─────┘
地址: 1000 1004 1008 1012 1016 (假设每个元素占4字节)
数组的存储特点:
  1. 连续的内存空间
  2. 通过索引直接计算地址:地址 = 基地址 + 索引 × 元素大小
  3. 随机访问时间复杂度:O(1)

链表的内存结构(非连续存储)

      ┌─────┐    ┌─────┐    ┌─────┐
头 →  │  10 │ →  │  20 │ →  │  30 │ → null
      └─────┘    └─────┘    └─────┘
地址:  2000       3040       4080  (地址不连续)
链表的特点
  1. 非连续的内存空间
  2. 每个节点包含数据和指向下一个节点的指针
  3. 随机访问需要遍历:O(n)
  4. 插入和删除只需要修改指针:O(1)

时间复杂度对比表

数据结构 访问 插入开头 插入结尾 插入中间 删除开头 删除结尾 删除中间 搜索
数组 O(1) O(n) O(1) O(n) O(n) O(1) O(n) O(n)
单向链表 O(n) O(1) O(n) O(n) O(1) O(n) O(n) O(n)
双向链表 O(n) O(1) O(1) O(n) O(1) O(1) O(n) O(n)
带尾指针的单向链表 O(n) O(1) O(1) O(n) O(1) O(n) O(n) O(n)

关键差异总结

  1. 随机访问元素:数组完胜(O(1) vs O(n))
  2. 插入/删除开头:链表完胜(O(1) vs O(n))
  3. 插入/删除结尾:数组和双向链表都很快
  4. 插入/删除中间:都不快,但链表稍好
  5. 内存使用:数组更紧凑,链表有指针开销

实现单向链表

基础节点类

class ListNode {
  constructor(value, next = null) {
    this.value = value;  // 存储的数据
    this.next = next;    // 指向下一个节点的指针
  }
}

单向链表类

class LinkedList {
  constructor() {
    this.head = null;    // 链表头节点
    this.length = 0;     // 链表长度
  }

  // 获取链表长度
  get size() {
    return this.length;
  }

  // 在链表头部添加节点
  addFirst(value) {
    // 创建新节点,指向原来的头节点
    const newNode = new ListNode(value, this.head);
    // 更新头节点为新节点
    this.head = newNode;
    this.length++;
    return this;
  }

  // 在链表尾部添加节点
  addLast(value) {
    const newNode = new ListNode(value);
    // 如果链表为空,新节点就是头节点
    if (this.head == null) {
      this.head = newNode;
    } else {
      // 找到最后一个节点
      let current = this.head;
      while (current.next != null) {
        current = current.next;
      }
      // 将新节点添加到末尾
      current.next = newNode;
    }
    this.length++;
    return this;
  }

  // 删除头节点
  removeFirst() {
    if (this.head == null) {
      return null;
    }
    const removedValue = this.head.value;
    this.head = this.head.next;
    this.length--;

    return removedValue;
  }

  // 删除尾节点
  removeLast() {
    if (this.head == null) {
      return null;
    }
    // 如果只有一个节点
    if (this.head.next == null) {
      const removedValue = this.head.value;
      this.head = null;
      this.length--;
      return removedValue;
    }
    // 找到倒数第二个节点
    let current = this.head;
    while (current.next.next != null) {
      current = current.next;
    }
    const removedValue = current.next.value;
    current.next = null;
    this.length--;
    return removedValue;
  }
}

单向链表的实际应用

应用1:浏览器历史记录

class BrowserHistory {
  constructor() {
    this.history = new LinkedList();
    this.current = null;
  }

  // 访问新页面
  visit(url) {
    // 如果当前有页面,添加到历史记录
    if (this.current !== null) {
      this.history.addLast(this.current);
    }
    this.current = url;
    console.log(`访问: ${url}`);
  }

  // 后退
  back() {
    if (this.history.size === 0) {
      console.log('无法后退:已经是第一页');
      return null;
    }
    const previous = this.history.removeLast();
    const current = this.current;
    this.current = previous;
    console.log(`后退: ${current}${previous}`);
    return previous;
  }

  // 查看历史记录
  showHistory() {
    console.log('历史记录:');
    this.history.forEach((url, index) => {
      console.log(`  ${index + 1}. ${url}`);
    });
    console.log(`当前: ${this.current}`);
  }
}

应用2:任务队列

class TaskQueue {
  constructor() {
    this.queue = new LinkedList();
  }

  // 添加任务
  enqueue(task) {
    this.queue.addLast(task);
    console.log(`添加任务: ${task.name}`);
  }

  // 执行下一个任务
  dequeue() {
    if (this.queue.isEmpty()) {
      console.log('任务队列为空');
      return null;
    }

    const task = this.queue.removeFirst();
    console.log(`执行任务: ${task.name}`);

    // 模拟任务执行
    try {
      task.execute();
    } catch (error) {
      console.error(`任务执行失败: ${error.message}`);
    }

    return task;
  }

  // 查看下一个任务(不执行)
  peek() {
    if (this.queue.isEmpty()) {
      return null;
    }
    return this.queue.get(0);
  }

  // 清空任务队列
  clear() {
    this.queue = new LinkedList();
    console.log('任务队列已清空');
  }

  // 获取队列长度
  get size() {
    return this.queue.size;
  }

  // 打印队列状态
  printQueue() {
    console.log('当前任务队列:');
    this.queue.forEach((task, index) => {
      console.log(`  ${index + 1}. ${task.name}`);
    });
  }
}

实现双向链表

基础节点类

class ListNode {
  constructor(value, next = null) {
    this.value = value;  // 存储的数据
    this.next = next;    // 指向下一个节点的指针
    this.prev = prev;    // 指向前一个节点的指针
  }
}

双向链表类

class DoublyLinkedList {
  constructor() {
    this.head = null;    // 链表头节点
    this.tail = null;    // 链表尾节点
    this.length = 0;     // 链表长度
  }

  get size() {
    return this.length;
  }

  // 在头部添加节点
  addFirst(value) {
    const newNode = new DoubleListNode(value, null, this.head);

    if (this.head !== null) {
      this.head.prev = newNode;
    } else {
      // 如果链表为空,新节点也是尾节点
      this.tail = newNode;
    }

    this.head = newNode;
    this.length++;
    return this;
  }

  // 在尾部添加节点
  addLast(value) {
    const newNode = new DoubleListNode(value, this.tail, null);

    if (this.tail !== null) {
      this.tail.next = newNode;
    } else {
      // 如果链表为空,新节点也是头节点
      this.head = newNode;
    }

    this.tail = newNode;
    this.length++;
    return this;
  }

  // 删除头节点
  removeFirst() {
    if (this.head === null) {
      return null;
    }

    const removedValue = this.head.value;

    if (this.head === this.tail) {
      // 只有一个节点
      this.head = null;
      this.tail = null;
    } else {
      this.head = this.head.next;
      this.head.prev = null;
    }

    this.length--;
    return removedValue;
  }

  // 删除尾节点
  removeLast() {
    if (this.tail === null) {
      return null;
    }

    const removedValue = this.tail.value;

    if (this.head === this.tail) {
      // 只有一个节点
      this.head = null;
      this.tail = null;
    } else {
      this.tail = this.tail.prev;
      this.tail.next = null;
    }

    this.length--;
    return removedValue;
  }
}

双向链表的实际应用

应用1:浏览器历史记录(增强版)

class EnhancedBrowserHistory {
  constructor() {
    this.history = new DoublyLinkedList();
    this.current = null;
    this.currentIndex = -1;
  }

  // 访问新页面
  visit(url) {
    console.log(`访问: ${url}`);

    // 如果当前位置不在末尾,需要截断后面的历史
    if (this.currentIndex < this.history.size - 1) {
      // 移除当前位置之后的所有历史
      const removeCount = this.history.size - 1 - this.currentIndex;
      for (let i = 0; i < removeCount; i++) {
        this.history.removeLast();
      }
    }

    // 添加新页面到历史记录
    if (this.current !== null) {
      this.history.addLast(this.current);
    }

    this.current = url;
    this.currentIndex = this.history.size;

    this.printHistory();
  }

  // 后退
  back() {
    if (this.currentIndex <= 0) {
      console.log('无法后退:已经是第一页');
      return null;
    }

    this.currentIndex--;
    this.current = this.history.get(this.currentIndex);

    console.log(`后退到: ${this.current}`);
    this.printHistory();

    return this.current;
  }

  // 前进
  forward() {
    if (this.currentIndex >= this.history.size - 1) {
      console.log('无法前进:已经是最后一页');
      return null;
    }

    this.currentIndex++;
    this.current = this.currentIndex === this.history.size ?
      '当前页面' : this.history.get(this.currentIndex);

    console.log(`前进到: ${this.current}`);
    this.printHistory();

    return this.current;
  }

  // 查看历史记录
  printHistory() {
    console.log('历史记录:');
    this.history.forEach((url, index) => {
      const marker = index === this.currentIndex ? '← 当前' : '';
      console.log(`  ${index + 1}. ${url} ${marker}`);
    });

    if (this.currentIndex === this.history.size) {
      console.log(`  当前: ${this.current}`);
    }
  }

  // 跳转到指定历史
  go(index) {
    if (index < 0 || index > this.history.size) {
      console.log(`无效的历史位置: ${index}`);
      return null;
    }

    this.currentIndex = index;
    this.current = index === this.history.size ?
      '当前页面' : this.history.get(index);

    console.log(`跳转到: ${this.current}`);
    this.printHistory();

    return this.current;
  }
}

核心要点总结

数组 vs 链表的本质差异

  • 数组:连续内存,随机访问快(O(1)),插入删除慢(O(n))
  • 链表:非连续内存,随机访问慢(O(n)),插入删除快(O(1))
  • JavaScript数组:是特殊对象,V8引擎会优化存储方式

单向链表 vs 双向链表

  • 单向链表:每个节点只有一个指针(next),内存开销小
  • 双向链表:每个节点有两个指针(prev, next),支持双向遍历
  • 选择:需要反向操作时用双向链表,否则用单向链表

结语

数据结构的选择没有绝对的对错,只有适合与否。理解数组和链表的差异,能帮助我们在实际开发中做出更明智的选择,写出更高效的代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

❌