普通视图

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

🧠 Next.js 安全防线:从 CSRF 到 XSS 的黑魔法防护 🌐⚔️

作者 LeonGao
2025年10月30日 09:22

🌋 前言:前端安全是个什么鬼?

想象你在海滩边写一个 Next.js 应用,API、登录、数据全都顺风顺水,突然来了个陌生请求,把你用户的 session 偷了。没错——那就是 CSRF(跨站请求伪造) 在作祟。而另一边,用户输入 <script> 标签在页面上弹出你的名字,这就是 XSS(跨站脚本攻击) 的优雅登场。

现代 Web 应用安全就像玩一场塔防游戏:攻击者只要找到一条缝,你的城堡就塌了。


🧱 一、CSRF:当用户被“借刀杀人”

“你点的是猫猫视频,发的却是转账请求。”

🔍 原理回顾(用人话讲)

CSRF 攻击的核心思想很简单:攻击者诱导用户浏览他们的恶意网站,从而“借”用户的登录凭证(通常是 Cookie),向真正的网站发起请求。

比如用户已登录 bank.com,攻击者在自己的网站上藏了段:

<img src="https://bank.com/transfer?to=hacker&amount=9999" />

用户一打开,浏览器乖乖带上了 Cookie,银行以为是你本人发起的请求 🤡


🧰 两步护法:Next.js + csurf

🧩 Step1:安装依赖

npm install csurf cookie-parser

🧩 Step2:配置中间件

pages/api/_middleware.js 或 Next 13+ 的 middleware.ts 中加入配置逻辑:

import { NextResponse } from 'next/server';
import csurf from 'csurf';
import cookieParser from 'cookie-parser';
import express from 'express';

const app = express();
app.use(cookieParser());
app.use(csurf({ cookie: true }));

export const config = {
  matcher: ['/api/:path*'],
};

export function middleware(req) {
  // 可以在此加入 token 注入逻辑
  return NextResponse.next();
}

💡 提示:CSRF Token 就像访客通行证,每次请求都验证身份,防止“伪装者”。


🕹️ CSRF Token 的工作流程

阶段 行为
🧙 生成 服务端为每个会话发一个 Token
🧾 注入 前端表单请求时带上这个 Token
🔍 验证 后端验证 Token 是否匹配
✅ 通过 如果匹配,请求被允许执行

你可以这么理解:

“服务端说,我送你一张签名卡,只有我认得的签名卡;你下次来买面包得出示这张卡,否则我打死也不认你。”


🦠 二、XSS:从 <script> 到失控的前端世界

“你以为你在输出 JSON,实际上是在输出一场灾难。”

🐍 XSS 攻击的本质

当用户输入的内容没有被安全过滤,最终被原样渲染在页面上时——攻击者就能注入脚本执行:

const comment = "<script>alert('你被骗了')</script>";
document.body.innerHTML = comment;

这就像你让用户在表单写留言,结果 TA 在留言板上开启了天眼通。


🛡️ 用 helmet 给页面加上“头盔”

helmet 是一个 Express 中间件,用来设置各种 HTTP 安全头,阻止常见攻击。

🧩 Step1:安装依赖

npm install helmet

🧩 Step2:配置 next.config.js 或自定义服务器

import helmet from 'helmet';
import express from 'express';
import next from 'next';

const app = next({ dev: true });
const server = express();
const handle = app.getRequestHandler();

server.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:"],
    },
  },
  referrerPolicy: { policy: 'no-referrer' },
}));

server.all('*', (req, res) => handle(req, res));
server.listen(3000);

🧠 Tip:CSP(内容安全策略)是浏览器的防护罩,用它限制脚本与外部资源来源,阻止第三方注入。


📋 三、安全清单:Next.js 项目部署前必查表 ✅

检查点 是否完成
🔐 CSRF csurf 已配置并验证 token
🧱 XSS 已使用 helmet / CSP
🍪 Cookie SameSite=strictHttpOnlySecure
🕵️‍♂️ 输入验证 所有用户输入清理与转义
🚷 CORS 限制跨域来源
🚨 错误信息 不在生产输出敏感堆栈信息
🧰 OWASP 对照 OWASP Cheat Sheet 检查

📚 四、参考:OWASP Cheat Sheet 系列(建议收藏)

主题 推荐阅读
🔒 CSRF 防护 OWASP CSRF Prevention Cheat Sheet
⚔️ XSS 防护 OWASP XSS Prevention Cheat Sheet
🧩 安全头 OWASP Secure Headers Project

🎨 五、彩蛋:一张简化安全架构脑图(ASCII 版)

         🧑 用户浏览器
              │
        [CSRF Token 验证]
              │
   ┌───► Next.js API Route ◄───┐
   │                            │
 [helmet安全头]             [输入过滤]
   │                            │
 [Express Server]         [数据库安全层]
   │                            │
       └────→ 🌐 安全的数据流 ───┘

🧩 结语:安全,就像代码审美

“代码干净是一种修养,安全意识是一种责任。”

CSRF 与 XSS 看似不起眼,却能敲开整个系统的大门。希望在实现酷炫功能的同时,你能戴上安全的头盔、举起防御的盾牌,让前端世界优雅且坚固。

Webpack配置魔法书:从入门到高手的通关秘籍

2025年10月30日 08:41

朋友们,我是小杨!今天咱们来聊聊Webpack配置这个话题。很多人第一次看到webpack.config.js文件时,感觉就像在看天书一样。别担心,今天我就带你从零开始,一步步解锁Webpack配置的奥秘!

初识Webpack:先来个"Hello World"

让我们从一个最简单的配置开始,就像学编程先写Hello World一样:

// 我的第一个webpack配置
const path = require('path');

module.exports = {
  // 入口:告诉Webpack从哪开始打包
  entry: './src/index.js',
  
  // 输出:打包后的文件放哪里
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  
  // 模式:开发还是生产
  mode: 'development'
};

这个基础配置就像搭积木的第一步,虽然简单,但已经能完成基本的打包任务了!

核心配置详解:拆解Webpack的"五脏六腑"

1. Entry(入口):打包的起点

// 单入口(SPA应用)
entry: './src/index.js'

// 多入口(多页面应用)
entry: {
  home: './src/home.js',
  about: './src/about.js',
  contact: './src/contact.js'
}

// 动态入口
entry: () => new Promise((resolve) => {
  resolve('./src/dynamic-entry.js');
})

2. Output(输出):打包成果的归宿

