普通视图

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

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

2026年3月3日 17:10

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

Vue3 嵌套路由 KeepAlive:动态缓存与反向配置方案

作者 Angelial
2026年3月3日 16:59

在电商系统中,用户的操作路径往往是这样的:

进入商品列表页 → 进行筛选、排序、分页 → 点击进入商品详情页 → 查看后返回列表页。

这时,用户通常有一个非常明确的预期:

  • 筛选条件还在
  • 分页状态保持
  • 滚动位置不丢失

但同时,我们也会遇到另一种场景:

  • 从首页、搜索页或其他模块进入商品列表页时
  • 希望列表页是“全新状态”
  • 需要重新请求数据

也就是说:

同一个页面,在不同来源路径下,对“是否缓存”的期望是不同的。

在 Vue 项目开发中,KeepAlive 是提升用户体验的重要工具。但在复杂的嵌套路由场景(例如:Layout → SubLayout → Page)下,我们常常会遇到两个典型痛点:

1️⃣ 缓存失效

明明在路由里配置了 keepAlive: true
但页面返回时依然重新挂载,onMounted 再次触发,状态丢失。

2️⃣ 控制逻辑越来越混乱

当我们尝试实现:

  • 从 A 页面跳到 B 页面时缓存
  • 从 C 页面跳到 B 页面时不缓存

路由守卫里开始出现大量 if-else 判断,
逻辑逐渐变得难以维护,甚至演变成“屎山”。

如何实现:

  • 精准控制缓存来源
  • 支持嵌套路由结构
  • 避免父级 Layout 被误销毁
  • 同时保持代码清晰、可维护

本文将分享一种“反向配置”的缓存设计方案,并深入解析嵌套路由场景下的一个核心原则:

父随子存。

通过这套设计,我们可以在复杂电商场景中,实现精细化、可控且可扩展的页面缓存策略。

一、核心痛点:为什么嵌套路由下的缓存容易失效?

在 Vue 中,KeepAlive 的本质其实是“链路存活”。

换句话说,只要组件所在的这条渲染链路还存在,它的缓存才能继续保留。一旦链路中某一层被销毁,缓存就会随之消失。

假设我们的路由结构是这样的:

  • 一级容器:WebsiteLayout(顶层布局)
  • 二级容器:UserLayout(用户中心布局)
  • 三级页面:UserProfile、UserFavorites 等子页面
WebsiteLayout
  └── UserLayout
        └── UserProfile / UserFavorites

这里有一个很多人忽略的“真相”:

如果父容器(例如 UserLayout)被销毁,那么它内部缓存的所有子页面,也会被瞬间“物理清空”。

即使你在 UserProfile 上配置了keepAlive: true, 只要 UserLayout 这一层被重新挂载, 内部所有缓存都会失效,onMounted 会再次触发。

这就是嵌套路由场景下缓存“看起来配置了却没生效”的根本原因。

很多开发者只给子页面设置缓存,却忽略了一个关键原则:

子页面想存活,父容器必须先存活。

这也是后文要讲的核心设计理念——“父随子存”原则。

二、 解决方案:反向配置 + 递归缓存

1. 路由配置:由“去向页”决定“来源页”

我们不再在每个页面写复杂的判断,而是在详情页声明:“从我这里回退时,请保持谁的缓存”

// router/index.ts
export const routes = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/UserLayout.vue'),
    meta: { keepAlive: true }, // 父容器必须支持缓存
    children: [
      {
        path: 'favorites',
        name: 'UserFavorites',
        component: () => import('@/views/user/Favorites.vue'),
        meta: { keepAlive: true }
      }
    ]
  },
  {
    path: '/product/:id',
    name: 'Product',
    component: () => import('@/views/product/index.vue'),
    meta: { 
      // 反向配置:从这里回退时,保护以下页面的缓存
      keepAliveSources: ['UserFavorites', 'Home'] 
    }
  }
];

2. 全局守卫:实现“父随子存”

这是整套方案的灵魂。我们需要在路由守卫中做两件事:

  1. 补全链路:进入子页面时,强制将其所有父 Layout 加入缓存名单。
  2. 精准清理:非合法来源进入时,即时销毁旧缓存。
export const cacheStack = ref<string[]>([]);

router.beforeEach(async (to, from, next) => {
    const fromName = from.name as string;
    const keepAliveSources = to.meta.keepAliveSources as string[] || [];

    // 1️⃣ 核心:补全父路由缓存名单
    // 遍历 to.matched,确保当前路由的所有父 Layout 都在缓存数组中
    to.matched.forEach((record) => {
      if (record.meta.keepAlive && record.name) {
        const name = record.name as string;
        if (!cacheStack.value.includes(name)) {
          cacheStack.value.push(name);
        }
      }
    });

    // 2️⃣ 处理来源页逻辑
    if (from.meta.keepAlive && fromName) {
      // 如果来源页本身声明了需要缓存,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (keepAliveSources.includes(fromName)) {
      // 如果去向页声明了它是合法的回退来源,保留它
      if (!cacheStack.value.includes(fromName)) cacheStack.value.push(fromName);
    } else if (fromName) {
      // 3️⃣ 清理:如果不是合法来源,从名单中移除,触发组件销毁
      const index = cacheStack.value.indexOf(fromName);
      if (index > -1) cacheStack.value.splice(index, 1);
    }

    next();
});

3. 布局组件:视图层的配合

在所有包含 router-viewLayout 组件中,必须使用 include 绑定这个全局名单。

  1. WebsiteLayout.vue
// WebsiteLayout.vue
<template>
    <router-view v-slot="{ Component, route }">
      <keep-alive :include="cacheStackList">
        <component 
          :is="Component" 
          :key="route.fullPath" 
        />
      </keep-alive>
    </router-view>
</template>

<script lang="ts" setup name="WebsiteLayout">
  import { defineComponent, computed } from 'vue';
  import { cacheStack } from '/@/router/guard/index';

  const cacheStackList = computed(() => {
    return cacheStack.value
  })
</script>
  1. UserLayout.vue
// UserLayout.vue
<template>
  <div class="flex flex-col">
    <div class="flex gap-6 py-4">
      <!-- 左侧菜单 -->
      <aside class="w-96 shrink-0">
        <UserMenu />
      </aside>

      <!-- 右侧内容 -->
      <main class="flex-1">
        <router-view v-slot="{ Component, route }">
          <keep-alive :include="cacheStackList">
            <component 
              :is="Component" 
              :key="route.fullPath" 
            />
          </keep-alive>
        </router-view>
      </main>
    </div>
  </div>
</template>
<script setup lang="ts" name="User">
import UserMenu from './userMenu/index.vue'
import { cacheStack } from '/@/router/guard/index';
import { computed } from 'vue';
const cacheStackList = computed(() => {
  return cacheStack.value
})
</script>

三、 方案优势

  1. 物理隔离确保生效:通过 to.matched 递归确保父容器存活,彻底解决了嵌套路由“名单对了但不缓存”的问题。

  2. 配置解耦:新加一个详情页时,只需在详情页 meta 里增加一行回退目标,无需改动任何业务组件。

  3. 内存友好:不满足回退条件时,缓存会被立即 splice 清理,避免内存堆积。

Kotlin抽象类与接口:相爱相杀的编程“CP”

作者 小码哥_常
2026年3月3日 16:50

一、开篇引入

在 Kotlin 的编程世界里,你是否常常在定义一些通用行为或属性时,纠结于到底该使用抽象类还是接口呢?就像在建造一座大厦时,选择合适的建筑材料至关重要,在 Kotlin 编程中,正确选用抽象类和接口,对于构建健壮、可维护的代码结构同样意义非凡 。今天,我们就一起来深入探讨 Kotlin 中抽象类以及它与接口的区别。

二、Kotlin 抽象类详解

(一)抽象类定义

在 Kotlin 中,抽象类是一种不能被直接实例化的类,就像是一个还未完成的 “蓝图”,它主要的作用是作为其他类的基类(父类) ,为子类提供通用的属性和方法定义,而将具体的实现细节留给子类去完成。我们使用abstract关键字来声明一个抽象类。例如:


abstract class AbstractClass {
    // 这里可以定义抽象属性和抽象方法
    // 也可以定义非抽象属性和非抽象方法
}

抽象类不能被直接实例化,比如不能写成val abstractObj = AbstractClass(),这就如同你不能直接使用一个未完成的蓝图来建造实际的建筑一样。它存在的意义更多是为了提供一种通用的结构和规范,让子类基于它进行扩展和实现 。

(二)抽象类示例

以一个图形相关的程序为例,我们定义一个Shape抽象类:


abstract class Shape {
    // 抽象属性,没有初始化值,必须在子类中重写
    abstract val name: String

    // 抽象方法,没有方法体,必须在子类中重写
    abstract fun calculateArea(): Double

    // 非抽象方法,有具体实现,可以被子类继承或重写
    fun printName() {
        println("形状名称: $name")
    }
}

在这个Shape抽象类中,name是一个抽象属性,它代表图形的名称,每个具体的图形(如圆形、矩形)名称都不同,所以需要在子类中具体实现;calculateArea()是一个抽象方法,用于计算图形的面积,不同图形的面积计算方式不同,也需要子类去实现;而printName()是一个非抽象方法,它会打印出图形的名称,这个方法有具体的实现,子类可以直接继承使用,如果有特殊需求也可以重写。

(三)继承抽象类

当子类继承抽象类时,必须使用override关键字重写所有抽象属性和方法。例如,我们定义Circle(圆形)和Rectangle(矩形)类来继承Shape类:


// 子类Circle继承自抽象类Shape
class Circle(val radius: Double) : Shape() {
    // 重写抽象属性
    override val name: String = "圆形"

    // 重写抽象方法
    override fun calculateArea(): Double {
        return Math.PI * radius * radius
    }
}

// 子类Rectangle继承自抽象类Shape
class Rectangle(val width: Double, val height: Double) : Shape() {
    override val name: String = "矩形"
    override fun calculateArea(): Double {
        return width * height
    }
}

Circle类中,我们重写了name属性为 “圆形”,并实现了calculateArea()方法来计算圆形的面积;在Rectangle类中,同样重写了name属性为 “矩形”,并实现calculateArea()方法来计算矩形面积。通过这种方式,抽象类的抽象成员在子类中得到了具体的实现 。

(四)抽象类特点总结

  1. 不能实例化:抽象类不能直接创建对象,它主要为子类提供一个通用的框架。就像我们不能直接使用一个抽象的 “交通工具” 类来创建一个具体的交通工具,而是需要基于它创建 “汽车”“飞机” 等具体子类的对象。

  2. 可包含抽象和非抽象成员:抽象类中既可以有抽象属性和抽象方法,这些需要子类去实现;也可以有非抽象属性和非抽象方法,子类可以直接继承使用,也可以根据需求重写。

  3. 子类必须实现所有抽象成员:如果一个子类继承了抽象类,那么它必须重写抽象类中的所有抽象属性和方法,否则这个子类也必须声明为抽象类。例如,下面这个只重写了部分抽象成员的类,就必须声明为抽象类:


abstract class Square(val sideLength: Double) : Shape() {
    // 只重写了抽象属性,没有重写抽象方法calculateArea()
    override val name: String = "正方形"
    // 因此Square类也必须是抽象的
}
  1. 可以继承其他类:抽象类可以继承自另一个非抽象类或抽象类,进一步扩展和定制自己的行为和属性。例如:

open class Animal {
    open fun makeSound() {
        println("动物发出声音")
    }
}

abstract class Dog : Animal() {
    override abstract fun makeSound() // 重写并声明为抽象方法,子类必须实现
}

class Puppy : Dog() {
    override fun makeSound() {
        println("小狗汪汪叫")
    }
}

在这个例子中,Dog抽象类继承自Animal类,并重写了makeSound()方法并声明为抽象方法,Puppy类再继承Dog类并实现了makeSound()方法 。

三、Kotlin 接口详解

(一)接口定义

在 Kotlin 中,接口是一种强大的抽象机制,它使用interface关键字来定义。与抽象类不同,接口主要用于定义一组方法的签名,这些方法可以是抽象的,也可以有默认实现 。接口无法存储状态,它就像是一份 “行为契约”,规定了实现它的类应该具备哪些行为 。接口可以包含抽象方法声明和方法实现 ,接口中的属性默认是抽象的,或必须提供getter实现 。例如:


interface MyInterface {
    // 抽象方法,没有方法体,实现接口的类必须实现这个方法
    fun abstractMethod()

    // 带默认实现的方法,实现接口的类可以选择重写这个方法,也可以使用默认实现
    fun methodWithDefaultImplementation() {
        println("这是一个带有默认实现的方法")
    }
}

(二)接口示例

Vehicle接口为例,展示接口中抽象方法和带默认实现方法的定义:


interface Vehicle {
    // 抽象方法,启动车辆,必须在实现接口的类中实现
    fun start()

    // 抽象方法,停止车辆,必须在实现接口的类中实现
    fun stop()

    // 带默认实现的方法,车辆鸣笛,实现接口的类可以选择重写,也可以使用默认实现
    fun honk() {
        println("嘟嘟!")
    }
}

在这个Vehicle接口中,start()stop()是抽象方法,因为不同类型的车辆启动和停止的方式可能不同,需要具体的实现类去实现;而honk()是一个带默认实现的方法,默认情况下车辆鸣笛输出 “嘟嘟!”,如果有特殊的鸣笛需求,实现类也可以重写这个方法 。

(三)实现接口

一个类或对象可以实现一个或多个接口。当一个类实现接口时,它必须实现接口中所有的抽象方法(除非这个类本身也是抽象类) 。以Car类实现Vehicle接口为例:


class Car : Vehicle {
    override fun start() {
        println("汽车启动")
    }

    override fun stop() {
        println("汽车停止")
    }

    // 这里没有重写honk()方法,所以会使用接口中honk()的默认实现
}

Car类中,通过override关键字重写了Vehicle接口中的start()stop()抽象方法,来实现汽车的启动和停止逻辑 。而对于honk()方法,由于没有重写,所以当调用Car对象的honk()方法时,会执行接口中honk()的默认实现,输出 “嘟嘟!” 。

(四)接口继承与解决覆盖冲突

接口可以继承其他接口,通过继承,接口可以扩展和增强自身的功能 。例如:


interface Moveable {
    fun move()
}

interface Flyable : Moveable {
    fun fly()
}

在这个例子中,Flyable接口继承了Moveable接口,这意味着实现Flyable接口的类不仅要实现fly()方法,还要实现Moveable接口中的move()方法 。

当一个类实现多个接口时,如果这些接口中定义了相同签名的方法,就会出现方法覆盖冲突 。例如:


interface A {
    fun foo() {
        println("A中的foo方法")
    }
}

interface B {
    fun foo() {
        println("B中的foo方法")
    }
}

class C : A, B {
    // 必须重写foo()方法来解决冲突
    override fun foo() {
        // 调用A接口中的foo()方法
        super<A>.foo()
        // 调用B接口中的foo()方法
        super<B>.foo()
        println("C中重写的foo方法")
    }
}

在上述代码中,C类实现了AB两个接口,而这两个接口都定义了foo()方法,所以在C类中必须重写foo()方法 。在重写的foo()方法中,通过super<A>.foo()super<B>.foo()分别调用了AB接口中的foo()方法,并添加了自己的逻辑 。这样就解决了方法覆盖冲突的问题 。

四、抽象类与接口的区别

通过前面的介绍,我们对 Kotlin 中的抽象类和接口都有了一定的了解 。接下来,我们来详细对比一下它们之间的区别,以便在实际开发中能够更准确地选择使用。

(一)构造函数

抽象类可以有构造函数,包括主构造函数和次构造函数,用于初始化抽象类中的属性和状态 。例如,我们在Shape抽象类中添加一个主构造函数:


abstract class Shape(val color: String) {
    abstract val name: String
    abstract fun calculateArea(): Double

    fun printName() {
        println("形状名称: $name")
    }
}

在这个例子中,Shape抽象类有一个主构造函数,接受一个color参数,用于表示图形的颜色 。

而接口不能有构造函数 。这是因为接口主要用于定义行为,不存储状态,所以不需要构造函数来初始化 。不过,从 Kotlin 1.9 + 开始,虽然支持接口中定义带默认实现的属性,但仍然不能有构造函数 。

(二)多重继承

在 Kotlin 中,一个类只能继承一个抽象类,即抽象类是单继承的 。这是为了避免多重继承带来的复杂性和冲突 。例如:


abstract class Animal {
    open fun makeSound() {
        println("动物发出声音")
    }
}

abstract class Dog : Animal() {
    override abstract fun makeSound()
}

这里Dog抽象类继承自Animal抽象类,一个类不能同时继承多个抽象类 。

而接口则不同,一个类可以实现多个接口,通过实现多个接口,一个类可以拥有多个不同的行为集合 。例如:


interface Flyable {
    fun fly()
}

interface Runable {
    fun run()
}

class Bird : Flyable, Runable {
    override fun fly() {
        println("鸟儿飞翔")
    }

    override fun run() {
        println("鸟儿奔跑")
    }
}

在这个例子中,Bird类实现了FlyableRunable两个接口,从而具备了飞翔和奔跑的行为 。

(三)属性

抽象类可以包含非抽象属性,这些属性可以有初始值,也可以在构造函数中初始化 。例如,我们在Shape抽象类中添加一个非抽象属性borderWidth


abstract class Shape(val color: String) {
    abstract val name: String
    abstract fun calculateArea(): Double

    val borderWidth: Int = 1

