普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月13日首页

Vue3 转 React:组件透传 Attributes 与 useAttrs 使用详解|VuReact 实战

作者 Ruihong
2026年4月13日 13:08

在 Vue3 迁移 React、跨框架组件封装的场景里,透传 Attributes 是几乎必用、但极易踩坑的能力。Vue 的 $attrs / useAttrs 和 React 的 props 体系设计差异很大,而 VuReact 作为稳定的 Vue3 → React 编译工具,已经把这套逻辑做了完整对齐。

本文带你一次性搞懂:透传属性是什么、为什么必须用 useAttrs、TS 怎么写、转换后长什么样,直接复制就能用。


一、先搞懂:透传 Attributes 到底是什么?

1. Vue 官方定义

透传 attribute:传给组件,但没有被声明为 props / emits 的属性或事件监听器。 最常见:classstyleid、自定义属性、v-on 监听等。

Vue 默认会把它们自动继承到组件根节点,也可以用 $attrsuseAttrs() 手动控制。

2. React 里的等价逻辑

React 没有“透传”这个名词,但行为一致: 所有没在 Props 里定义的属性,都属于“透传属性”,全部挂在 props 上。

区别是:

  • Vue:运行时自动处理
  • React + TS:必须显式写类型,否则报错

3. VuReact 的核心适配规则

VuReact 把透传属性统一理解为: 无类型约束的运行时对象 + 已声明 Props 合并 = 最终组件属性

  • 组件无 Props → 自动生成:props: Record<string, unknown>
  • 组件有 Props → 自动交叉类型:Props & Record<string, unknown>

二、关键:必须从 $attrs 转向 useAttrs()

Vue 里有两种写法:

  • $attrs:运行时隐式变量 → VuReact 无法静态分析
  • useAttrs():显式 API → VuReact 完美支持、推荐唯一写法

1. Vue 中标准 useAttrs 写法(必背)

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

好处:

  • 编译器可静态识别
  • 支持 TS 类型注解
  • 符合 React“显式优于隐式”的习惯

2. VuReact 转换规则(一张表看懂)

Vue useAttrs 写法 React 转换结果
无类型 const attrs = props as Record<string, unknown>
类型断言 as Attrs const attrs = props as Attrs
变量带类型 attrs: Attrs const attrs = props as Attrs
搭配 defineProps Props & Record<string, unknown>

三、实战示例:从 Vue 到 React 完整对照

示例 1:基础用法(无 TS)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.title }}
  </div>
</template>

<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>

React 输出

import { memo } from 'react'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props as Record<string, unknown>

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.title}
    </div>
  )
})

export default Comp

示例 2:TS 类型增强(企业级推荐)

Vue 输入

<template>
  <div :class="attrs.class" :style="attrs.style">
    {{ attrs.customTitle }}
  </div>
</template>

<script setup lang="ts">
import { useAttrs } from 'vue'

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

const props = defineProps<{
  id: string
}>()

const attrs = useAttrs() as CustomAttrs
</script>

React 输出

import { memo } from 'react'

interface CustomAttrs {
  class?: string
  style?: React.CSSProperties
  customTitle?: string
  [key: string]: unknown
}

type ICompProps = { id: string }

const Comp = memo((props: ICompProps & Record<string, unknown>) => {
  const attrs = props as CustomAttrs

  return (
    <div className={attrs.class} style={attrs.style}>
      {attrs.customTitle}
    </div>
  )
})

export default Comp

示例 3:动态属性 / 可选链(真实业务常用)

Vue 输入

<template>
  <div
    :class="[
      'base',
      attrs.class,
      attrs.xx?.class,
      attrs['custom-class']
    ]"
  >
    {{ attrs?.xxx?.content }}
  </div>
</template>

React 输出

import { memo } from 'react'
import { dir } from '@vureact/runtime-core'

const Comp = memo((props: Record<string, unknown>) => {
  const attrs = props

  return (
    <div
      className={dir.cls([
        'base',
        attrs.class,
        attrs.xx?.class,
        attrs['custom-class']
      ])}
    >
      {attrs?.xxx?.content}
    </div>
  )
})

四、避坑指南(VuReact 必看)

  1. 必须用 useAttrs(),禁止用 $attrs 编译器无法分析运行时变量,会丢属性。

  2. TS 尽量写接口 有利于提示、重构、避免空值报错。

  3. class/style 自动适配 classclassName style → 自动适配 React.CSSProperties

  4. defineProps + useAttrs 会自动合并类型 不用手动改。

  5. JS 项目直接用 会被编译成 const attrs = props,完全兼容。


五、总结

VuReact 处理透传 Attributes 的核心思想只有一句话: 把 Vue 隐式的 $attrs 变成显式的 useAttrs,再映射到 React 的 props 体系。

  • 你只管按 Vue 官方写法写
  • 编译器自动转成标准 React TSX
  • 类型安全、生产可用、迁移成本极低

正在做 Vue3 → React 迁移的同学,这套透传方案可以直接进团队规范。


🔗 相关资源


推荐阅读

#Vue3 #React #Vue转React #VuReact #前端迁移 #useAttrs #组件封装 #TypeScript

23种Promise高效用法,彻底搞定前端异步痛点,新手也能秒上手

2026年4月13日 11:47

前端人必看!你是不是也被异步操作逼疯过😭

回调地狱嵌套一层又一层,代码乱得像“蜘蛛网”;并发请求太多导致接口拥堵,页面卡顿崩溃;异步请求超时、无法取消,报错无从排查;不知道怎么顺序/并行执行异步任务,越写越乱……

其实这些前端异步痛点,Promise早就给出了完美解决方案!作为JS异步编程的核心,Promise不仅能替代繁琐的回调函数,还能优雅处理并发、超时、重试等复杂场景,是前端工程师必备的核心技能,也是面试高频考点。

今天这篇文章,结合实战场景,把23种Promise高效用法逐一拆解,每一种都配「核心作用+完整可复制代码+通俗解读+实战场景」,从基础到进阶,小白能看懂,老手能查漏补缺,收藏这一篇,再也不用东拼西凑找资料,异步开发效率直接翻倍!

核心提醒:Promise的核心是“异步操作的状态管理”,三种状态(pending/fulfilled/rejected)一旦改变就无法逆转,掌握它的核心用法,能解决80%的前端异步问题,剩下20%靠这23种实战技巧搞定!

一、基础核心用法(3种)—— 入门必备,筑牢基础

这3种是Promise最基础、最常用的用法,是后续高级用法的前提,新手务必先掌握,熟练运用就能告别基础异步烦恼。

1. 使用async/await简化Promise(最常用)

核心作用:用同步代码的写法,实现异步操作,彻底告别回调地狱,代码逻辑更清晰、更易维护,是日常开发中最推荐的异步写法。

通俗解读:async关键字标记函数为异步函数,await关键字“等待”异步操作完成,只有await后面的异步操作结束,才会执行下一行代码,避免嵌套。

// 基础用法:处理单个异步请求
async function asyncFunction() {
  try {
    // 等待异步操作完成(比如接口请求)
    const result = await fetch('/api/data');
    const data = await result.json();
    // 异步操作成功后,执行后续逻辑
    console.log('接口请求成功:', data);
    return data;
  } catch (error) {
    // 捕获异步操作中的所有错误
    console.error('接口请求失败:', error);
  }
}

// 实战用法:结合业务场景(获取用户信息并渲染)
async function getUserInfo() {
  try {
    const response = await fetch('/api/user/info');
    const user = await response.json();
    // 渲染用户信息到页面
    document.querySelector('.user-name').textContent = user.name;
    document.querySelector('.user-age').textContent = user.age;
  } catch (err) {
    // 错误处理(比如提示用户获取失败)
    alert('获取用户信息失败,请重试!');
  }
}
// 调用异步函数
getUserInfo();

2. 使用Promises替代回调函数

核心作用:将传统的回调函数(回调地狱的根源),转化为Promise链式调用,代码更优雅,错误处理更统一。

通俗解读:很多老项目或原生API仍使用回调函数(比如setTimeout、fs.readFile),通过封装,将其转化为Promise,就能享受链式调用的便捷。

// 核心封装:将回调函数转为Promise
const callbackToPromise = (fn, ...args) => {
  return new Promise((resolve, reject) => {
    // 执行原回调函数,在回调中触发Promise的resolve/reject
    fn(...args, (error, result) => {
      if (error) {
        // 回调报错,触发reject
        reject(error);
      } else {
        // 回调成功,触发resolve
        resolve(result);
      }
    });
  });
};

// 实战用法:封装setTimeout(回调转Promise)
const delay = callbackToPromise(setTimeout, 1000);
// 链式调用,替代回调嵌套
delay.then(() => {
  console.log('1秒后执行');
  return callbackToPromise(setTimeout, 2000);
}).then(() => {
  console.log('再等2秒执行');
}).catch(err => {
  console.error('出错了:', err);
});

3. 使用Promise实现一个延时函数

核心作用:异步延时操作,常用于防抖、节流、模拟接口请求延迟等场景,比原生setTimeout更灵活,可结合await使用。

// 基础封装:异步延时函数
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

// 实战用法1:结合await使用(同步写法实现延时)
async function delayDemo() {
  console.log('开始执行');
  await delay(1500); // 延时1.5秒
  console.log('1.5秒后执行');
  await delay(2000); // 再延时2秒
  console.log('总共延时3.5秒后执行');
}

// 实战用法2:模拟接口请求延迟
async function mockFetch() {
  console.log('正在请求接口...');
  // 模拟接口延迟1秒
  await delay(1000);
  return { code: 200, data: '模拟接口返回数据', msg: 'success' };
}

mockFetch().then(res => console.log(res)).catch(err => console.error(err));

二、并发控制用法(4种)—— 解决多异步拥堵,提升性能

日常开发中,经常需要同时执行多个异步请求(比如批量获取数据),但并发过多会导致接口拥堵、页面卡顿,这4种用法能优雅控制并发,兼顾效率和性能。

4. 映射并发Promises并处理结果数组(Promise.all)

核心作用:并行执行多个Promise,等待所有Promise都成功(fulfilled)后,返回所有结果的数组;只要有一个失败(rejected),就立即触发catch,适合“所有异步操作都必须成功”的场景。

// 基础用法:并发请求多个接口
const fetchUrls = urls => {
  // 映射所有url为fetch Promise
  const fetchPromises = urls.map(url => 
    fetch(url).then(response => response.json())
  );
  // 等待所有Promise执行完成
  return Promise.all(fetchPromises);
};

// 实战用法:批量获取用户列表、商品列表、分类列表
const urls = [
  '/api/users',
  '/api/goods',
  '/api/categories'
];

fetchUrls(urls).then(results => {
  const [users, goods, categories] = results;
  console.log('用户列表:', users);
  console.log('商品列表:', goods);
  console.log('分类列表:', categories);
  // 所有数据获取完成后,渲染页面
}).catch(error => {
  console.error('某个接口请求失败:', error);
});

5. 并发控制(限制并发数量)

核心作用:Promise.all会同时执行所有Promise,当并发数量过多(比如几十上百个请求),会给服务器造成压力,通过此方法可限制同时执行的Promise数量,避免拥堵。

// 核心封装:并发控制函数
const concurrentPromises = (promises, limit) => {
  return new Promise((resolve, reject) => {
    let i = 0; // 当前执行到的Promise索引
    let result = []; // 存储所有Promise的结果
    // 执行单个Promise的函数
    const executor = () => {
      // 所有Promise都执行完成,返回结果
      if (i >= promises.length) {
        return resolve(result);
      }
      // 取出当前要执行的Promise
      const promise = promises[i++];
      // 执行Promise并处理结果
      Promise.resolve(promise)
        .then(value => {
          result.push(value);
          // 继续执行下一个Promise(递归调用)
          if (i < promises.length) {
            executor();
          } else {
            // 最后一个Promise执行完成,返回结果
            resolve(result);
          }
        })
        .catch(reject); // 任意一个Promise失败,直接触发reject
    };

    // 初始化,同时执行limit个Promise
    for (let j = 0; j < limit && j < promises.length; j++) {
      executor();
    }
  });
};

// 实战用法:限制最多同时执行3个请求
const allPromises = [
  fetch('/api/data1').then(res => res.json()),
  fetch('/api/data2').then(res => res.json()),
  fetch('/api/data3').then(res => res.json()),
  fetch('/api/data4').then(res => res.json()),
  fetch('/api/data5').then(res => res.json())
];

// 限制并发数量为3
concurrentPromises(allPromises, 3).then(results => {
  console.log('所有请求完成,结果:', results);
}).catch(err => {
  console.error('请求失败:', err);
});

6. 使用Promise.allSettled处理多个异步操作

核心作用:与Promise.all相反,无论每个Promise是成功还是失败,都会等待所有Promise执行完成,返回每个Promise的状态和结果,适合“不需要所有异步都成功”的场景(比如批量统计接口状态)。

// 基础用法:统计多个接口的执行状态
const promises = [
  fetch('/api/endpoint1'),
  fetch('/api/endpoint2'),
  fetch('/api/endpoint3') // 假设这个接口会失败
];

Promise.allSettled(promises).then(results => {
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      // 成功的Promise,处理结果
      console.log(`接口${index + 1}请求成功:`, result.value);
    } else {
      // 失败的Promise,处理错误
      console.error(`接口${index + 1}请求失败:`, result.reason);
    }
  });
});

// 实战用法:批量上传文件,统计成功/失败数量
async function batchUpload(files) {
  const uploadPromises = files.map(file => 
    fetch('/api/upload', {
      method: 'POST',
      body: file
    }).then(res => res.json())
  );

  const results = await Promise.allSettled(uploadPromises);
  const successCount = results.filter(r => r.status === 'fulfilled').length;
  const failCount = results.filter(r => r.status === 'rejected').length;
  console.log(`上传完成:成功${successCount}个,失败${failCount}个`);
}

7. 处理多个Promises的最快响应(Promise.race)

核心作用:并行执行多个Promise,只关心“最快完成”的那个Promise的结果(无论成功还是失败),适合“取最快响应”的场景(比如多节点接口容错)。

// 基础用法:获取多个接口中最快的响应
const promises = [
  fetch('/api/node1').then(res => res.json()), // 节点1,响应时间约500ms
  fetch('/api/node2').then(res => res.json()), // 节点2,响应时间约300ms
  fetch('/api/node3').then(res => res.json())  // 节点3,响应时间约800ms
];

Promise.race(promises)
  .then(value => {
    console.log('最快响应的接口数据:', value); // 会输出节点2的数据
    // 使用最快的响应数据渲染页面,提升用户体验
  })
  .catch(reason => {
    console.error('最早失败的接口:', reason); // 若最快的接口失败,直接触发
  });

// 实战用法:多CDN资源加载,取最快的那个
const loadResource = (urls) => {
  const loadPromises = urls.map(url => 
    new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(url);
      img.src = url;
    })
  );
  return Promise.race(loadPromises);
};

// 加载3个CDN的图片,取最快加载完成的
const imgUrls = [
  'https://cdn1.example.com/img.jpg',
  'https://cdn2.example.com/img.jpg',
  'https://cdn3.example.com/img.jpg'
];

loadResource(imgUrls).then(fastUrl => {
  console.log('最快加载的图片CDN:', fastUrl);
  document.querySelector('.img').src = fastUrl;
});

三、异常与状态处理用法(5种)—— 规避异步报错,提升代码健壮性

异步操作中,报错、超时、状态未知是常见问题,这5种用法能优雅处理这些异常,避免页面崩溃,让代码更健壮、更易维护。

8. Promise超时处理

核心作用:给Promise设置超时时间,若超过指定时间仍未完成(未resolve/reject),则自动reject,避免异步操作“卡死”,提升用户体验。

// 核心封装:带超时的Promise
const promiseWithTimeout = (promise, ms) =>
  Promise.race([
    promise, // 原异步操作
    // 超时定时器,超过ms毫秒后reject
    new Promise((resolve, reject) =>
      setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
    )
  ]);

// 实战用法:给接口请求设置3秒超时
async function fetchWithTimeout(url, ms = 3000) {
  try {
    const response = await promiseWithTimeout(fetch(url), ms);
    return await response.json();
  } catch (error) {
    if (error.message.includes('Timeout')) {
      // 处理超时逻辑(提示用户、重试等)
      alert('接口请求超时,请检查网络后重试!');
    } else {
      console.error('接口请求失败:', error);
    }
  }
}

// 调用:3秒内未响应则超时
fetchWithTimeout('/api/data', 3000);

9. Promise的取消

核心作用:JavaScript原生Promise无法直接取消,但通过封装,可模拟取消逻辑(比如用户切换页面、取消请求),避免无效异步操作浪费资源。

// 核心封装:可取消的Promise
const cancellablePromise = promise => {
  let isCanceled = false; // 取消标记

  // 包装原Promise,添加取消逻辑
  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      value => {
        // 若已取消,触发reject(携带取消标记)
        isCanceled ? reject({ isCanceled, value }) : resolve(value);
      },
      error => {
        // 若已取消,触发reject(携带取消标记)
        isCanceled ? reject({ isCanceled, error }) : reject(error);
      }
    );
  });

  // 返回Promise和取消方法
  return {
    promise: wrappedPromise,
    cancel() {
      isCanceled = true; // 标记为已取消
    }
  };
};

// 实战用法:用户取消接口请求
const { promise, cancel } = cancellablePromise(
  fetch('/api/largeData').then(res => res.json())
);

// 监听取消按钮点击
document.querySelector('.cancel-btn').addEventListener('click', () => {
  cancel(); // 取消Promise
  console.log('请求已取消');
});

// 处理Promise结果
promise.then(data => {
  console.log('请求成功:', data);
}).catch(err => {
  if (err.isCanceled) {
    // 处理取消逻辑(不提示错误)
  } else {
    console.error('请求失败:', err);
  }
});

10. 检测Promise状态

核心作用:原生Promise不允许直接查询状态(pending/fulfilled/rejected),通过此方法可获取Promise的当前状态,用于调试或特殊业务逻辑。

// 核心封装:检测Promise状态
const reflectPromise = promise =>
  promise.then(
    value => ({ status: 'fulfilled', value }), // 成功:返回状态和结果
    error => ({ status: 'rejected', error })   // 失败:返回状态和错误
  );

// 实战用法:检测多个Promise的状态
const promise1 = fetch('/api/data1').then(res => res.json());
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 1000));

// 检测两个Promise的状态
Promise.all([reflectPromise(promise1), reflectPromise(promise2)]).then(results => {
  results.forEach((result, index) => {
    console.log(`Promise${index + 1}状态:`, result.status);
    if (result.status === 'fulfilled') {
      console.log('结果:', result.value);
    } else {
      console.log('错误:', result.error);
    }
  });
});

11. 确保Promise只解决一次

核心作用:避免因代码逻辑问题(比如重复调用resolve),导致Promise被多次解决(fulfilled),引发后续逻辑异常。

// 核心封装:确保只resolve一次的Promise
const onceResolvedPromise = executor => {
  let isResolved = false; // 标记是否已resolve
  return new Promise((resolve, reject) => {
    executor(
      value => {
        // 只有未resolve过,才执行resolve
        if (!isResolved) {
          isResolved = true;
          resolve(value);
        }
      },
      reject // reject可多次调用,不影响
    );
  });
};

// 实战用法:避免重复提交(比如表单提交)
const submitForm = (formData) => {
  return onceResolvedPromise((resolve, reject) => {
    // 模拟表单提交接口
    fetch('/api/submit', {
      method: 'POST',
      body: formData
    }).then(res => res.json())
      .then(data => {
        resolve(data);
        // 模拟重复调用resolve(无效)
        resolve('重复resolve');
      })
      .catch(reject);
  });
};

// 调用:即使内部重复resolve,也只执行一次
submitForm({ name: '张三' }).then(data => {
  console.log('提交成功:', data); // 只输出一次
}).catch(err => {
  console.error('提交失败:', err);
});

12. 同时执行多个异步任务并处理中途的失败

核心作用:类似Promise.allSettled,但更灵活,可自定义失败处理逻辑,适合“允许部分异步失败,同时处理成功和失败结果”的场景。

// 结合reflectPromise,处理部分失败的场景
const promises = [
  fetch('/api/data1').then(res => res.json()), // 成功
  fetch('/api/data2').then(res => res.json()), // 失败
  fetch('/api/data3').then(res => res.json())  // 成功
];

// 先通过reflectPromise获取所有状态,再分别处理
Promise.all(promises.map(reflectPromise)).then(results => {
  // 处理成功的结果
  const successResults = results.filter(r => r.status === 'fulfilled').map(r => r.value);
  console.log('成功的结果:', successResults);

  // 处理失败的结果(比如记录日志、提示用户)
  const failResults = results.filter(r => r.status === 'rejected').map(r => r.error);
  failResults.forEach((err, index) => {
    console.error(`任务${index + 1}失败:`, err);
    // 可选:失败重试
    // retryPromise(() => fetch(`/api/data${index + 2}`).then(res => res.json()), 3, 1000);
  });
});

四、流程控制用法(6种)—— 优雅管理异步流程,避免混乱

异步流程混乱是前端异步开发的常见问题,比如顺序执行、条件执行、动态执行等,这6种用法能帮你精准控制异步流程,让代码逻辑更清晰。

13. 顺序执行Promise数组

核心作用:按顺序执行一组Promise,前一个异步操作完成后,再执行下一个,适合“依赖前一个结果”的场景(比如分步提交、依赖接口)。

// 核心封装:顺序执行Promise数组
const sequencePromises = promises =>
  promises.reduce(
    (prev, next) => prev.then(() => next()), // 前一个完成,执行下一个
    Promise.resolve() // 初始值:一个已resolve的Promise
  );

// 实战用法:分步提交表单(先验证、再提交、最后提示)
const step1 = () => new Promise(resolve => {
  console.log('步骤1:验证表单');
  setTimeout(() => resolve(), 1000);
});

const step2 = () => new Promise(resolve => {
  console.log('步骤2:提交表单');
  setTimeout(() => resolve(), 1500);
});

const step3 = () => new Promise(resolve => {
  console.log('步骤3:提示提交成功');
  setTimeout(() => resolve(), 500);
});

// 顺序执行3个步骤
sequencePromises([step1, step2, step3]).then(() => {
  console.log('所有步骤执行完成');
});

14. 基于条件的Promise链

核心作用:根据条件判断,决定是否执行下一个Promise,适合“分支异步逻辑”(比如根据用户权限,决定是否获取敏感数据)。

// 核心封装:条件Promise链
const conditionalPromise = (conditionFn, promise) => 
  conditionFn() ? promise : Promise.resolve(); // 条件不满足,返回已resolve的Promise

// 实战用法:根据用户权限,决定是否获取敏感数据
// 模拟判断用户是否有权限
const hasPermission = () => {
  const user = JSON.parse(localStorage.getItem('user'));
  return user?.role === 'admin'; // 管理员有权限
};

// 敏感数据接口(只有管理员能获取)
const fetchSensitiveData = () => 
  fetch('/api/sensitive').then(res => res.json());

// 条件执行:有权限则获取,无权限则跳过
conditionalPromise(hasPermission, fetchSensitiveData())
  .then(data => {
    if (data) {
      console.log('敏感数据:', data);
      // 渲染敏感数据
    } else {
      console.log('无权限获取敏感数据');
    }
  })
  .catch(err => console.error('获取失败:', err));

15. Promise的重试逻辑

核心作用:当Promise因暂时性错误(比如网络波动、接口临时不可用)失败时,自动重试指定次数,提升接口成功率。

// 核心封装:带重试的Promise
const retryPromise = (promiseFn, maxAttempts, interval) => {
  return new Promise((resolve, reject) => {
    // 递归重试函数
    const attempt = attemptNumber => {
      // 达到最大重试次数,触发reject
      if (attemptNumber === maxAttempts) {
        reject(new Error('Max attempts reached'));
        return;
      }
      // 执行Promise,失败则重试
      promiseFn().then(resolve).catch(() => {
        // 间隔interval毫秒后,重试下一次
        setTimeout(() => {
          attempt(attemptNumber + 1);
        }, interval);
      });
    };
    // 开始第一次尝试
    attempt(0);
  });
};

// 实战用法:接口请求失败后,重试3次,每次间隔1秒
const fetchData = () => fetch('/api/data').then(res => res.json());

retryPromise(fetchData, 3, 1000)
  .then(data => console.log('请求成功:', data))
  .catch(err => {
    console.error('重试3次仍失败:', err);
    alert('请求失败,请稍后再试!');
  });

16. Promise-pipeline(管道化异步操作)

核心作用:将多个异步操作串联成“管道”,前一个操作的结果作为后一个操作的输入,适合“多步骤异步处理”(比如数据获取→处理→保存)。

// 核心封装:Promise管道函数
const promisePipe = (...fns) => value => 
  fns.reduce((p, f) => p.then(f), Promise.resolve(value));

// 实战用法:数据处理管道(获取数据→格式化→保存)
// 步骤1:获取数据
const fetchData = () => fetch('/api/rawData').then(res => res.json());
// 步骤2:格式化数据
const formatData = (data) => {
  return new Promise(resolve => {
    const formatted = data.map(item => ({
      id: item.id,
      name: item.name,
      time: new Date(item.time).toLocaleString()
    }));
    resolve(formatted);
  });
};
// 步骤3:保存数据
const saveData = (data) => fetch('/api/save', {
  method: 'POST',
  body: JSON.stringify(data),
  headers: { 'Content-Type': 'application/json' }
}).then(res => res.json());

// 管道化执行:fetchData → formatData → saveData
const dataPipeline = promisePipe(fetchData, formatData, saveData);
dataPipeline().then(res => {
  console.log('数据处理完成:', res);
}).catch(err => {
  console.error('数据处理失败:', err);
});

17. 动态生成Promise链

核心作用:根据不同条件,动态生成异步操作链,适合“不确定异步步骤数量”的场景(比如根据用户选择,执行不同的异步操作)。

// 实战用法:根据用户选择的操作,动态生成Promise链
// 模拟用户选择的操作(可动态变化)
const userActions = [
  () => fetch('/api/action1').then(res => res.json()),
  () => fetch('/api/action2').then(res => res.json()),
  () => fetch('/api/action3').then(res => res.json())
];

// 动态生成Promise链
const promiseChain = userActions.reduce((chain, currentTask) => {
  return chain.then(currentTask); // 依次添加异步任务到链中
}, Promise.resolve()); // 初始值

// 执行动态生成的Promise链
promiseChain.then(results => {
  console.log('所有动态操作完成:', results);
}).catch(err => {
  console.error('动态操作失败:', err);
});

18. 连续获取不确定数量的数据页

核心作用:获取分页数据时,不知道总页数,通过递归方式,自动获取所有分页数据,直到没有下一页,适合“无限滚动”“批量导出”场景。

// 核心封装:递归获取所有分页数据
async function fetchPages(apiEndpoint, page = 1, allResults = []) {
  try {
    // 发起分页请求
    const response = await fetch(`${apiEndpoint}?page=${page}`);
    const data = await response.json();
    // 合并当前页数据
    const newResults = allResults.concat(data.results);
    // 判断是否有下一页,有则继续递归,无则返回所有数据
    if (data.nextPage) {
      return fetchPages(apiEndpoint, page + 1, newResults);
    } else {
      return newResults;
    }
  } catch (error) {
    console.error('获取分页数据失败:', error);
    throw error; // 抛出错误,让调用者处理
  }
}

// 实战用法:获取所有用户分页数据(不知道总页数)
fetchPages('/api/users')
  .then(allUsers => {
    console.log('所有用户数据:', allUsers);
    // 渲染所有用户数据
  })
  .catch(err => {
    alert('获取用户数据失败,请重试!');
  });

五、高级进阶用法(5种)—— 提升编码上限,面试加分项

这5种用法属于进阶技巧,在复杂项目中经常用到,掌握它们,能让你在异步开发中更游刃有余,也是前端面试的高频加分项。

19. 使用Generators管理异步流程

核心作用:将async/await与Generators配合,创建可控制的异步流程管理器,适合“复杂异步流程控制”(比如暂停、继续、中断异步操作)。

// 基础用法:Generator配合Promise管理流程
function* asyncGenerator() {
  console.log('开始执行异步流程');
  //  yield后面跟Promise,暂停等待Promise完成
  const result1 = yield fetch('/api/data1').then(res => res.json());
  console.log('第一个异步操作完成:', result1);
  
  // 第二个异步操作,依赖第一个的结果
  const result2 = yield fetch(`/api/data2?userId=${result1.id}`).then(res => res.json());
  console.log('第二个异步操作完成:', result2);
  
  return result2; // 返回最终结果
}

// 执行Generator函数
function runGenerator(generator) {
  const iterator = generator();
  const handle = (result) => {
    if (result.done) return result.value;
    // 处理yield返回的Promise
    result.value.then(res => {
      handle(iterator.next(res));
    }).catch(err => {
      iterator.throw(err);
    });
  };
  handle(iterator.next());
}

// 调用:执行异步流程
runGenerator(asyncGenerator);

20. 流式处理大型数据集

核心作用:处理大型数据集时,避免一次性加载所有数据导致内存过载,通过“流式处理”(分块处理),提升性能和用户体验。

// 实战用法:流式处理大型Excel数据(分块读取、分块处理)
async function processLargeDataSet(dataSet) {
  // dataSet:大型数据集(比如10万条数据),按块分割
  for (const dataChunk of dataSet) {
    // 分块处理数据(每个块单独异步处理)
    const processedChunk = await processData(dataChunk); // 处理单个块
    // 分块保存数据,避免一次性保存导致卡顿
    await saveProcessedData(processedChunk);
    console.log('处理完成一个数据块');
  }
  console.log('所有大型数据处理完成');
}

// 模拟分块处理函数
function processData(chunk) {
  return new Promise(resolve => {
    // 模拟数据处理(比如格式化、过滤)
    const processed = chunk.map(item => item * 2);
    setTimeout(() => resolve(processed), 500);
  });
}

// 模拟分块保存函数
function saveProcessedData(chunk) {
  return fetch('/api/saveChunk', {
    method: 'POST',
    body: JSON.stringify(chunk),
    headers: { 'Content-Type': 'application/json' }
  }).then(res => res.json());
}

// 模拟大型数据集(10个块,每个块1万条数据)
const largeDataSet = Array(10).fill(0).map(() => Array(10000).fill(0).map((_, i) => i));
// 流式处理
processLargeDataSet(largeDataSet);

21. 使用Promise实现简易的异步锁

核心作用:在多线程/多异步场景中,确保同一时间只有一个异步操作执行(比如防止重复提交、并发修改数据),避免数据冲突。

// 核心封装:简易异步锁
let lock = Promise.resolve(); // 初始锁:已解锁状态

// 获取锁
const acquireLock = () => {
  let release;
  // 等待锁释放的Promise
  const waitLock = new Promise(resolve => {
    release = resolve; // 释放锁的方法
  });
  // 尝试获取锁:当前锁释放后,返回释放锁的方法
  const tryAcquireLock = lock.then(() => release);
  // 更新锁状态:当前锁变为等待状态
  lock = waitLock;
  return tryAcquireLock;
};

