普通视图

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

Three.js 着色器使用教程:进阶指南

作者 Mintopia
2025年4月17日 11:15

在 Three.js 的三维场景构建中,着色器(Shader)是极为强大的工具,能显著提升场景渲染效果,创造独特视觉体验。本文假定你已掌握 Three.js 基础,将深入探讨着色器在 Three.js 中的运用。

理解着色器

着色器是运行在 GPU 上的小程序,分为顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)。顶点着色器处理几何体顶点,常用于控制物体形状、位置和方向;片段着色器处理每个像素颜色,决定物体表面最终呈现颜色。在 Three.js 中,通过自定义着色器可实现复杂光影效果、材质模拟等。

在 Three.js 中使用自定义着色器

1. 创建着色器材质

使用ShaderMaterial类创建自定义着色器材质。需提供顶点着色器和片段着色器代码字符串。

const vertexShader = `
    void main() {
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
`;
const fragmentShader = `
    void main() {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }
`;
const shaderMaterial = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader
});

上述代码中,顶点着色器仅进行常规坐标变换,将顶点位置转换到屏幕空间。片段着色器将所有像素设置为红色。

2. 应用材质到几何体

将创建的ShaderMaterial应用到几何体上,与使用普通材质类似。

const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, shaderMaterial);
scene.add(mesh);

此时,场景中立方体将呈现红色。

示例:实现简单的渐变效果

修改片段着色器实现从顶部蓝色到底部绿色的渐变效果。

const vertexShader = `
    void main() {
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
`;
const fragmentShader = `
    varying vec2 vUv;
    void main() {
        vec4 topColor = vec4(0.0, 0.0, 1.0, 1.0);
        vec4 bottomColor = vec4(0.0, 1.0, 0.0, 1.0);
        float interpolate = vUv.y;
        gl_FragColor = mix(topColor, bottomColor, interpolate);
    }
`;
const shaderMaterial = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader
});
// 在顶点着色器中传递纹理坐标
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([
    0, 0,  1, 0,  1, 1,  0, 1
]), 2));
const mesh = new THREE.Mesh(geometry, shaderMaterial);
scene.add(mesh);

这里,在顶点着色器中未做太多修改。片段着色器引入vUv变量(用于存储纹理坐标),通过vUv.y值(范围 0 - 1)对顶部蓝色和底部绿色进行线性插值,实现渐变效果。需注意,要为几何体设置uv属性传递纹理坐标信息。

传递 Uniform 变量

Uniform 变量是在着色器中可在每一帧更新的全局变量。如传递时间变量实现动画效果。

const vertexShader = `
    uniform float time;
    void main() {
        vec3 newPosition = position;
        newPosition.y += sin(time + position.x) * 0.2;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
    }
`;
const fragmentShader = `
    void main() {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }
`;
const shaderMaterial = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
        time: { value: 0 }
    }
});
function animate() {
    requestAnimationFrame(animate);
    shaderMaterial.uniforms.time.value += 0.05;
    renderer.render(scene, camera);
}
animate();

在上述代码中,顶点着色器引入time uniform 变量,根据时间和顶点x坐标修改顶点y坐标,实现波浪状动画效果。在animate函数中每一帧更新time uniform 变量值。

总结

通过上述示例,你了解了在 Three.js 中创建和使用自定义着色器、实现渐变效果及传递 uniform 变量。着色器功能强大,可实现复杂材质、光影和动画效果。深入研究着色器语言(GLSL)语法和特性,能为 Three.js 场景开发带来更多创意和可能。

next-auth是如何保持登录状态的?回顾一下Session、Cookie、Token

作者 卸任
2025年4月17日 11:05

前言

前一段时间开发项目时,用到了Next框架,个人感觉这个框架还是很厉害的。大大减少了工作量。就用保持登录状态这个例子来说。

对于传统的开发来说:

后端需要,使用密钥和加密算法生成Token;拦截所有需要身份验证的受保护路由的请求;从 Authorization 请求头中获取Token并进行验证。

前端需要,发送登录请求到后端的登录接口;接收后端登录成功的响应,将Token存入Local Storage中;封装请求在每个请求的 Authorization 请求头中添加Token

这是一套标准的流程,当然还有一种是将Token放入Cookie中,看具体项目的做法。工作量还是有点多,在Next框架中使用next-auth,可以很轻松的实现我们的目的,差不多省了个后端的工作量。

正文

如果对SessionCookieToken不是很清楚的话,可以先往下面看介绍Session、Cookie、Token,然后再回来看

next-auth是如何保持登录状态的

那么next-auth是如何保存登录状态的呢。next-auth在用户成功登录后,会创建一个 Token,然后会将这个 Token 存储在客户端的 Cookie 中(默认情况下,Cookie 的名称是 next-auth.session-token)。从下面两幅图就很容易看出来他的实现方式是上面说的另一种实现方式,将Token放入Cookie中。

image.png

image.png

next-authsession指的是什么

在第一次使用next-auth时,我就感到奇怪都使用JWT了,为什么还是用session?后来经过了解才明白这里的 session 指的是 next-auth在客户端维护的用户会话状态(用户当前的登录状态和用户信息),而不是传统的服务器端 Session 存储。

session 回调函数的作用是将JWT中的信息提取出来,并构建成 session 对象,这个 session 对象最终会通过 useSession hook 在客户端组件中访问,也可以通过getServerSession在服务器端获取 session

为什么需要 session

  1. 方便访问用户信息:  useSession hook 提供了一个方便的方式在客户端组件中获取当前用户的登录状态和用户信息。无需像传统做法那样,通过请求来获取用户的信息。
  2. 状态管理: next-auth需要在客户端维护一个状态,知道用户是否已登录,以及用户的基本信息。useSessionhook 提供的 status 字段(例如 loadingauthenticatedunauthenticated) 可以让我们判断用户当前的状态。
  3. 其他: 比如路由保护和中间件中都需要判断用户的状态。

介绍Session、Cookie、Token

我们都知道HTTP 被称为是无状态的(stateless),意味着服务器不会记住客户端之间的任何交互信息。但是在实际中我们却又想让服务器知道我们是谁,这样才能对相应的用户做处理。为了实现这个目的就有了CookieSessionToken来实现在客户端和服务器端之间传递和管理状态信息。

我们用保持登录状态的来举例说明

Cookie

HTTP Cookie(通常简称 Cookie)是服务器发送到用户浏览的一小段文本数据。浏览器会将这段数据存储起来,并在后续相同域名(或符合特定规则的域名和路径)的请求中,通过 HTTP 请求头 自动携带 发送给服务器。本质就是存储在浏览器的数据。

Cookie 的工作原理:

  1. 服务器设置 Cookie

当用户第一次登录时,服务器可以在HTTP响应头中使用 Set-Cookie 字段来向用户的浏览器发送一个或多个 Cookie

  1. 浏览器存储 Cookie

用户的浏览器接收到包含 Set-Cookie 头的 HTTP 响应后,会解析这些指令,并将 Cookie 存储在本地。浏览器会根据Cookie的属性(例如 DomainPathExpires/Max-Age 等)来决定何时以及向哪些服务器发送这些 Cookie

  1. 浏览器发送 Cookie

当用户在之后访问相同域名(或符合 Domain 属性)下的符合 Path 属性的任何资源时,浏览器会在 HTTP 请求头中使用 Cookie 字段,将所有相关的 Cookie 一起发送给服务器。

  1. 服务器接收和使用 Cookie

服务器接收到包含 Cookie 头的 HTTP 请求后,可以解析这个字段,获取之前设置的 Cookie 值。服务器可以根据 Cookie 中存储的信息来识别用户。

但是Cookie 是存储在用户的浏览器中,不能保证数据安全,也永远不要在 Cookie 中存储敏感信息,也是这个问题才有了Session

Session

对于Cookie来说,Session敏感数据存储在服务器端,更加安全;Session 可以存储更大量的数据。所以开始使用Session,但是通常Session都依赖 Cookie,这是为什么,下面会讲。

Session 的工作原理:

  1. 用户首次请求: 当用户第一次登录时,服务器会创建一个与该用户关联的 Session 对象。这个 Session 对象在服务器的内存、文件系统、数据库或其他存储介质中创建并存储。

  2. 生成 Session ID: 服务器会为这个新创建的 Session 对象生成一个唯一的 Session ID

  3. 发送 Session ID 给客户端: 服务器需要将这个Session ID传递给用户的浏览器,以便在后续的请求中能够识别出是同一个会话。最常用的方式是通过设置一个 Session Cookie。这个 Cookie 通常只包含 Session ID

  4. 客户端发送 Session ID: 在用户后续对同一域名发起请求时,浏览器会自动将之前存储的 Session Cookie(包含 Session ID)通过 HTTP 请求头中的 Cookie 字段发送给服务器。

  5. 服务器识别 Session: 服务器接收到请求后,会读取请求头中的 Cookie 字段,获取 Session ID。然后,服务器会根据这个Session ID查找之前存储的对应的 Session 对象

  6. 维护用户状态: 通过找到对应的 Session 对象,服务器可以访问和修改与该用户会话相关的数据。这些数据可以包括用户的登录状态等。

  7. Session 过期和销毁:Session 在服务器端不会永久存在。它们通常会因为以下原因过期或被销毁:

    • 空闲超时: 如果用户在一段时间内没有活动(例如没有发送任何请求),服务器会认为该会话已过期。
    • 绝对超时: 会话可能被设置为在一定时间后强制过期,无论用户是否有活动。
    • 用户主动注销: 当用户点击“注销”等按钮时,服务器端的代码会显式地销毁当前的 Session
    • 会话 Cookie 过期: 如果 Session ID 存储在会话 Cookie 中,当浏览器关闭时,Cookie 会失效,服务器端的 Session 也可能被标记为过期等待回收。
    • 服务器垃圾回收: 服务器通常会有垃圾回收机制,定期清理过期的 Session 数据,以释放服务器资源。

但是,由于每个活跃的 Session 都会占用服务器的存储空间(内存、磁盘等)。在高并发场景下,大量的 Session 可能会消耗大量服务器资源。在多台服务器组成的分布式系统中,需要考虑如何共享 Session 数据,以确保用户的会话在不同的服务器之间保持一致。也是因为这些原因,后面才有了JWT,也就是常常说的Token

Token

Token与传统的基于 Session Cookie的身份验证方式不同,基于 Token 的身份验证通常是无状态的,这意味着服务器不需要为每个用户的会话维护持久化的状态信息。

Token 的工作原理:

  1. 用户登录:客户端向服务器发送登录凭据(例如用户名和密码)。
  2. 服务器验证:服务器验证凭据的有效性。
  3. 颁发 Token: 如果验证成功,服务器创建一个 Token,其中包含用户的身份信息和可选的过期时间等声明,并使用密钥对其进行签名。
  4. 返回 Token:服务器将生成的Token返回给客户端。
  5. 客户端存储 Token:客户端将收到的 Token 安全地存储起来(例如在 Cookie 中或Local Storage中)。
  6. 携带 Token 请求:在后续请求时,客户端会将 Token 通过 HTTP 请求头(通常是 Authorization: Bearer <Token>) 或Cookie发送给服务器。
  7. 服务器验证 Token: 服务器接收到请求后,提取 Token,使用相同的密钥验证其签名和有效性(例如是否过期)。
  8. 授权访问: 如果 Token 验证通过,服务器可以信任 Token 中包含的用户信息,并允许客户端访问相应的资源。

经历长期的发展,现在基本都是使用Token来保持登录状态的。

结语

谢谢大家观看!!!

《v-model原理 》以及 《自定义组件实现v-model》

作者 冰镇生鲜
2025年4月17日 10:58

一、v-model 的实现机制解析

1. 表单元素中的实现机制

v-model 的实现本质是通过 双向数据绑定语法糖,将 v-bind:valuev-on:input 组合封装。

<input v-model="message">

等价于以下代码

<input 
  :value="message" 
  @input="message = $event.target.value"
>

数据 → 视图:通过 v-bind:value 将数据绑定到表单的 value 属性(或 checkedselected 等属性)

视图 → 数据:监听 input 事件(或其他适配事件),通过事件对象 $event.target.value 更新数据


二、 自定义组件中的实现机制

在自定义组件中,v-model 通过 props + 事件 的组合实现父子组件双向通信,且支持 多数据绑定(Vue 3 特性)。

1、父组件实现

通过 v-model:test_data 指定自定义 prop 名称,将父组件 data 变量绑定到子组件的 test_data prop

<template> 
  <ChildComponent v-model:test_data="data" /> 
 </template> 
 
<script> 
  import ChildComponent from "./ChildComponent" 
  const data = ref('aaa') 
</script> 
   
<style> </style>

2、子组件实现

props 接收:通过 test_data prop 接收父组件数据

事件触发:通过 emit('update:test_data') 事件反向更新父组件数据

双向同步:子组件修改数据后,父组件的 data 变量会自动更新

// 接收父组件传递的 prop
const props = defineProps({
  test_data: {
    type: String,
    required: true
  }
});

// 定义事件发射器
const emit = defineEmits(['update:test_data']);

// 数据更新逻辑
const handleChange = (str) => {
  emit('update:test_data', str); // 触发自定义事件,告知父组件更新数据
}

三、 为什么定义emit事件为 update:xxx

update:xxx 是 Vue 的双向绑定约定语法,当父组件通过 v-model:test_data 绑定时:

  1. 自动映射

    父组件的 v-model:test_data="data" 会被编译为:

    <ChildComponent 
      :test_data="data" 
      @update:test_data="data = $event"
    />
    

    子组件的 emit('update:test_data', newValue) 会触发父组件的数据更新。

四、v-model可以绑定多个

v-model支持 多字段扩展