    fun printName() {
        println("形状名称: $name")
    }
}

在这个例子中,borderWidth是一个非抽象属性,它有初始值1

接口中的属性默认是抽象的,必须在实现接口的类中重写并提供具体实现,除非该属性提供了getter的默认实现 。例如:


interface ShapeInterface {
    val name: String
    val borderWidth: Int
        get() = 1
}

class Rectangle : ShapeInterface {
    override val name: String = "矩形"
    override val borderWidth: Int
        get() = super.borderWidth
}

在这个例子中,ShapeInterface接口中的name属性是抽象的,没有默认实现,必须在实现类Rectangle中重写;而borderWidth属性提供了getter的默认实现,在Rectangle类中如果不需要修改其行为,可以直接使用默认实现 。

(四)方法实现

抽象类可以包含非抽象方法的实现,子类可以继承这些方法,也可以根据需要重写它们 。例如,我们在Shape抽象类中的printName()方法就是一个非抽象方法,有具体的实现 。

接口中的方法默认是抽象的,没有方法体,必须在实现接口的类中实现 。不过,从 Kotlin 1.4 + 开始,接口支持方法的默认实现 。例如,我们在Vehicle接口中添加一个带默认实现的方法startEngine()


interface Vehicle {
    fun start()
    fun stop()

    fun honk() {
        println("嘟嘟!")
    }

    fun startEngine() {
        println("发动机启动")
    }
}

在这个例子中,start()stop()方法是抽象的,必须在实现类中实现;而honk()startEngine()方法是带默认实现的方法,实现类可以选择重写这些方法,也可以使用默认实现 。

(五)访问修饰符

抽象类可以使用privateprotectedpublic等访问修饰符来控制成员的访问权限 。private修饰的成员只能在抽象类内部访问,protected修饰的成员可以在抽象类及其子类中访问,public修饰的成员可以在任何地方访问 。例如:


abstract class Shape {
    private val privateProperty: String = "私有属性"
    protected val protectedProperty: String = "受保护属性"
    val publicProperty: String = "公共属性"

    private fun privateMethod() {
        println("这是一个私有方法")
    }

    protected fun protectedMethod() {
        println("这是一个受保护方法")
    }

    fun publicMethod() {
        println("这是一个公共方法")
    }
}

在这个例子中,privatePropertyprivateMethod()是私有的,只能在Shape抽象类内部访问;protectedPropertyprotectedMethod()是受保护的,可以在Shape抽象类及其子类中访问;publicPropertypublicMethod()是公共的,可以在任何地方访问 。

接口成员默认是public的,不能有private修饰符 。这是因为接口的主要目的是定义一组可供其他类实现的行为,这些行为通常是对外公开的 。例如:


interface MyInterface {
    fun method1()
    fun method2()
}

在这个MyInterface接口中,method1()method2()方法默认都是public的,不能声明为private

五、使用建议与场景

(一)抽象类使用场景

当你需要定义一个通用的基类,并且这个基类包含一些通用的属性、方法以及构造函数时,抽象类是一个很好的选择 。例如,在 Android 开发中,我们经常会创建一个BaseActivity抽象类,它包含了一些所有 Activity 都通用的逻辑,如设置布局、初始化视图、加载数据等:


import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

abstract class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(getLayoutId())
        initViews()
        initData()
    }

    // 抽象方法,由子类实现,返回布局ID
    abstract fun getLayoutId(): Int

    // 抽象方法,由子类实现,初始化视图
    abstract fun initViews()

    // 抽象方法,由子类实现,加载数据
    abstract fun initData()
}

然后,具体的 Activity 类可以继承自BaseActivity,并实现其中的抽象方法 。例如:


class MainActivity : BaseActivity() {
    override fun getLayoutId(): Int {
        return R.layout.activity_main
    }

    override fun initViews() {
        // 初始化视图的具体逻辑
    }

    override fun initData() {
        // 加载数据的具体逻辑
    }
}

通过这种方式,我们可以将通用的逻辑提取到BaseActivity抽象类中,减少代码重复,提高代码的可维护性和可扩展性 。

(二)接口使用场景

当你只需要定义一组抽象的行为,并且希望一个类可以实现多个这样的行为时,接口是更合适的选择 。例如,在一个图形绘制库中,我们可以定义多个接口来表示不同的功能:


// 定义一个可绘制的接口
interface Drawable {
    fun draw()
}

// 定义一个可点击的接口
interface Clickable {
    fun onClick()
}

// 定义一个可拖动的接口
interface Draggable {
    fun drag()
    fun drop()
}

然后,一个类可以实现多个接口,以具备多种行为 。比如一个Button类可以同时实现DrawableClickableDraggable接口:


class Button : Drawable, Clickable, Draggable {
    override fun draw() {
        println("绘制按钮")
    }

    override fun onClick() {
        println("按钮被点击")
    }

    override fun drag() {
        println("按钮被拖动")
    }

    override fun drop() {
        println("按钮被放下")
    }
}

通过接口,我们可以让不同的类灵活地组合不同的行为,而不受单继承的限制,使代码更加灵活和可复用 。

六、总结回顾

通过今天的学习,我们深入了解了 Kotlin 中抽象类和接口这两个重要的概念 。抽象类像是一个未完成的蓝图,不能被直接实例化,它为子类提供通用的属性和方法定义,子类继承抽象类时必须重写所有抽象成员 。而接口则是一份行为契约,定义了一组方法签名,一个类可以实现多个接口,以获得多种行为 。

它们在构造函数、多重继承、属性、方法实现以及访问修饰符等方面都存在明显的区别 。在实际的 Kotlin 开发中,我们要根据具体的需求来选择使用抽象类还是接口 。如果需要定义一个通用的基类,并且这个基类包含构造函数、非抽象属性和方法,那么抽象类是合适的选择 ;如果只是需要定义一组抽象的行为,并且希望一个类可以实现多个这样的行为,接口则更为合适 。 希望大家在今后的 Kotlin 编程中,能够熟练运用抽象类和接口,构建出更加健壮、灵活和可维护的代码 。如果对今天的内容还有任何疑问,欢迎在评论区留言交流 。

Angular学习笔记24:Angular 响应式表单 FormArray 与 FormGroup 相互嵌套

作者 jiayu
2026年3月3日 16:47

Angular 响应式表单 FormArray 与 FormGroup 相互嵌套

在类文件中(组件的TS文件):
声明一个Form表单:
  

 public validateForm: FormGroup;


在构造方法中:
  

  private fb: FormBuild;


    声明一个FormBuild的对象

在构造方法中:
  

constructor(private fb: FormBuilder) {
     this.validateForm = this.fb.group({
        name: [null],
        sex : [null],
        age : [null],
        address: this.fb.array([
            new FormGroup({
                street : new FormControl(null),
                country: new FormControl(null),
            }),
        ]),
    });
  }

这样在组件中就构造出来了一个嵌套了FormArray的FormGroup,
这个时候,需要将validateForm这个表单中address的属性实例成一个FormArray

使用Angular中的get 方法

  

 get addressFormArray(){
        return this.validateForm.controls['address'] as FormArray;
    }

这个时候,在组件中就会生成一个变量:addressFormArray;
    当想对表单中的address中的控件进行操作,可以直接对变量:addressFormArray进行操作;

1.对validateForm中的address增加一对新的 street 和 country 有两种方法:
    a.使用变量addressFormArray,具体如下:
      

  this.addressFormArray.push(
            new FormGroup({
                street : new FormControl(null),
                country: new FormControl(null),
            }),
        )


    b.直接对validateForm进行操作
      

 (this.validateForm.controls['address'] as FormArray).push(
            new FormGroup({
                street : new FormControl(null),
                country: new FormControl(null),
            })
        )

对validateForm的增加,可以放在一个事件的方法里

2.去掉validateForm中的address对某一对属性的控制,
    正常情况下,是可以知道在当前删除的是 street 和 country在address这个数组中的下标,从而可以快速准确的删除,同样,删除也是可以有两种方式:
        a.使用变量addressFormArray,具体如下:
          

  this.addressFormArray.removAt(需要删除元素的下标)


        b.直接对validateForm进行操作,具体如下:
          

 (this.validateForm.controls['address'] as FormArray).removeAt(需要删除的数组的下标)


3.在模版文件中如何显示
    

<form [FormGroup]="validateForm">
        <div> 
            ... 
            <!-- 关于直接在FormGroup的部分省略 -->
        </div>
    <div FormArrayName="adderss">
        <div *ngFor="let address of validateForm.controls['address'].controls;let i = index">
            <div [formGroupName]="i">
                <div>
                    在这里就可以自己定义address 中FormGroup的内容了,增加关于FormGroup的控件。
                </div>
            </div>
            
        </div>
    </div>

 

Angular6学习笔记13:HTTP(3)

作者 jiayu
2026年3月3日 16:47

HTTP

继学习笔记12以后,可以模拟向后端发送get/post/put/delete请求了。在项目中,有一个table,这个table的数据非常多,就好比现在的heroList,需要根据用户输入的信息发送给远端服务器,让远端服务器通过这个信息,返回搜索结果。现在要检索heroList中的信息,就需要一个输入框,让用户输入检索的值,然后将这个值发送给远端服务器(模拟),然后让远端服务器(模拟)返回检索的结果。

1.在heroService中创建一个关于搜索的方法:searchHeroes()

searchHeroes(term: string): Observable<Hero[]> {
  if (!term.trim()) {
    return of([]);
  }
  return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
    tap(_ => this.log(`found heroes matching "${term}"`)),
    catchError(this.handleError<Hero[]>('searchHeroes', []))
  );
}

在这个方法中,当没有搜素词,则返回一个空的数组,当有搜索词的时候,在url中拼接上name

2.在仪表盘的组件中添加搜索功能

<h3>Top Heroes</h3>
<div class="grid grid-pad">
  <a *ngFor="let hero of heroes" class="col-1-4"
      routerLink="/detail/{{hero.id}}">
    <div class="module hero">
      <h4>{{hero.name}}</h4>
    </div>
  </a>
</div>

<app-hero-search></app-hero-search>

这里会让这个应用挂了,因为找不到<app-hero-search></app-hero-search>(接下来创建)

3.创建HeroSearchComponent

利用angular CLI 创建组件

ng generate component hero-search

CLI 生成了 HeroSearchComponent 的三个文件,并把该组件添加到了 AppModule 的声明中。

修改HeroSearch组件的模版文件:

<div id="search-component">
  <h4>Hero Search</h4>
  <input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
  <ul class="search-result">
    <li *ngFor="let hero of heroes$ | async" >
      <a routerLink="/detail/{{hero.id}}">
        {{hero.name}}
      </a>
    </li>
  </ul>
</div>

在模版文件中,创建了keyup 事件,这个keyup事件绑定会调用该组件的 search() 方法,并传入新的搜索框的值。

*ngFor 是在一个名叫 heroes$ 的列表上迭代,在这里$ 是一个命名惯例,用来表明 heroes$ 是一个 Observable,而不是数组。

正常情况下,*ngFor是不能直接使用Observable,此时,就要用到管道,利用管道字符(|),后面紧跟着一个 async,它表示 Angular 的 AsyncPipeAsyncPipe 会自动订阅到 Observable,这样你就不用再在组件类中订阅了。

美化这个HeroSearch组件,修改heroSearch的CSS文件

.search-result li {
  border-bottom: 1px solid gray;
  border-left: 1px solid gray;
  border-right: 1px solid gray;
  width:195px;
  height: 16px;
  padding: 5px;
  background-color: white;
  cursor: pointer;
  list-style-type: none;
}

.search-result li:hover {
  background-color: #607D8B;
}

.search-result li a {
  color: #888;
  display: block;
  text-decoration: none;
}

.search-result li a:hover {
  color: white;
}
.search-result li a:active {
  color: white;
}
#search-box {
  width: 200px;
  height: 20px;
}


ul.search-result {
  margin-top: 0;
  padding-left: 0;
}

修改HeroSearch 的类文件

import { Component, OnInit } from '@angular/core';

import { Observable, Subject } from 'rxjs';

import {
   debounceTime, distinctUntilChanged, switchMap
 } from 'rxjs/operators';

import { Hero } from '../hero';
import { HeroService } from '../hero.service';

@Component({
  selector: 'app-hero-search',
  templateUrl: './hero-search.component.html',
  styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
  heroes$: Observable<Hero[]>;
  private searchTerms = new Subject<string>();

  constructor(private heroService: HeroService) {}

  // Push a search term into the observable stream.
  search(term: string): void {
    this.searchTerms.next(term);
  }

  ngOnInit(): void {
    this.heroes$ = this.searchTerms.pipe(
      // wait 300ms after each keystroke before considering the term
      debounceTime(300),

      // ignore new term if same as previous term
      distinctUntilChanged(),

      // switch to new search observable each time the term changes
      switchMap((term: string) => this.heroService.searchHeroes(term)),
    );
  }
}

注意,heroes$ 声明为一个 Observable

a.RxJS Subject 类型的 searchTerms

Subject 既是可观察对象的数据源,本身也是 Observable。可以像订阅任何 Observable 一样订阅 Subject。还可以通过调用它的 next(value) 方法往 Observable 中推送一些值,就像 search() 方法中一样。search() 是通过对文本框的 keystroke 事件的事件绑定来调用的。

private searchTerms = new Subject<string>();

search(term: string): void {
  this.searchTerms.next(term);
}

每当用户在文本框中输入时,这个事件绑定就会使用文本框的值(搜索词)调用 search() 函数。 searchTerms 变成了一个能发出搜索词的稳定的流。

b.串联 RxJS 操作符

每当用户击键后就直接调用 searchHeroes() 将导致创建海量的 HTTP 请求,浪费服务器资源并消耗大量网络流量。

应该怎么做呢?ngOnInit() 往 searchTerms 这个可观察对象的处理管道中加入了一系列 RxJS 操作符,用以缩减对 searchHeroes() 的调用次数,并最终返回一个可及时给出英雄搜索结果的可观察对象(每次都是 Hero[] )。

this.heroes$ = this.searchTerms.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((term: string) => this.heroService.searchHeroes(term)),
);
  • 在传出最终字符串之前,debounceTime(300) 将会等待,直到新增字符串的事件暂停了 300 毫秒。 你实际发起请求的间隔永远不会小于 300ms。

  • distinctUntilChanged() 会确保只在过滤条件变化时才发送请求。

  • switchMap() 会为每个从 debounce 和 distinctUntilChanged 中通过的搜索词调用搜索服务。 它会取消并丢弃以前的搜索可观察对象,只保留最近的。

借助 switchMap 操作符, 每个有效的击键事件都会触发一次 HttpClient.get() 方法调用。 即使在每个请求之间都有至少 300ms 的间隔,仍然可能会同时存在多个尚未返回的 HTTP 请求。

switchMap() 会记住原始的请求顺序,只会返回最近一次 HTTP 方法调用的结果。 以前的那些请求都会被取消和舍弃。

注意,取消前一个 searchHeroes() 可观察对象并不会中止尚未完成的 HTTP 请求。 那些不想要的结果只会在它们抵达应用代码之前被舍弃。

 

 

 

 

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

2026年3月3日 16:30

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

一个让人头秃的 bug

上周组里一个同事来问我:"为什么我给 reactive 对象加了个新属性,页面不更新?"

我看了一眼代码——Vue2 的写法,用的 Vue3 的 API。

const state = reactive({ name: '张三' })

// 他的操作:
state.age = 25 // 页面更新了 ✅(Vue3 没问题)

// 但他之前在 Vue2 项目里被坑过,条件反射写了:
Vue.set(state, 'age', 25) // Vue3 里根本没有 Vue.set 了

这件事让我意识到:很多人用了两三年 Vue3,但对响应式到底怎么工作的,还是停留在"Proxy 比 defineProperty 好"这句话上。

好在哪?为什么好?依赖怎么收集的?什么时候触发更新?

今天咱们把这事彻底说清楚,顺便手写一个能跑的迷你 reactivity。


Vue2 的 defineProperty 到底差在哪

先别急着夸 Proxy,得知道 Vue2 为啥被淘汰。

// Vue2 的响应式核心:逐个属性拦截
Object.defineProperty(obj, 'name', {
  get() {
    // 收集依赖
    return value
  },
  set(newVal) {
    value = newVal
    // 通知更新
  }
})

// ❌ 问题1:新增属性拦截不到,必须用 Vue.set
obj.age = 25 // set 根本不会触发,页面不更新

// ❌ 问题2:数组下标修改拦截不到
arr[0] = 'new' // 没反应

// ❌ 问题3:初始化时要递归遍历整个对象,性能差
// 1000 个属性 → 1000 次 defineProperty

本质问题就一句话:defineProperty 是"属性级别"的拦截,你得提前知道有哪些属性。

这就像安检——defineProperty 是给每个人单独装一个安检门,来一个新人得现装;Proxy 是在大楼入口装一个,谁进来都得过。


Proxy:对象级别的拦截

const raw = { name: '张三', age: 25 }

const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取了 ${key}`)  // 任何属性的读取都能拦截
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置了 ${key} = ${value}`)
    target[key] = value
    return true  // set 必须返回 true,不然严格模式报错
  }
})

proxy.name           // → "读取了 name"
proxy.hobby = '摸鱼'  // → "设置了 hobby = 摸鱼"  ✅ 新增属性也能拦截!
delete proxy.age     // 配合 deleteProperty trap,删除也能拦截

不用提前遍历,不用 Vue.set,天然支持新增/删除属性。这不是"好一点",这是降维打击。


