阅读视图

发现新文章,点击刷新页面。

el-calendar实现自定义展示效果

最终实现的日历效果

image.png

vue3页面已实现,可直接拿来用,背景需要自定义,这里把日历背景色设为透明了

<template>
    <el-calendar ref="calendar" v-model="data.value">
        <template #header="{ date }">
            <div class="flex-between w100">
                <div class="flex-start">
                    <!-- 上一年 -->
                    <el-icon color="#445cbf" @click="selectDate('prev-year')"><DArrowLeft /></el-icon>
                    <!-- 上一月 -->
                    <el-icon color="#445cbf" @click="selectDate('prev-month')"><ArrowLeft /></el-icon>
                </div>
                <span @click="selectDate('today')">{{ date }}</span>
                <div class="flex-start">
                    <!-- 下个月 -->
                    <el-icon color="#445cbf" @click="selectDate('next-month')"><ArrowRight /></el-icon>
                    <!-- 下一年 -->
                    <el-icon color="#445cbf" @click="selectDate('next-year')"><DArrowRight /></el-icon>
                </div>
            </div>
        </template>
    </el-calendar>
</template>
<script setup lang="ts">
import { reactive,ref } from "vue";
import type { CalendarDateType, CalendarInstance } from 'element-plus'
import { DArrowLeft, ArrowLeft, ArrowRight, DArrowRight } from '@element-plus/icons-vue'

const calendar = ref<CalendarInstance>()
const selectDate = (val: CalendarDateType) => {
  if (!calendar.value) return
  calendar.value.selectDate(val)
}
const data = reactive({
  value: new Date(),
});
</script>
<style lang="scss" scoped>

</style>
<style lang="scss">
.el-calendar{
    background: transparent;
    font-size: 14px;
}
.el-calendar-table .el-calendar-day{
    height: 35px;
    text-align: center;
}
.el-calendar-table thead th{
    color:#999;
}
.el-calendar-table tr td:first-child,.el-calendar-table tr:first-child td{
    border:0;
}

.el-calendar-table td{
    border:0;
}
.el-calendar-table td.is-today{
    background: #456cef;
    border-radius: 10px;
    color:#fff;
}
.el-calendar-table td.is-selected,.el-calendar-table .el-calendar-day:hover{
    border-radius: 10px;
}
</style>

Lua中的三个点(...):解锁函数参数的无限可能

一、基础:... 表达式

在函数的参数列表中,... 被用作最后一个参数,表示该函数可以接受任意数量的额外参数。

function sum(...)
    -- 1. 将所有可变参数打包到一个名为 'args' 的 table 中
    local args = { ... }
    local total = 0

    -- 2. 像遍历普通 table 一样遍历参数
    for i, v in ipairs(args) do
        total = total + v
    end

    return total
end

print(sum(1, 2, 3))       -- 输出: 6
print(sum(10, 20, 30, 40)) -- 输出: 100
print(sum())              -- 输出: 0

解析:在函数内部,... 并不是一个变量,而是一个表达式(Expression)。它代表了所有传递给它的可变参数的一个序列。

二、nil 的陷阱与专业工具

上面的 sum 函数看起来很完美,但它有一个隐藏的陷阱:如果参数中包含 nil会停止解析nil后续的参数,所以就需要selecttable.pack来正确解析不定参数。

local args = { 10, 20, nil, 40 }
-- ipairs(args) 在遇到 nil 后会停止
-- #args 的行为也可能不符合预期

1. select('#', ...): 获取参数的真实数量

select 函数是一个强大的内建工具。当它的第一个参数是字符串 '#' 时,它会返回传递给它的可变参数的确切数量,无视 nil

function count_args(...)
    -- 这才是获取可变参数数量的最可靠方法
    local arg_count = select('#', ...)
    return arg_count
end

print(count_args(1, "hello", nil, true)) -- 输出: 4

2. table.pack(...): 安全地打包参数

从 Lua 5.2 开始,官方提供了一个更好的打包函数 table.pack。它同样会将所有参数打包到一个 table 中,但会额外添加一个 n 字段,用于存储参数的真实数量(等同于 select('#', ...) 的结果)。

function safe_sum(...)
    -- 使用 table.pack 进行安全打包
    local args = table.pack(...)
    local total = 0

    -- 使用 args.n 来进行安全的循环,而不是 ipairs
    for i = 1, args.n do
        local v = args[i]
        print(v) -- 打印每个参数以进行调试
        -- 确保我们只对数字进行相加
        if type(v) == "number" then
            total = total + v
        end
    end
    return total
end

print(safe_sum(1, 2, nil, 4)) -- 输出: 7 (nil 被安全地跳过了)

结语

点个赞,关注我获取更多实用 Lua 技术干货!如果觉得有用,记得收藏本文!

webpack分包优化简单分析

分包是什么

“分包” 就是按 “使用时机” 和 “功能” 将代码分割成多个小文件,核心是 “按需加载”,解决传统单包模式下 “体积过大、加载慢” 的问题。

  • 路由分包、组件分包、第三方库分包是最常用的三种方式;
  • 实现上主要依赖 import() 动态导入语法和打包工具(Webpack/Vite)的配置;
  • 最终目标是让用户 “用什么加载什么”,提升页面打开速度和交互体验。

为什么分包能优化性能?

  1. 减少首屏加载时间:只加载必要代码,缩小初始下载体积;
  2. 利用浏览器缓存:第三方库、不常更新的代码被缓存,后续访问更快;
  3. 避免重复加载:多个页面共用的代码(如公共组件)可拆分成 “共享包”,加载一次后复用。

分包后的 “加载流程”:浏览器如何处理多个包?

  1. 首屏加载:浏览器下载主包(app.js)和当前页面必需的分包(如首页路由的 home.js);

  2. 解析执行:主包代码先执行,初始化应用(如创建 Vue 实例、配置路由);

  3. 按需加载:当用户触发某个操作(如跳转路由、点击按钮),需要新的分包时:

    • 浏览器通过 import() 动态请求对应的分包文件(如 order.js);
    • 下载完成后,执行分包代码并渲染新内容(过程中可显示 “加载中” 提示)。

分包后的三个基本方向

1. 路由分包(最常用):按页面拆分,访问时才加载对应页面代码

2. 组件分包:按组件拆分,用到时才加载大型组件

3. 第三方库分包:将大型依赖单独拆分,利用缓存

1. 路由分包

路由拆分的关键是修改 router/index.js 中 “路由组件的导入方式”,将静态 import 改为动态 () => import()

(1)未拆分的静态导入(反面示例)

所有页面代码会打包到一起,不推荐:

// router/index.js(未拆分,不推荐)
import Vue from 'vue';
import Router from 'vue-router';
// 静态导入所有路由页面(会全部打包到核心 JS)
import Home from '@/views/Home'; 
import About from '@/views/About';
import User from '@/views/User';

Vue.use(Router);

export default new Router({
  routes: [
    { path: '/', name: 'Home', component: Home },
    { path: '/about', name: 'About', component: About },
    { path: '/user', name: 'User', component: User }
  ]
});
(2)拆分后的动态导入(正确示例)

每个路由页面会被拆分为独立 Chunk:

// router/index.js(已拆分,推荐)
import Vue from 'vue';
import Router from 'vue-router';
// 无需静态导入页面,改为动态导入
import ElementUI from 'element-ui'; // 第三方 UI 库(会被拆到 chunk-vendors)
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(Router);
Vue.use(ElementUI);

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      // 动态导入:Home 页面会被拆分为独立 Chunk
      component: () => import('@/views/Home') 
    },
    {
      path: '/about',
      name: 'About',
      // 可选:给 Chunk 自定义名称(打包后文件名更清晰)
      component: () => import(/* webpackChunkName: "about-page" */ '@/views/About')
    },
    {
      path: '/user',
      name: 'User',
      component: () => import('@/views/User')
    }
  ]
});

打包前后对比:

非按需引入:

137cb84f27a2797ebbd8967f67983a8b.jpg

按需引入:

a2cf5fe7ed90b6f84b46857077eef9fe.jpg

2. 组件分包

组件的分包拆分(即 “异步组件”)是前端性能优化的关键手段之一,核心是将 “非首屏必需、体积较大或按需加载的组件” 从主页面代码中分离,单独打包成独立文件,仅在组件被使用时才加载。

一、先明确:哪些组件需要分包拆分?

不是所有组件都需要拆分,以下三类组件是 “分包重点”:

  1. 体积大的组件:包含大量 DOM 结构、复杂逻辑(如数据可视化图表、富文本编辑器)或依赖第三方库(如 ECharts 图表组件),单组件体积超过 100KB 时建议拆分。
  2. 按需触发的组件:用户操作后才显示的组件(如弹窗、抽屉、下拉菜单、折叠面板),默认隐藏状态下无需加载。
  3. 低频率使用的组件:如 “帮助中心”“关于我们”“投诉反馈” 等入口对应的组件,用户很少点击,没必要随页面初始加载。

二、Vue 中组件分包的实现方式(Vue 2 和 Vue 3)

1. Vue 2 中的实现:动态导入注册组件

Vue 2 中通过 “动态 import + 组件注册” 实现分包,无需额外 API:

<!-- 页面组件:ProductDetail.vue(商品详情页) -->
<template>
  <div>
    <!-- 主内容:立即加载 -->
    <div class="product-basic">图片、标题、价格...</div>
    
    <!-- 按需加载的组件:点击按钮才显示 -->
    <el-button @click="showComment = true">查看评价</el-button>
    <comment-list v-if="showComment" /> <!-- 评价列表组件(需拆分) -->
  </div>
</template>

<script>
export default {
  components: {
    // 关键:动态导入组件,实现分包
    CommentList: () => import('@/components/CommentList.vue') 
  },
  data() {
    return {
      showComment: false // 控制组件显示,初始为 false(不加载)
    };
  }
};
</script>
  • 原理:() => import('路径') 告诉 Webpack/Vite:“这个组件不是必须的,打包时单独拆成一个文件”。
  • 加载时机:只有当 showComment 变为 true(用户点击按钮)时,浏览器才会请求 CommentList 对应的 JS/CSS 文件。

2. Vue 3 中的实现:defineAsyncComponent(更强大、更完善、给vue官方点👍)

Vue 3 提供了 defineAsyncComponent API,专门用于异步组件,支持加载状态、错误处理等高级配置:

<!-- 页面组件:ProductDetail.vue -->
<template>
  <div>
    <div class="product-basic">图片、标题、价格...</div>
    <el-button @click="showComment = true">查看评价</el-button>
    <CommentList v-if="showComment" />
  </div>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue';
// 导入加载中、加载失败的占位组件(可选)
import Loading from '@/components/Loading.vue';
import Error from '@/components/Error.vue';

// 关键:用 defineAsyncComponent 定义异步组件,实现分包
const CommentList = defineAsyncComponent({
  loader: () => import('@/components/CommentList.vue'), // 动态导入路径
  loadingComponent: Loading, // 组件加载过程中显示的占位符
  errorComponent: Error, // 组件加载失败时显示的内容
  delay: 200, // 延迟 200ms 显示 loading(避免一闪而过)
  timeout: 5000 // 5秒内未加载完成则视为失败
});

const showComment = ref(false);
</script>
  • 优势:相比 Vue 2 的简单动态导入,defineAsyncComponent 能处理加载状态(避免用户看到空白)和错误情况(如网络故障),体验更友好。

三、自定义分包名称与公共组件拆分

1. 自定义分包文件名(便于调试)

默认情况下,拆分的组件文件会以哈希值命名(如 123.js),可通过 Webpack 魔法注释自定义名称:

// Vue 2 中
components: {
  CommentList: () => import(/* webpackChunkName: "comment-list" */ '@/components/CommentList.vue')
}

// Vue 3 中
const CommentList = defineAsyncComponent({
  loader: () => import(/* webpackChunkName: "comment-list" */ '@/components/CommentList.vue')
});

打包后会生成 comment-list.xxxx.js,更易识别。

2. 多个异步组件合并拆分(避免文件过多)

如果多个小异步组件(如弹窗 A、弹窗 B)都依赖同一个工具函数,可通过 “统一 chunk 名称” 将它们合并打包:

// 弹窗 A 组件
const PopupA = () => import(/* webpackChunkName: "popups" */ '@/components/PopupA.vue');
// 弹窗 B 组件
const PopupB = () => import(/* webpackChunkName: "popups" */ '@/components/PopupB.vue');

打包后,PopupA 和 PopupB 会合并到 popups.xxxx.js 中,避免生成过多小文件(小文件过多会增加 HTTP 请求次数)。

3. 避免过度拆分(反优化)

  • 体积小于 30KB 的组件无需拆分(拆分后增加的 HTTP 请求成本可能超过体积优化收益)。
  • 首屏必需的组件(如导航栏、页脚)不能拆分(拆分会导致首屏显示延迟)。

四、如何验证组件是否拆分成功?

  1. 打包后查看产物:执行 npm run build,在 dist/js 目录中查找是否有组件对应的独立文件(如 comment-list.xxxx.js)。

  2. 浏览器 Network 面板

    • 打开页面,初始加载时观察 Network 中的 JS 文件,确认异步组件的文件未被加载。
    • 触发组件显示(如点击 “查看评价”),此时会看到浏览器新请求该组件的 JS/CSS 文件,说明拆分生效。

注意

组件的分包拆分是 “同一页面内的按需加载优化”,与路由拆分(不同页面的按需加载)形成互补。核心逻辑是:用动态导入让非必需组件 “延迟加载”,减少首屏代码体积。实现时需注意 “按需拆分”(只拆大组件、按需组件),避免过度拆分导致请求增多。

第三方库的拆分

  1. Vue CLI 官方文档 - 构建优化在 Vue CLI 官方文档的「构建优化」章节中提到,其内置的 Webpack 配置会自动拆分代码,具体包括:

    • 分离第三方库(如 vuevue-router 等)和应用代码,避免第三方库被重复打包。
    • 拆分公共代码(多页面应用中共享的代码),减少整体打包体积。

    文档中明确说明:Vue CLI 的默认配置已针对大多数应用做了优化,包括合理的代码拆分策略。

  2. Vue CLI 内置 Webpack 配置解析Vue CLI 通过 @vue/cli-service 封装了 Webpack 配置,其默认的 splitChunks 配置逻辑可通过以下方式验证:

    • 执行 vue inspect --plugin splitChunks 命令(在 Vue CLI 项目根目录),可查看内置的代码拆分配置。

    • 输出结果中会包含类似以下的核心配置(简化版):

      splitChunks: {
        chunks: 'all', // 对所有类型的 chunk(初始、异步、所有)进行拆分
        cacheGroups: {
          vendors: {
            name: 'chunk-vendors', // 第三方库拆分后的文件名
            test: /[\/]node_modules[\/]/, // 匹配 node_modules 中的第三方库
            priority: 10, // 优先级高于默认的 common 组
            chunks: 'initial' // 针对初始 chunk 拆分
          },
          common: {
            name: 'chunk-common', // 公共代码拆分后的文件名
            minChunks: 2, // 被至少 2 个 chunk 共享才会拆分
            priority: 1, // 优先级低于 vendors 组
            reuseExistingChunk: true // 复用已存在的 chunk
          }
        }
      }
      

      这一配置明确将 node_modules 中的第三方库(如 vueaxios 等)拆分为 chunk-vendors.js,而应用自身代码和公共组件拆分为其他 chunk,与官方描述一致。也就是所有的三方库为一个大的文件,其他的为一个文件这样的形式去打包

如果对三方库各自进行打包?

