普通视图

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

当豆包手机刷屏时,另一场“静悄悄”的变革已经在你手机里发生

作者 FinClip
2025年12月5日 10:13

这两天,不少人的朋友圈被一款叫“豆包手机”的产品刷屏了。 

它最吸引人的地方,是手机里住进了一个“AI小助理”。

你只需要告诉它需求,它就能像人一样帮你操作APP、处理事务——定会议、回消息、整理文件,一句话搞定。 

很多人感叹:“手机真的越来越聪明了。” 

但如果你认为这只是一款手机的创新,那或许忽略了背后更重要的趋势:AI正在从“云端问答”走向“手机本地行动”。

而这,恰恰揭示了一个更深刻、更紧迫的需求—— 如果网络中断,你的AI还能继续工作吗?如果涉及敏感信息,你敢把数据交给云端吗?

离线,才是AI真正的“成人礼”

想象一个常见场景:飞机进入平飞,你打开飞行模式,准备用手机处理积压的公务。这时才突然意识到——所有需要联网的AI助手,瞬间“休眠”。 

或者,当你身处野外、地下室、保密会议室……网络受限,但工作不能停。 

这正是豆包手机启示我们的下一个问题:AI若不能离线运行,就永远无法成为真正的“个人助理”。 

基于以上难题,凡泰极客推出AI落地技术——FinClip Chatkit,它让AI的“离线智能”不再是想象。

图片

FinClip Chatkit

装在你APP里的“离线AI引擎”

与豆包手机的思路不同,FinClip Chatkit不是一个硬件,而是一个能嵌入任何手机APP的AI能力引擎,具备深度上下文感知,流式生产生成UI的交互体验。 

它的核心突破非常简单,却至关重要,就是:无缝切换“本地小模型”与“云端大模型”。

联网时,调用强大的云端大模型,处理复杂问题; 

断网时,自动切换为手机本地的轻量模型,继续推理、执行任务。 

这意味着什么? 

对于经常出差的商务人士:航班上,你依然可以用公司APP,让本地AI帮你撰写邮件草稿、分类整理单据、生成报表摘要。 

对于警务、政务、国防人员:在野外、涉密环境,无需担忧数据外泄,依然能通过内部APP进行高效的信息查询、报告生成、决策辅助。 

对于所有企业:可以构建完全自主可控的AI应用,核心数据永不离开手机或内网,满足最高级别的隐私合规要求。 

图片

你可能会好奇:本地小模型,能力够吗? 事实上,经过针对性训练的小模型,在特定任务(如文档理解、流程操作、数据归纳)上表现非常出色,且响应速度极快、功耗极低。更重要的是,它解决了三大痛点: 

  1. 隐私保障:敏感数据无需上传云端,从根本上杜绝泄露风险。 

  2. 持续可用:无网、弱网环境,服务不中断。 

  3. 成本可控:大量日常任务由本地处理,大幅降低云端算力成本。

未来的APP,都应该“自带AI大脑”

豆包手机的火爆,预示着一个“AI原生”设备时代的到来。而FinClip Chatkit则指向了更普适、更敏捷的路径:不换手机,只需升级你的APP。 

任何企业,都可以在不更换用户设备的前提下,为自己的APP注入“离线AI能力”,让用户在任何环境下,都能享受智能、连贯的服务体验。 这不仅是技术的进步,更是一种思维的重塑:AI不应是遥远的云服务,而应成为握在用户手中,随时待命、永远可信的伙伴。

关于FinClip Chatkit

Chatkit是凡泰极客FinClip超级应用智能平台的一次重大能力升级,它能助力企业构建具备深度上下文感知、流式生成原生UI的超级App。

响应式记录

2025年12月5日 10:05

源码中的TS类型记录

类型谓词is的使用(TS类型收窄方式)

  • TS类型收窄方式

deepseek_mermaid_20251204_b97591.png类型谓词是“用户自定义类型保护”中唯一且核心的语法形式

IfAny

typescript

export type UnwrapRef<T> = IfAny<T, T, 
  T extends Ref<infer V> 
    ? UnwrapRefSimple<V> 
    : UnwrapRefSimple<T>
>

// 如果没有 IfAny 保护:
// UnwrapRef<any> 会进行递归解包,可能导致深层嵌套的类型计算
// 有了 IfAny,直接返回 any,更高效且符合直觉

为了更全面地理解 IfAny 的行为,将其与TypeScript中其他特殊类型进行对比测试:

测试类型 1 & T 0 extends 1 & T IfAny<T, 'Y', 'N'> 说明
any any true 'Y' 目标检测类型
unknown 1 false 'N' 与 any 不同,unknown 更安全
never never false 'N' 空类型
1 never false 'N' 字面量类型
string never false 'N' 普通类型
any[] any true 'Y' 数组包含 any 元素,整个类型被视为 any
{ x: any } any true 'Y' 对象包含 any 属性,整个类型被视为 any

重要发现IfAny 不仅检测纯粹的 any,也检测包含 any 的复合类型(如 any[]{x: any})。这是因为 any 的“传染性”在交叉类型中会传播。

any & T 的结果总是 any,无论 T 是什么类型。这是 any 类型的特殊“传染性”。

为什么[T] extends [Ref]不写成T extends Ref

  • 核心区别:是否触发“分布式条件类型”
  • 这是 TypeScript 条件类型的一个高级特性。当 extends 左侧是裸类型参数时,会触发“分布式条件类型”,否则不会。

typescript

// 情况1:裸类型参数 - 触发分布式
type Test1<T> = T extends Ref ? 'Yes' : 'No'
// 当 T 是联合类型时,会分别检查每个成员

// 情况2:包裹类型参数 - 不触发分布式  
type Test2<T> = [T] extends [Ref] ? 'Yes' : 'No'
// 将 T 视为一个整体检查

Ref

一张图解释vue3中的响应式逻辑

deepseek_mermaid_20251204_4504f7.png

从图中可以看出,Ref<T> 是最通用的响应式容器,既能处理基础类型,也能处理对象类型。而 Reactive<T> 专门处理对象,ComputedRef<T> 是特殊的只读 Ref<T>

 实际使用中的类型特性

1. 自动解包(模板中)

vue

<template>
  <!-- 模板中自动解包,不需要 .value -->
  <div>{{ count }}</div> <!-- 显示 0,而不是 {value: 0} -->
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0) // Ref<number>
</script>
2. 响应式类型守卫

typescript

import { ref, isRef, unref } from 'vue'

function processValue(input: number | Ref<number>) {
  // 类型守卫:判断是否为 Ref
  if (isRef(input)) {
    // 此处 TypeScript 知道 input 是 Ref<number>
    return input.value * 2
  }
  // 此处 TypeScript 知道 input 是 number
  return input * 2
  
  // 或者使用 unref 自动解包
  const value = unref(input) // number
}
3. 在响应式对象中的自动解包【重要】

typescript

import { reactive, ref } from 'vue'

const count = ref(0)
const obj = reactive({
  count, // 自动解包为 number
  normal: 1
})

console.log(obj.count) // 0 (number,不是 Ref<number>)
console.log(obj.normal) // 1

4. 源码分析

function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

/**
 * @internal
 */
class RefImpl<T = any> {
  _value: T
  private _rawValue: T

  dep: Dep = new Dep()

  public readonly [ReactiveFlags.IS_REF] = true
  public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false

  constructor(value: T, isShallow: boolean) {
    this._rawValue = isShallow ? value : toRaw(value)
    this._value = isShallow ? value : toReactive(value)
    this[ReactiveFlags.IS_SHALLOW] = isShallow
  }

  get value() {
    if (__DEV__) {
      this.dep.track({
        target: this,
        type: TrackOpTypes.GET,
        key: 'value',
      })
    } else {
      this.dep.track()
    }
    return this._value
  }

  set value(newValue) {
    const oldValue = this._rawValue
    const useDirectValue =
      this[ReactiveFlags.IS_SHALLOW] ||
      isShallow(newValue) ||
      isReadonly(newValue)
    newValue = useDirectValue ? newValue : toRaw(newValue)
    if (hasChanged(newValue, oldValue)) {
      this._rawValue = newValue
      this._value = useDirectValue ? newValue : toReactive(newValue)
      if (__DEV__) {
        this.dep.trigger({
          target: this,
          type: TriggerOpTypes.SET,
          key: 'value',
          newValue,
          oldValue,
        })
      } else {
        this.dep.trigger()
      }
    }
  }
}
  • 总结:ref的对于基本类型的响应式处理就是包装成了一个具有value属性的对象,在访问和值变化的时候进行依赖收集和触发更新;即.value 只是普通对象属性 + getter/setter
  • 这种设计非常巧妙:
    • 具有统一性
// ref 可以包装任何类型,API 统一
const num = ref(0)           // Ref<number>
const obj = ref({ x: 1 })    // Ref<{x: number}>(内部用 reactive 包装)
const arr = ref([1, 2, 3])   // Ref<number[]>

// 都是 .value 访问,心智负担小
  • 可预测性
const count = ref(0)

// 明确的触发点:只有 .value 赋值会触发更新
count.value = 1  // ✅ 触发
count.value++    // ✅ 触发

// 没有意外的触发
const obj = ref({ x: 1 })
obj.value.x = 2  // ❌ 不触发(除非是 reactive 包装的深层 ref)
  • 类型安全
// TypeScript 完美支持
const count = ref(0)  // 推断为 Ref<number>
count.value = "hello" // ❌ 类型错误:不能将 string 赋值给 number

// 泛型支持
const maybe = ref<number | null>(null)  // Ref<number | null>
maybe.value = 42      // ✅
maybe.value = null    // ✅  
maybe.value = "text"  // ❌
  • 从Vue3 Ref的这段源码可以学习到类的分层级的访问控制策略

deepseek_mermaid_20251204_070071.png

  • 为什么这样分层设计?
  1. _rawValue 最严格

    • 存储原始值用于 hasChanged() 比较
    • 如果被外部修改,响应式系统会完全失效
    • 必须用 private 绝对保护
  2. _value 较宽松

    • 存储响应式值(可能是 reactive() 代理)
    • 外部访问可能看到代理对象,但不破坏核心逻辑
    • 开发工具可能需要检查这个值
  3. dep 完全隐藏

    • 实现机制,不是数据模型
    • 用户永远不需要直接操作依赖关系
    • 通过不导出类来彻底隐藏

理解洋葱模型

作者 颜酱
2025年12月5日 10:05

洋葱模型

洋葱模型(Onion Model)是中间件执行的核心逻辑模式,特点是请求先逐层穿过外层中间件到达核心逻辑,再反向逐层穿过外层中间件返回,形似洋葱的层级结构。每个中间件既可以处理 “进入” 阶段的逻辑(如前置校验),也可以处理 “返回” 阶段的逻辑(如结果处理)。

假设存在两个中间件 A 和 B,以及核心逻辑 C,执行顺序如下:进入 A → 进入 B → 执行 C → 离开 B → 离开 A 整个流程像剥洋葱一样,从外层到内层,再从内层回到外层。

代码实现洋葱模型

executeOnion 函数使用递归和闭包实现了中间件的 “逐层进入、逐层返回” 逻辑,核心是 next() 函数的闭包特性—— 它能记住当前执行到的中间件索引(index),每次调用 next() 就 “推进” 到下一个中间件,直到所有中间件执行完毕后触发核心逻辑。

  • executeOnion 函数是洋葱模型的核心,它接受三个参数:middlewares 中间件数组,core 核心逻辑,ctx 全局上下文对象,然后就是记录index和执行next函数
  • indexexecuteOnion 函数作用域内的变量,next() 作为闭包能持续访问和修改它 —— 每次调用 next()index 就会递增,确保中间件按顺序执行
  • next 函数是每次调用都“消费”一个中间件,每次调用 next()index 就会递增,确保中间件按顺序执行,当 index 达到中间件长度时,执行核心逻辑, 核心逻辑 core 是最后一个中间件,当所有中间件执行完后,执行核心逻辑
  • 每个中间件是一个函数,常用参数是ctxnextctx 是用于在中间件之间、中间件与核心逻辑之间传递数据(如请求参数、状态、结果),避免使用全局变量,保证数据隔离;next 用于触发下一个中间件或核心逻辑,是实现 “逐层进入、逐层返回” 的关键。中间件参数本质是解决数据共享和流程串联。
  • await next() 的核心作用是启动整个洋葱流程,index 的递增是由 next() 函数内部的逻辑完成的。await next()是 “触发按钮”,而 index 递增是 “按钮按下后内部的机械动作”—— 按钮本身不直接推动 index,但按下按钮会触发推动 index 的逻辑。
// 洋葱模型执行器:递归串联中间件和核心逻辑
const executeOnion = async (middlewares, core, ctx) => {
  let index = 0; // 记录当前执行到的中间件下标(闭包变量),

  // 定义next函数:每次调用都“消费”一个中间件,next函数是一个async函数,所以可以await下一个中间件或核心逻辑
  const next = async () => {
    if (index < middlewares.length) {
      // 1. 取出当前下标对应的中间件
      const currentMiddleware = middlewares[index];
      // 2. 下标+1,为下一次调用next()做准备
      index++;
      // 3. 执行当前中间件,并把ctx和next传给它
      await currentMiddleware(ctx, next);
    } else {
      // 4. 所有中间件执行完后,执行核心逻辑
      await core(ctx);
    }
  };

  // 启动:第一次调用next(),开始执行第一个中间件
  await next();
};

// 定义中间件数组
const middlewares = [
  // 中间件1:日志记录
  async (ctx, next) => {
    console.log('中间件1 - 进入');
    await next(); // 执行下一个中间件/核心逻辑
    console.log('中间件1 - 离开');
  },

  // 中间件2:耗时统计
  async (ctx, next) => {
    const start = Date.now();
    console.log('中间件2 - 进入');
    await next(); // 执行下一个中间件/核心逻辑
    const end = Date.now();
    console.log(`中间件2 - 离开,耗时:${end - start}ms`);
  },
];

// 核心逻辑
const coreLogic = async (ctx) => {
  console.log('执行核心逻辑');
  ctx.result = '核心逻辑结果';
};

// 测试执行
const ctx = {};
executeOnion(middlewares, coreLogic, ctx).then(() => {
  console.log('最终结果:', ctx.result);
});

// 输出:
// 中间件1 - 进入
// 中间件2 - 进入
// 执行核心逻辑
// 中间件2 - 离开,耗时:xms
// 中间件1 - 离开
// 最终结果:核心逻辑结果

详细的执行过程

// 初始状态:index = 0
await next(); // 第一次调用 next()

// next() 内部执行:
// 1. index=0 < 2 → 取出中间件1
// 2. index++ → index=1
// 3. 执行中间件1:console.log('中间件1进') → 调用 await next()(第二次调用 next())

// 第二次调用 next() 内部:
// 1. index=1 < 2 → 取出中间件2
// 2. index++ → index=2
// 3. 执行中间件2:console.log('中间件2进') → 调用 await next()(第三次调用 next())

// 第三次调用 next() 内部:
// 1. index=2 ≥ 2 → 执行核心逻辑
// 2. 核心逻辑执行完,回到中间件2的 await next() 之后 → console.log('中间件2出')
// 3. 中间件2执行完,回到中间件1的 await next() 之后 → console.log('中间件1出')
// 4. 中间件1执行完,回到第一次 await next() 之后 → 整个流程结束

深度理解中间件的代码,把中间件 1 的代码拆成三步看:

async (ctx, next) => {
  // 第一步:进入中间件1,先执行“进入”逻辑
  console.log('中间件1 - 进入');

  // 第二步:调用next(),触发后续所有逻辑(中间件2 → 核心逻辑)
  // 这里的await会“暂停”中间件1的执行,直到next()对应的Promise完成
  await next();

  // 第三步:只有等next()的所有后续逻辑执行完,才会走到这里
  console.log('中间件1 - 离开');
};

关键:await next() 的 “暂停 - 恢复” 机制

  • 暂停:当执行到 await next() 时,中间件 1 的执行会暂停,JavaScript 引擎会去执行 next() 指向的逻辑(中间件 2);
  • 递归触发:中间件 2 里也有 await next(),会继续暂停中间件 2,触发核心逻辑;
  • 恢复:核心逻辑执行完后,中间件 2 的 await next() 完成,继续执行中间件 2 的后续代码(“中间件 2 - 离开”);中间件 2 执行完后,中间件 1 的 await next() 才完成,继续执行中间件 1 的后续代码(“中间件 1 - 离开”)。

把中间件的执行逻辑用嵌套函数模拟,会更直观:

// 模拟中间件1的执行
const middleware1 = async () => {
  console.log('中间件1 - 进入');

  // 模拟await next():执行中间件2
  await middleware2();

  console.log('中间件1 - 离开');
};

// 模拟中间件2的执行
const middleware2 = async () => {
  console.log('中间件2 - 进入');

  // 模拟await next():执行核心逻辑
  await coreLogic();

  console.log('中间件2 - 离开');
};

// 模拟核心逻辑
const coreLogic = async () => {
  console.log('执行核心逻辑');
};

// 启动执行
middleware1();

await next() 就像 “打开一扇门进入内层”,只有等内层的所有事情(后续中间件、核心逻辑)全部办完,门才会关上,回到当前中间件继续执行后续代码。这也是为什么中间件的 “离开” 逻辑会按反向顺序执行 —— 内层逻辑必须先完成,外层才能收尾。

考验环节

  1. 请用一句话概括「洋葱模型」的核心执行逻辑,并用通俗的例子解释它的应用场景?
  2. 洋葱模型中,next() 函数的核心作用是什么?如果某个中间件里不调用 next(),会发生什么?

已知以下中间件数组和核心逻辑,结合我们之前写的 executeOnion 执行器:

const middlewares = [
  async (ctx, next) => {
    console.log('A 进');
    ctx.msg = 'A';
    await next();
    console.log('A 出');
  },
  async (ctx, next) => {
    console.log('B 进');
    ctx.msg += 'B';
    await new Promise((resolve) => setTimeout(resolve, 1000)); // 模拟异步
    await next();
    console.log('B 出');
  },
  async (ctx, next) => {
    console.log('C 进');
    ctx.msg += 'C';
    await next();
    console.log('C 出');
  },
];

