阅读视图

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

解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南

解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南

入职新公司接手前端项目,相信很多同学都遇到过这样的糟心事:明明用了同事给的setting.json配置,代码格式化却依然不遵循项目的 ESLint 规则,手动改格式又费时间又容易出错。

我最近就踩了这个坑,折腾了一番终于搞定了,今天把完整的解决方案整理出来,帮大家少走弯路。

一、先确认项目基础配置

在配置 VSCode 之前,首先要确保项目本身的 ESLint 配置是完整的,这是格式化生效的前提。

1. 检查项目根目录的 ESLint 配置文件

首先查看项目根目录下是否存在 ESLint 的核心配置文件,常见的有:

  • .eslintrc.js(最常用,推荐)
  • .eslintrc.json
  • .eslintrc
  • package.json中配置的eslintConfig字段

如果没有这些文件,说明项目本身未配置 ESLint 规则,后续 VSCode 配置再全也没用。可以找同事要一份项目对应的 ESLint 配置,或根据项目技术栈(Vue/React/TS)初始化一份。

2. 确认项目依赖已安装

确保项目node_modules中包含 ESLint 核心依赖及对应插件,比如:

# 安装核心ESLint(如果项目未安装)
npm install eslint --save-dev

# 针对Vue项目补充依赖(示例)
npm install eslint-plugin-vue @vue/eslint-config-standard --save-dev

# 针对React项目补充依赖(示例)
npm install eslint-plugin-react eslint-plugin-react-hooks --save-dev

二、VSCode 端配置:让格式化走 ESLint 规则

1. 安装并启用 ESLint 扩展

打开 VSCode 扩展市场(快捷键Ctrl+Shift+X),搜索ESLint(作者是 dbaeumer),安装后确保启用(扩展卡片显示"已启用")。

2. 配置 settings.json:核心步骤

打开 VSCode 的设置文件(快捷键Ctrl+,,然后点击右上角"打开设置(JSON)"图标),添加以下配置:

{
  // 启用ESLint作为格式化工具
  "eslint.format.enable": true,
  // 指定ESLint需要校验的文件类型
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "vue",
    "typescript",
    "typescriptreact" // 如有TS/TSX需求可添加
  ],
  // 为不同文件类型指定默认格式化器为ESLint
  "[javascript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[vue]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[typescript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  // 可选:自动保存(避免忘记保存导致格式化不生效)
  "files.autoSave": "afterDelay",
  // 可选:保存时自动格式化(核心!让保存即符合ESLint规则)
  "editor.formatOnSave": true,
  // 可选:保存时自动修复ESLint错误(比单纯格式化更强大)
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  // 可选:关闭其他可能冲突的格式化工具(如Prettier,避免规则冲突)
  "prettier.enable": false
}

关键配置说明

  • eslint.format.enable: 核心开关,允许 ESLint 作为格式化工具
  • eslint.validate: 告诉 ESLint 要处理哪些类型的文件,根据项目技术栈调整
  • editor.defaultFormatter: 为指定文件类型绑定 ESLint 作为默认格式化器,这是解决"格式化不走 ESLint"的核心
  • editor.codeActionsOnSave: 保存时自动修复 ESLint 错误(比如自动补分号、修正缩进),比单纯格式化更实用

三、常见问题排查

如果配置后仍不生效,按以下步骤排查:

  1. 重启 VSCode:修改settings.json后,重启编辑器让配置生效;
  2. 检查 ESLint 扩展状态:打开 VSCode 的"输出"面板(Ctrl+Shift+U),选择"ESLint",查看是否有报错(比如依赖缺失、配置文件语法错误);
  3. 确认文件类型:比如 Vue 文件是否被 VSCode 识别为"vue"类型(右下角可查看/修改);
  4. 排除规则冲突:如果项目同时配置了 Prettier,建议使用eslint-config-prettiereslint-plugin-prettier整合规则,避免冲突。

四、验证配置是否生效

  1. 打开项目中的一个 JS/Vue 文件,故意写一段不符合 ESLint 规则的代码(比如少分号、缩进错误);
  2. 按下Ctrl+S保存文件;
  3. 如果代码自动修正为符合 ESLint 规则的格式,说明配置成功。

总结

  1. 格式化生效的前提是项目有完整的 ESLint 配置文件和依赖,否则 VSCode 端配置无意义;
  2. VSCode 核心配置是绑定对应文件类型的默认格式化器为 ESLint,并启用保存自动修复;
  3. 配置后若不生效,优先检查 ESLint 扩展状态和配置文件语法,重启 VSCode 是简单有效的排查手段。

希望这篇指南能帮到刚入职新项目、被 ESLint 格式化困扰的同学,少踩坑,多写优雅的代码~

Vue 3 + Vite 自动引入插件完整指南(unplugin-vue-components,unplugin-auto-import)

Vue 3 + Vite 自动引入插件完整指南

介绍如何在 Vue 3 + Vite 项目中配置 unplugin-vue-components(自动引入组件)和 unplugin-auto-import(自动引入 API),实现零 import 开发体验


一、两个插件的区别

unplugin-vue-components unplugin-auto-import
作用 自动导入组件 自动导入 API / 函数
省去什么 import DictTag from '@/components/DictTag/index.vue' import { ref, computed } from 'vue'
生成的类型文件 components.d.ts auto-imports.d.ts

效果对比

使用前(手动导入):

<template>
  <DictTag :value="count" />
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import DictTag from "@/components/DictTag/index.vue";

const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>

使用后(自动导入):

<template>
  <DictTag :value="count" />
  <!-- 自动导入组件 -->
</template>

<script setup>
const count = ref(0); // 自动导入 ref
const doubled = computed(() => count.value * 2); // 自动导入 computed
const router = useRouter(); // 自动导入 useRouter
</script>

二、从零搭建步骤

2.1 安装依赖

npm install -D unplugin-vue-components unplugin-auto-import

如果需要自动导入 Element Plus 等 UI 框架的组件和样式,不需要额外安装 resolver,它们已内置在 unplugin-vue-components 中。

2.2 配置 vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Components from "unplugin-vue-components/vite";
import AutoImport from "unplugin-auto-import/vite";
// 如需 Element Plus 按需导入,取消下面注释
// import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),

    // ==========================================
    // 插件一:自动导入 API(ref、computed 等)
    // ==========================================
    AutoImport({
      // 需要自动导入的库
      imports: [
        "vue", // ref, computed, watch, onMounted 等
        "vue-router", // useRouter, useRoute 等
        "pinia", // defineStore, storeToRefs 等
        // '@vueuse/core', // 按需添加
      ],

      // 生成类型声明文件(让编辑器识别自动导入的 API)
      dts: "src/types/auto-imports.d.ts",

      // 是否在 Vue 模板中自动导入
      vueTemplate: true,

      // 如需自动导入 Element Plus 的 API(ElMessage 等),取消注释:
      // resolvers: [ElementPlusResolver()],

      // 生成 ESLint 配置(避免 eslint 报未定义错误)
      eslintrc: {
        enabled: true, // 首次生成后可改为 false
        filepath: "./.eslintrc-auto-import.json",
      },
    }),

    // ==========================================
    // 插件二:自动导入组件
    // ==========================================
    Components({
      // 指定组件扫描目录
      dirs: ["src/components"],

      // 递归扫描子目录
      deep: true,

      // 组件文件扩展名
      extensions: ["vue"],

      // 生成类型声明文件
      dts: "src/types/components.d.ts",

      // 如需自动导入 Element Plus 组件,取消注释:
      // resolvers: [ElementPlusResolver()],
    }),
  ],
});

2.3 配置 tsconfig.json

确保 TypeScript 能识别自动生成的类型文件:

{
  "compilerOptions": {
    // ... 其他配置
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.vue",
    "src/types/auto-imports.d.ts",
    "src/types/components.d.ts"
  ]
}

2.4 配置 ESLint(可选)

.eslintrc.cjs 中引入自动生成的全局变量声明:

module.exports = {
  extends: [
    // ... 其他配置
    "./.eslintrc-auto-import.json", // 自动导入的全局变量
  ],
};

三、组件目录结构

unplugin-vue-components 支持以下两种组件结构,组件名自动推导:

src/components/
│
├── MyButton.vue              → 组件名:<MyButton />
│
├── DictTag/
│   └── index.vue             → 组件名:<DictTag />
│
├── UserCard/
│   └── index.vue             → 组件名:<UserCard />
│
└── FileUpload/
    └── index.vue             → 组件名:<FileUpload />

四、自动生成的文件说明

启动项目后,插件会自动生成以下文件(不要手动修改,也建议加入 .gitignore):

src/types/components.d.ts(组件类型声明)

// 由 unplugin-vue-components 自动生成
declare module "vue" {
  export interface GlobalComponents {
    DictTag: (typeof import("../components/DictTag/index.vue"))["default"];
    FileUpload: (typeof import("../components/FileUpload/index.vue"))["default"];
    // ... 其他组件
  }
}

src/types/auto-imports.d.ts(API 类型声明)

// 由 unplugin-auto-import 自动生成
declare global {
  const ref: (typeof import("vue"))["ref"];
  const computed: (typeof import("vue"))["computed"];
  const watch: (typeof import("vue"))["watch"];
  const onMounted: (typeof import("vue"))["onMounted"];
  const useRouter: (typeof import("vue-router"))["useRouter"];
  // ... 其他 API
}

五、常用进阶配置

5.1 搭配 Element Plus 按需导入

npm install element-plus
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

AutoImport({
  imports: ["vue", "vue-router"],
  resolvers: [ElementPlusResolver()], // 自动导入 ElMessage, ElNotification 等
});

Components({
  dirs: ["src/components"],
  resolvers: [ElementPlusResolver()], // 自动导入 <el-button>, <el-input> 等
});

5.2 自定义导入规则

AutoImport({
  imports: [
    "vue",
    "vue-router",
    {
      // 自定义导入:从 '@/utils/request' 自动导入 request 函数
      "@/utils/request": ["request", "download"],
      // 从 axios 自动导入
      axios: [["default", "axios"]],
    },
  ],
});

5.3 排除不需要自动注册的组件

Components({
  dirs: ["src/components"],
  // 排除特定目录
  exclude: [/\.test\./, /node_modules/],
});

六、常见问题排查

Q1:组件自动导入不生效?

检查项 解决方案
components.d.ts 为空 删除后重启 npm run dev,确保有页面访问触发编译
项目路径含特殊字符 ()[]{} 重命名路径,去掉括号等 glob 特殊字符
组件结构不对 确保是 ComponentName/index.vueComponentName.vue
dirs 路径错误 用绝对路径验证:dirs: [path.resolve(__dirname, 'src/components')]

Q2:ESLint 报 ref is not defined

确保:

  1. AutoImporteslintrc.enabled 设为 true 生成配置文件
  2. .eslintrc.cjs 中 extends 了 .eslintrc-auto-import.json
  3. 生成后可将 enabled 改回 false(避免每次启动都重写)

Q3:编辑器没有智能提示?

确保 tsconfig.jsoninclude 中包含了两个 .d.ts 文件路径。


七、工作原理简述

┌──────────────────────────────────────────────────┐
│                   Vite 编译流程                    │
├──────────────────────────────────────────────────┤
│                                                  │
│  .vue 文件 → Vite 编译                            │
│     │                                            │
│     ├── <template> 中发现 <DictTag />             │
│     │   └── unplugin-vue-components 介入          │
│     │       └── 自动注入:                         │
│     │           import DictTag from               │
│     │           '@/components/DictTag/index.vue'  │
│     │                                            │
│     ├── <script> 中发现 ref()                     │
│     │   └── unplugin-auto-import 介入             │
│     │       └── 自动注入:                         │
│     │           import { ref } from 'vue'         │
│     │                                            │
│     └── 编译产物(已包含所有 import)                │
│                                                  │
└──────────────────────────────────────────────────┘

核心点:两个插件都是在 Vite 编译阶段 介入的,它们不改变你的源码,而是在编译产物中自动注入需要的 import 语句。写代码时完全不需要手动 import。

路由与布局骨架篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先搞清楚一件事:什么是布局?

布局(Layout)就是页面里不随路由变的那一部分:头部、侧边栏、面包屑、底部等。
真正随路由变化的是「内容区」。布局负责把这些固定区域包起来,内容区填进其中。

  • 布局:结构固定、多页面共用
  • 内容区:随路由切换、每页不同

理解了这一点,再去看 Vue Router 的嵌套路由,就很好理解。

二、为什么要拆分布局组件?

不拆的话,每个页面都要写一遍头部、侧边栏,会有这些问题:

  1. 重复代码多
  2. 改头部要改 N 个页面
  3. 页面结构和布局混在一起,难维护

拆分后:

  • 布局组件:只负责头部、侧边栏等固定结构
  • 内容区:只负责当前页面的业务
  • 路由:负责决定「用哪个布局」「在哪个槽位渲染内容」

三、整体结构预览

Layout(布局容器)
├── AppHeader(头部)
├── AppSidebar(侧边栏)
├── Breadcrumb(面包屑,可选)
└── 内容区(由 <router-view> 渲染)

接下来按「路由配置 → 布局组件 → 各子组件」的顺序说明。

四、路由配置:布局与路由如何配合?

核心思路:用嵌套路由,父路由用 Layout,子路由占内容区。

4.1 基础路由结构

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layouts/BasicLayout.vue'

