react-slingshot 离线优先应用设计:从架构到实现

【免费下载链接】react-slingshot React + Redux starter kit / boilerplate with Babel, hot reloading, testing, linting and a working example app built in 【免费下载链接】react-slingshot 项目地址: https://gitcode.com/gh_mirrors/re/react-slingshot

在移动互联网时代,用户对应用的可用性要求越来越高。即使在网络不稳定或完全断网的情况下,用户仍然希望能够正常使用应用的核心功能。离线优先(Offline-First)设计理念应运而生,它确保应用在任何网络环境下都能提供一致的用户体验。本文将以 react-slingshot 项目为基础,探讨如何设计和实现一个离线优先的 React 应用,从架构设计到具体代码实现,帮助开发者构建更健壮、更可靠的前端应用。

离线优先架构设计

离线优先应用的核心在于数据的本地存储和同步机制。在 react-slingshot 项目中,我们可以基于现有的 Redux 架构,引入 IndexedDB 或 localStorage 作为本地数据存储方案,并通过 Service Worker 实现资源缓存和离线访问。

数据流程设计

离线优先应用的数据流程相比传统在线应用更为复杂,需要考虑本地数据与服务器数据的同步问题。以下是一个典型的离线优先数据流程:

  1. 用户在离线状态下进行操作,数据首先被存储在本地数据库中。
  2. 应用记录用户的操作日志,标记哪些数据需要同步到服务器。
  3. 当网络恢复时,应用自动将本地变更同步到服务器。
  4. 服务器处理同步请求,并将结果返回给客户端。
  5. 客户端根据服务器返回的结果更新本地数据。

在 react-slingshot 项目中,我们可以利用 Redux 的 action 和 reducer 机制来管理离线数据流程。例如,我们可以创建专门的 action 来处理本地存储和同步逻辑,如 saveFuelSavingsOfflinesyncFuelSavings

状态管理设计

离线优先应用的状态管理需要考虑多种场景,如在线/离线状态切换、数据同步状态等。我们可以在 Redux store 中添加相应的状态字段来跟踪这些信息。

// src/reducers/initialState.js
export default {
  fuelSavings: {
    // 现有状态字段...
    isOffline: false,
    syncStatus: 'idle', // 'idle' | 'syncing' | 'synced' | 'error'
    pendingSync: []
  }
};

本地存储实现

在 react-slingshot 项目中,我们可以使用 IndexedDB 作为本地数据存储方案。IndexedDB 提供了比 localStorage 更大的存储容量和更强大的查询能力,适合存储复杂的应用数据。

IndexedDB 封装

我们可以创建一个 IndexedDB 封装模块,提供简单的 API 来操作本地数据库。

// src/utils/localDB.js
export const initDB = () => {
  return new Promise((resolve, reject) => {
    const request = window.indexedDB.open('FuelSavingsDB', 1);
    
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('fuelSavings')) {
        db.createObjectStore('fuelSavings', { keyPath: 'id', autoIncrement: true });
      }
    };
    
    request.onsuccess = (event) => {
      resolve(event.target.result);
    };
    
    request.onerror = (event) => {
      reject(event.target.error);
    };
  });
};

export const saveToDB = async (data) => {
  const db = await initDB();
  const transaction = db.transaction('fuelSavings', 'readwrite');
  const store = transaction.objectStore('fuelSavings');
  
  return new Promise((resolve, reject) => {
    const request = store.add({ ...data, timestamp: new Date().toISOString() });
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

export const getFromDB = async () => {
  const db = await initDB();
  const transaction = db.transaction('fuelSavings', 'readonly');
  const store = transaction.objectStore('fuelSavings');
  
  return new Promise((resolve, reject) => {
    const request = store.getAll();
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
};

结合 Redux 中间件

我们可以创建一个 Redux 中间件来处理离线操作,自动将特定的 action 数据保存到本地数据库。

// src/middleware/offlineMiddleware.js
import { saveToDB } from '../utils/localDB';
import { SAVE_FUEL_SAVINGS } from '../constants/actionTypes';

export default store => next => action => {
  const result = next(action);
  
  // 检查是否处于离线状态
  if (!navigator.onLine && action.type === SAVE_FUEL_SAVINGS) {
    saveToDB(action.settings)
      .then(id => {
        console.log('Saved to offline DB with id:', id);
        // 可以在这里 dispatch 一个 action 来更新 pendingSync 状态
        store.dispatch({
          type: 'ADD_TO_PENDING_SYNC',
          payload: { id, data: action.settings }
        });
      })
      .catch(error => {
        console.error('Failed to save to offline DB:', error);
      });
  }
  
  return result;
};

Service Worker 实现

Service Worker 是实现离线优先应用的关键技术,它可以在后台缓存静态资源和 API 响应,使应用在离线状态下仍然可以访问。

Service Worker 注册

我们需要在应用入口处注册 Service Worker。

// src/index.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then(registration => {
        console.log('ServiceWorker registration successful');
      })
      .catch(err => {
        console.log('ServiceWorker registration failed:', err);
      });
  });
}

