阅读视图

发现新文章,点击刷新页面。

react-grid-layout 原理拆解:布局引擎、拖拽系统与响应式设计

react-grid-layout是 React 生态中一个非常流行的、用于构建可拖拽可调整大小响应式网格布局的库。它的强大之处在于用简洁的 API 实现了复杂的布局管理。

一、 布局、坐标

react-grid-layout的实现基石在于它将组件的实际屏幕位置抽象的网格位置彻底分离

1. 布局数组

不直接存储组件的像素位置,而是维护一个名为 layout 的 JavaScript 对象数组

每个元素(item)在布局数组中都是一个对象,包含以下关键属性:

属性 类型 描述
i String 元素的唯一 ID,对应于其 key 或子组件的 key
x Number 元素在网格中的起始坐标(Grid X)。
y Number 元素在网格中的起始坐标(Grid Y)。
w Number 元素的宽度,占用的网格列数
h Number 元素的高度,占用的网格行数
static Boolean 如果为 true,则元素不可拖拽和调整大小。

2. 坐标转换

核心逻辑在于将上述抽象的 (x, y, w, h) 网格坐标实时转换成浏览器能理解的 CSS 像素坐标

该转换依赖于两个配置项:

  • cols: 网格的总列数
  • rowHeight: 每行网格的高度(像素值)
  • margin: 网格项之间的间隔(像素值)

Item Width (px)=(w×Cell Width)+((w1)×Margin)\text{Item Width (px)} = (w \times \text{Cell Width}) + ((w-1) \times \text{Margin})

Cell Width=Container Width((Cols+1)×Margin)Cols\text{Cell Width} = \frac{\text{Container Width} - ((\text{Cols} + 1) \times \text{Margin})}{\text{Cols}}

Item Height (px)=(h×Row Height)+((h1)×Margin)\text{Item Height (px)} = (h \times \text{Row Height}) + ((h-1) \times \text{Margin})

利用这些公式计算每个网格项的 topleftwidthheight,并通过 CSS transform: translate(x, y) 来定位组件,而不是传统的 top/left,这能带来更好的性能。

二、 核心组件结构

主要由以下三个 React 组件构成:

1. ResponsiveReactGridLayout

这是最外层的容器组件。它负责处理响应式逻辑

  • 监听窗口大小变化(resize 事件)
  • 根据当前的容器宽度,确定应该加载哪个断点(Breakpoint) (例如:lg, md, sm 等)
  • 根据断点和其对应的 layout 配置,渲染 ReactGridLayout

2. ReactGridLayout

这是网格渲染的核心组件,它负责:

  • 计算和设置容器的总高度(min-height),以确保所有网格项都能被容纳。
  • layout 数组遍历,为每一个网格项渲染一个 GridItem
  • 管理拖拽和调整大小操作的状态(影子/占位符)。

3. GridItem

这是每个可拖拽/调整大小的网格项的容器

  • 渲染一个内部的 div 来包裹用户传入的子组件
  • 注入拖拽句柄调整大小句柄
  • 通过监听鼠标事件(onMouseDown/onMouseMove/onMouseUp)实现交互

三、 拖拽和调整大小原理

拖拽和调整大小依赖于两个库react-draggablereact-resizable

1. 占位符与状态管理

当用户开始拖拽或调整大小时,不会立即更新 layout 状态,而是通过一种“影子”机制来优化性能和用户体验

  • 占位符(Placeholder) : 一个半透明的、与当前操作网格项具有相同尺寸的元素会出现在其下方,指示操作完成后网格项将占据的位置
  • 操作过程: 在 onMouseMove 过程中,RGL 实时计算鼠标位置对应的新网格坐标 (new_x, new_y, new_w, new_h)\text{(new\_x, new\_y, new\_w, new\_h)},并更新占位符的位置
  • 操作结束: 只有在 onMouseUp 释放时,RGL 才会调用 onLayoutChange,将最终的网格坐标更新到父组件中

