普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月29日首页

ECharts 全局触发click点击事件(柱状图、折线图增大点击范围)

作者 Lsx_
2025年10月28日 09:50

需求

image.png

image.png

如图所示,由于图表联动需求,选中图表中某一列数据,可联动其它图表数据进行渲染。

对于柱状图 需要支持点击其背景区域也可触发点击事件;

对于折线图,需要支持点击其坐标点左右范围的区间也可触发点击事件,并且点击后要保留竖线。

Echarts 点击处理事件,通过chartInstance.on可实现,但只能点击柱状图才能触发,点击label(即:坐标文本)、点击柱状图阴影区域无法触发;通过chartInstance.getZr().on可全局监听Echarts事件,但无法准确的获取当前点击柱状的index;解决办法如下:

解决方案

处理图表全局点击事件

  chartInstance?.getZr().on("click", (params: any) => {
    const pointInPixel = [params.offsetX, params.offsetY];
    const pointInGrid = chartInstance.convertFromPixel({ seriesIndex: 0 }, pointInPixel);
    // 柱状图-竖向(数据的索引值)
    const index = pointInGrid[0];

    console.log(index)
  });

🔍 一步步解析代码

第 1 行
chartInstance?.getZr().on("click", (params) => { ... })
  • chartInstance 是你通过 echarts.init(...) 得到的图表实例。
  • .getZr() 返回的是 ECharts 内部用的 ZRender 实例,它相当于一个底层的“画布层”,能监听点击、鼠标移动等低级事件。
  • .on("click", handler) 表示注册一个画布点击事件,params 是点击事件对象。

👉 所以这一行的作用是:
在整个 ECharts 图的画布上注册一个点击事件监听器。


第 2 行
const pointInPixel = [params.offsetX, params.offsetY];
  • params.offsetX / offsetY 是鼠标点击在画布中的像素坐标。
  • 即:你点击的位置在图表的画布上的绝对坐标点。

👉 这一步是把点击点的屏幕坐标保存成数组,例如 [x, y] = [320, 180]


第 3 行
const pointInGrid = chartInstance.convertFromPixel({ seriesIndex: 0 }, pointInPixel);
  • convertFromPixel 是 ECharts 提供的一个有用方法:

    可以反向计算:“像素坐标 → 对应到数据坐标(数据索引或坐标轴数值)”

  • 第二个参数是刚才的 [x, y] 像素坐标。

  • 第一个参数 { seriesIndex: 0 } 表示使用第 0 个系列(在图表的第一个数据集)来定义转换规则。

👉 如果这是一个柱状图或折线图,调用后得到的 pointInGrid 就是:

[x轴的索引, y轴的数值]

例如点击了第 3 根柱子:

pointInGrid = [3, 200]

第 4-5 行
const index = pointInGrid[0];
console.log(index);
  • 取得反算结果中的第一个值——即 x轴索引值(数据索引)
  • 打印到控制台。

处理图表点击选中态

以上方案只是解决了图表的点击问题,有时候,比如折线图,点击后,需要留下一个选中态的竖线。解决方案如下

let permanentSelectedLine: echarts.graphic.Line | null = null; // 点击列后,选中的竖线
let lastSelectedIndex = -1; // 上次选中的列索引
const SELECTED_LINE_Z = 1000; // 选中竖线的层级

// 点击列并显示虚线
const handleColumnClick = (index: number) => {
  const chartInstance = chartRef.value?.getInstance();
  if (!chartInstance || !data.value?.dayList) return;

  const dayList = data.value.dayList;

  // 检查索引是否有效
  if (index < 0 || index >= dayList.length) return;

  // 获取选中列的日期和数据
  const selectedDate = dayList[index].statDay;
  const selectedData = {
    index,
    date: selectedDate,
    data: dayList[index]
  };

  // 触发事件,用于联动其它图表
  assetDashboardStore.setSelectedOrderIncomeTrendDate(selectedDate);

  // 删除旧的永久选中竖线
  if (permanentSelectedLine) {
    chartInstance.getZr().remove(permanentSelectedLine);
    permanentSelectedLine = null;
  }

  // 如果点击的是同一列,取消选中
  if (lastSelectedIndex === index) {
    lastSelectedIndex = -1;
    return;
  }

  lastSelectedIndex = index;

  // 计算竖线位置
  const xPixel = chartInstance.convertToPixel({ xAxisIndex: 0 }, selectedDate);

  // 获取图表高度,用于绘制竖线
  const chartHeight = chartRef.value?.$el?.clientHeight || 300;

  // 确定竖线的起始和结束位置
  const yStart = 60; // 顶部边距
  const yEnd = chartHeight - 90; // 底部边距

  // 画新竖线(永久选中线,样式对齐 trigger: "axis")
  permanentSelectedLine = new echarts.graphic.Line({
    z: SELECTED_LINE_Z, // 最高层级,确保始终显示在最上层
    shape: { x1: xPixel, y1: yStart, x2: xPixel, y2: yEnd },
    style: {
      stroke: "#666666",
      lineWidth: 1,
      lineDash: [5, 5]
    }
  });

  chartInstance.getZr().add(permanentSelectedLine);
};

🔍 解析代码

这段代码的函数叫 handleColumnClick,它允许在点击某个“列”(比如折线图或柱状图的指定 x 轴点)时执行额外操作:

  1. 在图表上绘制一条永久竖线(虚线)以表示选中项;
  2. 触发一个联动事件(更新状态给其他图表用);
  3. 支持再次点击同一列取消选中。
删除旧的竖线:
if (permanentSelectedLine) {
  chartInstance.getZr().remove(permanentSelectedLine);
  permanentSelectedLine = null;
}
  • 图上可能已经有一条虚线(上次点击生成)。
  • 这一步从画布(ZRender 层)移除旧竖线。
  • 清空变量,以便下一次重新画新的竖线。
计算竖线位置(像素坐标):
const xPixel = chartInstance.convertToPixel({ xAxisIndex: 0 }, selectedDate);
  • selectedDate 是 x 轴的数据值,比如 '2024-10-02'
  • convertToPixel() 把 “坐标点” 转成 “画布像素位置”
  • 返回的是竖线绘制的 x 坐标
获取图表高度范围,确定线的起止点:
const chartHeight = chartRef.value?.$el?.clientHeight || 300;
const yStart = 60;
const yEnd = chartHeight - 90;
  • 计算竖线上下端在画布中的像素位置。
  • 顶部留 60px,底部留 90px,让线看起来不贴边。

创建一条虚线对象:
permanentSelectedLine = new echarts.graphic.Line({
  z: SELECTED_LINE_Z,
  shape: { x1: xPixel, y1: yStart, x2: xPixel, y2: yEnd },
  style: {
    stroke: "#666666",
    lineWidth: 1,
    lineDash: [5, 5]
  }
});

📌 这块非常关键:

  • 调用了 echarts.graphic.Line ——直接使用 ECharts 底层的图形类;
  • 生成一条线段;
  • shape 决定起点终点;style 决定样式;
  • lineDash: [5,5] 表示虚线;
  • z 控制层级(保证线上浮)。

这条线就是真正的「永久选中虚线」。


将虚线添加到图表画布内:
chartInstance.getZr().add(permanentSelectedLine);
  • 调用了 ECharts 的底层绘图引擎(ZRender)的 add() 方法;
  • 这条线直接绘制在图上,不受 tooltip 控制、不会消失;

Javascript常见面试题

作者 东方石匠
2025年10月29日 16:36

目录

  1. ES6+ 核心特性
  2. 原型链与继承
  3. 闭包与作用域
  4. 异步编程
  5. 手写实现
  6. 进阶技巧

ES6+ 核心特性

1. let 和 const

// var 存在变量提升和函数作用域问题
console.log(a); // undefined
var a = 1;

// let 和 const 是块级作用域,不存在变量提升
console.log(b); // ReferenceError
let b = 2;

// const 声明的常量不能重新赋值
const PI = 3.14;
PI = 3.15; // TypeError

// 但可以修改对象的属性
const obj = { name: 'Tom' };
obj.name = 'Jerry'; // 可以
obj = {}; // TypeError

学习要点:

  • var、let、const 的区别
  • 暂时性死区(TDZ)
  • 块级作用域的应用场景

2. 解构赋值

数组解构

// 基础用法
const [a, b, c] = [1, 2, 3];

// 跳过某些值
const [first, , third] = [1, 2, 3];

// 默认值
const [x = 1, y = 2] = [10];
console.log(x, y); // 10, 2

// 剩余参数
const [head, ...tail] = [1, 2, 3, 4];
console.log(head); // 1
console.log(tail); // [2, 3, 4]

// 交换变量
let a = 1, b = 2;
[a, b] = [b, a];

对象解构

// 基础用法
const { name, age } = { name: 'Tom', age: 18 };

// 重命名
const { name: userName, age: userAge } = { name: 'Tom', age: 18 };

// 默认值
const { x = 10, y = 20 } = { x: 30 };
console.log(x, y); // 30, 20

// 嵌套解构
const user = {
  name: 'Tom',
  address: {
    city: 'Beijing',
    district: 'Chaoyang'
  }
};
const { address: { city } } = user;

// 函数参数解构
function getUserInfo({ name, age = 18 }) {
  console.log(name, age);
}
getUserInfo({ name: 'Tom' });

3. 箭头函数

// 基础语法
const add = (a, b) => a + b;
const square = x => x * x; // 单参数可省略括号
const greet = () => 'Hello'; // 无参数

// 返回对象需要加括号
const getUser = id => ({ id, name: 'Tom' });

// this 指向特性
const obj = {
  name: 'Tom',
  sayHi: function() {
    setTimeout(() => {
      console.log(this.name); // 'Tom',箭头函数继承外层 this
    }, 1000);
  },
  sayHello: function() {
    setTimeout(function() {
      console.log(this.name); // undefined,普通函数 this 指向 window
    }, 1000);
  }
};

箭头函数特点:

  • 没有自己的 this,继承外层作用域的 this
  • 不能作为构造函数
  • 没有 arguments 对象
  • 不能使用 yield 命令

4. 模板字符串

// 基础用法
const name = 'Tom';
const age = 18;
const message = `My name is ${name}, I'm ${age} years old.`;

// 多行字符串
const html = `
  <div>
    <h1>${name}</h1>
    <p>Age: ${age}</p>
  </div>
`;

// 表达式计算
const price = 100;
const count = 3;
console.log(`Total: ${price * count}`);

// 标签模板
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<strong>${values[i]}</strong>` : '');
  }, '');
}

const result = highlight`Name: ${name}, Age: ${age}`;
console.log(result); // Name: <strong>Tom</strong>, Age: <strong>18</strong>

5. 扩展运算符与剩余参数

// 数组扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]

// 数组复制
const original = [1, 2, 3];
const copy = [...original];

// 数组去重
const arr = [1, 2, 2, 3, 3, 4];
const unique = [...new Set(arr)]; // [1, 2, 3, 4]

// 对象扩展运算符
const obj1 = { a: 1, b: 2 };
const obj2 = { c: 3, d: 4 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 2, c: 3, d: 4 }

// 对象浅拷贝
const user = { name: 'Tom', age: 18 };
const userCopy = { ...user };

// 剩余参数
function sum(...numbers) {
  return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum(1, 2, 3, 4)); // 10

// 结合解构
const [first, ...rest] = [1, 2, 3, 4];
const { name, ...others } = { name: 'Tom', age: 18, city: 'Beijing' };

6. 默认参数

// 基础用法
function greet(name = 'Guest') {
  return `Hello, ${name}`;
}

// 默认参数可以是表达式
function calculate(a, b = a * 2) {
  return a + b;
}

// 默认参数与解构结合
function createUser({ name = 'Anonymous', age = 0 } = {}) {
  return { name, age };
}

// 函数默认值是惰性求值的
let x = 1;
function foo(y = x) {
  console.log(y);
}
foo(); // 1
x = 2;
foo(); // 2

7. Promise

// 基础用法
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('Success!');
    } else {
      reject('Error!');
    }
  }, 1000);
});

promise
  .then(result => console.log(result))
  .catch(error => console.error(error))
  .finally(() => console.log('Done'));

// Promise 链式调用
fetch('/api/user')
  .then(response => response.json())
  .then(data => {
    console.log(data);
    return fetch(`/api/posts/${data.id}`);
  })
  .then(response => response.json())
  .then(posts => console.log(posts))
  .catch(error => console.error(error));

// Promise.all - 并发执行,全部成功才成功
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);

Promise.all([p1, p2, p3])
  .then(results => console.log(results)); // [1, 2, 3]

// Promise.race - 返回最快的结果
Promise.race([p1, p2, p3])
  .then(result => console.log(result)); // 最快返回的那个

// Promise.allSettled - 等待全部完成,不管成功失败
Promise.allSettled([p1, p2, p3])
  .then(results => console.log(results));

// Promise.any - 只要有一个成功就成功
Promise.any([p1, p2, p3])
  .then(result => console.log(result));

8. async/await

// 基础用法
async function fetchUser() {
  try {
    const response = await fetch('/api/user');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error);
  }
}

// 并发请求
async function fetchAll() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('/api/users').then(r => r.json()),
      fetch('/api/posts').then(r => r.json()),
      fetch('/api/comments').then(r => r.json())
    ]);
    return { users, posts, comments };
  } catch (error) {
    console.error(error);
  }
}

// 顺序执行
async function processInSequence(urls) {
  const results = [];
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    results.push(data);
  }
  return results;
}

// 错误处理
async function getUserData(id) {
  try {
    const user = await fetchUser(id);
    const posts = await fetchUserPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error('Error:', error);
    throw error; // 可以继续向上抛出
  }
}

9. Class 类

// 基础类
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }

  // 静态方法
  static create(name, age) {
    return new Person(name, age);
  }

  // getter
  get info() {
    return `${this.name}, ${this.age}`;
  }

  // setter
  set info(value) {
    const [name, age] = value.split(',');
    this.name = name;
    this.age = parseInt(age);
  }
}

// 继承
class Student extends Person {
  constructor(name, age, grade) {
    super(name, age); // 调用父类构造函数
    this.grade = grade;
  }

  sayHi() {
    super.sayHi(); // 调用父类方法
    console.log(`I'm in grade ${this.grade}`);
  }

  study() {
    console.log(`${this.name} is studying`);
  }
}

// 使用
const student = new Student('Tom', 18, 12);
student.sayHi();
student.study();

// 私有属性(ES2022)
class BankAccount {
  #balance = 0; // 私有属性

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

10. 模块化

// 导出 - module.js
export const PI = 3.14;
export function add(a, b) {
  return a + b;
}
export class Calculator {
  // ...
}

// 默认导出
export default function multiply(a, b) {
  return a * b;
}

// 导入
import multiply, { PI, add, Calculator } from './module.js';

// 全部导入
import * as math from './module.js';

// 重命名导入
import { add as sum } from './module.js';

// 仅执行模块
import './init.js';

// 动态导入
async function loadModule() {
  const module = await import('./module.js');
  module.default();
}

原型链与继承

1. 原型基础

// 构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 在原型上添加方法
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person = new Person('Tom', 18);
person.sayHi(); // Hi, I'm Tom

// 原型链关系
console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(person.__proto__.__proto__ === Object.prototype); // true

// 检查原型
console.log(person instanceof Person); // true
console.log(Person.prototype.isPrototypeOf(person)); // true

原型链图解:

person 
  ↓ __proto__
Person.prototype 
  ↓ __proto__
Object.prototype 
  ↓ __proto__
null

2. 继承的多种方式

1) 原型链继承

function Parent() {
  this.name = 'parent';
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child() {
  this.age = 18;
}

Child.prototype = new Parent();

const child1 = new Child();
const child2 = new Child();

// 缺点:所有实例共享引用类型属性
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue', 'green']

2) 构造函数继承

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name); // 调用父类构造函数
  this.age = age;
}

const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);

child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue']

// 缺点:无法继承父类原型上的方法
console.log(child1.getName); // undefined

3) 组合继承(推荐)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name); // 第二次调用 Parent
  this.age = age;
}

Child.prototype = new Parent(); // 第一次调用 Parent
Child.prototype.constructor = Child;

const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);

child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue']
console.log(child1.getName()); // 'Tom'

// 缺点:调用了两次父类构造函数

4) 寄生组合继承(最佳)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

// 关键:使用 Object.create
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const child = new Child('Tom', 18);
console.log(child.getName()); // 'Tom'

// 封装继承函数
function inherit(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype);
  Child.prototype.constructor = Child;
}

3. Object.create 深入

// Object.create 创建一个新对象,使用现有对象作为原型
const parent = {
  name: 'parent',
  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }
};

const child = Object.create(parent);
child.name = 'child';
child.sayHi(); // Hi, I'm child

// Object.create 的实现
function create(proto) {
  function F() {}
  F.prototype = proto;
  return new F();
}

// 创建一个没有原型的对象
const obj = Object.create(null);
console.log(obj.__proto__); // undefined

闭包与作用域

1. 作用域链

// 全局作用域
const globalVar = 'global';

function outer() {
  // 外层函数作用域
  const outerVar = 'outer';
  
  function inner() {
    // 内层函数作用域
    const innerVar = 'inner';
    console.log(globalVar); // 'global'
    console.log(outerVar);  // 'outer'
    console.log(innerVar);  // 'inner'
  }
  
  inner();
  // console.log(innerVar); // ReferenceError
}

outer();

2. 闭包原理

// 基础闭包
function createCounter() {
  let count = 0;
  return function() {
    return ++count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

// 闭包应用:私有变量
function createPerson(name) {
  let _age = 0; // 私有变量
  
  return {
    getName() {
      return name;
    },
    getAge() {
      return _age;
    },
    setAge(age) {
      if (age > 0 && age < 150) {
        _age = age;
      }
    }
  };
}

const person = createPerson('Tom');
person.setAge(18);
console.log(person.getAge()); // 18
console.log(person._age); // undefined

// 闭包应用:模块化
const calculator = (function() {
  let result = 0;
  
  return {
    add(num) {
      result += num;
      return this;
    },
    subtract(num) {
      result -= num;
      return this;
    },
    multiply(num) {
      result *= num;
      return this;
    },
    getResult() {
      return result;
    }
  };
})();

calculator.add(10).multiply(2).subtract(5).getResult(); // 15

3. 闭包常见问题

问题1:循环中的闭包

// 错误示例
for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 输出 5 个 5
  }, 1000);
}

// 解决方案1:使用 IIFE
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 0, 1, 2, 3, 4
    }, 1000);
  })(i);
}

// 解决方案2:使用 let
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2, 3, 4
  }, 1000);
}

// 解决方案3:使用 bind
for (var i = 0; i < 5; i++) {
  setTimeout(function(j) {
    console.log(j);
  }.bind(null, i), 1000);
}

问题2:内存泄漏

// 可能造成内存泄漏
function outer() {
  const largeData = new Array(1000000).fill('data');
  
  return function inner() {
    console.log('Hello');
    // inner 引用了 outer 的作用域
    // 即使不使用 largeData,它也不会被回收
  };
}

// 改进:只保留需要的数据
function outer() {
  const largeData = new Array(1000000).fill('data');
  const needed = largeData.slice(0, 10);
  
  return function inner() {
    console.log(needed); // 只保留需要的部分
  };
}

4. this 指向详解

// 1. 默认绑定
function foo() {
  console.log(this); // 严格模式: undefined, 非严格模式: window
}

// 2. 隐式绑定
const obj = {
  name: 'Tom',
  sayName() {
    console.log(this.name);
  }
};
obj.sayName(); // 'Tom'

const sayName = obj.sayName;
sayName(); // undefined,this 丢失

// 3. 显式绑定
function greet() {
  console.log(`Hello, ${this.name}`);
}

const user = { name: 'Tom' };
greet.call(user); // Hello, Tom
greet.apply(user); // Hello, Tom
const boundGreet = greet.bind(user);
boundGreet(); // Hello, Tom

// 4. new 绑定
function Person(name) {
  this.name = name;
}
const person = new Person('Tom');
console.log(person.name); // 'Tom'

// 5. 箭头函数
const obj2 = {
  name: 'Tom',
  sayName: () => {
    console.log(this.name); // 继承外层 this
  }
};

// 优先级:new > 显式绑定 > 隐式绑定 > 默认绑定

异步编程

1. 事件循环(Event Loop)

console.log('1'); // 同步任务

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步任务

// 输出顺序:1, 4, 3, 2

// 复杂示例
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

async1();

new Promise(resolve => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');

/* 输出顺序:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

宏任务 vs 微任务

  • 宏任务:setTimeout, setInterval, setImmediate, I/O, UI渲染
  • 微任务:Promise.then, MutationObserver, process.nextTick

执行顺序

  1. 执行同步代码
  2. 执行所有微任务
  3. 执行一个宏任务
  4. 执行所有微任务
  5. 重复 3-4

2. Promise 进阶

实现 Promise.all

Promise.myAll = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('参数必须是数组'));
    }
    
    const results = [];
    let completedCount = 0;
    
    if (promises.length === 0) {
      resolve(results);
      return;
    }
    
    promises.forEach((promise, index) => {
      Promise.resolve(promise).then(
        value => {
          results[index] = value;
          completedCount++;
          
          if (completedCount === promises.length) {
            resolve(results);
          }
        },
        reason => {
          reject(reason);
        }
      );
    });
  });
};

实现 Promise.race

Promise.myRace = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('参数必须是数组'));
    }
    
    promises.forEach(promise => {
      Promise.resolve(promise).then(resolve, reject);
    });
  });
};

Promise 链式调用

// 串行执行多个异步任务
function runInSequence(tasks) {
  return tasks.reduce((promise, task) => {
    return promise.then(result => {
      return task().then(Array.prototype.concat.bind(result));
    });
  }, Promise.resolve([]));
}

// 使用示例
const tasks = [
  () => Promise.resolve(1),
  () => Promise.resolve(2),
  () => Promise.resolve(3)
];

runInSequence(tasks).then(results => {
  console.log(results); // [1, 2, 3]
});

3. async/await 进阶

并发控制

// 控制并发数量
async function asyncPool(poolLimit, array, iteratorFn) {
  const results = [];
  const executing = [];
  
  for (const [index, item] of array.entries()) {
    const promise = Promise.resolve().then(() => iteratorFn(item, array));
    results.push(promise);
    
    if (poolLimit <= array.length) {
      const e = promise.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      
      if (executing.length >= poolLimit) {
        await Promise.race(executing);
      }
    }
  }
  
  return Promise.all(results);
}

// 使用示例
const timeout = i => new Promise(resolve => setTimeout(() => resolve(i), i));
asyncPool(2, [1000, 5000, 3000, 2000], timeout).then(results => {
  console.log(results);
});

错误重试

async function retry(fn, times = 3, delay = 1000) {
  for (let i = 0; i < times; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === times - 1) {
        throw error;
      }
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// 使用示例
retry(() => fetch('/api/data'), 3, 2000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('重试失败:', error));

超时控制

function timeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      setTimeout(() => reject(new Error('Timeout')), ms);
    })
  ]);
}

// 使用示例
timeout(fetch('/api/data'), 5000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

手写实现

1. 防抖(Debounce)

/**
 * 防抖:在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时
 * 应用场景:搜索框输入、窗口 resize
 */
function debounce(func, wait, immediate = false) {
  let timeout;
  
  return function(...args) {
    const context = this;
    
    const later = function() {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    
    if (callNow) func.apply(context, args);
  };
}

// 使用示例
const input = document.querySelector('input');
input.addEventListener('input', debounce(function(e) {
  console.log('搜索:', e.target.value);
}, 500));

2. 节流(Throttle)

/**
 * 节流:规定时间内只触发一次
 * 应用场景:滚动事件、按钮点击
 */
function throttle(func, wait) {
  let timeout;
  let previous = 0;
  
  return function(...args) {
    const context = this;
    const now = Date.now();
    const remaining = wait - (now - previous);
    
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func.apply(context, args);
    } else if (!timeout) {
      timeout = setTimeout(() => {
        previous = Date.now();
        timeout = null;
        func.apply(context, args);
      }, remaining);
    }
  };
}

// 使用示例
window.addEventListener('scroll', throttle(function() {
  console.log('滚动位置:', window.scrollY);
}, 1000));

3. 深拷贝

/**
 * 深拷贝:完整复制对象,包括嵌套对象
 */
function deepClone(obj, hash = new WeakMap()) {
  // null 或非对象类型直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理 Date
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 处理 RegExp
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  
  // 处理循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }
  
  // 创建新对象或数组
  const cloneObj = Array.isArray(obj) ? [] : {};
  hash.set(obj, cloneObj);
  
  // 递归拷贝
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  
  return cloneObj;
}

// 使用示例
const original = {
  name: 'Tom',
  hobbies: ['reading', 'coding'],
  address: {
    city: 'Beijing',
    district: 'Chaoyang'
  }
};

const copy = deepClone(original);
copy.address.city = 'Shanghai';
console.log(original.address.city); // 'Beijing'

4. 实现 call、apply、bind

call 实现

Function.prototype.myCall = function(context, ...args) {
  // context 为 null 或 undefined 时,指向 window
  context = context || window;
  
  // 创建唯一的 key 避免覆盖原有属性
  const fn = Symbol('fn');
  context[fn] = this;
  
  // 执行函数
  const result = context[fn](...args);
  
  // 删除添加的属性
  delete context[fn];
  
  return result;
};

// 测试
function greet(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}

const user = { name: 'Tom' };
greet.myCall(user, 'Hello', '!'); // Hello, I'm Tom!

apply 实现

Function.prototype.myApply = function(context, args = []) {
  context = context || window;
  
  const fn = Symbol('fn');
  context[fn] = this;
  
  const result = context[fn](...args);
  
  delete context[fn];
  
  return result;
};

// 测试
greet.myApply(user, ['Hi', '.']); // Hi, I'm Tom.

bind 实现

Function.prototype.myBind = function(context, ...args1) {
  const fn = this;
  
  return function(...args2) {
    // 如果是通过 new 调用,this 指向实例
    if (this instanceof fn) {
      return new fn(...args1, ...args2);
    }
    
    return fn.apply(context, [...args1, ...args2]);
  };
};

// 测试
const boundGreet = greet.myBind(user, 'Hey');
boundGreet('~'); // Hey, I'm Tom~

5. 实现 new 操作符

function myNew(Constructor, ...args) {
  // 创建一个新对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);
  
  // 执行构造函数,绑定 this
  const result = Constructor.apply(obj, args);
  
  // 如果构造函数返回对象,则返回该对象,否则返回创建的对象
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person = myNew(Person, 'Tom', 18);
person.sayHi(); // Hi, I'm Tom

6. 实现 instanceof

function myInstanceof(obj, constructor) {
  // 获取对象的原型
  let proto = Object.getPrototypeOf(obj);
  
  // 获取构造函数的 prototype
  const prototype = constructor.prototype;
  
  // 沿着原型链查找
  while (proto) {
    if (proto === prototype) {
      return true;
    }
    proto = Object.getPrototypeOf(proto);
  }
  
  return false;
}

// 测试
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof([], Object)); // true
console.log(myInstanceof({}, Array)); // false

7. 柯里化(Curry)

/**
 * 柯里化:将多参数函数转换为单参数函数序列
 */
function curry(fn) {
  return function curried(...args) {
    // 参数够了就执行
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    
    // 参数不够继续返回函数
    return function(...args2) {
      return curried.apply(this, [...args, ...args2]);
    };
  };
}

// 使用示例
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

8. 发布订阅模式

class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  // 订阅事件
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  
  // 发布事件
  emit(event, ...args) {
    if (this.events[event]) {
      this.events[event].forEach(callback => {
        callback(...args);
      });
    }
  }
  
  // 取消订阅
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
  
  // 只订阅一次
  once(event, callback) {
    const fn = (...args) => {
      callback(...args);
      this.off(event, fn);
    };
    this.on(event, fn);
  }
}

// 使用示例
const emitter = new EventEmitter();

function handleLogin(user) {
  console.log('用户登录:', user);
}

emitter.on('login', handleLogin);
emitter.emit('login', { name: 'Tom' }); // 用户登录: { name: 'Tom' }

emitter.off('login', handleLogin);
emitter.emit('login', { name: 'Jerry' }); // 无输出

9. 数组扁平化

// 方法1:递归
function flatten1(arr) {
  const result = [];
  
  arr.forEach(item => {
    if (Array.isArray(item)) {
      result.push(...flatten1(item));
    } else {
      result.push(item);
    }
  });
  
  return result;
}

// 方法2:reduce
function flatten2(arr) {
  return arr.reduce((acc, item) => {
    return acc.concat(Array.isArray(item) ? flatten2(item) : item);
  }, []);
}

// 方法3:使用栈
function flatten3(arr) {
  const stack = [...arr];
  const result = [];
  
  while (stack.length) {
    const item = stack.pop();
    if (Array.isArray(item)) {
      stack.push(...item);
    } else {
      result.unshift(item);
    }
  }
  
  return result;
}

// 方法4:指定深度
function flatten4(arr, depth = 1) {
  if (depth === 0) return arr;
  
  return arr.reduce((acc, item) => {
    return acc.concat(
      Array.isArray(item) ? flatten4(item, depth - 1) : item
    );
  }, []);
}

// 测试
const nested = [1, [2, [3, [4, 5]]]];
console.log(flatten1(nested)); // [1, 2, 3, 4, 5]
console.log(flatten4(nested, 2)); // [1, 2, 3, [4, 5]]

// 原生方法
console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5]

10. Promise 实现

class MyPromise {
  constructor(executor) {
    this.status = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };
    
    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err };
    
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolve(x);
          } catch (error) {
            reject(error);
          }
        });
      }
      
      if (this.status === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolve(x);
          } catch (error) {
            reject(error);
          }
        });
      }
      
      if (this.status === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              resolve(x);
            } catch (error) {
              reject(error);
            }
          });
        });
        
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              resolve(x);
            } catch (error) {
              reject(error);
            }
          });
        });
      }
    });
    
    return promise2;
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
  
  finally(callback) {
    return this.then(
      value => MyPromise.resolve(callback()).then(() => value),
      reason => MyPromise.resolve(callback()).then(() => { throw reason })
    );
  }
  
  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }
  
  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

// 测试
const promise = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('Success'), 1000);
});

promise.then(value => {
  console.log(value); // Success
});

进阶技巧

1. 数组高级操作

