普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月5日技术

【AI 编程实战】第 1 篇:TRAE SOLO 模式 10 倍速开发商业级全栈小程序

作者 HashTang
2025年12月5日 12:15
上周,我的朋友小何找到我,一脸愁容。他接了一个恋爱话术回复类的小程序项目——"心动恋聊",需要在 2 个月内完成从 0 到 1 的开发。看着需求文档上密密麻麻的功能点,他开始掰着手指算时间:

uni-app 使用 uview-plus

作者 一壶纱
2025年12月5日 11:11

uview-plus 是一个基于 uni-app 的高质量 UI 组件库,提供了丰富的组件和工具函数,帮助开发者快速构建跨平台应用。

1. 安装 uview-plus

在项目中安装 uview-plus

pnpm add uview-plus

2. 使用 easycom 方式引入组件

在本项目中,uview-plus 是通过 easycom 的方式按需引入组件的。以下是具体使用方法:

2.1 配置组件路径

pages.json 中配置 easycom 规则:

{
  "easycom": {
    "autoscan": true,
    "custom": {
      "^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
      "^up-(.*)": "uview-plus/components/u-$1/u-$1.vue"
    }
  }
}

说明:

  • autoscan: 设置为 true,自动扫描 node_modules/uview-plus/components 目录下的组件。
  • custom: 自定义组件匹配规则,^u-(.*)^up-(.*) 表示以 u-up- 开头的组件名会匹配到 uview-plus 的对应组件路径。

2.2 使用组件

配置完成后,可以直接在页面中使用 uview-plus 的组件,无需手动引入。例如:

<template>
  <view>
    <u-button type="primary">主要按钮</u-button>
    <up-button type="success">成功按钮</up-button>
  </view>
</template>

3. 自定义主题

uview-plus 支持通过 SCSS 变量自定义主题。以下是推荐的自定义主题方案:

3.1 创建自定义主题文件

在项目的 src/styles 目录下新建一个 uview-plus.theme.scss 文件,并将 node_modules/uview-plus/theme.scss 中的所有变量复制到该文件中。例如:

$u-primary: #007aff; // 修改主色
$u-success: #4cd964; // 修改成功色
$u-warning: #f0ad4e; // 修改警告色
$u-error: #dd524d; // 修改错误色
// 其他变量...

根据需求修改这些变量的值,以实现自定义主题。

3.2 引入自定义主题文件

src/uni.scss 文件的顶部引入自定义主题文件:

@import '@/styles/uview-plus.theme.scss';

通过这种方式,uview-plus 的组件将使用自定义的主题变量。

函数组件 useEffect 清理函数抛错:ErrorBoundary 能捕获吗?

作者 xiechao
2025年12月5日 11:09

函数组件 useEffect 清理函数抛错:ErrorBoundary 能捕获吗?

在函数组件中,useEffect 的返回方法(通常称为 “清理函数”)承担着类似类组件 componentWillUnmount 的职责,比如取消定时器、清除订阅、终止未完成的接口请求等。最近有开发者问:“如果在这个清理函数里不小心抛出了错误,ErrorBoundary 能捕获到吗?” 这个问题恰好卡在 ErrorBoundary 的 “能力边界” 上,我们结合之前讲过的限制来拆解分析。

先给结论:清理函数中的错误,ErrorBoundary 无法捕获

要理解原因,得先回顾两个关键前提:

  1. ErrorBoundary 仅能捕获 子组件渲染、生命周期(类组件)、构造函数中的同步错误
  1. useEffect 清理函数的执行时机,是在组件卸载时或依赖项更新导致 effect 重新执行前 —— 这个时机脱离了组件的 “渲染流程” ,属于 “组件销毁 / 更新后的收尾操作”,和我们之前讲的 “异步操作错误”“事件处理错误” 本质上是同一类:不在 ErrorBoundary 的监控范围内。

用实例验证:清理函数抛错会直接崩溃

我们写一段代码来模拟这个场景:在 useEffect 清理函数中故意抛出错误,看看 ErrorBoundary 是否生效。

// 1. 先定义基础的 ErrorBoundary 组件(复用之前的逻辑)
class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidCatch(error) {
    console.error('ErrorBoundary 捕获到错误:', error);
  }
  render() {
    if (this.state.hasError) return <div>页面出错了,但被捕获了~</div>;
    return this.props.children;
  }
}
// 2. 函数组件:在 useEffect 清理函数中抛错
function CleanupErrorComponent() {
  useEffect(() => {
    // effect 执行逻辑(空)
    return () => {
      // 组件卸载时执行的清理函数,故意抛错
      throw new Error('useEffect 清理函数出错了!');
    };
  }, []);
  return <div>我是一个会在卸载时抛错的组件</div>;
}
// 3. 父组件:用 ErrorBoundary 包裹目标组件,并加卸载触发按钮
function ParentComponent() {
  const [showChild, setShowChild] = useState(true);
  return (
    <div>
      <button onClick={() => setShowChild(false)}>卸载子组件</button>
      <ErrorBoundary>
        {showChild && <CleanupErrorComponent />}
      </ErrorBoundary>
    </div>
  );
}

当点击 “卸载子组件” 时,CleanupErrorComponent 触发清理函数并抛错 —— 此时控制台会打印红色错误,但 ErrorBoundary 没有渲染 “页面出错了,但被捕获了~” 的备用 UI,反而可能导致页面功能异常(比如按钮点击无响应)。

这就证明了:useEffect 清理函数中的错误,完全绕过了 ErrorBoundary 的捕获机制

为什么会这样?从 React 执行流程看本质

React 处理 useEffect 清理函数的逻辑,属于 “commit 阶段” 后的收尾操作:

  1. 当组件需要卸载时,React 先完成 “DOM 移除”“状态更新” 等核心渲染流程;
  1. 核心流程结束后,才会异步执行 useEffect 的清理函数;
  1. 此时 ErrorBoundary 对该组件的 “渲染监控” 已经结束 —— 毕竟组件都从 DOM 树上移除了,ErrorBoundary 自然无法感知后续的错误。

