阅读视图

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

一文了解Blob文件格式,前端必备技能之一

前言

最近在项目中需要导出文档时,我首次接触到了 Blob 文件格式。作为一个前端开发者,虽然经常听到 "Blob" 这个术语,但对其具体原理和应用场景并不十分了解。经过一番研究和实践,我决定将所学整理成文,与大家分享 Blob 技术的奥秘。

一、什么是Blob?

Blob(Binary Large Object,二进制大对象)是 JavaScript 中用于表示二进制数据的一个对象。它本质上是一个不可变的、原始数据的类文件对象,可以存储大量的二进制数据。

// 创建一个简单的Blob对象
const blob = new Blob(["Hello, world!"], { type: 'text/plain' });

AI写代码javascript
运行
12

二、Blob的基本特性

  • 不可变性:一旦创建,Blob 对象的内容无法直接修改
  • 类型标识:通过 MIME 类型标识数据格式
  • 大小存储:可以存储大量二进制数据
  • 分片能力:可以被分割成更小的 Blob 对象

三、Blob的构造函数

Blob构造函数接受两个参数:

new Blob(blobParts, options);

AI写代码javascript
运行
1
  • blobParts:由ArrayBuffer、ArrayBufferView、Blob、DOMString等对象构成的数组

  • options:可选参数,包含两个属性:

    • type:Blob内容的MIME类型
    • endings:指定包含行结束符\n的字符串如何写入

四、常见使用场景

1. 文件下载

function downloadFile(content, filename, type) {
  const blob = new Blob([content], { type });
  const url = URL.createObjectURL(blob);
  
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  
  URL.revokeObjectURL(url);
}

// 使用示例
downloadFile('Hello, world!', 'example.txt', 'text/plain');

AI写代码javascript
运行
1234567891011121314

2. 图片预览

function previewImage(file) {
  const blob = URL.createObjectURL(file);
  const img = document.createElement('img');
  
  img.onload = function() {
    URL.revokeObjectURL(this.src); // 释放内存
  };
  
  img.src = blob;
  document.body.appendChild(img);
}

// 使用示例
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', (e) => {
  previewImage(e.target.files[0]);
});

AI写代码javascript
运行
1234567891011121314151617

3. 大文件分片上传

function uploadLargeFile(file, chunkSize = 1024 * 1024) {
  let offset = 0;
  const fileSize = file.size;
  
  while (offset < fileSize) {
    const chunk = file.slice(offset, offset + chunkSize);
    // 上传chunk...
    offset += chunkSize;
  }
}

AI写代码javascript
运行
12345678910

四、Blob与其他API的关系

1. File API

File 对象继承自 Blob ,在Blob基础上增加了文件名、最后修改时间等元数据。

2. FileReader

用于读取 Blob 或 File 对象的内容:

const reader = new FileReader();
reader.onload = function(e) {
  console.log(e.target.result);
};
reader.readAsText(blob);

AI写代码javascript
运行
12345

3. URL.createObjectURL()

创建指向 Blob 对象的 URL ,可用于预览或下载。

4. Response

Fetch API 的 Response 对象可以将 Blob 作为响应体:

fetch(url)
  .then(response => response.blob())
  .then(blob => {
    // 处理blob
  });

AI写代码javascript
运行
12345

五、性能与内存管理

使用Blob时需要注意:

  1. 内存释放:通过 URL.revokeObjectURL() 及时释放不再需要的Blob URL
  2. 大文件处理:对于大文件,考虑使用 slice() 方法分块处理
  3. Worker线程:处理大型 Blob 时可在 Web Worker 中进行以避免阻塞主线程

六、实际案例:导出Word文档

最近我在项目中需要将 HTML 内容导出为 Word 文档,使用 Blob 技术可以轻松实现:

function exportAsWord(html, filename = 'document.doc') {
  // Word文档的HTML模板
  const template = `
    <html xmlns:o="urn:schemas-microsoft-com:office:office" 
          xmlns:w="urn:schemas-microsoft-com:office:word" 
          xmlns="http://www.w3.org/TR/REC-html40">
      <head>
        <meta charset="UTF-8">
        <title>Document</title>
      </head>
      <body>${html}</body>
    </html>
  `;
  
  // 创建Blob对象
  const blob = new Blob([template], {
    type: 'application/msword'
  });
  
  // 创建下载链接
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  
  // 清理
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

AI写代码javascript
运行
12345678910111213141516171819202122232425262728293031

七、浏览器兼容性

大多数现代浏览器都支持Blob API,包括:

  • Chrome 20+
  • Firefox 13+
  • Safari 6+
  • Edge 12+
  • Opera 15+

对于IE10及以下版本,需要使用替代方案如 msSaveBlob 或 FileSaver.js 等polyfill。

八、总结

Blob 作为 Web 开发中处理二进制数据的重要工具,在文件操作、多媒体处理、数据存储等场景中发挥着关键作用。通过本文的介绍,相信大家对 Blob 技术有了更深入的理解。在实际开发中,合理使用 Blob 可以大大提升应用的性能和用户体验。

vue3源码解析:响应式机制

一、示例组件

以下面这个简单的 Vue 组件为例,分析其在渲染过程中响应式机制的建立:

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script setup>
import { ref } from "vue";
const count = ref(0);
const increment = () => {
  count.value++;
};
</script>

<style lang="scss" scoped>
</style>

二、响应式机制建立流程

在深入分析具体流程之前,我们需要理解 Vue 的响应式机制本质上是一个发布订阅模式:

  • 订阅者(Subscriber) : ReactiveEffect 实例,即包装了组件更新函数的响应式效果
  • 发布者(Publisher) : 依赖集合(deps),存储了所有订阅该数据变化的效果
  • 订阅过程: 当 effect 执行时(运行组件的渲染函数render或者用户定义的副作用函数),会访问响应式数据,触发代理的 get 拦截器,此时 effect 就订阅了该数据的 deps
  • 发布过程: 当数据变化时,触发代理的 set 拦截器,找到对应的 deps,deps 通知所有订阅的 effect 执行更新

这个发布订阅模式通过以下步骤建立:

  1. 创建响应式代理,使数据可以被监听
  2. 创建 effect 封装更新函数
  3. 执行 effect 时自动完成订阅
  4. 数据变化时通过代理触发发布
  5. 创建更新任务(job)并加入调度队列

让我们详细看看这个过程是如何实现的:

1. 组件初始化与渲染入口

当执行 app.mount('#app') 时,渲染器开始工作:

// 源码位置: packages/runtime-core/src/renderer.ts
// 大约在 1248 行附近的 baseCreateRenderer 函数内
const render: RootRenderFunction = (vnode, container, namespace) => {
  patch(
    container._vnode || null,
    vnode,
    container,
    null,
    null,
    null,
    namespace
  );
  container._vnode = vnode;
};

2. 组件实例创建与响应式数据初始化

// 组件挂载过程调用链:
// packages/runtime-core/src/renderer.ts -> patch -> processComponent -> mountComponent -> setupComponent

// 1. 创建组件实例
// 源码位置: packages/runtime-core/src/component.ts 中的 createComponentInstance 函数
const instance = createComponentInstance(vnode, parent);

// 2. 初始化 props (变为 shallowReactive)
// 源码位置: packages/runtime-core/src/componentProps.ts 中的 initProps 函数
instance.props = shallowReactive({
  title: "Hello Vue",
});

// 3. 执行 setup 函数,创建响应式状态
// 源码位置: packages/runtime-core/src/component.ts 中的 setupStatefulComponent 函数
const setupResult = setupStatefulComponent(instance);

这里的 props 初始化过程实际上是响应式系统建立的重要一环。让我们详细分析这个过程:

// 源码位置: packages/reactivity/src/reactive.ts
export function shallowReactive<T extends object>(
  target: T
): ShallowReactive<T> {
  return createReactiveObject(
    target,
    false,
    shallowReactiveHandlers,
    shallowCollectionHandlers,
    shallowReactiveMap
  );
}

// 创建响应式对象的核心过程
function createReactiveObject(
  target,
  isReadonly,
  baseHandlers,
  collectionHandlers,
  proxyMap
) {
  // 1. 创建代理对象
  const proxy = new Proxy(
    target,
    baseHandlers // 这里使用 shallowReactiveHandlers
  );

  // 2. 存入响应式对象 Map
  proxyMap.set(target, proxy);
  return proxy;
}

baseHandlers 实现(简化版):

// 源码位置: packages/reactivity/src/baseHandlers.ts
class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _isShallow = false
  ) {}

  // 属性读取时收集依赖
  get(target: Target, key: string | symbol, receiver: object) {
    // ... 一些特殊 key 的处理 ...

    const res = Reflect.get(target, key, receiver);

    // 追踪依赖
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key);
    }

    // shallow 模式下不递归处理
    if (isShallow) {
      return res;
    }

    return res;
  }

  // 属性设置时触发更新
  set(
    target: Target,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    const oldValue = target[key];
    const result = Reflect.set(target, key, value, receiver);

    // 如果值发生变化,触发更新
    if (hasChanged(value, oldValue)) {
      trigger(target, TriggerOpTypes.SET, key, value, oldValue);
    }

    return result;
  }
}

// shallowReactive 使用的处理器
const shallowReactiveHandlers = new MutableReactiveHandler(
  true /* isShallow */
);

这个过程中的关键点:

  1. 响应式代理准备

    • 使用 Proxy 对 props 对象进行代理
    • 通过 shallowReactive 只代理对象的第一层属性
    • 为 title 属性设置 get/set 拦截器
    • 将代理对象保存到 Map 中以便复用
  2. get 拦截器的作用

    • 当后续渲染过程中访问 this.title 时会触发 get
    • get 触发时会调用 track 收集当前正在执行的 effect 作为依赖
    • 这样在 title 更新时就知道需要通知哪些 effect 重新执行
  3. set 拦截器的作用

    • 当父组件修改 title 属性时会触发 set
    • set 触发时会调用 trigger 通知之前收集的依赖进行更新
    • 确保值变化时可以触发组件的重新渲染
  4. 为后续阶段做准备

    • 这个代理过程是为了后续的依赖收集做准备
    • 在组件渲染时会访问 props.title 从而收集渲染 effect
    • 当 title 变化时就能触发这个渲染 effect 进行更新

这样,props 的响应式代理就为整个组件的响应式更新机制打下了基础:

  • 通过 get 收集谁用到了这个属性
  • 通过 set 在属性变化时通知依赖更新
  • 与后续的 effect 和调度系统配合,形成完整的响应式更新链路

3. 建立渲染函数的响应式包装

// 源码位置: packages/runtime-core/src/renderer.ts
// 大约在 setupRenderEffect 函数内(约 2500 行附近)
// setupRenderEffect 调用链:
// packages/runtime-core/src/renderer.ts -> patch -> processComponent -> mountComponent -> setupRenderEffect
// setupRenderEffect 中创建渲染的响应式效果
// 1. 开启作用域收集
instance.scope.on();

// 2. 创建响应式效果
const effect = (instance.effect = new ReactiveEffect(
  componentUpdateFn // 组件的更新函数
));
instance.scope.off();

// 3. 创建更新函数和任务
const update = (instance.update = effect.run.bind(effect));
const job: SchedulerJob = (instance.job = effect.runIfDirty.bind(effect));
job.i = instance;
job.id = instance.uid;

// 4. 设置调度器
effect.scheduler = () => queueJob(job);

// componentUpdateFn 的具体实现
function componentUpdateFn() {
  if (!instance.isMounted) {
    // 首次挂载
    const subTree = (instance.subTree = renderComponentRoot(instance));
    patch(null, subTree, container, anchor, instance, parentSuspense);
    instance.isMounted = true;
  } else {
    // 更新流程...
  }
}

这段代码展示了 Vue 如何建立组件的响应式更新机制:

  1. 作用域控制

    • instance.scope.on() 开启作用域收集
    • instance.scope.off() 关闭作用域收集
    • 这确保了副作用的收集范围限定在组件内
  2. 响应式效果创建

// 源码位置: packages/reactivity/src/effect.ts

export class ReactiveEffect<T = any>
  implements Subscriber, ReactiveEffectOptions
{
  // 依赖链表的头部
  deps?: Link = undefined;

  // 依赖链表的尾部
  depsTail?: Link = undefined;

  // 效果的状态标志位
  flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING;

  // 构造函数,接收要执行的副作用函数
  constructor(public fn: () => T) {}

  // 运行效果,执行副作用函数并收集依赖
  run(): T {
    // 如果效果已停止,直接执行函数不收集依赖
    if (!(this.flags & EffectFlags.ACTIVE)) {
      return this.fn();
    }

    // 设置运行标记
    this.flags |= EffectFlags.RUNNING;

    // 清理旧依赖并准备新的依赖收集
    cleanupEffect(this);
    prepareDeps(this);

    // 保存当前上下文
    const prevEffect = activeSub;
    const prevShouldTrack = shouldTrack;
    activeSub = this;
    shouldTrack = true;

    try {
      // 执行副作用函数,这个过程中会重新收集依赖
      return this.fn();
    } finally {
      // 恢复上下文并清理依赖
      cleanupDeps(this);
      activeSub = prevEffect;
      shouldTrack = prevShouldTrack;
      // 清除运行标记
      this.flags &= ~EffectFlags.RUNNING;
    }
  }

  // 触发更新,根据不同状态决定如何执行更新
  trigger(): void {
    if (this.flags & EffectFlags.PAUSED) {
      // 如果被暂停,加入暂停队列
      pausedQueueEffects.add(this);
    } else if (this.scheduler) {
      // 有调度器则使用调度器执行
      this.scheduler();
    } else {
      // 否则直接检查并运行
      this.runIfDirty();
    }
  }

  // 条件运行,只在"脏"的状态下才执行更新
  runIfDirty(): void {
    if (isDirty(this)) {
      this.run();
    }
  }

  // 其他函数...
}

当创建响应式效果时:

  1. 通过 new ReactiveEffect(componentUpdateFn) 创建实例
  2. 设置调度器 effect.scheduler = () => queueJob(job)
  3. 生成 update 和 job 函数:
const update = effect.run.bind(effect); // 直接运行更新
const job = effect.runIfDirty.bind(effect); // 条件运行更新

这个结构设计的优点:

  • 通过依赖链表(deps)高效管理依赖关系
  • 使用标志位(flags)控制效果的状态
  • 支持可配置的调度器实现更新队列

4. 依赖收集过程

当执行渲染函数时,会访问响应式数据,触发依赖收集:

// 源码位置: packages/reactivity/src/effect.ts 中的 trackEffects 函数
// 和 packages/reactivity/src/ref.ts 中的 RefImpl 类

// 1. 模板中访问数据,触发代理
_ctx.count; // 通过 proxy 访问 ref
_ctx.title; // 访问 props

// 2. 依赖收集的数据结构
// 源码位置: packages/reactivity/src/dep.ts
type Dep = Set<ReactiveEffect>;
type KeyToDepMap = Map<any, Dep>;

// 3. 依赖收集过程
count.dep.add(activeEffect); // activeEffect 就是组件的 update 函数
props.__dep__.add(activeEffect);

5. 更新任务的创建与调度

// 源码位置: packages/runtime-core/src/scheduler.ts

// 1. 更新任务的数据结构
type SchedulerJob = Function & {
  id?: number;
  active?: boolean;
  computed?: boolean;
  allowRecurse?: boolean;
  ownerInstance?: ComponentInternalInstance;
};

// 2. 任务队列
const queue: SchedulerJob[] = [];

// 3. 当数据变化时创建更新任务
function queueJob(job: SchedulerJob) {
  if (!queue.includes(job)) {
    queue.push(job);
    queueFlush();
  }
}

三、响应式机制运作示例

以点击按钮更新计数为例:

// 1. 点击按钮,触发 increment 函数
increment() {
  count.value++  // 触发 ref 的 set
}

// 2. ref 的 set 触发更新
set value(newVal) {
  if (newVal !== this._value) {
    this._value = newVal
    trigger(this.dep)  // 触发依赖
  }
}

// 3. 触发依赖,创建更新任务
trigger(dep) {
  const effects = new Set(dep)
  for (const effect of effects) {
    if (effect.scheduler) {
      effect.scheduler()  // 调用 queueJob
    }
  }
}

// 4. 将更新任务加入队列
queueJob(instance.update)  // instance.update 是之前创建的 effect

// 5. 在下一个微任务中执行更新
nextTick(() => {
  flushJobs()  // 执行所有更新任务
})

四、总结

通过这个具体示例,我们可以看到 Vue 渲染过程中响应式机制的建立是一个完整的链路:

  1. 组件初始化时创建响应式数据
  2. 渲染过程中通过 effect 建立响应式包装
  3. 渲染函数执行过程中进行依赖收集
  4. 数据变化时触发更新,创建更新任务
  5. 调度系统统一处理更新任务

整个过程中涉及的数据结构(effect、dep、job、queue 等)都是为了服务于这个响应式更新流程,确保数据变化能够精确且高效地触发组件更新。后续我们就来分析依赖收集系统的具体实现。

浏览器渲染机制详解(包含渲染流程、树结构、异步js)

公众号:小博的前端笔记

一:渲染机制

核心目标: 将 HTML、CSS、JavaScript 代码转换为用户可见、可交互的像素点。

关键渲染路径: 浏览器完成页面首次渲染所必须经过的一系列步骤。优化关键渲染路径是提升页面加载性能的关键。

