普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月30日掘金 前端

Promise × 定时器全场景手写

作者 二二四一
2025年11月30日 17:01

🥇 01. 并发限制调度器(异步霸榜 No.1)

场景:你要发 100 个请求,但后端限流,每次只能发 N 个。

交互:可以在中途暂停执行,获取已执行的结果。

🤔考点:工程思维能力


🌈实现:模拟一个迷你版“浏览器资源调度器”,这个调度器的核心本质,是通过「running 计数」「idx 游标」「runNext 自驱动」三者配合,实现一个动态的任务池。它保证任务源源不断执行,但同时不会超过给定的并发上限。

  1. 调用方法
export function MyWork() {
  // 生成调度器
  const scheduler = limitRequests(tasks, 3);

  function handleStart() {
    scheduler.start().then((res) => {
      console.log("所有任务完成!");
      console.log("结果:", res);
    });
  }

  function handleEnd() {
    console.log("暂停完成执行~");
    scheduler.stop();
  }

  return (
    <div>
      <button onClick={handleStart}>开始</button>
      <button onClick={handleEnd}>暂停</button>
    </div>
  );
}

2. 自定义调度器

export function limitRequests(tasks, limit) {
  const res = [] // 存所有任务返回的 Promise,用来最终 Promise.all
  let idx = 0 // 当前处理到第几个任务
  let running = 0 // 当前正在执行的任务数量(关键的并发控制变量)
  let stopped = false // 用于标识是否已停止

  // 暴露的停止执行的方法
  function stop() {
    stopped = true
  }

  function start() {
    return new Promise((resolve, reject) => {
      function runNext() {
        // 执行队列处理完毕或者已暂停,返回结果
        if (running === 0 && stopped) {
          return resolve(Promise.all(res))
        }

        // 正在执行的任务数量不超过单次限制,存在未执行的任务
        while (running < limit && idx < tasks.length) {
          // 如果停止标志为 true,阻止新的任务加入
          if (stopped) {
            return
          }
          // 获取当前任务并执行
          const cur = tasks[idx++]()
          res.push(cur)
          running++
          cur.then(() => {
            running--
            runNext()
          }).catch(reject)
        }
      }

      runNext()
    })
  }

  return { stop, start }
}

3. 模拟异步方法、准备数据

// 创建100个任务
export const tasks = Array.from({ length: 100 }, (_, i) => () => fetchData(i))

// 模拟异步请求方法
export function fetchData(id: number) {
  return new Promise(resolve => {
    const time = Math.random() * 2000
    console.log(`开始任务: ${id}`)

    setTimeout(() => {
      console.log(`完成任务: ${id}`)
      resolve(id)
    }, time)
  })
}

4. 自定义hook

const useLimitRequests = (tasks: any[], limit: number)=> {
  const resultRef = useRef<number[]>([]); // 用 useRef 存储任务结果,避免重新渲染
  const isStop = useRef<boolean>(false);
  const idx = useRef<number>(0);
  const reunning = useRef<number>(0);

  const onStop = useCallback(() => {
    isStop.current = true;
  },[]);

  const onStart = useCallback(() => {
    return new Promise((resolve, reject) => {
      function nextRun(){
        if(reunning.current === 0 && isStop.current) {
          return resolve(Promise.all(resultRef.current));
        }

        while(idx.current < tasks.length && reunning.current < limit){
          if(isStop.current){
            return;
          }

          const curTaskRes = tasks[idx.current]();
          idx.current += 1;
          resultRef.current.push(curTaskRes)
          reunning.current += 1;

          curTaskRes.then(() => {
            reunning.current -= 1;
            nextRun();
          }).catch((error: any) => reject(error))
        }
      }

      nextRun();
    })
  },[isStop, limit, tasks]);

  return { onStop, onStart};
}

image.png

🥈 02. 支持指数退避的重试(Backoff Retry)

场景:接口偶尔报错,你希望自动重试 3 次,每次等待时间翻倍。

💡 可靠性思维能力


  1. 自定义重试方法
function retry(fn, times = 3, delay = 500) {
  return new Promise((resolve, reject) => {
    const attempt = (n, d) => {
      fn().then(resolve).catch(err => {
        if (n === 0) return reject(err)
        setTimeout(() => attempt(n - 1, d * 2), d)
      })
    }
    attempt(times, delay)
  })
}

2. 调用

  function handleRetry() {
    retry(mockRequest, 3, 500)
      .then((result) => console.log(result)) // 如果请求成功,输出结果
      .catch((error) => console.log(error)); // 如果重试失败,输出错误
  }

image.png

🥉 03. 带超时控制的 Promise(Timeout Promise)

场景:请求超 3 秒自动失败,不等了。

🕒 超时包装器


  1. 自定义函数实现
export function withTimeout(fn, ms){
  // 存放定时器
  let timer = null;

  // 超时函数
  const timeOut = () => new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error('超时了')), ms);
  });

  // Promise.race 会返回一个结果, fn 目标函数
  return Promise.race([fn(), timeOut()]).finally(() => {
    clearTimeout(timer);
  })
}

2. 模拟延迟异步方法

export function slowTask() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('Task completed'), 2000); // 模拟一个 3 秒的任务
  });
}

3. 调用

  function handleTimeOut() {
    withTimeout(slowTask, 1000) // 设置 1 秒超时
      .then((result) => console.log(result)) // 如果任务完成,输出结果
      .catch((error) => console.log(error)); // 如果超时,输出超时错误
  }

image.png

🚢 04. 串行任务:一步一步稳扎稳打

每个任务会按顺序一个接一个地执行,直到上一个任务完成后,才会开始下一个任务

📌 场景:分片上传、表单分步骤提交


  1. 自定义方法
export async function runInSequence(tasks){
  const result = [];

  for (const task of tasks) {
    const res = await task();
    result.push(res);
  }

  return result;
}

2. 模拟异步请求

export const fetchData = (task: any) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`Task ${task} completed`);
      resolve(`Result of ${task}`);
    }, 1000); // 每个任务延迟 1 秒
  });
};

// 定义任务列表
export const tasks = [
  () => fetchData('task1'),
  () => fetchData('task2'),
  () => fetchData('task3')
];

3. 调用

async function handleEquence(){
    const result = await runInSequence(tasks);
    console.log('All tasks completed', result);
  }

image.png

⌚️ 05. Promise 版“多方等待 ready”机制

这个机制用于让多个任务或组件等待某个条件(如 ready() 方法被调用)满足后再继续执行

应用场景

  • 多个任务依赖同一个条件:比如,多个组件在等待某个数据加载完成后再开始执行某个操作

  • 等待多个异步任务的准备:多个异步任务可能依赖某个资源,只有当该资源准备好时,才能继续执行后续操作。

  • 协调并发任务的开始:不同的任务或组件可以等待一个共同的“开始信号”,一旦信号发送,所有等待的任务就可以同时开始。


  1. 自定义class
export class Waiter{
  queue: any[];
  readyFlag: boolean;

  constructor(){
    this.queue = []; // 所有等待的任务
    this.readyFlag = false; // 是否已经准备好
  }

  wait(){
    // 条件已经准备好了,直接返回一个已解决的 Promise
    if(this.readyFlag) {
      return Promise.resolve();
    } else {
      // 将该任务的 Promise 放入 queue 队列中,等待
      return new Promise((r) => this.queue.push(r));
    }
  }


  ready(){
    this.readyFlag = true; // 设置条件已准备好
    this.queue.forEach(r => r()); // 遍历队列并触发所有等待的任务
    this.queue = []; // 清空队列
  }
}

2. 调用

function handleReady() {
    const waiter = new Waiter();

    // 任务 1:等待条件准备好后执行
    waiter.wait().then(() => console.log("Task 1 completed"));

    // 任务 2:等待条件准备好后执行
    waiter.wait().then(() => console.log("Task 2 completed"));

    // 任务 3:等待条件准备好后执行
    waiter.wait().then(() => console.log("Task 3 completed"));

    // 在 2 秒后,调用 `ready()`,表示条件准备好,所有任务可以执行
    setTimeout(() => {
      waiter.ready(); // 调用 ready,触发所有等待的任务
    }, 2000);
  }

image.png

06. 可暂停 / 恢复的 setInterval(轮询神器)

可以启动、暂停和恢复一个定时任务,而无需重启整个定时器

🌡️ 场景:页面隐藏暂停轮询,返回恢复


  1. 自定义class
export class PausableInterval{
  delay: number;
  fn: any;
  timer: any;
  running: boolean;

  constructor(fn: any, delay: number){
    this.fn = fn            // 定时任务函数
    this.delay = delay      // 定时器的间隔时间(单位:毫秒)
    this.timer = null       // 存储定时器的标识符
    this.running = false    // 标记定时器是否正在运行
  }

  start(){
    // 如果定时器已经在运行,直接返回,不做重复启动
    if(this.running) return;

    this.running = true;

    const tick = () => {
      if (!this.running) return // 如果定时器已暂停,则不再继续执行
      this.fn(); // 执行定时任务
      this.timer = setTimeout(tick, this.delay) // 使用 setTimeout 模拟 setInterval
    }

    tick();
  }

  pause() {
    clearTimeout(this.timer);
    this.running = false;
  }

  resume(){
    this.start()
  }
}

2. 模拟请求

function printMessage() {
  console.log("Task is running...");
}

export const pausableInterval = new PausableInterval(printMessage, 1000);

3. 调用

  function handleStartInterval() {
    pausableInterval.start();

    // 停止定时器
    setTimeout(() => {
      console.log("Pausing the task...");
      pausableInterval.pause();
    }, 3000); // 3秒后暂停

    // 恢复定时器
    setTimeout(() => {
      console.log("Resuming the task...");
      pausableInterval.resume();
    }, 5000); // 5秒后恢复
  }

image.png

07. 带最大等待 maxWait 的防抖(搜索框的神)

用于优化那些频繁触发的事件,特别是在搜索框、输入框或滚动等高频率操作中,常常用来减少不必要的计算或请求

💡 场景:搜索请求太多?一招治愈


  1. 自定义方法
function debounce(fn, delay, { maxWait = 0 } = {}) {
  let timer = null; // 存放定时器
  let start = null; // 第一次调用时间

  return function (...args) {
    const now = Date.now();  // 获取当前时间戳
    if (!start) start = now; // 记录第一次调用的时间

    clearTimeout(timer);  // 清除之前的定时器,避免多次触发

    const run = () => { 
      start = null;  // 重置 `start`,表示已经执行过操作
      fn.apply(this, args);  // 执行函数,并传入当前的 `this` 和参数
    };

    // 如果到达 `maxWait` 时间,强制执行 `fn`;否则继续延迟执行
    if (maxWait && now - start >= maxWait) run(); 
    else timer = setTimeout(run, delay);  // 在 `delay` 时间后执行
  };
}

2. 模拟短期内多次触发

function searchQuery(query) {
  console.log("Searching for:", query);
}

const debouncedSearch = debounce(searchQuery, 500, { maxWait: 2000 });

// 模拟用户输入
debouncedSearch("apple");
debouncedSearch("app");
debouncedSearch("appl");
debouncedSearch("apple pie");

08. 可取消的异步任务(不要让旧任务留着捣乱)

场景:页面切换后取消 pending 的 loading。

function cancellable(fn, delay) {
  let timer
  const p = new Promise(resolve => {
    timer = setTimeout(() => resolve(fn()), delay)
  })
  return { promise: p, cancel: () => clearTimeout(timer) }
}

9. 时间窗口限流(搜索框请求合并)

🌈 场景:500ms 内所有输入合并一次请求,节省带宽又快。

function createWindowRequester(fn, ms) {
  let timer = null
  let queue = []

  return function (...args) {
    return new Promise(resolve => {
      queue.push({ args, resolve })

      if (!timer) {
        timer = setTimeout(async () => {
          const batch = [...queue]
          queue = []
          timer = null

          const res = await fn(batch.map(i => i.args))
          batch.forEach((item, i) => item.resolve(res[i]))
        }, ms)
      }
    })
  }
}

10. 带优先级任务调度(Mini Scheduler)

场景:动画、后台任务、预加载策略。

class Scheduler {
  constructor() {
    this.queue = []
    this.running = false
  }
  add(fn, priority = 0) {
    this.queue.push({ fn, priority })
    this.queue.sort((a, b) => b.priority - a.priority)
    this.run()
  }
  async run() {
    if (this.running) return
    this.running = true
    while (this.queue.length) {
      const job = this.queue.shift()
      await job.fn()
    }
    this.running = false
  }
}

12. 并行预加载 + 串行渲染(列表加载体验优化)

🎨 场景:图片墙“先加载、再有序渲染”。

async function preloadAndRender(urls, render) {
  const preloads = urls.map(url => fetch(url).then(r => r.blob()))
  for (let i = 0; i < preloads.length; i++) {
    const data = await preloads[i]
    render(data, i)
  }
}

ts类型工具

作者 Robet
2025年11月30日 16:54

TypeScript 提供了一套强大的内置工具类型(Utility Types),用于从已有类型中派生出新类型,从而提升代码的健壮性、可维护性和开发效率。这些工具类型就像“类型世界的高阶函数”,能对类型进行组合、裁剪、转换等操作。


🧰 常用 TypeScript 工具类型速查表

