前端权限校验最佳实践:一个健壮的柯里化工具函数

7 阅读7分钟

在业务开发中,权限校验是绕不开的常见场景。无论是管理后台的按钮权限控制,还是金融系统的操作权限验证,都需要在业务逻辑执行前进行权限判断。

然而,权限校验的代码往往散落在各处,重复且难以维护。本文分享一个经过多轮评审和实战检验的权限校验工具函数,从设计思路到最佳实践,帮助你在项目中优雅地处理权限校验。

需求背景

典型场景

假设我们在开发一个用户管理模块:

// 场景1:删除用户 - 需要管理员权限
const handleDelete = async (userId: string) => {
  if (!hasPermission('user:delete')) {
    message.error('无删除权限');
    return;
  }
  await deleteUser(userId);
};

// 场景2:编辑用户 - 需要特定角色
const handleEdit = async (user: User) => {
  if (!canEditUser(user)) {
    message.error('无编辑权限');
    return;
  }
  await updateUser(user);
};

// 场景3:异步权限校验 - 需要请求后端接口
const handleExport = async () => {
  const hasPerm = await checkPermissionAsync('user:export');
  if (!hasPerm) {
    message.error('无导出权限');
    return;
  }
  await exportUsers();
};

存在的问题

  1. 代码重复:每个函数都要写相同的校验逻辑
  2. 参数透传麻烦:Antd 等组件的事件处理函数需要传递事件参数
  3. 错误处理不统一:权限错误和业务错误混在一起
  4. 难以维护:权限校验逻辑分散,修改需要改动多处

设计思路

核心目标

  • 复用性:一次配置,多处使用
  • 参数透传:保持原函数参数不变
  • 类型安全:完整的 TypeScript 类型支持
  • 错误隔离:权限错误和运行时错误分开处理

方案选择

方案1:装饰器模式

@checkPermission('user:delete')
async handleDelete(userId: string) {
  await deleteUser(userId);
}

优点:语法优雅
缺点:对箭头函数不友好,React Hooks 场景受限

方案2:高阶函数

const handleDelete = withPermission(
  () => hasPermission('user:delete'),
  '无删除权限'
)((userId: string) => deleteUser(userId));

优点:函数式编程,与 React 兼容
缺点:需要处理参数透传

方案3:柯里化(最终选择)

const handleDelete = withPermissionCheck({
  validate: () => hasPermission('user:delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await deleteUser(userId);
});

优点:配置清晰、支持柯里化、参数自动透传
缺点:返回值类型需要处理

我们选择柯里化方案,它在灵活性和可读性之间取得了良好平衡。

实现详解

基础实现

export interface PermissionCheckOptions {
  validate: boolean | (() => boolean) | (() => Promise<boolean>);
  errorMessage?: string;
  onForbidden?: (message?: string) => boolean | void;
  onError?: (error: unknown) => void;
  onChecking?: (checking: boolean) => void;
  showMessage?: (message: string) => void;
}

export function withPermissionCheck<T extends (...args: unknown[]) => unknown>(
  options: PermissionCheckOptions
) {
  return (targetFn: T): ((...args: Parameters<T>) => Promise<ReturnType<T>>) => {
    return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
      try {
        // 校验权限
        let hasPermission: boolean;
        if (typeof options.validate === 'boolean') {
          hasPermission = options.validate;
        } else if (typeof options.validate === 'function') {
          hasPermission = await options.validate();
        } else {
          hasPermission = false;
        }

        // 权限失败处理
        if (!hasPermission) {
          const msg = options.errorMessage || '无操作权限';
          const handled = options.onForbidden?.(msg);

          if (handled !== true) {
            const messageHandler = options.showMessage || defaultMessageHandler;
            messageHandler(msg);
          }

          throw new PermissionDeniedError(msg, { args, handled });
        }

        // 执行目标函数
        return (await targetFn(...args)) as ReturnType<T>;
      } catch (error) {
        if (error instanceof PermissionDeniedError) {
          throw error;
        }
        options.onError?.(error);
        throw error;
      }
    }) as (...args: Parameters<T>) => Promise<ReturnType<T>>;
  };
}

关键设计点

1. 参数透传保证

使用 TypeScript 泛型和 Parameters<T> 实现参数自动透传:

// 原函数签名
type TargetFn = (pagination: TablePagination, filters: Record<string, any>, sorter: Sorter) => void;

// 包装后
const wrappedFn = withPermissionCheck({
  validate: () => hasPermission('view')
})(targetFn);

// 类型自动推断,参数完整透传
wrappedFn({ current: 1, pageSize: 10 }, {}, {});