const coreLogic = async (ctx) => {
  console.log('核心逻辑');
  ctx.msg += '核心';
};
  1. 请写出最终的控制台输出顺序(包括耗时相关日志)?

  2. 执行完后,ctx.msg 的值是什么?

  3. 如果把中间件 B 的 await next() 改成 next()(去掉 await),输出顺序会发生什么变化?为什么?

  4. 请基于洋葱模型,实现一个简化版的「Zustand 日志中间件」—— 要求:

  • 拦截 store 的 set 操作,打印「更新前状态」和「更新后状态」;
  • 支持异步 set 操作(比如异步修改状态);
  • 无需依赖 Zustand 源码,用伪代码模拟核心逻辑即可。
// 模拟Zustand的create函数(带中间件支持)
const create = (initializer) => {
  let state;
  // 中间件包装后的set方法
  const setState = (updater) => {
    // 处理函数式更新(如 (s) => ({ count: s.count + 1 }))
    const newState = typeof updater === 'function' ? updater(state) : updater;
    state = { ...state, ...newState }; // 合并新状态
  };

  // 初始化store(执行用户传入的initializer)
  state = initializer(setState, () => state);

  return {
    getState: () => ({ ...state }), // 返回状态副本,避免外部修改
    setState,
  };
};

// 使用中间件创建store
const initializer = (set, get) => ({
  count: 0,
  // 同步方法
  increment: () => set((s) => ({ count: s.count + 1 })),
  // 异步方法
  asyncIncrement: async () => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    set((s) => ({ count: s.count + 1 }));
  },
});
const useCounterStore = create(initializer);

// 实现日志中间件(洋葱模型思路) 调用 setState 前,打印「更新前状态:xxx」,调用 setState 后,打印「更新后状态:xxx」;
// const logMiddleware = 实现
// const useCounterStore = create(logMiddleware(initializer));
// 测试
console.log('初始状态:', useCounterStore.getState()); // 初始状态:{ count: 0 }
useCounterStore.increment(); // 触发同步更新
useCounterStore.asyncIncrement(); // 触发异步更新
  1. 除了 Zustand/Koa,你还知道哪些前端框架 / 库用到了洋葱模型?它在这些场景中解决了什么问题?
  2. 洋葱模型和「责任链模式」有什么区别?请举例说明(比如两者在处理请求时的不同逻辑)。
  3. 假设你正在开发一个接口请求工具,需要通过中间件实现「请求拦截(加 Token)」「响应拦截(统一处理错误)」「日志记录(打印请求耗时)」,请用洋葱模型设计这三个中间件的执行顺序,并简要说明理由。 下面是使用的逻辑,请开发 createRequestEnhancer
// 创建实例
const request = createRequestEnhancer();

// 添加日志中间件(前置+后置逻辑)
request.use(async (ctx, next) => {
  console.log('日志:请求开始,URL=', ctx.url);
  const start = Date.now();
  await next(); // 执行后续中间件+核心请求
  console.log('日志:请求结束,耗时=', Date.now() - start, 'ms');
});

// 添加请求拦截中间件(前置逻辑)
request.use(async (ctx, next) => {
  console.log('请求拦截:添加Token');
  ctx.options.headers = {
    ...ctx.options.headers,
    Authorization: 'Bearer 123456',
  };
  await next();
});

// 添加响应拦截中间件(后置逻辑)
request.use(async (ctx, next) => {
  await next(); // 先执行核心请求
  console.log('响应拦截:格式化数据');
  ctx.response = { code: 200, data: ctx.response }; // 包装响应
});

// 发送请求(测试洋葱模型)
request
  .fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then((res) => console.log('最终结果:', res))
  .catch((err) => console.log('错误:', err));

// 执行的时候
// 日志:请求开始,URL= https://jsonplaceholder.typicode.com/todos/1
// 请求拦截:添加Token
// (核心fetch请求)
// 响应拦截:格式化数据
// 日志:请求结束,耗时= 120 ms
// 最终结果: { code: 200, data: { userId: 1, id: 1, title: '...', completed: false } }
  1. 如果中间件数组很长(比如 100 个),洋葱模型的递归实现会导致栈溢出吗?如果会,如何优化执行器的实现(非递归方式)?

答案

  1. 请用一句话概括「洋葱模型」的核心执行逻辑,并用通俗的例子解释它的应用场景? 参考:洋葱模型的关键是 “逐层进入、逐层返回” 的双向流程,请求先逐层穿过外层中间件到达核心逻辑,再反向逐层穿过外层中间件返回(“进 - 核心 - 出” 的双向流程)。通俗例子:比如 Koa 处理 HTTP 请求时,先通过日志中间件记录请求开始(进),再通过权限中间件校验身份(进),执行核心的接口处理逻辑后,再通过权限中间件记录校验结果(出),最后通过日志中间件记录请求结束(出),全程不修改核心接口逻辑。
  2. 洋葱模型中,next() 函数的核心作用是什么?如果某个中间件里不调用 next(),会发生什么?
  • next() 的核心作用:触发下一个中间件或核心逻辑的执行,是串联洋葱模型 “逐层进入” 的关键,同时保证执行完后续逻辑后能回到当前中间件的 await next() 之后(实现 “逐层返回”)。
  • 若某个中间件不调用 next():后续所有中间件和核心逻辑都会被阻断(相当于 “拦截”),当前中间件 next() 之后的代码也不会执行(因为没有后续逻辑触发返回)。
  1. 请写出最终的控制台输出顺序(包括耗时相关日志)? A 进 -> B 进 -> (等待 1s) -> C 进 -> 核心逻辑 -> C 出 -> B 出 -> A 出

  2. 执行完后,ctx.msg 的值是什么? ABC 核心

  3. 如果把中间件 B 的 await next() 改成 next()(去掉 await),输出顺序会发生什么变化?为什么? 中间件 B 的代码里有 await new Promise(resolve => setTimeout(resolve, 1000))(1s 异步延迟),如果把 await next() 改成 next(),执行顺序会变成:A 进 -> B 进 (触发 next()但不等待,直接执行 console.log('B 出'))-> B 出 -> A 出(1s 后) -> C 进 -> 核心逻辑 -> C 出。因为 next 是 async 函数,所以会返回一个 Promise,进入了微任务队列。

    // 中间件B的代码
    async (ctx, next) => {
      console.log('B 进');
      await new Promise((resolve) => setTimeout(resolve, 1000)); // 等待1s(宏任务完成)
      // 调用 next(),但不 await
      next(); // next()是 async 函数,返回 Promise,进入微任务队列
    
      console.log('B 出'); // 主线程代码,直接执行
    };
    
    // 中间件 C 的代码(同步)
    async (ctx, next) => {
      console.log('C 进'); // 微任务:需等主线程执行完才会触发
      await next(); // 同步执行核心逻辑
      console.log('C 出');
    };
    
  4. 请基于洋葱模型,实现一个简化版的「Zustand 日志中间件」—— 要求:

  • 拦截 store 的 set 操作,打印「更新前状态」和「更新后状态」;
  • 支持异步 set 操作(比如异步修改状态);
  • 无需依赖 Zustand 源码,用伪代码模拟核心逻辑即可。

create(logMiddleware(initializer))这个是基于create(initializer))的增强版,也就是 logMiddleware(initializer)的返回值是类似initializer,initializer 这个函数入参是 set 和 get,返回值不知道是是啥,但通过 initializer(set, get) 可以拿到返回值。 本次写的中间件,是拦截 set 操作,也就是拿到 set 方法,然后包装成一个增强后的 set 方法,这个增强后的 set 方法,会在调用原始 set 方法之前,打印「更新前状态」,在调用原始 set 方法之后,打印「更新后状态」。再把这个增强后的 set 方法传给原始 initializer 函数,执行 initializer 函数,就是返回值了。

// 实现日志中间件(洋葱模型思路)
const logMiddleware = (initializer) => {
  return (set, get) => {
    // 包装原始set方法,添加日志逻辑
    const enhancedSet = async (updater) => {
      // 1. 进入阶段:打印更新前状态
      console.log('更新前状态:', get());

      // 处理异步updater(比如传入的是async函数)
      const newState =
        typeof updater === 'function'
          ? await updater(get()) // 等待异步函数执行完
          : updater;

      // 2. 执行核心逻辑:调用原始set方法更新状态
      set(newState);

      // 3. 离开阶段:打印更新后状态
      console.log('更新后状态:', get());
    };

    // 将增强后的set传给原始配置(洋葱模型的“next”逻辑)
    return initializer(enhancedSet, get);
  };
};
  • create(initializer) 的本质:initializer 是一个函数,接收 set/get,返回初始状态对象(比如 { count: 0, increment: ... }),create 执行它后会把返回值作为初始 state。
  • 中间件的作用链:logMiddleware(initializer) → 返回一个新的初始化函数(我们叫它 enhancedInitializer);这个 enhancedInitializer 会接收 create 传入的原始 set/get,然后包装 set(比如加日志),再把增强后的 set 传给原始 initializer 执行;最终 create 拿到的是 “增强版 set 执行后返回的状态”,实现对 set 操作的拦截。
  • 核心目标:不修改原始 initializer 的逻辑,只通过包装 set/get 实现功能增强 —— 这正是中间件 “开放 - 封闭原则” 的体现。
  • 可以说 “中间件是对 initializer 执行逻辑的增强”,但更准确的是:中间件通过包装 set/get 方法,间接增强 initializer 的执行效果——initializer 本身的业务逻辑不变,但它调用的 set 被增强了,最终实现功能扩展。
  1. 除了 Zustand/Koa,你还知道哪些前端框架 / 库用到了洋葱模型?它在这些场景中解决了什么问题?

还有 express 的中间件,线性执行模型,经典的中间件模式

Express 会把所有 app.use()/app.get() 注册的中间件 / 路由处理函数,按注册顺序存入一个数组,请求到来时依次执行,直到遇到 res.end() 或 next()

// 模拟 Express 的中间件容器
const middlewareStack = [];

// 模拟 app.use():注册中间件
function use(middleware) {
  middlewareStack.push(middleware);
}

// 模拟请求处理:依次执行中间件
function handleRequest(req, res) {
  let index = 0; // 记录当前执行的中间件下标

  // 定义 next 函数:执行下一个中间件
  function next() {
    if (index < middlewareStack.length) {
      const currentMiddleware = middlewareStack[index];
      index++;
      currentMiddleware(req, res, next); // 传入 next,让中间件手动调用
    }
  }

  next(); // 启动执行第一个中间件
}

// 使用的时候
// 注册中间件(按顺序)
use((req, res, next) => {
  console.log('中间件1:记录请求日志');
  next(); // 调用 next 执行下一个
});

use((req, res, next) => {
  console.log('中间件2:校验用户身份');
  req.user = { id: 1 };
  next(); // 调用 next 执行下一个
});

use((req, res, next) => {
  console.log('中间件3:处理路由逻辑');
  res.end(`Hello ${req.user.id}`); // 没有 next,流程终止
});

// 模拟请求
handleRequest({}, { end: (msg) => console.log('响应:', msg) });

// 输出:
// 中间件1:记录请求日志
// 中间件2:校验用户身份
// 中间件3:处理路由逻辑
// 响应:Hello 1
  1. 洋葱模型和「责任链模式」有什么区别?请举例说明(比如两者在处理请求时的不同逻辑)。

责任链像工厂的流水线,每个工位(中间件)都做一部分工作,最终产出成品(处理完请求),工位之间是 “接力” 关系。其核心是把多个独立的 “处理环节” 串成一条线,每个环节都是流程的一部分(没有明确的 “主逻辑”)—— 请求从第一个环节流到最后一个环节,每个环节都可能成为 “终点”(比如拦截请求、处理业务)。审批系统的 “员工申请 → 组长审批 → 经理审批 → 财务打款”,每个环节都是流程的必要步骤,没有 “辅助” 之说。

洋葱模型像给核心零件(主逻辑)包保护膜,内层是核心零件,外层的膜(中间件)负责防护、装饰,膜不改变零件本身,只增强功能。洋葱模型的核心是围绕一个明确的 “主逻辑”,用多层辅助逻辑做前后增强—— 主逻辑(如 Koa 的路由处理、Zustand 的 set 操作)是核心,其他中间件都是 “配角”,只负责前置 / 后置的辅助工作(日志、统计、拦截等)。

  1. 假设你正在开发一个接口请求工具,需要通过中间件实现「请求拦截(加 Token)」「响应拦截(统一处理错误)」「日志记录(打印请求耗时)」,请用洋葱模型设计这三个中间件的执行顺序,并简要说明理由。
function createRequestEnhancer() {
  const middlewares = [];

  const use = (middlewareFn) => {
    middlewares.push(middlewareFn);
  };

  const executeOnion = async (middlewares, ctx, coreFn) => {
    let index = 0;

    async function next() {
      if (index < middlewares.length) {
        // 这里是 < 不是 <=,避免越界
        const curMiddleware = middlewares[index];
        index++; // 先index++,再执行中间件(否则会重复执行第一个)
        await curMiddleware(ctx, next);
      } else {
        // 核心逻辑:执行fetch并把结果存入ctx
        const res = await coreFn(ctx.url, ctx.options);
        ctx.response = await res.json(); // 把响应挂载到ctx,供后续中间件使用
      }
    }

    await next();
    return ctx.response; // 最终返回响应结果
  };

  const enhancedFetch = async (url, options = {}) => {
    // 加async,支持await
    const ctx = { url, options, response: null }; // 初始化response
    return await executeOnion(middlewares, ctx, fetch); // 等待executeOnion完成
  };

  return {
    use,
    fetch: enhancedFetch,
  };
}

10.如果中间件数组很长(比如 100 个),洋葱模型的递归实现会导致栈溢出吗?如果会,如何优化执行器的实现(非递归方式)?

洋葱模型的递归实现(通过 next() 递归调用中间件)在中间件数量极多(比如 1000+)时,会导致栈溢出—— 因为 JavaScript 的调用栈深度有限(通常几千层),每递归一次就会向调用栈压入一层函数,超过阈值就会抛出 Maximum call stack size exceeded 错误。

但如果只是 100 个中间件,递归通常不会溢出(现代浏览器调用栈深度约 10000 层);但从健壮性角度,非递归实现更可靠。

优化方案:用迭代替代递归实现洋葱执行器

核心思路:把中间件执行逻辑从 “递归调用栈” 改为 “迭代 Promise 链”,通过循环依次执行中间件,利用 Promise 的异步特性避免栈溢出。

/**
 * 非递归洋葱执行器
 * @param {Array} middlewares - 中间件数组(每个中间件是 (ctx, next) => {})
 * @param {Object} ctx - 上下文对象
 * @param {Function} coreFn - 核心逻辑函数
 * @returns {Promise} - 执行结果
 */
function onionExecutor(middlewares, ctx, coreFn) {
  // 1. 把核心逻辑包装成最后一个“中间件”
  let nextMiddleware = async (ctx) => {
    await coreFn(ctx);
    return ctx;
  };

  // 2. 从后往前遍历中间件数组,逐个包装成嵌套链
  // 比如中间件是 [A,B,C],遍历顺序是 C → B → A
  for (let i = middlewares.length - 1; i >= 0; i--) {
    const currentMiddleware = middlewares[i];
    // 保存当前的nextMiddleware(下一个要执行的函数)
    const prevNext = nextMiddleware;
    // 重新定义nextMiddleware:当前中间件包裹prevNext
    nextMiddleware = async (ctx) => {
      await currentMiddleware(ctx, async () => await prevNext(ctx));
      return ctx;
    };
  }

  // 3. 执行最终构建好的链
  return nextMiddleware(ctx);
  // 从最后一个中间件开始,反向构建Promise链

  //也可以用 reduceRight 实现
  // return middlewares.reduceRight((nextMiddleware, currentMiddleware) => {
  //   // currentMiddleware需要调用nextMiddleware(即下一个中间件)
  //   return async (ctx) => {
  //     await currentMiddleware(ctx, async () => await nextMiddleware(ctx));
  //     return ctx;
  //   };
  // }, finalMiddleware)(ctx);
}

用具体例子拆解构建过程(中间件 [A,B,C]): 假设中间件数组是 [A,B,C],核心逻辑是 coreFn,我们从后往前遍历

第一步:处理最后一个中间件 C

运行;
// 初始 nextMiddleware 是 coreFn
prevNext = coreFn;
// 把 C 和 coreFn 包装成新的 nextMiddleware
nextMiddleware = (ctx) => C(ctx, () => coreFn(ctx));

第二步:处理中间件 B

// 保存当前的 nextMiddleware(即 C+coreFn)
prevNext = (ctx) => C(ctx, () => coreFn(ctx));
// 把 B 和 C+coreFn 包装成新的 nextMiddleware
nextMiddleware = (ctx) => B(ctx, () => C(ctx, () => coreFn(ctx)));

第三步:处理第一个中间件 A

// 保存当前的 nextMiddleware(即 B+C+coreFn)
prevNext = (ctx) => B(ctx, () => C(ctx, () => coreFn(ctx)));
// 把 A 和 B+C+coreFn 包装成新的 nextMiddleware
nextMiddleware = (ctx) => A(ctx, () => B(ctx, () => C(ctx, () => coreFn(ctx))));

最终得到的 nextMiddleware 就是:A(ctx, () => B(ctx, () => C(ctx, () => coreFn(ctx))))

完整使用示例

// 模拟1000个中间件(测试栈溢出)
const middlewares = Array.from({ length: 1000 }, (_, index) => {
  return async (ctx, next) => {
    ctx.count += 1;
    await next(); // 这里的next是迭代构建的Promise链,非递归
    ctx.count += 1;
  };
});

// 核心逻辑:修改状态
const coreFn = async (ctx) => {
  ctx.value = '核心逻辑执行';
};

// 执行器调用
const ctx = { count: 0, value: '' };
onionExecutor(middlewares, ctx, coreFn).then((res) => {
  console.log(res.count); // 2000(每个中间件执行两次count++)
  console.log(res.value); // 核心逻辑执行
});