分类 工具类型 作用
基础修饰 Partial<T> T 的所有属性变为可选
Required<T> T 的所有属性变为必填(移除 ?
Readonly<T> T 的所有属性变为只读
结构挑选 Pick<T, K> T选取指定键 K 的属性
Omit<T, K> T剔除指定键 K 的属性
Record<K, T> 构造一个键为 K、值为 T 的对象类型
类型过滤 Exclude<T, U> 从联合类型 T排除可赋值给 U 的类型
Extract<T, U> 从联合类型 T提取可赋值给 U 的类型
NonNullable<T> T 中移除 nullundefined
函数相关 ReturnType<T> 获取函数 T返回值类型
Parameters<T> 获取函数 T参数类型元组
ConstructorParameters<T> 获取构造函数的参数类型
InstanceType<T> 获取构造函数的实例类型
ThisParameterType<T> / OmitThisParameter<T> 处理函数中的 this 参数

🔍 典型示例与应用场景

1. Partial<T>:局部更新

interface User {
  id: number;
  name: string;
  email: string;
}

// 所有字段变为可选
type UpdateUser = Partial<User>;

function updateUser(id: number, changes: UpdateUser) {
  // 只需传入要修改的字段
}
updateUser(1, { name: "Alice" }); // ✅

2. Pick<T, K> / Omit<T, K>:按需选择或排除字段

type UserPreview = Pick<User, 'id' | 'name'>; // { id: number; name: string }
type UserWithoutId = Omit<User, 'id'>;        // { name: string; email: string }

3. Record<K, T>:构建配置对象

type Theme = 'light' | 'dark';
type ColorMap = Record<Theme, string>; // { light: string; dark: string }

4. ReturnType<T>:推导函数返回类型

function fetchUser() {
  return { id: 1, name: "Bob" };
}

type User = ReturnType<typeof fetchUser>; // { id: number; name: string }

5. Exclude<T, U> / Extract<T, U>

type Status = 'loading' | 'success' | 'error';
type ValidStatus = Exclude<Status, 'loading'>; // 'success' | 'error'
type LoadingOnly = Extract<Status, 'loading'>; // 'loading'

💡 小贴士

  • 这些工具类型基于 映射类型(Mapped Types)条件类型(Conditional Types)infer 等高级特性实现。
  • 它们是不可变的:不会修改原始类型,而是生成一个新类型。
  • 组合使用,例如:
    type SafeUser = Readonly<Partial<User>>;
    

如需深入某个工具类型的源码实现或更多实战案例,可以告诉我具体类型(如 OmitReturnType),我可以进一步详解!

react-native-promise-portal:React Native 弹窗管理的新思路

作者 soul96816
2025年11月30日 16:44
owjymdk6/f1627a1owjymdk6/f1627a1owjymdk6/f1627a1owjymdk6/f1627a1

在 React Native 开发中,我们经常需要弹出对话框、提示框、日期选择器或者其他 overlay 组件。传统方式通常依赖 state + conditional rendering + 回调函数,但在复杂场景下,代码会变得分散、难维护,尤其是当多个弹窗同时存在时。

react-native-promise-portal 提供了一种 Promise + Portal 的解决方案,让你可以像调用普通异步函数一样调用弹窗,同时保持逻辑线性、易于管理。

核心特点

  1. Promise-first 弹窗调用
    弹窗调用返回 Promise,你可以用 await 直接获取用户操作结果。业务逻辑与 UI 逻辑解耦,写出来的代码更直观。

  2. 支持多重弹窗 & name 去重

    • 可以在同一页面同时显示多个弹窗,每个弹窗独立关闭。
    • 通过 index 控制渲染层级,index 越大,显示在最上层。
    • 通过 name 去重,避免重复弹窗触发业务冲突。
  3. 局部 Portal

    • 支持在特定页面或模块内管理弹窗,不影响全局 Portal 状态。
    • 跨组件调用弹窗,保证弹窗只在目标页面显示。
    • 避免不同页面弹窗相互覆盖或冲突,提升模块化管理能力。
  4. 脱离 Hook 调用局部 UI

    • 使用 PortalManager 可以在 非组件上下文 触发局部弹窗。
    • 适用于后台逻辑、网络请求回调、定时任务等场景。
    • Promise 接口仍然保持逻辑线性。
  5. 基于 Portal 渲染

    • 利用 React Native 的 Portal 技术,将弹窗渲染到顶层,解决 z-index 和 clipping 问题。

基本使用

1. 设置 PortalProvider

import { PortalProvider } from 'react-native-promise-portal';

export default function RootLayout() {
  return <PortalProvider>{/* Your app content */}</PortalProvider>;
}

2. 使用 Hook 调用弹窗

import { usePortal } from 'react-native-promise-portal';

function MyComponent() {
  const { showWithOverlay } = usePortal();

  const handleShowDialog = async () => {
    try {
      const result = await showWithOverlay<boolean>({
        component: ({ close }) => (
          <Confirm title="Confirm" subTitle="Are you sure?" close={close} />
        ),
      });
      console.log('Result:', result);
    } catch (error) {
      console.error(error);
    }
  };

  return <Button onPress={handleShowDialog} title="Show Dialog" />;
}

多重弹窗与 name 去重示例

const { showWithOverlay } = usePortal();

// 弹出第一个弹窗
await showWithOverlay({
  name: 'confirm-delete',
  component: ({ close }) => <Confirm close={close} />,
});

// 再次调用同名弹窗会抛出 PortalAlreadyExistsError
await showWithOverlay({
  name: 'confirm-delete',
  component: ({ close }) => <Confirm close={close} />,
});

通过 index 可以控制层级顺序:

const promise1 = showWithOverlay({ title: 'Dialog 1', index: 1 });
const promise2 = showWithOverlay({ title: 'Dialog 2', index: 10 }); // 更高层级
const promise3 = showWithOverlay({ title: 'Dialog 3', index: 5 });

Promise.allSettled([promise1, promise2, promise3]).then((results) => {
  console.log('All dialogs closed:', results);
});

局部 Portal 解决的业务痛点

在大型应用中,某些弹窗只在特定页面显示,使用全局 Portal 管理容易导致以下问题:

  • 弹窗状态全局混乱
  • 不同页面弹窗可能相互覆盖
  • 逻辑与 UI 耦合,调用分散

局部 Portal 通过 PortalManager 提供页面级管理:

import { PortalRender, PortalManager } from 'react-native-promise-portal';
import { useLocalPortals } from './helper/LocalPortal';

function HomePage() {
  const homePortalContent = useLocalPortals((state) => state.homePortalContent);

  const handleShowLocalPortal = async () => {
    await HomePagePortalManager.showWithOverlay<boolean>({
      component: ({ close }) => (
        <Confirm
          title="Local portal"
          subTitle="Only on home page"
          close={close}
        />
      ),
    });
  };

  return (
    <>
      <Button onPress={handleShowLocalPortal} title="Show Local Portal" />
      <PortalRender portals={homePortalContent} />
    </>
  );
}

局部 Portal 优势:

  1. 弹窗只影响特定页面或模块
  2. 跨组件 / 跨 Hook 调用更灵活
  3. 避免全局冲突,提高模块化和可维护性

脱离 Hook 调用局部 UI

有些业务场景中,你可能希望在 非 React 组件上下文Hook 不方便使用的地方 调用弹窗,例如网络请求回调或定时任务。

import { HomePagePortalManager } from './helper/LocalPortal';

async function handleAsyncEvent() {
  try {
    const result = await HomePagePortalManager.showWithOverlay<boolean>({
      component: ({ close }) => (
        <Confirm
          title="Async Event"
          subTitle="This dialog is triggered outside React component"
          close={close}
        />
      ),
      overlay: { orientation: 'centerMiddle' },
    });
    console.log('User result:', result);
  } catch (err) {
    console.error('Portal closed or error:', err);
  }
}

特点:

  • 脱离组件 / Hook,任意业务逻辑可触发
  • 弹窗仅显示在目标页面或模块
  • Promise 接口保持逻辑线性

更多示例

Loading 指示器

const { showWithOverlay } = usePortal();

const showLoading = () => {
  const { close } = showWithOverlay<void>({
    component: () => <ActivityIndicator />,
    overlay: { closeable: false },
  });

  setTimeout(() => close(), 3000);
};

日期选择器(底部弹出)

const date = await showWithOverlay<string>({
  component: ({ close }) => (
    <DatePicker
      onSelect={(date) => close(date)}
      onCancel={() => close(new Error('Cancelled'))}
    />
  ),
  overlay: { orientation: 'centerBottom' },
});

错误处理

import { PortalError } from 'react-native-promise-portal';

try {
  const result = await showWithOverlay<boolean>({
    component: ({ close }) => <Confirm close={close} />,
  });
} catch (error) {
  if (error instanceof PortalError) {
    if (error.isCloseByOverlayPressError()) console.log('Closed by overlay');
    else if (error.isCloseByHardwareBackPressError()) console.log('Closed by back button');
    else if (error.isPortalAlreadyExistsError()) console.log('Portal already exists');
  }
}

总结

react-native-promise-portal 将弹窗调用封装为异步函数,支持:

  • 多重弹窗
  • name 去重机制
  • 局部 Portal
  • 脱离 Hook 调用局部 UI
  • Promise 异步接口

它极大简化了 React Native 弹窗管理,保证 UI 与业务逻辑解耦,调用方式直观、可维护,是复杂页面交互场景下的理想选择。


安装

npm install react-native-promise-portal

如果你喜欢这个库,欢迎点个 ⭐️ 支持一下!

GitHub stars


为什么 SVG 能在现代前端中胜出?

作者 吹水一流
2025年11月30日 16:14

如果你关注前端图标的发展,会发现一个现象:

过去前端图标主要有三种方案:

  • PNG 小图(配合雪碧图)

  • Iconfont

  • SVG

到了今天,大部分中大型项目都把图标系统全面迁移到 SVG。
无论 React/Vue 项目、新框架(Next/Remix/Nuxt),还是大厂的设计规范(Ant Design、Material、Carbon),基本都默认 SVG。

为什么是 SVG 胜出?
为什么不是 Iconfont、不是独立 PNG、不是雪碧图?
答案不是一句“清晰不失真”这么简单。

下面从前端实际开发的角度,把 SVG 胜出的原因讲透。


一、SVG 为什么比位图(PNG/JPG)更强?

矢量图永不失真(核心优势)

PNG/JPG 是位图,只能按像素存图。
移动端倍率屏越来越高(2x、3x、4x……),一张 24px 的 PNG 在 iPhone 高分屏里可能看起来糊成一团。

SVG 是矢量图,数学计算绘制:

  • 任意缩放不糊

  • 任意清晰度场景都不怕

  • 深色模式也不会变形

这点直接解决了前端图标领域长期存在的一个痛点:适配成本太高


体积小、多级复用不浪费

同样一个图标:

  • PNG 做 1x/2x/3x 需要三份资源

  • SVG 只要一份

而且:

  • SVG 本质是文本

  • gzip 压缩非常有效

在 CDN 下,通常能压到个位数 KB,轻松复用。


图标换色非常容易

PNG 改颜色很麻烦:

  • 设计师改

  • 重新导出

  • 重新上传/构建

Iconfont 的颜色只能统一,只能覆盖轮廓颜色,多色很麻烦。

SVG 则非常灵活:

.icon {
  fill: currentColor;
}

可以跟随字体颜色变化,支持 hover、active、主题色。

深浅模式切换不需要任何额外资源。


支持 CSS 动画、交互效果

SVG 不只是图标文件,它是 DOM,可以直接加动画:

  • stroke 动画

  • 路径绘制动画

  • 颜色渐变

  • hover 发光

  • 多段路径动态控制

PNG 和 Iconfont 都做不到这种级别的交互。

很多现代 UI 的微动效(Loading、赞、收藏),都是基于 SVG 完成。


二、SVG 为什么比 iconfont 更强?

Iconfont 在 2015~2019 年非常火,但明显已经退潮了。
原因有以下几个:


① 字体图标本质是“字符”而不是图形

这带来大量问题:

● 不能多色

只能 monochrome,彩色图标很难实现。

● 渲染脆弱

在 Windows 某些字体渲染环境下会出现:

  • 发虚

  • 锯齿

  • baseline 不一致

● 字符冲突

不同项目的字体图标可能互相覆盖。

相比之下,SVG 是独立图形文件,没有这些问题。


② iconfont 需要加载字体文件,失败会出现“乱码方块”

如果字体文件没加载成功,你会看到:

☐ ☐ ☐ ☐

这在弱网、支付类页面、海外环境都非常常见。

SVG 就没有这个风险。


③ iconfont 不利于按需加载

字体文件通常包含几十甚至几百个图标:
一次加载很重,不够精细。

SVG 可以做到按需加载:

  • 一个组件一个 SVG

  • 一个页面只引入用到的部分

  • 可组合、可动态切换

对于现代构建体系非常友好。


三、SVG 为什么比“新版雪碧图”更强?

即便抛开 iconfont,PNG 雪碧图也完全被淘汰。

原因很简单:

  • 雪碧图文件大

  • 缓存粒度差

  • 不可按需加载

  • 维护复杂

  • retina 适配麻烦

  • 颜色不可动态变更

而 SVG 天生具备现代开发所需的一切特性:

  • 轻量化

  • 组件化

  • 可变色

  • 可动画

  • 可 inline

  • 可自动 tree-shaking

雪碧图本质上是为了“减少请求数”而生的产物,
但在 HTTP/2/3 中已经没有价值。

而 SVG 不是 hack,而是自然适配现代 Web 的技术方案


四、SVG 为什么能在工程体系里更好地落地?

现代构建工具(Vite / Webpack / Rollup)原生支持 SVG:

  • 转组件

  • 优化路径

  • 压缩

  • 自动雪碧(symbol sprite)

  • Tree-shaking

  • 资源分包

这让 SVG 完全融入工程体系,而不是外挂方案。

例如:

import Logo from './logo.svg'

你可以:

  • 当组件使用

  • 当资源下载

  • 当背景图

  • 动态注入

工程化友好度是它胜出的关键原因之一。


五、SVG 胜出的根本原因总结

不是 SVG “长得好看”,也不是趋势,是整个现代前端生态把它推到了最合适的位置。

1)协议升级:HTTP/2/3 让雪碧图和 Iconfont 的优势全部消失
2)设备升级:高分屏让位图模糊问题暴露得更明显
3)工程升级:组件化开发需要精细化图标
4)体验升级:动画、主题、交互都离不开 SVG

一句话总结:

SVG 不只是“更清晰”,而是从工程到体验全面适配现代前端的图标方案,因此胜出。

前端转战后端:JavaScript 与 Java 对照学习指南 (第一篇 - 深度进阶版)

作者 汤姆Tom
2025年11月30日 15:24

对于习惯了 JavaScript (JS) 灵活性的前端开发者来说,Java 看起来可能充满了繁琐的定义和样板代码。但实际上,现代 Java (Java 8/11/17+) 已经吸收了很多函数式编程的特性,写起来越来越顺手。

本篇指南将通过 JS vs Java 代码对比的方式,深度解析 类型系统流式处理 (Stream API)集合操作 以及 常见的内存陷阱

1. 核心思维转变:从“自由”到“约束”

在开始写代码前,需要建立三个核心认知的转变:

  1. 入口函数:JS 代码通常从上到下执行;Java 程序必须从一个 main 方法开始。
  2. 类型约束:JS 是 let a = 1 (a 随后可以变成字符串);Java 是 int a = 1 (a 永远只能是整数)。
  3. 引用与值:JS 对对象默认是引用传递,Java 也是引用传递(操作内存地址),但 Java 的字符串是不可变的,且比较机制完全不同。

2. 变量声明:var 的真相与基本类型

Java 10 引入了 var,这让前端感到非常亲切,但它和 JS 的 let/var 有本质区别。

场景:类型推断与作用域

JavaScript

// JS: 动态类型
let id = 10;
id = "User-10"; // ✅ 合法,类型变了

// 作用域
if (true) {
    var oldVar = "I leak out"; // var 会提升 (Hoisting)
    let newLet = "I am safe";  // 块级作用域
}
console.log(oldVar); // 能打印

Java

public class VariableDeepDive {
    public static void main(String[] args) {
        // --- 1. Java 10+ 的 var (局部变量类型推断) ---
        // 看起来像 JS,但实际上编译器在编译时就确定了类型
        var id = 10; // 编译器推断 id 是 int 类型
        // id = "User-10"; // ❌ 报错!一旦推断为 int,就永远是 int
        
        // --- 2. 基本数据类型 vs 包装类型 (深度解析) ---
        // int: 存数值,占用内存少,默认值 0
        int count = 0;
        
        // Integer: 存对象的地址,默认值 null
        // 自动装箱(Autoboxing): Java 自动把 int 5 转为 Integer 对象
        Integer score = 5; 
        
        // ⚠️ 坑:空指针异常 (NPE)
        Integer unknownScore = null;
        // int finalScore = unknownScore; // ❌ 运行时崩溃!拆箱 null 会报错
        
        // 最佳实践:
        // 数据库实体类、泛型列表用 Integer
        // 局部变量循环计数用 int
    }
}

3. 字符串:不可变性与内存陷阱

JS 的字符串很简单,Java 的字符串为了性能做了很多底层优化(字符串常量池),导致比较逻辑不同。

场景:拼接与比较

JavaScript

let a = "hello";
let b = "hello";
console.log(a === b); // true

// 模板字符串
let msg = `Value is ${a}`; 

Java

public class StringDeepDive {
    public static void main(String[] args) {
        // --- 1. 比较陷阱 ---
        String s1 = "hello"; // 存放在常量池
        String s2 = new String("hello"); // 强制在堆内存创建新对象
        
        // ❌ == 比较的是内存地址
        System.out.println(s1 == s2); // false
        
        // ✅ equals 比较的是字符内容
        System.out.println(s1.equals(s2)); // true
        
        // --- 2. 拼接的性能问题 ---
        // 简单的拼接编译器会自动优化
        String msg = "Value is " + s1;
        
        // ⚠️ 循环中拼接严禁使用 "+"
        String res = "";
        // ❌ 性能极差,每次循环都会创建新 String 对象
        // for(int i=0; i<100; i++) res += i; 
        
        // ✅ 正确做法:StringBuilder (类似 JS 数组 join)
        StringBuilder sb = new StringBuilder();
        for(int i=0; i<100; i++) {
            sb.append(i);
        }
        System.out.println(sb.toString());
    }
}

4. 数组与列表:Stream API (前端最爱)

Java 8 引入的 Stream API 简直就是前端 Array.prototype 方法(filter, map, reduce)的亲兄弟。

场景:筛选大于 10 的数字并翻倍

JavaScript

const numbers = [5, 12, 8, 20];

// 链式调用:先过滤,再映射
const result = numbers
    .filter(n => n > 10)
    .map(n => n * 2);

console.log(result); // [24, 40]

Java (使用 Stream)

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class StreamExample {
    public static void main(String[] args) {
        // 快速初始化 List (Java 9+)
        List<Integer> numbers = List.of(5, 12, 8, 20); 
        // 注意:List.of 创建的是"不可变列表",不能 add/remove
        
        // --- Stream API ---
        List<Integer> result = numbers.stream() // 1. 开启流
            .filter(n -> n > 10)                // 2. 过滤 (Predicate)
            .map(n -> n * 2)                    // 3. 映射 (Function)
            .collect(Collectors.toList());      // 4. 收集结果回 List
            
        System.out.println(result); // [24, 40]
        
        // --- 传统遍历 (Enhanced For-Loop) ---
        // 类似 JS 的 for (const n of numbers)
        for (Integer n : numbers) {
            System.out.println(n);
        }
    }
}

🔍 差异点:

  • JS 的数组方法直接作用于数组。Java 必须先调用 .stream() 转换成流,处理完后再 .collect() 回集合。
  • Java 的 map 必须返回新值,不能像 JS 某些骚操作里那样不返回值只做副作用(虽然 JS 规范也不建议那样做)。

5. 字典与映射:Map 的花式操作

Map 在后端开发中无处不在,尤其是在处理 JSON 数据时。

场景:初始化与遍历

JavaScript

const map = {
    "key1": "value1",
    "key2": "value2"
};

// 遍历
Object.entries(map).forEach(([k, v]) => {
    console.log(k, v);
});

Java

import java.util.HashMap;
import java.util.Map;

public class MapDeepDive {
    public static void main(String[] args) {
        // --- 1. 快速初始化 (Java 9+) ---
        // 创建不可变 Map,最多支持 10 对
        Map<String, String> quickMap = Map.of(
            "key1", "value1",
            "key2", "value2"
        );
        
        // 常规可变 Map
        Map<String, String> map = new HashMap<>();
        map.put("key1", "value1");
        
        // --- 2. 遍历 ---
        // 方式 A: forEach + Lambda (最像 JS)
        map.forEach((k, v) -> {
            System.out.println("Key: " + k + ", Val: " + v);
        });
        
        // 方式 B: entrySet (性能好,传统方式)
        // Map.Entry 相当于 JS 的 [key, value] 元组
        for (Map.Entry<String, String> entry : map.entrySet()) {
            String k = entry.getKey();
            String v = entry.getValue();
        }
    }
}

6. 常见痛点对照表 (Cheatsheet)

场景 JavaScript Java (最佳实践)
定义不可变常量 const API_URL = "..." static final String API_URL = "...";
模板字符串 `Hello ${name}` String.format("Hello %s", name)"Hello " + name
数组包含 arr.includes(x) list.contains(x)
数组判空 arr.length === 0 list.isEmpty()
对象取值防崩 obj?.prop Optional.ofNullable(obj).map(...) (较复杂) 或简单判空 if (obj != null)
JSON 解析 JSON.parse(str) 使用库:Jackson (objectMapper.readValue(...))
JSON 序列化 JSON.stringify(obj) 使用库:Jackson (objectMapper.writeValueAsString(...))
比较对象 a === b (通常不行) a.equals(b) (必须重写 equals 方法)

核心建议

  1. 善用 IDE:IntelliJ IDEA 是 Java 开发的神器。当你不知道方法名时,输入 . 然后停顿,它会列出所有可用方法,这比查文档快得多。
  2. 拥抱类型:不要为了省事全部用 ObjectMap<String, Object> 模拟 JS 对象。定义一个明确的 User 类(Class)虽然前期麻烦,但在后期维护和重构时,它的优势会碾压动态类型。

一个有趣的CSS题目

作者 小熊哥722
2025年11月30日 15:01

前几天无意间看到一个有趣的题,题目很简单,但是最终的结果却出人意料,题目是这样的:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="theme-color" content="blue"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<style>
  .parent{
    display: flex;
    flex-direction: column;
    height: 600px;
    width: 300px;
    background-color: aqua;
  }
  .header{
    height: 200px;
    background-color: red;
  }
  .fotter{
    height: 200px;
    background-color: blue;
  }
  .content{
    height: 100%;
  }
</style>
<body>
  <div class="parent">
    <div class="header"></div>
    <div class="content"></div>
    <div class="fotter"></div>
  </div>
</body>
</html>

这段代码中的content的高度是多少,大家可以自己想一下,稍微思考一下。接下来让我们看一下豆包的回答: 再看一下通义千问的回答:

再看一下deepseek的回答:

最后再看看Gemini3的回答:

大家可能以为content的高度不就是200px吗,但是结果真的是这样的吗?

请大家看一下最终的结果:

       大家会看到content的高度为360px,header和footer的高度为120px,是不是感觉不可思议,为啥会这样嘞,这个问题,想问ai就会发现,这咋问啊,ai全是错的,那我来告诉你们答案(当然我说的也不一定对,但是大差不差,结果肯定是对的):

  • percent height 会被解析为父容器的绝对值:content 的 height:100% → 600px(父高度)。
  • 每个子项的 flex base size(假设基准)分别是 header=200, content=600, footer=200,总和 = 1000px。
  • 可用主轴空间 = 600px,差额(需要收缩) = 1000 - 600 = 400px。
  • 默认 flex-shrink = 1,按基准尺寸比例收缩:content 收缩量 = 600/1000 * 400 = 240 → 最终 content 高度 = 600 - 240 = 360px。

参考(建议重点看 W3C 规范里的算法):

大家可以详细看看理解一下,当然不看也没关系,知道这个计算规则也 可以了,至少比ai强了。

Rect深入学习

2025年11月30日 14:37

React核心机制

虚拟 DOM 和 Diff 算法

什么是虚拟DOM

虚拟DOM可以理解为模拟了DOM树的JS对象树

比如

var element = {
    element: 'ul',
    props: {
        id:"ulist"
    },
    children: [
    { element: 'li', props: { id:"first" }, children: ['这是第一个List元素'] },
    { element: 'li', props: { id:"second" }, children: ['这是第二个List元素'] }
    ]
}

为什么需要虚拟DOM