简单说:ErrorBoundary 只 “盯着” 组件 “活着” 时的渲染相关操作,组件 “死了” 之后(卸载后)的清理函数抛错,它管不着。

解决方案:手动用 try/catch 包裹清理函数

既然 ErrorBoundary 不管用,那该如何处理清理函数中的错误?答案和处理 “事件处理错误”“异步错误” 一致 ——主动用 try/catch 捕获

修改后的清理函数代码:

useEffect(() => {
  return () => {
    // 用 try/catch 包裹所有可能抛错的逻辑
    try {
      // 比如:取消接口请求、清除定时器等可能出错的操作
      const invalidJson = '这不是合法的JSON';
      JSON.parse(invalidJson); // 这里会抛错
    } catch (error) {
      // 错误处理:打印日志、上报监控平台,避免崩溃
      console.error('useEffect 清理函数出错(已捕获):', error);
      // 可选:如果需要用户感知,可以通过状态提示(但注意组件已卸载,需谨慎)
      // 比如:用一个全局状态管理错误提示,而非组件自身状态
    }
  };
}, []);

这里有个注意点:清理函数中不要更新组件自身的状态(比如 setState),因为组件此时已卸载,更新状态会触发 “内存泄漏警告”。如果需要告知用户错误,可以用全局状态(如 Redux、Context)管理错误提示,在其他未卸载的组件(如顶部通知栏)中显示。

延伸:类似场景的错误处理原则

除了 useEffect 清理函数,以下场景的错误也需要手动用 try/catch 处理,而非依赖 ErrorBoundary:

  • useLayoutEffect 的清理函数(执行时机虽早于 useEffect,但同样不在渲染流程内);
  • 自定义 Hook 中的清理逻辑(如 useRequest 中的请求取消函数);
  • 组件卸载时执行的其他回调(如第三方库的销毁方法)。

总结

useEffect 返回的清理函数,虽然承担着类组件 componentWillUnmount 的职责,但它的执行时机和错误性质,决定了 ErrorBoundary 无法捕获其中的错误。处理这类错误的核心原则是:主动预判风险,用 try/catch 包裹所有可能抛错的逻辑,再配合日志上报和用户提示,才能避免应用崩溃,同时保障用户体验。

记住:ErrorBoundary 是 “渲染流程的守护者”,而非 “所有错误的万能药”—— 在函数组件的副作用清理中,手动捕获错误才是更可靠的方案。

CSS属性:background-position

作者 tj
2025年12月5日 11:04

background-position是CSS中用于设置背景图片初始位置的属性。这个位置是相对于由background-origin定义的位置图层的。通过这个属性,可以精确控制背景图像在元素中的位置,无论是水平还是垂直方向。

使用background-position

background-position属性可以接受多种类型的值,包括关键字、百分比或长度值。这些值定义了背景图像相对于元素盒子模型边界的x/y坐标。例如:

/* 关键字值 */
background-position: top;
background-position: bottom;
background-position: left;
background-position: right;
background-position: center;
/* 百分比值 */
background-position: 25% 75%;
/* 长度值 */
background-position: 0 0;
background-position: 1cm 2cm;
background-position: 10ch 8em;
/* 多个背景图像 */
background-position: 0 0, center;
/* 边缘偏移值 */
background-position: bottom 10px right 20px;

语法和值

  • 单个值:如果只指定了一个值,第二个值默认是center
  • 两个值:第一个值通常是水平位置,第二个值是垂直位置。如果两个值都是关键字,比如top left,它们的顺序不重要,因为浏览器会重新排序。
  • 三个值:两个关键字值和一个偏移量,其中偏移量是前面关键字值的偏移。
  • 四个值:两个定义X和Y的关键字值,以及两个偏移量。

百分比值的计算

百分比值是相对于容器的大小减去背景图像大小的。例如,background-position: 25% 75% 表示图像上的左侧25%和顶部75%的位置将放置在距容器左侧25%和距容器顶部75%的容器位置。

示例

/* 使用 `background` 缩写 */
exampleone {
 background: url("startransparent.gif") #ffee99 2.5cm bottom no-repeat;
}
exampletwo {
 background: url("startransparent.gif") #ffee99 left 4em bottom 1em no-repeat;
}
/* 多背景图片:每个图片依次和相应的 `background-position` 匹配 */
examplethree {
 background-image: url("startransparent.gif"), url("catfront.png");
 background-position: 0px 0px, right 3em bottom 2em;
}

在上述示例中, .exampleone.exampletwo使用了background缩写来设置背景图像及其位置,而 .examplethree则演示了如何为两个不同的背景图片指定位置。

浏览器兼容性

background-position属性在所有现代浏览器中都得到了支持,包括Chrome、Firefox、Safari、Opera等。对于旧版本的IE,可能需要额外的兼容性考虑。

通过使用background-position属性,开发者可以灵活地控制背景图像的显示方式,无论是固定在某个位置,还是相对于元素的边缘进行偏移。这为页面设计提供了更多的创意空间和视觉效果的可能性。

数组判断?我早不用instanceof了,现在一行代码搞定!

作者 南游
2025年12月5日 11:02

传统方案

1. Object.prototype.toString.call 方法

原理:通过调用 Object.prototype.toString.call(obj) ,判断返回值是否为  [object Array] 。

function isArray(obj){
  return Object.prototype.toString.call(obj) === '[object Array]';
}

  缺陷

  • ES6 引入 Symbol.toStringTag 后,可被人为篡改。例如:

    const obj = {
      [Symbol.toStringTag]: 'Array'
    };
    console.log(Object.prototype.toString.call(obj)); // 输出 [object Array]
    
  • 若开发通用型代码(如框架、库),该漏洞会导致判断失效。

2. instanceof 方法

原理:判断对象原型链上是否存在 Array 构造函数。

function isArray(obj){
  return obj instanceof Array;
}

缺陷

  • 可通过 Object.setPrototypeOf 篡改原型链,导致误判。例如:

    const obj = {};
    Object.setPrototypeOf(obj, Array.prototype);
    console.log(obj instanceof Array); // 输出 true,但 obj 并非真正数组
    
  • 跨 iframe 场景失效。不同 iframe 中的 Array 构造函数不共享,导致真数组被误判为非数组。例如:

    const frame = document.querySelector('iframe');
    const Array2 = frame.contentWindow.Array;
    const arr = new Array2();
    console.log(arr instanceof Array); // 输出 false,但 arr 是真正数组
    

