0

Làm sao để integrate component React vào Solid JS (với Typescript) (phần 3)

phần 1phần 2 mình giải thích làm sao để integrate component React vào một ứng dụng dùng Solid JS thông qua một ứng dụng đơn giản nhất có thể.

Ở phần 3 này mình sẽ nói về các configuration cơ bản của việc build component thông qua một ứng dụng phức tạp một xíu.

react-in-solid-threejs-shirt.png

Bạn có thể xem nó tại đây và xem source code tại đây trên Github.

Đây là app render 3D một cái áo, kèm theo settings bên dưới để điều chỉnh ánh sáng, camera, đổ bóng,... Ngoài ra bạn cũng có thể đổi màu áo và tải logo lên nữa. Nó giống như project trong video này của Javascript Mastery nhưng thêm phần setting ở bên dưới.

So với ứng dụng tối giản ở 2 phần trước đây, ứng dụng này khá lớn nên mình không thể giải thích từng dòng code được. Thay vào đó, mình sẽ tập trung vào những phần quan trọng như phần config, function mount,... Nếu bạn muốn tìm hiểu thêm về React Three Fiber, bạn có thể xem video này trên YouTube của kênh JavaScript Master và khóa học này của Anderson Mancini. Còn nếu bạn muốn biết thêm về làm sao để làm phần setting đẹp mắt kiểu biểu đồ ở bên dưới cái áo, thì xem thêm ở https://reactflow.dev/ nhé.

Thông qua ứng dụng này, mình sẽ trình bày một số yếu tố cơ bản và các cấu hình thường gặp khi bạn tạo một package riêng trong dự án monorepo, bao gồm:

  • file entry và quá trình building
  • file .d.ts
  • một số "cải tiến" cho function mount
  • ...và nhiều thứ nữa

Tại sao không dùng tsc để build như mình làm ở phần 1?

Có thể bạn sẽ thấy cách dùng tsc mà mình trình bày ở phần 1 hơi... đơn giản quá mức. Chắc hẳn phải có một công cụ hoặc workflow nào đó... "tiêu chuẩn" hơn để giúp build một thư viện chứ nhỉ.

Thực ra thì mình thấy dùng tsc cũng không có vấn đề gì cả. Nó phụ thuộc vào từng dự án. Bạn càng sử dụng ít công cụ và càng đơn giản bao nhiêu thì dự án càng dễ hiểu và dễ bảo trì bấy nhiêu. Ngược lại bạn càng sử dụng dùng nhiều công cụ và workflow phức tạp bao nhiêu, thì bạn sẽ càng khó config và bảo trì bấy nhiêu.

Tuy nhiên thì cách mà sử dụng tsc như mình làm ở phần 1 có một vài điểm hạn chế:

  • Làm sao để sử dụng CSS, Module CSS, TailwindCSS?
  • tsc thực ra chỉ biết compile JSX theo cách của React, nên nếu bạn viết một component bằng Vue, Solid, hay Preact,... bạn không dùng cách này được
  • ...và còn nhiều vấn đề khác nữa

Để giải quyết các điểm hạn chế này, có một thư viện khá nổi tiếng tên là tsup được thiết kế chuyên để giúp bạn build một thư viện. Tsup cũng có rất nhiều những bài viết và tutorial khác hướng dẫn chi tiết làm sao để sử dụng nó.

Tuy nhiên ở bài viết này mình sẽ hướng dẫn bạn sử dụng một công cụ khác tên là Rslib.

Tại sao mình lại chọn Rslib?

Đầu tiên là vì Rslib thực sự rất tốt trong việc nó cần làm. Nó chạy nhanh, tương đối dễ để config, và support khá nhiều thứ cần trong việc viết và build một thư viện. Ví dụ, vì bên trong Rslib sử dụng LightningCSS, bạn có thể dùng CSS, CSS Module và PostCSS mà không cần phải config thêm gì cả. Điều này có nghĩa là để sử dụng TailwindCSS tất cả những gì bạn cần làm là install Tailwind vào và tạo file postcss.config.ts như sau:

export default { plugins: { "@tailwindcss/postcss": {} } };

Ngoài ra thì ngay cả với những project lớn thì Rslib cũng rất nhanh vì bên trong nó sử dụng Rspack.

Lý do thứ hai mình chọn viết về Rslib là vì Rslib tương đối mới (mới "ra mắt" vào khoảng tháng 8/2024) nên hiện giờ không có nhiều tutorial hướng dẫn về công cụ này.