主要阶段:

  1. 解析(Parsing)与构建 DOM / CSSOM 树:

    • 输入: 接收 HTML 和 CSS 字节流。

    • 过程:

      • 词法分析: 将字节流分解成有意义的标记(Token)。
      • 语法分析: 根据 HTML/CSS 语法规则,将这些标记构建成树形结构。
    • 输出:

      • DOM(Document Object Model)树: HTML 的树形结构表示。每个 HTML 元素(标签、属性、文本)都是树中的一个节点(Node)。DOM 树捕获了文档的内容和结构。
      • CSSOM(CSS Object Model)树: CSS 的树形结构表示。每个 CSS 规则及其包含的选择器和声明都被映射到树中。CSSOM 树捕获了应用于文档的样式信息。
    • 关键点:

      • 渐进式构建: 浏览器在接收到部分 HTML/CSS 后就会开始解析和构建,无需等待全部下载完成。

      • 阻塞行为:

        • <link rel="stylesheet"> 会阻塞渲染树的构建(Render Tree Construction)和首次渲染(Painting),但通常不会阻塞 DOM 的构建(现代浏览器)。 浏览器需要完整的 CSSOM 来计算样式。因此,它会阻塞后续的渲染步骤,直到 CSSOM 构建完成。
      • 优化提示:

        • 将 CSS 放在文档头部 (<head>),尽早开始 CSSOM 构建。
        • 将 JavaScript 放在文档底部 (</body> 前),或者使用 defer/async 属性来避免阻塞 DOM 构建。
        • 避免使用 @import 在 CSS 中引入其他 CSS 文件,因为它会增加 CSSOM 构建的延迟。
        • 使用媒体查询 (media="print", media="(max-width: 600px)") 让非关键 CSS 不阻塞渲染。
  2. 构建渲染树(Render Tree / Frame Tree):

    • 输入: DOM 树 + CSSOM 树。

    • 过程:

      • 浏览器将 DOM 树和 CSSOM 树合并。
      • 遍历可见的 DOM 节点(不包括 headmetascript[type="text/javascript"]display: none 的元素及其后代等),并为每个需要视觉渲染的节点找到匹配的 CSSOM 规则。
      • 应用这些样式规则,生成一棵只包含可见内容及其计算样式的树——渲染树。
    • 输出: 渲染树。渲染树节点通常称为“渲染对象”或“框”。

    • 关键点:

      • 渲染树只包含需要绘制到屏幕上的元素。
      • visibility: hidden 的元素会包含在渲染树中(因为它占据空间),而 display: none 的元素则不会。
      • 每个渲染对象知道如何将自己及其子元素绘制出来。
  3. 布局(Layout) / 重排(Reflow):

    • 输入: 渲染树。

    • 过程:

      • 计算渲染树中每个节点在视口(Viewport) 内的确切位置(x, y 坐标)和大小(宽度、高度)。这是一个递归过程。
      • 确定所有元素的几何信息(位置、尺寸)。
    • 输出: 每个渲染对象的精确坐标和尺寸信息。

    • 关键点:

      • “流式布局模型”: 网页通常采用流式布局,大多数元素的位置是根据其兄弟元素和父元素的位置计算出来的。

      • 全局布局 vs 增量布局: 首次构建渲染树后的布局称为“全局布局”。之后对渲染树的修改(如改变元素尺寸、位置、添加/删除元素)可能只触发受影响部分的“增量布局”(Reflow)。

      • 性能消耗大: 布局是计算密集型操作,频繁触发布局(重排)会严重影响性能。

      • 触发重排的操作示例:

        • 添加、删除、移动 DOM 元素。
        • 改变元素尺寸(width, height, padding, border, margin)。
        • 改变元素位置(position, top, left)。
        • 改变窗口大小(resize 事件)。
        • 改变字体大小或内容(如文本输入框输入文字)。
        • 激活 CSS 伪类(如 :hover 导致尺寸变化)。
        • 读取某些布局属性(如 offsetWidth, offsetHeight, getComputedStyle())会强制浏览器执行同步布局(也称为“强制同步布局”或“布局抖动”),以获得最新值。
  4. 绘制(Painting) / 栅格化(Rasterization):

    • 输入: 经过布局计算的渲染树。

    • 过程:

      • 绘制(Painting): 将每个渲染对象分解成多个绘制指令(如“画矩形”、“画文字”、“画边框”、“画背景”)。这是一个记录绘制步骤的过程,发生在主线程。

      • 栅格化(Rasterization): 将绘制指令实际执行,转换成屏幕上的像素点。这个过程通常由合成线程(Compositor Thread) 将任务分发给栅格化线程(Raster Threads) 在 GPU 上完成(硬件加速)。

        • 现代浏览器会将页面划分为多个图层(Layers)。
        • 每个图层独立栅格化(通常是在单独的栅格化线程中)。
        • 图层内容发生变化时(如动画),只需重新栅格化该图层,而不是整个页面。
    • 输出: 内存中的位图(Bitmap),表示页面特定区域的像素数据。

    • 关键点:

      • 重绘(Repaint): 当元素的外观(颜色、背景色、边框颜色、阴影等)改变,但不影响其几何属性(位置、大小)时,浏览器会触发重绘。重绘不一定会触发重排(Layout),但重排一定会触发重绘(因为布局改变后外观通常也需要更新)。

      • 性能消耗: 绘制和栅格化也是消耗性能的操作,但通常比重排轻量。复杂的 CSS 效果(如阴影、渐变)会增加绘制时间。

      • 分层(Layering)与合成(Composition):

        • 浏览器会根据 CSS 属性(如 transform, opacity, will-change, position: fixed, video 元素)将元素提升到独立的合成层(Compositing Layer)
        • 每个图层被栅格化后存储在 GPU 内存中。
        • 合成(Composition): 合成线程负责收集所有图层(称为“合成器帧”),计算它们在视口中的最终位置(考虑滚动、缩放、变换),并将它们合成为一个最终的屏幕图像帧。这个过程非常高效,因为它主要是在 GPU 上操作位图。
  5. 显示(Display):

    • 过程: 合成线程将最终合成的位图帧提交给 GPU,由 GPU 将其扫描输出到显示器上。

    • 关键点:

      • 显示器通常以固定频率(如 60Hz,即每秒 60 次)刷新。
      • 浏览器会尽量将新的合成帧与显示器的刷新周期(VSync)同步,以实现流畅的动画和滚动效果。这就是 requestAnimationFrame API 的意义所在。

流程图总结:

字节流 (HTML/CSS)
      ↓
解析 (Parsing)  → [JS 执行可能阻塞 DOM 构建]
      ↓
构建 DOM 树     构建 CSSOM 树 → [CSS 阻塞渲染树构建]
      ↘           ↙
      构建渲染树 (Render Tree) → [只含可见节点+计算样式]
             ↓
           布局 (Layout / Reflow) → [计算精确位置/尺寸]
             ↓
           绘制 (Painting) → [生成绘制指令列表]
             ↓
         栅格化 (Rasterization) → [GPU上执行绘制指令,生成图层位图] (通常在合成线程/栅格线程)
             ↓
          合成 (Composition) → [合成图层,形成最终帧]
             ↓
           显示 (Display) → [GPU输出到屏幕]

延伸点/优化点:

  1. 重排(Reflow)与重绘(Repaint)的优化:

    • 避免触发重排:

      • 避免频繁操作 DOM(使用 DocumentFragment 或离线 DOM 进行批量修改)。
      • 避免逐项修改样式,使用 classcssText 一次性修改。
      • 避免在循环中读取会触发重排的布局属性(offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollLeft, scrollWidth, scrollHeight, clientTop, clientLeft, clientWidth, clientHeight, getComputedStyle())。如果必须读取,先将它们缓存起来。
      • 对复杂动画元素使用绝对定位 (position: absolutefixed),使其脱离文档流,影响范围缩小。
    • 利用合成(Composition)优化动画: 优先使用 transform (位移、缩放、旋转) 和 opacity 属性来制作动画。这些属性可以由合成器线程直接在 GPU 上处理,跳过主线程的布局和绘制阶段,性能极高。

    • will-change 属性: 提示浏览器哪些元素可能会发生变化(如 transform, opacity),让浏览器提前为其创建独立的合成层,优化后续变化的性能(但要谨慎使用,滥用会增加内存消耗)。

    • content-visibility: auto 现代 CSS 属性,可以跳过屏幕外内容的渲染(布局和绘制),大幅提升长页面加载和滚动性能。

  2. 脚本加载优化:

    • defer: 脚本异步下载,在 DOM 解析完成之后、DOMContentLoaded 事件之前按顺序执行。不阻塞 DOM 构建
    • async: 脚本异步下载,下载完成后立即执行(可能在 DOM 解析完成之前或之后)。执行时会阻塞 HTML 解析。适用于无依赖、不操作 DOM 的独立脚本。
    • 模块脚本 (<script type="module">): 默认具有 defer 行为。
  3. 现代渲染架构(Chromium 为例):

    • 主线程(Main Thread / Renderer Thread): 处理 HTML 解析、DOM 构建、CSS 解析、CSSOM 构建、JS 执行(大部分)、布局(Layout)、绘制(Painting - 生成指令列表)。
    • 合成线程(Compositor Thread): 负责图层管理、滚动处理、动画处理(处理 transform/opacity 的合成层动画)、将图层分块(tiles)分发给栅格线程、合成最终帧。
    • 栅格线程(Raster Threads): 在 GPU 上执行绘制指令,将图层分块栅格化为位图。
    • GPU 进程(GPU Process): 管理 GPU 资源,最终将合成线程提交的帧绘制到屏幕上。

回答技巧:

  1. 结构化清晰: 按阶段(解析、构建树、布局、绘制、合成、显示)一步步讲清楚。
  2. 突出关键点: 强调阻塞行为(JS 阻塞 DOM 构建,CSS 阻塞渲染树构建)、重排重绘的区别与优化、合成(Composition)的优势。
  3. 联系实际: 解释为什么要把 CSS 放头部、JS 放底部?为什么 transform 动画更高效?什么是强制同步布局?
  4. 提及优化策略: 主动说出常见的优化手段(如避免重排、使用 transform/opacitydefer/async)。
  5. 了解核心概念: 确保理解 DOM、CSSOM、渲染树、图层(Layer)、合成(Composition)、重排(Reflow)、重绘(Repaint)等术语的含义。
  6. 结合浏览器架构(加分项): 如果深入,可以提一下主线程、合成线程、栅格线程的分工。

二:css树和dom树哪个先构建

核心结论:

DOM树和CSSOM树是并行构建的,但CSSOM的完成会阻塞渲染树构建,而同步JavaScript会阻塞DOM构建

详细构建流程:

20250704_87c3d9.png

关键规则解析:

  1. 并行启动

    • 浏览器同时开始解析HTML构建DOM树和下载/解析CSS构建CSSOM树

    • 示例时间线:

      0ms: 开始解析HTML → 启动DOM构建
      5ms: 发现<link> → 启动CSS下载
      10ms: 发现<img> → 启动图片下载(非阻塞)
      20ms: CSS下载完成 → 启动CSSOM构建
      
  2. 阻塞关系

    • CSSOM构建

      • 不会阻塞DOM树的构建(现代浏览器)
      • 但会阻塞渲染树构建(必须等待CSSOM完成)
      • 会阻塞后续JavaScript执行(JS可能依赖样式)
    • JavaScript

      • 同步脚本(<script>)会立即阻塞DOM构建
      • 遇到脚本时,必须等待当前所有CSS下载完成才执行(避免JS操作未解析的样式)
  3. 特殊场景

    <head>
      <link href="style.css" rel="stylesheet"> <!-- 并行构建CSSOM -->
      <script>
        // 此脚本需等待style.css下载完成才执行!
        console.log(getComputedStyle(document.body).color)
      </script>
    </head>
    <body> <!-- DOM构建在此处被脚本阻塞 -->
      <div>内容</div>
    </body>
    

构建优先级总结:

资源类型 阻塞DOM构建 阻塞渲染树构建 并行性
HTML解析 - - 基础解析流
外部CSS ✅ 并行下载
同步JavaScript ❌ 顺序执行
异步JavaScript ✅ 并行
图片 ✅ 并行下载

最佳答案:

"浏览器会并行启动DOM树和CSSOM树的构建过程,但两者存在关键依赖:

  1. CSS不会阻塞DOM构建:现代浏览器能边下载CSS边构建DOM
  2. CSSOM会阻塞渲染:必须等CSSOM完成才能构建渲染树
  3. JavaScript是最大阻塞源:同步脚本会阻塞DOM构建,且需等待前置CSS下载完成

优化核心:CSS放头部快速构建CSSOM,JavaScript放底部或用async/defer避免阻塞DOM构建"

附加考点:

  • 为什么CSS要放头部? 尽早开始CSS下载,避免渲染树构建延迟导致白屏时间过长

  • 为什么JS放底部? 防止阻塞DOM构建,让用户更快看到页面骨架

  • async vs defer 的区别:

    • async:下载完立即执行,可能中断HTML解析
    • defer:下载完等待HTML解析完成后执行

三、DOM树结构和CSS树结构

DOM树和CSSOM树在底层不是普通的JavaScript对象,而是浏览器渲染引擎用C++/Rust等系统语言实现的高度优化的专用数据结构。下面从底层实现角度详细解析:

DOM树的对象结构

核心实现原理:

// 以Chromium的Blink引擎为例(简化版C++)
class Node {
  NodeType type;          // 节点类型(元素/文本等)
  Node* parent;           // 父节点指针
  Node* first_child;      // 首子节点指针
  Node* next_sibling;     // 兄弟节点指针
  AtomicString node_name; // 节点名称(如"div")
};

class Element : public Node {
  HashMap<AtomicString, AtomicString> attrs; // 属性键值对
  StylePropertyMap style_properties;         // 内联样式
  ComputedStyle* computed_style = nullptr;    // 计算后的样式
};

class Text : public Node {
  String data; // 文本内容
};

在JavaScript中的表现:

// 浏览器暴露给JS的DOM对象只是底层对象的包装器
const div = document.createElement('div');

// 实际内存结构:
   JavaScript Heap
      ↓
   [JS Wrapper Object] ← 通过V8引擎绑定
        │
        └──→ [C++ Node对象] ← Blink引擎内存
               │
               ├── type: ELEMENT_NODE
               ├── tag: "DIV"
               ├── parent: <body的C++对象指针>
               └── style: <指向CSSOM的StylePropertyMap>

🔥 关键真相

  1. 你操作的 div 只是系统对象的JS代理
  2. 每次访问 div.style 都会触发跨引擎通信(JS引擎 ↔ 渲染引擎)
  3. DOM操作昂贵的原因:跨越语言边界 + 触发渲染管线更新

CSSOM树的对象结构

底层实现(以Blink为例):

// CSS规则存储结构
class CSSRule {
  CSSStyleSheet* parent_sheet;
  Vector<CSSSelector> selectors; // 选择器列表
  StylePropertySet properties;   // 样式键值对
};

// 样式表结构
class CSSStyleSheet {
  Vector<CSSRule> rules;
  bool disabled;
};

// 计算样式缓存(每个元素独立)
class ComputedStyle {
  const CSSValue* getProperty(CSSPropertyID id) const;
  // 存储最终计算值,如:
  // width: 100px → 实际像素值
  // color: red → RGBA数值
};

JS访问时的表现:

// 浏览器暴露的有限接口
const stylesheet = document.styleSheets[0];
const rule = stylesheet.cssRules[0];

// 底层实际结构:
   JavaScript访问层
      ↓
   [CSSStyleSheet JS对象] ← 受限代理
        │
        └──→ [C++ CSSStyleSheet对象]
               │
               ├── rules: [C++ CSSRule列表]
               └── 无法直接访问底层样式计算缓存

与普通JS对象的本质差异

特性 普通JS对象 DOM/CSSOM对象
存储位置 JS引擎堆内存 渲染引擎专用内存
操作成本 纳秒级 微秒~毫秒级(涉及跨进程通信)
内存结构 无序属性表 树形结构+样式继承链
修改代价 仅影响JS内存 可能触发重排/重绘
属性访问 直接内存读取 可能触发引擎间IPC通信
垃圾回收 V8引擎管理 跨引擎协同回收

性能陷阱示例

// 看似简单的操作,实际发生的事:
for(let i=0; i<1000; i++) {
  div.style.width = i + 'px'; 
}

// 底层代价:
1. JS → C++: 1000次跨引擎通信
2. 每次修改: 
   - 更新CSSOM计算样式
   - 检查是否触发重排(这里是会触发的!)
   - 重新计算布局
3. 可能触发1000次重排!

高级调试技巧

在Chrome DevTools中验证:

# 1. 访问原生函数(非JS包装器)
> document.body.appendChild.toString()
< "function appendChild() { [native code] }"

# 2. 查看隐藏的底层引用
> const div = document.createElement('div');
> %DebugPrint(div);  # 需启用--allow-natives-syntax flag
< 0x1e3e25c0aed9: [JSObject]
   - map=0x1e3e25d82ed1 [FastProperties]
   - prototype=0x1e3e25d0b111
   - elements=0x1e3e25d82671 [HOLEY_ELEMENTS]
   - embedder fields: 2
   - backing_store=0x55aabbcc3340  # ← 指向C++内存的指针!