光有 Proxy 还不够

拦截到读写只是第一步。关键问题是:谁在读?读了之后要通知谁?

这就是依赖收集。

想象一个场景:你在公司群里发了条消息"今晚团建",但不是所有人都需要知道——只有你组里的人需要收到通知。响应式系统干的就是这事:精准投递,别群发。

核心流程就三步:

  1. 读取(get) → 谁在读我?记住他(track)
  2. 修改(set) → 值变了,通知所有记住的人(trigger)
  3. effect → "那个在读的人"到底是谁?就是当前正在执行的副作用函数

手撸一个迷你 reactivity

别怕,核心代码不到 80 行。

第一步:全局变量——当前正在执行的 effect

// 全局指针:当前谁在执行?
// 这是整个系统的"指挥棒"
let activeEffect: Function | null = null

function effect(fn: Function) {
  activeEffect = fn  // 先把"当前执行者"挂上
  fn()               // 执行函数 → 函数内部会读取响应式数据 → 触发 get
  activeEffect = null // 执行完了,摘掉
}

这里有个精妙的设计:执行 fn() 的时候,fn 内部读取了什么属性,Proxy 的 get 就知道"当前是谁在读"。

时序上是这样的:

effect(() => console.log(state.name))
│
├─ activeEffect = fn        ← 挂上
├─ fn()                     ← 开始执行
│   └─ 读取 state.name      ← 触发 Proxy get
│       └─ get 里发现 activeEffect 不为 null
│           └─ 记住:name 这个属性被 fn 依赖了!(track)
└─ activeEffect = null       ← 摘掉

第二步:依赖存储结构

// 依赖关系的存储:target → key → Set<effect>
// 用 WeakMap 是为了不阻止对象被垃圾回收
const targetMap = new WeakMap<object, Map<string | symbol, Set<Function>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return // 没人在执行 effect,不用收集

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }

  deps.add(activeEffect) // 把当前 effect 加进去
}

数据结构长这样:

targetMap (WeakMap)
  └─ { name: '张三', age: 25 }  (Map)
       ├─ 'name'Set [ effect1, effect2 ]
       └─ 'age'Set [ effect3 ]

为什么用三层结构? 因为一个应用里有多个响应式对象(target),每个对象有多个属性(key),每个属性可能被多个 effect 依赖。三层刚好,多了浪费,少了不够。

第三步:触发更新

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const deps = depsMap.get(key)
  if (!deps) return

  // 遍历所有依赖了这个 key 的 effect,逐个执行
  deps.forEach(effect => effect())
}

简单粗暴:找到谁依赖了这个 key,挨个重新执行。

第四步:组装 reactive

function reactive<T extends object>(raw: T): T {
  return new Proxy(raw, {
    get(target, key, receiver) {
      track(target, key)                  // 读取时收集依赖
      const result = Reflect.get(target, key, receiver)

      // 如果值是对象,递归代理(懒代理,用到才包)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },

    set(target, key, value, receiver) {
      const oldValue = target[key as keyof T]
      const result = Reflect.set(target, key, value, receiver)

      if (oldValue !== value) {
        trigger(target, key)              // 值变了才触发,没变不浪费
      }
      return result
    }
  })
}

注意两个细节:

  • 懒代理:Vue3 不会在初始化时递归代理整个对象,只有 get 到嵌套对象时才代理。对比 Vue2 初始化就递归遍历,这就是性能差距。
  • Reflect.get/set:不直接用 target[key],因为 Reflect 能正确处理 this 指向和继承问题。你可能觉得"直接读不也行吗"——行,但在有 getter/继承的场景会出 bug。

跑一下看看

const state = reactive({ count: 0, msg: 'hello' })

// effect 1:只依赖 count
effect(() => {
  console.log('count changed:', state.count)
})
// 立即输出:count changed: 0

// effect 2:只依赖 msg
effect(() => {
  console.log('msg changed:', state.msg)
})
// 立即输出:msg changed: hello

state.count++
// → "count changed: 1"   ✅ effect1 触发
// → (effect2 没触发)      ✅ 精准更新,不是无脑全刷

state.msg = 'world'
// → "msg changed: world"  ✅ effect2 触发

70 多行代码,一个能跑的响应式系统就出来了。


设计权衡:Vue3 做了哪些取舍

为什么用 WeakMap 而不是 Map?

// WeakMap 的 key 是弱引用,对象没有其他引用时会被 GC 回收
// 如果用 Map → 响应式对象永远被 targetMap 引用 → 内存泄漏
const targetMap = new WeakMap() // ✅
const targetMap = new Map()     // ❌ 内存泄漏风险

为什么 effect 要立即执行一次?

因为不执行就收集不到依赖。依赖收集发生在 get 里,不读一遍属性,系统不知道你依赖了谁。

这也是 watchEffectwatch 的核心区别:

// watchEffect → 立即执行,自动收集依赖
watchEffect(() => {
  console.log(state.count) // 读了 count → 自动依赖 count
})

// watch → 你手动告诉它监听谁
watch(() => state.count, (newVal) => {
  console.log(newVal)
})

懒代理 vs 初始化全量代理

策略 初始化耗时 运行时耗时 适合场景
Vue2 全量递归 高(大对象很慢) 小型对象
Vue3 懒代理 几乎为零 首次访问有微小开销 大型 / 深层嵌套对象

Vue3 选了懒代理,因为实际项目中大部分属性不会在首帧全部读取——你一个 1000 行的 config 对象,首屏可能只用了 5 个字段,全量代理纯属浪费。


我们的迷你版漏了什么

写到这里你可能觉得"就这?挺简单啊"。别急,真实的 Vue3 reactivity 还处理了一堆你想不到的边界情况:

1. effect 嵌套

effect(() => {
  console.log('outer', state.a)
  effect(() => {
    console.log('inner', state.b) // 内层 effect 执行完,activeEffect 被置为 null
  })
  console.log(state.c) // ❌ 这里 activeEffect 已经是 null 了,c 的依赖收集不到!
})

Vue3 用 effectStack(栈结构)解决这个问题——进入 effect 时 push,退出时 pop,恢复上一层的 activeEffect。

2. 无限循环

effect(() => {
  state.count = state.count + 1 // 读 count → 触发 get → 收集依赖
                                 // 写 count → 触发 set → 执行 effect
                                 // effect 又读 count → 又触发 set → 💥 死循环
})

Vue3 的解法:如果当前正在执行的 effect 和要触发的 effect 是同一个,跳过。

3. ref 的存在意义

// reactive 只能代理对象
const count = reactive(0)  // ❌ Proxy 不能代理基本类型

// ref 用对象包一层,把基本类型变成对象
const count = ref(0)       // 内部:{ value: 0 } → 再用 reactive 代理
count.value++               // 通过 .value 触发 get/set

所以 ref.value 不是脱裤子放屁——是基本类型没法直接 Proxy 的无奈之举。

4. 集合类型的处理

const map = reactive(new Map())
map.set('key', 'value') // set 方法不是赋值操作,Proxy 的 set trap 拦不到

// Vue3 对 Map/Set/WeakMap/WeakSet 做了专门的 handler
// 拦截的是 get → 拿到 set 方法 → 返回一个包装后的 set 方法

这部分代码在 Vue3 源码里占了不少篇幅,也是最容易被忽略的。


可扩展性:这套模型能做什么

这套 track → trigger → effect 模型不只是给 Vue 用的,它本质上是一个自动依赖追踪的发布-订阅系统

你完全可以用它来做:

  • 状态管理:Pinia 的底层就是 reactive + 一些封装
  • 计算缓存:computed 就是一个带 dirty 标记的 effect
  • 跨组件通信:provide/inject + reactive = 自动响应式的上下文注入
  • 表单引擎:字段之间的联动关系,天然适合响应式依赖图

如果你的项目需要一套"数据变了自动通知"的机制,不一定要上 Vue,把这 70 行代码抄走改改就能用。


总结:一个通用模型

Vue3 响应式的本质,是解决一个古老的编程问题:状态同步。

A 变了,B 要跟着变。手动同步容易漏、容易错、容易忘。

Vue3 的解法是:

  1. Proxy 拦截读写——知道谁被读了、谁被改了
  2. effect + activeEffect——知道"谁在关心这个数据"
  3. track / trigger——自动建立和触发依赖关系
  4. WeakMap 三层结构——高效存储依赖映射

以后遇到类似的问题,不管是前端状态管理、后端事件驱动、还是 Excel 的单元格公式联动,底层模型都是一样的:观察者模式 + 自动依赖追踪。

区别只在于:谁来当观察者,怎么收集依赖,粒度做到多细。

想明白这一层,你看任何响应式框架(Solid、Svelte 5 runes、Preact signals)都会觉得——嗯,换了个壳,内核没跑出这个圈。

ln Cheatsheet

Basic Syntax

Core ln command forms.

Command Description
ln TARGET LINK_NAME Create a hard link
ln -s TARGET LINK_NAME Create a symbolic (soft) link
ln -sf TARGET LINK_NAME Create or overwrite a symbolic link
ln TARGET... DIRECTORY Create hard links to multiple targets in a directory
ln -s TARGET... DIRECTORY Create symbolic links to multiple targets in a directory

Symbolic Links

Create and manage soft links that point to a path.

Command Description
ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app Enable an Nginx virtual host
ln -s /usr/bin/python3 /usr/local/bin/python Create a python alias
ln -s /opt/myapp /usr/local/bin/myapp Link a binary into PATH
ln -s TARGET . Create a symlink to TARGET in the current directory
ln -sf NEW_TARGET LINK_NAME Update an existing symlink to point to a new target

Hard Links

Create additional directory entries pointing to the same inode.

Command Description
ln source.txt hardlink.txt Create a hard link to a file
ln file1.txt file2.txt /backup/ Create hard links to multiple files in a directory
ln -v source.txt link.txt Show each link as it is created

Hard links share the same inode and data. Removing one does not delete the data until all hard links are removed. Hard links cannot span filesystems and cannot be created for directories.

Link Options

Flags for controlling overwrite, backup, and verbosity behavior.

Option Description
-s, --symbolic Create a symbolic link instead of a hard link
-f, --force Remove the destination file if it exists before linking
-b, --backup Make a backup of each existing destination file
-i, --interactive Prompt before removing an existing destination file
-n, --no-dereference Treat a symlink to a directory as a normal file
-v, --verbose Print the name of each linked file
-r, --relative Create symbolic links relative to the link location

Inspect and Verify Links

Check where links point and confirm they are valid.

Command Description
ls -l link_name Show the link and its target
ls -la /path/ List all entries including hidden symlinks
readlink link_name Print the target of a symbolic link
readlink -f link_name Print the fully resolved absolute path
stat link_name Show full metadata including inode and link count
file link_name Identify whether a path is a symbolic link
find . -type l Find all symbolic links under the current directory
find . -xtype l Find broken symbolic links

Troubleshooting

Quick checks for common ln issues.

Issue Check
File exists Use -f to force overwrite, or -i to confirm before replacing
Too many levels of symbolic links A circular symlink chain exists; use readlink -f to trace the path
Invalid cross-device link Hard links cannot span different filesystems; use a symbolic link instead
Broken symlink (dangling) The target path no longer exists; update with ln -sf NEW_TARGET LINK_NAME
Symlink points to wrong target Run readlink link_name to confirm the current target, then use ln -sf to correct it

Related Guides

Use these guides for full workflows and file management patterns.

Guide Description
ln Command in Linux Full ln tutorial with examples
How to Remove Symbolic Links in Linux Delete symlinks safely
unlink Command in Linux Remove a single link entry
ls Command in Linux List and inspect files and links
chmod Command in Linux Set permissions on files and links

Tapable学习

作者 evelynlab
2026年3月3日 16:25

2020.2.28

前言

看webpack源码就绕不过Tapable,Tapable介绍里都会提到这么一句:Tapable是个类似于Node.js EventEmitter的库。于是不禁发问:EventEmitter有什么场景无法满足事件管理,导致开发了个Tapable呢?

本文带着这个问题,对Tapable是什么、如何使用、底层如何实现等知识进行介绍。

NodeJS EventEmitter

先来看一个使用EventEmitter注册自定义事件并触发的例子:

const EventEmitter = require('events').EventEmitter; 
const events = new EventEmitter()
// 注册自定义事件
events.on('login', (param) => {
  console.log('triggered', param)
})
events.once('logout', function() {
  console.log('logout triggered')
})
// 触发
events.emit('login', 'success') // triggered sucess
events.emit('loginout') // logout triggered

EventEmitter:

1.提供了on emit once三个方法

2.如果给一个事件订阅了多个监听器,那么会按注册顺序执行

那么,如果一个事件名称注册了多个监听器,如何控制这些监听器的执行顺序?如何在监听器之间传递值?如何中止某个监听器的执行?监听器有多个异步操作,想等这些异步操作都执行完了如何处理?

似乎做不到,猜想也许正是由于这些问题,产生了Tapable.

Tapable是什么

一个用于自定义事件的触发和处理管理的库,比EventEmitter强大。它定义了多种事件类型,能以更多方式控制监听器的执行,具体来说,

Tapable提供了9个钩子类型,使用不同类型的钩子自定义事件,能覆盖以下场景:

  • 连续地执行监听器
  • 并行地执行监听器
  • 一个接一个地执行监听器,从前面的监听器获取输入
  • 异步地执行监听器
  • 在允许时停止执行监听器

Tapable是webpack的一个核心组件,但它也可以用于其他类似提供插件接口场景的应用。

webpack中很多对象是继承自Tapable的.

Tapable的9个钩子类型

const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
 } = require("tapable");
序号 钩子名称 执行方式 特点
1 SyncHook 同步串行 不关心监听函数返回值
2 SyncBailHook 同步串行 监听函数中只要有一个return 有值,后面的就不执行
3 SyncWaterfallHook 同步串行 上一个监听函数返回值可以传递给下一个监听函数
4 SyncLoopHook 同步循环 监听函数返回true则一直循环执行,返回undefined则停止
5 AsyncParallelHook 异步并行 不关心监听函数返回值
6 AsyncParallelBailHook 异步并行 监听函数返回值不为null则后面的不执行,然后执行callAsync的回调函数(如果有的话)
7 AsyncSeriesHook 异步串行 不关心监听函数的参数
8 AsyncSeriesWaterfallHook 异步串行 监听函数的参数不为null,则直接执行callAsync的回调函数(如果有的话)
9 AsyncSeriesBailHook 异步串行 上一个监听函数callback(err, data)的第二个参数data作为值传递给下一个监听函数的参数

分类

每个钩子都可以订阅一个或者多个function。根据这些function如何执行可以将钩子分为4类:

  • basic hook 按顺序执行
  • waterfall 瀑布流式执行,与basic hook不同的是,可以在相邻的function之间传值
  • bail 允许你提前退出,如果有一个function有返回值,则停下来,后面的function就不执行了
  • loop 允许循环执行function

换个维度,根据钩子是同步的还是异步的,还可以分为3类:

  • Sync 同步函数
  • AsyncSeries,异步function串行执行
  • AsyncParallel,异步funciton并行执行

分类

每个钩子都可以订阅一个或者多个function。根据这些function如何执行可以将钩子分为4类:

  • basic hook 按顺序执行
  • waterfall 瀑布流式执行,与basic hook不同的是,可以在相邻的function之间传值
  • bail 允许你提前退出,如果有一个function有返回值,则停下来,后面的function就不执行了
  • loop 允许循环执行function

换个维度,根据钩子是同步的还是异步的,还可以分为3类:

  • Sync 同步函数
  • AsyncSeries,异步function串行执行
  • AsyncParallel,异步funciton并行执行

Tapable的使用

基本用法

注册

注册:tap/tapAsync/tapPromise

其中同步钩子使用tap注册,异步钩子使用tapAsync/tapPromise注册(效果不同)

调用

call/callAsync

Tapable SyncHook Demo:

const { SyncHook } = require('tapable')
const hook = new SyncHook(['arg1', 'arg2'])  // 自定义callback的参数

hook.tap('eventname', (arg1, arg2, arg3) => {
  console.log(arg1, arg2, arg3) // 1, undefined, undefined
})

hook.call(1)
const { SyncHook } = require('tapable')
class Car {
  constructor() {
    this.hooks = {
      accelarate: new SyncHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}
const myCar = new Car()

myCar.hooks.accelarate.tap('eventname1', (speed) => {
  console.log('speed cb 1:', speed)
})

myCar.hooks.accelarate.tap('eventname2', (speed) => {
  console.log('speed cb 2:', speed)
})

myCar.hooks.accelarate.call(50)

拦截器interception

tapable还提供了拦截器。所有的钩子都提供了拦截API, 共有register, call, tap, loop 四个API,这里不展开讲

原理实现

从上面的demo来看,Tapable的使用方式与EventEmitter还是不太一样的。(不同于on emit)那么它是如何实现事件的监听与触发的呢(call方法执行时如何找到监听器函数并按规则执行)?

SyncHook源码阅读

我们以SyncHook为例,了解下内部机制:

Tapable声明了四个类

class Hook {} // 基础的Hook类

class SyncHook extends Hook {} // 同步钩子类

class HookCodeFactory {}  // 用于生成hook代码的工厂类,类里的create方法使用new Function 为sync, async, promise三类钩子生成call时的fn

class SyncHookCodeFactory extends HookCodeFactory {}

其中,HookCodeFactory是编译生成可执行 fn 的工厂类,这意味着Hook的函数体代码是拼接生成的, HookCodeFactory类里的create方法使用new Function 为sync, async, promise三类钩子生成函数代码

初始化

class Hook {
  // ...
}
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
} // constructor里: this.call = this._call;

其中createCompileDelegate:

function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
this[name] = this._createCall(type); // name: 'call'
return this[name](...args);
};
}

