阅读视图

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

🧠 深入理解 JavaScript Promise 与 `Promise.all`:从原型链到异步编程实战

在现代 JavaScript 开发中,Promise 是处理异步操作的核心机制之一。ES6 引入的 Promise 极大地简化了“回调地狱”(Callback Hell)问题,并为后续的 async/await 语法奠定了基础。而 Promise.all 则是并发执行多个异步任务并统一处理结果的强大工具。

本文将结合 原型链原理Promise 基础用法实际示例代码,带你系统掌握 Promise 及其静态方法 Promise.all 的使用与底层逻辑。


🔗 一、JavaScript 的面向对象:原型链而非“血缘”

在深入 Promise 之前,我们先厘清一个关键概念:JavaScript 的继承不是基于“类”的血缘关系,而是基于原型(prototype)的链式查找机制

1.1 🏗️ 构造函数与原型对象

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.speci = '人类';

let zhen = new Person('张三', 18);
console.log(zhen.speci); // 输出: "人类"
  • Person 是构造函数。
  • Person.prototype 是所有 Person 实例共享的原型对象。
  • zhen.__proto__ 指向 Person.prototype
  • Person.prototype.constructor 又指回 Person,形成闭环。

🚂 小比喻:可以把 constructor 看作“车头”,prototype 是“车身”。实例通过 __proto__ 连接到车身,而车身知道自己的车头是谁。

1.2 ⚡ 动态修改原型链(不推荐)

const kong = {
    name: '孔子',
    hobbies: ['读书', '喝酒']
};

zhen.__proto__ = kong;
console.log(zhen.hobbies);     // ✅ 输出: ['读书', '喝酒']
console.log(kong.prototype);   // ❌ undefined!普通对象没有 prototype 属性

⚠️ 注意:

  • 只有函数才有 prototype 属性;
  • 普通对象(如 kong)只有 __proto__,没有 prototype
  • 在这里kong是object的一个实例kong.__prpto__ == object.prototype

💡 虽然可以动态修改 __proto__,但会破坏代码可预测性,影响性能,应避免使用。


⏳ 二、Promise:ES6 的异步解决方案

2.1 🧩 Promise 基本结构

<script>
const p = new Promise((resolve, reject) => {
    console.log(111); // 同步执行
    setTimeout(() => {
        console.log(333);
        // resolve('结果1');  // 成功
        reject('失败1');      // 失败
    }, 1000);
});

console.log(222);
console.log(p, '////////'); // 此时 p 状态仍是 pending
console.log(p.__proto__ == Promise.prototype); // true
</script>

📋 执行顺序分析:

  1. 111 立即输出(executor 函数同步执行)✅
  2. 222 紧接着输出 ✅
  3. p 此时处于 pending(等待) 状态 ⏳
  4. 1 秒后,333 输出,调用 reject('失败1'),状态变为 rejected
  5. .catch() 捕获错误,.finally() 无论成功失败都会执行 🔁

2.2 🎯 Promise 的三种状态

  • ⏳ pending:初始状态,既不是成功也不是失败。
  • ✅ fulfilled:操作成功完成(通过 resolve 触发)。
  • ❌ rejected:操作失败(通过 reject 触发)。

🔒 核心特性:一旦状态改变,就不可逆。这是 Promise 的设计基石。

2.3 🔍 原型关系验证

console.log(p.__proto__ === Promise.prototype); // ✅ true
  • pPromise 的实例。
  • 所有 Promise 实例的 __proto__ 都指向 Promise.prototype
  • Promise.prototype 上定义了 .then(), .catch(), .finally() 等方法。
  • Promise.prototype.__proto__ == object.prototype

🚀 三、Promise.all:并发处理多个异步任务

3.1 ❓ 什么是 Promise.all

Promise.all(iterable) 接收一个可迭代对象(如数组),其中包含多个 Promise。它返回一个新的 Promise:

  • ✅ 全部成功 → 返回一个包含所有结果的数组(顺序与输入一致)。
  • ❌ 任一失败 → 立即 rejected,返回第一个失败的原因。

3.2 💻 使用示例

const task1 = fetch('/api/user');       // 假设返回 { id: 1, name: 'Alice' }
const task2 = fetch('/api/posts');      // 假设返回 [{ title: 'JS' }]
const task3 = new Promise(resolve => setTimeout(() => resolve('done'), 500));

Promise.all([task1, task2, task3])
  .then(([user, posts, msg]) => {
    console.log('全部完成:', user, posts, msg);
  })
  .catch(err => {
    console.error('某个任务失败:', err);
  });

🌐 适用场景:需要同时加载用户信息、文章列表、配置数据等,全部就绪后再渲染页面。

3.3 ⚠️ 错误处理演示

const p1 = Promise.resolve('成功1');
const p2 = Promise.reject('失败2');
const p3 = Promise.resolve('成功3');

Promise.all([p1, p2, p3])
  .then(results => console.log('不会执行'))
  .catch(err => console.log('捕获错误:', err)); // 输出: "失败2"

关键点:只要有一个失败,整个 Promise.all 就失败,其余成功的 Promise 结果会被丢弃。

3.4 🛡️ 替代方案:Promise.allSettled(ES2020)

如果你希望无论成功失败都等待所有任务完成,可以使用 Promise.allSettled

Promise.allSettled([p1, p2, p3])
  .then(results => {
    results.forEach((res, i) => {
      if (res.status === 'fulfilled') {
        console.log(`✅ 任务${i} 成功:`, res.value);
      } else {
        console.log(`❌ 任务${i} 失败:`, res.reason);
      }
    });
  });

✅ 适用于:批量上传、日志收集、非关键资源加载等场景。


📚 四、总结:从原型到实践

概念 说明
🔗 原型链 JS 对象通过 __proto__ 查找属性,constructor 指回构造函数
Promise 表示异步操作的最终完成或失败,具有 pending/fulfilled/rejected 三种状态
🧩 Promise.prototype 所有 Promise 实例的方法来源(.then, .catch 等)
🚀 Promise.all 并发执行多个 Promise,全成功则成功,任一失败则整体失败
🛡️ 最佳实践 使用 Promise.all 提升性能;用 allSettled 处理非关键任务

💭 五、思考题

  1. 🤔 为什么 console.log(p)setTimeout 之前打印时,状态是 pending
  2. 🛠️ 能否通过修改 Promise.prototype.then 来全局拦截所有 Promise 的成功回调?这样做有什么风险?
  3. 📦 如果 Promise.all 中传入空数组 [],结果会是什么?

💡 答案提示

  1. 因为异步任务尚未执行,状态未改变。
  2. 技术上可行,但会破坏封装性、可测试性和团队协作,强烈不推荐
  3. 立即 resolved,返回空数组 [] —— 这是符合规范的!

通过本文,你不仅掌握了 PromisePromise.all 的用法,还理解了其背后的 原型机制异步执行模型。这将为你编写健壮、高效的异步代码打下坚实基础。🌟

Happy Coding! 💻✨

深入理解 Async/Await:现代 JavaScript 异步编程的优雅解决方案

在现代 JavaScript 开发中,异步编程是一个无法回避的话题。从早期的回调函数到 Promise,再到 Generator 函数,JavaScript 一直在探索更优雅的异步编程解决方案。而 async/await 的出现,可以说是 JavaScript 异步编程领域的一次重大突破,它让异步代码的书写和阅读变得更加直观和简洁。

什么是 Async 函数?

Async 函数实际上是 Generator 函数的语法糖,但它在多个方面进行了重要优化,使得异步编程变得更加简单和直观。

内置执行器

与 Generator 函数需要额外的执行器(如 co 模块)不同,async 函数内置了执行器,可以像普通函数一样直接调用:

javascript

复制下载

async function fn() {
  return '张三';
}

const result = fn(); // 直接调用,无需额外执行器
console.log(result); // Promise {<fulfilled>: '张三'}

更好的语义

从字面上看,async 和 await 关键字直接表达了异步操作的语义。async 表示函数内部有异步操作,await 表示需要等待一个异步操作的完成。这种直观的表达方式大大提高了代码的可读性。

更广的适用性

await 命令后面不仅可以跟 Promise 对象,还可以跟原始类型的值(数值、字符串、布尔值等),这时这些值会被自动转成立即 resolve 的 Promise 对象:

javascript

复制下载

async function f() {
  const a = await 'hello'; // 等同于 await Promise.resolve('hello')
  const b = await 123;     // 等同于 await Promise.resolve(123)
  return a + b;
}

f().then(console.log); // 'hello123'

返回值是 Promise

async 函数总是返回一个 Promise 对象,这意味着我们可以使用 then 方法链式处理异步操作的结果:

javascript

复制下载

async function fn() {
  return '张三';
}

fn().then(value => {
  console.log(value); // '张三'
});

Async 函数的返回值详解

async 函数的返回值行为有几种不同的情况,理解这些细节对于正确使用 async 函数至关重要。

返回非 Promise 类型的对象

当 async 函数返回一个非 Promise 类型的对象时,返回值会被包装成一个成功状态的 Promise 对象:

javascript

复制下载

async function fn() {
  return '张三';
}

const result = fn();
console.log(result); // Promise {<fulfilled>: '张三'}

fn().then(value => {
  console.log(value); // '张三'
});

抛出错误

当 async 函数内部抛出错误时,返回值是一个失败状态的 Promise:

javascript

复制下载

async function fn() {
  throw new Error('出错了');
}

const result = fn();
console.log(result); // Promise {<rejected>: Error: 出错了}

fn().then(
  value => console.log(value),
  reason => console.log(reason) // Error: 出错了
);

返回 Promise 对象

当 async 函数返回一个 Promise 对象时,该 Promise 对象的状态决定了 async 函数返回的 Promise 状态:

javascript

复制下载

async function fn() {
  return new Promise((resolve, reject) => {
    // resolve('成功了');
    reject('失败了');
  });
}

const result = fn();
console.log(result); // Promise {<rejected>: '失败了'}

fn().then(
  value => console.log(value),
  reason => console.log(reason) // '失败了'
);

Await 表达式的深入理解

await 表达式是 async/await 的核心,它只能在 async 函数内部使用,具有以下几个重要特性。

等待 Promise 完成

await 后面通常跟一个 Promise 对象,它会暂停 async 函数的执行,等待 Promise 完成,然后返回 Promise 的成功值

javascript

复制下载

const p = new Promise((resolve, reject) => {
  resolve('成功了');
  // reject('失败了');
});

async function f1() {
  const result = await p;
  console.log(result); // '成功了'
}
f1();

错误处理

当 await 后面的 Promise 变为拒绝状态时,await 表达式会抛出异常,需要通过 try...catch 结构来捕获:

javascript

复制下载

const p = new Promise((resolve, reject) => {
  reject('失败了');
});

async function f2() {
  try {
    const result = await p;
    console.log(result);
  } catch(err) {
    console.log(err); // '失败了'
  }
}
f2();

等待 Thenable 对象

await 后面不仅可以跟 Promise 对象,还可以跟任何定义了 then 方法的对象(thenable 对象),await 会将其视为 Promise 对象来处理:

javascript

复制下载

class Sleep {
  constructor(timeout) {
    this.timeout = timeout;
  }
  then(resolve, reject) {
    const startTime = Date.now();
    setTimeout(() => {
      resolve(Date.now() - startTime);
    }, this.timeout);
  }
}

(async () => {
  const sleepTime = await new Sleep(1000);
  console.log(sleepTime); // 大约 1000
})();

这种特性使得我们可以创建自定义的异步操作,只要对象实现了 then 方法,就可以与 await 一起使用。

错误处理策略

在 async 函数中,错误处理是一个需要特别注意的方面。

中断执行的问题

默认情况下,任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行:

javascript

复制下载

async function f() {
  await Promise.reject('出错了');
  await Promise.resolve('hello world'); // 不会执行
}

防止中断执行的策略

有时我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将 await 放在 try...catch 结构里面:

javascript

复制下载

async function f() {
  try {
    await Promise.reject('出错了');
  } catch (e) {
    // 捕获错误,但不中断执行
  }
  return await Promise.resolve('hello world');
}

f().then(v => console.log(v)); // 'hello world'

实际应用场景

文件读取

async/await 在处理多个顺序执行的异步操作时特别有用,比如文件读取:

// 模拟文件读取函数
function read1() {
  return new Promise((resolve, reject) => {
    // 模拟异步文件读取
    setTimeout(() => {
      resolve('文件1的内容');
    }, 1000);
  })
}

function read2() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('文件2的内容');
    }, 1000);
  })
}

function read3() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('文件3的内容');
    }, 1000);
  })
}

async function main() {
  try {
    const result1 = await read1();
    console.log(result1);
    const result2 = await read2();
    console.log(result2);
    const result3 = await read3();
    console.log(result3);
  } catch(err) {
    console.log(err);
  }
}