// 实战用法:防止重复提交表单
async function submitForm(formData) {
  // 获取锁,获取成功才能执行提交
  const release = await acquireLock();
  try {
    console.log('开始提交表单');
    // 模拟表单提交(异步操作)
    const res = await fetch('/api/submit', {
      method: 'POST',
      body: formData
    }).then(res => res.json());
    console.log('表单提交成功:', res);
    return res;
  } catch (err) {
    console.error('表单提交失败:', err);
  } finally {
    // 无论成功失败,都释放锁
    release();
    console.log('锁已释放');
  }
}

// 模拟多次点击提交按钮(只会执行一次提交)
submitForm({ name: '张三' });
submitForm({ name: '张三' });
submitForm({ name: '张三' });

22. 组合多个Promise操作为一个函数

核心作用:将多个相关的Promise操作合并为一个函数,实现代码复用,减少冗余,提升代码可维护性。

// 实战用法:组合“获取数据+处理数据”为一个函数
// 处理数据的辅助函数
const processData = (data) => {
  return new Promise(resolve => {
    // 模拟数据处理(过滤、格式化)
    const processed = data.filter(item => item.status === 1).map(item => ({
      id: item.id,
      name: item.name,
      time: item.createTime
    }));
    resolve(processed);
  });
};

// 组合多个Promise操作
const fetchDataAndProcess = async url => {
  // 步骤1:获取数据
  const response = await fetch(url);
  const rawData = await response.json();
  // 步骤2:处理数据
  const processedData = await processData(rawData);
  // 返回处理后的结果
  return processedData;
};

// 复用函数:获取不同接口的数据并处理
fetchDataAndProcess('/api/users')
  .then(users => console.log('处理后的用户数据:', users))
  .catch(err => console.error('处理失败:', err));

fetchDataAndProcess('/api/goods')
  .then(goods => console.log('处理后的商品数据:', goods))
  .catch(err => console.error('处理失败:', err));

23. 处理可选的异步操作

核心作用:处理“可选的异步操作”,当条件满足时执行异步操作,不满足时返回默认值,避免多余的错误处理。

// 核心封装:处理可选异步操作
async function optionallyAsyncTask(condition, asyncOperation, fallbackValue) {
  if (condition) {
    // 条件满足,执行异步操作
    return await asyncOperation;
  } else {
    // 条件不满足,返回默认值
    return fallbackValue;
  }
}

// 实战用法:根据用户是否登录,决定是否获取用户个性化数据
const isLogin = () => !!localStorage.getItem('token'); // 判断用户是否登录
const fetchPersonalData = () => fetch('/api/personal').then(res => res.json()); // 个性化数据接口

// 调用:登录则获取个性化数据,未登录则返回默认数据
optionallyAsyncTask(isLogin(), fetchPersonalData(), {
  name: '游客',
  recommend: []
}).then(data => {
  console.log('用户数据:', data);
  // 渲染用户数据(无论是否登录,都有默认值,避免报错)
  document.querySelector('.user-info').textContent = JSON.stringify(data);
});

六、Promise高频避坑总结(新手必记,少踩雷)

很多新手学完Promise,一写代码就报错,不是语法错了,就是用法不对,整理了5个高频坑,记牢就能避开!

  • 坑1:忘记处理Promise的reject——Promise失败时,若未用catch捕获,会报未捕获错误,导致页面崩溃,务必给每个Promise添加catch处理。
  • 坑2:混淆Promise.all和Promise.allSettled——前者只要一个失败就整体失败,后者无论成败都会等待所有执行完成,按需选择。
  • 坑3:认为Promise可以直接取消——原生Promise无法取消,需通过封装(添加取消标记)模拟取消逻辑。
  • 坑4:await后面跟非Promise值——await只能等待Promise或async函数,若跟普通值,会直接返回该值,相当于同步执行,无意义。
  • 坑5:重复调用resolve/reject——Promise状态一旦改变就无法逆转,重复调用resolve/reject无效,还可能导致逻辑混乱,可用onceResolvedPromise避免。

七、面试高频考点(新手必记,轻松拿捏面试官)

Promise是前端面试高频考点,不用死记硬背,记住这5个核心问题,面试时直接套用即可:

  • Q1:Promise有哪三种状态?状态能否逆转? A:pending(等待中)、fulfilled(成功)、rejected(失败);状态一旦改变(pending→fulfilled或pending→rejected),就无法逆转。
  • Q2:Promise.all和Promise.race的区别? A:Promise.all等待所有Promise成功,一个失败则整体失败;Promise.race只取最快完成的Promise结果,无论成功还是失败。
  • Q3:async/await和Promise的关系? A:asyncawait是Promise的语法糖,async函数本质上返回一个Promise,await只能在async函数中使用,用于等待Promise完成。
  • Q4:如何实现Promise的取消? A:原生Promise无法直接取消,可通过添加取消标记(isCanceled),在Promise.then/catch中判断标记,实现模拟取消。
  • Q5:Promise的错误捕获有哪些方式? A:两种方式:一是给每个Promise添加.catch();二是在async/await中用try/catch捕获所有异步错误。

八、最后说几句掏心窝的话

Promise不难,核心就是“异步状态管理”,23种用法看似多,但不用死记硬背——日常开发中,高频使用的也就10种左右(async/await、Promise.all、超时处理、顺序执行等),剩下的可作为储备,用到时翻这篇文章即可。

它不是前端进阶的“加分项”,而是“必备项”——现在前端开发几乎所有异步操作都离不开Promise,不学真的会被淘汰。这篇文章整理了所有用法的实战代码、通俗解读和避坑细节,代码可直接复制练习,建议收藏起来,开发时遇到问题就翻一翻,慢慢就熟练了。

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从新手进阶成资深开发者💪

卷AI、卷算法、2026 年的前端工程师到底在卷什么?

作者 ErpanOmer
2026年4月13日 11:18

1_jXusXvCfxECPU_Jh9S_E3w.jpg

最近是 2026 年的春招季,前几周密集面了大概快二十个前端。

翻开这批简历,我有一种极其魔幻的感觉:满屏都是 AI,满屏都是算法。

四五年前,大家简历上的高频词还是 精通 Vue3 响应式原理、熟练掌握 Webpack 性能调优

现在呢?十个候选人里,有九个写着熟练掌握 LLM 接入、深入理解 RAG(检索增强生成)、精通 Prompt 工程、参与过大模型 Agent 平台建设,剩下那个没写 AI 的,简历里赫然写着LeetCode 刷题 150+,精通动态规划与图论。

前端这个圈子,仿佛在一夜之间得了严重的技术焦虑并发症。大家都在拼命往简历里塞最高大上的词,生怕在 2026 年这个节点,因为不懂 AI 而被直接淘汰。

但现实是什么?

上周我淘汰了一个简历写得极其华丽、号称 主导过公司核心 AI 助手前端架构 的候选人。

我没问他大模型底层原理,也没让他手撕红黑树,我只问了他一个极其真实的业务场景: 在一个 AI 流式输出(Streaming)的对话场景里,如果大模型返回的是一个极其复杂的、带有代码块和多步工具调用(Tool Call)的 JSON 块。在流式传输还没结束、JSON 还是残缺状态的时候,你的前端是怎么保证 UI 不崩溃,并且能平滑渲染中间状态的?

他愣了半分钟,支支吾吾地说:我们用的是 Vercel AI SDK,它内部封装好了,直接拿 useChat 里的 messages 渲染就行……

😖😖😖...

我叹了口气,在面试评价上默默写下:只会调用 API,缺乏处理复杂工程能力。

这就是 2026 年前端圈最大的悲哀:大家都在卷 AI,但 90% 的人卷的只是如何发送一个带 API Key 的 HTTP 请求。


别把调用 API 包装成核心竞争力 🤷‍♂️

现在很多前端对懂 AI的理解极其肤浅。

以为在项目里接个 OpenAI 或者 Claude 的接口,搞个对话框,把输入框的字传过去,把返回的字用 Markdown 渲染出来,就叫AI 前端工程师了😖。

兄弟,那不叫 AI 开发,那叫表单提交。这种活儿,三年前刚培训班毕业的实习生也会干。

大模型时代,前端真正的难点根本不是发送请求,而是 应对大模型带来的复杂性。

以前我们写业务代码,接口返回的数据结构是确定的,是后端的 Swagger 定义好的。你只需要 if (res.code === 200) 然后按部就班地渲染。

但在 2026 年,大模型吐出来的东西是不可控的。 真实的高阶 AI 前端工程,每天要面对的是这些破事:

流式返回进行到一半,JSON 连个闭合的括号都没有,你的界面怎么解析?怎么渲染正在打字的生成式 UI?

一个 Agent 在后台疯狂调用工具(查天气、查数据库、画图),这个过程中产生的大量异步中间状态,如何在 React/Vue 中做防抖、状态合并和打断(Abort)?

大模型突然抽风,返回了完全不符合预期的组件协议,你的前端系统能不能做沙盒隔离,保证不引发整个页面的白屏崩溃?

这些问题,根本不是你背几个 Prompt 模板就能解决的。它考验的是你对数据流处理、AST(抽象语法树)解析、复杂状态机设计以及防御性编程的底层功底。

你卷了半天 Vercel AI SDK 的用法,一旦业务场景超出了 SDK 的默认配置,你立马就抓瞎了。


为什么面试官越来越爱考算法?

说完了 AI,再聊聊算法。这也是现在前端同行疯狂吐槽的点:我特么一个画页面的,凭什么让我手写动态规划?🤔

其实这是个很残酷的信号。

作为面试官,我跟你交个底:因为那些常规的、套路化的前端业务代码,现在 AI 真的能写了,而且写得比你快。

2026 年了,如果你只会写个增删改查的表格,只会封装个按钮组件,我在面试里连问你的兴趣都没有。既然基础的搬砖工作被 AI 大幅压缩了,那公司招人,过滤标准自然就要往上提。

考算法,本质上考的不是你对某道题的背诵能力,而是考你的复杂逻辑拆解能力和极限思维

特别是在做 AI 工具链的前端时:

  • 当你要在浏览器端用 WebAssembly 跑一个轻量级的向量数据库(Vector DB)进行本地 RAG 检索时,不懂数据结构你连原理都看不懂。
  • 当你要处理大模型返回的超大文档树,做精确的 DOM 节点比对和替换时,树的遍历算法就是你的基本功。

大家不是在卷算法,而是在抢夺那些AI 无法轻易替代的深水区岗位🤔。


没必要那么焦虑

前天面试结束,跟几个同组的技术老炮抽烟。大家感慨,其实这十年来,前端圈的焦虑从来没停过。

当年 jQueryReact 淘汰时,大家在卷;后来小程序大爆发时,大家也在卷;现在大模型来了,大家不过是换了个名词继续卷。

别被那种 AI 要干掉前端的鬼话吓倒了,也别为了迎合面试官去死记硬背什么 RAG 架构图。

潮水退去的时候,企业最终留下的,永远不是那个会背时髦名词的人,而是那个懂 HTTP 协议、懂浏览器底层、能在复杂的异步环境里把一个烂摊子稳稳托住的前端。

在这个越发喧嚣的 2026 年,少去追逐那些虚幻的词汇,多去打磨你手里的基本功吧🤷‍♂️

共勉🙌

加油加油加油.gif

ES6模块化保姆级教程,彻底告别全局污染,新手也能秒上手

2026年4月13日 10:40

前端人必看!你是不是也遇到过这些坑😭

写代码时,变量、函数越写越多,不小心就全局污染,导致代码冲突报错;引入多个JS文件,顺序乱了就崩;想复用一段代码,只能复制粘贴,后期维护堪比“拆炸弹”……

其实这些问题,ES6模块化早就帮我们解决了!它是浏览器端和服务器端通用的模块化规范,不用再额外学习AMD、CMD、CommonJS等复杂规范,新手入门零压力,学会它,代码整洁度、复用性直接翻倍,面试也能轻松加分!

今天这篇文章,结合实战代码,把ES6模块化拆解得明明白白,从核心概念到3种用法,从基础语法到避坑细节,小白能看懂,老手能查漏补缺,收藏这一篇,再也不用东拼西凑找资料!

核心提醒:ES6模块化的核心是“拆分代码、按需导入、按需导出”,每个JS文件都是独立模块,互不干扰,彻底解决全局污染和代码复用难题,是前端工程化的基础!

一、先搞懂:ES6模块化到底是什么?(新手必看)

在ES6出现之前,前端没有统一的模块化规范,开发者只能用AMD、CMD、CommonJS等规范,不同规范用法不同,学习成本高,还不通用——浏览器端用AMD,服务器端用CommonJS,切换起来很麻烦。

而ES6模块化的出现,直接统一了浏览器和服务器端的模块化标准,它的核心定义很简单,记住3点就够了:

  • 每个JS文件都是一个独立的模块,模块内部的变量、函数、类,默认都是私有的,不会污染全局作用域;
  • 想要使用其他模块的内容,用 import 关键字导入;
  • 想要把自己模块的内容共享给其他模块,用 export 关键字导出。

举个通俗的例子:ES6模块化就像一个个独立的“文件盒子”,每个盒子里装着自己的代码(变量、函数),盒子之间可以互相“借东西”(导入导出),但不会打乱彼此的内容,也不会影响外面的环境。

二、ES6模块化3种核心用法(实战为王,代码可直接复制)

ES6模块化主要有3种用法,覆盖所有开发场景,其中“默认导出/导入”和“按需导出/导入”最常用,一定要重点掌握,第三种“直接导入执行”按需了解即可。

1. 用法一:默认导出(export default)与默认导入(import)

核心作用:一个模块只能有一次默认导出,用于导出模块的“主要内容”(比如一个核心函数、一个对象),导入时可以自由命名,非常灵活。

✅ 默认导出语法(导出文件:比如 namedModule.js)

// 方式1:导出单个变量/函数/对象(推荐)
const user = {
  name: "前端小白",
  age: 22,
  skill: "ES6"
};
export default user; // 默认导出,每个模块只能写一次

// 方式2:直接导出(无需提前定义)
// export default {
//   name: "前端小白",
//   age: 22
// };

// 方式3:导出函数
// export default function sayHello() {
//   console.log("Hello ES6模块化!");
// }

✅ 默认导入语法(导入文件:比如 index.js)

// 语法:import 接收名称 from '模块标识符(文件路径)'
import userInfo from './namedModule.js'; // 接收名称可任意命名,合法即可

console.log(userInfo); 
// 输出:{name: "前端小白", age: 22, skill: "ES6"}

⚠️ 关键细节

每个模块中,只允许使用唯一的一次 export default,如果写多次,会直接报错;默认导入时,接收名称可以任意命名(比如把userInfo改成myUser),不影响使用。

2. 用法二:按需导出(export)与按需导入(import {})

核心作用:一个模块可以多次按需导出,用于导出模块的“多个零散内容”(比如多个变量、多个函数),导入时必须和导出的名称保持一致,也可以重命名,灵活性更高,是日常开发中最常用的方式。

✅ 按需导出语法(导出文件:比如 demandModule.js)

// 按需导出单个变量/函数(可多次导出)
export let s1 = 'aaa';
export let s2 = 'ccc';
export function say() {
  console.log("我是按需导出的函数");
}

// 也可以先定义,再批量按需导出(推荐,代码更整洁)
// let s1 = 'aaa';
// let s2 = 'ccc';
// function say() {
//   console.log("我是按需导出的函数");
// }
// export { s1, s2, say };

✅ 按需导入语法(导入文件:比如 index.js)

// 语法:import { 导出名称1, 导出名称2 } from '模块标识符'
// 基础用法:导入指定内容
import { s1, s2, say } from './demandModule.js';
console.log(s1); // 输出:aaa
console.log(s2); // 输出:ccc
say(); // 输出:我是按需导出的函数

// 进阶用法1:重命名(用as关键字,解决名称冲突)
import { s1, s2 as str2, say } from './demandModule.js';
console.log(str2); // 输出:ccc(s2重命名为str2)

// 进阶用法2:按需导入 + 默认导入(结合使用,最常用)
// 假设demandModule.js同时有默认导出和按需导出
import info, { s1, s2 as str2, say } from './demandModule.js';
console.log(info); // 输出:默认导出的内容(比如{ a: 20 })
console.log(s1); // 输出:aaa
console.log(str2); // 输出:ccc

⚠️ 关键细节

  • 每个模块中,可以使用多次按需导出,没有数量限制;
  • 按需导入的成员名称,必须和按需导出的名称完全一致,否则会报错;
  • 如果导入的名称和当前模块的变量冲突,可以用 as 关键字重命名;
  • 按需导入可以和默认导入一起使用,满足复杂场景需求。

3. 用法三:直接导入并执行模块中的代码

核心作用:不需要导入模块中的任何内容,只需要执行模块中的代码(比如模块中是一段初始化代码、打印日志、创建DOM等),语法非常简单。

✅ 语法示例

// 导出文件:initModule.js
console.log("模块代码执行了!");
// 比如一段初始化代码
function init() {
  console.log("初始化完成,页面可以正常使用~");
}
init(); // 模块内部直接执行

// 导入文件:index.js(直接导入,不接收任何内容)
import './initModule.js';
// 执行后会输出:模块代码执行了!  初始化完成,页面可以正常使用~

这种用法场景较少,常见于初始化配置、全局注册组件等场景,不需要复用模块内容,只需要执行模块中的代码即可。

三、ES6模块化必避坑(新手常犯错误,看完少踩雷)

很多新手学完模块化,一写代码就报错,不是语法错了,就是用法不对,整理了4个高频坑,记牢就能避开!

坑1:忘记给script标签加type="module"

在浏览器中直接运行模块化代码时,如果script标签没有加type="module",浏览器会把JS文件当作普通脚本解析,遇到import/export会直接报错(SyntaxError: Unexpected token 'export')。

<!-- ❌ 错误写法:会报错 -->
<!-- ✅ 正确写法:必须加type="module" -->

加了type="module"后,浏览器会按模块化规范解析文件,同时开启严格模式、私有作用域,支持import/export语法。

坑2:一个模块写多个export default

默认导出(export default)每个模块只能有一次,写多次会直接报错,若需要导出多个内容,优先用按需导出,或把多个内容包装成一个对象,再默认导出。

// ❌ 错误写法:多个export default
export default { a: 10 };
export default { b: 20 }; // 报错!

// ✅ 正确写法1:用按需导出
export { a: 10, b: 20 };

// ✅ 正确写法2:包装成一个对象默认导出
export default {
  a: 10,
  b: 20
};

坑3:按需导入时名称和导出名称不一致

按需导入的核心规则:导入名称必须和导出名称完全一致,除非用as关键字重命名,否则会提示“未定义”错误。

// 导出文件
export let s1 = 'aaa';

// ❌ 错误写法:导入名称不一致
import { s2 } from './demandModule.js'; // 报错:s2 is not defined

// ✅ 正确写法1:名称一致
import { s1 } from './demandModule.js';

// ✅ 正确写法2:用as重命名
import { s1 as str1 } from './demandModule.js';

坑4:导入变量后直接修改

ES6模块化的导入变量是只读的引用,不是值的拷贝,不能直接修改导入的变量,否则会报错;如果需要修改,可在导出模块中定义修改方法,再导入使用。

// 导出文件:counter.js
export let count = 0;
export function increment() {
  count++; // 导出模块内部修改变量
}

// 导入文件:index.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1(同步更新)

// ❌ 错误写法:直接修改导入的变量
// count = 5; // 报错:Assignment to constant variable.

四、ES6模块化实战场景(贴合真实开发,直接复用)

学会了语法,还要知道在实际开发中怎么用,以下3个高频场景,覆盖大部分前端开发需求,代码可直接复制使用。

场景1:封装工具函数(最常用)

把常用的工具函数(比如格式化时间、防抖节流)封装成一个模块,按需导入使用,避免重复写代码,方便维护。

// 工具模块:utils.js
// 按需导出多个工具函数
export function formatTime(time) {
  // 格式化时间:YYYY-MM-DD
  return new Date(time).toLocaleDateString().replace(///g, '-');
}

export function debounce(fn, delay) {
  // 防抖函数
  let timer = null;
  return function() {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, delay);
  };
}

// 导入使用:index.js
import { formatTime, debounce } from './utils.js';

// 使用格式化时间函数
console.log(formatTime(new Date())); // 输出:2026-04-13

// 使用防抖函数
const handleClick = debounce(() => {
  console.log("防抖触发");
}, 500);

场景2:拆分组件(前端工程化基础)

在Vue、React等框架中,模块化拆分组件是基础操作,每个组件是一个独立模块,导出组件,再在其他组件中导入使用。

// 组件模块:Button.js
// 模拟Vue组件导出
export default function Button(props) {
  return ``;
}

// 导入使用:App.js
import Button from './Button.js';

// 渲染按钮组件
document.body.innerHTML = Button({
  color: 'red',
  text: '点击我'
});

场景3:统一入口文件(优化导入路径)

当项目模块较多时,可创建一个入口文件(index.js),统一导出所有模块,其他文件只需导入入口文件,简化导入路径。

// 入口文件:index.js
// 统一导出其他模块
export { default as Button } from './components/Button.js';
export { formatTime, debounce } from './utils.js';
export { userInfo } from './user.js';

// 导入使用:app.js
// 只需导入入口文件,即可获取所有模块
import { Button, formatTime, userInfo } from './index.js';

五、面试高频考点(新手必记,轻松拿捏面试官)

ES6模块化是前端面试高频考点,不用死记硬背,记住这4个核心问题,面试时直接套用即可:

  • Q1:ES6模块化和CommonJS的区别? A:ES6模块化是浏览器和服务器端通用,用import/export;CommonJS主要用于服务器端(Node.js),用require/module.exports;ES6模块化是静态导入(编译时解析),CommonJS是动态导入(运行时解析)。
  • Q2:export default和export的区别? A:export default每个模块只能有一次,导出单个内容,导入时可任意命名;export可多次使用,导出多个内容,导入时名称必须一致(可重命名)。
  • Q3:为什么import/export在浏览器中会报错? A:因为script标签没有加type="module",浏览器会把JS文件当作普通脚本解析,不支持模块化语法。
  • Q4:ES6模块化的导入变量为什么不能直接修改? A:导入的变量是只读的引用(Live Bindings),不是值的拷贝,直接修改会违反模块化的封装原则,需在导出模块中定义修改方法。

六、最后说几句掏心窝的话

ES6模块化不难,核心就是“导入(import)”和“导出(export)”,记住3种用法和4个避坑点,就能轻松上手。它不是前端进阶的“加分项”,而是“必备项”——现在前端开发几乎全员使用模块化,不学真的会被淘汰。

这篇文章整理了模块化的核心语法、实战案例、避坑细节和面试考点,代码可直接复制练习,建议收藏起来,开发时遇到问题就翻一翻,慢慢就熟练了。

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从新手进阶成资深开发者💪

LeetCode 149. 直线上最多的点数:题解深度剖析

作者 Wect
2026年4月13日 10:36

LeetCode 中等难度题目「149. 直线上最多的点数」,这道题核心考察对“直线斜率”的理解和哈希表的运用,看似简单但细节超多,一不小心就会踩坑。下面结合完整代码,一步步讲透解题逻辑,新手也能轻松看懂。

题目回顾

题目很直白:给你一个数组 points ,其中 points[i] = [xi, yi] 表示 X-Y 平面上的一个点。求最多有多少个点在同一条直线上。

举个例子:如果 points = [[1,1],[2,2],[3,3]],那么这三个点在同一条直线上,答案就是 3;如果 points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]],答案则是 4(有4个点共线)。

核心难点:如何表示“同一条直线”?如何避免重复计数?如何处理斜率的精度问题?

解题核心思路

直线的核心特征是「斜率」—— 同一平面内,两点确定一条直线,而斜率相同(且经过同一点)的点,必然在同一条直线上。

基于这个原理,我们可以用「固定一点,遍历其他点」的思路,具体步骤如下:

  1. 边界处理:如果点的数量 ≤ 2,直接返回点的数量(因为两点必然共线);

  2. 遍历每个点 points[i],将其作为「基准点」;

  3. 计算基准点与其他所有点 points[j](j > i)的斜率,用哈希表记录「斜率对应的点的数量」;

  4. 统计当前基准点对应的最大共线点数,更新全局最大值;

  5. 优化剪枝:如果当前全局最大值已经 ≥ 剩余未遍历的点的数量,或者超过总点数的一半,直接终止循环(无需继续计算,因为不可能出现更大值)。

关键细节:斜率的表示(避坑重点)

这道题最容易踩坑的地方,就是「斜率的表示」。直接用 dy/dx (即两点纵坐标差除以横坐标差)会有两个问题:

  • 精度问题:浮点数计算会有误差(比如 1/3 和 2/6 本是同一个斜率,但浮点数表示可能不同);

  • 特殊情况:垂直直线(dx=0,斜率不存在)、水平直线(dy=0,斜率为0),无法用常规除法表示。

解决方案:用「最简整数比」表示斜率,将 dy 和 dx 化简为互质的整数,再用一个唯一的key表示这个比值。

具体做法(对应代码中的gcd函数和key计算):

  1. 计算两点的横坐标差 dx = x_i - x_j,纵坐标差 dy = y_i - y_j;

  2. 特殊处理:

    • dx=0(垂直直线):令 dy=1(统一表示所有垂直直线的斜率);

    • dy=0(水平直线):令 dx=1(统一表示所有水平直线的斜率);

  3. 符号统一:如果 dy 为负,将 dx 和 dy 同时取反(保证斜率的符号一致,比如 2/-3 和 -2/3 是同一个斜率,统一为 2/3);

  4. 化简:用最大公约数(gcd)将 dx 和 dy 化简为互质的整数(比如 dx=4,dy=2,化简为 dx=2,dy=1);

  5. 生成key:将二维的 (dy, dx) 转化为一维key,避免哈希表的key冲突。代码中用「dy + dx * 20001」,因为题目中坐标的范围是 [-10^4, 10^4],dx的最大绝对值是 20000,乘以20001后,再加上dy(范围 [-20000, 20000]),可以保证每个 (dy, dx) 对应唯一的key。

完整代码+逐行解析

先贴完整代码(TypeScript版本),再逐行拆解核心逻辑:

function maxPoints(points: number[][]): number {
  const n = points.length;
  if (n <= 2) return n; // 边界处理:2个及以下点必共线
  let res = 0;

  // 最大公约数函数:用于化简dx和dy
  const gcd = (a: number, b: number): number => {
    return b != 0 ? gcd(b, a % b) : a;
  }

  // 遍历每个点作为基准点i
  for (let i = 0; i < n; i++) {
    // 剪枝:如果当前最大结果已经≥剩余点数量,或超过总点数的一半,无需继续
    if (res >= n - i || res > n / 2) {
      break;
    }
    const map = new Map(); // 记录当前基准点下,斜率对应的点的数量

    // 遍历所有在i之后的点j(避免重复计算,因为i和j与j和i的斜率相同)
    for (let j = i + 1; j < n; j++) {
      let dx = points[i][0] - points[j][0];
      let dy = points[i][1] - points[j][1];

      // 特殊处理:垂直/水平直线,统一斜率表示
      if (dx === 0) {
        dy = 1; // 垂直直线,斜率统一用(1,0)表示
      } else if (dy === 0) {
        dx = 1; // 水平直线,斜率统一用(0,1)表示
      } else {
        // 符号统一:dy为负时,dx和dy同时取反
        if (dy < 0) {
          dx = -dx;
          dy = -dy;
        }
        // 化简dx和dy为互质整数
        const gcdXY = gcd(Math.abs(dx), Math.abs(dy));
        dx /= gcdXY;
        dy /= gcdXY;
      }

      // 生成唯一key,存入哈希表
      const key = dy + dx * 20001;
      map.set(key, (map.get(key) || 0) + 1);
    }

    // 统计当前基准点下,最多的共线点数(map的值是“与基准点共线的点的数量”,需+1包含基准点本身)
    let maxn = 0;
    for (const num of map.values()) {
      maxn = Math.max(maxn, num + 1);
    }
    // 更新全局最大值
    res = Math.max(res, maxn);
  }
  return res;
};

逐行解析核心代码

  1. 边界处理:if (n <= 2) return n; —— 这是最基础的优化,因为1个点返回1,2个点返回2,都无需后续计算。

  2. gcd函数:求两个数的最大公约数,用于化简dx和dy。比如gcd(4,2)=2,gcd(3,5)=1,核心是递归实现“辗转相除法”。

  3. 外层循环(基准点遍历):for (let i = 0; i < n; i++),每个i作为基准点,后续只遍历j > i的点,避免重复计算(比如i=0、j=1和i=1、j=0是同一个斜率,无需重复统计)。

  4. 剪枝逻辑:if (res >= n - i || res > n / 2) break; —— 比如总共有5个点,当前res=3,剩余未遍历的点只有2个(n-i=5-3=2),不可能超过3,直接终止循环;另外,最多共线点数不可能超过总点数的一半(如果超过,早就在之前的基准点中统计到了),这一步能大幅提升效率。

  5. 哈希表map:key是斜率的唯一标识,value是“与基准点i共线且在i之后的点的数量”。

  6. 内层循环(计算斜率):for (let j = i + 1; j < n; j++),计算基准点i和点j的dx和dy,然后进行化简和符号统一,生成key存入map。

  7. 统计当前基准点的最大共线点数:num + 1 是因为map的value是“除基准点外的共线点数”,加上基准点本身才是总共线点数。

  8. 更新全局最大值res:每次遍历完一个基准点,就用当前的maxn更新res,最终res就是答案。

常见坑点&优化建议

坑点1:斜率精度问题

千万不要用 dy/dx 计算斜率(比如用浮点数存储),会出现精度误差。比如 dx=1、dy=3 和 dx=2、dy=6,斜率都是1/3,但浮点数表示可能有微小差异,导致哈希表认为是两个不同的斜率。

坑点2:符号不统一

比如 dx=2、dy=-3 和 dx=-2、dy=3,其实是同一个斜率,但如果不统一符号,会生成两个不同的key。所以代码中才会判断“如果dy<0,dx和dy同时取反”,保证斜率符号一致。

坑点3:重复计算

如果内层循环遍历所有j(j从0到n-1,j≠i),会导致i和j、j和i重复计算,浪费时间。所以内层循环只遍历j > i的点,既避免重复,又提升效率。

优化建议

剪枝逻辑一定要加!尤其是当n较大时(比如n=1000),剪枝能大幅减少循环次数,避免超时。另外,哈希表的key生成方式可以灵活调整,只要能保证“不同斜率对应不同key,相同斜率对应相同key”即可,代码中的「dy + dx * 20001」是结合题目坐标范围的最优选择。

测试用例验证

