MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Svelte derived store 详解:从多个 store 中派生新状态

2021-01-311.5k 阅读

Svelte 中的 Store 基础概念

在深入探讨 Svelte 的 derived store 之前,我们先来回顾一下 Svelte 中 store 的基本概念。在 Svelte 里,store 是一种用来管理状态的机制。它通过一种简单而有效的方式,让我们可以在组件之间共享数据,并且当数据发生变化时,能够自动更新依赖它的组件。

一个基本的 Svelte store 通常是一个包含 subscribe 方法的对象。这个 subscribe 方法接受一个回调函数作为参数,当 store 的值发生变化时,这个回调函数就会被调用,从而通知订阅者数据的更新。以下是一个简单的示例:

<script>
    const myStore = {
        value: 0,
        subscribe: (callback) => {
            const unsubscribe = () => {
                // 这里实际上什么也不做,因为我们没有在内部维护订阅者列表
                // 但在更复杂的场景下,可能需要移除订阅者
            };
            callback(myStore.value);
            return unsubscribe;
        }
    };

    let value;
    const unsubscribe = myStore.subscribe((newValue) => {
        value = newValue;
        console.log('The value has changed to:', value);
    });

    // 模拟数据变化
    setTimeout(() => {
        myStore.value = 1;
        myStore.subscribe((newValue) => {
            value = newValue;
            console.log('The value has changed to:', value);
        });
    }, 2000);
</script>

<p>The current value is: {value}</p>

在上述代码中,我们手动创建了一个简单的 myStore。它有一个初始值 0,并且定义了 subscribe 方法。通过调用 subscribe 方法并传入一个回调函数,我们订阅了这个 store 的变化。当我们在 setTimeout 中改变 myStore.value 时,再次调用 subscribe 方法,回调函数会被触发,从而更新 value 并在控制台打印出新的值。

Svelte 也提供了一些内置的 store 创建函数,比如 writablewritable 创建的 store 不仅有 subscribe 方法,还提供了 setupdate 方法,让我们更方便地更新 store 的值。

<script>
    import { writable } from'svelte/store';

    const count = writable(0);

    let currentCount;
    const unsubscribe = count.subscribe((newCount) => {
        currentCount = newCount;
        console.log('Count updated:', currentCount);
    });

    setTimeout(() => {
        count.set(5);
    }, 2000);

    setTimeout(() => {
        count.update((prevCount) => prevCount + 1);
    }, 4000);
</script>

<p>The current count is: {currentCount}</p>

在这个例子中,我们使用 writable 创建了一个 count store,初始值为 0。通过 subscribe 方法订阅变化,然后使用 set 方法直接设置新值为 5,并使用 update 方法基于前一个值增加 1。每次值的变化都会触发订阅的回调函数,更新 currentCount 并在控制台打印。

为什么需要 derived store

在实际的前端开发中,我们经常会遇到这样的场景:某个状态并不是独立存在的,而是依赖于其他一个或多个状态的计算结果。例如,在一个电商应用中,购物车的总价是依赖于每个商品的价格和数量来计算的。如果每个商品的价格和数量分别由不同的 store 管理,那么购物车总价就需要从这些 store 中派生出来。

如果不使用 derived store,我们可能需要手动监听每个相关 store 的变化,并在变化发生时重新计算派生状态。这样做不仅代码繁琐,而且容易出错,特别是当依赖的 store 数量较多时。

derived store 则提供了一种简洁而优雅的解决方案。它允许我们基于一个或多个 existing store 派生新的状态,并且当任何一个依赖的 store 发生变化时,自动重新计算并更新派生状态。这使得我们的代码更加模块化和易于维护,同时也提高了代码的可读性。

derived store 的基本使用

在 Svelte 中,derived 函数用于创建派生 store。它接受两个参数:第一个参数可以是单个 store 或者一个包含多个 store 的数组;第二个参数是一个回调函数,用于计算派生状态。

基于单个 store 派生

首先,让我们看一个基于单个 store 派生新状态的简单例子。假设我们有一个表示当前温度(单位为摄氏度)的 store,我们想要派生出一个表示华氏温度的 store。

<script>
    import { writable, derived } from'svelte/store';

    const celsius = writable(20);

    const fahrenheit = derived(celsius, ($celsius) => {
        return ($celsius * 1.8) + 32;
    });

    let currentFahrenheit;
    const unsubscribe = fahrenheit.subscribe((newFahrenheit) => {
        currentFahrenheit = newFahrenheit;
        console.log('Fahrenheit updated:', currentFahrenheit);
    });

    setTimeout(() => {
        celsius.set(25);
    }, 2000);
</script>

<p>The current temperature in Fahrenheit is: {currentFahrenheit}</p>