假设项目有两个独立业务模块:

  • 数据可视化模块:依赖 echartschart.js
  • 文档处理模块:依赖 xlsxpdfjs-dist

默认分包会把这 4 个库全部混入 chunk-vendors.js,如果用户只访问 “数据可视化模块”,xlsx 和 pdfjs-dist 的代码就是 “无效加载”;且只要其中一个库更新(如 echarts 升级),整个 chunk-vendors.js 的 hash 会变,导致所有依赖这个包的页面缓存失效。

手动分库解决:

按业务模块拆分第三方库,让每个模块的依赖独立打包:

// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      splitChunks: {
        cacheGroups: {
          // 1. 数据可视化模块的第三方库
          vendor-visual: {
            test: /[\/]node_modules[\/](echarts|chart.js)[\/]/,
            name: 'chunk-vendor-visual', // 独立包:仅包含可视化相关库
            priority: 20,
            chunks: chunk => chunk.name.includes('visual') // 仅对“可视化模块页面”生效
          },
          // 2. 文档处理模块的第三方库
          vendor-doc: {
            test: /[\/]node_modules[\/](xlsx|pdfjs-dist)[\/]/,
            name: 'chunk-vendor-doc', // 独立包:仅包含文档相关库
            priority: 20,
            chunks: chunk => chunk.name.includes('doc') // 仅对“文档模块页面”生效
          },
          // 3. 通用核心库(vue、vue-router 等)
          vendors: {
            test: /[\/]node_modules[\/]/,
            name: 'chunk-vendors',
            priority: 10,
            // 排除上述两个业务模块的依赖
            exclude: /[\/]node_modules[\/](echarts|chart.js|xlsx|pdfjs-dist)[\/]/
          }
        }
      }
    }
  }
};

结果:

  • 用户访问 “可视化模块” 时,仅加载 chunk-vendors.js + chunk-vendor-visual.js,无无效代码;
  • 当 echarts 升级时,仅 chunk-vendor-visual.js 的 hash 变化,chunk-vendor-doc.js 和通用 chunk-vendors.js 的缓存不受影响,提升后续访问速度。
  1. 第三方库的打包是按需引入好还是全局引入好

先明确两种引用方式的打包差异

不管是 Vue CLI 还是 Vite,对 Vant UI 的打包处理逻辑都和 “引用范围” 强相关,先理清本质差异:

引用方式 打包结果 核心逻辑
全局引用 所有 Vant 组件(即使没用到)都打包进 chunk-vendors.js(或类似第三方库 chunk),最终只有 1 个第三方库文件 全局注册时,Webpack/Vite 会把整个 vant 包视为 “必需依赖”,无法 Tree-Shaking 剔除未使用组件
按需引用 只打包你实际用到的 Vant 组件(如 ButtonDialog),每个组件(或组件组)可能拆成独立小 chunk(如 chunk-vant-button.js),最终会多几个小文件 按需引入时(如 import Button from 'vant/lib/button' 或用 Vant 插件),工具能精准识别 “用到的代码”,未使用组件被 Tree-Shaking 剔除,同时按组件拆分 chunk

1. 优先选 “全局引用” 的场景

  • 小项目 / 工具类项目:如内部管理后台、简单的活动页,用到的 Vant 组件少(或几乎全用),且对首屏加载速度要求不高(用户多为内部人员,网络环境稳定)。
  • 快速迭代 / 原型开发:需要快速出效果,不想在 “组件引入” 上花时间,优先保证开发效率。

2. 优先选 “按需引用” 的场景

  • 首屏优化敏感项目:如 C 端用户产品(电商、社交 App 前端),首屏加载速度直接影响用户留存,需要极致减小首屏资源体积(LCP 指标要求 ≤2.5s)。
  • 只用到少量 Vant 组件:如项目只需要 Vant 的 ButtonToastDialog 3 个组件,按需引用能避免打包 150KB+ 的全量包,体积优势明显。
  • 用 HTTP/2 部署:现代服务器基本支持 HTTP/2,多路复用能并行处理多请求,“多文件” 的请求成本几乎可以忽略,按需引用的 “体积小” 优势被放大。

分包一定好吗

Vue CLI 默认会对 “体积超过 30KB(压缩前)” 的依赖单独拆分,但有时会出现两种问题:

  1. 小库过多:多个体积很小的依赖(如 lodash-es 的子模块、date-fns)被拆分成多个小 chunk,导致浏览器请求数增加(HTTP/1.1 环境下会阻塞加载);
  2. 重复依赖:不同业务包中重复引入了同一依赖(如 lodash 的 debounce 方法),默认未合并,导致代码冗余。

手动分库解决:

  • 合并小库:将多个小体积依赖合并到一个 chunk,减少请求数;
  • 提取重复依赖:将重复引入的依赖单独拆分,实现复用。
// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      splitChunks: {
        minSize: 10000, // 调整最小分包体积(如 10KB 以下不单独拆分)
        cacheGroups: {
          // 合并小体积工具库(lodash-es、date-fns 等)
          vendor-utils: {
            test: /[\/]node_modules[\/](lodash-es|date-fns|dayjs)[\/]/,
            name: 'chunk-vendor-utils', // 合并成一个工具库包
            priority: 20,
            minSize: 0, // 强制合并,忽略 minSize 限制
            minChunks: 2 // 被引用超过 2 次才拆分(避免单次引用的小库被合并)
          }
        }
      }
    }
  }
};

手动分库打包的核心判断标准(常规情况下)

当满足以下任一条件时,就需要手动干预 Vue CLI 的第三方库分包:

  1. 首屏 vendor 包体积过大(如超过 1MB),导致首屏加载慢;
  2. 第三方库按业务模块划分明确,需要拆分以优化缓存;
  3. 存在非标准依赖(私有库、CDN 依赖),默认分包未覆盖;
  4. 默认分包粒度不合理(小库过多导致请求数增加,或重复依赖导致冗余)。

简单说:Vue CLI 的默认分包是 “通用方案”,当项目有个性化的性能优化需求特殊依赖场景时,就需要手动配置 splitChunks 来调整分库逻辑。

总的来说分包常规配置就够用啦,无特殊需求千万别动,代码能跑就是好代码

没开玩笑,全框架支持的 dialog 组件,支持响应式

前言

朋友们好啊,我是 auto-plugin 掌门人德莱厄斯。

刚才有个朋友问我,德老师发生什么事了,我说怎么回事,给我发了几张截图,我一看,嗷!原来是昨天,有两个小项目,两三个页面,一个只有表单收集页,一个是登录页加信息页。他们说,唉...有一个说是他不想在这种小项目中引入大型组件库,徳老师你能不能教教我 auto 功法,哎帮助开发一下,我的小项目。我说可以,我说你老用组件库大力出奇迹,不好用,他不服气。我说小朋友,你一个组件库来用在我 vue 和 react 上,他说用不了。他说你这也没用。我说我这个有用,这是统一,传统开发是讲究一次编译到处运行。二百个组件的大型组件库,掰不动我这一个小组件。

啊...哈,他非和我试试,我说可以。哎...我一说啪一下就给 element-plus 引入了,很快啊!然后上来就是一个 message,一个 tooltip,一个响应式布局。我全部防出去了啊,防出去以后,自然是传统开发点到为止,autohue 藏在 github 没给他看。我笑一下准备上班。由于这时间,按传统开发的点到为止他已经输了,如果 autohue 发力,一下就把他组件库打散架了,放在 github 没给他看,他也承认,说组件库没有这种功能。啊,我收手的时间不聊了,他突然袭击说 dialog 你没有,啊,我大意了啊,没有做。哎,他的 dialog 给我脸打了一下,但是没关系啊!他也说,啊他截图也说了,两分多钟以后,当时流眼泪了,捂着眼说,我说停停。然后两分钟以后,哎两分钟以后就好了,我说小伙子你不讲武德你不懂,说徳老师对不起对不起,我不懂规矩。啊,他说他是乱打的,他可不是乱打啊,mmessage、tooltip 训练有素,后来他说他练过两年开源,啊,看来是有备而来。这两个年轻人不讲武德,来骗,来偷袭!我 26 岁的老同志,这好吗这不好,我劝!这位年轻人耗子尾汁,好好反思,以后不要再犯这样的聪明,小聪明啊。啊,呃...开发要以和为贵,讲究统一,不要搞窝里斗,谢谢朋友们。


dialog 的场景往往出现在表单收集、确认问询的场景,在 JQ 时代,我们可能很常用浏览器自带的 alert,但是这东西会阻塞主进程,且样式也不太好控制,或者用 bootstrap 的组件。到了框架时代,出现了各种组件库,但是它们都存在几个问题

  1. 要用必须全量安装(虽然现在大家都支持树摇)
  2. 修改样式比较麻烦
  3. 不支持跨框架,感知不统一

他们跟自身生态、框架生态深度绑定,虽然大多数时候我们用起来很方便,心智负担也很低。但是他们不可避免地出现了上述三个问题。

那么如果像我刚才提到的,只做一两个页面的简单应用,也要引入组件库吗,要是引入组件库,你还要画两分钟思考一下,你要用 vue 生态还是 react 生态的。那有人说了,现在组件库也是跨框架支持啊,下载对应的包就行了,但是你看,截至目前2025年10月28日,ant-design 的 vue 版本还停留在 4.26 ,而 react 版本已经到了 5.27.6(BTW:antd for react 组件库现在已支持 autofit.js)。容易发现,使用不同的框架,即使是同一个组件库,不同框架开发体验也是不同的。

这在多元化我们的选择的同时,也割裂了开发的生态。

碰巧我最近又在写一个简单项目,需要一个 dialog 组件,我又讨厌原子化 css 的写法(这就是为什么不直接用 shadcn 的原因),怎么办呢,再实现一个得了。

autodialog.js


github: github.com/Auto-Plugin…


我取名可不是瞎取的(也瞎取过),这个 autodialog.js 是真正的框架无关的 dialog 组件。那么它的 auto 体现在哪呢?它可以自动识别传入的弹窗内容是来自什么框架!甚至不会破坏 vue 的响应式和 react 的状态,你甚至可以在 原生 html、svelte、solid、augular 中无缝使用,而不破坏框架本身的特性。更惊奇的是,调用方法是一模一样的


在说实现思路之前,我想让你先感受一下 autodialog.js 的使用

快速使用示例

原生 HTML

import autodialog from 'autodialog.js'

autodialog.show('<div>Hello World!</div>')

Vue 3

import autodialog from 'autodialog.js'
import MyDialog from './MyDialog.vue'

