返回

Vue Composables 最佳实践:避免陷阱,提升代码质量

vue.js

Vue Composables 最佳实践

Composable 是 Vue 3 中强大的特性,它可以将可复用的逻辑提取到组件之外。正确使用 composable 能提升代码的可维护性和可测试性。本文讨论在开发 Vue 应用时使用 composable 时应该避免的常见问题,以及对应的解决办法。

Composable 的依赖关系

Composable 相互调用(如 useAuth 调用 useUser) 容易形成复杂且难以维护的依赖关系。 当一个 composable 修改或错误时,影响范围会很大。这种模式难以追踪,给调试带来困难。

解决方案:依赖注入和控制反转

将依赖传递到 composable 中,避免直接的模块导入。可以使用 Vue 的 provide/inject 或者简单的函数参数实现依赖注入。这样可以方便进行单元测试,也可以提高模块的复用性。

示例代码:

// useAuth.ts
import { ref } from 'vue';
import type User from '@/modules/model/User';

interface UserService {
  getUserByEmail(email: string): Promise<User | null>;
  addUser(user: User, userId: string): Promise<User>;
}

interface AuthService {
   signInWithEmailAndPassword(email: string, password: string) : Promise<any>
   createUserWithEmailAndPassword(email: string, password:string) : Promise<any>
}

export const useAuth = (authService:AuthService,userService: UserService) => {
  const error = ref<string>();

  const onLogin = async (email: string, password: string) => {
    try {
      const cred = await authService.signInWithEmailAndPassword(email, password)
      const user = await userService.getUserByEmail(email);
        // 简化错误判断
        if(!user){
          error.value = '用户不存在'
          return false;
        }
        // 在此处处理你的 Store
        console.log(user)
      return true
    } catch (err: any) {
        error.value = handleAuthError(err)
      return false;
    }
  };

  const onRegister = async (user: User) => {
      try {
         const userCred =  await authService.createUserWithEmailAndPassword(user.email, user.password)
          console.log(await userService.addUser(user, userCred.user.uid));
        return true
    } catch (err: any) {
         error.value = handleAuthError(err)
        return false;
      }
  };
  function handleAuthError(err:any) {
       if (err?.code === 'auth/invalid-credential') {
           return  '账号或密码错误';
        } else  if (err?.name === 'FirebaseError'){
           return '网络异常'
      } else {
           return '未知错误';
      }
  }
  return { error, onLogin, onRegister };
};
// useUser.ts
import type User from '@/modules/model/User';

interface Firestore {
  getDocs(query: any) : Promise<any>;
  setDoc(docRef: any,data:any) : Promise<any>
  query(collection:any,whereCondition: any): any
    doc(collection: any, id: string): any
}

export const useUser = (firestore:Firestore,usersCollection: any) => {

    const getUserByEmail = async (email: string): Promise<User | null> => {
       const  userDoc  = (await firestore.getDocs(firestore.query(usersCollection,firestore.where( 'email', '==', email )))).docs[0]
         if (userDoc) {
      return {
        ...(userDoc.data() as User),
        uid: userDoc.id,
      };
    }
    return null;
  };
    const addUser = async (user: User, userId: string): Promise<User> => {
     const  createdDoc = firestore.doc(usersCollection, userId)
        const userToAdd = { ...user, uid: userId };
      await firestore.setDoc(createdDoc, userToAdd);
    return userToAdd
  };
    return { getUserByEmail, addUser };
};


// 注入的模块, 使用实际的 Firebase 对象和 Collection

import { ref } from 'vue'
import {  getDocs,query, where, collection , doc , setDoc,getFirestore, } from 'firebase/firestore';
import {  signInWithEmailAndPassword,createUserWithEmailAndPassword,getAuth } from "firebase/auth";

const firestore = getFirestore()

const usersCollection = collection(firestore,"users")

export const  userService = useUser({getDocs,setDoc,query,doc,where},usersCollection)

const auth = getAuth()
export const  authService =  {
    signInWithEmailAndPassword : (email: string, password:string) => signInWithEmailAndPassword(auth, email, password) ,
    createUserWithEmailAndPassword:  (email:string, password:string) => createUserWithEmailAndPassword(auth, email, password),
}