允许一个组件同时绑定多个独立字段(如 v-model:firstName 和 v-model:lastName

<CustomInput 
  v-model:first-name="firstName" 
  v-model:last-name="lastName"
/>

(PS): 同时绑定多个独立数据流,需在子组件分别定义对应的 props 和 emit 事件

五、 注意事项

  1. 避免直接修改 prop
    子组件应通过事件触发更新,而非直接修改父组件传递的 prop
  2. 复杂数据结构处理
    对象类型数据需使用 v-model 的深层绑定特性(Vue 3 新增)
  3. 性能敏感场景
    高频输入场景建议使用 .lazy 修饰符减少更新频率

前端必看 | 零基础入门 | 你真的懂CSS选择器吗?

作者 天天扭码
2025年4月17日 10:54

CSS(层叠样式表)是网页设计的核心语言之一,而选择器则是CSS的基石。本文将带你全面了解CSS选择器的世界,通过简单易懂的解释和实际代码示例,让你快速掌握各种选择器的使用方法。

一、CSS选择器基础:认识选择器

CSS选择器的作用是"选择"HTML元素并为其应用样式。就像在人群中找人一样,我们需要不同的"识别方式"来找到特定的元素。

  • CSS 定义:层叠样式表,用于选择 HTML DOM 元素并应用样式规则。

  • 引入方式

    • 内联标签(<style> :在 HTML 文件的 <head> 标签内使用 <style> 标签编写 CSS 代码。
    • 外联样式(<link> :通过 <link> 标签引入外部 CSS 文件。
    • 行内样式:直接在 HTML 元素的 style 属性中编写 CSS 代码。
  • 渲染流程:先下载样式,再解析 DOM 并应用样式,DOM 与 CSS 结合形成渲染树(render tree),最后通过浏览器渲染引擎渲染得到页面。

渲染树示意图

image.png

1. 基本选择器类型

标签选择器 - 通过HTML标签名选择元素:

p {
  color: blue;
}

这会选择页面中所有的<p>段落元素,并将文字颜色设为蓝色。

类选择器 - 通过class属性选择元素:

.highlight {
  background-color: yellow;
}

在HTML中使用:<p class="highlight">这段文字会高亮</p>

ID选择器 - 通过id属性选择唯一元素:

#header {
  font-size: 24px;
}

在HTML中使用:<div id="header">网站标题</div>

二、CSS选择器优先级:谁说了算?

当多个样式规则作用于同一个元素时,浏览器如何决定应用哪个样式呢?这就涉及到优先级的概念。

优先级权重计算规则:

  • 标签选择器:1
  • 类选择器:10
  • ID选择器:100
  • 行内样式:1000
  • !important:最高优先级

让我们看一个实际例子:

<div class="container" id="main">
  <P style="color: pink;">我看看怎么个事!</P>
</div>
p {
  color: blue !important;
}
.container p {
  color: red;
}
#main p {
  color: green;
}

虽然行内样式权重最高(1000),但!important具有最高优先级,所以最终文字显示蓝色。如果没有!important,则行内样式的粉色会生效。

实例与展示

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS</title>
    <style>
        /* 1(样式) */
        /* 如果一定要显示蓝色 */
        /* !important 很重要 */
        p {
            color: blue !important;
        }
        /* 10(类) + 1(样式) */
        .container p {
            color: red;
        }
        /* 100(id) + 1(样式) */
        #main p {
            color: green;
        }

    </style>
</head>
<body>
    <div class="container" id="main">
        <P style="color: pink;/* 1000 */">我看看怎么个事!</P>
    </div>
</body>
</html>

image.png

三、组合选择器:精准定位元素

1. 后代选择器(空格)

选择某个元素内部的所有特定后代元素:

.container p {
  text-decoration: underline;
}

这会选择.container内部的所有<p>元素,无论嵌套多深。

2. 子元素选择器(>)

只选择直接子元素:

.container > p {
  font-weight: bold;
}

这只会选择.container直接子元素中的<p>,不会选择嵌套在其他元素中的<p>

3. 相邻兄弟选择器(+)

选择紧接在某个元素后的第一个兄弟元素:

h1 + p {
  color: red;
}

这会让紧跟在<h1>后的第一个<p>变为红色。

4. 通用兄弟选择器(~)

选择某个元素后面的所有同级元素:

h1 ~ p {
  color: blue;
}

这会让<h1>后面的所有<p>兄弟元素变为蓝色。

实例与展示

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>选择器</title>
    <style>
        /* 相邻兄弟选择器 */
        /* 得分为2 */
        h1+p{
            color: red;
        }
        /* 通用兄弟选择器 */
        /* 得分为2 */
        h1~p{
            color: blue; 
        }
        /* 子元素选择器 直接子元素*/
        /* 用于选择.container元素内的段落文本 */
        .container >p{
                font-weight: bold;
        }
        .container p{       
            text-decoration: underline;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>标题</h1>
        <p>这是第一段文字</p>
        <p>这是第二段文字</p>
        <a href="">链接</a>
        <span>这是一个span元素</span>
        <div class="inner">
            <p>这是一个内部段落</p>
        </div>
    </div>
</body>
</html>

image.png

四、伪类选择器:元素的状态选择

伪类选择器允许我们根据元素的状态或位置来应用样式。

1. 交互状态伪类

/* 鼠标悬停时 */
p:hover {
  background-color: yellow;
}

/* 按钮被点击时 */
button:active {
  background-color: red;
  color: white;
}

/* 输入框获得焦点时 */
input:focus {
  border: 2px solid blue;
}

2. 结构伪类

/* 选择奇数位置的列表项 */
li:nth-child(odd) {
  background-color: lightgray;
}

/* 选择除最后一个外的所有子元素 */
li:not(:last-child) {
  margin-bottom: 10px;
}

3. 表单相关伪类

/* 复选框被选中时改变相邻标签颜色 */
input:checked + label {
  color: blue;
}

实例与展示

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        /* 伪类选择器 */
        button:active{
            background-color: red; 
            color: white;
        }
        p:hover{
            background-color: yellow;
        }
        /* 鼠标选中文本的效果 */
        ::selection{
            background-color: blue;
            color: white;
        }
        /* 输入框的效果 */
        input:focus{
            border: 2px solid blue;
            outline: none; /* 移除浏览器默认outline */
            accent-color: blue;  /* 设置复选框选中标记颜色 */
        }
         /* 复选框的效果 */
        input:checked + label{
            color: blue;
        } 
        li:nth-child(odd){
            background-color: lightgray;
        }
        li:not(:last-child){
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>伪类选择器示例</h1>
        <button>点击我</button>
        <p>鼠标悬浮在这里</p>
        <input type="text" placeholder="输入框">
        <input type="checkbox" id="option1">
        <label for="option1">选项1</label>
        <input type="checkbox" id="option2" checked>
        <label for="option2">选项2</label>
        <ul>
            <li>列表项1</li>
            <li>列表项2</li>
            <li>列表项3</li>
            <li>列表项4</li>
        </ul>
    </div>
</body>
</html>

video (4).gif

五、易错点一——组合选择器的优先级计算

下面我们来看一个题目

.container ul li:nth-child(odd)

这个选择器的优先级是多少?

答案为10+1+1+10=22

因为:nth-child(odd)是伪类选择器,优先级是10

六、易错点二——nth-child vs nth-of-type:关键区别

这两个选择器经常让人困惑,让我们通过一个例子来理解它们的区别:

<div class="container">
  <h1>nth-child vs nth-of-type 例子</h1>
  <p>这是第一个段落</p>
  <div>这是一个div</div>
  <p>这是第二个段落</p>
  <p>这是第三个段落</p>
  <div>这是第二个div</div>
</div>
/* 选择.container下第3个子元素,且这个元素必须是<p>标签 */
.container p:nth-child(3) {
  background-color: yellow;
}
/* 实际不会生效,因为第3个子元素是div不是p */

/* 选择.container下第3个<p>类型的元素 */
.container p:nth-of-type(3) {
  background-color: lightblue;
}
/* 这会选择"这是第三个段落" */

关键区别

  • nth-child(n):选择父元素的第n个子元素,且必须是指定类型
  • nth-of-type(n):选择父元素下第n个指定类型的子元素(忽略其他类型元素)

image.png

结语

CSS选择器是前端开发的基石,掌握它们能让你更精准地控制页面样式。通过本文的学习,你应该已经了解了各种选择器的基本用法和区别。记住,实践是最好的老师,多写代码、多尝试,你会很快掌握这些知识!

希望这篇指南能帮助你更好地理解CSS选择器。如果有任何问题,欢迎在评论区留言讨论!

【kk-utils】Excel工具——实践篇(一)

作者 在掘金
2025年4月17日 10:39

前言

kk-utils 是一款我自己基于这几年开发封装出来的前端工具库

excel-js

excel-jskk-utils里的工具之一(1.2.0版本开始支持),其作用是有多个针对导出导出的方法函数,是我在写项目时积累下来的,几乎可以覆盖日常所需,具体文档请查看

今天这篇主要说的是实践应用

实践 —— 导出【测试导入模版】

实现效果

截屏2025-04-17 08.57.25.png

比如我要做一个导入模版给用户填写数据导入进系统,第一行有说明和必填的红色,第二行是表头,后面的是表体数据,某些单元格还支持下拉选择

实现方法

import { BiMap } from 'kk-utils-library/bidirectional-mapping';
import { exportExcel } from 'kk-utils-library/excel-js';

// 说明行数据
const instructionData = [
  '必填项\n请选择\n避免手动输入',
  '必填项\n请填写',
  '必填项\n请填写\n1、重复的客户编码将更新该客户的信息\n2、只允许输入英文字母或者阿拉伯数字\n3、编号一旦导入不允许修改',
  '必填项\n请选择\n避免手动输入',
  '必填项\n请填写',
  '必填项\n请填写',
  '必填项\n请填写',
  '非必填项\n请填写'
];

// 表头数据
const tableHeader = [
  '操作类型',
  '客户名称',
  '客户编码',
  '客户类型',
  '省',
  '市',
  '区',
  '备注'
];

// 文本和key的映射关系
const keyMap = new BiMap([
  ['操作类型', 'operationType'],
  ['客户名称', 'customerName'],
  ['客户编码', 'customerCode'],
  ['客户类型', 'customerType'],
  ['省', 'provinceName'],
  ['市', 'cityName'],
  ['区', 'areaName'],
  ['备注', 'remark'],
  ['错误信息', 'errorMessage']
]);

// 必填key
const requireKeys = [
  '操作类型',
  '客户名称',
  '客户编码',
  '客户类型',
  '省',
  '市',
  '区'
];

// 选项数据
const validationData = [
  {
    prop: 'operationType',
    options: [
      {
        label: '新增',
        value: 1
      },
      {
        label: '修改',
        value: 2
      },
      {
        label: '删除',
        value: 3
      },
      {
        label: '不变',
        value: 4
      }
    ],
    transformType: Number,
    label: 'label',
    value: 'value'
  },
  {
    prop: 'customerType',
    options: [
      {
        label: '客户',
        value: 0
      },
      {
        label: '供应商',
        value: 1
      },
      {
        label: '客户与供应商',
        value: 2
      }
    ],
    transformType: Number,
    label: 'label',
    value: 'value'
  }
];

// 表体数据
const bodyData = [
  {
    operationType: 1,
    customerName: '客户名称',
    customerCode: '客户编码',
    customerType: 0,
    provinceName: '省',
    cityName: '市',
    areaName: '区',
    remark: '备注'
  }
];

// 在这里把数组处理成二维数组的格式
function handleGetExportData() {
  return new Promise((resolve) => {
    const result = [
      [...instructionData],
      [...tableHeader],
      ...bodyData.map((item) => {
        const processItem = {
          ...item,
          // 这里要自己把枚举类型的value转成label
          operationType: validationData[0].options.find(
            (option) => option.value === item.operationType
          )?.label,
          // 这里要自己把枚举类型的value转成label
          customerType: validationData[1].options.find(
            (option) => option.value === item.customerType
          )?.label
        };
        return tableHeader.map((label) => {
          const valueKey = keyMap.get(label);
          return processItem[valueKey];
        });
      })
    ];
    // 打印一下result
    // 可以看出最终要的结果就是二维数组,你可以不参照我的自己写方法处理,只要结果是如下就行
    // [
    //   [
    //     '必填项\n请选择\n避免手动输入',
    //     '必填项\n请填写',
    //     '必填项\n请填写\n1、重复的客户编码将更新该客户的信息\n2、只允许输入英文字母或者阿拉伯数字\n3、编号一旦导入不允许修改',
    //     '必填项\n请选择\n避免手动输入',
    //     '必填项\n请填写',
    //     '必填项\n请填写',
    //     '必填项\n请填写',
    //     '非必填项\n请填写'
    //   ],
    //   [
    //     '操作类型',
    //     '客户名称',
    //     '客户编码',
    //     '客户类型',
    //     '省',
    //     '市',
    //     '区',
    //     '备注'
    //   ],
    //   [1, '客户名称', '客户编码', 0, '省', '市', '区', '备注']
    // ];
    resolve(result);
  });
}

// 导出模版
function handleExportTemplate() {
  handleGetExportData().then((data) => {
    exportExcel({
      filename: '测试导入模版',
      sheets: [
        {
          processing: (worksheet) => {
            worksheet.addRows(data);
          },
          extraProcessing: (worksheet) => {
            // 处理单元格的样式
            handleProcessingStyle(worksheet);
            // 处理单元格的额外处理
            handleExtraProcessing(worksheet);
          }
        }
      ]
    });
  });
}

// 处理单元格的样式
function handleProcessingStyle(worksheet) {
  handleProcessingInstructionLineStyle(worksheet);
  handleProcessingTableHeaderLineStyle(worksheet);
}

// 处理说明行的样式
function handleProcessingInstructionLineStyle(worksheet) {
  const compareRowLine = 2; // 对比行 要知道列是否必填就要拿表头行的label去对比
  const rowLine = 1; // 操作行 第一行
  // 无论是否合并单元格 Excel的每一行列数你肯定是知道的 一般都是表头是多少列就是多少列
  for (let i = 1; i <= tableHeader.length; i += 1) {
    const cell = worksheet.getRow(compareRowLine).getCell(i); // 获取表头单元格
    const isRequired = requireKeys.includes(cell.text); // 判断表头是否在必填key里
    if (!isRequired) continue;
    // 填充必填样式
    worksheet.getRow(rowLine).getCell(i).style.fill = {
      type: 'pattern',
      pattern: 'solid',
      fgColor: {
        argb: 'FFFF4949'
      }
    };
  }
}

// 处理表头行的样式
function handleProcessingTableHeaderLineStyle(worksheet) {
  const rowLine = 2; // 操作行 第二行 实际应用中你可以定义变量保存每个模块的行数 这样可以通过相加计算出来而不用写死
  // 无论是否合并单元格 Excel的每一行的列数你肯定是知道的 一般都是表头是多少列就是多少列
  for (let i = 1; i <= tableHeader.length; i += 1) {
    // 填充表头样式
    worksheet.getRow(rowLine).getCell(i).fill = {
      type: 'pattern',
      pattern: 'solid',
      fgColor: {
        argb: 'FF99CCFF'
      }
    };
  }
}

// 处理单元格的额外处理 比如下拉框啊 数字金额格式化等这些
function handleExtraProcessing(worksheet) {
  const instructionLineLength = 1; // 说明行的行数
  const tableHeaderLineLength = 1; // 表头行的行数
  const tableBodyLineLength = bodyData.length; // 表体行的行数
  const tableBodyLineStart =
    1 + instructionLineLength + tableHeaderLineLength; // 表体开始行
  const tableBodyLineEnd = tableBodyLineStart + tableBodyLineLength - 1; // 表体结束行
  // 遍历所有选项数据,给对应单元格设置
  for (let i = 0; i < validationData.length; i += 1) {
    const { prop, options, label } = validationData[i];
    const valueKey = keyMap.get(prop);
    const listLabel = options.map((item) => item.label).join(',');
    // 表体每一行的对应单元格都要设置 所以从表体开始行遍历到结束行
    for (let j = tableBodyLineStart; j <= tableBodyLineEnd; j += 1) {
      worksheet
        .getRow(j)
        .getCell(tableHeader.indexOf(valueKey) + 1).dataValidation = {
        type: 'list',
        allowBlank: true,
        formulae: [`"${listLabel}"`]
      };
    }
  }
}

// 执行导出 就可以看到Excel下载好了 打开就是开头的效果
handleExportTemplate();

实践 —— 导入【测试导入模版】填写的数据

实现效果

这是使用上面导出的模版填写的数据 截屏2025-04-17 10.01.32.png

这是导入后拿到的数组对象 截屏2025-04-17 10.02.46.png

实现方法

先写一个input拿文件和导入按钮

<input type="file" id="file" accept=".xlsx" />
<button id="import">导入所选文件</button>
import { BiMap } from 'kk-utils-library/bidirectional-mapping';
import { importExcel } from 'kk-utils-library/excel-js';

const fileInput = document.getElementById('file');
const importButton = document.getElementById('import');
importButton.addEventListener('click', () => {
  const file = fileInput.files[0];
  if (!file) {
    alert('请选择文件');
    return;
  }
  importExcel(file, {
    headerStartLine: 2, // 因为第一行是说明行,所以表头要设置参数从第二行开始
    headerTotalLine: 1 // 总共一行表头 可以不写 默认是1
  }).then((data) => {
    console.log('data', data);
    // 注意了 因为Excel是多工作表的 所以即使我们只要第一个工作表 返回的data还是嵌套多了一层数组
    // [
    //   [
    //     {
    //       操作类型: '新增',
    //       客户名称: '测试1',
    //       客户编码: 'TEST001',
    //       客户类型: '供应商',
    //       省: '辽宁省',
    //       市: '沈阳市',
    //       区: '辽中县'
    //     },
    //     {
    //       操作类型: '修改',
    //       客户名称: '测试2',
    //       客户编码: 'TEST002',
    //       客户类型: '客户',
    //       省: '山西省',
    //       市: '太原市',
    //       区: '晋源区'
    //     },
    //     {
    //       操作类型: '删除',
    //       客户名称: '测试3',
    //       客户编码: 'TEST003',
    //       客户类型: '客户与供应商',
    //       省: '吉林省',
    //       市: '长春市',
    //       区: '宽城区'
    //     },
    //     {
    //       操作类型: '不变',
    //       客户名称: '测试4',
    //       客户编码: 'TEST004',
    //       客户类型: '供应商',
    //       省: '湖南省',
    //       市: '长沙市',
    //       区: '雨花区'
    //     }
    //   ]
    // ];
  });
});

因为传给后台接收的key不可能是中文,所以我们还要使用前面定义的keyMap把中文key转成后台要的key值才能传给后台写入数据库

import { BiMapConversion } from 'kk-utils-library/bidirectional-mapping'

function handleGetImportData(data) {
  return new Promise((resolve) => {
    const result = data.map((list) => {
      return list.map((item) => {
        const mapData = BiMapConversion(item, keyMap);
        // 因为选项是中文的,要使用选项数据反向处理回对应的value传给后台
        validationData.forEach((el) => {
          const { prop, options, label, value } = el;
          const targetValue = options.find([label, mapData[prop]])?.[
            value
          ];
          (targetValue || target === 0) && (mapData[prop] = targetValue);
        });
        return mapData;
      });
    });
    resolve(result);
  });
}

把importExcel拿到的data传入,就拿到映射后的数据啦

handleGetImportData(data).then(res=>{
  console.log('res', res);
});

截屏2025-04-17 10.35.47.png

Vue Router 中 params 和 query 的区别

作者 Lestat
2025年4月17日 10:10

在 Vue Router 中,params 和 query 是两种不同的传递参数的方式,它们有以下主要区别:

1. 定义方式不同

params

  • 需要在路由路径中定义参数名
  • 适合传递必要参数(如ID)
// 路由配置
{
  path: '/user/:id',
  name: 'user',
  component: User
}

query

  • 不需要在路由路径中定义
  • 适合传递可选参数(如搜索条件)
// 路由配置
{
  path: '/search',
  name: 'search',
  component: Search
}

2. URL 表现形式不同

params

  • 参数是路径的一部分
  • 示例URL: /user/123
  • 不显示参数名

query

  • 参数在问号后以键值对形式出现
  • 示例URL: /search?name=john&age=25
  • 显示参数名和值

3. 使用方法不同

传递 params

// 编程式导航
router.push({ name: 'user', params: { id: 123 } })

// 声明式导航
<router-link :to="{ name: 'user', params: { id: 123 }}">用户</router-link>

传递 query

// 编程式导航
router.push({ path: '/search', query: { name: 'john', age: 25 } })

// 声明式导航
<router-link :to="{ path: '/search', query: { name: 'john', age: 25 }}">搜索</router-link>

4. 获取方式不同

获取 params

// 在组件中
this.$route.params.id
// 组合式API
import { useRoute } from 'vue-router'
const route = useRoute()
const id = route.params.id

获取 query

// 在组件中
this.$route.query.name
// 组合式API
const name = route.query.name

5. 其他重要区别

特性 params query
是否必须定义 是(在路由配置中)
刷新后 可能丢失(除非使用命名路由) 保留
SEO影响 较小(看起来像静态路径) 较大(可能被搜索引擎索引)
参数可见性 隐藏 明文显示
长度限制 受URL总长度限制 受URL总长度限制

使用场景建议

  • 使用 params 当

    • 参数是资源标识(如用户ID)
    • 参数是必需的
    • 希望URL更简洁
  • 使用 query 当

    • 参数是可选的(如分页、筛选条件)
    • 需要分享带参数的URL
    • 参数需要被搜索引擎抓取

注意事项

  1. 使用 params 时,如果使用 path 而不是 name 进行导航,params 会被忽略:

    // 这样不会工作
    router.push({ path: '/user', params: { id: 123 } })
    
    // 这样才有效
    router.push({ name: 'user', params: { id: 123 } })
    
  2. 在 Vue Router 4.x 中,如果没有在路由中定义 params 参数,它们将不会被添加到 URL 中。

  3. query 参数总是字符串类型,如果需要其他类型需要手动转换。

Vue3 在线 PDF 编辑 1.0 画线批注

作者 樊小肆
2025年4月17日 10:10

家人们!上回带大家看了 PDF 预览怎么玩,这回直接放大招 —— 拆解 基于 Vue3 的在线 PDF 编辑 1.0 项目诞生记 里超实用的 画线批注 功能!话不多说,快跟上,一起钻进代码世界 “挖宝”!

初始化 fabric-Canvas:从 “硬刚” 到 “躺平” 的技术自救

一开始咱也是 “技术猛男”,想着手写 Canvas 挑战批注功能,结果写着写着发现 —— 这简直是在给自己挖坑!各种功能需求 “狂轰滥炸”,写到怀疑人生。果断 “认怂”,搬出老伙计 fabric 救场!虽然它平时都在搞图片编辑,但这次跨界到 PDF 批注,居然完美适配,只能说 “专业的事还得专业的工具来干”!

要实现画线批注,先得在页面上 “召唤” 两个 canvas 兄弟:

<canvas
    :data-index="index"
    class="pdf-box"
    :ref="(el:any) => (canvasRefs['canvas' + index] = el)"
></canvas>
<canvas
    class="annotation-canvas"
    :id="`annotation-canvas_${index}`"
></canvas>

第二个 canvas 记得给它加上 z-index: 2 的 “buff”,不然在同一画布上操作,容易把 PDF 内容 “误伤”。当然,你也可以把 PDF 转成图片当背景,这样画布刷新时内容就不会 “人间蒸发”,怎么玩就看各位的脑洞啦!

在渲染 PDF 时,咱得让第二个 canvas 和第一个 canvas “保持同款身材”,再用 new fabric.Canvas 初始化,最后把鼠标事件绑定好,“万事俱备,只欠东风”:

if (!pdfUrl.value) return;
const loadingTask = pdfjsLib.getDocument(pdfUrl.value);
const pdf = await loadingTask.promise;
// 省略部分代码...
for (let i = 1; i <= pagesCount.value; i++) {
    const page = await pdf.getPage(i);
    const viewport = page.getViewport({ scale });
    const canvas = canvasRefs["canvas" + (i - 1)];
    if (!canvas) break;
    const context = canvas.getContext("2d");
    canvas.height = viewport.height;
    canvas.width = viewport.width;
    const fabricCanvas = new fabric.Canvas(`annotation-canvas_${i - 1}`, {
        width: viewport.width,
        height: viewport.height,
        isDrawingMode: false,
    });
    fabricCanvas.selectionColor = 'transparent';
    fabricCanvas.selectionBorderColor = 'transparent';
    fabricCanvas.on('mouse:down', startLine.bind(fabricCanvas, {
        page: i - 1,
        canvas: fabricCanvas,
    }));
    fabricCanvas.on('mouse:move', drawLine.bind(fabricCanvas, {
        page: i - 1,
        canvas: fabricCanvas,
    }));
    fabricCanvas.on('mouse:up', stopDrwa.bind(fabricCanvas, {
        page: i - 1,
        canvas: fabricCanvas,
    }));
    fabricCanvasObj.value[`annotation-canvas_${i - 1}`] = fabricCanvas;
    const wrapper = canvas.parentElement;
    wrapper.style.width = `${viewport.width}px`;
    wrapper.style.height = `${viewport.height}px`;
    const renderContext = {
        canvasContext: context,
        viewport: viewport,
    };
    await page.render(renderContext).promise;
}

我这里用瀑布流展示 PDF,看着超带感!不过要是追求性能,换成翻页模式也是个 “真香” 选择!

fabric 画线批注:代码里的 “魔法画笔”

绑定好事件后,fabric 就要开始 “表演” 了!三个核心函数,让你的鼠标化身 “神笔马良”:

  1. 鼠标按下:记录起点,创建线条
const startLine = async (event: { page: string, canvas: any }, e: any) => {
    if (!e || !event.canvas) return;
    const fabricCanvas = event.canvas;
    const pointer = fabricCanvas.getPointer(e.e);
    if (e.target) {
        fabricCanvas.setActiveObject(e.target);
        fabricCanvas.renderAll();
    } else if (drawConfig.value.type === "draw") {
        const currentObjet = new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], {
            stroke: drawConfig.value.lineColor,
            strokeWidth: drawConfig.value.lineWidth,
            selectable: true
        });
        fabricCanvas.add(currentObjet);
    }
};
  1. 鼠标移动:实时更新线条,“指哪画哪”