autodialog.show(MyDialog, {
  props: { title: '你好 Vue' })

React 18+

import autodialog from 'autodialog.js'
import MyDialog from './MyDialog.tsx'

autodialog.show(MyDialog, {
  props: { message: '你好 React' }
})

666 有没有?autodialog.js 内部自动判断了传入的组件类型,使无论什么框架的调用方式都完全一致!

QQ20251028-161029.webp

而且除了遮罩和最简单的动画外(当然也提供了自定义方式),其余样式完全由你的内容决定!你完全无需写多层选择器或者使用 !important 来覆盖样式。

原理解析

要实现这种跨框架,又保留框架特性,又保持感知统一的工具库,其实有一条成熟且稳妥的道路,那就是适配器(adapter)


autodialog.js 也是这样,它的 core 包是纯 js 的,但是接受各种各样的适配器,我定义的适配器格式如下:

/**
 * 适配器接口
 * - render: 渲染内容到 panel 上
 * - unmount: 卸载 panel 上的内容(可选)
 */
export interface Adapter {
  render: (content: any, options: { container: HTMLElement; panel: HTMLElement;[key: string]: any }) => void
  unmount?: (panel: HTMLElement) => void
}

比如要实现一个 vue 的适配器就是这样:

import { createApp, h, type Component } from 'vue'

interface VueRenderOptions {
  panel: HTMLElement
  title?: string
  props?: Record<string, any>
  onClose?: () => void
}

export const VueAdapter = {
  render(Component: Component, { panel, title, props = {}, onClose }: VueRenderOptions) {
    // 创建一个 Vue 应用实例
    const app = createApp({
      render() {
        return h('div', { class: 'autodialog-vue-wrapper' }, [
          title ? h('div', { class: 'autodialog-header' }, title) : null,
          h(Component, { ...props, onClose }),
        ])
      },
    })

    // 挂载到 panel
    app.mount(panel)
    ;(panel as any)._vueApp = app
  },

  unmount(panel: HTMLElement) {
    const app = (panel as any)._vueApp
    if (app) {
      app.unmount()
      delete (panel as any)._vueApp
    }
  },
}

当然,autodialog.js 已经内置了 vue 和 react 的适配器。

如果你使用 svelte,autodialog.js 没有内置,那么你可以使用适配器注册器函数来外部挂载一个适配器,像下面的章节(进阶使用)里就实现了一个 svelte 适配器。

你完全不必担心适配器很难写,因为你使用你熟悉的框架,如果你熟悉 vue,那么看一下上面的 vue 适配器,它也只是使用了 vue 的 render 和 h 函数。


在 autodialog.js 的 core 中,是这样自动判断内容来自什么组件,该用什么适配器的:

  /** 
   * 自动检测逻辑(detect 不强制
   */
  private detectAdapter(content: any): Adapter {
    // 1️⃣ 优先使用用户注册的自定义适配器
    for (const { detect, adapter } of Dialog.customAdapters) {
      try {
        // detect 可省略:省略则直接匹配
        if (!detect || detect(content)) return adapter
      } catch { }
    }

    // 2️⃣ 内置适配器兜底
    if (typeof content === 'string' || content instanceof HTMLElement || content instanceof DocumentFragment)
      return HtmlAdapter

    if (content && (typeof content === 'object' || typeof content === 'function')) {
      const proto = (content as any).prototype
      const hasSetup = !!(content as any).setup
      const hasRender = !!(content as any).render
      const isClass = proto && proto.isReactComponent
      const isFunctionComponent = typeof content === 'function' && /^[A-Z]/.test(content.name)
      if (hasSetup || hasRender) return VueAdapter
      if (isClass || isFunctionComponent) return ReactAdapter
    }

    throw new Error('[autodialog] Unsupported component type.')
  }

内置的 vue 和 react 适配器是直接检查了各自组件对象的特征,从而实现自动拾取适配器,自定义适配器则要写一个 detect 函数(特征检查),当然这不是必须的,因为你在你的框架中只会有一种组件传入,所以不必检查特征,detectAdapter 函数会优先拾取你的自定义适配器。


autodialog 有一个单例的默认导出,你可以直接导入使用,也可以引入 Dialog 类,实现多例弹窗。


所谓大道至简,核心原理就只有这些内容了!

进阶使用

API

autodialog.show(content, options?)

选项 类型 默认值 说明
title string undefined 可选标题
props object {} 传递给组件的参数
showMask boolean true 是否显示遮罩层
allowScroll boolean false 是否允许滚动页面
animation boolean true 是否启用动画
animationDuration number 200 动画持续时间(毫秒)
animationClass { enter?: string; leave?: string } - 自定义动画类名
onBeforeOpen () => void - 打开前
onOpened () => void - 打开后
onBeforeClose () => void - 关闭前
onClosed () => void - 关闭后
onMaskClick () => void - 点击遮罩层时触发

自定义适配器(例如 Svelte)

import { Dialog } from 'autodialog.js'
import { mount } from 'svelte'

export const SvelteAdapter = {
  render(Component: any, { panel, props = {}, onClose }: any) {
    const instance = mount(Component, {
      target: panel,
      props: { ...props, onClose }
    })
    ;(panel as any).__svelte__ = instance
  },
  unmount(panel: HTMLElement) {
    const inst = (panel as any).__svelte__
    inst?.destroy?.()
    delete (panel as any).__svelte__
  }
}

// ✅ 注册自定义适配器(detect 可省略)
Dialog.registerAdapter({
  name: 'svelte',
  adapter: SvelteAdapter
})

现在可以直接这样调用:

import MyDialog from './MyDialog.svelte'
autodialog.show(MyDialog, { props: { text: '来自 Svelte 的弹窗 ✨' } })

设计理念

Autodialog 的设计遵循三个核心原则:

  1. 框架独立:核心逻辑不依赖 Vue、React 或其他框架。
  2. 可扩展性:任何渲染系统都可以通过 Adapter 接入。
  3. 用户主导:样式、动画与生命周期完全开放给用户控制。

结语

希望 auto-plugin 的插件能给你带来帮助,让我们欢迎新成员:autodialog.js !


github:github.com/Auto-Plugin…

npm:www.npmjs.com/package/aut…


别忘了免费的小星星点一点。

VideoProc Converter AI(视频转换软件) 多语便携版

VideoProc Converter AI 是一款全能的视频处理软件,它集合了视频编辑、格式转换、压缩、录制和下载等多种功能于一体。用户可以使用该软件对视频文件进行各种处理操作,使之更加完美。

软件功能

视频编辑:支持剪辑、合并、裁剪、旋转、调整亮度、对比度、色度等视频编辑功能,用户可以轻松编辑视频文件。
格式转换:支持将视频文件转换为其他常见格式,如MP4、AVI、MOV、WMV等,及支持音频提取功能。
压缩:可以压缩视频文件大小,保持视频质量的同时减小文件体积,方便分享和传输。
录制:支持录制电脑屏幕和摄像头视频,用户可以录制游戏、视频教程、会议等内容。
下载:支持从 YouTube、Vimeo、Dailymotion 等网站下载视频,支持批量下载和视频转换。

软件特点

快速转换速度:拥有硬件加速技术,转换速度快,节省用户时间。
高质量输出:支持高清视频输出,保持视频质量,满足不同需求。
用户友好:界面简洁清晰,操作简单易懂,适合新手和专业用户使用。
多功能:功能全面,支持视频编辑、转换、压缩、录制和下载等多种操作,满足用户各种需求。
多平台支持:支持 Windows 和 Mac 系统,适用于不同操作系统用户。

「VideoProc Converter AI(视频转换软件) v8.4 多语便携版」 链接:pan.quark.cn/s/a204817f6…

Affinity Photo(图像编辑软件) 多语便携版

Affinity Photo是一款由Serif Labs公司设计的全面图像编辑软件,可以用于图像调整、颜色校正、修正图像缺陷、增强图像细节等。

软件功能
  1. 快速修正工具:包括修正红眼、去除皱纹、选择性模糊、清除杂点等功能。
  2. 高级色彩控制:可以对明亮度、色相、饱和度等进行精确调整。
  3. 图层面板:可以创建、编辑、组合和重定位图层,支持透明度和混合模式。
  4. 创意滤镜:如模糊、蒙版、锐化等。
  5. 非破坏性调整及历史记录:可以使用非破坏性调整图像,并检查历史记录以回溯操作。
  6. 图像格式的支持:支持多种图像格式,如JPEG、TIFF、PSD、PDF等。
软件特点
  1. 支持多个操作系统和设备:可以在Mac、Windows、iPad等各种设备上使用该软件。
  2. 操作流畅:运用GPU加速系统,使得操作流畅且响应迅速。
  3. 快速且稳定:该软件完全基于Cocoa,可以确保软件快速运行和稳定操作。
  4. 功能丰富:提供各种功能和工具,能够满足用户的各种编辑需求。
  5. 界面人性化:该软件界面设计简洁美观,易于操作和使用。

「Affinity Photo(图像编辑软件) v2.6.5.3782 多语便携版」 链接:pan.quark.cn/s/d97646000…

ToDoList(开源待办事项列表) 中文绿色版

ToDoList是一款免费的任务管理软件,它可以帮助用户更好地组织和管理日常的任务,提高工作效率和生活质量。

软件功能
  1. 任务管理:ToDoList可以帮助用户快速创建任务,设置任务的优先级、截止日期、提醒时间等信息,并可以将任务分组、归档等,方便用户查看和管理。
  2. 日程安排:ToDoList可以将任务按照时间轴排列,以日历形式显示,让用户更清晰地了解任务的时间安排和紧急程度。
  3. 标签分类:ToDoList支持对任务进行标签分类,方便用户快速找到相应的任务,同时也可以根据标签进行任务的过滤和排序。
  4. 任务协作:ToDoList支持多人协作,可以将任务分配给不同的团队成员,并设置任务的权限和通知方式,可以有效提高团队协作效率。
  5. 数据备份:ToDoList支持数据备份和恢复功能,用户可以将任务数据备份到本地或云端,保证数据的安全性和可靠性。
软件特点
  1. 界面简洁:ToDoList的界面设计简洁明了,功能区域清晰,操作简单易懂,方便用户快速上手。
  2. 功能强大:ToDoList支持任务管理、日程安排、标签分类、任务协作等多种功能,可以满足不同用户的需求。
  3. 免费开源:ToDoList是一款免费开源软件,用户可以自由使用和修改软件源代码,同时也可以参与到软件的开发和维护中来。
  4. 跨平台支持:ToDoList支持Windows、Mac、Linux等多个平台,用户可以在不同的设备上使用和同步任务数据,方便灵活。

「ToDoList(开源待办事项列表) v9.1.5.0 中文绿色版」 链接:pan.quark.cn/s/7feab3b0f…

commonjs的本质

this.a = 1;
exports.b = 2;

exports = {
  c: 3
}

module.exports = {
  d: 4,
};

exports.e = 5;

this.f = 6;

接下来理解 commonjs的本质,理解 commonjs的本质,什么题目都不难。

commonjs是一个模块化的标准,任何一个js文件,都是一个模块,每个模块有自己的导出结果。

我们要使用一个模块的时候,是不是要通过一个require函数,把模块路径传给他。

const r = require('./2');
console.log(r);

那么这个函数一运行。这个函数一运行,是不是相当于是在运行这个模块。运行完成之后,这个函数还有一个返回结果。那么这个返回结果,拿到就是这个模块导出的结果。

要理解commonjs,就要理解require函数。它到底是怎么运行的,做了啥事,为什么这里的结果就是这里的导出结果。导出到底又是啥,这一切的疑团都在require函数的内部。那这个require函数是怎么写的呢,它这个是node在本地实现的。

写伪代码,逻辑大致相同。

function require(modulePath) {
  // 1. 根据传递的模块路径,得到模块完整地绝对路径
  var moduleId = getModuleId(modulePath);
  
  // 2. 判断缓存
  if (cache[moduleId]) {
    return cache[moduleId];
  }
  
  // 3. 真正运行模块代码的辅助函数
  function _require(exports, require, module, __filename, __dirname) {
    // 目标模块的代码在这里
    // ...
  }
  
  // 4. 准备并运行辅助函数
  var module = {
    exports: {},
  };
  
  var exports = module.exports;
  
  // 得到模块文件的绝对路径
  var __filename = moduleId;
  // 得到模块所在目录的绝对路径
  var __filename = moduleId;
  // 得到模块所在目录的绝对路径
  var __dirname = getDirname(__filename);
  _require.call(exports, exports, require, module, __filelname, __dirname);
  
  // 5. 缓存 module.exports
  cache[moduleId] = module.exports;
  
  // 6. 返回 module.exports
  return module.exports;
}
const r = require('./2');
console.log(r);

这就是整个 require 函数的 伪代码,看上去呢, 挺复杂。其实非常简单,

把整个的结果,放到一个函数里边。函数里边直接放置模块代码。

也就是说

this.a = 1;
exports.b = 2;

exports = {
  c: 3
}

module.exports = {
  d: 4,
};

exports.e = 5;

this.f = 6;

这段代码一定是在函数里边,是函数一定要有一个arguments这么一个东西。全局里面没有,只有在函数里面才有。

真的能打印出来,说明啥,说明它在一个函数里边。

image.png

这个函数会给它传5个参数。

function _require(exports, require, module, __filename, __dirname) {

}

再来打印一下arguments的长度。

console.log(arguments.length);
  • exports
  • require
  • module
  • __filename
  • __dirname // 这个模块的目录的绝对路径

为什么在文件里面都可以直接使用,因为这5个它们都是参数。

有的用node的esmodule,然后这5个参数都不能用了。因为它不在一个函数环境里面了。

知识就是越深入越复杂,越深入越复杂,当深入到一定程度之后,反而变得更简单了。

反之,没有触碰到本质,那就是各种规则,要记exports表示啥意思,module.exports表达啥意思。要记各种各样的规则。

之后就是调用__require这个函数,调用的时候要准备好这5个参数。require参数就是require本身这个函数。

var module = {
  exports: {},
}

var exports = module.exports;

module.exports 和 exports 是同一个东西。

__filename表示文件的绝对路径。

var exports = module.exports; // 这就是模块的id

模块所在目录的绝对路径,通过内置模块就能够完成。

// 得到模块文件的绝对路径
var __filename = moduleId;

// 得到模块所在目录
var __dirname = getDirname(__filename)

_require.call(exports, exports, require, module,  __filename, __dirname);

// 5. 缓存 module.exports
cache[moduleId] = module.exports;

// 6. 返回 module.exports
return module.exports;

this 和 exports 和 module.exports 是一个东西。一开始都指向同一个对象,都指向一个空对象。

image.png

{ a: 1, b: 2, f: 6 }

exports
{ c: 3, e: 5 }

module.exports
{ d: 4 }

image.png

require函数最后返回的是module.exports。(从函数可以见得)。

image.png

用 JavaScript 打造 AI 大脑:前端开发者的新时代——基于 Brain.js 的浏览器端 NLP 实战

前言:前端的“智能革命”

你是否想过,前端开发者也能训练 AI 模型

过去,人工智能(AI)和自然语言处理(NLP)是 Python 和 GPU 的专属领域。
但今天,借助 Brain.js 这样的 JavaScript 库,我们可以在浏览器中直接训练神经网络,让网页应用拥有“智能”。

本文将带你用 Brain.js 实现一个前端/后端任务分类器,探索 JavaScript 如何在浏览器端完成机器学习任务。


一、为什么选择 Brain.js?

1. 纯 JavaScript,无需 Python

  • 无需配置复杂的 Python 环境。
  • 前端开发者可直接上手,无缝集成到现有项目。

2. 支持浏览器和 Node.js

  • 浏览器端:用户数据无需上传,保护隐私。
  • Node.js:可用于服务端 AI 推理。

3. 简洁 API,易于使用

const network = new brain.recurrent.LSTM();
network.train(data);
const output = network.run(input);

三行代码,搞定训练与预测。


二、项目目标:构建一个“智能任务分类器”

需求

输入一段技术描述,自动判断它是**前端(frontend)还是后端(backend)**任务。

例如:

  • 输入:"hover effects on buttons" → 输出:"frontend"
  • 输入:"optimizing SQL queries" → 输出:"backend"

这本质上是一个文本分类问题,属于 NLP 的基础任务。


三、HTML 结构:极简但强大

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI 端模型 智能前端开发新时代</title>
</head>
<body>
    <script src="./brain.js"></script>
    <script>
        // AI 逻辑写在这里
    </script>
</body>
</html>
  • 引入 brain.js 库(可从 CDN 或本地加载)。
  • 所有 AI 逻辑在 <script> 中完成。

无需后端,纯前端实现 AI!


四、样本数据:AI 的“知识库”

const data = [
  { "input": "hover effects on buttons", "output": "frontend" },
  { "input": "using flexbox for layout", "output": "frontend" },
  { "input": "optimizing SQL queries", "output": "backend" },
  { "input": "creating REST API endpoints", "output": "backend" },
  // ... 更多样本
];

数据设计原则:

原则 说明
输入(input) 自然语言描述,越贴近真实场景越好
输出(output) 分类标签,这里是 "frontend""backend"
数据量 越多越好,本文 19 条,实际项目建议 100+
多样性 覆盖 CSS、JS、API、性能、安全等场景

数据的质量和丰富性,直接决定 AI 的智能程度


五、选择神经网络:LSTM 模型

const network = new brain.recurrent.LSTM();

为什么用 LSTM?

  • LSTM(Long Short-Term Memory)是一种循环神经网络(RNN)
  • 擅长处理序列数据,如文本、时间序列。
  • 能捕捉词语之间的上下文关系。

例如:

  • "CSS Position Absolute And Animation" 中,"CSS""Animation" 都是前端关键词。

💡 对于简单分类,brain.recurrent.RNNbrain.NeuralNetwork 也可用,但 LSTM 效果更优。


六、训练模型:让 AI “学习”知识

network.train(data, {
    iterations: 2000,
    log: true,
    logPeriod: 100,
});

训练参数详解:

参数 作用
iterations 最多训练 2000 轮。轮数越多,学习越充分,但可能过拟合
log 是否输出训练日志
logPeriod 每 100 轮输出一次进度

训练过程日志示例:

training 100 / 2000
error: 0.45
training 200 / 2000
error: 0.32
...
  • error 越小,模型越准确。
  • 当误差不再明显下降时,训练自动停止。

七、推理预测:AI 的“智能表现”

const output = network.run("CSS Position Absolute And Animation");
console.log(output); // 输出: "frontend"

network.run(input) 的工作流程:

  1. 文本预处理:Brain.js 自动将输入字符串转换为向量。
  2. 前向传播:输入通过神经网络各层。
  3. 输出概率:模型计算 "frontend""backend" 的概率。
  4. 返回结果:返回概率最高的标签。

💡 即使输入是 "CSS Position Absolute And Animation",模型也能识别 "CSS""Animation" 为前端关键词,正确分类。


八、优化与挑战

1. 数据增强

  • 增加更多样本,如移动端、DevOps、安全等。
  • 使用同义词扩展,如 "flexbox""CSS layout"

2. 模型调优

  • 调整 iterations,避免过拟合。
  • 尝试不同网络结构,如 GRU

3. 性能考虑

  • 训练过程阻塞主线程,影响页面渲染。

  • 解决方案:

    • 使用 Web Workers 在后台训练。
    • 预训练模型,直接加载权重。
// 保存训练好的模型
const model = network.toJSON();
localStorage.setItem('ai-model', JSON.stringify(model));

// 加载模型
const savedModel = JSON.parse(localStorage.getItem('ai-model'));
network.fromJSON(savedModel);

九、应用场景:前端 AI 的无限可能

场景 说明
智能客服 自动分类用户问题,路由到正确处理模块
代码辅助 输入需求,推荐技术栈或代码片段
内容审核 检测评论中的敏感词或垃圾信息
个性化推荐 根据用户输入推荐相关内容

💡 所有这些,都可以在用户浏览器中完成,无需服务器,保护隐私!


十、未来展望:大模型训练师(LLM Trainer)的崛起

虽然 Brain.js 无法训练 GPT 级别的大模型,但它为前端开发者打开了 AI 的大门。

未来可能出现:

  • 轻量级 LLM 微调工具:在浏览器中微调开源小模型。
  • AI 增强的 IDE:实时代码建议、错误预测。
  • 去中心化 AI:用户拥有自己的“私人 AI 模型”。

前端开发者,不再只是“页面仔”,而是“AI 训练师”


十一、完整代码回顾

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI 端模型 智能前端开发新时代</title>
</head>
<body>
    <script src="https://unpkg.com/brain.js"></script>
    <script>
        const data = [
          { "input": "hover effects on buttons", "output": "frontend" },
          { "input": "optimizing SQL queries", "output": "backend" },
          // ... 更多样本
        ];

        const network = new brain.recurrent.LSTM();
        network.train(data, {
            iterations: 2000,
            log: true,
            logPeriod: 100,
        });

        const output = network.run("CSS Position Absolute And Animation");
        console.log(output); // "frontend"
    </script>
</body>
</html>

结语:前端的智能新时代

通过这个简单的例子,我们见证了:

  • JavaScript 不再局限于 DOM 操作。
  • 前端开发者也能玩转 AI。
  • 浏览器,正成为 AI 的新战场

用 CSS3 造一场星际穿越:前端导演的《星球大战》片场手记

前端工程师从来都不只是 "切图仔"。如果把网页比作一部电影,HTML 是剧本框架,JavaScript 是剧情逻辑,那 CSS 就是掌控光影、调度镜头的导演 —— 尤其是 CSS3 带来的 transform、animation 等特性,让我们能用代码拍出《星球大战》级别的视觉盛宴。

一、先搭个星际片场:HTML 的语义化布景

拍电影得先搭布景,写 CSS 动画的第一步是搭好 HTML 结构。星球大战最经典的开场是 "星空 + 滚动字幕",我们用语义化标签搭建这个场景:

html

预览

<!-- 整个场景容器 -->
<section class="star-wars">
  <!-- 星空背景 -->
  <div class="stars"></div>
  <!-- 电影标题 -->
  <h2 class="title">STAR WARS</h2>
  <!-- 滚动字幕 -->
  <div class="crawl">
    <p>很久很久以前,在一个遥远的星系...</p>
    <p>反抗军同盟与邪恶的帝国展开了殊死搏斗...</p>
  </div>
</section>

这里的标签选择藏着 "导演思维":

  • section 作为场景容器,像电影里的摄影棚,界定了整个动画的范围;

  • h2 承载标题,符合 "电影标题" 的语义,比 div 更有叙事感;

  • 星星呢?可以用 13 个 span(材料里提到的 span*13)作为星点,用循环生成更高效:

    html

    预览

    <div class="stars">
      <!-- 用JS生成13颗星星,或直接写13个span -->
      <span></span><span></span>...<span></span>
    </div>
    

好的布景不需要冗余标签,每个元素都该像电影里的道具 —— 有明确的 "戏份"。

二、定位:给每个元素找对 "站位"

拍群像戏时,演员的站位决定画面层次感。CSS 的position就是给元素 "站位" 的核心工具,在星球大战场景里,每个元素的定位都有讲究:

1. 星空背景:fixed 定位让宇宙 "固定不动"

星空需要铺满整个屏幕,且不随滚动变化,position: fixed是最佳选择:

css

.stars {
  position: fixed; /* 相对于视口定位,不随页面滚动 */
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: #000;
}

fixed会让元素脱离文档流,像电影里的背景幕布,永远停在镜头里。

2. 星星:absolute 定位让星光 "散落在宇宙"

13 颗星星需要随机分布在星空上,position: absolute能让它们相对于最近的非 static 父元素(这里是.stars)定位:

css

.stars span {
  position: absolute; /* 脱离文档流,自由定位 */
  width: 2px;
  height: 2px;
  background: #fff;
  border-radius: 50%;
}

/* 给每颗星星设置不同位置(可通过nth-child实现) */
.stars span:nth-child(1) { top: 20%; left: 30%; }
.stars span:nth-child(2) { top: 50%; left: 70%; }
/* ... 其余星星 */

如果父元素没设置position: relative,absolute 会一直往上找,直到 body—— 就像演员找不到站位参考时,只能看舞台边缘。

3. 滚动字幕:relative 定位做 "运动参考点"

字幕需要从下往上滚动,给容器加position: relative,既能保持在文档流中,又能成为内部元素的定位参考:

css

.crawl {
  position: relative; /* 不脱离文档流,但可作为子元素的定位基准 */
  width: 80%;
  margin: 0 auto; /* 水平居中 */
}

relative的妙处在于 "占着坑位移动"—— 元素原来的位置会保留,就像演员在自己的站位范围内小幅度移动。

三、transform:给画面加 "镜头特效"

如果说 position 是站位,那 transform 就是镜头运动。星球大战的视觉冲击力,很多来自于透视和运动感,这些都能用 transform 实现。

1. 星星闪烁:scale+opacity 营造 "星光忽明忽暗"

真实的星星不会一直亮着,用scale()缩放和透明度变化模拟闪烁:

css

.stars span {
  animation: twinkle 3s infinite;
}

@keyframes twinkle {
  0% { transform: scale(1); opacity: 1; }
  50% { transform: scale(0.5); opacity: 0.5; } /* 缩小+变暗 */
  100% { transform: scale(1); opacity: 1; }
}

给不同星星设置不同动画延迟,就能避免 "集体闪烁" 的尴尬:

css

.stars span:nth-child(odd) { animation-delay: 1s; }

2. 标题透视:3D 变换复刻 "星球大战片头"

星球大战的标题会有仰角,像从远处飞来,这需要 3D 变换:

css

.title {
  /* 开启3D空间 */
  transform-style: preserve-3d;
  /* 透视效果:值越小,立体感越强 */
  perspective: 500px;
  /* 旋转+平移营造仰角 */
  transform: rotateX(30deg) translateZ(0);
}

rotateX(30deg)让标题沿水平轴向上仰,perspective模拟人眼视角 —— 就像摄影师调整镜头焦距,让远处的物体有 "近大远小" 的深度。

3. 字幕滚动:translate3D 实现 "星际穿梭感"

字幕从屏幕底部滚向远方,需要结合 Y 轴平移和 Z 轴深度:

css

.crawl p {
  animation: crawl 30s linear infinite;
}

@keyframes crawl {
  0% {
    transform: translate3d(0, 100vh, 0); /* 开始在屏幕下方 */
    opacity: 0;
  }
  10% {
    opacity: 1;
  }
  100% {
    transform: translate3d(0, -200vh, -500px); /* 向上+向远处移动 */
    opacity: 0;
  }
}

translate3d(x,y,z)里的 Z 轴负值让文字 "远去",配合 Y 轴上移,完美复刻电影里字幕驶向星海的效果。

四、animation:让星际场景 "动起来"

动画是电影的灵魂,CSS 的animation属性就是给场景 "注入灵魂" 的工具。它由两部分组成:@keyframes定义关键帧,animation属性控制播放参数。

1. 关键帧:定义 "剧情转折点"

@keyframes就像分镜脚本,规定动画在不同时间点的状态。比如星星的闪烁,我们定义了 0%(初始)、50%(中途)、100%(结束)三个关键帧;字幕滚动则需要更细腻的控制 —— 从隐藏到显示,再到消失。

2. 动画属性:控制 "播放节奏"

把关键帧应用到元素上时,需要设置播放规则:

css

/* 完整写法 */
.crawl p {
  animation-name: crawl; /* 关联关键帧 */
  animation-duration: 30s; /* 播放时长 */
  animation-timing-function: linear; /* 速度曲线:匀速 */
  animation-iteration-count: infinite; /* 无限循环 */
}

/* 简写 */
.crawl p {
  animation: crawl 30s linear infinite;
}

不同元素需要不同的 "演技":星星的闪烁用ease-in-out(缓进缓出)更自然,字幕滚动用linear(匀速)更符合 "星际穿梭" 的稳定感。

五、从代码到电影:前端导演的核心思维

用 CSS 实现星球大战效果,本质是用代码模拟现实世界的视觉规律:

  • 星星的闪烁遵循 "随机节奏"—— 通过不同动画延迟实现;
  • 字幕的滚动符合 "透视原理"—— 近快远慢,Z 轴深度改变大小;
  • 整个场景的层次依赖 "定位逻辑"——fixed 背景、absolute 星星、relative 容器,各司其职。

现在的 CSS 早已不是简单的 "美化工具":transform 能模拟镜头运动,animation 能控制时间节奏,position 能调度元素站位。前端工程师完全可以像导演一样,用代码在浏览器里 "拍电影"。

最后,给你的星际片场加个小彩蛋:用 CSS4 的backdrop-filter给星空加层朦胧感,让整个场景更有电影质感:

css

.stars {
  backdrop-filter: blur(1px);
}

结语:每个前端都是造梦师

当你用position规划元素站位,用transform设计镜头运动,用animation控制时间流转时,你就不是在写代码 —— 而是在创造一个世界。

《星球大战》的导演乔治・卢卡斯说:"电影是视觉的艺术。" 对前端工程师来说,CSS 就是我们的摄影棚、摄像机和剪辑台。下次再写 CSS 时,不妨把自己当成导演 —— 毕竟,能用代码让文字飞向星海的人,都在创造属于自己的传奇。

html+layui+node+js做的个人财务管理系统

个人财务管理系统

一个基于 Layui、JavaScript 和 Node、MySQL 的个人财务管理系统,提供收入支出记录、预算管理、报表生成和数据分析等功能。

功能特性

1. 用户认证

  • 用户登录/注册
  • 密码加密存储

2. 收入支出记录

  • 添加、编辑、删除交易记录
  • 按类型、分类、日期筛选
  • 支持分页查询
  • 收入和支出分类管理

3. 预算管理

  • 创建月度/年度预算
  • 总预算和分类预算
  • 实时预算执行情况监控
  • 预算超支提醒

4. 报表生成

  • 收支概览统计
  • 收支趋势图表
  • 分类统计分析(饼图)
  • 月度对比分析
  • 收入支出明细表

5. 数据分析

  • 多维度数据可视化
  • 使用 ECharts 展示图表
  • 财务健康度分析

6. 分类管理

  • 自定义收入/支出分类
  • 分类图标和颜色配置
  • 默认分类模板

技术栈

前端

  • Layui 2.8.18 - UI框架
  • ECharts 5.4.3 - 数据可视化
  • 原生JavaScript - 业务逻辑

后端

  • Node.js - 运行环境
  • Express 4.x - Web框架
  • MySQL2 - 数据库驱动

数据库

  • MySQL - 关系型数据库

项目结构

gerencaiwu/
├── database/                # 数据库文件
│   └── schema.sql          # 数据库结构和初始数据
├── server/                 # 后端服务
│   ├── config/            
│   │   └── database.js    # 数据库连接配置
│   ├── routes/            # 路由模块
│   │   ├── auth.js        # 认证路由
│   │   ├── transactions.js # 交易记录路由
│   │   ├── categories.js  # 分类管理路由
│   │   ├── budgets.js     # 预算管理路由
│   │   └── reports.js     # 报表统计路由
│   ├── config.js          # 服务器配置
│   ├── server.js          # 服务器入口
│   └── package.json       # 依赖配置
└── public/                # 前端文件
    ├── js/
    │   ├── config.js      # API配置
    │   ├── main.js        # 主页面逻辑
    │   ├── transactions.js # 交易记录页面
    │   ├── budgets.js     # 预算管理页面
    │   ├── reports.js     # 报表分析页面
    │   └── categories.js  # 分类管理页面
    ├── index.html         # 登录页面
    ├── register.html      # 注册页面
    └── main.html          # 主应用页面

安装部署

1. 环境要求

  • Node.js 14.x 或更高版本
  • MySQL 5.7 或 MySQL 8.x(推荐 MySQL 8.x)

注意:如果使用 MySQL 8,请参考 MySQL8配置说明.md 文件进行配置。

2. 数据库配置

方法1:使用命令行导入

mysql -u root -p < database/schema.sql

方法2:登录后导入

# 登录 MySQL
mysql -u root -p

# 执行数据库脚本
source database/schema.sql

MySQL 8 用户注意:

如果遇到认证错误(ER_NOT_SUPPORTED_AUTH_MODE),需要修改用户认证方式:

# 登录 MySQL
mysql -u root -p

# 修改认证方式
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';
FLUSH PRIVILEGES;

详细配置请参考:MySQL8配置说明.md

测试数据库连接:

# 修改 test-mysql8-connection.js 中的密码
# 然后运行测试
node test-mysql8-connection.js

3. 后端配置

# 进入服务器目录
cd server

# 安装依赖
npm install

# 配置数据库连接
# 编辑 server/config.js 文件,修改数据库连接信息:
# - host: 数据库主机地址(默认:localhost)
# - user: 数据库用户名(默认:root)
# - password: 数据库密码(默认:空)
# - database: 数据库名称(默认:personal_finance)
# - port: 数据库端口(默认:3306)

4. 启动服务

# 在 server 目录下
npm start

# 或使用 nodemon 进行开发(需要先安装 nodemon)
npm run dev

服务器默认运行在:http://localhost:3000

5. 访问应用

使用浏览器打开 public/index.html 文件,或者配置一个Web服务器(如Nginx、Apache)指向 public 目录。

默认测试账号:

  • 用户名:admin
  • 密码:123456

API接口文档

认证接口

登录
  • URL: /api/auth/login
  • Method: POST
  • Body:
    {
      "username": "admin",
      "password": "123456"
    }
    
注册
  • URL: /api/auth/register
  • Method: POST
  • Body:
    {
      "username": "newuser",
      "password": "password",
      "email": "user@example.com"
    }
    

交易记录接口

获取交易记录列表
  • URL: /api/transactions
  • Method: GET
  • Query: userId, type, categoryId, startDate, endDate, page, limit
添加交易记录
  • URL: /api/transactions
  • Method: POST
  • Body:
    {
      "userId": 1,
      "categoryId": 1,
      "type": "income",
      "amount": 5000.00,
      "description": "工资",
      "transactionDate": "2025-10-27"
    }
    
更新交易记录
  • URL: /api/transactions/:id
  • Method: PUT
删除交易记录
  • URL: /api/transactions/:id
  • Method: DELETE

预算接口

获取预算列表
  • URL: /api/budgets
  • Method: GET
  • Query: userId, period
获取当前有效预算
  • URL: /api/budgets/current
  • Method: GET
  • Query: userId
添加预算
  • URL: /api/budgets
  • Method: POST
更新预算
  • URL: /api/budgets/:id
  • Method: PUT
删除预算
  • URL: /api/budgets/:id
  • Method: DELETE

报表接口

获取概览统计
  • URL: /api/reports/overview
  • Method: GET
  • Query: userId, startDate, endDate
按分类统计
  • URL: /api/reports/category
  • Method: GET
  • Query: userId, type, startDate, endDate
趋势分析
  • URL: /api/reports/trend
  • Method: GET
  • Query: userId, startDate, endDate, groupBy
月度对比
  • URL: /api/reports/monthly-comparison
  • Method: GET
  • Query: userId, months

分类接口

获取所有分类
  • URL: /api/categories
  • Method: GET
  • Query: userId
按类型获取分类
  • URL: /api/categories/type/:type
  • Method: GET
  • Query: userId
添加分类
  • URL: /api/categories
  • Method: POST
更新分类
  • URL: /api/categories/:id
  • Method: PUT
删除分类
  • URL: /api/categories/:id
  • Method: DELETE

使用说明

1. 登录系统

使用默认账号或注册新账号登录系统。

2. 添加交易记录

点击"收支记录"菜单,点击"添加记录"按钮,填写交易信息。

3. 设置预算

点击"预算管理"菜单,点击"添加预算"按钮,设置月度或年度预算。

4. 查看报表

点击"报表分析"菜单,查看各种统计图表和数据分析。

5. 管理分类

点击"分类管理"菜单,可以自定义收入和支出分类。

数据库设计

users(用户表)

  • id: 主键
  • username: 用户名(唯一)
  • password: 密码(MD5加密)
  • email: 邮箱
  • created_at: 创建时间
  • updated_at: 更新时间

categories(分类表)

  • id: 主键
  • user_id: 用户ID
  • name: 分类名称
  • type: 类型(income/expense)
  • icon: 图标
  • color: 颜色
  • created_at: 创建时间

transactions(交易记录表)

  • id: 主键
  • user_id: 用户ID
  • category_id: 分类ID
  • type: 类型(income/expense)
  • amount: 金额
  • description: 描述
  • transaction_date: 交易日期
  • created_at: 创建时间
  • updated_at: 更新时间

budgets(预算表)

  • id: 主键
  • user_id: 用户ID
  • category_id: 分类ID(NULL表示总预算)
  • amount: 预算金额
  • period: 周期(monthly/yearly)
  • start_date: 开始日期
  • end_date: 结束日期
  • description: 描述
  • created_at: 创建时间
  • updated_at: 更新时间

注意事项

  1. 安全性:密码使用MD5加密,生产环境建议使用更安全的加密方式(如bcrypt)
  2. CORS:后端已配置CORS,允许跨域访问
  3. 数据备份:建议定期备份MySQL数据库
  4. 端口冲突:如果3000端口被占用,可在 server/config.js 中修改
  5. 前端部署:可使用任何Web服务器托管public目录,或使用Express的静态文件服务

功能扩展建议

  1. 添加数据导出功能(Excel、PDF)

  2. 添加多用户权限管理

  3. 添加数据备份和恢复功能

  4. 添加移动端适配

  5. 添加消息通知功能

  6. 添加多币种支持

  7. 添加账户余额管理

  8. 添加定期记账提醒

  9. 添加财务目标设置

  10. 添加更多图表类型

1.png

2.png

3.png

4.png

5.png

6.png

7.png

8.png

9.png

10.png

11.png

TypeScript装饰器详解:像搭积木一样给代码加功能

作为一名前端开发者,你一定遇到过这样的场景:
想给类加一个日志功能,却要每个方法都写console.log()
想验证参数是否合法,却要在每个方法里重复判断。

TypeScript的装饰器就像给代码贴“魔法贴纸”,能优雅地解决这些问题。本文将带你从零掌握装饰器的5种类型,附带真实案例,看完就能直接用。


一、类装饰器:给整个类加功能

1.1 参数说明

function 类装饰器(target: Function): void | Function
  • target:当前类的构造函数(相当于类的身份证)

1.2 实战案例:记录类创建时间

// 创建装饰器
function 日志类(target: Function) {
  target.prototype.创建时间 = new Date().toLocaleString();
}

// 使用装饰器
@日志类
class 用户 {
  获取创建时间() {
    return this.创建时间;
  }
}

const user = new 用户();
console.log(user.获取创建时间()); // 输出类似 "2025-10-28 15:30:00"

💡 通过修改类的原型,我们给所有实例免费加了创建时间属性。


二、方法装饰器:修改方法行为

2.1 参数说明

function 方法装饰器(
  target: Object,          // 类的原型
  methodName: string,      // 方法名
  descriptor: PropertyDescriptor // 方法配置
): void | PropertyDescriptor

2.2 实战案例:给方法加权限校验

function 需要登录(target: any, methodName: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  
  descriptor.value = function(...args) {
    if (!this.用户是否登录) {
      throw new Error("请先登录");
    }
    return originalMethod.apply(this, args);
  };
  
  return descriptor;
}

class 服务 {
  用户是否登录 = false;
  
  @需要登录
  获取敏感数据() {
    return "机密信息";
  }
}

const service = new 服务();
service.获取敏感数据(); // 报错:请先登录

🛡️ 通过包装原方法,我们实现了权限控制而无需修改业务逻辑。


三、属性装饰器:标记或初始化属性

3.1 参数说明

function 属性装饰器(
  target: Object,          // 类的原型
  propertyName: string    // 属性名
): void

3.2 实战案例:标记必填字段

function 必填(target: any, propertyName: string) {
  const descriptor = {
    get() {
      throw new Error(`${propertyName} 是必填字段`);
    },
    set(value: any) {
      if (value === undefined) {
        throw new Error(`${propertyName} 不能为空`);
      }
      target[propertyName] = value;
    }
  };
  Object.defineProperty(target, propertyName, descriptor);
}

class 表单 {
  @必填
  用户名!: string;
}

const form = new 表单();
form.用户名 = "张三"; // 正常
console.log(form.用户名); // 输出 "张三"

form.用户名 = undefined; // 报错:用户名 不能为空

✅ 通过重写getter/setter,我们实现了字段校验。


四、参数装饰器:记录参数信息

4.1 参数说明

function 参数装饰器(
  target: Object,              // 类的原型
  methodName: string | undefined, // 方法名(构造函数为undefined)
  parameterIndex: number      // 参数位置(从0开始)
): void

4.2 实战案例:记录参数来源

function 参数追踪(target: any, methodName: string, parameterIndex: number) {
  console.log(`方法 ${methodName} 的第 ${parameterIndex} 个参数被调用`);
}

class 计算器 {
  加法(@参数追踪 a: number, @参数追踪 b: number) {
    return a + b;
  }
}

const calc = new 计算器();
calc.加法(10, 20); // 控制台输出:
// 方法 加法 的第 0 个参数被调用
// 方法 加法 的第 1 个参数被调用

📊 这个功能在调试时特别有用,能快速定位参数传递路径。


五、访问器装饰器:控制属性访问

5.1 参数说明

function 访问器装饰器(
  target: Object,          // 类的原型
  name: string,            // 属性名
  descriptor: PropertyDescriptor // 访问器配置
): void | PropertyDescriptor

5.2 实战案例:缓存get方法结果

function 缓存(target: any, name: string, descriptor: PropertyDescriptor) {
  const originalGetter = descriptor.get;
  
  const cacheKey = `$$${name}Cache`;
  
  descriptor.get = function() {
    if (this[cacheKey] === undefined) {
      this[cacheKey] = originalGetter.call(this);
    }
    return this[cacheKey];
  };
  
  return descriptor;
}

class 费波那契 {
  private n = 10;
  
  @缓存
  get 第n项() {
    // 模拟耗时计算
    let result = 0, a = 1, b = 1;
    for (let i = 3; i <= this.n; i++) {
      result = a + b;
      a = b;
      b = result;
    }
    return b;
  }
}

const fib = new 费波那契();
console.log(fib.第n项); // 第一次计算
console.log(fib.第n项); // 直接返回缓存结果

⚡ 通过缓存getter结果,避免重复计算。


六、使用装饰器的注意事项

  1. 启用配置:在tsconfig.json中添加

    {
      "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
      }
    }
    
  2. 执行顺序:多个装饰器按从下往上执行

    @装饰器A
    @装饰器B
    class 示例 {} 
    // 实际执行顺序是:装饰器B → 装饰器A
    
  3. 参数装饰器限制:不能用于getter/setter的参数


