第一篇:React 组件通信的底层逻辑

今天面试一个两年经验的 React 开发,简历写着熟悉 hooks 与状态管理。我问 React 中兄弟组件怎么共享状态,他答 props 层层传或者用 context。我追问 props 传多层有什么问题,context 直接把状态放在 provider 里有什么陷阱,如何让 context 实现按需更新——他逐渐含糊,最后卡在如何避免无关组件重渲染上。这就是典型的知道多种方式,却不理解使用场景。

本质一:props 与回调——最基础的父子通信

父传子靠 props,子传父靠回调,数据流单向可追踪。一旦跨越多层,中间组件被迫转发自身不需要的 props,造成"props 钻孔",代码冗余耦合,严重时牵一发动全身。

// ❌ Props 钻孔:Middle 完全不需要 user,却被迫传递
function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  return <Middle user={user} setUser={setUser} />;
}
function Middle({ user, setUser }) {
  // Middle 本身不用 user,只是中转
  return <Child user={user} setUser={setUser} />;
}
function Child({ user, setUser }) {
  return (
    <div>
      <span>{user.name}</span>
      <button onClick={() => setUser({ name: 'Bob' })}>改名</button>
    </div>
  );
}

// ✅ 正常父子通信:直接传,层级浅时没问题
function Parent() {
  const [count, setCount] = useState(0);
  return <Counter count={count} onIncrement={() => setCount(c => c + 1)} />;
}
function Counter({ count, onIncrement }) {
  return <button onClick={onIncrement}>点击了 {count} 次</button>;
}

本质二:context 解决跨层传递,但自带性能陷阱

context 让祖先直接向所有子孙提供数据,绕过中间组件。但直接把状态对象塞进 provider 会带来性能问题——只要 provider 的 value 引用变化,所有消费该 context 的组件都会重渲染,即使只用了其中一部分数据。

// ❌ 性能陷阱:每次 App 重渲染,value 都是新对象,所有消费者全部重渲
const AppContext = createContext(null);
function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('dark');
  // 每次任意状态变化,这个对象都是新引用
  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      <UserPanel />  {/* 只关心 user,但 theme 变也会重渲 */}
      <ThemePanel /> {/* 只关心 theme,但 user 变也会重渲 */}
    </AppContext.Provider>
  );
}

// ✅ 拆分 context + useMemo:按领域隔离,减少无关重渲
const UserContext = createContext(null);
const ThemeContext = createContext(null);

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('dark');

  const userValue = useMemo(() => ({ user, setUser }), [user]);
  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <UserContext.Provider value={userValue}>
      <ThemeContext.Provider value={themeValue}>
        <UserPanel />   {/* theme 变时不会重渲 */}
        <ThemePanel />  {/* user 变时不会重渲 */}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

function UserPanel() {
  const { user } = useContext(UserContext);
  return <div>用户:{user.name}</div>;
}
function ThemePanel() {
  const { theme } = useContext(ThemeContext);
  return <div>主题:{theme}</div>;
}

本质三:状态管理库——比 context 更专业的全局共享

context 的核心局限:无法跨非父子树共享(如跨路由)、与组件生命周期强绑定、大规模应用下调试困难、性能难以控制。

Zustand 本质是在 React 之外创建独立的响应式存储,通过发布-订阅让组件精确订阅部分状态,从机制上避免无关渲染。Zustand 默认不需要 Provider(这是它区别于 Context 的核心优势);Redux Toolkit 则仍然需要 <Provider store={store}> 包裹。

// ✅ Zustand:无需 Provider,组件直接订阅所需字段
import { create } from 'zustand';

const useStore = create((set) => ({
  user: { name: 'Alice' },
  theme: 'dark',
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
}));

// UserPanel 只订阅 user,theme 变化不会触发重渲
function UserPanel() {
  const user = useStore((state) => state.user); // 选择器精确订阅
  return <div>用户:{user.name}</div>;
}

// ThemePanel 只订阅 theme,user 变化不会触发重渲
function ThemePanel() {
  const theme = useStore((state) => state.theme);
  return <div>主题:{theme}</div>;
}

// ✅ Redux Toolkit:需要 Provider,但 useSelector 同样支持精确订阅
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

const userSlice = createSlice({
  name: 'user',
  initialState: { name: 'Alice' },
  reducers: {
    setUser: (state, action) => { state.name = action.payload; },
  },
});

const store = configureStore({ reducer: { user: userSlice.reducer } });

function App() {
  return (
    <Provider store={store}>  {/* Redux Toolkit 必须有 Provider */}
      <UserPanel />
    </Provider>
  );
}

function UserPanel() {
  const name = useSelector((state) => state.user.name); // 精确订阅
  const dispatch = useDispatch();
  return (
    <button onClick={() => dispatch(userSlice.actions.setUser('Bob'))}>
      {name}
    </button>
  );
}

本质四:其他通信配角

事件总线(如 mitt)适合极简单的跨组件通知,但在大型项目里难以追踪数据流,容易内存泄漏,不推荐大量使用。forwardRef + useImperativeHandle 适合父组件主动调用子组件方法,但本质上是命令式,应少用。render props / 组合模式可以把控制权反转,减少 props 钻孔,是设计层面的解法。

// ✅ forwardRef + useImperativeHandle:父调用子方法
const InputBox = forwardRef((props, ref) => {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; },
  }));
  return <input ref={inputRef} {...props} />;
});

function Parent() {
  const inputRef = useRef();
  return (
    <>
      <InputBox ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>聚焦</button>
      <button onClick={() => inputRef.current.clear()}>清空</button>
    </>
  );
}

// ✅ mitt 事件总线:跨组件简单通知(小型场景)
import mitt from 'mitt';
const emitter = mitt();

function ComponentA() {
  useEffect(() => {
    // 组件卸载时必须移除监听,否则内存泄漏
    const handler = (data) => console.log('收到:', data);
    emitter.on('update', handler);
    return () => emitter.off('update', handler);
  }, []);
  return <div>A</div>;
}

function ComponentB() {
  return <button onClick={() => emitter.emit('update', { msg: 'hello' })}>发送</button>;
}

灵魂总结

父子通信用 props 加回调,保证单向数据流;爷孙跨层级用 context,但必须配合拆分与 memo 控制渲染;全局状态用 Zustand(无需 Provider)或 Redux Toolkit(需要 Provider)实现精确订阅,摆脱 context 性能枷锁;透传与命令式场景用 ref,跨组件简单通知用事件总线,但都要克制使用。


第二篇:微前端 JS 隔离的完整答案

面试一个五年前端,简历写着主导过大型微前端架构落地,精通 JS 沙箱与隔离机制。我问 qiankun 是怎么做 JS 隔离的,他反应很快:主要靠快照沙箱和代理沙箱。快照沙箱是激活时记录 window 当前状态,失活时对比差异并恢复,适用于单实例场景;代理沙箱通过 Proxy 为每个子应用创建独立的全局对象代理,多实例也不会互相污染。

我继续追问:快照沙箱遍历 window 所有可枚举属性时开销怎么控制?代理沙箱如果子应用绕过 Proxy,比如用 eval 或 Function 构造器执行代码,这些变量能落到沙箱上吗?

第一层:快照沙箱与代理沙箱的原理与硬伤

// ✅ 快照沙箱简化实现
class SnapshotSandbox {
  constructor() {
    this.snapshot = {};
    this.modifyPropsMap = {};
  }

  activate() {
    // 记录当前 window 快照(只记录可枚举属性,性能硬伤所在)
    for (const key in window) {
      this.snapshot[key] = window[key];
    }
    // 恢复上次沙箱内的修改
    Object.keys(this.modifyPropsMap).forEach(key => {
      window[key] = this.modifyPropsMap[key];
    });
  }

  deactivate() {
    for (const key in window) {
      if (window[key] !== this.snapshot[key]) {
        this.modifyPropsMap[key] = window[key]; // 记录变更
        window[key] = this.snapshot[key];        // 恢复原值
      }
    }
  }
}

// ✅ 代理沙箱简化实现:多实例互不污染
class ProxySandbox {
  constructor() {
    this.fakeWindow = {};
    this.active = false;

    this.proxy = new Proxy(this.fakeWindow, {
      get(target, key) {
        // 先查沙箱自己的对象,找不到再查真实 window
        return key in target ? target[key] : window[key];
      },
      set(target, key, value) {
        if (this.active) {
          target[key] = value; // 写入沙箱对象,不污染真实 window
        }
        return true;
      }.bind(this),
    });
  }

  activate() { this.active = true; }
  deactivate() { this.active = false; }
}

// 两个子应用互不干扰
const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();
sandbox1.activate();
sandbox1.proxy.myLib = 'app1';
sandbox2.activate();
sandbox2.proxy.myLib = 'app2';
console.log(sandbox1.proxy.myLib); // 'app1'
console.log(sandbox2.proxy.myLib); // 'app2'
console.log(window.myLib);          // undefined,真实 window 未被污染

第二层:逃逸防御与副作用回收

代理沙箱只能拦截显式的属性读写,eval / new Function 执行的代码、addEventListener、setTimeout 等都会绕过 Proxy 直接操作真实 window。

// ❌ 逃逸示例:eval 声明的变量落在真实 window,不在沙箱里
const sandbox = new ProxySandbox();
sandbox.activate();
// eval 在全局作用域执行,绕过 proxy
eval('var leaked = "污染了真实window"');
console.log(window.leaked); // "污染了真实window" ← 逃逸!

// ❌ new Function 同理
const fn = new Function('globalVar = "污染"');
fn(); // window.globalVar 被污染

// ✅ 副作用收集与回收:拦截 addEventListener / setTimeout
class SandboxWithSideEffects {
  constructor() {
    this.sideEffects = [];
  }

  // 代理 addEventListener,失活时自动移除
  patchEventListener() {
    const original = window.addEventListener.bind(window);
    const self = this;
    window.addEventListener = function(type, handler, options) {
      self.sideEffects.push(() => window.removeEventListener(type, handler, options));
      return original(type, handler, options);
    };
  }

  // 代理 setTimeout,失活时自动清除
  patchTimer() {
    const original = window.setTimeout.bind(window);
    const self = this;
    window.setTimeout = function(fn, delay, ...args) {
      const id = original(fn, delay, ...args);
      self.sideEffects.push(() => clearTimeout(id));
      return id;
    };
  }

  // 失活时批量回收所有副作用
  deactivate() {
    this.sideEffects.forEach(cleanup => cleanup());
    this.sideEffects = [];
    // 清理逻辑超过 10ms 就分片,避免切换掉帧
  }
}

第三层:Shadow Realm——原生执行域隔离

Shadow Realm 是 TC39 已进入 Stage 3 的提案(部分现代浏览器已支持),能提供真正隔离的全局作用域,从机制上杜绝变量逃逸。注意:这是新兴方案,目前框架以特性检测降级处理,不能假设所有环境都支持。

// ✅ Shadow Realm:每个子应用拥有完全独立的全局对象
if (typeof ShadowRealm !== 'undefined') {
  const realm = new ShadowRealm();

  // 在 realm 内执行的代码拥有独立的全局作用域
  realm.evaluate('var x = 42');
  console.log(typeof x); // "undefined",主 window 完全不受影响

  // 通过 importValue 从 realm 取出值(只能传递 primitive 或 callable)
  const getX = realm.evaluate('() => x');
  console.log(getX()); // 42

  // 子应用代码完全无法访问主应用的任何变量
  realm.evaluate(`
    try { console.log(document.title); } // 受限,无法访问主 document
    catch(e) { console.log('隔离成功:', e.message); }
  `);
} else {
  // 降级为 ProxySandbox
  console.log('ShadowRealm 不支持,降级为 Proxy 沙箱');
}

// ✅ qiankun 风格的特性检测自动分流
function createSandbox(appName) {
  if (typeof ShadowRealm !== 'undefined') {
    return new ShadowRealmSandbox(appName);
  }
  return new ProxySandbox(appName); // 降级
}

第三篇:跨域携带 Cookie 的死胡同与新方案

面试候选人,题目是跨域携带 Cookie。他答:前端设置 withCredentials,后端返回 Access-Control-Allow-Origin 不能用通配符,再加上 Access-Control-Allow-Credentials: true,cookie 就能自动带过去。