main();

并发执行优化

虽然上面的例子展示了顺序执行异步操作,但在实际开发中,如果多个异步操作之间没有依赖关系,我们可以使用 Promise.all 来并发执行,提高效率:

javascript

复制下载

async function main() {
  try {
    const [result1, result2, result3] = await Promise.all([
      read1(),
      read2(),
      read3()
    ]);
    console.log(result1, result2, result3);
  } catch(err) {
    console.log(err);
  }
}

Async/Await 与传统异步方案的对比

与 Promise 链的对比

使用传统的 Promise 链:

javascript

复制下载

function fetchData() {
  return fetch('/api/data1')
    .then(response => response.json())
    .then(data1 => {
      return fetch('/api/data2')
        .then(response => response.json())
        .then(data2 => {
          return { data1, data2 };
        });
    });
}

使用 async/await:

javascript

复制下载

async function fetchData() {
  const response1 = await fetch('/api/data1');
  const data1 = await response1.json();
  
  const response2 = await fetch('/api/data2');
  const data2 = await response2.json();
  
  return { data1, data2 };
}

可以看到,async/await 版本的代码更加直观,逻辑更加清晰。

与 Generator 函数的对比

使用 Generator 函数处理异步:

javascript

复制下载

function* fetchData() {
  const response1 = yield fetch('/api/data1');
  const data1 = yield response1.json();
  
  const response2 = yield fetch('/api/data2');
  const data2 = yield response2.json();
  
  return { data1, data2 };
}

// 需要执行器
function run(generator) {
  const iterator = generator();
  
  function iterate(iteration) {
    if (iteration.done) return iteration.value;
    const promise = iteration.value;
    return promise.then(result => iterate(iterator.next(result)));
  }
  
  return iterate(iterator.next());
}

run(fetchData);

使用 async/await:

javascript

复制下载

async function fetchData() {
  const response1 = await fetch('/api/data1');
  const data1 = await response1.json();
  
  const response2 = await fetch('/api/data2');
  const data2 = await response2.json();
  
  return { data1, data2 };
}

// 直接调用
fetchData();

明显可以看出,async/await 方案更加简洁,无需额外的执行器。

最佳实践和注意事项

1. 始终处理错误

在使用 async/await 时,不要忘记错误处理。可以使用 try...catch 结构,或者使用 .catch() 方法:

javascript

复制下载

// 方式一:使用 try...catch
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('获取数据失败:', error);
    throw error; // 或者返回默认值
  }
}

// 方式二:使用 .catch()
fetchData().catch(error => {
  console.error('获取数据失败:', error);
});

2. 避免不必要的 await

不要滥用 await,只有在需要等待异步操作完成时才使用它:

javascript

复制下载

// 不推荐
async function example() {
  const a = await 1; // 不必要的 await
  const b = await 2; // 不必要的 await
  return a + b;
}

// 推荐
async function example() {
  const a = 1;
  const b = 2;
  return a + b;
}

3. 合理使用并发

当多个异步操作之间没有依赖关系时,应该并发执行它们,而不是顺序执行:

// 不推荐 - 顺序执行
async function fetchSequential() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  const comments = await fetchComments();
  return { user, posts, comments };
}

// 推荐 - 并发执行
async function fetchConcurrent() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ]);
  return { user, posts, comments };
}

总结

Async/await 是 JavaScript 异步编程的重大进步,它通过更加直观和简洁的语法,让我们能够以近乎同步的方式编写异步代码,同时保持了异步操作的非阻塞特性。

从本质上讲,async 函数是 Generator 函数的语法糖,但它通过内置执行器、更好的语义、更广的适用性和 Promise 返回值等优化,大大提升了开发体验。await 表达式则让我们能够以同步的方式编写异步逻辑,使代码更加清晰易读。

深入理解 JavaScript Promise:原理、用法与实践

引言

在现代 JavaScript 开发中,异步编程是无法回避的核心话题。随着 Web 应用复杂度的提升,传统的回调函数(Callback)方式逐渐暴露出“回调地狱”(Callback Hell)等问题。为了解决这一难题,ES6 引入了 Promise 对象,提供了一种更加优雅、可读性更强的异步处理机制。

本文将结合提供的代码示例和文档说明,系统性地讲解 Promise 的基本概念、状态机制、核心方法(如 .then().catch())、链式调用、嵌套 Promise 的行为,并通过实际案例展示其在文件读取等场景中的应用。


一、Promise 是什么?

根据 readme.md 中的定义:

Promise 简单说是一个容器(对象),里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

Promise 有三种状态:

  • pending(进行中) :初始状态,既不是成功也不是失败。
  • fulfilled(已成功) :操作成功完成。
  • rejected(已失败) :操作失败。

关键特性:

  • 状态不可逆:一旦状态从 pending 变为 fulfilled 或 rejected,就不会再改变
  • 状态由内部决定:Promise 的状态变化由其内部的异步操作决定,不受外界影响

二、Promise 的基本用法

1. 创建 Promise

// 1.js 示例
const p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    let err = '数据读取失败';
    reject(err);
  }, 1000);
});

p.then(
  function (value) {
    console.log(value); // 成功回调
  },
  function (reason) {
    console.log(reason); // 失败回调 → 输出 "数据读取失败"
  }
);

在这个例子中,我们创建了一个在 1 秒后调用 reject 的 Promise。.then() 方法接收两个参数:第一个是 resolve 的回调,第二个是 reject 的回调。

注意:虽然可以这样写,但更推荐使用 .catch() 来统一处理错误(见后文)。

2. Promise 立即执行


// 2.js 示例
let promise = new Promise(function (resolve, reject) {
  console.log('Promise'); // 立即执行
  resolve();
});

promise.then(function () {
  console.log('resolved');
});

console.log('Hi!');

// 输出顺序:
// Promise
// Hi!
// resolved

这说明:

  • Promise 构造函数是同步执行的,所以 'Promise' 最先输出。
  • .then() 中的回调是微任务(microtask) ,会在当前宏任务(script 执行)结束后、下一个宏任务开始前执行,因此 'resolved' 最后输出。

三、Promise 的链式调用与返回新 Promise

1. .then() 返回新 Promise

.then() 方法返回的是一个新的 Promise 实例(注意,不是原来那个 Promise 实例)。因此可以采用链式写法。

这意味着我们可以连续调用多个 .then(),每个 .then() 都可以处理上一个 Promise 的结果。

2. 在 .then() 中返回另一个 Promise

// 5.js 示例
getJSON("/post/1.json")
  .then(post => getJSON(post.commentURL)) // 返回新 Promise
  .then(
    comments => console.log("resolved: ", comments),
    err => console.log("rejected: ", err)
  );

这里的关键在于:第一个 .then() 返回的是 getJSON(...) 的结果,它本身就是一个 Promise。因此,第二个 .then() 会等待这个新 Promise 的状态变化。

  • 如果 post.commentURL 请求成功 → 调用第一个回调(打印 comments)
  • 如果任一环节失败 → 调用第二个回调(打印 error)

这种模式极大简化了多层异步依赖的处理。


四、错误处理:.catch() 的作用


// 6.js 示例
getJSON('/posts.json')
  .then(function (posts) {
    // ...
  })
  .catch(function (error) {
    console.log('发生错误!', error);
  });

根据 readme.md

.catch().then(null, rejection) 的别名,用于指定发生错误时的回调函数。

更重要的是:

  • .catch() 能捕获前面所有 .then() 中抛出的错误(包括同步错误和异步 reject)。
  • 它使得错误处理集中化,避免在每个 .then() 中都写错误回调。

例如:


Promise.resolve()
  .then(() => {
    throw new Error('出错了!');
  })
  .catch(err => {
    console.log(err.message); // "出错了!"
  });

五、嵌套 Promise 与状态传递

这是 Promise 中最容易被误解的部分之一。

// 3.js 示例(注释版)
const p1 = new Promise(function(resolve, reject){
  setTimeout(() => reject(new Error('fail')), 3000);
});

const p2 = new Promise(function(resolve, reject){
  setTimeout(() => resolve(p1), 1000); // resolve 传入的是 p1(另一个 Promise)
});

p2
  .then(result => console.log(result))
  .catch(err => console.log(err)); // 输出 Error: fail

关键点解析:

  • p2 在 1 秒后调用 resolve(p1),但 p1 本身是一个 Promise。
  • 当 resolve() 的参数是一个 Promise 实例时,当前 Promise(p2)的状态将由该 Promise(p1)决定
  • 因此,p2 的状态实际上“代理”了 p1 的状态。
  • 2 秒后(总耗时 3 秒),p1 被 reject,于是 p2 也变为 rejected,触发 .catch()

这一机制使得我们可以“转发”或“组合”多个异步操作,而无需手动监听每个 Promise。


六、实战:链式读取多个文件

// 7.js 示例(修正版)
const p = new Promise((resolve, reject) => {
  FileSystem.readFile('./1.txt', (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

p
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./2.txt', (err, data) => {
        if (err) reject(err);
        else resolve([value, data]);
      });
    });
  })
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./3.txt', (err, data) => {
        if (err) reject(err);
        else resolve([...value, data]);
      });
    });
  })
  .then(value => {
    console.log(value); // [data1, data2, data3]
  })
  .catch(err => {
    console.error('读取文件出错:', err);
  });

这个例子展示了:

  • 如何通过链式 .then() 依次读取多个文件。
  • 每一步都将之前的结果累积到数组中。
  • 使用 .catch() 统一处理任意一步的 I/O 错误。

虽然现代 Node.js 更推荐使用 fs.promisesasync/await,但此例清晰体现了 Promise 链如何管理依赖型异步流程。


七、最佳实践与注意事项

  1. 始终使用 .catch()
    不要只依赖 .then() 的第二个参数,因为 .then() 内部的同步错误无法被其自身捕获,但能被后续 .catch() 捕获。
  2. 避免“Promise 嵌套地狱”
    不要写 new Promise(resolve => { anotherPromise().then(...) }),应直接返回 Promise。
  3. 理解微任务队列
    Promise 回调属于微任务,执行时机早于 setTimeout 等宏任务。
  4. 不要忽略错误
    未处理的 rejected Promise 会导致“未捕获的异常”,在 Node.js 中可能使进程崩溃。
  5. 考虑使用 async/await
    虽然 Promise 很强大,但在复杂逻辑中,async/await 语法更接近同步代码,可读性更高。

结语

Promise 是 JavaScript 异步编程的基石。它通过状态机模型、链式调用和统一的错误处理机制,有效解决了回调地狱问题。通过本文分析,我们不仅掌握了 Promise 的基本用法,还深入理解了其内部状态传递、嵌套行为和实际应用场景。

掌握 Promise,是迈向现代前端与 Node.js 开发的关键一步。在此基础上,进一步学习 async/awaitPromise.all()Promise.race() 等高级特性,将使你能够构建更加健壮、可维护的异步程序。

正如那句老话:“理解了 Promise,你就理解了 JavaScript 的异步灵魂。

JavaScript 词法作用域与闭包:从底层原理到实战理解

JS运行机制

词法作用域

“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关”

换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。

你可以这样理解:

代码怎么写的,它就怎么执行——这非常符合我们的直觉。

比如,letconst 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。

所以,“词法”本质上就是:看代码结构,而不是看运行过程

看一段关于词法作用域的代码

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar()// 运行时
}
var myName = '极客时间'
foo();

这里输出的是

极客时间 为什么输出的不是 "极客邦"

因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域

JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用

bar 内部访问变量(比如 testmyName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。

因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。

image.png

总结:

JavaScript 使用 词法作用域(Lexical Scoping) ,也就是说,函数在定义时就决定了它能访问哪些变量,而不是在调用时

词法作用域链:变量查找的路径

当 JavaScript 引擎执行代码时,会为每一段可执行代码创建一个 执行上下文(Execution Context)
每个执行上下文都包含一个 词法环境(Lexical Environment) ,它不仅保存了当前作用域中声明的变量,还持有一个指向外层词法环境的引用。这些嵌套的词法环境连接起来,就形成了 作用域链(Scope Chain)

  • 全局执行上下文位于调用栈的底部,是程序启动时创建的。
  • 每当调用一个函数,就会创建一个新的函数执行上下文,并将其压入调用栈。
  • 当需要查找某个变量时,JavaScript 会从当前作用域开始,沿着作用域链由内向外逐层查找,直到找到该变量,或最终到达全局作用域为止。

这种机制确保了变量访问遵循词法作用域规则——即“在哪里定义,就看哪里的变量”,而不是“在哪里调用”。

看看这段关于作用域链和块级作用域的代码:

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"  // 1.先在词法环境查找一下
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

这段代码的执行结果不会报错,而是会正常输出:

1

原因正是基于 JavaScript 的 词法作用域(Lexical Scoping) 机制。

虽然 bar() 是在 foo() 内部被调用的,但它的声明位置在全局作用域。因此,当 bar 内部引用变量 test 时,JavaScript 引擎会从 bar 自身的作用域开始查找;找不到时,就沿着词法作用域链向外层查找——也就是直接跳到全局作用域,而不会进入 foo 的作用域。

由于全局作用域中存在 let test = 1;,所以 console.log(test) 最终输出的是 1

image.png

换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链

在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境

当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain)

