Showing Posts From
Vue
Vue 3 前端导出 Excel:xlsx + file-saver 封装 useExportExcel
安装 npm install xlsx file-saver基础用法 import * as XLSX from 'xlsx' import { saveAs } from 'file-saver'const data = [ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ]const ws = XLSX.utils.json_to_sheet(data) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })saveAs(blob, '数据.xlsx')封装 useExportExcel // useExportExcel.js import * as XLSX from 'xlsx' import { saveAs } from 'file-saver' import { ref } from 'vue'export function useExportExcel() { const exporting = ref(false) const exportExcel = (data = [], fileName = '数据表', headerMap = {}) => { if (!data.length) return exporting.value = true try { // 按 headerMap 重命名列 const exportData = data.map(row => { const newRow = {} Object.keys(row).forEach(key => { newRow[headerMap[key] || key] = row[key] }) return newRow }) const ws = XLSX.utils.json_to_sheet(exportData) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) saveAs(blob, `${fileName}.xlsx`) } finally { exporting.value = false } } const exportSelected = (allData, selectedRows, fileName, headerMap = {}) => { exportExcel(selectedRows?.length ? selectedRows : allData, fileName, headerMap) } return { exporting, exportExcel, exportSelected } }配合 Element Plus el-table 使用 <template> <el-button :loading="exporting" @click="onExportAll">导出全部</el-button> <el-button :loading="exporting" @click="onExportSelected">导出选中</el-button> <el-table :data="tableData" @selection-change="selectedRows = $event"> <el-table-column type="selection" /> <el-table-column prop="name" label="姓名" /> <el-table-column prop="age" label="年龄" /> <el-table-column prop="city" label="城市" /> </el-table> </template><script setup> import { ref } from 'vue' import { useExportExcel } from './useExportExcel'const tableData = ref([ { name: '张三', age: 18, city: '北京' }, { name: '李四', age: 20, city: '上海' } ])const selectedRows = ref([])const { exporting, exportExcel, exportSelected } = useExportExcel()const headerMap = { name: '姓名', age: '年龄', city: '城市' }const onExportAll = () => exportExcel(tableData.value, '用户列表', headerMap) const onExportSelected = () => exportSelected(tableData.value, selectedRows.value, '选中用户', headerMap) </script>导出 DOM table 如果页面已有 <table> 元素,不需要操作数据,直接从 DOM 生成: const table = document.getElementById('myTable') const wb = XLSX.utils.table_to_book(table) XLSX.writeFile(wb, 'table.xlsx')大数据后端流式下载 超过 5 万行时前端导出会明显卡顿(浏览器内存限制),推荐后端生成流式 Excel: const exportExcelFromServer = async (params) => { const res = await axios({ url: '/api/export', method: 'get', params, responseType: 'blob' }) const blob = new Blob([res.data]) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = '数据.xlsx' link.click() URL.revokeObjectURL(link.href) }后端(Laravel + Maatwebsite\Excel): return Excel::download(new UserExport(), 'users.xlsx');
Vue 里强制组件重新渲染:改 key 才是正解
Vue 里"刷新组件"是个模糊需求。要重新 render 一次?重新执行 setup 拉数据?还是彻底把内部 state 归零?不同目标要用不同手段。 四种方式的差别手段 会销毁组件 会重新执行 setup 会重置 state改 :key ✅ ✅ ✅$forceUpdate() ❌ ❌ ❌重新赋值 ref/reactive ❌ ❌ 只改被赋值的字段v-if 切换 ✅ ✅ ✅场景一:改 :key(推荐) 想"完全刷新"一个组件(比如切换 tab 后要重新拉数据、重置表单): <template> <UserForm :key="refreshKey" /> <button @click="refresh">重新加载</button> </template><script setup> import { ref } from "vue";const refreshKey = ref(0); const refresh = () => refreshKey.value++; </script>refreshKey 一变,Vue 视这是新组件——旧的销毁、新的挂载、setup() 重跑、生命周期钩子从 onMounted 全走一遍。 场景二:$forceUpdate(几乎用不到) 只想强制重新 render,不动 state: import { getCurrentInstance } from "vue"; const instance = getCurrentInstance(); instance.proxy.$forceUpdate();适用场景其实很窄——一般是把不响应的对象塞进模板,或者用了第三方非响应库。只要还能改成响应式,就别用 $forceUpdate。 场景三:v-if 切换 粗暴但清晰: <template> <UserForm v-if="show" /> </template><script setup> const show = ref(true); async function reset() { show.value = false; await nextTick(); show.value = true; } </script>等价于改 key,用于"父组件不方便传 key"的场景。 数据变了但界面不更新 Vue3 大多数场景响应式自动生效,出问题基本是下面几种: 1. 解构丢了响应式 const state = reactive({ count: 1 }); const { count } = state; // count 是快照,不再响应改成 toRefs: const { count } = toRefs(state); count.value++; // 会触发更新2. 用了 shallowRef 但改的是深层 const data = shallowRef({ a: 1 }); data.value.a = 2; // 不会更新手动触发: import { triggerRef } from "vue"; triggerRef(data);或者直接换整个引用: data.value = { ...data.value, a: 2 };3. 用了 markRaw / Object.freeze 这些对象上禁用响应式,任你怎么改视图都不动。 4. Vue 2 的经典坑(Vue 3 无此问题)this.arr[index] = value 不触发 → 用 this.$set 或 splice(index, 1, value) this.obj.newKey = 1 不触发 → 用 this.$set(this.obj, "newKey", 1)nextTick:拿最新 DOM 修改数据后立刻读 DOM 大小、滚动位置: import { nextTick } from "vue";count.value++; await nextTick(); console.log(el.value.scrollHeight); // 这时才是新高度一句话总结 "重置组件"改 :key、"只重画"用 $forceUpdate(且大概率你不该用它)、"数据不更新"先查是不是解构丢了响应式。
Vue 3 触发更新:key 强制重渲、triggerRef、nextTick 与解构丢失响应式
Vue 3 响应式自动更新 Vue 3 中 ref 和 reactive 的数据修改会自动触发组件重新渲染,无需手动操作: const count = ref(0); count.value++; // 自动触发更新const state = reactive({ name: 'hello' }); state.name = 'world'; // 自动触发更新强制重建组件:修改 :key 当需要完全销毁并重新创建一个组件(而不只是更新数据)时,修改 :key 是最推荐的方式: <template> <MyComponent :key="refreshKey" :data="data" /> <button @click="refreshKey++">强制刷新</button> </template><script setup> import { ref } from 'vue'; const refreshKey = ref(0); </script>:key 变化会让 Vue 销毁旧组件实例并创建新的,适合需要重置内部状态的场景。 shallowRef 深层修改:triggerRef shallowRef 只追踪引用本身的变化,不追踪对象内部属性: import { shallowRef, triggerRef } from 'vue';const data = shallowRef({ list: [] });data.value.list.push(1); // 不触发更新! triggerRef(data); // 手动通知 Vue 重新渲染非必要不用 shallowRef,直接用 ref 更简单。 nextTick:等待 DOM 更新完成 数据变更后,DOM 不会立即同步更新(Vue 批量异步更新),需要 nextTick 等待: import { ref, nextTick } from 'vue';const visible = ref(false); visible.value = true;await nextTick(); // 此时 DOM 已更新,可以安全操作 DOM 元素 const el = document.querySelector('.new-element');常见陷阱:解构 reactive 丢失响应式 const state = reactive({ count: 1, name: 'hello' });// 错误:解构后 count 是普通变量,不是响应式 const { count } = state; count; // 修改不会触发更新// 正确:用 toRefs 保留响应式 import { toRefs } from 'vue'; const { count, name } = toRefs(state); count.value++; // 正确触发更新ref 不存在这个问题,因为 ref 返回的是包装对象,count.value 始终访问同一个响应式引用。 Vue 2 迁移注意 Vue 2 中直接对数组索引赋值、添加新属性不响应,需要 $set: // Vue 2 this.$set(this.arr, 0, newValue); this.$set(this.obj, 'newKey', 1);Vue 3 用 Proxy 实现响应式,这两种写法都能正确追踪,不需要 $set。 $forceUpdate(不推荐) import { getCurrentInstance } from 'vue'; const instance = getCurrentInstance(); instance.proxy.$forceUpdate();$forceUpdate 只强制当前组件实例重渲染,不更新子组件,且不解决数据不响应的根本问题。遇到更新问题应优先排查响应式数据的使用方式,而不是调用 $forceUpdate。
页面快捷键监听不冲突:五条守则
给页面加自定义快捷键(Ctrl+K 打开搜索、Alt+1 切换 tab 之类),最容易踩的坑是和别的监听打架——把系统快捷键、编辑器热键、别的组件 hotkey 全干掉。列几条实用守则。 守则一:用 addEventListener,别赋值 错的写法: document.onkeydown = handler;这会覆盖之前的所有 keydown 监听——包括别的组件、别的库、浏览器扩展设置的。 正确写法: window.addEventListener("keydown", handler, { passive: false });// 组件卸载时移除 window.removeEventListener("keydown", handler);addEventListener 是往队列里追加,多个监听会一起执行。 守则二:判断 defaultPrevented Monaco Editor、CodeMirror、富文本组件、浏览器本身、系统快捷键——很多东西会调 e.preventDefault()。尊重它们: const handler = e => { if (e.defaultPrevented) return; // 别人已经处理了 // 你的逻辑 };不做这个判断,用户在 CodeMirror 里按 Ctrl+S 会既触发编辑器保存、又触发你的东西,行为不确定。 守则三:输入框里不触发 const t = e.target; const isEditable = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t?.isContentEditable;if (isEditable) return;打字时的每个键都会走 keydown,你的 按 K 打开面板 会毁掉用户所有 K 字母的输入。 守则四:少用 stopPropagation 看到这种写法要警觉: e.stopPropagation();stopPropagation 阻止事件冒泡到父级,等于把父组件、全局快捷键全干死。 除非你明确需要(比如自己写一个 modal,希望里面的键盘操作不冒到外面),否则别用。 守则五:单字母触发要慎重 if (e.key === "s") { ... } // 危险s 太常见了——浏览器 Ctrl+S 保存、编辑器保存、任何输入框都会用到。要监听单字母,几乎必须搭配 modifier: if (e.ctrlKey && e.key.toLowerCase() === "k") { ... } if (e.altKey && e.key === "1") { ... } if (e.shiftKey && e.key === "?") { ... }或者监听几乎没人用的组合:F8、Ctrl+Shift+P、?(在没输入框时)。 Vue3 封装 import { onMounted, onUnmounted } from "vue";type KeyPredicate = (e: KeyboardEvent) => boolean;export function useHotkey(predicate: KeyPredicate, callback: (e: KeyboardEvent) => void) { const handler = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const t = e.target as Element | null; if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || (t as HTMLElement)?.isContentEditable) return; if (predicate(e)) { e.preventDefault(); callback(e); } }; onMounted(() => window.addEventListener("keydown", handler, { passive: false })); onUnmounted(() => window.removeEventListener("keydown", handler)); }// 用法 useHotkey( e => e.ctrlKey && e.key.toLowerCase() === "k", () => openSearch(), );React 版 import { useEffect } from "react";export function useHotkey( predicate: (e: KeyboardEvent) => boolean, callback: (e: KeyboardEvent) => void, ) { useEffect(() => { const h = (e: KeyboardEvent) => { if (e.defaultPrevented) return; const t = e.target as HTMLElement; if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return; if (predicate(e)) { e.preventDefault(); callback(e); } }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [predicate, callback]); }passive: false 是什么 现代浏览器为了滚动性能,某些事件默认变成 passive——你在里面调 e.preventDefault() 会报警告并被忽略。keydown 不受影响,但显式写 passive: false 是好习惯,声明"我可能会 preventDefault": window.addEventListener("keydown", h, { passive: false });对 keydown 来说是无用功但无害。对 touchstart / wheel 就必须写了。 特殊环境的坑Electron:主进程 globalShortcut 会拦截全局键,注意别覆盖用户系统的 iframe:跨域 iframe 里的 keydown 拿不到,需要 postMessage 转发 Monaco / CodeMirror:编辑器有自己的键映射系统,用它们提供的 API 注册,别在外面挂 keydown 浏览器扩展 content script:可能碰到页面已经拦截了某些键,要么 capture: true 抢先,要么改键位一句话总结 addEventListener 追加、检查 defaultPrevented、跳过输入框、避免 stopPropagation、单字母必须搭 modifier——五条守则遵守,你的快捷键就不会踩别人也不会被别人踩。
Vue 项目国际化(i18n)配置:vue-i18n 基础用法
Vue 项目做多语言支持用 vue-i18n,核心是:把文字抽成 key-value 的语言文件,组件里用 $t('key') 调用。 安装 npm install vue-i18n创建语言文件 src/ ├── locales/ │ ├── zh-CN.json │ └── en-US.json// zh-CN.json { "setup": { "logout": "退出登录", "language": "语言设置", "version": "版本更新", "clearCache": "清理缓存", "darkMode": "暗黑模式" }, "ok": "确定", "cancel": "取消" }// en-US.json { "setup": { "logout": "Logout", "language": "Language", "version": "Version Update", "clearCache": "Clear Cache", "darkMode": "Dark Mode" }, "ok": "OK", "cancel": "Cancel" }配置 i18n 实例 // src/i18n/index.ts import { createI18n } from "vue-i18n"; import zhCN from "../locales/zh-CN.json"; import enUS from "../locales/en-US.json";const savedLocale = localStorage.getItem("lang") || "zh-CN";export const i18n = createI18n({ legacy: false, // 使用 Composition API 模式 locale: savedLocale, fallbackLocale: "zh-CN", messages: { "zh-CN": zhCN, "en-US": enUS, }, });// main.ts import { createApp } from "vue"; import App from "./App.vue"; import { i18n } from "./i18n";createApp(App).use(i18n).mount("#app");组件内使用 Options API <template> <button @click="handleLogout">{{ $t('setup.logout') }}</button> <span>{{ $t('ok') }}</span> </template>Composition API import { useI18n } from "vue-i18n";const { t } = useI18n(); console.log(t("setup.logout")); // "退出登录"动态切换语言 import { useI18n } from "vue-i18n";const { locale } = useI18n();function switchLanguage(lang: string) { locale.value = lang; localStorage.setItem("lang", lang); }// 切换到英文 switchLanguage("en-US");带参数的翻译 // zh-CN.json { "greeting": "你好,{name}!", "items": "共 {count} 个项目" }<template> <p>{{ $t('greeting', { name: 'Soulock' }) }}</p> <p>{{ $t('items', { count: 42 }) }}</p> </template>语言列表组件示例 <template> <div v-for="lang in languageList" :key="lang.id" @click="switchTo(lang)"> <span>{{ lang.label }}</span> <span v-if="currentLang === lang.id">✓</span> </div> </template><script setup lang="ts"> import { ref } from "vue"; import { useI18n } from "vue-i18n";const { locale } = useI18n();const languageList = [ { id: "zh-CN", label: "简体中文" }, { id: "en-US", label: "English" }, { id: "ja-JP", label: "日本語" }, ];const currentLang = ref(locale.value);function switchTo(lang: { id: string; label: string }) { locale.value = lang.id; currentLang.value = lang.id; localStorage.setItem("lang", lang.id); } </script>按需加载语言包 语言多时可以懒加载,避免全部打包: async function loadLocale(lang: string) { const messages = await import(`../locales/${lang}.json`); i18n.global.setLocaleMessage(lang, messages.default); i18n.global.locale.value = lang; }第一次切换到某语言时再加载对应文件,减小初始包体积。
TypeScript `import type` 和 "没有导出的成员" 的坑
Vue 项目里想约束一个变量只能是 element-plus tag 的合法类型: import type { TagType } from "element-plus";const t: TagType = "success";结果 TS 报错: 模块 "element-plus" 没有导出的成员 "TagType"。 你是想改用 "import TagType from 'element-plus'" 吗?问题不是 import type 用错了,是 element-plus 根本没在主入口导出 TagType。 import type 是干嘛的 先说 import type 本身,它只导入类型信息,编译成 JS 时会完全消失: import type { User } from "./types";const u: User = { id: 1 }; // 编译后 import 语句被抹掉对比普通 import: import { User } from "./types"; // 编译后仍然 require("./types") — 运行时如果这个模块只导出类型就报错import type 的三个作用:避免运行时副作用 — 只用类型的场景不会拉进模块 配合 verbatimModuleSyntax — 现代 TS 项目里"类型 vs 值"必须显式区分 减小打包体积 — 无用运行时代码会被 tree-shake 掉为什么 element-plus 报"没有导出的成员" TagType 在 element-plus 内部定义: // packages/components/tag/src/tag.ts export type TagType = "" | "success" | "info" | "warning" | "danger";但没转发到主入口 element-plus/es/index.d.ts。所以: import type { TagType } from "element-plus"; // 找不到两种解法 方案 1:自己定义(推荐) 三秒钟搞定,稳定: type TagType = "" | "success" | "info" | "warning" | "danger";const t: TagType = "success";对内部类型 element-plus 后续升级也不会一言不合就改。自己定义等于 API 边界完全掌控。 方案 2:从深路径引 理论上可以: import type { TagType } from "element-plus/es/components/tag/src/tag";局限:这是非公开 API,element-plus 版本升级可能改路径 需要构建工具能解析这种深路径 不同版本的 element-plus 目录结构可能不同(lib/ vs es/ vs dist/)做工具库或第三方组件时优先方案 1,业务代码里图省事偶尔可以用方案 2。 import type 的常见变体 内联 type(TS 4.5+): import { type User, createUser } from "./user"; // ^^^^ 只这个是类型同一 import 里混装类型和值,编译后 User 被抹掉,createUser 保留。 全部当值 import(老式,不推荐): import { User } from "./user"; type MyRole = User["role"]; // 运行时会真的加载模块export type: export type { User } from "./user";再转发类型,同样只影响类型系统,不产生运行时依赖。 一张速查表场景 写法只用类型 import type { X } from "..."同一 import 混装 import { type X, y } from "..."转发类型 export type { X } from "..."库没有导出的内部类型 自己定义 or 深路径引(慎用)verbatimModuleSyntax=true 类型必须用 import type,否则报错一句话总结 import type = 只要类型不要运行时代码。库没导出的内部类型别硬拽——自己定义一份最稳,深路径 import 只是应急。