const drawLine = (event: { page: string, canvas: any }, e: any) => {
    if (!e) return;
    const fabricCanvas = event.canvas;
    const pointer = fabricCanvas.getPointer(e.e);
    if (drawConfig.value.type === 'draw') {
        const currentObjet = fabricCanvas.getActiveObject();
        if (currentObjet) {
            currentObjet.set({ x2: pointer.x, y2: pointer.y });
            fabricCanvas.requestRenderAll();
        }
    }
};
  1. 鼠标松开:“收笔” 完成,完美 ending
const stopDrwa = (event: { page: string, canvas: any }, e: any) => {
    const fabricCanvas = event.canvas;
    fabricCanvas.discardActiveObject();
    fabricCanvas.renderAll();
};

有了这一套 “组合拳”,画线批注直接拿捏!

以上就是画线批注功能的核心代码解析!后续还有更多宝藏功能等着解锁,想亲自上手的小伙伴,速去 项目仓库 拉取源码!也欢迎各位大佬、小伙伴来唠嗑,咱们一起把这个项目卷成 “六边形战士”!

前端html、css基础的基础

作者 任子行
2025年4月17日 10:07

HTML与CSS基础入门指南

一、HTML基础

HTML(超文本标记语言)是网页的骨架,通过标签定义内容结构。每个HTML文档以<!DOCTYPE html>开头,包含<html>根标签,内部分为<head>(存放元信息)和<body>(显示内容)两部分。

常用标签示例:

html

复制

<h1>主标题</h1>    <!-- 标题标签 -->
<p>这是一个段落</p> <!-- 段落标签 -->
<ul>               <!-- 无序列表 -->
  <li>项目1</li>
  <li>项目2</li>
</ul>
<img src="image.jpg" alt="图片描述"> <!-- 图片标签 -->
<a href="https://example.com">链接</a> <!-- 超链接 -->

运行 HTML

二、CSS基础

CSS(层叠样式表)用于美化网页,通过选择器定位元素并设置样式。常用三种引入方式:内联样式、内部样式表(推荐)和外部.css文件。

基本语法:

css

复制

选择器 {
  属性: 值;
}

常用选择器:

css

复制

h1 { color: blue; }          /* 标签选择器 */
.content { font-size: 16px; } /* 类选择器 */
#header { background: #eee; } /* ID选择器 */

常见样式属性:

  • 文字:color, font-size, font-family
  • 布局:margin, padding, width
  • 背景:background-color, background-image

三、实践示例

html

复制

<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      font-family: Arial;
      margin: 20px;
    }
    .article {
      border: 1px solid #ddd;
      padding: 15px;
    }
    .highlight {
      color: red;
      font-weight: bold;
    }
  </style>
</head>
<body>
  <div class="article">
    <h1>网页设计入门</h1>
    <p class="highlight">关键知识点:</p>
    <ul>
      <li>HTML构建结构</li>
      <li>CSS添加样式</li>
    </ul>
  </div>
</body>
</html>

运行 HTML

四、学习建议

  1. 使用浏览器开发者工具(F12)实时调试
  2. 通过MDN文档查询标签/属性用法
  3. 从简单布局开始实践,逐步增加复杂度

掌握HTML和CSS基础后,可继续学习响应式设计、Flexbox布局等进阶内容。网页开发需要持续练习,建议每周完成1-2个小项目巩固知识。

Vue.js 3 渐进式实现之响应式系统——第十二节:watch 的基本实现原理

2025年4月17日 10:05

往期回顾

  1. 系列开篇与响应式基本实现
  2. effect 函数注册副作用
  3. 建立副作用函数与被操作字段之间的联系
  4. 封装 track 和 trigger 函数
  5. 分支切换与 cleanup
  6. 嵌套的 effect 与 effect 栈
  7. 避免无限递归循环
  8. 调度执行
  9. 懒执行的 effect
  10. 计算属性与缓存
  11. 计算属性的 track 和 trigger

watch 的基本实现原理

上一节中我们实现了完整的计算属性功能,这一节开始我们来实现 watch。

思路

watch

所谓 watch,本质就是观测一个响应式数据,数据发生变化时通知并执行相应的回调函数。watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项,下面是最简单的实现:

function watch(source, cb) {
    effect(
        // 读取 source,从而建立联系
        () => source.foo,
        {
            scheduler() {
                // source 有变化时,执行cb
                cb()
            }
        }
    )
}

递归读取完整对象

然而现在的实现有一个问题,我们硬编码了对 source.foo 的读取,也就是说现在只能监听 obj.foo 的改变。实际上我们希望的是监听的是整个 source 对象的改变,也就是说当 source 对象的任意属性发生变化时,都会触发 cb 的执行。

