普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月11日技术

toRef 和 toRefs 详解及应用

作者 北辰alk
2026年1月11日 11:28

1. 引言

为什么需要 toReftoRefs

在 Vue 3 中,响应式系统是核心特性之一。refreactive 是创建响应式数据的两种主要方式。然而,在某些场景下,我们需要更灵活地处理响应式数据,例如:

  • 局部响应式:将对象的某个属性转换为响应式引用。
  • 解构响应式对象:在解构响应式对象时保持其响应性。

toReftoRefs 正是为了解决这些问题而设计的工具函数。

toReftoRefs 的应用场景

  • 表单处理:将表单字段转换为响应式引用。
  • 状态管理:在组件之间共享状态时保持响应性。
  • 组件通信:在父组件和子组件之间传递响应式数据。

2. Vue 3 响应式系统简介

响应式系统的核心概念

Vue 3 的响应式系统基于 Proxy 实现,具有以下核心概念:

  • 响应式对象:通过 reactive 创建的对象,其属性是响应式的。
  • 响应式引用:通过 ref 创建的值,其本身是响应式的。

refreactive 的基本用法

  • ref:用于创建响应式引用。
    import { ref } from 'vue';
    
    const count = ref(0);
    console.log(count.value); // 0
    
  • reactive:用于创建响应式对象。
    import { reactive } from 'vue';
    
    const state = reactive({ count: 0 });
    console.log(state.count); // 0
    

3. toRef 详解

toRef 的定义与作用

toRef 用于将对象的某个属性转换为响应式引用。它的定义如下:

function toRef<T extends object, K extends keyof T>(object: T, key: K): Ref<T[K]>;

toRef 的使用场景

  • 局部响应式:将对象的某个属性转换为响应式引用。
    import { reactive, toRef } from 'vue';
    
    const state = reactive({ count: 0 });
    const countRef = toRef(state, 'count');
    console.log(countRef.value); // 0
    
  • 表单处理:将表单字段转换为响应式引用。
    const form = reactive({ username: '', password: '' });
    const usernameRef = toRef(form, 'username');
    

toRef 的源码解析

toRef 的源码实现如下:

export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K
): Ref<T[K]> {
  return {
    get value() {
      return object[key];
    },
    set value(newValue) {
      object[key] = newValue;
    },
  };
}

4. toRefs 详解

toRefs 的定义与作用

toRefs 用于将响应式对象的所有属性转换为响应式引用。它的定义如下:

function toRefs<T extends object>(object: T): ToRefs<T>;

toRefs 的使用场景

  • 解构响应式对象:在解构响应式对象时保持其响应性。
    import { reactive, toRefs } from 'vue';
    
    const state = reactive({ count: 0, name: 'Vue' });
    const { count, name } = toRefs(state);
    console.log(count.value); // 0
    console.log(name.value); // Vue
    
  • 组件通信:在父组件和子组件之间传递响应式数据。
    const state = reactive({ count: 0 });
    const { count } = toRefs(state);
    

toRefs 的源码解析

toRefs 的源码实现如下:

export function toRefs<T extends object>(object: T): ToRefs<T> {
  const ret: any = {};
  for (const key in object) {
    ret[key] = toRef(object, key);
  }
  return ret;
}

5. toReftoRefs 的区别

功能对比

  • toRef:将对象的某个属性转换为响应式引用。
  • toRefs:将对象的所有属性转换为响应式引用。

使用场景对比

  • toRef:适用于局部响应式场景。
  • toRefs:适用于解构响应式对象场景。

6. 实战:toReftoRefs 的应用

项目初始化

使用 Vue CLI 或 Vite 创建一个新的 Vue 3 项目:

npm create vite@latest my-vue-app --template vue-ts
cd my-vue-app
npm install

使用 toRef 实现局部响应式

在组件中使用 toRef 将对象的某个属性转换为响应式引用:

import { reactive, toRef } from 'vue';

export default {
  setup() {
    const state = reactive({ count: 0 });
    const countRef = toRef(state, 'count');
    return { countRef };
  },
};

使用 toRefs 解构响应式对象

在组件中使用 toRefs 解构响应式对象:

import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({ count: 0, name: 'Vue' });
    const { count, name } = toRefs(state);
    return { count, name };
  },
};

结合 Composition API 使用 toReftoRefs

在 Composition API 中使用 toReftoRefs

import { reactive, toRef, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({ count: 0, name: 'Vue' });
    const countRef = toRef(state, 'count');
    const { name } = toRefs(state);
    return { countRef, name };
  },
};

7. 进阶:toReftoRefs 的常见应用场景

表单处理

将表单字段转换为响应式引用:

const form = reactive({ username: '', password: '' });
const { username, password } = toRefs(form);

状态管理

在组件之间共享状态时保持响应性:

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

组件通信

在父组件和子组件之间传递响应式数据:

// 父组件
const state = reactive({ count: 0 });
const { count } = toRefs(state);

// 子组件
export default {
  props: {
    count: {
      type: Number,
      required: true,
    },
  },
};

8. 常见问题与解决方案

toReftoRefs 的性能问题

  • 问题toReftoRefs 可能会影响性能。
  • 解决方案:避免在大型对象上频繁使用 toReftoRefs

toReftoRefs 的兼容性问题

  • 问题toReftoRefs 在某些环境下可能无法正常使用。
  • 解决方案:确保 Vue 3 版本兼容,并测试不同环境下的兼容性。

toReftoRefs 的使用误区

  • 问题:误用 toReftoRefs 可能导致响应性丢失。
  • 解决方案:理解 toReftoRefs 的作用,避免误用。

9. 总结与展望

toReftoRefs 的最佳实践

  • 明确使用场景:根据需求选择合适的工具函数。
  • 优化性能:避免在大型对象上频繁使用 toReftoRefs
  • 确保响应性:理解 toReftoRefs 的作用,确保响应性不丢失。

未来发展方向

  • 更强大的工具函数:支持更复杂的响应式场景。
  • 更好的性能优化:提供更高效的响应式处理方式。

通过本文的学习,你应该已经掌握了 toReftoRefs 的用法和应用场景。希望这些内容能帮助你在实际项目中更好地处理响应式数据!

什么是 Vue 3 中的 `defineEmits`?

作者 北辰alk
2026年1月11日 11:24

1. 引言

Vue 3 的 Composition API 简介

Vue 3 引入了 Composition API,旨在解决 Options API 在复杂组件中的局限性。Composition API 提供了一种更灵活的方式来组织和复用逻辑代码。

defineEmits 的作用与优势

defineEmits 是 Vue 3 中用于定义组件事件的方法,它允许开发者在 setup() 函数中定义和触发事件。defineEmits 的优势包括:

  • 类型安全:支持 TypeScript,提供更好的类型推断和代码提示。
  • 灵活性:允许动态定义事件,适应复杂的业务场景。
  • 代码简洁:通过 defineEmits 定义事件,减少冗余代码。

本文的目标与结构

本文旨在全面解析 Vue 3 中的 defineEmits,并通过详细的代码示例帮助读者掌握这些技巧。文章结构如下:

  1. 介绍 defineEmits 的基础知识和用法。
  2. 探讨 defineEmits 在组件通信中的应用。
  3. 提供性能优化建议和实战案例。

2. defineEmits 的基础

defineEmits 的定义与使用

defineEmits 是 Vue 3 中用于定义组件事件的方法,通常在 setup() 函数中使用。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

defineEmits 的参数与返回值

defineEmits 接收一个事件名称数组作为参数,返回一个 emit 函数。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

示例:简单的 defineEmits 使用

通过 defineEmits 定义一个 increment 事件,并在按钮点击时触发。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

3. defineEmits 与组件通信

使用 defineEmits 实现父子组件通信

defineEmits 用于定义子组件的事件,父组件通过监听这些事件实现通信。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @increment="handleIncrement" />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    handleIncrement() {
      this.count++;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

使用 defineEmits 实现跨组件通信

通过 provideinject 实现跨组件通信,结合 defineEmits 触发事件。

示例代码

<!-- 祖先组件 -->
<template>
  <div>
    <ChildComponent />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import { provide, ref } from 'vue';
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  setup() {
    const count = ref(0);

    const increment = () => {
      count.value++;
    };

    provide('increment', increment);

    return {
      count,
    };
  },
};
</script>

<!-- 后代组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { inject } from 'vue';

export default {
  setup() {
    const increment = inject('increment');

    return {
      increment,
    };
  },
};
</script>

示例:在 setup() 中使用 defineEmits

通过 defineEmits 定义事件,并在 setup() 中触发。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @increment="handleIncrement" />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    handleIncrement() {
      this.count++;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

4. defineEmits 与 TypeScript

defineEmits 中使用 TypeScript

TypeScript 提供了强大的类型支持,可以在 defineEmits 中使用。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script lang="ts">
import { defineEmits, defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const emit = defineEmits<{
      (e: 'increment'): void;
    }>();

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
});
</script>

类型推断与类型安全

TypeScript 可以自动推断 defineEmits 的类型,减少类型错误。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script lang="ts">
import { defineEmits, defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const emit = defineEmits<{
      (e: 'increment'): void;
    }>();

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
});
</script>

示例:类型化的 defineEmits

通过 TypeScript 增强 defineEmits 的类型安全。

示例代码

<template>
  <button @click="increment">Increment</button>
</template>

<script lang="ts">
import { defineEmits, defineComponent } from 'vue';

export default defineComponent({
  setup() {
    const emit = defineEmits<{
      (e: 'increment'): void;
    }>();

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
});
</script>

5. defineEmits 的高级用法

使用 defineEmits 实现复杂事件处理

通过 defineEmits 定义复杂事件,并在 setup() 中处理。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click', 'custom-event']);

    const handleClick = () => {
      emit('click');
      emit('custom-event', 'Hello from child');
    };

    return {
      handleClick,
    };
  },
};
</script>

使用 defineEmits 实现自定义事件

通过 defineEmits 定义自定义事件,并在父组件中监听。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @custom-event="handleCustomEvent" />
    <p>Message: {{ message }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      message: '',
    };
  },
  methods: {
    handleCustomEvent(payload) {
      this.message = payload;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['custom-event']);

    const handleClick = () => {
      emit('custom-event', 'Hello from child');
    };

    return {
      handleClick,
    };
  },
};
</script>

示例:在 setup() 中实现复杂事件处理

通过 defineEmits 定义多个事件,并在 setup() 中处理。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click', 'custom-event']);

    const handleClick = () => {
      emit('click');
      emit('custom-event', 'Hello from child');
    };

    return {
      handleClick,
    };
  },
};
</script>

6. defineEmits 的性能优化

避免不必要的事件触发

通过条件判断避免不必要的事件触发。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      if (shouldEmit) {
        emit('click');
      }
    };

    return {
      handleClick,
    };
  },
};
</script>

使用 defineEmits 优化事件处理性能

通过 defineEmits 优化事件处理逻辑,减少不必要的渲染。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      emit('click');
    };

    return {
      handleClick,
    };
  },
};
</script>

示例:优化 defineEmits 的性能

通过条件判断和优化事件处理逻辑,提升性能。

示例代码

<template>
  <button @click="handleClick">Click Me</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      if (shouldEmit) {
        emit('click');
      }
    };

    return {
      handleClick,
    };
  },
};
</script>

7. defineEmits 的测试与调试

使用 Vitest 测试 defineEmits

通过 Vitest 测试 defineEmits 的功能。

示例代码

import { mount } from '@vue/test-utils';
import MyComponent from '@/components/MyComponent.vue';

test('测试 defineEmits', async () => {
  const wrapper = mount(MyComponent);
  await wrapper.find('button').trigger('click');
  expect(wrapper.emitted('click')).toBeTruthy();
});

使用 Vue Devtools 调试 defineEmits

通过 Vue Devtools 调试 defineEmits 的事件触发。

示例代码

// 在组件中使用 console.log 调试
export default {
  setup() {
    const emit = defineEmits(['click']);

    const handleClick = () => {
      console.log('Click event emitted');
      emit('click');
    };

    return {
      handleClick,
    };
  },
};

示例:测试与调试 defineEmits

通过 Vitest 和 Vue Devtools 测试与调试 defineEmits

示例代码

import { mount } from '@vue/test-utils';
import MyComponent from '@/components/MyComponent.vue';

test('测试 defineEmits', async () => {
  const wrapper = mount(MyComponent);
  await wrapper.find('button').trigger('click');
  expect(wrapper.emitted('click')).toBeTruthy();
});

8. 实战案例

案例一:实现一个计数器组件

通过 defineEmits 实现一个计数器组件,支持点击按钮增加计数。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @increment="handleIncrement" />
    <p>Count: {{ count }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    handleIncrement() {
      this.count++;
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <button @click="increment">Increment</button>
</template>

<script>
import { defineEmits } from 'vue';

export default {
  setup() {
    const emit = defineEmits(['increment']);

    const increment = () => {
      emit('increment');
    };

    return {
      increment,
    };
  },
};
</script>

案例二:实现一个表单验证组件

通过 defineEmits 实现一个表单验证组件,支持提交表单时触发验证事件。

示例代码

<!-- 父组件 -->
<template>
  <div>
    <ChildComponent @submit="handleSubmit" />
    <p>Validation Message: {{ message }}</p>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  data() {
    return {
      message: '',
    };
  },
  methods: {
    handleSubmit(isValid) {
      this.message = isValid ? 'Valid' : 'Invalid';
    },
  },
};
</script>

<!-- 子组件 -->
<template>
  <form @submit.prevent="submit">
    <input v-model="input" placeholder="Enter something" />
    <button type="submit">Submit</button>
  </form>
</template>

<script>
import { defineEmits, ref } from 'vue';

export default {
  setup() {
    const input = ref('');
    const emit = defineEmits(['submit']);

    const submit = () => {
      const isValid = input.value.length > 0;
      emit('submit', isValid);
    };

    return {
      input,
      submit,
    };
  },
};
</script>

深入浅出 TinyEditor 富文本编辑器系列2:快速开始

2026年1月11日 10:46

你好,我是 Kagol,个人公众号:前端开源星球

这是《深入浅出 TinyEditor 富文本编辑器系列》的第2篇,完整的系列文章:

欢迎使用 TinyEditor - 一款基于 Quill 2.0 构建的强大富文本编辑器,提供了开箱即用的丰富模块和格式。本指南将帮助你快速高效地开始使用 TinyEditor。

架构概述

TinyEditor 采用模块化架构,通过自定义模块、格式和主题扩展了 Quill 的功能。核心结构包括:

模块架构.png

安装

基础设置

使用 npm 安装 TinyEditor:

npm install @opentiny/fluent-editor

该包以 ES 模块形式提供,包含所有必要的依赖,包括作为基础的 Quill 2.0。

项目结构

项目结构.png

基本用法

最小示例

创建一个具有最小配置的基础编辑器实例:

import FluentEditor from '@opentiny/fluent-editor'
 
const editor = new FluentEditor('#editor', {
  theme: 'snow'
})

包含多个模块的示例

这是一个展示配置多个模块的综合设置:

import FluentEditor, { CollaborationModule } from '@opentiny/fluent-editor'

// 引入协同编辑相关依赖
import * as Y from 'yjs'
import { Awareness } from 'y-protocols/awareness'
import { QuillBinding } from 'y-quill'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
import QuillCursors from 'quill-cursors'

// 注册协同编辑模块
FluentEditor.register(
  'modules/collaborative-editing',
  CollaborationModule,
  true,
)

const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ 'header': [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ 'color': [] }, { 'background': [] }],
      [{ 'list': 'ordered'}, { 'list': 'bullet' }],
      ['link', 'image', 'video'],
      ['clean']
    ],
    // 配置协同编辑模块
    'collaborative-editing': {
        deps: {
          Y,
          Awareness,
          QuillBinding,
          QuillCursors,
          WebsocketProvider,
          IndexeddbPersistence,
        },
        provider: {
          type: 'websocket',
          options: {
            serverUrl: 'wss://ai.opentiny.design/tiny-editor/',
            roomName: 'tiny-editor-document-demo-roomName',
          },
        },
        awareness: {
          state: {
            name: `userId:${Math.random().toString(36).substring(2, 15)}`,
            color: `rgb(${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)})`,
          },
        },
    },
    'mathlive': true, // 需要引入 mathlive 相关依赖
    'syntax': true // 需要引入 highlight.js 相关依赖
  }
})

详细配置请参考文档:opentiny.github.io/tiny-editor…

可用模块

TinyEditor 提供了丰富的预注册模块集:

模块 描述 用法
toolbar 带有自定义处理器的增强工具栏 toolbar: { container: TOOLBAR_CONFIG }
image 支持格式化的高级图片处理 image: true
collaborative-editing 实时协作 参见上面的协作示例
mathlive LaTeX 数学公式 mathlive: true
syntax 代码语法高亮 syntax: true
emoji 表情选择器和支持 emoji: true
mention @提及功能 mention: true

配置选项

编辑器接受扩展了 Quill 选项的综合配置对象:

选项 类型 默认值 描述
modules IEditorModules {} 模块配置
scrollingContainer HTMLElement | string | null body 自定义滚动容器
autoProtocol boolean | string false 自动为链接添加协议
editorPaste any undefined 自定义粘贴处理
screenshot Partial<ScreenShotOptions> undefined 截图配置

快速开始模板

这是一个可用于快速原型设计的即用型 HTML 模板(可直接复制到 HTML 文件中运行):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>TinyEditor Quick Start</title>
  <style>
    #editor { 
      height: 400px; 
      border: 1px solid #ccc;
      padding: 10px;
    }
  </style>
  <!-- 引入 @opentiny/fluent-editor -->
  <script type="importmap">
    {
      "imports": {
        "@opentiny/fluent-editor": "https://unpkg.com/@opentiny/fluent-editor@3.18.3/index.es.js"
      }
    }
  </script>
  <!-- 引入 @opentiny/fluent-editor 样式 -->
  <link rel="stylesheet" href="https://unpkg.com/@opentiny/fluent-editor@3.18.3/style.css" />
</head>
<body>
  <div id="editor"></div>
  
  <script type="module">
    import FluentEditor from '@opentiny/fluent-editor'
    
    const editor = new FluentEditor('#editor', {
      theme: 'snow',
      modules: {
        toolbar: [
          ['bold', 'italic', 'underline'],
          [{ 'list': 'ordered'}, { 'list': 'bullet' }],
          ['link', 'image'],
          ['clean']
        ]
      }
    })
  </script>
</body>
</html>

效果图:

项目效果图.png

TinyEditor 类扩展了 Quill 的核心功能,同时保持与现有 Quill 配置的兼容性。这确保了现有 Quill 用户的平滑迁移路径,同时提供了对 TinyEditor 增强功能集的访问。

后续将全面介绍 TinyEditor 如何使用、设计架构、实现原理、二次开发等内容,点个关注,不迷路。

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-editor

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

从10分钟到30秒!Webpack 打包效率优化实战指南

2026年1月10日 23:51

前言

作为前端开发者,你是否经历过这些绝望时刻:

  • 开发时改一行代码,热更新要等半分钟;
  • 生产环境打包,喝两杯咖啡回来还没结束;
  • 项目越大,打包速度越慢,最后甚至影响迭代效率。

Webpack 作为前端工程化的核心工具,其打包效率直接决定了开发体验和发布效率。本文结合实战经验,整理了一套“从基础到进阶”的 Webpack 打包优化方案,帮你把打包时间从“分钟级”压缩到“秒级”,亲测有效!

先明确核心优化思路:让 Webpack 只做必要的事,减少无效工作;让重复工作复用结果;让多核 CPU 并行干活。下面按这个思路逐步拆解。

一、基础优化:立竿见影的“减法操作”

打包慢的核心原因之一是 Webpack 处理了过多不必要的文件。这一步先通过“缩小处理范围”做减法,优化成本最低,效果最明显。

1. 精准限定 loader 处理范围

loader 是打包耗时的重灾区(比如 babel-loader、css-loader),很多时候我们会让 loader 处理所有符合规则的文件,但实际上只有 src 目录下的源码需要处理,node_modules 里的第三方库早已是编译好的代码,无需重复处理。

优化方案:用 include 限定处理目录,exclude 排除无需处理的目录(exclude 优先级更高)。

const path = require('path');

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/, // 匹配 js 文件
        include: path.resolve(__dirname, 'src'), // 只处理 src 目录下的 js
        exclude: /node_modules/, // 排除 node_modules(第三方库无需 babel 转译)
        use: 'babel-loader',
      },
      {
        test: /.css$/,
        include: path.resolve(__dirname, 'src'),
        exclude: /node_modules/,
        use: ['style-loader', 'css-loader'],
      }
    ],
  },
};

避坑提示:不要用 exclude: /node_modules/ 同时又用 include: 非 src 目录,容易导致规则冲突,优先用 include 精准匹配。

2. 优化 resolve 配置:减少文件查找时间

Webpack 解析模块时会按规则遍历查找文件,比如默认会查找 .js.json.jsx 等多种后缀,还会向上级目录查找 node_modules,这些都需要耗时。

优化方案:

  • 限定扩展名:只保留常用后缀,按使用频率排序;
  • 指定模块查找目录:优先在项目本地 node_modules 查找,避免向上级目录遍历;
  • 配置别名:缩短常用目录的查找路径,比如用@ 代替 src
module.exports = {
  resolve: {
    // 1. 限定扩展名,按使用频率排序(减少遍历次数)
    extensions: ['.js', '.jsx', '.json'],
    // 2. 指定模块查找目录(优先本地 node_modules)
    modules: [path.resolve(__dirname, 'node_modules')],
    // 3. 配置别名(缩短路径查找,同时简化代码引入)
    alias: {
      '@': path.resolve(__dirname, 'src'),
      'components': path.resolve(__dirname, 'src/components'),
      // 对第三方库也可配置别名,直接指向优化后的版本
      'react$': 'react/dist/react.production.min.js',
      'react-dom$': 'react-dom/dist/react-dom.production.min.js'
    },
  },
};

效果:模块查找时间减少 30%+,同时代码中引入组件可以写成 import Button from '@/components/Button',更简洁。

3. 用 externals 排除第三方库打包

React、Vue、jQuery 这类第三方库体积大、不常变动,每次打包都要重复解析、压缩,非常耗时。我们可以把它们从打包流程中排除,改用 CDN 引入。

优化方案:配置 externals,告诉 Webpack 这些模块不需要打包,运行时从全局变量获取。

module.exports = {
  externals: {
    // 键:代码中 import 的名称;值:CDN 引入后暴露的全局变量名
    react: 'React',
    'react-dom': 'ReactDOM',
    vue: 'Vue',
    jquery: 'jQuery'
  },
};

然后在 index.html 中引入 CDN 资源:

<!-- 引入 React 和 ReactDOM 的 CDN -->
<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
<!-- 引入 Vue 的 CDN -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>

效果:打包体积大幅减小,打包时间直接减少 20%-50%(取决于第三方库的体积)。

二、进阶优化:复用结果,避免重复工作

很多时候打包慢是因为“重复劳动”——比如每次打包都重新编译所有文件,哪怕大部分文件没变动。这一步通过“缓存”复用之前的构建结果,让 Webpack 只处理变动的文件。

1. Webpack 5 内置缓存(推荐)

Webpack 5 自带了持久化缓存机制,无需额外安装插件,开启后会把构建结果缓存到文件系统,下次构建时直接复用未变动模块的缓存。

module.exports = {
  cache: {
    type: 'filesystem', // 缓存类型:文件系统(比内存缓存更持久,重启终端不丢失)
    cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'), // 缓存存放目录
    // 可选:自定义缓存失效规则(默认文件内容变动则失效)
    buildDependencies: {
      config: [__filename] // 当 webpack 配置文件变动时,缓存失效
    }
  },
};

效果:首次打包后,后续增量构建速度提升 60%+,比如之前改一行代码要等 20 秒,开启后可能只需要 5 秒。

2. 开启 loader 缓存(针对性优化)

babel-loader 处理 JS/JSX 时耗时较高,开启它的专属缓存,能避免重复编译相同的文件。

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true, // 开启缓存,默认缓存到 node_modules/.cache/babel-loader
            cacheCompression: false, // 开发环境关闭缓存压缩(提升缓存读取速度)
          },
        },
      },
    ],
  },
};

3. 生产环境:hard-source-webpack-plugin(可选)

如果需要更持久的缓存(比如跨构建过程复用),可以使用 hard-source-webpack-plugin,它会为每个模块生成独立的缓存,首次打包后,后续打包速度提升 50%+。

# 安装
npm install hard-source-webpack-plugin --save-dev
const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  plugins: [
    new HardSourceWebpackPlugin(), // 启用硬缓存
    new HardSourceWebpackPlugin.ExcludeModulePlugin([
      {
        test: /mini-css-extract-plugin[\/]dist[\/]loader/, // 排除部分易出错的 loader
      },
    ]),
  ],
};

注意:开发环境优先用 Webpack 内置缓存,生产环境可根据需求选择;如果项目依赖频繁变动,硬缓存可能导致缓存失效不及时,需谨慎使用。

三、并行优化:让多核 CPU 火力全开

Webpack 默认是单进程运行的,只能利用 CPU 的一个核心,而现代电脑都是多核 CPU,这就造成了资源浪费。通过多进程/多线程让多个核心同时干活,能大幅提升打包速度。

1. thread-loader:多线程处理 loader

把耗时的 loader(如 babel-loader、ts-loader)放到独立的线程中处理,主线程只负责统筹,不阻塞构建流程。

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: [
          // thread-loader 必须放在耗时 loader 前面
          {
            loader: 'thread-loader',
            options: {
              workers: require('os').cpus().length - 1, // 线程数 = CPU 核心数 - 1(避免占满 CPU)
              workerNodeArgs: ['--max-old-space-size=1024'], // 给每个线程分配内存
            },
          },
          'babel-loader', // 耗时 loader 放到线程中处理
        ],
      },
    ],
  },
};

避坑提示:thread-loader 不适合所有 loader,比如 file-loader(处理静态资源),多线程会增加文件 IO 开销,反而变慢;只对 babel-loader、ts-loader 这类 CPU 密集型 loader 生效。

2. 生产环境:多进程压缩代码