这里lazyCompileHook是个惰性函数 ,创建this.call函数,并在下一次使用时直接返回(提升性能)

其中_createCall是Hook类的成员函数:

class Hook {
// ...
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
// ...
}

其中compile:

class Hook {
// ...
  compile(options) {
factory.setup(this, options); // 取出taps里的fn
return factory.create(options);  // factory即是上述四个类中的SyncHookCodeFactory,create用来生成代码
}
// ...
}

factory.create根据"call", "sync"两个参数,生成了方法的函数体代码,这样就定义好了call函数:

class HookCodeFactory {
  create(options) {
this.init(options);
let fn;
switch (this.options.type) {
case "sync":
fn = new Function( // 生成动态的function
this.args(),
'"use strict";\n' +
this.header() +
this.content({ // this.content 每种类型的钩子不同的实现 会去取this.taps
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
resultReturns: true,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `_callback(${err});\n`,
onResult: result => `_callback(null, ${result});\n`,
onDone: () => "_callback();\n"
})
);
break;
case "promise":
let errorHelperUsed = false;
const content = this.content({
onError: err => {
errorHelperUsed = true;
return `_error(${err});\n`;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
let code = "";
code += '"use strict";\n';
code += "return new Promise((_resolve, _reject) => {\n";
if (errorHelperUsed) {
code += "var _sync = true;\n";
code += "function _error(_err) {\n";
code += "if(_sync)\n";
code += "_resolve(Promise.resolve().then(() => { throw _err; }));\n";
code += "else\n";
code += "_reject(_err);\n";
code += "};\n";
}
code += this.header();
code += content;
if (errorHelperUsed) {
code += "_sync = false;\n";
}
code += "});\n";
fn = new Function(this.args(), code);
break;
}
this.deinit();
return fn;
}
}

这里的new Function解释下:我们知道,js中定义函数常用的有:

// 定义1. 函数声明
function add(a, b){
    return a + b
}

// 定义2. 函数表达式
const add = function(a, b){
    return a + b
}
```js
还有第三种,我们平常用的相对较少:
```js
// 定义3. new Function
const add = new Function('a', 'b', 'return a + b')

他们的区别就是前两种是静态的,函数的功能是做什么是在定义时就定下来了;而第三种是动态的,也就是函数的功能可能会随程序运行发生变化。

tap收集监听器

class Hook {
  constructor(args) {
this.taps = [];
this.call = this._call;
}
  tap(options, fn) {
      this._insert(options);
  }
  _insert(item) {
    // ...
    // 检查name 
    if (before.has(x.name)) {
before.delete(x.name);
continue;
}
      this.taps[i] = item; // this.taps里保存了所有的监听器
  // ...
  }
}

示例

假设有下列demo使用代码:

const { SyncHook } = require('tapable')

class Car {
  constructor() {
    this.hooks = {
      accelarate: new SyncHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}

const myCar = new Car()

myCar.hooks.accelarate.tap('eventname1', (speed) => {
  console.log('speed cb 1:', speed)
})

myCar.hooks.accelarate.tap('eventname2', (speed) => {
  console.log('speed cb 2:', speed)
})

myCar.hooks.accelarate.call(50) // createCall时,已经根据之前tap,生成了call时的代码。 call执行时,会依次执行callback代码

这里我们执行到call时,进入函数,断点看一下call方法的函数体代码:

function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];// 注册的第一个监听器
  _fn0(newSpeed); // 执行
  var _fn1 = _x[1]; // 注册的第二个监听器
  _fn1(newSpeed);// 执行
}

总结一下,比较让我印象深刻的是:generate code这个地方

官网上 有段介绍很棒:

The Hook will compile a method with the most efficient way of running your plugins. It generates code depending on:

  • The number of registered plugins (none, one, many)
  • The kind of registered plugins (sync, async, promise)
  • The used call method (sync, async, promise)
  • The number of arguments
  • Whether interception is used

This ensures fastest possible execution.

也就是,Tapable会根据注册的监听器的数量、种类、call方法,参数个数,是否有拦截器等生成钩子的本体代码。

其他类型Hook如何管理顺序的?

SyncBailHook this.call.toString():

"function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  var _result0 = _fn0(newSpeed);
  if(_result0 !== undefined) {
    return _result0;;
  } else {
    var _fn1 = _x[1];
    var _result1 = _fn1(newSpeed);
    if(_result1 !== undefined) {
      return _result1;
    } else {
    }
  }
}"

SyncWaterfallHook  this.call.toString():

"function anonymous(newSpeed
/*``*/) {
  "use strict";
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  var _result0 = _fn0(newSpeed);
  if(_result0 !== undefined) {
  newSpeed = _result0;
  }
  var _fn1 = _x[1];
  var _result1 = _fn1(newSpeed);
  if(_result1 !== undefined) {
  newSpeed = _result1;
  }
  return newSpeed;
}"

AsyncParallelHook Demo:

const { AsyncParallelHook, SyncHook } = require('tapable')

class Car {
  constructor() {
    this.hooks = {
      accelarate: new AsyncParallelHook(['newSpeed']),
      brake: new SyncHook(),
    }
  }
}

const myCar = new Car()

// 1. tap
// myCar.hooks.accelarate.tap('eventname1', (speed) => {
//   console.log('speed cb 1:', speed)
// })

// myCar.hooks.accelarate.tap('eventname2', (speed) => {
//   console.log('speed cb 2:', speed)
// })

// myCar.hooks.accelarate.callAsync(50, (err) => {
//   console.log('end') // 
// })

// 2. tapAsync 注意这种用法里 参数多了个cb 回调
myCar.hooks.accelarate.tapAsync('eventname1', (speed, cb) => {
  setTimeout(() => {
    console.log(1, speed);
    cb();
}, 1000);
})

myCar.hooks.accelarate.tapAsync('eventname2', (speed, cb) => {
  setTimeout(() => {
    console.log(2, speed);
    cb();
}, 2000);
})

myCar.hooks.accelarate.callAsync(50, () => {
  console.log('end') // 
})

// 3.tapPromise
// myCar.hooks.accelarate.tapPromise('eventname1', (speed, cb) => {
//   return new Promise(function (resolve, reject) {
//     setTimeout(() => {
//         console.log(1, speed);
//         resolve();
//     }, 1000);
//   });
// })

// myCar.hooks.accelarate.tapPromise('eventname2', (speed, cb) => {
//   return new Promise(function (resolve, reject) {
//     setTimeout(() => {
//         console.log(2, speed);
//         resolve();
//     }, 2000);
//   });
// })

// myCar.hooks.accelarate.promise(50).then(() => {
//   console.log('end')
// })

AsyncParallelHook  this.callAsync.toString():

function anonymous(
  newSpeed,
  _callback,
  /*``*/
) {
  'use strict';
  var _context;
  var _x = this._x;
  do {
    var _counter = 2;
    var _done = () => {
      _callback();
    };
    if (_counter <= 0) break;
    var _fn0 = _x[0];
    _fn0(newSpeed, _err0 => {
      if (_err0) {
        if (_counter > 0) {
          _callback(_err0);
          _counter = 0;
        }
      } else {
        if (--_counter === 0) _done();
      }
    });
    if (_counter <= 0) break;
    var _fn1 = _x[1];
    _fn1(newSpeed, _err1 => {
      if (_err1) {
        if (_counter > 0) {
          _callback(_err1);
          _counter = 0;
        }
      } else {
        if (--_counter === 0) _done();
      }
    });
  } while (false);
}

AsyncParallel counter计数器,每执行完一个计数器减一,减到0则执行回调

function anonymous(
  newSpeed,
  _callback
  /*``*/
) {
  "use strict";
  var _context;
  var _x = this._x;
  function _next0() {
    var _fn1 = _x[1];
    _fn1(newSpeed, _err1 => {
      if (_err1) {
        _callback(_err1);
      } else {
        _callback();
      }
    });
  }
  var _fn0 = _x[0];
  _fn0(newSpeed, _err0 => {
    if (_err0) {
      _callback(_err0);
    } else {
      _next0();
    }
  });
}

AsyncSeriesHook 按顺序执行,回调函数执行法

Tips

Tapable 2.0 处于beta阶段,据说针对async的执行进行了优化(减小内存消耗问题,消除递归)

webpack + Tapable

webpack 就像一条生产线,具有多个处理流程,每个流程职责单一,流程之间有依赖关系,处理完交个下个流程。

插件就像插入到生产线中的一个功能,能在特定的时机对生产线上的资源做处理。

加入生产线的方式就是监听webpack广播出来的事件。

webpack通过Tapable来组织这条生产线。

好处:有序性好、扩展性好

webpack中都使用了什么样的钩子呢?

Compiler钩子 类型 描述
done AsyncSeriesHook 如果有两个插件监听了done钩子,意味着两个顺序执行完了才会执行回调
entryOption AsyncBailHook 如果有两个插件监听了entryOption钩子,意味着如果两个插件都开启了时,有一个plugin触发了如果有返回值,则另一个不会触发
make AsyncParallelHook 如果有四个插件监听了make钩子,意味着call触发时,回调函数会等待这四个插件的监听器并行执行完才会执行

调试技巧

1.断点查看function body code: fn.toString()

Reference

  1. 编写自定义webpack插件从理解Tapable开始: juejin.im/post/5dcba2…
  2. webpack4.0源码分析之Tapable: juejin.im/post/5abf33… (demo较多)
  3. Tapable Github:github.com/webpack/tap…
  4. js之惰性函数:juejin.im/entry/5a629…
  5. 可能是全网最全最新最细的 webpack-tapable-2.0 的源码分析

打包原理

作者 evelynlab
2026年3月3日 16:11

现在我们站在打包工具的角度来理解下打包原理,假设我们自己要去做一个打包工具,想象一下都应该做什么,怎么做。

打包工具都应该做什么

打包工具负责把源码打包成目标代码。

这个打包过程就涉及很多问题了,先看下源码都长什么样

  • 源码中JS可能是由各种语法来写的,ES6, ES7, TS等;
  • 源码不止包含JS, 还有静态资源,例如CSS, 图片等;
  • 源码的代码组织方式上,出于模块化的编程思想,项目代码会被自己拆解为多个文件,文件之间通过一定的依赖关系组织在一起;
  • 每个文件(模块)可能遵循不同的模块规范(CommonJS, ES6 module, AMD etc);

而目标代码是可能运行在不同环境上的,例如浏览器,NodeJS等。

因此,打包工具要做的就是把这样的源码打包成能运行在目标环境中的代码。

由此,一款打包工具应该做哪些工作从上述分析源码特点这里就可以知道了,它需要解决的问题至少有下面几个:

  • 转换

    • 例如语法转换(使用了ES6,TS等语法要转换为目标环境可以运行的语法)
  • 非JS的静态资源的处理

    • 例如CSS图片资源的处理
  • 识别各类模块规范并处理,并解析模块依赖关系

    • 例如CommonJS 中的require,浏览器并不认识,如何处理成为它可以运行的代码
    • 捋清楚模块的关系,打包到一起
  • 创建bundle代码

    • 针对一个入口,最终代码还是要化多模块为一份代码文件
  • 生成bundle文件

  • 输出代码文件,写入磁盘

三个打包工具如何实现的

那么,Webpack, Rollup, Parcel是现有的较为流行的打包工具,他们都是如何处理这些问题的呢?

(以下内容都假设目标环境是web)版本分别为:rollup: 1.28.0 webpack: 4.41.5 parcel-bundler:1.12.4

功能 Webpack Rollup Parcel
转换 js语法:需要配置babel-loader等完成转换 js语法:借助插件rollup-plugin-babel转换 js语法:无需配置,默认使用@babel/preset-env转换 开箱即用
非JS静态资源的处理 利用loader处理为模块(webpack主打“一切皆模块”的思想) 借助插件: - rollup-plugin-postcss - rollup-plugin-copy-assets 非js资源实际上支持的有限,与rollup定位有关系,适合库打包 无需配置,CSS/Less默认支持(会抽CSS为单独文件) Postcss需要.postcssrc文件 图片自动打包
解析模块依赖关系 使用acorn解析AST收集依赖关系 使用acorn解析AST收集依赖关系 使用babel解析AST收集依赖关系
创建bundle代码 使用template, __webpack_require__等辅助函数替换等,拼接成bundle代码,bundle形式:自执行函数,详见下述分析 利用magicString,代码拼接。bundle形式:自执行函数,代码粘贴,无额外辅助函数,详见下述分析 构建资源树,根据资源树构建Bundle树
生成bundle文件 使用文件系统如fs.writeFile写入磁盘 fs.writeFile写入磁盘 bundle形式:自执行函数,详见下述分析

这里我们来比较下在创建bundle代码上三者的不同:

Bundle形式/模块机制

webpack

webpack的模块我们看下打包后的代码形式:

// src/index.js:
const { b } = require('./b.js')
console.log(b)
// src/b.js
module.exports = {
  b: 'b'
}

// webpack.config.js mode:development

const path = require('path');
module.exports = {
    entry: {
      'index': './src/index.js',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].js'
    },
    devtool: 'source-map',
    mode: 'development',
};

Webpack打包结果, mode:development, src代码使用commonjs模块规范

(function(modules) {
  function __webpack_require__(moduleId) {...}
  // ...
  return __webpack_require__(入口文件) // 假设入口文件是src/index.js
})(obj);

其中,参数modules传入的obj的 key, value是下列形式:key是模块文件路径,value是个function, function内部是:转换后的模块代码,也就是,把模块用function包裹了一层:

(mode:production 使用es6模块规范时,key 是id, mode:development 使用es6模块规范时,key是文件名称)

obj:

{
"./src/index.js": function() { 转换后当前模块的代码 },  // function 的三个参数:module, __webpack_exports__, __webpack_require__
"./src/b.js": function () { 转换后当前模块的代码 }
}

整个obj作为参数传递给自执行函数,自执行的结果应该是__webpack_require__(入口文件)的执行结果。

(可谓是运行时获取,即层层依赖的模块是在运行__webpack_require__时才拿到模块代码执行的。)

Rollup

再来看rollup rollup.config.js:

export default {
  input: 'src/index.js',
  output: {
    file: 'dist/bundle.js',
    format: 'iife', // iife, cjs, amd, esm, umd
  }
}

src/index.js rollup默认使用ES6模块作为标准:

import { b } from './b'
console.log(b)

src/b.js:

export const b = 'b'

rollup打包结果:

(function () {
'use strict';
const b = 'b';
console.log(b);
}());

可以看到,rollup所采取的方式更直接一些,output format为iife时,外层是自执行函数,内部代码在导入模块时的处理像是将代码“粘贴”过去了一样,没有额外的require的实现。模块代码搬运到一起实现最终的输出。

可能会有疑问,变量名冲突了怎么办?rollup会自动为冲突的变量重新起名字。例如变量b出现了两次,那么就是var b, var b$1,...

Parcel

再看下parcel

parcel主打0配置和极速编译,所以没有配置文件

src/index.js:

const { b } = require('./b.js')
console.log(b)

src/b.js:

module.exports = {
  b: 'b'
}

parcel区分dev product是靠运行指令 parcel xxx.html/js 还是 parcel build xxx.html/js 来区分的。

这里,我们执行parcel index.html:

parcelRequire = (function(modules, cache, entry, globalName) { // entry数组
    var nodeRequire = typeof require === 'function' && require; 
  function newRequire(name, jumped) {
          if (!cache[name]) { 
            // ...
            // 没有模块缓存则取出模块执行
             modules[name][0].call(module.exports, localRequire, module, module.exports, this);
          }
      // 有模块缓存则返回模块
          return cache[name].exports;
  }
    newRequire.isParcelRequire = true;
    newRequire.isParcelRequire = true;
    newRequire.Module = Module;
    newRequire.modules = modules;
    newRequire.cache = cache;
    newRequire.parent = previousRequire;
    newRequire.register = function (id, exports) {
      modules[id] = [function (require, module) {
        module.exports = exports;
      }, {}];
    };
  for (var i = 0; i < entry.length; i++) {
      try {
        newRequire(entry[i]); // 遍历entry,开始执行 
      } catch (e) {
      }
    }
    if (entry.length) {
      // Expose entry point to Node, AMD or browser globals
      // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js
      var mainExports = newRequire(entry[entry.length - 1]);
      // CommonJS
      if (typeof exports === "object" && typeof module !== "undefined") {
        module.exports = mainExports;
      // RequireJS
      } else if (typeof define === "function" && define.amd) {
       define(function () {
         return mainExports;
       });
      // <script>
      } else if (globalName) {
        this[globalName] = mainExports;
      }
    }
    // Override the current require with this new one
    parcelRequire = newRequire;
    return newRequire;
})(obj, {}, ["../node_modules/parcel-bundler/src/builtins/hmr-runtime.js","index.js"], null)

其中参数modules传入的obj为:

{
  "b.js":[ function(){ 
    // ...b.js 的代码
  }, {}],// 空对象,说明b没有依赖其他模块
  "index.js": [ function(){
        // ...index.js的代码
        }, {
          "./b.js":"b.js" // index.js依赖b.js
        }],
  "../node_modules/parcel-bundler/src/builtins/hmr-runtime.js": [ // parcel dev模式需要的runtime代码
    function() {}, 
  ],
}