const users = [
  { id: 1, name: 'Tom', age: 18, city: 'Beijing' },
  { id: 2, name: 'Jerry', age: 20, city: 'Shanghai' },
  { id: 3, name: 'Mike', age: 18, city: 'Beijing' },
  { id: 4, name: 'Lucy', age: 22, city: 'Guangzhou' }
];

// 1. 分组
const groupBy = (arr, key) => {
  return arr.reduce((acc, item) => {
    (acc[item[key]] = acc[item[key]] || []).push(item);
    return acc;
  }, {});
};

const groupedByAge = groupBy(users, 'age');
// { 18: [...], 20: [...], 22: [...] }

// 2. 去重
const unique = arr => [...new Set(arr)];

// 对象数组去重
const uniqueBy = (arr, key) => {
  const seen = new Set();
  return arr.filter(item => {
    const k = item[key];
    return seen.has(k) ? false : seen.add(k);
  });
};

// 3. 排序
users.sort((a, b) => a.age - b.age); // 按年龄升序
users.sort((a, b) => b.age - a.age); // 按年龄降序

// 多条件排序
users.sort((a, b) => {
  return a.age - b.age || a.name.localeCompare(b.name);
});

// 4. 查找
const user = users.find(u => u.id === 2);
const userIndex = users.findIndex(u => u.id === 2);

// 5. 筛选
const adults = users.filter(u => u.age >= 18);

// 6. 映射
const names = users.map(u => u.name);

// 7. 累加
const totalAge = users.reduce((sum, u) => sum + u.age, 0);

// 8. 链式操作
const result = users
  .filter(u => u.age >= 18)
  .map(u => ({ ...u, isAdult: true }))
  .sort((a, b) => a.age - b.age);

2. 对象高级操作

// 1. 合并对象
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 3, c: 4 }

// 深度合并
function deepMerge(target, source) {
  for (let key in source) {
    if (source[key] instanceof Object && key in target) {
      Object.assign(source[key], deepMerge(target[key], source[key]));
    }
  }
  return Object.assign(target || {}, source);
}

// 2. 对象转数组
const obj = { a: 1, b: 2, c: 3 };
const entries = Object.entries(obj); // [['a', 1], ['b', 2], ['c', 3]]
const keys = Object.keys(obj); // ['a', 'b', 'c']
const values = Object.values(obj); // [1, 2, 3]

// 3. 数组转对象
const arr = [['a', 1], ['b', 2]];
const objFromArr = Object.fromEntries(arr); // { a: 1, b: 2 }

// 4. 对象属性筛选
function pick(obj, keys) {
  return keys.reduce((acc, key) => {
    if (obj.hasOwnProperty(key)) {
      acc[key] = obj[key];
    }
    return acc;
  }, {});
}

const user = { name: 'Tom', age: 18, email: 'tom@example.com' };
const picked = pick(user, ['name', 'age']); // { name: 'Tom', age: 18 }

// 5. 对象属性排除
function omit(obj, keys) {
  return Object.keys(obj)
    .filter(key => !keys.includes(key))
    .reduce((acc, key) => {
      acc[key] = obj[key];
      return acc;
    }, {});
}

const omitted = omit(user, ['email']); // { name: 'Tom', age: 18 }

// 6. 冻结对象
const frozen = Object.freeze({ a: 1 });
frozen.a = 2; // 无效
frozen.b = 3; // 无效

// 7. 密封对象
const sealed = Object.seal({ a: 1 });
sealed.a = 2; // 有效
sealed.b = 3; // 无效

3. 字符串技巧

// 1. 模板字符串高级用法
const tag = (strings, ...values) => {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<strong>${values[i]}</strong>` : '');
  }, '');
};

const name = 'Tom';
const age = 18;
const result = tag`Name: ${name}, Age: ${age}`;

// 2. 字符串填充
'5'.padStart(3, '0'); // '005'
'5'.padEnd(3, '0'); // '500'

// 3. 重复
'ha'.repeat(3); // 'hahaha'

// 4. 首字母大写
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);

// 5. 驼峰转换
const camelCase = str => {
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
};
camelCase('hello-world'); // 'helloWorld'

// 6. 蛇形转换
const snakeCase = str => {
  return str.replace(/([A-Z])/g, '_$1').toLowerCase();
};
snakeCase('helloWorld'); // 'hello_world'

// 7. 截断字符串
const truncate = (str, length) => {
  return str.length > length ? str.slice(0, length) + '...' : str;
};

4. 正则表达式

// 1. 邮箱验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
emailRegex.test('user@example.com'); // true

// 2. 手机号验证(中国)
const phoneRegex = /^1[3-9]\d{9}$/;
phoneRegex.test('13800138000'); // true

// 3. URL 验证
const urlRegex = /^https?:\/\/.+/;
urlRegex.test('https://example.com'); // true

// 4. 密码强度(至少8位,包含大小写字母和数字)
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;

// 5. 提取所有数字
const extractNumbers = str => str.match(/\d+/g);
extractNumbers('abc123def456'); // ['123', '456']

// 6. 替换
const text = 'Hello World';
text.replace(/World/, 'JavaScript'); // 'Hello JavaScript'
text.replace(/o/g, '0'); // 'Hell0 W0rld'

// 7. 命名捕获组
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = '2024-01-15'.match(dateRegex);
console.log(match.groups); // { year: '2024', month: '01', day: '15' }

5. 性能优化技巧

// 1. 使用对象查找代替 switch
// 不推荐
function getDiscount(type) {
  switch(type) {
    case 'VIP':
      return 0.8;
    case 'SVIP':
      return 0.5;
    default:
      return 1;
  }
}

// 推荐
const discountMap = {
  VIP: 0.8,
  SVIP: 0.5,
  default: 1
};

const getDiscount = type => discountMap[type] || discountMap.default;

// 2. 避免不必要的计算
// 不推荐
for (let i = 0; i < arr.length; i++) {
  // arr.length 每次都计算
}

// 推荐
const len = arr.length;
for (let i = 0; i < len; i++) {
  // 只计算一次
}

// 3. 使用 requestAnimationFrame
function animate() {
  // 动画逻辑
  requestAnimationFrame(animate);
}

// 4. 使用 Web Workers 处理复杂计算
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });
worker.onmessage = function(e) {
  console.log('结果:', e.data);
};

// 5. 使用 DocumentFragment 批量操作 DOM
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
document.querySelector('ul').appendChild(fragment);

学习资源推荐

在线文档

书籍

  • 《JavaScript 高级程序设计(第4版)》
  • 《你不知道的 JavaScript》(上、中、下)
  • 《JavaScript 设计模式与开发实践》
  • 《深入理解 ES6》

练习平台

视频课程

  • 慕课网 JavaScript 进阶课程
  • B站前端技术分享
  • 极客时间《重学前端》

学习建议

  1. 循序渐进:从基础开始,不要跳跃式学习
  2. 多写代码:理论结合实践,每个知识点都要写 Demo
  3. 阅读源码:学习优秀开源项目的代码风格和设计思想
  4. 总结归纳:定期整理笔记,构建知识体系
  5. 持续学习:JavaScript 在不断发展,保持学习热情

练习题目

基础题

  1. 实现一个函数,判断两个对象是否相等
  2. 实现数组的 map、filter、reduce 方法
  3. 实现一个简单的模板引擎
  4. 实现 Promise.all 和 Promise.race
  5. 实现一个 LRU 缓存

进阶题

  1. 实现一个完整的 Promise(符合 Promises/A+ 规范)
  2. 实现一个虚拟 DOM 和 diff 算法
  3. 实现一个简单的响应式系统(类似 Vue)
  4. 实现一个发布订阅模式的事件总线
  5. 实现一个带并发控制的请求调度器

实战项目

  1. 实现一个 TodoList 应用(包含增删改查)
  2. 实现一个图片懒加载库
  3. 实现一个无限滚动列表
  4. 实现一个拖拽排序功能
  5. 实现一个简单的打包工具

持续更新中... 🚀

祝学习顺利!如有问题欢迎交流探讨。

react-konva实战指南:Canvas高性能+易维护的组件化图形开发实现教程

作者 鹏多多
2025年10月28日 08:38

图形绘制与交互是许多复杂应用(如数据可视化、设计工具、画板,游戏等)的核心需求。而react-konva作为Konva.js的React封装库,将React的声明式编程理念与Konva.js强大的图形处理能力完美结合,让开发者能够以更直观、高效的方式构建交互式图形应用。我将从react-konva的核心特性出发,详细讲解其使用方法、性能优化技巧及实际应用场景,帮助读者快速上手并落地项目。

1. 介绍

react-konva并非一个独立的图形库,而是Konva.js与React的桥梁。Konva.js是一款基于Canvas的2D图形库,支持分层渲染、事件检测、动画过渡等核心能力,而react-konva则通过React组件的形式封装了Konva.js的API,让开发者可以用React的思维(如组件化、状态管理、Props传递)来操作图形元素,无需直接编写原生Canvas代码。

核心优势

  1. 声明式API:通过React组件(如<Stage><Layer><Rect><Circle>)描述图形结构,替代Konva.js的命令式调用,代码更易读、维护;
  2. React生态兼容:无缝集成React的状态管理(如useStateuseReducer)、生命周期(如useEffect),支持Redux、MobX等状态库;
  3. 高性能渲染:基于Konva.js的分层渲染机制,仅更新变化的图形元素,避免全量重绘;
  4. 完善的事件系统:支持鼠标(onClickonDrag)、触摸(onTouchStart)、键盘(onKeyPress)等事件,且事件检测精度不受Canvas像素限制;
  5. 丰富的图形与动画:内置矩形、圆形、文本、路径等基础图形,支持缩放、旋转、平移等变换,以及帧动画、过渡动画。

2.快速上手

从安装到第一个图形,步骤如下:

2.1. 安装依赖

react-konva依赖于konva核心库,需同时安装两个包:

# npm
npm install react-konva konva --save

# yarn
yarn add react-konva konva

2.2. 基础示例

react-konva的核心组件结构为:Stage(画布容器)→ Layer(渲染层)→ 图形元素(RectCircle等)。其中,Stage是顶层容器,一个应用可包含多个StageLayer是渲染层,每个Layer对应一个Canvas元素,建议将“频繁更新的元素”与“静态元素”分属不同Layer以优化性能。

以下是一个完整的示例,实现“点击按钮添加一个可拖拽的矩形”的功能:

import React, { useState } from 'react';
import { Stage, Layer, Rect, Text } from 'react-konva';

const App = () => {
  // 状态:存储所有矩形的信息(位置、大小、颜色)
  const [rectangles, setRectangles] = useState([
    { x: 50, y: 50, width: 100, height: 60, color: '#ff6347' }
  ]);
  // 状态:记录当前是否在拖拽矩形
  const [isDragging, setIsDragging] = useState(false);

  // 新增矩形:在随机位置添加一个蓝色矩形
  const addRectangle = () => {
    setRectangles([
      ...rectangles,
      {
        x: Math.random() * 400, // 随机X坐标(Stage宽度为500)
        y: Math.random() * 300, // 随机Y坐标(Stage高度为400)
        width: 80 + Math.random() * 60, // 随机宽度
        height: 50 + Math.random() * 40, // 随机高度
        color: '#4169e1'
      }
    ]);
  };

  // 拖拽事件:开始拖拽时更新状态
  const handleDragStart = () => {
    setIsDragging(true);
  };

  // 拖拽事件:结束拖拽时更新状态
  const handleDragEnd = (e) => {
    setIsDragging(false);
    // 更新被拖拽矩形的最终位置
    const updatedRects = rectangles.map((rect, index) => {
      if (index === e.target.index) { // e.target.index 是当前图形在父组件中的索引
        return { ...rect, x: e.target.x(), y: e.target.y() };
      }
      return rect;
    });
    setRectangles(updatedRects);
  };

  return (
    <div style={{ margin: '20px' }}>
      {/* 按钮:触发新增矩形 */}
      <button 
        onClick={addRectangle}
        style={{ marginBottom: '10px', padding: '8px 16px' }}
      >
        添加矩形
      </button>
      {/* 拖拽状态提示 */}
      {isDragging && <Text text="拖拽中..." x={200} y={10} fontSize={16} />}

      {/* Stage:画布容器,width/height 定义画布大小 */}
      <Stage width={500} height={400} style={{ border: '1px solid #eee' }}>
        {/* Layer:渲染层,所有图形元素必须放在Layer内 */}
        <Layer>
          {/* 遍历渲染所有矩形 */}
          {rectangles.map((rect, index) => (
            <Rect
              key={index} // 建议使用唯一ID此处为简化用index
              x={rect.x}
              y={rect.y}
              width={rect.width}
              height={rect.height}
              fill={rect.color}
              stroke="#333" // 边框颜色
              strokeWidth={2} // 边框宽度
              draggable // 允许拖拽
              onDragStart={handleDragStart}
              onDragEnd={handleDragEnd}
              // 鼠标悬停时显示指针
              onMouseOver={(e) => {
                e.target.setAttrs({ stroke: '#ff0' }); // 悬停时边框变黄
              }}
              onMouseOut={(e) => {
                e.target.setAttrs({ stroke: '#333' }); // 离开时恢复边框颜色
              }}
            />
          ))}
        </Layer>
      </Stage>
    </div>
  );
};

export default App;

2.3. 核心组件解析

组件 作用说明
<Stage> 顶层画布容器,对应Konva.js的Konva.Stage,需指定widthheight属性
<Layer> 渲染层,对应Konva.Layer,每个Layer包含一个Canvas元素,支持分层渲染
<Rect> 矩形图形,支持x(横坐标)、y(纵坐标)、widthheightfill(填充色)等属性
<Circle> 圆形图形,核心属性为xy(圆心坐标)、radius(半径)、fill
<Text> 文本元素,支持text(内容)、fontSizefontFamilyfill等属性
<Image> 图片元素,需通过image属性传入Image对象(需先加载完成)

3. 进阶功能

下面是一些进阶的功能,包括动画、变换与事件:

3.1. 实现图形动画

react-konva支持两种动画方式:基于状态的动画(通过React状态更新触发重绘)和Konva原生动画(通过Konva.Animation API)。

方式1:基于状态的简单动画(基础)

通过useState+useEffect实现矩形的“呼吸效果”(缩放动画),适合基础过渡:

import React, { useState, useEffect } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const AnimatedRect = () => {
  const [scale, setScale] = useState(1); // 缩放比例,初始为1
  const [growing, setGrowing] = useState(true); // 是否正在放大

  // 每30ms更新一次缩放比例,实现动画效果
  useEffect(() => {
    const timer = setInterval(() => {
      setScale(prev => {
        // 放大到1.2后开始缩小,缩小到0.8后开始放大
        if (prev >= 1.2) setGrowing(false);
        if (prev <= 0.8) setGrowing(true);
        return growing ? prev + 0.01 : prev - 0.01;
      });
    }, 30);

    // 组件卸载时清除定时器,避免内存泄漏
    return () => clearInterval(timer);
  }, [growing]);

  return (
    <Stage width={300} height={200}>
      <Layer>
        <Rect
          x={100}
          y={50}
          width={100}
          height={60}
          fill="#20b2aa"
          scaleX={scale} // X轴缩放比例
          scaleY={scale} // Y轴缩放比例
          offsetX={50} // 缩放中心点X矩形宽度的一半offsetY={30} // 缩放中心点Y矩形高度的一半)
        />
      </Layer>
    </Stage>
  );
};

export default AnimatedRect;

方式2:Konva原生动画(复杂)

对于需要精细控制的复杂帧动画(如多属性同步变化、物理运动),建议使用Konva的Animate组件或Konva.Animation API:

import React from 'react';
import { Stage, Layer, Rect, Animate } from 'react-konva';

const ComplexAnimation = () => {
  // 定义动画关键帧:x从50→400,y从50→250,同时旋转360度
  const animationConfig = {
    x: [50, 400],
    y: [50, 250],
    rotation: [0, 360], // 旋转角度(单位:度)
    duration: 2000, // 动画时长(ms)
    easing: Konva.Easings.EaseInOut // 缓动函数
  };

  return (
    <Stage width={500} height={300}>
      <Layer>
        <Rect
          width={80}
          height={50}
          fill="#ff4500"
          offsetX={40} // 旋转中心点矩形中心offsetY={25}
        >
          {/* Animate组件:绑定动画配置 */}
          <Animate
            config={animationConfig}
            repeat={Infinity} // 无限循环
            yoyo={true} // 动画结束后反向播放类似往返效果)
          />
        </Rect>
      </Layer>
    </Stage>
  );
};

export default ComplexAnimation;

3.2. 图形变换(缩放、旋转、平移)

react-konva的图形元素支持通过属性直接控制变换,核心属性包括:

  • x/y:元素的左上角坐标(默认基准点为左上角);
  • scaleX/scaleY:X/Y轴缩放比例(1为原始大小);
  • rotation:旋转角度(单位:度,顺时针为正);
  • offsetX/offsetY:变换基准点(如设置为元素中心,旋转/缩放将围绕中心进行)。

示例:通过滑块控制矩形的旋转角度:

import React, { useState } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const RotatableRect = () => {
  const [rotation, setRotation] = useState(0); // 初始旋转角度为0

  return (
    <div style={{ margin: '20px' }}>
      {/* 滑块:控制旋转角度(0~360度) */}
      <label>旋转角度:{rotation}°</label>
      <input
        type="range"
        min="0"
        max="360"
        value={rotation}
        onChange={(e) => setRotation(Number(e.target.value))}
        style={{ width: '300px', marginLeft: '10px' }}
      />

      <Stage width={300} height={200}>
        <Layer>
          <Rect
            x={150}
            y={100}
            width={100}
            height={60}
            fill="#9370db"
            rotation={rotation}
            offsetX={50} // 旋转基准点为矩形中心
            offsetY={30}
            stroke="#333"
            strokeWidth={2}
          />
        </Layer>
      </Stage>
    </div>
  );
};

export default RotatableRect;

3.3. 事件类型和处理

react-konva的事件系统基于Konva.js,能精准捕获与交互,支持像素级别的事件检测(即使两个图形重叠,也能精准识别鼠标 hover 的是哪个图形),且事件名称与React保持一致(如onClickonMouseMove)。

常见事件类型:

  • 鼠标事件:onClickonDoubleClickonMouseDownonMouseUponMouseOveronMouseOut
  • 拖拽事件:onDragStartonDragonDragEnd
  • 触摸事件:onTouchStartonTouchMoveonTouchEnd
  • 键盘事件:需先通过stage.on('keydown', handler)绑定,或在元素上使用onKeyPress(需元素处于焦点状态)。

示例:实现“点击矩形改变颜色”和“键盘删除选中矩形”:

import React, { useState, useRef } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const InteractiveRects = () => {
  const [rectangles, setRectangles] = useState([
    { id: 1, x: 50, y: 50, width: 80, height: 50, color: '#ff6b6b' },
    { id: 2, x: 200, y: 100, width: 80, height: 50, color: '#4ecdc4' }
  ]);
  const [selectedId, setSelectedId] = useState(null);
  const stageRef = useRef(null); // 用于获取Stage实例

  // 点击矩形:选中并改变颜色
  const handleRectClick = (e, id) => {
    setSelectedId(id);
    // 随机改变颜色
    const randomColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`;
    const updatedRects = rectangles.map(rect => 
      rect.id === id ? { ...rect, color: randomColor } : rect
    );
    setRectangles(updatedRects);
  };

  // 键盘事件:按Delete删除选中的矩形
  React.useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === 'Delete' && selectedId) {
        setRectangles(rectangles.filter(rect => rect.id !== selectedId));
        setSelectedId(null);
      }
    };

    // 绑定键盘事件
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [selectedId, rectangles]);

  return (
    <div>
      <p>点击矩形选中并改变颜色,按Delete删除选中矩形</p>
      <Stage 
        width={400} 
        height={200} 
        ref={stageRef}
        style={{ border: '1px solid #eee' }}
      >
        <Layer>
          {rectangles.map(rect => (
            <Rect
              key={rect.id}
              x={rect.x}
              y={rect.y}
              width={rect.width}
              height={rect.height}
              fill={rect.color}
              stroke={selectedId === rect.id ? '#ff0' : '#333'} // 选中时边框变黄
              strokeWidth={selectedId === rect.id ? 3 : 2} // 选中时边框变粗
              onClick={(e) => handleRectClick(e, rect.id)}
              onMouseOut={(e) => {
                e.target.setAttrs({
                  stroke: selectedId === rect.id ? '#ff0' : '#333',
                });
              }}
            />
          ))}
        </Layer>
      </Stage>
    </div>
  );
};

export default InteractiveRects;

4. 性能优化

应对大规模图形场景,当应用中需要渲染成百上千个图形元素(如数据可视化中的海量节点、设计工具中的复杂图层)时,单纯的基础用法可能会出现卡顿。react-konva虽基于 Konva.js 做了底层优化,但仍需结合 React 特性进行针对性优化,核心思路是减少不必要的重渲染降低绘制压力

4.1. 避免不必要的组件重渲染

React 组件的重渲染触发条件(如父组件重渲染、Props 变化、State 变化)会直接影响react-konva的性能,可通过以下方式优化:

方式1:使用 memo 缓存图形组件

对于纯展示型的图形组件(如静态矩形、文本),可通过 React.memo 缓存组件,避免父组件重渲染时被连带重渲染。

示例:封装一个缓存的矩形组件:

import React, { memo } from 'react';
import { Rect } from 'react-konva';

// 自定义比较函数:仅当Props中的关键属性变化时才重渲染
const RectMemoized = memo(
  ({ x, y, width, height, color, onMouseOver, onMouseOut }) => (
    <Rect
      x={x}
      y={y}
      width={width}
      height={height}
      fill={color}
      stroke="#333"
      strokeWidth={2}
      onMouseOver={onMouseOver}
      onMouseOut={onMouseOut}
    />
  ),
  (prevProps, nextProps) => {
    // 仅当关键属性(位置、大小、颜色)不变时,返回true(不重渲染)
    return (
      prevProps.x === nextProps.x &&
      prevProps.y === nextProps.y &&
      prevProps.width === nextProps.width &&
      prevProps.height === nextProps.height &&
      prevProps.color === nextProps.color
    );
  }
);

export default RectMemoized;

方式2:拆分状态与分层渲染

将“频繁变化的元素”(如拖拽中的图形、实时更新的数据标签)与“静态元素”(如背景、固定参考线)拆分到不同的 <Layer> 中。Konva.js 会仅重绘变化的 Layer,而非全量重绘整个 Stage。

示例:分层管理静态背景与动态图形:

<Stage width={800} height={600}>
  {/* 静态Layer:仅渲染一次,后续不重绘 */}
  <Layer>
    <Rect x={0} y={0} width={800} height={600} fill="#f5f5f5" /> {/* 背景 */}
    <Line points={[0, 300, 800, 300]} stroke="#ddd" strokeWidth={1} /> {/* 参考线 */}
  </Layer>

  {/* 动态Layer:仅当图形变化时重绘 */}
  <Layer>
    {dynamicRectangles.map(rect => (
      <RectMemoized key={rect.id} {...rect} />
    ))}
  </Layer>
</Stage>

方式3:使用 useCallback 缓存事件处理函数

若图形组件的事件处理函数(如 onClickonDrag)是在父组件中定义的,每次父组件重渲染时会生成新的函数实例,导致子组件 Props 变化而重渲染。可通过 useCallback 缓存函数。

示例:缓存拖拽事件处理函数:

const handleDragEnd = useCallback((e, id) => {
  setRectangles(prev => 
    prev.map(rect => 
      rect.id === id ? { ...rect, x: e.target.x(), y: e.target.y() } : rect
    )
  );
}, []); // 依赖为空,函数仅创建一次

4.2. 降低绘制压力

当图形数量超过 1000 个时,即使避免了重渲染,Canvas 的绘制操作仍可能成为瓶颈,可通过以下方式优化:

方式1:图形合并

对于大量重复且无交互的图形(如数据可视化中的网格点、背景纹理),可通过 Konva.js 的 Group 组件合并,批量绘制,减少绘制调用次数。

示例:合并多个静态小圆点:

import { Group, Circle } from 'react-konva';

const DotGroup = () => {
  // 生成1000个静态小圆点
  const dots = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    x: Math.random() * 800,
    y: Math.random() * 600,
    radius: 2,
    color: '#ccc'
  }));

  return (
    <Group> {/* 合并为一个Group,减少绘制调用 */}
      {dots.map(dot => (
        <Circle
          key={dot.id}
          x={dot.x}
          y={dot.y}
          radius={dot.radius}
          fill={dot.color}
        />
      ))}
    </Group>
  );
};

方式2:可视区域裁剪

仅渲染当前视图内的图形(Viewport Culling),隐藏视图外的图形(如滚动或缩放时)。可通过监听 Stage 的 zoomdrag 事件,计算可视区域范围,过滤掉不在范围内的图形。

示例:实现可视区域裁剪:

import React, { useState, useEffect } from 'react';
import { Stage, Layer, RectMemoized } from 'react-konva';

const ViewportCulling = () => {
  const [allRectangles, setAllRectangles] = useState([]);
  const [visibleRectangles, setVisibleRectangles] = useState([]);
  const stageRef = useRef(null);

  // 初始化10000个矩形(模拟大规模数据)
  useEffect(() => {
    const rects = Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      x: Math.random() * 2000,
      y: Math.random() * 1500,
      width: 20,
      height: 20,
      color: `#${Math.floor(Math.random() * 16777215).toString(16)}`
    }));
    setAllRectangles(rects);
  }, []);

  // 监听Stage的缩放和拖拽事件,更新可视区域内的图形
  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;

    const updateVisibleRects = () => {
      // 获取Stage的可视区域范围(考虑缩放和偏移)
      const stageRect = stage.getClientRect();
      const visibleLeft = stageRect.x;
      const visibleTop = stageRect.y;
      const visibleRight = visibleLeft + stageRect.width;
      const visibleBottom = visibleTop + stageRect.height;

      // 过滤出在可视区域内的矩形
      const visible = allRectangles.filter(rect => 
        rect.x + rect.width > visibleLeft &&
        rect.x < visibleRight &&
        rect.y + rect.height > visibleTop &&
        rect.y < visibleBottom
      );

      setVisibleRectangles(visible);
    };

    // 初始计算一次
    updateVisibleRects();
    // 监听缩放和拖拽事件
    stage.on('zoom drag end', updateVisibleRects);

    // 清理事件监听
    return () => stage.off('zoom drag end', updateVisibleRects);
  }, [allRectangles]);

  return (
    <Stage
      ref={stageRef}
      width={800}
      height={600}
      draggable // 允许拖拽Stage查看大范围图形
      scaleX={1}
      scaleY={1}
      onWheel={(e) => {
        // 实现滚轮缩放
        e.evt.preventDefault();
        const scale = stageRef.current.scaleX();
        const newScale = e.evt.deltaY > 0 ? scale - 0.1 : scale + 0.1;
        stageRef.current.scale({ x: newScale, y: newScale });
      }}
    >
      <Layer>
        {visibleRectangles.map(rect => (
          <RectMemoized key={rect.id} {...rect} />
        ))}
      </Layer>
    </Stage>
  );
};

export default ViewportCulling;

5. 实际应用场景与案例

react-konva 凭借其灵活性和高性能,广泛应用于各类图形交互场景,以下是几个典型案例:

5.1. 交互式图表数据可视化

结合 d3.js 等数据处理库,可构建支持拖拽、缩放、hover 提示的交互式图表(如散点图、热力图)。

示例:基于 react-konva + d3 的散点图:

import React, { useEffect, useState } from 'react';
import { Stage, Layer, Circle, Text } from 'react-konva';
import * as d3 from 'd3';

const ScatterPlot = ({ data }) => {
  const [scaledData, setScaledData] = useState([]);
  const [hoveredPoint, setHoveredPoint] = useState(null);

  // 使用d3.scale处理数据映射(将原始数据映射到Stage坐标)
  useEffect(() => {
    const xScale = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.x)])
      .range([50, 750]); // X轴范围:50~750(留出边距)

    const yScale = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.y)])
      .range([550, 50]); // Y轴范围:550~50(倒序,符合视觉习惯)

    const scaled = data.map(d => ({
      id: d.id,
      x: xScale(d.x),
      y: yScale(d.y),
      value: d.value,
      color: d3.interpolateViridis(d.value / 100) // 基于value生成颜色
    }));

    setScaledData(scaled);
  }, [data]);

  return (
    <Stage width={800} height={600}>
      <Layer>
        {/* 坐标轴 */}
        <Line points={[50, 50, 50, 550]} stroke="#333" strokeWidth={2} /> {/* Y轴 */}
        <Line points={[50, 550, 750, 550]} stroke="#333" strokeWidth={2} /> {/* X轴 */}
        {/* 轴标签 */}
        <Text text="X轴(数值)" x={400} y={580} fontSize={14} align="center" />
        <Text text="Y轴(数值)" x={20} y={300} fontSize={14} rotation={-90} align="center" />

        {/* 散点 */}
        {scaledData.map(point => (
          <Circle
            key={point.id}
            x={point.x}
            y={point.y}
            radius={hoveredPoint === point.id ? 8 : 5} // hover时放大
            fill={point.color}
            stroke={hoveredPoint === point.id ? "#fff" : "none"}
            strokeWidth={2}
            onMouseOver={() => setHoveredPoint(point.id)}
            onMouseOut={() => setHoveredPoint(null)}
          />
        ))}

        {/* Hover提示框 */}
        {hoveredPoint && (
          const point = scaledData.find(d => d.id === hoveredPoint);
          <Group x={point.x + 10} y={point.y - 10}>
            <Rect width={120} height={40} fill="#fff" stroke="#333" strokeWidth={1} />
            <Text text={`Value: ${point.value}`} x={10} y={10} fontSize={12} />
            <Text text={`X: ${point.x.toFixed(0)}`} x={10} y={25} fontSize={12} />
          </Group>
        )}
      </Layer>
    </Stage>
  );
};

// 使用示例:
// <ScatterPlot data={[{ id: 1, x: 20, y: 80, value: 50 }, ...]} />
export default ScatterPlot;

5.2. 简易图形编辑器

构建支持图形添加、拖拽、旋转、删除的轻量级设计工具(如流程图编辑器、海报制作工具)。