const routes = [
  {
    path: '/',
    component: Layout,  // 父路由使用布局组件
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { title: '仪表盘', icon: 'dashboard' }
      },
      {
        path: 'user',
        name: 'User',
        component: () => import('@/views/User.vue'),
        meta: { title: '用户管理', icon: 'user' }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

要点:

  • 父路由 path: '/'Layout
  • children 里的每个路由才是具体页面
  • meta 用来存标题、图标等,后面给面包屑和菜单用

4.2 多个布局怎么办?

例如:后台用带侧边栏的布局,登录页用简单布局。

const routes = [
  // 后台布局(带侧边栏)
  {
    path: '/',
    component: () => import('@/layouts/BasicLayout.vue'),
    children: [
      { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '仪表盘' } },
      { path: 'user', component: () => import('@/views/User.vue'), meta: { title: '用户管理' } }
    ]
  },
  // 登录页布局(无侧边栏)
  {
    path: '/login',
    component: () => import('@/layouts/BlankLayout.vue'),
    children: [
      { path: '', component: () => import('@/views/Login.vue') }
    ]
  }
]

每个布局对应一个父路由,它的 children 共用同一个布局。

五、布局组件 BasicLayout.vue

5.1 完整示例

<!-- layouts/BasicLayout.vue -->
<template>
  <el-container class="basic-layout">
    <!-- 头部 -->
    <AppHeader />
    
    <el-container>
      <!-- 侧边栏 -->
      <AppSidebar />
      
      <!-- 主内容区 -->
      <el-main class="main-content">
        <!-- 面包屑 -->
        <Breadcrumb />
        <!-- 内容区:由路由渲染 -->
        <div class="content-wrapper">
          <router-view v-slot="{ Component }">
            <transition name="fade" mode="out-in">
              <component :is="Component" />
            </transition>
          </router-view>
        </div>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import AppHeader from './components/AppHeader.vue'
import AppSidebar from './components/AppSidebar.vue'
import Breadcrumb from './components/Breadcrumb.vue'
</script>

<style scoped>
.basic-layout {
  min-height: 100vh;
  flex-direction: column;
}
.main-content {
  padding: 20px;
  background: #f5f7fa;
}
.content-wrapper {
  margin-top: 16px;
  padding: 20px;
  background: #fff;
  border-radius: 4px;
  min-height: calc(100vh - 180px);
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

要点:

  • <router-view> 就是子路由渲染的地方
  • v-slot="{ Component }" + <component :is="Component"> 可以配合过渡动画
  • 没有用 Element Plus 的话,把 el-container 换成普通 div 即可

六、各子组件实现

6.1 头部 AppHeader.vue

<!-- layouts/components/AppHeader.vue -->
<template>
  <header class="app-header">
    <div class="header-left">
      <span class="logo">后台管理系统</span>
    </div>
    <div class="header-right">
      <span class="user-name">管理员</span>
      <button @click="handleLogout">退出</button>
    </div>
  </header>
</template>

<script setup>
const handleLogout = () => {
  // 登出逻辑
  console.log('退出登录')
}
</script>

<style scoped>
.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 60px;
  padding: 0 24px;
  background: #001529;
  color: #fff;
}
.header-right {
  display: flex;
  align-items: center;
  gap: 16px;
}
</style>

6.2 侧边栏 AppSidebar.vue

侧边栏菜单需要和路由保持一致,用 routerroutes 或自己维护菜单配置都可以。

<!-- layouts/components/AppSidebar.vue -->
<template>
  <aside class="app-sidebar">
    <el-menu
      :default-active="activeMenu"
      router
      background-color="#001529"
      text-color="#fff"
    >
      <el-menu-item index="/dashboard">
        <span>仪表盘</span>
      </el-menu-item>
      <el-menu-item index="/user">
        <span>用户管理</span>
      </el-menu-item>
    </el-menu>
  </aside>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 高亮当前路由对应的菜单项
const activeMenu = computed(() => route.path)
</script>

<style scoped>
.app-sidebar {
  width: 200px;
  background: #001529;
}
</style>

要点:

  • router 属性:点击菜单项会直接 router.push(index),无需手动处理
  • default-active 绑定当前路径,实现高亮

6.3 面包屑 Breadcrumb.vue

面包屑需要从当前路由推导出层级,用 route.matched 即可。

<!-- layouts/components/Breadcrumb.vue -->
<template>
  <el-breadcrumb separator="/" class="breadcrumb">
    <el-breadcrumb-item
      v-for="(item, index) in breadcrumbList"
      :key="item.path"
    >
      <!-- 最后一项不跳转 -->
      <router-link v-if="index < breadcrumbList.length - 1" :to="item.path">
        {{ item.meta?.title || item.name || '未命名' }}
      </router-link>
      <span v-else>{{ item.meta?.title || item.name || '未命名' }}</span>
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 从路由的 matched 自动生成面包屑
const breadcrumbList = computed(() => {
  return route.matched.filter(item => item.meta?.title || item.name)
})
</script>

<style scoped>
.breadcrumb {
  margin-bottom: 16px;
}
</style>

要点:

  • route.matched 是当前路由及其所有父路由的数组,正好对应面包屑层级
  • 最后一项用 <span>,前面的用 <router-link> 方便点击返回

七、常见坑点

坑 1:侧边栏和路由不同步

  • 原因:菜单写死在模板里,路由改了菜单没改
  • 做法:用 router.options.routes 或单独维护菜单配置,和路由保持一致,用 route.path 作为菜单的 index

坑 2:面包屑不显示或显示不对

  • 原因:route.matched 里的路由没有 meta.title
  • 做法:给每个需要出现在面包屑中的路由加上 meta: { title: 'xxx' },根路由如果是 redirect 可以不加或设为 hidden: true

坑 3:刷新后侧边栏高亮错误

  • 原因:default-active 没正确绑定到当前路径
  • 做法:用 computed(() => route.path) 绑定,并且菜单项的 index 和路由的 path 一致

坑 4:布局组件被重复创建

  • 原因:同一个父路由下的子路由切换时,Vue Router 默认会复用父级 Layout
  • 做法:这是正常行为。若需要在切换子路由时强制重挂载 Layout,可以给 router-view:key="route.fullPath",但一般不需要

八、菜单与路由统一:进阶写法

为了不重复维护「路由」和「菜单」,可以统一用路由生成菜单:

// 在 router 里定义好 meta
// 在 AppSidebar 里动态读取
import { useRouter } from 'vue-router'

const router = useRouter()
const menuRoutes = computed(() => {
  const parent = router.options.routes.find(r => r.path === '/')
  return (parent?.children || []).filter(r => !r.meta?.hidden)
})
<el-menu-item
  v-for="item in menuRoutes"
  :key="item.path"
  :index="'/' + item.path"
>
  {{ item.meta?.title }}
</el-menu-item>

这样菜单和路由只维护一份。

九、总结

模块 职责 与路由的关系
Layout 包裹头部、侧边栏、内容区 作为父路由的 component
Header 顶部固定区域 一般与路由无关
Sidebar 菜单导航 使用 routerroute.path 高亮
Breadcrumb 当前路径层级展示 依赖 route.matchedmeta
内容区 子页面内容 <router-view> 渲染

记住三步:

  1. 用嵌套路由,父用 Layout,子用具体页面
  2. 布局拆成 Header、Sidebar、Breadcrumb、router-view 四个区域
  3. 菜单、面包屑都从 routemeta 推导,避免重复配置

如果你希望我把某个小节展开(例如只用原生 div + CSS,或用 Vue 2 + Vue Router 3 版本),可以说一下具体需求,我可以再补一版对应示例。

🔍 本系列专栏导航

一、《路由与布局扫盲篇:Vue Router 实战 | 动态路由、嵌套路由与多级菜单》

二、《路由与布局扫盲篇:登录态与路由守卫 | token 校验、白名单、重定向》

三、《路由与布局扫盲篇:多标签页(Tab)与缓存 | keep-alive、includeexclude、路由 meta》

四、《路由与布局扫盲篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

Vue 3 新标准:<script setup> 核心特性、宏命令与避坑指南

<script setup> 是 Vue 3.2 引入的一种编译时语法糖,旨在简化 Composition API 的使用。它并不是一个新的功能,而是对原有 <script> 中使用 Composition API 写法的一种语法优化

简单来说,它让你用更少的代码更直观的写法来实现同样的功能,同时在性能上也有显著提升。


1. 核心对比:传统写法 vs <script setup>

❌ 传统写法 (Vue 3.2 之前)

你需要手动导入 API,定义数据/方法,并显式 return 给模板使用。

<script>
import { ref, reactive } from 'vue'

export default {
  components: { MyComponent }, // 需手动注册组件
  props: ['title'],           // 需手动定义 props
  
  setup(props, { emit }) {
    const count = ref(0)
    const user = reactive({ name: 'Alice' })
    
    function increment() {
      count.value++
    }

    // ⚠️ 必须手动 return,模板才能访问
    return {
      count,
      user,
      increment,
      title // props 也要 return
    }
  }
}
</script>

✅ <script setup> 写法

无需 export default,无需 return,顶层变量自动暴露。

<script setup>
import { ref, reactive } from 'vue'
import MyComponent from './MyComponent.vue' // ✅ 自动注册组件

// ✅ 直接定义 props (编译后自动生成)
defineProps(['title'])

// ✅ 直接定义 emits
const emit = defineEmits(['change'])

// 顶层变量自动暴露给模板,无需 return
const count = ref(0)
const user = reactive({ name: 'Alice' })

function increment() {
  count.value++
  emit('change', count.value)
}
</script>

2. <script setup> 的五大核心好处

1. 代码更简洁(少写样板代码)

  • 无需 export default:组件选项直接在标签内定义。
  • 无需 return:在 <script setup> 中声明的所有顶层变量(reffunctionimport 的组件等)自动暴露给模板使用。这减少了大量的重复代码和出错可能。
  • 组件自动注册:导入的组件(如 import MyComp from ...)可以直接在模板中使用 <MyComp />,无需在 components 选项中注册。

2. 更好的 TypeScript 支持

  • 类型推导更精准:由于不需要通过 return 对象来暴露变量,TS 可以直接推断顶层变量的类型,无需复杂的泛型声明。
  • Props/Emits 类型化:配合 defineProps<Type>() 和 defineEmits<Type>(),可以获得完美的类型提示和校验,而传统写法需要繁琐的 withDefaults 或接口定义。

3. 更高的运行时性能

  • 编译优化<script setup> 的组件会被编译为一个匿名函数,作为 setup() 钩子的实现。
  • 避免代理开销:传统写法中,setup 返回的对象会被 Vue 包装成代理(Proxy)以便模板访问。而 <script setup> 中的绑定是通过闭包直接访问的,省去了创建代理对象的开销,访问速度更快。
  • Tree-shaking:未使用的代码更容易被打包工具剔除。

4. 逻辑更清晰

  • 消除“割裂感” :在传统写法中,定义的变量和模板中使用的变量之间隔着一个 return 块,阅读时需要上下跳转。<script setup> 让代码从上到下线性执行,定义即使用。
  • 专注于逻辑:开发者可以更专注于业务逻辑本身,而不是 Vue 的样板结构。

5. 原生支持宏(Macros)

提供了一些编译时宏,无需导入即可直接使用:

  • defineProps: 声明 props。
  • defineEmits: 声明 emits。
  • defineExpose: 显式暴露属性给父组件(默认情况下 <script setup> 组件实例是关闭的,即父组件无法通过 ref 访问其内部属性,除非使用此宏)。
  • defineOptions: (Vue 3.3+) 声明组件选项(如 nameinheritAttrs)。
  • withDefaults: 为 defineProps 设置默认值。

3. 特殊用法详解

A. 定义 Props 和 Emits

<script setup>
// 接收 props,具有类型推导
const props = defineProps({
  msg: String,
  count: { type: Number, required: true }
})

// 定义 emits
const emit = defineEmits(['update:count', 'submit'])

function update() {
  emit('update:count', props.count + 1)
}
</script>

B. 暴露给父组件 (defineExpose)

默认情况下,父组件通过 ref 获取子组件实例时,无法访问 <script setup> 内部的变量。如果需要暴露,必须显式声明:

<!-- Child.vue -->
<script setup>
import { ref } from 'vue'

const secret = 'hidden'
const publicData = ref(100)

function publicMethod() {
  console.log('called')
}

// 只暴露 publicData 和 publicMethod
defineExpose({
  publicData,
  publicMethod
})
</script>

C. 配合 TypeScript

<script setup lang="ts">
interface User {
  id: number
  name: string
}

// 泛型支持
const props = defineProps<{
  userId: number
  list: User[]
}>()

// 默认值
withDefaults(defineProps<{
  msg?: string
  labels?: string[]
}>(), {
  msg: 'Hello',
  labels: () => ['new'] // 对象/数组默认值需用工厂函数
})
</script>

4. 总结:为什么它是“最佳实践”?

特性 传统<script>+setup() <script setup>
代码量 多 (需 export, return, register) 极少 (声明即用)
性能 正常 (有代理开销) 更高 (闭包访问,无代理)
TS 支持 良好 (但需额外类型声明) 完美 (原生推导)
组件注册 手动 自动
推荐度 ⭐⭐ (兼容旧项目) ⭐⭐⭐⭐⭐ (新项目首选)

结论
除非你需要维护非常古老的 Vue 3 早期代码,否则在所有新的 Vue 3 项目中,都应该无条件使用 <script setup> 。它是 Vue 团队官方推荐的默认写法,代表了 Vue 未来的发展方向。

个人所得税计算器 在线工具核心JS实现

这篇只讲功能层 JavaScript 实现。这个工具的核心思路是:把税率规则、五险一金规则、累计预扣法放进一个计算类里,输入 12 个月收入和扣除配置后,一次产出整年的月度明细。

在线工具网址:see-tool.com/text-charac…
工具截图:
工具截图.png

1. 核心数据结构

计算器初始化时,先准备三类基础数据:

  • 累计预扣法税率表(含速算扣除数)
  • 城市社保/公积金缴费基数上下限
  • 每月减除费用(5000)
class IncomeTaxCalculator {
  constructor() {
    // 累计预扣税率区间
    this.taxBrackets = [
      { min: -1, max: 36000, rate: 0.03, deduction: 0 },
      { min: 36000, max: 144000, rate: 0.10, deduction: 2520 },
      { min: 144000, max: 300000, rate: 0.20, deduction: 16920 },
      { min: 300000, max: 420000, rate: 0.25, deduction: 31920 },
      { min: 420000, max: 660000, rate: 0.30, deduction: 52920 },
      { min: 660000, max: 960000, rate: 0.35, deduction: 85920 },
      { min: 960000, max: Infinity, rate: 0.45, deduction: 181920 }
    ]

    // 每月减除费用
    this.monthlyDeduction = 5000
  }
}

这一步把规则常量和计算逻辑解耦,后续计算函数不需要硬编码税率数字。

2. 月度五险一金计算

月度扣除支持三种基数来源:

  • 按月工资
  • 单一自定义基数
  • 养老/医疗/失业/公积金分别设置基数

关键点在“实际基数”处理:要同时受用户输入、封顶线、保底线、当月工资四个条件约束。

// 计算实际缴费基数
const getActualBase = (baseValue, capValue, floorValue, monthlyIncome) => {
  // 统一兜底,避免 NaN 和负数
  const safeBase = Math.max(0, baseValue || 0)
  const safeCap = Math.max(0, capValue || 0)
  const safeFloor = Math.max(0, floorValue || 0)

  // 先做上限约束:不能超过上限,也不能超过月薪
  let result = safeCap > 0
    ? Math.min(safeBase, safeCap, monthlyIncome)
    : Math.min(safeBase, monthlyIncome)

  // 再做下限约束
  if (safeFloor > 0) result = Math.max(result, safeFloor)

  return result
}

得到各项实际基数后,再乘对应费率得到养老、医疗、失业、公积金金额,并汇总 total。这一层输出会直接参与个税应纳税所得额计算。

3. 累计预扣税额计算

个税函数按“累计应纳税所得额”查税率区间并套公式:

calculateTaxForIncome(cumulativeIncome, cumulativeDeduction, cumulativeSpecialDeductions = 0) {
  // 累计应纳税所得额
  const taxableIncome = cumulativeIncome - cumulativeDeduction - cumulativeSpecialDeductions
  if (taxableIncome <= 0) return 0

  for (const bracket of this.taxBrackets) {
    if (taxableIncome > bracket.min && taxableIncome <= bracket.max) {
      return Math.max(0, taxableIncome * bracket.rate - bracket.deduction)
    }
  }

  // 兜底走最高档
  const highestBracket = this.taxBrackets[this.taxBrackets.length - 1]
  return Math.max(0, taxableIncome * highestBracket.rate - highestBracket.deduction)
}

这里返回的是“截至当前月的累计应纳税额”,不是当月税额。

4. 年度主流程:一次产出12个月明细

主流程会循环 12 次,每月做四件事:

  1. 计算当月五险一金
  2. 更新累计收入、累计专项扣除、累计附加扣除
  3. 计算累计应纳税额
  4. 反推当月税额并得到税后收入

核心公式是:

// 当月应纳税额 = 累计应纳税额 - 上月累计已纳税额
const monthlyTax = Math.max(0, cumulativeTaxAmount - cumulativeTax)

// 税后收入 = 税前收入 - 五险一金 - 当月个税
const afterTaxIncome = monthlyIncome - insurance.total - monthlyTax

每月结果会记录为结构化对象(收入、扣除、税额、税后、累计值等),最终返回一个 12 项数组,界面层可直接用于表格展示和汇总统计。

5. 工具方法

核心逻辑外还有两个实用方法:

  • 金额格式化:把数字转成带千分位、保留两位小数的货币字符串
  • 城市基数读取:按城市键返回对应的社保/公积金上下限配置

这两个方法让计算层对外输出更稳定,页面调用时不需要重复写格式化和城市映射逻辑。

整套实现的重点是“规则集中、计算分层、月度与累计并行维护”。这样既能保证个税计算口径一致,也方便后续扩展更多收入场景。

Vue 表单避坑(一):为什么 v-model 绑定对象属性会偷偷修改父组件数据?

场景引入

在 Vue 项目里,表单组件几乎无处不在。为了提高复用性,我们常常会把一堆输入框封装成一个“大表单组件”,然后通过 v-model 直接绑定一个对象给外部组件:

<!-- App.vue -->
<script setup>
  import { ref } from 'vue'
  import MyForm from './MyForm.vue'

  const data = ref({  })
</script>

<template>
  <MyForm v-model="data" />
</template>

在 MyForm.vue 里,我们定义一个 model,接着直接把 model 的属性绑定到 MyInput 上:

<!-- MyForm.vue -->
<script setup>
  import MyInput from './MyInput.vue'
  import { computed } from 'vue'
  const props = defineProps({
    modelValue: Object
  });
  const emit = defineEmits(['update:modelValue']);
  const model = computed({ 
    get: () => props.modelValue, 
    set: (v) => emit('update:modelValue', v)  
  })
</script>

<template>
  <div>开始:<MyInput v-model="model.start" /></div> 
  <div>结束:<MyInput v-model="model.end" /></div>
</template>

最后是简单的 MyInput.vue

<!-- MyInput.vue -->
<script setup>
  import { computed } from 'vue'
  const props = defineProps({
    modelValue: Number
  });
  const emit = defineEmits(['update:modelValue']);
  const value = computed({ 
    get: () => props.modelValue, 
    set: (v) => emit('update:modelValue', v)  
  })
</script>

<template>
  <span>
    <span>{{ value }}</span>
    <button @click="value = Date.now()">更新</button>
  </span>
</template>

看起来一气呵成,干净又优雅,不是吗?

然而,这段代码已经违背了单向数据流原则。

先做个实验:把 v-model 换成 :model-value

把 App.vue 里的 v-model 改成 :model-value(也就是只传 prop,不监听 update 事件):

<!-- App.vue -->
<script setup>
  import { ref } from 'vue'
  import MyForm from './MyForm.vue'

  const data = ref({  })
</script>

<template>
  <MyForm :model-value="data" />
</template>

按常理,此时 data 不应该被子组件修改,因为父组件没有监听 update 事件。

但是点击按钮后你会发现——data 还是被改了! (不信可以去 Vue Playground 试试)

这就怪了,明明没有监听 update 事件,数据怎么变的?因为子组件直接修改了同一个对象的属性,绕过了事件机制。

问题的本质:v-model 直接绑定属性值时发生了什么?

在 MyForm.vue 中,我们写了 <MyInput v-model="model.start" />v-model="model.start" 在 Vue 3 中会被展开为:

<MyInput
  :model-value="model.start"
  @update:model-value="v => model.start = v"
/>

model.start 是什么?是 modelValue 的一个属性,直接指向父组件的 data。所以 v => model.start = v 这一赋值直接修改了父组件的对象属性,根本没有触发 MyForm.vue 的 update:model-value 事件。

换句话说,MyForm.vue 没有发出 update:model-value 事件,App.vue 完全不知道自己数据已经被改了。


你还可以把 MyForm.vue 中的 model 调整为

const model = computed({ 
    get: () => props.modelValue, 
    set: (v) => {
      console.log('MyForm.vue update:modelValue', v)
      emit('update:modelValue', v) 
    } 
})

在控制台里,没有输出内容。console.log('MyForm.vue update:modelValue', v) 完全不会执行到

单向数据流到底是什么?

Vue 的单向数据流规定:

  • 父组件通过 props 把数据交给子组件。
  • 子组件不能直接修改 props,必须通过 emit 事件 通知父组件,由父组件自己修改数据。
  • 数据永远是从父 → 子,事件是从子 → 父。

v-model 本身是符合单向数据流的——前提是你通过事件更新的是整个数据,而不是直接修改对象的属性。

在上面的例子中,虽然我们用了 v-model,但实际更新时是直接改了对象的属性,跳过了通知 App.vue 更新数据的步骤,在 MyForm.vue 中偷偷改了数据,违背了设计原则。

修复方案

既然直接绑定属性会导致“暗箱操作”,那我们就改成显式的方式——**每次字段更新都通过一个 update 函数,生成一个新对象来赋值。

<!-- MyForm.vue -->
<script setup>
  import MyInput from './MyInput.vue'
  import { computed } from 'vue'
  const props = defineProps({
    modelValue: Object
  });
  const emit = defineEmits(['update:modelValue']);
  const model = computed({ 
    get: () => props.modelValue, 
    set: (v) => {
      console.log('MyForm.vue update:modelValue', v)
      emit('update:modelValue', v) 
    } 
  })
  function update(k, v) {
    model.value = {
      ...model.value,
      [k]: v
    }
  }
</script>

<template>
  <div>开始:<MyInput 
    :model-value="model.start" 
    @update:model-value="v => update('start', v)" 
  /></div> 
  <div>结束:<MyInput 
    :model-value="model.end" 
    @update:model-value="v => update('end', v)" 
  /></div>
</template>

此时,console.log('MyForm.vue update:modelValue', v) 代码正常执行。

App.vue<MyForm :model-value="data" /> 时,内层无法更新外层数据。

小结

在组件化设计中,数据的“所有权”必须与“修改权”严格对应。  App.vue 作为数据的拥有者,应该掌握唯一的修改权限;MyForm.vue只能通过“申请-批准”的机制(即 emit 事件)来请求变更。这是保证状态可预测、可调试的基石。

KeepAlive:组件缓存实现深度解析

在前面的文章中,我们学习了 Suspense 如何处理异步组件加载。今天,我们将探索Vue3中另一个强大的特性:KeepAlive。它允许我们在组件切换时缓存组件实例,避免重复渲染,极大地提升了用户体验和性能。理解它的实现原理,将帮助我们更好地处理需要保持状态的组件。

前言:为什么需要组件缓存?

在构建大型单页应用时,我们经常会遇到这样的场景:

  • 用户频繁切换标签页,每次切换回来表单数据却丢失了。
  • 一个复杂的图表组件每次重新进入都要重新渲染,造成性能浪费。

Vue3 的 KeepAlive 组件正是为了解决这些问题而生。本文将深入剖析 KeepAlive 的工作原理、LRU缓存策略、生命周期变化,并手写一个简易实现。

KeepAlive 组件概述

什么是 KeepAlive

KeepAlive 是 Vue 的内置组件,它能够在组件切换时,自动将组件实例保存在内存(缓存)中,而不是直接将其销毁。当组件再次被切回时,直接从缓存中恢复实例和 DOM,从而避免重复渲染和状态丢失:

<template>
  <keep-alive>
    <component :is="currentTab" />
  </keep-alive>
</template>

核心优势

  • 状态保持:表单输入、滚动位置等状态在切换后依然保留
  • 性能提升:避免重复创建和销毁组件实例,减少DOM操作
  • 数据复用:避免重复请求相同的数据,减少网络开销

KeepAlive 的工作机制

核心原理:DOM的"搬家"

很多人误以为 KeepAlive 只是简单的 display: none,其实不然:它的本质是将组件的 DOM 节点从页面上摘下来,并将组件实例和 DOM 引用保存在内存中。当再次切回来时,直接从内存中取出这个 DOM 节点重新挂上去。

这个过程可以简化为:

  • 组件失活时:container.removeChild(dom) ,移除组件节点,但在内存中保留实例
  • 组件激活时:container.appendChild(dom) ,挂载组件节点,并恢复组件状态

缓存队列的设计

KeepAlive 内部使用两个核心数据结构来管理缓存:

const cache: Map<string, VNode> = new Map();  // 缓存存储
const keys: Set<string> = new Set();          // 缓存key顺序队列
  • cache:存储组件 VNode 的 Map 结构,key 通常是组件的 id 或 key 属性
  • keys:维护缓存 key 的访问顺序,用于实现 LRU 淘汰策略

核心配置属性

<keep-alive
  :include="['ComponentA', 'ComponentB']"  
  :exclude="/ComponentC/"                  
  :max="10"                                 
>
  <component :is="currentComponent" />
</keep-alive>
  • include:只有名称匹配的组件才会被缓存,支持字符串、正则、数组
  • exclude:名称匹配的组件不会被缓存
  • max:最多缓存多少组件实例,超过时按 LRU 策略淘汰

激活与失活:特殊的生命周期

activated 和 deactivated

当组件被 KeepAlive 包裹时,它会多出两个生命周期钩子:

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // 调用时机:
  // 1. 组件首次挂载
  // 2. 每次从缓存中被重新插入时
  console.log('组件被激活了')
  // 适合恢复轮询、恢复动画等
})