面试官笑了:两个域名完全不一样,无法共享 cookie domain 配置。他慌了,说 SameSite=None; Secure 就行了。但浏览器 cookie 的归属只看请求目标域名,不看页面来自哪。而且 2026 年,Chrome 已彻底禁用第三方 cookie,这些方案全部失效。

本质一:cookie 的发送规则

// cookie 发送的三个条件:
// 1. cookie 的 domain 与请求 URL 的主机匹配
// 2. cookie 的 path 与请求路径匹配
// 3. SameSite 策略允许跨站发送

// ✅ 同域携带 cookie(正常场景)
// 页面:https://app.example.com
// 请求:https://api.example.com(子域)
// cookie:domain=.example.com  → 可以携带

fetch('https://api.example.com/user', {
  credentials: 'include', // 携带 cookie
});

// 后端响应头(子域共享场景下)
// Access-Control-Allow-Origin: https://app.example.com  (不能用 *)
// Access-Control-Allow-Credentials: true
// Set-Cookie: token=abc; Domain=.example.com; SameSite=Lax; Secure

// ❌ 跨站场景(两个完全不同的域名)
// 页面:https://site-a.com
// 要携带:https://site-b.com 的 cookie
// → 这在 2026 年的现代浏览器中已经不可能,SameSite=None 方案也失效

本质二:第三方 cookie 的消亡时间线

2020 年:Chrome  SameSite 默认值从 None 改为 Lax
2024 年:Chrome 开始对 1% 用户禁用第三方 cookie(灰度)
2026 年:所有主流浏览器(Chrome / Firefox / Safari)全面禁用第三方 cookie
         SameSite=None; Secure 在第三方上下文中也被分区存储(Partitioned)
          跨站请求中携带另一个域的 cookie 已是死胡同

本质三:2026 年可落地的三条替代路径

// ✅ 方案一:BFF 令牌中转模式(最稳定)
// 前端只与自身域名通信,由 BFF 服务端代理跨域请求

// 前端代码(只请求自己域名的 BFF)
async function getUserData() {
  const res = await fetch('/bff/api/user', {
    credentials: 'include', // 携带自己域的 cookie
  });
  return res.json();
}

// BFF 服务端(Node.js 示例):以服务端身份请求第三方接口
app.get('/bff/api/user', async (req, res) => {
  const sessionToken = req.cookies.session; // 读取前端的 cookie
  // 服务端直接请求第三方,不受浏览器限制
  const data = await fetch('https://third-party-api.com/user', {
    headers: {
      'Authorization': `Bearer ${sessionToken}`,
      'X-Service-Token': process.env.SERVICE_SECRET,
    },
  }).then(r => r.json());
  res.json(data);
});

// ✅ 方案二:FedCM 联合身份认证(W3C 标准)
// 利用 Federated Credential Management API,用户授权后获取 id_token
async function loginWithFedCM() {
  try {
    const credential = await navigator.credentials.get({
      identity: {
        providers: [{
          configURL: 'https://idp.example.com/.well-known/web-identity',
          clientId: 'my-client-id',
          nonce: crypto.randomUUID(),
        }],
      },
    });
    // credential.token 是 IDP 颁发的 id_token
    const { token } = credential;

    // 将 token 显式注入后续请求头,不依赖 cookie 自动携带
    await fetch('https://api.example.com/resource', {
      headers: { 'Authorization': `Bearer ${token}` },
    });
  } catch (err) {
    console.error('FedCM 失败,降级到传统登录', err);
  }
}

// ✅ 方案三:Related Website Sets(关联网站集合)
// 适合同一公司旗下多个域名(如 example.com / example.co.uk / brand.com)
// 在 .well-known/related-website-set.json 中声明集合成员
// {
//   "primary": "https://example.com",
//   "associatedSites": ["https://example.co.uk", "https://brand.com"]
// }

// 前端通过 Storage Access API 请求访问权限
async function requestCookieAccess() {
  try {
    await document.requestStorageAccess();
    // 获得授权后,集合内跨站请求可携带分区 cookie
    await fetch('https://example.co.uk/api/data', { credentials: 'include' });
  } catch (err) {
    console.error('访问被拒绝', err);
  }
}

本质四:老系统兼容方案(有历史包袱时)

// ✅ SSO 网关统一代理:cookie 只保留在中心域,前端显式注入凭据
// 登录流程:用户在 sso.example.com 登录 → 获取一次性跨域凭证

// 登录后,SSO 下发短期有效的 cross-domain-token
const crossDomainToken = await fetch('https://sso.example.com/token').then(r => r.json());

// 前端在所有子应用的请求拦截器中显式注入该凭据
// Axios 拦截器示例
axios.interceptors.request.use(config => {
  config.headers['X-Cross-Domain-Token'] = crossDomainToken.value;
  return config;
});

// 各子应用的 cookie 保留在各自域名,不依赖浏览器自动附加
// 网关收到 X-Cross-Domain-Token 后换取对应域的服务端 session

第四篇:百万任务不卡顿的浏览器调度体系

面试一个五年前端,简历写着主导过千万级数据可视化项目。我问:一个页面需要执行 100 万个任务,怎么保证浏览器不卡顿?

他答:用时间分片,把任务拆成小块,每执行一小段就让出主线程,用 requestIdleCallback 或 setTimeout 控制。思路没问题,但追问更深入的场景时他就卡住了。

第一层:任务分片与优先级调度

setTimeout(fn, 0)requestIdleCallback 都有硬伤:前者最小延迟约 4ms 且不感知帧,后者在移动端触发不稳定且低优任务可能长期饿死。2026 年的标准答案是 scheduler.postTask()

// ❌ 旧方案:setTimeout 分片,不感知用户输入,高低优混在一起
function processWithSetTimeout(tasks) {
  let index = 0;
  function run() {
    const start = performance.now();
    while (index < tasks.length && performance.now() - start < 5) {
      tasks[index++](); // 固定 5ms 切片,低端机照样卡
    }
    if (index < tasks.length) setTimeout(run, 0);
  }
  run();
}

// ✅ 新方案一:scheduler.postTask() 原生优先级调度
async function processWithScheduler(tasks) {
  const HIGH = [];
  const NORMAL = [];
  const LOW = [];

  tasks.forEach(task => {
    if (task.priority === 'user-input') HIGH.push(task);
    else if (task.priority === 'data-update') NORMAL.push(task);
    else LOW.push(task);
  });

  // user-blocking:用户正在等待的操作(最高优)
  for (const task of HIGH) {
    await scheduler.postTask(() => task.run(), { priority: 'user-blocking' });
  }
  // user-visible:用户可见但非立即阻塞
  for (const task of NORMAL) {
    await scheduler.postTask(() => task.run(), { priority: 'user-visible' });
  }
  // background:日志上报等后台任务(最低优,不会饿死,有超时提升)
  for (const task of LOW) {
    await scheduler.postTask(() => task.run(), { priority: 'background' });
  }
}

// ✅ 新方案二:配合 scheduler.isInputPending() 动态让渡
async function processWithInputCheck(tasks) {
  let index = 0;
  while (index < tasks.length) {
    // 检测到用户有输入,立刻让渡控制权
    if (navigator.scheduling?.isInputPending()) {
      await new Promise(resolve => setTimeout(resolve, 0));
      continue;
    }
    tasks[index++]();
  }
}

// ✅ 降级方案:不支持 scheduler API 时用 MessageChannel(比 setTimeout 更快)
function yieldToMain() {
  return new Promise(resolve => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve;
    channel.port2.postMessage(undefined);
  });
}

async function processWithYield(tasks) {
  for (let i = 0; i < tasks.length; i++) {
    tasks[i]();
    if (i % 100 === 0) await yieldToMain(); // 每 100 个任务让出一次
  }
}

第二层:Worker 线程池与职责分离

// ✅ 常驻 Worker 线程池(避免频繁创建销毁开销)
class WorkerPool {
  constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
    this.workers = Array.from({ length: poolSize }, () => new Worker(workerScript));
    this.queue = [];
    this.idle = [...this.workers];
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const execute = (worker) => {
        worker.onmessage = ({ data: result }) => {
          resolve(result);
          this.idle.push(worker);
          if (this.queue.length) this.queue.shift()(this.idle.pop());
        };
        worker.onerror = reject;
        worker.postMessage(data);
      };

      if (this.idle.length) {
        execute(this.idle.pop());
      } else {
        this.queue.push(execute); // 排队等待空闲 worker
      }
    });
  }
}

// worker.js(纯计算,不操作 DOM)
self.onmessage = ({ data }) => {
  const result = heavyCompute(data); // 排序、转换、加密等
  self.postMessage(result);
};

// 主线程:计算在 Worker,DOM 更新在主线程 + rAF 批处理
async function processAndRender(rawData) {
  const pool = new WorkerPool('./worker.js');
  const chunks = chunkArray(rawData, 1000); // 分块

  const results = await Promise.all(chunks.map(chunk => pool.run(chunk)));

  // 把所有 DOM 更新集中到一次 rAF,避免多次强制同步布局
  requestAnimationFrame(() => {
    const fragment = document.createDocumentFragment();
    results.flat().forEach(item => {
      const el = document.createElement('div');
      el.textContent = item.value;
      fragment.appendChild(el);
    });
    document.getElementById('list').appendChild(fragment);
  });
}

// ✅ OffscreenCanvas:图表绘制全部在 Worker,主线程彻底解放
// 主线程
const canvas = document.getElementById('chart');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('./chart-worker.js');
worker.postMessage({ canvas: offscreen, data: chartData }, [offscreen]);

// chart-worker.js
self.onmessage = ({ data: { canvas, data } }) => {
  const ctx = canvas.getContext('2d');
  // 直接在 worker 里绘制,不阻塞主线程
  drawChart(ctx, data);
};

第三层:自适应性能监控与防裂化

// ✅ PerformanceObserver 监控长任务,自动降级
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`长任务检测:${entry.duration.toFixed(0)}ms`);
      // 连续两个长任务 + 交互延迟 → 自动降级
      scheduler.adjustBatchSize('down'); // 伪代码,实际业务逻辑
    }
  }
});
observer.observe({ type: 'longtask', buffered: true });

// ✅ 任务老化计数器:低优任务等待超时自动提升优先级
class PriorityQueue {
  constructor() {
    this.tasks = [];
  }

  add(task, priority = 'background') {
    this.tasks.push({ task, priority, addedAt: Date.now() });
  }

  flush() {
    const now = Date.now();
    this.tasks.forEach(item => {
      // 低优任务等待超过 3 秒,自动提升为 user-visible
      if (item.priority === 'background' && now - item.addedAt > 3000) {
        item.priority = 'user-visible';
        console.log('任务老化提升优先级');
      }
    });
    // 按优先级排序执行
    this.tasks.sort((a, b) => priorityWeight(a.priority) - priorityWeight(b.priority));
  }
}

function priorityWeight(p) {
  return { 'user-blocking': 0, 'user-visible': 1, 'background': 2 }[p] ?? 2;
}

第五篇:白屏排查的系统性诊断流程

面试一个自称对前端性能与稳定性有深度实践的三年前端工程师。我问用户打开页面白屏了,可能的原因有哪些,怎么排查?他答:可能是网络问题、JS 报错或者接口挂了,按 F12 看控制台和网络面板。

真正的白屏排查是一套系统性诊断流程,分四层。

第一层:基础信息确认

// ✅ 在页面入口处埋点,主动上报白屏
// 判断页面是否真正渲染了内容
function detectBlankPage() {
  const checkTime = 3000; // 3 秒后检测

  setTimeout(() => {
    const rootEl = document.getElementById('root') || document.getElementById('app');
    const hasContent = rootEl && rootEl.children.length > 0 && rootEl.innerText.trim().length > 0;

    if (!hasContent) {
      // 上报白屏事件
      reportBlankPage({
        url: location.href,
        timing: performance.timing,
        userAgent: navigator.userAgent,
        timestamp: Date.now(),
      });
    }
  }, checkTime);
}