因此我们需要封装一个通用的读取操作:

function watch(source, cb) {
    effect(
        () => traverse(source),
        {
            scheduler() {
                cb()
            }
        }
    )
}

function traverse(value, seen = new Set()) {
    // 如果 value 是原始值,或者已经被读取过,则什么都不做
    if (typeof value !== 'object' || value === null || seen.has(value)) {
        return
    }

    // 将 value 添加到 seen 中代表读取过了,避免循环引用引起的死循环
    seen.add(value)

    // 暂时只考虑对象,遍历读取对象每一个属性,递归调用 traverse
    for (const key in value) {
        traverse(value[key], seen)
    }

    return value
}

如上述代码所示,在 watch 内部 的 effect 中调用 traverse 函数递归读取对象的每一个属性,使得任意属性发生变化时都能触发回调函数执行。

支持接收 getter

watch 函数除了可以观测响应式数据,还支持接收一个 getter 函数。在 getter 函数内部,用户可以指定该 watch 监听哪些响应式数据。:

watch(
    () => source.foo,
    () => {
        console.log('source.foo changed')
    }
)

实现这一功能的代码如下:

function watch(source, cb) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    effect(
        () => getter(),
        {
            scheduler() {
                cb()
            }
        }
    )
}

获取新值和旧值

在使用 Vue.js 的 watch 时,有一个非常重要的功能,那就是能够在回调函数中得到变化前后的值:

watch(
    () => source.foo,
    (newVal, oldVal) => {
        console.log('source.foo changed', newVal, oldVal)
    }
)

实现这一功能,需要充分利用 effect 函数的 lazy 选项。每次监听的数据更新时手动调用 effect 函数的返回值 effectFn 获取更新后的响应式数据的值,而上一次调用 effectFn 的结果则作为旧值:

function watch(source, cb) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }

    let oldValue, newValue

    // 使用 effect 注册副作用函数,开启 lazy 选项,把返回值储存在 effectFn 中以便之后手动调用
    const effectFn = effect(
        () => getter(),
        {
            lazy: true,
            scheduler() {
                // 在 scheduler 中重新执行副作用函数,获取新值
                newValue = effectFn()
                // 将新值和旧值作为回调函数的参数
                cb(newValue, oldValue)
                // 更新旧值
                oldValue = newValue
            }
        }
    )

    // 手动调用副作用函数,拿到的就是初始旧值
    oldValue = effectFn()
}

上述代码最下面的部分,我们手动调用 effectFn 得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 并得到新值。这样我们就拿到了新值和旧值,进而传给回调函数。最后不要忘了用新值更新旧值:oldValue = newValue,毕竟本次更新后的新值也是下一次更新后的旧值。

已实现

目前我们实现了 watch 的基本功能,支持监听对象的任意属性变化,也支持接收 getter 函数,并且回调函数中也能获取到监听的数据的新值和旧值。

缺陷/待实现

下一节我们将继续实现关于 watch 的两个特性:

  • 立即执行的回调函数
  • 回调函数执行时机

three.js三维场景内容数据存储方案(indexedDB/toJSON)

作者 答案answer
2025年4月17日 10:04

前言

相信大家在使用three.js中开发过基于3D模型数据内容编辑功能时可能会遇到这样一些问题吧。

当你辛苦半天将一个模型场景效果如:相机角度,材质贴图,金属度,粗糙度,自发光,灯光,曝光度,色调映色,场景环境光等参数调至最佳后,因为页面关闭或者刷新导致重新进入页面需要重新去编辑调试数据模型场景,又或者需要手动修改代码默认参数值时,这种情况无论是对于开发者和使用者来说都是一个痛苦的过程。

基于这个需求背景作者也尝试探索了一下three.js三维场景内容数据的存储方案 ↓

一. indexedDB 存储方案(Vue3项目)

three.js提供了将场景数据内容转化为json数据格式的API(toJSON),通过将当前场景(scene)数据转换为json数据然后在存储到indexedDB

1.为了方便操作indexedDB这里我们对其进行单独封装
/**
 * 数据库工具类
 */
export default class IndexDBUtil {
  private dbName: string;
  private version: number;
  private db: IDBDatabase | null;

  constructor(dbName: string, version: number = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }

  /**
   * 初始化数据库
   * @param stores - 对象仓库
   * @returns 是否初始化成功
   */
  async initDB(stores: { name: string; keyPath: string }[]): Promise<boolean> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onerror = () => {
        reject(new Error('数据库打开失败'));
      };

      request.onsuccess = (event) => {
        this.db = (event.target as IDBOpenDBRequest).result;
        resolve(true);
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;

        // 创建对象仓库
        stores.forEach((store) => {
          if (!db.objectStoreNames.contains(store.name)) {
            db.createObjectStore(store.name, { keyPath: store.keyPath });
          }
        });
      };
    });
  }

  /**
   * 添加数据
   * @param storeName - 对象仓库名称
   * @param data - 数据
   * @returns 添加的数据
   */
  async add<T>(storeName: string, data: T): Promise<T> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.add(data);

      request.onsuccess = () => resolve(data);
      request.onerror = () => {
        reject(new Error('添加数据失败'));
      };
    });
  }

  /**
   * 更新数据
   * @param storeName - 对象仓库名称
   * @param data - 数据
   * @returns 更新的数据
   */
  async update<T>(storeName: string, data: T): Promise<T> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.put(data);

      request.onsuccess = () => resolve(data);
      request.onerror = () => reject(new Error('更新数据失败'));
    });
  }

  /**
   * 删除数据
   * @param storeName - 对象仓库名称
   * @param key - 数据键
   * @returns 是否删除成功
   */
  async delete(storeName: string, key: string | number): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.delete(key);

      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(new Error('删除数据失败'));
    });
  }

  /**
   * 查询单条数据
   * @param storeName - 对象仓库名称
   * @param key - 数据键
   * @returns 查询的数据
   */
  async get<T>(storeName: string, key: string | number): Promise<T | null> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readonly');
      const store = transaction.objectStore(storeName);
      const request = store.get(key);

      request.onsuccess = () => resolve(request.result as T);
      request.onerror = () => reject(new Error('查询数据失败'));
    });
  }

  /**
   * 获取所有数据
   * @param storeName - 对象仓库名称
   * @returns 所有数据
   */
  async getAll<T>(storeName: string): Promise<T[]> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readonly');
      const store = transaction.objectStore(storeName);
      const request = store.getAll();

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(new Error('获取所有数据失败'));
    });
  }

  /**
   * 清空对象仓库
   * @param storeName - 对象仓库名称
   * @returns 是否清空成功
   */
  async clear(storeName: string): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('数据库未初始化'));
        return;
      }

      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.clear();

      request.onsuccess = () => resolve(true);
      request.onerror = () => reject(new Error('清空数据失败'));
    });
  }
}

2.在需要使用的页面进行引入和初始化

页面加载时初始化连接indexedDB



<script setup>
import IndexDBUtil from '@/utils/indexedDB';
import { onMounted, ref} from 'vue';
import { cloneDeep } from 'lodash-es';
import {
  IndexDbStoreName,
  IndexDbStoreKeyPath,
  IndexDbDataName,
} from '@/enums/indexDb';
import { useIndexDbStore } from '@/store/indexDbStore';
 // 当前场景存储在pinia中
 const store = useModelStore();
 const indexDbUtil = ref<IndexDBUtil | null>()
 
 // 初始化indexedDB
  onMounted(()=>{
      indexDbUtil.value =  new IndexDBUtil(IndexDbDataName.sceneEditor);  
      indexDbUtil.value.initDB([
        {
          name: IndexDbStoreName.scene,
          keyPath: IndexDbStoreKeyPath.sceneBlobData,
        },
      ])
  })
</script>
3.保存场景数据到indexedDB

<script setup>
import IndexDBUtil from '@/utils/indexedDB';
import { onMounted, ref} from 'vue';
import { cloneDeep } from 'lodash-es';
import {
  IndexDbStoreName,
  IndexDbStoreKeyPath,
  IndexDbDataName,
} from '@/enums/indexDb';
import { useIndexDbStore } from '@/store/indexDbStore';
 // 当前场景存储在pinia中
 const store = useModelStore();
 const indexDbUtil = ref<IndexDBUtil | null>()
 
  // 初始化indexedDB
  onMounted(()=>{
      indexDbUtil.value =  new IndexDBUtil(IndexDbDataName.sceneEditor); 
      indexDbUtil.value.initDB([
        {
          name: IndexDbStoreName.scene,
          keyPath: IndexDbStoreKeyPath.sceneBlobData,
        },
      ])
  })
//保存场景到indexedDB
const saveSceneIndexDb = async () => {
  try {
    if (!store.sceneApi) {
      throw new Error('场景未初始化');
    }
    let newScene = cloneDeep(store.sceneApi?.scene);

    // 创建一个新的对象来存储序列化后的数据
    let jsonData = {
      scene: newScene?.toJSON(),
      camera: store.sceneApi.camera?.toJSON(),
    };
    let sceneInfo = {
      sceneBlobData: IndexDbStoreKeyPath.sceneBlobData,
      ...jsonData,
    }; 
  // 先查询之前是否有保存
    const oldData = await indexDbStore.indexDbUtil?.get(
      IndexDbStoreName.scene,
      IndexDbStoreKeyPath.sceneBlobData
    );
     // 如果有历史记录则更新indexedDB
    if (oldData) {
      await indexDbStore.indexDbUtil?.update(IndexDbStoreName.scene, {
        ...oldData,
        ...jsonData,
      });
    } else {
      // 如果没有历史记录则添加
      await indexDbStore.indexDbUtil?.add(IndexDbStoreName.scene, sceneInfo);
    }
  } catch (error) {
    console.error('保存场景失败:', error);
    return Promise.reject(error);
  } finally {
    return Promise.resolve();
  }
};
</script>

浏览器 F12查看数据内容 在这里插入图片描述

4. 加载indexedDB场景内容数据

three.js 提供了 ObjectLoader 加载器支持将json数据解析

将当前场景和相机等json 数据解析出来然后赋值给场景内容

<script setup>
import IndexDBUtil from '@/utils/indexedDB';
import { onMounted, ref} from 'vue';
import { cloneDeep } from 'lodash-es';
import {
  IndexDbStoreName,
  IndexDbStoreKeyPath,
  IndexDbDataName,
} from '@/enums/indexDb';
import { useIndexDbStore } from '@/store/indexDbStore';
import { ObjectLoader } from 'three';
import * as THREE from 'three';

 // 当前场景存储在pinia中
 const store = useModelStore();
 const indexDbUtil = ref<IndexDBUtil | null>()
 
 onMounted(()=>{
    // 获取indexDb场景数据
      const loadSceneData =indexDbUtil.value.get(
        IndexDbStoreName.scene,
        IndexDbStoreKeyPath.sceneBlobData
      );
     //  创建加载器(可以考虑缓存loader实例)
      const loader = new ObjectLoader();

     // 并行加载场景和相机数据
      const [parseScene, parseCamera] = await Promise.all([
        loader.parseAsync(indexDbSceneData.scene),
        loader.parseAsync(indexDbSceneData.camera),
      ]);

      // 更新场景
       store.sceneApi.scene = parseScene as THREE.Scene;
   
      //  更新相机(避免创建不必要的克隆)
      if (store.sceneApi.camera) {
        store.sceneApi.camera.clear();
        store.sceneApi.camera.copy(parseCamera as THREE.PerspectiveCamera);
      } else {
        store.sceneApi.camera= parseCamera as THREE.PerspectiveCamera;
      }

})

</script>

二. .json文件格式存储

实现原理和indexedDB存储逻辑差不多,只是将three.js场景数据存储在了 .json 格式的文件中

使three.js场景数据内容更加灵活化

如果需要涉及和后端交互则可将文件内容存储到服务端中去

1.封装一个导出场景内容的方法,将three.js场景内容数据导出json

因为如果场景数据内容过大,在导出数据过程中可能会导致页面有明显的卡顿,这里通过添加定时器(setTimeout)和 loading 来实现一个过渡效果提高用户体验

使用防抖函数来防止用户多次点击

将场景数据转换成 二进制(Blob) 然后下载为 .json 格式

const debounceExportScene = debounce(async () => {
  loading.value = true;
  loadingText.value = '导出场景中,页面可能会有卡顿请耐心等待...';
  loadingTimeout.value = setTimeout(() => {
    try {
      const newScene = cloneDeep(store.sceneApi?.scene);
      newScene?.remove(transformControlsRoot as THREE.Object3D);
      // 创建一个新的对象来存储序列化后的数据
      const jsonData = {
        scene: newScene?.toJSON(),
        camera: store.sceneApi?.camera?.toJSON(),
      };
      const blob = new Blob([JSON.stringify(jsonData)], {
        type: 'application/json',
      });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      document.body.appendChild(link);
      link.href = url;
      link.download = `${new Date().toLocaleString()}.json`;
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(url);
      loading.value = false;
      clearTimeout(loadingTimeout.value);
 
    } catch (error) {
      loading.value = false;
      clearTimeout(loadingTimeout.value);
    }
  }, 1000);
}, 1000);


在这里插入图片描述 导出的数据文件 在这里插入图片描述

2.导入场景:将.json文件内容解析并导入到three.js场景中

通过表单获取到.json 文件的file

通过 FileReader 读取 file

将读取成功的内容通过JSON.parse() 为 json格式

最后通过 ObjectLoader 加载解析为 three.js 场景内容的数据格式

<template>
  <el-upload
      style="height: 0"
      ref="uploadSceneRef"
      accept=".json"
      :show-file-list="false"
      :auto-upload="false"
       type="hidden"
      :on-change="chooseSceneJson"
       >
   </el-upload>  
   <el-button @click="importScene">导入场景</el-button>      
</template>
<script setup lang="ts">
import {
  ElMessage,
  ElMessageBox,
  ElUpload,
  type UploadFile,
} from 'element-plus';
import { useModelStore } from '@/store/modelEditStore';
import { ObjectLoader } from 'three';

const uploadSceneRef = ref<InstanceType<typeof ElUpload>>();
const loading = ref(false);
const loadingText = ref('保存场景中...');
const store = useModelStore();

