阅读视图

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

怎么在VS Code 调试vue2 源码

总结一下怎么在VS Code 调试vue2 源码

  • 克隆vue2源码到本地
  • 在源码根目录安装依赖 把项目跑起来 生成/dist/vue.js文件
  • 在/examples/ 下随便找个文件(引入的文件要是我们生成的vue.js) 打上断点
  • 安装Live Server插件 把我们打上断点的文件在浏览器打开
  • 在.vscode文件夹下配置launch.json
  • 点击VS Code的Run and Debug图标 就可以进入了

我们开始吧~

克隆vue2源码到本地

去Github克隆源码,克隆后我们用VS Code打开。

git clone https://github.com/vuejs/vue

image.png

在源码根目录安装依赖 把项目跑起来
pnpm i

image.png

image.png

把项目跑起来

npm/pnpm run dev

image.png

bundles /Users/gongzemin/Documents/GitHub/vue/src/platforms/web/entry-runtime-with-compiler.ts → dist/vue.js...

entry-runtime-with-compiler.ts 这个入口文件打包生成 dist/vue.js 这个最终可用的 Vue 文件

生成了dist文件夹 里面有vue.js

image.png

在examples/ 下随便找个文件 打上断点

我们找examples/classic/commits/app.js 在如图位置打上断点

image.png

commits/index.html 这个文件引入了vue.min.js, 我们刚才构建出来的是vue.js文件,我们把引入的文件改成vue.js

<script src="../../../dist/vue.js"></script>
安装Live Server插件 把我们打上断点的文件在浏览器打开

image.png

安装好插件后,打开文件的上下文菜单 可以看到Open with Live Server

image.png

这样我们就可以打开我们的examples/classic/commits/index.html 文件了 是用服务器打开的

image.png

在.vscode文件夹下配置launch.json

注意这里的URL是我们的要调试URL路径

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run parser",
      "runtimeExecutable": "parser",
      "cwd": "${workspaceFolder}/packages/reactivity-transform/node_modules/@vue/compiler-core",
      "args": []
    },
    {
      "type": "chrome",
      "request": "launch",
      "name": "调试Vue源码",
      "url": "http://localhost:5501/examples/classic/commits/index.html",
      "webRoot": "${workspaceFolder}",
      "sourceMaps": true,
      "sourceMapPathOverrides": {
        "webpack:///./*": "${webRoot}/*",
        "webpack:///packages/*": "${webRoot}/packages/*",
        "*": "${webRoot}/*"
      },
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}
点击VS Code的Run and Debug图标

点击Run and Debug图标, 选择调试Vue源码(就是我们配置launch.json里面配置的name)

image.png 看到app.js 进入我们打的断点了

image.png

我们点击Step Into

image.png

就进入Vue()构造函数了

image.png

调试vue3源码方法也一样 参考这篇笔记

能够插入 DOM 的输入框

简易富文本编辑器

使用input、textarea 这种输入框会出现一个问题,就是无法在其中写入 DOM 结构,浏览器不会把 DOM 进行渲染,这样的话在某些情况下使用他们只会浪费时间,复制粘贴半天,发现没办法放 UI 内容,无敌了孩子。

如果你的内容需要很多操作可以选择去使用富文本编辑器,这里就说一下怎么写一个简单的富文本编辑器。

     <div
        id="editor"
        contenteditable="true" // 赋予容器可编辑的能力
        ref="editorRef"
      ></div>

只要是 DOM 能放的结构,他都可以。

他也有一些缺点,就是没有input简便,好写,而且它只有一部分 input 对应的方法, 比如以下常见方法:

  • input
  • paste
  • blur、focus
  • keydown、keyup

如何插入 DOM(组件) 和文本

插入 DOM

const textNode = document.createTextNode(featureData.description); // 创建文本
const placeholder = document.createElement('span'); // 创建节点
placeholder.contentEditable = false; // 不可编辑
// 变量记录文本节点
featureData.lastTextNode = textNode;
featureData.lastTagHolder = placeholder; 
// 在编辑器最前方进行插入
editor.insertBefore(textNode, editor.firstChild);
editor.insertBefore(placeholder, editor.firstChild);

在vue的程序里面想要在普通函数中动态创建、挂载、操作组件可以通过vue提供的createApp去创建vue的节点

const app = createApp({
    render: () =>
      h(Tag, {
        text: featureData.title, // 组件 props 
        bgColor: featureData.bgColor, // 组件 props 
        onClose: () => {
          featureData.lastApp?.unmount();
          featureData.lastApp = null;
          featureData.lastTextNode?.remove();
          featureData.lastTagHolder?.remove();
          featureData.lastTextNode = null;
          featureData.lastTagHolder = null;
        },
      }),
  });
  app.mount(placeholder);
  featureData.lastApp = app; // 记录app实例进行卸载

h 函数

用于创建虚拟节点,可以渲染多个/嵌套/动态结构。

  1. 渲染组件 vnode 时 children 参数需要通过插槽函数书写,可以通过设置props为null避免将插槽识别为props。
  2. 渲染为 html 的节点 children 可以随意文本或者数组传递多个节点。
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots   // 为组件时需要通过插槽函数
): VNode

h( 
    组件 / 标签名, 
    属性、props、事件, 
    子节点/内容            // 子节点不是插槽就可以省略 props 书写
)
// 多个节点
h(
    'div'
    null,
    [
        h('div','文字') 
    ]
)
// 动态结构
h('div', isShow ? h(Tag) : h('span', '无标签') )

// 组件插槽传递 vnode
h(Components,null,{default:()=>'你的内容'})// 默认插槽

// html节点
h('div',null,['文字', h('span', '内容')])

鼠标选中区域

可以通过选中区域对文本区域进行记录,选中区域内容、获取选区范围等等,可以用于加粗、添加标题。

// 创建鼠标选区
  const range = document.createRange();
  // 设定鼠标选中区域
  range.setStartAfter(textNode); // 在 textNode 后面开始
  range.setEndAfter(textNode);   // 在 textNode 后面结束
  // 获取选区管理
  const sel = window.getSelection();
  // 获取选中文字
  const selectedText = sel.toString()
  // 获取第一个选区
  const range = sel.getRangeAt(0)
  // 移除先前选区
  sel.removeAllRanges();
  // 记录当前鼠标选区
  sel.addRange(range);

VTJ.PRO 发布 v2.3.6:开放共享模版、优化发布流程,低代码开发体验再升级

摘要: 基于 Vue 3 的开源 AI 低代码平台 VTJ.PRO 于 2026 年 4 月 10 日正式发布 v2.3.6 版本。本次更新聚焦模版共享与发布体验,开放共享模版功能,整合发布操作链路,并优化了版本控制与自动化截图能力,进一步降低了项目复用与协作的门槛。

未命名.png


开放共享模版,构建可复用的组件生态

v2.3.6 最值得关注的变化是 开放共享模版功能。开发者现在可以将自己设计的页面、模块或完整应用打包为模版,并发布到共享空间供团队或社区复用。同时,发布模版的版本控制机制得到强化,解决了此前模版更新失败的问题,使模版的迭代和回滚更加可靠。

  • 发布模版后,系统会自动在开发项目中创建对应的模版引用页面,实现“一次发布,处处引用”。
  • 模版共享结合原有的 AI 能力(设计稿转代码、自然语言生成页面),可大幅提升团队内部标准化组件的沉淀效率。

发布操作统一化,支持自动截图

为了减少多入口切换的认知负担,新版本将 发布应用、发布模版、项目出码 三个核心操作按钮整合至同一界面,开发者无需在不同菜单间跳转即可完成完整的交付流程。

此外,发布应用现已支持自动生成截图。系统会在发布时自动捕获当前应用界面的关键视图,方便在版本记录、发布日志或团队协作中快速识别应用状态。

默认公开与取消自动启动页,更贴合实际开发习惯

  • 创建应用时,访问权限默认为“公开”。这一调整降低了团队内部或开源项目中的分享门槛,同时也保持了随时可改为私有的灵活性。
  • 取消创建应用时自动新增启动页。此前新建应用会自动生成一个示例启动页,部分开发者反馈会带来额外的删除操作。新版本不再自动生成启动页,应用创建后直接进入空白设计状态,更符合从零开始的开发直觉。

开发者体验:从“可用”到“好用的低代码”

VTJ.PRO 一直强调 “降低复杂度,不降低自由度” ,v2.3.6 的更新再次印证了这一理念——通过优化发布链路和模版共享能力,让团队协作中的资产复用更加自然,同时保持对 Vue 源码的完全控制。

目前,VTJ.PRO 已在 Gitee 收获 9.9K Star,荣获 Gitee 2025 年度“大前端 Top3”。项目基于 Vue 3 + TypeScript + Vite,深度集成 ElementPlus、ECharts 等主流库,并已接入 DeepSeek、Qwen、Gemini、GPT 等 10+ 款大模型。

快速体验与更新方式


关于 VTJ.PRO
VTJ.PRO 是一款开源、基于 Vue 3 的 AI 低代码开发平台,支持可视化设计与手写代码双向转换,并提供私有化部署、多端输出(Web、H5、UniApp)、版本管理与企业级协作能力。项目始终保持“源码透明、无黑盒锁定”,是面向专业开发者的低代码解决方案。

11.png

Vue 迁移 React 实战:VuReact 一键自动化转换方案

一、核心关键词盘点

在 Vue 转 React 的技术迁移场景中,以下核心关键词是开发者必须聚焦的核心,也是本次方案落地的关键抓手:

  • 核心诉求:Vue 3 迁移 React 18+、自动化转换、减少手动重写成本、保留 TypeScript 类型、响应式系统适配
  • 核心工具:VuReact(编译核心 @vureact/compiler-core + 运行时 @vureact/runtime-core@vureact/router
  • 核心能力:智能编译、一键命令行转换、Scoped 样式适配、Composition API 转 React Hook、渐进式迁移
  • 核心痛点:手动改写易出错、响应式系统差异、生命周期不兼容、Scoped 样式迁移、混合开发模式适配

vureact_hero_demo.gif

二、痛点拆解与优化方案

痛点 1:手动迁移成本高、易出错

现状分析

传统 Vue 转 React 需逐行改写组件、模板、响应式逻辑,大型项目耗时数月,且易因语法差异引入 Bug。

优化方案:VuReact 一键自动化编译

通过 VuReact 实现零手动改写的自动化转换,核心步骤如下:

  1. 安装核心依赖
npm install -D @vureact/compiler-core
  1. 配置转换规则 创建 vureact.config.js,精准控制输入/输出/排除规则:
import { defineConfig } from '@vureact/compiler-core';

export default defineConfig({
  input: 'src', // 待迁移的 Vue 源码目录
  exclude: ['src/main.ts'], // 排除 Vue 入口文件
  output: {
    outDir: 'react-app', // React 代码输出目录
  },
});
  1. 执行一键转换
# 完整编译(生产环境)
npx vureact build
# 实时编译(开发调试)
npx vureact watch

痛点 2:Vue 响应式系统与 React Hook 不兼容

现状分析

Vue 的 ref/computed/watch 与 React 的 Hook 模式差异大,手动转换易破坏响应式逻辑。

优化方案:响应式语法自动适配

VuReact 内置专属运行时 Hook,无缝转换 Vue 响应式语法:

Vue 3 原语法 React 转换后语法
ref(0) useVRef(0)
computed(() => {}) useComputed(() => {})
watch(source, callback) useWatch(source, callback)

实战示例

<!-- Vue 原代码 -->
<script setup lang="ts">
// @vr-name: Demo
import { ref, computed, watch } from 'vue';
const price = ref(100);
const quantity = ref(2);
const total = computed(() => price.value * quantity.value);
watch(quantity, (newVal) => console.log('数量变化:', newVal));
</script>
// VuReact 自动转换后的 React 代码:Demo.tsx
import { useVRef, useComputed, useWatch } from '@vureact/runtime-core';

const Demo =  memo(() => {
  const price = useVRef(100);
  const quantity = useVRef(2);
  const total = useComputed(() => price.value * quantity.value);
  useWatch(quantity, (newVal) => console.log('数量变化:', newVal));
});

export default Demo;

痛点 3:Vue Scoped 样式迁移后失效

现状分析

Vue 的 Scoped 样式通过 data-v-hash 隔离,React 无原生支持,手动迁移易导致样式污染。

优化方案:Scoped 样式自动模块化

VuReact 编译时自动生成 CSS Module,零运行时开销实现样式隔离:

<!-- Vue 原代码 -->
<template>
  <div class="container"><h1>标题</h1></div>
</template>
<style scoped>
.container { padding: 20px; background: #f5f5f5; }
h1 { color: #333; }
</style>
// 自动生成的 React 代码
import $style from './Component-abc123.module.css';

const Component = () => {
  return (
    <div className={$style.container} data-css-abc123>
      <h1 data-css-abc123>标题</h1>
    </div>
  );
};
/* 自动生成的 CSS Module 文件 */
.container[data-css-abc123] {
  padding: 20px;
  background: #f5f5f5;
}
h1[data-css-abc123] {
  color: #333;
}

痛点 4:大型项目无法一次性迁移

现状分析

企业级项目直接全量迁移风险高,需支持 Vue/React 混合开发、按模块渐进迁移。

优化方案:渐进式迁移策略

  1. 按目录精准迁移
# 仅迁移组件目录
npx vureact build --input src/components
# 排除遗留代码目录
npx vureact build --exclude "src/legacy/**/*"
  1. 混合开发模式配置
export default defineConfig({
  input: 'src',
  exclude: [
    'src/legacy', // 保留未迁移的 Vue 代码
    'src/main.ts', // 保留 Vue 入口
  ],
  output: { outDir: 'react-app' },
});

痛点 5:工程化配置迁移繁琐

现状分析

迁移后需重新配置 React 项目的依赖、构建工具(Vite/Webpack),耗时且易遗漏。

优化方案:全自动工程化输出

  1. 自动生成依赖清单
{
  "name": "react-app",
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@vureact/runtime-core": "^1.0.0",
    "@vureact/router": "^2.0.1"
  },
  "devDependencies": {
    "typescript": "~5.8.3",
    "@eslint/js": "^9.25.0",
    "@types/react": "^19.1.2",
    "@types/react-dom": "^19.1.2",
    "@vitejs/plugin-react": "^6.0.1",
    "eslint": "^9.25.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript-eslint": "^8.30.1",
    "vite": "^8.0.0"
  }
}
  1. 自动生成构建配置(以 Vite 为例)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
})

三、完整迁移流程(开箱即用)

# 1. 安装 VuReact
npm install -D @vureact/compiler-core

# 2. 快速创建配置文件
echo "import { defineConfig } from '@vureact/compiler-core';
export default defineConfig({
  input: 'src',
  exclude: ['src/main.ts'],
  output: { outDir: 'react-app' },
});" > vureact.config.js

# 3. 执行迁移编译
npx vureact build

四、核心支持能力汇总

特性 VuReact 支持情况
Vue 3 <script setup> ✅ 完整支持
TypeScript 类型保留 ✅ 零丢失
模板指令(v-if/v-for) ✅ 自动转 JSX
生命周期(onMounted/onUnmounted) ✅ 转专属 Hook
Scoped 样式 ✅ 转 CSS Module
混合开发模式 ✅ 支持
渐进式迁移 ✅ 按目录/文件控制

五、总结

VuReact 作为 Vue 转 React 的一站式自动化工具,核心价值在于:

  1. 降成本:一行命令替代手动重写,迁移效率提升 90%+;
  2. 低风险:保留原有业务逻辑、TypeScript 类型,减少 Bug 引入;
  3. 高灵活:支持渐进式迁移、混合开发,适配大型项目场景;
  4. 全兼容:覆盖响应式、样式、生命周期、模板等全维度语法转换。

无论是中小型组件库迁移,还是大型企业级 Vue 应用升级 React 架构,VuReact 都能实现“无痛迁移”,让前端技术栈升级不再是技术债务,而是高效的架构迭代。

推荐阅读

Vue3 代码编写规范 | 避坑指南+团队协作标准

一、Vue3 通用基础规范(必看!统一编码底线)

1.1 编码格式规范:避免格式混乱,提升代码可读性

  • 缩进:统一使用4个空格缩进(禁止使用Tab),确保不同编辑器渲染一致。
  • 换行:每个独立代码块之间空1行,逻辑相关的代码块紧密排列,提升可读性。
  • 分号:语句结尾统一添加分号,避免因自动分号插入(ASI)导致的语法歧义。
  • 引号:模板内属性使用双引号(""),Script中字符串优先使用单引号(''),特殊场景(如嵌套引号)可灵活切换。
  • 注释:关键逻辑、复杂业务代码必须添加注释,注释需简洁明了,说明“为什么做”而非“做了什么”;组件开头可添加类注释,说明组件功能、Props、使用场景。

1.2 命名规范:一眼看懂用途,降低协作成本

核心原则:JS/TS领域遵循camelCase(小驼峰)/PascalCase(大驼峰),HTML领域使用kebab-case(连字符),保持项目内命名一致性,提升代码可读性与协作效率。

  • 变量/函数:使用camelCase,首字母小写,动词开头命名函数(如handleClick、fetchData),名词开头命名变量(如userInfo、goodsList)。
  • 常量:使用UPPER_SNAKE_CASE(全大写下划线分隔),如const API_BASE_URL = 'api.example.com'。
  • 类/组件:使用PascalCase,首字母大写,组件名需为多个单词(根组件App除外),避免与HTML原生元素冲突,如UserProfile、GoodsCard而非Todo、Button。
  • 自定义指令:使用kebab-case,如v-focus、v-scroll-to,符合HTML属性命名规范。

二、Vue3 单文件组件(SFC)规范(核心重点!避坑关键)

2.1 组件结构规范:固定结构,避免渲染异常

单文件组件(.vue)内部顺序固定为:template → script → style,每个部分独立成块,结构清晰;template内最多包含一个顶级元素,避免多根节点导致的渲染异常。