output: {
  path: path.resolve(__dirname, 'dist'),
  // 使用占位符确保文件名唯一
  filename: '[name].[contenthash].js',
  // 清理输出目录
  clean: true,
  // 公共路径(CDN场景很有用)
  publicPath: 'https://cdn.example.com/'
}

3. Loader:文件转换的"翻译官"

Loader是Webpack最强大的功能之一,让我展示几个常用配置:

module: {
  rules: [
    // 处理CSS文件
    {
      test: /.css$/i,
      use: ['style-loader', 'css-loader']
    },
    
    // 处理SCSS文件
    {
      test: /.scss$/i,
      use: [
        'style-loader',
        'css-loader',
        'sass-loader'
      ]
    },
    
    // 处理图片
    {
      test: /.(png|jpg|jpeg|gif|svg)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'images/[name].[hash][ext]'
      }
    },
    
    // 处理字体
    {
      test: /.(woff|woff2|eot|ttf|otf)$/i,
      type: 'asset/resource',
      generator: {
        filename: 'fonts/[name].[hash][ext]'
      }
    },
    
    // Babel转译JS
    {
      test: /.js$/,
      exclude: /node_modules/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}

4. Plugins:增强功能的"外挂"

插件让Webpack变得更强大,来看我的常用插件组合:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

plugins: [
  // 自动生成HTML文件
  new HtmlWebpackPlugin({
    template: './src/index.html',
    title: '我的应用',
    minify: true
  }),
  
  // 提取CSS到单独文件
  new MiniCssExtractPlugin({
    filename: '[name].[contenthash].css'
  }),
  
  // 清理输出目录
  new CleanWebpackPlugin(),
  
  // 定义环境变量
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify('production')
  })
]

实战配置:搭建完整的开发环境

让我分享一个我在实际项目中使用的完整配置:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  
  return {
    entry: {
      main: './src/index.js',
      vendor: './src/vendor.js'
    },
    
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: isProduction 
        ? '[name].[contenthash].js' 
        : '[name].js',
      publicPath: '/'
    },
    
    module: {
      rules: [
        {
          test: /.js$/,
          exclude: /node_modules/,
          use: 'babel-loader'
        },
        {
          test: /.css$/,
          use: [
            isProduction 
              ? MiniCssExtractPlugin.loader 
              : 'style-loader',
            'css-loader',
            'postcss-loader'
          ]
        },
        {
          test: /.(png|jpg|gif)$/,
          type: 'asset/resource'
        }
      ]
    },
    
    plugins: [
      new HtmlWebpackPlugin({
        template: './src/index.html',
        inject: true
      }),
      ...(isProduction 
        ? [new MiniCssExtractPlugin({
            filename: '[name].[contenthash].css'
          })]
        : []
      )
    ],
    
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\/]node_modules[\/]/,
            name: 'vendors',
            priority: 10
          }
        }
      }
    },
    
    devServer: {
      static: './dist',
      hot: true,
      open: true,
      port: 3000
    },
    
    devtool: isProduction ? 'source-map' : 'eval-cheap-module-source-map'
  };
};

环境特定配置:开发vs生产

开发环境配置要点

// webpack.dev.js
module.exports = {
  mode: 'development',
  devtool: 'eval-source-map',
  devServer: {
    static: './dist',
    hot: true,
    open: true,
    historyApiFallback: true
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  }
};

生产环境配置要点

// webpack.prod.js
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  mode: 'production',
  devtool: 'source-map',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerPlugin()
    ],
    splitChunks: {
      chunks: 'all'
    }
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
};

高级配置技巧:我的独门秘籍

1. 动态配置

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';
  const isAnalyze = env && env.analyze;
  
  const config = {
    // 基础配置...
  };
  
  if (isAnalyze) {
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    config.plugins.push(new BundleAnalyzerPlugin());
  }
  
  return config;
};

2. 多目标构建

// 同时构建多个配置
module.exports = [
  {
    name: 'client',
    target: 'web',
    entry: './src/client.js',
    output: {
      filename: 'client.bundle.js'
    }
  },
  {
    name: 'server',
    target: 'node',
    entry: './src/server.js',
    output: {
      filename: 'server.bundle.js'
    }
  }
];

常见问题排查:我踩过的那些坑

问题1:文件找不到?
检查路径配置,记得用path.resolve

问题2:Loader不生效?
检查test正则和use数组顺序

问题3:打包文件太大?
合理配置splitChunks和压缩选项

问题4:热更新不工作?
检查devServer配置和HotModuleReplacementPlugin

性能优化配置

optimization: {
  // 代码分割
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // 第三方库单独打包
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendors',
        priority: 20
      },
      // 公共代码单独打包
      common: {
        name: 'common',
        minChunks: 2,
        priority: 10,
        reuseExistingChunk: true
      }
    }
  },
  // 运行时代码单独提取
  runtimeChunk: {
    name: 'runtime'
  }
}

总结

Webpack配置就像搭积木,从简单开始,逐步添加需要的功能。记住几个关键点:

  1. 理解核心概念:Entry、Output、Loader、Plugin
  2. 区分环境:开发环境要快,生产环境要小
  3. 渐进式配置:从简单开始,按需添加功能
  4. 善用优化:代码分割、压缩、缓存一个都不能少

配置Webpack不是一蹴而就的,需要在实际项目中不断实践和调整。希望这篇指南能帮你少走弯路,快速掌握Webpack配置的精髓!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

webpack了解吗,讲一讲原理,怎么压缩代码

2025年10月30日 08:36

大家好,我是小杨!今天我要带大家探索前端工程化的核心魔法——Webpack。很多人觉得Webpack很复杂,但其实掌握了它的原理,你就会发现它就像个智能的"代码料理机",能把各种原料加工成美味佳肴!

Webpack的核心概念:先认识这些"厨房工具"

想象一下,Webpack就是一个现代化的智能厨房:

// 这是我的webpack.config.js - 相当于"菜谱"
module.exports = {
  // 入口起点 - 告诉厨房从哪开始准备食材
  entry: './src/index.js',
  
  // 输出 - 成品要放在哪里
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  
  // 加载器 - 各种食材处理工具
  module: {
    rules: [
      {
        test: /.css$/,        // 遇到CSS食材
        use: ['style-loader', 'css-loader']  // 用这两个工具处理
      },
      {
        test: /.(png|jpg)$/,  // 遇到图片食材
        use: ['file-loader']   // 用文件处理工具
      }
    ]
  },
  
  // 插件 - 高级厨房设备
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ]
};