onDeactivated(() => {
  // 调用时机:
  // 1. 从 DOM 上移除、进入缓存时
  // 2. 组件卸载时
  console.log('组件被停用了')
  // 适合清除定时器、暂停网络请求等
})
</script>

与普通生命周期的关系

被缓存的组件在切换时不会触发 unmountedmounted,而是触发 deactivatedactivated。这意味着组件实例一直活着,只是暂时休眠,其生命周期流程如下:

  • 首次进入: beforeMount -> mounted -> activated
  • 切换出去: -> deactivated
  • 切换回来: -> activated
  • 最终销毁: -> beforeUnmount -> unmounted -> deactivated

源码实现机制

Vue3 内部通过 registerLifecycleHook 来管理这些钩子:

function registerLifecycleHook(type, hook) {
  const instance = getCurrentInstance()
  if (instance) {
    (instance[type] || (instance[type] = [])).push(hook)
  }
}

// 激活时执行
function activateComponent(instance) {
  if (instance.activated) {
    instance.activated.forEach(hook => hook())
  }
}

// 失活时执行
function deactivateComponent(instance) {
  if (instance.deactivated) {
    instance.deactivated.forEach(hook => hook())
  }
}

LRU 淘汰策略深度解析

为什么需要 LRU

当设置了 max 属性后,缓存池容量有限。如果没有淘汰策略,无限缓存会导致内存溢出。LRU(Least Recently Used)算法正是解决这个问题的经典方案。

LRU 核心思想

LRU 基于"最近被访问的数据将来被访问的概率更高"这一假设:

  • 新数据插入到链表尾部
  • 每当缓存命中,将数据移到链表尾部
  • 链表满时,丢弃链表头部的数据(最久未使用)

KeepAlive 中的 LRU 实现

KeepAlive 利用 Set 的迭代顺序特性来实现 LRU,即:每次访问时先删除再添加,就实现了"移到末尾"的效果:

// 核心LRU逻辑
if (cachedVNode) {
  // 缓存命中:删除旧key,重新添加到末尾(表示最新使用)
  keys.delete(key)
  keys.add(key)
  return cachedVNode
} else {
  // 缓存未命中:添加新key
  keys.add(key)
  
  // 检查是否超过最大限制
  if (max && keys.size > max) {
    // 淘汰最久未使用的key(Set的第一个元素)
    const oldestKey = keys.values().next().value
    pruneCacheEntry(oldestKey)
  }
  cache.set(key, vnode)
  return vnode
}

手写实现简易 KeepAlive 组件

核心实现思路

// MyKeepAlive.ts
import { defineComponent, h, onBeforeUnmount, getCurrentInstance } from 'vue'

export default defineComponent({
  name: 'MyKeepAlive',
  
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },
  
  setup(props, { slots }) {
    // 缓存容器
    const cache = new Map()
    const keys = new Set()
    
    // 当前渲染的 vnode
    let current = null
    
    // 工具函数:检查组件名是否匹配规则
    const matches = (pattern, name) => {
      if (Array.isArray(pattern)) {
        return pattern.includes(name)
      } else if (pattern instanceof RegExp) {
        return pattern.test(name)
      } else if (typeof pattern === 'string') {
        return pattern.split(',').includes(name)
      }
      return false
    }
    
    // 工具函数:获取组件名称
    const getComponentName = (vnode) => {
      const type = vnode.type
      return type.name || type.__name
    }
    
    // 淘汰缓存
    const pruneCacheEntry = (key) => {
      const cached = cache.get(key)
      if (cached && cached.component) {
        // 如果不是当前激活的组件,需要卸载
        if (cached !== current) {
          cached.component.unmount()
        }
      }
      cache.delete(key)
      keys.delete(key)
    }
    
    // 根据 include/exclude 清理缓存
    const pruneCache = (filter) => {
      cache.forEach((vnode, key) => {
        const name = getComponentName(vnode)
        if (name && filter(name)) {
          pruneCacheEntry(key)
        }
      })
    }
    
    // 监听 include/exclude 变化
    if (props.include || props.exclude) {
      watch(
        () => [props.include, props.exclude],
        ([include, exclude]) => {
          include && pruneCache(name => !matches(include, name))
          exclude && pruneCache(name => matches(exclude, name))
        },
        { flush: 'post' }
      )
    }
    
    // 组件卸载时清理所有缓存
    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        if (vnode.component) {
          vnode.component.unmount()
        }
      })
      cache.clear()
      keys.clear()
    })
    
    return () => {
      // 获取默认插槽的第一个子节点
      const vnode = slots.default?.()[0]
      if (!vnode) return null
      
      const name = getComponentName(vnode)
      
      // 检查 include/exclude
      if (
        (props.include && name && !matches(props.include, name)) ||
        (props.exclude && name && matches(props.exclude, name))
      ) {
        // 不缓存,直接返回
        return vnode
      }
      
      // 生成缓存key
      const key = vnode.key ?? vnode.type.__id ?? name
      
      // 命中缓存
      if (cache.has(key)) {
        const cachedVNode = cache.get(key)
        // 复用组件实例和DOM
        vnode.component = cachedVNode.component
        vnode.el = cachedVNode.el
        
        // 标记为 KeepAlive 组件
        vnode.shapeFlag |= 1 << 11 // ShapeFlags.COMPONENT_KEPT_ALIVE
        
        // LRU: 刷新key顺序
        keys.delete(key)
        keys.add(key)
        
        current = vnode
        return vnode
      }
      
      // 未命中缓存
      cache.set(key, vnode)
      keys.add(key)
      
      // LRU: 检查是否超过max限制
      if (props.max && keys.size > Number(props.max)) {
        const oldestKey = keys.values().next().value
        pruneCacheEntry(oldestKey)
      }
      
      // 标记为需要被 KeepAlive 的组件
      vnode.shapeFlag |= 1 << 12 // ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      
      current = vnode
      return vnode
    }
  }
})

原生 JS 模拟演示

为了更直观地理解 KeepAlive 的"DOM搬家"原理,这里提供一个原生 JS 的简单实现:

<div id="app"></div>
<button onclick="switchTab('home')">首页</button>
<button onclick="switchTab('profile')">个人</button>

<script>
  const cache = {}
  const container = document.getElementById('app')
  let currentTab = null

  function createHomePage() {
    const div = document.createElement('div')
    div.innerHTML = `
      <h3>首页</h3>
      <input placeholder="试试输入内容..." />
    `
    return div
  }

  function createProfilePage() {
    const div = document.createElement('div')
    div.innerHTML = `<h3>个人中心</h3><p>这是个人页</p>`
    return div
  }

  function switchTab(tab) {
    // 移除当前页面
    if (currentTab && cache[currentTab]) {
      container.removeChild(cache[currentTab])
      console.log(`[缓存] ${currentTab} 已暂停 (DOM移除)`)
    }

    // 加载新页面
    if (cache[tab]) {
      // 命中缓存,直接复用DOM
      container.appendChild(cache[tab])
      console.log(`[缓存] ${tab} 命中缓存,恢复DOM`)
    } else {
      // 首次创建
      const page = tab === 'home' ? createHomePage() : createProfilePage()
      cache[tab] = page
      container.appendChild(page)
      console.log(`[缓存] ${tab} 首次创建并缓存`)
    }
    
    currentTab = tab
  }
</script>

常见陷阱

陷阱1:组件名不匹配导致缓存失效

KeepAliveinclude/exclude 是根据组件的 name 选项来匹配的,而不是文件名或路径,因此必须显示地声明组件的 name

陷阱2:滥用缓存导致内存溢出

对于频繁切换且数量众多的组件,务必设置合理的 max 值,避免无限缓存。

陷阱3:WebSocket 等全局资源重复创建

// ❌ 错误:每次激活都新建连接
onActivated(() => {
  ws = new WebSocket('wss://...') // 重复创建
})

// ✅ 正确:全局单例 + 按需消费
const socketStore = useSocketStore() // Pinia 全局单例
onActivated(() => {
  socketStore.subscribe('chat')
})
onDeactivated(() => {
  socketStore.unsubscribe('chat')
})

清除缓存的几种方式

方法1:动态修改 include/exclude

const cachedComponents = ref(['ComponentA', 'ComponentB'])
const clearCache = () => {
  cachedComponents.value = []  // 清空 include,所有组件不再缓存
}

方法2:改变 key 强制重新渲染

const componentKey = ref(0)
const forceRerender = () => {
  componentKey.value++  // key 变化,组件重新创建
}

方法3:调用 unmount(不推荐)

const clearCache = (key) => {
  // 通过 ref 访问组件实例,调用 unmount
}

结语

KeepAlive 是 Vue 中提升性能的重要工具,它通过缓存组件实例,避免重复渲染。理解它的实现原理,不仅帮助我们更好地使用它,也能在遇到性能问题时找到合适的优化方案。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

JitWord 2.3: 墨定,行远

今天,我们宣布推出 JitWord AI文档 2.3版本。

图片

在持续两年的研究和技术难点攻克下,我们取得了如下成果:

  • 实现了高效的Word在线协同编辑能力
  • 实现了高效的Excel在线协同编辑能力
  • 实现了业内领先的Docx/PDF高精度导入导出能力
  • 实现了Office办公套件的嵌入和预览(Word,PDF, PPT, Excel)
  • 实现了国产化环境兼容适配(本地部署安全可靠)
  • 支持多端适配和编辑(PC,移动, IPad等设备)
  • 兼容市面上所有主流AI模型,并研发设计了AI Native组件,全面打造智慧办公场景
  • 多模态能力(图文/音视频/思维导图/图表/电子签名等)
  • 实现了高性能文档渲染引擎(支持50W字超大文档渲染,目前还在持续优化)
  • 实现了复杂的数学公式渲染引擎(支持导出为word可编辑的公式)

 当然我们的目标是全面对齐 Office,并基于AI Native 的设计理念,打造国产化的 AI Office 解决方案。同时我们是开源了一个基础版的SDK(v1.0版本),供大家直接本地调用:

图片我们的目标是为全球的科研人员、企业和组织赋能,帮助他们利用我们的前沿解决方案和AI能力构建安全可靠,符合企业自身需求的创新协同AI办公解决方案。