生产环境需要压缩 JS、CSS,这是非常耗时的操作。默认是单进程压缩,我们可以开启多进程压缩。

const TerserPlugin = require('terser-webpack-plugin'); // JS 压缩插件
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); // CSS 压缩插件

module.exports = {
  mode: 'production',
  optimization: {
    minimizer: [
      // 多进程压缩 JS
      new TerserPlugin({
        parallel: true, // 自动开启多进程(默认开启,线程数 = CPU 核心数 - 1)
        extractComments: false, // 不提取注释(减少文件体积和处理时间)
      }),
      // 多进程压缩 CSS
      new CssMinimizerPlugin({
        parallel: true,
      }),
    ],
  },
};

四、环境专属优化:按需配置,不做无用功

开发环境和生产环境的优化目标不同:开发环境追求“热更新速度”,生产环境追求“打包速度 + 产物体积”。分开配置,避免在开发环境启用生产环境的耗时插件(如压缩、代码分割)。

1. 开发环境优化

// webpack.dev.js
const webpack = require('webpack');
const path = require('path');

module.exports = {
  mode: 'development', // 开发模式默认开启:代码未压缩、tree-shaking 关闭等
  devtool: 'eval-cheap-module-source-map', // 高效的 source map(速度快,调试体验好)
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  devServer: {
    hot: true, // 开启热模块替换(HMR):只更新变动模块,不刷新整个页面
    open: true, // 自动打开浏览器
    port: 8080,
    static: path.resolve(__dirname, 'public'),
  },
  cache: {
    type: 'filesystem', // 开启文件缓存
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(), // HMR 核心插件
  ],
  module: {
    rules: [
      {
        test: /.js$/,
        include: path.resolve(__dirname, 'src'),
        exclude: /node_modules/,
        use: ['thread-loader', 'babel-loader'],
      },
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'], // 开发环境用 style-loader 更快(无需提取 CSS)
      },
    ],
  },
};

2. 生产环境优化

// webpack.prod.js
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); // 提取 CSS 为单独文件

module.exports = {
  mode: 'production', // 生产模式默认开启:代码压缩、tree-shaking、作用域提升等
  devtool: 'nosources-source-map', // 不暴露源代码的 source map(兼顾调试和安全)
  entry: './src/index.js',
  output: {
    filename: '[name].[contenthash:8].js', // 用 contenthash 做缓存优化
    path: path.resolve(__dirname, 'dist'),
    clean: true, // 打包前清空 dist 目录(避免旧文件残留)
  },
  cache: {
    type: 'filesystem',
  },
  optimization: {
    minimizer: [
      new TerserPlugin({ parallel: true }),
      new CssMinimizerPlugin({ parallel: true }),
    ],
    splitChunks: {
      chunks: 'all', // 拆分同步/异步 chunk
      cacheGroups: {
        vendor: {
          test: /node_modules/, // 把第三方库拆成单独 chunk(便于缓存)
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash:8].css', // 提取 CSS 并加 hash
    }),
  ],
  module: {
    rules: [
      {
        test: /.js$/,
        include: path.resolve(__dirname, 'src'),
        exclude: /node_modules/,
        use: ['thread-loader', 'babel-loader'],
      },
      {
        test: /.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'], // 生产环境提取 CSS(便于缓存和并行加载)
      },
    ],
  },
};

五、关键工具:先分析瓶颈,再精准优化

优化前一定要先找到打包慢的核心瓶颈,不要盲目加配置。推荐用 webpack-bundle-analyzer 分析打包体积和依赖关系,找到大文件、重复依赖等问题。

# 安装
npm install webpack-bundle-analyzer --save-dev
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'server', // 启动服务器展示分析结果
      analyzerPort: 8888, // 端口号
    }),
  ],
};

运行打包命令后,会自动打开浏览器,展示一个可视化的打包分析图:

  • 红色块:体积较大的文件,优先考虑拆分或替换为更小的替代方案;
  • 重复依赖:比如多个组件都引入了 lodash,可以用 lodash-es 按需引入,或用 ProvidePlugin 全局引入;
  • 不必要的依赖:比如把开发环境的依赖(如 mockjs)打包到生产环境,需要排除。

六、其他优化小技巧

  1. 升级 Webpack 版本:Webpack 5 相比 4 有大幅性能提升(如持久化缓存、更好的 tree-shaking、模块联邦等),老项目优先升级;
  2. 避免在配置中做耗时操作:比如每次打包都读取文件、执行复杂计算,尽量提前计算好结果;
  3. 使用 ES 模块语法:CommonJS 模块无法被 tree-shaking 优化,尽量用 import/export
  4. 关闭不必要的插件:比如开发环境关闭 BundleAnalyzerPluginMiniCssExtractPlugin 等生产环境插件。

七、优化效果对比

以一个中型 React 项目(约 50 个组件,依赖 React、Ant Design 等)为例,优化前后对比:

场景 优化前 优化后 提升比例
开发热更新(改一行代码) 25 秒 4 秒 84%
生产环境首次打包 8 分钟 1 分钟 87.5%
生产环境增量打包 3 分钟 30 秒 83.3%

总结

Webpack 打包优化的核心逻辑是“减少无效工作、复用已有成果、利用多核资源、按需环境配置”,按以下步骤逐步优化即可:

  1. 基础优化:用 include/excluderesolveexternals 缩小处理范围;
  2. 进阶优化:开启 Webpack 内置缓存、loader 缓存,减少重复构建;
  3. 并行优化:用 thread-loader、多进程压缩,利用多核 CPU;
  4. 环境适配:开发/生产环境分开配置,不做无用功;
  5. 精准优化:用 webpack-bundle-analyzer 找到瓶颈,针对性优化。

优化不是一蹴而就的,建议逐步尝试,每加一个配置就测试一次打包速度,找到最适合自己项目的方案。如果你的项目有特殊场景(比如超大单页应用、多入口项目),欢迎在评论区留言,一起探讨优化方案!

最后,觉得有用的话,点赞 + 收藏,下次优化 Webpack 直接抄作业~ 🚀

Vue createRenderer 自定义渲染器从入门到实战

作者 如果你好
2026年1月10日 23:44

Vue createRenderer 自定义渲染器从入门到实战

🔥 Vue 3它不仅能高效渲染浏览器 DOM,还能实现小程序、Native 等多端运行。而支撑这一切的核心,就是 createRenderer 函数。它允许我们自定义渲染逻辑,摆脱 Vue 内置 DOM 渲染的限制,打造适配任意平台的渲染器

一、自定义 DOM 渲染器

示例重点实现支持事件绑定的 patchProp 方法,还会加入虚拟节点更新案例,直观看到渲染器的更新流程。

完整可运行代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Vue 自定义渲染器入门示例</title>
  <!-- 引入 Vue 3 完整版,方便浏览器直接运行 -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <!-- 渲染挂载容器 -->
  <div id="app"></div>

  <script>
    // 从 Vue 中解构出 createRenderer 和 h 函数
    const { createRenderer, h } = Vue;

    // 1. 创建自定义渲染器:传入平台渲染配置对象
    const renderer = createRenderer({
      // 创建元素节点:根据标签名创建 DOM 元素
      createElement(tag) {
        console.log(`[渲染步骤] 创建元素节点:<${tag}>`);
        return document.createElement(tag);
      },

      // 更新元素属性:核心改造!支持普通属性 + 事件绑定(onXXX 格式)
      patchProp(el, key, prevValue, nextValue) {
        // 判断是否是事件属性(以 on 开头,且第二个字母大写,如 onClick、onInput)
        const isEvent = key.startsWith('on') && /^on[A-Z]/.test(key);
        
        if (isEvent) {
          // 提取事件名(去掉 on 前缀,转为小写,如 onClick -> click)
          const eventName = key.slice(2).toLowerCase();
          
          // 移除旧的事件监听(如果有旧值)
          if (prevValue) {
            el.removeEventListener(eventName, prevValue);
          }
          
          // 绑定新的事件监听(如果有新值)
          if (nextValue) {
            el.addEventListener(eventName, nextValue);
            console.log(`[渲染步骤] 绑定事件:${eventName},回调函数已挂载`);
          }
        } else {
          // 普通属性:直接用 setAttribute 处理
          if (nextValue === undefined || nextValue === null) {
            el.removeAttribute(key);
            console.log(`[渲染步骤] 移除普通属性:${key}`);
          } else {
            el.setAttribute(key, nextValue);
            console.log(`[渲染步骤] 更新普通属性:${key} = ${nextValue}`);
          }
        }
      },

      // 插入元素:将子元素插入到父元素的指定位置
      insert(el, parent, anchor) {
        console.log(`[渲染步骤] 插入元素:将 <${el.tagName.toLowerCase()}> 插入到 <${parent.tagName.toLowerCase()}>`);
        parent.insertBefore(el, anchor || null);
      },

      // 移除元素:从父节点中移除当前元素
      remove(el) {
        console.log(`[渲染步骤] 移除元素:<${el.tagName.toLowerCase()}>`);
        el.parentNode.removeChild(el);
      },

      // 创建文本节点:创建 DOM 文本节点
      createText(text) {
        console.log(`[渲染步骤] 创建文本节点:${text}`);
        return document.createTextNode(text);
      },

      // 更新文本节点:修改文本节点的内容
      setText(node, text) {
        console.log(`[渲染步骤] 更新文本节点:${node.nodeValue}${text}`);
        node.nodeValue = text;
      }
    });

    // 2. 获取挂载容器
    const app = document.getElementById('app');

    // 3. 初始虚拟节点(无事件)
    const vnode1 = h('div', { title: '初始节点' }, 'Hello initial vnode');

    // 4. 1秒后更新的虚拟节点(带 onClick 事件)
    const vnode2 = h(
      'div',
      {
        onClick() {
          console.log('更新了!点击事件触发成功~');
        },
        title: '更新后节点(带点击事件)' // 同时更新普通属性
      },
      'hello world'
    );

    // 5. 先渲染初始虚拟节点
    renderer.render(vnode1, app);

    // 6. 1秒后更新虚拟节点,触发 patchProp 处理事件和属性更新
    setTimeout(() => {
      console.log('==== 开始更新虚拟节点 ====');
      renderer.render(vnode2, app);
    }, 1000);
  </script>
</body>
</html>

运行效果

  1. 打开浏览器运行该 HTML 文件,页面先显示 Hello initial vnode,鼠标悬浮弹出「初始节点」提示;
  2. 1秒后,文本自动更新为 hello world,悬浮提示变为「更新后节点(带点击事件)」;
  3. 点击文本所在的 div,控制台打印 更新了!点击事件触发成功~
  4. 全程控制台会清晰打印渲染、更新、事件绑定的日志,直观看到自定义渲染器的完整执行流程。

二、核心拆解:这段代码到底在做什么?

我们逐部分拆解代码,理解 createRenderer 的核心组成和工作逻辑,重点解析新增的虚拟节点更新案例。

1. 核心引入:createRendererh 函数

const { createRenderer, h } = Vue;

这两个函数是实现自定义渲染的关键,各自承担核心职责:

  • createRenderer:Vue 3 提供的渲染器工厂函数,接收一套「平台渲染接口」,返回一个具备完整渲染能力的自定义渲染器实例。这个实例拥有 createApprender 方法,和 Vue 默认的 DOM 渲染器功能一致,只是渲染逻辑由我们自定义。
  • h 函数:全称 createVNode,核心作用是构建虚拟 DOM 节点(VNode)。它接收标签名/组件、属性对象、子节点/文本内容,返回一个标准的 VNode 对象,作为渲染器的输入数据。

2. 核心步骤:创建自定义渲染器(createRenderer

const renderer = createRenderer({ /* 渲染配置对象 */ });

createRenderer 接收一个配置对象作为唯一参数,这个对象必须实现 6 个核心方法,它们是渲染器与「目标平台」的交互桥梁,负责将 VNode 转换为目标平台的真实节点(这里是浏览器 DOM)。

6 个核心渲染方法详解(DOM 平台)
方法名 核心作用 入参说明
createElement 创建元素节点 tag:标签名(如 'div'、'p'),返回创建好的 DOM 元素
patchProp 更新元素属性 el:真实 DOM 元素、key:属性名、prevValue:旧属性值、nextValue:新属性值
insert 插入元素 el:要插入的 DOM 元素、parent:父 DOM 元素、anchor:插入参考节点(null 则插入末尾)
remove 移除元素 el:要移除的 DOM 元素
createText 创建文本节点 text:文本内容,返回创建好的 DOM 文本节点
setText 更新文本节点 node:真实 DOM 文本节点、text:新的文本内容
关键亮点:patchProp 支持事件绑定

本次改造的核心是 patchProp 方法,它不仅能处理 title 这类普通属性,还能识别 onClick 这类事件属性,实现 DOM 事件的绑定与移除:

  • 先判断属性是否为 onXXX 格式的事件;
  • 提取原生事件名(onClickclick);
  • 遵循「先清后绑」原则,避免重复绑定导致多次触发。

3. 新增亮点:虚拟节点更新案例(核心解析)

自定义渲染器如何处理 VNode 更新,这也是 Vue 响应式更新的底层缩影:

// 2. 获取挂载容器
const app = document.getElementById('app');

// 3. 初始虚拟节点(无事件)
const vnode1 = h('div', { title: '初始节点' }, 'Hello initial vnode');

// 4. 1秒后更新的虚拟节点(带 onClick 事件)
const vnode2 = h(
  'div',
  {
    onClick() {
      console.log('更新了!点击事件触发成功~');
    },
    title: '更新后节点(带点击事件)' // 同时更新普通属性
  },
  'hello world'
);

// 5. 先渲染初始虚拟节点
renderer.render(vnode1, app);

// 6. 1秒后更新虚拟节点,触发 patchProp 处理事件和属性更新
setTimeout(() => {
  console.log('==== 开始更新虚拟节点 ====');
  renderer.render(vnode2, app);
}, 1000);
这段代码的核心逻辑:
  1. 初始渲染:调用 renderer.render(vnode1, app),渲染器将 vnode1 转换为真实 DOM,插入到挂载容器中,完成首次渲染;
  2. 延迟更新:1 秒后调用 renderer.render(vnode2, app),渲染器会自动对比 vnode1vnode2 的差异(属性、文本内容);
  3. 差异更新
    • 对于 title 属性:触发 patchProp 方法,将旧值「初始节点」更新为新值「更新后节点(带点击事件)」;
    • 对于 onClick 事件:触发 patchProp 方法,绑定新的点击事件回调;
    • 对于文本内容:触发 setText 方法,将「Hello initial vnode」更新为「hello world」;
  4. 无全量重建:整个更新过程没有删除旧 DOM 再创建新 DOM,而是只更新有差异的部分,这也是 Vue 渲染高效的核心原因。

4. 挂载应用的两种方式

案例使用 renderer.render(vnode, container) 直接渲染 VNode,除此之外,也可以通过 renderer.createApp(component).mount(container) 挂载组件,两种方式均有效:

  • 直接渲染 VNode:更灵活,适合手动控制渲染流程(如本次的延迟更新案例);
  • 通过 createApp 挂载:更贴近日常 Vue 开发,适合组件化开发场景。

三、深入理解:自定义渲染器的工作流程

整个渲染与更新过程可以总结为 4 个核心步骤,形成一个完整的闭环:

  1. 生成 VNode:通过 h 函数创建标准 VNode,提供渲染的数据源;
  2. 首次渲染:渲染器调用 6 个核心方法,将 VNode 转换为真实节点,插入到挂载容器中;
  3. VNode 对比:更新时,渲染器对比新旧 VNode,找出属性、文本等差异;
  4. 差异更新:针对差异部分,调用对应的 patchPropsetText 等方法,更新真实节点,无需全量重建。

computed、watch 与 watchEffect 的使用边界与实战指南

作者 boooooooom
2026年1月10日 23:11

Vue3 中 computed、watch 与 watchEffect 的使用边界与实战指南

在 Vue3 的响应式系统中,computed、watch 和 watchEffect 是处理响应式数据依赖的核心 API。它们看似都能监听数据变化并执行相应逻辑,但各自的设计初衷、适用场景和使用边界存在显著差异。很多开发者在实际开发中容易混淆三者的用法,导致出现性能冗余、逻辑混乱甚至响应式失效的问题。本文将从“边界”视角出发,深入剖析三者的核心定位、适用场景、禁忌用法及实战技巧,帮助开发者精准把握其使用边界,写出更优雅、高效的响应式代码。

一、核心定位:明确边界的前提

要掌握三者的使用边界,首先需要明确它们的核心定位——Vue 团队在设计这三个 API 时,赋予了它们截然不同的职责:

  • computed(计算属性) :核心定位是“派生状态”,用于基于已有响应式数据生成新的响应式数据。它的本质是“数据的加工者”,专注于数据的转换与派生,而非执行副作用。
  • watch(监听器) :核心定位是“数据变化的响应器”,用于监听特定响应式数据的变化,并在变化时执行自定义逻辑(通常是副作用)。它的特点是“精准监听、主动触发”,需要明确指定监听目标。
  • watchEffect(副作用监听器) :核心定位是“隐式依赖的副作用执行器”,用于自动追踪函数内部的响应式依赖,当依赖变化时重新执行函数(副作用)。它的特点是“隐式监听、自动触发”,无需指定监听目标,依赖由函数内部使用自动收集。

简单来说:computed 管“数据派生”,watch 管“精准副作用”,watchEffect 管“隐式依赖副作用”。这一定位差异,是划分它们使用边界的根本依据。

二、computed 的使用边界:只做派生,不做副作用

computed 的设计初衷是为了简化“基于已有数据生成新数据”的场景,它具有缓存机制(只有依赖变化时才重新计算),能有效提升性能。但它的边界也非常明确:仅用于数据派生,禁止在其中执行副作用

2.1 适用场景

  • 基于多个响应式数据的组合/转换生成新数据(如拼接字符串、计算总和、过滤数组);
  • 需要对数据进行格式化处理(如日期格式化、金额千分位处理);
  • 依赖数据变化时需要自动更新的派生状态(如购物车总价、列表筛选结果)。

示例:购物车总价计算(典型的派生状态场景)

import { ref, computed } from 'vue';

const cartItems = ref([
  { id: 1, name: '手机', price: 5999, quantity: 1 },
  { id: 2, name: '耳机', price: 1299, quantity: 2 }
]);

// 正确:computed 用于派生购物车总价
const totalPrice = computed(() => {
  return cartItems.value.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);
});

2.2 禁忌边界(绝对不能做的事)

  1. 禁止执行副作用操作:如修改 DOM、发送网络请求、修改其他响应式数据、打印日志等。computed 的回调函数应是“纯函数”(输入不变则输出不变,无副作用),否则会导致逻辑混乱、响应式追踪异常。
  2. 禁止依赖非响应式数据:computed 仅能追踪响应式数据(ref/reactive)的变化,依赖非响应式数据(如普通变量、全局变量)会导致计算结果无法自动更新。
  3. 禁止过度复杂的计算逻辑:computed 适合简单的数据派生,若包含大量循环、复杂算法,会阻塞页面渲染。复杂计算应拆分到方法中,或使用防抖/节流处理。

错误示例(computed 中执行副作用):

// 错误:在 computed 中发送网络请求(副作用)
const userInfo = computed(async () => {
  const res = await fetch(`/api/user/${userId.value}`); // 副作用
  return res.json();
});

// 错误:在 computed 中修改其他响应式数据(副作用)
const count = ref(0);
const doubleCount = computed(() => {
  count.value += 1; // 修改其他响应式数据,导致死循环
  return count.value * 2;
});

2.3 实战注意点

  • 利用缓存机制:computed 的缓存特性可以避免重复计算,但若依赖的响应式数据未变化,多次访问 computed 会直接返回缓存结果,无需重新计算。
  • 避免循环依赖:两个 computed 相互依赖会导致无限循环,应重构逻辑,拆分依赖关系。
  • 只读与可写 computed:默认 computed 是只读的,若需要修改 computed 的值,可通过 set 方法定义可写 computed,但需确保逻辑清晰,避免破坏派生关系。

三、watch 的使用边界:精准监听,副作用可控

watch 是 Vue 中最常用的监听 API,它的核心优势是“精准控制”——可以明确指定监听目标、深度监听、控制执行时机。其使用边界在于:仅用于监听特定数据变化并执行可控的副作用,避免过度监听或监听不明确的目标

3.1 适用场景

  • 监听特定响应式数据变化,执行副作用(如发送网络请求、修改 DOM、更新全局状态);
  • 需要获取数据变化前后的值(oldValue 和 newValue);
  • 需要控制监听时机(如初始执行、深度监听对象/数组内部变化);
  • 需要条件性执行副作用(如仅当数据变化满足特定条件时执行)。

示例:监听用户 ID 变化,重新获取用户信息(精准监听 + 副作用)

import { ref, watch } from 'vue';

const userId = ref(1);
const userInfo = ref(null);

// 正确:watch 监听 userId 变化,发送网络请求(副作用)
watch(userId, async (newId, oldId) => {
  console.log(`用户 ID 从 ${oldId} 变为 ${newId}`);
  const res = await fetch(`/api/user/${newId}`);
  userInfo.value = res.json();
}, {
  immediate: true, // 初始执行一次(页面加载时获取默认用户信息)
  deep: false // 基本类型无需深度监听,默认 false
});

3.3 禁忌边界(绝对不能做的事)

  1. 禁止监听非响应式数据:watch 无法追踪普通变量、全局变量的变化,监听这些数据会导致回调函数永远不执行。
  2. 禁止过度深度监听:对大型对象/数组进行深度监听(deep: true)会严重影响性能,因为 Vue 会递归遍历整个数据结构。应尽量监听对象的具体属性(如 watch(() => obj.xxx))。
  3. 禁止在 watch 中修改监听目标本身:这会导致无限循环(数据变化 → watch 执行 → 修改数据 → 再次触发 watch)。
  4. 禁止监听过多目标:一个 watch 监听多个不相关的目标会导致逻辑混乱,应拆分多个 watch,每个 watch 专注于一个监听目标。

错误示例(过度深度监听 + 循环修改):

const largeObj = ref({ /* 大型对象,包含几十层嵌套 */ });

// 错误:过度深度监听,严重影响性能
watch(largeObj, (newObj) => {
  console.log('大型对象变化', newObj);
}, { deep: true });

// 错误:watch 中修改监听目标,导致无限循环
const count = ref(0);
watch(count, (newVal) => {
  count.value = newVal + 1; // 修改监听目标,触发无限循环
});

3.3 实战注意点

  • 监听对象属性:对于 reactive 对象的单个属性,应使用函数形式指定监听目标(watch(() => obj.xxx, ...)),避免直接监听 obj.xxx(无法正确追踪)。
  • 清理副作用:若 watch 中包含异步操作(如定时器、网络请求),应在回调函数中返回清理函数,避免内存泄漏(如组件卸载时取消未完成的请求)。
  • 控制初始执行:通过 immediate: true 控制是否在初始时执行回调,避免重复编写初始化逻辑。

四、watchEffect 的使用边界:隐式依赖,简化副作用

watchEffect 是 Vue3 新增的 API,它的核心优势是“简化”——无需指定监听目标,自动追踪函数内部的响应式依赖。其使用边界在于:仅用于副作用逻辑简单、依赖明确且无需获取旧值的场景,避免依赖模糊导致的逻辑不可控

4.1 适用场景

  • 副作用逻辑依赖多个响应式数据,且无需区分具体哪个数据变化;
  • 无需获取数据变化前后的旧值,只需在依赖变化时重新执行副作用;
  • 简单的副作用操作(如更新 DOM、打印日志、同步状态)。

示例:监听搜索关键词和分页变化,重新获取列表数据(多依赖简化监听)

import { ref, watchEffect } from 'vue';

const keyword = ref('');
const page = ref(1);
const list = ref([]);

// 正确:watchEffect 自动追踪 keyword 和 page 的变化
watchEffect(async () => {
  const res = await fetch(`/api/list?keyword=${keyword.value}&page=${page.value}`);
  list.value = res.json();
});

上述场景若用 watch 实现,需要监听 [keyword, page] 两个目标,而 watchEffect 只需在函数内部使用依赖,即可自动追踪,代码更简洁。

4.2 禁忌边界(绝对不能做的事)

  1. 禁止依赖模糊的逻辑:若函数内部包含大量条件判断,导致依赖关系不明确,会增加调试难度(无法直观知道哪些数据会触发副作用)。
  2. 禁止需要获取旧值的场景:watchEffect 无法获取数据变化前后的旧值,若需要对比新旧值,必须使用 watch。
  3. 禁止在函数内部创建未清理的长期副作用:如未清除的定时器、未取消的事件监听,会导致内存泄漏(需使用 onInvalidate 清理)。
  4. 禁止过度复杂的逻辑:watchEffect 适合简单的副作用,复杂逻辑应拆分,避免函数体积过大、可读性差。

错误示例(需要旧值却用 watchEffect):

const count = ref(0);

// 错误:需要对比新旧值,watchEffect 无法实现
watchEffect((onInvalidate) => {
  console.log(`count 变化了,旧值:?,新值:${count.value}`); // 无法获取旧值
});

// 正确:使用 watch 获取新旧值
watch(count, (newVal, oldVal) => {
  console.log(`count 变化了,旧值:${oldVal},新值:${newVal}`);
});

4.3 实战注意点

  • 清理副作用:通过 onInvalidate 函数清理长期副作用(如定时器、网络请求),确保组件卸载时不会残留资源。
  • 控制执行时机:默认 watchEffect 在组件渲染前执行,可通过 flush: 'post' 配置改为渲染后执行(避免修改 DOM 影响渲染)。
  • 手动停止监听:watchEffect 返回一个停止函数,若需要条件性停止监听(如某个状态满足后不再监听),可调用该函数。

五、三者核心差异对比与选择指南

为了更清晰地划分使用边界,我们整理了三者的核心差异,并给出具体的选择指南:

5.1 核心差异对比

特性 computed watch watchEffect
核心定位 派生状态(数据 → 数据) 精准监听(数据 → 副作用) 隐式监听(副作用 → 自动追踪数据)
是否需要指定目标 无需(自动追踪依赖) 需要(明确监听目标) 无需(自动追踪函数内依赖)
是否能获取旧值 不能 能(newVal, oldVal) 不能
是否执行副作用 禁止 允许(核心用途) 允许(核心用途)
缓存机制 有(依赖不变则缓存) 无(变化即执行) 无(依赖变化即执行)