ES6 原生方法

方法:使用 Array.isArray 静态方法。

console.log(Array.isArray(arr));

  优势

  • 该方法由JavaScript引擎内部实现,直接判断对象是否由 Array 构造函数创建,不受原型链、 Symbol.toStringTag  或跨 iframe 影响;

  • 完美解决所有边界场景。

总结

判断数组的方法中 Array.isArray 是唯一准确且无缺陷的方案。其他方法(如Object.prototype.toString.callinstanceof)均存在局限性,仅在特定场景下可用。

pnpm approve-builds报错

作者 mouseliu
2025年12月5日 10:58

首次安装依赖或某些依赖的构建脚本被忽略时,pnpm 会拦截这些脚本的自动执行,并提示您运行 pnpm approve-builds 来选择允许哪些包运行脚本。这通常发生在项目依赖中包含可能执行敏感操作的包时

1. ‌pnpm 安全机制

  • 默认行为‌:pnpm 会拦截 postinstall 等敏感脚本,防止自动执行潜在恶意代码3。这导致依赖包的构建脚本(如 core-jsesbuild)被忽略。
  • 触发条件‌:当安装的依赖包包含构建脚本时,pnpm 会提示需要手动批准23。

2. ‌差异原因

  • 环境差异‌:另一个项目可能未触发此安全机制,原因可能包括:

    • 依赖包不同‌:未包含 core-jsesbuild 等需要构建脚本的依赖2。
    • pnpm 版本差异‌:旧版本 pnpm 可能未启用此安全特性2。
    • 配置差异‌:项目根目录存在 .npmrc 文件,配置了 allowed-dep-scripts 参数允许特定依赖脚本运行45。
    • CI/CD 环境‌:CI 环境可能通过环境变量(如 CI=true)或脚本(如 pnpm approve-builds)自动批准构建脚本36。

3. ‌解决方案

  • 临时允许‌:在安装命令后添加 --unsafe-perm 参数(不推荐,范围太广)4。
  • 永久配置‌:在项目根目录创建 .npmrc 文件,添加 allowed-dep-scripts=core-js,esbuild45。
  • 手动批准‌:运行 pnpm approve-builds 命令,交互式选择允许的依赖(输入 a 允许全部)26。

4. ‌安全建议

  • 风险评估‌:确保批准的构建脚本(如 core-jsesbuild)来自可信来源3。
  • 版本更新‌:检查 pnpm 是否为最新版本,旧版本可能存在安全漏洞2。

解决步骤

  1. 运行命令‌:在项目根目录下执行以下命令:

    bashCopy Code
    pnpm approve-builds
    

    执行后,终端会进入一个交互式界面,列出被忽略的依赖包(例如 core-jsesbuild@vue-office/docx 等)。‌12

  2. 选择依赖‌:

    • 允许全部依赖‌:直接输入 a(代表 all),然后按回车。这是最快捷的方式,适用于您信任所有列出的包。‌24
    • 选择性允许‌:使用空格键( )勾选特定包,再按回车。例如,如果只信任 core-js 和 esbuild,可勾选它们后确认。‌12
  3. 重新安装并构建‌:
    完成选择后,重新安装依赖并运行构建:

    bashCopy Code
    pnpm install && pnpm run build
    

    被批准的依赖将正常执行脚本,构建应成功。‌‌

组件与外部世界的桥梁:一文读懂 useEffect 的核心机制

2025年12月5日 10:55

简单来说,useEffect 是 React 函数组件中用来处理副作用 (Side Effects) 的钩子。

你可以把它理解为“组件与外部世界沟通的桥梁”。

组件的核心任务是渲染(把数据变成 UI),这是一个纯净的计算过程。但有时候你需要做一些“不纯净”的事情,比如:

  • 去服务器拿数据。
  • 手动改一下网页标题。
  • 设置一个定时器。
  • 监听鼠标滚动。

这些事情都不能在渲染过程中直接做,必须交给 useEffect渲染结束后去做。


它的三个核心作用(对应类组件的生命周期)

如果你熟悉 React 类组件,useEffect 相当于 componentDidMountcomponentDidUpdatecomponentWillUnmount 这三个生命周期的组合体。

1. 组件挂载时执行 (Mount)

“组件刚出现时,做点什么。”

比如:页面一加载就请求 API,或者建立 WebSocket 连接。

useEffect(() => {
  console.log('组件挂载了(只运行一次)');
  fetchData();
}, []); // ✅ 空数组:代表没有任何依赖,只在出生时跑一次

2. 依赖更新时执行 (Update)

“当某个数据变了,做点同步工作。”

这是 useEffect 最核心的设计理念:同步。保持组件内部状态和外部系统同步。

useEffect(() => {
  console.log('userId 变了,我要重新获取用户信息');
  fetchUserInfo(userId);
}, [userId]); // ✅ 依赖数组:只要 userId 变,我就重跑

3. 组件卸载/清理时执行 (Unmount / Cleanup)

“组件要消失了(或者依赖变了),把之前的烂摊子收拾一下。”

比如:清除定时器、取消订阅、断开连接,防止内存泄漏。

useEffect(() => {
  const timer = setInterval(() => console.log('Tick'), 1000);

  // 👇 返回一个清理函数
  return () => {
    clearInterval(timer);
    console.log('组件卸载了,或者下次 Effect 运行前,先清理旧的定时器');
  };
}, []);

总结一张表

写法 含义 对应类组件生命周期
useEffect(() => { ... }) 每次渲染后都跑 componentDidMount + componentDidUpdate
useEffect(() => { ... }, []) 只在第一次渲染后跑 componentDidMount
useEffect(() => { ... }, [prop]) 只在 prop 变化后跑 componentDidUpdate (带判断)
useEffect(() => { return () => ... }, []) 组件销毁时跑 componentWillUnmount

一句话心法