传统DOM更新方式的问题:

  • 在原生JS中,更新DOM的方式往往是粗颗粒度的更新,直接替换整颗子树,容易造成性能的浪费
  • 如果要做到细颗粒度更新,则需要自己决定修改哪一部分,但这种手动diff很麻烦

虚拟DOM更新的优势:

  • 框架自动对新旧虚拟DOM树进行diff算法
  • 然后精准更新DOM树中变化的部分,大幅度提升性能

举例:

比如有一个列表,我对其进行了修改

//旧UI
<ul id="list">
  <li>苹果</li>
  <li>香蕉</li>
</ul>

//新UI
<ul id="list">
  <li>苹果</li>
  <li>橘子</li> <!-- 改动 -->
</ul>
  • 传统DOM更新:

    • 粗暴做法list.innerHTML = render(items) → 把整个 <ul> 清空并重建 <li>,即使“苹果”没变也会被销毁重建。

    • 精细做法:你必须写逻辑找到第 2 个 <li> 并替换它的文本

      • list.children[1].textContent = '橘子';
      • 但这种手动 diff 很麻烦,开发者必须自己维护 UI 和数据的一致性。
  • 虚拟DOM更新:

    • 框架自动比较新旧虚拟 DOM:

      • 第 1 个 <li> 一样 → 复用。
      • 第 2 个 <li> 文本不同 → 只更新文本。
    • 最终只执行一条 DOM 操作:list.children[1].textContent = '橘子';

diff算法

传统diff算法的时间复杂度是O(n³)

  • 我们要把旧树变成新树,找到最小修改路径

为什么时间复杂度是O(n³)?

  1. 遍历旧树的每个节点(n次)

  2. 遍历新树的每个节点(n次)

    • 对比每个旧树中的节点,找到新树中可能对应的新节点
  3. 比较两个节点的子结构是否完全相同

    • 因为判断“是否同一个节点”不仅要看标签名,还要看它的整个子结构是否相同。 这就需要再深入进去比较它们的子树。

    • 每对匹配节点都可能有一整棵子树;

      每棵子树的节点数也可能接近 n;

      所以在最坏情况下,每一对匹配都要再递归比较一遍整棵子树。

于是复杂度变成:

O(n)(旧树) × O(n)(新树) × O(n)(子树递归) = O(n³)

第 3 层递归比较子树的复杂度,是因为每一对匹配节点都还要递归地比较它们的子树结构

前两层只是找出“候选节点对”,第三层才是深入检查“它们真的一样吗”。

所以整体复杂度是: 旧树节点数 × 新树节点数 × 子树递归 = O(n³)


React的diff算法的时间复杂度是O(n)

React 把问题简化成了三条“经验规则”,正是这三条规则让复杂度从 O(n³) → O(n)。

  1. 同层比较,不跨层

    • 复杂度就从 O(n³)O(n²)
  2. 不同类型节点,直接替换整棵子树

    • 也就是说,不同类型的节点永远不去比较子树。 这避免了对子树的递归匹配,进一步从 O(n²)O(n)
  3. 通过 key 标识子节点的稳定性

    • 对于同一层的子节点列表,React 通过 key 来判断哪些节点是“同一个节点”
//旧
<ul>
  <li key="A">A</li>
  <li key="B">B</li>
  <li key="C">C</li>
</ul>
//新
<ul>
  <li key="B">B</li>
  <li key="A">A</li>
  <li key="C">C</li>
</ul>

React 会通过 key 识别出:

  • A、B、C 都还在;
  • 只是顺序变了;
  • 所以只需调整位置,不需要删除重建

这就让 同层的节点比较只需一次线性扫描

👉 因此,同层 diff 的复杂度变为 O(n)

key 的作用是什么?

  • 是React对于列表元素的唯一标识

    • 如果key相同,那么认为是同一节点,可以复用DOM元素
    • 如果key不同,则会销毁旧的,创建新的节点

为什么不能用 index 作为 key?

因为会导致错误的复用和性能问题

  • 因为列表内容如果从中间新增或者删除一项,那么index对应的元素将会错误的被复用

React 中 reconciliation 的过程是怎样的?

  • 当组件的 stateprops 变化时,React 会比较新旧虚拟 DOM(Fiber 树) ,找出需要更新的部分并同步到真实 DOM。这个比较与更新过程叫 Reconciliation

React 更新是同步还是异步的?

同步更新

同步模式下,React一旦开始渲染,就会一口气渲染完所有组件,期间不会中断

  • 页面上的表现

    • 当你触发一个大型渲染(比如 setState 导致 1000 个组件更新)时,页面会卡顿一下
    • 浏览器在 React 渲染完成前,无法响应用户操作(比如滚动、点击)
function App() {
  const [count, setCount] = useState(0)

  const handleClick = () => {
    for (let i = 0; i < 10000; i++) {
      setCount(i) // 模拟大规模更新
    }
  }

  return <button onClick={handleClick}>count: {count}</button>
}

在同步更新下,点击按钮后:

  • UI 会“卡死”几百毫秒;
  • 最后一次性更新成最终结果。

异步(Concurrent)更新(React 18 createRoot)

在并发模式下,React会把渲染拆分为小任务,在空闲时间片执行,可以随时暂停、恢复或丢弃

  • 页面上的表现

    • 大型渲染不再卡顿;
    • 页面仍能响应滚动、输入、动画;
    • React 会优先处理用户交互(高优先级),低优先级任务(如列表渲染)可延后执行。
import { useState, startTransition } from 'react'

function App() {
  const [value, setValue] = useState('')
  const [list, setList] = useState([])

  const handleChange = (e) => {
    const val = e.target.value
    setValue(val)
    startTransition(() => {
      // 模拟高开销任务
      const items = Array.from({ length: 5000 }, (_, i) => `${val}-${i}`)
      setList(items)
    })
  }

  return (
    <>
      <input value={value} onChange={handleChange} placeholder="输入点东西" />
      <ul>{list.map((item) => <li key={item}>{item}</li>)}</ul>
    </>
  )
}

在异步(Concurrent)模式下:

  • 输入框 不会卡顿
  • React 会优先更新输入框的值
  • 再利用空闲时间慢慢渲染列表;
  • 如果你输入更快,React 会丢弃旧的渲染任务,直接开始最新的。

同步场景

React17及以前的全部更新,默认同步
// React 17 写法(使用 ReactDOM.render)
import ReactDOM from 'react-dom'

function App() {
  const [count, setCount] = React.useState(0)

  console.log('render:', count)

  return (
    <button onClick={() => setCount(count + 1)}>
      Click: {count}
    </button>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))

💬 说明:

  • 在 React 17(及更早版本)中,React 没有并发模式(Concurrent Mode)
  • 所有更新(无论大或小)都是同步执行的。
  • 点击按钮时,会立刻执行所有渲染逻辑。

📍页面效果:

即使组件很复杂、渲染耗时,React 也会“卡着”把它一次性渲染完。

React 18 中的旧 Root(非 Concurrent Root)
// React 18,但仍使用 ReactDOM.render(旧 Root)
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))
  • 即使你使用 React 18,只要还用旧的 ReactDOM.render, React 就不会启用并发模式(仍是同步更新)。
  • 所以这种 root 下的渲染依然会一次性执行完,期间不能被打断。

📍页面效果:

和 React 17 完全一样,仍然是同步阻塞渲染。

在 React 事件回调中调用的更新
import { useState } from 'react'

function App() {
  const [count, setCount] = useState(0)

  const handleClick = () => {
    console.log('Before:', count)
    setCount(count + 1)
    console.log('After:', count)
  }

  return <button onClick={handleClick}>Click: {count}</button>
}

即使你在 React 18 并发模式下(使用 createRoot), 在 React 事件回调中触发的更新仍是同步批量更新

React 会立即计算新的 Fiber 树,保证交互即时。

异步场景

使用 createRoot()

使用 createRoot()(并发 root)—— 开启异步渲染能力

// React 18 推荐写法(Concurrent Root)
import ReactDOM from 'react-dom/client'
import App from './App'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(<App />)
  • createRoot() 会启用 Concurrent Mode(并发模式)
  • 在这种 root 下,React 的更新具备可中断、可延迟的能力;
  • 不代表所有更新都异步,但具备异步调度的基础条件

📍页面效果:

如果组件渲染量大,React 可以暂停、分段渲染,不会卡死主线程。 用户输入或动画依然流畅。

使用 startTransition()(标记低优先级更新)
import { useState, startTransition } from 'react'

function App() {
  const [value, setValue] = useState('')
  const [list, setList] = useState([])

  const handleChange = (e) => {
    const val = e.target.value
    setValue(val)
    // 👇 告诉 React:这是低优先级任务,可延迟执行
    startTransition(() => {
      const items = Array.from({ length: 5000 }, (_, i) => `${val}-${i}`)
      setList(items)
    })
  }

  return (
    <>
      <input value={value} onChange={handleChange} placeholder="输入点东西" />
      <ul>{list.map((item) => <li key={item}>{item}</li>)}</ul>
    </>
  )
}

说明:

  • startTransition() 将内部更新标记为可中断任务
  • 高优先级任务(输入框更新)会先执行
  • 低优先级任务(列表渲染)会在空闲时执行;
  • 若用户继续输入,React 会丢弃旧任务、渲染最新的。

📍页面效果:

输入非常流畅,列表延迟更新但不卡顿。

使用 useDeferredValue()(延迟渲染依赖值)
import { useState, useDeferredValue } from 'react'

function App() {
  const [text, setText] = useState('')
  const deferredText = useDeferredValue(text) // 延迟使用 text 的值

  const list = Array.from({ length: 5000 }, (_, i) => (
    <li key={i}>{deferredText}</li>
  ))

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <ul>{list}</ul>
    </>
  )

说明:

  • useDeferredValue() 会在高优先级更新(输入)后,延迟执行耗时渲染
  • 输入流畅;
  • 列表内容更新稍后完成。

📍页面效果:

输入框立即响应,列表延迟一点点更新。 类似于“防抖 + 并发更新”的效果

一句话总结:

在 React 18 中,只要使用 createRoot() 启动应用, 你就进入了并发世界。 再搭配 startTransition() / useDeferredValue(), 就能让 React 的更新更智能、更流畅。

Fiber 是为了解决什么问题?

  • Fiber 是 React 为了解决「同步更新导致的卡顿问题」而引入的「可中断、可恢复的虚拟 DOM 架构」
  • Fiber 是 React 的底层重构,用于让虚拟 DOM 更新“可中断、可恢复、可调度”,从而提升大规模渲染的流畅度。

背景问题

React15的缺陷,在React15之前,更新流程是这样的:

  1. 状态更新后,React 会从根节点开始,递归遍历整棵虚拟 DOM 树
  2. 每次更新都同步执行到底(不能中断)
  3. 如果组件层级很深、计算复杂,就会长时间占用主线程
  4. 主线程被占用时,浏览器无法响应用户操作(如输入、滚动) → 卡顿、掉帧

fiber的核心目标

React团队为了解决“更新太重,无法中断”的问题,引入了fiber架构

  1. 可中断更新:react更新可以被中断,让浏览器先去响应用户操作
  2. 可分片执行:大任务被拆分为小任务,(每一帧执行一点)
  3. 可恢复与重用:被中断后可以从上次中断的地方继续执行

Fiber的设计思路

React 把每个虚拟 DOM 节点(VNode)包装成一个 Fiber 对象, 这个对象包含:

  • 节点类型、props、state
  • 指向父节点、子节点、兄弟节点的指针(形成链表结构)
  • 更新优先级信息(lane)
  • 副作用标记(如需插入/删除/更新)

➡️ 这样 React 就可以:

  • 用「遍历链表」代替「递归函数调用」(可随时暂停)
  • 在空闲时间片中继续工作(用调度器协调)
  • 动态决定哪部分更新优先(配合 Concurrent Mode)

Fiber 是如何实现可中断渲染的?

  • Fiber 通过把递归改为循环、把组件树改为链表结构、并利用时间片调度机制,使得 React 的渲染可以“暂停—恢复—继续”,从而实现了可中断渲染。

在React15中,更新虚拟DOM时使用的是递归遍历整棵树的方式

function updateComponent(component) {
  component.render()
  component.children.forEach(updateComponent)
}

问题是:

  • JS 是单线程的;
  • 一旦进入这段递归逻辑,就无法中途暂停;
  • 如果组件树很大,浏览器主线程会被长期占用;
  • 用户交互、动画、输入都会卡顿。

React 16 重写架构为 Fiber Reconciler。 目标:让“渲染过程”像执行协程一样 —— 可中断、可恢复、可调度。

Fiber 的关键设计思想是:

“把每个虚拟 DOM 节点(VNode)变成一个 Fiber 对象,并把组件树改成链表结构。”

这样 React 就可以:

  1. while 循环遍历链表(而非递归);
  2. 每处理一个 Fiber 节点,都检查当前帧是否超时;
  3. 如果时间用完,就暂停渲染,把控制权交还浏览器;
  4. 下一帧(或空闲时)再从上次中断的 Fiber 继续工作。

React Hooks

useState

基础概念

useState 是 React 提供的用于在函数组件中声明状态(state)的 Hook。

const [state, setState] = useState(initialValue)

  • state:当前状态值。
  • setState:更新状态的函数,会触发组件重新渲染。
  • initialValue:初始状态值,只在组件首次渲染时使用。

运行机制

当组件执行时(函数重新运行),useState 并不会重新创建新的状态,而是通过 React 内部的 Hook 链表(Fiber)机制 取回上一次保存的状态值。

也就是说:

  • 虽然函数重新执行了,
  • useState 通过闭包 + 内部索引保存并取回之前的状态。

👉 因此即使多次调用 useState(0)state 也不会回到 0。

React 约束: Hook 调用顺序必须一致,否则状态会错位。

let x = []
let index = 0
const myUseState = initial => {
let currentIndex = index 
x[currenIndex] = x[currentIndex] === undefined ? initial : x[currentIndex]

const setInitial = value => {
x[currentIndex] = value
render()
}
}

//模拟render函数
const render = () => {
index = 0
ReactDOM.render(<App/>, document.querySelector("#root"))
}

const App = () => {
const [n, setN] = myUseState(0)
const [m, setM] = myUseState(0)
return (
<div>
         <p>n:{n}</p>
         <button onClick={()=>setN(n+1)}>+1</button>
         <p>m:{m}</p>
         <button onClick={()=>setM(m+1)}>+1</button>
     </div>
)
}

异步批处理(Batch Update)

React 会将多个状态更新合并执行(在事件回调中)。

setCount(count + 1)

setCount(count + 1)

// 实际只增加一次React 18 中,异步任务(如 setTimeoutPromise)中的 setState 不再强制合并。

惰性初始化

初始值可以是一个函数:

const [data, setData] = useState(() => heavyCalculation())

heavyCalculation() 只在首次渲染时执行,避免每次渲染重复计算。

更新函数形式

当新状态依赖旧状态时,用函数式更新:

setCount(prev => prev + 1)

useEffect

一、作用

useEffect 用于处理 副作用(side effects) ,比如:

  • 网络请求
  • 订阅 / 事件监听
  • 操作 DOM
  • 定时器

这些逻辑不能直接放在渲染阶段,否则会阻塞或污染渲染。

  • 函数组件需要是纯函数:

    • 相同输入 → 永远相同输出
    • 不修改外部变量
    • 不产生额外行为
  • React 设定函数组件必须满足:

    • 相同的 props & state → 必须产生完全相同的 UI
    • 不依赖外部可变环境
    • 没有无法预测的行为
    • 渲染阶段必须同步、快速、纯净

换句话说:

组件函数必须像数学函数一样:输入 → 输出 UI

二、执行时机

  • 初次渲染后 执行(不会阻塞渲染)。
  • 依赖项变化 时重新执行。
  • 组件卸载前 执行清理函数。

三、依赖数组 [deps]

写法 执行时机
useEffect(fn) 每次渲染都执行
useEffect(fn, []) 仅挂载和卸载时执行一次
useEffect(fn, [a, b]) 当依赖项 a 或 b 改变时执行

⚠️ React 比较依赖项是浅比较,如果依赖对象或数组的引用变了,即使内容没变也会触发。

四、清理函数

返回一个函数,用于卸载或重新执行前清理副作用:

useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000)
  return () => clearInterval(id)
}, [])

执行时机:

  1. 组件卸载时;
  2. 副作用重新执行前。

五、面试常问点

  1. useEffect 为什么在渲染后执行? 为了让渲染过程纯净,不被副作用打断。

  2. 为什么要写依赖数组? 告诉 React 什么时候重新运行副作用,否则可能死循环。

  3. 依赖项写错或少写会怎样? 可能导致状态不同步或逻辑失效(React 会在严格模式下警告)。

  4. useLayoutEffect 和 useEffect 的区别?

    • useEffect:渲染完成后异步执行,不阻塞绘制。
    • useLayoutEffect:DOM 更新后、浏览器绘制前同步执行,可用于测量 DOM。

useRef

核心定义

  • useRef 是一个能在组件整个生命周期内 保持引用不变 的 Hook。
  • 它返回一个可变对象 { current: ... },这个对象在组件的重新渲染中不会被重置
const ref = useRef(initialValue)
console.log(ref.current) // ref.current 保存的数据在组件多次渲染之间是持久的

应用场景

获取DOM节点
  • 在React中使用useRef获取DOM比原生方式获取DOM更可靠
  • inputRef.current 会指向对应的 DOM 元素。
  • 通常用于:聚焦、滚动、测量宽高、绑定第三方库。
function App() {
  const inputRef = useRef(null)

  useEffect(() => {
    inputRef.current.focus()
  }, [])

  return <input ref={inputRef} />
}
保存 任意可变值(不触发重新渲染)

count.current 的值在组件重渲染时仍然保持;

修改 ref.current 不会引起重新渲染

所以它非常适合存储:

  • 前一次的值(用于比较)
  • 定时器 id
  • 防抖节流计数器
  • 某个状态的缓存值
function Timer() {
  const count = useRef(0)

  const handleClick = () => {
    count.current += 1
    console.log(count.current)
  }

  return <button onClick={handleClick}>Click</button>
}

useMemo

  • 开发中,我们只要修改了父组件的数据,所有的子组件都会重新渲染,这是十分消耗性能的
  • 如果我们希望子组件不要进行这种没有必要的重新渲染,我们可以将子组件继承PureComponent或者使用memo函数包裹
import React, { memo, useState, useEffect } from 'react'
const A = (props) => {
  console.log('A1')
  useEffect(() => {
    console.log('A2')
  })
  return <div>A</div>
}

const B = memo((props) => {
  console.log('B1')
  useEffect(() => {
    console.log('B2')
  })
  return <div>B</div>
})

const Home = (props) => {
  const [a, setA] = useState(0)
  useEffect(() => {
    console.log('start')
    setA(1)
  }, [])
  return <div><A n={a} /><B /></div>
}
  • 将子组件B使用memo包裹之后,Home组件中的状态a的变化就不会导致B组件的重新渲染
  • 但是在子组件B使用了父组件的某个引用类型的变量或者函数时,那么当父组件状态更新之后,这些变量和函数就会重新赋值,导致子组件B还是会重新渲染
  • 想解决这个问题,就需要使用useMemo和useCallback了