5.2 选择指南(一句话总结)

  • 当需要派生新的响应式数据时,用 computed;
  • 当需要精准监听特定数据变化,且可能需要新旧值对比控制执行时机时,用 watch;
  • 当需要执行副作用,且副作用依赖的响应式数据较多,无需区分具体目标、无需新旧值对比时,用 watchEffect。

六、实战避坑:常见边界错误与修复方案

结合实际开发场景,我们整理了以下常见的边界错误及对应的修复方案,帮助开发者快速避坑:

6.1 错误 1:用 computed 发送网络请求

错误原因:违反 computed 禁止副作用的边界。

// 错误
const userInfo = computed(async () => {
  const res = await fetch(`/api/user/${userId.value}`);
  return res.json();
});

修复方案:改用 watch 或 watchEffect(根据是否需要精准监听)。

// 正确(需要精准监听 userId,用 watch)
const userInfo = ref(null);
watch(userId, async (newId) => {
  const res = await fetch(`/api/user/${newId}`);
  userInfo.value = res.json();
}, { immediate: true });

6.2 错误 2:用 watch 监听整个大对象,开启 deep: true

错误原因:过度深度监听,性能损耗大。

// 错误
const form = ref({ name: '', age: '', address: { province: '', city: '' } });
watch(form, (newForm) => {
  console.log('表单变化', newForm);
}, { deep: true });

修复方案:监听对象的具体属性,避免深度监听整个对象。

// 正确(监听具体属性)
watch(
  () => [form.value.name, form.value.address.city],
  ([newName, newCity]) => {
    console.log('关键属性变化', newName, newCity);
  }
);

6.3 错误 3:用 watchEffect 却需要获取旧值

错误原因:违反 watchEffect 无法获取旧值的边界。

// 错误
const count = ref(0);
watchEffect(() => {
  console.log(`count 从 ${?} 变为 ${count.value}`); // 无法获取旧值
});

修复方案:改用 watch。

// 正确
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`);
});

七、总结

Vue3 的 computed、watch、watchEffect 虽同属响应式依赖处理 API,但边界清晰:computed 专注“数据派生”,watch 专注“精准副作用”,watchEffect 专注“简化隐式依赖副作用”。掌握它们的使用边界,核心在于理解其设计初衷——避免“用错工具”导致的性能问题和逻辑混乱。

在实际开发中,应遵循“数据派生用 computed,精准监听用 watch,多依赖副作用用 watchEffect”的原则,同时避开各自的禁忌边界(如 computed 不做副作用、watch 不过度深度监听、watchEffect 不依赖模糊逻辑)。只有精准把握边界,才能充分发挥 Vue3 响应式系统的优势,写出更高效、可维护的代码。

读懂 Tailwind v4:为什么它是现代前端项目的必选项?

2026年1月10日 23:00

摘要:当原子化 CSS 已经成为事实标准,Tailwind CSS 还能如何进化?答案不是更多的 Utility Class,而是彻底重构底层引擎。本文将带你深入了解 Tailwind v4 (代号 Oxy),看它如何通过 Rust 和“去 PostCSS 化”,将开发体验提升到全新维度。


在过去几年里,Tailwind CSS 几乎凭一己之力改变了前端开发者编写样式的方式。它赢得了“原子化 CSS vs 语义化 CSS”的战争,成为了现代前端项目的标配。

然而,随着项目规模的扩大,Tailwind v3 的局限性也逐渐显现:在包含数千个组件的大型 Monorepo 中,开发服务器的启动和 HMR(热更新)速度开始变慢。这并非 Tailwind 的设计缺陷,而是其依赖的 JavaScript 和 PostCSS 工具链的性能天花板。

站在 2026 年初,Tailwind CSS v4 的正式发布,标志着这个天花板被彻底击碎。这是一次**“为了速度而重写,为了简单而重构”**的革命性升级。

一、速度的质变:Rust 引擎登场

Tailwind v4 最大的变化在于其内部代号为 "Oxy" 的全新引擎。

告别 JavaScript 的束缚

在 v3 版本中,Tailwind 本质上是一个复杂的 PostCSS 插件。每当你保存文件,它都需要通过 JavaScript 解析你的代码,扫描类名,然后生成 CSS。

而在 v4 中,核心引擎完全使用 Rust 重写。

结果是惊人的。在大型项目中,构建速度和 HMR 速度提升了 10 倍以上。这种感觉就像是从机械硬盘升级到了 NVMe SSD,曾经需要几秒钟的重新编译现在几乎是瞬间完成的。你甚至感觉不到构建过程的存在。

二、架构的极简:再见,PostCSS

对于许多开发者来说,v4 最令人兴奋的改动或许不是速度,而是它不再依赖 PostCSS

工具链的解耦

长久以来,配置 Tailwind 意味着你必须配置 PostCSS。你需要一个 postcss.config.js,里面塞着 tailwindcssautoprefixer。如果你的项目构建链比较复杂(比如以前的 Webpack),这层依赖往往是痛苦的来源。

Tailwind v4 变成了一个独立的 CLI 工具(当然也提供了极其优秀的 Vite 插件)。它自带了解析和前缀添加功能。

这意味着什么?

  • • 你的项目根目录少了一个配置文件。
  • • 你的 package.json 少了一堆依赖。
  • • 构建流程少了一个中间环节,更加健壮。

三、配置的范式转移:CSS-First

v4 带来了配置方式的重大变革。它试图摆脱对 tailwind.config.js 的重度依赖,转而拥抱原生的 CSS 变量。这是一个非常现代化的理念:让 CSS 的归 CSS。

对比:自定义主题颜色

在 Tailwind v3 中,你需要修改 JavaScript 配置文件:


    
    
    
  // tailwind.config.js (旧版)
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          DEFAULT: '#0070f3',
          dark: '#024dbc',
        },
      },
    },
  },
  // ...
}

在 Tailwind v4 中,你可以直接在你的主 CSS 文件中定义,利用新的 @theme 指令:


    
    
    
  /* globals.css (新版 v4) */
@tailwind base;
@tailwind components;
@tailwind utilities;

@theme {
  /* 直接使用 CSS 变量定义主题 */
  --color-brand#0070f3;
  --color-brand-dark#024dbc;

  /* 甚至可以定义字体和断点 */
  --font-sans"Inter", sans-serif;
  --breakpoint-3xl1920px;
}

Tailwind v4 的引擎会自动读取这些 CSS 变量,并生成对应的工具类(如 bg-brand, text-brand-dark)。

这种变化的优势在于:

    1. 更符合 Web 标准: 配置就在 CSS 里,而不是 JS 里。
    1. 动态性: 你可以在运行时通过 JS 修改这些 CSS 变量,Tailwind 的样式会自动响应(虽然工具类名是静态的,但它们引用的值是动态的)。
    1. 零配置启动: 对于大多数简单项目,你甚至完全不需要 tailwind.config.js 文件。引擎会自动扫描你的文件并开始工作。

四、极其丝滑的 Vite 集成

Tailwind v4 团队与 Vite 团队进行了深度合作,推出了全新的官方 Vite 插件 @tailwindcss/vite

这个插件绕过了许多中间环节,直接介入 Vite 的构建流程。它带来的体验是:

  • 零配置: 安装插件,在 vite.config.ts 中引入,结束。它会自动找到你的 CSS 入口并开始工作。
  • 瞬间 HMR: 无论你的项目多大,修改一个 class 名,浏览器里的样式几乎是立刻更新。

    
    
    
  // vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(), // 就这么简单
  ],
})

五、总结:成熟的标志

Tailwind CSS v4 并不是一次简单的功能叠加,而是一次成熟的标志。

它不再满足于仅仅改变我们写 CSS 的方式,它开始深入底层,优化整个前端工具链的性能和体验。通过拥抱 Rust,它解决了性能瓶颈;通过抛弃 PostCSS,它简化了架构;通过转向 CSS-First 配置,它拥抱了 Web 标准。

如果说 v3 让 Tailwind 成为了主流,那么 v4 则让它成为了**“不可替代”**的基础设施。对于任何追求极致开发体验和性能的团队来说,升级到 v4 都是一个无需犹豫的选择。

Web 图形合成技术:Blending 与 Porter-Duff Compositing

作者 温宇飞
2026年1月10日 21:46

Web 图形渲染中,当多个图层重叠时,需要将它们组合成最终显示的图像。本文基于 W3C Compositing and Blending 规范,系统讲解 Blending 和 Porter-Duff Compositing 两大核心技术。从数学原理到实际应用,深入解析 Web 图形合成的完整体系。

符号与公式约定

理解图形合成的数学公式需要先明确符号约定。本章定义文中使用的核心术语和数学符号,包括颜色表示方式、Alpha 通道处理以及合成过程中的中间结果。

核心术语

Source 是当前要绘制的图层,通常是前景元素。它包含颜色信息和 Alpha 通道。

Backdrop 是 Source 后面的图层内容,是 Source 要与之合成的对象。Backdrop 是之前所有图层合成的累积结果。

Destination 是渲染目标,即画布上的像素缓冲区。合成的最终结果会写入 Destination。

Alpha 通道表示像素的不透明度,取值范围为 [0, 1]。Alpha 为 0 表示完全透明,Alpha 为 1 表示完全不透明。

预乘 Alpha 与非预乘 Alpha

颜色可以用两种形式表示:

非预乘 Alpha (Non-premultiplied Alpha) 表示颜色分量和 Alpha 通道独立存储。例如,半透明红色表示为 (R=1.0, G=0, B=0, α=0.5),颜色值不受 Alpha 影响。

预乘 Alpha (Premultiplied Alpha) 表示颜色分量已乘以 Alpha 值。同样的半透明红色表示为 (r=0.5, g=0, b=0, α=0.5),其中 r = R × α。预乘形式在图形硬件中更高效,简化了合成运算。

两种形式之间的转换:

// 非预乘转预乘
const cs = Cs * αs;
const cb = Cb * αb;

// 预乘转非预乘(需处理 α = 0 的情况)
const Cs = αs > 0 ? cs / αs : 0;
const Cb = αb > 0 ? cb / αb : 0;

符号定义

本文使用以下符号约定:

符号 含义 取值范围
Cs Source 的非预乘颜色 (R, G, B) [0, 1]
Cb Backdrop 的非预乘颜色 (R, G, B) [0, 1]
Cr Blending 后的结果颜色 (R, G, B) [0, 1]
Co 最终输出的非预乘颜色 (R, G, B) [0, 1]
cs Source 的预乘颜色 (r, g, b) [0, 1]
cb Backdrop 的预乘颜色 (r, g, b) [0, 1]
cr Blending 后的预乘颜色 (r, g, b) [0, 1]
co 最终输出的预乘颜色 (r, g, b) [0, 1]
αs Source 的 Alpha 值 [0, 1]
αb Backdrop 的 Alpha 值 [0, 1]
αo 输出的 Alpha 值 [0, 1]
Fa Source 的 Porter-Duff 因子 [0, 1]
Fb Backdrop 的 Porter-Duff 因子 [0, 1]

关键符号说明:

  • Cr (Result color):Blending 阶段计算出的中间结果颜色。它是 Backdrop 颜色 Cb 和 Source 颜色 Cs 通过 Blending 函数混合后的结果,会在 Porter-Duff Compositing 阶段作为新的 Source 颜色使用。
  • Fa 和 Fb:Porter-Duff Compositing 的控制因子,决定 Source 和 Backdrop 对最终结果的贡献程度。

颜色值裁剪

合成计算过程中,颜色值可能超出 [0, 1] 范围。需要进行裁剪 (clamp) 处理:

// 裁剪到有效范围
const clamp = (value) => Math.max(0, Math.min(1, value));

Co = clamp(Co);
αo = clamp(αo);

某些 Blending 模式(如 color-dodge)可能产生大于 1 的值,裁剪确保最终输出的颜色在显示设备的有效范围内。

合成模型

图形合成是一个严格的两阶段流程:先进行 Blending 计算重叠区域的颜色混合,再使用 Alpha 合成公式处理 Alpha 通道的影响。本章介绍通用合成公式及其特例 Simple Alpha Compositing。

通用合成公式

完整的合成流程严格按照以下顺序执行:

输入: Source (Cs, αs), Backdrop (Cb, αb)
     ↓
[1. Blending]
     Cr = (1 - αb) × Cs + αb × B(Cb, Cs)
     ↓
[2. Alpha Compositing]
     co = Fa × (Cr × αs) + Fb × (Cb × αb)
     αo = Fa × αs + Fb × αb
     ↓
输出: Result (Co, αo)

第 1 步:Blending

计算混合后的颜色 Cr,对每个颜色通道(R/G/B)应用 Blending 函数 B(Cb, Cs)

// 非预乘形式
Cr = (1 - αb) × Cs + αb × B(Cb, Cs);

B(Cb, Cs) 定义了 Backdrop 和 Source 颜色的混合方式。不同的 Blending 模式对应不同的 B 函数实现:

  • normalB(Cb, Cs) = Cs(无混合,保持 Source 原色)
  • multiplyB(Cb, Cs) = Cb × Cs(正片叠底)
  • screenB(Cb, Cs) = Cb + Cs - Cb × Cs(滤色)

αb 控制 Blending 的影响程度:当 Backdrop 透明(αb=0)时保持 Source 原色,当 Backdrop 不透明(αb=1)时完全混合。

第 2 步:Alpha Compositing

Cr 作为新的 Source 颜色,使用因子 FaFb 控制 Source 和 Backdrop 的贡献程度:

// 预乘形式
co = Fa × (Cr × αs) + Fb × (Cb × αb);
αo = Fa × αs + Fb × αb;

FaFb 是合成因子,通过不同的取值实现不同的合成效果。例如:

  • Fa = 1, Fb = 1 - αs:Source 覆盖 Backdrop(source-over)
  • Fa = αb, Fb = 0:Source 裁剪到 Backdrop 形状(source-in)
  • Fa = 1 - αb, Fb = 1:Backdrop 覆盖 Source(destination-over)

合并公式

将两个步骤合并,得到完整的合成公式:

// 完整的合成公式(预乘形式)
co = Fa × B(Cb, Cs) × αs × αb + Fa × (1 - αb) × cs + Fb × cb;
αo = Fa × αs + Fb × αb;

这个公式清楚地展示了三部分的贡献:

  • Fa × B(Cb, Cs) × αs × αb:Blending 结果对重叠区域的贡献
  • Fa × (1 - αb) × cs:Source 在 Backdrop 透明区域的贡献
  • Fb × cb:Backdrop 根据合成因子的贡献

Simple Alpha Compositing

Simple Alpha Compositing 是通用公式的最常用特例,对应参数:

  • Blending 模式:normal,即 B(Cb, Cs) = Cs
  • 合成因子:Fa = 1, Fb = 1 - αs

代入通用公式得到:

// 预乘形式
co = cs + cb × (1 - αs);
αo = αs + αb × (1 - αs);

这就是默认的图层叠加方式(source-over):Source 以其不透明度覆盖在 Backdrop 上方。当 Source 完全不透明(αs = 1)时,Backdrop 完全被遮盖;当 Source 完全透明(αs = 0)时,输出就是 Backdrop。

关键要点:

  • Blending 在前,Alpha Compositing 在后,顺序不可颠倒
  • Blending 通过函数 B 计算颜色,Alpha Compositing 通过因子 FaFb 控制区域和 Alpha 通道
  • Simple Alpha Compositing 是 B=Cs, Fa=1, Fb=1-αs 的特例
  • 两个阶段通过中间结果 Cr 连接

Porter-Duff Compositing Operators

Porter-Duff Compositing Operators 由 Thomas Porter 和 Tom Duff 在 1984 年提出,定义了通过 Alpha 通道控制图层合成区域的代数运算体系。本章介绍 Porter-Duff 操作符的数学原理、13 种标准操作符以及它们与 Compositing Groups 的交互行为。

操作符原理

Porter-Duff 操作符基于 Alpha 通道的线性合成。核心思想是通过两个因子 FaFb 控制 Source 和 Backdrop 对最终结果的贡献程度:

co = Fa × cs + Fb × cb;
αo = Fa × αs + Fb × αb;

Porter-Duff 理论将两张图像的像素空间分为四个逻辑区域:

  1. Source 独占区域:Source 不透明,Backdrop 透明(αs > 0, αb = 0)
  2. Backdrop 独占区域:Backdrop 不透明,Source 透明(αs = 0, αb > 0)
  3. 重叠区域:两者都不透明(αs > 0, αb > 0)
  4. 空白区域:两者都透明(αs = 0, αb = 0)

为了精确控制这四个区域,FaFb 的取值被限制在与 Alpha 值相关的离散集合中:

Fa ∈ {0, αb, 1 - αb, 1}
Fb ∈ {0, αs, 1 - αs, 1}

这种限制确保了操作符具有明确的几何意义:

  • Fa = 0:Source 不贡献
  • Fa = 1:Source 完全贡献
  • Fa = αb:Source 仅在 Backdrop 存在的区域贡献
  • Fa = 1 - αb:Source 仅在 Backdrop 不存在的区域贡献

Fb 的含义与 Fa 对称。

13 种 Porter-Duff 操作符

从数学上看,FaFb 的取值集合可以产生 4 × 4 = 16 种组合。W3C 规范定义了其中 13 种具有实际意义的操作符:

操作符 Fa Fb 效果描述
clear 0 0 清除所有内容
copy 1 0 仅显示 Source
destination 0 1 仅显示 Backdrop
source-over 1 1 - αs Source 覆盖 Backdrop(默认)
destination-over 1 - αb 1 Backdrop 覆盖 Source
source-in αb 0 Source 裁剪到 Backdrop 形状
destination-in 0 αs Backdrop 裁剪到 Source 形状
source-out 1 - αb 0 Source 在 Backdrop 外的部分
destination-out 0 1 - αs Backdrop 在 Source 外的部分
source-atop αb 1 - αs Source 在 Backdrop 上方(限定在 Backdrop 区域)
destination-atop 1 - αb αs Backdrop 在 Source 上方(限定在 Source 区域)
xor 1 - αb 1 - αs 仅显示非重叠区域
lighter 1 1 相加(需要 clamp)

未定义的 3 种组合(Fa=αb, Fb=αsFa=αb, Fb=1Fa=1, Fb=αs)缺乏明确的语义或实际应用价值,因此被排除在规范之外。

W3C 选择这 13 种操作符的原则是:

  1. 完备性:覆盖所有常见的图像合成需求(覆盖、裁剪、遮罩、异或等)
  2. 对称性:提供 Source 和 Destination 的对称操作(如 source-in 对应 destination-in
  3. 语义清晰:每个操作符都有明确的视觉效果和应用场景

常见应用场景

遮罩效果:使用 source-indestination-in

const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");

// 绘制遮罩形状(圆形)
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();

// 使用 source-in 将图像裁剪到圆形
ctx.globalCompositeOperation = "source-in";
ctx.drawImage(image, 0, 0);

擦除效果:使用 destination-out

// 绘制底图
ctx.drawImage(image, 0, 0);

// 使用 destination-out 擦除
ctx.globalCompositeOperation = "destination-out";
ctx.arc(100, 100, 30, 0, Math.PI * 2);
ctx.fill(); // 擦除圆形区域

发光叠加:使用 lighter

// 绘制第一个光源
ctx.drawImage(light1, 0, 0);

// 使用 lighter 叠加第二个光源
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(light2, 50, 50);

Blending 混合模式

Blending 控制重叠区域的颜色如何混合。与 Porter-Duff Compositing Operators 通过 FaFb 控制区域选择不同,Blending 通过函数 B(Cb, Cs) 改变颜色的计算方式。本章介绍 Blending 函数、与 Alpha Compositing 的结合、可分离和不可分离混合模式,以及 Group isolation 对 Blending 的影响。

Blending 函数

Blending 的核心是 Blending 函数 B(Cb, Cs),它定义了 Backdrop 颜色 Cb 和 Source 颜色 Cs 如何计算出混合后的颜色 Cr

// Blending 公式(非预乘形式)
Cr = (1 - αb) × Cs + αb × B(Cb, Cs);

αb 控制 Blending 函数的影响程度:

  • αb = 0(Backdrop 透明)时,Cr = Cs(保持 Source 原色)
  • αb = 1(Backdrop 不透明)时,Cr = B(Cb, Cs)(完全混合)

不同的 Blending 模式对应不同的 B 函数实现。Blending 模式分为两大类:

  • 可分离混合模式 (Separable Blend Modes)B(Cb, Cs) 对 RGB 每个通道独立计算
  • 不可分离混合模式 (Non-separable Blend Modes)B(Cb, Cs) 需要同时考虑所有颜色通道,涉及 RGB 与 HSL 色彩空间转换

Blending 与 Alpha Compositing

在完整的合成流程中,Blending 计算出 Cr 后,需要与 Porter-Duff Compositing 结合。最常见的简化模式是:Blending 之后进行 source-over 合成

这种模式对应参数:

  • Blending 模式:任意 B(Cb, Cs) 函数(如 multiply、screen 等)
  • Alpha Compositing 因子:Fa = 1, Fb = 1 - αs(source-over)

完整公式为:

// 第 1 步:Blending
Cr = (1 - αb) × Cs + αb × B(Cb, Cs);

// 第 2 步:source-over Alpha Compositing
co = Cr × αs + Cb × αb × (1 - αs);
αo = αs + αb × (1 - αs);

合并为单一公式:

// Blending + source-over 合成(预乘形式)
co = B(Cb, Cs) × αs × αb + (1 - αb) × cs + (1 - αs) × cb;
αo = αs + αb × (1 - αs);

这个公式清楚地展示了三部分的贡献:

  • B(Cb, Cs) × αs × αb:Blending 结果对重叠区域的贡献
  • (1 - αb) × cs:Source 在 Backdrop 透明区域的贡献
  • (1 - αs) × cb:Backdrop 在 Source 透明区域的贡献

这是 Web 平台最常用的合成模式,通过 CSS 的 mix-blend-mode 属性或 Canvas 的 globalCompositeOperation 属性使用。

可分离混合模式

可分离混合模式的 B(Cb, Cs) 函数对每个颜色通道独立应用。

混合模式 B(Cb, Cs) 公式 效果描述 常见用途
normal Cs 无混合 默认
multiply Cb × Cs 正片叠底 阴影、纹理叠加
screen Cb + Cs - Cb × Cs 滤色 发光效果
overlay Cb ≤ 0.5 ? 2×Cb×Cs : 1 - 2×(1-Cb)×(1-Cs) 叠加 图像增强
darken min(Cb, Cs) 变暗 暗部合成
lighten max(Cb, Cs) 变亮 亮部合成
color-dodge Cs ≥ 1 ? 1 : min(1, Cb / (1 - Cs)) 颜色减淡 强光效果
color-burn Cs ≤ 0 ? 0 : 1 - min(1, (1 - Cb) / Cs) 颜色加深 加深效果
hard-light Cs ≤ 0.5 ? 2×Cb×Cs : 1 - 2×(1-Cb)×(1-Cs) 强光 高对比度效果
soft-light 复杂公式(详见 W3C 规范) 柔光 柔光效果
difference |Cb - Cs| 差值 差异检测
exclusion Cb + Cs - 2×Cb×Cs 排除 柔和差异

代码示例:使用 multiply 混合模式

const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");

// 绘制 Backdrop(蓝色矩形)
ctx.fillStyle = "blue";
ctx.fillRect(50, 50, 100, 100);

// 使用 multiply 混合模式绘制 Source(红色圆形)
ctx.globalCompositeOperation = "multiply";
ctx.fillStyle = "red";
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();
// 重叠区域:blue × red = 暗紫色

不可分离混合模式

不可分离混合模式需要在 HSL 色彩空间中操作。辅助函数:

// 计算亮度 (Luminosity)
Lum(C) = 0.3 × C.red + 0.59 × C.green + 0.11 × C.blue;

// 计算饱和度 (Saturation)
Sat(C) = max(C.red, C.green, C.blue) - min(C.red, C.green, C.blue);

// 设置亮度:保持色相和饱和度,修改亮度
SetLum(C, lum);

// 设置饱和度:保持色相和亮度,修改饱和度
SetSat(C, sat);
混合模式 B(Cb, Cs) 公式 效果描述 常见用途
hue SetLum(SetSat(Cs, Sat(Cb)), Lum(Cb)) 使用 Source 色相 色彩调整
saturation SetLum(SetSat(Cb, Sat(Cs)), Lum(Cb)) 使用 Source 饱和度 饱和度调整
color SetLum(Cs, Lum(Cb)) 使用 Source 色相和饱和度 着色效果
luminosity SetLum(Cb, Lum(Cs)) 使用 Source 亮度 亮度调整

Compositing Groups

Compositing Groups 影响元素如何与背景交互。理解 Groups 对于掌握复杂的视觉效果至关重要,它决定了子元素能否看到外部背景,以及 Group 如何作为整体与外部合成。

Group Invariance(组不变性)

Group Invariance 是 Compositing Groups 的核心原则:一个 Group 作为整体与外部 Backdrop 进行合成的结果,应该与 Group 内部的具体实现细节无关。

具体来说,Group Invariance 要求:

  1. Group 内部的元素先相互合成,生成 Group 的最终图像
  2. Group 作为单个图层与外部 Backdrop 进行合成
  3. Group 内部元素的顺序、数量、合成方式对外部不可见

这个原则确保了模块化和封装性。例如,一个包含多个子元素的 <div> 可以作为一个整体与页面其他部分进行合成,而不会因为内部结构变化而影响最终效果。

Group Invariance 的数学表达:

// Group 内部合成
let groupResult = initialBackdrop;
for (const element of groupElements) {
  groupResult = composite(groupResult, element);
}

// Group 作为整体与外部合成
const finalResult = composite(externalBackdrop, groupResult);

组不变性的数学基础

Compositing 操作满足结合律,这是组不变性的数学基础:

// 三个元素 A、B、C
composite(composite(A, B), C) === composite(A, composite(B, C));
// (A + B) + C = A + (B + C)

Simple Alpha Compositing 满足组不变性

对于 Simple Alpha Compositing (source-over),组不变性总是成立,因为其公式是线性的:

// source-over 是线性操作
co = cs + cb × (1 - αs);
αo = αs + αb × (1 - αs);

这意味着:

  • 可以先把 A 和 B 合成,再与 C 合成 → (A + B) + C
  • 也可以先把 B 和 C 合成,再与 A 合成 → A + (B + C)
  • 两种方式结果相同

因为满足结合律,所以可以安全地将元素分组:先在 Group 内部合成,再与外部合成,不会改变最终结果。

Blending 打破组不变性

某些 Blending 模式会打破组不变性,因为 Blending 函数 B(Cb, Cs) 是非线性的。例如 multiply 模式:

B(Cb, Cs) = Cb × Cs;

// 非线性导致不满足结合律
B(B(Ca, Cb), Cc) ≠ B(Ca, B(Cb, Cc));

考虑完整的 Blending 公式:

Cr = (1 - αb) × Cs + αb × B(Cb, Cs);

由于 αbB 的非线性特性,不同的分组方式会导致不同的结果:

// 情况 1:B 作为 Group 先与 C 合成
// B 看到的 Backdrop 是 C,αb 来自 C
groupResult = blend(B, C);
final = blend(groupResult, A);

// 情况 2:B 直接与外部 A 合成
// B 看到的 Backdrop 是 A,αb 来自 A
final = blend(blend(A, B), C);

// 两种情况结果不同

Isolated Groups 的必要性

为了在使用 Blending 时保持确定性,CSS 中设置 mix-blend-mode 会自动创建新的层叠上下文和 Compositing Group:

.element {
  mix-blend-mode: multiply;
  /* 自动创建 Compositing Group,确保混合顺序的确定性 */
}

通过创建 Compositing Group,系统明确定义:

  1. 混合顺序:Group 内的元素按文档顺序依次混合
  2. Backdrop 来源:Blending 函数 B(Cb, Cs) 中的 Cb 来自 Group 的 initialBackdrop
  3. 隔离范围:如果是 Isolated Group,initialBackdrop 为透明,元素只与 Group 内部混合

实际例子:

<div style="background: red;">
  <div style="background: green; mix-blend-mode: multiply;">
    <div style="background: blue; mix-blend-mode: multiply;"></div>
  </div>
</div>
  • 绿色层设置了 multiply,创建 Compositing Group
  • 蓝色层在绿色层的 Group 内,先与绿色混合
  • 绿色 Group 的结果再与红色背景混合
  • 混合顺序:blue → green → red,确定且不可改变

这就引出了 Isolated Groups 和 Non-isolated Groups 的区别:它们的 initialBackdrop 不同,决定了 Group 内元素能否看到外部背景。

Isolated 和 Non-isolated Groups

Isolated Groups 和 Non-isolated Groups 的核心区别在于 initialBackdrop 的取值:

Isolated Groups(隔离组)

初始 Backdrop 为全透明黑色 rgba(0, 0, 0, 0),Group 内部元素看不到外部背景:

let groupResult = { r: 0, g: 0, b: 0, α: 0 }; // 透明背景

for (const element of groupElements) {
  groupResult = composite(groupResult, element);
}

// Group 作为整体与外部合成
const finalResult = composite(externalBackdrop, groupResult);

Non-isolated Groups(非隔离组)

初始 Backdrop 使用外部 Backdrop,Group 内部元素可以看到外部背景:

let groupResult = externalBackdrop; // 使用外部背景

for (const element of groupElements) {
  groupResult = composite(groupResult, element);
}

const finalResult = groupResult;

创建 Isolated Group 的方式

根据 W3C 规范:

  • CSS:所有创建层叠上下文 (stacking context) 的操作都会创建 Isolated Group
  • SVG:opacity、filters、3D transforms、blending、masking 会创建 Isolated Group
  • 显式创建:使用 isolation: isolate 属性

默认情况下,HTML 元素形成 Non-isolated Group。

Isolated vs Non-isolated 对比

假设有一个绿色圆形使用 multiply 混合模式叠加在红色方块和蓝色背景上:

<!-- Non-isolated Group(默认) -->
<div style="background: blue; padding: 50px;">
  <div style="background: red; width: 100px; height: 100px;"></div>
  <div
    style="background: green; border-radius: 50%; width: 80px; height: 80px;
              margin: -60px 0 0 40px; mix-blend-mode: multiply;"
  ></div>
</div>

<!-- Isolated Group -->
<div style="background: blue; padding: 50px;">
  <div style="isolation: isolate;">
    <div style="background: red; width: 100px; height: 100px;"></div>
    <div
      style="background: green; border-radius: 50%; width: 80px; height: 80px;
                margin: -60px 0 0 40px; mix-blend-mode: multiply;"
    ></div>
  </div>
</div>

Non-isolated Group 的合成过程:

// 初始 Backdrop 是蓝色背景
let result = blue;

// 红色方块与蓝色背景合成
result = composite(result, red); // → 蓝色背景上的红色方块

// 绿色圆形使用 multiply 模式与当前结果合成
// 绿色圆形会与红色方块和蓝色背景都发生混合
result = composite_with_blend(result, green, multiply);
// 重叠红色区域:green × red = 暗红色
// 重叠蓝色区域:green × blue = 暗蓝色

Isolated Group 的合成过程:

// Group 内部:初始 Backdrop 是透明
let groupResult = transparent;

// 红色方块与透明背景合成
groupResult = composite(groupResult, red); // → 红色方块(无背景)

// 绿色圆形使用 multiply 模式与红色方块合成
// 绿色圆形只能看到红色方块,看不到外部蓝色背景
groupResult = composite_with_blend(groupResult, green, multiply);
// 重叠区域:green × red = 暗红色
// 非重叠区域:保持原色

// Group 结果与蓝色背景合成
result = composite(blue, groupResult);
// 最终:蓝色背景 + 红色方块 + 暗红色重叠区域(绿色圆形不会与蓝色混合)

关键区别:

  • Non-isolated:绿色圆形与红色方块和蓝色背景都发生 multiply 混合
  • Isolated:绿色圆形只与红色方块发生 multiply 混合,看不到蓝色背景

实际应用

Compositing 和 Blending 技术在 Web 平台的三个主要领域有广泛应用:Canvas 2D API、HTML/CSS、SVG。本章介绍如何在这些平台使用这些技术,以及性能优化建议。

平台能力对比

  • Canvas 2D:同时支持 Porter-Duff 操作符和 Blending 模式
  • CSS:仅支持 Blending 模式(通过 mix-blend-modebackground-blend-mode
  • SVG:通过滤镜元素支持 Porter-Duff 操作符(feComposite)和 Blending 模式(feBlend

重要说明:在所有 Web 平台(Canvas 2D、CSS、SVG)中,Blending 模式都采用 Blending + source-over 的合成方式,即 Blending 计算后使用 source-over 进行 Alpha Compositing(Fa=1, Fb=1-αs)。

Canvas 2D

Canvas 2D 通过 globalCompositeOperation 属性控制 Compositing 和 Blending。

globalCompositeOperation 属性

该属性接受字符串值,指定合成或混合模式。支持的值包括:

  • Porter-Duff 操作符source-over(默认), source-in, source-out, source-atop, destination-over, destination-in, destination-out, destination-atop, lighter, copy, xor, destination
  • Blending 模式multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity

CSS

CSS 仅支持 Blending 模式,不支持 Porter-Duff 操作符。提供以下属性控制 Blending:

mix-blend-mode

控制元素与其 Backdrop 的混合方式。元素会与下方的所有内容进行混合。

  • 语法:mix-blend-mode: <blend-mode>
  • 支持的值:normal(默认), multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity
  • 自动创建 Compositing Group(非 normal 时)

background-blend-mode

控制同一元素的多个背景层之间的混合。多个背景按照从上到下的顺序混合。

  • 语法:background-blend-mode: <blend-mode>
  • 支持的值:与 mix-blend-mode 相同
  • 可以指定多个值,对应多个背景层

isolation

显式创建 Isolated Group,隔离子元素的混合效果。

  • 语法:isolation: auto | isolate
  • auto:默认,不创建隔离组
  • isolate:创建 Isolated Group,子元素的混合不会影响外部背景

SVG

SVG 通过滤镜元素实现 Compositing 和 Blending。

feComposite 元素

实现 Porter-Duff Compositing 操作符。

  • 属性:operator 指定操作符类型
  • 支持的值:over, in, out, atop, xor, lighter, arithmetic
  • inin2 属性指定 Source 和 Backdrop
  • arithmetic 模式支持自定义线性组合公式

feBlend 元素

实现 Blending 混合模式。

  • 属性:mode 指定混合模式
  • 支持的值:normal, multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity
  • inin2 属性指定要混合的两个输入源

Isolated Group 的性能影响

Isolated Group 需要离屏渲染,带来额外的性能开销:

  • 浏览器为 Group 分配离屏缓冲区(offscreen buffer)
  • Group 内容先渲染到离屏缓冲区,再合成到主画布
  • 增加内存占用和 GPU 计算负担

过多或嵌套的 Isolated Group 会显著影响渲染性能。

Canvas 2D

Canvas 2D 是即时模式(immediate mode)绘图 API,不会自动创建 Isolated Group。如需隔离效果,需手动使用离屏 Canvas。

HTML/CSS

根据 W3C 规范 Section 3.2,HTML/CSS 中所有创建层叠上下文(Stacking Context)的属性都会创建 Isolated Group,包括 opacitytransformfiltermix-blend-modeisolation: isolate 等。

SVG

根据 W3C 规范 Section 3.3,SVG 中只有特定操作会创建 Isolated Group:

  • opacity
  • filters(滤镜)
  • 3D transforms(2D transforms 不会创建)
  • blending(混合模式)
  • masking(遮罩)

个人开发者系列-上线即“爆火”?那些掏空你 Cloudflare 额度的虚假繁荣

作者 vueTmp
2026年1月10日 21:31

本系列专为个人开发者打造,核心理念是以最小成本实现最佳效果,主要侧重于免费或低成本的解决方案。旨在为早期独立开发者提供实用参考,助力其在资源有限的情况下开启创业之旅。 下面的内容是对2025年10月的内容记录,希望我趟过的坑,你不再踩。

01 意外的“惊喜”:我的插件网站火了?

我最近用 Nuxt v4 开发了一个网站:VSCode Plugin Toolkit - Professional Plugin Offline Download,主要提供 VS Code 插件的搜索和下载,并将其部署在 Cloudflare Workers 上。这个选择很符合我们一直讨论的“低成本启动”理念——Cloudflare Workers提供每天10万次请求的免费额度,对于早期项目来说应该绰绰有余。

Cloudflare Workers 和 Pages Functions 免费额度:

  • 每个请求最多占用 10 毫秒 CPU 时间;
  • 每天最多 100,000 (UTC+0)。

就在网站上线不久后,我连续收到了三封来自 Cloudflare 的邮件。

邮件预警.png

第一眼看到“您的账户已超过每日请求限制”时,我内心的第一反应竟然是: “天呐,难道我这网站上线即火,流量爆了?”

3 秒过后想想网站没做任何 SEO,也没做过推广的新站能火个 Der ,这么大的自然流量,这显然不正常。难道被挂片了?我拉跨,Cloudflare 也不拉库呀。

2 案发现场:谁在掏空我的免费额度?

Cloudflare Workers 免费版虽然慷慨,但也有严格的限制:每天 10 万次请求 (UTC+0) 。如果请求被透传到后端 Server,每一次都会扣减这个额度。

我赶紧打开日志进行排查,结果让我大跌眼镜。

罪魁祸首一:消失的占位图

在开发插件搜索功能时,我设计了一个逻辑:图片加载中或查询失败时显示一张默认占位图。然而,由于粗心,我忘记将这张图放到 public 静态资源目录中了,导致请求路径不存在。

更糟糕的是,作为 Nuxt 新手,我当时没有配置 error.vue 页面。

在 Nuxt 的机制下,如果一个静态资源请求(如 .png)在静态目录找不到,且没有错误拦截,这个请求就会回源到服务器(Server)

默认图片请求.png

从日志中可以看到,大量的 404 请求涌向了那个并不存在的图片。由于没有缓存,每一次 404 都在实打实地消耗我的 Workers 额度。

图片请求详情.png

罪魁祸首二:勤奋的“不速之客”

除了自己留下的坑,我还发现了一些“不怀好意”的访客。

异常访问信息截图.png

通过日志分析,我发现大量针对 .envphpinfo.phpwp-admin 等路径的扫描请求。这些典型的恶意扫描和爬虫行为,因为我缺少全局错误拦截,导致所有的异常请求全部走到了 Nuxt 的服务器端处理逻辑里。

漏风的窗户(没占位图)+ 敞开的大门(没错误页面)+ 门外的路人(扫描器) ,最终导致我的 10 万次额度在短短几个小时内被彻底掏空。

03 止损方案:最小成本的修复

针对上面两个做了如下修复:

  1. 补全静态资源:将丢失的占位图重新放回 public 文件夹。这样 Cloudflare CDN 会直接拦截并返回静态资源,不再消耗 Workers 的 CPU 计算配额。
  2. 增加 error.vue:这是最关键的一步。在 Nuxt 中增加一个简单的自定义错误页,确保所有非预期路径的访问在前端就被拦截处理,而不是交给后端逻辑去消耗资源。

04 复盘:给个人开发者的避坑指南

这次“虚假繁荣”让我深刻意识到,在利用 Serverless 云服务享受“零成本”部署的同时,必须要注意资源的防御性管理。

  • 不要信任开发环境的“丝滑” :本地开发时,由于网络延迟低,图片缺失可能只是一闪而过,容易被忽略。
  • 路由兜底是刚需:无论是 Nuxt 还是其他框架,上线前请务必确认是否有处理 404 和 500 错误的兜底方案。
  • 监控优于直觉:当看到流量暴涨时,先别急着庆祝,去分析一下日志。

修复这些问题后,网站的请求量迅速回归到了正常水平(周末使用的人相对较少)。

修改后访问流量.png

结语

独立开发的路上,我们不仅要学会构建,更要学会如何“防守”。Cloudflare 是个人开发者的神兵利器,但如果不注意细节,它也会因为额度超限而变得“钝重”。

如果你也正在使用 Nuxt ,记得检查一下你的 404 逻辑!

从零打造AI智能博客:一个项目带你入门全栈与大模型应用开发

作者 SpringLament
2026年1月10日 19:58

写在前面

最近 AI Coding 实在太火了,Cursor、Claude Code 这些工具让写代码变得越来越轻松。你可能也注意到了,这些工具都有一个共同点:在你写代码的时候,它们会实时给你补全建议,按 Tab 就能接受。这种体验太爽了,以至于我想在自己的博客编辑器里也搞一个类似的功能。

与此同时,「全栈开发」和「大模型应用开发」也成了很多人想要学习的方向。

我自己折腾了一个 Next.js 全栈 AI 博客项目,把 Prompt 工程、RAG 知识库、流式输出、AI Copilot 这些东西都实践了一遍。今天想通过这篇文章,把我在这个项目里学到的东西分享出来,希望能帮到想入门这个领域的朋友。

GitHub 地址github.com/flawlessv/S…

🔗 线上地址powder.icu/

本文主要讲述如何实现博客的AI相关功能,想了解基础功能如何实现的同学可以看下Next.js全栈开发从入门到部署实战

先看看这个博客长什么样:

shouye.png

后台仪表盘.png 项目用的技术栈:

  • 前端:Next.js 15 + TypeScript + shadcn/ui + Tailwind CSS
  • 后端:Next.js API Routes + Prisma ORM
  • AI:Kimi API + Ollama + ChromaDB

接下来我会从 Prompt 讲起,然后聊聊 AI Copilot、RAG、流式输出这些功能是怎么实现的。


一切的起点:Prompt

说到大模型应用开发,绑不开的就是 Prompt。

Prompt 是什么? 说白了就是你跟大模型说的话。你怎么问,它就怎么答。问得好,答案就靠谱;问得烂,答案就离谱。

我在做这个项目的时候发现,很多 AI 功能的本质都是一样的:构造一个 Prompt,然后调 LLM API

比如:

  • AI 生成文章标题?Prompt + LLM
  • AI 生成摘要?Prompt + LLM
  • AI 推荐标签?还是 Prompt + LLM

所以想玩好大模型应用,Prompt 工程是必须要会的。

结构化 Prompt

写 Prompt 其实跟写文章差不多,有结构会比乱写好很多。我在项目里用的是一种叫「结构化 Prompt」的写法,大概长这样:

# Role: 你的角色

## Profile

- Author: xxx
- Version: 1.0
- Language: 中文
- Description: 角色描述

## Skills

- 技能1
- 技能2

## Rules

1. 规则1
2. 规则2

## Workflow

1. 第一步做什么
2. 第二步做什么

## OutputFormat

- 输出格式要求

## Initialization

作为 <Role>,严格遵守 <Rules>,按照 <Workflow> 执行任务。

这种写法的好处是逻辑清晰,大模型更容易理解你想要什么。

举个实际的例子,这是我项目里用来生成文章摘要的 Prompt:

export function buildExcerptPrompt(content: string): string {
  return `# Role: 内容摘要撰写专家