key value, key为模块文件名称(有时也会带路径),value是个数组,数组第一项为模块代码,第二项为该模块依赖其他模块的关系, 最后一项固定为parcel hmr 的runtime 文件(dev模式下)

parcel打包结果是一个自执行函数的同时,还将parcelRequire挂在了window上,不同于webpack的使用webpack_require来替换require或import,parcel将模块平铺展开,并将模块的依赖通过模块名指定出来。

备注

  1. magicstring: 快速轻量的工具,用来操作字符串,生成sourcemap等(例如replace, wrap some code, 生成sourcemap等)
  2. webpack target除了web还有,web-worker, electron等

小结

从打包结果来看,rollup是最直接和容易理解的,webpack 和 parcel 都各自用了自己的方法支持模块关系的解析。

代码比rollup打包结果大,由此,也容易看出,rollup适合类库类打包,webpack parcel适用于应用类项目的打包

Reference

  1. parcel官网:parceljs.org/
  2. Parcel 源码解读
  3. magicstring: github.com/Rich-Harris…

20个例子掌握RxJS——第十一章实现 WebSocket 消息节流

作者 LeeYaMaster
2026年3月3日 15:55

RxJS 实战:WebSocket 连接管理与消息节流

概述

WebSocket 是一种全双工通信协议,允许服务器和客户端之间进行实时双向通信。在实际应用中,我们需要:

  1. 管理连接状态:处理连接、断开、重连等
  2. 控制消息频率:避免发送过于频繁的消息
  3. 错误处理:处理连接错误和消息错误
  4. 自动重连:连接断开后自动重连

本章将介绍如何使用 RxJS 管理 WebSocket 连接,并使用 throttleTime 实现消息节流。

WebSocket 基础

WebSocket 的特点

  1. 全双工通信:客户端和服务器可以同时发送和接收消息
  2. 低延迟:比 HTTP 轮询更高效
  3. 持久连接:建立连接后保持打开状态
  4. 实时性:适合实时通信场景

WebSocket 连接状态

  • CONNECTING (0):正在连接
  • OPEN (1):连接已打开
  • CLOSING (2):正在关闭
  • CLOSED (3):连接已关闭

实现思路

1. WebSocket 连接管理

// WebSocket 服务器地址
private readonly wsUrl = 'ws://localhost:8080/ws';

// WebSocket 连接
private ws: WebSocket | null = null;

// 连接状态
connectionStatus: 'disconnected' | 'connecting' | 'connected' = 'disconnected';

// 连接 WebSocket
connect(): void {
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
    return;
  }
  
  this.connectionStatus = 'connecting';
  this.cdr.detectChanges();
  
  try {
    this.ws = new WebSocket(this.wsUrl);
    
    // 连接打开
    this.ws.onopen = () => {
      console.log('WebSocket 连接已建立');
      this.connectionStatus = 'connected';
      this.cdr.detectChanges();
    };
    
    // 接收消息
    this.ws.onmessage = (event) => {
      try {
        const message: WebSocketMessage = JSON.parse(event.data);
        console.log('收到 WebSocket 消息:', message);
        this.handleReceivedMessage(message);
      } catch (error) {
        console.error('解析 WebSocket 消息错误:', error);
      }
    };
    
    // 连接关闭
    this.ws.onclose = (event) => {
      console.log('WebSocket 连接已关闭', event);
      this.connectionStatus = 'disconnected';
      this.cdr.detectChanges();
      
      // 如果非正常关闭,尝试重连(可选)
      if (event.code !== 1000) {
        console.log('连接异常关闭,5秒后尝试重连...');
        setTimeout(() => {
          if (this.connectionStatus === 'disconnected') {
            this.connect();
          }
        }, 5000);
      }
    };
    
    // 连接错误
    this.ws.onerror = (error) => {
      console.error('WebSocket 错误:', error);
      this.connectionStatus = 'disconnected';
      this.cdr.detectChanges();
    };
  } catch (error) {
    console.error('创建 WebSocket 连接失败:', error);
    this.connectionStatus = 'disconnected';
    this.cdr.detectChanges();
  }
}

// 断开 WebSocket 连接
disconnect(): void {
  if (this.ws) {
    this.ws.close(1000, '正常关闭');
    this.ws = null;
  }
  this.connectionStatus = 'disconnected';
  this.cdr.detectChanges();
}

2. 消息发送节流

使用 throttleTime 限制消息发送频率:

// 消息发送 Subject(用于节流)
private messageSendSubject$ = new Subject<string>();

// 是否启用节流
throttleEnabled = true;

ngOnInit(): void {
  // 设置消息发送节流(500ms 内最多发送一次)
  this.messageSendSubject$
    .pipe(
      throttleTime(500), // 节流:每 500ms 最多发送一次
      takeUntil(this.destroySubject$)
    )
    .subscribe((message) => {
      // 只有在连接状态下才发送
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.sendMessageToServer(message);
      }
    });
}

// 发送消息(点击按钮)
sendMessage(): void {
  const message = this.messageInput.value?.trim() || '';
  if (!message) {
    return;
  }
  
  if (this.throttleEnabled) {
    // 使用节流发送
    this.messageSendSubject$.next(message);
  } else {
    // 直接发送
    this.sendMessageToServer(message);
  }
  
  // 清空输入框
  this.messageInput.setValue('');
}

// 发送消息到服务器
private sendMessageToServer(content: string): void {
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
    console.warn('WebSocket 未连接,无法发送消息');
    return;
  }
  
  if (!content || content.trim() === '') {
    return;
  }
  
  // 发送 echo 类型的消息(服务器会回显)
  const message = {
    type: 'echo',
    content: content.trim()
  };
  
  try {
    this.ws.send(JSON.stringify(message));
    
    // 添加发送记录
    const record: MessageRecord = {
      id: ++this.messageCounter,
      type: 'sent',
      content: content.trim(),
      timestamp: Date.now(),
      messageType: 'echo'
    };
    
    this.messages.unshift(record);
    this.cdr.detectChanges();
  } catch (error) {
    console.error('发送消息失败:', error);
  }
}

3. 消息接收处理

// 处理接收到的消息
handleReceivedMessage(message: WebSocketMessage): void {
  let displayContent = '';
  let messageType = message.type;
  
  switch (message.type) {
    case 'welcome':
      displayContent = message.message || '连接成功';
      if (message.clientId) {
        this.clientId = message.clientId;
      }
      break;
    case 'echo':
      displayContent = message.original || '';
      break;
    case 'pong':
      displayContent = '收到心跳响应';
      break;
    case 'broadcast':
      displayContent = `${message.from ? `来自 ${message.from}: ` : ''}${message.content || ''}`;
      break;
    case 'message':
      displayContent = `${message.from ? `来自 ${message.from}: ` : ''}${JSON.stringify(message.content)}`;
      break;
    default:
      displayContent = JSON.stringify(message);
  }
  
  // 添加消息记录
  const record: MessageRecord = {
    id: ++this.messageCounter,
    type: 'received',
    content: displayContent,
    timestamp: Date.now(),
    messageType: messageType
  };
  
  this.messages.unshift(record);
  this.cdr.detectChanges();
}

关键点解析

1. 连接状态管理

通过维护 connectionStatus 状态,可以:

  • 在 UI 中显示连接状态
  • 根据状态决定是否允许发送消息
  • 处理重连逻辑

2. 消息节流

使用 throttleTime 可以:

  • 限制消息发送频率,避免服务器压力过大
  • 提升用户体验,避免消息过于频繁
  • 可以通过开关控制是否启用节流

3. 自动重连

onclose 事件中,如果非正常关闭,可以自动重连:

this.ws.onclose = (event) => {
  if (event.code !== 1000) { // 1000 表示正常关闭
    setTimeout(() => {
      if (this.connectionStatus === 'disconnected') {
        this.connect(); // 自动重连
      }
    }, 5000);
  }
};

4. 错误处理

确保所有可能的错误都有适当的处理:

  • 连接错误
  • 消息解析错误
  • 发送消息错误

实际应用场景

1. 实时聊天

// 聊天消息发送
sendChatMessage(message: string): void {
  this.messageSendSubject$.next(message);
}

// 接收聊天消息
handleChatMessage(message: WebSocketMessage): void {
  this.chatMessages.push(message);
  this.scrollToBottom();
}

2. 实时通知

// 接收服务器推送的通知
handleNotification(message: WebSocketMessage): void {
  if (message.type === 'notification') {
    this.showNotification(message.content);
  }
}

3. 实时数据更新

// 接收实时数据更新
handleDataUpdate(message: WebSocketMessage): void {
  if (message.type === 'data-update') {
    this.updateData(message.data);
  }
}

性能优化建议

1. 心跳机制

定期发送心跳消息,保持连接活跃:

// 心跳间隔
private readonly HEARTBEAT_INTERVAL = 30000; // 30 秒

// 启动心跳
startHeartbeat(): void {
  setInterval(() => {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type: 'ping' }));
    }
  }, this.HEARTBEAT_INTERVAL);
}

2. 消息队列

对于重要消息,可以实现消息队列,确保消息不丢失:

private messageQueue: string[] = [];

// 发送消息(带队列)
sendMessageWithQueue(message: string): void {
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
    // 发送队列中的消息
    while (this.messageQueue.length > 0) {
      this.ws.send(this.messageQueue.shift()!);
    }
    // 发送当前消息
    this.ws.send(message);
  } else {
    // 连接未建立,加入队列
    this.messageQueue.push(message);
  }
}

3. 限制消息历史

限制保存的消息数量,避免内存占用过大:

// 限制消息数量
if (this.messages.length > 100) {
  this.messages = this.messages.slice(0, 100);
}

注意事项

  1. 内存泄漏:确保在组件销毁时关闭连接和取消订阅
  2. 重连策略:合理设置重连间隔,避免频繁重连
  3. 消息格式:统一消息格式,便于解析和处理
  4. 安全性:使用 WSS(WebSocket Secure)保护数据传输

总结

使用 RxJS 管理 WebSocket 连接是一个完整的解决方案,它提供了:

  • 连接管理:处理连接、断开、重连等状态
  • 消息节流:使用 throttleTime 限制消息发送频率
  • 错误处理:处理各种错误情况
  • 自动重连:连接断开后自动重连
  • 消息处理:统一处理不同类型的消息

通过合理使用 RxJS 操作符(throttleTimetakeUntil 等),我们可以构建一个稳定、高效的 WebSocket 通信系统。

记住:WebSocket 适合实时通信场景,但对于不需要实时性的场景,HTTP 轮询可能更简单

码云地址:gitee.com/leeyamaster…

15个例子熟练异步框架 Zone.js

作者 LeeYaMaster
2026年3月3日 15:50

15个例子熟练异步框架 Zone.js

一、理解 Zone.js

可以把 Zone.js 理解成异步的监听器,通过钩子感知异步的各个阶段:

钩子 对应阶段
onScheduleTask 订阅/注册(调用 setTimeout、.then 等)
onInvokeTask 执行(回调真正运行)
onCancelTask 取消(clearTimeout 等)
onHasTask 是否有未完成任务(全部完成时 hasTask 变为 false)

核心价值:

  1. 提取冗余代码:错误处理、耗时统计、日志等可集中到 Zone 钩子,业务回调只保留核心逻辑
  2. 共享变量:用 properties 在 Zone 上挂数据,Zone.current.get('key') 即可访问,无需闭包或层层传参
  3. 统一错误捕获onHandleError 可捕获 Zone 内同步和异步抛出的错误
  4. 等所有异步完成onHasTask 在 hasTask 变为 false 时,表示全部完成,可触发回调(类似自动版 Promise.all)

官方总结

  • Zone.js 通过包装异步 API,在订阅、执行、取消、完成等阶段提供钩子,让你可以集中处理错误、上下文、监控和“全部完成”等逻辑,从而减少重复代码并提高可读性。

白话文总结:

  • Zone.js 就是一个异步“监听器”,可以追踪异步任务的执行取消注册/订阅等各个阶段,把原本分散在异步代码里的冗余处理(比如日志、错误捕获、耗时统计)提取到统一的位置,让业务代码更清晰。
  • 当“异步套异步”时,各层 Zone 可以共享变量,无需再用闭包或层层传参。
  • 支持在异步任务中统一捕获错误,不用再每处手动 try/catch。
  • 可以检测多个异步任务何时全部完成,比如多个 loading 结束后再统一触发某些操作。

二、示例精华(01-15)

01 最基本用法

// Zone.current 获取当前 Zone
console.log('当前 Zone:', Zone.current.name);

// zone.run() 在 Zone 内执行代码
Zone.current.run(function() {
  console.log('在 Zone 内执行,当前 Zone:', Zone.current.name);
});

讲解:Zone.js 加载后自动创建 root Zone。zone.run(fn) 在指定 Zone 内执行函数。


02 Zone 嵌套

var childZone = Zone.current.fork({ name: 'child-zone' });
var grandchildZone = childZone.fork({ name: 'grandchild-zone' });

childZone.run(function() {
  console.log('在 child-zone 内:', Zone.current.name);
  grandchildZone.run(function() {
    console.log('在 grandchild-zone 内:', Zone.current.name);
  });
});

讲解zone.fork(config) 基于当前 Zone 创建子 Zone,形成父子层级关系。


03 Zone 存储数据

var myZone = Zone.current.fork({
  name: 'my-zone',
  properties: {
    userId: 'user-123',
    requestId: 'req-456'
  }
});

myZone.run(function() {
  console.log('同步:', Zone.current.get('userId'));
  setTimeout(function() {
    // 异步回调里也能拿到!
    console.log('异步:', Zone.current.get('requestId'));
  }, 500);
});

讲解properties 让 Zone 携带数据,同步和异步代码都能用 Zone.current.get('key') 访问。


04 对比:上下文数据

无 Zone:多层 setTimeout 需闭包或层层传参才能拿到 requestId。

有 Zone:在 Zone 上设置一次,所有异步回调都能直接拿到。

var myZone = Zone.current.fork({
  name: 'request-zone',
  properties: { requestId: 'req-002' }
});

myZone.run(function() {
  setTimeout(function() {
    setTimeout(function() {
      // 照样能拿到,不用传参!
      console.log(Zone.current.get('requestId'));
    }, 200);
  }, 200);
});

05 对比:任务追踪

无 Zone:需手动 pendingCount++/--,每次 setTimeout 前后自己维护。

有 ZoneonHasTask 自动感知「有任务」或「全部完成」。

var trackingZone = Zone.current.fork({
  name: 'tracking-zone',
  onHasTask: function(delegate, current, target, hasTaskState) {
    var hasTask = hasTaskState.macroTask || hasTaskState.microTask || hasTaskState.eventTask;
    // hasTask 为 true:有异步任务
    // hasTask 为 false:全部完成
    console.log(hasTask ? '有任务执行中' : '空闲');
  }
});

trackingZone.run(function() {
  setTimeout(function() {
    setTimeout(function() { /* 什么都不用做 */ }, 300);
  }, 500);
});

06 对比:错误捕获

无 Zone:try-catch 抓不到 setTimeout 里的错误。

有 ZoneonHandleError 统一捕获 Zone 内所有异步错误。

var errorZone = Zone.current.fork({
  name: 'error-zone',
  onHandleError: function(delegate, current, target, error) {
    console.log('捕获到:', error.message);
    return false; // 不继续向外抛
  }
});

errorZone.run(function() {
  setTimeout(function() {
    throw new Error('setTimeout 里的错误!');
  }, 300);
});

07 对比:任务拦截

无 Zone:无法知道 setTimeout、Promise.then 何时执行。

有 ZoneonInvokeTask 在每次异步回调执行前都会触发。

var interceptZone = Zone.current.fork({
  name: 'intercept-zone',
  onInvokeTask: function(delegate, current, target, task, applyThis, applyArgs) {
    console.log('▶ 执行任务:', task.source);
    return delegate.invokeTask(target, task, applyThis, applyArgs);
  }
});

interceptZone.run(function() {
  setTimeout(function() { /* ... */ }, 200);
  Promise.resolve().then(function() { /* ... */ });
});

注意:onInvokeTask 在回调执行前触发,不是执行后。delegate.invokeTask() 会同步执行回调,执行完才返回。


08 onScheduleTask vs onInvokeTask

var z = Zone.current.fork({
  name: 'demo',
  onScheduleTask: function(delegate, curr, target, task) {
    console.log('📋 任务被注册:', task.source);  // 调用 setTimeout 的瞬间
    return delegate.scheduleTask(target, task);
  },
  onInvokeTask: function(delegate, curr, target, task, applyThis, applyArgs) {
    console.log('▶ 任务即将执行:', task.source);  // 回调真正运行的瞬间
    return delegate.invokeTask(target, task, applyThis, applyArgs);
  }
});

z.run(function() {
  setTimeout(function() { console.log('回调执行了'); }, 500);
});
// 顺序:onScheduleTask → (500ms) → onInvokeTask → 回调

讲解:onScheduleTask = 注册时;onInvokeTask = 执行时。


09 zone.wrap

var myZone = Zone.current.fork({
  name: 'my-zone',
  properties: { requestId: 'req-999' }
});

// 包装后,无论何时何处被调用,都会在 my-zone 内执行
var wrappedCallback = myZone.wrap(function() {
  console.log(Zone.current.get('requestId'));
}, 'button-callback');

document.getElementById('btn').addEventListener('click', wrappedCallback);

讲解:适合 addEventListener、第三方库回调等,你无法控制调用时机,但希望它在你的 Zone 内执行。


10 异步耗时统计