Webpack的工作原理:看看"厨房"是怎么运作的

让我用最通俗的方式解释Webpack的工作流程:

第一步:依赖收集(找食材)
Webpack从入口文件开始,像侦探一样找出所有的import和require语句,建立完整的依赖关系图。

// 假设这是我的项目结构
// src/
//   index.js (入口)
//   utils.js  
//   styles.css

// index.js
import { calculateTotal } from './utils.js';
import './styles.css';

const result = calculateTotal(100, 20);
console.log('总价:', result);

// utils.js
export function calculateTotal(price, tax) {
  return price + (price * tax);
}

第二步:模块转换(处理食材)
通过loader系统,把不同类型的文件都转换成JavaScript能理解的模块。

// 比如CSS文件,经过css-loader和style-loader处理
// 原始的styles.css
.body { color: red; }

// 被转换成JavaScript模块
const styles = ".body { color: red; }";
// 然后通过style-loader注入到页面

第三步:代码生成(装盘上菜)
把所有模块组合成一个或多个bundle文件。

代码压缩的魔法:让文件瘦身的秘密武器

说到代码压缩,这可是Webpack的拿手好戏!让我展示几种常见的压缩方式:

1. JavaScript压缩

// 压缩前的代码
function calculatePrice(originalPrice, discountRate) {
    const discountAmount = originalPrice * discountRate;
    const finalPrice = originalPrice - discountAmount;
    return finalPrice;
}

const result = calculatePrice(100, 0.2);
console.log("最终价格是:", result);

// 经过TerserWebpackPlugin压缩后
function n(n,r){return n-n*r}console.log("最终价格是:",n(100,.2));

在webpack中配置:

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true, // 移除console.log
          pure_funcs: ['console.log'] // 移除特定的函数调用
        }
      }
    })]
  }
};

2. CSS压缩

// 压缩前
.container {
    margin: 10px 20px 10px 20px;
    padding: 15px;
    background-color: #ff0000;
}

.title {
    font-size: 16px;
    font-weight: bold;
}

// 压缩后
.container{margin:10px 20px;padding:15px;background-color:red}.title{font-size:16px;font-weight:700}

配置方法:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
    ],
  },
};

3. 图片压缩

module.exports = {
  module: {
    rules: [
      {
        test: /.(png|jpg|jpeg|gif)$/i,
        use: [
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
                quality: 65
              },
              optipng: {
                enabled: true,
              },
              pngquant: {
                quality: [0.65, 0.90],
                speed: 4
              }
            }
          }
        ]
      }
    ]
  }
};

高级优化技巧:我的实战经验分享

1. 代码分割

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
        common: {
          name: 'common',
          minChunks: 2,
          chunks: 'all',
          minSize: 0
        }
      }
    }
  }
};

2. Tree Shaking

// utils.js - 原始代码
export function usedFunction() {
  return '这个函数会被使用';
}

export function unusedFunction() {
  return '这个函数不会被使用,会被tree shaking掉';
}

// index.js - 只导入usedFunction
import { usedFunction } from './utils';

// 打包后,unusedFunction会被自动移除

实战案例:看我如何优化一个真实项目

让我分享一个真实的优化经历:

// 优化前的配置
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  }
};

// 优化后的配置
module.exports = {
  entry: {
    main: './src/index.js',
    admin: './src/admin.js'
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10
        }
      }
    },
    usedExports: true,
    minimize: true
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css'
    }),
    new CompressionPlugin({
      algorithm: 'gzip'
    })
  ]
};

优化效果:

  • 文件大小减少60%
  • 加载速度提升40%
  • 缓存命中率大幅提高

常见坑点和解决方案

坑1:Tree Shaking不生效
检查:package.json中要有"sideEffects": false

坑2:压缩后代码报错
可能是ES6+语法问题,确保terser配置正确

坑3:文件太大
合理使用代码分割和动态导入

总结

Webpack就像前端开发的"瑞士军刀",掌握它的原理和优化技巧,能让你在性能优化的道路上如鱼得水。记住:好的打包策略不是一蹴而就的,需要根据项目特点不断调整优化

希望这篇分享能帮你更好地理解Webpack!如果有任何问题,欢迎在评论区交流~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

玩转小程序生命周期:从入门到上瘾

2025年10月30日 08:30

作为前端老司机,我经常被问到:“小杨,小程序的生命周期到底怎么玩?”今天我就用最接地气的方式,带你解锁小程序生命周期的正确打开方式。准备好,我们要发车了!

先来认识下小程序的生命周期家族

想象一下,你的小程序就像一个有生命的个体,从诞生到离开,每个阶段都有专属的“人生时刻”。让我用一个简单的Page示例来演示:

// 我的第一个小程序页面
Page({
  data: {
    userInfo: {},
    isLoading: true
  },
  
  // 生命周期函数们要登场啦!
  onLoad(options) {
    console.log('页面加载啦!');
    // 我在这里初始化数据
    this.fetchUserData();
  },
  
  onShow() {
    console.log('页面显示啦!');
    // 每次进入页面都会执行
    this.updateBadgeCount();
  },
  
  onReady() {
    console.log('页面准备就绪!');
    // 页面渲染完成,可以操作DOM了
    this.initCanvas();
  },
  
  onHide() {
    console.log('页面隐藏了');
    // 页面被收起时清理一些资源
    this.clearTimer();
  },
  
  onUnload() {
    console.log('页面被卸载了');
    // 页面被关闭时彻底清理
    this.cleanup();
  }
})

全局生命周期:小程序的“大脑”

App级别的生命周期就像是整个小程序的大脑,掌控着全局的生死轮回:

App({
  onLaunch(options) {
    // 小程序启动时的初始化
    console.log('小程序诞生了!');
    this.initCloudService();
  },
  
  onShow(options) {
    // 小程序切换到前台
    console.log('小程序被唤醒了');
    this.syncData();
  },
  
  onHide() {
    // 小程序被切换到后台
    console.log('小程序去休息了');
    this.saveState();
  },
  
  onError(msg) {
    // 出错时的处理
    console.error('出问题啦:', msg);
    this.reportError(msg);
  }
})

组件生命周期:精致的“小部件”

组件的生命周期更加精细,让我用一个自定义组件来展示:

Component({
  lifetimes: {
    created() {
      // 组件实例刚被创建
      console.log('组件出生了!');
    },
    
    attached() {
      // 组件进入页面节点树
      console.log('组件安家落户了');
      this.initData();
    },
    
    ready() {
      // 组件渲染完成
      console.log('组件装修完毕');
      this.startAnimation();
    },
    
    detached() {
      // 组件被从页面移除
      console.log('组件搬家了');
      this.releaseResource();
    }
  },
  
  // 还有页面生命周期,专门为组件定制
  pageLifetimes: {
    show() {
      // 页面展示时的逻辑
      this.resumeMusic();
    },
    
    hide() {
      // 页面隐藏时的逻辑
      this.pauseMusic();
    }
  }
})

实战技巧:生命周期的最佳拍档

在我多年的开发经验中,发现这些生命周期组合使用效果最佳:

场景1:数据加载优化

Page({
  data: {
    listData: [],
    hasMore: true
  },
  
  onLoad() {
    // 初次加载数据
    this.loadInitialData();
  },
  
  onShow() {
    // 每次显示时检查更新
    if (this.shouldRefresh()) {
      this.refreshData();
    }
  },
  
  onHide() {
    // 离开时保存状态
    this.saveScrollPosition();
  }
})

场景2:资源管理

Page({
  timer: null,
  
  onShow() {
    // 开启定时器
    this.timer = setInterval(() => {
      this.updateRealTimeData();
    }, 5000);
  },
  
  onHide() {
    // 及时清理,避免内存泄漏
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  },
  
  onUnload() {
    // 双重保险
    this.onHide();
    this.cleanupNetworkRequests();
  }
})

常见坑点与解决方案

坑1:onLoad vs onShow 傻傻分不清

  • onLoad:只在页面创建时执行一次,适合一次性初始化
  • onShow:每次页面显示都执行,适合状态更新

坑2:内存泄漏
记得在onHide或onUnload中清理定时器、事件监听等资源。

坑3:数据状态混乱
合理利用onHide保存状态,onShow恢复状态。

总结

掌握小程序生命周期,就像掌握了小程序的“呼吸节奏”。合理运用它们,能让你的小程序运行更加流畅,用户体验更加丝滑。记住:合适的生命周期做合适的事,这是写出优质小程序的秘诀!

希望这篇分享能帮你更好地理解小程序生命周期。如果有任何问题,欢迎在评论区交流讨论~

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

记录一次修改 PNPM 版本,部署 NextJs 服务时导致服务器崩溃的问题 😡😡😡

作者 Moment
2025年10月30日 08:17

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

最近在使用 NextJs 开发 DocFlow 协同编辑这个项目,运行了很久都没有问题,最近终于在部署方面出现了问题了,早上的时候发现 ssh 连不上了,一开始并没有太在意,但后来打开京东云控制台,好家伙真的好家伙:

20251029202122

真的小母牛坐飞机牛逼上天了!原来是 CPU 爆了,怪不得连接不上了,当然网站也 502 了:

20251029202311

那是什么原因呢,接下来就要开始慢慢排查了,既然 vscode 连不上,那我们就在官方提供的 ssh 来连接:

20251029202432

输入命令 htop,罪魁祸首马上出现:

20251029202653

原来是 next-server 和 node(运行在 /home/DocFlow-Server/dist/main.js)占用了基本全部的 CPU,那这个原因就很清楚了,那么接下来就可以排查了这个问题的原因了。

首先,可以明确的一点是,之前一直都是同样的方式部署的,没有出现问题,但是为什么今天就一直出现这个问题呢,那肯定是在运行的进程出现了问题。

我项目使用的是 pm2 部署的,那打个日志看看咯:

20251029203101

问题找到了,原来是 pm2 一直在重启,把内存全部占用了,具体是什么原因的已经不好查了,有可能是安装了一些依赖,但是我的 Github Action 是有一些问题没有处理的:

name: Deploy Next.js to JD Cloud

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4.2.0

      - name: Deploy to server via SSH
        uses: appleboy/ssh-action@v1.2.1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          port: ${{ secrets.SERVER_PORT }}
          password: ${{ secrets.SERVER_PASSWORD }}
          script: |
            # 加载环境配置
            source ~/.bashrc 2>/dev/null || true
            source ~/.profile 2>/dev/null || true

            # 加载 NVM 环境
            export NVM_DIR="$HOME/.nvm"
            [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

            # 设置 PATH
            export PATH="$HOME/.nvm/versions/node/*/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.npm-global/bin:$PATH"

            # 设置工作目录
            PROJECT_DIR="/home/DocFlow"
            mkdir -p $PROJECT_DIR
            cd $PROJECT_DIR

            # 克隆或更新代码
            if [ ! -d ".git" ]; then
              echo "Cloning repository..."
              rm -rf ./*
              git clone git@github.com:xun082/DocFlow.git .
            else
              echo "Updating repository..."
              git fetch origin main
              git reset --hard origin/main
            fi

            # 创建 .npmrc 文件 (如果不存在)
            if [ ! -f ".npmrc" ]; then
              echo "Creating .npmrc file..."
              echo "@tiptap-pro:registry=https://registry.tiptap.dev/" > .npmrc
              echo "//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_AUTH_TOKEN }}" >> .npmrc
            else
              echo ".npmrc file already exists, skipping creation."
            fi

            # 安装全局依赖 (只在需要时安装)
            command -v pnpm >/dev/null 2>&1 || npm install -g pnpm@9.4.0
            command -v pm2 >/dev/null 2>&1 || npm install -g pm2

            # 安装项目依赖
            pnpm install

            # 构建项目
            pnpm run build

            # 重启应用
            pm2 restart DocFlow 2>/dev/null || pm2 start "pnpm start" --name DocFlow

我这个部署脚本是基本没问题的,至少能跑,现在查看这两天的 commit,想起来有一个这样的操作:

20251029204359

那基本就可以确定是这个问题了。

其他部署方案

PM2 是一种流行的进程管理工具,适用于 Node.js 应用,尤其在小型项目和单机部署时很有效。然而,PM2 的部署流程存在一些局限性:

  1. 依赖管理和版本控制:PM2 需要手动确保服务器上安装正确的 Node.js 和 pnpm 版本。每次代码更新后,你还需要在服务器上重新 构建项目(执行 pnpm run build),这可能会导致生产环境与开发环境之间的不一致,并增加了维护工作。

  2. 服务中断问题:每次代码更新时,PM2 需要 重启应用 来使新版本生效。如果没有正确配置或管理,应用在重启过程中可能会停机一段时间,这对于高可用性要求较高的生产环境来说是个问题。

与 PM2 相比,Docker 自动化部署 通过容器化解决了这些问题,提供了更简洁、更一致的部署流程,特别适用于云端和多环境部署。主要优势包括:

  1. 无需依赖管理和环境配置:Docker 将应用及其所有依赖、环境变量、构建过程封装在 Docker 镜像 中。这样,服务器上无需安装特定版本的 Node.js 或 pnpm,避免了手动配置和版本不一致的问题。你只需要在 构建镜像时 配置好所有的依赖和环境,部署时无需再进行任何安装或构建操作。

  2. 代码问题不会影响运行:如果代码有问题,Docker 使得应用 不会受到当前部署的影响。因为 Docker 镜像已经包含了所有必要的依赖,部署失败时,原先的镜像仍然可以继续运行,确保生产环境不受影响。而在 PM2 部署 时,更新代码后需要重启应用,如果代码有问题,应用会直接停机,导致服务中断。

  3. 无缝的 CI/CD 集成:Docker 完美集成 CI/CD 流程。例如,使用 GitHub Actions,你可以自动构建 Docker 镜像并推送到仓库。服务器只需要 拉取最新镜像,并启动新的容器。如果部署失败,不会影响当前运行的容器,你可以迅速恢复服务,且无需手动修复。相反,PM2 需要重新启动应用,且服务停机时需要手动修复代码。

  4. 快速回滚:由于 Docker 镜像的版本化机制,你可以轻松回滚到之前的稳定版本。即便遇到部署失败的情况,只需要拉取旧版本的镜像,应用立即恢复,不需要手动操作,这为应用提供了更高的可靠性。

总结

PM2 部署中,如果代码有问题,必须手动修复并重启应用,这可能会导致 服务中断,并影响用户体验。而且每次更新都需要在服务器上重新构建项目,这可能导致环境不一致,增加了部署的复杂性。

Docker 部署则通过 容器化 确保应用和环境的一致性,部署失败时不会影响生产环境的运行,原有容器仍可继续工作,且通过 CI/CD 流程可以自动恢复和快速回滚,不需要人工干预。即使代码存在问题,原有的容器仍可保持正常服务,确保应用的高可用性。

因此,对于生产环境,Docker 提供了比 PM2 更稳定、高效、自动化的部署方式,尤其适合大规模、高可用的应用部署。

力扣热题100(前10道题目)

2025年10月30日 01:10

前言

算法题几乎是面试必考的,许多同学一看到算法题就是一个头两个大,所以笔者这次准备把力扣热题100写成文章与jym一起学习,估计会分为10篇文章来写。在这个过程中会分享一些自己刷题的想法和思路,让大家能够轻松看懂,这些题目我会采用js来写,有看不懂js的同学可以看个思路然后换成自己熟悉的语言去写🔥 LeetCode 热题 HOT 100

在每道题目之前我都会把对应的题目链接贴出来,方便大家可以看完我的解法再去力扣上刷题,而且这些题目我会尽可能多种解法去写,大家可以参考一下。

160. 相交链表

给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null 。

图示两个链表在节点 c1 开始相交

题目数据 保证 整个链式结构中不存在环。

注意,函数返回结果后,链表必须 保持其原始结构 。

自定义评测:

评测系统 的输入如下(你设计的程序 不适用 此输入):

  • intersectVal - 相交的起始节点的值。如果不存在相交节点,这一值为 0
  • listA - 第一个链表
  • listB - 第二个链表
  • skipA - 在 listA 中(从头节点开始)跳到交叉节点的节点数
  • skipB - 在 listB 中(从头节点开始)跳到交叉节点的节点数

评测系统将根据这些输入创建链式数据结构,并将两个头节点 headA 和 headB 传递给你的程序。如果程序能够正确返回相交节点,那么你的解决方案将被 视作正确答案 。

 

示例 1:

输入: intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出: Intersected at '8'
解释: 相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A[4,1,8,4,5],链表 B[5,6,1,8,4,5]。
在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
— 请注意相交节点的值不为 1,因为在链表 A 和链表 B 之中值为 1 的节点 (A 中第二个节点和 B 中第三个节点) 是不同的节点。换句话说,它们在内存中指向两个不同的位置,而链表 A 和链表 B 中值为 8 的节点 (A 中第三个节点,B 中第四个节点) 在内存中指向相同的位置。

 

示例 2:

输入: intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出: Intersected at '2'
解释: 相交节点的值为 2 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A[1,9,1,2,4],链表 B[3,2,4]。
在 A 中,相交节点前有 3 个节点;在 B 中,相交节点前有 1 个节点。
解法一
思路

如果我们从两个链表的最后一个节点往前遍历,那么相交的这个节点其实就是两个链表最后一个相等的节点,一旦不相等了,说明这个节点的前一个节点就是相交节点,所以我们可以把这两个链表分别放进两个数组里,然后倒序遍历

var getIntersectionNode = function (headA, headB) {
    const list1 = []
    const list2 = []
    while (headA) {
        list1.push(headA)
        headA = headA.next
    }
    while (headB) {
        list2.push(headB)
        headB = headB.next
    }
    let i = list1.length - 1
    let j = list2.length - 1
    let temp
    while (i && j && list1[i] === list2[j]) {
        temp = list1[i]
        i--
        j--
    }
    return temp
};

因为新增了数组,所以这其实并不是最优解,他的时间复杂度和空间复杂度都为O(m+n),m为headA的长度,n为headB的长度。

解法二
思路

:双指针法,先初始化两个指针p = headA,q = headB,然后不断循环直到p = q,每次循环让pq各向后走一步,当p为空时,让pheadBq为空时,qheadA,在循环结束时,如果两个链表相交,那么pq都会在相交的起始节点处就可以返回p了,如果不相交,那么他们都到空节点了,也可以返回p,即空节点

代码如下:

var getIntersectionNode = function (headA, headB) {
    let p = headA
    let q = headB
    while (p !== q) {
        p = p ? p.next : headB
        q = q ? q.next : headA
    }
    return p
};

这种解法的时间复杂度为O(m+n),空间复杂度为O(1)

236. 二叉树的最近公共祖先

示例 1:

输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出: 3
解释: 节点 5 和节点 1 的最近公共祖先是节点 3

示例 2:

输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出: 5
解释: 节点 5 和节点 4 的最近公共祖先是节点 5 。因为根据定义最近公共祖先节点可以为节点本身。

示例 3:

输入: root = [1,2], p = 1, q = 2
输出: 1
思路

如果当前节点是空节点,就返回当前节点,如果当前节点是p或者q也就返回当前节点,当左右子树都能找到时,他们的公共祖先也就是当前节点,只有左子树能找到就返回左子树递归的结果,只有右子树能找到就返回右子树递归的结果。

var lowestCommonAncestor = function (root, p, q) {
    if (!root || root === p || root === q) return root
    let left = lowestCommonAncestor(root.left, p, q)
    let right = lowestCommonAncestor(root.right, p, q)
    if (left && right) return root
    if (left) return left
    if (right) return right
};

234. 回文链表

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。

 

示例 1:

输入: head = [1,2,2,1]
输出: true

示例 2:

输入: head = [1,2]
输出: false

 

提示:

  • 链表中节点数目在范围[1, 105] 内
  • 0 <= Node.val <= 9

 

进阶: 你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

解法一
思路

将链表的每个值都存进数组里,然后使用双指针来比较前后两端的元素,一个指针从起点向中间移动看,另一个指针从终点向中间移动。

var isPalindrome = function (head) {
    const res = []
    while (head) {
        res.push(head.val)
        head = head.next
    }
    for (let i = 0, j = res.length - 1; i < j; i++, j--) {
        if (res[i] !== res[j]) return false
    }
    return true
};

由于要逐个访问n个元素,所以他的时间复杂度和空间复杂度都是O(n),来看下面的这个解法,就能让他的空间复杂度变成O(1)。

解法二
思路

找到中间节点并反转链表,如果链表有奇数个节点,就找他正中间的节点,如果链表有偶数个节点,就找他正中间右边的节点。

image.png

image.png 找到中间节点后把中间节点到链表末尾反转如上图所示,把他的头节点记为head2,然后从head2开始,依次遍历原链表的最后一个节点、倒数第二个节点...,最后同时遍历head和head2,循环比较两个值是否相等,相等就返回true,不等就返回false,循环结束后如果没有返回false,就说明链表是回文的。用这个解法前可以先刷下这两道题目 876. 链表的中间结点206. 反转链表

function midNode(head) {
    let slow = head
    let fast = head
    while (fast && fast.next) {
        slow = slow.next
        fast = fast.next.next
    }
    return slow
}
function reverseList(head) {
    let p1 = head
    let p2 = null
    while (p1) {
        let tem = p1.next
        p1.next = p2
        p2 = p1
        p1 = tem
    } return p2
}
var isPalindrome = function (head) {
    const mid = midNode(head)
    let head2 = reverseList(mid)
    while (head2) {
        if (head.val !== head2.val) return false
        head = head.next
        head2 = head2.next
    }
    return true
};

739. 每日温度

给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer ,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。

 

示例 1:

输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]

示例 2:

输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]

示例 3:

输入: temperatures = [30,60,90]
输出: [1,1,0]

 

提示:

  • 1 <= temperatures.length <= 105
  • 30 <= temperatures[i] <= 100
解法一
思路

直接暴力解法,对于每个温度,向后遍历查找第一个比他高的温度,然后计算间隔天数,用一个数组来存放0,对于比他高的温度后者-前者就是这个天数

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function (temperatures) {
    const res = new Array(temperatures.length)
    for (let i = 0; i < temperatures.length; i++) {
        res[i] = 0
        for (let j = i + 1; j < temperatures.length; j++) {
            if (temperatures[j] > temperatures[i]) {
                res[i] = j - i
                break
            }
        }
    }
    return res
};

这种解法是最直观的,但是这种解法在力扣上提交会超时 ,因为他的时间复杂度是O(n²),而力扣上的测试用例包含长度高达10⁵的数组,在最坏情况下要执行10¹⁰次比较操作,所以会超时

解法二
思路

单调栈,不主动寻找每个温度后面的第一个高温,当遇到高温时,回头解决之前没有解决的低温问题,用栈来存储温度索引,保持栈中的温度值单调递增。

/**
 * @param {number[]} temperatures
 * @return {number[]}
 */
var dailyTemperatures = function (temperatures) {
    const res = new Array(temperatures.length).fill(0)
    const stack = []
    for (let i = 0; i < temperatures.length; i++) {
        while (stack && temperatures[i] > temperatures[stack[stack.length - 1]]) {
            const index = stack.pop()
            res[index] = i - index
        }
        stack.push(i)
    }
    return res
};

226. 翻转二叉树

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

 

示例 1:

转存失败,建议直接上传图片文件

输入: root = [4,2,7,1,3,6,9]
输出: [4,7,2,9,6,3,1]

示例 2:

转存失败,建议直接上传图片文件

输入: root = [2,1,3]
输出: [2,3,1]

示例 3:

输入: root = []
输出: []

 

提示:

  • 树中节点数目范围在 [0, 100] 内
  • -100 <= Node.val <= 100
思路

用深度优先搜索(DFS)递归的交换每个节点的左右子节点,核心就是利用分治思想将大问题分解为小问题,然后递归的解决每个子问题

var invertTree = function (root) {
    if (!root) return null
    return {
        val: root.val,
        left: invertTree(root.right),
        right: invertTree(root.left)
    }
};

221. 最大正方形

在一个由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。

 

示例 1:

转存失败,建议直接上传图片文件

输入: matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
输出: 4

示例 2:

转存失败,建议直接上传图片文件

输入: matrix = [["0","1"],["1","0"]]
输出: 1

示例 3:

输入: matrix = [["0"]]
输出: 0

 

提示:

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m, n <= 300
  • matrix[i][j] 为 '0' 或 '1'
思路

用动态规划来解决,以每个位置为右下角,计算能形成的最大正方形的边长,用dp[i][j]来表示以(i,j)为右下角顶点的,只包含'1'的最大正方形的边长,其状态转移方程为 dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1,比如说要形成一个边长为k的正方形,就必须满足上方能形成变成为k-1的正方形,左方能形成边长为k-1的正方形,左上方能形成边长为k-1的正方形,只有这三个条件都满足时,才能在当前位置扩展成边长为k的正方形

var maximalSquare = function (matrix) {
    let maxSideLen = 0
    let dp = new Array(matrix.length)
    for (let i = 0; i < matrix.length; i++) {
        dp[i] = new Array(matrix[i].length).fill(0)
        for (let j = 0; j < matrix[i].length; j++) {
            if (matrix[i][j] === '1') {
                if (i === 0 || j === 0) {
                    dp[i][j] = 1
                } else {
                    dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1],dp[i - 1][j - 1]) + 1
                }
                maxSideLen = Math.max(maxSideLen, dp[i][j])
            }
        }
    }
    return maxSideLen * maxSideLen
}