## Profile
- Author: Spring Broken AI Blog
- Version: 2.0
- Language: 中文
- Description: 你是一位专业的内容编辑,擅长从长文中提取核心信息,撰写简洁有力的摘要。

## Rules
1. 摘要长度必须严格控制在 100-200 个汉字之间
2. 必须包含文章的核心观点和主要结论
3. 使用简洁、专业的语言,避免冗余表达
4. 只返回摘要文本,不要包含任何其他内容

## Workflow
1. 仔细阅读并理解完整的文章内容
2. 识别文章的核心主题和主要论点
3. 用简洁的语言组织摘要
4. 输出纯文本摘要

## Input
文章内容:
${content.slice(0, 3000)}

## Initialization
作为 <Role>,严格遵守 <Rules>,按照 <Workflow> 撰写摘要。`;
}

你看,其实就是告诉大模型:你是谁、要遵守什么规则、按什么流程做事、输出什么格式。把这些说清楚了,大模型的输出质量会好很多。


AI Copilot:编辑器里的智能补全

这个功能是我觉得最有意思的一个,效果类似 GitHub Copilot 或者 Cursor,在你写文章的时候实时给你补全建议。

AI文章新建和编辑页.png

实现思路

说穿了也不复杂:把文章上下文 + Prompt 丢给 LLM,让它帮你续写

具体流程是这样的:

  1. 用户在编辑器里打字
  2. 我提取光标前 500 个字符作为上下文
  3. 构造一个 Prompt,大意是「根据上下文,续写 5-30 个字」
  4. 调 Kimi API 拿到补全建议
  5. 把建议以灰色斜体显示在光标后面
  6. 用户按 Tab 接受,按 Esc 取消

技术难点

这个功能看起来简单,但实际做起来有几个坑:

1. 非侵入式显示

补全建议不能直接写入文档,只能在视图层显示。

我一开始想的就是用样式来实现——在光标位置叠加一个灰色斜体的文本,看起来像是补全建议,但实际上不是文档的一部分。这个思路是对的,关键是怎么实现。

参考了 VSCode 的做法。VSCode 的 AI 补全(GitHub Copilot)用的是「虚拟文本」机制:补全建议只在视图层显示,不写入文档模型。只有用户按 Tab 确认后,才真正写入。

我用的编辑器是 Tiptap(基于 ProseMirror),刚好有类似的机制叫 Decoration。它可以在视图层叠加显示内容,不影响文档结构,正好符合我的需求。

2. 防抖

用户打字很快的时候,不能每敲一个字就调一次 API,那样太浪费了。我设了 500ms 的防抖,用户停下来半秒钟才触发补全请求。

3. 异步竞态

用户可能在 API 返回之前又继续打字了,这时候光标位置已经变了。如果直接把补全建议显示出来,位置就对不上了。

我的做法是双重位置校验:发请求前记录光标位置,API 返回后再校验一次,位置变了就不显示。

// 第一次校验:防抖回调执行时
const currentState = extension.editor.state;
if (currentSelection.from !== currentFrom) {
  return; // 位置已改变,丢弃请求
}

// 调用 AI API...

// 第二次校验:API 返回后
const latestState = extension.editor.state;
if (latestState.selection.from === currentFrom) {
  // 位置仍然一致,才更新状态
}

4. ProseMirror 插件

编辑器用的是 Tiptap(基于 ProseMirror),补全建议的显示用的是 Decoration,不会影响文档结构,只是视觉上的装饰。

核心代码大概长这样:

// 创建补全建议的视觉装饰
const widget = document.createElement("span");
widget.className = "ai-completion-suggestion";
widget.style.cssText =
  "color: #9ca3af; " + // 灰色
  "font-style: italic; " + // 斜体
  "pointer-events: none; " + // 不拦截鼠标
  "user-select: none;"; // 不可选中

widget.textContent = suggestion;

// 在光标位置显示
const decoration = Decoration.widget(position, widget, {
  side: 1, // 光标后
  ignoreSelection: true,
});

RAG:让 AI 基于你的内容回答问题

RAG 是这个项目里我花时间最多的功能。

先聊聊向量数据库

在讲 RAG 之前,得先说说向量数据库是什么。

我们平时用的数据库,比如 MySQL、MongoDB,存的都是结构化数据或文档。查询的时候用的是精确匹配或者关键词搜索。

但 AI 领域有个问题:怎么找到「语义相似」的内容?比如「如何写好 Prompt」和「Prompt 工程技巧」,这两句话关键词不一样,但意思很接近。传统数据库搞不定这个。

向量数据库就是为了解决这个问题。它的思路是:

  1. 把文本转成一串数字(向量),这个过程叫 Embedding
  2. 语义相似的文本,转出来的向量也相似
  3. 查询的时候,把问题也转成向量,然后找最相似的几个

常见的向量数据库有 Pinecone、Milvus、Chroma 等。我用的是 Chroma,开源免费,轻量好用。

为什么需要 RAG?

大模型虽然很聪明,但它不知道你博客里写了什么。你问它「我之前写的那篇关于 Prompt 的文章讲了什么」,它只能瞎猜。

这是因为大模型的知识有两个问题:

  1. 知识不新:训练数据有截止日期,不知道最新的事
  2. 知识不全:不知道你的私有内容

RAG(Retrieval-Augmented Generation,检索增强生成)就是为了解决这个问题。简单说就是给大模型「开卷考试」:先从你的内容里检索相关信息,再让大模型基于这些信息回答。

cover.gif

我的实现思路

整个流程分两部分:

离线索引(把文章存起来)

  1. 把文章切成小块(语义分块)
  2. 用 Ollama 把每个块转成向量(Embedding)
  3. 把向量存到 ChromaDB

在线检索(用户提问时)

  1. 把用户的问题也转成向量
  2. 在 ChromaDB 里找最相似的几个块
  3. 把这些块作为上下文,构造 Prompt
  4. 调 Kimi API 生成回答

分块的坑

分块这一步踩了不少坑。

一开始我想简单点,按固定字符数切,比如每 500 字一块。结果发现很多问题:句子被截断、段落被分割、检索时匹配到不完整的片段。

后来改成了语义分块,按优先级:

  1. 先按段落分(\n\n
  2. 段落太长就按句子分(。!?
  3. 实在不行才硬切

还有一个坑是 Ollama 的 nomic-embed-text 模型有 800 字符的限制。超过这个长度就报错。

我的处理方式是:如果一个块超过 800 字符,就把它切成多个子块,每个子块单独生成向量,单独存储。这样虽然麻烦点,但不会丢信息。

// 语义分块的核心逻辑
export function chunkPost(content: string, options = {}) {
  const maxChars = options.maxChars || 800; // Ollama 硬限制
  const chunks = [];

  // 按段落分割
  const paragraphs = content.split(/\n\n+/).filter((p) => p.trim());

  for (const para of paragraphs) {
    if (para.length > maxChars) {
      // 段落太长,按句子分割
      splitBySentence(para, chunks);
    } else {
      chunks.push(para);
    }
  }

  return chunks;
}

流式输出:打字机效果

如果你用过 ChatGPT,应该对那个打字机效果有印象。AI 的回答不是一下子全出来,而是一个字一个字蹦出来的。

这个效果不只是好看,更重要的是用户体验。如果等 AI 生成完再返回,用户可能要干等好几秒,体验很差。流式输出让用户立刻看到反馈,感觉响应更快。

实现思路

流式输出的核心是 SSE(Server-Sent Events)

传统的 HTTP 请求是:发请求 → 等待 → 收到完整响应。

SSE 是:发请求 → 保持连接 → 服务器持续推送数据 → 最后关闭连接。

一个请求,多次推送

后端代码大概是这样:

// 创建 SSE 流
const stream = new ReadableStream({
  async start(controller) {
    const sendEvent = (type, data) => {
      const message = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
      controller.enqueue(encoder.encode(message));
    };

    // 调用 Kimi API,流式返回
    await aiClient.chatStream(messages, {}, (chunk) => {
      // 每收到一个文本块,就推送给前端
      sendEvent("chunk", { chunk });
    });

    // 完成后关闭连接
    sendEvent("complete", { done: true });
    controller.close();
  },
});

return new Response(stream, {
  headers: {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  },
});

前端用 fetch + ReadableStream 读取:

const response = await fetch("/api/ai/rag/stream", {
  method: "POST",
  body: JSON.stringify({ question }),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  const text = decoder.decode(value);
  // 解析 SSE 格式,更新 UI
  parseSSE(text, (chunk) => {
    setContent((prev) => prev + chunk); // 追加文本,实现打字机效果
  });
}

其他功能

除了上面说的 AI 功能,项目里还有一些基础功能:

详情.png

aboutme.png

AI 生成标题、摘要、标签

这些都是「Prompt + LLM」的套路。给大模型文章内容,让它生成标题/摘要/标签。

相关文章推荐

用当前文章的标题和摘要生成向量,在 ChromaDB 里找最相似的几篇文章。比传统的「按标签匹配」更智能。

降级机制

RAG 依赖 Ollama 和 ChromaDB,这两个服务挂了怎么办?

我做了降级处理:如果 RAG 不可用,就退化成纯 LLM 模式。虽然回答质量会差一些,但至少功能还能用。


如何获取 LLM API Key

这个项目用的是 Kimi(Moonshot AI)的 API,申请地址:

platform.moonshot.cn/

注册后会有免费额度,个人学习完全够用。

其他可选的 LLM 服务:


快速上手

项目开源在 GitHub,感兴趣的话可以 clone 下来跑一跑:

GitHub 地址github.com/flawlessv/S…

如果觉得有帮助,欢迎给个 ⭐️ Star!

详细的安装步骤在 README 里都有,这里就不展开了。简单说就是:

  1. 安装 Node.js、Ollama、ChromaDB
  2. 配置 Kimi API Key
  3. npm install + npm run dev

总结

做完这个项目,我最大的感受是:大模型应用开发没有想象中那么难

很多功能的本质都是「Prompt + 调 API」,关键是把 Prompt 写好,把流程理清楚。

通过这个项目,你可以学到:

  • Next.js 全栈开发(前端 + 后端 + 数据库)
  • Prompt 工程(结构化 Prompt、角色设定、规则约束)
  • RAG 实现(向量化、语义分块、相似度检索)
  • 流式输出(SSE、ReadableStream)

如果想继续深入,可以看看这些方向:

  • Agent:让 AI 自己规划任务、调用工具
  • MCP:模型上下文协议,统一 AI 与外部系统的交互
  • 微调:用自己的数据训练模型

希望这篇文章对你有帮助。有问题欢迎交流!

附录:参考资料

本项目在开发过程中参考了以下优秀内容:

lecen:一个更好的开源可视化系统搭建项目--数据、请求、寄连对象使用--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人

作者 晴虹
2026年1月10日 19:34

基本定义

前端系统主要由在线编写代码与可视化操作两种方式来构建。页面结构主要通过在线可视化拖拽组合生成,一些页面元素model的绑定及交互事件通过在元素属性面板编辑来操作。

我们提供了多个数据源来获取需要的数据,由于数据类型的不同,我们使用对象来对他们进行分类管理。

从功能性上主要分为下面几类:

属性值、预置函数、寄连、视图、元素、工具类。

由于细分下来属性特别多。因此我们通过不同的对象来对这些值进行访问。

名称 定义 说明
B 基础对象 包含initData,collectionData等
G 全局对象 包含page,menu,user等
R 请求链接对象 包含一些链接调用的方法和属性
P 执行寄连对象 包含一些寄连调取的方法和属性

几乎所有的变量都能够通过这四个对象访问到,每个页面都有自己的 BGRP 对象,除了 G 之外,不同页面之间的 BRP 对象都是相互独立的

这四个对象既可以独立访问,也可以在某个对象中访问另一个对象

页面中所有的地方都能够访问这些对象,所有可以写脚本的地方都能通过 this 来获取这些变量

除了当前页面下的公共对象之外,还有两个对象是只在当前组件的事件中能访问到的属性

名称 定义 说明
describe 当前组件视图配置 包含了渲染该组建所需的所有配置
scopeData 渲染数据 当前作用域插槽的数据

数据对象

initData

该对象主要存储一些初始化数据,一般情况下它的属性值不会发生变化,可以通过寄连、请求的方式进行设定,我们也可以手动指定初始化的数据,供之后访问、比较等操作。

该对象并没有严格的限制说能够存储哪些数据,只是约定好只存储页面初始化数据,或者一些其他的信息等

initData 在页面加载的时候,会默认填充两个属性:formCodeserviceTable,分别表示当前页面的编码和当前页面存储的数据表

initData默认填充

然后我们可以在任意其他位置对它进行设定

比如我们创建一个请求链接,然后在回调函数里面把接口的返回值放到 initData 里面

请求链接的回调函数里面写上 this.B.setInitData(data)

先看下接口的返回值:

接口返回值

给页面添加该接口之后,接口调用完毕会执行回调函数,将数据填充到 initData

initData赋值

collectionData

所有在视图中带有model字段的属性都会被收集到这个对象中,我们也可以给该对象赋值一些临时的其他属性,这样方便在别的地方都可以访问到。

详见 collectionData收集model

requestData

如果请求链接配置了绑定数据字段,那么通过请求返回的数据就会被存储到该对象中,它还包含了两个特殊的属性:

handle:所有请求中如果设定了code,那么就会被保存在该对象中,以备之后手动触发请求。

code:带有权限控制的code可以通过这个对象访问到,我们也可以手动指定code属性。

关于如何将请求链接返回的数据绑定到 collectionData 对象中,以及它们的绑定机制和手动触发请求,可参考 请求链接配置

关于 requestData.code 对象的作用和运行机制,可参考 权限控制

controlData

页面中的所有数据视图都会以code为标识存储在该对象中,我们手动设定的dom和view也会通过它进行访问。

关于组件的 domview 设置规则和它们的使用方式,可参考 元素和视图的引用

setInitData(data)

这是 B 对象暴露出来的用于设置 initData 对象的方法,直接传入需要设置的数据对象即可

setCollectionData(data)

这是 B 对象暴露出来的用于设置 collectionData 对象的方法,直接传入需要设置的数据对象即可

请求链接对象

管理页面中所有的请求链接。

请求链接对象

每个 请求链接 的回调函数中的this都指向这个对象。

除了在请求链接中使用,在页面的其他任何地方都可以通过 this.R 拿到这个对象。

也可以通过这个对象获取到 GPcollectionDatacontrolDatarequestData 等等公共对象。

主要有以下方法:

  • doCallBack:用于执行请求链接返回数据之后的回调函数

  • filterCancel:用于过滤掉被取消的请求

  • getLists:实际发起接口请求获取数据

  • initLists:根据请求链接的配置信息初始化请求

  • pitchRequest:根据请求策略拆分出不同的请求集合

  • prepare:发起实际请求之前的准备

  • request:管理一个请求从开始到结束的整个周期

  • setLists:根据请求链接的配置将返回的数据进行处理

  • trigger:根据请求编码发起请求

除了 preparetrigger 之外,其他方法均属于内部使用的方法,一般用不到

prepare

这是一个请求预处理,主要用来在发起一个请求前做一些逻辑处理,比如设置参数等。

如果页面中的一个请求链接具有 code 值,那么通过调用 prepare 方法,传入对应请求的 code,就会对该请求进行初始化并返回一个 promise

在then方法传递回来的参数是一个对象,其中包含两个属性:requestrun

request 代表当前的请求,可以通过它修改参数或执行其他操作。

run 是一个函数,执行之后将会发起真正的请求。

关于具体的使用方式可参照 请求编码

trigger

根据传入的对应请求的 code 发起请求,主要是用来处理手动触发的请求。

具体的示例可见 请求编码

寄连对象

用来管理页面中的执行寄连

函数 说明
bond 执行策略为before的寄连
doCallBack 自动执行指定的寄连
pitchBond 根据寄连策略拆分出不同的寄连集合
runIt 手动执行指定的寄连

还有一个 handle 的对象属性和一个 prepare 的数组属性

handle 主要用来存放需要手动执行的寄连

prepare 存放了执行策略为before的寄连

RunIt

通过它来手动执行寄连,第一个参数表示要执行的寄连 code,从第二个参数开始,都是要传入寄连函数的参数。

比如现在有一个编码为 getAverage 的寄连,它用来计算多个数的平均数,寄连内容如下:

计算平均数

现在在一个按钮的点击事件里面来调用这个执行寄连

执行寄连

点击按钮之后就会执行该寄连

计算结果

关于手动执行寄连的配置可参考 寄连执行策略

【项目体验】

系统管理端地址www.lecen.top/manage

系统用户端地址www.liudaxianer.com/user

系统文档地址www.lnsstyp.com/web

【前端性能优化】指标篇:卡顿率——如何去定义你的页面卡不卡

2026年1月10日 18:32

最近有大量用户反馈,使用我们的平台实在是太卡了。所以于是总监大手一挥,“咱们这个Q必须得做性能优化,如果用户用咱们的平台都卡、用户怎么可能乐意来消费呢?”

我大声反驳“用户用起来卡是因为用户的电脑太差了,换台性能好点的电脑就不卡了”

———以上是我的幻想

1.jpeg

于是乎在组里大佬的带领下、吭哧吭哧搞了一个Q,就有了性能优化一系列的文章;

事先声明、作为一个刚毕业一年多的前端菜狗、这个东西肯定不是我搞出来的、这个得感谢公司里的前辈、做了完善的监控机制,打好了基础架构;才能让我在前端的海洋里不停溺水又浮起来,然后继续溺水。。。。

如何去定义性能指标?

首先、我们的项目是一个普普通通的web端的项目;那怎么去看一个网页它的性能呢?诶这个时候肯定有同学说了,这个我知道lighthouse来一波,常用的性能指标:FP、FCP、CLS等看一看、再看看白屏时间啥的; 在掘金里搜前端性能优化、大部分都是这个方向的内容;

也不能说这些东西是错的、但是有几个问题:

  1. lighthouse在不同性能的电脑上结果不一样,不能当作一个可以锚定的指标。
  2. 用常用的性能指标来作为标准?这么多个指标怎么去平衡这些指标呢?定一个比例?还是就选取几个?
  3. 其实最重要的是老板可能不知道你的性能指标是什么东西,如果+1或者+2是rd/qa/pm升上去的,你对着老板说我们使用FCP首次内容绘制来定义性能;老板:“啥?你在说什么玩意?” 咱打工人 到最后都是为了满足那个okr嘛,老板都不理解你在搞什么东西,这绩效还要吗。。。

所以有没有办法去整一个指标,既能让非前端人员简单易懂,又能比较能表达性能呢?

有的,兄弟,有的。

2.jpeg

卡顿率

什么是卡顿率?

先来一段文绉绉的定义:卡顿率用于衡量用户在使用页面过程中,画面无法以正常帧率持续渲染的时间占比,是一个反映页面整体流畅性体验的百分比指标。

一句话来概括就是:卡顿率表示用户在可感知使用页面的过程中,有多大比例的时间画面处于“卡住”的状态。

这下其他岗位的同学也能听懂了,就是我在使用某个页面的时间里,有多长时间的占比卡了嘛;

怎么统计卡顿率?

卡顿率的简单分类

目前卡顿率分为三种类型:

  • 时间卡顿率:卡顿时间 / 总观察时间;
    • 卡顿率 = Long Task 总耗时 / 总时长
  • 交互卡顿率:交互的卡顿次数 / 交互总次数;
    • 100 次点击,其中 12 次发生 Long Task,就认为卡顿率 = 12%
  • Task卡顿率
    • Long Task 次数 / 总 Task 次数

目前我们使用的是时间卡顿率、也就是第一种;

卡顿时间的优化

W3C 标准里提出,任何在主线程上执行时间超过 50 毫秒 (ms) 的任务都被定义为 Long Task。 也就是说:单次 task > 50ms 就算长任务,就应该算入卡顿时间。

对吗???

从前端的角度来看,对的对的。但是从用户的感受来看,卡50ms,好像也感觉不出来?

现在屏幕的理想刷新率一般是60帧每秒,1000/60=16.7ms; 50/16.7约等于3帧;于是我们加上了一个前提条件:连续三帧都有长任务并且被完全阻塞,才会算做卡顿。

(一帧能用,两帧流畅,三帧电竞。。。。)

注意点: 这里的有长任务不是说我只要碰到0.1ms都算,而是完全占满才算;

todo:补一张图,拿nano banana跑了几次出来的图都不可用,还得自己画。。 大概长这样:

Frame 0: [0 ----- 16.7] (ms)

Frame 1: [16.7 -- 33.4]

Frame 2: [33.4 -- 50.1]

Frame 3: [50.1 -- 66.8]

Long Task [10ms ------------------------- 61ms] 长任务耗时51ms,可能从frame0的中间开始,frmae3的中间结束

因为我们部分占用并不代表着会影响帧渲染,当然也不一定能保证说剩下的时间就一定够渲染;比如占用6ms,剩下的10ms不能确定能不能完成渲染;目前也没有办法去统计是非完成渲染,所以我们只统计一定被占满的帧,去减少噪音;

情况 渲染统计
帧完全落在 Long Task 内 100% 无法渲染
帧部分落在 Long Task 内 不确定,忽略不计
帧未落在 Long Task 内 不影响

总观察时间

为什么叫做总观察时间而不是总时间,这个也好理解;

如果页面处于前台、用户操作,那必须得记录;

如果用户失焦了、点到了别的标签,这里后台可能会开始执行一些动作比如上报埋点数据、这个时候因为用户看不到,所以就算发生了长任务也不计入;

又或者是进入了下一个界面,当前界面冻结了,过了一会点了回退;中间这段时间就得排除,因为实际上在这段时间里,没有做任何的交互,不做排除会导致分母变大,数据失真;

伪代码部分

那么其实我们要做的就很明显了:

卡顿率 = 页面处于前台、用户可感知期间,画面被连续 ≥3 帧阻塞的时间 / 可感知总时间

核心常量 & 状态

const FRAME_TIME = 16.7;           // 一帧时间
const STALL_FRAMES = 3;            // 连续三帧算卡顿

let longTasks = [];

// 统计页面的可感知时间
let observing = true;
let lastActiveTime = performance.now();
let activeTime = 0;

// 卡顿时间
let stallTime = 0;

// 是否结束统计
let finalized = false;

Long Task的监听

const longTaskObserver = new PerformanceObserver(list => {
  if (!observing) return;

  list.getEntries().forEach(entry => {
    longTasks.push({
      start: entry.startTime,
      duration: entry.duration
    });
  });
});

longTaskObserver.observe({ entryTypes: ['longtask'] });

用Long Task计算出完整占用帧数的时间

function getBlockedFrameCount(task) {
  const startFrame = Math.floor(task.start / FRAME_TIME);
  const endFrame = Math.floor(
    (task.start + task.duration) / FRAME_TIME
  );

  // 只计算完整被占用的帧
  return Math.max(0, endFrame - startFrame - 1);
}

用visibilitychange和pagehide监听,排除标签切走/最小化/页面冻结的时间

document.addEventListener('visibilitychange', () => {
  const now = performance.now();

  if (document.hidden) {
    if (observing) {
      activeTime += now - lastActiveTime;
      observing = false;
    }
  } else {
    lastActiveTime = now;
    observing = true;
  }
});

window.addEventListener('pagehide', event => {
  if (event.persisted) {
    return;
  }
  finalize();
});

window.addEventListener('pageshow', event => {
  if (event.persisted) {
    lastActiveTime = performance.now();
    observing = true;
  }
});

结束统计

function finalize() {
  if (finalized) return;
  finalized = true;

  const now = performance.now();
  if (observing) {
    activeTime += now - lastActiveTime;
  }

  longTasks.forEach(task => {
    const blockedFrames = getBlockedFrameCount(task);
    if (blockedFrames >= STALL_FRAMES) {
      stallTime += blockedFrames * FRAME_TIME;
    }
  });

  const stallRate = activeTime > 0 ? (stallTime / activeTime) * 100 : 0;

  report({
    activeTime,
    stallTime,
    stallRate,
    stallCount: longTasks.filter(
        t => getBlockedFrameCount(t) >= STALL_FRAMES
    ).length
  });

  longTaskObserver.disconnect();
}

结语

其实如果要把卡顿率做一个完整商用的sdk里的某个指标,除了本文提到的内容,缺失的部分还有很多,比如什么时候执行、什么时候发送,等等…… 这里只是搜了一下掘金发现前端性能优化这块没有找到相关内容,因此把在工作中学到的知识,简单总结了一下,感谢阅读。

3.png

每日一题-最大矩形🔴

2026年1月11日 00:00

给定一个仅包含 01 、大小为 rows x cols 的二维二进制矩阵,找出只包含 1 的最大矩形,并返回其面积。

 

示例 1:

输入:matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
输出:6
解释:最大矩形如上图所示。

示例 2:

输入:matrix = [["0"]]
输出:0

示例 3:

输入:matrix = [["1"]]
输出:1

 

提示:

  • rows == matrix.length
  • cols == matrix[0].length
  • 1 <= rows, cols <= 200
  • matrix[i][j]'0''1'

直接调用 84 题代码解决(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2025年6月19日 19:14

回顾一下,这是 84. 柱状图中最大的矩形 的图:

lc84.jpg{:width=400}

对于本题,设 $\textit{matrix}$ 有 $m$ 行,我们可以枚举矩形的底边,做 $m$ 次 84 题。

lc85.png{:width=300}

  • 以第一行为底的柱子高度为 $[1,0,1,0,0]$,最大矩形面积为 $1$。
  • 以第二行为底的柱子高度为 $[2,0,2,1,1]$,最大矩形面积为 $3$。
  • 以第三行为底的柱子高度为 $[3,1,3,2,2]$,最大矩形面积为 $6$。
  • 以第四行为底的柱子高度为 $[4,0,0,3,0]$,最大矩形面积为 $4$。
  • 答案为 $\max(1,3,6,4) = 6$。

由于我们枚举的是矩形的底边,如果 $\textit{matrix}[i][j]=0$,那么没有柱子,高度等于 $0$。否则,在上一行柱子的基础上,把柱子高度增加 $1$。形象地说,就是在柱子下面垫一块石头,把柱子抬高。

###py

class Solution:
    # 84. 柱状图中最大的矩形
    def largestRectangleArea(self, heights: List[int]) -> int:
        st = [-1]  # 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
        ans = 0
        for right, h in enumerate(heights):
            while len(st) > 1 and heights[st[-1]] >= h:
                i = st.pop()  # 矩形的高(的下标)
                left = st[-1]  # 栈顶下面那个数就是 left
                ans = max(ans, heights[i] * (right - left - 1))
            st.append(right)
        return ans

    def maximalRectangle(self, matrix: List[List[str]]) -> int:
        n = len(matrix[0])
        heights = [0] * (n + 1)  # 末尾多一个 0,理由见我 84 题题解
        ans = 0
        for row in matrix:
            # 计算底边为 row 的柱子高度
            for j, c in enumerate(row):
                if c == '0':
                    heights[j] = 0  # 柱子高度为 0
                else:
                    heights[j] += 1  # 柱子高度加一
            ans = max(ans, self.largestRectangleArea(heights))  # 调用 84 题代码
        return ans

###java

class Solution {
    int maximalRectangle(char[][] matrix) {
        int n = matrix[0].length;
        int[] heights = new int[n + 1]; // 末尾多一个 0,理由见我 84 题题解
        int ans = 0;
        for (char[] row : matrix) {
            // 计算底边为 row 的柱子高度
            for (int j = 0; j < n; j++) {
                if (row[j] == '0') {
                    heights[j] = 0; // 柱子高度为 0
                } else {
                    heights[j]++; // 柱子高度加一
                }
            }
            ans = Math.max(ans, largestRectangleArea(heights)); // 调用 84 题代码
        }
        return ans;
    }

    // 84. 柱状图中最大的矩形
    private int largestRectangleArea(int[] heights) {
        int n = heights.length;
        int[] st = new int[n]; // 用数组模拟栈
        int top = -1; // 栈顶下标
        st[++top] = -1; // 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
        int ans = 0;
        for (int right = 0; right < n; right++) {
            int h = heights[right];
            while (top > 0 && heights[st[top]] >= h) {
                int i = st[top--]; // 矩形的高(的下标)
                int left = st[top]; // 栈顶下面那个数就是 left
                ans = Math.max(ans, heights[i] * (right - left - 1));
            }
            st[++top] = right;
        }
        return ans;
    }
}

###cpp

class Solution {
    // 84. 柱状图中最大的矩形
    int largestRectangleArea(vector<int>& heights) {
        stack<int> st;
        st.push(-1); // 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
        int ans = 0;
        for (int right = 0; right < heights.size(); right++) {
            int h = heights[right];
            while (st.size() > 1 && heights[st.top()] >= h) {
                int i = st.top(); // 矩形的高(的下标)
                st.pop();
                int left = st.top(); // 栈顶下面那个数就是 left
                ans = max(ans, heights[i] * (right - left - 1));
            }
            st.push(right);
        }
        return ans;
    }

public:
    int maximalRectangle(vector<vector<char>>& matrix) {
        int n = matrix[0].size();
        vector<int> heights(n + 1); // 末尾多一个 0,理由见我 84 题题解
        int ans = 0;
        for (auto& row : matrix) {
            // 计算底边为 row 的柱子高度
            for (int j = 0; j < n; j++) {
                if (row[j] == '0') {
                    heights[j] = 0; // 柱子高度为 0
                } else {
                    heights[j]++; // 柱子高度加一
                }
            }
            ans = max(ans, largestRectangleArea(heights)); // 调用 84 题代码
        }
        return ans;
    }
};

###c

#define MAX(a, b) ((b) > (a) ? (b) : (a))

// 84. 柱状图中最大的矩形
int largestRectangleArea(int* heights, int n) {
    int* stack = malloc(n * sizeof(int));
    int top = -1; // 栈顶下标
    stack[++top] = -1; // 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
    int ans = 0;
    for (int right = 0; right < n; right++) {
        int h = heights[right];
        while (top > 0 && heights[stack[top]] >= h) {
            int i = stack[top--]; // 矩形的高(的下标)
            int left = stack[top]; // 栈顶下面那个数就是 left
            ans = MAX(ans, heights[i] * (right - left - 1));
        }
        stack[++top] = right;
    }
    free(stack);
    return ans;
}

int maximalRectangle(char** matrix, int matrixSize, int* matrixColSize) {
    int n = matrixColSize[0];
    int* heights = calloc(n + 1, sizeof(int)); // 末尾多一个 0,理由见我 84 题题解
    int ans = 0;
    for (int i = 0; i < matrixSize; i++) {
        char* row = matrix[i];
        // 计算底边为 row 的柱子高度
        for (int j = 0; j < n; j++) {
            if (row[j] == '0') {
                heights[j] = 0; // 柱子高度为 0
            } else {
                heights[j]++; // 柱子高度加一
            }
        }
        ans = MAX(ans, largestRectangleArea(heights, n + 1)); // 调用 84 题代码,注意传入的是 n+1
    }
    free(heights);
    return ans;
}

###go

// 84. 柱状图中最大的矩形
func largestRectangleArea(heights []int) (ans int) {
    st := []int{-1} // 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
    for right, h := range heights {
        for len(st) > 1 && heights[st[len(st)-1]] >= h {
            i := st[len(st)-1] // 矩形的高(的下标)
            st = st[:len(st)-1]
            left := st[len(st)-1] // 栈顶下面那个数就是 left
            ans = max(ans, heights[i]*(right-left-1))
        }
        st = append(st, right)
    }
    return
}

func maximalRectangle(matrix [][]byte) (ans int) {
    heights := make([]int, len(matrix[0])+1) // 末尾多一个 0,理由见我 84 题题解
    for _, row := range matrix {
        // 计算底边为 row 的柱子高度
        for j, c := range row {
            if c == '0' {
                heights[j] = 0 // 柱子高度为 0
            } else {
                heights[j]++ // 柱子高度加一
            }
        }
        ans = max(ans, largestRectangleArea(heights)) // 调用 84 题代码
    }
    return
}

###js

// 84. 柱状图中最大的矩形
var largestRectangleArea = function(heights) {
    const st = [-1]; // 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
    let ans = 0;
    for (let right = 0; right < heights.length; right++) {
        const h = heights[right]
        while (st.length > 1 && heights[st[st.length - 1]] >= h) {
            const i = st.pop(); // 矩形的高(的下标)
            const left = st[st.length - 1]; // 栈顶下面那个数就是 left
            ans = Math.max(ans, heights[i] * (right - left - 1));
        }
        st.push(right);
    }
    return ans;
};

var maximalRectangle = function(matrix) {
    const n = matrix[0].length;
    let heights = Array(n + 1).fill(0); // 末尾多一个 0,理由见我 84 题题解
    let ans = 0;
    for (const row of matrix) {
        // 计算底边为 row 的柱子高度
        for (let j = 0; j < n; j++) {
            if (row[j] === '0') {
                heights[j] = 0; // 柱子高度为 0
            } else {
                heights[j]++; // 柱子高度加一
            }
        }
        ans = Math.max(ans, largestRectangleArea(heights)); // 调用 84 题代码
    }
    return ans;
};

###rust

impl Solution {
    // 84. 柱状图中最大的矩形
    fn largest_rectangle_area(heights: &[i32]) -> i32 {
        let mut st = vec![-1]; // 在栈中只有一个数的时候,栈顶的「下面那个数」是 -1,对应 left[i] = -1 的情况
        let mut ans = 0;
        for (right, &h) in heights.iter().enumerate() {
            let right = right as i32;
            while st.len() > 1 && heights[*st.last().unwrap() as usize] >= h {
                let i = st.pop().unwrap() as usize; // 矩形的高(的下标)
                let left = *st.last().unwrap(); // 栈顶下面那个数就是 left
                ans = ans.max(heights[i] * (right - left - 1));
            }
            st.push(right);
        }
        ans
    }

    pub fn maximal_rectangle(matrix: Vec<Vec<char>>) -> i32 {
        let n = matrix[0].len();
        let mut heights = vec![0; n + 1]; // 末尾多一个 0,理由见我 84 题题解
        let mut ans = 0;
        for row in matrix {
            // 计算底边为 row 的柱子高度
            for (j, c) in row.into_iter().enumerate() {
                if c == '0' {
                    heights[j] = 0; // 柱子高度为 0
                } else {
                    heights[j] += 1; // 柱子高度加一
                }
            }
            ans = ans.max(Self::largest_rectangle_area(&heights)); // 调用 84 题代码
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别为 $\textit{matrix}$ 的行数和列数。做 $m$ 次 84 题,每次 $\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(n)$。

相似题目

见下面单调栈题单的「二、矩形」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

最大矩形

2020年12月25日 23:43

方法一: 使用柱状图的优化暴力方法

思路与算法

最原始地,我们可以列举每个可能的矩形。我们枚举矩形所有可能的左上角坐标和右下角坐标,并检查该矩形是否符合要求。然而该方法的时间复杂度过高,不能通过所有的测试用例,因此我们必须寻找其他方法。

我们首先计算出矩阵的每个元素的左边连续 $1$ 的数量,使用二维数组 $\textit{left}$ 记录,其中 $\textit{left}[i][j]$ 为矩阵第 $i$ 行第 $j$ 列元素的左边连续 $1$ 的数量。

<ppt1,ppt2,ppt3,ppt4,ppt5,ppt6,ppt7,ppt8,ppt9,ppt10>

随后,对于矩阵中任意一个点,我们枚举以该点为右下角的全 $1$ 矩形。

具体而言,当考察以 $\textit{matrix}[i][j]$ 为右下角的矩形时,我们枚举满足 $0 \le k \le i$ 的所有可能的 $k$,此时矩阵的最大宽度就为

$$
\textit{left}[i][j], \textit{left}[i-1][j], \ldots, \textit{left}[k][j]
$$

的最小值。

下图有助于理解。给定每个点的最大宽度,可计算出底端黄色方块的最大矩形面积。

<fig1,fig2,fig3,fig4,fig5,fig6,fig7>

对每个点重复这一过程,就可以得到全局的最大矩形。

我们预计算最大宽度的方法事实上将输入转化成了一系列的柱状图,我们针对每个柱状图计算最大面积。

fig2
{:align="center"}

于是,上述方法本质上是「84. 柱状图中最大的矩形」题中优化暴力算法的复用。

代码

###C++

class Solution {
public:
    int maximalRectangle(vector<vector<char>>& matrix) {
        int m = matrix.size();
        if (m == 0) {
            return 0;
        }
        int n = matrix[0].size();
        vector<vector<int>> left(m, vector<int>(n, 0));

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '1') {
                    left[i][j] = (j == 0 ? 0: left[i][j - 1]) + 1;
                }
            }
        }

        int ret = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '0') {
                    continue;
                }
                int width = left[i][j];
                int area = width;
                for (int k = i - 1; k >= 0; k--) {
                    width = min(width, left[k][j]);
                    area = max(area, (i - k + 1) * width);
                }
                ret = max(ret, area);
            }
        }
        return ret;
    }
};

###Java

class Solution {
    public int maximalRectangle(char[][] matrix) {
        int m = matrix.length;
        if (m == 0) {
            return 0;
        }
        int n = matrix[0].length;
        int[][] left = new int[m][n];

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '1') {
                    left[i][j] = (j == 0 ? 0 : left[i][j - 1]) + 1;
                }
            }
        }

        int ret = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '0') {
                    continue;
                }
                int width = left[i][j];
                int area = width;
                for (int k = i - 1; k >= 0; k--) {
                    width = Math.min(width, left[k][j]);
                    area = Math.max(area, (i - k + 1) * width);
                }
                ret = Math.max(ret, area);
            }
        }
        return ret;
    }
}

###JavaScript

var maximalRectangle = function(matrix) {
    const m = matrix.length;
    if (m === 0) {
        return 0;
    }
    const n = matrix[0].length;
    const left = new Array(m).fill(0).map(() => new Array(n).fill(0));

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (matrix[i][j] === '1') {
                left[i][j] = (j === 0 ? 0 : left[i][j - 1]) + 1;
            }
        }
    }

    let ret = 0;
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (matrix[i][j] === '0') {
                continue;
            }
            let width = left[i][j];
            let area = width;
            for (let k = i - 1; k >= 0; k--) {
                width = Math.min(width, left[k][j]);
                area = Math.max(area, (i - k + 1) * width);
            }
            ret = Math.max(ret, area);
        }
    }
    return ret;
};

###go

func maximalRectangle(matrix [][]byte) (ans int) {
    if len(matrix) == 0 {
        return
    }
    m, n := len(matrix), len(matrix[0])
    left := make([][]int, m)
    for i, row := range matrix {
        left[i] = make([]int, n)
        for j, v := range row {
            if v == '0' {
                continue
            }
            if j == 0 {
                left[i][j] = 1
            } else {
                left[i][j] = left[i][j-1] + 1
            }
        }
    }
    for i, row := range matrix {
        for j, v := range row {
            if v == '0' {
                continue
            }
            width := left[i][j]
            area := width
            for k := i - 1; k >= 0; k-- {
                width = min(width, left[k][j])
                area = max(area, (i-k+1)*width)
            }
            ans = max(ans, area)
        }
    }
    return
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

###C

int maximalRectangle(char** matrix, int matrixSize, int* matrixColSize) {
    int m = matrixSize;
    if (m == 0) {
        return 0;
    }
    int n = matrixColSize[0];
    int left[m][n];
    memset(left, 0, sizeof(left));
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (matrix[i][j] == '1') {
                left[i][j] = (j == 0 ? 0 : left[i][j - 1]) + 1;
            }
        }
    }

    int ret = 0;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (matrix[i][j] == '0') {
                continue;
            }
            int width = left[i][j];
            int area = width;
            for (int k = i - 1; k >= 0; k--) {
                width = fmin(width, left[k][j]);
                area = fmax(area, (i - k + 1) * width);
            }
            ret = fmax(ret, area);
        }
    }
    return ret;
}

###C#

public class Solution {
    public int MaximalRectangle(char[][] matrix) {
        int m = matrix.Length;
        if (m == 0) return 0;
        int n = matrix[0].Length;
        int[][] left = new int[m][];
        for (int i = 0; i < m; i++) {
            left[i] = new int[n];
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '1') {
                    left[i][j] = (j == 0 ? 0 : left[i][j - 1]) + 1;
                }
            }
        }

        int ret = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '0') {
                    continue;
                }
                int width = left[i][j];
                int area = width;
                for (int k = i - 1; k >= 0; k--) {
                    width = Math.Min(width, left[k][j]);
                    area = Math.Max(area, (i - k + 1) * width);
                }
                ret = Math.Max(ret, area);
            }
        }
        return ret;
    }
}

###Python

class Solution:
    def maximalRectangle(self, matrix: list[list[str]]) -> int:
        if not matrix:
            return 0
        m, n = len(matrix), len(matrix[0])
        left = [[0] * n for _ in range(m)]

        for i in range(m):
            for j in range(n):
                if matrix[i][j] == '1':
                    left[i][j] = (0 if j == 0 else left[i][j - 1]) + 1

        ret = 0
        for i in range(m):
            for j in range(n):
                if matrix[i][j] == '0':
                    continue
                width = left[i][j]
                area = width
                for k in range(i - 1, -1, -1):
                    width = min(width, left[k][j])
                    area = max(area, (i - k + 1) * width)
                ret = max(ret, area)
        return ret

###TypeScript

function maximalRectangle(matrix: string[][]): number {
    const m = matrix.length;
    if (m === 0) {
        return 0;
    }
    const n = matrix[0].length;
    const left: number[][] = Array.from({ length: m }, () => Array(n).fill(0));

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (matrix[i][j] === '1') {
                left[i][j] = (j === 0 ? 0 : left[i][j - 1]) + 1;
            }
        }
    }

    let ret = 0;
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (matrix[i][j] === '0') {
                continue;
            }
            let width = left[i][j];
            let area = width;
            for (let k = i - 1; k >= 0; k--) {
                width = Math.min(width, left[k][j]);
                area = Math.max(area, (i - k + 1) * width);
            }
            ret = Math.max(ret, area);
        }
    }
    return ret;
}

###Rust

impl Solution {
    pub fn maximal_rectangle(matrix: Vec<Vec<char>>) -> i32 {
        let m = matrix.len();
        if m == 0 {
            return 0;
        }
        let n = matrix[0].len();
        let mut left = vec![vec![0; n]; m];

        for i in 0..m {
            for j in 0..n {
                if matrix[i][j] == '1' {
                    left[i][j] = if j == 0 { 0 } else { left[i][j - 1] } + 1;
                }
            }
        }

        let mut ret = 0;
        for i in 0..m {
            for j in 0..n {
                if matrix[i][j] == '0' {
                    continue;
                }
                let mut width = left[i][j];
                let mut area = width;
                for k in (0..i).rev() {
                    width = width.min(left[k][j]);
                    area = area.max((i - k + 1) * width);
                }
                ret = ret.max(area);
            }
        }
        ret as i32
    }
}

复杂度分析

  • 时间复杂度:$O(m^2n)$,其中 $m$ 和 $n$ 分别是矩阵的行数和列数。计算 $\textit{left}$ 矩阵需要 $O(mn)$ 的时间。随后对于矩阵的每个点,需要 $O(m)$ 的时间枚举高度。故总的时间复杂度为 $O(mn) + O(mn) \cdot O(m) = O(m^2n)$。

  • 空间复杂度:$O(mn)$,其中 $m$ 和 $n$ 分别是矩阵的行数和列数。我们分配了一个与给定矩阵等大的数组,用于存储每个元素的左边连续 $1$ 的数量。

方法二:单调栈

思路与算法

在方法一中,我们讨论了将输入拆分成一系列的柱状图。为了计算矩形的最大面积,我们只需要计算每个柱状图中的最大面积,并找到全局最大值。

我们可以使用「84. 柱状图中最大的矩形的官方题解」中的单调栈的做法,将其应用在我们生成的柱状图中。

代码

###C++

class Solution {
public:
    int maximalRectangle(vector<vector<char>>& matrix) {
        int m = matrix.size();
        if (m == 0) {
            return 0;
        }
        int n = matrix[0].size();
        vector<vector<int>> left(m, vector<int>(n, 0));

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '1') {
                    left[i][j] = (j == 0 ? 0: left[i][j - 1]) + 1;
                }
            }
        }

        int ret = 0;
        for (int j = 0; j < n; j++) { // 对于每一列,使用基于柱状图的方法
            vector<int> up(m, 0), down(m, 0);

            stack<int> stk;
            for (int i = 0; i < m; i++) {
                while (!stk.empty() && left[stk.top()][j] >= left[i][j]) {
                    stk.pop();
                }
                up[i] = stk.empty() ? -1 : stk.top();
                stk.push(i);
            }
            stk = stack<int>();
            for (int i = m - 1; i >= 0; i--) {
                while (!stk.empty() && left[stk.top()][j] >= left[i][j]) {
                    stk.pop();
                }
                down[i] = stk.empty() ? m : stk.top();
                stk.push(i);
            }

            for (int i = 0; i < m; i++) {
                int height = down[i] - up[i] - 1;
                int area = height * left[i][j];
                ret = max(ret, area);
            }
        }
        return ret;
    }
};

###Java

class Solution {
    public int maximalRectangle(char[][] matrix) {
        int m = matrix.length;
        if (m == 0) {
            return 0;
        }
        int n = matrix[0].length;
        int[][] left = new int[m][n];

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '1') {
                    left[i][j] = (j == 0 ? 0 : left[i][j - 1]) + 1;
                }
            }
        }

        int ret = 0;
        for (int j = 0; j < n; j++) { // 对于每一列,使用基于柱状图的方法
            int[] up = new int[m];
            int[] down = new int[m];

            Deque<Integer> stack = new ArrayDeque<Integer>();
            for (int i = 0; i < m; i++) {
                while (!stack.isEmpty() && left[stack.peek()][j] >= left[i][j]) {
                    stack.pop();
                }
                up[i] = stack.isEmpty() ? -1 : stack.peek();
                stack.push(i);
            }
            stack.clear();
            for (int i = m - 1; i >= 0; i--) {
                while (!stack.isEmpty() && left[stack.peek()][j] >= left[i][j]) {
                    stack.pop();
                }
                down[i] = stack.isEmpty() ? m : stack.peek();
                stack.push(i);
            }

            for (int i = 0; i < m; i++) {
                int height = down[i] - up[i] - 1;
                int area = height * left[i][j];
                ret = Math.max(ret, area);
            }
        }
        return ret;
    }
}

###JavaScript

var maximalRectangle = function(matrix) {
    const m = matrix.length;
    if (m === 0) {
        return 0;
    }
    const n = matrix[0].length;
    const left = new Array(m).fill(0).map(() => new Array(n).fill(0));

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (matrix[i][j] === '1') {
                left[i][j] = (j === 0 ? 0 : left[i][j - 1]) + 1;
            }
        }
    }

    let ret = 0;
    for (let j = 0; j < n; j++) { // 对于每一列,使用基于柱状图的方法
        const up = new Array(m).fill(0);
        const down = new Array(m).fill(0);

        let stack = new Array();
        for (let i = 0; i < m; i++) {
            while (stack.length && left[stack[stack.length - 1]][j] >= left[i][j]) {
                stack.pop();
            }
            up[i] = stack.length === 0 ? -1 : stack[stack.length - 1];
            stack.push(i);
        }
        stack = [];
        for (let i = m - 1; i >= 0; i--) {
            while (stack.length && left[stack[stack.length - 1]][j] >= left[i][j]) {
                stack.pop();
            }
            down[i] = stack.length === 0 ? m : stack[stack.length - 1];
            stack.push(i);
        }

        for (let i = 0; i < m; i++) {
            const height = down[i] - up[i] - 1;
            const area = height * left[i][j];
            ret = Math.max(ret, area);
        }
    }
    return ret;
};

###go

func maximalRectangle(matrix [][]byte) (ans int) {
    if len(matrix) == 0 {
        return
    }
    m, n := len(matrix), len(matrix[0])
    left := make([][]int, m)
    for i, row := range matrix {
        left[i] = make([]int, n)
        for j, v := range row {
            if v == '0' {
                continue
            }
            if j == 0 {
                left[i][j] = 1
            } else {
                left[i][j] = left[i][j-1] + 1
            }
        }
    }
    for j := 0; j < n; j++ { // 对于每一列,使用基于柱状图的方法
        up := make([]int, m)
        down := make([]int, m)
        stk := []int{}
        for i, l := range left {
            for len(stk) > 0 && left[stk[len(stk)-1]][j] >= l[j] {
                stk = stk[:len(stk)-1]
            }
            up[i] = -1
            if len(stk) > 0 {
                up[i] = stk[len(stk)-1]
            }
            stk = append(stk, i)
        }
        stk = nil
        for i := m - 1; i >= 0; i-- {
            for len(stk) > 0 && left[stk[len(stk)-1]][j] >= left[i][j] {
                stk = stk[:len(stk)-1]
            }
            down[i] = m
            if len(stk) > 0 {
                down[i] = stk[len(stk)-1]
            }
            stk = append(stk, i)
        }
        for i, l := range left {
            height := down[i] - up[i] - 1
            area := height * l[j]
            ans = max(ans, area)
        }
    }
    return
}

func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

###C

int maximalRectangle(char** matrix, int matrixSize, int* matrixColSize) {
    int m = matrixSize;
    if (m == 0) {
        return 0;
    }
    int n = matrixColSize[0];
    int left[m][n];
    memset(left, 0, sizeof(left));
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (matrix[i][j] == '1') {
                left[i][j] = (j == 0 ? 0 : left[i][j - 1]) + 1;
            }
        }
    }

    int ret = 0;
    for (int j = 0; j < n; j++) {  // 对于每一列,使用基于柱状图的方法
        int up[m], down[m];
        memset(up, 0, sizeof(up));
        memset(down, 0, sizeof(down));
        int stk[m], top = 0;
        for (int i = 0; i < m; i++) {
            while (top > 0 && left[stk[top - 1]][j] >= left[i][j]) {
                top--;
            }
            up[i] = top == 0 ? -1 : stk[top - 1];
            stk[top++] = i;
        }
        top = 0;
        for (int i = m - 1; i >= 0; i--) {
            while (top > 0 && left[stk[top - 1]][j] >= left[i][j]) {
                top--;
            }
            down[i] = top == 0 ? m : stk[top - 1];
            stk[top++] = i;
        }

        for (int i = 0; i < m; i++) {
            int height = down[i] - up[i] - 1;
            int area = height * left[i][j];
            ret = fmax(ret, area);
        }
    }
    return ret;
}

###C#

public class Solution {
    public int MaximalRectangle(char[][] matrix) {
        int m = matrix.Length;
        if (m == 0) return 0;
        int n = matrix[0].Length;
        int[][] left = new int[m][];
        for (int i = 0; i < m; i++) {
            left[i] = new int[n];
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == '1') {
                    left[i][j] = (j == 0 ? 0 : left[i][j - 1]) + 1;
                }
            }
        }

        int ret = 0;
        for (int j = 0; j < n; j++) {
            int[] up = new int[m];
            int[] down = new int[m];
            Stack<int> stk = new Stack<int>();

            for (int i = 0; i < m; i++) {
                while (stk.Count > 0 && left[stk.Peek()][j] >= left[i][j])
                    stk.Pop();
                up[i] = stk.Count == 0 ? -1 : stk.Peek();
                stk.Push(i);
            }

            stk.Clear();
            for (int i = m - 1; i >= 0; i--) {
                while (stk.Count > 0 && left[stk.Peek()][j] >= left[i][j])
                    stk.Pop();
                down[i] = stk.Count == 0 ? m : stk.Peek();
                stk.Push(i);
            }

            for (int i = 0; i < m; i++) {
                int height = down[i] - up[i] - 1;
                ret = Math.Max(ret, height * left[i][j]);
            }
        }
        return ret;
    }
}

###Python

class Solution:
    def maximalRectangle(self, matrix: List[List[str]]) -> int:
        if not matrix:
            return 0
        m, n = len(matrix), len(matrix[0])
        left = [[0] * n for _ in range(m)]

        for i in range(m):
            for j in range(n):
                if matrix[i][j] == '1':
                    left[i][j] = (0 if j == 0 else left[i][j - 1]) + 1

        ret = 0
        for j in range(n):
            up = [0] * m
            down = [0] * m
            stk = []

            for i in range(m):
                while stk and left[stk[-1]][j] >= left[i][j]:
                    stk.pop()
                up[i] = stk[-1] if stk else -1
                stk.append(i)

            stk = []
            for i in range(m - 1, -1, -1):
                while stk and left[stk[-1]][j] >= left[i][j]:
                    stk.pop()
                down[i] = stk[-1] if stk else m
                stk.append(i)

            for i in range(m):
                height = down[i] - up[i] - 1
                ret = max(ret, height * left[i][j])

        return ret

###TypeScript

function maximalRectangle(matrix: string[][]): number {
    const m = matrix.length;
    if (m === 0) {
        return 0;
    }
    const n = matrix[0].length;
    const left: number[][] = Array.from({ length: m }, () => Array(n).fill(0));

    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (matrix[i][j] === '1') {
                left[i][j] = (j === 0 ? 0 : left[i][j - 1]) + 1;
            }
        }
    }

    let ret = 0;
    for (let j = 0; j < n; j++) {
        const up: number[] = new Array(m);
        const down: number[] = new Array(m);
        const stk: number[] = [];

        for (let i = 0; i < m; i++) {
            while (stk.length && left[stk[stk.length - 1]][j] >= left[i][j]) {
                stk.pop();
            }
            up[i] = stk.length ? stk[stk.length - 1] : -1;
            stk.push(i);
        }

        stk.length = 0;
        for (let i = m - 1; i >= 0; i--) {
            while (stk.length && left[stk[stk.length - 1]][j] >= left[i][j]) {
                stk.pop();
            }
            down[i] = stk.length ? stk[stk.length - 1] : m;
            stk.push(i);
        }

        for (let i = 0; i < m; i++) {
            const height = down[i] - up[i] - 1;
            ret = Math.max(ret, height * left[i][j]);
        }
    }

    return ret;
}

###Rust

impl Solution {
    pub fn maximal_rectangle(matrix: Vec<Vec<char>>) -> i32 {
        let m = matrix.len();
        if m == 0 {
            return 0;
        }
        let n = matrix[0].len();
        let mut left = vec![vec![0; n]; m];

        for i in 0..m {
            for j in 0..n {
                if matrix[i][j] == '1' {
                    left[i][j] = if j == 0 { 0 } else { left[i][j - 1] } + 1;
                }
            }
        }

        let mut ret = 0;
        for j in 0..n {
            let mut up = vec![0; m];
            let mut down = vec![0; m];
            let mut stk: Vec<usize> = Vec::new();

            for i in 0..m {
                while let Some(&top) = stk.last() {
                    if left[top][j] >= left[i][j] {
                        stk.pop();
                    } else {
                        break;
                    }
                }
                up[i] = stk.last().map(|&x| x as i32).unwrap_or(-1);
                stk.push(i);
            }

            stk.clear();
            for i in (0..m).rev() {
                while let Some(&top) = stk.last() {
                    if left[top][j] >= left[i][j] {
                        stk.pop();
                    } else {
                        break;
                    }
                }
                down[i] = stk.last().copied().unwrap_or(m);
                stk.push(i);
            }

            for i in 0..m {
                let height = down[i] - up[i] as usize - 1;
                ret = ret.max(height * left[i][j]);
            }
        }

        ret as i32
    }
}

读者可以自行比对上面的代码与此前第 84 题的代码的相似之处。

复杂度分析

  • 时间复杂度:$O(mn)$,其中 $m$ 和 $n$ 分别是矩阵的行数和列数。计算 $\textit{left}$ 矩阵需要 $O(mn)$ 的时间;对每一列应用柱状图算法需要 $O(m)$ 的时间,一共需要 $O(mn)$ 的时间。

  • 空间复杂度:$O(mn)$,其中 $m$ 和 $n$ 分别是矩阵的行数和列数。我们分配了一个与给定矩阵等大的数组,用于存储每个元素的左边连续 $1$ 的数量。

详细通俗的思路分析,多解法

作者 windliang
2019年6月18日 18:36

题目描述(困难难度)

image.png

给一个只有 0 和 1 的矩阵,输出一个最大的矩形的面积,这个矩形里边只含有 1。

解法一 暴力破解

遍历每个点,求以这个点为矩阵右下角的所有矩阵面积。如下图的两个例子,橙色是当前遍历的点,然后虚线框圈出的矩阵是其中一个矩阵。

image.png

怎么找出这样的矩阵呢?如下图,如果我们知道了以这个点结尾的连续 1 的个数的话,问题就变得简单了。

image.png

  1. 首先求出高度是 1 的矩形面积,也就是它自身的数,如图中橙色的 4,面积就是 4。

  2. 然后向上扩展一行,高度增加一,选出当前列最小的数字,作为矩阵的宽,求出面积,对应上图的矩形框。

  3. 然后继续向上扩展,重复步骤 2。

按照上边的方法,遍历所有的点,求出所有的矩阵就可以了。

以橙色的点为右下角,高度为 1。

image.png

高度为 2。

image.png

高度为 3。

image.png

###java

public int maximalRectangle(char[][] matrix) {
    if (matrix.length == 0) {
        return 0;
    }
    //保存以当前数字结尾的连续 1 的个数
    int[][] width = new int[matrix.length][matrix[0].length];
    int maxArea = 0;
    //遍历每一行
    for (int row = 0; row < matrix.length; row++) {
        for (int col = 0; col < matrix[0].length; col++) {
            //更新 width
            if (matrix[row][col] == '1') {
                if (col == 0) {
                    width[row][col] = 1;
                } else {
                    width[row][col] = width[row][col - 1] + 1;
                }
            } else {
                width[row][col] = 0;
            }
            //记录所有行中最小的数
            int minWidth = width[row][col];
            //向上扩展行
            for (int up_row = row; up_row >= 0; up_row--) {
                int height = row - up_row + 1;
                //找最小的数作为矩阵的宽
                minWidth = Math.min(minWidth, width[up_row][col]);
                //更新面积
                maxArea = Math.max(maxArea, height * minWidth);
            }
        }
    }
    return maxArea;
}

时间复杂度:O(m²n)。

空间复杂度:O(mn)。

解法二

接下来的解法,会让这道题变得异常简单。还记得84 题吗?求一个直方图矩形的最大面积。

image.png

大家可以先做84 题,然后回来考虑这道题。

再想一下这个题,看下边的橙色的部分,这完全就是上一道题呀!

image.png

算法有了,就是求出每一层的 heights[] 然后传给上一题的函数就可以了。

利用上一题的栈解法。

###java

public int maximalRectangle(char[][] matrix) {
    if (matrix.length == 0) {
        return 0;
    }
    int[] heights = new int[matrix[0].length];
    int maxArea = 0;
    for (int row = 0; row < matrix.length; row++) {
        //遍历每一列,更新高度
        for (int col = 0; col < matrix[0].length; col++) {
            if (matrix[row][col] == '1') {
                heights[col] += 1;
            } else {
                heights[col] = 0;
            }
        }
        //调用上一题的解法,更新函数
        maxArea = Math.max(maxArea, largestRectangleArea(heights));
    }
    return maxArea;
}

public int largestRectangleArea(int[] heights) {
    int maxArea = 0;
    Stack<Integer> stack = new Stack<>();
    int p = 0;
    while (p < heights.length) {
        //栈空入栈
        if (stack.isEmpty()) {
            stack.push(p);
            p++;
        } else {
            int top = stack.peek();
            //当前高度大于栈顶,入栈
            if (heights[p] >= heights[top]) {
                stack.push(p);
                p++;
            } else {
                //保存栈顶高度
                int height = heights[stack.pop()];
                //左边第一个小于当前柱子的下标
                int leftLessMin = stack.isEmpty() ? -1 : stack.peek();
                //右边第一个小于当前柱子的下标
                int RightLessMin = p;
                //计算面积
                int area = (RightLessMin - leftLessMin - 1) * height;
                maxArea = Math.max(area, maxArea);
            }
        }
    }
    while (!stack.isEmpty()) {
        //保存栈顶高度
        int height = heights[stack.pop()];
        //左边第一个小于当前柱子的下标
        int leftLessMin = stack.isEmpty() ? -1 : stack.peek();
        //右边没有小于当前高度的柱子,所以赋值为数组的长度便于计算
        int RightLessMin = heights.length;
        int area = (RightLessMin - leftLessMin - 1) * height;
        maxArea = Math.max(area, maxArea);
    }
    return maxArea;
}

时间复杂度:O(mn)。

空间复杂度:O(n)。

利用上一题的解法四。

###java

public int maximalRectangle(char[][] matrix) {
    if (matrix.length == 0) {
        return 0;
    }
    int[] heights = new int[matrix[0].length];
    int maxArea = 0;
    for (int row = 0; row < matrix.length; row++) {
        //遍历每一列,更新高度
        for (int col = 0; col < matrix[0].length; col++) {
            if (matrix[row][col] == '1') {
                heights[col] += 1;
            } else {
                heights[col] = 0;
            }
        }
        //调用上一题的解法,更新函数
        maxArea = Math.max(maxArea, largestRectangleArea(heights));
    }
    return maxArea;
}

public int largestRectangleArea(int[] heights) {
    if (heights.length == 0) {
        return 0;
    }
    int[] leftLessMin = new int[heights.length];
    leftLessMin[0] = -1;
    for (int i = 1; i < heights.length; i++) {
        int l = i - 1;
        while (l >= 0 && heights[l] >= heights[i]) {
            l = leftLessMin[l];
        }
        leftLessMin[i] = l;
    }

    int[] rightLessMin = new int[heights.length];
    rightLessMin[heights.length - 1] = heights.length;
    for (int i = heights.length - 2; i >= 0; i--) {
        int r = i + 1;
        while (r <= heights.length - 1 && heights[r] >= heights[i]) {
            r = rightLessMin[r];
        }
        rightLessMin[i] = r;
    }
    int maxArea = 0;
    for (int i = 0; i < heights.length; i++) {
        int area = (rightLessMin[i] - leftLessMin[i] - 1) * heights[i];
        maxArea = Math.max(area, maxArea);
    }
    return maxArea;
}

时间复杂度:O(mn)。

空间复杂度:O(n)。

解法三

解法二中套用的栈的解法,我们其实可以不用调用函数,而是把栈糅合到原来求 heights 中。因为栈的话并不是一次性需要所有的高度,所以可以求出一个高度,然后就操作栈。

###java

public int maximalRectangle(char[][] matrix) {
    if (matrix.length == 0) {
        return 0;
    }
    int[] heights = new int[matrix[0].length + 1]; //小技巧后边讲
    int maxArea = 0;
    for (int row = 0; row < matrix.length; row++) {
        Stack<Integer> stack = new Stack<Integer>();
        heights[matrix[0].length] = 0;
        //每求一个高度就进行栈的操作
        for (int col = 0; col <= matrix[0].length; col++) {
            if (col < matrix[0].length) { //多申请了 1 个元素,所以要判断
                if (matrix[row][col] == '1') {
                    heights[col] += 1;
                } else {
                    heights[col] = 0;
                }
            }
            if (stack.isEmpty() || heights[col] >= heights[stack.peek()]) {
                stack.push(col);
            } else {
                //每次要判断新的栈顶是否高于当前元素
                while (!stack.isEmpty() && heights[col] < heights[stack.peek()]) {
                    int height = heights[stack.pop()];
                    int leftLessMin = stack.isEmpty() ? -1 : stack.peek();
                    int RightLessMin = col;
                    int area = (RightLessMin - leftLessMin - 1) * height;
                    maxArea = Math.max(area, maxArea);
                }
                stack.push(col);
            }
        }

    }
    return maxArea;
}

时间复杂度:O(mn)。

空间复杂度:O(n)。

里边有一个小技巧,84 题的栈解法中,我们用了两个 while 循环,第二个 while 循环用来解决遍历完元素栈不空的情况。其实,我们注意到两个 while 循环的逻辑完全一样的。所以我们可以通过一些操作,使得遍历结束后,依旧进第一个 while 循环,从而剩下了第 2 个 while 循环,代码看起来会更简洁。

那就是 heights 多申请一个元素,赋值为 0。这样最后一次遍历的时候,栈顶肯定会大于当前元素,所以就进入了第一个 while 循环。

解法四 动态规划

解法二中,用了 84 题的两个解法,解法三中我们把栈糅合进了原算法,那么另一种可以一样的思路吗?不行!因为栈不要求所有的高度,可以边更新,边处理。而另一种,是利用两个数组, leftLessMin [ ] 和 rightLessMin [ ]。而这两个数组的更新,是需要所有高度的。

解法二中,我们更新一次 heights,就利用之前的算法,求一遍 leftLessMin [ ] 和 rightLessMin [ ],然后更新面积。而其实,我们求 leftLessMin [ ] 和 rightLessMin [ ] 可以利用之前的 leftLessMin [ ] 和 rightLessMin [ ] 来更新本次的。

我们回想一下 leftLessMin [ ] 和 rightLessMin [ ] 的含义, leftLessMin [ i ] 代表左边第一个比当前柱子矮的下标,如下图橙色柱子时当前遍历的柱子。rightLessMin [ ] 时右边第一个。

image.png

left 和 right 是对称关系,下边只考虑 left 的求法。

如下图,如果当前新增的层全部是 1,当然这时最完美的情况,那么 leftLessMin [ ] 根本不需要改变。

image.png

然而事实是残酷的,一定会有 0 的出现。

image.png

我们考虑最后一个柱子的更新。上一层的 leftLessMin = 1,也就是蓝色 0 的位置是第一个比它低的柱子。但是在当前层,由于中间出现了 0。所以不再是之前的 leftLessMin ,而是和上次出现 0 的位置进行比较(因为 0 一定比当前柱子小),谁的下标大,更接近当前柱子,就选择谁。上图中出现 0 的位置是 2,之前的 leftLessMin 是 1,选一个较大的,那就是 2 了。

###java

public int maximalRectangle4(char[][] matrix) {
    if (matrix.length == 0) {
        return 0;
    }
    int maxArea = 0;
    int cols = matrix[0].length;
    int[] leftLessMin = new int[cols];
    int[] rightLessMin = new int[cols];
    Arrays.fill(leftLessMin, -1); //初始化为 -1,也就是最左边
    Arrays.fill(rightLessMin, cols); //初始化为 cols,也就是最右边
    int[] heights = new int[cols];
    for (int row = 0; row < matrix.length; row++) {
        //更新所有高度
        for (int col = 0; col < cols; col++) {
            if (matrix[row][col] == '1') {
                heights[col] += 1;
            } else {
                heights[col] = 0;
            }
        }
//更新所有leftLessMin
        int boundary = -1; //记录上次出现 0 的位置
        for (int col = 0; col < cols; col++) {
            if (matrix[row][col] == '1') {
                //和上次出现 0 的位置比较
                leftLessMin[col] = Math.max(leftLessMin[col], boundary);
            } else {
                //当前是 0 代表当前高度是 0,所以初始化为 -1,防止对下次循环的影响
                leftLessMin[col] = -1; 
                //更新 0 的位置
                boundary = col;
            }
        }
        //右边同理
        boundary = cols;
        for (int col = cols - 1; col >= 0; col--) {
            if (matrix[row][col] == '1') {
                rightLessMin[col] = Math.min(rightLessMin[col], boundary);
            } else {
                rightLessMin[col] = cols;
                boundary = col;
            }
        }

        //更新所有面积
        for (int col = cols - 1; col >= 0; col--) {
            int area = (rightLessMin[col] - leftLessMin[col] - 1) * heights[col];
            maxArea = Math.max(area, maxArea);
        }

    }
    return maxArea;

}

时间复杂度:O(mn)。

空间复杂度:O(n)。

有时候,如果可以把题抽象到已解决的问题当中去,可以大大的简化问题,很酷!

Incremark Solid 版本上线:Vue/React/Svelte/Solid 四大框架,统一体验

作者 king王一帅
2026年1月11日 03:17

Incremark 现已支持 Solid,至此完成了对 Vue、React、Svelte、Solid 四大主流前端框架的全面覆盖。

为什么要做框架无关

市面上大多数 Markdown 渲染库都是针对特定框架开发的。这带来几个问题:

  1. 重复造轮子:每个框架社区都在独立实现相似的功能
  2. 能力不一致:不同框架的实现质量参差不齐
  3. 团队切换成本:换框架意味着重新学习新的 API

Incremark 采用不同的思路:核心逻辑与 UI 框架完全解耦

@incremark/core 负责所有解析、转换、增量更新的工作,输出的是框架无关的数据结构。各框架包(@incremark/vue@incremark/react@incremark/svelte@incremark/solid)只需要把这些数据渲染成对应框架的组件即可。

这意味着:

  • 核心能力一次实现,四个框架同时受益
  • Bug 修复和性能优化自动同步到所有框架
  • API 设计保持高度一致,切换框架几乎零学习成本

包结构

┌───────────────────────────────┐
│       @incremark/core         │
│                               │
│  增量解析 · 双引擎 · 插件系统  │
└───────────────┬───────────────┘
                │
                ▼
┌───────────────────────────────┐
│  @incremark/vue               │
│  @incremark/react             │
│  @incremark/svelte            │
│  @incremark/solid  ← NEW      │
└───────────────┬───────────────┘
                │
                ▼
┌───────────────────────────────┐
│       @incremark/theme        │
│                               │
│     样式 · 主题 · 代码高亮     │
└───────────────────────────────┘

增量解析

传统 Markdown 渲染器在流式场景下存在性能问题:每次新内容到达都要重新解析整个文档,复杂度是 O(n²)。

Incremark 只处理新增内容,已解析的块不再重复处理,复杂度降至 O(n)。

四个框架的用法对比

四个框架的组件 API 完全一致,只是语法风格不同:

Vue

<script setup>
import { IncremarkContent } from '@incremark/vue'
// ...
</script>

<template>
  <IncremarkContent :content="content" :is-finished="isFinished" />
</template>

React

import { IncremarkContent } from '@incremark/react'
// ...

<IncremarkContent content={content} isFinished={isFinished} />

Svelte

<script>
import { IncremarkContent } from '@incremark/svelte'
// ...
</script>

<IncremarkContent content={content} isFinished={isFinished} />

Solid

import { IncremarkContent } from '@incremark/solid'
// ...

<IncremarkContent content={content()} isFinished={isFinished()} />

可以看到,除了各框架本身的响应式语法差异(Vue 的 ref、React 的 useState、Svelte 的 $state、Solid 的 createSignal),组件的使用方式完全统一。

在线演示

链接

MIT 许可证。

昨天 — 2026年1月10日技术

前端必备动态规划的10道经典题目

作者 颜酱
2026年1月10日 21:36

前端必备动态规划:10道经典题目详解(DP五部曲实战)

动态规划是前端算法面试的高频考点。本文通过「DP五部曲」框架,手把手带你掌握10道前端必备的DP题目,从基础递推到背包问题,每道题都包含详细注释、易错点分析和前端实际应用场景。

动态规划零基础的可以先补下

一、动态规划五部曲(核心框架)

无论什么DP问题,都可以按以下5个步骤拆解,这是解决DP问题的「万能钥匙」:

  1. 确定dp数组及下标的含义:明确 dp[i](或二维 dp[i][j])代表什么物理意义(比如"第i阶台阶的爬法数")
  2. 确定递推公式:找到 dp[i] 与子问题 dp[i-1]/dp[i-2] 等的依赖关系(核心)
  3. dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值
  4. 确定遍历顺序:保证计算 dp[i] 时,其依赖的子问题已经被计算完成
  5. 打印dp数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)

下面结合具体问题,逐一实战这套框架。


二、入门级(3道,理解DP核心三步法,必刷)

1. LeetCode70. 爬楼梯 ★

题目链接70. 爬楼梯

难度:简单

核心:单状态转移,入门必做,会基础版 + 空间优化版

前端场景:步数计算、递归转迭代优化、分页器跳转步数计算、游戏角色移动路径数计算

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2

输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
DP五部曲分析
  1. dp数组含义dp[i] 表示爬到第i阶台阶的不同方法数
  2. 递推公式dp[i] = dp[i-1] + dp[i-2](到第i阶的方法=到i-1阶爬1步 + 到i-2阶爬2步)
  3. 初始化dp[1] = 1(1阶只有1种方法),dp[2] = 2(2阶有2种方法)
  4. 遍历顺序:从左到右(i从3到n)
  5. 打印验证:遍历过程中打印dp[i],验证方法数是否符合预期
完整版代码(二维DP思想,但实际用一维数组)
/**
 * LeetCode70. 爬楼梯
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function climbStairs(n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示爬到第i阶台阶的不同方法数
  const dp = new Array(n + 1);

  // 【步骤3】dp数组如何初始化
  // 边界条件:1阶只有1种方法,2阶有2种方法
  if (n === 1) return 1;
  if (n === 2) return 2;

  dp[1] = 1; // 1阶:只有1种方法(直接爬1阶)
  dp[2] = 2; // 2阶:有2种方法(1+1 或 2)

  // 【步骤4】确定遍历顺序:从左到右,保证计算dp[i]时dp[i-1]和dp[i-2]已计算
  for (let i = 3; i <= n; i++) {
    // 【步骤2】确定递推公式
    // 到达第i阶只有两种方式:
    // 1. 从第i-1阶爬1步到达 → 方法数 = dp[i-1]
    // 2. 从第i-2阶爬2步到达 → 方法数 = dp[i-2]
    // 总方法数 = 两种方式的方法数之和(加法原理)
    dp[i] = dp[i - 1] + dp[i - 2];

    // 【步骤5】打印dp数组(验证) - 调试时可以取消注释
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  return dp[n];
}

// 测试用例
console.log(climbStairs(2)); // 2
console.log(climbStairs(3)); // 3
console.log(climbStairs(4)); // 5
console.log(climbStairs(5)); // 8
空间优化版(滚动数组)
/**
 * 空间优化版:滚动数组
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i] = dp[i-1] + dp[i-2]
 * 发现dp[i]只依赖前两个状态,不需要保存整个数组
 * 可以用三个变量滚动更新:prevPrev(dp[i-2]), prev(dp[i-1]), cur(dp[i])
 */
function climbStairs(n) {
  // 【易错点1】边界处理:n=1或n=2时需要提前返回
  // 如果n=1时进入循环,prevPrev=1, prev=2会计算出错误结果
  if (n === 1) return 1;
  if (n === 2) return 2;

  // 初始化:对应dp[1]=1, dp[2]=2
  let prevPrev = 1; // dp[i-2],初始表示dp[1]=1
  let prev = 2; // dp[i-1],初始表示dp[2]=2
  let cur;

  // 从第3阶开始计算
  for (let i = 3; i <= n; i++) {
    // 计算当前阶的方法数
    cur = prevPrev + prev;

    // 【易错点2】滚动更新顺序很重要:先更新prevPrev,再更新prev
    // 如果顺序错误(如先更新prev),会导致prevPrev获取到错误的值
    prevPrev = prev; // 下一轮的dp[i-2] = 当前的dp[i-1]
    prev = cur; // 下一轮的dp[i-1] = 当前的dp[i]
  }

  return cur;
}

前端应用场景

  • 分页器组件:计算从第1页跳转到第n页的不同路径数(每次可以跳1页或2页)
  • 游戏开发:角色在台阶上移动,每次可以走1步或2步,计算到达目标位置的方案数
  • 动画路径计算:计算元素从位置A到位置B的不同动画路径数量

2. LeetCode53. 最大子数组和 ★

题目链接53. 最大子数组和

难度:简单

核心:贪心 + DP 结合,理解「状态转移的条件选择」

前端场景:数据趋势统计、收益/数值最值分析、股票K线图最大收益区间、用户行为数据峰值分析

题目描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组是数组中的一个连续部分。

示例 1

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6。

示例 2

输入:nums = [1]
输出:1

示例 3

输入:nums = [5,4,-1,7,8]
输出:23
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为结尾的最大子数组和(注意:必须以nums[i]结尾)
  2. 递推公式dp[i] = Math.max(nums[i], dp[i-1] + nums[i])(要么重新开始,要么延续前面的和)
  3. 初始化dp[0] = nums[0](第一个元素的最大子数组和就是它自己)
  4. 遍历顺序:从左到右(i从1到n-1)
  5. 打印验证:打印dp数组,找到最大值
完整版代码
/**
 * LeetCode53. 最大子数组和
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxSubArray(nums) {
  const len = nums.length;

  // 【易错点1】边界处理:空数组返回0
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示以nums[i]为结尾的最大子数组和(注意:必须以nums[i]结尾)
  // 这个定义很关键:保证子数组是连续的
  const dp = new Array(len);

  // 【步骤3】dp数组如何初始化
  // 第一个元素的最大子数组和就是它自己(没有前面的元素可以延续)
  dp[0] = nums[0];

  // 用于记录全局最大值(因为dp[i]只表示以i结尾的最大和,不一定全局最大)
  let maxSum = dp[0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < len; i++) {
    // 【步骤2】确定递推公式
    // 核心思想:如果前面的和是负数,不如重新开始(贪心思想)
    // 两种选择:
    // 1. 重新开始:只选当前元素nums[i](前面的和是负数,拖累总和)
    // 2. 延续前面的:dp[i-1] + nums[i](前面的和是正数,可以继续累加)
    dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);

    // 【易错点2】必须实时更新全局最大值
    // 因为dp[i]只是以i结尾的最大和,最终答案不一定是dp[len-1]
    maxSum = Math.max(maxSum, dp[i]);

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}, maxSum = ${maxSum}`);
  }

  return maxSum;
}