可以实现如下核心功能:

  • 图形库:提供矩形、圆形、文本等基础图形选择;
  • 画布操作:支持画布拖拽、缩放;
  • 图层管理:显示/隐藏、锁定/解锁图层;
  • 导出功能:将画布内容导出为图片(通过 stage.toDataURL())。

5.3. 简单2D游戏开发

实现支持碰撞检测、角色动画的 2D 游戏(如贪吃蛇、拼图游戏)。

示例:贪吃蛇游戏的核心逻辑(简化):

import React, { useEffect, useRef, useState } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const SnakeGame = () => {
  const [snake, setSnake] = useState([{ x: 200, y: 200 }, { x: 190, y: 200 }, { x: 180, y: 200 }]);
  const [food, setFood] = useState({ x: 300, y: 300 });
  const [direction, setDirection] = useState({ x: 10, y: 0 }); // 初始方向:右
  const gameLoopRef = useRef(null);

  // 生成随机食物位置
  const generateFood = () => {
    const x = Math.floor(Math.random() * 40) * 10; // 10的倍数,与蛇身对齐
    const y = Math.floor(Math.random() * 30) * 10;
    setFood({ x, y });
  };

  // 游戏循环:每100ms更新一次蛇的位置
  useEffect(() => {
    gameLoopRef.current = setInterval(() => {
      setSnake(prev => {
        // 计算新蛇头位置
        const head = { x: prev[0].x + direction.x, y: prev[0].y + direction.y };
        // 检查是否吃到食物
        const ateFood = head.x === food.x && head.y === food.y;
        if (ateFood) generateFood();

        // 更新蛇身:吃到食物则增加一节,否则删除尾部
        const newSnake = [head, ...prev];
        if (!ateFood) newSnake.pop();
        return newSnake;
      });
    }, 100);

    // 清理定时器
    return () => clearInterval(gameLoopRef.current);
  }, [direction, food]);

  // 监听键盘事件控制方向
  useEffect(() => {
    const handleKeyDown = (e) => {
      switch (e.key) {
        case 'ArrowUp':
          if (direction.y !== 10) setDirection({ x: 0, y: -10 }); // 避免反向
          break;
        case 'ArrowDown':
          if (direction.y !== -10) setDirection({ x: 0, y: 10 });
          break;
        case 'ArrowLeft':
          if (direction.x !== 10) setDirection({ x: -10, y: 0 });
          break;
        case 'ArrowRight':
          if (direction.x !== -10) setDirection({ x: 10, y: 0 });
          break;
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [direction]);

  return (
    <Stage width={400} height={300}>
      <Layer>
        {/* 蛇身 */}
        {snake.map((segment, index) => (
          <Rect
            key={index}
            x={segment.x}
            y={segment.y}
            width={10}
            height={10}
            fill={index === 0 ? '#2ecc71' : '#27ae60'} // 蛇头绿色更深
          />
        ))}

        {/* 食物 */}
        <Rect
          x={food.x}
          y={food.y}
          width={10}
          height={10}
          fill="#e74c3c"
        />
      </Layer>
    </Stage>
  );
};

export default SnakeGame;

6. 常见问题与解决方案

在使用 react-konva 开发过程中,开发者常会遇到一些共性问题,以下是高频问题及对应的解决方案:

图形元素不显示

可能原因与解决方法:

  • 未放在 <Layer>:所有图形元素(RectCircle 等)必须嵌套在 <Layer> 内,否则无法渲染。
    解决方案:确保组件结构为 Stage → Layer → 图形元素

  • 坐标或尺寸设置错误:若图形的 x/y 坐标超出 <Stage> 范围,或 width/height 设为 0,会导致图形不可见。
    解决方案:检查坐标是否在 Stagewidth/height 范围内,确认尺寸属性大于 0。

  • 图片加载顺序问题:使用 <Image> 组件时,若图片未加载完成就传入 image 属性,会导致图片不显示。
    解决方案:通过 useEffect 监听图片加载完成后再渲染 <Image>

    import React, { useState, useEffect } from 'react';
    import { Stage, Layer, Image } from 'react-konva';
    
    const KonvaImage = ({ src }) => {
      const [image, setImage] = useState(null);
    
      useEffect(() => {
        const img = new Image();
        img.src = src;
        img.onload = () => setImage(img); // 加载完成后更新状态
      }, [src]);
    
      return image ? <Image image={image} width={200} height={150} /> : null;
    };
    

拖拽事件不生效

可能原因与解决方法:

  • 未设置 draggable={true}:图形元素默认不支持拖拽,需显式添加 draggable 属性。
    解决方案:在图形组件上添加 draggable,如 <Rect draggable />

  • 事件被上层元素遮挡:若图形上方有其他元素(如透明的 Rect),会导致拖拽事件被拦截。
    解决方案:通过 zIndex 属性调整图形层级(zIndex 越大,层级越高),或确保上层元素不拦截事件(设置 pointerEvents="none")。

  • 拖拽范围限制问题:若通过 dragBoundFunc 限制拖拽范围时逻辑错误,可能导致拖拽失效。
    解决方案:检查 dragBoundFunc 函数返回值是否正确(需返回 { x, y } 对象):

    <Rect
      draggable
      dragBoundFunc={(pos) => {
        // 限制拖拽范围在 Stage 内
        return {
          x: Math.max(0, Math.min(pos.x, 800 - 100)), // 800 是 Stage 宽度,100 是矩形宽度
          y: Math.max(0, Math.min(pos.y, 600 - 60))   // 600 是 Stage 高度,60 是矩形高度
        };
      }}
    />
    

大规模图形场景下性能卡顿

可能原因与解决方法:

  • 未做重渲染优化:父组件频繁重渲染导致所有图形组件连带重渲染。
    解决方案:参考第四章内容,使用 React.memo 缓存图形组件、useCallback 缓存事件函数。

  • Layer 数量过多或不合理:若每个图形都单独放在一个 Layer 中,会增加 Canvas 绘制开销。
    解决方案:合理拆分 Layer,将静态元素归为一个 Layer,动态元素归为一个或少数几个 Layer

  • 未启用可视区域裁剪:渲染了视图外的大量图形,浪费性能。
    解决方案:实现第四章提到的“可视区域裁剪”逻辑,仅渲染当前视图内的图形。

与 React状态同步延迟

可能原因与解决方法:

  • 直接操作 Konva 实例属性:若通过 e.target.setAttrs({ x: 100 }) 直接修改图形属性,未同步到 React 状态,会导致状态与视图不一致。
    解决方案:修改属性后,需同步更新 React 状态(如 onDragEnd 事件中更新 x/y 状态),确保状态是唯一数据源。

  • 动画导致的状态滞后:Konva 原生动画(如 Animate 组件)修改属性时,不会自动同步到 React 状态,导致状态滞后。
    解决方案:在动画结束后,通过 onFinish 事件同步状态:

    <Animate
      config={animationConfig}
      onFinish={() => {
        // 动画结束后同步状态到 React
        setRectX(400);
        setRectY(250);
      }}
    />
    

7. 版本兼容与升级要点

react-konva 与 React、Konva.js 的版本存在一定依赖关系,升级时需注意兼容性,避免出现 API 不兼容问题。

7.1. 版本依赖关系

react-konva 版本 支持 React 版本 依赖 Konva.js 版本
2.x 16.8+(支持 Hooks) 7.x
1.x 15.x - 16.x 6.x

注意react-konva@2.x 是目前的稳定版本,推荐使用,且需确保 konva 版本与 react-konva 兼容(通常安装时会自动匹配)。

7.2. 升级核心注意事项

  • 从 1.x 升级到 2.x

    1. react-konva@2.x 移除了 ReactKonvaCore 等旧 API,统一使用顶层导出组件(如 import { Stage } from 'react-konva');
    2. 不再支持 React 16.8 以下版本,需先升级 React 到 16.8+;
    3. Konva 实例获取方式变化:从 ref 获取时,需通过 ref.current 访问(如 stageRef.current),而非旧版的 ref 直接访问。
  • Konva.js 升级注意事项

    1. Konva.js 7.x 对事件系统做了优化,部分事件名称调整(如 dragmove 改为 drag),需同步修改事件处理函数;
    2. 图形属性 offset 不再支持数组形式(如 offset={[50, 30]}),需拆分为 offsetX={50}offsetY={30}

8. 总结

react-konva 作为 React 生态中成熟的 2D 图形库,其核心价值在于:

  • 低学习成本:使用 React 组件化思维操作图形,无需从零学习 Canvas 或 Konva.js 原生 API;
  • 高性能:基于 Konva.js 的分层渲染和事件优化,支持大规模图形场景;
  • 强扩展性:可与 React 生态工具(如 Redux、React Router)无缝集成,也可结合 d3.jschart.js 等库实现复杂功能;
  • 完善的生态:官方文档详细,社区活跃,问题解决资源丰富。

在选择之前,请了解它的能力边界,适用场景与不适用场景如下:

  • 适用场景

    • 交互式数据可视化(如散点图、流程图);
    • 轻量级设计工具(如简易海报编辑器、思维导图);
    • 2D 小游戏(如贪吃蛇、拼图);
    • 自定义图形组件(如仪表盘、进度条)。
  • 不适用场景

    • 3D 图形渲染(需使用 Three.js 等 3D 库);
    • 超大规模图形渲染(如百万级节点的地图,需使用 WebGL 优化的库);
    • 复杂的矢量图形编辑(需使用 SVG 或专业矢量库)。

通过本文的讲解,相信开发者已掌握 react-konva 的核心用法、性能优化技巧和实际应用场景。在实际项目中,建议结合具体需求选择合适的功能模块,灵活运用优化策略,构建高效、流畅的图形交互应用。

参考来源:


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

Langchain4j Rag 知识库教程

2025年10月29日 15:08

 Langchain4j Rag 知识库教程

Rag 原理

RAG,Retrieval Augmented Generation,检索增强生成。通过检索外部知识库的方式增强大模型的生成能力。

基础大模型训练完成后,随着时间的推移,产生的新数据大模型是无法感知的;而且训练大模型的都是通用数据,有关专业领域的数据大模型也是不知道的。此时就需要外挂一个知识库。

其中,2.3 组织Prompt、3.1 发送Prompt、3.2 生成结果、3.3 返回响应、4 返回响应的流程由 Langchain4j 来完成。

向量知识库

向量数据库: Milvus、Chroma、Pinecone、RedisSearch(Redis)、pgvector(PostgreSQL) 向量是表示具有大小和方向的量。

向量余弦相似度,用于表示坐标系中两个点之间的距离远近

多维向量余弦相似度

向量知识库索引和检索

索引(存储)

向量存储步骤:

  1. 把最新或者专业的数据存储到文档(Document)中
  2. 文本分割器把一个大的文档切割成一个一个小的文本片段(Segments)
  3. 这些小的文本片段需要用一种专门擅长文本向量化的向量大模型转换成向量(Embeddings)
  4. 把文本片段对应的向量存储到向量数据库(Embedding Store)中

检索

检索阶段通常在线进行,当用户提交一个应该使用索引文档回答的问题时。

这个过程可能因使用的信息检索方法而异。 对于向量搜索,这通常涉及嵌入用户的查询(问题) 并在嵌入存储中执行相似度搜索。 然后将相关片段(原始文档的片段)注入到提示中并发送给 LLM。

如果余弦相似度 > 0.5的数据会被检索出来,然后再把检索结果和用户输入发送给大模型,大模型响应后返回给用户。

Rag 快速入门

存储:构建向量数据库操作对象

引入依赖

<!-- 提供向量数据库和向量模型 -->
<dependency>
    <groupld>dev.langchain4j</groupld>
    <artifactld>langchain4j-easy-rag</artifactld>
    <version>1.0.1-beta6</version>
</dependency>

 加载知识数据文档

List<Document> documents = ClassPathDocumentLoader.loadDocuments("文档路径");

构建向量数据库操作对象

InMemoryEmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>();

把文档切割、向量化并存储到向量数据库中

EmbeddingStorelngestor ingestor = EmbeddingStorelngestor.builder()
        .embeddingStore(store)
        .build();
ingestor.ingest(documents);

检索:构建向量数据库检索对象

构建向量数据库检索对象

ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(store) // 指定向量数据库
        .maxResults(3) // 最高、最多检索结果的数量
        .minScore(0.6) // 最小余弦相似度
        .build();

配置向量数据库检索对象

@AiService(
        wiringMode = AiServiceWiringMode.EXPLICIT,
        contentRetriever = "retriever"
)

Rag 核心 API

Document Loader 文档加载器

用于把磁盘或者网络中的数据加载进程序,常用的文档加载器:

  • FileSystemDocumentLoader,根据本地磁盘绝对路径加载
  • ClassPathDocumentLoader,相对于类路径加载
  • UrlDocumentLoader,根据url路径加载

Document Parser 文档解析器

用于解析使用文档加载器加载进内存的内容,把非纯文本数据转化成纯文本,常用的文档解析器:

  • TextDocumentParser,解析纯文本格式的文件
  • ApachePdfBoxDocumentParser,解析pdf格式文件
  • ApachePoiDocumentParser,解析微软的office文件,例如DoC、PPT、XLS
  • ApacheTikaDocumentParser(默认),几乎可以解析所有格式的文件

Document Splitter 文档分割器

用于把一个大的文档,切割成一个一个的小片段,常用的文档分割器:

  • DocumentByParagraphSplitter,按照段落分割文本
  • DocumentByLineSplitter,按照行分割文本
  • DocumentBySentenceSplitter,按照句子分割文本
  • DocumentByWordSplitter,按照词分割文本
  • DocumentByCharacterSplitter,按照固定数量的字符分割文本
  • DocumentByRegexSplitter,按照正则表达式分割文本
  • DocumentSplitters.recursive(...)(默认),递归分割器,优先段落分割, 再按照行分割,再按照句子分割,再按照词分割

Embedding Model 向量模型

用于把文档分割后的片段向量化或者查询时把用户输入的内容向量化

Langchain4j 内置的向量模型

内置的向量模型可能不是那么强大,需要在application.yml中配置第三方更强大的向量模型 配置完成后 Langchain4j 会根据配置信息向容器中注入一个向量模型对象,我们只需要把该向量模型对象设置给EmbeddingStoreIngestorEmbeddingStoreContentRetriever即可。

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
          .embeddingStore(store)
          .documentSplitter(ds)
          .embeddingModel(embeddingModel)
          .build();
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
                .embeddingStore(store)
                .embeddingModel(embeddingModel)
                .minScore(0.5)
                .maxResults(3)
                .build();

EmbeddingStore 向量数据库操作对象

配置 RedisSearch 向量数据库

参考链接:

RAG 工作机制详解——一个高质量知识库背后的技术全流程

黑马程序员LangChain4j从入门到实战项目全套视频课程,涵盖LangChain4j+ollama+RAG

js中如何隐藏eval关键字?

作者 w2sfot
2025年10月29日 13:20

本文介绍了JavaScript中隐藏eval关键字的多种方法,从简单的字符串拼接和Function构造函数,到使用字符编码动态生成字符串。更复杂的方案包括通过JS混淆工具(如JShaman)将代码转换为难以辨识的格式,甚至模拟虚拟机执行字节码来重构eval。这些技术通过层层包装,使原始eval调用在代码审计中难以被发现。

JavaScript中隐藏eval关键字的技巧

某些情况下,我们在进行JS编程时,可能想要用eval执行一些特殊的代码,但想不想让他人轻易看出是使用了eval。那么,就得想办法隐藏eval关键字了。

简单的隐藏方法

// 方法1:使用字符串分割
const ev = "ev";
const al = "al";
const hiddenEval = window[ev + al];

// 使用
hiddenEval('console.log("隐藏的eval执行")');

// 方法2:通过Function构造函数
const executeCode = new Function('code', 'return eval(code)');
executeCode('2 + 2'); // 返回4

复杂的隐藏方法

// 使用字符编码
const encodedEval = () => {
    const chars = [101, 118, 97, 108];
    const str = String.fromCharCode(...chars);
    return window[str];
};

const myEval = encodedEval();

更更更复杂的隐藏方法

如果还想隐藏的更深,可以再用JShaman进行JS代码混淆加密,上面代码会变成:

const encodedEval = () => {
  const _0x35ea38 = {
    "\u006d\u004f\u0067\u006c\u0048": function (_0x55d02e, _0x5cdb2b) {
      return _0x55d02e ^ _0x5cdb2b;
    },
    "\u0076\u006a\u0048\u0044\u0073": function (_0x4c98c3, _0xa2b4f0) {
      return _0x4c98c3 ^ _0xa2b4f0;
    }
  };
  const _0x2cd5ff = [0x47a4d ^ 0x47a28, _0x35ea38["\u006d\u004f\u0067\u006c\u0048"](0xd8290, 0xd82e6), _0x35ea38['vjHDs'](0xb9759, 0xb9738), _0x35ea38["\u0076\u006a\u0048\u0044\u0073"](0x7b450, 0x7b43c)];

  const _0x3d45d7 = String['fromCharCode'](..._0x2cd5ff);

  return window[_0x3d45d7];
};

const myEval = encodedEval();

或:

function _0x927a(opcode) {
  var op = {
    push: 32,
    add: 33,
    sub: 34,
    mul: 35,
    div: 36,
    pop: 37,
    xor: 38
  };
  var stack = [];
  var ip = -1;
  var sp = -1;

  while (ip < opcode.length) {
    ip++;

    switch (opcode[ip]) {
      case op.push:
        {
          ip++;
          stack.push(opcode[ip]);
          sp++;
          break;
        }

      case op.add:
        {
          stack.push(stack[sp - 1] + stack[sp]);
          sp++;
          break;
        }

      case op.sub:
        {
          stack.push(stack[sp - 1] - stack[sp]);
          sp++;
          break;
        }

      case op.mul:
        {
          stack.push(stack[sp - 1] * stack[sp]);
          sp++;
          break;
        }

      case op.div:
        {
          stack.push(stack[sp - 1] / stack[sp]);
          sp++;
          break;
        }

      case op.xor:
        {
          stack.push(stack[sp - 1] ^ stack[sp]);
          sp++;
          break;
        }

      case op.pop:
        {
          return stack[sp];
        }
    }
  }
}

const encodedEval = () => {
  const chars = [_0x927a([32, 865932, 32, 866025, 38, 37]), _0x927a([32, 625917, 32, 625803, 38, 37]), _0x927a([32, 750963, 32, 750866, 38, 37]), _0x927a([32, 753540, 32, 753640, 38, 37])];
  const str = String['\x66\x72\x6f\x6d\x43\x68\x61\x72\x43\x6f\x64\x65'](...chars);
  return window[str];
};

const myEval = encodedEval();

怎么样,还能找到eval关键字吗?

【React】19深度解析:掌握新一代React特性

作者 珑墨
2025年10月29日 11:54

前言

一直在关注React的每一次更新。React 19已正式发布了,改动还有点大。这次更新不仅仅是简单的功能增强,而是对整个React生态系统的重新思考。 现在咱们就深入解析React 19的核心特性,并打出一些实用的代码例子。

一、Actions:重新定义异步操作

什么是Actions?

在19中,Actions是一个革命性的概念。它允许在组件中直接处理异步操作,而不需要复杂的状态管理。这让我想起了早期使用Redux时的痛苦经历——为了一个简单的异步请求,需要写大量的样板代码。。。

实际应用场景

假设我们正在构建一个博客系统,用户可以在文章下方发表评论。

传统方式(React 18及之前):

import { useState } from 'react';

function CommentForm({ postId }) {
  const [comment, setComment] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);
    
    try {
      const response = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content: comment })
      });
      
      if (!response.ok) throw new Error('提交失败');
      
      setComment('');
      // 刷新评论列表...
    } catch (err) {
      setError(err.message);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <textarea 
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        placeholder="写下你的评论..."
      />
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '发表评论'}
      </button>
    </form>
  );
}

而React 19 Actions方式:

import { useActionState } from 'react';

async function submitComment(prevState, formData) {
  const comment = formData.get('comment');
  
  try {
    const response = await fetch(`/api/posts/${formData.get('postId')}/comments`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: comment })
    });
    
    if (!response.ok) throw new Error('提交失败');
    
    return { success: true, message: '评论发表成功!' };
  } catch (error) {
    return { success: false, message: error.message };
  }
}

function CommentForm({ postId }) {
  const [state, formAction, isPending] = useActionState(submitComment, null);

  return (
    <form action={formAction}>
      <input type="hidden" name="postId" value={postId} />
      <textarea 
        name="comment"
        placeholder="写下你的评论..."
        required
      />
      {state?.message && (
        <div className={state.success ? 'success' : 'error'}>
          {state.message}
        </div>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? '提交中...' : '发表评论'}
      </button>
    </form>
  );
}

代码对比分析

通过对比可以看出,React 19的Actions方式有以下优势:

  1. 代码更简洁:不需要手动管理loading状态和错误状态
  2. 逻辑更清晰:异步逻辑被封装在Action函数中
  3. 更好的用户体验:自动处理pending状态,用户界面更加流畅

二、useOptimistic:用户体验能乐观点吧

理解乐观更新

乐观更新是一种用户体验优化技术,即在服务器确认操作之前,先假设操作会成功,并立即更新用户界面。如果操作失败,再回滚到之前的状态。

比如点赞功能

让我分享一个我在社交媒体项目中实现的点赞功能:

import { useOptimistic } from 'react';

function LikeButton({ postId, initialLikes, isLiked }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, newLike) => state + newLike
  );

  const handleLike = async () => {
    // 乐观更新:立即增加点赞数
    addOptimisticLike(1);
    
    try {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: isLiked ? 'unlike' : 'like' })
      });
      
      if (!response.ok) throw new Error('操作失败');
      
      // 服务器响应成功,保持乐观更新的结果
    } catch (error) {
      // 操作失败,useOptimistic会自动回滚
      console.error('点赞操作失败:', error);
    }
  };

  return (
    <button 
      onClick={handleLike}
      className={`like-btn ${isLiked ? 'liked' : ''}`}
    >
      ❤️ {optimisticLikes}
    </button>
  );
}

useOptimistic的核心?

useOptimistic的核心思想是"先假设成功,失败再回滚"。这种模式特别适合以下场景:

  1. 社交互动:点赞、关注、收藏等
  2. 购物车操作:添加商品、修改数量
  3. 表单提交:评论、消息发送

三、Server Components:服务端渲染不卡壳

为什么需要Server Components?

在我开发电商网站时,经常遇到这样的问题:商品列表页面需要从数据库获取大量数据,如果全部在客户端渲染,会导致首屏加载缓慢。Server Components完美解决了这个问题。

看例子

// app/products/page.js - Server Component
import { Suspense } from 'react';
import ProductCard from './ProductCard';
import LoadingSkeleton from './LoadingSkeleton';

async function getProducts(category) {
  // 这里直接访问数据库,无需API调用
  const products = await db.products.findMany({
    where: { category },
    include: { reviews: true, images: true }
  });
  return products;
}

export default async function ProductsPage({ searchParams }) {
  const category = searchParams.category || 'all';
  const products = await getProducts(category);

  return (
    <div className="products-page">
      <h1>产品列表</h1>
      <Suspense fallback={<LoadingSkeleton />}>
        <div className="products-grid">
          {products.map(product => (
            <ProductCard key={product.id} product={product} />
          ))}
        </div>
      </Suspense>
    </div>
  );
}
// components/ProductCard.js - Client Component
'use client';

import { useState } from 'react';
import { useOptimistic } from 'react';

function ProductCard({ product }) {
  const [isInCart, setIsInCart] = useState(false);
  const [optimisticInCart, addOptimisticToCart] = useOptimistic(
    isInCart,
    (state, newState) => newState
  );

  const handleAddToCart = async () => {
    addOptimisticToCart(true);
    
    try {
      await fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify({ productId: product.id })
      });
      setIsInCart(true);
    } catch (error) {
      addOptimisticToCart(false);
    }
  };

  return (
    <div className="product-card">
      <img src={product.images[0].url} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="price">¥{product.price}</p>
      <p className="rating">
        评分: {product.reviews.reduce((sum, r) => sum + r.rating, 0) / product.reviews.length}
      </p>
      <button 
        onClick={handleAddToCart}
        disabled={optimisticInCart}
        className={optimisticInCart ? 'added' : ''}
      >
        {optimisticInCart ? '已加入购物车' : '加入购物车'}
      </button>
    </div>
  );
}

Server Components的优势

  1. 性能提升:数据在服务端获取,减少客户端请求
  2. SEO友好:内容在服务端渲染,搜索引擎更容易抓取
  3. 安全性:敏感操作在服务端执行,避免暴露API密钥

四、Web Components集成:拥抱标准?

为什么选择Web Components?

在开发企业级应用时,我们经常需要集成第三方组件库。React 19对Web Components的增强支持让我们可以无缝使用这些组件。

集成

// 集成Chart.js Web Component
function AnalyticsDashboard() {
  const [chartData, setChartData] = useState(null);

  useEffect(() => {
    // 获取图表数据
    fetch('/api/analytics')
      .then(res => res.json())
      .then(data => setChartData(data));
  }, []);

  return (
    <div className="dashboard">
      <h2>数据分析</h2>
      {chartData && (
        <chart-component
          type="line"
          data={JSON.stringify(chartData)}
          options={JSON.stringify({
            responsive: true,
            plugins: {
              legend: { position: 'top' }
            }
          })}
        />
      )}
    </div>
  );
}

五、新的Hooks:更强大的状态管理

useFormStatus:表单状态管理的新选择

import { useFormStatus } from 'react';

function SubmitButton() {
  const { pending, data, method, action } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function ContactForm() {
  return (
    <form action="/api/contact">
      <input type="text" name="name" placeholder="姓名" required />
      <input type="email" name="email" placeholder="邮箱" required />
      <textarea name="message" placeholder="留言内容" required />
      <SubmitButton />
    </form>
  );
}

useActionState:Actions的状态管理

import { useActionState } from 'react';

async function updateProfile(prevState, formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  // 验证数据
  if (!name || !email) {
    return { error: '请填写所有必填字段' };
  }
  
  try {
    const response = await fetch('/api/profile', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, email })
    });
    
    if (!response.ok) throw new Error('更新失败');
    
    return { success: true, message: '资料更新成功!' };
  } catch (error) {
    return { error: error.message };
  }
}

function ProfileForm() {
  const [state, formAction, isPending] = useActionState(updateProfile, null);

  return (
    <form action={formAction}>
      <input type="text" name="name" placeholder="姓名" required />
      <input type="email" name="email" placeholder="邮箱" required />
      
      {state?.error && (
        <div className="error">{state.error}</div>
      )}
      
      {state?.success && (
        <div className="success">{state.message}</div>
      )}
      
      <button type="submit" disabled={isPending}>
        {isPending ? '更新中...' : '更新资料'}
      </button>
    </form>
  );
}

六、性能优化:React 19的性能提升

自动批处理优化

19进一步优化了批处理机制,现在即使是异步操作也能被自动批处理:

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  const handleClick = () => {
    // 这些状态更新会被自动批处理
    setCount(c => c + 1);
    setName('新名称');
    
    // 即使是异步操作也会被批处理
    setTimeout(() => {
      setCount(c => c + 1);
      setName('异步更新');
    }, 100);
  };

  return (
    <div>
      <p>计数: {count}</p>
      <p>名称: {name}</p>
      <button onClick={handleClick}>更新状态</button>
    </div>
  );
}

并发特性增强

并发特性也得到了进一步增强,特别是在处理大量数据时:

import { Suspense, useDeferredValue } from 'react';

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  
  return (
    <Suspense fallback={<div>搜索中...</div>}>
      <ResultsList query={deferredQuery} />
    </Suspense>
  );
}

function ResultsList({ query }) {
  // 模拟大量数据的渲染
  const results = useMemo(() => {
    return generateLargeResults(query);
  }, [query]);

  return (
    <div>
      {results.map(result => (
        <ResultItem key={result.id} result={result} />
      ))}
    </div>
  );
}

七、迁移指南:从React 18到React 19(还是得谨慎,虽然我不敢迁🤣)

逐步迁移策略

  1. 更新依赖
npm install react@19 react-dom@19
  1. 处理破坏性变更
// React 18
import { createRoot } from 'react-dom/client';

// React 19 - 更简洁的API
import { createRoot } from 'react-dom/client';
  1. 利用新特性
// 逐步将现有的异步操作迁移到Actions
// 将乐观更新场景迁移到useOptimistic
// 将服务端逻辑迁移到Server Components

常见问题解决

问题1:useActionState的类型定义

// 定义Action函数的类型
type ActionFunction<T> = (prevState: T, formData: FormData) => Promise<T>;

// 使用示例
const updateUser: ActionFunction<UserState> = async (prevState, formData) => {
  // 实现逻辑
};

问题2:Server Components的客户端交互

// 错误:在Server Component中使用useState
// export default function ServerComponent() {
//   const [state, setState] = useState(0); // 这会报错
// }

// 正确:将交互逻辑分离到Client Component
export default function ServerComponent() {
  return (
    <div>
      <h1>服务端内容</h1>
      <ClientInteractiveComponent />
    </div>
  );
}

八、最佳使用例子

1. Actions

// 好的实践:Action函数保持纯净
async function createUser(prevState, formData) {
  const name = formData.get('name');
  const email = formData.get('email');
  
  // 验证
  if (!name || !email) {
    return { error: '请填写所有字段' };
  }
  
  // 业务逻辑
  try {
    const user = await userService.create({ name, email });
    return { success: true, user };
  } catch (error) {
    return { error: error.message };
  }
}

// 避免:在Action中直接操作DOM
async function badAction(prevState, formData) {
  // 错误:不要这样做
  document.getElementById('result').innerHTML = '处理中...';
}

2. useOptimistic

// 好的实践:提供回滚逻辑
function OptimisticCounter({ initialCount }) {
  const [count, addOptimisticCount] = useOptimistic(
    initialCount,
    (state, increment) => state + increment
  );

  const increment = async () => {
    addOptimisticCount(1);
    
    try {
      await api.increment();
    } catch (error) {
      // 自动回滚,无需手动处理
      console.error('操作失败:', error);
    }
  };

  return (
    <button onClick={increment}>
      计数: {count}
    </button>
  );
}

3. Server Components的性能优化

// 好的实践:合理使用缓存
async function getExpensiveData() {
  // 使用Next.js的缓存
  const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 } // 缓存1小时
  });
  return data.json();
}