正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。

image.png

这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。

闭包 ——前面内容的优雅升华

闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。


一、什么是闭包?

闭包 = 一个函数 + 它定义时所处的词法环境。

换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。

这并不是魔法,而是 JavaScript 词法作用域机制的自然结果


二、闭包形成的两个必要条件(缺一不可)

  1. 函数嵌套:内部函数引用了外部函数的变量;
  2. 内部函数被暴露到外部:比如通过 return 返回、赋值给全局变量、作为回调传递等,并在外部被调用。

只有同时满足这两点,闭包才会真正“生效”。


三、经典示例:直观感受闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2; // 注意:test2 未被内部函数使用

  var innerBar = {
    getName: function () {
      console.log(test1); // 引用了外部变量 test1
      return myName;      // 引用了外部变量 myName
    },
    setName: function (newName) {
      myName = newName;   // 修改外部变量 myName
    }
  };

  return innerBar; // 将内部对象返回,使内部函数可在外部调用
}

// 执行 foo,获取返回的对象
var bar = foo(); // 此时 foo 已执行完毕,上下文出栈

// 在外部调用内部函数 —— 闭包开始工作!
bar.setName("极客邦");
bar.getName();               // 输出:1
console.log(bar.getName());  // 输出:1 和 "极客邦"

输出结果:

1
1
极客邦

四、关键问题:为什么 foo 的变量没被垃圾回收?

  • 通常情况下,函数执行结束后,其局部变量会被垃圾回收。

  • 但在闭包场景中,只要内部函数仍被外部引用,JavaScript 引擎就会保留该函数所依赖的外部变量

  • 在本例中:

    • getName 和 setName 引用了 myName 和 test1 → 这两个变量被“捕获”并保留在内存中;
    • test2 没有被任何函数使用 → 被正常回收。

📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量
这既保证了功能,又避免了内存浪费。

image.png


五、闭包的本质与词法作用域的关系

1. 闭包的本质

闭包不是某种特殊语法,而是一种运行时行为

函数 + 它出生时的词法环境 = 闭包

你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。

2. 与词法作用域的关联

  • 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
  • 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。

✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。


💡 记住一句话
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。

理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

开场白:一个变量的"无法无天"与它的"寻亲之路"


📖 第一章:黑马喽的嚣张岁月

话说在前端江湖的ES5时代,有个叫var的黑马喽,这家伙简直无法无天!它想来就来,想走就走,完全不顾什么块级作用域的规矩。

// 你们看看这黑马喽的德行
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 你猜输出啥?3,3,3!
    }, 100);
}
// 循环结束了,i还在外面晃荡
console.log(i); // 3,瞧瞧,跑出来了吧!

但今天咱们不仅要扒一扒var的底裤,还要讲讲变量们是怎么"寻亲"的——这就是作用域作用域链的故事。


🔧 第二章:编译器的三把斧——代码的"梳妆打扮"

要说清楚作用域,得先从JavaScript的编译说起。别看JS是解释型语言,它在执行前也要经历一番"梳妆打扮"。

2.1 词法分析:拆解字符串的魔术

想象一下,编译器就像个认真的语文老师,把代码这个长句子拆成一个个有意义的词语:

var a = 1vara=1

注意:空格要不要拆开,得看它有没有用。就像读书时要不要停顿,得看语气!

2.2 语法分析:构建家谱树

拆完词之后,编译器开始理清关系——谁声明了谁,谁赋值给谁,最后生成一棵抽象语法树(AST)

这就像把一堆零散的家庭成员信息,整理成清晰的家谱。

2.3 代码生成:准备执行

最后,编译器把家谱树转换成机器能懂的指令,准备执行。

关键点:JS的编译发生在代码执行前的一瞬间,快到你几乎感觉不到!


💕 第三章:变量赋值的三角恋

var a = 1这么简单的一行代码,背后居然上演着一场"三角恋":

  • 🎯 编译器:干脏活累活的媒人,负责解析和牵线
  • JS引擎:执行具体动作的新郎
  • 🏠 作用域:管理宾客名单的管家

3.1 订婚仪式(编译阶段)

// 当看到 var a = 1;
编译器:管家,咱们这有叫a的变量吗?
作用域:回大人,还没有。
编译器:那就在当前场合声明一个a!

3.2 结婚典礼(执行阶段)

JS引擎:管家,我要找a这个人赋值!
作用域:大人请,a就在这儿。
JS引擎:好,把1赋给a!

这里涉及到两种查找方式:

LHS查询:找容器(找新娘)

var a = 1; // 找到a这个容器装1

RHS查询:找源头(找新娘的娘家)

console.log(a); // 找到a的值
foo();         // 找到foo函数本身

编译过程示意图


🐒 第四章:黑马喽的罪证展示

在ES5时代,var这家伙真是目中无人:

4.1 无视块级作用域

{
    var rogue = "我是黑马喽,我想去哪就去哪";
}
console.log(rogue); // 照样能访问!

4.2 变量提升的诡计

console.log(naughty); // undefined,而不是报错!
var naughty = "我提升了";

这货相当于:

var naughty;          // 声明提升到顶部
console.log(naughty); // undefined
naughty = "我提升了"; // 赋值留在原地

🙏 第五章:如来佛祖的五指山——let和const

ES6时代,如来佛祖(TC39委员会)看不下去了,派出了letconst两位大神:

5.1 块级作用域的紧箍咒

{
    let disciplined = "我在块里面很老实";
    const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不来咯

5.2 暂时性死区的降妖阵

console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?没门!";

真相let/const其实也会提升,但是被关进了"暂时性死区"这个五指山里,在声明前谁都别想访问!


🧩 第六章:黑马喽的迷惑行为——词法作用域的真相

6.1 一个让黑马喽困惑的例子

function bar(){
    console.log( myName);  // 黑马喽:这里该输出啥?
}

function foo(){
    var myName = "白吗喽";
    bar()
    console.log("1:", myName)   // 这个我懂,输出"白吗喽"
}

var myName = "黑吗喽";
foo()  // 输出:"黑吗喽","白吗喽"

黑马喽挠着头想:"不对啊!bar()foo()里面调用,不是应该找到foo()里的myName = "白吗喽"吗?怎么会是黑吗喽呢?"

6.2 outer指针:函数的"身份证"

原来,在编译阶段,每个函数就已经确定了自己的"娘家"(词法作用域):

// 编译阶段发生的事情:
// 1. bar函数出生,它的outer指向全局作用域(它声明在全局)
// 2. foo函数出生,它的outer也指向全局作用域(它声明在全局)
// 3. 变量myName声明提升:var myName = "黑吗喽"

// 执行阶段:
var myName = "黑吗喽";  // 全局myName赋值为"黑吗喽"
foo();                 // 调用foo函数

黑马喽的错误理解

bar() → foo() → 全局

实际的作用域查找(根据outer指针):

bar() → 全局

如图

C9AE3D8E-F1DA-4767-AE87-AF4B1AF8B94D.png

6.3 词法作用域 vs 动态作用域

词法作用域(JavaScript):看出生地

var hero = "全局英雄";

function createWarrior() {
    var hero = "部落勇士";
    
    function fight() {
        console.log(hero); // 永远输出"部落勇士"
    }
    
    return fight;
}

const warrior = createWarrior();
warrior(); // "部落勇士" - 记得出生时的环境

动态作用域:看调用地(JavaScript不是这样!)

// 假设JavaScript是动态作用域(实际上不是!)
var hero = "战场英雄";
const warrior = createWarrior();
warrior(); // 如果是动态作用域,会输出"战场英雄"

🗺️ 第七章:作用域链——变量的寻亲路线图

7.1 每个函数都带着"出生证明"

var grandma = "奶奶的糖果";

function mom() {
    var momCookie = "妈妈的饼干";
    
    function me() {
        var myCandy = "我的棒棒糖";
        console.log(myCandy);    // 自己口袋找
        console.log(momCookie);  // outer指向mom
        console.log(grandma);    // outer的outer指向全局
    }
    
    me();
}

mom();

7.2 作用域链的建造过程

// 全局作用域
var city = "北京";

function buildDistrict() {
    var district = "朝阳区";
    
    function buildStreet() {
        var street = "三里屯";
        console.log(street);     // 自己的
        console.log(district);   // outer指向buildDistrict
        console.log(city);       // outer的outer指向全局
    }
    
    return buildStreet;
}

// 编译阶段就确定的关系:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域

如图

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

⚔️ 第八章:作用域链的实战兵法

8.1 兵法一:模块化开发

function createCounter() {
    let count = 0; // 私有变量,外部无法直接访问
    
    return {
        increment: function() {
            count++; // 闭包:outer指向createCounter作用域
            return count;
        },
        getValue: function() {
            return count;
        }
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 报错!count是私有的

8.2 兵法二:解决循环陷阱

黑马喽的坑
for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 3, 3, 3 - 所有函数共享同一个i
    }, 100);
}
作用域链的救赎
// 方法1:使用let创建块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2 - 每个i都有自己的作用域
    }, 100);
}

// 方法2:IIFE创建新作用域
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 - j在IIFE作用域中
        }, 100);
    })(i);
}

8.3 兵法三:正确的函数嵌套

function foo(){
    var myName = "yang";
    
    function bar(){  // 现在bar的outer指向foo了!
        console.log("2:", myName);  // 找到foo的myName
    }
    
    bar()
    console.log("1:", myName)
}

var myName = "yang1";
foo()  // 输出:2: yang, 1: yang

🚀 第九章:现代JavaScript的作用域体系

9.1 块级作用域的精细化管理

function modernScope() {
    var functionScoped = "函数作用域";
    let blockScoped = "块级作用域";
    
    if (true) {
        let innerLet = "内部的let";
        var innerVar = "内部的var"; // 依然提升到函数顶部!
        
        console.log(blockScoped); // ✅ 可以访问外层的let
        console.log(functionScoped); // ✅ 可以访问外层的var
    }
    
    console.log(innerVar); // ✅ 可以访问
    // console.log(innerLet); // ❌ 报错!let是块级作用域
}

9.2 作用域链的新层级

// 全局作用域
const GLOBAL = "地球";

function country() {
    // 函数作用域
    let nationalLaw = "国家法律";
    
    {
        // 块级作用域1
        let provincialLaw = "省法规";
        
        if (true) {
            // 块级作用域2
            let cityRule = "市规定";
            
            console.log(cityRule);     // ✅ 本市有效
            console.log(provincialLaw); // ✅ 本省有效
            console.log(nationalLaw);   // ✅ 全国有效
            console.log(GLOBAL);        // ✅ 全球有效
        }
        
        // console.log(cityRule); // ❌ 跨市无效
    }
}

⚡ 第十章:作用域链的性能与优化

10.1 作用域查找的代价

var globalVar = "我在最外层";

function level3() {
    // 这个查找要经过:自己 → level2 → level1 → 全局
    console.log(globalVar);
}

function level2() {
    level3();
}

function level1() {
    level2();
}

10.2 优化心法

function optimized() {
    const localCopy = globalVar; // 局部缓存,减少查找深度
    
    function inner() {
        console.log(localCopy); // 直接访问,快速!
    }
    
    inner();
}

🏆 大结局:黑马喽的毕业总结

经过这番学习,黑马喽终于明白了作用域的真谛:

🎯 作用域的进化史

  1. ES5的混乱var无视块级作用域,到处捣乱
  2. ES6的秩序let/const引入块级作用域和暂时性死区
  3. outer指针机制:词法作用域在编译时确定,一辈子不变

🧠 作用域链的精髓

  1. outer指针:函数在编译时就确定了自己的"娘家"
  2. 词法作用域:看出生地,不是看调用地
  3. 就近原则:先找自己,再按outer指针找上级
  4. 闭包的力量:函数永远记得自己出生时的环境

💡 最佳实践心法

// 好的作用域设计就像好的家风
function createFamily() {
    // 外层:家族秘密,内部共享
    const familySecret = "传家宝";
    
    function teachChild() {
        // 中层:教育方法
        const education = "严格教育";
        
        return function child() {
            // 内层:个人成长
            const talent = "天赋异禀";
            console.log(`我有${talent},接受${education},知道${familySecret}`);
        };
    }
    
    return teachChild();
}

const familyMember = createFamily();
familyMember(); // 即使独立生活,依然记得家族传承

🌟 终极奥义