<!-- 正确示例 -->
<template>
    <div class="user-profile">
        <!-- 组件内容 -->
    </div>
</template>

<script setup>
// 逻辑代码
</script>

<style scoped>
// 样式代码
</style>

2.2 Template 规范:高效渲染,减少性能损耗

  • 指令使用:v-bind、v-on可使用简写(:、@),v-slot使用#简写;指令顺序统一为:v-for → v-if → v-bind → v-on,如<div v-for="item in list" :key="item.id" v-if="item.visible" @click="handleClick">
  • v-for 要求:必须搭配key,key值需为唯一标识(如id),禁止使用index作为key;避免在v-for内使用v-if,可通过计算属性过滤数据后再渲染,提升性能。
  • 组件引用:模板中使用组件时,优先使用PascalCase标签(如),明确区分原生HTML元素;DOM模板中必须使用kebab-case(如),因HTML不区分大小写。
  • 属性绑定:多个属性分行书写,每个属性占一行,提升可读性;布尔属性直接写属性名,如而非。

2.3 Script 规范:简洁高效,符合Vue3最佳实践

2.3.1 语法选择:优先<script setup>,拒绝混合语法

优先使用<script setup>语法(Vue3推荐),简洁高效;复杂组件(如需要生命周期钩子、Props验证、 emits定义)可结合Options API,但同一项目内语法需统一,禁止混合使用。

2.3.2 导入顺序:规范排序,提升代码可维护性

导入语句按以下顺序排列,不同类别之间空1行,提升可读性:

  1. Vue内置API(如ref、computed、watch);
  2. 第三方库(如Pinia、Axios、Element Plus);
  3. 项目内部组件(如子组件、基础组件);
  4. 工具函数、常量、样式文件;
  5. API接口请求函数。
<script setup>
// 1. Vue内置API
import { ref, computed, watch } from 'vue';
// 2. 第三方库
import { useUserStore } from 'pinia';
import axios from 'axios';
// 3. 内部组件
import BaseButton from './BaseButton.vue';
import UserCard from '@/components/UserCard.vue';
// 4. 工具函数/常量
import { formatDate } from '@/utils/format';
import { API_BASE_URL } from '@/constants';
// 5. API接口
import { fetchUserInfo } from '@/api/user';
</script>

2.3.3 Props 规范:严谨定义,避免传参异常

  • 命名:Props定义使用camelCase(如userName),模板中传递时使用kebab-case(如user-name),Vue会自动完成转换。
  • 定义:Props需详细定义,至少指定类型;必填项标注required: true,可选值通过validator验证,提升组件可维护性与容错性。
// 正确示例
const props = defineProps({
    // 基础类型定义
    userId: {
        type: Number,
        required: true,
        validator: (value) => value > 0 // 验证值为正整数
    },
    // 布尔类型,推荐前缀is
    isDisabled: {
        type: Boolean,
        default: false
    },
    // 数组/对象类型,默认值需用函数返回,避免引用共享
    goodsList: {
        type: Array,
        default: () => []
    },
    userInfo: {
        type: Object,
        default: () => ({
            name: '',
            age: 0
        })
    }
});

2.3.4 Emits 规范:明确声明,避免事件混乱

  • 命名:定义时使用camelCase(如updateValue),模板中监听时使用kebab-case(如@update-value),符合HTML属性命名习惯。
  • 定义:通过defineEmits明确声明组件触发的事件,禁止隐式触发事件;事件参数需清晰,避免传递过多参数,复杂参数建议封装为对象。
// 正确示例
const emit = defineEmits(['updateValue', 'deleteItem']);

// 触发事件(传递单个参数)
const handleValueChange = (value) => {
    emit('updateValue', value);
};

// 触发事件(传递复杂参数,封装为对象)
const handleDelete = (id, name) => {
    emit('deleteItem', { id, name });
};

2.3.5 异步逻辑规范:优雅处理,避免报错中断

  • 优先使用async/await语法,禁止使用Promise链式调用(then/catch),代码更易读且便于调试。
  • 所有async/await必须包裹try/catch,或在调用时用.catch()捕获错误,避免控制台报错和逻辑中断;错误处理需友好,可结合UI提示反馈给用户。
  • 高频触发的异步请求(如搜索输入框)必须加防抖,避免无效请求,推荐用组合式函数useDebounce封装复用。
// 正确示例(async/await + try/catch)
const fetchUser = async () => {
    try {
        const res = await fetchUserInfo(); // 调用异步接口
        return res.data;
    } catch (err) {
        console.error('获取用户信息失败:', err);
        ElMessage.error('加载失败,请重试');
        throw err; // 如需上层处理,可重新抛出错误
    }
};

// 错误示例(Promise链式调用)
const fetchUser = () => {
    return fetchUserInfo()
        .then(res => res.data)
        .catch(err => {
            console.error('获取用户信息失败:', err);
            ElMessage.error('加载失败,请重试');
            throw err;
        });
};

2.3.6 TypeScript 规范:强类型约束,减少类型报错

  • 禁止滥用any类型:除非明确兼容所有类型(如第三方库无类型声明),否则必须用具体类型、unknown或泛型;若用any,需加注释说明原因。
  • 接口(interface)与类型别名(type)区分:定义对象/类的结构用interface(支持扩展、实现);定义联合类型、交叉类型或简单类型别名用type。
  • Props/Emits 类型:使用TypeScript时,优先通过泛型定义Props和Emits类型,提升类型安全性。
// 正确示例(interface定义对象结构)
interface Goods {
    id: number;
    name: string;
    price: number;
    stock: number;
}
const goods: Goods = { id: 1, name: '手机', price: 5999, stock: 100 };

// 正确示例(type定义联合类型)
type GoodsCategory = 'electronics' | 'clothes' | 'food';

// Props类型定义
interface Props {
    userId: number;
    isDisabled?: boolean;
}
const props = defineProps<Props>();

// Emits类型定义
const emit = defineEmits<{
    (e: 'updateValue', value: string): void;
    (e: 'deleteItem', params: { id: number; name: string }): void;
}>();

2.4 Style 规范:避免污染,提升样式复用性

  • 作用域:组件样式优先使用scoped(如),避免样式污染;全局样式统一放在src/styles目录下,禁止在组件内写全局样式(除非特殊需求)。
  • 命名:样式类名使用kebab-case,与组件名、功能对应,如.user-profile、goods-card;避免使用无意义的类名(如box1、content2)。
  • 样式顺序:按“布局 → 尺寸 → 样式 → 交互”的顺序编写,如position → width → background → hover。
  • 复用:公共样式(如颜色、字体、间距)提取为变量,统一管理;重复使用的样式封装为Mixin或自定义样式类,提升复用性。

三、Vue3 组件设计规范(高复用+低耦合,团队必守)

3.1 组件拆分原则:拒绝大组件,提升可维护性

  • 单一职责:一个组件只负责一个功能,避免“大组件”(代码超过500行),复杂功能拆分为多个子组件,如将用户列表拆分为UserList(列表容器)、UserItem(列表项)、UserSearch(搜索框)。
  • 高复用低耦合:可复用组件(如按钮、输入框)提取为基础组件(放在src/components/base目录),组件间通过Props传递数据、Emits触发事件,禁止直接操作父/子组件数据。
  • 命名区分:基础组件统一前缀Base(如BaseButton、BaseInput),业务组件按功能命名(如OrderList、PaymentForm),布局组件前缀Layout(如LayoutHeader、LayoutSidebar)。

3.2 组件通信规范:清晰传参,避免数据混乱

  • 父子组件:父传子用Props,子传父用Emits,禁止子组件直接修改Props(单向数据流);复杂数据可通过v-model双向绑定(Vue3支持多v-model)。
  • 跨层级组件:优先使用Pinia状态管理,或使用provide/inject(适用于深层组件通信,需明确注入类型),禁止使用EventBus(易造成事件混乱)。
  • 同级组件:通过父组件中转(子传父 → 父传另一个子),或使用Pinia共享状态,避免直接通信。

四、Vue3 Pinia 状态管理规范(替代Vuex,简洁高效)

4.1 Store 设计原则:模块化拆分,避免冗余

  • 模块化:按业务模块拆分Store(如userStore、cartStore、goodsStore),避免单一Store过大;Store命名统一前缀use(如useUserStore),使用camelCase命名法。
  • 状态划分:State(状态)、Getters(计算属性)、Actions(异步/同步操作)分离,禁止在Getters中修改State,禁止在组件中直接修改Store的State(需通过Actions)。

4.2 状态操作规范:规范调用,避免状态异常

// stores/user.ts 正确示例
import { defineStore } from 'pinia';
import { fetchUserInfo } from '@/api/user';

export const useUserStore = defineStore('user', () => {
    // State:定义状态,使用ref/reactive
    const userInfo = ref({
        id: 0,
        name: '',
        avatar: ''
    });
    const isLogin = ref(false);

    // Getters:计算属性,依赖State,只读
    const userNickname = computed(() => userInfo.value.name || '未知用户');

    // Actions:处理同步/异步操作,修改State
    const setUserInfo = (info) => {
        userInfo.value = info;
        isLogin.value = true;
    };

    const logout = () => {
        userInfo.value = { id: 0, name: '', avatar: '' };
        isLogin.value = false;
    };

    // 异步Action,使用async/await
    const loadUserInfo = async (userId) => {
        try {
            const res = await fetchUserInfo(userId);
            setUserInfo(res.data);
        } catch (err) {
            console.error('加载用户信息失败:', err);
            throw err;
        }
    };

    return { userInfo, isLogin, userNickname, setUserInfo, logout, loadUserInfo };
});

五、Vue3 Vue Router 路由规范(优化体验,避免路由踩坑)

  • 路由命名:路由name使用kebab-case(如user-profile),与组件名、路径对应,提升可读性;路由path使用kebab-case(如/user/profile),符合URL命名规范。
  • 路由懒加载:所有路由组件均使用懒加载(() => import('组件路径')),减少首屏加载时间;基础组件无需懒加载。
  • 路由守卫:全局守卫用于权限控制(如登录验证),路由独享守卫用于单个路由的特殊控制,组件内守卫用于组件内的生命周期控制;避免在守卫中写复杂业务逻辑。
  • 参数传递:路径参数(params)用于必填参数(如/user/:id),查询参数(query)用于可选参数(如/list?page=1&size=10);接收参数时需做类型校验。
// router/index.ts 正确示例
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
    {
        path: '/',
        name: 'home',
        component: () => import('@/views/Home.vue'),
        meta: { title: '首页', requiresAuth: false }
    },
    {
        path: '/user/:id',
        name: 'user-profile',
        component: () => import('@/views/UserProfile.vue'),
        meta: { title: '用户详情', requiresAuth: true },
        props: true // 自动将params转为Props传递给组件
    },
    {
        path: '/404',
        name: '404',
        component: () => import('@/views/404.vue')
    },
    {
        path: '/:pathMatch(.*)*',
        redirect: '/404' // 路由匹配失败,重定向到404
    }
];

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes
});

// 全局前置守卫:登录验证
router.beforeEach((to, from, next) => {
    const userStore = useUserStore();
    if (to.meta.requiresAuth && !userStore.isLogin) {
        next('/login');
    } else {
        document.title = to.meta.title || 'Vue3 项目';
        next();
    }
});

export default router;

六、Vue3 工程化与协作规范(团队高效协作必备)

6.1 文件目录规范:结构清晰,便于维护

项目目录结构清晰,按功能模块划分,便于维护和协作,推荐目录结构如下:

src/
├── assets/          // 静态资源(图片、字体、图标等),命名使用kebab-case
│   ├── images/
│   ├── fonts/
│   └── icons/
├── components/      // 公共组件
│   ├── base/        // 基础组件(BaseButton、BaseInput等)
│   ├── layout/      // 布局组件(LayoutHeader、LayoutSidebar等)
│   └── business/    // 业务组件(OrderList、GoodsCard等)
├── views/           // 页面视图组件,命名使用PascalCase
│   ├── Home.vue
│   ├── UserProfile.vue
│   └── Order/
│       ├── OrderList.vue
│       └── OrderDetail.vue
├── stores/          // Pinia状态管理,命名使用useXXXStore.ts
│   ├── useUserStore.ts
│   └── useCartStore.ts
├── router/          // 路由配置
│   └── index.ts
├── api/             // API接口封装,按模块划分
│   ├── user.ts
│   └── goods.ts
├── utils/           // 工具函数,命名使用camelCase
│   ├── format.ts
│   └── request.ts
├── constants/       // 常量定义
│   └── index.ts
├── styles/          // 全局样式
│   ├── index.scss
│   └── variables.scss
├── composables/     // 组合式函数,复用逻辑
│   └── useDebounce.ts
└── App.vue          // 根组件

6.2 代码提交规范(Git Commit):清晰可追溯,便于审查

采用Conventional Commits标准,提交信息清晰,便于代码审查和版本回溯,格式为:(): 。

  • type(提交类型):feat(新功能)、fix(Bug修复)、docs(文档变更)、style(代码样式调整,不影响逻辑)、refactor(重构,不修复Bug也不增加功能)、test(测试相关)、chore(构建/工具变更)。
  • scope(范围):指定提交影响的模块(如user、router、goods),无明确范围可省略。
  • subject(描述):简洁明了,说明提交内容,首字母小写,结尾不加句号。
// 示例
feat(user): add password reset UI
fix(router): handle 404 redirect
chore(deps): upgrade axios to 1.2.0
docs: update component usage documentation

6.3 代码校验规范:统一格式,减少冲突

  • 工具配置:项目必须集成ESLint、Prettier,统一代码格式;安装依赖:npm install -D eslint prettier eslint-plugin-vue @typescript-eslint/parser eslint-config-prettier husky lint-staged。
  • 自动校验:配置pre-commit钩子(husky + lint-staged),提交代码时自动校验格式,不符合规范的代码禁止提交;开发过程中使用编辑器插件(如ESLint、Prettier)实时校验。
  • ESLint配置:继承vue3-recommended规范,结合项目需求调整规则,禁止禁用必要的校验规则(如禁止滥用any、禁止Props修改)。

七、Vue3 性能与安全规范(优化体验+规避风险)

7.1 性能优化规范:提速降耗,提升用户体验

  • 响应式优化:避免过度使用reactive,简单数据使用ref;大数据列表使用v-virtual-scroller(虚拟滚动),减少DOM渲染数量。
  • 计算属性与监听:computed用于依赖状态的计算(缓存结果),watch用于监听状态变化并执行副作用(如请求接口);避免在watch中写复杂逻辑,避免监听过多状态。
  • 资源优化:静态资源(图片)压缩,使用CDN加载;路由懒加载、组件懒加载;避免重复请求(添加请求缓存、防抖节流)。
  • DOM优化:减少DOM操作,避免在模板中使用复杂表达式;使用v-show替代v-if(频繁切换场景),v-if替代v-show(一次性渲染场景)。

7.2 安全规范:规避漏洞,保障项目稳定

  • XSS防护:避免直接插入HTML(如v-html),若必须使用,需对内容进行过滤;禁止使用eval、with等危险语法。
  • 接口安全:请求接口时添加token验证;敏感数据(如密码)加密后传输;接口返回数据需做类型校验,避免恶意数据导致的报错。
  • 依赖安全:定期更新项目依赖,避免使用存在安全漏洞的依赖包;安装依赖前检查依赖安全性(如使用npm audit)。

八、Vue3 补充规范(细节拉满,避免踩坑)

  • 兼容性:兼容主流浏览器(Chrome、Edge、Firefox最新版本),如需兼容旧浏览器(如IE11),需添加相应的polyfill。
  • 可维护性:代码书写简洁,避免冗余(如重复代码封装为函数/组件);注释清晰,便于后续维护和他人理解。
  • 一致性:项目内所有代码严格遵循本规范,团队成员需统一认知;新增规范需团队讨论确认后补充,避免个人风格差异导致的代码混乱。
  • 废弃代码:禁止保留无用代码(如注释掉的代码、未使用的变量/函数/组件),提交代码前删除废弃内容,保持代码整洁。

超级好用的三原后台管理v1.0.0发布🎉(Vue3 + Ant Design Vue + Java Spring Boot )附源码

好久没更新文章了,确实有AI后好像大家都不太关注技术了。好像大家的关注点都是那个AI 模型又又又增强。

但是我建议看到这篇文章的同学,还是要持续学习,你面试的时候不能告诉面试官我会用某个AI?如果AI模型花钱包月就能使用那么你的优势又是哪里。 流水不争先,争的是滔滔不绝 持续学习吧,至少现在出去面试还是问你的技术决定你的待遇呢,等真到某天我去面试,面试官查看我使用消耗的TOKEN,那我觉得就可以不用学了哈哈哈~

博主之前更新了一篇java的学习,是的java有所小成了,java spring boot基本上都可以写了,因为有一定的编程思想,也就只有语法不熟练了。

最近看了好多后台管理系统,有免费的RuoYI、纯前端的Naive UI Pro,收费的丰富了大家可以自行查找。

客观评价一下优秀开源作品RuoYi(仅仅个人观点):

  • 优点:功能成熟,可靠、稳定。
  • 缺点:vue2(现在大多都vue3了) 界面不是那么美观,后台代码应该是写的非常不错(我毕竟刚入门不好评价),但是不适合学习,如果你是专业后端我觉得上手难度还可以,如果偏前端的我觉得还是我的这个框架更合适。

Sanyuan VS RuoYi(客观评价)

  • 不足:后端因为我java刚入门,有编程思想,毕竟没那么多的经验,肯定好多地方没那么完善,但是博主再这里说明下,后续会继续学习多多学习。前端:我经验很足,一些细节也都考虑到了,也借鉴了一些别的系统,技术选型啥的肯定没问题。

  • 优点:因为我的java刚入门,没有引入什么特殊的插件,所以想学习或者写项目的同学更好使用。前端:支持Vue3、国际化、界面相对美观些吧(这个每人审美都不一样)

访问地址

访问地址: sanyuan.website/ 用户名:admin 密码:123456

