欢迎光临
我们一直在努力

uni-app 数据绑定和事件处理,用一个筛选表单讲清楚

页面真正变复杂,往往不是因为元素多,而是因为用户开始操作了。

输入关键词。

选择分类。

调整价格。

点搜索。

页面要跟着这些操作变化。数据变了,视图要更新;用户点了按钮,方法要执行;某个字段变了,可能还要触发请求。

这一篇不用零散例子讲 ref、v-model、computed、watch。我们写一个商品筛选表单,把它们串起来。

太长不看版

这一篇用商品筛选表单讲交互链路。

用户输入或选择以后,事件函数更新响应式数据;computed 负责生成展示文案和派生值;点击搜索时再把页面状态整理成请求参数。能用 computed 表达的派生数据,不要重复存一份。

这个表单要做什么

页面上有四件事:

  • 输入关键词。
  • 选择分类。
  • 输入最低价和最高价。
  • 点击搜索,得到请求参数。

交互链路是这样。

#mermaid-svg-waLyTLTMur5NDH1M{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-waLyTLTMur5NDH1M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-waLyTLTMur5NDH1M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-waLyTLTMur5NDH1M .error-icon{fill:#552222;}#mermaid-svg-waLyTLTMur5NDH1M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-waLyTLTMur5NDH1M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-waLyTLTMur5NDH1M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-waLyTLTMur5NDH1M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-waLyTLTMur5NDH1M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-waLyTLTMur5NDH1M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-waLyTLTMur5NDH1M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-waLyTLTMur5NDH1M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-waLyTLTMur5NDH1M .marker.cross{stroke:#333333;}#mermaid-svg-waLyTLTMur5NDH1M svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-waLyTLTMur5NDH1M p{margin:0;}#mermaid-svg-waLyTLTMur5NDH1M .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-waLyTLTMur5NDH1M .cluster-label text{fill:#333;}#mermaid-svg-waLyTLTMur5NDH1M .cluster-label span{color:#333;}#mermaid-svg-waLyTLTMur5NDH1M .cluster-label span p{background-color:transparent;}#mermaid-svg-waLyTLTMur5NDH1M .label text,#mermaid-svg-waLyTLTMur5NDH1M span{fill:#333;color:#333;}#mermaid-svg-waLyTLTMur5NDH1M .node rect,#mermaid-svg-waLyTLTMur5NDH1M .node circle,#mermaid-svg-waLyTLTMur5NDH1M .node ellipse,#mermaid-svg-waLyTLTMur5NDH1M .node polygon,#mermaid-svg-waLyTLTMur5NDH1M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-waLyTLTMur5NDH1M .rough-node .label text,#mermaid-svg-waLyTLTMur5NDH1M .node .label text,#mermaid-svg-waLyTLTMur5NDH1M .image-shape .label,#mermaid-svg-waLyTLTMur5NDH1M .icon-shape .label{text-anchor:middle;}#mermaid-svg-waLyTLTMur5NDH1M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-waLyTLTMur5NDH1M .rough-node .label,#mermaid-svg-waLyTLTMur5NDH1M .node .label,#mermaid-svg-waLyTLTMur5NDH1M .image-shape .label,#mermaid-svg-waLyTLTMur5NDH1M .icon-shape .label{text-align:center;}#mermaid-svg-waLyTLTMur5NDH1M .node.clickable{cursor:pointer;}#mermaid-svg-waLyTLTMur5NDH1M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-waLyTLTMur5NDH1M .arrowheadPath{fill:#333333;}#mermaid-svg-waLyTLTMur5NDH1M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-waLyTLTMur5NDH1M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-waLyTLTMur5NDH1M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-waLyTLTMur5NDH1M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-waLyTLTMur5NDH1M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-waLyTLTMur5NDH1M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-waLyTLTMur5NDH1M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-waLyTLTMur5NDH1M .cluster text{fill:#333;}#mermaid-svg-waLyTLTMur5NDH1M .cluster span{color:#333;}#mermaid-svg-waLyTLTMur5NDH1M div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-waLyTLTMur5NDH1M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-waLyTLTMur5NDH1M rect.text{fill:none;stroke-width:0;}#mermaid-svg-waLyTLTMur5NDH1M .icon-shape,#mermaid-svg-waLyTLTMur5NDH1M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-waLyTLTMur5NDH1M .icon-shape p,#mermaid-svg-waLyTLTMur5NDH1M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-waLyTLTMur5NDH1M .icon-shape .label rect,#mermaid-svg-waLyTLTMur5NDH1M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-waLyTLTMur5NDH1M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-waLyTLTMur5NDH1M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-waLyTLTMur5NDH1M :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