// ✅ 检查关键生命周期事件是否触发
window.addEventListener('DOMContentLoaded', () => {
  performance.mark('dom-ready');
});
window.addEventListener('load', () => {
  performance.mark('page-load');
  performance.measure('load-time', 'dom-ready', 'page-load');
});

第二层:链路与资源诊断

// ✅ 资源加载失败监听(捕获阶段,能拦截 img/script/link 的加载失败)
window.addEventListener('error', (event) => {
  const target = event.target;
  if (target && (target.tagName === 'SCRIPT' || target.tagName === 'LINK' || target.tagName === 'IMG')) {
    console.error('资源加载失败:', target.src || target.href);
    reportResourceError({ tag: target.tagName, url: target.src || target.href });
    // SRI 完整性校验失败也会触发这里
  }
}, true); // 必须用捕获阶段

// ✅ 检查 CSP 是否阻断脚本(监听 securitypolicyviolation 事件)
document.addEventListener('securitypolicyviolation', (e) => {
  console.error('CSP 违规,脚本被阻断:', e.blockedURI, e.violatedDirective);
  // 这种情况下控制台没有普通红色报错,需要主动监听
});

// ✅ 使用 Performance API 检测 FCP 是否触发
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log('FCP:', entry.startTime, 'ms');
      if (entry.startTime > 3000) {
        console.warn('FCP 超过 3s,疑似白屏');
      }
    }
  }
});
observer.observe({ type: 'paint', buffered: true });

第三层:渲染与脚本执行层

// ✅ 捕获未处理的 Promise 异常(不触发 window.onerror,需单独监听)
window.addEventListener('unhandledrejection', (event) => {
  console.error('未捕获的 Promise 异常,可能导致后续脚本停止:', event.reason);
  reportError({ type: 'unhandledrejection', message: event.reason?.message });
  event.preventDefault(); // 阻止默认控制台输出(可选)
});

// ✅ 全局错误边界(React):防止组件异常导致整个树白屏
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    // 上报错误和组件堆栈
    reportError({ error: error.message, stack: info.componentStack });
  }

  render() {
    if (this.state.hasError) {
      return <div>页面出现问题,请刷新重试</div>; // 降级 UI,不白屏
    }
    return this.props.children;
  }
}

// ✅ SSR 注水失败检测(React 18)
// 注水不匹配时 React 会输出警告,但不会抛错,需主动检测
if (typeof window !== 'undefined') {
  const rootEl = document.getElementById('root');
  if (rootEl && !rootEl.hasAttribute('data-reactroot') && rootEl.children.length === 0) {
    console.warn('SSR 注水失败,客户端接管渲染');
  }
}

第四层:工程化兜底与监控

// ✅ Service Worker 兜底:主服务不可用时返回最小可用 HTML
// sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).catch(() => {
      // 网络请求失败时,返回缓存的骨架 HTML
      return caches.match('/offline-shell.html') || new Response(
        '<html><body><div id="root"><p>网络异常,请检查连接</p></div></body></html>',
        { headers: { 'Content-Type': 'text/html' } }
      );
    })
  );
});

// ✅ 弱网检测与降级
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
  const effectiveType = connection.effectiveType; // '2g' | '3g' | '4g' | '5g'
  if (effectiveType === '2g' || effectiveType === 'slow-2g') {
    // 弱网时启用极简骨架,延迟加载非关键资源
    document.documentElement.classList.add('low-bandwidth');
    // 禁止自动播放视频、关闭高清图片
  }
}

// ✅ 103 Early Hints:服务端提前推送关键 CSS,消除白屏等待
// 服务端(Node.js + HTTP/2)
res.writeEarlyHints({
  link: [
    '</styles/critical.css>; rel=preload; as=style',
    '</fonts/main.woff2>; rel=preload; as=font; crossorigin',
  ],
});

第六篇:健壮的并发请求方案设计

面试场景:页面需要同时请求用户画像、商品库存、广告位配置三个接口,用 Promise.all。广告位接口挂了,整个 Promise.all 失败,用户画像和商品库存也出不来了。如何设计更健壮的方案?

一、Promise.allSettled——ES2020 的答案

// ❌ Promise.all:任一失败则整体失败
async function fetchAll_bad() {
  const [user, stock, ad] = await Promise.all([
    fetchUser(),    // 成功
    fetchStock(),   // 成功
    fetchAd(),      // 失败 → 整体失败,user 和 stock 白请求了
  ]);
}

// ✅ Promise.allSettled:等所有请求安顿,单独处理每个结果
async function fetchAll_good() {
  const results = await Promise.allSettled([
    fetchUser(),
    fetchStock(),
    fetchAd(),
  ]);

  results.forEach((result, index) => {
    const names = ['用户画像', '商品库存', '广告位配置'];
    if (result.status === 'fulfilled') {
      console.log(`${names[index]} 成功:`, result.value);
    } else {
      console.warn(`${names[index]} 失败:`, result.reason);
      // 广告位失败只是不展示广告,不影响核心功能
    }
  });
}

二、手工实现软失败

// ✅ 为每个 Promise 添加软失败包装,失败时返回约定的默认值
function withFallback(promise, fallback = null) {
  return promise.catch(err => {
    console.warn('请求失败,使用降级数据:', err.message);
    return fallback;
  });
}

async function fetchPageData() {
  const [user, stock, ad] = await Promise.all([
    withFallback(fetchUser(), { name: '游客', level: 0 }),
    withFallback(fetchStock(), { available: true }),
    withFallback(fetchAd(), null), // 广告失败返回 null,不展示广告
  ]);

  return { user, stock, ad };
}

三、超时控制 + 并发限制 + 重试

// ✅ 给单个请求加超时限制
function withTimeout(promise, ms = 3000) {
  const timeout = new Promise((_, reject) => {
    const id = setTimeout(() => {
      clearTimeout(id);
      reject(new Error(`请求超时(${ms}ms)`));
    }, ms);
  });
  return Promise.race([promise, timeout]);
}

// ✅ 带中断的超时(配合 AbortController,真正取消请求)
function fetchWithTimeout(url, options = {}, ms = 3000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), ms);

  return fetch(url, { ...options, signal: controller.signal })
    .finally(() => clearTimeout(id));
}

// ✅ 并发数量控制:控制同时发起的请求数,避免占满带宽
async function concurrentFetch(urls, limit = 3) {
  const results = [];
  const executing = new Set();

  for (const url of urls) {
    const promise = fetch(url).then(r => r.json()).finally(() => executing.delete(promise));
    results.push(promise);
    executing.add(promise);

    if (executing.size >= limit) {
      await Promise.race(executing); // 等最快的一个完成再继续
    }
  }

  return Promise.allSettled(results);
}