七、装饰器应用场景

场景 推荐装饰器类型
权限控制 方法装饰器
参数校验 参数装饰器
缓存结果 方法/访问器装饰器
字段验证 属性装饰器
日志记录 类/方法装饰器

八、总结

装饰器就像给代码贴“功能补丁”,能让你:

  • 解耦业务逻辑:把日志、权限等公共逻辑抽离
  • 提升代码可维护性:修改装饰器就能影响所有相关代码
  • 实现AOP编程:在不修改原代码的情况下增强功能

建议从简单场景开始尝试,比如先用方法装饰器加日志,逐步探索更复杂的用法。掌握装饰器后,你会发现很多重复代码都可以优雅地消失了!

5分钟搞定 DeepSeek API 配置:从配置到调用一步到位

如果你正想快速上手 DeepSeek,却还在为“API Key 配置”、“接口调用出错”这些问题头疼——
这篇文章将带你一步步完成从注册、配置到实际调用的全过程。
只需 5 分钟,你就能跑通第一个 DeepSeek 请求。

1-48.jpeg

DeepSeek 是一款高性能的大模型 API,兼容 OpenAI API 接口协议,支持文本生成、代码补全、对话问答等功能。相比传统模型,它的响应速度更快、性价比更高,而且集成方式几乎零学习成本