步骤:

  1. 定义 UserServiceAuthService接口来声明所需的操作。
  2. useAuthuseUser 接收服务接口的实现作为参数,而不是直接引入。
  3. useUser.ts 模块中引入 firestore 的方法来模拟对应的服务方法。
  4. 在需要的模块进行注入和初始化。
  5. 单元测试时,可以传入模拟实现。

数据传递到 Composables

允许 composables 接收参数增强了其灵活性,可以针对特定场景进行定制。但参数应谨慎使用,过多参数会降低可读性和维护性,这时考虑使用配置对象可能更清晰。

示例代码:

export const useAuth = ( authService : AuthService, userService: UserService, options?:{
    storeKey:string;
})  => {

     const storeKey = options?.storeKey ?? 'user'; //默认参数
  //...逻辑
  return{
   ... ,storeKey
  }

}

const {  ...  ,storeKey  } =  useAuth( authService,userService ,{
      storeKey:'otherKey'
  })


步骤:

  1. 定义可选的 options 对象作为 composable 函数的参数。
  2. 使用解构方式在 composable 中设置参数的默认值。

代码可测试性

直接依赖具体实现使 composables 很难进行单元测试,应使用依赖注入增加测试性。

解决方案: Mock 服务

使用测试框架(如 Jest)和mock工具来模拟 Firebase 服务,隔离单元测试。通过控制mock的行为测试composables的逻辑。
示例:

// useAuth.spec.ts

import { useAuth } from './useAuth';
import { AuthServiceInterface ,  UserServiceInterface } from '@/interfaces'


const mockAuthService:  AuthServiceInterface  =  {
      signInWithEmailAndPassword : jest.fn(() =>  Promise.resolve({})),
    createUserWithEmailAndPassword: jest.fn(()=> Promise.resolve({ user: { uid: 'testUID' }}))
}
const mockUserService : UserServiceInterface  =  {
      getUserByEmail :  jest.fn(()=> Promise.resolve( {uid:'testId',email:"test@test.com"} ) )
       ,
        addUser :  jest.fn(() => Promise.resolve({uid:'testId',email:"test@test.com"})),
}

describe('useAuth', () => {
    it('Should register a new user and login it',async () => {
      const { onRegister , error}  = useAuth(mockAuthService ,mockUserService)
       const user =  {  email : "test@test.com" ,password :  "test",name:'test'}
        await onRegister(user);

          expect(mockAuthService.createUserWithEmailAndPassword).toHaveBeenCalledWith("test@test.com" , "test")
          expect(mockUserService.addUser).toHaveBeenCalled();
          expect(error.value).toBeUndefined()
    })

    it("Should not create if an firebase Error is occurred", async () =>{
       const mockErrorAuth = {...mockAuthService,  createUserWithEmailAndPassword : jest.fn(()=> Promise.reject({ name : "FirebaseError"}))
       }
        const {onRegister,error }  =  useAuth( mockErrorAuth ,mockUserService);

       const user =  {  email : "test@test.com" ,password :  "test",name:'test'};
         await onRegister(user)

         expect(error.value).toBe("网络异常")
     })


    it('should login a valid user', async () =>{
         const {onLogin, error}  =  useAuth( mockAuthService ,mockUserService)
        await  onLogin('test@test.com','test');

       expect(mockUserService.getUserByEmail).toHaveBeenCalledWith('test@test.com')
       expect(error.value).toBeUndefined();
    })

       it('should login and not user find', async () =>{
       const mockNoUser  = {... mockUserService ,  getUserByEmail: jest.fn(()=>Promise.resolve(null))}
         const {onLogin, error}  = useAuth( mockAuthService ,mockNoUser)
        await onLogin('test@test.com','test')

         expect(error.value).toBe("用户不存在");
    })
  });


步骤:

  1. 创建模拟的服务实现来代替实际的 firebase 实现。
  2. 使用 jest.fn() 函数 mock 关键的 signInWithEmailAndPasswordcreateUserWithEmailAndPassword 函数。
  3. 测试 onRegisteronLogin 方法 ,确保在不同的输入情况下 composables 可以正常工作。

安全建议

在处理用户认证和数据访问时需要格外注意安全性。 确保输入数据验证,并且采取措施保护用户凭证和敏感数据。使用合适错误处理来防止信息泄露。

正确使用 composables 能使你的 Vue 3 代码更加模块化、可测试且易于维护。遵循这些最佳实践,将显著提升开发体验和最终产品的质量。