我们用两个典型测试用例验证代码:

  1. 测试用例1:points = [[1,1],[2,2],[3,3]]

    • i=0(基准点[1,1]),j=1:dx=-1,dy=-1 → 符号统一后dx=1,dy=1 → key=1+1*20001=20002,map={20002:1};

    • j=2:dx=-2,dy=-2 → 化简后dx=1,dy=1 → key=20002,map={20002:2};

    • maxn=2+1=3,res=3;后续循环剪枝,最终返回3。

  2. 测试用例2:points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]]

    • i=0(基准点[1,1]),遍历j=1~5,计算各个斜率,最终map中最大value为3(对应4个点共线),maxn=4,res=4;

    • 后续循环无法超过4,最终返回4。

总结

这道题的核心是「用最简整数比表示斜率」,避免精度和符号问题,再通过「固定基准点+哈希表计数」的思路,统计每个基准点对应的最大共线点数,最后结合剪枝优化提升效率。

整体难度中等,重点在于细节处理——斜率的化简、符号统一、key的生成,这些都是避坑的关键。理解之后会发现,这道题本质是“哈希表的应用+直线斜率的数学理解”,掌握后可以举一反三,应对类似的几何计数问题。

ES6 40个数组方法保姆级拆解

2026年4月13日 10:34

前端人必看!数组是JS开发中最常用的数据结构,没有之一✨

很多开发者写数组操作,还在死磕for循环写几十行冗余代码,要么用错方法导致bug频发,要么不知道哪个方法更高效——其实ES6+早已贴心提供了40个数组方法,覆盖遍历、筛选、修改、转换等所有场景,学会它们,编码效率直接翻倍,面试也能轻松拿捏面试官!

今天这篇文章,结合实战场景,把40个数组方法逐一拆解,每个方法都配「核心特点+可复制代码+避坑提示」,从基础到进阶,小白能看懂,老手能查漏补缺,收藏这一篇,再也不用东拼西凑找资料!

核心提醒:很多人觉得“不用学完40个”,但实际开发中,选对方法能少写50%代码!比如筛选数据用filter,批量转换用map,累加计算用reduce,找元素用find——精准匹配场景,才是高效开发的关键👇

一、基础遍历类(3个)—— 替代for循环,简洁不冗余

核心作用:遍历数组元素,执行指定操作,告别手动维护索引的麻烦,代码可读性拉满,是日常开发使用频率最高的一类方法。

1. Array.forEach() —— 基础遍历神器

核心特点:遍历数组,对每个元素执行回调函数,无返回值;第一个参数始终是当前元素,还可接收索引、原数组作为可选参数。

避坑提示:无法通过break中断遍历,若需中断,可改用for...of循环;回调函数自身可修改原数组,需谨慎使用。

// 基础用法:遍历并打印每个元素
const array = ['a', 'b', 'c'];
array.forEach((e) => console.log(e));
// 输出结果:a、b、c(依次打印)

// 进阶用法:获取元素+索引
array.forEach((e, index) => {
  console.log(`索引${index}${e}`);
});
// 输出结果:索引0:a、索引1:b、索引2:c

2. Array.keys() —— 遍历数组索引

核心特点:返回一个数组迭代器对象,包含数组所有元素的索引,可通过for...of循环遍历获取所有索引。

const array = ['a', 'b', 'c', 'd'];
const iterator = array.keys();
// 遍历所有索引
for (const key of iterator) {
  console.log(key);
}
// 输出结果:0、1、2、3

3. Array.values() —— 遍历数组值

核心特点:返回一个数组迭代器对象,包含数组所有元素的值,可通过for...of循环遍历获取所有元素,与forEach功能互补。

const array = ['a', 'b', 'c', 'd'];
const iterator = array.values();
// 遍历所有元素值
for (const key of iterator) {
  console.log(key);
}
// 输出结果:a、b、c、d

二、筛选查找类(7个)—— 精准定位元素,告别手动判断

核心作用:根据指定条件,快速查找、筛选数组中的元素或索引,替代繁琐的if-else判断,减少代码冗余,提升开发效率。

4. Array.map() —— 批量转换数组

核心特点:遍历数组,对每个元素执行回调函数,返回一个新数组(长度与原数组一致),不改变原数组,常用于数据格式转换。

避坑提示:若回调函数无返回值,新数组会充满undefined,务必确保每个元素都有返回值。

// 基础用法:将数组中每个元素乘以2
const array = [1, 4, 9, 16];
const map1 = array.map((x) => x * 2);
console.log(map1); // 输出:[2, 8, 18, 32]
console.log(array); // 输出:[1, 4, 9, 16](原数组未改变)

// 实战用法:提取接口数据中的指定字段
const users = [{name: '张三'}, {name: '李四'}, {name: '王五'}];
const userNames = users.map(user => user.name);
console.log(userNames); // 输出:['张三', '李四', '王五']

5. Array.filter() —— 筛选符合条件的元素

核心特点:遍历数组,返回一个新数组,包含所有满足回调函数条件的元素,不改变原数组,常用于数据筛选、去重预处理。

// 基础用法:筛选长度大于6的单词
const words = ['spray', 'elite', 'exuberant', 'destruction', 'present'];
const result = words.filter((word) => word.length > 6);
console.log(result); // 输出:['exuberant', 'destruction', 'present']

// 实战用法:筛选有库存的商品
const products = [
  { name: '手机', price: 5999, inStock: true },
  { name: '电脑', price: 8999, inStock: false },
  { name: '耳机', price: 799, inStock: true }
];
const inStockProducts = products.filter(product => product.inStock);
console.log(inStockProducts); // 输出:[{name: '手机', ...}, {name: '耳机', ...}]

6. Array.find() —— 查找首个符合条件的元素

核心特点:遍历数组,返回首个满足回调函数条件的元素(返回元素本身);若找不到,返回undefined,适合查找单个目标元素。

const array = [5, 12, 8, 130, 44];
// 查找第一个大于10的元素
console.log(array.find((e) => e > 10)); // 输出:12

// 实战用法:查找指定id的用户
const users = [{id: 1, name: '张三'}, {id: 2, name: '李四'}];
const targetUser = users.find(user => user.id === 2);
console.log(targetUser); // 输出:{id: 2, name: '李四'}

7. Array.findIndex() —— 查找首个符合条件的元素索引

核心特点:与find()功能类似,区别在于返回首个满足条件的元素索引;若找不到,返回-1,常用于需要获取元素位置的场景。

const array = [5, 12, 8, 130, 44];
// 查找第一个大于45的元素索引
console.log(array.findIndex((e) => e > 45)); // 输出:3(元素130的索引)

8. Array.indexOf() —— 查找指定元素的索引

核心特点:查找数组中第一个目标元素的索引,若找不到返回-1;可设置第二个参数,指定起始查询位置,适合精确查找已知元素。

const beasts = ['ant', 'bison', 'camel', 'duck', 'bison'];
console.log(beasts.indexOf('bison')); // 输出:1(第一个bison的索引)
console.log(beasts.indexOf('bison', 2)); // 输出:4(从索引2开始查找的第一个bison)
console.log(beasts.indexOf('giraffe')); // 输出:-1(找不到该元素)

9. Array.lastIndexOf() —— 从末尾查找指定元素

核心特点:与indexOf()相反,从数组末尾开始搜索,返回第一个目标元素的索引;若找不到返回-1,适合查找元素最后出现的位置。

const array = ['ant', 'bison', 'camel', 'duck', 'bison'];
console.log(array.lastIndexOf('bison')); // 输出:4(最后一个bison的索引)
console.log(array.lastIndexOf('camel')); // 输出:2(camel的索引)

10. Array.includes() —— 判断元素是否存在

核心特点:判断数组是否包含指定元素,返回true/false,语法简洁,替代传统的indexOf() !== -1,可读性更高。

避坑提示:与some()区别:includes()直接判断元素是否存在,some()判断是否有元素满足自定义条件。

const array = [1, 30, 39, 29, 10, 13];
console.log(array.includes(30)); // 输出:true
console.log(array.includes(100)); // 输出:false

三、数组修改类(8个)—— 增删改查,灵活操作数组

核心作用:直接修改数组(或返回修改后的结果),涵盖元素添加、删除、替换、排序等操作,满足日常数组修改的所有需求,注意区分“是否改变原数组”。

11. Array.push() —— 末尾添加元素

核心特点:往数组末尾添加一个或多个元素,改变原数组,返回添加后数组的新长度。

const array = ['ant', 'duck', 'bison'];
// 添加单个元素
const count = array.push('cows');
console.log(count); // 输出:4(添加后的数组长度)
console.log(array); // 输出:["ant", "duck", "bison", "cows"]

// 批量添加元素
array.push('cat', 'dog');
console.log(array); // 输出:["ant", "duck", "bison", "cows", "cat", "dog"]

12. Array.unshift() —— 开头添加元素

核心特点:往数组开头添加一个或多个元素,改变原数组,返回添加后数组的新长度。

const array = [1, 10, 13];
// 批量添加元素到开头
console.log(array.unshift(4, 5)); // 输出:5(添加后的数组长度)
console.log(array); // 输出:[4, 5, 1, 10, 13]

13. Array.pop() —— 删除末尾元素

核心特点:删除数组最后一个元素,改变原数组,返回被删除的元素;若数组为空,返回undefined。

const array = ['ant', 'duck', 'bison'];
console.log(array.pop()); // 输出:bison(被删除的元素)
console.log(array); // 输出:["ant", "duck"]
// 继续删除
array.pop();
console.log(array); // 输出:["ant"]

14. Array.shift() —— 删除开头元素

核心特点:删除数组第一个元素,改变原数组,返回被删除的元素;若数组为空,返回undefined。

const array = [1, 6, 10, 13];
const firstElement = array.shift();
console.log(firstElement); // 输出:1(被删除的元素)
console.log(array); // 输出:[6, 10, 13]

15. Array.splice() —— 万能修改方法

核心特点:可实现“添加、删除、替换”三种功能,改变原数组;返回被删除的元素组成的数组,若未删除元素则返回空数组。

语法:array.splice(起始索引, 删除个数, 要添加的元素)

const array = ['ant', 'bison', 'camel', 'duck', 'bison'];
// 1. 插入元素(不删除):在索引1处插入'Feb'
array.splice(1, 0, 'Feb');
console.log(array); // 输出:["ant", "Feb", "bison", "camel", "duck", "bison"]

// 2. 替换元素:在索引4处删除1个元素,插入'May'
array.splice(4, 1, 'May');
console.log(array); // 输出:["ant", "Feb", "bison", "camel", "May", "bison"]

// 3. 删除元素:在索引1处删除1个元素
array.splice(1, 1);
console.log(array); // 输出:["ant", "bison", "camel", "May", "bison"]

16. Array.sort() —— 数组排序

核心特点:对数组元素进行排序,改变原数组;默认按字符串Unicode码排序,数字排序需手动传入比较函数。

// 字符串排序(默认)
const array1 = ['ant', 'bison', 'camel', 'duck'];
array1.sort();
console.log(array1); // 输出:["ant", "bison", "camel", "duck"]

// 数字排序(需传入比较函数)
const array2 = [3, 1, 4, 1, 5, 9];
// 升序排序
array2.sort((a, b) => a - b);
console.log(array2); // 输出:[1, 1, 3, 4, 5, 9]
// 降序排序
array2.sort((a, b) => b - a);
console.log(array2); // 输出:[9, 5, 4, 3, 1, 1]

17. Array.reverse() —— 数组反转

核心特点:反转数组中元素的顺序,改变原数组,语法简单,常用于需要倒序排列的场景。

const array = ['ant', 'bison', 'camel', 'duck'];
array.reverse();
console.log(array); // 输出:["duck", "camel", "bison", "ant"]

18. Array.fill() —— 填充数组

核心特点:用指定内容填充数组,改变原数组;可指定填充的起始索引和结束索引(不包含结束索引),不指定则填充整个数组。

const array = [1, 6, 10, 13];
// 填充索引2-4(不包含4)为0
console.log(array.fill(0, 2, 4)); // 输出:[1, 6, 0, 0]
// 填充索引1及以后为5
console.log(array.fill(5, 1)); // 输出:[1, 5, 5, 5]
// 填充整个数组为6
console.log(array.fill(6)); // 输出:[6, 6, 6, 6]

四、数组转换类(7个)—— 格式转换,适配不同场景

核心作用:将数组转换为其他格式(字符串、迭代器、新数组等),或从其他格式转为数组,满足数据展示、传递等不同需求,大多不改变原数组。

19. Array.toString() —— 数组转字符串

核心特点:将数组转为字符串,元素用逗号分隔,不改变原数组;简单直接,但无法自定义分隔符。

const array = [1, 6, 'a', '1a'];
console.log(array.toString()); // 输出:'1,6,a,1a'

20. Array.join() —— 自定义分隔符转字符串

核心特点:将数组用指定符号连接成字符串,不改变原数组;省略分隔符则用逗号分隔,可自定义任意分隔符(空字符串、横线等)。

const array = ['ant', 'duck', 'bison'];
console.log(array.join()); // 输出:ant,duck,bison(默认逗号分隔)
console.log(array.join('')); // 输出:antduckbison(无分隔符)
console.log(array.join('-')); // 输出:ant-duck-bison(横线分隔)

21. Array.concat() —— 合并数组

核心特点:合并两个或多个数组,返回一个新数组,不改变原数组;可替代扩展运算符(...),语法更简洁。

const array1 = ['a', 'b', 'c'];
const array2 = ['d', 'e', 'f'];
const array3 = array1.concat(array2);
console.log(array3); // 输出:['a', 'b', 'c', 'd', 'e', 'f']
console.log(array1); // 输出:['a', 'b', 'c'](原数组未改变)

22. Array.slice() —— 截取数组

核心特点:截取数组中的指定片段,返回一个新数组,不改变原数组;可指定起始索引和结束索引(不包含结束索引),支持负数索引(从末尾开始计数)。

const array = ['ant', 'bison', 'camel', 'duck', 'bison'];
console.log(array.slice(2)); // 输出:["camel", "duck", "bison"](从索引2开始截取)
console.log(array.slice(2, 4)); // 输出:["camel", "duck"](截取索引2-4,不包含4)
console.log(array.slice(-2)); // 输出:["duck", "bison"](从倒数第二个开始截取)
console.log(array.slice()); // 输出:原数组(相当于浅拷贝)

23. Array.from() —— 类数组转数组

核心特点:将类数组(如字符串、DOM集合、JSON)转为真正的数组,返回新数组;最简单的应用是克隆数组,还可传入回调函数处理元素。

// 字符串转数组
console.log(Array.from('foo')); // 输出:["f", "o", "o"]

// 克隆数组并处理元素
console.log(Array.from([1, 2, 3], x => x + x)); // 输出:[2, 4, 6]

// JSON(类数组)转数组
const json = { '0': 'a', '1': 'b', '2': 'c', length: 3 };
console.log(Array.from(json)); // 输出:['a', 'b', 'c']

24. Array.of() —— 创建新数组

核心特点:创建一个新的Array实例,将传入的参数作为数组元素;与new Array()区别:Array.of(3)创建[3],new Array(3)创建长度为3的空数组。

console.log(Array.of('foo', 2, 'bar', true)); // 输出:["foo", 2, "bar", true]
console.log(Array.of()); // 输出:[](空数组)
console.log(Array.of(3)); // 输出:[3]
console.log(new Array(3)); // 输出:[empty × 3](空数组,长度为3)

25. Array.entries() —— 生成键值对迭代器

核心特点:返回一个新的数组迭代器对象,包含数组中每个索引的键/值对([索引, 元素]),可通过for...of循环遍历。

const array = ['a', 'b', 'c', 'd'];
const iterator = array.entries();
for (const key of iterator) {
  console.log(key[0], key[1]);
}
// 输出结果:0 "a"、1 "b"、2 "c"、3 "d"

五、高级操作类(10个)—— 复杂场景必备,提升编码上限

核心作用:应对更复杂的数组操作(累加、扁平化、批量处理等),是前端进阶的关键,学会这些,能轻松处理复杂数据逻辑,面试加分项!

26. Array.reduce() —— 万能累加器

核心特点:从左到右遍历数组,通过回调函数将数组元素“累积”为一个值(数字、对象、数组等),不改变原数组,功能最灵活,可替代sum、map+filter等组合用法。

语法:array.reduce((累加器, 当前值, 索引, 原数组) => {}, 初始值)

// 1. 基础用法:数组求和
const array = [1, 2, 3, 4];
const initialValue = 0;
const sumWithInitial = array.reduce(
  (accumulator, currentValue) => accumulator + currentValue,
  initialValue
);
console.log(sumWithInitial); // 输出:10

// 2. 进阶用法:数组转对象(按id分组)
const users = [{id: 1, name: '张三'}, {id: 2, name: '李四'}];
const userMap = users.reduce((acc, user) => {
  acc[user.id] = user;
  return acc;
}, {});
console.log(userMap); // 输出:{1: {id:1, name:'张三'}, 2: {id:2, name:'李四'}}

27. Array.reduceRight() —— 反向累加

核心特点:与reduce()功能一致,区别在于从数组末尾开始遍历累加,最终返回一个单个值,适合需要反向处理数组的场景。

const array = [2, 3, 4, 5, 6, 7, 8, 9];
const sumWithInitial = array.reduceRight((pv, cv) => {
  console.log(`当前值: ${cv}, 上一次累积值: ${pv}`);
  return 2 * cv;
});
console.log(sumWithInitial); // 输出:36(从9开始反向计算:2*9=18 → 2*8=16 → ...最终结果36)

28. Array.flat() —— 数组扁平化

核心特点:将嵌套数组(多维数组)展开,返回一个新数组,不改变原数组;默认展开1层,可传入数字指定展开层数(Infinity表示无限层)。

// 基础用法:展开1层嵌套数组
const array = [0, 1, 2, [3, 4]];
const result = array.flat();
console.log(result); // 输出:[0, 1, 2, 3, 4]

// 进阶用法:展开多层嵌套数组
const array2 = [0, 1, [2, [3, [4, 5]]]];
console.log(array2.flat(2)); // 输出:[0, 1, 2, 3, [4, 5]](展开2层)
console.log(array2.flat(Infinity)); // 输出:[0, 1, 2, 3, 4, 5](无限层展开)

29. Array.flatMap() —— 映射+扁平化

核心特点:先对数组每个元素执行map()操作,再对结果执行flat()操作(默认展开1层),返回一个新数组,不改变原数组,简化map+flat的组合写法。

const array = [2, 3, 4];
// 对每个元素映射,再扁平化
const result = array.flatMap(num => (num === 2 ? [2, 2] : 1));
console.log(result); // 输出:[2, 2, 1, 1]
// 等价于:array.map(...).flat(1)

30. Array.copyWithin() —— 数组内部拷贝

核心特点:从数组的指定位置拷贝元素,粘贴到数组的另一个指定位置,改变原数组;语法:array.copyWithin(粘贴位置, 拷贝起始位置, 拷贝结束位置)。

const array = ['a', 'b', 'c', 'd', 'e'];
// 从索引3拷贝1个元素(d),粘贴到索引0
console.log(array.copyWithin(0, 3, 4)); // 输出:["d", "b", "c", "d", "e"]
// 从索引3拷贝所有元素(d、e),粘贴到索引1
console.log(array.copyWithin(1, 3)); // 输出:["d", "d", "e", "d", "e"]

31. Array.at() —— 按索引获取元素

核心特点:根据指定索引获取数组中的元素,支持负数索引(从末尾开始计数),语法比array[index]更灵活,可避免负数索引返回undefined的问题。

const array = [5, 12, 8, 130, 44];
console.log(array.at(2)); // 输出:8(索引2对应的元素)
console.log(array.at(-1)); // 输出:44(倒数第一个元素)
console.log(array.at(-3)); // 输出:8(倒数第三个元素)

32. Array.findLast() —— 查找最后一个符合条件的元素

核心特点:与find()功能类似,区别在于从数组末尾开始查找,返回最后一个满足条件的元素;若找不到,返回undefined。

const array = [5, 12, 8, 130, 44];
// 查找最后一个大于45的元素
const found = array.findLast(el => el > 45);
console.log(found); // 输出:130

33. Array.fromAsync() —— 异步类数组转数组

核心特点:将异步类数组(如异步迭代器、Promise数组)转为数组,返回一个新的Promise实例,其履行值是转换后的新数组,适合异步场景。

// 示例:将异步迭代器转为数组
async function getArray() {
  const asyncIterator = (async function* () {
    yield 1;
    yield 2;
    yield 3;
  })();
  const array = await Array.fromAsync(asyncIterator);
  console.log(array); // 输出:[1, 2, 3]
}
getArray();

34. Array.with() —— 替换指定索引元素

核心特点:将数组中指定索引的元素替换为目标值,返回一个新数组,不改变原数组,语法简洁,替代splice()的替换功能(无需改变原数组)。

const arr = [1, 2, 3, 4, 5];
// 将索引2的元素替换为6
console.log(arr.with(2, 6)); // 输出:[1, 2, 6, 4, 5]
console.log(arr); // 输出:[1, 2, 3, 4, 5](原数组未改变)

35. Array.toSorted() —— 排序不改变原数组

核心特点:与sort()功能一致,对数组元素进行排序,但不改变原数组,返回排序后的新数组,避免sort()修改原数组的副作用。

const array = ['ant', 'bison', 'camel', 'duck'];
const sortedMonths = array.toSorted();
console.log(sortedMonths); // 输出:["ant", "bison", "camel", "duck"](排序后)
console.log(array); // 输出:['ant', 'bison', 'camel', 'duck'](原数组未改变)

36. Array.toLocaleString() —— 本地化字符串转换

核心特点:将数组转为本地化字符串,元素用逗号分隔,不改变原数组;与toString()区别:会根据当前地区的语言和格式规则转换(如数字、日期格式)。

const array = [1234, new Date(), 'hello'];
// 本地化转换(根据当前地区格式)
console.log(array.toLocaleString()); 
// 输出示例:1,234, 2026/4/13 10:30:00, hello

37. Array.isArray() —— 判断是否为数组

核心特点:判断一个值是否为数组,返回true/false;注意:若值是TypedArray实例(如Int16Array),始终返回false,是最可靠的数组判断方法。

console.log(Array.isArray([1, 3, 5])); // 输出:true
console.log(Array.isArray('[]')); // 输出:false(字符串不是数组)
console.log(Array.isArray(new Array(5))); // 输出:true(new Array创建的是数组)
console.log(Array.isArray(new Int16Array([15, 33]))); // 输出:false(TypedArray实例)

38. Array.valueOf() —— 返回数组本身

核心特点:返回数组本身,不做任何修改和转换,常用于确保变量是数组类型,避免类型转换错误。

const fruits = ['a', 'b', 'c', 'd'];
console.log(fruits.valueOf()); // 输出:["a", "b", "c", "d"](返回数组本身)

39. Array.some() —— 判断是否有元素满足条件

核心特点:遍历数组,只要有一个元素满足回调函数条件,就返回true;所有元素都不满足,返回false,常用于“判断是否存在符合条件的元素”。

const array = [1, 2, 3, 4, 5];
// 判断数组中是否有偶数
const even = (el) => el % 2 === 0;
console.log(array.some(even)); // 输出:true

40. Array.every() —— 判断所有元素是否满足条件

核心特点:与some()相反,遍历数组,所有元素都满足回调函数条件,才返回true;只要有一个元素不满足,返回false,常用于“校验所有元素是否符合规则”。

const array = [1, 30, 39, 29, 10, 13];
// 判断所有元素是否都小于20
console.log(array.every((item) => item < 20)); // 输出:false(30、39大于20)

二、面试+实战高频避坑总结(必记)

很多开发者用错数组方法,不是不会用,而是没分清“是否改变原数组”“适用场景”,整理了4个高频避坑点,记牢少踩bug!

  • 改变原数组的方法(10个):push、unshift、pop、shift、splice、sort、reverse、fill、copyWithin
  • 不改变原数组的方法(30个):除上述10个外,其余30个均不改变原数组,可放心使用
  • 高频混淆方法:map(返回等长新数组)vs filter(返回筛选后数组)、some(有一个满足即true)vs every(所有满足才true)、slice(不改变原数组)vs splice(改变原数组)
  • 高效组合用法:map+filter(先转换再筛选)、flatMap(映射+扁平化)、reduce(替代sum、数组转对象等)

三、最后说几句掏心窝的话

40个数组方法看似多,但不用死记硬背——日常开发中,高频使用的也就10个左右(forEach、map、filter、push、splice、slice、reduce、find、includes、sort),剩下的可作为储备,用到时翻这篇文章即可。

前端开发的核心是“高效编码”,选对数组方法,能帮你少写冗余代码、减少bug,还能提升代码可读性——这篇文章整理了所有方法的实战代码和避坑点,建议收藏,面试前过一遍,开发时直接复制使用!

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端干货,一起从新手进阶成资深开发者💪

前端视频媒体带声音自动播放方案最佳实践和教程

作者 鹏多多
2026年4月13日 10:29

在前端开发过程中,经常会碰到这样的需求,自动播放视频,要求默认带声音。在浏览器环境下,视频媒体自动播放是可以的,默认静音的自动播放可以正常执行。浏览器不能自动播放的限制,仅针对带声音的自动播放。当网页无用户交互、媒体参与度不足时,带声音的自动播放会被浏览器拦截。

本文结合「用户交互触发」「媒体参与度优化」「跨域权限下放」三大核心场景,提供可落地的实现方案,附代码片段与关键细节说明,明确区分静音与带声音自动播放的实现差异。看完如有帮助,谢谢三连~

1. 核心前提:浏览器自动播放策略

浏览器对自动播放的限制核心的是「避免声音突然打扰用户」,具体规则如下:

  1. 「静音自动播放」:默认允许,无需用户交互、无需满足媒体参与度阈值,可直接实现;
  2. 「带声音自动播放」:受严格限制,需满足以下任一条件才可正常执行:
  3. 用户有强交互(如点击、Tab切换、滑动等),且交互触发在当前域名下;
  4. 媒体参与度(Media Engagement)达标(不同浏览器阈值不同,阈值达标后浏览器会放宽限制);
  5. 父元素(如顶级页面)已获得带声音播放权限,可下放给子iframe(同域名/配置跨域权限后)。

1.1. 什么是媒体参与度?

媒体参与度

媒体参与度(Media Engagement)是浏览器(以Chrome为首)内置的一种用户行为评分机制,核心是通过监测用户对当前域名的媒体交互行为,评估该网站的信任度,进而决定是否放宽带声音自动播放的限制,本质是浏览器给予域名的「信任积分」。

其判定依据主要包括:用户在当前域名播放过音频/视频、进行过点击/滑动/输入等交互操作、停留时间较长或浏览多个页面等;得分越高,浏览器对该域名的信任度越高,越容易允许带声音自动播放。

查看 Chrome 浏览器媒体参与度:直接访问 chrome://media-engagement,可查看当前域名的参与度得分及阈值。其中,Score 为当前得分(0-100分),Threshold 为准入阈值(通常20-30分,因浏览器版本和设备而异),Engaged 为 true 时,说明得分达标,浏览器授予带声音自动播放权限。

2. 基础实现:静音自动播放

该方式无需交互,直接可用。

2.2. 实现逻辑

无需用户前置交互,直接创建媒体元素并设置 muted="true",即可实现静音自动播放; 若需切换为带声音播放,需通过用户交互触发(如点击按钮取消静音)。

2.3. 案例

<!-- 页面结构:静音自动播放 + 手动取消静音按钮 -->
<div class="media-container">
  <video 
    id="videoPlayer" 
    muted="true"  <!-- 关键设置静音实现自动播放 -->
    autoplay       <!-- 自动播放属性 -->
    loop           <!-- 可选:循环播放 -->
    style="width: 100%;"
  >
    <source src="your-video-url.mp4" type="video/mp4">
  </video>
  <button id="unmuteBtn" style="margin-top: 10px; padding: 8px 16px;">
    点击开启声音
  </button>
</div>

<script>
  const video = document.getElementById('videoPlayer');
  const unmuteBtn = document.getElementById('unmuteBtn');

  // 静音自动播放无需额外触发,浏览器默认允许
  console.log('静音自动播放已执行');

  // 用户交互触发:取消静音(带声音播放)
  unmuteBtn.addEventListener('click', async () => {
    try {
      // 取消静音并尝试带声音播放(需用户交互触发,否则会报错)
      video.muted = false;
      await video.play();
      console.log('带声音播放成功');
    } catch (error) {
      console.log('带声音播放失败(未满足权限条件):', error);
      // 失败后恢复静音,避免影响自动播放
      video.muted = true;
    }
  });
</script>

2.4. 关键细节

  • 只要设置 muted="true"autoplay 属性可直接生效,无需用户交互;
  • 带声音播放必须通过用户交互触发(如点击按钮),否则即使取消静音,play() 也会被拦截;
  • 建议搭配 try/catch 包裹带声音播放逻辑,避免报错影响页面正常运行。

3. 带声音自动播放

若需实现「无需用户每次交互,即可带声音自动播放」,核心是提升当前域名的媒体参与度:

  1. 设计引导页,让用户完成高频交互(如点击、滑动、按键);
  2. 引导页中播放静音媒体,持续积累媒体参与度;
  3. 参与度达标后,跳转至目标页面,此时浏览器会默认允许带声音自动播放。

这里的引导页,实际上可以是任意页面,设计得让用户无感,只要发生交互即可。

示例代码:

<!-- 引导页:用于提升媒体参与度,为带声音自动播放铺路 -->
<div class="guide-page" style="text-align: center; padding: 50px 0;">
  <h3>点击任意区域进入播放页</h3>
  <p id="guideTip" style="margin: 20px 0; color: #666;">当前媒体参与度:<span id="engagementScore">0</span>(达标即可带声音自动播放)</p>
</div>

<script>
  // 创建静音音频(用于积累参与度,不干扰用户)
  const engagementAudio = new Audio('silent-audio.mp3');
  engagementAudio.loop = true;
  engagementAudio.muted = true; // 静音播放,避免打扰

  // 监听用户交互,触发参与度提升
  document.querySelector('.guide-page').addEventListener('click', async () => {
    // 首次点击触发静音播放,开始积累参与度
    await engagementAudio.play();
    // 模拟参与度更新(实际可通过 chrome://media-engagement 查看真实值)
    updateEngagementScore();
    // 假设参与度达标(模拟值≥80),跳转至目标页面
    const currentScore = Number(document.getElementById('engagementScore').textContent);
    if (currentScore >= 80) {
      setTimeout(() => {
        window.location.href = 'autoplay-page.html'; // 目标带声音自动播放页面
      }, 1000);
    }
  });

  // 模拟媒体参与度积累
  function updateEngagementScore() {
    const scoreSpan = document.getElementById('engagementScore');
    let currentScore = Number(scoreSpan.textContent) + 20;
    scoreSpan.textContent = Math.min(currentScore, 100); // 参与度上限100
  }
</script>

3.1. 媒体参与度提升流程图

插图1.png

4. 跨域 iframe 自动播放

  • 静音自动播放:iframe 可直接实现,无需父页面权限;
  • 带声音自动播放:需父页面已获得带声音播放权限(通过用户交互/参与度达标),并将权限下放给 iframe(同域名/配置跨域权限)。