// 导入场景
const importScene = async () => {
  const input = uploadSceneRef?.value?.$el.querySelector('input');
  if (input instanceof HTMLInputElement) input.click();
};
// 选择场景json
const chooseSceneJson = async (file: UploadFile) => {
  try {
    loading.value = true;
    loadingText.value = '导入场景中...';
    const raw: File = file.raw as File;
    const reader = new FileReader();
    const fileContent = await new Promise<string>((resolve, reject) => {
      reader.onload = (e) => {
        if (e.target?.result) {
          resolve(e.target.result as string);
        } else {
          reject(new Error('文件读取失败'));
        }
      };
      reader.onerror = () => reject(new Error('文件读取失败'));
      reader.readAsText(raw);
    });
    const sceneData = JSON.parse(fileContent);
    // 验证场景数据结构
    if (!sceneData.scene || !sceneData.camera) {
      throw new Error('无效的场景文件格式');
    }

    // 加载场景数据
    if (store.sceneApi) {
          //  创建加载器(可以考虑缓存loader实例)
      const loader = new ObjectLoader();

     // 并行加载场景和相机数据
      const [parseScene, parseCamera] = await Promise.all([
        loader.parseAsync(sceneData.scene),
        loader.parseAsync(sceneData.camera),
      ]);

      // 更新场景
       store.sceneApi.scene = parseScene as THREE.Scene;
   
      //  更新相机(避免创建不必要的克隆)
      if (store.sceneApi.camera) {
        store.sceneApi.camera.clear();
        store.sceneApi.camera.copy(parseCamera as THREE.PerspectiveCamera);
      } else {
        store.sceneApi.camera= parseCamera as THREE.PerspectiveCamera;
      }
      
      ElMessage.success('场景导入成功');
   
    }
  } catch (error) {
    ElMessage.error('导入场景失败');
  } finally {
    loading.value = false;
  }
};
</script>

导入成功后的场景内容

2025_4_17 09_57_30.png

结语

ok以上就是作者探索出的three.js场景数据存储方案,如果你有更好的方案欢迎评论交流 完整的效果案例可以通过开源项目附带链接查看:

github:github.com/zhangbo126/…

gitee:gitee.com/ZHANG_6666/…

如何从0到1搭建基于antd的monorepo库——使用rollup进行打包、lerna进行版本管理和发布(六)

作者 Kincy
2025年4月17日 09:58

文章系列

上一章:如何从0到1搭建基于antd的monorepo库——使用dumi进行文档展示(五)

作者有话说

目前已经实现了一部分功能,源代码在 github,欢迎大家 Star 和 PR,一些待实现的功能都在 issue 中,感兴趣的同学可以一起加入进来。

看完这个系列可以收获什么:

  1. 如何使用 pnpm workspace + lerna 搭建 monorepo 仓库
  2. antd 的单个组件怎么进行文件结构的设计
  3. 基于 antd form 实现一个 Json 渲染表单
  4. antd 的打包方式以及如何使用 rollup 实现
  5. 如何发布 monorepo 包到 npm

前瞻

组件库技术选型:

  1. pnpm 10
  2. node 20
  3. lerna 8
  4. react 18
  5. antd 5
  6. dumi 2

正片开始

安装依赖

pnpm add -D rollup @rollup/plugin-typescript rollup-plugin-dts @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-peer-deps-external rollup-plugin-postcss rollup-plugin-terser

配置 rollup

在子包根目录下新增 rollup.config.mjs 文件。

import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'rollup';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';
import { terser } from 'rollup-plugin-terser';

const DIR_MAP = {
  ESM: 'es',
  CJS: 'lib',
  UMD: 'dist',
};