在上述代码中,我们首先使用 writable 创建了一个 celsius store,初始值为 20。然后,通过 derived 函数基于 celsius store 派生出 fahrenheit store。derived 的回调函数接受 $celsius 作为参数,$ 符号是 Svelte 中用于访问 store 当前值的语法糖。在回调函数中,我们根据摄氏度到华氏度的转换公式计算并返回华氏温度值。

当我们订阅 fahrenheit store 并在 setTimeout 中改变 celsius store 的值时,fahrenheit store 会自动重新计算并更新,从而触发订阅的回调函数,更新 currentFahrenheit 并在控制台打印新的值。

基于多个 store 派生

更常见的情况是基于多个 store 派生新状态。比如,我们有一个表示商品价格和数量的 store,要计算商品的总价。

<script>
    import { writable, derived } from'svelte/store';

    const price = writable(10);
    const quantity = writable(2);

    const total = derived([price, quantity], ([$price, $quantity]) => {
        return $price * $quantity;
    });

    let currentTotal;
    const unsubscribe = total.subscribe((newTotal) => {
        currentTotal = newTotal;
        console.log('Total updated:', currentTotal);
    });

    setTimeout(() => {
        price.set(15);
    }, 2000);

    setTimeout(() => {
        quantity.set(3);
    }, 4000);
</script>

<p>The total price is: {currentTotal}</p>

在这个例子中,我们创建了 pricequantity 两个 writable store,分别表示商品价格和数量。然后通过 derived 函数,将这两个 store 作为数组传递给第一个参数。回调函数接受解构后的 $price$quantity,计算并返回总价。

pricequantity 任何一个 store 的值发生变化时(通过 setTimeout 模拟),total store 会自动重新计算并更新,触发订阅回调,更新 currentTotal 并在控制台打印新的总价。

派生状态的初始化值

derived 函数还接受一个可选的第三个参数,用于设置派生状态的初始值。这在某些情况下非常有用,特别是当依赖的 store 初始值可能导致计算错误或者需要一个默认的派生值时。

<script>
    import { writable, derived } from'svelte/store';

    const numerator = writable(null);
    const denominator = writable(null);

    const quotient = derived([numerator, denominator], ([$numerator, $denominator]) => {
        if ($numerator === null || $denominator === null || $denominator === 0) {
            return null;
        }
        return $numerator / $denominator;
    }, null);

    let currentQuotient;
    const unsubscribe = quotient.subscribe((newQuotient) => {
        currentQuotient = newQuotient;
        console.log('Quotient updated:', currentQuotient);
    });

    setTimeout(() => {
        numerator.set(10);
        denominator.set(2);
    }, 2000);

    setTimeout(() => {
        denominator.set(0);
    }, 4000);
</script>

<p>The quotient is: {currentQuotient}</p>

在上述代码中,numeratordenominator 初始值都为 null。如果没有设置初始值,在刚开始订阅 quotient store 时,由于 numeratordenominatornull,计算会出现问题。通过设置初始值为 null,我们确保了在刚开始时 currentQuotient 也为 null,并且在计算过程中,如果 numeratordenominatornull 或者 denominator0,也返回 null,避免了潜在的错误。

处理异步操作

在实际应用中,派生状态的计算可能涉及到异步操作,比如从 API 获取数据并结合本地 store 进行计算。Svelte 的 derived 同样可以很好地处理这种情况。

假设我们有一个表示用户 ID 的 store,并且我们需要根据这个 ID 从 API 获取用户信息,并计算用户的全名(假设 API 返回的用户对象包含 firstNamelastName)。

<script>
    import { writable, derived } from'svelte/store';
    const userId = writable(1);

    const userFullName = derived(userId, async ($userId) => {
        const response = await fetch(`https://example.com/api/users/${$userId}`);
        const user = await response.json();
        return `${user.firstName} ${user.lastName}`;
    });

    let currentFullName;
    const unsubscribe = userFullName.subscribe((newFullName) => {
        currentFullName = newFullName;
        console.log('User full name updated:', currentFullName);
    });

    setTimeout(() => {
        userId.set(2);
    }, 2000);
</script>

<p>The user's full name is: {currentFullName}</p>

在这个例子中,derived 的回调函数是一个异步函数。当 userId store 的值发生变化时,会触发异步操作,从 API 获取用户信息并计算全名。derived 会自动处理异步返回的值,当 Promise 解决时,更新派生状态 userFullName,从而触发订阅回调,更新 currentFullName 并在控制台打印新的全名。

性能优化

虽然 derived store 非常方便,但在处理大量数据或者频繁变化的 store 时,可能会出现性能问题。因为每次依赖的 store 变化时,derived 的回调函数都会被执行。