4.1. 案例

示例代码:

  • 父页面(同域名,已获带声音播放权限)
<!-- 父页面:通过用户交互获得带声音播放权限 -->
<button id="parentPlayBtn" style="padding: 8px 16px; margin: 20px 0;">点击开启带声音播放(授权)</button>
<iframe id="mediaIframe" src="iframe-page.html" width="800" height="450"></iframe>

<script>
  const parentPlayBtn = document.getElementById('parentPlayBtn');
  const iframe = document.getElementById('mediaIframe');
  let hasAudioPermission = false;

  // 父页面用户交互,获得带声音播放权限
  parentPlayBtn.addEventListener('click', async () => {
    const testAudio = new Audio('test-audio.mp3');
    try {
      await testAudio.play();
      testAudio.pause();
      hasAudioPermission = true;
      console.log('父页面已获得带声音播放权限');
      // 向iframe发送权限下放通知
      iframe.contentWindow.postMessage('autoplay-allowed', '*');
    } catch (error) {
      console.log('父页面带声音播放授权失败:', error);
    }
  });
</script>
  • iframe 页面
<!-- 子iframe:根据父页面权限,实现对应自动播放 -->
<video id="iframeVideo" muted="true" autoplay loop style="width: 100%;">
  <source src="iframe-video-url.mp4" type="video/mp4">
</video>

<script>
  const video = document.getElementById('iframeVideo');

  // 监听父页面权限通知,切换为带声音播放
  window.addEventListener('message', async (e) => {
    if (e.data === 'autoplay-allowed') {
      try {
        // 父页面已授权,尝试带声音播放
        video.muted = false;
        await video.play();
        console.log('iframe 带声音自动播放成功');
      } catch (error) {
        console.log('iframe 带声音播放失败:', error);
        video.muted = true; // 失败后恢复静音自动播放
      }
    }
  });
</script>

4.2. 关键细节

  • 跨域场景下,需在父页面响应头配置 Permissions-Policy: autoplay=(self "https://子域名.example.com"),允许权限下放;
  • 即使父页面授权,iframe 带声音播放仍建议用 try/catch 处理异常,避免权限失效导致播放失败;
  • 若父页面未授权,iframe 仍可正常实现静音自动播放,不影响基础体验。

5. 进阶方案:智能检测播放能力

封装音频/视频播放类,自动检测浏览器是否允许带声音播放:能播放则自动开启声音,不能则默认静音播放,无需手动判断,适配所有场景。

5.1. 案例

// 封装智能媒体播放类(适配音频/视频,自动区分静音/带声音)
class SmartMediaPlayer {
  constructor(mediaUrl, isVideo = false) {
    // 创建媒体元素(音频/视频)
    this.media = isVideo ? document.createElement('video') : document.createElement('audio');
    this.media.src = mediaUrl;
    this.media.loop = true; // 可选:循环播放
    this.canPlayWithAudio = false; // 标记是否可带声音播放
  }

  // 初始化检测:自动判断播放能力
  async init() {
    try {
      // 尝试带声音播放(无用户交互时,此处会报错)
      await this.media.play();
      this.canPlayWithAudio = true;
      console.log('可带声音自动播放');
    } catch (error) {
      // 带声音播放失败,切换为静音自动播放(默认允许)
      this.media.muted = true;
      await this.media.play();
      this.canPlayWithAudio = false;
      console.log('带声音播放受限,已切换为静音自动播放');
    }
  }

  // 手动切换声音(需用户交互触发)
  toggleAudio() {
    if (!this.canPlayWithAudio) return; // 未获得带声音权限,不执行
    this.media.muted = !this.media.muted;
  }

  // 播放/暂停控制
  togglePlay() {
    this.media.paused ? this.media.play() : this.media.pause();
  }
}

// 调用示例(音频)
(async () => {
  const audioPlayer = new SmartMediaPlayer('background-music.mp3');
  await audioPlayer.init();
  // 页面加载完成后自动播放(静音/带声音自动适配)
  audioPlayer.togglePlay();
})();

// 调用示例(视频)
(async () => {
  const videoPlayer = new SmartMediaPlayer('demo-video.mp4', true);
  await videoPlayer.init();
  // 追加到页面
  document.body.appendChild(videoPlayer.media);
})();

5.2. 智能播放能力检测流程图

插图2.png

6. 参考资料与注意事项

6.1. 官方参考

6.2. 开发注意事项

  • 静音自动播放虽无需交互,但建议搭配加载状态提示,避免用户误以为媒体未加载;
  • 带声音自动播放的核心是「用户交互」或「媒体参与度」,二者缺一不可,不可强行绕过浏览器限制;
  • 移动端浏览器对带声音自动播放的限制更严格,即使参与度达标,部分机型仍需用户交互触发;
  • 媒体文件建议压缩优化,避免加载延迟导致自动播放触发时机滞后,影响用户体验;
  • 可通过 chrome://media-engagement 调试当前域名的参与度,适配不同浏览器的阈值差异。

以上,在实际开发中,可根据业务需求(是否需要声音),组合使用以上方案,既满足自动播放需求,又符合浏览器权限策略,兼顾用户体验与开发合规性。

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

救命!ES6入门到精通,前端小白也能秒上手

2026年4月13日 10:24

谁懂啊家人们!前端入门绕不开ES6,可网上的教程要么太晦涩,要么代码零散,新手看了直接劝退😭

其实ES6根本没那么难!它不是全新的语言,只是JavaScript的“升级补丁”——把ES5里繁琐的写法简化,新增了超多实用功能,学会它,写代码效率直接翻倍,面试也能轻松拿捏!

今天就结合实战代码,把ES6核心知识点拆解得明明白白,从基础到进阶,小白也能跟着敲、跟着会,收藏这一篇就够了,再也不用东拼西凑找资料!

一、先搞懂:ES和JS到底是什么关系?(新手必看)

很多小白刚入门就被“ES6”“JavaScript”搞懵,其实一句话就能说清:

ES 是 ECMAScript 的简写,是 JavaScript 的“核心语法标准”;而 JS 由 3 部分组成:ECMAScript(核心)+ DOM(文档对象模型)+ BOM(浏览器对象模型)。简单说,ES 就是 JS 的“灵魂”,学 JS 必学 ES!

以前我们学的大多是 ES5 语法,而 ES6 及以后的版本,做了大量优化,解决了 ES5 的很多痛点(比如变量提升、代码冗余),现在前端开发几乎全员用 ES6+ 写法,不学真的会被淘汰!

💡 开发工具推荐:VS Code(免费又强大),必装 2 个插件:

  • View in Browser:一键在浏览器中查看效果
  • JavaScript (ES6) code snippets:ES6 代码片段,一键生成,提升编码速度

二、ES6 核心知识点(实操为王,代码可直接复制)

这部分是重点!每个知识点都配了「代码示例+通俗解释」,敲一遍就懂,建议边看边练,记得更牢~

1. 变量声明:let/const 替代 var(彻底解决变量提升坑)

ES5 里我们用 var 声明变量,会有“变量提升”“重复声明”“全局污染”三个大坑,而 ES6 的 let 和 const 直接解决了这些问题,用法超简单!

✨ let 用法(声明局部变量)

// 1. 不允许未定义就使用(避免变量提升)
// console.log(k); // 报错:Uncaught ReferenceError: k is not defined

// 2. 不允许重复声明
let k = 10;
// let k = 101; // 报错:Uncaught SyntaxError: Identifier 'k' has already been declared

// 3. 块级作用域(只在当前代码块有效)
for (let j = 0; j < 5; j++) {
  console.log("循环里的j:" + j); // 正常输出 0-4
}
// console.log("循环外的j:" + j); // 报错:j is not defined

✨ const 用法(声明常量)

// 声明常量,指向的内存地址不能修改
const x = 2;
// x = 991; // 报错:Uncaught TypeError: Assignment to constant variable.

// 注意:如果常量是对象/数组,内部属性可以修改
const obj = { name: "jspang" };
obj.name = "技术胖"; // 正常生效,不报错

小技巧:能⽤ const 就⽤ const,需要修改的变量再⽤ let,避免全局污染!

2. 变量解构赋值:简化赋值,少写冗余代码

以前给多个变量赋值,要写多行代码,ES6 的解构赋值,一行就能搞定,还支持数组、对象、字符串解构,超实用!

✨ 数组解构

// ES5 写法
let a = 0; let b = 1; let c = 2;

// ES6 解构写法(简洁!)
let [a, b, c] = [1, 2, 3];
console.log(a); // 1
console.log(b); // 2
console.log(c); // 3

// 嵌套数组解构
let [a, [b, c], d] = [1, [2, 3], 4];
console.log(a); // 1,b:2,c:3,d:4

✨ 对象解构(最常用,重点记!)

// 核心:变量名必须和对象属性名一致
let { foo, bar } = { foo: 'JSPang', bar: '技术胖' };
console.log(foo + bar); // 输出:JSPang技术胖

// 圆括号用法(当变量已经声明时)
let foo;
({ foo } = { foo: 'JSPang' }); // 必须加圆括号,否则报错
console.log(foo); // JSPang

✨ 解构默认值(避免 undefined)

// 当解构的值不存在时,使用默认值
let [a, b = "JS"] = ['张三'];
console.log(a + b); // 张三JS

// 注意:undefined 和 null 的区别
let [a, b = "JSPang"] = ['技术胖', undefined]; // undefined 用默认值
console.log(a + b); // 技术胖JSPang

let [a, b = "JSPang"] = ['技术胖', null]; // null 不用默认值
console.log(a + b); // 技术胖null

3. 字符串扩展:新增方法,简化字符串操作

ES6 给字符串新增了 includes()、startsWith()、endsWith()、repeat() 等方法,替代了传统的 indexOf(),写法更简洁,语义更清晰!

let str = "https://www.baidu.com/";

// 1. includes():判断是否包含指定字符串(返回true/false)
console.log(str.includes("www")); // true
console.log(str.includes("yyy")); // false

// 2. startsWith():判断是否以指定字符串开头
console.log(str.startsWith("https")); // true
console.log(str.startsWith("baidu", 12)); // 从第12位开始,是否以baidu开头?true

// 3. endsWith():判断是否以指定字符串结尾
console.log(str.endsWith("com")); // false(原字符串结尾是/)
console.log(str.endsWith("www", 11)); // 前11位是否以www结尾?true

// 4. repeat():复制字符串
console.log('jspang|'.repeat(3)); // jspang|jspang|jspang|

4. 数组扩展:新增方法,搞定数组操作

ES6 给数组新增了 Array.from()、Array.of()、find()、filter()、map() 等方法,再也不用手动写循环,效率翻倍!

// 1. Array.from():将类数组(如JSON)转为真正的数组
let json = {
  '0': 'jspang',
  '1': '技术胖',
  '2': '大胖逼逼叨',
  length: 3
};
let arr = Array.from(json);
console.log(arr); // ['jspang', '技术胖', '大胖逼逼叨']

// 2. Array.of():将任意值转为数组
let arr1 = Array.of(3, 4, 5, 6);
console.log(arr1); // [3,4,5,6]

// 3. find():查找数组中第一个满足条件的元素
let arr2 = [1,2,3,4,5,6];
console.log(arr2.find(value => value > 5)); // 6

// 4. filter():过滤数组(返回满足条件的新数组)
let num = [1, 5, 5, 9];
let num1 = num.filter(x => x != 5); // 过滤掉5
console.log(num1); // [1,9]

// 5. map():映射数组(对每个元素做处理,返回新数组)
let arr3 = ['jspang','技术胖','前端教程'];
console.log(arr3.map(x => 'web')); // ['web', 'web', 'web']

5. 扩展运算符(...):万能简化神器

扩展运算符(...)是 ES6 最常用的语法之一,能拆分数组、对象,简化函数参数传递,解决数组浅拷贝问题,用法超灵活!

// 1. 简化函数参数
let add = (...c) => {
  let sum = 0;
  for (const num of c) {
    sum += num;
  }
  return sum;
};
let num = [1, 5, 5, 9];
console.log(add(...num)); // 20(相当于add(1,5,5,9))

// 2. 数组浅拷贝(避免修改新数组影响原数组)
let arr1 = ['www','jspang','com'];
let arr2 = [...arr1]; // 浅拷贝
arr2.push('shengHongYu');
console.log(arr1); // ['www','jspang','com'](原数组不变)
console.log(arr2); // ['www','jspang','com','shengHongYu']

6. 箭头函数:简化函数写法,告别this坑

ES6 的箭头函数,把 function 关键字简化成 =>,代码更简洁,还解决了传统函数中 this 指向混乱的问题,前端面试高频考点!

// ES5 函数写法
function fun1(x, y) {
  return x + y;
}

// ES6 箭头函数写法(简化!)
let fun1 = (x, y) => x + y; // 只有一句执行语句,可省略{}和return
console.log(fun1(2, 6)); // 8

// 带默认值的箭头函数
let fun3 = (x, y = 1) => x + y;
console.log(fun3(4)); // 5(y默认值为1)

// 注意:箭头函数没有自己的this,this指向外层作用域
const obj = {
  name: "技术胖",
  say: () => {
    console.log(this.name); // undefined(this指向window,不是obj)
  }
};
obj.say();

7. Set/WeakSet:数组去重神器

Set 是 ES6 新增的数据结构,和数组类似,但不允许有重复值,天生适合数组去重,还有 add()、delete()、has() 等方法,用法简单!

// 1. 声明Set(自动去重)
let setArr = new Set(['jspang','技术胖','web','jspang']);
console.log(setArr); // Set {"jspang", "技术胖", "web"}(重复值被自动过滤)

// 2. 常用方法
setArr.add('前端职场'); // 新增元素
setArr.delete('jspang'); // 删除元素
console.log(setArr.has('技术胖')); // true(判断是否存在)
setArr.clear(); // 清空Set

// 3. 数组去重(实战常用)
let arr = [1,2,2,3,3,3];
let newArr = [...new Set(arr)];
console.log(newArr); // [1,2,3]

8. Map:比对象更灵活的键值对

Map 和对象类似,都是键值对结构,但 Map 的键可以是任意类型(数字、数组、函数、对象),而对象的键只能是字符串/ Symbol,灵活性更高!

// 声明Map并添加键值对
const map = new Map();
let num = 123;
let arr = [1,2,3];
map.set(num, "数字键");
map.set(arr, "数组键");
map.set('name', "技术胖");

// 常用方法
console.log(map.get(num)); // 数字键(获取值)
console.log(map.has('name')); // true(判断键是否存在)
map.delete(arr); // 删除指定键值对
console.log(map.size); // 2(获取键值对数量)
map.clear(); // 清空Map

9. Promise:解决回调地狱,异步编程神器

以前写异步代码(如请求接口、定时器),会出现“回调嵌套回调”的情况,也就是回调地狱,代码混乱难维护,而 Promise 完美解决了这个问题!

// 实战案例:模拟异步操作(洗菜做饭→吃饭→收拾桌子)
let state = 1; // 1表示成功,0表示失败

// 第一步:洗菜做饭
function step1(resolve, reject) {
  console.log('1.开始-洗菜做饭');
  if (state == 1) {
    resolve('洗菜做饭--完成'); // 成功,执行then
  } else {
    reject('洗菜做饭--出错'); // 失败,执行catch
  }
}

// 第二步:吃饭
function step2(resolve, reject) {
  console.log('2.开始-坐下来吃饭');
  if (state == 1) {
    resolve('坐下来吃饭--完成');
  } else {
    reject('坐下来吃饭--出错');
  }
}

// 第三步:收拾桌子
function step3(resolve, reject) {
  console.log('3.开始-收拾桌子洗碗');
  if (state == 1) {
    resolve('收拾桌子洗碗--完成');
  } else {
    reject('收拾桌子洗碗--出错');
  }
}

// 链式调用,避免回调地狱
new Promise(step1)
  .then(val => {
    console.log(val);
    return new Promise(step2); // 执行下一步
  })
  .then(val => {
    console.log(val);
    return new Promise(step3);
  })
  .then(val => {
    console.log(val);
  })
  .catch(err => {
    console.log(err); // 捕获任意一步的错误
  });

10. Class:面向对象编程,简化构造函数

ES6 引入了 Class(类)的概念,简化了 ES5 中构造函数的写法,让面向对象编程更直观,还支持继承,适合大型项目开发!

// 声明类
class Coder {
  // 构造函数(初始化属性)
  constructor(a, b) {
    this.a = a;
    this.b = b;
  }

  // 类的方法
  name(val) {
    console.log(val);
    return val;
  }

  skill(val) {
    console.log(this.name('jspang') + ':' + 'Skill:' + val);
  }

  add() {
    return this.a + this.b;
  }
}

// 实例化类
let jspang = new Coder(1, 2);
jspang.name('jspang'); // 输出:jspang
jspang.skill('web'); // 输出:jspang:Skill:web
console.log(jspang.add()); // 3

// 类的继承(extends关键字)
class htmler extends Coder {}
let pang = new htmler;
pang.name('技术胖'); // 输出:技术胖(继承了Coder类的方法)

三、ES6 必背面试考点(小白必记)

学会以上知识点,日常开发足够用了,但如果要面试,这几个考点一定要记牢,避免踩坑!

  • let/const 和 var 的区别(变量提升、重复声明、块级作用域)
  • 箭头函数和普通函数的区别(this 指向、arguments、不能作为构造函数)
  • Promise 的三种状态(pending、fulfilled、rejected)及链式调用
  • Set 和 Array 的区别(去重、无索引)
  • Map 和对象的区别(键的类型、遍历方式)

四、最后说几句掏心窝的话

很多小白觉得 ES6 难,其实是因为一开始就啃复杂的概念,忽略了“实操”。ES6 的核心是“简化代码、提高效率”,所有知识点都围绕这个核心,只要多敲代码、多练案例,3-7 天就能掌握核心用法!

这篇文章整理了 ES6 最常用、最核心的知识点,代码可直接复制练习,建议收藏起来,遇到不会的就翻一翻,慢慢就熟练了~

如果觉得有用,记得点赞+收藏,关注我,后续更新更多前端小白干货,一起从入门到精通!💪

SDD 实战:用 Claude Code + OpenSpec,把 AI 编程变成“流水线”

作者 zhEng
2026年4月13日 10:03

一、什么是 OpenSpec?

OpenSpec 指的是一个规范驱动开发SDD(Spec-Driven Development) 的范式,为AI编码提供了“规格说明书”,把AICoding从“凭感觉写代码”提升到“按规格任务执行”的高度,告别“开盲盒”式的AI编程。

一句话定义:

在写任何一行代码之前,先定义一份“AI可执行的规格说明书(Spec)”

它的核心思想是:

  • 人类负责:定义规则(Spec)
  • AI 负责:执行规则(生成代码)

(1)什么是规范驱动开发(SDD)?

规范驱动开发(Spec-Driven Development, SDD)是一种软件开发方法论,其核心理念是:

  • 规范定义行为:系统的行为由规范(Specification)明确定义
  • 代码实现规范:代码是对规范的实现,而非规范的替代
  • 规范驱动变更:所有变更都从规范变更开始
  • 规范即文档:规范既是需求文档,也是设计文档

(2)和传统开发有什么不同?

方式 核心输入 AI行为
Prompt 编程 自然语言
OpenSpec 结构化规范 执行

从“描述需求” → “定义系统行为”

二、为什么需要 OpenSpec?

2.1 传统 AI 编程的核心问题

AI 编程助手(如 Claude / Copilot)存在几个致命缺陷:

  • ❌ 模糊输入 → 不稳定输出
  • ❌ 无法形成“系统级约束”
  • ❌ 无版本追踪(改了啥说不清)
  • ❌ 上下文一长就失控
  • ❌ 遗漏重要功能 & 添加了不必要的功能

2.2 OpenSpec 的解决方案

OpenSpec 通过“规范驱动”解决这些问题:

  • ✅ 明确共识:编码前锁定需求
  • ✅ 结构化管理:所有规范集中管理
  • ✅ 可审查:Spec 可读、可评审
  • ✅ 可执行:AI根据确定的需求生成代码
  • ✅ 可追踪:所有变更都有历史

❗ 其本质就是: AI 不再自由发挥,而是严格执行 Spec

三、快速开始 OpenSpec

3.1 环境准备

Node.js >= 20.19.0

全局安装

npm install -g @fission-ai/openspec@latest

验证是否安装成功:

openspec --version

image.png

3.2 初始化项目

cd openspec-demo
openspec init

image.png

初始化过程中会让你选择 AI 编程工具(推荐 Claude Code)。

image.png 完成后会生成核心目录:

image.png

openspec/
├── specs/        # 当前系统规范(源真相)
├── changes/      # 变更提案
└──── archive/      # 历史归档

四、OpenSpec 核心能力(Skills)

4.1 openspec-propose(发起变更)

  • 核心作用:发起一个变更提案

  • 做什么:

    • openspec/changes/ 下创建一个独立变更目录
    • 引导你编写变更说明(proposal.md) :为什么改、改什么、影响范围
    • 生成待完善的规格文档(spec),先和AI对齐需求,在写代码
  • 场景:

    • 新增功能
    • 重构模块
    • 修复重大问题前的需求对齐

4.2 openspec-explore(分析系统)

  • 核心作用:探索与分析当前规范与变更

  • 做什么:

    • 读取 openspec/specs/ 里的现有规范,帮你理解系统当前行为
    • 分析待处理的变更提案,评估影响范围、依赖关系
    • 辅助你细化方案、拆分任务,避免开发时偏离规范
  • 适用场景:

    • 开发前做技术调研、
    • 理解现有系统、
    • 评估变更风险

4.3 openspec-apply-change(生成代码)

  • 核心作用:将规范变更落地到代码实现

  • 做什么:

    • 读取指定变更提案的规范文档
    • 引导 Claude 按规范生成 / 修改代码,严格对齐 spec
    • 确保代码实现与规范完全一致,避免 “写的和想的不一样”
  • 适用场景:

    • 规范定稿后
    • 正式开发 / 迭代代码阶段

4.4 openspec-archive-change(归档)

  • 核心作用:归档已完成的变更,更新项目规范

  • 做什么:

    • 将已实现的变更规范合并到 openspec/specs/ (项目 “源真相”)
    • 把变更目录移动到 openspec/changes/archive/ 归档
    • 生成交付记录,让项目规范始终保持最新状态
  • 适用场景:代码开发完成、测试通过后,正式纳入项目规范

4.5 整体工作流程

  1. propose → 定义需求,定义规范
  2. explore → 分析影响
  3. apply-change → 按照规范生成代码
  4. archive → 更新规范,归档

这其实就是:把软件开发变成“规范驱动流水线”

五、实战:用 OpenSpec + Claude Code 对TodoList进行优化

这是上一篇文章使用Claude Code实现的TodoList

Claude Code 入门实战:从安装配置到真实项目落地

image.png

本次需求:

  1. 待办事项的列表改为使用Table展示,并且支持批量改变完成状态和删除功能;
  2. 在列表中增加创建时间和更新时间两个字段,展示格式为YYYY-MM-DD hh:mm:ss
  3. 现状:添加相同的待办事项可以添加成功;期望:不允许添加重复的待办事项,并给出存在重复的待办事项提示;
  4. 改为Table展示后再调整下页面的样式,待办事项清单的宽度以及背景颜色;

Step 1:通过 /openspec-propose调用openspec的skills

  • /openspec-propose提交需求后,系统自动在openspec/change目录下创建了本次需求的独立目录enhance-todo-list,这里的目录名称可以理解为就是本次的需求ID
  • 目录自动生成标准化需求文档,支持反复评审打磨,确保需求清晰,边界明确后再进入开发阶段,避免需求存在偏差

image.png

创建proposal.md提案文件

## Why

当前待办事项应用使用List组件展示,功能较为基础,不支持批量操作。同时,缺少对重复事项的校验机制,以及用户无法直观查看待办事项的创建和更新时间。这些限制降低了应用的用户体验和管理效率。

## What Changes

- **UI组件升级**: 将List组件替换为Table组件,支持更丰富的展示和交互
- **批量操作**: 新增批量改变完成状态和批量删除功能
- **时间字段增强**: 添加创建时间(createdAt)和更新时间(updatedAt)字段,格式化为 `YYYY-MM-DD hh:mm:ss`
- **重复校验**: 添加待办事项内容去重机制,防止重复添加

## Capabilities

### New Capabilities
- `batch-todo-operations`: 批量操作待办事项(批量完成/取消完成、批量删除)
- `todo-time-tracking`: 待办事项时间记录和展示(创建时间、更新时间)
- `todo-duplicate-validation`: 待办事项重复性校验

### Modified Capabilities
- `todo-crud`: 基础待办事项增删改查(添加重复校验到创建操作)

## Impact

- **代码变更**:
  - `src/components/TodoList.tsx`: 重构为Table组件,添加批量操作逻辑
  - `src/types/todo.ts`: 添加updatedAt字段
  - 新增依赖: `dayjs` 时间处理库

- **API变更**:
  - addTodo: 添加重复校验逻辑
  - 新增: batchToggleTodos、batchDeleteTodos 方法

- **用户体验**:
  - 提供更高效的批量操作能力
  - 更清晰的时间信息展示
  - 避免重复待办事项的创建

  • openspec/change/enhance-todo-list/specs 这个文件里面的内容可以理解为是本次需求的测试用例文件。
  • design.md & tasks.md是根据需求创建的设计文档和将需求拆解为一个个的Task文档
  • 这里就需要我们去确认这个task.md文档中拆解的task是否合理,是否可以满足我们的需求,在后续apply的时候会去执行文档中所有的task

image.png

Step 2: 自动化生成代码,上述文档确认完成后,执行指令: /openspec-apply-change 需求ID

系统将会自动按照tasks.md中的任务清单,逐个执行任务

image.png

完成需求后页面效果: image.png

查看实现的代码,整个过程中几乎没有“手写业务代码”,而是把精力放在“定义系统行为”上面。

这就是OpenSpec传统 AI 编程最大的不同。 image.png

Step 3: 执行 /openspec-archive-change 需求ID将本次的迭代的需求进行归档操作,方便后续追溯

  • 执行完本次的需求文件夹会被移动到openspec/changes/archive/日期+需求ID目录下

image.png

六、总结

OpenSpec 的意义,不只是一个工具,更像是一种开发范式的转变:

从“人写代码,AI辅助”
到“人定义系统,AI负责实现”

在这种模式下:

  • 代码不再是“源真相”,规范才是
  • AI 不再是“猜需求”,而是“执行规则”
  • 开发过程从“不断试错”,变成“按规范推进”

这背后,其实是软件工程的一次“回归”:

👉 回归到“用明确的约束定义系统行为”

当 AI 编码能力越来越强,真正拉开差距的,不再是“谁写代码更快”,而是:

谁能定义出更清晰、更严谨的系统规范

React 文件处理:上传、拖放区与对象 URL

作者 AI划重点
2026年4月13日 09:55

任何稍有规模的应用最终都要处理文件。个人资料编辑页要传头像。笔记应用要附加图片。CSV 导入器要拖放区。相册要在客户端生成缩略图。而每一个这样的功能都要从零开始重做一遍——因为 React 里的文件处理同时涉及三套浏览器 API(<input type="file">、Drag and Drop API、URL.createObjectURL),再加上 React 本身的 ref 和 effect 机制——大多数开发者每次都从头把它们拼一遍。

本文将带你过一遍每个 React 应用迟早都会遇到的四个文件处理基本能力:一个不需要在 DOM 里渲染隐藏 <input> 的文件选择器、一个能接收拖入文件的拖放区、一个不会泄漏内存的对象 URL 助手,以及一个按需加载第三方库的脚本标签加载器。每一个我们都会先写出手动实现,让你看清底层在做什么,然后再换成 ReactUse 里专门的 Hook。最后我们会把四个 Hook 组合成一个完整的照片上传组件,集挑选、拖放、预览和按需加载图片库于一身。

1. 不用隐藏 input 也能选文件

手动实现

React 中传统的文件选择写法看起来人畜无害,但暗藏不少坑:

import { useRef, useState } from "react";

function ManualFilePicker() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<FileList | null>(null);

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept="image/*"
        style={{ display: "none" }}
        onChange={(e) => setFiles(e.target.files)}
      />
      <button onClick={() => inputRef.current?.click()}>
        选择图片
      </button>
      {files && <p>已选 {files.length} 个文件</p>}
    </div>
  );
}

它能跑,但只要你想用第二次,缝合的痕迹就藏不住了。隐藏的 <input> 仍然在你的渲染树里,你的样式重置必须考虑它的存在。重置选中状态需要写 inputRef.current.value = ""——这种命令式的副作用,React 的 lint 规则会跳出来警告你。要是你想在异步处理逻辑里 await 用户的选择(比如想在一个处理文件的 async handler 里),你还得自己造一个一次性的 promise。

而且你没法在同一个页面上重复使用同一个组件两次而不让 ref 互相打架。如果用户连续选择同一个文件,第二次 change 事件根本不会触发——这是历代 React 开发者都踩过的著名陷阱。

ReactUse 的方式:useFileDialog

useFileDialog 把整个 input 元素从渲染树里抬出去,交给你一个 [files, open, reset] 的元组:

import { useFileDialog } from "@reactuses/core";