终极总结

“DOM树和CSSOM树不是JavaScript对象,而是:

  1. 浏览器内核实现的C++树结构(如Blink中的Node/ComputedStyle)
  2. JS对象只是底层对象的跨语言代理(通过V8绑定实现)
  3. 每次访问 element.style 都涉及 JS引擎 ↔ 渲染引擎的IPC通信

这解释了:

  • 为什么DOM操作比JS对象慢100倍+
  • 为什么前端框架要用虚拟DOM批量更新
  • 为什么直接操作CSSOM的API受限(性能和安全考虑)”

四:什么是异步js

异步 JavaScript 的核心特征

不阻塞 DOM 解析,允许浏览器继续构建 DOM 树


异步 JS 的 5 种实现方式

1. async 属性(经典异步)

<script src="app.js" async></script>
  • 行为

    • 立即并行下载脚本,下载完成立即中断 HTML 解析执行脚本
    • 执行顺序:不可控(先下载完先执行)
  • 适用场景:独立脚本(如埋点统计、广告加载)

2. defer 属性(延迟异步)

<script src="app.js" defer></script>
  • 行为

    • 并行下载脚本,但延迟到 DOM 解析完成后执行DOMContentLoaded 前)
    • 执行顺序:按文档位置顺序执行
  • 适用场景:依赖 DOM 的脚本(如页面初始化逻辑)

3. 动态脚本注入(高级异步)

const script = document.createElement('script');
script.src = 'app.js';
document.head.appendChild(script); // 此时开始异步加载
  • 行为

    • 默认具有 async 行为(可通过 script.async=false 改为按顺序执行)
    • 完全不阻塞解析

4. ES6 模块 (type="module")

<script type="module" src="app.mjs"></script>
  • 行为

    • 默认具有 defer 行为(延迟到 DOM 解析后执行)
    • 添加 async 属性可转为立即执行模式

5. Web Worker(线程级异步)

const worker = new Worker('task.js'); // 在独立线程运行
  • 行为

    • 在后台线程执行,完全不阻塞主线程渲染

同步 vs 异步 关键区别

特性 同步 JS (<script>) 异步 JS (async/defer/动态加载)
阻塞 DOM 构建 ✅ 立即停止解析 ❌ 不阻塞
执行时机 下载完立即执行 async:下载完立即执行 defer:DOM 解析后
执行顺序 按文档顺序执行 async:乱序 defer:顺序
依赖 DOM 可能操作未解析的 DOM defer可安全操作完整 DOM

黄金答案

“异步 JavaScript 是指 不阻塞 HTML 解析器 的脚本加载方式,包含 5 种实现:

  1. async 属性:并行下载,下载完立即执行(可能中断解析),执行顺序不可控
  2. defer 属性:并行下载,DOM 解析完成后按序执行
  3. 动态脚本注入:通过 JS 创建的 <script> 默认异步
  4. ES Module (type="module"):默认等效 defer
  5. Web Worker:在独立线程运行,彻底避免阻塞

核心价值:加速首屏渲染,避免白屏时间过长


⚠️ 常见面试陷阱

问题

<head>
  <script async src="A.js"></script>
  <script defer src="B.js"></script>
  <script src="C.js"></script>
</head>

执行顺序是什么? 答案C.jsA.js(如果先下载完) → B.js 解析

  1. 同步脚本 C.js 立即阻塞解析优先执行
  2. async 脚本 A.js 下载完可能抢在 B.js 前执行
  3. defer 脚本 B.js 最后执行(DOM 解析后)

性能优化必记法则

20250704_104f1b.png

从 "等一下" 到 "马上说":React 牵手 DeepSeek 玩转文本大模型

前言

在人工智能技术日新月异的当下,大型语言模型(Large Language Models, LLMs)无疑是自然语言处理(NLP)领域最具革命性的突破之一。这些模型展现出令人惊叹的理解、推理和生成类人文本的能力,深刻变革了问答系统、内容创作、代码生成、机器翻译等诸多应用场景。本文将结合一个具体的开发实例——基于 React 前端框架与 DeepSeek API 构建的智能问答应用,深入剖析文本大模型的核心原理、关键特性(特别是流式输出)以及如何高效地将其集成到现代 Web 应用中,为开发者提供切实可行的实践参考。

1. 文本大模型:概念与原理

1.1 什么是文本大模型

文本大模型,通常指参数规模巨大(数十亿乃至数万亿)、在海量无标注文本数据上通过自监督学习(如掩码语言建模、下一词预测)进行预训练(Pre-training)的深度学习模型,其核心架构多为 Transformer。这类模型的核心能力在于习得了丰富的语言知识、世界常识和上下文推理能力。区别于传统基于规则或小规模统计的 NLP 模型,大模型展现出强大的泛化能力(Generalization) 和 上下文学习(In-context Learning) 特性,能够根据简单的提示(Prompt)执行多样化的语言任务,而无需针对每个任务进行专门的微调(Fine-tuning)。例如,DeepSeek、GPT、Claude 等都属于此类模型的代表。

1.2 文本大模型的工作原理

文本大模型的核心是 Transformer 架构。其核心机制在于 自注意力(Self-Attention) ,它允许模型在处理序列(如句子)中的每个词(Token)时,动态地计算该词与序列中所有其他词的相关性权重,从而高效地捕捉长距离依赖关系和上下文信息。模型的工作流程通常包含以下关键步骤:

  1. 分词(Tokenization): 将输入文本分割成模型可理解的子词(Subword)单元(Token)。

  2. 嵌入(Embedding): 将每个 Token 映射为高维空间中的稠密向量表示,包含语义和位置信息。

  3. Transformer 编码器/解码器处理

    • 编码器(Encoder) (常用于理解任务): 通过多层 Transformer 块(包含多头自注意力层和前馈神经网络层)处理输入序列,生成包含上下文信息的隐藏状态表示。

    • 解码器(Decoder) (常用于生成任务): 在自回归生成过程中,不仅关注输入序列(通过编码器-解码器注意力),还关注已生成的部分输出序列(通过掩码自注意力),逐步预测下一个最可能的 Token。

  4. 预测与输出: 解码器输出的最终状态通过线性层和 Softmax 层,计算词汇表中每个 Token 作为下一个输出 Token 的概率分布,选择概率最高的 Token(或采用采样策略)作为输出,并迭代此过程直至生成完整响应。

大模型的强大能力源于其在海量数据上预训练获得的参数化知识模式识别能力,使其能够根据提示灵活地完成问答、摘要、翻译、创作等多种任务。

2. 流式输出:提升交互体验的关键

2.1 什么是流式输出

流式输出(Streaming Output)是指模型在生成完整响应内容之前,就开始将已生成的部分结果(通常是 Token 或小的文本片段)实时地、增量地传输并呈现给用户的技术。这类似于“边想边说”的过程,用户无需等待整个响应完全生成完毕即可看到模型思考的初步结果。

2.2 流式输出与非流式输出的区别

特性 流式输出 (Streaming) 非流式输出 (Non-Streaming)
响应方式 响应内容分块(Chunk)实时、增量传输和显示。 等待模型完全生成整个响应后,一次性返回并显示全部内容。
用户体验 显著优越。用户感知延迟低,交互感强,体验更自然流畅。 用户需等待较长时间(尤其长响应时),可能产生“卡顿”感。
适用场景 聊天对话、长文本生成、实时翻译等需要即时反馈的场景。 对响应速度要求不高、内容较短或需要完整内容再处理的场景。
资源占用 服务器和客户端需要维持连接处理数据流。 请求处理完毕即释放连接,资源管理相对简单。

核心优势总结: 流式输出通过降低用户感知延迟(Perceived Latency),极大地提升了人机对话的自然性和流畅性,是现代交互式 AI 应用(如聊天机器人)的标配功能。

3. 项目解析:React 与 DeepSeek 集成实践

3.1 核心功能实现

3.1.1 双模式响应处理(流式与非流式)

以下 React 代码片段展示了如何实现与 DeepSeek API 的集成,并支持根据开关 (streaming) 动态选择流式或非流式响应处理模式:

const update = async () => {
  // 1. 用户输入验证
  if (question.trim() === "") {
    alert("请输入有效的问题内容!");
    return;
  }

  // 2. 设置加载状态,提升用户体验
  setContent("模型正在思考中...");

  try {
    // 3. 配置 DeepSeek API 请求
    const endpoint = "https://api.deepseek.com/chat/completions";
    const headers = {
      "Content-Type": "application/json",
      Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`, // 安全地从环境变量读取密钥
    };

    // 4. 发送 POST 请求
    const response = await fetch(endpoint, {
      method: "POST",
      headers: headers,
      body: JSON.stringify({
        model: "deepseek-chat", // 指定使用的模型版本
        messages: [{ role: "user", content: question }], // 用户问题作为消息
        stream: streaming, // 核心开关:决定是否启用流式传输
      }),
    });

    // 5. 根据流式开关进行分支处理
    if (streaming) {
      // 流式响应处理逻辑 (见 3.1.2)
      await handleStreamingResponse(response);
    } else {
      // 非流式响应处理
      const data = await response.json(); // 等待并解析完整 JSON 响应
      if (data.choices && data.choices.length > 0 && data.choices[0].message) {
        setContent(data.choices[0].message.content); // 一次性更新内容
      } else {
        throw new Error("未收到有效的模型响应数据");
      }
    }
  } catch (error) {
    console.error("API请求或处理错误:", error);
    setContent(`发生错误: ${error.message || "请检查网络或API配置"}`); // 友好错误提示
  }
};

代码解析与关键点:

  1. 输入验证: 确保用户输入非空,防止无效请求。

  2. 状态反馈: setContent("模型正在思考中...") 提供即时视觉反馈,降低用户等待焦虑。

  3. API 配置

    • 使用标准 fetch API 发起请求。

    • Authorization 头安全地从环境变量 (VITE_DEEPSEEK_API_KEY) 获取敏感 API 密钥,避免硬编码风险。

    • 请求体 (body) 指定模型 (model)、包含用户问题的消息数组 (messages),以及控制流式传输的关键开关 stream

  4. 双模式处理分支

    • 流式模式 (streaming: true): 调用专门的 handleStreamingResponse 函数处理数据流(见下文)。

    • 非流式模式 (streaming: false)

      • 使用 await response.json() 等待并解析完整的 API 响应 JSON。

      • 从响应结构 (data.choices[0].message.content) 中提取模型生成的文本内容。

      • 进行必要的错误检查(如响应结构校验)。

      • 使用 setContent 一次性更新 UI 显示完整内容。

  5. 错误处理: 使用 try-catch 捕获网络错误、API 错误或解析错误,并在 UI 上显示友好且信息明确的错误消息。

3.1.2 实时文本解码

流式响应的核心在于前端如何逐步接收、解码和呈现不断到达的数据块(Chunk)。以下代码展示了 handleStreamingResponse 的核心逻辑:

const handleStreamingResponse = async (response) => {
  // 1. 获取可读流读取器和文本解码器
  const reader = response.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let result = ""; // 累积最终完整内容

  try {
    // 2. 循环读取流数据块
    while (true) {
      const { value, done } = await reader.read(); // 读取下一个数据块
      if (done) {
        // 流结束,处理最终逻辑(如标记完成状态)
        break;
      }

      // 3. 解码数据块 (可能是多个事件或部分Token)
      const chunk = decoder.decode(value, { stream: true }); // 注意 `stream: true` 允许处理不完整字符序列

      // 4. 解析和处理增量内容 (核心步骤)
      const lines = chunk.split("\n");
      for (const line of lines) {
    if (line.startsWith("data: ")) {
            const data = line.slice(6); //从下标6开始切割字符串
            if (data === "[DONE]") {
                break; // 跳出当前循环
              }
        try {
            const parsed = JSON.parse(data);
            const delta = parsed.choices?.[0]?.delta?.content; //一小节中文
            if (delta) {
                result += delta;
                setContent(result);
            }
            }
          } catch (e) {
            console.log("解析流事件JSON失败:", e, "原始行:", line);
          }
        }
      }
    }
  } catch (e) {
    console.log("处理流数据时出错:", e);
    setContent(accumulatedContent + `\n\n[流处理中断: ${e.message}]`);
  } finally {
    reader.releaseLock(); // 释放读取器锁
  }
};

流式处理关键点解析:

  1. 获取读取器和解码器: getReader() 获取 ReadableStream 的读取器,TextDecoder 用于将二进制数据块解码为字符串。

  2. 循环读取: 使用 while 循环配合 reader.read() 持续读取数据块,直到流结束 (done === true)。

  3. 解码数据块: decoder.decode(value, { stream: true }) 将接收到的 Uint8Array 数据块解码为字符串。{ stream: true } 选项至关重要,它允许处理跨越多个数据块的字符序列(如多字节 UTF-8 字符)。

  4. 解析流事件

    • 格式假设: 大多数 LLM API 的流式响应采用 Server-Sent Events (SSE) 格式,即每个事件以 data: 开头,后跟 JSON 数据(或 [DONE]),并以 \n\n 分隔。

    • 分割与过滤: 按换行符分割数据块字符串,并过滤掉空行。

    • 提取事件数据: 识别 data: 行,提取后续 JSON 字符串。

    • 解析 JSON: 尝试解析 JSON 字符串。

    • 提取增量内容: 从解析后的对象中获取增量文本(通常位于类似 choices[0].delta.content 的路径下)。

  5. 实时 UI 更新

    • 累积增量: 将每次获得的 Delta 追加到累积内容 (result)

    • 状态更新: 调用 setContent(result) 来实时更新 React 组件的状态。这是实现用户“逐字”或“逐句”看到模型响应的关键步骤。

  6. 结束处理: 处理 [DONE] 事件或 done 信号,进行清理(如释放读取器锁 reader.releaseLock())。

  7. 错误处理: 捕获流处理过程中可能出现的 JSON 解析错误或其他异常,并在 UI 上提供反馈(如显示已累积内容并附加错误信息)。

结语

大型语言模型(LLMs)作为人工智能在自然语言理解与生成领域的巅峰成就,正在以前所未有的方式重塑人机交互和应用开发范式。其强大的泛化能力和上下文学习特性,为构建高度智能化的应用提供了坚实基础。本文通过一个具体的 React + DeepSeek API 智能问答应用案例,系统性地阐述了文本大模型的核心原理(Transformer, 自注意力)、关键交互技术(流式输出)以及在现代前端框架中的集成实践(双模式处理、实时解码)。

js高级程序设计(日期)

1.Date时间类型

Date时间类型可以通过构造函数Date实现对时间的操作,如:获取时间戳等。今天看了一下明白了起航转换为时间戳的一些原理,看下面。

   var newDate = new Date();  //获取当前时间

下面看一下转换为毫秒数和之前使用的方法的不同,这里只是一些方法转换,我还是倾向于之前的方法转换,因为简便。

    Date.parse(日期字符串)  //参数是一个日期字符串,是一个将日期字符串转换为毫秒数的方法,一般这个方法在后台调用,对用户是透明的。
    Date.UTC(年, 月, 日, 时, 分, 秒)   //它的参数将日期的年月日时分秒,分别转换为单个的参数,日期的月份的参数的基数是从0开始的,作用个上述的方法一样都是转换为日期的毫秒数。

我之前在进行转换时直接使用的就是将参数放在new Date()方法中,今天看到这,才知道日期转换的原理,原来是在后台调用的是Date.parse()和Date.UTC()方法。如下所示:

     new Date(日期字符串) //将日期字符串转换为指定的日期
     new Date(年, 月, 日, 时, 分, 秒)  //将日期字符串转换为指定的日期

其实还有一种方法就是Date.now()这是获取的当前日期时间。

     Date.now()  //将当前日期转换为毫秒数,可以用来计算时间差,或者比较两个时间

对了,Date.parse()和Date.UTC()方法的区别就是参数不同😆。

2.Date中的继承方法以及时间组件中常用的方法

继承的方法有两个: * toString()(包含时区信息)和toLocaleString()*这两种方法用的不是很多,这里的作用是返回日期和时间信息。 下面就是日期组件的常用的方法了(这些是最常用的方法):

  • getTime(): 返回日期的毫秒数,也叫时间戳
  • getFullYear(): 返回日期的年份(四位数字)
  • setFullYear(): 设置日期的年份(四位数字)
  • getMonth(): 返回日期的月份,基数从0开始,0代表一月,以此类推。
  • setMonth(): 设置日期的月份,规则如上。
  • getDate(): 返回日期的天数
  • setDate(): 设置日期的天数
  • getHours(): 返回日期的小时数
  • setHours(): 设置日期的小数数
  • getMinutes(): 返回日期的分钟数
  • setMinutes(): 设置日期的分钟数
  • getSeconds(): 返回日期的秒数
  • setSeconds(): 设置日期的秒数
  • getMilliseconds(): 返回日期的毫秒数
  • setMilliseconds(): 设置日期的毫秒数
  • getDay(): 获取周几(这和我们平常的理解有点不同,0代表周日,以此类推,6代表周六
  • getTimezoneOffset(): 获取本地时间与UTC时间相差的分钟数,这个方法我还是第一次见

3.在工作中看到的一个方法format(),这里作为补充

这个方法我是第一次见,所以就google了一下,发现这真是个好东西,它是Date对象的一个原型方法,给我的最直观的感觉就是在拼接一个日期的时候,不需要再去一一调用上面的方法,而是直接调用format方法进行格式化即可,里面的参数就是按照需求想得到的日期格式的字符串。

var now = new Date('2017-09-18 12:09:20');  
var newDate = now.format('yyyy-MM-dd'); 
console.log(newDate);  //2017-09-18 
var newYear = now.format('yyyy'); 
console.log(newDate);  //2017

以后在业务里面可以封装一个获取年月日时分秒以外还包含周几的方法😯,这样可以适用更多的业务场景。

js高级程序设计(4/5章节)

1.对执行环境的理解

 执行环境分为局部环境和全局环境,这个是一个线性的作用域链,全局环境位于链的末尾,当前环境位于链的开始。比如说在函数中嵌套函数,内层函数的环境就会处在作用域链的开始,如果要访问外层函数的变量对象,作用域会沿着该链向上搜索要访问的变量对象,直到找到window对象(全局对象)。总结一句话:在局部环境中可以访问全局环境中的变量对象,颠倒过来则不成立。
 

2.if和for中的局部变量

由于JavaScript中没有块级作用域这一概念,所以在像if和for循环语句中定义的局部变量,在执行完这些代码后定义的局部变量并不会立即销毁,而是保存在当前的作用域中,即在if和for循环之外仍然可以访问这些局部变量。

3.对于垃圾回收机制的简单认识

 在执行环境中,当变量不在使用时,可以通过'标记清除’的策略进行垃圾回收,给这些白能量加上标签,在垃圾收集器运行的时候,销毁它的值,释放其占用的内存,这样来提高网络的性能,防止因为内存占用过多导致的系统性能下降甚至崩溃。
 还有一种是'引用计数’的垃圾回收机制策略,这种策略会导致循环计数,由于对这个的理解不是太清楚,只是看到在互相引用时,回导致计数值永远不会为0,也就是说永远不可能回收其内存,这种方法目前已不被使用。
 

4.创建对象的三种方式

1)使用new关键字加上Object构造函数进行创建,再通过打点去创建,如:var obj = new Object(); obj.name = 'cs';
(2)通过字面量的方法进行创建,如:var obj = {name: ‘cs'}
(3)可以通过一个空的对象字面量,再通过打点去创建,如:var obj = {}; obj.name = ‘cs';
 总结:在后两种以字面量进行创建对象的同时,并不会再去调用Object的构造函数。

5.获取对象的属性值得两种方式

1)可以通过打点方式获取,但是打点后面的属性名不能是数字
(2)可以通过’[]’方式去获取属性的值,这里面可以放变量,或者字符串的属性名,并且变量的值可以为数字或者其它,它使用的扩展性更强,一般很少使用。

6.创建数组的两种方式

1)可以通过new关键字,并且调用Array的构造函数进行创建,如:var arr = new Array();
(2)可以通过字面量的方式进行创建,如:var arr = [];这种创建方式并不会去调用Array的构造函数

7.对数组length属性的认识

 针对看到的length属性的特点,它不仅仅可以计算数组的长度,还可以对数组的元素在数组的末尾进行移除或者增加。比如数组的长度为3,即var arr = [1, 2, 3];可以通过设置数组的属性length,arr.length = 2;这样再去访问arr[3]的时候就会变为undefined。同样如果对之前的数组长度设置为arr.length = 100;那么arr[3]到arr[99]都是undefined。
 

8.检测数组类型的方式

1)使用instanceof操作符,如:a instanceof Array会返回布尔值,但是他有个局限,就是用在单一的全局执行环境中,如果一个网页上有个框架,那么可能就会有多个全局执行环境,那么这时就会有多个不同的Array构造函数,就不适用了。
 (2)对于有多个全局执行环境和构造函数而言,可以使用Array.isArray(a)来判断a的值是不是数组,它的返回结果也是布尔值。
 

9.为数组添加和移除新的元素

1push()和pop(),这两个方法是从数组的末尾操作数组元素,push()可以传入多个值,返回的时传入参数之后的数组长度,pop返回的是移除数组的那个元素。
 (2unshift()和shift(),这是在数组的开头进行操作数组元素的方法,同样,unshift()也可以传入多个参数,返回的是传入参数之后的长度,shift()是从数组的开头移除数组元素,返回的是移除的元素。

如何在程序中嵌入有大量字符串的 HashMap

作者:Rstack 团队 - quininer

当需要在应用程序中静态的嵌入大量可查询的数据时,你会怎么做?

常见的做法是直接嵌入 JSON 数据,然后在初始访问时做解析:

static MAP: LazyLock<HashMap<&str, usize>> =
    LazyLock::new(|| serde_json::from_str(include_str!("data.json")).unwrap());

缺点显而易见:

  • 需要在初次访问时阻塞调用
  • 需要分配永不释放的常驻内存
  • 还需要引入复杂的 json parser

我们在这里介绍一些技术,构造完全静态、高效的 Map:

  • 即不需要初始化、不需要解析、不需要内存分配
  • 同时尽可能保持快速的编译性能和极小的体积占用

高效的静态可查询 Map

MPHF:全称 Minimal Perfect Hash Function,是构造高效静态 Map 的常用技术

通常我们认为 hashmap 的时间复杂度 O(1),额外空间开销为 0,但这仅限于所有 keys 的 hash 不发生冲突的情况。 当发生冲突时,通常冲突的 key 将会被分配到同一个 bucket 中, 这导致时间复杂度可能回落到 O(N),并且 bucket 总是引入额外的空间占用,不能保证空间紧凑。

而 mphf 技术可以使得 hashmap 时间、空间总处在完美状态。 最坏时间复杂度 O(1),不存在空槽,空间布局紧凑。

最简单的 MPHF

mphf 定义上是一种完全单射的 hash function,可以将指定的 key 集合一对一映射到同等大小的值域。

此结构最简单的 mphf 构造方法是:

  • f(x) = h(seed, x) mod n
  • 不停轮换 hash function 所使用的的 seed
  • 直到有合适的 seed 可以使得所有 x 产生的 hash mod n 无冲突

但对于大量 keys 的情况,期待有单个 seed 能够使得 hash 无冲突的映射所有 x 是不现实的

  • 即使高质量的 hash function 存在这个可能性,也很难在合理时间内搜索到合适的 seed

更实用的 MPHF 构造

对于大量 keys 的情况,常见的处理和传统 hashmap 颇为类似

  • 对 keys 分 bucket 处理,按 bucket 去冲突
  • 每个 bucket 记录一个值,称为 displace
    • 当 bucket 发生冲突时,修改 displace 值
    • 调整该 bucket 内 keys 具体映射到的 index
    • 重复直到所有 bucket 无冲突

我们在这里简单描述几种 MPHF 构造:FCH、CDH、PtHash,三者大同小异,主要区别是使用 displace 的方式不同

hash 特点
FCH f(x) = (h(x) + d[i]) mod n 按数字调整偏移。这导致调整范围过小,很难找到合适的 displace 值。
CDH f(x) = φσ[g(x)](x) mod n 使用独立的 hash 值调整偏移。效果很好,但比起其他方案要多进行一次 hash (或更长的 hash 输出)。
PtHash f(x) = (h(x) ⊕ h'(k[i])) mod n 使用数字k做 xor。兼具以上两者的好处,调整范围足够大,也不需要进行较重的 hash。

更先进的 MHPF 构造:PtrHash

Ptr-hash 在 PtHash 基础上有许多改进,其中最有趣的是 bucket 调整使用了 cuckoo hash 策略。

Cuckoo hash 是一种经典的 hashmap 去冲突方案

  • 当 key 发生冲突时,不像典型的 hashmap 使用 bucket 同时容纳多个 key
  • 而是新 key 会将旧 key 踢出,旧 key 使用新的 hash 函数重新分配 index
  • 如果旧 key 重新分配 index 时发生冲突,重复以上步骤
  • 其过程神似杜鹃鸠占雀巢,故称为 Cuckoo hash

高效的去冲突策略使得 ptr-hash 可以使用质量更低的 hash 算法,以及为每个 displace 仅使用 1byte,相比传统方案时间、空间开销更小。

Cuckoo hash 策略的好处

踢出旧 key 重新安排听起来非常低效,为什么这是个好的构造策略?

其基本思路是:

  • 在合适的负载下,很大可能存在可以容纳所有 keys 的排布方式
  • 但传统 hashmap 中,旧 bucket 的位置一旦产生就不会变更
    • 这很可能不是最佳的排布方式,尤其是早期插入的 bucket 的 displace 基本是 0
  • 而 Cuckoo hash 的踢出策略,可以给予旧 bucket 重新调整排布的机会

面临体积膨胀问题…

MPHF 解决了静态数据的查询问题,但… 如果我们写出以下类似以下的代码

static NAMES: &[&str] = &[
    "alice", "bob", // ...
];

当我们朴素的使用 MPHF 来索引 &[&str] 等数据,会发现它编译过慢、产物体积过大,

问题出在哪里呢?

&[&str] 膨胀

&str 开销

如果对 Rust 的内存布局有一定了解,很容易注意到 &str 其本质是 (*const u8, usize)

这意味着 &[&str] 中,每个字符串要有 16byte 的额外体积占用。 对于短字符串而言,其索引的二进制体积开销甚至要大于其字符串本身。

但这还不是全部。

relocation 开销

我们构造一个使用大量 &[&str] 的程序,打印 section headers 会发现

  9 .rela.dyn          00499248 0000000000000fd8 DATA
 11 .rodata            00419c10 000000000049a240 DATA
 24 .data.rel.ro       00496b80 00000000009042a8 DATA

它最大的 section 并不是用于存放不变数据的 .rodata,而是 .rela.dyn.data.rel.ro

回想一下我们定义的全局变量 NAMES,这是一个 &str 的列表,里面存放的是指向字符串的指针。 但我们的 so 在被加载前,它的基地址尚未确定,编译器怎么能产生一个还不确定的指针列表呢?

答案是 dynamic linker 需要在 so 被加载时对数据进行调整,该过程被称为 relocation。

  • 编译时,产生 .data.rel.ro 段和 .rela.dyn
    • 其中 .data.rel.ro 存放的是仅在 relocation 过程中可变的 readonly data
    • .rela.dyn 存放的是需要 relocation 的数据的 metadata
  • so 被加载时,dynamic linker 遍历整个 .rela.dyn 段对 .data.rel.ro 段(及其他)进行调整
typedef struct {
  Elf64_Addr    r_offset;   // Address
  Elf64_Xword   r_info;     // 32-bit relocation type; 32-bit symbol index
  Elf64_Sxword  r_addend;   // Addend
} Elf64_Rela;

这意味着 &[&str] 中每个字符串还额外有 24byte 的体积开销,并且会影响整个程序的启动性能!

ELF 新的 relocation 方案 RELR 能够极大改善该问题,可以做到每个字符串仅 2bit 的体积开销 see maskray.me/blog/2021-1…

Mac 和 Windows 上的 relocation 占用要比 ELF 的 RELA 方案稍好,但不如新方案 RELR

打包字符串和手动索引

Position index

既然编译器产生的字符串索引有那么多开销,那么我们可以打包字符串并且手动建立索引。

一个简单方案是拼接所有字符串,然后使用 string end position 作为索引

const STRINGS: &str = include_str!("string.bin");
const INDEX: &[u32] = [
    4, 10, 15, // ...
];

fn get(index: usize) -> Option<&'static str> {
    let end = INDEX.get(index)? as usize;
    let start = index.checked_sub(1)
        .map(|i| INDEX[i])
        .unwrap_or_default() as usize;
    Some(&STRINGS[start..end])
}

这使得每个字符串索引开销仅有 4byte,并且完全消除了 relocation 开销。

  • 所有内容均处于 .rodata 中,可以直接通过相对地址访问。

访问过程仅涉及简单的运算,性能没有太大牺牲。

String pool

Position index 很紧凑,我们还可以使用 Elias-Fano 技术使得它更紧凑。 但简单的拼接字符串会使得编译器无法合并重复的字符串,导致它在特定场景下反而有所劣化。

例如以下代码,可以观察到 S[0]S[1] 的两个字符串地址完全相同,证明编译器对它们进行了合并。

    static S: &[&str] = &[
        "alice", "alice"
    ];

    assert_eq!(S[0].as_ptr(), S[1].as_ptr());

我们可以使用 string pool 自行对字符串进行合并,来避免这一缺点。

#[derive(Default)]
struct StrPool<'s> {
    pool: String,
    map: HashMap<&'s str, u32>,
}

impl<'s> StrPool<'s> {
    pub fn insert(&mut self, s: &'s str) -> u32 {
        *self.map.entry(s).or_insert_with(|| {
            let offset = self.pool.len();
            self.pool.push_str(&s);
            let len: u8 = (self.pool.len() - offset).try_into().unwrap();
            let offset: u32 = offset.try_into().unwrap();

            if offset > (1 << 24) {
                panic!("string too large");
            }

            offset | (u32::from(len) << 24)
        })
    }
}

作为节省空间的技巧,我们可以将 offset 和 length 通过 bitpack 打包到单个 u32 中。 使得它和 position index 一样,每个字符串的索引开销仅为 4byte,但代价是单个字符串的最大长度被限制为 255。

编译速度

作为打包字符串的另一个好处,这也极大的改善了编译时间和内存使用。

朴素的&[&str]方案中 rustc 耗时极大的三个阶段会在字符串打包后减少至可忽略。

before:

time:   1.414; rss:  258MB ->  761MB ( +503MB)        type_check_crate
time:   2.325; rss:  712MB ->  651MB (  -61MB)        LLVM_passes
time:   2.217; rss:  476MB ->  597MB ( +121MB)        finish_ongoing_codegen

after:

time:   0.022; rss:  107MB ->  142MB (  +35MB)        type_check_crate
time:   0.100; rss:  194MB ->  183MB (  -11MB)        LLVM_passes
time:   0.098; rss:  159MB ->  177MB (  +18MB)        finish_ongoing_codegen

主要是避免让编译器产生大量对象、以及避免对其进行不必要的优化。

总结

嵌入静态 HashMap 看起來很简单,但 naive 的方案总有各种代价需要牺牲。 我们介绍了 MPHF 技术和字符串打包技术,可以在不牺牲任何方面的情况下实现该功能。

启动耗时 查询性能 内存占用 体积占用 编译速度
lazy + json parse O(N) Θ(1) O(N) 紧凑
mphf + &[&str] _ O(1) _ 膨胀
mphf + string pack _ O(1) _ 紧凑

js高级程序设计(1/2章节)

 前两天看了js高级编程的前两章,这两章是一些叙述性的东西,大部分是介绍了js的发展历史和与es5之前的一些差异。今天看了第三章,这章讲述了基础的知识点。
 

1.Number()和parseInt()、parseFloat()之间的区别:

1Number()适用于任何的数据类型的转换,ParseIntParseFloat只是适用于将字符串转换为数值
(2Number()可以将空字符串转换为0, 而后者是以读取到的第一个数字字符为标准,所以它在对空字符继续进行转换的时候,由于一直没有读取到数值字符,所以回返回NaN3Number()只能识别十六进制的数值,对八进制的数值不会识别,它会直接忽略前导0,转换为十进制。后者可以通过再传递一个参数(基数)会对八进制和十六进制正常转换。                  如:Number(’0Xaf’)和parseInt(‘0Xaf’, 16)
(4parseFloat()会忽略前导0,只能识别第一个小数点,遇到第二个就停止解析,但会返回之前解析的数值。对十六进制的字符串进行解析的时候,直接返回的是0,这是和parseInt()的不同之处。

2.String()和toString()方法的区别:

String可以将任何类型的数值转换为字符串,后者对于大部分的值可以转换为字符串,但对nullundefined不行,因为这两个数值没有toString()方法,只能进行强制转换

3.Object类型

1)可以通过new进行创建,对象中常用的属性和方法有这几个:Construct()(这里存放的是创建对象的函数)、hasOwnProperty()(里面的参数必须是字符串,可以判断一个对象实例中是否有这个属性)、isPrototypeOf(obj)(判断传入的对象是否是另一个对象的原型),这里在属性和方法在原型的应用中会见到。
 (2)对象有个valueOf()方法,第一次见到,var o = {valueOf: function() {return -1;}} 这样o的值就是-1,这里的valueOf会返回对象的字符串、布尔值、数值的表示

4.逻辑与和逻辑或

1)逻辑与和逻辑或都属于短路操作,即有一个值为符合条件的话就不会再去求职第二个值,这在平常的代码中经常看到,尤其是逻辑或操作。
(2)逻辑与就是只要其中一个操作数是NaNnullundefined那么返回的结果就是这些数,如果第一个操作数为对象,则返回第二个操作数。如果第二个操作数为对象,则在第一个操作数为true的情况下,返回第二个操作数。如果两个操作数都是对象,则返回第二个操作数。
(3)逻辑或如果两个操作数都是NaNnullundefined那么返回的结果就是这些数。如果第一个操作数为对象,则返回第一个操作数。如果第二个操作数为对象,则在第一个操作数为false的情况下,返回第二个操作数。如果两个操作数都为对象,则返回第一个操作数。

5.==和===

 由于undefined派生于null,所以他们只是类似的数值,并不是同一种类型的值,null是一个空对象,undefined是应该有值但是未定义。所以null == undefined(true),null === undefined(false)

6.while循环和for循环的关系

 以前没有深入了解过这种关系,今天看到了感觉它们都是扣在一起的。如果while循环不能实现,那么for循环也不能实现,可以这样理解,for循环实在while循环的基础上进行演化的,其灵活性更加强大。比如:for(var i = 0; i < 10; i++) {}在代码块外面也可以访问到i,猛然一看给人一种错觉,但是因为他是while的转换,可以转换为这样,var i = 0;while (i <  10) {i++;}这样也可以在外面访问到i。所以再去理解for外面访问i的问题应该就不难了。
 

7.for...in语句

 这是进行循环对象属性的循环语句,在使用前要先确定该对象的值不是null或者undefined否则改循环代码不会执行,也不抛出错误。在循环过程中,属性的先后顺序可以不同,这是因浏览器的差异导致的。
 以上是今天看书后的总结,继续加油!

大厂面试官都在问的 WEUI Uploader,源码里藏了多少干货?🤔

作为前端开发,想进大厂光会写业务可不够 ——啃透优秀开源组件的源码,才是拉开差距的关键!今天就带大家扒一扒微信 WEUI Uploader 组件的源码,从 HTML 结构到 CSS 细节,手把手拆解那些让面试官眼前一亮的「神仙操作」✨

一、先看整体:这个上传组件到底长啥样?

WEUI Uploader 是一个支持图片 / 文件 / 视频上传的通用组件。整体设计遵循「移动端友好」原则,从布局到交互都透着大厂级的细节把控。

先放张简化的结构示意图(直观感受下):

.page(页面容器)
├─ .page__hd(头部)
└─ .page__bd(主体)
   └─ .weui-cells(表单容器)
      └─ .weui-cell(表单单元)
         └─ .weui-uploader(上传核心组件)
            ├─ .weui-uploader__hd(上传头部:标题+计数)
            └─ .weui-uploader__bd(上传区域:文件列表)

是不是一目了然?这种「嵌套式结构」正是大厂组件设计的精髓 ——职责分明,层级清晰🧩

二、HTML 结构:语义化 + BEM 命名,代码会「说话」📚

先看HTML的核心代码,里面藏着 2 个前端基本功的「教科书级示范」:

1. 语义化标签:不止是好看,更是专业

<header class="page__hd">...</header>
<main class="page__bd">...</main>

这里没用一堆<div>堆结构,而是用了<header>(头部)、<main>(主体)等语义化标签。好处是:

  • 机器能看懂:搜索引擎更易抓取内容,SEO 友好;
  • 人能看懂:同事接手代码时,不用猜「这个 div 是干嘛的」。

2. BEM 命名规范:类名即注释,维护不头秃

WEUI 的类名堪称 BEM 典范,比如:

  • weui-uploader:Block(块)—— 整个上传组件

  • weui-uploader__hd:Element(元素)—— 上传组件的头部(__连接块和元素)

  • weui-cell_uploader:Modifier(修饰符)—— 带上传功能的表单单元(_连接块和修饰符)

这种命名方式就像给代码贴标签,看到weui-uploader__info就知道是「上传组件的计数信息」,再也不用对着class="left-box"猜半天了😎

三、CSS 细节:这些「骚操作」让组件活起来🎨

CSS里的代码看似简单,实则全是移动端适配的「小心机」,挑 3 个最值得学的点:

1. 伪元素玩得溜:用::before 画分隔线,不污染 HTML

.weui-cells::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 1px;
  background-color: rgba(0,0,0,0.1);
}

用伪元素::before给表单容器加顶部边框,好处是:

  • HTML 里不用写额外的<div class="line">,结构更干净;
  • 边框样式改起来只动 CSS,符合「结构与样式分离」原则。

2. 移动端滚动优化:-webkit-overflow-scrolling 让滑动丝滑

.page {
  overflow: scroll;
  -webkit-overflow-scrolling: touch; /* 关键! */
}

这个属性专门为移动端设计:开启后滚动会带「惯性」,手指离开屏幕后还能滑一会儿,比普通滚动手感好 10 倍!而且只在 webkit 内核(安卓 / 苹果浏览器)生效,完美适配移动端🌍

3. float 布局的「复古用法」:多图排列还得靠它

.weui-uploader__file {
  float: left; /* 左浮动 */
  margin-right: 8px;
  margin-bottom: 8px;
  width: 96px;
  height: 96px;
}

虽然现在 flex/grid 是主流,但 float 在「多图瀑布流」场景下依然能打:

  • 图片左浮动,一行排满自动换行;

  • 用负 margin(.weui-uploader__bdmargin-right: -8px)抵消外层间距,避免最后一行右侧多出空白。

这种「旧技术新用法」的思路,面试官超爱考!💡

移动端效果图

image.png

四、核心技术点总结:5 个让你加分的知识点📝

  1. 语义化标签<header><main>提升可读性和 SEO;
  2. BEM 命名Block__Element--Modifier让类名有意义;
  3. 伪元素技巧:用::before/::after做装饰,不污染结构;
  4. 移动端滚动优化-webkit-overflow-scrolling: touch增强滑动体验;
  5. float 布局场景:多列排列 + 自动换行,复古但实用。

最后:源码学习的正确姿势是什么?

WEUI 这类大厂组件的源码,值得学的不只是「代码怎么写」,更是「为什么这么写」:

  • 为什么用 BEM?因为团队协作需要统一规范;

  • 为什么加 - webkit 前缀?因为要兼容移动端主流浏览器;

  • 为什么用 float 排图片?因为在低版本浏览器里更稳定。

吃透这些「设计思路」,下次自己写组件时,才能从「实现功能」升级到「做好体验」🚀

放弃 JSON.parse(JSON.stringify()) 吧!试试现代深拷贝!

作者:程序员成长指北

原文:mp.weixin.qq.com/s/WuZlo_92q…

最近小组里的小伙伴,暂且叫小A吧,问了一个bug:图片

提示数据循环引用,相信不少小伙伴都遇到过类似问题,于是我问他:

我:你知道问题报错的点在哪儿吗

小A: 知道,就是下面这个代码,但不知道怎么解决。

onst a = {};
const b = { parent: a };
a.child = b; // 形成循环引用

try {
  const clone = JSON.parse(JSON.stringify(a));
} catch (error) {
  console.error('Error:', error.message); // 会报错:Converting circular structure to JSON
}

上面是我将小A的业务代码提炼为简单示例,方便阅读。

  • 这里 a.child 指向 b,而 b.parent 又指回 a,形成了循环引用。
  • 用 JSON.stringify 时会抛出 Converting circular structure to JSON 的错误。

我顺手查了一下小A项目里 JSON.parse(JSON.stringify()) 的使用情况:

图片

一看有50多处都使用了, 使用频率相当高了。

我继续提问:

我:你有找解决方案吗?

小A: 我看网上说可以自己实现一个递归来解决,但是我不太会实现

于是我帮他实现了一版简单的递归深拷贝:

function deepClone(obj, hash = new Map()) {
if (typeof obj !== 'object' || obj === null) return obj;
if (hash.has(obj)) return hash.get(obj);

const clone = Array.isArray(obj) ? [] : {};
  hash.set(obj, clone);

for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key], hash);
    }
  }
return clone;
}

// 测试
const a = {};
const b = { parent: a };
a.child = b;

const clone = deepClone(a);
console.log(clone.child.parent === clone); // true

此时,为了给他拓展一下,我顺势抛出新问题:

我: 你知道原生Web API 现在已经提供了一个深拷贝 API吗?

小A:???

于是我详细介绍了一下:

主角 structuredClone登场

structuredClone() 是浏览器原生提供的 深拷贝 API,可以完整复制几乎所有常见类型的数据,包括复杂的嵌套对象、数组、Map、Set、Date、正则表达式、甚至是循环引用。

它遵循的标准是:HTML Living Standard - Structured Clone Algorithm(结构化克隆算法)。

语法:

const clone = structuredClone(value);

一行代码,优雅地解决刚才的问题:

const a = {};
const b = { parent: a };
a.child = b; // 形成循环引用

const clone = structuredClone(a);

console.log(clone !== a); // true
console.log(clone.child !== b); // true
console.log(clone.child.parent === clone); // true,循环引用关系被保留

为什么增加 structuredClone

在 structuredClone 出现之前,常用的深拷贝方法有:

方法 是否支持函数/循环引用 是否支持特殊对象
JSON.parse(JSON.stringify(obj)) ❌ 不支持函数、循环引用 ❌ 丢失 DateRegExpMapSet
第三方库 lodash.cloneDeep ✅ 支持 ✅ 支持,但体积大,速度较慢
手写递归 ✅ 可支持 ❌ 复杂、易出错

structuredClone 是 原生、极速、支持更多数据类型且无需额外依赖 的现代解决方案。

支持的数据类型

类型 支持
Object ✔️
Array ✔️
Map / Set ✔️
Date ✔️
RegExp ✔️
ArrayBuffer / TypedArray ✔️
Blob / File / FileList ✔️
ImageData / DOMException / MessagePort ✔️
BigInt ✔️
Symbol(保持引用) ✔️
循环引用 ✔️

❌ 不支持:

  • 函数(Function)
  • DOM 节点
  • WeakMap、WeakSet

常见使用示例

1. 克隆普通对象

const obj = { a: 1, b: { c: 2 } };
const clone = structuredClone(obj);
console.log(clone);  // { a: 1, b: { c: 2 } }
console.log(clone !== obj); // true

2. 支持循环引用

const obj = { name: 'Tom' };
obj.self = obj;
const clone = structuredClone(obj);
console.log(clone.self === clone);  // true

3. 克隆 Map、Set、Date、RegExp

const complex = {
  mapnew Map([["key""value"]]),
  setnew Set([123]),
  datenew Date(),
  regex/abc/gi
};
const clone = structuredClone(complex);
console.log(clone);

兼容性

提到新的API,肯定得考虑兼容性问题:

图片

  • Chrome 98+
  • Firefox 94+
  • Safari 15+
  • Node.js 17+ (global.structuredClone)

如果需要兼容旧浏览器:

  • 可以降级使用 lodash.cloneDeep
  • 或使用 MessageChannel Hack

很多小伙伴一看到兼容性问题,可能心里就有些犹豫:

"新API虽然好,但旧浏览器怎么办?"

但技术的发展离不开新技术的应用和推广,只有更多人开始尝试并使用,才能让新API真正普及开来,最终成为主流。

建议:

如果你的项目运行在现代浏览器或 Node.js 环境,structuredClone 是目前最推荐的深拷贝方案。 Node.js 17+:可以直接使用 global.structuredClone

fit parse解析佳明.fit 运动数据,模仿zwift数据展示

fitparse是一个用于解析 Garmin .FIT 文件的Python库。

本文中主要用到fitparse库来解析、提取数据;maplotlib库用来进行可视化绘制。

定义get_fit_list函数,其功能为:读取fit文件中的全部数据,并返回一个list。

使用fitparse.FitFile()函数读取fit文件,返回一个FitFile对象,使用list方法将其转换成list,其中每个元素为一个DataMessage对象。

def get_fit_list(file_name):
    m_fit_file = fitparse.FitFile(file_name)
    return list(m_fit_file.get_messages())

获取数据list后,使用DataMessage.get_value()方法获取指定的字段。例如heartrate、 power、 cadence等。

    m_fit_file = get_fit_list(file_name)
    for i in m_fit_file:
        m_heart_rate.append(i.get_value('heart_rate'))
        m_power.append(i.get_value('power'))

matplotlib参数进行初始设置:

    matplotlib.rc('figure', figsize=(40, 10))  # 图片大小,单位厘米
    matplotlib.rc('font', size=20)  # 字体大小
    matplotlib.rc('axes', grid=False)  # 是否显示网格
    matplotlib.rc('axes', facecolor='white')  # 背景颜色
    plt.title('Entire Workout')
    plt.xlabel('Time(s)')  # 横坐标

划分功率区间zone1到zone7,将功率数据分类,具体分类见下表:

区间 含义 功率
zone1 主动恢复区 FTP的55%以下
zone2 有氧耐力区 FTP的55%~75%
zone3 节奏强度或高阶有氧区 FTP的75%~90%
zone4 乳酸强度区 FTP的90%~105%
zone5 最大摄氧量强度区 FTP的105%~120%
zone6 无氧能力强度区 FTP的120%~150%
zone7 神经肌肉能力区 FTP的150%以上

详细内容参考开元老师的文章:zhuanlan.zhihu.com/p/356084873

data_len = len(m_power)
    z1 = [0] * data_len
    z2 = [0] * data_len
    z3 = [0] * data_len
    z4 = [0] * data_len
    z5 = [0] * data_len
    z6 = [0] * data_len
    z7 = [0] * data_len
    for i in range(len(m_power)):
        df = m_power[i]
        if df != None:
            if df >= 0 and df <= 0.5 * ftp:
                z1[i] = df
            if df > 0.5 * ftp and df <= 0.75 * ftp:
                z2[i] = df
            if df > 0.75 * ftp and df <= 0.9 * ftp:
                z3[i] = df
            if df > 0.9 * ftp and df <= 1.05 * ftp:
                z4[i] = df
            if df > 1.05 * ftp and df <= 1.2 * ftp:
                z5[i] = df
            if df > 1.2 * ftp and df <= 1.5 * ftp:
                z6[i] = df
            if df > 1.5 * ftp:
                z7[i] = df

 绘制心率曲线图,并标记max heart rate:

    plt.plot(m_heart_rate, label='HeartRate', linewidth=1, color='red')
    max_HR = max(m_heart_rate)
    max_HR_Index = m_heart_rate.index(max_HR)
    max_hr = 'Max HR:' + str(max_HR)
    plt.annotate(max_hr, (max_HR_Index, max_HR), color='red')

 根据功率区间按照不同颜色绘图:

    _x = np.arange(len(m_power))
    plt.bar(_x, z1, color='grey')
    plt.bar(_x, z2, color='royalblue')
    plt.bar(_x, z3, color='lime')
    plt.bar(_x, z4, color='orange')
    plt.bar(_x, z5, color='red')
    plt.bar(_x, z6, color='firebrick')
    plt.bar(_x, z7, color='darkred')
    plt.savefig("fit_analyze.jpg")

绘图结果:

image.png

react受控模式和非受控模式(日历的实现)

前言

在现代Web开发中,React凭借其组件化开发和声明式编程的特性,已成为构建交互式用户界面的首选框架。本文将带您系统了解React项目开发的完整流程,从项目初始化到复杂组件实现,涵盖以下核心内容:

  1. 项目搭建与环境配置
    通过Vite快速创建React项目,解析dependenciesdevDependencies的本质区别,掌握生产环境与开发环境的依赖管理策略。

  2. 表单组件开发范式
    深度解析受控组件与非受控组件的实现差异,通过代码示例演示状态驱动(useState+onChange)与默认值控制(defaultValue)两种开发模式的应用场景。

  3. 复杂组件实战:日历控件
    从零实现一个完整的月视图日历组件,涵盖以下技术要点:

    • 日期计算逻辑(月份切换、日期填充)
    • 组件状态管理与外部通信(defaultValue初始化 + onChange回调)
    • CSS Flex布局实现网格日历视图
    • 交互优化(选中态反馈、悬停效果)

日期api

1. 创建日期对象

方法 说明
new Date() 创建当前日期和时间
new Date(timestamp) 通过时间戳创建(毫秒数,1970-01-01起)
new Date("2024-07-07") 通过日期字符串创建(ISO格式推荐)
new Date(2024, 6, 7, 12, 30) 通过年、月、日、时、分创建(月从0开始

2. 获取日期组件

方法 返回值范围 说明
.getFullYear() 4位数年份 如 2024
.getMonth() 0-11 0=1月, 11=12月
.getDate() 1-31 月中的第几天
.getDay() 0-6 0=周日, 1=周一
.getHours() 0-23 小时
.getMinutes() 0-59 分钟
.getSeconds() 0-59
.getMilliseconds() 0-999 毫秒
.getTime() 时间戳 1970年至今的毫秒数

UTC 版本
如 .getUTCHours().getUTCMonth() 等,用法与本地时间相同。


3. 设置日期组件

方法 说明
.setFullYear(year) 设置年份
.setMonth(month) 设置月份(0-11)
.setDate(day) 设置月中日期
.setHours(hours) 设置小时(可链式设置)
.setTime(timestamp) 通过时间戳设置

注意:设置方法会直接修改原 Date 对象。


4. 日期格式化

方法 示例输出 说明
.toString() "Sun Jul 07 2024 12:30:00 GMT+0800" 完整日期字符串
.toDateString() "Sun Jul 07 2024" 仅日期部分
.toTimeString() "12:30:00 GMT+0800" 仅时间部分
.toISOString() "2024-07-07T04:30:00.000Z" ISO标准格式
.toLocaleString() "2024/7/7 12:30:00" 本地化格式
.toLocaleDateString() "2024/7/7" 本地化日期
.toLocaleTimeString() "12:30:00" 本地化时间

5. 其他实用方法

方法 说明
Date.now() 返回当前时间戳(毫秒)
Date.parse("2024-07-07") 解析字符串返回时间戳
.valueOf() 等价于 .getTime()

401未经授权

创建项目

npx create-react-app xxxx

使用vite来创建项目

使用前需要安装vite

使用npm install -g vite

第一种方式npm create vite@latest my-app -- --template react 第二种方式npm create-vite

image.png

什么是依赖?依赖的种类?

dependencies

  • 生产依赖:项目开发完成后,denpendencies中的第三方源代码也会被打包进来

devDependencies

  • 开发依赖:只在项目开发过程中有意义,devDependencies中的第三方源代码不会被打包进最终的项目中

在创建项目的终端执行npm i

自动识别我们需要的依赖并且帮我们安装

终端使用npm run dev运行项目

image.png

antd寻找我们需要的组件样式

什么是受控模式和非受控模式?

受控模式 vs 非受控模式

  • 表单中的 input
  1. 用户修改 input 值
  2. 代码修改 input 值
  • 能用代码设置表单的初始值,但是无法再次修改 value,能修改value的只有用户,这种就叫做 非受控模式

受控模式

  • 通常情况下,不建议使用受控模式 每次都会重新渲染组件,造成过多性能消耗

  • 当需要对输入的值进行特殊处理,处理完在设置到表单或则要实时处理,把状态同步到组件的时候用受控组件

import { useState } from "react";

function App() {
  const [value, setValue] = useState("hello");

  console.log('App');
  

  function onChange(e) {
    // setValue(e.target.value);
    setValue(e.target.value.toUpperCase());

    console.log(e.target.value);
  }

  return <input type="text" value={value} onChange={onChange} />;
}
//声明死了value,需要通过代码setvalue()更改值
export default App;
//受控模式
import { useEffect, useState } from "react";
async function queryData() {
  const data = await new Promise((resolve) => {
    setTimeout(()=>{
      resolve(666)
    },2000)
  });
  return data;
}
function App() {
const [num,setNum]=useState(0)
useEffect(()=>{
  queryData().then(res=>{
    setNum(res)
  })
},[])
  return(
    <div>{num}</div>
  )
}

export default App;
//受控模式

非受控模式

表单defaultValue默认属性值设为hello word

function App() {

function  onChange(e) {
  console.log(e.target.value);//事件对象 表单对象身上的值
  
}
  
  return <input type="text"  defaultValue={"hello world"}  onChange={onChange}/>
}
export default App;

组件实战开发

日历的实现

核心功能
  1. 日历组件:显示月视图日历,支持日期选择

  2. 月份导航:通过左右箭头按钮切换上/下个月

  3. 日期选择:点击日期可选中并触发回调

  4. 状态管理

    • 使用 useState 管理当前显示的日期
    • 通过 props 接收默认值 (defaultValue)
    • 日期变化时通过 onChange 回调通知父组件
import { useState } from "react";
import "./index.css";
function Calendar(props) {
const {defaultValue,onChange}=props
// 对象的解构
const [date, setDate]=useState(defaultValue);
const handlePrevMonth=()=>{ 
  setDate(new Date(date.getFullYear(),date.getMonth()-1,1))
}
const handleNextMonth=()=>{ 
  setDate(new Date(date.getFullYear(),date.getMonth()+1,1))
}

const daysOfMonth=(year,month)=>{
return new Date(year,month+ 1,0).getDate();
}

const firstDayOfMonth=(year,month)=>{
return new Date(year,month,1).getDay();
}
// getDate()方法参数6,0 日期是从1号开始的,如果写0会自动变成一个月的最后一天
// getDay()得到的是星期几

const renderDates=()=>{
const days=[]
const daysCount=daysOfMonth(date.getFullYear(),date.getMonth())
const firstDay=firstDayOfMonth(date.getFullYear(),date.getMonth())

for(let i=0;i<firstDay;i++){
  days.push(<div key={`empty--${i}`} className="empty"></div>)
}

for(let i=1;i<=daysCount;i++){ 
const clickHandler=()=>{ 
  const curDate=new Date(date.getFullYear(),date.getMonth(),i)
  setDate(curDate)
  // 对每个日期添加点击函数
  onChange(curDate)
}
  if(i===date.getDate()){ 
days.push(<div key={i} className="day selected" onClick={()=>{clickHandler()}}>{i}</div>)
  }

  else{days.push(<div key={i} className="day" onClick={()=>{clickHandler()}}>{i}</div>)}

}

return days

}

return (  
  <div className="calendar">
    <div className="header">
      <button  onClick={handlePrevMonth}>&lt;</button>
      <div>{date.getFullYear()}年{date.getMonth()+1}月</div>
        <button onClick={handleNextMonth}>&gt;</button>
    </div>
    <div className="days">
      <div className="day"></div>
        <div className="day"></div>
        <div className="day"></div>
        <div className="day"></div>
        <div className="day"></div>
        <div className="day"></div>
        <div className="day"></div>
        {renderDates()}
    </div>
  </div>
)
}
export default Calendar;



  1. 月份处理

    • JavaScript 的 Date 对象中月份从 0 开始(0=1月)
    • 显示时需 month + 1,操作时需注意原始值
  2. 日期选择逻辑

    • 点击日期时创建新的日期对象:new Date(year, month, day)
    • 同时更新组件状态和触发父组件回调
.calendar {

  width: 300px;

  height: 250px;

  border: 1px solid black;

  padding: 10px;

}

  


.header {

  display: flex;

  justify-content: space-between;

  align-items: center;

  height: 40px;

}

  


.days {

  display: flex;

  flex-wrap: wrap;

}

  


.empty,.day {

  width: calc(100% / 7);

  height: 30px;

  text-align: center;

}

  


.day:hover,.selected{

  background-color: #ccc;

  cursor: pointer;

}
import Calendar from "./calendar";
//通过defaultValue来传入初始值date
// 修改date之后可以在onChange里拿到最新的值  ---非受控
function App(){
 return(
<Calendar defaultValue={new Date(2025,6,8)}  onChange={(newDate) => { alert(newDate.toLocaleDateString())
//父传子
}} />

)
}
export default App

%E5%B1%8F%E5%B9%95%E5%BD%95%E5%88%B6%202025-07-08%20003706_converted.gif 快来试试写一个日历吧

🪄 用 React 玩转「图片识词 + 语音 TTS」:月影大佬的 AI 英语私教是怎么炼成的?


前言:
你好,我是卷王队形中那只还在啃 React 的小白。这次掘金写这篇文章,带你完整拆解一个 AI 小项目:
上传一张图,自动帮你找出一个最简单的英文单词,顺带生成解释、例句,最后用 TTS 读给你听!
这不比隔壁花大几千请外教香?


一、项目简介:这玩意儿是干嘛的?

这个小项目核心功能一句话就能讲明白:

“分析用户上传的图片 ➜ 找出最能代表图片的一个英文单词(简单词汇,A1-A2 级别) ➜ 自动生成例句 + 段落解释 + 语音朗读。”

简单粗暴,但背后集合了:

  • 图像识别(用 Moonshot Vision)
  • 自动文本生成(AIGC大语言模型 Chat 完成)
  • TTS(来自火山引擎的文字转语音)

而整个流程用 React + 简单的后端服务就能跑通,关键代码就散落在三个文件里:

  • App.jsx — 项目入口,状态管理大本营
  • PictureCard.jsx — 图片上传和语音播放的可爱小卡片
  • /lib/audio.js — TTS 核心逻辑(把文字变成人声 MP3)

二、项目结构:前端是怎么组织的?

大体目录结构长这样(主要文件):

路径 说明
my-picture-ai-app/ 项目根目录
src/ 源码文件夹
src/App.jsx 应用入口,管理状态和逻辑
src/components/ 组件文件夹
src/components/PictureCard.jsx 图片上传 + 音频播放组件
src/lib/ 公共库文件夹
src/lib/audio.js 文字转语音核心逻辑
src/App.css 全局样式
src/style.css 局部样式

是不是清爽?真·月影大佬手把手模板。


三、核心流程拆解(含关键代码)


1️⃣ App.jsx — 全局状态调度中心

这里干了几件大事:

1. 定义核心状态

const [word, setWord] = useState('请上传图片');
const [sentence, setSentence] = useState('');
const [explainations, setExplainations] = useState([]);
const [expReply, setExpReply] = useState('');
const [audio, setAudio] = useState('');
const [detailExpand, setDetailExpand] = useState(false);
const [imgPreview, setImgPreview] = useState('默认示例图URL');

意思很简单:

  • word:识别出来的英文单词
  • sentence:例句
  • explainations:段落解释,分句处理后是数组
  • expReply:针对解释的可能对话回复
  • audio:生成的音频 URL
  • detailExpand:是否展开详细信息
  • imgPreview:图片预览 URL

2. 核心函数 uploadImg

这段就是把图片丢给 Moonshot,拿回来 AI 分析结果的全过程。 这一段中imageData由子组件PictureCard中的uploadImgData自定义函数转成了chrome浏览器提供的base64格式的文件然后通过props再‘汇报’给父组件,此过程完成了一个单向数据流的操作

const uploadImg = async (imageData) => {
  setImgPreview(imageData);
  setWord('分析中...');

  const endpoint = 'https://api.moonshot.cn/v1/chat/completions';
  const headers = { 
    'Content-Type': 'application/json', 
    'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}` 
  };

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'moonshot-v1-8k-vision-preview',
      messages: [ 
        {
          role: 'user',
          content: [
            { type: "image_url", image_url: { "url": imageData } },
            { type: "text", text: userPrompt }
          ]
        }
      ],
      stream: false
    })
  });

  const data = await response.json();
  const replyData = JSON.parse(data.choices[0].message.content);

  setWord(replyData.representative_word);
  setSentence(replyData.example_sentence);
  setExplainations(replyData.explaination.split('\n'));
  setExpReply(replyData.explaination_replys);

  // TTS 生成
  const audioUrl = await generateAudio(replyData.example_sentence);
  setAudio(audioUrl);
};