// 避免:在Server Component中进行不必要的计算
export default async function BadServerComponent() {
  // 错误:不要在服务端进行复杂的客户端计算
  const processedData = heavyClientSideProcessing(rawData);
  return <div>{processedData}</div>;
}

九、整体融合下:构建一个现代化的任务管理应用

通过一个完整的例子来展示React 19的强大功能:

// app/tasks/page.js - Server Component
import { Suspense } from 'react';
import TaskList from './TaskList';
import CreateTaskForm from './CreateTaskForm';

async function getTasks() {
  // 模拟数据库查询
  await new Promise(resolve => setTimeout(resolve, 100));
  return [
    { id: 1, title: '学习React 19', completed: false },
    { id: 2, title: '写技术文章', completed: true },
    { id: 3, title: '重构旧项目', completed: false }
  ];
}

export default async function TasksPage() {
  const tasks = await getTasks();

  return (
    <div className="tasks-page">
      <h1>任务管理</h1>
      <CreateTaskForm />
      <Suspense fallback={<div>加载任务中...</div>}>
        <TaskList initialTasks={tasks} />
      </Suspense>
    </div>
  );
}
// components/TaskList.js - Client Component
'use client';

import { useOptimistic } from 'react';
import TaskItem from './TaskItem';

function TaskList({ initialTasks }) {
  const [tasks, addOptimisticTask] = useOptimistic(
    initialTasks,
    (state, newTask) => [...state, newTask]
  );

  const [tasks, updateOptimisticTask] = useOptimistic(
    tasks,
    (state, { id, updates }) => 
      state.map(task => 
        task.id === id ? { ...task, ...updates } : task
      )
  );

  const handleToggleTask = async (id) => {
    const task = tasks.find(t => t.id === id);
    updateOptimisticTask({ id, updates: { completed: !task.completed } });
    
    try {
      await fetch(`/api/tasks/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: !task.completed })
      });
    } catch (error) {
      // 自动回滚
      console.error('更新任务失败:', error);
    }
  };

  return (
    <div className="task-list">
      {tasks.map(task => (
        <TaskItem
          key={task.id}
          task={task}
          onToggle={() => handleToggleTask(task.id)}
        />
      ))}
    </div>
  );
}
// components/CreateTaskForm.js - 使用Actions
import { useActionState } from 'react';

async function createTask(prevState, formData) {
  const title = formData.get('title');
  
  if (!title.trim()) {
    return { error: '请输入任务标题' };
  }
  
  try {
    const response = await fetch('/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: title.trim() })
    });
    
    if (!response.ok) throw new Error('创建失败');
    
    const newTask = await response.json();
    return { success: true, task: newTask };
  } catch (error) {
    return { error: error.message };
  }
}

function CreateTaskForm() {
  const [state, formAction, isPending] = useActionState(createTask, null);

  return (
    <form action={formAction} className="create-task-form">
      <input
        type="text"
        name="title"
        placeholder="输入新任务..."
        required
        disabled={isPending}
      />
      
      {state?.error && (
        <div className="error">{state.error}</div>
      )}
      
      {state?.success && (
        <div className="success">任务创建成功!</div>
      )}
      
      <button type="submit" disabled={isPending}>
        {isPending ? '创建中...' : '添加任务'}
      </button>
    </form>
  );
}

19的发布标志着React生态系统的又一次重大进化。看来 通过Actions、useOptimistic、Server Components等新特性,能够构建更加高效、用户友好产品了。

关键收获

  1. Actions简化了异步操作:不再需要复杂的状态管理,让代码更加简洁
  2. useOptimistic提升了用户体验:乐观更新让应用感觉更加流畅
  3. Server Components优化了性能:服务端渲染减少了客户端负担
  4. Web Components增强了互操作性:可以更好地集成第三方组件

Vue2日历组件-仿企微日程日历

作者 Onion
2025年10月29日 11:46

概述

基于 Vue2 的高仿企业微信日程日历组件,支持月视图和周视图两种展示模式,具备完整的移动端适配能力。组件提供了丰富的日程管理功能,包括日程展示、点击交互、跨天事件处理等特性。

核心特性

🗓️ 多视图支持

  • 月视图:传统日历网格布局,直观查看整月日程
  • 周视图:时间轴布局,精确到小时的日程安排展示
  • 响应式设计:自动适配桌面端和移动端

📱 移动端优化

  • 触摸友好的交互设计
  • 移动端专属的紧凑布局
  • 原生日期选择器集成

🎯 智能日程管理

  • 日程事件重叠自动分组
  • 跨天事件特殊处理
  • 实时时间指示器
  • 农历日期和节日显示

安装与使用

基本用法

<template>
  <Calendar
    :events="scheduleList"
    :view="currentView"
    @change="handleCalendarChange"
    @click-event="handleEventClick"
    @selectDayChange="handleDaySelect"
  >
    <template #toolbarright>
      <!-- 自定义工具栏内容 -->
      <button @click="addSchedule">新建日程</button>
    </template>
  </Calendar>
</template>

<script>
import Calendar from './components/Calendar.vue'

export default {
  components: {
    Calendar
  },
  data() {
    return {
      currentView: 'month',
      scheduleList: [
        {
          date: '2024-01-15',
          scheduleList: [
            {
              id: 1,
              title: '团队周会',
              startTime: '2024-01-15 09:00:00',
              endTime: '2024-01-15 10:30:00',
              location: '会议室A'
            }
          ]
        }
      ]
    }
  },
  methods: {
    handleCalendarChange(params) {
      console.log('视图变更:', params)
      // 加载对应时间段的日程数据
    },
    handleEventClick(event) {
      console.log('点击日程:', event)
    },
    handleDaySelect(date) {
      console.log('选择日期:', date)
    }
  }
}
</script>

Props 配置

属性名 类型 默认值 说明
events Array [] 日程数据数组
view String 'month' 初始视图模式
scheduleKey String 'scheduleList' 日程列表字段名
scheduleOnlyKey String 'scheduleId' 日程唯一标识字段

事件说明

change

视图或日期范围变化时触发

{
  view: 'month' | 'week',
  begin: '2024-01-01',
  end: '2024-01-31'
}

click-event

点击日程事件时触发

{
  item: {
    id: 1,
    title: '会议',
    startTime: '2024-01-15 09:00:00',
    // ...其他日程属性
  }
}

selectDayChange

选择日期变化时触发

'2024-01-15' // 选中的日期字符串

数据格式

日程数据结构

{
  date: '2024-01-15', // 日期字符串 YYYY-MM-DD
  scheduleList: [
    {
      id: 1, // 唯一标识
      title: '会议主题', // 日程标题
      startTime: '2024-01-15 09:00:00', // 开始时间
      endTime: '2024-01-15 10:00:00', // 结束时间
      location: '会议室A', // 地点(可选)
      // ...其他自定义字段
    }
  ]
}

核心功能详解

1. 视图切换逻辑

组件支持月视图和周视图的平滑切换,在移动端通过下拉选择器切换,桌面端通过按钮切换。

2. 日程重叠处理

采用智能分组算法,自动检测重叠事件并合理布局:

processOverlappingEvents(events) {
  // 按开始时间排序
  // 创建列数组存储事件
  // 分配事件到合适的列
  // 计算重叠计数
}

3. 跨天事件支持

特殊处理跨天日程的显示:

  • 开始日:显示从开始时间到午夜
  • 中间日:全天显示
  • 结束日:显示从午夜到结束时间

4. 农历和节日系统

集成 lunar-javascript 库,支持:

  • 农历日期显示
  • 传统节日识别
  • 二十四节气显示
  • 阳历节日支持

5. 移动端适配

通过 CSS 媒体查询和 JavaScript 检测实现:

@media (max-width: 768px) {
  /* 移动端专属样式 */
}

自定义样式

组件提供了丰富的 CSS 类名用于样式定制:

主要样式类

  • .calendar-toolbar - 工具栏容器
  • .month-view / .week-view - 视图容器
  • .day-cell - 日期单元格
  • .week-event - 周视图日程项
  • .event-item - 月视图日程项

状态类

  • .today - 今天日期
  • .selected - 选中状态
  • .other-month - 非当前月份
  • .cross-day - 跨天事件

进阶用法

动态加载日程

javascript

async loadSchedule(range) {
  const { begin, end } = range
  const schedules = await api.getSchedules(begin, end)
  this.scheduleList = schedules
}

自定义日程颜色

重写 getEventColor 方法: javascript

methods: {
  getEventColor(event) {
    // 根据事件类型返回对应颜色
    const typeColors = {
      meeting: '#0e7cff',
      personal: '#51cf66',
      urgent: '#ff6b6b'
    }
    return typeColors[event.type] || '#0e7cff'
  }
}

浏览器兼容性

  • Chrome 60+
  • Firefox 55+
  • Safari 12+
  • Edge 79+

源码

calender

<template>
  <div id="my-calendar" ref="myCalendar">
    <div class="calendar-toolbar">
      <div class="view-switcher-mobile" v-if="isMobile">
        <select
          v-model="currentView"
          @change="handleViewChange"
          class="view-select"
        >
          <option value="week">周</option>
          <option value="month">月</option>
        </select>
      </div>
      <div class="view-switcher" style="flex: 1" v-else>
        <button
          class="view-btn"
          :class="{ active: currentView === 'month' }"
          @click="changeView('month')"
        >
          月
        </button>
        <button
          class="view-btn"
          :class="{ active: currentView === 'week' }"
          @click="changeView('week')"
        >
          周
        </button>
        <button class="today-btn" @click="goToToday">今天</button>
      </div>
      <div class="nav-controls" style="flex: 1">
        <div class="nav-btn" @click="navigate(-1)">
          <i class="arrow">←</i>
        </div>
        <div class="current-date">
          {{ currentDateText }}
        </div>
        <div class="nav-btn" @click="navigate(1)">
          <i class="arrow">→</i>
        </div>
      </div>
      <div class="toolbarRight" style="flex: 1">
        <slot name="toolbarright"></slot>
      </div>
    </div>
    <div v-if="loading" class="loading">
      <div class="spinner"></div>
    </div>
    <div v-else>
      <!-- 月视图 -->
      <div
        class="month-view-mobile"
        v-show="currentView === 'month'"
        v-if="isMobile"
      >
        <div class="weekdays-mobile">
          <div v-for="(day, index) in weekDaysMobile" :key="day + '-' + index">
            {{ day }}
          </div>
        </div>
        <div class="days-grid-mobile">
          <div
            v-for="day in monthDays"
            :key="day.date"
            class="day-cell-mobile"
            @click="selectDay(day)"
            :class="{
              'other-month': !day.isCurrentMonth,
              selected: day.date === selectDayStr,
              today: day.isToday,
            }"
          >
            <div class="day-number-mobile">{{ day.day }}</div>
            <div class="lunar-date-mobile" v-if="day.holiday">
              {{ day.holiday }}
            </div>
            <div class="lunar-date-mobile" v-else>{{ day.lunar }}</div>
            <div
              class="events-indicator"
              v-if="day.events && day.events.length > 0"
            >
              <div class="event-dots">
                <span
                  v-for="(event, index) in day.events.slice(0, 3)"
                  :key="event.id + '-' + index"
                  class="event-dot"
                  :style="{ backgroundColor: getEventColor(event) }"
                ></span>
              </div>
              <div class="more-events" v-if="day.events.length > 3">
                +{{ day.events.length - 3 }}
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="month-view" v-show="currentView === 'month'" v-else>
        <div class="weekdays">
          <div v-for="day in weekDays" :key="day">{{ day }}</div>
        </div>
        <div class="days-grid">
          <div
            v-for="day in monthDays"
            :key="day.date"
            class="day-cell"
            @click="selectDay(day)"
            :class="{
              'other-month': !day.isCurrentMonth,
              selected: day.date === selectDayStr,
            }"
          >
            <div class="day-header">
              <div
                class="day-number"
                :class="{
                  today: day.isToday,
                  'other-month': !day.isCurrentMonth,
                }"
              >
                {{ day.day }}
              </div>
              <div class="day-holiday" :class="{ today: day.isToday }">
                {{ day.holiday }}
              </div>
              <div class="lunar-date" :class="{ today: day.isToday }">
                {{ day.lunar }}
              </div>
            </div>
            <div
              class="events-container"
              v-show="day.events && day.events.length > 0"
            >
              <div
                :title="event.title"
                v-for="(event, index) in day.events.slice(0, 3)"
                :key="index"
                class="event-item"
                @click.stop="handleEventClick(event)"
              >
                <span style="margin-right: 3px">{{
                  formatTime(event.startTime)
                }}</span>
                {{ event.title }}
              </div>
              <div></div>
              <PopupComponent
                :ref="'monthViewMoreEvent' + day.day"
                v-if="day.events && day.events.length > 3 && day.isCurrentMonth"
              >
                <template v-slot:trigger="{ open }">
                  <div class="custom-trigger" @click.stop="open">
                    <div v-show="day.events.length > 3" class="more-events">
                      还有{{ day.events.length - 3 }}个日程
                    </div>
                  </div>
                </template>
                <template v-slot:content>
                  <div
                    class="custom-content"
                    style="width: 240px; max-width: 500px"
                  >
                    <div class="title">
                      <span style="font-size: 25px; font-weight: 600">{{
                        day.day
                      }}</span>
                      <span
                        style="
                          font-size: 14px;
                          font-weight: 600;
                          margin-left: 5px;
                        "
                        >{{ day.week }}</span
                      >
                    </div>
                    <div
                      class="task-list custom-scrollbar"
                      style="max-height: 150px; overflow-y: auto"
                    >
                      <div v-for="item in day.events" :key="item.id">
                        <div
                          class="event-item"
                          @click.stop="handleEventClick(item, day)"
                        >
                          <span style="margin-right: 3px">{{
                            formatTime(item.startTime)
                          }}</span>
                          <span :title="item.title">{{ item.title }}</span>
                        </div>
                      </div>
                    </div>
                  </div>
                </template>
              </PopupComponent>
              <div v-else>
                <div v-show="day.events.length > 3" class="more-events">
                  +{{ day.events.length - 3 }}个日程
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <!-- 周视图 -->
      <div
        class="week-view-mobile"
        v-show="currentView === 'week'"
        v-if="isMobile"
      >
        <div class="week-header-mobile">
          <div class="week-dates">
            <div
              v-for="(day, index) in weekDaysData"
              :key="index"
              class="week-day-header"
              :class="{
                today: day.isToday,
                selected: day.date === selectDayStr,
              }"
              @click="selectDay(day)"
            >
              <div class="week-day-name">{{ weekDayNamesMobile[index] }}</div>
              <div class="week-day-number">{{ day.day }}</div>
              <div class="week-lunar">{{ day.holiday || day.lunar }}</div>
            </div>
          </div>
        </div>

        <div class="week-events-container">
          <div
            class="current-time-indicator"
            :style="{ top: currentTimePosition + 'px' }"
            v-if="showCurrentTime"
          >
            <div class="time-dot"></div>
            <div class="time-line"></div>
          </div>

          <div class="time-column-mobile">
            <div v-for="hour in 24" :key="hour" class="time-slot-mobile">
              {{ hour === 0 ? "00:00" : `${hour}:00` }}
            </div>
          </div>

          <div class="events-column-mobile">
            <div
              v-for="(day, dayIndex) in weekDaysData"
              :key="dayIndex"
              class="day-events"
            >
              <div
                v-for="event in getEventsForDay(day.date)"
                :key="event.id"
                class="mobile-event"
                :style="getMobileEventStyle(event)"
                @click="handleEventClick(event)"
              >
                <div class="event-time-mobile">
                  {{ formatTime(event.startTime) }} -
                  {{ formatTime(event.endTime) }}
                </div>
                <div class="event-title-mobile">{{ event.title }}</div>
                <div class="event-location-mobile" v-if="event.location">
                  {{ event.location }}
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div
        class="week-container"
        style="padding: 16px; width: 100%; height: 100%"
        v-show="currentView === 'week'"
        v-else
      >
        <div style="display: flex; padding-left: 60px">
          <div
            v-for="(day, index) in weekDaysData"
            :key="index"
            style="flex: 1"
          >
            <div class="day-header-week">
              <div class="week-name" style="color: #666666">
                {{ weekDayNames[index] }}
              </div>
              <div
                class="day-date"
                style="display: inline-block; margin-right: 5px"
                :class="{ today: day.isToday }"
              >
                {{ day.day }}
              </div>
              <div
                :class="{
                  'lunar-date': !day.holiday,
                  'day-holiday': !!day.holiday,
                }"
                style="display: inline-block"
              >
                {{ day.holiday ? day.holiday : day.lunar }}
              </div>
            </div>
          </div>
        </div>
        <div class="week-time-view">
          <div class="week-view">
            <div class="time-column">
              <!-- 时间刻度改为每小时120px高度 -->
              <div v-for="hour in 25" :key="hour" class="time-slot">
                {{ hour === 0 ? "00:00" : `${hour - 1}:00` }}
              </div>
            </div>
            <div class="days-container">
              <div
                v-for="(day, index) in weekDaysData"
                :key="index"
                class="day-column"
              >
                <div class="events-week" style="overflow-y: auto">
                  <!-- 跨天事件指示器 - 高度调整为2880px (24*120) -->
                  <div
                    v-for="event in getOngoingEvents(day.date)"
                    :key="'ongoing-' + event.id"
                    class="ongoing-event-indicator"
                    :style="{
                      top: '0px',
                      height: '2880px',
                      width: 'calc(100% - 8px)',
                      left: '4px',
                    }"
                  >
                    <div class="ongoing-event-line"></div>
                  </div>

                  <!-- 正常事件 - 使用新的样式计算方法 -->
                  <div
                    v-for="event in getEventsForDay(day.date)"
                    :key="event.id"
                    class="week-event"
                    :class="{
                      'multi-column': event.columnIndex > 0,
                      'cross-day': isCrossDayEvent(event),
                      'short-event': isShortEvent(event), // 添加短事件类
                    }"
                    :style="getWeekEventStyle(event, day.date)"
                    @click="handleEventClick(event)"
                  >
                    <div class="event-time">
                      <div v-show="isCrossDayEvent(event)">
                        {{
                          formatTime(event.startTime, true) +
                          "至" +
                          formatTime(event.endTime, true)
                        }}
                      </div>
                      <div v-show="!isCrossDayEvent(event)">
                        {{
                          formatTime(event.startTime) +
                          "-" +
                          formatTime(event.endTime)
                        }}
                      </div>
                    </div>
                    <div class="event-title">{{ event.title }}</div>
                    <div
                      v-if="
                        isCrossDayEvent(event) &&
                        isEventStartDay(event, day.date)
                      "
                      class="cross-day-badge"
                    >
                      跨天
                    </div>
                    <div v-if="event.overlapCount > 0" class="event-badge">
                      +{{ event.overlapCount }}
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import * as lunar from "lunar-javascript";
import PopupComponent from "./childcom/popupComponent.vue";
export default {
  components: { PopupComponent },
  name: "Hcaleader",
  props: {
    // 日程列表
    events: {
      type: Array,
      default: () => []
    },
    // 日程列表key值
    scheduleKey: {
      type: String,
      default: "scheduleList",
    },
    //视图
    view: {
      type: String,
      default: "month",
    },
    scheduleOnlyKey: {
      type: String,
      default: "scheduleId",
    },
  },
  watch: {
    events: {
      handler(newVal, oldVal) {
        console.log("scheduleList???", newVal, oldVal);
        if (oldVal !== undefined && newVal !== oldVal) {
          this.$nextTick(() => {
            this.loading = true;
            const res = this.currentDate.setHours(0, 30, 0, 0);
            this.currentDate = new Date(res);
            this.loading = false;
          });
        }
      },
      deep: true,
      immediate: true,
    },
    selectDayStr: {
      handler(newVal, oldVal) {
        if (newVal !== oldVal) {
          this.$emit("selectDayChange", newVal);
        }
      },
      immediated: true,
      deep: true,
    },
  },
  data() {
    return {
      // 新增移动端相关数据
      isMobile: false,
      touchTimer: null,
      selectedDay: "",
      weekDaysMobile: ["一", "二", "三", "四", "五", "六", "日"],
      weekDayNamesMobile: [
        "周一",
        "周二",
        "周三",
        "周四",
        "周五",
        "周六",
        "周日",
      ],
      currentTimePosition: 0,
      showCurrentTime: true,
      // 其他原有数据
      currentView: "month",
      selectDayStr: "", //选中的日
      loading: false,
      currentDate: new Date(),
      weekDays: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
      weekDayNames: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
      // 阳历节日
      solarFestivals: {
        11: "元旦",
        38: "妇女节",
        51: "劳动节",
        61: "儿童节",
        71: "建党节",
        81: "建军节",
        910: "教师节",
        101: "国庆节",
      },
    };
  },
  mounted() {
    // 检测设备类型
    this.checkMobile();
    window.addEventListener("resize", this.checkMobile);
    // 初始化选中日期
    if (this.monthDays.length > 0) {
      const today = this.monthDays.find((day) => day.isToday);
      this.selectedDay = today || this.monthDays[15]; // 默认选择月中某天
      this.selectDayStr = this.selectedDay.date;
    }
    this.changeView(this.view);
    // 更新时间指示器
    this.updateCurrentTimeIndicator();
    setInterval(this.updateCurrentTimeIndicator, 60000); // 每分钟更新一次
  },
  beforeDestroy() {
    window.removeEventListener("resize", this.checkMobile);
    this.clearTouchTimer();
  },
  computed: {
    currentYear() {
      return this.currentDate.getFullYear();
    },
    // 当前日期文本显示
    currentDateText() {
      const year = this.currentDate.getFullYear();
      const month = this.currentDate.getMonth() + 1;
      if (this.currentView === "month") {
        return `${year}年${month}月`;
      } else if (this.currentView === "week") {
        const weekStart = new Date(this.weekDaysData[0].date);
        const weekEnd = new Date(this.weekDaysData[6].date);
        const startMonth = weekStart.getMonth() + 1;
        const endMonth = weekEnd.getMonth() + 1;
        if (startMonth === endMonth) {
          return `${year}年${startMonth}月${weekStart.getDate()}日 - ${weekEnd.getDate()}日`;
        } else {
          return `${year}年${startMonth}月${weekStart.getDate()}日 - ${endMonth}月${weekEnd.getDate()}日`;
        }
      }
    },
    // 月视图数据
    monthDays() {
      const year = this.currentDate.getFullYear();
      const month = this.currentDate.getMonth();
      // 当月第一天
      const firstDay = new Date(year, month, 1);
      // 当月最后一天
      const lastDay = new Date(year, month + 1, 0);
      // 当月第一天是周几(0是周日,1是周一...)
      const firstDayOfWeek = firstDay.getDay() === 0 ? 7 : firstDay.getDay();
      // 日历开始日期(上月最后几天)
      const startDate = new Date(firstDay);
      startDate.setDate(firstDay.getDate() - (firstDayOfWeek - 1));
      // 日历结束日期(下月前几天)
      const endDate = new Date(lastDay);
      endDate.setDate(
        lastDay.getDate() + (42 - lastDay.getDate() - (firstDayOfWeek - 1))
      );
      const days = [];
      const currentDate = new Date(startDate);
      while (currentDate <= endDate) {
        const weekStr = this.getDayOfWeek(currentDate);
        const dateStr = this.formatDate(currentDate);
        const isCurrentMonth = currentDate.getMonth() === month;
        const isToday = this.isToday(currentDate);
        // 获取农历(简化处理)
        const lunarDay = this.getLunarInfo(currentDate);
        // 获取该日期的日程
        const dayEvents = this.getEventsByDate(dateStr);
        days.push({
          date: dateStr,
          day: currentDate.getDate(),
          year: currentDate.getFullYear(),
          month: currentDate.getMonth() + 1,
          lunar: lunarDay.display,
          holiday: lunarDay.holiday || "",
          isCurrentMonth,
          isToday,
          events: dayEvents,
          week: weekStr,
        });
        currentDate.setDate(currentDate.getDate() + 1);
      }
      return days;
    },

    // 周视图数据
    weekDaysData() {
      const weekStart = new Date(this.currentDate);
      // 设置到本周一
      weekStart.setDate(
        weekStart.getDate() -
          (weekStart.getDay() === 0 ? 6 : weekStart.getDay() - 1)
      );
      const days = [];
      for (let i = 0; i < 7; i++) {
        const date = new Date(weekStart);
        date.setDate(weekStart.getDate() + i);
        const dateStr = this.formatDate(date);
        const isToday = this.isToday(date);
        // 获取农历(简化处理)
        const lunarDay = this.getLunarInfo(date);
        days.push({
          date: dateStr,
          year: date.getFullYear(),
          month: date.getMonth() + 1,
          day: date.getDate(),
          holiday: lunarDay.holiday || "",
          isToday,
          events: this.getEventsByDate(dateStr),
          lunar: lunarDay.display,
        });
      }

      return days;
    },
  },
  methods: {
    getDayOfWeek(dateString) {
      const date = new Date(dateString);
      const day = date.getDay();
      const days = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
      return days[day];
    },
    // 移动端判断方法
    checkMobile() {
      this.isMobile = window.innerWidth <= 768;
    },
    handleTouchStart(e, direction) {
      // 防止默认行为
      e.preventDefault();
      // 设置定时器实现长按连续导航
      this.touchTimer = setTimeout(() => {
        this.navigate(direction);
        // 递归调用实现连续导航
        this.handleTouchStart(e, direction);
      }, 300);
    },

    clearTouchTimer() {
      if (this.touchTimer) {
        clearTimeout(this.touchTimer);
        this.touchTimer = null;
      }
    },
    handleViewChange() {
      this.emitChange();
    },

    selectMonth(month) {
      const newDate = new Date(this.currentDate);
      newDate.setMonth(month - 1);
      this.currentDate = newDate;
      this.changeView("month");
    },

    selectQuarter(quarter) {
      // 切换到选定的季度
      this.currentDate = new Date(quarter.startDate);
      this.changeView("month");
    },
    isCurrentMonth(month) {
      const today = new Date();
      return (
        this.currentYear === today.getFullYear() &&
        month === today.getMonth() + 1
      );
    },

    isCurrentQuarter(quarter) {
      const today = new Date();
      const quarterStart = new Date(quarter.startDate);
      const quarterEnd = new Date(quarter.endDate);
      return today >= quarterStart && today <= quarterEnd;
    },

    getMonthEvents(year, month) {
      // 获取某月所有事件
      const monthStr = month < 10 ? `0${month}` : `${month}`;
      const monthPrefix = `${year}-${monthStr}`;
      return this.events
        .filter((event) => event.date.startsWith(monthPrefix))
        .flatMap((event) => event[this.scheduleKey]);
    },
    getEventColor(event) {
      // 根据事件类型返回颜色
      const colors = ["#0e7cff", "#ff6b6b", "#51cf66", "#fcc419", "#ae3ec9"];
      const hash = event.title.split("").reduce((a, b) => {
        a = (a << 5) - a + b.charCodeAt(0);
        return a & a;
      }, 0);
      return colors[Math.abs(hash) % colors.length];
    },
    getMobileEventStyle(event) {
      // 移动端周视图事件样式
      const start = new Date(event.startTime);
      const end = new Date(event.endTime);

      const startMinutes = start.getHours() * 60 + start.getMinutes();
      const endMinutes = end.getHours() * 60 + end.getMinutes();
      const duration = endMinutes - startMinutes;

      return {
        top: startMinutes * 2 + "px",
        height: Math.max(duration * 2, 40) + "px",
        backgroundColor: this.getEventColor(event) + "20",
        borderLeft: `3px solid ${this.getEventColor(event)}`,
      };
    },
    updateCurrentTimeIndicator() {
      const now = new Date();
      const currentMinutes = now.getHours() * 60 + now.getMinutes();
      this.currentTimePosition = currentMinutes * 2; // 每分钟2px
      this.showCurrentTime =
        now >= new Date(this.weekDaysData[0].date) &&
        now <= new Date(this.weekDaysData[6].date);
    },

    formatFullDate(day) {
      if (!day || !day.date) return "";
      const date = new Date(day.date);
      const weekdays = [
        "星期日",
        "星期一",
        "星期二",
        "星期三",
        "星期四",
        "星期五",
        "星期六",
      ];
      return `${date.getFullYear()}年${
        date.getMonth() + 1
      }月${date.getDate()}日 ${weekdays[date.getDay()]}`;
    },
    showDatePicker() {
      // 移动端显示原生日期选择器
      if (this.isMobile) {
        const input = document.createElement("input");
        input.type = "date";
        input.value = this.currentDate.toISOString().split("T")[0];
        input.addEventListener("change", (e) => {
          this.currentDate = new Date(e.target.value);
          this.emitChange();
        });
        input.click();
      }
    },
    //其他原有方法
    openLoading() {
      this.loading = true;
    },
    closeLoading() {
      this.loading = false;
    },
    // 重新加载方法(实际是手动触发changa)
    reload() {
      this.emitChange("手动触发");
    },
    //获取日期板块数据
    getBoardData() {
      let obj = {
        view: this.currentView,
        begin: "",
        end: "",
      };
      if (this.currentView === "month") {
        obj.begin = this.monthDays[0].date;
        obj.end = this.monthDays[this.monthDays.length - 1].date;
      } else if (this.currentView === "week") {
        obj.begin = this.weekDaysData[0].date;
        obj.end = this.weekDaysData[this.weekDaysData.length - 1].date;
      }
      return obj;
    },
    //获取农历信息方法
    getLunarInfo(date) {
      const year = date.getFullYear();
      const month = date.getMonth() + 1;
      const day = date.getDate();
      // 使用新的农历库
      const solar = lunar.Solar.fromYmd(year, month, day);
      const l = solar.getLunar();
      // 1. 获取传统节日(农历节日)
      const traditionalFestival = l.getFestivals()[0] || null;
      // 2. 获取节气
      const solarTerm = l.getJieQi() || null;
      // 3. 获取阳历节日
      const solarFestival = this.solarFestivals[month + "" + day] || null;
      // 显示内容(农历日)
      let display = l.getDayInChinese();
      if (l.getDay() === 1) {
        // 初一显示月份
        display = l.getMonthInChinese() + "月";
      }
      // 节日显示优先级:传统节日 > 节气 > 阳历节日
      const holiday = traditionalFestival || solarTerm || solarFestival;
      return {
        display: display,
        holiday: holiday,
        isTraditionalFestival: !!traditionalFestival,
        isSolarTerm: !!solarTerm,
      };
    },
    selectDay(day) {
      if (this.currentDate.getMonth() + 1 !== day.month) {
        this.currentDate = new Date(day.date);
        this.emitChange("切换日期触发");
      }
      this.selectDayStr = day.date;
    },
    // 处理日程点击事件
    handleEventClick(event, day) {
      if (day && day.day) {
        console.log("弹出层内点击", this.$refs["monthViewMoreEvent" + day.day]);
        this.$refs["monthViewMoreEvent" + day.day] &&
          this.$refs["monthViewMoreEvent" + day.day][0].closePopup();
      }
      console.log("@@@@handleEventClick", event);
      this.$emit("click-event", { item: event });
    },
    // 切换视图
    changeView(view) {
      this.currentView = view;
      // this.$emit('update:view', view);
      this.emitChange();
    },
    // 导航(上一月/周,下一月/周)
    navigate(direction) {
      const newDate = new Date(this.currentDate);
      if (this.currentView === "month") {
        newDate.setMonth(newDate.getMonth() + direction);
      } else if (this.currentView === "week") {
        newDate.setDate(newDate.getDate() + direction * 7);
      }
      this.currentDate = newDate;
      this.emitChange("导航");
    },

    // 返回今天
    goToToday() {
      this.currentDate = new Date();
      this.emitChange("回到今天");
    },
    emitChange() {
      let obj = {
        view: this.currentView,
        begin: "",
        end: "",
      };
      if (this.currentView === "month") {
        obj.begin = this.monthDays[0].date;
        obj.end = this.monthDays[this.monthDays.length - 1].date;
      } else if (this.currentView === "week") {
        obj.begin = this.weekDaysData[0].date;
        obj.end = this.weekDaysData[this.weekDaysData.length - 1].date;
      }
      console.log("emitChange", obj);
      this.$emit("change", obj);
    },
    // 根据日期获取日程
    getEventsByDate(date) {
      const eventData = this.events.find((e) => e.date === date);
      return eventData ? eventData[this.scheduleKey] : [];
    },
    // 格式化日期为 YYYY-MM-DD
    formatDate(date) {
      const year = date.getFullYear();
      const month = String(date.getMonth() + 1).padStart(2, "0");
      const day = String(date.getDate()).padStart(2, "0");
      return `${year}-${month}-${day}`;
    },

    // 判断是否为今天
    isToday(date) {
      const today = new Date();
      return (
        date.getDate() === today.getDate() &&
        date.getMonth() === today.getMonth() &&
        date.getFullYear() === today.getFullYear()
      );
    },

    // 格式化时间(周视图中使用)
    formatTime(dateTime, isShowMonthAndDay) {
      const date = new Date(dateTime);
      const hours = String(date.getHours()).padStart(2, "0");
      const minutes = String(date.getMinutes()).padStart(2, "0");
      const month = String(date.getMonth() + 1).padStart(2, "0");
      const day = String(date.getDate()).padStart(2, "0");
      if (isShowMonthAndDay) {
        return `${month}-${day} ${hours}:${minutes}`;
      } else {
        return `${hours}:${minutes}`;
      }
    },
    // 计算事件在周视图中的位置(顶部位置)
    getEventPosition(event) {
      const start = new Date(event.startTime);
      const hours = start.getHours();
      const minutes = start.getMinutes();
      // 每分钟对应1px(1440分钟 * 1px = 1440px)
      return hours * 60 + minutes + "px";
    },
    getEventHeight(event) {
      const start = new Date(event.startTime);
      const end = new Date(event.endTime);
      // 计算事件持续时间(分钟)
      const duration = (end - start) / (1000 * 60);
      // 每分钟对应1px
      return Math.max(duration, 30) + "px";
    },
    // 新增方法:获取某天的正在进行中的跨天事件
    getOngoingEvents(date) {
      const result = [];
      const currentDate = new Date(date);
      this.events.forEach((dayEvents) => {
        dayEvents[this.scheduleKey]?.forEach((event) => {
          const eventStart = new Date(event.startTime);
          const eventEnd = new Date(event.endTime);
          // 检查事件是否跨天并且当前日期在事件期间内(但不是开始日)
          if (
            this.isCrossDayEvent(event) &&
            currentDate > eventStart &&
            currentDate < eventEnd
          ) {
            result.push(event);
          }
        });
      });

      return result;
    },
    // 获取某天的所有事件(处理跨天事件)
    getEventsForDay(date) {
      const dayEvents = this.getEventsByDate(date);
      const processedEvents = [];
      dayEvents.forEach((event) => {
        // 克隆事件对象以避免修改原始数据
        const processedEvent = { ...event };
        // 标记跨天事件
        processedEvent.isCrossDay = this.isCrossDayEvent(event);
        // 对于跨天事件,计算在当前天的显示比例
        if (processedEvent.isCrossDay) {
          const eventStart = new Date(event.startTime);
          const eventEnd = new Date(event.endTime);
          const currentDate = new Date(date);
          // 如果是开始日,计算到午夜的比例
          if (this.isSameDay(eventStart, currentDate)) {
            const startMinutes =
              eventStart.getHours() * 60 + eventStart.getMinutes();
            const dayEndMinutes = 24 * 60;
            processedEvent.displayRatio =
              (dayEndMinutes - startMinutes) /
              ((eventEnd - eventStart) / (1000 * 60));
          }
          // 如果是结束日,计算从午夜开始的比例
          else if (this.isSameDay(eventEnd, currentDate)) {
            const dayStartMinutes = 0;
            const endMinutes = eventEnd.getHours() * 60 + eventEnd.getMinutes();
            processedEvent.displayRatio =
              endMinutes / ((eventEnd - eventStart) / (1000 * 60));
          }
          // 如果是中间日,显示全天
          else {
            processedEvent.displayRatio = 1;
          }
        }
        processedEvents.push(processedEvent);
      });
      // 处理事件重叠
      return this.processOverlappingEvents(processedEvents);
    },

    // 周视图分组算法
    processOverlappingEvents(events) {
      if (!events.length) return [];

      // 按开始时间排序
      const sortedEvents = [...events].sort((a, b) => {
        return new Date(a.startTime) - new Date(b.startTime);
      });

      // 创建列数组来存储事件
      const columns = [];
      const eventColumns = new Map();

      // 分配事件到列
      sortedEvents.forEach((event) => {
        let placed = false;

        // 尝试将事件放入现有列
        for (let i = 0; i < columns.length; i++) {
          const col = columns[i];
          const lastEvent = col[col.length - 1];

          // 检查事件是否与列中最后一个事件重叠
          if (!this.eventsOverlap(lastEvent, event)) {
            col.push(event);
            eventColumns.set(event, i);
            placed = true;
            break;
          }
        }

        // 如果没有合适的列,创建新列
        if (!placed) {
          columns.push([event]);
          eventColumns.set(event, columns.length - 1);
        }
      });

      // 为每个事件添加列信息
      sortedEvents.forEach((event) => {
        event.columnIndex = eventColumns.get(event);
        event.totalColumns = columns.length;

        // 计算重叠计数
        event.overlapCount = 0;
        sortedEvents.forEach((otherEvent) => {
          if (
            event !== otherEvent &&
            this.eventsOverlap(event, otherEvent) &&
            eventColumns.get(event) === eventColumns.get(otherEvent)
          ) {
            event.overlapCount++;
          }
        });
      });

      return sortedEvents;
    },
    // 检查两个事件是否重叠
    eventsOverlap(eventA, eventB) {
      const startA = new Date(eventA.startTime);
      const endA = new Date(eventA.endTime);
      const startB = new Date(eventB.startTime);
      const endB = new Date(eventB.endTime);

      return startA < endB && endA > startB;
    },
    // 检查是否为短事件(小于1小时)
    isShortEvent(event) {
      const start = new Date(event.startTime);
      const end = new Date(event.endTime);
      const duration = (end - start) / (1000 * 60); // 分钟数
      return duration < 60;
    },
    // 获取周视图事件样式
    getWeekEventStyle(event, currentDate) {
      const start = new Date(event.startTime);
      const end = new Date(event.endTime);
      const currentDay = new Date(currentDate);

      let top = 0;
      let height = 0;

      // 处理跨天事件
      if (this.isCrossDayEvent(event)) {
        if (this.isSameDay(start, currentDay)) {
          // 开始日:从开始时间到午夜
          const startMinutes = start.getHours() * 60 + start.getMinutes();
          top = startMinutes * 2; // 乘以2,每分钟对应2px
          height = (24 * 60 - startMinutes) * 2;
        } else if (this.isSameDay(end, currentDay)) {
          // 结束日:从午夜到结束时间
          const endMinutes = end.getHours() * 60 + end.getMinutes();
          top = 0;
          height = endMinutes * 2;
        } else {
          // 中间日:全天显示
          top = 0;
          height = 24 * 60 * 2; // 2880px
        }
      } else {
        // 非跨天事件 - 每分钟对应2px
        const startMinutes = start.getHours() * 60 + start.getMinutes();
        const endMinutes = end.getHours() * 60 + end.getMinutes();
        const duration = endMinutes - startMinutes;

        top = startMinutes * 2;
        // 确保最小高度为60px(对应半小时)
        height = Math.max(duration * 2, 60);
      }

      // 计算宽度和位置(考虑重叠列)
      const columnWidth =
        event.totalColumns > 0 ? 100 / event.totalColumns : 100;
      const left = event.columnIndex * columnWidth;
      const width = columnWidth;

      return {
        top: top + "px",
        height: height + "px",
        left: left + "%",
        width: width + "%",
        "z-index": event.columnIndex + 1, // 添加z-index确保重叠正确显示
      };
    },

    // 新增方法:检查是否为同一天
    isSameDay(date1, date2) {
      return (
        date1.getFullYear() === date2.getFullYear() &&
        date1.getMonth() === date2.getMonth() &&
        date1.getDate() === date2.getDate()
      );
    },
    // 新增方法:检查是否为跨天事件
    isCrossDayEvent(event) {
      const start = new Date(event.startTime);
      const end = new Date(event.endTime);
      return !this.isSameDay(start, end);
    },
    // 新增方法:检查事件是否在当前日开始
    isEventStartDay(event, currentDate) {
      const start = new Date(event.startTime);
      const currentDay = new Date(currentDate);
      return this.isSameDay(start, currentDay);
    },
  },
};
</script>
<style scoped>
/* 移动端容器 */
.calendar-container {
  max-width: 100%;
  overflow: hidden;
}

/* 媒体查询 - 移动端样式 */
@media (max-width: 768px) {
  .calendar-toolbar {
    flex-direction: column;
    padding: 10px;
    gap: 12px;
  }

  .nav-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
  }

  .current-date {
    font-size: 16px;
    min-width: auto;
    padding: 0 10px;
  }

  .view-select {
    width: 100%;
    padding: 8px;
    border-radius: 8px;
    border: 1px solid #ddd;
    background: white;
  }

  .month-cell {
    aspect-ratio: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background: white;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    position: relative;
  }

  .month-cell.current-month {
    background: #ecf5ff;
    border: 1px solid #0e7cff;
  }

  .month-number {
    font-size: 16px;
    font-weight: 500;
    margin-bottom: 5px;
  }

  .month-events {
    display: flex;
    align-items: center;
    gap: 2px;
  }

  .event-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background-color: #0e7cff;
    display: inline-block;
  }

  .event-count {
    font-size: 12px;
    color: #666;
  }

  /* 月视图移动端样式 */
  .month-view-mobile {
    padding: 0 5px;
  }

  .weekdays-mobile {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    font-size: 12px;
    color: #666;
    margin-bottom: 5px;
  }

  .days-grid-mobile {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 2px;
  }

  .day-cell-mobile {
    aspect-ratio: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    padding: 5px 0;
    border-radius: 50%;
    position: relative;
  }

  .day-cell-mobile.today {
    background-color: #0e7cff;
  }

  .day-cell-mobile.today .day-number-mobile {
    color: white;
  }

  .day-cell-mobile.selected {
    background-color: #e6f3ff;
  }

  .day-cell-mobile.other-month {
    opacity: 0.3;
  }

  .day-number-mobile {
    font-size: 14px;
    font-weight: 500;
    margin-bottom: 2px;
  }

  .lunar-date-mobile {
    font-size: 10px;
    color: #999;
  }

  .events-indicator {
    position: absolute;
    bottom: 2px;
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
  }

  .event-dots {
    display: flex;
    gap: 1px;
    justify-content: center;
  }

  .more-events {
    font-size: 9px;
    color: #666;
  }

  /* 周视图移动端样式 */
  .week-view-mobile {
    height: calc(100vh - 150px);
    display: flex;
    flex-direction: column;
  }

  .week-header-mobile {
    padding: 10px 0;
    background: white;
    border-bottom: 1px solid #eee;
  }

  .week-dates {
    display: flex;
    justify-content: space-around;
  }

  .week-day-header {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 5px;
    border-radius: 12px;
    min-width: 40px;
  }

  .week-day-header.today {
    background: #0e7cff;
  }

  .week-day-header.today .week-day-number {
    color: white;
  }

  .week-day-header.selected {
    background: #e6f3ff;
  }

  .week-day-name {
    font-size: 12px;
    color: #666;
  }

  .week-day-number {
    font-size: 16px;
    font-weight: 500;
    margin: 3px 0;
  }

  .week-lunar {
    font-size: 10px;
    color: #999;
  }

  .week-events-container {
    flex: 1;
    display: flex;
    position: relative;
    overflow-y: auto;
  }

  .time-column-mobile {
    width: 50px;
    flex-shrink: 0;
  }

  .time-slot-mobile {
    height: 120px;
    font-size: 10px;
    color: #999;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding-top: 5px;
  }

  .events-column-mobile {
    flex: 1;
    position: relative;
  }

  .day-events {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
  }

  .mobile-event {
    position: absolute;
    left: 2px;
    right: 2px;
    border-radius: 6px;
    padding: 5px;
    overflow: hidden;
  }

  .event-time-mobile {
    font-size: 10px;
    margin-bottom: 2px;
  }

  .event-title-mobile {
    font-size: 12px;
    font-weight: 500;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .event-location-mobile {
    font-size: 10px;
    color: #666;
  }

  .current-time-indicator {
    position: absolute;
    left: 0;
    right: 0;
    height: 2px;
    background-color: #ff6b6b;
    z-index: 10;
    pointer-events: none;
    display: flex;
    align-items: center;
  }

  .time-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: #ff6b6b;
    margin-right: 5px;
  }
  .event-title {
    font-size: 16px;
    margin-bottom: 5px;
  }
}

/* 桌面样式保持不变,通过媒体查询隔离 */
@media (min-width: 769px) {
  .month-view-mobile,
  .week-view-mobile,
  .day-view-mobile,
  .year-view-mobile,
  .quarter-view-mobile {
    display: none;
  }
}

/* 原有样式保持不变 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "PingFang SC", "Microsoft YaHei", sans-serif;
}

body {
  background: #f0f2f5;
  color: #333;
  padding: 20px;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}
.calendar-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 24px;
  border-bottom: 1px solid #eee;
  background: #fafbfc;
}

.view-switcher {
  display: flex;
  gap: 8px;
}

.view-btn {
  padding: 4px 8px;
  border-radius: 6px;
  border: 1px solid #d9d9d9;
  background: #fff;
  color: #666;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.view-btn.active {
  background: #0e7cff;
  color: white;
  border-color: #0e7cff;
}

.nav-controls {
  display: flex;
  align-items: center;
  gap: 16px;
}

.nav-btn {
  background: #fff;
  border: 1px solid #d9d9d9;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: all 0.2s;
}

.nav-btn:hover {
  border-color: #0e7cff;
  color: #0e7cff;
}

.today-btn {
  padding: 4px 8px;
  border-radius: 6px;
  border: 1px solid #d9d9d9;
  background: #fff;
  color: #666;
  cursor: pointer;
  font-size: 14px;
}

.current-date {
  font-size: 18px;
  font-weight: 500;
  color: #1a1a1a;
  min-width: 180px;
  text-align: center;
}

/* 月视图样式 */
.month-view {
  padding: 16px;
}

.weekdays {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  text-align: center;
  padding: 12px 0;
  font-weight: 500;
  color: #666;
  border-bottom: 1px solid #eee;
}

.days-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  min-height: auto;
}

.day-cell {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  border: 1px solid #f0f0f0;
  padding: 8px;
  position: relative;
  min-height: 90px;
  transition: background 0.2s;
}
.day-cell.selected {
  /* background: #d9ecff; */
  /* border: 1px solid #409eff; */
}
.day-cell:hover {
  /* background: #f9f9f9; */
  cursor: pointer;
}

.day-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 4px;
}

.day-number {
  font-size: 16px;
  font-weight: 500;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
}
.day-holiday {
  font-size: 14px;
  font-weight: 500;
  color: #f56c6c;
}

.day-number.today {
  background: #0e7cff;
  color: white;
}
.day-holiday.today {
  color: #0e7cff;
}
.lunar-date.today {
  color: #0e7cff;
}
.day-number.other-month {
  color: #ccc;
}

.lunar-date {
  font-size: 12px;
  color: #999;
}

.events-container {
  overflow-y: auto;
  max-height: 100px;
}

.event-item {
  background: #ffffff;
  padding: 2px 3px;
  margin-bottom: 3px;
  border-radius: 2px;
  font-size: 12px;
  cursor: pointer;
  transition: all 0.2s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.event-item:hover {
  /* background: #d0e7ff; */
  background: #f9f9f9;
}
.event-item::before {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  background-color: #f53e37;
  border-radius: 50%;
  margin-right: 4px;
}
.more-events {
  color: #999;
  font-size: 12px;
  margin-top: 4px;
  cursor: pointer;
}

/* 周视图样式 */
.week-view {
  height: auto;
  display: flex;
}

.time-column {
  width: 60px;
}

.time-slot {
  height: 120px;
  position: relative;
  text-align: right;
  padding-right: 8px;
  font-size: 12px;
  color: #999;
  border-top: 1px solid #f0f0f0;
}
/* 移除原来的after伪元素边框 */
.time-slot::after {
  display: none;
}
/* .time-slot::after {
  content: '';
  position: absolute;
  left: 50px;
  right: 0;
  top: 0;
  border-top: 1px solid #f0f0f0;
} */

.days-container {
  display: flex;
  flex: 1;
}

.day-column {
  flex: 1;
  border-left: 1px solid #f0f0f0;
  position: relative;
}

.day-column:last-child {
  border-right: 1px solid #f0f0f0;
}

.day-header-week {
  text-align: center;
  border-bottom: 1px solid #f0f0f0;
  height: 60px; /* 固定头部高度 */
}

.day-name {
  font-size: 14px;
  color: #666;
}

.day-date {
  font-size: 16px;
  font-weight: 500;
  margin-top: 5px;
}

.day-date.today {
  font-size: 14px;
  display: inline-block;
  width: 21px;
  height: 21px;
  line-height: 21px;
  border-radius: 50%;
  background: #0e7cff;
  color: white;
}
/* 周视图事件容器增加相对定位 */
.events-week {
  position: relative;
  height: 2880px;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0,
    transparent 119px,
    #f0f4f9 119px,
    #f0f4f9 120px
  );
}

.week-event {
  position: absolute;
  background: #e6f3ff;
  border-left: 3px solid #0e7cff;
  border-radius: 4px;
  padding: 4px 6px;
  overflow: hidden;
  font-size: 12px;
  cursor: pointer;
  transition: all 0.2s;
  box-sizing: border-box;
}
.week-event:hover {
  background: #d0e7ff;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* 短事件特殊样式 */
.week-event.short-event {
  min-height: 60px; /* 半小时事件的最小高度 */
  display: flex;
  flex-direction: column;
  justify-content: center;
}
/* 多列事件 */
.week-event.multi-column {
  background: #d4e7ff;
  border-left: 3px solid #0a68d4;
}
/* 重叠事件标记 */
.event-badge {
  position: absolute;
  top: 4px;
  right: 4px;
  background: rgba(255, 255, 255, 0.8);
  border-radius: 50%;
  width: 18px;
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  font-weight: bold;
  color: #0a68d4;
}
.event-time {
  font-size: 11px;
  color: #666;
}
/* 原有样式保持不变,新增以下样式 */

/* 跨天事件指示器 */
.ongoing-event-indicator {
  position: absolute;
  pointer-events: none;
  z-index: 1;
}

.ongoing-event-line {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background-color: rgba(14, 124, 255, 0.2);
  margin-left: 2px;
}

/* 跨天事件样式 */
.week-event.cross-day {
  background: linear-gradient(45deg, #e6f3ff 0%, #d4e7ff 100%);
  border-left: 3px solid #0a68d4;
}

.cross-day-badge {
  position: absolute;
  bottom: 2px;
  right: 2px;
  background: rgba(10, 104, 212, 0.9);
  color: white;
  font-size: 10px;
  padding: 1px 4px;
  border-radius: 3px;
}

/* 调整事件时间样式 */
.event-time {
  font-size: 11px;
  color: #666;
}

@keyframes modalIn {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* 响应式设计 */
@media (max-width: 768px) {
  .calendar-toolbar {
    flex-direction: column;
    gap: 12px;
  }
  .week-view {
    height: 500px;
    overflow-y: auto;
  }
  .events-week {
    height: 1440px; /* 移动设备上恢复较小高度 */
  }
  .time-slot {
    height: 60px; /* 移动设备上恢复较小高度 */
  }
  .view-switcher {
    width: 100%;
    justify-content: center;
  }

  .nav-controls {
    width: 100%;
    justify-content: space-between;
  }

  .days-grid {
    min-height: 400px;
  }

  .day-cell {
    min-height: 70px;
  }
}

/* 加载动画 */
.loading {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 40px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid rgba(14, 124, 255, 0.2);
  border-top: 3px solid #0e7cff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
.custom-scrollbar::-webkit-scrollbar {
  width: 8px;
}

.custom-scrollbar::-webkit-scrollbar-track {
  background: transparent;
}

.custom-scrollbar::-webkit-scrollbar-thumb {
  background: #d1d5db;
  border-radius: 4px;
}

.custom-scrollbar::-webkit-scrollbar-thumb:hover {
  background: #9ca3af;
}

.custom-scrollbar {
  scrollbar-width: thin;
  scrollbar-color: #d1d5db transparent;
}
</style>

popupComponent

<template>
  <div class="popup-container">
    <!-- 触发元素 -->
    <slot name="trigger" :open="openPopup">
      <button class="default-trigger" @click="openPopup">点击触发弹出层</button>
    </slot>
    <!-- 弹出层 -->
    <transition name="popup-fade">
      <div v-if="isVisible" class="popup-overlay" :style="{ zIndex: computedZIndex }" @click.self="closePopup">
        <div ref="popupContent" class="popup-content" :class="[finalPlacement, customClass]" :style="popupStyle">
          <button class="close-btn" @click="closePopup">&times;</button>
          <div class="popup-body">
            <slot name="content"></slot>
          </div>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'PopupComponent',
  props: {
    // 弹出层位置,支持 'top', 'bottom', 'left', 'right'
    placement: {
      type: String,
      default: 'right',
      validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value),
    },
    // 自定义样式类
    customClass: {
      type: String,
      default: '',
    },
    // 偏移量
    offset: {
      type: Number,
      default: 20,
    },
    // 是否启用自动调整位置
    autoAdjust: {
      type: Boolean,
      default: true,
    },
  },
  data() {
    return {
      isVisible: false,
      computedZIndex: 1000,
      popupStyle: {},
      finalPlacement: this.placement,
    };
  },
  mounted() {
    // 添加全局点击事件监听器
    document.addEventListener('click', this.handleOutsideClick);
  },
  beforeDestroy() {
    // 移除全局点击事件监听器
    document.removeEventListener('click', this.handleOutsideClick);
  },
  methods: {
    openPopup(event) {
      if (!this.isVisible) {
        this.isVisible = true;
        this.finalPlacement = this.placement;
        this.$nextTick(() => {
          this.calculatePosition(event);
          this.calculateZIndex();
        });
      }
    },
    closePopup() {
      this.isVisible = false;
    },
    calculatePosition(event) {
      const triggerEl = event.currentTarget;
      const triggerRect = triggerEl.getBoundingClientRect();
      const popupRect = this.$refs.popupContent.getBoundingClientRect();

      let top, left;
      this.finalPlacement = this.placement;

      // 视口尺寸
      const viewportWidth = window.innerWidth;
      const viewportHeight = window.innerHeight;

      // 初始位置计算
      switch (this.placement) {
        case 'top':
          top = triggerRect.top - popupRect.height - this.offset;
          left = triggerRect.left + (triggerRect.width - popupRect.width) / 2;

          // 如果上方空间不足,自动调整为下方
          if (this.autoAdjust && top < 10) {
            top = triggerRect.bottom + this.offset;
            this.finalPlacement = 'bottom';
          }
          break;

        case 'bottom':
          top = triggerRect.bottom + this.offset;
          left = triggerRect.left + (triggerRect.width - popupRect.width) / 2;

          // 如果下方空间不足,自动调整为上方
          if (this.autoAdjust && top + popupRect.height > viewportHeight - 10) {
            top = triggerRect.top - popupRect.height - this.offset;
            this.finalPlacement = 'top';
          }
          break;

        case 'left':
          top = triggerRect.top + (triggerRect.height - popupRect.height) / 2;
          left = triggerRect.left - popupRect.width - this.offset;

          // 如果左侧空间不足,自动调整为右侧
          if (this.autoAdjust && left < 10) {
            left = triggerRect.right + this.offset;
            this.finalPlacement = 'right';
          }
          break;

        case 'right':
          top = triggerRect.top + (triggerRect.height - popupRect.height) / 2;
          left = triggerRect.right + this.offset;

          // 如果右侧空间不足,自动调整为左侧
          if (this.autoAdjust && left + popupRect.width > viewportWidth - 10) {
            left = triggerRect.left - popupRect.width - this.offset;
            this.finalPlacement = 'left';
          }
          break;

        default:
          top = triggerRect.bottom + this.offset;
          left = triggerRect.left + (triggerRect.width - popupRect.width) / 2;
          this.finalPlacement = 'bottom';
      }

      // 二次边界检查,确保调整后不超出视口
      if (left < 10) left = 10;
      if (left + popupRect.width > viewportWidth - 10) {
        left = viewportWidth - popupRect.width - 10;
      }

      if (top < 10) top = 10;
      if (top + popupRect.height > viewportHeight - 10) {
        top = viewportHeight - popupRect.height - 10;
      }

      this.popupStyle = {
        top: `${top}px`,
        left: `${left}px`,
      };

      // 更新CSS类以反映最终的位置
      this.$refs.popupContent.className = `popup-content ${this.finalPlacement} ${this.customClass}`;
    },
    calculateZIndex() {
      // 计算当前页面最大z-index
      const allElements = document.querySelectorAll('*');
      let maxZIndex = 1000;

      Array.from(allElements).forEach((element) => {
        const zIndex = parseInt(window.getComputedStyle(element).zIndex, 10);
        if (!isNaN(zIndex) && zIndex > maxZIndex) {
          maxZIndex = zIndex;
        }
      });

      this.computedZIndex = maxZIndex + 1;
    },
    handleOutsideClick(event) {
      // 如果点击了弹出层外部,关闭弹出层
      if (this.isVisible && !this.$el.contains(event.target)) {
        this.closePopup();
      }
    },
  },
};
</script>

<style scoped>
/* 样式保持不变,与之前相同 */
.popup-container {
  display: inline-block;
  position: relative;
}

.default-trigger {
  padding: 10px 20px;
  background: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.3s;
}

.default-trigger:hover {
  background: #2980b9;
}

.popup-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: transparent;
  z-index: 1000;
}

.popup-content {
  position: absolute;
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  min-width: 200px;
  animation: popup-appear 0.3s ease;
}

.popup-body {
  padding: 10px;
}

.close-btn {
  position: absolute;
  top: 10px;
  right: 10px;
  background: none;
  border: none;
  font-size: 25px;
  cursor: pointer;
  color: #7f8c8d;
  transition: color 0.3s;
  z-index: 10;
}

/* 箭头样式 */
.popup-content::before {
  content: '';
  position: absolute;
  width: 0;
  height: 0;
  border-style: solid;
}

.popup-content.top::before {
  bottom: -10px;
  left: 50%;
  transform: translateX(-50%);
  border-width: 10px 10px 0 10px;
  border-color: white transparent transparent transparent;
}

.popup-content.bottom::before {
  top: -10px;
  left: 50%;
  transform: translateX(-50%);
  border-width: 0 10px 10px 10px;
  border-color: transparent transparent white transparent;
}

.popup-content.left::before {
  right: -10px;
  top: 50%;
  transform: translateY(-50%);
  border-width: 10px 0 10px 10px;
  border-color: transparent transparent transparent white;
}

.popup-content.right::before {
  left: -10px;
  top: 50%;
  transform: translateY(-50%);
  border-width: 10px 10px 10px 0;
  border-color: transparent white transparent transparent;
}

/* 动画效果 */
.popup-fade-enter-active,
.popup-fade-leave-active {
  transition: opacity 0.3s;
}

.popup-fade-enter,
.popup-fade-leave-to {
  opacity: 0;
}

@keyframes popup-appear {
  from {
    opacity: 0;
    transform: translateY(10px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}
</style>

总结

这个 Vue2 日历组件为企业级应用提供了完整的日程管理解决方案,具有高度可定制性和优秀的用户体验。无论是简单的个人日程管理还是复杂的企业级应用,都能满足需求。 通过合理的组件设计和丰富的 API,开发者可以快速集成到现有项目中,并根据具体业务需求进行深度定制。

零依赖!教你用原生 JS 把 JSON 数组秒变 CSV 文件

作者 技术小丁
2025年10月29日 11:22

一、核心思路

  1. 把 JS 数组拼成「逗号分隔 + 换行」的字符串 → 这就是 CSV 的“文本协议”。
  2. 利用 Blob 把字符串变成文件流。
  3. 创建一个看不见的 <a> 标签,给它一个 download 属性,再自动点一下,浏览器就会弹出保存框。

二、核心代码

1. 准备原始数据

原始数据可以是接口返回,也可以是 mock。

const posts = [
  { id:1, title:'用 Vite 搭建 React 18 项目', link:'...', img:'...', views:12034 },
  // ...
];

2. 定义表头

顺序随意,只要和下面 map 对应即可。

const headers = ['id','名称','链接','图片','阅读'];

3. 拼接数据

const csvContent = [
  headers.join(','), // 第一行:表头
  ...posts.map(item => [ // 剩余行:数据
    `"${item.id}"`,
    `"${item.title}"`,
    `"${item.link}"`,
    `"${item.img}"`,
    `"${item.views}"`
  ].join(','))
].join('\n');

4. 生成文件并下载

const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.href = url;
link.download = `文章信息_${new Date().toISOString()}.csv`;
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);

三、完整代码

// 1. 造点假数据
const posts = [
  {
    id: 1,
    title: '用 Vite 搭建 React 18 项目',
    link: 'https://example.com/vite-react18',
    img: 'https://example.com/cover/vite-react.jpg',
    views: 12034
  },
  {
    id: 2,
    title: 'Tailwind CSS 3 响应式布局技巧',
    link: 'https://example.com/tailwind-layout',
    img: 'https://example.com/cover/tailwind.jpg',
    views: 8721
  },
  {
    id: 3,
    title: '深入浅出浏览器事件循环',
    link: 'https://example.com/event-loop',
    img: 'https://example.com/cover/event-loop.jpg',
    views: 15003
  },
  {
    id: 4,
    title: 'Webpack 5 性能优化清单',
    link: 'https://example.com/webpack5-optimize',
    img: 'https://example.com/cover/webpack.jpg',
    views: 9855
  },
  {
    id: 5,
    title: '前端图片懒加载完整方案',
    link: 'https://example.com/lazy-load',
    img: 'https://example.com/cover/lazy-load.jpg',
    views: 6542
  }
];

// 2. 组装 CSV
const headers = ['id', '名称', '链接', '图片', '阅读'];
const csvContent = [
  headers.join(','),
  ...posts.map(item => [
    `"${item.id}"`,
    `"${item.title}"`,
    `"${item.link}"`,
    `"${item.img}"`,
    `"${item.views}"`
  ].join(','))
].join('\n');

// 3. 下载
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `文章信息_${new Date().toISOString()}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);   // 释放内存

快速体验

  • 打开任意网页,F12 进控制台
  • 把完整代码全部粘进去,回车

图吧工具箱-电脑硬件圈的“瑞士军刀”

作者 非凡ghost
2025年10月29日 10:20

软件定位

  • 全称:图拉丁吧硬件检测工具箱(民间简称“图吧工具箱”)
  • 价格:永久免费,无广告,不开会员
  • 体积:185 MB 左右(Win 版),支持 Windows 7/8/10/11 64 位
  • 内核:收录 CPU-Z、GPU-Z、AIDA64、HWiNFO、FurMark、Prime95、DisplayX 等官方最新版,统一图形菜单,一键调用,无需重复安装

图片

亮点

  1. 全面支持 Intel 13/14 代酷睿 与 NVIDIA RTX 40/50 系显卡 检测
  2. 新增 DDR5 内存、PCIe 5.0 SSD 信息识别
  3. 显存温度压力测试回归,一键烤卡不再闪退 
  4. 工具库持续更新:CPU-Z、GPU-Z、HWiNFO、FurMark2 全部升至最新版
  5. 适配 Windows 12 预览版,高分辨率界面自动缩放

图片

功能分区

硬件信息 CPU-Z / GPU-Z / HWiNFO 处理器、主板、内存、显卡详细参数一次看清
性能测试 FurMark、Prime95、3DMark Demo 显卡烤机、CPU 压力、温度/功耗/频率曲线实时监控
外设检测 DisplayX、Keyboard Test 屏幕坏点、色域、键盘按键触发测试
硬盘工具 CrystalDiskInfo、AS SSD 通电时间、健康度、PCIe 速率、4K 读写跑分
综合检测 AIDA64 工程版 一键生成 40 页硬件报告,买二手电脑防翻车
系统维护 DDU 驱动卸载、Everything 搜索 清旧驱动、秒搜文件,重装系统必备

图片
图片

实用场景

  • 买新机/二手:先跑 HWiNFO + CrystalDiskInfo,看通电时长、电池循环、显卡核心/显存温度,避免矿卡翻新
  • 装系统后:DDU 清旧驱动 → FurMark 20 分钟烤卡 → Prime95 30 分钟烤 CPU,确认散热压得住
  • 升级前规划:主板型号、电源瓦数、PCIe 插槽版本一目了然,防止“i7 配 B660”尴尬

图吧工具箱 = 硬件圈的“瑞士军刀” :买机、验机、烤机、清驱动、查坏点、跑分、写报告,一个安装包全搞定免费、无广告、持续更新,电脑装机必备!

「图吧工具箱2025.07R2安装包.exe」 链接:pan.quark.cn/s/5c68cbac6…

飞书多维表格插件:进一步封装,提升开发效率!🚀

作者 橙某人
2025年10月29日 10:17

写在开头

Hello,各位好呀!今是 2025 年 10 月 26 日。😀

距离上次写文已经是一个多月前了呢,事有点多😋,简洁罗列记录下:

  • 又去爬了几次山,这次不仅广州,也去了周边城市
  • 参加了两次 TRAE 的线下活动
  • 换季中招,感冒了一场
  • 换了些身上的电子设备
  • ...

这一个多月过得还是比较充实的,平衡了工作与生活。💯

然后呢,最近小编在做飞书多维表格插件方面的业务开发,飞书官方提供了 JS SDK 来帮助咱们开发插件:

📖 官方文档:传送门

不过呢,官方 SDK 的方法都比较"原子化",虽然灵活,但写业务时往往需要把多个 API 组合起来才能完成一次具体操作。每次都要写一堆重复代码,有点...痛苦!😭

所以呢,小编基于项目里的真实需求,进一步做了"组合封装",让咱们在写插件时能"一把梭",效率蹭蹭往上涨!希望你能多一些时间出来摸鱼,沸点池里都没鱼了喂 (๑•̀ㅂ•́)و✧。

接下来,是一些方法情况的详情介绍,请诸君按需食用哈。

方法详解 🔧

在实际开发中,咱们经常需要:

  • 🔍 获取表格实例
  • 🏷️ 获取字段 ID
  • 📝 批量添加记录
  • ✅ 确保字段存在
  • 📊 处理大量数据的分批操作
  • 🔄 数据格式转换和映射
  • ...

基于这些常见需求,小编封装了一系列实用方法,让开发更加高效,告别重复造轮子!🎉

本次的封装形式是直接写了一个 bitable.js 的工具文件,使用ESM形式,使用非常简单,需要就直接导出使用即可。

1️⃣ 获取表格实例

功能说明:支持通过表格名称或ID获取表格实例,传入名称时若表格不存在会自动创建。

// 导入飞书多维表格的 JS SDK
import { bitable } from '@lark-base-open/js-sdk';

// 获取基础操作对象和UI操作对象
const base = bitable.base;
const ui = bitable.ui;

/**
 * 获取表格实例
 * @description 支持通过表格名称或ID获取表格实例,传入名称时若表格不存在会自动创建
 * @param {string} tableIdentifier - 表格标识符,可以是表格名称或表格ID
 * @returns {Promise<Object>} 返回表格实例对象
 * @example
 * // 通过表格名称获取(不存在则自动创建)
 * const table = await getTableInstance('我的数据表');
 * 
 * // 通过表格ID获取
 * const table = await getTableInstance('tblxxxxxxxxxxxxxx');
 */
export async function getTableInstance(tableIdentifier) {
  let targetTableId = ""; // 用于存储目标表格的ID

  // 判断传入的参数是表格ID还是表格名称
  // 表格ID通常以'tbl'开头且长度较长,或者长度超过15个字符
  const isTableId = tableIdentifier.length > 10 && (
    tableIdentifier.startsWith('tbl') || 
    tableIdentifier.length > 15
  );

  if (isTableId) {
    // 如果传入的是表格ID,直接通过ID获取表格实例
    const table = await base.getTable(tableIdentifier);
    return table; // 返回表格实例
  } else {
    // 如果传入的是表格名称,需要先尝试获取,失败则创建
    try {
      // 尝试通过名称获取已存在的表格
      const { id } = await base.getTable(tableIdentifier);
      targetTableId = id; // 获取成功,记录表格ID
    } catch (error) {
      // 表格不存在,创建新表格
      const { tableId } = await base.addTable({ name: tableIdentifier });
      targetTableId = tableId; // 记录新创建的表格ID
      await ui.switchToTable(targetTableId); // 自动切换到新创建的表格
    }
    // 通过表格ID获取表格实例并返回
    const table = await base.getTable(targetTableId);
    return table;
  }
}

使用示例:

// 通过“名称”获取(不存在则自动创建)
const table = await getTableInstance('分镜设计表');

// 或通过“ID”获取
const tableById = await getTableInstance('tblxxxxxxxxxxxxxx');

2️⃣ 获取字段ID

功能说明:通过字段名获取字段ID,若字段不存在则自动创建。

import { FieldType } from '@lark-base-open/js-sdk';

/**
 * 获取字段ID
 * @description 通过字段名获取字段ID,若字段不存在则自动创建
 * @param {Object} tableInstance - 表格实例对象
 * @param {string} fieldName - 字段名称
 * @param {FieldType} [fieldType=FieldType.Text] - 字段类型,默认为文本类型
 * @returns {Promise<string>} 返回字段ID
 * @example
 * // 获取文本字段ID(不存在则创建)
 * const fieldId = await getFieldId(tableInstance, '姓名');
 * 
 * // 获取数字字段ID
 * const numberFieldId = await getFieldId(tableInstance, '年龄', FieldType.Number);
 */
export async function getFieldId(tableInstance, fieldName, fieldType = FieldType.Text) {
  try {
    // 尝试获取已存在的字段
    const { id } = await tableInstance.getField(fieldName);
    return id;
  } catch (error) {
    // 字段不存在,创建新字段
    const fieldId = await tableInstance.addField({ type: fieldType, name: fieldName });
    return fieldId;
  }
}

使用示例:

const fieldId = await getFieldId(table, '镜头', FieldType.Text);

3️⃣ 批量添加记录

功能说明:按字段映射关系将对象数组批量写入表格,相比逐条添加效率更高。

/**
 * 批量添加记录到表格
 * @description 按字段映射关系将对象数组批量写入表格,相比逐条添加效率更高
 * @param {Object} tableInstance - 表格实例对象
 * @param {Array<Object>} dataList - 要添加的数据列表,每个元素为一个数据对象
 * @param {Array<Object>} tableFields - 字段映射配置数组
 * @param {string} tableFields[].field_name - 表格中的目标字段名
 * @param {string} tableFields[].field_value - 数据对象中的源字段名
 * @returns {Promise<Array<string>>} 返回所有添加记录的ID数组
 * @example
 * const dataList = [
 *   { name: '张三', age: 25, city: '北京' },
 *   { name: '李四', age: 30, city: '上海' }
 * ];
 * const tableFields = [
 *   { field_name: '姓名', field_value: 'name' },
 *   { field_name: '年龄', field_value: 'age' },
 *   { field_name: '城市', field_value: 'city' }
 * ];
 * const recordIds = await addRecordsToTable(table, dataList, tableFields);
 */
export async function addRecordsToTable(tableInstance, dataList, tableFields) {
  const allRecordIds = [] // 存储所有添加的记录ID
  // 遍历每一行数据
  for (const rowData of dataList) {
    const textCellList = [] // 存储当前行的所有单元格
    // 根据字段映射创建单元格
    for (const item of tableFields) {
      const fieldName = item.field_name // 目标字段名
      const fieldValue = item.field_value // 数据源字段名
      const textField = await tableInstance.getField(fieldName); // 获取字段实例
      const cellValue = rowData[fieldValue] || ''; // 获取单元格值
      const textCell = await textField.createCell(cellValue); // 创建单元格
      textCellList.push(textCell) // 添加到单元格列表
    }
    // 将当前行的所有单元格添加为一条记录
    const recordIds = await tableInstance.addRecords(textCellList);
    allRecordIds.push(...recordIds) // 收集记录ID
  }
  return allRecordIds // 返回所有记录ID
}

映射配置示例:

const tableFields = [
  { field_name: '镜头', field_value: 'shot' },
  { field_name: '场景类型', field_value: 'sceneType' },
  { field_name: '时长', field_value: 'duration' },
  { field_name: '内容', field_value: 'content' },
  { field_name: '对话', field_value: 'dialogue' }
];

const dataList = [
  { shot: '第1镜', sceneType: '室内', duration: '30秒', content: '主角进入房间', dialogue: '你好,我回来了' },
  { shot: '第2镜', sceneType: '室外', duration: '45秒', content: '街道场景', dialogue: '再见' }
];

await addRecordsToTable(table, dataList, tableFields);

4️⃣ 确保字段存在

功能说明:自动检查并创建缺失的字段,避免因字段不存在导致的数据写入失败。

/**
 * 确保字段存在,不存在则自动创建
 * @description 检查表格中是否存在指定字段,如不存在则按指定类型创建。对于单选字段,会自动添加选项
 * @param {Object} tableInstance - 表格实例对象
 * @param {Array<Object>} fieldConfigs - 字段配置数组
 * @param {string} fieldConfigs[].name - 字段名称
 * @param {FieldType} fieldConfigs[].type - 字段类型
 * @param {Object} fieldConfigs[].property - 字段属性配置(可选)
 *   - 对于单选字段:property.options 为 { name: string, color?: number }[] 格式
 *   - 对于其他字段:根据字段类型传入相应的配置对象
 * @returns {Promise<Array<Object>>} 返回所有字段实例数组
 * @example
 * const fieldConfigs = [
 *   { name: '姓名', type: FieldType.Text },
 *   { name: '年龄', type: FieldType.Number },
 *   { 
 *     name: '状态', 
 *     type: FieldType.SingleSelect, 
 *     property: { 
 *       options: [
 *         { name: '进行中', color: 0 },
 *         { name: '已完成', color: 1 }
 *       ]
 *     }
 *   }
 * ];
 * const fields = await ensureFieldsExist(table, fieldConfigs);
 */
export async function ensureFieldsExist(tableInstance, fieldConfigs) {
  const fieldInstances = []
  
  for (const config of fieldConfigs) {
    try {
      // 尝试获取已存在的字段
      const existingField = await tableInstance.getField(config.name)
      fieldInstances.push(existingField)
    } catch (error) {
      // 字段不存在,创建新字段
      const newField = await tableInstance.addField({
        type: config.type,
        name: config.name,
        property: config.property || {}
      })
      // 如果是单选字段且有选项配置,添加选项
      if (config.type === FieldType.SingleSelect && config.property?.options && Array.isArray(config.property.options)) {
        await newField.addOptions(config.property.options)
      }
      fieldInstances.push(newField)
    }
  }
  
  return fieldInstances
}

使用示例:

import { FieldType } from '@lark-base-open/js-sdk';

const fieldConfigs = [
  { name: '项目名称', type: FieldType.Text },
  { 
    name: '优先级', 
    type: FieldType.SingleSelect, 
    property: { 
      options: [
        { name: '高', color: 0 },
        { name: '中', color: 1 },
        { name: '低', color: 2 }
      ]
    }
  },
  { name: '完成度', type: FieldType.Number },
  { name: '截止日期', type: FieldType.DateTime }
];

const fields = await ensureFieldsExist(table, fieldConfigs);
console.log('所有字段准备就绪!');

5️⃣ 分批处理大量数据

功能说明:将大量数据分批处理,避免一次性操作过多数据导致的接口超时或性能问题。

/**
 * 分批添加记录到表格
 * @description 将大量数据分批处理,避免一次性操作过多数据导致超时
 * @param {Object} tableInstance - 表格实例对象
 * @param {Array<Object>} dataList - 要添加的数据列表
 * @param {Array<Object>} tableFields - 字段映射配置数组
 * @param {Object} options - 配置选项
 * @param {number} options.batchSize - 每批处理的记录数,默认50
 * @param {number} options.delay - 批次间延迟时间(毫秒),默认100
 * @param {Function} options.onProgress - 进度回调函数
 * @returns {Promise<Array<string>>} 返回所有添加记录的ID数组
 * @example
 * const recordIds = await chunkedAddRecords(table, dataList, tableFields, {
 *   batchSize: 30,
 *   delay: 200,
 *   onProgress: (current, total) => console.log(`进度: ${current}/${total}`)
 * });
 */
export async function chunkedAddRecords(tableInstance, dataList, tableFields, options = {}) {
  const { batchSize = 50, delay = 100, onProgress } = options
  const allRecordIds = []
  const totalBatches = Math.ceil(dataList.length / batchSize)
  
  for (let i = 0; i < dataList.length; i += batchSize) {
    const batch = dataList.slice(i, i + batchSize)
    const currentBatch = Math.floor(i / batchSize) + 1
    
    // 处理当前批次
    const batchRecordIds = await addRecordsToTable(tableInstance, batch, tableFields)
    allRecordIds.push(...batchRecordIds)
    
    // 进度回调
    if (onProgress) {
      onProgress(i + batch.length, dataList.length)
    }
    
    // 批次间延迟,避免接口压力过大
    if (i + batchSize < dataList.length && delay > 0) {
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
  
  return allRecordIds
}

使用示例:

// 处理大量数据(比如1000条记录)
const largeDataList = [...]; // 1000条数据
const tableFields = [
  { field_name: '姓名', field_value: 'name' },
  { field_name: '部门', field_value: 'department' }
];

const recordIds = await chunkedAddRecords(table, largeDataList, tableFields, {
  batchSize: 30,        // 每批30条
  delay: 200,           // 批次间延迟200ms
  onProgress: (current, total) => {
    const percent = Math.round((current / total) * 100);
    console.log(`导入进度: ${percent}% (${current}/${total})`);
  }
});

6️⃣ 文本解析转对象数组

功能说明:将制表符分隔的文本(如从Excel复制的数据)解析为对象数组,支持自动识别表头和数据行,是数据导入的核心工具。

/**
 * 将制表符分隔的文本解析为对象数组
 * @description 解析从Excel或表格复制的制表符分隔文本,第一行作为表头
 * @param {string} text - 制表符分隔的文本内容
 * @param {Object} options - 解析选项
 * @param {string} options.delimiter - 分隔符,默认为制表符
 * @param {boolean} options.hasHeader - 是否包含表头,默认true
 * @param {boolean} options.trimValues - 是否去除值的首尾空格,默认true
 * @returns {Array<Object>} 解析后的对象数组
 * @example
 * const text = `姓名\t年龄\t城市\n张三\t25\t北京\n李四\t30\t上海`;
 * const objects = parseTabularTextToObjects(text);
 * // 返回: [
 * //   { 姓名: '张三', 年龄: '25', 城市: '北京' },
 * //   { 姓名: '李四', 年龄: '30', 城市: '上海' }
 * // ]
 */
export function parseTabularTextToObjects(text, options = {}) {
  const { delimiter = '\t', hasHeader = true, trimValues = true } = options
  
  if (!text || typeof text !== 'string') {
    return []
  }
  
  // 按行分割文本
  const lines = text.split('\n').filter(line => line.trim())
  
  if (lines.length === 0) {
    return []
  }
  
  // 获取表头
  const headers = lines[0].split(delimiter).map(header => 
    trimValues ? header.trim() : header
  )
  
  if (!hasHeader) {
    // 如果没有表头,使用列索引作为键名
    headers = headers.map((_, index) => `column_${index}`)
  }
  
  // 解析数据行
  const dataLines = hasHeader ? lines.slice(1) : lines
  const objects = []
  
  for (let i = 0; i < dataLines.length; i++) {
    const values = dataLines[i].split(delimiter)
    const obj = {}
    
    headers.forEach((header, index) => {
      const value = values[index] || ''
      obj[header] = trimValues ? value.trim() : value
    })
    
    objects.push(obj)
  }
  
  return objects
}

使用示例:

// 从剪贴板获取的文本
const clipboardText = `项目名称负责人状态优先级
网站重构张三进行中高
移动端开发李四已完成中
数据分析王五待开始低`;

// 解析文本
const projects = parseTabularTextToObjects(clipboardText);
console.log('解析结果:', projects);
// 输出:[
//   { 项目名称: '网站重构', 负责人: '张三', 状态: '进行中', 优先级: '高' },
//   { 项目名称: '移动端开发', 负责人: '李四', 状态: '已完成', 优先级: '中' },
//   { 项目名称: '数据分析', 负责人: '王五', 状态: '待开始', 优先级: '低' }
// ]

// 自定义分隔符解析CSV
const csvText = `姓名,年龄,部门\n张三,25,技术部\n李四,30,产品部`;
const employees = parseTabularTextToObjects(csvText, { delimiter: ',' });

7️⃣ 条件查询记录

功能说明:根据指定条件查询表格记录,支持多字段组合查询、模糊匹配等,是数据筛选和分析的基础工具。

/**
 * 根据条件查询表格记录
 * @description 支持多字段条件查询,可进行精确匹配或模糊匹配
 * @param {Object} tableInstance - 表格实例对象
 * @param {Array<Object>} conditions - 查询条件数组
 * @param {string} conditions[].fieldName - 字段名称
 * @param {any} conditions[].value - 查询值
 * @param {string} conditions[].operator - 操作符:'equals'(精确)、'contains'(包含)、'startsWith'(开头)、'endsWith'(结尾)
 * @param {string} logic - 条件间逻辑关系:'AND' 或 'OR',默认'AND'
 * @returns {Promise<Array<Object>>} 返回符合条件的记录数组
 * @example
 * const conditions = [
 *   { fieldName: '状态', value: '进行中', operator: 'equals' },
 *   { fieldName: '负责人', value: '张', operator: 'contains' }
 * ];
 * const records = await queryRecordsByConditions(table, conditions, 'AND');
 */
export async function queryRecordsByConditions(tableInstance, conditions, logic = 'AND') {
  try {
    // 获取所有记录
    const recordList = await tableInstance.getRecords({
      pageSize: 5000  // 获取足够多的记录
    })
    
    const matchedRecords = []
    
    for (const record of recordList.records) {
      let isMatch = logic === 'AND' ? true : false
      
      for (const condition of conditions) {
        const { fieldName, value, operator = 'equals' } = condition
        
        // 获取字段值
        const field = await tableInstance.getField(fieldName)
        const cellValue = await record.getCellValueString(field.id)
        
        // 执行匹配检查
        let conditionMatch = false
        switch (operator) {
          case 'equals':
            conditionMatch = cellValue === String(value)
            break
          case 'contains':
            conditionMatch = cellValue.includes(String(value))
            break
          case 'startsWith':
            conditionMatch = cellValue.startsWith(String(value))
            break
          case 'endsWith':
            conditionMatch = cellValue.endsWith(String(value))
            break
          default:
            conditionMatch = cellValue === String(value)
        }
        
        // 根据逻辑关系更新匹配状态
        if (logic === 'AND') {
          isMatch = isMatch && conditionMatch
          if (!isMatch) break  // AND逻辑下,一个不匹配就可以跳出
        } else {
          isMatch = isMatch || conditionMatch
          if (isMatch) break   // OR逻辑下,一个匹配就可以跳出
        }
      }
      
      if (isMatch) {
        // 构建记录对象,包含所有字段值
        const recordData = { recordId: record.recordId }
        const fieldMetaList = await tableInstance.getFieldMetaList()
        
        for (const fieldMeta of fieldMetaList) {
          const cellValue = await record.getCellValueString(fieldMeta.id)
          recordData[fieldMeta.name] = cellValue
        }
        
        matchedRecords.push(recordData)
      }
    }
    
    return matchedRecords
    
  } catch (error) {
    throw error
  }
}

使用示例:

// 查询状态为"进行中"且负责人包含"张"的记录
const conditions = [
  { fieldName: '状态', value: '进行中', operator: 'equals' },
  { fieldName: '负责人', value: '张', operator: 'contains' }
];

const records = await queryRecordsByConditions(table, conditions, 'AND');
console.log('查询结果:', records);

// 查询优先级为"高"或"紧急"的记录
const urgentConditions = [
  { fieldName: '优先级', value: '高', operator: 'equals' },
  { fieldName: '优先级', value: '紧急', operator: 'equals' }
];

const urgentRecords = await queryRecordsByConditions(table, urgentConditions, 'OR');

8️⃣ 批量删除记录

功能说明:根据条件批量删除表格记录,支持条件筛选删除和记录ID列表删除,操作前会进行安全确认。

/**
 * 批量删除表格记录
 * @description 根据条件或记录ID列表批量删除记录,支持安全确认
 * @param {Object} tableInstance - 表格实例对象
 * @param {Object} options - 删除选项
 * @param {Array<string>} options.recordIds - 要删除的记录ID数组(优先使用)
 * @param {Array<Object>} options.conditions - 删除条件数组(当recordIds为空时使用)
 * @param {boolean} options.confirm - 是否需要确认,默认true
 * @param {boolean} options.dryRun - 是否为试运行(只查询不删除),默认false
 * @returns {Promise<Object>} 返回删除结果统计
 * @example
 * // 按记录ID删除
 * const result = await batchDeleteRecords(table, {
 *   recordIds: ['rec123', 'rec456'],
 *   confirm: false
 * });
 * 
 * // 按条件删除
 * const result = await batchDeleteRecords(table, {
 *   conditions: [{ fieldName: '状态', value: '已废弃', operator: 'equals' }],
 *   dryRun: true  // 先试运行看看会删除哪些记录
 * });
 */
export async function batchDeleteRecords(tableInstance, options = {}) {
  const { recordIds, conditions, confirm = true, dryRun = false } = options
  
  try {
    let targetRecords = []
    
    // 确定要删除的记录
    if (recordIds && recordIds.length > 0) {
      // 按记录ID删除
      targetRecords = recordIds.map(id => ({ recordId: id }))
    } else if (conditions && conditions.length > 0) {
      // 按条件查询要删除的记录
      const queryResults = await queryRecordsByConditions(tableInstance, conditions)
      targetRecords = queryResults
    } else {
      throw new Error('必须提供 recordIds 或 conditions 参数')
    }
    
    if (targetRecords.length === 0) {
      return { deleted: 0, skipped: 0, errors: 0 }
    }
    
    // 试运行模式
    if (dryRun) {
      targetRecords.forEach((record, index) => {
        console.log(`${index + 1}. 记录ID: ${record.recordId}`)
      })
      return { 
        deleted: 0, 
        skipped: targetRecords.length, 
        errors: 0,
        preview: targetRecords 
      }
    }
    
    // 安全确认
    if (confirm) {
      const confirmMessage = `⚠️ 即将删除 ${targetRecords.length} 条记录,此操作不可撤销!确认继续吗?`
      console.warn(confirmMessage)
      // TODO:在实际应用中,这里应该弹出确认对话框
    }
    
    // 执行删除
    const deleteResults = { deleted: 0, skipped: 0, errors: 0 }
    
    // 分批删除,避免一次删除过多
    const batchSize = 50
    for (let i = 0; i < targetRecords.length; i += batchSize) {
      const batch = targetRecords.slice(i, i + batchSize)
      const batchIds = batch.map(record => record.recordId)
      
      try {
        await tableInstance.deleteRecords(batchIds)
        deleteResults.deleted += batchIds.length
      } catch (error) {
        deleteResults.errors += batchIds.length
      }
    }
    
    return deleteResults
  } catch (error) {
    throw error
  }
}

使用示例:

// 删除所有状态为"已废弃"的记录(先试运行)
const previewResult = await batchDeleteRecords(table, {
  conditions: [{ fieldName: '状态', value: '已废弃', operator: 'equals' }],
  dryRun: true
});
console.log('预览删除结果:', previewResult);

// 确认后执行删除
if (previewResult.preview.length > 0) {
  const deleteResult = await batchDeleteRecords(table, {
    conditions: [{ fieldName: '状态', value: '已废弃', operator: 'equals' }],
    confirm: false  // 已经预览过了,跳过确认
  });
}

// 按记录ID删除
const specificIds = ['rec123', 'rec456', 'rec789'];
await batchDeleteRecords(table, {
  recordIds: specificIds,
  confirm: false
});

9️⃣ 数据导出功能

功能说明:将表格数据导出为多种格式(JSON、CSV、TSV、Excel),支持字段筛选、条件过滤和格式化选项。

/**
 * 导出表格数据
 * @description 将表格数据导出为指定格式,支持字段筛选和条件过滤
 * @param {Object} tableInstance - 表格实例对象
 * @param {Object} options - 导出选项
 * @param {string} options.format - 导出格式:'json'、'csv'、'tsv'(制表符分隔)、'excel'(.xlsx格式),默认'json'
 * @param {Array<string>} options.fields - 要导出的字段名数组,为空则导出所有字段
 * @param {Array<Object>} options.conditions - 过滤条件数组,为空则导出所有记录
 * @param {boolean} options.includeHeader - CSV/TSV/Excel格式是否包含表头,默认true
 * @param {string} options.filename - 导出文件名(不含扩展名)
 * @param {string} options.sheetName - Excel工作表名称,默认'Sheet1'
 * @returns {Promise<Object>} 返回导出结果,包含数据和下载链接
 * @example
 * // 导出为JSON格式
 * const result = await exportTableData(table, {
 *   format: 'json',
 *   fields: ['姓名', '部门', '状态'],
 *   filename: '员工数据'
 * });
 * 
 * // 导出符合条件的记录为CSV
 * const result = await exportTableData(table, {
 *   format: 'csv',
 *   conditions: [{ fieldName: '状态', value: '在职', operator: 'equals' }],
 *   filename: '在职员工'
 * });
 * 
 * // 导出为Excel格式
 * const excelResult = await exportTableData(table, {
 *   format: 'excel',
 *   fields: ['姓名', '部门', '状态'],
 *   filename: '员工数据',
 *   sheetName: '员工信息'
 * });
 */
export async function exportTableData(tableInstance, options = {}) {
  const { 
    format = 'json', 
    fields = [], 
    conditions = [], 
    includeHeader = true,
    filename = 'export_data',
    sheetName = 'Sheet1'
  } = options
  
  try {
    // 获取要导出的记录
    let records = []
    if (conditions.length > 0) {
      records = await queryRecordsByConditions(tableInstance, conditions)
    } else {
      const recordList = await tableInstance.getRecords({ pageSize: 5000 })
      
      // 获取所有字段信息
      const fieldMetaList = await tableInstance.getFieldMetaList()
      
      for (const record of recordList.records) {
        const recordData = { recordId: record.recordId }
        
        for (const fieldMeta of fieldMetaList) {
          const cellValue = await record.getCellValueString(fieldMeta.id)
          recordData[fieldMeta.name] = cellValue
        }
        
        records.push(recordData)
      }
    }
    
    if (records.length === 0) {
      return { success: false, message: '没有数据可导出' }
    }
    
    // 确定要导出的字段
    let exportFields = fields
    if (exportFields.length === 0) {
      // 如果没有指定字段,导出所有字段(除了recordId)
      exportFields = Object.keys(records[0]).filter(key => key !== 'recordId')
    }
    
    // 过滤记录,只保留指定字段
    const filteredRecords = records.map(record => {
      const filteredRecord = {}
      exportFields.forEach(field => {
        filteredRecord[field] = record[field] || ''
      })
      return filteredRecord
    })
    
    // 根据格式生成导出内容
    let exportContent = ''
    let mimeType = 'text/plain'
    let fileExtension = 'txt'
    
    switch (format.toLowerCase()) {
      case 'json':
        exportContent = JSON.stringify(filteredRecords, null, 2)
        mimeType = 'application/json'
        fileExtension = 'json'
        break
        
      case 'csv':
        exportContent = convertToCSV(filteredRecords, exportFields, includeHeader)
        mimeType = 'text/csv'
        fileExtension = 'csv'
        break
        
      case 'tsv':
        exportContent = convertToTSV(filteredRecords, exportFields, includeHeader)
        mimeType = 'text/tab-separated-values'
        fileExtension = 'tsv'
        break
        
      case 'excel':
        exportContent = convertToExcel(filteredRecords, exportFields, includeHeader, sheetName)
        mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
        fileExtension = 'xlsx'
        break
        
      default:
        throw new Error(`不支持的导出格式: ${format}`)
    }
    
    // 创建下载链接
    const blob = new Blob([exportContent], { type: mimeType })
    const downloadUrl = URL.createObjectURL(blob)
    const fullFilename = `${filename}.${fileExtension}`
    
    return {
      success: true,
      data: filteredRecords,
      content: exportContent,
      downloadUrl: downloadUrl,
      filename: fullFilename,
      recordCount: filteredRecords.length,
      fieldCount: exportFields.length
    }
    
  } catch (error) {
    throw error
  }
}

// 辅助函数:转换为CSV格式
function convertToCSV(records, fields, includeHeader) {
  const lines = []
  
  // 添加表头
  if (includeHeader) {
    lines.push(fields.map(field => `"${field}"`).join(','))
  }
  
  // 添加数据行
  records.forEach(record => {
    const values = fields.map(field => {
      const value = record[field] || ''
      // TODO: CSV格式需要转义双引号
      // return `"${String(value).replace(/"/g, '""')}"`
    })
    lines.push(values.join(','))
  })
  
  return lines.join('\n')
}

// 辅助函数:转换为TSV格式
function convertToTSV(records, fields, includeHeader) {
  const lines = []
  
  // 添加表头
  if (includeHeader) {
    lines.push(fields.join('\t'))
  }
  
  // 添加数据行
  records.forEach(record => {
    const values = fields.map(field => {
      const value = record[field] || ''
      // TSV格式需要转义制表符和换行符
      return String(value).replace(/\t/g, ' ').replace(/\n/g, ' ')
    })
    lines.push(values.join('\t'))
  })
  
  return lines.join('\n')
}

// 辅助函数:转换为Excel格式(.xlsx);前端导出Excel可以使用一些现成的库,这里小编由于某些原因只能使用JS来完成,所以让AI用纯JS撸了一个来满足需求。。。
function convertToExcel(records, fields, includeHeader, sheetName = 'Sheet1') {
  // Excel文件的基本XML结构
  const xmlHeader = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
  
  // 构建工作表数据
  let sheetData = ''
  let rowIndex = 1
  
  // 添加表头
  if (includeHeader) {
    sheetData += `<row r="${rowIndex}">`
    fields.forEach((field, colIndex) => {
      const cellRef = getCellReference(rowIndex, colIndex + 1)
      sheetData += `<c r="${cellRef}" t="inlineStr"><is><t>${escapeXml(field)}</t></is></c>`
    })
    sheetData += '</row>'
    rowIndex++
  }
  
  // 添加数据行
  records.forEach(record => {
    sheetData += `<row r="${rowIndex}">`
    fields.forEach((field, colIndex) => {
      const cellRef = getCellReference(rowIndex, colIndex + 1)
      const value = record[field] || ''
      const cellValue = String(value)
      
      // 判断是否为数字
      const isNumber = !isNaN(cellValue) && !isNaN(parseFloat(cellValue)) && cellValue.trim() !== ''
      
      if (isNumber) {
        sheetData += `<c r="${cellRef}"><v>${cellValue}</v></c>`
      } else {
        sheetData += `<c r="${cellRef}" t="inlineStr"><is><t>${escapeXml(cellValue)}</t></is></c>`
      }
    })
    sheetData += '</row>'
    rowIndex++
  })
  
  // 完整的工作表XML
  const worksheet = `${xmlHeader}
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
  <sheetData>
    ${sheetData}
  </sheetData>
</worksheet>`
  
  // 创建ZIP文件结构(简化版Excel文件)
  const zipContent = createExcelZip(worksheet, sheetName)
  
  return zipContent
}

// 辅助函数:获取Excel单元格引用(如A1, B2等)
function getCellReference(row, col) {
  let colName = ''
  while (col > 0) {
    col--
    colName = String.fromCharCode(65 + (col % 26)) + colName
    col = Math.floor(col / 26)
  }
  return colName + row
}

// 辅助函数:转义XML特殊字符
function escapeXml(text) {
  return String(text)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}

// 辅助函数:创建Excel ZIP文件结构
function createExcelZip(worksheet, sheetName) {
  // 这里使用简化的方法,实际上Excel文件是一个ZIP包含多个XML文件
  // 为了不使用外部库,我们创建一个包含基本结构的XML文件
  // 注意:这是一个简化版本,可能不被所有Excel版本完全支持
  
  const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`

  const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
  <sheets>
    <sheet name="${escapeXml(sheetName)}" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
  </sheets>
</workbook>`

  // 由于不使用外部库,我们返回一个包含所有必要信息的数据结构
  // 实际使用时,这需要被正确地打包成ZIP格式
  // 这里我们返回一个特殊格式的字符串,包含所有必要的Excel文件内容
  return JSON.stringify({
    '[Content_Types].xml': contentTypes,
    'xl/workbook.xml': workbook,
    'xl/worksheets/sheet1.xml': worksheet,
    '_format': 'excel-json' // 标识这是Excel JSON格式
  })
}

使用示例:

// 导出所有数据为JSON格式
const jsonResult = await exportTableData(table, {
  format: 'json',
  filename: '完整数据'
});

// 导出指定字段为CSV格式
const csvResult = await exportTableData(table, {
  format: 'csv',
  fields: ['姓名', '部门', '入职日期'],
  filename: '员工基本信息'
});

// 导出符合条件的记录
const filteredResult = await exportTableData(table, {
  format: 'tsv',
  conditions: [
    { fieldName: '状态', value: '在职', operator: 'equals' },
    { fieldName: '部门', value: '技术', operator: 'contains' }
  ],
  filename: '技术部在职员工'
});

// 导出为Excel格式
const excelResult = await exportTableData(table, {
  format: 'excel',
  fields: ['姓名', '部门', '入职日期', '状态'],
  filename: '员工信息表',
  sheetName: '员工数据'
});

// 触发下载
if (csvResult.success) {
  const link = document.createElement('a');
  link.href = csvResult.downloadUrl;
  link.download = csvResult.filename;
  link.click();
}

// Excel文件下载(需要特殊处理)
if (excelResult.success) {
  // 注意:由于Excel格式的复杂性,实际使用时可能需要额外的处理
  // 这里提供的是基础的XML结构,可以被大多数Excel应用程序识别
  const link = document.createElement('a');
  link.href = excelResult.downloadUrl;
  link.download = excelResult.filename;
  link.click();
}

🔟 智能字段映射写入

功能说明:结合字段检查、数据解析和批量写入的综合解决方案,实现从文本到表格的一站式智能导入,自动处理字段创建和数据类型转换。

/**
 * 智能字段映射写入数据
 * @description 综合解决方案:解析文本 → 检查字段 → 创建缺失字段 → 批量写入数据
 * @param {Object} tableInstance - 表格实例对象
 * @param {string} textData - 要导入的文本数据(制表符分隔)
 * @param {Array<Object>} fieldMappings - 字段映射配置
 * @param {string} fieldMappings[].name - 目标字段名
 * @param {string} fieldMappings[].valueKey - 数据源字段名
 * @param {FieldType} fieldMappings[].type - 字段类型
 * @param {Object} fieldMappings[].property - 字段属性配置(可选)
 * @param {Object} options - 导入选项
 * @param {number} options.batchSize - 批次大小,默认50
 * @param {boolean} options.autoCreateFields - 是否自动创建缺失字段,默认true
 * @param {Function} options.onProgress - 进度回调函数
 * @returns {Promise<Object>} 返回导入结果统计
 * @example
 * const textData = `项目名称\t负责人\t状态\t优先级
 * 网站重构\t张三\t进行中\t高
 * 移动端开发\t李四\t已完成\t中`;
 * 
 * const fieldMappings = [
 *   { name: '项目名称', valueKey: '项目名称', type: FieldType.Text },
 *   { name: '负责人', valueKey: '负责人', type: FieldType.Text },
 *   { 
 *     name: '状态', 
 *     valueKey: '状态', 
 *     type: FieldType.SingleSelect, 
 *     property: { 
 *       options: [
 *         { name: '进行中', color: 0 },
 *         { name: '已完成', color: 1 },
 *         { name: '待开始', color: 2 }
 *       ]
 *     }
 *   },
 *   { 
 *     name: '优先级', 
 *     valueKey: '优先级', 
 *     type: FieldType.SingleSelect, 
 *     property: { 
 *       options: [
 *         { name: '高', color: 0 },
 *         { name: '中', color: 1 },
 *         { name: '低', color: 2 }
 *       ]
 *     }
 *   }
 * ];
 * 
 * const result = await smartFieldMappingImport(table, textData, fieldMappings, {
 *   batchSize: 30,
 *   onProgress: (current, total) => console.log(`导入进度: ${current}/${total}`)
 * });
 */
export async function smartFieldMappingImport(tableInstance, textData, fieldMappings, options = {}) {
  const { 
    batchSize = 50, 
    autoCreateFields = true, 
    onProgress 
  } = options
  
  try {
    // 第1步:解析文本数据
    const parsedData = parseTabularTextToObjects(textData)
    if (parsedData.length === 0) {
      throw new Error('没有解析到有效数据')
    }
    // 第2步:检查并创建字段
    if (autoCreateFields) {
      const fieldConfigs = fieldMappings.map(mapping => ({
        name: mapping.name,
        type: mapping.type,
        property: mapping.property
      }))
      await ensureFieldsExist(tableInstance, fieldConfigs)
    }
    // 第3步:准备字段映射配置
    const tableFields = fieldMappings.map(mapping => ({
      field_name: mapping.name,
      field_value: mapping.valueKey
    }))
    // 第4步:批量写入数据
    const recordIds = await chunkedAddRecords(tableInstance, parsedData, tableFields, {
      batchSize,
      onProgress
    })
    // 第5步:生成导入报告
    const importResult = {
      success: true,
      totalRecords: parsedData.length,
      importedRecords: recordIds.length,
      failedRecords: parsedData.length - recordIds.length,
      fieldCount: fieldMappings.length,
      recordIds: recordIds,
      summary: {
        parseTime: new Date().toISOString(),
        fieldsCreated: autoCreateFields ? fieldMappings.length : 0,
        batchSize: batchSize
      }
    }
    return importResult
  } catch (error) {
    return {
      success: false,
      error: error.message,
      totalRecords: 0,
      importedRecords: 0,
      failedRecords: 0
    }
  }
}

使用示例:

import { FieldType } from '@lark-base-open/js-sdk';

// 从剪贴板或文件获取的数据
const projectData = `项目名称负责人状态优先级开始日期
网站重构张三进行中高2024-01-15
移动端开发李四已完成中2024-01-10
数据分析王五待开始低2024-01-20
API开发赵六进行中高2024-01-12`;

// 定义字段映射和类型
const fieldMappings = [
  { 
    name: '项目名称', 
    valueKey: '项目名称', 
    type: FieldType.Text 
  },
  { 
    name: '负责人', 
    valueKey: '负责人', 
    type: FieldType.Text 
  },
  { 
    name: '状态', 
    valueKey: '状态', 
    type: FieldType.SingleSelect, 
    property: { 
      options: [
        { name: '进行中', color: 0 },
        { name: '已完成', color: 1 },
        { name: '待开始', color: 2 },
        { name: '已暂停', color: 3 }
      ]
    }
  },
  { 
    name: '优先级', 
    valueKey: '优先级', 
    type: FieldType.SingleSelect, 
    property: { 
      options: [
        { name: '高', color: 0 },
        { name: '中', color: 1 },
        { name: '低', color: 2 },
        { name: '紧急', color: 3 }
      ]
    }
  },
  { 
    name: '开始日期', 
    valueKey: '开始日期', 
    type: FieldType.DateTime 
  }
];

// 执行智能导入
const importResult = await smartFieldMappingImport(table, projectData, fieldMappings, {
  batchSize: 20,
  autoCreateFields: true,
  onProgress: (current, total) => {
    const percent = Math.round((current / total) * 100);
  }
});

// 检查导入结果
if (importResult.success) {
  console.log('导入详情:', importResult);
} else {
  console.error('导入失败:', importResult.error);
}

// 一键导入Excel数据示例
async function quickImportFromClipboard() {
  try {
    // 从剪贴板读取数据
    const clipboardText = await navigator.clipboard.readText();
    
    // 自动识别字段并创建映射
    const lines = clipboardText.split('\n');
    const headers = lines[0].split('\t');
    
    const autoFieldMappings = headers.map(header => ({
      name: header.trim(),
      valueKey: header.trim(),
      type: FieldType.Text  // 默认为文本类型,可根据需要调整
    }));
    
    // 执行导入
    const result = await smartFieldMappingImport(table, clipboardText, autoFieldMappings);
  } catch (error) {
    console.error('快速导入失败:', error);
  }
}




至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

Xrecode3(多功能音频转换工具)

作者 非凡ghost
2025年10月29日 10:12

Xrecode3是一款功能强大的音频转换工具,它能够将多种音频格式相互转换,并提供了许多其他的音频处理功能。

软件功能

音频格式转换:支持将大多数常见的音频格式互相转换,包括MP3、FLAC、WAV、AAC、OGG等。
批量转换:可以同时转换多个文件,节省大量时间和精力。
支持多通道的音频:可以处理多通道音频文件,并保存每个通道的独立文件。
提供了高质量的音频编码器:内置了多种高质量音频编解码器,保证转换过程中音质不损失。
支持CUE文件:能够直接读取CUE文件,并根据CUE文件分割音轨。
ID3标签编辑:可以编辑音频文件的ID3标签,包括歌曲标题、艺术家、专辑等信息。
内置音频播放器:可以通过内置音频播放器来预览转换后的音频文件。
提供了音频剪辑功能:可以从音频文件中剪切出所需部分。
支持多核处理器:利用多核处理器的优势,加快转换速度。

软件特点

界面简洁直观:操作简单易懂,即使对音频处理不熟悉的人也能够轻松上手。
多种输出配置:提供了许多输出配置选项,用户可以根据需要自定义输出文件的音频格式、采样率、码率等参数。
高度可定制化:支持设置转换任务的优先级,可以根据需要调整转换的顺序。
快速转换速度:利用了硬件加速和多线程处理技术,大大提高了转换速度。
资源占用少:在转换过程中占用的系统资源较少,运行稳定、流畅。

「Xrecode3(多功能音频转换工具) 」 链接:pan.quark.cn/s/1480b6f1d…

Subtitle Edit(字幕编辑软件) 中文绿色版

作者 非凡ghost
2025年10月29日 09:40

Subtitle Edit 是一款功能强大的免费字幕编辑软件,它支持多种字幕格式,包括 SRT、SSA、ASS、SUB、LRC、TXT 等常用格式,可以实现快速创建、编辑和同步字幕文件。

软件功能
  1. 支持多种字幕格式:SRT、SSA、ASS、SUB、LRC、TXT 等。
  2. 可以实时预览字幕效果,方便编辑和调整字幕。
  3. 支持批量处理字幕文件,快速完成字幕制作任务。
  4. 支持语音识别功能,可以将视频的音频转换成文本字幕。
  5. 支持多种翻译工具,可以进行实时翻译和字幕翻译。
  6. 支持字幕同步功能,可以根据视频的时间轴自动调整字幕时间点。
  7. 支持多种字体和样式选择,可以自定义字幕风格。
软件特点
  1. 界面简洁、操作简单,易于上手。
  2. 支持多种语言界面,包括中文、英文、日文等。
  3. 提供丰富的字幕编辑功能,包括剪切、复制、粘贴、删除、移动等。
  4. 支持多种视频格式,包括 AVI、MP4、MKV、WMV 等。
  5. 支持多种字幕语言,包括中文、英文、法文、西班牙文等。
  6. 提供多种字幕效果,包括字体大小、颜色、样式、阴影、描边等。
  7. 支持字幕翻译功能,可以根据用户需求自动翻译字幕。
    总之,Subtitle Edit 是一款功能强大、易于操作的字幕编辑软件,可以帮助用户快速创建、编辑和同步字幕文件,是制作字幕的不错选择。
中文设置

Options – Choose Language… – 中文简体 – OK。

「Subtitle Edit(字幕编辑软件) v4.0.14 中文绿色版」 链接:pan.quark.cn/s/7c01467e1…

10个JavaScript编程实用技巧

作者 w2sfot
2025年10月29日 09:13

JavaScript编程10个实用技巧

1. 箭头函数简化

// 传统写法
setTimeout(function() {
    console.log('Hello');
}, 1000);

// 箭头函数
setTimeout(() => console.log('Hello'), 1000);

2. 解构赋值

const config = {apiUrl: 'https://api.example.com', timeout: 5000};
const {apiUrl, timeout} = config;

3. 模板字符串

const name = '李四';
console.log(`欢迎 ${name} 使用我们的服务!`);

4. 可选链操作符

// 安全访问嵌套属性
const userCity = user?.address?.city ?? '未知';

5. 空值合并

const port = process.env.PORT ?? 3000;

6. 展开运算符

const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5]; // [1,2,3,4,5]

7. Promise简化

// 使用async/await替代.then()
async function fetchData() {
    try {
        const data = await fetch('/api/data');
        return data.json();
    } catch (error) {
        console.error('请求失败', error);
    }
}

8. 数组方法链式调用

const result = array
    .filter(item => item.active)
    .map(item => item.name)
    .slice(0, 10);

9. 短路求值

// 条件赋值
const isAdmin = user.role === 'admin';
isAdmin && showAdminPanel();

10. 事件委托

// 避免为每个子元素绑定事件
document.getElementById('list').addEventListener('click', (e) => {
    if (e.target.tagName === 'LI') {
        console.log('点击了:', e.target.textContent);
    }
});

掌握这些实用技巧能让你的JavaScript代码更加简洁、高效且易于维护。

另外,值得注意的是:JavaScript代码是公开透明的代码,容易在执行环境中被查看、复制、盗用,如果有重要的代码,建议使用JShaman进行JS代码混淆加密,使代码变的不可读、不可分析,以保护代码安全。

扩展卡片效果:用 Flexbox 和 CSS 过渡打造惊艳交互体验

作者 San30
2025年10月29日 01:07

前言

本文将带你使用纯 CSS 和少量 JavaScript 实现一个流行的扩展卡片效果,探索 Flexbox 布局和 CSS 过渡动画的强大能力。

效果预览

这是一个优雅的图片展示组件,包含多个卡片面板。当用户点击某个面板时,该面板会平滑扩展并显示标题,其他面板则相应收缩。这种交互方式不仅美观,还能有效吸引用户的注意力。

HTML 结构设计

首先,我们来看 HTML 结构的设计:

<div class="container">
    <div class="panel" style="background-image: url('image1.jpg')">
        <h3>Explore The World</h3>
    </div>
    <div class="panel" style="background-image: url('image2.jpg')">
        <h3>Wild Forest</h3>
    </div>
    <!-- 更多面板... -->
</div>

设计思路

  • 使用容器包裹所有面板,便于整体布局控制
  • 每个面板通过内联样式设置背景图片,保持灵活性
  • 标题使用 <h3> 标签,语义化且样式可控
  • 结构简洁,便于维护和扩展

CSS 核心技术解析

1. 基础重置与整体布局

* {
    margin: 0;
    padding: 0;
}

body {
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    height: 100vh;
    overflow: hidden;
}

关键技术点

  • 通配符重置消除浏览器默认样式差异
  • display: flex 让 body 成为弹性容器,便于居中布局
  • height: 100vh 确保布局充满整个视口高度
  • overflow: hidden 防止滚动条出现

2. 弹性容器设置

.container {
    display: flex;
    width: 90vw;
}

这里将容器设置为 Flex 布局,width: 90vw 让容器宽度为视口宽度的 90%,留出适当的边距。

3. 面板基础样式

.panel {
    height: 80vh;
    border-radius: 50px;
    color: #fff;
    cursor: pointer;
    flex: 0.5;
    margin: 10px;
    position: relative;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    transition: all 700ms ease-in;
}

样式解析

  • height: 80vh - 相对视口高度,保持响应式
  • border-radius: 50px - 圆角设计,现代感强
  • flex: 0.5 - 初始状态下每个面板占据较小空间
  • position: relative - 为绝对定位的标题做准备
  • 背景相关属性确保图片完美显示
  • transition: all 700ms ease-in - 平滑的过渡动画

4. 标题样式与动画

.panel h3 {
    font-size: 24px;
    position: absolute;
    left: 20px;
    bottom: 20px;
    margin: 0;
    opacity: 0;
    transition: opacity 300ms ease-in 400ms;
}

动画设计

  • opacity: 0: 初始状态隐藏
  • 独立的过渡动画:opacity 300ms ease-in 400ms
  • 400ms 延迟让面板扩展完成后再显示文字

5. 激活状态样式

.panel.active {
    flex: 5;
}

.panel.active h3 {
    opacity: 1;
}

激活状态时:

  • flex: 5:面板扩展占据更多空间
  • opacity: 1: 标题透明度变为1,平滑显示

6. 响应式设计

@media (max-width: 480px) {
    .container {
        width: 100vw;
    }
    .panel:nth-of-type(4),
    .panel:nth-of-type(5) {
        display: none;
    }
}

移动端适配

  • 小屏幕下隐藏最后两个面板,保持可用性
  • 容器宽度调整为 100%,充分利用空间

JavaScript 交互逻辑

const panels = document.querySelectorAll(".panel");

panels.forEach(function(panel) {
    panel.addEventListener("click", function() {
        panel.classList.toggle("active");
    })
})

交互逻辑

  • 使用事件委托模式,为每个面板添加点击监听
  • classList.toggle() 方法智能切换 active 类
  • 简洁高效,易于理解和维护

CSS Transition 属性深度解析

在这个项目中,我们大量使用了 CSS transition 属性来创建平滑的动画效果。让我们深入了解这个强大的特性:

transition 属性完整语法

transition: property duration timing-function delay;

属性分解说明

  1. transition-property - 指定要过渡的 CSS 属性

    transition-property: all;           /* 所有可过渡属性 */
    transition-property: opacity;       /* 仅透明度 */
    transition-property: transform, opacity; /* 多个属性 */
    
  2. transition-duration - 过渡持续时间

    transition-duration: 700ms;        /* 700毫秒 */
    transition-duration: 1s;           /* 1秒 */
    transition-duration: 0.5s;         /* 0.5秒 */
    
  3. transition-timing-function - 时间函数(动画速度曲线)

    transition-timing-function: ease-in;     /* 慢开始 */
    transition-timing-function: ease-out;    /* 慢结束 */
    transition-timing-function: ease-in-out; /* 慢开始和结束 */
    transition-timing-function: linear;      /* 匀速 */
    transition-timing-function: cubic-bezier(0.1, 0.7, 1.0, 0.1); /* 贝塞尔曲线 */
    
  4. transition-delay - 过渡延迟时间

    transition-delay: 400ms;           /* 400毫秒后开始 */
    transition-delay: 0s;              /* 立即开始(默认) */
    transition-delay: 1s;              /* 1秒后开始 */
    

项目中使用的过渡效果

/* 面板的主要过渡 */
.panel {
    transition: all 700ms ease-in;
    /* 等价于: */
    transition-property: all;
    transition-duration: 700ms;
    transition-timing-function: ease-in;
    transition-delay: 0s;
}

/* 标题的延迟过渡 */
.panel h3 {
    transition: opacity 300ms ease-in 400ms;
    /* 等价于: */
    transition-property: opacity;
    transition-duration: 300ms;
    transition-timing-function: ease-in;
    transition-delay: 400ms;
}

时间函数详解

时间函数控制动画的速度曲线,对动画的"感觉"至关重要:

  • ease-in - 慢开始,逐渐加速(项目中使用的)

    transition-timing-function: ease-in;
    

    适合元素进入场景的动画

  • ease-out - 快速开始,慢结束

    transition-timing-function: ease-out;
    

    适合元素离开场景的动画

  • ease-in-out - 慢开始和慢结束

    transition-timing-function: ease-in-out;
    

    适合需要平滑变化的动画

  • linear - 匀速运动

    transition-timing-function: linear;
    

    机械感强,适合进度条等

  • cubic-bezier - 自定义贝塞尔曲线

    transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
    

    创建弹性、反弹等特殊效果

可过渡的 CSS 属性

并非所有 CSS 属性都可以应用过渡效果,常见的可过渡属性包括:

  • 尺寸相关:width, height, flex-grow, flex-shrink
  • 位置相关:margin, padding, top, left, right, bottom
  • 颜色相关:color, background-color, border-color
  • 视觉效果:opacity, visibility, box-shadow
  • 变换相关:transform 的所有函数

多重过渡

一个元素可以同时应用多个不同的过渡效果:

.element {
    transition: 
        opacity 300ms ease-in,
        transform 500ms ease-out 100ms,
        background-color 200ms linear;
}

弹性布局(Flexbox)深度解析

什么是弹性布局?

Flexbox(弹性盒子布局)是 CSS3 中一种新的布局模式,专门为解决复杂布局而设计。它能够让我们更轻松地创建响应式布局,特别是在一维布局中表现出色。

Flexbox 核心概念

1. 弹性容器和弹性项目

.container {
    display: flex; /* 容器变为弹性容器 */
}
.panel {
    /* 每个 panel 自动成为弹性项目 */
}

2. 主轴和交叉轴

  • 主轴(main axis) :弹性项目主要沿此方向排列
  • 交叉轴(cross axis) :与主轴垂直的方向

Flexbox 容器属性详解

1. flex-direction - 定义主轴方向

.container {
    flex-direction: row;            /* 默认:水平从左到右 */
    flex-direction: row-reverse;    /* 水平从右到左 */
    flex-direction: column;         /* 垂直从上到下 */
    flex-direction: column-reverse; /* 垂直从下到上 */
}

2. justify-content - 主轴对齐方式

.container {
    justify-content: flex-start;    /* 默认:从主轴起点开始 */
    justify-content: flex-end;      /* 从主轴终点开始 */
    justify-content: center;        /* 居中对齐 */
    justify-content: space-between; /* 两端对齐,项目间间隔相等 */
    justify-content: space-around;  /* 每个项目两侧间隔相等 */
    justify-content: space-evenly;  /* 项目间和两端间隔都相等 */
}

3. align-items - 交叉轴对齐方式

.container {
    align-items: stretch;       /* 默认:拉伸填满容器高度 */
    align-items: flex-start;    /* 交叉轴起点对齐 */
    align-items: flex-end;      /* 交叉轴终点对齐 */
    align-items: center;        /* 交叉轴居中对齐 */
    align-items: baseline;      /* 项目的第一行文字基线对齐 */
}

4. flex-wrap - 换行控制

.container {
    flex-wrap: nowrap;      /* 默认:不换行 */
    flex-wrap: wrap;        /* 换行,第一行在上方 */
    flex-wrap: wrap-reverse; /* 换行,第一行在下方 */
}

Flexbox 项目属性详解

1. flex-grow - 放大比例

.panel {
    flex-grow: 0.5; /* 默认0,不放大 */
}
.panel.active {
    flex-grow: 5;   /* 放大5倍 */
}

2. flex-shrink - 缩小比例

.panel {
    flex-shrink: 1; /* 默认1,空间不足时等比例缩小 */
}

3. flex-basis - 项目初始大小

.panel {
    flex-basis: auto; /* 默认auto,基于内容计算 */
    flex-basis: 200px; /* 固定200px */
    flex-basis: 20%;   /* 容器宽度的20% */
}

简写顺序

flex: <grow> <shrink> <basis>

flex: 2 1 300px;   /* grow = 2, shrink = 1, basis = 300px */

项目中 Flexbox 的应用

.container {
    display: flex;          /* 创建弹性容器 */
    width: 90vw;           /* 容器宽度 */
    /* 默认值:flex-direction: row, justify-content: flex-start */
}

.panel {
    flex: 0.5;             /* 初始状态:flex-grow: 0.5 */
    height: 80vh;          /* 固定高度 */
}

.panel.active {
    flex: 5;               /* 激活状态:flex-grow: 5 */
}

布局计算原理

  • 初始状态:所有面板 flex-grow 总和 = 0.5 × 5 = 2.5
  • 激活面板占据:5 ÷ 2.5 = 2/5 的剩余空间
  • 其他面板各占据:0.5 ÷ 2.5 = 1/5 的剩余空间
  1. flex-grow 分配的是剩余空间,不是总空间
  2. 分配比例 = 项目flex-grow ÷ 所有项目flex-grow总和
  3. 最终宽度 = 基础宽度 + 分配的剩余空间

弹性布局的实际应用技巧

1. 居中布局的多种方式

传统方式

.center {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
}

Flexbox 方式

.container {
    display: flex;
    justify-content: center;  /* 水平居中 */
    align-items: center;      /* 垂直居中 */
}

2. 等分布局

.container {
    display: flex;
}
.item {
    flex: 1;  /* 所有项目等分空间 */
}

3. 圣杯布局

.container {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}
.header, .footer {
    flex: 0 0 auto;  /* 不伸缩,基于内容高度 */
}
.content {
    flex: 1;        /* 占据剩余所有空间 */
}

4. 响应式导航

.nav {
    display: flex;
    flex-wrap: wrap;
}
.nav-item {
    flex: 1 0 200px;  /* 最小200px,可伸缩 */
}

Stylus 预处理器详解

项目中提供了 Stylus 版本,展示了 CSS 预处理器的强大功能:

Stylus 是什么?

Stylus 是一种 CSS 预处理器,它扩展了 CSS 的功能,提供了更简洁、更强大的语法,最终编译成标准的 CSS。

安装和使用

# 全局安装 Stylus
npm install -g stylus

# 编译 Stylus 文件为 CSS
stylus style.styl -o style.css

# 监听文件变化自动编译
stylus style.styl -o style.css -w

Stylus 语法特性

1. 简洁的语法(可选花括号、分号和冒号)

/* Stylus 语法 */
.container
  display flex
  width 90vw
  
/* 编译后的 CSS */
.container {
  display: flex;
  width: 90vw;
}

2. 嵌套规则

.container
  display flex
  .panel
    height 80vh
    h3
      font-size 24px

3. & 符号引用父选择器

.panel
  height 80vh
  &.active
    flex 5
  &:hover
    transform scale(1.05)

4. 变量和计算

// 定义变量
primary-color = #fff
panel-height = 80vh

.panel
  color primary-color
  height panel-height
  margin panel-height * 0.1

5. Mixins(混合)

// 定义混合
border-radius(n)
  -webkit-border-radius n
  -moz-border-radius n
  border-radius n

// 使用混合
.panel
  border-radius(50px)

Stylus 优势总结

  • 代码更简洁:减少约 40% 的代码量
  • 可读性更强:清晰的嵌套结构
  • 维护更方便:变量和混合功能
  • 自动化前缀:自动添加浏览器前缀
  1. 减少重绘区域
    • 确保动画元素有自己的复合层
    • 使用 transform: translateZ(0) 开启硬件加速

总结

通过这个扩展卡片项目,我们深入学习了:

  1. Flexbox 弹性布局的完整体系:容器属性、项目属性、主轴交叉轴概念
  2. CSS Transition 的详细配置:属性、时长、时间函数、延迟的完整用法
  3. Stylus 预处理器的强大功能:更简洁、更强大的 CSS 编写方式
  4. 响应式设计的实践:通过媒体查询确保多设备兼容性

弹性布局的核心价值在于它提供了一种更加直观、灵活的布局方式,特别适合构建现代 Web 应用的界面。结合 CSS 过渡动画,我们可以创建出既美观又交互流畅的用户体验。

前端开发的进阶之路在于掌握这些基础技术的深度原理,并能够灵活运用于实际项目中。通过这个项目,你不仅学会了一个酷炫的效果,更重要的是掌握了实现这种效果的核心技术原理。


完整代码已在文章中提供,建议亲手实践并尝试不同的修改。你可以调整 flex-grow 值观察布局变化,修改 transition 参数体验不同的动画效果,尝试用 Stylus 重写 CSS 代码,添加新的交互功能或动画效果,创造出属于你自己的独特效果!

前端别再乱存数据了!这3种存储方案让你的应用快如闪电

2025年10月28日 07:39

你是不是也遇到过这样的场景?

用户刚填完一个超长的表单,不小心刷新了页面,所有数据都没了... 从接口请求的数据,用户每次操作都要重新加载,体验卡成PPT... 应用离线状态下完全无法使用,用户直接流失...

别担心!今天我就带你彻底解决这些问题。看完这篇文章,你将掌握一套完整的数据交互方案,让你的应用在任何网络状态下都能流畅运行。

为什么数据存储这么重要?

想象一下,你去超市购物,每次想买什么东西,都要跑回家查一下购物清单,然后再跑回超市... 这得多累啊!

网页应用也是同样的道理。合理的数据存储就像你的购物清单,把需要的东西记下来,随用随取,效率直接翻倍。

先来看看我们最常用的数据获取方式——Fetch API

Fetch API:现代前端的数据搬运工

Fetch API 是现在最主流的数据请求方式,比老旧的 XMLHttpRequest 好用太多了。它基于 Promise,写起来特别优雅。

// 最基本的 GET 请求
async function fetchUserData(userId) {
  try {
    // 发起请求,等待响应
    const response = await fetch(`https://api.example.com/users/${userId}`);
    
    // 检查响应是否成功(状态码 200-299)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    // 解析 JSON 数据
    const userData = await response.json();
    return userData;
  } catch (error) {
    // 统一的错误处理
    console.error('获取用户数据失败:', error);
    throw error;
  }
}

// 使用示例
fetchUserData(123)
  .then(user => {
    console.log('用户信息:', user);
    // 在这里更新页面显示
  })
  .catch(error => {
    // 显示错误提示给用户
    alert('加载用户信息失败,请重试');
  });

但光会请求数据还不够,聪明的开发者都知道:好的数据要懂得缓存。这就引出了我们的主角——本地存储。

本地存储三剑客:sessionStorage、localStorage、IndexedDB

1. sessionStorage:短暂的记忆

sessionStorage 就像你的短期记忆,页面会话结束时数据就清空了。适合存储一些临时数据。

// 保存表单草稿
function saveFormDraft(formData) {
  // 将对象转换为 JSON 字符串存储
  sessionStorage.setItem('formDraft', JSON.stringify(formData));
  console.log('表单草稿已保存');
}

// 读取表单草稿
function loadFormDraft() {
  const draft = sessionStorage.getItem('formDraft');
  if (draft) {
    // 将 JSON 字符串解析回对象
    return JSON.parse(draft);
  }
  return null;
}

// 清除草稿
function clearFormDraft() {
  sessionStorage.removeItem('formDraft');
  console.log('表单草稿已清除');
}

// 使用示例:页面加载时恢复草稿
window.addEventListener('load', () => {
  const draft = loadFormDraft();
  if (draft) {
    // 用草稿数据填充表单
    document.getElementById('username').value = draft.username || '';
    document.getElementById('email').value = draft.email || '';
    console.log('表单草稿已恢复');
  }
});

// 输入时实时保存
document.getElementById('myForm').addEventListener('input', (event) => {
  const formData = {
    username: document.getElementById('username').value,
    email: document.getElementById('email').value
  };
  saveFormDraft(formData);
});

2. localStorage:持久的仓库

localStorage 是长期存储,除非主动清除,否则数据会一直存在。适合存储用户偏好设置等。

// 用户主题偏好管理
class ThemeManager {
  constructor() {
    this.currentTheme = this.getSavedTheme() || 'light';
    this.applyTheme(this.currentTheme);
  }
  
  // 获取保存的主题
  getSavedTheme() {
    return localStorage.getItem('userTheme');
  }
  
  // 保存主题偏好
  saveTheme(theme) {
    localStorage.setItem('userTheme', theme);
    this.currentTheme = theme;
    console.log(`主题已保存: ${theme}`);
  }
  
  // 应用主题
  applyTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    this.saveTheme(theme);
  }
  
  // 切换主题
  toggleTheme() {
    const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
    this.applyTheme(newTheme);
  }
  
  // 清除主题设置
  clearTheme() {
    localStorage.removeItem('userTheme');
    this.currentTheme = 'light';
    this.applyTheme('light');
    console.log('主题设置已清除');
  }
}

// 使用示例
const themeManager = new ThemeManager();

// 主题切换按钮
document.getElementById('themeToggle').addEventListener('click', () => {
  themeManager.toggleTheme();
});

3. IndexedDB:大数据专家

当你的数据量很大,或者需要复杂查询时,IndexedDB 就是最佳选择。

// 创建一个简单的数据库管理器
class DBManager {
  constructor(dbName, version) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }
  
  // 打开数据库
  async open() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);
      
      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };
      
      // 第一次创建数据库时初始化结构
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        
        // 创建用户表
        if (!db.objectStoreNames.contains('users')) {
          const store = db.createObjectStore('users', { keyPath: 'id' });
          // 创建索引,方便按姓名搜索
          store.createIndex('name', 'name', { unique: false });
        }
        
        // 创建文章表
        if (!db.objectStoreNames.contains('articles')) {
          const store = db.createObjectStore('articles', { keyPath: 'id' });
          store.createIndex('title', 'title', { unique: false });
          store.createIndex('createdAt', 'createdAt', { unique: false });
        }
      };
    });
  }
  
  // 添加数据
  async add(storeName, data) {
    const transaction = this.db.transaction([storeName], 'readwrite');
    const store = transaction.objectStore(storeName);
    
    return new Promise((resolve, reject) => {
      const request = store.add(data);
      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);
    });
  }
  
  // 获取所有数据
  async getAll(storeName) {
    const transaction = this.db.transaction([storeName], 'readonly');
    const store = transaction.objectStore(storeName);
    
    return new Promise((resolve, reject) => {
      const request = store.getAll();
      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);
    });
  }
  
  // 按索引查询
  async getByIndex(storeName, indexName, value) {
    const transaction = this.db.transaction([storeName], 'readonly');
    const store = transaction.objectStore(storeName);
    const index = store.index(indexName);
    
    return new Promise((resolve, reject) => {
      const request = index.getAll(value);
      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);
    });
  }
}