为了优化性能,我们可以采取一些措施。一种方法是使用 throttledebounce 技术。例如,如果有一个频繁变化的 store,我们可以使用 debounce 来延迟计算派生状态,避免不必要的频繁计算。

<script>
    import { writable, derived } from'svelte/store';
    import { debounce } from 'lodash';

    const inputValue = writable('');

    const debouncedValue = derived(inputValue, ($inputValue) => {
        return $inputValue;
    }, '');

    const debouncedCallback = debounce((newValue) => {
        console.log('Debounced value updated:', newValue);
    }, 300);

    let currentDebouncedValue;
    const unsubscribe = debouncedValue.subscribe((newDebouncedValue) => {
        currentDebouncedValue = newDebouncedValue;
        debouncedCallback(newDebouncedValue);
    });

    setTimeout(() => {
        inputValue.set('new value 1');
    }, 1000);

    setTimeout(() => {
        inputValue.set('new value 2');
    }, 1500);

    setTimeout(() => {
        inputValue.set('new value 3');
    }, 2000);
</script>

<p>The debounced value is: {currentDebouncedValue}</p>

在上述代码中,我们使用 lodashdebounce 函数。当 inputValue store 变化时,debouncedValue 会更新,但通过 debounce 延迟了 console.log 的执行,避免了在短时间内频繁打印,从而提高了性能。

另一种优化方法是在 derived 的回调函数中进行更精细的判断,只有当真正影响派生状态的依赖发生变化时才重新计算。例如,如果派生状态只依赖于某个对象的特定属性,而不是整个对象,我们可以在回调函数中比较该属性的值,只有当该属性变化时才重新计算。

<script>
    import { writable, derived } from'svelte/store';

    const user = writable({ name: 'John', age: 30 });

    const userName = derived(user, ($user, set) => {
        let previousName = $user.name;
        set(previousName);
        return () => {
            // 清理函数,当不再需要派生状态时执行
        };
    });

    let currentUserName;
    const unsubscribe = userName.subscribe((newUserName) => {
        currentUserName = newUserName;
        console.log('User name updated:', currentUserName);
    });

    setTimeout(() => {
        user.update((prevUser) => ({...prevUser, age: 31 }));
    }, 2000);

    setTimeout(() => {
        user.update((prevUser) => ({...prevUser, name: 'Jane' }));
    }, 4000);
</script>

<p>The user's name is: {currentUserName}</p>

在这个例子中,userName 派生状态只依赖于 user 对象的 name 属性。在 derived 的回调函数中,我们记录了 previousName,并在 user 对象变化时,只有当 name 属性变化时才会更新 userName,从而减少了不必要的计算。

与组件的结合使用

derived store 在组件中使用非常方便,可以帮助我们更好地管理组件内部和组件之间的状态。

假设有一个父组件包含一个子组件,父组件管理商品的价格和数量,子组件显示商品的总价。我们可以使用 derived store 来实现这种状态管理和传递。

<!-- Parent.svelte -->
<script>
    import { writable, derived } from'svelte/store';
    import Child from './Child.svelte';

    const price = writable(10);
    const quantity = writable(2);

    const total = derived([price, quantity], ([$price, $quantity]) => {
        return $price * $quantity;
    });
</script>

<Child {total} />
<!-- Child.svelte -->
<script>
    import { readable } from'svelte/store';

    export let total;

    let currentTotal;
    const unsubscribe = total.subscribe((newTotal) => {
        currentTotal = newTotal;
    });
</script>

<p>The total price in child component is: {currentTotal}</p>

在上述代码中,父组件创建了 pricequantity store,并派生出 total store。然后将 total store 传递给子组件。子组件通过 export let 接收 total store,并订阅它来获取最新的总价并显示。

这种方式使得组件之间的状态管理更加清晰和可控,每个组件只需要关注自己相关的状态,而 derived store 则负责处理状态的派生和更新。

错误处理

在使用 derived store 时,特别是当涉及异步操作或者复杂计算时,可能会出现错误。我们需要妥善处理这些错误,以确保应用的稳定性和用户体验。

derived 的回调函数中抛出错误时,subscribe 的第二个参数可以作为错误处理函数。

<script>
    import { writable, derived } from'svelte/store';

    const numerator = writable(10);
    const denominator = writable(0);

    const quotient = derived([numerator, denominator], ([$numerator, $denominator]) => {
        if ($denominator === 0) {
            throw new Error('Division by zero');
        }
        return $numerator / $denominator;
    });

    let currentQuotient;
    const unsubscribe = quotient.subscribe(
        (newQuotient) => {
            currentQuotient = newQuotient;
            console.log('Quotient updated:', currentQuotient);
        },
        (error) => {
            console.error('Error:', error.message);
        }
    );
</script>

<p>The quotient is: {currentQuotient}</p>

