Dự án này được xây dựng bằng NextJS. Kiến trúc chính của dự án đang sử dụng React ContextAPI để thay thế cho việc sử dụng các thư viện quản lý State khác. Mục tiêu của dự án nhằm xây dựng một concept chung cho cả Web và App. Hạn chế phụ thuộc vào các thư viện State Management bên ngoài.
Vì dự án đang được xây dựng dựa bên NextJS và React ContextAPI do vậy cần người sử dụng phải nắm vững các kiến thức của 2 thành phần này trước khi bắt đầu vào với dự án này.
- Hiểu về SSR và SSG của NextJS.
- Hiểu cách routing của NextJS thông qua folder page.
- Các kiến thức cơ bản của functional component.
- Nắm các hook cơ bản của react (useState, useEffect, useRef, forwardRef,...).
- Hiểu và biết cách viết một custom hook.
- Hiểu ContextAPI hoạt động để truyền dữ liệu tới một component cụ thể, biết cách viết một Provider đơn giản bằng React ContextAPI.
-
Vì sao phải dùng Context API.
-
Cấu trúc thư mục của dự án.
-
Quản lý State (lớp Provider) trong dự án.
-
Cách sử dụng RouteProvider cho người mới. (sử dụng addRule, toUrl, push, breadcrumbs).
-
Về styling và các icons trong dự án. (sử dụng classnames và module.scss, ví dụ về cách sử dụng cx).
-
Tech stack của dự án: tailwindcss, scss, formik, styled, lodash, classnames.
-
Các quy ước chung của dự án:
7.1 Như thế nào thì sẽ define thành một Provider.
7.2 Phân biệt hook và provider.
7.3 Nên define initial language ngay từ đầu.
Việc truyền dữ liệu từ components cha xuống component con thông thường ta sẽ truyền qua props, nhưng đối với một số lượng components lồng nhau lớn thì việc này sẽ dài dòng và khó kiểm soát, sẽ có rất nhiều components đóng vai trò là con đường vận chuyển dữ liệu, thay vì trực tiếp sử dụng dữ liệu đó. Vì vậy sử dụng Context API sẽ giúp mình truyền trực tiếp dữ liệu tới component nhận và không phải thông qua các components khác.
Ví dụ:
const Context = React.createContext()
const View = () => {
const [page, setPage] = React.useState(0);
const data = bigQuery(page);
return (
<Context.Provider value={{ data }}>
<Parent />
</Context.Provider>
);
}
const Parent = () => {
return <ChildOfParent />
}
const ChildOfParent = () => {
const { data } = React.useContext(Context);
return <>{data.map((item) => { ...})}</>
}- Hệ thống folder của dự án sẽ tương tự như một hệ thống folder của một dự án NextJS, chúng ta sẽ có các folders sau:
- pages: tạo các routes cho dự án.
- providers: là context API dùng để quản lý state cho dự án.
- hooks: là các custom hook của dự án.
- gstyles: là nơi lưu trữ font, setup tailwind config, màu sắc chung, icons.
- translations: chứa các config liên quan tới ngôn ngữ cho dự án, đang sử dụng i18n.
Như đã nói ở trên lớp providers được sử dụng bằng Context API dùng để quản lý state cho dự án. Các Context API này sẽ đặt các context provider lồng nhau thành một cây dữ liệu. Một provider trong dự án đóng vai trò là quản lý và cung cấp các methods cho một tính năng nhất định, như: AuthProvider sẽ liên quan tới việc quản lý Auth cho dự án, cung cấp các method như login, refresh token. Quản lý dữ liệu của user.
Ví dụ:
export default function Wrapper({ children, ...props }: AppWrapperProps) {
const value = [
RefProvider,
PageProvider,
TranslationProvider,
AuthProvider,
RouteProvider,
BookingProvider,
];
return value.reduceRight((acc, Component: any) => {
return React.createElement(Component, props, acc);
}, children);
}Điều này tương đương
<RefProvider>
<PageProvider>
<TranslationProvider>
<AuthProvider>
<RouteProvider>
<BookingProvider
{children}
</BookingProvider>
</RouteProvider>
</AuthProvider>
</TranslationProvider>
</PageProvider>
</RefProvider>Như vậy dựa vào tính chất của Context APIta sẽ có 1 cây dữ liệu như sau:
RefProvider=>PageProvider=>TranslationProvider=>AuthProvider=>RouteProvider=>BookingProvider. Đặc điểm củaContext API, là các node con sẽ có thể gọi để sử dụng các dữ liệu của cácContexttrên node cha, tuy nhiên sẽ không thể có chiều ngược lại. Dữ liệu sẽ được đổ từ cácProviderngoài cùng dần vào cácProviderbên trong, đây là đặc điểm cơ bản củaContext API. Và sẽ không có vấn đề gì nếu luồng dữ liệu đi từ trên xuống dưới như vậy. Tuy nhiên trong nhiều trường hợp thực tế thì node cha vẫn có khả năng sử dụng dữ liệu của node con,
Ví dụ: Giả định trường hợp TranslationProvider đang cần sử dụng dữ liệu của User để biết User đó đã chọn ngôn ngữ như thế nào để cập nhật lại ngôn ngữ cho chính xác thì TranslationProvider phải cần dữ liệu của AuthProvider, tuy nhiên sẽ không thể gọi trược tiếp dữ liệu trong trường hợp này được vì AuthProvider đang là node con của TranslationProvider. Trường hợp này mình đưa currentLanguage ra các lớp Provider bên ngoài, từ đó RefProvider được sinh ra để làm việc này. Khi đó dữ liệu currentLanguagesẽ được set ra lớp RefProvidernày, và TranslationProvider sẽ sử dụng được currentLanguage thông qua RefProvider.
Lưu ý: Việc RefProvider (core) được sinh ra để mục đích giao tiếp giữa các Providers cho trường hợp cần hoisting dữ liệu, các trường hợp dữ liệu trong dự án thì không được sử dụng RefProvider để hoisting vì sẽ gây khó trong việc kiểm soát dòng chảy dữ liệu và bảo trì dự án.
Giả định tại một component bất kì là node con của WrapperProvider, ta muốn sử dụng Transalationđể dịch thuật thì làm như sau:
- Bước 1:
import { useTranslationContext } from "@providers/TranslationProvider"; - Bước 2:
- Gọi
useTranslationContext()trong component muốn sử dụng:const { i18n, Trans } = useTranslationContext();. - Trong đó
i18n,Translà giá trị củaTranslationProviderđược thực thi trước đó trong fileTranslationProvider.tsx.
- Gọi
Concept này được sử dụng tương tự cho các Provider khác xuyên suốt trong toàn bộ dự án. Ta cũng sẽ có tương ứng các useRouteContext, usePageContext,... cho các Provider khác.
Để có 1 route mới thì mình cần tạo thêm 1 route trong folder pages (phần này là của NextJS mình xem thêm docs để nắm chi tiết).
Tiếp theo như đã nói ở trên mỗi một Provider trong dự án sẽ quản lý dữ liệu và các methods cho một tính năng tương ứng, RouteProvider cũng không ngoại lệ. RouteProvider là nơi cài đặt và cung cấp các methods cần thiết để sử dụng cho việc routing của dự án.
Sau khi tạo thêm 1 route trong folder pages, ta cần nắm thêm một số hàm cơ bản trong file RouteProvider.tsx sau đây: addRule, toUrl, push, breadcrumbs.
import routeStore, { helper } from "@utils/routeStore";
import i18n from "@translations/i18n";
routeStore.addRule("productDetail", {
url: (params?: object) => {
return helper.url("product-detail", params);
},
breadcrumbs: (params: object) => {
return _.get(params, "breadcrumbs", [
{
name: i18n.t("Product.title"),
},
]);
},
});- Hàm nắm vai trò thêm một số
methodssẵn vào cho mộtroute. Cấu trúc của hàm này sẽ như sau:
addRule(<ruleName>, {
url: () => string,
breadcrumbs: () => [...]
})Trong đó:
- Đối số thứ nhất
ruleNamelà tên người dùng đặt, đặt cái gì cũng được, nhưng mình nên quy ước sẽ đặt theo chuẩn ví dụ mình có route làproduct-detailthìruleNamelàproductDetail.ruleNamenày cũng sẽ được sử dụng tương ứng cho các hàmtoUrlvàpushsau đó. - Đối số thứ hai là một
object,objectnày hiện tại có 2 methods làurlvàbreadcrumbs. Hai hàm này sẽ được lưu trữ và sử dụng trong core để hỗ trợ xây dựng biếnrouterlà giá trị củaRouteProvider.- Hàm
urltrả về mộtpathcủaroutetương ứng. Để hiểu thêm cách xây dựng hàmurlnày thì có thể đọc thêm phầnhelpertrongrouteStore- phần này được viết sẵn để truyềnidhoặcslugnếu có vào trongurl. Dev mới cũng có thể không cần đọc, chỉ cần biết cách sử dụng là được. - Hàm
breadcrumbslà xây dựng một mảngbreadcrumbssẵn dùng để gọi lại sau này khi sử dụng.
- Hàm
- Hàm để trả về url string. Ví dụ: để lấy link
/product-detail/san-pham-1mà mình đã define trước đó trong khi dùngaddRule. - Cách sử dụng
router.toUrl('productDetail', { slug: 'san-pham-1' }). Kết quả mình sẽ có một url string như sau:/en/product-detail/san-pham-1hoặc/vi/product-detail/san-pham-1, tùy vào giá trịcurrentLanguagelúc đó.
- Tương tự
toUrl, hàmpushnày đểdirectvàoroutetương ứng vớiruleNameđã được define trước đó khi dùngaddRule. - Cách dùng:
router.push('productDetail', { slug: 'san-pham-1'}). Hàm sẽ direct tới/en/product-detail/san-pham-1hoặc/vi/product-detail/san-pham-1, tùy vào giá trịcurrentLanguagelúc đó.
- Cho trường hợp mà
addRulecó methodbreadcrumbsthì router lúc này có thể call hàm này để sử dụng. - Các dùng:
router.breadcrumbs('productDetail', {...}). Hàm trả về mảng giá trị breadcrumbs tương ứng (việc này cũng tùy thuộc vào người dev setup trước đó ở method breadcrumbs của addRule).
-
Về styling sẽ có 2 folders chính. Các styling global scss của dự án đang được đặt ở folder
styles. Các phần config về tailwindcss, icons, colors, fonts và một số styling core thì sẽ đặt ởgstyles. -
styles/globals.scssfile lưu các biến chung (:root) các styling chung, và overwrite các thư viện sẽ thông qua file này. Trong file này sẽ add dòng code@import "@gstyles/tailwind/style.scss";, các phần nay thông thường sẽ được setup sẵn trong dự án mẫu. -
gstyles/tailwind/style.scssfile cài đặtfont-facevà styling chungtext-ellipsis-<index>(index: 1 -> 10). -
gstyles/tailwind/index.jsfile config của tailwindcss - file này sẽ requiredgstyles/styleguide/colors.js,gstyles/styleguide/fontSize.js,gstyles/styleguide/borderRadius.js.
- Đối với các icon svg không có animation mình sẽ copy file svg vào folder
gstyles/styleguide/icons/svgs. - Sau đó sử dụng nhanh mà không cần
importthông qua:
import gstyles from '@gstyles/index';
gstyles.icons({name: <tên file>, size, color })- Hạn chế sử dụng classnames inline của tailwind để lên layout, responsive và anim cho component. Vì sẽ khó maintain. Việc này mình tạo thêm file module.scss và gơm styling cũng như sử dụng thêm breakpoint để làm responsive, ở component tương ứng.
- Một tip nhỏ nữa là sử dụng thư viện
classnamesđể kiểm tra các trường hợp className đúng sai. Ví dụ:import cx from 'classnames';.className={!!active ? 'active' : ''}=>className={cx({'active': !!active})}className={`${class1} ${class2} ${class3}`}=>className={cx(class1, class2, class3)}.- Cách dùng của
classnames:- Viết nhiều cases:
cx('foo', {'xa bar': true, duck: false }, 'baz', { quux: true })// => 'foo bar baz quux' - Trường hợp bị bỏ qua:
cx(null, false, 'bar', undefined, 0, 1, { baz: null }, '')// => 'bar 1'
- Viết nhiều cases:
- Dự án core sẽ xài một số thư viện: tailwindcss, scss, formik, styled, lodash, classnames.
- Trường hợp các dự án làm anim sẽ có: locomotive-scroll, gsap, swiperjs,
Các quy ước dưới đây đang hỗ trợ cho kiến trúc này, nó mang tính cá nhân của tác giả, không phải là quy định của React về việc hiện thực các mã nguồn (custom hook, Provider) hay ở các dự án sử dụng React khác.
-
Khi có 1 tính năng phát sinh của dự án,dữ liệu sẽ được dùng lại ở nhiều nơi, các flow logic tương đối nhiều và phức tạp, thì ta gơm logic đó thành một
Providervà trả về mộtinstance, mọi thay đổi về logic và dữ liệu thì components sẽ tương tác thông qua cácinstancecủaProvidercung cấp. -
Các tính chất của một
Provider:- Có thể dùng lại dữ liệu, logic ở nhiều nơi (component khác nhau).
- Các flow và logic sẽ tương đối nhiều.
- Hỗ trợ một tính năng lớn cụ thể nào đó. Ví dụ: Authentication, Booking, Payment, Translation, Route,...
-
Cusom
Hookcũng là dùng để gơm logic chung và sử dụng ở nhiều chỗ đặc điểm của các logic chung này thì thường không quá lớn, đơn giản và có tính độc lập cao hơnProvider. -
Các tính chất của một custom
Hook:- Sử dụng trong functional component cụ thể.
- Logic nhỏ và độc lập.
- Hỗ trợ một tính năng duy nhất cụ thể. Ví dụ: useLocalStorage, useWindowSize, useCountdown, usePromise, usePagination, useFilter,...
Đối với các dự án có nhiều ngôn ngữ, mình nên code luôn phần dịch thuật, khi lên layout ngay từ đầu, để giảm tải công việc cập nhật về sau, việc này sử dụng thông qua TranslationProvider.
Ví dụ:
Bước 1: import useTranslationContext.
import { useTranslationContext } from "@providers/TranslationProvider";
Bước 2: sử dụng useTranslationContext ở component cần dùng.
const { i18n, Trans } = useTranslationContext();
Bước 3: Sử dụng i18n hoặc Transtùy mục đích sử dụng.
i18n:i18n.t("ContactUs.addressAmanakiThaoDien").Trans:<Trans i18nKey={"Discover.BlockEvents.findMore"} components={[<br key="trans_0" />]} />
- Các đường dẫn của đối số của hàm
i18n.tcũng nhưprop i18nKeycủa componentTranssẽ được define trong foldertranslations/lang, tùy thuộc vào dự án mà trong đây sẽ có các file nhưvi.ts,en.ts,... để define các giá trị ban đầu cho ngôn ngữ. Sau này sẽ update lại thì update vào file này sẽ đỡ công sức dò tìm.