Modèles Avancés
Cette page couvre les modèles d'utilisation avancés et les techniques pour construire des applications sophistiquées avec les composables Nuxt Tauri.
Composition de Composables
Création de Composables Personnalisés
Construisez des composables de niveau supérieur en combinant les composables de base :
typescript
// composables/useFileManager.ts
export function useFileManager() {
const {
data: files,
pending,
error,
execute: refreshFiles,
} = useTauriInvoke<FileItem[]>("list_files", {}, { immediate: true });
const { emit: emitFileEvent } =
useTauriEvent<FileSystemEvent>("file-event");
const selectedFiles = ref<string[]>([]);
const selectFile = (path: string) => {
if (!selectedFiles.value.includes(path)) {
selectedFiles.value.push(path);
}
};
const deselectFile = (path: string) => {
selectedFiles.value = selectedFiles.value.filter((f) => f !== path);
};
const deleteSelected = async () => {
for (const file of selectedFiles.value) {
await useTauriInvoke("delete_file", { path: file }).execute();
}
selectedFiles.value = [];
await refreshFiles();
};
const createFolder = async (name: string, parentPath?: string) => {
await useTauriInvoke("create_folder", { name, parentPath }).execute();
await refreshFiles();
await emitFileEvent({
type: "folder_created",
path: `${parentPath}/${name}`,
});
};
return {
files: readonly(files),
pending: readonly(pending),
error: readonly(error),
selectedFiles: readonly(selectedFiles),
selectFile,
deselectFile,
deleteSelected,
createFolder,
refreshFiles,
};
}Modèle de Gestion d'État
typescript
// composables/useAppState.ts
interface AppState {
user: User | null;
settings: AppSettings;
notifications: Notification[];
}
export function useAppState() {
const state = ref<AppState>({
user: null,
settings: defaultSettings,
notifications: [],
});
// Gestion des utilisateurs
const { execute: login } = useTauriInvoke("user_login");
const { execute: logout } = useTauriInvoke("user_logout");
const { data: userData, startListening: startUserEvents } =
useTauriEvent<UserEvent>("user-event");
// Gestion des paramètres
const { execute: saveSettings } = useTauriInvoke("save_settings");
const { data: settingsData, startListening: startSettingsEvents } =
useTauriEvent<SettingsEvent>("settings-event");
// Gestion des notifications
const { data: notificationData, startListening: startNotificationEvents } =
useTauriEvent<Notification>("notification");
// Observateurs
watch(userData, (event) => {
if (event?.type === "login") {
state.value.user = event.user;
} else if (event?.type === "logout") {
state.value.user = null;
}
});
watch(settingsData, (event) => {
if (event?.type === "updated") {
state.value.settings = {
...state.value.settings,
...event.settings,
};
}
});
watch(notificationData, (notification) => {
if (notification) {
state.value.notifications.unshift(notification);
// Garder les 100 dernières notifications
if (state.value.notifications.length > 100) {
state.value.notifications = state.value.notifications.slice(
0,
100
);
}
}
});
// Actions
const loginUser = async (credentials: LoginCredentials) => {
await login(credentials);
};
const logoutUser = async () => {
await logout();
};
const updateSettings = async (newSettings: Partial<AppSettings>) => {
await saveSettings(newSettings);
};
const dismissNotification = (id: string) => {
state.value.notifications = state.value.notifications.filter(
(n) => n.id !== id
);
};
// Initialisation
const initialize = async () => {
await Promise.all([
startUserEvents(),
startSettingsEvents(),
startNotificationEvents(),
]);
};
return {
state: readonly(state),
loginUser,
logoutUser,
updateSettings,
dismissNotification,
initialize,
};
}Modèles de Données Avancés
Pagination avec Chargement Infini
vue
<template>
<div class="infinite-list">
<div class="search-bar">
<input
v-model="searchQuery"
placeholder="Rechercher des éléments..."
@input="debouncedSearch"
/>
</div>
<div class="items-container" @scroll="handleScroll" ref="container">
<div v-for="item in allItems" :key="item.id" class="item-card">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<span class="timestamp">{{ formatDate(item.createdAt) }}</span>
</div>
<div v-if="loadingMore" class="loading-more">
Chargement d'autres éléments...
</div>
<div v-if="hasError" class="error">
Échec du chargement d'autres éléments
<button @click="loadMore">Réessayer</button>
</div>
<div v-if="hasReachedEnd" class="end-message">
Plus d'éléments à charger
</div>
</div>
</div>
</template>
<script setup>
import { debounce } from 'lodash-es'
interface ListItem {
id: string
title: string
description: string
createdAt: number
}
interface PaginatedResponse {
items: ListItem[]
hasMore: boolean
nextCursor?: string
}
const searchQuery = ref('')
const allItems = ref<ListItem[]>([])
const currentCursor = ref<string>()
const hasReachedEnd = ref(false)
const loadingMore = ref(false)
const hasError = ref(false)
const container = ref<HTMLElement>()
// Recherche avec debouncing
const {
data: searchResults,
pending: searchPending,
execute: executeSearch
} = useTauriInvoke<PaginatedResponse>('search_items')
// Charger plus d'éléments
const {
data: moreItems,
pending: loadMorePending,
error: loadMoreError,
execute: executeLoadMore
} = useTauriInvoke<PaginatedResponse>('load_more_items')
const debouncedSearch = debounce(async () => {
// Réinitialiser l'état pour une nouvelle recherche
allItems.value = []
currentCursor.value = undefined
hasReachedEnd.value = false
hasError.value = false
await executeSearch({
query: searchQuery.value,
limit: 20
})
}, 300)
// Gérer les résultats de recherche
watch(searchResults, (response) => {
if (response) {
allItems.value = response.items
currentCursor.value = response.nextCursor
hasReachedEnd.value = !response.hasMore
}
})
// Gérer les résultats de chargement supplémentaire
watch(moreItems, (response) => {
if (response) {
allItems.value.push(...response.items)
currentCursor.value = response.nextCursor
hasReachedEnd.value = !response.hasMore
loadingMore.value = false
}
})
// Gérer les erreurs de chargement supplémentaire
watch(loadMoreError, (error) => {
if (error) {
hasError.value = true
loadingMore.value = false
}
})
const loadMore = async () => {
if (loadingMore.value || hasReachedEnd.value) return
loadingMore.value = true
hasError.value = false
await executeLoadMore({
query: searchQuery.value,
cursor: currentCursor.value,
limit: 20
})
}
const handleScroll = () => {
if (!container.value) return
const { scrollTop, scrollHeight, clientHeight } = container.value
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 100
if (isNearBottom && !loadingMore.value && !hasReachedEnd.value && !hasError.value) {
loadMore()
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString()
}
// Chargement initial
onMounted(() => {
debouncedSearch()
})
</script>
<style scoped>
.infinite-list {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.search-bar {
margin-bottom: 2rem;
}
.search-bar input {
width: 100%;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
.items-container {
max-height: 600px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 8px;
}
.item-card {
padding: 1.5rem;
border-bottom: 1px solid #f0f0f0;
}
.item-card:last-child {
border-bottom: none;
}
.item-card h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.item-card p {
margin: 0 0 1rem 0;
color: #666;
}
.timestamp {
font-size: 0.875rem;
color: #999;
}
.loading-more,
.end-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error {
text-align: center;
padding: 2rem;
color: #e74c3c;
}
.error button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>Collaboration Temps Réel
vue
<template>
<div class="collaborative-editor">
<div class="editor-header">
<h2>Éditeur de Document Collaboratif</h2>
<div class="connection-status" :class="{ connected: isConnected }">
{{ isConnected ? "Connecté" : "Déconnecté" }}
</div>
</div>
<div class="users-online">
<h4>Utilisateurs en ligne ({{ onlineUsers.length }})</h4>
<div class="user-list">
<div
v-for="user in onlineUsers"
:key="user.id"
class="user-badge"
:style="{ backgroundColor: user.color }"
>
{{ user.name }}
</div>
</div>
</div>
<div class="editor-container">
<textarea
v-model="documentContent"
@input="handleContentChange"
@selectionchange="handleSelectionChange"
class="editor"
placeholder="Commencez à taper..."
/>
<div class="cursors-overlay">
<div
v-for="cursor in otherUserCursors"
:key="cursor.userId"
class="remote-cursor"
:style="{
top: cursor.line * 20 + 'px',
left: cursor.column * 8 + 'px',
borderColor: cursor.color,
}"
>
<span
class="cursor-label"
:style="{ backgroundColor: cursor.color }"
>
{{ cursor.userName }}
</span>
</div>
</div>
</div>
<div class="status-bar">
<span>Lignes : {{ lineCount }}</span>
<span>Caractères : {{ characterCount }}</span>
<span
>Dernière sauvegarde :
{{ lastSaved ? formatTime(lastSaved) : "Jamais" }}</span
>
</div>
</div>
</template>
<script setup>
import { debounce } from 'lodash-es'
interface User {
id: string
name: string
color: string
}
interface CursorPosition {
userId: string
userName: string
color: string
line: number
column: number
}
interface DocumentOperation {
type: 'insert' | 'delete' | 'replace'
position: number
content?: string
length?: number
userId: string
timestamp: number
}
interface CollaborationEvent {
type: 'user_joined' | 'user_left' | 'document_operation' | 'cursor_moved' | 'document_saved'
data: any
}
const documentContent = ref('')
const onlineUsers = ref<User[]>([])
const otherUserCursors = ref<CursorPosition[]>([])
const isConnected = ref(false)
const lastSaved = ref<number>()
// Opérations de document
const { execute: sendOperation } = useTauriInvoke('send_document_operation')
const { execute: saveDocument } = useTauriInvoke('save_document')
// Événements de collaboration temps réel
const {
data: collaborationEvent,
startListening,
stopListening,
emit: emitEvent
} = useTauriEvent<CollaborationEvent>('collaboration')
// Charger le document initial
const {
data: initialDocument,
execute: loadDocument
} = useTauriInvoke<{ content: string, users: User[] }>('load_document', { documentId: 'doc1' }, { immediate: true })
// Observer le chargement du document
watch(initialDocument, (doc) => {
if (doc) {
documentContent.value = doc.content
onlineUsers.value = doc.users
isConnected.value = true
}
})
// Gérer les événements de collaboration
watch(collaborationEvent, (event) => {
if (!event) return
switch (event.type) {
case 'user_joined':
if (!onlineUsers.value.find(u => u.id === event.data.id)) {
onlineUsers.value.push(event.data)
}
break
case 'user_left':
onlineUsers.value = onlineUsers.value.filter(u => u.id !== event.data.userId)
otherUserCursors.value = otherUserCursors.value.filter(c => c.userId !== event.data.userId)
break
case 'document_operation':
applyOperation(event.data)
break
case 'cursor_moved':
updateUserCursor(event.data)
break
case 'document_saved':
lastSaved.value = event.data.timestamp
break
}
})
const applyOperation = (operation: DocumentOperation) => {
// Ne pas appliquer nos propres opérations
if (operation.userId === getCurrentUserId()) return
const content = documentContent.value
switch (operation.type) {
case 'insert':
documentContent.value =
content.slice(0, operation.position) +
operation.content +
content.slice(operation.position)
break
case 'delete':
documentContent.value =
content.slice(0, operation.position) +
content.slice(operation.position + (operation.length || 0))
break
case 'replace':
documentContent.value =
content.slice(0, operation.position) +
operation.content +
content.slice(operation.position + (operation.length || 0))
break
}
}
const updateUserCursor = (cursorData: CursorPosition) => {
const existingIndex = otherUserCursors.value.findIndex(c => c.userId === cursorData.userId)
if (existingIndex !== -1) {
otherUserCursors.value[existingIndex] = cursorData
} else {
otherUserCursors.value.push(cursorData)
}
}
const debouncedContentChange = debounce(async (newContent: string, oldContent: string) => {
// Calculer l'opération
const operation = calculateOperation(oldContent, newContent)
if (operation) {
await sendOperation(operation)
}
}, 200)
const handleContentChange = (event: Event) => {
const target = event.target as HTMLTextAreaElement
const oldContent = documentContent.value
const newContent = target.value
documentContent.value = newContent
debouncedContentChange(newContent, oldContent)
}
const handleSelectionChange = debounce(async (event: Event) => {
const target = event.target as HTMLTextAreaElement
const { selectionStart } = target
const content = target.value
// Calculer la ligne et la colonne
const beforeCursor = content.slice(0, selectionStart)
const line = beforeCursor.split('\n').length - 1
const column = beforeCursor.split('\n').pop()?.length || 0
await emitEvent({
type: 'cursor_moved',
data: {
userId: getCurrentUserId(),
userName: getCurrentUserName(),
color: getCurrentUserColor(),
line,
column
}
})
}, 100)
const calculateOperation = (oldContent: string, newContent: string): DocumentOperation | null => {
// Algorithme de diff simple - en production, utilisez une vraie bibliothèque de diff
if (newContent.length > oldContent.length) {
// Le contenu a été inséré
for (let i = 0; i < Math.min(oldContent.length, newContent.length); i++) {
if (oldContent[i] !== newContent[i]) {
return {
type: 'insert',
position: i,
content: newContent.slice(i, i + (newContent.length - oldContent.length)),
userId: getCurrentUserId(),
timestamp: Date.now()
}
}
}
// Insertion à la fin
return {
type: 'insert',
position: oldContent.length,
content: newContent.slice(oldContent.length),
userId: getCurrentUserId(),
timestamp: Date.now()
}
} else if (newContent.length < oldContent.length) {
// Le contenu a été supprimé
for (let i = 0; i < Math.min(oldContent.length, newContent.length); i++) {
if (oldContent[i] !== newContent[i]) {
return {
type: 'delete',
position: i,
length: oldContent.length - newContent.length,
userId: getCurrentUserId(),
timestamp: Date.now()
}
}
}
// Suppression à la fin
return {
type: 'delete',
position: newContent.length,
length: oldContent.length - newContent.length,
userId: getCurrentUserId(),
timestamp: Date.now()
}
}
return null
}
const lineCount = computed(() => documentContent.value.split('\n').length)
const characterCount = computed(() => documentContent.value.length)
// Sauvegarde automatique
const debouncedSave = debounce(async () => {
await saveDocument({ content: documentContent.value })
}, 2000)
watch(documentContent, debouncedSave)
// Fonctions d'aide (celles-ci viendraient de votre système d'auth)
const getCurrentUserId = () => 'current-user-id'
const getCurrentUserName = () => 'Utilisateur Actuel'
const getCurrentUserColor = () => '#3498db'
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString()
}
// Cycle de vie
onMounted(async () => {
await startListening()
})
onUnmounted(() => {
stopListening()
})
</script>
<style scoped>
.collaborative-editor {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.connection-status {
padding: 0.5rem 1rem;
border-radius: 20px;
background: #e74c3c;
color: white;
font-size: 0.875rem;
}
.connection-status.connected {
background: #27ae60;
}
.users-online {
margin-bottom: 1rem;
}
.user-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.user-badge {
padding: 0.25rem 0.75rem;
border-radius: 15px;
color: white;
font-size: 0.875rem;
font-weight: bold;
}
.editor-container {
position: relative;
margin-bottom: 1rem;
}
.editor {
width: 100%;
height: 400px;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
font-family: "Courier New", monospace;
font-size: 14px;
line-height: 20px;
resize: vertical;
}
.cursors-overlay {
position: absolute;
top: 1rem;
left: 1rem;
pointer-events: none;
z-index: 10;
}
.remote-cursor {
position: absolute;
width: 2px;
height: 20px;
border-left: 2px solid;
animation: blink 1s infinite;
}
.cursor-label {
position: absolute;
top: -20px;
left: -5px;
padding: 2px 6px;
font-size: 10px;
color: white;
border-radius: 3px;
white-space: nowrap;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.status-bar {
display: flex;
gap: 2rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.875rem;
color: #666;
}
</style>Optimisation des Performances
Cache de Commandes
typescript
// composables/useCachedInvoke.ts
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
const cache = new Map<string, CacheEntry<any>>();
export function useCachedInvoke<T>(
command: string,
args?: Record<string, unknown>,
options: { immediate?: boolean; ttl?: number } = {}
) {
const { ttl = 300000 } = options; // Par défaut 5 minutes
const cacheKey = `${command}:${JSON.stringify(args)}`;
const getCachedData = (): T | null => {
const entry = cache.get(cacheKey);
if (entry && Date.now() - entry.timestamp < entry.ttl) {
return entry.data;
}
return null;
};
const setCachedData = (data: T) => {
cache.set(cacheKey, {
data,
timestamp: Date.now(),
ttl,
});
};
const cachedData = getCachedData();
const initialData = ref<T | null>(cachedData);
const {
data: fetchedData,
pending,
error,
execute,
} = useTauriInvoke<T>(command, args, {
immediate: options.immediate && !cachedData,
});
// Mettre à jour le cache quand de nouvelles données sont récupérées
watch(fetchedData, (newData) => {
if (newData !== null) {
setCachedData(newData);
initialData.value = newData;
}
});
const refresh = async () => {
cache.delete(cacheKey);
await execute();
};
return {
data: computed(() => fetchedData.value ?? initialData.value),
pending,
error,
execute,
refresh,
};
}Opérations par Lots
typescript
// composables/useBatchOperations.ts
export function useBatchOperations() {
const pendingOperations = ref<
Array<{
command: string;
args: any;
resolve: Function;
reject: Function;
}>
>([]);
const isProcessing = ref(false);
const { execute: executeBatch } = useTauriInvoke(
"execute_batch_operations"
);
const addOperation = <T>(command: string, args?: any): Promise<T> => {
return new Promise((resolve, reject) => {
pendingOperations.value.push({ command, args, resolve, reject });
processBatch();
});
};
const processBatch = debounce(async () => {
if (isProcessing.value || pendingOperations.value.length === 0) return;
isProcessing.value = true;
const operations = [...pendingOperations.value];
pendingOperations.value = [];
try {
const results = await executeBatch({
operations: operations.map((op) => ({
command: op.command,
args: op.args,
})),
});
operations.forEach((op, index) => {
op.resolve(results[index]);
});
} catch (error) {
operations.forEach((op) => {
op.reject(error);
});
} finally {
isProcessing.value = false;
}
}, 50);
return {
addOperation,
isProcessing: readonly(isProcessing),
};
}Modèles de Test
Mocking pour Tests Unitaires
typescript
// tests/mocks/tauriMocks.ts
export const mockUseTauriInvoke = <T>(mockData: T, mockError?: Error) => {
const data = ref<T | null>(mockData);
const pending = ref(false);
const error = ref<Error | null>(mockError || null);
const execute = vi.fn().mockImplementation(async () => {
pending.value = true;
await new Promise((resolve) => setTimeout(resolve, 100));
pending.value = false;
if (mockError) {
error.value = mockError;
} else {
data.value = mockData;
}
});
return {
data: readonly(data),
pending: readonly(pending),
error: readonly(error),
execute,
refresh: execute,
};
};
export const mockUseTauriEvent = <T>() => {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const startListening = vi.fn();
const stopListening = vi.fn();
const emit = vi.fn();
// Simuler la réception d'un événement
const simulateEvent = (eventData: T) => {
data.value = eventData;
};
return {
data: readonly(data),
error: readonly(error),
startListening,
stopListening,
emit,
simulateEvent,
};
};Ces modèles avancés montrent comment construire des applications sophistiquées et prêtes pour la production en utilisant les composables Nuxt Tauri. Ils couvrent la gestion d'état, la collaboration temps réel, l'optimisation des performances et les stratégies de test.
Prochaines Étapes
- Gestion d'Erreurs - Stratégies complètes de gestion d'erreurs
- Référence API - Documentation API détaillée