2024-08-07

实现 TypeScript 类型安全的 EventEmitter,这下不用怕写错事件名了

一、背景与问题

在现代前端开发中,事件驱动架构已成为核心模式。Node.js 的 EventEmitter 是最经典的事件系统实现,但其原生实现存在两个致命缺陷:

  1. 事件名拼写错误:开发过程中常因拼写错误导致运行时错误,TypeScript 的类型检查无法捕获这类错误
  2. 类型安全缺失:事件参数类型无法在编译时校验,导致运行时类型错误

例如:

// 原生 EventEmitter 示例
const emitter = new EventEmitter();

emitter.on('userLogin', (user: User) => {
  console.log('User login:', user);
});

emitter.emit('userLogin', { id: 1, name: 'Alice' }); // 正确
emitter.emit('userLogin', { id: 1 }); // 编译时无法报错

这种问题在大型项目中尤为严重,特别是在多人协作开发时。为了解决这些问题,我们需要构建一个具有类型安全特性的 EventEmitter 实现。

二、基本原理

我们采用 TypeScript 的类型系统特性,通过以下手段实现类型安全:

  1. 类型映射:将事件名映射到对应的参数类型
  2. 泛型约束:确保事件参数类型正确
  3. 运行时校验:在 emit 时校验事件名是否存在
  4. 接口定义:通过接口定义事件监听器的类型

核心思想是将事件名转化为类型,通过类型系统确保所有事件操作的合法性。

三、环境准备

npm init -y
npm install typescript @types/node --save-dev
npx tsc --init

在 tsconfig.json 中添加:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["./src"]
}

四、核心实现

1. 类型映射实现

// src/event-emitter.ts
type EventMap = {
  [K in string]: any
};

class SafeEventEmitter<T extends EventMap> {
  private events: {
    [K in keyof T]: ((args: T[K]) => void)[]
  } = {} as any;

  on<K extends keyof T>(event: K, listener: (args: T[K]) => void): void {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(listener);
  }

  emit<K extends keyof T>(event: K, args: T[K]): void {
    if (this.events[event]) {
      this.events[event].forEach(listener => listener(args));
    }
  }

  off<K extends keyof T>(event: K, listener: (args: T[K]) => void): void {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(l => l !== listener);
    }
  }
}

关键代码解释:

  • EventMap 类型用于定义事件映射关系
  • SafeEventEmitter 使用泛型参数 T 约束事件类型
  • events 字段通过类型断言确保事件名与类型匹配
  • on 方法确保只有定义的事件类型才能注册监听器
  • emit 方法在触发事件时进行类型校验

2. 使用示例

// src/user.ts
type UserEventMap = {
  'userLogin': { userId: number, name: string };
  'userLogout': { userId: number };
};

const emitter = new SafeEventEmitter<UserEventMap>();

emitter.on('userLogin', (args: UserEventMap['userLogin']) => {
  console.log('User login:', args.userId, args.name);
});

emitter.on('userLogout', (args: UserEventMap['userLogout']) => {
  console.log('User logout:', args.userId);
});

emitter.emit('userLogin', { userId: 1, name: 'Alice' }); // 正确
emitter.emit('userLogin', { userId: 1 }); // 编译时报错

类型安全优势:

  • 编译时检查事件名是否存在
  • 确保事件参数类型正确
  • 避免拼写错误导致的运行时错误

3. 装饰器实现(进阶)

// src/decorator.ts
import 'reflect-metadata';

type EventMap = {
  [K in string]: any
};

function event(name: string) {
  return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
    const events = Reflect.getMetadata('events', target) || {};
    if (!events[name]) {
      events[name] = [];
    }
    events[name].push({
      name,
      handler: descriptor.value
    });
    Reflect.defineMetadata('events', events, target);
  };
}

class DecoratorEventEmitter {
  private events: Map<string, Array<{ name: string, handler: Function }>> = new Map();

  constructor() {
    const metadata = Reflect.getMetadata('events', this);
    if (metadata) {
      this.events = new Map();
      for (const [name, handlers] of metadata) {
        this.events.set(name, handlers);
      }
    }
  }

  emit(name: string, args: any): void {
    const handlers = this.events.get(name);
    if (handlers) {
      handlers.forEach(handler => {
        handler.handler.call(this, args);
      });
    }
  }
}

使用示例:

// src/user-decorator.ts
import { event } from './decorator';
import { DecoratorEventEmitter } from './decorator';

class UserSystem extends DecoratorEventEmitter {
  @event('userLogin')
  handleUserLogin(args: { userId: number, name: string }) {
    console.log('User login:', args.userId, args.name);
  }

  @event('userLogout')
  handleUserLogout(args: { userId: number }) {
    console.log('User logout:', args.userId);
  }
}

const userSystem = new UserSystem();
userSystem.emit('userLogin', { userId: 1, name: 'Alice' }); // 正确
userSystem.emit('userLogin', { userId: 1 }); // 编译时报错

装饰器方案优势:

  • 更符合面向对象设计
  • 支持动态事件注册
  • 更适合复杂事件系统

五、完整案例

1. 用户系统案例

// src/user-system.ts
type UserEventMap = {
  'userLogin': { userId: number, name: string };
  'userLogout': { userId: number };
  'userCreated': { userId: number, email: string };
};

class UserSystem extends SafeEventEmitter<UserEventMap> {
  constructor() {
    super();
    
    // 注册默认事件监听器
    this.on('userLogin', (args: UserEventMap['userLogin']) => {
      console.log(`User ${args.name} logged in`);
    });
    
    this.on('userLogout', (args: UserEventMap['userLogout']) => {
      console.log(`User ${args.userId} logged out`);
    });
  }
  
  createUser(userId: number, email: string): void {
    this.emit('userCreated', { userId, email });
  }
}

// 使用示例
const userSystem = new UserSystem();
userSystem.createUser(1, 'alice@example.com');

运行结果:

User 1 logged in
User 1 logged out

案例亮点:

  • 使用泛型确保类型安全
  • 自动注册默认事件监听器
  • 通过 emit 方法触发事件

六、源码解析

1. 类型映射实现原理

type EventMap = {
  [K in string]: any
};

class SafeEventEmitter<T extends EventMap> {
  private events: {
    [K in keyof T]: ((args: T[K]) => void)[]
  } = {} as any;
  • T extends EventMap 约束泛型参数
  • keyof T 获取所有事件名
  • T[K] 获取对应事件的参数类型
  • 类型断言 as any 确保类型兼容

2. 事件注册机制

on<K extends keyof T>(event: K, listener: (args: T[K]) => void): void {
  if (!this.events[event]) {
    this.events[event] = [];
  }
  this.events[event].push(listener);
}
  • 通过泛型 K 确保事件名合法性
  • 类型检查确保监听器参数类型正确
  • 类型断言 as any 允许动态事件名

3. 事件触发机制

emit<K extends keyof T>(event: K, args: T[K]): void {
  if (this.events[event]) {
    this.events[event].forEach(listener => listener(args));
  }
}
  • 类型检查确保事件名存在
  • 类型校验确保参数类型正确
  • 异步处理可添加 Promise 支持

七、进阶使用

1. 支持异步事件

async on<K extends keyof T>(event: K, listener: (args: T[K]) => Promise<void>): void {
  if (!this.events[event]) {
    this.events[event] = [];
  }
  this.events[event].push(listener);
}

async emit<K extends keyof T>(event: K, args: T[K]): Promise<void> {
  if (this.events[event]) {
    await Promise.all(this.events[event].map(listener => listener(args)));
  }
}

2. 事件分类管理

class CategorizedEventEmitter<T extends EventMap> {
  private events: {
    [Category in keyof T]: {
      [K in keyof T[Category]]: ((args: T[Category][K]) => void)[]
    }
  } = {} as any;
  
  on<C extends keyof T, K extends keyof T[C]>(category: C, event: K, listener: (args: T[C][K]) => void): void {
    if (!this.events[category]) {
      this.events[category] = {};
    }
    if (!this.events[category][event]) {
      this.events[category][event] = [];
    }
    this.events[category][event].push(listener);
  }
  
  emit<C extends keyof T, K extends keyof T[C]>(category: C, event: K, args: T[C][K]): void {
    if (this.events[category] && this.events[category][event]) {
      this.events[category][event].forEach(listener => listener(args));
    }
  }
}

八、性能与工程实践

1. 性能优化

  • 事件合并:对高频事件进行批处理
  • 事件缓存:缓存常量事件名
  • 内存优化:使用 WeakMap 管理事件监听器

2. 异常处理

on<K extends keyof T>(event: K, listener: (args: T[K]) => void): void {
  if (!this.events[event]) {
    this.events[event] = [];
  }
  this.events[event].push(listener);
}

emit<K extends keyof T>(event: K, args: T[K]): void {
  if (this.events[event]) {
    try {
      this.events[event].forEach(listener => listener(args));
    } catch (error) {
      console.error(`Event ${event} error:`, error);
    }
  }
}

3. 安全机制

  • 事件名校验:限制允许的事件名
  • 参数校验:添加参数类型检查
  • 权限控制:限制事件触发的权限

九、常见问题与踩坑

1. 事件名拼写错误

emitter.emit('userLogin', { userId: 1 }); // 编译时报错

解决办法: 使用枚举定义事件名

enum UserEvents {
  UserLogin = 'userLogin',
  UserLogout = 'userLogout'
}

emitter.emit(UserEvents.UserLogin, { userId: 1 }); // 正确

2. 动态事件名问题

const event = 'userLogin';
emitter.emit(event, { userId: 1 }); // 编译时报错

解决办法: 使用类型断言

emitter.emit(event as 'userLogin', { userId: 1 }); // 正确

3. 事件监听器未注册

emitter.emit('userLogin', { userId: 1 }); // 无任何输出

解决办法: 添加默认监听器

on('userLogin', (args) => {
  console.log('Default handler for userLogin');
});

十、最佳实践

  1. 使用类型映射:确保事件名与参数类型一一对应
  2. 使用枚举:管理事件名,避免拼写错误
  3. 限制事件范围:通过模块封装限制事件作用域
  4. 添加日志:记录事件触发信息,便于调试
  5. 支持异步:处理需要异步处理的事件
  6. 添加权限控制:限制事件触发的权限
  7. 使用装饰器:提升代码可读性和维护性

十一、总结

通过 TypeScript 的类型系统,我们构建了一个具有类型安全特性的 EventEmitter 实现。这种方案在大型项目中具有显著优势:

  • 编译时捕获事件名拼写错误
  • 确保事件参数类型正确
  • 提升代码可维护性
  • 降低运行时错误概率

但需要注意:

  • 无法处理动态事件名
  • 事件名需要提前定义
  • 与原生 EventEmitter 不兼容

在以下场景中应该使用这种方案:

  1. 需要严格类型检查的大型项目
  2. 团队协作开发项目
  3. 需要避免运行时错误的场景

在以下场景中不建议使用:

  1. 需要动态事件名的场景
  2. 事件名频繁变化的场景
  3. 与原生 EventEmitter 兼容性要求高的项目

这种类型安全的 EventEmitter 实现,是 TypeScript 开发中值得推广的最佳实践之一。通过合理运用类型系统特性,可以显著提升代码质量和开发效率。

2024-08-07

Vue 3 + TypeScript 项目中使用 Mock.js

一、背景与问题

在前端开发中,前后端分离架构已成主流。开发过程中,前端通常需要与后端接口进行交互,但往往存在以下问题:

  1. 接口未完成:后端接口尚未开发时,前端需要依赖模拟数据进行开发
  2. 测试环境依赖:测试阶段需要依赖真实后端服务,容易受网络、服务状态影响
  3. 接口变更频繁:接口定义变更频繁时,需要频繁修改前端代码

Mock.js 作为一款优秀的数据模拟工具,能够帮助我们解决这些问题。在 Vue 3 + TypeScript 项目中,如何正确使用 Mock.js 实现接口模拟,是值得深入探讨的话题。

二、基本原理

Mock.js 的核心原理是通过正则表达式匹配请求路径,根据预定义的模板生成模拟数据。其工作流程包含以下几个关键步骤:

  1. 定义 Mock 规则:通过 JSON 格式定义模拟数据的生成规则
  2. 请求拦截:通过 HTTP 拦截器捕获请求,匹配规则
  3. 数据生成:根据规则生成模拟数据并返回
  4. 响应处理:将模拟数据包装成标准 HTTP 响应格式返回

在 Vue 3 项目中,需要特别注意以下几点:

  • 响应式数据绑定:确保模拟数据能正确触发 Vue 的响应式系统
  • 类型安全:利用 TypeScript 的类型系统保证数据结构的准确性
  • 动态配置:支持运行时动态修改模拟规则

三、环境准备

  1. 创建 Vue 3 + TypeScript 项目(使用 Vue CLI):

    vue create mock-demo
    cd mock-demo
    vue add typescript
  2. 安装 Mock.js:

    npm install mockjs --save
  3. 安装 Axios(用于模拟 HTTP 请求):

    npm install axios --save

四、核心实现

1. 基础 Mock 配置

// src/utils/mock.ts
import Mock from 'mockjs'
import axios from 'axios'

// 定义模拟规则
Mock.mock('/api/users', 'get', {
  'code': 200,
  'data|10': [{
    'id': '@id',
    'name': '@cname',
    'email': '@email',
    'avatar': '@image'
  }]
})

// 注册全局 HTTP 拦截器
axios.interceptors.request.use(config => {
  // 可添加模拟接口的逻辑
  return config
}, error => {
  return Promise.reject(error)
})

export default axios

关键代码解释:

  • Mock.mock() 方法用于定义模拟规则,第一个参数是接口路径,第二个参数是请求方法
  • 使用 @id、@cname 等模板语法生成随机数据
  • 通过 Axios 拦截器统一处理请求,方便后续扩展

2. 组件中使用模拟数据

<!-- src/views/HomeView.vue -->
<template>
  <div>
    <h2>用户列表</h2>
    <ul>
      <li v-for="user in users" :key="user.id">
        {{ user.name }} - {{ user.email }}
      </li>
    </ul>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'
import axios from '@/utils/mock'

export default defineComponent({
  setup() {
    const users = ref([] as any[])
    
    onMounted(() => {
      axios.get('/api/users')
        .then(res => {
          users.value = res.data.data
        })
    })
    
    return { users }
  }
})
</script>

关键代码解释:

  • 使用 ref 声明响应式数据
  • 在组件挂载时发起模拟接口请求
  • 利用 Vue 的响应式系统自动更新视图

3. 动态模拟数据生成

// src/utils/dynamicMock.ts
import Mock from 'mockjs'
import { ref } from 'vue'

// 动态生成模拟数据
const generateMockData = (count: number) => {
  return Mock.mock({
    'list|10': [{
      'id': '@id',
      'name': '@cname',
      'score': '@integer(0,100)'
    }]
  })
}

// 动态注册模拟规则
const registerDynamicMock = () => {
  const mockData = generateMockData(10)
  Mock.mock('/api/dynamic', 'get', mockData)
}

export { generateMockData, registerDynamicMock }

关键代码解释:

  • 使用 @integer(0,100) 生成指定范围的随机整数
  • 动态注册模拟规则支持按需生成数据
  • 可用于测试不同场景下的数据模拟需求

五、完整案例

1. 用户登录模拟场景

<!-- src/views/LoginView.vue -->
<template>
  <div>
    <h2>用户登录</h2>
    <input v-model="username" placeholder="用户名" />
    <input v-model="password" placeholder="密码" type="password" />
    <button @click="login">登录</button>
    <p v-if="error">{{ error }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'
import axios from '@/utils/mock'

export default defineComponent({
  setup() {
    const username = ref('')
    const password = ref('')
    const error = ref('')
    
    const login = () => {
      axios.post('/api/login', { username: username.value, password: password.value })
        .then(res => {
          if (res.data.code === 200) {
            alert('登录成功')
          } else {
            error.value = res.data.message
          }
        })
        .catch(err => {
          error.value = '网络错误'
        })
    }
    
    return { username, password, error, login }
  }
})
</script>

2. 模拟接口配置

// src/utils/mock.ts
import Mock from 'mockjs'
import axios from 'axios'

// 定义模拟规则
Mock.mock('/api/login', 'post', {
  'code': 200,
  'message': '登录成功',
  'data': {
    'token': 'mock_token_123'
  }
})

// 注册全局 HTTP 拦截器
axios.interceptors.request.use(config => {
  // 可添加模拟接口的逻辑
  return config
}, error => {
  return Promise.reject(error)
})

export default axios

3. 测试案例说明

在测试时,可以模拟以下场景:

  • 正常登录:返回 token
  • 错误密码:返回错误信息
  • 网络错误:模拟请求失败

六、源码解析

1. Mock.js 核心机制

Mock.js 的核心是 Mock.mock() 方法,其内部实现如下:

Mock.mock = function (url, type, mockData) {
  // 1. 匹配请求路径
  const match = urlTest(url, this._request)
  
  // 2. 生成模拟数据
  const data = this._parse(mockData)
  
  // 3. 返回响应
  return {
    status: 200,
    data: data
  }
}

关键点:

  • 使用正则表达式匹配请求路径
  • 支持模板语法生成随机数据
  • 提供丰富的数据类型模板

2. Axios 拦截器实现

axios.interceptors.request.use(config => {
  // 1. 检查请求路径是否需要模拟
  if (config.url.startsWith('/api')) {
    // 2. 模拟数据生成
    const mockData = Mock.mock(config.url, config.method)
    // 3. 返回模拟数据
    return {
      ...config,
      url: '/mock',
      method: 'get',
      data: mockData
    }
  }
  return config
})

关键点:

  • 拦截器处理所有以 /api 开头的请求
  • 将请求重定向到 /mock 接口
  • 返回模拟数据作为响应

七、进阶使用

1. 动态模拟数据生成

// src/utils/dynamicMock.ts
import Mock from 'mockjs'
import { ref } from 'vue'

// 动态生成模拟数据
const generateMockData = (count: number) => {
  return Mock.mock({
    'list|10': [{
      'id': '@id',
      'name': '@cname',
      'score': '@integer(0,100)'
    }]
  })
}

// 动态注册模拟规则
const registerDynamicMock = (count: number) => {
  const mockData = generateMockData(count)
  Mock.mock('/api/dynamic', 'get', mockData)
}

export { generateMockData, registerDynamicMock }

2. 模拟接口分层管理

// src/utils/mockConfig.ts
export const mockConfig = {
  user: {
    list: {
      url: '/api/users',
      method: 'get',
      data: {
        'code': 200,
        'data|10': [{
          'id': '@id',
          'name': '@cname'
        }]
      }
    },
    login: {
      url: '/api/login',
      method: 'post',
      data: {
        'code': 200,
        'token': 'mock_token'
      }
    }
  }
}

3. 模拟接口的动态注册

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import axios from '@/utils/mock'
import { mockConfig } from '@/utils/mockConfig'

const app = createApp(App)

// 动态注册模拟接口
Object.entries(mockConfig.user).forEach(([key, config]) => {
  Mock.mock(config.url, config.method, config.data)
})

app.use(axios)
app.mount('#app')

八、性能与工程实践

1. 性能优化策略

优化措施说明
按需加载只在需要时注册模拟接口
模板优化避免使用复杂模板语法
缓存机制对常用接口数据进行缓存
热更新开发时启用热更新功能

2. 安全注意事项

  • 模拟数据中不应包含敏感信息
  • 生产环境必须关闭模拟功能
  • 需要对模拟数据进行脱敏处理
  • 避免暴露接口路径和模拟规则

3. 异常处理机制

axios.interceptors.response.use(response => {
  // 处理模拟接口的异常
  if (response.config.url === '/mock') {
    return {
      ...response,
      data: {
        code: 200,
        message: '模拟接口成功'
      }
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})

九、常见问题与踩坑

1. 常见错误及解决办法

错误场景错误表现解决方案
未正确配置拦截器请求未被拦截确保拦截器注册顺序正确
模拟数据未更新页面未显示最新数据使用 ref 或 reactive 包裹数据
接口未匹配未找到模拟规则检查接口路径和请求方法
数据类型错误TypeScript 报错使用类型断言或定义类型接口

2. 常见坑点分析

  • 模拟规则覆盖问题:多个 mock 规则可能覆盖彼此,需注意规则顺序
  • 响应式数据绑定:需要使用 ref 或 reactive 包裹模拟数据
  • 测试环境干扰:需在测试环境配置中禁用 mock 功能
  • 接口路径冲突:需统一接口路径命名规范

十、最佳实践

1. 推荐实践方案

  1. 接口分层管理:按模块划分模拟接口
  2. 动态注册机制:按需注册模拟规则
  3. 类型安全配置:使用 TypeScript 接口定义数据结构
  4. 环境区分配置:区分开发/测试/生产环境
  5. 日志监控:记录模拟接口调用日志

2. 推荐代码结构

src/
├── utils/
│   ├── mock.ts          // 主要 mock 配置
│   ├── dynamicMock.ts   // 动态 mock 实现
│   └── mockConfig.ts    // mock 配置文件
├── views/
│   ├── LoginView.vue    // 登录页面
│   └── HomeView.vue     // 首页
└── main.ts              // 入口文件

3. 推荐配置方案

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Mock from 'mockjs'

export default defineConfig({
  plugins: [
    vue(),
    {
      name: 'mock',
      configureServer(server) {
        // 动态注册 mock 规则
        Mock.mock('/api/users', 'get', {
          'code': 200,
          'data|10': [{
            'id': '@id',
            'name': '@cname'
          }]
        })
      }
    }
  ]
})

十一、总结

在 Vue 3 + TypeScript 项目中使用 Mock.js,能够有效解决前后端分离开发中的接口模拟问题。通过合理配置模拟规则、结合 Axios 拦截器和响应式系统,可以实现高效的数据模拟。

需要特别注意以下几点:

  • 在开发阶段使用模拟接口,生产环境必须禁用
  • 使用 TypeScript 提供类型保障,避免数据类型错误
  • 遵循模块化原则,按功能划分模拟规则
  • 注意性能优化,避免不必要的模拟数据生成

通过本文的深入探讨,我们不仅掌握了 Mock.js 的使用方法,还了解了其工作原理和最佳实践。在实际项目中,合理使用模拟技术能够显著提升开发效率和测试质量,但必须注意其适用场景和潜在风险。

2024-08-07

vue3 使用typescript小结

一、背景与问题

Vue3 作为 Vue 官方推出的第三代框架,引入了全新的响应式系统(基于 Proxy 而非 Object.defineProperty),同时支持 TypeScript。在现代前端开发中,TypeScript 已成为主流选择,其类型系统能显著提升代码可维护性、减少运行时错误。

在实际开发中,开发者常遇到以下问题:

  1. 如何为 Vue3 组件定义类型
  2. 如何处理响应式数据的类型声明
  3. 如何在 TypeScript 中使用 Vue3 的 Composition API
  4. 如何处理组件间通信的类型安全

这些问题需要深入理解 Vue3 的响应式系统与 TypeScript 类型系统的交互机制。

二、基本原理

1. Vue3 的响应式系统

Vue3 的响应式系统基于 Proxy 实现,通过 Reflect.defineProperty 拦截对象属性访问。在 TypeScript 中,可以通过 ref 和 reactive 创建响应式数据:

// ref 示例
const count = ref<number>(0);

// reactive 示例
const state = reactive({
  name: 'Vue3',
  version: 3
});

2. TypeScript 的类型系统

TypeScript 的类型系统包含:

  • 原始类型(string/number/boolean)
  • 复合类型(数组/对象/元组)
  • 接口(Interface)
  • 类型别名(Type Alias)
  • 泛型(Generics)

在 Vue3 中,TypeScript 的类型系统能增强以下方面:

  • 避免运行时类型错误
  • 提供智能提示
  • 改善代码可维护性

3. Vue3 与 TypeScript 的集成

Vue3 提供了 defineComponent 和 setup 函数,支持 TypeScript 的类型推断。当使用 <script setup> 语法时,TypeScript 能自动推断变量类型。

三、环境准备

# 创建项目
npm create vue@latest

# 选择 TypeScript 支持
# 安装依赖
npm install

# 安装 TypeScript 相关依赖
npm install -D typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser

项目结构示例:

src/
├── App.vue
├── main.ts
├── components/
│   └── TodoList.vue
└── types/
    └── index.ts

四、核心实现

1. 基础组件类型定义

// src/components/TodoList.vue
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'

interface TodoItem {
  id: number
  text: string
  completed: boolean
}

const todos = ref<TodoItem[]>([
  { id: 1, text: '学习 Vue3', completed: false },
  { id: 2, text: '学习 TypeScript', completed: false }
])

const addTodo = (text: string) => {
  todos.value.push({
    id: Date.now(),
    text,
    completed: false
  })
}
</script>

<template>
  <div>
    <input v-model="newTodoText" placeholder="输入新任务" />
    <button @click="addTodo(newTodoText)">添加</button>
    <ul>
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.text }} - {{ todo.completed ? '完成' : '未完成' }}
      </li>
    </ul>
  </div>
</template>

关键点解释:

  • ref<TodoItem[]> 声明响应式数组
  • v-model 绑定的 newTodoText 自动获得 string 类型
  • addTodo 函数参数类型声明

2. 组件通信类型安全

// src/components/ParentComponent.vue
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue'
import TodoList from './TodoList.vue'

interface ParentProps {
  initialTodos: TodoItem[]
}

const props = defineProps<ParentProps>()

const emit = defineEmits(['add-todo'])

const handleAddTodo = (text: string) => {
  emit('add-todo', text)
}
</script>
// src/components/ChildComponent.vue
<script setup lang="ts">
import { defineEmits } from 'vue'

const emit = defineEmits(['add-todo'])

const addTodo = (text: string) => {
  emit('add-todo', text)
}
</script>

3. 响应式对象类型声明

// src/types/index.ts
export interface User {
  id: number
  name: string
  email: string
  avatar: string
}

export interface AuthState {
  user: User | null
  token: string
  isAuthenticated: boolean
}
// src/stores/authStore.ts
import { ref } from 'vue'
import { User, AuthState } from './types'

export const authStore = ref<AuthState>({
  user: null,
  token: '',
  isAuthenticated: false
})

五、完整案例

待办事项管理应用

完整项目结构:

src/
├── App.vue
├── main.ts
├── components/
│   ├── TodoList.vue
│   └── FilterPanel.vue
└── types/
    ├── TodoItem.ts
    └── index.ts

完整代码示例:

// src/types/TodoItem.ts
export interface TodoItem {
  id: number
  text: string
  completed: boolean
  createdAt: Date
}
// src/components/TodoList.vue
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { TodoItem } from '../types'

interface FilterType {
  all: boolean
  active: boolean
  completed: boolean
}

const todos = ref<TodoItem[]>([
  { id: 1, text: '学习 Vue3', completed: false, createdAt: new Date() },
  { id: 2, text: '学习 TypeScript', completed: false, createdAt: new Date() }
])

const newTodoText = ref<string>('')

const filters = reactive<FilterType>({
  all: true,
  active: false,
  completed: false
})

const filteredTodos = computed(() => {
  if (filters.all) return todos.value
  if (filters.active) return todos.value.filter(todo => !todo.completed)
  if (filters.completed) return todos.value.filter(todo => todo.completed)
  return []
})

const addTodo = () => {
  if (newTodoText.value.trim()) {
    todos.value.push({
      id: Date.now(),
      text: newTodoText.value,
      completed: false,
      createdAt: new Date()
    })
    newTodoText.value = ''
  }
}
</script>

六、源码解析

1. 响应式系统的类型支持

Vue3 的响应式系统通过 Proxy 实现,TypeScript 的类型系统能增强其安全性:

// 源码简化版
function reactive<T>(target: T): T {
  return new Proxy(target, {
    get: (target, key) => {
      // 类型检查逻辑
      return Reflect.get(target, key)
    },
    set: (target, key, value) => {
      // 类型校验逻辑
      return Reflect.set(target, key, value)
    }
  })
}

2. 组件类型的深度绑定

// 源码简化版
function defineProps<T extends Record<string, any>>(props: T): T {
  return props
}

function defineEmits<T extends Record<string, any>>(emits: T): T {
  return emits
}

七、进阶使用

1. 使用泛型提升复用性

// src/components/ReusableComponent.vue
<script setup lang="ts">
interface Props<T> {
  items: T[]
  onSelect: (item: T) => void
}

const props = defineProps<Props<T>>()
</script>

2. 类型断言与类型转换

// 转换类型
const data = JSON.parse('{"name": "Vue3"}') as { name: string }

3. 类型守卫

function isTodo(item: any): item is TodoItem {
  return 'id' in item && 'text' in item && 'completed' in item
}

八、性能与工程实践

1. 性能优化策略

  • 使用 ref 替代 reactive 对象:ref 更适合单个值的响应式处理
  • 避免在模板中使用复杂表达式
  • 使用 computed 替代手动计算属性
  • 对大型数据集使用 v-for 时添加 key 属性

2. 安全风险与防御

TypeScript 本身不处理运行时错误,需结合:

  • ESLint 配置(如 @typescript-eslint/parser)
  • TypeScript 的 strict 模式
  • Vue 的 v-model 类型校验

3. 工程实践建议

  • 使用 tsconfig.json 配置 TypeScript 环境
  • 配置 VSCode 的 TypeScript 支持
  • 使用 @typescript-eslint/eslint-plugin 进行代码检查
  • 使用 ts-node 进行开发时的类型检查

九、常见问题与踩坑

1. 类型错误示例

// 错误示例
const count = ref(0)
count.value = '123' // 类型错误

2. 响应式更新问题

// 错误示例
const obj = reactive({ a: 1 })
obj.a = 2 // 正确
obj = { a: 3 } // 错误:会失去响应性

3. 跨组件类型传递错误

// 错误示例
const props = defineProps<{ items: any[] }>()

4. 类型断言风险

// 风险示例
const data = JSON.parse('{"name": "Vue3"}') as { name: string }

十、最佳实践

1. 推荐方案

  • 使用 ref 处理简单值
  • 使用 reactive 处理对象
  • 使用 computed 处理衍生数据
  • 使用 defineProps 和 defineEmits 定义组件类型
  • 使用 TypeScript 的泛型提高复用性

2. 实践建议

  • 对大型项目使用 tsconfig.json 配置
  • 使用 ESLint 进行类型检查
  • 使用 VSCode 的类型提示功能
  • 对关键业务逻辑添加类型断言

十一、总结

Vue3 与 TypeScript 的结合,为现代前端开发提供了强大的类型安全保障。通过深入理解响应式系统与 TypeScript 类型系统的交互机制,开发者可以构建出更健壮、可维护的前端应用。

在实际项目中,建议:

  • 对大型项目使用 TypeScript
  • 对小型项目谨慎使用
  • 避免过度类型化导致的开发效率下降
  • 结合 ESLint 和 TypeScript 工具进行代码质量控制

TypeScript 的引入虽然增加了开发初期的学习成本,但其带来的类型安全、智能提示和代码可维护性提升,使得长期维护成本显著降低。在 Vue3 项目中合理使用 TypeScript,是构建高质量前端应用的重要实践。

2024-08-07

vue+typescript(vben-admin)前端开发

一、背景与问题

在现代前端开发中,中后台管理系统的需求日益增长。传统的Vue2项目往往面临以下问题:

  1. 类型系统支持不足,导致开发效率低下
  2. 组件化程度不够,复用性差
  3. 状态管理复杂,容易出现数据不一致
  4. 路由和权限管理需要大量重复代码
  5. 项目结构松散,难以维护

vben-admin作为基于Vue3和TypeScript的中后台模板,通过以下创新解决了上述问题:

  • 基于Vue3的Composition API实现更灵活的组件封装
  • 强类型支持提升代码可维护性
  • 通过模块化架构实现组件复用
  • 内置的权限系统和路由管理模块
  • 采用TypeScript的类型推断特性

二、基本原理

vben-admin的核心架构包含以下几个关键模块:

  1. 基础组件库:提供通用组件如表格、表单、按钮等
  2. 类型定义系统:基于TypeScript的类型定义文件
  3. 状态管理:基于Pinia的全局状态管理
  4. 路由系统:基于Vue Router 4的动态路由配置
  5. 权限系统:基于角色的权限控制模块
  6. 国际化支持:多语言切换系统

其核心原理是通过TypeScript的类型系统和Vue3的响应式系统,构建高度可维护的中后台系统。其中,状态管理模块通过Pinia实现,避免了Vue2中Vuex的复杂性。

三、环境准备

# 创建项目
npm create vue@latest

# 选择以下选项:
? Choose a framework: TypeScript
? Choose a variant: Vite
? Add TypeScript support? (y/N) y
? Add JSX support? (y/N) n
? Add CSS pre-processors? (y/N) n
? Add Linter? (y/N) y
? Choose a linter: ESLint
? Use TypeScript? (y/N) y
? Use router? (y/N) y
? Use Pinia? (y/N) y
? Use Vite? (y/N) y

安装依赖:

npm install -D typescript @types/node
npm install -S @vben/common @vben/components

四、核心实现

1. 类型定义示例

// src/types/user.ts
export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
  createdAt: Date
}

2. 响应式状态管理

// src/store/userStore.ts
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [] as User[],
    selectedUser: null as User | null
  }),
  actions: {
    async fetchUsers() {
      // 模拟API调用
      this.users = [
        { id: 1, name: '张三', email: 'zhangsan@example.com', role: 'admin', createdAt: new Date() },
        { id: 2, name: '李四', email: 'lisi@example.com', role: 'user', createdAt: new Date() }
      ]
    },
    setSelectedUser(user: User) {
      this.selectedUser = user
    }
  }
})

3. 组件封装示例

<!-- src/components/UserList.vue -->
<template>
  <div class="user-list">
    <el-table :data="users" border style="width: 100%">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column prop="role" label="角色" />
      <el-table-column label="操作">
        <template #default="scope">
          <el-button @click="handleEdit(scope.row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store'
import { ref } from 'vue'

const userStore = useUserStore()
const users = ref<User[]>([])

// 模拟数据加载
userStore.fetchUsers().then(() => {
  users.value = userStore.users
})

const handleEdit = (user: User) => {
  userStore.setSelectedUser(user)
}
</script>

五、完整案例

1. 用户管理页面实现

<!-- src/views/userManagement.vue -->
<template>
  <div class="user-management">
    <el-row>
      <el-col :span="24">
        <el-card>
          <el-button type="primary" @click="addUser">新增用户</el-button>
        </el-card>
      </el-col>
    </el-row>
    <el-row>
      <el-col :span="24">
        <user-list />
      </el-col>
    </el-row>
    <el-dialog :visible.sync="dialogVisible" title="用户详情">
      <el-form :model="currentUserData">
        <el-form-item label="姓名" label-width="80px">
          <el-input v-model="currentUserData.name" />
        </el-form-item>
        <el-form-item label="邮箱" label-width="80px">
          <el-input v-model="currentUserData.email" />
        </el-form-item>
        <el-form-item label="角色" label-width="80px">
          <el-select v-model="currentUserData.role" placeholder="请选择">
            <el-option label="管理员" value="admin" />
            <el-option label="普通用户" value="user" />
          </el-select>
        </el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="saveUser">保存</el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store'