useCallback

  • 当函数组件重新渲染时,其中的函数也会被重复定义多次
  • 如果使用useCallBack对函数进行包裹,那么在依赖(第二个参数)不变的情况下,会返回同一个函数 这样子组件就不会因为函数的重新定义而导致重新渲染了
  • useMemo和useCallBack相似,缓存的是函数的返回值,一般用来优化变量,但是如果将useMemo的返回值定义为返回一个函数就可以实现useCallBack一样的功能
//用useMemo实现同useCallback一样的效果
   const increment = useCallback(fn,[])
   const increment2 = useMemo(()=>fn,[])
import React, { memo, useState, useEffect, useMemo } from 'react'
const Home = (props) => {
  const [a, setA] = useState(0)
  const [b, setB] = useState(0)
  useEffect(() => {
    setA(1)
  }, [])

  const add = useCallback(() => {
    console.log('b', b)
  }, [b])

  const name = useMemo(() => {
    return b + 'xuxi'
  }, [b])
  return <div><A n={a} /><B add={add} name={name} /></div>
}

useContext

useContext 是什么?

useContext 是 React 的一个 Hook,用于:

  • 函数组件中直接读取由上层组件 Context.Provider 提供的值。

简单理解:

  • 不用一层层 props 传递,也能让深层组件拿到共享数据。

语法

const value = useContext(MyContext)
  • MyContext 是通过 React.createContext() 创建的上下文对象。
  • useContext() 返回最近的 <MyContext.Provider> 提供的 value
  • 当 Provider 的 value 变化时,所有使用该 context 的组件都会重新渲染。

使用步骤

// context.js
import { createContext } from "react"
export const ThemeContext = createContext("light")

// App.jsx
import { ThemeContext } from "./context"
import Child from "./Child"

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Child />
    </ThemeContext.Provider>
  )
}

// Child.jsx
import { useContext } from "react"
import { ThemeContext } from "./context"

function Child() {
  const theme = useContext(ThemeContext)
  return <div>当前主题:{theme}</div>
}

特性

特性 说明
最近优先 如果组件外层有多个相同类型的 Provider,会取最近一层的 value
响应式更新 Provider 的 value 改变,会触发所有消费该 Context 的组件重新渲染
只能在函数组件或自定义 Hook 中使用 不能在类组件或普通函数中调用
不能脱离 Provider 使用 如果没有 Provider 包裹,会使用 createContext() 时设置的默认值

应用场景

场景 示例
✅ 主题切换 dark / light 模式
✅ 登录状态 用户信息、Token
✅ 多语言切换 中英文语言包
✅ 全局配置 比如分页大小、API地址等

常见问题

useContext 和 props 传递的区别?

对比项 props useContext
数据传递 一层层手动传递 任何层级都可直接拿到
灵活性 高(精确控制) 全局性(可能过度渲染)
适用场景 局部数据传递 全局共享状态

useContext 的缺点是什么?

  • Provider 的 value 改变时,所有消费它的组件都会重新渲染
  • 这可能导致性能问题(无论组件是否使用了 value 的具体字段);
  • 因此大型项目中往往结合 useReducerRedux / Zustand 等状态管理库 一起使用

forwardRef

在React开发中,有些时候我们需要获取DOM或者组件来进行某些操作

  • 如何使用ref来获取DOM

    • 使用createref创建ref对象,并且绑定到DOM元素上
  • forwardRef解决的问题是ref不会通props传递下去,因为ref和key一样被React做了特殊处理

import React, { PureComponent ,createRef} from 'react'
export class App extends PureComponent {
    //创建ref
    this.titleRef = createRef()
  }
  getNativeDOM(){
    console.log(this.titleRef.current)
  }
  render() {
    return (
      <div>
        <h2 ref={this.titleRef}>hello world</h2>
        <button onClick={e=>this.getNativeDOM()}>获取DOM</button>
      </div>
    )
  }
}
export default App

ref 的值根据节点的类型有所不同:

  1. ref属性作用于HTML属性时,接收底层DOM元素作为其current属性
  2. ref属性作用于class组件时,接收组件实例作为其current属性
  3. 不能在函数组件上使用ref属性,因为他们没有实例

想将ref挂载到函数组件内部的某个class组件或者HTML元素上时,我们需要使用React.forwardRef将函数组件包裹,从而将ref传递到组件内部

//获取函数组件的某个DOM
//使用forwardRef之后,可以传入两个参数,第二个为ref,我们可以实现ref转发
const Fun = forwardRef(function (props,ref) {
  return (
    <h1 ref={ref}>hello react</h1>
  )
})

使用ref作用于类组件,并调用类组件实例的方法

import React, { PureComponent, createRef, forwardRef } from 'react'

//类子组件
class HelloWorld extends PureComponent {
  test() {
    console.log("test---")
  }
  render() {
    return (
      <h1>hello world</h1>
    )
  }
}
export class App extends PureComponent {
  constructor() {
    super()
    this.state = {}
    this.hwRef = createRef()
  }
  getComponent() {
    //调用类组件实例的方法
    console.log(this.hwRef.current)
    this.hwRef.current.test()
  }
  render() {
    return (
      <div>
        <HelloWorld ref={this.hwRef} />
        <button onClick={e => this.getComponent()}>获取组件实例</button>
      </div>
    )
  }
}
export default App

useImperativeHandle

  • forwardRef使用带来的问题

    • 直接暴露给父组件,使得某些情况不可控
    • 父组件拿到子组件之后可以进行任意的操作
  • 通过useImperativeHandle可以暴露固定的操作

import React, {
  useRef,
  forwardRef, useImperativeHandle
} from 'react'

const HYInput = forwardRef((props,ref)=> {
  const inputRef = useRef()
  useImperativeHandle(ref,()=> {
    return {
      focus: () => {
        console.log(345);
        inputRef.current.focus()
      }
    } 
  })

  return <input ref={inputRef} type="text"/>
})
export default function UseImperativeHandleHookDemo() {
  const inputRef = useRef()
  return (
    <div>
      <HYInput ref={inputRef}/>
      <button onClick={e=>inputRef.current.focus()}>聚焦</button>
    </div>
  )
}

原理题

React Hooks 为什么不能放在条件判断里?

  • React Hooks 不能放在条件判断、循环或嵌套函数中,必须在组件的顶层调用。

    • 因为——React 是通过调用顺序来识别每一个 Hook 的。

每个组件渲染时,React 会维护一个“Hook 调用链表”或“数组”, 类似这样(简化理解):

// 第一次渲染
useState('A')   // Hook 1
useEffect(...)  // Hook 2
useState('B')   // Hook 3

React 会按顺序记下每一个 Hook 对应的状态(存在 Fiber 节点上)。 下一次渲染时,React 会再次按相同顺序调用 Hook 来匹配之前的状态。


如果放在条件语句中会发生什么?

if (flag) {
  useState(1)
}
useEffect(() => {})
  • 第一次渲染:

    • flag = true → 执行 useState(Hook 1)
    • 执行 useEffect(Hook 2)
  • 第二次渲染:

    • flag = false → 跳过 useState
    • useEffect 变成了 Hook 1!

🚨 React 内部匹配错位! 本该给 useEffect 的 Hook 状态,被错误地分配成了之前的 useState 状态。

结果可能报错:

Rendered fewer hooks than expected
Invalid hook call

Hooks的执行顺序是如何保证的?

  • React 通过在每个 Fiber 节点上维护一个 Hook 链表
  • 并在每次渲染时按顺序遍历执行
  • 从而确保每个 Hook 的状态和顺序一致。

自定义 Hook 怎么避免闭包陷阱?

  1. 使用函数式更新(最常用)
const increment = () => setCount(prev => prev + 1)
  • prev 永远是最新 state
  • 无需依赖闭包捕获的旧值
  • 适用于事件回调、定时器、异步请求等
  1. useRef 保存最新值

如果你需要在闭包里访问最新状态而不触发重新渲染: const countRef = useRef(count) useEffect(() => { countRef.current = count }, [count])

const logCount = () => { console.log(countRef.current) // 永远是最新值 }

  • 异步函数或事件可以使用 countRef.current
  • 不影响 React 渲染流程

组件渲染与性能优化

React 组件何时重新渲染?

  1. state发生变化:只要你调用 setState 产生了新的值(引用变化),组件就会重新渲染。

  2. props变化:只要父组件重新渲染,子组件也会跟着渲染(除非使用 React.memo)。

    即使 props 内容没变 —— 只要父组件 render,子组件也会 render。

  3. context变化:当某个 Context Provider 的 value 改变,所有消费该 context 的子组件都会重渲染。

  4. 父组件重新渲染导致子组件渲染(哪怕 props 不变)

浅比较会对比:

  • 基本类型值(number / string / boolean / null / undefined) → 直接比较值是否相等。
  • 引用类型(object / array / function)只比较引用地址是否相同,不会比较内部的内容。

React 在性能优化时会用浅比较,比如:

  • React.memo
  • PureComponent
  • shouldComponentUpdate
  • useMemo / useCallback 的依赖项比较
  • useEffect / useCallback 的依赖数组

因为浅比较非常快,不需要深度遍历对象。

  • 浅比较带来的典型问题

    • 使用 inline function 导致子组件重新渲染
<Child onClick={() => setCount(count + 1)} />

每次父组件 render 时都会创建新的函数引用 → 浅比较结果:不同 → 子组件重新渲染

  • 解决方法

    • useCallback 固定函数引用
    • useMemo 固定对象 / 数组引用
    • 子组件用 React.memo

如何优化一个大表格或长列表的渲染性能?(虚拟列表)

  • 为什么需要虚拟列表?

    • 当列表有 成百上千甚至上万条数据时:

      • 浏览器会创建大量 DOM(慢)
      • 布局、重排、重绘消耗巨大(卡)
      • 滚动时频繁触发渲染(卡顿)

👉 核心思路: 只渲染可视区域内的那几十个节点,其余内容用占位高度撑开。

  • 什么时候用虚拟列表

    • 满足任一即可使用虚拟列表:

      • 单页表格数据量 > 200 行
      • 单页列表 > 300 行
      • 存在大量复杂 DOM(图片、按钮、操作列)
      • 有频繁更新、滚动操作
  • 核心原理(一句话版本)

    • 只有可视区域 + 缓冲区的元素真实渲染
    • 其他区域只用一个大容器撑开高度
    • 视觉上像完整列表,但实际 DOM 数量永远保持几十个
  • 虚拟列表关键技术

    • 容器高度:列表容器要固定高度或可计算高度,否则无法计算可视范围。

    • 每行高度:

      • 固定高度:最好实现,可用rowheight直接算
      • 不定高度:需要实时记录高度(难度更高)
    • 计算可视区域的起止 index

      startIndex = Math.floor(scrollTop / rowHeight)
      endIndex = startIndex + 可视区域行数 + buffer
      
    • 渲染可视区域数据

      • 只渲染这一小段数据即可。
    • 使用 translateY 把渲染的内容“挪”到正确位置

      style="transform: translateY(startIndex * rowHeight px)"
      
  • 前端常用虚拟列表方案

    • React:react-window

      • 轻量、简单、性能极佳。
      • <FixedSizeList> 固定行高列表
      • <VariableSizeList> 不定高度列表
      • <FixedSizeGrid> 表格(大表格强烈推荐)
import { FixedSizeList as List } from "react-window";

<List
  height={600}
  width={800}
  itemSize={40}
  itemCount={list.length}
>
  {({ index, style }) => (
    <div style={style}>{list[index].name}</div>
  )}
</List>
  • Ant Design v5
<Table
  scroll={{ y: 600 }}
  virtual
  columns={columns}
  dataSource={data}
/>
  • 自己实现
import React, { useRef, useState, useEffect } from "react";

export default function VirtualList({ itemHeight, height, data, renderItem }) {
  const containerRef = useRef(null);
  const [startIndex, setStartIndex] = useState(0);

  const visibleCount = Math.ceil(height / itemHeight); // 可视区域展示多少条

  // 滚动事件
  const onScroll = () => {
    const scrollTop = containerRef.current.scrollTop;
    const newStartIndex = Math.floor(scrollTop / itemHeight);
    setStartIndex(newStartIndex);
  };

  // 当前需要渲染的数据
  const endIndex = startIndex + visibleCount;
  const visibleData = data.slice(startIndex, endIndex);

  // 用两个 padding 占位本来应该存在的高度
  const paddingTop = startIndex * itemHeight;
  const paddingBottom = (data.length - endIndex) * itemHeight;

  return (
    <div
      ref={containerRef}
      style={{
        height,
        overflowY: "auto",
        border: "1px solid #ccc",
      }}
      onScroll={onScroll}
    >
      <div style={{ paddingTop, paddingBottom }}>
        {visibleData.map((item, i) =>
          renderItem(item, startIndex + i)
        )}
      </div>
    </div>
  );
}

React 中没有 v-model,如何优雅地处理表单输入

作者 凯心
2025年11月30日 14:11

React 中没有 v-model,如何优雅地处理表单输入

在 Vue 中,我们可以很方便地使用 v-model 实现数据的双向绑定。但在 React 的世界里,并没有这样的语法糖,我们需要通过不同的方式来处理表单数据。

Vue 的简洁写法

<template>
  <input v-model="value" />
</template>

React 的几种实现方案

方案一:基础受控组件

function App() {
  const [value, setValue] = useState("");
  
  return (
    <input 
      value={value} 
      onChange={e => setValue(e.target.value)} 
    />
  );
}

这是 React 初学者最常用的写法。在简单场景下表现良好,但在复杂表单或大型应用中,每次输入都会触发组件重新渲染,可能导致性能问题。

方案二:非受控组件 + useRef

function App() {
  const inputRef = useRef("");
  
  return (
    <input 
      onChange={e => (inputRef.current = e.target.value)} 
    />
  );
}

这种方案避免了频繁的重新渲染,适合性能敏感的场景。

方案三:防抖优化

function App() {
  const [value, setValue] = useState("");
  
  const handleChange = useCallback(
    debounce((newValue) => {
      setValue(newValue);
    }, 300),
    []
  );

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

通过防抖函数减少状态更新的频率,在需要实时搜索等场景下特别有用。


深入理解:受控组件 vs 非受控组件

概念解析

受控组件和非受控组件是数据驱动框架中的重要概念:

  • 表面区别:值是否只能由用户输入改变,还是也可以由程序逻辑直接改变
  • 本质区别:数据是由 React 状态托管,还是由 DOM 自身管理

受控组件(Controlled Components)

表单元素的值完全由 React 状态控制,通过 onChange 事件同步更新。

优点:

  • ✅ 符合 React 单向数据流理念,状态完全可控
  • ✅ 便于实现实时验证和输入格式化
  • ✅ 可动态控制表单提交状态
  • ✅ 支持多组件间的数据同步

缺点:

  • ❌ 需要为每个字段编写事件处理逻辑
  • ❌ 表单复杂时可能引发性能问题

适用场景:

  • 需要实时验证用户输入
  • 需要根据输入动态更新UI
  • 需要强制特定的输入格式
  • 表单数据被多个组件共享
function LoginForm() {
  const [formData, setFormData] = useState({
    username: "",
    password: ""
  });

  const handleChange = (field) => (e) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }));
  };

  return (
    <form>
      <input 
        value={formData.username} 
        onChange={handleChange("username")} 
      />
      <input 
        type="password"
        value={formData.password} 
        onChange={handleChange("password")} 
      />
    </form>
  );
}

非受控组件(Uncontrolled Components)

表单数据由 DOM 自身管理,通过 ref 在需要时获取值。

优点:

  • ✅ 代码简洁,减少事件处理逻辑
  • ✅ 性能更优,避免频繁重新渲染
  • ✅ 更接近原生 DOM 操作

缺点:

  • ❌ 不符合 React 数据流最佳实践
  • ❌ 无法实现实时验证和UI反馈
  • ❌ 状态管理不够直观

适用场景:

  • 简单表单,无需实时验证
  • 只在提交时需要获取数据
  • 性能敏感的大型表单
  • 集成第三方表单库
function UncontrolledForm() {
  const usernameRef = useRef();
  const passwordRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();
    const data = {
      username: usernameRef.current.value,
      password: passwordRef.current.value
    };
    console.log("表单数据:", data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={usernameRef} />
      <input type="password" ref={passwordRef} />
      <button type="submit">提交</button>
    </form>
  );
}

实践建议

  1. 当需要做性能优化时,可以考虑使用非受控组件
  2. 非受控组件和受控组件可以混用

element-plus源码解读2——vue3组件的ref访问与defineExpose暴露机制

作者 Joie
2025年11月30日 13:54

vue3组件的ref访问与defineExpose暴露机制

vue官方文档:

refcn.vuejs.org/api/reactiv…

defineExposecn.vuejs.org/api/sfc-scr…

以el-button举例:

1. 正确的访问方式

看 Button 组件暴露的内容:

defineExpose({
  /** @description button html element */
  ref: _ref,
  /** @description button size */
  size: _size,
  /** @description button type */
  type: _type,
  /** @description button disabled */
  disabled: _disabled,
  /** @description whether adding space */
  shouldAddSpace,
})

2. 实际使用示例

<template>
  <el-button ref="buttonRef" type="primary" size="large">
    按钮
  </el-button>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

onMounted(() => {
  // ✅ 正确:访问所有暴露的属性
  console.log('DOM 元素:', buttonRef.value?.ref)        // HTMLButtonElement
  console.log('按钮尺寸:', buttonRef.value?.size)       // ComputedRef<'large'>
  console.log('按钮类型:', buttonRef.value?.type)      // ComputedRef<'primary'>
  console.log('是否禁用:', buttonRef.value?.disabled)   // ComputedRef<boolean>
  console.log('是否加空格:', buttonRef.value?.shouldAddSpace) // ComputedRef<boolean>
  
  // ✅ 打印整个组件实例,可以看到所有暴露的属性
  console.log('组件实例:', buttonRef.value)
})
</script>

3. 打印结果示例

当你 console.log(buttonRef.value) 时,会看到类似:

{
  ref: HTMLButtonElement,           // DOM 元素
  size: ComputedRef<'large'>,        // 尺寸(注意是 ComputedRef)
  type: ComputedRef<'primary'>,      // 类型(注意是 ComputedRef)
  disabled: ComputedRef<false>,      // 禁用状态(注意是 ComputedRef)
  shouldAddSpace: ComputedRef<false> // 是否加空格(注意是 ComputedRef)
}

4. 重要提示:ComputedRef 的访问

注意 sizetypedisabled 等是 ComputedRef,访问值需要用 .value

// ❌ 错误:这样得到的是 ComputedRef 对象
console.log(buttonRef.value?.size)  // ComputedRef { ... }

// ✅ 正确:需要 .value 才能拿到实际值
console.log(buttonRef.value?.size.value)  // 'large'
console.log(buttonRef.value?.type.value)  // 'primary'
console.log(buttonRef.value?.disabled.value)  // false