原理说明

  • 反向 reduce 构建链:从最后一个中间件开始,用 reduceRight 把中间件嵌套成一个 Promise 链 —— 每个中间件的 next() 指向 “下一个中间件的执行函数”,而非递归调用自身。
  • 异步解耦:利用 Promise 的异步特性,每次执行 next() 都是一个新的 Promise,不会压入调用栈,而是进入微任务队列,彻底避免栈溢出。
  • 核心逻辑收尾:把核心函数作为最后一个中间件,确保所有中间件执行完后才执行核心逻辑。

Vue无限滚动实战——从原理到企业级优化方案

作者 云技纵横
2025年12月5日 09:58

引言:为什么需要无限滚动?

在电商、社交、资讯类应用中,无限滚动已成为标配交互模式。相比传统分页,无限滚动能带来更沉浸式的浏览体验,用户无需频繁点击"下一页",内容自然呈现。但看似简单的功能背后,隐藏着诸多技术细节和性能陷阱。

一、核心原理剖析

1.1 v-infinite-scroll 工作机制

我们以实际代码为例:

<ul class="goods-list" v-infinite-scroll="load">
  <!-- 商品列表 -->
</ul>

底层实现原理

  1. 滚动监听:Vue 通过自定义指令监听容器元素的 scroll 事件
  2. 阈值计算:当滚动位置距离底部小于设定阈值(默认通常为50px)时触发回调
  3. 防抖处理:内部实现防抖机制,避免滚动过程中频繁触发

数学表达

触发条件:scrollTop + clientHeight >= scrollHeight - threshold

1.2 核心控制逻辑深度分析

让我们重新审视这个关键的 load 函数:

function load() {
    // 第一层防护:边界检查
    if (data.isLast) {
        return; // 直接返回,中断执行链
    }
    
    // 第二层操作:状态更新
    data.pageIndex++;
    
    // 第三层执行:数据加载
    loadDataList();
}

1.2.1 return 的深度语义

这里的 return 绝非简单的"返回空",它体现了防御性编程的核心思想:

// 等价展开写法,更易理解
function load() {
    if (data.isLast === true) {
        console.log('已达末页,终止加载');
        return undefined; // 显式返回undefined
    }
    
    // 只有非末页才执行后续逻辑
    incrementPageAndLoad();
}

技术价值

  • 短路求值:避免不必要的计算和IO操作
  • 状态一致性:确保分页状态机的正确流转
  • 资源节约:减少网络带宽和服务器压力

二、企业级架构设计

2.1 状态管理模型

在实际项目中,我们需要更完善的状态管理:

// 增强版数据模型
const data = reactive({
    dataList: [],           // 商品数据集
    pageIndex: 1,           // 当前页码
    pageSize: 20,           // 每页容量
    isLast: false,          // 末页标志
    loading: false,         // 加载状态锁
    error: null,            // 错误状态
    retryCount: 0           // 重试计数
});

2.2 完整加载流程设计

async function load() {
    // 多层防护网
    if (data.isLast || data.loading) {
        return;
    }
    
    // 设置加载锁
    data.loading = true;
    data.error = null;
    
    try {
        // 页码递增
        const currentPage = data.pageIndex + 1;
        
        // 发起数据请求
        const response = await loadDataList({
            page: currentPage,
            size: data.pageSize
        });
        
        // 数据合并策略
        if (response.data && response.data.length > 0) {
            // 追加模式(推荐)
            data.dataList.push(...response.data);
            data.pageIndex = currentPage;
            
            // 末页判断逻辑
            data.isLast = response.data.length < data.pageSize;
        } else {
            // 空数据即末页
            data.isLast = true;
        }
        
    } catch (error) {
        // 错误处理与重试机制
        handleLoadError(error);
    } finally {
        // 释放加载锁
        data.loading = false;
    }
}

三、性能优化策略矩阵

3.1 渲染性能优化

问题:长列表导致的DOM节点爆炸

解决方案

  1. 虚拟滚动结合
<!-- 仅渲染可视区域元素 -->
<RecycleScroller 
    :items="data.dataList"
    :item-size="320"
    v-slot="{ item }">
    <GoodsItem :data="item" />
</RecycleScroller>
  1. CSS硬件加速
.goods-list {
    transform: translateZ(0); /* 开启GPU加速 */
    will-change: transform;   /* 预告浏览器优化 */
}

3.2 内存管理策略

// 实现数据分页卸载
const MAX_CACHED_PAGES = 5;

function optimizeMemory() {
    if (data.dataList.length > MAX_CACHED_PAGES * data.pageSize) {
        // 移除最早的数据页
        const removeCount = data.pageSize;
        data.dataList.splice(0, removeCount);
        data.pageIndex -= 1; // 调整页码映射
    }
}

3.3 网络层优化

  1. 请求去重:使用防抖或节流
  2. 缓存策略:相同查询参数使用本地缓存
  3. 预加载:预测用户行为提前加载下一页
import { debounce } from 'lodash';

// 防抖包装
const optimizedLoad = debounce(load, 300, {
    leading: false,
    trailing: true
});

四、异常处理与边界情况

4.1 网络异常恢复

const ERROR_RETRY_LIMIT = 3;

function handleLoadError(error) {
    data.retryCount++;
    
    if (data.retryCount <= ERROR_RETRY_LIMIT) {
        // 指数退避重试
        setTimeout(() => {
            load();
        }, Math.pow(2, data.retryCount) * 1000);
    } else {
        data.error = '加载失败,请稍后重试';
    }
}

4.2 数据一致性保障

// 乐观更新 + 回滚机制
async function optimisticLoad() {
    const previousState = cloneDeep(data);
    
    try {
        // 乐观更新UI
        data.dataList.push(placeholderItems);
        await realLoadOperation();
    } catch (error) {
        // 回滚到之前状态
        Object.assign(data, previousState);
        showErrorMessage(error);
    }
}

五、监控与指标体系

建立完整的性能监控:

// 性能指标采集
const metrics = {
    firstLoadTime: 0,      // 首屏加载时间
    subsequentLoadTimes: [], // 后续加载耗时数组
    renderFPS: 0,          // 渲染帧率
    memoryUsage: 0         // 内存占用
};

function collectMetrics(startTime) {
    const endTime = performance.now();
    const duration = endTime - startTime;
    
    if (metrics.firstLoadTime === 0) {
        metrics.firstLoadTime = duration;
    } else {
        metrics.subsequentLoadTimes.push(duration);
    }
    
    // 上报监控系统
    reportToMonitoring(metrics);
}

六、最佳实践总结

  1. 分层防护:多重条件检查确保逻辑健壮性
  2. 状态驱动:明确的状态转换避免竞态条件
  3. 性能优先:虚拟滚动+内存管理应对大数据量
  4. 优雅降级:完善的异常处理保证用户体验
  5. 可观测性:建立监控体系持续优化

结语

无限滚动看似简单,实则是前端工程化能力的综合体现。通过深入理解其原理,结合业务场景进行架构设计和性能优化,我们不仅能实现基础功能,更能打造出高性能、高可用的企业级解决方案。

可关注微信公众号:云技纵横 相关技术文档也会同步进行更新

基于 uView 的 u-picker 自定义时分秒选择器实现(支持反显)

作者 MoMoDad
2025年12月5日 09:52

在 uni-app 开发中,uView 作为主流的 UI 组件库,其u-timepicker时间选择器组件能满足大部分时间选择场景,但在需要精确到秒的业务场景中(如烹饪步骤耗时、视频剪辑时间点、设备操作时长等),u-timepicker仅支持时分选择的限制就无法满足需求。本文将分享基于u-picker自定义实现支持时分秒选择 + 反显的时间选择器,解决秒级选择的业务痛点。

一、实现思路

核心思路是基于 uView 的u-picker基础选择器,手动构建时分秒三列数据,同时实现 “时间字符串→选择器索引” 的解析逻辑(支持反显),以及 “选择器索引→时间字符串” 的拼接逻辑(支持确认更新),具体拆解为以下步骤:

  1. 基于u-picker搭建选择器基础结构,通过show控制显隐;
  2. 封装列数据生成函数,构建 “小时(00-23)、分钟(00-59)、秒(00-59)” 三列标准数据;
  3. 实现时间字符串解析函数,将业务中已有的时间(如02:01:00)转换为选择器列索引,支持反显;
  4. 处理选择器确认 / 取消事件,更新业务数据并保证响应式,同时做边界校验避免异常。

二、核心代码实现与解析

1. 模板结构:u-picker 基础布局

首先搭建u-picker的基础模板,绑定核心属性和事件,同时添加触发选择器的按钮:

vue

<template>
  <!-- 自定义时分秒选择器 -->
  <u-picker 
    :show="showPickerVis"
    :columns="timeColumns" 
    :defaultIndex="defaultIndex"
    @cancel="handleClosePicker"
    @confirm="handleConfirm">
  </u-picker>
  
  <!-- 触发选择器的按钮(示例:烹饪步骤耗时选择) -->
  <view class="time-select" @tap="showTimePicker(step, index)"></view>
</template>

关键属性说明:

  • show:控制选择器显隐;
  • columns:选择器的列数据(时分秒三列);
  • defaultIndex:默认选中的索引(实现反显的核心);
  • @cancel/@confirm:取消 / 确认选择的事件回调。

2. 数据初始化:定义核心响应式数据

data中定义选择器运行所需的核心数据,保证响应式:

javascript

运行

data() {
  return {
    showPickerVis: false, // 选择器显隐状态
    currentTimeStepIndex: -1, // 当前操作的业务数据索引(如烹饪步骤索引)
    timeColumns: [], // 时分秒列数据([[小时], [分钟], [秒]])
    defaultIndex: [0,0,0], // 选择器默认索引(反显用)
    formData: {
      cookingSteps: [] // 业务数据示例:烹饪步骤列表,含estSpendTime(耗时)字段
    }
  }
}

3. 列数据生成:构建标准时分秒列

封装fillTimeColumns函数,生成标准化的时分秒列数据,保证格式统一(两位数字,不足补 0):

javascript

运行

fillTimeColumns(initialArr = [[], [], []]) {
  // 辅助函数:数字补零(如1→01)
  const padZero = (num) => num.toString().padStart(2, '0')
  
  // 生成小时列(00-23):兼容初始数据,无数据则重新生成
  const hoursColumn = initialArr[0].length === 24 
    ? initialArr[0] 
    : Array.from({ length: 24 }, (_, index) => padZero(index));
  
  // 生成分钟/秒列(00-59)
  const minuteSecondColumn = Array.from({ length: 60 }, (_, index) => padZero(index));
  
  // 赋值给列数据,供u-picker渲染
  this.timeColumns = [    hoursColumn,    minuteSecondColumn,    minuteSecondColumn  ]
}

核心逻辑:

  • padZero保证所有时间单位都是两位字符串(如05而非5),避免格式混乱;
  • 小时列限制为 00-23,分秒列限制为 00-59,符合时间规范;
  • 兼容初始数据,若已有完整小时列则复用,否则重新生成,提升灵活性。

4. 反显核心:时间字符串转选择器索引

封装getTimeColumnIndexes函数,将业务中的时间字符串(如02:01:0002:01:00)解析为选择器的列索引,实现反显:

javascript

运行

/**
 * 根据时间字符串获取时分秒在timeColumns中的对应索引
 * @param {string} timeStr - 时间字符串,格式如 "02:01:00"(中文冒号)或 "02:01:00"(英文冒号)
 * @param {Array<Array<string>>} timeColumns - 时分秒列数据
 * @returns {Array<number>} [小时索引, 分钟索引, 秒索引]
 */
getTimeColumnIndexes(timeStr, timeColumns) {
  // 边界校验:列数据异常则返回默认索引
  if (!Array.isArray(timeColumns) || timeColumns.length < 3) {
    console.warn('timeColumns格式异常');
    return [0, 0, 0];
  }

  // 统一分隔符(兼容中文/英文冒号),分割为时/分/秒
  const timeParts = timeStr.replace(/:/g, ':').split(':');
  
  // 格式校验:必须是HH:MM:SS格式
  if (timeParts.length !== 3 || !/^\d{2}$/.test(timeParts[0]) || !/^\d{2}$/.test(timeParts[1]) || !/^\d{2}$/.test(timeParts[2])) {
    console.warn('时间格式错误,应为 "HH:MM:SS" 或 "HH:MM:SS"');
    return [0, 0, 0];
  }

  const [hourStr, minuteStr, secondStr] = timeParts;
  // 查找对应索引,找不到则返回0
  const hourIndex = timeColumns[0].findIndex(item => item === hourStr) || 0;
  const minuteIndex = timeColumns[1].findIndex(item => item === minuteStr) || 0;
  const secondIndex = timeColumns[2].findIndex(item => item === secondStr) || 0;

  return [hourIndex, minuteIndex, secondIndex];
}

核心逻辑:

  • 兼容中英文冒号,解决业务中时间字符串格式不统一的问题;
  • 严格的格式校验,避免非法时间字符串导致选择器异常;
  • 从列数据中精准查找索引,保证反显的准确性。

5. 事件处理:打开 / 确认 / 关闭选择器

(1)打开选择器:记录索引 + 设置反显

javascript

运行

showTimePicker(step, index) {
  // 1. 解析当前步骤的时间字符串,设置反显索引
  this.defaultIndex = this.getTimeColumnIndexes(step.estSpendTime, this.timeColumns);
  // 2. 记录当前操作的业务数据索引(如烹饪步骤索引)
  this.currentTimeStepIndex = index;
  // 3. 显示选择器
  this.showPickerVis = true;
}

打开选择器时,先解析当前业务数据的时间字符串,设置defaultIndex实现反显,同时记录当前操作的索引,为后续更新数据做准备。

(2)确认选择:更新业务数据

javascript

运行

handleConfirm(e) {
  // 1. 边界校验:避免索引越界
  if (this.currentTimeStepIndex < 0 || this.currentTimeStepIndex >= this.formData.cookingSteps.length) {
    this.showPickerVis = false;
    return;
  }

  // 2. 拼接选择的时分秒为标准字符串
  const [hour, minute, second] = e.value;
  const estSpendTime = `${hour}:${minute}:${second}`;

  // 3. $set更新响应式数组(关键:保证数据响应式)
  this.$set(
    this.formData.cookingSteps[this.currentTimeStepIndex],
    'estSpendTime',
    estSpendTime
  );

  // 4. 关闭选择器
  this.showPickerVis = false;
}

核心注意点:

  • 必须做索引边界校验,避免操作不存在的业务数据;
  • 使用this.$set更新数组元素,保证 Vue 响应式(直接修改数组元素无法触发视图更新);
  • 拼接时间字符串为标准HH:MM:SS格式,统一业务数据格式。

(3)取消 / 关闭选择器

javascript

运行

handleClosePicker() {
  this.showPickerVis = false;
}

取消选择时仅隐藏选择器,不修改业务数据,保证操作的合理性。

三、使用说明

  1. 初始化列数据:在页面onLoadonShow中调用fillTimeColumns(),初始化时分秒列数据:

javascript

运行

onLoad() {
  this.fillTimeColumns();
}
  1. 业务数据适配:将示例中的formData.cookingSteps替换为实际业务数据(如订单耗时、视频时长等),保证数据结构中包含时间字段(如estSpendTime);
  2. 样式调整:根据业务需求调整u-picker和触发按钮的样式,适配页面 UI。

四、总结

本文基于 uView 的u-picker组件,实现了支持时分秒选择 + 反显的自定义时间选择器,解决了u-timepicker不支持秒选择的痛点。核心亮点在于:

  1. 兼容中英文冒号的时间字符串解析,适配不同格式的业务数据;
  2. 完善的边界校验,避免索引越界、格式错误等异常场景;
  3. 保证 Vue 响应式更新,避免数据修改后视图不刷新的问题。

该自定义选择器可复用性强,适用于烹饪步骤耗时、设备操作时长、视频剪辑时间点等需要秒级时间选择的场景,只需稍作调整即可适配不同业务场景的时间选择需求。

前端转战后端:JavaScript 与 Java 对照学习指南(第四篇 —— List)

作者 汤姆Tom
2025年12月5日 09:48

前端工程师写惯了 JavaScript 的数组,一开始接触 Java 的集合时很容易迷茫:

  • “为什么 List 要分 ArrayList、LinkedList?”
  • “为什么不能像 JS 一样直接 arr[0] 用?”
  • “为什么 List 删除要区分 remove(索引) 和 remove(对象)?”
  • “为什么要写 List 而不是 List?”

本篇将从 JavaScript 的视角,完整讲清楚 Java 的 List 体系,并帮助你在后端开发中真正用明白。


🟦 1. JavaScript 数组 vs Java List:本质差异

✔ JavaScript 数组的本质:动态对象 & 哈希结构

JavaScript 数组不是严格意义的“数组”,底层类似:

{
  "0": 10,
  "1": "abc",
  "2": true,
  length: 3
}

特点:

  • 类型随便放(动态类型)
  • 可随便扩容
  • 可缺省元素([1, , 3])
  • 很多场景是 JS 引擎优化过的对象

对前端来说,这非常自由。


✔ Java 的 List 本质:严格类型、固定结构、接口规范

Java 的 List 是一个接口:

public interface List<E> extends Collection<E> {
    ...
}

所有 List 只能存放同一类型 E(泛型)。

List 有多种实现方式:

实现类 底层结构 类比 JS
ArrayList 动态数组 标准数组
LinkedList 双向链表 无直接等价(手写链表)
CopyOnWriteArrayList 线程安全 JS 无对应

🟩 2. ArrayList:前端最先上手的 List

⭐ 底层原理(重点)

ArrayList 底层是 动态数组

Object[] elementData;

默认容量 10,满了之后 1.5 倍扩容

扩容做的事类似:

新数组 = new Object[旧容量 * 1.5]
把旧数组 copy 过去

扩容成本较高,所以:

后端开发中频繁往 ArrayList 加东西时,建议预估一下初始容量。

示例:

List<Integer> list = new ArrayList<>(1000);

⭐ 常用 API 全对照 JS