function ImagePicker() {
  const [files, open, reset] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  return (
    <div>
      <button onClick={() => open()}>选择图片</button>
      <button onClick={reset} disabled={!files}>
        重置
      </button>
      {files && (
        <ul>
          {Array.from(files).map((file) => (
            <li key={file.name}>
              {file.name} —— {(file.size / 1024).toFixed(1)} KB
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

手动版本忽略的三件小事,但都很重要:

  1. 没有隐藏 DOM。input 在内存里创建,不在你的渲染树里。组件输出就是按钮本身。
  2. 每次调用都能传参。在 open() 上直接传选项,可以覆盖 Hook 级别的默认值。想让同一个选择器既能选文档又能选图片?调用时再传 accept 就行。
  3. 真正的重置reset() 同时清空 React state 和底层 input,所以同一个文件可以再选一次。

open() 函数还会返回一个 promise,resolve 时给你已选的文件。这让异步流程清爽得多:

const handleUpload = async () => {
  const picked = await open();
  if (!picked) return;
  await uploadAll(Array.from(picked));
};

你不再需要把逻辑切分到 onChange 和按钮的点击处理函数之间。选择器就是一个可以 await 的函数。

2. 拖放文件区

手动实现

拖放是那种"教程里看着简单,生产环境里裂得稀碎"的 API。最直白的版本:

function ManualDropZone({ onFiles }: { onFiles: (f: File[]) => void }) {
  const [over, setOver] = useState(false);

  return (
    <div
      onDragOver={(e) => {
        e.preventDefault();
        setOver(true);
      }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setOver(false);
        onFiles(Array.from(e.dataTransfer.files));
      }}
      style={{
        border: over ? "2px solid blue" : "2px dashed gray",
        padding: 40,
      }}
    >
      把文件拖到这里
    </div>
  );
}

这个版本看似没问题,直到用户拖到子元素上时一切都崩了。光标一踏进子元素,浏览器就在父元素上触发 dragleave,尽管从逻辑上看文件还在区域内。你的边框开始闪烁,over state 变成谎言。要正确修复它,你得用计数器跟踪 dragenterdragleave,每次离开就减一,只有当计数器归零时才认定文件"离开"了。还得记得在 dragover 上调 preventDefault——否则 drop 根本不会触发——并且记住 dataTransfer.filesFileList 而不是数组。

大多数生产环境里的拖放区都做错了。闪烁就是破绽。

ReactUse 的方式:useDropZone

useDropZone 替你跳完了这套计数器舞蹈:

import { useRef } from "react";
import { useDropZone } from "@reactuses/core";

function CsvDropZone() {
  const dropRef = useRef<HTMLDivElement>(null);
  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    const csvs = files.filter((f) => f.name.endsWith(".csv"));
    console.log("拖入的 CSV:", csvs);
  });

  return (
    <div
      ref={dropRef}
      style={{
        border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
        background: isOver ? "#eff6ff" : "transparent",
        padding: 60,
        borderRadius: 12,
        textAlign: "center",
        transition: "all 120ms ease",
      }}
    >
      <p style={{ margin: 0 }}>
        {isOver ? "松开以上传" : "把 CSV 文件拖到这里"}
      </p>
    </div>
  );
}

注意 API 本质上就是 (target, onDrop) => isOver。就这么简单。Hook 内部处理 dragenter/dragover/dragleave/drop,维护进入/离开计数器,让子元素不会破坏高亮,阻止浏览器默认的"在新标签页打开"行为,最后把一个 boolean 还给你来驱动样式。

回调收到的是 File[] | null——null 代表一次空拖放(没错,某些浏览器在用户拖入非文件内容时确实会触发)。你的处理函数可以一次判断后就干净地退出。

3. 用对象 URL 预览文件

手动实现

拿到 File 之后,你通常想把它展示给用户看。浏览器给了你 URL.createObjectURL(blob),可以把任何 blob 变成一个临时 URL,扔进 <img><video> 就能用。代价是:你创建的每一个 URL 都会占内存,必须记得用完调 URL.revokeObjectURL——否则就泄漏了。在 React 里,"用完"通常意味着"组件卸载或文件变化时",这正是 effect 存在的意义,也正是开发者最容易忘记的事情:

function ManualImagePreview({ file }: { file: File | null }) {
  const [url, setUrl] = useState<string>();

  useEffect(() => {
    if (!file) {
      setUrl(undefined);
      return;
    }
    const next = URL.createObjectURL(file);
    setUrl(next);
    return () => URL.revokeObjectURL(next);
  }, [file]);

  if (!url) return null;
  return <img src={url} alt={file?.name} />;
}

这是对的,但是那种"再不小心改一笔就漏的对"。清理函数和 createObjectURL 调用要永远成对存在。多加一个条件 return 或者忘了一个依赖,就会出现一个只有在长会话里才暴露的 bug。

ReactUse 的方式:useObjectUrl

useObjectUrl 是那段 effect 的单行版:

import { useObjectUrl } from "@reactuses/core";

function ImagePreview({ file }: { file: File }) {
  const url = useObjectUrl(file);
  if (!url) return null;
  return (
    <img
      src={url}
      alt={file.name}
      style={{ maxWidth: 200, borderRadius: 8 }}
    />
  );
}

Hook 接管了生命周期。当 file prop 变化时,它会回收旧 URL 并创建新 URL。组件卸载时,它会回收最后一个。你不可能忘记清理,因为你压根就没写过它。

4. 按需加载第三方脚本

手动实现

有时候你想处理的文件,对应的库太大或太冷门,不值得放进主包。图片裁剪库、PDF 解析器、OCR 引擎、视频转码器——它们都是几十 MB 的体积,对那些从不上传文件的用户来说一文不值。你只想在第一个文件到来之后才付出这个代价。

在 React 里手动加载脚本标签本身就是一道菜谱:

function loadScript(src: string): Promise<void> {
  return new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) {
      resolve();
      return;
    }
    const el = document.createElement("script");
    el.src = src;
    el.async = true;
    el.onload = () => resolve();
    el.onerror = () => reject(new Error(`加载失败 ${src}`));
    document.head.appendChild(el);
  });
}

function ManualImageProcessor() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    loadScript("https://cdn.example.com/heavy-image-lib.js")
      .then(() => setReady(true))
      .catch(console.error);
    // 没有清理 —— 一旦加载就保留
  }, []);

  return ready ? <Editor /> : <p>正在加载编辑器...</p>;
}

这覆盖了正常路径,但忽略了乱七八糟的情况:如果两个组件同时请求同一个脚本(竞态条件)怎么办?如果脚本加载失败你想重试怎么办?如果你想在组件消失时主动卸载它怎么办?

ReactUse 的方式:useScriptTag

useScriptTag 给你的就是你本来要写的那些原语,但边界情况都已经处理好:

import { useScriptTag } from "@reactuses/core";

function HeavyImageEditor() {
  const [, status, , unload] = useScriptTag(
    "https://cdn.example.com/image-editor.js",
    () => console.log("编辑器库已就绪"),
    { manual: false, async: true },
  );

  if (status === "loading") return <p>正在下载编辑器...</p>;
  if (status === "error") return <p>编辑器加载失败</p>;
  if (status !== "ready") return null;

  return <ImageEditorComponent onClose={unload} />;
}

四样白送的好处:

  1. 单例行为。同一个脚本 URL 被请求两次,Hook 会去重——没有竞态,没有重复加载。
  2. 状态机idle/loading/ready/error 让你在每一步都能渲染恰当的内容。
  3. 手动控制。设置 manual: true,脚本要等你显式调用返回的 load() 才会加载——非常适合"首次交互时再加载"的模式。
  4. 卸载。调用 unload() 可以把 script 标签从 document 里移除。如果你想在用户关闭编辑器后把那个庞大的库从内存里清掉,这就派上用场了。

全部组合:照片上传组件

现在我们把四个 Hook 组合成一个组件:一个允许用户挑选或拖入图片、即时预览、并在第一次需要时延迟加载一个假想的客户端图片缩放库的照片上传组件。

import { useRef, useState } from "react";
import {
  useFileDialog,
  useDropZone,
  useObjectUrl,
  useScriptTag,
} from "@reactuses/core";

interface QueuedImage {
  file: File;
  id: string;
}