// 使用示例
async function initDB() {
  const dbManager = new DBManager('MyAppDB', 1);
  await dbManager.open();
  
  // 添加示例用户
  await dbManager.add('users', {
    id: 1,
    name: '张三',
    email: 'zhangsan@example.com',
    createdAt: new Date()
  });
  
  // 获取所有用户
  const users = await dbManager.getAll('users');
  console.log('所有用户:', users);
  
  return dbManager;
}

// 初始化数据库
initDB().then(dbManager => {
  console.log('数据库初始化完成');
});

实战:构建智能数据缓存系统

现在让我们把 Fetch API 和本地存储结合起来,打造一个真正智能的数据缓存系统。

// 智能数据管理器
class SmartDataManager {
  constructor() {
    this.cache = new Map(); // 内存缓存
  }
  
  // 获取数据(带缓存)
  async getData(url, options = {}) {
    const {
      cacheKey = url,           // 缓存键名
      cacheTime = 5 * 60 * 1000, // 默认缓存5分钟
      forceRefresh = false      // 强制刷新
    } = options;
    
    // 检查内存缓存
    if (!forceRefresh) {
      const cached = this.getFromCache(cacheKey, cacheTime);
      if (cached) {
        console.log('从内存缓存返回数据');
        return cached;
      }
      
      // 检查 localStorage 缓存
      const stored = this.getFromStorage(cacheKey, cacheTime);
      if (stored) {
        console.log('从本地存储返回数据');
        // 同时更新内存缓存
        this.cache.set(cacheKey, {
          data: stored,
          timestamp: Date.now()
        });
        return stored;
      }
    }
    
    // 缓存中没有,从接口获取
    console.log('从接口获取数据');
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      
      const data = await response.json();
      
      // 同时更新内存缓存和本地存储
      this.setCache(cacheKey, data);
      this.setStorage(cacheKey, data);
      
      return data;
    } catch (error) {
      console.error('获取数据失败:', error);
      throw error;
    }
  }
  
  // 从内存缓存获取
  getFromCache(key, cacheTime) {
    const cached = this.cache.get(key);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      return cached.data;
    }
    return null;
  }
  
  // 从本地存储获取
  getFromStorage(key, cacheTime) {
    try {
      const stored = localStorage.getItem(`cache_${key}`);
      if (stored) {
        const { data, timestamp } = JSON.parse(stored);
        if (Date.now() - timestamp < cacheTime) {
          return data;
        } else {
          // 缓存过期,清理
          localStorage.removeItem(`cache_${key}`);
        }
      }
    } catch (error) {
      console.warn('读取缓存失败:', error);
    }
    return null;
  }
  
  // 设置内存缓存
  setCache(key, data) {
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    });
  }
  
  // 设置本地存储
  setStorage(key, data) {
    try {
      localStorage.setItem(`cache_${key}`, JSON.stringify({
        data,
        timestamp: Date.now()
      }));
    } catch (error) {
      console.warn('存储缓存失败:', error);
      // 如果存储失败(比如超出容量),清理最旧的缓存
      this.cleanupStorage();
    }
  }
  
  // 清理过期缓存
  cleanupStorage() {
    const keysToRemove = [];
    
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith('cache_')) {
        try {
          const stored = JSON.parse(localStorage.getItem(key));
          // 删除超过1天的缓存
          if (Date.now() - stored.timestamp > 24 * 60 * 60 * 1000) {
            keysToRemove.push(key);
          }
        } catch (error) {
          // 数据格式错误,直接删除
          keysToRemove.push(key);
        }
      }
    }
    
    keysToRemove.forEach(key => localStorage.removeItem(key));
  }
  
  // 清除指定缓存
  clearCache(key) {
    this.cache.delete(key);
    localStorage.removeItem(`cache_${key}`);
  }
  
  // 清除所有缓存
  clearAllCache() {
    this.cache.clear();
    Object.keys(localStorage)
      .filter(key => key.startsWith('cache_'))
      .forEach(key => localStorage.removeItem(key));
  }
}