github地址: github.com/MrXujiang/j…

体演示地址:jitword.com

接下来我就和大家分享一下 JitWord 2.3 版本带来的新功能。

一、电子签名功能:数字化时代的"最后一公里"

去年我们团队在做用户调研时,遇到一个令人意外的场景。

某建筑设计公司的项目经理李哥向我们吐槽:"我们用你们的 jitword 写方案、改图纸备注都很爽,但每到签合同环节,就得全部打印出来,手写签字,再扫描回传。一套流程下来,半天没了,纸摞得比字典还厚。"

这番话让我们愣住了。

在 All-in-Digital 的今天,我们实现了云端协作、AI辅助写作、多人实时编辑,却在最原始的"确认"环节卡了壳。

电子签名这个看似简单的功能,成了文档数字化流程中的"断点"。

更让我们震惊的是数据:据我们抽样调研,73%的企业用户仍在使用"打印-签字-扫描"的传统模式处理合同和确认文件,平均每周浪费4.6小时在这类机械操作上。

这不是技术问题,这是体验设计的失职。

那一刻我们决定:JitWord 2.3 必须解决这个"最后一公里"问题。而且,不能做成简单的图片贴入,要让它真正可用、好用、让人愿意用

于是我们研发并上线了电子签名组件:

图片

大家可以在 jitword 编辑器的插入分类下使用电子签名,插入到文档的效果如下:

图片

我们在用户体验和界面设计上做了大量的优化,保证用户能以最好的体验使用这个功能。

大家可以在文档的任何位置插入电子签名,并且能一键导出为PDF和Docx文件,直接用于合同等场景的打印流程:

图片

二、分栏布局:回归文档的"阅读本质"

图片

如果说电子签名解决的是"出口"问题,那么分栏功能解决的就是"呈现"问题。

2.1 为什么传统文档编辑器"不好看"?

长期以来,Web文档编辑器有个通病:它们更像是"网页"而不是"文档"。单栏通顶的布局适合屏幕阅读,但一旦需要打印成册、制作手册、设计简报,就显得笨拙不堪。

我们观察到一个趋势:越来越多的用户把jitword当作轻量级排版工具使用。市场部门做产品手册,教研组编试卷,律师团队整理证据目录...他们不需要InDesign的专业,但Word的分栏功能又总让他们在Web和桌面软件之间来回切换。

2.2 技术实现:浏览器里的"排版引擎"

分栏功能看似简单,在Web技术栈里却是个硬骨头。

浏览器的流式布局天生是单栏的,要实现像LaTeX那样的专业分栏,需要重写文本流算法。我们团队花了两个月时间,基于CSS Columns规范做了深度定制:

  • 智能断栏:避免标题孤行、段落割裂,确保阅读连贯性
  • 图文混排:图片跨栏、文字环绕的像素级精准控制
  • 动态平衡:根据内容长度自动调整栏高,告别"最后一栏空荡荡"
  • 打印还原:屏幕所见即打印所得,解决Web文档打印走样的顽疾

特别值得一提的是分栏协同编辑的难点。当两个用户同时编辑不同分栏的内容时,光标定位、选区计算、冲突合并的复杂度呈指数级上升。

图片

我们重构了底层的 CRDT 算法,确保分栏场景下的协同体验与单栏一样流畅。

2.3 应用场景:从"能用"到"好用"的跃迁

现在,jitword 的分栏功能已经成为一些用户的"秘密武器":

  • 教育行业:老师用双栏排版制作试卷,左栏题干右栏答题区,直接导出印刷
  • 法律行业:律师用三栏整理证据清单,证据编号、内容摘要、页码索引一目了然
  • 市场营销:运营用混栏设计制作产品白皮书,图文穿插,专业度不输设计公司

三、表格多人协同:复杂数据的"共舞"方案

图片

3.1 被低估的协同场景

表格,是文档中最复杂的数据结构,也是协同编辑的"雷区"。

传统方案要么采用"锁定整表"的保守策略(一个人改,其他人看),要么"自由混战"(最后保存的人覆盖一切)。前者效率低下,后者数据灾难。

在 jitword 2.3中,我们实现了单元格级细粒度协同——这是技术架构上的重大突破。

3.2 技术架构:从"文档"到"数据"的视角转换

要实现真正的表格协同,必须改变底层思维:把表格不再视为"文档的一部分",而是视为嵌入式数据库

我们的技术方案包含三个层次:

第一,结构层解耦。  表格的每个单元格都是独立的数据对象,拥有唯一的CRDT(无冲突复制数据类型)标识。这意味着A用户在改A1单元格,B用户在改B2单元格,两者完全隔离,互不阻塞。

第二,冲突层智能。  当两人同时修改同一单元格时,系统不是简单"后覆盖前",而是基于语义合并策略:如果是数值,做算术合并;如果是文本,做差异对比;如果是公式,重新计算依赖链。冲突解决过程可视化呈现,用户可选择接受哪个版本或手动合并。

第三,感知层细腻。  我们设计了"单元格 occupancy"机制:当某人正在编辑某单元格,该单元格边缘会显示其头像呼吸灯,其他人点击时会收到友好提示"某某正在编辑,是否加入协作?"。这种"软阻塞"既避免了冲突,又保留了灵活性。

3.3 真实场景:一场没有"等等我"的会议

想象一下这个场景:周五下午,财务、销售、运营三个部门负责人要赶在下班前确认Q3预算表。以前,他们需要:

  1. 各自填好Excel分表
  2. 发给财务汇总
  3. 发现数据对不上,群里@来@去
  4. 修改,再发,再核对...
  5. 三小时后,终于搞定

现在,大家在 jitword 中打开同一张表格,各自在自己负责的栏目实时填写,公式自动计算,批注即时可见,有疑问直接@相关人在单元格内讨论。20分钟,预算表确认完毕,直接签名定稿。

这不是未来场景,这是 jitword 2.3 用户的日常。

四、价值重构:我们到底在做什么?

写到这里,我想停下来回答一个根本问题:jitword 2.3的这三个功能,到底创造了什么价值?

效率价值:时间的复利

电子签名节省的"打印-签字-扫描"流程,按每次30分钟、每周3次计算,一年就是78小时,相当于10个工作日;

分栏功能节省的软件切换和格式调整时间;

表格协同节省的汇总核对时间...这些碎片时间积累起来,是组织效率的复利增长。

体验价值:心流的守护

更重要的是认知成本的降低。当工具不再打断你的工作流——不需要为了签个字打开另一个系统,不需要为了排个版导出到另一个软件,不需要为了合个表发无数封邮件——你就能保持专注,进入心流状态。这种"不卡顿"的体验,是数字化办公的稀缺品。

信任价值:数字的确定性

电子签名的法律效力、协同编辑的版本可追溯、分栏排版的所见即所得,共同构建了一种数字确定性

在远程办公常态化的今天,这种确定性是团队协作的基石。我们知道谁在什么时候做了什么修改,我们知道这份文件被谁确认过,我们知道打印出来和屏幕上看到的一样——这些"知道",就是信任。

写在最后

我们团队的一个共识:最好的技术,是让人感受不到技术的存在,却能感受到人的温度。

电子签名的笔迹,是承诺的温度;分栏排版的精致,是专业的温度;表格协同的流畅,是协作的温度。

JitWord 2.3 不是功能的堆砌,是我们对"文档应该是什么样"的持续思考。在这个AI重构一切的时代,我们选择先做好人与文档、人与人之间的连接

如果大家也厌倦了工具的割裂、流程的繁琐、协作的摩擦,欢迎体验 jitword 。我们相信,好的工具,会让你重新爱上工作本身。


关于JitWord

JitWord 是面向企业的下一代协同AI文档平台,致力于让文档创作更智能、协作更流畅、知识更有序。

2.3版本现已全面上线,访问官网即可体验电子签名、分栏排版、表格协同等全新功能。

Vue Router与响应式系统的集成

在前面的文章中,我们深入学习了 Vue3 的响应式系统、组件渲染、生命周期等核心机制。今天,我们将探索 Vue Router 是如何与 Vue 的响应式系统无缝集成的。理解路由的实现原理,将帮助我们更好地处理页面导航、路由守卫等复杂场景。

前言:路由的核心挑战

Vue Router 作为 Vue 的官方路由管理器,其最精妙的设计之一就是与 Vue 响应式系统的无缝集成。Vue 作为单页应用(SPA),在路由管理中,面临的核心挑战是:在URL变化时,不刷新页面,而是动态切换组件: 路由的核心挑战 同时,也面临诸多问题:

  • 如何监听URL变化而不刷新页面?
  • 如何让路由变化触发组件重新渲染?
  • 如何管理路由历史?

Vue Router 响应式设计总览

响应式数据的核心

Vue Router 实现响应式导航的核心是:将当前路由状态(currentRoute)作为响应式数据。当路由发生变化时,依赖这个响应式数据的组件(如 router-view)会自动重新渲染:

// 简化的核心代码
const currentRoute = shallowRef(initialRoute);

整体架构

Vue Router 的响应式集成主要包含三个层次:

  • 数据层:currentRoute 响应式对象
  • 视图层:router-view 组件监听路由变化
  • 交互层:router-link 组件和编程式导航

currentRoute:路由响应式数据的实现

核心响应式设计

在 Vue Router 4 中,当前路由状态被设计为一个 shallowRef 响应式对象:

import { shallowRef } from 'vue'

function createRouter(options) {
  // 初始化路由状态
  const START_LOCATION_NORMALIZED = {
    path: '/',
    matched: [],
    meta: {},
    // ... 其他路由属性
  }
  
  // 核心响应式数据
  const currentRoute = shallowRef(START_LOCATION_NORMALIZED)
  
  const router = {
    // 暴露当前路由为只读属性
    get currentRoute() {
      return currentRoute.value
    },
    // ... 其他方法
  }
  
  return router
}

为什么使用 shallowRef 而不是 ref?因为路由对象结构较深,shallowRef 只代理 .value 的变更,内部属性变更不需要触发响应式,这样可以获得更好的性能。

路由响应式数据的使用

Vue Router 通过依赖注入将响应式路由数据提供给所有组件:

install(app) {
  // 注册路由实例
  app.provide(routerKey, router)
  app.provide(routeLocationKey, reactive(this.currentRoute))
  
  // 注册全局组件
  app.component('RouterLink', RouterLink)
  app.component('RouterView', RouterView)
  
  // 在原型上挂载 $router 和 $route
  app.config.globalProperties.$router = router
  app.config.globalProperties.$route = reactive(this.currentRoute)
}

这样,我们在组件中就可以通过 $route 或 useRoute() 访问响应式路由数据:

<script setup>
import { useRoute } from 'vue-router'

// 返回一个响应式对象,当路由变化时会自动更新
const route = useRoute()

console.log(route.path) // 当前路径
console.log(route.params) // 路由参数
</script>

<template>
  <div>当前路径: {{ $route.path }}</div>
</template>

路由变化时如何触发更新

当路由发生变化时,Vue Router 会更新 currentRoute.value,从而触发所有依赖的重新渲染:

// 路由导航的核心逻辑
async function navigate(to, from) {
  // ... 执行导航守卫、解析组件等
  
  // 更新当前路由(触发响应式更新)
  currentRoute.value = to
  
  // 调用 afterEach 钩子
  callAfterEachGuards(to, from)
}

router-view 组件的渲染原理

router-view 的作用

router-view 是一个函数式组件,它的核心职责是:根据当前路由的匹配结果,渲染对应的组件:

<template>
  <div id="app">
    <!-- 路由匹配的组件会在这里渲染 -->
    <router-view></router-view>
  </div>
</template>

router-view 的源码实现

const RouterView = defineComponent({
  name: 'RouterView',
  setup(props, { attrs, slots }) {
    // 注入路由实例和当前路由
    const injectedRoute = inject(routeLocationKey)
    const router = inject(routerKey)
    
    // 获取深度(用于嵌套路由)
    const depth = inject(viewDepthKey, 0)
    const matchedRouteRef = computed(() => {
      // 获取当前深度对应的匹配记录
      const matched = injectedRoute.matched[depth]
      return matched
    })
    
    // 提供下一层的 depth
    provide(viewDepthKey, depth + 1)
    
    return () => {
      const match = matchedRouteRef.value
      const component = match?.components?.default
      
      if (!component) {
        return slots.default?.() || null
      }
      
      // 渲染匹配到的组件
      return h(component, {
        ...attrs,
        ref: match.instances?.default,
      })
    }
  }
})

嵌套路由的处理

router-view 通过 depth 参数支持嵌套路由:

<template>
  <div>
    <h1>用户中心</h1>
    <!-- 默认 depth = 1,会渲染子路由组件 -->
    <router-view></router-view>
  </div>
</template>

每个嵌套的 router-view 都会通过 provide/inject 获得递增的深度值,从而从 matched 数组中取出对应的组件记录。

路由钩子的实现机制

钩子函数分类

Vue Router 提供了三类导航守卫:

  • 全局守卫:beforeEach、beforeResolve、afterEach
  • 路由独享守卫:beforeEnter
  • 组件内守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave

钩子执行流程源码简析

async function navigate(to, from) {
  const guards = []
 
  // 收集所有需要执行的守卫,并按顺序执行
  // 1. 执行 beforeRouteLeave(从最深的路由记录开始)
  const leaveGuards = extractLeaveGuards(from.matched)
  guards.push(...leaveGuards)
  
  // 2. 执行全局 beforeEach
  guards.push(router.beforeEachGuards)
  
  // 3. 执行 beforeRouteUpdate(如果组件复用)
  const updateGuards = extractUpdateGuards(from.matched, to.matched)
  guards.push(...updateGuards)
  
  // 4. 执行路由配置的 beforeEnter
  const enterGuards = extractEnterGuards(to.matched)
  guards.push(...enterGuards)
  
  // 5. 执行全局 beforeResolve
  guards.push(router.beforeResolveGuards)
  
  // 串行执行所有守卫
  for (const guard of guards) {
    const result = await guard(to, from)
    // 如果守卫返回 false 或重定向路径,中断导航
    if (result === false || typeof result === 'string') {
      return result
    }
  }
  
  // 6. 执行全局 afterEach(不阻塞导航)
  callAfterEachGuards(to, from)
}

组件内守卫的实现

组件内守卫通过 Vue 的生命周期钩子集成:

// 组件内守卫的注册
export default {
  beforeRouteEnter(to, from, next) {
    // 在渲染前调用,不能访问 this
    // 可以通过 next 回调访问组件实例
    next(vm => {
      // 通过 `vm` 访问组件实例
    })
  },
  beforeRouteUpdate(to, from, next) {
    // 路由改变但组件复用时调用
    // 可以访问 this
  },
  beforeRouteLeave(to, from, next) {
    // 离开路由时调用
    // 可以访问 this
  }
}

Hash模式 vs History模式

两种模式的本质区别

Vue Router 支持两种路由模式:

模式 创建方式 URL格式 服务器配置 原理
Hash createWebHashHistory() /#/home 不需要 监听 hashchange 事件 + pushState
History createWebHistory() /home 需要 HTML5 History API

Hash模式的实现

// hash.js - Hash模式实现
function createWebHashHistory(base = '') {
  // Hash模式本质上是在 History 模式基础上加了 '#' 前缀
  return createWebHistory(base ? base : '#')
}

// 处理 Hash 路径
function getHashLocation() {
  const hash = window.location.hash.slice(1) // 去掉开头的 '#'
  return hash || '/' // 空 hash 返回根路径
}

// 监听 hash 变化
window.addEventListener('hashchange', () => {
  const to = getHashLocation()
  // 更新路由状态
  changeLocation(to)
})

注:在 Vue Router 4 中,Hash 模式也统一使用 History API 进行导航,hashchange 仅作为兜底监听。

History模式的实现

// html5.js - History模式实现
function createWebHistory(base = '') {
  // 创建状态管理器
  const historyState = useHistoryState()
  const currentLocation = ref(createCurrentLocation(base))
  
  // 监听 popstate 事件
  window.addEventListener('popstate', (event) => {
    const to = createCurrentLocation(base)
    currentLocation.value = to
    // 触发路由更新
  })
  
  function push(to) {
    // 调用 history.pushState
    window.history.pushState({}, '', to)
    currentLocation.value = to
  }
  
  function replace(to) {
    window.history.replaceState({}, '', to)
    currentLocation.value = to
  }
  
  return {
    location: currentLocation,
    push,
    replace
  }
}

History模式的服务器配置

History 模式需要服务器配置支持,否则刷新页面会 404。Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

createRouter核心逻辑源码简析

createRouter的整体结构