仓库地址: gitee.com/hejingyuan0…

  • 角色管理
  • 日志管理
  • 菜单管理
  • 用户管理
  • 字典管理
  • 暗色主题适配切换
  • 现支持2种布局方式,未来会支持更多常用的布局已经在规划中~
  • 支持适配主题色、色弱模式、灰色模式
  • 支持4种路由过渡动画
  • 支持国际化,内涵轻松适配各种语言的方案
  • 支持权限控制
  • 支持路由缓存
  • 支持菜单外链、内嵌自定义
  • 支持两种标签栏定制

功能演示

  • 主题切换 image.pngimage.png

水平布局、垂直布局

image.png

国际化

image.png

还有一些别的自定义,大家自己去系统中使用吧

国际化

这里使用的是vite-auto-i18n-plugin 传送门 可以直接抽代码,配置好了自动翻译

image.png

使用方式如下图,别忘记再main.js引入生成的lang资源哈

image.png

动态菜单

其实做动态菜单有两种:

一种是再router.js中定义好code,然后根据后端的返回的code,在router.beforeEach中去拦截,遇到每权限的就next(/403)页面。这种实现简单,挺好用的,我们有些项目也在使用这种。

第二种是使用router.addRoute动态的去插入到路由里面,比如RuoYI等后台管理系统都是用的这种方式,再页面上选图标、输入路由路径、对应的前端组件路径。 然后把信息返回给前端,前端去router.addRoute。优点是可以在界面上自动配置,配置完后也可以给权限分配去使用了。如下图:

image.png

再用vue3 router.addRoute的时候也遇到过几个问题,第一点就是怎么匹配前端的组件资源:

const getRouterLoadView = (menu) => {
   //VUE3 要使用import.meta.glob 获取使用 不能使用() => import(`@/views/**/*.vue`)
  const modules = import.meta.glob('@/views/**/*.vue')
  const filePath = `/src/views${menu.component}`
  const componentLoader = modules[filePath]
  return componentLoader
}

还遇到一个问题就是因为我router在没有获取到权限数据的时候是就一些默认的(403、404、500、登录)。再访问https://sanyuan.website/home路径的时候,我在用router.beforeEach(async (to, _, next) => {}) 这个时候的router.to的信息已经是404的路由信息。 那么我怎么知道是Home 路由呢,通过URL的路径定位如下:

function getRoutePath(url = window.location.href) {
  const urlObj = new URL(url)
  return urlObj.pathname || '/'
}

完整的router如下:

router.beforeEach(async (to, _, next) => {
  NProgress.start()
  const { closeGlobalLoading } = useGlobalLoading()
  if (to.path === '/login') {
    next()
    closeGlobalLoading()
    return
  }

  // 没有token 去登录页面
  if (!localStorage.getItem('token')) {
    next('/login')
    return
  }

  const userStore = useUserStore()
  if (!userStore.userInfo.id) {
    // 判断是不是首次登录,首次登录获取用户信息
    try {
      // 这里获取用户信息、用户权限、用户动态菜单
      const { initPreposition } = usePreposition()
      await initPreposition()
      // ps: 因为to的信息这个时候已经变成404的路由信息了,用URL上的路由做为跳转
      closeGlobalLoading()
      next(getRoutePath(), { replace: true })
      return
    } catch (error) {
      console.log(error, '获取用户信息失败')
      localStorage.removeItem('token')
      next('/login')
      return
    }
  } else {
    next()
  }
})

路由缓存

使用keep-alive做缓存include动态做缓存,如果我不是动态的就比较好实现。例如我想要在User、Menu做缓存。我只需要在对应的页面文件定义 defineOptions({name: 'UserPage'})、defineOptions({name: 'MenuPage'}) 然后如下这么使用就生效:

<router-view v-slot="{ Component }">
      <transition :name="appStore.appConfig.transitionName" mode="out-in">
        <keep-alive :include="['UserPage', 'MenuPage']">
          <component :is="Component" :key="currentComponentKey" />
        </keep-alive>
      </transition>
    </router-view>

但是做成动态的就又有点不一样了如下图让用户自己在配置页面可选:

image.png

并且页面还有个刷新按钮

image.png

原本做刷新很简单只需要给component绑定一个动态的KEY,再点击的时候刷新这个动态key就行了。但是需要做keep-alive那这个key 就不能是纯动态了,比如缓存了2个页面,你刷新之后把key变了,做的缓存也失效了。

完整的实现如下: 1、路由中在meta保留缓存标识,因为我的是页面配置的,是在服务端直接处理好返回的noCache如下

{
    "name": "home",
    "path": "/home",
    "hidden": false,
    "component": "/home/index.vue",
    "alwaysShow": null,
    "meta": {
        "title": "首页",
        "titleEn": "Home",
        "frameType": null,
        "icon": "AppstoreOutlined",
        "noCache": true,
        "isFrame": false
    }
}

2、使用router.beforeResolveto里面收集到需要缓存的PageName如下:

import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { defineStore } from 'pinia'
import { uniq } from 'lodash'

function getRouteComponentName(route) {
  const currentRoute = route.matched[route.matched.length - 1]
  const currentRouteComponent = currentRoute?.components?.default
  return currentRouteComponent?.name
}

export const useRouteCacheStore = defineStore('route-cache', () => {
  const router = useRouter()
  const cachedViews = ref([])

  const setRouteCachedViews = (route) => {
    if (!route.meta.noCache) {
      // 需要缓存
      const componentName = getRouteComponentName(route)
      cachedViews.value = uniq([...cachedViews.value, componentName])
    }
  }

  // ps: 【缺陷】 如果首次刷新刚好在缓存的页面, 不会被收集到
  router.beforeResolve((to) => {
    setRouteCachedViews(to)
  })

  return { cachedViews }
})

3、解决刷新影响到keep-alive的问题,做缓存如下:


<template>
  <div class="app-content">
    <router-view v-slot="{ Component }">
      <transition :name="appStore.appConfig.transitionName" mode="out-in">
      // cachedViews 就是缓存收集到的需要 keep-alive的page
        <keep-alive :include="routeCacheStore.cachedViews">
          <component :is="Component" :key="currentComponentKey" />
        </keep-alive>
      </transition>
    </router-view>

    <slot name="footer"></slot>
  </div>
</template>

<script setup>
import { RouterView, useRouter } from 'vue-router'
import { computed, defineOptions, watch, ref } from 'vue'

import { useLayoutStore } from '@/stores/layout'
import { useAppStore } from '@/stores/app.js'
import { useRefreshStore } from '@/stores/refresh'
import { useRouteCacheStore } from '@/stores/route-cache.js'

import { useThemeToken } from '../hooks/use-theme-token.js'

defineOptions({name: 'AppContent',})

const router = useRouter()
const routeCacheStore = useRouteCacheStore()
const { colorBgLayout } = useThemeToken()
const layoutStore = useLayoutStore()
const appStore = useAppStore()
const refreshStore = useRefreshStore()


// 缓存每次的key, 在刷新的时候只更新当前的key
const cachedMap = ref({})

const currentComponentKey = computed(() => {
  const name = router.currentRoute.value.name
  if (cachedMap.value[name]) {
    return cachedMap.value[name]
  }
  cachedMap.value[name] = name
  return name
})

watch(
  () => refreshStore.refreshKey,
  () => {
    const name = router.currentRoute.value.name
    cachedMap.value[name] = `${name}_${refreshStore.refreshKey}`
  },
)
</script>

外链内嵌

可以看到我菜单配置页面有一个外链内嵌也是动态可配置的,这个要怎么实现呢?

1、实现一个iframe内嵌的组件:

<template>
  <a-spin tip="加载中..." :spinning="loading">
    <ProLayout>
      <ProLayoutMain>
        <iframe class="size-full border-none overflow-hidden" :src="url" @load="loading = false" />
      </ProLayoutMain>
    </ProLayout>
  </a-spin>
</template>

<script setup>
import { ProLayout, ProLayoutMain } from '@/components/pro-layout/index'
import { ref } from 'vue'

defineProps({
  url: {
    type: String,
    required: true,
  },
})

const loading = ref(true)
</script>

2、在处理菜单路由的时候把components改成这个使用vue render包裹的,然后把url传给ifrmae组件

import { ref, h } from 'vue'
const renderIframe = (url) => {
  return h(Iframe, { url })
}

function generateDynamicRoutes(menus) {
  if (!menus || !menus.length) return []

  return menus.map((menu) => {
    // 【iframe 外链】 内嵌模式的数据 isFrame = true, frameType = 2
    /**
     * 【iframe 外链】
     * 处理path 默认生成使用/iframe/xxxx 模式
     */
    const isFrameAdnInline = menu?.meta?.frameType === '2' && menu?.meta?.isFrame
    const route = {
      ...menu,
      path: isFrameAdnInline ? `/iframe/${menu.meta.titleEn}` : menu.path,
      name: menu.name || menu.path,
      meta: {
        ...menu.meta,
        title: isEN ? menu.meta?.titleEn : menu.meta?.title,
        icon: menu.meta?.icon ? h(ProIcon, { name: menu.meta.icon }) : '',
        isFrame: isFrameAdnInline ? false : menu.meta.isFrame,
      },
      component: isFrameAdnInline ? renderIframe(menu.path) : getRouterLoadView(menu),
    }
    if (menu.children && menu.children.length > 0) {
      route.children = generateDynamicRoutes(menu.children)
    }
    return route
  })
}

总结

做这个后台管理还是有不少知识点的,像路由过渡动画、缓存、动态菜单路由等等,大家具体可以查询源码。

问答

Q: 现在是vue3 + js写的,为啥不用ts?
A: 因为现在好多单位还是使用的js,相比较来说还是js更方便,编码更快, 只想做简单好用的框架,不增加负担

Q: 现在用的Ant Design Vue UI,后续会不会出Element Plus、Naiveui?
A: 这个要看系统使用的情况,如果比较受大家欢迎,后续会考虑出别的UI的后台管理系统

前端表单构建神器 - formkit初体验

传统表单开发 vs 低代码方案

传统的表单开发,无论是基于dom还是数据驱动的,都离不开手写html模板。尤其对于复杂的表单:关联字段联动、校验、表单字段的排版等等都有相当大的工作量。为此近些年涌现出不少的低代码方案,旨在通过页面拖拽配置的形式来高效的维护表单功能,来代替繁重的代码开发维护。基于JSON Schema的表单构建方案就是在这个背景下诞生,而具有代表性的就是本文要介绍的FormKit

FormKit项目初始化

准备源码路径:D:\2026学习\study\code\formkit示例\001_formkit项目初始化\parent
右键parent,用idea打开

20260410112337.png

20260410112551.png

自动完成依赖安装更新

20260410113807.png

看下默认安装的依赖:

"dependencies": {  
  "@formkit/core": "^2.0.0",  
  "@formkit/icons": "^2.0.0",  
  "@formkit/themes": "^2.0.0",  
  "@formkit/vue": "^2.0.0",  
  "@tailwindcss/vite": "^4.2.2",  
  "tailwindcss": "^4.2.2",  
  "vue": "^3.5.32"  
}

formkit不光是UI框架,更是开箱即用的json schema渲染表单的解决方案。对于UI,formkit直接用Tailwind来构建和维护组件样式。

修复类型引入问题

20260410114017.png

创建pnpm启动项

20260410114231.png

运行dev,访问:http://localhost:5173/,将看到页面:

20260410114451.png

组件的渲染方式

有两种方式:html中编写组件标签和基于schema的集中维护定义。
前者属于传统的组件使用方式,大部分场景下我们的表单开发都是直接用开源组件库如element plus,来编写和维护表单,FormKit也支持这个方式,它提供了内置的常用表单组件,同时提供了非常好的机制让我们扩展自定义组件,包括集成现有的UI组件。

组件定义方式

直接写组件标签,类似于使用Element Plus中的组件来手动构建表单:

<FormKit
  type="form"
  #default="{ value }"
  @submit="submit"
>
  <FormKit
type="text"
name="name"
label="Name"
help="..."
  />
  <FormKit
type="checkbox"
name="flavors"
label="..."
:options="{ ... }"
validation="required|min:2"
  />
  
  <FormKit
type="checkbox"
name="agree"
label="..."
  />
  ...
</FormKit>

基于Schema的定义方式

这种方式方便集中维护表单字段定义,FormKit可以基于表单定义的Schema动态的渲染表单,是低代码表单设计器的构建产物。有了它,我们只要关注于字段配置的扩展以及如何设计和实现表单设计器来在线生成表单定义数据。

<script setup lang="ts">
import {ref} from "vue"

const formSchema = {
  $formkit: 'form',
  children: [{
    $formkit: 'text',
    name: 'name',
    label: 'Name',
    help: '...',
  },
  {
    $formkit: 'checkbox',
    name: 'flavors',
    label: 'Favorite ice cream flavors',
    options: { ... },
    validation: 'required|min:2',
  },
  {
    $formkit: 'checkbox',
    name: 'agree',
    label: '...',
  },
]}

const data = ref({})

async function submit() {
  await new Promise(r => setTimeout(r, 1000))
  alert('Submitted! 🎉')
}
</script>

<template>
  <div class="...">
    <img ...>
    <FormKitSchema :schema="formSchema" v-model="data" @submit="submit" />
    <pre class="...">{{ data }}</pre>
  </div>
</template>

校验

FormKit提供了非常强大的内置校验和自定义扩展的方式,具体可参考校验官方文档
示例中对一个字段启用非空和长度校验非常简单,比如这里的多选框字段,只需简单配置为:validation: 'required|min:2',页面效果:

20260410152500.png

FormKit支持国际化,只需要在formkit.config.ts中进行如下配置:

...
import { zh } from '@formkit/i18n'
const config: DefaultConfigOptions = {
  ...
  locales: { zh },
  locale: 'zh',
}
export default config

会看到系统的校验信息变成了中文

20260410153511.png

官方文档

以上我们的介绍只是FormKit功能特性的九牛一毛,具体的API用法配置请参考FormKit官方文档。个人觉得看了那么多技术文档,FormKit无论是可读性和用户体验都是非常好的,唯一的遗憾是没有中文版。后续的例子也都会从官方文档来扩展。

20260410155356.png

好了,本次的学习分享就到这里。希望本篇能给前端低代码研发的小伙伴一些启示,我是小卷,一个爱学习分享的搬砖老码农,我们下期再见!

@embedpdf/vue-pdf-viewer内网使用避坑

之前刷到过embedpdf这个新的pdf预览库,就想着把pdfjs-dist或者vue-pdfjs换掉,默认样式非常好看,和前端的设计也很贴近

image.png

默认是使用英语,可以直接配置一下展示中文:

import { PDFViewer } from '@embedpdf/vue-pdf-viewer'

<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
  }"
/>

但是真实部署测试的时候发现,有些资源会请求cdn

image.png

image.png

经过一段时间在embedpdf文档、npmjs 和 node_modules摸索,可以找到pdfium.wasm在 @embedpdf/snippet 包,manifest.json@embedpdf/default-stamps 包提供

如果使用pnpm这种没有幽灵依赖的包管理,需要手动加一个 @embedpdf/snippet 依赖:

image.png

打包的话,因为我使用了vite,vite本身提供了wasm导入的方式:cn.vitejs.dev/guide/asset…

所以可以直接引入并提供url:

import pdfiumUrl from '@embedpdf/snippet/dist/pdfium.wasm?url'
const wasmUrl = pdfiumUrl.startsWith('http') ? pdfiumUrl : `${window.location.origin}${pdfiumUrl}`

因为dev的时候url引入是/node_modules/xxx的地址,embedpdf似乎会先校验是否是一个正确的URL地址,如果不是就会加载失败。

然后就是manifest.json主要是一些盖章的功能,我感觉大部分人不需要这个,如果不需要的话就可以这样阻止加载文件,解决加载过程中一直阻塞pdf预览的问题。

<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
    stamp: {
      manifests: [],
    },
    wasmUrl,
  }"
/>

当然你需要加载这个默认json的话可以:

import stampJson from '@embedpdf/default-stamps/zh-CN/manifest.json?no-inline&url'
<PDFViewer
  :config="{
    src,
    theme: { preference: 'light' },
    i18n: {
      defaultLocale: 'zh-CN',
      fallbackLocale: 'en',
    },
    stamp: {
      manifests: [{
          url: stampJson,
        }],
    },
    wasmUrl,
  }"
/>

这个json文件很小,不加no-inline的话打包后会被内联成base64,会和上面的wasm的加载一样报错。json还表示了使用了一个stamp.pdf,如果需要使用盖章还需要把node_modules/@embedpdf/default-stamps/zh-CN/stamps.pdf复制到public/assets/stamps.pdf

至此应该就能完全内网使用了XD

[前端]可折叠容器组件、信息展示卡片组件

分享两个Web前端组件,均为可折叠的内容展示组件,基于Vue3、ElementPlus框架。

可折叠容器组件

组件Vue源码

<!--
  * 可折叠容器组件
  * 
  * Author: GFire
  * Date: 2026/03/23
-->
<template>
  <div class="box-container">
    <div class="title" @click="isCollapse = !isCollapse">
      <el-icon style="margin: 0 5px">
        <ArrowUpBold v-if="isCollapse" />
        <ArrowDownBold v-else />
      </el-icon>
      {{ title }}
    </div>
    <el-collapse-transition>
      <div v-show="!isCollapse" :class="contentContainerClass || 'content-container'">
        <!-- 内容区域默认插槽 -->
        <slot></slot>
      </div>
    </el-collapse-transition>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * 标题文字
     */
    title?: string;
    /**
     * 内容区域样式class名(用于自定义样式)
     */
    contentContainerClass?: string;
  }>(),
  {
    title: '',
  },
);

// 是否折叠
const isCollapse = ref(false);
</script>

<style scoped>
.box-container {
  border: 1px solid #ececef;
  border-radius: 4px;
}

.title {
  background-color: #f7f8fb;
  padding: 5px;
  border-bottom: 1px solid #ececef;
  cursor: pointer;
}