这里有几个点可以偷学:

  • import.meta.env环境变量,管理密钥,安全一点,实际的在.env.local文件中
  • userPrompt 里直接用 JSON 模板让 LLM 生成结构化输出,避免抓瞎。
  • 图片直接转 Base64 当 URL,后端 Vision 可以吃。

PictureCard.jsx — 图片上传 + 播放语音

小卡片逻辑超级简单,只有两块:

  • 上传图片 ➜ 转 Base64
  • 点按钮播放音频

上传图片是经典 FileReader 用法:

const uploadImgData = (e) => {
  const file = e.target.files?.[0];//可选链运算符
  if (!file) return;

  const reader = new FileReader();//FileReader API 
  reader.readAsDataURL(file);//API
  reader.onload = () => {
    const data = reader.result;//读取结果
    setImgPreview(data);//图片预览,提升用户体验
    uploadImg(data);//传回给父组件
  };
};

点按钮放音频用 Audio

const playAudio = () => {
  const audioEle = new Audio(audio);
  audioEle.play();
};

可见:一整个复古 Vanilla 实现。


3️⃣ audio.js — 文字转语音核心

TTS 流程也挺朴实无华:

  • 拼接请求体 ➜ 调后端 ➜ 后端给个音频 Base64 ➜ 用 atob 转字节 ➜ BlobURL.createObjectURL ➜ 给 <audio>