2. 网格冲突解决算法

  1. 冲突检测: 检测新位置 A’\text{A'} 是否与任何其他网格项 B\text{B} 发生矩形重叠。
  2. 向下推 : 如果发生冲突,会尝试将 B\text{B} 以及与 B\text{B} 冲突的其他网格项向下(增大 y\text{y} 坐标)推动,直到不再发生冲突
  3. 紧凑化 : 在每次布局变化后,可以执行 Compaction 算法。它会尝试将所有非静态的网格项向上(减小 y\text{y} 坐标)或向左(减小 x\text{x} 坐标)移动到可用的最高/最左位置,从而消除网格中的不必要空白

基本数据类型Symbol的基本应用场景

Symbol 作为 ES6 新增的基本数据类型,核心特性是唯一性不可枚举性,在实际项目中主要用于解决命名冲突、保护对象私有属性等场景。以下是具体的应用举例及代码实现:

一、作为对象的唯一属性名,避免属性冲突

当多人协作开发或引入第三方库时,普通字符串属性名容易被覆盖,Symbol 可确保属性唯一。

示例:组件库的私有属性

// 定义唯一的 Symbol 属性
const internalState = Symbol('internalState');

class Button {
  constructor() {
    // 用 Symbol 作为私有属性名,外部无法直接访问
    this[internalState] = {
      clicked: false,
      disabled: false
    };
  }

  click() {
    if (!this[internalState].disabled) {
      this[internalState].clicked = true;
      console.log('按钮被点击');
    }
  }

  disable() {
    this[internalState].disabled = true;
  }
}

const btn = new Button();
btn.click(); // 正常执行

// 外部无法通过常规方式访问或修改 internalState
console.log(btn.internalState); // undefined
console.log(btn[Symbol('internalState')]); // undefined(Symbol 是唯一的)

二、定义常量,避免魔术字符串

魔术字符串(直接写在代码中的字符串)易出错且难维护,用 Symbol 定义唯一常量更可靠。

示例:状态管理中的事件类型

// event-types.js
export const EVENT_TYPES = {
  LOGIN: Symbol('login'),
  LOGOUT: Symbol('logout'),
  UPDATE_USER: Symbol('updateUser')
};

// 使用常量
function handleEvent(eventType) {
  switch (eventType) {
    case EVENT_TYPES.LOGIN:
      console.log('用户登录');
      break;
    case EVENT_TYPES.LOGOUT:
      console.log('用户登出');
      break;
    default:
      console.log('未知事件');
  }
}

handleEvent(EVENT_TYPES.LOGIN); // 输出“用户登录”

三、实现对象的 “私有属性”

虽然 JavaScript 没有真正的私有属性,但 Symbol 属性默认不可被 for...inObject.keys() 枚举,可模拟私有属性。

示例:类的私有方法 / 属性

const privateMethod = Symbol('privateMethod');

class User {
  constructor(name) {
    this.name = name; // 公共属性
    this[Symbol('id')] = Math.random().toString(36).slice(2); // 私有属性
  }

  [privateMethod]() {
    // 私有方法,外部无法调用
    return `用户ID:${this[Symbol('id')]}`;
  }

  getInfo() {
    // 公共方法间接调用私有方法
    return `${this.name} - ${this[privateMethod]()}`;
  }
}

const user = new User('Alice');
console.log(user.getInfo()); // 正常输出

// 无法枚举 Symbol 属性
console.log(Object.keys(user)); // ['name']
for (const key in user) {
  console.log(key); // 仅输出 'name'
}

四、自定义迭代器(Iterator)

Symbol.iterator 是内置 Symbol,用于定义对象的迭代器,让对象可被 for...of 遍历。

示例:自定义可迭代对象

const iterableObj = {
  data: ['a', 'b', 'c'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          return { value: this.data[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// 可通过 for...of 遍历
for (const item of iterableObj) {
  console.log(item); // 输出 a、b、c
}

五、Vue 中的应用:自定义组件的 v-model 修饰符

在 Vue 3 中,可通过 Symbol 定义自定义的 v-model 修饰符,避免与内置修饰符冲突。

示例:Vue 组件的自定义修饰符

// 定义唯一的修饰符 Symbol
const trimSymbol = Symbol('trim');

// 组件内
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    handleInput(e) {
      let value = e.target.value;
      // 判断是否使用自定义修饰符
      if (this.modelModifiers[trimSymbol]) {
        value = value.trim();
      }
      this.$emit('update:modelValue', value);
    }
  }
};

总结

Symbol 在项目中的核心应用场景包括:

  1. 避免属性名冲突(多人协作 / 第三方库集成);
  2. 模拟私有属性 / 方法(不可枚举特性);
  3. 定义唯一常量(替代魔术字符串);
  4. 扩展内置对象行为(如自定义迭代器)。

优化:如何避免 React Context 引起的全局挂载节点树重新渲染

最近项目中一个React Context在不断的接受websocket事件,然后一直修改state,导致重复渲染过多,比较卡顿

TM的这状态管理真乱,趁机总结一下React Context的使用的注意事项

React Context 用于在组件树中传递数据,而不必手动地通过 props 逐层传递。然而,它的便利性也带来了一个常见的性能陷阱:当 Context 的值发生变化时,所有依赖该 Context 的消费组件都会重新渲染,即使它们只使用了 Context 值中的一小部分。

如果处理不当,这种全局性的重新渲染可能会拖慢你的应用,尤其是在 Context Provider 位于组件树顶层,并且其值包含频繁变动的数据时。

Context的重新渲染机制

当我们使用 useContext(MyContext)<MyContext.Consumer> 时,React 会在内部建立一个订阅关系。

  1. 当 Provider 的 value 属性发生变化时:React 会检查新旧值是否严格相等(===)。
  2. 如果值不相等:React 会通知所有订阅了该 Context 的 Consumer 组件执行重新渲染。

React 并没有对 Consumer 实际使用了 Context 值中的哪部分属性进行细粒度分析。只要 Context 的 value 对象本身 引用发生了变化,所有 Consumer 都会触发更新。

// ❌ 常见但易导致全局渲染的模式
const MyContext = createContext({ user: null, settings: {} });

function App() {
  // state 只要更新,value 对象就会创建一个新的引用
  const [appState, setAppState] = useState({ user: { name: 'Gemini' }, theme: 'dark' });

  // 每次 App 渲染,这个对象都是一个新的引用
  const contextValue = useMemo(() => appState, [appState]);

  return (
    <MyContext.Provider value={contextValue}>
      <Header />
      <Content />
      <Footer />
    </MyContext.Provider>
  );
}

// 假设 Header 只使用了 appState.user
function Header() {
  const { user } = useContext(MyContext);
  // ... 其他代码
  return <h1>Welcome, {user.name}</h1>;
}

// 假设 Footer 只使用了 appState.theme
function Footer() {
  const { theme } = useContext(MyContext);
  // ... 其他代码
  return <p>Current theme: {theme}</p>;
}

// ⚡️ 陷阱:即使只有 theme 变化,Header 也会重新渲染!

避免全局重新渲染

我们可以通过以下几种策略,将 Context 的重新渲染范围限制在真正需要更新的组件。

1. 拆分 Context

这是最简单、最有效的策略之一。与其将所有状态都塞入一个“大 Context”中,不如根据数据的更新频率耦合关系将其拆分成多个独立的 Context。

  • 高频更新 / 独立的 Context:例如,用户交互状态(IsLoadingContext)。
  • 低频更新 / 共享的 Context:例如,全局配置和静态数据(ThemeContext)。
// ✅ 拆分成多个独立的 Context
const UserContext = createContext(null);
const ThemeContext = createContext(null);

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

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <Header /> {/* 仅消费 UserContext */}
        <Footer /> {/* 仅消费 ThemeContext */}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// 优化效果:
// 1. user 变化,只有 Header 及其子树可能重新渲染。
// 2. theme 变化,只有 Footer 及其子树可能重新渲染。互不影响。

2. 使用 Custom Hook 和 memo 结合

这种方法适用于你无法拆分 Context,但又想防止 Consumer 重新渲染的情况

通过 useMemo 或自定义 Hook 仅提取 Context 中需要的属性,并结合 React.memo 来跳过不必要的渲染

// 针对 Header 组件的自定义 Hook
const useUser = () => {
  const context = useContext(MyContext);
  // 仅返回 user 部分,确保只有 user 改变时才返回新引用
  return useMemo(() => context.user, [context.user]); 
};

// 结合 React.memo
const MemoizedHeader = React.memo(function Header() {
  const user = useUser(); // 即使 MyContext 整体变了,只要 user 不变,useUser 就会返回旧引用

  // ... 渲染逻辑
});

// ⚡️ 陷阱规避:
// 1. MyContext 整体改变,MemoizedHeader 接收新的 props (即 useUser 返回的值)。
// 2. 但由于 useUser() 对 user 属性进行了 useMemo 优化,如果 user 对象引用没有变化,
// 3. React.memo 就会发挥作用,跳过 Header 的渲染。

END

祝大家暴富!!!

【每日一面】如何解决内存泄漏

基础问答

问:有没有遇到过内存泄漏?怎么排查处理的

答:前端页面上出现内存泄露,使用 Chrome devtools -> memory 工具排查,选择时间轴分配(Allocations on timeline)功能后开始录制操作,在页面上进行相关组件的操作,停止录制后,查看内存曲线,重点关注内存曲线上升的和下降的位置,如出现只升不降,没有明显回落的区域,再重点操作,重新录制对应位置的操作,逐步缩小定位。对于这种重点关注的区域,可以同时使用堆快照追踪持续增长的对象。对排查出来的点位进行验证的时候,可以通过内存面板的垃圾回收按钮,如下图,回收后如果内存大小还是很高,可以确认是存在无法回收的内存,有泄露的情况。 image-20251116025955-kdes7f7-tmiqyaxt.png

扩展延伸

内存泄漏是 JavaScript 开发中隐蔽性强且影响严重的问题,尤其在长生命周期应用,如 SPA、后台管理系统中,可能导致页面卡顿、崩溃甚至浏览器无响应的问题。

内存泄露的本质是:本来应该被回收的对象因为意外的引用而保留了下来,导致垃圾回收器无法释放这个对象所占用的内存,使得内存占用持续增长。

垃圾回收机制

JavaScript 采用自动垃圾回收机制,不需要手动释放内存,通过引用计数标记-清除算法回收不再使用的内存:

  • 引用计数:跟踪每个对象被引用的次数,次数为 0 时回收,但是出现循环引用的时候,这个就无法解决了。
  • 标记 - 清除:从根对象(如 window )出发,标记所有可达对象,未被标记的对象将被回收,这是目前浏览器主流的算法。

OOM

和内存泄露相关联的还有一个概念,即OOM,内存溢出,指的是在程序申请内存时,发现没有可用内存分配,直接抛出了 OOM 异常。

一般来说,内存泄露是内存溢出的一个原因,但不是唯一的原因,而内存泄露会持续消耗内存资源,最终导致没有可以分配的内存给程序,出现 OOM。

内存泄露的场景

  1. 意外的全局变量

    一般是在非严格模式下出现,使用的变量没有声明,会隐式的绑定到 window 对象上,变成持久性的引用,如:

    function fn() {
    data = {};
    }
    

    解决方案:对于这种情况,第一优先的是启动严格模式(现在的框架或项目都是默认为严格模式,通常不需要关注),其次,在现在使用的 es6 规范下,优先使用 let/const 关键字声明,最后如果真的是全局变量,我们应该在确定不再使用后,赋值为 null ,从而切断对象的引用,让 GC 自动回收。

  2. 闭包导致内存泄露

    对于前端,闭包是一个非常好用的特性,但同时也需要在使用的时候注意,如果创建的闭包被长期使用,则闭包持有的变量就无法释放,一个经典案例就是计时器:

    function handleOnClickFac() {
    let timer = null;
    return function () {
    timer = setInterval(() => {
    console.log('hello');
    }, 3000);
    }
    }
    
    window.clickBtn = handleOnClickFac();
    
    btn.addEventListener('click', window.clickBtn);
    

    在这里,每次点击按钮都会触发定时器的创建,但是我们没有清除回收,所以导致这个定时器一直存在,每次点击的时候都会创建一个新的定时器。

    这个例子中,包含两个场景,一是闭包,二是定时器。

    解决方案:限制闭包生命周期,比如这里在 btn 组件卸载时,销毁闭包,从而实现“不可达”的情况,让 GC 回收,其次需要在使用完成后,清除闭包内的引用,在这个例子中,我们不仅要清楚引用,同时还应该清除定时器,否则依旧存在问题。

  3. DOM 元素引用未释放

    分两种情况:1. DOM 树中已经没有 DOM 元素了,但是 JavaScript 中还有这个 DOM 元素的链接(变量),2. 事件监听器没有移除,存在 DOM 和监听回调存在互相引用的情况。

    // 场景1:DOM已删除但 JS 仍引用
    const list = document.getElementById('list');
    const data = { element: list }; // 引用DOM元素
    document.body.removeChild(list); 
    // list已从DOM树移除,但data.element仍引用它,无法回收
    
    // 场景2:事件监听器未移除
    const button = document.getElementById('btn');
    button.addEventListener('click', () => {
      console.log('点击事件');
    });
    // 按钮被删除后,监听器未移除,导致按钮和回调函数都无法回收
    

    解决方案:解决这类场景的核心依旧是在不需要的时候释放引用,不过对于 DOM,还有一种方式就是使用事件委托,从而在子元素删除的时候不受影响。

  4. 第三方库资源未清理

    类似于 Echarts 、地图等库,会要求我们在不使用的时候,调用对应的销毁的 API,如果我们没有调用,这些库创建的临时资源就会持续占用内存,导致内存泄露。

这些场景下的解决方案都是需要我们手动在需要的地方去清除引用,从而使 GC 能够识别并回收内存,通过这些例子也不难发现,虽然在 JavaScript 中不需要我们做类似于 C++ 的手动内存回收,但是依旧需要我们去帮助 GC 更好的判断资源是否需要回收。

检测和分析

内存泄露的检测和分析主要是通过浏览器的内存工具,这里以 Chrome 为例,我们在检测和分析时使用的是 Chrome Devtool Memory 面板: image-20251116153104-s5tb0qs-scjvtike.png

  1. 观察时间线上的分配(Allocation Timeline)

    1. 开启记录后,按照推测的问题,操作页面内容,完成后停止记录,开始自动分析
    2. 观察只升不降的区域,重复录制该区域对应的操作,查看内存是否确实存在只分配不回收的情况,记录该操作
  2. 记录堆快照(Heap Snapshot)

    1. 操作开始前,记录一次初始的堆快照

    2. 重复第一步记录的操作,拍摄第二次快照,并开启比较(Comparison)模式,重点关注 Delta 和 Retainers 指标(这里对应的面板的中文名是 #增量固定装置 ,翻译不是很准确,这里提供英文界面的图作为参考

      image-20251116153926-8vvr07i-edfpjyti.png Delta 关注持续增长的对象,Retainer 追踪引用该对象的变量

  3. 点击垃圾桶(代表 GC)触发一次 GC,如果 GC 后内存依旧很高,就可以确认是存在内存泄露。

面试追问

  1. 内存泄露和内存溢出有什么关系?

    内存泄露会导致内存溢出,但是内存溢出不一定是内存泄露导致的。

  2. 常见的内存泄露场景,举个例子?

    参考本文【内存泄露的场景】一节

  3. Node.js 服务中,长生命周期对象持有短生命周期对象是一个典型的泄露场景,举例并给出排查思路

    // 用全局对象做缓存,无淘汰策略
    const cache = {}; 
    
    // 接口每次请求都往缓存加数据
    app.get('/api/data', (req, res) => {
      const key = `data_${req.query.id}`;
      const largeData = fetchLargeData(req.query.id); // 10MB 数据
      cache[key] = largeData; // 只加不删,缓存持续膨胀
      res.send(largeData);
    });
    

    由于 cache 没有设置缓存的过期时间、淘汰的方式,导致 largeData 一直被持有,使得内存不断增长。

    排查思路:1. Node.js 应用启动时添加 --inspect 标志,2. 在 Chrome 浏览器中,访问 chrome://inspect 链接对应的 Node 进程,开始监测,3. 记录初始时的堆快照和多次触发后的堆快照,方式参考【检测和分析】一节,4. 查看 cache 的引用路径以及清理逻辑。5. 设置缓存时间或LRU淘汰策略解决这个问题

  4. 线上环境 Nodejs OOM 触发报警了,你应该怎么做?

    首先,应急止损,滚动重启服务,避免损失扩大,同时增加内存延缓 OOM 时间。

    其次,分析问题出现的时间,判断是否可以回滚服务解决。

    最后,分析定位根源,按照服务日志和本地排查手段进行。

    如果使用的是 k8s 等虚化手段,可以配置服务重启规则,避免人工低效的操作方式。

一文搞懂:localhost和局域网 IP 的核心区别与使用场景

前端项目运行时给出的 localhost:3000 和 192.168.1.41:3000 本质上指向同一项目服务,但适用场景和访问范围不同,具体区别及选择建议如下:

一、核心区别

维度 localhost:3000 192.168.1.41:3000
指向对象 仅指向「当前运行项目的本机」(通过本地回环地址 127.0.0.1 实现) 指向本机在局域网中的 IP 地址(192.168.1.41 是本机在路由器分配的私有 IP)
访问范围 只能在「本机」上访问(其他设备无法通过 localhost 访问) 同一局域网内的所有设备(如手机、其他电脑、平板)均可访问
依赖条件 无需网络(断网也能访问),仅依赖本机服务是否启动 需保证本机和其他设备在同一局域网(如同一 WiFi / 网线),且本机防火墙允许端口访问

二、选择建议:根据场景决定

  1. 开发调试时优先用 localhost:3000

    • 优势:访问速度更快(本地回环不经过网络路由),且不受局域网波动影响(断网也能工作),更稳定。
    • 适用场景:自己在电脑上写代码、调试功能、修改样式等。
  2. 需要跨设备测试时用 192.168.1.41:3000

    • 优势:可以在手机、平板或同事的电脑上访问你的项目,验证响应式布局、多设备兼容性等。
    • 适用场景:测试移动端显示效果、让团队成员临时查看项目进度、跨设备联调(如手机扫码测试支付流程)。

三、注意事项

  • 若用局域网地址访问失败,可能是本机防火墙阻止了 3000 端口,或项目配置限制了仅本地访问(部分框架需手动开启局域网访问权限)。
  • 局域网 IP 可能会变化(路由器重启可能重新分配),若后续访问失败,可重新运行项目获取新的局域网地址。

总之,日常开发用 localhost 更高效,跨设备测试时再用局域网 IP 即可。

js深入之从原型到原型链

构造函数创建对象

function A {
}
let a=new A();
a.name="abc";
console.log(a.name);

在这个例子中A就是一个构造函数,我们使用new创建了一个实例对象a

prototype

每个函数都有一个prototype属性

function A {
}
A.prototype.name="张三"
let a1=new A();
let a2=new A();
console.log(a1.name,a2.name)

prototype指向的是调用该函数创建的实例的原型,也就是例子中a1,a2的原型。 每一个对象(null除外)创建的时候,都会关联另外一个对象,这个对象就是原型,每个对象都会从原型“继承”属性

3bdfe951-d6de-4e73-ae8b-8db05e5d54bf.jpeg

_proto_

每个对象(除了null)都有一个__proto__属性,这个属性指向该对象的原型

function A {
}
let a=new A();
console.log(a.__proto__===A.prototype)

a8d6b4f1-e328-44f6-8c7b-421bcfe8ac22.jpeg

constructor

每个原型都有一个constructor属性指向关联的构造函数

function A {
}
console.log(A.prototype.constructor===A)

4e507f00-af31-4942-bf0a-ffc0c89cd49c.jpeg

function A {
}
let a=new A();
console.log(A.prototype.constructor===A)
console.log(a.__proto__===A.prototype)
console.log(a.contructor===A.prototype.constructor)
// 顺便学习一个ES5的方法,可以获得对象的原型
console.log(Object.getPrototypeOf(a) === A.prototype)

当读取实例属性时,如果找不到就会从与对象关联的原型上查找,如果还查不到就会查找原型的原型,一直找到最顶层为止。

function A {
}
A.prototype.name="test";
let a=new A();
a.name="aaa";
console.log(a.name);//aaa
delete a.name;
console.log(a.name);//test

原型的原型,其实是有Object构造函数生成

a15399d7-b5ff-4fbc-afa3-9026ca9cddb7.jpeg

原型链

红色部分就代表了原型链的形成

dd4ff7f1-95d8-4007-9c1e-551d3ea83545.jpeg

面试过别人后,我对面试祛魅了

由于公司老员工走了一些,我一不小心变成最老的前端了

所以有幸能够担任公司前端一面的工作

和每位应聘者交流的过程,也是我的学习过程,从中发现一些不一样的视角的东西,写下来记录一下

老了后回来再看看掘金,也是自己坚持写文章的原因之一,到时候抱着孩子说,看爷爷当年写的文章,这么多人点赞嘞!

沟通表达能力非常重要

坦白讲,你的技术实力有时候不如表达能力有竞争力,即使你的技术再NB,问个问题,半天没有表达清楚,怎么和复杂的实际工作中沟通呢

但是这个沟通表达能力有点邪乎,没有明确的标准

遇到过什么难点,怎么解决

这个问题不是很好回答,但是一定不要回答没有遇到什么难点,其实问这个目的 一是正好看你的技术深度,二是看一下表达能力,即使真的没有很难的点,也讲一下自己认为比较费劲实现的 功能,清晰流畅准确的表达出来也是很加分的

八股还是会问的

AI冲击下问八股文好像没啥意义了,不会的都问一下AI就行了,但是多多少少还是有意义的

一个节流防抖使用场景都说不上来的人,即使在AI的加持下,其实力我也持怀疑态度

刚毕业的校招生说不上来也就算了,工作三年不知道节流防抖好像多多少少有点说不过去了

学历还是好使

坦白讲,遇到高学历的,对其技术要求确实放松了条件,我也想说程序员最重要的是技术实力,学历不重要

但是真正轮到自己的时候,技术实力有时候不如学历那么明显,可量化

以及领导一听XX大学的,他也认可,不然还得证明这人不错

所以你看,只要你能证明你的实力优势大于学历的劣势,那么学历完全不是问题

只可惜对大部分人来讲实力劣势与学历劣势共存(别骂了别骂了)

在线简历、Blog、Github

简历中有在线简历、Blog、Github等,还是比较加分的,可能对于大公司来讲,你的github没有很高star,别人不觉得你优秀

但是对于小公司来时,你有Github,至少证明你 用心一些,以及自己想探索一些项目

面试真就是碰运气

面试官也是在繁忙的工作中与你沟通,可能面试不过就是不和面试官的“胃口”,所以大家千万别灰心丧气

END

以上是我自己的一些看法,祝大家一些顺利

别再滥用 Base64 了——Blob 才是前端减负的正确姿势

一、什么是 Blob?

Blob(Binary Large Object,二进制大对象)是浏览器提供的一种不可变、类文件的原始数据容器。它可以存储任意类型的二进制或文本数据,例如图片、音频、PDF、甚至一段纯文本。与 File 对象相比,Blob 更底层,File 实际上继承自 Blob,并额外携带了 namelastModified 等元信息 。

Blob 最大的特点是纯客户端、零网络:数据一旦进入 Blob,就活在内存里,无需上传服务器即可预览、下载或进一步加工。


二、构造一个 Blob:一行代码搞定

const blob = new Blob(parts, options);
参数 说明
parts 数组,元素可以是 StringArrayBufferTypedArrayBlob 等。
options 可选对象,常用字段:
type MIME 类型,默认 application/octet-stream
endings 是否转换换行符,几乎不用。

示例:动态生成一个 Markdown 文件并让用户下载

const content = '# Hello Blob\n> 由浏览器动态生成';
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url;
a.download = 'hello.md';
a.click();

// 内存用完即弃
URL.revokeObjectURL(url);

三、Blob URL:给内存中的数据一个“临时地址”

1. 生成方式

const url = URL.createObjectURL(blob);
// 返回值样例
// blob:https://localhost:3000/550e8400-e29b-41d4-a716-446655440000

2. 生命周期

  • 作用域:仅在当前文档、当前会话有效;页面刷新、close()、手动调用 revokeObjectURL() 都会使其失效 。
  • 性能陷阱:不主动释放会造成内存泄漏,尤其在单页应用或大量图片预览场景 。

最佳实践封装:

function createTempURL(blob) {
  const url = URL.createObjectURL(blob);
  // 自动 revoke,避免忘记
  requestIdleCallback(() => URL.revokeObjectURL(url));
  return url;
}

四、Blob vs. Base64 vs. ArrayBuffer:如何选型?

场景 推荐格式 理由
图片回显、<img>/<video> Blob URL 浏览器可直接解析,无需解码;内存占用低。
小图标内嵌在 CSS/JSON Base64 减少一次 HTTP 请求,但体积增大约 33%。
纯计算、WebAssembly 传递 ArrayBuffer 可写、可索引,适合高效运算。
上传大文件、断点续传 Blob.slice 流式分片,配合 File.prototype.slice 做断点续传 。

五、高频实战场景

1. 本地图片/视频预览(零上传)

<input type="file" accept="image/*" id="uploader">
<img id="preview" style="max-width: 100%">

<script>
uploader.onchange = e => {
  const file = e.target.files[0];
  if (!file) return;
  const url = URL.createObjectURL(file);
  preview.src = url;
  preview.onload = () => URL.revokeObjectURL(url); // 加载完即释放
};
</script>

2. 将 Canvas 绘图导出为 PNG 并下载

canvas.toBlob(blob => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'snapshot.png';
  a.click();
  URL.revokeObjectURL(url);
}, 'image/png');

3. 抓取远程图片→Blob→本地预览(跨域需 CORS)

fetch('https://i.imgur.com/xxx.png', { mode: 'cors' })
  .then(r => r.blob())
  .then(blob => {
    const url = URL.createObjectURL(blob);
    document.querySelector('img').src = url;
  });

若出现图片不显示,99% 是因为服务端未返回 Access-Control-Allow-Origin 头 。


六、踩坑指南与性能锦囊

坑点 解决方案
内存暴涨 每次 createObjectURL 后,务必在合适的时机 revokeObjectURL
跨域失败 确认服务端开启 CORS;fetch 时加 {credentials: 'include'} 如需 Cookie。
移动端大视频卡顿 避免一次性读完整文件,使用 blob.slice(start, end) 分段读取。
旧浏览器兼容 IE10+ 才原生支持 Blob;如需更低版本,请引入 Blob.js 兼容库。

七、延伸:Blob 与 Stream 的梦幻联动

当文件超大(GB 级)时,全部读进内存并不现实。可以借助 ReadableStream 把 Blob 转为流,实现渐进式上传:

const stream = blob.stream(); // 返回 ReadableStream
await fetch('/upload', {
  method: 'POST',
  body: stream,
  headers: { 'Content-Type': blob.type }
});

Chrome 85+、Edge 85+、Firefox 已经支持 blob.stream(),能以流式形式边读边传,内存占用极低。


八、总结:记住“三句话”

  1. Blob = 浏览器端的二进制数据仓库,File 只是它的超集。
  2. Blob URL = 指向内存的临时指针,用完后必须手动或自动释放。
  3. 凡是“本地预览、零上传、动态生成下载”的需求,优先考虑 Blob + Blob URL 组合。

用好 Blob,既能提升用户体验(秒开预览),又能降低服务端压力(无需中转),是每一位前端工程师的必备技能。

JavaScript原型与原型链:深入理解面向对象编程的基石

引言

在JavaScript的世界中,原型(Prototype)是一个核心概念,它构成了JavaScript面向对象编程的基石。对于许多初学者来说,原型和原型链可能是最令人困惑的概念之一,但一旦深入理解,就会发现它实际上是JavaScript最强大、最灵活的特性之一。本文将通过详细的理论解释和丰富的代码示例,全面剖析JavaScript中的原型对象、原型继承以及原型链机制。

一、原型对象:共享属性和方法的智慧

1.1 什么是原型对象

在JavaScript中,每个函数都有一个特殊的属性prototype,这就是我们所说的原型对象。这个属性指向一个对象,其主要目的是包含可以由特定类型的所有实例共享的属性和方法。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}
// 通过构造函数的prototype属性访问原型对象
console.log(Star.prototype); // 输出原型对象

1.2 为什么需要原型对象

考虑以下场景:我们创建了一个构造函数,并实例化了多个对象。如果每个对象都有自己独立的方法副本,会造成内存的极大浪费。

javascript

复制下载

// 不推荐的方式:每个实例都有独立的方法副本
function Star(uname){
  this.uname = uname;
  this.sing = function(){
    console.log(this.uname + '会唱歌');
  }
}

const ldh = new Star('刘德华');
const zxy = new Star('张学友');

console.log(ldh.sing === zxy.sing); // false,方法是不同的函数实例

使用原型对象可以优雅地解决这个问题:

javascript

复制下载

// 推荐的方式:方法定义在原型上,所有实例共享
function Star(uname){
  this.uname = uname;
}

Star.prototype.sing = function(){
  console.log(this.uname + '会唱歌');
}

const ldh = new Star('刘德华');
const zxy = new Star('张学友');

ldh.sing(); // 刘德华会唱歌
zxy.sing(); // 张学友会唱歌

console.log(ldh.sing === zxy.sing); // true,所有实例共享同一个方法

1.3 原型对象的工作原理

当我们访问一个对象的属性或方法时,JavaScript引擎会首先在对象自身查找,如果找不到,就会沿着原型链向上查找,直到找到该属性或到达原型链的末端。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

Star.prototype.sing = function(){
  console.log(this.uname + '会唱歌');
}

const ldh = new Star('刘德华');

// ldh对象本身没有sing方法,但通过原型链可以访问到
console.log(ldh.hasOwnProperty('sing')); // false
console.log('sing' in ldh); // true
ldh.sing(); // 刘德华会唱歌

1.4 原型对象中的this指向

一个重要的细节是:无论方法定义在构造函数中还是原型对象中,方法内部的this都指向调用该方法的实例对象。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

Star.prototype.sing = function(){
  // 这里的this指向调用该方法的实例对象
  console.log(this.uname + '会唱歌');
}

const ldh = new Star('刘德华');
ldh.sing(); // 输出"刘德华会唱歌",this指向ldh实例

二、constructor属性:连接实例与构造函数的桥梁

2.1 原型对象中的constructor属性

每个原型对象都有一个constructor属性,默认指向该原型对象所属的构造函数。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

console.log(Star.prototype.constructor === Star); // true

2.2 实例对象中的constructor属性

通过实例对象访问constructor属性时,实际上是通过原型链访问到原型对象的constructor属性。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

const ldh = new Star('刘德华');
console.log(ldh.constructor === Star); // true

2.3 重写原型对象时的constructor问题

当我们完全重写原型对象时,会丢失原有的constructor属性,需要手动重新指向。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

// 完全重写原型对象
Star.prototype = {
  sing: function(){
    console.log(this.uname + '会唱歌');
  },
  dance: function(){
    console.log(this.uname + '会跳舞');
  }
};

console.log(Star.prototype.constructor === Star); // false
console.log(Star.prototype.constructor === Object); // true

// 正确的方式:重写原型对象时手动设置constructor
Star.prototype = {
  constructor: Star, // 手动指向构造函数
  sing: function(){
    console.log(this.uname + '会唱歌');
  },
  dance: function(){
    console.log(this.uname + '会跳舞');
  }
};

console.log(Star.prototype.constructor === Star); // true

三、对象原型:__proto__与原型链的纽带

3.1 什么是对象原型

每个JavaScript对象(除null外)都有一个内置属性[[Prototype]],在大多数浏览器中可以通过__proto__属性访问。这个属性指向创建该对象的构造函数的原型对象。

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

const ldh = new Star('刘德华');

// 实例对象的__proto__指向构造函数的原型对象
console.log(ldh.__proto__ === Star.prototype); // true

3.2 __proto__与prototype的关系

  • prototype是构造函数的属性,指向原型对象
  • __proto__是实例对象的属性,指向构造函数的原型对象

javascript

复制下载

function Star(uname){
  this.uname = uname;
}

const ldh = new Star('刘德华');

// 三者关系
console.log(ldh.__proto__ === Star.prototype); // true
console.log(Star.prototype.constructor === Star); // true
console.log(ldh.constructor === Star); // true

3.3 对象原型的实际意义

对象原型__proto__的主要意义在于为对象成员查找机制提供一个方向,或者说一条路线,这就是我们接下来要讨论的原型链。

四、原型继承:实现代码复用的优雅方式

4.1 什么是原型继承

原型继承是JavaScript中实现继承的主要方式。其核心思想是:让一个构造函数的原型对象等于另一个构造函数的实例,这样前者就可以继承后者的属性和方法。

4.2 原型继承的实现

javascript

复制下载

// 父类
function Person(){
  this.eyes = 2;
  this.head = 1;
}

// 子类
function Woman(sex){
  this.sex = sex;
}

function Man(sex){
  this.sex = sex;
}

// 实现继承:子类的原型对象是父类的实例
Woman.prototype = new Person();
// 修复constructor指向
Woman.prototype.constructor = Woman;

Man.prototype = new Person();
Man.prototype.constructor = Man;

const red = new Woman('女');
console.log(red.eyes); // 2,继承自Person
console.log(red.head); // 1,继承自Person
console.log(red.sex); // 女,自身的属性

const blue = new Man('男');
console.log(blue.eyes); // 2,继承自Person
console.log(blue.head); // 1,继承自Person
console.log(blue.sex); // 男,自身的属性

4.3 原型继承的内存效率

通过原型继承,所有子类实例共享父类原型上的方法,这大大提高了内存使用效率。

javascript

复制下载

function Person(){
  this.eyes = 2;
}

Person.prototype.breathe = function(){
  console.log('呼吸');
};

function Woman(sex){
  this.sex = sex;
}

Woman.prototype = new Person();
Woman.prototype.constructor = Woman;

const red = new Woman('女');
const pink = new Woman('女');

// 两个实例共享同一个breathe方法
console.log(red.breathe === pink.breathe); // true

4.4 方法重写与属性屏蔽

子类可以重写父类的方法,或者在实例上定义与原型链上同名的属性,实现属性屏蔽。

javascript

复制下载

function Person(){
  this.eyes = 2;
}

Person.prototype.see = function(){
  console.log('用眼睛看');
};

function Superman(){
  this.eyes = 3; // 属性屏蔽
}

Superman.prototype = new Person();
Superman.prototype.constructor = Superman;

// 方法重写
Superman.prototype.see = function(){
  console.log('用超级眼睛看');
};

const clark = new Superman();
console.log(clark.eyes); // 3,访问的是自身属性
clark.see(); // "用超级眼睛看",调用的是重写后的方法

五、原型链:JavaScript对象查找机制的核心

5.1 什么是原型链

原型链是JavaScript中实现继承和属性查找的机制。当访问一个对象的属性时,JavaScript引擎会执行以下步骤:

  1. 首先在对象自身查找该属性
  2. 如果找不到,则在该对象的原型(__proto__指向的对象)上查找
  3. 如果还找不到,则继续在原型的原型上查找
  4. 依此类推,直到找到该属性或到达原型链的顶端(null)

5.2 原型链的图示与理解

考虑以下代码:

javascript

复制下载

function Person(name){
  this.name = name;
}

Person.prototype.sayHello = function(){
  console.log('Hello, I am ' + this.name);
};

function Student(name, grade){
  this.name = name;
  this.grade = grade;
}

// 实现继承
Student.prototype = new Person();
Student.prototype.constructor = Student;

Student.prototype.study = function(){
  console.log(this.name + ' is studying');
};

const tom = new Student('Tom', 5);

此时的原型链关系为:

text

复制下载

tom -> Student.prototype -> Person.prototype -> Object.prototype -> null

属性查找过程:

  • tom.grade:在tom对象自身找到
  • tom.study:在Student.prototype中找到
  • tom.sayHello:在Person.prototype中找到
  • tom.toString:在Object.prototype中找到

9f7a6fe1f768b069e3c5125a949cf993.png

5.3 原型链的终点

所有普通的原型链最终都会指向Object.prototype,而Object.prototype的__proto__指向null,这是原型链的终点。

javascript

复制下载

function Person(){}

const person = new Person();

console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true

5.4 instanceof操作符

instanceof运算符用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。

javascript

复制下载

function Person(){}
function Student(){}

Student.prototype = new Person();
Student.prototype.constructor = Student;

const tom = new Student();

console.log(tom instanceof Student); // true
console.log(tom instanceof Person); // true
console.log(tom instanceof Object); // true
console.log(tom instanceof Array); // false

5.5 原型链与性能考虑

虽然原型链提供了强大的继承机制,但过长的原型链可能会影响性能,因为属性查找需要遍历整个原型链。在实际开发中,应尽量避免过深的继承层次。

六、实际应用与最佳实践

6.1 组合使用构造函数和原型模式

这是创建自定义类型的最常见方式,通过构造函数定义实例属性,通过原型定义共享的方法和属性。

javascript

复制下载

// 最佳实践:组合使用构造函数和原型模式
function Person(name, age){
  // 实例属性
  this.name = name;
  this.age = age;
}

// 共享方法
Person.prototype.sayHello = function(){
  console.log('Hello, I am ' + this.name);
};

Person.prototype.toString = function(){
  return '[Person: ' + this.name + ', ' + this.age + ']';
};

const alice = new Person('Alice', 25);
const bob = new Person('Bob', 30);

alice.sayHello(); // Hello, I am Alice
bob.sayHello(); // Hello, I am Bob

console.log(alice.toString()); // [Person: Alice, 25]

6.2 原型与对象创建性能

在需要创建大量相似对象的场景中,使用原型可以显著提高性能。

javascript

复制下载

// 性能对比:使用原型 vs 不使用原型

// 方式1:不使用原型(性能较差)
function createUserWithoutPrototype(name, email) {
  return {
    name: name,
    email: email,
    getInfo: function() {
      return this.name + ' <' + this.email + '>';
    }
  };
}

// 方式2:使用原型(性能较好)
function User(name, email) {
  this.name = name;
  this.email = email;
}

User.prototype.getInfo = function() {
  return this.name + ' <' + this.email + '>';
};

function createUserWithPrototype(name, email) {
  return new User(name, email);
}

// 测试性能
console.time('Without Prototype');
for (let i = 0; i < 100000; i++) {
  createUserWithoutPrototype('user' + i, 'user' + i + '@example.com');
}
console.timeEnd('Without Prototype');

console.time('With Prototype');
for (let i = 0; i < 100000; i++) {
  createUserWithPrototype('user' + i, 'user' + i + '@example.com');
}
console.timeEnd('With Prototype');

七、常见问题与解决方案

7.1 原型对象共享引用类型值的问题

当原型对象包含引用类型值时,所有实例会共享同一个引用,这可能导致意外的行为。

javascript

复制下载

// 问题:共享引用类型值
function Person(name){
  this.name = name;
}

Person.prototype.friends = []; // 引用类型值

const alice = new Person('Alice');
const bob = new Person('Bob');

alice.friends.push('Charlie');
console.log(bob.friends); // ['Charlie'],bob也受到了影响

// 解决方案:在构造函数中定义引用类型属性
function BetterPerson(name){
  this.name = name;
  this.friends = []; // 每个实例有自己的friends数组
}

BetterPerson.prototype.addFriend = function(friend){
  this.friends.push(friend);
};

const carol = new BetterPerson('Carol');
const dave = new BetterPerson('Dave');

carol.addFriend('Eve');
console.log(carol.friends); // ['Eve']
console.log(dave.friends); // [],dave不受影响

7.2 原型链与枚举属性

使用for...in循环时会遍历对象自身和原型链上的可枚举属性,这可能不是我们想要的行为。

javascript

复制下载

function Person(name){
  this.name = name;
}

Person.prototype.sayHello = function(){
  console.log('Hello');
};

const person = new Person('Alice');

// for...in会遍历原型链上的属性
for(let key in person){
  console.log(key); // 输出: name, sayHello
}

// 解决方案:使用hasOwnProperty过滤
for(let key in person){
  if(person.hasOwnProperty(key)){
    console.log(key); // 只输出: name
  }
}

结论

JavaScript的原型机制是其面向对象编程的核心,理解原型对象、原型继承和原型链对于掌握JavaScript至关重要。通过原型,JavaScript实现了高效的代码复用和灵活的继承机制。虽然ES6引入了class语法,使其更接近传统面向对象语言,但底层仍然是基于原型的继承。

在实际开发中,我们应该:

  1. 理解原型链的工作原理,避免过深的继承层次
  2. 合理使用原型共享方法,提高内存效率
  3. 注意引用类型值的共享问题
  4. 掌握现代class语法,同时理解其背后的原型机制

通过深入理解和合理应用原型相关概念,我们能够编写出更加高效、可维护的JavaScript代码,充分利用JavaScript这门语言的强大特性。

🔥 React 高频 useEffect 导致页面崩溃的真实案例:从根因排查到彻底优化

如果你在 React 中遇到过“页面卡死 / 高频请求 / useEffect 无限触发”,这篇文章会帮你一次搞懂根因,并提供可直接复制的最佳解决方案。

很多同学遇到性能问题时,会立刻想到:
👉 “加防抖呀?”
👉 “加 useMemo / useCallback 缓存呀?”

但实际上,这些方式在某些场景下根本无效。特别是当问题来自 深层子组件 的 useEffect 重复触发时,你必须回到 React 的底层原则: 单向数据流 + 渲染链传播效应。

下面用一个 真实可复现的代码示例,带你从问题现场走到完整解决方案。

问题现场:子组件 useEffect 高频触发,直接把页面搞崩

来看看最典型的错误写法。

子组件中监听 props 变化,然后发起请求

// Child.jsx
import { useEffect } from 'react';

export default function Child({ value }) {
  useEffect(() => {
    // “监听值变化”
    fetch(`/api/search?q=${value}`)
      .then(res => res.json())
      .then(console.log);
  }, [value]);

  return <div>Child Component: {value}</div>;
}

父组件层级复杂、数据源更新频繁:

// Parent.jsx
import { useState } from 'react';
import Child from './Child';

export default function Parent() {
  const [text, setText] = useState('');

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

触发链:value 更新 → 子组件重渲染 → useEffect 再次执行 → 发请求

只要用户输入速度稍快一点:

  • 会触发几十次请求
  • 浏览器线程被占满
  • 页面直接卡死 / 崩溃

为什么难定位?React 的单向数据流是关键

乍一看你会觉得:

“不是 value 改变才触发 useEffect 吗?怎么会到处连锁反应?”

问题在于:

  • 组件树嵌套太深(真实项目都这样)
  • 上层某个 state 变化导致整个父组件重渲染
  • re-render 会逐层传播到所有子组件
  • 子组件 props 引用被重建
  • useEffect 认为依赖变化 → 再次触发

哪怕 value 内容没变,也会因为引用变化触发 effect。

这就是为什么:

  • useMemo / useCallback 并不是万能的
  • 防抖也不能解决根因(子组件仍在重复渲染)

你必须从根本上切断触发链。

真正有效的解决路线:把数据源提升到最高层父组件

要解决这种高频触发 effect 的问题,最有效的方式是:

将触发 request 的逻辑,从子组件提取到父组件中进行统一控制。

为什么?

  • 父组件能控制数据源
  • 可以集中做防抖、节流、缓存、限流
  • 子组件变“纯展示组件”,不会再触发副作用
  • 渲染链被隔离,高频触发链路彻底消失

父组件统一管理副作用(正确写法)

// Parent.jsx
import { useState, useEffect } from 'react';
import Child from './Child';

export default function Parent() {
  const [text, setText] = useState('');
  const [result, setResult] = useState(null);

  // 副作用上移:只在父组件执行
  useEffect(() => {
    if (!text) return;

    const controller = new AbortController();

    fetch(`/api/search?q=${text}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setResult)
      .catch(() => {});

    return () => controller.abort();
  }, [text]);

  return (
    <>
      <input onChange={(e) => setText(e.target.value)} />
      <Child value={text} result={result} />
    </>
  );
}

子组件变为纯展示组件(无副作用)

// Child.jsx
export default function Child({ value, result }) {
  return (
    <div>
      <div>Input: {value}</div>
      <pre>{JSON.stringify(result, null, 2)}</pre>
    </div>
  );
}

这种方式为什么最可靠?

  1. 完全切断子组件 effect 高频触发:再也不会因为渲染链导致 API 请求频繁发出。
  2. React 的渲染机制变得可控:副作用从不可控(子组件) → 可控(父组件)。
  3. 适配任何复杂场景:深层嵌套、多层传参、多状态联动、高频输入流、多 API 串联
  4. 不再依赖“防抖、缓存”等外力:这些都是辅助,而不是根治方式。

额外可选优化(视情况使用)

1. useMemo / useCallback

减少无意义渲染(但无法解决副作用重复触发的根因)。

2. 防抖(debounce)

如果希望输入不触发太多请求,可以:

const debouncedValue = useDebounce(text, 300);

但请注意:如果不解决渲染链问题,防抖依旧无法从根本解决 useEffect 高频触发。

总结

把副作用提升到父组件,让子组件保持纯净。这是 React 设计理念下最符合逻辑,同时也最稳定的解决方式。

“一招鲜吃遍天”,React的开发,全部遵循这种方式的开发,是不是也能避免很多 BUG!

你认为呢?欢迎在评论区讨论!

前端实时推送 & WebSocket 面试题(2026版)

一、历史背景 + 时间轴 网页一旦需要 “实时” ,麻烦就开始了:数据在不断变化,用户却只能等下一次刷新; 刷新解决不了的延迟,用短轮询凑数,又被无数空请求反噬; 再加长轮询,试图把“有了新数据再说”

CSS 像素≠物理像素:0.5px 效果的核心密码是什么?

先明确两者的关系:CSS 像素是 “逻辑像素”(页面布局用),物理像素是屏幕实际发光的像素点,两者通过 设备像素比(DPR)  关联,公式为:1 个 CSS 像素 = DPR × DPR 个物理像素(仅高清屏缩放为 1 时)。

理解这个核心关系后,再看 0.5px 效果的实现逻辑就更清晰了,以下重新整理(重点补充像素关系,再对应方法):

一、先搞懂:CSS 像素、物理像素、DPR 的核心关系

  1. 定义

    • CSS 像素:写代码时用的单位(如 width: 100px),是浏览器渲染布局的 “逻辑单位”,和屏幕硬件无关。

    • 物理像素:屏幕面板上实际的发光点(如手机屏分辨率 1080×2340,就是横向 1080 个、纵向 2340 个物理像素),是屏幕的硬件属性。

    • DPR(设备像素比):DPR = 物理像素宽度 / CSS 像素宽度(默认页面缩放为 1 时),由设备硬件决定。

      • 例 1:老款普通屏(DPR=1):1 个 CSS 像素 = 1×1 个物理像素(写 1px 就对应屏幕 1 个发光点)。
      • 例 2:高清屏(DPR=2,如 iPhone 8):1 个 CSS 像素 = 2×2 个物理像素(写 1px 实际占用屏幕 4 个发光点,视觉上更粗)。
      • 例 3:超高清屏(DPR=3,如 iPhone 14 Pro):1 个 CSS 像素 = 3×3 个物理像素(写 1px 占用 9 个发光点,更粗)。
  2. 关键结论

    • 我们想要的 “0.5px 效果”,本质是 让线条只占用 1 个物理像素(视觉上最细)。
    • 但高清屏(DPR≥2)默认下,1 个 CSS 像素会占用多个物理像素,所以不能直接写 1px,需要通过方法 “压缩” CSS 像素对应的物理像素数量,最终落到 1 个物理像素上。

二、按 DPR 要求分类的 0.5px 实现方法(结合像素关系)

(一)仅 DPR≥2 生效:直接让 CSS 像素对应 1 个物理像素

核心逻辑:利用 DPR≥2 的像素映射关系,让 CSS 像素经过计算后,刚好对应 1 个物理像素。

1. 直接声明 0.5px
  • 像素关系:DPR=2 时,0.5px CSS 像素 = 0.5×2 = 1 个物理像素(刚好满足需求);DPR=3 时,0.5px CSS 像素 = 0.5×3 = 1.5 个物理像素(接近细线条,视觉可接受)。
  • 前提:DPR≥2 + 浏览器支持亚像素渲染(iOS 9+、Android 8.0+)。
  • 代码border: 0.5px solid #000;
  • 局限:DPR=1 时,0.5px CSS 像素 = 0.5×1 = 0.5 个物理像素(屏幕无法渲染,会四舍五入为 0px 或 1px)。
2. transform: scale(0.5) 缩放
  • 像素关系:先写 1px CSS 像素(DPR=2 时对应 2 个物理像素),再缩放 50%,最终 2×50% = 1 个物理像素。

  • 前提:DPR≥2(只有 DPR≥2 时,1px CSS 像素才会对应 ≥2 个物理像素,缩放后才能落到 1 个)。

  • 代码

    .line::after {
      content: '';
      width: 200%;
      height: 1px; /* 1px CSS = 2 物理像素(DPR=2) */
      background: #000;
      transform: scale(0.5); /* 2 物理像素 × 0.5 = 1 物理像素 */
    }
    
  • 局限:DPR=1 时,1px CSS 像素 = 1 物理像素,缩放后变成 0.5 物理像素(屏幕无法渲染,线条消失或模糊)。

3. viewport 缩放(全局方案)
  • 像素关系:通过 initial-scale=1/DPR 改变页面缩放比例,让 1px CSS 像素直接对应 1 个物理像素。

    • 例:DPR=2 时,缩放 50%(1/2),此时 1px CSS 像素 = 1 物理像素(原本 2 物理像素,缩放后压缩为 1);DPR=3 时,缩放 33.3%(1/3),1px CSS 像素 = 1 物理像素。
  • 前提:DPR≥2(高清屏),需配合布局单位(如 rem)调整。

  • 代码

    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <script>
      const dpr = window.devicePixelRatio || 1;
      document.querySelector('meta[name="viewport"]').setAttribute('content', 
        `width=device-width, initial-scale=${1/dpr}, user-scalable=no`
      );
    </script>
    
  • 优势:直接写 border: 1px 就是 1 物理像素,适配所有 DPR≥2 的设备。

  • 局限:全局缩放会影响布局,需重新计算 rem 基准值(如 html { font-size: 16px * dpr })。

(二)DPR≥2 最优,DPR=1 可模拟:视觉层面实现 “细于 1px”

核心逻辑:不依赖像素映射的精准计算,而是通过视觉欺骗或矢量渲染,让线条看起来比 1px 细(DPR=1 时无法实现 1 物理像素,只能模拟)。

1. SVG 绘制
  • 像素关系:SVG 是矢量图,不依赖 CSS 像素和物理像素的映射,直接按 “坐标 + 线条宽度” 渲染。

    • DPR≥2 时:stroke-width="1" + y1="0.5" 直接渲染为 1 个物理像素(矢量渲染支持亚像素精准控制)。
    • DPR=1 时:同样的代码会渲染为 “视觉上 0.5px 细的线条”(实际还是 1 物理像素,但矢量缩放让边缘更细腻,比直接写 1px 看起来细)。
  • 前提:无严格 DPR 要求,所有支持 SVG 的浏览器(几乎所有移动端)。

  • 代码

    <svg width="100%" height="1" xmlns="http://www.w3.org/2000/svg">
      <line x1="0" y1="0.5" x2="100%" y2="0.5" stroke="#000" stroke-width="1" />
    </svg>
    
2. 背景渐变(background-image
  • 像素关系:利用 1px 高的 CSS 容器,通过颜色分割模拟 “半像素”。

    • DPR=2 时:1px CSS 容器 = 2 物理像素高,渐变 “透明 50% + 有色 50%” 刚好对应 1 个物理像素的有色线条。
    • DPR=1 时:1px CSS 容器 = 1 物理像素高,渐变后视觉上是 “半透明细线”(比纯 1px 细,但本质是 1 物理像素的颜色叠加)。
  • 前提:支持 CSS3 渐变的浏览器(iOS 7+、Android 4.4+)。

  • 代码

    .line {
      height: 1px;
      background: linear-gradient(to bottom, transparent 50%, #000 50%);
    }
    
3. box-shadow 模拟
  • 像素关系:DPR=2 时,box-shadow: 0 0.5px 0 #000 中,0.5px CSS 偏移量 = 1 物理像素,形成 1 物理像素的细阴影(视觉上是细线条)。
  • 前提:DPR≥2(DPR=1 时,0.5px 偏移 = 0.5 物理像素,屏幕无法渲染,阴影不显示或模糊)。
  • 代码box-shadow: 0 0.5px 0 #000;

三、最终总结(结合像素关系)

实现方式 像素映射逻辑(核心) 依赖 DPR 视觉效果
直接 0.5px DPR≥2 时,0.5px CSS = 1 物理像素 DPR≥2 精准细线条
transform: scale DPR≥2 时,1px CSS(2 物理像素)缩放 50% = 1 物理像素 DPR≥2 兼容性好,精准细线条
viewport 缩放 DPR≥2 时,缩放 1/DPR 让 1px CSS = 1 物理像素 DPR≥2 全局适配,精准细线条
SVG 绘制 矢量渲染,直接控制 1 物理像素(DPR≥2)或模拟细线条(DPR=1) 无(DPR≥2 最优) 跨设备,细腻无模糊
背景渐变 DPR≥2 时 1px CSS(2 物理像素)颜色分割 = 1 物理像素;DPR=1 时视觉欺骗 无(DPR≥2 最优) 模拟细线条,无兼容性问题
box-shadow DPR≥2 时,0.5px CSS 偏移 = 1 物理像素阴影 DPR≥2 非边框线条适用

核心一句话:所有 “真实 0.5px 效果”(1 物理像素)都依赖 DPR≥2 的高清屏(利用 CSS 像素与物理像素的映射关系);DPR=1 时只能模拟,无法实现物理级半像素。

以下是包含 CSS 像素 / 物理像素 / DPR 关系说明 的 0.5px 兼容代码合集,每个方法都标注核心逻辑和适用场景,可直接复制使用:

一、说明(所有方法通用)

  • 核心目标:让线条最终占用 1 个物理像素(视觉最细)。
  • 像素关系:1 CSS 像素 = DPR × DPR 物理像素(默认缩放 1 时),高清屏(DPR≥2)需通过代码 “压缩” 映射关系。
  • 适配原则:优先选兼容性广、无布局影响的方法(如 SVG、transform 缩放)。

二、6 种实用兼容代码

1. 推荐首选:transform: scale (0.5) 缩放(DPR≥2 生效,兼容性最好)

  • 核心逻辑:1px CSS 像素(DPR=2 时对应 2 物理像素)→ 缩放 50% → 最终 1 物理像素。
  • 适用场景:边框、独立线条,不影响布局。
/* 通用细线条类(上下左右可按需调整) */
.thin-line {
  position: relative;
  /* 父容器需触发 BFC,避免线条溢出 */
  overflow: hidden;
}

.thin-line::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 1px; /* 1px CSS = 2 物理像素(DPR=2) */
  background: #000; /* 线条颜色 */
  transform: scaleY(0.5); /* 垂直缩放 50% → 2 物理像素 → 1 物理像素 */
  transform-origin: 0 0; /* 缩放原点避免偏移 */
}

/* 横向线条(默认)、纵向线条(按需添加) */
.thin-line-vertical::after {
  width: 1px;
  height: 100%;
  transform: scaleX(0.5);
}
  • 使用方式:<div class="thin-line">内容</div>

2. 跨 DPR 优选:SVG 绘制(所有设备适配,精准无模糊)

  • 核心逻辑:SVG 矢量渲染不依赖像素映射,直接指定 1 物理像素线条(DPR≥2 精准,DPR=1 模拟细线条)。
  • 适用场景:UI 严格还原、跨设备兼容(推荐用于分割线、边框)。
<!-- 横向细线条(直接嵌入,可复用) -->
<svg class="svg-thin-line" width="100%" height="1" xmlns="http://www.w3.org/2000/svg">
  <!-- y1="0.5" + stroke-width="1" → 直接对应 1 物理像素(DPR≥2) -->
  <line x1="0" y1="0.5" x2="100%" y2="0.5" stroke="#000" stroke-width="1" />
</svg>

<!-- 纵向细线条(宽度 100%,高度自适应) -->
<svg class="svg-thin-line-vertical" width="1" height="100%" xmlns="http://www.w3.org/2000/svg">
  <line x1="0.5" y1="0" x2="0.5" y2="100%" stroke="#000" stroke-width="1" />
</svg>

<!-- 样式优化(可选) -->
<style>
  .svg-thin-line {
    display: block;
    margin: 8px 0; /* 上下间距 */
  }
</style>
  • 使用方式:直接嵌入 HTML,修改 stroke 颜色、width/height 适配场景。

3. 现代设备:直接 0.5px 声明(简洁高效,DPR≥2 + 现代浏览器)

  • 核心逻辑:DPR=2 时,0.5px CSS 像素 = 1 物理像素,浏览器直接渲染。
  • 适用场景:iOS 9+、Android 8.0+ 设备,无需兼容旧机型。
/* 直接声明,简洁高效 */
.simple-thin-line {
  border-bottom: 0.5px solid #000; /* 横向线条 */
  /* 纵向线条:border-left: 0.5px solid #000; */
}

/* 兼容写法(部分浏览器需前缀) */
.compact-thin-line {
  border-bottom: 0.5px solid #000;
  -webkit-border-bottom: 0.5px solid #000;
}
  • 使用方式:<div class="simple-thin-line">内容</div>

4. 全局适配:viewport 缩放(DPR≥2,全局细线条统一)

  • 核心逻辑:缩放页面为 1/DPR,让 1px CSS 像素 = 1 物理像素(需配合 rem 布局)。
  • 适用场景:整个页面需要大量细线条,愿意调整布局单位。
<!-- 第一步:设置 viewport(初始缩放 1.0) -->
<meta name="viewport" id="viewport" content="width=device-width, user-scalable=no">

<!-- 第二步:动态调整缩放比例 -->
<script>
  (function() {
    const dpr = window.devicePixelRatio || 1;
    const viewport = document.getElementById('viewport');
    // 缩放 1/DPR,让 1px CSS = 1 物理像素(DPR=2 → 缩放 50%)
    viewport.setAttribute('content', `width=device-width, initial-scale=${1/dpr}, user-scalable=no`);
    
    // 可选:调整 rem 基准值(避免布局错乱)
    const html = document.documentElement;
    html.style.fontSize = `${16 * dpr}px`; // 1rem = 16*dpr px(适配缩放后布局)
  })();
</script>

<!-- 第三步:直接写 1px 即可(此时 1px = 1 物理像素) -->
<style>
  .global-thin-line {
    border-bottom: 1px solid #000; /* 实际是 1 物理像素细线条 */
    margin: 0.5rem 0; /* rem 单位适配缩放后布局 */
  }
</style>
  • 使用方式:全局引入脚本,之后所有 1px 边框都会变成细线条。

5. 视觉模拟:背景渐变(无兼容性问题,DPR≥2 最优)

  • 核心逻辑:1px CSS 容器(DPR=2 时 2 物理像素)→ 颜色分割为 50% 透明 + 50% 有色 → 视觉上 1 物理像素。
  • 适用场景:背景线条、无法用边框 / 伪元素的场景。
/* 横向线条 */
.gradient-thin-line {
  height: 1px;
  width: 100%;
  /* 上半透明,下半有色 → 视觉上细线条 */
  background: linear-gradient(to bottom, transparent 50%, #000 50%);
  background-size: 100% 1px;
}

/* 纵向线条 */
.gradient-thin-line-vertical {
  width: 1px;
  height: 100%;
  background: linear-gradient(to right, transparent 50%, #000 50%);
  background-size: 1px 100%;
}
  • 使用方式:<div class="gradient-thin-line"></div>(独立线条容器)。

6. 非边框场景:box-shadow 模拟(DPR≥2,适合阴影类线条)

  • 核心逻辑:DPR=2 时,0.5px CSS 偏移 = 1 物理像素,阴影即细线条。
  • 适用场景:无需占用布局空间的线条(如文字下方细下划线)。
.shadow-thin-line {
  height: 0;
  /* y 轴偏移 0.5px → 1 物理像素,无模糊、无扩散 */
  box-shadow: 0 0.5px 0 #000;
  -webkit-box-shadow: 0 0.5px 0 #000; /* 兼容 Safari */
}

/* 文字下划线示例 */
.text-thin-underline {
  display: inline-block;
  box-shadow: 0 0.5px 0 #000;
  padding-bottom: 2px;
}
  • 使用方式:<span class="text-thin-underline">带细下划线的文字</span>

三、使用建议

  1. 优先选 transform 缩放 或 SVG 绘制:兼容性广、无布局影响,覆盖 99% 场景。
  2. 现代设备(iOS 9+/Android 8.0+)直接用 0.5px 声明:代码最简洁。
  3. 全局大量细线条用 viewport 缩放:需配合 rem 布局,一次性解决所有线条问题。

iOS 社招 - Runtime 相关知识点

核心概念 本质:runtime是 oc 的一个运行时库(libobjc.A,dylib),它为 oc 添加了 面向对象的能力 以及 运行时的动态特性。 面向对象的能力:rutime用 C 语言实现了类

Flutter插件与包的本质差异

原文:xuanhu.info/projects/it…

Flutter插件与包的本质差异

1 核心概念定义

1.1 Flutter包(Package)

  • 纯Dart实现:仅包含Dart语言编写的逻辑代码
  • 跨平台特性:不依赖任何原生平台(Android/iOS)API
  • 典型用例
    // 日期格式化工具包示例
    class DateFormatter {
      static String formatDateTime(DateTime dt) {
        return '${dt.year}-${dt.month}-${dt.day}'; 
      }
    }
    

1.2 Flutter插件(Plugin)

  • 混合架构:包含Dart接口 + 平台特定实现
  • 平台通道:通过MethodChannel进行通信
    // Dart端调用原生摄像头
    final cameraPlugin = CameraPlugin();
    final imagePath = await cameraPlugin.takePicture(); 
    
  • 原生层实现
    // Android端Java实现
    public class CameraPlugin implements MethodCallHandler {
      @Override
      public void onMethodCall(MethodCall call, Result result) {
        if (call.method.equals("takePicture")) {
          dispatchTakePictureIntent(result); // 启动相机
        }
      }
    }
    

2 底层通信机制剖析

2.1 平台通道(Platform Channel)工作原理

sequenceDiagram
    Flutter->>Native: 调用方法 (MethodCall)
    Native->>Native: 执行原生操作
    Native->>Flutter: 返回结果 (Result)

2.2 数据类型映射表

Dart类型 Android类型 iOS类型
int java.lang.Integer NSNumber
double java.lang.Double NSNumber
String java.lang.String NSString
Uint8List byte[] FlutterStandardTypedData

3 实战场景对比

3.1 何时使用纯包

  • UI组件库:如pub.dev/packages/fl…
  • 业务逻辑封装:JWT令牌解析工具
  • 状态管理:Riverpod状态管理库
// 纯Dart包实现状态管理
final authProvider = Provider<User>((ref) {
  return User.fromToken(JWTParser.parse(token));
});

3.2 何时必须用插件

// 蓝牙插件使用示例
FlutterBlue flutterBlue = FlutterBlue.instance;
flutterBlue.scan().listen((scanResult) {
  print('发现设备: ${scanResult.device.name}');
});

4 混合开发进阶技巧

4.1 FFI(外部函数接口)替代方案

import 'dart:ffi';

typedef NativeAddFunc = Int32 Function(Int32, Int32);

final dylib = DynamicLibrary.open('libmath.dylib');
final add = dylib.lookupFunction<NativeAddFunc, NativeAddFunc>('add');

void main() {
  print('3+5=${add(3, 5)}'); // 直接调用C函数
}

4.2 插件性能优化策略

  • 批处理调用:减少平台通道通信次数
  • 二进制传输:使用ByteData代替Base64
  • 后台线程:耗时操作脱离UI线程
// 图像处理优化示例
final Isolate isolate = await Isolate.spawn(_processImage, imageData);

static void _processImage(Uint8List data) {
  // 在独立isolate中处理
  final result = applyFilters(data); 
  Isolate.exit(result);
}

5 企业级项目架构

5.1 分层架构设计

lib/
├── domain/       # 业务逻辑层(纯Dart包)
├── infrastructure/ # 基础设施层
│   ├── api_client.dart # 网络请求(纯Dart)
│   └── sensors.dart    # 传感器(插件)
└── presentation/ # UI层

5.2 联邦插件(Federated Plugins)

graph TD
    A[接口包] --> B[Android实现包]
    A --> C[iOS实现包]
    A --> D[Web实现包]

6 版本兼容性管理

6.1 多平台支持矩阵

插件版本 Flutter SDK Android API iOS版本
1.x >=2.0 21+ 11+
2.x >=3.3 24+ 13+

6.2 依赖冲突解决方案

dependency_overrides:
  plugin_core: 1.2.3 # 强制统一核心版本

总结

::: tabs#platform

@tab 核心差异

  • :纯Dart逻辑复用,适用于UI组件/工具类
  • 插件:平台桥接器,用于硬件/系统服务访问

@tab 选型决策树

flowchart TD
    A[需要访问硬件/系统API?] -->|是| B[使用插件]
    A -->|否| C[开发纯Dart包]

@tab 未来演进

  • WebAssembly支持:Dart与Rust的FFI深度整合
  • 宏编程:Dart 3元编程简化插件开发
  • 统一渲染引擎:减少平台特定代码需求

::: tip 最佳实践建议
1. **优先纯包架构**:90%业务逻辑应保持平台无关
2. **插件轻量化**:原生层仅做必要代理
3. **性能监控**:使用`devtools`检查通道调用耗时
4. **联邦插件策略**:支持多平台渐进增强
:::

```dart
// 健康检查工具(同时使用包和插件)
void checkHealth() {
  final memory = SystemInfo.getMemoryUsage(); // 插件访问系统API
  final status = HealthAnalyzer.analyze(memory); // 纯Dart分析逻辑
  print('系统健康度: ${status.level}');
}

结论

技术选型

考量维度 包(Package) 插件(Plugin)
开发成本 ★★☆☆☆ ★★★★☆
跨平台一致性 ★★★★★ ★★★☆☆
系统能力访问
热重载支持 部分支持
空安全支持 100% 依赖原生实现

演进趋势预测

  1. 插件包融合:Dart FFI技术将缩小两者差异
  2. Wasm跨平台:WebAssembly可能替代部分原生插件
  3. AI代码生成:GPT工程自动生成平台通道代码

原文:xuanhu.info/projects/it…

SwiftUI 键盘快捷键作用域深度解析

原文:xuanhu.info/projects/it…

SwiftUI 键盘快捷键作用域深度解析

SwiftUI 的 keyboardShortcut 修饰符让为应用添加快捷键变得简单直观。然而,这些快捷键的作用域(Scope)生命周期可能会带来一些意想不到的行为,例如即使关联的视图不在屏幕可见区域,其快捷键仍可能被激活。本文将深入探讨 SwiftUI 键盘快捷键的作用域机制,并提供一系列解决方案和最佳实践。

1. SwiftUI 键盘快捷键基础

在 SwiftUI 中,你可以使用 .keyboardShortcut 修饰符为任何可交互的视图(如 Button)附加键盘快捷键。

1.1 基本用法

以下代码为一个按钮添加了快捷键 Command + Shift + P

import SwiftUI

struct ContentView: View {
    var body: some View {
        Button("打印信息") {
            print("Hello World!")
        }
        .keyboardShortcut("p", modifiers: [.command, .shift]) // 
    }
}

1.2 关键概念解析

  • KeyEquivalent:表示快捷键的主键,可以是单个字符(如 "p")或特殊键(如 .return, .escape, .downArrow)。它遵循 ExpressibleByExtendedGraphemeClusterLiteral 协议,允许我们用字符串字面量创建实例。
  • EventModifiers:表示修饰键(如 .command, .shift, .control, .option),它是一个遵循 OptionSet 协议的结构体,允许组合多个修饰键。
  • 默认修饰符:如果省略 modifiers 参数,SwiftUI 默认使用 .command 修饰符。
  • 关联操作:触发快捷键等效于直接与视图交互(例如,点击按钮)。

1.3 应用于不同视图

keyboardShortcut 修饰符可以应用于任何视图,不仅是 Button。例如,可以将其应用于 Toggle

struct ContentView: View {
    @State private var isEnabled = false
    
    var body: some View {
        Toggle(isOn: $isEnabled) {
            Text(String(isEnabled))
        }
        .keyboardShortcut("t") // 按下快捷键将切换 Toggle 的状态 
    }
}

它也可以应用于容器视图(如 VStack, HStack)。在这种情况下,快捷键会作用于该容器层次结构中第一个可交互的子视图

struct ContentView: View {
    var body: some View {
        VStack {
            Button("打印信息") {
                print("Hello World!")
            }
            Button("删除信息") {
                print("信息已删除。")
            }
        }
        .keyboardShortcut("p") // 此快捷键将激活第一个按钮(打印信息) 
    }
}

2. 快捷键的作用域与生命周期

理解快捷键的作用域(Scope)生命周期(Lifetime) 是有效管理它们的关键。

2.1 作用域机制

SwiftUI 的键盘快捷键在视图层次结构中进行管理。其解析过程遵循深度优先、从前向后的遍历规则。当多个控件关联到同一快捷键时,系统会使用最先找到的那个

2.2 生命周期与“离屏”激活

一个非常重要的特性是:只要附加了快捷键的视图仍然存在于视图层次结构中(即使该视图当前不在屏幕可见范围内,例如在 TabView 的非活动标签页、NavigationStack 的深层页面,或者简单的 if 条件渲染但视图未销毁),其快捷键就保持有效并可激活

这种行为可能导致非预期的操作:

  • 用户意图在当前活跃的上下文中使用一个快捷键,却意外触发了另一个在背景中不可见视图的操作。
  • 在标签页 A 中定义的快捷键,在标签页 B 中仍然可以触发。

2.3 示例:标签页中的潜在问题

struct ContentView: View {
    @State private var selection = 1
    
    var body: some View {
        TabView(selection: $selection) {
            Tab("标签 1", systemImage: "1.circle") {
                Button("标签1的按钮") {
                    print("标签1动作")
                }
                .keyboardShortcut("a") // ⌘A 在标签1
            }
            .tag(1)
            
            Tab("标签 2", systemImage: "2.circle") {
                Button("标签2的按钮") {
                    print("标签2动作")
                }
                .keyboardShortcut("b") // ⌘B 在标签2
            }
            .tag(2)
        }
    }
}

在此例中,即使你在标签页 2(⌘B 活跃),按下 ⌘A 仍然会触发标签页 1 中的按钮动作,因为标签页 1 的视图仍然在视图层次结构中(只是未被显示)。

3. 管理快捷键作用域的解决方案

为了解决快捷键意外激活的问题,我们需要有意识地控制其作用域。以下是几种有效的方法。

3.1 条件修饰符(动态禁用视图)

最直接的方法是通过条件语句(如 if.disabled)控制视图的存在与否或可交互性,从而间接控制快捷键。

使用 if 条件语句

通过 @State 驱动视图的条件渲染,当视图被移除时,其快捷键自然失效。

struct ContentView: View {
    @State private var isFeatureEnabled = false
    
    var body: some View {
        VStack {
            Toggle("启用功能", isOn: $isFeatureEnabled)
            
            if isFeatureEnabled {
                Button("执行功能") {
                    // 执行操作
                }
                .keyboardShortcut("e") // 仅在 isFeatureEnabled 为 true 时存在且有效
            }
        }
    }
}
使用 .disabled 修饰符

.disabled 修饰符会禁用视图的交互能力,同时也会使其关联的快捷键失效

struct ContentView: View {
    @State private var isButtonDisabled = true
    
    var body: some View {
        Button("点击我") {
            // 执行操作
        }
        .keyboardShortcut("k")
        .disabled(isButtonDisabled) // 为 true 时,按钮无法点击且快捷键无效
    }
}

3.2 基于 isPresented 的状态控制

对于通过 sheetalertpopover 等呈现的视图,其快捷键的生命周期通常与模态视图的呈现状态绑定。

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("显示表单") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            SheetView()
        }
    }
}

struct SheetView: View {
    var body: some View {
        Button("提交表单") {
            // 提交操作
        }
        .keyboardShortcut(.return, modifiers: [.command]) // ⌘Return
        // 此快捷键仅在 Sheet 呈现时有效
    }
}

在这个例子中,⌘Return 快捷键只在 SheetView 显示时有效。当 sheet 被关闭后,该快捷键也随之失效,完美避免了与主界面快捷键的冲突。

3.3 使用 AppDelegateUIKeyCommand 进行全局管理

对于更复杂的应用,尤其是在 macOS 或需要非常精确控制快捷键的 iPad 应用上,你可以选择绕过 SwiftUI 的修饰符,直接在 AppDelegate 中使用 UIKit 的 UIKeyCommand

这种方法让你可以完全自主地决定在不同场景下哪些快捷键应该被激活

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    // 跟踪当前视图状态
    var currentView: CurrentViewType = .main
    
    override var keyCommands: [UIKeyCommand]? {
        switch currentView {
        case .main:
            return [
                UIKeyCommand(title: "搜索", action: #selector(handleKeyCommand(_:)), input: "f", modifierFlags: .command, propertyList: "search"),
                UIKeyCommand(title: "新建", action: #selector(handleKeyCommand(_:)), input: "n", modifierFlags: .command, propertyList: "new")
            ]
        case .sheet:
            return [
                UIKeyCommand(title: "保存", action: #selector(handleKeyCommand(_:)), input: "s", modifierFlags: .command, propertyList: "saveSheet")
            ]
        case .settings:
            return [] // 在设置页面禁用所有自定义快捷键
        }
    }
    
    @objc func handleKeyCommand(_ sender: UIKeyCommand) {
        guard let action = sender.propertyList as? String else { return }
        
        switch action {
        case "search": // 处理搜索逻辑
        case "new":   // 处理新建逻辑
        case "saveSheet": // 处理Sheet保存逻辑
        default: break
        }
    }
    
    // ... 其他 AppDelegate 方法
}

enum CurrentViewType {
    case main, sheet, settings
}

通过在 AppDelegate 中维护一个状态机(如 currentView),你可以根据应用当前所处的不同界面或模式,动态返回不同的快捷键数组,实现精准的全局快捷键管理。

4. 高级技巧与最佳实践

4.1 优先级与冲突解决

如前所述,SwiftUI 会选择在深度优先遍历中最先找到的快捷键。 因此,在设计快捷键时,需要注意其唯一性,避免无意中的覆盖。如果确实需要覆盖,可以利用视图的层次结构,将高优先级的快捷键定义放在更靠近视图树根部的位置或确保其被先定义。

4.2 隐藏快捷键与用户体验

你可以创建“隐藏”的快捷键(不显示在菜单中),用于一些通用操作,如关闭模态框。

// 在 AppDelegate 的 keyCommands 中
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: UIKeyCommand.inputEscape, propertyList: "closeModal"),
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: "w", modifierFlags: .command, propertyList: "closeModal")

这些没有标题的 UIKeyCommand 不会出现在菜单中,但用户按下 Esc⌘W 时仍然会触发关闭操作,这符合许多桌面应用的用户习惯。

4.3 调试快捷键

在模拟器中测试快捷键时,记得点击模拟器窗口底部的 “Capture Keyboard” 按钮(看起来像一个小键盘图标),以确保模拟器捕获你的键盘输入。

4.4 与 FocusState 结合管理文本输入焦点

在处理文本输入时,快捷键常与焦点管理配合使用。SwiftUI 的 @FocusState 可以用来程序控制第一个响应者(焦点)。

struct ContentView: View {
    @State private var username = ""
    @State private var password = ""
    @FocusState private var focusedField: Field? // 焦点状态
    
    enum Field: Hashable {
        case username, password
    }
    
    var body: some View {
        Form {
            TextField("用户名", text: $username)
                .focused($focusedField, equals: .username)
                .keyboardShortcut("1", modifiers: [.control, .command]) // 切换焦点快捷键
            
            SecureField("密码", text: $password)
                .focused($focusedField, equals: .password)
                .keyboardShortcut("2", modifiers: [.control, .command]) // 切换焦点快捷键
        }
        .onSubmit { // 处理回车键提交
            if focusedField == .username {
                focusedField = .password
            } else {
                login()
            }
        }
    }
    
    private func login() { ... }
}

4.5 在 macOS 中与菜单栏集成

在 macOS 应用中,SwiftUI 的 .commands 修饰符允许你向菜单栏添加项目,并为其指定快捷键。这些快捷键通常具有全局性,但系统会自动处理其与当前焦点视图的优先级关系。

import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("编辑") {
                Button("复制") {
                    // 执行复制操作
                }
                .keyboardShortcut("c") // 定义在菜单栏中
                
                Button("粘贴") {
                    // 执行粘贴操作
                }
                .keyboardShortcut("v")
            }
        }
    }
}

5. 实战案例:一个多视图的应用

假设我们有一个文档编辑器,它包含:

  1. 一个主编辑界面(MainEditorView)。
  2. 一个设置页面(SettingsView),通过导航链接推送。
  3. 一个导出模态框(ExportView),通过 sheet 呈现。
struct MainEditorView: View {
    @State private var documentText: String = ""
    @State private var showSettings = false
    @State private var showExportSheet = false
    @State private var isExportDisabled = true
    
    var body: some View {
        NavigationStack {
            TextEditor(text: $documentText)
                .toolbar {
                    ToolbarItemGroup {
                        Button("设置") { showSettings.toggle() }
                        Button("导出") { showExportSheet.toggle() }
                            .disabled(isExportDisabled) // 初始状态下导出禁用
                    }
                }
                .navigationDestination(isPresented: $showSettings) {
                    SettingsView()
                }
                .sheet(isPresented: $showExportSheet) {
                    ExportView()
                }
                // 主编辑器的快捷键
                .keyboardShortcut("s", modifiers: [.command]) // 保存,始终有效
        }
        .onChange(of: documentText) { 
            isExportDisabled = documentText.isEmpty // 有内容时才允许导出
        }
    }
}

struct SettingsView: View {
    var body: some View {
        Form {
            // 各种设置选项...
        }
        // 设置页面可能有自己的快捷键,但只在当前视图活跃
        .keyboardShortcut("r", modifiers: [.command]) // 重置设置
    }
}

struct ExportView: View {
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack {
            // 导出选项...
            Button("确认导出") {
                // 导出逻辑
                dismiss()
            }
            .keyboardShortcut(.return, modifiers: [.command]) // ⌘Return 在Sheet中有效
        }
        .frame(minWidth: 300, minHeight: 200)
        .padding()
    }
}