// 处理 Ant Design 样式按需加载
const antdStyles = () => ({
  name: 'antd-styles',
  transform(code, id) {
    if (/node_modules\/antd/.test(id) && id.endsWith('.js')) {
      return code.replace(/import\s+['"].*\.less['"]/, '');
    }
    return null;
  },
});

const baseConfig = (tsConfig) => ({
  external: ['react', 'react-dom', 'antd', 'tslib'],
  input: 'src/index.ts',
  plugins: [
    peerDepsExternal(),
    nodeResolve({
      modulesOnly: true,
      extensions: ['.ts', '.tsx', '.js', '.jsx'],
      preferBuiltins: true,
    }),
    commonjs(),
    typescript(tsConfig),
    babel({
      babelHelpers: 'bundled',
      extensions: ['.ts', '.tsx'],
      presets: [
        '@babel/preset-react',
        ['@babel/preset-env', { modules: false }],
      ],
    }),
    postcss({
      extract: true, // 分离 CSS 文件
      modules: false,
      use: ['less'],
      minimize: true,
    }),
    antdStyles(),
  ],
});

const esmConfig = {
  ...baseConfig({
    declaration: true,
    declarationDir: DIR_MAP.ESM,
  }),
  output: {
    dir: DIR_MAP.ESM,
    format: 'esm',
    preserveModules: true,
    preserveModulesRoot: 'src',
  },
};

const cjsConfig = {
  ...baseConfig({
    declaration: true,
    declarationDir: DIR_MAP.CJS,
  }),
  output: {
    dir: DIR_MAP.CJS,
    format: 'cjs',
    exports: 'named',
    preserveModules: true,
    preserveModulesRoot: 'src',
  },
};

const umdConfig = {
  ...baseConfig(),
  output: [
    {
      file: DIR_MAP.UMD + '/index.js',
      format: 'umd',
      name: 'KcComponents',
      globals: {
        react: 'React',
        'react-dom': 'ReactDOM',
        antd: 'antd',
      },
      sourcemap: true,
    },
    {
      file: DIR_MAP.UMD + '/index.min.js',
      format: 'umd',
      name: 'KcComponents',
      plugins: [terser()],
      sourcemap: true,
    },
  ],
};

export default defineConfig([esmConfig, cjsConfig, umdConfig]);

新增 build 脚本

在子包 package.json 中新增脚本。

{
  "scripts": {
    "build": "rollup -c"
  }
}

在父包 package.json 中新增脚本。

{
  "scripts": {
    "build": "pnpm clean:dist && lerna run build",
    "clean": "lerna clean --yes",
    "clean:dist": "lerna exec -- rm -rf dist es lib"
  }
}

打包构建

在父包或者子包运行 pnpm build 即可进行打包,产物有三份,分别用于 ESM、CMJ、UMD:

image.png

lerna 版本管理

在父包 package.json 中新增脚本。

{
  "scripts": {
    "lv": "lerna version"
  }
}

运行 pnpm lv 会进入一个对话命令行,选择你想要的版本。

image.png

lerna 统一发布

在子包 package.json 中新增内容。

{
  "sideEffects": false,
  "main": "lib/index.js",
  "unpkg": "dist/index.min.js",
  "module": "es/index.js",
  "typings": "es/index.d.ts",
  "files": [
    "es",
    "lib",
    "dist"
  ],
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.org/"
  }
}

在父包 package.json 中新增内容。

{
  "private": true,
  "scripts": {
    "lp": "lerna publish from-git"
  }
}

在父包根目录下运行 pnpm lp 即可发布 npm。

image.png

image.png

注意事项

  1. 在发布前需要先登录 npm
  2. 发布 monorepo 包需要在 npm 组织中发布,且组织名字为 @ 到 / 之间的内容

总结

至此,一个基于 antd 二次封装且使用 pnpm workspace + lerna 的 monorepo 组件库已经完成,想要查看更多内容可以在我的项目查看,如果感兴趣想要一起开发可以查看 issue,里面会有一些未来准备实现的功能。

如果想提前知道更多内容可以直接查看github,欢迎大家 Star 和 PR,如有疑问可以评论或私信。

得物自研DGraph4.0推荐核心引擎升级之路

作者 得物技术
2025年4月17日 09:44

一、前言

DGraph是得物自主研发的新一代推荐系统核心引擎,基于C++语言构建,自2021年启动以来,经过持续迭代已全面支撑得物社区内容分发、电商交易等核心业务的推荐场景。DGraph在推荐链路中主要承担数据海选和粗排序功能,为上层精排提供高质量候选集。

核心技术特性:

  • 索引层 - 支持KV(键值)、KVV(键-多值)、INVERT(倒排)、DENSE-KV(稠密键值)等。索引存储支持磁盘 & 内存两种模式,在预发等延迟压力低场景,通过磁盘索引使用低规格服务器提供基本服务。线上场景使用内存索引保证服务稳定性,提供毫秒级延迟响应。索引更新支持双buff热更新【内存足够】、服务下线滚动更新【内存受限】、Kafka流式数据实时更新等三种模式。

  • 查询层 - 支持向量检索IVF & HNSW、键值(KV)查询、倒排检索、X2I关联查询、图查询。对外提供JavaSDK & C++ SDK。

系统依赖架构:

  • 索引全生命周期管理由得物索引平台DIP统一管控。
  • 服务发现基于ZooKeeper(zk)。
  • 集群资源调度基于得物容器平台,目前已经支持HPA。

服务规模:

目前在线100+集群,2024年双11在线突破了100W qps。

本文主要介绍DGraph系统在2024年的一些重要改进点。主要包括两次架构调整 + 性能优化 + 用户体验提升方面的一些工作。

二、架构升级

2.1 垂直拆分业务集群支持

在2023年前,DGraph系统始终采用单一集群架构提供服务。该架构模式在平台发展初期展现出良好的经济性和运维便利性,但随着业务规模扩张,单集群架构在系统层面逐渐显露出三重刚性约束:

  1. 存储容量瓶颈 - 单节点内存上限导致数据规模受限;
  2. 网络带宽瓶颈 - 单物理机Pod共享10Gbps带宽,实际可用带宽持续承压,推荐引擎业务中部分核心集群200余张数据表(单表需20分钟级更新)的实时处理需求已遭遇传输瓶颈;
  3. 计算能力瓶颈 - 单实例最大64核的算力天花板,难以支撑复杂策略的快速迭代,核心场景响应时效与算法复杂度形成显著冲突;
  4. 稳定性 - 大规格集群对于容器调度平台不友好,在扩容、集群故障、集群发布时耗时较久;基于得物平台推荐数据量增长和算法迭代需求,我们实施业务垂直拆分的多集群架构升级,通过资源解耦与负载分离,有效突破了单节点资源约束,为复杂算法策略的部署预留出充足的技术演进空间。

系统改进点是在DGraph中增加了访问了其他DGraph集群 & FeatureStore特征集群的能力(图1)。为了成本考虑,我们复用了之前系统的传输协议flatbuffers,服务发现仍基于ZooKeeper。

图 1 DGraph 访问架构改进

改造的难点在图化集群!

目前推荐业务的核心场景都进行了图化改造,图化查询是把多路召回、打散、融合、粗排等策略打包到一个DAG图中一次发送到DGraph,DGraph的算子调度模块根据DAG的描述查询索引数据 & 执行算子最终把结果返回给业务系统,但这些DAG图规模都很大,部分业务DAG图涉及300+算子,因此如何在垂直拆分业务中把这些DAG图拆分到不同的DGraph集群中是一个非常复杂的问题,我们主要做了三方面改进:

  1. DAG管理 - 集群分主集群和从集群【多个】,DAG图部署在存在主集群中,DIP平台会分析DAG的拓步结构并把属于从集群的部分复制出来分发给从集群,为了保证DAG的一致性,只允许从主集群修改DAG图;

  2. 集群划分 - 通常按召回划分,比如Embedding召回、X2I召回、实验召回可以分别部署在不同的集群,另外也可以把粗排等算力需求大的部分单独放在一个集群,具体根据业务场景调整;

  3. 性能优化 - 核心表多个集群存放,减少主集群和从集群间数据交换量。

图 2 DGraph业务垂直拆分集群

2.2 分布式能力支持

垂直拆分集群,虽然把推荐N路召回分散到了M个集群,但是每个集群中每个表依然是全量。随着得物业务的发展,扩类目、扩商品,部分业务单表的数据量级已经接近单集群的存储瓶颈。因此需要DGraph中引入数据水平拆分的能力。

图 3 DGraph 分布式集群架构图

在DGraph分布式架构设计中,重点考虑了部署成本优化与业务迁移工作量:

  1. 分布式集群采用【分片数2】×【双活节点2】×【数据副本数2】的最小拓扑结构,理论上需要8台物理节点保障滚动更新与异常容灾时的稳定性。针对CPU负载较轻的场景,为避免独立Proxy集群带来的额外资源开销,DGraph将Proxy模块和DGraph引擎以对称架构部署到所有节点,通过本地优先的智能路由策略(本地节点轮询优先于跨节点访问)实现资源利用率与访问效率的平衡;

  2. 在业务兼容性方面,基础查询接口(KV检索、倒排索引、X2I关联查询)保持完全兼容以降低迁移成本,而DAG图查询需业务侧在查询链路中明确指定Proxy聚合算子的位置以发挥分布式性能优势。数据链路层面,通过DIP平台实现索引无缝适配,支持DataWorks原有任务无需改造即可对接分布式集群,同时增量处理模块内置分片过滤机制,可直接复用现有Flink实时计算集群进行数据同步。

三、性能优化

3.1 算子执行框架优化

在DGraph中,基于DGraph DAG图(参考图9)的一次查询就是图查询,内部简称graphSearch。在一个DAG图中,每个节点都是一个算子(简称Op),算子通过有向边连接其他算子,构成一个有向无环图,算子执行引擎按DAG描述的关系选择串行或者并发执行所有算子,通过组合不同算子DAG图能在推荐场景中灵活高效的完成各种复杂任务。

在实际应用场景中受DAG图规模 & 超时时间(需要控制在100ms内)限制,算子执行框架的效率非常重要。在最开始的版本中我们使用过Omp & 单队列线程池,集群在CPU负载低于30%时表现尚可,但在集群CPU负载超过30%后,rt99表现糟糕。在降本增效的背景下,我们重点对算子执行框架进行了优化,引入了更高效的线程池 & 减少了调度过程中锁的使用。优化后目前DGraph 在CPU压力超过60%依然可以提供稳定服务。

图4 DGraph算子执行框架优化

线程池优化:将原1:N 的线程池-队列架构调整为M:N 分组模式。具体实现为将N个工作线程划分为M个执行组(每组N/M线程),各组配备独立任务队列。任务提交采用轮询分发机制至对应组队列,通过资源分区有效降低线程调度时的锁竞争强度。

调度器优化:在DAG调度过程中存在两个典型多写场景

  1. 前驱算子节点完成时需并行更新后继节点标记;

  2. DAG全局任务计数器归零判断。原方案通过全局锁(Graph锁+Node锁)保障原子性,但在高负载场景引发显著锁竞争开销,影响线程执行效率。经分析发现这两个状态变更操作符合特定并发模式:所有写操作均为单调增减操作,因此可将锁机制替换为原子变量操作。针对状态标记和任务计数场景,分别采用原子变量的FetchAdd和FetchSub指令即可实现无锁化同步,无需引入CAS机制即满足线程安全要求。

3.2 传输协议编码解码优化

优化JavaSDK - DGraph数据传输过程:在DGraph部分场景,由于请求引擎返回的数据量很大,解码编码耗时占整个请求20%以上。分析已有的解码编码模块,引擎在编码阶段会把待传输数据编码到一个FlatBuffer中,然后通过rpc协议发送到业务侧的JavaSDK,sdk 解码FlatBuffer封装成List 返回给业务代码,业务代码再把List 转化成 List<业务Object>。过程中没有并发 & sdk侧多了一层冗余转换。

优化方案如下: 

  1. 串行编码调整为根据文档数量动态调整编码块数量。各子编码块可以并发编码解码,加快编码&解码速度,提升整体传输性能;

  2. sdk 侧由 Doc -> Map -> JavaObject 的转化方式调整为 Doc -> JavaObject,减少解码端算力开销。

图5 DGraph 传输编码解码过程优化

四、用户体验优化

4.1 DAG图调试功能优化

目前我们已经把DGraph DAG图查询的调试能力集成到DIP平台。其原理是:DGraph 的算子基类实现了执行结果输出,由于算子的中间结果数据量极大,当调试模块发现调试标志后会先把当前算子的中间结果写入日志中,数据按TraceID + DAGID+ NodeID 组织,最终这些数据被采集到SLS日志平台。

图6 DGraph DAG图查询调试

从DIP平台调试DAG图请求,首先通过DGraph JavaSDK的调试入口拿到DAG图请求json,填入DIP平台图请求调试入口,发起请求。索引平台会根据请求体自动关联DAG图并结合最终执行结果通过页面的方式展示。DIP平台拿到结果后,在DAG图中成功的算子节点标记为绿色,失败的节点标记为红色(图6)。点击任意节点可以跳转到日志平台查看该节点的中间结果输出。可用于分析DAG图执行过程中的各种细节,提升业务排查业务问题效率。

4.2 DAG图支持TimeLine分析

基于Chrome浏览器中的TimeLine构建,用于DGraph DAG图查询时算子性能分析优化工作。TimeLine功能集成在算子基类中,启动时会记录每个算子的启动时间、等待时间、完成时间、执行线程pid等信息,这些信息首先输出到日志,然后被SLS日志平台采集。用户可以使用查询时的TraceID在日志平台搜索相关的TimeLine信息。

图7 DGraph DAG图例子

图8 使用浏览器查看DGraph DAG图 TimeLine

当我们拿到请求的TimeLine信息后,通过浏览器加载可以通过图形化的方式分析DAG执行过程中耗时分布。图7是一个DAG 请求,它有9个算子节点,图8是它的一次请求的TimeLine。通过分析这些算子的耗时,可以帮助我们定位当前DAG图查询的瓶颈点在哪里,从而精准去解决性能方面的问题。

4.3 DAG图支持动态子图

在DAG图召回中,业务的召回通常都带有一些固定模式,比如一个业务在一个DAG图召回中有N路召回,每一路召回都是:① 查找数据;② 关联可推池;③ 打散; 它们之间的区别可能仅仅是召回数据表名不同或者传递的参数不同。通常我们业务调整或者算法实验调整只需要增加或者减少部分召回,原有模式下这些操作需要去新增或者修改DAG图,加上算法实验很多,业务维护DAG图的成本会非常高。

DAG动态子图的引入就是为了解决这类问题,首先我们在DAG图中配置一个模板子图,它仅仅描述一个行为模式,代表会涉及几个算子,算子之间的关系如何,实际的参数以及召回路的数量则由业务方在发起请求时动态决定。子图的执行和主图的执行共用同一套调度框架,共享运行时资源以降低运行开销。

图9 DGraph 子图

图9是一个DAG召回使用DAG子图后的变化,它有8路召回,一个Merge节点,这些召回分为两类,一类是基于KV表(ForwardSearch)触发的向量召回,另外一类是基于KVV表(IvtSearch)触发的向量召回。引入DAG子图后,在主图中节点数量由17个降为3个。

五、展望未来

过去四年,DGraph聚焦于实现得物推荐引擎体系从0到1的突破,重点完成了核心系统架构搭建、算法策略支持及业务迭代空间拓展,取得多项基础性成果。基于2024年底的用户调研反馈结合DGraph当前的发展,后续将重点提升产品易用性、开发与运维效能及用户体验,同时在系统稳定性、可扩展架构和平台化建设方面持续深化。

算法团队大量HC,欢迎加入我们:得物技术大量算法岗位多地上线,“职”等你来!

文 / 寻风

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

AutoDecimal:轻松解决 JavaScript 运算精度问题之toDecimal

作者 静思己过
2025年4月17日 09:43

toDecimal

文档传送门

1. AutoDecimal:轻松解决 JavaScript 运算精度问题

2. AutoDecimal:轻松解决 JavaScript 运算精度问题之跳过转换

3. AutoDecimal:轻松解决 JavaScript 运算精度问题之显式转换

书接上篇。

限制

由于一些限制,导致在显式使用 toDecimal 时,如果想把某个计算做特殊处理,参数无法传递变量,只能使用基本类型字面量。如:

const num = (0.1111 + 0.2222).toDecimal({
    callMethod: 'toFixed', precision: 3, roundingModes: 'ROUND_UP'
})
// 结果为 0.334
// 上述表达式会被转换为 
// const num = new Decimal(0.1111).plus(0.2222).toFixed(3, 0)

像上述使用方式是没有问题的。可以正常转换,但是如果想把属性提取出来或者通过参数或者变量传递进来的话

const precision = 3
const num = (0.1111 + 0.2222).toDecimal({
    callMethod: 'toFixed', precision, roundingModes: 'ROUND_UP'
})
// 结果为 0.3333
// 如果没有在 vite.config 中配置 toDecimal 参数,将使用默认参数进行转换
// 上述表达式会被转换为 
// const num = new Decimal(0.1111).plus(0.2222).toNumber()

这样的使用方式就会无法解析,所以得到的结果是 0.3333。因为传递的参数无法被解析,所以会使用默认的参数进行转换。

如果在 vite.config 中进行了相应的配置,如:

defineConfig({
    plugins: [
        ...
        AutoDecimal({
            toDecimal: {
                callMethod: 'toFixed',
                precision: 2,
                roundingModes: 'ROUND_UP'
            }
        })
    ]
})

那么:

const precision = 3
const num = (0.1111 + 0.2222).toDecimal({
    callMethod: 'toFixed', precision, roundingModes: 'ROUND_UP'
})
// 结果为 0.34
// 上述表达式会被转换为 
// const num = new Decimal(0.1111).plus(0.2222).toFixed(2, 0)

解决办法

为了解决无法传递变量的问题,现将 callMethod 添加了一个新值 decimal 。当 callMethod: 'decimal' 时,表达式将会返回一个 Decimal 实例。

const precision = 3
const num = (0.1111 + 0.2222).toDecimal({ callMethod: 'decimal'})
// 此时,num 为 Decimal,所以可以直接使用 Decimal 的实例方法进行操作,也可是传递变量等任意操作
num.toFixed(precision)
num.abs()
num.comparedTo(precision)
num.div(10)
num.lessThan(precision)
num.isFinite()
...
Typescript

如果使用的是 typescript 的话,想要得到正确的返回类型,那么需要手动创建一个 d.ts 文件并添加 decimal 类型

export {}
declare module 'unplugin-auto-decimal/types' {
    interface AutoDecimal {
        // 填写项目中对应的包类型即可
        decimal: import('decimal.js-light').Decimal
    }
}

CSS篇:前端开发必备:Flex布局在真实项目中的妙用

2025年4月17日 09:23

🎓 作者简介前端领域优质创作者

🚪 资源导航: 传送门=>

🎬 个人主页:  江城开朗的豌豆

🌐 个人网站:    江城开朗的豌豆 🌍

📧 个人邮箱: YANG_TAO_WEB@163.com 📩

💬 个人微信:     y_t_t_t_ 📱

📌  座  右 铭: 生活就像心电图,一帆风顺就证明你挂了 💔

👥 QQ群:  906392632 (前端技术交流群) 💬

一、为什么Flex布局成为现代前端标配?

还记得被float和clearfix支配的恐惧吗?Flex布局的出现彻底改变了前端开发者的布局方式。根据2023年前端工具调查报告,Flex布局的使用率已经达到惊人的98%,成为最受欢迎的CSS布局方案。今天,我将通过10个真实场景,带你全面掌握Flex布局的实战应用。

二、Flex布局核心概念快速回顾

1. Flex容器属性

.container {
  display: flex;
  flex-direction: row; /* 主轴方向 */
  justify-content: center; /* 主轴对齐 */
  align-items: stretch; /* 交叉轴对齐 */
  flex-wrap: nowrap; /* 换行方式 */
}

2. Flex项目属性

.item {
  flex: 1; /* 缩写:grow shrink basis */
  align-self: center; /* 单独对齐 */
  order: 1; /* 排序 */
}

三、10大Flex布局实战场景

场景1:水平导航菜单

.nav {
  display: flex;
  gap: 20px; /* 项目间距 */
}

.nav-item {
  padding: 10px 15px;
}

优势:自动等间距分布,无需计算margin

场景2:垂直居中(告别绝对定位)

.center-box {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 300px;
}

效果:一行代码实现完美居中

场景3:等高卡片布局

.card-container {
  display: flex;
}

.card {
  flex: 1; /* 自动等高 */
}

痛点解决:传统浮动布局难以实现的等高效果

场景4:底部固定页脚

body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.main {
  flex: 1; /* 占据剩余空间 */
}

技巧:flex-grow扩展剩余空间

场景5:响应式网格系统

.grid {
  display: flex;
  flex-wrap: wrap;
}

.grid-item {
  flex: 1 0 200px; /* 基础宽度200px */
}

响应式:自动换行适应不同屏幕

场景6:输入框+按钮组合

.search-bar {
  display: flex;
}

.input {
  flex: 1;
}

.btn {
  width: 80px;
}

体验优化:输入框自动填充剩余空间

场景7:多列表单布局

.form-row {
  display: flex;
  gap: 15px;
}

.form-group {
  flex: 1;
}

优势:自动对齐,间距统一

场景8:瀑布流布局(有限高度)

.masonry {
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  max-height: 1200px;
}

注意:需要固定容器高度

场景9:订单控制(视觉与DOM顺序分离)

.item:nth-child(1) { order: 3; }
.item:nth-child(2) { order: 1; }
.item:nth-child(3) { order: 2; }

应用场景:响应式布局中的元素重排

场景10:空间分配比例

.dashboard {
  display: flex;
}

.sidebar {
  flex: 0 0 250px;
}

.content {
  flex: 3;
}

.aside {
  flex: 1;
}

效果:主内容区是侧边栏的3倍宽

四、Flex布局性能优化技巧

  1. 避免过度嵌套:建议不超过3层Flex容器

  2. 慎用flex-grow:可能导致布局抖动

  3. 合理使用min-width:防止内容挤压

    .item {
      min-width: 0; /* 修复文本溢出 */
    }
    
  4. 优先使用gap属性:替代margin间距

五、常见问题解决方案

问题1:flex项目宽度超出容器
解决:设置min-width: 0overflow: hidden

问题2:移动端flex布局错乱
解决:添加媒体查询调整flex-direction

@media (max-width: 768px) {
  .container {
    flex-direction: column;
  }
}

问题3:IE11兼容性问题
解决:使用autoprefixer添加前缀

/* 编译前 */
.container {
  display: flex;
}

/* 编译后 */
.container {
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
}

六、Flex与Grid如何选择?

特性 Flex布局 Grid布局
维度 一维 二维
最佳场景 线性布局、内容流 整体页面布局、精确网格
复杂度 相对简单 学习曲线较陡
浏览器支持 更好(包括IE部分支持) 较新浏览器支持更完整

建议:简单线性布局用Flex,复杂二维布局用Grid,两者可以组合使用

结语

Flex布局已经成为现代Web开发的基石技术。通过本文的10个实战场景,相信你已经掌握了Flex布局的核心应用技巧。记住:

  1. 根据内容语义选择合适的布局方式
  2. 不要过度使用Flex(不是所有地方都需要Flex)
  3. 结合其他CSS技术(如Grid、定位)实现复杂布局
  4. 始终考虑响应式和可访问性

你在项目中用过最巧妙的Flex布局方案是什么?欢迎在评论区分享你的实战经验!

CSS篇:解码W3C:Web标准背后的故事与前端开发实践

2025年4月17日 09:23

🎓 作者简介前端领域优质创作者

🚪 资源导航: 传送门=>

🎬 个人主页:  江城开朗的豌豆

🌐 个人网站:    江城开朗的豌豆 🌍

📧 个人邮箱: YANG_TAO_WEB@163.com 📩

💬 个人微信:     y_t_t_t_ 📱

📌  座  右 铭: 生活就像心电图,一帆风顺就证明你挂了 💔

👥 QQ群:  906392632 (前端技术交流群) 💬

一、一个让所有开发者受益的约定

还记得那个"浏览器战争"的年代吗?不同浏览器对同一网页的渲染效果千差万别,开发者不得不为IE、Netscape等不同浏览器编写特定代码。正是Web标准和W3C的出现,终结了这种混乱局面,让"一次编写,到处运行"成为可能。今天,我们就来深入探讨这个支撑现代Web发展的基础架构。

二、Web标准到底是什么?

1. Web标准的三大支柱

  • 结构标准(HTML):网页内容的骨架
  • 表现标准(CSS):网页的视觉呈现
  • 行为标准(JavaScript/DOM):网页的交互逻辑

2. Web标准的核心价值

graph TD
    A[Web标准] --> B[兼容性]
    A --> C[可访问性]
    A --> D[可维护性]
    A --> E[搜索引擎友好]
    A --> F[设备兼容]

三、W3C:Web标准的守护者

1. W3C是什么?

万维网联盟(World Wide Web Consortium)由Tim Berners-Lee于1994年创立,是制定Web标准的核心国际组织。

2. W3C的工作流程

  1. 工作草案(Working Draft)
  2. 候选推荐标准(Candidate Recommendation)
  3. 提案推荐标准(Proposed Recommendation)
  4. W3C正式推荐标准(W3C Recommendation)

3. 现代前端开发者应该关注的W3C规范

  • HTML5.3
  • CSS3模块化规范
  • Web Components
  • Web Accessibility Initiative (WAI)

四、为什么Web标准如此重要?

1. 真实案例:标准化的力量

+ 标准前:需要针对不同浏览器写hack代码
- <!--[if IE 6]>
- <link href="ie6.css" rel="stylesheet">
- <![endif]-->

+ 标准后:一套代码适配所有现代浏览器
  <button class="btn">提交</button>

2. Web标准带来的直接好处

  • 开发效率提升50%+
  • 维护成本降低70%+
  • 页面加载速度提升30%+
  • 可访问性评分提高2-3个等级

五、如何在实际开发中践行Web标准

1. HTML编写最佳实践

<!-- 不推荐 -->
<div onclick="submitForm()">提交</div>

<!-- 推荐 -->
<button type="submit" id="submit-btn">提交</button>
<script>
  document.getElementById('submit-btn')
    .addEventListener('click', submitForm);
</script>

2. CSS编写标准建议

/* 不推荐 */
#header div ul li a { color: red; }

/* 推荐 */
.nav-link { color: var(--primary-color); }

3. JavaScript标准实践

javascript

复制

// 不推荐
document.write('<p>动态内容</p>');

// 推荐
const p = document.createElement('p');
p.textContent = '动态内容';
document.body.appendChild(p);

六、现代前端框架与Web标准

1. React/Vue/Angular如何遵循标准

  • 最终仍编译为标准HTML/CSS/JS
  • 虚拟DOM提高标准DOM操作效率
  • 组件化符合Web Components方向

2. 框架开发者应该注意的"标准陷阱"

// 谨慎使用非标准属性
<div className="container">...</div> // React中的className而非class

七、Web标准的未来趋势

1. 即将到来的新标准

  • CSS Container Queries
  • CSS Nesting
  • WebGPU
  • Import Maps

2. 渐进增强与优雅降级

/* 支持的新特性 */
@supports (display: grid) {
  .container { display: grid; }
}

/* 不支持时的fallback */
@supports not (display: grid) {
  .container { display: flex; }
}

八、常见问题解答

Q:还需要考虑IE浏览器吗?
A:根据项目需求,但微软已正式终止IE支持,建议优先考虑现代浏览器。

Q:如何检查代码是否符合Web标准?
A:使用W3C Validator(validator.w3.org/)和ESLint等工具…

Q:Web组件是未来吗?
A:Web Components是W3C标准,但当前生态不如React/Vue丰富,适合特定场景。

结语

Web标准和W3C规范不是限制开发者创造力的枷锁,而是确保Web长期健康发展的基石。作为前端开发者,我们应该:

  1. 积极学习最新Web标准
  2. 在实际项目中践行标准最佳实践
  3. 参与标准讨论和制定(如GitHub上的W3C提案)
  4. 平衡创新与兼容性的关系

你对Web标准有什么独到见解?在项目中遇到过哪些标准相关的挑战?欢迎在评论区分享你的故事!

AbortController:让异步操作随时说停就停

作者 日升
2025年4月17日 09:05

AbortController:让异步操作随时说停就停

一、什么是 AbortController?

AbortController 是 JavaScript 在浏览器和部分 Node.js 环境中提供的全局类,用来中止正在进行或待完成的异步操作(如 fetch() 请求、事件监听、可写流、数据库事务等)。通过它,我们可以在需要的时候自由地终止这些操作,避免不必要的开销或冗长的等待。

1. 核心思路

创建一个 AbortController 实例后,可以通过它的 signal 属性将中止信号传递给相应的 API。当调用 controller.abort() 时,所有使用该信号的操作都会收到中止通知,并根据设置的逻辑停止执行或抛出错误。

二、基础用法

// 创建 AbortController 实例
const controller = new AbortController();

// 拿到 signal,并可将其传入需要可中止的 API
const signal = controller.signal;

// 主动触发中止
controller.abort();

1. 实例 signal 属性

  • signal 是一个 AbortSignal 实例,可被用于任何支持中止的 API(如 fetch()、事件监听器等)
  • 一旦 abort() 被调用,signal 就会触发 abort 事件,标记为已中止

2. 实例 abort() 方法

  • 调用后会让该 signal 上监听的所有异步操作同时中止
  • 可选地,abort(reason) 可以传递一个原因或错误,供业务逻辑作更细粒度的处理

3. 监听中止事件

controller.signal.addEventListener('abort', () => {
  // 在这里编写中止后的逻辑,例如清理资源或提示用户
});

三、实用场景与示例

1. 事件监听器自动清理

我们可以在添加事件监听器时将 signal 作为选项的一部分

一旦 abort() 被调用,会自动移除与该 signal 关联的监听器,从而简化了取消事件监听的流程

const controller = new AbortController();

window.addEventListener('resize', () => {
  console.log('Window resized!');
}, { signal: controller.signal });

// 调用 abort(),会自动移除 resize 监听器
document.getElementById('but').onclick = () => {
  controller.abort();
}
1.1. 优势
  • 无需手动保存监听器引用再调用 removeEventListener()
  • 若有多个监听器共用同一个 signal,只需一次 abort() 就能一并移除

2. 在 React 中的应用示例

useEffect(() => {
  const controller = new AbortController();

  window.addEventListener('resize', handleResize, { signal: controller.signal });
  window.addEventListener('hashchange', handleHashChange, { signal: controller.signal });
  window.addEventListener('storage', handleStorageChange, { signal: controller.signal });

  return () => {
    // 调用一次 abort(),所有监听器全部被移除
    controller.abort();
  };
}, []);

3. 中止 fetch() 请求

fetch() 支持通过 signal 中止 HTTP 请求,是 AbortController 最常见的应用场景之一

async function uploadFile(file) {
  const controller = new AbortController();

  const responsePromise = fetch('/upload', {
    method: 'POST',
    body: file,
    signal: controller.signal,
  });

  return { responsePromise, controller };
}

const { responsePromise, controller } = uploadFile(file);

document.getElementById('but').onclick = () => {
    controller.abort();
}
  • 一旦 abort 被触发,fetch() 返回的 Promise 将被拒绝,后端也将收到挂断请求(具体取决于实际网络环境)

4. Node.js 中止 http 请求

在新版 Node.js 环境中(v16+),AbortSignal 同样可以应用于内置的 http 或 https 模块中,以取消请求或响应读取。用法与浏览器环境基本一致

4.1. server.js
const http = require('http');

const server = http.createServer((req, res) => {
  console.log('收到请求:', req.url);

  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });

  // 模拟长时间响应:每 500ms 输出一段文字,共输出 50 次
  let count = 0;
  const intervalId = setInterval(() => {
    if (count < 50) {
      res.write(`数据块 #${count}\n`);
      count++;
    } else {
      clearInterval(intervalId);
      res.end('响应全部完成\n');
    }
  }, 500);

  req.on('close', () => {
    console.log('客户端连接关闭,停止发送数据');
    clearInterval(intervalId);
  });
});

