30秒原型验证:自定义v-model组件的N种写法

快速体验

  1. 打开 InsCode(快马)平台 https://www.inscode.net
  2. 输入框内输入如下内容:
    生成一个可交互的代码比较工具,展示5种自定义v-model的实现方式:1) 基础计算属性 2) 带修饰符 3) 使用model选项 4) Composition API版本 5) 第三方库方案。要求每种实现都有实时预览,可以切换对比,并生成优缺点分析报告,支持一键导出任何版本的代码片段。
  3. 点击'项目生成'按钮,等待项目生成完整后预览效果

示例图片

在Vue开发中,我们经常会遇到需要自定义组件实现双向绑定的场景。但直接使用v-model绑定prop时会遇到"v-model cannot be used on a prop"的警告,这是因为prop默认是单向数据流。今天我们就来快速验证几种不同的自定义v-model实现方案。

  1. 基础计算属性方案 这是最直接的实现方式,通过计算属性的getter和setter来实现数据的双向绑定。优点是实现简单直观,适合简单的场景。缺点是当组件逻辑复杂时,计算属性会变得臃肿。

  2. 带修饰符方案 通过在组件内部使用.sync修饰符,可以更灵活地控制数据流。这个方案特别适合需要自定义事件名称的场景,提供了更好的语义化支持。但是需要注意,在Vue 3中.sync已经被废弃,需要用其他方式替代。

  3. model选项方案 这是Vue官方推荐的方式,通过在组件选项中定义model属性来指定prop和event名称。优点是结构清晰,符合Vue的设计理念。缺点是灵活性稍逊于其他方案,适合标准化的组件开发。

  4. Composition API版本 使用Vue 3的Composition API实现,通过ref和emit来管理状态。这个方案的最大优势是可以更好地组织复杂逻辑,代码可复用性高。对于大型项目或需要复用逻辑的场景特别适用。

  5. 第三方库方案 比如使用v-model扩展库,这些库通常提供了更丰富的功能和更好的类型支持。优点是开箱即用,功能强大。缺点是增加了项目依赖,可能带来额外的学习成本。

在实际开发中,选择哪种方案取决于具体需求。简单组件可以用基础计算属性,复杂逻辑推荐Composition API,需要标准化则选择model选项,追求功能丰富性可以考虑第三方方案。

InsCode(快马)平台上,我们可以快速生成和比较这些实现方案。平台提供了实时预览功能,可以即时看到不同方案的运行效果。示例图片 最方便的是,平台支持一键导出任何版本的代码片段,大大提高了开发效率。对于需要长期运行的演示项目,还可以使用一键部署功能快速上线分享。示例图片 我实际使用下来发现,从原型设计到代码生成再到部署上线,整个过程非常流畅,特别适合快速验证技术方案。

快速体验

  1. 打开 InsCode(快马)平台 https://www.inscode.net
  2. 输入框内输入如下内容:
    生成一个可交互的代码比较工具,展示5种自定义v-model的实现方式:1) 基础计算属性 2) 带修饰符 3) 使用model选项 4) Composition API版本 5) 第三方库方案。要求每种实现都有实时预览,可以切换对比,并生成优缺点分析报告,支持一键导出任何版本的代码片段。
  3. 点击'项目生成'按钮,等待项目生成完整后预览效果

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