215. 数组中的第K个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

 

示例 1:

输入: [3,2,1,5,6,4], k = 2
输出: 5

示例 2:

输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4

 

提示:

  • 1 <= k <= nums.length <= 105
  • -104 <= nums[i] <= 104
思路

可以使用快速选择的思想来解决,要找第k个最大元素,其实就相当于找第(n-k)小的元素,我们可以先选中一个基准值,然后使用双指针从数组两端向中间扫描,将小于基准值的元素移到左边,大于基准值的元素移到右边,完成一次分区操作后,再根据目标元素应该所在区域来决定继续在左半部分还是右半部分进行递归搜索,这样的话每次都能排除掉一部分元素而不需要处理整个数组

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function (nums, k) {
  const n = nums.length;
  return quickSelect(nums, 0, n - 1, n - k); // n-1是数组长度-1,n-k是第k大的元素对应的下标
};

function quickSelect(nums, l, r, k) {
  // l 和 r 是左右边界,k 是要找的第 k 小元素的下标
  if (l === r) return nums[k];
  const x = nums[l];
  let i = l - 1,
    j = r + 1; // 因为下面的do while循环会先自增或自减一次,所以这里要-1和+1
  while (i < j) {
    // i<j 说明还没有扫描完的数组
    do i++;
    while (nums[i] < x);
    do j--;
    while (nums[j] > x);
    if (i < j) {
      [nums[i], nums[j]] = [nums[j], nums[i]];
    }
  }
  if (k <= j) {
    return quickSelect(nums, l, j, k);
  } else {
    return quickSelect(nums, j + 1, r, k);
  }
}