黑马喽感慨地总结道:

"原来JavaScript的作用域就像血缘关系:

  • 作用域是家规(在哪里能活动)
  • 作用域链是族谱(怎么找到祖先)
  • outer指针是出生证明(一辈子不变)
  • 词法作用域是家族传承(看出生地,不是看现住地)"

从此,黑马喽明白了:想要在前端江湖混得好,就要遵守作用域的家规,理解作用域链的族谱,尊重outer指针的出生证明!


🐒 黑马喽寄语:记住,函数的作用域是它的"娘家",编译时定亲,一辈子不变!理解了这套规则,你就能驯服任何JavaScript代码!

前端高频面试题之CSS篇(一)

1、实现垂直居中的方式有哪些?

  • line-height:文本可以使用 line-height 等于容器高度。
  • Flex 布局:display: flex; align-items: center;)。
  • absolute + transform 或者 absolute + 负marginposition: absolute; top: 50%; transform: translateY(-50%);或者 position: absolute; top: 50%; margin-top: -50px;(容器高度为 100px)
  • absolute + margin: autoposition: absolute; inset: 0; margin: auto
  • Grid 布局display: grid; place-items: center;
  • table 布局display: table-cell;vertical-align: middle;

2、选择器权重和样式优先级是怎样的?

CSS 各选择器权重(从高到低):

选择器名称 选择器格式 权重
id选择器 #id 100
类选择器 .classname 10
属性选择器 [attr=value] 10
伪类选择器 li:first-child 10
标签选择器 a 1
伪元素选择器 div::after 1
相邻兄弟选择器 div+div 0
子选择器 div > a 0
后代选择器 div a 0
通配符选择器 * 0
  • 可以在样式后面使用 !important ,比如 display: none !important,此时该样式的权重最高,权重值为 Infinity,但需要慎用。
  • style内联样式的权重为1000
  • 权重相同,后出现的覆盖前面。

3、CSS 隐藏元素的方法有哪些?

  1. display:none: 元素不会渲染,不占据空间,也不响应绑定的事件。
  2. opacity: 0: 将元素的透明度设置为0,进而让元素从视觉上消失,但元素仍然会占据空间,并且会响应绑定的事件
  3. visibility: hidden: 这种方式隐藏会让元素依旧占据空间,但不会响应事件
  4. position: absolute;left: -9999px;top: -9999px;: 利用绝对定位将元素移到屏幕外。
  5. z-index: -9999: 降低元素层级,让当前元素被其它元素覆盖,间接达到元素隐藏的目的。
  6. overflow: hidden: 超出该元素范围内的元素将会隐藏显示。
  7. clip-path: inset(100%)(向内裁剪100%)或者clip-path: circle(0)(半径为0的圆形): 使用元素裁剪来实现元素隐藏。
  8. transform: scale(0,0): 利用 css3 的元素缩放能力,将元素缩放为0来实现元素的隐藏。

4、Link 和 @import 的区别

特性 link标签 @import
所属标准 XHTML/HTML标准 CSS标准
引用内容类型 可用于引入 CSS、RSS、图标等多种资源 仅支持css样式
加载时机 页面加载时同步加载 等待整个页面加载完成后再加载
JavaScript 控制 支持通过 DOM 操作修改样式链接 不支持动态控制
兼容性 无兼容问题 CSS2.1 才有的语法,在 IE5+ 才能识别
性能 更快,有利于首屏渲染 相对较慢,可能造成样式延迟加载

5、transition 和 animation 的区别

  • 过渡(transition):其核心是状态变化,如 transition: all 0.5s ease;,给所有属性加上一个 0.5s 的平滑过渡。
  • 动画(animation):其核心是对动画过程进行多帧关键帧控制,如 @keyframes name { 0% { ... } 100% { ... } },然后 animation: name 1s infinite;。动画相比过渡而言更加复杂,支持循环和暂停。

6、聊一聊盒模型

CSS 盒模型描述了元素在页面上的空间占用,包括内容(content)、内边距(padding)、边框(border)和外边距(margin)。

  • 标准盒模型(W3C 标准,默认盒模型):width 和 height 仅包含内容的宽度和高度,元素的总宽度 = width + 左右 padding + 左右 border + 左右 margin。
.box {
  box-sizing: content-box;
}
  • IE 盒模型(border-box),也叫怪异盒模型:元素的宽度 = width(width 里面包括内边距 padding 和边框 border) + 左右 margin。
.box {
  box-sizing: border-box;
}

7、聊一聊 CSS 预处理器

CSS 预处理器是一种工具或语言扩展,它可以让开发者以更加高级的语法来编写 CSS,比如可以定义变量、支持 CSS 嵌套写法、定义函数、使用循环等,在开发时可以提高我们的开发效率和项目的可维护性。但由于我们运行的平台,比如浏览器不支持这些高级语法,所以在代码的运行的时候,需要利用对应的工具编译成标准的 CSS。

CSS 常见的预处理器包括 SassLessStylusPostCSS 等。

8、什么是 CSS Sprites?

CSS Sprites 技术就是我们常说的雪碧图,通过将多张小图标拼接成一张大图,然后通过 CSS 的 background-imagebackground-position 属性来显示图像的特定部分,能有效的减少HTTP请求数量以达到加速显示内容的技术。

9、什么是BFC?它有什么作用?

BFC(block formatting context):简单来说,BFC 就是一种属性,这种属性会影响着元素的定位以及与其兄弟元素之间的相互作用。

形成 BFC 的条件:

  1. 浮动元素,floatnone 以外的值;
  2. 绝对定位元素,position(absolute,fixed)
  3. display 为以下其中之一的值:inline-blocks,table-cells,table-captions
  4. overflow 除了 visible 以外的值(hidden,auto,scroll)。

BFC常见作用:

  1. 包含浮动元素。
  2. 不被浮动元素覆盖。
  3. BFC 会阻止外边距折叠,可解决 margin 塌陷问题。

10、Flex 布局是什么?常用属性有哪些?

Flex(弹性盒布局)用于一维布局(如行或列),父容器设置 display: flex;

其常用属性如下:

  • 容器:flex-direction(方向)、justify-content(主轴对齐)、align-items(交叉轴对齐)、flex-wrap(换行)。
  • 子项:flex-grow(增长比例)、flex-shrink(收缩比例)、flex-basis(基础大小)。适合响应式设计。

11、flex:1 是哪些属性组成的?

flex 实际上是 flex-growflex-shrinkflex-basis 三个属性的缩写。

  • flex-grow:定义项目的的放大比例;
  • flex-shrink:定义项目的缩小比例;
  • flex-basis: 定义在分配多余空间之前,项目占据的主轴空间(main size),浏览器根据此属性计算主轴是否有多余空间。

12、flex-basis 和 width 的区别有哪些?

定义和作用:

  • flex-basis: 是 CSS Flexbox 布局中的专有属性,在其它地方使用不生效。
  • width 是一个通用的 CSS 属性。它适用于任何元素(块级、行内块等),不受布局模式限制。

优先级:

如果flex-basis的值为 auto(其默认值就是 auto,未显示设置采用的就是默认值),则 flex item 的初始大小会 fallbackwidth,也就是采用 width 设置的值作为初始大小,而如果 flex-basis 一旦设置了值,比如同时设置 width: 100px;flex-basis: 200px;, flex item 的初始大小会采用 flex-basis 设置的 200px

计算规则

  • flex-basis: flex item 的最终大小 = flex-basis + (剩余空间 * flex-grow) - (不足空间 * flex-shrink)
  • width,如果父级宽度足够,最终 flex-item 的最终大小 = width,如果空间不足,会进行宽度压缩, flex-item 的最终大小 < width,除非设置 flex-shark: 0,宽度就不会被压缩,最终宽度还是为 width的大小。

13、rem、em、px有什么区别?

  • **px(像素):**绝对单位,相对于屏幕分辨率大小固定。
  • rem(root em):CSS3 引入的相对长度单位,相对于 HTML 根元素的字体大小计算。可实现响应式布局。
  • em:是相对长度单位。相对于当前对象内文本的字体尺寸

这个很多人有个误解,em 在设置自身字体大小的时候是相对于父元素的字体大小; 在用 em设置其他属性单位的时候, 比如width,是相对于自身的字体属性大小, 只是很多时候自身字体属性是继承自父元素.

14、如何清除浮动?

浮动会导致父元素高度塌陷。清除方法如下:

  • 父元素添加 overflow: hidden;(触发 BFC)。
  • 使用伪元素:.clearfix::after { content: ''; display: block; clear: both; }
  • 父元素设置 floatdisplay: table;

现代布局更推荐使用 Flex/Grid 避免浮动。

15、CSS 性能优化的常见技巧?

  • CSS 加载性能优化:

    • 提取公共 CSS 文件。
    • 避免使用 @import
    • 压缩 CSS 文件。
    • 利用浏览器缓存。
    • 使用 CDN 加速。
    • 使用 CSS Sprite
    • CSS 样式抽离和去除无用 CSS
    • 合理使用内嵌 CSS
  • CSS 选择器性能优化:

    • 避免使用通配符选择器。
    • 使用子选择器代替后代选择器。
    • 优先使用类(Class)和 ID 选择器。
    • 避免深层嵌套的选择器。
  • CSS 选择器性能优化:

    • 避免使用过于复杂的属性。
    • 避免使用不必要的属性。
    • 避免使用 !important
  • CSS 动画性能优化:

    • 使用 transformopacity 属性来进行动画。
    • 避免使用过于复杂的动画效果。
    • 在动画中使用 will-change 属性。
    • 使用 requestAnimationFrame() 函数来优化动画。
  • CSS 渲染性能优化:

    • 使用 class 合并 DOM 的修改。
    • DOM 元素脱离文档流。

16、最后来一道考察 CSS 的 z-index 的面试真题

请按层级从高到低的顺序列出元素:

<body>
  <div id="dom-1" style="position: fixed; z-index: 100;">
    <div id="dom-2" style="position: absolute; z-index: 2000;"></div>
  </div>
  <div id="dom-3" style="position: relative; z-index: 1000;"></div>
</body>

这里主要考察 z-index 的两条层级计算规则:

  • 当父元素创建了一个层叠上下文 position: relative/absolute/fixed 时,此时父子元素的层级与 z-index 无关,就算 dom-2z-index99,其层级也比父级高。
  • 子元素的 z-index 受父元素限制。即使子元素 z-index 很高,如果父元素 z-index 低,整个子树都会在低层。

所以层级顺序从高到低依次是 dom-3 > dom-2 > dom-1

我们加一点样式就能很明显看出层级关系,代码如下:

<!-- z-index.html -->
<style>
  #dom-1 {
    width: 300px;
    height: 300px;
    background-color: red;
  }
  #dom-2 {
    width: 200px;
    height: 200px;
    background-color: blue;
  }
  #dom-3 {
    width: 100px;
    height: 100px;
    background-color: yellow;
  }
</style>
<body>
  <div id="dom-1" style="position: fixed; z-index: 100">
    <div id="dom-2" style="position: absolute; z-index: 2000"></div>
  </div>
  <div id="dom-3" style="position: relative; z-index: 1000"></div>
</body>

其渲染结果如下:

小结

以上是整理的一部分 CSS 的相关面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 CSS 一些常见布局实现的面试题。

关于XSS和CSRF,面试官更喜欢这样的回答!

这是我们前端最常见的两种攻击手段,也是面试中最常考的前端攻击。这篇文章我用最精炼、最优雅,也是面试官最喜欢的回答方式来讲解下 XSS 和 CSRF。

一、XSS(跨站脚本)

原理

攻击者把 恶意脚本 注入到受信任页面并被浏览器执行,脚本 利用页面的信任上下文(Cookies、localStorage、DOM)窃取数据或劫持会话。

常见类型

  • 反射型(参数或路径直接反射到页面并执行)
  • 存储型(恶意内容存储在服务器,其他用户访问时触发)
  • DOM-based(客户端不安全的 DOM 操作导致执行,和服务器无关)

最小复现示例(不安全的后端 + 不安全的前端)

后端(Express — 危险示例)

// server.js(示例,仅演示不安全行为)
const express = require('express');
const app = express();

app.get('/search', (req, res) => {
  const q = req.query.q || '';
  // 直接把用户输入拼到 HTML 中 —— 危险!
  res.send(`<html><body>搜索: ${q}</body></html>`);
});

app.listen(3000);

访问 /search?q=<script>alert(1)</script> 会执行脚本(反射型)。

前端 DOM XSS(危险)

<div id="out"></div>
<script>
  const q = location.search.split('q=')[1] || '';
  document.getElementById('out').innerHTML = q; // 不转义 —— 危险(DOM XSS)
</script>