useEffect 的作用是告诉 React: “等把界面画好之后,去帮我做这件这件额外的事(副作用)。如果我依赖的变量变了,记得重做一遍。”

永远不要欺骗 React:详解 useEffect 依赖规则与“闭包陷阱”

2025年12月5日 10:50

如果你在 useEffect 内部使用了某个 prop 或 state,但没有把它放到依赖数组里,你会遇到 React 中最著名的 Bug —— 闭包陷阱 (Stale Closure)

这意味着:你的 Effect 只能“看见”旧的数据,永远看不见新的数据。


1. 为什么“必须”放?(原理演示)

看这个经典的错误例子:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // ❌ 永远打印 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // ⚠️ 空数组:意思是“只在挂载时运行一次”
}

发生了什么?

  1. 第一次渲染 (Mount): count 是 0。useEffect 执行,创建了一个定时器。这个定时器捕获了当时count (也就是 0)。
  2. 第二次渲染: 用户点了按钮,count 变成了 1。
  3. React 检查依赖: 依赖数组是 [] (空的),跟上次一样。
  4. React 决定: “既然依赖没变,那我就不重新运行 Effect 了。”
  5. 结果: 旧的定时器还在跑,它手里的 count 依然是第一次渲染时的那个 0。它永远不知道外面 count 已经变了。

这就是“对 React 撒谎”的代价。


2. 但是,如果你把它们放进去...

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, [count]); // ✅ 加进去了

现在的行为:

  1. count 变了 (0 -> 1)。
  2. React 发现依赖变了。
  3. 清除旧的定时器。
  4. 运行新的 Effect,创建新的定时器(捕获新的 count 1)。

问题来了: 定时器被不断重置,这可能不是你想要的(比如会导致计时不准)。


3. 如何“既不撒谎,又不重置”?

可以使用 useRefuseEffectEvent。这两个工具存在的意义,就是为了合法地把变量从依赖数组里拿出来。

方法 A:使用函数式更新 (如果是 setState)

如果你只是想修改状态,不需要读取它:

useEffect(() => {
  const id = setInterval(() => {
    // ✅ 不需要依赖 count,因为 prev 永远是 React 传给你的最新值
    setCount(prev => prev + 1); 
  }, 1000);
}, []); // ✅ 空数组是安全的

方法 B:使用 useRef (逃生舱)

如果你需要读取值,但不想触发 Effect 重跑:

const countRef = useRef(count);
// 每次渲染都同步最新值
useEffect(() => { countRef.current = count });

useEffect(() => {
  const id = setInterval(() => {
    // ✅ 读 ref,永远是最新的,且 ref 不需要放进依赖
    console.log(countRef.current); 
  }, 1000);
}, []); // ✅ 安全

方法 C:使用 useEffectEvent (最新标准)

我们在上一个问题里用到的方法:

const onTick = useEffectEvent(() => {
  console.log(count); // ✅ 在这里读最新值
});

useEffect(() => {
  const id = setInterval(() => {
    onTick();
  }, 1000);
}, []); // ✅ 安全

总结

  1. 官方规则 (ESLint): 凡是用到的响应式数据(props, state, context),必须全部填入依赖数组。不要试图通过欺骗 linter (// eslint-disable) 来解决逻辑问题。

  2. 后果: 如果不填,代码会引用旧值(闭包陷阱)。

  3. 正确做法: 如果你不希望某个变量导致 Effect 重新运行,不要简单地把它从数组里删掉,而是应该:

    • useRef 把它包起来。
    • 或者用 useEffectEvent 把它隔离开。
    • 或者检查是否可以移出 Effect。

利用requestIdleCallback优化Dom的更新性能

作者 时7
2025年12月5日 10:49

概述

requestIdleCallback 是一个浏览器 API,允许在浏览器空闲时执行非关键任务,不会阻塞主线程。这对于提升用户体验和性能非常重要,特别是在处理大量 DOM 更新时。

实现

1. 工具函数 (idleCallback.ts)

创建一个完整的 requestIdleCallback 工具库,包括:

  • requestIdleCallback: 在浏览器空闲时执行回调
  • cancelIdleCallback: 取消之前调度的回调
  • IdleTaskQueue: 批量处理任务的队列类

特性

  • ✅ 自动降级: 对于不支持 requestIdleCallback 的浏览器,自动降级为 setTimeout + MessageChannel
  • ✅ 超时保护: 支持超时配置,确保任务最终会被执行
  • ✅ 批量处理: IdleTaskQueue 可以在空闲时间内批量处理多个任务
/**
 * requestIdleCallback 工具函数
 * 在浏览器空闲时执行回调,不会阻塞主线程
 * 提供降级方案以支持不支持 requestIdleCallback 的浏览器
 */

interface IdleCallbackOptions {
  timeout?: number; // 超时时间(毫秒),如果指定,回调会在超时后强制执行
}

interface IdleDeadline {
  didTimeout: boolean; // 是否因为超时而执行
  timeRemaining(): number; // 返回当前空闲时间(毫秒)
}

type IdleCallbackHandle = number;

/**
 * 在浏览器空闲时执行回调
 * @param callback 要执行的回调函数
 * @param options 配置选项
 * @returns 请求 ID,可用于取消
 */
export const requestIdleCallback = (
  callback: (deadline: IdleDeadline) => void,
  options?: IdleCallbackOptions
): IdleCallbackHandle => {
  // 如果浏览器支持原生 requestIdleCallback,直接使用
  if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
    return window.requestIdleCallback(callback, options);
  }

  // 降级方案:使用 setTimeout 模拟
  // 使用 1ms 延迟,让浏览器有机会处理其他任务
  const timeout = options?.timeout ?? 5000; // 默认 5 秒超时
  const startTime = Date.now();

  const timeoutId = setTimeout(() => {
    callback({
      didTimeout: true,
      timeRemaining: () => Math.max(0, 50 - (Date.now() - startTime)),
    });
  }, timeout);

  // 使用 MessageChannel 实现更接近 requestIdleCallback 的行为
  // MessageChannel 会在当前任务完成后、下一个任务之前执行
  const channel = new MessageChannel();
  channel.port1.onmessage = () => {
    clearTimeout(timeoutId);
    callback({
      didTimeout: false,
      timeRemaining: () => Math.max(0, 50 - (Date.now() - startTime)),
    });
  };
  channel.port2.postMessage(null);

  // 返回一个可以用于取消的 ID
  return timeoutId as unknown as IdleCallbackHandle;
};