.content-container {
  padding: 10px;
}
</style>

组件使用示例

使用代码:

<CollapseBox title="张雪820RR资料" style="width: 600px" content-container-class="collapseContent">
  <div>
    张雪820RR搭载张雪机车自研的820cc直列三缸发动机,最大功率99kW,最大扭矩80N·m,零百加速仅需2.81秒,极速高达280km/h。结合其43800元的售价来看,性价比简直爆棚,毕竟价格贵了快三倍的杜卡迪V2也没有如此炸裂的性能。不过张雪820RR最硬核的并非参数,而是背后的技术实力。
  </div>
</CollapseBox>

<style scoped>
/* 自定义内容区域的样式 */
:deep(.collapseContent) {
  padding: 15px;
}
</style>

解释:

  1. title为标题
  2. content-container-class为内容区域样式class名(用于自定义样式)
  3. 内容区域默认展开,点击标题区域切换折叠/展开状态。

显示效果:

image.png

折叠效果:

image.png

信息展示卡片组件

组件Vue源码

<!--
  * 基础组件:信息展示卡片
  * 
  * Author: GFire
  * Date: 2024/10/25
-->
<template>
  <el-card shadow="hover">
    <template #header>
      <div class="header">
        <div class="title" :style="getTitleStyle()">
          {{ props.title }}
        </div>
        <el-button @click="visible = !visible" :icon="visible ? 'Minus' : 'Plus'" text type="primary" size="small"></el-button>
      </div>
    </template>
    <!-- 卡片内容插槽 -->
    <el-collapse-transition>
      <div v-show="visible">
        <slot></slot>
      </div>
    </el-collapse-transition>
  </el-card>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { CSSProperties } from 'vue';

const props = withDefaults(
  defineProps<{
    title: string;
    titleSize?: number | string; // 标题字体大小, 传数字时单位为px, 传字符串时原样赋值
  }>(),
  {
    titleSize: 16,
  },
);

const visible = ref(true);

function getTitleStyle(): CSSProperties {
  return {
    fontSize: getTitleSize(),
  };
}

function getTitleSize() {
  const size = props.titleSize;
  if (typeof size === 'number') {
    return size + 'px';
  } else {
    return size;
  }
}
</script>

<style scoped>
:deep(.el-card__header) {
  padding: 10px;
}

:deep(.el-card__body) {
  padding: 12px;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.title {
  border-left: 7px solid var(--el-color-primary);
  padding: 3px 10px;
  font-weight: bold;
}
</style>

组件使用示例

使用代码:

<InfoCard title="张雪820RR资料" style="width: 600px">
  <div>
    张雪820RR搭载张雪机车自研的820cc直列三缸发动机,最大功率99kW,最大扭矩80N·m,零百加速仅需2.81秒,极速高达280km/h。结合其43800元的售价来看,性价比简直爆棚,毕竟价格贵了快三倍的杜卡迪V2也没有如此炸裂的性能。不过张雪820RR最硬核的并非参数,而是背后的技术实力。
  </div>
</InfoCard>

显示效果:

image.png

点击右上角的“-”按钮可以折叠,效果:

image.png

再点击右上角的“+”按钮则展开

3.响应式系统基础:从发布订阅模式的角度理解 Vue2 的数据响应式原理

前言

关于 Vue2 和 Vue3 的数据响应式原理,相信 Vue 技术栈的同学或多或少都了解过,甚至在简历上写很熟悉,而且我们在第一篇中也已经基本实现过了,但大家是否真的彻底掌握了呢?

正如我们前面所说的那样,Vue2、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,也就是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。

同样地基于依赖追踪和触发的响应式系统都是通过发布订阅模式进行实现的,那么你知道 Vue 的数据响应式原理中是如何运用发布订阅模式的吗?

所以我们在本篇当中将从发布订阅模式的角度来理解 Vue 的数据响应式原理,彻底掌握数据响应式的基本原理,同时也巩固我们在上一篇中所说的发布订阅模式。我们在上一篇中学习了发布订阅模式,我们都是基于一些 demo 的例子去理解,本篇则真正的把发布订阅模式在实际项目的进行运用。

温馨提示,阅读本本之前最好先阅读前一篇文章,对发布订阅模式有一定的理解

发布订阅模式原理回顾

我们在上一篇中最后是通过 Object.defineProperty 方法对公众号对象 weChatOfficialAccountarticle 属性进行劫持监听,然后在 getter 的时候进行订阅,在 setter 的时候进行发布。

代码如下:

// 定义公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(this.article))
    },
    // 取消订阅
    remove(fn) { 
        // 找到需要删除的订阅者
        const index = this.subscribers.indexOf(fn)
        // 删除订阅者
        this.subscribers.splice(index, 1 )
    }
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) weChatOfficialAccount.addDep(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            weChatOfficialAccount.notify()
        }
    })
}

我们可以看到 weChatOfficialAccount 对象上有很多属于发布订阅模式中的功能,如果说还有其他对象也需要实现这样的功能,那么也要实现一遍这些功能,很明显这样是不可接受的,我们可以通过上一篇中实现的消息代理来代替 weChatOfficialAccount 对象中的发布订阅模式的功能。

首先我们对消息代理的实现做如下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}

如果熟悉 Vue2 数据响应式原理的同学对上面的代码肯定很熟悉,这个就是 Vue2 源码中的 Dep 类的简易实现,所以 Vue2 源码中的 Dep 其实就是一个事件总线或者叫消息代理,但它又不仅仅是消息代理,在某些时刻它同时又是一个订阅者,这个情况就是我们在上篇当中所说的一个对象既可以是发布者也可以是订阅者,而在 Vue2 源码中 Dep 类既是消息代理中心又是订阅者,具体情况我们将下文中进行详细讲解。

接下来我们继续做如下修改:

const weChatOfficialAccount = {
    // 消息代理对象
    __ob__: new Dep(),
    // 文章内容
    article: '', 
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) dep.addSub(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们在公众号对象中通过 __ob__ 属性来存储消息代理对象,这样公众号对象原本属于发布订阅的功能就通过 Dep 类来实现了,这样代码的功能职责就梳理得十分清晰了,也符合代码整洁之道。

我们对修改后的代码进行测试:

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.article}`)
}
// 读取一次,触发 getter 进行订阅
weChatOfficialAccount.article
// 设置为 null 防止重复订阅
subscriber = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

上述测试代码的测试结果将会打印:

收到的公众号文章:通过 Object.defineProperty 方法实现订阅发布模式

我们通过 Object.defineProperty 方法实现对 weChatOfficialAccount 对象的属性 article 进行监听实现发布订阅功能,然后订阅的时候需要读取一下,触发 getter 进行订阅,这个行为在上述例子中比较奇怪,我们把它改成我们容易理解的例子。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title></title>
  </head>
  <body>
    <div id='content'></div>
    <script>
        const weChatOfficialAccount = {
            // 消息代理对象
            __ob__: new Dep(),
            // 文章内容
            article: '文章内容', 
        }
        // 省略 ...
        // 订阅者小明
        let subscriber = () => {
            const el = document.getElementById('content')
            el.textContent = `小明收到的公众号文章:${weChatOfficialAccount.article}`
        }
        // 初始化
        subscriber()
        subscriber = null
    </script>
  <body>
</html>

我们改成我们 web 应用程序中的例子就比较容易理解了。所谓订阅者小明,就是一个 HTML 更新函数,在初始化执行 subscriber() 函数的时候,会读取公众号对象 weChatOfficialAccountarticle 属性的值,这样就会触发 getter 函数进行订阅,在后续当公众号对象 weChatOfficialAccountarticle 属性值发生变化的时候,就会触发 setter 进行发布,也就是重新执行订阅者函数,然后网页内容发生变化。这个也是 Vue2 中的数据响应式的基本原理。

在上述例子中我们只对其中一个属性进行监听,但实际情况很有可能有其他订阅者对其他属性的进行引用。

// 订阅者郭靖
let guojingSubscriber = () => {
    console.log(`郭靖收到的公众号文章作者:${weChatOfficialAccount.author}`)
} 
// 订阅者杨过
let yangguoSubscriber = () => {
    console.log(`杨过收到的公众号发布时间:${weChatOfficialAccount.date}`)
}

然后我们需要对公众号对象进行修改:

const weChatOfficialAccount = {
    // 事件总线对象
    __ob__: new Dep(),
    // 文章内容
    article: '文章内容',
+    author: '作者',
+    date: '日期'
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
+ defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
+ defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)

同时需要对 getter 中的添加订阅者部分进行修改,为了精准添加对应的订阅者,我们需要判断对应的属性:

function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 为了精准添加对应属性的订阅者,我们需要判断对应的属性
+            if (subscriber && key === 'article') dep.addSub(subscriber)
+            if (guojingSubscriber && key === 'author') dep.addSub(guojingSubscriber)
+            if (yangguoSubscriber && key === 'date') dep.addSub(yangguoSubscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们上述代码中为了精准添加对应属性的订阅者,我们需要在 getter 中判断对应的属性,在功能简单的情况下可以,如果功能复杂,对象的属性庞大的情况下,这样肯定是不能接受的。

又因为在触发 getter 的时候,只会是在某个订阅者函数在执行的时候,也就是说在 getter 被触发的时候,这个时候的订阅者是确定的,所以我们可以采用 中间变量 形式来解决这个问题。我们设置一个全局变量 activeEffect,也就是所谓中间变量,然后在初始化执行订阅者函数之前把需要执行的订阅者函数赋值给 activeEffect,然后在 getter 里面就可以把中间变量 activeEffect 通过消息代理对象添加到订阅者记录里面了,然后在执行完该订阅者函数之后则需要把中间变量 activeEffect 设置为 null,防止重复添加。

代码修改如下:

+ // 订阅者中间变量
+ let activeEffect
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 存在订阅者中间变量就进行订阅者添加
+            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}
+ // 初始化订阅者
+ activeEffect = subscriber
+ subscriber()
+ activeEffect = null
+ activeEffect = guojingSubscriber
+ guojingSubscriber()
+ activeEffect = null
+ activeEffect = yangguoSubscriber
+ yangguoSubscriber()
+ activeEffect = null

接着我们进行测试:

// 公众号发布文章
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

打印结果如下:

C01.png

我们就发现我们虽然只对 article 属性进行赋值,但也触发了其他属性的订阅者的执行。那么我们就需要对属性与订阅者之间进行准确关联。那么如何进行准确关联呢?我们通过第一篇文章可以知道,在通过 Object.defineProperty 对每一个属性进行劫持监听的时候,通过闭包的形式把属性值缓存下来的,所以每一个属性的消息代理也放在闭包函数 defineReactive 中。

// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
+    // 通过闭包把每一个属性的消息代理进行缓存
+    const dep = new Dep()
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

这时我们再进行测试的时候,就可以精准触发订阅者了。

我们上面是通过手动调用 defineReactive 函数进行对象的属性劫持的,我们可以通过获取所有对象的属性然后遍历调用 defineReactive 函数进行对象的属性劫持,同时把这个功能封装成一个工具函数 observe。我们在第一篇中也实现过的了,下面我们来重新实现一下:

function observe (data) {
    Object.keys(data).forEach(key => {
        defineReactive(data, key, data[key]) 
    })
}

这样我们就可以通过 observe 函数来定义一个响应式对象了。

const weChatOfficialAccount = {
    // 文章内容
    article: '文章内容',
    author: '作者',
    date: '日期'
}
- defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
- defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
- defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)
+ observe(weChatOfficialAccount)

至此我们便通过发布订阅模式初步实现了 Vue2 的响应式原理。创建一个对象,通过 observe 工具函数遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter,然后在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。

从发布订阅模式的角度来说就是每一个对象的 property 都是发布者,然后它的消息代理则通过闭包的形式跟每一个 property 的值一起缓存在 defineReactive 闭包函数创建独立空间中,它们是多对多的关系。

小结

在我们一般的发布订阅模式(也叫观察者模式)中,发布者或者被观察者是很明确的,是一个具体的对象,但正如我们前一篇文章所说的那样,发布订阅模式是没有标准范式的,设计模式也是,辨别一种模式不能通过代码结构,而是代码意图。而在我们上述实现的 Vue2 响应式原理的过程中,我们发现其实每一个对象属性(property)都是一个发布者或者叫被观察者,它的发布者功能则通过消息代理中介进行实现,而这个消息代理对象则通过闭包的形式跟每个属性值一起缓存在闭包当中。我们又可以发现所谓发布订阅模式的触发条件也不是唯一的,我们一般的描述定义是,当一个对象发现变化的时候才去触发所有依赖它的订阅者,其实不然,发布订阅模式的触发条件可以是状态的变化、某个操作的变化、甚至是发布订阅者的通知也可以触发另外一个发布者进行发布操作。如果有在 Vue 中使用过事件总线的同学会很清楚,我们在组件中触发通知(emit)订阅者操作的时候并不一定是组件属性发生了变化,而有可能是某个方法触发了通知(emit)订阅者操作。

对数组进行响应式的处理

可以通过 Object.defineProperty 对数组进行监听,但监听不了数组自身的原型链方法,而 pushpopshiftunshiftsplicesortreverse 对数组进行操作是会改变数组的数据结构的,从发布订阅模式的角度来说数据发生变化后我们需要通知该数组对象的所有订阅者。为了实现这需求我们需要劫持数组的操作方法,即在对数组进行 push 等操作的时候我们能监听到。实现方案就是对数组的原型进行重写,重写的方法就是覆盖数组数组对象上的原型对象 __proto__。我们在第一篇当中是通过粗暴的直接覆盖的方式,但那样会把原来的一些数组方法也覆盖掉了,那样是不可取。

我们可以通过获取数组原型上的对象,然后只修改需要修改的方法即可。我们对 observe 方法修改如下:

function observe (data) {
+    // 如果是数组则重写数组上的原型
+    if (Array.isArray(data)) {
+        // 获取数组原型
+        const arrayProto = Array.prototype
+        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+        const arrayMethods = Object.create(arrayProto)
+        // 修改 push 方法
+        arrayMethods['push'] = function (...args) {
+            // 获取原始方法
+            const original = arrayProto['push']
+            // 执行原始方法
+            const result = original.apply(this, args)
+            return result
+        }
+        // 覆盖原型对象
+        data.__proto__ = arrayMethods
+    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
+    }
}

上述代码我们通过 Object.create 创建一个原型为 arrayProto 的空对象:arrayMethods。然后给空对象设置 push 属性值为一个函数,最终把 arrayMethods 赋值给 data__proto__。这里就涉及到了一个 JavaScript 原型链的基础知识,当我们获取一个对象的属性值的时候,我们优先从该对象的自身属性上去获取,如果找不到则沿着该对象的 __proto__ 属性上的对象上的属性去查找,如果还找不到,则继续沿着 __proto__ 上的对象去查找。

我们经过上面的代码设置之后,我们通过 observe 设置一个数组,那么这个数组的原型对象则变为了arrayMethods,当执行该数组的 push 方法,根据原型链的规则,它会先执行 arrayMethods 对象上的 push 方法,这样我们就可以对该数组的 push 方法进行了监听,我们最终还是通过原本数组上 push 方法进行操作,但我们可以捕捉到了 push 的动作,这样我们就可以在 push 操作之后,进行通知所有该数组上的订阅者了。

我们从前面的发布订阅模式的知识可以知道,一个发布者对象上需要有一个消息代理对象,所以我们需要继续迭代我们的代码:

function observe (data) {
+    // 不存在消息代理则设置消息代理对象
+    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
+            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+            data.__ob__.notify()
             // 同时对新添加的数据也进行响应式化
+             for (let i = 0, l = args.length; i < l; i++) {
+              observe(args[i])
+             }
        }
        // 覆盖原型对象
        data.__proto__ = arrayMethods
    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
    }
}

那么我们这个发布者对象的订阅者在哪里进行添加呢,从数据响应式的角度就是这个响应式对象的依赖在哪里收集呢?

其实不管是对象还是数组的订阅者都是在 getter 中进行添加的。
例如:{ list: [1,2,3,4] }
你要获取到 list 数组的内容,首先是通过 list 这个 property 进行获取的,所以当通过 list 这个 property 进行获取数组内容的时候,就触发了 list 这个 property 的 getter。

所以我们需要对 defineReactive 函数进行修改:

function defineReactive(data, key) {
    let val = data[key]
    // 获取消息代理对象
    const dep = new Dep()
+    // 对获取到的属性值进行递归 observe 监听
+    const childOb = observe(val)
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
+                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
+                if (childOb) childOb.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

在 getter 中会进行 property 的订阅者添加,收集到的订阅者保存在对应 property 的消息代理对象中,同时也会判断,property 的值如果是一个对象,还会对这个对象进行订阅者添加,收集到的订阅者还会保存到这个对象的消息代理对象上。

所以我们还需要对 observe 函数进行修改:

+ // 判断是否是对象
+ function isObject(obj) {
+     return obj !== null && typeof obj === 'object'
+ }
function observe (data) {
+    // 不是对象则直接返回
+    if (!isObject(data)) return 
    
    // 省略 ...
    
+    // 返回消息代理对象
+    return data.__ob__
}

至此我们对数组的响应式也实现了,接下来就是进行测试:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}

observe(weChatOfficialAccount)

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
}

// 初始化订阅者
activeEffect = subscriber
subscriber()
activeEffect = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

我们可以看到正确打印了结果,也就是我们也实现了对数组的响应式。

通过重构实现 Observer 类

我们上述函数 observe 的实现其实职责是很不清晰的,也不利于后续的维护,所以我们需要对它进行重构。软件应该是“自描述”的,代码除了给机器看之外,也要给人看。我们希望写的代码更易读,让代码可以更好地表达自己的意图。

这里我们涉及到一些开发技巧,我们可以先实现具体的功能,然后再重构,在重构的时候通过封装成抽象的类或者其他函数,让代码可以更好地表达自己的意图。那么 observe 函数中可以将对数组响应式的处理,还有对对象属性循环劫持分别封装成不同的函数,然后通过函数名称可以让我们的代码意图更明显。

我们对 observe 函数进行重构,代码如下:

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return 
    // 不存在消息代理则设置消息代理对象
    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
+        protoAugment(data)
    } else {
+        walk(data)
    }
    // 返回消息代理对象
    return data.__ob__
}
+ // 对数组进行响应式处理
+ function protoAugment(target) {
+    // 获取数组原型
+    const arrayProto = Array.prototype
+    // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+    const arrayMethods = Object.create(arrayProto)
+    // 修改 push 方法
+    arrayMethods['push'] = function (...args) {
+        // 获取原始方法
+        const original = arrayProto['push']
+        // 执行原始方法
+        const result = original.apply(this, args)
+        // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+        target.__ob__.notify()
        // 同时对新添加的数据也进行响应式化
        observeArray(args)
+    }
+    // 覆盖原型对象
+    target.__proto__ = arrayMethods
+ }
+ // 对对象进行响应式处理
+ function walk(obj) {
+    const keys = Object.keys(obj)
+    keys.forEach(key => {
+        // 如果属性不是 __ob__ 则进行监听
+        if (key !== '__ob__')  defineReactive(obj, key) 
+    })
+ }
 // 对数组的每一项元素都进行响应式处理
 function observeArray (items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
 }

经过我们上面对不同的功能代码进行重构后,我们就可以通过函数名称很容易理解代码的意图了。但我们上面的功能函数还是十分的分散,而它们都是同一种功能类型的函数,都是实现对象响应式的功能函数,所以我们可以通过 OOP 的思想把响应式数据和操作封装到一个类里面,这个类我们把它命名为 Observer

Observer 类的代码实现如下:

class Observer {
    constructor(value) {
        this.value = value
        this.dep = new Dep()
        this.value.__ob__ = this
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
            this.protoAugment(value)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {
            // 对对象进行响应式处理
            this.walk(value)
        }
    }
    // 进行原型重写
    protoAugment(target) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            const ob = this.__ob__
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
            ob.dep.notify()
            // 同时对新添加的数据也进行响应式化
            ob.observeArray(args)
        }
        // 覆盖原型对象
        target.__proto__ = arrayMethods
    }

    // 对对象进行响应式处理
    walk(obj) {
        const keys = Object.keys(obj)
        keys.forEach(key => {
            // 如果属性不是 __ob__ 则进行监听
            if (key !== '__ob__')  defineReactive(obj, key) 
        })
    }
    // 对数组的每一项元素都进行响应式处理
    observeArray (items) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
}

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return
    const ob = new Observer(data)
    // 返回 Observer 实例对象
    return ob
}

defineReactive 函数也需要进行以下修改:

function defineReactive(data, key) {
// ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
// ...
        }
    })
}

我们修改后进行重新测试也正常打印了结果:

C03.png

我们还可以进行一下性能优化,我们上述代码 protoAugment 函数部分,我们创建了数组原型对象的变量,而这些变量其实是不会变化,我们可以把它们的声明移到 protoAugment 函数外面,这样每一次调用 protoAugment 函数就不会重复重新创建这些变量了。

+ // 获取数组原型
+ const arrayProto = Array.prototype
+ // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+ const arrayMethods = Object.create(arrayProto)
+ // 修改 push 方法
+ arrayMethods['push'] = function (...args) {
+    const ob = this.__ob__
+    // 获取原始方法
+    const original = arrayProto['push']
+    // 执行原始方法
+    const result = original.apply(this, args)
+    // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+    ob.dep.notify()
+    // 同时对新添加的数据也进行响应式化
+    ob.observeArray(args)
+ }

class Observer {
    constructor(value) {
        // 省略 ...
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
+            this.protoAugment(value, arrayMethods)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {

        }
    }

+    protoAugment(target, src) {
+        // 覆盖原型对象
+        target.__proto__ = src
+    }
    // 省略 ...
}

至此我们通过重构就实现了 Observer 类,这一节没有涉及到发布订阅模式和数据响应式相关的内容,只是一下编程技巧的内容,而之所以有这一节是为了我们的代码结构更贴近 Vue2 源码的实现。通过这一节的实现,我们也可以知道发布订阅模式是如何在 Vue2 数据响应式中实现的。

那么从发布订阅模式的角度来看所谓 Observer 类,其实是一个发布者或者叫被观察者,虽然它的类命叫 Observer 翻译过叫观察者,但从观察者模式的角度来看,它不能叫观察者,因为它并没有向哪个被观察者进行订阅操作。但它又不是一个纯粹的发布者,它主要作用是将数据对象转换为响应式对象,使得当数据发生变化时能够触发相应的更新操作,它同时通过递归遍历数据对象中的所有属性,为每个属性设置 gettersetter 来实现数据的劫持和监听,从功能上来看它是在观察自己的属性。

从代码结构上来看,它的发布订阅模式的实现跟传统标准的发布订阅模式的结构还是存在很大差别的,但正如我们上篇文章中所说的那样,我们并不能从代码结构上去判断是否属于什么模式,而是从代码意图去判断。

订阅者中介实现

我们知道 Vue2 中的订阅者是通过 Watcher 类来实现的,也就是我们上一篇文章中所讲的订阅者中介

我们先实现一个订阅者中介:

class Watcher {
    constructor(fn) {
        // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
        this.getter = fn
        // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
        this.get()
    }
    get() {
        // 通过 Dep.target 来设置当前的订阅者是谁
        Dep.target = this
        this.getter()
        Dep.target = null
    }
    // 接受发布者通知的更新方法
    update() {
        this.getter()
    }
}

我们这里设计的订阅者中介类的实现跟我们上一篇中的订阅者中介类的实现,最大的不同就是,这里的设计需要在初始化的时候就要去执行一次订阅者所传的参数函数,因为在 web 应用应用中,应用需要初始化。

我们在实例化订阅者的时候,就把该订阅者需要做的事情当成参数传进去:

new Subscriber(() => {
    console.log(`郭靖收到的公众号文章:${weChatOfficialAccount.article}`)
}) 

同时 Dep 类的 notify 方法也需要修改一下:

class Dep {
  // 通知订阅者
  notify() {
-    this.subs.forEach(sub => sub())
+    this.subs.forEach(sub => sub.update())
  }
}

defineReactive 函数也需要修改:

- // 订阅者中间变量
- let activeEffect
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 省略 ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
-            if (activeEffect){
+            if (Dep.target){
-                dep.addSub(activeEffect)
+                dep.addSub(Dep.target)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.dep.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(Dep.target) 
            }
            return val
        },
        set(newVal) {
            // 省略 ...
        }
    })
}

这样我们就可以很方便通过一下进行测试了:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}
observe(weChatOfficialAccount)
// 初始化订阅者
new Watcher(() => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
})

// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

小结

Dep 和 Watcher 互为订阅者

我们通过上文知道 Dep 类其实是一个消息代理或者叫事件总线,而 Watcher 则是一个订阅者,但我们前面也留了一个引子,说它们还有一层关系,就是互为订阅者。那么既然 DepWatcher 互为订阅者,也就是说它们其实也是一个发布者的角色。所以现实系统中的应用远远要比我们所学的所谓标准模式要复杂得多。

我们知道在 Vue2 中可以通过 Options 选项设置 watcher 来实现对响应式数据的监听,其实还可以通过 this.$watcher() 来实现对响应式数据的监听,使用方法都是一样的,唯一的不同就是 this.$watcher() 会返回一个函数,这个函数的作用就是停止对响应式数据的监听。

那么要实现停止对响应式数据的监听则需要知道那些 Dep 记录了当前的 Watcher,我们就需要通知那些 Dep 取消订阅当前的 Watcher。那么要实现这个功能,就需要 Watcher 也进行记录自己订阅了哪些 Dep,当取消对响应式数据的监听的时候,就从当前 Watcher 的订阅记录里去通知那些 Dep 取消自己的订阅。

比如说我们在 Vue2 当中有这么一个功能:

const unwatch = this.$watch(function(){
      return this.name + this.age + this.sex
    }, function(newValue, oldValue){
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

我们接下来去实现这个功能,也就是实现 Vue2 的 $watcher() 功能。以下是 Vue2 官网对 $watcher API 的一些参数和功能的介绍。

  • [vm.$watch( expOrFn, callback, options )]

  • 参数

    • {string | Function} expOrFn

    • {Function | Object} callback

    • {Object} [options]
      
      • {boolean} deep
      • {boolean} immediate
  • 返回值{Function} unwatch

  • 用法

    观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。

这个 $watcher 的功能从发布订阅模式的角度可以看成是,第一个参数是订阅者要做的事情,第二个参数是在做完事情后拿到结果再通过第二参数输出结果,而且是每次所依赖的响应式数据发生变化后都需要执行第二个参数函数,输出新的结果。

那么我们先实现下面的功能:

new Watcher(function() {
   return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
   console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
})

我们对 Watcher 类做以下修改:

class Watcher {
-    constructor(fn) {
+    constructor(fn, cb) {
+        this.cb = cb
       // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
       this.getter = fn
       // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
-        this.get()
+        this.value = this.get()
   }
   get() {
+        let value
       // 通过 Dep.target 来设置当前的订阅者是谁
       Dep.target = this
+        value = this.getter()
       Dep.target = null
+        return value
   }
   // 接受发布者通知的更新方法
   update() {
     // 获取新值
-      this.getter()
+      const value = this.getter()
+      // 设置旧值
+      const oldValue = this.value
+      // 更新值
+      this.value = value
+      if (this.cb) {
+        // 因为是用户写的,有可能存在错误
+        try {
+          this.cb(value, oldValue)
+        } catch(err) {
+          throw err
+        }
+      }
   }
}

我们进行测试:

weChatOfficialAccount.article = '第一次更新'
weChatOfficialAccount.article = '第二次更新'

测试结果如下:

C04.png

我们看到正确打印了结果。

有了以上的基础功能,接下来我们就很容易实现 $watcher API,代码如下:

function $watcher(expOrFn, cb) {
    const watcher = new Watcher(expOrFn, cb)
}

我们知道 $watcher API 是有很多配置选项的,也就是第三个参数,比如立即执行回调就是通过第三个参数配置 immediatetrue 来实现的,下面我们也来实现它:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

在 Vue2 中 Watcher 实例是分为系统的 Watcher 和用户的 Watcher 的,像我们在组件里面通过配置 watcher,就是用户的 Watcher,怎么体现区分呢?我们下面来设置,其实也很简单:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
+    // 设置用户级的 Watcher
+    options.user = true
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

接着修改 Watcher 类:

class Watcher {
-    constructor(fn, cb){
+    constructor(fn, cb, options) {
+      if (options) {
+          this.user = !!options.user
+      } else {
+          this.user = false
+      }
      // 省略...
    }
    update() {
      // 省略...
-      if (this.cb) {
+      if (this.user) {
        // 省略...
      }
    }
}

修改也很简单,从上面的修改可以看得出,只有用户级的 Watcher 才会在更新的时候执行回调函数。

接下来我们测试立即回调功能:

$watcher(function() {
    return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
    console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
}, { immediate: true })

我们可以看到初始化的时候,就立即执行回调函数了。

C05.png

立即执行,旧值为 undefined,符合如期。

实现了上面的基础部分的功能,我们就可以实现重要的功能了,取消订阅。

function $watcher(expOrFn, cb, options) {
     // 省略...
+    // 返回一个可以取消订阅的函数
+    return function unwatchFn () {
+      watcher.teardown()
+    }
}

我们这里通过 Watcher 实例对象的 treardown 方法去取消订阅,其实是要去通知那些记录了该 Watcher 的 Dep 去删除其记录中的该 Watcher。那么我们怎么知道哪些 Dep 记录该 Watcher 呢?所以我们就需要在 Watcher 中记录其订阅了的 Dep。从发布订阅模式的角度来说就是 Dep 要对 Watcher 进行订阅,Dep 是订阅者,Watcher 是发布者,而我们之前是 Watcher 对相关的属性的 Dep 进行订阅,Watcher 是订阅者,相关属性的 Dep 是发布者。

我们首先对 Watcher 实现发布订阅的功能,代码迭代如下:

class Watcher {
    constructor(fn, cb, options) {
        // 省略...
+        this.deps = []
        // 省略...
    }
+    addDep(dep) {
+        this.deps.push(dep)
+    }
+    // 取消订阅
+    teardown() {
+        let i = this.deps.length
+        while (i--) {
+            this.deps[i].removeSub(this)
+        }
+    }
}

我们这里的取消订阅是通过 Watcher 所记录的 Dep 实例对象去执行 Dep 上的 removeSub 方法去把自己删除,这样将来 Dep 触发更新的时候,就通知不了自己了,也就执行不了 update 方法了。

接下来我们实现 Dep 类上的 removeSub 方法,迭代代码如下:

class Dep {
  // 省略..

+  // 取消订阅
+  removeSub (sub) {
+    // 找到需要取消的订阅者
+    const index = this.subs.indexOf(sub)
+    if (index > -1) {
+        // 删除订阅者
+        this.subs.splice(index, 1)
+    }
+  }
  // 省略...
}

我们可以看到 Dep 类中的取消订阅功能,跟普通发布订阅中的取消订阅功能是一样的。

我们前面已经实现了 DepWatcher 的订阅,那么接下来就是 Watcher 怎么对 Dep 进行订阅了。我们知道不管是什么数据类型都是在 getter 中进行依赖收集的,所以要实现 WatcherDep 的订阅,也要从 getter 开始。我们在 getter 里面可以通过 Dep.target 获取到当前的 Watcher,也可以获取到当前属性对应的 Dep 实例对象,那么就可以互相添加订阅者了。

代码迭代如下:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
                dep.addSub(Dep.target)
+                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                Dep.target.addDep(dep)
                if (childOb){
                    childOb.dep.addSub(Dep.target)
+                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                    Dep.target.addDep(childOb.dep)
                }
            }
            return val
        },
        // 省略...
    })
}

接下来我们就可以进行测试了:

const unwatch = $watcher(function(){
    return weChatOfficialAccount.article + weChatOfficialAccount.author
}, function(newValue, oldValue) {
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

console.log('会打印新值旧值')
weChatOfficialAccount.article = 'co'
console.log('会打印新值旧值')
weChatOfficialAccount.author = 'byte'
console.log('不会打印新值旧值')
weChatOfficialAccount.article = 'cobyte'

我们发现如期打印了我们期待的结果:

C06.png

我们上面在 getter 中对 Dep 和 Watcher 进行相互订阅的操作,还可以进行优化一下,让代码更优雅。

class Dep {
    // 省略...
+  // 通过 depend 方法进行依赖收集
+  depend() {
+    if (Dep.target) {
+      // 在 Dep 中进行 Watcher 
+      Dep.target.addDep(this)
+    }
+  }
  // 省略...
}

接着在 Watcher 中调用 Dep 的方法添加自己

class Watcher {
    // 省略...
    addDep(dep) {
        this.deps.push(dep)
+        // 调用 Dep 实例的添加订阅方法添加自己
+        dep.addSub(this)
    }
    // 省略...
}

接着我们修改 getter 中代码:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
-                dep.addSub(Dep.target)
-                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                Dep.target.addDep(dep)
+                dep.depend()
                if (childOb){
-                    childOb.dep.addSub(Dep.target)
-                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                    Dep.target.addDep(childOb.dep)
+                    childOb.dep.depend()
                }
            }
            return val
        },
        // 省略...
    })
}

经过我们的重构,getter 中的依赖收集相关代码变得清晰多了。

总结

从发布订阅模式的角度来理解 Vue 的数据响应式原理,就是发布订阅模式的具体运用的过程。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

前端架构演进:基于AST的常量模块自动化迁移实践

前端架构演进:基于AST的常量模块自动化迁移实践

从“硬编码”到“全自动”:一次常量模块重构的工程化探索

在这里插入图片描述

一、背景与痛点

在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。

在我们的项目中,常量定义最初采用了一种集中式导出方式:

// src/constants/Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
  // ... 数十个常量
}

而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:

// 旧代码片段
import { Constants_expert } from '@/locales';

if (status === Constants_expert.default.STATUS_APPROVED) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/locales 的导出,极易遗漏。

为了彻底解决这些问题,我们决定进行两项重构:

  • 常量文件:将 export default { ... } 拆解为多个 export const,实现具名导出。
  • 业务代码:将所有 Constants_xxx.default.PROP 替换为直接使用 PROP,并添加对应的具名导入。

项目涉及 30+ 个常量文件200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。

本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。


二、整体方案设计

整个迁移流程分为两个独立的阶段,必须严格按顺序执行

graph LR
    A[常量文件] -->|transform-const.js| B[具名导出常量]
    C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
    B -.->|提供导出变量列表| D
  • 第一阶段:扫描 src/constants/*.ts,将每个文件中的 export default 对象转换为多个 export const 语句。
  • 第二阶段:扫描 src/views 下的所有 .vue.ts.js 文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。

两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。


三、第一阶段:常量文件格式转换(transform-const.js)

3.1 核心目标

将这样的代码:

// Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
}

转换为:

export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;

同时保留所有注释(文件头注释、属性上方注释等)。

3.2 AST 操作流程

我们使用 Babel 全家桶完成这次转换:

  • @babel/parser:将源码解析为 AST
  • @babel/traverse:遍历和修改 AST 节点
  • @babel/types:构建新的 AST 节点
  • @babel/generator:将 AST 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

traverse(ast, {
  ExportDefaultDeclaration(path) {
    if (t.isObjectExpression(path.node.declaration)) {
      defaultExportObject = path.node.declaration;
      path.remove(); // 移除整个 export default
    }
  },
});

defaultExportObject.properties.forEach((prop) => {
  const propName = prop.key.name;
  const propValue = prop.value;
  const exportDecl = t.exportNamedDeclaration(
    t.variableDeclaration('const', [
      t.variableDeclarator(t.identifier(propName), propValue),
    ])
  );
  // 保留注释
  if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
  exportConstNodes.push(exportDecl);
});

3.3 易错点与防御

  • 非对象默认导出:某些常量文件可能已经是 export const 格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。
  • 属性名非标识符:如果对象的键是字符串字面量(如 "my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。
  • 文件备份:转换前自动创建 .bak 文件,防止误操作导致代码丢失。

四、第二阶段:业务代码引用迁移(transform-project.js)

这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScriptVue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。

4.1 动态发现常量文件

第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。

function loadAllConstantFiles() {
  const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
  const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }

  for (const filePath of constantFiles) {
    const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
    const exportedNames = new Set();
    traverse(ast, {
      ExportNamedDeclaration(path) {
        if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
          path.node.declaration.declarations.forEach(d => {
            if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
          });
        }
      },
    });
    constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
  }
  return constantMap;
}

这样我们就获得了所有常量文件的“导出变量白名单”。

4.2 识别旧的导入模式

在业务代码中,旧的导入语句通常长这样:

import { Constants_expert, Constants_supplier_portrait } from '@/locales';

我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。

使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:

traverse(ast, {
  ImportDeclaration(path) {
    if (path.node.source.value === OLD_IMPORT_SOURCE) {
      path.node.specifiers.forEach(spec => {
        if (t.isImportSpecifier(spec)) {
          const importedName = spec.imported.name;
          const localName = spec.local.name;
          if (constantMap.has(importedName)) {
            oldLocalToConstantMap.set(localName, importedName);
            shouldRemove = true;
          }
        }
      });
      if (shouldRemove) path.remove(); // 删除整条导入语句
    }
  },
});

4.3 替换成员访问表达式

旧的引用方式有两种常见形态:

  • Constants_expert.default.STATUS_PENDING
  • Constants_expert.STATUS_PENDING(某些早期代码省略了 .default

我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。

通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:

traverse(ast, {
  MemberExpression(path) {
    const root = findRootIdentifier(path.node);
    if (!root) return;
    const localName = root.name;
    if (!oldLocalToConstantMap.has(localName)) return;

    const constantSetName = oldLocalToConstantMap.get(localName);
    const chain = getPropertyChain(path.node);
    let propName = null;
    if (chain.length >= 3 && chain[1] === 'default') {
      propName = chain[2];
    } else if (chain.length >= 2) {
      propName = chain[1];
    }

    if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
      // 记录需要导入的变量
      neededImports.get(constantSetName).add(propName);
      // 替换整个节点为一个简单的标识符
      path.replaceWith(t.identifier(propName));
    }
  },
});

4.4 Vue SFC 的特殊处理

Vue 单文件组件包含 <template><script><script setup> 等多个块,需要分别处理。

Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script><script setup>,需要分别处理并合并导入变量。

Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。

但正则替换有几个坑:

  • 常量名可能包含正则元字符(如 +.),需要转义。
  • 需要同时匹配 .default 和没有 .default 的情况。
  • 替换后要记录使用了哪些变量,以便生成导入。

我们构建动态正则:

const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');

匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。

4.5 生成新的导入语句

经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expertConstants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。

因此,我们必须先检测冲突:

const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
  for (const v of vars) {
    if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
      throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
    }
    varToConstMap.set(v, constName);
  }
}

如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:

const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');

最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。


五、技术难点与解决方案

5.1 路径别名动态转换

最初我们使用 path.relative 然后替换 ../@/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。

5.2 多个 <script> 块的替换位置

Vue SFC 可能同时存在 <script><script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offsetloc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。

5.3 模板正则的精确匹配

模板中可能包含字符串字面量,例如:

<div :title="'Constants_expert.default.STATUS'"></div>

我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。

5.4 保留代码格式与注释

AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate{ retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。


六、工程化落地与自动化流程

为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:

# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants

# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js

# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js

# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test

# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"

两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:

# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done

七、成果与思考

通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:

  • Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
  • 可读性提升:代码中直接使用 STATUS_PENDING 而非冗长的 Constants_expert.default.STATUS_PENDING
  • 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。

更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。

未来拓展方向

  • 支持更复杂的引用模式:如 Constants_expert['default'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章

Vue 中实现文字滚动(跑马灯)的多种方式

在 Vue 里实现文字滚动(跑马灯) ,最常用、最稳的就两种:

  1. CSS 动画纯实现(简单、性能好)
  2. JS 控制滚动(可暂停、可控制速度)

下面直接给你可复制粘贴的 Vue 组件代码


方式1:纯 CSS 跑马灯(推荐)

Marquee.vue

<template>
  <div class="marquee-wrap">
    <div class="marquee-content">
      {{ text }}
    </div>
  </div>
</template>

<script setup>
const text = '这里是需要滚动的文字,Vue 跑马灯效果,从右向左无限滚动~';
</script>

<style scoped>
.marquee-wrap {
  width: 100%;
  overflow: hidden;
  white-space: nowrap;
  background: #f5f5f5;
  padding: 8px 16px;
  border-radius: 8px;
}

.marquee-content {
  display: inline-block;
  animation: marquee 15s linear infinite;
}

@keyframes marquee {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(-100%);
  }
}
</style>

特点:

  • 一行无限滚动
  • 无 JS,性能最好
  • 鼠标悬浮暂停版往下看

方式2:hover 暂停 + 无缝滚动(更常用)

<template>
  <div class="box">
    <div class="marquee" @mouseenter="pause" @mouseleave="play">
      <div class="text" :style="{ animationPlayState }">
        {{ content }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const content = 'Vue3 无缝跑马灯,鼠标移入暂停,移出继续滚动~';
const animationPlayState = ref('running');

const pause = () => {
  animationPlayState.value = 'paused';
};
const play = () => {
  animationPlayState.value = 'running';
};
</script>

<style scoped>
.box {
  width: 100%;
  overflow: hidden;
  background: #f9f9f9;
  padding: 10px;
  border-radius: 6px;
}
.marquee {
  white-space: nowrap;
}
.text {
  display: inline-block;
  animation: move 12s linear infinite;
}
@keyframes move {
  0% { transform: translateX(100%); }
  100% { transform: translateX(-100%); }
}
</style>

方式3:真正无缝(无空白,首尾衔接)

适合公告、长文本:

<template>
  <div class="wrap">
    <div class="box">
      <span class="txt1">{{ text }}</span>
      <span class="txt2">{{ text }}</span>
    </div>
  </div>
</template>

<script setup>
const text = '这里是真正无缝跑马灯,没有空白间隔,一直循环滚动';
</script>

<style scoped>
.wrap {
  width: 100%;
  overflow: hidden;
  background: #fff8e1;
  padding: 8px 0;
}
.box {
  display: flex;
  width: max-content;
  animation: scroll 10s linear infinite;
}
.txt1, .txt2 {
  padding: 0 20px;
}
@keyframes scroll {
  0% { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}
</style>

方式4:JS 控制滚动(可变速、可停止)

<template>
  <div class="box" style="overflow: hidden">
    <div class="text" :style="{ marginLeft: `${left}px` }">
      JS 控制跑马灯,可随时停止、加速、减速
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const left = ref(300);
let timer = null;

onMounted(() => {
  timer = setInterval(() => {
    left.value -= 1;
    if (left.value < -300) left.value = 300;
  }, 20);
});
onUnmounted(() => clearInterval(timer));
</script>

Vue 项目必备:10 个高频实用自定义指令,直接复制即用(Vue2 / Vue3 通用)

在实际开发中,很多重复逻辑(权限控制、防抖点击、图片懒加载、文本复制等)用自定义指令来做最优雅,不污染组件、不写冗余代码、复用性极强。

今天整理了 10 个企业级最常用的 Vue 自定义指令,Vue2 / Vue3 都能跑,复制到项目里直接用,建议收藏进你的工具库。


1. v-permission 按钮权限控制(后台系统必用)

根据权限码控制按钮显隐,后端返回权限列表直接用。

// directives/permission.js
import { useUserStore } from '@/stores/user'

export default {
  mounted(el, binding) {
    const { permissions } = useUserStore()
    const value = binding.value
    if (!value) return
    // 无权限则移除元素
    if (!permissions.includes(value)) {
      el.parentNode?.removeChild(el)
    }
  }
}

使用:

<button v-permission="'user:add'">添加用户</button>

2. v-debounce 防抖点击(搜索/提交防重复)

// directives/debounce.js
export default {
  mounted(el, binding) {
    const { func, delay = 300 } = binding.value
    let timer = null
    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => func(), delay)
    })
  }
}

使用:

<button v-debounce="{ func: handleSearch, delay: 500 }">搜索</button>

3. v-throttle 节流指令(滚动/防狂点)

// directives/throttle.js
export default {
  mounted(el, binding) {
    const { func, delay = 500 } = binding.value
    let lastTime = 0
    el.addEventListener('click', () => {
      const now = Date.now()
      if (now - lastTime >= delay) {
        func()
        lastTime = now
      }
    })
  }
}

4. v-copy 一键复制文本

// directives/copy.js
export default {
  mounted(el, binding) {
    el.addEventListener('click', () => {
      const text = binding.value
      navigator.clipboard.writeText(text).then(() => {
        ElMessage.success('复制成功')
      })
    })
  }
}

使用:

<span v-copy="orderNo">复制订单号</span>

5. v-longpress 长按指令

// directives/longpress.js
export default {
  mounted(el, binding) {
    const { func, time = 1000 } = binding.value
    let timer = null
    el.addEventListener('mousedown', () => {
      timer = setTimeout(() => func(), time)
    })
    el.addEventListener('mouseup mouseleave', () => clearTimeout(timer))
  }
}

6. v-input-number 仅允许输入数字(支持小数)

// directives/number.js
export default {
  mounted(el) {
    const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
    input.addEventListener('input', () => {
      input.value = input.value.replace(/[^\d.]/g, '')
      const arr = input.value.split('.')
      if (arr.length > 2) input.value = arr[0] + '.' + arr[1]
    })
  }
}

使用:

<el-input v-input-number v-model="num" />

7. v-lazy 图片懒加载(性能优化)

// directives/lazy.js
export default {
  mounted(el, binding) {
    const observer = new IntersectionObserver(([{ isIntersecting }]) => {
      if (isIntersecting) {
        el.src = binding.value
        observer.unobserve(el)
      }
    })
    observer.observe(el)
  }
}

使用:

<img v-lazy="imgUrl" alt="" />

8. v-draggable 元素拖拽

// directives/drag.js
export default {
  mounted(el) {
    el.style.cssText += ';position:fixed;cursor:move;'
    el.addEventListener('mousedown', (e) => {
      const x = e.clientX - el.offsetLeft
      const y = e.clientY - el.offsetTop
      const move = (e) => {
        el.style.left = e.clientX - x + 'px'
        el.style.top = e.clientY - y + 'px'
      }
      document.addEventListener('mousemove', move)
      document.addEventListener('mouseup', () => {
        document.removeEventListener('mousemove', move)
      }, { once: true })
    })
  }
}

9. v-watermark 页面水印(防截图)

// directives/watermark.js
export default {
  mounted(el, binding) {
    const text = binding.value || '内部资料'
    const canvas = document.createElement('canvas')
    canvas.width = 200
    canvas.height = 150
    const ctx = canvas.getContext('2d')
    ctx.font = '14px Arial'
    ctx.fillStyle = 'rgba(0,0,0,0.1)'
    ctx.rotate(-0.2)
    ctx.fillText(text, 20, 50)
    el.style.background = `url(${canvas.toDataURL()}) repeat`
  }
}

10. v-auto-height 自适应高度(表格/弹窗常用)

自动计算高度,避免滚动条错乱

// directives/autoHeight.js
export default {
  mounted(el) {
    const resize = () => {
      const top = el.getBoundingClientRect().top
      el.style.height = window.innerHeight - top - 20 + 'px'
    }
    resize()
    window.addEventListener('resize', resize)
    el._resize = resize
  },
  unmounted(el) {
    window.removeEventListener('resize', el._resize)
  }
}

统一注册(Vue3)

directives/index.js 统一导出:

import permission from './permission'
import debounce from './debounce'
// ...其他

export default {
  install(app) {
    app.directive('permission', permission)
    app.directive('debounce', debounce)
  }
}

main.js 引入:

import directives from '@/directives'
app.use(directives)

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

同样做缩略图,为什么别人又快又稳?踩过无数坑后,我总结出前端缩略图实战指南

最近有个项目需要用到上传图片,然后在列表页回显一下图片。

需求这边还想着要不要做一个瀑布图,但是做好以后图片量太多而且图片太大,导致展示效果并不好。

image.png

尤其是放在首屏上,长时间的白屏。

核心问题是:用户上传的图片过大,不仅导致页面加载缓慢、消耗过多带宽,而且还影响了服务器存储。

所以缩略图就成了最优解。

既不影响视觉展示,又能大幅降低资源消耗。

先明确结论:业界主流做法是什么?

很多人会陷入“非此即彼”的误区,纠结到底该前端还是后端生成缩略图。

但实际上,生产环境中最主流、最稳妥的架构是:前端做预览缩略图 + 后端/云存储做正式缩略图

两者分工配合,兼顾用户体验、性能和安全性。这也是目前大厂主流的实现方案。

简单来说:

  • 前端负责"":用户上传图片后,立即生成缩略图用于页面预览,提升交互体验;

  • 后端/云存储负责""和"生成":存储用户上传的原图,同时生成多尺寸正式缩略图,供页面正式展示。

前端缩略图实现(4种方案附代码)

前端生成缩略图的核心目的是"预览"和"减少上传流量",核心技术依赖 Canvas 绘图缩放createImageBitmap API

方案1:Canvas

这是前端生成缩略图的"标准方案",兼容所有浏览器(包括IE10+),零依赖,无需引入任何第三方库,是生产环境中最常用的方案。

核心原理

读取用户上传的图片文件 → 用Image对象加载图片 → 绘制到Canvas并按比例缩小 → 导出为缩略图Blob/Base64。

完整代码

/**
 * 生成图片缩略图
 * @param {File} file - 用户上传的图片文件(input[type="file"]获取)
 * @param {Number} maxWidth - 缩略图最大宽度(默认300px)
 * @param {Number} maxHeight - 缩略图最大高度(默认300px)
 * @param {Number} quality - 图片质量(0~1,1为最高质量,默认0.8)
 * @returns {Promise<Blob>} 缩略图文件(可直接上传或预览)
 */
async function createThumbnail(file, maxWidth = 300, maxHeight = 300, quality = 0.8) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.onload = () => {
      URL.revokeObjectURL(img.src);
      let { width, height } = img;
      // 如果原图尺寸超过设定的最大尺寸,进行等比缩小
      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height);
        width *= ratio;
        height *= ratio;
      }
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0, width, height);
      canvas.toBlob(
        (blob) => resolve(blob), // 成功回调,返回缩略图Blob
        file.type || 'image/jpeg', // 保持原图格式,无格式则默认jpeg
        quality // 图片质量
      );
    };
    img.onerror = () => reject(new Error('图片加载失败,请检查文件格式'));
  });
}

// 使用
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return; // 未选择文件,直接返回

  try {
    // 生成300x300的缩略图(可根据需求调整尺寸和质量)
    const thumbBlob = await createThumbnail(file, 300, 300, 0.7);
    
    // 场景1:预览缩略图(页面展示)
    const thumbUrl = URL.createObjectURL(thumbBlob);
    document.querySelector('#preview').src = thumbUrl;

    // 场景2:将缩略图上传到服务器(搭配FormData)
    const formData = new FormData();
    // 第三个参数是缩略图文件名,可自定义
    formData.append('thumbnail', thumbBlob, `thumbnail_${Date.now()}.jpg`);
    // 发起上传请求(实际项目中替换为自己的接口地址)
    const response = await fetch('/api/upload/thumbnail', {
      method: 'POST',
      body: formData
    });
    const result = await response.json();
    console.log('缩略图上传成功:', result);
  } catch (error) {
    console.error('缩略图生成/上传失败:', error);
  }
});

方案2:createImageBitmap

如果项目不考虑兼容性问题,那么这个方案比Canvas原生方案更高效。

它支持直接解析File/Blob对象,无需创建Image对象,加载速度更快。

而且还能在Web Worker中使用(避免阻塞主线程),适合处理大尺寸图片。

完整代码

/**
 * 高性能缩略图生成(createImageBitmap方案)
 * @param {File} file - 用户上传的图片文件
 * @param {Number} maxWidth - 缩略图最大宽度(默认300px)
 * @param {Number} maxHeight - 缩略图最大高度(默认300px)
 * @returns {Promise<Blob>} 缩略图Blob文件
 */
async function createThumbnailFast(file, maxWidth = 300, maxHeight = 300) {
  try {
    // 直接解析File对象,生成ImageBitmap(比Image对象更快)
    const bitmap = await createImageBitmap(file);
    
    // 计算等比缩放尺寸(和Canvas方案逻辑一致)
    let { width, height } = bitmap;
    if (width > maxWidth || height > maxHeight) {
      const ratio = Math.min(maxWidth / width, maxHeight / height);
      width *= ratio;
      height *= ratio;
    }

    // 创建Canvas并绘制
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(bitmap, 0, 0, width, height);

    // 释放ImageBitmap内存(优化性能)
    bitmap.close();

    // 导出为Blob
    return new Promise((resolve) => {
      canvas.toBlob(resolve, file.type || 'image/jpeg', 0.8);
    });
  } catch (error) {
    console.error('高性能缩略图生成失败:', error);
    throw error;
  }
}

// 使用方式(和Canvas方案一致,直接替换函数名即可)
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const thumbBlob = await createThumbnailFast(file, 300, 300);
  // 预览/上传逻辑和上面一致,此处省略
});

方案3:browser-image-compression插件

如果懒得手写,可以使用browser-image-compression插件。Github地址: github.com/vitaly-z/br…

这是一个轻量级前端图片压缩库,自动处理图片缩放、压缩、格式转换,零配置即可使用,还能解决图片旋转(Exif orientation)等常见问题。

完整代码

// 安装依赖
// npm install browser-image-compression --save

// 导入
import imageCompression from 'browser-image-compression';

/**
 * 基于第三方库的缩略图生成
 * @param {File} file - 用户上传的图片文件
 * @returns {Promise<Blob>} 缩略图文件
 */
async function createThumbnailWithLib(file) {
  // 配置选项(灵活调整,无需手写逻辑)
  const options = {
    maxSizeMB: 0.1, // 缩略图最大体积(100KB,超过会自动压缩)
    maxWidthOrHeight: 300, // 缩略图最大尺寸(宽/高不超过300px)
    useWebWorker: true, // 使用Web Worker,避免阻塞主线程
    useWebp: true, // 导出为WebP格式(比JPG小30%+,质量无损失)
    initialQuality: 0.8 // 初始压缩质量
  };

  try {
    // 直接调用库方法,自动生成缩略图
    const thumbBlob = await imageCompression(file, options);
    console.log('库生成缩略图成功,大小:', thumbBlob.size);
    return thumbBlob;
  } catch (error) {
    console.error('库生成缩略图失败:', error);
    throw error;
  }
}

// 使用方式(和前面一致)
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const thumbBlob = await createThumbnailWithLib(file);
  // 预览/上传逻辑省略
});

当然,还有个偷懒的方法,直接给图片一个最大宽高,让他看起来像缩略图,不过仍然无法解决加载速度的问题。

后端/云存储方案

前面说过,前端生成的缩略图主要用于"预览",而"正式缩略图"(用于页面正式展示、多端适配),必须由后端或云存储生成——这是生产环境的标准做法,也是大厂通用架构。

为什么不能全靠前端生成正式缩略图?

很多兄弟会疑惑,既然前端能生成缩略图,为什么还要麻烦后端?

核心原因主要有4点:

  1. 可靠性不足:不同浏览器、不同设备(手机/PC)的Canvas渲染效果存在差异,可能导致缩略图模糊、变形,甚至生成失败。

  2. 安全性风险:前端传什么后端存什么,无法验证缩略图的真实性和合法性,可能存在恶意文件上传风险,甚至出现脚本。

  3. 多尺寸需求:一个项目通常需要多种尺寸的缩略图(如列表图300x300、头像图100x100、详情图1080x720),前端不可能生成所有尺寸,且维护成本极高。

  4. 性能与成本:云存储(如阿里云OSS、腾讯云COS、七牛云)的图片处理功能几乎免费,且速度极快,比自己写后端压缩代码更省资源、更稳定。

主流实现方式:云存储自动生成

目前大厂最常用的方式,是将用户上传的原图存储到云存储(如阿里云OSS)。

云存储会自动生成多种尺寸的缩略图,前端只需通过URL参数即可获取对应尺寸的缩略图,无需后端额外开发。

以阿里云OSS为例(实战示例)

  1. 用户上传原图到OSS,获得原图URL:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg

  2. 前端直接通过URL参数,获取不同尺寸的缩略图(无需后端干预):

  • 300x300缩略图:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_300,h_300

  • 100x100头像图:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_100,h_100,m_fixed

  • WebP格式缩略图(更小):https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_300/format,wewebp

其中,x-oss-process=image/resize是OSS的图片缩放参数,还支持裁剪、旋转、加水印等功能,详细参数可参考阿里云OSS官方文档。

如果没有使用云存储,也可以通过后端代码生成缩略图(如Node.js、Java),核心逻辑和前端Canvas类似,都是"读取原图→缩放→保存"。

如果是Node的后端推荐尝试一下sharp库:sharp(originalPath).resize(width, height, {fit: 'cover',position: 'center'}).toFile(thumbnailPath);

总结

个人推荐:

前端负责“预览”,后端/云存储负责“正式生成”,用云存储自动生成多尺寸缩略图(生产首选)。

不过生产状况下有些事需要注意(个人踩过坑的):

  • 大图片上传建议提示用户压缩,同时建议仅支持上传jpg、png、webp等常见格式。
  • 推荐用户优先使用webp格式文件(图片小)。
  • 图片上传过程中的异常处理记得给足提示。
  • 多端适配记得覆盖全,尤其是支持移动端的项目。

通过 npm 下载node_modules 某个依赖 ;例如 下载 @rollup/rollup-linux-arm64-gnu

方法1:通过 npm 下载

从官方 npm registry 下载

# 1. 下载 .tgz 包
npm pack @rollup/rollup-linux-arm64-gnu

# 2. 或者直接安装
npm install @rollup/rollup-linux-arm64-gnu

# 3. 指定版本
npm pack @rollup/rollup-linux-arm64-gnu@latest
npm pack @rollup/rollup-linux-arm64-gnu@4.9.5

从淘宝镜像下载(更快)

# 设置淘宝镜像
npm config set registry https://registry.npmmirror.com

# 下载
npm pack @rollup/rollup-linux-arm64-gnu
# 或
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

方法2:从 GitHub Releases 下载

# Rollup 官方 GitHub Releases
# https://github.com/rollup/rollup/releases

# 下载特定版本
curl -L -O https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

# 或者查看所有 Assets
# 在 Releases 页面找: rollup-linux-arm64-gnu.tgz

方法3:通过 npm 查看可用版本

# 查看所有版本
npm view @rollup/rollup-linux-arm64-gnu versions

# 查看最新版本
npm view @rollup/rollup-linux-arm64-gnu version

# 查看包信息
npm view @rollup/rollup-linux-arm64-gnu

方法4:下载脚本

#!/bin/bash
# download-rollup-arm64.sh

echo "下载 Rollup ARM64 二进制包..."

# 方法1: 从 npm 下载
echo "方法1: 从 npm 下载..."
npm pack @rollup/rollup-linux-arm64-gnu@latest
ls -lh *.tgz

# 方法2: 从淘宝镜像
echo "方法2: 从淘宝镜像下载..."
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

# 验证下载
echo "验证文件..."
tar -tzf rollup-linux-arm64-gnu-*.tgz 2>/dev/null | head -10

方法5:如果无法下载,安装可选依赖

在你的 package.json中添加:

{
  "optionalDependencies": {
    "@rollup/rollup-linux-arm64-gnu": "^4.9.5"
  }
}

然后运行:

npm install
# 这会尝试安装 ARM64 的二进制包

下载链接(直接访问)

在浏览器中直接访问:

npm 官方

https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

淘宝镜像

https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

GitHub

https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

一个让产品经理和设计师都满意的卡片边框方案

📖 前言

上周设计突然甩过来一张图,问我能不能不切图做出这种效果?

image.png

我蒙了一下第一反应感觉可以,无非就是常规的伪类+渐变。但尝试了一下发现两个致命问题:

1、border-image支持渐变但不支持每条边自定义设置;

2、使用伪类可以解决线的问题但是不能解决圆角问题;

忙乎半天又问了问ai感觉还是实现起来不容易,但随后产品过来又是那老一套。拿着别人家的产品看人家这个如何好看,如何优雅,巴拉巴拉。大有一种:

别人能做,你做不了。

这是不能接受的,于是又潜心研究了下,有了最后的效果。

🎯 需求拆解

先梳理一下具体需求:

需求 描述
位置 卡片左下角 L 形(左边 + 底边)
渐变 左下角颜色最深,向两端渐淡
粗细 视觉上 1px
长度 左边和底边长度大致相等
圆角 适配卡片 20px 圆角
性能 纯 CSS,无图片,无 SVG

看起来简单,做起来全是坑。

🧪 方案探索

方案一:两个伪元素分别画线

最直观的想法:用 ::before 画底部线,::after 画左边线。

scss

.card {
  &::before {
    // 底部线
    background: linear-gradient(90deg, gold, transparent);
  }
  &::after {
    // 左边线
    background: linear-gradient(0deg, gold, transparent);
  }
}

问题:两条线在圆角处有接缝,怎么都对不齐。调整了半天,还是能看到明显的拼接痕迹。

结论:放弃,圆角处无法完美衔接。


方案二:SVG 路径描边

SVG 可以精确控制路径和圆角,效果确实完美。

问题

  • 需要额外 HTML 结构
  • 移动端多一个网络请求或内联代码
  • 响应式适配需要额外处理

结论:能用,但不够优雅,性能也不够极致。


方案三:border-image + 渐变

scss

border-image: radial-gradient(circle at bottom left, gold, transparent) 1;

问题border-image 会覆盖四边,无法只控制左下角。

结论:放弃。


方案四:radial-gradient + mask(最终方案)

经过多次尝试,我发现径向渐变的圆心在左下角时,渐变会自然地向左和向上扩散,形成完美的 L 形。

配合 mask 组合,可以精确控制只显示边框区域,而不是整个渐变圆。

完美解决所有问题!

💻 最终代码

以下是基于vue2的一个组件CornerGradientCard,开箱即用。

但注意基于他的点击事件要使用click.native!!!

<template>
   <div
        :class="`gradient-wrapper ${type} `"
        :style="wrapperStyle"
   >
        <div
            class="gradient-wrapper__content"
            :style="{ borderRadius: radiusRem }"
        >
            <slot></slot>
        </div>
    </div>
  </template>

  <script>
  /** 与 postcss.config.js 中非 vant 资源的 rootValue(75) 一致,设计稿 px → rem */
  const POSTCSS_ROOT_VALUE = 75
  function pxToRem(px) {
    const n = Number(px)
    if (Number.isNaN(n)) return '0rem'
    return `${parseFloat((n / POSTCSS_ROOT_VALUE).toFixed(10))}rem`
  }

  export default {
    props: {
      type: {
        default: '',
        type: String
      },
      radius: {
        default: 12,
        type: Number
      },
      marginBottom: {
        default: 14,
        type: Number
      }
    },
    computed: {
      radiusRem() {
        return pxToRem(this.radius)
      },
      wrapperStyle() {
        const r = this.radiusRem
        return {
          borderRadius: r,
          marginBottom: pxToRem(this.marginBottom),
          '--corner-radius': r
        }
      }
    }
  }
  </script>

  <style lang="scss" scoped>
    $gradient-first-percent: 4%;    // 第一个实色节点百分比
    $gradient-second-percent: 10%;  // 第二个半透明节点百分比
    $gradient-transparent-percent: 30%; // 透明节点百分比

    .gradient-wrapper {
        width: 100%;
        position: relative;
        padding: 0 0 1px 1px;
        box-sizing: border-box;
        background-color: #fff;
        overflow: hidden;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;

        &__content {
            width: 100%;
            position: relative;
            z-index: 3;
            margin: 0 0 1px 1px;
            box-sizing: border-box;
            overflow: hidden;
            background-color: transparent;
        }

        // 渐变边框线(核心)
        &::after {
            content: '';
            position: absolute;
            z-index: 2;
            bottom: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: var(--corner-radius);
            pointer-events: none;
            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            mask-composite: exclude;
            -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            -webkit-mask-composite: xor;
            padding: 1px;
        }

        // 渐变底色(光晕效果)
        &::before {
            content: '';
            position: absolute;
            z-index: 1;
            bottom: 1px;
            left: 1px;
            width: 143px;
            height: 73px;
            border-radius: var(--corner-radius);
            filter: blur(10px);
            pointer-events: none;
        }

        &.WX {
            &::after {
                background: radial-gradient(
                circle at bottom left,
                #B6E2C8  $gradient-first-percent,
                #DFF7EA $gradient-second-percent,
                transparent $gradient-transparent-percent
                );
            }
            &::before {
                background: radial-gradient( 83% 83% at 31% 52%, #F0FBF5 0%, rgba(239,255,246,0) 100%);
            }
        }
}
  </style>

 <CornerGradientCard
    v-for="(item, index) in infoData"
    :key="item.id"
    :id="item.id"
    :type="item.type"
    @click.native="clickItem(item)"
>
    <!-- 卡片内容 -->
</CornerGradientCard>
    

🎨 参数调节指南

参数 位置 作用 移动端建议
padding: 1px .wrapper 边框粗细 保持 1px
4% / 10% / 30% 径向渐变 边框长度 根据卡片大小调整
blur(10px) 光晕 柔和度 移动端 8-12px 较佳
border-radius 全局 圆角 与设计稿保持一致

当然,基于此样式还可以可发出各种变种,例如将渐变等放到常规的右上角,替代常规的卡片标签展示样式。

如果这篇文章对你有帮助,烦请动动发财的小手点个赞~

Vue3 插件开发实战 | 从 0 开发一个全局通知组件(Toast/Message)并发布到 npm

一、为什么要自己写插件?

在日常 Vue3 开发中,我们经常使用 Element Plus 或 Ant Design Vue 的 Message/Toast 组件。但你有没有想过:

  • 这些组件是怎么实现 this.$message.success('操作成功') 这种调用的?
  • 为什么它们不需要在模板里写 <message /> 就能显示?
  • 如何把自己写的组件发布到 npm 供别人使用?

今天,我们就从 0 到 1,手写一个全局通知插件,并发布到 npm,成为真正的“开源贡献者”!

二、插件基础结构

Vue3 插件本质上是一个对象或函数,它暴露一个 install 方法。当使用 app.use(plugin) 时,install 方法会被调用,并接收 app 实例和可选的 options

// 插件基础结构
const MyPlugin = {
  install(app: App, options?: any) {
    // 在这里添加全局功能
    // 1. 注册全局组件
    // 2. 添加全局属性/方法
    // 3. 提供全局指令
    // 4. 注入依赖
  }
}

三、项目初始化

我们使用 Vite 创建一个专门用于插件开发的项目:

npm create vite@latest vue3-toast-plugin -- --template vue-ts
cd vue3-toast-plugin
npm install

为了打包到 npm,我们需要的目录结构如下:

vue3-toast-plugin/
├── src/
│   ├── components/
│   │   └── Toast.vue          # 通知组件本体
│   ├── types/
│   │   └── index.ts           # 类型定义
│   ├── index.ts               # 插件入口
│   └── style.css              # 样式(可选)
├── dist/                      # 打包输出
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md

四、开发 Toast 组件

4.1 组件功能设计

一个成熟的 Toast/Message 组件需要支持:

  • 四种类型:successerrorwarninginfo
  • 可配置:显示时长、是否可关闭、位置、自定义内容
  • 支持链式调用:Toast.success('成功').then(...)
  • 支持手动关闭
  • 多个 Toast 自动堆叠

4.2 组件实现

<!-- src/components/Toast.vue -->
<template>
  <Transition name="toast-fade" @after-leave="handleAfterLeave">
    <div
      v-if="visible"
      class="toast"
      :class="[`toast--${type}`, positionClass]"
      :style="customStyle"
      @mouseenter="pauseTimer"
      @mouseleave="resumeTimer"
    >
      <div class="toast__icon">
        <span v-html="iconMap[type]"></span>
      </div>
      <div class="toast__content">
        <slot>{{ message }}</slot>
      </div>
      <button v-if="closable" class="toast__close" @click="close">×</button>
    </div>
  </Transition>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'

const props = withDefaults(defineProps<{
  message: string
  type?: ToastType
  duration?: number
  closable?: boolean
  position?: ToastPosition
  onClose?: () => void
}>(), {
  type: 'info',
  duration: 3000,
  closable: false,
  position: 'top'
})

const visible = ref(true)
let timer: ReturnType<typeof setTimeout> | null = null

const iconMap = {
  success: '✓',
  error: '✕',
  warning: '⚠',
  info: 'ℹ'
}

const positionClass = computed(() => `toast--${props.position}`)
const customStyle = computed(() => ({})) // 可扩展自定义样式

const startTimer = () => {
  if (props.duration > 0) {
    timer = setTimeout(() => {
      close()
    }, props.duration)
  }
}

const clearTimer = () => {
  if (timer) {
    clearTimeout(timer)
    timer = null
  }
}

const pauseTimer = () => clearTimer()
const resumeTimer = () => startTimer()

const close = () => {
  visible.value = false
}

const handleAfterLeave = () => {
  props.onClose?.()
}

onMounted(() => {
  startTimer()
})
</script>

<style scoped>
/* 样式在下一节给出 */
</style>

4.3 样式设计

为了让通知美观且不影响页面布局,我们使用固定定位(fixed)。

/* src/style.css */
.toast {
  position: fixed;
  z-index: 9999;
  min-width: 200px;
  max-width: 300px;
  padding: 12px 16px;
  border-radius: 8px;
  background: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 14px;
  transition: all 0.3s ease;
}

/* 位置 */
.toast--top {
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
}
.toast--top-right {
  top: 20px;
  right: 20px;
}
.toast--top-left {
  top: 20px;
  left: 20px;
}
.toast--bottom {
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
}
.toast--bottom-right {
  bottom: 20px;
  right: 20px;
}
.toast--bottom-left {
  bottom: 20px;
  left: 20px;
}

/* 类型颜色 */
.toast--success {
  border-left: 4px solid #67c23a;
}
.toast--success .toast__icon {
  color: #67c23a;
}
.toast--error {
  border-left: 4px solid #f56c6c;
}
.toast--error .toast__icon {
  color: #f56c6c;
}
.toast--warning {
  border-left: 4px solid #e6a23c;
}
.toast--warning .toast__icon {
  color: #e6a23c;
}
.toast--info {
  border-left: 4px solid #409eff;
}
.toast--info .toast__icon {
  color: #409eff;
}

.toast__icon {
  font-size: 18px;
  font-weight: bold;
}
.toast__content {
  flex: 1;
  word-break: break-word;
}
.toast__close {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
  padding: 0 4px;
}
.toast__close:hover {
  color: #333;
}

/* 过渡动画 */
.toast-fade-enter-active,
.toast-fade-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
  opacity: 0;
  transform: translateY(-20px) scale(0.9);
}
.toast-fade-leave-to {
  transform: translateY(-20px) scale(0.9);
}

五、插件核心逻辑:管理多个 Toast 实例

为了实现链式调用和多个 Toast 同时存在,我们需要一个管理器(Manager),负责创建、销毁 Toast 实例。

5.1 创建 Toast 管理器

// src/index.ts
import type { App, ComponentPublicInstance } from 'vue'
import { createVNode, render } from 'vue'
import ToastComponent from './components/Toast.vue'
import './style.css'

export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'

export interface ToastOptions {
  message: string
  type?: ToastType
  duration?: number
  closable?: boolean
  position?: ToastPosition
  onClose?: () => void
}

// 存储所有活跃的 Toast 实例
let toastInstances: ComponentPublicInstance[] = []

// 生成唯一 ID(用于区分实例)
let seed = 0

function createToast(options: ToastOptions) {
  const container = document.createElement('div')
  document.body.appendChild(container)
  
  // 创建虚拟节点
  const vnode = createVNode(ToastComponent, {
    ...options,
    onClose: () => {
      // 卸载组件并移除容器
      render(null, container)
      container.remove()
      toastInstances = toastInstances.filter(ins => ins !== vnode.component?.proxy)
      options.onClose?.()
    }
  })
  
  // 渲染组件
  render(vnode, container)
  
  const instance = vnode.component?.proxy
  if (instance) {
    toastInstances.push(instance)
  }
  
  return instance
}

// 核心 API
function show(message: string, options?: Partial<ToastOptions>): Promise<void> {
  return new Promise((resolve) => {
    createToast({
      message,
      type: 'info',
      duration: 3000,
      ...options,
      onClose: () => {
        options?.onClose?.()
        resolve()
      }
    })
  })
}

// 快捷方法
function success(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'success' })
}

function error(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'error' })
}

function warning(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'warning' })
}

function info(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'info' })
}

// 关闭所有 Toast
function closeAll() {
  toastInstances.forEach(instance => {
    if (instance && instance.close) {
      (instance as any).close()
    }
  })
  toastInstances = []
}

// 导出插件对象
export default {
  install(app: App) {
    // 添加全局属性 $toast
    app.config.globalProperties.$toast = {
      show,
      success,
      error,
      warning,
      info,
      closeAll
    }
  }
}

// 单独导出 API(用于按需引入)
export { show, success, error, warning, info, closeAll }

六、Vite 打包配置

为了发布到 npm,我们需要将组件打包成 UMD、ES 模块等多种格式。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'Vue3ToastPlugin',
      fileName: (format) => `vue3-toast-plugin.${format}.js`,
      formats: ['es', 'umd']
    },
    rollupOptions: {
      // 确保外部化处理那些你不希望打包进库的依赖
      external: ['vue'],
      output: {
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: 'Vue'
        },
        assetFileNames: (assetInfo) => {
          if (assetInfo.name === 'style.css') return 'style.css'
          return assetInfo.name || 'assets/[name]-[hash][extname]'
        }
      }
    },
    cssCodeSplit: false, // 将所有 CSS 打包成一个文件
    sourcemap: true,
    emptyOutDir: true
  }
})
// package.json 关键字段配置
{
  "name": "vue3-toast-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/vue3-toast-plugin.umd.js",
  "module": "./dist/vue3-toast-plugin.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/vue3-toast-plugin.es.js",
      "require": "./dist/vue3-toast-plugin.umd.js",
      "types": "./dist/index.d.ts"
    },
    "./style.css": "./dist/style.css"
  },
  "files": ["dist"],
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build && npm run build:types",
    "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist"
  },
  "peerDependencies": {
    "vue": "^3.2.0"
  }
}

七、生成类型声明文件

为了让 TypeScript 用户有良好的体验,我们需要生成 .d.ts 文件。

// tsconfig.json 中开启声明
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist",
    "emitDeclarationOnly": true
  }
}

也可以在 src/index.ts 中导出类型:

// src/index.ts
export type { ToastOptions, ToastType, ToastPosition } from './components/Toast.vue'

八、本地测试

在发布之前,本地测试非常重要。我们可以使用 npm link 或者在项目的 example 目录下测试。

8.1 创建测试项目

# 在插件项目根目录执行
npm link

# 进入测试项目(比如一个新建的 Vue3 项目)
cd ../vue3-test-project
npm link vue3-toast-plugin

8.2 在测试项目中使用

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'

const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <button @click="$toast.success('操作成功!')">成功提示</button>
    <button @click="$toast.error('出错了!')">错误提示</button>
    <button @click="$toast.warning('警告信息')">警告提示</button>
    <button @click="$toast.info('普通消息')">普通提示</button>
  </div>
</template>

九、发布到 npm

9.1 准备工作

  • 注册 npm 账号:www.npmjs.com/
  • 在终端登录:npm login
  • 确保 package.json 中的 name 未被占用

9.2 打包

npm run build

9.3 发布

npm publish --access public

如果版本更新,需要修改 version 后再次发布:

npm version patch  # 1.0.0 -> 1.0.1
npm publish

十、编写 README 文档

一个好的开源项目必须有清晰的文档。README.md 应该包含:

  • 安装方法
  • 基本使用
  • API 文档
  • 示例代码
  • 贡献指南
# vue3-toast-plugin

一个轻量级、高度可定制的 Vue3 全局通知插件。

安装

npm install vue3-toast-plugin

使用

import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'

const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<template>
  <button @click="$toast.success('Hello World!')">Show Toast</button>
</template>

API

$toast.success(message, options)

显示成功提示。

参数 类型 默认值 描述
message string - 提示内容
options object {} 可选配置

Options

属性 类型 默认值 描述
duration number 3000 显示时长(ms),设为0则不自动关闭
closable boolean false 是否显示关闭按钮
position string 'top' 位置,可选值见下方

位置选项toptop-righttop-leftbottombottom-rightbottom-left

License

MIT


## 十一、进阶:支持 Vue3 和 Nuxt3

如果你想让插件同时支持 Vue3 和 Nuxt3,可以增加判断环境自动适配的逻辑。Nuxt3 中插件需要写在 `plugins` 目录下,并提供 `ssr: false` 选项。

```typescript
// nuxt 插件适配示例
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(ToastPlugin)
})