让 AI 在你的系统中说话、思考、执行!

一、准备工作:账号与密钥

1. 注册账号

访问 DeepSeek 开放平台,使用手机号、邮箱或 微信登录即可。

image.png

注册完成后进入「API Keys(密钥管理) 」页面。

2. 创建 API Key

点击「创建 API key」按钮,并复制生成的 Key。

image.png

注意:密钥只显示一次,请立刻保存!遗失后将无法找回!

3. 充值(别跳过这步!)

进入 充值入口,支持支付宝、微信,几秒即可到账

image.png

DeepSeek 的 token 计费很低,都是百万 Token 计费起步,可以先小额试用。

image.png

建议:先充值最低额度,方便调试和测试,非常利好开发者。是不是很 nice !

4. 环境变量配置(保护好你的 Key)

将密钥保存在本地或服务器环境变量中,避免泄露

export DEEPSEEK_API_KEY="你的密钥"

切记:不要把密钥写进代码仓库,尤其是前端项目!

一切就绪,下面开始上手实战。

二、快速上手,看看实际效果(推荐两种方式)

DeepSeek 的 API 与 OpenAI 完全兼容。你只需要更改 base_urlmodel 即可调用。题外话题:不止 DeepSeek,其他的 AI 模型大都是遵循 OpenAI 模式,所以我们一套代码也能支持多种模型使用,不用单独开发。

接口地址:

https://api.deepseek.com

请求头示例:

Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

这里我们简单了解下,详见 DeepSeek API 文档。大家先按照下面我写的例子,快速应用看效果。

✅ 方式一:Python SDK 调用(最简单)

from openai import OpenAI

client = OpenAI(
    api_key="YOUR_API_KEY",
    base_url="https://api.deepseek.com"
)

response = client.chat.completions.create(
    model="deepseek-chat",
    messages=[
        {"role": "system", "content": "你是一个智能助手。"},
        {"role": "user", "content": "帮我写一句励志语。"}
    ]
)

print(response.choices[0].message.content)

核心要点:

  • 模型名称使用 deepseek-chatdeepseek-reasoner
  • 其余参数完全与 OpenAI 相同

✅ 方式二:REST 接口调用(最快)

curl https://api.deepseek.com/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
        "model": "deepseek-chat",
        "messages": [
          {"role": "user", "content": "你好,介绍一下 DeepSeek。"}
        ]
      }'

将上面 YOUR_API_KEY 更改成你自己的API key后,直接在电脑终端执行,即可看到返回内容:

{
  "id": "chatcmpl-xxxx",
  "object": "chat.completion",
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "DeepSeek 是一个高性能大模型..."
      }
    }
  ]
}

四、项目中应用

在前端项目中使用(Vue 示例)

如果你希望在前端直接调用,可封装一个通用请求函数:

// api/deepseek.ts
export async function callDeepSeek(messages) {
  const res = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages
    })
  })
  return res.json()
}

在组件中使用:

const res = await callDeepSeek([
  { role: 'user', content: '你好,介绍一下 DeepSeek。' }
])
console.log(res.choices[0].message.content)

建议:

  • .env 文件中管理密钥
  • 生产环境使用后端代理转发请求,避免密钥泄露

调用示例(Node.js)

DeepSeek 完全兼容 OpenAI SDK 的调用方式。
你只需更换 baseURLapiKey 即可:

在项目根目录下新建 .env 文件,添加如下内容:

DEEPSEEK_API_KEY=你的API密钥
DEEPSEEK_BASE_URL=https://api.deepseek.com

这样就能在代码中通过环境变量调用,无需暴露 Key。

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: process.env.DEEPSEEK_BASE_URL
});

const response = await client.chat.completions.create({
  model: "deepseek-chat",
  messages: [{ role: "user", content: "帮我写一段JavaScript生成随机密码的代码" }]
});

console.log(response.choices[0].message.content);

在环境 .env 文件中,一行换源即可兼容!
你不需要重新学习新的 SDK 或接口结构。

四、总结:配置其实就这几步

  • 注册账号 →
  • 获取 API Key →
  • 充值 →
  • 调整 base_url
  • 直接调用!

DeepSeek 的计费更低、兼容性强、接入门槛几乎为零。
一次配置,全语言通用

打造梦幻粒子动画效果:基于 Vue 的 Canvas 实现方案

粒子动画效果在现代网页设计中越来越受欢迎,它能为页面增添动态感和视觉吸引力。本文将分享一个基于 Vue 和 Canvas 实现的粒子动画组件,该组件具有高度可定制性,可轻松集成到各种 Web 项目中。

我们实现的粒子动画具有以下特点:

  • 粒子从底部向上飘动,模拟轻盈上升的效果
  • 粒子带有呼吸式发光效果,增强视觉层次感
  • 每个粒子都有随机的大小、速度和颜色
  • 支持响应式布局,自动适应容器大小变化
  • 所有参数均可通过 props 灵活配置

技术选择

为什么选择 Canvas 而非 DOM 元素来实现粒子效果?

  1. 性能优势:Canvas 在处理大量粒子时性能远优于 DOM 操作
  2. 绘制灵活性:Canvas 提供丰富的绘图 API,便于实现复杂的视觉效果
  3. 资源占用低:相比创建大量 DOM 节点,Canvas 渲染更高效

核心实现步骤

  1. 初始化 Canvas 并设置合适的尺寸
  2. 创建粒子类,定义粒子的属性和行为
  3. 实现粒子的绘制逻辑,包括发光效果
  4. 构建动画循环,更新粒子状态
  5. 添加响应式处理和组件生命周期管理

组件结构

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

模板部分非常简洁,只包含一个容器和 canvas 元素,canvas 将作为我们绘制粒子的画布。

可配置参数

props: {
  // 粒子数量
  particleCount: {
    type: Number,
    default: 50,
    validator: (value) => value >= 0
  },
  // 粒子颜色数组
  particleColors: {
    type: Array,
    default: () => [
      'rgba(255, 255, 255,',    // 白色
      'rgba(153, 204, 255,',   // 淡蓝
      'rgba(255, 204, 255,',   // 淡粉
      'rgba(204, 255, 255,'    // 淡青
    ]
  },
  // 发光强度
  glowIntensity: {
    type: Number,
    default: 1.5
  },
  // 粒子大小控制参数
  minParticleSize: {
    type: Number,
    default: 0.5  // 最小粒子半径
  },
  maxParticleSize: {
    type: Number,
    default: 1.5  // 最大粒子半径
  }
}

这些参数允许开发者根据需求调整粒子效果的密度、颜色、大小和发光强度。

粒子创建与初始化

createParticle() {
  // 根据传入的范围计算粒子半径
  const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

  return {
    x: Math.random() * this.canvasWidth,
    y: this.canvasHeight + Math.random() * 50,
    radius,  // 使用新的半径范围
    color: this.getRandomColor(),
    speedY: Math.random() * 1.5 + 0.5,  // 垂直速度
    speedX: (Math.random() - 0.5) * 0.3,  // 水平漂移
    alpha: Math.random() * 0.5 + 0.5,
    life: Math.random() * 150 + 150,  // 生命周期
    glow: Math.random() * 0.8 + 0.2,
    glowSpeed: (Math.random() - 0.5) * 0.02,
    shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
  }
}

每个粒子都有随机的初始位置(从底部进入)、大小、速度和发光属性,这确保了动画效果的自然和丰富性。

