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 API
ta 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ácContext
trên node cha, tuy nhiên sẽ không thể có chiều ngược lại. Dữ liệu sẽ được đổ từ cácProvider
ngoài cùng dần vào cácProvider
bê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 currentLanguage
sẽ được set ra lớp RefProvider
nà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
,Trans
là 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ố
methods
sẵ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
ruleName
là 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-detail
thìruleName
làproductDetail
.ruleName
này cũng sẽ được sử dụng tương ứng cho các hàmtoUrl
vàpush
sau đó. - Đối số thứ hai là một
object
,object
này hiện tại có 2 methods làurl
vàbreadcrumbs
. Hai hàm này sẽ được lưu trữ và sử dụng trong core để hỗ trợ xây dựng biếnrouter
là giá trị củaRouteProvider
.- Hàm
url
trả về mộtpath
củaroute
tương ứng. Để hiểu thêm cách xây dựng hàmurl
này thì có thể đọc thêm phầnhelper
trongrouteStore
- phần này được viết sẵn để truyềnid
hoặcslug
nế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
breadcrumbs
là xây dựng một mảngbreadcrumbs
sẵ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-1
mà 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-1
hoặc/vi/product-detail/san-pham-1
, tùy vào giá trịcurrentLanguage
lúc đó.
- Tương tự
toUrl
, hàmpush
này đểdirect
vàoroute
tươ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-1
hoặc/vi/product-detail/san-pham-1
, tùy vào giá trịcurrentLanguage
lúc đó.
- Cho trường hợp mà
addRule
có methodbreadcrumbs
thì 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.scss
file 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.scss
file cài đặtfont-face
và styling chungtext-ellipsis-<index>
(index
: 1 -> 10). -
gstyles/tailwind/index.js
file 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
import
thô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
Provider
và 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ácinstance
củaProvider
cung 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
Hook
cũ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 Trans
tù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.t
cũng nhưprop i18nKey
của componentTrans
sẽ đượ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.