// 测试用例
console.log(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])); // 6
console.log(maxSubArray([1])); // 1
console.log(maxSubArray([5, 4, -1, 7, 8])); // 23
console.log(maxSubArray([-1])); // -1
空间优化版(只需一个变量)
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * dp[i]只依赖dp[i-1],不需要保存整个数组
 * 用一个变量prev保存上一个状态即可
 */
function maxSubArray(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 用prev代替dp[i-1],初始值为dp[0]
  let prev = nums[0];
  let maxSum = prev;

  for (let i = 1; i < len; i++) {
    // 计算当前状态:要么重新开始,要么延续前面的
    prev = Math.max(nums[i], prev + nums[i]);

    // 更新全局最大值
    maxSum = Math.max(maxSum, prev);
  }

  return maxSum;
}

前端应用场景

  • 股票K线图:计算某段时间内买入卖出的最大收益(价格差的最大连续子数组和)
  • 用户行为分析:分析用户在某段时间内的活跃度峰值(数据流的最大连续区间和)
  • 性能监控:找到服务器响应时间最差的连续时间段(负值转换为响应时间)
  • 数据可视化:在折线图中高亮显示数据增长最快的连续区间

3. LeetCode198. 打家劫舍 ★

题目链接198. 打家劫舍

难度:简单