十二、总结

通过本篇文章,我们完成了一个完整的 Vue3 插件从开发、打包、测试到发布的全流程。你不仅掌握了插件的核心机制(installcreateVNoderender),还学会了如何管理多个动态组件实例,以及如何让插件具有良好的 TypeScript 支持。

核心收获

  • Vue3 插件本质:{ install(app) {} }
  • 动态渲染组件:createVNode + render
  • 多个实例管理:维护实例数组,提供关闭/销毁逻辑
  • 打包配置:vite.config.ts 的 build.lib 配置
  • 发布流程:npm login → npm run build → npm publish

现在,你可以骄傲地告诉别人:“我发布过一个 npm 包!” 下次遇到重复的组件需求,不妨考虑封装成插件,提升团队复用效率。🚀


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,让更多人学会 Vue3 插件开发!

我用 AI 撸了个开源"万能预览器":浏览器直接打开 Office、CAD 和 3D 模型

最近一直在深耕 AI Agent 与大模型应用,比如 JitKnow AI 知识库、JitWord协同AI文档、Pxcharts 超级表格,同时也持续在给大家分享 GitHub 上真正能落地、能解决实际问题的优质AI开源项目。

两周前发布了我们开源的文档预览SDK——jit-viewer。

图片

目前在npm上已有 2.1k 的下载量,我们也在持续更新迭代,满足更多开发者的需求。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