<template> <div class="content"> <div class="breadcrumd header"> <BreadcrumbHeader> <template #breadcrumb> <el-breadcrumb-item v-if="!isEdit"> {{ $t('新增') }} </el-breadcrumb-item> <el-breadcrumb-item v-else> {{ $t('编辑') }} </el-breadcrumb-item> </template> <template #expand> <div class="btn"> <el-button v-preventReClick @click="back"> {{ $t('Back') }} </el-button> <el-button v-preventReClick type="primary" @click="sure"> {{ $t('确定') }} </el-button> </div> </template> </BreadcrumbHeader> </div> <div class="context"> <MyCard :title=" productCategory === 'opticalModule' ? $t('光模块组基础信息') : $t('产品模型基础信息') " > <FormModel ref="formModel" :type="type" :isEdit="isEdit" :isClassify="true" :needTitle="false" :engineerProduct="true" ></FormModel> </MyCard> <MyCard :title="$t('配置列表')" class="list"> <el-row> <el-col :span="8"> <el-input v-if="isEdit && productCategory !== 'opticalModule'" v-model.trim="condition" clearable class="config_input" :placeholder="$t('搜索')" @keyup.enter="enterSearch" @clear="enterSearch" > <template #suffix> <el-icon><Search /></el-icon> </template> </el-input> </el-col> <el-col :offset="12" :span="4" class="btn_col"> <el-button type="primary" style="margin-bottom: 12px" @click="addconfigData"> {{ $t('新增配置') }} </el-button> </el-col> </el-row> <DynamicTable ref="configTable" type="Virtual_Engineer_Product_Config" :needIndex="true" :tableData="configData" :needExpand="true" :needShowImg="['istatus']" :imgAddress="imgAddress" :formatDiction="['istatus', 'source']" :dictionTypes="['Virtual_Engineer_Product_Config']" :dynamicField="['config_name']" :dynamicSlotName="['action']" :toggleRow="expandChange" > <template #action="props"> <span v-if="props.row.source_data === 'manual' || props.row.source === 'custom'" class="action_btn" @click="deleteConfigData(props.row)" > {{ $t('删除') }} </span> </template> <template #expand="props"> <DynamicTable ref="productTable" :type=" productCategory === 'opticalModule' ? 'Virtual_Engineer_Optical_Module_Product_List' : 'Virtual_Engineer_Product_List' " :needIndex="true" :tableData="supplyData" :parentData="props.row" :needShowImg="['istatus']" :imgAddress="imgAddress" :formatDiction="['istatus', 'release_status']" :dictionTypes="[ productCategory === 'opticalModule' ? 'Virtual_Engineer_Optical_Module_Product_List' : 'Virtual_Engineer_Product_List', ]" :dynamicField="['supply_product', 'count']" :dynamicSlotName="['action']" @productChange="productChange" > <template #action="scoped"> <span v-if="parantData.source_data === 'manual' || parantData.source === 'custom'" class="action_btn" @click="deleteProductData(scoped.row)" > {{ $t('删除') }} </span> </template> </DynamicTable> <el-row v-if="showProductAdd" type="flex" justify="center" class="add_btn"> <el-col :span="24"> <div @click="addProductData"> <el-icon><Plus /></el-icon> <span>{{ $t('新增产品') }}</span> </div> </el-col> </el-row> </template> </DynamicTable> </MyCard> </div> </div> </template> <script> import BreadcrumbHeader from '@/components/BreadcrumbHeader'; import MyCard from '../components/MyCard.vue'; import DynamicTable from './engineerComponent/dynamicTable.vue'; import FormModel from './engineerComponent/normalForm.vue'; import { getEngineerProductConfigAPI, saveProductMappingConfigAPI, saveOpticalModuleProductInfoAPI, } from '@/apis/ptp.js'; import { Plus, Search } from '@element-plus/icons-vue'; import { each } from 'lodash'; import pinia from '@/store/pinia'; import { useProductMgmtStore } from '@/store/modules/productMgmt'; export default { components: { BreadcrumbHeader, MyCard, FormModel, DynamicTable, Plus, Search, }, setup() { const productStore = useProductMgmtStore(pinia); return { productStore, }; }, data() { return { type: '', isEdit: true, configData: [], condition: '', supplyData: [], engineerProduct: '', productCategory: '', parantData: {}, showProductAdd: true, imgAddress: { 'istatus': { Y: new URL('../images/Status/completed.png', import.meta.url).href, N: new URL('../images/Status/offline.png', import.meta.url).href, }, }, }; }, created() { this.isEdit = this.$route.query.id ? true : false; this.type = this.$route.query.type; this.productCategory = this.$route.query.productCategory; if (this.isEdit) { this.engineerProduct = this.$route.query.engineerProduct; this.getConfigTableData(); } this.productStore.setResetPage(false); }, methods: { enterSearch() { this.getConfigTableData(); }, addconfigData() { let index = this.$refs.configTable.dynamicIndex; this.configData.push({ dynamicIndex: index + 1, source: 'custom', 'source_data': 'manual', version: 1, istatus: 'Y', }); }, getConfigTableData() { let { engineerProduct, condition } = this; let params = { engineerProduct, condition, }; getEngineerProductConfigAPI({ params }) .then(res => { if (res.success) { this.configData = res.data; } }) .catch(() => { // catch空值 }); }, deleteConfigData(row) { this.$confirm(this.$t('确认删除'), this.$t('提示'), { confirmButtonText: this.$t('确定'), cancelButtonText: this.$t('取消'), type: 'warning', }) .then(() => { let tableData = this.configData.filter(v => v.dynamicIndex !== row.dynamicIndex); this.configData = tableData; }) .catch(() => { // catch空值 }); }, deleteProductData(row) { for (let i = 0; i < this.supplyData.length; i++) { if (this.supplyData[i].dynamicIndex === row.dynamicIndex) { this.supplyData.splice(i, 1); return; } } }, productChange(key, row) { each(this.supplyData, v => { if (key.value === v.supply_product && v.dynamicIndex !== row.dynamicIndex) { this.$message.error(this.applyLang('供应产品名称重复', 'Duplicate supply product name.')); row['supply_product'] = ''; return; } if (v.dynamicIndex === row.dynamicIndex) { v.istatus = key.status; v.comments = key.comments; v['release_status'] = key.releaseStatus; } }); }, expandChange(row) { this.parantData = row; if ((row.source_data && row.source_data === 'manual') || row.source === 'custom') { this.showProductAdd = true; } else { this.showProductAdd = false; } if (row.mapping_product_list) { this.supplyData = row.mapping_product_list; } else { row['mapping_product_list'] = []; this.supplyData = row.mapping_product_list; } }, addProductData() { if (this.productCategory === 'Server' && this.supplyData.length >= 1) { this.$message.warning( this.applyLang( '机型组只能配置一个产品', 'Only one product can be configured for a model group.', ), ); return; } let index = this.$refs.productTable.dynamicIndex; this.supplyData.push({ dynamicIndex: index + 1, }); }, back() { let pathList = this.$route.path.split('/'); let path = pathList.slice(0, pathList.length - 1).join('/'); this.$router.push(path); }, isRepeat() { let result = true; const newArr = this.configData.map(item => item.config_name); each(newArr, (val, index) => { if (val && newArr.indexOf(val) !== index) { this.$message.error( this.applyLang( `序号${index + 1}配置名称重复`, `No. ${index + 1}: Duplicate configuration name.`, ), ); result = false; } }); return result; }, verifyConfig() { let result = true; let flag = true; const tableData = this.configData.filter(v => v.source === 'custom'); if (!this.isRepeat()) { return (result = false); } // eslint-disable-next-line consistent-return each(tableData, val => { if (!val.config_name) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}配置名称为空`, `No. ${val.dynamicIndex + 1} Configuration name is empty.`, ), ); return (result = false); } else { if (val.mapping_product_list) { if (!flag) { return (result = false); } each(val.mapping_product_list, v => { if (!v.supply_product || !v.count) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}产品配置有误`, `No. ${val.dynamicIndex + 1} Product Configuration Error`, ), ); result = false; flag = false; } }); } else { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}配置产品列表为空`, `No. ${val.dynamicIndex + 1} The product list is empty.`, ), ); return (result = false); } return (result = false); } }); return result; }, assembleData() { let dataList = []; const tableData = this.configData.filter(v => v.source === 'custom'); each(tableData, val => { let mappingList = {}; if (val.id) { mappingList.id = val.id; } mappingList['config_name'] = val.config_name; if (val.mapping_product_list) { let productList = []; each(val.mapping_product_list, key => { productList.push({ count: key.count, 'supply_product': key.supply_product, }); }); mappingList['mapping_product_list'] = productList; } dataList.push(mappingList); }); return dataList; }, saveOpticalModuleProductInfoSure() { Promise.resolve(this.$refs.formModel?.reloadData()) .then(data => { if (data) { let result = this.verifyConfig(); // 校验配置列表 if (result) { this.saveOpticalModuleProductInfo(); } } }) .catch(() => { // catch空值 }); }, isSureSave(cb) { this.$confirm(this.$t('是否保存'), this.$t('提示'), { confirmButtonText: this.$t('确定'), cancelButtonText: this.$t('取消'), type: 'warning', }) .then(() => { if (cb) { cb(); } }) .catch(() => { // catch空值 }); }, saveOpticalModuleProductInfo() { this.isSureSave(() => { const baseInfo = this.$refs.formModel?.getFormData(); const params = { ...baseInfo, 'config_detail': this.assembleData(), }; if (!this.isEdit) { delete params.id; } saveOpticalModuleProductInfoAPI(params) .then(res => { if (res.success) { this.productStore.setResetPage(true); this.$message.success(this.$t('保存成功')); this.back(); } }) .catch(resData => { this.$message.error(resData.message); }); }); }, sure() { if (this.productCategory === 'opticalModule') { this.saveOpticalModuleProductInfoSure(); return; } let result = this.verifyConfig(); // 校验配置列表 if (result) { let data = this.assembleData(); this.isSureSave(() => { const params = { engineer_product: this.engineerProduct, mapping_config_list: data, }; saveProductMappingConfigAPI(params) .then(res => { if (res.success) { this.productStore.setResetPage(true); this.$message.success(this.$t('保存成功')); this.back(); } }) .catch(resData => { this.$message.error(resData.message); }); }); } }, }, }; </script> <style lang="less" scoped> .content { padding-bottom: 30px; } .header { margin-bottom: 20px; height: 40px; background-color: #fff; } .context { margin: 0 20px 20px; .list { margin-top: 12px; } } .action_btn { color: #526ecc; &:hover { cursor: pointer; font-weight: bold; } &:active { font-weight: normal; color: #adb0b8; } } .config_input { margin-bottom: 12px; input { height: 28px; } } .add_btn { margin-top: 7px; margin-bottom: 12px; .el-col { text-align: center; background-color: #eef0f5; height: 40px; line-height: 40px; color: #526ecc; span { margin-left: 10px; &:hover, &:active { font-weight: bold; } } &:hover { cursor: pointer; } } } .btn_col { text-align: right; display: flex; justify-content: flex-end; } </style> 点击确定按钮有的情景下不生效
11-04
你提到“点击确定按钮有的情景下不生效”,这是一个在前端开发中常见的交互问题。我们来从代码逻辑、数据流、条件判断和异步处理等多个角度分析可能的原因,并提供解决方案。 --- ### 🔍 问题定位 你的 `<el-button @click="sure">` 按钮在某些情况下点击没有反应,说明 `sure()` 方法未被正确触发或执行过程中被中断。 根据你提供的 Vue 组件代码(基于 Vue 3 + Composition API / Options API 混合写法),我们可以逐步排查: --- ## ✅ 原因分析与解决方案 ### ✅ 1. **`sure()` 函数内部提前返回(逻辑阻断)** 查看 `sure()` 方法: ```js sure() { if (this.productCategory === 'opticalModule') { this.saveOpticalModuleProductInfoSure(); return; } let result = this.verifyConfig(); // 校验配置列表 if (result) { // ... } } ``` - 如果 `productCategory !== 'opticalModule'`,则进入 `verifyConfig()`。 - 而 `verifyConfig()` 返回 `false` 时,不会执行保存逻辑,但用户**看不到任何提示**?这可能是“看似没反应”的原因! #### ❌ 问题点: - `verifyConfig()` 中使用了 `each(..., () => { return false; })`,但 `return` 只退出当前回调函数,**并不能中断循环或让外层函数立即返回**。 - 所以即使报错提示弹出,`result` 的更新是混乱的,最终返回值不可靠! #### ✅ 修复方案:改用 `for...of` 或正确控制流程 ```js verifyConfig() { const tableData = this.configData.filter(v => v.source === 'custom'); // 检查配置名重复 if (!this.isRepeat()) { return false; } for (const val of tableData) { if (!val.config_name) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}配置名称为空`, `No. ${val.dynamicIndex + 1} Configuration name is empty.` ) ); return false; } const productList = val.mapping_product_list; if (!productList || productList.length === 0) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}配置产品列表为空`, `No. ${val.dynamicIndex + 1} The product list is empty.` ) ); return false; } for (const p of productList) { if (!p.supply_product || !p.count) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}产品配置有误`, `No. ${val.dynamicIndex + 1} Product Configuration Error` ) ); return false; } } } return true; } ``` > 💡 使用 `for...of` 替代 `lodash.each`,可确保 `return false` 正确中断整个验证流程。 --- ### ✅ 2. **`saveOpticalModuleProductInfoSure()` 异步流程异常** ```js saveOpticalModuleProductInfoSure() { Promise.resolve(this.$refs.formModel?.reloadData()) .then(data => { if (data) { let result = this.verifyConfig(); if (result) { this.saveOpticalModuleProductInfo(); } } }) .catch(() => {}); } ``` #### ❌ 问题点: - `reloadData()` 是否一定返回一个 `Promise`? - 若 `reloadData()` 抛错或返回非真值(如 `null`, `undefined`),会导致后续逻辑跳过。 - 即便失败也没有提示,用户感觉“点了没反应”。 #### ✅ 解决方案:增强错误反馈 ```js saveOpticalModuleProductInfoSure() { const formModel = this.$refs.formModel; if (!formModel || !formModel.reloadData) { this.$message.error('表单加载失败,请刷新重试'); return; } Promise.resolve(formModel.reloadData()) .then(data => { if (!data) { this.$message.warning('请完善基础信息'); return; } const valid = this.verifyConfig(); if (valid) { this.saveOpticalModuleProductInfo(); } }) .catch(err => { console.error('Form reload error:', err); this.$message.error('表单验证出错'); }); } ``` --- ### ✅ 3. **按钮被防重复点击指令阻止 `v-preventReClick`** 你用了自定义指令 `v-preventReClick`,这类指令通常用于防止重复提交,但如果实现不当,可能导致**短时间内多次点击后锁死按钮**。 #### ❓检查点: - 查看 `v-preventReClick` 指令是否设置了较长的禁用时间(如 2s)? - 是否存在异常抛出导致无法释放锁定状态? #### ✅ 建议测试方法: 临时替换为原生 click 测试: ```html <el-button type="primary" @click="sure"> {{ $t('确定') }} </el-button> ``` 如果此时可以正常响应 → 说明 `v-preventReClick` 有问题。 #### 示例:安全的防重复点击指令 ```js // directives/preventReClick.js export default { mounted(el, binding) { el.addEventListener('click', function (e) { if (el.classList.contains('is-disabled')) return; el.classList.add('is-disabled'); setTimeout(() => { el.classList.remove('is-disabled'); }, 1000); // 1内禁止重复点击 }); }, }; ``` 并在样式中添加 `.is-disabled { pointer-events: none; opacity: 0.6; }` --- ### ✅ 4. **动态组件引用失效(`$refs` 未正确获取)** ```js this.$refs.formModel?.reloadData() this.$refs.configTable.dynamicIndex ``` #### ❌ 问题点: - 如果 `FormModel` 或 `DynamicTable` 渲染延迟或未完成,`$refs` 可能为 `undefined`。 - 特别是在 `addconfigData()` 中依赖 `$refs.configTable.dynamicIndex`,若组件尚未挂载会出错。 #### ✅ 改进方式: 在 `nextTick` 中操作: ```js this.$nextTick(() => { const index = this.$refs.configTable?.dynamicIndex || this.configData.length; this.configData.push({ dynamicIndex: index + 1, ... }); }); ``` 或者将 `dynamicIndex` 管理移到 data 中维护,避免依赖 ref。 --- ### ✅ 5. **Pinia store 或路由状态影响行为** 你在 `created()` 中设置: ```js this.productStore.setResetPage(false); ``` 但在 `sure()` 成功后才设为 `true`。虽然不影响点击本身,但如果页面做了防离开提示等逻辑,可能会干扰用户体验。 建议确认是否有全局守卫拦截了跳转,造成“以为没保存成功”。 --- ### ✅ 6. **语言包 `applyLang` 缺失导致报错静默失败** 你调用了 `this.applyLang(...)`,但该方法并未在 `methods` 或原型上定义。 #### ❌ 问题点: - `applyLang` 很可能是缺失的,导致 `TypeError`,进而中断流程。 - 错误被捕获在 `catch(()=>{})` 中,无提示! #### ✅ 修复方式: 要么定义它: ```js methods: { applyLang(zh, en) { return this.$i18n.locale === 'zh' ? zh : en; }, } ``` 要么统一使用 `$t(key)` 并国际化配置: ```js // 在语言文件中 { "configNameEmpty": "序号{index}配置名称为空", "productConfigError": "序号{index}产品配置有误" } ``` 然后: ```js this.$t('configNameEmpty', { index: val.dynamicIndex + 1 }) ``` --- ## ✅ 总结:完整修复建议 ### 修改后的关键点总结如下: | 问题 | 修复方式 | |------|---------| | `verifyConfig` 逻辑错误 | 改用 `for...of` 避免 `each` 的 `return` 失效 | | `reloadData` 异常沉默 | 加强 `Promise.catch` 提示 | | `v-preventReClick` 锁死 | 检查指令实现或临时移除测试 | | `$refs` 访问不稳定 | 使用 `nextTick` 或本地维护 index | | `applyLang` 不存在 | 定义或改用 `$t` 国际化 | --- ## ✅ 推荐修改后的 `verifyConfig` 完整代码 ```js verifyConfig() { const tableData = this.configData.filter(v => v.source === 'custom'); // 检查名称重复 const names = tableData.map(item => item.config_name); for (let i = 0; i < names.length; i++) { if (names[i] && names.indexOf(names[i]) !== i) { this.$message.error( this.applyLang( `序号${i + 1}配置名称重复`, `No. ${i + 1}: Duplicate configuration name.` ) ); return false; } } for (const val of tableData) { if (!val.config_name) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}配置名称为空`, `No. ${val.dynamicIndex + 1} Configuration name is empty.` ) ); return false; } const productList = val.mapping_product_list; if (!Array.isArray(productList) || productList.length === 0) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}配置产品列表为空`, `No. ${val.dynamicIndex + 1} The product list is empty.` ) ); return false; } for (const p of productList) { if (!p.supply_product) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}供应产品不能为空`, `No. ${val.dynamicIndex + 1} Supply product cannot be empty.` ) ); return false; } if (!p.count || p.count <= 0) { this.$message.error( this.applyLang( `序号${val.dynamicIndex + 1}数量必须大于0`, `No. ${val.dynamicIndex + 1} Count must be greater than 0.` ) ); return false; } } } return true; } ``` --- ## ✅ 如何调试这类“按钮无效”问题? 1. 在浏览器 F12 打开 DevTools 2. 在 `sure()` 函数第一行加 `debugger;` 3. 点击按钮,观察是否进入断点 - 如果没进 → 是事件绑定或指令阻止 - 如果进了但很快退出 → 是某个 `if` 条件失败或 `return` 4. 查看 Console 是否有报错(尤其是 `applyLang is not defined`) ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

RubyLion28

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值