操作 JavaScript Java ArrayList
创建 const arr = [] List<T> list = new ArrayList<>();
添加尾部 arr.push(x) list.add(x)
插入 arr.splice(i,0,x) list.add(i, x)
获取 arr[i] list.get(i)
设置 arr[i] = x list.set(i, x)
删除(按索引) arr.splice(i,1) list.remove(i)
删除(按值) arr = arr.filter(v => v != x) list.remove("A")
长度 arr.length list.size()
查找索引 arr.indexOf(x) list.indexOf(x)

⭐ 性能分析(简单记住即可)

操作 复杂度 原因
get(i) O(1) 数组寻址
set(i) O(1) 数组寻址
add(x) 尾部 摊销 O(1) 偶尔扩容
add(i,x) 中间插入 O(n) 需要移动元素
remove(i) O(n) 需要移动元素

结论:

ArrayList = 读多写少的场景最佳选择

在后端,90% 业务都是 “读多写少”。


🟨 3. LinkedList:链表 List

如果你写过 LeetCode 链表题,你就懂 LinkedList。

⭐ 底层结构

双向链表:

prev ← [node] → next

插入/删除只需要:

  1. 找到节点
  2. 改指针

但无法随机访问。


⭐ 性能对比

操作 ArrayList LinkedList 为什么
get(i) O(1) O(n) 链表需要遍历
add(i,x) O(n) O(n) ArrayList 移动元素,LinkedList 找节点
addFirst O(n) O(1) LinkedList 不移动元素
removeFirst O(n) O(1) 同上

因此:

LinkedList 的中间插入删除不是真正的 O(1),因为必须先找到节点!

很多前端误解:“链表插入 O(1) 啊”,那是指“找到节点之后”。


🟦 4. List 遍历方式(对比 JS)

✔ JavaScript(简单)

arr.forEach(item => console.log(item));

✔ Java 有 4 种常见遍历方式

① 普通 for(可随机访问)

for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

② 增强 for

for (String s : list) {
    System.out.println(s);
}

③ Iterator(可安全删除元素)

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    if (s.equals("A")) {
        it.remove(); // 安全
    }
}

对照 JS:

arr = arr.filter(x => x !== "A");

④ Stream(最像 JS 函数式)

list.stream()
    .filter(x -> x.startsWith("A"))
    .forEach(System.out::println);

对应 JS:

arr.filter(x => x.startsWith("A")).forEach(console.log);

🟥 5. List 的 7 个常见坑(前端最容易踩)

❗坑 1:不能 list[0]

JS:

arr[0]

Java:

list.get(0)

为什么不给支持?
因为 List 是接口,不保证一定是数组结构,比如 LinkedList。


❗坑 2:删除整数值可能错用 remove(int)

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.remove(1);

你以为删除 “1”,实际删除索引 1(值 2)…

正确写法:

list.remove(Integer.valueOf(1));

❗坑 3:List.of() 是不可变的(Java 9+)

List<String> list = List.of("A", "B");
list.add("C"); // ❌ 运行时报错 UnsupportedOperationException

JS 的 Object.freeze() 类似。


❗坑 4:Arrays.asList() 不是完全可变

List<String> list = Arrays.asList("A", "B");
list.add("C"); // ❌ 不支持添加/删除

因为它的底层是固定数组。


❗坑 5:不要在增强 for 中删除元素

for(String s : list) {
    list.remove(s); // ❌ ConcurrentModificationException
}

要用 Iterator:

Iterator<String> it = list.iterator();
it.remove();

❗坑 6:代码中频繁 new ArrayList() 会影响性能

后端开发中常见:

List<User> users = new ArrayList<>();
for (...) {
    users = new ArrayList<>(); // ❌ 无意义开销
}

要注意避免反复建立对象。


❗坑 7:在高并发下 ArrayList 不是线程安全的

如果在多线程中使用:

List<String> unsafe = new ArrayList<>();

可能引发异常、数据错乱。

正确方式:

List<String> safe = Collections.synchronizedList(new ArrayList<>());

或使用:

CopyOnWriteArrayList<String> safeList = new CopyOnWriteArrayList<>();

🟧 6. 后端实战场景示例

下面举几个开发中常见的 List 使用示例。


场景 1:查询数据库返回 List

List<User> users = userDao.queryAllUsers();

JS 类比:

const users = await api.getUsers();

场景 2:遍历用户并构造 DTO

Java:

List<UserDTO> dtos = new ArrayList<>();
for (User user : users) {
    dtos.add(new UserDTO(user.getId(), user.getName()));
}

JS:

const dtos = users.map(u => ({ id: u.id, name: u.name }));

场景 3:去重(Java 要借助 Set)

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "A"));
List<String> result = new ArrayList<>(new HashSet<>(list));

JS:

const result = [...new Set(["A", "B", "A"])];

场景 4:按条件过滤

Java:

List<User> adults = users.stream()
    .filter(u -> u.getAge() >= 18)
    .toList();

JS:

const adults = users.filter(u => u.age >= 18);

🟫 7. ArrayList vs LinkedList:详细对比总结

类型 底层 优点 缺点 使用场景
ArrayList 动态数组 随机访问快,内存连续 插入/删除慢,扩容成本高 大部分业务使用
LinkedList 双向链表 插入删除快(找到节点后) 随机访问慢,内存分散 队列、头部操作频繁

后端实际经验:

  • 95% 用 ArrayList
  • LinkedList 很少用,因为场景不常见

🎉 总结

对于从前端转后端的开发者:

✔ 把 ArrayList 当 JS 数组用足够了

✔ LinkedList 除非你确定需要,否则不要用

✔ List 操作与 JS 高度类比,功能更严格

✔ 泛型保证类型安全

✔ 遍历方式更多(for / for-each / Iterator / Stream)

✔ 注意一些常见坑(remove、不可变 List、线程安全等)

你的代码在裸奔?给 React 应用穿上“防弹衣”的保姆级教程

2025年12月5日 09:47

前言:Localhost 是天堂,生产环境是地狱

很多兄弟写代码有个坏习惯:在自己电脑上(Localhost)跑通了,就觉得万事大吉了。

在你的电脑上:

  • 网速是 1000M 光纤。
  • 后端接口永远返回标准 JSON,从来不报错。
  • 操作流程永远是按你设计的“快乐路径”走的。

但在生产环境(线上):

  • 用户的网速可能还不如 2G,丢包率 50%。
  • 后端服务偶尔会 502 Bad Gateway,或者心情不好给你返个 null
  • 用户可能会疯狂点击那个“提交”按钮,或者在数据没加载完时狂点 Tab 切换。

于是,你的 React 应用白屏了(White Screen of Death)。 控制台一片血红:Cannot read properties of undefined (reading 'map')

u=636573894,1285475435&fm=253&fmt=auto&app=138&f=JPEG.webp 今天,我们要聊聊防御性编程。别指望环境完美,我们要假设世界下一秒就会毁灭,而我们的代码依然要屹立不倒。


第一层防御:不要相信任何人(尤其是后端)

后端老哥跟你说:“放心,这个字段我肯定返给你数组。” 千万别信。 在防御性编程的字典里,所有的外部输入都是有罪的

❌ 裸奔写法:

const UserList = ({ data }) => {
  // 如果后端脑抽返了个 null,或者 data 还没加载完
  // 这里直接报错,整个组件树崩溃,页面白屏
  return (
    <ul>
      {data.users.map(user => (
        <li key={user.id}>{user.name.toUpperCase()}</li>
      ))}
    </ul>
  );
};

✅ 防弹写法(可选链 + 空值合并):

我们要把代码写得像个怕死鬼一样小心。

  // 1. data 可能是 undefined
  // 2. data.users 可能是 null
  // 3. user.name 可能是 null (toUpperCase 会炸)
  
  const safeUsers = data?.users ?? [];

  return (
    <ul>
      {safeUsers.map(user => (
        <li key={user?.id}>
          {/* 如果 name 没有,给个默认值 '-',别让页面挂掉 */}
          {user?.name?.toUpperCase() ?? '-'}
        </li>
      ))}
    </ul>
  );
};

记住口诀: ?. 是你的保命符,?? 是你的底裤。多写几个问号,不会怀孕,但会救命。

第二层防御:Error Boundaries(错误边界)—— 熔断机制

即使你再小心,JS 还是可能会报错。 React 有一个特性:如果一个组件在渲染过程中抛出错误,整个组件树会被“卸载”(Unmount)。

这意味着,仅仅因为侧边栏的一个小图标渲染错了,你整个网页(包括顶部的导航、中间的内容)都会瞬间消失,变成一张白纸。用户除了刷新(然后再次崩溃),什么都做不了。

这就像家里厕所灯泡坏了,结果整栋楼自动引爆了一样离谱。

我们需要错误边界(Error Boundaries) ,它的作用就是:隔离错误。厕所灯坏了,就把厕所门关上,贴个条子“维修中”,别的地方照常使用。

怎么用?推荐 react-error-boundary

React 官方目前还只支持用 Class 组件写 Error Boundary(这很复古),所以我强烈建议直接用 react-error-boundary 这个库,它不仅支持 Hooks,还更优雅。


// 1. 定义一个备用 UI(出错时显示这个)
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="p-4 bg-red-100 text-red-800">
      <p>哎呀,这里好像出了点问题。</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>重试一下</button>
    </div>
  );
}

// 2. 把容易出事的组件包起来
const App = () => {
  return (
    <Layout>
      <Sidebar />
      <Main>
        {/* 给核心内容区穿上防弹衣 */}
        <ErrorBoundary 
          FallbackComponent={ErrorFallback}
          onReset={() => {
            // 重试时的逻辑,比如重新请求数据
            window.location.reload(); 
          }}
        >
          <HighRiskComponent />
        </ErrorBoundary>
      </Main>
    </Layout>
  );
};

现在,如果 HighRiskComponent 炸了,用户只会看到一个友好的“出错了”提示框,而侧边栏和导航栏依然完好无损。用户体验从“灾难级”变成了“可接受级”。

第三层防御:API 请求的兜底

useEffect 里请求数据也是重灾区。很多人只写 then,不写 catch。 ❌ 乐观派写法:

  setLoading(true);
  api.getData().then(res => {
    setData(res);
    setLoading(false); // 如果 api 报错了,loading 永远是 true,页面永远在转圈
  });
}, []);

✅ 悲观派写法(Finally大法):

  let isMounted = true; // 防止组件卸载后还去 setState (这也是个常见警告)

  const fetchData = async () => {
    setLoading(true);
    setError(null); // 每次请求前记得重置错误状态
    
    try {
      const res = await api.getData();
      if (isMounted) setData(res);
    } catch (err) {
      if (isMounted) setError(err); // 捕获错误,展示 Error UI
      // 甚至可以在这里上报错误日志到 Sentry
      reportToSentry(err); 
    } finally {
      // 无论成功失败,必须结束 loading
      if (isMounted) setLoading(false);
    }
  };

  fetchData();

  return () => { isMounted = false; };
}, []);

总结:哪怕天塌下来,你的 App 也要优雅地死

防御性编程的核心心态就两个字:悲观

  1. 数据层面:默认所有数据都可能是空的。用 ?.?? 处理掉所有 undefined
  2. UI 层面:用 ErrorBoundary 包裹主要区域。局部坏死好过全身瘫痪。
  3. 交互层面:永远要有 Loading 状态,永远要有 Error 状态,永远要有重试按钮。

当你的应用在断网、接口报错、数据异常时,还能给用户显示一个漂亮的“请检查网络”或者“重试”,而不是直接白屏或者卡死,这时候,你才算是一个成熟的前端工程师。

好了,我要去给那个没有任何空值判断的详情页加“防弹衣”了,祝大家的线上环境永远不死。

lg_90841_1619336946_60851ef204362.png


下期预告:你有没有觉得现在的 React 项目越来越大,打包出来几 MB,首屏加载慢得像蜗牛? 下一篇,我们要聊聊 “代码分割(Code Splitting)”与 Lazy Loading。教你如何把你的应用切成小块,按需加载,让首屏速度起飞。

“求求你别在 JSX 里写逻辑了” —— Headless 思想与自定义 Hook 的“灵肉分离”术

2025年12月5日 09:43

前言:一种名为“组件肥胖症”的绝症

xiaotu.jpg

恭喜你,经过前几篇的洗礼,你已经学会了用 useRef 逃生,用 Context 拆分状态,用 Composition 组合组件。

现在,你的代码跑得很快,组件层级也很扁平。但是,当你打开你的核心组件文件(比如一个复杂的 MultiSelect 下拉框),你还是会感到一阵眩晕。

症状如下: 文件一共 500 行。 前 400 行全是 useStateuseEffectuseCallback,以及各种 handleChange, handleBlur, handleKeyDown。 最后 100 行才是可怜巴巴的 JSX 代码。

每次你想改个样式,得在 400 行逻辑代码里翻山越岭找 className。 每次你想复用这个逻辑但换个皮肤(比如 PC 端换成移动端 BottomSheet),你只能把那 400 行代码复制粘贴一份。

这就叫**“组件肥胖症”。你的组件承载了太多它不该承受的痛苦:既要负责长得好看(UI),又要负责脑子好使(Logic)**。

今天,我要教你一招灵肉分离术,也就是 Headless(无头)组件Custom Hooks 的终极奥义。 灵肉分离 额,不是 骨肉相连 啊

002wiWvPly1hnurhxqqxmg606w05vgyy02.gif


第一阶段:手动抽脂(Extract Hooks)

我们要做的第一件事,就是把组件里的“脑子”挖出来。

场景:一个发送验证码的按钮。 逻辑:点击 -> 请求接口 -> 开始倒计时 60s -> 倒计时结束变回“重新发送”。

❌ 典型的“胖”组件:

// SendCodeBtn.tsx
const SendCodeBtn = ({ phone }) => {
  const [count, setCount] = useState(0);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let timer;
    if (count > 0) {
      timer = setInterval(() => setCount(c => c - 1), 1000);
    }
    return () => clearInterval(timer);
  }, [count]);

  const handleClick = async () => {
    setLoading(true);
    try {
      await api.sendCode(phone);
      setCount(60);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button disabled={count > 0 || loading} onClick={handleClick}>
      {loading ? '发送中...' : count > 0 ? `${count}s 后重试` : '发送验证码'}
    </button>
  );
};

这就很难受。如果你在另一个页面需要一个长得不一样的验证码按钮(比如是个纯文字链接),你还得把这堆定时器逻辑重写一遍。

✅ 抽脂后(Custom Hook):

我们新建一个 hooks 文件,把所有跟 UI 无关的脏活累活都扔进去。

export const useCountDown = (initialCount = 60) => {
  const [count, setCount] = useState(0);
  const [loading, setLoading] = useState(false);

  // 这里的 useEffect 逻辑跟上面一模一样,省略...

  const start = async (task) => {
    setLoading(true);
    try {
      await task();
      setCount(initialCount);
    } finally {
      setLoading(false);
    }
  };

  return { count, loading, start, isRunning: count > 0 };
};

✅ 瘦身后的组件:

const SendCodeBtn = ({ phone }) => {
  // 核心逻辑只有这一行!
  const { count, loading, start, isRunning } = useCountDown();

  const handleClick = () => start(() => api.sendCode(phone));

  return (
    <button disabled={isRunning || loading} onClick={handleClick}>
      {loading ? '...' : isRunning ? `${count}s` : '发送'}
    </button>
  );
};

发现了吗? SendCodeBtn 现在变成了一个弱智(褒义)。它完全不知道什么是 setInterval,它只关心:给我状态,我画界面。

以后你在做移动端、小程序端,甚至 React Native,这个 useCountDown 都可以直接拷过去用。这就叫逻辑复用

第二阶段:Headless 组件思想(无头骑士)

Hook 解决了逻辑复用,但有时候,我们需要复用的不仅仅是逻辑,还有交互行为(Accessibility/Keyboard Support)

比如一个 Modal(弹窗)或者 Switch(开关)。 你需要处理 aria-expanded,处理 ESC 关闭,处理 Focus Trap(焦点锁定)。

这时候,Headless UI 概念就登场了。 它的理念是:我给你提供功能齐全的组件,但是我不带任何样式(CSS)。 你想给它穿什么衣服,是你自己的事。

举个栗子:一个简单的 Toggle 开关

如果你用普通的封装方式,你可能会写死 CSS:

const Toggle = ({ on, onClick }) => (
  <div className="toggle-bg-green" onClick={onClick}> {/* 样式写死了 */}
    <div className={`circle ${on ? 'right' : 'left'}`} />
  </div>
);

Headless 的做法是,我们写一个 Hook,把所有交互需要的属性吐出来:

const useToggle = (initialState = false) => {
  const [on, setOn] = useState(initialState);
  
  const toggle = () => setOn(!on);

  // 重点:Headless 的精髓在于生成 Props
  const getTogglerProps = () => ({
    'aria-pressed': on,
    onClick: toggle,
    role: 'button',
    tabIndex: 0,
    // 甚至可以处理键盘回车事件
    onKeyDown: (e) => { if(e.key === 'Enter') toggle() }
  });

  return { on, toggle, getTogglerProps };
};

怎么用?(穿衣服环节)

场景 A:我要一个像 iOS 那样的开关

  const { on, getTogglerProps } = useToggle();
  
  return (
    // 把逻辑 Props 展开到 UI 上
    <div {...getTogglerProps()} className={`ios-switch ${on ? 'on' : 'off'}`}>
      <span className="circle" />
    </div>
  );
};

场景 B:我要一个复古的 Checkbox 样式

  const { on, getTogglerProps } = useToggle();

  return (
    <button {...getTogglerProps()} style={{ border: '2px solid black', background: on ? 'black' : 'white' }}>
      {on ? 'ON' : 'OFF'}
    </button>
  );
};

看到没有? 同一个 Hook,驱动了两个完全长得不一样的组件。 这就是像 TanStack Table (React Table) 或者 Radix UI 这种库的核心原理。它们不给你画表格,它们只告诉你“这一行该不该显示,这一格数据是多少”,剩下的 HTML/CSS 你自己写。

什么时候该用这招?

虽然 Headless 很帅,但不要走火入魔。

  1. 简单的展示型组件(比如 Avatar, Button, Badge):别折腾了,直接写组件就行,逻辑很少。
  2. 复杂的交互型组件(比如 DatePicker, Autocomplete, Carousel):强烈建议先把逻辑抽成 Hook。