用户输入或选择

事件函数

修改响应式数据

computed 生成派生结果

视图自动更新

点击搜索时组装参数

文件位置:

pages/goods/filter.vue

也别忘了注册到 pages.json。

{
"path": "pages/goods/filter",
"style": {
"navigationBarTitleText": "筛选商品"
}
}

先把响应式数据建好

在 Vue3 的 script setup 里,最常用的是 ref 和 reactive。

这里表单字段属于一个整体,用 reactive 很顺手。

<script setup>
import { reactive, computed, watch } from 'vue'

const categories = ['全部', '食品', '日用品', '数码', '服饰']

const form = reactive({
keyword: '',
categoryIndex: 0,
minPrice: '',
maxPrice: ''
})
</script>

reactive 适合管理一组相关字段,比如表单。

如果只是一个简单值,比如 count、visible、status,用 ref 更直接。

有一个新手很容易混的点:ref 在脚本里要 .value,在模板里不用。reactive 对象字段正常用点操作就行。

const count = ref(0)

count.value++

模板里写:

<text>{{ count }}</text>

不用写 count.value。

输入框有两种写法

商品关键词可以用 v-model。

<input
class="input"
v-model="form.keyword"
placeholder="输入商品关键词"
/>

v-model 适合普通输入框,能少写一段事件赋值逻辑。

但你仍然要理解事件写法,因为 uni-app 里很多组件的值都在事件对象里。

比如价格输入,我们故意用 @input 写。

<input
class="input"
type="digit"
:value="form.minPrice"
placeholder="最低价"
@input="handleMinPriceInput"
/>

const handleMinPriceInput = (e) => {
form.minPrice = e.detail.value
}

在 uni-app 和小程序场景里,表单事件值经常在 e.detail.value。

你可以把它理解成:组件把用户操作结果放进事件详情里,再交给你的方法。

视图页面状态handleInputinput用户视图页面状态handleInputinput用户#mermaid-svg-ZZofu8ptCLaA197A{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZZofu8ptCLaA197A .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZZofu8ptCLaA197A .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZZofu8ptCLaA197A .error-icon{fill:#552222;}#mermaid-svg-ZZofu8ptCLaA197A .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZZofu8ptCLaA197A .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZZofu8ptCLaA197A .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZZofu8ptCLaA197A .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZZofu8ptCLaA197A .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZZofu8ptCLaA197A .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZZofu8ptCLaA197A .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZZofu8ptCLaA197A .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZZofu8ptCLaA197A .marker.cross{stroke:#333333;}#mermaid-svg-ZZofu8ptCLaA197A svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZZofu8ptCLaA197A p{margin:0;}#mermaid-svg-ZZofu8ptCLaA197A .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZZofu8ptCLaA197A text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZZofu8ptCLaA197A .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZZofu8ptCLaA197A .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZZofu8ptCLaA197A .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZZofu8ptCLaA197A .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZZofu8ptCLaA197A #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZZofu8ptCLaA197A .sequenceNumber{fill:white;}#mermaid-svg-ZZofu8ptCLaA197A #sequencenumber{fill:#333;}#mermaid-svg-ZZofu8ptCLaA197A #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZZofu8ptCLaA197A .messageText{fill:#333;stroke:none;}#mermaid-svg-ZZofu8ptCLaA197A .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZZofu8ptCLaA197A .labelText,#mermaid-svg-ZZofu8ptCLaA197A .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZZofu8ptCLaA197A .loopText,#mermaid-svg-ZZofu8ptCLaA197A .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZZofu8ptCLaA197A .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZZofu8ptCLaA197A .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZZofu8ptCLaA197A .noteText,#mermaid-svg-ZZofu8ptCLaA197A .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZZofu8ptCLaA197A .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZZofu8ptCLaA197A .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZZofu8ptCLaA197A .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZZofu8ptCLaA197A .actorPopupMenu{position:absolute;}#mermaid-svg-ZZofu8ptCLaA197A .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZZofu8ptCLaA197A .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZZofu8ptCLaA197A .actor-man circle,#mermaid-svg-ZZofu8ptCLaA197A line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZZofu8ptCLaA197A :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}输入价格触发 input 事件读取 e.detail.value 并写入 form视图更新