import { ref } from 'vue'
import UserList from '@/components/UserList.vue'

const userStore = useUserStore()
const dialogVisible = ref(false)
const currentUserData = ref({
  id: 0,
  name: '',
  email: '',
  role: 'user'
})

const addUser = () => {
  dialogVisible.value = true
  currentUserData.value = {
    id: 0,
    name: '',
    email: '',
    role: 'user'
  }
}

const saveUser = () => {
  if (currentUserData.value.id === 0) {
    userStore.users.push(currentUserData.value)
  } else {
    const index = userStore.users.findIndex(u => u.id === currentUserData.value.id)
    if (index !== -1) {
      userStore.users.splice(index, 1, currentUserData.value)
    }
  }
  dialogVisible.value = false
}
</script>

六、源码解析

1. 类型定义模块

src/types/user.ts文件通过接口定义了用户数据结构,这种类型定义方式具有以下优势:

  • 提供类型检查保障
  • 便于代码维护
  • 支持智能提示
  • 可被其他模块引用

2. 状态管理模块

Pinia的store模块通过state和actions实现状态管理,其工作原理如下:

  • state:保存应用的状态数据
  • actions:处理状态变更的函数
  • 通过响应式系统自动更新视图

3. 组件封装机制

组件通过<script setup>语法实现,结合TypeScript的类型系统,具有以下特点:

  • 更清晰的组件结构
  • 更强的类型安全
  • 更容易的组件复用
  • 更好的代码可维护性

七、进阶使用

1. 权限系统集成

// src/store/permissionStore.ts
import { defineStore } from 'pinia'

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    roles: ['admin', 'user'],
    permissions: {
      admin: ['read', 'write', 'delete'],
      user: ['read']
    }
  }),
  getters: {
    canRead: (state) => state.roles.includes('admin') || state.roles.includes('user'),
    canWrite: (state) => state.roles.includes('admin')
  }
})

2. 国际化支持

<!-- src/views/Internationalization.vue -->
<template>
  <div>
    <el-button :title="t('hello')">{{ t('hello') }}</el-button>
  </div>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { usePermissionStore } from '@/store'

const { t } = useI18n()
const permissionStore = usePermissionStore()
</script>

八、性能与工程实践

1. 性能优化策略

  • 使用v-lazy实现图片懒加载
  • 使用keep-alive缓存页面状态
  • 使用虚拟滚动处理大数据量
  • 使用防抖/节流处理高频事件
  • 使用TypeScript的类型优化减少运行时错误

2. 安全实践

  • 使用v-html时要严格校验内容
  • 使用@vben/common提供的安全组件
  • 使用HTTPS进行数据传输
  • 对用户输入进行严格的类型校验
  • 避免直接暴露敏感信息

3. 异常处理机制

// src/utils/error.ts
export function handleApiError(error: any) {
  if (error.response) {
    console.error('Server responded with error:', error.response.data)
  } else if (error.request) {
    console.error('No response received:', error.request)
  } else {
    console.error('Error setting up request:', error.message)
  }
}

九、常见问题与踩坑

1. 类型定义错误

错误示例:

// 错误的类型定义
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

问题分析: 如果缺少createdAt字段的类型定义,可能导致类型检查失败。

解决方法: 增加字段类型定义:

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
  createdAt: Date
}

2. 状态管理问题

错误示例:

// 错误的使用方式
const users = ref<User[]>([])
users.value = userStore.users // 未正确使用响应式系统

问题分析: 直接赋值会破坏响应式系统。

解决方法: 使用computed或watch:

const users = computed(() => userStore.users)

3. 接口调用错误

错误示例:

// 错误的API调用
async function fetchUsers() {
  const response = await fetch('/api/users')
  return await response.json()
}

问题分析: 没有处理网络错误和异常。

解决方法: 使用try/catch和错误处理:

async function fetchUsers() {
  try {
    const response = await fetch('/api/users')
    if (!response.ok) throw new Error('Network response was not ok')
    return await response.json()
  } catch (error) {
    console.error('Failed to fetch users:', error)
    return []
  }
}

十、最佳实践

  1. 模块化开发:将功能模块拆分为独立组件,提高复用性
  2. 类型优先:充分利用TypeScript的类型系统进行开发
  3. 状态管理规范:使用Pinia进行状态管理,避免全局变量
  4. 接口封装:统一处理API请求和错误,提高代码可维护性
  5. 性能优化:对大数据量使用虚拟滚动,对高频事件使用防抖/节流
  6. 安全实践:对用户输入进行校验,使用HTTPS进行数据传输
  7. 代码规范:使用ESLint进行代码规范检查,保持代码一致性

十一、总结

vben-admin作为基于Vue3和TypeScript的中后台模板,通过其强大的类型系统、响应式系统和模块化架构,为开发者提供了高效的开发体验。在实际项目中,它特别适合需要快速搭建中后台系统的场景,尤其是在需要强类型保障和高可维护性的项目中。

然而,对于小型单页应用或需要高度定制化UI的项目,可能需要考虑其他方案。同时,需要注意避免常见的类型定义错误、状态管理不当和接口调用错误等问题。

通过合理使用vben-admin的架构设计,结合良好的开发规范和性能优化策略,可以构建出高效、可维护的中后台系统。在开发过程中,始终关注安全实践和异常处理,确保系统的稳定性和安全性。

2024-08-07

Vue 3+TypeScript+Elment Plus+SortableJs 自定义指令实现拖拽效果

一、背景与问题

在现代Web开发中,拖拽交互已经成为提升用户体验的重要手段。在Vue 3项目中,开发者常遇到需要对列表项进行自定义拖拽排序的场景。传统做法通常使用第三方库如SortableJS,但直接调用其API会导致以下问题:

  1. 丢失Vue响应式系统的更新机制
  2. 需要手动处理大量DOM操作
  3. 难以与Element Plus等UI组件深度集成
  4. 难以实现复杂的拖拽逻辑(如限制拖拽区域、拖拽过程中数据预览等)

本文将通过自定义Vue 3指令的方式,结合SortableJS实现一个高性能、可维护的拖拽解决方案。通过深度解析实现原理,我们将掌握如何在保持Vue响应式特性的前提下,实现优雅的拖拽交互。

二、基本原理

Vue 3的自定义指令系统提供了beforeMount、mounted、beforeUpdate等生命周期钩子,可以结合SortableJS的API实现拖拽功能。其核心原理如下:

  1. 在mounted阶段初始化SortableJS实例
  2. 在beforeUpdate阶段处理数据变更带来的DOM更新
  3. 在beforeUnmount阶段销毁SortableJS实例
  4. 通过指令参数控制拖拽行为(如是否允许拖拽、拖拽方向等)

关键在于如何将SortableJS的事件回调与Vue响应式系统对接,确保数据变更能及时反映到UI。

三、环境准备

  1. 安装依赖:

    npm install @types/SortableJS --save-dev
    npm install element-plus sortablejs
  2. TypeScript配置:

    // tsconfig.json
    {
      "compilerOptions": {
     "types": ["sortablejs"]
      }
    }

四、核心实现

1. 基础自定义指令实现

// directives/draggable.ts
import { DirectiveBinding, DirectiveOptions } from 'vue'
import Sortable from 'sortablejs'

export const draggable: DirectiveOptions = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const options: any = {
      animation: 150,
      onEnd: (evt: any) => {
        const newIndex = evt.newIndex
        const oldIndex = evt.oldIndex
        
        // 通知组件更新数据
        binding.instance.$emit('update:items', el.dataset.index)
      }
    }
    
    // 合并用户自定义配置
    if (binding.value) {
      Object.assign(options, binding.value)
    }
    
    // 初始化Sortable
    new Sortable(el, options)
    
    // 记录元素索引
    el.dataset.index = binding.instance.items?.indexOf(binding.value) || 0
  },
  
  updated(el: HTMLElement, binding: DirectiveBinding) {
    // 当数据更新时重新设置索引
    el.dataset.index = binding.instance.items?.indexOf(binding.value) || 0
  },
  
  unmounted(el: HTMLElement) {
    // 销毁Sortable实例
    if (el.sortable) {
      el.sortable.destroy()
    }
  }
}

关键点说明:

  • 使用dataset.index保存元素索引,避免直接操作DOM
  • 通过$emit触发父组件更新数据
  • 通过unmounted钩子确保资源释放
  • 使用Object.assign合并用户配置

2. 带参数的指令使用示例

<template>
  <el-list v-draggable="items" :drag-allow="true" @update:items="handleUpdate">
    <el-list-item v-for="(item, index) in items" :key="item.id" :index="index">
      {{ item.name }}
    </el-list-item>
  </el-list>
</template>

<script lang="ts">
export default {
  data() {
    return {
      items: [
        { id: 1, name: 'Item 1' },
        { id: 2, name: 'Item 2' },
        { id: 3, name: 'Item 3' }
      ]
    }
  },
  methods: {
    handleUpdate(index: string) {
      // 处理排序更新逻辑
      this.items.sort((a, b) => {
        return this.items.indexOf(a) - this.items.indexOf(b)
      })
    }
  }
}
</script>

3. 与Element Plus深度集成

// directives/el-draggable.ts
import { DirectiveBinding, DirectiveOptions } from 'vue'
import Sortable from 'sortablejs'

export const elDraggable: DirectiveOptions = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const options: any = {
      animation: 150,
      onEnd: (evt: any) => {
        const newIndex = evt.newIndex
        const oldIndex = evt.oldIndex
        
        // 通知组件更新数据
        binding.instance.$emit('update:items', el.dataset.index)
      }
    }
    
    // 合并用户自定义配置
    if (binding.value) {
      Object.assign(options, binding.value)
    }
    
    // 初始化Sortable
    new Sortable(el, options)
    
    // 记录元素索引
    el.dataset.index = binding.instance.items?.indexOf(binding.value) || 0
  },
  
  updated(el: HTMLElement, binding: DirectiveBinding) {
    // 当数据更新时重新设置索引
    el.dataset.index = binding.instance.items?.indexOf(binding.value) || 0
  },
  
  unmounted(el: HTMLElement) {
    // 销毁Sortable实例
    if (el.sortable) {
      el.sortable.destroy()
    }
  }
}

五、完整案例

1. 任务管理应用示例

<template>
  <div class="task-board">
    <h2>待办事项</h2>
    <el-list v-el-draggable="tasks" @update:items="handleTasksUpdate">
      <el-list-item 
        v-for="(task, index) in tasks" 
        :key="task.id" 
        :index="index"
        class="task-item"
      >
        <el-tag type="success">{{ task.name }}</el-tag>
      </el-list-item>
    </el-list>
    
    <h2>完成事项</h2>
    <el-list v-el-draggable="completedTasks" @update:items="handleCompletedUpdate">
      <el-list-item 
        v-for="(task, index) in completedTasks" 
        :key="task.id" 
        :index="index"
        class="task-item"
      >
        <el-tag type="primary">{{ task.name }}</el-tag>
      </el-list-item>
    </el-list>
  </div>
</template>

<script lang="ts">
export default {
  data() {
    return {
      tasks: [
        { id: 1, name: '完成项目文档' },
        { id: 2, name: '修复BUG' },
        { id: 3, name: '编写测试用例' }
      ],
      completedTasks: [
        { id: 4, name: '完成用户注册功能' },
        { id: 5, name: '优化性能' }
      ]
    }
  },
  methods: {
    handleTasksUpdate(index: string) {
      // 重新排序待办事项
      this.tasks.sort((a, b) => {
        return this.tasks.indexOf(a) - this.tasks.indexOf(b)
      })
    },
    handleCompletedUpdate(index: string) {
      // 重新排序完成事项
      this.completedTasks.sort((a, b) => {
        return this.completedTasks.indexOf(a) - this.completedTasks.indexOf(b)
      })
    }
  }
}
</script>