// 使用示例
const dataManager = new SmartDataManager();

// 获取用户列表(带缓存)
async function loadUsers() {
  try {
    const users = await dataManager.getData('/api/users', {
      cacheKey: 'user_list',
      cacheTime: 10 * 60 * 1000 // 缓存10分钟
    });
    
    // 渲染用户列表
    renderUserList(users);
  } catch (error) {
    // 显示错误状态
    showError('加载用户列表失败');
  }
}

// 强制刷新数据
async function refreshUsers() {
  try {
    const users = await dataManager.getData('/api/users', {
      cacheKey: 'user_list',
      forceRefresh: true // 强制从接口获取最新数据
    });
    
    renderUserList(users);
    showSuccess('数据已刷新');
  } catch (error) {
    showError('刷新数据失败');
  }
}

离线优先:打造极致用户体验

现代 Web 应用应该具备离线能力,让用户在网络不稳定时也能正常使用。

// 离线优先的数据同步器
class OfflineFirstSync {
  constructor() {
    this.dbManager = null;
    this.pendingSync = []; // 待同步的操作
    this.init();
  }
  
  async init() {
    // 初始化 IndexedDB
    this.dbManager = new DBManager('OfflineApp', 1);
    await this.dbManager.open();
    
    // 监听网络状态
    this.setupNetworkListener();
    
    // 尝试同步待处理的操作
    this.trySyncPending();
  }
  