在这个案例中:

  • ⌘S (保存):定义在 MainEditorView 上,只要该视图在层次结构中就有效(即使在设置页面或导出Sheet背后)。
  • ⌘R (重置):定义在 SettingsView 上,仅在设置页面可见时有效。
  • ⌘Return (确认导出):定义在 ExportView 上,仅在导出 Sheet 显示时有效。
  • 导出按钮的禁用状态:通过 isExportDisabled 状态控制,同时也禁用了其快捷键,避免了无效操作。

总结

SwiftUI 的键盘快捷键功能强大且易于使用,但其“离屏”激活的特性要求开发者仔细考虑其作用域管理。

  • 核心机制:快捷键的生命周期与其附加的视图绑定,只要视图在层次结构中,快捷键就有效。
  • 主要解决方案
    • 条件渲染与禁用:使用 if.disabled(_:) 动态控制视图及其快捷键的可用性。
    • 状态绑定:利用 isPresented 等状态,将模态视图的快捷键生命周期限制在模态显示期间。
    • 全局管理:对于复杂场景,可退回到 AppDelegate 中使用 UIKeyCommand 实现精细的、基于状态的全局快捷键控制。
  • 建议:始终考虑用户体验,确保快捷键在正确的上下文中生效,避免冲突和意外操作。善用 .commands 为 macOS 应用添加快捷键,并结合 @FocusState 管理文本输入焦点。

原文:xuanhu.info/projects/it…

Xcode 26 的10个新特性解析

Xcode 26 的10个新特性解析 1. 生成式AI编程助手:ChatGPT与本地模型的深度集成 Xcode 26最引人注目的特性是深度集成了大语言模型(LLM),使开发者能够使用自然语言与代码交互
❌