var timingZone = Zone.current.fork({
  name: 'timing-zone',
  onInvokeTask: function(delegate, curr, target, task, applyThis, applyArgs) {
    var start = performance.now();
    var result = delegate.invokeTask(target, task, applyThis, applyArgs);
    var cost = (performance.now() - start).toFixed(2);
    console.log(task.source + ' 耗时: ' + cost + ' ms');
    return result;
  }
});

讲解delegate.invokeTask() 是同步的,执行完才返回,所以前后 performance.now() 的差值就是回调耗时。


11 onInvoke 同步钩子

var z = Zone.current.fork({
  name: 'invoke-zone',
  onInvoke: function(delegate, curr, target, callback, applyThis, applyArgs, source) {
    console.log('onInvoke: 即将执行', source);
    return delegate.invoke(target, callback, applyThis, applyArgs, source);
  }
});

z.run(function() {
  console.log('zone.run 里的回调体执行了');
}, null, null, 'main');

讲解onInvoke 针对 zone.run(fn)同步执行;onInvokeTask 针对异步任务。


12 onCancelTask

var z = Zone.current.fork({
  onCancelTask: function(delegate, curr, target, task) {
    console.log('❌ 任务被取消:', task.source);
    return delegate.cancelTask(target, task);
  }
});

z.run(function() {
  var id = setTimeout(function() {}, 3000);
  // 点击按钮时 clearTimeout(id) → onCancelTask 触发
});

讲解clearTimeoutclearInterval 取消任务时,onCancelTask 会触发。


13 模拟 Angular 变更检测

var ngZone = Zone.current.fork({
  name: 'ng-zone',
  onHasTask: function(delegate, curr, target, hasTaskState) {
    delegate.hasTask(target, hasTaskState);
    var hasTask = hasTaskState.macroTask || hasTaskState.microTask || hasTaskState.eventTask;
    if (!hasTask) {
      console.log('🔔 所有异步完成 → 执行变更检测');
    }
  }
});

ngZone.run(function() {
  setTimeout(function() {
    // 更新数据...
    setTimeout(function() { /* 后续处理 */ }, 200);
  }, 500);
});

讲解:Angular 的 NgZone 就是利用 onHasTask,在「全部完成」时触发变更检测。


14 Zone 边界

// Zone 内发起 → 会被追踪
trackingZone.run(function() {
  setTimeout(function() { /* 会被 onInvokeTask 捕获 */ }, 200);
});

// Zone 外发起 → 不会被追踪
setTimeout(function() { /* 不会被 Zone 追踪! */ }, 400);

讲解:只有在 Zone 内发起的异步才会被追踪。Zone 外调用的 setTimeout 不会被感知。


15 zone.runGuarded

var safeZone = Zone.current.fork({
  onHandleError: function(delegate, curr, target, error) {
    console.log('捕获:', error.message);
    return false; // 不继续向外抛
  }
});

safeZone.runGuarded(function() {
  throw new Error('故意的错误!');
});
console.log('程序继续运行');

讲解zone.run(fn) 抛错会向外冒泡;zone.runGuarded(fn) 会捕获错误交给 onHandleError,不向外抛。


三、核心概念速查

概念 说明
Zone.current 当前所在的 Zone
zone.run(fn) 在指定 Zone 内执行函数
zone.runGuarded(fn) 安全执行,错误交给 onHandleError
zone.fork(config) 基于当前 Zone 创建子 Zone
zone.wrap(callback) 包装回调,使其在 Zone 内执行
Zone.current.get('key') 获取 Zone 的 properties
properties Zone 携带的数据

四、码云地址

码云地址gitee.com/leeyamaster…

JavaScript数据类型整理1

作者 willow
2026年3月3日 15:48

有哪些数据类型?

  1. 基本类型:Number String Boolean Null Undefined Symbol BigInt
  2. 引用类型:Object Function Array Map Set
  3. 区别:是数据存储不同

a. 基本类型的值存储在栈中,在栈中存储的是值,它赋值后值相同,但是两个值对应的地址不同,所以a = 1;b=a;a=2;b还是等于1;
b. 引用类型的值存储在堆中,在栈存放的是指向堆内存的指针地址;它赋值时候是将对象的内存地址赋值給另一个对象,也就是说两个对象指向的是同一个堆内存,所以a={name:11};b=a;b.name=22; a也会改变;

null与undefined区别是什么?

  1. ①undefined代表定义未赋值;②nulll定义并赋值了, 只是值为null
  2. typeof null是object
  3. 什么时候给变量赋值为null呢?①初始赋值, 表明将要赋值为对象;②结束前, 让对象成为垃圾对象(被垃圾回收器回收)
  4. null==undefined; null!==undefined

Symbol创建唯一值

  1. 是什么?给对象设置“唯一值"的属性名,对象的属性名可以是数字、字符串、Symbol类型;
  2. 用法?
let a1= symbol('AA'); 
let a2= symbol('AA'); 
let a3 = a1; 
a1 === a2; //false 
a1 === a3 //true
  1. 作用? Symbol.asyncIterator/iterator/hasInstance/toPrimitive/tostringTag 是某些js底层原理的实现机制。基于symbol类型的值,保证行为标识的唯一性

BigInt大数类型

  1. 超过安全数后,进行运算或者访问,结果会不准确
 a. Number.MAX_SAFE_INTEGER: 9007199254740991 //jS中的最大安全数
 b. Number.MIN_SAFE_INTEGER: -9007199254740991 //jS中的最小安全数
  1. 解决方案 a. 服务器返回给客户端的大数,按照"字符串"格式返回;然后客户端把其变为 BigInt,然后按照BigInt进行运算,最后把运算后的BigInt转换为字符串,在传递给服务器即可

数据类型的检测方式有哪些?

  1. typeof:其中数组、对象、null 都会被判断为 object
console.log(typeof 2); // number
console.log(typeof true); // boolean
console.log(typeof "str"); // string
console.log(typeof undefined); // undefined
console.log(typeof function () {}); // function
console.log(typeof null); // object
console.log(typeof {}); // object
console.log(typeof []); // object
  • 数据类型的值在计算机底层是按照64位的二进制值进行存储的,typeof也是按照二进制进行类型检测。
  • 前三位是0认为是对象,然后再去看有没有实现call方法。如果实现了则返回'function',没有实现,则返回'object'。null是64个零,所以typeof null->'object'
  1. instanceof:可以判断对象的类型,不能判断基本类型的;原理是构造函数的prototype属性是否出现在对象原型链的任何位置。
console.log(2 instanceof Number); // false
console.log(true instanceof Boolean); // false
console.log("str" instanceof String); // false

console.log([] instanceof Array); // true
console.log(function () {} instanceof Function); // true
console.log({} instanceof Object); // true
  • 先检测构造函数是否拥有 Symbol.hasInstance 方法
  • 如果有这个方法:构造函数[Symbol.hasInstance](实例)返回的值就是结果;
  • 如果没有这个方法,则按照原型链进行查找:按照实例的_proto-一直向上找,直到找到0bject.prototype为止,只要在原型链上出现了“构造函数.prototype”,说明当前实例率属于它,结果返回true;如果没找到,结果就是false;
  1. constructor:判断两种数据的类型,对象实例通过constructor来访问它的构造函数,缺点是如果对象改变过它的原型,那么constructor判断会有问题
console.log((2).constructor === Number); // true
console.log((true).constructor === Boolean); // true
console.log(('').constructor === String); // true
console.log(([]).constructor === Array); // true
console.log((function() {}).constructor === Function); // true
console.log(({}).constructor === Object); // true
  1. Object.prototype.toString.call([value]):使用Object对象的原型方法toString来判断数据类型,属于检测最准确、最全面的方式了,能够区分null、能够检测原始值类型、能够细分对象、即便重构原型对象检测也是准确的
console.log(Object.prototype.toString.call(2));        //'[object Number]'
console.log(Object.prototype.toString.call(true));     //'[object Boolean]'
console.log(Object.prototype.toString.call("str"));    //'[object String]'
console.log(Object.prototype.toString.call([]));       //'[object Array]'
console.log(Object.prototype.toString.call(function () {}));//'[object Function]'
console.log(Object.prototype.toString.call({}));       //'[object Object]'
console.log(Object.prototype.toString.call(undefined));//'[object Undefined]'
console.log(Object.prototype.toString.call(null));     //'[object Null]'
  • 返回结果“[object ?] ?:一般是自己所属的构造函数
  • 首先会看[value]值是否有 Symbol.tostringTag 属性,有这个属性,属性值是啥,检测出来是啥就是啥; Math[Symbol.tostringTag]:'Math' map.prototype[Symbol.tostringTag]:'Map' Promise.prototype[Symbol.tostringTag]:'Promise' Set.prototype[Symbol.tostringTag]:'set'
  • 如果没有这个属性,才一般是按照自己所属的构造函数返回

数组的判断有几种?

  1. Object.prototype.toString.call(): Object.prototype.toString.call([]).slice(8,-1) === "Array"
  2. Array.isArray(): Array.isArray([])
  3. instanceof: [] instanceof Array
  4. Array.prototype.isPrototypeOf:Array.prototype.isPrototypeOf([])
  5. __proto__: [].__proto__ === Array.prototype

typeof NaN是多少?

  1. typeof NaN是number
  2. NaN:指不是一个数字,意思是执行数字运行失败,是失败后返回的结果
  3. NaN === NaN 为false

instanceof操作符的实现原理?

  1. 作用:检测某个对象是否属于某个类型 [] instanceof Array
  2. 原理是判断构造函数的prototype属性是否出现在对象原型链的任何位置;所以重点就是找到递归对象的原型链以及构造函数的prototype
  3. 步骤

a. 获取对象的原型 proto = Object.getPrototypeOf(obj)
b. 获取构造函数的原型对象 prototype = ctor.prototype;
c. 判断构造函数的原型对象是否在对象的原型链上 proto === prototype
d. 如果没有找到就继续在其原型上找 proto = Object.getPrototypeOf(proto);

function myInstanceof(obj, ctor) {
  // 获取对象的原型
  let proto = Object.getPrototypeOf(obj);
  // 获取构造函数的 prototype 对象
  let prototype = ctor.prototype;
  // 判断构造函数的 prototype 对象是否在对象的原型链上
  while (true) {
    if (!proto) return false;
    if (proto === prototype) return true;
    // 如果没有找到,就继续从其原型上找,Object.getPrototypeOf方法用来获取指定对象的原型
    proto = Object.getPrototypeOf(proto);
  }
}

类型转换机制?

  1. 常见的类型转换有:强制转换(显式转换)、自动转换(隐式转换)
  2. 显式转换转化规则常见的方法有:Number() parseInt() parseFloat() String() Boolean()
  3. 隐式转换转化规则?比较运算(==、!=、>、<)if、while;算术运算(+、-、*、/、%)
  4. 其他类型转数字: ①Number([val]):比较严格,只要有一个字符无法转成数值,整个字符串就会被转为NaN; ②parseInt([val], [radix]):没那么严格,遇到不能转换的字符就停下来。
  1. 空字符串为0,有出现非数字结果为NaN
  2. 布尔值转数字,true:1,false:0
  3. Symbol会报错;
  4. null:0; undefined:NaN;
  5. BigInt去除“n”
  6. 对象转数字:①先调用对象原型上的一个函数Symbol.toPrimitive()②如果不存在则调用对象的valueOf获取原始值;③如果获取的不是原始值,再调用对象的toString转为字符串;④再把字符串基于Number方法转换为数字;
Number([10]) //10
// 1 首先检测Symbol.toPrimitive是否存在,如果存在就调用 ƒ [Symbol.toPrimitive]() { [native code] }
// 2 如果是undefined,那么arr.valueOf()获取原始值 
// 3 结果为[10],不是原始值,那么就是arr.toString()转为字符串‘10’;
// 4 最后再把字符串转为数字 Number('10')->10
// Number([10, 20])结果为NaN,分析如下:
//  [10,20].valueOf():[10, 20]-> [10,20].toString():'10,20' -> Number('10,20')->NaN
  1. 其他类型转字符串:①拿字符串包起来String({}) //'[object Object]'; ②“+”出现在两边,其中一边是字符串或者某些对象,会以字符串拼接规则处理
console.log(10+'10') //'1010'
console.log(10+new Number(10)) //20
//①new Number(10)[Symbol.toPrimitive]:undefined;-> ②new Number(10).value0f():10 -> 10+10 = 20
console.log(10+[10]) //'1010' 
//①[10][Symbol.toPrimitive]:undefined;-> ②[10].valueOf().toString():'10',为字符串,所以是拼接 
  1. 其他类型转布尔值: Boolean([val])或者!/!! 除了null undefined '' NaN,其他结果都是true。

ToPrimitive: 用来将值转换为基本类型值

  1. 如果值为对象,ToPrimitive(obj, type);对象默认type为number。
  2. 当type为number时规则:var objToNumber = (value) => Number(value.valueOf().toString());
  3. 当type为string时规则:var objToNumber = (value) => Number(value.toString().valueOf());
  4. 对于 Date 以外的对象,转换为基本类型的大概规则可以概括为一个函数:var objToNumber = (value) => Number(value.valueOf().toString());
var a = { name: "Tom" };
var b = { age: 18 };
a + b; // "[object Object][object Object]"
a.valueOf().toString(); // "[object Object]"
b.valueOf().toString(); // "[object Object]"
a + b; // "[object Object][object Object]"

==操作符的强制类型转换规则?

  1. 首先会判断两者的类型是否一样,一样则比较大小;不一样则进行类型装换;
  2. 两个都为简单类型则有一方为字符串和布尔值都会转换成数值再进行判断;null==undefined是true;
  3. 两个都为引用类型则比较他们是否指向同一对象,比较的是堆内存地址,地址相同则相等
  4. 一个为引用类型,一个为复杂类型则将复杂类型用ToPrimitive转为原始类型再进行判断
  5. 除了以上情况,只要两边类型不一致,剩下的都是转换为数字,然后再进行比较的。
// 一个为引用类型,一个为复杂类型
//对象==字符串 需要将对象转字符串再比较「symbol.toPrimitive->value.toString().valueOf()]
//对象==数字 需要将对象转数字再比较「symbol.toPrimitive->value.valueOf().toString()]
[] == false //true
//只要两边类型不一致,剩下的都是转换为数字 Number([])为0,所以0 == 0为true

为什么 0.1+0.2 ! == 0.3,如何让其相等?

  1. 为什么小数(浮点数)的计算会出现精准度丢失问题?0.1+0.2 // 0.30000000000000004 0.1+0.7 //0.7999999999999999
  • 计算机存储值是以二进制在计算机底层来存的,所以需要先把十进制转为二进制的科学计数法n.toString(2)
  • 整数:(10).toString(2) //'1010'->10一直去除以2,余数组合就是结果;
  • 浮点数:(0.1).toString(2) //'0.0001100110011001100110011001100110011001100110011001101'
  • 某些十进制的浮点数在转二进制可能会无限循环下去,在底层存储最多存64位,舍弃了一些值后值本身就失去了精准值,再转成十进制就有了误差;
  1. 怎么解决精准度问题?使用 toPrecision 凑整;扩大系数法,把小数转成整数后再运算;使用第三方库,如Math.js、BigDecimal.js
//扩大系数法,把小数转成整数后再运算
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}

一个非常实用的Three.js3D模型爆破💥和切割开源插件

作者 答案answer
2026年3月2日 09:29

前言

给大家分享一个非常实用Three.js3D模型爆破切割插件,这个插件能够使前端可以直接在浏览器中,对 3D 模型进行实时且物理效果真实的 “爆破”“粉碎”“切片” 处理。

Mar-01-2026 18-55-28.gif

安装

安装也是非常的简单直接通过 npm 安装即可

但需要注意的是Three.js版本需要大于 0.158

npm install @dgreenheck/three-pinata

使用

使用也是非常的简单只要将插件提供的方法引入即可

DestructibleMesh 用于创建可切割或爆破的物体物体

FractureOptions 用于设置参数配置

import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";

const outerMaterial = new THREE.MeshStandardMaterial({ color: 0x4a90e2 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });

const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
scene.add(mesh);

const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 16,
  voronoiOptions: {
    mode: "3D",
  },
});
const fragments = mesh.fracture(options);

fragments.forEach((fragment) => scene.add(fragment));
mesh.visible = false;

参数方法

该插件大概提供了7种不同的针对3D模型爆破和切割的场景方法,并且官网示例都可以直接查看演示效果

比如这个砸碎物体方法:

image.png

image.png

又或者说这个物体切片方法:

image.png

image.png

项目仓库

该项目插件是一个外国大佬开发,如果有使用Three.js开发一个小游戏的需求,或者说想丰富一下你的3D网站这个插件都会可以给你提供不错的帮助的

项目演示地址:three-pinata-demo.vercel.app/

Github: github.com/dgreenheck/…

如何用一份 JSON 配置搞定“法律计算器”的动态表单

2026年3月3日 15:18

引言:小明的工伤赔偿奇遇记

想象一下,当事人小明打开我们的“法律计算器”小程序,想算算工伤赔偿。

  • 场景 A:他手抖选了“劳动关系”,页面立刻弹出“月工资是多少?”;
  • 场景 B:他改主意选了“交通事故”,页面瞬间变身,开始问“伤残等级”;
  • 场景 C:他填了个“30000”的月薪,系统立马提示:“哥们,这超过社平工资 3 倍了,你确定没填错?”(后端异步校验)。