判断标准: 如果你的组件里 useEffect 超过了 2 个,或者当你试图测试这个组件时,发现还得去模拟 DOM 点击才能测逻辑,那就是时候把逻辑剥离出来了。


总结:让代码“各司其职”

所谓的高级工程师代码,其实就是极度的强迫症

  • UI 组件:只管 className,只管布局,只管颜色。像个单纯的模特。
  • Custom Hooks:只管数据变没变,只管接口通不通,只管逻辑对不对。像个幕后的导演。

当你把这种模式应用到项目中,你会发现:

  1. Bug 少了:因为逻辑集中在 Hook 里,不用在 UI 代码的迷宫里找 Bug。
  2. 改版快了:UI 设计师把页面改得面目全非,你只需要换一套 JSX,逻辑一行都不用动。
  3. 同事跪了:他们看着你清爽的代码结构,会流下感动的泪水。

好了,我要去把那个混杂了 800 行表单验证逻辑的 Form 组件给拆了。

lg_90841_1619336946_60851ef204362.png


下期预告:你以为写好代码就万事大吉了吗?生产环境是残酷的,用户是不可预测的。 接口会挂,数据会空,JS 会报错。 下一篇,我们来聊聊 “防御性编程”与 React Error Boundaries。教你如何给你的应用穿上“防弹衣”,让它在崩溃边缘也能优雅地着陆。

最新的 Dart sdk 安装教程

2025年12月5日 09:08

下载安装

Dart sdkwindows 和mac 安装方法有区别,根据个人需求进行下载安装。

windows 安装

官网下载链接dart-windows

image.png

上面是稳定版,下面开发版,如果是与他人做之前的项目或是继续开发,用稳定版为好。如果是自己新开发一个项目,那么推荐开发版。

image.png

双击exe文件即可,接下来就是无脑操作一步步next。

注意:安装路径除C盘外任意一个盘都可以。

mac 安装

如果为mac安装就简单了,第一步 如果没有安装brew这个工具,就需要安装它brew工具;然后分别运行两行命令。

brew tap dart-lang/dart 

brew install dart

配置环境变量

1.找到安装路径

找到Dart sdk安装路径,去配置环境变量。我这里路径是D:\run\dart\Dart

image.png

2.新建环境变量

打开环境变量,找到path,点击编辑,把刚才复制好的路径粘贴到新建环境变量输入框里。

image.png

image.png

image.png

image.png

配置完成后检查Dart sdk是否安装成功

命令

dart --version

安装成功会展示安装版本,如下图

image.png

Flutter 用到插件

如果你使用vs code 或者tare Ai编辑开发,需要安装的插件有两个 Dart Code Runner

image.png

关于git的操作命令(一篇盖全),可不用,但不可不知!

作者 向北者
2025年12月5日 08:20

前言:虽然 SourceTree / Fork/ Github Desktop /Visual Studio Code自带的git工具 等等,这些工具操作很简单,用起来也很爽,但是基本git操作命令也是万万不能丢的,本文将带你记牢那些git命令,不用这些工具,也照样玩得飞起,开撸!

一:日常敲代码git大致流程图

虽然说咱们⽇常使⽤最频繁的操作命令就是下图这六个:

image.png

但实际上,如果想要熟练使⽤,有超过60多个命令需要去了解,下⾯则介绍下常⻅的的 git 命令

二:你想用的都在下面

1.配置方面

Git ⾃带⼀个 git config 的⼯具来帮助设置控制 git 之后,第⼀件事就是设置你的⽤户名和邮件地址,在我们安装完后续每⼀个提交都会使⽤这些信息,它们会写⼊到你的每⼀次提交中,设置提交代码时的⽤户信息命令如下:

git config [--global] user.name "[name]" 
git config [--global] user.email "[email address]"

启动方面

初始git 项⽬有两个途径,分别是:

git init [project-name] // 创建或在当前⽬录初始化⼀个git代码库
git clone url  // 克隆,根据仓库地址克隆⼀个项⽬和它的整个代码历史

2.日常基本操作

在⽇常⼯作中,代码常⽤的基本操作如下:

git init 初始化仓库,默认为 master 分⽀ 
git add . 提交全部⽂件修改到缓存区 
git add <具体某个⽂件路径+全名> 提交某些⽂件到缓存区 
git diff  查看当前代码 add后,会 add 哪些内容 
git diff --staged查看现在 commit 提交后,会提交哪些内容 
git status 查看当前分⽀状态 
git pull <远程仓库名> <远程分⽀名> 拉取远程仓库的分⽀与本地当前分⽀合并 
git pull <远程仓库名> <远程分⽀名>:<本地分⽀名> 拉取远程仓库的分⽀与本地某个分⽀合并 
git commit -m "<注释>" 提交代码到本地仓库,并写提交注释 
git commit -v 提交时显示所有diff信息
git commit --amend [file1] [file2] 重做上⼀次commit,并包括指定⽂件的新变化

3.分支相关操作

git branch 查看本地所有分⽀ 
git branch -r 查看远程所有分⽀ 
git branch -a 查看本地和远程所有分⽀ 
git merge <分⽀名> 合并分⽀ 
git merge --abort 合并分⽀出现冲突时,取消合并,⼀切回到合并前的状态 
git branch <新分⽀名> 基于当前分⽀,新建⼀个分⽀ 
git checkout --orphan <新分⽀名> 新建⼀个空分⽀(会保留之前分⽀的所有⽂件) 
git branch -D <分⽀名> 删除本地某个分⽀ 
git push <远程库名> :<分⽀名> 删除远程某个分⽀ 
git branch <新分⽀名称> <提交ID> 从提交历史恢复某个删掉的某个分⽀ 
git branch -m <原分⽀名> <新分⽀名> 分⽀更名 
git checkout <分⽀名> 切换到本地某个分⽀ 
git checkout <远程库名>/<分⽀名> 切换到线上某个分⽀ 
git checkout -b <新分⽀名> 把基于当前分⽀新建分⽀,并切换为这个分⽀

4.远程同步

远程操作常⻅的命令:

git fetch [remote] 下载远程仓库的所有变动 
git remote -v 显示所有远程仓库 
git pull [remote] [branch] 拉取远程仓库的分⽀与本地当前分⽀合并 
git fetch 获取线上最新版信息记录,不合并 
git push [remote] [branch] 上传本地指定分⽀到远程仓库 
git push [remote] --force 强⾏推送当前分⽀到远程仓库,即使有冲突 
git push [remote] --all 推送所有分⽀到远程仓库

5.操作撤销

git checkout [file] 恢复暂存区的指定⽂件到⼯作区 
git checkout [commit] [file]  恢复某个commit的指定⽂件到暂存区和⼯作区
git checkout . 恢复暂存区的所有⽂件到⼯作区 
git reset [commit] 重置当前分⽀的指针为指定commit,同时重置暂存区,但⼯作区不变 
git reset --hard 重置暂存区与⼯作区,与上⼀次commit保持⼀致 
git reset [file] 重置暂存区的指定⽂件,与上⼀次commit保持⼀致,但⼯作区不变 
git revert [commit]  后者的所有变化都将被前者抵消,并且应⽤到当前分⽀

// reset :真实硬性回滚,⽬标版本后⾯的提交记录全部丢失了 
// revert :同样回滚,这个回滚操作相当于⼀个提交,⽬标版本后⾯的提交记录也全部都有

6.存储操作

你正在进⾏项⽬中某⼀部分的⼯作,⾥⾯的东⻄处于⼀个⽐较杂乱的状态,⽽你想转到其他分⽀上进⾏ ⼀些⼯作,但⼜不想提交这些杂乱的代码,这时候可以将代码进⾏存储:

git stash 暂时将未提交的变化移除 
git stash pop 取出储藏中最后存⼊的⼯作状态进⾏恢复,会删除储藏 
git stash list 查看所有储藏中的⼯作 
git stash apply <储藏的名称>  取出储藏中对应的⼯作状态进⾏恢复,不会删除储藏 
git stash clear 清空所有储藏中的⼯作 
git stash drop <储藏的名称>  删除对应的某个储藏

三.git提交规范

下面是相关的提交规范,供铁子们参考,当然一切以公司规范为准,仅供借鉴:

提交开头 功能 示例
feat 新增功能 feat(auth): 添加登录谷歌验证功能
fix 修复Bug fix: 登录问题修复
docs 文档变更 docs: 更改了***文档内容
style 代码样式调整 style: 调整首页布局
refactor 代码重构 refactor: 优化用户登录逻辑
test 测试相关 test: 增加用户登录功能的单元测试
chore 构建/工具变更 chore: 更新.gitignore文件
perf 性能优化 perf: 提高首屏加载速度
ci CI/CD配置变更 ci: CI配置文件修改
revert 回退提交 revert: 还原错误提交

四,送给大家一个git常用命令速查表,方便快速定位

image.png


文章若有不足之处,欢迎各位大神评论区交流,我是向北者,喜欢的话点个关注,我将持续为大家填补软肋!

Vue打包后静态资源图片失效?一网打尽所有解决方案!

作者 北辰alk
2025年12月5日 08:15

今天我们来解决Vue项目中一个常见但令人头疼的问题:为什么开发环境好好的图片,一打包部署就失效了?

问题根源深度剖析

首先,让我们搞清楚问题的本质。为什么图片在开发环境正常,打包后却失效?

问题定位流程图

是

否

图片显示异常检查流程开发环境正常?打包后失效检查图片路径和格式失效类型分析绝对路径问题相对路径问题构建配置问题使用public目录或正确base路径调整路径处理方式修改vue.config.js配置

核心原因分析:

  1. 1. 路径解析差异:开发服务器与实际部署服务器的路径解析方式不同
  2. 2. 资源处理机制:Webpack对静态资源的处理方式
  3. 3. 基础路径配置:Vue应用的publicPath设置
  4. 4. 引用方式错误:不同引用方式导致的不同结果

解决方案大全

方案一:正确使用public目录(最简单)

Vue CLI官方推荐:将不需要处理的静态资源放在public目录中

目录结构示例:

项目根目录/
├── public/
│   ├── index.html
│   └── images/
│       ├── logo.png
│       └── banner.jpg
├── src/
└── package.json

引用方式:

<!-- 在模板中直接引用 -->
<img src="/images/logo.png" alt="Logo">
<img :src="'/images/banner.jpg'" alt="Banner">

<!-- 或在CSS中引用 -->
<style>
.header {
  background-imageurl('/images/banner.jpg');
}
</style>

关键点:

  • • 使用绝对路径(以/开头)
  • • 图片不会被Webpack处理,直接复制到dist目录
  • • 适合固定不变的图片

方案二:使用require动态引入(最灵活)

// 在Vue组件中
export default {
  data() {
    return {
      // 方法1:直接require
      imageUrlrequire('@/assets/images/logo.png'),
      
      // 方法2:动态require(根据条件加载)
      dynamicImagenull
    }
  },
  methods: {
    loadImage(imageName) {
      this.dynamicImage = require(`@/assets/images/${imageName}.png`)
    }
  },
  mounted() {
    this.loadImage('banner')
  }
}
<template>
  <div>
    <!-- 直接使用 -->
    <img :src="imageUrl" alt="Logo">
    
    <!-- 动态图片 -->
    <img v-if="dynamicImage" :src="dynamicImage" alt="动态图片">
    
    <!-- 内联require -->
    <img :src="require('@/assets/images/icon.png')" alt="图标">
  </div>
</template>

方案三:配置vue.config.js(最彻底)

创建或修改vue.config.js文件:

const path = require('path')

module.exports = {
  // 1. 设置publicPath(根据部署环境动态设置)
  publicPath: process.env.NODE_ENV === 'production' 
    ? '/your-project-name/'  // 生产环境路径
    : '/',                   // 开发环境路径
  
  // 2. 配置Webpack
  configureWebpack: {
    resolve: {
      alias: {
        // 设置别名,方便引用
        '@': path.resolve(__dirname, 'src'),
        'assets': path.resolve(__dirname, 'src/assets')
      }
    }
  },
  
  // 3. 链式操作Webpack配置
  chainWebpackconfig => {
    // 处理图片资源
    config.module
      .rule('images')
        .test(/.(png|jpe?g|gif|webp|svg)(?.*)?$/)
        .use('url-loader')
          .loader('url-loader')
          .tap(options => {
            // 修改loader配置
            options.limit = 4096  // 小于4KB的图片转为base64
            options.fallback = {
              loader'file-loader',
              options: {
                name'img/[name].[hash:8].[ext]',
                esModulefalse  // 解决某些版本的文件加载问题
              }
            }
            return options
          })
          .end()
  },
  
  // 4. 生产环境特定配置
  productionSourceMapfalse,
  
  // 5. 配置devServer(开发环境)
  devServer: {
    // 解决开发环境跨域和路径问题
    proxy: {
      '/api': {
        target'http://localhost:3000',
        changeOrigintrue
      }
    }
  }
}

方案四:CSS中图片处理技巧

/* 1. 相对路径(assets目录) */
.background-relative {
  /* 会被Webpack处理,转换为正确路径 */
  background-imageurl('./assets/bg.jpg');
}

/* 2. 使用别名 */
.background-alias {
  background-imageurl('~@/assets/images/bg.jpg');
}

/* 3. 使用data URL(小图片) */
.small-icon {
  background-imageurl('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PC9zdmc+');
}

/* 4. 使用CSS变量动态设置 */
:root {
  --theme-bgurl('../assets/theme.jpg');
}

.dynamic-bg {
  background-imagevar(--theme-bg);
}

实战案例:多环境配置

创建环境配置文件

.env.development(开发环境):

NODE_ENV=development
VUE_APP_BASE_URL=/api
VUE_APP_PUBLIC_PATH=/

.env.production(生产环境):

NODE_ENV=production
VUE_APP_BASE_URL=https://api.yourdomain.com
VUE_APP_PUBLIC_PATH=/your-project/

.env.staging(预发布环境):

NODE_ENV=production
VUE_APP_BASE_URL=https://staging-api.yourdomain.com
VUE_APP_PUBLIC_PATH=/

智能配置脚本

// config/pathConfig.js
const getPublicPath = () => {
  if (process.env.NODE_ENV === 'development') {
    return '/'
  }
  
  // 根据部署环境自动判断
  const hostname = window.location.hostname
  const pathname = window.location.pathname
  
  if (hostname.includes('staging')) {
    return '/'
  }
  
  if (hostname.includes('github.io')) {
    // GitHub Pages部署
    const repoName = process.env.VUE_APP_REPO_NAME || ''
    return repoName ? `/${repoName}/` : '/'
  }
  
  // 默认生产环境
  return process.env.VUE_APP_PUBLIC_PATH || '/'
}

export { getPublicPath }

完整示例:企业级解决方案

项目结构优化

src/
├── assets/
│   ├── images/
│   │   ├── common/     # 公共图片
│   │   ├── icons/      # 图标
│   │   └── banners/    #  banner图片
│   └── styles/
│       └── variables.scss
├── components/
├── utils/
│   └── imageLoader.js  # 图片加载工具
└── views/

图片加载工具类

// utils/imageLoader.js
class ImageLoader {
  constructor() {
    this.cache = new Map()
  }
  
  /**
   * 加载静态图片
   * @param {stringpath - 图片路径
   * @returns {string} 图片URL
   */
  static loadStatic(path) {
    try {
      return require(`@/assets/images/${path}`)
    } catch (error) {
      console.warn(`图片加载失败: ${path}`)
      return require('@/assets/images/default.png')
    }
  }
  
  /**
   * 加载网络图片(带预加载)
   * @param {stringurl - 图片URL
   * @returns {Promise}
   */
  static loadNetwork(url) {
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.onload = () => resolve(url)
      img.onerror = () => reject(new Error(`图片加载失败: ${url}`))
      img.src = url
    })
  }
  
  /**
   * 批量预加载图片
   * @param {Arrayimages - 图片数组
   */
  static preloadImages(images) {
    return Promise.all(images.map(url => this.loadNetwork(url)))
  }
}

export default ImageLoader

Vue组件中使用

<template>
  <div class="image-container">
    <!-- 1. 静态图片 -->
    <img :src="localImage" alt="本地图片" class="responsive-img">
    
    <!-- 2. 网络图片 -->
    <img 
      :src="networkImage" 
      alt="网络图片"
      @error="handleImageError"
      class="responsive-img"
    >
    
    <!-- 3. 懒加载图片 -->
    <img 
      v-lazy="lazyImage" 
      alt="懒加载图片"
      class="responsive-img"
    >
    
    <!-- 4. 响应式图片 -->
    <picture>
      <source 
        :srcset="require('@/assets/images/hero-large.jpg')" 
        media="(min-width: 1200px)"
      >
      <source 
        :srcset="require('@/assets/images/hero-medium.jpg')" 
        media="(min-width: 768px)"
      >
      <img 
        :src="require('@/assets/images/hero-small.jpg')" 
        alt="响应式图片"
        class="responsive-img"
      >
    </picture>
  </div>
</template>

<script>
import ImageLoader from '@/utils/imageLoader'

export default {
  name'ImageDemo',
  data() {
    return {
      localImageImageLoader.loadStatic('common/logo.png'),
      networkImage'https://example.com/image.jpg',
      lazyImageImageLoader.loadStatic('banners/home.jpg'),
      fallbackImageImageLoader.loadStatic('common/default.png')
    }
  },
  directives: {
    // 图片懒加载指令
    lazy: {
      inserted(el, binding) {
        const observer = new IntersectionObserver((entries) => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              el.src = binding.value
              observer.unobserve(el)
            }
          })
        })
        observer.observe(el)
      }
    }
  },
  methods: {
    handleImageError(event) {
      // 图片加载失败时显示默认图片
      event.target.src = this.fallbackImage
    },
    
    async preloadImportantImages() {
      const importantImages = [
        require('@/assets/images/hero.jpg'),
        require('@/assets/images/feature1.jpg'),
        require('@/assets/images/feature2.jpg')
      ]
      
      try {
        await ImageLoader.preloadImages(importantImages)
        console.log('重要图片预加载完成')
      } catch (error) {
        console.error('图片预加载失败:', error)
      }
    }
  },
  mounted() {
    this.preloadImportantImages()
  }
}
</script>