说明 Vue 3 的生命周期和 ref 访问时机:

1. Vue 3 没有 onCreated 钩子

在 Vue 3 的 Composition API 中:

  • 没有 onCreated() 钩子
  • setup() 函数本身就相当于 Vue 2 的 created + beforeCreate
  • 如果需要访问 DOM 或组件实例,应该用 onMounted()

2. 为什么必须在 onMounted() 中?

setup() 顶层(组件未挂载)
<script setup>
import { ref } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// ❌ 错误:此时 buttonRef.value 是 undefined
// 因为组件还没有挂载,ref 还没有被赋值
console.log(buttonRef.value)  // undefined
</script>
onMounted() 中(组件已挂载)
<script setup>
import { ref, onMounted } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

onMounted(() => {
  // ✅ 正确:此时组件已经挂载,ref 已经被赋值
  console.log(buttonRef.value)  // ButtonInstance 对象
  console.log(buttonRef.value?.ref)  // HTMLButtonElement
})
</script>

3. Vue 3 生命周期对比

Vue 2 Options API Vue 3 Composition API 说明
beforeCreate setup() 开始执行 组件创建前
created setup() 执行中 组件创建后(但未挂载)
beforeMount onBeforeMount() 挂载前
mounted onMounted() 挂载后(DOM 已存在)
beforeUpdate onBeforeUpdate() 更新前
updated onUpdated() 更新后
beforeUnmount onBeforeUnmount() 卸载前
unmounted onUnmounted() 卸载后

4. 完整示例对比

错误示例(在 setup 顶层)
<template>
  <el-button ref="buttonRef">按钮</el-button>
</template>

<script setup>
import { ref } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// ❌ 错误:此时 buttonRef.value 是 undefined
console.log('setup 顶层:', buttonRef.value)  // undefined
</script>
正确示例(在 onMounted 中)
<template>
  <el-button ref="buttonRef">按钮</el-button>
</template>

<script setup>
import { ref, onMounted, onBeforeMount } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// 在 setup 顶层
console.log('setup 顶层:', buttonRef.value)  // undefined

// 在 beforeMount 中
onBeforeMount(() => {
  console.log('beforeMount:', buttonRef.value)  // 可能还是 undefined
})

// 在 mounted 中
onMounted(() => {
  // ✅ 正确:此时组件已挂载,ref 已赋值
  console.log('mounted:', buttonRef.value)  // ButtonInstance 对象
  console.log('DOM 元素:', buttonRef.value?.ref)  // HTMLButtonElement
})
</script>

5. 为什么 ref 在 onMounted 中才有值?

Vue 的 ref 赋值时机:

  1. 模板编译阶段:Vue 识别 ref="buttonRef"
  2. 组件挂载阶段:创建组件实例,将实例赋值给 buttonRef.value
  3. DOM 渲染完成:onMounted() 执行时,ref 已经有值

6. 如果需要在 setup 中访问怎么办?

可以使用 watchEffectwatch

<script setup>
import { ref, watchEffect } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// 使用 watchEffect,会在 ref 有值后自动执行
watchEffect(() => {
  if (buttonRef.value) {
    console.log('ref 有值了:', buttonRef.value)
  }
})
</script>

7. 总结

  • Vue 3 没有 onCreated()setup() 本身就相当于 created
  • 访问 ref.value 必须在 onMounted() 中,因为此时组件已挂载
  • setup() 顶层访问 ref.value 会是 undefined
  • 如果需要响应式监听 ref 的变化,可以用 watchEffectwatch

性能优化:从“用户想走”到“愿意留下”的1.8秒

作者 小时前端
2025年11月30日 13:37

前言

"今天,我们来聊聊性能优化。在我们团队看来,性能优化不是简单的'减少加载时间'的技术活,而是数字世界的'流体力学'工程。我们试图在有限的带宽的约束下,让用户体验如流水般顺滑自然。"

"因此,当我考察一个候选人对性能优化的理解时,我核心关注的不是他知道多少种优化技巧,而是他是否具备一种 '性能优化' 的视角。他是否明白,优化某个指标,不仅是应用某个最佳实践,更是要理解整个渲染流水线、网络协议栈和运行时环境。"

"这意味着,你需要去理解每个性能指标背后的运行机制——浏览器是如何构建渲染树的?JavaScript引擎是如何执行代码的?网络请求是如何被调度和处理的?如果对细节不太了解,可以阅读这篇文章前端面试经典题:从URL到页面展示,这一次让你彻底搞懂"

"所以,今天的问题不仅仅是关于'如何减少LCP'这些具体技巧,我更想听到的是,你如何系统性地思考、诊断和解决性能问题。让我们就从这里开始聊起吧。"

当我问起这个问题时,我不仅仅是想听几个优化技巧。我想考察的是:

  1. 深度:你对性能优化的理解是否停留在"用个懒加载"的表面?
  2. 广度:你是否了解从网络到渲染的全链路性能影响因素?
  3. 实践:你是否有过真实的性能优化经验,或者至少深入分析过性能瓶颈?
  4. 数据驱动:你是那个凭感觉优化的人,还是能基于数据做出精准决策的人?

一、核心理念:性能 ≠ 快

首先要明确,性能优化的核心是在技术理想用户感知之间寻找最佳平衡点。

  • 技术指标站在天平的最右端:精确的毫秒数,但可能偏离用户感受
  • 用户感知站在最左端:主观的"快慢",但难以量化衡量
  • 现代性能优化分布在中间的广阔光谱上,每个点代表不同的权衡

所以,性能优化的本质是技术指标与用户体验的匹配游戏

二、性能优化的演进:从"减少字节"到"提升感知"

性能优化的三次进化

  • 第一代:资源优化时代

    • 核心思路:"让文件更小,让请求更少"
    • 关键技术:Gzip压缩、图片优化、CSS Sprites、减少HTTP请求
    • 突破性:首次系统性地从资源层面解决性能问题
    • 局限性:过度聚焦于技术指标,忽略用户感知
  • 第二代:渲染优化时代

    • 核心思路:"让关键内容先出来"
    • 关键技术:关键CSS内联、异步加载、懒加载、服务端渲染
    • 突破性:开始关注用户看到内容的时机,而非单纯的技术指标
    • 局限性:缺乏统一的衡量标准,优化方向分散
  • 第三代:用户体验量化时代

    • 核心思路:"用科学指标衡量用户体验"
    • 关键技术:Core Web Vitals、Performance API、真实用户监控
    • 突破性:建立了标准化的用户体验衡量体系
    • 局限性:指标之间存在权衡,需要业务层面的决策

如何回答(展现你的历史观) "性能优化的演进本质上是不断重新定义'性能'含义的过程。从早期的关注服务器响应时间,到中期的关注首屏渲染,再到现在的关注用户核心任务完成度,每一次演进都在解决前一代的核心局限。"

三、现代性能优化深度对比:不只是技术技巧

核心维度对比

  • 加载性能:用户的第一印象

    • LCP:衡量主要内容加载,直接影响用户对速度的感知
    • 优化策略:图片优化、字体优化、服务端渲染、资源预加载
    • 权衡考量:预加载可能浪费带宽,需要基于用户行为数据决策
  • 交互响应:使用的流畅度

    • FID/INP:衡量输入响应,决定用户操作的顺滑程度
    • 优化策略:代码分割、长任务拆分、Web Worker、优化JavaScript执行
    • 权衡考量:过度拆分可能增加复杂度,需要平衡可维护性
  • 视觉稳定性:体验的可靠性

    • CLS:衡量布局偏移,影响用户的阅读和操作精度
    • 优化策略:设置尺寸属性、预留空间、避免动态插入内容
    • 权衡考量:预留空间可能造成空白,需要设计系统配合

如何回答(展现你的技术判断力) "当我们对比现代性能优化指标时,实际上是在对比不同的用户体验维度。LCP关注的是'什么时候能用',FID关注的是'用起来卡不卡',CLS关注的是'用起来准不准'。好的性能优化不是单独优化某个指标,而是找到这些指标在具体业务场景下的最佳平衡点。"

四、实战:一个LCP从4.2s到1.8s的优化案例

"理论总是灰色的,而性能优化的实践之树常青。下面我想分享一个真实的产品详情页优化案例,看看我们如何将一个 4.2秒的LCP 优化到 1.8秒。"

问题诊断:拨开迷雾,定位七重瓶颈

我们发现了完整的"性能问题瀑布链":

  1. LCP元素:首屏的主商品图
  2. 资源加载:一张450KB的JPEG图片
  3. 渲染阻塞:800KB的Web字体文件
  4. JavaScript竞争:分析脚本抢占带宽
  5. 服务端延迟:TTFB达到600ms
  6. 缓存失效:CDN配置不当
  7. 布局偏移:CLS高达0.25

"诊断性能问题就像破案,你不能只看到表面的'凶器'(那张大图),而是要还原整个'犯罪现场'(从用户点击到屏幕渲染的完整链条)。"

优化措施:一套组合拳,拳拳到肉

第一阶段:资源层面的"瘦身"与"调度"

技术说明:这个阶段的核心目标是让关键资源变得更小,并让浏览器优先处理它们

<!-- 图片格式优化:为不同浏览器提供最合适的图片格式 -->
<picture>
  <!-- 现代浏览器优先使用更小的WebP格式 -->
  <source srcset="hero-image.webp" type="image/webp">
  <!-- 老版本浏览器使用优化后的JPEG作为备选 -->
  <source srcset="hero-image.jpg" type="image/jpeg">
  <!-- 最终回退方案 -->
  <img src="hero-image.jpg" alt="产品主图" width="800" height="600">
</picture>

<!-- 关键图片预加载:告诉浏览器这个图片最重要,请立即下载 -->
<link rel="preload" as="image" href="hero-image.webp" imagesrcset="hero-image.webp 800w, hero-image-mobile.webp 400w">

"这里有个关键洞察:我们发现主图虽然通过<picture>元素做了格式优化,但浏览器仍然需要经过图片发现、请求队列、DNS查找、TCP连接等一系列步骤才能开始下载。通过preload,我们告诉浏览器:'这个资源极其重要,请跳过常规队列,立即开始下载'。"

Preload的实战细节:

  • 时机把握:将preload链接放在HTML的<head>中,确保浏览器在解析完基本结构后立即处理
  • 格式适配:预加载WebP格式,因为它是我们为现代浏览器准备的最优解
  • 响应式考虑:通过imagesrcset告知浏览器不同视口宽度下应该加载的图片版本

第二阶段:渲染层面的"清障"与"加速"

技术说明:这个阶段的目标是消除阻止页面渲染的障碍,让内容尽快显示

/* 字体加载优化:避免文字显示空白期 */
@font-face {
  font-family: 'ProductSans';
  src: url('product-sans-subset.woff2') format('woff2');
  font-display: swap; /* 关键:先显示系统字体,Web字体加载后再替换 */
}
<!-- 非关键脚本延迟加载:让分析工具等不阻塞页面渲染 -->
<script async src="analytics.js"></script>
<script defer src="non-critical.js"></script>

"字体优化是另一个战场。原本800KB的字体文件不仅下载慢,还会导致文本内容在字体加载完成前完全不可见(FOIT问题)。通过font-display: swap,我们让浏览器先用系统字体显示文字,等Web字体下载完成后再静默替换——用户能立即看到内容,而不是面对一片空白。"

第三阶段:服务端与基础设施的"深水区"优化

技术说明:这个阶段处理网络层面和服务端响应速度的问题

<!-- 提前建立CDN连接:减少DNS查询和TCP握手时间 -->
<link rel="preconnect" href="https://cdn.our-platform.com">

"你可能想不到,浏览器在真正开始下载图片前,需要先完成'自我介绍'——DNS查询找到CDN服务器的IP地址,然后TCP三次握手建立连接。通过preconnect,我们在浏览器遇到实际图片URL前就提前完成这些步骤,为后续请求节省了宝贵的几百毫秒。"

效果评估:数据是最好的证明

经过上述优化,我们在下一个发布周期后观察到了显著变化:

指标 优化前 优化后 提升幅度 用户感知
LCP 4.2s 1.8s 57% 从"等待"到"顺畅"
TTFB 600ms 80ms 87% 服务器响应更快
首屏图片体积 450KB 120KB 73% 流量节省,加载更快
跳出率 45% 32% 13个百分点 更多用户留下

"最让我们兴奋的不是漂亮的性能图表,而是业务数据的正面反馈:详情页到购物车的转化率提升了 7%。这实实在在地证明了,性能优化不是技术团队的自我感动,而是真金白银的商业回报。"

经验总结:从一次优化到一种能力

这个案例给我们的启示远不止于技术点:

  1. 性能优化是系统工程:它涉及前端、后端、运维多个环节,需要打破团队壁垒协同作战。
  2. 度量是优化的起点和终点:没有精确的测量,优化就是盲人摸象。我们建立了持续的性能监控仪表盘。
  3. 优化需要勇气做减法:敢于对"历来如此"的设计(如全尺寸大图)和"别人都这么用"的技术(如完整Web字体)提出挑战。
  4. 用户体验是最终裁判:优化的目标不是跑分,而是让用户觉得"快"。即使LCP还有提升空间,但1.8秒的加载速度已经让用户感知从"等待"变成了"顺畅"。

"这个案例之后,我们形成了一种肌肉记忆:每当启动一个新项目,LCP 会作为一项核心验收指标,与功能需求并列进入产品清单。这或许是一次优化带来的最大价值——将性能意识植入了团队的基因。"

五、超越技术优化:构建性能工程体系

一个优秀的候选人,还能聊到性能优化的工程化挑战:

  1. 度量和监控体系

    • 真实用户监控:收集真实场景下的性能数据
    • 关键业务路径追踪:从用户进入到最后转化的全链路分析
  2. 性能文化建设

    • 性能预算:为每个关键指标设置明确的数值目标
    • 代码审核集成:在代码合并前检查性能影响
  3. 渐进式优化策略

    • 基准建立:首先建立当前的性能基线
    • 快速胜利:优先实施高影响低成本的优化
    • 长期投资:规划需要架构层面改变的深度优化

面试官总结:我心目中的理想回答

一个让我眼前一亮的回答,应该是这样的:

"说实话,性能优化这事儿我踩过不少坑。比如上次我们有个页面LCP一直上不去,我开始也以为是图片太大的问题,后来用Performance面板一分析,发现是字体文件阻塞了渲染。"

"我的思路其实挺简单的——先测量,再动手。我不会一上来就说要用什么preload或者WebP,而是先搞清楚瓶颈到底在哪。是网络慢?还是渲染被阻塞?或者是JS执行太耗时?"

"具体到LCP优化,我的经验是分三步走:

  1. 找到元凶——用Lighthouse和DevTools确定到底是哪个元素拖慢了LCP
  2. 资源优先级——如果是图片,就用preload提前加载;如果是字体,就用font-display:swap避免阻塞
  3. 持续监控——优化完了不是结束,要盯着真实用户的数据看效果"

"而且我现在会特别关注业务价值。比如上次我们把LCP从4秒优化到1.8秒后,特意去看了转化率数据,发现确实提升了7%。"

"最重要的是,我觉得性能优化不是一次性的活,而是个持续过程。我们现在会把Core Web Vitals写进需求文档里,就像写功能需求一样自然。"

这样的回答为什么让我印象深刻?因为:

  • 真实经历,不是空谈理论
  • 具体方法,不是泛泛而谈
  • 业务思维,不只关注技术指标
  • 落地经验,知道怎么在团队里推动这些事情

说白了,我想找的不是一个只会背面试题的人,而是一个真正解决过问题的工程师。

思考题: 当Core Web Vitals要求LCP在2.5秒内时,你是否思考过这在不同网络环境、不同设备配置下的实际意义?当你通过代码分割减少初始包体积时,是否评估过这对后续页面加载的潜在影响?这些,才是性能优化思考的真正深度所在。

类属性公共还是私有

作者 Robet
2025年11月30日 13:37

决定一个类的属性(成员变量)是公共(public)还是私有(private) ,是面向对象设计中的核心问题之一。这不仅关乎代码封装性,还直接影响系统的可维护性、可扩展性和健壮性

以下是系统化的思考框架和实用原则,帮助你做出合理决策:


🔑 核心原则:最小暴露原则(Principle of Least Exposure)

“只暴露必须暴露的内容,其余一律隐藏。”

换句话说:
默认私有,只有当确实需要外部访问时,才设为公共。


一、判断标准:问自己这几个问题

1. 外部是否需要直接读取这个值?

  • ✅ 是 → 考虑 public 或提供 getter
  • ❌ 否 → private

📌 示例:

class BankAccount {
  private balance: number; // 外部不应直接读余额(需鉴权/日志)
  getBalance(): number { /* ... */ } // 通过方法控制访问
}

2. 外部是否需要直接修改这个值?

  • ✅ 是 → 考虑 public 或提供 setter(但要谨慎!)
  • ❌ 否 → private

⚠️ 直接暴露可变状态容易导致 bug:

// 危险!
user.profile.settings.darkMode = true; // 绕过校验/事件通知

更好的方式:

user.setTheme('dark'); // 内部可触发 re-render / save / log

3. 这个属性是否属于“内部实现细节”?

  • 如果未来可能重构、重命名或删除它 → 必须私有
  • 如果它是稳定契约的一部分(如 API 返回结构)→ 可 public

💡 例子:

  • 缓存字段(private cache: Map<...>)→ 私有
  • 用户 ID(public id: string)→ 公共(业务标识)

4. 是否需要保持对象的“不变性”(Invariants)?

如果属性参与维持对象的内部一致性,则必须私有,并通过方法控制变更。

📌 示例:矩形的宽高不能为负数

class Rectangle {
  private _width: number;
  private _height: number;

  setWidth(w: number) {
    if (w < 0) throw new Error('Width must be positive');
    this._width = w;
  }
}

二、优先使用 方法(Method)而非公共属性

即使需要“读取”或“设置”,也优先提供方法而非直接暴露属性:

场景 推荐做法
读取计算值 getFullName() 而非 fullName(除非是简单数据)
设置需校验 setEmail(email) 而非 email = ...
触发副作用 activate() 而非 isActive = true

✅ 好处:

  • 未来可加日志、权限、缓存、事件通知等逻辑
  • 避免“属性被意外覆盖”导致状态不一致

三、特殊情况处理

✅ 可以公开的属性类型

类型 说明 示例
不可变数据 初始化后永不改变 public readonly id: string
纯数据载体(DTO/POJO) 仅用于传输,无行为 interface UserDTO { name: string; email: string }
配置对象 明确设计为可读写的配置 public config: RenderConfig(但建议用 getter/setter 封装)