核心:状态转移的「条件限制」(相邻不选),基础空间优化

前端场景:资源筛选、最优选择问题、权限分配优化、任务调度(不能同时执行相邻任务)

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

示例 1

输入:nums = [1,2,3,1]
输出:4
解释:偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。总金额 = 1 + 3 = 4

示例 2

输入:nums = [2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 9),接着偷窃 5 号房屋(金额 = 1)。总金额 = 2 + 9 + 1 = 12
DP五部曲分析
  1. dp数组含义dp[i] 表示前i间房屋能偷到的最高金额
    • 可以用二维状态:dp[i][0] 表示第i间不偷,dp[i][1] 表示第i间偷
  2. 递推公式
    • dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1])(不偷当前,前一间可偷可不偷)
    • dp[i][1] = dp[i-1][0] + nums[i-1](偷当前,前一间必须不偷)
  3. 初始化dp[0] = [0, 0](前0间,偷或不偷都是0)
  4. 遍历顺序:从左到右(i从1到n)
  5. 打印验证:打印dp数组验证
完整版代码(二维状态)
/**
 * LeetCode198. 打家劫舍
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][0] = 前i间房屋,第i间不偷能获取的最高金额
  // dp[i][1] = 前i间房屋,第i间偷能获取的最高金额
  // 使用二维状态可以清晰地表达"相邻不能同时偷"的约束
  const dp = new Array(len + 1);

  // 【步骤3】dp数组如何初始化
  // 前0间房屋:不偷和偷的金额都是0(没有房屋可偷)
  dp[0] = [0, 0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i <= len; i++) {
    // 【易错点2】数组索引转换:dp[i]对应nums[i-1]
    // dp[1]对应nums[0](第1间房屋),dp[2]对应nums[1](第2间房屋)
    const curVal = nums[i - 1];

    // 【步骤2】确定递推公式
    // 状态1:第i间不偷 → 前i-1间可以偷也可以不偷,取最大值
    const valNotThief = Math.max(...dp[i - 1]);

    // 状态2:第i间偷 → 前i-1间必须不偷(相邻不能同时偷),加上当前金额
    // 【易错点3】必须是dp[i-1][0],不能是dp[i-1][1](违反相邻规则)
    const valThief = curVal + dp[i - 1][0];

    // 更新当前状态
    dp[i] = [valNotThief, valThief];

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = [不偷:${valNotThief}, 偷:${valThief}]`);
  }

  // 最终结果:前len间房屋偷或不偷的最大值
  return Math.max(...dp[len]);
}

// 测试用例
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([2, 7, 9, 3, 1])); // 12
console.log(rob([2, 1, 1, 2])); // 4
空间优化版(两个变量)
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i]只依赖dp[i-1]的两个值
 * 可以用两个变量vNot和vYes滚动更新
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 初始化:对应dp[0] = [0, 0]
  let vNot = 0; // 前i间不偷的最大值
  let vYes = 0; // 前i间偷的最大值

  for (let i = 1; i <= len; i++) {
    const curVal = nums[i - 1];

    // 【易错点4】关键:提前保存上一轮的所有状态,避免更新时覆盖
    // 如果直接使用vNot和vYes,更新vNot时可能会用到已经更新的vYes值
    const prevNot = vNot;
    const prevYes = vYes;

    // 不偷当前间:上一轮偷或不偷的最大值
    vNot = Math.max(prevNot, prevYes);

    // 偷当前间:上一轮不偷的最大值 + 当前金额
    // 【易错点5】必须用prevNot,不能用vNot(因为vNot已经更新了)
    vYes = curVal + prevNot;
  }

  return Math.max(vNot, vYes);
}

前端应用场景

  • 任务调度:在任务列表中,某些任务不能同时执行(有依赖关系),求最大收益
  • 权限分配:某些权限不能同时授予(互斥权限),求最大权限价值组合
  • 资源选择:在资源列表中,相邻资源有冲突,求最优选择方案
  • 广告投放优化:相邻时段的广告不能同时投放,求最大收益的投放方案

三、经典应用级(4道,DP核心考点,高频考)

4. LeetCode62. 不同路径 ★

题目链接62. 不同路径

难度:中等

核心:二维DP基础(可优化为一维),理解「路径型DP」

前端场景:可视化布局路径计算、网格类问题、Canvas/SVG路径绘制、游戏地图路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

问总共有多少条不同的路径?

示例 1

输入:m = 3, n = 7
输出:28

示例 2

输入:m = 3, n = 2
输出:3
解释:从左上角开始,总共有 3 条路径可以到达右下角:
1. 向右 → 向下 → 向下
2. 向下 → 向下 → 向右
3. 向下 → 向右 → 向下
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点(0,0)走到位置(i,j)的不同路径数
  2. 递推公式dp[i][j] = dp[i-1][j] + dp[i][j-1](只能从上方或左方来)
  3. 初始化
    • 第一行所有位置:dp[0][j] = 1(只能从左边来)
    • 第一列所有位置:dp[i][0] = 1(只能从上边来)
  4. 遍历顺序:从上到下、从左到右(双重循环)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode62. 不同路径
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePaths(m, n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从左上角(0,0)走到位置(i,j)的不同路径数
  // 为了方便处理边界,使用dp[i+1][j+1]表示网格(i,j)的路径数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 第一行(i=1):所有位置只能从左边来,路径数都是1
  for (let j = 1; j <= n; j++) {
    dp[1][j] = 1;
  }

  // 第一列(j=1):所有位置只能从上边来,路径数都是1
  for (let i = 1; i <= m; i++) {
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从上到下、从左到右
  // 从(2,2)开始,因为第一行和第一列已经初始化
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 【步骤2】确定递推公式
      // 走到(i,j)只有两种方式:
      // 1. 从上方(i-1,j)向下走一步 → 路径数 = dp[i-1][j]
      // 2. 从左方(i,j-1)向右走一步 → 路径数 = dp[i][j-1]
      // 总路径数 = 两种方式的路径数之和(加法原理)
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }

  // 【步骤5】打印验证(调试时取消注释)
  // console.log('DP数组:');
  // for (let i = 1; i <= m; i++) {
  //   console.log(dp[i].slice(1).join(' '));
  // }

  return dp[m][n];
}

// 测试用例
console.log(uniquePaths(3, 7)); // 28
console.log(uniquePaths(3, 2)); // 3
console.log(uniquePaths(7, 3)); // 28
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n) ← 从O(m*n)优化到O(n)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
 * 计算第i行时,只需要用到:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-1列的值(dp[i][j-1])→ 对应更新后的dp[j-1]
 * 可以用一维数组dp[j]滚动更新
 */