<style scoped>
.responsive-img {
  max-width100%;
  height: auto;
  display: block;
}

.image-container {
  margin20px;
  padding20px;
  border1px solid #eee;
  border-radius8px;
}

/* 图片加载动画 */
.responsive-img {
  opacity0;
  transition: opacity 0.3s ease;
}

.responsive-img.loaded {
  opacity1;
}
</style>

常见问题及排查清单

问题排查流程图

图片不显示检查控制台错误错误类型404错误路径错误跨域问题检查dist目录图片是否存在检查publicPath配置检查引用方式检查别名配置配置代理或CORS重新构建项目调整publicPath改用require或绝对路径检查vue.config.js配置正确响应头问题解决

快速排查步骤:

  1. 1. 检查控制台错误

    • • 查看Network面板,确认图片请求状态
    • • 检查Console面板,查看是否有加载错误
  2. 2. 检查构建输出

    # 查看dist目录结构
    ls -la dist/
    
    # 检查图片是否被正确复制
    find dist -name "*.png" -o -name "*.jpg"
    
  3. 3. 检查路径引用

    • • 绝对路径 vs 相对路径
    • • require引入 vs 直接引用
  4. 4. 检查环境配置

    // 在main.js中打印配置
    console.log('publicPath:', process.env.BASE_URL)
    console.log('NODE_ENV:', process.env.NODE_ENV)
    

最佳实践总结

  1. 1. 分类管理图片资源
    • • 小图标、Logo:使用雪碧图或字体图标
    • • 内容图片:放在public目录或使用CDN
    • • 背景图片:根据大小选择base64或单独文件
  2. 2. 环境感知配置
    • • 开发环境使用相对路径
    • • 生产环境使用绝对路径或CDN
    • • 预发布环境单独配置
  3. 3. 性能优化
    • • 小图片转为base64
    • • 大图片使用懒加载
    • • 重要图片预加载
  4. 4. 错误处理
    • • 添加图片加载失败回调
    • • 提供默认占位图
    • • 监控图片加载性能

结语

解决Vue打包后图片失效问题,关键在于理解Webpack的资源处理机制和Vue的路径解析规则。通过合理配置vue.config.js、正确使用图片引用方式、以及根据部署环境调整publicPath,你可以彻底告别图片加载问题。

记住,没有一种方案适用于所有场景。根据你的项目需求选择合适的方法,或者组合使用多种方案,才能达到最佳效果。

WebRTC 入门:一分钟理解一对多直播的完整实现流程

作者 三十_
2025年12月5日 08:07

本文以一个“老师直播授课、学生观看”的场景为例,梳理整个一对多 WebRTC 直播的实现流程,包括:

  • 老师端媒体流发布
  • 学生端媒体流接收
  • 双端 PeerConnection 的建连流程
  • addTransceiver 的作用与设计

本文的 demo 实现结构如下图所示:

image-20251201083100466.png


核心需求

在这个场景中:

  • 老师端发布自己的音视频流(推流)
  • 学生端只接收老师的音视频流(拉流),不发布自己的

实现要点

1. 角色区分

直播中所有人都加入同一个“房间”,但角色不同:

  • 老师端: 推流方,与每个学生建立 P2P,维护多个 PeerConnection,分别对应每个学生
  • 学生: 只拉流不推流,仅维护一个 PeerConnection(连接到老师)

image-20251201083748208.png

2. WebRTC 媒体流发布策略

老师端会获取本机摄像头/麦克风,并向每一个学生发送媒体流。

// 老师端
const pc = new RTCPeerConnection()
// 获取本地媒体流
localStream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
})
// 添加轨道
localStream.getTracks().forEach(track => {
  pc.addTrack(track, localStream!)
})

学生端不需要摄像头,也不发布任何多媒体,它只需要收老师的流。

// 学生端
const pc = new RTCPeerConnection()
pc.addTransceiver('audio', {direction: 'recvonly'})
pc.addTransceiver('video', {direction: 'recvonly'})

addTransceiver 解析

addTransceiver(kind, options) 它能让开发者在协商开始之前就明确媒体轨道的方向、数量和能力。相比 addTrack() 或仅靠收 offer/answer 再决定行为,addTransceiver() 的控制力是最强的。

分类 字段名 类型 默认值 说明
基本 kind "audio" / "video" 媒体类型
方向控制 direction string "sendrecv" 控制发送/接收方向
Track 关联 streams MediaStream[] [] 将 track 添加到哪些 stream
编码控制 sendEncodings RTCRtpEncodingParameters[] [] 控制码率/分辨率/Simulcast
(外部设置) codecPreferences RTCRtpCodecCapability[] 通过 transceiver.setCodecPreferences 设置

针对 direction 它提供四个选项:

direction 描述 使用场景
sendonly 只发送 推流、直播源
recvonly 只接收 观众、拉流端
sendrecv 双向发送接收 视频会议模式
inactive 暂不发送也不接收 预留媒体线路

回顾 P2P 实现流程

image-20251122113244881.png


总结直播实现流程

  • 学生端加入之后查询一次房间内所有人,找到老师后向其发起 P2P 连接;
  • 老师端则做为被叫等待学生端的呼叫,从而和学生建立连接。
  • 学生端 -> 发现老师 -> 发起呼叫
  • 老师端 -> 收到 Offer -> 回复 Answer
  • 双端交换 ICE -> 学生端接收轨道 -> 展示画面

最终形成:

  • 老师端推流(addTrack)
  • 学生端拉流(recvonly)
  • 多个学生 = 老师端维护 N 个 RTCPeerConnection
  • 每个学生只有 1 个 PeerConnection(只连老师)

JavaScript 继承与 `instanceof`:从原理到实践

作者 Tzarevich
2025年12月5日 00:40

JavaScript 继承与 instanceof:从原理到实践

在大型 JavaScript 项目中,多人协作开发时,我们常常会面对这样的问题:

“这个对象到底是什么类型?它有哪些属性和方法?”

此时,instanceof 运算符就显得尤为重要。它能帮助我们判断一个对象是否是某个构造函数的实例,从而安全地调用其方法或访问其属性。

但要真正理解 instanceof,我们必须深入 JavaScript 的原型与原型链机制,并掌握继承的多种实现方式——因为 instanceof 的本质,就是检查原型链上是否存在某个构造函数的 prototype 对象


一、instanceof 是什么?

instanceof 是一个二元关系运算符,语法为:

A instanceof B

它的含义是:

B.prototype 是否出现在 A 的原型链([[Prototype]])上?

✅ 示例:

[] instanceof Array;     // true
[] instanceof Object;    // true
new Date() instanceof Date; // true

🔍 底层原理(手写实现):

function myInstanceof(left, right) {
    if (typeof right !== 'function') return false;
    if (left == null) return false;

    let proto = Object.getPrototypeOf(left);
    while (proto) {
        if (proto === right.prototype) {
            return true;
        }
        proto = Object.getPrototypeOf(proto);
    }
    return false;
}

💡 注意:ES6+ 中 instanceof 还支持 Symbol.hasInstance 自定义行为,但核心仍是原型链查找。


二、为什么需要继承?

在 OOP 中,继承的本质是:子类能够复用父类的属性和方法
但在 JavaScript 中,由于没有“类”的概念(ES6 之前),我们必须通过原型机制来模拟继承。

🔗 原型机制与原型链

在 JavaScript 中,每个对象(除 null 外)都有一个内部属性 [[Prototype]](可通过 __proto__Object.getPrototypeOf() 访问),它指向另一个对象——即该对象的原型

当访问一个对象的属性时,如果自身没有,引擎会沿着 [[Prototype]] 链向上查找,直到找到该属性或到达链尾(null)。这条查找路径就是原型链

🧪 原型链长什么样?让我们实地看看:

运行以下代码,观察一个空数组的完整原型链:

const arr = []; // 等价于 new Array()
console.log(
arr.__proto__ === Array.prototype,          // true
arr.__proto__.__proto__ === Object.prototype, // true
arr.__proto__.__proto__.__proto__ === null    // true
);

输出清晰地展示了这条链:

 arr 
  → Array.prototype       // 第一层:数组方法(如 push, slice)Object.prototype    // 第二层:通用对象方法(如 toString, hasOwnProperty)null              // 第三层:原型链终点

🔍 这正是 instanceof 的判断依据!

  • 执行 arr instanceof Array 时,引擎检查:

    • Array.prototype 是否在 arr 的原型链上?✅(第一层命中)
  • 执行 arr instanceof Object 时,引擎检查:

    • Object.prototype 是否在链上?✅(第二层命中)

因此两者都返回 true

💡 核心原理
instanceof 的工作方式就是遍历 left[[Prototype]],逐个比对是否等于 right.prototype
只要找到,就返回 true;若遍历到 null 仍未找到,则返回 false

下面,我们从最简单的继承方式出发,逐步演进,揭示每种方法的缺陷,并引出更优解。


三、继承方式的演进

1. 构造函数绑定继承(借用构造函数)

✅ 实现:
function Animal(name) {
    this.name = name;
    this.species = '动物';
    this.hobbies = []; // 引用类型
}

function Dog(name, breed) {
    // 借用父类构造函数
    Animal.call(this, name);
    this.breed = breed;
}
✅ 优点:
  • 每个实例拥有独立的属性,避免引用类型共享;
  • 可向父类传参。
❌ 缺陷:
  • 无法继承父类原型上的方法

    Animal.prototype.eat = function() { console.log('吃'); };
    new Dog().eat(); // ❌ TypeError
    

结论:只能继承实例属性,不能复用方法。


2. 原型链继承(Prototype 模式)

✅ 实现:
function Animal() {
    this.species = '动物';
    this.hobbies = [];
}

function Dog(breed) {
    this.breed = breed;
}

// 父类实例作为子类原型
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog; // 修复 constructor
✅ 优点:
  • 子类实例可访问父类原型方法
  • 方法复用,节省内存。
❌ 缺陷:
  1. 所有子类实例共享父类实例属性

    const d1 = new Dog(), d2 = new Dog();
    d1.hobbies.push('睡');
    console.log(d2.hobbies); // ['睡'] ❌
    
  2. 无法向父类构造函数传参

  3. constructor 被破坏(需手动修复)。

结论:方法可复用,但实例属性不安全。


3. 组合继承(经典继承)

✅ 思路:结合前两种方式
  • call 继承实例属性
  • 用原型链继承原型方法
function Dog(name, breed) {
    Animal.call(this, name); // 独立属性
    this.breed = breed;
}

Dog.prototype = new Animal(); // 继承方法
Dog.prototype.constructor = Dog;
✅ 优点:
  • 属性独立 + 方法复用;
  • 支持传参;
  • instanceof 判断正常。
❌ 缺陷:
  • 父类构造函数被调用两次

    • 第一次:new Animal() 设置原型;
    • 第二次:Animal.call(this) 初始化实例。
  • 原型上多出无用属性(虽被覆盖,但存在)。

性能浪费,逻辑冗余。


4. 寄生组合继承(最优解)

✅ 核心思想:

不通过 new Parent() 创建原型,而是创建一个干净的中间对象,其原型指向 Parent.prototype

✅ 实现:
function inheritPrototype(Child, Parent) {
    const prototype = Object.create(Parent.prototype); // 创建空中介
    prototype.constructor = Child;
    Child.prototype = prototype;
}

function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}

inheritPrototype(Dog, Animal);
✅ 优点:
  • 父类构造函数只调用一次
  • 实例属性独立;
  • 原型方法复用;
  • 原型链干净,无冗余属性;
  • instanceofconstructor 均正确。

这是引用《JavaScript 高级程序设计》推荐的最佳继承模式。


四、现代方案:ES6 classextends

ES6 的 class 语法糖,底层正是基于寄生组合继承

class Animal {
    constructor(name) {
        this.name = name;
    }
    eat() { /*...*/ }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name); // 相当于 Animal.call(this, name)
        this.breed = breed;
    }
}
  • 自动处理原型链;
  • 自动修复 constructor
  • 代码简洁,语义清晰。

📌 新项目请优先使用 class,但务必理解其背后的原型机制。


五、总结:继承方式对比

方式 实例属性独立 方法复用 支持传参 调用次数 安全性
构造函数绑定 1
原型链继承 1
组合继承 2
寄生组合继承 1 最高
ES6 class 1 最高(推荐)

六、回到 instanceof

正是因为有了正确的继承(尤其是原型链的建立),instanceof 才能可靠工作:

const dog = new Dog('旺财', '金毛');
console.log(dog instanceof Dog);    // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true

在大型项目中,instanceof类型守卫(Type Guard) 的重要工具,能有效避免运行时错误。


结语

JavaScript 的继承看似简单,实则暗藏玄机。从最初的构造函数绑定,到原型链,再到组合与寄生组合,每一步演进都是对前一种方式缺陷的修正。

instanceof 作为原型链的“探测器”,其可靠性完全依赖于继承实现的正确性。

理解这些底层机制,不仅能写出更健壮的代码,也能在面试中游刃有余。

像前任一样捉摸不定的异步逻辑,一文让你彻底看透——JS 事件循环

2025年12月5日 00:17

一、进程线程那点事儿:浏览器里的 "打工人天团"

咱们先从最基础的概念聊起。很多人写了几年 JS,还分不清进程和线程,就像分不清奶茶里的珍珠和椰果 —— 虽然都在一个杯子里,但本质不一样。

  • 进程:可以理解为 CPU 运行指令时加载和保存上下文的时间成本,相当于一个独立的 "工作车间"

  • 线程:是 CPU 实际执行指令的时间,相当于车间里的 "打工人"

举个例子:当你多开一个浏览器标签页,就相当于新增了一个进程。每个进程里至少有这几个核心线程:

  1. 渲染线程(负责画页面)

  2. JS 引擎线程(执行我们写的代码)

  3. HTTP 请求线程(帮我们发网络请求)

这里有个关键知识点:JS 引擎线程和渲染线程是互斥的。就像同一时间只能有一个人用厕所,JS 在执行时浏览器没法没法渲染页面,渲染页面时 JS 也歇着 —— 所以别写太耗时的同步代码,不然页面会卡死,用户体验堪比便秘。

二、V8 引擎:单线程的 "倔强打工人"

咱们写的 JS 代码主要靠 V8 引擎执行,这货有个特点:默认只开一个线程

单线程意味着什么?所有任务得排队执行。就像只有一个收银台的超市,前面的人买完单,后面的才能上。但问题来了:如果前面有人买了 100 瓶矿泉水(比如一个耗时 3 秒的网络请求),后面的人岂不是要等到天荒地老?

