记一次 UniApp 自定义权限指令在 Android 上失效的调试过程

在开发 UniApp 项目的过程中,我遇到了一个非常诡异的 bug:同样的权限控制代码,在 Web (H5) 上运行正常,但在 Android 原生 App 上却完全失效。这个问题困扰了我很久,最终通过系统的调试找到了根本原因,并总结了在 UniApp 中进行权限控制的最佳实践。

问题描述

症状

在某个详情页面(pages/detail/index.vue)中,有一个”特殊操作”按钮,该按钮应该只对”管理员”角色可见:

1
2
3
4
5
6
7
8
<button
v-if="item.status !== 'DONE' && item.status !== 'PROCESSING'"
v-permission="[Role.ADMIN]"
class="action-btn"
@click.stop="handleSpecialAction(item)"
>
<text class="action-text">特殊操作</text>
</button>

预期行为:

  • 管理员(角色代码:ADMIN)登录 → 按钮显示
  • 普通用户(角色代码:USER)登录 → 按钮隐藏

实际表现:

  • ✅ Web (H5):权限控制正常工作
  • ❌ Android 原生 App:按钮始终显示,无论用户角色如何

技术背景

项目架构

  • 框架:UniApp 3.0 + Vue 3 Composition API
  • 状态管理:Pinia
  • 运行环境:Android 原生 App
  • 权限实现:自定义 v-permission 指令

权限系统设计

1. 用户 Store (stores/user.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref({})
const roleCodes = ref([]) // 用户角色编码列表

// 从本地存储恢复状态
const restoreFromStorage = () => {
const savedToken = uni.getStorageSync('token')
const savedUserInfo = uni.getStorageSync('userInfo')
const savedRoleCodes = uni.getStorageSync('roleCodes')

if (savedToken) token.value = savedToken
if (savedUserInfo) userInfo.value = savedUserInfo
if (savedRoleCodes) roleCodes.value = savedRoleCodes
}

return { token, userInfo, roleCodes, restoreFromStorage }
})

2. 权限检查函数 (utils/permission.js)

1
2
3
4
5
6
7
8
9
10
11
export function hasPermission(roles) {
if (!roles || !Array.isArray(roles) || roles.length === 0) {
return false
}

const userStore = useUserStore()
const userRoleCodes = userStore.roleCodes || []

// 检查用户是否拥有任一指定角色
return roles.some(role => userRoleCodes.includes(role))
}