实战防范要点

  1. **输出编码(服务器端)**:所有插入 HTML 的内容做 HTML 转义(&<>\"')。
  2. 前端最小化 innerHTML:尽量用框架绑定(React/Vue 的模板)替代 innerHTML

    框架框出来的插值({value} / {{ value }})会自动做 HTML 转义,把 <>&"' 等关键字符替换成实体(&lt; 等),从而把攻击脚本当文本显示,而不是执行。

  3. 富文本白名单清洗:对于必须存储/渲染的 HTML(富文本),后端用白名单 sanitizer(比如 bleach / html-sanitizer),前端再用 DOMPurify 做一次保护,对标签属性等进行清洗。
  4. Content-Security-Policy(CSP)头部:禁止内联脚本、只允许可信源。
  5. HttpOnly Cookie 头部:token/cookie 设置 HttpOnly,防止被脚本直接读取(减轻 XSS 后果)。

示例代码 — 安全改造

后端(Express + 转义)

const escapeHtml = s => String(s)
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');

app.get('/search', (req, res) => {
  const q = escapeHtml(req.query.q || '');
  res.send(`<html><body>搜索: ${q}</body></html>`);
});

前端(若必须渲染 HTML,用 DOMPurify)

<!-- npm install dompurify -->
<script src="https://unpkg.com/dompurify@2.<!--version-->/dist/purify.min.js"></script>
<div id="content"></div>
<script>
  // htmlFromServer 来自后端 API,仍需 sanitize
  const htmlFromServer = '<img src=x onerror=alert(1)>';
  document.getElementById('content').innerHTML = DOMPurify.sanitize(htmlFromServer);
</script>

设置 CSP(Nginx/Express header 示例)

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none';

二、CSRF(跨站请求伪造)

原理

利用用户已登录且浏览器会自动带上凭证(cookie)的特性,攻击者诱导用户发起对受信任站点的请求(如通过自动提交表单或图片请求),从而在用户 名下执行未授权操作。

最小复现示例(攻击者页面)

如果 bank.com/transfer 接受 GET 或 POST 并依赖 cookie 验证,攻击页面可这样写:

<!-- auto.html(在攻击者域名上) -->
<form action="https://bank.com/transfer" method="POST" id="f">
  <input name="to" value="attacker" />
  <input name="amount" value="1000" />
</form>
<script>document.getElementById('f').submit();</script>

用户在已登录 bank.com 的情况下访问攻击页面时,浏览器会自动带上 bank.com 的 cookie,导致转账。

防护要点

  1. SameSite Cookie:把 session/cookie 设置 SameSite=LaxStrict(Lax 对 POST 有保护,适配大多数情形)。
  2. **CSRF Token(同步/双提交)**:服务端生成随机 token,响应给前端;敏感请求必须携带并校验该 token。

    该 token 不同于 jwt token ,此处的 csrf-token 只为配合 session+cookie 传统鉴权策略做安全防护。

  3. 检查 Origin/Referer:对跨站请求校验 OriginReferer 头(通常对 POST/PUT/DELETE 生效)。
  4. 避免用 cookie 做对外 API 的认证:采用 Authorization: Bearer header 的 token 机制(只有 JS 能读/写),结合 CORS 限制。
  5. 敏感操作二次确认:密码/OTP/二次验证。

示例代码(Express + scrf token + csurf)

csurf 使用 **双提交验证机制(CSRF Token)**:

  1. 服务端生成一个 CSRF Token,放在 cookie 或 session 中。
  2. 前端每次发 POST/PUT/DELETE 请求要带上这个 token,常放在请求头或表单隐藏字段,比如:X-CSRF-Token: ey2423482374823748234
  3. 服务端校验 token,是否匹配、是否未过期、是否合法。

后端(Express)

// server.js
const express = require('express');
const cookieParser = require('cookie-parser');
const csurf = require('csurf');

const app = express();
app.use(cookieParser());
app.use(express.json());
app.use(csurf({ cookie: { httpOnly: true, sameSite: 'lax' } }));

app.get('/csrf-token', (req, res) => {
  // 返回 token 给 SPA 前端(用于后续请求 header)
  res.json({ csrfToken: req.csrfToken() });
});

app.post('/transfer', (req, res) => {
  // csurf 中间件会自动校验请求中的 token(_csrf 字段或 X-CSRF-Token header)
  // 执行转账逻辑...
  res.json({ ok: true });
});

app.listen(3000);

前端 SPA(获取 token 并在请求头中发送)

// 初始化时获取 token
async function init() {
  const r = await fetch('/csrf-token', { credentials: 'include' });
  const { csrfToken } = await r.json();
  window.__CSRF_TOKEN = csrfToken;
}

// 发送受保护请求
async function transfer() {
  await fetch('/transfer', {
    method: 'POST',
    credentials: 'include', // 仍然带 cookie
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': window.__CSRF_TOKEN
    },
    body: JSON.stringify({ to: 'bob', amount: 100 })
  });
}

只用 SameSite(简洁替代,适用多数场景),在服务端设置 cookie:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/;

这就能阻止绝大多数通过第三方页面触发的 POST/跨站敏感操作。

三、XSS 与 CSRF 的关键总结

概念:

  • XSS:攻击者注入脚本并可读取页面内容(更强),根源是输出/DOM 不安全。
  • CSRF:攻击者伪造用户请求,无法直接读取响应,根源是浏览器自动带凭证。

防护:

  1. 后端统一使用 HTML escape 库;富文本走白名单 sanitizer。
  2. 全站 Cookie:HttpOnly; Secure; SameSite=Lax
  3. 对需要的页面开启 CSP(report-only 先观测,再 enforce)。
  4. SPA:首次获取 CSRF token 并在后续请求中以 header 发送;服务端检查 Origin/Referer
  5. CI/代码审查禁止随意使用 innerHTML/eval/dangerouslySetInnerHTML
  6. 对关键操作实施二次验证(密码/OTP)。

🧱 深入理解栈(Stack):原理、实现与实战应用

一、什么是栈?🤔

(Stack)是一种经典的线性数据结构,其核心特性是 “先进后出” (Last In First Out, LIFO)。
你可以把它想象成一摞盘子🍽️:每次只能从顶部放入或取出盘子,最晚放进去的盘子最先被拿出来。

在计算机科学中,栈广泛应用于以下场景:

  • 🔁 函数调用(调用栈)
  • 🧮 表达式求值与转换(如中缀转后缀)
  • ✅ 括号匹配验证
  • 🖥️ 浏览器前进/后退历史
  • 🔄 撤销(Undo)操作

💡 栈的核心思想:只操作一端(栈顶),另一端封闭


二、栈的抽象数据类型(ADT)🧩

一个标准的栈应具备以下属性和方法

方法 / 属性 说明
push(item) ➕ 入栈:将元素压入栈顶
pop() ➖ 出栈:移除并返回栈顶元素
peek() / top() 👀 查看栈顶元素但不移除
isEmpty() ❓ 判断栈是否为空
size 🔢 获取栈中元素数量
toArray()(可选) 📤 将栈内容转为数组(用于调试或展示)

⚠️ 注意:栈不允许随机访问中间元素,只能操作栈顶


三、ES6 Class 与栈的封装 🛠️