/**
 * 取消之前通过 requestIdleCallback 调度的回调
 * @param handle 之前返回的请求 ID
 */
export const cancelIdleCallback = (handle: IdleCallbackHandle): void => {
  if (typeof window !== 'undefined' && 'cancelIdleCallback' in window) {
    window.cancelIdleCallback(handle);
  } else {
    // 降级方案:清除 setTimeout
    clearTimeout(handle as unknown as number);
  }
};

/**
 * 批量执行任务,在空闲时逐个处理
 * 适用于需要处理大量非关键任务的场景
 */
export class IdleTaskQueue {
  private tasks: Array<() => void> = [];
  private isProcessing = false;
  private currentHandle: IdleCallbackHandle | null = null;

  /**
   * 添加任务到队列
   */
  add(task: () => void): void {
    this.tasks.push(task);
    this.process();
  }

  /**
   * 批量添加任务
   */
  addBatch(tasks: Array<() => void>): void {
    this.tasks.push(...tasks);
    this.process();
  }

  /**
   * 处理队列中的任务
   */
  private process(): void {
    if (this.isProcessing || this.tasks.length === 0) {
      return;
    }

    this.isProcessing = true;
    this.currentHandle = requestIdleCallback(
      (deadline) => {
        // 在空闲时间内尽可能多地处理任务
        while (deadline.timeRemaining() > 0 && this.tasks.length > 0) {
          const task = this.tasks.shift();
          if (task) {
            try {
              task();
            } catch (error) {
              console.error('IdleTaskQueue task error:', error);
            }
          }
        }

        // 如果还有任务未处理,继续调度
        if (this.tasks.length > 0) {
          this.isProcessing = false;
          this.process();
        } else {
          this.isProcessing = false;
        }
      },
      { timeout: 5000 } // 5 秒超时,确保任务最终会被执行
    );
  }

  /**
   * 清空队列
   */
  clear(): void {
    this.tasks = [];
    if (this.currentHandle !== null) {
      cancelIdleCallback(this.currentHandle);
      this.currentHandle = null;
    }
    this.isProcessing = false;
  }

  /**
   * 获取队列中剩余任务数
   */
  get length(): number {
    return this.tasks.length;
  }
}

2. DOM 更新优化

优化前:

// 使用 setTimeout 延迟更新
this.timmer = setTimeout(() => {
  patch(this.vNode, vNode);
  // ...
}, DOM_UPDATE_DELAY_MS);

优化后:

// 使用 requestIdleCallback 在浏览器空闲时更新
this.idleCallbackHandle = requestIdleCallback(
  (deadline) => {
    // 检查是否有足够的时间
    if (deadline.timeRemaining() < 5 && !deadline.didTimeout) {
      // 重新调度到下一个空闲周期
      this.idleCallbackHandle = requestIdleCallback(/* ... */);
      return;
    }
    this.performDomUpdate(deadline);
  },
  { timeout: DOM_UPDATE_DELAY_MS }
);

优势:

  • 🚀 不阻塞主线程: DOM 更新在浏览器空闲时执行
  • 🎯 智能调度: 如果当前空闲时间不够,自动延迟到下一个空闲周期
  • ⏱️ 超时保护: 即使浏览器一直忙碌,也会在超时后执行

3. 批量更新优化

优化前:

// 场景:数据变化时,更新对应的dom显示
handleHeartbeat = (uploader: FileUploader): void => {
  for (let i = 0; i < uploader.uploadingTaskList.length; i++) {
    const taskItem = uploader.uploadingTaskList[i];
    const item = this.itemsMap.get(taskItem.id);
    if (item) {
      item.updateDom(taskItem); // 立即执行
    }
  }
};

优化后:

// 批量收集更新任务,在空闲时执行
handleHeartbeat = (uploader: FileUploader): void => {
  const updateTasks: Array<() => void> = [];

  for (let i = 0; i < uploader.uploadingTaskList.length; i++) {
    const taskItem = uploader.uploadingTaskList[i];
    const item = this.itemsMap.get(taskItem.id);
    if (item) {
      updateTasks.push(() => {
        item.updateDom(taskItem);
      });
    }
  }

  // 批量添加到空闲任务队列
  if (updateTasks.length > 0) {
    this.idleUpdateQueue.addBatch(updateTasks);
  }
};

优势:

  • 📦 批量处理: 多个 DOM 更新在同一个空闲周期内批量处理
  • ⚡ 减少重排: 减少浏览器重排/重绘次数
  • 🎯 优先级管理: 非关键更新不会阻塞关键操作

使用场景

适合使用 requestIdleCallback 的场景

  1. ✅ DOM 更新(非关键)

    • 进度条更新
    • 状态显示更新
    • 统计数据展示
  2. ✅ 数据统计/分析

    • 上传速度计算
    • 进度统计
    • 性能指标收集
  3. ✅ 预加载/预取

    • 预加载下一个文件
    • 预计算 MD5

不适合使用 requestIdleCallback 的场景

  1. ❌ 用户交互响应

    • 点击事件处理
    • 输入事件处理
    • 必须立即响应的操作
  2. ❌ 关键路径操作

    • 文件上传请求
    • 错误处理
    • 状态变更通知

性能收益

预期改进

  1. 主线程阻塞减少: DOM 更新不再阻塞主线程,提升页面响应性
  2. 帧率提升: 减少不必要的重排/重绘,提升动画流畅度
  3. CPU 使用优化: 在浏览器空闲时执行任务,更好地利用 CPU 资源
  4. 用户体验提升: 页面更流畅,交互更及时

实际测试建议

  1. Chrome DevTools Performance: 检查主线程阻塞情况
  2. FPS 监控: 监控帧率变化
  3. Lighthouse: 运行性能测试
  4. 真实场景测试: 测试大量文件上传时的性能

