perf(react-router-hooks): improve performance

This commit is contained in:
Zeke Zhang 2024-11-04 17:06:55 +08:00
parent c838ac70ad
commit f395e814f2
14 changed files with 186 additions and 113 deletions

View File

@ -7,8 +7,19 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { FC, useEffect } from 'react';
import { Location, NavigateFunction, NavigateOptions, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Schema } from '@formily/json-schema';
import _ from 'lodash';
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
import {
Location,
NavigateFunction,
NavigateOptions,
useHref,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from 'react-router-dom';
const NavigateNoUpdateContext = React.createContext<NavigateFunction>(null);
NavigateNoUpdateContext.displayName = 'NavigateNoUpdateContext';
@ -34,6 +45,61 @@ MatchAdminNameContext.displayName = 'MatchAdminNameContext';
const IsInSettingsPageContext = React.createContext<boolean>(false);
IsInSettingsPageContext.displayName = 'IsInSettingsPageContext';
const CurrentTabUidContext = React.createContext<string>('');
CurrentTabUidContext.displayName = 'CurrentTabUidContext';
const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams());
SearchParamsContext.displayName = 'SearchParamsContext';
const RouterBasenameContext = React.createContext<string>('');
RouterBasenameContext.displayName = 'RouterBasenameContext';
const IsSubPageClosedByPageMenuContext = React.createContext<{
isSubPageClosedByPageMenu: boolean;
setFieldSchema: React.Dispatch<React.SetStateAction<Schema>>;
}>({
isSubPageClosedByPageMenu: false,
setFieldSchema: () => {},
});
IsSubPageClosedByPageMenuContext.displayName = 'IsSubPageClosedByPageMenuContext';
export const IsSubPageClosedByPageMenuProvider: FC = ({ children }) => {
const params = useParams();
const prevParamsRef = useRef<any>({});
const [fieldSchema, setFieldSchema] = useState<Schema>(null);
const isSubPageClosedByPageMenu = useMemo(() => {
const result =
_.isEmpty(params['*']) &&
fieldSchema?.['x-component-props']?.openMode === 'page' &&
!!prevParamsRef.current['*']?.includes(fieldSchema['x-uid']);
prevParamsRef.current = params;
return result;
}, [fieldSchema, params]);
const value = useMemo(() => ({ isSubPageClosedByPageMenu, setFieldSchema }), [isSubPageClosedByPageMenu]);
return (
<IsSubPageClosedByPageMenuContext.Provider value={value}>{children}</IsSubPageClosedByPageMenuContext.Provider>
);
};
/**
* see: https://stackoverflow.com/questions/50449423/accessing-basename-of-browserouter
* @returns {string} basename
*/
const RouterBasenameProvider: FC = ({ children }) => {
const basenameOfCurrentRouter = useHref('/');
return <RouterBasenameContext.Provider value={basenameOfCurrentRouter}>{children}</RouterBasenameContext.Provider>;
};
const SearchParamsProvider: FC = ({ children }) => {
const [searchParams] = useSearchParams();
return <SearchParamsContext.Provider value={searchParams}>{children}</SearchParamsContext.Provider>;
};
const IsInSettingsPageProvider: FC = ({ children }) => {
const isInSettingsPage = useLocation().pathname.includes('/settings');
return <IsInSettingsPageContext.Provider value={isInSettingsPage}>{children}</IsInSettingsPageContext.Provider>;
@ -62,6 +128,11 @@ export const CurrentPageUidProvider: FC = ({ children }) => {
return <CurrentPageUidContext.Provider value={params.name}>{children}</CurrentPageUidContext.Provider>;
};
export const CurrentTabUidProvider: FC = ({ children }) => {
const params = useParams();
return <CurrentTabUidContext.Provider value={params.tabUid}>{children}</CurrentTabUidContext.Provider>;
};
/**
* When the URL changes, components that use `useNavigate` will re-render.
* This provider provides a `navigateNoUpdate` method that can avoid re-rendering.
@ -107,7 +178,7 @@ const LocationSearchProvider: FC = ({ children }) => {
};
/**
* use `useNavigateNoUpdate` to avoid components that use `useNavigateNoUpdate` re-rendering.
* use `useNavigateNoUpdate` to avoid components re-rendering.
* @returns
*/
export const useNavigateNoUpdate = () => {
@ -115,7 +186,7 @@ export const useNavigateNoUpdate = () => {
};
/**
* use `useLocationNoUpdate` to avoid components that use `useLocationNoUpdate` re-rendering.
* use `useLocationNoUpdate` to avoid components re-rendering.
* @returns
*/
export const useLocationNoUpdate = () => {
@ -146,6 +217,32 @@ export const useIsInSettingsPage = () => {
return React.useContext(IsInSettingsPageContext);
};
export const useCurrentTabUid = () => {
return React.useContext(CurrentTabUidContext);
};
export const useCurrentSearchParams = () => {
return React.useContext(SearchParamsContext);
};
export const useRouterBasename = () => {
return React.useContext(RouterBasenameContext);
};
/**
* Used to determine if the user closed the sub-page by clicking on the page menu
* @returns
*/
export const useIsSubPageClosedByPageMenu = (fieldSchema: Schema) => {
const { isSubPageClosedByPageMenu, setFieldSchema } = React.useContext(IsSubPageClosedByPageMenuContext);
useEffect(() => {
setFieldSchema(fieldSchema);
}, [fieldSchema, setFieldSchema]);
return isSubPageClosedByPageMenu;
};
export const CustomRouterContextProvider: FC = ({ children }) => {
return (
<NavigateNoUpdateProvider>
@ -154,7 +251,11 @@ export const CustomRouterContextProvider: FC = ({ children }) => {
<LocationSearchProvider>
<MatchAdminProvider>
<MatchAdminNameProvider>
<IsInSettingsPageProvider>{children}</IsInSettingsPageProvider>
<SearchParamsProvider>
<RouterBasenameProvider>
<IsInSettingsPageProvider>{children}</IsInSettingsPageProvider>
</RouterBasenameProvider>
</SearchParamsProvider>
</MatchAdminNameProvider>
</MatchAdminProvider>
</LocationSearchProvider>

View File

@ -11,4 +11,3 @@ export * from './useApp';
export * from './useAppSpin';
export * from './usePlugin';
export * from './useRouter';
export * from './useRouterBasename';

View File

@ -1,19 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useHref } from 'react-router-dom';
/**
* see: https://stackoverflow.com/questions/50449423/accessing-basename-of-browserouter
* @returns {string} basename
*/
export const useRouterBasename = () => {
const basenameOfCurrentRouter = useHref('/');
return basenameOfCurrentRouter;
};

View File

@ -62,7 +62,7 @@ export function withDynamicSchemaProps<T = any>(
options: WithSchemaHookOptions = {},
) {
const displayName = options.displayName || Component.displayName || Component.name;
const ComponentWithProps: ComponentType<T> = (props) => {
const ComponentWithProps: ComponentType<T> = React.memo((props) => {
const { dn, findComponent } = useDesignable();
const useComponentPropsStr = useMemo(() => {
const xComponent = dn.getSchemaAttribute('x-component');
@ -85,7 +85,7 @@ export function withDynamicSchemaProps<T = any>(
}, [schemaProps, props]);
return <Component {...memoProps}>{props.children}</Component>;
};
});
Component.displayName = displayName;
ComponentWithProps.displayName = `withSchemaProps(${displayName})`;

View File

@ -11,14 +11,14 @@ import { ApiOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, Dropdown, Tooltip } from 'antd';
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useApp } from '../application';
import { Link } from 'react-router-dom';
import { useApp, useNavigateNoUpdate } from '../application';
import { useCompile } from '../schema-component';
import { useToken } from '../style';
export const PluginManagerLink = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const navigate = useNavigateNoUpdate();
const { token } = useToken();
return (
<Tooltip title={t('Plugin manager')}>
@ -47,8 +47,11 @@ export const SettingsCenterDropdown = () => {
return {
key: setting.name,
icon: setting.icon,
label: setting.link ? <div onClick={() => window.open(setting.link)}>{compile(setting.title)}</div> :
label: setting.link ? (
<div onClick={() => window.open(setting.link)}>{compile(setting.title)}</div>
) : (
<Link to={setting.path}>{compile(setting.title)}</Link>
),
};
});
}, [app, t]);

View File

@ -35,6 +35,8 @@ import {
} from '../../../';
import {
CurrentPageUidProvider,
CurrentTabUidProvider,
IsSubPageClosedByPageMenuProvider,
useCurrentPageUid,
useIsInSettingsPage,
useMatchAdmin,
@ -467,41 +469,45 @@ export const InternalAdminLayout = () => {
<Layout>
<GlobalStyleForAdminLayout />
<CurrentPageUidProvider>
<Layout.Header className={layoutHeaderCss}>
<div style={style1}>
<div style={style2}>
<div className={className1}>
{result?.data?.data?.logo?.url ? (
<img className={className2} src={result?.data?.data?.logo?.url} />
) : (
<span style={fontSizeStyle} className={className3}>
{result?.data?.data?.title}
</span>
)}
<CurrentTabUidProvider>
<IsSubPageClosedByPageMenuProvider>
<Layout.Header className={layoutHeaderCss}>
<div style={style1}>
<div style={style2}>
<div className={className1}>
{result?.data?.data?.logo?.url ? (
<img className={className2} src={result?.data?.data?.logo?.url} />
) : (
<span style={fontSizeStyle} className={className3}>
{result?.data?.data?.title}
</span>
)}
</div>
<div className={className4}>
<SetThemeOfHeaderSubmenu>
<MenuEditor sideMenuRef={sideMenuRef} />
</SetThemeOfHeaderSubmenu>
</div>
</div>
<div className={className5}>
<PinnedPluginList />
<ConfigProvider theme={theme}>
<Divider type="vertical" />
</ConfigProvider>
<Help />
<CurrentUser />
</div>
</div>
<div className={className4}>
<SetThemeOfHeaderSubmenu>
<MenuEditor sideMenuRef={sideMenuRef} />
</SetThemeOfHeaderSubmenu>
</div>
</div>
<div className={className5}>
<PinnedPluginList />
<ConfigProvider theme={theme}>
<Divider type="vertical" />
</ConfigProvider>
<Help />
<CurrentUser />
</div>
</div>
</Layout.Header>
<AdminSideBar sideMenuRef={sideMenuRef} />
{/* Use the "nb-subpages-slot-without-header-and-side" class name to locate the position of the subpages */}
<Layout.Content className={`${layoutContentClass} nb-subpages-slot-without-header-and-side`}>
<header className={layoutContentHeaderClass}></header>
<Outlet />
{/* {service.contentLoading ? render() : <Outlet />} */}
</Layout.Content>
</Layout.Header>
<AdminSideBar sideMenuRef={sideMenuRef} />
{/* Use the "nb-subpages-slot-without-header-and-side" class name to locate the position of the subpages */}
<Layout.Content className={`${layoutContentClass} nb-subpages-slot-without-header-and-side`}>
<header className={layoutContentHeaderClass}></header>
<Outlet />
{/* {service.contentLoading ? render() : <Outlet />} */}
</Layout.Content>
</IsSubPageClosedByPageMenuProvider>
</CurrentTabUidProvider>
</CurrentPageUidProvider>
</Layout>
);

View File

@ -8,9 +8,8 @@
*/
import { useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React, { createContext, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { useIsSubPageClosedByPageMenu } from '../../../application/CustomRouterContextProvider';
import { useDataBlockRequest } from '../../../data-source';
import { useCurrentPopupContext } from '../page/PagePopups';
import { getBlockService, storeBlockService } from '../page/pagePopupUtils';
@ -19,37 +18,13 @@ import { ActionContextProps } from './types';
export const ActionContext = createContext<ActionContextProps>({});
ActionContext.displayName = 'ActionContext';
/**
* Used to determine if the user closed the sub-page by clicking on the page menu
* @returns
*/
const useIsSubPageClosedByPageMenu = () => {
// Used to trigger re-rendering when URL changes
const params = useParams();
const prevParamsRef = useRef<any>({});
const fieldSchema = useFieldSchema();
const isSubPageClosedByPageMenu = useMemo(() => {
const result =
_.isEmpty(params['*']) &&
fieldSchema?.['x-component-props']?.openMode === 'page' &&
!!prevParamsRef.current['*']?.includes(fieldSchema['x-uid']);
prevParamsRef.current = params;
return result;
}, [fieldSchema, params]);
return isSubPageClosedByPageMenu;
};
export const ActionContextProvider: React.FC<ActionContextProps & { value?: ActionContextProps }> = React.memo(
(props) => {
const [submitted, setSubmitted] = useState(false); //是否有提交记录
const { visible } = { ...props, ...props.value } || {};
const { setSubmitted: setParentSubmitted } = { ...props, ...props.value };
const service = useBlockServiceInActionButton();
const isSubPageClosedByPageMenu = useIsSubPageClosedByPageMenu();
const isSubPageClosedByPageMenu = useIsSubPageClosedByPageMenu(useFieldSchema());
useEffect(() => {
if (visible === false && service && !service.loading && (submitted || isSubPageClosedByPageMenu)) {

View File

@ -17,12 +17,16 @@ import classNames from 'classnames';
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { NavigateFunction, Outlet, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
import { NavigateFunction, Outlet, useOutletContext } from 'react-router-dom';
import { FormDialog } from '..';
import { antTableCell } from '../../../acl/style';
import { useRequest } from '../../../api-client';
import { useNavigateNoUpdate } from '../../../application/CustomRouterContextProvider';
import { useRouterBasename } from '../../../application/hooks/useRouterBasename';
import {
useCurrentSearchParams,
useCurrentTabUid,
useNavigateNoUpdate,
useRouterBasename,
} from '../../../application/CustomRouterContextProvider';
import { useDocumentTitle } from '../../../document-title';
import { useGlobalTheme } from '../../../global-theme';
import { Icon } from '../../../icon';
@ -36,25 +40,24 @@ import { ErrorFallback } from '../error-fallback';
import { useStyles } from './Page.style';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
export const Page = (props) => {
export const Page = React.memo((props: any) => {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const dn = useDesignable();
const { theme } = useGlobalTheme();
const { getAriaLabel } = useGetAriaLabelOfSchemaInitializer();
const { tabUid } = useParams();
const currentTabUid = useCurrentTabUid();
const basenameOfCurrentRouter = useRouterBasename();
const disablePageHeader = fieldSchema['x-component-props']?.disablePageHeader;
const enablePageTabs = fieldSchema['x-component-props']?.enablePageTabs;
const options = useContext(SchemaOptionsContext);
const navigate = useNavigateNoUpdate();
const [searchParams] = useSearchParams();
const searchParams = useCurrentSearchParams();
const loading = false;
const activeKey = useMemo(
// 处理 searchParams 是为了兼容旧版的 tab 参数
() => tabUid || searchParams.get('tab') || Object.keys(fieldSchema.properties || {}).shift(),
[fieldSchema.properties, searchParams, tabUid],
() => currentTabUid || searchParams.get('tab') || Object.keys(fieldSchema.properties || {}).shift(),
[fieldSchema.properties, searchParams, currentTabUid],
);
const { wrapSSR, hashId, componentCls } = useStyles();
const { token } = useToken();
@ -168,14 +171,19 @@ export const Page = (props) => {
) : null;
}, [activeKey, enablePageTabs, handleTabsChange, items, tabBarExtraContent, tabBarStyle]);
const outletContext = useMemo(
() => ({ loading, disablePageHeader, enablePageTabs, fieldSchema, tabUid: currentTabUid }),
[currentTabUid, disablePageHeader, enablePageTabs, fieldSchema, loading],
);
return wrapSSR(
<div className={`${componentCls} ${hashId} ${antTableCell}`}>
<NocoBasePageHeader footer={footer} />
<div className="nb-page-wrapper">
<ErrorBoundary FallbackComponent={ErrorFallback} onError={console.error}>
{tabUid ? (
{currentTabUid ? (
// used to match the rout with name "admin.page.tab"
<Outlet context={{ loading, disablePageHeader, enablePageTabs, fieldSchema, tabUid }} />
<Outlet context={outletContext} />
) : (
<>
<PageContent
@ -193,7 +201,9 @@ export const Page = (props) => {
</div>
</div>,
);
};
});
Page.displayName = 'NocoBasePage';
export const PageTabs = () => {
const { loading, disablePageHeader, enablePageTabs, fieldSchema, tabUid } = useOutletContext<any>();

View File

@ -109,6 +109,7 @@ const PopupTabsPropsProvider: FC = ({ children }) => {
);
};
const displayNone = { display: 'none' };
const PagePopupsItemProvider: FC<{
params: PopupParams;
context: PopupContext;
@ -180,7 +181,7 @@ const PagePopupsItemProvider: FC<{
{/* Pass the service of the block where the button is located down, to refresh the block's data when the popup is closed */}
<BlockRequestContextProvider recordRequest={storedContext.service}>
<PopupTabsPropsProvider>
<div style={{ display: 'none' }}>{children}</div>
<div style={displayNone}>{children}</div>
</PopupTabsPropsProvider>
</BlockRequestContextProvider>
</DataBlockProvider>

View File

@ -9,7 +9,7 @@
import { useFieldSchema } from '@formily/react';
import { useCallback } from 'react';
import { useCollection_deprecated } from '../../collection-manager';
import { useCollection } from '../../data-source/collection/CollectionProvider';
/**
* label = 'schema-initializer' + x-component + [x-initializer] + [collectionName] + [postfix]
@ -18,7 +18,7 @@ import { useCollection_deprecated } from '../../collection-manager';
export const useGetAriaLabelOfSchemaInitializer = () => {
const fieldSchema = useFieldSchema();
const { name } = useCollection_deprecated();
const { name } = useCollection() || {};
const getAriaLabel = useCallback(
(postfix?: string) => {
if (!fieldSchema) return '';

View File

@ -34,7 +34,7 @@ const useContextVariable = (): VariableOption => {
const { field, blockData, rowKey, collection: collectionName } = tableBlockContext || {};
const contextData = useMemo(
() => blockData?.data?.filter((v) => (field?.data?.selectedRowKeys || [])?.includes(v[rowKey])),
() => blockData?.data?.filter?.((v) => (field?.data?.selectedRowKeys || [])?.includes(v[rowKey])),
[field?.data?.selectedRowKeys, rowKey, blockData],
);

View File

@ -139,7 +139,7 @@ export const useUpdateCollectionActionAndRefreshCM = (options) => {
const ctx = useActionContext();
const { name } = useParams();
const { refresh } = useResourceActionContext();
const { resource, targetKey } = useResourceContext();
const { targetKey } = useResourceContext();
const { [targetKey]: filterByTk } = useRecord();
const api = useAPIClient();
const dm = useDataSourceManager();

View File

@ -9,7 +9,6 @@
import { css, cx } from '@nocobase/client';
import React from 'react';
import { useLocation } from 'react-router-dom';
import Device from './iOS6';
export const MobileDevice: React.FC = (props) => {

View File

@ -8,10 +8,8 @@ import {
MobileTitleProvider,
} from '@nocobase/plugin-mobile/client';
import React from 'react';
import { useLocation } from 'react-router-dom';
const Demo = () => {
const { pathname } = useLocation();
return (
<div style={{ position: 'relative' }}>
<MobileTitleProvider title="Title">