作为开发者,你是不是已经开始头疼了?如果针对劳动争议、交通事故、借贷纠纷等等法律业务都分别写一个 .vue 页面,光是维护 v-if/v-else 就得掉一半头发。万一明天产品经理说:“在这个表单中间加个‘案发地点’字段”,你是不是得发版重新提审?

拒绝写死代码! 今天我们来聊聊如何用 数据驱动UI 架构,打造一个“千人千面”的法律计算器。


一、 核心原理:把前端做成“乐高底板”

在传统的开发模式中,前端是“建筑师”,负责设计页面结构(Template);后端只是“搬砖工”,负责提供数据(Data)。

但在 数据驱动UI 架构下,角色反转了。前端退化成了一块纯粹的 “乐高底板”,而后端发来的 JSON 配置,就是那张 “搭建图纸”。前端不关心业务逻辑,只负责一件事:给什么积木,就搭什么房子。

1. 渲染引擎:v-for 的魔法

让我们看看核心渲染引擎 customForm/index.vue 是如何工作的。它的核心逻辑极其简单,就像是在遍历一份清单:

<!-- components/customForm/index.vue (精简版) -->
<template>
  <view class="custom-form">
    <!-- 第一层循环:遍历表单分组 (Section) -->
    <view v-for="(section, sIndex) in formConfig" :key="sIndex">
      <view class="section-title">{{ section.title }}</view>
      
      <!-- 第二层循环:遍历具体的题目 (Items) -->
      <view v-for="(item, iIndex) in section.items" :key="iIndex">
        
        <!-- 积木 A:普通输入框 (component === 3) -->
        <u-form-item v-if="item.component === 3" :label="item.title">
          <u-input v-model="formData[item.qId]" />
        </u-form-item>

        <!-- 积木 B:选择器 (component === 4) -->
        <u-form-item v-if="item.component === 4" :label="item.title">
          <view @click="openPicker(item)">{{ getLabel(item) }}</view>
        </u-form-item>

        <!-- 积木 C:复杂的利率选择器 (component === 9) -->
        <rate-selector 
          v-if="item.component === 9" 
          :init-value="formData[item.qId]"
        />
        
      </view>
    </view>
  </view>
</template>

前端不再写死 <input><select>,而是根据 JSON 中的 component 字段(3 代表输入框,4 代表选择器,9 代表复杂组件)动态决定渲染什么。

2. 每次修改表单都动态获取JSON数据

最精彩的部分来了。既然前端不写 v-if="salary > 30000",那条件分支怎么实现?

答案是:不要在前端做逻辑判断,把用户的每一次交互都告诉后端。

这是一个 (问后端) 的过程。在具体业务代码中,我们监听了表单的每一次变更:

// pages/enterpriseLaw/legalCalculator/form.vue

// 1. 用户修改了答案
handleFormChange(newAnswer) {
  // 更新本地答案池
  this.updateAnswers(newAnswer);
  
  // 2. 核心:带着当前的答案,去问后端“下一步该展示什么?”
  this.getDynamic(); 
},

async getDynamic() {
  // 3. 调用接口,把所有已填答案扔给后端
  const payload = { 
    appId: this.appId, 
    answers: this.answers 
  };
  
  // 4. 后端的大脑开始飞速运转,计算出新的题目列表
  const res = await this.$api.getDynamic(payload);
  
  // 5. 前端拿到新的 JSON,Vue 自动 diff 更新视图
  this.questions = res.data.questions;
}

点睛之笔:这就是“一份 JSON 配置”的真相。逻辑在后端,前端只是负责画图的“画笔”。 这样一来,无论是增加题目、修改逻辑分支,还是调整校验规则,都只需要后端改配置,前端代码一行都不用动!


二、 难点攻克:细节决定成败

痛点一:嵌套条件分支的“配置化”

如果题目之间有复杂的嵌套关系(例如 是否有借款 -> 有几笔 -> 第一笔利息 -> 怎么算的),JSON 结构该怎么设计?

我们采用 Section (分组) -> Group (实例) -> Items (题目) 的三层结构。Vue 的响应式系统在这里帮了大忙。当后端返回的 questions 数组发生变化(比如因为你选了“有借款”,数组里多了一个“借款详情”的 Section),Vue 会自动检测到数据的变化,并高效地修补 DOM。

// 后端返回的 JSON 结构示意
[
  {
    "groupTitle": "基本信息",
    "items": [ ... ]
  },
  {
    "groupTitle": "借款详情", // 只有当用户选了“有借款”才会返回这个 Section
    "isGroup": true,        // 标记为可重复的分组(如多笔借款)
    "items": [ ... ]
  }
]

痛点二:无缝嵌入异步校验

有些校验前端做不了,比如“赔偿金是否符合当地最新的法律标准”。这时候,我们需要把校验权也交给后端。

在代码中,我们设计了一个巧妙的 backendErrors 机制:

  1. 用户填完:触发 validateByBackend
  2. 后端校验:发现 Q101 题目的金额填错了,返回错误 Map:{ "q_101": "金额过大,请确认" }
  3. 前端标红
// customForm/index.vue

// 监听后端传来的错误对象
props: ['backendErrors'],

// 在模板中精准展示错误
<view v-if="backendErrors[item.qId]" class="backend-error">
  {{ backendErrors[item.qId] }}
</view>

这样,异步的业务校验就像本地校验一样自然流畅,用户根本感觉不到请求的延迟。

痛点三:原子组件的扩展(RateSelector)

这时候有人会问:“如果我要一个超级复杂的组件,比如‘LPR 利率计算器’,JSON 配置能描述清楚吗?”

当然可以!这就是 数据驱动UI 的灵活性。我们不需要用 JSON 描述组件内部的每一个 div,而是把这个复杂组件封装成一个原子积木

看看 components/customForm/RateSelector.vue,它内部包含了:

  • 日/月/年利率的切换
  • 百分比/千分比的换算
  • LPR 动态查询

但在表单引擎眼里,它只是一个普通的积木:

// 如果 component 代码是 9,我就渲染 RateSelector
<rate-selector v-if="rawItem.component === 9" ... />

这样,我们既保持了引擎的通用性(处理普通输入框),又保留了处理复杂业务的能力(通过自定义组件扩展)。


三、 总结:从“搬砖”到“搭积木”

数据流转图

最后,让我们用一张图来总结整个流程:

graph TD
    User[用户输入] -->|触发| Event[handleFormChange]
    Event -->|携带 Current Answers| API[调用 getDynamic 接口]
    API -->|发送至| Server[后端逻辑大脑]
    Server -->|计算条件分支/校验| Config[生成新的 JSON 配置]
    Config -->|返回| Frontend[前端 Vue 引擎]
    Frontend -->|Vue Reactivity| DOM[界面无感刷新]
    DOM -->|展示| User

架构优势

  1. 配置热更新: 运营人员想在表单里加一个“备注”字段?改一下数据库里的 JSON 配置就行了。用户端不需要发版,不需要更新,打开小程序就能看到新字段。这在法律法规频繁变动的行业简直是救命稻草。

  2. 逻辑复用: 我们只写了一套 customForm 引擎,却同时支持了“劳动争议”、“交通事故”、“民间借贷”等等法律业务的计算器。每个计算器只是后端数据库里的一条配置记录而已。

“偷懒”是程序员的第一生产力。 把复杂的逻辑甩给后端,把繁琐的渲染交给引擎,我们前端开发者,终于可以安心地喝一杯咖啡了。☕️


如果你对这套代码感兴趣,欢迎在评论区留言

defineModel 是进步还是边界陷阱?双数据源组件的选择逻辑

2026年3月3日 15:15

defineModel 是 Vue 3.4 引入的语法糖。

它看起来只是让 v-model 更优雅:

const visible = defineModel<boolean>('visible')

但它背后做的事情,远不止简单的语法糖,甚至改变了组件的状态哲学

传统 v-model 的“单一数据源”假设

在大部分 v-model 语义里,存在一个隐含规则:

只传 prop,不监听 update 事件 = 组件不可更新。

比如对弹窗组件,如果父组件只传递了 visibleprop

<MyDialog :visible="visible" />

我们会认为 MyDialog 的显示和隐藏完全由父组件控制

父组件的 visible 变量是控制 MyDialog 显示/隐藏的唯一数据源

这是一种非常清晰的“受控组件”边界

defineModel 在子组件中引入的本地数据源

但是如果 MyDialog.vuevisibledefineModel 实现时,情况会有些不一样:

<script setup>
  const visible = defineModel('visible')
</script>

<template>
  <div>
    <div>MyDialog 内的 visible:{{ visible }}</div>
    <button @click="visible = !visible">MyDialog 内切换 visible</button>
  </div>
</template>

如果父组件中还是

<MyDialog :visible="visible" />

请问子组件按钮点击时,visible 会变化吗?

答案是:会变化

switch.gif

代码链接

defineModel 做了什么?

直接看 playground 生成的代码:

image.png

defineModel 除了生成对应的 propsemits,还通过 useModel 产生了 MyDialog 内的 visible 变量。

而在 useModel 里,会使用 customRef 创建一个本地变量。

在这个本地变量的设值逻辑里,是这样的(简化):

if (
  !(
    rawProps &&
    // check if parent has passed v-model
    (name in rawProps ||
      camelizedName in rawProps ||
      hyphenatedName in rawProps) &&
    (`onUpdate:${name}` in rawProps ||
      `onUpdate:${camelizedName}` in rawProps ||
      `onUpdate:${hyphenatedName}` in rawProps)
  )
) {
  // no v-model, local update
  localValue = value
  trigger()
}

:visible="visible"@update:visible="..." 任意一个不存在,就会更新本地数据。

翻译一下:

只有父组件同时提供 “prop + @update”,子组件才会始终使用父组件的值

否则 —— 子组件会使用本地变量的数据

useModel 的动态数据源

这意味着:

父组件传入的数据,并不一定是最终数据源。

真正的数据源变成:

  • 有监听 → 父组件
  • 无监听 → 子组件本地

这是一种动态切换的数据源模型。

这是不是问题?

从功能角度看,它很强大。

优点

  • 支持“受控 / 非受控”自动切换
  • 多 model 场景写法更优雅

对于“有内部状态”的组件,非常舒服。比如手风琴组件,使用方不需要提供变量保存手风琴的开关状态。

但对于大部分输入组件,它带来了新的权衡。

模糊了边界

传统设计下:

只传 prop = 组件不可修改

现在:

只传 prop ≠ 不可修改

如果你想让组件真正受控,你必须写:

<MyDialog
  :visible="visible"
  @update:visible="() => {}"
/>

用一个空监听器,强制关闭本地数据源。

这就产生了一个认知断层:

  • 使用者需要知道组件内部是否用了 defineModel
  • 否则无法判断它是否会维护本地状态

组件的状态模型,不再从接口上显式表达。

语义变化

<MyDialog :visible="visible" />

由原本的只读受控语义,隐式拓展出了类似 init-visible 的初始值赋值语义。

而是受控,还是初始值赋值,取决于内部是否使用了 defineModel

总结与想法

defineModel 带来的不只是语法糖。还把

数据源从“静态归属”变成了“动态判断”。

它让 Vue 组件具备了“双数据源能力”。需要清醒认知它的能力边界。

defineModel 并没有让 v-model 更简单,反而让组件的状态模型更复杂。

两个想法:

  • 对于普通的输入组件,尽量避免使用 defineModel,保持单一数据源
  • 在状态不一致的问题排查上,需要考虑缺少监听器引发的问题

要闹哪样?又出现了一款新的格式化插件,尤雨溪力荐,速度提升了惊人的45倍!

作者 李剑一
2026年3月3日 15:05

前两天刚刚讨论完Vize(参考这篇文章: # 前端圈子又出新东西了,大幅提升解析速度。尤雨溪推荐,但我不太推荐),这两天发现前端又出现新工具了,而且是尤大力荐的,我得到这个消息还算是比较晚的了。

其实这款插件早已官宣,最最关键的一点是,它的速度比咱们常用的Prettier快了整整45倍。

今天咱们简单看一下这款插件 —— oxfmt

image.png

背景

其实前端最近几年一直在致力于底层的革新,原因也非常简单,Js在系统中的运行效率和编解码速度远逊于Rust这样的语言。

所以Vite中的 Rollup 变成了 Rolldownesbuild 变成了 Oxc

大家可能不太清楚 Oxc 是啥,咱们简单过一下。

image.png

OxcVoidZero 团队(Vite 核心团队,尤雨溪的公司)用 Rust 开发的 JS/TS 全链路工具链。

简单说就是以后前端的底层部分全都用 Rust 写了,补齐了 Oxc 以后,Rust在前端领域实现了全替换。

带来的好处不言而喻,首先是速度。

作为编译型语言,Rust 的执行效率接近 C/C++,相比传统前端工具的 JS/Go 实现构建 / 转译速度提升 数倍到数十倍。

并且内存占用降低 50%+,大型项目不会出现 JS 工具的内存溢出 / 卡顿问题,真正意义上实现了闪电般的加载速度

其次做为底层语言,Rust 的所有权、借用检查机制从语法层面杜绝空指针、内存泄漏等常见问题,前端工具的崩溃率、异常率大幅降低,尤其适合大型工程化场景。

最关键的一点,Rust 编译出的二进制文件无需依赖 Node.js 运行时,在 Windows/macOS/Linux 上的执行逻辑、性能表现高度一致。

解决了 JS 工具在不同系统下的兼容性问题,完美解决了跨平台一致性的问题。

Oxfmt

理解了Oxc就能简单说说 Oxfmt 了,Oxfmt 是 Oxc 生态中的代码格式化工具,也是目前已经基本上完成的 Rust 替换 Prettier 的例子。

image.png

Oxfmt Beta 几个关键词过一下:

  • 100% Prettier 兼容,无缝迁移
  • 支持 --migrate-prettier
  • 支持更丰富的文件格式
  • Import 自动排序
  • package.json 自动排序
  • Node.js API
  • IDE 完美支持

因为本身 Oxfmt 就可以看作是 Prettier 的Rust版本,所以团队在开发的时候也选择了对 Prettier API的完全兼容,所以开发者一般是没啥感知的。

你能够感受到的也就是快!

尝鲜

安装 Oxfmt

pnpm add -D oxfmt

这里需要给 oxfmt 配置一下脚本,找到 package.json

{
  "scripts": {
    "fmt": "oxfmt",
    "fmt:check": "oxfmt --check"
  }
}

现在已经可以用了。

# 格式化文件
pnpm run fmt

# 检查格式,但不修改文件
pnpm run fmt:check

以上是比较粗浅的应用,真正想要实现项目内详细可用还需要创建一下配置文件,oxfmt 默认使用 .oxfmtrc.json 作为配置文件。

# 初始化配置文件
oxfmt --init

# 从Prettier迁移
oxfmt --migrate prettier

# 全量格式化
npx oxfmt . --write

工程化应用

日常项目开发过程中主要是保存、提交的时候自动格式化,这个场景应用的比较多。

oxfmt 在 vscode 中可以通过 Oxfmt 官方扩展实现保存格式化。

首先安装 Oxfmt 官方插件,搜索 Oxc 即可。

image.png

.vscode/settings.json 中添加以下配置:

{
    "editor.formatOnSave": true,
    "[vue]": {
        "editor.defaultFormatter": "oxc.oxfmt"
    },
    "[javascript]": {
        "editor.defaultFormatter": "oxc.oxfmt"
    },
    "[typescript]": {
        "editor.defaultFormatter": "oxc.oxfmt"
    }
}

之前用 Prettier 的同学记得关掉,避免冲突。

提交格式化可以通过 pre-commit 钩子实现:

pnpm install -D husky lint-staged
npx husky install
# 添加 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"

同时在 package.json 中增加相应配置:

{
    "lint-staged": {
        "*.{js,ts,vue,json,css,scss,md}": [
        "oxfmt --write"
        ]
    }
}

总结

我个人比较建议大家从现在开始就把 Prettier 替换为 Oxfmt,原因主要有三:

  • Rust实现前端底层已成为大趋势,未来一定是这套工程大一统。
  • 速度更快,内存用的更少,Vite团队开发。
  • 确实好用,接近无感的存在。

借助VTable Skill实现10W+数据渲染

作者 核以解忧
2026年3月3日 14:22

前言

借助VTable Skill实现vtable的基础功能

SKILL作用

输入关键词后结合skill快速的生成我们需要的内容,把“经验手册”变成 AI 可以读取和执行的能力结构。通过Skill,开发者无需记忆繁琐的API文档,只需用自然语言描述需求,AI就能基于VTable的最佳实践生成高质量代码。

安装vtable skill

npx skills add VisActor/VChart 或者

npx skills add [GitHub - VisActor/VChart: VChart, more than just a cross-platform charting library](https://github.com/visactor/vchart) --skill vchart-development-assistant

进行安装在 Cursor、Trae等支持 skills 的 AI 编辑器中使用。

image.png

将技能安装到项目的 .``XXX``/skills 目录下,如下图

image.png

快速上手vtable

安装vtable包和准备项目结构

使用 npm 安装

npm install @visactor/vtable 

使用 yarn 安装

yarn add @visactor/vtable

生成基础表格

开始之前,我们先来看一下SKILL里面的文档

image.png

我用的是Trae,我们提前配置好智能体和模式

image.png

根据skill的用户意图关键词和查询规则,我们输入以下内容:

结合skill技能,创建一个基本表格

可以看到,AI会根据技能,查找对应的md文件,生成如下内容:

image.png

image.png

数据、列、主题处理

接下来,我们通过更复杂的指令来完善表格功能: 结合skill,我的数据有10万条,列有10列,姓名列固定,主题使用默认主题 AI会根据Skill中的性能优化指南,生成适合大数据量展示的表格配置,包括虚拟滚动、列固定等特性: image.png

固定列的时候发现AI处理成了固定2列,姓名在第二列 需要固定前两列,这里要手动处理一下:

image.png

252.gif

为了满足更复杂的展示需求,我们需要对某些列进行复杂的业务处理: 薪资列需要自定义渲染,薪资超过8000的字体变红,超过1万的背景色变红 字体白色 AI会利用VTable的自定义渲染能力,生成满足条件的单元格样式配置:

image.png

image.png

到此,一个具备大10W+数据渲染的表格就完成了。

总结

AI也不是万能的,有时候生成的代码跟你想要的有一丢丢出入,比如我那个固定列的问题,稍微手动调一下就好。但总体来说,以前写个表格要半小时,现在五分钟搞定,剩下的时间摸鱼不香吗?

参考资料:

vtable官网: visactor.com/vtable/exam…

VTable Skill GitHub: github.com/VisActor/VC…

Trae参考文档: docs.trae.ai/ide/skills?…

Cursor参考文档:cursor.com/cn/docs/con…

React Context 详解:从入门到性能优化

作者 阿虎儿
2026年3月3日 14:21

React Context 详解:从入门到性能优化

本文适合熟悉 Vue 但刚开始学习 React 的开发者,通过 Vue 的 provide/inject 对比来理解 React Context。

一、什么是 Context?

在组件开发中,我们经常遇到这样的场景:某个数据需要在多层嵌套的组件间共享。如果一层层通过 props 传递,代码会变得非常冗长且难以维护,这就是所谓的 "prop drilling" 问题。

React 的 Context 和 Vue 的 provide/inject 都是为了解决这个问题而设计的 —— 它们允许数据跨层级传递,跳过中间组件。

二、React Context 基础用法

核心三步

  1. 创建 Context —— 创建一个数据共享的"通道"
  2. 提供数据 —— 父组件通过 Provider 提供数据
  3. 消费数据 —— 子组件通过 useContext 获取数据

完整示例

// ========== 1. 创建 Context ==========
// context.tsx
import { createContext, useContext } from 'react'

// 定义数据类型
type MyContextValue = {
  name: string
  age: number
}

// 创建 Context(可设置默认值)
const MyContext = createContext<MyContextValue>({ name: '', age: 0 })

// 导出一个 hook 方便使用
const useMyContext = () => useContext(MyContext)

export { MyContext, useMyContext }
// ========== 2. 父组件提供数据 ==========
// parent.tsx
import { MyContext } from './context'
import Child from './child'

const Parent = () => {
  const data = { name: '张三', age: 18 }

  return (
    <MyContext.Provider value={data}>
      <Child />
    </MyContext.Provider>
  )
}
// ========== 3. 子组件消费数据 ==========
// child.tsx
import { useMyContext } from './context'

const Child = () => {
  const { name, age } = useMyContext()
  
  return <div>{name} - {age}岁</div>
}

三、对比 Vue 的 provide/inject

如果你熟悉 Vue,这个概念其实非常相似:

步骤 React Vue
创建 createContext() 无需显式创建
提供 <Context.Provider value={}> provide(key, value)
消费 useContext(Context) inject(key)

Vue 等价写法

<!-- 父组件 -->
<script setup>
import { provide } from 'vue'
import Child from './child.vue'

const data = { name: '张三', age: 18 }
provide('myContext', data)
</script>

<template>
  <Child />
</template>
<!-- 子组件 -->
<script setup>
import { inject } from 'vue'

const { name, age } = inject('myContext')
</script>

<template>
  <div>{{ name }} - {{ age }}岁</div>
</template>

可以看到,两者的设计思想是一致的,只是语法不同:

  • React 使用 JSX 的组件包裹方式 <Context.Provider>
  • Vue 使用 Composition API 的函数调用方式

四、原生 Context 的性能问题

原生 React Context 存在一个性能陷阱:

只要 Context value 中的任何一个字段变化,所有消费这个 Context 的组件都会重新渲染,即使它们只用到了没变的字段。

// 原生 React Context 的问题
const MyContext = createContext({ name: '张三', age: 18, city: '北京' })

// 这个组件只用 name,但 age 或 city 变化时也会重新渲染!
const Child = () => {
  const { name } = useContext(MyContext)
  return <div>{name}</div>
}

当 Context 中有几十个字段时(这在大型应用中很常见),这个问题会严重影响性能。

五、use-context-selector:性能优化方案

为了解决这个问题,社区提供了 use-context-selector 库。它支持选择器模式,让组件只订阅自己关心的字段。

安装

npm install use-context-selector

使用方式

// 从 use-context-selector 导入,而不是 react
import { createContext, useContext } from 'use-context-selector'

const MyContext = createContext({ name: '张三', age: 18, city: '北京' })

// 使用选择器,只订阅 name
const Child = () => {
  const name = useContext(MyContext, v => v.name)  // age 或 city 变化不会触发重渲染
  return <div>{name}</div>
}

核心区别

特性 React 原生 use-context-selector
导入来源 'react' 'use-context-selector'
更新粒度 整个 Context 变化就重渲染 可以用选择器精确订阅某个字段
性能 大型 Context 可能性能差 优化了选择器模式,避免不必要的重渲染
使用方式 useContext(ctx) useContext(ctx, selector?)

六、实际案例分析

以 Dify 项目中的 ChatWithHistoryContext 为例:

// context.tsx
import { createContext, useContext } from 'use-context-selector'

export type ChatWithHistoryContextValue = {
  appMeta?: AppMeta | null
  appData?: AppData | null
  appParams?: ChatConfig
  currentConversationId: string
  conversationList: AppConversationData['data']
  handleNewConversation: () => void
  handleChangeConversation: (conversationId: string) => void
  // ... 还有 20+ 个字段
}

export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
  currentConversationId: '',
  // ... 默认值
})