里面有个知识点:

const getAudioUrl = (base64Data) => {
  const byteCharacters = atob(base64Data);
  const byteArrays = [];

  for (let offset = 0; offset < byteCharacters.length; offset++) {
    byteArrays.push(byteCharacters.charCodeAt(offset));
  }

  const blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
  return URL.createObjectURL(blob);
};
  • 记住 atob():ASCII to Binary,把data解码成二进制字符串(前端处理二进制老朋友)。
  • 接着charCodeAt():用for循环遍历,会把字符串转成ASCII值。
  • 再用Blob封装被Uint8Array()打包成的真实的二进制数组成浏览器可识别的文件。
  • 最后用return URL.createObjectURL(blob)返回一个临时地址,可以给 直接用。

四、亮点小结

这个项目说复杂不复杂,说简单也有点小巧思:

  • 用 Vision + LLM 做到图像语义理解(Moonshot 8K Vision)
  • 返回 JSON 保证结构化,不怕 LLM 胡说八道
  • TTS 音频用纯前端就能把 Base64 变可播放 URL

对初学者来说:
✅ React 的状态怎么拆?
✅ 事件流 + 状态传递怎么配?
✅ 调用异步接口、处理 Blob、玩文件流?
全在里面!


五、适合谁玩?

  • 学 React 的同学:练状态、练组件通信、练异步。
  • 想做 AI 应用 Demo 的同学:大语言模型 + Vision + TTS,三合一小样板。
  • 想发掘 Moonshot、Kimi 这类国内可用 LLM 的同学:怎么调怎么封装,一看就会。

六、最后一句

上传张图 ➜ 自动学个单词 ➜ 还能听一遍
要是小时候就有这玩意儿,我的四六级词汇量也不至于这么拉胯……

希望这套拆解能帮你看懂背后思路,自己也可以魔改试试:

  • 换个更复杂的 Prompt
  • 加个单词词根解释
  • 换个外语读音
  • 甚至直接做成单词卡片库!

卷就完了,咱在掘金见!


有需要源码或想看其他解读,评论区喊我,一起写起来~

免费开源!微信小程序商城源码,快速搭建你的线上商城系统!

由于近来有不少朋友提了一些关于微信小程序的需求,所以决定新增一些关于微信小程序的分享内容,结合实际的一些需求情况来看,决定优先分享关于商城小程序的内容。

今天介绍的是开发者lin-xin在几年前开源的一款微信小程序商城系统项目——wxapp-mall。虽然是几年前的项目了,但是个人看来不论是用来做二开,还是学习都是非常好的,所以在此分享给大家。

优点:

  • 具备商城常规的所有功能,首页、商品分类、详情、购物车、下单、个人中心、订单列表等等;
  • UI完整度较好;
  • 提供了uni-app版本,支持编译为app、小程序或者H5页面,覆盖全平台;
  • 开源易于定制开发;

项目地址:

github.com/lin-xin/wxa…

项目预览:

image.png

项目使用:

1、代码下载

使用以下命令将代码下载到本地:

git clone https://github.com/lin-xin/wxapp-mall.git

注:由于github少部分人不一定能顺利下载,也可以在文末直接下载项目源码。

2、导入项目

将下载到的项目代码,使用微信小程序开发者工具导入打开即可预览看到上述项目截图预览效果。

3、其他注意事项

  • API地址

由于项目确实有一定时间了,作者提供的API已经无法访问,需要我们自己根据自己业务更换地址使用,不影响页面交互和页面的逻辑计算。

  • uni分支

uni-app分支的代码下载路径为github.com/lin-xin/wxa…

代码下载

如果github无法下载代码,可以在以下链接中下载: mp.weixin.qq.com/s/3J_Gx7Uca…

写在最后

最近我会陆续增加分享一下微信小程序相关的用法和实用项目、技巧啥的,感兴趣的朋友欢迎关注,私信、点赞评论交流~

如果你也有更感兴趣的内容,也可以私信我,我也学习了解一下,充实充实个人不太聪明的脑子~

多状态映射不同样式(scss语法)

image.png

如设计图中,不同状态的卡片的标题对应着不同的背景色以及不同的标签样式,所以需要根据卡片的状态值来动态改变卡片的样式。

示例代码如下:

<view class="card" v-for="(item, index) in list" :key="index">
    <view :class="['card-title', classMap[item.fine_type]]">
        <view>{{item.created_date}}</view>
        <view class="tag">{{item.type_text}}</view>
    </view>
    ...
