@tker-react/layout 0.2.14 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  React 后台管理布局框架,提供菜单、面包屑、标签页、侧边栏等布局能力。
4
4
 
5
5
  - **无内置 adapter**:不依赖特定 UI 库,所有 UI 渲染由使用者通过 adapter 组件控制
6
- - **React Context 共享状态**:`<Layout>` 内置 Provider,通过 `useLayout()` 管理状态
7
- - **路由解耦**:不直接依赖路由库,通过 `setNavigateAdapter` 和 `setActivePath` 与路由层对接
6
+ - **React Context 共享状态**:`<Layout>` 内置 Provider
7
+ - **路由解耦**:不直接依赖路由库,通过 `setNavigateAdapter` 和 `<Layout>` 的 `activePath`/`activeFullPath` props 与路由层对接
8
+ - **细粒度重渲染**:`activePath` 通过独立 context 传播,路由变化时仅菜单、面包屑、标签页重渲染,`<Outlet />` 不受影响
8
9
 
9
10
  ## 安装
10
11
 
@@ -26,59 +27,66 @@ import "@tker-react/layout/layout.css";
26
27
 
27
28
  ```tsx
28
29
  // layouts/AppLayout.tsx
29
- import { Layout, useLayout } from "@tker-react/layout";
30
- import { Outlet, useRouter, useRouterState } from "@tanstack/react-router";
31
- import { useLayoutEffect } from "react";
30
+ import {
31
+ Layout,
32
+ setMenus,
33
+ setMenuAdapter,
34
+ setTabAdapter,
35
+ setBreadcrumbAdapter,
36
+ setLogoAdapter,
37
+ setToolbarAdapter,
38
+ setUserAvatarAdapter,
39
+ setHomePath,
40
+ setNavigateAdapter,
41
+ } from "@tker-react/layout";
42
+ import { Outlet, useRouterState } from "@tanstack/react-router";
43
+ import { useMemo } from "react";
44
+
45
+ // adapter 组件和菜单数据
46
+ import MenuAdapter from "../adapters/MenuAdapter";
47
+ import TabAdapter from "../adapters/TabAdapter";
48
+ import BreadcrumbAdapter from "../adapters/BreadcrumbAdapter";
49
+ import LogoAdapter from "../adapters/LogoAdapter";
50
+ import ToolbarAdapter from "../adapters/ToolbarAdapter";
51
+ import UserAvatarAdapter from "../adapters/UserAvatarAdapter";
52
+ import { router } from "../router";
53
+
54
+ // -- 静态配置:模块顶层调用即可 --
55
+ setMenuAdapter(MenuAdapter);
56
+ setTabAdapter(TabAdapter);
57
+ setBreadcrumbAdapter(BreadcrumbAdapter);
58
+ setLogoAdapter(LogoAdapter);
59
+ setToolbarAdapter(ToolbarAdapter);
60
+ setUserAvatarAdapter(UserAvatarAdapter);
61
+ setHomePath("/dashboard");
62
+ setNavigateAdapter((path) => router.navigate({ to: path }));
63
+ setMenus([
64
+ { path: "/dashboard", title: "仪表盘" },
65
+ {
66
+ path: "/users",
67
+ title: "用户管理",
68
+ children: [
69
+ { path: "/users/list", title: "用户列表" },
70
+ { path: "/users/detail", title: "用户详情" },
71
+ ],
72
+ },
73
+ ]);
32
74
 
33
75
  export function AppLayout() {
34
- return (
35
- <Layout>
36
- <SetupLayout />
37
- <Outlet />
38
- </Layout>
39
- );
40
- }
41
-
42
- function SetupLayout() {
43
- const layout = useLayout();
44
- const router = useRouter();
45
76
  const pathname = useRouterState({ select: (s) => s.location.pathname });
46
77
  const search = useRouterState({ select: (s) => s.location.searchStr });
47
78
 
48
- // adapter 注册和菜单设置必须在 useLayoutEffect 中调用
49
- useLayoutEffect(() => {
50
- layout.setMenuAdapter(MenuAdapter);
51
- layout.setBreadcrumbAdapter(BreadcrumbAdapter);
52
- layout.setTabAdapter(TabAdapter);
53
- layout.setLogoAdapter(LogoAdapter);
54
- layout.setToolbarAdapter(ToolbarAdapter);
55
- layout.setUserAvatarAdapter(UserAvatarAdapter);
56
- layout.setMenus([
57
- { path: "/dashboard", title: "仪表盘" },
58
- {
59
- path: "/users",
60
- title: "用户管理",
61
- children: [
62
- { path: "/users", title: "用户列表" },
63
- { path: "/users/detail", title: "用户详情" },
64
- ],
65
- },
66
- ]);
67
- layout.setHomePath("/dashboard");
68
- }, []);
69
-
70
- // setNavigateAdapter 存储 ref,可以直接在 render 中调用
71
- layout.setNavigateAdapter((path) => router.navigate({ to: path }));
72
-
73
- useLayoutEffect(() => {
74
- layout.setActivePath(pathname, pathname + search);
75
- }, [pathname, search]);
76
-
77
- return null;
79
+ const outlet = useMemo(() => <Outlet />, []);
80
+
81
+ return (
82
+ <Layout activeFullPath={pathname + search} activePath={pathname}>
83
+ {outlet}
84
+ </Layout>
85
+ );
78
86
  }
79
87
  ```