export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
// parent.tsx - 提供数据
const ChatWithHistoryWrap = () => {
  const contextValue = useChatWithHistory()  // 获取所有数据

  return (
    <ChatWithHistoryContext.Provider value={contextValue}>
      <ChatWithHistory />
    </ChatWithHistoryContext.Provider>
  )
}
// child.tsx - 消费数据
const ChatWithHistory = () => {
  const { 
    appData, 
    conversationList, 
    handleChangeConversation 
  } = useChatWithHistoryContext()
  
  // 使用数据...
}

这个 Context 有 30+ 个字段,如果使用原生 Context,任何一个字段变化都会导致所有子组件重渲染。使用 use-context-selector 后,框架内部做了优化,避免了不必要的渲染。

七、数据流图解

┌─────────────────────────────────────────────────┐
│  ChatWithHistoryWrap (父组件)                    │
│                                                 │
│  通过 useChatWithHistory() 获取所有数据          │
│  { appData, appParams, conversationList, ... }  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ChatWithHistoryContext.Provider                │
│  value={{ appData, appParams, ... }}            │  ← 数据注入到 Context
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│  ChatWithHistory (子组件)                        │
│                                                 │
│  useChatWithHistoryContext() 获取数据           │
└─────────────────────────────────────────────────┘
                        │
          ┌─────────────┼─────────────┐
          ▼             ▼             ▼
     ┌─────────┐  ┌─────────┐  ┌─────────┐
     │ Sidebar │  │ Header  │  │ ChatWrap│
     └─────────┘  └─────────┘  └─────────┘
          │             │             │
          └─────────────┴─────────────┘
                        │
              孙组件同样可以通过
           useChatWithHistoryContext() 获取数据

八、最佳实践

  1. 小型项目:使用原生 React Context 即可,简单直接
  2. 大型项目:当 Context 字段较多(10+)时,考虑使用 use-context-selector
  3. 拆分 Context:如果可能,将不相关的数据拆分到不同的 Context 中
  4. 命名规范:导出一个自定义 hook(如 useMyContext),统一消费方式

九、总结

场景 推荐方案
简单数据共享 React 原生 Context
大型 Context,字段多 use-context-selector
Vue 背景开发者 理解为 provide/inject 的 React 版本

Context 本质上就是 跨层级传递数据 的工具,理解了这一点,无论是 React 还是 Vue,核心概念都是相通的。

不写 Canvas 也能搞定!小程序图片导出的 WebView 通信方案

作者 王嗨皮
2026年3月3日 14:06

大家好,我是王嗨皮,一名主业前端,副业全栈的程序员,如果我的文章能让您有所收获,欢迎一键三连(评论,点赞,关注),感谢!

年前业务部门的同事提了一个需求,将公司PC端询价系统的报价单导出功能移植到到小程序上。

最初接到这个任务时,有点小崩溃,主要问题有两个:

  1. 小程序无法操作DOM元素,因此不能使用 html2Canvas 像PC端一样直接将DOM元素生成图片。
  2. 如果用Canvas自己画,只能手写大量代码,可读性差,拓展困难。

在对着uni-app文档思考了一段时间之后,决定尝试一下小程序与webview双向通信这个解决方案。

解决思路

其实思路也不复杂,就是利用 uni-app 的 <webview> 组件嵌入一个部署在服务器上的 H5 页面,借助小程序与 H5 之间的通信机制,将图片生成的工作转移到 H5 端完成,然后将生成的 base64 图片文件返回给小程序并保存到本地相册。

Screenshot 2026-03-03 at 09.31.35.png

完整方案代码

简单说一下这个方案的优势:

  1. 解决了小程序的DOM限制:H5的环境下可以操作DOM,使用 html2canvas 可以正常运行。
  2. 可读性/拓展性强:页面直接用传统的三件套(HTML/CSS/JS)构建,容易理解。同时针对不同业务板块可以拓展多个模板。
  3. 职责分离:小程序只负责传递数据,H5负责渲染页面和截图。

当然,在这个过程中我也需要和后端同事提前做好沟通,H5页面的数据是需要通过接口传参获取的。

小程序端代码:

<template>
  <view class="container">
    <web-view :src="url" @message="handleMessage"></web-view>
  </view>
</template>

<script>
  export default {
    data() {
      return {
        url: '' ,
        isShow: '',
        imgUrl: '',
        priceId: '',
        priceType: '',
        tokenId: '',
      }
    },

    onLoad(options) {
      this.priceId = options.feeId
      this.priceType = options.Type
      this.tokenId = uni.getStorageSync('loginInfo').F_WxToken

      //跳转的url并传递参数
      this.url = 'https://wxa.xxxx.com/index/index/lclindex?priceId=' + this.priceId + '&priceType=' + this.priceType + '&tokenId=' + this.tokenId
    },

    methods: {
      // 保存相册
      savePoster() {
        // 获取用户的当前设置
        uni.getSetting({
          success: (res) => {
            // 验证用户是否授权可以访问相册
            if (res.authSetting['scope.writePhotosAlbum']) {
              this.saveImageToMobilePhotos()
            } else {
              uni.authorize({
                scope: 'scope.writePhotosAlbum',
                success: () => {
                  this.saveImageToMobilePhotos()
                },
                fail: () => {
                  uni.showToast({
                    title: this.$t('pub.author'),
                    icon: 'none',
                    duration: 2000
                  })
                  setTimeout(() => {
                    uni.openSetting({
                      // 调起客户端小程序,让用户开启访问相册
                      success: (res2) => {
                        console.log(res2.authSetting)
                      }
                    })
                  }, 3000)
                }
              })
            }
          }
        })
      },

      // 接收webview传回参数
      handleMessage(e) {
        if(e.detail.data[0].url) {
          this.imgUrl = e.detail.data[0].url
          let base64 = this.imgUrl.replace(/^data:image\/\w+;base64,/, "")
          let filePath = wx.env.USER_DATA_PATH + '/worldjaguar_lclprice.jpg'

          uni.getFileSystemManager().writeFile({
            filePath: filePath,
            data: base64,
            encoding: 'base64',
            success: res => {
              uni.saveImageToPhotosAlbum({
                filePath: filePath,
                success: res2 => {
                  uni.hideLoading()
                  uni.showToast({
                    title: this.$t('pub.saveimageauthor'),
                    icon: "none",
                    duration: 3000
                  })
                },
                fail: err => {
                  uni.hideLoading()
                  console.log(err)
                }
              })
            },
            fail: err => {
              uni.hideLoading()
              console.log(err)
            }
          })
        }
      }
    }
  }
</script>

H5页面代码

在H5页面中兼容小程序并调用uni-app部分API需要分别引入 jweixin.jsuni.webview.js

与小程序端完成信息通信则使用 uni.postMessage

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="content-type" content="application/json; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0, viewport-fit=cover">
    <title>生成报价单</title>
    <link rel="stylesheet" href="./css/common.css">
    <link rel="shortcut icon" type="image/x-icon" href="./favicon.ico" />
  </head>

  <body>
    <div id="app">
      <!-- 添加一层Loading页面遮罩 -->
      <div class="pub-mask" v-show="showMask">
        <div class="pub-mask-box">
          <img src="./images/loading.gif" alt="">
          <span>报价图片生成中...</span>
        </div>
      </div>
      <div class="savebox" id="savebox">
       ......布局代码
      </div>
      <!-- 点击保存图片触发postMessage -->
      <button type="button" @click="postMessage" id="postMessage" class="savebox-image">保存图片</button>
    </div>
    <script type="text/javascript" src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.9/vue.min.js"></script>
    <script type="text/javascript" src="https://cdn.bootcdn.net/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <!-- 兼容小程序 -->
    <script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.4.0.js"></script>
    <!-- 必须引入 -->
    <script type="text/javascript" src="https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js"></script>
    <script type="text/javascript" src="https://cdn.bootcdn.net/ajax/libs/axios/1.5.0/axios.min.js"></script>
    <script type="text/javascript">
      var App = new Vue({
        el: '#app',
        data: {
          priceId: '',
          priceType: '',
          tokenId: '',
          dataInfo: {},
          userInfo: {},
          freightArr: [],
          polArr: [],
          podArr: [],
          showMask: true,
          dateNumber: ''
        },

        mounted() {
          console.log(document.title)
          this.$nextTick(() => {
            document.addEventListener('UniAppJSBridgeReady', function () {
              uni.getEnv(function (res) {
                console.log('当前环境:' + JSON.stringify(res));
              })
            })
          })
        },

        created() {
          this.priceId = this.getQuery('priceId')? this.getQuery('priceId') : ''
          this.priceType = this.getQuery('priceType')? this.getQuery('priceType') : ''
          this.tokenId = this.getQuery('tokenId')? this.getQuery('tokenId') : ''

          document.title = this.priceType == 1? '生成海出拼箱报价单' : '生成海进拼箱报价单'

          this.getSaveImageData()
        },

        methods: {
          // 接收uni-app小程序传递的参数
          getQuery(name) {
            let reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
            let r = window.location.search.substr(1).match(reg);
            if (r != null) {
              // 对参数值进行解码
              return decodeURIComponent(r[2]);
            }
            return null;
          },

          postMessage() {
            html2canvas(document.querySelector('#savebox'), {
              allowTaint: true,
              scale: 2,
              dpi: 300,
              useCORS: true
            }).then(canvas => {
              let imgUrl = canvas.toDataURL('image/jpeg', 1.0)
              // 注意:base64大图可能导致通信超时,如果是海报或高清图片需求建议上传服务器后返回URL
              uni.postMessage({
                data: {
                  url: imgUrl
                }
              })
              uni.navigateBack({
                url: '/pages/saveimage/saveimage‘
              })
            })
          },

          getSaveImageData() {
            axios({
              method: 'post',
              url: 'https://wxa.worldjaguar.com/apis/Lclquote/getLclImgInfo',
              data: {
                QuoteId: 'xxxxxxxx',
                Type: 1,
                ApiType: 2,
                wxAuthorization: 'xxxxxxxx'
              }
            })
            .then(res => {
              if(res.data.code == 200) {
                this.dataInfo = res.data.data
                this.userInfo = res.data.data.Contact

                this.freightArr = res.data.data.freightSurcharge
                this.polArr = res.data.data.departurePortCharges
                this.podArr = res.data.data.destinationPorts

                this.createdPriceNumber()

                setTimeout(() => {
                  this.showMask = false
                }, 2600)
              }else {
                alert(res.data.info)
                this.showMask = false
              }
            })
          }
        }
      })
    </script>
  </body>
</html>

注意事项

<webview> 组件中的 @message 只会在组件销毁页面回退分享时进行触发,不会立刻收到消息,由于我的系统业务逻辑相对简单,所以采用了回退的方式。

比较推荐的做法是将生成的图片上传给服务器接口,然后将生成的URL传递给小程序,这样既能保证同步性,也能解决如果图片过大直接返回小程序造成卡顿的问题。

另外,由于每次生成图片都需要加载一个H5页面, 用户等待时会出现白屏或中间态等影响体验的问题,最好添加一个loading遮罩,优化用户体验。

最后,不要忘记在小程序后台将访问域名配置到白名单,域名确认为HTTPS,确保 <webview> H5页面URL可以正常访问。

QQ截图20231204161819.png

后续思考

这个案例本身并不复杂,但在实际落地过程中,我们仍然经历了近两个小时的讨论与权衡。从技术深度的角度来看,Canvas 方案无疑更能体现开发者的深层技术能力;但在真实的业务场景下,我们更倾向于选择一个可读性强、易于维护、迭代成本低的务实方案。

如果你有更好的方案或建议,欢迎分享交流,感谢🙏!!

❌
❌