<style scoped>
.task-board {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.task-item {
  cursor: grab;
}
</style>

关键实现点:

  • 使用v-el-draggable指令绑定两个列表
  • 通过@update:items事件处理排序更新
  • 使用sort方法保持数组顺序
  • 通过CSS设置cursor实现拖拽提示

六、源码解析

1. 指令初始化流程

mounted(el: HTMLElement, binding: DirectiveBinding) {
  // 初始化Sortable实例
  new Sortable(el, options)
  
  // 记录元素索引
  el.dataset.index = binding.instance.items?.indexOf(binding.value) || 0
}
  • el 是当前元素节点
  • binding.value 是传递给指令的参数
  • binding.instance 是当前组件实例
  • el.dataset.index 用于保存元素索引,避免直接操作DOM

2. 事件处理机制

onEnd: (evt: any) => {
  const newIndex = evt.newIndex
  const oldIndex = evt.oldIndex
  
  // 通知组件更新数据
  binding.instance.$emit('update:items', el.dataset.index)
}
  • onEnd 是SortableJS的事件回调
  • newIndex 和 oldIndex 是拖拽后的新旧索引
  • 通过 $emit 触发组件更新
  • el.dataset.index 获取当前元素的索引

3. 响应式更新机制

updated(el: HTMLElement, binding: DirectiveBinding) {
  // 当数据更新时重新设置索引
  el.dataset.index = binding.instance.items?.indexOf(binding.value) || 0
}
  • 当绑定的数据变更时触发
  • 重新计算元素索引
  • 确保SortableJS能正确识别元素位置

七、进阶使用

1. 添加拖拽限制

// 在指令选项中添加
const options: any = {
  animation: 150,
  handle: '.handle', // 指定可拖拽的元素
  onEnd: (evt: any) => {
    // 处理拖拽结束逻辑
  }
}

2. 拖拽过程中预览

onStart: (evt: any) => {
  // 创建预览元素
  const preview = document.createElement('div')
  preview.className = 'preview'
  preview.textContent = 'Dragging...'
  document.body.appendChild(preview)
  
  // 拖拽结束时移除预览
  evt.dragEvent.addEventListener('end', () => {
    document.body.removeChild(preview)
  })
}

3. 拖拽时的视觉反馈

.task-item {
  cursor: grab;
  transition: background-color 0.2s;
}

.task-item.dragging {
  background-color: #f0f0f0;
}
onStart: (evt: any) => {
  evt.item.classList.add('dragging')
},
onEnd: (evt: any) => {
  evt.item.classList.remove('dragging')
}

八、性能与工程实践

1. 性能优化方案

  1. 防抖处理:对频繁的DOM操作使用防抖

    const debounce = (fn: Function, delay: number) => {
      let timer: number
      return (...args: any[]) => {
     clearTimeout(timer)
     timer = setTimeout(() => fn.apply(this, args), delay)
      }
    }
  2. 虚拟滚动:对于大量数据使用虚拟滚动技术

    import { useVirtualScroll } from 'vue-virtual-scroll-list'
  3. 减少不必要的更新:使用v-if控制渲染范围

    <el-list v-if="tasks.length > 0" v-el-draggable="tasks" ...>

2. 异常处理机制

onEnd: (evt: any) => {
  try {
    // 拖拽结束逻辑
  } catch (error) {
    console.error('拖拽异常:', error)
    // 添加错误处理逻辑
  }
}

3. 安全性考虑

  1. XSS防护:确保用户输入内容经过过滤

    sanitizeContent(content: string): string {
      return content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
    }
  2. 限制拖拽范围:防止元素被拖出容器

    const options: any = {
      containment: 'parent' // 限制拖拽范围
    }

九、常见问题与踩坑

1. 常见错误

问题原因解决方案
拖拽无效指令未正确绑定检查指令名称是否匹配
数据未更新未触发响应式更新使用$emit或$forceUpdate
性能问题频繁更新DOM使用防抖/节流处理
元素消失未正确设置dataset确保索引正确更新
事件未触发未正确处理事件检查事件绑定是否正确

2. 常见坑点

  1. 指令参数类型问题:确保参数类型与TypeScript类型定义匹配
  2. 元素索引丢失:在数据变更时及时更新索引
  3. 事件冒泡问题:正确处理事件冒泡和捕获
  4. 样式冲突:确保自定义样式不影响原有UI

十、最佳实践

  1. 使用TypeScript类型定义:确保指令参数类型安全

    type DraggableOptions = {
      animation?: number
      handle?: string
      onEnd?: (evt: any) => void
      // ...其他选项
    }
  2. 封装可复用的指令:将常用功能封装成通用指令

    export const draggable: DirectiveOptions = {
      // 实现代码
    }
  3. 严格控制拖拽区域:使用containment选项限制拖拽范围

    const options: any = {
      containment: '.container'
    }
  4. 添加视觉反馈:通过CSS实现拖拽状态提示

    .dragging {
      opacity: 0.5;
      transform: scale(0.95);
    }
  5. 性能监控:使用性能分析工具监控拖拽操作

    import { performance } from 'perf_hooks'

十一、总结

通过自定义Vue 3指令结合SortableJS,我们实现了一个既保持Vue响应式特性,又具备强大拖拽功能的解决方案。这种实现方式在以下场景特别有用:

  • 需要精确控制拖拽行为的场景
  • 需要与现有UI组件深度集成的场景
  • 需要处理大量数据的场景
  • 需要实现复杂拖拽逻辑的场景

但需要注意以下限制:

  • 对于需要复杂拖拽交互的场景,建议使用更专业的拖拽库
  • 对于需要实时更新的场景,需要额外的性能优化
  • 对于涉及安全性的场景,需要额外的数据验证和过滤

通过本文的深入探讨,我们掌握了如何在Vue 3项目中高效实现拖拽功能,同时避免了传统方法的诸多弊端。这种实现方式在现代Web开发中具有很高的实用价值,特别是在需要交互性与性能平衡的场景中。

2024-08-07

TypeScript 中如何使用 getter 和 setter

一、背景与问题

在面向对象编程中,getter 和 setter 是封装属性访问的核心手段。它们允许开发者在读取或写入属性时执行额外的逻辑,例如数据验证、触发副作用、缓存计算结果等。TypeScript 作为静态类型语言,提供了对 getter 和 setter 的强大支持,但其底层机制和实际应用场景常被开发者忽视。

本篇文章将深入探讨 TypeScript 中 getter 和 setter 的实现原理、使用场景、常见陷阱以及性能优化策略,帮助开发者在实际项目中做出更合理的封装决策。


二、基本原理

1. 语法结构

TypeScript 中的 getter 和 setter 是通过 get 和 set 关键字定义的,它们与类中的属性绑定:

class Example {
  private _value: number;

  get value(): number {
    return this._value;
  }

  set value(value: number) {
    this._value = value;
  }
}
  • get 定义了属性的读取逻辑。
  • set 定义了属性的写入逻辑。
  • 属性名(value)在 get/set 中保持一致。

2. 与私有字段的结合

TypeScript 2.8+ 引入了 private 字段修饰符,使得 getter/setter 与私有字段的配合更加紧密:

class User {
  private _name: string;

  constructor(name: string) {
    this._name = name;
  }

  get name(): string {
    return this._name;
  }

  set name(name: string) {
    if (name.trim().length < 3) {
      throw new Error('Name must be at least 3 characters');
    }
    this._name = name;
  }
}

此时,_name 是实际存储数据的字段,而 name 是对外暴露的接口。

3. 与继承的关系

getter/setter 会随着类的继承被继承,但默认不会被重写(除非显式使用 override 关键字)。这种行为在设计类层次结构时需要特别注意。


三、环境准备

  • TypeScript 4.8+
  • Node.js 18+
  • IDE:VS Code(支持类型检查和代码提示)

四、核心实现

1. 基础用法

class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  get celsius(): number {
    return this._celsius;
  }

  set celsius(celsius: number) {
    this._celsius = celsius;
  }

  get fahrenheit(): number {
    return (this._celsius * 9 / 5) + 32;
  }
}

关键点分析:

  • fahrenheit 是一个计算属性,无需显式存储。
  • celsius 是可读可写属性,通过 setter 控制输入值。

2. 数据验证与副作用

class BankAccount {
  private _balance: number = 0;

  get balance(): number {
    return this._balance;
  }

  set balance(value: number) {
    if (value < 0) {
      throw new Error('Balance cannot be negative');
    }
    this._balance = value;
  }

  deposit(amount: number): void {
    this.balance += amount;
    console.log(`Deposited ${amount}, new balance: ${this.balance}`);
  }
}

关键点分析:

  • balance 的 setter 实现了数据验证。
  • deposit 方法通过 balance 属性间接修改值,确保所有修改都经过验证。

3. 缓存与性能优化

class Cache {
  private _data: Map<string, number> = new Map();

  get data(): Map<string, number> {
    return this._data;
  }

  set data(data: Map<string, number>) {
    this._data = data;
  }

  getCache(key: string): number | undefined {
    return this._data.get(key);
  }

  setCache(key: string, value: number): void {
    this._data.set(key, value);
  }
}

关键点分析:

  • data 是缓存接口,通过 getter/setter 控制访问。
  • 外部代码无需直接操作 Map 实例,避免了内存泄漏风险。

五、完整案例

1. 用户管理系统的完整实现

class User {
  private _id: number;
  private _name: string;
  private _email: string;
  private _passwordHash: string;

  constructor(id: number, name: string, email: string, password: string) {
    this._id = id;
    this._name = name;
    this._email = email;
    this._passwordHash = this.hashPassword(password);
  }

  get id(): number {
    return this._id;
  }

  get name(): string {
    return this._name;
  }

  set name(name: string) {
    if (name.trim().length < 3) {
      throw new Error('Name must be at least 3 characters');
    }
    this._name = name;
  }

  get email(): string {
    return this._email;
  }

  set email(email: string) {
    if (!this.validateEmail(email)) {
      throw new Error('Invalid email format');
    }
    this._email = email;
  }

  get passwordHash(): string {
    return this._passwordHash;
  }

  private hashPassword(password: string): string {
    return btoa(password); // 简化示例
  }

  private validateEmail(email: string): boolean {
    const re = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
    return re.test(email);
  }
}

关键点分析:

  • id 是只读属性,通过 getter 提供访问。
  • name 和 email 的 setter 包含业务逻辑验证。
  • passwordHash 是只读属性,防止直接修改敏感数据。

使用示例:

const user = new User(1, 'Alice', 'alice@example.com', '123456');
console.log(user.name); // Alice
user.name = 'Bob'; // 合法
user.name = 'A'; // 抛出错误

六、源码解析

1. 编译后的 JavaScript

TypeScript 编译器会将 getter/setter 转换为 JavaScript 的 get/set 方法:

class User {
  constructor(id, name, email, password) {
    this._id = id;
    this._name = name;
    this._email = email;
    this._passwordHash = this.hashPassword(password);
  }

  get id() {
    return this._id;
  }

  get name() {
    return this._name;
  }

  set name(name) {
    if (name.trim().length < 3) {
      throw new Error('Name must be at least 3 characters');
    }
    this._name = name;
  }

  // 其他方法...
}

2. 与私有字段的绑定

TypeScript 的 private 字段在编译后会转换为 _propertyName 形式,getter/setter 通过 this.propertyName 访问,确保封装性。


七、进阶使用

1. 与装饰器结合