80
88
 
81
- > **注意**:`setXxxAdapter`、`setMenus` 等方法内部会调用 `setState`,必须在 `useLayoutEffect`(或 `useEffect`)中调用,不能在 render 期间直接调用,否则 React 会报 "Cannot update a component while rendering" 错误。`setNavigateAdapter` 只写 ref,可以在 render 中直接调用。
89
+ > **注意**:adapter、菜单等静态配置在模块顶层调用一次即可(只写 module 变量)。`activePath`/`activeFullPath` 通过 props 传入 `<Layout>`。`<Outlet />` `useMemo` 稳定引用,配合 `LayoutInner` `React.memo` 避免路由变化时 `Outlet` Layout 层牵连重渲染。
82
90
 
83
91
  ### 2. 定义路由并挂载
84
92
 
@@ -429,62 +437,65 @@ function UserAvatarAdapter({ onSettings, onLogout }: UserAvatarAdapterProps) {
429
437
 
430
438
  ## 使用不同路由库
431
439
 
432
- 以下展示 `SetupLayout` 组件中路由同步部分的写法。
440
+ 静态配置(adapter、菜单)在模块顶层调用不变。以下展示各路由器 `activePath` 的对接方式。
433
441
 
434
442
  ### TanStack Router
435
443
 
436
444
  ```tsx
437
- // SetupLayout 中的路由同步部分
438
- const router = useRouter();
445
+ // AppLayout 中的路由同步部分
446
+ setNavigateAdapter((path) => router.navigate({ to: path }));
447
+
439
448
  const pathname = useRouterState({ select: (s) => s.location.pathname });
440
449
  const search = useRouterState({ select: (s) => s.location.searchStr });
441
450
 
442
- layout.setNavigateAdapter((path) => router.navigate({ to: path }));
443
-
444
- useLayoutEffect(() => {
445
- layout.setActivePath(pathname, pathname + search);
446
- }, [pathname, search]);
451
+ return (
452
+ <Layout activeFullPath={pathname + search} activePath={pathname}>
453
+ {outlet}
454
+ </Layout>
455
+ );
447
456
  ```
448
457
 
449
458
  ### React Router v6+
450
459
 
451
460
  ```tsx
452
- // SetupLayout 中的路由同步部分
453
- const location = useLocation();
454
- const navigate = useNavigate();
461
+ // AppLayout 中的路由同步部分
462
+ setNavigateAdapter((path) => navigate(path));
455
463
 
456
- layout.setNavigateAdapter((path) => navigate(path));
464
+ const location = useLocation();
457
465
 
458
- useLayoutEffect(() => {
459
- layout.setActivePath(location.pathname, location.pathname + location.search);
460
- }, [location.pathname, location.search]);
466
+ return (
467
+ <Layout activeFullPath={location.pathname + location.search} activePath={location.pathname}>
468
+ {outlet}
469
+ </Layout>
470
+ );
461
471
  ```
462
472
 
463
473
  ### Next.js App Router
464
474
 
465
475
  ```tsx
466
- // SetupLayout 中的路由同步部分
476
+ // AppLayout 中的路由同步部分
477
+ setNavigateAdapter((path) => router.push(path));
478
+
467
479
  const pathname = usePathname();
468
480
  const searchParams = useSearchParams();
469
- const router = useRouter();
470
-
471
- layout.setNavigateAdapter((path) => router.push(path));
472
-
473
481
  const fullPath = pathname + (searchParams.toString() ? `?${searchParams}` : "");
474
482
 
475
- useLayoutEffect(() => {
476
- layout.setActivePath(pathname, fullPath);
477
- }, [pathname, fullPath]);
483
+ return (
484
+ <Layout activeFullPath={fullPath} activePath={pathname}>
485
+ {outlet}
486
+ </Layout>
487
+ );
478
488
  ```
479
489
 
480
490
  ## API
481
491
 
482
- ### `useLayout()`
492
+ ### 模块级 setter
493
+
494
+ 所有配置通过模块级 setter 设置,在 import 阶段调用即可(不需要 hook),内部存储为 module 变量。
483
495
 
484
- | 方法 | 说明 |
496
+ | 函数 | 说明 |
485
497
  |------|------|
486
498
  | `setMenus(data)` | 设置菜单数据 |
487
- | `setActivePath(path, fullPath?)` | 设置当前激活路径,自动触发面包屑更新和标签页打开 |
488
499
  | `setMenuAdapter(component)` | 注册菜单适配器组件 |
489
500
  | `setTabAdapter(component)` | 注册标签页适配器组件 |
490
501
  | `setBreadcrumbAdapter(component)` | 注册面包屑适配器组件 |
@@ -492,11 +503,23 @@ useLayoutEffect(() => {
492
503
  | `setToolbarAdapter(component)` | 注册工具栏适配器组件 |
493
504
  | `setUserAvatarAdapter(component)` | 注册用户头像适配器组件 |
494
505
  | `setNavigateAdapter(fn)` | 设置导航回调,Layout 内部点击菜单/tab/面包屑时调用 |
495
- | `toggleCollapse()` | 切换侧边栏折叠 |
496
- | `setLayoutMode(mode)` | 设置布局模式:`"side-menu"` 或 `"top-menu"` |
506
+ | `setLayoutMode(mode)` | 设置布局模式:`"side-menu"` 或 `"top-menu"`,默认 `"side-menu"` |
497
507
  | `setHomePath(path)` | 设置首页路径,首页 tab 固定在最左侧 |
498
508
  | `setMaxTabs(n)` | 设置最大标签页数量,超出后关闭最早的 |
499
- | `getFullPath(path)` | 根据菜单 path 获取存储的完整路径 |
509
+ | `setExpandedWidth(width)` | 侧边栏展开宽度,默认 `"200px"` |
510
+ | `setCollapsedWidth(width)` | 侧边栏折叠宽度,默认 `"64px"` |
511
+
512
+ ### 组件
513
+
514
+ | 组件 | Props | 说明 |
515
+ |------|-------|------|
516
+ | `<Layout>` | `children`, `activePath?`, `activeFullPath?` | 布局容器,内置所有 Provider。`activePath` 为当前路由路径,`activeFullPath` 为含 query string 的完整路径 |
517
+
518
+ ### Hook
519
+
520
+ | Hook | 说明 |
521
+ |------|------|
522
+ | `useActivePathContext()` | 获取当前 activePath,通常仅在自定义 adapter 时使用 |
500
523
 
501
524
  ### Adapter 组件 Props
502
525
 
package/dist/index.d.mts CHANGED
@@ -1,11 +1,12 @@
1
- import * as react from 'react';
2
- import { ReactNode, ComponentType } from 'react';
3
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode, ComponentType } from 'react';
4
3
 
5
4
  interface LayoutProps {
6
5
  children?: ReactNode;
6
+ activePath?: string;
7
+ activeFullPath?: string;
7
8
  }
8
- declare const Layout: react.MemoExoticComponent<({ children }: LayoutProps) => react_jsx_runtime.JSX.Element>;
9
+ declare function Layout({ children, ...props }: LayoutProps): react_jsx_runtime.JSX.Element;
9
10
 
10
11
  interface BreadcrumbItem {
11
12
  path: string;
@@ -73,8 +74,10 @@ interface LayoutAdapters {
73
74
 
74
75
  interface LayoutProviderProps {
75
76
  children?: ReactNode;
77
+ activePath?: string;
78
+ activeFullPath?: string;
76
79
  }
77
- declare function LayoutProvider({ children }: LayoutProviderProps): react_jsx_runtime.JSX.Element;
80
+ declare function LayoutProvider({ children, activePath, activeFullPath, }: LayoutProviderProps): react_jsx_runtime.JSX.Element;
78
81
 
79
82
  declare function setMenus(data: MenuItem[]): void;
80
83
  declare function setMenuAdapter(c: ComponentType<MenuAdapterProps>): void;
@@ -89,7 +92,6 @@ declare function setLayoutMode(mode: "side-menu" | "top-menu"): void;
89
92
  declare function setExpandedWidth(width: string): void;
90
93
  declare function setCollapsedWidth(width: string): void;
91
94
  declare function setNavigateAdapter(fn: (path: string) => void): void;
92
- declare function setActivePath(path: string, fullPath?: string): void;
93
95
 
94
- export { Layout, LayoutProvider, setActivePath, setBreadcrumbAdapter, setCollapsedWidth, setExpandedWidth, setHomePath, setLayoutMode, setLogoAdapter, setMaxTabs, setMenuAdapter, setMenus, setNavigateAdapter, setTabAdapter, setToolbarAdapter, setUserAvatarAdapter };
96
+ export { Layout, LayoutProvider, setBreadcrumbAdapter, setCollapsedWidth, setExpandedWidth, setHomePath, setLayoutMode, setLogoAdapter, setMaxTabs, setMenuAdapter, setMenus, setNavigateAdapter, setTabAdapter, setToolbarAdapter, setUserAvatarAdapter };
95
97
  export type { BreadcrumbAdapterProps, BreadcrumbItem, LayoutAdapters, LogoAdapterProps, MenuAdapterProps, MenuItem, TabAdapterProps, TabItem, UserAvatarAdapterProps };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
- import * as react from 'react';
2
- import { ReactNode, ComponentType } from 'react';
3
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode, ComponentType } from 'react';
4
3
 
5
4
  interface LayoutProps {
6
5
  children?: ReactNode;
6
+ activePath?: string;
7
+ activeFullPath?: string;
7
8
  }
8
- declare const Layout: react.MemoExoticComponent<({ children }: LayoutProps) => react_jsx_runtime.JSX.Element>;
9
+ declare function Layout({ children, ...props }: LayoutProps): react_jsx_runtime.JSX.Element;
9
10
 
10
11
  interface BreadcrumbItem {
11
12
  path: string;
@@ -73,8 +74,10 @@ interface LayoutAdapters {
73
74
 
74
75
  interface LayoutProviderProps {
75
76
  children?: ReactNode;
77
+ activePath?: string;
78
+ activeFullPath?: string;
76
79
  }
77
- declare function LayoutProvider({ children }: LayoutProviderProps): react_jsx_runtime.JSX.Element;
80
+ declare function LayoutProvider({ children, activePath, activeFullPath, }: LayoutProviderProps): react_jsx_runtime.JSX.Element;
78
81
 
79
82
  declare function setMenus(data: MenuItem[]): void;
80
83
  declare function setMenuAdapter(c: ComponentType<MenuAdapterProps>): void;
@@ -89,7 +92,6 @@ declare function setLayoutMode(mode: "side-menu" | "top-menu"): void;
89
92
  declare function setExpandedWidth(width: string): void;
90
93
  declare function setCollapsedWidth(width: string): void;
91
94
  declare function setNavigateAdapter(fn: (path: string) => void): void;
92
- declare function setActivePath(path: string, fullPath?: string): void;
93
95
 
94
- export { Layout, LayoutProvider, setActivePath, setBreadcrumbAdapter, setCollapsedWidth, setExpandedWidth, setHomePath, setLayoutMode, setLogoAdapter, setMaxTabs, setMenuAdapter, setMenus, setNavigateAdapter, setTabAdapter, setToolbarAdapter, setUserAvatarAdapter };
96
+ export { Layout, LayoutProvider, setBreadcrumbAdapter, setCollapsedWidth, setExpandedWidth, setHomePath, setLayoutMode, setLogoAdapter, setMaxTabs, setMenuAdapter, setMenus, setNavigateAdapter, setTabAdapter, setToolbarAdapter, setUserAvatarAdapter };
95
97
  export type { BreadcrumbAdapterProps, BreadcrumbItem, LayoutAdapters, LogoAdapterProps, MenuAdapterProps, MenuItem, TabAdapterProps, TabItem, UserAvatarAdapterProps };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import "./layout.css";
2
2
  import { jsx, jsxs } from 'react/jsx-runtime';
3
- import { useRef, useCallback, useMemo, useState, useSyncExternalStore, createContext, useContext, memo, useEffect } from 'react';
3
+ import { useRef, useCallback, useMemo, useState, useEffect, createContext, useContext, memo } from 'react';
4
4
 
5
5
  function findMenuItemByPath(menuData, path) {
6
6
  for (const item of menuData) {
@@ -112,29 +112,6 @@ function consumeLayoutConfig() {
112
112
  navigateAdapter: _navigateAdapter
113
113
  };
114
114
  }
115
- let _activePath = typeof window !== "undefined" ? window.location.pathname : "";
116
- const _pathParamsMap = /* @__PURE__ */ new Map();
117
- let _activePathListeners = [];
118
- function setActivePath(path, fullPath) {
119
- if (fullPath && fullPath !== path) {
120
- _pathParamsMap.set(path, fullPath);
121
- }
122
- if (path === _activePath) return;
123
- _activePath = path;
124
- _activePathListeners.forEach((l) => l());
125
- }
126
- function subscribeActivePath(listener) {
127
- _activePathListeners.push(listener);
128
- return () => {
129
- _activePathListeners = _activePathListeners.filter((l) => l !== listener);
130
- };
131
- }
132
- function getActivePathSnapshot() {
133
- return _activePath;
134
- }
135
- function getFullPathByActivePath(path) {
136
- return _pathParamsMap.get(path) || path;
137
- }
138
115
 
139
116
  const LayoutContext = createContext(null);
140
117
  const LayoutInteractionContext = createContext(null);
@@ -158,7 +135,11 @@ const ActivePathContext = createContext("");
158
135
  function useActivePathContext() {
159
136
  return useContext(ActivePathContext);
160
137
  }
161
- function LayoutProvider({ children }) {
138
+ function LayoutProvider({
139
+ children,
140
+ activePath = "",
141
+ activeFullPath
142
+ }) {
162
143
  const config = consumeLayoutConfig();
163
144
  const navigateRef = useRef(null);
164
145
  navigateRef.current = config.navigateAdapter;
@@ -197,7 +178,12 @@ function LayoutProvider({ children }) {
197
178
  menuDataRef.current = config.menus;
198
179
  const layoutModeRef = useRef(config.layoutMode);
199
180
  layoutModeRef.current = config.layoutMode;
200
- const activePath = useSyncExternalStore(subscribeActivePath, getActivePathSnapshot);
181
+ const pathParamsMap = useRef(/* @__PURE__ */ new Map());
182
+ useEffect(() => {
183
+ if (activeFullPath && activeFullPath !== activePath) {
184
+ pathParamsMap.current.set(activePath, activeFullPath);
185
+ }
186
+ }, [activePath, activeFullPath]);
201
187
  const toggleMenuOpen = useCallback((path, forceOpen) => {
202
188
  setOpenKeys((prev) => {
203
189
  const next = new Set(prev);
@@ -228,7 +214,7 @@ function LayoutProvider({ children }) {
228
214
  toggleMenuOpen(path);
229
215
  }
230
216
  } else {
231
- const fullPath = getFullPathByActivePath(path);
217
+ const fullPath = pathParamsMap.current.get(path) || path;
232
218
  if (window.location.pathname + window.location.search !== fullPath) {
233
219
  navigateRef.current?.(fullPath);
234
220
  }
@@ -245,7 +231,7 @@ function LayoutProvider({ children }) {
245
231
  setCollapsedState(nextCollapsed);
246
232
  }, []);
247
233
  const getFullPath = useCallback(
248
- (path) => getFullPathByActivePath(path),
234
+ (path) => pathParamsMap.current.get(path) || path,
249
235
  []
250
236
  );
251
237
  const isConcretePage = useCallback(
@@ -623,8 +609,8 @@ const LayoutInner = memo(function LayoutInner2({ children }) {
623
609
  ] })
624
610
  ] });
625
611
  });
626
- const Layout = memo(function Layout2({ children }) {
627
- return /* @__PURE__ */ jsx(LayoutProvider, { children: /* @__PURE__ */ jsx(LayoutInner, { children }) });
628
- });
612
+ function Layout({ children, ...props }) {
613
+ return /* @__PURE__ */ jsx(LayoutProvider, { ...props, children: /* @__PURE__ */ jsx(LayoutInner, { children }) });
614
+ }
629
615
 
630
- export { Layout, LayoutProvider, setActivePath, setBreadcrumbAdapter, setCollapsedWidth, setExpandedWidth, setHomePath, setLayoutMode, setLogoAdapter, setMaxTabs, setMenuAdapter, setMenus, setNavigateAdapter, setTabAdapter, setToolbarAdapter, setUserAvatarAdapter };
616
+ export { Layout, LayoutProvider, setBreadcrumbAdapter, setCollapsedWidth, setExpandedWidth, setHomePath, setLayoutMode, setLogoAdapter, setMaxTabs, setMenuAdapter, setMenus, setNavigateAdapter, setTabAdapter, setToolbarAdapter, setUserAvatarAdapter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tker-react/layout",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",