3. 自定义指令 (main.js - 原始版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.directive('permission', {
mounted(el, binding) {
const roles = binding.value
if (!hasPermission(roles)) {
el.style.display = 'none'
}
},
updated(el, binding) {
const roles = binding.value
if (!hasPermission(roles)) {
el.style.display = 'none'
} else {
el.style.display = ''
}
}
})

调试过程

第一步:确认数据是否正确加载

首先怀疑是用户角色数据没有正确加载。添加调试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
onShow(() => {
setTimeout(() => {
const userStore = useUserStore()
const permissionCheck = hasPermission([Role.ADMIN])

uni.showModal({
title: '权限调试',
content:
`用户角色: ${JSON.stringify(userStore.roleCodes)}\n` +
`需要角色: ${Role.ADMIN}\n` +
`权限检查: ${permissionCheck ? '有权限' : '无权限'}`,
showCancel: false
})
}, 1000)
})

结果:

  • roleCodes 数据正确:["USER"](普通用户)
  • hasPermission() 返回正确:false(无权限)
  • 但按钮仍然显示!

这说明数据加载没问题,问题出在指令本身。

第二步:定位指令执行时机

添加指令内部的调试日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.directive('permission', {
mounted(el, binding) {
const roles = binding.value
const userStore = useUserStore()

uni.showModal({
title: 'v-permission 挂载',
content:
`需要角色: ${JSON.stringify(roles)}\n` +
`用户角色: ${JSON.stringify(userStore.roleCodes)}\n` +
`权限检查: ${hasPermission(roles)}`,
showCancel: false
})

if (!hasPermission(roles)) {
el.style.display = 'none'
}
}
})

发现关键问题:

在 Android 上的调试结果让我大吃一惊:

  • userStore.roleCodes = ["cgy"] (数据正确!)
  • hasPermission([Role.WAREHOUSE_MANAGER]) 返回 false (逻辑正确!)
  • 但按钮仍然可见!

而在 Web 上:

  • userStore.roleCodes = ["cgy"]
  • hasPermission() 返回 false
  • ✅ 按钮正确隐藏

第三步:定位真正的问题

既然数据正确、逻辑正确,问题一定出在视图更新机制上。

继续深入调试,在指令中添加更多日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
app.directive('permission', {
mounted(el, binding) {
const roles = binding.value
const hasPermissionFlag = hasPermission(roles)

uni.showModal({
title: '指令执行',
content:
`权限检查: ${hasPermissionFlag}\n` +
`执行操作: ${hasPermissionFlag ? '显示' : '隐藏'}\n` +
`el.style.display: ${el.style.display}`,
showCancel: false
})

if (!hasPermissionFlag) {
el.style.display = 'none'
// 验证设置是否生效
setTimeout(() => {
uni.showToast({
title: `实际 display: ${el.style.display}`,
icon: 'none'
})
}, 100)
}
}
})

关键发现:

  • 设置 el.style.display = 'none'
  • 通过 el.style.display 读取确实是 'none'
  • 但按钮在界面上依然可见!

这说明在 Android 原生环境中,直接操作 el.style 不会触发视图更新

第四步:问题本质

通过多次测试和对比,问题的本质浮出水面:

问题 1:DOM 操作不触发原生渲染

1
2
// 这行代码在 Android 上不生效
el.style.display = 'none'

在 UniApp 的 Android 原生环境中:

  • el 不是真正的 DOM 元素
  • 它是 Vue 虚拟 DOM 到 Native 组件的映射
  • 直接修改 el.style 不会触发 Native 层的重新渲染
  • 必须通过 Vue 的响应式系统来控制显示/隐藏

问题 2:即使加上 watch 也无济于事

尝试添加响应式监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { watch } from 'vue'

app.directive('permission', {
mounted(el, binding) {
const userStore = useUserStore()

const updateVisibility = () => {
if (!hasPermission(roles)) {
el.style.display = 'none' // ❌ 仍然不生效
}
}

// 监听角色变化
watch(() => userStore.roleCodes, updateVisibility)
}
})

即使 watch 能正常触发(实际上在某些情况下也不会触发),核心问题仍然存在:

  • el.style.display 操作在 Android 原生环境中不可靠
  • 这不是响应式的问题,而是渲染机制的问题

根本原因分析

为什么 Web 上可以,Android 上不行?

Web (H5) 环境:

  • 使用标准的 Web 技术栈
  • Vue 的响应式系统直接操作浏览器 DOM
  • el.style.display = 'none' 直接修改 DOM 样式,立即生效
  • 浏览器会立即重新渲染

Android 原生环境:

  • UniApp 通过 Native 渲染引擎(类似 React Native/Flutter)
  • Vue 虚拟 DOM → Native 组件的间接映射
  • el.style 的修改不会自动同步到 Native 层
  • Native 渲染依赖 Vue 的响应式数据驱动,而非直接 DOM 操作
  • 自定义指令中直接修改样式属性会被渲染引擎忽略

技术细节

UniApp 在编译到原生平台时的渲染机制:

1
2
3
Vue 组件 → 虚拟 DOM → UniApp 渲染层 → Native 组件 → 屏幕显示
↑ ↓
└────────── 响应式数据驱动 ──────────────────┘

在这个流程中:

  1. 视图更新必须通过响应式数据驱动
  2. 直接 DOM 操作会被跳过,因为 Native 层不监听 DOM 变化
  3. 自定义指令中的 el 是虚拟 DOM 节点的引用,修改它不会触发渲染流程

因此:

  • el.style.display = 'none' → 修改了虚拟 DOM,但 Native 不知道
  • v-if="condition" → 响应式数据变化,触发完整渲染流程

解决方案

最终方案:Computed Property + v-if

放弃自定义指令,改用 Vue 原生的响应式机制:

1. 创建 Computed 属性

1
2
3
4
5
6
7
8
9
10
11
12
// 在 <script setup> 中
import { computed } from 'vue'
import { useUserStore } from '@/stores/user.js'
import { hasPermission } from '@/utils/permission.js'
import { Role } from '@/constants/enums.js'

const userStore = useUserStore()

// Computed: 检查是否为管理员
const isAdmin = computed(() => {
return hasPermission([Role.ADMIN])
})

2. 创建组合条件方法

1
2
3
4
5
6
// 方法: 是否显示特殊操作按钮
const showActionButton = (item) => {
const statusCheck = item.status !== 'DONE' && item.status !== 'PROCESSING'
const permissionCheck = isAdmin.value
return statusCheck && permissionCheck
}

3. 在模板中使用原生 v-if

1
2
3
4
5
6
7
<button
v-if="showActionButton(item)"
class="action-btn"
@click.stop="handleSpecialAction(item)"
>
<text class="action-text">特殊操作</text>
</button>

为什么这个方案有效?

  1. Vue 原生 v-if:UniApp 对 Vue 核心指令有完整支持和优化
  2. Computed 属性:天然响应式,当 roleCodes 变化时自动重新计算
  3. 无 DOM 操作:不直接操作样式,而是通过 Vue 的渲染机制
  4. 跨平台一致:Web、Android、iOS 表现一致

测试结果

✅ **Web (H5)**:按钮正确显示/隐藏
Android App:按钮正确显示/隐藏
响应式:角色变化时自动更新
性能:无额外开销

最佳实践总结

❌ 避免的做法

1. 在 UniApp 中避免直接 DOM 操作

1
2
3
4
5
6
// ❌ 不推荐:在指令中直接操作样式
app.directive('myDirective', {
mounted(el) {
el.style.display = 'none' // Android 上可能不生效
}
})

2. 避免在指令内部使用复杂的响应式逻辑

1
2
3
4
5
6
7
8
9
// ❌ 不推荐:指令内部的 watch 可能不触发
app.directive('permission', {
mounted(el, binding) {
const userStore = useUserStore()
watch(() => userStore.roleCodes, () => {
// 这在 Android 上可能不工作
})
}
})

✅ 推荐的做法

1. 使用 Computed 属性 + v-if

1
2
3
4
5
6
7
8
9
<template>
<button v-if="hasPermission">操作</button>
</template>

<script setup>
const hasPermission = computed(() => {
return hasPermission([Role.ADMIN])
})
</script>

2. 组合多个条件

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<button v-if="showButton(item)">操作</button>
</template>

<script setup>
const showButton = (item) => {
return item.status === 'ACTIVE' && hasManagerRole.value
}

const hasManagerRole = computed(() => {
return hasPermission([Role.MANAGER])
})
</script>

3. 使用 v-show(需要频繁切换时)

1
2
3
4
5
6
7
8
9
10
<template>
<!-- v-show 比 v-if 更适合频繁切换 -->
<view v-show="isVisible">内容</view>
</template>

<script setup>
const isVisible = computed(() => {
return userStore.roleCodes.length > 0
})
</script>

权限控制模式对比

方案 1: 自定义指令(不推荐用于 UniApp)

优点:

  • 代码简洁,用法统一
  • 适合纯 Web 项目

缺点:

  • ❌ UniApp 原生平台支持不佳
  • ❌ 响应式可能失效
  • ❌ 调试困难

适用场景:

  • 纯 Web Vue 项目
  • 不需要编译到原生平台

方案 2: Computed + v-if(推荐)

优点:

  • ✅ 跨平台一致性
  • ✅ 完全响应式
  • ✅ Vue 原生支持
  • ✅ 易于调试
  • ✅ 性能优秀

缺点:

  • 每个权限点需要单独定义

适用场景:

  • UniApp 项目(强烈推荐
  • 需要组合多个条件的场景
  • 需要跨平台支持

方案 3: 渲染函数(复杂场景)

适用场景:

  • 动态组件渲染
  • 复杂的权限逻辑树
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { h } from 'vue'

const PermissionWrapper = (props, { slots }) => {
const userStore = useUserStore()
const hasPermission = userStore.roleCodes.some(
code => props.roles.includes(code)
)

return hasPermission ? slots.default() : null
}

// 使用
<PermissionWrapper :roles="[Role.ADMIN]">
<button>管理操作</button>
</PermissionWrapper>

通用调试技巧

1. 使用 uni.showModal 而非 console.log

在 Android 原生环境中,console.log 可能不会显示或难以查看。

1
2
3
4
5
6
7
8
9
// ❌ Android 上可能看不到
console.log('Debug info:', data)

// ✅ 使用模态框调试
uni.showModal({
title: '调试信息',
content: JSON.stringify(data, null, 2),
showCancel: false
})

2. 延迟检查状态

给异步操作足够的时间:

1
2
3
4
5
6
7
8
9
10
11
onShow(() => {
setTimeout(() => {
// 检查状态
const state = userStore.roleCodes
uni.showModal({
title: '状态检查',
content: `角色: ${JSON.stringify(state)}`,
showCancel: false
})
}, 1000) // 延迟 1 秒
})

3. 分步骤调试

1
2
3
4
5
6
7
8
9
// 步骤 1: 检查数据是否加载
console.log('Step 1: roleCodes =', userStore.roleCodes)

// 步骤 2: 检查权限函数
const permission = hasPermission([Role.ADMIN])
console.log('Step 2: hasPermission =', permission)

// 步骤 3: 检查计算属性
console.log('Step 3: computed =', isAdmin.value)

性能优化建议

1. 缓存权限检查结果

1
2
3
4
5
6
// ❌ 每次渲染都检查
<button v-if="hasPermission([Role.ADMIN])">

// ✅ 使用 computed 缓存
const isAdmin = computed(() => hasPermission([Role.ADMIN]))
<button v-if="isAdmin">

2. 避免在循环中重复检查

1
2
3
4
5
6
7
8
9
<!-- ❌ 在列表中每个 item 都检查一次 -->
<view v-for="item in list" :key="item.id">
<button v-if="hasPermission([Role.ADMIN])">操作</button>
</view>

<!-- ✅ 在外部检查一次 -->
<view v-for="item in list" :key="item.id">
<button v-if="isAdmin">操作</button>
</view>

3. 使用函数工厂模式

1
2
3
4
5
6
7
8
9
10
11
// 创建权限检查器工厂
const createPermissionChecker = (roles) => {
const userStore = useUserStore()
return computed(() => {
return roles.some(role => userStore.roleCodes.includes(role))
})
}

// 使用
const canEdit = createPermissionChecker([Role.EDITOR, Role.ADMIN])
const canDelete = createPermissionChecker([Role.ADMIN])

代码组织建议

1. 提取权限 Composable

创建 composables/usePermission.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { computed } from 'vue'
import { useUserStore } from '@/stores/user.js'

export function usePermission() {
const userStore = useUserStore()

const hasPermission = (roles) => {
if (!roles || roles.length === 0) return false
return roles.some(role => userStore.roleCodes.includes(role))
}

const hasAllPermissions = (roles) => {
if (!roles || roles.length === 0) return false
return roles.every(role => userStore.roleCodes.includes(role))
}

// 常用角色检查
const isAdmin = computed(() => hasPermission(['ADMIN']))
const isManager = computed(() => hasPermission(['MANAGER']))
const isUser = computed(() => hasPermission(['USER']))

return {
hasPermission,
hasAllPermissions,
isAdmin,
isManager,
isUser
}
}

2. 在组件中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import { usePermission } from '@/composables/usePermission.js'

const { isManager, hasPermission } = usePermission()

const showSpecialAction = computed(() => {
return isManager.value && item.status !== 'DONE'
})
</script>

<template>
<button v-if="showSpecialAction">特殊操作</button>
<button v-if="hasPermission(['ADMIN'])">管理</button>
</template>

总结

关键收获

  1. 跨平台差异要重视:Web 上能运行的代码,在原生平台可能失效
  2. 优先使用平台原生特性:Vue 的 v-ifcomputed 比自定义指令更可靠
  3. 响应式是关键:利用 Vue 的响应式系统,而非手动 DOM 操作
  4. 调试方法要适配平台:Android 上用 uni.showModal,不要依赖 console.log

UniApp 权限控制金科玉律

1
2
3
4
5
6
✅ 使用 Computed 属性 + v-if
❌ 避免自定义指令操作 DOM
✅ 利用 Vue 响应式系统
❌ 避免直接样式操作
✅ 在实际设备上测试
❌ 不要假设 Web 和原生行为一致

最终方案模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<button v-if="showButton(item)">操作</button>
</template>

<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user.js'
import { hasPermission } from '@/utils/permission.js'
import { Role } from '@/constants/enums.js'

const userStore = useUserStore()

// 权限检查 computed
const hasAdminRole = computed(() => {
return hasPermission([Role.ADMIN])
})

// 组合条件方法
const showButton = (item) => {
const statusCheck = item.status === 'ACTIVE'
const permissionCheck = hasAdminRole.value
return statusCheck && permissionCheck
}
</script>

后记

这次调试经历让我深刻认识到:

  1. **不要迷信”跨平台”**:即使是 Vue 这样成熟的框架,在不同平台也会有差异
  2. 简单往往更好:复杂的自定义指令不如简单的 v-if + computed
  3. 测试很重要:在实际设备上测试是必须的,不能只在开发环境验证
  4. 文档化经验:记录这类坑,可以帮助团队避免重复踩坑

希望这篇文章能帮助到同样在使用 UniApp 开发跨平台应用的开发者。如果你也遇到了类似的问题,欢迎交流讨论!