手把手实现 SWR 缓存策略，彻底告别页面闪烁
前言在开发移动端 H5 项目时，我们经常面临一个棘手的用户体验问题：页面切换时的加载闪烁。当用户在 Tabbar 之间切换时，页面组件通常会重新渲染并触发 API 请求。即使后端响应迅速，用户依然会看到短暂的“白屏”或“骨架屏”，随后内容突然跳出。这种反复的 Loading 状态打断了用户的操作流，体验并不友好。为了解决这一痛点，通常的思路是进行接口缓存。即：将数据缓存在本地，再次进入页面时优先读取缓存。但如果完全依赖缓存，又会导致数据更新不及时。如何在“极速渲染”和“数据新鲜度”之间找到平衡？业界有一个成熟的方案——SWR (Stale-While-Revalidate)。本文将带你在 Vue 3 项目中从零实现一套轻量级的 SWR 缓存机制。什么是 SWR？SWR 全称是 Stale-While-Revalidate，其核心理念可以概括为：“优先返回陈旧数据（Stale），同时后台发起请求（Revalidate），数据返回后更新 UI。”这种策略将网络请求与 UI 渲染解耦，让应用能够瞬间响应用户操作，而不必等待网络请求完成。为什么选择 SWR？相比传统的请求模式，SWR 带来了质的飞跃：极致的性能体验：优先读取缓存，用户切换页面时数据几乎“秒开”，完全感知不到加载过程。消除加载闪烁：UI 始终有内容展示，告别了反复出现的 Loading 动画或骨架屏。减轻服务器压力：配合 TTL（Time To Live，有效期）机制，在有效期内的重复访问可以直接拦截请求，降低后端负载。响应式与自动化：结合 Vue 3 的响应式系统，由于数据是 Ref，一旦后台请求返回新数据，视图会自动更新。核心实现解析我们的实现分为两层架构：底层的缓存管理工具和上层的 Vue Composable (Hooks)。1. 缓存管理工具 (src/utils/cache)为了满足不同场景的需求，我们设计了一个支持内存缓存和持久化缓存的双层缓存工具。内存缓存：使用 Map，刷新页面即失效，适合临时数据。持久化缓存：借助 vueuse 的 useLocalStorage，适合长期存储（如字典数据、用户信息）。首先定义类型接口：// src/utils/cache/types.ts /** 缓存项结构 */ export interface CacheItem<T = any> { data: T; timestamp: number; // 存入时间，用于计算过期 } export interface GetCacheOptions { key: string; ttl: number; // 有效期 (ms) persist?: boolean; // 是否持久化 } export interface SetCacheOptions<T> { key: string; data: T; persist?: boolean; }接下来是具体的实现逻辑，核心在于 getCache 时的 TTL 过期判定：// src/utils/cache/index.ts import { useLocalStorage } from "@vueuse/core"; import type { CacheItem, GetCacheOptions, SetCacheOptions } from "./types"; export type * from "./types"; // 内存缓存堆 const memoryCache = new Map<string, CacheItem>(); // 持久化缓存 (基于 LocalStorage) const persistentCache = useLocalStorage<Record<string, CacheItem>>("swr-cache-storage", {}); /** 读取缓存 */ export function getCache<T>(options: GetCacheOptions): T | null { const { key, ttl, persist = false } = options; if (!key) return null; let item: CacheItem | undefined; if (persist) { item = persistentCache.value[key]; } else { item = memoryCache.get(key); } // 判断ttl if (item && Date.now() - item.timestamp < ttl) { return item.data; } // 过期清理 removeCache(key, persist); return null; } /** 写入缓存 */ export function setCache<T>(options: SetCacheOptions<T>): void { const { key, data, persist = false } = options; if (!key) return; const item: CacheItem = { data, timestamp: Date.now() }; if (persist) { persistentCache.value[key] = item; } else { memoryCache.set(key, item); } } /** 删除缓存 */ export function removeCache(key: string, persist = false): void { if (!key) return; if (persist) { delete persistentCache.value[key]; } else { memoryCache.delete(key); } } /**清空所有缓存（调试用）*/ export function clearAllCache(): void { memoryCache.clear(); persistentCache.value = {}; }2. useSWR 组合式函数 (src/composables/useSWR)这是业务逻辑的封装层，它负责协调“读取缓存”和“发起请求”的竞态关系，并处理 Vue 的响应式状态。// src/composables/useSWR/types.ts export interface SWROptions { /** 缓存时间，单位毫秒，5分钟 = 5 * 60 * 1000 */ ttl?: number; /** */ auto?: boolean; /** 是否持久化（必须配合有效 key） */ persist?: boolean; /** 默认值 */ defaultData?: any; }import { getCache, setCache } from "@/utils/cache"; import type { SWROptions } from "./types"; export type * from "./types"; /** 默认缓存5分钟 */ const DEFAULT_TTL = 5 * 60 * 1000; export function useSWR<T>( key: string | Ref<string>, fetcher: (key: string) => Promise<T>, options: SWROptions = {} ) { const { ttl = DEFAULT_TTL, auto = true, persist = false, defaultData } = options; const data = shallowRef<T | null>(defaultData ?? null); const error = ref<Error | null>(null); const loading = ref(false); const currentKey = computed(() => (typeof key === "string" ? key : key.value)); const fetchData = async (isRefresh = false) => { const k = currentKey.value; if (!k) return; // 重置错误 error.value = null; // 读取缓存 const cached = getCache<T>({ key: k, ttl, persist }); // loading 策略 const shouldSetLoading = isRefresh || !cached; if (shouldSetLoading) { loading.value = true; } // 命中缓存 if (!isRefresh && cached) { data.value = cached; } try { // 静默刷新 const result = await fetcher(k); // 竞态锁：如果在等待 fetcher 结果期间，key 发生了变化，则放弃此次结果 if (currentKey.value !== k) return; // 更新缓存和数据 setCache({ key: k, data: result, persist }); data.value = result; return result; } catch (err) { error.value = err as Error; console.error(`[useSWR] Error for key "${k}":`, err); } finally { loading.value = false; } }; /** 重新获取数据 */ const refresh = () => fetchData(true); // 是否自动获取数据 if (auto) { if (typeof key === "string") { fetchData(); } else { watch( currentKey, (newKey, oldKey) => { if (newKey && newKey !== oldKey) { fetchData(); } }, { immediate: true } ); } } return { data, loading, error, refresh }; } 使用示例以常见的“首页分类列表”为例，这类数据变动频率低，非常适合 SWR。 <van-tabs v-model:active="activeTab"> <van-tab v-for="category in categoriesSWR.data.value" :key="category.id" :title="category.name" > <CategoryAppList :category-id="category.id" /> </van-tab> </van-tabs> 在这种实现下，用户第一次打开 App 可能需要加载；但当用户去其他页面溜达一圈再回来，或者关闭 App 再打开（如果开启了 persist），首页分类是瞬间展示的，体验极其丝滑。总结通过手写这个轻量级的 useSWR，我们解决了移动端 H5 项目中两个核心痛点：用户体验：消除了页面切换时的加载闪烁，实现了类似原生应用的流畅感。开发效率：将缓存逻辑从业务代码中抽离，只需一行代码即可复用。当然，这个实现还有优化的空间，例如添加请求去重（防止短时间内重复请求同一接口）、聚焦窗口重新验证（Window Focus Revalidation）等高级特性。但在大多数中小型项目中，目前的实现已经足以带来显著的体验提升。如果不想封装，也可以尝试使用更成熟的第三方库，比如：VueRequest，这个库支持分页SWR等更多复杂场景。
Vue 3 进阶实战：基于 Promise 的通用弹窗管理器（命令式调用）
🚀 前言在 Vue 项目开发中，弹窗（Dialog/Modal/Drawer）是交互的核心载体。然而，随着业务复杂度的攀升，传统的声明式弹窗管理逐渐暴露出“样板代码多”、“状态分散”、“逻辑割裂”等痛点。本文将介绍一种基于 Promise 驱动的命令式弹窗管理方案，它能让你像调用 API 一样从 JS/TS 中直接唤起弹窗，并获取用户操作结果，彻底告别繁琐的 visible 变量。😫 痛点分析：传统模式的“四宗罪”看看这段典型的父组件代码，你是否感到熟悉且头疼？ <UserEditDialog v-model="showUserEdit" :id="curId" @success="refresh" /> <FilePicker v-model="showFilePicker" @confirm="onSelect" /> <ConfirmDialog v-model="showDelete" @confirm="doDelete" /> 核心痛点：状态爆炸：每增加一个弹窗，就要增加一套 ref 和 props 变量。模板污染：组件即使未被使用，也必须在 中预先挂载，影响 DOM 结构清晰度。复用困难：想在 Pinia 或纯 JS 文件中唤起弹窗？传统方式几乎做不到。流程割裂：触发逻辑与回调逻辑在代码空间上分离，难以阅读。🛠️ 解决方案：Promise 驱动的命令式调用我们借鉴 Element Plus ElMessageBox 的设计思想，实现一套通用的管理器。调用体验对比：// ✅ 优化后：像 await 异步函数一样流畅 const handleEdit = async (user) => { try { // 一行代码唤起，直到用户操作才继续往下执行 const formData = await modal.open({ component: UserEditDialog, props: { userId: user.id } }); // 拿到结果直接调用 API await updateUser(formData); Message.success('更新成功'); } catch (e) { console.log('用户取消了操作'); } }🧠 硬核解析：useModal 核心实现原理为了让技术实现更透彻，我们需要像“手术刀”一样剖析 useModal 内部到底发生了什么。1. 核心设计图解整个管理器可以看作是一个 "Promise 句柄劫持中心"。调用 open()：创建一个 Promise，但暂时不 resolve。劫持句柄：将 resolve 和 reject 函数提取出来，连同组件信息（Props, Component）挂载到一个响应式数组 modals 中。渲染层响应：全局挂载的渲染组件监测到 modals 变化，自动渲染对应的弹窗组件。用户交互：用户在弹窗点击“确定”，触发渲染层的 confirm 方法。归还句柄：管理器找到对应的 resolve 函数并执行，Promise 状态翻转，await 结束，父组件拿到数据。2. 代码实现 (Hooks)先定义类型// src/hooks/useModal/types.ts import type { Component } from "vue"; /** 弹窗持久化配置 */ export interface PersistentOptions { /** 手动指定唯一 key（推荐） */ key?: string; /** 根据 props 动态生成 key */ keyGenerator?: (props: Record<string, any>) => string; /** 全局单例模式（整个应用只有一个实例，无论 props 是什么） */ singleton?: boolean; } /** 打开弹窗参数 */ export interface OpenOptions { /** 弹窗组件 */ component: Component; /** props */ props?: Record<string, any>; /** 是否常驻 */ persistent?: boolean | PersistentOptions; } /** 弹窗实例 */ export type ModalInstance<T = any> = { modalId: symbol; visible: boolean; component: Component; props?: Record<string, any>; resolve: undefined | ((value: T) => void); reject: undefined | ((reason?: any) => void); }; /** 受控的弹窗组件基础props */ export interface BaseModalProps { modalId: symbol; } /** 受控的弹窗组件基础emit事件 */ export interface BaseModalEmit { confirm: [val: any]; cancel: [reason?: any]; }接着定义核心逻辑// src/hooks/useModal/index.ts import type { ModalInstance, OpenOptions, PersistentOptions } from "./types"; export type * from "./types"; interface InternalModal<T = any> extends ModalInstance<T> { persistent?: boolean | PersistentOptions; _key?: string | symbol; // 内部计算出的唯一 key } class ModalManager { private modals = ref<InternalModal[]>([]); private componentIds = new WeakMap<Component, symbol | string>(); /** 打开弹窗 */ public open<T = any>(options: OpenOptions): Promise<T> { const { component, props, persistent } = options; // 计算唯一 key，用于复用 const key = this.computeKey(options); // 查看是否已经存在可复用弹窗 const existing = key ? this.modals.value.find((m) => m._key === key && m.persistent) : null; if (existing) { existing.props = props; existing.visible = true; // 必须重置 Promise 句柄，因为上一次的 promise 早已结束 return new Promise((resolve, reject) => { existing.resolve = resolve; existing.reject = reject; }); } // 创建弹窗 return new Promise((resolve, reject) => { const modalId = Symbol("modal"); const _key = key ?? modalId; const instance: InternalModal<T> = { modalId, visible: false, component: markRaw(component), props, // 关键点：将 Promise 的控制权暴露给外部 resolve, reject, persistent, _key }; this.modals.value.push(instance); // 记录组件引用（用于单例模式） this.componentIds.set(component, _key); // HACK: Element Plus 弹窗的open事件会在 “由关闭 → 打开” 的状态变化中触发，所以这里等下一个 tick 时设置为true // 确保组件先挂载(v-if/v-for生效)，下一帧再设置 visible=true， // 这样才能正确触发 ElementPlus/AntDesign 等 UI 库的进入动画 nextTick().finally(() => { const modal = this.getByModalId(modalId); if (!modal) return; modal.visible = true; }); }); } /** 确认关闭（resolve） */ public confirm<T>(modalId: symbol, value?: T) { const modal = this.getByModalId(modalId); if (!modal) return; modal.resolve?.(value as T); modal.resolve = void 0; modal.reject = void 0; // 关闭弹窗，不销毁 modal.visible = false; } /** 取消关闭（reject） */ public cancel(modalId: symbol, reason?: any) { const modal = this.getByModalId(modalId); if (!modal) return; modal.reject?.(reason); modal.resolve = void 0; modal.reject = void 0; // 关闭弹窗，不销毁 modal.visible = false; } /** 关闭弹窗的回调方法，用于销毁非常驻弹窗 */ public closed(modalId: symbol) { const modal = this.getByModalId(modalId); if (!modal) return; if (typeof modal.reject === "function") { modal.reject(new Error("用户取消了")); modal.resolve = void 0; modal.reject = void 0; } // 非常驻弹窗，销毁 if (!modal.persistent) { this.removeByModalId(modal.modalId); } } /** 获取当前弹窗栈（仅供渲染层使用） */ public get list() { return this.modals.value as readonly ModalInstance[]; } /** 手动销毁常驻弹窗实例 */ public destroyPersistent(options: OpenOptions) { if (!options.persistent) return; const key = this.computeKey(options); if (!key) return; this.modals.value = this.modals.value.filter((m) => m._key !== key); this.componentIds.delete(options.component); } /** 销毁全部弹窗 */ public destroyAll() { this.modals.value = []; } /** 计算唯一 key */ private computeKey(options: OpenOptions): symbol | string | undefined { const { component, props = {}, persistent } = options; if (!persistent) return void 0; const config = typeof persistent === "boolean" ? {} : persistent; // 情况1：手动指定 key if (config.key) return config.key; // 情况2：动态生成 key if (config.keyGenerator) return config.keyGenerator(props); // 情况3：单例模式 const key = this.componentIds.get(component); return key; } /** 通过modalId获取弹窗实例 */ private getByModalId(modalId: symbol): InternalModal | undefined { return this.modals.value.find((m) => m.modalId === modalId); } /** 通过modalId删除弹窗实例 */ private removeByModalId(modalId: symbol) { this.modals.value = this.modals.value.filter((m) => m.modalId !== modalId); } } // 单例导出 const modalManager = new ModalManager(); export function useModal() { return modalManager; }渲染层实现 (The Renderer)渲染层需要处理一个关键问题：Props 透传与事件监听。// src/components/GlobalModalRenderer.vue <component :is="item.component" :modal-id="item.modalId" v-bind="item.props" v-model="item.visible" @confirm="(val: any) => modal.confirm(item.modalId, val)" @cancel="(reason?: any) => modal.cancel(item.modalId, reason)" @closed="() => modal.closed(item.modalId)" /> 渲染层主要处理以下事项：:modal-id="item.modalId" 自动注入唯一 ID。v-bind="item.props" 批量绑定业务 Props。v-model="item.visible" 双向绑定 visible，控制显示隐藏事件绑定，监听业务事件，桥接到 Promise。最后我们在App.vue中挂载这个组件：// App.vue <GlobalModalRenderer /> 💻 场景实战：更复杂的业务场景光有理论不够，我们来看几个真实的业务场景代码。场景一：带表单验证的用户编辑（数据回传）这是最常见的场景：打开弹窗 -> 填表 -> 校验 -> 点击确定 -> 返回数据。UserEditModal.vue <el-dialog v-model="visible" title="编辑用户" width="500px"> <el-form ref="formRef" :model="formData" :rules="rules"> <el-form-item label="用户名" prop="name"> <el-input v-model="formData.name" /> </el-form-item> </el-form> <el-button @click="visible = false">取消</el-button> <el-button type="primary" @click="handleConfirm">保存</el-button> </el-dialog> 调用方：const openEdit = async () => { try { // 等待弹窗关闭并拿到 formData const formData = await modal.open({ component: UserEditModal, props: { userInfo: { name: 'Jack' } } }); // 只有校验通过才会走到这里 console.log('用户提交的数据:', formData); await api.updateUser(formData); } catch (e) { console.log('用户取消或关闭了弹窗'); } }场景二：弹窗内部处理异步逻辑（Loading 状态）有时我们希望在弹窗内部点击“确定”后，先调用 API，成功后再关闭弹窗。AsyncActionModal.vue <el-dialog v-model="visible" title="异步操作"> 确定要执行敏感操作吗？ <el-button type="primary" :loading="loading" @click="handleConfirm"> 确定执行 </el-button> </el-dialog> 💾 进阶：持久化 (Persistence) 详解弹窗的创建和销毁是有性能开销的。对于地图选择器、富文本编辑器等重型组件，我们希望它关闭后只是 v-show="false" 隐藏，而不是销毁 DOM。配置解析：全局单例 (Singleton) 无论调用多少次，整个应用生命周期内该组件只有一个实例。modal.open({ component: HeavyEditorModal, persistent: { singleton: true } // 与 singleton: true 等价 });动态 Key (Key Generator) 根据传入的参数决定是否复用。例如：聊天窗口，同一个人复用一个窗口，不同人创建新窗口。modal.open({ component: ChatWindow, props: { userId: 101 }, persistent: { // 返回相同的 string 就会复用实例 keyGenerator: (props) => `chat-${props.userId}` } });指定 Key (推荐) 如果你需要复用的弹窗组件引用不变，但是参数不同，又希望他们能区分复用，比如参数A的弹窗一个实例，参数B的弹窗一个实例，虽然都基于SimpleDialog，但是两个都是独立的，那么使用指定key的方式来实现。// 场景 A：选择源目录（状态独立） modal.open({ component: FilePicker, persistent: { key: "source-dir-picker" }, props: { title: "请选择源目录" } }); // 场景 B：选择输出目录（状态独立） modal.open({ component: FilePicker, persistent: { key: "output-dir-picker" }, props: { title: "请选择输出目录" } });手动销毁实例持久化的弹窗不会自动销毁（只会隐藏）。如果需要释放内存或重置状态，可以使用 destroyPersistent。// 销毁指定配置的持久化实例 modal.destroyPersistent({ component: FilePicker, persistent: { key: "source-dir-picker" } // persistent的配置也必须和open时传入的配置相同 }); // 或者暴力销毁所有 modal.destroyAll();📝 总结通过 useModal，我们成功将弹窗管理重构为Promise 驱动的命令式服务。它的核心优势在于：清洁的代码：父组件 0 状态变量，逻辑线性流畅。极致的解耦：弹窗组件与调用方彻底解耦，甚至可以在非 Vue 组件文件（如 axios 拦截器）中唤起登录弹窗。强大的扩展：天然支持 TS 类型推导，支持持久化缓存，支持多种 UI 库。
搭建基于 Vite + TypeScript + Vue 3 + Electron的小项目
前言有时候我们需要将前端项目打包成一个exe文件供人使用，在不考虑性能或者包体大小的情况下，使用Electron来打包项目是一个不错的选择，那么有没有办法从现有的前端项目中加入Electron呢？答案，当然是可以的，这里以我自己的项目为例，我的项目基于Vue3 + TypeScript + Vite，目的是为了实现一个excel文件的格式化处理功能，打包成exe安装包也只是为了减少运维的工作，用户自己安装本地运行就可以了，没必要部署到线上。核心概念：主进程与渲染进程在开始之前，理解 Electron 的两个核心概念至关重要：主进程 (Main Process)：每个 Electron 应用有且只有一个主进程。它相当于应用的“大脑”，是整个应用的入口点。主进程在 Node.js 环境中运行，拥有操作原生 GUI（如图形用户界面）、管理所有渲染进程以及与操作系统底层交互的能力。我们将在 electron/main.ts 文件中编写主进程代码。渲染进程 (Renderer Process)：每个 Electron 应用可以有一个或多个渲染进程。每个 BrowserWindow 实例都运行着一个独立的渲染进程，负责渲染 Web 页面，也就是我们用 Vue、React 等框架编写的用户界面。它运行在 Chromium 环境中，出于安全考虑，默认无法直接访问 Node.js API 或操作系统资源。环境准备在开始之前，请确保您的开发环境中已安装以下软件：Node.js: 建议使用 LTS 版本。Yarn: 本教程推荐使用 Yarn 作为包管理器。pnpm 我在测试中发现有很多问题，electron相关的文档都是推荐npm或者yarn。初始化vue3项目这里就使用官方的cli命令：yarn create vue这里我建议使用yarn来管理项目依赖，因为Electron使用pnpm会出现依赖问题。大家根据自己需求来配置项目，这里我配置的是：[##] 2/2┌ Vue.js - The Progressive JavaScript Framework │ ◇ 请输入项目名称： │ test │ ◆ 请选择要包含的功能： (↑/↓ 切换，空格选择，a 全选，回车确认) │ ◼ TypeScript │ ◻ JSX 支持 │ ◼ Router（单页面应用开发） │ ◼ Pinia（状态管理） │ ◻ Vitest（单元测试） │ ◻ 端到端测试 │ ◼ ESLint（错误预防） │ ◼ Prettier（代码格式化） └回车后，等待项目初始化完成。集成 Electron为了将我们的 Web 应用打包成桌面应用，需要引入 Electron 相关依赖。yarn add electron dotenv yarn add electron-builder vite-plugin-electron -Delectron: Electron 的核心框架。dotenv: 用于从 .env 文件加载环境变量到 process.env 中，方便主进程使用。electron-builder: 功能强大且广泛使用的 Electron 应用打包和分发工具。vite-plugin-electron: 一个 Vite 插件，它简化了在开发模式下同时运行 Vite 开发服务器和 Electron 应用的过程。创建主进程入口文件安装完成后我们需要创建一个electron的入口文件：在项目根目录，也就是与src同级目录下创建electron文件夹，并创建main.js文件，注意必须是js文件，不能是ts，内容如下：import { app, BrowserWindow, globalShortcut } from "electron"; // import { Menu } from "electron"; import path from "path"; import { fileURLToPath } from "url"; import dotenv from "dotenv"; // 常量 const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const isDev = Boolean(process.env.VITE_DEV_SERVER_URL ?? false); // 加载环境变量 { const envPath = isDev ? path.resolve(__dirname, "../.env.development") : path.resolve(__dirname, "../.env.production"); dotenv.config({ path: envPath }); } // 屏蔽安全警告 // ectron Security Warning (Insecure Content-Security-Policy) process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true"; // 创建浏览器窗口时，调用这个函数。 const createWindow = async () => { const win = new BrowserWindow({ width: 800, height: 600, maximizable: true, autoHideMenuBar: isDev ? false : true, // 生产关闭菜单栏 icon: fileURLToPath(new URL("../public/favicon.ico", import.meta.url)), webPreferences: { nodeIntegration: false, contextIsolation: true } }); // 禁用右键菜单（如果非调试模式） if (process.env.VITE_APP_DEBUG !== "true") { win.webContents.on("context-menu", (e) => { e.preventDefault(); }); } // 最大化窗口 win.maximize(); // 开启调试台 if (process.env.VITE_APP_DEBUG === "true") { win.webContents.openDevTools({ mode: "bottom" }); } // development模式 if (isDev) { await win.loadURL(process.env.VITE_DEV_SERVER_URL); } else { await win.loadFile(path.join(__dirname, "../dist/index.html")); } // NOTE: 调试用的，别删了，调试记得把autoHideMenuBar=false // 创建菜单显示环境信息 // const menu = Menu.buildFromTemplate([ // { // label: "Debug", // submenu: [ // { // label: `NODE_ENV: ${process.env.NODE_ENV || "undefined"}`, // enabled: false // }, // { // label: `App Packaged: ${app.isPackaged}`, // enabled: false // }, // { // label: `${process.env.VITE_APP_TITLE}`, // enabled: false // } // ] // } // ]); // Menu.setApplicationMenu(menu); }; const registerShortcuts = () => { // 常见快捷键全封 const shortcuts = [ "F12", "Ctrl+Shift+I", "Cmd+Option+I", "Ctrl+Shift+J", "Cmd+Option+J", "Ctrl+U", // 查看源代码 "Cmd+U" ]; shortcuts.forEach((shortcut) => { globalShortcut.register(shortcut, () => { console.log(`Blocked development shortcut: ${shortcut}`); // 啥也不干，进行拦截 }); }); }; // Electron 会在初始化后并准备 app.whenReady().then(() => { createWindow(); // 在应用准备好后，根据条件注册全局快捷键 if (process.env.VITE_APP_DEBUG !== "true") { registerShortcuts(); } app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { if (process.env.VITE_APP_DEBUG !== "true") globalShortcut.unregisterAll(); app.quit(); } });这里的逻辑稍后再分别解释，反正这是一份完整的代码，大家可以先看看。Vite 配置打开 vite.config.ts 文件，引入并配置 vite-plugin-electron。import electron from "vite-plugin-electron"; export default defineConfig({ plugins: [ electron({ entry: "./electron/main.js" }), ], });Vue Router配置在 Electron 的生产环境中，应用是通过 file:// 协议加载的，标准的 createWebHistory 模式无法正常工作。因此，我们必须使用哈希模式 (createWebHashHistory)。import { createRouter, createWebHashHistory } from "vue-router"; const router = createRouter({ history: createWebHashHistory(import.meta.env.BASE_URL), routes: [], scrollBehavior: () => ({ left: 0, top: 0 }) }); export default router;package.json配置这是整合的最后一步，也是非常关键的一步。我们需要配置 main 入口、scripts 命令以及 electron-builder 的打包选项。{ "name": "qyx-excel-tool", "version": "0.0.1", "main": "./electron/main.js", "author": { "name": "作者名称", "url": "作者主页链接，例如：公司网站", "email": "联系邮箱" }, "scripts": { "electron:build": "vite build && electron-builder" }, "build": { "productName": "打包后的包名", "appId": "com.xxx.xx", "copyright": "版权说明。例：Copyright © 2025 xxx. All rights reserved.", "compression": "maximum", "asar": true, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, "perMachine": true, "allowElevation": true, "deleteAppDataOnUninstall": true, "createDesktopShortcut": true, "createStartMenuShortcut": true }, "files": [ "dist/**/*", "dist-electron/**/*", "electron/**/*", "public/**/*", ".env.production" ], "directories": { "output": "output/" }, "win": { "icon": "./public/favicon.ico", "artifactName": "${productName}-v${version}-${platform}-setup.${ext}", "target": [ { "target": "nsis" } ] } } }关键配置项解释：main: 指定 Electron 应用的主进程入口文件。构建后，它位于 dist-electron/main.js。scripts.electron:build: 定义了打包命令，它会先执行 vite build 构建 Web 内容，然后执行 electron-builder 进行桌面应用打包。build: electron-builder 的所有配置都写在这里。productName: 安装包和应用显示的名字。appId: 应用的唯一标识符，通常采用反向域名格式。directories.output: 指定打包文件的输出目录，这里设置为 release/。files: 一个数组，指定哪些文件和目录需要被打包进最终的应用中。dist 存放 Vue 应用构建产物，dist-electron 存放主进程构建产物。我们还需要将生产环境变量文件 .env.production 打包进去。win: 针对 Windows 平台的特定配置。nsis: NSIS 安装程序制作工具的配置项。oneClick: false 表示创建传统的多步安装向导，而不是一键安装。allowToChangeInstallationDirectory: true 允许用户在安装时选择安装路径。运行与构建开发模式执行 dev 命令，vite-plugin-electron 会自动启动 Vite 开发服务器和 Electron 应用窗口。yarn dev在开发模式下，对 Vue 组件或 Electron 主进程代码的任何修改都会触发热重载，极大地提升了开发效率。打包构建当应用开发完成后，执行 electron:build 命令来进行打包。yarn run electron:build命令执行成功后，会在 output 目录下找到生成的安装包（例如 .exe 文件）和相关文件。高级：环境变量Vite 的环境变量（import.meta.env）和主进程的 Node.js 环境变量（process.env）是隔离的。主进程无法直接访问 Vite 的 VITE_ 前缀变量。而且需要注意的是，这里的node进程不是运行打包命令时的命令运行进程，而是运行electron的进程。由于我们需要使用环境变量来控制一些配置，但是灵活性暂时没有vite那边方便，vite可以通过mode参数来自由指定环境变量文件，但是在electron中，我们需要自己实现这种功能，索性就简单点，就用两个环境变量文件：.env.development、.env.production来做。我们通过Boolean(process.env.VITE_DEV_SERVER_URL ?? false)判断是不是dev环境，如果不是就是生产环境。然后固定加载指定的环境变量文件，使用dotenv库将环境变量自动挂载到process.env中。这个命令必须在使用之前执行，所以一般都是写在最开头的位置。但是需要注意，在build的时候，我们还需要将这个文件也打进去，所以需要在package.json中的files中将生产的文件也加上。操作步骤：创建 .env.development 和 .env.production 文件。在 main.ts 的最顶部，通过判断 isDev 变量来确定当前环境。使用 dotenv 库加载对应的 .env 文件，将其中的变量注入到 process.env 中。// electron/main.ts 开头部分 const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); const envPath = isDev ? path.resolve(__dirname, "../.env.development") : path.resolve(__dirname, "../.env.production"); dotenv.config({ path: envPath }); // 现在可以在 main.ts 的任何地方使用 process.env.VITE_APP_...切记：如 package.json 中配置所示，必须将 .env.production 文件包含在 build.files 数组中，这样打包时它才会被复制到应用资源目录中，dotenv 在生产环境才能找到并加载它。{ "build": { "files": [".env.production"] } }高级：Prettier和ESLint配置忽略项electron的一些文件是不需要格式化的，比如打包后的文件。Prettier在 .prettierignore 文件中添加以下内容（没有自己手动创建）：dist-electron/* output/*ESLint现在的都是v9版本ESLint，在 eslint.config.ts文件中添加以下内容：export default defineConfigWithVueTs( { name: "app/files-to-ignore", ignores: [ "**/dist-electron/**", "**/output/**" ] } )总结这个项目只是一个起点。接下来，你还可以探索更多高级功能，例如：创建自定义的原生菜单栏。使用 electron-store 实现应用配置的本地持久化存储。集成 electron-updater 实现应用的热更新功能。调用更多系统原生 API，如系统通知、剪贴板等。希望这篇更详尽的指南能帮助你顺利开启 Electron 开发之旅！
前言最近在写AI训练器的UI界面，有一个需求就是展示TOML配置，为此就需要用到代码高亮，我之前用过 highlight.js 和 PrismJS，但是在撰写VitePress项目配置时发现了一个新的代码高亮库：shiki。shiki可以省去维护css和html，并且原生就是ESM按需加载，支持多种主题等，已经满足了我对一个代码高亮的需求，为此我去尝试用了下这个，于是有了本篇文章。插件文档：Shiki语法高亮器github：shiki注意事项shiki是按需加载的，它的实现方式就是除了一个主体的js文件，在打包后会有非常多的不同代码语言的解析器脚本文件，但是不用担心，这些脚本文件只有在真实使用的时候会加载，只是预先生成了罢了。如果你使用了 vite-bundle-analyzer 这类的分析库，可能会导致你看到的包体大小非常大，解决办法就是你可以使用CDN的方式来做，具体官方有文档，可以自行研究，我个人觉得这不是什么大的问题。效果图教程我们先安装插件：pnpm add -D shiki安装完成后就可以创建vue组件来使用代码高亮，这里我直接贴出我的组件代码： 首先声明了一个props类型，要求传入 toml 代码文本，组件会将这个代码文本转换成代码高亮的html元素并渲染。由于我的项目是支持 dark/light 两种主题模式，所以我从pinia仓库引入了useAppStore来获取当前主题模式是dark还是light。剩下的就是代码高亮的逻辑，创建了 tomlHtml 变量接收高亮后的html元素文本，再通过 v-html 渲染。组件watch监听toml文本或者isDark是否发生变化，变化了就重新生代码高亮文本成并渲染。需要注意的是 createHighlighter 创建的 shiki 实例是可以重复使用的，官方也推荐你复用这一个实例，所以我会判断，如果实例存在就不会重复 init 初始化了，反之，如果组件被销毁了就会将实例也进行销毁。这里比较有意思的就是 createHighlighter 它是一个异步的操作，所以我们除了在 onUnmounted 做实例销毁的处理，还需要在 init 中判断组件是否销毁，因为有可能用户显示了这个组件又很快的不显示了，这时候 init 触发了，init调用createHighlighter又是一个异步的，导致组件不显示后这个异步才完成，但是在完成之前onUnmounted就已经被触发了，这就导致了这个创建的实例无法被销毁。为了解决这个问题，我们可以通过 getCurrentInstance 获取当前组件实例对象，通过对象上的 isUnmounted 来判断组件是不是被卸载了，卸载了我们就进行销毁实例处理。基本逻辑就是这样了，这也是我第一次使用 isUnmounted 这种属性，以防有其他人也碰到这样的问题，特此记录一下。
前言最近在开发一个Chrome插件，在阅读官方文档时发现它提供的都是js的方式，完全没有工程化的概念，而且插件的版本已经到v3了，v3版本限制了非常多的东西，比如我们使用vue的template渲染，由于vue用到了eval来实现，导致插件会报错，蛋疼的很，总不能写手写render函数吧，太累了。最好的解决办法就是用vite来打包vue项目，这样既可以用vue组件的方式开发，打包时也会将template转换成render函数，从而避免了eval的报错，我们还可以使用到ts和scss这些便利的工具。为此我们搭建一个开发模板需要实现以下功能：支持vite打包支持typescript支持scss支持pinia支持vue-router自动生成manifest.json文件自动生成background.js文件封装常用插件方法：message发布订阅、上下文菜单生成、浏览器消息通知、fetch请求等[hide]Chrome插件文档官方其实提供了中文的文档教程，在开发前可以先看看《Hello World 扩展程序》这个例子。Hello World 扩展程序初始化项目简单看了文档后我们也应该知道了插件中使用html页面有这么几种方式：弹出式窗口 popup 页面；侧边栏窗口 side panel 页面；background脚本通过tab方法打开固定的html文件；既然有这么多页面，那说明这是一个多页面的项目，初始化的时候就得往多页面那块发展，需要自己配置input入口和输出。我们先初始化：pnpm create vue@latest这种方式初始化比较便捷。Vue.js - The Progressive JavaScript Framework √ 请输入项目名称： ... chrome-extension-template √ 是否使用 TypeScript 语法？ ... 是 √ 是否启用 JSX 支持？ ... 否 √ 是否引入 Vue Router 进行单页面应用开发？ ... 是 √ 是否引入 Pinia 用于状态管理？ ... 是 √ 是否引入 Vitest 用于单元测试？ ... 否 √ 是否要引入一款端到端（End to End）测试工具？ » 不需要 √ 是否引入 ESLint 用于代码质量检测？ » 是，并同时引入 Oxlint 以加快检测（试验阶段） √ 是否引入 Prettier 用于代码格式化？ ... 是 正在初始化项目 E:\code\chrome-extension-template\chrome-extension-template... 项目初始化完成，可执行以下命令： cd chrome-extension-template pnpm install pnpm format pnpm dev 添加：TypeScript、Vue Router、Pinia 、ESLint 、Prettier 依赖即可。Oxlint 是最近新出的一个代码检查工具，比Eslint快了几十倍，有兴趣可以尝试一下。配置Prettier.prettierrc.json{ "$schema": "https://json.schemastore.org/prettierrc", "semi": true, "useTabs": true, "tabWidth": 2, "singleQuote": false, "printWidth": 100, "trailingComma": "none", "endOfLine": "lf" }.prettierignoredist/* html/* .local node_modules/** **/*.svg **/*.sh public/*没有的文件就创建，这两个文件都是在项目根目录。安装主要依赖谷歌插件要求项目中存在一个manifest.json配置文件，它存储这插件的各种页面入口配置和背景脚本路径，但是它是一个纯静态的文件，我们想要动态的调整并不方便，比如插件的名称在开发模式可以在开头加上[dev]来区分。而且谷歌插件在开发时加载的文件是build后的产物，如何能使其支持dev模式和HMR热更也是一个难题。解决以上问题的答案就是安装：crxjsnpm仓库地址：@crxjs/vite-pluginpnpm i @crxjs/vite-plugin@2.0.0-beta.28 -D需要注意，该插件支持vite3版本还在beta阶段，但是已经可以正常使用，安装的时候记得指定版本，如果已经正式到2.0版本就不需要指定了。大家可以在npm仓库的versions tab页面查看最新版本。创建manifest.json依赖安装成功后我们在项目根目录创建manifest.json文件，内容如下：{ "manifest_version": 3, "name": "Chrome Extension Template", "version": "1.0.0", "action": { "default_popup": "index.html" } }简答解释下就是这是一个v3标准的谷歌插件，插件名是Chrome Extension Template，版本1.0.0，它的action表示用户操作插件图标时候的操作，比如这里配置了一个默认popup页面，那就表示用户点击插件图标的时候，会打开一个弹窗页面。效果：当然还有更多的操作，具体可以看官方action的文档：action注册插件打开vite.config.ts文件，注册插件：import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import { crx } from "@crxjs/vite-plugin"; import manifest from "./manifest.json"; // https://vite.dev/config/ export default defineConfig({ plugins: [vue(), crx({ manifest })], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } } });如果你的项目安装了vite-plugin-vue-devtools，记得去掉，不然你的js会报ws链接错误，毕竟现在是浏览器插件，不是完整的浏览器环境。浏览器加载开发的插件此时我们启动dev命令：pnpm run dev注意是dev命令，默认情况下vite的dev并不会在项目产生dist文件，但是注册crx插件后会给你产出。然后去到谷歌浏览器扩展管理，开启右上角的开发者模式，再去左边点击加载已解压的扩展程序，找到刚刚产出的dist目录即可。最后将插件固定到浏览器工具栏，点击图标就能看到效果。清空目录清理src下的所有文件，以及public目录下的所有文件，还有index.html，由于是多页面开发，所以单页面的方式已经不适合了。content内容脚本HMRcontent脚本是注入到页面上运行的，所以它能读取页面信息，相对来说权限更广了。具体可以查看官方文档：内容脚本在src创建一个目录：pages/content，添加以下文件：├─ src │ └─ pages │ ├─ content │ │ ├─ App.vue │ │ └─ main.tsApp.vue内容如下： content app main.ts内容如下：import { createApp } from "vue"; import App from "./App.vue"; const root = document.createElement("div"); root.id = "crx-root"; document.body.append(root); const app = createApp(App); app.mount(root);有趣的，因为是注入到网页，所以我们需要手动创建一个根元素并append到页面中，最后mount挂载到root元素上。接着我们配置下manifest.json中的脚本路径：{ "manifest_version": 3, "name": "Chrome Extension Template", "version": "1.0.0", "content_scripts": [ { "js": ["src/pages/content/main.ts"], "matches": ["https://www.google.com/*"] } ] }我们指定这个脚本的注入网址匹配谷歌站点，配置完毕后dev启动，在浏览器扩展管理那，刷新下加载。然后我们再打开谷歌搜索网页，可以看到在页面底部有我们app.vue的内容。我们再去改动app.vue的内容，保存后，你再回来会发现已经热更新了，crx插件会对内容注入脚本实现热更新。注意css样式污染由于我们的html元素也是渲染在谷歌搜索网页，所以一些通用css定义会存在污染的情况，比如收到原页面的样式影响，或者我们不小心设置了body { background-color: red; }也会影响原网页。所以常见的做法就是加个父级约束：#crx-root { position: fixed; top: 3rem; left: 50%; transform: translate(-50%, 0); } #crx-root button { background-color: rgb(239, 239, 239); border-color: rgb(118, 118, 118); border-image: initial; border-style: outset; border-width: 2px; margin: 0; padding: 1px 6px; }manifest加强处理在之前的配置中我们只是简单的配置了下crx的manifest，事实上crx还提供了更强的处理。代码类型约束和提示JSON我们有两种方式实现代码提示，第一种是JSON Schema，我们需要在vscode的设置里面添加下面这段配置：settings.json{ "json.schemas": [ { "fileMatch": ["manifest.json"], "url": "https://json.schemastore.org/chrome-manifest.json" } ] }然后我们去编写manifest.json文件时会有约束效果。但是这样也一般，因为改动json文件并不会触发dev重建，我更推荐使用第二种方式。TypeScript我们使用vite的defineConfig这种函数的方式来实现。不过在之前我们先删除manifest.json文件。在根目录创建manifest.config.ts文件，内容如下：import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { manifest_version: 3, name: "chrome-extension-template", version: "0.0.1", content_scripts: [ { js: ["src/pages/content/main.ts"], matches: ["https://www.google.com/*"] } ] }; });可以看到这种方式代码提示会稍微好一些，编写完毕后调整下vite.config.ts。// vite.config.ts import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import { crx } from "@crxjs/vite-plugin"; import defineManifest from "./manifest.config"; // https://vite.dev/config/ export default defineConfig({ plugins: [vue(), crx({ manifest: defineManifest })], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } } }); 此时我们dev启动项目，你会发现效果相同，此时我们再去动态修改manifest.config.ts文件，是可以触发重新构建的。插件版本号一般情况下我们直接使用package.json文件中version字段来作为谷歌插件的版本号就行了，不过可以先安装一个插件专门来更新版本号。pnpm i bumpp -D然后去package.json的scripts中添加如下快捷命令：{ "scripts": { "version:update": "bumpp --no-commit --no-tag --no-push" } }这样当我们运行命令：pnpm run version:update就会触发版本更新选项：> bumpp --no-commit --no-tag --no-push i Failed to locate the previous tag v0.0.0 ? Current version 0.0.0 » major 1.0.0 minor 0.1.0 patch 0.0.1 > next 0.0.1 pre-patch 0.0.1-beta.1 pre-minor 0.1.0-beta.1 pre-major 1.0.0-beta.1 as-is 0.0.0 custom ...我们就可以更加便捷更新版本号，这里我们选择next，让版本号成为0.0.1。然后我们去manifest.config.ts中调整如下：import { defineManifest } from "@crxjs/vite-plugin"; import packageJson from "./package.json"; export default defineManifest(async (env) => { return { manifest_version: 3, name: "chrome-extension-template", version: packageJson.version, content_scripts: [ { js: ["src/pages/content/main.ts"], matches: ["https://www.google.com/*"] } ] }; }); 直接从package中取version就行了，省事，我看了好多插件基本都是3个版本，比如：7.3.2、2023.3.2912。插件名称一般情况下由于manifest中的name是固定死的，这就导致我们开发后，build来测试正式包，会发现插件完全一致，不知道现在测的是开发版本还是正式版本，所以我们可以在插件名称上做点调整。// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; import packageJson from "./package.json"; export default defineManifest(async (env) => { return { manifest_version: 3, name: env.mode === "production" ? packageJson.name : `[dev] ${packageJson.name}`, version: packageJson.version, content_scripts: [ { js: ["src/pages/content/main.ts"], matches: ["https://www.google.com/*"] } ] }; });这里我们也从package中取名称就行了，不是正式打包就加个前缀dev做区分。补充description也是从package中取，所以先在package中设置：{ "description": "这是一个谷歌浏览器扩展模板，基于Vite+Vue3+TypeScript开发" }// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; import packageJson from "./package.json"; export default defineManifest(async (env) => { return { manifest_version: 3, name: env.mode === "production" ? packageJson.name : `[dev] ${packageJson.name}`, description: packageJson.description, version: packageJson.version, content_scripts: [ { js: ["src/pages/content/main.ts"], matches: ["https://www.google.com/*"] } ] }; });Chrome类型manifest的设置已经很完善了，但是我们用了ts，在ts中是没有Chrome api类型的，直接使用相关api机会报ts警告。chrome.runtime.onInstalled.addListener(() => { ... });事实上谷歌有在维护一份类型插件，说是每天都会构建，但是我用起来发现各种不对劲，这是他们的官方地址：chrome-types所以我也不太建议大家用这个，建议安装这个：@types/chrome这个是由社区维护的，我看更新时间也很快，用起来没有问题。pnpm i @types/chrome -D安装完后配置下tsconfig.app.json{ "compilerOptions": { "types": ["chrome"] } }因为我们的包是@types/chrome，填写在types属性下是可以省略@types的，为此这里就是chrome。接着我们去ts文件中使用chrome的api是有提示的。多入口处理背景脚本背景脚本相对特殊一点，因为它不用html文件，是一个纯脚本。在src目录创建background/background.ts文件。然后我们在manifest.config.ts中配置改入口。// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; import packageJson from "./package.json"; export default defineManifest(async (env) => { return { ... background: { service_worker: "src/background/background.ts", type: "module" } }; });注意：在manifest中配置的路径全都是绝对路径，不能用相对路径。接着我们就可以在背景脚本中监听插件安装和启用的事件：/** 插件安装 */ chrome.runtime.onInstalled.addListener(async () => { }); /** 插件启用 */ chrome.runtime.onStartup.addListener(async () => { });有意思的是，dev启动后，背景脚本也是热更新的，不需要手动点击插件的刷新按钮。popup 页面为了方便演示功能，我将pnpm create vue@latest初始化项目创建的vue相关的文件全部放到了src/pages/popup目录了，结构如下：└─ popup │ ├─ App.vue │ ├─ assets │ │ ├─ base.css │ │ ├─ logo.svg │ │ └─ main.css │ ├─ components │ │ ├─ HelloWorld.vue │ │ ├─ icons │ │ │ ├─ IconCommunity.vue │ │ │ ├─ IconDocumentation.vue │ │ │ ├─ IconEcosystem.vue │ │ │ ├─ IconSupport.vue │ │ │ └─ IconTooling.vue │ │ ├─ TheWelcome.vue │ │ └─ WelcomeItem.vue │ ├─ index.html │ ├─ main.ts │ ├─ router │ │ └─ index.ts │ ├─ stores │ │ └─ counter.ts │ └─ views │ ├─ AboutView.vue │ └─ HomeView.vue由于是多页面结构，文件中使用了@/assets这种路径别名的地方需要调整，或者自己去tsconfig中配置一个popup的路径别名。调整示例：// 旧代码 // 调整 配置了路径别名：// tsconfig.app.json { "compilerOptions": { "paths": { "@/*": ["./src/*"], "@popup/*": ["./src/pages/popup/*"] } } }// vite.config.ts import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import defineManifest from "./manifest.config"; // https://vite.dev/config/ export default defineConfig({ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), "@popup": fileURLToPath(new URL("./src/pages/popup", import.meta.url)) } } }); index.html需要将里面main.ts文件引入改为相对路径： 文件资源搞定后我们还需要配置popup的插件资源入口和build打包入口。// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { action: { default_popup: "src/pages/popup/index.html" } }; });// vite.config.ts import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ build: { rollupOptions: { input: { popup: "src/popup/index.html" } } } });这样vite打包和谷歌插件入口都能找到对应的文件。效果：WebSocket连接失败虽然我们将路由换成了hash模式可以解决路由在popup页面报错的问题，但是还会报一个ws连接的错误。Uncaught (in promise) SyntaxError: Failed to construct 'WebSocket': The URL 'ws://localhost:undefined/' is invalid.感觉应该是端口没有配置成功。找了下插件的issue，有这么一个提问：《SyntaxError: Failed to construct 'WebSocket': The URL 'ws://localhost:undefined/' is invalid》里面提到，我们需要配置下vite的server属性：// vite.config.ts import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ server: { port: 5173, strictPort: true, hmr: { port: 5173 } }, });问题就解决了。side panel 侧边栏文件目录：src/pages/side-panel└─ side-panel │ ├─ App.vue │ ├─ index.html │ └─ main.ts具体文件就不多解释了，这都不会就去学一下vue基础。侧边栏页面有两种打开方式，一种就是像popup页面一样，点击插件图标打开侧边栏页面，一种就是通过chrome.sidePanel API 打开。如果像popup页面那样通过点击插件图标打开，那你只能side panel 和 popup二选一。通过API打开参考官方文档：《chrome.sidePanel》如果只是单侧边栏，可以这样：// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { "permissions": [ "sidePanel" ], action: { "default_title": "点击打开侧边栏" }, "side_panel": { "default_path": "src/side-panel/index.html" } }; });这样配置manifest，就能通过点击顶栏插件图标打开了。为了完整演示整个模板，我们还是用API的方式。// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; import packageJson from "./package.json"; export default defineManifest(async (env) => { return { manifest_version: 3, name: env.mode === "production" ? packageJson.name : `[dev] ${packageJson.name}`, description: packageJson.description, version: packageJson.version, permissions: ["sidePanel"], content_scripts: [ { js: ["src/content/main.ts"], matches: ["https://www.google.com/*"] } ], background: { service_worker: "src/background/background.ts", type: "module" }, action: { default_popup: "src/pages/popup/index.html" }, side_panel: { default_path: "src/pages/side-panel/index.html" } }; }); 接着配置下vite的入口：// vite.config.ts import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ build: { rollupOptions: { input: { popup: "src/pages/popup/index.html", "side-panel": "src/pages/side-panel/index.html" } } } });接着我们去popup页面增加一个触发按钮，比如在App.vue中增加一个按钮和点击事件。 打开侧边栏 我们使用chrome.tabs获取当前页的tab对象，并从中获取id和windowId，用于在打开侧边栏时传的必须参数。这里用到了tabs，所以manifest中permissions需要补充一个tabs权限声明：// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { permissions: ["sidePanel", "tabs"], }; });通过chrome API打开的页面除了像popup和side panel 这种有明确入口的页面，在插件中我们也可以像window.open()方法一样打开页面，不过我们此时传参的值是页面path路径，通过chrome.tabs.create方法打开。当然它的permissions权限也是tabs。 这种页面vite的处理方式也是相同的，在build的input中添加该入口，比如我们创建了src/pages/404目录，文件结构如下：├─ src │ └─ pages │ ├─ 404 │ │ ├─ App.vue │ │ ├─ index.html │ │ └─ main.ts// vite.config.ts import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ build: { rollupOptions: { input: { popup: "src/pages/popup/index.html", "side-panel": "src/pages/side-panel/index.html", 404: "src/pages/404/index.html" // 新增一个入口 } } } });现在vite打包没有问题了，我们还需要配置下谷歌插件中这个页面的权限，否则无法被访问。// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { web_accessible_resources: [ { resources: ["src/pages/404/index.html"], matches: ["<all_urls>"] } ] }; }); 现在配置好后我们还是去popup页面，在App.vue中增加一个按钮触发。 打开404页面 如果你有其他这种页面，也可以按照这种方式实现。更多入口看完上面三种配置方式，你应该已经明白该怎么做了，谷歌插件提供了很多入口，这里就不一一细说了，原理都是一样的，根据vite多页面的方式配置，然后在manifest中配置path或者权限声明就行。pinia持久化处理我的建议就是，如果你能用网页的缓存API：localStorage、sessionStorage这些，那你就用它，因为谷歌插件提供的存储API，它全是异步的，并不能很好的和pinia结合，我尝试过使用异步的持久化pinia插件，但是他们都一个问题，就是你没办法在存储完后马上获取到数据，你甚至都没办法使用async...await来等待。比较好的解决方式就是，如果我是一个网页级的页面，且能拿到localStorage这种缓存API，那就配置pinia的pinia-plugin-persistedstate插件来做自身的缓存处理。如果你是background这种背景脚本，你想要缓存数据，那么就得自己封装一下存储工具函数：/** 写入持久化数据 */ export async function localStorageSet(key: string, value: any) { await chrome.storage.local.set({ [key]: value }); } /** 获取持久化数据 */ export async function localStorageGet(key: string, defaultValue: any) { const localData = await chrome.storage.local.get(key); const value = localData?.[key]; if (typeof value === "undefined" && typeof defaultValue !== "undefined") { return defaultValue; } return value; }更多信息你可以阅读下谷歌的插件文档：《chrome.storage》manifest中的permissions权限添加"storage"。请求封装在谷歌浏览器插件中，推荐用fetch请求（背景脚本只能用fetch），如果你是web级的页面，比如content注入到页面了，这种就可以使用xhr，也就是axios来请求，但是为了一劳永逸，我觉得使用fetch来发请求，也是不错的选择。对fetch封装后，可以用于所有地方，而不用维护两份核心请求逻辑。找了下fetch的插件，决定用一下ky这个库。pnpm i kyGithub地址：ky创建目录：src/request，结构如下：├─ src │ ├─ request │ │ ├─ index.ts │ │ └─ types.ts代码内容：// types.ts import type { Options } from "ky"; /** 请求参数 */ export interface BaseOptions extends Options { /** 请求地址 */ url: string; /** 是否关闭错误消息提示 */ showErrorMessages?: boolean; } export interface JSONOptions extends BaseOptions { responseType: "json"; } export interface TextOptions extends BaseOptions { responseType: "text"; } export interface BlobOptions extends BaseOptions { responseType: "blob"; } export interface ArrayBufferOptions extends BaseOptions { responseType: "arrayBuffer"; } export interface FormDataOptions extends BaseOptions { responseType: "formData"; } export type RequestOptions = | JSONOptions | TextOptions | BlobOptions | ArrayBufferOptions | FormDataOptions; import ky from "ky"; import type { ArrayBufferOptions, BlobOptions, FormDataOptions, JSONOptions, RequestOptions, TextOptions } from "./types"; /** ky 实例 */ const kyInstance = ky.create({ timeout: 3000000 // 单位ms, 30s }); export function request<T>(op: JSONOptions): Promise<T>; export function request(op: TextOptions): Promise<string>; export function request(op: BlobOptions): Promise<Blob>; export function request(op: ArrayBufferOptions): Promise<ArrayBuffer>; export function request(op: FormDataOptions): Promise<FormData>; export async function request(options: RequestOptions): Promise<unknown> { const { url, responseType, showErrorMessages = true, ...opt } = options; try { const res = await kyInstance(url, opt)[responseType](); return res as any; } catch (error) { if (showErrorMessages) { if ((error as Error).name === "AbortError") { // 取消请求 console.warn("请求已取消"); } else { console.error((error as Error)?.message ?? "请求失败"); } } return Promise.reject(error as Error); } }简单封装了一个request函数，使用如下：const a = await request<{ a: number }>({ url: "xxx", responseType: "json" });当我们传递的responseType为 json时，可以传递一个泛型来表示返回值的类型。如果是其他类型，比如responseType为blob时，那么得到的就是Blob类型返回。const a = await request({ url: "xxx", responseType: "blob" });具体可以参考request函数的类型定义。封装notifications通知有时候我们可能需要浏览器级别的消息通知，为此谷歌也提供了对应的API：chrome.notifications我们做一下简单封装，具体大家可以根据自己的业务需求调整。目录：src/utils/chrome-notifications，具体实现请去模板仓库查看代码。使用时：import { ChromeNotifications } from "@/utils/chrome-notifications"; ChromeNotifications.warning("这是一条警告消息");manifest中的permissions权限添加"notifications"。封装谷歌message插件中页面有很多种，他们之间如果想要通信，就只能通过谷歌的chrome.runtime.sendMessage和chrome.runtime.onMessage.addListener来实现消息发送接收。为此我将其封装成一个发布订阅模式，方便使用。具体代码就不提供了，文章末尾会提供模板仓库，大家自行查看：src\utils\chrome-message使用如下：import { EventName, chromeMessage } from "@/utils/chrome-message"; // 订阅消息 chromeMessage.on(EventName.EXAMPLE_EVENT, (message) => { ... }); chromeMessage.once(EventName.EXAMPLE_EVENT, (message) => { ... }); // 发布消息 chromeMessage.emit(EventName.EXAMPLE_EVENT, "发布一条消息"); // 取消订阅 chromeMessage.off(EventName.EXAMPLE_EVENT);manifest中的permissions权限添加"nativeMessaging"。封装谷歌上下文菜单由于谷歌注册菜单整个逻辑非常啰嗦，比如你要考虑菜单是不是已经存在了，存在了就得更新，不存在就得创建，如果每个菜单都要判断这个，那么代码重复率非常高，所以我做了一个封装统一入参和处理逻辑，简化了调用。代码路径：src\utils\chrome-context-menus使用：import { MenuId, chromeContextMenu } from "@/utils/chrome-context-menus"; chromeContextMenu.create({ menuProperties: { id: MenuId.EXAMPLE_MENU, ... }， onClicked: () => { ... } });通过统一的MenuId枚举来限制id值，这样就不会产生写错值导致的菜单重复问题。manifest中的permissions权限添加"contextMenus"。插件图标图标资源也必须放在public目录下，要求格式：16*16、32*32、48*48、128*128像素的，配置在manifest文件中。// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { icons: { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" } }; }); 其他资源文件处理有时候有些资源我们可能放置在public或者其它地方，但是在dev模式下，有些文件是没法检测到的，可能是因为v3版本的插件，它对资源的安全要求是非常高的，我们需要手动去在manifest中去声明这个资源，然后dev模式下才会给你自动移动到dist目录中，但是你用build打包不会有这个问题，但是我还是建议在manifest声明一下。举个例子，我们在public一般会放置一个favicon.ico的图标文件，但是在dev模式下，是不会给你丢到dist目录的。解决办法就是在manifest中声明一下资源文件：// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { web_accessible_resources: [ { resources: [ "favicon.ico" ], matches: ["<all_urls>"] } ] }; }); 在web_accessible_resources中声明，因为是public，所以path路径不需要加什么前缀，直接favicon.ico即可。我们的chrome-notifications封装也用到了图标，它的路径是：─ public │ └─ notifications-icon │ ├─ error.png │ └─ warning.png那么我们就可以这样写：// manifest.config.ts import { defineManifest } from "@crxjs/vite-plugin"; export default defineManifest(async (env) => { return { web_accessible_resources: [ { resources: [ "favicon.ico", "images/notifications-icon/error.png", "images/notifications-icon/warning.png" ], matches: ["<all_urls>"] } ] }; }); 如果是src下的资源，path示例：src/assets/xxx.jpg。具体路径大家根据自己的项目实际情况进行调整。Github谷歌插件模板仓库地址chrome-extension-template[/hide]
vite+vue3和nuxt3实现低版本浏览器兼容的解决方案（白屏问题）
前言项目全线使用vue3的时候，自然使用的是配套更加契合的vite打包工具，于是自然而然会用到很多新的语法，比如这两年开始普及的可选链语法，这个语法都已经纳入声呐的代码规范中了，但是vite自身打包的时候，并不会做过多的兼容处理。默认情况vite的兼容目标是支持原生ES模块，原生ESM动态导入和import.meta的浏览器，官方预设的兼容目标如下：es2020edge 88firefox 78chrome 87safari 14而我们的可选链正是es2020版本才有的，所以vite在打包后不会做任何兼容处理，这就导致了一些浏览器版本低一点的设备，打开网站就会出现白屏的情况。显然这么新的版本在大部分的目标用户中，也是很超前的，所以做一些适当的兼容处理还是有必要的。教程[hide]只兼容到ES2015（ES6）由于vue3的特性，最低也只能兼容只是es2015的浏览器，因为再低点没有proxy了，vite官方也提供了方便的配置项：// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' // https://vitejs.dev/config/ export default defineConfig({ plugins: [vue()], build: { target: "es2015" } })通过指定打包后的目标版本就行了，vite会将新的语法通过esbuild进行转换，此时我们在支持esm的浏览器上访问时正常的。（注意只有打包后的代码才会有效）这里我特意大写了 支持ESM的浏览器是有用意的，我们看下打包后的index.html文件。可以看到script标签上使用了type="module"属性，显然即便我们的脚本里面已经兼容到了es2015，但是如果浏览器稍微低一点的版本的，它是支持es2015但是它不支持ESM模块啊。我们看一下对应的支持统计：es2015es6版本最经典的就是proxy的支持ESM模块可以看到和es2015的支持度还是有一些差别的。那么就进入到第二个问题，如何兼容不支持esm的浏览器！兼容ESM浏览器官方提供了一个插件：legacy官方对它的定义是：为打包后的文件提供传统浏览器兼容性支持其实就是对不支持esm的浏览器的支持，同时还有语法降级处理。先进行安装：pnpm i @vitejs/plugin-legacy@4.1.1 terser -D需要注意的是vite4的legacy插件最高目前是4.1.1，再高的版本只支持vite5了，使用起来会报错。legacy插件需要依赖terser对代码进行压缩。使用起来也很简单，我们直接引入插件并启用即可：// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import legacy from "@vitejs/plugin-legacy"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), legacy() ], build: { target: "es2015" } })官方定义，在没有明确指定插件配置参数targets时，他会去读取项目中的.browserslistrc文件，我们知道这个文件是一个用于配置支持的浏览器范围的文件，通常会被Babel、Autoprefixer等插件使用。我个人根据es2015的支持版本书写了一下内容：.browserslistrcChrome >= 51 Edge >= 15 Safari >= 10 Firefox >= 54 Opera >= 38 iOS >= 10 not ie <= 11 Android >= 5 not IE <= 11然后我们再去打包项目，可以发现index.html的脚本引入发生了变化。可以看到script发生了变化，除了type="module"还有一个nomodule脚本，nomodule这个属性表示在支持esm的浏览器不运行里面的代码，而不支持esm的浏览器又无法识别type="module"，反而会去运行nomodule的script，从而实现了降级区分。而降级的脚本都会携带一个legacy文本这个就是用于兼容不支持esm模块的浏览器降级方案。这个方式的好处在较新的浏览器中可以使用新的特性，旧的浏览器使用legacy降级脚本，从而避免了以前打包时一刀切的兼容方式，所有的代码全部降级处理，使得一些新设备没法体现出新语法的性能。nuxt3兼容处理nuxt3有点沙雕，虽然他用了vite做打包，但是它不会默认读取项目根目录的.browserslistrc文件，所以我们需要将浏览器的版本兼容要求配置在package.json文件里。{ "name": "nuxt-app", "browserslist": [ ">0.3%", "not dead", "not op_mini all", "last 3 version", "Chrome >= 51", "Edge >= 15", "Safari >= 10", "Firefox >= 54", "Opera >= 38", "iOS >= 10", "Android >= 5", "not IE <= 11" ] } 我推荐是使用我这套配置，测试是支持vue3最低版本的配置，也是目前我使用中的，上面的那份.browserslistrc也可以根据这个调整一下。这里配置完毕后，我们再去nuxt.config.ts文件中配置一下vite：import legacy from "@vitejs/plugin-legacy"; export default defineNuxtConfig({ vite: { build: { target: ["es2015"] }, plugins: [ legacy({ renderLegacyChunks: false, modernPolyfills: true }) ] }, hooks: { "build:manifest": (manifest) => { const polyfillKey = "vite/legacy-polyfills"; const polyfillEntry = manifest[polyfillKey]; if (!polyfillEntry) return; const oldManifest = { ...manifest }; delete oldManifest[polyfillKey]; for (const key in manifest) { delete manifest[key]; } manifest[polyfillKey] = polyfillEntry; for (const key in oldManifest) { manifest[key] = oldManifest[key]; } } }, }) renderLegacyChunks和modernPolyfills配不配影响不大。hooks的配置是我看了下其他人的教程，有提到一个polyfill顺序的问题，需要自己手动调整顺序，所以我就照搬过来的，这点没有实际去验证，等有机会再说吧。[/hide]
前言最近发现动态去拼接资源路径还是挺常用的，于是把我之前封装的hooks单独分享出来。教程useAssets.tsexport function useAssets() { /** 前缀补路径斜杠 */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function addPrefixSlash(path: string) { return path.startsWith("/") ? path : `/${path}`; } /** 前缀去路径斜杠 */ function removePrefixSlash(path: string) { return path.startsWith("/") ? path.slice(1) : path; } /** * @description: 获取assets路径 * @param {string} path * @Date: 2023-08-03 17:35:35 * @Author: copilot */ function getAssetsUrl(path: string) { return new URL(`../assets/${removePrefixSlash(path)}`, import.meta.url).href; } /** * @description: 获取images路径 * @param {string} path * @Date: 2023-08-03 17:41:23 * @Author: copilot */ function getImagesUrl(path: string) { return new URL(`../assets/images/${removePrefixSlash(path)}`, import.meta.url).href; } return { getAssetsUrl, getImagesUrl }; } 封装了两个方法，一个是比较通用的拼接assets目录路径的，另一个也是用的比较多的，去拼接assets中images目录的方法，需要注意的是，new URL中的相对路径需要自行根据自己的目录结构调整下，比如我在src/hooks目录下，那么../就能返回到src，所以路径就不用调整了。使用 比较便捷的是我做了路径前面的/斜线处理，你可以不写，也可以加上。在hooks也增加了目前没有用到的addPrefixSlash，看大家自己的需求可以自行新增更多便捷的hooks。
前言多页面其实可以理解为多个独立的业务之间共享部分代码逻辑的实现。业务之间是相对独立的，只是小部分或者大部分的代码是可以共用的，常见的一个场景就是：改头换面形式的换肤。不同的皮肤之间毫无可复用组件代码的情况下，我们只能将通用的业务逻辑抽离成可复用的部分，简单点来说就是将数据与ui分离，数据是共用部分，ui是各自独立。最终衍生出的多页面结构大体如下：pages ├── pageA │ ├── App.vue │ ├── index.html │ ├── main.ts └── pageB ├── App.vue ├── index.html └── main.ts那么我们使用vite该如何实现这样的特性呢，下面开始一步步实践它！[hide]初始化项目目前官方其实有两种初始化项目的方式：vite官方初始化vue官方初始化其中vite官方提供的初始化命令：pnpm create vite 提供了一个非常简洁的环境，简洁带来了更好的应用广度，但是由于cli工具的日益复杂，很多配置网上的分享都是五花八门，所以我个人更推荐vue官方提供的：pnpm create vue@latest。包管理工具默认：pnpm可以看到官方提供了非常丰富的选项：✔ Project name: … <your-project-name> ✔ Add TypeScript? … No / Yes ✔ Add JSX Support? … No / Yes ✔ Add Vue Router for Single Page Application development? … No / Yes ✔ Add Pinia for state management? … No / Yes ✔ Add Vitest for Unit testing? … No / Yes ✔ Add an End-to-End Testing Solution? … No / Cypress / Playwright ✔ Add ESLint for code quality? … No / Yes ✔ Add Prettier for code formatting? … No / Yes Scaffolding project in ./<your-project-name>... Done.除了JSX、Vitest、End-to-End Testing我选择的no，其他都是yes，这些依赖项都已经是一个完整项目的标配了。配置多入口初始化完毕后我们定义下多入口的层级格式：ui ├── theme1 │ ├── App.vue │ ├── index.html │ ├── main.ts ├── theme2 │ ├── App.vue │ ├── index.html │ ├── main.ts └── theme3 ├── App.vue ├── index.html └── main.tsvite官方提供了多入口配置方式，如下：vite.config.tsimport { defineConfig } from "vite"; import { resolve } from "node:path"; export default defineConfig({ build: { rollupOptions: { input: { theme1: resolve(__dirname, "./src/ui/theme1/index.html"), theme2: resolve(__dirname, "./src/ui/theme2/index.html"), theme3: resolve(__dirname, "./src/ui/theme3/index.html"), }, }, }, })vite和webpack不同，vite是以html文件为入口的，webpack是用main.ts文件作为入口的，这点需要注意，我尝试通过插件将vite改为main文件为入口，打包时会出现各种问题，所以不建议main这种方式。然后我们将每个页面的index.html文件中的main文件因为改为相对地址： main文件也简单调整一下，去除一些不用的引入，暂时先只挂载app。import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) app.use(createPinia()) app.mount('#app')现在我们启动项目就可以通过访问：启动的地址+/src/ui/theme1/index.html 这种形式访问到页面。然后我们再去配置一下路由，创建几个页面，然后在app.vue中增加跳转按钮，测试路由跳转。配置路由router.tsimport { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition; } return { top: 0 }; }, routes: [ { path: "/a", name: "A", component: () => import("../views/a.vue") }, { path: "/b", name: "B", component: () => import("../views/b.vue") }, { path: "/c", name: "C", component: () => import("../views/c.vue") } ] }); export default router;App.vue中使用路由视图： <router-link :to="{ name: 'A' }">a</router-link> <router-link :to="{ name: 'B' }">b</router-link> <router-link :to="{ name: 'C' }">c</router-link> <router-view v-slot="{ Component }"> <component :is="Component"></component> </router-view> main文件中引入路由：import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; import router from "./router"; const app = createApp(App); app.use(createPinia()); app.use(router); app.mount("#app");测试当我们点击页面路由时，可以发现浏览器path发生了变化：路径由原来的：http://localhost:5173/src/ui/theme1/index.html变更为：http://localhost:5173/a显然这是不对的，当我们刷新页面后，就再也进不去页面了。这时有人会想到，既然切换路由的时候丢失了前缀，那么在使用createWebHistory创建history模式的时候传入前缀路径：/src/ui/theme1/index.html不就可以了。但是这样也不能解决问题，除非你采用hash路由，因为我们的前缀是带有index.html，是无法使用history路由模式的。import { createRouter, createWebHashHistory } from "vue-router"; const router = createRouter({ history: createWebHashHistory("/src/ui/theme1/index.html"), scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition; } return { top: 0 }; }, routes: [ { path: "/a", name: "A", component: () => import("../views/a.vue") }, { path: "/b", name: "B", component: () => import("../views/b.vue") }, { path: "/c", name: "C", component: () => import("../views/c.vue") } ] }); export default router;此时我们访问和刷新就正常了，但是这并不是最优解。我就是想要用history路由模式，并且我不希望加这么长的前缀，我希望我能这么访问：http://localhost:5173/theme1能做到吗？答案是可以的，解决这个问题我们需要从两方面去入手：1.本地开发时我们通过重写本地server的路径实现这个功能，类似于重定向，然后在createWebHistory("/theme1")传入简写路径实现。但是这种方式虽然可以开发时实现这个功能，但是build打包后，生成的文件路径如下：dist │ favicon.ico ├─assets │ a-4b2daf72.js │ b-ec5a6768.js │ c-454535b7.js │ theme1-aca1d732.js │ theme2-bb87a7b5.js │ theme3-bb87a7b5.js │ _plugin-vue_export-helper-bbe9ee29.js │ └─src └─ui ├─theme1 │ index.html │ ├─theme2 │ index.html │ └─theme3 index.html你会发现index.html文件还是保持原路径层级不变的，这就导致我们生产环境通过访问域名/theme1无法真正的访问到对应的index.html文件，但是我们在路由创建时传入的路径又是/theme1，这就很尴尬了。为了解决这个问题我们就需要第二步。2.手动实现将打包后的文件更改为正确的目录结构，实际上就是利用node的fs来对文件进行移动删除。通过这两步我们可以实现开发和生产路由路径一致。编写重定向插件这里要感谢《【vite +Vue3多入口开发】配置及踩坑实录》这篇文章的作者，重定向我是从这里学到的。先安装依赖：pnpm i connect-history-api-fallback -D创建文件：vite-rewrite-path.ts内容如下：import type { Plugin } from "vite"; import history from "connect-history-api-fallback"; import type { Rewrite } from "connect-history-api-fallback"; import { join } from "node:path"; import { readdirSync } from "node:fs"; /** 读取项目ui下的皮肤生成重定向规则 */ function readDirGeneratedRules(rootPath: string): Array<Rewrite> { const rules: Array<Rewrite> = []; const uiPath = join(rootPath, "src/ui"); readdirSync(uiPath).forEach((name) => { rules.push({ from: new RegExp(`/${name}`), to: `/src/ui/${name}/index.html` }); }); console.log("📄 重新生成了重定向规则"); return rules; } export default function viteRewritePath(): Plugin { let rules: Array<Rewrite> = []; return { name: "vite-rewrite-path", configResolved(config) { rules = readDirGeneratedRules(config.root); }, configureServer(server) { server.middlewares.use( // @ts-ignore history({ htmlAcceptHeaders: ["text/html", "application/xhtml+xml"], disableDotRule: void 0, rewrites: rules }) ); } }; } 其实就是通过读取ui下的所有目录，得到对应的分页名，然后进行“重定向”，将：/theme1重定向到/src/ui/theme1/index.html，同时路由path不发生路径变化。然后通过for循环批量生成上述规则。引入插件：vite.config.tsimport { defineConfig } from "vite"; import viteRewritePath from "./vite-rewrite-path"; export default defineConfig({ plugins: [viteRewritePath()], })此时还没有完，将router的模式改为如下：history: createWebHistory("/theme1"),然后再将index.html中的main文件因为改为绝对地址，因为此时我们的实际运行环境和文件所在的环境路径已经不一样了，使用相对路径拿不到main文件。 此时再启动项目，即可通过：http://localhost:5173/theme1正常访问到页面，刷新页面也能正常查看。编写打包后移动文件脚本创建一个： moveHtml.ts 文件import { resolve, join } from "node:path"; import { readdirSync, statSync, access, rm, rename } from "node:fs"; /** dist路径 */ const distPath = resolve(__dirname, "./dist"); /** 获取皮肤目录 */ function getThemeDirList(themePath: string) { const dirList = readdirSync(themePath).filter((filePath) => { const fullPath = join(themePath, filePath); return statSync(fullPath).isDirectory(); }); return dirList; } /** 判断文件或者目录是否存在 */ function isExists(path: string) { return new Promise((resolve) => { access(path, (error) => { //错误对象不为null表示文件或者目录不存在 if (error) return resolve(false); return resolve(true); }); }); } /** 删除文件或者目录 */ function removeFileOrDir(path: string) { return new Promise((resolve, reject) => { rm(path, { force: true, recursive: true }, (error) => { //如果error存在那表示删除出现了错误 if (error) return reject(error); return resolve(true); }); }); } /** 移动文件 */ function moveFile(oldPath: string, newPath: string) { //先判断新位置文件是否存在 return isExists(newPath) .then((isExists) => { //如果已经存在，先删除 if (isExists) { return removeFileOrDir(newPath); } return Promise.resolve(true); }) .then(() => { //移动 return new Promise((resolve, reject) => { rename(oldPath, newPath, (error) => { //error存在表示移动失败了 if (error) return reject(error); return resolve(true); }); }); }) .then(() => { console.log(`📁 已将 ${oldPath} 移动到 ${newPath}`); }) .catch((error) => { console.error("移动文件发生了错误：", error); throw error; }); } /** 移动html文件 */ function moveHtml() { // 项目路径/src/ui const uiPath = join(distPath, "src/ui"); //获取皮肤目录名 const themeDirList = getThemeDirList(uiPath); const movePromiseList: Array<Promise<any>> = []; themeDirList.forEach((themePath) => { const oldPath = join(uiPath, themePath); const newPath = join(distPath, themePath); movePromiseList.push(moveFile(oldPath, newPath)); }); //所以的都移动完成 Promise.all(movePromiseList).then(() => { //删除dist/项目/src目录 const srcPath = join(distPath, "src"); removeFileOrDir(srcPath); }); } moveHtml(); 打包完成后我们再运行：ts-node moveHtml.ts 命令。由于是ts，所以我们还需要安装一下ts-node依赖：pnpm i ts-node -D为了方便，我们可以把命令集成到build命令中去，修改一下package.json文件：{ "scripts": { "vite-build": "vite build", "move-html": "ts-node moveHtml.ts", "build": "run-s vite-build move-html" } }run-s这个是我们使用vue的初始化项目命令时，它会给我们安装npm-run-all这个依赖，里面就会有run-s这个命令，表示顺序执行。此时我们运行pnpm run build命令就可以实现打包并移动html文件的行为。多页面Nginx配置location ~ ^/(theme[0-9]+)/ { try_files $uri $uri/ /$1/index.html; }假设我们的访问地址是：www.xxxx.com/theme1www.xxxx.com/theme2www.xxxx.com/theme3可以看到我们需要它在访问二级目录时能返回对应二级目录的index.html文件，以适应history路由，目前这份配置测试没有什么问题。[/hide]
vue3 自定义移动端拖拽指令（支持吸边和offset间距以及回调）
前言移动端开发的时候，总是会遇到编写全局浮动组件的情况，比如客服图标啦，消息通知啦，但是浮动就会产生一个问题：阻挡了下层内容展示，常见的做法就是让这个浮动的内容支持拖拽功能，用户将浮动组件拖拽到其他位置即可。这里我参考了vConsole组件，自己根据实际情况编写了一个拖拽指令。代码指令文件：drag.tsimport type { Directive } from "vue"; /** 指令接收的参数 */ interface BindingValue { /** 出现位置的偏移量 [x,y] */ offset?: [number, number]; /** 缓存的key前缀 */ storageKey: string; /** 是否吸边x轴 */ adsorptionX?: boolean; /** 是否吸边y轴 */ adsorptionY?: boolean; /** 拖拽开始 */ dragStart?: (event: TouchEvent) => void; /** 拖拽中 */ dragMove?: (event: TouchEvent) => void; /** 拖拽结束 */ dragEnd?: (event: TouchEvent) => void; } /** 默认参数 */ type DefaultBindingValue = Required<Omit<BindingValue, "storageKey">>; /** 拖拽的数据 */ interface DragData { x: number; y: number; startX: number; startY: number; endX: number; endY: number; hasMove: boolean; init: boolean; } /** 拖拽元素 */ interface DragElement extends HTMLElement { __dragData: DragData; __bindingValue: Required<BindingValue>; } /** 缓存位置数据 */ interface LocalPositionData { x: number | null; y: number | null; } /** ios安全底部高度 */ const IOS_SAFE_BOTTOM = 20; /** 指令参数校验 */ function validateBindingValue(bindingValue: BindingValue): [Error | null, boolean] { if (!bindingValue) { return [new Error("Missing argument for drag directive!"), false]; } if (typeof bindingValue.storageKey !== "string" || bindingValue.storageKey.trim() === "") { const error = new Error( "The storageKey parameter of the drag directive must be a string and cannot be an empty string!" ); return [error, false]; } if (!Array.isArray(bindingValue.offset) || bindingValue.offset.length !== 2) { return [new Error("The drag directive offset argument must be array and length 2!"), false]; } else { const isNotNumber = bindingValue.offset.some((item) => typeof item !== "number"); if (isNotNumber) { return [new Error("The drag instruction offset array must be all numbers"), false]; } } if (typeof bindingValue.adsorptionX !== "boolean") { return [new Error("The adsorptionX parameter of the drag directive must be a boolean!"), false]; } if (typeof bindingValue.adsorptionY !== "boolean") { return [new Error("The adsorptionY parameter of the drag directive must be a boolean!"), false]; } if (typeof bindingValue.dragStart !== "function") { return [new Error("The dragStart parameter of the drag directive must be a function!"), false]; } if (typeof bindingValue.dragMove !== "function") { return [new Error("The dragMove parameter of the drag directive must be a function!"), false]; } if (typeof bindingValue.dragEnd !== "function") { return [new Error("The dragEnd parameter of the drag directive must be a function!"), false]; } return [null, true]; } /** 获取设备大小 */ function getDeviceSize() { const docWidth = Math.max(document.documentElement.offsetWidth, window.innerWidth); const docHeight = Math.max(document.documentElement.offsetHeight, window.innerHeight); return { width: docWidth, height: docHeight }; } /** 获取安全位置 */ function getSafePosition(el: DragElement, x: number, y: number): [number, number] { const deviceSize = getDeviceSize(); const { offset } = el.__bindingValue; const minX = offset[0]; const maxX = deviceSize.width - el.offsetWidth - offset[0]; const minY = IOS_SAFE_BOTTOM + offset[1]; const maxY = deviceSize.height - el.offsetHeight - offset[1]; if (x < minX) x = minX; if (x > maxX) x = maxX; if (y < minY) y = minY; if (y > maxY) y = maxY; return [x, y]; } /** 获取持久化位置数据 */ function getLocalStoragePosition(prefix: string): LocalPositionData { const x: string | null = localStorage.getItem(`__${prefix}_x`); const y: string | null = localStorage.getItem(`__${prefix}_y`); return { x: x ? parseInt(x) : null, y: y ? parseInt(y) : null }; } /** 设置持久化位置数据 */ function setLocalStoragePosition(prefix: string, x: number, y: number) { localStorage.setItem(`__${prefix}_x`, x.toString()); localStorage.setItem(`__${prefix}_y`, y.toString()); } /** 初始化位置 */ function initPosition(el: DragElement) { const dragData = el.__dragData; if (dragData.init) return; let flag = false; const rect = el.getBoundingClientRect(); const bindingValue = el.__bindingValue; const deviceSize = getDeviceSize(); //缓存的位置 const localPosition = getLocalStoragePosition(bindingValue.storageKey); if (localPosition.x !== null && localPosition.y !== null) { dragData.x = localPosition.x; dragData.y = localPosition.y; flag = true; } else { if (rect.left === 0 && rect.bottom === 0) return; //created钩子中初始化 rect是获取不到值的 dragData.x = rect.left; dragData.y = deviceSize.height - rect.bottom; flag = true; } if (flag) { el.style.right = "auto"; el.style.top = "auto"; el.style.left = dragData.x + "px"; el.style.bottom = dragData.y + "px"; dragData.init = true; } } /** 获取吸边生成的位置 */ function getAdsorptionPosition(el: DragElement, x: number, y: number): [number, number] { const deviceSize = getDeviceSize(); const halfDeviceWidth = deviceSize.width / 2; const halfDeviceHeight = deviceSize.height / 2; const { offset, adsorptionX, adsorptionY } = el.__bindingValue; //x轴吸边 if (adsorptionX) { if (x < halfDeviceWidth) { x = offset[0]; } else { x = deviceSize.width - el.offsetWidth - offset[0]; console.log("🚀 ~ file: drag.ts:193 ~ offset[0]:", offset[0]); console.log("🚀 ~ file: drag.ts:193 ~ el.offsetWidth:", el.offsetWidth); console.log("🚀 ~ file: drag.ts:193 ~ deviceSize.width:", deviceSize.width); } } //y轴吸边,xy轴吸边默认x轴优先 if (adsorptionY && !adsorptionX) { if (y < halfDeviceHeight) { y = IOS_SAFE_BOTTOM + offset[1]; } else { y = deviceSize.height - el.offsetHeight - offset[1]; } } return [x, y]; } /** 开始触摸 */ function onTouchStart(event: TouchEvent) { const el = event.currentTarget as DragElement; const dragData = el.__dragData; const bindingValue = el.__bindingValue; const touchData = event.touches[0]; //回调 bindingValue.dragStart(event); dragData.startX = touchData.clientX; dragData.startY = touchData.clientY; dragData.hasMove = false; //初始化方位 initPosition(el); } /** 触摸移动 */ function onTouchMove(event: TouchEvent) { event.preventDefault(); if (event.touches.length <= 0) return; const el = event.currentTarget as DragElement; const dragData = el.__dragData; const bindingValue = el.__bindingValue; const touchData = event.touches[0]; //回调 bindingValue.dragMove(event); const offsetX = touchData.clientX - dragData.startX; const offsetY = touchData.clientY - dragData.startY; let x = Math.floor(dragData.x + offsetX); let y = Math.floor(dragData.y - offsetY); [x, y] = getSafePosition(el, x, y); el.style.left = x + "px"; el.style.bottom = y + "px"; dragData.endX = x; dragData.endY = y; dragData.hasMove = true; } /** 触摸结束 */ function onTouchEnd(event: TouchEvent) { const el = event.currentTarget as DragElement; const dragData = el.__dragData; const bindingValue = el.__bindingValue; //回调 bindingValue.dragEnd(event); if (!dragData.hasMove) return; dragData.startX = 0; dragData.startY = 0; dragData.hasMove = false; let [x, y] = getSafePosition(el, dragData.endX, dragData.endY); if (bindingValue.adsorptionX || bindingValue.adsorptionY) { [x, y] = getAdsorptionPosition(el, x, y); } dragData.x = x; dragData.y = y; el.style.left = x + "px"; el.style.bottom = y + "px"; dragData.hasMove = false; //持久化位置 setLocalStoragePosition(bindingValue.storageKey, x, y); } const directive: Directive<DragElement, BindingValue> = { created(el, binding, vnode, prevVnode) { const { value } = binding; const bindingValue = Object.assign<DefaultBindingValue, BindingValue>( { offset: [0, 0], adsorptionX: false, adsorptionY: false, dragStart: () => {}, dragMove: () => {}, dragEnd: () => {} }, value ); //指令参数校验 const [error, validate] = validateBindingValue(bindingValue); if (import.meta.env.VITE_ENV !== "development" && error) { console.error(error.message); return; } else { if (error) throw error; } //数据处理 const dragData: DragData = { x: 0, y: 0, startX: 0, startY: 0, endX: 0, endY: 0, hasMove: false, init: false }; el.__dragData = dragData; el.__bindingValue = bindingValue; //初始化位置 initPosition(el); //绑定事件 el.addEventListener("touchstart", onTouchStart); el.addEventListener("touchmove", onTouchMove); el.addEventListener("touchend", onTouchEnd); }, beforeUnmount(el) { //删除数据 Reflect.deleteProperty(el, "__dragData"); Reflect.deleteProperty(el, "__bindingValue"); //解绑事件 el.removeEventListener("touchstart", onTouchStart); el.removeEventListener("touchmove", onTouchMove); el.removeEventListener("touchend", onTouchEnd); } }; const dragDirective = { name: "drag", directive }; export { dragDirective }; export default dragDirective; 具名和默认导出了dragDirective，然后去main.ts中激活指令即可。import drag from "./directive/drag"; const app = createApp(App); app.directive(drag.name, drag.directive);使用时： 支持的参数参考类型BindingValue。 {}, dragMove: ()=> {}, dragEnd: ()=> {}, }"> 吸边的效果，一般情况下要么是X轴吸边，要么Y轴吸边，不可能同时两个轴都进行吸边，因为体验会很差，所以这里做了处理，如果xy同时为true的情况下，优先x轴吸边。用户拖拽后肯定是需要记住拖拽后的位置，所以强制要求传入storageKey，如果你的项目中不需要记住，就自行调整下吧。不知道为什么，自定义指令在实际使用的时候并没有被正确识别指令参数类型，所以我加了个参数校验，在开发者模式中直接就抛出错误，而在非开发者模式，仅打印错误并return，不处理任何拖拽逻辑。拖拽核心逻辑touchstart事件触发时，记录用户点击的位置，在touchmove的时候，计算移动时的位置与初始位置的差值，然后将差值与元素初始位置xy进行加减。注意精华就是这个：在move时所有的移动位置计算，都是基于初始位置计算的，我一开始认为move时边移动，边计算与上一个移动位置的差距，导致计算非常复杂，效果还很差。当用户touchend后，根据配置计算吸边，然后清空起始位置，缓存记录位置。
全局方法挂载和类型声明全局挂载还是很简单的，照着官方的例子写就可以了，我的做法是创建了一个单独ts文件，通过app.use()的方式来进行批量挂载，代码如下：import type { App } from "vue"; /** * @description: 获取assets文件 * @param {string} path * @Date: 2023-05-05 10:05:18 * @Author: mulingyuer */ export function getAssetsFile(path: string): string { return new URL(`../assets/${path}`, import.meta.url).href; } export default { install(app: App) { app.config.globalProperties[`$getAssetsFile`] = getAssetsFile; app.provide(`$getAssetsFile`, getAssetsFile); } };除了通过globalProperties的方式注册在全局上，我还通过provide注入的方式挂载，这个注入可比globalProperties省事的多了，但是它的类型推断最后是unknown，还得自个声明一下类型，就又给人一种鸡肋的感觉。最后在main.ts中use一下import { createApp } from "vue"; import App from "./App.vue"; //全局方法 import globalMethods from "./utils/globalMethods"; const app = createApp(App); app.use(globalMethods); app.mount("#app");这就完事了，使用的时候如下： 直接通过inject接，或者通过getCurrentInstance获取，各有各的恶心人的地方：inject：获取的内容类型是unknown，使用时需要自己把类型贴上，比如：const fns = inject<Function>("$getAssetsFile");。为了更加准确的推导，你可能还需要从别处引入类型声明，我都从别处引入类型声明了，我还用个勾八全局方法，都是import，我直接import引入对应的方法不是更加省事了。getCurrentInstance：这个玩意也很膈应人，getCurrentInstance()返回值可能是null，它的proxy属性也可能null，所以不得不加!声明存在，然后再通过属性获取到挂载的方法。使用这种方式的优势在于如果我们事先声明了类型，那么在这就能直接推导出来，恶心的点也在这，新版的类型声明又特么变了，具体如下：创建一个global.d.ts文件，global名字自己随意，你也可以起个vue.d.ts文件。import { ComponentCustomProperties } from "vue"; import { getAssetsFile } from "../utils/globalMethods"; declare module "vue" { export interface ComponentCustomProperties { $getAssetsFile: typeof getAssetsFile; } } export default ComponentCustomProperties;注意：务必默认导出ComponentCustomProperties否则ts类型各种报错，这个问题之前是没有的。vw的转换相关配置这次是新项目，所以我才用了现在比较新的vw转换，用于代替rem转换，他有一个优势：不需要单独的js脚本去更改根元素字体大小由于不需要单独的js脚本，对于spa做ssr还是很省事的，就不需要单独把这个rem脚本提取出来，然后在index.html中引入了。vw目前业界支持度还是很高的，如果不是支持远古设备，值得体验。vw的原理和rem差不多，只不过现在是相对于视口宽度vw来做了。安装插件：pnpm i postcss-px-to-viewport-8-plugin autoprefixer -Dpostcss-px-to-viewport-8-plugin做vw转换，autoprefixer 做浏览器前缀添加。vite内置了postcss，所以不需要我们单独安装，直接在项目目录与src目录同级，创建postcss.config.js文件，填入以下内容：module.exports = { plugins: { "postcss-px-to-viewport-8-plugin": { viewportWidth: 375, unitPrecision: 6, unitToConvert: "px", propList: ["*"], selectorBlackList: [], minPixelValue: 2 }, autoprefixer: {} } };viewportWidth 表示的是设计稿的宽度unitPrecision 表示转换后保留几位小数位unitToConvert 需要被转换的单位是什么propList 需要转换的css属性，比如font-size这些，星号表示所有的都需要selectorBlackList 指定不需要进行转换的class选择器minPixelValue 最小转换的值，最小2是可以转换的，1不行。本来plugins应该是最新是以数组形式使用的const postcssPxToViewport8Plugin = require("postcss-px-to-viewport-8-plugin"); const autoprefixer = require("autoprefixer"); module.exports = { plugins: [ postcssPxToViewport8Plugin({ viewportWidth: 375, unitPrecision: 6, unitToConvert: "px", propList: ["*"], selectorBlackList: [], minPixelValue: 2 }), autoprefixer ] }; 但是感觉略有麻烦，看大家自己选择了。配置完毕后我们还需要创建一个.browserslistrc用来表面需要兼容的浏览器有哪些，这个文件和postcss.config.js同级.browserslistrc> 1% last 2 versions not ie 11具体的可以看看我之前那篇 浏览器前缀 的文章，里面有具体讲过这个文件的配置意思。到这基本就完事了，启动项目即可享受。varlet框架的自动引入github地址：varlet这是一个vue3的移动端Material design风格框架，简单用用还是可以，如果是复杂业务考虑好再用。框架本身就有对这个做支持，我们只需要安装一下插件：pnpm i @varlet/ui unplugin-vue-components unplugin-auto-import -D由于使用自动引入，所以务必不要在main.ts中引入注册了，不需要，否则你打包完就是全量引入，错误示范如下：import App from './App.vue' import Varlet from '@varlet/ui' import { createApp } from 'vue' import '@varlet/ui/es/style' createApp(App).use(Varlet).mount('#app')vite.config.ts配置自动引入：import vue from '@vitejs/plugin-vue' import components from 'unplugin-vue-components/vite' import autoImport from 'unplugin-auto-import/vite' import { VarletUIResolver } from 'unplugin-vue-components/resolvers' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ vue(), components({ resolvers: [VarletUIResolver()] }), autoImport({ resolvers: [VarletUIResolver({ autoImport: true })] }) ] })最后会在第一次启动的时候生成：auto-imports.d.ts和components.d.ts文件，建议这个也上传到git仓库，省的每次都生成，在大量代码的情况下可能会卡顿。tsconfig.json中还需要添加一下这两个文件：{ "include": ["auto-imports.d.ts", "components.d.ts"] }至此完成。环境变量和对应的类型声明使用环境变量是项目完整度一个离不开的东西，你想想你在开发环境api可能是局域网提供的，正式版又是另一个地方提供的，通过环境变量进行配置可以节省很多重复的操作。vite使用指定的环境变量和webpack一样，都是通过--model [环境变量文件名] 实现vite在初始化的时候就已经创建了一个vite-env.d.ts文件，我们直接用它这个，没有就自己手动创建。/// <reference types="vite/client" /> interface ImportMetaEnv { readonly VITE_APP_NAME: string; readonly VITE_ENV: string; readonly VITE_OPEN_ANALYZER: string; } interface ImportMeta { readonly env: ImportMetaEnv; }这次的环境变量类型声明和之前略有不同，这也是难受之所在，老特么换，唉。实现在vite.config.ts中获取到环境变量咋一看好像有问题，其实在vite.config.ts中通过process.env拿到的只是node的环境变量，而vite提供的import.meta.env这种方式只能在项目文件中使用，比如main.ts，是无法在vite本身的配置文件中使用的，我们只能通过其他方式实现：import { defineConfig, loadEnv } from "vite"; export default ({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; return defineConfig({...}); }这样我们就能通过 process.env 拿到当前启动时所使用的环境变量了。利用环境变量实现正式打包gzip安装一下gizp插件：pnpm i vite-plugin-compression -D创建两个环境变量文件，于src目录同级，填入对应的内容：.env.developmentVITE_ENV="development".env.productionVITE_ENV="production"vite.config.ts文件添加import { defineConfig, loadEnv } from "vite"; import vue from "@vitejs/plugin-vue"; import viteCompression from "vite-plugin-compression"; // https://vitejs.dev/config/ export default ({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; //插件 const plugins = [ vue(), ]; //gzip压缩 if (process.env.VITE_ENV === "production") { plugins.push( viteCompression({ verbose: true, disable: false, threshold: 10240, algorithm: "gzip", ext: ".gz" }) ); } return defineConfig({ plugins, }); };通过这种方式我们还可以实现一个功能，就是代码视图分析。利用环境变量实现代码视图分析vue cli 提供了report修饰符vue-cli-service build --report这样就能生成一个打包后代码分析网页report.html用于帮助我们更好的处理打包后的代码处理。但是vite自身没有这个功能，我们的目标是和vue cli类似，通过一个命令实现这个功能。首先我们创建一个专门的环境变量文件：.env.analyzerVITE_ENV="production" VITE_OPEN_ANALYZER="true"通过这个VITE_OPEN_ANALYZER我们来标识是否需要开启代码分析。安装依赖：pnpm i rollup-plugin-visualizer -D配置vite.config.ts文件import { defineConfig, loadEnv } from "vite"; import vue from "@vitejs/plugin-vue"; import { visualizer } from "rollup-plugin-visualizer"; // https://vitejs.dev/config/ export default ({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; //插件 const plugins = [ vue(), ]; //代码分析 if (process.env.VITE_OPEN_ANALYZER && process.env.VITE_OPEN_ANALYZER === "true") { plugins.push( visualizer({ emitFile: true, filename: "analyzer.html", gzipSize: true }) ); } return defineConfig({ plugins, }); }; 配置完成后我们给package.json文件配置一个scripts脚本命令：{ "scripts": { "analyzer": "vue-tsc && vite build --mode analyzer" } }使用的时候运行analyzer即可。pnpm run analyzer这个插件缺陷也很大，样式丑，而且分析的文件大小居然是没有打包前的文件大小，我build之后，js文件可能就80kb，这个分析的文件，他把没有压缩的包体给你计算出来了，巨难受，只能用来看看是不是有多余的引入之类的。Prettier - Code formatter推荐使用这个vscode插件来进行代码格式化，这里提供一份目前个人使用的配置：.prettierrc{ "arrowParens": "always", "bracketSameLine": false, "bracketSpacing": true, "embeddedLanguageFormatting": "auto", "htmlWhitespaceSensitivity": "css", "insertPragma": false, "jsxSingleQuote": false, "printWidth": 120, "proseWrap": "preserve", "quoteProps": "as-needed", "requirePragma": false, "semi": true, "singleAttributePerLine": false, "singleQuote": false, "tabWidth": 2, "trailingComma": "none", "useTabs": true, "vueIndentScriptAndStyle": false, "endOfLine": "lf" }本博客发布了一篇关于Prettier 格式化配置，建议使用那份，博客内搜索Prettier 即可。eslint配合Prettier做代码检查eslint恶心人，但是它又能很好的规范你的代码，又爱又恨。安装依赖：pnpm i @vue/eslint-config-typescript eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-vue vite-plugin-eslint -Dvite.config.ts加入配置：import { defineConfig, loadEnv } from "vite"; import vue from "@vitejs/plugin-vue"; import EslintPlugin from "vite-plugin-eslint"; // https://vitejs.dev/config/ export default ({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; //插件 const plugins = [ vue(), EslintPlugin({ cache: false, fix: true }) ]; return defineConfig({ plugins, }); };在项目根目录，与src同级创建eslint的配置文件：.eslintrc.jsmodule.exports = { root: true, env: { node: true }, extends: [ "plugin:vue/vue3-essential", "eslint:recommended", "@vue/eslint-config-typescript", "plugin:prettier/recommended" ], rules: { "vue/multi-word-component-names": "off", "no-unused-vars": ["error", { vars: "all", args: "none", ignoreRestSiblings: false }], "vue/no-unused-components": "off" // 取消检查未使用的组件 } };.eslintignore这个文件暂时空白，主要是用于添加文件黑名单，这样这些对应的文件不会走eslint检查。小技巧：当你有一个函数，它接收3个参数，而你只需要用到最后一个参数，这个时候eslint其实会报错的，我们可以通过给不用的参数名前缀加下划线来实现忽略，vue-router的例子：import { createRouter, createWebHashHistory } from "vue-router"; import routes from "./routes"; const router = createRouter({ history: createWebHashHistory(), routes, scrollBehavior(_to, _from, savedPosition) { return savedPosition || { top: 0 }; } }); export default router;添加一个vscode的项目配置文件：.vscode/settings.json{ "eslint.validate": ["javascript", "javascriptreact", "vue", "typescript", "typescriptreact"], } 至此完成。路径别名vite.config.tsimport { defineConfig, loadEnv } from "vite"; import vue from "@vitejs/plugin-vue"; import { fileURLToPath, URL } from "node:url"; // https://vitejs.dev/config/ export default ({ mode }) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; //插件 const plugins = [ vue(), ]; return defineConfig({ plugins, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, extensions: [".js", ".json", ".jsx", ".mjs", ".ts", ".tsx", ".vue"] }, }); };ts.config.json{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] }, }, }推荐在.vscode/settings.json中加入："typescript.suggest.paths": true, "javascript.suggest.paths": true明明我安装了Path Intellisense和 Path Autocomplete，但是不配这两个总是不出来路径提示，唉！img图片路径相关vite的静态资源引入相对于vue cli还是差了很多的，对于动态绑定图片路径基本没办法，官方提供的解决方案也是有各种限制，复用度基本为0。首先，只有在vue组件中，你的img图片src属性可以直接绑定显式路径。 除了可以使用路径别名，你还可以使用相对路径。 但是便利性也仅限于此了，如果你想中间动态拼接一个关键词，哦吼！不好意思，使用路径别名@/将无法正确解析。然后官方提供了几种解决方案：import引入你可以直接import引入图片资源然后return出去。 使用的时候绑定即可。 但是这种方式未免有点麻烦，如果图片地址是从配置文件里面拿的，就不好弄了。new URL引入如果存在动态变量的情况下，官方又提供了new URL拼接的方式。官方文档： new URLfunction getImageUrl(name: string) { return new URL(`../assets/images/${name}`, import.meta.url).href } 最新vite4测试中发现，new Url确实可以作为require函数的替代品，此时../assets是相对于这个函数所在的文件的，为此我提供一个自己封装的hooks代码。useAssets.tsexport default function useAssets() { /** 前缀补路径斜杠 */ function addPrefixSlash(path: string) { return path.startsWith("/") ? path : `/${path}`; } /** 前缀去路径斜杠 */ function removePrefixSlash(path: string) { return path.startsWith("/") ? path.slice(1) : path; } /** * @description: 获取assets路径 * @param {string} path * @Date: 2023-08-03 17:35:35 * @Author: copilot */ function getAssetsUrl(path: string) { return new URL(`../assets/${removePrefixSlash(path)}`, import.meta.url).href; } /** * @description: 获取images路径 * @param {string} path * @Date: 2023-08-03 17:41:23 * @Author: copilot */ function getImagesUrl(path: string) { return new URL(`../assets/images/${removePrefixSlash(path)}`, import.meta.url).href; } return { getAssetsUrl, getImagesUrl }; } 事实上其实也不必非得封装成一个hooks，个人理解这个功能是用于替代webpack中的require函数的，它可以说不是一JavaScript环境应该提供的功能，所以我没有把他封装成普通工具函数丢到utils工具函数中去。import.meta.glob上面两种方案都有各自限制，我看了一些文档，官方提供了这么一个方法用于以字符串形式导入资源。其原理和node的glob差不多，我们先通过正则匹配到一个目录下的所有文件，然后得到一个对象，对象的key就是文件的path，值则是一个方法，方法是一个promise，我们需要在then中得到一个res对象，通过res.default得到正确资源路径。但是由于是一个异步的内容，我们不得不自己在组件中创建一个ref变量做占位，然后等待promise完成后将ref的值改变，图片src则绑定这个ref值。但是这也很痛苦，我需要书写特别多的代码来实现这个功能。const getImgSrc = (path: string) => { const path = `/src/assets/images/${name}`; const modules = import.meta.globEager('/src/assets/images/*'); const fn = modules[`/src/assets/images/${path}`]; if(typeof fn === "function") { return fn(); } else { return Promise.reject(new Error("资源不存在")); } } export default getImgSrc 相当痛苦。我感觉目前vite对于静态资源的导入，真的没有什么好办法了，我们知道，引入assets中的图片资源是有优化处理的，比如hash后缀防止缓存，base64处理，使用图片压缩插件还能压缩图片，但是这些好的效果在稍微复杂一点的需求的情况下，基于上面三种方案，就不得不去做妥协，可能是我已经习惯了webpack那种方式带来的便捷了。最终我的方案 public由于我的需求是维护一份大的配置文件，这个文件导出对象，对象里会有对应的图片资源路径，这样方便共用同一个项目结构的情况下，切换不同的内容。我通过更改环境变量中的key来指定使用对象中哪个属性。基于这个场景，我发现我要么在每个组件里写上一个相同的imgPath方法，否则没有更便捷的方式了，想来想去我选择了最省事的办法，就是使用public，这样我不需要书写动态引入的方法，所有的资源文件全部绝对路径引入。为此我不得不放弃assets带来的资源优化特性。具体代码就不写了，没啥特殊的。