于是,异步机制应运而生 —— 这是单线程逼出来的智慧:

  • 同步任务:直接执行(比如console.log

  • 异步任务:先放到任务队列排队,等主线程空闲了再处理(比如setTimeout

三、事件循环(Event Loop):异步任务的 "调度总指挥"

如果把 JS 执行过程比作演唱会,事件循环就是那个负责叫号的工作人员。它的核心工作就是协调同步任务、微任务、宏任务的执行顺序。

3.1 任务队列的 "阶级划分"

异步任务不是随便排队的,这里面有明确的 "等级制度":

  • 微任务(VIP 队列)

    • Promise.then()Promise.catch()等 Promise 回调

    • process.nextTick()(Node 环境,优先级比 Promise 还高)

    • MutationObserver(监听 DOM 变化的 API)

  • 宏任务(普通队列)

    • 整个 script 标签的代码(最开始执行的宏任务)

    • setTimeout()setInterval()

    • AJAX 网络请求

    • I/O 操作(比如读写文件)

    • UI 渲染(浏览器负责,JS 只能触发不能直接控制)

3.2 执行顺序:牢记这个 "四字口诀"

事件循环的执行规则就像医院就诊流程,记住这四步准没错:

  1. 执行同步代码(属于宏任务的一种),过程中遇到异步任务就按类型放进对应队列

  2. 清空微任务队列:同步代码执行完,立刻把所有微任务按顺序执行完毕

  3. 可能的渲染:微任务全搞定后,浏览器可能会渲染一次页面(不是每次都有)

  4. 执行下一个宏任务:从宏任务队列里取一个任务执行,然后重复 1-3 步

四、async/await:披着同步外衣的异步 "卧底"

很多新手被async/await迷惑,以为它们是同步代码,其实这俩是Promise的语法糖,本质还是异步。咱们来拆解一下:

  1. async 函数:在函数前面加async,相当于给函数装上了 "自动包装器"—— 无论你 return 什么值,都会被包装成Promise.resolve(返回值)。比如:
async function fn() { return 1 }

// 等同于

function fn() { return Promise.resolve(1) }
  1. await 关键字:必须和async搭配使用,就像泡面和热水的关系。它的作用是:
  • 等待后面的表达式执行(如果是 Promise 就等它状态变更,不是就直接拿结果)

  • 把自己后面的代码 "踢" 到微任务队列里(这是关键!)

举个例子:

async function foo() {

 await a()  // 这里会暂停,把后续代码丢进微任务队列

 b()

 console.log('hello');

}

执行到await a()时,JS 会先执行a(),然后把b()console.log('hello')打包扔进微任务队列,接着继续执行主线程的其他同步代码。

五、代码实测:用案例打脸 "想当然"

光说不练假把式,咱们用几个经典案例验证一下上面的理论。

案例 1:最基础的异步顺序

let a = 1

setTimeout(() => {

 a = 2

}, 1000)

console.log(a)  // 输出:1

解析setTimeout里的代码是异步宏任务,会在同步代码console.log(a)之后执行,所以打印 1 而不是 2。

案例 2:Promise 与 setTimeout 的较量

console.log(1);

new Promise((resolve) => {

 console.log(2);  // Promise构造函数是同步的

 resolve()

})

.then(() => {

 console.log(3);  // 微任务

 setTimeout(() => {

   console.log(4);  // 宏任务

 }, 0)

})

setTimeout(() => {

 console.log(5);  // 宏任务

 setTimeout(() => {

   console.log(6);  // 宏任务

 }, 0)

}, 0)

console.log(7);  // 同步代码

执行结果1 2 7 3 5 4 6

解析

  1. 先执行同步代码:12(Promise 构造函数)、7

  2. 清空微任务:3

  3. 执行宏任务队列:先执行第一个setTimeout打印5,再执行它里面的setTimeout(加入宏任务队列);然后执行then里的setTimeout打印4;最后执行最里面的setTimeout打印6

案例 3:多层 setTimeout 的执行顺序

console.log(1);

setTimeout(() => {

 console.log(2);

 setTimeout(() => {

   console.log(3)  // 1秒后执行

 }, 1000)

}, 0)

setTimeout(() => {

 console.log(4)  // 2秒后执行

}, 2000)

console.log(5);

执行结果1 5 2 3 4

解析

  1. 同步代码先执行:15

  2. 第一个setTimeout(0 延迟)先进入宏任务队列,执行后打印2,同时把内部的setTimeout(1 秒)加入队列

  3. 第二个setTimeout(2 秒)后进入队列,所以在3之后执行

案例 4:async/await 与 Promise 的混合战斗

console.log('script start');//1

async function async1() {

 await async2()

 console.log('async1 end');  // 5微任务

}

async function async2() {

 console.log('async2 end');  // 2同步执行

}

async1()

setTimeout(() => {

 console.log('setTimeout');  // 8宏任务

}, 0)

new Promise((resolve, reject) => {

 console.log('promise');  // 3同步执行

 resolve()

})

 .then(() => {

   console.log('then1');  // 6微任务

 })

 .then(() => {

   console.log('then2');  // 7微任务

 });

console.log('script end');  // 4同步执行

执行结果script start async2 end promise script end async1 end then1 then2 setTimeout

解析

  1. 同步代码阶段:打印script start → 调用async1()执行async2()打印async2 end → 遇到await,把async1 end丢进微任务 → 执行Promise构造函数打印promise → 打印script end

  2. 微任务阶段:先执行async1 end → 再执行第一个then打印then1 → 触发第二个then打印then2

  3. 宏任务阶段:执行setTimeout打印setTimeout

案例 5:async 函数中的定时器

function a() {

 return new Promise((resolve) => {

   setTimeout(() => {

     console.log('a');  // 1秒后执行的宏任务

     resolve()

   }, 1000)

 })

}

function b() {

 console.log('b');

}

async function foo() {

 setTimeout(() => {

   console.log('c');  // 1.5秒后执行的宏任务

 }, 1500)

 await a()  // 等待a()的Promise完成

 b()  // 微任务

 console.log('hello');  // 微任务

}

foo()

执行结果a b hello c

解析

  1. 调用foo()后,先执行里面的setTimeout(1.5 秒)加入宏任务队列

  2. 执行await a()时,先运行a()里的setTimeout(1 秒)加入宏任务队列

  3. 1 秒后,a()setTimeout执行,打印aresolve,此时b()console.log('hello')作为微任务执行

  4. 1.5 秒时,foo()里的setTimeout执行,打印c

六、避坑指南:这些 "坑" 我替你踩过了

  1. setTimeout (0) 不是立即执行:它只是告诉 JS"尽快",但必须等同步代码和微任务都执行完。实际延迟可能大于 0,取决于主线程忙碌程度。

  2. Promise 构造函数是同步的:只有.then().catch()里的回调才是微任务。别看到new Promise就以为里面的代码是异步的。

  3. 多个 setTimeout 的执行顺序:不一定按代码顺序,取决于延迟时间和事件循环的时机。比如延迟 100ms 的可能比延迟 50ms 的后执行,如果前者先被加入队列但延迟更长。

  4. async/await 的错误用法:如果await后面跟的不是 Promise,它就失去了 "等待" 的意义,后续代码会立即执行(相当于没加await)。

  5. 微任务的嵌套执行:在微任务里再添加微任务,会继续在当前微任务阶段执行,直到清空所有微任务。就像在医院急诊室里又新增了急诊病人,还是会优先处理。

七、总结:事件循环的 "一句话精髓"

同步代码先执行,遇到异步分两队(微任务、宏任务);同步干完清微队,微队清空看宏队,循环往复不停歇。

理解了事件循环,你会发现 JS 的异步行为其实非常有规律,就像广场舞大妈的舞步 —— 看似杂乱,实则章法严明。下次再遇到代码执行顺序不符合预期,不妨画个任务队列图,一步一步分析,再复杂的情况也能迎刃而解~

告别"回调地狱"!Promise让异步代码"一线生机"

2025年12月5日 00:16

什么是异步

我们都知道代码执行时,v8把这段代码先编译再运行,此时cpu就会接受一个指令来执行上下文环境,这时候用的时间就叫做进程。执行指令的时间就是一个线程,所以一个进程可以有多个线程。但是我们的v8编译器跟别的编程语言就有点不一样来看下面的代码。

 let a=1
setTimeout(()=>{
    a=2
},1000)
console.log(a);

如果按照正常的编译顺序这时候输出打印的a就是2,但是我们可以看到这时的结果是1。很显然,v8是直接先执行了第一行与第五行的代码把中间这个定时器挂起了。

屏幕截图 2025-12-04 184215.png 那这是为什么呢?

js默认是单线程运行的(v8默认只会开一个主线程来跑js代码)

  1. 因为js设计之处是浏览器的脚本语言,设计成单线程可以节约用户的设备性能
  2. js代码中存在耗时执行的,会被v8挂起,先执行不耗时的(同步代码)

这时候这种编程方式我们就叫作异步

如何解决异步

1.回调函数(初级)

来看这段代码,首先肯定就是先执行不消耗时间的指令所以先输出打印baz,a再是其他另外两个,但是我有没有一种方法可以foo先执行再是bar最后baz呢?

let a=1
function foo(){
    setTimeout(()=>{
        a=2
        
        console.log('foo',a);
        
    
    },1000)
}
function bar(){
    setTimeout(()=>{
        a=3
        
        console.log('bar',a);
        
    
    },2000)
}
function baz(){
    console.log('baz',a);
}
foo()

这时候我们就可以用到回调函数,把baz放进bar里面,把bar放进foo里面,形成一个嵌套。

let a=1
function foo(){
    setTimeout(()=>{
        a=2
        bar()
        console.log('foo',a);
        
    
    },1000)
}
function bar(){
    setTimeout(()=>{
        a=3
        baz()
        console.log('bar',a);
        
    
    },2000)
}
function baz(){
    console.log('baz',a);
}
foo()

屏幕截图 2025-12-04 194707.png 但是如果代码这样写的话,代码量大的话,就容易嵌套过深,形成回调地狱,我们维护代码时难以维护,一个函数有问题,一整个代码运行不了。所以这种方法虽然可行但是还是不够完美。

2.promise解决异步(高级)

我们用promise来以一个更加优雅的方式来解决这个问题,来看代码

let a=1
function foo(){
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
        a=2
        console.log('foo',a);
        resolve()
    },2000)
    })
}
function bar(){
   return new Promise((resolve,reject)=>{
     setTimeout(()=>{
        a=3
        
        console.log('bar',a);
        resolve()
    },1000)
   })
}
function baz(){
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
        a=4
     console.log('baz',a);
     resolve() 
    },500)
    })
    
}
foo().then(()=>{
    return bar()
})
.then(()=>{
    return baz()
})

在三个函数加入promise Promise 有三种状态:

  1. pending(进行中) - 初始状态
  2. fulfilled(已成功) - 操作成功完成
  3. rejected(已失败) - 操作失败、

Promise 创建后会立即执行这时候每个promise对象的状态会被修改为成功,而.then顾名思义就是下一个,执行完函数里的再执行.then。而then源码也默认返回了一个promise对象,状态继承了前面的foo。return 让 then 返回的promise状态根据bar返回的promise状态改变

3.Async/Await(进阶)

这里这样写虽然和promise差不多区别就在于最后把then换成了async,await.

  • 函数前面加一个async等同于函数内部返回一个promise实例对象

  • await 必须跟async 配合使用,并且await后面如果不接一个promise对象,await无法约束它

  • await fn() 把 fn() 当成同步看待

let a=1
function foo(){
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
        a=2
        console.log('foo',a);
        resolve()
    },2000)
    })
}
function bar(){
   return new Promise((resolve,reject)=>{
     setTimeout(()=>{
        a=3
        
        console.log('bar',a);
        resolve()
    },1000)
   })
}
function baz(){
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
        a=4
     console.log('baz',a);
     resolve() 
    },500)
    })
    
}
async function fn(){
    await foo()
    await bar()
    baz()
}
fn()


总结:异步编程的"思想升华"

JavaScript的异步演进,就像人类处理多任务的智慧成长:

  1. 原始时代(回调) :一件事一件事做,记在脑子里(嵌套)
  2. 农业时代(Promise) :用记事本记下待办事项(任务队列)
  3. 信息时代(Async/Await) :用智能助手管理所有任务(自动化调度)

记住这三个核心点:

  1. JavaScript是单线程,但不代表它慢 —— Event Loop让它高效处理并发
  2. Promise不是魔法,是一种状态管理机制 —— 三种状态,两种结果
  3. Async/Await是语法糖,不是新东西 —— 底层依然是Promise

JavaScript 垃圾回收机制详解

作者 之恒君
2025年12月5日 00:07

什么是垃圾回收?

垃圾回收(Garbage Collection)是 JavaScript 引擎自动管理内存的机制,它会定期找出不再使用的变量和对象,并释放它们占用的内存空间。

主要垃圾回收算法

1. 引用计数(Reference Counting) - 已淘汰

工作原理:跟踪每个值被引用的次数

let obj1 = { name: 'Alice' };  // 引用计数: 1
let obj2 = obj1;              // 引用计数: 2

obj1 = null;                  // 引用计数: 1
obj2 = null;                  // 引用计数: 0 → 可回收

循环引用问题

function problem() {
    let objA = {};
    let objB = {};
    
    objA.ref = objB;  // objA 引用 objB
    objB.ref = objA;  // objB 引用 objA
    
    // 即使函数执行完毕,引用计数都不为0,无法回收!
}

2. 标记-清除(Mark-and-Sweep) - 现代主流

工作原理:从根对象开始标记所有可达对象,清除未标记对象

// 根对象(全局变量、当前函数局部变量等)
// ↓ 标记阶段:遍历所有从根对象可达的对象
// ↓ 清除阶段:回收不可达对象

function example() {
    let obj1 = { data: 'temp' };    // 可达
    let obj2 = { related: obj1 };   // 可达
    
    obj1.connection = obj2;         // 互相引用,但都可达
}

// 函数执行完毕后,obj1 和 obj2 都不可达 → 被回收

现代 V8 引擎的垃圾回收机制

代际假说(Generational Hypothesis)

  • 大部分对象生存时间很短
  • 少数对象会存活很长时间

基于这个假说,V8 将堆内存分为两个区域:

1. 新生代(Young Generation)

  • 存放新创建的对象
  • 空间小(1-8MB),垃圾回收频繁
  • 使用 Scavenge 算法

Scavenge 算法过程

// 新生代分为两个半空间:FromTo
function scavengeExample() {
    // 新对象分配在 From 空间
    let newObj1 = { id: 1 };  // From 空间
    let newObj2 = { id: 2 };  // From 空间
    
    // 当 From 空间快满时,执行 Scavenge:
    // 1. 将存活对象复制到 To 空间
    // 2. 清空 From 空间
    // 3. 交换 FromTo 角色
}

对象晋升

function objectPromotion() {
    let tempObj = { data: 'temporary' };
    
    // 第一次 GC:tempObj 存活 → 复制到 To 空间
    // 第二次 GC:tempObj 仍然存活 → 晋升到老生代
    // 后续多次存活的对象会晋升到老生代
}

2. 老生代(Old Generation)

  • 存放存活时间较长的对象
  • 空间大,垃圾回收不那么频繁
  • 使用 标记-清除标记-整理 算法
let longLiveObj = { important: 'data' };  // 长期存活的对象

function createManyObjects() {
    for (let i = 0; i < 1000; i++) {
        let temp = { index: i };  // 大部分很快被回收
        if (i === 500) {
            longLiveObj.ref = temp;  // 让某个临时对象存活更久
        }
    }
}

垃圾回收的触发时机

1. 自动触发

// 以下情况可能触发 GC:
function autoGC() {
    // 1. 分配新对象时空间不足
    let bigArray = new Array(1000000);
    
    // 2. 定时执行(不同浏览器策略不同)
    setTimeout(() => {
        let anotherArray = new Array(100000);
    }, 1000);
}

2. 手动触发(谨慎使用)

// 非标准方法,主要用于调试
if (typeof global.gc === 'function') {
    global.gc();  // Node.js 中手动触发 GC
}

// 浏览器中(Chrome)
// 打开开发者工具 → Memory → 点击垃圾箱图标

内存泄漏的常见模式及避免方法

1. 意外的全局变量

// ❌ 错误做法
function leak1() {
    leakedVar = '这是一个全局变量';  // 忘记 var/let/const
}

// ✅ 正确做法
function noLeak1() {
    let localVar = '局部变量';
}

2. 遗忘的定时器和回调

// ❌ 内存泄漏
class Component {
    constructor() {
        this.data = largeData;
        this.timer = setInterval(() => {
            this.update();
        }, 1000);
    }
    
    // 忘记清理定时器!
}

// ✅ 正确做法
class SafeComponent {
    constructor() {
        this.data = largeData;
        this.timer = setInterval(() => {
            this.update();
        }, 1000);
    }
    
    destroy() {
        clearInterval(this.timer);  // 及时清理
        this.data = null;           // 解除引用
    }
}

3. DOM 引用泄漏

// ❌ 泄漏
const elements = {
    button: document.getElementById('myButton'),
    container: document.getElementById('container')
};

// 即使从 DOM 移除,JS 引用仍然存在
document.body.removeChild(elements.container);

// ✅ 正确做法
function cleanUp() {
    elements.button = null;
    elements.container = null;
}

4. 闭包引用

// ❌ 可能泄漏
function createClosure() {
    const bigData = new Array(1000000);
    
    return function() {
        // 闭包持有 bigData 的引用,即使不再需要
        console.log('closure executed');
    };
}

// ✅ 优化版本
function optimizedClosure() {
    const bigData = new Array(1000000);
    
    // 使用完后显式释放
    const result = processData(bigData);
    bigData.length = 0;  // 释放数组内存
    
    return function() {
        console.log(result);
    };
}

性能优化建议

1. 对象池模式

// ✅ 减少垃圾回收压力
class ObjectPool {
    constructor(createFn) {
        this.create = createFn;
        this.pool = [];
    }
    
    get() {
        return this.pool.length > 0 ? this.pool.pop() : this.create();
    }
    
    release(obj) {
        // 重置对象状态而不是销毁
        this.pool.push(obj);
    }
}

// 使用对象池
const particlePool = new ObjectPool(() => ({ x: 0, y: 0, active: false }));

2. 避免内存抖动

// ❌ 频繁创建销毁对象
function badPattern() {
    for (let i = 0; i < 1000; i++) {
        let tempObj = { index: i };  // 频繁 GC
        process(tempObj);
    }
}

// ✅ 重用对象
function goodPattern() {
    let tempObj = {};
    for (let i = 0; i < 1000; i++) {
        tempObj.index = i;  // 重用对象
        process(tempObj);
    }
}

调试内存问题

Chrome DevTools 内存分析

// 1. 创建内存快照
function createSnapshot() {
    // 在 DevTools Memory 面板点击 "Take snapshot"
}

// 2. 内存分配时间线
function trackAllocations() {
    // 使用 "Allocation instrumentation on timeline"
}

// 3. 检查分离的 DOM 节点
function checkDetachedDOM() {
    // 查看 "Detached" 的 DOM 节点
}

总结

JavaScript 的垃圾回收机制让开发者无需手动管理内存,但理解其工作原理对于:

  • 避免内存泄漏
  • 优化性能
  • 调试内存问题
  • 编写高效代码

至关重要。记住关键原则:及时释放不再需要的引用,特别是在处理大型数据结构和长期运行的应用中。

JavaScript 中 this 的那些事儿:从“指哪打哪”到“我到底是谁?”

作者 ohyeah
2025年12月4日 23:50

大家好!今天我们来聊一聊 JavaScript 中那个让人又爱又恨、经常让人抓狂的关键词 —— this

你是不是也曾经在控制台看到 undefined 或者意外地修改了全局变量,然后一脸懵:“这 this 到底指向谁啊?”别急,今天我们就结合几个小例子,轻松愉快地把 this 的各种行为搞清楚!


一、this 由调用方式决定

先说一个关键点:this 的值不是在函数定义时确定的,而是在函数被调用时才确定的。

换句话说,this 是个“执行时变量”,和作用域链(词法作用域)无关。而像 myName 这样的变量,是通过词法作用域查找的,属于“编译阶段就定好的”。

来看第一个例子:

'use strict'

var bar = {
  myName: 'time.geekbang.com',
  printName: function(){
    console.log(myName)        // 自由变量,查词法作用域 → 全局 var myName
    console.log(this.myName)   // this 指向谁?看怎么调用!
    console.log(this)
  }
}

var myName = '极客邦'
var _printName = foo() // 返回 bar.printName 函数引用
_printName() // 普通函数调用!

关键解析:

  • printName 虽然写在 bar 对象里,但它本质上是在全局作用域中定义的函数(JS 对象不创建新作用域)。

  • 所以 console.log(myName) 中的 myName自由变量,引擎会沿着词法作用域向外找 → 找到全局的 var myName = '极客邦'

  • this.myName 就不一样了!this 取决于调用方式

    • _printName()普通函数调用 → 非严格模式下 this === window,严格模式下 this === undefined
    • 所以 this.myName 在严格模式下会报错(因为 undefined.myName);非严格模式下会输出 window.myName,而 var 声明的变量会挂载到 window 上,所以能拿到 '极客邦'

✅ 小贴士:let 声明的变量不会挂载到 window,所以如果你把 var myName 改成 let myName,即使非严格模式下 this.myName 也会是 undefined


二、作为对象方法调用:这才是 this 的“本职工作”

当你这样调用:

bar.printName() // 作为对象的方法调用

这时,this 就会正确指向 bar 对象,于是 this.myName 输出 'time.geekbang.com'