208. 实现 Trie (前缀树)

207. 课程表

206. 反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

 

示例 1:

输入: head = [1,2,3,4,5]
输出: [5,4,3,2,1]

示例 2:

输入: head = [1,2]
输出: [2,1]

示例 3:

输入: head = []
输出: []
思路

迭代法,先创建一个空链表,然后用头插法依次把节点插入到这个新链表的头部,就得到了反转后的链表

var reverseList = function (head) {
    let p1 = head
    let p2 = null
    while (p1) {
        let temp = p1.next
        p1.next = p2
        p2 = p1
        p1 = temp
    }
    return p2
};

函数组件和异步组件

2025年10月29日 22:53

“函数式组件” 和 “异步组件” 是 Vue 中两种不同定位的组件形态,前者通过 “无状态、无实例” 精简渲染流程,后者通过 “按需加载” 减少初始资源体积,二者从不同维度优化性能,具体解析如下:

一、函数式组件:无状态、无实例的 “轻量渲染器”

1. 核心定义

函数式组件是 仅接收 props 和 context 作为参数、无自身状态(无 data/reactive)、无组件实例(无 this)、无生命周期钩子 的组件,本质是一个 “纯函数”—— 输入 props 后直接返回虚拟 DOM,不参与组件实例的创建和挂载流程。

在 Vue 2 中需通过 functional: true 声明,Vue 3 中则直接用 “无 <script setup> 的单文件组件” 或 “返回虚拟 DOM 的函数” 实现,例如:

组件UserCard

<!-- Vue 3 函数式组件:仅渲染,无状态 -->
<template functional>
  <div class="user-card">
    <img :src="props.avatar" alt="用户头像" />
    <div>{{ props.name }}</div>
  </div>
</template>

<script>
// 也可通过 JS 定义:接收 props,返回虚拟 DOM
export default function UserCard(props) {
  return h('div', { class: 'user-card' }, [
    h('img', { src: props.avatar, alt: '用户头像' }),
    h('div', props.name)
  ]);
}
</script>

2. 函数组件≠jsx

函数组件可以用jsx写,jsx只是一种语法,函数组件的强调在状态和实例

举个例子:

// 用 JSX 写的普通组件(有状态,不是函数组件)
import { ref } from 'vue';

export default () => {
  // 有自身状态(count),不符合函数组件“无状态”特征
  const count = ref(0);

  // 有自身事件处理逻辑
  const handleAdd = () => {
    count.value++;
  };

  // JSX 渲染,但组件是普通组件
  return (
    <div>
      <span>计数:{count.value}</span>
      <button onClick={handleAdd}>+1</button>
    </div>
  );
};

2. 性能提升原理:跳过 “组件实例创建” 流程

Vue 普通组件的渲染需经历 “创建组件实例 → 初始化状态 → 执行生命周期 → 渲染虚拟 DOM” 等完整流程,而函数式组件会 跳过 “实例创建” 和 “状态初始化” 步骤,直接根据 props 生成虚拟 DOM,减少内存占用和渲染耗时。

3. 适用场景:纯展示、高频复用的轻量组件

仅当组件满足 “无状态、仅渲染” 时,用函数式组件才能提升性能,典型场景:

  • 列表项组件:如表格行、列表项(v-for 循环渲染几十上百个的场景,减少实例数量);
  • 纯展示组件:如标签(Tag)、头像(Avatar)、按钮组(ButtonGroup)等无交互或仅触发父组件事件的组件;
  • 高阶组件包装:如用于封装逻辑、生成新组件的 “容器组件”(无自身状态,仅转发 props)。

注意:若组件需状态(如 ref/reactive)、生命周期(如 onMounted)或复杂交互(如内部事件处理),则不适合用函数式组件 —— 强行使用会导致代码复杂度上升,反而抵消性能优势。

二、异步组件:按需加载的 “延迟渲染组件”

1. 核心定义

异步组件是 不在初始渲染时加载,而是在 “需要时”(如路由跳转、条件渲染触发)才动态加载组件代码 的组件,本质是通过 “代码分割” 将组件打包成独立的 chunk 文件,避免初始包体积过大。

Vue 3 中通过 defineAsyncComponent 声明,Vue 2 中通过 “返回 Promise 的函数” 声明,例如:

// Vue 3 异步组件:路由跳转时才加载 UserDetail 组件
import { defineAsyncComponent } from 'vue';
import { Loading, ErrorComponent } from './components';