// ✅ 失败重试(指数退避,避免雪崩)
async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fetch(url, options).then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      });
    } catch (err) {
      if (i === retries - 1) throw err;
      const delay = Math.min(1000 * 2 ** i, 8000); // 1s → 2s → 4s
      console.warn(`第 ${i + 1} 次重试,等待 ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

第七篇:微应用跨应用通信容错的四层防线

面试两年前端,简历写着精通微应用架构与跨应用通信。我问跨应用通信故障怎么做容错处理,他张口就来:加个重试,返回默认数据,隐藏报错模块就行。

第一层:基础兜底

// ✅ 基础通信封装:try-catch + 超时 + 默认数据
async function crossAppFetch(targetApp, method, params) {
  try {
    const result = await withTimeout(
      postMessageRequest(targetApp, method, params),
      2000
    );
    return result;
  } catch (err) {
    console.warn(`跨应用调用 ${targetApp}.${method} 失败:`, err);
    return getDefaultData(method); // 返回约定的降级数据
  }
}

// postMessage 跨应用通信基础封装
function postMessageRequest(targetApp, method, params) {
  return new Promise((resolve, reject) => {
    const requestId = Date.now() + Math.random();
    const handler = ({ data }) => {
      if (data.requestId === requestId) {
        window.removeEventListener('message', handler);
        data.error ? reject(data.error) : resolve(data.result);
      }
    };
    window.addEventListener('message', handler);
    targetApp.postMessage({ requestId, method, params }, targetApp.origin);
  });
}

第二层:分类降级

// ✅ 父子应用通信降级:postMessage 失败时用 URL 参数 / localStorage 备选
class ParentChildComm {
  async send(childWindow, data) {
    try {
      // 主通道:postMessage
      childWindow.postMessage({ type: 'data', payload: data }, '*');
    } catch (err) {
      // 降级:localStorage 临时同步
      localStorage.setItem('cross-app-fallback', JSON.stringify({ data, timestamp: Date.now() }));
      console.warn('postMessage 失败,已写入 localStorage 降级通道');
    }
  }

  listen(callback) {
    // 主通道监听
    window.addEventListener('message', ({ data }) => {
      if (data.type === 'data') callback(data.payload);
    });
    // 轮询降级通道(备选)
    setInterval(() => {
      const raw = localStorage.getItem('cross-app-fallback');
      if (raw) {
        const { data, timestamp } = JSON.parse(raw);
        if (Date.now() - timestamp < 5000) callback(data);
        localStorage.removeItem('cross-app-fallback');
      }
    }, 500);
  }
}

// ✅ 跨应用状态同步降级:快照恢复核心状态
class StateSyncManager {
  constructor() {
    this.snapshot = null;
  }

  saveSnapshot(state) {
    this.snapshot = { ...state, savedAt: Date.now() };
    sessionStorage.setItem('app-state-snapshot', JSON.stringify(this.snapshot));
  }

  restoreFromSnapshot() {
    const raw = sessionStorage.getItem('app-state-snapshot');
    if (raw) {
      const snapshot = JSON.parse(raw);
      console.warn('跨应用状态同步失败,从快照恢复');
      return snapshot;
    }
    return null;
  }
}

第三层:工程化熔断机制

// ✅ 自动熔断:失败频次超阈值时停止调用,避免级联崩溃
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.recoveryTime = options.recoveryTime || 10000;
    this.failures = 0;
    this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
    this.nextAttempt = null;
  }

  async call(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('熔断器已开启,拒绝请求');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failures++;
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.recoveryTime;
      console.error(`熔断器开启,${this.recoveryTime / 1000}s 后尝试恢复`);
    }
  }
}

const breaker = new CircuitBreaker({ failureThreshold: 5, recoveryTime: 10000 });

async function safeCrossAppCall(method, params) {
  return breaker.call(() => crossAppFetch('subApp', method, params));
}

第四层:监控与用户体验

// ✅ 通信失败上报监控
function reportCommFailure(appName, method, error) {
  const metric = {
    type: 'cross-app-comm-failure',
    app: appName,
    method,
    error: error.message,
    timestamp: Date.now(),
    url: location.href,
  };
  // 上报到监控平台(如 Sentry、自建监控)
  navigator.sendBeacon('/api/monitor', JSON.stringify(metric));
}

// ✅ 弱网环境优化:自动切换轻量通信协议
const connection = navigator.connection;
const isSlowNetwork = connection && ['2g', 'slow-2g'].includes(connection.effectiveType);

async function adaptiveCrossAppCall(method, params) {
  if (isSlowNetwork) {
    // 弱网:只传核心字段,非核心通信延迟执行
    const coreParams = pickCoreFields(params);
    return crossAppFetch('subApp', method, coreParams);
  }
  return crossAppFetch('subApp', method, params);
}

第八篇:useOptimistic 核心原理深度解析

今天面试一个三年 React 经验的开发者,简历写着熟练使用 React 19+ 新特性,掌握乐观更新原理。我问:为什么 useOptimistic 不能在非用户交互场景调用?他只说了表层结论,追问底层机制时开始支支吾吾。

本质一:useOptimistic 的底层存储——状态队列加版本标记

React 19 内部通过一个与 fiber 节点绑定的状态队列管理乐观更新,每个乐观更新携带唯一 lane(优先级标记),与触发源(用户交互的 transition)强关联,而非简单的"版本号栈"。

// ✅ useOptimistic 基础用法(React 19)
import { useOptimistic, useTransition } from 'react';

function MessageList({ messages, onSend }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    // reducer:如何将乐观值合并到当前状态
    (currentMessages, newMessage) => [
      ...currentMessages,
      { ...newMessage, sending: true }, // 标记为发送中
    ]
  );

  const [isPending, startTransition] = useTransition();

  async function handleSend(text) {
    // ✅ 必须在 startTransition 内调用 addOptimistic
    // 因为 transition 创建了 React 可追踪的交互上下文
    startTransition(async () => {
      addOptimistic({ id: Date.now(), text }); // 立即显示乐观状态

      try {
        await onSend(text); // 等待服务端确认
        // 成功:服务端返回后,React 用真实数据替换乐观状态
      } catch (err) {
        // 失败:React 自动回滚到 addOptimistic 之前的状态
        console.error('发送失败,已自动回滚');
      }
    });
  }

  return (
    <ul>
      {optimisticMessages.map(msg => (
        <li key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
          {msg.text} {msg.sending && '(发送中...)'}
        </li>
      ))}
      <button onClick={() => handleSend('新消息')}>发送</button>
    </ul>
  );
}

本质二:为什么不能在非用户交互场景调用

// ❌ 错误:在 useEffect 中直接调用 addOptimistic(非交互场景)
function BadComponent({ messages }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(messages, (s, v) => [...s, v]);

  useEffect(() => {
    // ❌ 这里没有 transition 上下文
    // React 无法将这个乐观更新与任何"可回滚的交互"关联
    // 如果后续有异步操作失败,React 不知道该回滚到哪个状态
    addOptimistic({ id: 999, text: '自动添加' }); // 会被 React 警告或忽略
  }, []);

  return <ul>{optimisticMessages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}

// ❌ 错误:在普通异步回调中调用(同样缺少交互上下文)
function BadComponent2() {
  const [data, setData] = useState([]);
  const [optimistic, addOptimistic] = useOptimistic(data, (s, v) => [...s, v]);

  // 定时器触发,不是用户交互,没有 transition 上下文
  useEffect(() => {
    const id = setInterval(() => {
      addOptimistic({ id: Date.now(), text: '定时推送' }); // ❌
    }, 1000);
    return () => clearInterval(id);
  }, []);
}

// ✅ 正确:必须包裹在 startTransition 或 action 中
function GoodComponent({ onAutoRefresh }) {
  const [data, setData] = useState([]);
  const [optimistic, addOptimistic] = useOptimistic(data, (s, v) => [...s, v]);
  const [, startTransition] = useTransition();

  // 如果确实需要非交互触发,必须手动创建 transition
  function triggerOptimisticUpdate() {
    startTransition(() => {
      addOptimistic({ id: Date.now(), text: '程序触发' }); // ✅
    });
  }
}

本质三:与 React 19 的 useActionState 配合使用

// ✅ React 19 推荐模式:useActionState + useOptimistic 配合
import { useActionState, useOptimistic } from 'react';

function TodoList({ initialTodos }) {
  const [todos, formAction, isPending] = useActionState(
    async (currentTodos, formData) => {
      const text = formData.get('todo');
      await addTodoToServer(text); // 服务端操作
      return [...currentTodos, { id: Date.now(), text, done: false }];
    },
    initialTodos
  );

  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (current, newTodo) => [...current, { ...newTodo, isPending: true }]
  );

  return (
    <form action={(formData) => {
      // action 内部自动创建 transition 上下文,addOptimistic 可以安全调用
      addOptimistic({ id: Date.now(), text: formData.get('todo') });
      formAction(formData);
    }}>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.isPending ? 0.6 : 1 }}>
            {todo.text}
          </li>
        ))}
      </ul>
      <input name="todo" />
      <button type="submit" disabled={isPending}>添加</button>
    </form>
  );
}

第九篇:Vite 为什么比 Webpack 快

面试一个精通工程化构建的五年前端,我问 Vite 为什么能比 Webpack 启动快这么多。他答:Vite 是新一代构建工具,Webpack 太老了,用了 ES 模块不用打包所以更快。这些都是表面答案,核心原理完全没讲清。

Webpack 的三个致命缺陷

// Webpack 启动时的工作流程(伪代码说明)
// 1. 从入口文件开始,递归解析所有依赖(可能有数千个模块)
// 2. 将所有模块打包合并成 bundle(哪怕你只改了一行代码)
// 3. 等全部打包完成,dev server 才能启动

// 项目有 1000 个模块时,每次启动都要完整走一遍这个流程
// 项目越大,启动越慢(可能需要 30s~120s)

// HMR(热更新)同样有问题:
// 改动一个文件 → 重新构建该文件的整个依赖链 → 推送到浏览器
// 即使是小改动,依赖链很长时也会明显卡顿

Vite 的四个核心优势

// ✅ 优势一:基于原生 ESM,开发时无需打包
// Vite dev server 启动时几乎不做任何构建工作
// 浏览器请求哪个模块,Vite 才即时编译那个模块

// 浏览器请求 /src/main.js
// 浏览器请求 /src/App.vue   ← 只有这两个被请求了才会被编译
// 其他几百个未访问的模块完全不处理

// ✅ 优势二:依赖预构建(只做一次,之后缓存)
// vite.config.js
export default {
  optimizeDeps: {
    include: ['lodash-es', 'react', 'react-dom'], // 明确包含的依赖
    exclude: ['my-local-package'],                 // 排除本地包
  },
};
// Vite 用 esbuild(Go 编写,比 JS 工具快 10~100 倍)做依赖预构建
// 将 node_modules 中的 CommonJS/UMD 模块转为 ESM
// 结果缓存在 node_modules/.vite/deps/,二次启动直接复用

// ✅ 优势三:精确的 HMR(只更新改动模块本身)
// 改动 /src/components/Button.vue
// Vite HMR:只重新编译 Button.vue,通过 ESM HMR API 热替换这一个模块
// Webpack HMR:重新构建从入口到 Button 的整条依赖链

// ✅ 优势四:生产构建使用 Rollup(更好的 Tree-shaking)
// vite.config.js 生产配置
export default {
  build: {
    target: 'es2015',
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],   // 手动分包
          utils: ['lodash-es'],
        },
      },
    },
    // Rollup 对 ESM 的 Tree-shaking 比 Webpack 更彻底
    // 因为 ESM 的静态结构让 Rollup 能精确分析哪些代码真正被用到
  },
};

Vite vs Webpack 对比总结

指标           Webpack                    Vite
--------------------------------------------------------------
冷启动         全量递归打包,慢(大项目30s+)  无需打包,极快(<1s)
HMR速度        重建依赖链,随项目增大变慢     只编译改动文件,始终极快
依赖处理        每次重新解析                  预构建+缓存,二次启动快
模块规范        需兼容 CJS/AMD/UMD/ESM       原生 ESM,不需兼容转换
生产构建        Webpack(Terser)            Rollup(更好的 Tree-shaking)
配置复杂度      高(大量 loader/plugin)       低(内置大部分常用功能)

第十篇:首屏加载优化的五个核心模块

面试一个高级前端,简历写着首屏加载提速 60%。我问:首次加载资源体积超 2MB,LCP 4.8 秒,怎么落地优化?他只说了图片懒加载、压缩、CDN,追问到细节就卡住了。

本质一:关键渲染路径优化

<!-- ✅ 关键 CSS 内联:避免额外 HTTP 请求阻塞渲染 -->
<head>
  <!-- 内联首屏必须的 CSS(通常 < 14KB,一个 TCP 包内) -->
  <style>
    /* critical.css:只包含首屏可见元素的样式 */
    body { margin: 0; font-family: sans-serif; }
    .header { height: 60px; background: #fff; }
    .hero { min-height: 400px; }
  </style>

  <!-- 非关键 CSS 异步加载,不阻塞渲染 -->
  <!-- media="print" 技巧:先以打印样式异步加载,onload 后切换为 all -->
  <link rel="stylesheet" href="/styles/non-critical.css"
        media="print" onload="this.media='all'">
  <noscript><link rel="stylesheet" href="/styles/non-critical.css"></noscript>
</head>

<!-- ✅ JS 脚本加载策略 -->
<!-- async:下载完立即执行,不保证顺序,适合无依赖的独立脚本(如统计) -->
<script async src="/analytics.js"></script>

<!-- defer:等 HTML 解析完再按顺序执行,适合有依赖关系的脚本 -->
<script defer src="/vendor.js"></script>
<script defer src="/app.js"></script>  <!-- 保证在 vendor.js 之后执行 -->

<!-- type="module" 默认 defer 行为,且支持 ES 模块 -->
<script type="module" src="/main.js"></script>

本质二:资源优先级——preload / preconnect / prefetch

<head>
  <!-- ✅ preload:强制优先加载当前页面必须的资源 -->
  <!-- 适合:首屏关键 JS、关键字体、LCP 图片 -->
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/js/critical.js" as="script">
  <!-- LCP 图片预加载(解决 LCP 图片发现太晚的问题) -->
  <link rel="preload" href="/images/hero.jpg" as="image" fetchpriority="high">

  <!-- ✅ preconnect:提前建立 TCP+TLS 连接,减少跨域资源首次请求延迟 -->
  <!-- 适合:CDN 域名、字体服务、API 域名 -->
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="dns-prefetch" href="https://api.example.com"> <!-- 低优降级版 -->

  <!-- ✅ prefetch:空闲时预加载下一页可能用到的资源,不抢占首屏带宽 -->
  <!-- 适合:用户大概率会访问的下一个页面 -->
  <link rel="prefetch" href="/js/detail-page.js" as="script">
</head>

本质三:弱网降级与容错

// ✅ 基于网络类型的服务端资源适配
// Node.js 服务端读取 Network Information API 提示的 header
app.get('/api/page-data', (req, res) => {
  const saveData = req.headers['save-data'] === 'on'; // 用户开启了省流模式
  const ect = req.headers['ect']; // '2g' | '3g' | '4g'

  if (saveData || ect === '2g') {
    return res.json({
      images: 'low-res',     // 返回低分辨率图片 URL
      components: 'minimal', // 返回精简版组件数据
    });
  }
  res.json({ images: 'hd', components: 'full' });
});

// ✅ 前端资源加载容错:超时自动重试
function loadScriptWithRetry(src, retries = 3) {
  return new Promise((resolve, reject) => {
    let attempts = 0;

    function attempt() {
      const script = document.createElement('script');
      script.src = `${src}?t=${Date.now()}`; // 防止缓存旧失败请求
      script.onload = resolve;
      script.onerror = () => {
        if (++attempts < retries) {
          setTimeout(attempt, 1000 * attempts); // 递增延迟重试
        } else {
          reject(new Error(`${src} 加载失败,已重试 ${retries} 次`));
          showFallbackUI(); // 展示降级骨架屏
        }
      };
      document.head.appendChild(script);
    }

    attempt();
  });
}

// ✅ 103 Early Hints(HTTP/2 + 现代 Node.js)
// 在主 HTML 响应之前,服务器提前推送资源提示
// Express + Node 18+
app.get('/', (req, res) => {
  res.writeEarlyHints({
    link: [
      '</styles/critical.css>; rel=preload; as=style',
      '<https://cdn.example.com>; rel=preconnect',
    ],
  }, () => {
    // 早期提示发出后,继续处理主响应
    res.send(renderHTML());
  });
});

本质四:路由懒加载 + 预加载动态平衡

// ✅ React 路由懒加载(减少首屏体积)
import { lazy, Suspense } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';

// 路由级代码分割:首屏只加载当前路由的代码
const HomePage = lazy(() => import('./pages/Home'));
const DetailPage = lazy(() => import('./pages/Detail'));
const ProfilePage = lazy(() => import('./pages/Profile'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/detail/:id" element={<DetailPage />} />
        <Route path="/profile" element={<ProfilePage />} />
      </Routes>
    </Suspense>
  );
}

// ✅ 基于用户行为的智能预取:鼠标悬停时预加载
function NavLink({ to, label }) {
  const [prefetched, setPrefetched] = useState(false);

  function handleMouseEnter() {
    if (prefetched) return;
    setPrefetched(true);
    // 用户悬停时触发预加载(用户大概率会点击)
    if (to === '/detail') import('./pages/Detail');
    if (to === '/profile') import('./pages/Profile');
  }

  return <a href={to} onMouseEnter={handleMouseEnter}>{label}</a>;
}

本质五:性能监控与持续迭代

// ✅ 监控核心 Web Vitals 指标
import { onLCP, onFID, onCLS, onINP } from 'web-vitals';

function reportWebVitals(metric) {
  const { name, value, rating } = metric;
  // rating: 'good' | 'needs-improvement' | 'poor'

  if (rating === 'poor') {
    console.warn(`${name}: ${value.toFixed(0)}ms - 需要优化`);
    // 上报到监控平台
    navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
  }
}

onLCP(reportWebVitals);   // Largest Contentful Paint,理想 < 2.5s
onFID(reportWebVitals);   // First Input Delay,理想 < 100ms(已被 INP 替代)
onCLS(reportWebVitals);   // Cumulative Layout Shift,理想 < 0.1
onINP(reportWebVitals);   // Interaction to Next Paint(新指标),理想 < 200ms

// ✅ 预加载与懒加载不冲突的缓存策略
// Service Worker 缓存配置:预加载资源存入 Cache,懒加载时优先命中
// sw.js
const CACHE_NAME = 'app-v1';

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      if (cached) return cached; // 命中缓存,无需再请求(预加载的资源在这里生效)

      return fetch(event.request).then(response => {
        // 缓存新资源(懒加载的路由 chunk 也会被缓存)
        const toCache = response.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, toCache));
        return response;
      });
    })
  );
});

第十一篇:Vue3 与 React18 响应式更新机制深度对比

面试一个七年前端,简历写着熟练掌握 Vue3 和 React18,有大型项目性能调优经验。我问:Vue3 和 React18 的更新机制有什么根本不同,哪个更精准高效?他答:Vue3 是 Proxy 劫持,React 是状态快照,具体细节不太确定。

React18 的更新机制:组件级快照 + Fiber 调度

// ✅ React:不可变数据 + 组件级更新
function UserProfile() {
  const [user, setUser] = useState({ name: 'Alice', address: { city: '北京' } });

  function updateCity() {
    // 必须返回新对象(不可变更新),React 通过引用比较判断是否更新
    setUser(prev => ({
      ...prev,
      address: { ...prev.address, city: '上海' }, // 生成新引用
    }));
    // ⚠️ 只改了 address.city,但整个 UserProfile 组件都会重渲
  }

  return <div onClick={updateCity}>{user.name} - {user.address.city}</div>;
}

// ✅ 用 useMemo / memo 减少 React 的冗余渲染
const AddressCard = memo(({ address }) => {
  // 只有 address 引用变化时才重渲
  return <div>{address.city}</div>;
});

function UserProfile() {
  const [user, setUser] = useState({ name: 'Alice', address: { city: '北京' } });

  // 用 useMemo 稳定不依赖变化的派生数据
  const fullName = useMemo(() => `用户:${user.name}`, [user.name]);

  return (
    <>
      <span>{fullName}</span>
      <AddressCard address={user.address} /> {/* address 引用不变时不重渲 */}
    </>
  );
}

// ✅ React 18 自动批处理(同时合并多次 setState)
function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 18 之前:触发两次重渲
  // React 18 之后:自动批处理,只触发一次重渲(即使在异步回调中)
}

Vue3 的响应式机制:Proxy + 精准依赖追踪

// ✅ Vue3 Proxy 代理:精准到字段级别的更新
import { reactive, ref, computed, watchEffect } from 'vue';

const user = reactive({
  name: 'Alice',
  address: { city: '北京' }, // 深层属性也会被自动代理
});

// 直接修改深层属性,Vue3 能精准检测到
user.address.city = '上海';
// 只有依赖 address.city 的视图会更新,依赖 user.name 的不会动

// ✅ 依赖收集原理演示
const state = reactive({ count: 0, name: 'test' });

// watchEffect 执行时,内部访问了 state.count
// Vue3 的 Proxy getter 会把这个 effect 收集为 count 的依赖
watchEffect(() => {
  console.log('count 变了:', state.count); // 只有 count 变才重新执行
  // state.name 没有被访问,name 变化不会触发这个 effect
});

state.count++; // ✅ 触发上面的 watchEffect
state.name = 'new'; // ✅ 不触发上面的 watchEffect(未收集到依赖)

// ✅ Vue3 对 Map/Set/数组的完整支持(Vue2 的痛点)
const list = reactive([1, 2, 3]);
const map = reactive(new Map([['a', 1]]));

// Vue3 完全支持,Vue2 需要用 Vue.set 或重写数组方法才能触发响应
list.push(4);        // ✅ 触发更新
list[0] = 99;        // ✅ 触发更新(Vue2 不支持)
list.length = 1;     // ✅ 触发更新(Vue2 不支持)
map.set('b', 2);     // ✅ 触发更新(Vue2 完全不支持 Map)

Vue2 vs Vue3 vs React 的核心差异对比

// Vue2 的 defineProperty 局限(为什么要换 Proxy)
const obj = {};
Object.defineProperty(obj, 'name', {
  get() { return this._name; },
  set(val) { this._name = val; trigger(); },
});

// ❌ Vue2 检测不到的场景:
obj.newProp = 'value';     // 新增属性 → 无响应(需用 Vue.set)
delete obj.name;           // 删除属性 → 无响应
obj.arr[0] = 'x';         // 数组下标修改 → 无响应(需用 splice 或 Vue.set)
obj.arr.length = 0;        // 修改 length → 无响应

// ✅ Vue3 Proxy 全部支持:
const proxy = new Proxy({}, {
  get(target, key, receiver) {
    track(target, key); // 收集依赖
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver);
    trigger(target, key); // 触发更新(包括新增属性)
    return result;
  },
  deleteProperty(target, key) {
    const result = Reflect.deleteProperty(target, key);
    trigger(target, key); // 删除属性也能触发
    return result;
  },
});

第十二篇:刷新就好,不刷新就异常——渲染缺失三层防御

今天面了个四年前端。线上问题:新上线活动页,用户第一次打开关键内容不渲染,刷新就好。所有接口 200,没有报错,业务逻辑正常执行。

这种问题表面是渲染缺失,本质是资源加载时序紊乱加首屏渲染触发时机过早。

第一层:构建阶段——资源版本一致性

// ✅ Webpack/Vite 配置:所有静态资源带 contenthash
// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        // contenthash:文件内容变才改 hash,缓存更精确
        entryFileNames: 'js/[name]-[hash].js',
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash][extname]',
      },
    },
  },
};

// ✅ HTML 不启用强缓存(保证用户拿到最新 HTML,从而加载最新 hash 资源)
// nginx 配置
// location /index.html {
//   add_header Cache-Control "no-cache, no-store, must-revalidate";
// }
// location /assets/ {
//   add_header Cache-Control "public, max-age=31536000, immutable"; # 强缓存
// }

// ✅ 资源版本不匹配时主动刷新(解决用户持有旧 HTML 的问题)
async function checkAppVersion() {
  try {
    const res = await fetch('/version.json', { cache: 'no-cache' });
    const { version } = await res.json();
    const currentVersion = window.__APP_VERSION__; // 构建时注入
    if (version !== currentVersion) {
      console.warn('检测到新版本,即将刷新');
      location.reload(true);
    }
  } catch {
    // 版本检测失败不影响正常使用
  }
}

第二层:运行时渲染时机控制

// ✅ 等待核心依赖就绪后再渲染(避免提前挂载)
async function bootstrapApp() {
  try {
    // 并行加载核心依赖
    await Promise.all([
      loadCriticalConfig(),   // 加载配置
      checkUserAuth(),        // 鉴权
      loadI18n(),             // 国际化资源
    ]);

    // 依赖全部就绪后才挂载 React/Vue 根节点
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<App />);

    // 移除骨架屏
    document.getElementById('skeleton')?.remove();
  } catch (err) {
    // 关键依赖加载失败,展示降级页面
    showErrorPage(err);
  }
}

bootstrapApp();

// ✅ 异步组件按需加载时,等依赖就绪后再挂载(Vue3)
// 组件级等待,防止局部渲染空白
const AsyncModule = defineAsyncComponent({
  loader: () => import('./HeavyModule.vue'),
  loadingComponent: LoadingSpinner,   // 加载中显示骨架
  errorComponent: ErrorFallback,      // 加载失败显示降级
  delay: 200,                         // 200ms 内完成不显示 loading
  timeout: 5000,                      // 超时后显示 error
});

第三层:异常兜底与监控

// ✅ 页面加载后校验核心模块是否正常渲染
function validateCriticalModules() {
  const CRITICAL_SELECTORS = [
    '#header',
    '#main-content',
    '.product-list',
  ];

  const missingModules = CRITICAL_SELECTORS.filter(
    sel => !document.querySelector(sel) || document.querySelector(sel).children.length === 0
  );

  if (missingModules.length > 0) {
    console.error('关键模块渲染缺失:', missingModules);
    // 上报
    reportRenderFailure({ missing: missingModules, url: location.href });
    // 自动触发重渲染(仅执行一次,防止死循环)
    if (!window.__RENDER_RETRY__) {
      window.__RENDER_RETRY__ = true;
      triggerReRender(); // 通知框架重新渲染
    }
  }
}

// 在 load 事件后延迟检测(等框架渲染完成)
window.addEventListener('load', () => {
  setTimeout(validateCriticalModules, 1000);
});

// ✅ 采集用户设备 + 资源版本 + 加载时序,精准定位复现条件
function collectDiagnosticInfo() {
  return {
    userAgent: navigator.userAgent,
    screen: `${screen.width}x${screen.height}`,
    connection: navigator.connection?.effectiveType,
    appVersion: window.__APP_VERSION__,
    loadTiming: performance.getEntriesByType('navigation')[0],
    failedResources: performance.getEntriesByType('resource')
      .filter(r => r.transferSize === 0 && r.decodedBodySize === 0)
      .map(r => r.name),
  };
}

第十三篇:大文件分片上传与断点续传

阿里面试官的标准:能把大文件分片上传与断点续传说清楚的,二面直接问;说不清楚的,哪怕 Promise 背得再溜也直接 pass。

一、为什么直接上传会出问题

单次请求体过大:
  - 浏览器/服务器 body size 限制(Nginx 默认 1MB,需配置 client_max_body_size)
  - 上传到 90% 断网 → 从头开始,浪费带宽和时间
  - 无法展示可靠进度,无法续传

二、核心方案:分片上传 + 断点续传

// ✅ 完整实现:分片上传 + 断点续传 + 并发控制 + 进度展示
class FileUploader {
  constructor(file, options = {}) {
    this.file = file;
    this.chunkSize = options.chunkSize || 1 * 1024 * 1024; // 默认 1MB
    this.concurrency = options.concurrency || 3;            // 并发数
    this.maxRetries = options.maxRetries || 3;              // 重试次数
    this.fileHash = null;
    this.uploadedChunks = new Set();
  }

  // Step 1:计算文件唯一标识(用 Web Worker 避免阻塞主线程)
  async computeHash() {
    return new Promise((resolve) => {
      const worker = new Worker('/hash-worker.js');
      worker.postMessage({ file: this.file });
      worker.onmessage = ({ data }) => {
        resolve(data.hash);
        worker.terminate();
      };
    });
  }

  // Step 2:文件切片
  createChunks() {
    const chunks = [];
    let start = 0;
    let index = 0;
    while (start < this.file.size) {
      chunks.push({
        index,
        blob: this.file.slice(start, start + this.chunkSize),
        start,
      });
      start += this.chunkSize;
      index++;
    }
    return chunks;
  }

  // Step 3:询问服务端已上传了哪些分片
  async checkUploadedChunks(fileHash) {
    const res = await fetch(`/api/upload/check?hash=${fileHash}`);
    const { uploadedIndexes } = await res.json();
    return new Set(uploadedIndexes); // [0, 1, 3] → 只需要上传第 2 片
  }

  // Step 4:上传单个分片(带重试)
  async uploadChunk(chunk, fileHash) {
    const formData = new FormData();
    formData.append('hash', fileHash);
    formData.append('index', chunk.index);
    formData.append('total', Math.ceil(this.file.size / this.chunkSize));
    formData.append('chunk', chunk.blob);

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        const res = await fetch('/api/upload/chunk', { method: 'POST', body: formData });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        this.uploadedChunks.add(chunk.index);
        this.onProgress?.((this.uploadedChunks.size / this.totalChunks) * 100);
        return;
      } catch (err) {
        if (attempt === this.maxRetries - 1) throw err;
        await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
      }
    }
  }

  // Step 5:并发控制上传(避免一次性发起所有请求)
  async uploadWithConcurrency(chunks, fileHash) {
    const queue = [...chunks];
    const executing = new Set();

    while (queue.length > 0 || executing.size > 0) {
      while (executing.size < this.concurrency && queue.length > 0) {
        const chunk = queue.shift();
        const p = this.uploadChunk(chunk, fileHash).finally(() => executing.delete(p));
        executing.add(p);
      }
      if (executing.size > 0) await Promise.race(executing);
    }
  }

  // Step 6:通知服务端合并分片
  async mergeChunks(fileHash, filename) {
    await fetch('/api/upload/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        hash: fileHash,
        filename,
        total: Math.ceil(this.file.size / this.chunkSize),
        chunkSize: this.chunkSize,
      }),
    });
  }

  // 完整上传流程
  async upload(onProgress) {
    this.onProgress = onProgress;

    // 计算 hash(用于秒传和断点续传标识)
    this.fileHash = await this.computeHash();
    const chunks = this.createChunks();
    this.totalChunks = chunks.length;

    // 询问已上传分片
    this.uploadedChunks = await this.checkUploadedChunks(this.fileHash);

    // 秒传检测:全部分片已上传
    if (this.uploadedChunks.size === chunks.length) {
      console.log('秒传成功!文件已存在');
      await this.mergeChunks(this.fileHash, this.file.name);
      return;
    }

    // 只上传未传的分片
    const pendingChunks = chunks.filter(c => !this.uploadedChunks.has(c.index));
    await this.uploadWithConcurrency(pendingChunks, this.fileHash);

    // 合并
    await this.mergeChunks(this.fileHash, this.file.name);
    console.log('上传完成!');
  }
}

// hash-worker.js(Web Worker 中计算 hash,不阻塞 UI)
// 需要引入 spark-md5 库
importScripts('/lib/spark-md5.min.js');
self.onmessage = async ({ data: { file } }) => {
  const spark = new SparkMD5.ArrayBuffer();
  const chunkSize = 2 * 1024 * 1024;
  let offset = 0;

  while (offset < file.size) {
    const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
    spark.append(chunk);
    offset += chunkSize;
  }

  self.postMessage({ hash: spark.end() });
};

// 使用示例
const uploader = new FileUploader(file, { chunkSize: 2 * 1024 * 1024, concurrency: 3 });
await uploader.upload((progress) => {
  progressBar.style.width = `${progress.toFixed(1)}%`;
  progressText.textContent = `${progress.toFixed(1)}%`;
});

第十四篇:浏览器渲染性能与动画卡顿排查

面试官追问:浏览器一帧里有几个步骤?什么是 layout thrashing?2026 年的 View Transitions 和滚动驱动动画你了解吗?

第一阶段:渲染流水线与重排重绘

/* ✅ 只触发 composite:transform 和 opacity 是动画最佳选择 */
.box-good {
  /* ✅ 移动位置:用 transform,不用 left/top */
  transform: translateX(100px);
  /* ✅ 显示隐藏:用 opacity,不用 visibility/display */
  opacity: 0;
  /* ✅ 提前创建合成层,避免动画时临时提升的性能波动 */
  will-change: transform, opacity;
}

/* ❌ 触发重排(最贵):改变几何属性 */
.box-bad {
  left: 100px;      /* ❌ 触发 layout + paint + composite */
  width: 200px;     /* ❌ 触发 layout + paint + composite */
  margin-top: 10px; /* ❌ 触发 layout + paint + composite */
}

/* ❌ 触发重绘(中等):改变外观属性 */
.box-repaint {
  background-color: red; /* ❌ 触发 paint + composite,不触发 layout */
  border: 1px solid red; /* ❌ 同上 */
}

第二阶段:强制同步布局(Layout Thrashing)

// ❌ 布局抖动:每帧内先写后读,强制同步布局
function badAnimation(elements) {
  elements.forEach(el => {
    el.style.width = '100px';         // 写(标记 layout 失效)
    const height = el.offsetHeight;   // 读(强制浏览器立即执行 layout!)
    el.style.height = height + 'px';  // 写(再次标记失效)
    // 如果有 100 个元素,就触发了 100 次 layout → 必掉帧
  });
}

// ✅ 读写分离:先批量读,再批量写
function goodAnimation(elements) {
  // 第一步:批量读取(只触发一次 layout)
  const heights = elements.map(el => el.offsetHeight);

  // 第二步:批量写入(不触发 layout,下一帧统一处理)
  elements.forEach((el, i) => {
    el.style.width = '100px';
    el.style.height = heights[i] + 'px';
  });
}

// ✅ 用 requestAnimationFrame 将写操作推到下一帧
function animateWithRAF(el) {
  const height = el.offsetHeight; // 读(当前帧读,可以)

  requestAnimationFrame(() => {
    el.style.transform = `translateY(${height}px)`; // 写(下一帧统一写)
  });
}

// ✅ 更好的方案:用 FLIP 技术实现无 layout 的位置动画
// First:记录初始位置
const first = el.getBoundingClientRect();

// Last:执行 DOM 变更(触发 layout,但只触发一次)
el.classList.add('moved');
const last = el.getBoundingClientRect();

// Invert:用 transform 抵消视觉位移(不触发 layout)
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;

// Play:动画归位(transform 动画,只触发 composite)
requestAnimationFrame(() => {
  el.style.transition = 'transform 300ms ease';
  el.style.transform = 'translate(0, 0)';
});

第三阶段:2026 年新增优化武器

// ✅ View Transitions API:页面/元素切换的原生过渡(无需手动操作 DOM)
async function navigateWithTransition(newPage) {
  if (!document.startViewTransition) {
    // 不支持时降级
    renderPage(newPage);
    return;
  }

  // startViewTransition 自动:
  // 1. 截图当前状态
  // 2. 执行 DOM 更新
  // 3. 截图新状态
  // 4. 用 CSS 动画过渡两个截图(在合成器线程,不阻塞主线程)
  const transition = document.startViewTransition(() => {
    renderPage(newPage); // 同步更新 DOM
  });

  await transition.finished;
}

// 自定义过渡动画(CSS 控制)
/* css */
/*
::view-transition-old(root) {
  animation: fade-out 0.3s ease forwards;
}
::view-transition-new(root) {
  animation: fade-in 0.3s ease forwards;
}
@keyframes fade-out { to { opacity: 0; transform: scale(0.95); } }
@keyframes fade-in  { from { opacity: 0; transform: scale(1.05); } }
*/

// ✅ 滚动驱动动画(Scroll-driven Animations):完全在合成器线程,不阻塞主线程
/* 进度条随滚动变化 */
/*
@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
#progress-bar {
  transform-origin: left;
  animation: grow linear;
  animation-timeline: scroll(root);  // 绑定到页面滚动
  animation-fill-mode: both;
}
*/

// ✅ content-visibility: auto:跳过离屏元素的渲染计算
/* CSS */
/*
.feed-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 300px; // 预留高度,防止滚动条抖动
}
*/
// 效果:只渲染当前视口附近的列表项,滚动到哪才渲染哪

// ✅ will-change 正确用法(用完要移除,避免内存浪费)
function animateElement(el) {
  el.style.willChange = 'transform'; // 开始前提升合成层

  el.animate([
    { transform: 'translateX(0)' },
    { transform: 'translateX(200px)' },
  ], { duration: 500, easing: 'ease' }).finished.then(() => {
    el.style.willChange = 'auto'; // 动画结束后移除,释放内存
  });
}

第十五篇:Vue 封装 Web Component 实现跨框架复用

面试简历写着精通 Vue 组件设计,熟悉前端新趋势的四年前端。我问 Vue 如何封装原生 Web Component,实现跨框架无侵入复用。他答:打包好直接引入。单纯打包引入会出现样式污染、响应式失联、生命周期不兼容。

一、Vue3 defineCustomElement:将 Vue 组件编译为原生自定义元素

// ✅ Vue3 官方支持:defineCustomElement
import { defineCustomElement } from 'vue';

// 把 Vue 组件转为自定义元素
const MyButtonElement = defineCustomElement({
  name: 'MyButton',
  props: {
    label: String,
    variant: { type: String, default: 'primary' },
  },
  emits: ['click'],

  setup(props, { emit }) {
    return () => (
      <button
        class={`btn btn--${props.variant}`}
        onClick={() => emit('click', { label: props.label })}
      >
        {props.label}
      </button>
    );
  },

  // ✅ 关键:styles 会注入到 Shadow DOM,实现完全样式隔离
  styles: [`
    .btn { padding: 8px 16px; border-radius: 4px; cursor: pointer; }
    .btn--primary { background: #007bff; color: white; border: none; }
    .btn--secondary { background: #6c757d; color: white; border: none; }
  `],
});

// 注册为全局自定义元素(带缓存,避免重复注册)
if (!customElements.get('my-button')) {
  customElements.define('my-button', MyButtonElement);
}

// ✅ 在任何框架中直接使用(无需 Vue 运行时)
// HTML 原生
// <my-button label="点击我" variant="primary"></my-button>

// React 中使用
function ReactApp() {
  return (
    <my-button
      label="React 里的 Vue 组件"
      onMyClick={(e) => console.log('收到自定义事件:', e.detail)}
    />
  );
}

// Angular 中使用(需在 module 设置 CUSTOM_ELEMENTS_SCHEMA)
// <my-button label="Angular 里用" (click)="handleClick($event)"></my-button>

二、样式隔离原理与数据双向同步

// ✅ Shadow DOM 实现完全样式隔离(自动)
// defineCustomElement 创建的组件默认使用 Shadow DOM
// 外部 CSS 无法穿透 Shadow DOM,内部 CSS 也不会泄漏到外部

// 验证隔离效果
const el = document.querySelector('my-button');
console.log(el.shadowRoot); // DocumentFragment(Shadow DOM 根节点)
// 外部 .btn 样式对 shadow DOM 内部无效,shadow 内的 .btn 也不影响外部

// ✅ 跨框架数据同步:attribute(字符串) vs property(任意类型)
const myBtn = document.querySelector('my-button');

// 方式一:attribute(适合简单字符串,会反映在 HTML 上)
myBtn.setAttribute('label', '新标签');

// 方式二:property(适合对象/数组等复杂类型)
myBtn.config = { icon: 'star', badge: 3 }; // 直接赋值 property
// Vue 的 defineCustomElement 会自动将 props 映射为 property

// ✅ 监听自定义事件(标准 CustomEvent,跨框架通用)
myBtn.addEventListener('click', (e) => {
  console.log('自定义事件数据:', e.detail);
});

三、低版本兼容降级

// ✅ 特性检测 + polyfill 降级
function registerCustomElement(name, definition) {
  if (typeof customElements !== 'undefined') {
    if (!customElements.get(name)) {
      customElements.define(name, definition);
    }
  } else {
    // 非常老的浏览器:降级为普通 div + 手动初始化
    console.warn(`customElements 不支持,${name} 降级为普通组件`);
    loadPolyfill().then(() => customElements.define(name, definition));
  }
}

async function loadPolyfill() {
  if (typeof customElements === 'undefined') {
    await import('@webcomponents/custom-elements'); // 动态加载 polyfill
  }
}

第十六篇:跨域解决方案的体系化认知

面试一个三年前端,他把五种跨域方案都列出来,说遇到跨域就选一个试试。连 CORS 预检请求的触发条件都没说清。

第一层:同源策略的核心

同源 = 协议 + 域名 + 端口 完全一致

示例:
  当前页面:https://app.example.com:443
  ✅ 同源:https://app.example.com/api(路径不同,同源)
  ❌ 跨域:http://app.example.com   (协议不同)
  ❌ 跨域:https://api.example.com  (子域不同)
  ❌ 跨域:https://app.example.com:8080(端口不同)

重要理解:
  - 同源策略限制的是 JS 读取跨域响应,而非请求本身的发出
  - 跨域请求实际上发出去了,服务器也收到了并处理了
  - 是浏览器在收到响应后,检查 CORS 头决定是否允许 JS 读取
  - 这就是为什么 CORS 配置错误时,服务器日志里有请求记录,但浏览器报错

第二层:CORS 简单请求 vs 预检请求

// ✅ 简单请求:不触发预检(OPTIONS)
// 条件:方法是 GET/HEAD/POST 之一,且 Header 只有安全头,Content-Type 是特定值
fetch('https://api.example.com/data', {
  method: 'GET', // ✅ 简单方法
  headers: {
    'Content-Type': 'text/plain', // ✅ 简单 Content-Type
  },
});

// ❌ 触发预检(OPTIONS)的情况
fetch('https://api.example.com/data', {
  method: 'DELETE',                          // ❌ 非简单方法 → 预检
  headers: {
    'Content-Type': 'application/json',      // ❌ 非简单 Content-Type → 预检
    'Authorization': 'Bearer token',          // ❌ 自定义 Header → 预检
  },
});
// 浏览器会先发一个 OPTIONS 请求询问服务器是否允许,再发真实请求

// ✅ 服务端 CORS 配置(Node.js Express)
const cors = require('cors');
app.use(cors({
  origin: ['https://app.example.com', 'https://m.example.com'], // 明确白名单,禁止 *
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,                         // 允许携带 cookie
  maxAge: 86400,                             // 预检结果缓存 24h,减少 OPTIONS 请求数
}));

// ✅ 携带 cookie 的跨域请求(同主域子域场景)
fetch('https://api.example.com/user', {
  credentials: 'include', // 携带 cookie
  // 同时服务端必须:
  // Access-Control-Allow-Origin: https://app.example.com  (不能是 *)
  // Access-Control-Allow-Credentials: true
});

第三层:各方案适用场景

// ✅ 代理转发(最推荐,彻底绕开浏览器限制)
// 开发环境:Vite 代理配置
// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://backend.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''), // /api/user → /user
        configure: (proxy) => {
          proxy.on('error', (err) => console.error('代理错误:', err));
        },
      },
    },
  },
};

// 生产环境:Nginx 反向代理
/*
server {
  listen 80;
  server_name app.example.com;

  location /api/ {
    proxy_pass https://backend.example.com/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Credentials true always;
  }
}
*/

// ✅ JSONP(已过时,仅了解)
function jsonpRequest(url, callbackName) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    const cb = `jsonp_${Date.now()}`;

    // 安全:校验回调函数名,只允许字母数字下划线
    if (!/^\w+$/.test(callbackName)) throw new Error('非法回调名');

    window[cb] = (data) => {
      resolve(data);
      delete window[cb];
      document.head.removeChild(script);
    };

    script.src = `${url}?callback=${cb}`;
    script.onerror = () => reject(new Error('JSONP 请求失败'));
    document.head.appendChild(script);
  });
}
// ⚠️ JSONP 只支持 GET,且存在 XSS 风险,现代项目不推荐

// ✅ postMessage 跨域通信(iframe 场景)
// 父页面
const iframe = document.getElementById('child-frame');
iframe.contentWindow.postMessage(
  { type: 'REQUEST', data: { userId: 123 } },
  'https://child.example.com' // 必须指定具体 origin,禁止用 *
);

// 子页面(child.example.com)
window.addEventListener('message', (event) => {
  // 安全:必须校验来源
  if (event.origin !== 'https://parent.example.com') return;
  const { type, data } = event.data;
  if (type === 'REQUEST') {
    event.source.postMessage(
      { type: 'RESPONSE', result: processData(data) },
      event.origin // 只回复给父页面
    );
  }
});

第四层:安全与性能注意点

// ✅ CSRF 防御(携带 cookie 的跨域请求需要防 CSRF)
// 方式一:双重 cookie 验证
// 方式二:自定义 Header(CSRF token)
const csrfToken = document.cookie.match(/csrf=([^;]+)/)?.[1];
fetch('/api/sensitive', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'X-CSRF-Token': csrfToken, // 自定义 header,简单跨域请求无法携带
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ action: 'delete' }),
});

// ✅ 优化预检请求:设置 maxAge 缓存预检结果
// 服务端返回:Access-Control-Max-Age: 86400
// 浏览器缓存 OPTIONS 结果 24h,同 URL 不再重复发预检
// Chrome 最大值:7200s(2h);Firefox 最大值:86400s(24h)

第十七篇:JS 事件循环的 P7 级别理解

面试官:微任务为什么要在宏任务间隙全部清空?浏览器和 Node 的事件循环核心区别是什么?

第一阶段:执行顺序基础

// ✅ 基础执行顺序验证
console.log('1. 同步代码');

setTimeout(() => console.log('5. 宏任务(定时器)'), 0);

Promise.resolve()
  .then(() => console.log('3. 微任务(Promise.then)'))
  .then(() => console.log('4. 微任务(链式 then)'));

console.log('2. 同步代码');

// 输出顺序:1 → 2 → 3 → 4 → 5
// 原因:同步代码先执行完,再清空所有微任务,最后执行宏任务

// ✅ async/await 的执行顺序
async function main() {
  console.log('A. async 函数同步部分');
  await Promise.resolve();
  console.log('C. await 之后(等价于 Promise.then,微任务)');
}
console.log('B. 调用 main 之后的同步代码(先于 C 执行)');
main();
// 输出:A → B → C

第二阶段:微任务清空机制

// ✅ 微任务会被清空到没有为止(包括微任务中产生的新微任务)
Promise.resolve()
  .then(() => {
    console.log('微任务 1');
    return Promise.resolve(); // 产生新的微任务
  })
  .then(() => {
    console.log('微任务 2(由微任务 1 产生)');
  });

setTimeout(() => console.log('宏任务(等所有微任务清空才执行)'), 0);

// 输出:微任务 1 → 微任务 2 → 宏任务
// 即使微任务内产生新微任务,也会在同一轮全部清空,宏任务一直等着

// ⚠️ 风险:递归产生微任务会导致页面永久卡死,渲染永远无法进行
function infiniteMicrotask() {
  Promise.resolve().then(infiniteMicrotask); // ❌ 死循环微任务,页面冻结
}

第三阶段:渲染时机 + RAF

// ✅ 渲染发生在:微任务清空后,下一个宏任务执行前
// 注意:并非每次微任务清空后都渲染,浏览器会根据屏幕刷新率决定(通常 16.6ms 一次)

// requestAnimationFrame 的执行时机:在渲染前,微任务之后
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
requestAnimationFrame(() => console.log('rAF(渲染前触发)'));

// 一次事件循环的顺序:
// 微任务 → rAF → 渲染(layout/paint/composite)→ 宏任务

// ✅ MutationObserver 是微任务(设计目的:DOM 变化后立即响应)
const observer = new MutationObserver((mutations) => {
  console.log('DOM 变化(微任务):', mutations.length);
});
observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));
console.log('DOM 操作后的同步代码');
// 输出:DOM 操作后的同步代码 → DOM 变化(同步代码执行完才触发微任务)

第四阶段:Node.js 事件循环

// ✅ Node.js 事件循环六个阶段
/*
 ┌──────────────────────────────────┐
 │          timers 阶段              │ ← setTimeout / setInterval 回调
 │  pending callbacks 阶段           │ ← 上轮 I/O 错误回调
 │  idle, prepare 阶段               │ ← 内部使用
 │          poll 阶段                │ ← 等待新的 I/O 事件(核心阶段)
 │          check 阶段               │ ← setImmediate 回调
 │     close callbacks 阶段          │ ← socket.on('close') 等
 └──────────────────────────────────┘
*/

// ✅ process.nextTick 优先级高于所有微任务(Node 独有)
process.nextTick(() => console.log('nextTick(最高优先级微任务)'));
Promise.resolve().then(() => console.log('Promise.then'));
setImmediate(() => console.log('setImmediate(check 阶段)'));

// 输出:nextTick → Promise.then → setImmediate
// 每个阶段切换时,先清空 nextTick 队列,再清空 Promise 队列

// ✅ Node 11+ 对齐浏览器行为
// Node 10 及以前:同一阶段的所有宏任务执行完,才统一清空微任务
// Node 11+:每执行一个宏任务,就清空一次微任务(与浏览器行为一致)

// 示例(Node 10 vs Node 11+ 结果不同)
setTimeout(() => {
  console.log('timer1');
  Promise.resolve().then(() => console.log('promise1'));
}, 0);
setTimeout(() => {
  console.log('timer2');
  Promise.resolve().then(() => console.log('promise2'));
}, 0);

// Node 10:timer1 → timer2 → promise1 → promise2
// Node 11+:timer1 → promise1 → timer2 → promise2(与浏览器一致)

第十八篇:防抖节流的场景化深度理解

面试一个五年前端,问防抖节流。他对答如流说完定义后,我问具体场景:滚动加载用防抖,搜索联想用节流,会出现什么问题?他愣了。

本质一:场景化控频,而非单纯延时

// ✅ 防抖(debounce):适合"触发完才执行"的场景
// 原理:每次触发重置定时器,只有停止触发后的 delay 时间才执行
function debounce(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// 适合场景:
// ✅ 搜索框输入联想(停止输入后才请求)
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((e) => {
  fetchSuggestions(e.target.value); // 停止输入 300ms 后才请求
}, 300));

// ✅ 窗口 resize 后重新计算布局(拖动结束才计算)
window.addEventListener('resize', debounce(() => {
  recalculateLayout();
}, 200));

// ✅ 按钮防重复点击(点击后 500ms 内不再响应)
button.addEventListener('click', debounce(handleSubmit, 500));

// ❌ 错误使用:滚动加载下一页用防抖 → 用户快速滚动时请求一直被延后,列表不加载
window.addEventListener('scroll', debounce(loadNextPage, 300)); // ❌ 体验极差

本质二:节流与防抖的正确选型

// ✅ 节流(throttle):适合"持续触发时,固定间隔执行"的场景
// 原理:固定时间窗口内只执行一次,不管触发多少次
function throttle(fn, interval = 300) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

// 适合场景:
// ✅ 滚动加载(滚动时每 300ms 检测一次是否到底部)
window.addEventListener('scroll', throttle(() => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
  if (scrollTop + clientHeight >= scrollHeight - 100) {
    loadNextPage(); // 滚动时每 300ms 最多触发一次
  }
}, 300));

// ✅ 鼠标移动(mousemove 100ms 内最多更新一次坐标)
document.addEventListener('mousemove', throttle((e) => {
  updateCursorPosition(e.clientX, e.clientY);
}, 100));

// ✅ 游戏中的技能冷却(1 秒内只能释放一次)
button.addEventListener('click', throttle(castSkill, 1000));

本质三:进阶实现与工程化优化

// ✅ 防抖前缘执行(immediate):第一次立即执行,之后 delay 内不再执行
function debounceImmediate(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    const callNow = !timer; // 没有 timer 说明是第一次或 delay 过后
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
    }, delay);
    if (callNow) fn.apply(this, args); // 第一次立即执行
  };
}
// 使用场景:提交按钮(第一次点击立即响应,防止重复点击)

// ✅ 节流时间戳 + 定时器双保障(保证最后一次也能执行)
function throttleComplete(fn, interval = 300) {
  let lastTime = 0;
  let timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      // 达到间隔,立即执行
      clearTimeout(timer);
      timer = null;
      lastTime = now;
      fn.apply(this, args);
    } else if (!timer) {
      // 未达到间隔,设置定时器保证最后一次执行
      timer = setTimeout(() => {
        lastTime = Date.now();
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

// ✅ React Hook 封装(避免闭包陷阱和重复创建定时器)
import { useRef, useCallback } from 'react';

function useDebounce(fn, delay = 300) {
  const timer = useRef(null);
  const fnRef = useRef(fn);
  fnRef.current = fn; // 每次渲染更新 ref,避免闭包陷阱

  return useCallback((...args) => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      fnRef.current(...args); // 始终调用最新的 fn
    }, delay);
  }, [delay]); // delay 不变则 callback 引用稳定
}

// 使用
function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const debouncedSearch = useDebounce(async (text) => {
    const data = await searchAPI(text);
    setResults(data);
  }, 300);

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        debouncedSearch(e.target.value);
      }}
    />
  );
}

第十九篇:闭包的五个层面——从概念到内存优化

面试精通 JavaScript 的前端,他背出了闭包的定义,但追问变量生命周期和内存影响时完全卡住了。

第一层:基本概念与常见陷阱

// ✅ 闭包的本质:函数 + 其创建时的词法环境
function outer() {
  let count = 0; // 词法环境中的变量
  return function inner() {
    count++; // inner 函数"记住"了 outer 的 count
    return count;
  };
}
const counter = outer();
console.log(counter()); // 1
console.log(counter()); // 2(count 保持在内存中)

// ❌ 经典陷阱:var 在循环中共享同一个词法环境
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 输出:3 3 3(全是 3)
  // 原因:闭包捕获的是 i 的引用,循环结束后 i=3
}

// ✅ 修复方式一:let(每次循环创建新的块级作用域)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 输出:0 1 2 ✅
}

// ✅ 修复方式二:IIFE(立即执行函数,每次传入当前 i 的值)
for (var i = 0; i < 3; i++) {
  ((j) => setTimeout(() => console.log(j), 0))(i); // 输出:0 1 2 ✅
}

第二层:作用域链与内存

// ✅ 闭包捕获的是引用,多个闭包共享同一变量
function makeSharedCounter() {
  let count = 0;
  const inc = () => ++count;
  const dec = () => --count;
  const get = () => count;
  return { inc, dec, get };
}
const c = makeSharedCounter();
c.inc(); c.inc(); // count = 2
c.dec();          // count = 1
console.log(c.get()); // 1 (三个函数共享同一个 count)

// ✅ 手动解除闭包引用,触发 GC 回收
function createHeavyClosure() {
  const largeData = new Array(1000000).fill('大数据'); // 占用大量内存
  return function process() {
    return largeData.length; // 闭包持有 largeData 引用,阻止 GC
  };
}

let process = createHeavyClosure();
console.log(process()); // 1000000

// 不再需要时,手动解除引用
process = null; // ✅ GC 现在可以回收 largeData

第三层:闭包导致的内存泄漏

// ❌ DOM 事件回调中的内存泄漏
function setupButton() {
  const btn = document.getElementById('btn');
  const heavyData = new Array(100000).fill('占内存'); // 大数据

  btn.addEventListener('click', function handler() {
    // handler 通过闭包持有 heavyData 引用
    // 即使 btn 从 DOM 中移除,heavyData 也无法被 GC
    console.log(heavyData.length);
  });
  // ❌ 没有提供移除监听器的方法,内存永远泄漏
}

// ✅ 修复:提供清理函数
function setupButtonFixed() {
  const btn = document.getElementById('btn');
  const heavyData = new Array(100000).fill('占内存');

  function handler() {
    console.log(heavyData.length);
  }

  btn.addEventListener('click', handler);

  // 返回清理函数
  return function cleanup() {
    btn.removeEventListener('click', handler); // 移除监听
    // handler 无引用 → GC 回收 → heavyData 也可以被回收
  };
}

// ❌ 定时器中的内存泄漏(React 组件卸载后定时器还在)
function BadComponent() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // ❌ 组件卸载后定时器仍持有 setCount 引用
    }, 1000);
    // 没有清理!内存泄漏
  }, []);
}

// ✅ 修复:返回清理函数
function GoodComponent() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id); // ✅ 组件卸载时清理定时器
  }, []);
}

第四层:现代模式——React Hooks 中的闭包陷阱

// ❌ React Hooks 闭包陷阱:useEffect 捕获的是旧的 state
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // ❌ 永远打印 0(闭包捕获了初始值)
      setCount(count + 1); // ❌ 永远 0 + 1 = 1
    }, 1000);
    return () => clearInterval(id);
  }, []); // 依赖数组为空 → effect 只执行一次 → count 永远是 0

  return <div>{count}</div>;
}

// ✅ 修复方式一:函数式更新(不依赖当前 state)
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1); // ✅ 始终在最新值基础上 +1
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ 修复方式二:用 useRef 保存最新值
function Counter2() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count; // 每次渲染更新 ref

  useEffect(() => {
    const id = setInterval(() => {
      console.log(countRef.current); // ✅ 始终是最新值
      setCount(countRef.current + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
}

// ✅ WeakMap 实现私有属性(闭包 + WeakMap,不影响 GC)
const _privateData = new WeakMap();

class BankAccount {
  constructor(balance) {
    _privateData.set(this, { balance }); // 私有数据存在 WeakMap 里
  }

  deposit(amount) {
    const data = _privateData.get(this);
    data.balance += amount;
  }

  getBalance() {
    return _privateData.get(this).balance;
  }
}

const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// account 被 GC 后,WeakMap 中的对应数据也会自动被 GC(弱引用)

第五层:V8 优化与性能

// ✅ V8 逃逸分析:未逃逸的闭包对象可被栈分配,无 GC 压力
function localClosure() {
  let x = 10;
  function inner() { return x * 2; }
  return inner(); // inner 没有逃逸出函数,V8 可优化为直接计算
}

// ❌ 在列表中频繁创建闭包:GC 压力大
function renderListBad(items) {
  return items.map(item => ({
    id: item.id,
    onClick: () => handleClick(item.id), // 每个 item 创建一个新闭包
    onHover: () => handleHover(item.id), // 又一个
  }));
  // 10000 个 item → 20000 个闭包对象
}

// ✅ 优化:事件委托 + 单一闭包
function renderListGood(items) {
  document.getElementById('list').addEventListener('click', (e) => {
    const id = e.target.closest('[data-id]')?.dataset.id;
    if (id) handleClick(id); // 只需一个事件监听器
  });

  return items.map(item => `<li data-id="${item.id}">${item.name}</li>`).join('');
}

第二十篇:Vue3 的 ref 与 reactive 深度原理

面试两年 Vue3 开发,问 ref 和 reactive 的区别与响应式原理,他只说了用途,追问深层时开始支支吾吾。

第一层:设计初衷与核心区别

import { ref, reactive, isRef, isReactive } from 'vue';

// ✅ ref:包装任意类型(包括基本类型)
const count = ref(0);           // 基本类型
const user = ref({ name: 'Alice' }); // 对象(内部会调用 reactive)
console.log(count.value);       // 必须通过 .value 访问
console.log(isRef(count));      // true

// ✅ reactive:只能包装对象/数组/Map/Set
const state = reactive({ count: 0, name: 'Alice' });
console.log(state.count);       // 直接访问,不需要 .value
console.log(isReactive(state)); // true

// ❌ 常见错误:reactive 包装基本类型
// const num = reactive(0); // ❌ 警告:value cannot be made reactive

// ✅ 为什么 ref 需要 .value?
// 基本类型按值传递,包裹成 { value: xxx } 才能用 Proxy 追踪变化
const wrapped = ref(42);
// 内部相当于:
// const wrapped = reactive({ value: 42 })
// 修改 wrapped.value = 100 → 触发 Proxy setter → 通知依赖更新

第二层:响应式原理——依赖收集与触发

import { effect, ref, reactive } from '@vue/reactivity'; // 核心包(与 Vue3 同源)

// ✅ 手动演示依赖收集过程
const state = reactive({ count: 0, name: 'Alice' });

// effect 是响应式系统的核心:执行时自动收集依赖
effect(() => {
  // 执行时访问了 state.count → Proxy getter 触发 → track(state, 'count')
  // 将当前 effect 记录为 count 的依赖
  console.log('count 变化了:', state.count);
  // 注意:没有访问 state.name,所以 name 变化不会触发这个 effect
});

state.count = 1; // → Proxy setter 触发 → trigger(state, 'count') → 重新执行上面的 effect
state.name = 'Bob'; // → trigger(state, 'name') → 无依赖,什么都不发生

// ✅ 深层属性的响应式(Vue3 自动深层代理)
const nested = reactive({ a: { b: { c: 1 } } });

effect(() => {
  console.log('深层属性:', nested.a.b.c);
});

nested.a.b.c = 99; // ✅ 触发更新(Vue3 会在访问时递归代理,lazy proxy)

第三层:常见陷阱与正确用法

// ❌ 陷阱一:解构 reactive 对象丢失响应式
const state = reactive({ count: 0, name: 'Alice' });
const { count, name } = state; // ❌ count 和 name 是普通值,不再响应式
count = 10; // 不会触发更新

// ✅ 修复:使用 toRefs 解构,每个属性变成 ref
import { toRefs } from 'vue';
const { count, name } = toRefs(state); // ✅ count 和 name 是 ref,保持响应式
count.value = 10; // ✅ 触发更新

// ❌ 陷阱二:直接替换整个 reactive 对象丢失响应式
const data = reactive({ list: [] });
// data = reactive({ list: [1, 2, 3] }); // ❌ 整个引用被替换,响应式连接断开

// ✅ 修复:修改属性而非替换对象
data.list = [1, 2, 3]; // ✅ 修改属性,保持响应式
// 或者:
Object.assign(data, { list: [1, 2, 3] }); // ✅

// ❌ 陷阱三:将 reactive 对象赋值给 ref 后的行为
const obj = reactive({ count: 0 });
const refObj = ref(obj); // ref 包装 reactive 对象
// refObj.value === obj → true(ref 不会二次包装 reactive 对象)
refObj.value.count = 1; // ✅ 触发更新(本质上还是修改 reactive 对象)

第四层:性能优化——shallowRef / shallowReactive / markRaw

import { shallowRef, shallowReactive, markRaw, readonly } from 'vue';

// ✅ shallowRef:只有 .value 本身的变化触发更新(深层不追踪)
const shallowState = shallowRef({ a: { b: 1 } });
shallowState.value.a.b = 99; // ❌ 不触发更新(深层不追踪)
shallowState.value = { a: { b: 99 } }; // ✅ 替换整个 .value 才触发更新
// 适合:大型不可变数据(表格数据、图表 data)

// ✅ shallowReactive:只追踪第一层属性
const shallow = shallowReactive({ count: 0, nested: { value: 1 } });
shallow.count = 1;         // ✅ 触发更新(第一层)
shallow.nested.value = 99; // ❌ 不触发更新(深层)

// ✅ markRaw:标记对象永远不被代理(适合第三方实例、大型只读数据)
import * as THREE from 'three';

const state = reactive({
  // Three.js 场景对象有大量内部状态,不需要也不适合被 Vue 代理
  scene: markRaw(new THREE.Scene()),
  count: 0,
});

state.count = 1;       // ✅ 触发更新
state.scene.add(mesh); // ✅ Three.js 正常工作,Vue 不追踪这个对象

// ✅ toRaw:获取原始对象(跳过响应式系统,用于性能敏感的批量操作)
import { toRaw } from 'vue';
const raw = toRaw(state); // 获取不被代理的原始对象
// 对 raw 的修改不会触发更新,适合在 worker 中处理或传给第三方库

第五层:Composable 最佳实践

// ✅ 封装可复用的响应式逻辑(Composable)
import { ref, onMounted, onUnmounted } from 'vue';

// 通用数据请求 composable
function useFetch(url) {
  const data = ref(null);
  const loading = ref(false);
  const error = ref(null);
  const controller = new AbortController();

  async function fetchData() {
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(url, { signal: controller.signal });
      data.value = await res.json();
    } catch (err) {
      if (err.name !== 'AbortError') {
        error.value = err.message;
      }
    } finally {
      loading.value = false;
    }
  }

  onMounted(fetchData);
  onUnmounted(() => controller.abort()); // 组件卸载时取消请求,防止内存泄漏

  return { data, loading, error, refetch: fetchData };
}

// 鼠标位置 composable
function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(e) {
    x.value = e.clientX;
    y.value = e.clientY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update)); // 清理!

  return { x, y };
}

// 在组件中使用
export default {
  setup() {
    const { data, loading, error } = useFetch('/api/users');
    const { x, y } = useMouse();
    return { data, loading, error, x, y };
  },
};