function Thumbnail({ image }: { image: QueuedImage }) {
  const url = useObjectUrl(image.file);
  return (
    <figure
      style={{
        margin: 0,
        padding: 8,
        background: "#f8fafc",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {url && (
        <img
          src={url}
          alt={image.file.name}
          style={{
            width: 120,
            height: 120,
            objectFit: "cover",
            borderRadius: 4,
          }}
        />
      )}
      <figcaption
        style={{
          marginTop: 6,
          fontSize: 12,
          maxWidth: 120,
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
        }}
      >
        {image.file.name}
      </figcaption>
    </figure>
  );
}

function PhotoUploadWidget() {
  const [queue, setQueue] = useState<QueuedImage[]>([]);
  const [shouldLoadResizer, setShouldLoadResizer] = useState(false);
  const dropRef = useRef<HTMLDivElement>(null);

  const [, openPicker, resetPicker] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    addFiles(files);
  });

  const [, resizerStatus] = useScriptTag(
    "https://cdn.example.com/image-resize.js",
    () => console.log("缩放器已就绪"),
    { manual: !shouldLoadResizer },
  );

  const addFiles = (files: File[]) => {
    const newImages = files
      .filter((f) => f.type.startsWith("image/"))
      .map((file) => ({
        file,
        id: `${file.name}-${file.lastModified}-${Math.random()}`,
      }));
    setQueue((prev) => [...prev, ...newImages]);
    if (newImages.length > 0) setShouldLoadResizer(true);
  };

  const handlePick = async () => {
    const picked = await openPicker();
    if (picked) addFiles(Array.from(picked));
  };

  const clearAll = () => {
    setQueue([]);
    resetPicker();
  };

  return (
    <div style={{ maxWidth: 720, fontFamily: "system-ui, sans-serif" }}>
      <div
        ref={dropRef}
        style={{
          border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
          background: isOver ? "#eff6ff" : "#ffffff",
          padding: 48,
          borderRadius: 16,
          textAlign: "center",
          transition: "all 120ms ease",
        }}
      >
        <p style={{ marginTop: 0, fontSize: 18 }}>
          {isOver ? "松开即可上传" : "把照片拖到这里"}
        </p>
        <button
          onClick={handlePick}
          style={{
            padding: "8px 16px",
            borderRadius: 8,
            border: "1px solid #3b82f6",
            background: "#3b82f6",
            color: "white",
            cursor: "pointer",
          }}
        >
          或从设备中选择
        </button>
      </div>

      <div
        style={{
          marginTop: 16,
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <span style={{ fontSize: 14, color: "#64748b" }}>
          已排队 {queue.length} 张图片
          {shouldLoadResizer && ` —— 缩放器:${resizerStatus}`}
        </span>
        {queue.length > 0 && (
          <button
            onClick={clearAll}
            style={{
              padding: "6px 12px",
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              background: "white",
              cursor: "pointer",
            }}
          >
            全部清空
          </button>
        )}
      </div>

      {queue.length > 0 && (
        <div
          style={{
            marginTop: 16,
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
            gap: 12,
          }}
        >
          {queue.map((image) => (
            <Thumbnail key={image.id} image={image} />
          ))}
        </div>
      )}
    </div>
  );
}

四个 Hook,四个职责,互不重叠:

  • useFileDialog 负责"点击挑选"流程,并提供可 await 的 promise
  • useDropZone 处理拖放,并解决子元素引发的边框闪烁
  • useObjectUrl 为每个缩略图生成并回收预览 URL,绑定到组件生命周期
  • useScriptTag 只在第一张图片到来后延迟加载缩放库,并且整个会话只加载一次

组合很自然,因为每个 Hook 只做一件事。Hook 之间不共享 ref,effect 不会级联。你最终发布的组件大概 100 行,大部分是标签和样式,那些棘手的浏览器底层活计被藏在已经经过测试和 SSR 加固的 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useFileDialog —— 打开文件选择器,无需在 DOM 中渲染隐藏的 input
  • useDropZone —— 跟踪文件拖入元素的状态,正确处理子元素事件
  • useObjectUrl —— 为 File 和 Blob 创建并自动回收 URL
  • useScriptTag —— 动态加载外部脚本,带状态跟踪和卸载支持
  • useEventListener —— 声明式地附加事件监听器,可用于自定义上传进度事件
  • useSupported —— 响应式地检查浏览器是否支持某个 API

ReactUse 提供了 100+ 个 React Hook。全部探索 →

JS手撕:手写Koa中间件与Promise核心特性

作者 Wect
2026年4月13日 09:16

在前端开发中,Koa框架的洋葱模型、Promise的各类静态方法以及异步流程控制,是每个开发者必须掌握的核心知识点。它们看似独立,实则底层逻辑高度关联——都是为了解决异步代码的可读性、可维护性问题。本文将从实战出发,手把手拆解核心代码,用“通俗解释+专业剖析”的方式,让你不仅能看懂手写代码,更能理解背后的设计思想。

一、手写Koa中间件调用(洋葱模型):理解“层层嵌套,反向回流”

用过Koa的同学都知道,它的中间件执行机制被称为“洋葱模型”——就像剥洋葱一样,中间件会从外到内依次执行,执行到最内层后,再从内到外反向执行。这种机制的核心价值的是:让中间件既能处理请求进入时的逻辑(如日志记录、权限校验),也能处理响应返回时的逻辑(如统一异常处理、响应格式化)。

1.1 核心代码实现(可直接运行)

function koa() {
  // 存放所有通过 app.use() 注册的中间件函数
  const middlewares = []
  const app = async (ctx) => {
    // 从第0个中间件开始执行调度
    await dispatch(0, ctx)
  }

  // 注册中间件的方法:将中间件存入数组
  app.use = (middleware) => {
    middlewares.push(middleware)
  }

  // 核心调度函数:递归执行中间件
  const dispatch = async (index, ctx) => {
    // 终止条件:所有中间件都执行完毕,直接返回
    if (index === middlewares.length) return

    // 获取当前索引对应的中间件
    const middleware = middlewares[index]
    // 执行中间件:第二个参数是next函数,调用next()即执行下一个中间件
    await middleware(ctx, () => dispatch(index + 1, ctx))
  }
  return app
}

// 1. 创建 app 实例
const app = koa()

// 2. 注册 3 个中间件(模拟真实开发中的分层逻辑)
app.use(async (ctx, next) => {
  console.log('【中间件 1 开始】—— 日志记录:请求进入')
  console.log('请求URL:', ctx.req.url)
  
  await next() // 放行,执行下一个中间件(核心:交出执行权)
  
  console.log('【中间件 1 结束】—— 日志记录:响应返回')
  console.log(6)
})

app.use(async (ctx, next) => {
  console.log('  【中间件 2 开始】—— 权限校验:通过')
  console.log(2)
  
  await next() // 放行,执行下一个中间件
  
  console.log(5)
  console.log('  【中间件 2 结束】—— 响应处理:添加响应头')
})

app.use(async (ctx, next) => {
  console.log('    【中间件 3 开始】—— 业务逻辑:处理请求')
  console.log(3)
  
  await next() // 没有更多中间件,直接返回(执行终止)
  
  console.log(4)
  console.log('    【中间件 3 结束】—— 业务逻辑:返回结果')
})

// 3. 模拟请求上下文(ctx:Koa的核心,封装请求和响应信息)
const ctx = {
  req: { url: '/' },
  res: {}
}

// 4. 启动执行
app(ctx).then(() => {
  console.log('\n所有中间件执行完毕!')
})

1.2 核心原理拆解(通俗+专业)

通俗理解

把每个中间件想象成一个“关卡”,请求要经过所有关卡才能到达最核心的业务逻辑(中间件3);处理完业务逻辑后,响应要再反向经过所有关卡,才能返回给客户端。比如:

请求进入 → 中间件1(记录日志)→ 中间件2(权限校验)→ 中间件3(处理业务)→ 中间件2(处理响应)→ 中间件1(记录响应日志)→ 响应返回

专业剖析

  • 中间件存储:用数组middlewares存储所有通过app.use()注册的中间件,保证执行顺序与注册顺序一致。

  • 调度函数dispatch:递归实现中间件的依次执行,index参数控制当前执行的中间件索引,当index等于中间件数组长度时,递归终止(最内层执行完毕)。

  • next函数:本质是dispatch(index+1, ctx)的封装,调用next()就相当于“交出执行权”,让下一个中间件执行;await next()则保证“下一个中间件执行完毕后,再继续执行当前中间件的后续逻辑”,这是洋葱模型反向回流的关键。

  • ctx上下文:统一封装请求(req)和响应(res)信息,所有中间件共享同一个ctx,实现数据传递(比如中间件1存储的用户信息,中间件3可以直接使用)。

1.3 执行结果与验证

运行上述代码,控制台输出如下(完美匹配洋葱模型):

【中间件 1 开始】—— 日志记录:请求进入
请求URL: /
2
  【中间件 2 开始】—— 权限校验:通过
3
    【中间件 3 开始】—— 业务逻辑:处理请求
4
    【中间件 3 结束】—— 业务逻辑:返回结果
5
  【中间件 2 结束】—— 响应处理:添加响应头
6
【中间件 1 结束】—— 日志记录:响应返回

所有中间件执行完毕!

二、手写简易co模块:自动执行Generator函数(告别手动.next())

在async/await出现之前,Generator函数是解决异步回调地狱的重要方案,但它有一个痛点:需要手动调用.next()方法才能逐步执行,非常繁琐。co模块的核心作用就是“自动执行Generator函数”,它会自动遍历Generator的迭代器,直到执行完毕。

核心逻辑:Generator函数中,yield后面通常跟Promise(异步操作),co模块会等待Promise完成,将结果传给Generator,再自动执行下一步,直到迭代结束。

2.1 核心代码实现(可直接运行)

// 手写co模块核心函数:自动执行带Promise的Generator
function run(generatorFunc) {
  // 1. 生成Generator迭代器(Generator函数执行后返回迭代器)
  let it = generatorFunc()

  // 2. 第一次启动Generator,获取第一个yield的结果(通常是Promise)
  let result = it.next()

  // 3. 用Promise包装自动执行流程,最终返回一个Promise(方便外部使用.then())
  return new Promise((resolve, reject) => {
    // 递归函数:自动执行下一个yield
    const next = function (result) {
      // 终止条件:Generator执行完毕(done为true),resolve最终返回值
      if (result.done) {
        resolve(result.value)
        return
      }

      // 核心:result.value是yield后面的Promise,等待它完成
      result.value
        .then((res) => {
          // Promise成功:将结果传给Generator(it.next(res)),并继续执行下一步
          let nextResult = it.next(res)
          next(nextResult)
        })
        .catch((err) => reject(err)) // 捕获异步错误,终止执行
    }

    // 启动自动执行流程
    next(result)
  })
}

// 模拟异步请求(真实开发中可能是接口请求、文件读取等)
function fetchData(data) {
  return new Promise(resolve => {
    setTimeout(() => resolve(data), 500) // 延迟500ms模拟异步
  })
}

// 定义一个Generator函数(包含多个异步操作)
function* gen() {
  console.log('开始执行Generator,发起第一个异步请求')
  
  let res1 = yield fetchData('数据1') // 第一个异步请求,等待完成后赋值给res1
  console.log('第一个请求结果:', res1)
  
  let res2 = yield fetchData('数据2') // 第二个异步请求,依赖第一个请求完成
  console.log('第二个请求结果:', res2)
  
  let res3 = yield fetchData('数据3') // 第三个异步请求,依赖第二个请求完成
  console.log('第三个请求结果:', res3)

  return '全部异步请求完成' // Generator最终返回值
}

// 自动执行Generator函数(无需手动调用.next())
run(gen).then(finalVal => {
  console.log('Generator执行完毕,最终返回:', finalVal)
})

2.2 核心原理拆解(通俗+专业)

通俗理解

把Generator函数想象成一个“异步任务清单”,co模块(这里的run函数)就是一个“自动执行者”:它会先拿出清单上的第一个任务(第一个yield),等待任务完成后,把结果记下来,再自动拿出下一个任务,直到所有任务都完成,最后把清单的最终结果返回给你。

专业剖析

  • Generator迭代器:Generator函数(function*)执行后会返回一个迭代器(it),迭代器的next()方法会返回一个对象{ value: ..., done: ... },value是yield后面的值(这里是Promise),done表示Generator是否执行完毕。

  • 自动迭代逻辑:next函数是核心,它接收上一个yield的执行结果,调用it.next(res)将结果传入Generator(赋值给res1、res2等),同时获取下一个yield的结果,递归执行自身,实现自动迭代。

  • Promise封装:run函数最终返回一个Promise,这样外部可以通过.then()获取Generator的最终返回值,也能通过.catch()捕获异步错误,符合异步编程的统一规范。

  • 异步依赖处理:由于每次yield的Promise完成后才会执行下一个yield,因此可以轻松实现异步操作的顺序执行(比如先获取数据1,再用数据1获取数据2)。

2.3 执行结果与验证

运行代码后,控制台每隔500ms输出一次结果,最终输出如下:

开始执行Generator,发起第一个异步请求
第一个请求结果: 数据1
第二个请求结果: 数据2
第三个请求结果: 数据3
Generator执行完毕,最终返回: 全部异步请求完成

三、异步串行/并行加法:理解异步流程控制的核心

异步加法看似简单,却能完美体现“串行”和“并行”两种异步流程控制的差异:

  • 串行:多个异步操作按顺序执行,前一个操作完成后,再执行下一个(适合有依赖关系的场景);

  • 并行:多个异步操作同时执行,无需等待前一个完成(适合无依赖关系的场景,能提升效率)。

我们先实现一个基础的异步加法函数,再基于它分别实现串行和并行求和。

3.1 基础准备:异步加法函数与Promise包装

// 1. 基础异步加法函数(基于回调函数,模拟真实异步场景)
// 接收 a, b 两个数字,callback 是回调函数(错误优先原则:第一个参数是错误,第二个是结果)
const asyncAdd = (a, b, callback) => {
  // 模拟异步操作(延迟 500ms,比如接口请求、计算密集型操作)
  setTimeout(() => {
    // 这里简化处理,不模拟错误,直接返回结果 a+b
    callback(null, a + b);
  }, 500);
};

// 2. 包装函数:将 callback 风格的异步方法,转成 Promise 风格
// 目的:方便在 async/await、Promise 链式调用中使用(更符合现代异步编程规范)
const promiseAdd = (a, b, index) => {
  console.log(`第 ${index} 次计算,参数 ${a}, ${b}`);
  return new Promise((resolve, reject) => {
    // 调用原来的 callback 异步加法
    asyncAdd(a, b, (err, res) => {
      if (err) {
        reject(err); // 出错时,抛出错误
      } else {
        resolve(res); // 成功时,返回计算结果
      }
    });
  });
};

3.2 方式一:异步串行求和(reduce实现)

核心逻辑:用数组的reduce方法,将前一次的计算结果(Promise)作为下一次计算的输入,实现“一步一步按顺序执行”。

// 串行求和:reduce + Promise 链式,实现异步累加
const add1 = (arr) => {
  // reduce参数说明:
  // acc:上一次的Promise结果(累加和),初始值为0
  // val:当前数组要加的数
  // index:当前索引(用于打印日志)
  return arr.reduce((acc, val, index) => {
    // Promise.resolve(acc):确保acc始终是Promise(兼容初始值0)
    return Promise.resolve(acc).then((value) => {
      // 等待上一步累加完成,再和当前值 val 相加
      return promiseAdd(value, val, index + 1); // index+1 是因为索引从0开始
    });
  }, 0); // 初始值 acc = 0(第一次计算:0 + arr[0])
};

// 执行串行求和:1+2+3+...+9,一步一步按顺序执行
add1([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步串行加法结果", sum)
);

3.3 方式二:异步并行求和(递归+Promise.all实现)

核心逻辑:采用“二叉树式”分组,将数组两两分组,每组同时执行加法(并行),再将每组的结果递归分组,直到得到最终总和。这种方式比“所有数字同时相加”更高效(避免过多并发任务)。

// 并行求和:递归 + Promise.all 实现并行归约求和(二叉树式计算)
async function parallelSum(arr) {
  // 递归终止条件:数组只剩一个数,直接返回(无需再计算)
  if (arr.length === 1) return arr[0];

  const tasks = []; // 存放所有并行执行的异步任务

  // 步长为2,将数组两两分组:[1,2] [3,4] [5,6] ... [9,0](奇数长度时,最后一个补0)
  for (let i = 0; i < arr.length; i += 2) {
    // arr[i+1] || 0:处理奇数长度数组(比如最后一个元素9,没有i+1,补0)
    tasks.push(promiseAdd(arr[i], arr[i + 1] || 0));
  }

  // Promise.all:并行执行所有任务,等待所有任务完成后,返回结果数组
  const results = await Promise.all(tasks);

  // 递归:将上一轮的计算结果,继续两两分组并行计算
  return parallelSum(results);
}

// 执行并行求和:速度比串行快(无需等待上一步完成)
parallelSum([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步并行加法结果", sum)
);

3.4 核心差异对比(通俗+专业)

对比维度 异步串行 异步并行
执行顺序 按顺序执行,前一个完成再执行下一个 所有任务同时执行,无顺序依赖
执行时间 总时间 = 所有任务时间之和(本例:9*500ms=4500ms) 总时间 = 最长任务时间 * 递归次数(本例:3*500ms=1500ms)
适用场景 任务有依赖(比如下一个任务需要上一个任务的结果) 任务无依赖(比如多个独立的接口请求、计算任务)
实现核心 Promise链式调用 + reduce Promise.all + 递归归约

3.5 执行结果与验证

串行求和会依次打印每次计算的参数,总耗时约4500ms;并行求和会同时打印多组计算参数,总耗时约1500ms,最终两者的求和结果均为45。

四、手写Promise核心静态方法:理解Promise的底层逻辑

Promise的静态方法(all、race、allSettled、any)是异步流程控制的常用工具,它们的底层逻辑都基于Promise的核心特性——状态不可逆(pending→fulfilled/rejected)。下面我们逐个手写实现,拆解它们的核心规则。

4.1 手写Promise.all:“全部成功才成功,一个失败就失败”

核心规则:接收一个Promise数组,只有所有Promise都成功(fulfilled),才返回所有结果的数组;只要有一个Promise失败(rejected),就立即返回该失败原因,终止执行。

// 手写实现 Promise.all 核心方法
function myPromiseAll(promiseArr) {
  // 返回一个新的 Promise(外部可以通过.then()/.catch()获取结果)
  return new Promise((resolve, reject) => {
    const len = promiseArr.length;    // 传入的 Promise 数组长度
    const result = [];                // 存放所有成功结果的数组(按原数组顺序)
    let count = 0;                    // 记录已经成功完成的任务数量

    // 边界处理:如果传入空数组,直接resolve空结果
    if (!len) {
      resolve(result);
      return; // 必须加return,防止后续代码继续执行
    };

    // 遍历所有promise(用entries()获取索引,保证结果顺序与输入一致)
    for (const [i, p] of promiseArr.entries()) {
      // Promise.resolve(p):包装非Promise值(比如普通数字、字符串),统一处理成Promise
      Promise.resolve(p).then(
        (value) => {
          // 成功:按原数组索引存入结果(确保顺序正确)
          result[i] = value;
          count++; // 成功数 +1

          // 所有任务都成功 → 调用resolve,返回结果数组
          if (count === len) {
            resolve(result);
          }
        },
        (reason) => {
          // 任何一个任务失败 → 立刻reject,终止所有任务(失败优先)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败'));

// 测试1:全部成功
myPromiseAll([p1, p2]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));
// 测试2:有一个失败
myPromiseAll([p1, p3]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));

4.2 手写Promise.race:“谁先完成,就返回谁”

核心规则:接收一个Promise数组,不管是成功还是失败,只要有一个Promise先完成(状态变为fulfilled或rejected),就立即返回该结果,其他任务继续执行,但结果会被忽略。

// 规则:谁最先完成(成功/失败),就返回谁
function myPromiseRace(promiseArr) {
  // 返回一个新的 Promise
  return new Promise((resolve, reject) => {
    // 遍历所有传入的promise
    for (const p of promiseArr) {
      // 统一包装成Promise(处理普通值)
      Promise.resolve(p).then(
        (value) => {
          // 任何一个成功 → 立刻resolve(状态不可逆,后续结果不会覆盖)
          resolve(value);
        },
        (reason) => {
          // 任何一个失败 → 立刻reject(状态不可逆,后续结果不会覆盖)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例:模拟快慢不同的Promise
const fastPromise = new Promise((resolve) => setTimeout(() => resolve('快的Promise'), 100));
const slowPromise = new Promise((resolve) => setTimeout(() => resolve('慢的Promise'), 1000));
const errorPromise = new Promise((_, reject) => setTimeout(() => reject('失败的Promise'), 500));

// 测试1:成功的Promise更快
myPromiseRace([fastPromise, slowPromise]).then(res => console.log('race结果:', res));
// 测试2:失败的Promise更快
myPromiseRace([errorPromise, fastPromise]).then(res => console.log('race结果:', res)).catch(err => console.log('race失败:', err));

4.3 手写Promise.allSettled:“无论成败,都返回所有结果”

核心规则:接收一个Promise数组,等待所有Promise都完成(无论成功还是失败),返回一个包含所有任务结果的数组,每个结果对象包含状态(fulfilled/rejected)和对应的值/原因,不会因为某个任务失败而终止。

// 规则:无论成功/失败,都返回所有结果,不会中断
Promise.allSettled = function (promiseArr) {
  return new Promise(function (resolve) {
    const len = promiseArr.length;  // 数组长度
    const result = [];              // 存放所有结果
    let count = 0;                  // 已完成的promise数量

    // 空数组直接返回空
    if (!len) {
      resolve(result);
      return; // 必须加return!
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // 成功:按标准格式存入(status为fulfilled,value为成功结果)
          result[i] = { status: "fulfilled", value };
          count++;
          if (count === len) { // 全部完成就resolve
            resolve(result);
          }
        },
        (reason) => {
          // 失败:按标准格式存入(status为rejected,reason为失败原因)
          result[i] = { status: "rejected", reason };
          count++;
          if (count === len) { // 失败也要计数,确保所有任务都完成
            resolve(result);
          }
        }
      );
    }
  });
};

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.reject(new Error('失败'));
Promise.allSettled([p1, p2]).then(res => {
  console.log('allSettled结果:', res);
  // 输出:[ {status: 'fulfilled', value: 1}, {status: 'rejected', reason: Error} ]
});

4.4 手写Promise.any:“只要有一个成功就成功,全部失败才失败”

核心规则:接收一个Promise数组,只要有一个Promise成功(fulfilled),就立即返回该成功结果;如果所有Promise都失败(rejected),则抛出一个AggregateError(包含所有失败原因)。

注意:与Promise.race的区别——any只关注成功,只有全部失败才会失败;race不管成功失败,谁先完成就返回谁。

// 规则:
// 1. 只要有一个成功,就返回这个成功结果
// 2. 全部失败 → 抛出 AggregateError 错误
function myPromiseAny(promiseArr) {
  return new Promise(function (resolve, reject) {
    const len = promiseArr.length;
    const errors = []; // 收集所有失败原因(全部失败时使用)
    let count = 0;

    // 空数组:标准规定返回 AggregateError
    if (len === 0) {
      return reject(new AggregateError([], "All promises were rejected"));
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // ✅ 任何一个成功 → 直接返回成功结果(状态不可逆)
          resolve(value);
        },
        (reason) => {
          // ❌ 失败:记录错误,计数+1
          errors[i] = reason;
          count++;

          // 全部都失败了 → 抛出 AggregateError(包含所有失败原因)
          if (count === len) {
            reject(new AggregateError(errors, "All promises were rejected"));
          }
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.reject(new Error('失败1'));
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败2'));

// 测试1:有一个成功
myPromiseAny([p1, p2, p3]).then(res => console.log('any成功:', res)); // 输出2
// 测试2:全部失败
myPromiseAny([p1, p3]).then(res => console.log('any成功:', res)).catch(err => {
  console.log('any失败:', err.message); // 输出"All promises were rejected"
  console.log('所有失败原因:', err.errors); // 输出[Error('失败1'), Error('失败2')]
});

五、Promise并发控制(带超时、重传、失败收集):实战级封装

在真实开发中,我们经常会遇到“大量异步任务需要并发执行,但不能无限制并发”(比如同时调用100个接口,会导致服务器压力过大),同时还需要处理“任务超时”“失败重试”“收集失败任务”等需求。下面我们封装一个实战级的Promise并发控制器,满足这些核心需求。

5.1 核心代码实现(可直接复用)

/**
 * Promise 并发控制器(带 并发限制 + 超时 + 自动重试)
 * @param {Array} tasks - 任务数组,每一项是 () => Promise 的函数(必须是函数,确保懒执行)
 * @param {Object} options - 配置参数(均有默认值)
 * @param {number} options.limit - 最大并发数,默认5
 * @param {number} options.timeout - 单个任务超时时间,默认3000ms
 * @param {number} options.maxRetries - 最大重试次数,默认3次
 * @returns {Promise} 最终返回【所有失败的任务列表】(方便后续重试或排查问题)
 */
function promiseConcurrencyControl(tasks, {
  limit = 5,
  timeout = 3000,
  maxRetries = 3
} = {}) {
  return new Promise((resolve) => {
    const results = [];          // 存储所有任务最终结果(成功/失败)
    const failedTasks = [];      // 存储【最终彻底失败】的任务(重试后仍失败)
    let taskIndex = 0;           // 下一个要执行的任务下标(控制任务顺序)
    let runningCount = 0;        // 当前正在运行的任务数量(控制并发数)

    // ==========================================
    // 核心函数:启动下一个任务(调度器)
    // 只要有任务未执行、且当前并发数未达上限,就持续启动任务
    // ==========================================
    function runNextTask() {
      // 终止条件:所有任务都执行完毕(taskIndex >= 任务总数),且没有正在运行的任务
      if (taskIndex >= tasks.length && runningCount === 0) {
        return resolve(failedTasks); // 返回最终失败的任务列表
      }

      // 循环启动任务:只要还有任务,且并发数未达上限
      while (taskIndex < tasks.length && runningCount < limit) {
        const currentIndex = taskIndex++; // 取当前任务下标(避免并发时下标混乱)
        const task = tasks[currentIndex]; // 取出当前任务(函数)
        runningCount++;                   // 正在运行的任务数 +1

        // 执行任务(带超时、重试逻辑)
        executeTaskWithRetry(task, currentIndex, 0);
      }
    }

    // ==========================================
    // 带【超时】和【自动重试】的任务执行器
    // @param task - 任务函数 () => Promise
    // @param index - 任务下标(用于定位任务)
    // @param retryCount - 当前已经重试的次数(初始为0)
    // ==========================================
    function executeTaskWithRetry(task, index, retryCount) {
      // 1. 创建超时Promise:超过指定时间未完成,直接reject(超时错误)
      const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`Task ${index} timed out after ${timeout}ms`));
        }, timeout);
      });

      // 2. 竞速:任务执行 和 超时监控 谁先完成
      Promise.race([
        task(),                  // 执行真实任务(懒执行,避免提前启动)
        timeoutPromise           // 超时监控
      ])
      .then(result => {
        // ======================
        // 任务执行成功
        // ======================
        results[index] = {
          success: true,
          result,
          retries: retryCount // 记录重试次数(0表示未重试)
        };
        runningCount--; // 正在运行的任务数 -1
        runNextTask();   // 启动下一个任务(维持并发数)
      })
      .catch(error => {
        // ======================
        // 任务失败 / 超时
        // ======================
        if (retryCount < maxRetries) {
          // 还有重试次数 → 立即重试,重试次数+1
          console.log(`Task ${index} 失败(原因:${error.message}),重试 ${retryCount + 1}/${maxRetries}`);
          executeTaskWithRetry(task, index, retryCount + 1);
        } else {
          // 重试次数用完 → 标记为彻底失败,存入失败列表
          const failureInfo = {
            taskIndex: index,       // 任务下标(方便定位)
            error: error.message,   // 失败原因
            retries: maxRetries     // 已重试次数
          };
          failedTasks.push(failureInfo);

          results[index] = {
            success: false,
            ...failureInfo
          };

          runningCount--;
          runNextTask(); // 继续启动下一个任务
        }
      });
    }

    // 启动并发控制(入口)
    runNextTask();
  });
}

// ------------------------------
// 测试工具函数(模拟真实场景中的异步任务)
// ------------------------------

/**
 * 创建测试任务(随机成功/失败,可模拟接口请求)
 * @param {number} id - 任务ID(用于区分)
 * @param {number} successProbability - 成功率(0~1,默认0.7)
 * @param {number} delay - 任务执行延迟(默认1000ms)
 */
function createTestTask(id, successProbability = 0.7, delay = 1000) {
  return () => new Promise((resolve, reject) => {
    setTimeout(() => {
      // 随机成功/失败(模拟接口请求的不确定性)
      if (Math.random() < successProbability) {
        resolve(`Task ${id} 成功`);
      } else {
        reject(new Error(`Task ${id} 执行失败`));
      }
    }, delay);
  });
}

// 生成 10 个测试任务(成功率70%,延迟800ms)
const testTasks = Array.from({ length: 10 }, (_, i) =>
  createTestTask(i + 1, 0.7, 800)
);

// 启动并发控制(配置:最大并发3个,超时1500ms,最多重试2次)
promiseConcurrencyControl(testTasks, {
  limit: 3,         // 最多同时运行3个任务
  timeout: 1500,    // 单个任务超过1.5秒超时
  maxRetries: 2     // 每个任务最多重试2次
})
.then(failedTasks => {
  console.log('\n=== 全部执行完成 ==');
  console.log('最终失败的任务:', failedTasks);
});

5.2 核心功能拆解(实战重点)

  • 并发限制:通过runningCount(当前运行任务数)和limit(最大并发数)控制,只有runningCount < limit时,才会启动新任务,避免并发过多导致的性能问题。

  • 任务调度:runNextTask函数作为调度器,循环启动任务,确保并发数维持在limit以内,同时处理任务执行完毕后的“补位”(启动下一个任务)。

  • 超时控制:通过Promise.race将任务执行与超时监控绑定,超过指定时间未完成的任务,直接视为失败,进入重试逻辑。

  • 自动重试:任务失败后,若重试次数未用完,立即重试,重试次数用完后,标记为彻底失败,存入失败列表。

  • 失败收集:最终返回所有彻底失败的任务列表,包含任务下标、失败原因和重试次数,方便后续排查问题或重新重试。

  • 懒执行:任务数组中的每一项是一个返回Promise的函数,而非直接执行的Promise,确保任务只有在被调度时才会启动,避免提前执行导致的并发混乱。

5.3 应用场景

该并发控制器可直接用于真实开发中的场景,比如:

  • 批量接口请求(比如批量获取用户信息、批量上传文件);

  • 批量处理异步任务(比如批量处理文件、批量发送消息);

  • 需要容错的异步场景(比如部分任务失败后,无需终止全部,只需收集失败任务后续处理)。

六、总结:核心知识点串联

本文讲解的所有内容,核心都是围绕“异步流程控制”展开:

  1. Koa洋葱模型:通过递归调度中间件,实现“请求进入→业务处理→响应返回”的分层逻辑,核心是next函数的执行权移交;

  2. co模块:自动迭代Generator函数,解决手动.next()的繁琐,本质是Promise与Generator的结合;

  3. 异步串/并行:串行适合有依赖的任务,并行适合无依赖的任务,核心是Promise链式调用与Promise.all的运用;

  4. Promise静态方法:all、race、allSettled、any,分别对应不同的异步场景,底层都是基于Promise的状态不可逆特性;

  5. 并发控制:在Promise基础上,增加并发限制、超时、重试等实战功能,解决大量异步任务的高效、稳定执行问题。

掌握这些知识点,不仅能看懂框架底层代码,更能在实际开发中灵活处理各类异步场景,写出更高效、更健壮的代码。

Flex + Grid 混合布局指南

作者 小霍同学
2026年4月13日 09:04

Flex + Grid 混合布局指南

在现代前端开发中,Flex 和 Grid 并不是非此即彼的选择,而是一对强大的组合拳。Flex 擅长一维排列(行或列),Grid 擅长二维布局(行与列同时控制)。混合使用它们,可以同时获得两种布局模型的优点:用 Grid 搭建页面的宏观骨架,用 Flexbox 处理组件内部的微观对齐。

Flex 与 Grid 的核心差异

维度 Flex Grid
维度 一维(行 列) 二维(行 列同时控制)
内容驱动 项目沿主轴分布,由内容撑开 轨道由容器定义,项目放置其中
对齐能力 主轴/交叉轴对齐,强大且灵活 单元格内对齐 + 整个网格对齐
典型用途 导航栏、列表、卡片内元素 整体页面布局、相册网格、表单

一句话总结:

  • 需要精确控制行和列的位置关系 → Grid
  • 需要沿着一个方向灵活排列,或处理未知数量的项目 → Flex

快速决策:何时用 Flex,何时用 Grid

优先使用 Grid 的场景

  • 整体页面结构(header, main, sidebar, footer)
  • 复杂的二维网格(如仪表盘、作品集、图库)
  • 需要显式控制重叠区域(grid-template-areas
  • 轨道尺寸需要基于比例(fr)和内容(minmax())配合

优先使用 Flexbox 的场景

  • 导航栏、工具栏、按钮组
  • 列表项内部(头像 + 文本 + 操作按钮)
  • 居中对齐(特别是垂直居中)需求强烈
  • 项目数量不固定,需要自动换行(flex-wrap
  • 顺序重排(order)的轻量需求

混合策略:宏观 Grid + 微观 Flex

这是最常用的模式:用 Grid 划分大区域,用 Flexbox 安排每个区域内部的内容

┌─────────────────────────────────────┐
│  Header (Grid 区域)                  │
│  └─ 内部用 Flex 排列 Logo + 导航菜单   │
├───────────┬─────────────────────────┤
│ Sidebar   │ Main (Grid 区域)         │
│ (Grid)    │ └─ 卡片列表用 Grid        │
│ └─ 内部用 │   每个卡片内部用 Flex      │
│   Flex 列 │   排列头像/标题/按钮       │
│   表导航  │                          │
├───────────┴─────────────────────────┤
│  Footer (Grid 区域)                  │
│  └─ 内部用 Flex 排列版权与链接         │
└─────────────────────────────────────┘

混合布局的四种实用模式

模式一:Grid 容器内使用 Flex 项目

场景:Grid 定义了宏观区域(如页眉、侧边栏),每个区域内需要水平或垂直排列元素。

<div class="page">
  <header class="header"> ... </header>
  <aside class="sidebar"> ... </aside>
  <main class="content"> ... </main>
  <footer class="footer"> ... </footer>
</div>
.page {
  display: grid;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 250px 1fr;
  gap: 20px;
}

.header {
  grid-area: header;
  display: flex;           /* 内部使用 Flexbox */
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
}

.sidebar {
  grid-area: sidebar;
  display: flex;
  flex-direction: column;  /* 垂直排列导航项 */
  gap: 0.5rem;
}

.footer {
  grid-area: footer;
  display: flex;
  justify-content: center;
  gap: 2rem;
}

模式二:Flex 容器内使用 Grid 项目

场景:外层是 Flex 排列的卡片容器,但每个卡片内部是二维网格布局(如图片区、标题区、描述区)。

<div class="card-list">
  <div class="card">
    <div class="card-image">...</div>
    <div class="card-title">...</div>
    <div class="card-desc">...</div>
    <div class="card-footer">...</div>
  </div>
  <!-- 更多卡片 -->
</div>
.card-list {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
}

.card {
  flex: 280px;              /* 简写,等价于 flex: 1 1 280px */
  display: grid;            /* 卡片内部使用 Grid */
  grid-template-rows: auto auto 1fr auto;
  gap: 12px;
  background: #f5f5f5;
  padding: 1rem;
}

.card-image { grid-row: 1; }
.card-title { grid-row: 2; }
.card-desc { grid-row: 3; }
.card-footer { 
  grid-row: 4;
  display: flex;            /* 甚至可以再嵌套 Flex */
  justify-content: flex-end;
}

模式三:响应式切换布局模式(Grid ↔ Flex)

场景:在宽屏时使用 Grid 展示多列,在窄屏时改为 Flex 垂直堆叠。

.container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}

@media (max-width: 768px) {
  .container {
    display: flex;
    flex-direction: column;
    gap: 20px;
  }
  /* 关键:Flex 子项默认不拉伸宽度,需显式设置 */
  .container > * {
    width: 100%;
  }
}

注意:从 Grid 切换到 Flex 时,Grid 子项默认会拉伸填满列宽,而 Flex 子项默认按内容宽度显示。添加 width: 100% 可使其填满父容器。

模式四:利用 Flex 对齐能力简化 Grid 内部对齐

Grid 虽然提供了 place-items / place-self,但对于单行/单列的简单排列,Flex 往往更简洁。

需求:在 Grid 单元格内让一个按钮水平居中且底部对齐。

.grid-cell {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
}

相比之下,如果只用 Grid 属性,需要写 align-self: end; justify-self: center,且无法轻松实现垂直方向上的多个元素排列(如按钮上方还有文本)。Flexbox 更自然。

最佳实践与注意事项

推荐做法

  1. 从宏观到微观:先用 Grid 设计整体页面结构,再用 Flexbox 处理局部细节。
  2. 利用 gap 统一间距:Grid 和 Flex 都支持 gap,尽量使用而非 margin 来避免外边距折叠问题。
  3. 响应式优先使用 auto-fill / auto-fit:在 Grid 中结合 minmax() 创建弹性网格,减少媒体查询。
  4. 避免过度嵌套:能用一个 Grid 实现二维布局,就不要在外面再套一层 Flex。
  5. 善用 flex: 1 填充剩余空间:在 Grid 单元格内,如果需要某个 Flex 子项占满剩余高度,使用 flex: 1

常见陷阱及解决方案

  • Grid 子项作为 Flex 容器时,min-width: auto 可能导致内容溢出 解决方案:给该 Flex 容器设置 min-width: 0overflow: hidden,允许内容收缩到比默认最小宽度更小。

    .grid-item {
      display: flex;
      min-width: 0;   /* 防止长单词或图片撑开父级 */
    }
    
  • flex 属性在 Grid 项目上无效 Grid 项目的尺寸由 grid-template-columns/rows 决定,设置 flex: 1 不会影响其在网格中的大小。如果希望 Grid 项目内部有弹性填充,请在该项目内部使用 Flex 容器。

  • gap 在 Grid 与 Flex 中的行为差异

    • Grid:gap 在轨道之间添加间隔,间隔尺寸先于 fr 分配扣除,剩余空间再按比例分配。
    • Flex:gap 仅在项目之间添加间隔,不影响主轴对齐(如 space-between 会忽略最后一个 gap)。
  • Flex 换行与 Grid 混用的理解 Grid 容器内的项目默认不会自动换行(除非使用 repeat(auto-fill, ...))。如需类似 Flex 的换行效果,请使用 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))

  • align-self / justify-self 在 Flex 项目中无效 这些属性是 Grid 项目的专用属性,不要错误地应用于 Flex 子项。

兼容性提示

特性 Chrome Firefox Safari Edge IE
Flexbox (无前缀) 29+ 28+ 9+ 12+ 11(部分)
Grid (无前缀) 57+ 52+ 10.1+ 16+ 不支持
gap in Flexbox 84+ 63+ 14.1+ 84+ 不支持
subgrid 117+ 71+ 16+ 117+ 不支持

现状说明gap 在 Flexbox 中的支持现已普及(主流浏览器最新版本均支持),仅需注意 Safari < 14.1 或 iOS < 14.1 的旧设备。subgrid 已得到现代浏览器良好支持,可用于复杂嵌套网格。

对于需要兼容 IE 的项目

  • 不要使用 Grid(或使用降级方案,如 @supports 检测)
  • 完全依赖 Flexbox 配合 float 后备
  • 避免使用 Flexbox 的 gap,改用 margin

推荐使用 Autoprefixer 处理旧版浏览器前缀,并在 Grid 布局中提供简单的 Flex 后备:

.container {
  display: flex;           /* 后备 */
  flex-direction: column;
}

@supports (display: grid) {
  .container {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}

4.响应式系统基础:从发布订阅模式的角度理解 Vue3 的数据响应式原理

作者 Cobyte
2026年4月13日 09:00

前言

我们从前面的文章中知道的所谓发布订阅模式的本质是不管代码结构如何变化,它的核心都是管理对象间的依赖关系,或者说是事件间的依赖关系,一方变化了,所有跟其建立依赖关系的依赖都将得到通知。同时发布者对象既可以是发布者也可以是订阅者,所以我们不能只从代码组织结构去分辨模式,而是从意图去分辨。

Vue2 的数据响应式的实现,在代码结构层面多少是看得出有经典发布订阅模式的架构影子,所以社区里也有人从发布订阅模式角度去分析过,但 Vue3 的数据响应式的实现从代码结构上来看跟所谓标准的发布订阅模式的代码架构差别是很大的。一般社区作者也不从发布订阅模式的角度去分析它的实现原理,那么今天就让我们从发布订阅模式的角度去理解 Vue3 的数据响应式原理吧。

发布订阅模式原理回顾

我们经过前面的学习,我们很容易通过发布订阅模式初步实现 Vue3 的 reactive API,代码如下:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && dep.addSub(activeEffect)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            dep.notify()
            return result
        }
    })
}

我们就可以进行以下测试了:

const proxy = reactive({ author: 'Cobyte' })

// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}

activeEffect = subscriber
subscriber()
activeEffect = null

// 修改
proxy.author = 'coboy'

根据上一篇 Vue2 的数据响应式原理的实践,我们可以做小小的优化:

class Dep {
  // 省略...
-  addSub(sub) {
+  addSub() {
    if (activeEffect) {
-        this.subs.push(sub)
+        this.subs.push(activeEffect)
    }
  }
  // 省略...
}
// 省略...
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            activeEffect && dep.addSub(activeEffect)
+            dep.addSub()
            return Reflect.get(target, key) 
        },
        // 省略... 
    })
}

我们上面 reactive 的实现,每个订阅者还不能进行跟每个对象的属性进行隔离的。什么意思呢?看以下测试代码:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
    console.log(`日期是:${proxy.date}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null

// 修改
proxy.author = 'coboy'

测试结果如下:

D01.png

我们可以看到最后修改 author 属性值的时候,两个订阅者函数都执行了。是因为我们在 getter 进行订阅的时候,把不同属性的订阅者都存储在同一个全局变量中了,而在 Vue2 中把每一个属性的消息代理都通过闭包进行了隔离,也就是每一个属性都拥有属于自己的消息代理,相当于每一个属性都是一个发布者。

而 Vue3 中的 Proxy API 很明显不能通过闭包来进行隔离每个属性的消息代理。那么我们根据前面的发布订阅模式的实践理解,还可以通过给消息代理对象通过添加 key 的方式来让订阅者只订阅自己感兴趣的内容。

那么相关代码修改如下:

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = []
+    this.subs = {}
  }
  // 添加订阅者
-  addSub() {
+  addSub(key) {
+    if (!this.subs[key]) {
+        this.subs[key] = []
+    }
    if (activeEffect) {
-    this.subs.push(sub)
+    this.subs[key].push(activeEffect)
    }
  }
  // 通知订阅者
-  notify() {
+  notify(key)
-    this.subs.forEach(sub => sub())
+    this.subs[key].forEach(sub => sub())
  }
}
const dep = new Dep()
let activeEffect
// reactive
function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub()
+            dep.addSub(key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) { 
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify()
+            dep.notify(key)
            return result
        }
    })
}

我们经过上面的修改再进行测试,我们发现已经可以正确打印我们期待的结果了。

D02.png

我们上面实现的 reactive 函数还存在一个问题,我们现在可以通过 key 来把不同订阅者进行分类,但不同的对象中可能会存在相同的 key,例子如下:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const proxy2 = reactive({ author: 'Cobyte2' })
// 订阅者
const subscriber = () => {
    console.log(`我是:${proxy.author}`)
}
// 订阅者2
const subscriber2 = () => {
    console.log(`我是:${proxy2.author}`)
}
activeEffect = subscriber
subscriber()
activeEffect = subscriber2
subscriber2()
activeEffect = null

// 修改
proxy.author = 'coboy'

测试结果如下:

D03.png

我们发现我们只修改了 proxy.author 的值,但订阅者2 subscriber2 也执行了,这不是我们期待的结果,所以我们还要迭代我们的功能。

我们既然可以添加 key 来让订阅者订阅自己喜欢的内容,那么是否还可以进行增加 key, 来区分不同的对象呢?我们把对象也当成一个 key,也就是在 getter 添加依赖的时候这样操作:dep.addSub(target, key, activeEffect),那么在 setter 的时候这样操作:dep.notify(target, key)。很明显我们可以通过 Map 来把一个对象作为一个 key。

所以我们对消息代理中心做以下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = {}
+    this.subs = new Map()
  }
  // 添加订阅者
-  addSub(key) {
+  addSub(target, key) {
+    let depsMap = this.subs.get(target)
+    if (!depsMap) {
+        depsMap = {}
+        this.subs.set(target, depsMap)
+    }
    
-    if (!this.subs[key]) {
-      this.subs[key] = []
-    }
+    if (!depsMap[key]) {
+        depsMap[key] = []
+    }
     if (activeEffect) {
-    this.subs[key].push(activeEffect)
+    depsMap[key].push(activeEffect)
     }
  }
  // 通知订阅者
-  notify(key) {
+  notify(target, key) {
-    this.subs[key].forEach(sub => sub())
+    const depsMap = this.subs.get(target)
+    if (!depsMap) return
+    const deps = depsMap[key] 
+    deps && deps.forEach(sub => sub())
  }
}

接着我们也去修改 reactive 中相关的地方:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub(key)
+            dep.addSub(target, key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify(key)
+            dep.notify(target, key)
            return result
        }
    })
}

我们重新测试,我们发现打印了如期的结果:

D04.png

桶的数据结构设计?

我们看到通过发布订阅模式去理解 Vue3 的数据响应式原理,理解起所谓依赖数据结构 ,是非常好理解的。我们通过由浅入深的地讲解所谓 数据结构的形成,它的形成是自然而然的形成的,而不是一开始就经过特别精心设计的,它没有那么的神秘,它是由最简单的功能一步步迭代形成的,是非常符合我们的日常开发规律的,因为我们日常的应用也是由最简单的功能开始慢慢迭代成非常复杂的功能。一开始所谓 ,只是一个 Array (可以简单理解为:[]) 的结构,后来我们通过增加 key 来区分不同的订阅者,这行为在发布订阅模式中就是通过 key 来让订阅者只订阅自己感兴趣的内容;增加 key 后, 的数据结构变为 Object -> Array (可以简单理解为:{ key: [] }),再后来我们继续增加响应式对象作为 key,来区分不同的属性,避免不同响应式数据中可能存在相同属性的情况。最后我们的 的数据结构变为 Map -> Object -> Array (可以简单理解为:{ target: { key: [] } })。

我们熟悉 Vue3 源码的同学会知道,所谓 的数据结构跟我们上面还是区别的,那其实都是性能优化迭代的结果,我们也可以继续迭代我们的功能。首先是我们的订阅者是通过 Array 的方式存储的,为了防止重复添加订阅者,我们需要在执行完订阅者函数之后把 activeEffect 变量设置为 null,同时也是为了确保只在副作用函数中读取响应式变量才进行依赖收集。我们可以把订阅者的存储方法改成 Set 的数据结构,因为 Set 具有自动去除重复的功能。

相关代码修改如下:

class Dep {
  // 省略...
  addSub(target, key, sub) {
    // 省略...
    if (!depsMap[key]) {
-        depsMap[key] = []
+        depsMap[key] = new Set()
    }
    if (activeEffect) {
-    depsMap[key].push(activeEffect)
+    depsMap[key].add(activeEffect)
    }
  }
  // 省略...
}

经过上面修改,我们的 结构变成了 Map -> Object -> Set。我们还可以继续优化,我们可以把中间的 Object 改成 Map,因为在频繁增删键值对和存储大量数据的场景下 Map 的性能要比 Ojbect 更好。

class Dep {
  // 省略...
  addSub(target, key, sub) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
-        depsMap = {}
        depsMap = new Map()
        this.subs.set(target, depsMap)
    }
+    let dep = depsMap.get(key)
-    if (!depsMap[key]) {
+    if (!dep) {
-        depsMap[key] = new Set()
+        dep = new Set()
+        depsMap.set(key, dep)
+    }
    if (activeEffect) {
-    depsMap[key].add(activeEffect)
+    dep.add(activeEffect)
    }
  }
  // 通知订阅者
  notify(target, key) {
    const depsMap = this.subs.get(target)
    if (!depsMap) return
-    const deps = depsMap[key] 
+    const deps = depsMap.get(key) 
    deps && deps.forEach(sub => sub())
  }
}

最后我们还可以继续优化的地方就是把存储订阅者的变量 this.subsMap 类型改成 WeakMap 类型。

class Dep {
  constructor() {
    // 订阅者存储中心
-    this.subs = new Map()
+    this.subs = new WeakMap()
  }
}

为什么不采用 WeakMap 而不采用 Map 呢?我们通过下面的一个例子来说明:

const map = new Map()
const weakMap = new WeakMap()

function test() {
    const mapObj = { test: 'mapObj' }
    const weakMapObj = { test: 'weakMapObj' }
    map.set(mapObj, true)
    weakMap.set(weakMapObj, true)
}

test()

console.log('map', map)
console.log('weakMap', weakMap)

我们从打印的结果中可以一目了然地看出两者的区别,WeakMap 对 key 是弱引用的,所谓弱引用就是一旦上下文执行完毕,WeakMap 中 key 对象没有被其他代码引用的时候,垃圾回收器就会把该对象从内存移除。Map 则不会把 key 对象进行移除,这样就会容易导致内存溢出,就算不内存溢出,当数据大的时候,操作性能也会下降,所以 Vue3 源码中就采用了 WeakMap。

最后小结 Vue3 底层源码是使用 WeakMap 和 Map 来构建依赖关系图,具体来说是:

  • targetMap 是一个WeakMap,键是响应式对象(target),值是一个Map(depsMap)。
  • depsMap 的键是对象的属性(key),值是一个Dep(即一个Set),存储了所有依赖该属性的副作用函数。

订阅者中介的实现

我们通过前面的文章对发布订阅模式的学习,可以知道发布者可以抽离一些公共功能统一放到一个中介类中,也就是所谓的事件总线或者消息代理,而订阅者同样也可以进行中介化,从而实现订阅者的多态化。所谓多态就是当不同的对象去执行同一个方法时会产生出不同的状态。我们通过上一篇文章可以知道 Vue2 中所谓的 Watcher 类其实就是订阅者中介,在项目中不同的组件其实底层都是通过 Watcher 类来执行的,而所谓依赖收集,其中收集的是 Watcher,那些响应式数据发生变化后去通知的也是 Watcher,然后再通过 Watcher 去执行具体的组件渲染。

那么 Vue3 的数据响应式也是通过发布订阅模式实现的,那么很自然的也存在订阅者中介。在 Vue3 源码中 ReactiveEffect 类从发布订阅模式的角度理解就是订阅者中介的角色,所以从发布订阅模式的角度理解 Vue3 的数据响应式原理,就非常容易理解为什么要有一个 ReactiveEffect 类了,甚至不用去看具体的实现细节,我们都可以知道 ReactiveEffect 所实现的功能是什么了。

我们知道 Vue2 中的 Watcher 有一个 update 方法,就是在发布者去通知所有订阅者的时候,订阅者统一执行的方法就是 update,那么很明显 ReactiveEffect 也同样需要这样的一个方法,在 Vue3 源码中这个方法叫 run,同样初始化的时候需要接收一个函数作为参数也就是具体订阅者需要做的事情。

ReactiveEffect 的初步实现:

class ReactiveEffect {
    constructor(fn) {
        this._fn = fn
    }
    run () {
        // 根据 Vue2 的数据响应式原理,我们知道在执行具体订阅者函数之前需要把当前订阅者赋值给一个中间变量。
        activeEffect = this
        this._fn()
        // 确保只在副作用函数中读取响应式变量才进行依赖收集
        activeEffect = null
    }
}

然后我们进行测试:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })
const _effect = new ReactiveEffect(() => {
    console.log(`我是:${proxy.author}`)
})
_effect.run()
proxy.author = 'coboy' 

我们可以看到正确打印了结果:

D06.png

同时我们发现 ReactiveEffect 的订阅者函数参数初始化在外部手动执行的,而 Vue2 的 Watcher 中的订阅者函数初始化在 Watcher 内部实例化的时候自动执行的,这个只是设计上区别。

我们把上述实现订阅的过程进行封装一下,那么就是 effect API 了,代码如下:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
}

从发布订阅模式的角度来看本质上 Vue3 的数据响应式实现原理跟 Vue2 的数据响应式原理的实现是一脉相承的。

互为订阅者

我们通过前面文章的学习,我们知道在 Vue2 中会存在发布者中介类 Dep 和订阅者类 Watcher 互为订阅者的情况,场景就是可能会取消某一个副作用函数的中的响应式数据的追踪,比如组件卸载了,那么我们就需要停止组件的依赖追踪。在 Vue3 中自然也存在这种场景,那么也就说在 Vue3 中也存在互为订阅者的情况。但在 Vue3 中的情况又会跟 Vue2 不一样,Vue2 是订阅者 Watcher 类直接订阅发布者中介类 Dep,因为在 Vue2 中每一个 Dep 实例都和一个发布者关联,也就是和每一个属性或者对象进行关联。而在 Vue3 中因为是通过 Proxy API 实现的数据响应式,每一个 Dep 的实例并不对应着具体的属性,所以我们要找到对应具体的属性的记录的变量,其实就是对应 key 的记录变量。

我们再看看 Dep 中关于对应 key 部分的订阅者记录变量部分代码:

class Dep {
  // 省略...
  addSub(target, key) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
        depsMap = new Map()
        this.subs.set(target, depsMap)
    } 
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, deps)
    }
    if (activeEffect) {
      dep.add(activeEffect)
    }
  }
  // 省略...
}

我们可以看到对应每一个 key 的订阅者记录变量是 deps,所以我们只需要把对应的 deps 记录到 ReactiveEffect 中即可。

首先我们修改 ReactiveEffect 类,添加记录变量 deps

class ReactiveEffect {
+    // 记录哪些变量记录了该订阅者,在 Vue2 中则是记录哪些 Dep 记录了该 Watcher
+    deps = []
    // 省略...
}

接着我们在记录响应式数据对象的 key 的消息代理对象的地方把对应的 key 的消息代理对象添加到订阅者 ReactiveEffectdeps 变量中,代码如下:

class Dep {
  // 省略...
  addSub(target, key) {
    // 省略...
    if (activeEffect) {
      deps.add(activeEffect)
+      activeEffect.deps.push(deps)
    }
  }
  // 省略...
}

这样我们就完成了对应 key 的变量对 ReactiveEffect 的订阅,那么有订阅,也就有取消订阅。

取消订阅功能如下:

class ReactiveEffect {
    // 省略...
    // 取消订阅
+    stop () {
+      this.deps.forEach(dep => dep.delete(this))
+    }
}

接着我们再修改 effect API:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
+    return _effect
}

这样我们就可以进行以下测试了:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const _effect = effect(() => {
  console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
_effect.stop()
proxy.author = '掘金签约作者'

打印结果如下:

D06.png

我们看到取消依赖追踪后,我们再去修改响应式数据,我们之前设置的订阅者函数就不再执行了,也就是得不到通知了。

那么停止依赖追踪之后,我又想它继续进行依赖追踪呢?这样我们就需要把 ReactiveEffect 中的 run 方法也返回出来。

我们继续进行 effect API 的功能迭代,新的修改如下:

function effect(fn) {
    const _effect = new ReactiveEffect(fn)
    _effect.run()
+    const runner = _effect.run.bind(_effect)
+    runner.effect = _effect
+    return runner 
}

这样我们就可以在取消依赖追踪后,还可以在某个时机中又恢复依赖追踪了,测试代码如下:

const proxy = reactive({ author: 'Cobyte', date: '2024-03-05' })

const runner = effect(() => {
  console.log(`我是:${proxy.author}`)
})
proxy.author = 'Coboy'
// 取消订阅,也就是取消依赖追踪
runner.effect.stop()
proxy.author = '掘金签约作者'
// 恢复依赖追踪
runner()
proxy.author = '恢复依赖追踪了'

我们可以看到如期打印了我们期待的结果:

D07.png

为什么 Vue3 的发布订阅模式不采用传统代码结构?

我们上面实现 Vue2 的数据响应式原理是很明显采用了发布订阅模式的,因为我们存在一个发布者中介类 Dep,这个代码结构跟传统教学中的发布订阅模式中的代码结构是很相似的。但实际上 Vue3 源码中是不存在发布者中介类的,也就是跟传统发布订阅模式的代码结构是不相同的,那么是否意味着 Vue3 并没有采用发布订阅模式呢?答案是否定的,正如我们前面文章中所说的那样,判断模式不能从代码结构上进行判断,而应该从代码意图。

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = new WeakMap()
  }
  // 添加订阅者
-  addSub(target, key) {
+  track(target, key) {
    let depsMap = this.subs.get(target)
    if (!depsMap) {
        depsMap = new Map()
        this.subs.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    if (activeEffect) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
  // 通知订阅者
-  notify(target, key) {
+  trigger(target, key) {
    const depsMap = this.subs.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(effect => effect.run())
  }
}

上面我们经过对方法名称的修改,我们的代码结构从命名上跟 Vue3 源码有些类似了,我们接着把 Dep 类也去掉:

  // 全局订阅者记录变量
  const targetMap = new WeakMap()
  // 添加订阅者
  function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (!dep) {
      dep = new Set()
      depsMap.set(key, dep)
    }
    if (activeEffect) {
      dep.add(activeEffect)
      activeEffect.deps.push(dep)
    }
  }
  // 通知订阅者
  function trigger(target, key){
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const deps = depsMap.get(key)
    deps && deps.forEach(effect => effect.run())
  }

接着我们也要把 reactive 中的相关代码也进行修改:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
-            dep.addSub(target, key)
+            track(target, key)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
-            dep.notify(target, key)
+            trigger(target, key)
            return result
        }
    })
}

我们可以看到经过上述修改之后,我们的代码结构跟 Vue3 源码是一模一样的了,但并不是说代码结构变了,模式也变了,上述代码结构依然是发布订阅模式。那么 Vue3 为什么要把依赖收集和依赖触发的函数进行分开呢?主要是因为分开之后依赖收集和依赖触发的函数就可以分别独立导出了,给其他功能 API 比如 ref、computed 使用了,代码可以达到最极致的抽象及复用。

确保只在副作用函数中读取响应式变量才进行依赖收集

不采用 Proxy API 实现数据响应式

因为 Proxy 无法提供对原始值的代理,所以我们需要对原始值的响应式进行特别处理,我们可以使用一层对象作为包裹,间接实现原始值的响应式方案。

当我们不通过 Proxy 实现代理的时候,除了使用 Vue2 中使用的 Object.defineProperty以外,我们还可以根据前面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显我们可以使用在发布订阅模式那篇中讲到的公众号的例子。

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '原始值内容',
    // 发布文章
    setArticle(value) {
        this.article = value
        // 更新文章的时候通知所有的订阅者
        this.notify()
    },
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(title));
    }
}

上述代码就是前面我们实现公众号讲解发布订阅模式的例子。在上述例子中,我们实现了在数据更新的时候触发依赖,也就是 setArticle 函数。那么我们再实现在数据读取的时候进行依赖收集即可,为了现在这个功能,我们把读取 article 属性值的行为也封装成一个函数。

代码如下:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
+    getArticle() {
+        return this.article
+    },
    // 省略...
}

这样我们就可以通过以下的方式获取文章内容了:

effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.getArticle()}`)
})
// 更改内容
weChatOfficialAccount.setArticle(520)

同时我们的发布者的通知函数也需要进行修改:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 省略...
    // 广播信息
-    notify(title) {
+    notify() {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
-        this.subscribers.forEach(fn => fn(title))
+        this.subscribers.forEach(dep => dep.run())
    }
}

那么我们就可以在 getArticle 函数中进行依赖收集了:

// 定义发布者公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    getArticle() {
+        // 进行依赖收集,也就是进行订阅
+        if (activeEffect) this.addDep(activeEffect)
        return this.article
    },
    // 省略...
}

