关于使用onfocus=”this.blur()”的利与弊

本文探讨了使用onfocus=this.blur()去除链接聚焦时虚线框的问题,并指出这种方法会影响视障用户的Tab键导航体验。文章提供了几种替代方案,包括利用CSS样式和JavaScript代码,以确保无障碍性的同时移除虚线框。

onfocus=”this.blur()” 这句代码有没有觉得眼熟?没错,我们常用它来去除链接取得焦点时外围出现的虚线框(如图一),google一下,前面几十页谈的都是这个去除虚线框的技巧。但我们也许以前从未想过:我们的这行代码给盲人用户们带来了巨大的困扰:这中断了盲人用户的Tab键路径,导致Tab光标无法聚焦页面的下一个控制器(链接、表单域、object、image map等)。测试如下:

各种浏览器虚线框差异图

(图一)

<body>
<a href=”#” >第一个链接</a>
<a href=”#” >第二个链接</a>
<a href=”#” onfocus=”this.blur();”>第三个链接</a>
<a href=”#” >第四个链接</a>
<a href=”#” >第五个链接</a>
<a href=”#” >第六个链接</a>
</body>

按下Tab键,第一和第二个链接都可以正常获取焦点,继续Tab到第三个链接时,悲剧出现了:此时焦点会回到第一个链接,而无法Focus到第四个链接,原因是当Focus到第三个链接时,onfocus=”this.blur()” 事件处理强制触发了失焦,焦点重新回到文档的最开始。于是不停按Tab的结果就是焦点在前面三个链接轮流转,后面的内容通过Tab键无法访问[1]。

虚线框影响视觉效果 (图二)

那么,有更好的方式吗?从根源上看,加onfocus=”this.blur()”是为了去除链接获取焦点后外围的虚线框(当然chrome、safari、opera下的focus效果各异,这里姑且就这么叫吧 )。W3C关于Outline的文章里说明这个虚线框用来告诉用户当前页面获取焦点的元素。我觉得,虚线框的存在有它的合理性,但有时你也许无法回避某些”视觉洁癖”需求(如图二:虚线框使“商品”背景和下面的红色色块分隔开了),以下总结了去掉虚线框的几种常用方法:

去除虚线框的方法优劣兼容性是否中断tab
<a href=”#” onfocus=”this.blur()”>this blur</a>链接聚焦触发时失去焦点,js和html耦合在一起没有兼容性问题
a:focus {outline:none}或
a{outline:none}
outline由css2.1引入,去除虚线框视觉上的问题正是css的职责ie6/ie7不支持,ie8+/ff /safari/opera[2]支持
<a href=”#” hidefocus=”true” >hidefocus</a>该属性是ie的私有属性[3]ie5+支持
a { noFocusLine: expression(this.onFocus = this.blur())}可批量处理,但expression的性能问题不能忽视expression ie6/7支持,ie8+、非ie不支持

综合以上,去除链接虚线框的推荐方法是:ie下用hidefocus属性,ff/chorme/opera/safari下用outline:none。即:

<a href=”#” hidefocus=”true” >链接</a>
a:focus {
    outline:none;
}