function createRouter(options) {
  // 1. 创建路由匹配器
  const matcher = createRouterMatcher(options.routes)
  
  // 2. 创建响应式路由状态
  const currentRoute = shallowRef(START_LOCATION)
  
  // 3. 根据模式创建 history 实例
  const history = options.history
  
  // 4. 定义路由方法
  const router = {
    // 响应式路由
    currentRoute,
    
    // 导航方法
    push(to) {
      return pushWithRedirect(to)
    },
    
    replace(to) {
      return push(to, true)
    },
    
    // 后退
    back() {
      history.go(-1)
    },
    
    // 前进
    forward() {
      history.go(1)
    },
    
    // 插件安装方法
    install(app) {
      // 提供路由实例
      app.provide(routerKey, router)
      app.provide(routeLocationKey, reactive(currentRoute))
      
      // 注册全局组件
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)
      
      // 挂载到全局属性
      app.config.globalProperties.$router = router
      app.config.globalProperties.$route = reactive(currentRoute)
      
      // 初始化路由
      if (currentRoute.value === START_LOCATION) {
        // 解析初始路径
        history.replace(history.location)
      }
    }
  }
  
  return router
}

createRouterMatcher的实现

路由匹配器负责将配置的路由表拍平,建立父子关系:

function createRouterMatcher(routes) {
  const matchers = []
  
  // 递归添加路由记录
  function addRoute(record, parent) {
    // 标准化路由记录
    const normalizedRecord = normalizeRouteRecord(record)
    
    // 创建匹配器
    const matcher = createRouteRecordMatcher(normalizedRecord, parent)
    
    // 处理子路由
    if (normalizedRecord.children) {
      for (const child of normalizedRecord.children) {
        addRoute(child, matcher)
      }
    }
    
    matchers.push(matcher)
  }
  
  // 初始化所有路由
  routes.forEach(route => addRoute(route))
  
  // 解析路径,返回匹配的路由记录
  function resolve(location) {
    const matched = []
    let path = location.path
    
    // 找到匹配的 matcher
    for (const matcher of matchers) {
      if (path.startsWith(matcher.path)) {
        matched.push(matcher.record)
      }
    }
    
    return {
      path,
      matched
    }
  }
  
  return {
    addRoute,
    resolve
  }
}

手写简易路由实现

// 简易路由实现
import { ref, shallowRef, reactive, computed, provide, inject } from 'vue'

const ROUTER_KEY = '__router__'
const ROUTE_KEY = '__route__'

// 创建路由
function createRouter(options) {
  // 1. 创建匹配器
  const matcher = createMatcher(options.routes)
  
  // 2. 响应式路由状态
  const currentRoute = shallowRef({
    path: '/',
    matched: []
  })
  
  // 3. 处理历史模式
  const history = options.history
  
  // 4. 监听 popstate
  window.addEventListener('popstate', () => {
    const path = window.location.pathname
    const matched = matcher.match(path)
    currentRoute.value = { path, matched }
  })
  
  // 5. 导航方法
  function push(path) {
    window.history.pushState({}, '', path)
    const matched = matcher.match(path)
    currentRoute.value = { path, matched }
  }
  
  const router = {
    currentRoute,
    push,
    install(app) {
      app.provide(ROUTER_KEY, router)
      app.provide(ROUTE_KEY, reactive(currentRoute))
      
      app.component('RouterLink', {
        props: { to: String },
        setup(props, { slots }) {
          const router = inject(ROUTER_KEY)
          return () => (
            h('a', {
              href: props.to,
              onClick: (e) => {
                e.preventDefault()
                router.push(props.to)
              }
            }, slots.default?.())
          )
        }
      })
      
      app.component('RouterView', {
        setup() {
          const route = inject(ROUTE_KEY)
          const depth = inject('depth', 0)
          provide('depth', depth + 1)
          
          return () => {
            const component = route.value.matched[depth]?.component
            return component ? h(component) : null
          }
        }
      })
    }
  }
  
  return router
}

// 简易匹配器
function createMatcher(routes) {
  const records = []
  
  function normalize(route, parent) {
    const record = {
      path: parent ? parent.path + route.path : route.path,
      component: route.component,
      parent
    }
    
    records.push(record)
    
    if (route.children) {
      route.children.forEach(child => normalize(child, record))
    }
  }
  
  routes.forEach(route => normalize(route))
  
  return {
    match(path) {
      return records.filter(record => path.startsWith(record.path))
    }
  }
}

性能优化与最佳实践

路由懒加载

const routes = [
  {
    path: '/dashboard',
    // 使用动态导入实现懒加载
    component: () => import('./views/Dashboard.vue')
  }
]

避免不必要的响应式开销

// 如果只需要一次性值,可以不用解构
const route = useRoute()
// ❌ 避免:每次路由变化都会重新计算
const id = computed(() => route.params.id)

// ✅ 推荐:直接在需要的地方使用
watch(() => route.params.id, (newId) => {
  // 只在变化时执行
})

路由守卫的最佳实践

// 全局前置守卫:适合做权限验证
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isLoggedIn()) {
    next('/login')
  } else {
    next()
  }
})

// 组件内守卫:适合做数据预加载
beforeRouteEnter(to, from, next) {
  fetchData(to.params.id).then(data => {
    next(vm => vm.data = data)
  })
}

结语

Vue Router 与响应式系统的集成是 Vue 生态中最精妙的设计之一,理解这些原理不仅帮助我们更好地使用 Vue Router,也为处理复杂路由场景(如权限控制、动态路由、嵌套路由等)提供了理论基础。在实际开发中,合理利用路由响应式特性和导航守卫,可以构建出既高效又易维护的单页应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue 3 性能优化的 5 个隐藏技巧,第 4 个连老手都未必知道

上周,我们上线了一个数据看板页面,本地跑得飞快,一上生产——滚动卡成 PPT

Profiler 一抓,发现:

  • 每次滚动都在重复创建 computed 函数
  • 列表项里嵌套了 3 层 <Suspense>
  • 一个 watch 竟然监听了整个 reactive 对象……

问题不在逻辑,而在 “你以为没问题的写法”

今天,我就分享 5 个 Vue 3 中少有人提、但效果惊人的性能优化技巧,尤其第 4 个,连很多 5 年经验的老手都没用过。


技巧 1:别在模板里写“方法调用”,用 computed + 缓存

反面教材:

<template>
  <div>{{ formatUserName(user) }}</div> <!-- 每次渲染都执行! -->
</template>

<script setup>
const formatUserName = (user) => `${user.firstName} ${user.lastName}`;
</script>

正确做法:

const formattedName = computed(() => 
  `${user.value.firstName} ${user.value.lastName}`
);
<template>
  <div>{{ formattedName }}</div> <!-- 响应式缓存,依赖不变不重算 -->
</template>

关键点:模板中的函数调用 没有缓存,每次 re-render 都会执行!


技巧 2:v-for 里的组件,记得加 key —— 但别用 index

很多人知道要加 key,但随手写:

<div v-for="(item, index) in list" :key="index">
  <ItemCard :data="item" />
</div>

问题:当列表发生插入/删除时,index 会变,导致 Vue 错误复用组件实例,引发状态错乱 or 不必要的销毁重建。

正确做法:用唯一 ID

<div v-for="item in list" :key="item.id">
  <ItemCard :data="item" />
</div>

如果真没 ID?考虑用 Symbol()crypto.randomUUID() 生成稳定 key(仅限静态列表)。


技巧 3:慎用 watch 监听整个 reactive 对象

const state = reactive({ a: 1, b: 2, c: 3 });

watch(state, () => {
  console.log('state changed');
});

这会导致:只要 abc 任意一个变了,回调就触发,即使你只关心 a

更精准的写法:

// 方案 A:监听具体属性
watch(() => state.a, (newVal) => { ... });

// 方案 B:用 toRefs 解构后监听
const { a } = toRefs(state);
watch(a, (newVal) => { ... });

高级技巧:如果必须监听多个字段,用 getter 函数组合:

watch(
  () => ({ a: state.a, b: state.b }),
  (newVals) => { /* 只有 a 或 b 变才触发 */ }
);

技巧 4:用 shallowRefmarkRaw 跳过不必要的响应式(隐藏大招!)

这是 Vue 3 响应式系统中最被低估的 API

场景:你有一个大型配置对象 or 第三方库实例(如 echarts 实例),不需要响应式?

默认写法(性能杀手):

const chart = ref(null); // Vue 会尝试把 echarts 实例变成响应式!
onMounted(() => {
  chart.value = echarts.init(dom); // 内部 thousands of properties!
});

正确做法:

// 方案 A:用 shallowRef(只让 .value 响应,内部不递归)
const chart = shallowRef(null);

// 方案 B:用 markRaw 明确告诉 Vue “别动它”
const chartInstance = markRaw(echarts.init(dom));
const chart = ref(chartInstance);

效果:避免 Vue 递归遍历大型对象,节省内存 + 提升初始化速度 10x+

适用场景:

  • 图表实例(ECharts、Chart.js)
  • 复杂配置对象(如 Monaco Editor options)
  • 不变的数据结构(如路由 meta、常量字典)

技巧 5:懒加载组件 + 异步 setup,减少首屏负担

别让所有组件都在首屏加载!

<!-- 同步引入,打包进主 chunk -->
<script setup>
import HeavyChart from './HeavyChart.vue';
</script>

改成动态导入 + Suspense:

<template>
  <Suspense>
    <template #default>
      <LazyChart />
    </template>
    <template #fallback>
      <div>Loading chart...</div>
    </template>
  </Suspense>
</template>

<script setup>
// 自动代码分割
const LazyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
</script>

进阶:配合 IntersectionObserver 实现滚动到可视区再加载

const isVisible = ref(false);
// 当元素进入视口,isVisible = true → 再加载组件

总结:5 个技巧速查表

技巧 适用场景 性能收益
模板中用 computed 代替方法调用 频繁渲染的格式化逻辑 避免重复计算
v-for 用唯一 ID 做 key 动态列表(增删改) 减少 DOM 重建
精准 watch 而非监听整个对象 复杂状态管理 避免无效回调
shallowRef / markRaw 跳过响应式 大型对象、第三方实例 内存 & 初始化提速
异步组件 + Suspense 重型组件(图表、编辑器) 首屏加载更快

最后说两句

Vue 3 的性能,80% 取决于你如何使用响应式系统,而不是框架本身慢。

真正的优化,不是“加缓存”“开 SSR”,而是:

在正确的地方,用正确的 API,做最小化的响应式。

下次写组件前,先问自己:

“这个数据,真的需要响应式吗?”


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

拿来就用!Vue3+Cesium 飞入效果封装,3D大屏多场景直接复用

最近有点儿事儿,之前的大屏项目拖了一段时间,现在打算继续开发。原本以为用熟悉的Cesium能快速搞定,没想到还是踩了几个坑,整理出来和大家分享,避免后续有人走同样的弯路。

页面地球飞入效果采用 Cesium 进行开发,一来 Cesium 作为开源的3D地理信息可视化框架,API 封装完善,开发效率高。

二来个人长期使用该框架,对其核心逻辑比较熟悉,本以为能快速落地,实际开发中却遇到了加载、时机监听、多场景复用等多个问题,逐一排查解决后,才实现了流畅的飞入效果。

实现效果

Video Project 2.gif

Cesium 地球初始化完成后,自动触发指定地点的飞入动画,相机从初始视角平滑过渡到目标经纬度对应的视角,过程流畅无卡顿、无图层闪烁。

核心问题

简单的飞入效果之前都是使用的现成的方法,从零开始遇到点问题。

实际开发中需要兼顾加载性能、时机准确性、多场景复用等,具体问题如下:

  • Cesium 加载速度问题:在线地图服务加载延迟高,弱网环境下易报错,影响用户体验;
  • Cesium 加载完成时机的监听:若监听时机不准确,会导致飞入动画触发时,地图图层、影像未加载完成,出现“空地球”或“图层闪烁”问题;
  • 多地点复用问题:大屏项目中可能需要切换多个目标地点,需对飞入逻辑进行封装,实现灵活调用。

解决方案(附完整代码+细节说明)

加载速度优化:解决在线地图加载慢的问题

我最初采用的是 Cesium 官方的 Ion 在线地图服务,毕竟无需额外配置,直接调用即可,但实际测试后发现两个致命问题:

  1. 加载延迟高:官方服务器位于海外,国内网络环境下,地图影像加载速度极慢,甚至需要10秒以上才能完全渲染,远超用户6~8秒的等待极限;

  2. 稳定性差:弱网环境下,地图会直接加载失败,控制台报错“影像图层加载超时”,导致页面无法正常展示。

在线地图服务受网络环境影响极大,若要实现生产环境的稳定运行,优先替换为本地地图服务(如天地图本地部署、GeoServer 发布的本地影像),从根源上解决加载慢、报错的问题。

由于手头暂无本地地图部署工具,本次开发暂用在线地图过渡,后续会替换为本地服务。

以下是优化后的初始化代码,增加了加载超时处理,提升弱网环境下的容错性:

// 导入 Cesium 核心模块
import * as Cesium from 'cesium'
// 引入 Cesium 样式(必须,否则控件和地球样式异常)
import 'cesium/Build/Cesium/Widgets/widgets.css'

// 初始化 Cesium 地球(增加超时处理,优化加载体验)
const initCesium = async () => {
    try {
        // 配置 Cesium Token(可从 Cesium 官网免费申请,需注册账号)
        Cesium.Ion.defaultAccessToken = '你的官网Token';

        // 创建 Cesium 视图实例,精简界面控件,提升加载速度
        viewer.value = new Cesium.Viewer('cesiumContainer', {
            // 隐藏默认控件,适配大屏简洁风格
            timeline: false, // 时间轴控件
            animation: false, // 动画控件
            baseLayerPicker: false, // 底图切换控件
            geocoder: false, // 地理编码控件(搜索地点)
            homeButton: false, // 首页按钮
            infoBox: false, // 信息弹窗(点击要素时显示)
            sceneModePicker: false, // 场景模式切换(2D/3D/哥伦布视图)
            navigationHelpButton: false, // 导航帮助按钮
            // 性能优化配置
            scene3DOnly: true, // 仅开启3D模式,减少2D渲染开销
            requestRenderMode: true, // 开启请求渲染模式,降低CPU占用
            maximumRenderTimeChange: 1 / 60, // 控制渲染帧率,避免卡顿
            // 开启地形(如果不需要地形展示,可注释,进一步提升加载速度)
            // terrainProvider: Cesium.createWorldTerrain()
        });

        // 隐藏 Cesium 底部版权信息(可选,根据项目需求调整)
        viewer.value._cesiumWidget._creditContainer.style.display = 'none';

        // 等待 Cesium 完全加载完成(包括影像图层、场景渲染)
        await waitForCesiumFullyLoaded();
        
        // 触发 cesiumReady 事件,通知外部执行飞入等后续操作
        emit('cesiumReady', viewer.value);
    } catch (error) {
        console.error('Cesium 初始化失败:', error);
        // 加载失败提示,提升用户体验
        ElMessage.error('地球加载失败,请检查网络或刷新页面重试');
    }
}

加载时机监听

这是本次开发最容易踩坑的点,在创建 viewer 实例后,直接触发飞入动画,导致动画执行时,地图影像还未加载完成,出现“相机飞向空地球”的尴尬场景。

Cesium 初始化是异步过程,创建 viewer 实例只是第一步,后续还需要加载影像图层、渲染场景、初始化相机等操作,这些操作完成后,才能确保飞入动画的流畅性。

封装两个异步方法,分别监听场景渲染就绪影像图层加载完成,只有两个条件都满足,才触发后续的飞入操作,确保时机精准。

代码如下:

/**
 * 等待 Cesium 完全加载完成(包括场景渲染和影像图层)
 * 核心逻辑:先确保场景渲染就绪,再等待影像图层加载完成,双重校验
 * @returns {Promise}
 */
const waitForCesiumFullyLoaded = () => {
    return new Promise((resolve) => {
        const checkSceneReady = () => {
            // 先检查 viewer 和 scene 是否存在(避免初始化未完成时调用)
            if (!viewer.value || !viewer.value.scene) {
                // 每50ms检查一次,避免频繁占用资源
                setTimeout(checkSceneReady, 50);
                return;
            }
            
            // 使用 postRender 事件,确保场景至少完成一帧渲染
            viewer.value.scene.postRender.addEventListener(() => {
                // 场景就绪后,再等待影像图层加载完成
                waitForImageryLoaded().then(resolve);
            }, viewer.value.scene);
        };
        checkSceneReady();
    });
}