Setup ứng dụng

App này của mình sẽ bao gồm 3 ứng dụng nhỏ:

  • react-shirt: render 3D cái áo
  • react-flow: phần setting điều chỉnh camera, ánh sáng,... bên dưới cái áo
  • solid-project: app chính, render sidebar, react-shirt và react-flow

image.png

Thông qua phần setting của ứng dụng react-flow bạn có thể chỉnh sửa cường độ (intensity) của ambient và randomize light, số lượng frames của accumulate shadow, và camera lùi xa hay tiến gần (z-axis) vào cái áo. Khi những setting này bị thay đổi, data setting sẽ được chuyển từ react-flow lên app chính solid-project xuống ứng dụng react-shirt (ứng dụng mà render cái áo).

App chính solid-project sẽ chứa global store cho cả project này.

Đây là cấu trúc file cơ bản của cả project này:

image.png

Tiếp theo mình sẽ nói một chút về phần setup. Với phần setup này mình làm tương tự như phần 1 thôi.

Đầu tiên mình tạo folder mới tên là threejs-shirt-solid-in-react.

Tiếp theo, để tạo file package.json ở thư mục gốc, mình chạy:

pnpm init

Tiếp theo, mình tạo file pnpm-workspace.yaml như sau:

packages:
  - "packages/solid-project"
  - "packages/react-shirt"
  - "packages/react-flow"

Tiếp theo mình tạo folder packages chứa 3 app: solid-project, react-shirt, và react-flow.

Bây giờ mình sẽ nói thêm một chút về react-flow.

react-flow nhận vào các data init như ambientLightIntensity, randomizeLightIntensity,... từ ứng dụng chính solid-project và chuyển data lên solid-project thông qua callbacks như onAmbientLightingIntensityChange, onRandomizeLightIntensityChange,...

image.png

Mình tạo ứng dùng react-flow dùng Rslib. Ở folder packages, mình chạy pnpm create rslib@latest và chọn config như sau:

pnpm create rslib@latest

◆  Create Rslib Project
│
◇  Project name or path
│  react-flow
│
◇  Select template
│  React
│
◇  Select language
│  TypeScript
│
◇  Select development tools (Use <space> to select, <enter> to continue)
│  none
│
◇  Select additional tools (Use <space> to select, <enter> to continue)
│  none

rslib-generate-default.png

Với configuration như trên thì Rslib sẽ generate ra project như thế này: folder src với file index.tsx, Button.tsxbutton.css như một ví dụ, file .gitignore cho git, file package.json, 1 file README.md, file tsconfig.json để config Typescript, và file rslib.config.ts để config Rslib.

Giải thích về config của Rslib

File config rslib.config.ts mặc định trông sẽ thế này:

import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rslib/core';

export default defineConfig({
  source: {
    entry: {
      index: ['./src/**'],
    },
  },
  lib: [
    {
      bundle: false,
      dts: true,
      format: 'esm',
    },
  ],
  output: {
    target: 'web',
  },
  plugins: [pluginReact()],
});

Mình sẽ giải thích những config trong file này nghĩa là gì.

Setting bundle và entry

Đầu tiên hãy nói về dòng config source.entry.index = ['./src/**'].

source: {
  entry: {
    index: ['./src/**'],
  },
},

source.entry.index = ['./src/**'] nghĩa là Rslib coi tất cả những file trong thư mục /src như file entry để compile. Nếu bạn config như thế này:

source: {
  entry: {
    index: ['./src/index.tsx'],
  },
},

...nghĩa là Rslib chỉ coi mỗi file index.tsx như file entry và không quan tâm đến những file còn lại.

image.png

Tiếp theo là config lib.bundle:

lib: [
    {
      bundle: false,
      ...
    },
  ],

Config này nghĩa là Rslib sẽ không bundle tất cả mọi file mà nó compile thành 1 file duy nhất.

Ví dụ, chẳng hạn bạn có file index.tsx import file Label.tsxButton.tsx. Nếu bạn config là bundle: false, bạn sẽ thấy Rslib compile ra trong thư mục dist 3 file: index.js, label.jsbutton.js. Nếu bạn config là bundle: true, bạn sẽ thấy chỉ có 1 file index.js mà mọi thứ được bundle hết vào.

image.png

Bây giờ hãy kết hợp 2 setting này vào với nhau:

source: {
  entry: {
      index: ['./src/**'],
    },
},
lib: [
  {
    bundle: false,
    ...
  },
]

Điều này có nghĩa là Rslib sẽ compile tất cả những file trong thư mục src và không bundle chúng lại với nhau. Bây giờ nếu bạn chỉnh source.entry.index = ['./src/index.tsx'] nhưng giữ nguyên lib.bundle = false như thế này:

source: {
  entry: {
      index: ['./src/index.tsx'],
    },
},
lib: [
  {
    bundle: false,
    ...
  },
]

...nó sẽ thành thảm họa. Setting thế này nghĩa là Rslib sẽ chỉ compile file index.tsx nhưng bỏ qua tất cả những file mà index.tsx import.

image.png

Khi nào thì mình muốn bundle tất cả mọi thứ thành 1 file và khi nào mình không muốn bundle?

Ví dụ chẳng hạn như bạn phát triển một thư viện component cho React. Bạn có rất nhiều component như Button, Label, Input, Card,... nhưng người dùng thư viện của bạn chỉ cần mỗi component Button. Nếu bạn bundle tất cả mọi thứ vào với nhau, người dùng sẽ không có lựa chọn nào ngoài việc import tất cả mọi thứ - bất chấp họ chỉ dùng mỗi component Button. Nếu bạn không bundle tất cả mọi thứ vào với nhau, người dùng của bạn có thể có lựa chọn chỉ cần import mỗi file Button.tsx cùng với tất cả dependency của file này - thay vì phải import tất cả mọi thứ.

Việc người dùng chỉ bundle những import cần thiết vào trong ứng dụng cuối cùng này của họ gọi là tree-shaking. Bằng việc cho phép người dùng import từng file một, mình giúp người dùng dùng tree-shaking để file js cuối cùng của họ nhỏ hơn.

Setting dts

Tiếp theo là setting lib.dts = true. Một project biết type, props của một component hay kết quả trả về của function của một project khác thông qua file .d.ts.

image.png

Ví dụ, nếu mình có component Card như thế này:

interface Props {
  header?: React.ReactNode;
  body?: React.ReactNode;
}

export const Card: React.FC<Props> = (props) => {
	...
};

...thì đây sẽ là file .d.ts được generate ra tương ứng:

interface Props {
    header?: React.ReactNode;
    body?: React.ReactNode;
}

export declare const Card: React.FC<Props>;

Nếu một project khác sử dụng component Card của mình, project đó sẽ biết component Card này có 2 props là header and body với type là ReactNode.

Như vậy setting lib.dts = true:

  lib: [
    {
      ...
      dts: true,
    },
  ],

...nghĩa là Rslib sẽ generate những file .d.ts tương ứng cho từng file .tsx, .ts cho mình.

Setting format esm

Bạn có nhớ là có 2 cách để import file trong Javascript không? Một cách là dùng require còn cách còn lại dùng import.

require là cách gọi là CommonJS cũ mà NodeJS dùng, còn import gọi là cách gọi là ESM mới hơn mà các trình duyệt ngày nay sử dụng. So với CommonJS, ESM có nhiều điểm lợi, ví dụ như với ESM mình có thể sử dụng tree shaking mà mình nói ở trên đây. Vì thế, mình sẽ sử dụng ESM:

lib: [
  {
    ...
    format: 'esm',
  },
],

Setting output target web

Tiếp theo là setting output.target = web.

output: {
  target: 'web',
}

Để xây dựng một thư viện dành cho frontend, có nhiều vấn đề mình phải giải quyết. Một trong số đó là vấn đề về CSS. Một thư viện dành cho Backend (NodeJS) không cần phải giải quyết vấn đề CSS, SCSS, CSS Module, PostCSS, minimize và transform CSS,... nhưng một thư viện dành cho frontend cần phải giải quyết những thứ đó.

output.target = web nghĩa là Rslib sẽ giải quyết các vấn đề liên quan đến việc build cho frontend.

Setting plugin React

Cuối cùng là setting plugin React:

{
  ...
  plugins: [pluginReact()],
}

Một trong những nhiệm vụ chính của những công cụ hỗ trợ phát triển một thư viện như Rslib là compile những thứ đặc biệt của framework (ví dụ như JSX của React chẳng hạn) thành Javascript thuần. Plugin pluginReact() giúp Rslib giải quyết làm việc này