❌ 应避免公开的属性

  • 内部状态(如 isLoading, retryCount
  • 依赖其他属性的派生值(如 fullName = firstName + lastName → 应用 getter)
  • 敏感数据(密码、token、余额)
  • 复杂对象引用(如 private domElement: HTMLElement

四、TypeScript / JavaScript 中的具体实践

方案 1:使用 # 私有字段(推荐,ES2022+)

class Timer {
  #startTime: number;
  #isRunning = false;

  start() {
    this.#startTime = Date.now();
    this.#isRunning = true;
  }

  get elapsed() {
    return this.#isRunning ? Date.now() - this.#startTime : 0;
  }
}

✅ 真正私有,运行时安全

方案 2:TypeScript private(仅开发时保护)

class Logger {
  private logs: string[] = [];
  log(msg: string) { this.logs.push(msg); }
}

⚠️ 注意:编译后仍可被外部访问,仅防“手误”

方案 3:readonly + 公共(用于不可变数据)

class Point {
  constructor(
    public readonly x: number,
    public readonly y: number
  ) {}
}

✅ 安全暴露,且不可修改


五、团队协作建议

  1. 约定优于配置:团队统一规则,如“所有状态属性默认私有”
  2. 代码审查重点:检查是否有不必要的 public 属性
  3. 文档说明:对 public 属性明确其用途和约束

✅ 快速决策流程图

这个属性需要被外部访问吗?
│
├─ 否 → private / #
│
└─ 是 → 
     ├─ 是否需要修改? 
     │   ├─ 是 → 提供 setter 方法(而非直接 public)
     │   └─ 否 → 
     │        ├─ 是否不可变? → public readonly
     │        └─ 是否计算值? → 提供 getter 方法
     │
     └─ 是否属于稳定数据契约? → 可 public(如 DTO)

🎯 总结:黄金法则

“属性代表状态,状态应受控。
暴露行为(方法),而非状态(属性)。”

  • 默认 私有private#
  • 仅在必要且安全时暴露为公共
  • 优先通过 方法 控制访问,而非直接暴露字段
  • 对于纯数据对象(如 API 响应),可适当放宽

这样做,你的类将更健壮、更易测试、更易演进。

关于微前端框架wujie的一次企业级应用实践demo?

作者 寻找光_sxy
2025年11月30日 13:33

前言

本文将介绍我一种wujie的一次具体的应用,包括使用的场景、方式等等,完成一个具体的demo;

为什么要用微前端

事情是这样的,我们之前的业务有一个vue3+ts+vite的后台项目,后来公司决定新开发一个新的业务线,但是由于人力有限,如果重新搭建一个新的后台时间和人力成本较大都,尤其是其中的权限登录功能的设计都比较复杂,所以我们综合考虑,有没有一种可以直接用旧后台的权限和登录功能,然后其它功能完全隔离的,且旧后台和新后台可用两个部门的人来开发,可以独立开发、测试、部署,甚至技术栈也可以不受影响呢?这里我们想到了微前端方案;

微前端方案选择

我们经过调研,目光逐步瞄向了两种微前端的方案:无界乾坤

对比我们的业务,经过调研发现无界相比于乾坤更有优势:

  • 1、对旧后台项目影响较小,侵入程度低:只需要在旧有后台的项目上新起page页,以及新增一个路由即可;
  • 2、可单独开发、部署:子应用可以单独开发、部署,也可以使用一个全新的技术栈,即使生产环境无界挂了,出现问题了,也可以直接访问子应用;

综上两种原因,我们决定使用无界的方案;

怎么用无界(demo演示)

我们的主应用是vue3,这里将子应用通过菜单栏的形式嵌入到父应用中间,点击菜单即可进入到子应用

登录场景,在子应用请求时,若发现登录失效,通过子组件通信window.$wujie.bus.$emit('notLogin')向父应用传递未登录消息,父应用执行后续逻辑

权限逻辑,天然就互通,当子应用的菜单权限在某些角色下不可见时,在父应用下直接隐藏掉菜单就行;如果是子应用下按钮权限等功能权限时,可在子应用单独再次调用权限接口,或通过父子应用通信方式获取权限信息 image.png

具体步骤

父应用改造

  • 下载新依赖
  • wujie相关文件
  • 路由 image.png

下载相关依赖

pnpm install wujie-vue3

创建wujie文件

用于补充wujie的相关逻辑:

  • wujietemplate相关属性
    • name: 子应用唯一标识
    • url: 子应用运行地址
    • props:向子应用传递的参数
  • 父子应用通信
    • 通知子应用路由发生改变
    • 通知子应用其他数据
    • 子应用告知父应用未登录
    • 子应用告知父应用其他信息 image.png
<template>
  <div class="main-app">
    <h1>Vue3 主应用</h1>
    <!-- 嵌入 React 子应用 -->
    <WujieVue width="100%" height="600px" :url="subAppUrl" :name="subAppName" :props="subAppProps" />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { watch } from "vue";
import WujieVue from "wujie-vue3";

const { bus } = WujieVue;

// 子应用配置(React 子应用的运行地址,后续启动子应用后会用到)
const subAppName = ref("react-sub-app"); // 子应用唯一标识(必须唯一)
const subAppUrl = ref("http://localhost:1074/#/wujieDemo1"); // 子应用端口(后续配置 React 子应用为 3001)

// 主应用向子应用传递的 props(可选)
const subAppProps = ref({
  mainAppName: "Vue3 主应用",
  token: "main-app-token-123",
});

const router = useRouter();
/** 监听子应用的数据 */
bus.$on("subAppData", (data: { type: string, payload?: any }) => {
  const { type } = data;
  if (type == "noLogin") {
    alert("未登录")
  }
});

/** 监听子应用的数据 */


watch(
  () => router.currentRoute.value.meta.subAppPath,
  (newVal) => {
    if (newVal === undefined) return;
    bus.$emit("routeChange", newVal);
  },
  {
    immediate: true,
  }
);
</script>

创建wujie路由

这里新建了一个路由的文件wujieRouter.ts

通过监听subAppPath去判断跳转到子应用对应路由,且这里的subAppPath其实对应的是子应用的路由path

const routerName = "wujiePage";

const wujieRouters = [
  {
    path: `/${routerName}`,
    name: `${routerName}`,
    component: () => import("@/pages/wujie/index.vue"),
    meta: {
      title: '新项目-react', // 菜单显示文本
      icon: 'CreditCard', // 菜单图标
      hidden: false,
      level: 0,
    },
    children: [
      {
        path: "wujieDemo1", // 子路由直接使用相对路径,不要包含父路由名称
        name: `${routerName}wujieDemo1`, // 名称保持唯一,不要使用斜杠
        component: () => import("@/pages/wujie/wujie.vue"),
        meta: {
          title: 'wujieDemo1', // 菜单显示文本
          icon: 'Present', // 子菜单图标
          hidden: false,
          level: 1,
          subAppPath: "/wujieDemo1",
        },
      },
      {
        path: "wujieDemo2", // 子路由直接使用相对路径,不要包含父路由名称
        name: `${routerName}wujieDemo2`, // 名称保持唯一,不要使用斜杠
        component: () => import("@/pages/wujie/wujie.vue"),
        meta: {
          title: 'wujieDemo2', // 菜单显示文本
          icon: 'Present', // 子菜单图标
          hidden: false,
          level: 1,
          subAppPath: "/wujieDemo2",
        },
      },
    ]
  },

]

export default wujieRouters;

image.png

子应用改造

  • 运行环境判断
  • 路由通信
  • 嵌入子页面
  • 路由
  • 接口响应拦截器

image.png

运行环境判断

这里我们在main.tsx文件通过判断window.$wujie属性是否存在,来判断当前的运行环境是独立运行还是微前端环境

原理wujie会自动给子应用的window上挂载一个$wujie对象

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { HashRouter } from "react-router-dom";
import "./style/index.css";
import App from "./App";

import { Provider } from "react-redux";
import { store } from "./model/store";

// Wujie 子应用生命周期:挂载(主应用嵌入时调用)
const mount = (container: HTMLElement | ShadowRoot, props: any) => {
  // 将主应用 props 存入 React 上下文(方便子应用内部使用)
  createRoot(container).render(
    <StrictMode>
      <Provider store={store}>
        <HashRouter>
          <App {...props} />
        </HashRouter>
      </Provider>
    </StrictMode>
  );
};

// 判断是否在 Wujie 微前端环境中
if (window.$wujie) {
  mount(document.getElementById("root")!, window.$wujie.props);
} else {
  // 独立运行环境(正常启动)
  mount(document.getElementById("root")!, {
    mainAppName: "独立运行",
    token: "local-token",
  });
}

路由通信

app.tsx文件中修改

子应用监听到父应用的路由发生了改变,立即进行路由跳转

import { router } from "./router/createRouteConfig";
import { useNavigate, useRoutes } from "react-router-dom";
import useLocationChange from "./router/useLocationChange";
import routerListener from "./router/routerListener";
import "./style/index.css";
import { useEffect } from "react";

const App = function (props: any) {
  const elements = useRoutes(router);
  const navigate = useNavigate();

  useEffect(() => {
    const wujieBus = window.$wujie?.bus;
    const routeChangeHandler = (path: string) => {
      navigate(path);
    };
    wujieBus?.$on("routeChange", routeChangeHandler);
    return () => {
      wujieBus?.$off("routeChange", routeChangeHandler);
    };                                                                                                                               
  }, [navigate]);

  useLocationChange((to, from) => {
    routerListener(navigate, to, from);
  });
  return elements;
};

export default App;

嵌入的子页面

新建立一个文件用于放嵌入的子页面,且在该子页面中还可以向父应用通信

const wujieDemo1 = () => {

  return (
    <div>
      <h1>我是子应用(react)的wujieDemo1</h1>
      <button onClick={() =>  window.$wujie?.bus.$emit("subAppData", "我是子应用数据")}>向主应用提交数据</button>
    </div>
  );
};

export default wujieDemo1;

路由

新建路由用于对应上面的子页面

其中需要注意的是,路由的path需要对应父应用路由上的subAppPath

......
  {
      name: "wujieDemo1",
      path: "/wujieDemo1",
      component: lazy(() => import("../page/wujiePage/wujieDemo1/index")),
      isMenu: false,
    },
......

接口响应拦截器

在响应拦截器中,主要是针对未登录的场景,在未登录时,告知父应用

这里也做了运行环境的判断,用于判断是进入子应用的登录页面还是父应用的登录页面

// 将方法封装成一个函数
const http = async (config: IAxiosParam): Promise<any> => {
  return request(config)
    .then((res: IResponse) => {
      switch (res.code) {
        case ResCode.notLogin:
          // 未登录
          if (window.$wujie) {
            window.$wujie?.bus.$emit("subAppData", {
              type: "noLogin"
            })
          } else {
            window.location.href = "/login";
          }
          break;
      }

      if (res.code !== 0 && !config.noAlert) {
        // 异常提示
        alert(res.msg || "出现问题啦~");
        return;
      }
      return config.needRes ? res : res.data;
    })
    .catch((res) => {
      return Promise.reject(res);
    });
};

总结

这里我完成了一个基础的demo,在时间的应用还有一些需要注意或优化的点:

  • 子应用的运行地址可配置化
  • 子应用的预加载与保活
  • 多个子应用的配置

后续可根据自己的实际场景来配置

JavaScript数组去重的多种实现方式

作者 瓶子in
2025年11月30日 11:53

前言

在前端开发中,数组去重是一个常见的需求。无论是处理用户数据、API响应还是进行数据清洗,我们都需要掌握去重方法。本文将详细介绍JavaScript中数组去重的多种实现方式。

1. 使用Set数据结构(ES6推荐)

javascript

const arr = [1, 2, 2, 3, 4, 4, 5, 'a', 'a'];
const uniqueArr = [...new Set(arr)];
console.log(uniqueArr); // [1, 2, 3, 4, 5, "a"]

Set 是 ES6 引入的一种新的数据结构,它类似于数组,但是成员的值都是唯一的,没有重复的值。

javascript

// Set的基本使用
const arr1 = new Set()
arr1.add(1)
arr1.add(2)
arr1.add(3)
arr1.add(1)  // 重复添加
console.log(arr1)  // {1, 2, 3} - 重复的自动过滤

2. 使用filter + indexOf方法

javascript

const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = arr.filter((item, index) => arr.indexOf(item) === index);
console.log(uniqueArr); // [1, 2, 3, 4, 5]

这里利用了filter方法和indexOf方法的特性:

  • filter会遍历数组,将符合条件的元素组成新数组返回
  • indexOf返回元素在数组中第一次出现的位置索引
  • 只有当元素第一次出现时的索引与当前索引相等时,才保留该元素

3. 使用reduce方法

javascript

const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = arr.reduce((acc, current) => {
  return acc.includes(current) ? acc : [...acc, current];
}, []);
console.log(uniqueArr); // [1, 2, 3, 4, 5]

reduce方法通过累积器遍历数组,检查当前元素是否已存在于累积器中,不存在则添加。

4. 使用forEach + includes方法

javascript

const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = [];
arr.forEach(item => {
  if (!uniqueArr.includes(item)) {
    uniqueArr.push(item);
  }
});
console.log(uniqueArr); // [1, 2, 3, 4, 5]

这种方法思路直观:

  • 创建空数组存储结果
  • 遍历原数组,检查元素是否已存在
  • 不存在则添加到结果数组

方法对比

  • forEach:遍历数组执行操作,不关心返回值
  • map:将数组转换为新数组(1:1映射)
  • filter:根据条件筛选数组元素

5. 对象数组去重(基于特定属性)

实际开发中,我们经常需要根据对象属性去重:

javascript

const users = [
  {id: 1, name: 'Alice'},
  {id: 2, name: 'Bob'},
  {id: 1, name: 'Alice'},  // 重复
  {id: 3, name: 'Charlie'}
];

// 方法1:使用Map(推荐)
const uniqueUsers = [...new Map(users.map(item => [item.id, item])).values()];

// 方法2:使用reduce
const uniqueUsers2 = users.reduce((acc, current) => {
  const exists = acc.find(item => item.id === current.id);
  return exists ? acc : [...acc, current];
}, []);

console.log(uniqueUsers); // 基于id去重后的数组

Map方法利用键的唯一性,将对象ID作为键,对象本身作为值,最后通过values()获取去重后的对象。

6. 复杂数据类型去重

对于包含对象、数组等复杂数据类型的数组:

javascript

const complexArr = [{a:1}, {a:1}, [1,2], [1,2], 'hello', 'hello'];

const uniqueComplex = complexArr.reduce((acc, current) => {
  const isDuplicate = acc.some(item =>
    JSON.stringify(item) === JSON.stringify(current)
  );
  return isDuplicate ? acc : [...acc, current];
}, []);

console.log(uniqueComplex); // 每个元素都是唯一的

这种方法通过JSON.stringify将对象转为字符串进行比较,但要注意对象属性顺序必须一致。

7. 排序后去重(适用于可排序数据)

javascript

const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = arr
  .sort((a, b) => a - b)
  .filter((item, index, array) =>
    index === 0 || item !== array[index - 1]
  );
console.log(uniqueArr); // [1, 2, 3, 4, 5]

先排序,然后过滤掉与前一元素相同的元素。注意这会改变原数组的顺序。

性能对比与使用建议

方法 适用场景
Set 简单数据类型,现代浏览器
filter+indexOf 兼容性要求高
reduce 需要自定义逻辑
Map对象 对象数组去重

日常开发建议

  • 简单数组去重:优先使用 [...new Set(arr)]
  • 对象数组去重:使用 Map 方法
  • 兼容性要求:使用 filter + indexOf
  • 复杂数据:使用 reduce + JSON.stringify

vue3 Composable介绍

作者 南雨北斗
2025年11月30日 11:38

好的,我们来深入探讨一下 Vue 3 的 Composable

1. 什么是 Composable?

Composable 是 Vue 3 中一个核心的概念和功能,它是可复用的、有状态的逻辑块,用于封装和提取组件中的重复逻辑,让代码更清晰、更易于维护。

你可以把它理解为:专门用来存放 “可以被多个组件复用的业务逻辑” 的函数

2. 为什么需要 Composable?

在 Vue 2 中,我们通常使用 Mixins 来复用逻辑。但 Mixins 存在一些问题:

  • 命名冲突:多个 Mixin 可能会定义相同名称的数据或方法,导致覆盖。
  • 来源不清晰:当一个组件使用了多个 Mixin 后,很难追踪某个数据或方法到底来自哪个 Mixin。
  • 逻辑耦合度高:Mixins 和组件之间是隐式依赖关系,不易于单独测试和维护。

而 Composable 则完美解决了这些问题:

  • 明确的依赖关系:通过函数调用和返回值来显式地传入和获取数据,来源清晰。
  • 无命名冲突:返回的内容通过解构赋值的方式被组件接收,组件可以自己决定命名。
  • 更好的类型推断:对于 TypeScript 非常友好,能提供完整的类型提示。
  • 更易于测试:Composable 本身就是一个函数,可以独立进行单元测试。

3. 如何创建一个 Composable?

创建一个 Composable 非常简单,它就是一个以 use 开头的函数(这是一个约定,便于识别),在函数内部可以使用 Vue 的响应式 API(如 refreactivecomputedwatch 等),并返回需要暴露给组件使用的状态和方法。

示例:创建一个处理计数器逻辑的 Composable

运行

// src/composables/useCounter.js

import { ref, computed, onMounted } from 'vue';

// 定义一个 Composable 函数,通常以 use 开头
export function useCounter(initialValue = 0) {
  // 1. 定义响应式状态
  const count = ref(initialValue);

  // 2. 定义基于状态的计算属性
  const doubleCount = computed(() => count.value * 2);

  // 3. 定义修改状态的方法
  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  // 4. 可以使用生命周期钩子
  onMounted(() => {
    console.log(`计数器已初始化,初始值为: ${count.value}`);
  });

  // 5. 返回需要暴露给组件的内容
  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  };
}

4. 如何在组件中使用 Composable?

在组件中,你只需要 导入并调用 这个 Composable 函数,然后通过解构赋值的方式获取其返回的状态和方法即可。

示例:在组件中使用 useCounter

<!-- src/components/CounterDisplay.vue -->
<template>
  <div>
    <p>当前计数: {{ count }}</p>
    <p>计数的两倍: {{ doubleCount }}</p>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup>
import { useCounter } from '@/composables/useCounter';

// 调用 Composable 函数
// 可以传入初始值,如 useCounter(10)
// 通过解构赋值获取返回的状态和方法
const { count, doubleCount, increment, decrement, reset } = useCounter();
</script>

关键点

  • 每次调用 useCounter(),都会创建一个全新的、独立的状态实例。这意味着如果两个组件都使用了 useCounter,它们的状态是完全隔离的,互不影响。
  • 组件可以根据需要,只解构自己需要的部分。

5. Composable 的最佳实践

  1. 命名规范

    • Composable 函数名必须以 use 开头,例如 useCounteruseUseruseCart
    • 文件名通常与函数名一致,例如 useCounter.js
  2. 单一职责原则

    • 一个 Composable 应该只负责一个特定的功能或逻辑。如果一个 Composable 变得过于庞大和复杂,应该考虑将其拆分成多个更小的、职责更单一的 Composable。
  3. 组合与嵌套

    • Composable 可以相互调用。这是一个非常强大的特性,允许你构建复杂的逻辑。

    • 例如,一个 useCart Composable 内部可以调用 useUser Composable 来获取当前用户信息,以便计算购物车商品的会员价格。

    运行

    // src/composables/useCart.js
    import { useUser } from './useUser';
    
    export function useCart() {
      const { user } = useUser(); // 调用另一个 Composable
    
      // ... 购物车相关逻辑,可以使用 user 的信息
      const cartItems = ref([]);
      
      const calculateDiscountPrice = (item) => {
        if (user.value?.isVIP) {
          return item.price * 0.9; // VIP 9 折
        }
        return item.price;
      };
    
      // ...
      return { cartItems, calculateDiscountPrice };
    }
    
  4. 避免在 Composable 中访问组件实例

    • 理想情况下,Composable 应该是纯逻辑的封装,不应该直接依赖于 Vue 组件实例 (this)。
    • 如果确实需要使用组件实例的属性(如 $route$store),应该通过参数的形式从组件中传递进来,或者使用 Vue 提供的 getCurrentInstance() 函数(但这会降低 Composable 的通用性,应谨慎使用)。

总结

Composable 是 Vue 3 中替代 Mixins 的更优、更现代的逻辑复用方案。

  • 它通过函数的形式封装逻辑,通过返回值暴露状态和方法。
  • 它实现了逻辑的复用和隔离,让组件代码更简洁、更清晰。
  • 它遵循明确的依赖关系单一职责原则,极大地提升了代码的可维护性和可测试性。
  • 在 Vue 3 项目中,任何可复用的逻辑都应该被提取为 Composable

拒绝卡顿!小程序图片本地“极速”旋转与格式转换,离屏 Canvas 性能调优实战

作者 小皮虾
2025年11月30日 10:43

1. 背景与痛点:高清大图的“崩溃”瞬间

在开发小程序图片工具时,我们经常面临“两难”境地:

  1. 用户上传原图:现代手机拍摄的照片动辄 4000x3000 分辨率,在 iOS 设备上 DPR(设备像素比)通常为 3。
  2. 内存爆炸:如果直接按原图渲染,画布像素高达 (4000*3) * (3000*3) ≈ 1亿像素!这远超小程序的 Canvas 内存限制,导致微信客户端直接闪退
  3. 传统方案弊端:上传服务器处理费流量且慢;普通 Canvas 渲染又卡顿界面。

为了解决这个问题,我们打磨出了一套基于 OffscreenCanvas 的高性能本地处理方案,核心在于“智能计算,动态降级”。

2. 核心思路:离屏渲染 + 智能防爆

我们的方案包含两个关键技术点:

  1. OffscreenCanvas(2D 离屏画布): 相比传统 Canvas,它在内存中渲染,不占用 DOM,没有任何 UI 开销,绘图指令执行极快。

  2. 智能 DPR 限制(核心黑科技): 这是防止闪退的关键。我们在绘制前计算“目标画布尺寸”。

    • 判断:如果 逻辑尺寸 * 系统DPR 超过了安全阈值(如 4096px)。
    • 降级:强制降低使用的 DPR 值,确保最终纹理尺寸在安全范围内。
    • 结果:牺牲肉眼难以察觉的极微小清晰度,换取 100% 不闪退 的稳定性。

3. 硬核代码实现

以下是帮小忙工具箱小程序封装好的 imageUtils.js 核心源代码,包含格式转换带防爆逻辑的旋转功能。

// utils/imageUtils.js

// 1. 获取系统基础信息
const wxt = {
  dpr: wx.getSystemInfoSync().pixelRatio || 2
};

// 2. 图片对象缓存池(避免重复加载同一张图)
const cacheCanvasImageMap = new Map();

/**
 * 内部方法:获取/创建 Canvas Image 对象
 */
async function getCanvasImage(canvas, imageUrl) {
  if (cacheCanvasImageMap.has(imageUrl)) {
    return cacheCanvasImageMap.get(imageUrl);
  }
  
  // 兼容 Promise.withResolvers 或使用 new Promise
  const { promise, resolve, reject } = Promise.withResolvers();
  const image = canvas.createImage();
  image.onload = () => {
    cacheCanvasImageMap.set(imageUrl, image);
    resolve(image);
  };
  image.onerror = (e) => reject(new Error(`图片加载失败: ${e.errMsg}`));
  image.src = imageUrl;
  await promise;
  return image;
}

/**
 * 功能一:离屏 Canvas 转换图片格式 (PNG/HEIC -> JPG)
 * @param {string} imageUrl 图片路径
 * @param {string} destFileType 目标类型 'jpg' | 'png'
 * @param {number} quality 质量 0-1
 */
export async function convertImageType(imageUrl, destFileType = 'jpg', quality = 1) {
  const offscreenCanvas = wx.createOffscreenCanvas({ type: '2d' });
  const image = await getCanvasImage(offscreenCanvas, imageUrl);
  const { width, height } = image;

  // 基础转换:直接使用系统 DPR 保证高清
  offscreenCanvas.width = width * wxt.dpr;
  offscreenCanvas.height = height * wxt.dpr;

  const ctx = offscreenCanvas.getContext('2d');
  ctx.scale(wxt.dpr, wxt.dpr);
  ctx.drawImage(image, 0, 0, width, height);

  const res = await wx.canvasToTempFilePath({
    canvas: offscreenCanvas,
    fileType: destFileType,
    quality: quality,
  });
  return res.tempFilePath;
}

/**
 * 功能二:极速旋转图片 (含内存保护)
 * @param {string} imageUrl 图片路径
 * @param {number} degree 旋转角度 (90, 180, 270...)
 */
export async function rotateImage(imageUrl, degree = 90, destFileType = 'jpg', quality = 1) {
  const offscreenCanvas = wx.createOffscreenCanvas({ type: '2d' });
  const image = await getCanvasImage(offscreenCanvas, imageUrl);
  const { width, height } = image;

  const radian = (degree * Math.PI) / 180;
  
  // 1. 计算旋转后的逻辑包围盒宽高
  const newWidth = Math.abs(width * Math.cos(radian)) + Math.abs(height * Math.sin(radian));
  const newHeight = Math.abs(width * Math.sin(radian)) + Math.abs(height * Math.cos(radian));

  // --- ⚡️ 性能优化核心 Start ---
  
  // 2. 智能计算 DPR:避免画布过大炸内存
  // 设定安全纹理阈值,4096px 是大多数移动端 GPU 的安全线
  const LIMIT_SIZE = 4096; 
  let useDpr = wxt.dpr;

  // 核心判断:如果 (逻辑边长 * dpr) 超过限制,自动计算最大允许的 dpr
  if (Math.max(newWidth, newHeight) * useDpr > LIMIT_SIZE) {
    useDpr = LIMIT_SIZE / Math.max(newWidth, newHeight);
    console.warn(`[ImageRotate] 图片过大,触发自动降级,DPR调整为: ${useDpr.toFixed(2)}`);
  }

  // 3. 设置物理画布尺寸 (使用计算后的安全 DPR)
  offscreenCanvas.width = newWidth * useDpr;
  offscreenCanvas.height = newHeight * useDpr;

  const ctx = offscreenCanvas.getContext('2d');
  ctx.scale(useDpr, useDpr); 
  
  // --- 性能优化核心 End ---

  // 4. 绘图逻辑:平移 -> 旋转 -> 绘制
  ctx.translate(newWidth / 2, newHeight / 2);
  ctx.rotate(radian);
  ctx.drawImage(image, -width / 2, -height / 2, width, height);

  // 5. 导出文件 
  const res = await wx.canvasToTempFilePath({
    canvas: offscreenCanvas,
    fileType: destFileType,
    quality: quality,
  });

  return res.tempFilePath;
}

4. 避坑与实战经验

  1. 图片转pdf场景经验 图片转成pdf,在使用pdf-lib插入图片时,只支持jpg、png在插入前先判断一下是否符合,用户可能上传webp等图片(有些人觉得限制上传类型,但图片后缀有可能被篡改过),就需要先转换;另外如果要保证pdf是纵向的,使用canvas提前确保图片为纵向的,就简单很多,无需在pdf-lib做坐标变换
  2. DPR 的取舍艺术: 很多开发者喜欢写死 offscreenCanvas.width = width,这样导出的图是模糊的。也有人写死 width * systemDpr,这会导致大图闪退。 最佳实践就是代码中的 Math.min 逻辑:在安全范围内,尽可能高清
  3. 兼容性提示: 代码中使用了 Promise.withResolvers(),这是 ES2024 新特性。我全局内置兼容代码。
/**
 * 创建withResolvers函数
 */
Promise.withResolvers =
Promise.withResolvers ||
function () {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve,
reject,
};
};