ES6 引入了 class 语法,使面向对象编程更清晰。结合私有字段(#)、get/set 访问器等新特性,我们可以优雅地实现栈。

下面深入解析这些关键特性👇:


1️⃣ class:定义类的模板 📐

在 ES6 之前,JavaScript 通过构造函数 + 原型链模拟类:

// 🕰️ ES5 风格
function Person(name) {
  this.name = name;
}
Person.prototype.sayHello = function() {
  console.log('Hello, ' + this.name);
};

ES6 的 class 是对上述模式的语法糖,但结构更清晰、更接近传统 OOP:

// ✨ ES6 class
class Person {
  constructor(name) {
    this.name = name;
  }
  sayHello() {
    console.log('Hello, ' + this.name);
  }
}

关键点

  • 底层仍基于 原型(prototype)
  • 提供声明式、结构化的代码组织方式
  • 显著提升可读性与可维护性,尤其适合大型项目

2️⃣ #privateField:私有属性 🔒

传统 JS 中所有属性都是公开的,容易被外部篡改:

class Counter {
  constructor() {
    this.count = 0; // 😱 外部可随意修改!
  }
}
const c = new Counter();
c.count = 999; // 破坏封装!

ES2022 引入 私有字段(以 # 开头),仅限类内部访问:

class Counter {
  #count = 0; // 🔒 私有属性

  increment() { this.#count++; }
  getCount() { return this.#count; } // ✅ 安全暴露
}

const c = new Counter();
c.increment();
console.log(c.getCount()); // 1

// c.#count; // ❌ SyntaxError!

优势

  • 封装性:隐藏实现细节
  • 安全性:防止外部误操作
  • 可维护性:内部逻辑变更不影响外部调用

⚠️ 私有字段必须显式声明,不能动态添加。


3️⃣ constructor():初始化实例 🧬

constructor 是类的构造函数,在 new 实例时自动调用:

class Stack {
  #items;
  constructor(initialItems = []) {
    this.#items = [...initialItems]; // 初始化私有数组
  }
}

作用

  • 初始化实例属性(包括私有属性)
  • 接收参数设置初始状态
  • 若未定义,JS 会提供空默认构造函数

🔁 注意:每个类最多只能有一个 constructor


4️⃣ get size():只读属性访问器 📏

有时我们希望暴露某个值,但禁止修改。这时可用 get 定义访问器:

class ArrayStack {
  #stack = [];
  get size() {
    return this.#stack.length; // 📊 像读属性一样使用
  }
}

const stack = new ArrayStack();
console.log(stack.size); // 0
// stack.size = 10; // ❌ 无效(严格模式报错)

好处

  • 语义清晰:size 看似属性,实为计算值
  • 可加入校验、日志、缓存等逻辑
  • 实现只读接口,避免误写

💡 同理,set 可拦截赋值:

set maxSize(value) {
  if (value < 0) throw new Error('maxSize 不能为负');
  this._maxSize = value;
}

5️⃣ 方法共享于原型链,节省内存 🧠

这是 class 最重要的性能优势!

所有实例方法(非静态、非箭头函数)都定义在类的原型上:

class Stack {
  push() { /* ... */ }
  pop() { /* ... */ }
}

const s1 = new Stack();
const s2 = new Stack();

console.log(s1.push === s2.push); // ✅ true!

这意味着

  • 方法只在内存中存在一份
  • 所有实例通过原型链共享方法
  • 极大节省内存,尤其适合创建大量对象(如游戏实体、UI 组件)

❌ 对比反模式(ES5 常见陷阱):

function BadStack() {
  this.push = function() { /* 每次 new 都新建函数!*/ };
}

📌 建议:现代项目优先使用 ES6+ class,善用私有字段与访问器,构建高内聚、低耦合的组件。


四、两种实现方式:数组 vs 链表 ⚖️

栈可以用数组链表实现,各有优劣:


1️⃣ 基于数组的栈(ArrayStack)📦

class ArrayStack {
  #stack = [];
  get size() { return this.#stack.length; }
  isEmpty() { return this.size === 0; }
  push(num) { this.#stack.push(num); }
  pop() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack.pop();
  }
  peek() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack[this.size - 1];
  }
  toArray() { return [...this.#stack]; }
}

✅ 优点:

  • 时间效率高push/pop 在尾部操作,平均 O(1)
  • 内存连续,缓存友好(CPU 更快访问)
  • 代码简洁,JS 数组原生支持

❌ 缺点:

  • 扩容成本高:容量不足时需复制所有元素 → O(n)
  • 可能存在空间浪费(预分配未用完)

💡 实际中,扩容是低频事件均摊时间复杂度仍为 O(1)


2️⃣ 基于链表的栈(LinkedListStack)⛓️

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

class LinkedListStack {
  #stackPeek = null;
  #size = 0;

  get size() { return this.#size; }
  isEmpty() { return this.size === 0; }

  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek;
    this.#stackPeek = node;
    this.#size++;
  }

  peek() {
    if (!this.#stackPeek) throw new Error('栈为空');
    return this.#stackPeek.val;
  }

  pop() {
    const num = this.peek();
    this.#stackPeek = this.#stackPeek.next;
    this.#size--;
    return num;
  }

  toArray() {
    const arr = new Array(this.size);
    let node = this.#stackPeek;
    let i = this.size - 1;
    while (node) {
      arr[i--] = node.val;
      node = node.next;
    }
    return arr;
  }
}

✅ 优点:

  • 动态扩容:每次插入只需 O(1) ,无复制开销
  • 空间按需分配,无浪费

❌ 缺点:

  • 每个节点需额外存储 next 指针 → 内存开销更大
  • 节点在内存中离散分布 → 缓存局部性差
  • 实例化 ListNode 有一定性能损耗

总结

  • 🚀 日常开发、轻量场景 → 数组实现
  • 🏗️ 大数据、稳定性要求高 → 链表实现

五、实战应用:有效的括号匹配 ✅

栈的经典应用场景之一!

📌 问题描述:

给定字符串 s,仅含 '(', ')', '[', ']', '{', '}',判断是否有效:

  • 左右括号必须正确闭合
  • 顺序必须匹配(如 "([)]" ❌ 无效)

🧠 解题思路:

  1. 遇到左括号 → 将其对应的右括号压入栈
  2. 遇到右括号 → 检查是否与栈顶匹配
  3. 遍历结束 → 栈必须为空

💻 代码实现:

const leftToRight = {
  "(": ")",
  "[": "]",
  "{": "}",
};

function isValid(s) {
  if (!s) return true;
  const stack = [];
  for (let ch of s) {
    if (ch in leftToRight) {
      stack.push(leftToRight[ch]); // 压入期望的右括号
    } else {
      if (!stack.length || stack.pop() !== ch) {
        return false; // 不匹配或栈空
      }
    }
  }
  return stack.length === 0; // 栈空则有效
}

🧪 测试

console.log(isValid("()"));       // ✅ true
console.log(isValid("()[]{}"));   // ✅ true
console.log(isValid("(]"));       // ❌ false
console.log(isValid("([)]"));     // ❌ false
console.log(isValid("{[]}"));     // ✅ true

🔍 为什么压入“右括号”?
这样遇到右括号时可直接比较 stack.pop() === ch无需二次查表,逻辑更简洁高效!


六、总结对比 📊

维度 数组栈 📦 链表栈 ⛓️
时间复杂度(平均) O(1) O(1)
扩容开销 O(n)(低频) O(1)
空间效率 可能浪费 指针开销(约 +50%)
实现难度 ⭐ 简单 ⭐⭐ 中等
适用场景 通用、轻量级 大数据、稳定性要求高

🎯 结语

栈虽简单,却是理解程序运行机制(如调用栈)和解决算法问题(DFS、表达式解析、回溯)的基石数据结构

掌握其两种实现方式及典型应用,不仅能写出更高效的代码,还能在面试中展现扎实的基本功!

📌 终极建议

  • 日常开发 → 优先用 数组实现(简单高效)
  • 面试/性能敏感场景 → 主动讨论 链表方案,展现深度思考 💡

📚 延伸思考:你能用栈实现“浏览器后退”功能吗?或者用两个栈实现一个队列?欢迎动手尝试!

双非同学校招笔记——离开字节入职小📕

本文纯属个人碎碎念,想到哪写到哪,不喜勿喷,也请不要上升到“公司员工文笔水平”这种维度。

一些概括

接触前端也两年了。还记得 23 年初跟着 B 站黑马的课程学 HTML、CSS,从 <div /><span /> 这些最基础的标签开始,一点点摸索,再到后来接触 JavaScript、Vue、React。

学习的过程中,也在网上加入了不少学习群,认识了许多同路人、前辈和朋友。

当时写下这篇 👉 《快手 ks 前端实习小记》
还是我在快手的 last day。转眼 9 个多月过去,现在我又来到小红书,只能说——人生无常,大肠包小肠。

image.png

关于秋招

其实秋招我没投很多公司,一方面是在字节实习,另一方面很多公司也确实不太想去。当然,说实话,也没有几家公司来约我面试😄。

cdf952d901750c159e868b437ba05b73.jpg

我在字节待过两个部门:直播和生服。
在直播待了半年,但因为 HC 的原因,连答辩流程都没推进。后来转去杭州的生服,两个月提前答辩通过了,但因为薪资和业务方向原因,最后还是没有过去,选择离职。

离职后,TT 直播连续给我发了 5 次面试邀请。虽然我说明了拒绝原因,但 HR 和老板还是希望我再面一下。于是 3 天推进了 3 轮。
记得 3 面老板问我最后的一个问题:

“为什么你都过了,还离职拒了?”

我如实说是薪资原因(但是没说业务原因)。老板说他下播后会跟另外两个面试官聊聊。
第二天我就收到了感谢信。

聊聊面试

关于面试,我真的想说一句:
双非同学完全没必要太自卑。

在字节、快手、小红书,我遇到的双非同事真的很多。能见到面试官这一关,学历的劣势基本就过去了;
真正会被卡掉的学历问题,大多都发生在简历筛选阶段。如果两个人技术能力差不多,HR 是会优先看学历的,这是现实;但只要你已经走到技术面了,那就只比实力。

4fa9faa2d8e2b8e2dcb48281c7832d98.jpg

b5c9478e559be882920d45b168f25f44.jpg

328f52e70e24a6e4da6297dacc6e328f.jpg

结尾

“红黄蓝”三家公司进进出出,一年多了。虽然我还是大四学生,但更多时候已经把自己当成一个刚踏入职场一年的年轻人。

以前在掘金看到那些感慨文,说不上不理解,但也没太有共鸣。那时候我以为掘金应该是讨论技术的地方。但现在逐渐理解了:工作占据了我们绝大多数时间,程序员要终身学习,而学习往往发生在为数不多的休息时间里,投入和产出常常不成正比,还伴随着所谓“中年危机”,于是,大家自然就会有更多感慨。

我也慢慢明白了(特别是在直播没转正、秋招又不算顺利的那段时间):

生活没有那么多必须烦恼的事。
把工作做好,不要过度焦虑未来。
把握当下——在自己的能力范围内做自己真正想做的事
不要因为浪费了时间、花了点钱而懊恼。

我们应该感受生活,而不是被生活推着走。

Webpack高级之常用配置项

常用配置项——Mode

默认值是 production(什么都不设置的情况下);可选值有:'none' | 'development' | 'production'。

  • development:会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 development,为模块和 chunk 启用有效的名。
  • production:会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 production,为模块和 chunk 启用确定性的混淆名称。
  • none:不使用任何默认优化选项

常用配置项——Source Map

一、如何在 Webpack 中启用 Source Map

webpack.config.js 中,通过配置 devtool 选项来控制是否生成 source map 以及生成的类型:

module.exports = {
  // ...
  devtool: 'source-map', // 或其他值
};

二、常见的 devtool 选项及其区别

💡 实践建议

  • 开发/测试环境:常用 eval-cheap-module-source-map(速度快 + 能定位到原始模块)
  • 生产环境
    • 如果需要错误监控(如 Sentry),可使用 hidden-source-mapnosources-source-map
      • 通过 sentry-cli.map 文件上传到 Sentry
      • 用户报错时,Sentry 利用 source map 自动还原原始错误位置
    • 一般不直接暴露完整 source-map,以防源码泄露

三、Source Map 的工作原理简述

  1. Webpack 在打包时根据 devtool 配置生成 .map 文件(如 main.js.map
  2. 在输出的 bundle 文件(转换后的文件)末尾添加一行魔法注释:
//# sourceMappingURL=main.js.map
  1. 浏览器 DevTools 自动加载该 .map 文件,并将压缩代码映射回原始源码
  2. 开发者可以在 DevTools 中像调试原始代码一样设置断点、查看变量等

常用配置项——Babel、Browserslist、Polyfill

见之前的文章:《身为大厂前端的你,不能不知道 Babel + Polyfill!》

常用配置项——devServer

你有没有想过一个问题,我们干前端的,为什么要在构建工作内嵌一个服务器(devServer)。

原因其实有这么两个:

  1. 文件变化时完成自动构建
  2. 启动一个服务查看页面展示(最最之前我们学 html 的时候也是通过 live server 启动的一个内置服务器查看页面)

webpack-dev-server 在编译之后不会写入到任何输出文件,而是将 bundle 文件保留在内存中。

常见配置:

devServer: {
  host: '0.0.0.0', // 允许外部访问(如手机调试)
  port: 3000,
  open: true, // 启动后自动打开默认浏览器
  hot: true, // 启用 HMR(模块热更新)
  static: { // 指定静态资源目录(如 public 文件夹)
    directory: path.join(__dirname, 'public'), // 静态资源根目录
    publicPath: '/', // 访问路径前缀
    watch: true,     // 监听变化并刷新
  },
  compress: true, // 是否为静态文件开启 gzip压缩,默认为false
  proxy: { // 一般用于开发环境反向代理避免CORS
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true, // 改变请求头中的 host
      pathRewrite: {
        '^/api': '', // 重写路径,去掉 /api 前缀
      },
    },
  },
  historyApiFallback: true, // 解决SPA页面在路由跳转之后,进行页面刷新时,返回404的错误
}
  • 默认 localhost(仅本机访问),这里我们在实际开发中可能会遇到一个问题,就是后端同学无法通过我们项目的 ip 地址访问,这是因为你和他的主机处于同一网段
  • 设为 '0.0.0.0' 可局域网访问(常用于真机调试)
  • hot: true:支持 HMR,若失败则 fallback 到页面刷新
  • hotOnly: true:仅 HMR,编译失败不刷新页面(hot: true 编译失败会重新刷新整个页面)

常用配置——哈希

在我们给打包的文件进行命名的时候,会使用 placeholder 占位符,这里详细说说占位符的这几个属性:

hash 本身是通过 MD4 的散列函数处理后,生成一个 128 位的 hash 值(32 个十六进制)

  • hash 值的生成和整个项目有关系:
    • 比如我们现在有两个入口 index.js 和 main.js;
    • 它们分别会输出到不同的 bundle 文件中,并且在文件名称中我们有使用 hash;
    • 这个时候,如果修改了 index.js 文件中的内容,那么 hash 会发生变化,两个文件的名称都会发生变化;
  • chunkhash 可以有效的解决上面的问题,它会根据不同的入口进行借来解析来生成 hash 值:
    • 比如我们修改了 index.js,那么 main.js 的 chunkhash 是不会发生改变的;
  • contenthash 表示生成的文件 hash 名称,只和内容有关系:
    • 比如我们的 index.js,引入了一个 style.css,style.css 有被抽取到一个独立的 css 文件中;
    • 这个 css 文件在命名时,如果我们使用的是 chunkhash,那么当 index.js 文件的内容发生变化时,css 文件的命名也会发生变化;
    • 这个时候我们可以使用 contenthash,不影响 css 文件名

前端三大权限场景全解析:设计、实现、存储与企业级实践

权限控制是前端应用(尤其是中大型系统)的核心安全与体验保障,完整的权限体系需覆盖「路由权限、页面元素权限、接口权限」三大场景。本文结合真实项目落地经验,系统梳理各场景的应用逻辑、实现方案、设计模式与存储安全,补充企业级开发的关键细节与避坑要点。

一、路由权限:页面访问的 “第一道门槛”

1. 核心应用场景

  • 角色差异化访问:企业后台中,管理员可访问用户管理、系统配置页,运营仅能访问订单管理、商品管理页。
  • SaaS 多租户定制:不同租户(客户)开通不同功能模块(如 A 租户有报表分析模块,B 租户无),后端根据租户 ID 返回对应路由。
  • 权限动态变更:管理员在后台修改用户角色后,前端无需重启应用,实时更新可访问页面。
  • 刷新丢失修复:单页应用(SPA)刷新后,动态添加的路由会丢失,需重新加载路由配置。
  • 路由懒加载适配:大型应用中,路由组件体积大,需结合权限预校验实现按需加载,避免无效资源请求。

2. 企业级实现方式

(1)混合式路由配置(前端预设 + 后端过滤)

纯后端返回路由灵活性差,纯前端预设路由难以应对权限变更,实际项目常用混合方案:

  • 前端预设「基础路由」(登录页、404 页、首页)和「权限路由模板」(含 meta.permission 标识页面所需权限)。
  • 登录后,后端返回用户「权限标识列表」,前端过滤出可访问的权限路由,动态添加到路由实例。
// 前端预设路由模板
const asyncRoutes = [
  {
    path: '/user-manage',
    name: 'UserManage',
    component: () => import('../views/UserManage.vue'),
    meta: { permission: 'user:manage', title: '用户管理' } // 页面所需权限标识
  },
  {
    path: '/system-config',
    name: 'SystemConfig',
    component: () => import('../views/SystemConfig.vue'),
    meta: { permission: 'system:config', title: '系统配置' }
  }
];

// 生成可访问路由(核心逻辑)
export const generateAccessibleRoutes = (permissions) => {
  return asyncRoutes.filter(route => {
    // 无权限标识的路由默认可访问,有权限标识需匹配用户权限
    return !route.meta?.permission || permissions.includes(route.meta.permission);
  });
};

(2)路由守卫的 “责任链校验”

将登录校验、权限校验、刷新修复等逻辑拆分为独立守卫,按顺序执行,降低耦合:

router.beforeEach(async (to, from, next) => {
  const token = localStorage.getItem('token');
  const userPermissions = store.getters.userPermissions;

  // 1. 未登录拦截(责任链第一环)
  if (!token && to.path !== '/login') {
    return next('/login?redirect=' + to.fullPath); // 记录跳转目标,登录后回跳
  }

  // 2. 已登录但无权限访问(责任链第二环)
  if (to.meta.permission && !userPermissions.includes(to.meta.permission)) {
    return next('/403'); // 跳转到自定义无权限页,提升体验
  }

  // 3. 刷新后动态路由丢失修复(责任链第三环)
  if (store.getters.accessRoutes.length === 0 && token) {
    const accessibleRoutes = generateAccessibleRoutes(userPermissions);
    store.dispatch('setAccessRoutes', accessibleRoutes);
    accessibleRoutes.forEach(route => router.addRoute(route));
    // 重新触发路由跳转,避免空白页
    return next({ ...to, replace: true });
  }

  next();
});

(3)路由独享守卫增强

针对敏感页面(如财务对账、用户权限配置),添加二次校验:

const routes = [
  {
    path: '/financial-reconciliation',
    component: FinancialReconciliation,
    meta: { permission: 'financial:view' },
    beforeEnter: (to, from, next) => {
      // 额外校验:仅工作时间可访问
      const hour = new Date().getHours();
      if (hour < 9 || hour > 18) {
        ElMessage.warning('仅工作时间(9:00-18:00)可访问');
        return next(from.path);
      }
      next();
    }
  }
];

3. 设计模式

  • 责任链模式:路由守卫按 “登录校验→权限校验→刷新修复” 的顺序执行,每个守卫负责单一职责,可灵活增删调整。
  • 策略模式:根据用户角色(如 admin、editor)应用不同的路由过滤策略,例如管理员保留所有权限路由,运营仅保留业务相关路由。
  • 观察者模式:权限变更时(如管理员修改用户权限),触发路由重新加载事件,更新可访问路由列表。

4. 权限存储与安全性

(1)存储方案

  • 路由配置:存储在 Vuex/Pinia(内存),刷新后重新请求后端获取,避免 localStorage 存储敏感路由规则(如接口地址、权限标识)。
  • 用户权限标识:采用 “localStorage(持久化)+ Vuex/Pinia(内存访问)” 双存储,localStorage 确保刷新后不丢失,Vuex/Pinia 提升访问效率。
  • Token:存储在 localStorage 或 sessionStorage,建议设置过期时间(如 2 小时),降低泄露风险。

(2)安全性保障

  • 权限标识加密:对 localStorage 中的权限标识做简单加密(如 Base64),避免明文泄露(虽不能防破解,但能提升基础安全)。
  • 防 URL 绕过:即使前端隐藏路由,用户直接输入 URL 访问时,后端需对 “页面初始化接口” 做权限校验(如访问/system-config时,后端校验system:config权限)。
  • 敏感路由隐藏:无权限的路由不添加到路由实例,同时在菜单渲染时过滤,避免用户感知未授权功能。

二、页面元素权限:细粒度的 “功能可见性控制”

1. 核心应用场景

  • 操作权限差异化:同一页面中,管理员可看到 “删除用户”“批量导出” 按钮,普通用户仅能看到 “查看详情” 按钮。
  • 数据列权限:表格中,管理员可查看 “手机号”“身份证号” 列,运营仅能查看 “用户名”“订单号” 列。
  • 复杂权限组合:按钮显示需满足 “角色为 editor + 拥有 order:edit 权限 + 数据归属本部门” 等多条件组合。
  • 灰度功能发布:新功能上线时,仅对部分用户(如内部测试账号)显示入口,逐步全量开放。
  • 权限动态更新:管理员修改用户权限后,页面元素实时刷新(如立即隐藏 “删除” 按钮)。

2. 企业级实现方式

(1)权限中心封装(解决碎片化问题)

构建全局权限中心,统一管理权限校验逻辑,避免散落在各组件中:

// src/utils/permissionCenter.js
class PermissionCenter {
  constructor() {
    this.permissions = []; // 权限码列表(如["user:add", "order:edit"])
    this.roles = []; // 角色列表(如["admin", "editor"])
    this.customRules = new Map(); // 自定义权限规则(应对复杂场景)
    this.cache = new Map(); // 校验结果缓存,提升性能
  }

  // 初始化权限数据
  init(permissions, roles) {
    this.permissions = permissions;
    this.roles = roles;
    this.cache.clear(); // 初始化时清空缓存
  }

  // 基础权限校验(权限码匹配)
  hasPermission(code) {
    if (this.cache.has(code)) return this.cache.get(code);
    const result = this.permissions.includes(code);
    this.cache.set(code, result); // 缓存校验结果
    return result;
  }

  // 角色校验
  hasRole(role) {
    return this.roles.includes(role);
  }

  // 复杂规则校验(支持多条件组合)
  checkCustomRule(ruleName, ...args) {
    const rule = this.customRules.get(ruleName);
    if (!rule) return false;
    return rule(...args);
  }

  // 注册自定义规则
  registerCustomRule(ruleName, ruleFn) {
    this.customRules.set(ruleName, ruleFn);
  }

  // 权限更新(触发元素重新渲染)
  updatePermissions(permissions, roles) {
    this.init(permissions, roles);
    // 触发Vue响应式更新(需结合Vuex/Pinia)
    store.dispatch('updateUserPermissions', { permissions, roles });
  }
}

export default new PermissionCenter();

(2)指令 + 组件的双层控制

  • 全局指令(v-perm) :负责基础权限校验,快速隐藏无权限元素:
// 注册全局权限指令
app.directive('perm', {
  mounted(el, binding) {
    const { value, arg } = binding;
    let hasAccess = false;

    // 支持权限码校验(v-perm="user:delete")和自定义规则校验(v-perm:rule="xxx")
    if (arg === 'rule') {
      const { name, args } = value;
      hasAccess = permissionCenter.checkCustomRule(name, ...args);
    } else {
      hasAccess = permissionCenter.hasPermission(value);
    }

    // 无权限时移除元素(避免用户通过DOM操作显示)
    if (!hasAccess) {
      el.parentNode?.removeChild(el);
    }
  },
  // 权限更新时重新校验(如管理员修改权限后)
  updated(el, binding) {
    // 复用mounted逻辑,重新校验权限
    this.mounted(el, binding);
  }
});
  • 权限组件(PermissionWrap) :应对复杂场景(如多条件组合、权限变更刷新):
<!-- components/PermissionWrap.vue -->
<template>
  <slot v-if="hasAccess" />
</template>

<script setup>
import { computed } from 'vue';
import permissionCenter from '@/utils/permissionCenter';

const props = defineProps({
  // 权限码(单个或数组)
  perm: { type: [String, Array], default: '' },
  // 角色(单个或数组)
  role: { type: [String, Array], default: '' },
  // 自定义规则
  customRule: { type: Object, default: null }
});

// 权限校验逻辑
const hasAccess = computed(() => {
  // 权限码校验
  if (props.perm) {
    const perms = Array.isArray(props.perm) ? props.perm : [props.perm];
    if (!perms.every(perm => permissionCenter.hasPermission(perm))) {
      return false;
    }
  }

  // 角色校验
  if (props.role) {
    const roles = Array.isArray(props.role) ? props.role : [props.role];
    if (!roles.some(role => permissionCenter.hasRole(role))) {
      return false;
    }
  }

  // 自定义规则校验
  if (props.customRule) {
    const { name, args } = props.customRule;
    if (!permissionCenter.checkCustomRule(name, ...args)) {
      return false;
    }
  }

  return true;
});
</script>

(3)页面中使用示例

<!-- 基础权限按钮 -->
<button v-perm="user:delete">删除用户</button>

<!-- 角色+权限组合控制 -->
<PermissionWrap perm="order:export" role="admin">
  <button>批量导出订单</button>
</PermissionWrap>

<!-- 复杂自定义规则(仅能操作自己创建的订单) -->
<PermissionWrap :custom-rule="{ name: 'canOperateOrder', args: [order] }">
  <button @click="editOrder(order.id)">编辑订单</button>
</PermissionWrap>

<!-- 表格列权限 -->
<el-table-column label="手机号" v-if="permissionCenter.hasPermission('user:view:phone')">
  <template #default="scope">{{ scope.row.phone }}</template>
</el-table-column>

3. 设计模式

  • 装饰器模式:通过v-perm指令或PermissionWrap组件包装原有元素,添加权限控制逻辑,不修改元素本身代码,符合开闭原则。
  • 组合模式:将基础权限校验、角色校验、自定义规则校验组合为复杂权限逻辑,支持灵活组合与扩展。
  • 单例模式:权限中心(PermissionCenter)采用单例设计,确保全应用权限数据一致,避免重复初始化。

4. 权限存储与安全性

(1)存储方案

  • 权限码 / 角色列表:存储在 Vuex/Pinia(内存)+ localStorage(持久化),与路由权限共用存储,确保数据一致性。
  • 自定义规则:存储在权限中心实例中(内存),初始化时注册,无需持久化。
  • 校验结果缓存:存储在权限中心的cache属性(内存),页面刷新后清空,避免缓存过期。

(2)安全性保障

  • 防 DOM 篡改:仅用v-if隐藏元素不够,需用el.remove()彻底移除,避免用户通过浏览器控制台修改 DOM 显示无权限元素。
  • 二次校验:按钮点击事件中添加权限二次校验,防止用户通过控制台触发事件:
const handleDelete = (userId) => {
  if (!permissionCenter.hasPermission('user:delete')) {
    ElMessage.error('无删除权限');
    return;
  }
  // 执行删除逻辑
};
  • 权限更新同步:管理员修改用户权限后,前端调用permissionCenter.updatePermissions更新权限数据,触发元素重新渲染。

三、接口权限:系统安全的 “最后一道防线”

1. 核心应用场景

  • 敏感操作接口控制/api/user/delete(删除用户)、/api/order/update-status(修改订单状态)等高危接口,仅授权角色可调用。
  • 数据范围权限/api/order/list接口,管理员返回所有订单,普通用户仅返回自己创建的订单。
  • 接口频率限制:免费用户调用/api/search接口每分钟最多 10 次,付费用户无限制。
  • 接口版本权限:新版本接口(如/api/v2/order)仅对已升级版本的租户开放。
  • Token 失效 / 权限变更处理:Token 过期或权限被回收时,接口返回 403/401,前端需优雅处理(如登出、刷新 Token)。

2. 企业级实现方式

(1)请求 / 响应拦截器的全链路控制

  • 请求拦截器:添加 Token、权限预判、数据范围参数:
axios.interceptors.request.use(
  (config) => {
    // 1. 添加Token(鉴权基础)
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }

    // 2. 接口权限预判(减少无效请求)
    const { url, method } = config;
    // 生成接口权限标识(如"DELETE:/api/user/delete" → "user:delete")
    const apiPerm = generateApiPermissionKey(method, url);
    if (apiPerm && !permissionCenter.hasPermission(apiPerm)) {
      return Promise.reject(new Error(`无接口权限:${apiPerm}`));
    }

    // 3. 数据范围参数(配合后端过滤)
    if (url.includes('/api/order/list') && !permissionCenter.hasRole('admin')) {
      config.params = { ...config.params, creatorId: store.getters.userId };
    }

    // 4. 幂等性处理(敏感接口防重复调用)
    if (['POST', 'PUT', 'DELETE'].includes(method)) {
      config.headers['X-Request-Id'] = uuidv4();
    }

    return config;
  },
  (error) => Promise.reject(error)
);
  • 响应拦截器:处理权限异常、Token 失效:
axios.interceptors.response.use(
  (response) => {
    // 缓存频繁调用的非敏感接口(如用户信息)
    if (response.config.url === '/api/user/info' && response.status === 200) {
      store.dispatch('cacheUserInfo', response.data);
    }
    return response;
  },
  (error) => {
    const { status, data } = error.response || {};
    switch (status) {
      case 401:
        // Token失效,登出并跳转登录页
        ElMessage.error(data.msg || '登录已失效,请重新登录');
        store.dispatch('logout');
        break;
      case 403:
        // 区分接口权限不足和数据权限不足
        const msg = data.msg || '无接口访问权限';
        ElMessage.error(msg);
        // 敏感操作权限不足,可记录日志
        if (msg.includes('敏感操作')) {
          reportPermissionError({ url: error.config.url, msg });
        }
        break;
      case 429:
        ElMessage.error('接口调用过于频繁,请稍后再试');
        break;
    }
    return Promise.reject(error);
  }
);

(2)接口权限标识生成规则

统一接口与权限码的映射关系,避免混乱:

// 生成接口权限标识(method + 接口路径简化)
const generateApiPermissionKey = (method, url) => {
  // 示例:DELETE /api/user/123 → user:delete
  // 示例:GET /api/order/list → order:list
  const pathParts = url.replace(/^/api//, '').split('/');
  if (pathParts.length === 0) return '';
  const resource = pathParts[0]; // 资源名(user/order)
  let action = method.toLowerCase(); // 操作(get/post/delete)
  // 特殊映射:get列表 → list,get详情 → view
  if (method === 'GET' && pathParts.includes('list')) action = 'list';
  if (method === 'GET' && pathParts.length >= 2 && !isNaN(pathParts[1])) action = 'view';
  return `${resource}:${action}`;
};

3. 设计模式

  • 代理模式:请求拦截器作为接口调用的代理,统一添加鉴权信息、权限预判、数据范围参数,不修改业务请求代码。
  • 责任链模式:后端接口校验按 “Token 校验→权限码校验→数据范围校验→频率限制” 的顺序执行,前端响应拦截器按 “401 处理→403 处理→429 处理” 顺序处理异常。
  • 缓存模式:对非敏感接口结果进行缓存,减少重复请求,提升性能(需在权限变更时清空缓存)。

4. 权限存储与安全性

(1)存储方案

  • Token:存储在 localStorage(持久化)或 sessionStorage(会话级),建议设置HttpOnlySecure属性(需后端配合),防止 XSS 攻击。
  • 接口权限标识映射规则:前端预设(如user:delete对应/api/user/delete),无需持久化,确保与后端一致。
  • 接口缓存:存储在 Vuex/Pinia(内存),缓存 key 包含用户 ID 和权限标识,避免多用户数据混淆。

(2)安全性保障

  • 后端绝对校验:前端权限预判仅为体验优化,后端必须对每个接口做独立权限校验,防止用户通过 Postman 等工具绕过前端拦截。
  • Token 加密传输:所有接口采用 HTTPS 协议,Token 在传输过程中加密,避免中间人攻击。
  • 敏感接口二次验证:核心接口(如删除用户、转账)需添加二次验证(如输入密码、短信验证码),即使权限校验通过,也需确认操作意图。
  • 权限日志记录:前端调用敏感接口时,传递操作人、操作时间、IP 地址等信息,后端存储日志,便于安全追溯。

四、限设计的额外关键要点

1. 权限可视化配置

  • 管理员通过系统后台的 “权限配置页面”,勾选角色对应的权限(如 “用户管理”→“删除用户”),后端存储角色 - 权限映射关系。
  • 前端渲染 “权限树”,支持按模块折叠 / 展开,便于管理员操作;支持批量分配权限(如给 “运营” 角色分配所有订单相关权限)。

2. 权限动态更新与同步

  • 管理员修改用户权限后,前端通过 WebSocket 或轮询实时获取最新权限,调用permissionCenter.updatePermissions更新数据,无需用户刷新页面。
  • 跨标签页权限同步:通过localStorage监听事件,一个标签页修改权限后,其他标签页自动更新权限状态。

3. 跨端权限统一管理

  • 若项目包含 PC 端、移动端、小程序,设计统一的权限中心(后端),各端共用一套权限码和角色体系(如user:delete在所有端含义一致)。
  • 前端各端复用权限校验逻辑(如permissionCenter工具类),确保权限控制行为一致。

4. 性能优化

  • 权限校验缓存:对频繁校验的权限码(如表格列权限)进行缓存,避免重复计算。
  • 动态路由懒加载:动态添加的路由组件采用懒加载(() => import('../views/xxx.vue')),减少首屏加载时间。
  • 权限数据批量请求:登录后一次性获取用户角色、权限码、路由配置,避免多次请求后端。

5. 灰度发布与 A/B 测试

  • 权限中心支持 “灰度规则”,如 “用户 ID 在白名单内”“部门为测试部” 的用户可访问新功能路由和按钮。
  • 通过权限配置实现 A/B 测试,给不同用户组分配不同权限,验证功能效果后全量开放。

五、总结

1. 权限设计的三层逻辑

  • 前端路由权限:控制 “能否访问页面”,优化用户体验,减少无效请求。
  • 前端元素权限:控制 “能否看到功能”,隐藏无权限元素,避免用户困惑。
  • 后端接口权限:控制 “能否执行操作”,是安全核心,必须绝对可靠。

2. 安全性原则

  • 前端权限是 “体验层”,不能替代后端校验;后端权限是 “安全层”,必须覆盖所有敏感操作。
  • 敏感信息(Token、权限标识)需加密存储和传输,防止泄露。
  • 权限变更需实时同步,避免权限不一致导致的安全隐患。

3. 可扩展性原则

  • 采用设计模式(如责任链、装饰器、单例)降低代码耦合,便于后期扩展权限规则。
  • 预留自定义权限规则接口,应对复杂业务场景(如多条件组合权限)。
  • 权限配置可视化、动态化,减少前端发版频率,提升运营效率。

项目中,权限设计并非一成不变,需根据业务规模和安全要求逐步迭代:初期可采用 “角色 + 静态权限”,中期引入 “权限码 + 动态路由”,后期升级为 “可视化配置 + 细粒度数据权限”,最终实现 “安全、灵活、易维护” 的权限体系。

Vue 跨组件通信底层:provide/inject 原理与实战指南

一、provide/inject 的核心设计思想

provide/inject 是 Vue 实现依赖注入(Dependency Injection)的核心 API,其设计目标是:

  • 解决跨层级组件通信问题(props 逐级透传的痛点)
  • 实现组件间的松耦合(后代组件无需知道依赖的具体来源)
  • 维持响应式数据传递

其核心机制是:每个组件实例都维护一个 provides 对象,子组件的 provides 原型链指向父组件的 provides,形成一个原型链查找机制

二、底层实现原理的代码模拟

为了让你直观理解,我将用 JavaScript 模拟 Vue 组件实例的 provides 链和 provide/inject 方法的实现。

1. 组件实例的基础结构

// 模拟 Vue 组件实例的构造函数
class ComponentInstance {
  constructor(parent) {
    this.parent = parent; // 父组件实例引用
    this.props = {};
    this.data = {};
    
    // 核心:构建 provides 原型链
    // 如果有父组件,当前组件的 provides 继承自父组件的 provides
    // 如果没有父组件(根组件),创建一个空对象
    this.provides = parent ? Object.create(parent.provides) : Object.create(null);
  }

  // 实现 provide 方法
  provide(key, value) {
    // 将提供的键值对存储到当前组件的 provides 对象上
    this.provides[key] = value;
  }

  // 实现 inject 方法
  inject(key, defaultValue = undefined) {
    // 从当前组件的 provides 开始查找
    let provides = this.provides;
    
    // 沿着原型链向上查找(直到根组件)
    while (provides) {
      if (Object.prototype.hasOwnProperty.call(provides, key)) {
        // 找到则返回对应的值
        return provides[key];
      }
      // 找不到则继续向上查找父组件的 provides
      provides = Object.getPrototypeOf(provides);
    }
    
    // 如果最终没找到,返回默认值
    return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
  }
}

2. 原型链查找机制的验证

// 创建根组件实例
const root = new ComponentInstance(null);
// 根组件提供数据
root.provide('theme', 'dark');
root.provide('user', { name: 'admin' });

// 创建子组件实例(父组件为 root)
const child = new ComponentInstance(root);
// 子组件提供自己的数据
child.provide('lang', 'zh-CN');

// 创建孙组件实例(父组件为 child)
const grandChild = new ComponentInstance(child);

// 孙组件注入数据
console.log(grandChild.inject('theme')); // 'dark' (从根组件找到)
console.log(grandChild.inject('lang'));  // 'zh-CN'(从子组件找到)
console.log(grandChild.inject('user'));  // { name: 'admin' }(从根组件找到)
console.log(grandChild.inject('age', 18)); // 18(使用默认值)
console.log(grandChild.inject('gender', () => 'male')); // 'male'(函数默认值)

3. 响应式数据的传递原理

provide/inject 本身不处理响应式,它只是传递数据引用。响应式由 Vue 的响应式系统(ref/reactive)保证:

// 模拟 Vue 的 ref 实现
class Ref {
  constructor(value) {
    this._value = value;
  }
  
  get value() {
    console.log('触发依赖收集');
    return this._value;
  }
  
  set value(newValue) {
    this._value = newValue;
    console.log('触发更新');
  }
}

// 创建响应式数据
const themeRef = new Ref('light');

// 根组件提供响应式数据
root.provide('theme', themeRef);

// 孙组件获取
const injectedTheme = grandChild.inject('theme');
console.log(injectedTheme.value); // 'light'(触发依赖收集)

// 修改值会触发响应式更新
injectedTheme.value = 'dark'; // 触发更新

三、Vue 源码中的真实实现(简化版)

下面是从 Vue 3 源码中提取的核心逻辑,展示真实的实现方式:

1. 组件实例的 provides 初始化

// 源码位置:packages/runtime-core/src/component.ts
export function createComponentInstance(vnode, parent, suspense) {
  const instance = {
    vnode,
    parent,
    provides: parent ? Object.create(parent.provides) : Object.create(appContext.provides),
    // ...其他属性
  };
  return instance;
}

2. provide 函数的实现

// 源码位置:packages/runtime-core/src/apiInject.ts
export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
  if (!currentInstance) {
    if (__DEV__) {
      warn(`provide() can only be used inside setup().`);
    }
    return;
  }
  // 获取当前组件的 provides 对象
  const provides = currentInstance.provides;
  // 获取父组件的 provides 对象(原型)
  const parentProvides =
    currentInstance.parent && currentInstance.parent.provides;

  // 如果是首次提供该 key,或者 key 的值发生变化
  if (parentProvides === provides) {
    // 继承父组件的 provides 并创建新对象
    provides = currentInstance.provides = Object.create(parentProvides);
  }
  // 将值存入 provides
  provides[key as string] = value;
}

3. inject 函数的实现

// 源码位置:packages/runtime-core/src/apiInject.ts
export function inject<T>(
  key: InjectionKey<T> | string | number,
  defaultValue?: T | (() => T),
  treatDefaultAsFactory = false
): T | undefined {
  // 获取当前组件实例
  const instance = currentInstance || currentRenderingInstance;
  
  if (instance) {
    // 优先从组件自身的 provides 查找,否则从 appContext 查找
    const provides = instance.provides || instance.appContext.provides;
    
    if (provides && (key as string | symbol) in provides) {
      // 找到则返回值
      return provides[key as string];
    } 
    // 处理默认值
    else if (arguments.length > 1) {
      return treatDefaultAsFactory && typeof defaultValue === 'function'
        ? (defaultValue as () => T)()
        : defaultValue;
    } 
    // 开发环境警告
    else if (__DEV__) {
      warn(`injection "${String(key)}" not found.`);
    }
  }
}

四、实际项目中的高级应用(结合原理)

理解原理后,我们可以更灵活地使用 provide/inject

场景:创建可复用的组件上下文

// 创建上下文的组合函数
import { provide, inject, reactive, readonly } from 'vue';

// 使用 Symbol 作为唯一键(避免命名冲突)
const TABLE_CONTEXT_KEY = Symbol('table-context');

// 父组件提供上下文
export function useTableProvider(props) {
  const tableState = reactive({
    data: props.data,
    loading: false,
    pagination: {
      page: 1,
      pageSize: 10
    },
    // 方法
    fetchData: () => {
      tableState.loading = true;
      // 实际请求逻辑...
    },
    changePage: (page) => {
      tableState.pagination.page = page;
      tableState.fetchData();
    }
  });

  // 提供只读的上下文(防止子组件修改)
  provide(TABLE_CONTEXT_KEY, readonly(tableState));
  
  return tableState;
}

// 子组件注入上下文
export function useTableInject() {
  const context = inject(TABLE_CONTEXT_KEY, () => {
    throw new Error('useTableInject must be used within a Table component');
  });
  
  return context;
}

组件中使用

<!-- Table.vue(父组件) -->
<script setup>
import { defineProps } from 'vue';
import { useTableProvider } from './useTable';

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  }
});

const tableState = useTableProvider(props);
</script>

<!-- TablePagination.vue(子组件) -->
<script setup>
import { useTableInject } from './useTable';

const tableContext = useTableInject();

// 使用上下文数据
console.log(tableContext.pagination.page);
// 调用上下文方法
const handlePageChange = (page) => {
  tableContext.changePage(page);
};
</script>

总结

provide/inject 的底层原理核心要点:

  1. 原型链继承:每个组件实例的 provides 对象通过 Object.create() 继承自父组件的 provides,形成链式查找结构。
  2. 查找机制inject 时会从当前组件的 provides 开始,沿着原型链向上查找,直到找到匹配的键或到达根组件。
  3. 响应式传递provide/inject 仅传递数据引用,响应式由 Vue 的响应式系统(ref/reactive)保证。
  4. 作用域隔离:每个组件的 provides 是独立的,但通过原型链共享父组件的提供值,实现隔离与共享的平衡。
❌