这样我们的测试结果如下:

D08.png

我们上述方式是通过一个典型的发布订阅模式来实现对一个对象的观察,当这个对象发生改变之后,所有依赖该对象的订阅者都将得到通知。

我们通过一个工厂函数上面的公众号对象进行进行封装,代码如下:

// ref 工厂函数
function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
        getArticle() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
        setArticle(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 添加订阅者
        addDep(fn) {
            // 把订阅者添加进记录列表
            this.subscribers.push(fn) 
        },
        // 广播信息
        notify() {
            // 发布信息时就是把记录列表中的订阅者全部通知一次
            this.subscribers.forEach(dep => dep.run());
        }
    }
}

我们可以看到经过上述的代码封装之后,我们实现了对原始值的响应式。那么接下来我们希望通过普通的方式获取和设置对象的值:

const weChatOfficialAccount = ref('初始值')
effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.article}`)
})
// 更改内容
weChatOfficialAccount.article = 520

通过前面的学习我们知道除了使用 Object.defineProperty 进行显式声明属性访问器之外,还可以通过字面量的方式,本质还是属性访问器

修改如下:

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
-        getArticle() {
+        get article() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
-        setArticle(value) {
+        set article(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 省略...
}

经过上述修改之后,我们就可以通过属性访问器像普通方式那样访问和设置对象的属性值了。

那么为了跟 Vue3 的 ref API 设计一致,我们把 article 属性改成 value

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
        subscribers: [],
        // 文章内容
        _value: value,
-        get article() {
+        get value() {
            if (activeEffect) this.addDep(activeEffect)
            return this._value
        },
        // 发布文章
-        set article(value) {
+        set value(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
            this.notify()
        },
        // 省略...
}

那么改了之后我们的 ref 就跟 Vue3 的一样用法了:

const weChatOfficialAccount = ref('初始值')
effect(() => {
    console.log(`原始值内容:${weChatOfficialAccount.value}`)
})
// 更改内容
weChatOfficialAccount.value = 520

接着我们对依赖收集函数 track 和依赖触发函数 trigger 进行修改让我们的代码尽可能地复用。修改如下:

// 添加订阅者
function track(target, key) {
    // 省略...
    if (activeEffect) {
-        dep.add(activeEffect)
-        activeEffect.deps.push(dep)
+        trackEffect(dep)
    }
}
+ function trackEffect(dep) {
+     dep.add(activeEffect)
+     activeEffect.deps.push(dep)
+ }
// 通知订阅者
function trigger(target, key) {
    // 省略...
-    deps && deps.forEach(effect => effect.run())
+    triggerEffect(deps)
}
+ function triggerEffect(deps) {
+     if(deps) {
+         deps.forEach(effect => effect.run());
+     }
}

接着我们进行重构 ref 函数:

function ref(value) {
    return {
        // 订阅公众号的人的记录列表
-        subscribers: [],
+        dep: new Set()
        // 文章内容
        _value: value,
        get value() {
-            if (activeEffect) this.addDep(activeEffect)
+            if (activeEffect) trackEffect(this.dep)
            return this._value
        },
        // 发布文章
        set value(value) {
            this._value = value
            // 更新文章的时候通知所有的订阅者
-            this.notify()
+            triggerEffect(this.dep)
        },
-        // 添加订阅者
-        addDep(fn) {
-            // 把订阅者添加进记录列表
-            this.subscribers.push(fn) 
-        },
-        // 广播信息
-        notify() {
-            // 发布信息时就是把记录列表中的订阅者全部通知一次
-            this.subscribers.forEach(dep => dep.run());
-        }
-    }
}

我们可以看到经过重构之后,我们的 ref 函数就变得比较整洁了,我们 ref 中的部分发布订阅的功能就和前面 reative 的发布订阅已经实现的功能代码进行了复用。

我们通过前面文章的学习,我们知道 Vue3 的 ref 底层是通过 OOP 的方式进行实现的,但本质还是跟我们上面一样的,那么我们也通过 OOP 的方式实现一遍吧。

实现代码如下:

class RefImpl {
    _value
    dep = new Set()
    constructor(value) {
        // 如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式
        this._value = isObject(value) ? reactive(value) : value
    }
    get value() {
       // 存在依赖就把依赖收集到依赖存储中心
       if (activeEffect) trackEffect(this.dep)
       return this._value 
    }
    set value(val) {
        this._value = val
        // 更新文章的时候通知所有的订阅者
        triggerEffect(this.dep)
    }
}

function ref(value) {
    return new RefImpl(value)
}

最终我们的测试结果还是一样的,这里唯一值得注意的是,如果传进来的是对象那么最终还是通过 reactive API 实现数据响应式。

API 的设计技巧及知识的串联

我们上文中实现的数据响应式代码中,有一个函数的名称叫:observe,还有一个类叫:Observer,在 Vue2 源码中也是这么起名的。那么为什么要这么起名称呢?这么起名称有什么特殊的含义吗?

我们上面这个所谓数据响应式的原理,其实是在观察数据的变化,跟我们在 web 开发中观察 DOM 对象的变化的行为是很像的,甚至可以说本质是一样的。

MutationObserver 与 Vue2 数据响应式的联系

我们如果要观察一个 DOM 对象发生改变了就进行某些操作的话,可以通过 MutationObserver API来实现。例子如下:

// 获取 DOM 对象
const targetNode = document.querySelector('#some-id');

// 观察者回调函数
const subscriber = (mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((addedNode) => {
        console.log(`添加了子元素:${addedNode.nodeName}`);
        // 执行相应的处理逻辑
      });
      mutation.removedNodes.forEach((removedNode) => {
        console.log(`移除了子元素:${removedNode.nodeName}`);
        // 执行相应的处理逻辑
      });
    }
  });
} 

// 创建一个观察器实例并传入回调函数,当观察到变动时便执行回调函数
const observer = new MutationObserver(subscriber);
// 配置需要观察的选项
const config = {
  childList: true, // 观察子元素是否发生变化
};
// 观察 DOM 对象是否发生变化
observer.observe(targetNode, config);

我们从上面的代码可以看出 MutationObserver 所做的事情,跟我们 Vue2 中对响应式数据的监听是一样的。DOM 对象就是我们 Vue2 中的响应式数据,当它发生变化之后就会去触发回调函数执行,相当于 Vu2 中的响应式数据发生改变后会触发 Watcher 一样。所以 MutationObserver 本质也是一个发布订阅模式,但它使用方式跟我们所谓传统的发布订阅模式是不一样的,但正如我们前面说的理解一种模式不应该从代码组织结构去进行分辨,而是意图。

所以我们从 Vue2 的数据响应式实现原理,就可以联系到 MutationObserver,然后联系它们的相同点,从而加深我们对知识的理解。当然尤雨溪当初给 Vue2 对一个对象实现数据响应式的处理函数和类命名为 observeObserver,是否参考了 MutationObserver 的 API 命名规则我们无从考证,但它们的工作方式值得我们联系,从而加深我们的知识理解。

总结

本文从发布订阅模式的核心思想出发,深入剖析了 Vue3 响应式系统的设计本质。发布订阅模式的关键在于管理对象间的依赖关系——一方变化时,所有依赖方都能得到通知,而非拘泥于特定的代码结构。Vue3 虽然不再像 Vue2 那样拥有显式的 Dep 类,但其底层依然遵循这一模式。

通过逐步迭代,我们自然形成了 Vue3 中著名的“桶”数据结构:最初用一个数组存储订阅者,然后按属性 key 分类,再按响应式对象 target 隔离,最终演变为 WeakMap(target) → Map(key) → Set(effect) 的依赖图。这种结构并非凭空设计,而是功能迭代的自然产物,体现了发布订阅模式在 Proxy 场景下的灵活应用。

Vue3 中的 ReactiveEffect 类扮演了订阅者中介的角色,类似于 Vue2 的 Watcher,负责管理具体副作用函数的执行与依赖追踪。通过 effect 函数封装,我们可以轻松创建响应式副作用,并借助 stop 机制实现取消订阅,这体现了订阅者与发布者之间“互为订阅”的关系。

值得注意的是,Vue3 将依赖收集(track)和依赖触发(trigger)拆分为独立函数,而非保留传统的 Dep 类结构。这一设计变化并非模式的改变,而是为了提升代码复用性,让 refcomputed 等 API 也能共享同一套响应式核心。

此外,对原始值的响应式实现(ref)同样基于发布订阅模式——通过属性访问器(getter/setter)在读取时收集依赖,在修改时触发更新。当 ref 包裹对象时,内部会回退到 reactive 处理,保证了逻辑的一致性。

最后,从 API 命名(如 observe / Observer)到与浏览器原生 MutationObserver 的类比,都能看出响应式系统与观察者模式之间的深刻联系。理解这些设计背后的模式思想,远比记忆具体代码实现更有价值。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

如何实现RN应用的离线功能、数据缓存策略?

作者 光影少年
2026年4月12日 20:29

React Native(RN) 里实现“离线可用 + 数据缓存”,本质是两件事:

① 本地持久化数据(缓存)
② 网络恢复后的同步策略(离线→在线)

我给你按“能直接落地”的思路讲,不整虚的👇


🧩 一、离线能力的整体架构

一个靠谱的 RN 离线方案一般是这样:

接口请求 → 缓存层 → UI展示
            ↓
        本地存储(数据库/缓存)
            ↓
        同步机制(网络恢复)

核心点就3个:

  1. 缓存数据
  2. 判断网络状态
  3. 同步数据(冲突处理)

📦 二、本地存储方案选型(很关键)

1️⃣ 轻量缓存(推荐入门)

👉 AsyncStorage

适合:

  • token
  • 用户信息
  • 小量接口缓存
import AsyncStorage from '@react-native-async-storage/async-storage'

// 存
await AsyncStorage.setItem('user', JSON.stringify(user))

// 取
const user = JSON.parse(await AsyncStorage.getItem('user'))

👉 ❗缺点:

  • 不适合大数据
  • 没查询能力

2️⃣ 高性能KV缓存(推荐)

👉 react-native-mmkv

优势:

  • C++实现,性能非常高
  • 支持同步读取(比 AsyncStorage 快很多)
import { MMKV } from 'react-native-mmkv'

const storage = new MMKV()

storage.set('name', 'zhang')
const name = storage.getString('name')

👉 适合:

  • 高频读写
  • 状态缓存(Redux持久化)

3️⃣ 本地数据库(复杂业务必选)

👉 realm

  • 离线能力强
  • 支持对象存储
  • 自动同步(可选)

👉 SQLite

  • 更底层
  • 灵活但要自己写SQL

👉 适合:

  • 列表数据
  • 离线表单
  • 聊天记录

🌐 三、网络状态监听(离线核心)

👉 用:@react-native-community/netinfo

import NetInfo from "@react-native-community/netinfo";

NetInfo.addEventListener(state => {
  console.log("是否联网:", state.isConnected);
});

👉 用途:

  • 判断是否走缓存
  • 触发“数据同步”

🔁 四、数据缓存策略(重点)

🧠 策略1:Cache First(优先缓存)

👉 打开页面先读缓存,再请求接口更新

const cache = await getCache()

if (cache) {
  setData(cache)
}

fetchApi().then(res => {
  setData(res)
  saveCache(res)
})

👉 优点:

  • 秒开
  • 离线可用

🧠 策略2:Network First(优先网络)

try {
  const res = await fetchApi()
  saveCache(res)
  return res
} catch {
  return getCache()
}

👉 优点:

  • 数据新
    👉 缺点:
  • 离线体验一般

🧠 策略3:Stale-While-Revalidate(推荐)

👉 最适合RN

先用缓存  后台更新  UI刷新

👉 类似 Web 的 SWR


📝 五、离线写入(重点难点🔥)

比如:

👉 用户离线提交表单 / 点赞 / 操作

你要做:

1️⃣ 本地先存操作(队列)

const queue = [
  { type: 'CREATE', data: {...}, status: 'pending' }
]

2️⃣ 网络恢复后同步

if (isOnline) {
  queue.forEach(async item => {
    await api(item)
    markDone(item)
  })
}

3️⃣ 冲突处理(很关键)

常见策略:

  • 时间戳优先(last-write-wins)
  • 版本号控制
  • 服务端合并

⚡ 六、工程级方案(推荐你用)

👉 状态管理 + 持久化

  • Redux + redux-persist
  • Zustand + persist

👉 Zustand示例:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useStore = create(persist(
  (set) => ({
    user: null,
    setUser: (user) => set({ user })
  }),
  { name: 'app-storage' }
))

👉 请求缓存库(高级)

  • react-query(TanStack Query)
  • SWR(也有RN版本)

👉 react-query支持:

  • 自动缓存
  • 重试
  • 离线恢复

🚀 七、一个完整落地方案(推荐你这样做)

结合你现在前端背景,我给你一个“企业级方案”👇

RN App
├── Zustand(状态管理)
├── MMKV(本地缓存)
├── React Query(接口缓存)
├── NetInfo(网络检测)
└── Sync Queue(离线同步)

🧠 总结(最重要)

👉 RN离线能力 =

缓存(读) + 队列(写) + 同步(网络恢复)

🎯 给你的进阶建议(很关键)

你现在是前端出身,可以重点搞这个方向:

👉 “离线优先应用(Offline-first App)”

做一个项目练手:

  • 离线Todo App(带同步)
  • 离线表单系统(类似工单)
  • IoT设备数据缓存(结合你Go也能玩)

Recent lld/ELF performance improvements

作者 MaskRay
2026年4月12日 15:00

Since the LLVM 22 branch was cut, I've landed patches thatparallelize more link phases and cut task-runtime overhead. This postcompares current main against lld 22.1, mold, and wild.

Headline: a Release+Asserts clang --gc-sections link is1.37x as fast as lld 22.1; Chromium debug with --gdb-indexis 1.07x as fast. mold and wild are still ahead — the last sectionexplains why.

Benchmark

lld-0201 is main at 2026-02-01 (6a1803929817);lld-load is main plus the new[ELF] Parallelize input file loading. mold andwild run with --no-fork so the wall-clocknumbers include the linker process itself.

Three reproduce tarballs, --threads=8,hyperfine -w 1 -r 10, pinned to CPU cores withnumactl -C.

Workload lld-0201 lld-load mold wild
clang-23 Release+Asserts, --gc-sections 1.255 s 917.8 ms 552.6 ms 367.2 ms
clang-23 Debug (no --gdb-index) 4.582 s 4.306 s 2.464 s 1.565 s
clang-23 Debug (--gdb-index) 6.291 s 5.915 s 4.001 s N/A
Chromium Debug (no --gdb-index) 6.140 s 5.904 s 2.665 s 2.010 s
Chromium Debug (--gdb-index) 7.857 s 7.322 s 3.786 s N/A

Note that llvm/lib/Support/Parallel.cpp design keeps themain thread idle during parallelFor, so--threads=N really utilizes N+1 threads.

wild does not yet implement --gdb-index — it silentlywarns and skips, producing an output about 477 MB smaller on Chromium.For fair 4-way comparisons I also strip --gdb-index fromthe response file; the no --gdb-index rows above use thatsetup.

A few observations before diving in:

  • The --gdb-index surcharge on the Chromium link is+1.42 s for lld (5.90 s → 7.32 s) versus+1.12 s for mold (2.67 s → 3.79 s). This is currently oneof the biggest remaining gaps.
  • Excluding --gdb-index, mold is 1.66x–2.22x as fast andwild 2.5x–2.94x as fast on this machine. There is plenty of roomleft.
  • clang-23 Release+Asserts --gc-sections (workload 1) hascollapsed from 1.255 s to 918 ms, a 1.37x speedup over 10 weeks. Most ofthat came from the parallel --gc-sections mark, parallelinput loading, and the task-runtime cleanup below — each contributing amultiplicative factor.

macOS (Apple M4) notes

The same clang-23 Release+Asserts link, --threads=8, onan Apple M4 (macOS 15, system allocator for all four linkers):

Linker Wall User Sys (User+Sys)/Wall
lld-0201 324.4 ± 1.5 ms 502.1 ms 171.7 ms 2.08x
lld-load 221.5 ± 1.8 ms 476.5 ms 368.8 ms 3.82x
mold 201.2 ± 1.7 ms 875.1 ms 220.5 ms 5.44x
wild 107.1 ± 0.5 ms 456.8 ms 284.6 ms 6.92x

Parallelize--gc-sections mark

Garbage collection had been a single-threaded BFS overInputSection graph. On a Release+Asserts clang link,markLive was ~315 ms of the 1562 ms wall time (20%).

6f9646a598f2adds markParallel, a level-synchronized BFS. Each BFS levelis processed with parallelFor; newly discovered sectionsland in per-thread queues, which are merged before the next level. Theparallel path activates when!TrackWhyLive && partitions.size() == 1.Implementation details that turned out to matter:

  • Depth-limited inline recursion (depth < 3) beforepushing to the next-level queue. Shallow reference chains stay hot incache and avoid queue overhead.
  • Optimistic "load then compare-exchange" section-flag dedup insteadof atomic fetch-or. The vast majority of sections are visited once, sothe load almost always wins.

On the Release+Asserts clang link, markLive dropped from315 ms to 82 ms at --threads=8 (from 199 ms to 50 ms at--threads=16); total wall time 1.16x–1.18x.

Two prerequisite cleanups were needed for correctness:

  • 6a874161621emoved Symbol::used into the existingstd::atomic<uint16_t> flags. The bitfield waspreviously racing with other mark threads.
  • 2118499a898bdecoupled SharedFile::isNeeded from the mark walk.--as-needed used to flip isNeeded insideresolveReloc, which would have required coordinated writesacross threads; it is now a post-GC scan of global symbols.

Parallelize input fileloading

Historically, LinkerDriver::createFiles walked thecommand line and called addFile serially.addFile maps the file (MemoryBuffer::getFile),sniffs the magic, and constructs an ObjFile,SharedFile, BitcodeFile, orArchiveFile. For thin archives it also materializes eachmember. On workloads with hundreds of archives and thousands of objects,this serial walk dominates the early part of the link.

The pending patch will rewrite addFile to record aLoadJob for each non-script input together with a snapshotof the driver's state machine (inWholeArchive,inLib, asNeeded, withLOption,groupId). After createFiles finishes,loadFiles fans the jobs out to worker threads. Linkerscripts stay on the main thread because INPUT() andGROUP() recursively call back intoaddFile.

A few subtleties made this harder than it sounds:

  • BitcodeFile and fatLTO construction callctx.saver / ctx.uniqueSaver, both of which arenon-thread-safe StringSaver /UniqueStringSaver. I serialized those constructors behind amutex; pure-ELF links hit it zero times.
  • Thin-archive member buffers used to be appended toctx.memoryBuffers directly. To keep the outputdeterministic across --threads values, each job nowaccumulates into a per-job SmallVector which is merged intoctx.memoryBuffers in command-line order.
  • InputFile::groupId used to be assigned inside theInputFile constructor from a global counter. With parallelconstruction the assignment race would have been unobservable but stillugly; b6c8cba516dahoists ++nextGroupId into the serial driver loop and storesthe value into each file after construction.

The output is byte-identical to the old lld and deterministic across--threads values, which I verified with diffacross --threads={1,2,4,8} on Chromium.

A --time-trace breakdown is useful to set expectations.On Chromium, the serial portion of createFiles accounts foronly ~81 ms of the 5.9 s wall, and loadFiles (after thispatch) runs in ~103 ms in parallel. Serial readFile/mmap isnot the bottleneck. What moves the needle is overlapping the per-fileconstructor work — magic sniffing, archive member materialization,bitcode initialization — with everything else that now kicks off on themain thread while workers chew through the job list.

Extending parallelrelocation scanning

Relocation scanning has been parallel since LLVM 17, but three caseshad opted out via bool serial:

  1. -z nocombreloc, because .rela.dyn mergedrelative and non-relative relocations and needed deterministicordering.
  2. MIPS, because MipsGotSection is mutated duringscanning.
  3. PPC64, because ctx.ppc64noTocRelax (aDenseSet of (Symbol*, offset) pairs) waswritten without a lock.

076226f378dfand dc4df5da886eseparate relative and non-relative dynamic relocations unconditionallyand always build .rela.dyn withcombreloc=true; the only remaining effect of-z nocombreloc is suppressing DT_RELACOUNT. 2f7bd4fa9723then protects ctx.ppc64noTocRelax with the already-existingctx.relocMutex, which is only taken on rare slow paths.After these changes, only MIPS still runs scanning serially.

Faster getSectionPiece

Merge sections (SHF_MERGE) split their input into"pieces". Every reference into a merge section needs to map an offset toa piece. The old implementation was always a binary search inMergeInputSection::pieces, called fromMarkLive, includeInSymtab, andgetRelocTargetVA.

42cc45477727changes this in two ways:

  1. For non-string fixed-size merge sections,getSectionPiece uses offset / entsizedirectly.
  2. For non-section Defined symbols pointing into mergesections, the piece index is pre-resolved duringsplitSections and packed into Defined::valueas ((pieceIdx + 1) << 32) | intraPieceOffset.

The binary search is now limited to references via section symbols(addend-based), which is common on AArch64 but rare on x86-64 where theassembler emits local labels for .L references intomergeable strings. The clang-relassert link with--gc-sections is 1.05x as fast.

Optimizingthe underlying llvm/lib/Support/Parallel.cpp

All of the wins above rely onllvm/lib/Support/Parallel.cpp, the tiny work-stealing-ishtask runtime shared by lld, dsymutil, and a handful of debug-info tools.Four changes in that file mattered:

  • c7b5f7c635e2— parallelFor used to pre-split work into up toMaxTasksPerGroup (1024) tasks and spawn each through theexecutor's mutex + condvar. It now spawns only ThreadCountworkers; each grabs the next chunk via an atomic fetch_add.On a clang-14 link (--threads=8), futex calls dropped from~31K to ~1.4K (glibc release+asserts); wall time 927 ms → 879 ms. Thisis the reason the parallel mark and parallel scan numbers are worthquoting at all — on the old runtime, spawn overhead was a real fractionof the work being parallelized.
  • 9085f74018a4— TaskGroup::spawn() replaced the mutex-basedLatch::inc() with an atomic fetch_add andpasses the Latch& through Executor::add()so the worker calls dec() directly. Eliminates onestd::function construction per spawn.
  • 5b1be759295c— removed the Executor abstract base class.ThreadPoolExecutor was always the only implementation;add() and getThreadCount() are now directcalls instead of virtual dispatches.
  • 8daaa26efdda— enables nested parallel TaskGroup via work-stealing.Historically, nested groups ran serially to avoid deadlock (the threadthat was supposed to run a nested task might be blocked in the outergroup's sync()). Worker threads now actively execute tasksfrom the queue while waiting, instead of just blocking. Root-levelgroups on the main thread keep the efficient blockingLatch::sync(), so the common non-nested case pays nothing.In lld this lets SyntheticSection::writeTo calls withinternal parallelism (GdbIndexSection,MergeNoTailSection) parallelize automatically when calledfrom inside OutputSection::writeTo, instead of degeneratingto serial execution on a worker thread — which was the exact situationD131247 had worked aroundby threading a root TaskGroup all the way down.

Small wins worth mentioning

  • 036b755daedbparallelizes demoteAndCopyLocalSymbols. Each file collectslocal Symbol* pointers in a per-file vector viaparallelFor, which are merged into the symbol tableserially. Linking clang-14 (--no-gc-sections) with its 208K.symtab entries is 1.04x as fast.

Where lld still loses time

To locate the gap I ran lld --time-trace,mold --perf, and wild --time on the Chromium--gdb-index link (--threads=8). Grouped intocomparable phases:

Phase lld mold
Parse input files 2778 ms 1034 ms
Scan relocations 233 ms 103 ms
Assign / finalize layout 750 ms ~150 ms
Symtab + synthetic finalize 570 ms ~80 ms
Write sections (copy chunks) 533 ms 558 ms
Create gdb index 1317 ms 911 ms
Wall 6742 ms 3428 ms

That leaves four meaningful gaps, in order of absolute impact:

Parse input files: 2.78 s vs 1.03 s, ~52% of the totalgap. Same ratio on clang-debug (2.49 s vs 1.09 s). The phase isalready parallel; the gap is pure constant factor in the per-objectparse path (reading section headers, interning strings, splittingCIEs/FDEs, resolving symbols into the global table). wild is even moreextreme here — its whole "Load inputs into symbol DB" is ~255 ms onChromium, which is where most of its overall advantage comes from.

Assign / finalize / symtab finalize: ~1.3 s vs ~0.23s. finalizeAddressDependentContent,assignAddresses, finalizeSynthetic,Add symbols to symtabs, and Finalize .eh_frametogether cost ~1.3 s on Chromium. mold's equivalents(compute_section_sizes, compute_symtab_size,create_output_sections, set_osec_offsets)total ~230 ms. .symtab alone is ~127 ms lld vs ~27 ms moldon clang-debug; I have a local branch that turnsSymbolTableBaseSection::finalizeContents into aprefix-sum-driven parallel fill and replaces thestable_partition + MapVector shuffle withper-file lateLocals buffers. 1640 ELF tests pass; notposted yet.

Create gdb index: +1.32 s lld vs +0.91 s mold onChromium. Varies by workload — on clang-debug the two are within 200 ms(1.73 s vs 1.54 s). The work is embarrassingly parallel per input, butlld funnels a lot of string interning through a singleDenseMap (sharded, but still); mold uses a lock-freeConcurrentMap sized by HyperLogLog.

Scan relocations: 233 ms vs 103 ms. Small absolutebut a clean 2.3x ratio. Target-specific scanning (theAdd target-specific relocation scanning for … series fromlast year) already removed much of the dispatch overhead; what remainsis per-relocation work in the x86-64 path.

Interestingly, writing section content is not a gap.lld spends 533 ms in Write sections vs mold's 558 ms incopy_chunks vs wild's 574 ms inWrite data to file — all within noise of each other. Theearlier assumption that .debug_* section writes were a lldweakness didn't survive measurement; the --gdb-indexsurcharge really lives in index construction, not the write.

wild is worth calling out separately: its user time is comparable tolld's but its system time is roughly half, and its parse phase is 4-8xfaster than either of the C++ linkers. mold is at the other extreme —the highest user time on every workload, bought back by aggressiveparallelism.

Hello 算法:贪心的世界

作者 灵感__idea
2026年4月13日 00:21

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

一轮轮的简单选择中,时刻追求自身成长的最大可能,逐步导向最佳答案。

本篇话题展开之前,先看个日常很常见的问题:零钱兑换

你去超市购物,给收银员100元,而你购买的商品只需要2元,他要怎样给你找钱?

很简单,会100内加减的都能轻易搞定,你会根据还剩余的找零额度,从可选择的币种中选择面值最大的,直至达到数额为止。

其实你会发现,“零钱”只是一种比较具象的表达,把“找零”这件事进一步抽象,可用于解决很多领域的问题。

它,就是“贪心算法”。

贪心算法

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。

贪心算法简洁且高效,在许多实际问题中有着广泛的应用。

除了找零钱,它还可用于解决“分数背包问题”:

给一个背包,有容量限制,另有N件物品,每件物品都有它的重量和价值,每件物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算

求:在限定背包容量下,背包中物品的最大价值。

这个问题的解答策略和“找零”类似:最大化背包内物品总价值,本质上是最大化单位重量下的物品价值

  1. 将物品按照单位价值从高到低进行排序。
  2. 遍历所有物品,每轮贪心地选择单位价值最高的物品。
  3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。

注意:这里说的是“分数背包”,不是“0-1背包”。

代码实现

以“找零”为例,贪心算法的核心实现:

/* 零钱兑换:贪心 */
function coinChangeGreedy(coins, amt) {
    // 假设 coins 数组有序
    let i = coins.length - 1;
    let count = 0;
    // 循环进行贪心选择,直到无剩余金额
    while (amt > 0) {
        // 找到小于且最接近剩余金额的硬币
        while (i > 0 && coins[i] > amt) {
            i--;
        }
        // 选择 coins[i]
        amt -= coins[i];
        count++;
    }
    // 若未找到可行方案,则返回 -1
    return amt === 0 ? count : -1;
}

示意图如下:

微信图片_20260413001704_150.jpg

优点与局限

贪心算法的优点是操作直接、实现简单,通常效率也很高。

但不是所有分步解决的问题都适合使用贪心,什么样的问题适合呢?

主要关注两个性质。

  • 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
  • 最优子结构:原问题的最优解包含子问题的最优解。

关键词:最优解

还是找零问题,贪心能找到最优解的前提是,有足够的币值种类可选,如果没有,像下面这样:

给定币值:[1,20,50],目标值是 60,使用贪心策略,它会找到 50 + 1*10,总数是11,但实际更优的做法是 20 * 3,只需要3就可以。

所以可以理解为,贪心适合的场景是“想要多少(比如10),就有多少”,而不是妥协退而求其次。

其他应用

除了上面介绍的两种,贪心的适用场景还包括但不限于:

  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

小结

贪心是必掌握的经典算法之一,实现也不难,重点是吃透它的使用场景。

下一篇,将是本系列的终篇,让我们一起期待会是什么。

更多好文第一时间接收,可关注公众号:“前端说书匠”

昨天 — 2026年4月12日首页

《前端周刊》尤大开源 Vite+ 全家桶,前端工业革命启动;尤大爆料 Void 云服务新产品,Vite 进军全栈开发;ECMA 源码映射规范......

作者 Web情报局
2026年4月12日 19:40

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周全球 Web 开发圈的主要情报如下:

  • 🎉 尤大出席 Vue 大会,发表了关于 Vue & Vite 的重要讲话
  • ✨ Vue 生态“文艺复兴“,“蒸汽模式“公测,Pinia Colada 新品首发
  • ✅ TC39 工作组推进 ECMA 源码映射规范
  • 👍 Vite 生态“工业革命“,Vite+ 全家桶免费开源
  • 🙏 尤大爆料 Void 一键部署云平台,Vite 进军全栈开发

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 Vue 生态文艺复兴

近日,Vue 之父 & Vite 之父 & VoidZero CEO 尤雨溪出席了 Vue 阿姆斯特丹大会和 D2 技术大会,发表了重要讲话。

cover.png

本期我们就来回顾一下这位“前端之神“已公开的关于 Vite & Vue 生态的最新情报和未来规划。

Vue 3.6 Beta

Vue 是 GitHub 第二 UI 框架,也是唯一一个同时支持 SFC(单文件组件)/ JSX、集成 Signals(细粒度响应性)的渐进式框架。

Vue 目前发布了 v3.6-beta(公测版),主要包括:

  • 移植 alien-signals 重构 @vue/reactivity
  • 新增可选的 Vapor Mode 编译模式,这是一种专属于 <script setup> SFC 的无虚拟 DOM “蒸汽模式“
  • Vapor Mode 是 Vue 当前 API 的子集,所以部分功能受限,比如不支持 <Suspense /> 组件和 Options API

尤大最初在 2022 透露了 Vapor Mode(蒸汽模式),去年 Vue 从 Alpha 顺利晋升到 Beta,今年有望正式发布。但考虑到尤大还需要兼顾 Vite+ 等海量开源项目,暂不确定,敬请期待!

Vue Router 5.0

Vue Router 是 Vue 生态官方的客户端路由库,年初发布了 v5.0 主版本,主要包括:

  • 不再依赖 unplugin-vue-router,将其集成为 vue-router/unplugin,支持基于文件的路由
  • IIFE 构建不再包含 @vue/devtools-api,该模块升级到 v8.0 之后,不再提供 IIFE 格式
  • v5.0 是一个过渡版本,v6.0 将只支持纯 ESM 模块

Pinia Colada v1 首发

Pinia 近期没有主/次版本更新,但上线了新的产品 Pinia Colada。

pinia-colada.png

Pinia Colada 是基于 Pinia 的 Vue 专属异步状态管理库,第一个主版本正式发布,优点在于:

  • 支持缓存、去重、SWR(过期重验证)等高级功能,不用我们自己定义相关复杂逻辑
  • 无需手写 isLoading 等属性,内部封装后暴露这些接口
  • 消除数据请求的大量重复模板代码,更符合人体工学

colada-demo.png

Nuxt 4.4

Vue 生态的第一全栈元框架 Nuxt 发布了 v4.4 次版本,主要包括:

  • 新增 createUseFetch() 等工厂函数,支持组合拦截器定义高级实例,比如创建带有默认选项的 useApiFetch() 来替换 useFetch()
  • 新增 useAnnouncer() 组合函数和 <NuxtAnnouncer /> 组件,适用于页面内容动态变化、但焦点不变的场景,比如表单提交
  • 更棒的 import 保护,现在会显示建议和完整的追踪信息
  • 构建性能分析报告,显示构建阶段或打包插件的持续时间等数据,轻松诊断性能瓶颈

profiler.png

🎉 Vite 生态工业革命

而 Vite 生态,尤大所在的 VoidZero 团队掀起了一场前端“工业革命“。

上周我们提到了 Rust 驱动的第一个 Vite 稳定版 Vite 8 正式发布,替换 Rollup + esbuild,采用 Rolldown + Oxc,性能爆表。

此外,基于 Oxc 编译器的格式化神器 Oxfmt 发布了 beta(公测版),JavaScript 跟 TypeScript 的格式化功能 100% 兼容 Prettier,但性能比快了 30 倍。

本期补充更多 Vite 生态的进展,包括 Oxlint 和 Vitest。

Oxlint JS 插件 Alpha

ESLint 的 Rust 移植版 Oxlint 也有新进展,它的 JS 插件进入 Alpha 阶段,目前 100% 通过 ESLint 内置规则的官方测试套件。

oxc-test.png

具体而言,Oxlint 采用 Rust 重写了 650+ 多条代码质检规则,涵盖了 ESLint 的大部分规则。即使没有使用 Rust 重写的规则,Oxlint 也提供了 oxlint-plugin-eslint 插件来无缝迁移,使得 Oxlint 100% 兼容 ESLint 的所有内置规则。

性能方面,Oxlint 团队把 Node 源码库的 ESLint 替换掉,进行测评跑分,性能暴涨近 5 倍。

Oxlint JS 插件的成熟意味着目前 JS 生态现存的 ESLint 插件,比如非官方的社区插件,也能无缝迁移到 Oxlint 项目。这样用户无需重写插件,同时部分受益于 Rust 的原生性能。

Vitest 4.1

Vite 生态衍生的 Vitest 是 GitHub 前十的测试框架,近期也发布了 4.1 次版本,主要包括:

  • 采用新鲜出炉的 Vite 8
  • 测试标签分组,按标签设置或筛选测试,借鉴 pytest 筛选标签的自定义语法
  • 开发体验优化,比如自定义 UI 窗口配置,Playwright 追踪视图改进,自动生成 GitHub Actions Job 摘要
  • Vitest 的 VS Code 扩展现在支持 Deno,import 语句后会显示模块加载时间

vscode.png

🛜 官方情报

ECMA-426 源码映射格式规范

Source Map(源码映射)是一种特殊的 JSON 文件,用于在我们编写的源码和运行时代码之间进行映射。

举个栗子,我们在开发时可能编写一些强大的方言,比如 Sass 或 TypeScript,再把它们转换成 HTML、CSS 或 JavaScript,这样浏览器才能正常执行。

source-map.gif

问题在于,当我们使用 devtools(开发者工具) debug 时,我们希望直接定位到源码,而不是编译或压缩后人类不可读的代码。

这时就要用到 Source Map 了,这个 JSON 文件中保留了源码和运行时代码之间的映射关系,比如哪一行、哪一列等等。

过去,Source Map 并未被标准化,大家通过一份谷歌文档约定实现,但一些功能始终无法协调。

为此,彭博社成立了 TC39-TG4(源码映射工作组),制定了 ECMA-426(源码映射格式规范)。

ecma.png

近年来,它们标准化了更多功能,Scopes 和 Range Mappings 也即将上线 devtools!

React 文档更新

React 文档更新了 <ViewTransition /> 组件结合 <Activity /> 组件章节,如果你想让组件在保持状态的同时实现进场或出场动画,可以使用 <Activity /> 组件。

react-doc.png

Svelte 最佳实践

Svelte 文档更新了“最佳实践“章节,帮助大家编写快速健壮的 Svelte 应用,可以将 svelte-core-bestpractices 投喂给 AI 代理作为 Skills 使用。

svelte-doc.png

🛠️ 工具推荐

Vite+ 全家桶开源

随着 Vite 生态的各个工具逐渐成熟,尤大创立的 Void Zero 公司也官宣:Vite+ 进入 Alpha 阶段!

Vite+ 是将 Vite 生态所有开发工具叠加在一起的一体化工具链,由 Vite Task 任务运行器驱动,提供了 vp install / vp dev 等命令。

具体而言,Vite+ 把 Vite 生态的所有流行的开源软件 —— Vite、Vitest、Oxlint、Oxfmt、Rolldown 和 tsdown 都添加到一个全家桶,用于开发、测试、代码质检、格式化和构建生产环境项目。

此外,Vite+ 还支持管理 pnpm / Bun 等包管理器,甚至能管理 Node 的版本。同时,我们配置的 lint 或格式化规则,比如 eslint.config.js.prettierrc 等配置文件,可以整合到单一的 vite.config.js 中。

vite-plus.gif

之前,Vite+ 原本要求对企业用户付费授权,现在尤大直接开源,完全免费。不管是公司还是独立开发者,都能纵享丝滑了,感谢 Void Zero 的慷慨!

随着 Vite+ 官宣 Alpha 版本,很多项目开始试用,Vite+ 目前已经集成到 Vue 源码的相关分支,还有 Vue CLI create-vue,进一步投入到生产环境测评。

Void Cloud 全栈开发

尤大在 Vue 大会演讲的最后,致敬乔布斯经典的“One More Thing“环节放大招,透露了新产品 Void Cloud。

VOID 是 Vite+ / Optimized(优化)/ Isomorphic(同构)/ Deployment(部署)这几个设计理念的首字母缩写,这是一款 Vite 专属的云服务插件,也是一个也云服务平台,或者全栈元框架。

Void 内置了强大的后端 SDK(软件开发工具),包括数据库、键值存储、对象存储、AI 推理、身份认证等后端应用常见功能,可以按需采用。

void.gif

由于 Void 基于 Vite 生态,因此所有前端框架/元框架天然支持,比如 React、Vue、Nuxt 等,且支持静态站点生成或服务端渲染等不同渲染方式。

Void Cloud 旨在让 Vite 用户能够一键部署,直接上线全栈应用,标志着 Vite 生态将进军全栈开发领域。

Antdv Next 组件库

我一般不推荐组件库,因为 GitHub 有大量成熟的组件库供大家白嫖,容易选择困难。但最近新出了一个 Vue 3 的组件库,它就是 Antdv Next!

Antdv Next 是一套开箱即用的高质量 Vue3 企业级组件库,基于阿里系的蚂蚁设计系统构建。

阿里系之前 Ant Design Vue 是比较流行的组件库,虽然其源码仓库还有提交,但我发现 2024 之后就没有再发布新版本了,目测不会推出新功能了。Ant Design Vue 支持 Vue 2 和 Vue 3,而 Antdv Next 只服务于 Vue 3。

我粗略看了一下,Antdv Next 采用了现代化的技术栈,比如 Vite / Vitest / pnpm 等,可以集成 AI、UnoCSS、Tailwind CSS 等。

Vue 初学者最不习惯的应该是 Antd 系列的组件源码都是基于 TSX 来实现,而不是常见的 SFC,但这只影响开源贡献,不会影响我们以 SFC 的方式使用。

由于组件实现采用了 TSX,Antdv Next 的自定义主题相应地也采用了 CSS-in-JS,其主题是目前我个人比较喜欢的亮点之一。

antdv-next.gif

总之,Antdv Next v1.0 已经正式官宣,值得继续关注,欢迎大家去 GitHub star 支持一波~

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

重排、重绘与合成——浏览器渲染性能的底层逻辑

作者 yuki_uix
2026年4月12日 18:03

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧——明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。


一、先厘清一个容易混淆的概念

在进入正题前,必须先把这两件事分开:React re-render浏览器重排/重绘

它们经常被放在一起讨论,但其实是两个不同层的事情:

筛选条件变化
    ↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
    ↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制

React re-render 可能触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。


二、浏览器渲染流程回顾

在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:

DOM + CSSOM
    ↓
Render Tree(渲染树)
    ↓
Layout(重排)   ← 计算每个元素的位置和大小
    ↓
Paint(重绘)    ← 填充颜色、边框、阴影……
    ↓
Composite(合成)← 合并图层,输出到屏幕

重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。


三、重排(Reflow):最贵的一步

什么是重排?

当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息——这个过程叫重排,也叫 Reflow。

典型触发场景:

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';

// 改变元素显示状态
element.style.display = 'none';   // 从文档流移除,触发重排
element.style.display = 'block';  // 重新加入文档流,触发重排

// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);

// 窗口大小变化
window.addEventListener('resize', handler);

为什么代价大?

重排的代价在于连锁反应。HTML 元素的布局是相互影响的——一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响……

浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。


四、重绘(Repaint):比重排轻,但不是没有代价

什么是重绘?

当元素的外观发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素——这叫重绘,也叫 Repaint。

典型触发场景:

// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';

// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';

// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';

重绘不需要重新计算布局,只需要重新"上色"——所以比重排轻得多,但仍然有开销,不是免费的。


五、关键规律:三者的包含关系

重排 ⊃ 重绘 ⊃ 合成

重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)

开销排序:重排 > 重绘 > 合成


六、合成层与 transform 为什么快

这是整篇文章最关键的部分。

三种操作的完整流程对比

操作 触发流程 性能
width / height / top / left 重排 → 重绘 → 合成 最差
color / background-color 重绘 → 合成 中等
transform / opacity 只合成 最好

transform 的工作原理

当浏览器发现一个元素使用了 transformopacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。

普通元素动画(left/top):
    修改样式
        ↓
    重新 Layout(计算位置)    ← CPU,影响其他元素
        ↓
    重新 Paint(绘制像素)     ← CPU,绘制整个区域
        ↓
    Composite(合成)          ← GPU

transform/opacity 动画:
    修改样式
        ↓
    Composite(合成)          ← GPU 直接处理
    (跳过 Layout 和 Paint)

关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素——只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。

实际代码对比

/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 0.3s ease;
}
.box-bad:hover {
  left: 200px; /* 每一帧都触发重排 */
}

/* 只触发合成,动画丝滑 */
.box-good {
  position: absolute;
  transform: translateX(0);
  transition: transform 0.3s ease;
}
.box-good:hover {
  transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}

视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform

主动触发合成层提升

除了 transformopacity,还可以通过 will-change 提示浏览器提前创建合成层:

/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
  will-change: transform;
}
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

will-change 不是越多越好——每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。


七、实际开发陷阱:循环里交替读写 DOM

这是一个在真实项目里很容易踩的坑,也是面试的高频考题。

为什么读取布局属性会触发强制重排?

当你读取 offsetHeightclientWidthgetBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值

如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。

问题代码:循环内交替读写

// 每次循环都触发一次强制重排——100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');

for (let i = 0; i < boxes.length; i++) {
  const height = boxes[i].offsetHeight;        // 读:强制触发重排,获取准确值
  boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
  // 下一次循环读 offsetHeight,又强制清算上面的标记
}

浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理——每次读取都迫使浏览器立即清算之前积累的修改。

修复:先批量读,再批量写

// 先读完所有值,再批量写——只触发 1 次重排
const boxes = document.querySelectorAll('.box');

// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);

// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
  box.style.height = heights[i] + 10 + 'px';
});

本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。

如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:

// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);

requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.height = heights[i] + 10 + 'px';
  });
});

八、浏览器完整渲染流程总图

把前面所有内容串起来,完整看一遍:

URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
                                    ↓
                              解析 HTML → DOM 树
                              解析 CSS  → CSSOM 树
                                    ↓
                              Render Tree(去掉不可见节点)
                                    ↓
┌───────────────────────────────────────────────────────────┐
│                    浏览器渲染流水线                         │
│                                                           │
│  Layout(重排)                                            │
│  触发条件:width/height/top/left/margin/display 等改变      │
│       ↓                                                   │
│  Paint(重绘)                                             │
│  触发条件:color/background/shadow/visibility 等改变        │
│       ↓                                                   │
│  Composite(合成)                                         │
│  所有操作最终都到这一步                                       │
│                                                           │
│  ✦ transform / opacity                                    │
│    → 元素提升为独立合成层,GPU 直接处理                        │
│    → 跳过 Layout 和 Paint,直达 Composite                   │
└───────────────────────────────────────────────────────────┘
                                    ↓
                               屏幕显示 🎉

延伸思考

梳理完这些,还有几个问题没完全搞清楚:

  1. 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
  2. React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
  3. CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部——这个机制是怎么运作的?

🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
  2. 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
  3. transform 快的原因:浏览器把 transform/opacity 的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top 每帧都触发重排,transform 每帧只做合成——这是动画性能差异的根源。
  4. 强制同步重排陷阱:读取 offsetHeightgetBoundingClientRect() 等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。
  5. will-change 的正确用法:提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用 will-change: auto 释放。

参考资料

虚拟 DOM 与 Diff 算法——React 性能优化的底层逻辑

作者 yuki_uix
2026年4月12日 17:17

用了两三年 React,我一直对"虚拟 DOM 更快"这个说法半信半疑。直到有一次优化一个长列表卡顿问题,才真正逼着自己把这套底层逻辑摸清楚。这篇是我的学习笔记,试图用具体例子把"为什么"和"怎么做"说清楚,而不是把概念堆在一起。


一、为什么需要虚拟 DOM?

先从"直接操作真实 DOM 有什么问题"聊起。

真实 DOM 操作慢在哪?

上一篇聊浏览器渲染时提到过,每次修改 DOM,浏览器都要重跑一遍渲染流水线:

修改 DOM → 重新计算样式 → Layout(重排)→ Paint(重绘)→ Composite

这个流水线本身没问题,问题在于频率。如果你有一个复杂页面,状态变化触发了 100 次 DOM 修改,流水线就要跑 100 次。每次都是真实的浏览器渲染工作,代价不低。

那"每次重新渲染整个页面"呢?

你可能会想:干脆每次状态变化,把整个页面 innerHTML 全部重写,不就省事了?

理论上是"最简单"的方案,但问题是:

  1. :重建整个 DOM 树,触发全量 Layout + Paint,比局部更新慢得多
  2. 丢失用户状态:用户正在输入的文本框内容会被清空、滚动位置跳回顶部、当前 focus 的元素失焦——体验直接崩掉

虚拟 DOM 要解决的,正是这两个问题之间的矛盾:既不想每次手动挑出要更新的 DOM 节点,又不想粗暴地全量重建。


二、虚拟 DOM 是什么?

虚拟 DOM(Virtual DOM)本质上就是用普通 JS 对象来描述 DOM 结构

操作真实 DOM 慢,但操作 JS 对象快得多(快几百倍)。所以 React 的思路是:先在内存里用 JS 对象"演练"要做的改动,算出最小改动集,再一次性更新到真实 DOM。

来看一个具体的对应关系:

<!-- 真实 DOM -->
<div class="card">
  <h1>标题</h1>
  <p>描述内容</p>
</div>
// 对应的虚拟 DOM(JS 对象)
{
  type: 'div',
  props: { className: 'card' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['标题']
    },
    {
      type: 'p',
      props: {},
      children: ['描述内容']
    }
  ]
}

React 的 JSX 语法,本质上就是在写这样的对象描述,只是换了一套更好看的语法糖。

// 你写的 JSX
const element = (
  <div className="card">
    <h1>标题</h1>
    <p>描述内容</p>
  </div>
);

// Babel 编译后,等价于
const element = React.createElement(
  'div',
  { className: 'card' },
  React.createElement('h1', null, '标题'),
  React.createElement('p', null, '描述内容')
);

三、虚拟 DOM 的工作流程

有了虚拟 DOM,React 的渲染流程变成了这样:

状态变化(setState / useState)
        ↓
生成新的虚拟 DOM 树
        ↓
与上一次的旧虚拟 DOM 树做 Diff(对比)
        ↓
找出差异部分(patch)
        ↓
只把差异更新到真实 DOM

核心价值只有一句话:最小化真实 DOM 操作次数

举个例子——一个有 1000 个节点的页面,某次状态变化只影响了其中 3 个节点。

方案 真实 DOM 操作次数
全量重建 1000 次
手动精准更新 3 次(但需要你自己写逻辑)
虚拟 DOM + Diff 3 次(自动计算)

虚拟 DOM 让你享受到了"手动精准更新"的性能,但不需要你自己写那些繁琐的 DOM 操作逻辑。


四、Diff 算法:如何高效比较两棵树?

现在问题来了:比较两棵树,算出最小改动,怎么做?

理论最优解有多慢?

计算机科学中,对比两棵树的最优算法复杂度是 O(n³)

100 个节点?10⁶ = 100 万次计算。 1000 个节点?10⁹ = 10 亿次计算

每次状态更新都跑 10 亿次操作,页面直接冻住。这个路走不通。

React 的解法:三个假设,换来 O(n)

React 选择了一个工程上的妥协:基于三个在实际开发中几乎总是成立的假设,把复杂度降到 O(n)。


假设 1:不同类型的节点,直接替换

如果一个节点从 <div> 变成了 <p>,React 不会试图比较它们的内部差异——直接销毁整棵旧树,重建新树。

// 旧的虚拟 DOM
<div>
  <input value="用户输入的内容" />
  <span>子元素</span>
</div>

// 新的虚拟 DOM(根节点类型变了)
<p>
  <input value="用户输入的内容" />
  <span>子元素</span>
</p>

这种情况下,React 会:

  1. 卸载整个 <div> 及其所有子节点(包括 input 里用户输入的内容)
  2. 重新挂载整个 <p>

所以如果你的根节点类型频繁切换,会造成不必要的子组件销毁重建。这个假设告诉我们:组件的根节点类型,能稳定就稳定


假设 2:只比较同层节点,不跨层级

React 的 Diff 是逐层对比的,不会尝试找跨层移动的节点。

旧树                    新树

    A                       A
   / \                     / \
  B   C        →          B   C
 / \                           \
D   E                           E

如果你把节点 D 从 B 的子节点移动到了 C 的子节点下,React 看到的是:

  • B 层:少了 D → 删除 D
  • C 层:多了 D → 新建 D

它不会识别出"这是同一个节点在移动",而是执行一次删除 + 一次创建。

这意味着:跨层级移动 DOM 节点,在 React 里代价比你想象的高。在实际组件设计中,尽量避免通过条件渲染在不同层级之间"搬运"同一个组件。


假设 3:用 key 识别列表节点

这是三个假设里和日常开发最紧密的一个。

当对比一组子节点(列表)时,如果没有 key,React 只能按顺序逐一对比:

// 旧列表
<ul>
  <li>张三</li>   // 位置 0
  <li>李四</li>   // 位置 1
  <li>王五</li>   // 位置 2
</ul>

// 在开头插入"赵六"后的新列表
<ul>
  <li>赵六</li>   // 位置 0
  <li>张三</li>   // 位置 1
  <li>李四</li>   // 位置 2
  <li>王五</li>   // 位置 3
</ul>

没有 key,React 按位置对比:位置 0 内容变了(张三→赵六)→ 更新;位置 1 内容变了 → 更新;位置 2 内容变了 → 更新;位置 3 是新增 → 新建。改了 4 个节点,实际上只是新增了 1 个。

有了 key,React 能识别出哪些节点是"同一个",从而准确复用:

<ul>
  <li key="zhaoliu">赵六</li>   // 新增
  <li key="zhangsan">张三</li>  // 复用,不更新
  <li key="lisi">李四</li>      // 复用,不更新
  <li key="wangwu">王五</li>    // 复用,不更新
</ul>

只做 1 次插入操作,剩下三个节点直接复用。


五、为什么不能用 index 做 key?

这是 React 开发中最经典的"坑"之一,我觉得有必要把例子说完整。

场景:删除列表项

初始列表 [张三, 李四, 王五],用 index 做 key:

// 初始状态
<ul>
  <li key={0}>张三</li>
  <li key={1}>李四</li>
  <li key={2}>王五</li>
</ul>

现在删除张三,列表变成 [李四, 王五]

// 删除后
<ul>
  <li key={0}>李四</li>  // key=0,内容从"张三"变成了"李四"
  <li key={1}>王五</li>  // key=1,内容从"李四"变成了"王五"
                          // key=2 消失 → 删除
</ul>

React 看到的是:

  • key=0:内容变了 → 更新
  • key=1:内容变了 → 更新
  • key=2:消失了 → 删除

结果:3 次 DOM 操作。但我们实际上只删了 1 个元素,只需要 1 次 DOM 操作


改用唯一 ID 做 key:

// 初始状态
<ul>
  <li key="zhangsan">张三</li>
  <li key="lisi">李四</li>
  <li key="wangwu">王五</li>
</ul>

// 删除后
<ul>
  <li key="lisi">李四</li>   // key 没变,内容没变 → 跳过
  <li key="wangwu">王五</li> // key 没变,内容没变 → 跳过
                              // key="zhangsan" 消失 → 删除
</ul>

React 准确识别出只有"zhangsan"消失了:1 次 DOM 操作,完全正确。


更严重的 bug:输入框状态错乱

上面的例子只是性能问题,但下面这个是功能 bug

场景:列表每一项有一个输入框,用户在第一项(张三)的输入框里填了内容,然后删除第一项。

// 每一项带输入框的组件
function ListItem({ name }) {
  return (
    <li>
      <span>{name}</span>
      <input placeholder={`备注 ${name}`} />
    </li>
  );
}

// 用 index 做 key
{list.map((item, index) => (
  <ListItem key={index} name={item.name} />
))}

删除"张三"后,React 对 key=0 做的是更新(把 name prop 改成"李四"),而不是销毁重建。

React 复用了原来"张三"那个 DOM 节点,只更新了 name 属性——但输入框是非受控的,它的内部状态(用户输入的内容)跟着 DOM 节点走,不跟着数据走。

结果:删掉张三之后,李四的输入框里还显示着刚才给张三写的备注内容。数据删了,UI 状态还留着

这种 bug 在测试环境容易被漏掉,到了生产环境才被用户发现,排查起来也很头疼。


结论

✅ 用数据的唯一 ID 做 key(数据库主键、UUID 等)
❌ 不用 index 做 key(除非列表永远不会增删排序)
❌ 不用随机数做 key(每次渲染都会强制重建,比没有 key 更差)

六、整体流程回顾

用户交互 / 数据请求
        ↓
setState / useState 触发更新
        ↓
React 调用 render,生成新的虚拟 DOM 树
        ↓
┌─────────────────────────────────────┐
│           Diff 算法(O(n))          │
│                                     │
│  类型不同?→ 直接替换                  │
│  只比同层  → 不跨层                   │
│  有 key?  → 精准识别复用              │
└─────────────────────────────────────┘
        ↓
生成最小 patch(差异集合)
        ↓
批量更新到真实 DOM
        ↓
浏览器渲染(只有变化的部分触发重排/重绘)

延伸思考

梳理完这些,我产生了几个新问题,暂时还没完全搞清楚:

  1. React Fiber 和虚拟 DOM 是什么关系? Fiber 架构是 React 16 引入的,它把虚拟 DOM 的 Diff 过程变成了可中断的,这对长列表渲染有什么具体影响?
  2. Vue 的 Diff 和 React 的 Diff 有什么区别? 听说 Vue 3 的双端对比算法在某些场景下效率更高,是什么原理?
  3. React.memouseMemo 和虚拟 DOM 的 Diff 是什么关系? 它们是在 Diff 之前就跳过了,还是 Diff 之后的优化?

这些可能是下一篇的方向,也欢迎有研究的朋友交流。


🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. 虚拟 DOM 的本质:用 JS 对象描述 DOM 结构,在内存中做 Diff,最小化真实 DOM 操作次数,解决"全量重建"导致的慢和状态丢失问题。
  2. Diff 算法的三个假设:① 不同类型节点直接替换;② 只对比同层节点;③ 用 key 识别列表节点。三个假设把复杂度从 O(n³) 降到 O(n)。
  3. key 的作用:帮助 React 识别哪些节点是"同一个",从而在列表更新时准确复用,避免不必要的 DOM 操作。
  4. index 做 key 的两种问题:性能问题(删除头部节点会触发全量更新)+ 功能 bug(非受控组件的状态跟 DOM 节点走,不跟数据走,导致状态错乱)。
  5. key 的正确选择:用数据的唯一 ID(数据库主键、UUID 等),不用 index,不用随机数。

参考资料

❌
❌