或者使用js,代码如下:(详细情况请参考http://www.mikesmullin.com/?p=23)

 1 <script type="text/javascript">//<!--
 2 var runOnLoad = new Array(); // for queuing multiple onLoad event functions
 3 window.onload = function() { for(var i=0; i<runOnLoad.length; i++) runOnLoad[i]() }
 4 // hide dotted :focus outlines when mouse is used
 5 // but NOT when tab key is used
 6 if(document.getElementsByTagName)
 7 for(var i in a = document.getElementsByTagName('A')) {
 8   a[i].onmousedown = function() {
 9     this.blur(); // most browsers
10     this.hideFocus = true; // ie
11     this.style.outline = 'none'; // mozilla
12   }
13   a[i].onmouseout = a[i].onmouseup = function() {
14     this.blur(); // most browsers
15     this.hideFocus = false; // ie
16     this.style.outline = null; // mozilla
17   }
18 }
19 //--></script> 

 

无奈地说,如果页面因为onfocus=”this.blur()”导致tab无法访问页面全部内容,读屏软件在读取页面之前会强制过滤掉这个属性,但是如果用户是在js里面动态触发this.blur(),读屏软件又要出新招来克制了。这无疑增加了读屏软件的开发工作量,为了让盲人用户们能更顺畅的访问我们的网站,尽量避免使用onfocus=”this.blur()”哦。

注释

[1]Safari默认情况下,按tab键是不会focus链接的,但会focus表单域,在偏好设置-高级勾选“按下tab以高亮显示网页上的每一项”可开启该功能。Opera比较特殊,它通过shift+上下左右方向键可以向上下左右focus页面焦点。

[2]在Opera下点击链接(focus和active状态)时都不会出现所谓的虚线框,所以Opera下链接的虚线框问题可以不计。 Opera 通过shift+上下左右键产生的线框通过outline:none并不能去除,但是Opera支持outline这个属性。

[3]hidefocus属性是ie的私有属性,虽然hidefocus属性有true or false两个值,但测试结果是ie5-ie9不管其值为true or false, 只要添加hidefocus属性,该链接都会失去虚线框。

点击了解更多>>

转载于:https://www.cnblogs.com/leolai/articles/2545078.html

<template> <view :class="{ 'next-tree': !toEject, 'next-tree-eject': toEject }" v-if="showTree"> <view v-if="toEject && needMask" :class="[{ show: showTree }, 'next-tree-mask']" @tap="_cancel(1)"></view> <view :class="[{ show: showTree }, 'next-tree-cnt', { 'next-tree-view-sc-focused': componentType == 'customer' }]"> <view class="next-tree-bar" v-if="needBtn && componentType != 'customer'"> <view class="next-tree-bar-cancel" :style="$rpxStyle({ color: cancelColor })" hover-class="hover-c" @tap="_cancel" >取消</view > <view class="next-tree-bar-title" :style="$rpxStyle({ color: titleColor })">{{ title }}</view> <view class="next-tree-bar-confirm" :style="$rpxStyle({ color: confirmColor })" hover-class="hover-c" @tap="_confirmBtn" >确定</view > </view> <view class="next-tree-bar-box" v-if="componentType == 'customer'"> <view class="next-tree-bar-customer" :style="$rpxStyle({ color: titleColor })">{{ title || '客户所属行业' }}</view> <uni-icons class="next-tree-bar-customer-icon" type="close-outline" size="13" color="#999999" @tap="_cancel" /> </view> <slot></slot> <view class="search-input-box" v-if="needSearch"> <view :class="['search-input-wrapper', { focused: isFocused }]" @click="onFocusS"> <uni-icons :size="18" :type="'search'" :style="{ marginRight: '6px' }" :color="'#CCCCCC'" /> <!-- 输入框 --> <input :value="inputValue" class="search-input" :placeholder="placeholder" @input="onInput" @confirm="onConfirm" @focus="onFocusS" @blur="onBlurS" /> <!-- 右侧清空 Icon --> <uni-icons v-if="inputValue" :color="'#999999'" :style="{ marginLeft: '6px' }" :size="18" :type="'clear'" @tap.stop.prevent="onClearS" /> </view> </view> <view class="next-tree-view" :class="{ customer: componentType == 'customer' }" :style="$rpxStyle(nextTreeStyleCp)" > <view class="search-input-box" v-if="componentType == 'customer'"> <view :class="['search-input-wrapper', { focused: isFocused }]" @click="onFocus"> <uni-icons :size="18" :type="'search'" :style="$rpxStyle({ marginRight: '6px' })" :color="'#CCCCCC'" /> <!-- 输入框 --> <input :value="inputValue" class="search-input" :placeholder="placeholder" @input="onInput" @confirm="onConfirm" @focus="onFocus" @blur="onBlur" /> <!-- 右侧清空 Icon --> <uni-icons v-if="inputValue" :color="'#999999'" :style="$rpxStyle({ marginLeft: '6px' })" :size="18" :type="'clear'" @tap.stop.prevent="onClear" /> </view> </view> <scroll-view :class="['next-tree-view-sc', { 'next-tree-view-sc-boy': componentType == 'customer' }]" :scroll-y="true" :style="$rpxStyle(nextTreeScrollStyleCp)" > <slot name="empty"></slot> <template v-if="!showDropdown"> <block v-for="(item, index) in treeList" :key="index"> <view class="next-tree-item" :style=" $rpxStyle([ { marginLeft: componentType != 'customer' ? item.rank * marginLeftNum + 'px' : '0px', paddingLeft: componentType != 'customer' ? '0px' : item.rank == 0 ? '8px' : (item.rank - 1) * marginLeftNum + 20 + 'px', zIndex: item.rank * -1 + 50 } ]) " :class="{ border: showBorder(item.show, item.lastRank), show: item.show, last: item.lastRank, showchild: item.showChild, open: item.open, first: !item.parentIdNub, borderTop: componentType == 'customer' && !showDropdown && index == 0 }" > <view class="next-tree-label" :class="{ checked: item.checked && componentType != 'customer', disabled: item.disabled }" > <uni-icons v-if="!item.lastRank && componentType != 'customer'" class="next-tree-icon" :size="iconSize" :type="item.lastRank ? lastIcon : item.showChild ? currentIcon : defaultIcon" :color="treeIconColor" @tap.stop="_treeItemTap(item, index)" /> <uni-icons v-if="item.lastRank && zipList" class="next-tree-icon" :size="zipListIconSize" :type="imgTypeFn(item.type)" @tap.stop="_treeItemTap(item, index)" /> <uni-icons v-if="item.parentIdNub && componentType == 'customer'" class="next-tree-icon-zhexian" :size="8" color="#CCCCCC" :type="'zhexian'" @tap.stop="_treeItemSelect(item, index)" /> <view class="next-tree-icon next-tree-icon-not" v-if="!item.checked && !item.indeterminate && componentType == 'customer'" @tap.stop="_treeItemSelect(item, index)" > <view class="next-tree-icon-not-selected"> </view> </view> <!-- <uni-icons v-if="!item.checked && !item.indeterminate && componentType == 'customer'" class="next-tree-icon" :size="iconSize" :type="'not-selected'" :color="'#333'" @tap.stop="_treeItemSelect(item, index)" /> --> <uni-icons v-if="componentType == 'customer' && item.checked" class="next-tree-icon" :size="iconSize" :type="'all-selected'" :color="'#0F56D5'" @tap.stop="_treeItemSelect(item, index)" /> <uni-icons v-if="componentType == 'customer' && item.indeterminate && !item.checked" class="next-tree-icon" :color="'#0F56D5'" :size="iconSize" :type="'indeterminate'" @tap.stop="_treeItemSelect(item, index)" /> <!-- {{indeterminate}} --> <!-- <view v-if="!item.lastRank" class="next-tree-icon">{{ item.lastRank ? '' : item.showChild ? 'X' : '>' }}</view> --> <view v-if="!zipList" @tap.stop="_treeItemSelect(item, index)"> {{ item.name }}</view> <view v-if="zipList" class="zipList_box" @tap.stop="_treeItemSelect(item, index)"> <view class="zipList_box_one">{{ item.name }}</view> <view v-if="item.lastRank" class="zipList_box_two">文件大小:{{ getSize(item.size) }}</view> </view> </view> <view class="next-tree-check" @tap.stop="_treeItemTap(item, index)"> <view class="next-tree-check-yes" v-if="item.checked && !zipList" :class="{ radio: !multiple }"> <!-- <text class="icon-text">✔</text> --> <uni-icons v-if="componentType != 'customer'" :type="checkIcon" color="#0f56d5" /> </view> <view class="next-tree-check-yes" v-if="item.lastRank && zipList" :class="{ radio: !multiple }"> <uni-icons type="forward" color="#CCCCCC" size="12" /> </view> <view class="next-tree-check-no" v-if="componentType == 'customer'" :class="{ radio: !multiple }" ></view> </view> <uni-icons v-if="(!item.lastRank && componentType == 'customer') || item.showDownIcom" class="next-tree-icon" :size="iconSize" :type="item.open ? 'up' : 'down'" :color="!item.parentIdNub ? '#333' : '#999'" @tap.stop="_treeItemTap(item, index)" /> </view> </block ></template> <template v-else> <view v-if="showDropdown" class="dropdown-list"> <view v-if="inputSelection && inputValue && filteredList.length === 0" class="dropdown-item add-new" @click=" onSelect({ [otherLabelKeys]: inputValue, [otherValueKeys]: inputValue }) " > <rich-text :nodes=" renderItemText({ [otherLabelKeys]: inputValue, [otherValueKeys]: inputValue }) " /> </view> <!-- 存在匹配项时显示 --> <view v-else-if="filteredList.length > 0" class="dropdown-item-list"> <view v-for="(item, index) in filteredList" :key="index" class="dropdown-item" @click="onSelect(item)"> <rich-text :nodes="renderItemText(item)" /> </view> </view> <!-- 既无可添加、也无匹配项时 --> <view v-else class="empty-tip"> <image class="empty-image" src="https://gateway-in.rosino.com/prod/biz-zy-gateway/admin-api/infra/file/22/get/xzzl/notFound.png" mode="widthFix" ></image> <text class="empty-text">未找到搜索结果</text> </view> </view> </template> </scroll-view> <!-- <view class="next-tree-view-bottom" v-if="componentType == 'customer'"> <view class="next-tree-view-bottom-cancel" hover-class="hover-c" @tap="_cancel">取消</view> <view class="next-tree-view-bottom-confirm" hover-class="hover-c" @tap="_confirmBtn">确定</view> </view> --> <zy-sticky-bottom class="next-tree-view-bottom" v-if="componentType == 'customer'" :buttons="buttons" /> </view> </view> <view v-if="!toEject && needMask" :class="[{ show: showTree }, 'next-tree-mask-eject']" @tap="_cancel(1)"></view> </view> </template> <script> // 防抖函数 function debounce(func, wait) { let timeout return function (...args) { clearTimeout(timeout) timeout = setTimeout(() => func.apply(this, args), wait) } } /** * zy-tree 树组件 * @description 树组件 * @property {Array} treeData 组件数据{needShowChild: true,当前项与父级展开,当前项子级不展开 needOpen: true,当前项子级与父级展开,checked: true默认选中的值,irrevocable: true不能取消选中,选中的值父级自动打开} * @property {String} valueKey 选中值键名 * @property {String} labelKey 展示值键名 * @property {String} childrenKey 子类数据键名 * @property {String} title 标题 * @property {Boolean} multiple 是否可以多选 * @property {Boolean} selectParent = 是否可以选父级 * @property {Boolean} foldAll = 折叠时关闭所有已经打开的子集,再次打开时需要一级一级打开 * @property {String} confirmColor = 确定按钮颜色 * @property {String} cancelColor = 取消按钮颜色 * @property {String} titleColor = 标题颜色 * @property {String} currentIcon = 展开时候的icon * @property {String} checkIcon = 选中时候的icon * @property {String} defaultIcon = 折叠时候的icon * @property {String} lastIcon = 没有子集的icon * @property {Boolean} border = 是否有分割线 * @property {Boolean} needBtn = 是否有操作按钮 * @property {Boolean} toEject = 是否是从底部弹出 * @property {Boolean} needMask = 是否是从底部弹出 * @property {String} boxHeight = 是否是从底部弹出时组件可选区域的高度默认112px * @property {Boolean} clickMask = 点击遮罩层关闭组件 * @property {String} treeIconColor = 父级icon颜色 * @property {String} componentType = 组件类型('customer') * @event {Function} _cancel弹窗关闭触发的方法 * @event {Function} _confirmBtn数据选中或有按钮时点击‘确定’触发的方法 * @event {Function} 可通过ref调用组件内方法_show,进行组件的展示 * @event {Function} 可通过ref调用组件内方法_hide,进行组件的关闭但不触发cancel事件 * @event {Function} 可通过ref调用组件内_reTreeList()对数据进行重置 */ export default { name: 'ZyTree', props: { treeData: { type: Array, default: function () { return [] } }, valueKey: { type: String, default: 'id' }, labelKey: { type: String, default: 'label' }, childrenKey: { type: String, default: 'children' }, treeIconColor: { type: String, default: '#0f56d5' }, title: { type: String, default: '' }, multiple: { // 是否可以多选 type: Boolean, default: true }, selectParent: { //是否可以选父级 type: Boolean, default: true }, foldAll: { //折叠时关闭所有已经打开的子集,再次打开时需要一级一级打开 type: Boolean, default: false }, confirmColor: { // 确定按钮颜色 type: String, default: '#0f56d5' }, cancelColor: { // 取消按钮颜色 type: String, default: '#0f56d5' }, titleColor: { // 标题颜色 type: String, default: '' }, currentIcon: { // 展开时候的icon type: String, default: 'folder' }, checkIcon: { // 选中时候的icon type: String, default: 'checkmarkempty' }, defaultIcon: { // 折叠时候的icon type: String, default: 'folder' }, lastIcon: { // 没有子集的icon type: String, default: '' }, border: { // 是否有分割线 type: Boolean, default: true }, faNoBorder: { // 是否有分割线 type: Boolean, default: false }, needBtn: { // 是否有操作按钮 type: Boolean, default: false }, toEject: { // 是否是从底部弹出 type: Boolean, default: false }, boxHeight: { // 是否是从底部弹出时组件可选区域的高度默认100% type: String, default: '500px' }, clickMask: { // 点击遮罩层关闭组件 type: Boolean, default: true }, openAll: { // 全部打开 type: Boolean, default: false }, irrevocableAll: { // 全部不能取消选中 type: Boolean, default: false }, nextTreeStyle: { // 样式 type: Object, default: () => ({}) }, nextTreeScrollStyle: { // 样式 type: Object, default: () => ({}) }, defaultValue: { // 默认选中的值 type: Array, default: function () { return [] } }, nextTreeMinHeight: { type: String, default: '500px' }, needClickFn: { // 选中子节点是否需要触发confirm type: Boolean, default: false }, otherValueKey: { // 额外选中的值 type: Array, default: function () { return [] } }, needMask: { // 是否需要遮罩层关闭组件 type: Boolean, default: true }, allWayShow: { // 是否一直展示 type: Boolean, default: false }, iconSize: { // icon大小默认16 type: String, default: '16' }, zipList: { // 是否是文件展示列表 type: Boolean, default: false }, zipListIconSize: { // 文件列表icon大小默认34 type: String, default: '34' }, marginLeftNum: { // 每行左边缩进的距离 type: Number, default: 25 }, componentType: { // 组件类型('customer')customer~是客户编辑时编辑的样式,正常为'' type: String, default: '' }, // 默认占位符 placeholder: { type: String, default: '搜索行业关键词' }, // 列表数据(由父组件传入) list: { type: Array, default: () => [] }, // 控制是否显示下拉(可选,也可内部控制) show: { type: Boolean, default: null }, // 展示字段名,默认 'name' otherLabelKeys: { type: String, default: 'name' }, // 值字段名,默认 'value' otherValueKeys: { type: String, default: 'value' }, // 默认选中的值(用于回显) value: { type: [String, Number], default: '' }, // 防抖时间 ms debounceTime: { type: Number, default: 60 }, // 是否选中输入 inputSelection: { type: Boolean, default: true }, // 是否选中输入 otherCloseFn: { type: Boolean, default: false }, // 展示字段名,默认 'name' showDownIcom: { type: Boolean, default: false }, // 是否需要输入框 needSearch: { type: Boolean, default: false }, // 在 props 中新增 loadChildren: { type: Function, default: null // 注意:必须是 null,不能是 () => {} } }, watch: { defaultValue: { handler(val) { console.log('🚀 ~ handler ~ val:', val) // console.count('🚀 ~ defaultValue ~ val:111111111111111111111watch', val) // 类型保护 if (!Array.isArray(val)) return // 去重比较:避免内容未变却触发 const currentIds = [...val].sort() const lastIds = [...(this.lastDefaultValue || [])].sort() const isSame = currentIds.length === lastIds.length && currentIds.every((id, i) => id === lastIds[i]) if (isSame) return // 内容没变,跳过 // 缓存当前值 this.lastDefaultValue = [...val] // 防抖执行 if (this._defaultSelectDebounceTimer) { clearTimeout(this._defaultSelectDebounceTimer) } this._defaultSelectDebounceTimer = setTimeout(() => { this._defaultSelect() }, 60) // 60ms 内只执行最后一次 }, immediate: false, deep: false // ❌ 不要用 deep: true 对数组!无效且性能差 }, value: { immediate: true, handler(val) { if (val !== undefined && val !== '') { // 根据 value 查找对应的 name 回显 const matched = this.list.find(item => item[this.otherValueKeys] === val) this.inputValue = matched ? matched[this.otherLabelKeys] : '' } else { this.inputValue = '' this.showDropdown = false } } }, show: { handler(val) { this.showDropdown = val }, immediate: true } }, created() { // 创建防抖函数 this.debouncedSearch = debounce(query => { this.$emit('search', query) // 抛出给父组件调用接口 }, this.debounceTime) }, data() { return { showTree: false, treeList: [], selectIndex: -1, childNums: [], rt: [], rtName: [], otherValue: [], cklickType: { type: '', value: '' }, inputValue: '', isFocused: false, showDropdown: false, debouncedSearch: null, buttons: [ { text: '取消', type: 'cancel', click: () => this._cancel() }, { text: '提交', type: 'confirm', click: () => this._confirmBtn(), loading: false // ✅ 提前定义 } ] } }, computed: { nextTreeStyleCp() { return { minHeight: this.nextTreeStyle.minHeight ?? this.nextTreeMinHeight, height: this.nextTreeStyle.height ?? this.boxHeight, ...this.nextTreeStyle } }, nextTreeScrollStyleCp() { return { minHeight: this.nextTreeStyle.minHeight ?? this.nextTreeMinHeight, ...this.nextTreeStyle } }, filteredList() { if (!this.inputValue) return this.list return this.list.filter(item => String(item[this.otherLabelKeys]).toLowerCase().includes(this.inputValue.toLowerCase()) ) } }, methods: { escapeHTML(str) { if (typeof str !== 'string') return '' return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') }, onInput(e) { const value = e.detail.value.trim() this.inputValue = value // 触发防抖搜索 this.debouncedSearch(value) // 抛出输入事件 this.$emit('input-change', value) }, onConfirm() { this.$emit('otherConfirm', this.inputValue) }, onFocus() { this.isFocused = true this.showDropdown = true this.$emit('focus', this.inputValue) }, onFocusS() { this.isFocused = true this.$emit('focus', this.inputValue) }, onBlur() { setTimeout(() => { this.isFocused = false }, 200) // 延迟隐藏避免点击选项时消失 this.$emit('blur', this.inputValue) }, onBlurS() { setTimeout(() => { this.isFocused = false }, 200) // 延迟隐藏避免点击选项时消失 this.$emit('blur', this.inputValue) }, onClear() { this.inputValue = '' this.showDropdown = false this.$emit('clear') this.$emit('input-change', '') }, onClearS() { this.inputValue = '' this.$emit('clear') this.$emit('input-change', '') }, onSelect(item) { const selectedValue = item[this.otherValueKeys] const selectedLabel = item[this.otherLabelKeys] // 反显到输入框 this.inputValue = selectedLabel // 关闭下拉 this.showDropdown = false // 返回选中值(使用自定义 valueField) this.$emit('select', { [this.otherValueKeys]: selectedValue, label: selectedLabel }) // 通知父组件更新 v-model 或状态 this.$emit('input', selectedValue) }, /** * 渲染带高亮的文本(匹配输入内容) */ renderItemText(item) { const text = item[this.otherLabelKeys] || '' const query = this.inputValue.trim() if (!query || !text) { return this.escapeHTML(text) } const escapedText = this.escapeHTML(text) const escapedQuery = this.escapeHTML(query) // 注意:正则需确保转义后的字符串仍能正确匹配 const regex = new RegExp(`(${escapedQuery})`, 'gi') const highlighted = escapedText.replace(regex, '<span style="color: #007AFF; font-weight: bold;">$1</span>') // ✅ 使用 <p> 替代 <div> return `<p>${highlighted}</p>` }, escapeHTML(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') }, imgTypeFn(type) { let imgType = 'oth' const typeMap = { img: ['png', 'jpg', 'jpeg', 'gif', 'bmp'], exel: ['xls', 'xlsx', 'xlsm'], word: ['doc', 'docx'], pdf: ['pdf'], ppt: ['ppt', 'pptx'], txt: ['txt'] } for (let key in typeMap) { if (typeMap[key].includes(type)) { imgType = key } } return imgType }, getSize(val) { let sizeInKB = val / 1024 return sizeInKB.toFixed(2) + 'KB' }, showBorder(show, item) { if (this.faNoBorder && !item) { return false } else { return show && this.border } }, _show() { this.showTree = true this.$emit('showTree', true) this._confirm() }, _hide() { this.$emit('showTree', false) this.showTree = false this.showDropdown = false this.inputValue = '' }, _cancel(val) { if (val == 1 && !this.clickMask) return if (this.otherCloseFn) return this.$emit('cancel', '') this._hide() this.$emit('cancel', '') }, _confirm(val) { this.rt = [] this.rtName = [] this.otherValue = [] // 处理所选数据 this.treeList.forEach(item => { // if (item.id == 100 && item.checked) { if (item.checked || item.indeterminate) { this.confirmEach(this.treeData) } // if (item.checked && item.parentId && item.parentId != '100') { if ((item.checked || item.indeterminate) && item.parentId) { this.rt.push(item.id) this.rtName.push(item.name) this.otherValueKey.forEach(key => { if (!this.otherValue[key]) { this.otherValue[key] = [] // 初始化数组 } this.otherValue[key].push(item[key]) // 将对应字段值加入数组 }) } }) if (val == 1 && !this.allWayShow) { this._cancel() } this.$emit('confirm', { id: this.rt, name: this.rtName, otherValue: this.otherValue }, { ...this.cklickType }) }, _confirmBtn() { this.rt = [] this.rtName = [] this.otherValue = [] // 处理所选数据 this.treeList.forEach(item => { // if (item.id == 100 && item.checked) { // if (item.checked) { // return this.confirmEach(this.treeData) // } // if (item.checked && item.parentId && item.parentId != '100') { if ((item.checked || item.indeterminate) && item.parentId) { this.rt.push(item.id) this.rtName.push(item.name) this.otherValueKey.forEach(key => { if (!this.otherValue[key]) { this.otherValue[key] = [] // 初始化数组 } this.otherValue[key].push(item[key]) // 将对应字段值加入数组 }) } }) this.$emit('_confirmBtn', { id: this.rt, name: this.rtName, otherValue: this.otherValue }) }, confirmEach(list) { list.forEach(item => { if (item.isDy) { this.rtName.push(item.name) this.rt.push(item.id) this.otherValueKey.forEach(key => { if (!this.otherValue[key]) { this.otherValue[key] = [] // 初始化数组 } this.otherValue[key].push(item[key]) // 将对应字段值加入数组 }) } if (item.children && item.children.length > 0) this.confirmEach(item.children) }) }, //扁平化树结构 _renderTreeList(list = [], rank = 0, parentId = [], parents = []) { // console.time('_renderTreeList') // console.count('_renderTreeList 执行次数') list.forEach(item => { this.treeList.push({ ...item, id: item[this.valueKey], name: item[this.labelKey], source: item, parentIdNub: item.parentId, parentId, parents, rank, showChild: false, needShowChild: this.openAll ? this.openAll : (item.needShowChild ?? false), open: false, needOpen: this.openAll ? this.openAll : (item.needOpen ?? false), show: rank === 0, hideArr: [], orChecked: this.defaultValue.some(obj => obj == item[this.valueKey]) ? true : item.checked ? true : false, checked: this.defaultValue.some(obj => obj == item[this.valueKey]) ? true : item.checked ? true : false, disabled: item.disabled ?? false, isParent: Array.isArray(item[this.childrenKey]) && item[this.childrenKey].length > 0, irrevocable: this.irrevocableAll ? this.irrevocableAll : (item.irrevocable ?? false), indeterminate: false, hasLoadedChildren: !!item.children?.length, // 已有子节点视为已加载 loading: false, // 加载状态 lastRank: !(item.showDownIcom || (Array.isArray(item[this.childrenKey]) && item[this.childrenKey].length > 0)) }) if ((Array.isArray(item[this.childrenKey]) && item[this.childrenKey].length > 0) || item.showDownIcom) { const parentid = [...parentId] const parentArr = [...parents] parentid.push(item[this.valueKey]) parentArr.push({ [this.valueKey]: item[this.valueKey], [this.labelKey]: item[this.labelKey] }) this._renderTreeList(item[this.childrenKey], rank + 1, parentid, parentArr) } }) // console.time('_renderTreeList') }, _defaultSelect() { // console.time('_defaultSelect') // console.count('_defaultSelect 执行次数') if (this.componentType === 'customer') { this.treeList.forEach(v => { this.setAncestorsCustomerT(this.defaultValue, !v.checked, v.id) if (v.isParent && (v.checked || v.indeterminate)) { this.$set(v, 'open', true) this.$set(v, 'showChild', true) // 如果 showChild 控制显示,也要同步 } }) this._expandParentsOfCheckedNodes() console.log('🚀 ~ _defaultSelect ~ this.treeList:11111111111111111', this.treeList) } else { this.treeList.forEach(v => { const isChecked = this.defaultValue.some(obj => obj === v.id) console.log('🚀 ~ _defaultSelect ~ isChecked:', isChecked) if (isChecked !== v.checked) { v.checked = isChecked } }) // 同步父子级选中状态 this.treeList.forEach(v => { if (v.checked) { this._handleCheckedNode(v) } if (v.needOpen) { this._handleOpenNode(v) } if (v.needShowChild) { this._handleShowChildNode(v) } }) } // console.timeEnd('_defaultSelect') }, _propagateExpansionUpward() { // console.count('_propagateExpansionUpward 执行次数') const shouldExpand = new Set() // 存储应展开的节点 ID // 第一步:标记所有 checked/indeterminate 节点 for (const node of this.treeList) { if (node.checked || node.indeterminate) { shouldExpand.add(node.id) } } // 第二步:向上传播展开状态(利用 parentId 链) for (const node of this.treeList) { if (shouldExpand.has(node.id)) { let current = node while (current.parentId && current.parentId.length > 0) { const parentId = current.parentId[current.parentId.length - 1] // 直接父级 const parent = this.treeList.find(n => n.id === parentId) if (!parent) break shouldExpand.add(parent.id) current = parent } } } // 第三步:批量更新状态(只对发生变化的节点使用 $set) for (const node of this.treeList) { if (shouldExpand.has(node.id)) { if (!node.showChild) this.$set(node, 'showChild', true) if (!node.open) this.$set(node, 'open', true) if (!node.show) this.$set(node, 'show', true) } } return shouldExpand }, _expandParentsOfCheckedNodes() { // console.count('_expandParentsOfCheckedNodes 执行次数') const shouldExpand = this._propagateExpansionUpward() // 只对根展开节点调用 _updateDescendantsVisibility(避免重复) const rootExpandNodes = [...shouldExpand] .map(id => this.treeList.find(n => n.id === id)) .filter(n => n && n.isParent) // 使用 Set 去重后调用 const processedParents = new Set() for (const node of rootExpandNodes) { if (node && !processedParents.has(node.id)) { processedParents.add(node.id) this._updateDescendantsVisibility(node) } } }, _handleCheckedNode(v) { this.treeList.forEach(v2 => { if (v.parentId.toString().indexOf(v2.parentId.toString()) >= 0) { v2.show = true if (v.parentId.includes(v2.id)) { v2.showChild = true v2.open = true } } }) }, _handleOpenNode(v) { v.showChild = true v.open = true v.show = true this.treeList.forEach(v2 => { if (v2.parentId.toString().indexOf(v.id.toString()) >= 0) { v2.show = true if (v2.parentId.includes(v.id)) { if (this.componentType == 'customer') { v2.showChild = false v2.open = false } else { v2.showChild = true v2.open = true } } } if (v.parentId.includes(v2.id)) { v2.showChild = true v2.open = true } }) }, _handleShowChildNode(v) { this.treeList.forEach(v2 => { if (v.parentId.toString().indexOf(v2.parentId.toString()) >= 0) { v2.show = true if (v.parentId.includes(v2.id)) { v2.showChild = true v.showChild = true } } if (v.id === v2.id) { v.showChild = false v.open = false } }) }, _updateDescendantsVisibility(parentNode) { const { id: parentId } = parentNode const queue = [] // 使用队列模拟 BFS // 找到所有直接子节点作为起点 for (const node of this.treeList) { if (Array.isArray(node.parentId) && node.parentId.includes(parentId)) { queue.push(node) } } // BFS 遍历后代 while (queue.length > 0) { const node = queue.shift() const parentInPath = this.treeList.find(p => p.id === node.parentId[node.parentId.length - 1]) // 判断是否应该显示:父节点 showChild === true 且父节点已展开 const parentVisible = parentInPath ? parentInPath.showChild : false const shouldBeShown = parentVisible && node.parentId.every(pid => { const p = this.treeList.find(n => n.id === pid) return p && p.showChild }) if (node.show !== shouldBeShown) { this.$set(node, 'show', shouldBeShown) } // 继续向下遍历 for (const child of this.treeList) { if (Array.isArray(child.parentId) && child.parentId.includes(node.id)) { queue.push(child) } } } }, _expandParentsOfNodeById(itemId) { const node = this.treeList.find(n => n.id === itemId) if (!node || !node.parentId || node.parentId.length === 0) return const visited = new Set() // 防止重复处理 let current = node while (current.parentId && current.parentId.length > 0) { const parentId = current.parentId[current.parentId.length - 1] const parent = this.treeList.find(n => n.id === parentId) if (!parent || visited.has(parent.id)) break visited.add(parent.id) // 强制展开父节点 if (!parent.open) this.$set(parent, 'open', true) if (!parent.showChild) this.$set(parent, 'showChild', true) if (!parent.show) this.$set(parent, 'show', true) // 触发后代可见性更新 this._updateDescendantsVisibility(parent) current = parent } }, // 点击 _treeItemTap(item, index) { // 如果是客户类型且支持动态加载子节点 if (this.componentType === 'customer' && item.showDownIcom && !item.hasLoadedChildren) { // console.time('_treeItemTap') // console.count('_treeItemTap 执行次数') if (!this.loadChildren) return this.$set(this.treeList[index], 'loading', true) this.loadChildren(item) .then(rawChildren => { console.log('🚀 ~ _treeItemTap ~ rawChildren111111111111:', rawChildren) const children = Array.isArray(rawChildren) ? rawChildren : [] console.log('🚀 ~ _treeItemTap ~ children22222222222222:', children) if (children.length === 0) { this.$set(this.treeList[index], 'lastRank', true) this.$set(this.treeList[index], 'hasLoadedChildren', true) return } // 已经加载过则不再重复插入 if (this.treeList.some(node => node.parentId[node.parentId.length - 1] === item.id)) { console.warn(`⚠️ 节点 ${item.id} 的子节点已存在,跳过重复加载`) this.$set(this.treeList[index], 'hasLoadedChildren', true) this.$set(this.treeList[index], 'showChild', true) this.$set(this.treeList[index], 'open', true) this._updateDescendantsVisibility(this.treeList[index]) // 触发展开 if (this.needClickFn) this._confirm() return } // 构建子节点列表 const tempTreeList = [] const buildNodes = (list, rank, parentId, parents) => { list.forEach(child => { if (!child) return const node = { ...child, id: child[this.valueKey], name: child[this.labelKey], source: child, parentIdNub: child.parentId, parentId: [...parentId], pathAncestors: [...parentId], // 关键:记录祖先路径用于高效查找 parents: [...parents], rank, showChild: false, open: false, show: false, checked: this.defaultValue.includes(child[this.valueKey]) && !(child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0)), indeterminate: this.defaultValue.includes(child[this.valueKey]), isParent: !!child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0), hasLoadedChildren: !!child.children?.length, lastRank: !(child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0)), loading: false, disabled: child.disabled ?? false, irrevocable: child.irrevocable ?? false, hideArr: Array(rank + 1).fill(null) // 根据层级创建对应长度的 null 数组 } console.log( '111111111111111111111111111```````', child, child.name, child.id, child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0) ) if (node.isParent && (node.checked || node.indeterminate)) { node.open = true } tempTreeList.push(node) // 递归构建子节点 if (node.isParent && Array.isArray(child.children) && child.children.length > 0) { const pids = [...parentId, child[this.valueKey]] const pnames = [ ...parents, { [this.valueKey]: child[this.valueKey], [this.labelKey]: child[this.labelKey] } ] buildNodes(child.children, rank + 1, pids, pnames) } }) } // 开始构建 buildNodes( children, item.rank + 1, [...item.parentId, item.id], [...item.parents, { [this.valueKey]: item.id, [this.labelKey]: item.name }] ) // 插入到当前节点之后 const insertIndex = index + 1 this.treeList.splice(insertIndex, 0, ...tempTreeList) // 🔥 强制修正当前节点状态 const currentNode = this.treeList[index] if (currentNode) { this.$set(currentNode, 'lastRank', children.length === 0 ? true : false) this.$set(currentNode, 'hasLoadedChildren', true) this.$set(currentNode, 'open', children.length > 0) this.$set(currentNode, 'showChild', children.length > 0) // 如果有子节点,触发展开逻辑 if (children.length > 0) { this._updateDescendantsVisibility(currentNode) } } // 显示直接子节点 tempTreeList.forEach(child => { if (child.parentId[child.parentId.length - 1] === item.id && child.rank === item.rank + 1) { this.$set(child, 'show', true) } }) // 触发确认回调(如需) if (this.needClickFn) this._confirm() }) .catch(err => { console.error('加载失败:', err) }) .finally(() => { this.$set(this.treeList[index], 'loading', false) console.log('🚀 ~ .finally ~ item.id:', item.id) this._expandParentsOfNodeById(item.id) // console.log('🚀 ~ .finally ~ this.treeList:11111111111111111', this.treeList) }) // console.time('_treeItemTap') return } if (this.zipList && item.lastRank == true) { //点击最后一级时触发事件 this.treeList[index].checked = !this.treeList[index].checked for (let i = 0; i < this.treeList.length; i++) { if (this.treeList[i][this.valueKey] == this.treeList[index][this.valueKey]) { this.treeList[i].checked = this.treeList[index].checked } } this._fixMultiple(index) if (this.needClickFn) { this._confirm() } this.$emit('lastRank', item, this.treeList[index].checked) return } this.cklickType = { type: '', value: '' } if (item.disabled) return if (item.irrevocable && this.treeList[index].checked) return if (item.lastRank == true) { //点击最后一级时触发事件 this.treeList[index].checked = !this.treeList[index].checked for (let i = 0; i < this.treeList.length; i++) { if (this.treeList[i][this.valueKey] == this.treeList[index][this.valueKey]) { this.treeList[i].checked = this.treeList[index].checked } } this._fixMultiple(index) if (this.needClickFn) { this._confirm() } //子级选择/选满/取消选择, 父级往上同步状态 if (this.componentType == 'customer') { console.log(1) this.setAncestorsCustomer(item.parentId, this.treeList[index].checked, item.id) } this.$emit('lastRank', item, this.treeList[index].checked) return } let list = this.treeList let id = item.id if (item.showChild && !item.open) { item.showChild = false item.open = false } else { item.showChild = !item.showChild } item.open = item.showChild ? true : !item.open list.forEach(childItem => { if (item.showChild === false) { this.cklickType = { type: 'click', value: 'close' } //隐藏所有子级 if (!childItem.parentId.includes(id)) { return } if (!this.foldAll) { if (childItem.lastRank !== true && !childItem.open) { childItem.showChild = false } // 为隐藏的内容添加一个标记 if (childItem.show) { childItem.show = false childItem.hideArr[item.rank] = id } } else { if (childItem.lastRank !== true) { childItem.showChild = false } } childItem.show = false } else { this.cklickType = { type: 'click', value: 'open' } // 打开子集 if (childItem.parentId[childItem.parentId.length - 1] === id) { childItem.show = true } // 打开被隐藏的子集 if (childItem.parentId.includes(id) && !this.foldAll) { if (childItem.hideArr[item.rank] === id) { childItem.show = true if (childItem.open && childItem.showChild) { childItem.showChild = true } else { childItem.showChild = false } childItem.hideArr[item.rank] = null } } } }) if (this.selectParent) { this.$emit('faClick', item, this.cklickType.value) } if (!this.selectParent) return if (!this.needBtn || this.needClickFn) { this._confirm() } }, _treeItemSelect(item, index) { this.cklickType = { type: '', value: '' } if (this.selectParent) { this.$emit('faClick', item, !this.treeList[index].checked) } if (item.lastRank == true) { this.$emit('lastRank', item, !this.treeList[index].checked) } if (item.disabled) return if (!this.selectParent && !item.lastRank) return this._treeItemTap(item, index) if (item.irrevocable && this.treeList[index].checked) return this.treeList[index].checked = !this.treeList[index].checked if (this.treeList[index].checked && this.treeList[index].indeterminate) { this.treeList[index].indeterminate = false } for (let i = 0; i < this.treeList.length; i++) { if (this.treeList[i][this.valueKey] == this.treeList[index][this.valueKey]) { this.treeList[i].checked = this.treeList[index].checked } } // 选父级, 子级自动全选 // this.syncChecked(this.treeList, item.id, this.treeList[index].checked) this.syncChecked(item.id, this.treeList[index].checked) if (item.rank > 0) { item.parentId.forEach(pid => { const parent = this.treeList.filter(i => i.id === pid) const childNum = parent.length > 0 ? parent[0].childNum : 0 if (this.childNums[pid] === undefined) { this.childNums[pid] = 1 } else if (this.childNums[pid] < childNum) { this.childNums[pid]++ } }) //子级选择/选满/取消选择, 父级往上同步状态 if (this.componentType == 'customer') { console.log(1) this.setAncestorsCustomerT(item.parentId, this.treeList[index].checked, item.id) } else { console.log(2) this.setAncestors(item.parentId, this.treeList[index].checked) } } else { if (this.componentType == 'customer') { this.setAncestorsCustomerT(item.parentId, this.treeList[index].checked, item.id) } } this._fixMultiple(index) if (!this.needBtn) { if (this.multiple) { this._confirm() } else { if (this.componentType == 'customer') { this._confirm() } else { this._confirm(1) } } } if (this.needClickFn) { this._confirm() } }, syncChecked(parentId, checked) { this.treeList.forEach(item => { if (item.disabled || item.irrevocable) return // 判断当前节点是否在 parent 路径下 if (Array.isArray(item.parentId) && item.parentId.includes(parentId)) { this.$set(item, 'checked', checked) } }) }, setAncestors(pids, checked) { this.treeList.forEach((item, index) => { if (pids.includes(item.id)) { if (checked && this.childNums[item.id] !== undefined && item.childNum === this.childNums[item.id]) { // 子级全部选中, 父级才选中 this.treeList[index].checked = true } else { this.treeList[index].checked = false } this.setAncestors(item.parentId, checked) this.cklickType = { type: 'checked', value: checked } } }) }, setAncestorsCustomer(pids, checked, id) { if (this.multiple) { console.log('---multiple') } else { for (let index = 0; index < this.treeList.length; index++) { if (!checked) { this.treeList[index].checked = false this.treeList[index].indeterminate = false } else { if (pids.includes(this.treeList[index].id) && !this.treeList[index].lastRank) { this.treeList[index].checked = false this.treeList[index].indeterminate = true } else if (pids.includes(this.treeList[index].id) && this.treeList[index].lastRank) { this.treeList[index].checked = true this.treeList[index].indeterminate = false } else { this.treeList[index].indeterminate = false } } } } // 记录操作类型 this.cklickType = { type: 'checked', value: checked } }, setAncestorsCustomerT(pids, checked, id) { if (this.multiple) { console.log('---multiple') } else { for (let index = 0; index < this.treeList.length; index++) { if (!checked) { this.treeList[index].checked = false this.treeList[index].indeterminate = false } else { if (pids.includes(this.treeList[index].id) && !this.treeList[index].lastRank) { this.treeList[index].checked = false this.treeList[index].indeterminate = true } else if (pids.includes(this.treeList[index].id) && this.treeList[index].lastRank) { this.treeList[index].checked = true this.treeList[index].indeterminate = false } else { this.treeList[index].indeterminate = false } } } } // 记录操作类型 this.cklickType = { type: 'checked', value: checked } }, // _treeItemSelect(item, index) { // this.treeList[index].checked = !this.treeList[index].checked // this._fixMultiple(index) // }, // 处理单选多选 _fixMultiple(index) { if (!this.multiple) { // 如果是单选 this.treeList.forEach((v, i) => { if (i != index) { this.treeList[i].checked = false } else { if (this.treeList[i].checked) { this.treeList[i].checked = true } else { this.treeList[i].checked = false } } }) if (this.componentType == 'customer') { this._confirm() } else { this._confirm(1) } } }, // 重置数据 _reTreeList() { this.treeList.forEach((v, i) => { this.treeList[i].checked = v.orChecked }) }, _initTree(treeData = this.treeData) { // console.count('_initTree执行次数') this.treeList = [] this._renderTreeList(treeData) this.$nextTick(() => { // this._defaultSelect(treeData) }) } }, mounted() { this._initTree() this.$watch( () => this.treeData, list => { // console.count('_initTree执行次数') this._initTree(list) }, { deep: true, immediate: true } ) this.$watch( () => [this.multiple, this.selectParent], () => { if (this.treeData.length) { this._reTreeList() } }, { deep: true, immediate: true } ) } } </script> <style lang="scss"> @import './style.scss'; </style> 在传入defaultValue的时候,如果默认选中项是数组中的最后一个父级中的某一个选项,那么就会导致无法选中
最新发布
11-20
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值