function LogProperty(target: any, propertyName: string) {
  const value = target[propertyName];
  Object.defineProperty(target, propertyName, {
    get: () => {
      console.log(`Getting ${propertyName}`);
      return value;
    },
    set: (newValue) => {
      console.log(`Setting ${propertyName}`);
      value = newValue;
    },
    enumerable: true
  });
}

使用示例:

class Config {
  @LogProperty
  private _timeout: number = 1000;
}

2. 与代理模式结合

class DataProxy {
  private _data: Record<string, any> = {};

  get data(): Record<string, any> {
    return this._data;
  }

  set data(data: Record<string, any>) {
    this._data = data;
  }

  get(key: string): any {
    return this._data[key];
  }

  set(key: string, value: any): void {
    this._data[key] = value;
  }
}

八、性能与工程实践

1. 性能优化策略

场景优化方案
频繁访问计算属性使用缓存字段,如 private _cache: Map<string, any>
高频写入属性使用 set 方法进行批量处理
大数据对象使用 WeakMap 或 Proxy 管理动态属性

2. 异常处理

class Validator {
  private _data: Record<string, any> = {};

  get data(): Record<string, any> {
    return this._data;
  }

  set data(data: Record<string, any>) {
    if (!this.validate(data)) {
      throw new Error('Invalid data');
    }
    this._data = data;
  }

  private validate(data: Record<string, any>): boolean {
    return Object.keys(data).every(key => {
      if (typeof data[key] === 'string') {
        return data[key].length > 0;
      }
      return true;
    });
  }
}

3. 安全风险

  • 数据泄露:直接暴露私有字段(如 this._passwordHash)可能导致敏感信息泄露。
  • 注入攻击:未正确验证输入值可能导致 SQL 注入或 XSS 攻击。
  • 缓存污染:未正确管理缓存可能导致数据不一致。

九、常见问题与踩坑

1. 常见错误

错误原因解决方案
Property 'name' has no initializer and is not declared in the constructor忘记初始化私有字段在构造函数中显式初始化
Type 'string' is not assignable to type 'number'类型不匹配在 set 方法中进行类型校验
Cannot assign to 'name' because it is a getter错误地尝试直接修改属性使用 set 方法控制修改

2. 高级陷阱

  • getter/setter 与 Object.defineProperty 的差异:JavaScript 原生的 Object.defineProperty 无法直接支持类型检查,而 TypeScript 的 getter/setter 会强制类型校验。
  • 继承中的 getter/setter 重写:子类需要显式使用 override 关键字,否则会创建新的属性。

十、最佳实践

1. 使用建议

场景推荐方案
需要数据验证使用 set 方法校验输入
需要计算属性使用 get 方法定义计算逻辑
需要缓存使用私有字段存储计算结果
需要安全控制在 set 方法中进行输入过滤

2. 避免滥用

场景不建议使用原因
简单属性使用直接属性避免过度封装
频繁访问属性使用 get 方法增加性能开销
大数据对象使用 get 方法可能导致内存泄漏

3. 工程实践

  • 统一封装策略:对所有业务属性使用 getter/setter,保持代码一致性。
  • 类型安全:利用 TypeScript 的类型系统,避免运行时错误。
  • 文档注释:为 getter/setter 添加详细注释,说明其用途和限制。

十一、总结

TypeScript 的 getter 和 setter 是封装属性访问的强大工具,但其使用需要结合具体场景。通过合理设计 getter/setter,可以实现数据验证、计算属性、缓存控制等高级功能。然而,过度使用会导致代码复杂化,而滥用则可能引入安全风险和性能问题。

在实际项目中,建议:

  • 在需要数据校验、计算属性或副作用的场景使用 getter/setter;
  • 避免对简单属性进行封装;
  • 结合 TypeScript 的类型系统,确保代码安全;
  • 通过单元测试验证 getter/setter 的行为。

通过深入理解 getter 和 setter 的原理和最佳实践,开发者可以更高效地构建安全、可维护的 TypeScript 项目。

2024-08-07

vite - vue 中 typescript 中使用@ 前缀的别名提示错误(cannot find module...)

一、背景与问题

在使用 Vite + Vue + TypeScript 构建现代前端项目时,开发者常通过 @ 前缀设置路径别名(如 @/components/)来简化相对路径引用。然而在实际开发中,开发者常常遇到以下错误提示:

ERROR  Failed to load resource: The module 'xxx' was not found in the project.
ERROR  Cannot find module 'xxx' from 'xxx'

这种错误通常发生在以下场景:

  1. 配置未正确指定路径别名
  2. TypeScript 配置与 Vite 配置不一致
  3. 模块解析策略冲突
  4. 路径映射未覆盖所有需要的模块

这个问题的核心在于模块解析机制与路径别名配置的协作方式,需要深入理解 Vite 和 TypeScript 的模块解析策略。

二、基本原理

1. 模块解析机制

Vite 使用 node_modules 的模块解析策略,但通过 resolve.alias 配置可自定义路径别名。TypeScript 的路径映射(tsconfig.json)则通过 paths 字段定义路径别名。

两者的关键区别在于:

  • Vite 的 resolve.alias 是运行时配置
  • TypeScript 的 paths 是编译时配置

两者需要配合使用才能实现完整的路径别名支持。

2. 路径别名的映射规则

假设配置:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': '/src'
    }
  }
});
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

当使用 @/components/Hello.vue 引用时,TypeScript 会将其映射到 ./src/components/Hello.vue,而 Vite 会在构建时将路径转换为实际的文件路径。

3. 模块解析顺序

Vite 的模块解析顺序是:

  1. 检查 resolve.alias 配置
  2. 检查 node_modules 目录
  3. 检查 tsconfig.json 中的 baseUrl 和 paths

三、环境准备

1. 项目结构示例

my-vue-project/
├── src/
│   ├── components/
│   │   └── Hello.vue
│   └── main.ts
├── vite.config.ts
├── tsconfig.json
└── package.json

2. 安装依赖

npm create vite@latest my-vue-project -- --template vue-ts
cd my-vue-project
npm install

四、核心实现

1. 正确配置路径别名

正确配置示例

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': '/src'
    }
  }
});
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

错误配置示例

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

问题:缺少 tsconfig.json 中的 baseUrl 配置,导致路径解析失败。

2. 配置文件详解

{
  "compilerOptions": {
    "baseUrl": ".", // 指定路径解析的根目录
    "paths": {
      "@/*": ["./src/*"] // 将 @/xxx 映射到 src/xxx
    },
    "moduleResolution": "node" // 使用 node 模块解析策略
  }
}

3. 路径映射规则

配置解析结果
@/components/Hello.vue./src/components/Hello.vue
@/types/index.d.ts./src/types/index.d.ts
@/utils/helper.ts./src/utils/helper.ts

五、完整案例

1. 项目结构

my-vue-project/
├── src/
│   ├── components/
│   │   └── Hello.vue
│   └── main.ts
├── vite.config.ts
├── tsconfig.json
└── package.json

2. 配置文件

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': '/src'
    }
  }
});
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "moduleResolution": "node"
  }
}

3. 代码示例

<!-- src/components/Hello.vue -->
<template>
  <h1>Hello Vite + Vue + TypeScript</h1>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'Hello'
});
</script>
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');
<!-- src/App.vue -->
<template>
  <Hello />
</template>

<script lang="ts">
import Hello from '@/components/Hello.vue';

export default {
  components: {
    Hello
  }
};
</script>

4. 运行结果

npm run dev

访问 http://localhost:5173 应看到 "Hello Vite + Vue + TypeScript" 的页面。

六、源码解析

1. Vite 的模块解析流程

// vite/src/node/index.ts
function resolveId(id: string, importer: string | null = null) {
  // 检查 alias 配置
  if (id.startsWith('@')) {
    const alias = config.resolve.alias[id];
    if (alias) {
      return alias;
    }
  }

  // 常规模块解析逻辑
  // ...
}

2. TypeScript 的路径映射机制

// tsconfig.json 解析逻辑
function resolvePath(path: string, baseUrl: string) {
  // 根据 baseUrl 和 paths 配置解析路径
  // ...
}

七、进阶使用

1. 多层级路径别名

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "shared/*": ["./shared/*"]
    }
  }
}

2. 动态路径映射

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': '/src',
      'common': '/src/common'
    }
  }
});

3. 与 ESM 的兼容性

// src/utils/helper.ts
export function sayHello() {
  console.log('Hello from TypeScript');
}
// src/main.ts
import { sayHello } from '@/utils/helper';

sayHello();

八、性能与工程实践

1. 性能优化建议

  • 避免过度使用路径别名,保持路径清晰
  • 在大型项目中使用分层路径别名(如 @/pages/、@/components/)
  • 使用 tsconfig.json 的 paths 配置,而不是 Vite 的 resolve.alias(更符合 TypeScript 的规范)

2. 异常处理

// src/utils/helper.ts
export function sayHello() {
  try {
    console.log('Hello from TypeScript');
  } catch (error) {
    console.error('Error in helper.ts:', error);
  }
}

3. 安全风险

  • 路径注入攻击:确保别名配置不包含动态拼接的路径
  • 避免暴露敏感路径别名(如 @/config/)

九、常见问题与踩坑

1. 常见错误

问题原因解决方案
Cannot find module '@/components/Hello.vue'配置不完整确保 tsconfig.json 中配置了 baseUrl 和 paths
Module not found: @/types路径映射未覆盖在 paths 中添加 @/types 映射
TypeError: Cannot read property '...' of undefined模块未正确导入检查 resolve.alias 是否正确映射

2. 常见坑点

  • 忘记配置 tsconfig.json 中的 baseUrl,导致路径解析失败
  • 混淆 Vite 的 resolve.alias 和 TypeScript 的 paths 配置
  • 在动态导入中未正确处理路径别名

十、最佳实践

1. 推荐配置方案

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "shared/*": ["./shared/*"]
    },
    "moduleResolution": "node"
  }
}

2. 推荐配置方式

  • 使用 tsconfig.json 的 paths 配置
  • 在 Vite 中使用 resolve.alias 配合路径别名
  • 避免在 resolve.alias 中使用动态路径

3. 推荐实践

  • 在大型项目中使用分层路径别名
  • 使用 TypeScript 的类型检查确保路径正确
  • 定期检查配置文件的兼容性

十一、总结

在 Vite + Vue + TypeScript 的项目中,使用 @ 前缀的路径别名时,需要正确配置 tsconfig.json 和 vite.config.ts 文件。理解 Vite 和 TypeScript 的模块解析机制是解决路径别名问题的关键。

通过合理配置 baseUrl 和 paths,以及 Vite 的 resolve.alias,可以实现高效的路径别名支持。在实际开发中,需要注意配置的一致性,避免路径映射错误,同时也要注意性能和安全问题。

掌握这些技巧不仅能解决常见的路径别名问题,还能提升项目代码的可维护性和可读性。合理使用路径别名是现代前端开发的重要实践,值得在项目中广泛应用。

2024-08-07

TypeScript中定时器的运用

一、背景与问题

在现代Web开发中,定时器是实现异步任务调度的核心工具。TypeScript作为JavaScript的静态类型超集,在保持原有定时器API的同时,通过类型系统提供了更严格的类型保障。然而在实际开发中,开发者常遇到以下问题:

  1. 定时器未正确清理导致内存泄漏
  2. 多个定时器相互干扰的竞态条件
  3. 高频定时任务引发的性能瓶颈
  4. 跨平台兼容性差异(如Node.js与浏览器环境)

本文将深入解析TypeScript中定时器的工作原理,通过多个实战案例展示其应用场景,并探讨性能优化方案。

二、基本原理

JavaScript运行时通过事件循环(Event Loop)处理异步任务。定时器的执行逻辑如下:

  1. 任务被放入任务队列(Task Queue)
  2. 事件循环检测到任务队列中有可执行的微任务
  3. 执行任务(包括setTimeout/setInterval的回调函数)