const PORT = 3000;
server.listen(PORT, () => {
  console.log(`服务器已启动,监听端口 ${PORT}`);
});
4.2. controller.js
const http = require('http');

function makeAbortableRequest() {
  const controller = new AbortController();
  const { signal } = controller;

  const req = http.get(
    'http://localhost:3000',
    { signal },
    (res) => {
      console.log('已连接到服务器,响应状态:', res.statusCode);
      res.on('data', (chunk) => {
        console.log('收到数据块:', chunk.toString());
      });
      res.on('end', () => {
        console.log('响应结束');
      });
    }
  );
  req.on('error', (err) => {
    console.error('请求错误:', err.message || err);
  });
  setTimeout(() => {
    console.log('5 秒时间到,准备中止请求...');
    controller.abort();
  }, 5000);
}

makeAbortableRequest();

5. 终止流操作

  • 可写流
    • 对于基于 WritableStream 或者更底层的写操作,如果支持将 signal 与写操作关联,当 abort() 被调用时,即可停止写入并执行清理
  • 自定义可中止逻辑
    • 在数据库事务中,自行监听 signal 的 abort 事件来回滚事务或抛出特定错误,都能让流程变得更灵活
async function example() {
  const abortController = new AbortController();
  const stream = new WritableStream({
    write(chunk, sinkController) {
      console.log('正在写入:', chunk);
    },
    close() {
      console.log('写入完成');
    },
    abort(reason) {
      console.warn('写操作被中止:', reason);
    }
  });

  const writer = stream.getWriter();
  let i = 1
  const inter = setInterval(async () => {
    await writer.write(`数据块 ${i}`);
    i++
  }, 1000)

  window.currentAbortController = abortController;
  window.currentWriter = writer;

  document.getElementById('cancelButton').onclick = async () => {
    clearInterval(inter)
    if (window.currentAbortController && window.currentWriter) {
      console.log('点击了取消写操作按钮');
      await window.currentWriter.abort('用户主动终止写入');
      window.currentAbortController.abort('用户主动终止写入');
    } else {
      console.log('没有正在进行的写操作');
    }
  };
}

example();

四、兼容性

  • 浏览器

  • Node.js

Node.js v16 及以上原生支持在 fetch()、http/https 模块中使用 signal

五、总结与扩展

1. 核心价值

  • AbortController 让我们可以更优雅地中止异步操作,不再依赖过时的回调或繁琐的清理逻辑。
  • 避免占用资源或等待无效请求,提升性能和用户体验。

2. 应用场景广泛

  • 事件监听清理、fetch() 请求取消、Node.js HTTP 请求中止、数据库事务可回滚……几乎所有异步操作都能通过一个 signal 实现“一键叫停”。

3. 常见注意点

  • 在代码中使用多条 fetch() 时,务必区分好各自的 controller,或使用 AbortSignal.any() 合并控制。
  • 对于中止错误,需要在 .catch() 或事件监听中显式处理,防止误认为是网络异常或其他错误。

react UI=f(state) 的演进之路

2025年4月17日 05:13

本文围绕 react 及其相关生态,探讨其核心理念 UI=f(state) 的演进过程。

范式革命:前端框架的诞生

在谈范式革命之前,我们需要先搞清楚一点:原生的 JavaScript 这门语言,是基于什么范式的?

实际上这个问题有些难以回答,js 作为一门脚本语言诞生之初,只是被用来做简单的命令式 dom 更新;但用户与浏览器的回调式交互机制也决定了 js 一等函数的特性;基于 prototype 的继承方式使 js 本身就可以进行另类的 OOP 编程;ES5、ES6 则进一步扩展了 class 语法糖和很多声明式的内置方法。js 根本就是一个多范式的融合怪物!

很不幸的是,多范式=无范式,前端开发者不得不在命令式 dom 操作、函数式回调和 OOP 状态管理中左右横跳。一个简单的 UI 动态更新,背后常常是复杂的事件绑定和 dom 操作语句堆叠而成的屎山,代码难以维护,任何一点变更都可能牵一发而动全身。前端陷入了“混沌时代”。

也正是在这个混沌的环境中,人们开始思考一种更清晰的数据到视图的表达方式。于是像 MVC、MVVM、Flux 等模型逐渐浮出水面,它们的共同点是:将数据与 UI 解耦,并以某种方式完成 数据 → UI 的映射

前端框架应运而生。React、Vue、Angular……它们就是这些模型的代理人,承担着将状态映射为 UI 的职责;为了让这种映射更高效、可控,这些框架也不可避免地引入了自己的“编程范式”。

举个例子:使用原生 js 需要把数据命令式插入 dom:
document.querySelector('.name').innerText = name;

而使用 react 则强调声明式编程,开发者通过 jsx 向 react 声明他希望数据如何展示,至于数据到底是如何被插入 HTML,背后的 vdom 也好 fiber 也好,开发者并不需要关心:
<span>{name}</span>

文艺复兴:脱离 OOP 思想的桎梏

在 react 的早期,ES6 甚至还没正式投入使用,连 class 关键字都没有的 react 依然选择了使用‘类’作为组件的载体。这种组件的建模源于 OOP:组件是真实存在于内存中的类的实例,具有生命周期,可以挂载状态,实例上的 render 函数负责生产 UI。

这是一个简单直观的模型。类天然适合承载状态,时间维度的生命周期也符合人类的直觉。而且对于当时大多数开发者来说,OOP 是最熟悉的范式。

但它并不完美:this 的动态作用域在 js 中本身就是一种不够优雅的设计,开发者对 this.state 乃至于 this 的随意修改更是十分危险。更重要的是,类组件的结构与 react 的核心理念是冲突的,react 想要纯粹声明式的 UI=f(state),但类组件让这个 f 的定义变得复杂而模糊。

其实从 react 设计的最早版本,函数组件就存在了,不过它完全是 props => jsx 的简单、无状态函数,直到 React16.8,那个 hooks 发布的版本。

函数组件终于可以拥有自己的状态、副作用和逻辑控制权。没有 this,没有类,没有对 OOP 的模仿,只有函数和闭包,react 完成了前端世界的一次文艺复兴。

傅里叶变换:类组件到函数组件

image.png

傅里叶变换是一种将‘时间上的变化’转换为‘频率上的分布’的方法。它重新定义了数据观察的‘角度’。

react 中类组件与函数组件的差异,并不只是语法形式的演变,更像是“时域”与“频域”的维度切换

面试中大家可能遇到过这样的问题:类组件的 componentDidMount 生命周期在函数组件里要怎么表达?我个人认为代码上讨论要怎么重构是可以的,但从逻辑上来讲,componentDidMount 和 useEffect 完全是两个维度的事物,是不可以类比的。

对于类组件,componentDidMount 是它在时间维度上的生命周期,而对于函数组件,useEffect 的语义已经表达的非常清楚了:这是在函数执行完毕,UI更新后,衍生副作用的处理器。它会在每次计算 UI=f(state) 后执行,之所以能模拟时间维度上的生命周期,只是因为 useEffect 没有观察任何依赖所以只执行了一次而已。

另外,函数组件更能表达’时间切片‘或者‘快照’这个概念。类组件由于实例 this 一直存在,会给人一种‘连续’或者‘持久’的感觉,有的开发者会把类组件的 UI 当成对实例实时观测的结果。实际上无论类组件还是函数组件,UI 本质上都是某个确定时间点上函数执行的结果。函数组件没有实例这一点,让人更容易理解:执行一次就结束了,下一次 UI 更新就是一次全新的计算。

许多初学者会觉得函数组件的闭包是一个陷阱,但其实这正是函数式作用域和闭包的正确表现。函数组件不是一个‘持续存在的对象’,它是每一次 render 的‘快照’,每次 render 都会重新定义上下文,掉入所谓闭包陷阱的原因只是错误引用了旧的上下文而已。

只可到此,不可越过:react 的妥协

react 虽然通过增强版的函数组件模型,使自身更加靠近理想中的 UI=f(state),但标准的函数式编程中,f 应该是一个无状态无副作用的纯函数,stateHook 从最开始的设计方向上已经舍弃了纯函数的可能性

但是我们可以通过一些状态管理库来更进一步:以比较纯粹的 redux 为例。redux 把所有组件的 state 统一维护在和 react 无关的 store 里,再通过 connect & map 函数注入 props。到这一步所有的 state 都被提取到了组件之外,组件只是一个展示层——这似乎已经非常接近函数式编程的哲学了。即便如此,我们仍然没有真正达到纯函数的定义:组件中依然要通过 dispatch(action) 产生副作用。

如果我们想把函数组件做得更纯呢?我们能不能这样思考:函数组件只负责从输入状态计算 UI 和“下一状态”,它不触发任何副作用,只是输出 [UI, nextState],我们把副作用交给“外部引擎”来处理,彻底实现 f(oldState) = [UI, newState]

可惜的是,React 并没有迈出这一步。也许是因为:

  • 生态的破坏性更新成本太高;
  • 对外部 store 的耦合性增强;
  • 又或者只是因为这种形式过于晦涩,失去了 UI=f(state) 的美感。

react 只可到此,但是真的没人可以越过吗?

函数式的极致:狂信者 Elm

没错,Elm 就是那个实现 f(oldState) = [UI, newState] 或者说 update(msg, oldState) => [newState, Cmd] 的狂信者。

所有状态变更都要通过纯函数描述,所有副作用都要显式声明。Elm 抛弃了 js 社区的一切,转而投奔 Haskell 的怀抱——其语言本身几乎就是简化的 Haskell,编译器也是用 Haskell 编写的。