这就是 this 最常见的用途:在面向对象编程中,让方法能访问所属对象的属性

可惜的是,JavaScript 的设计有点“灵活过头”——函数可以脱离对象单独调用,这时候 this 就“迷失自我”了。


三、严格模式:给 this 加个“安全锁”

早期 JavaScript 有个“偷懒”设计:普通函数调用时,this 默认指向全局对象(浏览器中是 window),虽然说此时this是没有必要的,但是它总得有要指向的东西,作者直接让this指向全局了。这很容易造成意外污染全局变量

比如:

function foo() {
  this.x = 100; // 如果不小心这么写,x 就挂到 window 上了!
}
foo(); // 普通调用 → this = window

为了解决这个问题, 引入了 严格模式('use strict'

'use strict'
function foo() {
  console.log(this); // undefined!
}
foo(); // 报错 if you access this.property

🔒 严格模式下,普通函数调用的 thisundefined,规避了没必要的this指向,防止意外的全局绑定,提高了代码的安全性


四、手动指定 thiscall / apply

有时候我们想“强行”让某个函数的 this 指向特定对象,怎么办?

JavaScript 提供了两大法宝(其实还有bind,我们下次再聊它):

  • func.call(obj, arg1, arg2)
  • func.apply(obj, [arg1, arg2])

看看这个例子:

let bar = { myName: '极客邦', test1: 1 }

function foo() {
  this.myName = '极客时间'
}

foo.call(bar) // 强制 this 指向 bar
console.log(bar) // { myName: '极客时间', test1: 1 }

foo.call(bar)这段代码是关键,强制执行foo函数,并将其内部this强行指向bar


五、构造函数中的 this:指向新实例

再看:

function CreateObj() {
  console.log(this) // 指向新创建的实例
  this.name = '极客时间'
}

var myObj = new CreateObj()

当使用 new 调用函数时,JavaScript 引擎会:

  1. 创建一个空对象 {}
  2. 把这个对象的 __proto__ 指向构造函数的 prototype
  3. this 绑定到这个新对象
  4. 执行函数体;
  5. 返回这个对象(除非你显式 return 一个对象)。

所以,构造函数里的 this 永远指向即将被创建的实例


六、事件处理函数中的 this

在 DOM 事件中,this 也有特殊规则。

<a href="#" id="link">点击我</a>
<script>
  document.getElementById('link').addEventListener('click', function() {
    console.log(this) // 指向 <a> 元素!
  })
</script>

普通函数作为事件处理器时,this 指向触发事件的 DOM 元素。

但如果换成箭头函数:

addEventListener('click', () => {
  console.log(this) // 指向外层作用域的 this(通常是 window 或 undefined)
})

因为箭头函数没有自己的 this,它会继承外层作用域的 this。这也是为什么在 React 等框架中,类方法常用箭头函数避免 this 丢失。


七、常见误区澄清

❌ 误区1:“返回函数就形成闭包”

看这段代码:

function foo() {
  let myName = '极客时间'
  return bar.printName
}

很多人以为这里形成了闭包。其实没有!因为 printName 并没有引用 foo 内部的任何变量。它的词法作用域仍然是全局,所以 console.log(myName) 打印的是全局的 '极客邦',而不是 '极客时间'

✅ 闭包 = 函数 + 引用了外层变量。缺一不可!

❌ 误区2:“对象内部的函数有自己的作用域”

JS 中,只有函数能创建作用域,对象 {} 不会!所以 bar.printName 的作用域就是它被定义的地方 —— 全局。


八、总结:this 的五大绑定规则

调用方式 this 指向
普通函数调用 全局对象(非严格模式)/ undefined(严格模式)
对象方法调用 该对象
call / apply 第一个参数指定的对象
new 构造函数调用 新创建的实例
DOM 事件处理函数 触发事件的元素

记住一句话:this 指向“调用者”,而不是“定义者”


结语:和 this 和解吧!

this 看似混乱,其实规则很清晰。只要记住它的核心原则 —— 由调用方式决定,再配合严格模式、箭头函数、apply 等工具,你就能完全掌控它。

下次再遇到 this 问题,不妨问自己一句:

“这个函数,到底是在调用它?”

答案,就在调用的那一行代码里。

希望这篇文章能帮你彻底理清 this 的迷思!如果觉得有用,欢迎点赞、收藏、转发~也欢迎在评论区分享你和 this 的“相爱相杀”故事 😄


JavaScript 继承终极解析:原型链闭环、六大继承方式对比与所有致命坑

作者 不会js
2025年12月4日 23:47

JavaScript 继承终极解析:原型链闭环、六大继承方式对比与所有致命坑

大家好,今天带大家彻底搞懂 JavaScript 中最绕脑、面试最爱问、却又最容易踩坑的知识点——继承

我们先来了解一下继承的大概知识点:6 种继承方式对比 + 手写 instanceof 然后直接闪现来到本文的核心重点————继承中最烧脑的几个知识点

7d51c79f4346e65c2eafb673286d21c7.jpg

一、先搞清楚 JavaScript 的“血缘关系”—— 原型链

在开始继承之前,先问你三个灵魂问题:

[].__proto__               // 指向谁?
Array.prototype.__proto__  // 指向谁?
Object.prototype.__proto__ // 是 null!

这就是传说中的原型链:

实例 → 构造函数.prototypeObject.prototypenull

记住这张图,它是后面所有继承的根基:

cat1 → Cat.prototypeAnimal.prototypeObject.prototypenull

二、手写 instanceof —— 面试必考

 function isInstanceOf(left,right){
            let proto = left.__proto__;
            while(proto){
                if(proto===right.prototype){
                    return true
                }
                proto=proto.__proto__;//null
            }
             return false
        }

经典测试:

function Animal() {}
function Cat() {}
Cat.prototype = new Animal();

const cat = new Cat();
console.log(myInstanceof(cat, Animal)); // true
console.log(myInstanceof(cat, Object)); // true
console.log(myInstanceof(cat, Cat));     // true

易错点提醒

  • instanceof 看的是原型链,不是 constructor!
  • cat.constructor === Animal 可能是 false!因为 constructor 很容易被改掉
console.log(cat.constructor === Animal) // 很可能 false!
console.log(cat.constructor === Cat)    // 我们后面会修复它

三、JavaScript 继承的六种姿势(从原始到完美)

方式一:构造函数继承(借用构造函数)

function Animal() {
    this.species = "动物";
}
function Cat(name, color) {
    Animal.call(this);  // 借用父类构造函数
    this.name = name;
    this.color = color;
}

优点:能继承实例属性
缺点:方法全在构造函数里,每次 new 都重新创建,浪费内存 + 拿不到父类原型上的方法

方式二:原型链继承(经典但有坑)

function Animal() {}
Animal.prototype.species = "动物";

function Cat(name, color) {
    this.name = name;
    this.color = color;
}
Cat.prototype = new Animal();        // 关键!
Cat.prototype.constructor = Cat;     // 手动修复 constructor

优点:能继承原型上的方法
缺点:所有实例共享父类实例的引用属性(大坑!)

Cat.prototype.habits = ['sleep'];
const cat1 = new Cat();
const cat2 = new Cat();
cat1.habits.push('eat');
console.log(cat2.habits); // ["sleep", "eat"] 被污染了!

方式三:直接继承 prototype(副作用爆炸)

Cat.prototype = Animal.prototype; // 千万别写!

后果:给 Cat 加方法,Animal 也被污染了!

Cat.prototype.say = () => console.log('miao');
console.log(Animal.prototype.say); // 也有了!血案!

方式四:圣杯模式(经典中间函数)

function inherit(Child, Parent) {
    const F = function() {};
    F.prototype = Parent.prototype;
    Child.prototype = new F();
    Child.prototype.constructor = Child;
    // 可选:保留对父类的引用
    Child.prototype.uber = Parent.prototype;
}

inherit(Cat, Animal);

这是尤雨溪(Vue 作者)当年在论坛里学的经典写法,至今仍是最佳实践之一。

方式五:ES6 class extends(现代写法,底层还是原型)

class Animal {
    constructor() {
        this.species = "动物";
    }
    eat() {
        console.log("eating...");
    }
}

class Cat extends Animal {
    constructor(name, color) {
        super(); // 必须先调用 super
        this.name = name;
        this.color = color;
    }
}

注意:子类 constructor 里必须先调用 super(),否则 this 会报错!

方式六:终极方案 —— 组合继承(生产推荐)

综合前面两种优点,避免所有缺点:

function Animal() {
    this.species = "动物";
}
Animal.prototype.eat = function() {
    console.log("eating...");
};

function Cat(name, color) {
    Animal.call(this);     // 继承实例属性
    this.name = name;
    this.color = color;
}
Cat.prototype = Object.create(Animal.prototype); // 继承原型
Cat.prototype.constructor = Cat;

Cat.prototype.say = function() {
    console.log("miao~");
};

这就是 Vue/React 源码里真正使用的继承方式!

四、终极对比表(建议收藏)

方式 实例属性 原型方法 引用属性安全 constructor正确 推荐指数
call 继承 Yes No Yes Yes 2星
prototype = new Parent() Yes Yes No Need repair 3星
直接继承 prototype Yes Yes Yes Need repair 1星(副作用太大)
圣杯模式 Yes Yes Yes Yes 4星
Object.create Yes Yes Yes Yes 5星
class extends Yes Yes Yes Yes 5星(现代首选)

五、继承中最烧脑的几个知识点

7371e6c646ae0a7919845d85490c9946.jpg

1.直接继承 prototype(最严重、最容易被忽视的副作用)

function Animal() {}
Animal.prototype.species = '动物';

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 危险写法!!!直接把父类的 prototype 赋值给子类
Cat.prototype = Animal.prototype;   // ← 这里就是罪魁祸首
副作用一:子类给原型加方法,父类也被污染(双向污染)
// 给 Cat 的原型加一个方法
Cat.prototype.say = function() {
  console.log('miao~');
};

console.log(Animal.prototype.say);  
// 输出:function say() { console.log('miao~') }
// 什么???Animal 实例怎么也会叫了?!
详细过程图解:
原来的结构:
Cat.prototype      → 指向一个对象 A
Animal.prototype    → 指向另一个对象 B

执行 Cat.prototype = Animal.prototype 之后:
Cat.prototype      → 指向对象 B   ←┐
Animal.prototype   → 指向对象 B     → 同一个对象!
                                    ↑
                              两个构造函数共用同一个原型对象

此时你对 Cat.prototype 做的任何修改,等同于直接修改 Animal.prototype

const cat = new Cat('小黑', '黑色');
const tiger = new Animal();  // 老虎是 Animal 的实例

cat.say();        // "miao~"
tiger.say();      // "miao~" !!!老虎也会猫叫了,彻底乱套
副作用二:constructor 被彻底搞乱
console.log(Cat.prototype.constructor);     // 原来应该是 Cat
// 结果:function Animal() {}   ← 被覆盖成了 Animal!!

// 所有 Cat 实例的 constructor 也错了
console.log(cat.constructor === Animal);    // true
console.log(cat.constructor === Cat);       // false
副作用三:instanceof 完全失灵
console.log(cat instanceof Cat);     // true  (侥幸还对)
console.log(cat instanceof Animal);  // true

// 但是如果你再定义一个 Dog 也这么干:
Dog.prototype = Animal.prototype;
const dog = new Dog();

console.log(dog instanceof Cat);     // true!!!狗居然是猫的实例???
console.log(dog instanceof Dog);     // true
console.log(dog instanceof Animal);  // true

彻底乱套了!所有继承 Animal 的子类之间互相都是 instanceof true。

结论:直接把 Child.prototype = Parent.prototype 是 JavaScript 继承中的“核弹级”错误,绝对禁止!

2.原型链继承(Cat.prototype = new Animal())的经典副作用:引用类型属性被所有实例共享

function Animal() {
  this.habits = ['睡觉', '吃饭'];  // 注意:引用类型!
}
Animal.prototype.species = '动物';

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 经典原型链继承(很多人还在用)
Cat.prototype = new Animal();        // ← 这里创建了父类实例
Cat.prototype.constructor = Cat;

const cat1 = new Cat('小黑', '黑色');
const cat2 = new Cat('小白', '白色');
副作用:所有猫共享同一份习惯列表!
cat1.habits.push('玩毛球');

console.log(cat1.habits);  // ["睡觉", "吃饭", "玩毛球"]
console.log(cat2.habits);  // ["睡觉", "吃饭", "玩毛球"]  ← 被污染了!
详细过程图解:
Cat.prototypenew Animal() 创建的实例
                  │
                  ├── species: "动物" (原型上的)
                  └── habits: ["睡觉", "吃饭"] ← 堆内存中的同一个数组!

cat1.__proto__Cat.prototype → 这个实例 → habits 数组
cat2.__proto__Cat.prototype → 同一个实例 → 同一个 habits 数组

image.png

所有 Cat 实例的 habits 指向的是 Cat.prototype(即那个 Animal 实例)上的同一个数组引用!

更隐蔽的坑:即使你不直接访问 habits,也会被污染
function Animal() {
  this.friends = [];
}

Cat.prototype = new Animal();

const cat1 = new Cat('小黑');
const cat2 = new Cat('小白');

cat1.friends.push(cat2);  // 小黑把小白加为好友
console.log(cat2.friends); // [cat2]  小白居然是自己的好友?!循环引用!
为什么 constructor 需要手动修复?

因为 new Animal() 创建的实例的 constructor 是 Animal:

const temp = new Animal();
console.log(temp.constructor === Animal);  // true

Cat.prototype = temp;
// 所以 Cat.prototype.constructor 现在也指向 Animal 了!
console.log(Cat.prototype.constructor === Animal); // true ← 错误!

不手动修复的话:

console.log(cat1.constructor === Animal); // true!明明是 Cat 的实例

这就是为什么经典写法一定要加这一行:

Cat.prototype.constructor = Cat;  // 必须手动修复!

正确做法对比(推荐的三种)

写法 是否有引用共享问题 是否污染父类 constructor 是否正确 推荐度
Cat.prototype = Animal.prototype 严重污染 错误 0星
Cat.prototype = new Animal() 有(父类实例属性) 需要手动修复 3星
Cat.prototype = Object.create(Animal.prototype) 需要修复或用 defineProperty 5星
class Cat extends Animal 自动正确 5星

终极推荐写法(杜绝所有副作用)

function Animal(name) {
  this.name = name;
  this.friends = [];  // 即使父类有引用类型也没事
}
Animal.prototype.species = '动物';

function Cat(name, color) {
  Animal.call(this, name);                    // 继承实例属性(各自独立)
  this.color = color;
}

// 关键:用 Object.create 创建一个干净的中间对象
Cat.prototype = Object.create(Animal.prototype);
// 完美修复 constructor(推荐这种不可枚举的方式)
Object.defineProperty(Cat.prototype, 'constructor', {
  value: Cat,
  enumerable: false,
  writable: true
});

这样:

  • 引用类型属性各自独立
  • 不污染父类原型
  • constructor 正确
  • instanceof 完全正常

这就是 Vue 2.x 中 Vue.extend、React 老版本 createClass 底层真正使用的继承方式!

记住:

直接赋值 prototype = 父类原型 → 核弹
new 父类() 当原型 → 地雷
Object.create + call → 完美

3.你不知道的prototype和__proto__

prototype是构造函数的属性,__proto__是实例的属性

  1. 什么时候用 prototype(构造函数层面) 使用场景:当你想要定义或修改某个构造函数创建的"所有实例"的共享属性和方法时

text

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

// 使用 prototype - 为所有Person实例添加共享方法
Person.prototype.sayHello = function() {
    console.log(`Hello, I'm ${this.name}`);
};

Person.prototype.species = 'Human'; // 共享属性

const person1 = new Person('Alice');
const person2 = new Person('Bob');

person1.sayHello(); // 所有实例都能访问prototype上的方法
person2.sayHello();

关键点:

  • 只有函数(构造函数)才有 prototype属性
  • 用于设置"蓝图"或"模板"
  • 修改会影响所有已创建和未来创建的实例
  1. 什么时候用 proto(实例层面) 使用场景:当你想要查看或操作某个具体实例的原型链时

text

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

const person = new Person('Alice');

// 使用 __proto__ - 查看或修改这个具体实例的原型
console.log(person.__proto__ === Person.prototype); // true

// 检查原型链
console.log(person.__proto__.__proto__ === Object.prototype); // true

// 实际开发中更推荐使用以下方法:
console.log(Object.getPrototypeOf(person) === Person.prototype); // true
console.log(person instanceof Person); // true

关键点:

  • 所有对象(包括实例)都有 __proto__属性
  • 主要用于调试、检查继承关系

基于第3点引出了最烧脑的一个知识点

“Function 是由 Function 自己 new 出来的, Object 是由 Function new 出来的, Function.prototype 也是由 Object new 出来的…… 这就是一个完美的鸡生蛋蛋生鸡的闭环!”

看完这段话你肯定满脸问号?这是个啥?

a8a0956310fbc3378caa4831a0918c2b.jpg 别急,我们只需要理解这三句话就能读懂:

  • 所有函数(包括 Function 和 Object 自己)都是 Function 的实例 → 所以它们的 proto 统统指向 Function.prototype

  • Object 也是函数,所以 Object.proto === Function.prototype → 这一步把 Object 拉进来了

  • Function.prototype 自己也是一个对象,所以它的 proto 必须指向所有对象的终点 —— Object.prototype → 这一步把 Function.prototype 接回去,闭环完成!

console.log(Function.__proto__ === Function.prototype);          // true  自己是自己的爹
console.log(Function.prototype.__proto__ === Object.prototype);  // true  爹的爹是 Object.prototype
console.log(Object.__proto__ === Function.prototype);            // true  Object 的爹是 Function.prototype
console.log(Object.prototype.__proto__ === null);                // true  终点

写在最后

JavaScript 的继承,本质就是原型链的游戏。
掌握了原型链,你就掌握了 JavaScript 的灵魂。

记住一句话:

在 JavaScript 中,没有类,只有原型。
class 只是语法糖,extends 只是 Object.create 的甜甜圈。

❌
❌