Ví dụ như thay vì build một component React bạn build một component Solid chẳng hạn, thì setting của bạn trông sẽ thế này:

plugins: [
  pluginBabel({
    include: /\.(?:jsx|tsx)$/,
  }),
  pluginSolid(),
],

Optional: tại sao với SolidJS bạn lại cần thêm pluginBabel?

Babel và SWC cơ bản đều làm một việc: compile những file ts, tsx, jsx,... và nhiều những ngôn ngữ đặc trưng cho từng framework khác nữa thành Javascript thuần (Vanilla Javascript). Ví dụ như bạn muốn tạo ra một ngôn ngữ hay framework của riêng mình, bạn có thể "dạy" Babel và SWC compile thành Javascript thuần bằng cách tự viết một plugin của chính mình.

Theo mặc định thì Rslib sử dụng SWC vì SWC rất nhanh. Nhưng tại vì tác giả của Solid JS mới chỉ viết mỗi plugin dành cho Babel chứ chưa viết dành cho SWC, mình cần bảo Rslib cài đặt Babel rồi dùng Solid plugin cho Babel để compile.

Sử dụng Tailwind CSS với Rslib

Tailwind CSS sử dụng PostCSS để tìm tất cả những class mà bạn sử dụng rồi thêm những class đó vào file CSS. Vì Rslib sử dụng LightningCSS mà LightningCSS mặc định đã hỗ trợ PostCSS, mình chỉ cần cài thêm một vài dependency đặc trưng của TailwindCSS rồi config thêm ở file config postcss.config.ts là được

Đầu tiên, hãy install TailwindCSS:

pnpm install @tailwindcss/postcss tailwindcss

Tiếp theo, hãy tạo file postcss.config.ts với setting như sau:

export default { plugins: { "@tailwindcss/postcss": {} } };

Tiếp theo như documentation của TailwindCSS, ở file CSS chính (index.css hay main.css chẳng hạn), hãy thêm dòng sau:

@import "tailwindcss";

Vậy là xong rồi! Bây giờ mình có thể sử dụng Tailwind được rồi.

Optional: setting dts abort on error

Theo mặc định setting lib.dts = true nghĩa là Rslib sẽ generate file .d.ts cho mình. Tuy nhiên điều đó cũng có nghĩa là khi lỗi type xảy ra, Rslib cũng sẽ dừng compile.

Ví dụ như mình bảo Rslib compile đoạn code này:

interface Options {
    fullscreen?: boolean;
}

const options: Options = {
	fullScreen: true,
	hiThere: false // <-- type error here: 'hithere' does not exist in type 'Options'
}

Mặc dù đoạn code trên hoàn toàn có thể compile thành Javascript một cách bình thường và chạy không lỗi chút nào cả, lỗi type này sẽ làm cho Rslib dừng compile.

Mình nghĩ điều này khá khó chịu khi đang code. Khi mình code mình muốn thử nghiệm và nghịch ngợm trước khi đảm bảo mọi thứ đều phải đúng từ A-Z mỗi lần bấm Ctrl + S.

Vì vậy mình chỉnh config lib.dts = true thành:

  lib: [
    {
      ...
      dts: {
		abortOnError: false,
	  },
    },
  ],

Config trên có nghĩa là khi có lỗi type xảy ra, Rslib sẽ vẫn compile mọi thứ cho mình.

Làm sao để vừa chạy như một thư viện vừa có thể chạy một cách độc lập

Quay lại vấn đề ở phần 1 mình sử dụng tsc để compile thư viện của mình thành Javascript thuần đồng thời sử dụng Vite để chạy component React một cách độc lập nếu cần. Để làm việc này mình tạo 2 file entry: index.tsx cho tscmain.tsx cho Vite.

image.png

Ở phần này mình cũng sẽ làm tương tự như vậy: mình sẽ sử dụng Rslib để compile và Rsbuild để chạy nó một cách độc lập. (Rsbuild cũng giống như Vite hay CRA vậy, để chạy một app dùng Solid, React, Svelt,...)

image.png

Cài đặt Rsbuild

Đầu tiên, hãy cài đặt Rsbuild. Ở trong folder react-flow, chạy:

pnpm install @rsbuild/core

Tiếp theo, hãy bảo Rsbuild sử dụng file main.tsx là file entry bằng cách config trong file rsbuild.config.ts như thế này:

import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";

export default defineConfig({
  plugins: [pluginReact()],
  source: {
    entry: {
      index: "./src/main.tsx",
    },
  }
});