2. 自定义错误类型

引入 PermissionDeniedError 区分权限错误和运行时错误:

export class PermissionDeniedError extends Error {
  constructor(
    message: string,
    public readonly context?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'PermissionDeniedError';
  }
}

使用场景:

try {
  await handleDelete('user-123');
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    message.warning(error.message);
    return;
  }
  message.error('系统错误');
}

3. 解耦 UI 库

通过 showMessage 配置项实现 UI 库解耦:

// 使用 Ant Design
import { message } from 'antd';
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => message.error(msg)
})(deleteUser);

// 使用 Naive UI
import { useMessage } from 'naive-ui';
const { error } = useMessage();
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => error(msg)
})(deleteUser);

// 完全自定义
const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  showMessage: (msg) => {
    const div = document.createElement('div');
    div.textContent = msg;
    document.body.appendChild(div);
    setTimeout(() => div.remove(), 3000);
  }
})(deleteUser);

4. 避免 Loading 闪烁

仅在异步校验时触发 onChecking

// 判断是否为异步校验
const isAsyncValidation =
  typeof options.validate === 'function' &&
  (options.validate as () => Promise<boolean>)().then !== undefined;

// 仅异步校验时触发
if (isAsyncValidation) {
  options.onChecking?.(true);
}

// ... 执行逻辑

if (isAsyncValidation) {
  options.onChecking?.(false);
}

使用指南

基础用法

1. 静态权限(boolean)

const handleDelete = withPermissionCheck({
  validate: hasPermission('delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await deleteUser(userId);
});

// 调用
try {
  await handleDelete('user-123');
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    // 权限不足
  }
}

2. 同步校验函数

const handleClick = withPermissionCheck({
  validate: () => canEdit(),
  errorMessage: '无编辑权限'
})((event: React.MouseEvent) => {
  console.log(event.currentTarget);
});

// 在 React 组件中使用
<Button onClick={handleClick}>编辑</Button>

3. 异步校验函数

const handleExport = withPermissionCheck({
  validate: async () => {
    const result = await checkPermissionAsync('export');
    return result;
  },
  errorMessage: '无导出权限',
  onChecking: (loading) => setLoading(loading)
})(async () => {
  await exportData();
});

高级用法

1. 自定义错误提示

import { Modal } from 'antd';

const handleDelete = withPermissionCheck({
  validate: () => hasPermission('delete'),
  errorMessage: '删除权限不足',
  onForbidden: (msg) => {
    Modal.warning({
      title: '权限提示',
      content: msg,
    });
    return true; // 已自定义处理,不显示默认提示
  }
})(deleteUser);

2. 处理运行时错误

const handleAsync = withPermissionCheck({
  validate: () => true,
  errorMessage: '操作失败',
  onError: (error) => {
    console.error('执行出错:', error);
    message.error('操作失败,请重试');
  }
})(async () => {
  await riskyOperation();
});

3. Antd 组件集成

// Table onChange - 多参数透传
const handleTableChange = withPermissionCheck({
  validate: () => hasPermission('view'),
  errorMessage: '无查看权限'
})((pagination, filters, sorter, extra) => {
  console.log(pagination.current, filters, sorter.field, extra.action);
  fetchData();
});

<Table onChange={handleTableChange} />

// Form onFinish
const handleFormSubmit = withPermissionCheck({
  validate: () => canSubmit(),
  errorMessage: '无提交权限'
})(async (values: FormValues) => {
  await submitForm(values);
});

<Form onFinish={handleFormSubmit}>

最佳实践

1. UI 层预处理

在按钮或入口处判断权限,避免触发校验:

const deleteUser = withPermissionCheck({
  validate: () => hasPermission('delete'),
  errorMessage: '无删除权限'
})(async (userId: string) => {
  await api.delete(userId);
});

// 使用
<DataTable
  rowActions={(record) => [
    <Button
      key="delete"
      disabled={!hasPermission('delete')}
      danger
      onClick={() => deleteUser(record.id)}
    >
      删除
    </Button>
  ]}
/>

2. 错误边界处理

在 React Error Boundary 中统一处理:

class PermissionErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError(error: Error) {
    if (error instanceof PermissionDeniedError) {
      return { hasError: false }; // 不显示错误边界,由组件自行处理
    }
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    if (!(error instanceof PermissionDeniedError)) {
      // 记录其他错误
      logError(error);
    }
  }

  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

3. 权限校验与业务逻辑分离

将权限校验逻辑抽离为独立模块:

// permissions.ts
export const UserPermissions = {
  canDelete: () => hasPermission('user:delete'),
  canEdit: (user: User) => user.id === currentUser.id || hasRole('admin'),
  canExport: async () => {
    const { data } = await api.checkPermission('user:export');
    return data.allowed;
  }
};

// 使用
const handleDelete = withPermissionCheck({
  validate: UserPermissions.canDelete,
  errorMessage: '无删除权限'
})(deleteUser);

常见问题

Q1: 为什么权限失败要抛出错误而不是返回 undefined?

答案:类型安全的考虑。

如果返回 undefined

const getUserData = withPermissionCheck({
  validate: false
})(async (id: string): Promise<User> => {
  return await fetchUser(id);
});

// 类型推断为 Promise<User>,实际返回 Promise<User | undefined>
const user = await getUserData('123');
user.name; // 运行时报错!

抛出错误确保类型契约完整:

try {
  const user = await getUserData('123'); // 类型安全
  user.name;
} catch (error) {
  if (error instanceof PermissionDeniedError) {
    // 明确处理权限错误
  }
}

Q2: 为什么包装后的函数总是异步的?

答案:统一行为,减少复杂度。

虽然这会导致同步函数也被包装成异步,但有以下好处:

  1. API 一致性:所有包装函数的调用方式相同
  2. 简化类型:不需要复杂的函数重载
  3. 扩展性:方便后续添加异步权限校验

在文档中明确说明这一点即可。

Q3: 如何在单元测试中使用?

答案:mock 消息提示函数。

import { withPermissionCheck } from '@/utils/permission-check';

describe('权限校验', () => {
  let showMessageMock: jest.Mock;

  beforeEach(() => {
    showMessageMock = jest.fn();
  });

  it('应该调用自定义提示函数', async () => {
    const targetFn = jest.fn();
    const wrappedFn = withPermissionCheck({
      validate: false,
      errorMessage: '无权限',
      showMessage: showMessageMock,
    })(targetFn);

    await expect(wrappedFn()).rejects.toThrow(PermissionDeniedError);
    expect(showMessageMock).toHaveBeenCalledWith('无权限');
  });
});

Q4: 如何处理高频调用的权限校验?

答案:在 validate 函数外部缓存。

// 简单缓存
let permissionCache: Map<string, boolean> = new Map();

const getPermission = async (key: string) => {
  if (permissionCache.has(key)) {
    return permissionCache.get(key);
  }

  const result = await api.checkPermission(key);
  permissionCache.set(key, result);
  return result;
};

// 使用
const handleDelete = withPermissionCheck({
  validate: async () => await getPermission('user:delete')
})(deleteUser);

如果需要更复杂的缓存逻辑(如 TTL),建议使用成熟的缓存库。

性能考虑

开销分析

包装函数的开销主要来自:

  1. 异步函数调用:Promise 包装的开销很小(< 1ms)
  2. 类型检查:仅在编译时,无运行时开销
  3. 条件判断:几个 if/else 判断,开销可忽略

优化建议

  1. 避免重复创建:在组件外或 useMemo 中创建包装函数
// ❌ 每次 render 都创建新函数
function Component() {
  const handleDelete = withPermissionCheck({ ... })(deleteUser);

  return <Button onClick={handleDelete}>删除</Button>;
}

// ✅ 在组件外创建
const handleDelete = withPermissionCheck({ ... })(deleteUser);

function Component() {
  return <Button onClick={handleDelete}>删除</Button>;
}

// ✅ 或使用 useMemo
function Component() {
  const handleDelete = useMemo(() => withPermissionCheck({ ... })(deleteUser), []);

  return <Button onClick={handleDelete}>删除</Button>;
}
  1. 异步校验加缓存:如上文提到的缓存方案

  2. 批量校验:对于需要多次校验的场景,可以批量获取权限

const permissions = await api.batchCheckPermissions([
  'user:delete',
  'user:edit',
  'user:export'
]);

const handleDelete = withPermissionCheck({
  validate: () => permissions['user:delete']
})(deleteUser);

总结

本文介绍的 withPermissionCheck 工具函数,经过多轮实战和评审,在以下方面取得了平衡:

维度设计决策权衡
类型安全抛出 PermissionDeniedError保持类型契约完整
参数透传使用 Parameters灵活性 > 简洁性
UI 解耦showMessage 配置项通用性 > 默认行为
错误处理分离权限错误和运行时错误清晰度 > 统一性
异步化统一返回 Promise一致性 > 适配性

核心设计原则:优先保证类型安全和行为可预期,其次考虑灵活性和易用性

如果你有更好的想法或建议,欢迎交流讨论。