动画循环

动画的核心是animate方法,它使用requestAnimationFrame创建流畅的动画循环:

animate() {
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

  this.particles.forEach((particle, index) => {
    // 更新粒子位置
    particle.y -= particle.speedY
    particle.x += particle.speedX
    particle.life--

    // 处理发光动画
    particle.glow += particle.glowSpeed
    if (particle.glow > 1.2) {
      particle.glow = 1.2
      particle.glowSpeed = -particle.glowSpeed
    } else if (particle.glow < 0.2) {
      particle.glow = 0.2
      particle.glowSpeed = -particle.glowSpeed
    }

    // 粒子生命周期结束,重新创建
    if (particle.y < -particle.radius || particle.life <= 0) {
      this.particles[index] = this.createParticle()
    }

    // 绘制粒子(包括发光效果、核心和高光)
    // ...绘制代码省略
  })

  this.animationId = requestAnimationFrame(this.animate)
}

在每次动画帧中,我们更新所有粒子的位置和状态,当粒子超出画布或生命周期结束时,会创建新的粒子替换它,从而实现循环不断的动画效果。

响应式处理

为了使粒子动画适应不同屏幕尺寸,我们添加了窗口大小变化的监听:

handleResize() {
  this.initCanvas()
  this.particles = this.particles.map(() => this.createParticle())
}

当窗口大小改变时,我们重新初始化 Canvas 尺寸并重新创建所有粒子,确保动画始终充满整个容器

完整代码

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

<script>
export default {
  name: 'ParticleAnimation',
  props: {
    // 粒子数量
    particleCount: {
      type: Number,
      default: 50,
      validator: (value) => value >= 0
    },
    // 粒子颜色数组
    particleColors: {
      type: Array,
      default: () => [
        'rgba(255, 255, 255,',    // 白色
        'rgba(153, 204, 255,',   // 淡蓝
        'rgba(255, 204, 255,',   // 淡粉
        'rgba(204, 255, 255,'    // 淡青
      ]
    },
    // 发光强度
    glowIntensity: {
      type: Number,
      default: 1.5
    },
    // 粒子大小控制参数
    minParticleSize: {
      type: Number,
      default: 0.5  // 最小粒子半径
    },
    maxParticleSize: {
      type: Number,
      default: 1.5  // 最大粒子半径
    }
  },
  data() {
    return {
      canvas: null,
      ctx: null,
      particles: [],
      animationId: null,
      canvasWidth: 0,
      canvasHeight: 0
    }
  },
  watch: {
    particleCount(newVal) {
      this.particles = []
      this.initParticles(newVal)
    },
    particleColors: {
      deep: true,
      handler() {
        this.particles.forEach((particle, index) => {
          this.particles[index].color = this.getRandomColor()
        })
      }
    },
    // 监听粒子大小变化
    minParticleSize() {
      this.resetParticles()
    },
    maxParticleSize() {
      this.resetParticles()
    }
  },
  methods: {
    initCanvas() {
      this.canvas = this.$refs.particleCanvas
      this.ctx = this.canvas.getContext('2d')

      const container = this.canvas.parentElement
      this.canvasWidth = container.clientWidth
      this.canvasHeight = container.clientHeight
      this.canvas.width = this.canvasWidth
      this.canvas.height = this.canvasHeight
    },

    initParticles(count) {
      for (let i = 0; i < count; i++) {
        this.particles.push(this.createParticle())
      }
    },

    createParticle() {
      // 根据传入的范围计算粒子半径
      const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

      return {
        x: Math.random() * this.canvasWidth,
        y: this.canvasHeight + Math.random() * 50,
        radius,  // 使用新的半径范围
        color: this.getRandomColor(),
        speedY: Math.random() * 1.5 + 0.5,  // 降低速度,配合小粒子
        speedX: (Math.random() - 0.5) * 0.3,  // 减少漂移
        alpha: Math.random() * 0.5 + 0.5,
        life: Math.random() * 150 + 150,  // 延长生命周期,让小粒子存在更久
        glow: Math.random() * 0.8 + 0.2,
        glowSpeed: (Math.random() - 0.5) * 0.02,
        shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
      }
    },

    getRandomColor() {
      if (this.particleColors.length === 0) {
        return 'rgba(255, 255, 255,'
      }
      return this.particleColors[Math.floor(Math.random() * this.particleColors.length)]
    },

    animate() {
      this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

      this.particles.forEach((particle, index) => {
        particle.y -= particle.speedY
        particle.x += particle.speedX
        particle.life--

        // 闪亮动画
        particle.glow += particle.glowSpeed
        if (particle.glow > 1.2) {
          particle.glow = 1.2
          particle.glowSpeed = -particle.glowSpeed
        } else if (particle.glow < 0.2) {
          particle.glow = 0.2
          particle.glowSpeed = -particle.glowSpeed
        }

        if (particle.y < -particle.radius || particle.life <= 0) {
          this.particles[index] = this.createParticle()
        }

        // 绘制粒子(适配小粒子的比例)
        this.ctx.save()

        // 阴影效果
        this.ctx.shadowColor = `${particle.color}${particle.glow * this.glowIntensity})`
        this.ctx.shadowBlur = particle.shadowBlur * particle.glow
        this.ctx.shadowOffsetX = 0
        this.ctx.shadowOffsetY = 0

        // 外发光圈(按粒子大小比例缩放)
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius * (1 + particle.glow * 0.8), 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${0.2 * particle.glow})`
        this.ctx.fill()

        // 粒子核心
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${particle.alpha + (particle.glow * 0.3)})`
        this.ctx.fill()

        // 高光点(适配小粒子)
        if (particle.glow > 0.8) {
          this.ctx.beginPath()
          const highlightSize = particle.radius * 0.3 * particle.glow
          this.ctx.arc(
            particle.x - particle.radius * 0.2,
            particle.y - particle.radius * 0.2,
            highlightSize,
            0,
            Math.PI * 2
          )
          this.ctx.fillStyle = `rgba(255, 255, 255, ${0.6 * particle.glow})`
          this.ctx.fill()
        }

        this.ctx.restore()
      })

      this.animationId = requestAnimationFrame(this.animate)
    },

    handleResize() {
      this.initCanvas()
      this.particles = this.particles.map(() => this.createParticle())
    },

    // 重置粒子大小
    resetParticles() {
      this.particles = this.particles.map(() => this.createParticle())
    }
  },
  mounted() {
    this.initCanvas()
    this.initParticles(this.particleCount)
    this.animate()
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy() {
    cancelAnimationFrame(this.animationId)
    window.removeEventListener('resize', this.handleResize)
  }
}
</script>

<style scoped>
.particle-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.particle-canvas {
  display: block;
  width: 100%;
  height: 100%;
}
</style>

使用方法

<template>
  <div class="page-container">
    <ParticleAnimation 
      :particle-count="80"
      :glow-intensity="2"
      :min-particle-size="0.8"
      :max-particle-size="2"
    />
    <!-- 其他内容 -->
  </div>
</template>

<script>
import ParticleAnimation from '@/components/ParticleAnimation.vue'

export default {
  components: {
    ParticleAnimation
  }
}
</script>

<style>
.page-container {
  width: 100vw;
  height: 100vh;
}
</style>

如何在 React 中实现键盘快捷键管理器以提升用户体验

本文详解如何在 React 应用中实现键盘交互功能,通过集中式快捷方式管理器提升用户体验,附完整代码(含 ShortcutProvider、useShortcuts 钩子)及 Next.js 集成案例,助开发者避开冲突与内存问题。

一、揭秘 Web 应用中键盘交互的强大价值

用过 Google 表格、Figma 这类专业 Web 应用的人,大概率会被它们流畅的操作体验吸引 —— 其中一个关键加分项,就是 键盘交互。无需频繁点击鼠标,按下几组快捷键就能完成保存、复制、切换功能等操作,既提升效率,也让用户体验更丝滑。

如何在 React 中实现键盘快捷键管理器以提升用户体验

而这种实用的功能,并非大型应用专属 —— 你也能在自己的 React 项目中实现。很多开发者初次尝试时,会把键盘监听逻辑散落在各个组件里,最后不仅代码混乱、容易出现快捷键冲突,还可能导致内存泄漏。本文就带你用最佳实践,打造一套“干净、易维护、对团队友好”的 React 键盘交互系统。

二、集中式快捷方式管理器:一种革新性的实现思路

1. 核心概念:让“一个大脑”管理所有快捷键

传统实现方式中,每个需要键盘交互的组件都会单独写监听逻辑,比如 A 组件监听“Ctrl+S”保存,B 组件监听“Ctrl+Z”撤销,代码分散且难以维护。

集中式快捷方式管理器的思路恰好相反:它为整个应用设定 一个“智能大脑” ,统一负责所有键盘交互相关的工作,具体职责包括:

  • 监听应用内所有按键操作,确保不遗漏关键指令;
  • 维护一份“快捷键 – 功能”对照表,明确每个组合键对应的操作;
  • 自动忽略文本输入框(INPUT、TEXTAREA)和可编辑区域的按键,避免影响用户打字;
  • 收到匹配的快捷键时,立即触发对应的功能,无延迟响应。

2. 集中式系统的 3 大核心优势

相比分散式实现,这种“统一管理”的模式能解决很多实际问题:

  • 避免快捷键冲突:所有快捷键都注册到同一个“注册表”,新增时能自动检测是否重复,无需手动排查各组件;
  • 简化内存管理:统一注册和注销逻辑,不会因组件卸载后监听未清除导致内存泄漏;
  • 保持代码整洁:组件无需关心全局监听细节,只需“告诉管理器要什么快捷键、做什么操作”,逻辑更聚焦。

三、实现流程拆解:从核心组件到钩子函数

要搭建这套系统,只需两个核心文件和一份类型定义 —— 结构清晰,新手也能快速上手。

1. 上下文提供者:ShortcutProvider(全局“快捷键注册表”)

ShortcutProvider 是整个系统的“中枢”,需要用它包裹你的 React 应用(通常在根组件中)。它的核心作用是:存储所有已注册的快捷键及对应功能,同时监听全局按键事件。

简单来说,它就像一个“快捷键管理员”,所有组件的快捷键需求都要通过它处理,确保全局逻辑统一。

2. 自定义钩子:useShortcuts(组件的“交互接口”)

有了“管理员”,组件怎么和它沟通?答案就是 useShortcuts 钩子。

这个钩子封装了从上下文获取“注册”和“注销”函数的逻辑,组件只需调用这两个函数,就能轻松添加或移除快捷键 —— 无需关心全局监听、冲突检测等底层细节,实现“即插即用”。

四、逐行解析代码:从核心文件到项目集成

1. ShortcutsProvider.tsx:实现全局管理逻辑

"use client";
import React, {createContext, useEffect, useRef} from "react";
import {
  Modifier,
  Shortcut,
  ShortcutHandler,
  ShortcutRegistry,
  ShortcutsContextType,
} from "./types";
 
// 1. 创建上下文,定义组件可调用的方法(register/unregister)export const ShortcutsContext = createContext<ShortcutsContextType>({register: () => {},
  unregister: () => {},
});
 
// 2. 工具函数:统一规范化快捷键(比如将 "ctrl+s" 转为 "Ctrl+S",避免大小写 / 顺序问题)const normalizeShortcut = (shortcut: Shortcut): string => {const mods = shortcut.modifiers?.slice().sort() || []; // 修饰键按字母排序(如 Ctrl、Shift)const key = shortcut.key.toUpperCase(); // 按键转为大写(统一 "s" 和 "S")return [...mods, key].join("+");
};
 
// 3. 核心提供者组件:包裹应用并实现管理逻辑
const ShortcutProvider = ({children}: {children: React.ReactNode}) => {
  // 用 useRef 存储快捷键注册表(Map 结构:键 = 规范化后的快捷键,值 = 对应的处理函数)const ShortcutRegisteryRef = useRef<ShortcutRegistry>(new Map());
 
  // 4. 注册快捷键:接收快捷键、处理函数,支持强制覆盖已存在的快捷键
  const register = ( 
    shortcut: Shortcut,
    handler: ShortcutHandler,
    override = false
  ) => {
    const ShortcutRegistery = ShortcutRegisteryRef.current;
    const normalizedKey = normalizeShortcut(shortcut);
 
    // 检测冲突:若快捷键已存在且未开启覆盖,提示警告
    if (ShortcutRegistery.has(normalizedKey) && !override) {
      console.warn(` 冲突警告:快捷键 "${normalizedKey}" 已被注册。可设置 override=true 强制替换,或处理冲突。`
      );
      return;
    }
 
    // 无冲突则添加到注册表
    ShortcutRegistery.set(normalizedKey, handler);
  };
 
  // 5. 注销快捷键:根据规范化后的键,从注册表中移除
  const unregister = (shortcut: Shortcut) => {const normalizedKey = normalizeShortcut(shortcut);
    ShortcutRegisteryRef.current.delete(normalizedKey);
  };
 
  // 6. 全局按键监听:判断按键是否匹配已注册的快捷键
  const handleKeyDown = (event: KeyboardEvent) => {
    const target = event.target as HTMLElement;
 
    // 关键判断:忽略输入框和可编辑区域的按键,避免影响打字
    if (
      target.tagName === "INPUT" ||
      target.tagName === "TEXTAREA" ||
      target.isContentEditable
    ) {return;}
 
    // 收集当前按下的修饰键(Ctrl/Alt/Shift/Meta)const modifiers: Modifier[] = [];
    if (event.ctrlKey) modifiers.push("Ctrl");
    if (event.altKey) modifiers.push("Alt");
    if (event.shiftKey) modifiers.push("Shift");
    if (event.metaKey) modifiers.push("Meta");
 
    // 规范化当前按下的键,与注册表匹配
    const key = event.key.toUpperCase();
    const normalizedKey = [...modifiers.sort(), key].join("+");
    const handler = ShortcutRegisteryRef.current.get(normalizedKey);
 
    // 匹配成功则触发对应函数,并阻止默认行为(如避免浏览器默认的 "Ctrl+S" 保存页面)if (handler) {event.preventDefault();
      handler(event);
    }
  };
 
  // 7. 挂载 / 卸载监听:组件初始化时添加全局监听,卸载时移除(防止内存泄漏)useEffect(() => {window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);
 
  // 8. 提供上下文:让子组件能获取 register 和 unregister 方法
  return (<ShortcutsContext.Provider value={{ register, unregister}}>
      {children}
    </ShortcutsContext.Provider>
  );
};
 
export default ShortcutProvider;

2. useShortcuts.tsx:让组件轻松调用快捷键功能

这个钩子的作用很纯粹 —— 从上下文获取快捷键管理方法,并做简单的错误提示,确保组件使用前已包裹 ShortcutProvider。

import {useContext} from "react";
import {ShortcutsContext} from "./ShortcutsProvider";
 
const useShortcuts = () => {
  // 从上下文获取 register 和 unregister
  const shortcutContext = useContext(ShortcutsContext);
 
  // 错误提示:若组件未在 ShortcutProvider 内使用,及时提醒
  if (!shortcutContext) {console.error("请在 ShortcutProvider 组件内部使用 useShortcuts 钩子!");
  }
 
  return shortcutContext;
};
 
export default useShortcuts;

3. types.ts:定义类型,提升代码健壮性

为了避免类型混乱,我们用 TypeScript 定义所有涉及的类型,确保参数和返回值符合预期,减少开发中的错误。