资源缓存策略

Service Worker 可以缓存应用所需的静态资源,如 HTML、CSS、JavaScript 文件等。我们可以使用 Workbox 库来简化 Service Worker 的编写。

// service-worker.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';

// 预缓存构建产物
precacheAndRoute(self.__WB_MANIFEST);

// 缓存 API 请求
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
  })
);

离线优先组件实现

在 react-slingshot 项目中,我们可以以 FuelSavingsForm 组件为基础,添加离线支持功能。

修改 FuelSavingsForm 组件

我们需要修改 FuelSavingsForm 组件,使其能够在离线状态下正常工作,并向用户显示离线状态提示。

// src/components/FuelSavingsForm.js
import React, { useState, useEffect } from 'react';
import { func } from 'prop-types';
import FuelSavingsResults from './FuelSavingsResults';
import FuelSavingsTextInput from './FuelSavingsTextInput';
import { fuelSavings } from '../types';

const FuelSavingsForm = ({ fuelSavings, onSaveClick, onChange }) => {
  const [isOffline, setIsOffline] = useState(!navigator.onLine);
  
  useEffect(() => {
    const handleOnlineStatusChange = () => {
      setIsOffline(!navigator.onLine);
    };
    
    window.addEventListener('online', handleOnlineStatusChange);
    window.addEventListener('offline', handleOnlineStatusChange);
    
    return () => {
      window.removeEventListener('online', handleOnlineStatusChange);
      window.removeEventListener('offline', handleOnlineStatusChange);
    };
  }, []);
  
  return (
    <div>
      <h2>Fuel Savings Analysis</h2>
      {isOffline && (
        <div className="offline-badge" style={{ 
          backgroundColor: '#ff4444', 
          color: 'white', 
          padding: '5px', 
          borderRadius: '3px', 
          marginBottom: '10px' 
        }}>
          离线模式 - 数据将在网络恢复后同步
        </div>
      )}
      <table>
        {/* 现有表单内容... */}
        <tbody>
          <tr>
            <td><label htmlFor="newMpg">New Vehicle MPG</label></td>
            <td><FuelSavingsTextInput onChange={onChange} name="newMpg" value={fuelSavings.newMpg}/>
            </td>
          </tr>
          <tr>
            <td><label htmlFor="tradeMpg">Trade-in MPG</label></td>
            <td><FuelSavingsTextInput onChange={onChange} name="tradeMpg" value={fuelSavings.tradeMpg}/>
            </td>
          </tr>
          <tr>
            <td><label htmlFor="newPpg">New Vehicle price per gallon</label></td>
            <td><FuelSavingsTextInput onChange={onChange} name="newPpg" value={fuelSavings.newPpg}/>
            </td>
          </tr>
          <tr>
            <td><label htmlFor="tradePpg">Trade-in price per gallon</label></td>
            <td><FuelSavingsTextInput onChange={onChange} name="tradePpg" value={fuelSavings.tradePpg}/>
            </td>
          </tr>
          <tr>
            <td><label htmlFor="milesDriven">Miles Driven</label></td>
            <td>
              <FuelSavingsTextInput
                onChange={onChange}
                name="milesDriven"
                value={fuelSavings.milesDriven}/>
              miles per
              <select
                name="milesDrivenTimeframe"
                onChange={onChange}
                value={fuelSavings.milesDrivenTimeframe}>
                <option value="week">Week</option>
                <option value="month">Month</option>
                <option value="year">Year</option>
              </select>
            </td>
          </tr>
          <tr>
            <td><label>Date Modified</label></td>
            <td>{fuelSavings.dateModified}</td>
          </tr>
        </tbody>
      </table>
      
      <hr/>
      
      {fuelSavings.necessaryDataIsProvidedToCalculateSavings && <FuelSavingsResults savings={fuelSavings.savings}/>}
      <input type="submit" value="Save" onClick={onSaveClick}/>
    </div>
  );
};

FuelSavingsForm.propTypes = {
  onSaveClick: func.isRequired,
  onChange: func.isRequired,
  fuelSavings: fuelSavings.isRequired
};

export default FuelSavingsForm;

修改 Action 创建器

我们需要修改现有的 action 创建器,使其能够处理离线情况。

// src/actions/fuelSavingsActions.js
import * as types from '../constants/actionTypes';
import { getFormattedDateTime } from '../utils/dates';
import { saveToDB } from '../utils/localDB';

export function saveFuelSavings(settings) {
  return function (dispatch) {
    // 检查是否在线
    if (navigator.onLine) {
      // 在线状态,正常 dispatch action
      return dispatch({
        type: types.SAVE_FUEL_SAVINGS,
        dateModified: getFormattedDateTime(),
        settings
      });
    } else {
      // 离线状态,保存到本地数据库
      return saveToDB(settings)
        .then(id => {
          console.log('Saved to offline DB with id:', id);
          // 仍然 dispatch 一个 action 来更新 UI,但不发送到服务器
          return dispatch({
            type: types.SAVE_FUEL_SAVINGS_OFFLINE,
            dateModified: getFormattedDateTime(),
            settings,
            offlineId: id
          });
        });
    }
  };
}

// 其他 action 创建器...

数据同步实现

数据同步是离线优先应用中最复杂的部分之一,需要处理各种边缘情况,如网络中断、数据冲突等。

同步策略

我们可以实现一个简单的基于时间戳的同步策略:

  1. 为每个数据记录添加时间戳和版本号。
  2. 同步时,客户端将本地修改的数据发送到服务器。
  3. 服务器根据时间戳和版本号来决定是否接受客户端的修改。
  4. 如果发生冲突(即同一数据在客户端和服务器都被修改),可以采用乐观并发控制或提示用户手动解决冲突。

同步实现

// src/utils/syncService.js
import { getFromDB } from './localDB';

export const syncFuelSavings = async () => {
  if (!navigator.onLine) return false;
  
  try {
    const offlineData = await getFromDB();
    
    if (offlineData.length === 0) return true;
    
    // 发送数据到服务器
    const response = await fetch('/api/sync-fuel-savings', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(offlineData)
    });
    
    if (response.ok) {
      // 同步成功,清除本地 pending 数据
      const db = await initDB();
      const transaction = db.transaction('fuelSavings', 'readwrite');
      const store = transaction.objectStore('fuelSavings');
      
      await Promise.all(offlineData.map(item => 
        store.delete(item.id)
      ));
      
      return true;
    } else {
      console.error('Sync failed:', response.statusText);
      return false;
    }
  } catch (error) {
    console.error('Sync error:', error);
    return false;
  }
};

// 监听网络恢复事件
window.addEventListener('online', () => {
  console.log('Network restored, trying to sync...');
  syncFuelSavings()
    .then(success => {
      if (success) {
        // 可以 dispatch 一个 action 来通知用户同步成功
        store.dispatch({ type: 'SYNC_SUCCESS' });
      }
    });
});

离线优先应用测试

离线优先应用的测试需要考虑多种场景,包括在线、离线、网络波动等。我们可以使用 Chrome 开发者工具的 "Network" 面板来模拟不同的网络状况,测试应用的离线表现。

测试场景

  1. 在线状态下的正常操作。
  2. 离线状态下的数据录入和存储。
  3. 网络恢复后的自动同步。
  4. 数据冲突处理。

自动化测试

我们可以使用 Jest 和 React Testing Library 来编写自动化测试,验证离线优先功能的正确性。

// src/components/FuelSavingsForm.spec.js
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import FuelSavingsForm from './FuelSavingsForm';
import { Provider } from 'react-redux';
import configureStore from '../store/configureStore';

describe('FuelSavingsForm offline mode', () => {
  let store;
  
  beforeEach(() => {
    // Mock IndexedDB
    global.indexedDB = require('fake-indexeddb');
    global.IDBKeyRange = require('fake-indexeddb/lib/FDBKeyRange');
    
    store = configureStore();
  });
  
  it('should show offline badge when offline', () => {
    // Mock navigator.onLine
    Object.defineProperty(navigator, 'onLine', {
      value: false,
      writable: true
    });
    
    const { getByText } = render(
      <Provider store={store}>
        <FuelSavingsForm 
          fuelSavings={store.getState().fuelSavings}
          onSaveClick={() => {}}
          onChange={() => {}}
        />
      </Provider>
    );
    
    expect(getByText('离线模式 - 数据将在网络恢复后同步')).toBeInTheDocument();
  });
  
  // 更多测试...
});

总结与展望

离线优先应用设计是提升用户体验的重要手段,尤其对于移动应用和网络不稳定环境下的应用。通过本文的介绍,我们了解了如何基于 react-slingshot 项目构建离线优先应用,包括架构设计、本地存储、Service Worker 实现、数据同步等关键技术点。

未来,随着 Web 技术的不断发展,离线优先应用的开发将变得更加简单。例如,Background Sync API 和 Periodic Background Sync API 可以进一步简化数据同步逻辑,而 Workbox 等库的不断完善也将降低 Service Worker 的使用门槛。

作为开发者,我们应该始终关注用户体验,将离线优先设计理念融入到应用开发的早期阶段,而不是作为后期的补充功能。只有这样,才能构建出真正健壮、可靠的前端应用,为用户提供更好的使用体验。

希望本文能够帮助开发者更好地理解和实现离线优先应用。如果你有任何问题或建议,欢迎在评论区留言讨论。让我们一起构建更好的 Web 应用!

【免费下载链接】react-slingshot React + Redux starter kit / boilerplate with Babel, hot reloading, testing, linting and a working example app built in 【免费下载链接】react-slingshot 项目地址: https://gitcode.com/gh_mirrors/re/react-slingshot

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