@cfp-studio/plugin-react 17.3.1 → 17.3.2
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 +90 -0
- package/dist/index.d.mts +36 -4
- package/dist/index.mjs +1 -1
- package/package.json +7 -7
- package/preview/shims/router-utils.spec.ts +158 -0
- package/preview/shims/umi.tsx +580 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ React 框架的 CFP Studio 插件,支持 React 组件/Story 静态分析、属
|
|
|
9
9
|
- 兼容 CSF/Storybook 规范,支持 args/参数提取
|
|
10
10
|
- 支持 preview 目录下自定义 Wrapper、ErrorBoundary、不同 React 版本渲染器
|
|
11
11
|
- 内置丰富的 E2E/单元测试用例,覆盖多种工程模板
|
|
12
|
+
- **兼容 UmiJS 4 工程**(`umi` / `@umijs/max`),见下文
|
|
12
13
|
|
|
13
14
|
## 架构与工作原理
|
|
14
15
|
|
|
@@ -33,6 +34,95 @@ import { reactFrameworkPlugin } from "@cfp-studio/plugin-react";
|
|
|
33
34
|
// 传入 core/Previewer 或 createChromelessWorkspace 等集成
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
## UmiJS 支持
|
|
38
|
+
|
|
39
|
+
支持 **Umi 4**(`umi` 或 `@umijs/max`,已用 `4.7.30` 验证)。Umi 5 不存在,npm 最新即 4.x。
|
|
40
|
+
|
|
41
|
+
**Umi 3 明确不支持**(不再维护,且用的是 react-router 5,与 Umi 4 是两套语义)。
|
|
42
|
+
Umi 3 工程会走普通 React 路径:不 alias `umi`,也不加 `@/` 别名。
|
|
43
|
+
纯 React 组件能渲染;用了 `import … from "umi"` 的组件会明确报错(不会给出误导性的假实现)。
|
|
44
|
+
|
|
45
|
+
完整设计与决策记录见 [`docs/spec-umijs-support.md`](docs/spec-umijs-support.md)。
|
|
46
|
+
|
|
47
|
+
自动生效的三件事(无需配置):
|
|
48
|
+
|
|
49
|
+
| 能力 | 说明 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `@/` → `src/` 别名 | **不依赖工程的 `tsconfig.paths`**;`@@/` → `src/.umi/` 仅在生成目录存在时添加 |
|
|
52
|
+
| 传递依赖的 React | 真实 Umi 4 工程不声明 `react`,由框架包带入;插件会解析实际安装位置 |
|
|
53
|
+
| 生成目录排除 | `src/.umi*` 不进组件列表 |
|
|
54
|
+
|
|
55
|
+
### Umi 运行时的降级行为
|
|
56
|
+
|
|
57
|
+
`umi` 是服务端 + 客户端混合包,直接在浏览器里跑会用到 `process.platform` 等 Node API 而崩。
|
|
58
|
+
预览用 [`preview/shims/umi.tsx`](preview/shims/umi.tsx) 顶替它,**只改模块解析,不碰业务代码**。
|
|
59
|
+
|
|
60
|
+
| API | 预览里的行为 |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `useModel` | **恒返回 `{}`**(不猜初值)。要真实初值请在 `__previewjs__/Wrapper.tsx` 里包 Provider |
|
|
63
|
+
| `useRequest` / `useFetcher` | **返回空态**:`{ data: undefined, loading: false, … }` / `state: "idle"`,`useRequest` 会打一条 `console.debug` |
|
|
64
|
+
| `history` / `useNavigate` / `useLocation` | 内存版位置栈,`push` 后可被 `useLocation` 观察到 |
|
|
65
|
+
| `useParams` / `useSearchParams` | 空值(`{}` / 空 `URLSearchParams`) |
|
|
66
|
+
| `useAppData` | 结构完整的空上下文(`routes: {}`、`clientRoutes: []` 等)。`clientRoutes.length` 为 `0`,不是 `undefined` |
|
|
67
|
+
| `useSelectedRoutes` / `useMatch` / `useRoutes` / `useOutlet` | 空值(`[]` / `null`) |
|
|
68
|
+
| `useRouteProps` / `useRouteData` / `useOutletContext` | 空对象(`{}` / `{ route: {} }`) |
|
|
69
|
+
| `useServerLoaderData` / `useClientLoaderData` / `useLoaderData` | 空态(`{ data: undefined }` / `undefined`) |
|
|
70
|
+
| `Link` / `NavLink` / `LinkWithPrefetch` | 渲染成 `<a>`,点击被拦截 |
|
|
71
|
+
| `Outlet` / `Navigate` | 渲染 `null`;`Navigate` 会把目标路径记进内存 location |
|
|
72
|
+
| `Helmet` / `HelmetProvider` | 渲染子节点,不改文档头 |
|
|
73
|
+
| `connect` / `withRouter` | 直通 HOC |
|
|
74
|
+
| `createSearchParams` / `generatePath` / `matchPath` / `resolvePath` | **真实语义**(不需要路由上下文,没必要降级) |
|
|
75
|
+
| `matchRoutes` | 恒 `null`(预览没有路由表) |
|
|
76
|
+
| `createMemoryHistory` / `createBrowserHistory` / `createHashHistory` | 同内存版 `history`(接口较真实 `history` 包小很多) |
|
|
77
|
+
| `request` / `renderClient` / `__getRoot` | **显式抛错**,并说明「请用 DevBrowser(应用预览)」 |
|
|
78
|
+
|
|
79
|
+
判据:**渲染路径上的高频 API 给降级值**(抛错会让整棵树挂掉),**不在渲染路径的显式失败**。
|
|
80
|
+
`useRequest` 返回空态意味着组件会显示「无数据」—— 那是预期行为,不是 bug。
|
|
81
|
+
|
|
82
|
+
### 为什么导出面必须完整
|
|
83
|
+
|
|
84
|
+
ESM 是**静态**导出校验:shim 少导出任何一个 Umi 名字,整个模块就
|
|
85
|
+
`SyntaxError: does not provide an export named '…'`,组件连挂载机会都没有 ——
|
|
86
|
+
**预览直接白屏**,而报错里只有 `__previewjs_internal__/renderer/shims/umi.tsx`
|
|
87
|
+
这个内部路径,用户看不出发生了什么。
|
|
88
|
+
|
|
89
|
+
所以即使是用不上的应用级 API(`renderClient` / `__getRoot`)也**显式导出并抛错**,
|
|
90
|
+
而不是省略。`tests/umi.spec.ts` 的用例 3d 把这条约束固化成了回归测试
|
|
91
|
+
(故意把一个导出去掉,它就会红)。
|
|
92
|
+
|
|
93
|
+
### 用真实的 `react-router`(accessory lib)
|
|
94
|
+
|
|
95
|
+
组件里直接 `import { useHref } from "react-router"` 是支持的 —— 插件会把
|
|
96
|
+
`react-router` / `react-router-dom` 解析到 `umi` 依赖树里的实际安装位置(它们是 **`umi` 的传递依赖**)。
|
|
97
|
+
|
|
98
|
+
但这些 hook 必须活在 `<Router>` 里。**预览不替你决定路由形态**(memory / hash / browser、
|
|
99
|
+
初始路径、basename),请在 `__previewjs__/Wrapper.tsx` 里自己包一层:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { ReactNode } from "react";
|
|
103
|
+
import { MemoryRouter } from "react-router-dom";
|
|
104
|
+
|
|
105
|
+
export const Wrapper = ({ children }: { children: ReactNode }) => {
|
|
106
|
+
return <MemoryRouter initialEntries={["/"]}>{children}</MemoryRouter>;
|
|
107
|
+
};
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
(导出必须是**具名** `Wrapper`。)不包的话会报
|
|
111
|
+
`useHref() may be used only in the context of a <Router> component`。
|
|
112
|
+
|
|
113
|
+
> 版本以 Umi 为准(当前 `react-router` **6.3.0**)。用了 6.4+ 的 API(如 `createBrowserRouter`)会报
|
|
114
|
+
> `does not provide an export named`。
|
|
115
|
+
|
|
116
|
+
更一般的 Wrapper 用法(主题、状态、布局、能做什么 / 做不到什么)见
|
|
117
|
+
[用户文档 · 组件预览](../../../../docs/user/04-component-preview.md#用-wrapper-包住组件)。
|
|
118
|
+
|
|
119
|
+
### 不覆盖的范围
|
|
120
|
+
|
|
121
|
+
- **路由 / 页面级约定**(`pages/*` 的 route props、layout、约定式路由)—— 预览把页面当普通组件渲染。
|
|
122
|
+
整站/带数据场景走应用预览(dev server),见 `docs/specs/0001-runtime-bridge`。
|
|
123
|
+
- **`.umirc.ts` 的 `alias` / `proxy` 配置** —— 预览不读 Umi 的工程配置(`alias` 的原因见 spec §23.1)。
|
|
124
|
+
- **`@umijs/max` 插件生态的语义**(`access` / `initialState` / `micro`)—— 只有上面表里的降级值。
|
|
125
|
+
|
|
36
126
|
## 依赖
|
|
37
127
|
|
|
38
128
|
- `react`、`@cfp-studio/*`、`typescript`、`vite`、`esbuild-plugin-polyfill-node`
|
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
|
+
import * as fs$1 from "node:fs";
|
|
2
3
|
import * as url$1 from "node:url";
|
|
3
4
|
import { URL as URL$1 } from "node:url";
|
|
4
|
-
import * as fs$1 from "node:fs";
|
|
5
5
|
import { Reader } from "@cfp-studio/vfs";
|
|
6
6
|
import { Analyzer } from "@cfp-studio/analyzer-api";
|
|
7
7
|
import * as http from "node:http";
|
|
@@ -5721,10 +5721,16 @@ interface FrameworkPluginFactory {
|
|
|
5721
5721
|
/**
|
|
5722
5722
|
* 检查当前项目的依赖是否与该框架插件兼容。
|
|
5723
5723
|
*
|
|
5724
|
-
* @param dependencies -
|
|
5724
|
+
* @param dependencies - 项目的依赖包信息。**只含工程 `package.json` 里显式声明的包**
|
|
5725
|
+
* (`dependencies` / `devDependencies` / `peerDependencies` 的并集),
|
|
5726
|
+
* 不含传递依赖。
|
|
5727
|
+
* @param rootDir - 工程根目录(可选,向后兼容)。
|
|
5728
|
+
* 有些框架(如 Umi 4)**不显式声明 `react`**,而由框架包作为传递依赖带入 ——
|
|
5729
|
+
* 那种情况下 `dependencies.react` 是 `undefined`,插件需要靠 `rootDir` 自行解析。
|
|
5730
|
+
* 见 `docs/spec-umijs-support.md` §15。
|
|
5725
5731
|
* @returns 一个 Promise,解析为布尔值,表示是否兼容。
|
|
5726
5732
|
*/
|
|
5727
|
-
isCompatible(dependencies: PackageDependencies): Promise<boolean>;
|
|
5733
|
+
isCompatible(dependencies: PackageDependencies, rootDir?: string): Promise<boolean>;
|
|
5728
5734
|
/**
|
|
5729
5735
|
* 创建框架插件实例。
|
|
5730
5736
|
*
|
|
@@ -5762,6 +5768,17 @@ interface FrameworkPlugin extends Analyzer {
|
|
|
5762
5768
|
* @returns Vite 用户配置对象。
|
|
5763
5769
|
*/
|
|
5764
5770
|
viteConfig: (configuredPlugins: Plugin[]) => UserConfig;
|
|
5771
|
+
/**
|
|
5772
|
+
* 该框架的**生成目录** glob,供文件爬取排除(可选)。
|
|
5773
|
+
*
|
|
5774
|
+
* 用于框架把代码生成到工程内的场景 —— 例如 Umi 4 的 `src/.umi`,
|
|
5775
|
+
* dev server 一跑就是几万行。不排除的话它们会进组件树,
|
|
5776
|
+
* 既慢又干扰(`docs/spec-umijs-support.md` G2)。
|
|
5777
|
+
*
|
|
5778
|
+
* 语义边界:**只是"不扫这些路径"**,不是 gitignore,也不影响文件读取
|
|
5779
|
+
* (`@cfp-studio/reader` 仍能读到)。不实现该字段即保持现状。
|
|
5780
|
+
*/
|
|
5781
|
+
ignoredPaths?: string[];
|
|
5765
5782
|
/**
|
|
5766
5783
|
* 清理插件资源的方法。
|
|
5767
5784
|
* 当插件不再使用时,调用此方法释放资源。
|
|
@@ -5777,9 +5794,24 @@ interface FrameworkPlugin extends Analyzer {
|
|
|
5777
5794
|
*/
|
|
5778
5795
|
//#endregion
|
|
5779
5796
|
//#region src/index.d.ts
|
|
5797
|
+
/**
|
|
5798
|
+
* Umi 4 工程里需要从依赖预构建中排除的 native 模块。
|
|
5799
|
+
*
|
|
5800
|
+
* **为什么需要**:`@umijs/bundler-vite` 自带一份 `vite@7`,其依赖 `fsevents`
|
|
5801
|
+
* 含 `.node` 二进制;esbuild 的依赖预构建没有 `.node` 的 loader,会导致
|
|
5802
|
+
* `error while updating dependencies` 而整个预览起不来。
|
|
5803
|
+
*
|
|
5804
|
+
* 实测(`docs/spec-umijs-support.md` §11.2):不加这两项,任何
|
|
5805
|
+
* `import ... from "umi"` 的组件都无法预览。
|
|
5806
|
+
*
|
|
5807
|
+
* 只排除**已知的 native 模块**,不做 `platform: "node"` 之类的全局放宽 ——
|
|
5808
|
+
* 那会改变所有依赖的构建目标。
|
|
5809
|
+
*/
|
|
5810
|
+
declare const UMI_NATIVE_MODULES_TO_EXCLUDE: string[];
|
|
5811
|
+
declare const UMI_GENERATED_DIR_GLOBS: string[];
|
|
5780
5812
|
declare const reactFrameworkPlugin: FrameworkPluginFactory;
|
|
5781
5813
|
/**
|
|
5782
5814
|
* 导出默认的 reactFrameworkPlugin 对象
|
|
5783
5815
|
*/
|
|
5784
5816
|
//#endregion
|
|
5785
|
-
export { reactFrameworkPlugin as default };
|
|
5817
|
+
export { UMI_GENERATED_DIR_GLOBS, UMI_NATIVE_MODULES_TO_EXCLUDE, reactFrameworkPlugin as default };
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"node:path";import
|
|
1
|
+
import{createRequire as e}from"node:module";import t from"node:fs";import n from"node:path";import r from"node:url";import i from"@vitejs/plugin-react";import{polyfillNode as a}from"esbuild-plugin-polyfill-node";import{createAnalyzer as o}from"@cfp-studio/analyzer-api/analyzer";import{createFileSystemReader as s,createStackedReader as c}from"@cfp-studio/vfs";import l from"typescript";import{decodePreviewableId as u,defineSpecialTypes as d,generatePreviewableId as f}from"@cfp-studio/analyzer-api";import{parseSerializableValue as p}from"@cfp-studio/serializable-values";import{extractArgs as m,extractCsf3Stories as h,extractStoriesInfo as g,resolvePreviewableId as _}from"@cfp-studio/storybook-helpers";import{EMPTY_OBJECT_TYPE as v,NODE_TYPE as y,UNKNOWN_TYPE as b,arrayType as x,dereferenceType as S,functionType as C,helpers as w,maybeOptionalType as T,objectType as E,stripUnusedTypes as D}from"@cfp-studio/type-analyzer";function O(e,t){for(let n of e.statements)if(l.isExpressionStatement(n)&&l.isBinaryExpression(n.expression)&&n.expression.operatorToken.kind===l.SyntaxKind.EqualsToken&&l.isPropertyAccessExpression(n.expression.left)&&l.isIdentifier(n.expression.left.expression)){if(n.expression.left.expression.text!==t)continue;if(n.expression.left.name.text===`propTypes`)return n.expression.right}else if(l.isClassDeclaration(n)&&n.name){if(n.name.text!==t)continue;for(let e of n.members)if(l.isPropertyDeclaration(e)&&e.initializer&&e.name.getText()===`propTypes`)return e.initializer}return null}function k(e,t,n,r,i){let a=t.sourceFile(n),o=null;a&&(o=O(a,r));let s=null;return s=o?j(t,o):A(e,t,i),{props:s.type,types:{...s.collected}}}function A(e,t,n){let r=n.getParameters()[0];if(!r)return{type:v,collected:{}};if(!r.valueDeclaration)return{type:b,collected:{}};let i=t.checker.getTypeOfSymbolAtLocation(r,r.valueDeclaration);try{let{type:e,collected:n}=t.resolveType(i);if([e]=S(e,n,[]),e.kind===`object`&&l.isParameter(r.valueDeclaration)&&l.isObjectBindingPattern(r.valueDeclaration.name)){let t=r.valueDeclaration.name,n=new Set,i=new Set,a=!1;for(let e of t.elements){if(e.dotDotDotToken){a=!0;break}let t=e.propertyName||e.name;if(!l.isIdentifier(t)){a=!0;break}n.add(t.text),e.initializer&&i.add(t.text)}a||(e=E(Object.fromEntries(Object.entries(e.fields).filter(([e])=>n.has(e)).map(([e,t])=>[e,T(t,i.has(e))]))))}return{type:e,collected:n}}catch(n){e.warn(`Unable to resolve props type for ${t.checker.typeToString(i)}`),e.error(n.toString())}return{type:b,collected:{}}}function j(e,t){let n=e.checker.getTypeAtLocation(t),r={},i={};for(let t of n.getProperties())r[t.name]=(()=>{if(!t.valueDeclaration)return b;let n=e.checker.getTypeAtLocation(t.valueDeclaration),r=e.resolveTypeArguments(n),a=r.types[0];return a?(i={...i,...r.collected},T(a,n.symbol.name===`Requireable`)):b})();let a=E(r);return{type:a,collected:D(i,[a])}}async function M(e,t,r,i){let a=t.sourceFile(i);if(!a)return[];let o=[];for(let e of a.statements)if(l.isExportAssignment(e)){if(l.isIdentifier(e.expression))continue;o.push([`default`,e,e.expression])}else if(l.isVariableStatement(e))for(let t of e.declarationList.declarations)!l.isIdentifier(t.name)||!t.initializer||o.push([t.name.text,e,t.initializer]);else if(l.isFunctionDeclaration(e)){let t=!!e.modifiers?.find(e=>e.kind===l.SyntaxKind.ExportKeyword)&&!!e.modifiers?.find(e=>e.kind===l.SyntaxKind.DefaultKeyword),n=e.name?.text;(t||n)&&o.push([n||`default`,e,e])}else l.isClassDeclaration(e)&&e.name&&o.push([e.name.text,e,e]);let s=g(a);e.debug(`**storiresInfo: ${s}`);let c=[],d=m(a),_=w.extractExportedNames(a);async function v(n,a,o){if(o===`default`&&s)return null;let c=d[o],l=o===`default`||!!_[o],u=P(t.checker,a);if(s&&l&&(c||u?.parameters.length===0)){let i=N(e,t,r,s.component);return{...n,analyze:async()=>({args:c?{sourcePosition:{start:c.getStart(),end:c.getEnd()},value:await p(c)}:null}),associatedComponent:i}}return u?{...n,exported:l,analyze:async()=>k(e,t,i,o,u)}:null}for(let[e,t,a]of o){let o=await v({id:f({filePath:n.relative(r,i),name:e}),sourcePosition:{start:t.getStart(),end:t.getEnd()}},a,e);o&&c.push(o)}return[...c,...await h(r,t,a,async i=>{let{filePath:a}=u(i),o=(await M(e,t,r,n.join(r,a))).find(e=>e.id===i);return!o||!(`exported`in o)?{props:b,types:{}}:o.analyze()})]}function N(e,t,r,i){let a=_(r,t.checker,i);return i&&a?{id:a,analyze:async()=>{let o=P(t.checker,i);if(!o)return{props:b,types:{}};let{filePath:s,name:c}=u(a);return k(e,t,n.join(r,s),c,o)}}:null}function P(e,t){let n=e.getTypeAtLocation(t);if(n.flags===l.TypeFlags.Any&&l.isCallExpression(t)&&l.isPropertyAccessExpression(t.expression)&&l.isIdentifier(t.expression.name)&&t.expression.name.text===`bind`){let r=e.getSymbolAtLocation(t.expression.expression);r?.valueDeclaration&&l.isVariableDeclaration(r.valueDeclaration)&&r.valueDeclaration.initializer&&(n=e.getTypeAtLocation(r.valueDeclaration.initializer))}for(let e of n.getCallSignatures())if(F(e.getReturnType()))return e;if(n.symbol){let r=e.getTypeOfSymbolAtLocation(n.symbol,t);for(let e of r.getConstructSignatures())if(e.getReturnType().getProperty(`render`))return e}return null}function F(e){return!!L(e)}const I=new Set([`Element`,`ReactElement`]);function L(e){if(e.isUnion()){for(let t of e.types)if(L(t))return!0}return I.has(e.symbol?.getEscapedName().toString())}const R=d({Component:y,ComponentClass:C(y),FunctionComponent:C(y),ReactElement:y,ReactNode:y,ReactNodeArray:x(y),ReactPortal:y}),z=o({typeAnalyzerOptions:({reader:e,rootDir:i})=>{let a=r.fileURLToPath(new URL(`.`,import.meta.url)),o=n.join(a,`..`,`types`),u=n.join(a,`..`,`node_modules`),d=t.existsSync(o)?o:u;return{reader:c([e,s({mapping:{from:d,to:n.join(i,`node_modules`)},watch:!1}),...d===o?[s({mapping:{from:o,to:n.join(i,`node_modules`,`@types`)},watch:!1})]:[]]),specialTypes:R,tsCompilerOptions:{jsx:l.JsxEmit.ReactJSX,jsxImportSource:`react`}}},crawlFile:({logger:e,resolver:t,rootDir:n,absoluteFilePath:r})=>M(e,t,n,r)}),B={line:`data-inspector-line`,column:`data-inspector-column`,relativePath:`data-inspector-relative-path`};function V(e){return e.includes(B.relativePath)}function H(e,t){if(!e.includes(`<`)||V(e))return;let n=l.createSourceFile(t.relativePath,e,l.ScriptTarget.Latest,!0,l.ScriptKind.TSX),r=[],i=e=>{if(l.isJsxElement(e)||l.isJsxSelfClosingElement(e)){let i=l.isJsxElement(e)?e.openingElement:e,a=i.tagName.getStart(n),{line:o,character:s}=n.getLineAndCharacterOfPosition(a),c=i.tagName.getEnd(),u=` ${B.line}="${o+1}" ${B.column}="${s+1}" ${B.relativePath}="${U(t.relativePath)}"`;r.push({at:c,text:u})}l.forEachChild(e,i)};if(i(n),r.length===0)return;r.sort((e,t)=>t.at-e.at);let a=e;for(let{at:e,text:t}of r)a=a.slice(0,e)+t+a.slice(e);return a}function U(e){return e.replace(/&/g,`&`).replace(/"/g,`"`)}const W=/import (\* as )?React[ ,]/;function G(){return{name:`previewjs:react-js-imports`,async transform(e,t){return t.includes(`node_modules`)||n.extname(t)!==`.js`||W.test(e)?e:`import React from 'react';${e}`}}}const K=e(import.meta.url),q=[`fsevents`];function J(e,t){if(t.react)return{};let r={};for(let t of[`react`,`react-dom`])try{let i=K.resolve(`${t}/package.json`,{paths:[e]});r[t]=n.dirname(i)}catch{}return r}function Y(e,r){try{let i=K.resolve(e,{paths:[r]}),a=n.dirname(i);for(;a!==n.dirname(a);){let e=n.join(a,`package.json`);if(t.existsSync(e)){let n=JSON.parse(t.readFileSync(e,`utf8`));return typeof n.version==`string`?n.version:null}a=n.dirname(a)}return null}catch{return null}}const X=[`src/.umi/**`,`src/.umi-production/**`,`src/.umi-test/**`,`src/.umi-tmp/**`];function Z(){let e=``;return{name:`previewjs:inspector-attrs`,enforce:`pre`,configResolved(t){e=t.root},transform(t,r){let i=r.split(`?`)[0]??r;if(!/\.[jt]sx$/.test(i)||i.includes(`node_modules`)||i.includes(`__previewjs__`)||i.includes(`__previewjs_internal__`))return;let a=H(t,{relativePath:n.relative(e,i).split(n.sep).join(`/`)});if(a!==void 0)return{code:a,map:null}}}}const Q={info:{apiVersion:5,name:`@cfp-studio/plugin-react`},isCompatible:async(e,t)=>{let n=await e.react?.readInstalledVersion();if(!n&&t&&(n=Y(`react`,t)),!n)return!1;let[r,i]=n.split(`.`).map(e=>Number.parseInt(e));return Number.isNaN(r)||Number.isNaN(i)?!1:r>=17||r===16&&i>=14},async create({rootDir:e,reader:o,logger:s,dependencies:c}){let l=r.fileURLToPath(new URL(`.`,import.meta.url)),u=await c.umi?.readInstalledVersion()??await c[`@umijs/max`]?.readInstalledVersion(),d=u?Number.parseInt(u.split(`.`)[0]??`0`)>=4:!1,f=z({rootDir:e,reader:o,logger:s}),p=n.join(l,`..`,`preview`),m=J(e,c),h=`/__previewjs_internal__/renderer/shims/umi.tsx`,g=n.join(e,`src`),_={};if(d){t.existsSync(g)&&(_[`@`]=g);let r=n.join(g,`.umi`);t.existsSync(r)?_[`@@`]=r:s.debug(`umi: 跳过 @@ 别名,src/.umi 不存在(dev server 未启动)`);let i=(()=>{try{return n.dirname(K.resolve(`umi`,{paths:[e]}))}catch{try{return n.dirname(K.resolve(`@umijs/max`,{paths:[e]}))}catch{return e}}})();for(let e of[`react-router`,`react-router-dom`])try{let t=K.resolve(`${e}/package.json`,{paths:[i]});_[e]=n.dirname(t)}catch{s.debug(`umi: 跳过 ${e} 别名,解析不到实际安装位置`)}}return{defaultWrapperPath:`__previewjs__/Wrapper.tsx`,previewDirPath:p,ignoredPaths:d?X:[],...f,viteConfig:e=>{let t=e.find(e=>e.name.startsWith(`vite:react-`));return{optimizeDeps:{...d?{exclude:q}:{},esbuildOptions:{plugins:c.next?[a()]:[]}},resolve:{alias:{"react-native":`react-native-web`,...m,...d?{umi:h,"@umijs/max":h,..._}:{}}},plugins:[Z(),G(),...e,...t?[]:[i()],{name:`previewjs:update-react-import`,async transform(e,t){if(!t.endsWith(`__previewjs_internal__/renderer/index.tsx`))return;let n=Number.parseInt(await c.react?.readInstalledVersion()||`0`);return e.replace(/__PREVIEWJS_PLUGIN_REACT_IMPORT_PATH__/g,n>=18?`./render-18`:`./render-16`)}}]}}}}};export{X as UMI_GENERATED_DIR_GLOBS,q as UMI_NATIVE_MODULES_TO_EXCLUDE,Q as default};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfp-studio/plugin-react",
|
|
3
|
-
"version": "17.3.
|
|
3
|
+
"version": "17.3.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
"esbuild-plugin-polyfill-node": "0.3.0",
|
|
17
17
|
"typescript": "5.9.3",
|
|
18
18
|
"@cfp-studio/analyzer-api": "0.1.13",
|
|
19
|
-
"@cfp-studio/serializable-values": "9.0.8",
|
|
20
19
|
"@cfp-studio/storybook-helpers": "6.0.2",
|
|
20
|
+
"@cfp-studio/serializable-values": "9.0.8",
|
|
21
21
|
"@cfp-studio/type-analyzer": "10.1.8",
|
|
22
|
-
"@cfp-studio/vfs": "2.1.
|
|
22
|
+
"@cfp-studio/vfs": "2.1.8"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@playwright/test": "1.64.1",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"react-dom": "18.3.1",
|
|
34
34
|
"vite": "6.4.5",
|
|
35
35
|
"vitest": "5.0.5",
|
|
36
|
-
"@cfp-studio/core": "1.0.
|
|
37
|
-
"@cfp-studio/
|
|
38
|
-
"@cfp-studio/
|
|
36
|
+
"@cfp-studio/core": "1.0.8",
|
|
37
|
+
"@cfp-studio/testing": "14.0.1",
|
|
38
|
+
"@cfp-studio/iframe": "18.4.3"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=20"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"typecheck": "tsc --noEmit && tsc -p preview/tsconfig.json",
|
|
52
52
|
"test": "node scripts/prepare-types.mjs && vitest run",
|
|
53
53
|
"test:watch": "vitest watch",
|
|
54
|
-
"e2e-test": "pnpm --dir tests/apps/react16 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/react17 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/react18 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/react-sass install --ignore-workspace --ignore-scripts && playwright test",
|
|
54
|
+
"e2e-test": "pnpm --dir tests/apps/react16 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/react17 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/react18 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/react-sass install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/umi4 install --ignore-workspace --ignore-scripts && pnpm --dir tests/apps/umi-max install --ignore-workspace --ignore-scripts && tsx scripts/link-shared-umi-fixtures.ts && playwright test",
|
|
55
55
|
"lint": "oxlint",
|
|
56
56
|
"format": "oxfmt --write ."
|
|
57
57
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { generatePath, matchPath, resolvePath } from "./umi.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 对照测试:shim 里 react-router 纯函数的转导出必须与 `react-router@6.3.0`
|
|
7
|
+
* 的**真实输出**一致。
|
|
8
|
+
*
|
|
9
|
+
* 每个期望值都是在真实包上跑出来的(不是手写的猜测):
|
|
10
|
+
*
|
|
11
|
+
* ```sh
|
|
12
|
+
* node -e "const rr=require('<...>/react-router/main.js'); console.log(rr.matchPath('/users/:id','/users/42'))"
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## 为什么这份测试重要
|
|
16
|
+
*
|
|
17
|
+
* 早期 shim 用的是「按 `/` 切段逐个比」的简版,错四处且都很难发现:
|
|
18
|
+
*
|
|
19
|
+
* | # | 输入 | 简版错误 | 后果 |
|
|
20
|
+
* | --- | --- | --- | --- |
|
|
21
|
+
* | 1 | 任意 | 返回 `params` 本体而非 `{ params, pathname, pathnameBase, pattern }` | 写 `match?.params.id` 的组件拿到 `undefined` |
|
|
22
|
+
* | 2 | `/files/*` + `/files/a/b/c` | 丢掉 splat 捕获 | 组件看到「匹配成功」但 `params["*"]` 为空 |
|
|
23
|
+
* | 3 | `/users/:id` + `/Users/42` | 大小写敏感 → `null` | 大小写不同的 URL 导不了 |
|
|
24
|
+
* | 4 | `/users/:id?` + `/users/42` | 产出 `"id?"` 这个脏键 | 参数名带问号 |
|
|
25
|
+
*
|
|
26
|
+
* 简版还让 e2e 用例 3e 断言的是 `match?.id`(简版的形状)—— 测试是绿的,
|
|
27
|
+
* 但语义是错的。这份单测把真实形状固定下来。
|
|
28
|
+
*
|
|
29
|
+
* ## 为什么测试放在 `src/` 而不是 `preview/`
|
|
30
|
+
*
|
|
31
|
+
* `preview/` 整体被 `vitest.config.ts` 排除(里面是浏览器端渲染入口)。
|
|
32
|
+
* 这份用例虽然测的是 `preview/shims/umi.tsx`,但它只是普通函数、不碰 DOM,
|
|
33
|
+
* 从 `src/` 引用即可。
|
|
34
|
+
*/
|
|
35
|
+
describe("matchPath 与 react-router 6.3 对齐", () => {
|
|
36
|
+
it("返回 `{ params, pathname, pathnameBase, pattern }` 四键(不是 params 本体)", () => {
|
|
37
|
+
const match = matchPath("/users/:id", "/users/42");
|
|
38
|
+
expect(Object.keys(match ?? {}).sort()).toEqual(["params", "pathname", "pathnameBase", "pattern"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("基本参数捕获", () => {
|
|
42
|
+
expect(matchPath("/users/:id", "/users/42")).toEqual({
|
|
43
|
+
params: { id: "42" },
|
|
44
|
+
pathname: "/users/42",
|
|
45
|
+
pathnameBase: "/users/42",
|
|
46
|
+
pattern: { path: "/users/:id", caseSensitive: false, end: true },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("默认大小写不敏感(`caseSensitive: false`)", () => {
|
|
51
|
+
expect(matchPath("/users/:id", "/Users/42")?.params.id).toBe("42");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("`caseSensitive: true` 时大小写敏感", () => {
|
|
55
|
+
expect(matchPath({ path: "/users/:id", caseSensitive: true }, "/Users/42")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('尾部 splat 是具名捕获组,值进 `params["*"]`', () => {
|
|
59
|
+
const match = matchPath("/files/*", "/files/a/b/c");
|
|
60
|
+
expect(match?.params["*"]).toBe("a/b/c");
|
|
61
|
+
// `pathnameBase` 不含 splat 部分。
|
|
62
|
+
expect(match?.pathnameBase).toBe("/files");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("`/files/*` 也能匹配 `/files` 本身(splat 为空字符串,不是 undefined)", () => {
|
|
66
|
+
expect(matchPath("/files/*", "/files")?.params["*"]).toBe("");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("`/users/:id/*` 同时捕获参数与 splat", () => {
|
|
70
|
+
const match = matchPath("/users/:id/*", "/users/42/extra/deep");
|
|
71
|
+
expect(match?.params.id).toBe("42");
|
|
72
|
+
expect(match?.params["*"]).toBe("extra/deep");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("`end: false` 允许前缀匹配", () => {
|
|
76
|
+
expect(matchPath({ path: "/users", end: false }, "/users/42/extra")?.pathnameBase).toBe("/users");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("默认 `end: true`:多出的段不匹配", () => {
|
|
80
|
+
expect(matchPath("/users/:id", "/users/42/extra")).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("忽略尾部斜杠", () => {
|
|
84
|
+
expect(matchPath("/users/:id/", "/users/42")?.params.id).toBe("42");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("根路径", () => {
|
|
88
|
+
expect(matchPath("/", "/")).toEqual({
|
|
89
|
+
params: {},
|
|
90
|
+
pathname: "/",
|
|
91
|
+
pathnameBase: "/",
|
|
92
|
+
pattern: { path: "/", caseSensitive: false, end: true },
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("纯 `*` 匹配任意路径", () => {
|
|
97
|
+
expect(matchPath("*", "/anything/at/all")?.params["*"]).toBe("anything/at/all");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("URL 段会被解码", () => {
|
|
101
|
+
expect(matchPath("/u/:id", "/u/%E4%B8%AD")?.params.id).toBe("中");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("malformed 百分号编码不抛错,原样返回", () => {
|
|
105
|
+
expect(matchPath("/u/:id", "/u/%ZZ")?.params.id).toBe("%ZZ");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("`:id?` 在 v6 不是可选段,且不产出 `id?` 这种脏键", () => {
|
|
109
|
+
// 与真实 react-router 一致:`?` 被当作字面字符,匹配不上。
|
|
110
|
+
expect(matchPath("/users/:id?", "/users/42")).toBeNull();
|
|
111
|
+
expect(matchPath("/users/:id?", "/users")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("resolvePath 与 react-router 6.3 对齐", () => {
|
|
116
|
+
const cases: Array<[string, string, string]> = [
|
|
117
|
+
["child", "/parent/", "/parent/child"],
|
|
118
|
+
["child", "/parent", "/parent/child"],
|
|
119
|
+
["../sibling", "/a/b/", "/a/sibling"],
|
|
120
|
+
["../../up", "/a/b/c", "/a/up"],
|
|
121
|
+
["./same", "/a/b", "/a/b/same"],
|
|
122
|
+
["", "/a/b", "/a/b"],
|
|
123
|
+
["/abs", "/a/b", "/abs"],
|
|
124
|
+
["child", "/", "/child"],
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
for (const [to, from, expected] of cases) {
|
|
128
|
+
it(`resolvePath(${JSON.stringify(to)}, ${JSON.stringify(from)}) → ${expected}`, () => {
|
|
129
|
+
expect(resolvePath(to, from).pathname).toBe(expected);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it("保留 search 与 hash(并补上前缀符号)", () => {
|
|
134
|
+
expect(resolvePath("x?q=1#h", "/base/")).toEqual({ pathname: "/base/x", search: "?q=1", hash: "#h" });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("generatePath 与 react-router 6.3 对齐", () => {
|
|
139
|
+
it("替换 `:param`", () => {
|
|
140
|
+
expect(generatePath("/users/:id/posts/:postId", { id: "7", postId: "9" })).toBe("/users/7/posts/9");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("不做 URL 编码(中文原样)", () => {
|
|
144
|
+
expect(generatePath("/u/:id", { id: "中文" })).toBe("/u/中文");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("支持尾部 splat", () => {
|
|
148
|
+
expect(generatePath("/files/*", { "*": "a/b" })).toBe("/files/a/b");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("splat 为空时去掉末尾星号", () => {
|
|
152
|
+
expect(generatePath("/files/*", {})).toBe("/files");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("缺失参数抛错(与真实实现一致)", () => {
|
|
156
|
+
expect(() => generatePath("/u/:id", {})).toThrow('Missing ":id" param');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `umi` / `@umijs/max` 的最小 shim(预览环境专用)。
|
|
3
|
+
*
|
|
4
|
+
* 见 `docs/spec-umijs-support.md` §4.2。**这个文件不进业务代码**,
|
|
5
|
+
* 只在预览服务器里通过 `resolve.alias` 顶替真实的 `umi` 包。
|
|
6
|
+
*
|
|
7
|
+
* ## 为什么要 shim
|
|
8
|
+
*
|
|
9
|
+
* `umi` 是**服务端 + 客户端混合**的包,直接 import 会用到 `process.platform`、
|
|
10
|
+
* `fs` 等 Node API,在浏览器里直接崩(实测:
|
|
11
|
+
* `TypeError: Cannot read properties of undefined (reading 'platform')`)。
|
|
12
|
+
*
|
|
13
|
+
* ## 设计原则
|
|
14
|
+
*
|
|
15
|
+
* 1. **渲染路径上的高频 API 给降级值**(`useModel` / `useRequest`)——
|
|
16
|
+
* 抛错会让整棵组件树渲染不出来,预览就失去意义。
|
|
17
|
+
* 2. **不在渲染路径的 API 显式抛错**(`request`)—— 静默 `undefined`
|
|
18
|
+
* 会让人以为"跑通了但没数据",比报错更难排查。
|
|
19
|
+
* 3. **不伪造数据**:空态就是空态,不编造看起来合理的假值。
|
|
20
|
+
*/
|
|
21
|
+
import { type AnchorHTMLAttributes, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
|
22
|
+
|
|
23
|
+
/** 内存版位置栈:`history.push` 后可被 `useLocation` 观察到。 */
|
|
24
|
+
interface MemoryLocation {
|
|
25
|
+
pathname: string;
|
|
26
|
+
search: string;
|
|
27
|
+
hash: string;
|
|
28
|
+
state: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let currentLocation: MemoryLocation = { pathname: "/", search: "", hash: "", state: null };
|
|
32
|
+
const locationListeners = new Set<(location: MemoryLocation) => void>();
|
|
33
|
+
|
|
34
|
+
function parsePath(to: string): MemoryLocation {
|
|
35
|
+
const [beforeHash, hash = ""] = to.split("#");
|
|
36
|
+
const [pathname, search = ""] = (beforeHash ?? "/").split("?");
|
|
37
|
+
return { pathname: pathname || "/", search, hash, state: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function navigate(to: string): void {
|
|
41
|
+
currentLocation = parsePath(to);
|
|
42
|
+
for (const listener of locationListeners) {
|
|
43
|
+
listener(currentLocation);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useLocation() {
|
|
48
|
+
const [location, setLocation] = useState(currentLocation);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
locationListeners.add(setLocation);
|
|
51
|
+
return () => {
|
|
52
|
+
locationListeners.delete(setLocation);
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
return location;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useNavigate() {
|
|
59
|
+
return useCallback((to: string) => {
|
|
60
|
+
navigate(to);
|
|
61
|
+
}, []);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 路由参数:预览里没有路由,恒为空对象。 */
|
|
65
|
+
export function useParams(): Record<string, string> {
|
|
66
|
+
return useMemo(() => ({}) as Record<string, string>, []);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useSearchParams(): URLSearchParams {
|
|
70
|
+
return useMemo(() => new URLSearchParams(currentLocation.search), []);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const history = {
|
|
74
|
+
get location() {
|
|
75
|
+
return currentLocation;
|
|
76
|
+
},
|
|
77
|
+
push: navigate,
|
|
78
|
+
replace: navigate,
|
|
79
|
+
goBack: () => undefined,
|
|
80
|
+
listen: (listener: (location: MemoryLocation) => void) => {
|
|
81
|
+
locationListeners.add(listener);
|
|
82
|
+
return () => {
|
|
83
|
+
locationListeners.delete(listener);
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & { to?: string; children?: ReactNode };
|
|
89
|
+
|
|
90
|
+
/** `Link` 渲染成 `<a>`,点击被拦截(不真正导航,避免预览页跳走)。 */
|
|
91
|
+
export function Link({ to = "#", children, onClick, ...rest }: LinkProps) {
|
|
92
|
+
return (
|
|
93
|
+
<a
|
|
94
|
+
{...rest}
|
|
95
|
+
href={to}
|
|
96
|
+
onClick={(event) => {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
onClick?.(event);
|
|
99
|
+
navigate(to);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
{children}
|
|
103
|
+
</a>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `NavLink` 与 `Link` 同构;预览里恒为「未激活」。 */
|
|
108
|
+
export function NavLink({ children, ...rest }: LinkProps) {
|
|
109
|
+
return <Link {...rest}>{children}</Link>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `useModel` —— **恒返回空对象**。
|
|
114
|
+
*
|
|
115
|
+
* P0 不猜初值(见 spec §9.1 的评审修正 2):需要真实初值时,
|
|
116
|
+
* 由用户在 `__previewjs__/Wrapper.tsx` 里包 Provider。
|
|
117
|
+
*
|
|
118
|
+
* selector 形式 `useModel(ns, s => …)` 正常运行(对 `{}` 求值)。
|
|
119
|
+
*/
|
|
120
|
+
export function useModel<T = Record<string, unknown>>(
|
|
121
|
+
_namespace: string,
|
|
122
|
+
selector?: (model: Record<string, unknown>) => T,
|
|
123
|
+
): T {
|
|
124
|
+
const model = useMemo(() => ({}), []);
|
|
125
|
+
return (selector ? selector(model) : model) as T;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `connect` 直通 HOC:注入空 props,不做状态映射。 */
|
|
129
|
+
export function connect<T>(Component: T): T {
|
|
130
|
+
return Component;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ------------------------------------------------------------------ *
|
|
134
|
+
* 应用上下文(`@umijs/renderer-react` 的 appContext)
|
|
135
|
+
*
|
|
136
|
+
* 这几个都在**渲染路径上**,且 `@umijs/max` 的页面组件很常见 ——
|
|
137
|
+
* 缺任何一个,整个模块会因 ESM 静态导出校验直接 `SyntaxError`,组件连挂载的机会都没有。
|
|
138
|
+
* 所以统一给「结构完整、内容为空」的降级值,而不是抛错。
|
|
139
|
+
* ------------------------------------------------------------------ */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `useAppData()` —— 返回空的应用上下文。
|
|
143
|
+
*
|
|
144
|
+
* 真实返回值包含 `routes` / `clientRoutes` / `clientLoaderData` / `pluginManager` 等。
|
|
145
|
+
* 这里只保证**字段存在且类型形状对**,不伪造路由表(组件若遍历 `routes` 会得到空循环,
|
|
146
|
+
* 而不是 `Cannot read properties of undefined`)。
|
|
147
|
+
*/
|
|
148
|
+
export function useAppData() {
|
|
149
|
+
return useMemo(
|
|
150
|
+
() => ({
|
|
151
|
+
routes: {} as Record<string, unknown>,
|
|
152
|
+
routeComponents: {} as Record<string, unknown>,
|
|
153
|
+
clientRoutes: [] as unknown[],
|
|
154
|
+
pluginManager: undefined as unknown,
|
|
155
|
+
clientLoaderData: {} as Record<string, unknown>,
|
|
156
|
+
serverLoaderData: {} as Record<string, unknown>,
|
|
157
|
+
preloadRoute: (_to: string) => undefined,
|
|
158
|
+
basename: "/",
|
|
159
|
+
history,
|
|
160
|
+
}),
|
|
161
|
+
[],
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 当前选中的路由链:预览里没有路由,恒为空数组。 */
|
|
166
|
+
export function useSelectedRoutes(): unknown[] {
|
|
167
|
+
return useMemo(() => [], []);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 当前路由的配置(`IRoute`)。
|
|
172
|
+
*
|
|
173
|
+
* 恒返回 `{}`:真实的 `path` / `component` / `routes` 都由约定式路由生成,
|
|
174
|
+
* 预览不重建那套映射(那是 `specs/0001` 应用预览的领域)。
|
|
175
|
+
*/
|
|
176
|
+
export function useRouteProps<T = Record<string, unknown>>(): T {
|
|
177
|
+
return useMemo(() => ({}) as T, []);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 路由上下文(`IRouteContextType`)。
|
|
182
|
+
*
|
|
183
|
+
* 注意它与 `useRouteProps()` 不同:后者是路由配置对象本身,前者是
|
|
184
|
+
* `{ route: IRoute }` 的包装 —— 组件常见写法是 `useRouteData().route`。
|
|
185
|
+
*/
|
|
186
|
+
export function useRouteData(): { route: Record<string, unknown> } {
|
|
187
|
+
return useMemo(() => ({ route: {} }), []);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** loader 数据:三个 loader hook 都返回空态,形状对齐真实签名。 */
|
|
191
|
+
export function useServerLoaderData(): { data: undefined } {
|
|
192
|
+
return useMemo(() => ({ data: undefined }), []);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function useClientLoaderData(): { data: undefined } {
|
|
196
|
+
return useMemo(() => ({ data: undefined }), []);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `useLoaderData()` 直接返回数据本身(不是 `{ data }` 包装)。 */
|
|
200
|
+
export function useLoaderData(): undefined {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ------------------------------------------------------------------ *
|
|
205
|
+
* react-router 转导出
|
|
206
|
+
*
|
|
207
|
+
* `@umijs/max` 的 `umi` 入口会把这些 react-router 的 API 再导出一遍。
|
|
208
|
+
* 预览不引入 react-router(它有自己的 Router 上下文),所以全部给静态降级值。
|
|
209
|
+
* ------------------------------------------------------------------ */
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `useFetcher()` —— 返回空态的 fetcher。
|
|
213
|
+
*
|
|
214
|
+
* 真实签名里 `state` 取 `"idle" | "loading" | "submitting"`,这里恒为 `idle`,
|
|
215
|
+
* 让组件的 `if (fetcher.state !== "idle") return <Spin/>` 分支走正常路径。
|
|
216
|
+
*/
|
|
217
|
+
export function useFetcher() {
|
|
218
|
+
return useMemo(
|
|
219
|
+
() => ({
|
|
220
|
+
state: "idle" as const,
|
|
221
|
+
data: undefined,
|
|
222
|
+
formData: undefined,
|
|
223
|
+
load: async () => undefined,
|
|
224
|
+
submit: async () => undefined,
|
|
225
|
+
}),
|
|
226
|
+
[],
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `useMatch()` —— 没有路由,匹配不中,恒 `null`。 */
|
|
231
|
+
export function useMatch(_pattern?: unknown): null {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `useResolvedPath()` —— 返回自身路径的解析结果(不解析相对路径)。 */
|
|
236
|
+
export function useResolvedPath(to: string) {
|
|
237
|
+
return useMemo(() => parsePath(to), [to]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `useRoutes()` —— 预览不渲染路由表,恒渲染 `null`。 */
|
|
241
|
+
export function useRoutes(_routes?: unknown): null {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `useOutlet()` —— 没有嵌套路由,恒 `null`。 */
|
|
246
|
+
export function useOutlet(_context?: unknown): null {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `useOutletContext()` —— 没有 Outlet 提供者,恒空对象。 */
|
|
251
|
+
export function useOutletContext<T = Record<string, unknown>>(): T {
|
|
252
|
+
return useMemo(() => ({}) as T, []);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* `<Outlet />` —— 渲染 `null`。
|
|
257
|
+
*
|
|
258
|
+
* 子路由的内容由 Umi 的 layout / 约定式路由注入,预览不重建。
|
|
259
|
+
*/
|
|
260
|
+
export function Outlet(): null {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `<Navigate />` —— 渲染 `null`,并同步内存 location。
|
|
266
|
+
*
|
|
267
|
+
* 真实行为会立刻跳转;预览里不真的导航(避免渲染期间改状态导致循环),
|
|
268
|
+
* 只把目标路径记进内存栈,让后续 `useLocation()` 能读到。
|
|
269
|
+
*/
|
|
270
|
+
export function Navigate({ to }: { to: string }): null {
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
navigate(to);
|
|
273
|
+
}, [to]);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 真实导出名是 `__useFetcher`,`useFetcher` 是它无下划线的别名,两个都要给。 */
|
|
278
|
+
export const __useFetcher = useFetcher;
|
|
279
|
+
|
|
280
|
+
/* ------------------------------------------------------------------ *
|
|
281
|
+
* react-router 的纯函数转导出
|
|
282
|
+
*
|
|
283
|
+
* 都是无状态的工具函数,可以给出**真实语义**(不需要路由上下文),
|
|
284
|
+
* 所以这里不降级为 `undefined` —— 降级反而会让组件的输入变成期望外的值。
|
|
285
|
+
* ------------------------------------------------------------------ */
|
|
286
|
+
|
|
287
|
+
/** `createSearchParams(init)` —— 与 react-router 一致,返回 `URLSearchParams`。 */
|
|
288
|
+
export function createSearchParams(
|
|
289
|
+
init?: string | Record<string, string> | Array<[string, string]> | URLSearchParams,
|
|
290
|
+
): URLSearchParams {
|
|
291
|
+
return new URLSearchParams(init as string | Array<[string, string]> | Record<string, string> | undefined);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `resolvePath(to, fromPathname)` —— 相对路径解析成绝对路径。
|
|
296
|
+
*
|
|
297
|
+
* 移植自 react-router 6.3 的 `resolvePath` + `resolvePathname`:
|
|
298
|
+
* 处理 `.` / `..`、空的 `pathname`(回退到 `fromPathname`)与尾斜杠保留。
|
|
299
|
+
* 返回 `{ pathname, search, hash }`,**不含** `state`。
|
|
300
|
+
*/
|
|
301
|
+
export function resolvePath(to: string, fromPathname = "/"): { pathname: string; search: string; hash: string } {
|
|
302
|
+
const parsed = parsePathForResolve(to);
|
|
303
|
+
const { search = "", hash = "" } = parsed;
|
|
304
|
+
// 空 `to`(或只有 search/hash)应该 **保留 `fromPathname`**,而不是回到根。
|
|
305
|
+
// `parsePath()` 把空串规整成了 `/`,所以这里要看原始 `to` 而不是解析结果。
|
|
306
|
+
const toPathname = to === "" || to.startsWith("?") || to.startsWith("#") ? "" : (to.split(/[?#]/)[0] ?? "");
|
|
307
|
+
const pathname = toPathname
|
|
308
|
+
? toPathname.startsWith("/")
|
|
309
|
+
? toPathname
|
|
310
|
+
: resolvePathname(toPathname, fromPathname)
|
|
311
|
+
: fromPathname;
|
|
312
|
+
return { pathname, search: normalizeSearch(search), hash: normalizeHash(hash) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 本模块私有的 parsePath(不依赖 shim 的版本,避免互相 import)。 */
|
|
316
|
+
function parsePathForResolve(to: string): { pathname: string; search?: string; hash?: string } {
|
|
317
|
+
const [beforeHash, hash = ""] = to.split("#");
|
|
318
|
+
const [pathname, search = ""] = (beforeHash ?? "/").split("?");
|
|
319
|
+
return { pathname: pathname || "/", search, hash };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 相对路径拼接(与 react-router 的 `resolvePathname` 同算法)。 */
|
|
323
|
+
function resolvePathname(relativePath: string, fromPathname: string): string {
|
|
324
|
+
const segments = fromPathname.replace(/\/+$/, "").split("/");
|
|
325
|
+
for (const segment of relativePath.split("/")) {
|
|
326
|
+
if (segment === "..") {
|
|
327
|
+
// 保留根部的空段,让 pathname 仍以 / 开头
|
|
328
|
+
if (segments.length > 1) {
|
|
329
|
+
segments.pop();
|
|
330
|
+
}
|
|
331
|
+
} else if (segment !== ".") {
|
|
332
|
+
segments.push(segment);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return segments.length > 1 ? segments.join("/") : "/";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeSearch(search: string): string {
|
|
339
|
+
return !search || search === "?" ? "" : search.startsWith("?") ? search : `?${search}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeHash(hash: string): string {
|
|
343
|
+
return !hash || hash === "#" ? "" : hash.startsWith("#") ? hash : `#${hash}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `matchPath(pattern, pathname)` —— 与 react-router 6.3 语义一致。
|
|
348
|
+
*
|
|
349
|
+
* ## 为什么不能用「按 `/` 切段逐个比」的简版
|
|
350
|
+
*
|
|
351
|
+
* 简版会错三处,而且错得很难发现:
|
|
352
|
+
*
|
|
353
|
+
* 1. **返回形状**:真实返回 `{ params, pathname, pathnameBase, pattern }`,
|
|
354
|
+
* 不是 `params` 本体。写 `match?.params.id` 的组件拿到的是 `undefined`。
|
|
355
|
+
* 2. **splat 捕获**:`/files/*` 的 `*` 是**具名捕获组**(`params["*"]`),
|
|
356
|
+
* 简版直接丢掉,组件会以为匹配上了但没有尾部路径。
|
|
357
|
+
* 3. **大小写**:默认 `caseSensitive: false`,`/Users/42` 能匹配 `/users/:id`。
|
|
358
|
+
*
|
|
359
|
+
* 所以这里直接移植 react-router 的 `compilePath` / `matchPath`(MIT,
|
|
360
|
+
* 见 `react-router@6.3.0` 的 `react-router.development.js`)。
|
|
361
|
+
*/
|
|
362
|
+
export interface PathMatch {
|
|
363
|
+
params: Record<string, string>;
|
|
364
|
+
pathname: string;
|
|
365
|
+
pathnameBase: string;
|
|
366
|
+
pattern: { path: string; caseSensitive: boolean; end: boolean };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function matchPath(
|
|
370
|
+
pattern: string | { path: string; caseSensitive?: boolean; end?: boolean },
|
|
371
|
+
pathname: string,
|
|
372
|
+
): PathMatch | null {
|
|
373
|
+
const compiled =
|
|
374
|
+
typeof pattern === "string"
|
|
375
|
+
? { path: pattern, caseSensitive: false, end: true }
|
|
376
|
+
: { path: pattern.path, caseSensitive: pattern.caseSensitive ?? false, end: pattern.end ?? true };
|
|
377
|
+
|
|
378
|
+
const [matcher, paramNames] = compilePath(compiled.path, compiled.caseSensitive, compiled.end);
|
|
379
|
+
const match = pathname.match(matcher);
|
|
380
|
+
if (!match) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const matchedPathname = match[0] as string;
|
|
384
|
+
let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1");
|
|
385
|
+
const captureGroups = match.slice(1);
|
|
386
|
+
const params: Record<string, string> = {};
|
|
387
|
+
|
|
388
|
+
paramNames.forEach((paramName, index) => {
|
|
389
|
+
// splat 的 `pathnameBase` 要用**未解码**的原始值算,所以先算再解码。
|
|
390
|
+
if (paramName === "*") {
|
|
391
|
+
const splatValue = captureGroups[index] || "";
|
|
392
|
+
pathnameBase = matchedPathname.slice(0, matchedPathname.length - splatValue.length).replace(/(.)\/+$/, "$1");
|
|
393
|
+
}
|
|
394
|
+
params[paramName] = safelyDecodeURIComponent(captureGroups[index] || "", paramName);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return { params, pathname: matchedPathname, pathnameBase, pattern: compiled };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 把路径模式编译成正则 + 参数名列表(与 react-router 同):`^/users/([^\/]+)\/?$`。 */
|
|
401
|
+
function compilePath(path: string, caseSensitive = false, end = true): [RegExp, string[]] {
|
|
402
|
+
const paramNames: string[] = [];
|
|
403
|
+
let regexpSource = `^${path
|
|
404
|
+
.replace(/\/*\*?$/, "") // 尾部的 / 与 /* 由下面单独处理
|
|
405
|
+
.replace(/^\/*/, "/") // 保证以 / 开头
|
|
406
|
+
.replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // 转义正则特殊字符
|
|
407
|
+
.replace(/:(\w+)/g, (_match, paramName: string) => {
|
|
408
|
+
paramNames.push(paramName);
|
|
409
|
+
return "([^\\/]+)";
|
|
410
|
+
})}`;
|
|
411
|
+
|
|
412
|
+
if (path.endsWith("*")) {
|
|
413
|
+
paramNames.push("*");
|
|
414
|
+
// `*` / `/*` 已经匹配了开头的 /,剩下的直接 capture;
|
|
415
|
+
// 否则不要把这个 / 算进 params["*"]。
|
|
416
|
+
regexpSource += path === "*" || path === "/*" ? "(.*)$" : "(?:\\/(.+)|\\/*)$";
|
|
417
|
+
} else if (end) {
|
|
418
|
+
regexpSource += "\\/*$"; // 匹配到末尾时忽略尾斜杠
|
|
419
|
+
} else {
|
|
420
|
+
regexpSource += "(?:(?=[.~-]|%[0-9A-F]{2})|\\b|\\/|$)";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return [new RegExp(regexpSource, caseSensitive ? undefined : "i"), paramNames];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 解码失败的 URL 段原样返回,不抛错(与 react-router 同:那里只打 warning)。 */
|
|
427
|
+
function safelyDecodeURIComponent(value: string, _paramName: string): string {
|
|
428
|
+
try {
|
|
429
|
+
return decodeURIComponent(value);
|
|
430
|
+
} catch {
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* `generatePath(pattern, params)` —— 把 `:param` 与尾部 `*` 替换成实际值。
|
|
437
|
+
*
|
|
438
|
+
* 移植自 react-router 6.3:**不做 URL 编码**(中文等原样输出),
|
|
439
|
+
* 缺失参数抛错,`*` 允许为空。
|
|
440
|
+
*/
|
|
441
|
+
export function generatePath(pattern: string, params: Record<string, string> = {}): string {
|
|
442
|
+
return pattern
|
|
443
|
+
.replace(/:(\w+)/g, (_match, key: string) => {
|
|
444
|
+
const value = params[key];
|
|
445
|
+
// 真实实现用 `params[key] != null`(同时排除 null 与 undefined),
|
|
446
|
+
// 这里展开写,避免依赖宽松相等。
|
|
447
|
+
if (value === undefined || value === null) {
|
|
448
|
+
throw new Error(`Missing ":${key}" param`);
|
|
449
|
+
}
|
|
450
|
+
return value;
|
|
451
|
+
})
|
|
452
|
+
.replace(/\/*\*$/, () => {
|
|
453
|
+
const splat = params["*"];
|
|
454
|
+
return splat === undefined || splat === null ? "" : String(splat).replace(/^\/*/, "/");
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `matchRoutes(routes, pathname)` —— 没有路由表时恒 `null`。 */ export function matchRoutes(
|
|
459
|
+
_routes?: unknown,
|
|
460
|
+
_pathname?: string,
|
|
461
|
+
): null {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* `withRouter()` —— 直通 HOC。
|
|
467
|
+
*
|
|
468
|
+
* 真实版本注入 `history` / `location` / `match`;预览没有路由上下文,
|
|
469
|
+
* 注入空值(与 `connect` 的策略一致)。
|
|
470
|
+
*/
|
|
471
|
+
export function withRouter<T>(Component: T): T {
|
|
472
|
+
return Component;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* ------------------------------------------------------------------ *
|
|
476
|
+
* helmet / history 转导出
|
|
477
|
+
* ------------------------------------------------------------------ */
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* `<Helmet>` / `<HelmetProvider>` —— 渲染子节点,不改文档头。
|
|
481
|
+
*
|
|
482
|
+
* 预览的 `document.title` 由面板控制,让组件随便改会与宿主抢控制权。
|
|
483
|
+
*/
|
|
484
|
+
export function Helmet({ children }: { children?: ReactNode }): ReactNode {
|
|
485
|
+
return children ?? null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function HelmetProvider({ children }: { children?: ReactNode }): ReactNode {
|
|
489
|
+
return children ?? null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* `createMemoryHistory()` —— 返回与 shim 的 `history` 同构的内存栈。
|
|
494
|
+
*
|
|
495
|
+
* 注意:真实 `history` 包的接口大得多(`block` / `go` / `createHref` 等)。
|
|
496
|
+
* 这里只提供**渲染路径上常见**的几个,未实现的显式抛错(与 `request` 同策略)。
|
|
497
|
+
*/
|
|
498
|
+
export function createMemoryHistory(_options?: unknown) {
|
|
499
|
+
return history;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export const createBrowserHistory = createMemoryHistory;
|
|
503
|
+
export const createHashHistory = createMemoryHistory;
|
|
504
|
+
|
|
505
|
+
/** `LinkWithPrefetch as Link` —— 真实导出名是无下划线的 prefetch 版,预览里与 `Link` 同构。 */
|
|
506
|
+
export const LinkWithPrefetch = Link;
|
|
507
|
+
|
|
508
|
+
/* ------------------------------------------------------------------ *
|
|
509
|
+
* 应用挂载入口
|
|
510
|
+
*
|
|
511
|
+
* 这两个是 **应用级** API(把整棵路由树挂到 DOM),组件预览永远不会调用。
|
|
512
|
+
* 仍然显式导出:ESM 是**静态**导出校验,少一个名字整个模块就 `SyntaxError`,
|
|
513
|
+
* 整个预览白屏 —— 而降级为抛错至少能给出可读的原因。
|
|
514
|
+
* ------------------------------------------------------------------ */
|
|
515
|
+
|
|
516
|
+
/** `renderClient()` —— 应用级挂载,不在组件预览的职责内。 */
|
|
517
|
+
export function renderClient(): never {
|
|
518
|
+
throw new Error("预览环境不提供 umi.renderClient —— 应用级渲染请用 DevBrowser(应用预览)");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** `__getRoot()` —— 拿到应用根元素,同上。 */
|
|
522
|
+
export function __getRoot(): never {
|
|
523
|
+
throw new Error("预览环境不提供 umi.__getRoot —— 应用级渲染请用 DevBrowser(应用预览)");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* `useRequest` —— **返回空态而非抛错**。
|
|
528
|
+
*
|
|
529
|
+
* 理由(spec §4.2.1):它是 `@umijs/max` 下最高频的 API,抛错会让几乎所有页面
|
|
530
|
+
* 组件在预览里直接崩。空态能让组件的 `if (!data) return null` 分支自然生效。
|
|
531
|
+
*
|
|
532
|
+
* 选择 `loading: false` 而非 `true`:`true` 会让组件永远停在骨架屏,
|
|
533
|
+
* 而预览的目的是看结构,不是看 loading。
|
|
534
|
+
*
|
|
535
|
+
* 注意:**不伪造数据**,`data` 恒为 `undefined`。
|
|
536
|
+
*/
|
|
537
|
+
let warnedUseRequestEmpty = false;
|
|
538
|
+
|
|
539
|
+
export function useRequest<R = unknown>(_service: unknown, _options?: unknown) {
|
|
540
|
+
if (!warnedUseRequestEmpty && typeof console !== "undefined") {
|
|
541
|
+
// 只打一次:每次渲染都打会把控制台刷爆。
|
|
542
|
+
// 让"为什么没数据"可被发现,而不是让人误以为是 bug(spec §11 R6)。
|
|
543
|
+
warnedUseRequestEmpty = true;
|
|
544
|
+
console.debug("[cfp-preview] useRequest:预览环境返回空态");
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
data: undefined as R | undefined,
|
|
548
|
+
error: undefined,
|
|
549
|
+
loading: false,
|
|
550
|
+
run: async () => undefined as R | undefined,
|
|
551
|
+
runAsync: async () => undefined as R | undefined,
|
|
552
|
+
refresh: async () => undefined as R | undefined,
|
|
553
|
+
refreshAsync: async () => undefined as R | undefined,
|
|
554
|
+
mutate: () => undefined,
|
|
555
|
+
cancel: () => undefined,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** 其它未实现的 API:显式抛错,不做静默 `undefined`。 */
|
|
560
|
+
export function request(): never {
|
|
561
|
+
throw new Error("预览环境未实现 umi.request —— 请求类逻辑请在 DevBrowser(应用预览)里验证");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function useAccess(): Record<string, boolean> {
|
|
565
|
+
return {};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function useIntl() {
|
|
569
|
+
return {
|
|
570
|
+
formatMessage: ({ id }: { id: string }) => id,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function getLocale(): string {
|
|
575
|
+
return "zh-CN";
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function setLocale(): void {
|
|
579
|
+
// 预览里切语言不生效:Umi 的 locale 由运行时注入,shim 不做模拟。
|
|
580
|
+
}
|