  // 设置网络状态监听
  setupNetworkListener() {
    window.addEventListener('online', () => {
      console.log('网络已连接,开始同步数据...');
      this.trySyncPending();
    });
    
    window.addEventListener('offline', () => {
      console.log('网络已断开,进入离线模式');
      this.showOfflineIndicator();
    });
  }
  
  // 创建数据(离线优先)
  async createData(storeName, data) {
    // 先保存到本地数据库
    const localId = await this.dbManager.add(storeName, {
      ...data,
      _local: true,    // 标记为本地创建
      _synced: false,  // 未同步
      _createdAt: new Date()
    });
    
    // 添加到待同步队列
    this.pendingSync.push({
      type: 'create',
      storeName,
      data: { ...data, _localId: localId }
    });
    
    // 尝试立即同步
    await this.trySyncPending();
    
    return localId;
  }
  
  // 尝试同步待处理操作
  async trySyncPending() {
    if (!navigator.onLine || this.pendingSync.length === 0) {
      return;
    }
    
    console.log(`开始同步 ${this.pendingSync.length} 个操作`);
    
    const successes = [];
    const failures = [];
    
    for (const operation of [...this.pendingSync]) {
      try {
        await this.syncOperation(operation);
        successes.push(operation);
        
        // 从待同步队列中移除成功的操作
        const index = this.pendingSync.indexOf(operation);
        if (index > -1) {
          this.pendingSync.splice(index, 1);
        }
      } catch (error) {
        console.error('同步操作失败:', error);
        failures.push(operation);
      }
    }
    
    if (successes.length > 0) {
      console.log(`成功同步 ${successes.length} 个操作`);
      this.showSyncSuccess(successes.length);
    }
    
    if (failures.length > 0) {
      console.warn(`${failures.length} 个操作同步失败,将在下次重试`);
    }
  }
  