/**
 * 等待影像图层加载完成(单独封装,便于后续扩展)
 * 核心逻辑:遍历所有影像图层,检查是否有正在加载的图块
 * @returns {Promise}
 */
const waitForImageryLoaded = () => {
    return new Promise((resolve) => {
        // 若 viewer 或 scene 不存在,直接resolve(容错处理)
        if (!viewer.value || !viewer.value.scene) {
            resolve();
            return;
        }

        const imageryLayers = viewer.value.imageryLayers;
        // 若没有影像图层,直接resolve
        if (!imageryLayers || imageryLayers.length === 0) {
            resolve();
            return;
        }

        // 循环检查所有影像图层是否加载完成
        const checkLoaded = () => {
            let allLoaded = true;
            
            for (let i = 0; i < imageryLayers.length; i++) {
                const layer = imageryLayers.get(i);
                if (layer && layer.imageryProvider) {
                    // 检查当前图层是否有正在加载的图块(_loading 为Cesium内部属性)
                    if (layer._loading) {
                        allLoaded = false;
                        break;
                    }
                }
            }

            if (allLoaded) {
                // 确保影像加载完成后,场景再渲染一帧,避免闪烁
                viewer.value.scene.postRender.addEventListener(() => {
                    resolve();
                }, viewer.value.scene);
            } else {
                // 每100ms检查一次,平衡性能和准确性
                setTimeout(checkLoaded, 100);
            }
        };

        checkLoaded();
    });
}

关键注意点:将两个方法拆分开写,是为了后续扩展——比如项目中需要添加3D模型、矢量数据加载。

可直接在 waitForCesiumFullyLoaded 方法中添加对应的等待逻辑,无需大幅修改代码,提升可维护性。

封装飞入方法

大屏项目中,往往需要切换多个目标地点(如从全国视角飞入各省、从省视角飞入各市),若每次切换都重复编写代码冗余。

因此,简单封装一个通用的飞入方法。

/**
 * 控制 Cesium 相机飞往指定目标地点(通用封装,支持多场景复用)
 * @param {Object} options - 飞行配置项(必传参数标注,可选参数有默认值)
 * @param {Number} options.longitude - 目标经度(必传,如北京:116.4074)
 * @param {Number} options.latitude - 目标纬度(必传,如北京:39.9042)
 * @param {Number} options.height - 目标高度 (米,必传,根据场景调整,如大屏常用5000米)
 * @param {Number} [options.duration=3] - 飞行时长 (秒,可选,默认3秒,兼顾流畅度和效率)
 * @param {Number} [options.heading=0] - 相机朝向 (角度,可选,0 为正北,可根据需求调整)
 * @param {Number} [options.pitch=-60] - 俯仰角 (角度,可选,-90 为垂直向下,-60为常用视角)
 * @param {Function} [options.onComplete] - 飞行完成回调(可选,如飞行结束后加载区域数据)
 * @param {Function} [options.onCancel] - 飞行取消回调(可选,如用户手动中断飞行时的处理)
 */
const flyToLocation = async (options) => {
    // 校验 viewer 实例是否存在,避免报错
    if (!viewer.value) {
        console.warn('Viewer 实例不存在,无法执行飞行操作');
        ElMessage.warning('地球未加载完成,无法执行飞入操作');
        return;
    }

    // 解构配置项,设置默认值
    const {
        longitude,
        latitude,
        height,
        duration = 3,
        heading = 0,
        pitch = -60,
        onComplete,
        onCancel
    } = options

    // 由于 cesiumReady 触发时已确保影像加载完成,这里直接执行飞行
    viewer.value.camera.flyTo({
        // 将经纬度、高度转换为 Cesium 支持的笛卡尔坐标系
        destination: Cesium.Cartesian3.fromDegrees(longitude, latitude, height),
        // 相机朝向配置(heading:方位角,pitch:俯仰角,roll:翻滚角)
        orientation: {
            heading: Cesium.Math.toRadians(heading), // 角度转弧度(Cesium 内部使用弧度)
            pitch: Cesium.Math.toRadians(pitch),
            roll: 0.0 // 翻滚角,默认0,无需调整
        },
        duration: duration, // 飞行时长
        complete: () => {
            console.log('已飞到目标地点!');
            // 执行完成回调(若有)
            if (onComplete) onComplete();
        },
        cancel: () => {
            console.log('飞行被取消!');
            // 执行取消回调(若有)
            if (onCancel) onCancel();
        },
        canInterrupt: true // 允许用户手动中断飞行(如鼠标拖拽相机)
    })
}

注意:项目使用 Vue3 + setup 语法,需通过 defineExposeflyToLocation 方法导出,外部组件才能调用。

总结

Cesium 作为成熟的3D地理可视化框架,本身的 API 封装已经非常完善,实现飞入效果的核心逻辑并不复杂。

但实际开发中,往往是细节问题导致踩坑,总结几点关键经验,供大家参考:

  1. 加载优化优先选本地地图:生产环境中,务必替换掉官方在线地图,改用本地部署的地图服务(天地图、高德地图本地切片等),彻底解决加载慢、报错的问题;

  2. 加载时机监听不能省:不要省略 waitForCesiumFullyLoaded 方法,否则会出现图层闪烁、空地球等问题,拆分方法便于后续扩展;

  3. 封装逻辑提升复用性:多地点切换场景,一定要封装通用的飞入方法,明确配置项的必传/可选,增加容错处理,减少代码冗余;

  4. 内存管理要注意:页面卸载时,务必销毁 Cesium 实例(包括 viewer事件监听等),避免内存泄漏,导致页面卡顿、崩溃,销毁代码示例如下:

// 页面卸载时销毁 Cesium 实例(Vue3 onUnmounted 中调用)
onUnmounted(() => {
    if (viewer.value) {
        // 销毁 viewer 实例,释放内存
        viewer.value.destroy();
        viewer.value = null;
    }
});

最后,Cesium 的坑大多集中在“加载时机”和“性能优化”上,只要理清初始化流程、做好细节校验,就能快速实现流畅的交互效果。

后续我会继续更新这个大屏项目中 Cesium 的其他坑点,欢迎大家留言交流,共勉!

pxcharts Ultra V2.3更新:多维表一键导出 PDF,渲染兼容性拉满!

最近粉丝咨询最多的问题莫过于 pxcharts 多维表是否能导出PDF的能力了。

图片

说实话,我回避了很久。浏览器打印引擎差异大,中文渲染、分页断行、复杂表格适配...每个都是坑。

直到上个月,一个做财务的朋友跟我吐槽:月底导报表,调格式调到凌晨2点。我决定,这功能必须上。

于是在1周的设计和研究下,终于实现了多维表导出PDF的功能。

演示如下:

图片

导出后的PDF文件预览效果:

图片

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

接下来和大家分享一下详细的功能技术实现。

Pxcharts多维表导出PDF功能技术实现

支持将表格数据导出为 PDF 格式,便于用户打印、存档和分享,核心需求包括:

  • 保持表格结构和样式
  • 支持分页(避免行被截断)
  • 支持封面页(统计信息)
  • 状态标签着色
  • 横向/纵向布局可选

技术选型

为了实现这个方案,我们的核心依赖如下:

依赖 版本 用途
jspdf latest 生成 PDF 文件
html2canvas latest 将 HTML 渲染为 Canvas 图像

选型理由

为什么选择 html2canvas + jsPDF?原因如下:

  1. 纯前端实现无需后端服务,保护数据隐私
  2. 样式可控通过 CSS 精确控制 PDF 外观
  3. 兼容性好支持现代浏览器
  4. 生态成熟社区活跃,文档完善

为什么不直接用 jsPDF 的表格 API?

  • jsPDF 的 autoTable 插件对复杂样式支持有限
  • 自定义样式(状态标签着色、交替行背景)实现困难
  • html2canvas 可以复用现有的 HTML/CSS 样式

实现架构

整体流程我这里设计如下:

表格数据
    ↓
生成 HTML(按页)
    ↓
html2canvas 渲染为 Canvas
    ↓
Canvas 转 PNG 图像
    ↓
jsPDF 写入 PDF(每页一张图)
    ↓
下载 PDF 文件

分页策略

关键问题:如何避免表格行在分页时被截断?

我的解决方案:按行预分页

  1. 估算每行高度(约 36px)
  2. 计算每页可容纳行数:rowsPerPage = floor((pageHeight - headerHeight) / rowHeight)
  3. 按行数切分数据,每页独立渲染
  4. 每页都包含表头,方便阅读
const estimateRowHeight36// 每行大约 36px
const headerHeight60// 表头高度
const pageContentHeightPx = Math.round(contentHeight / scale)
const rowsPerPage = Math.floor((pageContentHeightPx - headerHeight) / estimateRowHeight)

// 分页
for (let i0; i < records.length; i += rowsPerPage) {
const pageRecords = records.slice(i, i + rowsPerPage)
  pages.push(renderDataPage(pageRecords, i))
}

核心代码解析

1. 动态导入(SSR 兼容):

const [{ default: jsPDF }, { default: html2canvas }] = awaitPromise.all([
import("jspdf"),
import("html2canvas"),
])

原因jspdf 和 html2canvas 依赖浏览器 API(如 documentwindow),在 Next.js SSR 阶段会报错。使用动态导入确保只在客户端执行。

2. 页面尺寸计算:

const pageDimensions = {
a4: { width: 595, height: 842 },  // pt 单位
a3: { width: 842, height: 1191 },
}

const pdfWidth = orientation === "landscape"
  ? pageDimensions[pageSize].height
  : pageDimensions[pageSize].width

注意:jsPDF 使用 pt(点)作为单位,1pt = 1/72 英寸。

3. HTML 生成

数据页结构这里我预设如下

<divstyle="width:1122px;padding:32px;box-sizing:border-box;background:#fff">
<tablestyle="width:100%;border-collapse:collapse">
<thead><!-- 表头 --></thead>
<tbody><!-- 数据行 --></tbody>
</table>
</div>

关键样式

  • width:1122px固定 canvas 宽度(A4 横向像素)
  • border-collapse:collapse合并表格边框
  • white-space:nowrap防止文本换行

4. Canvas 渲染

const canvasawaithtml2canvas(element, {
scale2,              // 2倍缩放,提高清晰度
useCORStrue,         // 允许跨域图片
allowTainttrue,      // 允许污染 canvas
backgroundColor"#ffffff",
loggingfalse,
})

参数说明

参数 说明
scale: 2 2倍分辨率,PDF 更清晰
useCORS 处理跨域图片(如附件预览图)
allowTaint 允许 canvas 被污染(某些图片需要)

5. PDF 写入

const imgData = canvas.toDataURL("image/png"1.0)
const imgWidth = contentWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width

pdf.addImage(imgData, "PNG", margin, margin, imgWidth, imgHeight)

图像格式选择

  • PNG无损,清晰度高,适合文字
  • JPEG有损压缩,文件小,但不适合文字

样式处理技巧

状态标签着色这里我做了一层数据映射,方便精准还原样式:

constcolorMap: Record<stringstring> = {
"已完成""#dcfce7;color:#16a34a",
"进行中""#dbeafe;color:#2563eb",
"待开始""#fef3c7;color:#d97706",
"已停滞""#f3f4f6;color:#6b7280",
"重要紧急""#fee2e2;color:#dc2626",
}

交替行背景我采用的逻辑判断来动态渲染:

<tr style="background:${idx % 2 === 0 ? "#fff" : "#f8fafc"}">

如果文本出现截断换行,用canvas很难处理,这里我采用如下方案截断处理:

// 方案1:省略号截断(适合固定宽度列)
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">

// 方案2:完全显示(适合自动宽度列)
<spanstyle="white-space:nowrap">

当然还有很多细节的处理,这里就不一一介绍了。我们可以基于这个方案,继续扩展出如下场景:

  1. 水印支持添加企业 Logo 或水印
  2. 页码在页脚添加 "第 X 页 / 共 Y 页"
  3. 图表嵌入将图表大屏的图表嵌入 PDF
  4. 批量导出支持同时导出多个表格

今天就分享到这,后续我们还会持续迭代和更新,打造最强大的多维表格和文档协同系统。

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

如何实现一个「万能」的通用打印组件?

在我们组开发的业务系统中,存在文书种类多、格式不一的场景,但又要求保持一致的打印体验,怎么办呢?难道每次加一种新文书就写一套打印逻辑?不存在的。用「配置 + 动态模板 + iframe 打印」的思路,可以搭出一套一个组件打天下的通用打印方案。


一、先想清楚:我们要解决什么问题?

  • 多种文书:不同业务对应不同的文书模板,字段、布局、样式都不一样。
  • 统一入口:希望小伙伴调用时只关心「打开打印、传文书类型和业务单号」,不用关心具体模板和接口。
  • 可编辑再打:部分文书需要在预览里编辑或填充后再打印,而不是纯静态展示。
  • 打印体验:要能控制打印样式(页眉页脚、分页、字体),并且不把整页 UI 一起打出去。

二、整体架构:三层拆解

可以把通用打印拆成三层,逻辑会非常清晰:

  1. 主组件:包含组件状态提示、调用 iframe 执行打印等功能;
  2. 配置层:文书类型与文书模版要一一对应;
  3. 模板层:每种文书一个 Vue 模板组件,负责展示、编辑字段,同时提供方法给壳层拿去保存/打印。

三、配置层:文书类型与模板的映射

用一份配置集中维护,后续扩展新文书主要就是:加一条配置 + 加一个模板组件。

export const DOC_TYPE = {
  FORM_A: 'FORM_A',  // 例如:某登记表
  FORM_B: 'FORM_B',  // 例如:某告知书
  // ...
};

export const documentTemplates = {
  [DOC_TYPE.FORM_A]: {
    title: '某登记表',
  },
  [DOC_TYPE.FORM_B]: {
    title: '某告知书',
  },
};

export function getTemplateConfig(docType) {
  const config = documentTemplates[docType];
  if (!config) {
    console.warn(`未找到文书类型 ${docType} 的模板配置`);
    return null;
  }
  return config;
}

主组件里使用 getTemplateConfig(docType) 拿配置,这样「加新文书」对主组件来说就是多一个配置键和对应的模板组件啦。


四、壳层:动态组件 + 打印流程

主组件只认「当前 docType 对应哪个模板组件」,用 component :is 动态渲染,这样无需在壳里写一长串 if/else 或 v-if。

4.1 模板区域与动态组件

<!-- 打印区域:唯一 id 便于后面克隆到 iframe -->
<div id="commonPrintArea" class="print-area">
  <component
    :is="templateComponent"
    ref="templateRef"
    :data="printData"
    :numb="numb"
    :template-config="templateConfig"
  />
</div>
computed: {
  templateComponent() {
    const componentMap = {
      FORM_A: 'FormATemplate',
      FORM_B: 'FormBTemplate',
      // 新文书:加一行即可
    };
    return componentMap[this.docType] || null;
  },
},

printData 由你在 init/loadCommonData 里请求接口或直接使用外部传入的数据;templateConfig 来自 getTemplateConfig(this.docType)

4.2 从模板组件拿数据:约定 getData()

打印或保存前,主组件需要拿到当前模板里用户可能改过的内容,所以约定:每个模板组件暴露 getData()方法,返回要落库/打印的纯数据。

// 主组件 methods
getTemplateData() {
  const templateComponent = this.$refs.templateRef;
  if (!templateComponent || typeof templateComponent.getData !== 'function') {
    return null;
  }
  return templateComponent.getData();
},

async handlePrint() {
  const templateData = this.getTemplateData();
  if (!templateData) return;

  const saved = await this.savePrintRecord(templateData);
  if (!saved) return;

  this.executePrint();
  this.$emit('print-success', { docType: this.docType, numb: this.numb, printData: templateData });
}

这样无论是「先保存再打」还是「仅打印」,数据源都统一来自模板的 getData()


五、模板层:可编辑字段与 getData()

模板里会有大量「看起来像下划线填空」的格子,既要可编辑又要打印时样式干净,我们的做法是,用一个可编辑字段的子组件包一层,再在模板里用 v-model 绑定 editableData对象,最后 getData() 直接返回这个对象。

5.1 可编辑字段的子组件(EditableField组件)

用 HTML5的contenteditable属性做内联编辑,通过 v-model和父组件同步;输入法期间用 compositionstart/end 防抖。

<template>
  <span
    ref="editableElement"
    :class="['editable-field', customClass]"
    :contenteditable="editable"
    :data-placeholder="placeholder"
    @blur="handleBlur"
    @input="handleInput"
    @compositionstart="isComposing = true"
    @compositionend="isComposing = false; handleInput($event)"
  />
</template>