在这个例子中,当 denominator0 时,derived 的回调函数抛出一个错误。通过在 subscribe 中提供第二个参数作为错误处理函数,我们可以捕获并处理这个错误,在控制台打印错误信息,而不是让应用崩溃。

如果派生状态的计算涉及异步操作,我们可以使用 try...catch 块来处理异步操作中的错误。

<script>
    import { writable, derived } from'svelte/store';

    const userId = writable(1);

    const userFullName = derived(userId, async ($userId) => {
        try {
            const response = await fetch(`https://example.com/api/users/${$userId}`);
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }
            const user = await response.json();
            return `${user.firstName} ${user.lastName}`;
        } catch (error) {
            console.error('Error fetching user:', error.message);
            return null;
        }
    });

    let currentFullName;
    const unsubscribe = userFullName.subscribe((newFullName) => {
        currentFullName = newFullName;
        console.log('User full name updated:', currentFullName);
    });
</script>

<p>The user's full name is: {currentFullName}</p>

在这个异步示例中,我们在 derived 的回调函数中使用 try...catch 块来捕获可能发生的网络错误或 JSON 解析错误。如果发生错误,我们在控制台打印错误信息,并返回 null 作为派生状态的值,以避免应用出现异常。

通过合理的错误处理,我们可以提高使用 derived store 构建的应用的健壮性,确保在各种情况下都能提供良好的用户体验。

高级应用场景

除了前面提到的基本和常见的应用场景,derived store 在一些更复杂的场景中也能发挥重要作用。

动态依赖的派生

有时候,派生状态的依赖可能不是固定的,而是根据其他状态动态变化的。例如,我们有一个表示当前选择的图表类型的 store,根据不同的图表类型,我们需要从不同的数据源(由不同的 store 表示)中派生出图表数据。

<script>
    import { writable, derived } from'svelte/store';

    const chartType = writable('bar');
    const barData = writable([1, 2, 3, 4]);
    const lineData = writable([5, 6, 7, 8]);

    const chartDataSource = derived(chartType, ($chartType) => {
        return $chartType === 'bar'? barData : lineData;
    });

    const chartData = derived(chartDataSource, ($dataSource) => {
        return $dataSource;
    });

    let currentChartData;
    const unsubscribe = chartData.subscribe((newChartData) => {
        currentChartData = newChartData;
        console.log('Chart data updated:', currentChartData);
    });

    setTimeout(() => {
        chartType.set('line');
    }, 2000);
</script>

<p>The current chart data is: {currentChartData}</p>

在这个例子中,chartDataSource 派生状态根据 chartType 的值动态选择 barDatalineData。然后 chartData 又基于 chartDataSource 派生,这样当 chartType 变化时,chartData 会自动从新的数据源派生,实现了动态依赖的派生。

复杂的多步骤派生

在一些业务场景中,派生状态可能需要经过多个步骤的计算,并且每个步骤可能依赖于不同的 store 或派生状态。

假设我们正在开发一个财务应用,需要计算公司的净利润。净利润的计算需要先从不同的 store 获取总收入、总成本和税收减免信息,经过多个步骤的计算得出。

<script>
    import { writable, derived } from'svelte/store';

    const totalRevenue = writable(1000);
    const totalCost = writable(500);
    const taxDeduction = writable(100);

    const grossProfit = derived([totalRevenue, totalCost], ([$totalRevenue, $totalCost]) => {
        return $totalRevenue - $totalCost;
    });

    const taxableIncome = derived([grossProfit, taxDeduction], ([$grossProfit, $taxDeduction]) => {
        return $grossProfit - $taxDeduction;
    });

    const netProfit = derived(taxableIncome, ($taxableIncome) => {
        // 假设税率为 20%
        return $taxableIncome * 0.8;
    });

    let currentNetProfit;
    const unsubscribe = netProfit.subscribe((newNetProfit) => {
        currentNetProfit = newNetProfit;
        console.log('Net profit updated:', currentNetProfit);
    });

    setTimeout(() => {
        totalRevenue.set(1200);
    }, 2000);

    setTimeout(() => {
        taxDeduction.set(150);
    }, 4000);
</script>

<p>The net profit is: {currentNetProfit}</p>

在这个例子中,我们首先从 totalRevenuetotalCost 派生出 grossProfit,然后结合 taxDeduction 派生出 taxableIncome,最后基于 taxableIncome 派生出 netProfit。通过这种多步骤的派生,我们可以清晰地管理复杂的业务计算逻辑,并且当任何一个相关 store 变化时,最终的 netProfit 会自动更新。

通过这些高级应用场景的示例,我们可以看到 derived store 在处理复杂状态管理和计算时的强大能力,它能够帮助我们构建灵活、可维护的前端应用。