首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
74
篇与
» admin
的结果
2026-07-19
uni-app 自定义组件开发实战:easycom 规范、组件通信与自定义导航栏
uni-app 的组件体系继承了 Vue 语法,但运行环境横跨小程序和 H5,组件的注册方式、通信限制、样式隔离都有平台特色。本文讲透 uni-app 组件开发的规范与实战,最后以一个自定义导航栏组件收官。一、easycom:不用 import 的组件注册传统 Vue 组件需要 import + components 注册,uni-app 提供了 easycom 规范:组件路径符合约定,即可直接使用。规范约定组件路径:components/组件名/组件名.vuesrc/ └── components/ ├── user-card/user-card.vue ├── empty-state/empty-state.vue └── upload-image/upload-image.vue符合规范后,模板里直接写标签,无需任何注册:<template> <user-card :user="userInfo" @follow="handleFollow" /> </template>自定义 easycom 规则组件库或目录结构特殊时,在 pages.json 里扩展规则:{ "easycom": { "autoscan": true, "custom": { "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue", "^my-(.*)": "@/components/$1/index.vue" } } }<uni-icons>、<my-search> 都能自动解析。这就是 uni-ui 等组件库"引入即用"的原理。注意:easycom 只解决注册问题,组件内部的通信、传值仍然遵循 Vue 规范。二、组件通信的平台差异uni-app 组件通信支持 props / emit,这一点与 Vue 相同。但有几个平台差异必须注意:差异一:vue2 语法下 this.$refs 可用,vue3 组合式 API 写法部分端受限<script setup> import { ref } from 'vue' const formRef = ref(null) // 调用子组件方法 function submit() { formRef.value.validate() } </script>Vue 3 项目在 H5 和 App 端正常,部分小程序端旧版本对 expose 支持不完整,遇到问题优先确认基础库/HBuilderX 版本。差异二:自定义事件在原生组件上的差异比如 input 组件,各端 v-model 的支持程度不同,表单组件建议同时声明 modelValue prop 和 update:modelValue 事件,与 Vue 3 标准对齐:<!-- components/my-input/my-input.vue --> <script setup> const props = defineProps({ modelValue: { type: String, default: '' }, type: { type: String, default: 'text' } }) const emit = defineEmits(['update:modelValue']) function onInput(e) { emit('update:modelValue', e.detail.value) // 注意:小程序事件对象在 detail 里 } </script> <template> <input class="my-input" :type="type" :value="modelValue" @input="onInput" /> </template>关键差异:小程序原生事件的值在 e.detail.value,H5 端在 e.target.value。做跨端组件时这是必踩的坑,要么用 uni 的统一封装,要么条件编译处理。三、实战组件一:UploadImage 图片上传<!-- components/upload-image/upload-image.vue --> <script setup> import { ref, computed } from 'vue' const props = defineProps({ modelValue: { type: Array, default: () => [] }, // 已上传图片 url 数组 maxCount: { type: Number, default: 9 }, sourceType: { type: Array, default: () => ['album', 'camera'] } }) const emit = defineEmits(['update:modelValue', 'change']) const uploading = ref(0) const canAdd = computed(() => props.modelValue.length + uploading.value < props.maxCount ) async function choose() { const remain = props.maxCount - props.modelValue.length if (remain <= 0) return const [err, res] = await uni.chooseImage({ count: remain, sizeType: ['compressed'], // 压缩图,控制体积 sourceType: props.sourceType }) if (err) return uploading.value += res.tempFilePaths.length try { const urls = await Promise.all( res.tempFilePaths.map(path => uploadFile(path)) ) const next = [...props.modelValue, ...urls] emit('update:modelValue', next) emit('change', next) } catch (e) { uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uploading.value -= res.tempFilePaths.length } } function remove(index) { const next = props.modelValue.filter((_, i) => i !== index) emit('update:modelValue', next) emit('change', next) } function preview(index) { uni.previewImage({ urls: props.modelValue, current: index }) } function uploadFile(filePath) { return new Promise((resolve, reject) => { uni.uploadFile({ url: 'https://api.example.com/upload', filePath, name: 'file', success: (res) => { const body = JSON.parse(res.data) // uploadFile 响应是字符串! body.code === 0 ? resolve(body.data.url) : reject(body) }, fail: reject }) }) } </script> <template> <view class="upload-grid"> <view v-for="(url, index) in modelValue" :key="url" class="upload-item"> <image :src="url" mode="aspectFill" @click="preview(index)" /> <view class="upload-delete" @click="remove(index)">×</view> </view> <view v-if="canAdd" class="upload-add" @click="choose"> <text v-if="uploading" class="add-text">{{ uploading }}张上传中</text> <text v-else class="add-icon">+</text> </view> </view> </template> <style scoped> .upload-grid { display: flex; flex-wrap: wrap; gap: 16rpx; } .upload-item { position: relative; width: 200rpx; height: 200rpx; } .upload-item image { width: 100%; height: 100%; border-radius: 12rpx; } .upload-delete { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; line-height: 36rpx; text-align: center; background: #f56c6c; color: #fff; border-radius: 50%; font-size: 24rpx; } .upload-add { width: 200rpx; height: 200rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; } </style>使用方式因为 easycom + v-model 而极其简洁:<upload-image v-model="goods.images" :max-count="9" @change="onImagesChange" />四、实战组件二:自定义导航栏小程序原生导航栏定制能力有限,很多设计稿要求沉浸式导航。方案是 navigationStyle: "custom" 后自己实现:<!-- components/nav-bar/nav-bar.vue --> <script setup> import { ref, computed } from 'vue' const props = defineProps({ title: { type: String, default: '' }, backVisible: { type: Boolean, default: true }, background: { type: String, default: '#ffffff' }, color: { type: String, default: '#333333' } }) // 状态栏高度 + 胶囊信息 const statusBarHeight = ref(0) const navHeight = ref(44) // #ifdef MP-WEIXIN const menuButton = uni.getMenuButtonBoundingClientRect() const { statusBarHeight: sbh } = uni.getSystemInfoSync() statusBarHeight.value = sbh navHeight.value = (menuButton.top - sbh) * 2 + menuButton.height // #endif // #ifdef H5 const sys = uni.getSystemInfoSync() statusBarHeight.value = 0 // #endif const totalHeight = computed(() => statusBarHeight.value + navHeight.value) function goBack() { const pages = getCurrentPages() if (pages.length > 1) { uni.navigateBack() } else { // 首个页面无返回栈,回首页 uni.reLaunch({ url: '/pages/index/index' }) } } </script> <template> <view> <!-- 占位:防止内容顶到导航栏下面 --> <view :style="{ height: totalHeight + 'px' }" /> <!-- 固定定位的实际导航栏 --> <view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px', height: totalHeight + 'px', background, color }" > <view class="nav-content" :style="{ height: navHeight + 'px' }"> <view v-if="backVisible" class="nav-back" @click="goBack"> <text class="back-arrow">‹</text> </view> <view class="nav-title">{{ title }}</view> <!-- 右侧留出微信胶囊按钮位置 --> <view class="nav-right"><slot name="right" /></view> </view> </view> </view> </template> <style scoped> .nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 999; } .nav-content { display: flex; align-items: center; position: relative; padding: 0 24rpx; } .nav-back { width: 64rpx; height: 64rpx; display: flex; align-items: center; } .back-arrow { font-size: 44rpx; line-height: 1; } .nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 32rpx; font-weight: 500; } .nav-right { margin-left: auto; } </style>两个核心细节:占位 view:固定定位的导航栏会脱离文档流,需要一个等高占位块把页面内容顶下来胶囊按钮对齐:微信端通过 uni.getMenuButtonBoundingClientRect() 拿到右上角胶囊的位置,让自定义导航内容与胶囊垂直居中对齐,这是"看着专业"的关键页面使用:// pages.json 中对应页面 { "path": "pages/order/detail", "style": { "navigationStyle": "custom" } }<nav-bar title="订单详情" background="linear-gradient(#1a73e8, #4a90d9)" color="#fff"> <template #right> <text class="report-btn">举报</text> </template> </nav-bar>五、组件库生态不想重复造轮子时的选择:库特点uni-uiDCloud 官方,easycom 无缝集成,稳定uview-plusuview 的 vue3 版,组件数量最多,社区活跃TuniaoUI设计感强,适合 toC 产品wot-design-units 编写,暗黑模式支持好组件库选型建议:组件数量和颜值之外,重点看 issues 里平台兼容性问题的响应速度。多端项目里组件库的兼容 bug 会消耗大量时间。总结easycom 靠目录约定免注册,自定义规则支持任意目录映射小程序事件对象的值在 e.detail.value,与 H5 的 e.target.value 不同uploadFile 响应体是字符串,二次封装时必须 JSON.parse自定义导航栏 = 状态栏高度 + 胶囊对齐 + 占位块三件套优先用 v-model + update:modelValue 让组件 API 对齐 Vue 3 标准写好这三五个基础组件,项目里的重复代码能砍掉一半。下一篇讲性能优化——分包加载、图片优化与长列表渲染。
2026年07月19日
5 阅读
0 评论
0 点赞
2026-07-17
uni-app 性能优化实战:分包加载、首屏提速与长列表渲染
小程序的性能红线比 Web 苛刻得多:主包 2MB 上限、启动加载有评分、setData 频繁会掉帧。本文围绕包体积、启动速度、运行时性能三个维度,给出可直接落地的优化清单。一、包体积优化:分包是第一要务为什么必须分包微信小程序限制:主包 ≤ 2MB,单个分包 ≤ 2MB,总包 ≤ 30MB(普通分包)。业务做多了,主包必然爆。分包加载(subPackages)把非首屏页面拆出去,用户进入对应模块时才下载。分包配置// pages.json { "pages": [ "pages/index/index", "pages/order/list" ], "subPackages": [ { "root": "subpkg-order", "pages": [ "pages/detail/detail", "pages/refund/refund", "pages/invoice/invoice" ] }, { "root": "subpkg-member", "pages": [ "pages/coupon/coupon", "pages/points/points" ] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["subpkg-order"] }, "pages/order/list": { "network": "wifi", "packages": ["subpkg-member"] } } }目录结构相应调整:src/ ├── pages/ # 主包:只放首屏路径 ├── subpkg-order/ │ └── pages/ │ ├── detail/ │ ├── refund/ │ └── invoice/ ├── subpkg-member/ │ └── pages/ ├── static/ # 只放主包用的静态资源 └── components/分包的两个关键规则:分包页面跳转用完整路径:uni.navigateTo({ url: '/subpkg-order/pages/detail/detail?id=1' })分包可以引用主包的组件/工具,反之不行(主包不能 import 分包内资源,否则等于没拆)preloadRule:分包预下载上面配置里用户在首页时,预下载订单分包(全网络),进入订单列表时预下载会员分包(仅 WiFi)。配置得当能把"进入子模块的白屏时间"压到无感。静态资源治理主包 2MB 里最容易被图片吃掉:图片 CDN 化:static 目录里只留 tab 图标等必须本地的资源,其余全部走远程 URL压缩:tinypng 压一遍,通常能砍 60%+按需引入组件库:uni-ui 等支持 easycom 按需引入,但要确认 tree-shaking 生效;uview-plus 建议用 its 按需加载配置// vite.config.js 分析包体积 import { visualizer } from 'rollup-plugin-visualizer' export default { plugins: [visualizer({ filename: 'stats.html' })] }构建后打开 stats.html,哪些模块占了体积一目了然。二、启动性能:首屏体验启动流程与耗时构成小程序启动 = 下载代码包 → 初始化 → 页面首次渲染。开发者能控制的是初始化阶段的同步任务量和首屏依赖的数据请求。优化一:App.onLaunch 里别做重活// App.vue export default { onLaunch() { // ❌ 常见错误:启动时同步拉一堆配置 // this.initConfig() // 200ms+ 的同步阻塞 // ✅ 延迟到首页渲染后再做 // this.getConfigId = setTimeout(() => this.initConfig(), 0) } }启动阶段每个同步 API 调用、每个 await 都直接推迟首屏。原则:onLaunch 只做必须的(登录态检查),其余全部后置。优化二:骨架屏// pages.json { "path": "pages/index/index", "style": { "navigationStyle": "custom" } }HBuilderX 可以把页面骨架生成微信快照。手写方案是在数据未到时渲染结构占位:<template> <view> <template v-if="loaded"> <view v-for="item in list" :key="item.id" class="goods-card"> <image :src="item.cover" /> <text>{{ item.title }}</text> </view> </template> <template v-else> <view v-for="i in 4" :key="i" class="goods-card skeleton"> <view class="skeleton-img" /> <view class="skeleton-line" /> </view> </template> </view> </template> <style> .skeleton-img, .skeleton-line { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 50%, #f2f2f2 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; } @keyframes shimmer { to { background-position: -200% 0; } } </style>优化三:初始渲染缓存微信端开启后,第二次打开直接展示上次渲染的快照,再用新数据更新:{ "path": "pages/index/index", "style": { "initialRenderingCache": "static" } }三、运行时性能:setData 与长列表理解 setData 的成本uni-app 编译到小程序后,逻辑层和渲染层分离,每次响应式数据变化都会产生一次 setData 通信。数据量大、频率高时,通信本身成为瓶颈。规则一:不要频繁 setData 小数据// ❌ 倒计时每秒 setData 一次整个对象 this.timer = setInterval(() => { this.leftSeconds-- // 对象级更新 }, 1000) // ✅ 高频更新只改必要字段,或者干脆用 wx 层优化 // 页面数据结构上把高频变化的字段隔离出来规则二:长列表分页 + 局部更新<script> export default { data() { return { list: [], page: 1, finished: false, loading: false } }, onReachBottom() { this.loadMore() }, methods: { async loadMore() { if (this.loading || this.finished) return this.loading = true try { const { records, hasMore } = await this.$api.getOrders({ page: this.page + 1 }) // 追加而不是替换,配合 :key 复用节点 this.list.push(...records) this.page++ this.finished = !hasMore } finally { this.loading = false } } } } </script>长列表的进阶方案是虚拟列表:只渲染可视区域附近的节点。长列表组件(如 z-paging、uview 的 u-list)内置了该能力:<z-paging v-model="list" @query="queryList" :default-page-size="20" use-virtual-list use-inner-scroll > <view v-for="(item, index) in list" :key="item.id"> <text>{{ item.title }}</text> </view> </z-paging>上千条数据滚动如丝般顺滑的核心:滚动容器内只保留视口 ± 缓冲区内的真实节点,用撑高的占位元素维持滚动条。规则三:图片懒加载<image :src="item.cover" lazy-load mode="aspectFill" />原生 image 组件的 lazy-load 让图片进入视口前后才加载,列表页流量和渲染压力立减。规则四:避免大对象进 data不变的数据(城市列表、配置表)放 this 挂载而非 data,避免进入响应式系统产生 setData 开销:// ❌ 一万条城市数据放 data,每次任意 setData 都可能全量传输 // ✅ 非渲染数据脱离响应式 created() { this.allCities = cityData // 不进 data,只做查找用 }四、优化检查清单上线前对照过一遍:包体积[ ] 主包 < 1.5MB(留余量)[ ] 非首屏页面全部拆分包[ ] preloadRule 覆盖高频跳转路径[ ] static 目录无超过 50KB 的图片[ ] stats.html 确认无意外的大依赖启动[ ] onLaunch 只留登录态检查[ ] 首页有骨架屏或初始渲染缓存[ ] 首屏接口合并,首屏数据 < 3 个请求运行时[ ] 长列表分页 + 虚拟列表[ ] image 全部 lazy-load + 合适的 mode[ ] 高频更新字段做隔离[ ] 定时器在 onHide 暂停、onUnload 清除[ ] 真机(低端安卓)过一遍核心页面流畅度五、工具微信开发者工具 → 审计:代码依赖分析、包体积构成体验评分:自动检测常见性能问题真机调试 Performance 面板:setData 频率和耗时可视化优化永远要以数据为依据:先跑一遍体验评分和真机 profile,找到真实瓶颈再动手,而不是凭感觉瞎改。总结分包是包体积问题的根本解,preloadRule 消除分包切换白屏启动性能核心是砍掉 onLaunch 的同步任务,骨架屏改善体感setData 通信是小程序性能的第一杀手:合并更新、隔离高频字段、大对象不进 data千级长列表必须上虚拟列表一切优化先 profile 后动手性能优化做到位的小程序,启动快、滚动顺、耗电少,用户体验评分和留存都会直观反映出来。
2026年07月17日
6 阅读
0 评论
0 点赞
2026-07-14
Vue 3 插槽深度实战:默认、具名与作用域插槽及自定义指令
如果说 props 是组件的"输入参数",插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件,绕不开插槽;而自定义指令则是 DOM 级复用的利器。这两个主题合起来,是 Vue 中级开发者必须吃透的内容。一、插槽的三种形态1. 默认插槽父组件传入的内容替换子组件的 <slot> 占位:<!-- Card.vue --> <template> <div class="card"> <div class="card-body"> <slot></slot> </div> </div> </template><!-- 使用 --> <Card> <h3>订单详情</h3> <p>共 3 件商品,合计 ¥299.00</p> </Card>2. 具名插槽:多区域布局组件有多个内容区域时,用 name 区分。典型场景:页头/页身/页脚的布局组件。<!-- PageLayout.vue --> <template> <div class="page"> <header class="page-header"> <slot name="header"></slot> </header> <main class="page-main"> <slot></slot> <!-- 默认插槽 --> </main> <footer class="page-footer"> <slot name="footer"></slot> </footer> </div> </template><!-- 使用:template + v-slot 指定区域 --> <PageLayout> <template #header> <h1>管理后台</h1> </template> <p>这里是主内容区域</p> <template #footer> <span>© 2026 My Corp</span> </template> </PageLayout>注意 #header 是 v-slot:header 的缩写,且 v-slot 只能写在 <template> 上(默认插槽的简写除外)。3. 作用域插槽:数据反向传递普通插槽的内容编译在父组件作用域,无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容:<!-- DataList.vue --> <script setup> const props = defineProps({ items: { type: Array, required: true } }) </script> <template> <ul class="data-list"> <li v-for="(item, index) in items" :key="item.id"> <slot :item="item" :index="index"> <!-- 后备内容:父组件不传插槽时的默认渲染 --> {{ item.name }} </slot> </li> </ul> </template><!-- 父组件决定每一行怎么渲染 --> <DataList :items="orders"> <template #default="{ item, index }"> <div class="order-row"> <span>#{{ index + 1 }}</span> <span>{{ item.orderNo }}</span> <span :class="item.status">{{ statusText[item.status] }}</span> </div> </template> </DataList>这是设计"无头组件"(Headless Component)的核心思想:组件负责数据和逻辑,父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。实战:封装一个通用描述列表组件综合运用具名 + 作用域插槽,封装一个类似 Ant Design Descriptions 的组件:<!-- DescList.vue --> <script setup> defineProps({ items: { type: Array, required: true } }) </script> <template> <dl class="desc-list"> <template v-for="item in items" :key="item.field"> <dt>{{ item.label }}</dt> <dd> <!-- 命名插槽接管某个字段的渲染,否则显示原始值 --> <slot :name="item.field" :item="item"> {{ item.value }} </slot> </dd> </template> </dl> </template><!-- 使用方 --> <DescList :items="[ { field: 'name', label: '姓名', value: user.name }, { field: 'avatar', label: '头像', value: user.avatar }, { field: 'status', label: '状态', value: user.status } ]"> <template #avatar="{ item }"> <img :src="item.value" class="avatar" /> </template> <template #status="{ item }"> <el-tag :type="item.value === 1 ? 'success' : 'danger'"> {{ item.value === 1 ? '正常' : '禁用' }} </el-tag> </template> </DescList>一个组件同时满足了通用性和定制性——这就是插槽设计的精髓。二、自定义指令当复用的是 DOM 行为而不是组件结构时,用指令。指令只在底层操作 DOM,不关心业务。注册与钩子// main.js 全局注册 const app = createApp(App) app.directive('focus', { mounted(el) { el.focus() } })Vue 3 指令的钩子与组件生命周期对齐:钩子触发时机created元素属性和事件监听器设置之前beforeMount挂载之前mounted挂载到 DOM 后beforeUpdate更新前updated更新后beforeUnmount卸载前unmounted卸载后绝大多数指令只需要 mounted 和 updated,可以用简写函数形式:app.directive('color', (el, binding) => { el.style.color = binding.value })实战指令一:v-loading// directives/loading.js export const vLoading = { mounted(el, binding) { const mask = document.createElement('div') mask.className = 'v-loading-mask' mask.innerHTML = '<div class="v-loading-spinner"></div>' el.style.position = el.style.position || 'relative' el.appendChild(mask) el.__loadingMask = mask toggle(el, binding.value) }, updated(el, binding) { toggle(el, binding.value) }, unmounted(el) { el.__loadingMask?.remove() } } function toggle(el, show) { el.__loadingMask.style.display = show ? 'flex' : 'none' }<div class="panel" v-loading="fetching"> <p v-for="row in rows" :key="row.id">{{ row.name }}</p> </div>实战指令二:v-debounce按钮防抖是高频需求,每个地方手写 setTimeout 太啰嗦:export const vDebounce = { mounted(el, binding) { const [fn, delay = 300] = binding.value instanceof Array ? binding.value : [binding.value, 300] let timer = null el.__debounceHandler = (event) => { if (timer) clearTimeout(timer) timer = setTimeout(() => fn(event), delay) } el.addEventListener('click', el.__debounceHandler) }, unmounted(el) { el.removeEventListener('click', el.__debounceHandler) } }<button v-debounce="[saveOrder, 500]">提交订单</button>实战指令三:v-permission 权限控制import { useUserStore } from '@/stores/user' export const vPermission = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value // 'order:delete' const modifiers = binding.modifiers // { some: true } → v-permission.some let hasPermission if (modifiers.some) { // 任一权限即可 hasPermission = [].concat(required).some(p => userStore.permissions.includes(p)) } else { hasPermission = userStore.permissions.includes(required) } if (!hasPermission) { el.parentNode?.removeChild(el) } } }<button v-permission="'order:delete'">删除订单</button> <button v-permission.some="['a', 'b']">复合操作</button><script setup> 中局部注册指令也支持在 SFC 内直接定义,变量名以 v 开头即可自动注册:<script setup> // 变量名 vFocus 自动映射为 v-focus 指令 const vFocus = { mounted: (el) => el.focus() } </script> <template> <input v-focus /> </template>插槽 vs 指令:选型原则维度插槽指令复用内容结构 + 样式DOM 行为典型场景布局、列表渲染定制权限、防抖、拖拽、埋点作用对象组件原生 DOM 元素灵活性高(父级完全接管渲染)中(操作属性和事件)一个简单的判断:需求是"换个样子"用插槽,需求是"加点行为"用指令。总结作用域插槽 = 子组件向插槽内容"回传数据",是无头组件的基石具名插槽支撑多区域布局,#name 是标准缩写自定义指令只在需要直接操作 DOM 时使用,能用组件解决的别用指令指令钩子与组件生命周期对齐,简写形式覆盖 mounted + updatedv-loading、v-debounce、v-permission 是三个最值得收进工具箱的指令把插槽和指令用好,你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数(Composables)——Vue 3 逻辑复用的终极形态。
2026年07月14日
5 阅读
0 评论
0 点赞
2026-07-12
Vue Router 4 路由实战:动态路由、导航守卫与懒加载
任何多页面 SPA 都绕不开 Vue Router。但很多项目对它的使用停留在"能跳转"——路由懒加载没配、守卫写得一团乱麻、动态路由权限方案稀里糊涂。本文以 Vue Router 4(对应 Vue 3)为准,把这几个中级必考点一次讲透。基础配置与懒加载路由懒加载是必选项打包工具默认会把所有页面组件打进一个 bundle,首屏加载动辄几 MB。动态 import() 让每个页面按需加载:// router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/views/Home.vue'), children: [ { path: 'orders', component: () => import('@/views/order/List.vue') }, { path: 'orders/:id', component: () => import('@/views/order/Detail.vue'), props: true // 把路由参数作为 props 传入组件 } ] } ] })配合 Vite 的手动分包,可以进一步把公共依赖抽出来:// vite.config.ts export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'] } } } } }props 解耦:别在组件里读 $route<!-- ❌ 与路由强耦合,组件无法复用 --> <script setup> import { useRoute } from 'vue-router' const route = useRoute() const id = route.params.id </script> <!-- ✅ props: true 后,组件像普通组件一样接收参数 --> <script setup> defineProps({ id: String }) </script>函数模式更灵活:{ path: 'orders/:id', component: OrderDetail, props: route => ({ id: Number(route.params.id), tab: route.query.tab }) }动态路由与权限系统后台管理系统的经典需求:不同角色看到不同菜单。标准方案是前置白名单 + 动态添加路由:第一步:定义静态与动态路由// router/index.ts import { createRouter, createWebHistory } from 'vue-router' // 静态路由:任何人都能访问 export const constantRoutes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/404', component: () => import('@/views/404.vue') } ] // 动态路由:按角色分配,meta.roles 记录可访问角色 export const asyncRoutes = [ { path: '/', component: () => import('@/views/Layout.vue'), children: [ { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '工作台', roles: ['admin', 'operator'] } }, { path: 'system', component: () => import('@/views/system/Index.vue'), meta: { title: '系统管理', roles: ['admin'] } } ] } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes })第二步:登录后过滤并挂载// stores/permission.ts import { defineStore } from 'pinia' import { asyncRoutes, constantRoutes } from '@/router' function hasPermission(roles, route) { return route.meta?.roles ? roles.some(role => route.meta.roles.includes(role)) : true // 没声明 roles 默认放行 } export function filterRoutes(routes, roles) { return routes.reduce((acc, route) => { const tmp = { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children = filterRoutes(tmp.children, roles) } acc.push(tmp) } return acc }, []) } export const usePermissionStore = defineStore('permission', { state: () => ({ accessibleRoutes: [] }), actions: { generateRoutes(roles) { this.accessibleRoutes = [...constantRoutes, ...filterRoutes(asyncRoutes, roles)] return this.accessibleRoutes } } })第三步:addRoute 动态注册// 登录成功后 const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(user.roles) routes.forEach(route => router.addRoute(route))注意:addRoute 后必须用 router.replace 或返回新 location 触发一次重新导航,否则匹配不到刚添加的路由。导航守卫体系Vue Router 4 的守卫分三类,执行顺序必须烂熟于心:导航触发 → beforeEach(全局前置) → beforeEnter(路由独享) → beforeRouteUpdate / beforeRouteEnter(组件内) → afterEach(全局后置)全局前置守卫:登录鉴权标准模板const WHITE_LIST = ['/login', '/404'] router.beforeEach(async (to, from) => { const userStore = useUserStore() // 1. 白名单直接放行 if (WHITE_LIST.includes(to.path)) return true // 2. 未登录跳登录页,带上 redirect 参数 if (!userStore.token) { return { path: '/login', query: { redirect: to.fullPath } } } // 3. 已登录但还没拉取用户信息/动态路由 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(userStore.roles) routes.forEach(r => router.addRoute(r)) // 关键:addRoute 后重新进入当前路由 return { ...to, replace: true } } catch { userStore.logout() return { path: '/login', query: { redirect: to.fullPath } } } } // 4. 正常放行 return true })守卫返回值语义Vue Router 4 的守卫返回值规则:返回值效果undefined / true放行false取消导航路由地址对象 / 字符串重定向Promiseresolve 上述值,reject 则取消并报错afterEach:动态修改页面标题router.afterEach((to) => { document.title = to.meta?.title ? `${to.meta.title} - 管理系统` : '管理系统' })组件内守卫:离开确认表单页防止用户误关,用 onBeforeRouteLeave:<script setup> import { ref } from 'vue' import { onBeforeRouteLeave } from 'vue-router' const dirty = ref(false) onBeforeRouteLeave(() => { if (dirty.value) { return window.confirm('表单未保存,确定离开?') } }) </script>滚动行为与过渡动画const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition // 前进后退恢复位置 if (to.hash) return { el: to.hash, behavior: 'smooth' } // 锚点 return { top: 0 } // 默认回到顶部 } })路由切换配合 <transition> 做页面动画:<template> <router-view v-slot="{ Component, route }"> <transition name="fade" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> </template> <style> .fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style>useRoute 与 useRouter<script setup> 中通过组合式 API 获取路由:import { useRoute, useRouter } from 'vue-router' const route = useRoute() // 当前路由信息(响应式):params、query、meta const router = useRouter() // 路由实例:push、replace、go // 带参数跳转 router.push({ name: 'order-detail', params: { id: 123 }, query: { tab: 'logs' } }) // query 变化时重新拉数据(同一路由复用时 watch) watch(() => route.query.tab, (tab) => { fetchList(tab) })易错点:route.params 不是深度响应式的可靠来源,路径参数变化而组件复用时(如 /orders/1 → /orders/2),要 watch route.params.id 或给 router-view 加 :key。常见坑速查刷新 404:动态路由方案里,刷新后路由表被重置,必须在守卫里重新 addRoute(见上文模板第 3 步)通配符路由位置:{ path: '/:pathMatch(.*)*' } 必须放在动态路由 addRoute 之后,否则先匹配到 404history 模式 404:服务器需配置所有路径回落到 index.html(Nginx 的 try_files $uri $uri/ /index.html;)循环重定向:守卫里 next() 与返回值混用会导致逻辑混乱,Vue Router 4 统一用返回值风格keep-alive 失效:配合动态路由时 include 需要组件 name,确保组件显式声明了 name(defineOptions({ name: 'OrderList' }))总结懒加载是标配,props: true 让组件与路由解耦权限路由三步走:静态/动态路由分离 → 登录后按角色过滤 → addRoute + 重新导航守卫统一用返回值风格,beforeEach 模板可以直接抄走onBeforeRouteLeave 处理离开确认,scrollBehavior 处理滚动恢复刷新 404 和通配符路由顺序是动态路由方案最常踩的两个坑路由系统是后台管理项目的骨架,把这套方案吃透,遇到再复杂的权限场景都能从容拆解。
2026年07月12日
5 阅读
0 评论
0 点赞
2026-07-11
Vue 3 + TypeScript 类型化开发实战:从 Props 到泛型组件
Vue 3 是用 TS 重写的,类型支持是其核心卖点。但很多项目只是"用了 TS",模板里的类型断言满天飞、组件 Props 无类型、第三方库全是 any。本文覆盖 Vue 3 + TS 的核心类型工具,从基础用法一路讲到泛型组件,帮你把类型系统真正用起来。一、Props 类型化的三个层次层次一:运行时校验(纯 JS 风格)defineProps({ id: [Number, String], items: { type: Array, required: true } })有运行时警告,但没有编辑器类型推导,TS 项目不推荐。层次二:类型声明(类型推导最佳)<script setup lang="ts"> interface Order { id: number title: string status: 'pending' | 'paid' | 'closed' } const props = defineProps<{ order: Order showActions?: boolean }>() // props.order.status 自动推导为联合类型 // 模板中尝试比较错误状态会有类型提示 </script>层次三:withDefaults 提供默认值类型声明语法不支持直接给默认值,需要 withDefaults:const props = withDefaults( defineProps<{ items: Order[] pageSize?: number labels?: Record<string, string> }>(), { pageSize: 20, labels: () => ({}) // 对象/数组默认值必须用工厂函数 } )从接口自动生成 Props(进阶)interface Props { userId: number; compact?: boolean } // 响应式解包后仍是响应式的 const props = defineProps<Props>()props 是被 reactive 包装的对象,解构会丢失响应性。需要解构时用 Vue 3.5+ 的响应式 Props 解构:const { userId, compact = false } = defineProps<Props>() // 3.5+ 编译器自动保持响应性二、emit 与 ref 的类型化emit 类型const emit = defineEmits<{ (e: 'change', value: string): void (e: 'select', id: number, item: Order): void }>() // Vue 3.3+ 更简洁的具名元组语法 const emit2 = defineEmits<{ change: [value: string] select: [id: number, item: Order] }>()父组件在模板上监听时,回调参数类型自动校验。ref 的类型// 基础:自动推导 const count = ref(0) // Ref<number> // 初始值为 null 时必须显式标注 const el = ref<HTMLInputElement | null>(null) onMounted(() => el.value?.focus()) // 复杂对象推荐接口先行 interface FormData { name: string tags: string[] } const form = ref<FormData>({ name: '', tags: [] })模板引用组件实例import FormModal from './FormModal.vue' const modalRef = ref<InstanceType<typeof FormModal> | null>(null) onMounted(() => { modalRef.value?.open() // expose 的方法有完整类型 })InstanceType<typeof Component> 会读取 defineExpose 暴露的成员类型,这是父组件调用子组件方法的类型安全姿势。三、computed 与 watch 的类型const orderList = ref<Order[]>([]) const pendingCount = computed(() => orderList.value.filter(o => o.status === 'pending').length ) // 自动推导 ComputedRef<number> // watch 回调参数类型自动对应数据源 watch( () => props.order.status, (newStatus, oldStatus) => { // newStatus: 'pending' | 'paid' | 'closed' console.log(`${oldStatus} → ${newStatus}`) } ) // watchEffect 不需要指定类型,内部自动收集依赖四、provide / inject 的类型安全// symbols/keys.ts —— 集中管理 InjectionKey import type { InjectionKey, Ref } from 'vue' export interface UserContext { user: Ref<{ id: number; name: string }> refresh: () => Promise<void> } export const UserKey: InjectionKey<UserContext> = Symbol('user')// 祖先组件 provide(UserKey, { user, refresh }) // 后代组件:完整类型推导 + 缺省兜底 const ctx = inject(UserKey) if (!ctx) throw new Error('UserKey 未在祖先组件提供') ctx.user.value.name // string,自动推导五、泛型组件:类型跟着数据走普通组件的 Props 类型是固定的,泛型组件让类型由调用方决定。最典型的场景是列表组件和选择器组件。普通写法的困境// ❌ items 只能声明成 any[],丢失元素类型 defineProps<{ items: any[]; modelValue: any }>()泛型组件写法<!-- SelectList.vue --> <script setup lang="ts" generic="T extends { id: number }"> defineProps<{ items: T[] modelValue: T['id'] | null labelField?: keyof T }>() const emit = defineEmits<{ 'update:modelValue': [id: T['id'] | null] select: [item: T] }>() </script> <template> <ul class="select-list"> <li v-for="item in items" :key="item.id" :class="{ active: item.id === modelValue }" @click="emit('update:modelValue', item.id); emit('select', item)" > <slot :item="item">{{ item[labelField ?? 'id'] }}</slot> </li> </ul> </template>generic="T extends { id: number }" 声明泛型参数,调用方传 Order[] 时所有相关类型自动实例化为 Order:<SelectList v-model="selectedOrderId" :items="orders" label-field="title" @select="(order) => console.log(order.status)" // order: Order,类型完整 />泛型 composable:类型化的 useListexport function useList<T>(initial: T[] = []) { const list = ref<T[]>([...initial]) function add(item: T) { list.value.push(item) } function remove(predicate: (item: T) => boolean) { list.value = list.value.filter(i => !predicate(i)) } function find(predicate: (item: T) => boolean): T | undefined { return list.value.find(predicate) } return { list, add, remove, find } } // 使用:所有方法参数和返回值都有精确类型 const { list, add, find } = useList<Order>([]) add({ id: 1, title: 'x', status: 'pending' }) // OK add({ id: 2, title: 'y' }) // 报错:缺 status六、SFC 与 TS 工程细节defineComponent 与组件类型<script setup> 的组件是匿名的,需要显式 name 时(keep-alive include、devtools 显示):<script setup lang="ts"> defineOptions({ name: 'OrderList' }) </script>外部类型文件的组织src/ ├── types/ │ ├── api.d.ts # 后端接口类型(可由 OpenAPI 生成) │ ├── models.ts # 业务模型 Order、User 等 │ └── global.d.ts # 全局类型扩展 ├── components/api.d.ts 建议用工具从后端 Swagger/OpenAPI 规范生成,杜绝手抄接口字段。常用工具类型速查import type { Ref, ComputedRef, MaybeRef, UnwrapRef } from 'vue' // MaybeRef<T>:参数既可以是 T 也可以是 Ref<T> function useX(source: MaybeRef<string>) { /* ... */ } // UnwrapRef<T>:ref 的解包类型 const state = ref({ nested: { count: 0 } }) // state.value.nested.count 类型是 number(自动解包) // ExtractPropTypes:从运行时 props 选项提取类型 import type { ExtractPropTypes } from 'vue' const propsSchema = { title: String, count: { type: Number, default: 0 } } type Props = ExtractPropTypes<typeof propsSchema>vue-tsc 做模板类型检查vue-tsc 能检查模板中的表达式类型,接入 CI:// package.json { "scripts": { "type-check": "vue-tsc --noEmit" } }模板里 {{ order.statu }} 这类错误会在构建前暴露,而不是上线后白屏。七、别过度类型化类型是工具不是目的,几个克制的建议:联合类型优先于枚举:'pending' | 'paid' 比 enum 更利于 tree-shaking后端接口类型交给代码生成,手写注定跟不上变化第三方库无类型时的最小兜底:declare module 'xxx',而不是到处 any断言 as 只用于"我确信类型系统不知道的事",当作逃生舱而非常规操作总结Props 用类型声明语法,默认值交给 withDefaultsdefineEmits 用具名元组语法,参数类型双向校验InstanceType<typeof Comp> 是引用子组件的规范类型InjectionKey<T> 让 provide/inject 摆脱字符串裸奔泛型组件 generic="T" 是封装列表/选择器类组件的杀手锏vue-tsc 进 CI,模板类型错误提前拦截类型系统用到位后,重构敢下手、接口变更立刻报错、新人看类型就能懂用法——这 defensive 能力正是中级向高级进阶的分水岭。
2026年07月11日
6 阅读
0 评论
0 点赞
1
...
3
4
5
...
15
0:00