写在最后

通过这一套组合拳,我们成功在小程序中实现了稳定、高效的本地图片处理。无论用户使用几年前的安卓机还是最新的 iPhone,都能流畅地完成图片旋转与转换,再也不用担心内存溢出带来的闪退噩梦了!

希望这篇实战分享能帮你解决 Canvas 开发中的性能难题!

跨域难题终结者:Vue项目中优雅解决跨域问题的完整指南

作者 北辰alk
2025年11月30日 10:37

跨域难题终结者:Vue项目中优雅解决跨域问题的完整指南

作为前端开发者,跨域问题就像一道绕不过去的坎。今天,就让我们彻底攻克这个难题!

什么是跨域?为什么会出现跨域问题?

同源策略:安全的守护者

在深入解决方案之前,我们首先要明白同源策略这个概念。浏览器出于安全考虑,实施了同源策略,它限制了不同源之间的资源交互。

什么是"同源"?
简单来说,当两个URL的协议、域名、端口完全相同时,我们称它们为同源。

举个例子:

当前页面URL 请求URL 是否同源 原因
https://www.example.com/index.html https://www.example.com/api/user ✅ 是 协议、域名、端口完全相同
https://www.example.com/index.html http://www.example.com/api/user ❌ 否 协议不同(https vs http)
https://www.example.com/index.html https://api.example.com/user ❌ 否 域名不同(www vs api)
https://www.example.com:8080/index.html https://www.example.com:3000/api/user ❌ 否 端口不同(8080 vs 3000)

跨域的限制范围

当发生跨域时,以下行为会受到限制:

  • • AJAX请求被阻止(核心问题)
  • • LocalStorageIndexedDB等存储无法访问
  • • DOM无法通过JavaScript操作
  • • Cookie读写受限

但有些资源是允许跨域加载的:

  • • 图片<img>
  • • 样式表<link>
  • • 脚本<script>
  • • 嵌入框架<iframe>(但内容访问受限)

Vue项目中的跨域解决方案

在实际的Vue项目中,我们主要有以下几种解决方案:

方案一:开发环境下的代理配置(最常用)

这是开发阶段最常用的解决方案,通过Vue CLI或Vite的代理功能实现。

Vue CLI项目配置

1. 创建vue.config.js文件

// vue.config.js
const { defineConfig } = require('@vue/cli-service')

module.exports = defineConfig({
  transpileDependenciestrue,
  devServer: {
    port8080,
    proxy: {
      // 简单配置:匹配以/api开头的请求
      '/api': {
        target'http://localhost:3000', // 后端服务器地址
        changeOrigintrue, // 改变请求源
        pathRewrite: {
          '^/api''' // 重写路径,去掉/api前缀
        }
      }
    }
  }
})

2. 复杂场景的多代理配置

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      // 用户服务代理
      '/user-api': {
        target: 'http://user-service:3001',
        changeOrigin: true,
        pathRewrite: {
          '^/user-api''/api'
        },
        logLevel: 'debug' // 开启调试日志
      },
      // 商品服务代理
      '/product-api': {
        target: 'http://product-service:3002',
        changeOrigin: true,
        pathRewrite: {
          '^/product-api''/api'
        }
      },
      // WebSocket代理
      '/ws-api': {
        target: 'ws://websocket-service:3003',
        changeOrigin: true,
        ws: true
      }
    }
  }
}

3. 在Vue组件中使用

// src/services/api.js
import axios from 'axios'

// 创建axios实例
const api = axios.create({
  baseURL'/api'// 使用代理的前缀
  timeout10000
})

// 请求拦截器
api.interceptors.request.use(
  config => {
    // 在发送请求之前做些什么
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 响应拦截器
api.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    // 处理响应错误
    if (error.response?.status === 401) {
      // 未授权,跳转到登录页
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

export default api

// 在Vue组件中使用
// src/components/UserList.vue
<script>
import api from '@/services/api'

export default {
  name'UserList',
  data() {
    return {
      users: [],
      loadingfalse
    }
  },
  async created() {
    await this.fetchUsers()
  },
  methods: {
    async fetchUsers() {
      this.loading = true
      try {
        // 实际请求会发送到代理服务器,然后转发到目标服务器
        const response = await api.get('/users')
        this.users = response.data
      } catch (error) {
        console.error('获取用户列表失败:', error)
        this.$message.error('获取用户列表失败')
      } finally {
        this.loading = false
      }
    },
    
    async createUser(userData) {
      try {
        await api.post('/users', userData)
        this.$message.success('用户创建成功')
        await this.fetchUsers() // 刷新列表
      } catch (error) {
        console.error('创建用户失败:', error)
        this.$message.error('创建用户失败')
      }
    }
  }
}
</script>
Vite项目配置
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port5173,
    proxy: {
      '/api': {
        target'http://localhost:3000',
        changeOrigintrue,
        rewrite(path) => path.replace(/^/api/, ''),
        configure(proxy, options) => {
          // 代理配置回调
          proxy.on('error'(err, _req, _res) => {
            console.log('proxy error', err)
          })
          proxy.on('proxyReq'(proxyReq, req, _res) => {
            console.log('Sending Request:', req.method, req.url)
          })
        }
      }
    }
  }
})

代理工作原理流程图

浏览器发送请求到 Unsupported markdown: linkVue开发服务器代理中间件检测到/api前缀重写请求路径转发到 Unsupported markdown: link后端服务器返回响应数据

方案二:生产环境解决方案

开发环境的代理配置在生产环境是无效的,我们需要其他方案:

1. Nginx反向代理

Nginx配置文件示例:

# nginx.conf
server {
    listen 80;
    server_name your-domain.com;
    
    # 前端静态文件
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
    
    # API代理配置
    location /api/ {
        proxy_pass http://backend-server:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # CORS头(如果需要)
        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE';
        add_header Access-Control-Allow-Headers '*';
        
        # 处理预检请求
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Allow-Origin *;
            add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE';
            add_header Access-Control-Allow-Headers '*';
            add_header Access-Control-Max-Age 86400;
            return 204;
        }
    }
    
    # 静态资源缓存
    location ~* .(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}
2. 后端配置CORS

Node.js Express示例:

// server.js
const express require('express')
const cors require('cors')

const app express()

// 基础CORS配置
app.use(cors())

// 自定义CORS配置
app.use(cors({
  origin: [
    'http://localhost:8080',
    'http://localhost:5173',
    'https://your-production-domain.com'
  ],
  methods: ['GET''POST''PUT''DELETE''OPTIONS'],
  allowedHeaders: ['Content-Type''Authorization''X-Requested-With'],
  credentialstrue, // 允许携带cookie
  maxAge86400 // 预检请求缓存时间
}))

// 或者针对特定路由配置CORS
app.get('/api/data'cors(), (req, res) => {
  res.json({ message'This route has CORS enabled' })
})

// 手动设置CORS头
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin''https://your-domain.com')
  res.header('Access-Control-Allow-Methods''GET, POST, PUT, DELETE, OPTIONS')
  res.header('Access-Control-Allow-Headers''Content-Type, Authorization')
  res.header('Access-Control-Allow-Credentials''true')
  
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200)
  }
  
  next()
})

app.get('/api/users', (req, res) => {
  res.json([{ id1, name'John' }, { id2, name'Jane' }])
})

app.listen(3000, () => {
  console.log('Server running on port 3000')
})

方案三:JSONP(适用于老项目)

// JSONP工具函数
function jsonp(url, callbackName = 'callback') {
  return new Promise((resolve, reject) => {
    // 创建script标签
    const script = document.createElement('script')
    const callbackFunctionName = `jsonp_${Date.now()}_${Math.random().toString(36).substr(2)}`
    
    // 设置全局回调函数
    window[callbackFunctionName] = (data) => {
      // 清理工作
      delete window[callbackFunctionName]
      document.body.removeChild(script)
      resolve(data)
    }
    
    // 处理URL,添加回调参数
    const separator = url.includes('?') ? '&' : '?'
    script.src = `${url}${separator}${callbackName}=${callbackFunctionName}`
    
    // 错误处理
    script.onerror = () => {
      delete window[callbackFunctionName]
      document.body.removeChild(script)
      reject(new Error('JSONP request failed'))
    }
    
    document.body.appendChild(script)
  })
}

// 使用示例
async function fetchData() {
  try {
    const data = await jsonp('http://api.example.com/data')
    console.log('Received data:', data)
  } catch (error) {
    console.error('Error:', error)
  }
}

环境区分的最佳实践

在实际项目中,我们需要根据环境使用不同的配置:

// src/config/index.js
const config = {
  // 开发环境
  development: {
    baseURL: '/api' // 使用代理
  },
  // 测试环境
  test: {
    baseURL: 'https://test-api.yourcompany.com'
  },
  // 生产环境
  production: {
    baseURL: 'https://api.yourcompany.com'
  }
}

const environment = process.env.NODE_ENV || 'development'
export default config[environment]
// src/services/api.js
import config from '@/config'
import axios from 'axios'

const api = axios.create({
  baseURL: config.baseURL,
  timeout: 10000
})

// 环境判断
if (process.env.NODE_ENV === 'development') {
  // 开发环境特殊处理
  api.interceptors.request.use(request => {
    console.log('开发环境请求:', request)
    return request
  })
}

export default api

完整的工作流程图

开发环境

生产环境

Vue应用发起请求判断当前环境使用开发服务器代理使用生产环境API地址Vue开发服务器接收请求代理中间件处理转发到目标服务器直接请求生产APINginx反向代理后端API服务器返回响应数据

常见问题与解决方案

1. 代理不生效怎么办?

检查步骤:

// 1. 检查vue.config.js配置是否正确
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target'http://localhost:3000',
        changeOrigintrue,
        // 添加日志查看代理是否工作
        onProxyReq(proxyReq, req, res) => {
          console.log('Proxying request:', req.url'->', proxyReq.path)
        }
      }
    }
  }
}

// 2. 检查网络面板,确认请求是否发送到正确地址
// 3. 确认后端服务是否正常运行

2. 预检请求(OPTIONS)处理

// 后端需要正确处理OPTIONS请求
app.use('/api/*', (req, res, next) => {
  if (req.method === 'OPTIONS') {
    res.header('Access-Control-Allow-Origin''*')
    res.header('Access-Control-Allow-Methods''GET, POST, PUT, DELETE, OPTIONS')
    res.header('Access-Control-Allow-Headers''Content-Type, Authorization')
    res.status(200).end()
    return
  }
  next()
})

总结

跨域问题是前端开发中的常见挑战,但通过合适的解决方案可以轻松应对:

  • • 开发环境:使用Vue CLI或Vite的代理功能
  • • 生产环境:使用Nginx反向代理或后端配置CORS
  • • 特殊情况:考虑JSONP或WebSocket代理