浏览器兼容性

原生支持

  • ✅ Chrome 47+
  • ✅ Edge 79+
  • ✅ Firefox 55+
  • ✅ Safari 不支持(需要降级方案)

降级方案

我们的实现自动提供了降级方案:

  • 使用 MessageChannel + setTimeout 模拟 requestIdleCallback
  • 确保所有浏览器都能正常工作
  • 性能可能略低于原生实现,但仍然比直接执行更好

最佳实践

1. 合理设置超时时间

// 对于关键更新,设置较短的超时时间
requestIdleCallback(callback, { timeout: 100 });

// 对于非关键更新,可以设置较长的超时时间
requestIdleCallback(callback, { timeout: 5000 });

2. 检查空闲时间

requestIdleCallback((deadline) => {
  // 如果时间不够,延迟执行
  if (deadline.timeRemaining() < 5 && !deadline.didTimeout) {
    // 重新调度
    return;
  }

  // 执行任务
  performTask();
});

3. 批量处理任务

// 使用 IdleTaskQueue 批量处理
const queue = new IdleTaskQueue();
queue.addBatch([
  () => updateTask1(),
  () => updateTask2(),
  () => updateTask3(),
]);

4. 清理资源

// 组件销毁时清理
destroy() {
  if (this.idleCallbackHandle) {
    cancelIdleCallback(this.idleCallbackHandle);
  }
  this.idleUpdateQueue.clear();
}

注意事项

  1. 超时时间: 设置合理的超时时间,确保任务最终会被执行
  2. 任务大小: 避免在单个空闲周期内执行过大的任务
  3. 错误处理: 确保任务中的错误不会影响后续任务
  4. 内存管理: 及时清理不再需要的回调句柄

未来优化方向

  1. IntersectionObserver: 结合使用,只更新可见区域的任务
  2. Web Workers: 将计算密集型任务移到 Worker 线程
  3. 虚拟滚动: 对于大量任务列表,使用虚拟滚动优化
  4. 增量更新: 只更新变化的部分,而不是整个 DOM

深入解析 OOP 考题之 EditInPlace 类:从零开始掌握面向对象编程实战

作者 AAA阿giao
2025年12月5日 10:47

引言:什么是“就地编辑”?

想象一下你在使用某社交平台时,点击自己的昵称,它立刻变成一个输入框,你可以直接修改并保存。这种交互方式叫做 “就地编辑”(Edit in Place) —— 不需要跳转页面或弹出新窗口,直接在当前位置完成编辑。

这看似简单的小功能,其实蕴含了非常典型的 面向对象编程(OOP) 思想。今天我们就通过一道经典的 OOP 考题——EditInPlace 类,手把手带你从一行代码都不懂的新手,成长为能写出专业级封装代码的开发者。

我们将 逐行解析 edit_in_place.js 文件中的每一行代码,彻底讲透这个类是如何工作的。


二、项目结构概览

  • index.html:空页面(作为挂载点,实际使用时会引入 JS)
  • edit_in_place.js:核心实现文件,包含完整的 EditInPlace
edit-in-place/
├── index.html
├── edit_in_place.js

我们的重点就是 edit_in_place.js。下面我们将逐行拆解它。


三、完整代码 + 逐行详细注释

/**
 * @func EditInPlace 就地编辑
 * @params {string} value 初始值
 * @params {element} parentElement 挂载点
 * @params {string} id 自身ID
 */
function EditInPlace(id, value, parentElement) {
    // 此时 this 指向一个全新的空对象 {}
    // 构造函数的作用:初始化实例的属性

    this.id = id; // 给实例设置唯一 ID,用于 DOM 元素标识
    this.value = value || '这个家伙很懒,什么都没有留下'; // 如果传入 value 为假值(如 null/undefined),则使用默认文本
    this.parentElement = parentElement; // 记录父容器,后续要把编辑区域挂载到它里面

    // 预先声明所有将要使用的 DOM 元素引用,初始为 null(良好习惯:提前声明变量)
    this.containerElement = null; // 最外层容器 div
    this.saveButton = null;       // “保存”按钮
    this.cancelButton = null;     // “取消”按钮
    this.fieldElement = null;     // 输入框 input
    this.staticElement = null;    // 静态文本 span

    // 按功能拆分逻辑:先创建 DOM 元素
    this.createElement(); 
    // 再绑定事件监听器
    this.attachEvent();
}

构造函数解析

  • 这是一个 构造函数(不是 ES6 的 class,而是传统基于函数的 OOP 写法)。

  • 当你写 new EditInPlace(...) 时,JavaScript 会:

    1. 创建一个空对象 {}
    2. this 指向这个空对象;
    3. 执行函数体内的代码,给 this 添加属性;
    4. 返回这个对象。

提示this 在这里代表“当前正在创建的编辑器实例”。