需要注意的是,定时器的执行时间并非严格精确。浏览器会根据当前空闲时间进行调度,这可能导致实际执行时间与预期存在偏差。例如:

setTimeout(() => {
  console.log('Hello'); // 可能会晚于1000ms执行
}, 1000);

三、环境准备

创建一个标准TypeScript项目:

npm init -y
npm install -D typescript ts-node
npx tsc --init

在tsconfig.json中配置:

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ESNext",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "moduleResolution": "node",
    "outDir": "./dist",
    "rootDir": "."
  }
}

四、核心实现

1. 基础定时器用法

// 基础用法
function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function main() {
  console.log('Start');
  await delay(1000);
  console.log('End');
}
main();

关键点分析:

  • setTimeout返回的ID可用于清除定时器
  • Promise封装便于异步控制流
  • 使用await确保顺序执行

2. 定时器清理机制

let timerId: NodeJS.Timeout;

function startTimer() {
  timerId = setTimeout(() => {
    console.log('Timer triggered');
  }, 1000);
}

function stopTimer() {
  if (timerId) {
    clearTimeout(timerId);
    timerId = undefined;
  }
}

注意事项:

  • 变量作用域管理
  • 需要显式清除定时器
  • 可能存在的内存泄漏风险

3. 使用Promise封装定时器

function createTimer(ms: number): Promise<void> {
  return new Promise((resolve) => {
    const id = setTimeout(() => {
      resolve();
    }, ms);
    
    // 可选:添加清除逻辑
    return () => clearTimeout(id);
  });
}

扩展性分析:

  • 可以链式调用
  • 支持取消操作
  • 可与async/await无缝集成

五、完整案例

实现一个定时任务管理器

// taskManager.ts
type Task = () => Promise<void>;
type TaskId = number;

class TaskManager {
  private tasks: Map<TaskId, Task> = new Map();
  private nextId = 1;
  
  addTask(task: Task, interval: number): TaskId {
    const id = this.nextId++;
    const wrappedTask = async () => {
      try {
        await task();
        this.addTask(task, interval); // 自动重置
      } catch (err) {
        console.error('Task failed:', err);
        this.removeTask(id);
      }
    };
    
    const timerId = setTimeout(wrappedTask, interval);
    this.tasks.set(id, () => {
      clearTimeout(timerId);
      this.tasks.delete(id);
    });
    
    return id;
  }
  
  removeTask(id: TaskId): void {
    const cleanup = this.tasks.get(id);
    if (cleanup) {
      cleanup();
      this.tasks.delete(id);
    }
  }
  
  getTaskCount(): number {
    return this.tasks.size;
  }
}

// 使用示例
async function main() {
  const manager = new TaskManager();
  const taskId = manager.addTask(async () => {
    console.log('Executing task...');
    await new Promise(resolve => setTimeout(resolve, 500));
  }, 1000);
  
  await new Promise(resolve => setTimeout(resolve, 3000));
  manager.removeTask(taskId);
}

关键代码分析:

  • 使用Map管理任务状态
  • 自动重试机制
  • 可控的清理逻辑
  • 错误处理机制

六、源码解析

1. setTimeout源码实现

// 模拟setTimeout实现(简化版)
function setTimeout(func: () => void, ms: number): number {
  const id = Date.now();
  const timer = {
    id,
    callback: func,
    timeout: ms
  };
  
  // 模拟事件循环
  setTimeout(() => {
    timer.callback();
  }, timer.timeout);
  
  return id;
}

注意事项:

  • 实际实现中需要处理多个定时器的排序
  • 需要处理微任务队列的优先级
  • 需要处理跨平台兼容性

2. 定时器清理机制

// 模拟清除定时器
function clearTimeout(id: number): void {
  // 实际实现中需要遍历所有定时器
  console.log(`Clearing timer ${id}`);
}

优化点:

  • 使用弱引用管理定时器
  • 使用WeakMap存储定时器信息
  • 跨平台兼容性处理

七、进阶使用

1. 节流/防抖技术

function debounce<T>(fn: (args: T) => void, delay: number): (args: T) => void {
  let timer: number;
  return (args: T) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn(args);
    }, delay);
  };
}

function throttle<T>(fn: (args: T) => void, delay: number): (args: T) => void {
  let lastTime = 0;
  return (args: T) => {
    const now = Date.now();
    if (now - lastTime > delay) {
      fn(args);
      lastTime = now;
    }
  };
}

2. 使用requestAnimationFrame

function animate(callback: () => void): number {
  return requestAnimationFrame(callback);
}

适用场景:

  • 动画渲染
  • 游戏开发
  • 响应式设计

八、性能与工程实践

1. 性能优化策略

问题解决方案
高频定时器使用requestAnimationFrame
内存泄漏始终清除定时器
竞态条件使用锁机制或状态机
资源浪费使用节流/防抖

2. 安全风险分析

  • 定时器注入攻击:恶意代码可创建大量定时器耗尽资源
  • 跨域定时器:浏览器限制跨域定时器执行
  • 内存泄漏:未清理的定时器会持续占用内存

3. 工程实践建议

  1. 使用WeakMap存储定时器信息
  2. 实现定时器统计和监控
  3. 使用装饰器模式管理定时器
  4. 在Node.js中使用setInterval代替setTimeout

九、常见问题与踩坑

1. 常见错误示例

let timerId = setTimeout(() => {
  console.log('Hello');
}, 1000);

// 错误:未清除定时器

问题分析:

  • 未清除定时器导致内存泄漏
  • 可能引发性能问题

2. 正确实现方式

function startTimer() {
  const timerId = setTimeout(() => {
    console.log('Hello');
  }, 1000);
  
  return () => clearTimeout(timerId);
}

const cleanup = startTimer();
// 在适当的时候调用 cleanup()

3. 竞态条件问题

let count = 0;
let timerId: number;

function startCounter() {
  timerId = setTimeout(() => {
    count++;
    console.log(count);
  }, 1000);
}

// 竞态条件:多个调用可能覆盖彼此

解决方案:

  • 使用锁机制
  • 使用状态机管理
  • 使用Promise封装

十、最佳实践

  1. 优先使用Promise封装:提高代码可读性和可维护性
  2. 始终清除定时器:避免内存泄漏和资源浪费
  3. 合理选择定时器类型:根据需求选择setTimeout或setInterval
  4. 使用第三方库:如lodash的debounce/throttle
  5. 实现监控机制:统计定时器数量和执行情况
  6. 在Node.js中使用worker_threads:处理高并发定时任务

十一、总结

TypeScript中的定时器是实现异步任务调度的核心工具,但其使用需要特别注意以下几点:

  1. 原理理解:了解事件循环机制和定时器调度规则
  2. 性能优化:避免高频定时器和内存泄漏
  3. 安全实践:防范注入攻击和资源耗尽
  4. 工程规范:建立统一的定时器管理机制
  5. 场景适配:根据具体需求选择合适的定时器类型

在实际开发中,建议结合使用Promise、装饰器模式和监控机制,构建健壮的定时器系统。对于高并发场景,可考虑使用Node.js的worker_threads或集群模块来处理定时任务。通过合理的设计和实践,可以充分发挥定时器在现代Web开发中的价值。

2024-08-07

vue3使用全局扩展属性app.config.globalProperties

一、背景与问题

在Vue3的开发中,开发者常常需要在多个组件之间共享某些全局属性或方法。传统的解决方案包括使用全局状态管理库(如Vuex/Pinia)、自定义Hook、全局混入等。然而,Vue3提供了更简洁的方式:通过app.config.globalProperties扩展全局属性。

这一特性在Vue3中被设计为替代Vue2的Vue.prototype,但其背后隐藏着一些需要注意的细节。本文将深入解析其工作原理、使用场景、常见陷阱,并通过完整案例展示其在实际项目中的应用。


二、基本原理

Vue3的app.config.globalProperties本质是向Vue实例添加全局属性的机制。通过创建Vue应用实例时,开发者可以将任意对象或函数挂载到app.config.globalProperties上,这些属性会自动绑定到所有组件实例的this对象中。

其底层原理与Vue3的响应式系统密切相关。当全局属性被访问时,Vue会通过其响应式代理机制进行追踪,确保在数据变化时能够触发视图更新。

需要注意的是,globalProperties的特性与Vue3的Composition API存在差异。它更适合在使用Options API的场景中使用,而Composition API的开发者更推荐使用自定义Hook或全局状态管理方案。


三、环境准备

确保你的开发环境满足以下要求:

  1. 安装Vue3:

    npm install -g @vue/cli
    vue create my-project
    cd my-project
  2. 在main.js中创建Vue实例:

    import { createApp } from 'vue'
    const app = createApp({})
    
    // 全局属性扩展逻辑将在此处实现
  3. 创建测试组件:

    <!-- components/HelloWorld.vue -->
    <template>
      <div>
     <p>当前时间:{{ formatDate() }}</p>
     <p>全局变量:{{ globalVar }}</p>
      </div>
    </template>

四、核心实现

1. 基础用法:添加全局方法和变量

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 定义全局方法
app.config.globalProperties.formatDate = function(date) {
  return date.toLocaleString()
}

// 定义全局变量
app.config.globalProperties.globalVar = 'Hello Vue3'

app.mount('#app')
<!-- components/HelloWorld.vue -->
<template>
  <div>
    <p>当前时间:{{ formatDate() }}</p>
    <p>全局变量:{{ globalVar }}</p>
  </div>
</template>

关键代码解析:

  • app.config.globalProperties是Vue实例的配置属性,其值类型可以是Object或Function
  • 添加的属性会自动绑定到所有组件实例的this对象
  • 全局方法和变量的访问方式为this.formatDate()和this.globalVar

2. 响应式全局变量

// main.js
import { createApp, ref } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 响应式全局变量
app.config.globalProperties.globalState = ref({
  count: 0,
  increment: () => {
    this.globalState.value.count++
  }
})

app.mount('#app')
<!-- components/HelloWorld.vue -->
<template>
  <div>
    <p>全局状态:{{ globalState.count }}</p>
    <button @click="globalState.increment()">+1</button>
  </div>
</template>

关键代码解析:

  • 使用ref创建响应式全局变量
  • 通过this.globalState访问响应式数据
  • 方法绑定时使用this确保上下文正确

3. 路由守卫中的全局属性使用

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter, createWebHistory } from 'vue-router'
import Home from './components/Home.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home }
  ]
})

const app = createApp(App)
app.use(router)

// 添加全局属性
app.config.globalProperties.auth = {
  isAuthenticated: false,
  login: () => {
    this.auth.isAuthenticated = true
  }
}

app.mount('#app')
<!-- components/Home.vue -->
<template>
  <div>
    <p v-if="auth.isAuthenticated">欢迎回来!</p>
    <p v-else>请登录</p>
    <button @click="auth.login()">登录</button>
  </div>
</template>

关键代码解析:

  • 全局属性可以包含复杂对象
  • 在路由守卫中可以访问全局属性进行权限控制
  • 方法绑定时需确保this上下文正确

五、完整案例:构建全局工具库

项目结构

src/
├── main.js
├── App.vue
├── components/
│   └── Toolbar.vue
│   └── Page.vue
├── utils/
│   └── globalUtils.js

1. 创建全局工具库

// utils/globalUtils.js
export const formatCurrency = (value, currency = 'CNY') => {
  return `${currency} ${value.toFixed(2)}`
}

export const formatDate = (date) => {
  return date.toLocaleString()
}

2. 注册全局属性

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { formatCurrency, formatDate } from './utils/globalUtils'

const app = createApp(App)

// 注册全局方法
app.config.globalProperties.formatCurrency = formatCurrency
app.config.globalProperties.formatDate = formatDate

app.mount('#app')

3. 使用组件

<!-- components/Toolbar.vue -->
<template>
  <div>
    <p>当前时间:{{ formatDate() }}</p>
    <p>格式化金额:{{ formatCurrency(100) }}</p>
  </div>
</template>
<!-- components/Page.vue -->
<template>
  <div>
    <p>全局工具库已注册</p>
  </div>
</template>

六、源码解析

Vue3的全局属性扩展机制在源码中体现为对App实例的扩展。当调用createApp创建实例时,会初始化app.config对象,其中包含globalProperties属性。

关键源码片段(简化版):

function createApp(rootComponent) {
  const app = {
    config: {
      globalProperties: {}
    }
  }

  // 注册全局属性
  app.config.globalProperties = {
    ...app.config.globalProperties,
    [key]: value
  }

  return app
}

在组件实例化时,Vue会将app.config.globalProperties挂载到组件实例的this上:

function initMixin(target) {
  target.prototype.$ = target.prototype
  target.prototype._config = target.prototype.config
  target.prototype._globalProperties = app.config.globalProperties
}

七、进阶使用

1. 结合Vue3的自定义元素

// custom-element.js
export const MyCustomElement = {
  template: `<div>全局属性:{{ globalVar }}</div>`,
  props: ['globalVar']
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { MyCustomElement } from './custom-element'

const app = createApp(App)

// 注册全局属性
app.config.globalProperties.globalVar = 'Custom Element'

// 注册自定义元素
app.use(MyCustomElement)

app.mount('#app')

2. 在插件系统中使用

// my-plugin.js
export default {
  install(app) {
    // 添加全局属性
    app.config.globalProperties.myPlugin = {
      version: '1.0.0',
      log: () => {
        console.log('Plugin method called')
      }
    }
  }
}
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import myPlugin from './my-plugin'

const app = createApp(App)
app.use(myPlugin)
app.mount('#app')

八、性能与工程实践

1. 性能优化

  • 避免过度使用:每个全局属性都会增加内存占用,过多使用可能导致性能下降
  • 响应式优化:使用ref或reactive创建响应式全局变量时,需注意其更新机制
  • 懒加载策略:对于不常用的全局属性,可采用动态加载方式

2. 异常处理

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 添加全局异常处理
app.config.globalProperties.handleError = (error) => {
  console.error('全局异常处理:', error)
  // 可以添加上报机制
}

app.mount('#app')

3. 安全风险

  • 数据泄露风险:敏感信息不应通过全局属性暴露
  • 污染全局命名空间:大量全局属性可能导致命名冲突
  • 安全注入:确保注入的全局属性不会被恶意篡改

九、常见问题与踩坑

1. 错误示例:上下文丢失

// 错误代码
app.config.globalProperties.myMethod = function() {
  console.log(this) // 此处this可能指向全局对象而非组件实例
}

解决办法:

// 正确代码
app.config.globalProperties.myMethod = function() {
  console.log(this) // 此时this指向组件实例
}

2. 错误示例:异步数据未更新

// 错误代码
app.config.globalProperties.globalData = {
  count: 0,
  increment: () => {
    this.globalData.count++
  }
}

解决办法:

// 正确代码
app.config.globalProperties.globalData = ref({
  count: 0,
  increment: () => {
    this.globalData.value.count++
  }
})

3. 错误示例:全局属性未注册

// 错误代码
app.config.globalProperties.myVar = 'test'

解决办法:

// 正确代码
const app = createApp(App)
app.config.globalProperties.myVar = 'test'
app.mount('#app')

十、最佳实践

  1. 适用场景:

    • 需要向所有组件暴露工具函数
    • 项目规模较小,无需复杂状态管理
    • 需要快速实现全局变量共享
  2. 不适用场景:

    • 项目规模较大时,建议使用Pinia进行状态管理
    • 需要模块化组件时,使用Composition API的自定义Hook
    • 需要复杂的权限控制时,使用路由守卫和状态管理结合
  3. 推荐做法:

    • 对于工具函数,建议封装为独立模块
    • 对于响应式数据,使用ref或reactive创建
    • 在SSR环境中注意全局属性的初始化时机
    • 对于关键数据,建议使用全局状态管理库进行封装

十一、总结

app.config.globalProperties是Vue3提供的一种全局属性扩展机制,其核心原理是将属性绑定到Vue实例,从而在所有组件中通过this访问。这一特性在特定场景下非常有用,但也存在一些需要注意的问题。

在实际开发中,我们应根据项目规模和复杂度选择合适的方案。对于小型项目或需要快速实现全局功能的场景,globalProperties是一个简洁的选择。但对于大型项目或需要复杂状态管理的场景,建议使用Pinia等专门的状态管理库。

在使用过程中,需要注意上下文丢失、响应式更新、安全风险等问题,通过合理的设计和优化,可以充分发挥这一特性的优势。同时,要避免过度使用,防止全局命名空间污染和性能问题。

2024-08-07

TypeScript中的定时器

一、背景与问题

在事件驱动的编程模型中,定时器是处理异步任务的核心工具。TypeScript作为JavaScript的超集,继承了JavaScript的定时器机制,同时通过类型系统增强了其安全性。但开发者常陷入几个误区:

  • 忽视内存泄漏导致的资源浪费
  • 在异步回调中误用this上下文
  • 频繁创建定时器造成性能损耗
  • 使用setTimeout替代requestAnimationFrame导致动画卡顿

本文将深入解析TypeScript中定时器的底层机制,通过实际案例揭示其工作原理,并探讨最佳实践。

二、基本原理

1. JavaScript事件循环机制

JavaScript运行在单线程的事件循环中,定时器通过setTimeout和setInterval将任务加入宏任务队列。V8引擎在以下场景触发定时器回调:

// 宏任务队列执行顺序
setTimeout(() => { console.log(1); }, 0);
Promise.resolve().then(() => { console.log(2); });
setImmediate(() => { console.log(3); });

2. 定时器的精度限制

浏览器中定时器的最小时间间隔为4ms(Chrome 60+),受浏览器渲染和垃圾回收影响。精确控制需要使用requestAnimationFrame或performance.now()配合自定义时间戳。

3. 定时器的生命周期

const timer = setTimeout(() => {
  console.log('Timeout');
}, 1000);
clearTimeout(timer); // 取消定时器

三、环境准备

创建TypeScript项目:

npm init -y
npm install --save-dev typescript ts-node
npx tsc --init

tsconfig.json配置:

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ESNext",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src"]
}

四、核心实现

1. 基础用法

// src/index.ts
function delayedLog(message: string, delay: number = 1000): number {
  const timer = setTimeout(() => {
    console.log(message);
  }, delay);
  return timer;
}

const timerId = delayedLog("Hello from setTimeout", 2000);
clearTimeout(timerId);

关键点:

  • 返回的timerId用于后续清除
  • 参数类型校验防止类型错误
  • 避免在回调中使用this时的上下文问题

2. 结合泛型的类型安全

// src/generic.ts
type Callback<T> = (arg: T) => void;

function delayedCallback<T>(callback: Callback<T>, delay: number = 1000, arg: T): number {
  const timer = setTimeout(() => {
    callback(arg);
  }, delay);
  return timer;
}

delayedCallback("Hello", 500); // 正确
delayedCallback(42, 500); // 正确
delayedCallback(true, 500); // 正确

3. 异步函数中的定时器

// src/async.ts
async function asyncDelay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function main() {
  console.log("Start");
  await asyncDelay(1000);
  console.log("End");
}

main();

五、完整案例

1. 实时数据更新系统

// src/dataUpdater.ts
interface DataPoint {
  id: number;
  value: number;
  timestamp: number;
}

class DataCollector {
  private intervalId: number;
  private dataPoints: DataPoint[] = [];
  
  constructor(private updateInterval: number = 1000) {}
  
  startCollection(): void {
    this.intervalId = setInterval(() => {
      const newPoint: DataPoint = {
        id: Date.now(),
        value: Math.random() * 100,
        timestamp: Date.now()
      };
      this.dataPoints.push(newPoint);
      console.log(`New data point added: ${newPoint.id}`);
    }, this.updateInterval);
  }
  
  stopCollection(): void {
    clearInterval(this.intervalId);
    console.log("Data collection stopped");
  }
  
  getLatestData(): DataPoint[] {
    return [...this.dataPoints];
  }
}

// 使用示例
const collector = new DataCollector(500);
collector.startCollection();

// 模拟5秒后停止
setTimeout(() => {
  collector.stopCollection();
  console.log("Latest data:", collector.getLatestData());
}, 5000);

关键点:

  • 使用setInterval持续收集数据
  • 通过clearInterval停止数据采集
  • 数据封装在类中保证类型安全
  • 5秒后停止避免资源泄漏

六、源码解析

1. V8引擎的定时器实现

在V8中,定时器通过v8::Isolate::SetTimeout接口注册。当事件循环执行时,会遍历定时器队列,比较当前时间与设置时间的差值,若超过则触发回调。

2. 定时器的垃圾回收

未清除的定时器可能导致内存泄漏,因为回调函数可能持有对对象的引用。例如:

const obj = { data: "secret" };
setTimeout(() => {
  console.log(obj.data);
}, 1000);

即使obj被回收,定时器回调仍可能访问其属性。

七、进阶使用

1. 定时器的组合使用

function staggeredExecution(tasks: (() => void)[], delay: number = 100) {
  let index = 0;
  const timer = setInterval(() => {
    if (index < tasks.length) {
      tasks[index]();
      index++;
    } else {
      clearInterval(timer);
    }
  }, delay);
}

2. 精确时间控制

function preciseTimeout(callback: () => void, delay: number) {
  const start = performance.now();
  const timer = setTimeout(() => {
    const elapsed = performance.now() - start;
    callback();
  }, delay);
  
  return {
    elapsed: elapsed,
    timer: timer
  };
}

八、性能与工程实践

1. 性能优化策略

  • 使用一次性定时器代替循环
  • 批量处理任务减少调用次数
  • 使用requestAnimationFrame进行动画控制
  • 避免在回调中创建大量临时对象

2. 安全风险防范

  • 避免在全局作用域中创建定时器
  • 对用户输入的定时器参数进行校验
  • 限制定时器的执行频率
  • 使用WeakMap管理定时器上下文

3. 异常处理机制

function safeTimeout(callback: () => void, delay: number) {
  return setTimeout(() => {
    try {
      callback();
    } catch (err) {
      console.error("Timeout error:", err);
    }
  }, delay);
}

九、常见问题与踩坑

1. 常见错误

  • 忘记清除定时器导致内存泄漏
  • 在异步函数中误用this上下文
  • 超时回调未处理异常
  • 频繁创建定时器导致性能下降

2. 解决方案

  • 使用WeakMap管理定时器上下文
  • 在组件卸载时清除定时器
  • 使用try/catch包裹回调函数
  • 使用防抖/节流控制调用频率

十、最佳实践

1. 推荐方案

  • 使用Promise和async/await替代setTimeout
  • 对关键路径使用requestAnimationFrame
  • 使用WeakMap管理定时器上下文
  • 在组件卸载时清除定时器
  • 对用户输入进行严格的类型校验

2. 使用建议

  • 定时器适合处理:

    • 异步任务调度
    • 数据更新
    • 事件监听
    • 资源回收
  • 不适合:

    • 高精度动画
    • 频繁的短时任务
    • 需要立即执行的任务
    • 资源密集型操作

十一、总结

TypeScript中的定时器是处理异步任务的重要工具,但其使用需要谨慎。通过理解其底层机制,我们可以避免常见的内存泄漏和性能问题。在实际开发中,应根据具体场景选择合适的方案:

  • 简单任务使用setTimeout/setInterval
  • 动画使用requestAnimationFrame
  • 异步任务使用Promise/async/await
  • 资源管理使用WeakMap
  • 安全性考虑使用类型校验和异常处理

通过合理的设计和实践,我们可以充分利用TypeScript的类型系统,构建更加健壮和高效的定时器系统。