picker 也走事件

分类选择用 picker。

<picker :range="categories" :value="form.categoryIndex" @change="handleCategoryChange">
<view class="picker-value">
{{ currentCategory }}
</view>
</picker>

const handleCategoryChange = (e) => {
form.categoryIndex = Number(e.detail.value)
}

picker 返回的是索引,当前分类文本可以通过计算属性得到。

const currentCategory = computed(() => {
return categories[form.categoryIndex]
})

computed 适合派生数据,不要重复存

如果一个值可以由其他字段算出来,就不要再存一份。

比如当前分类文本、是否填写价格、搜索按钮文案,都可以算出来。

const hasPriceRange = computed(() => {
return Boolean(form.minPrice || form.maxPrice)
})

const searchSummary = computed(() => {
const parts = []

if (form.keyword.trim()) {
parts.push(`关键词:${form.keyword.trim()}`)
}

if (currentCategory.value !== '全部') {
parts.push(`分类:${currentCategory.value}`)
}

if (hasPriceRange.value) {
parts.push(`价格:${form.minPrice || 0}${form.maxPrice || '不限'}`)
}

return parts.length > 0 ? parts.join(',') : '还没有设置筛选条件'
})

这里不要额外维护一个 summary 字段。

否则你要在关键词变化、分类变化、价格变化时到处同步,一漏就错。

数据绑定里很重要的一条经验是:源数据只留一份,能算出来的就算出来。

watch 适合副作用,但不要滥用

computed 适合算值。

watch 适合做副作用。

什么叫副作用?比如数据变化以后,你要请求接口、写缓存、打印日志、上报埋点,这些都不是单纯展示。

比如关键词变化后,你想提示用户正在准备搜索。

watch(
() => form.keyword,
(newKeyword) => {
if (!newKeyword.trim()) return

console.log('关键词变化了,新的关键词是:', newKeyword)
}
)

但初学阶段要克制使用 watch。

如果你只是想显示一个由其他数据计算出来的文本,用 computed。

如果你想在关键词变化后自动请求接口,要考虑防抖或取消旧请求。否则用户连续输入 a、ab、abc,你可能瞬间打三次请求,旧请求还可能比新请求更晚回来。这个问题会在网络请求那篇专门讲。

点击搜索时组装参数

搜索按钮不要直接把整个 form 原样扔给接口。

页面状态是给页面用的,请求参数是给接口用的,中间最好做一次整理。

const buildSearchParams = () => {
const params = {}

const keyword = form.keyword.trim()
if (keyword) {
params.keyword = keyword
}

if (currentCategory.value !== '全部') {
params.category = currentCategory.value
}

if (form.minPrice) {
params.minPrice = Number(form.minPrice)
}

if (form.maxPrice) {
params.maxPrice = Number(form.maxPrice)
}

return params
}

const handleSearch = () => {
const params = buildSearchParams()

uni.showToast({
title: Object.keys(params).length > 0 ? '开始搜索' : '请先设置条件',
icon: 'none'
})

console.log('搜索参数', params)
}

这样做有两个好处。

页面字段可以保留页面需要的形态,比如 categoryIndex。

接口参数可以整理成接口需要的形态,比如 category、minPrice、maxPrice。

这也是项目里很常见的分层:页面状态归页面,请求参数归请求。

完整页面代码

<template>
<view class="page">
<view class="panel">
<view class="form-item">
<text class="label">关键词</text>
<input
class="input"
v-model="form.keyword"
placeholder="输入商品关键词"
/>
</view>

<view class="form-item">
<text class="label">分类</text>
<picker :range="categories" :value="form.categoryIndex" @change="handleCategoryChange">
<view class="picker-value">
{{ currentCategory }}
</view>
</picker>
</view>

<view class="price-row">
<view class="price-item">
<text class="label">最低价</text>
<input
class="input"
type="digit"
:value="form.minPrice"
placeholder="0"
@input="handleMinPriceInput"
/>
</view>

<view class="price-item">
<text class="label">最高价</text>
<input
class="input"
type="digit"
:value="form.maxPrice"
placeholder="不限"
@input="handleMaxPriceInput"
/>
</view>
</view>

<view class="summary">
{{ searchSummary }}
</view>

<button type="primary" @click="handleSearch">
搜索商品
</button>
</view>
</view>
</template>

<script setup>
import { reactive, computed, watch } from 'vue'