</view>
data() {
    return {
        classMap: {
            0: 'fine-status0',
            1: 'fine-status1',
            2: 'fine-status2',
            3: 'fine-status3',
            4: 'fine-status4',
        },
        ...
    }
}
<style lang="scss">
    $fine-statuses: (
        0: (
            title-bgc: linear-gradient(90deg, #FFEBDC 0%, rgba(255, 241, 232, 0) 100%),
            tag-bgc: #FFF0E5,
            tag-color: #FF9B54
        ),
        1: (
            title-bgc: linear-gradient(90deg, #FFF7E9 0%, rgba(255, 247, 233, 0) 100%),
            tag-bgc: #FFF3E7,
            tag-color: #FF8000
        ),
        2: (
            title-bgc: linear-gradient(90deg, #FFF3F1 0%, rgba(255, 243, 241, 0) 100%),
            tag-bgc: #FFF1EF,
            tag-color: #FF5B3F
        ),
        3: (
            title-bgc: linear-gradient(90deg, #FFF9E7 0%, rgba(255, 249, 231, 0) 100%),
            tag-bgc: #FFF7DD,
            tag-color: #D9B31D
        ),
        4: (
            title-bgc: linear-gradient(90deg, #FFF1F4 0%, rgba(255, 241, 244, 0) 100%),
            tag-bgc: #FFE8ED,
            tag-color: #F94A6F
        )
    );

    // 动态生成 fine-status 样式
    @each $key, $value in $fine-statuses {
        .fine-status#{$key} {
            background: map-get($value, title-bgc);

            .tag {
                background: map-get($value, tag-bgc);
                color: map-get($value, tag-color);
            }
        }
    }
</style>

作为一个新手,如果让你去用【微信小程序通过BLE实现与设备通讯】,你会怎么做,

背景

作为一个程序员,基本上公司的需求就是你的技能。最近公司让我做一个与设备进行蓝牙通信的微信小程序。起初我一脸懵,但经过摸索,还是成功打通了 BLE 通信的完整流程。

适合第一次接触 BLE 的小程序开发者,本文将完整讲解连接、读写、监听的基础用法。

一、目标拆解

我们的目标是:
🔧 通过微信小程序连接蓝牙设备,并实现读写数据通信。

实现步骤大致如下:

  1. 初始化蓝牙模块
  2. 监听蓝牙模块状态
  3. 获取本机蓝牙状态
  4. 开启扫描设备
  5. 监听发现设备
  6. 停止扫描
  7. 连接设备
  8. 获取服务列表
  9. 获取特征值并监听通知
  10. 读写数据
  11. 断开连接&关闭蓝牙

二、准备工作

环境要求

  • 微信开发者工具(建议新版本)
  • 安卓或支持 BLE 的 iOS 手机(真机调试必须)
  • 一台支持蓝牙 BLE 的设备(或模拟器)

权限配置(app.json

"permission": {
  "scope.userLocation": {
    "desc": "用于蓝牙设备搜索"
  }
},
"requiredBackgroundModes": ["bluetooth"]

很重要,如果你没有配置,你会发现上线后,你会搜索不到设备或者直接使用不了蓝牙。

蓝牙权限

在真机中需要开启蓝牙和定位,安卓手机必须开启定位权限才能搜索到设备。

1、初始化蓝牙模块

目标:激活微信小程序蓝牙模块。

注意:安卓首次需授权蓝牙权限,IOS如果为开启蓝牙,将直接报错。

wx.openBluetoothAdapter({
  success() {
    console.log("蓝牙初始化成功");
    // 可继续 startBluetoothDevicesDiscovery
  },
  fail(err) {
    console.error("蓝牙初始化失败", err);
  }
});
2、监听蓝牙模块状态

目的:检查蓝牙是否被用户关闭,或临时断开

wx.onBluetoothAdapterStateChange((res) => {
  console.log("蓝牙状态变化:", res);
  if (!res.available) {
    wx.showToast({ title: '蓝牙不可用', icon: 'none' });
  }
});
3、获取本机蓝牙状态

目的:初始化时判断蓝牙是否可用。避免用户未开启蓝牙就触发后续流程。

wx.getBluetoothAdapterState({
  success(res) {
    if (!res.available) {
      wx.showToast({ title: '请开启蓝牙', icon: 'none' });
    }
  }
});
4、开始扫描设备

目的:搜索附近蓝牙设备,通常需设置allowDuplicatesKey: false,避免重复。

wx.startBluetoothDevicesDiscovery({
allowDuplicatesKey: falsesuccess() {
    console.log("开始搜索设备");
  }
});
5、监听发现设备

目的:拿到设备列表,根据设备名或广播字段筛选出目标设备。

wx.onBluetoothDeviceFound(function (res) {
  const devices = res.devices;
  devices.forEach(device => {
    console.log('发现设备:', device);
    if (device.name.includes('MyDevice')) {
      // 记录下来供点击连接
    }
  });
});
6、停止扫描

目的:找到设备后停止扫描,可以节省资源,防止冲突。

wx.stopBluetoothDevicesDiscovery();

7、连接蓝牙设备

目的:建立BLE连接,注意处理连接失败重试。

wx.createBLEConnection({
  deviceId: deviceId,
  success() {
    console.log("连接成功");
  },
  fail(err) {
    console.log("连接失败", err);
  }
});
8、获取服务列表

目的:获取设备的服务(service),筛选出主服务(一般不是isPrimary:false的跳过)

wx.getBLEDeviceServices({
  deviceId,
  success(res) {
    const primaryService = res.services.find(s => s.isPrimary);
    console.log('主服务', primaryService);
  }
});

9、获取特征值并监听通知

目的:获取通知特征值,用于接收设备传回的数据。

wx.getBLEDeviceCharacteristics({
  deviceId,
  serviceId,
  success(res) {
    const notifyChar = res.characteristics.find(item => item.properties.notify);
    wx.notifyBLECharacteristicValueChange({
      deviceId,
      serviceId,
      characteristicId: notifyChar.uuid,
      state: true
    });
  }
});

10、读写数据

目的:收发数据,完成通信。

// 写入数据
wx.writeBLECharacteristicValue({
  deviceId,
  serviceId,
  characteristicId,
  value: new Uint8Array([0x01, 0xA0]).buffer
});

// 监听数据返回
wx.onBLECharacteristicValueChange(function (res) {
  const buffer = res.value;
  const data = new Uint8Array(buffer);
  console.log('收到数据:', data);
});

11、断开连接&关闭蓝牙
wx.closeBLEConnection({
  deviceId,
  success() {
    console.log("🔌 连接断开");
  }
});

wx.closeBluetoothAdapter();

三、踩坑小结(新手必看)

问题 解决方式
安卓搜索不到设备 一定要打开位置权限
ios设备搜索不到服务 ios设备服务较少,需特定服务
发现设备列表 设备一定要去重,不然会出现几百条数据
设备MTU IOS一般都是512,但安卓一般的都是23,并且不支持修改,所以你发送的数据包字节数大于20的时候,是需要分包发送的,ios则是509
数据格式 每个设备的接受的数据格式是不一样的,这个你需要和嵌入式那边去定好协议
监听不到设备数据 必须先 notifyBLECharacteristicValueChange 成功后设备才上报数据
写入数据错误 数据类型必须是ArrayBuffer

四、总结

BLE开发并不复杂,难的是:不知从何下手,以及细节调试一堆。 这篇文章希望给初学者一个完整的思路,如果你也在学习BLE项目,欢迎留言交流。

下一篇:实现蓝牙列表点击连接,扫码连接,以及自动回连

五 代码实例(可直接用)

// 文件结构:
// ├── pages
// │   └── ble-demo
// │       └── index.js / index.json / index.wxml / index.wxss
// └── utils
//     └── ble.js

// ========== utils/ble.js ==========
const ble = {
    deviceId: null,
    serviceId: null,
    writeCharId: null,
    notifyCharId: null,
    /**
     * 打开蓝牙适配器
     */
    openAdapter() {
        return new Promise((resolve, reject) => {
            wx.openBluetoothAdapter({
                success: resolve,
                fail: reject
            });
        });
    },
    /**
     * 开始搜索蓝牙设备
     */
    startDiscovery() {
        return new Promise((resolve, reject) => {
            wx.startBluetoothDevicesDiscovery({
                allowDuplicatesKey: false,
                success: resolve,
                fail: reject
            });
        });
    },
    /**
     * 监听设备发现事件,并通过回调返回设备信息
     * @param {*} callback 
     */
    onDeviceFound(callback) {
        wx.onBluetoothDeviceFound(callback);
    },
    /**
     * 停止搜索设备
     */
    stopDiscovery() {
        return wx.stopBluetoothDevicesDiscovery();
    },
    /**
     * 创建蓝牙连接
     * @param {*} deviceId 
     */
    createConnection(deviceId) {
        this.deviceId = deviceId;
        return new Promise((resolve, reject) => {
            wx.createBLEConnection({
                deviceId,
                success: resolve,
                fail: reject
            });
        });
    },
    /**
     * 获取设备主服务
     */
    getPrimaryService() {
        return new Promise((resolve, reject) => {
            wx.getBLEDeviceServices({
                deviceId: this.deviceId,
                success(res) {
                    const primary = res.services.find(s => s.isPrimary);
                    ble.serviceId = primary.uuid;
                    resolve(primary);
                },
                fail: reject
            });
        });
    },
    /**
     * 获取设备特征值
     */
    getCharacteristics() {
        const that=this;
        return new Promise((resolve, reject) => {
            wx.getBLEDeviceCharacteristics({
                deviceId: this.deviceId,
                serviceId: this.serviceId,
                success(res) {
                    for (let char of res.characteristics) {
                        if (char.properties.write) ble.writeCharId = char.uuid;
                        if (char.properties.notify) ble.notifyCharId = char.uuid;
                        if (char.properties.notify || char.properties.indicate) {
                            that.enableNotify(char.uuid); // 启用特征值变化监听
                        }
                    }
                    resolve(res.characteristics);
                },
                fail: reject
            });
        });
    },
    /**
     * 开启特征值通知(监听蓝牙数据)
     * @param {*} characteristicId 
     */
    enableNotify(callback) {
        wx.notifyBLECharacteristicValueChange({
            deviceId: this.deviceId,
            serviceId: this.serviceId,
            characteristicId: this.notifyCharId,
            state: true,
            success: () => {
            },
            fail:()=>{

            }
        });
        wx.onBLECharacteristicValueChange(callback)
        
        // wx.onBLECharacteristicValueChange((characteristic) => {
        //     console.log(characteristic)
        //     const value = characteristic.value;
        //     const data = new Uint8Array(value);
        //     console.log(this.ab2hex(data))
        // })
    },
    // ArrayBuffer转16进度字符串示例
    ab2hex(buffer) {
        var hexArr = Array.prototype.map.call(
            new Uint8Array(buffer),
            function (bit) {
                return ('00' + bit.toString(16)).slice(-2)
            }
        )
        return hexArr.join('');
    },
    /**
     * 向设备写入数据(valueArray 是一个字节数组)
     * @param {*} valueArray 
     */
    write(valueArray) {
        let buffer = new Uint8Array(valueArray).buffer;
        return new Promise((resolve, reject) => {
            wx.writeBLECharacteristicValue({
                deviceId: this.deviceId,
                serviceId: this.serviceId,
                characteristicId: this.writeCharId,
                value: buffer,
                success: resolve,
                fail: reject
            });
        });
    },
    /**
     * 获取设备MTU大小
     * @param {*} mtu 
     */
    requestMTU() {
        return new Promise((resolve, reject) => {
            wx.getBLEMTU({
                deviceId: this.deviceId,
                success: (res) => {
                    let mtu = res.mtu;
                    let maxWriteSize = Math.min(mtu - 3, 509); // **最大单次可写入的数据**
                    console.log(`✅ 设备 MTU: ${mtu}, 单次最大可写: ${maxWriteSize}`);
                    resolve({
                        mtu,
                        maxWriteSize
                    })
                },
                fail: (err) => {
                    reject(err)
                }
            });
        });
    },
    /**
     * 断开蓝牙连接
     */
    closeConnection() {
        return new Promise((resolve, reject) => {
            wx.closeBLEConnection({
                deviceId: this.deviceId,
                success: resolve,
                fail: reject
            });
        });
    },
    /**
     * 工具方法:将十六进制字符串转换为字节数组
     * @param {*} hexStr 
     */
    hexStringToBytes(hexStr) {
        if (!hexStr || typeof hexStr !== 'string') return [];
        return hexStr.match(/.{1,2}/g).map(byte => parseInt(byte, 16));
    },
};

module.exports = ble;
// ========== pages/ble-demo/index/index.js ==========
const ble = require('../../../utils/ble');
Page({

    /**
     * 页面的初始数据
     */
    data: {
        log: '',
        foundDevice: null
    },
    log(msg) {
        this.setData({
            log: this.data.log + `\n` + msg
        });
    },
    /**
     * 初始化 BLE 并扫描设备
     */
    async startBLE() {
        try {
            this.log('开始初始化蓝牙');
            await ble.openAdapter();
            this.log('蓝牙模块已打开');
            await ble.startDiscovery();
            this.log('正在扫描设备...');
            this.discoveredIds = new Set();
            ble.onDeviceFound(res => {
                 /**
                 * 增加筛选条件,不然会出现很多设备
                 * 可以是名字,也可以是UUID
                 * 还可以是值搜索有广播的数据
                 * 添加去重
                 */
                const devices = res.devices || [res.device];
                devices.forEach(device => {
                    if (!device.name || !device.name.includes('TPS-22C1')) return;
                    if (this.discoveredIds.has(device.deviceId)) return; // 跳过重复设备

                    this.discoveredIds.add(device.deviceId); // 添加到已发现集合
                    this.setData({ foundDevice: device });
                    ble.stopDiscovery();
                    this.log(`发现设备: ${device.name} 设备id:(${device.deviceId})`);
                });
               
            });

        } catch (e) {
            this.log('初始化失败: ' + e.errMsg);
        }
    },
    /**
     * 与设备建立连接
     */
    async connectBLE() {
        try {
            const {
                foundDevice
            } = this.data;
            if (!foundDevice) return;

            this.log('正在连接设备...');
            await ble.createConnection(foundDevice.deviceId);
            await ble.getPrimaryService();
            await ble.getCharacteristics();

            // 协商 MTU 提高数据传输效率
            const mtuInfo = await ble.requestMTU();
            this.log(`MTU: ${mtuInfo.mtu}, 单次最大可写: ${mtuInfo.maxWriteSize}`);
            ble.enableNotify(res => {
              
                console.log(res)
                const value = res.value;
                const data = new Uint8Array(value);
                this.log('监听设备返回数据: ' + ble.ab2hex(data));
                console.log(ble.ab2hex(data))
            });
            this.log('设备连接成功');

        } catch (e) {
            this.log('连接失败: ' + e.errMsg);
        }
    },
    /**
     * 发送测试数据(0x01, 0xA0)
     */
    sendTestData() {
        const hex = '5AA5060000';
        const data = ble.hexStringToBytes(hex);
        ble.write(data).then(() => {
            this.log('指令已发送: 5AA5060000');
        }).catch(err => {
            this.log('指令发送失败: ' + err.errMsg);
        });
    },
    /**
     * 断开设备连接
     */
    disconnectBLE() {
        ble.closeConnection().then(() => {
            this.log('已断开连接');
        }).catch(err => {
            this.log('断开失败: ' + err.errMsg);
        });
    },
    
})

// ========== pages/ble-demo/index/index.wxml ==========
<view class="container">
    <button bindtap="startBLE">初始化并扫描</button>
    <button bindtap="connectBLE">连接设备</button>
    <button bindtap="sendTestData">发送测试数据</button>
    <button bindtap="disconnectBLE">断开连接</button>
    <view class="log-area">{{log}}</view>
</view>

// ========== pages/ble-demo/index/index.wxss ==========
.container {
    padding: 20rpx;
    width: 96%;
}

.log-area {
    margin-top: 30rpx;
    white-space: pre-wrap;
    font-size: 28rpx;
    background: #333333;
    padding: 20rpx;
    border-radius: 12rpx;
    max-height: 600rpx;
    overflow-y: scroll;
    color: #ffffff;
}

Next.js 教程系列(十一)数据缓存策略与 Next.js 运行时

前言

大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!


第十一章 数据缓存策略与 Next.js 运行时

一、理论讲解

1. Next.js 缓存体系全景

Next.js 13+ 引入了更细粒度的缓存体系,主要包括:

  • Data Cache:针对 fetch 数据请求的缓存,支持自动失效、手动刷新。
  • Route Cache:针对页面路由的缓存,提升 SSR/ISR 性能。
  • Full Route Cache:整页缓存,适合静态内容和高并发场景。
  • Server Components 缓存:RSC 级别的缓存,结合 Memos 提升渲染效率。
  • CDN 协同:结合 CDN 边缘缓存,实现全球加速和高可用。
Data Cache(数据缓存)怎么用?

Data Cache 主要用于缓存 fetch 请求的数据,减少重复请求,提升性能。

const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 }, // 60秒自动失效
  cache: 'force-cache',     // 强制缓存
  tags: ['dashboard']       // 便于后续按 tag 刷新
});
const data = await res.json();
  • revalidate:设置缓存失效时间(秒)。
  • cache: 'force-cache':强制缓存(默认行为)。
  • tags:为缓存打标签,便于后续批量刷新。
Route Cache(路由缓存)怎么用?

Route Cache 用于缓存整个页面的渲染结果,提升 SSR/ISR 性能。

// 页面级别缓存
export const revalidate = 300; // 页面缓存5分钟自动失效
Full Route Cache(整页缓存)怎么用?

Full Route Cache 适合静态内容和高并发场景。只要页面是 SSG/ISR(即用 getStaticProps + revalidate),Next.js 会自动为其生成 Full Route Cache。

  • 配合 CDN,静态页面会被缓存到边缘节点,极大提升访问速度。
Server Components 缓存与 Memos 怎么用?

Server Components 支持在服务端缓存组件渲染结果,减少重复渲染。

// app/components/HeavyChart.server.tsx
import { cache } from 'react';
const getChartData = cache(async (id) => {
  // 只会请求一次,后续命中缓存
  const res = await fetch(`/api/chart/${id}`);
  return res.json();
});
export default async function HeavyChart({ id }) {
  const data = await getChartData(id);
  return <Chart data={data} />;
}
  • cache 是 React 18+ 的新特性,配合 Server Components 使用,自动缓存异步函数结果。
缓存失效机制
  • 定时失效:通过 revalidate 参数设置自动刷新周期。
  • 手动失效:通过 revalidatePath、revalidateTag、API 路由等手动刷新。
  • 依赖失效:数据变更时自动失效相关缓存。
revalidatePath / revalidateTag 怎么用?

用于手动失效某个路径或一组带标签的缓存,常用于内容变更后主动刷新页面。

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(req) {
  const { path, tag, secret } = await req.json();
  if (secret !== process.env.REVALIDATE_SECRET) return new Response('无权限', { status: 401 });
  if (path) revalidatePath(path); // 刷新指定路径
  if (tag) revalidateTag(tag);    // 刷新所有带该 tag 的缓存
  return Response.json({ revalidated: true });
}

前端调用示例:

await fetch('/api/revalidate', {
  method: 'POST',
  body: JSON.stringify({ path: '/dashboard', secret: 'xxx' })
});
缓存一致性与安全
  • 强一致性:关键业务数据需保证缓存与源数据同步。
  • 弱一致性:允许短暂延迟,提升性能。
  • 缓存安全:防止缓存穿透、缓存污染、权限数据泄露。
  • API 路由涉及缓存刷新时,务必校验权限(如 secret)。
  • 用户/角色相关数据不要全局缓存,需按用户粒度缓存或禁用缓存。
if (secret !== process.env.REVALIDATE_SECRET) return new Response('无权限', { status: 401 });
企业级缓存架构
  • 多级缓存(内存、磁盘、CDN、数据库)协同
  • 监控与报警,自动降级
  • 缓存预热与冷启动优化
常见误区
  • 只依赖默认缓存,忽略失效策略
  • 缓存粒度过粗或过细,导致性能或一致性问题
  • 忽略缓存安全,导致数据泄露

二、代码示例

1. fetch 缓存参数与 Data Cache

(见上文 Data Cache 怎么用)

2. 路由与全局缓存控制

(见上文 Route Cache 怎么用)

3. 手动刷新缓存(revalidatePath/revalidateTag)

(见上文 revalidatePath/revalidateTag 怎么用)

4. API 路由缓存与 SSR/ISR 缓存控制

// pages/api/data.ts
export default async function handler(req, res) {
  res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate');
  const data = await fetchData();
  res.json({ data });
}

5. 缓存失效与刷新

// 业务数据变更后,主动刷新相关页面/tag
await fetch('/api/revalidate', { method: 'POST', body: JSON.stringify({ path: '/dashboard', secret: 'xxx' }) });

6. Server Components 缓存与 Memos

(见上文 Server Components 缓存与 Memos 怎么用)

7. 缓存监控与错误处理

// pages/_app.tsx
import { useEffect } from 'react';
useEffect(() => {
  window.addEventListener('error', (e) => {
    // 上报缓存相关错误
    reportError(e);
  });
}, []);

8. 移动端适配

@media (max-width: 600px) {
  .dashboard { padding: 8px; font-size: 16px; }
}

三、实战项目:仪表盘数据报表组件缓存策略设计

1. 项目需求

  • 仪表盘报表数据需高性能、低延迟展示
  • 支持多级缓存(Data Cache、Route Cache、CDN)
  • 自动失效与手动刷新结合
  • 支持缓存监控、错误兜底、极端场景降级
  • 移动端体验友好

2. 技术选型

  • Next.js 13+ App Router
  • fetch Data Cache + revalidateTag
  • API 路由缓存
  • CDN 边缘缓存
  • Sentry/自研埋点监控

3. 目录结构

/dashboard-demo
  |-- app/
      |-- dashboard/
          |-- page.tsx
      |-- api/
          |-- revalidate/
              |-- route.ts
  |-- components/
      |-- Report.tsx
      |-- Skeleton.tsx
  |-- styles/
      |-- globals.css

4. 报表组件实现

// app/dashboard/page.tsx
import Report from '../../components/Report';
import Skeleton from '../../components/Skeleton';
import { useState } from 'react';
export default async function Dashboard() {
  const res = await fetch('https://api.example.com/report', {
    next: { revalidate: 120, tags: ['report'] },
    cache: 'force-cache',
  });
  const data = await res.json();
  return (
    <div className="dashboard">
      <h1>数据报表</h1>
      {data ? <Report data={data} /> : <Skeleton />}
    </div>
  );
}
// components/Report.tsx
export default function Report({ data }) {
  return (
    <div className="report">
      <h2>核心指标</h2>
      <ul>
        {data.metrics.map((m) => (
          <li key={m.name}>{m.name}: {m.value}</li>
        ))}
      </ul>
    </div>
  );
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req) {
  const { tag, secret } = await req.json();
  if (secret !== process.env.REVALIDATE_SECRET) return new Response('无权限', { status: 401 });
  revalidateTag(tag);
  return Response.json({ revalidated: true });
}
// components/Skeleton.tsx
export default function Skeleton() {
  return <div className="skeleton">报表加载中...</div>;
}
.skeleton {
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: skeleton 1.2s infinite linear;
  height: 80px;
  border-radius: 8px;
  margin-bottom: 12px;
}
@media (max-width: 600px) {
  .dashboard { padding: 8px; font-size: 16px; }
  .skeleton { height: 60px; }
}

四、最佳实践

  1. 缓存粒度选择:按页面、数据、API 细分缓存,避免全局失效。
fetch(url, { next: { revalidate: 60, tags: ['dashboard'] } });
  1. 失效策略:结合定时与手动失效,关键数据用 revalidateTag。
  2. 缓存安全:API 路由校验权限,防止未授权刷新。
if (secret !== process.env.REVALIDATE_SECRET) return new Response('无权限', { status: 401 });
  1. 团队协作:文档化缓存策略,约定失效流程,自动化测试。
  2. 监控报警:集成 Sentry/自研埋点,缓存异常自动报警。
  3. 极端场景降级:接口超时、缓存失效时前端兜底提示。
if (error) return <div>数据加载失败,请稍后重试</div>;

五、常见问题与解决方案

  • Q: 缓存不一致/延迟?
    • A: 合理设置 revalidate,结合手动刷新,监控延迟。
  • Q: 缓存穿透?
    • A: 校验参数,防止无效请求击穿缓存。
  • Q: 缓存雪崩?
    • A: 缓存失效错峰、分批刷新,避免瞬时高并发。
  • Q: 缓存失效慢?
    • A: 缩短 revalidate 间隔,结合 on-demand 刷新。
  • Q: 数据延迟?
    • A: 关键数据用 SSR/ISR,弱一致性数据用缓存。
  • Q: 权限问题?
    • A: 缓存内容按用户/角色区分,API 校验。
  • Q: CDN 缓存未同步?
    • A: 检查 CDN 配置,支持 stale-while-revalidate。
  • Q: 监控如何做?
    • A: 埋点上报缓存命中率、失效、异常。

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

从递归到动态规划:手把手教你玩转算法三剑客

一、递归:用 “拆礼物” 思维解决问题

(一)递归的核心逻辑:自顶向下的拆解

递归就像拆礼物 —— 当你遇到一个大问题(比如求斐波那契数列第 n 项),先别慌!试着把它拆成两个更小的同类问题:f(n) = f(n-1) + f(n-2)。这时候你会发现,每个小问题又能继续拆,直到遇到 “拆不动” 的基本情况(比如n≤1时直接返回 n)。这种 “站在问题终点想解法” 的思路,就是递归的灵魂。

举个栗子🌰,经典斐波那契数列的递归实现:

function fib(n) {
    if (n <= 1) return n; // 退出条件:拆到最小礼物就停手
    return fib(n-1) + fib(n-2); // 拆成两个小礼物,结果拼起来
}

(二)递归的 “甜蜜烦恼”:重复计算与栈溢出

不过递归有个小毛病 —— 太 “实诚” 了!比如算fib(10)时,fib(8)会被算两次,fib(7)会被算三次(画个树状图秒懂:f(10)下面是f(9)f(8)f(9)又拆出f(8)f(7)……)。这种重复计算让时间复杂度飙升到O(2ⁿ),算fib(1000)?计算机怕是要 “罢工” 咯!

还有个隐藏风险 —— 调用栈溢出。每次调用函数都会压栈,递归深度太深(比如几万层),栈内存就爆了,程序直接崩溃,堪称 “递归刺客”。

二、闭包:给递归配个 “小本本” 做记忆

(一)闭包优化递归:记住算过的结果

别急,闭包来救场啦!闭包就像一个 “小本本”,能把递归算过的结果存起来,下次用到直接查,避免重复计算。具体怎么做?看下面的 “记忆化” 操作:

function memorizeFib() {
    const cache = {}; // 小本本:存已经算过的结果
    return function fib(n) {
        if (n <= 1) return n;
        if (cache[n]) return cache[n]; // 查小本本,有的话直接用
        cache[n] = fib(n-1) + fib(n-2); // 没的话算一遍,记下来
        return cache[n];
    };
}
const fib = memorizeFib(); // 闭包生成的fib函数自带记忆功能
console.log(fib(100)); // 秒出结果,再也不怕重复计算啦~

这里的关键是利用闭包的 “变量捕获”:外层函数的cache被内层fib记住,每次调用都能访问,形成一个私有缓存空间。这波操作把时间复杂度砍到O(n),效率飙升!

(二)闭包的适用场景:需要 “记住历史” 的递归

只要递归问题满足 “重复子问题”(比如斐波那契、爬楼梯),闭包就能派上用场。但注意哦,闭包会占用额外内存(存缓存),不过用空间换时间,在大数据量时血赚!

三、动态规划:从 “拆礼物” 到 “搭积木” 的逆袭

(一)动态规划的核心:自底向上搭积木

递归是 “从上往下拆”,动态规划则是 “从下往上搭”。以爬楼梯问题为例:假设你要爬 n 阶楼梯,每次能走 1 或 2 步,求有多少种方法。

递归思路(自顶向下):f(n) = f(n-1) + f(n-2),但重复计算严重(比如算f(4)时,f(2)会被算两次)。

动态规划思路(自底向上):先算小问题的解,存起来,再一步步算大问题。比如:

  • f(1) = 1(直接走 1 步)

  • f(2) = 2(1+1 或直接 2 步)

  • f(3) = f(2) + f(1) = 3(最后一步是走 1 步到 3,或走 2 步到 3)

  • …… 以此类推,用数组dp存每个f(i)的结果。

代码实现:

function climbStairs(n) {
    const dp = new Array(n + 1); // dp数组:dp[i]表示爬i阶的方法数
    dp[1] = 1; // 初始条件:1阶只有1种方法
    dp[2] = 2; // 2阶有2种方法
    for (let i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程:当前解=前两个子问题解的和
    }
    return dp[n];
}

(二)动态规划的三大要素:状态、转移方程、初始条件

  1. 状态定义:明确dp[i]表示什么(比如这里是爬 i 阶的方法数)。
  2. 转移方程:找到dp[i]和之前状态的关系(核心!比如dp[i] = dp[i-1] + dp[i-2])。
  3. 初始条件:最小子问题的解(比如i=1i=2的情况)。

(三)动态规划的优化:空间压缩

如果发现dp[i]只和前面几个状态有关(比如爬楼梯只和前两个状态有关),可以不用存整个数组,只用几个变量就能搞定,把空间复杂度从O(n)降到O(1)

function climbStairs(n) {
    if (n === 1) return 1;
    let a = 1, b = 2; // a=f(1), b=f(2)
    for (let i = 3; i <= n; i++) {
        const c = a + b; // c=f(i)
        a = b; // 左移一位,a变成f(i-1)
        b = c; // b变成f(i)
    }
    return b;
}

这波操作就像 “滚动数组”,省内存又高效,面试官看了直点头~

四、三者关系:从递归到动态规划的进化之路

特性 递归 闭包优化递归 动态规划
思路 自顶向下拆解 自顶向下 + 记忆化 自底向上递推
重复计算 有(指数级耗时) 无(缓存复用) 无(按顺序计算)
空间问题 可能栈溢出 缓存占用内存 可优化到常数空间
适用场景 小规模问题、树状结构 中等规模重复子问题 大规模最值 / 计数问题

简单说:

  • 递归是 “暴力拆礼物”,适合理解问题但效率低;
  • 闭包是 “拆礼物 + 记笔记”,优化重复计算;
  • 动态规划是 “按顺序搭积木”,适合大规模问题,是递归的 “终极进化形态”。

五、面试官爱考啥?记住这三个 “灵魂拷问”

(一)递归的缺点是什么?怎么优化?

答:缺点是重复计算和栈溢出。优化方法有两种:

  1. 用闭包或数组做记忆化(适合自顶向下递归);
  2. 改用动态规划自底向上计算(适合大规模问题)。

(二)动态规划的状态转移方程怎么找?

答:关键是找 “最后一步” 的决策。比如爬楼梯,最后一步要么是走 1 阶(前面是 n-1 阶的解),要么是走 2 阶(前面是 n-2 阶的解),所以dp[n] = dp[n-1] + dp[n-2]

(三)闭包在优化递归时起到什么作用?

答:闭包提供了一个私有缓存空间(比如cache对象),让递归函数能记住之前算过的结果,避免重复计算。这其实是 “记忆化搜索” 的核心思想,在算法题中超级实用!

六、总结:算法三剑客,各有各的范儿

  • 递归是 “思路担当”,简单直接,适合小规模问题;

  • 闭包是 “优化小能手”,用记忆化让递归效率起飞;

  • 动态规划是 “效率王者”,自底向上解决大规模问题,是算法面试的常客。

下次遇到类似问题(比如斐波那契、爬楼梯、零钱兑换),记得按这个套路来:先用递归理清思路,再看有没有重复子问题,有的话用闭包优化,最后转成动态规划提升效率。搞定这一套,面试官都得夸你 “思路清晰,优化到位”!

深入理解 Vue 3 响应式系统原理:Proxy、Track 与 Trigger 的协奏曲

深入理解 Vue 3 响应式系统原理:Proxy、Track 与 Trigger 的协奏曲

Vue 3 的响应式系统从 Vue 2 的 Object.defineProperty 升级为基于 Proxy 的全新实现,带来了更强的性能和更高的灵活性。本文将深入剖析其底层原理,解构响应式的关键组成部分,并辅以代码和图示,带你看懂 Vue 3 的响应式魔法。


一、引言:从 Vue 2 到 Vue 3 的响应式变革

Vue 2 使用 Object.defineProperty 拦截对象属性读写,但存在如下限制:

  • 不能监听数组索引和 length 的变化;
  • 不能动态添加属性;
  • 深层嵌套对象递归成本高。

Vue 3 使用 Proxy 解决了这些痛点,实现了更强大、灵活的响应式系统。


二、Vue 3 响应式系统架构图

graph TD
  A[reactive包裹的对象] --> B[Proxy 对象]
  B --> C[getter -> track 收集依赖]
  B --> D[setter -> trigger 派发更新]
  C --> E[activeEffect 注册副作用]
  D --> F[effect 重新执行 -> 更新视图]

三、Proxy 是如何劫持对象的?Reflect 有何作用?

Vue 3 利用 Proxy 对原始对象进行拦截,监听属性访问和修改操作:

const handler = {
  get(target, key, receiver) {
    // 依赖收集
    track(target, key);
    const result = Reflect.get(target, key, receiver);
    return typeof result === 'object' ? reactive(result) : result;
  },
  set(target, key, value, receiver) {
    const oldValue = target[key];
    const result = Reflect.set(target, key, value, receiver);
    if (oldValue !== value) {
      // 触发更新
      trigger(target, key);
    }
    return result;
  }
};

function reactive(target) {
  return new Proxy(target, handler);
}

Proxy 拦截职责:

  • get:用于依赖收集(track)
  • set:用于触发依赖(trigger)

Reflect 的作用:

  • 提供更规范的原始操作执行方式
  • 返回布尔值表示操作是否成功
  • 避免直接访问原对象,支持继承关系和代理转发

四、track 与 trigger:响应式的灵魂

Vue 响应式系统核心在于:依赖收集(track)依赖触发(trigger)

1. 依赖收集 track

let activeEffect = null;

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  deps.add(activeEffect);
}

每次读取属性时,当前副作用函数(effect)会作为依赖被存储在 targetMap 中,数据结构如下:

// WeakMap<target, Map<key, Set<effects>>>
targetMap = {
  obj: {
    name: Set(effect1, effect2),
    age: Set(effect3)
  }
}

2. 依赖触发 trigger

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const deps = depsMap.get(key);
  if (deps) {
    deps.forEach(effect => effect());
  }
}