// 定义异步组件,指定加载函数、加载中/加载失败占位组件
const UserDetail = defineAsyncComponent({
  loader: () => import('./UserDetail.vue'), // 动态导入,打包成独立 chunk
  loadingComponent: Loading, // 加载中显示的组件
  errorComponent: ErrorComponent, // 加载失败显示的组件
  delay: 200, // 延迟 200ms 显示加载组件(避免闪屏)
  timeout: 3000 // 3 秒加载超时则显示错误组件
});

// 路由配置中使用:访问 /user/:id 时才加载 UserDetail
const routes = [
  { path: '/user/:id', component: UserDetail }
];

2. 性能提升原理:减少初始资源体积

普通组件会被打包到主包(如 app.js)中,若项目包含大量组件(如几十个页面组件),会导致初始包体积过大(如超过 2MB),首屏加载时间变长;而异步组件会 被单独打包成小 chunk(如 UserDetail.[hash].js ,初始加载时仅下载主包,需要时再通过网络请求加载组件 chunk,从而减少首屏加载时间和初始内存占用。

三、核心差异

维度 函数式组件 异步组件
核心特性 无状态、无实例、同步渲染 有状态 / 无状态均可、异步加载、延迟渲染
性能优化点 减少组件实例创建开销,提升渲染速度 减少初始包体积,提升首屏加载速度
适用组件类型 纯展示、高频复用的轻量组件 非首屏、条件触发的重量级组件
代码分割 不涉及代码分割,组件代码在主包中 强制代码分割,组件代码在独立 chunk 中

怎么理解函数式组件会 跳过 “实例创建” 和 “状态初始化” 步骤,呢?

Vue 中普通组件和函数式组件的渲染流程差异,本质是 “是否创建组件实例” 导致的流程分支。下面从源码的角度去拆分下这个过程:

一、普通组件的完整渲染流程(包含实例创建)

普通组件的渲染是一个 “从组件定义到 DOM 挂载” 的完整生命周期,可分为 5 个核心步骤,每一步都和 “组件实例” 强绑定:

1. 解析组件定义,准备创建实例

当 Vue 解析到模板中的组件标签(如 <UserForm>)时,会先读取该组件的选项定义(data/methods/computed 等),然后调用 Vue 内部的 createComponentInstance 方法,初始化一个组件实例对象(VNode 组件实例) 。这个实例对象会包含:

  • 基础属性:uid(唯一标识)、vnode(虚拟 DOM 节点)、parent(父实例)等;
  • 状态容器:ctx(上下文,用于存放 data/props 等)、setupState(组合式 API 的状态)等;
  • 方法引用:emit 方法、生命周期钩子队列等。

2. 初始化组件状态(实例的核心工作)

实例创建后,Vue 会执行 initComponent 方法,为实例 “注入” 状态和能力:

  • 处理 props:将父组件传入的 props 解析后挂载到实例的 ctx 中(如 this.props.name);
  • 初始化 data:执行 data() 函数,将返回的对象通过 reactive 转为响应式数据,挂载到实例(如 this.count);
  • 绑定 computed/watch:将计算属性和监听器与实例关联,依赖收集时绑定到实例的更新逻辑;
  • 处理生命周期:将 mounted/updated 等钩子函数添加到实例的钩子队列,等待触发时机。

3. 执行初始化生命周期钩子

实例状态准备好后,Vue 会按顺序执行初始化阶段的生命周期钩子:

  • beforeCreate:此时 props 和 data 尚未挂载到实例,无法访问;
  • createdprops 和 data 已初始化,可通过 this 访问,但 DOM 尚未生成。

4. 渲染虚拟 DOM(VNode)

初始化完成后,Vue 调用实例的 render 方法(模板会被编译为 render 函数),生成组件的虚拟 DOM 树(VNode)。这个过程中,render 函数通过 this 访问实例上的 props/data(如 this.name),最终生成描述 DOM 结构的 VNode 对象(包含标签名、属性、子节点等信息)。

5. 挂载真实 DOM,执行挂载生命周期

  • 虚拟 DOM 转真实 DOM:Vue 调用 patch 方法,将 VNode 转换为真实 DOM 节点,并插入到父组件的 DOM 中;
  • 执行挂载钩子:触发 beforeMount → 真实 DOM 挂载完成 → 触发 mounted
  • 实例关联 DOM:实例的 el 属性(Vue 2)或 vnode.el(Vue 3)指向真实 DOM,方便后续更新。

二、函数式组件的渲染流程(跳过实例创建)

函数式组件因为 “无状态、无实例”,流程被大幅简化,直接跳过 “实例创建” 和 “状态初始化”,仅保留 “输入 props → 输出 VNode” 的核心步骤:

1. 解析组件定义,确认函数式标识

当 Vue 解析到函数式组件(如 <template functional> 或返回 VNode 的函数)时,会识别其 “函数式” 标识(Vue 3 中通过 functional: true 或无状态函数判断),直接进入轻量渲染流程。

2. 直接接收 props 和上下文(无实例,无初始化)

  • 函数式组件没有实例,所以不需要创建 componentInstance 对象;

  • 父组件传入的 props 和上下文(slots/emit 等)会被直接打包成参数,传递给渲染函数(模板或 JSX 函数)。例如:

    • 模板式函数组件中,通过 props.xxx 直接访问数据,context.emit 触发事件;
    • JSX 函数组件中,函数参数直接接收 props 和 context(props, context) => { ... })。

3. 生成虚拟 DOM(直接渲染,无生命周期)

函数式组件的 “渲染函数”(模板编译后的函数或 JSX 函数)会直接使用 props 和 context 生成 VNode,过程中:

  • 不需要访问 this(因为没有实例);
  • 不需要处理响应式数据初始化(props 由父组件传入,已在父组件中完成响应式处理);
  • 没有生命周期钩子(无需执行 created/mounted 等)。

4. 挂载真实 DOM(复用父组件的挂载流程)

生成的 VNode 会直接进入父组件的 patch 流程,和父组件的其他节点一起被转换为真实 DOM。因为没有实例,所以 DOM 挂载后也不会触发任何生命周期钩子,完成渲染后即结束。

三、一句话总结

普通组件的渲染是 “先创建一个‘管理者(实例)’,由管理者统筹状态、生命周期和渲染”,流程完整但冗余;函数式组件的渲染是 “无管理者,直接用输入数据生成输出结果”,跳过所有和实例相关的步骤,因此更轻量、更快。

昨天 — 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…

❌
❌