devfab 1.5.0 → 2.0.0

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
@@ -1,290 +1,512 @@
1
- # devfab
2
-
3
- 低代码「开发者工厂」的浏览器运行时库。
4
-
5
- 链路:后端按 `billno` 下发视图元数据 JSON → `builder.render(meta)` 渲染成组件树 → 各 adapter 把第三方控件包成「受模型驱动、状态可被外部 `setState` / `getState` 读写」的受控组件。
6
-
7
- - 源码纯 JavaScript(`.js` / `.jsx`),无 TypeScript、不产出类型声明
8
- - React 与 Vue 3 双适配层,共用一个与框架无关的模型层(`BaseModel` 体系)
9
- - 通过 RequireJS 加载领域扩展脚本,实现「元数据 + 扩展脚本」的二次开发模式
10
-
11
- ## 目录结构
12
-
13
- ```
14
- src/
15
- ├── index.js 包入口:组装 builder / models / utils
16
- ├── _models/ 模型层(与框架无关)
17
- │ ├── BaseModel.js 状态容器基类:_data 读写、状态推送、事件、缓存、代理
18
- │ ├── SimpleModel.js 单字段受控模型
19
- │ ├── ViewModel.js 页面级容器:子模型登记与数据收集
20
- │ ├── FilterModel.js 筛选条件容器
21
- │ ├── GridModel.js 表格模型(两层数据 / 行状态 / 分页 / 选中 / 校验)
22
- │ └── index.js 模型工厂 createViewModel / initViewModel
23
- ├── _utils/
24
- │ ├── builder.js 组件注册表 + 渲染工厂(模块级单例)
25
- │ ├── event.js 事件总线(同步 execute + 异步 promiseExecute)
26
- │ ├── cache.js 键值缓存
27
- │ ├── dynamicProxy.js 把接口配置映射成可调用的方法
28
- │ ├── ajax.js XHR / JSONP 请求封装
29
- │ └── string.js URL 拼接、QueryString、uid 生成、base64
30
- ├── _lib/requirejs.js vendored RequireJS 2.3.2 + 扩展脚本适配层
31
- ├── react/
32
- │ ├── index.js React 适配层入口
33
- │ └── adapter/
34
- │ ├── common/ refAdapter / labelAdapter / loadMetaAdapter / dataSourceAdapter / modalAdapter
35
- │ └── model/ modelAdapter / pageAdapter / dataSourceAdapter / filterAdapter
36
- └── vue/
37
- ├── index.js Vue 适配层占位(未参与构建)
38
- └── vue-adapter/ Vue 3 适配层:class-adapter / function-adapter
39
- ```
40
-
41
- `dist/` 与 `lib/` 是构建产物,已移出版本控制,不要手改;改完源码重新构建。
42
-
43
- ## 安装
44
-
45
- ```bash
46
- npm install devfab
47
- ```
48
-
49
- 外部依赖由宿主提供,不打进产物(见 `peerDependencies`):
50
-
51
- - React 侧:`react` >= 16 / `react-dom` >= 16 / `classnames`
52
- - Vue 侧:`vue` >= 3(**仅支持 Vue 3**,适配层使用 `defineComponent` / `reactive` / `h`)
53
- - `antd` 在构建配置里被列为 React 产物的 `external`,但未写进 `peerDependencies`,宿主需自行保证存在
54
-
55
- ## 开发与构建
56
-
57
- | 命令 | 说明 |
58
- | --- | --- |
59
- | `npm run dev` | 清理后以 development 模式构建(产物不压缩,便于调试) |
60
- | `npm run build` | 清理 → 生产构建(压缩产物) |
61
- | `npm run smoke` | 产物冒烟测试(零依赖脚本,需先 `npm run build`) |
62
- | `npm run lint` / `npm run lint:fix` | ESLint 检查 / 自动修复 |
63
- | `npm run format` / `npm run format:check` | Prettier 格式化 / 校验 |
64
- | `npm run clean` | 删除 `dist` 与 `lib` |
65
-
66
- 构建用 Rollup 多入口配置(`rollup.config.js`),每个入口产出 CJS / ESM(部分含 UMD)。几处需要注意的编译边界:
67
-
68
- - React 侧 JSX 走 `@babel/preset-react`(编译成 `createElement`);Vue 侧 JSX 走 `@vue/babel-plugin-jsx`(编译成 `createVNode`)。两套 babel 配置不能混用。
69
- - `resolve.extensions` 显式加入了 `.jsx` —— 适配层文件名是 `.jsx`,但内部 import 不带扩展名。
70
- - `babelHelpers: 'bundled'` 内联 helper,避免 `preserveModules` 产物在 CJS 里 `require` ESM 版 `@babel/runtime` helper。
71
-
72
- ## 产物清单
73
-
74
- | 路径 | 格式 | 说明 |
75
- | --- | --- | --- |
76
- | `lib/devfab.js` | UMD | 核心运行时,`<script>` 引入后挂到 `window.devfab`;始终不压缩,便于排查 |
77
- | `lib/devfab.min.js` | UMD | 压缩版,任何构建模式都产出 |
78
- | `lib/requirejs.js` | — | vendored RequireJS,挂 `window.require` / `window.define` 与 `window.__devfab` |
79
- | `dist/index.js` `.esm.js` `.umd.js` | CJS / ESM / UMD | 核心包入口,对应 `package.json` 的 `main` / `module` / `umd` |
80
- | `dist/react/**` | CJS(`preserveModules`,保留 `src/react` 目录结构) | React 适配层 |
81
- | `dist/vue/vue3-adapter.js` `.esm.js` | CJS / ESM | Vue 3 适配层,由宿主显式 import,不产出 UMD、不挂全局 |
82
- | `dist/common/models.js` `.esm.js` | CJS / ESM | 模型层单独入口,可脱离框架使用 |
83
- | `dist/common/utils.js` `.esm.js` | CJS / ESM | 工具层单独入口 |
84
-
85
- ## 运行时契约
86
-
87
- React 适配层从**全局**读取运行时:`src/react/adapter/**` 里直接使用裸标识符 `devfab.builder`、`devfab.utils.models`,编译后原样保留,运行时解析到 `window.devfab`。因此宿主必须先准备好全局运行时,再加载适配层组件:
88
-
89
- ```html
90
- <script src="..../lib/requirejs.js"></script>
91
- <script src="..../lib/devfab.js"></script>
92
- ```
93
-
94
- ## 模型层
95
-
96
- ### `BaseModel` —— 状态容器基类
97
-
98
- 所有模型共享的底座,数据统一放在 `_data` 里(`_set_data` / `_get_data` / `_del_data` / `_cls_data`)。
99
-
100
- | 分组 | 方法 |
101
- | --- | --- |
102
- | 取值 | `getValue` / `getData` / `get(name)` / `getState(name, ctrlName)` |
103
- | 赋值 | `setValue(value, fireEvent, ctrlName)` / `setState(name, value, ctrlName)` / `setVisible` / `setDisabled` |
104
- | 控件登记 | `addComponent(component, ctrlName)` / `removeComponent` / `doPropertyChange` |
105
- | 父子关系 | `setParent` / `getParent` / `getRootParent` / `setName` / `getName` |
106
- | 脏检查 | `setDirty` / `getDirtyData(necessary)` / `clear(useDefault)` |
107
- | 事件 | `on` / `onFirst` / `un` / `hasEvent` / `execute` / `fireEvent` / `promiseExecute` |
108
- | 缓存 | `setCache` / `getCache` / `clearCache` |
109
- | 请求代理 | `setProxy(config)` / `getProxy()` |
110
-
111
- 状态推送的核心是 `_applyState(name, value, ctrlName)`:优先调用控件的 `setXxx`(如 `setValue`),控件没实现就退回 `setState({ [name]: value })`。传 `ctrlName` 可定向通知,只推给匹配的控件。
112
-
113
- `setValue` 支持事件拦截:传 `fireEvent`(或模型上标记 `forceFireEvent`)时走 `promiseExecute('beforeValueChange', ...)`,先跑 `beforeValueChange` 监听,通过后才写入并触发 `afterValueChange`,任一步返回 `false` 则回滚成旧值。
114
-
115
- ### 四个具体模型
116
-
117
- | 类 | 职责 |
118
- | --- | --- |
119
- | `SimpleModel` | 单字段受控模型,补充 `setReadonly` / `getReadonly` |
120
- | `ViewModel` | 页面级容器:`addProperty` 登记子模型,`getData` / `getAllData(withOutGrid)` 收集数据,`getGridModel` / `getGridModels` 定位子表格,`loadData` 批量灌数据 |
121
- | `FilterModel` | 筛选条件容器:`mounted` 按 `conditions` 初始化条件模型,`getParams` 收集条件,`search()` 触发查询事件 |
122
- | `GridModel` | 表格模型,见下 |
123
-
124
- ### `GridModel`
125
-
126
- 参照中台 `mdf-cube/src/models/GridModel.js` 重写,把它的约定搬进 devfab 的 ui-model 体系(文件头注释里有完整对照说明):
127
-
128
- - 两层数据:`dataSource`(全量)+ `rows`(当前展示行,本地模式下是 `dataSource` 的分页切片)
129
- - 行状态与数据同序:`rowsDataState[i]` 对应 `dataSource[i]`,取值见 `DataStates`(`''` / `Insert` / `Update` / `Delete`)
130
- - 内部字段:`_id` / `_selected` / `_status`
131
- - 列是 **object map**(key = `field`)而非数组,渲染 vxe-grid 时用 `getColumnList()` 转数组
132
- - 分页状态叫 `pageInfo`(`pageIndex` / `pageSize` / `pageCount` / `recordCount`),`pageSize = -1` 表示不分页
133
- - 选中分两层:当前页 `_selected` 标记 + 跨页选中缓存
134
- - 提供 `insertRow` / `appendRow` / `updateRow` / `deleteRows` / `clear`、`getDirtyData` / `backupState` / `restoreState`、`getCellValue` / `setCellValue` / `setCellState`、`validate` 等
135
-
136
- 与中台实现的有意差异:取数直接用 `proxyConfig.load`(兼容旧的 `dataSource: fn` / `cUrl`);`setRows` 推原始行对象而非 `getShowRows()` 展示副本(否则选中回灌与单元格编辑会落到副本上);未移植中台的行模型(editRowModel / ReferModel / 特征 / 孙表)。
137
-
138
- ### 模型工厂
139
-
140
- `_models/index.js` 提供两个入口,用于按元数据批量造模型:
141
-
142
- ```js
143
- import { createViewModel, initViewModel } from 'devfab/dist/common/models'
144
-
145
- // 返回 VM 类,可自行 new + init
146
- const VM = createViewModel(meta, params)
147
- // 直接返回实例:内部已执行 init(meta.models, meta)
148
- const vm = initViewModel(meta, params)
149
- ```
150
-
151
- `params` 可配置:`domainKeyField` / `billNoField` / `modelNameField`(默认 `domainKey` / `billNo` / `modelName`)、`enableExtendjs`(是否加载扩展脚本)、`require`(覆盖 require 实现)、`createModelFactory`(自定义控件类型的模型映射)、`onBeforeCreateModel`(造模型前的钩子)。
152
-
153
- 创建后 `initData()` → `initExtendJs()` 会加载 `${billno}_VM.Extend.js` 并执行其 `doAction('init', vm)`;无论成功失败都会抛出 `extendReady` 事件,宿主可监听它作为「渲染就绪」信号。
154
-
155
- ## `builder` —— 组件注册与渲染
156
-
157
- `_utils/builder.js` 是模块级单例(`isInit` / `components` / `renderFactory` 都在模块作用域),一个页面只能存在一份。
158
-
159
- | 方法 | 说明 |
160
- | --- | --- |
161
- | `init(fn)` | 只执行一次的初始化钩子 |
162
- | `initComponent(name, comp)` / `initComponent({ Name: comp })` | 注册组件,支持单个或对象批量;重名只告警不覆盖 |
163
- | `getComponent` / `hasComponent` / `getComponents` | 读取注册表 |
164
- | `initRenderFactory(fn)` | 注入「元数据节点 → 框架元素」的转换函数,**必须由宿主提供** |
165
- | `render(config, extraConfig)` | 按元数据数组递归渲染,返回节点数组 |
166
- | `clear()` | 清空注册表、渲染工厂与初始化标记 |
167
-
168
- `render` 的渲染优先级:节点自带的 `render({ node, props })` > 全局 `renderFactory({ node, props })` > 直接返回 `{ component, children, ...props }`。`extraConfig.props` 会作为默认 props 展开。组件未注册时只打印告警,不抛错。
169
-
170
- ## React 适配层
171
-
172
- 所有 adapter 都是高阶组件:`Adapter(Com) => 包装后的组件`。
173
-
174
- ### `common/`
175
-
176
- | 文件 | 作用 |
177
- | --- | --- |
178
- | `refAdapter.jsx` | 受控壳:维护 `value` / `disabled` / `visible` / `readOnly` / `required` 状态,`visible === false` 时不渲染;通过 `useImperativeHandle` 暴露 `setState`(兼容 `setState(obj)` 与 `setState(key, val)`)与 `getState`。`readonly` 会归一化成控件惯用的 `readOnly` |
179
- | `labelAdapter.jsx` | 给控件套 `label` 外壳(`mini-control-block` 结构),`required` 时加必输样式 |
180
- | `loadMetaAdapter.jsx` | 挂载时调 `getViewMeta(billno)` 拉视图元数据,加载完成前渲染占位,完成后把元数据作为 props 铺给内层组件 |
181
- | `dataSourceAdapter.jsx` | 取数壳:调 `props.api.list` 拉数据,并 `setData` / `setLoading` 到内层组件;暴露 `loadData` 命令式方法。同文件里的 `TableAdapter` 未导出 |
182
- | `modalAdapter.jsx` | 把组件包进 Modal(`visible` → `open`、关闭时回调 `onClose`)。**当前未被 `adapter/index.js` 导出** |
183
-
184
- ### `model/`
185
-
186
- | 导出 | 作用 |
187
- | --- | --- |
188
- | `ModelAdapter(Com)` | 控件接入模型层。挂载时按 props 造 `SimpleModel`,以 `name` 登记进 `vm`(走 `addProperty`,父级 `getData()` 才收得到),并把控件实例交给模型托管;卸载时反注册 |
189
- | `PageModelAdapter(Com)` | 页面外壳:创建根 `ViewModel` 并以 `vm` / `builder` 两个 prop 注入内层组件 |
190
- | `ExtendjsAdapter(Com)` | 加载 `extendjs` 扩展脚本(默认延迟 300ms,可被 `extendDelay` 覆盖),加载完成前渲染 loading;`devfab.require` 不可用时直接跳过,避免页面卡死 |
191
- | `BillAdapter(Com)` / `BillListAdapter(Com)` | 单据 / 列表外壳:把单据的 `vm` 挂到根 `vm` 上(key 为 `billno`),触发 `afterMount` / `afterUnMount`;列表额外把 `filterModel` 的 `search` 事件接到 `vm.execute('loadData', params)` |
192
- | `DataSourceModelAdapter(Com)` | 把 `vm` 的 `loadData` 事件接到取数壳,并从 `parentModel` 合并 `api.bill` 配置 |
193
- | `FilterAdapter(Com)` | 创建 `FilterModel`,向内层注入 `onSearch` 与 `renderCondition`(用 `builder.render` 渲染单个条件) |
194
-
195
- 用法示意:
196
-
197
- ```jsx
198
- import React from 'react'
199
- import { adapter } from 'devfab/dist/react'
200
- import Input from 'antd/lib/input'
201
-
202
- const { ModelAdapter, PageModelAdapter } = adapter
203
-
204
- // 1. 注册控件:把元数据里的 component 名映射到实际组件
205
- builder.initComponent({
206
- Input: ModelAdapter(Input),
207
- })
208
-
209
- // 2. 告诉 builder 如何把节点变成 React 元素(必须由宿主提供)
210
- builder.initRenderFactory(({ node, props }) =>
211
- React.createElement(node.component || 'div', { key: props.key, ...props }, node.children)
212
- )
213
-
214
- // 3. 用后端下发的元数据渲染
215
- const nodes = builder.render(meta)
216
- ```
217
-
218
- > 组件里出现的全局 `devfab` 由宿主的 `<script>`(或引入 `devfab` 后自行赋值)提供,见「运行时契约」。
219
-
220
- ## Vue 3 适配层
221
-
222
- 入口 `src/vue/vue-adapter/index.js`,产物 `dist/vue/vue3-adapter.{js,esm.js}`,由宿主显式 `import` / `require`,不产出 UMD、不挂全局。两个入口函数分别对应 React 的两种组件心智模型:
223
-
224
- **`defineClassComponent(Class, options)`** —— 用 class 写组件。`this` 由 Proxy 包装,`this.$el` / `this.$refs` / `this.$nextTick` / `this.$forceUpdate` 在 `render` 与所有钩子里行为一致;同时提供 `this.setState` / `this.forceUpdate`。生命周期桥接:`componentWillMount` / `componentDidMount` / `componentWillReceiveProps`(浅比较 props)/ `componentDidUpdate` / `componentWillUnmount` / `componentDidUnmount` / `componentDidCatch`。可选基类 `VueComponent` 负责存好 `props` / `context`。
225
-
226
- ```js
227
- import { defineClassComponent } from 'devfab/dist/vue/vue3-adapter'
228
-
229
- export default defineClassComponent(
230
- class Counter {
231
- state = { n: 0 }
232
- componentDidMount() {
233
- console.log(this.$el)
234
- }
235
- render(h) {
236
- return h('div', { onClick: () => this.setState({ n: this.state.n + 1 }) }, String(this.state.n))
237
- }
238
- }
239
- )
240
- ```
241
-
242
- **`defineFunctionComponent(fn, options)`** —— 用函数组件 + Hooks 写组件,`h` 由第三个参数注入,Hooks 按索引复用状态。
243
-
244
- ```js
245
- import { defineFunctionComponent, useState } from 'devfab/dist/vue/vue3-adapter'
246
-
247
- export default defineFunctionComponent((props, ctx, h) => {
248
- const [n, setN] = useState(0)
249
- return h('div', { onClick: () => setN(n.value + 1) }, String(n.value))
250
- })
251
- ```
252
-
253
- 可用 Hooks:`useState`、`useRef`、`useMemo`、`useCallback`、`useEffect`、`useLayoutEffect`、`useReducer`、`useContext`、`useProvide`。
254
-
255
- Hooks 依赖索引存储,必须**无条件、按固定顺序**调用。非生产环境下内置了一道校验:索引位置上的 Hook 类型变了、或本次与上次调用的 Hook 数量不一致,都会 `console.error` 提示。
256
-
257
- ## 扩展脚本机制(`_lib/requirejs.js`)
258
-
259
- vendored 的 RequireJS 2.3.2 之上挂了一层本地适配,暴露到 `window.__devfab`:
260
-
261
- | 成员 | 说明 |
262
- | --- | --- |
263
- | `__devfab.require(domain, deps, callback, errback)` | 扩展脚本加载入口,支持 `require(deps, cb, err)` 与 `require(domain, deps, cb, err)` 两种签名 |
264
- | `__devfab.define` | 扩展脚本注册入口 |
265
- | `__devfab.requireInner` / `defineInner` | 原生 RequireJS 实现,绕过适配层 |
266
-
267
- 另外定制了 `req.createNode`:URL 以 `.css` 结尾时创建 `<link>` 而不是 `<script>`,扩展脚本可以顺带带样式。模型层默认从 `window.__devfab.require` 取加载器(见 `_models/index.js`)。
268
-
269
- ## 已知约束与当前实现状态
270
-
271
- ### 设计约束
272
-
273
- - Vue 适配层仅支持 Vue 3,产物文件名带 `vue3` 即用于标明这条边界;兼容 Vue 2 需另写适配层。
274
- - 纯 JavaScript 工程,不产出类型声明。
275
- - `builder` 是模块级单例,微前端或多实例场景需要额外隔离。
276
- - React 适配层依赖全局 `devfab`,不支持「多份运行时共存」。
277
-
278
- ### 待收敛项(写 README 时已核实,非推测)
279
-
280
- - **包入口与适配层约定的运行时形状不一致**:`src/index.js` 导出的 default 是 `{ utils: { builder, cache, event }, common: {...} }`,而 React 适配层读的是 `devfab.builder`、`devfab.utils.models`,`scripts/smoke.js` 还要求 `hasRuntime()` / `getRuntime()` 与挂载 `window.devfab` 的行为。这些在源码里都**尚未实现**,因此当前构建产物跑 `npm run smoke` 会失败。
281
- - **`ExtendjsAdapter` 调用的 `getRuntime()` 未定义**(`_utils/index.js` 与 `src` 全局都没有),走到扩展脚本分支会直接 `ReferenceError`。
282
- - **`GridModel` 存在从原实现带过来的未定义引用**:`setCachePaginationRowData`、`resetSelected`、`createDefaults`、以及全局 `cb`、`_`(lodash)。涉及 `setDataSource` / `showPage` 等取数与分页路径。
283
- - **`src/vue/index.js` 目前不被任何入口引用**,`vue-adapter` 由 `src/vue/vue-adapter/index.js` 单独作为构建入口,前者只是占位。
284
- - **`adapter/index.js` 未导出 `ModalAdapter`**,`common/dataSourceAdapter.jsx` 里的 `TableAdapter` 也未导出且含 `debugger` 语句。
285
- - `npm run lint` 当前不通过(约 200+ error),主要是 `react/prop-types` 与 `react/display-name`,也有上面列的 `no-undef`。
286
- - `package.json` 的 `license` 写的是 `ISC`,而 `LICENSE` 文件是 Apache License 2.0。
287
-
288
- ## License
289
-
290
- Apache License 2.0,见 [LICENSE](./LICENSE)。
1
+ # devfab
2
+
3
+ > 面向**实施 / 交付**团队的前端**客开(二次开发)**基础库 —— Developer Factory for Low-Code。
4
+
5
+ `devfab` 是一套运行在宿主应用(如 frontend)里的**运行时客开机制**。它的核心只有一件事:让实施人员能在**不改宿主、不发版**的前提下完成前端二次开发。做法是宿主内嵌一个运行时模块加载器 **FileRequire**,实施人员交付的**扩展脚本**与**自定义组件**都通过它在运行时动态加载、动态注册、动态使用。
6
+
7
+ - 二开产物不参与宿主构建:改了扩展脚本 / 组件,替换静态资源即生效。
8
+ - 一套机制,多框架:Vue 3、React、以及 **jQuery + 原生 ES Module** 宿主都能用,写法和加载方式一致。
9
+ - 统一的结果回调契约,加载成功 / 失败可感知、可兜底。
10
+
11
+ ---
12
+
13
+ ## 目录
14
+
15
+ - [简介](#简介)
16
+ - [二开能力](#二开能力)
17
+ - [快速开始](#快速开始)
18
+ - [项目结构](#项目结构)
19
+ - [架构](#架构)
20
+ - [FileRequire 模块加载器](#filerequire-模块加载器)
21
+ - [加载策略与合规](#加载策略与合规)
22
+ - [客开使用文档](#客开使用文档)
23
+ - [开发与构建](#开发与构建)
24
+ - [License](#license)
25
+
26
+ ---
27
+
28
+ ## 简介
29
+
30
+ ### 一句话定位
31
+
32
+ `devfab` 解决的是「实施人员在客户现场做前端二开」这个场景:宿主的正式产品已经定版,实施需要为某个客户加上一点定制逻辑或定制控件,但又不能因此去改宿主代码、走宿主发版。
33
+
34
+ `devfab` 给出的答案是**运行时装配**:
35
+
36
+ | 二开交付物 | 形态 | 加载方式 |
37
+ | --- | --- | --- |
38
+ | 扩展脚本 | `xxx_FE.Extend.js` | `define([], factory)` 注册 → 宿主 `require` 加载 |
39
+ | 自定义组件 | `components.js`(JSX 打包产物) | `provideScript(components)` 注册 → 宿主 `injectScript` 加载 |
40
+
41
+ 这两类产物都通过 **FileRequire**(内嵌 RequireJS 的 AMD 加载器)在运行时按需加载,从而把「改代码」变成「换脚本 / 换组件」。
42
+
43
+ ### 核心概念
44
+
45
+ - **FileRequire**:AMD 模块加载器。实施侧用 `define` / `provideScript` 注册二开产物,宿主侧用 `require` / `injectScript` 加载使用。
46
+ - **扩展脚本(Extend.js)**:一段返回 `{ doAction(name, viewModel) }` 的脚本,加载后被宿主按约定触发(如 `doAction('init', vm)`),用来注入业务逻辑。
47
+ - **自定义组件**:用 JSX 写的普通组件,整体注册成一个模块,宿主加载后按组件名渲染。
48
+
49
+ > 说明:devfab 仓库内 `src/_utils/` 下还有模型、渲染等内部实现,本次文档聚焦实施 / 客开 / 二开链路,不展开。
50
+
51
+ ---
52
+
53
+ ## 二开能力
54
+
55
+ - ✅ 扩展脚本二开:脚本按 `domain` 组织,运行时动态加载,加载后回调 `doAction` 注入逻辑。
56
+ - ✅ 自定义组件二开:JSX 组件打包后 `provideScript` 注册,宿主 `injectScript` 加载即用。
57
+ - ✅ 目录化加载:`require(path, files, cb)` 以「目录 + 文件列表」拼模块路径,无需关心 AMD 模块名规则。
58
+ - ✅ 统一结果契约:`{ code, message, data, error }`,加载 / 执行失败可感知。
59
+ - ✅ 不改宿主不发版:二开产物是独立静态资源,替换即生效。
60
+
61
+ ---
62
+
63
+ ## 快速开始
64
+
65
+ ```bash
66
+ # 安装依赖
67
+ npm install
68
+
69
+ # 构建库产物(dist/ 与 lib/)
70
+ npm run build
71
+
72
+ # 跑冒烟测试
73
+ npm run smoke
74
+ ```
75
+
76
+ ### 运行示例
77
+
78
+ 示例演示了「扩展脚本 + 自定义组件」两条二开链路:
79
+
80
+ ```bash
81
+ # Vue 3 宿主示例(默认端口 9010,dev 模式含 live reload)
82
+ npm run example:vue3
83
+
84
+ # React 宿主示例(默认端口 9011)
85
+ npm run example:react
86
+
87
+ # jQuery + 原生 ESM 宿主示例(默认端口 9012,无构建步骤,自带 mock 接口)
88
+ npm run example:html5
89
+ ```
90
+
91
+ 子命令:
92
+
93
+ ```bash
94
+ npm run example:vue3 -- start # 构建 + 起静态服务
95
+ npm run example:vue3 -- build # 只构建一次
96
+ npm run example:vue3 -- serve # 只起静态服务(加 --live 开自动刷新)
97
+ ```
98
+
99
+ > `example:html5` 只有 `dev`(默认) / `serve` / `build` 三种跑法,其中 `build` 只打印一句「本示例无构建」——
100
+ > 它的宿主页面是 `index.html` + 原生 ES Module,不需要打包。详细的对比与目录说明见
101
+ > [`example/html5/README.md`](./example/html5/README.md)。
102
+
103
+ 端口可用 `EXAMPLE_PORT=9020 npm run example:vue3` 覆盖。
104
+
105
+ ---
106
+
107
+ ## 项目结构
108
+
109
+ ```
110
+ devfab/
111
+ ├── src/
112
+ │ ├── index.js # 入口:挂载 window.__devfab,暴露 define / require / utils
113
+ │ └── FileRequire/ # 运行时模块加载器(二开核心)
114
+ │ ├── file.js # FileRequire 类:define / provideScript / require / injectScript / loadModules
115
+ │ ├── policy.js # 加载策略:清单登记、域名白名单、版本锁、sha256 校验
116
+ │ ├── guard.js # 全局污染检测:加载前后 diff window
117
+ │ ├── requirejs.js # 内嵌 RequireJS 2.3.2 + 本地 domain 适配层
118
+ │ └── index.js # 导出并挂载 window.__devfab.FileRequire
119
+ ├── example/
120
+ │ ├── vue3/ # Vue 3 宿主示例
121
+ │ │ ├── src/components/ # 自定义组件源码(provideScript 注册)
122
+ │ │ └── static/extendjs/ # 扩展脚本(define 注册,运行时按 domain 加载)
123
+ │ ├── react/ # React 宿主示例(结构同上,需 rollup 构建)
124
+ │ └── html5/ # jQuery + 原生 ESM 宿主示例(无构建)
125
+ │ ├── index.html # 宿主页面:<script> 引 jQuery 与 /vendor/devfab.js
126
+ │ ├── dev.js # 静态服务 + live reload + mock 接口
127
+ │ ├── lib/ # vendored jQuery
128
+ │ ├── css/example.css # 手写的控件样式(等价于 antd 那一层)
129
+ │ ├── src/ # 宿主源码(ui-model / views / components)
130
+ │ └── static/extendjs/ # 扩展脚本(与 vue3 / react 同一套路径约定)
131
+ ├── scripts/
132
+ │ ├── smoke.js # 零依赖冒烟测试
133
+ │ ├── manifest.js # 生成二开包清单 manifest.json(含 sha256)
134
+ │ └── fixtures/ # 冒烟测试夹具(扩展脚本 + ESM 模块)
135
+ ├── dist/ # 构建产物(cjs / es / umd)
136
+ ├── lib/ # 可 <script> 直引的 UMD 运行时
137
+ ├── rollup.config.js
138
+ └── package.json
139
+ ```
140
+
141
+ ---
142
+
143
+ ## 架构
144
+
145
+ ### 客开二开架构图
146
+
147
+ ```mermaid
148
+ flowchart TB
149
+ subgraph HOST["宿主应用(运行时内嵌 devfab)"]
150
+ HTML["页面加载 lib/devfab.js<br/>window.devfab / window.__devfab"]
151
+ end
152
+
153
+ subgraph RT["devfab 运行时"]
154
+ FR["FileRequire<br/>AMD 加载器(内嵌 RequireJS + domain 适配)"]
155
+ end
156
+
157
+ subgraph CUSTOM["实施交付物(运行时动态加载,不参与宿主构建)"]
158
+ EXTEND["扩展脚本<br/>xxx_FE.Extend.js<br/>define([], factory)"]
159
+ COMP["自定义组件<br/>components.js<br/>provideScript(components)"]
160
+ end
161
+
162
+ HOST --> FR
163
+ FR -- "define / require" --> EXTEND
164
+ FR -- "provideScript / injectScript" --> COMP
165
+ ```
166
+
167
+ - **宿主**:只负责内嵌 devfab 运行时,提供扩展脚本 / 组件的存放位置与加载入口。
168
+ - **FileRequire**:唯一的装配通道,二开产物的注册与加载都经过它。
169
+ - **实施交付物**:扩展脚本(注入业务逻辑)与自定义组件(扩展 UI),与宿主解耦。
170
+
171
+ ### 二开加载时序
172
+
173
+ ```mermaid
174
+ sequenceDiagram
175
+ participant Host as 宿主页面
176
+ participant FR as FileRequire
177
+ participant RS as RequireJS(内嵌)
178
+ participant Ext as 扩展脚本 / 组件产物
179
+
180
+ Note over Ext: 脚本内 window.__devfab.define([], factory)<br/>组件内 provideScript(components)
181
+ Host->>FR: require(path, files, cb)<br/>或 injectScript([url], cb)
182
+ FR->>RS: 按 domain 拼接路径,动态注入 ⟨script⟩ 加载
183
+ Ext-->>RS: define() 注册模块
184
+ RS-->>FR: 返回模块内容 contents[]
185
+ FR-->>Host: result = { code, message, data, error }
186
+ Note over Host: code===0 时遍历 data:<br/>扩展脚本 → doAction('init', vm)<br/>自定义组件 → 直接渲染
187
+ ```
188
+
189
+ ---
190
+
191
+ ## FileRequire 模块加载器
192
+
193
+ `FileRequire` 是 devfab 的二开核心:一个内嵌 **RequireJS 2.3.2** 的 AMD 加载器,并在其上加了一层「domain 路径适配」,让你以「目录 + 文件列表」的方式加载脚本,而不必关心 AMD 的模块名规则。
194
+
195
+ ### 全局挂载
196
+
197
+ 加载 `lib/devfab.js`(UMD)后,浏览器环境可拿到:
198
+
199
+ | 全局对象 | 说明 |
200
+ | --- | --- |
201
+ | `window.devfab` | UMD 导出对象,含 `define` / `require` / `FileRequire` / `utils` / `debug` |
202
+ | `window.__devfab` | 源码入口显式挂载的同一份能力(`Object.assign({}, window.__devfab, devfab)`) |
203
+ | `window.__devfab.FileRequire` | `FileRequire` 类 |
204
+ | `window.__devfab_requirejs` | 内嵌 RequireJS 的 `require` / `define`(含 domain 适配) |
205
+
206
+ > 扩展脚本里统一写 `window.__devfab.define(...)`。`window.devfab` 是 UMD 导出对象,
207
+ > 只导出 `default`(`src/index.js` 没有具名导出),所以 `window.devfab.define` 是
208
+ > `undefined` —— 两者**并不等价**,别照抄网上的写法。
209
+
210
+ ### 核心 API
211
+
212
+ `FileRequire.create(args?)` 返回一个实例。`args` 同时接受两类配置:
213
+
214
+ | 分区 | 字段 | 说明 |
215
+ | --- | --- | --- |
216
+ | 底层加载器 | `define` / `require` / `undef` / `requireConfig` / `importer` | 覆盖默认加载器,默认取 `window.__devfab_requirejs` |
217
+ | 加载策略 | `version` / `timeout` / `retry` / `strict` / `baseUrl` | 见下方「加载策略与合规」 |
218
+ | 合规 | `manifest` / `origins` / `lockVersion` / `verifyHash` / `fetch` | 见下方「加载策略与合规」 |
219
+ | 污染检测 | `pollution` / `watch` / `maxPollution` / `target` | `pollution: false` 可关闭 |
220
+
221
+ | 方法 | 签名 | 作用 |
222
+ | --- | --- | --- |
223
+ | `define` | `define(deps, factory)` | 注册 AMD 模块(匿名,`deps` 为依赖数组) |
224
+ | `provideScript` | `provideScript(objOrFn)` | 把对象 / 函数包装成无依赖匿名模块注册 |
225
+ | `require` | `require(path, files, callback)` | 加载模块,`callback` 收到统一结果对象 |
226
+ | `load` | `load(path, files, callback)` | 与 `require` 等价,名字用来标记「这一处走完整策略」 |
227
+ | `injectScript` | `injectScript(pathOrFiles, callback)` | 加载脚本,对每个模块分别回调 `(content, index)` |
228
+ | `loadModules` | `loadModules(pathOrFiles, callback?)` | **ESM 通道**:原生 `import()`,返回 Promise |
229
+ | `configure` | `configure(options)` | 运行时改策略(切版本、换清单、调重试次数) |
230
+ | `setVersion` | `setVersion(version)` | 设置二开包版本,写进 RequireJS 的 `urlArgs` |
231
+ | `describe` | `describe()` | 输出当前策略摘要,宿主启动时打一行日志 |
232
+
233
+ ### 加载结果契约
234
+
235
+ `require` / `load` / `injectScript` / `loadModules` 的回调拿到统一结构:
236
+
237
+ ```js
238
+ {
239
+ code: 0, // 0 成功 | 403 策略拒绝 | 404 加载器缺失 | 500 加载/执行失败 | 504 超时
240
+ message: '', // 失败原因
241
+ data: [], // 加载到的模块内容数组
242
+ error: null, // 失败时的原始错误对象
243
+ warnings: [], // 全局污染告警(见下)
244
+ }
245
+ ```
246
+
247
+ - `code === 0`:`data` 里是各脚本 `define` 返回的模块内容;
248
+ - `code === 403`:**未在清单中登记 / 来源不在白名单 / 版本锁不匹配 / sha256 校验失败**,
249
+ 这种情况下加载器不会被触达,脚本不会执行;
250
+ - `code === 404`:底层 `requirejs` 未就绪;
251
+ - `code === 500`:脚本加载或执行出错,`message` 会带上文件列表,`error` 带原始异常;
252
+ - `code === 504`:超过 `timeout` 仍未回调(默认 15s,`timeout: 0` 关闭,改由 RequireJS 的
253
+ `waitSeconds` 兜底)。
254
+
255
+ > 老宿主只判 `code === 0` 即可,新增的 `403` / `504` 一定伴随失败分支,不会误判为成功。
256
+
257
+ ---
258
+
259
+ ## 加载策略与合规
260
+
261
+ 这一层回答的是「这段代码该不该被执行」。**它不做代码隔离**(那是沙箱的事),
262
+ 只做四件企业交付必须有的判断,且默认全部**不启用**(零影响),需要时显式打开。
263
+
264
+ ### 版本:让「换了文件」真的生效
265
+
266
+ ```js
267
+ const fileRequire = window.__devfab.FileRequire.create({ version: '2026.10.20' })
268
+ // 或运行时切:fileRequire.setVersion('2026.10.21')
269
+ ```
270
+
271
+ 版本会作为 `?v=` 交给 RequireJS 的 `urlArgs`,进而进入模块缓存键。
272
+ 没有它,二开文件替换后浏览器仍命中旧缓存 —— 「明明换了脚本,行为没变」的典型成因。
273
+
274
+ > 这是 RequireJS **context 级**配置,一个页面内以最后一次设置为准。这与实际情况相符:
275
+ > 整包本来就应该同一个版本,分模块各自版本只会让缓存失效变成玄学。
276
+
277
+ ### 失败兜底:超时 + 重试
278
+
279
+ ```js
280
+ const fileRequire = window.__devfab.FileRequire.create({ timeout: 8000, retry: 2 })
281
+ ```
282
+
283
+ - 超时先于 RequireJS 的 `waitSeconds`(默认 30s)触发,回 `code: 504`;
284
+ - 重试前会对失败模块调 `undef()` —— 不 undef 的话 RequireJS 会直接复用失败状态,
285
+ 重试形同虚设;
286
+ - 重试仍失败才回 `500`,`error` 保留最后一次原始异常。
287
+
288
+ ### 清单、白名单、版本锁
289
+
290
+ ```js
291
+ // 1) 构建期产出清单(含 sha256)
292
+ // node scripts/manifest.js example/vue3/static --name frontend-extends --version 1.0.0
293
+
294
+ // 2) 运行时加载清单并启用检查
295
+ const fileRequire = window.__devfab.FileRequire.create({
296
+ manifest, // 也可以传 URL 自己 fetch
297
+ lockVersion: '1.0.0', // 锁定版本,防止「悄悄换文件」
298
+ origins: ['https://cdn.example.com'], // 只允许从这些来源加载
299
+ verifyHash: true, // ESM 通道默认校验 sha256
300
+ })
301
+ ```
302
+
303
+ | 检查 | AMD 通道(`require` / `injectScript`) | ESM 通道(`loadModules`) |
304
+ | --- | --- | --- |
305
+ | 清单登记制 | ✅ 未登记直接 `403` | ✅ |
306
+ | 域名白名单 | ✅ | ✅ |
307
+ | 版本锁 | ✅ | ✅ |
308
+ | sha256 完整性 | ❌ 拿不到脚本文本 | ✅ 加载前 `fetch` 预检 |
309
+
310
+ > AMD 通道做不了 sha256 是**结构性限制**:RequireJS 自己插 `<script>`,加载器拿不到文本。
311
+ > 所以 AMD 侧的完整性靠「构建期校验 + 版本锁 + 清单登记」组合,别指望运行时会话级校验。
312
+ > `crypto.subtle` 只在安全上下文(https / localhost)可用,不可用时会跳过并在结果里标注,
313
+ > 不会静默通过。
314
+
315
+ ### 全局污染检测
316
+
317
+ 二开脚本直接跑在宿主 `window` 上,A 客户的脚本改了一个全局变量,B 客户就跟着崩,
318
+ 而且现场无法归因。加载器会在加载前后 diff `window` 的 key 集合:
319
+
320
+ ```js
321
+ fileRequire.require(['extendjs', 'frontend'], ['xxx.js'], (result) => {
322
+ if (result.warnings.length) {
323
+ console.warn('[devfab] 二开脚本动了这些全局:', result.warnings)
324
+ // [{ type: 'added', key: '__someSdk', watched: false }, ...]
325
+ }
326
+ })
327
+ ```
328
+
329
+ - 命中盯防名单(`$` / `jQuery` / `define` / `window.__devfab` 等)立即告警;
330
+ - 新增全局超过 `maxPollution`(默认 3)告警;
331
+ - `strict: true` 时把告警升级为 `code: 500`(**开发期用**:脚本已经执行过了,
332
+ 这是事后报错,不是拦截);
333
+ - 这是**观测**不是隔离,并发加载时归因可能不准。
334
+
335
+ ### ESM 通道:给 RequireJS 的技术债留一条退路
336
+
337
+ RequireJS 2.3.2 是 2018 年的技术,新宿主清一色 Vite + ESM。所以加载器提供第二条通道,
338
+ 让 AMD 从「唯一实现」降级成「兼容层之一」:
339
+
340
+ ```js
341
+ // 新:原生 import(),返回 Promise
342
+ const result = await fileRequire.loadModules(['extendjs/frontend/xxx.mjs'])
343
+ result.data // 各模块的导出对象
344
+
345
+ // 旧:AMD,保留给存量宿主
346
+ fileRequire.require(['extendjs', 'frontend'], ['xxx.js'], (result) => {})
347
+ ```
348
+
349
+ - ESM 通道要求产物是**标准 ESM**,不参与 AMD 的匿名 `define` 匹配;
350
+ - 相对路径以 `baseUrl`(缺省 `document.baseURI`)为基准解析;
351
+ - 依赖 `new Function` 构造原生 `import()`(rollup 会把字面量 `import()` 改写成 `require()`),
352
+ 因此要求 CSP 允许 `unsafe-eval` —— 和内嵌 RequireJS 本身是同一个前提。
353
+
354
+ ---
355
+
356
+ ## 客开使用文档
357
+
358
+ 以下示例均取自 `example/`,只涉及 FileRequire,是实施 / 二开的标准写法。
359
+
360
+ ### 场景一:扩展脚本二开
361
+
362
+ **第一步:写扩展脚本,用 `define` 注册。**
363
+
364
+ `example/vue3/static/extendjs/frontend/frontend_view_list_FE.Extend.js`:
365
+
366
+ ```js
367
+ /**
368
+ * 扩展脚本示例
369
+ * 运行时契约:用 window.__devfab.define([], factory) 注册匿名模块,
370
+ * 模块返回 { doAction(name, viewModel) },加载后由宿主按约定调用。
371
+ */
372
+ window.__devfab.define([], function () {
373
+ return {
374
+ doAction(name, viewModel) {
375
+ if (name !== 'init') return
376
+ viewModel.setCache('extendLoaded', 'frontend_view_list')
377
+ console.info('[extend] frontend_view_list_FE.Extend.js init')
378
+ },
379
+ }
380
+ })
381
+ ```
382
+
383
+ > 扩展脚本按 `extendjs/{domainKey}/{pageKey}_FE.Extend.js` 的路径约定存放,`domainKey` 用来把脚本归类到不同客户 / 业务域。
384
+
385
+ **第二步:宿主用 `require` 加载并触发。**
386
+
387
+ `require(path, files, callback)` 里 `path` 是目录前缀(可传数组),`files` 是文件列表,二者会被拼接成模块路径:
388
+
389
+ ```js
390
+ window.__devfab.FileRequire.create().require(
391
+ ['extendjs', 'frontend'], // path:目录前缀 → 'extendjs/frontend'
392
+ ['frontend_view_list_FE.Extend.js'], // files:文件列表
393
+ function (result) {
394
+ if (result.code === 0) {
395
+ // 对每个加载到的扩展模块执行 doAction
396
+ result.data.forEach((mod) => mod?.doAction?.('init', viewModel))
397
+ } else {
398
+ console.error(result.message, result.error)
399
+ }
400
+ }
401
+ )
402
+ ```
403
+
404
+ ### 场景二:自定义组件二开
405
+
406
+ **第一步:写组件,用 `provideScript` 注册。**
407
+
408
+ `example/vue3/src/components/index.js`(React 侧写法一致):
409
+
410
+ ```js
411
+ function Test(props) {
412
+ return <div>I am a test vue component, props={props.value}</div>
413
+ }
414
+ function Message(props) {
415
+ return <div>I am a message extend component, props={props.value}</div>
416
+ }
417
+
418
+ const components = { Test, Message }
419
+
420
+ // 把整个组件表注册成一个无依赖的匿名模块
421
+ window.__devfab?.FileRequire?.create().provideScript(components)
422
+ ```
423
+
424
+ > 组件源码正常写 JSX,构建时打进一个独立的 `components.js` 产物(`example/vue3/dist/components/vue-components.js`),加载该文件即完成注册。
425
+
426
+ 不带构建的宿主同样只认这条契约,组件返回什么由宿主自己决定 —— jQuery 侧直接返回一段 DOM:
427
+
428
+ ```js
429
+ // example/html5/src/components/html5-components.js(无 JSX、无打包)
430
+ function Test(props) {
431
+ return window.jQuery('<span class="df-tag df-tag-geekblue"/>')
432
+ .text(`I am a test html5 component, props=${props.value}`)
433
+ }
434
+
435
+ window.__devfab?.FileRequire?.create().provideScript({ Test })
436
+ ```
437
+
438
+ **第二步:宿主用 `injectScript` 加载并使用。**
439
+
440
+ `example/vue3/src/views/index.js`(页面挂载时):
441
+
442
+ ```js
443
+ useEffect(() => {
444
+ window.__devfab.FileRequire.create().injectScript(
445
+ ['http://localhost:9010/components/vue-components.js'],
446
+ function (result) {
447
+ // provideScript 注册的是整个 components 对象,
448
+ // 这里 result 即 { Test, Message },可直接作为组件使用
449
+ setState(result)
450
+ }
451
+ )
452
+ }, [])
453
+ ```
454
+
455
+ 之后按组件名渲染即可(此处 `Test` 即第二步回调里拿到的组件):
456
+
457
+ ```js
458
+ const TestCom = state.Test
459
+ // ...
460
+ {TestCom && <TestCom value={'12345'} />}
461
+ ```
462
+
463
+ ### 二开交付清单
464
+
465
+ 一次典型的现场二开,交付物就两类,全部无需改动宿主构建:
466
+
467
+ 1. **扩展脚本**:`extendjs/{domainKey}/{pageKey}_FE.Extend.js`,`define([], () => ({ doAction }))`,替换静态资源即生效;
468
+ 2. **自定义组件**:JSX 组件打包成 `components.js`,`provideScript(components)` 注册,宿主 `injectScript([url], cb)` 加载使用。
469
+
470
+ ---
471
+
472
+ ## 开发与构建
473
+
474
+ ### 构建
475
+
476
+ ```bash
477
+ npm run build # 生产构建(clean + rollup,压缩产物)
478
+ npm run dev # 开发构建(clean + rollup,保持可读)
479
+ ```
480
+
481
+ 产物分布:
482
+
483
+ | 路径 | 内容 |
484
+ | --- | --- |
485
+ | `dist/index.js` / `dist/index.esm.js` / `dist/index.umd.js` | 库主体(CJS / ESM / UMD) |
486
+ | `dist/common/require.js` / `require.esm.js` | FileRequire 独立产物 |
487
+ | `lib/devfab.js` / `lib/devfab.min.js` | 宿主 `<script>` 直引的 UMD 运行时(可读 / 压缩) |
488
+ | `lib/devfab.require.js` / `.min.js` | FileRequire 的 UMD 运行时 |
489
+
490
+ > `lib/devfab.js` 刻意不压缩,便于宿主排查;压缩版见 `lib/devfab.min.js`。宿主只需 `<script>` 引入它,即可拿到 `window.devfab` / `window.__devfab`。
491
+
492
+ ### 脚本
493
+
494
+ | 命令 | 说明 |
495
+ | --- | --- |
496
+ | `npm run smoke` | 零依赖冒烟测试 |
497
+ | `npm run manifest -- <目录> --version 1.0.0` | 生成二开包清单 `manifest.json`(含 sha256),加 `--check` 只校验不写盘 |
498
+ | `npm run example:vue3` | 启动 Vue 3 示例 |
499
+ | `npm run example:react` | 启动 React 示例 |
500
+ | `npm run example:html5` | 启动 jQuery + html5 示例 |
501
+
502
+ ### 技术栈
503
+
504
+ - 构建:Rollup + Babel(JSX 双编译:React 走 `@babel/preset-react`,Vue 走 `@vue/babel-plugin-jsx`)
505
+ - 宿主框架:Vue 3 / React / jQuery(`example/html5` 不经过构建,浏览器直接跑 ES Module)
506
+ - 语言:JavaScript(`.js` / `.jsx`)
507
+
508
+ ---
509
+
510
+ ## License
511
+
512
+ [ISC](./LICENSE)