当属性变化时,所有依赖这个属性的 effect 函数将被重新执行。


五、effect 是什么?响应式如何与副作用函数关联?

effect 是用来注册副作用函数的机制。它将副作用函数与响应式数据绑定起来:

function effect(fn) {
  const run = () => {
    activeEffect = run;
    fn();
    activeEffect = null;
  };
  run();
}

例如:

const state = reactive({ count: 0 });

effect(() => {
  console.log(`count changed: ${state.count}`);
});

上述代码中,state.count 被读取,因此 console.log 所在的 effect 被收集。当 count 改变时,effect 会重新执行,自动更新输出。


六、如何避免重复收集?如何处理嵌套对象?

1. 避免重复收集

使用 Set 去重,保证同一个 effect 只被收集一次:

deps.add(activeEffect); // Set 会自动去重

2. 嵌套对象自动递归响应式

get 中递归调用 reactive()

const result = Reflect.get(target, key, receiver);
return typeof result === 'object' && result !== null
  ? reactive(result)
  : result;

这确保即使嵌套对象尚未访问,也能在访问时被转为响应式。

例如:

const state = reactive({
  user: {
    name: 'Heart',
    address: {
      city: 'guangzhou'
    }
  }
});

只有当你访问 state.user.address.city 时,对应层级才会被递归地变成 Proxy。


七、小结:Vue 响应式的设计与优势

特性 Vue 2 Vue 3
实现方式 Object.defineProperty Proxy
动态属性 不支持 支持
数组监听 局限较多 完善支持
嵌套对象处理 初始化递归 访问时递归,懒代理
性能 响应式递归开销大 更加高效、灵活

Vue 3 的响应式机制不仅解决了 Vue 2 的痛点,还借助现代 JavaScript 特性构建了一个轻量而强大的响应式系统,是前端响应式范式演进的重要一环。


💡 延伸阅读

❌