记住,安全始终是首要考虑因素。在配置CORS时,不要简单地使用*作为允许的源,而应该明确指定可信的域名。

希望这篇详细的指南能帮助你彻底解决Vue项目中的跨域问题!如果你有任何疑问或补充,欢迎在评论区留言讨论。

手势操控 Three.js!效果炸裂!

作者 看晴天了
2025年11月29日 23:12

太牛了!手势操控 Three.js!效果炸裂!

部署运行你感兴趣的模型镜像一键部署

作为一名前端开发者,我一直热衷于界面效果展示,尤其喜欢那些炫酷的 3D 效果。

最近发现了一个很赞的开源项目 “stark-shapes”,它将手势识别与 3D 动画完美结合,借助 Three.js 和 MediaPipe 手势跟踪技术,实现了通过手势操控 3D 粒子动画的效果,视觉体验非常出色。

stark-shapes 亮点

多样的手势操控

项目支持丰富手势操作

右手捏合能放大或缩小场景,左手旋转能让摄像机环绕场景,拍手可切换动画模式,交互方式简单又有趣。

精美的 3D 几何图案

项目包含多种 3D 几何图案,如立方体球体螺旋星系等,这些图案由粒子系统生成,具有动态、流动的效果。

螺旋图案粒子沿轨迹旋转延伸,星系图案模拟宇宙星系旋转运行,视觉效果极佳。

强大的后处理效果

项目引入后处理效果,如 Bloom 效果。它能让场景中明亮部分添加光晕,使画面更柔和梦幻,增强视觉层次感和氛围感。

技术实现

Three.js 的关键作用

Three.js 是基于 WebGL 的 3D 绘图库,负责创建、渲染 3D 几何体及动画处理。

“stark-shapes” 项目中,它管理场景的摄像机位置、光照设置及动画循环等,呈现流畅且视觉效果出色的 3D 世界。

MediaPipe Hands 的手势识别功能

MediaPipe Hands 是强大的手势识别库,能实时跟踪手部关键点位置和动作。

在项目中,它与 Three.js 紧密结合,实现手势与 3D 场景的无缝交互。

快速本地运行

项目体验起来很有趣,手势操作简单,并且这个项目是开源的,对前端开发者来说是个很好的学习资源。

特别是想在 3D 领域深入学习的开发者,可以通过阅读和研究该项目的代码,了解如何结合 Three.js 和 MediaPipe 实现复杂的交互效果。

如果你也对这个项目感兴趣,不妨动手下载源码试试。以下是下载和运行项目的教程:

克隆项目仓库:

git clone git@github.com:collidingScopes/stark-shapes.git
智能体编程go
运行

安装项目依赖:

cd stark-shapes
npm install
智能体编程go
运行

启动项目:

npm start
智能体编程go
运行

在浏览器中打开 http://localhost:8080 即可查看项目效果。

总之, “stark-shapes” 项目实现手势操控 3D 粒子动画,带来新交互体验,是优质学习平台,拓展应用前景广阔。

感兴趣的朋友可体验在线预览或克隆源码深入研究。

  • stark-shapes Github 地址https://github.com/collidingScopes/stark-shapes/tree/main
  • stark-shapes 在线体验地址https://collidingscopes.github.io/stark-shapes/

(首次访问体验地址较慢,耐心等待资源加载。当摄像头里的手势出现识别圆点连线后,就可以体验了。)

Tauri(十九)——实现 macOS 划词监控的完整实践

2025年11月30日 16:04

背景

为了提高 Coco AI 的用户使用率,以及提供快捷操作等,我给我们 Coco AI 也增加了划词功能。

image.png

接下来就介绍一下如何在 Tauri v2 中实现“划词”功能(选中文本的实时检测与前端弹窗联动),覆盖 macOS 无障碍权限、坐标转换、多屏支持、前端事件桥接与性能/稳定性策略。

功能概述

  • 在系统前台 App 中选中文本后,后端读取选区文本与鼠标坐标,通过事件主动推给前端。
  • 前端根据事件展示/隐藏弹窗(或“快查”面板),并在主窗口中同步输入/状态。
  • 提供“全局开关”,随时启停划词监控。

image.png

关键点与设计思路

  • 权限:macOS 读取选区依赖系统“无障碍(Accessibility)”权限;首次运行时请求用户授权。
  • 稳定性:对选区读取做轻量重试与去抖,避免弹窗闪烁。
  • 坐标:Quartz 坐标系为“左下角为原点”,前端常用“左上角为原点”;需要对 y 做翻转。
  • 多屏:在多显示器场景下,根据鼠标所在显示器与全局边界计算统一坐标。
  • 交互保护:当 Coco 自己在前台时,暂不读取选区,避免把弹窗交互误判为空选区。
  • 事件协议:统一向前端发两个事件:
    • selection-detected:选区文本与坐标(或空字符串表示隐藏)
    • selection-enabled:开关状态

后端实现(Tauri v2 / Rust)

  • 定义事件载荷与全局开关,导出命令给前端调用。
  • 在启动入口中开启监控线程,不断读取选区并发事件。
/// 事件载荷:选中文本与坐标(逻辑点、左上为原点)
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
    text: String,
    x: i32,
    y: i32,
}

use std::sync::atomic::{AtomicBool, Ordering};

/// 全局开关:默认开启
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);

#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
    enabled: bool,
}

/// 读写开关并广播
pub fn is_selection_enabled() -> bool { SELECTION_ENABLED.load(Ordering::Relaxed) }
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
    SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
    let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}

/// Tauri 命令:供前端调用开关
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
    set_selection_enabled_internal(&app_handle, enabled);
}
#[tauri::command]
pub fn get_selection_enabled() -> bool { is_selection_enabled() }
  • 启动监控线程:权限校验、选区读取、坐标转换与事件发送。
#[cfg(target_os = "macos")]
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
    use std::time::Duration;
    use tauri::Emitter;

    // 同步初始开关状态到前端
    set_selection_enabled_internal(&app_handle, is_selection_enabled());

    // 申请/校验无障碍权限(macOS)
    {
        let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
        if !trusted_before {
            let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
        }
        let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
        if !trusted_after {
            return; // 未授权则不启动监控
        }
    }

    // 监控线程
    std::thread::spawn(move || {
        use objc2_core_graphics::CGEvent;
        use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
        #[cfg(target_os = "macos")]
        use objc2_app_kit::NSWorkspace;

        // 计算鼠标全局坐标(左上原点),并做 y 翻转
        let current_mouse_point_global = || -> (i32, i32) {
            unsafe {
                let event = CGEvent::new(None);
                let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
                // 多屏取全局边界并翻转 y
                // ...(详见源码的显示器遍历与边界计算)
                // 返回 (x_top_left, y_flipped)
                // ... existing code ...
                (/*x*/0, /*y*/0)
            }
        };

        // Coco 在前台时不读选区,避免交互中误判空
        let is_frontmost_app_me = || -> bool {
            #[cfg(target_os = "macos")]
            unsafe {
                let workspace = NSWorkspace::sharedWorkspace();
                if let Some(frontmost) = workspace.frontmostApplication() {
                    let pid = frontmost.processIdentifier();
                    let my_pid = std::process::id() as i32;
                    return pid == my_pid;
                }
            }
            false
        };

        // 状态机与去抖
        let mut popup_visible = false;
        let mut last_text = String::new();
        let stable_threshold = 2; // 连续一致≥2次视为稳定
        let empty_threshold = 2;  // 连续空≥2次才隐藏
        let mut stable_text = String::new();
        let mut stable_count = 0;
        let mut empty_count = 0;

        loop {
            std::thread::sleep(Duration::from_millis(30));

            if !is_selection_enabled() {
                if popup_visible {
                    let _ = app_handle.emit("selection-detected", "");
                    popup_visible = false;
                    last_text.clear();
                    stable_text.clear();
                }
                continue;
            }

            let front_is_me = is_frontmost_app_me();
            let selected_text = if front_is_me {
                None // 交互期间不读选区
            } else {
                read_selected_text_with_retries(2, 35) // 轻量重试
            };

            match selected_text {
                Some(text) if !text.is_empty() => {
                    // 稳定性检测
                    // ... existing code ...
                    if stable_count >= stable_threshold {
                        if !popup_visible || text != last_text {
                            let (x, y) = current_mouse_point_global();
                            let payload = SelectionEventPayload { text: text.clone(), x, y };
                            let _ = app_handle.emit("selection-detected", payload);
                            last_text = text;
                            popup_visible = true;
                        }
                    }
                }
                _ => {
                    // 非前台且空选区:累计空次数后隐藏
                    // ... existing code ...
                }
            }
        }
    });
}
  • 读取选区(AXUIElement):优先系统级焦点,其次前台 App 的焦点/窗口;仅读取 AXSelectedText
#[cfg(target_os = "macos")]
fn read_selected_text() -> Option<String> {
    use objc2_application_services::{AXError, AXUIElement};
    use objc2_core_foundation::{CFRetained, CFString, CFType};
    // 优先系统级焦点 AXFocusedUIElement,失败则回退到前台 App/窗口焦点
    // 跳过当前进程(Coco)避免误判
    // 成功后读取 AXSelectedText,转为 String 返回
    // ... existing code ...
    Some(/*selected text*/ String::new())
}

#[cfg(target_os = "macos")]
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
    // 最多重试 N 次:缓解 AX 焦点短暂不稳定
    // ... existing code ...
    None
}

前端事件桥接

  • 事件名称

    • selection-enabled:载荷 { enabled: boolean },用于同步开关状态
    • selection-detected:载荷 { text: string, x: number, y: number }""(隐藏)
  • 监听与联动建议

    • 通过 platformAdapter.listenEvent("selection-detected", ...) 已完成桥接。
    • 收到带文本的事件后,渲染弹窗;收到 "" 时隐藏。
    • 在主窗口中同步搜索/聊天输入与模式。例如配合 useSearchStore/useAppStore 更新 searchValueisChatModeaskAiMessage 等。
// 伪示例:监听 selection-detected 并联动 UI
function useListenSelection() {
  // ... existing code ...
  platformAdapter.listenEvent("selection-detected", (payload) => {
    if (payload === "") {
      // 隐藏弹窗
      // ... existing code ...
      return;
    }
    const { text, x, y } = payload as { text: string; x: number; y: number };
    // 展示弹窗(使用 x, y 定位)
    // 同步到主窗口输入或 AI 询问
    // ... existing code ...
  });
}

Tauri v2 集成与命令注册

  • 在后端入口(如 main.rs):
    • 注册命令:set_selection_enabledget_selection_enabled
    • 应用启动后调用一次 start_selection_monitor(app_handle.clone()) 开启监控线程
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            set_selection_enabled,
            get_selection_enabled
        ])
        .setup(|app| {
            let handle = app.handle().clone();
            #[cfg(target_os = "macos")]
            {
                start_selection_monitor(handle);
            }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running coco app");
}

权限与配置

  • macOS 无障碍(Accessibility)权限
    • 首次启动会触发系统授权提示;用户需在“系统设置 → 隐私与安全 → 辅助功能”中允许 Coco。
    • 代码中使用 macos_accessibility_client 检查与提示,不需额外 Info.plist 键。
  • Tauri v2 Capabilities
    • Tauri 对前端 API 能力有更细粒度的限制;如需事件、命令调用等,确保 tauri.conf.jsoncapabilities 配置允许相应操作。

稳定性与性能策略

  • 去抖与重试
    • stable_threshold = 2:相同文本稳定两次再触发事件,减少闪烁与误报
    • empty_threshold = 2:空选区累计两次再隐藏,避免短暂抖动导致过度隐藏
  • 轮询间隔
    • 30ms 足够流畅,实际可根据功耗与体验权衡调整
  • 交互保护
    • 前台为 Coco 时不读选区,避免把弹窗交互过程误读为空选区,从而误触隐藏

坐标与多屏支持

  • Quartz 坐标系为“左下为原点”,很多前端布局为“左上为原点”
    • 通过计算全局高度并翻转 y,确保前端定位直观
  • 多屏场景
    • 遍历所有活动显示器,计算全局最左、最上、最下边界,统一映射全局坐标
    • 根据鼠标实际所在显示器确定相对坐标,兼顾跨屏切换的平滑性

常见问题与排查

  • 未授权导致“没有任何事件”
    • 检查“系统设置 → 隐私与安全 → 辅助功能”是否勾选 Coco
  • 前端没有响应 selection-detected
    • 确认事件监听正确(命名与载荷形态)、确保主窗口同步更新输入与模式
  • 坐标不正确或弹窗偏移
    • 排查坐标系转换(y 翻转)、多屏边界计算是否符合实际布局
  • 弹窗闪烁或频繁隐藏
    • 调整 stable_threshold / empty_threshold 与轮询间隔;也可对文本变化设更严格的稳定条件

测试清单

  • 授权流程:首次运行提示、授权后是否正常读取
  • 多屏场景:跨屏移动鼠标后坐标是否正确、弹窗位置是否稳定
  • 交互过程:点击弹窗与主窗口时是否停止读取选区、不会误判空而隐藏
  • 文本变化:快速划词切换时是否平滑、不会频繁闪烁

小结

  • 划词功能的核心在于 “权限 → 获取选区 → 稳定性处理 → 事件联动 → 前端渲染” 这条链路。
  • Tauri v2 在能力管理与事件桥接上更清晰,结合 macOS 的 AX 接口与坐标转换,可以构建稳定、体验良好的系统级“快查”能力。

开源共建,欢迎 Star ✨:github.com/infinilabs/…

Vue3 如何实现图片懒加载?其实一个 Intersection Observer 就搞定了

作者 刘大华
2025年11月30日 13:31

大家好,在当今图片密集的网络环境中,优化图片加载已成为前端开发的重要任务。今天我们分享一下怎么使用 Vue3 实现图片的懒加载功能。

什么是图片懒加载?

假如你打开一个有大量图片的页面,如果所有图片同时加载,会导致页面卡顿、流量浪费,特别是对于那些需要滚动才能看到的图片。

懒加载技术就是解决这个问题的方案,只有当图片进入或即将进入可视区域的时候,才加载它们

效果预览:

6vue3图片懒加载3.gif

完整示例代码可在文末获取

实现原理

我们的Vue3懒加载实现基于以下核心技术:

1. Intersection Observer API

这是现代浏览器提供的强大API,可以高效监听元素是否进入可视区域,而无需频繁计算元素位置,性能远优于传统的滚动监听方式。

// 创建观察器
observer.value = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) { // 元素进入可视区域
      loadImage();
      observer.value.unobserve(entry.target); // 加载后停止观察
    }
  });
}, {
  rootMargin: '50px 0px', // 提前50px开始加载
  threshold: 0.1 // 元素10%可见时触发
});

2. 组件化设计

我们将懒加载功能封装为独立的 LazyImage 组件,提高代码复用性和可维护性。

代码实现详解

组件模板结构

<div class="lazy-image-container" ref="container">
  <img
    v-if="isLoaded && !hasError"
    :src="actualSrc"
    :alt="alt"
    class="lazy-image"
    :style="{ opacity: imageOpacity }"
    @load="onLoad"
    @error="onError"
  />
  <div v-else-if="hasError" class="image-placeholder">
    <div class="error-message">图片加载失败</div>
    <button @click="retryLoad" style="margin-top: 10px;">重试</button>
  </div>
  <div v-else class="image-placeholder">
    <div class="spinner"></div>
    <div>加载中...</div>
  </div>
</div>

组件包含三种状态:

  • 加载中:显示旋转加载动画
  • 加载完成:显示实际图片,带有淡入效果
  • 加载失败:显示错误信息和重试按钮

核心逻辑实现

状态管理

setup(props, { emit }) {
  const isLoaded = ref(false);    // 是否已加载
  const hasError = ref(false);    // 是否加载失败
  const imageOpacity = ref(0);    // 图片透明度(用于淡入效果)
  const observer = ref(null);     // Intersection Observer实例
  const container = ref(null);    // 容器DOM引用
  const actualSrc = ref('');      // 实际图片地址
  // ...
}

使用Vue3的Composition API,我们可以更清晰地组织代码逻辑。

图片加载控制

const loadImage = () => {
  if (props.slowLoad) {
    // 模拟慢速网络 - 延迟2秒加载
    setTimeout(() => {
      actualSrc.value = props.src;
      isLoaded.value = true;
    }, 2000);
  } else {
    // 正常加载
    actualSrc.value = props.src;
    isLoaded.value = true;
  }
};

这个函数根据slowLoad属性决定是否模拟慢速网络,便于测试不同网络条件下的表现。

生命周期管理

onMounted(() => {
  // 创建并启动Intersection Observer
  observer.value = new IntersectionObserver((entries) => {
    // 观察逻辑...
  });
  
  if (container.value) {
    observer.value.observe(container.value);
  }
});

onUnmounted(() => {
  // 组件卸载时清理观察器
  if (observer.value) {
    observer.value.disconnect();
  }
});

确保在组件销毁时正确清理资源,避免内存泄漏。

错误处理与重试机制

const onError = () => {
  hasError.value = true;
  emit('error'); // 向父组件发送错误事件
};

const retryLoad = () => {
  hasError.value = false;
  isLoaded.value = false;
  // 重新触发观察
  if (observer.value && container.value) {
    observer.value.observe(container.value);
  }
};

良好的错误处理机制可以提升用户体验,让用户在图片加载失败时有机会重试。

应用该组件

在主组件中使用懒加载

<div class="gallery">
  <div 
    v-for="(image, index) in images" 
    :key="index" 
    class="image-card"
  >
    <lazy-image
      :src="image.url"
      :alt="image.title"
      :slow-load="networkSlow"
      @loaded="onImageLoaded"
      @error="onImageError"
    ></lazy-image>
    <div class="image-info">
      <div class="image-title">{{ image.title }}</div>
      <div class="image-description">{{ image.description }}</div>
    </div>
  </div>
</div>

功能控制与统计

我们的主组件提供了实用的控制功能:

  • 添加更多图片:动态加载更多图片
  • 重置图片:恢复初始状态
  • 模拟网络速度:切换正常/慢速网络模式
  • 加载统计:实时显示已加载和失败的图片数量

进一步优化

在实际项目中,还可以考虑以下优化:

  1. 图片压缩与格式选择:使用WebP等现代格式,减小文件体积
  2. 渐进式加载:先加载低质量预览图,再加载高清图
  3. 预加载关键图片:对首屏内的关键图片不使用懒加载
  4. 使用CDN加速:通过内容分发网络提高图片加载速度

Github示例代码github.com/1344160559-…

总结

Vue3图片懒加载是一个简单但极其实用的优化技术。通过Intersection Observer API和Vue3的响应式系统,我们可以以少量代码实现高效的懒加载功能,显著提升页面性能和用户体验。

这个实现不仅适用于图片展示类网站,也可以应用于任何需要优化资源加载的Vue3项目。希望本文能帮助你理解和实现这一重要前端优化技术!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+MySQL+Vue实现文件共享系统》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

❌
❌