EditInPlace.prototype = {
    // 封装了DOM操作
    createElement: function() {
        // 创建最外层容器 <div>
        this.containerElement = document.createElement('div');
        
        // 给容器设置 ID(方便调试或 CSS 样式控制)
        this.containerElement.id = this.id;

        // 创建静态文本显示区域 <span>
        this.staticElement = document.createElement('span');
        this.staticElement.innerHTML = this.value; // 显示初始值
        
        // 把 span 挂到容器里
        this.containerElement.appendChild(this.staticElement);

        // 创建输入框 <input type="text">
        this.fieldElement = document.createElement('input');
        this.fieldElement.type = 'text';
        this.fieldElement.value = this.value; // 输入框也显示初始值
        this.containerElement.appendChild(this.fieldElement); // 挂到容器

        // 把整个容器挂到用户指定的父元素中(比如 body 或某个 div)
        this.parentElement.appendChild(this.containerElement);

        // 创建“保存”按钮
        this.saveButton = document.createElement('input');
        this.saveButton.type = 'button';
        this.saveButton.value = '保存';
        this.containerElement.appendChild(this.saveButton);

        // 创建“取消”按钮
        this.cancelButton = document.createElement('input');
        this.cancelButton.type = 'button';
        this.cancelButton.value = '取消';
        this.containerElement.appendChild(this.cancelButton);

        // 初始化时,显示文本,隐藏输入框和按钮
        this.convertToText(); // 切换到文本显示状态
    },

createElement 方法详解

  • 所有 DOM 元素都在内存中创建(不会闪屏)。

  • 结构如下(逻辑上):

    <div id="xxx">
      <span>初始文本</span>
      <input type="text" value="初始文本" style="display:none">
      <input type="button" value="保存" style="display:none">
      <input type="button" value="取消" style="display:none">
    </div>
    
  • 最后调用 convertToText(),确保一开始只显示 <span>,其他都隐藏。


    convertToText: function() {
        // 隐藏编辑相关元素
        this.fieldElement.style.display = 'none';   // 隐藏输入框
        this.saveButton.style.display = 'none';     // 隐藏保存按钮
        this.cancelButton.style.display = 'none';   // 隐藏取消按钮
        
        // 显示静态文本
        this.staticElement.style.display = 'inline'; // 显示 span
    },

    convertToField: function() {
        // 隐藏静态文本
        this.staticElement.style.display = 'none';
        
        // 设置输入框的值(防止用户多次点击导致旧值残留)
        this.fieldElement.value = this.value;
        
        // 显示编辑相关元素
        this.fieldElement.style.display = 'inline';
        this.saveButton.style.display = 'inline';
        this.cancelButton.style.display = 'inline';
    },

状态切换机制

  • 两种状态

    • 文本状态(只看不改)→ 调用 convertToText
    • 编辑状态(可输入)→ 调用 convertToField
  • 通过 style.display 控制元素显隐,这是最简单的 UI 状态管理方式。

  • 注意:convertToField 中重新赋值 this.fieldElement.value = this.value 是为了防止用户取消后再次进入时看到错误内容。


    attachEvent: function() {
        // 点击静态文本 → 进入编辑模式
        this.staticElement.addEventListener('click', () => {
            this.convertToField(); // 切换到输入框显示状态
        });

        // 点击“保存”按钮 → 保存数据
        this.saveButton.addEventListener('click', () => {
            this.save();
        });

        // 点击“取消”按钮 → 放弃修改
        this.cancelButton.addEventListener('click', () => {
            this.cancel();
        });
    },

事件绑定

  • 使用 箭头函数 () => {} 是为了 保持 this 指向当前实例

    • 如果用普通函数 function() {}this 会指向触发事件的 DOM 元素,导致错误!
  • 三个事件分别对应三种用户行为:

    • 点文本 → 编辑
    • 点保存 → 存新值
    • 点取消 → 恢复原状

    save: function() {
        // 获取用户输入的新值
        var value = this.fieldElement.value;
        
        // 【重要】这里可以加 fetch 发送到后端(题目提到但未实现)
        // fetch('/api/update', { method: 'POST', body: JSON.stringify({id: this.id, value}) });
        
        // 更新内部状态
        this.value = value;
        // 同步更新静态文本内容
        this.staticElement.innerHTML = value;
        // 切回文本显示状态
        this.convertToText();
    },

    cancel: function() {
        // 直接切回文本状态,不保存任何更改
        this.convertToText();
    }
};

保存与取消逻辑

  • save()

    • 读取输入框的值;
    • 更新实例的 this.value(这是“真实数据源”);
    • 同步更新 <span> 的显示内容;
    • 切回只读状态。
  • cancel()

    • 不做任何数据修改,直接隐藏输入框,恢复原样。

四、如何使用这个类?(实战演示)

我们需要编写一个完整的 HTML 页面来加载并使用 EditInPlace 类。下面这个示例就是最标准的用法——它展示了如何在真实网页中“实例化”一个就地编辑组件。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!-- 
    这是一个空的 div 容器,id 为 "app"。
    它的作用是作为 EditInPlace 组件的“挂载点”——
    所有动态生成的编辑区域(文本、输入框、按钮)都会被插入到这个 div 内部。
  -->
  <div id="app"></div>

  <!-- 
    引入我们编写的 EditInPlace 类定义文件。
    浏览器会先加载并解析这个 JS 文件,使 EditInPlace 构造函数在全局可用。
  -->
  <script src="./edit_in_place.js"></script>

  <!-- 
    下面的 <script> 标签用于执行实际的业务逻辑:
    创建一个 EditInPlace 实例,并将其挂载到 #app 容器中。
  -->
  <script>
  // 注释说明:这里正在使用面向对象编程(OOP)的方式创建一个可编辑组件实例

  // 调用构造函数 new EditInPlace(...),传入三个必要参数:
  const ep = new EditInPlace(
    'slogan',                     // 参数1: id —— 用于给生成的容器 div 设置唯一 ID(如 <div id="slogan">)
    '有了肯德基,生活好滋味',   // 参数2: value —— 初始显示的文本内容
    document.getElementById('app') // 参数3: parentElement —— 指定要把编辑器挂载到哪个 DOM 元素内(这里是 id="app" 的 div)
  );

  // 在浏览器控制台打印出刚创建的实例 ep
  // 你可以打开开发者工具(F12),在 Console 面板看到这个对象
  // 它包含所有属性(id, value, containerElement...)和方法(save, cancel, createElement...)
  console.log(ep);
  </script>
</body>
</html>

使用流程总结

  1. 准备容器:HTML 中提供一个空的 <div id="app"></div> 作为“插槽”。

  2. 引入脚本:通过 <script src="./edit_in_place.js"> 加载类定义。

  3. 创建实例:调用 new EditInPlace(...),传入:

    • 唯一 ID(用于内部 DOM 标识),
    • 初始文本,
    • 挂载目标(父元素)。
  4. 自动渲染:构造函数内部会自动调用 createElement()attachEvent(),完成 DOM 创建与事件绑定。

  5. 调试辅助console.log(ep) 让你可以在控制台查看整个对象结构,验证是否创建成功。

效果预览

当你在浏览器中打开这个 HTML 文件时,你会看到:

  • 页面上显示一行文字:“有了肯德基,生活好滋味”
  • 点击这行字 → 出现一个输入框,里面是同样的文字,旁边有“保存”和“取消”按钮
  • 修改文字后点击“保存” → 文字更新;点击“取消” → 恢复原样

