React 问世已有一段时间,如今依旧表现强劲。 相信你也会在项目中经常遇见他
核心:React + TypeScript
元框架:Next.js
Next.js[3] 堪称 React 开发的 “瑞士军刀”,功能齐全且表现出色。目前最新版本(Next.js 15)[4]已经全面支持 React 19,集成了路由和 API 管理功能,还内置了性能优化机制。
不过,它不是唯一的选择。对于全栈应用而言,Remix[5] 依旧很棒;还有个崭露头角的 Tanstack Start[6],也在做出一些有趣的成果,大家不妨关注一下。要是你只需要路由功能,可以试试 React Router[7]。
样式设计:Tailwind CSS+shadcn/ui
你也许会对 Tailwind CSS[8] 持怀疑态度,不过实际使用后可能会改变你的看法。Tailwind CSS 与 shadcn/ui[9] 搭配,就能打造出强大的样式设计组合。AI 工具能生成精准的 Tailwind 类,shadcn/ui 提供开箱即用的无障碍组件,同时还能优化代码包体积。这样一来,你可以在保持设计一致性的同时,快速进行原型设计和迭代。
客户端状态管理:Zustand
服务器状态管理:TanStack Query
TanStack Query[11] 能处理服务器状态中所有令人头疼的问题。它可以自动刷新数据,智能缓存十分有效,轻松处理实时更新,乐观更新功能更是神奇,而且其开发工具会让你不禁感叹,要是没有它该怎么开发。
动画效果:Motion
在 React 中实现动画效果,Motion[12] 是最佳选择。它支持声明式动画,易于理解,对各种手势的支持也很出色,还具备共享布局动画等高级功能,无论是简单的过渡效果,还是复杂的动画设计,它都能完美胜任。
测试工具
测试环节也不能马虎。Vitest、React Testing Library 和 Playwright 这三款工具堪称黄金组合:Vitest[13] 比 Jest[14] 速度更快,并且原生支持 ES 模块;React Testing Library[15] 依旧是组件测试的得力助手,能帮你发现可访问性问题,让测试过程更贴近用户使用场景;而 Playwright[16] 在端到端测试方面表现卓越,能支持多种浏览器、进行视觉测试、处理网络相关事务,还能模拟移动设备,并且测试结果稳定可靠。
表格处理:
1.TanStack Table
如果涉及表格相关开发,TanStack Table[17] 必不可少。它提供类型安全的表格,对于大量数据支持虚拟滚动,排序和筛选功能易用,列设置灵活,即便处理海量数据集,性能依旧出色。
2.vxetable
vue中用过的比较好用的table处理框架,支持虚拟滚动,拖拽,排序,分页,列控制,动态打印,导出,多语言等
3.material-react-table
支持虚拟滚动,拖拽,排序,分页,列控制,请求动画,动态列,动态头,多头等配置
type ArgInstance = {
data?: any[];
api?: any;
columns: MRT_ColumnDef<any>[];
loading?: boolean;
enablePager?: boolean;
searchParam?: any;
};
type Setting = {
enablePager?: boolean;
searchParam?: any;
};
type State = {
loading?: boolean;
};
type TableInstance = {
data: ArgInstance,
settings?: Setting;
state?: State;
};
const loadingErrorMessage = 'Loading Error';
const MaterialStaticTable = (props: TableInstance) => {
//should be memoized or stable
const { data, columns, } = props.data;
const [tableData, setTableData] = useState<any[]>(data || []);
const showRowsPage: any = [10, 20, 30, 50];
const [frontPager, setFrontPager] = useState(true);
const [enableColumnOrdering, setEnableColumnOrdering] = useState(true);
const [pagination, setPagination] = useState({
pageIndex: frontPager ? 0 : 1, // 对于支持后端分页的接口0页代表查所有
pageSize: 10,
});
const [columnsData, setColumnsData] = useState<any>(columns || []);
const [enableRowVirtual, setEnableRowVirtual] = useState(true);
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(props.state?.loading || false);
const [isFetching, setIsFetching] = useState(false);
const [globalFilter, setGlobalFilter] = useState('');
const [isEnablePager, setIsEnablePager] = useState(props.data.enablePager);
const [rowCount, setRowCount] = useState(0);
const [totalPage, setTotalPage] = useState(0);
const [searchCondition, setSearchCondition] = useState('');
const [tablesReqInfo, setTablesReqInfo] = useState<any>(null);
const [tablesData, setTablesData] = useState(data || []);
const [filterString, setFilterString] = useState<any>('');
const refreshTable = () => {
// Refresh table logic here
};
const table = useMaterialReactTable({
columns: columns,
data: tableData,
enablePagination: isEnablePager,
enableRowVirtualization: !enableRowVirtual,
muiCircularProgressProps: {
color: 'warning',
thickness: 5,
size: 80,
},
muiSkeletonProps: {
animation: 'pulse',
height: 28,
},
// enableColumnVirtualization: enableColumnVirtualization,
initialState: {
// pagination,
// density: 'compact', // 表格紧密 compact comfortable
// showGlobalFilter: !isPrint,
// ...customState,
},
manualPagination: !frontPager, // 是否前端分页(接口请求走后端分页)
rowCount: rowCount, // 数据总数
pageCount: pagination.pageSize,
state: {
pagination,
// pagination: pagination,
isLoading: isLoading,
showAlertBanner: isError,
showProgressBars: isFetching,
},
// muiPaginationProps: {
// color: 'primary',
// showRowsPerPage: false,
// rowsPerPageOptions: showRowsPage, //showRowsPage,
// shape: 'rounded',
// variant: 'outlined',
// size: 'small',
// defaultPage: frontPager ? 0 : 1,
// count: totalPage,
// page: (frontPager ? 1 : 0) + pagination.pageIndex,
// onChange: (event, page) => {
// // fix default page init to 0 cause two request
// // console.log('onchange', (frontPager ? -1 : 0) + page, page);
// setPagination({ ...pagination, ...{ pageIndex: (frontPager ? -1 : 0) + page } });
// },
// },
// defaultColumn: {
// minSize: 1,
// maxSize: 100,
// // size: document.body.clientWidth / columns.length - 3,
// },
paginationDisplayMode: 'pages',
// enableStickyHeader: true,
enableStickyFooter: true,
enableColumnOrdering: enableColumnOrdering,
enableColumnResizing: true, //false为自动分布行
layoutMode: 'grid',
enableFullScreenToggle: false, // 全屏
enableColumnDragging: true, // 列拖拽
enableDensityToggle: false, // 密度
enableColumnActions: false, // 列操作
enableFilters: true, // 控制全局过滤以及每一列过滤(开启会导致表格右上角出现过滤框)
manualFiltering: true,
// enableColumnVirtualization: true,//虚拟滚动
enableGlobalFilter: false, // /searchFromEnd,
positionGlobalFilter: 'right', // "left",
///
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: (filterData: any) => { },
onShowColumnFiltersChange: (filterData: any) => { },
enableColumnFilterModes: true,// 开启列过滤模式
///
// columnFilterDisplayMode: "custom",
// enableRowNumbers: true,
// enableRowDragging: true,
enableTableHead: true,
enableMultiSort: true,
manualSorting: false, // 手动排序
enableEditing: true,
editDisplayMode: 'row', //default
onEditingRowSave: ({ table, values }) => {
//validate data
//save data to api
console.log('onEditingRowSave', table, values);
table.setEditingRow(null); //exit editing mode
},
muiEditTextFieldProps: ({ cell, row, table }) => ({
onBlur: (event) => {
//validate data
//save data to api and/or rerender table
// table.setEditingCell(null) is called automatically onBlur internally
},
}),
muiTableBodyCellProps: ({ cell, column, table }) => ({
onClick: () => {
table.setEditingCell(cell); //set editing cell
//optionally, focus the text field
queueMicrotask(() => {
const textField = table.refs.editInputRefs.current[column.id];
if (textField) {
textField.focus();
textField.select?.();
}
});
},
}),
onEditingRowCancel: () => {
//clear any validation errors
},
enableRowActions: true,
enableRowSelection: true,// 允许行选择
enableGrouping: false,
enableHiding: true,
enableSorting: true,
// defaultColumn: {
// minSize: 40,
// maxSize: 1000,
// size: 180,
// },
// muiTableContainerProps: {
// sx: {
// },
// },
muiTableBodyProps: {
sx: {
maxHeight: isEnablePager ? '700px' || '600px' : 'unset',
overflowY: 'auto',
minHeight: '600px',
},
},
muiTableBodyRowProps: ({ row }: any) => {
let { original } = row;
let custStyle: any = {};
// if (original.moreLabRecordFound === true) {
// custStyle.background = '#F7EAC1';
// }
// if (original.moreLabRecordFound === false) {
// custStyle.background = '#FFC4C4';
// }
return {
sx: { ...custStyle },
className: '',
};
},
// muiTableBodyRowProps: muiTableBodyRowProps,
muiTableBodyCellProps: ({ column }: any) => {
return {}
},
//
renderTopToolbarCustomActions: () => (
<Box className="m-r-10 table_top_left">
<Box className="flex flex-1">
{/* {newFormFn && (
<span className="m-r-10 visibleNotPrinter">
<Button
onClick={newFormFn}
size="small"
variant="contained"
color="primary"
disableRipple
>
{newFormFnText}
</Button>
</span>
)}
{leftForm} */}
</Box>
</Box>
),
renderToolbarInternalActions: ({ table }) => {
return (
<>
<Box className="visibleNotPrinter">
{/* <TextField
name="searchCondition"
onChange={(e: any) => {
setGlobalFilter(e.target.value || '');
}}
value={globalFilter}
autoComplete="off"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search
onClick={() => {
setLoadTime(new Date());
}}
sx={{ cursor: 'pointer' }}
/>
</InputAdornment>
),
endAdornment: (
<InputAdornment position="start">
<Clear
onClick={() => {
setGlobalFilter('');
}}
sx={{ cursor: 'pointer' }}
/>
</InputAdornment>
),
}}
/> */}
</Box>
</>
);
},
// muiBottomToolbarProps: {
// sx: {
// // display: "inline-block",
// display: "flex",
// width: "100%",
// },
// },
muiTableFooterCellProps: {
sx: {
display: 'flex',
width: '45%',
float: 'right',
},
},
// renderBottomToolbarCustomActions: () => (
// <Box className="f-r f-s-14">
// <span className="total_counts">Total {rowCount} Records</span>
// </Box>
// ),
renderBottomToolbar: () => (
<Box className="flex flex-1" sx={{ justifyContent: 'space-between' }}>
{/* <Box>
{' '}
<span className="total_counts">Total {rowCount} Records</span>{' '}
</Box>
<Box className="visibleNotPrinter">
<Box>
<span className="total_counts">Rows Per Page:</span>
<Select
disabled={isLoading}
value={pagination.pageSize}
onChange={(event: any) => {
setPagination({
...pagination,
pageSize: event?.target?.value || 10,
pageIndex: frontPager ? 0 : 1
});
const pageCount = tablesData.length > 0
? Math.ceil(tablesData.length / event?.target?.value || 10)
: 0;
setTotalPage(pageCount);
}}
>
{showRowsPage.map((pageItem: any, indexItem: number) => (
<MenuItem key={indexItem} value={pageItem}>
{pageItem}
</MenuItem>
))}
</Select>
</Box>
<Pagination
disabled={isLoading}
showFirstButton
showLastButton
shape="rounded"
page={pagination.pageIndex + (frontPager ? 1 : 0)}
count={totalPage}
onChange={(event: any, page: number) => {
setPagination({
...pagination,
pageIndex: (frontPager ? -1 : 0) + page
});
}}
/>
</Box> */}
</Box>
),
muiToolbarAlertBannerProps: isError
? {
color: 'error',
children: loadingErrorMessage,
}
: undefined,
});
return <MaterialReactTable table={table} />;
};
表单处理:
1.React Hook Form
过去,在 React 中处理表单让人头疼,但有了 React Hook Form[18] 就不一样了。它专为速度而生,搭配 Zod[19] 进行表单验证易如反掌,与 TypeScript 配合默契,代码包小,API 设计直观易懂。
import * as React from "react"
import { useForm } from "react-hook-form"
type FormInputs = {
username: string
firstName: string
}
const App = () => {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<FormInputs>()
const onSubmit = (data: FormInputs) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>Username</label>
<input {...register("username")} />
{errors.username && <p>{errors.username.message}</p>}
<label>First Name</label>
<input {...register("firstName")} />
{errors.firstName && <p>{errors.firstName.message}</p>}
<button
type="button"
onClick={() => {
const inputs = [
{
type: "manual",
name: "username",
message: "Double Check This",
},
{
type: "manual",
name: "firstName",
message: "Triple Check This",
},
]
inputs.forEach(({ name, type, message }) => {
setError(name, { type, message })
})
}}
>
Trigger Name Errors
</button>
<input type="submit" />
</form>
)
}
2.Formik
[Formik](Validation | Formik)
import React from 'react';
import { Formik, Form, Field } from 'formik';
import * as Yup from 'yup';
const SignupSchema = Yup.object().shape({
firstName: Yup.string()
.min(2, 'Too Short!')
.max(50, 'Too Long!')
.required('Required'),
lastName: Yup.string()
.min(2, 'Too Short!')
.max(50, 'Too Long!')
.required('Required'),
email: Yup.string().email('Invalid email').required('Required'),
});
export const ValidationSchemaExample = () => (
<div>
<h1>Signup</h1>
<Formik
initialValues={{
firstName: '',
lastName: '',
email: '',
}}
validationSchema={SignupSchema}
onSubmit={values => {
// same shape as initial values
console.log(values);
}}
>
{({ errors, touched }) => (
<Form>
<Field name="firstName" />
{errors.firstName && touched.firstName ? (
<div>{errors.firstName}</div>
) : null}
<Field name="lastName" />
{errors.lastName && touched.lastName ? (
<div>{errors.lastName}</div>
) : null}
<Field name="email" type="email" />
{errors.email && touched.email ? <div>{errors.email}</div> : null}
<button type="submit">Submit</button>
</Form>
)}
</Formik>
</div>
);