你不会在 Elm 中找到 useEffect,也不会有 this.state =,甚至你都不能写一个 副作用而不标注它的类型。Elm 是前端世界中最接近“纯函数式编程范式”的存在,极致的浪漫主义。

当然,看看社区活跃度和使用率,你会明白追求信仰的代价。

王权没有永恒:serverComponent、svelte

UI=f(state) 是 react 独家的诠释吗?不是的,这只是一个前端系统的通用模型而已,其他框架也可以有自己的理解。

像是 Svelte 这样的 AOT 框架,在编译阶段就完成了 f 的推导,比起传统的虚拟 dom 框架,牺牲了一些动态自由却换来了小型程序的效率碾压。

即使是 react 自身,也有 server component 这种由前到后的迁移,浏览器里副作用太多,没有纯粹的 f(state),那么就交给服务去跑。果然历史就是一个循环,写着写着前端又被 next 和 react 联手赶回去写 jsp 了。

再换个问法:函数式就一定是 js 架构设计的最优范式吗?

也许浏览器退休那一天我们才能得到答案。

手搓ag-grid带筛选的下拉选择器(类似企业版的agRichSelectCellEditor)

作者 圆号手
2025年4月17日 03:01

这两天有个项目需要用到ag-grid,有个需求要实现编辑单元格时出现下拉选项。如图所示

001.png

但是ag-grid的社区版只提供简单的下拉选项(agSelectCellEditor),只能在出现的下拉选项中选,而不能通过输入的方式筛选选项。

002.png

003.png

只有企业版的下拉选项(agRichSelectCellEditor)才提供输入和筛选功能。

004.png

005.png

所以这里手搓了一个自定义的编辑器组件,主要功能如下:

  • ✅ 输入筛选下拉选项

  • ✅ 首次打开显示全部选项

  • ✅ 键盘上下选择、回车选择

  • ✅ 只能选择可选项,非法输入时不更新

  • ✅ 点击组件外部时自动关闭

主要代码有两部分:js和css

class RichSelectEditorWithFilter {
      init(params) {
        this.params = params;
        this.originalOptions = params.values || [];
        this.filteredOptions = [...this.originalOptions];
        this.selectedIndex = 0;
        this.initialValue = params.value; // 用于回退非法输入

        this.eGui = document.createElement('div');
        this.eGui.className = 'custom-rich-select';
        this.eGui.tabIndex = 0;

        // 输入框
        this.input = document.createElement('input');
        this.input.type = 'text';
        this.input.className = 'dropdown-input';
        this.input.value = params.value || '';
        this.hasUserTyped = false;

        this.input.addEventListener('input', () => {
          this.hasUserTyped = true;
          this.filterOptions();
        });

        this.input.addEventListener('keydown', (e) => this.handleKeyDown(e));

        this.dropdown = document.createElement('ul');
        this.dropdown.className = 'custom-rich-select-dropdown';

        this.eGui.appendChild(this.input);
        this.eGui.appendChild(this.dropdown);

        // 初始渲染所有选项
        this.renderOptions();

        // 添加全局点击事件监听器
        this.clickListener = (event) => {
          if (!this.eGui.contains(event.target)) {
            this.params.stopEditing(); // 点击编辑器外部时停止编辑
          }
        };
        document.addEventListener('mousedown', this.clickListener);
      }

      filterOptions() {
        const keyword = this.input.value.toLowerCase();

        if (!this.hasUserTyped || keyword === '') {
          this.filteredOptions = [...this.originalOptions];
        } else {
          this.filteredOptions = this.originalOptions.filter(opt =>
            opt.toLowerCase().includes(keyword)
          );
        }

        this.selectedIndex = 0;
        this.renderOptions();
      }

      renderOptions() {
        this.dropdown.innerHTML = '';

        this.filteredOptions.forEach((option, index) => {
          const item = document.createElement('li');
          item.textContent = option;
          item.className = 'dropdown-item';
          if (index === this.selectedIndex) {
            item.classList.add('selected');
          }

          item.addEventListener('mousedown', () => {
            this.input.value = option;
            this.params.stopEditing();
          });

          this.dropdown.appendChild(item);
        });
      }

      handleKeyDown(e) {
        if (e.key === 'ArrowDown') {
          this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredOptions.length - 1);
          this.renderOptions();
          e.preventDefault();
        } else if (e.key === 'ArrowUp') {
          this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
          this.renderOptions();
          e.preventDefault();
        } else if (e.key === 'Enter') {
          if (this.filteredOptions.length > 0) {
            this.input.value = this.filteredOptions[this.selectedIndex];
          }
          this.params.stopEditing();
        } else if (e.key === 'Escape') {
          this.params.stopEditing(true);
        }
      }

      getGui() {
        return this.eGui;
      }

      afterGuiAttached() {
        this.input.focus();
        this.input.select();
      }

      getValue() {
        const value = this.input.value;
        // 只有值合法(在可选项中)才返回,否则返回原值
        if (this.originalOptions.includes(value)) {
          return value;
        } else {
          return this.initialValue;
        }
      }

      isPopup() {
        return true;
      }

      destroy() {
        // 移除全局点击事件监听器
        document.removeEventListener('mousedown', this.clickListener);
      }
    }

样式文件:

.custom-rich-select {
    border: 1px solid #ccc;
    background: white;
    width: 100%;
    font-family: sans-serif;
    font-size: 14px;
    outline: none;
    padding: 4px;
    box-sizing: border-box;
  }

  .dropdown-input {
    width: 100%;
    box-sizing: border-box;
    padding: 4px;
    margin-bottom: 4px;
    font-size: 14px;
  }

  .custom-rich-select-dropdown {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 150px;
    overflow-y: auto;
    border-top: 1px solid #ddd;
  }

  .dropdown-item {
    padding: 6px 10px;
    cursor: pointer;
  }

  .dropdown-item.selected {
    background-color: #007acc;
    color: white;
  }

  .dropdown-item:hover {
    background-color: #cce5ff;
  }

完整html演示代码

<html lang="en">

<head>
  <!-- Includes all JS & CSS for the JavaScript Data Grid -->
  <!-- ag-grid-enterprise.min.js -->
  <script src="https://cdn.jsdelivr.net/npm/ag-grid-community/dist/ag-grid-community.min.js"></script>
  <!-- <script src="https://cdn.jsdelivr.net/npm/ag-grid-enterprise@33.2.3/dist/ag-grid-enterprise.min.js"></script> -->
</head>

<style>
  .custom-rich-select {
    border: 1px solid #ccc;
    background: white;
    width: 100%;
    font-family: sans-serif;
    font-size: 14px;
    outline: none;
    padding: 4px;
    box-sizing: border-box;
  }

  .dropdown-input {
    width: 100%;
    box-sizing: border-box;
    padding: 4px;
    margin-bottom: 4px;
    font-size: 14px;
  }

  .custom-rich-select-dropdown {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 150px;
    overflow-y: auto;
    border-top: 1px solid #ddd;
  }

  .dropdown-item {
    padding: 6px 10px;
    cursor: pointer;
  }

  .dropdown-item.selected {
    background-color: #007acc;
    color: white;
  }

  .dropdown-item:hover {
    background-color: #cce5ff;
  }
</style>


<body>
  <!-- Your Data Grid container -->
  <div id="myGrid" style="height: 500px"></div>
  <script>
    let gridApi;

    class RichSelectEditorWithFilter {
      init(params) {
        this.params = params;
        this.originalOptions = params.values || [];
        this.filteredOptions = [...this.originalOptions];
        this.selectedIndex = 0;
        this.initialValue = params.value; // 用于回退非法输入

        this.eGui = document.createElement('div');
        this.eGui.className = 'custom-rich-select';
        this.eGui.tabIndex = 0;

        // 输入框
        this.input = document.createElement('input');
        this.input.type = 'text';
        this.input.className = 'dropdown-input';
        this.input.value = params.value || '';
        this.hasUserTyped = false;

        this.input.addEventListener('input', () => {
          this.hasUserTyped = true;
          this.filterOptions();
        });

        this.input.addEventListener('keydown', (e) => this.handleKeyDown(e));

        this.dropdown = document.createElement('ul');
        this.dropdown.className = 'custom-rich-select-dropdown';

        this.eGui.appendChild(this.input);
        this.eGui.appendChild(this.dropdown);

        // 初始渲染所有选项
        this.renderOptions();

        // 添加全局点击事件监听器
        this.clickListener = (event) => {
          if (!this.eGui.contains(event.target)) {
            this.params.stopEditing(); // 点击编辑器外部时停止编辑
          }
        };
        document.addEventListener('mousedown', this.clickListener);
      }

      filterOptions() {
        const keyword = this.input.value.toLowerCase();

        if (!this.hasUserTyped || keyword === '') {
          this.filteredOptions = [...this.originalOptions];
        } else {
          this.filteredOptions = this.originalOptions.filter(opt =>
            opt.toLowerCase().includes(keyword)
          );
        }

        this.selectedIndex = 0;
        this.renderOptions();
      }

      renderOptions() {
        this.dropdown.innerHTML = '';

        this.filteredOptions.forEach((option, index) => {
          const item = document.createElement('li');
          item.textContent = option;
          item.className = 'dropdown-item';
          if (index === this.selectedIndex) {
            item.classList.add('selected');
          }

          item.addEventListener('mousedown', () => {
            this.input.value = option;
            this.params.stopEditing();
          });

          this.dropdown.appendChild(item);
        });
      }

      handleKeyDown(e) {
        if (e.key === 'ArrowDown') {
          this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredOptions.length - 1);
          this.renderOptions();
          e.preventDefault();
        } else if (e.key === 'ArrowUp') {
          this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
          this.renderOptions();
          e.preventDefault();
        } else if (e.key === 'Enter') {
          if (this.filteredOptions.length > 0) {
            this.input.value = this.filteredOptions[this.selectedIndex];
          }
          this.params.stopEditing();
        } else if (e.key === 'Escape') {
          this.params.stopEditing(true);
        }
      }

      getGui() {
        return this.eGui;
      }

      afterGuiAttached() {
        this.input.focus();
        this.input.select();
      }

      getValue() {
        const value = this.input.value;
        // 只有值合法(在可选项中)才返回,否则返回原值
        if (this.originalOptions.includes(value)) {
          return value;
        } else {
          return this.initialValue;
        }
      }

      isPopup() {
        return true;
      }

      destroy() {
        // 移除全局点击事件监听器
        document.removeEventListener('mousedown', this.clickListener);
      }
    }


    const categoryList = [
      "小碗菜", "米线", "火锅", "串串", "小吃", "金汤米线"
    ]

    const gridOptions = {
      // Data to be displayed
      rowData: [
        { category: "小碗菜", name: '宫保鸡丁', foodFeature: '', dishFeature: '', publishCategory: '热销菜品', publishName: '特价宫保鸡丁' },

      ],
      // Columns to be displayed (Should match rowData properties)
      columnDefs: [
        {
          headerName: '品类', field: 'category',
          editable: true,
          cellEditor: RichSelectEditorWithFilter,
          cellEditorParams: {
            values: categoryList,
          },
          cellEditorPopup: true, // 让下拉框浮动显示
        },
        { headerName: '菜品名称', field: 'name' },
        { headerName: '菜品特色', field: 'foodFeature' },
        { headerName: '菜品描述', field: 'dishFeature' },
        { headerName: '发布品类', field: 'publishCategory' },
        { headerName: '发布名称', field: 'publishName' },
      ],
    };

    const gridDiv = document.querySelector("#myGrid");
    gridApi = agGrid.createGrid(gridDiv, gridOptions);

  </script>
</body>

</html>

希望以上代码能够帮到你,顺便点个赞!!!

ReAct的介绍和使用

作者 拖拖765
2025年4月17日 02:01

在大语言模型的世界里,仅仅依靠“单轮输出”已经不足以应对复杂的任务。为了解决这一问题,研究者们提出了一种新范式 —— ReAct(Reasoning + Acting) ,它让模型可以“边想边干”,结合推理能力和工具调用,从而完成更复杂、更真实的任务。

🧠 ReAct 是什么?

传统的语言模型只擅长单步推理,但当任务需要中间步骤,例如调用外部 API、搜索信息或查询数据库时,模型就显得力不从心。

ReAct 的提出者认为,模型需要像人类一样,一边“思考”接下来该做什么(Reasoning),一边“动手”去获取信息或操作工具(Acting),然后根据观察结果继续下一步,直到完成任务。

这就形成了一个循环:

思考 → 行动 → 观察 → 再次思考 → …… → 输出答案

🛠 ReAct 的实际用途

ReAct 非常适合 Agent 架构,比如:

  • 智能问答(结合搜索、数据库查询)
  • 自动数据分析(分析 + 可视化)
  • 任务规划(如旅行路线、学习计划)
  • 多工具交互(例如调用计算器 + 天气 API)

🔍 一个简单的 ReAct 示例(基于类 LangChain 架构)

下面是一个简化的 ReAct 示例代码。这个例子中,模型的任务是计算一个数学表达式,但它不会直接计算,而是“思考”该使用计算器工具,然后调用该工具执行操作。

from langchain.agents import Tool, initialize_agent
from langchain.agents.agent_types import AgentType
from langchain.llms import OpenAI

# 一个简单的计算器工具
def simple_calculator(input: str) -> str:
    try:
        result = eval(input)
        return str(result)
    except:
        return "Invalid expression"

# 注册工具
tools = [
    Tool(
        name="Calculator",
        func=simple_calculator,
        description="用于数学计算,如 '2 + 2 * (3 + 4)'"
    )
]

# 使用 OpenAI + ReAct agent 初始化智能体
llm = OpenAI(temperature=0)
agent = initialize_agent(
    tools, 
    llm, 
    agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION, 
    verbose=True
)

# 测试一下
response = agent.run("请帮我计算 3 * (4 + 5)")
print(response)

🧾 输出示例:

> Entering new AgentExecutor chain...
Thought: 我需要使用计算器来求值 3 * (4 + 5)
Action: Calculator
Action Input: 3 * (4 + 5)
Observation: 27
Thought: 我已经得到了结果
Final Answer: 27

🧩 关键能力:Tool 使用 + 思考链(Chain-of-Thought)

ReAct 不只是工具调用,它背后的强大之处在于:

  • CoT(思考链) :模型会像写作文一样输出中间思考过程。
  • Action-Observation Loop:每一次调用工具后的观察结果都会影响下一步推理。
  • Memory(可选) :一些实现会带上短期记忆能力,让 Agent 更加智能。

📜 相关论文

  • 论文名:ReAct: Synergizing Reasoning and Acting in Language Models
  • 链接arxiv.org/abs/2210.03…
  • 作者:Shinn et al.(Google Research & Princeton)

🧠 总结

ReAct 是连接语言模型与真实世界的桥梁,赋予大模型“像人一样动脑筋+动手”的能力。如果你正在构建 Agent、Chatbot 或工具协同系统,不妨试试 ReAct,它可能是你通往智能自动化

❌
❌