Ngoài setting cơ bản trên ra, mình sẽ bảo Rsbuild output vào folder dist-dev thay vì mặc định là folder dist:

import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";

export default defineConfig({
  plugins: [pluginReact()],
  source: {
    entry: {
      index: "./src/main.tsx",
    },
  },
  output: {
    distPath: {
      root: "dist-dev",
    },
  },
});

Mình thêm config này vì mỗi Rsbuild chạy, mặc định nó sẽ xóa tất cả những file trong thư mục dist. Ở trong trường hợp của mình, vì cả Rslib và Rsbuild đều output ra cùng một thư mục dist, nếu bạn chạy Rslib và Rsbuild cùng một lúc, Rsbuild sẽ xóa tất cả những file build của Rslib.

Config Rslib ignore file main.tsx

Tiếp theo, hãy "bảo" Rslib file main.tsx:

import { pluginReact } from "@rsbuild/plugin-react";
import { defineConfig } from "@rslib/core";

export default defineConfig({
    source: {
        entry: {
            index: ["./src/**"],
        },
        exclude: ["./src/main.tsx"],
    },
    ...
})

File main.tsx là file entry chỉ cần thiết để cho Rsbuild chạy một cách độc lập. Mình sẽ không muốn Rslib compile cả file này.

Cải tiến cho function mount

Vấn đề render nhiều lần

Function mount có thể bị gọi rất nhiều lần tại vì component ở app chính bị re-render. Vì thế mỗi lần gọi function mount, mình sẽ unmount tất cả những root ở lần gọi trước rồi re-mount ở div mới:

let root: ReactDOM.Root | undefined = undefined;

export const mount = (rootEl?: Element) => {
    // If not provided with a component to render, return
    if (!rootEl) {
        return;
    }

    // If already render before, unmount it
    if (root) {
        root.unmount();
    }
  
    // Render on root element
    root = ReactDOM.createRoot(rootEl);
    root.render(
        <React.StrictMode>
            <App />
        </React.StrictMode>,
    );
};

Option để render full screen

Ở function mount của project react-flow, mình sử dụng react-flow để render trong cái div mà nó nhận vào. Việc quy định chiều cao và chiều rộng cho div này sẽ phụ thuộc vào ứng dụng Solid chính. Tuy nhiên khi mình chạy project này một cách độc lập, cái div được render lên sẽ là div #root mà div này không có chiều rộng và chiều cao.

Nếu bạn thử chạy project react-flow một cách độc lập, bạn sẽ thấy màn hình trắng xóa với console có warning thế này: [React Flow]: The React Flow parent container needs a width and a height to render the graph.

react-shirt-with-no-width-height.png

Vì vậy ở function mount mình sẽ thêm option cho phép render full screen nếu cần. Ở file index.tsx hãy chỉnh sửa thêm vào thế này:

// Options (fullscreen: for running this app independently)
interface Options {
  fullscreen?: boolean;
}

// Mount
let root: ReactDOM.Root | undefined = undefined;

export const mount = (
  rootEl: Element | null | undefined,
  props?: Callbacks & InitialValue,
  options?: Options,
) => {
  // If not provided with a component to render, return
  if (!rootEl) {
    return;
  }

  // If already render before, unmount it
  if (root) {
    root.unmount();
  }

  // Options
  let AppWrapper: React.FC = App;

  if (options?.fullscreen) {
    AppWrapper = () => (
      <div className="w-screen h-screen">
        <App />
      </div>
    );
  }

  // Render on root element
  root = ReactDOM.createRoot(rootEl);

  root.render(
    <React.StrictMode>
      <AppWrapper />
    </React.StrictMode>,
  );
};

Lúc này ở file main.tsx mình có thể thêm lựa chọn full screen:

import { mount } from ".";

mount(document.querySelector("#root"), undefined, {
    fullscreen: true,
});

Kết luận

Đó là tất cả những chi tiết và configuration mà mình nghĩ là đáng lưu ý khi bạn tự build một thư viện của mình.

Mình hy vọng là với tutorial 3 phần này và project của mình trên Github, bạn có thể áp dụng cho project của mình để vừa có thể sử dụng Solid vừa có thể tận dụng ecosystem với rất nhiều thư viện hay của React.

Nếu bạn có cải tiến hay những cách khác để integrate một component React vào trong một ứng dụng Solid, hãy comment ở bên dưới nhé.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí