× Giới thiệu Lịch khai giảng Tin tức Sản phẩm học viên

React Native App Targeting Mobile, Web & Desktop với Expo & Tauri

01/11/2023 01:22

Trong bài viết này, chúng tôi sẽ kiểm tra cách kết hợp các ứng dụng React Native nhắm mục tiêu vào nền tảng di động và web với khung máy tính để bàn mới có tên Tauri.

Hệ sinh thái JS đang phát triển mỗi năm, với các framework và thư viện mới được phát hành để giúp việc phát triển trở nên dễ dàng và nhanh chóng hơn.

Ngày nay, các nhà phát triển có thể nhắm mục tiêu đến các thiết bị web, thiết bị di động và thậm chí cả máy tính để bàn có cùng mã JS. Trong bài viết này, chúng tôi sẽ kiểm tra cách kết hợp các ứng dụng React Native nhắm mục tiêu vào nền tảng di động và web với khung máy tính để bàn mới có tên Tauri.

Chào! Nhưng thực ra Tauri là gì?

Tauri là một giải pháp đa nền tảng để sản xuất các ứng dụng máy tính để bàn an toàn và hiệu quả. Đó là một giải pháp thay thế cho khung JS phổ biến và trưởng thành – Electron . Tuy nhiên, hai cái này có một số khác biệt về kiến ​​trúc.

Electron cung cấp thời gian chạy Chrome và Node.js được nhúng cho phép các nhà phát triển sử dụng tất cả các API trình duyệt và nút có thể có, điều này mở ra khả năng phát triển các ứng dụng có tính năng phong phú một cách nhanh chóng và chia sẻ logic kinh doanh đó với các ứng dụng web. Tuy nhiên, đối với các sản phẩm đơn giản và nhỏ hơn, có thể hơi quá mức cần thiết nếu gộp tất cả các chức năng có thể có do Chrome cung cấp khi không cần thiết.

Mặt khác, Tauri nhúng các lượt xem web trên nền tảng (WebKit cho macOS, Webview2 cho Windows và webkit2gtk cho Linux) bên trong cửa sổ máy tính để bàn gốc được viết và quản lý bằng Rust. Điều này cho phép kích thước nhị phân của ứng dụng nhỏ hơn và có độ linh hoạt tương tự trong việc phát triển phía giao diện người dùng – hoặc quản lý những thứ như quản lý tệp, hộp thoại gốc và các bản cập nhật ứng dụng do Electron cung cấp. Trong tương lai sắp tới, Tauri có kế hoạch mở rộng sang các ứng dụng di động , điều này khiến nó trở thành một sự thay thế khá thú vị trong thế giới đa nền tảng.

Làm cách nào để bạn có thể làm cho mã React Native của mình chạy trên Tauri?

Hãy nhớ rằng chúng ta có thể sử dụng bất kỳ khung JS nào cho phần giao diện người dùng của mình trong Tauri? Điều đó có nghĩa là chúng tôi có thể sử dụng React Native Web kết hợp với gói Webpack và chia sẻ mã JS giữa các ứng dụng di động, ứng dụng web và ứng dụng nhắm mục tiêu đến thiết bị máy tính để bàn. Trong trường hợp này, chúng tôi sẽ sử dụng không gian làm việc sợi để tạo cấu trúc monorepo nhằm tách biệt các ứng dụng, logic nghiệp vụ chung và giao diện người dùng chung. Để phát triển thiết bị di động và web, chúng tôi sẽ tận dụng Expo để cấu hình sẵn thiết lập Webpack (mặc dù không bắt buộc nhưng bạn cũng có thể làm điều đó với React Native và React Native Web). Để phát triển máy tính để bàn với Tauri, chúng tôi sẽ sử dụng cùng một thiết lập Expo được cải tiến bằng Webpack có thể phân giải các tệp .tauri.[ext]. Nhờ đó, chúng ta sẽ có thể viết một số mã dành riêng cho Tauri.

Toàn bộ mã có thể được tìm thấy trong kho lưu trữ này .

Nó trông như thế nào?

Kho trưng bày được chia thành bốn gói:

  • my-expo-app- chứa phiên bản web và di động của ứng dụng React Native của chúng tôi; nó sử dụng logic kinh doanh và giao diện người dùng được chia sẻ
  • my-tauri-app-chứa ứng dụng Tauri trên máy tính để bàn; nó sử dụng logic kinh doanh và giao diện người dùng được chia sẻ
  • my-shared-bl- là mô-đun logic kinh doanh chung của chúng tôi
  • my-shared-ui-là mô-đun giao diện người dùng được chia sẻ của chúng tôi; nó hiển thị cùng một giao diện người dùng dựa trên các chức năng được hiển thị từ logic nghiệp vụ được chia sẻ

Để các gói của ứng dụng có thể sử dụng các mô-đun được chia sẻ, chúng ta cần tùy chỉnh cấu hình Metro và Webpack. Nếu bạn muốn tùy chỉnh cấu hình Webpack trong create-expo-appdự án đã khởi động, hãy kiểm tra Expo . Khi có metro.config.jssẵn webpack.config.js, chúng tôi cần áp dụng những thay đổi nhỏ để đảm bảo rằng các mô-đun dùng chung của chúng tôi sẽ được dịch mã và giải quyết đúng cách.

Tùy chỉnh Metro & Webpack

metro.config.jsTRONGmy-expo-app

const path = require('path');

const { getDefaultConfig } = require('@expo/metro-config');
const escape = require('escape-string-regexp');
const exclusionList = require('metro-config/src/defaults/exclusionList');

const defaultConfig = getDefaultConfig(__dirname);

const sharedBLPak = require('../my-shared-bl/package.json');
const sharedUIPak = require('../my-shared-ui/package.json');

const sharedBLPath = path.resolve(__dirname, '..', 'my-shared-bl');
const sharedUIPath = path.resolve(__dirname, '..', 'my-shared-ui');

const modules = Object.keys({
  ...sharedBLPak.peerDependencies,
  ...sharedUIPak.peerDependencies,
});

const blockList = exclusionList(
  modules.map(
    (m) =>
      new RegExp(`^(${escape(path.join(sharedBLPath, 'node_modules', m))})|(${escape(path.join(sharedUIPath, 'node_modules', m))})\\/.*$`)
  )
);
const extraNodeModules = modules.reduce((acc, name) => {
  acc[name] = path.join(__dirname, 'node_modules', name);
  return acc;
}, {
  '@tauri-and-expo/shared-bl': sharedBLPath,
  '@tauri-and-expo/shared-ui': sharedUIPath,
});

module.exports = {
  ...defaultConfig,

  projectRoot: __dirname,
  watchFolders: [ sharedBLPath, sharedUIPath ],

  // We need to make sure that only one version is loaded for peerDependencies
  // So we block them at the shared-expo-bl & shared-ui and alias them to the versions in example's node_modules
  resolver: {
    ...defaultConfig.resolver,

    blockList,
    extraNodeModules,
  },

  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};

webpack.config.jsTRONGmy-expo-app

const path = require('path');

const createExpoWebpackConfigAsync = require('@expo/webpack-config');

const node_modules = path.join(__dirname, 'node_modules');

const sharedBLPak = require('../my-shared-bl/package.json');
const sharedUIPak = require('../my-shared-ui/package.json');

const sharedBLPath = path.resolve(__dirname, '..', 'my-shared-bl');
const sharedUIPath = path.resolve(__dirname, '..', 'my-shared-ui');

const modules = Object.keys({
  ...sharedBLPak.peerDependencies,
  ...sharedUIPak.peerDependencies,
});

module.exports = async function (env, argv) {
  const config = await createExpoWebpackConfigAsync(env, argv);

  // Handle shared-bl and shared-ui babel transpilation
  config.module.rules.push({
    test: /\.(js|jsx|ts|tsx)$/,
    include: [ sharedBLPath, sharedUIPath ],
    use: {
      loader: 'babel-loader',
      options: {
        presets: [
          'babel-preset-expo',
        ],
      },
    },
  });

  // We need to make sure that only one version is loaded for peerDependencies
  // So we alias them to the versions in example's node_modules
  Object.assign(config.resolve.alias, {
    ...modules.reduce((acc, name) => {
      acc[name] = path.join(__dirname, 'node_modules', name);
      return acc;
    }, {}),
    '@tauri-and-expo/shared-bl': sharedBLPath,
    '@tauri-and-expo/shared-ui': sharedUIPath,
    'react': path.resolve(node_modules, 'react'),
    'react-native': path.resolve(node_modules, 'react-native-web'),
    'react-native-web': path.resolve(node_modules, 'react-native-web'),
  });

  return config;
};

webpack.config.jsTRONGmy-tauri-app

const path = require('path');

const createExpoWebpackConfigAsync = require('@expo/webpack-config');

const node_modules = path.join(__dirname, 'node_modules');

const sharedBLPak = require('../my-shared-bl/package.json');
const sharedUIPak = require('../my-shared-ui/package.json');

const sharedBLPath = path.resolve(__dirname, '..', 'my-shared-bl');
const sharedUIPath = path.resolve(__dirname, '..', 'my-shared-ui');

const modules = Object.keys({
  ...sharedBLPak.peerDependencies,
  ...sharedUIPak.peerDependencies,
});

module.exports = async function (env, argv) {
  const config = await createExpoWebpackConfigAsync(env, argv);

  // handle webpack resolver with Tauri specific files
  config.resolve.extensions = [ '.tauri.tsx', '.web.tsx', '.tsx', '.tauri.ts', '.web.ts', '.ts', 'tauri.js', '.web.js', '.js', '.json', '...' ];
  // Handle shared-bl and shared-ui babel transpilation
  config.module.rules.push({
    test: /\.(js|jsx|ts|tsx)$/,
    include: [ sharedBLPath, sharedUIPath ],
    use: {
      loader: 'babel-loader',
      options: {
        presets: [
          'babel-preset-expo',
        ],
      },
    },
  });

  // We need to make sure that only one version is loaded for peerDependencies
  // So we alias them to the versions in example's node_modules
  Object.assign(config.resolve.alias, {
    ...modules.reduce((acc, name) => {
      acc[name] = path.join(__dirname, 'node_modules', name);
      return acc;
    }, {}),
    '@tauri-and-expo/shared-bl': sharedBLPath,
    '@tauri-and-expo/shared-ui': sharedUIPath,
    'react': path.resolve(node_modules, 'react'),
    'react-native': path.resolve(node_modules, 'react-native-web'),
    'react-native-web': path.resolve(node_modules, 'react-native-web'),
  });

  return config;
};

Ở đây, chúng tôi đang đặt bí danh cho các mô-đun được chia sẻ của mình để các mô-đun ứng dụng có thể tham chiếu chúng một cách chính xác trong monorepo. Ngoài ra, chúng tôi đang xử lý các tệp dành riêng cho Tauri để ứng dụng Tauri có thể sử dụng chúng thay vì các tệp trên thiết bị di động/web.

Mã chia sẻ và dành riêng cho nền tảng

Mô-đun chia sẻ của chúng tôi giới thiệu cách chia sẻ giao diện người dùng JS và logic kinh doanh cũng như cách viết các cách triển khai khác nhau cho ứng dụng dành cho thiết bị di động và web so với ứng dụng dành cho máy tính để bàn. Mô-đun chia sẻ giao diện người dùng có một <App />thành phần duy nhất hiển thị hai nhãn, hai nút và một danh sách chứa dữ liệu API.