// 修饰键类型(如 Ctrl、Alt、Shift、Meta)export type Modifier = "Ctrl" | "Alt" | "Shift" | "Meta";
// 单个按键类型(如 s、z、Enter)export type Key = string;
 
// 快捷键配置:包含单个按键和可选的修饰键
export interface Shortcut {
  key: Key;
  modifiers?: Modifier[];
}
 
// 快捷键对应的处理函数(接收键盘事件参数)export type ShortcutHandler = (e: KeyboardEvent) => void;
 
// 上下文提供的方法类型:注册和注销快捷键
export interface ShortcutsContextType {register: (shortcut: Shortcut, handler: ShortcutHandler, override?: boolean) => void;
  unregister: (shortcut: Shortcut) => void;
}
 
// 快捷键注册表类型:用 Map 存储“规范化键 - 处理函数”的映射
export type ShortcutRegistry = Map<string, ShortcutHandler>;

4. 主应用组件集成:以 Next.js 为例

要让整个应用都能使用快捷键功能,只需在根组件中用 ShortcutProvider 包裹所有子内容 —— 以 Next.js 的 RootLayout 为例:

继续阅读全文:如何在 React 中实现键盘快捷键管理器以提升用户体验

如何使用 TinyEditor 快速部署一个协同编辑器?

本文由曹里林同学原创。

简介

TinyEditor 是一个框架无关的富文本编辑器,既可以在原生 JavaScript 项目中使用,也可以在 Vue、React 等前端框架中使用。

本篇文章带来的是如何使用 TinyEditor 最新的协同编辑模块快速部署多人实时协作编辑。

1.JPG

前端集成

1、安装TinyEditor

首先需要安装 TinyEditor

pnpm i @opentiny/fluent-editor

编写 Html 引入 TinyEditor 和对应的样式


@import '@opentiny/fluent-editor/style.css';
 
<div id="editor">
  <p>Hello TinyEditor!</p>
</div>
import FluentEditor from '@opentiny/fluent-editor'

const editor = new FluentEditor('#editor', {
  theme: 'snow',
})

至此已经引入了 TinyEditor 编辑器,接下来安装协同编辑模块。

2、安装协同编辑模块

安装额外依赖

pnpm i quill-cursors y-protocols y-quill yjs y-indexeddb y-websocket

引入协同编辑模块

import FluentEditor, { CollaborationModule } from '@opentiny/fluent-editor'
FluentEditor.register('modules/collaborative-editing', CollaborationModule, true)

编辑器基础配置:通过配置 serverUrl 和 roomName, 双方进行协同编辑通信

const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    'collaborative-editing': {
      provider: {
        type: 'websocket',
        options: {
          serverUrl: 'wss://demos.yjs.dev/ws',  // 正式环境更换成正式服务地址
          roomName: 'Tiny-Editor-Demo-juejin',  // 双方通信的房间名
        },
      },
    },
  },
})
 

现在协同编辑已经可用。

PixPin_2025-10-20_21-46-06.gif

3、Provider配置

Provider 是协同编辑的核心,它负责管理客户端和服务器之间的数据同步。TinyEditor 支持多种 Provider 类型,最常见的是 WebSocket Provider 和 WebRTC Provider。

WebSocket Provider

这是最常用的连接方式,通过标准的 WebSocket 协议与后端服务器进行通信。

3.PNG

示例配置:

const editor = new FluentEditor('#editor', {
  modules: {
    'collaborative-editing': {
      provider: {
        type: 'websocket',
        options: {
          serverUrl: 'wss://demos.yjs.dev/ws',
          roomName: 'my-unique-document-id',
        },
      },
    },
  },
});

WebRTC Provider

注意: 需要额外安装 WebRTC 依赖 pnpm i y-webrtc。使用这种方式采用点对点连接,不需要中心化的 WebSocket 服务器,更适合低延迟和对网络拓扑有特殊要求的场景。

4.PNG

示例配置:

const editor = new FluentEditor('#editor', {
  modules: {
    'collaborative-editing': {
      provider: {
        type: 'webrtc',
        options: {
          roomName: 'tiny-editor-webrtc-demo',
          signaling: ['wss://signaling.yjs.dev'],
        },
      },
    },
  },
});

4、Awareness 配置

Awareness 模块负责同步用户的在线状态、光标位置和选区。通过配置,你可以自定义用户的显示信息。

Awareness 实现用户在线状态、光标位置等信息的实时同步。每个用户的在线状态、名称、颜色、光标位置等会自动广播给其他协作者,实现多人编辑时的身份和操作可视化。

5.PNG

Awareness 结构

6.PNG

示例配置:

awareness: {
  state: {
    name: `user${Math.random().toString(36).substring(2, 8)}`,
    color: `#${Math.floor(Math.random() * 16777215).toString(16)}`
  },
  timeout: 30000,
}

5、Cursor 配置

cursors 默认开启,并且支持以下配置(详细配置可见 quill-cursors):

7.PNG

注意光标模板内的类名不可变

示例配置:

const CURSOR_CLASSES = {
  SELECTION_CLASS: 'ql-cursor-selections',
  CARET_CONTAINER_CLASS: 'ql-cursor-caret-container',
  CARET_CLASS: 'ql-cursor-caret',
  FLAG_CLASS: 'ql-cursor-flag',
  NAME_CLASS: 'ql-cursor-name',
}

cursors: {
  template: `
    <span class="${CURSOR_CLASSES.SELECTION_CLASS}"></span>
    <span class="${CURSOR_CLASSES.CARET_CONTAINER_CLASS}">
      <span class="${CURSOR_CLASSES.CARET_CLASS}"></span>
    </span>
    <div class="${CURSOR_CLASSES.FLAG_CLASS}">
      <small class="${CURSOR_CLASSES.NAME_CLASS}"></small>
    </div>
  `,
  hideDelayMs: 300,
  hideSpeedMs: 300,
  transformOnTextChange: true,
}

6、事件回调

8.PNG

后端部署

TinyEditor 的协同编辑后端服务已为你准备好 Docker 镜像,只需简单几步即可快速部署,无需复杂的本地环境配置。

1、准备 Docker 环境

确保你的机器上已安装 Docker 和 Docker Compose。

2、拉取镜像并配置

在您的项目根目录下创建 docker-compose.yml 文件。

docker-compose.yml 此文件定义了两个服务:mongodb(用于数据持久化)和 websocket-server(协同编辑后端服务)。

  • 如果您没有可用的 MongoDB 服务:

    • 请使用完整的 docker-compose.yml 文件,它会自动启动一个名为 mongodb 的服务。
  • 如果您已经有可用的 MongoDB 服务:

    • 您不需要启动 mongodb 服务(可以将其从 docker-compose.yml 文件中删除或注释掉)。
    • 您只需修改 websocket-server 服务中的 MONGODB_URL 环境变量,将其指向您现有的 MongoDB 实例地址。

services:
  mongodb:
    image: mongo:latest 
    container_name: yjs-mongodb 
    restart: always
    ports:
      - "27017:27017" 
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin # 设置 MongoDB 初始用户名
      MONGO_INITDB_ROOT_PASSWORD: admin!123 # 设置 MongoDB 初始密码
    volumes:
      - mongodb_data:/data/db

websocket-server:
    image: yinlin124/collaborative-editor-backend:latest 
    container_name: yjs-websocket-server 
    restart: always 
    ports:
      - "${PORT:-1234}:${PORT:-1234}" 
    environment:
      HOST: ${HOST:-0.0.0.0} # 设置后端监听的网络接口
      PORT: ${PORT:-1234} # 默认 1234 端口,可以使用环境变量修改
      MONGODB_URL: ${MONGODB_URL:-mongodb://admin:admin!123@mongodb:27017/?authSource=admin} # 如果你使用自己的 mongodb 服务需要修改此项
      MONGODB_DB: ${MONGODB_DB:-tinyeditor} # 数据库名称
      MONGODB_COLLECTION: ${MONGODB_COLLECTION:-documents} # 集合名称
      
    depends_on:
      - mongodb 

volumes:
  mongodb_data:

如果你需要更换映射端口等,可创建 .env 文件按照下面的参数值更改环境变量:

9.PNG

3、一键启动服务

在项目根目录下运行 docker-compose 命令,Docker 会自动下载镜像、创建容器并启动服务。

docker compose up -d

后端启动后将前端编辑器配置中的 serverUrl 改成对应的服务器 IP:port。

更多需求

如果你有自定义持久化或者自定义 provider 等需求可查看文档:opentiny.github.io/tiny-editor… issue。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
🌐 官网:opentiny.design
📦 GitHub:github.com/opentiny (欢迎star)

OpenTiny NEXT 正式发布,官网、文档、示例、Demo 一站配齐。未来已来,欢迎上车!

同时欢迎大家进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

文件Base64转换工具升级:从图片到多格式文件的全新体验

文件Base64转换工具升级:从图片到多格式文件的全新体验

image.png

图片和Base64 互相转换工具:一个简单但实用的离线工具

图片Base64转换工具新增剪贴板粘贴功能

在日常使用中发现,原有的“图片 ↔ Base64 离线转换工具”已经无法满足更复杂的场景。尤其是在需要处理压缩包、文档等非图片文件时,单个图片转换工具的局限性变得明显。为此,我开发了全新的“文件Base64转换工具集合”,为用户带来更强大、更便捷的文件转换体验,方便一次性处理多个文件。


为什么要升级?

  • 图片工具的局限:原工具仅支持图片格式,无法处理ZIP、RAR等压缩文件,也不支持文件名保留。
  • 多样化需求:实际应用中,除了图片,常常需要传输或存储压缩包、文档、音频等多种文件格式。
  • 操作不便:当有多个文件类型需要转换时,用户不得不寻找不同的工具,效率低下。

新工具亮点

1. 多格式支持

  • 支持图片(JPG、PNG、GIF、BMP、WebP)与压缩文件(ZIP、RAR、7Z、TAR、GZ、BZ2、XZ)与Base64互转。

2. 文件名保留

  • 转换为Base64时自动保存原始文件名,还原时自动恢复,方便文件管理和分享。

3. 剪贴板操作

  • 支持从剪贴板粘贴图片、Base64文本,提升操作效率。

4. 响应式设计

  • 完美适配桌面和移动设备,界面美观,操作流畅。

5. 现代化UI

  • 左侧导航栏分类,支持后续扩展更多文件类型。

使用场景举例

  • 前端开发:快速将图片或压缩包转为Base64嵌入代码或配置文件。
  • 数据传输:将文件编码为Base64,便于API传输或文本存储。
  • 文件分享:保留原始文件名,方便接收方还原文件。

项目地址与推荐

🚀 推荐体验:文件Base64转换工具集合

支持图片、压缩包等多种文件格式与Base64互转,功能强大,完全离线,保护隐私!


技术细节解析

压缩文件如何转Base64?

  1. 文件读取

    • 工具采用浏览器原生的 FileReader API,支持直接读取本地的 ZIP、RAR、7Z、TAR、GZ、BZ2、XZ 等压缩文件。
    • 用户选择文件后,FileReader 会以 DataURL 方式读取整个文件内容。
  2. Base64编码

    • DataURL 本质上就是 data:[MIME类型];base64,[数据] 格式,自动将文件内容转为Base64字符串。
    • 工具会自动识别文件类型,设置正确的 MIME 类型,保证还原时格式不丢失。
  3. 文件名保留机制

    • 为了让还原后的文件名与原始一致,工具会将文件名以自定义前缀方式编码到Base64字符串中:
      • 格式:FILENAME:[编码的文件名]|[原始Base64数据]
    • 还原时自动解析前缀,恢复原始文件名。
  4. 完全本地处理,安全可靠

    • 所有转换和解析操作均在浏览器本地完成,不上传任何数据,保护用户隐私。
  5. 兼容性与扩展性

    • 支持所有现代浏览器,无需安装插件。
    • 代码结构模块化,便于后续扩展更多文件类型(如PDF、音频、视频等)。

代码片段示例

// 压缩文件转Base64
const reader = new FileReader();
reader.onload = (e) => {
  // 文件名编码
  const base64WithFileName = `FILENAME:${btoa(encodeURIComponent(file.name))}|${e.target.result}`;
  // 显示/复制/下载
};
reader.readAsDataURL(file);

// Base64还原文件名
function extractFileNameFromBase64(base64Data) {
  if (base64Data.startsWith('FILENAME:')) {
    const parts = base64Data.split('|');
    const fileName = decodeURIComponent(atob(parts[0].replace('FILENAME:', '')));
    const actualBase64 = parts.slice(1).join('|');
    return { fileName, base64Data: actualBase64 };
  }
  return { fileName: null, base64Data };
}

结语

新版本工具不仅解决了原有图片工具的局限,还为未来扩展更多文件类型(如文档、音频、视频)打下了坚实基础。欢迎大家体验并提出建议,让工具变得更好用!

如果你觉得有用,欢迎Star支持!

干货!Python采集淘宝商品详情数据,淘宝API接口系列(json数据返回)

以下是基于淘宝开放平台API的Python商品详情采集深度指南,包含完整技术实现与合规注意事项:

一、前置条件准备

  1. 开放平台入驻

    • 注册平台账号
    • 创建应用获取app_keyapp_secret
    • 申请taobao.item.get接口权限(需企业认证)
  2. 商品ID获取技巧

    python
    # 从商品链接提取num_iid
    import re
    url = "https://detail.tmall.com/item.htm?id=68543210987"
    item_id = re.search(r'id=(\d+)', url).group(1)
    

二、完整API调用实现(增强版)

python
import requests
import hashlib
import time
import json
from urllib.parse import urlparse

class TaobaoAPI:
    def __init__(self, app_key, app_secret, sandbox=False):
        self.app_key = app_key
        self.app_secret = app_secret
        self.sandbox = sandbox
        self.base_url = "https://gw.api.tbsandbox.com/router/rest" if sandbox else "https://eco.taobao.com/router/rest"

    def _generate_sign(self, params):
        """生成符合淘宝规范的MD5签名"""
        param_str = "".join(f"{k}{params[k]}" for k in sorted(params.keys()))
        sign_str = f"{self.app_secret}{param_str}{self.app_secret}"
        return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()

    def get_item_detail(self, item_id, fields="num_iid,title,price,pic_url,desc,skus,props_name,quantity"):
        """获取商品详情(含错误重试机制)"""
        params = {
            'method': 'taobao.item.get',
            'app_key': self.app_key,
            'timestamp': time.strftime("%Y-%m-%d %H:%M:%S"),
            'format': 'json',
            'v': '2.0',
            'sign_method': 'md5',
            'num_iid': item_id,
            'fields': fields
        }
        
        # 添加签名
        params['sign'] = self._generate_sign(params)
        
        # 添加公共参数
        params.update(self._get_common_params())
        
        try:
            response = requests.get(self.base_url, params=params, timeout=5)
            response.raise_for_status()
            return response.json()
        except (requests.exceptions.RequestException, KeyError) as e:
            return self._handle_error(e, item_id)

    def _get_common_params(self):
        """获取公共请求参数"""
        return {
            'partner_id': 'open-api-sdk',
            'target_app_key': '12345678',  # 替换为目标APPKEY
            'sdk_version': '2.0',
            'simplify': 'false'
        }

    def _handle_error(self, error, item_id):
        """错误处理与重试逻辑"""
        if isinstance(error, requests.exceptions.Timeout):
            return {"error": "Request timeout"}
        elif 'code' in str(error):
            error_code = json.loads(error).get('error_response', {}).get('code')
            return self._map_error_code(error_code, item_id)
        return {"error": str(error)}

    def _map_error_code(self, code, item_id):
        """错误码映射处理"""
        error_map = {
            11: "API权限不足,请检查应用权限",
            27: f"商品不存在或无权限访问: {item_id}",
            100: "参数错误,请检查请求参数",
            10001: "系统内部错误,请重试"
        }
        return {"error": error_map.get(code, "未知错误")}

# 使用示例
if __name__ == "__main__":
    APP_KEY = "YOUR_APP_KEY"
    APP_SECRET = "YOUR_APP_SECRET"
    ITEM_ID = "68543210987"
    
    taobao = TaobaoAPI(APP_KEY, APP_SECRET)
    result = taobao.get_item_detail(ITEM_ID)
    
    # 解析响应数据
    if 'error' not in result:
        item_data = result['taobao_item_get_response']['item']
        print(f"商品标题: {item_data['title']}")
        print(f"价格: ¥{item_data['price']}")
        print(f"主图: {item_data['pic_url']}")
        
        # 处理SKU数据
        skus = item_data.get('skus', {}).get('sku', [])
        for sku in skus:
            print(f"规格: {sku['properties']} | 价格: {sku['price']} | 库存: {sku['quantity']}")
    else:
        print(f"错误信息: {result['error']}")

三、关键技术细节解析

  1. 签名算法优化

    • 采用参数名排序+值拼接的MD5加密方式
    • 示例签名串:secretkeyapp_key12345fieldsnum_iid,titleformatjsonmethodtaobao.item.getnum_iid123456timestamp2025-10-28 12:00:00v2.0secretkey
  2. 字段选择策略

    • 基础字段:num_iid,title,price,pic_url
    • 扩展字段:desc(详情描述)、props_name(属性名)、quantity(库存)
    • 规格数据:通过skus字段获取多规格商品信息
  3. 错误处理增强

    • 网络请求超时重试机制
    • 错误码映射系统(如11→权限不足,27→商品不存在)
    • 沙箱环境测试支持

四、合规与反爬策略

  1. 频率控制

    python
    # 请求间隔控制示例
    import time
    def safe_request(item_id):
        time.sleep(0.2)  # 5秒内不超过25次请求
        return taobao.get_item_detail(item_id)
    
  2. **User-Agent设置

    python
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
    }
    
  3. IP代理轮换

    • 建议使用代理IP池应对高频访问限制
    • 可集成scrapy-rotating-proxy等中间件