在 AI Coding的帮助下,我加速了迭代频率,今天很高兴和大家分享Jit-Viewer最新版本 V1.3.0.

什么是 Jit-Viewer

图片

简单来说,它是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。

图片

过去我们 preview 文件,要么调用微软/Google 的在线接口(有隐私风险),要么自建转换服务(服务器成本高)。

jit-viewer 的思路很直接:把解析能力搬到浏览器端。下面我就和大家分享一下最新版本的更新内容。

1. 支持CAD文件预览功能

图片

事情的起因很简单:工程团队在处理设计稿交付时,总是要在微信里发"麻烦安装个 CAD 看图软件"或者"这个 3D 模型我截图给你"。

作为一位写过无数款文档编辑器、多维表格的开发者,我突然意识到——为什么我们不能在浏览器里直接预览这些文件?

没有安装包,没有兼容性问题,打开链接就能看。这不应该是 2026 年的标配吗?

于是借助 AI, 我在 Jit-viewer sdk中支持了CAD文件的预览。

目前线上已提供demo测试,大家也可以体验测试一下。

2. 支持3D文件预览功能

图片

3D模型预览我们开放了很多能力,比如自动旋转3D模型,对3D模型进行旋转,截图,环境渲染器配置等,基本上开箱即用,开发者不需要关注复杂的3D空间知识,只需要按照我们api文档提供的信息配置,即可实现专业的3D模型预览功能。比如你想在web系统中预览3D商品图,手动调整模型渲染方式,都是用轻松用Jit-Viewer 来实现。大家另一个比较关注的问题可能是性能问题,这里我也做了性能优化的方案:

  1. WebAssembly 承担重计算:CAD 的几何解析、3D 模型的三角化都在 WASM 中完成,避免阻塞主线程
  2. 流式加载:大模型支持 LOD(细节层次)加载,先展示低精度轮廓,再逐步细化
  3. Worker 多线程:解析和渲染分离,UI 永不卡顿