const categories = ['全部', '食品', '日用品', '数码', '服饰']

const form = reactive({
keyword: '',
categoryIndex: 0,
minPrice: '',
maxPrice: ''
})

const currentCategory = computed(() => {
return categories[form.categoryIndex]
})

const hasPriceRange = computed(() => {
return Boolean(form.minPrice || form.maxPrice)
})

const searchSummary = computed(() => {
const parts = []

if (form.keyword.trim()) {
parts.push(`关键词:${form.keyword.trim()}`)
}

if (currentCategory.value !== '全部') {
parts.push(`分类:${currentCategory.value}`)
}

if (hasPriceRange.value) {
parts.push(`价格:${form.minPrice || 0} – ${form.maxPrice || '不限'}`)
}

return parts.length > 0 ? parts.join(',') : '还没有设置筛选条件'
})

const handleCategoryChange = (e) => {
form.categoryIndex = Number(e.detail.value)
}

const handleMinPriceInput = (e) => {
form.minPrice = e.detail.value
}

const handleMaxPriceInput = (e) => {
form.maxPrice = e.detail.value
}

const buildSearchParams = () => {
const params = {}
const keyword = form.keyword.trim()

if (keyword) {
params.keyword = keyword
}

if (currentCategory.value !== '全部') {
params.category = currentCategory.value
}

if (form.minPrice) {
params.minPrice = Number(form.minPrice)
}

if (form.maxPrice) {
params.maxPrice = Number(form.maxPrice)
}

return params
}

const handleSearch = () => {
const params = buildSearchParams()

uni.showToast({
title: Object.keys(params).length > 0 ? '开始搜索' : '请先设置条件',
icon: 'none'
})

console.log('搜索参数', params)
}

watch(
() => form.keyword,
(newKeyword) => {
if (!newKeyword.trim()) return

console.log('关键词变化了:', newKeyword)
}
)
</script>

<style lang="scss">
.page {
min-height: 100vh;
padding: 32rpx;
background: #f6f7fb;
}

.panel {
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
}

.form-item {
margin-bottom: 28rpx;
}

.label {
display: block;
margin-bottom: 12rpx;
font-size: 26rpx;
color: #374151;
}

.input,
.picker-value {
min-height: 80rpx;
padding: 0 24rpx;
border: 1rpx solid #d1d5db;
border-radius: 10rpx;
font-size: 28rpx;
line-height: 80rpx;
}

.price-row {
display: flex;
gap: 20rpx;
}

.price-item {
flex: 1;
}

.summary {
margin: 8rpx 0 28rpx;
padding: 20rpx;
border-radius: 10rpx;
background: #f3f4f6;
font-size: 26rpx;
color: #4b5563;
}
</style>

这条链路比背语法重要

数据绑定和事件处理的核心不是某个单独 API。

它是一条链路:

用户操作组件。

组件触发事件。

事件函数读取 e.detail.value 或直接通过 v-model 更新数据。

响应式数据变化。

模板自动更新。

computed 负责派生展示。

点击搜索时,把页面状态整理成请求参数。

这条链路顺了,页面就从静态模板变成了真正能交互的应用。

后面做列表、路由、请求、状态管理,本质上都还在处理同一件事:数据放哪里,谁来改,改完以后谁会受到影响。

这里容易翻车

在脚本里忘记 .value

ref 在脚本里读写要用 .value,模板里会自动解包。

把 computed 能算出的值再存一份

比如筛选摘要可以由表单字段算出来,就不要额外维护 summary 状态。

watch 里直接请求接口

关键词一变就请求,用户连续输入时会打很多请求。网络请求篇会讲防抖、取消旧请求和请求序号校验,参见 08-网络请求与页面状态设计.md。

页面状态和请求参数混在一起

页面可以存 categoryIndex,但接口可能要的是 category。提交前做一次 buildSearchParams,项目后面会清楚很多。

自己试试

  • 给筛选表单加一个“只看有库存”的开关,并把它放进搜索参数。
  • 给价格区间加一个校验:最低价不能大于最高价。
  • 参考资料

    • Vue Composition API 文档:https://vuejs.org/api/composition-api-setup.html
    • Vue computed 文档:https://vuejs.org/guide/essentials/computed.html
    • DCloud input 组件文档:https://uniapp.dcloud.net.cn/component/input.html
    赞(0)
    未经允许不得转载:171主机测评 » uni-app 数据绑定和事件处理,用一个筛选表单讲清楚
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址