<script>
export default {
  name: 'EditableField',
  props: ['value', 'editable', 'placeholder', 'customClass', 'maxlength'],
  data() {
    return { isComposing: false, innerValue: '' };
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        if (!this.isComposing && newVal !== this.innerValue) {
          this.innerValue = newVal || '';
          if (this.$refs.editableElement) this.$refs.editableElement.innerText = this.innerValue;
        }
      },
    },
  },
  methods: {
    handleBlur(e) {
      const text = e.target.innerText.trim();
      this.innerValue = text;
      this.$emit('input', text);
    },
    handleInput(e) {
      if (this.isComposing) return;
      let text = e.target.innerText;
      if (this.maxlength && text.length > this.maxlength) {
        text = text.substring(0, this.maxlength);
        this.$refs.editableElement.innerText = text;
      }
      this.innerValue = text;
      this.$emit('input', text);
    },
  },
};
</script>

模板里用法示例:

<editable-field v-model="editableData.name" placeholder="请输入" custom-class="inline-underline-field" />

打印样式里对 .editable-field.inline-underline-field 等做「无边框、无背景、保下划线」的覆盖,即可做到「屏幕可编辑、纸上像填空」。


六、iframe 打印:只打「这一块」且样式可控

直接 window.print() 会连侧边栏、导航、按钮一起打。我们的做法是:把要打印的那块 DOM 克隆到隐藏的 iframe 里,在 iframe 里注入完整打印样式,再对 iframe 执行 print()

6.1 克隆 + 处理特殊节点(如复选框)

克隆时注意:像 Element UI 的 checkbox,在 iframe 里可能不会按「勾选状态」渲染,所以克隆后先把这类控件转成「勾选用 ☑ / 未勾选用 ☐」的纯文本,再塞进 iframe,这样打印出来稳定一致。

processCheckboxes(container) {
  container.querySelectorAll('.el-checkbox').forEach((el) => {
    const input = el.querySelector('input[type="checkbox"]');
    const isChecked = input && input.checked;
    const checkmark = document.createElement('span');
    checkmark.textContent = isChecked ? '☑' : '☐';
    // 若有 .el-checkbox__label,可把 label 文本和 checkmark 拼成新节点替换 el
    el.parentNode.replaceChild(checkmark, el);
  });
}

6.2 创建 iframe 并写入 HTML + 样式

executePrint() {
  const printArea = document.getElementById('commonPrintArea');
  if (!printArea) return;

  const cloned = printArea.cloneNode(true);
  this.processCheckboxes(cloned);

  const iframe = document.createElement('iframe');
  iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none';
  document.body.appendChild(iframe);

  const printStyles = this.getPrintStyles(); // 见下一小节

  const doc = iframe.contentWindow.document;
  doc.open();
  doc.write(`
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>${this.templateConfig.title}</title>
        <style>
          * { margin: 0; padding: 0; box-sizing: border-box; }
          body { font-family: "Microsoft YaHei", Arial, sans-serif; line-height: 1.5; color: #000; background: #fff; }
          ${printStyles}
        </style>
      </head>
      <body>${cloned.innerHTML}</body>
    </html>
  `);
  doc.close();

  iframe.onload = () => {
    iframe.contentWindow.focus();
    setTimeout(() => {
      iframe.contentWindow.print();
      setTimeout(() => document.body.removeChild(iframe), 500);
    }, 100);
  };
}

这样只有 iframe 里的 body 被打印,且样式完全由你注入的 printStyles 控制。


七、打印样式:基础 + 按文书类型扩展

拆成「基础样式(所有文书共用)」和「按 docType 的扩展样式」,主组件里根据 docType 拼成最终样式字符串。

getPrintStyles() {
  const baseStyles = `
    @page { margin: 0; size: A4; }
    body { margin: 10mm 10mm 15mm 10mm; font-family: "仿宋", serif; }
    .form-table { width: 100%; border-collapse: collapse; border: 2px solid #000; }
    .form-table th, .form-table td { border: 1px solid #000; padding: 6px 8px; }
    .form-table tr { page-break-inside: avoid; }
    .editable-field { border: none !important; background: transparent !important; box-shadow: none !important; }
    .inline-underline-field { border-bottom: 1px solid #333 !important; min-height: 1.2em; }
  `;
  const docTypeStyles = this.getDocTypeSpecificStyles(); // 从 styleMap[docType] 取
  return `${baseStyles}\n${docTypeStyles}`;
}

新增文书时,如需单独调表格列宽、标题字号等,在 getDocTypeSpecificStyles() 的 styleMap 里加一条即可,主组件逻辑不用改。


结尾:按这套思路实现后,业务侧只需要「传 docType + 外部数据) + 监听事件」,就能接住多种文书、可编辑、可保存的通用打印能力啦;后续加新文书也不会再在主组件里堆逻辑,维护成本也会低很多。

别再无脑用 `JSON.parse()` 了!这个安全漏洞你可能每天都在触发

你以为只是解析个字符串?其实黑客已经在你服务器上跑脚本了!

在前端和 Node.js 开发中,JSON.parse() 几乎无处不在:

const data = JSON.parse(localStorage.getItem('user'));
const config = JSON.parse(req.body.payload);
const settings = JSON.parse(fs.readFileSync('config.json'));

简洁、直接、好用——但极其危险

如果你没有对输入做任何校验就调用 JSON.parse(),你正在为应用打开一扇“任意代码执行”的后门。

今天,我们就来揭开 JSON.parse() 背后的安全雷区,并告诉你如何用更安全、更现代的方式处理 JSON 数据。


危险场景一:原型污染(Prototype Pollution)

这是 JSON.parse() 最臭名昭著的安全漏洞之一。

虽然原生 JSON.parse() 本身不会执行代码,但它会忠实地还原对象结构——包括 __proto__constructor.prototype 这类特殊属性。

来看一个真实攻击载荷:

const userInput = '{"__proto__":{"isAdmin":true}}';
const obj = {};
JSON.parse(userInput, (key, value) => {
  obj[key] = value;
  return value;
});
console.log({}.isAdmin); // true!全局对象被污染!

如果这段代码出现在你的登录逻辑、权限校验或配置合并中,攻击者就能:

  • 绕过身份验证(isAdmin: true);
  • 注入恶意属性(如 exec: 'rm -rf /');
  • 篡改全局行为,导致服务崩溃或数据泄露。

尤其在使用 Lodash、merge、assign 等工具库时,风险更高!


危险场景二:拒绝服务(DoS)

恶意构造的 JSON 字符串可导致内存爆炸CPU 耗尽

// 深度嵌套攻击
const evil = '{"a":{"a":{"a":{"a":{"a":{"a": ... }}}}}}';

// 或超大数组
const evil2 = '[1,1,1,...,1]' // 1000 万个元素

调用 JSON.parse(evil) 可能:

  • 占用数 GB 内存;
  • 阻塞事件循环数秒;
  • 直接触发 OOM(Out of Memory)崩溃。

在 API 接口或 Webhook 处理中,这等于把“关机按钮”交给了攻击者。


正确姿势:安全解析 JSON 的三重防护

第一步:限制输入大小

在解析前先检查字符串长度:

function safeParse(str, maxSize = 1024 * 100) { // 100KB
  if (typeof str !== 'string' || str.length > maxSize) {
    throw new Error('Input too large');
  }
  return JSON.parse(str);
}

第二步:禁用危险键(如 __proto__

使用 reviver 函数过滤敏感属性:

function secureJSONParse(str) {
  return JSON.parse(str, (key, value) => {
    if (key === '__proto__' || key === 'constructor') {
      throw new Error('Disallowed key in JSON');
    }
    return value;
  });
}

第三步(推荐):用 Zod / Joi 做运行时校验

这才是现代 JS 工程的最佳实践!

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  isAdmin: z.boolean().optional(),
});

function parseUser(jsonStr: string) {
  const raw = secureJSONParse(jsonStr);
  return UserSchema.parse(raw); // 自动校验 + 类型推导
}

优势:

  • 类型安全(配合 TypeScript 完美);
  • 自动过滤多余字段
  • 明确拒绝非法结构
  • 防止原型污染、字段注入等攻击

特别提醒:Node.js 中的额外风险

在服务端,如果你从以下来源解析 JSON,风险更高:

  • HTTP 请求体(req.body
  • 文件读取(用户上传的 JSON 配置)
  • Redis / 数据库存储的序列化数据
  • 第三方 Webhook 回调

务必在解析前做来源校验 + 结构校验 + 大小限制三重保险!


结语

JSON.parse() 不是“坏 API”,但它是一把没有保险的枪
在现代 Web 开发中,信任任何用户输入 = 自毁程序

下次当你写下 JSON.parse(someString) 时,请自问:

“我确定这个字符串来自可信源吗?它的结构真的安全吗?”

如果答案不确定,请立即切换到 Zod / Joi + 安全解析函数 的组合。

转发给那个还在裸用 JSON.parse() 的队友吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再让 `console.log` 上线了!它正在悄悄拖垮你的生产系统

你以为只是“打个日志”?其实它在泄露数据、吃光内存、暴露源码!

在开发过程中,console.log() 是我们最亲密的伙伴:

function calculatePrice(items) {
  console.log('items:', items); // 调试用
  return items.reduce((sum, item) => sum + item.price, 0);
}

方便、直观、零成本——但一旦这段代码被部署到生产环境,隐患就开始蔓延

今天我们就来揭开 console.log 在生产环境中的三大“罪状”,并告诉你如何彻底杜绝它。


危害一:敏感信息泄露

这是最致命的问题。

你在本地调试时可能这样写:

console.log('User login:', { email, password });
console.log('DB connection string:', process.env.DB_URL);
console.log('Admin token:', req.headers.authorization);

如果这些日志随代码上线:

  • 用户密码、API 密钥、数据库地址 会直接打印到服务器控制台;
  • 如果你用了 PM2、Docker、K8s 或云平台(如阿里云、AWS),这些日志会被自动采集到日志系统;
  • 任何有日志权限的运维、实习生、外包人员都能看到!
  • 更糟的是,如果日志被错误地公开(比如 GitHub 泄露、ELK 未设权限),黑客将直接拿到“系统钥匙”。

真实案例:2023 年某电商因 console.log 泄露支付密钥,导致数万元盗刷。


危害二:性能损耗与内存泄漏

别小看一个 console.log,它在高并发下是“隐形杀手”。

1. 同步 I/O 阻塞

Node.js 中的 console.log 默认是同步写入 stdout 的(尤其在非 TTY 环境,如 Docker 容器)。
这意味着:每打一行日志,事件循环都会被短暂阻塞。

在 QPS 1000+ 的接口中,频繁 console.log 可能导致:

  • 响应延迟增加 10%~30%;
  • CPU 使用率异常飙升;
  • 请求排队甚至超时。

2. 大对象序列化开销

console.log('Full user object:', hugeUserData); // 包含头像 Buffer、历史订单等

console.log 会调用 .toString() 或内部序列化逻辑,若对象巨大(如图片 Buffer、长数组),会:

  • 消耗大量 CPU;
  • 生成超长字符串,占用堆内存;
  • 触发频繁 GC,甚至 OOM 崩溃。

危害三:暴露源码结构与业务逻辑

生产环境的日志往往会被集中管理(如 Sentry、Datadog、阿里云 SLS)。
如果你不小心把函数名、变量名、内部路径打出来:

console.log('Calling internal service: /v1/billing/calculate-discount');
console.log('Error in function: validatePromoCodeV2');

攻击者就能:

  • 推测你的 API 设计;
  • 发现未公开的内部接口;
  • 结合其他漏洞发起精准攻击(如 IDOR、越权)。

这等于主动给黑客画地图


正确姿势:用专业日志系统替代 console.log

第一步:开发阶段就禁用生产级日志输出

使用环境判断(但不推荐仅靠这个!):

if (process.env.NODE_ENV !== 'production') {
  console.log('Debug info:', data);
}

问题:容易遗漏,且无法防止“忘记删除”的日志。


第二步(强烈推荐):引入专业日志库

使用 WinstonBunyanPino 等结构化日志工具:

import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  transports: [
    new winston.transports.Console(),
    // 生产环境可加文件、Sentry、阿里云 SLS 等
  ],
});

// 安全地记录
logger.debug('User data', { userId: user.id }); // 不会打印完整对象
logger.error('Payment failed', { orderId, reason });

优势:

  • 支持日志级别(debug/info/warn/error);
  • 自动过滤敏感字段(可通过 format 实现);
  • 异步/高性能输出;
  • 与监控系统无缝集成。

第三步:构建时自动清除 console.log

在打包阶段用工具彻底移除:

Webpack:

// webpack.config.js
optimization: {
  minimizer: [
    new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true, // 删除所有 console.*
        },
      },
    }),
  ],
}

Vite / Rollup:

使用插件如 rollup-plugin-stripvite-plugin-remove-console

ESLint(预防):

配置规则禁止提交 console

{
  "rules": {
    "no-console": "warn"
  }
}

配合 Git Hooks(如 husky + lint-staged),提交前自动检查。


终极建议:建立“日志规范”

  • 绝不在生产代码中使用 console.log
  • 所有日志必须通过统一 logger 实例输出
  • 敏感字段(密码、token、身份证)必须脱敏
  • 日志内容需经过安全审计

结语

console.log 是开发的好帮手,但它是生产环境的毒药
一次疏忽,可能导致数据泄露、服务崩溃、甚至法律风险。

记住:

真正的专业,不是能写出功能,而是能守住底线。

从今天起,让 console.log 止步于你的本地开发机。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

我的新同事是个AI:支持skills后,它用TinyVue搭项目还挺溜!

本文由体验技术团队Kagol原创。

一个月前,有用户建议 TinyVue 出几个 Skills,方便 AI 编程。

1.png

必须安排上!

目前 TinyVue 组件库和 TinyRobot AI 对话组件均已支持 Agent Skills,你可以在支持 Skills 的 IDE(比如 VSCode、Cursor、Trae 等) 上配置和使用。

1 演示视频

先看下使用效果(以 Trae 为例)。

TinyVue Skills:让 AI 使用 TinyVue 组件生成前端页面:www.bilibili.com/video/BV1d6…

以 Trae 为例,给大家介绍如何安装和配置 TinyVue Skills。

2 安装 TinyVue Skills

在命令行终端中执行以下命令:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent trae

2.png

安装方式选择 Symlink (Recommended)

安装成功!

3.png

查看 Skills 是否安装成功:

npx skills list -g

4.png

3 开启 TinyVue Skills

打开 Trae 的设置页面,在左侧的【规则和技能】菜单中找到【技能】,开启【tiny-vue-skill】这个技能即可。

5.png

4 在 AI 对话框中使用 TinyVue Skills

在 Trae 中打开 AI 侧栏,输入以下内容:

使用TinyVue组件创建一个登录组件,并集成到App.vue中

AI 会去调用 tiny-vue-skill 技能,根据其中的 SKILL.md 中的描述,去查看对应的组件 API/Demo 文档,然后使用适当的 TinyVue 组件搭建你需要的页面。

这样比 AI 去海量互联网信息中寻找 TinyVue 的用法要准确得多,而且消耗更少的 Token,也不容易产生幻觉。

6.png

如果你正在使用 TinyVue 组件库,强烈推荐你配置上 tiny-vue-skill,让 AI 辅助编码,效率更高!

如果你用的是 VSCode Copilot、Cursor 等其他 IDE也没关系,安装 TinyVue Skills 遵循类似的步骤,只需要把命令中的 --agent 修改成对应的 IDE 即可,以下是对应表格。

比如在 Cursor 中安装 tiny-vue-skill:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent cursor
Agent --agent 项目内路径 全局路径
Amp amp .agents/skills/ ~/.config/agents/skills/
Antigravity antigravity .agent/skills/ ~/.gemini/antigravity/skills/
Claude Code claude-code .claude/skills/ ~/.claude/skills/
Clawdbot clawdbot skills/ ~/.clawdbot/skills/
Codex codex .codex/skills/ ~/.codex/skills/
Cursor cursor .cursor/skills/ ~/.cursor/skills/
Droid droid .factory/skills/ ~/.factory/skills/
Gemini CLI gemini-cli .gemini/skills/ ~/.gemini/skills/
GitHub Copilot github-copilot .github/skills/ ~/.copilot/skills/
Goose goose .goose/skills/ ~/.config/goose/skills/
Kilo Code kilo .kilocode/skills/ ~/.kilocode/skills/
Kiro CLI kiro-cli .kiro/skills/ ~/.kiro/skills/
OpenCode opencode .opencode/skills/ ~/.config/opencode/skills/
Roo Code roo .roo/skills/ ~/.roo/skills/
Trae trae .trae/skills/ ~/.trae/skills/
Windsurf windsurf .windsurf/skills/ ~/.codeium/windsurf/skills/

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue skill源码:github.com/opentiny/ag… (欢迎 Star ⭐)

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