3. 视频预览支持完全可控的视频播放控件

图片

我基本上重写了视频播放器,隐藏了video原生的视频播放控件,利用js api,重写了一个完全可控的视频播放 API 接口。

大家可以通过编程式来控制视频的播放,同时还能配置式控制播放控件的显示逻辑:

图片

那么最近的迭代,有哪些应用场景呢?

jit-viewer 不只是用来"打开文件",在我们实际业务中,它解决了几个实际的痛点:

场景 1:设计评审系统

  • 设计师上传 CAD 图纸,产品经理和开发直接在浏览器标注尺寸,无需安装 AutoCAD
  • 支持测量工具(距离、角度、面积),数据实时同步到多维表格

场景 2:3D 电商展示

  • 用户上传 3D 模型,自动生成 360° 预览,替代传统的图片轮播
  • 支持爆炸图动画,展示产品内部结构

场景 3:BIM 轻量化查看

  • 建筑信息模型在浏览器端轻量化展示,现场工程师用手机就能查看管线碰撞

场景 4:制造业协同

  • 供应商和客户之间传递 3D 模型,不再担心"你用的 SolidWorks 版本和我不兼容"

优缺点分析(客观总结,方便大家参考评估)

✅ 优势:

  • 零服务端成本:纯前端方案,不需要维护昂贵的文件转换服务器
  • 隐私安全:文件不上云,本地解析,适合涉密图纸
  • 极致体验:打开即看,无需等待"转换中"的 loading
  • 插件化架构:按需加载,不用 CAD 功能就不加载 2MB 的 WASM 文件

❌ 局限:

  • 超大文件限制:超过 500MB 的 CAD 文件还是建议用桌面软件,浏览器内存有限
  • 复杂特性缺失:CAD 的图层编辑、3D 的复杂材质节点暂时不支持(仅预览)
  • 移动端性能:3D 模型在低端手机上帧率可能下降,建议开启简化模式

写在最后:独立开发者的 vibe coding 感悟

作为一个连续创业者,我越来越确信:AI 不是替代开发者,而是让独立开发者有了对抗大厂的武器。

jit-viewer 的 CAD 解析模块,如果让我手写 C++ 几何算法,可能需要半年。但在 AI 辅助下,我花了两周就把 OpenCascade 移植到了 WebAssembly。剩下的时间,我可以专注在产品设计和开发者体验上。

这也是我开源这个项目的初衷——降低技术门槛,让更多人能做出专业的工具

如果你在做 PLG(产品驱动增长)的 SaaS 工具,或者有文件预览的需求,欢迎试试 jit-viewer。

遇到问题直接提 Issue,我会亲自回复(没错,目前 issue 响应速度还在 2 小时内 ~)。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

字节/腾讯内部流出!Claude Code 2026王炸玩法!效率暴涨10倍

还在把 Claude 当“高级代码抄写员”?
让它写个函数、改个 bug,一问一答像聊天?

大错特错! 2026 年的 Claude Code 早已进化成自主 AI 开发智能体——
它能自己读项目、自己规划、自己写代码、自己跑测试、自己修 bug,甚至直接操控你的电脑完成全流程开发!

真实案例
字节某团队用 Claude Code 的 Subagents(多智能体) 功能,30 分钟交付一个带用户认证的完整博客系统;
腾讯某工程师靠 Computer Use(电脑直控),让 AI 自动部署项目、复现并修复 UI Bug,全程无需动手。

今天这篇,把 Claude Code 2026 最强玩法、最新功能、隐藏技巧、实战避坑 一次性讲透,看完直接从新手变大神!


一、先看效果:以前累死,现在躺赢

场景 旧方式 Claude Code 新方式
开新对话 重复解释项目架构、规范 Kairos 长期记忆自动加载上下文
部署项目 手动敲命令、点 Vercel Computer Use 自动操作 GUI 完成
复杂开发 一人单干,耗时一天 Subagents 派出 AI 团队并行开发
关机后 任务中断 /schedule 云端继续跑

核心价值
从“人写代码” → “人定目标,AI 自主完成全流程”


二、2026 三大王炸功能(官方 3 月刚上线)

1. Computer Use:AI 直接操控你的 macOS 电脑

这是 AI 编程的革命性突破!

Claude 不再局限于代码文本,而是像人一样操作系统

  • ✅ 自动打开终端、执行 npm install
  • ✅ 截图识别报错弹窗、日志
  • ✅ 点击按钮、填写表单、操作 GUI 工具
  • ✅ 完整 Debug 循环:运行→报错→修改→再运行

实战场景
“帮我部署这个 React 项目到 Vercel”
→ Claude 自动登录 Vercel → 构建 → 部署 → 返回结果
全程你只需要看着!

注意:目前仅支持 macOS + Pro/Max 订阅,需授权安全目录。


2. Subagents:召唤你的 AI 开发团队

一个 Claude 不够用?直接派多个分身并行工作!

  • 前端组:开发页面、写样式
  • 后端组:设计 API、写逻辑
  • 测试组:编写用例、跑测试
  • 安全组:审查漏洞、提建议

效率提升:日常开发 3-5 倍,复杂项目 10 倍+


3. Kairos 长期记忆:AI 永远记住你的项目

解决“金鱼记忆”痛点——跨会话永久记忆 + 自动整理

启用方式
在项目根目录创建 CLAUDE.md,Claude 自动读取并永久记忆。

# 项目规范(示例)
- 技术栈:React 18 + TypeScript
- 代码规范:ESLint + Prettier
- 命名:小驼峰,组件名大写开头
- 禁止:直接修改 src/legacy 目录

下次打开,无需重复解释任何信息


三、硬核对比:为什么 Claude Code 是 2026 最强?

SWE-bench 权威数据(复杂任务通过率)

  • Claude Opus 4.680.8%(行业第一)
  • GPT-5.2:80.0%
  • Cursor(GPT-5 后端):61.3%

Token 效率:省 5.5 倍成本

同样复杂任务:

  • Claude Code:33,000 tokens,零错误
  • Cursor:188,000 tokens,多次报错

适用场景对比

工具 最佳场景 劣势
Claude Code 大型项目、全流程开发、跨文件重构 界面极简,学习曲线略陡
Cursor 前端快速开发、实时补全 复杂项目理解弱
Copilot 单行补全、IDE 集成 自主能力差

结论
做正经开发,选 Claude Code;简单业务,选 Cursor。


四、90% 人不知道的隐藏技巧

1. 7 个必学斜杠命令

/auto          # 全自动模式,AI 自主决策
/debug         # 查看会话状态、工具调用
/skill list    # 查看所有可用技能
/schedule      # 云端定时任务(关机后继续跑)
/context clear # 清理上下文,防“变笨”
/llm           # 切换模型(Sonnet/Opus)

2. 提示词黄金公式

角色+目标+规范+示例+约束

【角色】资深全栈,精通 React+TS
【目标】开发登录页,含表单验证
【规范】Tailwind CSS,小驼峰命名
【示例】参考注册页风格
【约束】响应式,支持移动端

3. Computer Use 安全玩法

  • 开启 Safe Mode:敏感操作需二次确认
  • /allow dir ./my-project 限定工作目录
  • 重要操作前手动审查 AI 计划

五、新手 3 步速成指南(闭眼操作)

第 1 步:安装+配置

npm install -g @anthropic/claude-code
claude login
claude config set model opus-4.6
claude config set computer_use true

第 2 步:必装 Skills 包

# 添加市场
claude market add official
claude market add https://github.com/affaan-m/everything-claude

# 安装神级包
claude install everything-claude   # 60+ 全能技能
claude install kairos-mem          # 长期记忆
claude install computer-use-pro    # 电脑直控增强

第 3 步:最佳工作流

  1. 项目根目录创建 CLAUDE.md
  2. 启动 Claude:claude
  3. 启用技能:/skill use everything-claude
  4. 下达目标:“帮我分析项目,规划开发计划”
  5. 确认后执行:/auto

六、避坑指南:7 个常见错误

错误 正确做法
把 Claude 当聊天机器人 给完整角色、目标、规范
不设权限,放任 AI 操作 严格限定目录,开安全模式
上下文爆炸不清理 定期 /context clear
所有任务用最贵模型 简单用 Sonnet,复杂用 Opus
忽略 Computer Use 安全 仅授权工作目录,手动审查

七、2026 选型指南:谁最适合用?

必选 Claude Code,如果你是:

  • 后端/全栈,做复杂业务系统
  • 架构师,负责大型项目重构
  • 技术团队,追求效率最大化
  • 独立开发者,想一个人顶一个团队

考虑其他工具,如果你是:

  • 纯前端,只做快速页面(选 Cursor)
  • 学生/新手,追求简单易用(选 Cursor)
  • 仅需单行补全(选 Copilot)

结语:AI 编程已进入 2.0 时代

2026 年,AI 编程不再是“辅助”,而是“主力”
Claude Code 代表的自主智能体开发模式,正在彻底重构软件开发流程。

今天学会 Claude Code,不是掌握一个工具,而是抢占 AI 时代的开发效率制高点

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

❌