function uniquePaths(m, n) {
  // 【步骤1】一维dp数组:dp[j]表示当前行第j列的路径数
  // 初始化为第一行的值:所有位置路径数都是1(只能从左边来)
  const dp = new Array(n + 1).fill(1);

  // 【步骤4】确定遍历顺序:从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    // 【易错点1】j从2开始,因为第1列(j=1)的值永远是1(只能从上边来)
    for (let j = 2; j <= n; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 上一行第j列的路径数(dp[i-1][j])
      // dp[j-1](更新后)= 当前行第j-1列的路径数(dp[i][j-1])
      // 更新:dp[j] = dp[j](旧值,来自上方)+ dp[j-1](新值,来自左方)
      dp[j] = dp[j] + dp[j - 1];

      // 【易错点2】注意:这里dp[j-1]是已经更新的值(当前行),
      // 而dp[j]是旧值(上一行),正好符合递推公式的需求
    }
  }

  return dp[n];
}

前端应用场景

  • Canvas/SVG路径绘制:计算从起点到终点的不同绘制路径数量
  • 游戏地图:计算角色从起点到终点的移动方案数(只能向右或向下)
  • 网格布局计算:在CSS Grid或Flex布局中,计算元素排列的不同路径数
  • 路由规划:在地图应用中,计算从A点到B点的不同路线数量

5. LeetCode63. 不同路径 II

题目链接63. 不同路径 II

难度:中等

核心:带障碍的路径DP,学会「状态转移的边界判断」

前端场景:网格布局中的障碍物处理、表单验证路径计算、游戏地图障碍物路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 10 来表示。

示例 1

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 → 向右 → 向下 → 向下
2. 向下 → 向下 → 向右 → 向右

示例 2