从入门到精通:Vue3 ref vs reactive 最佳实践与底层原理

在 Vue 3 中,ref 和 reactive 是 Composition API 提供的两个核心响应式 API,用于创建响应式状态。它们都基于 JavaScript 的 Proxy(reactive)和 getter/setter(ref 的内部机制)来实现响应式追踪,但在使用场景和行为上有一些关键区别。


1. ref

用途

  • 用于定义基本数据类型(如 stringnumberboolean)的响应式数据。
  • 也可以用于定义对象或数组,此时内部会自动调用 reactive
  • 在模板中使用时,无需 .value,Vue 会自动解包。
  • 在 JavaScript 中访问或修改时,必须通过 .value

示例

import { ref } from 'vue'

const count = ref(0)
console.log(count.value) // 0

count.value++
<template>
  <p>{{ count }}</p> <!-- 自动解包,无需 .value -->
  <button @click="count++">增加</button>
</template>

特点

  • 适用于任何类型。
  • 对于对象/数组,ref 内部会调用 reactive
  • 可以通过 ref() 创建对对象的响应式引用,保留其引用身份(identity)。

2. reactive

用途

  • 专门用于定义对象或数组类型的响应式数据。
  • 不能用于基本数据类型(如 numberstring)。
  • 返回的是一个代理对象(Proxy),直接操作其属性即可,不需要 .value

示例

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: 'Alice' }
})

state.count++
state.user.name = 'Bob'
<template>
  <p>{{ state.count }}</p>
  <p>{{ state.user.name }}</p>
</template>

特点

  • 只能用于对象/数组。

  • 返回的是原始对象的代理,无法替换整个对象(否则失去响应性)。

    // ❌ 错误做法:直接赋值新对象会丢失响应性
    state = { count: 1 } 
    
    // ✅ 正确做法:修改属性
    state.count = 1
    

对比总结

特性 ref reactive
支持类型 任意类型(基本类型 + 对象/数组) 仅对象或数组
访问方式(JS 中) 需 .value 直接访问属性
模板中使用 自动解包,无需 .value 直接访问
替换整个对象 可以(重新赋值 .value 不可以(会丢失响应性)
内部实现 基本类型用 getter/setter;对象用 reactive 基于 Proxy
适用场景 简单值、需要替换整个对象的情况 复杂对象状态管理

最佳实践建议

  • 优先使用 ref:尤其在 TypeScript 项目中,ref 的类型推断更直观,且统一使用 .value 有助于代码一致性。
  • 当你有一个复杂的对象状态,并且不需要替换整个对象时,可以使用 reactive
  • 避免混用导致困惑。例如,不要在一个 reactive 对象中嵌套 ref 除非必要(虽然 Vue 会自动解包,但可能影响可读性)。

补充:toRefs 和 toRef

当你从 reactive 对象中解构属性时,会丢失响应性。此时可使用 toRefs 或 toRef

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0 })
const { count } = toRefs(state) // count 是一个 ref

count.value++ // 仍然响应式

大屏天气展示太普通?视觉升级!用 Canvas 做动态天气遮罩,雷阵雨效果直接封神

之前做天气那个模块的时候,突发奇想想做一个大屏实时展示天气状况的蒙版。# Vue实现大屏获取当前所处城市及当地天气(纯免费)

需求

现在大屏上展示天气一般都是在左上/右上做天气的图标/纯文字的展示,虽然看起来非常直观,但是对于大屏这种需要炫酷效果的产品显得不合适。

目前市面上对于天气这一块也并不是非常重视,我接触的大屏项目/产品对这部分基本都没啥要求。

但是能够展示天气效果对于大屏本身有相当不错的加成效果。

屏幕录制 2026-03-05 102909.gif

所以我开发了这个大屏天气展示蒙版组件,能够根据当前天气状况以蒙版的形式展示出来,目前支持多种天气的展示效果。

方案

视频方案

一开始考虑的是纯视频解决方案,首先说这个方案非常的简单,将视频以背景图的形式放在蒙版上,通过 pointer-events: none; 鼠标穿透就能算是完成了。

但是实际操作过程中发现问题比较多,首先是透明背景需要特定格式的视频才能够支持。

必须使用支持 Alpha 通道(透明通道)的视频格式‌。

常见支持透明背景的格式包括:

  • ‌WebM(VP8 或 VP9 编码 + Alpha 通道)‌:Chrome、Firefox 和 WebView2 等 Chromium 内核环境支持良好 。
  • ‌MOV(Apple ProRes 4444 编码)‌:支持 Alpha 通道,但主要在 macOS 和专业软件(如 Final Cut Pro、After Effects)中使用 ‌。
  • ‌MP4(H.265/HEVC 编码)‌:部分平台(如 WebView2)支持含 Alpha 通道的 H.265 视频。

但是我在网上并没有找到相应格式的视频,自己录也弄得不好,所以放弃了。

另外以视频作为背景图在弱网环境下比较难加载,毕竟视频一般都要超过5M以上了。

但是如果有相应的视频,效果做出来绝对是最顶尖的。

GIF动图方案

和视频方案基本一致,唯一的区别是使用 GIF 动图作为背景图使用,效果也非常好。

问题点在于需要UI做一系列的动图效果,GIF 动图在加载上速度也不算太快,毕竟比较好的动图也不会太小。

还有一个问题在于如果屏幕大小发生变化,或者不是标准屏,可能存在图片拉伸/裁切等问题。

如果有UI协助,采用这个方案也非常不错。

Canvas渲染

采用 Canvas 渲染的方案实现这个是我最后的选择,原因有三:

  • Canvas性能开销不算太大,对低端设备相对比较友好
  • Canvas不依赖静态资源,弱网环境下不影响加载效果
  • Canvas能够根据屏幕大小达到自适应效果,避免特殊屏幕尺寸显示异常

采用 Canvas 粒子效果和渐变效果模拟阳光和雨滴、雪花等等状态,实现天气状态。

代码

初始化遮罩层

目前使用的是 Vue3 框架,因为是遮罩层所以采用 pointer-events: none; 鼠标穿透,避免影响大屏正常的操作。

<template>
    <canvas
        ref="weatherCanvas"
        class="weather-mask"
        :style="{ opacity: maskOpacity }"
    ></canvas>
</template>

<style scoped>
.weather-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none; /* 鼠标事件穿透 */
    z-index: 10; /* 确保在内容上方,可根据项目调整 */
}
</style>

这里需要注意,初始化画布的时候要记得设置一下width、height,让画布充满整个屏幕。

晴天效果

晴天效果采用光照渐变效果,在 Canvas 中绘制了一个从左上角到右下角线性渐变的效果,来模拟阳光照射的感觉。

同时增加部分光斑效果,模仿阳光投射在玻璃上的感觉。

// 创建从左上角到右下角的线性渐变(模拟阳光照射)
lightGradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);

// 绘制光斑效果
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 200, ${Math.random() * 0.1 + 0.05})`;
ctx.fill();

雨天效果

雨天采用粒子效果,实现细长的雨丝效果,这里没有做明显的区分,对于小雨、中雨、大雨。

其实想要区分也很简单,只要控制粒子的数量和速度即可。

ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(particle.x, particle.y + particle.height);
ctx.strokeStyle = particle.color.replace('OPACITY', particle.opacity);
ctx.lineWidth = particle.width;
ctx.stroke();

这里我进行了简单的封装,因为雨天、雪天、雾天等等大部分都用到了粒子效果,所以针对粒子的绘制部分进行了封装。

因为下雨是一个连续的绘制过程,所以动画部分做了简单的循环。

const animate = () => {
    updateParticles(props.weatherType);
    animationId = requestAnimationFrame(animate);
};

下雪效果

下雪本质上和下雨区别不大,唯一的区别是粒子的状态、运动速度和运动方向。

这里没有采用雪花造型的粒子,确实做出来了,但是效果并不好,不如这种圆形的效果看起来好一些。

雪花的绘制和雨滴的绘制区别在于,雨滴的宽度是1,而雪花的大小是一定范围内随机的。

ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = particle.color.replace('OPACITY', particle.opacity);
ctx.fill();

雷阵雨效果

雷阵雨效果是这里面个人觉得做的最好的一个,通过对下雨效果增加随机雷电闪烁屏幕的效果,达到雷阵雨天气的遮罩。

下雨仍然是复用的。

// 绘制主闪电路径
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(255, 255, 255, ${thunderAlpha})`;
ctx.lineWidth = Math.random() * 8 + 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(startX, startY);

// 绘制闪电分支
ctx.lineWidth = Math.random() * 4 + 1;
ctx.beginPath();
ctx.moveTo(branch.startX, branch.startY);
let bx = branch.startX;
let by = branch.startY;
const dx = Math.random() * 30 - 15;
const dy = Math.random() * 20 + 5;
ctx.lineTo(bx + dx, by + dy);
bx += dx;
by += dy;
ctx.stroke();

// 闪烁效果
ctx.fillStyle = `rgba(255, 255, 255, ${thunderAlpha * 0.1})`;

总结

这个遮罩我做了好几天,Canvas部分我也不是特别的熟悉,所以很多地方仍然有非常大的优化空间,有感兴趣的朋友可以移步下面的文章获取源代码。

# 动态天气实时渲染动态生成组件,附源代码及详细注释

至于为啥收费,我也是想尝试一下代码还能不能搞到钱,毕竟现在的软件行业白嫖是大家的常态。

如果您能支持1元钱那我不胜感激,如果确实认为不值,自己能够写出更好的,那我也祝福。

一共做个几个效果:晴、雨、雪、雾、雷阵雨、多云、沙尘、阴天。有兴趣的朋友可以运行起来自行查看一下。

文本字符数统计 在线工具核心JS实现

这篇只讲功能层 JavaScript。这个工具的实现方式很直接:用户输入文本后,触发统一统计函数,一次计算出全部指标,再把结果绑定到界面。

在线工具网址:see-tool.com/text-charac…
工具截图:
工具截图.png

1. 核心状态

先定义输入文本、统计结果、字符频率三个响应式状态:

import { ref } from 'vue'

const inputText = ref('')

const stats = ref({
  lines: 0,
  chars: 0,
  chinese: 0,
  english: 0,
  words: 0,
  numbers: 0,
  symbols: 0,
  displayLength: 0,
  fileSize: 0,
  charsNoSpace: 0,
  sentences: 0,
  paragraphs: 0,
  avgWordLen: '0',
  readTime: 0
})

const charFrequency = ref([])

2. 统一统计入口函数

输入框 @input 直接调用 updateStatistics,所有统计都在这个函数里完成:

const updateStatistics = () => {
  if (!process.client) return

  const text = inputText.value

  // 行数
  const lines = text ? text.split('\n') : []
  stats.value.lines = lines.length

  // 总字符数
  stats.value.chars = text.length

  // 中文字符数
  const chineseChars = text.match(/[\u4e00-\u9fff]/g) || []
  stats.value.chinese = chineseChars.length

  // 英文字符数
  const englishChars = text.match(/[a-zA-Z]/g) || []
  stats.value.english = englishChars.length

  // 单词数
  const words = text.trim() ? text.trim().split(/\s+/).filter(w => w.length > 0) : []
  stats.value.words = words.length

  // 符号数(排除字母、数字、下划线、空白、中日韩统一表意文字)
  const symbols = text.match(/[^\w\s\u4e00-\u9fff]/g) || []
  stats.value.symbols = symbols.length

  // 数字数
  const numbers = text.match(/[0-9]/g) || []
  stats.value.numbers = numbers.length

  // 显示长度:中文记 2,其它记 1
  let displayLength = 0
  for (const char of text) {
    if (/[\u4e00-\u9fff]/.test(char)) {
      displayLength += 2
    } else {
      displayLength += 1
    }
  }
  stats.value.displayLength = displayLength

  // UTF-8 字节大小
  stats.value.fileSize = new Blob([text]).size

  // 去空白字符数
  stats.value.charsNoSpace = text.replace(/\s/g, '').length

  // 句子数
  const sentences = text.split(/[.!?。!?]+/).filter(s => s.trim().length > 0)
  stats.value.sentences = sentences.length

  // 段落数(空行分隔)
  const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0)
  stats.value.paragraphs = paragraphs.length

  // 平均词长
  const avgWordLen = words.length > 0
    ? (words.reduce((sum, w) => sum + w.length, 0) / words.length).toFixed(1)
    : '0'
  stats.value.avgWordLen = avgWordLen

  // 阅读时长(200词/分钟)
  const readTime = Math.ceil(words.length / 200)
  stats.value.readTime = readTime

  updateCharFrequency(text)
}

这段代码把基础统计和派生统计放在同一流程,输入变化时只跑一次主函数,逻辑清晰。

3. 字符频率分析

字符频率的做法是遍历全文,过滤空白,统一小写,最后排序截断:

const updateCharFrequency = (text) => {
  if (!text.trim()) {
    charFrequency.value = []
    return
  }

  const freq = {}
  for (const char of text) {
    if (/\S/.test(char)) {
      const lower = char.toLowerCase()
      freq[lower] = (freq[lower] || 0) + 1
    }
  }

  const sorted = Object.entries(freq)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 20)
    .map(([char, count]) => ({ char, count }))

  charFrequency.value = sorted
}

这里 Aa 会被合并统计,更符合实际阅读习惯。

4. 复制统计结果

复制逻辑分两层:优先使用 Clipboard API,不可用时自动降级:

const formatNumber = (num) => num.toLocaleString()

const copyStats = async (t, MessagePlugin) => {
  if (!process.client) return

  const statsText = [
    t('textCharacterCount.statsResult'),
    '==================',
    `${t('textCharacterCount.lines')}: ${formatNumber(stats.value.lines)}`,
    `${t('textCharacterCount.chars')}: ${formatNumber(stats.value.chars)}`,
    `${t('textCharacterCount.chinese')}: ${formatNumber(stats.value.chinese)}`,
    `${t('textCharacterCount.english')}: ${formatNumber(stats.value.english)}`,
    `${t('textCharacterCount.words')}: ${formatNumber(stats.value.words)}`,
    `${t('textCharacterCount.numbers')}: ${formatNumber(stats.value.numbers)}`,
    `${t('textCharacterCount.symbols')}: ${formatNumber(stats.value.symbols)}`,
    `${t('textCharacterCount.displayLength')}: ${formatNumber(stats.value.displayLength)} (${t('textCharacterCount.displayLengthHint')})`,
    `${t('textCharacterCount.fileSize')}: ${formatNumber(stats.value.fileSize)} ${t('textCharacterCount.bytes')}`,
    `${t('textCharacterCount.charsNoSpace')}: ${formatNumber(stats.value.charsNoSpace)}`,
    `${t('textCharacterCount.sentences')}: ${formatNumber(stats.value.sentences)}`,
    `${t('textCharacterCount.paragraphs')}: ${formatNumber(stats.value.paragraphs)}`,
    `${t('textCharacterCount.avgWordLen')}: ${stats.value.avgWordLen}`,
    `${t('textCharacterCount.readTime')}: ${stats.value.readTime} ${t('textCharacterCount.minutes')}`
  ].join('\n')

  try {
    await navigator.clipboard.writeText(statsText)
    MessagePlugin.success(t('textCharacterCount.messages.copySuccess'))
  } catch {
    const textarea = document.createElement('textarea')
    textarea.value = statsText
    textarea.style.position = 'fixed'
    textarea.style.opacity = '0'
    document.body.appendChild(textarea)
    textarea.select()

    try {
      document.execCommand('copy')
      MessagePlugin.success(t('textCharacterCount.messages.copySuccess'))
    } catch {
      MessagePlugin.error(t('textCharacterCount.messages.copyFailed'))
    }

    document.body.removeChild(textarea)
  }
}

5. 示例文本与清空

两个动作都很轻:改值 + 重新统计。

const loadSample = (t, MessagePlugin) => {
  const sampleText = `Hello World! 这是一个文本统计分析的示例。

这个工具可以统计文本中的字符数、单词数、行数、句子数和段落数。
同时还能分析字符频率,帮助您了解文本的构成特点。`

  inputText.value = sampleText
  updateStatistics()
  MessagePlugin.success(t('textCharacterCount.messages.sampleLoaded'))
}

const clearText = (t, MessagePlugin) => {
  inputText.value = ''
  updateStatistics()
  MessagePlugin.info(t('textCharacterCount.messages.textCleared'))
}

6. 触发方式

输入区直接绑定:

<textarea v-model="inputText" @input="updateStatistics"></textarea>

这种绑定方式保证了“输入即统计”,不需要额外点击计算按钮。

❌