五、数据解析示例

返回JSON典型结构:

json
{
  "taobao_item_get_response": {
    "item": {
      "num_iid": "68543210987",
      "title": "2025春季新款男士休闲裤",
      "price": "129.00",
      "pic_url": "https://img.alicdn.com/example.jpg",
      "desc": "<img src='...' />商品详细描述",
      "skus": {
        "sku": [
          {
            "properties": "颜色:深蓝;尺码:30",
            "price": "129.00",
            "quantity": 200
          },
          {
            "properties": "颜色:黑色;尺码:32",
            "price": "139.00",
            "quantity": 150
          }
        ]
      }
    }
  }
}

六、常见问题解决方案

  1. 权限不足(错误码11)

    • 检查应用权限配置
    • 确认已申请taobao.item.get接口
    • 联系开放平台客服提升权限
  2. 商品不存在(错误码27)

    • 确认商品ID正确性
    • 检查商品是否下架或区域限制
    • 验证应用是否有权访问该商品
  3. 签名验证失败

    • 检查时间戳格式(YYYY-MM-DD HH:MM:SS)
    • 确认参数排序正确
    • 验证app_secret是否泄露

企业级用户登录Token存储最佳实践,吊打面试官

目录

  1. 引言
  2. Token存储位置对比
  3. 常见安全威胁
  4. HttpOnly + Secure + SameSite Cookie方案详解
  5. 双Token认证方案
  6. 不同场景下的最佳实践
  7. 代码实现示例
  8. 总结

引言

用户登录后获取的Token(令牌)是用户身份的临时凭证,正确存储和使用Token对应用安全至关重要。本文将详细讨论Token的存储位置、安全威胁、防御措施以及最佳实践,帮助开发者构建更安全的认证系统。

Token存储位置对比

localStorage/sessionStorage

  • 优点
    • 使用简单,API友好
    • 前端可随时读写
    • 容量较大(通常5MB)
    • sessionStorage在会话结束后自动清除
  • 缺点
    • 易受XSS攻击(JavaScript可直接读取)
    • 不适合存储敏感信息
    • 无法设置过期时间(需手动管理)

内存(变量/状态管理)

  • 优点
    • 页面刷新后丢失,降低被窃取的时间窗口
    • JavaScript不易持久化
    • 不受同源策略限制
  • 缺点
    • 页面刷新会丢失,需要刷新机制
    • 标签页关闭后无法恢复
    • 无法在多标签页间共享

Cookie

  • 优点
    • 可设置HttpOnly防止JavaScript读取
    • 自动随请求发送到服务器
    • 可设置过期时间和域范围
    • 配合SameSite可抵御部分CSRF攻击
  • 缺点
    • 容量小(通常4KB)
    • 默认随请求自动发送,需防CSRF
    • 跨域复杂,受同源策略限制
    • 用户可手动清除或禁用

常见安全威胁

XSS(跨站脚本攻击)

攻击原理:攻击者在网页中注入恶意JavaScript代码,当用户访问该页面时,恶意代码会在用户的浏览器中执行。

生活案例:就像有人在银行大厅安装了隐形摄像头,当你输入密码时,他可以看到你的一举一动。

Token风险:如果Token存储在localStorage或普通Cookie中,恶意JavaScript可以读取并发送到攻击者的服务器。

CSRF(跨站请求伪造)

攻击原理:攻击者诱导已登录用户访问恶意网站,该网站会"代替用户"向目标网站发送请求,利用浏览器会自动携带Cookie的特性。

生活案例:想象你收到一封看似银行的邮件,点击链接后,实际上触发了一个转账请求。因为你已登录银行网站,银行会认为这是你本人操作。

场景演示

  1. 用户登录了银行网站A
  2. 用户访问恶意网站B
  3. B网站包含一个表单,自动提交到A网站的转账接口
  4. 浏览器发送请求时会自动携带A网站的Cookie
  5. A网站验证Cookie有效,执行转账操作

关键点:攻击者不需要读取Cookie内容,只需让浏览器自动携带Cookie发起请求。

中间人攻击

攻击原理:攻击者位于用户与服务器之间,可以拦截和修改通信内容。

生活案例:你以为在和银行柜员对话,实际上中间有人在传话,可能篡改你的指令。

Token风险:如果不使用HTTPS,Token在传输过程中可能被窃取。

HttpOnly + Secure + SameSite Cookie方案详解

HttpOnly

  • 作用:禁止JavaScript通过document.cookie访问Cookie
  • 防御:有效防止XSS攻击读取Cookie中的Token
  • 限制:只防止读取,不防止CSRF(因为浏览器仍会自动发送Cookie)

Secure

  • 作用:仅在HTTPS连接中发送Cookie
  • 防御:防止明文传输被窃听
  • 必要性:现代Web应用必须启用

SameSite

  • 作用:控制跨站请求是否携带Cookie
  • 选项
    • Lax(默认):顶级导航(如点击链接)会发送Cookie,但大多数跨站子请求(如图片加载)不会
    • Strict:只有同站请求才发送Cookie
    • None:允许跨站请求发送Cookie,但必须同时设置Secure
  • 防御效果
    • Lax可防御大部分CSRF攻击,但不完美
    • Strict安全性最高,但用户体验可能受影响
    • None需要额外CSRF防御措施

配置示例

Set-Cookie: token=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600

双Token认证方案

概念解释

  • Access Token:短期访问令牌,用于API请求认证
  • Refresh Token:长期刷新令牌,用于获取新的Access Token

工作流程

  1. 用户登录成功,服务器返回Access Token和Refresh Token
  2. Access Token存储在内存中,Refresh Token存储在HttpOnly Cookie中
  3. 每次API请求使用Access Token认证
  4. Access Token过期后,使用Refresh Token获取新的Access Token
  5. 如果Refresh Token也过期,用户需要重新登录

安全增强措施

  • Token轮换:每次刷新都生成新的Refresh Token,旧Token立即失效
  • 复用检测:如果旧Refresh Token被再次使用,说明可能被盗用,立即撤销所有Token
  • 设备绑定:将Token与设备指纹关联,防止跨设备使用

生活案例

就像游乐园的"腕带+身份证"系统:

  • 腕带(Access Token):当天有效,用于快速进入各项目,丢失影响小
  • 身份证(Refresh Token):长期有效,只在腕带失效时用于换取新腕带,平时妥善保管

不同场景下的最佳实践

Web单页应用(SPA)

  • Access Token存内存,通过Authorization头发送
  • Refresh Token存HttpOnly + Secure + SameSite Cookie
  • 实现CSRF Token机制
  • 全站HTTPS

服务端渲染(SSR)

  • 优先使用服务器会话
  • 通过HttpOnly会话Cookie维持状态
  • 前端不直接接触Token

移动应用

  • 使用系统安全存储(iOS Keychain/Android Keystore)
  • 实现证书固定(Certificate Pinning)
  • 考虑生物认证(指纹/面部识别)

跨域应用

  • 谨慎设置Cookie Domain
  • 必要时使用SameSite=None + Secure
  • 强化CSRF防御和CORS配置

代码实现示例

后端实现(Node.js + Express)

// 登录接口
app.post('/api/login', (req, res) => {
  // 验证用户凭据
  const { username, password } = req.body;
  
  // 假设验证通过
  const accessToken = generateAccessToken(username);
  const refreshToken = generateRefreshToken(username);
  
  // 设置Refresh Token为HttpOnly Cookie
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
  });
  
  // 返回Access Token
  res.json({
    accessToken,
    expiresIn: 900 // 15分钟
  });
});

// 刷新Token接口
app.post('/api/refresh', (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  
  if (!refreshToken) {
    return res.status(401).json({ message: '未授权' });
  }
  
  // 验证Refresh Token
  try {
    const user = verifyRefreshToken(refreshToken);
    
    // 生成新Token
    const newAccessToken = generateAccessToken(user.username);
    const newRefreshToken = rotateRefreshToken(user.username, refreshToken);
    
    // 设置新的Refresh Token
    res.cookie('refresh_token', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });
    
    res.json({
      accessToken: newAccessToken,
      expiresIn: 900
    });
  } catch (err) {
    res.clearCookie('refresh_token');
    return res.status(401).json({ message: '刷新Token无效' });
  }
});

// 登出接口
app.post('/api/logout', (req, res) => {
  // 清除Refresh Token Cookie
  res.clearCookie('refresh_token');
  // 在服务器端将Refresh Token加入黑名单
  blacklistRefreshToken(req.cookies.refresh_token);
  
  res.json({ message: '登出成功' });
});

前端实现(React)

// 认证上下文
const AuthContext = createContext();

function AuthProvider({ children }) {
  const [accessToken, setAccessToken] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // 初始化检查登录状态
  useEffect(() => {
    checkAuth();
  }, []);
  
  // 登录函数
  const login = async (username, password) => {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
        credentials: 'include' // 重要:允许发送和接收Cookie
      });
      
      if (!response.ok) throw new Error('登录失败');
      
      const data = await response.json();
      setAccessToken(data.accessToken);
      
      // 设置自动刷新
      setTimeout(refreshToken, (data.expiresIn - 60) * 1000);
      
      return true;
    } catch (error) {
      console.error('登录错误:', error);
      return false;
    }
  };
  
  // 刷新Token
  const refreshToken = async () => {
    try {
      const response = await fetch('/api/refresh', {
        method: 'POST',
        credentials: 'include'
      });
      
      if (!response.ok) {
        setAccessToken(null);
        return false;
      }
      
      const data = await response.json();
      setAccessToken(data.accessToken);
      
      // 设置下次刷新
      setTimeout(refreshToken, (data.expiresIn - 60) * 1000);
      
      return true;
    } catch (error) {
      setAccessToken(null);
      return false;
    }
  };
  
  // 登出
  const logout = async () => {
    try {
      await fetch('/api/logout', {
        method: 'POST',
        credentials: 'include'
      });
    } finally {
      setAccessToken(null);
    }
  };
  
  // API请求拦截器
  const authFetch = async (url, options = {}) => {
    // 添加Authorization头
    const authOptions = {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${accessToken}`
      }
    };
    
    try {
      const response = await fetch(url, authOptions);
      
      // 如果返回401,尝试刷新Token
      if (response.status === 401) {
        const refreshed = await refreshToken();
        if (refreshed) {
          // 使用新Token重试请求
          authOptions.headers.Authorization = `Bearer ${accessToken}`;
          return fetch(url, authOptions);
        } else {
          throw new Error('未授权');
        }
      }
      
      return response;
    } catch (error) {
      console.error('请求错误:', error);
      throw error;
    }
  };
  
  // 检查是否已登录
  const checkAuth = async () => {
    setLoading(true);
    try {
      const refreshed = await refreshToken();
      setLoading(false);
      return refreshed;
    } catch (error) {
      setLoading(false);
      return false;
    }
  };
  
  const value = {
    accessToken,
    isAuthenticated: !!accessToken,
    login,
    logout,
    authFetch,
    loading
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// 使用Hook
function useAuth() {
  return useContext(AuthContext);
}

CSRF防御实现

// 后端生成CSRF Token
app.get('/api/csrf-token', (req, res) => {
  const csrfToken = generateRandomToken();
  
  // 存储在普通Cookie中
  res.cookie('csrf_token', csrfToken, {
    secure: true,
    sameSite: 'lax'
  });
  
  res.json({ csrfToken });
});

// CSRF保护中间件
function csrfProtection(req, res, next) {
  // 跳过GET请求
  if (req.method === 'GET') return next();
  
  const cookieToken = req.cookies.csrf_token;
  const headerToken = req.headers['x-csrf-token'];
  
  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ message: 'CSRF验证失败' });
  }
  
  next();
}

// 应用到需要保护的路由
app.post('/api/sensitive-action', csrfProtection, (req, res) => {
  // 处理敏感操作
});

// 前端实现
async function performSensitiveAction() {
  // 从Cookie中读取CSRF Token
  const csrfToken = document.cookie
    .split('; ')
    .find(row => row.startsWith('csrf_token='))
    ?.split('=')[1];
  
  // 发送请求时在头部包含CSRF Token
  const response = await fetch('/api/sensitive-action', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    credentials: 'include',
    body: JSON.stringify({ /* 数据 */ })
  });
  
  return response.json();
}

总结

最佳实践清单

  1. Web应用

    • Access Token存内存,通过Authorization头发送
    • Refresh Token存HttpOnly + Secure + SameSite Cookie
    • 实现CSRF Token机制
    • 全站HTTPS
    • 敏感操作不使用GET方法
  2. Token安全

    • Access Token短期有效(5-15分钟)
    • Refresh Token适中有效期(7-30天)
    • 实现Token轮换与复用检测
    • 使用JTI(JWT ID)管理Token撤销
  3. 防御措施

    • XSS防御:CSP、输入验证、输出编码
    • CSRF防御:SameSite Cookie + CSRF Token
    • 中间人防御:HTTPS + 证书固定

安全与用户体验平衡

安全性和用户体验往往需要权衡。最佳方案应根据应用场景、用户群体和安全需求来确定。双Token方案在大多数情况下能提供良好的安全性和用户体验平衡。

最终建议

无论选择哪种方案,都应定期审计安全措施,关注新的安全威胁,并及时更新防御策略。安全是一个持续过程,而非一次性工作。

❌