五、本例中使用的所有 DOM 操作方法详解

EditInPlace 类的实现中,我们用到了多个原生 JavaScript 的 DOM(文档对象模型)操作方法。下面将它们集中整理并逐个解释,帮助新手彻底理解这些 API 的作用和用法。

📌 DOM 是什么?
DOM(Document Object Model)是浏览器将 HTML 文档解析成的一棵“树”,每个标签都是一个“节点”(Node)。JavaScript 可以通过 DOM API 增删改查这些节点,从而动态控制网页内容。


5.1 document.createElement(tagName)

const div = document.createElement('div');
  • 作用:在内存中创建一个新的 HTML 元素节点(不显示在页面上)。
  • 参数tagName 是标签名,如 'div''span''input'
  • 返回值:一个 HTMLElement 对象(如 HTMLDivElement)。
  • 注意:此时元素尚未挂载到页面,需后续用 appendChild 添加。

用途:构建组件的内部结构(容器、输入框、按钮等)。


5.2 element.appendChild(childElement)

this.containerElement.appendChild(this.staticElement);
  • 作用:将一个子节点添加到父节点的末尾。
  • 参数childElement 是通过 createElement 创建的元素。
  • 效果:元素被插入到真实 DOM 树中,用户可见。

用途:组装组件结构,最终将整个编辑器挂载到页面。


5.3 element.innerHTML

this.staticElement.innerHTML = this.value;
  • 作用:设置或获取元素内部的 HTML 内容。

  • 特点

    • 可解析 HTML 标签(如 <b>加粗</b> 会生效);
    • 若只处理纯文本,建议使用 textContent(更安全)。
  • 本例用途:将字符串(如“点击我编辑”)显示在 <span> 中。


5.4 element.style.display

this.fieldElement.style.display = 'none';
this.staticElement.style.display = 'inline';
  • 作用:控制元素的显示与隐藏。

  • 常用值

    • 'none':完全隐藏,不占空间;
    • 'block':块级显示(如 div);
    • 'inline':行内显示(如 span、input);
    • 'inline-block':行内块(兼顾宽高和同行排列)。
  • 本例用途:在“文本状态”和“编辑状态”之间切换 UI。

优势:简单直接,适合小型交互。


5.5 element.addEventListener(eventType, handler)

this.staticElement.addEventListener('click', () => {
    this.convertToField();
});
  • 作用:为元素绑定事件监听器。

  • 参数

    • eventType:事件类型,如 'click''input''keydown'
    • handler:回调函数,事件触发时执行。
  • 本例用途

    • 点击文本 → 进入编辑;
    • 点击按钮 → 保存或取消。

💡 关键技巧:使用箭头函数 () => {} 可确保 this 指向 EditInPlace 实例,而非触发事件的 DOM 元素。


5.6 element.id = id

this.containerElement.id = this.id;
  • 作用:设置元素的 id 属性。

  • 用途

    • 方便 CSS 样式定位;
    • 便于调试(在开发者工具中快速查找);
    • 确保多个实例 ID 不冲突(由用户传入唯一 ID)。

5.7 input.value

this.fieldElement.value = this.value;
var value = this.fieldElement.value;
  • 作用:获取或设置 <input><textarea> 的当前值。

  • 注意:这是属性(property) ,不是 getAttribute('value')(那是初始值)。

  • 本例用途

    • 初始化输入框内容;
    • 读取用户修改后的新值。

DOM 操作全景图

方法 / 属性 用途 是否改变页面
createElement 创建元素(内存中)
appendChild 插入元素到页面
innerHTML 设置元素内容
style.display 控制显隐
addEventListener 绑定交互行为 —(响应式)
element.id 设置唯一标识
input.value 读写输入值 —(数据层)

掌握这些基础 DOM API,你就具备了用原生 JavaScript 构建动态交互组件的能力,无需依赖任何框架!


六、常见问题解答

Q1:为什么不用 class 语法?

A:这道题考察的是 原型链和构造函数 的理解。ES6 的 class 本质仍是基于原型的语法糖。掌握底层机制更重要。

Q2:this 为什么会指向实例?

A:因为用了 new 关键字。new 会自动创建对象并绑定 this

Q3:能不能多个编辑器共存?

A:完全可以!每个实例都有自己的 idvalue 和 DOM 元素,彼此隔离。

Q4:如何支持多行文本?

A:把 input 换成 textarea,并调整样式即可。这是很好的扩展练习!


为什么这是 OOP 的典范?

关键词 本例体现
封装 所有逻辑藏在类内部,外部只需 new EditInPlace(...) 即可使用
复用 可在多个地方创建多个实例,互不影响
模块化 整个类在一个文件中,独立、可移植
隐藏实现细节 用户不需要知道 DOM 如何操作,只需调用即可
编写注释 函数开头有 JSDoc 注释,说明参数和用途
拿来就用 类的使用者和编写者可以是不同人

 OOP 核心思想:把“数据”和“操作数据的方法”打包在一起,形成一个独立的对象。


当然可以!以下是为你的博客文章量身打造的一段生动有趣、鼓舞人心又带点幽默感的结语,既呼应全文主题,又能让读者会心一笑、印象深刻:


结语:你写的不是代码,是“魔法盒子”

恭喜你!
走到这里,你已经亲手打造了一个会“变身”的小精灵——点一下变输入框,再点一下变回文字,还能记住你说过的话。它不靠框架,不靠魔法咒语,只靠一行行清晰、封装良好的 JavaScript 代码。

这,就是面向对象编程的魅力:
把复杂藏起来,把简单交出去。

未来的你,可能会用 React 写组件,用 Vue 做响应式,甚至让 AI 自动生成代码。但请永远记得今天这个小小的 EditInPlace——它教会你的不是“怎么写”,而是“怎么想”。

当你能像搭积木一样,把功能封装成独立、可复用、有名字的“盒子”,你就不再是代码搬运工,而是用户体验的建筑师交互逻辑的导演,甚至是产品灵魂的塑造者

世界正等着被你“就地编辑”得更美好 🌟

❌
❌