export const App: React.FC = () => {
  const platformTuple = useAtomValue(atom(async () => PlatformModule.getPlatform()));
  const [ photos, setPhotos ] = useAtom(atom<PhotoObject[]>([]));

  const sendNotification = React.useCallback(() => {
    NotificationModule.sendNotification('Notification from shared UI', 'How cool is that?');
  }, []);

  const fetchData = React.useCallback(async () => {
    const response = await HttpClientModule.get<PhotoObject[]>('https://jsonplaceholder.typicode.com/photos?albumId=1');

    setPhotos(response.data ?? []);
  }, [ setPhotos ]);

  return (
    <SafeAreaView style={styles.container}>
      <Text>Open up App.js to start working on your app!</Text>
      <Text>Hello from {platformTuple.platform} {platformTuple.os}</Text>
      <Button onPress={sendNotification} title="Send notification" />
      <Button onPress={fetchData} title="Fetch photos" />
      <View style={styles.list}>
        <FlashList
          data={photos}
          renderItem={({ item, index }) => {
            return (
              <View style={styles.item}>
                <View style={styles.itemElement}>
                  <Image source={ { uri: item.url } } style={styles.itemImage} />
                </View>
                <View style={styles.itemElement}>
                  <Text style={styles.itemText}>{item.title} {index}</Text>
                </View>
              </View>
            );
          }}
          estimatedItemSize={200}
        />
      </View>
      <StatusBar  />
    </SafeAreaView>
  );
};

Thành phần này sử dụng ba mô-đun dùng chung: HttpClientModuleNotificationModulePlatformModule. Đây là những bản tóm tắt trên các API có sẵn trong React Native và Tauri. Chúng ta hãy xem một trong số họ:

notification.tsx

import * as Notifications from 'expo-notifications';

import type { NotificationModuleInterface, StaticImplements } from './types';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    priority: Notifications.AndroidNotificationPriority.HIGH,
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

export class NotificationModule implements StaticImplements<NotificationModuleInterface, typeof NotificationModule> {
  static async sendNotification(title: string, body?: string) {
    const permissionsStatus = await Notifications.getPermissionsAsync();

    if (
      !permissionsStatus.granted &&
      permissionsStatus.ios?.status !== Notifications.IosAuthorizationStatus.PROVISIONAL &&
      permissionsStatus.ios?.status !== Notifications.IosAuthorizationStatus.AUTHORIZED
    ) {
      const permissionResult = await Notifications.requestPermissionsAsync();

      if (
        !permissionResult.granted &&
        permissionResult.ios?.status !== Notifications.IosAuthorizationStatus.PROVISIONAL &&
        permissionResult.ios?.status !== Notifications.IosAuthorizationStatus.AUTHORIZED
      ) {
        return;
      }
    }

    Notifications.scheduleNotificationAsync({
      content: { body, title },
      trigger: {
        seconds: 5,
      },
    });
  }
}

notification.tauri.tsx

import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/api/notification';

import type { NotificationModuleInterface, StaticImplements } from './types';

export class NotificationModule implements StaticImplements<NotificationModuleInterface, typeof NotificationModule> {
  static async sendNotification(title: string, body?: string) {
    if (!await isPermissionGranted()) {
      const permissionResult = await requestPermission();

      if (permissionResult !== 'granted') {
        return;
      }
    }

    sendNotification({ body, title });
  }
}

Ở đây, nhờ giao diện TypeScript phổ biến, chúng ta có thể khai báo cùng một lớp cho môi trường React Native và Tauri với cách triển khai khác nhau cho từng môi trường. Lớp React Native tận dụng expo-notificationsgói để hiển thị thông báo trong ứng dụng, trong khi lớp Tauri sử dụng API thông báo tích hợp. Điều này mở rộng tính linh hoạt được biết đến từ React Native, trong đó chúng ta có thể viết mã dành riêng cho nền tảng cho thế giới Tauri. Theo đó, giao diện người dùng của chúng tôi sẽ sử dụng thành công các lớp và hàm giống nhau mà không cần biết chi tiết triển khai.

Chạy mã React Native của bạn trên tất cả các thiết bị

Phát triển di động và phát triển web ngày càng trở nên phổ biến, với nhiều công ty đặt cược vào các công nghệ đa nền tảng như React Native để có tốc độ phát triển nhanh hơn trên tất cả các nền tảng mà không cần nhiều nhà phát triển. Chỉ với một vài tùy chỉnh, có thể khiến mã React Native chạy trên tất cả các thiết bị máy tính để bàn, bao gồm cả các bản phân phối Linux.