输入:obstacleGrid = [[0,1],[0,0]]
输出:1
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点到达位置(i,j)的路径数
  2. 递推公式
    • 如果(i,j)是障碍物:dp[i][j] = 0
    • 否则:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化
    • 第一行:遇到障碍物前都是1,遇到障碍物后都是0
    • 第一列:遇到障碍物前都是1,遇到障碍物后都是0
  4. 遍历顺序:从上到下、从左到右
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode63. 不同路径 II(带障碍物)
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  // 【易错点1】边界处理:起点或终点是障碍物,直接返回0
  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从起点到达位置(i-1,j-1)的路径数(索引从1开始便于处理边界)
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 初始化第一行:只能从左边来,遇到障碍物则后续位置无法到达
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1]; // 网格索引转换
    if (curGrid === 1) {
      // 【易错点2】遇到障碍物,后续位置都无法到达,直接跳出
      break;
    }
    dp[1][j] = 1;
  }

  // 初始化第一列:只能从上边来,遇到障碍物则后续位置无法到达
  for (let i = 2; i <= m; i++) {
    // 【易错点3】从i=2开始,因为dp[1][1]已在第一行初始化
    const curGrid = obstacleGrid[i - 1][0];
    if (curGrid === 1) {
      break;
    }
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从第2行第2列开始
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 网格索引转换:dp[i][j]对应网格(i-1, j-1)
      const curGrid = obstacleGrid[i - 1][j - 1];

      // 【步骤2】确定递推公式
      if (curGrid === 1) {
        // 【易错点4】当前位置是障碍物,无法到达,路径数为0
        dp[i][j] = 0;
      } else {
        // 当前位置不是障碍物,可以从上方或左方到达
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      }
    }
  }

  return dp[m][n];
}
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 一维dp数组:dp[j]表示当前行第j列的路径数
  const dp = new Array(n + 1).fill(0);

  // 初始化第一行
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1];
    if (curGrid === 1) break;
    dp[j] = 1;
  }

  // 从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      const curGrid = obstacleGrid[i - 1][j - 1];

      if (curGrid === 1) {
        // 【易错点5】障碍物位置路径数置0
        dp[j] = 0;
      } else if (j === 1) {
        // 第一列:只能从上边来,保持dp[j]不变(如果上边是障碍物,dp[j]已经是0)
        // 不需要更新,因为第一列的路径数在初始化时已经确定
      } else {
        // 非第一列:可以从上方或左方到达
        dp[j] = dp[j] + dp[j - 1];
      }
    }
  }

  return dp[n];
}

前端应用场景

  • 表单验证:在复杂的多步骤表单中,某些步骤可能被禁用(障碍物),计算完成表单的不同路径
  • 游戏地图:在游戏中,某些格子是障碍物,计算从起点到终点的路径数
  • 权限路由:在权限系统中,某些路由节点被禁用,计算用户可访问的路由路径数
  • 工作流设计:在工作流中,某些节点可能被跳过,计算完成流程的不同路径

6. LeetCode213. 打家劫舍 II

题目链接213. 打家劫舍 II

难度:中等

核心:环形DP,拆分为两个基础DP问题(分治思想)

前端场景:环形资源分配、循环任务调度、权限系统中的循环依赖处理

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你在不触动警报装置的情况下,今晚能够偷窃到的最高金额。

示例 1

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2),因为他们是相邻的。

示例 2

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4

示例 3

输入:nums = [1,2,3]
输出:3
DP五部曲分析(分治思想)

核心思路:环形问题转化为两个线性问题

  • 情况1:不偷第一间(可以偷最后一间)→ 转换为线性问题:偷 [1, len-1]
  • 情况2:不偷最后一间(可以偷第一间)→ 转换为线性问题:偷 [0, len-2]
  • 取两种情况的最大值
完整版代码
/**
 * LeetCode213. 打家劫舍 II(环形)
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 【核心思路】环形问题拆分为两个线性问题
  // 情况1:不偷第一间(可以偷最后一间)→ 范围 [1, len-1]
  // 情况2:不偷最后一间(可以偷第一间)→ 范围 [0, len-2]
  // 取两种情况的最大值

  /**
   * 辅助函数:线性数组的打家劫舍(LeetCode198的解法)
   * @param {number[]} arr - 线性房屋数组
   * @returns {number} - 能偷到的最大金额
   */
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    // 二维状态DP
    const dp = new Array(n + 1);
    dp[0] = [0, 0]; // 前0间:不偷和偷都是0

    for (let i = 1; i <= n; i++) {
      const curVal = arr[i - 1];
      // 不偷当前间:前i-1间偷或不偷的最大值
      const valNotThief = Math.max(...dp[i - 1]);
      // 偷当前间:前i-1间必须不偷
      const valThief = curVal + dp[i - 1][0];
      dp[i] = [valNotThief, valThief];
    }

    return Math.max(...dp[n]);
  }

  // 情况1:不偷第一间,范围是nums[1]到nums[len-1]
  const case1 = robLinear(nums.slice(1));

  // 情况2:不偷最后一间,范围是nums[0]到nums[len-2]
  const case2 = robLinear(nums.slice(0, len - 1));

  // 【易错点2】返回两种情况的最大值
  return Math.max(case1, case2);
}

// 测试用例
console.log(rob([2, 3, 2])); // 3
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([1, 2, 3])); // 3
空间优化版(使用滚动变量)
/**
 * 空间优化版:robLinear函数使用滚动变量
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 辅助函数:线性数组打家劫舍(空间优化版)
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    let vNot = 0; // 不偷的最大值
    let vYes = 0; // 偷的最大值

    for (let i = 0; i < n; i++) {
      const curVal = arr[i];
      const prevNot = vNot;
      const prevYes = vYes;

      vNot = Math.max(prevNot, prevYes);
      vYes = curVal + prevNot;
    }

    return Math.max(vNot, vYes);
  }

  const case1 = robLinear(nums.slice(1));
  const case2 = robLinear(nums.slice(0, len - 1));

  return Math.max(case1, case2);
}

前端应用场景

  • 循环任务调度:在循环列表中,某些任务不能同时执行,求最大收益的调度方案
  • 环形权限分配:在权限环中,相邻权限互斥,求最大权限价值组合
  • 资源循环利用:在循环资源池中,某些资源不能同时使用,求最优资源分配
  • 时间轮调度:在时间轮算法中,计算最优的任务执行方案

7. LeetCode322. 零钱兑换 ★

题目链接322. 零钱兑换

难度:中等

核心:完全背包基础版,理解「最值型DP」的状态转移

前端场景:金额/资源最优分配、最少步骤问题、支付找零算法、资源最小化配置

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1

输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2

输入:coins = [2], amount = 3
输出:-1
解释:无法凑成总金额 3

示例 3

输入:coins = [1], amount = 0
输出:0
DP五部曲分析
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j所需的最少硬币个数
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]] + 1(注意是dp[i]不是dp[i-1],因为可以重复选)
    • 取最小值:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
  3. 初始化
    • dp[0][0] = 0(0种硬币凑0元需要0个)
    • dp[0][j>0] = Infinity(0种硬币无法凑正数金额)
    • dp[i][0] = 0(凑0元永远需要0个)
  4. 遍历顺序:外层遍历硬币种类,内层遍历金额(正序,因为是完全背包)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode322. 零钱兑换(完全背包-最值型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  // 【易错点1】边界处理:凑0元直接返回0
  if (target === 0) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j所需的最少硬币个数
  // 初始化:所有值先填Infinity(表示初始无法凑出)
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(target + 1).fill(Infinity));

  // 【步骤3】dp数组如何初始化
  dp[0][0] = 0; // 0种硬币凑0元,需要0个
  // 0种硬币凑正数金额,无法凑出(保持Infinity)
  for (let j = 1; j <= target; j++) {
    dp[0][j] = Infinity;
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币种类,内层遍历金额(正序)
  // 正序遍历是因为完全背包:每种硬币可以使用无限次
  for (let i = 1; i <= coinCount; i++) {
    dp[i][0] = 0; // 凑0元永远需要0个硬币
    const curCoin = coins[i - 1]; // 【易错点2】数组索引转换:第i种硬币对应coins[i-1]

    for (let j = 1; j <= target; j++) {
      if (j < curCoin) {
        // 金额不足,无法使用当前硬币,继承前i-1种硬币的结果
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式
        // 完全背包核心:用当前硬币时是dp[i][j-curCoin]+1(而非dp[i-1])
        // 因为硬币可以重复使用,所以用dp[i](已经考虑了当前硬币)
        dp[i][j] = Math.min(
          dp[i - 1][j], // 不用第i种硬币
          dp[i][j - curCoin] + 1 // 用第i种硬币(注意是dp[i],可以重复选)
        );
      }
    }
  }

  // 【易错点3】无法凑出时返回-1,而非Infinity
  return dp[coinCount][target] === Infinity ? -1 : dp[coinCount][target];
}

// 测试用例
console.log(coinChange([1, 2, 5], 11)); // 3
console.log(coinChange([2], 3)); // -1
console.log(coinChange([1], 0)); // 0
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount) ← 从O(coins.length * amount)优化到O(amount)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
 * 计算dp[i][j]时只需要:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-coins[i-1]列的值(dp[i][j-coins[i-1]])→ 对应更新后的dp[j-coins[i-1]]
 * 可以用一维数组dp[j]正序遍历(完全背包特征:正序)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  if (target === 0) return 0;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j所需的最少硬币个数
  const dp = new Array(target + 1).fill(Infinity);

  // 【步骤3】初始化
  dp[0] = 0; // 凑0元需要0个硬币

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【易错点4】完全背包必须正序遍历:保证每种硬币可以使用无限次
  // 如果倒序遍历,就变成了01背包(每种硬币只能用一次)
  for (let i = 1; i <= coinCount; i++) {
    const curCoin = coins[i - 1];

    // 【易错点5】从curCoin开始遍历,避免j<curCoin的无效判断
    for (let j = curCoin; j <= target; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的最少个数(上一轮的结果)
      // dp[j - curCoin] + 1 = 用当前硬币的最少个数(当前轮已更新的结果)
      dp[j] = Math.min(dp[j], dp[j - curCoin] + 1);
    }
  }

  // 【易错点6】返回前检查是否为Infinity
  return dp[target] === Infinity ? -1 : dp[target];
}

前端应用场景

  • 支付找零:在支付系统中,计算用最少硬币数找零给用户
  • 资源最小化配置:在资源分配中,用最少的资源组合达到目标值
  • API调用优化:计算用最少的API调用次数完成某个任务
  • 组件懒加载:计算用最少的组件加载次数完成页面渲染

8. LeetCode518. 零钱兑换 II

题目链接518. 零钱兑换 II

难度:中等

核心:完全背包的「组合数型DP」,与322(最值型)做区分

前端场景:组合方案统计、支付方式组合数计算、资源配置方案数统计

题目描述

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。

示例 1

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币无法凑成总金额 3。

示例 3

输入:amount = 10, coins = [10]
输出:1
DP五部曲分析(与322的区别)
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j的组合数(注意:是组合数,不是最少个数)
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]](注意是加法,不是取最小值)
    • 总组合数:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]
  3. 初始化
    • dp[0][0] = 1(0种硬币凑0元,有1种组合:不选任何硬币)
    • dp[i][0] = 1(凑0元永远只有1种组合)
    • dp[0][j>0] = 0(0种硬币无法凑正数金额)
  4. 遍历顺序:外层遍历硬币(保证组合不重复),内层正序遍历金额
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
/**
 * LeetCode518. 零钱兑换 II(完全背包-组合数型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 *
 * 【与322的区别】
 * - 322求:最少的硬币个数(最值型)→ Math.min
 * - 518求:组合数(计数型)→ 加法
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  // 【易错点1】边界处理:凑0元返回1(唯一组合:不选任何硬币)
  if (targetAmount === 0) return 1;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j的组合数
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(targetAmount + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 【易错点2】凑0元的组合数是1(不选任何硬币),不是0
  for (let i = 0; i <= coinCount; i++) {
    dp[i][0] = 1; // 凑0元永远只有1种组合
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键】外层遍历硬币保证了组合不重复:如[1,2]和[2,1]被视为同一种组合
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1]; // 【易错点3】数组索引转换

    for (let j = 1; j <= targetAmount; j++) {
      if (j < currentCoin) {
        // 金额不足,无法用当前硬币,继承前i-1种的组合数
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式(组合数 = 不用 + 用)
        // dp[i-1][j]:不用第i种硬币的组合数
        // dp[i][j-currentCoin]:用第i种硬币的组合数(注意是dp[i],可重复选)
        dp[i][j] = dp[i - 1][j] + dp[i][j - currentCoin];
      }
    }
  }

  // 无法凑出时自然返回0(符合题目要求)
  return dp[coinCount][targetAmount];
}

// 测试用例
console.log(change(5, [1, 2, 5])); // 4
console.log(change(3, [2])); // 0
console.log(change(10, [10])); // 1
console.log(change(0, [1, 2])); // 1
空间优化版(一维DP)
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount)
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  if (targetAmount === 0) return 1;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j的组合数
  const dp = new Array(targetAmount + 1).fill(0);
  dp[0] = 1; // 【核心初始化】凑0元的组合数为1

  // 【步骤4】遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键理解】外层遍历硬币 → 保证组合数不重复
  // 如果外层遍历金额,内层遍历硬币,会得到排列数(顺序有关)
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1];

    // 【易错点4】完全背包:金额正序遍历(从currentCoin开始)
    for (let j = currentCoin; j <= targetAmount; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的组合数(上一轮的结果)
      // dp[j - currentCoin](更新后)= 用当前硬币的组合数(当前轮已更新的结果)
      dp[j] = dp[j] + dp[j - currentCoin];
    }
  }

  return dp[targetAmount];
}

前端应用场景

  • 支付方式组合:计算用户可以用多少种不同的支付方式组合完成支付
  • 资源配置方案:计算有多少种不同的资源配置方案可以达到目标
  • 功能组合统计:计算有多少种不同的功能组合可以满足用户需求
  • 优惠券组合:计算有多少种不同的优惠券组合可以使用

四、进阶拓展级(3道,中大厂加分,理解即可)

9. LeetCode300. 最长递增子序列

题目链接300. 最长递增子序列

难度:中等

核心:单维度DP的经典拓展,理解「非连续状态转移」

前端场景:数据趋势分析、序列统计、时间线组件、用户行为序列分析

题目描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4。

示例 2

输入:nums = [0,1,0,3,2,3]
输出:4
解释:最长递增子序列是 [0,1,2,3],长度为 4

示例 3

输入:nums = [7,7,7,7,7,7,7]
输出:1
解释:最长递增子序列是 [7],长度为 1
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为最后一个元素的最长严格递增子序列的长度
  2. 递推公式
    • 对于每个 nums[i],遍历前面所有元素 nums[j] (j < i)
    • 如果 nums[i] > nums[j],则 nums[i] 可以接在 nums[j] 的子序列后面
    • dp[i] = Math.max(dp[i], dp[j] + 1) (在所有满足条件的j中取最大值)
  3. 初始化dp[i] = 1(每个元素自身构成长度为1的子序列)
  4. 遍历顺序:外层遍历i(从1到n-1),内层遍历j(从0到i-1)
  5. 打印验证:打印dp数组,返回最大值(注意:最长子序列不一定以最后一个元素结尾)
完整版代码
/**
 * LeetCode300. 最长递增子序列
 * 时间复杂度:O(n²)
 * 空间复杂度:O(n)
 */
function lengthOfLIS(nums) {
  const count = nums.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return count;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 以nums[i]为最后一个元素的最长严格递增子序列的长度
  // 【易错点2】初始化错误:必须初始化为1,不能初始化为0
  // 因为每个元素自身至少构成长度为1的子序列
  const dp = new Array(count).fill(1);

  // 【步骤4】确定遍历顺序:外层遍历i,内层遍历j
  // 【易错点3】i从1开始:i=0时前面没有元素,无法计算
  for (let i = 1; i < count; i++) {
    const curNum = nums[i]; // 当前元素

    // 内层遍历:检查i前面所有元素j(而非仅j=i-1)
    // 【易错点4】必须遍历所有j<i,不能只遍历j=i-1
    // 因为子序列可以非连续,nums[i]可以接在任意满足条件的nums[j]后面
    for (let j = 0; j < i; j++) {
      // 【步骤2】确定递推公式
      // 【易错点5】递增条件:必须是严格递增(>),不能是>=
      if (curNum > nums[j]) {
        // 如果nums[i] > nums[j],则nums[i]可以接在nums[j]的子序列后面
        // 取所有满足条件的dp[j]+1的最大值
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  // 【易错点6】返回错误:不能返回dp[count-1]
  // 因为最长递增子序列不一定以最后一个元素结尾
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4
console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4
console.log(lengthOfLIS([7, 7, 7, 7, 7, 7, 7])); // 1
console.log(lengthOfLIS([1, 3, 6, 7, 9, 4, 10, 5, 6])); // 6

前端应用场景

  • 时间线组件:在时间线中,找到最长连续增长的时间段
  • 用户行为分析:分析用户行为序列中最长的正向发展趋势
  • 数据可视化:在图表中高亮显示数据的最长递增区间
  • 版本号比较:找到版本号序列中最长的递增子序列

10. LeetCode121. 买卖股票的最佳时机 ★

题目链接121. 买卖股票的最佳时机

难度:简单

核心:DP + 贪心结合,也可纯DP实现,理解「状态定义的简化」

前端场景:数据趋势、收益计算、股票K线图分析、价格波动分析

题目描述

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

如果你不能获取任何利润,返回 0 。

示例 1

输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2

输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 交易无法完成, 所以返回 0。
DP五部曲分析
  1. dp数组含义dp[i] 表示第i天卖出股票能获得的最大利润
  2. 递推公式dp[i] = prices[i] - minPrice(当天价格减去之前的最小价格)
  3. 初始化dp[0] = 0(第0天无法卖出),minPrice = prices[0]
  4. 遍历顺序:从左到右(i从1到n-1),同时维护最小价格
  5. 打印验证:打印dp数组,返回最大值(注意:最大利润不一定在最后一天卖出)
完整版代码
/**
 * LeetCode121. 买卖股票的最佳时机
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxProfit(prices) {
  const count = prices.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 第i天卖出股票能获得的最大利润
  const dp = new Array(count).fill(0);

  // 【步骤3】dp数组如何初始化
  // 第0天无法卖出(必须先买入),利润为0
  dp[0] = 0;

  // 维护遍历到当前的最小价格(用于计算利润)
  let minPrice = prices[0]; // 初始买入价格是第0天的价格

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < count; i++) {
    const curPrice = prices[i]; // 当天价格

    // 【核心逻辑1】更新最小价格(必须先更新,再计算利润)
    // 【易错点2】顺序错误:如果先计算利润再更新minPrice,会导致"当天买当天卖"的逻辑错误
    // 正确的顺序:先更新minPrice(基于之前的价格),再计算当天卖出的利润
    minPrice = Math.min(minPrice, curPrice);

    // 【步骤2】确定递推公式
    // 第i天卖出的最大利润 = 当天价格 - 之前的最小价格(最佳买入点)
    // 如果结果为负,dp[i]保持0(等价于不交易)
    dp[i] = Math.max(0, curPrice - minPrice);

    // 【步骤5】打印验证
    // console.log(`第${i}天:价格=${curPrice}, 最小价格=${minPrice}, 利润=${dp[i]}`);
  }

  // 【易错点3】返回错误:不能返回dp[count-1]
  // 因为最大利润不一定在最后一天卖出(如示例1中最大利润在第5天,不是最后一天)
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(maxProfit([7, 1, 5, 3, 6, 4])); // 5
console.log(maxProfit([7, 6, 4, 3, 1])); // 0
console.log(maxProfit([2, 4, 1])); // 2
console.log(maxProfit([3, 2, 6, 5, 0, 3])); // 4
空间优化版(只需一个变量)
/**
 * 空间优化版:贪心思想
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察:dp[i]只依赖dp[i-1]和minPrice
 * 而且我们只需要最大值,不需要保存整个dp数组
 * 用一个变量maxProfit实时更新最大值即可
 */
function maxProfit(prices) {
  const count = prices.length;
  if (count <= 1) return 0;

  let minPrice = prices[0]; // 最小买入价格
  let maxProfit = 0; // 最大利润

  for (let i = 1; i < count; i++) {
    const curPrice = prices[i];

    // 更新最小价格
    minPrice = Math.min(minPrice, curPrice);

    // 计算当天卖出的利润,并更新最大利润
    maxProfit = Math.max(maxProfit, curPrice - minPrice);
  }

  return maxProfit;
}

前端应用场景

  • 股票K线图:在股票图表中,计算买入卖出的最佳时机和最大收益
  • 价格趋势分析:分析商品价格变化,找到最佳买卖点
  • 收益计算器:在投资应用中,计算投资组合的最大收益
  • 数据波动分析:分析数据序列中的最大正向波动(类似股票收益)

五、总结

通过这10道动态规划经典题目,我们掌握了:

核心框架:DP五部曲

  1. 确定dp数组及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组(验证)

题目分类

类别 题目 核心特点 空间优化
基础递推 爬楼梯、最大子数组和、打家劫舍 一维DP,状态转移简单 滚动变量 O(1)
路径型DP 不同路径、不同路径II 二维DP,网格问题 一维数组 O(n)
背包问题 零钱兑换、零钱兑换II 完全背包,最值/计数 一维数组 O(amount)
序列问题 最长递增子序列 非连续状态转移 难优化
状态机DP 买卖股票、打家劫舍II 状态转换,环形问题 滚动变量 O(1)

易错点总结

  1. 边界处理:空数组、单元素、索引转换
  2. 初始化:根据问题特点正确初始化(0、1、Infinity等)
  3. 遍历顺序:完全背包正序,01背包倒序
  4. 返回值:注意是返回dp[n]还是Math.max(...dp)
  5. 空间优化:注意更新顺序,避免覆盖未使用的值

前端应用价值

动态规划在前端开发中广泛应用于:

  • 性能优化:资源分配、组件懒加载优化
  • 业务逻辑:支付找零、权限分配、任务调度
  • 数据可视化:趋势分析、K线图、时间线组件
  • 算法优化:路径规划、组合统计、最值计算

掌握这10道题目,足以应对前端算法面试中的大部分DP问题。记住:先理解DP五部曲框架,再套用到具体问题,最后优化空间复杂度


相关资源

前端佬们!!AI大势已来,未来的上限取决你的独特气质!恭请批阅!!

作者 大怪v
2026年1月10日 20:23

前言

写这篇文章,纯粹是AI热潮给炸出来的。本来想继续更我的虚拟世界专栏,但看了一眼沸点,好家伙,大家都在聊DeepSeek、ChatGPT,感觉我不说两句显得我很不合群。

还有另外一个原因,就是身边的很多程序员,都在焦虑。

那么,程序员真的会被替代吗?兄弟们,别急别急!

我直接给出我自己的观点:如果还是之前那种记俩API、写两功能的程序员,我相信很快会替代。但是那种会整活、会整合,直接面向问题解决的程序员,不会的!!!

O9.gif

对比以前还要苦哈哈地背API,现在AI把门槛直接铲平了。这哪里是危机?这分明是从“螺丝钉”进化成“架构师”的最佳版本!

我的核心观点:AI对于之前的经验总结、归纳非常牛牪犇!!但是对于复杂问题、现实的敏感度以及所谓创新,他们还直接不能!!

来,上理由!

0变1的门槛,被无限拉低了!

以前你想做一个全栈应用,你得懂前端、后端、数据库、运维... 还没开始写代码,环境配置就先把你劝退了。

现在呢?

只要你的想法够骚,AI就是你的一万个分身。

骄傲.gif

我有一个专栏,手搓虚拟世界

在没有 AI 的时候,你想从 0 到 1 做一个产品,你要懂后端、懂数据库、懂运维,甚至还得懂点 UI 设计。这每一项,都是一座大山。很多很棒的 idea,就死在了“我不会写后端接口”或者“这 UI 丑得我没眼看”上。

现在呢?

AI 就是你的那个全能外包团队。你不会写 SQL?问它。你不会画 icon?让它画。 以前我们为了画一个完美的圆,可能要算半天 Math.PI,现在你只需要告诉 AI:“给我整一个圆,要五彩斑斓的那种。”

0 变 1 的过程,不再是技术的堆砌,而是你“脑洞”的直接具象化。 只要你有想法,技术实现的壁垒正在被 AI 暴力拆除。

这是拼气质的时代

很多人说 AI 出来的代码没有灵魂,是缝合怪。 我说:别急别急!

当所有人都能一键生成标准化代码的时候,什么东西最值钱? 是个性。是那种“独属前端佬气质”的创新。

0.gif

就像当年的 Flash,工具大家都有,但只有少数人能做出《小小火柴人》。AI 时代同理,它能帮你生成 90% 的通用代码,但剩下那 10% 的、决定产品气质的、让人眼前一亮的 “手搓” 部分,才是你真正的价值所在。

未来的牛人,不是谁 API 背得熟,而是谁能用 AI 这个超级引擎,组合出别人没见过的玩法。这不就是我们最擅长的吗?不依赖第三方库(因为 AI 可能会瞎引用),纯靠逻辑和创意,去构建一个新的虚拟世界。

能做什么,取决于你能想到什么

以前想整一个事情,大致如下流程:想法=>需求=>原型=>UI=>交互=>编写代码

完全靠人海战术。 现在?一个拿着 AI 的工程师(或者说“全栈工程师”),战斗力可能抵得上以前的一个公司。

这意味着什么?意味着个人创新的回报率被无限放大了。你不需要在一个项目中,当一颗在大机器里运转的螺丝钉,你有机会成为那个设计机器的人。

假如未来硬件再大升级(就像我之前说的智能眼镜、脑机接口),结合 AI 的生产力,一个人手搓一个“元宇宙”雏形,可能真的不再是梦。

93d794510fb30f248be3f2baca95d143ac4b03e8.gif

AI 不会淘汰有想法的人,它只会淘汰那些只会 copy-paste 的“代码搬运工”。

与其在焦虑中等待被替代,不如现在就头脑热一把,利用这个时代的红利,去“手搓”一点属于你自己的、独一无二的东西。

毕竟,只有当现有的规则已经装不下你的野心时,打破规则才更有乐趣。

前端佬们,别怂,干!

❌
❌