  // 同步单个操作
  async syncOperation(operation) {
    switch (operation.type) {
      case 'create':
        // 调用 API 创建数据
        const response = await fetch('/api/' + operation.storeName, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(operation.data)
        });
        
        if (!response.ok) {
          throw new Error(`创建失败: ${response.status}`);
        }
        
        const result = await response.json();
        
        // 更新本地数据,标记为已同步
        // 这里可以根据需要更新本地记录的ID等
        console.log('数据同步成功:', result);
        break;
        
      default:
        console.warn('未知的操作类型:', operation.type);
    }
  }
  
  // 显示离线指示器
  showOfflineIndicator() {
    // 在实际应用中,可以显示一个离线提示条
    const indicator = document.createElement('div');
    indicator.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      background: #ff6b6b;
      color: white;
      text-align: center;
      padding: 10px;
      z-index: 1000;
    `;
    indicator.textContent = '当前处于离线模式,部分功能可能受限';
    indicator.id = 'offline-indicator';
    
    document.body.appendChild(indicator);
  }
  
  // 显示同步成功提示
  showSyncSuccess(count) {
    const indicator = document.getElementById('offline-indicator');
    if (indicator) {
      indicator.remove();
    }
    
    // 显示同步成功提示(可以替换为更优雅的通知)
    console.log(`成功同步 ${count} 条数据`);
  }
  
  // 获取数据(离线优先)
  async getData(storeName, useLocalFirst = true) {
    if (useLocalFirst) {
      // 先返回本地数据
      const localData = await this.dbManager.getAll(storeName);
      
      // 同时在后台尝试获取最新数据
      this.fetchLatestData(storeName);
      
      return localData;
    } else {
      // 直接获取最新数据
      return await this.fetchLatestData(storeName);
    }
  }
  
  // 获取最新数据
  async fetchLatestData(storeName) {
    if (!navigator.onLine) {
      throw new Error('网络不可用');
    }
    
    try {
      const response = await fetch(`/api/${storeName}`);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      
      const data = await response.json();
      
      // 更新本地数据库
      // 这里需要根据具体业务逻辑实现数据合并
      console.log('获取到最新数据:', data);
      
      return data;
    } catch (error) {
      console.error('获取最新数据失败:', error);
      throw error;
    }
  }
}

// 使用示例
const offlineSync = new OfflineFirstSync();

// 在离线状态下创建用户
async function createUserOffline(userData) {
  try {
    const localId = await offlineSync.createData('users', userData);
    console.log('用户已创建(本地):', localId);
    showSuccess('用户已保存,将在网络恢复后同步');
  } catch (error) {
    console.error('创建用户失败:', error);
    showError('保存用户失败');
  }
}

性能优化与最佳实践

掌握了基础用法,再来看看一些提升性能的实用技巧。

// 防抖请求,避免频繁调用接口
function createDebouncedFetcher(delay = 500) {
  let timeoutId;
  
  return async function debouncedFetch(url, options) {
    // 清除之前的定时器
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    
    // 设置新的定时器
    return new Promise((resolve, reject) => {
      timeoutId = setTimeout(async () => {
        try {
          const response = await fetch(url, options);
          const data = await response.json();
          resolve(data);
        } catch (error) {
          reject(error);
        }
      }, delay);
    });
  };
}

// 使用防抖的搜索功能
const debouncedSearch = createDebouncedFetcher(300);

document.getElementById('searchInput').addEventListener('input', async (event) => {
  const query = event.target.value.trim();
  
  if (query.length < 2) {
    // 清空搜索结果
    clearSearchResults();
    return;
  }
  
  try {
    const results = await debouncedSearch(`/api/search?q=${encodeURIComponent(query)}`);
    displaySearchResults(results);
  } catch (error) {
    console.error('搜索失败:', error);
    // 可以显示本地缓存的结果或错误提示
  }
});

// 批量操作优化
async function batchOperations(operations, batchSize = 5) {
  const results = [];
  
  for (let i = 0; i < operations.length; i += batchSize) {
    const batch = operations.slice(i, i + batchSize);
    
    // 并行执行批次内的操作
    const batchResults = await Promise.allSettled(
      batch.map(op => executeOperation(op))
    );
    
    results.push(...batchResults);
    
    // 可选:批次间延迟,避免对服务器造成太大压力
    if (i + batchSize < operations.length) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
  }
  
  return results;
}

// 数据压缩,减少存储空间
function compressData(data) {
  // 简单的数据压缩示例
  const compressed = {
    // 移除空值
    ...Object.fromEntries(
      Object.entries(data).filter(([_, value]) => 
        value !== null && value !== undefined && value !== ''
      )
    ),
    // 添加压缩标记
    _compressed: true
  };
  
  return compressed;
}

// 数据解压缩
function decompressData(compressedData) {
  const { _compressed, ...data } = compressedData;
  return data;
}

// 使用压缩存储
function saveCompressedData(key, data) {
  const compressed = compressData(data);
  localStorage.setItem(key, JSON.stringify(compressed));
}

function loadCompressedData(key) {
  const stored = localStorage.getItem(key);
  if (stored) {
    const compressed = JSON.parse(stored);
    return decompressData(compressed);
  }
  return null;
}

错误处理与监控

健壮的应用离不开完善的错误处理。

// 增强的错误处理包装器
function createRobustFetcher(options = {}) {
  const {
    maxRetries = 3,
    retryDelay = 1000,
    timeout = 10000
  } = options;
  
  return async function robustFetch(url, fetchOptions = {}) {
    let lastError;
    
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        // 创建超时控制器
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), timeout);
        
        const response = await fetch(url, {
          ...fetchOptions,
          signal: controller.signal
        });
        
        clearTimeout(timeoutId);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        return await response.json();
        
      } catch (error) {
        lastError = error;
        
        console.warn(`请求失败 (尝试 ${attempt}/${maxRetries}):`, error);
        
        if (attempt < maxRetries) {
          // 指数退避延迟
          const delay = retryDelay * Math.pow(2, attempt - 1);
          console.log(`等待 ${delay}ms 后重试...`);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }
    
    // 所有重试都失败了
    throw new Error(`请求失败,已重试 ${maxRetries} 次: ${lastError.message}`);
  };
}

// 使用增强的请求器
const robustFetch = createRobustFetcher({
  maxRetries: 3,
  retryDelay: 1000,
  timeout: 15000
});

// 数据健康检查
class DataHealthChecker {
  static checkLocalStorage() {
    const issues = [];
    
    try {
      // 测试写入和读取
      const testKey = '__health_check__';
      const testValue = { timestamp: Date.now() };
      
      localStorage.setItem(testKey, JSON.stringify(testValue));
      const retrieved = JSON.parse(localStorage.getItem(testKey));
      localStorage.removeItem(testKey);
      
      if (!retrieved || retrieved.timestamp !== testValue.timestamp) {
        issues.push('localStorage 数据完整性检查失败');
      }
    } catch (error) {
      issues.push(`localStorage 不可用: ${error.message}`);
    }
    
    return issues;
  }
  
  static checkIndexedDB() {
    return new Promise((resolve) => {
      const issues = [];
      
      const request = indexedDB.open('health_check', 1);
      request.onerror = () => {
        issues.push('IndexedDB 无法打开');
        resolve(issues);
      };
      
      request.onsuccess = () => {
        const db = request.result;
        db.close();
        
        // 清理测试数据库
        indexedDB.deleteDatabase('health_check');
        resolve(issues);
      };
      
      request.onblocked = () => {
        issues.push('IndexedDB 被阻塞');
        resolve(issues);
      };
    });
  }
  
  static async runAllChecks() {
    const localStorageIssues = this.checkLocalStorage();
    const indexedDBIssues = await this.checkIndexedDB();
    
    const allIssues = [...localStorageIssues, ...indexedDBIssues];
    
    if (allIssues.length === 0) {
      console.log('✅ 所有存储系统正常');
    } else {
      console.warn('❌ 存储系统问题:', allIssues);
    }
    
    return allIssues;
  }
}

// 定期运行健康检查
setInterval(async () => {
  await DataHealthChecker.runAllChecks();
}, 5 * 60 * 1000); // 每5分钟检查一次

总结

通过今天的学习,相信你已经掌握了:

✅ Fetch API 的现代用法和错误处理 ✅ 三种本地存储方案的适用场景 ✅ 如何构建智能缓存系统提升性能 ✅ 离线优先的设计思路 ✅ 各种性能优化和监控技巧

数据交互不再是简单的"请求-显示",而是要考虑缓存、离线、同步、性能等方方面面。一个好的数据层设计,能让你的应用用户体验提升好几个档次。

🧠 一文吃透 Next.js 中的 JWT vs Session:底层原理+幽默拆解指南

作者 LeonGao
2025年10月29日 09:29

🪐 开场白:Web 的“失忆症”

HTTP 是一种无状态协议
换句话说,它的记忆力…基本等于一条金鱼。

🐠 → 用户登录
🐠 → 下一个请求?抱歉我不认识你。

所以我们不得不在应用层想办法维持身份状态(Session Management),主流方案就是:

  1. Session(会话 + 服务端存储)
  2. JWT(JSON Web Token) (令牌 + 客户端存储)

下面,我们来一场优雅又搞笑的底层拆解!🕶️


🧩 Part 1:Session 机制——“服务器记性超好型”

🧭 流程图(Session)

sequenceDiagram
    participant User as 🧑 用户浏览器
    participant Server as 🖥️ Next.js服务器
    participant DB as 🗄️ Session存储
    
    User->>Server: 提交表单(username, password)
    Server->>DB: 验证用户并生成Session记录 (session_id)
    DB-->>Server: 返回session_id
    Server-->>User: 设置Cookie: session_id=abc123
    User->>Server: 请求受保护资源 + Cookie(session_id)
    Server->>DB: 查找session_id并获取用户信息
    DB-->>Server: 返回用户状态
    Server-->>User: 返回响应内容

🧠 底层剖析

  • Session ID 存放在 Cookie 中,本身并不包含用户数据。
  • 服务端保存实际用户信息,通常存在内存(MemoryStore)、Redis 或数据库中。
  • 每次请求时,服务器从 session_id 映射到对应的用户状态。

🏆 优点

✔️ 简单直接、成熟稳定。
✔️ 可以强制下线用户(删除服务端 session 即可)。
✔️ 用户状态集中存储,易于审计和控制。

❌ 缺点

⚠️ 需要服务端存储,分布式架构扩展不易
⚠️ session 同步和持久化管理复杂。
⚠️ Cookie 容易被截取(如果不使用 HTTPS)。


🦾 Part 2:JWT 机制——“客户端自带记忆芯片型”

🧭 流程图(JWT)

sequenceDiagram
    participant User as 🧑 用户浏览器
    participant Server as 🧠 Next.js服务器
    participant JWT as 🔐 签名引擎
    
    User->>Server: 登录请求(username, password)
    Server->>JWT: 生成签名(token)
    JWT-->>Server: 返回(encoded JWT)
    Server-->>User: 返回JWT (放在Cookie或LocalStorage)
    User->>Server: 请求受保护资源 + JWT
    Server->>JWT: 验证签名、解析用户信息
    JWT-->>Server: 返回用户状态
    Server-->>User: 发送响应

🧠 底层剖析

  • JWT 通常由三部分组成:Header.Payload.Signature
  • 服务器使用一个秘密密钥 (Secret) 对内容签名 🧾。
  • 验证时不依赖数据库:仅通过验签即可判断是否合法。

🏆 优点

✔️ 无状态,不依赖服务端存储(超适合微服务与无服务架构)。
✔️ 可跨域、跨服务传递认证信息。
✔️ 性能好,因为不需要每次查询数据库。

❌ 缺点

⚠️ 无法强制注销一个已签发的 Token(除非配黑名单)。
⚠️ token 暴露后,后果严重。
⚠️ token 体积较大,会加重传输成本。


🧬 Part 3:Next.js 角度的整合与实现

Next.js 中,我们通常有两类场景:

1️⃣ 服务端渲染(SSR)中验证身份

  • Session 方案

    • 需要在 getServerSideProps() 中读取和验证 Cookie。
    • 借助 next-auth 等库实现,内部封装 session 存储(默认用 JWT,但也可用数据库 session)。
  • JWT 方案

    • 每个请求都要在 SSR 阶段验证 JWT 签名。
    • 无需访问数据库,速度快,但丧失实时控制。

2️⃣ API Route 中授权控制

// pages/api/protect.js
import jwt from 'jsonwebtoken';

export default function handler(req, res) {
  const token = req.cookies.token || '';
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    res.status(200).json({ user: decoded });
  } catch (err) {
    res.status(401).json({ message: 'Unauthorized' });
  }
}

轻量 & 灵活,但是请记得在生产中开启 HTTPS 和 SameSite Cookie!


🧯 Part 4:双雄对决总表

特性 Session JWT
状态存储 服务端 客户端
扩展性 差(需集中存储) 优(无状态)
性能 每次查询存储 验签即可
控制力 可强制注销 不易
安全性 相对安全 暴露风险高
适合场景 传统 Web/内网系统 微服务/跨域/移动端 API

💡 结尾:哲学层的思考

JWT 与 Session,就像两种人生:

  • Session 像个控制狂:万物都掌握在自己手里。
  • JWT 像个自由派:只要签过名,天涯各处皆可去。

所以选择用哪个,不取决于“哪个更好”,而取决于你的网站要记住谁、怎么记住、记多久

若你要控制用户在线状态,就拥抱 Session。

若你要系统轻盈无羁,就放飞 JWT。


🧭 小结图:选择指南

flowchart TD
    A["用户认证需求"] --> B{"是否需要集中控制与强制注销?"}
    B -- Yes --> S["使用 Session"]
    B -- No --> J["使用 JWT"]
    S --> E["适合 SSR + 内部系统"]
    J --> F["适合分布式 + 微前端架构"]

🌟 写在最后:
无论选哪种方案,你都躲不过 Cookie 安全、HTTPS、CSRF 防护这些老朋友。
记得:安全不是一段代码,而是一种偏执的习惯

✨祝你在认证的世界少踩坑,多登出~

❌
❌