devfab
| Install | |
|---|---|
npm install devfab |
|
| Latest Version: | 2.3.0 |
| Vue: | 3 |
| License: | ISC |
| Last Updated: | Oct 23, 2026 |
| Links: | npm |
devfab
面向实施 / 交付团队的前端**客开(二次开发)**基础库 —— Developer Factory for Low-Code。
devfab 是一套运行在宿主应用(如 frontend)里的运行时客开机制。它的核心只有一件事:让实施人员能在不改宿主、不发版的前提下完成前端二次开发。做法是宿主内嵌一个运行时模块加载器 FileRequire,实施人员交付的扩展脚本与自定义组件都通过它在运行时动态加载、动态注册、动态使用。
- 二开产物不参与宿主构建:改了扩展脚本 / 组件,替换静态资源即生效。
- 一套机制,多框架:Vue 3、React、以及 jQuery + 原生 ES Module 宿主都能用,写法和加载方式一致。
- 统一的结果回调契约,加载成功 / 失败可感知、可兜底。
目录
简介
一句话定位
devfab 解决的是「实施人员在客户现场做前端二开」这个场景:宿主的正式产品已经定版,实施需要为某个客户加上一点定制逻辑或定制控件,但又不能因此去改宿主代码、走宿主发版。
devfab 给出的答案是运行时装配:
| 二开交付物 | 形态 | 加载方式 |
|---|---|---|
| 扩展脚本 | xxx_FE.Extend.js |
define([], factory) 注册 → 宿主 require 加载 |
| 自定义组件 | components.js(JSX 打包产物) |
provideScript(components) 注册 → 宿主 injectScript 加载 |
这两类产物都通过 FileRequire(内嵌 RequireJS 的 AMD 加载器)在运行时按需加载,从而把「改代码」变成「换脚本 / 换组件」。
核心概念
- FileRequire:AMD 模块加载器。实施侧用
provide/provideScript注册二开产物,宿主侧用inject/injectScript加载使用。 - 扩展脚本(Extend.js):一段返回
{ doAction(name, viewModel) }的脚本,加载后被宿主按约定触发(如doAction('init', vm)),用来注入业务逻辑。 - 自定义组件:用 JSX 写的普通组件,整体注册成一个模块,宿主加载后按组件名渲染。
说明:devfab 仓库内
src/_utils/下还有模型、渲染等内部实现,本次文档聚焦实施 / 客开 / 二开链路,不展开。
二开能力
- ✅ 扩展脚本二开:脚本按
domain组织,运行时动态加载,加载后回调doAction注入逻辑。 - ✅ 自定义组件二开:JSX 组件打包后
provideScript注册,宿主injectScript加载即用。 - ✅ 目录化加载:
require(path, files, cb)以「目录 + 文件列表」拼模块路径,无需关心 AMD 模块名规则。 - ✅ 统一结果契约:
{ code, message, data, error },加载 / 执行失败可感知。 - ✅ 不改宿主不发版:二开产物是独立静态资源,替换即生效。
快速开始
# 安装依赖
npm install
# 构建库产物(dist/ 与 lib/)
npm run build
# 跑冒烟测试
npm run smoke
运行示例
示例演示了「扩展脚本 + 自定义组件」两条二开链路:
# Vue 3 宿主示例(默认端口 9010,dev 模式含 live reload)
npm run example:vue3
# React 宿主示例(默认端口 9011)
npm run example:react
# jQuery + 原生 ESM 宿主示例(默认端口 9012,无构建步骤,自带 mock 接口)
npm run example:html5
子命令:
npm run example:vue3 -- start # 构建 + 起静态服务
npm run example:vue3 -- build # 只构建一次
npm run example:vue3 -- serve # 只起静态服务(加 --live 开自动刷新)
example:html5只有dev(默认) /serve/build三种跑法,其中build只打印一句「本示例无构建」—— 它的宿主页面是index.html+ 原生 ES Module,不需要打包。详细的对比与目录说明见example/html5/README.md。
端口可用 EXAMPLE_PORT=9020 npm run example:vue3 覆盖。
项目结构
devfab/
├── src/
│ ├── index.js # 入口:挂载 window.__devfab,暴露 define / require / utils
│ └── FileRequire/ # 运行时模块加载器(二开核心)
│ ├── file.js # FileRequire 类:provide / provideScript / inject / injectScript / loadModules
│ ├── policy.js # 加载策略:清单登记、域名白名单、版本锁、sha256 校验
│ ├── requirejs.js # 内嵌 RequireJS 2.3.2 + 本地 domain 适配层
│ └── index.js # 导出并挂载 window.__devfab.FileRequire
├── example/
│ ├── vue3/ # Vue 3 宿主示例
│ │ ├── src/components/ # 自定义组件源码(provideScript 注册)
│ │ └── static/extendjs/ # 扩展脚本(define 注册,运行时按 domain 加载)
│ ├── react/ # React 宿主示例(结构同上,需 rollup 构建)
│ └── html5/ # jQuery + 原生 ESM 宿主示例(无构建)
│ ├── index.html # 宿主页面:<script> 引 jQuery 与 /vendor/devfab.js
│ ├── dev.js # 静态服务 + live reload + mock 接口
│ ├── lib/ # vendored jQuery
│ ├── css/example.css # 手写的控件样式(等价于 antd 那一层)
│ ├── src/ # 宿主源码(ui-model / views / components)
│ └── static/extendjs/ # 扩展脚本(与 vue3 / react 同一套路径约定)
├── scripts/
│ ├── smoke.js # 零依赖冒烟测试
│ ├── manifest.js # 生成二开包清单 manifest.json(含 sha256)
│ └── fixtures/ # 冒烟测试夹具(扩展脚本 + ESM 模块)
├── dist/ # 构建产物(cjs / es / umd)
├── lib/ # 可 <script> 直引的 UMD 运行时
├── rollup.config.js
└── package.json
架构
客开二开架构图
flowchart TB
subgraph HOST["宿主应用(运行时内嵌 devfab)"]
HTML["页面加载 lib/devfab.js<br/>window.devfab / window.__devfab"]
end
subgraph RT["devfab 运行时"]
FR["FileRequire<br/>AMD 加载器(内嵌 RequireJS + domain 适配)"]
end
subgraph CUSTOM["实施交付物(运行时动态加载,不参与宿主构建)"]
EXTEND["扩展脚本<br/>xxx_FE.Extend.js<br/>define([], factory)"]
COMP["自定义组件<br/>components.js<br/>provideScript(components)"]
end
HOST --> FR
FR -- "define / require" --> EXTEND
FR -- "provideScript / injectScript" --> COMP
- 宿主:只负责内嵌 devfab 运行时,提供扩展脚本 / 组件的存放位置与加载入口。
- FileRequire:唯一的装配通道,二开产物的注册与加载都经过它。
- 实施交付物:扩展脚本(注入业务逻辑)与自定义组件(扩展 UI),与宿主解耦。
二开加载时序
sequenceDiagram
participant Host as 宿主页面
participant FR as FileRequire
participant RS as RequireJS(内嵌)
participant Ext as 扩展脚本 / 组件产物
Note over Ext: 脚本内 window.__devfab.provide([], factory)<br/>组件内 provideScript(components)
Host->>FR: inject(path, files, cb)<br/>或 injectScript([url], cb)
FR->>RS: 按 domain 拼接路径,动态注入 ⟨script⟩ 加载
Ext-->>RS: define() 注册模块
RS-->>FR: 返回模块内容 contents[]
FR-->>Host: result = { code, message, data, error }
Note over Host: code===0 时遍历 data:<br/>扩展脚本 → doAction('init', vm)<br/>自定义组件 → 直接渲染
FileRequire 模块加载器
FileRequire 是 devfab 的二开核心:一个内嵌 RequireJS 2.3.2 的 AMD 加载器,并在其上加了一层「domain 路径适配」,让你以「目录 + 文件列表」的方式加载脚本,而不必关心 AMD 的模块名规则。
全局挂载
加载 lib/devfab.js(UMD)后,浏览器环境可拿到:
| 全局对象 | 说明 |
|---|---|
window.__devfab |
推荐入口。源码显式挂载的门面:provide / inject / provideScript / injectScript / FileRequire / utils / debug(define / require 保留为兼容别名) |
window.devfab |
UMD 导出对象。门面在 .default 上,所以 window.devfab.define 是 undefined |
window.__devfab.FileRequire |
加载器单例(全局唯一,没有 .create()) |
扩展脚本里统一写
window.__devfab.provide(...)/window.__devfab.inject(...)。window.devfab是 UMD 的命名空间,门面挂在.default上 —— 两者并不等价,别照抄网上的写法。内嵌的 RequireJS 不再挂
window.__devfab_requirejs:那个全局会被第二个 bundle 无条件覆盖, 导致两个 RequireJS context 并存、define与require的注册表对不上。加载器只认内嵌那一份。
核心 API
加载器是单例,没有 create()。 原因很实际:清单、白名单、版本锁都是
部署级约定,一个页面只应有一套。以前每次 create() 都派生一份全新的 policy,
宿主在实例 A 上配的清单,扩展脚本主链路(_extend.js)用的实例 B 完全不知道 —— 配了等于没配。
const fileRequire = window.__devfab.FileRequire
fileRequire.configure({
version: '2026.10.20', // 进 RequireJS urlArgs,换文件后不吃缓存
timeout: 8000, // 早于 RequireJS 的 waitSeconds 兜底
retry: 2, // 失败重试(重试前自动 undef)
manifest, // 构建期产出的清单,未登记模块直接 403
lockVersion: '1.0.0', // 版本锁,防止现场悄悄换包
})
configure(options) 接受三类字段:
| 分区 | 字段 | 说明 |
|---|---|---|
| 加载策略 | version / timeout / retry / baseUrl |
见下方「加载策略与合规」 |
| 合规 | manifest / origins / lockVersion / verifyHash / fetch |
见下方「加载策略与合规」 |
| 调试 | debug |
等价于 window.__devfab.debug = true |
加载器不可替换。 AMD 通道固定走内嵌的 RequireJS 2.3.2,构造函数不再接受
define/require/undef/requireConfig覆盖。旧版允许覆盖,结果是 「宿主传了自己的 require,但 undef 仍取内嵌那份」,两套注册表混用且覆盖是静默的。 要换通道请用loadModules()(ESM),不要替换加载器。
| 方法 | 签名 | 作用 |
|---|---|---|
provide |
provide(path, files, factory) / provide(deps, factory) |
注册 AMD 模块(deps 数组即匿名模块) |
provideScript |
provideScript(objOrFn) |
把对象 / 函数包装成无依赖匿名模块注册 |
inject |
inject(path, files, callback) / inject(deps, callback) |
加载模块,callback 收到统一结果对象 |
load |
load(path, files, callback) |
与 inject 等价,名字用来标记「这一处走完整策略」 |
injectScript |
injectScript(pathOrFiles, callback) |
加载脚本,对每个模块分别回调 (content, index) |
loadModules |
loadModules(pathOrFiles, callback?) |
ESM 通道:原生 import(),返回 Promise |
configure |
configure(options) |
运行时改策略(切版本、换清单、调重试次数) |
setVersion |
setVersion(version) |
设置二开包版本,写进 RequireJS 的 urlArgs |
describe |
describe() |
输出当前策略摘要,宿主启动时打一行日志 |
实例状态只有
__data一个属性。加载器句柄、时钟、options/policy全部归纳在FileRequire.__data下,FileRequire.options/.policy这些直接挂在实例上的 字段已经没有了。业务代码不要读写__data,它是内部结构、随时可能调整;要看配置就describe(), 要改配置就configure(options)。console.log(window.__devfab.FileRequire) // → { __data: {...} },展开才看到内部 console.log(window.__devfab.FileRequire.describe()) // → 干净的策略摘要
加载结果契约
inject / load / injectScript / loadModules 的回调拿到统一结构:
{
code: 0, // 0 成功 | 403 策略拒绝 | 404 加载器缺失 | 500 加载/执行失败 | 504 超时
message: '', // 失败原因
data: [], // 加载到的模块内容数组
error: null, // 失败时的原始错误对象
warnings: [], // 保留位,本轮起不再有任何来源写入(见下)
}
code === 0:data里是各脚本define返回的模块内容;code === 403:未在清单中登记 / 来源不在白名单 / 版本锁不匹配 / sha256 校验失败, 这种情况下加载器不会被触达,脚本不会执行;code === 404:底层requirejs未就绪;code === 500:脚本加载或执行出错,message会带上文件列表,error带原始异常;code === 504:超过timeout仍未回调(默认 15s,timeout: 0关闭,改由 RequireJS 的waitSeconds兜底)。
老宿主只判
code === 0即可,新增的403/504一定伴随失败分支,不会误判为成功。
加载策略与合规
这一层回答的是「这段代码该不该被执行」。它不做代码隔离(那是沙箱的事), 只做四件企业交付必须有的判断,且默认全部不启用(零影响),需要时显式打开。
版本:让「换了文件」真的生效
const fileRequire = window.__devfab.FileRequire
fileRequire.setVersion('2026.10.20')
// 等价写法:fileRequire.configure({ version: '2026.10.20' })
版本会作为 ?v= 交给 RequireJS 的 urlArgs,进而进入模块缓存键。
没有它,二开文件替换后浏览器仍命中旧缓存 —— 「明明换了脚本,行为没变」的典型成因。
这是 RequireJS context 级配置,一个页面内以最后一次设置为准。这与实际情况相符: 整包本来就应该同一个版本,分模块各自版本只会让缓存失效变成玄学。
失败兜底:超时 + 重试
window.__devfab.FileRequire.configure({ timeout: 8000, retry: 2 })
- 超时先于 RequireJS 的
waitSeconds(默认 30s)触发,回code: 504; - 重试前会对失败模块调
undef()—— 不 undef 的话 RequireJS 会直接复用失败状态, 重试形同虚设; - 重试仍失败才回
500,error保留最后一次原始异常。
清单、白名单、版本锁
// 1) 构建期产出清单(含 sha256)
// node scripts/manifest.js example/vue3/static --name frontend-extends --version 1.0.0
// 2) 运行时加载清单并启用检查
window.__devfab.FileRequire.configure({
manifest, // 也可以传 URL 自己 fetch
lockVersion: '1.0.0', // 锁定版本,防止「悄悄换文件」
origins: ['https://cdn.example.com'], // 只允许从这些来源加载
verifyHash: true, // ESM 通道默认校验 sha256
})
| 检查 | AMD 通道(require / injectScript) |
ESM 通道(loadModules) |
|---|---|---|
| 清单登记制 | ✅ 未登记直接 403 |
✅ |
| 域名白名单 | ✅ | ✅ |
| 版本锁 | ✅ | ✅ |
| sha256 完整性 | ❌ 拿不到脚本文本 | ✅ 加载前 fetch 预检 |
AMD 通道做不了 sha256 是结构性限制:RequireJS 自己插
<script>,加载器拿不到文本。 所以 AMD 侧的完整性靠「构建期校验 + 版本锁 + 清单登记」组合,别指望运行时会话级校验。crypto.subtle只在安全上下文(https / localhost)可用,不可用时会跳过并在结果里标注, 不会静默通过。
关于全局污染检测(已移除)
2.1.0 及之前的版本会在每次加载前后 diff window 的 key 集合,把二开脚本新增 / 覆盖的全局
放进 result.warnings,strict: true 时还能升级成 code: 500。已整体移除,原因:
- 它是默认开启的,而这一层的规矩是「不配置不生效」——它是唯一破例的模块;
- 每次加载要全量
Object.keys(window)拍两次快照(实测空页面 237 个 key、其中 187 个是访问器, 单次约 2.6ms),换来的warnings默认没人消费(strict默认false); - 只比一层引用:
window.foo.bar = 1和delete window.foo都检测不到,并发加载归因也不准。
result.warnings 字段保留为空数组(契约形状不变),但不再有任何来源写入。
需要「这脚本到底动了什么全局」时,正确做法是在接入期手工比对,或直接上 iframe 隔离。
ESM 通道:给 RequireJS 的技术债留一条退路
RequireJS 2.3.2 是 2018 年的技术,新宿主清一色 Vite + ESM。所以加载器提供第二条通道, 让 AMD 从「唯一实现」降级成「兼容层之一」:
// 新:原生 import(),返回 Promise
const result = await fileRequire.loadModules(['extendjs/frontend/xxx.mjs'])
result.data // 各模块的导出对象
// 旧:AMD,保留给存量宿主
fileRequire.inject(['extendjs', 'frontend'], ['xxx.js'], (result) => {})
- ESM 通道要求产物是标准 ESM,不参与 AMD 的匿名
define匹配; - 相对路径以
baseUrl(缺省document.baseURI)为基准解析; - 依赖
new Function构造原生import()(rollup 会把字面量import()改写成require()), 因此要求 CSP 允许unsafe-eval—— 和内嵌 RequireJS 本身是同一个前提。
客开使用文档
以下示例均取自 example/,只涉及 FileRequire,是实施 / 二开的标准写法。
场景一:扩展脚本二开
第一步:写扩展脚本,用 define 注册。
example/vue3/static/extendjs/frontend/frontend_view_list_FE.Extend.js:
/**
* 扩展脚本示例
* 运行时契约:用 window.__devfab.provide([], factory) 注册匿名模块,
* 模块返回 { doAction(name, viewModel) },加载后由宿主按约定调用。
*/
window.__devfab.provide([], function () {
return {
doAction(name, viewModel) {
if (name !== 'init') return
viewModel.setCache('extendLoaded', 'frontend_view_list')
console.info('[extend] frontend_view_list_FE.Extend.js init')
},
}
})
扩展脚本按
extendjs/{domainKey}/{pageKey}_FE.Extend.js的路径约定存放,domainKey用来把脚本归类到不同客户 / 业务域。
第二步:宿主用 inject 加载并触发。
inject(path, files, callback) 里 path 是目录前缀(可传数组),files 是文件列表,二者会被拼接成模块路径:
window.__devfab.inject(
['extendjs', 'frontend'], // path:目录前缀 → 'extendjs/frontend'
['frontend_view_list_FE.Extend.js'], // files:文件列表
function (result) {
if (result.code === 0) {
// 对每个加载到的扩展模块执行 doAction
result.data.forEach((mod) => mod?.doAction?.('init', viewModel))
} else {
console.error(result.message, result.error)
}
}
)
场景二:自定义组件二开
第一步:写组件,用 provideScript 注册。
example/vue3/src/components/index.js(React 侧写法一致):
function Test(props) {
return <div>I am a test vue component, props={props.value}</div>
}
function Message(props) {
return <div>I am a message extend component, props={props.value}</div>
}
const components = { Test, Message }
// 把整个组件表注册成一个无依赖的匿名模块
window.__devfab?.provideScript(components)
组件源码正常写 JSX,构建时打进一个独立的
components.js产物(example/vue3/dist/components/vue-components.js),加载该文件即完成注册。
不带构建的宿主同样只认这条契约,组件返回什么由宿主自己决定 —— jQuery 侧直接返回一段 DOM:
// example/html5/src/components/html5-components.js(无 JSX、无打包)
function Test(props) {
return window.jQuery('<span class="df-tag df-tag-geekblue"/>')
.text(`I am a test html5 component, props=${props.value}`)
}
window.__devfab?.provideScript({ Test })
第二步:宿主用 injectScript 加载并使用。
example/vue3/src/views/index.js(页面挂载时):
useEffect(() => {
window.__devfab.injectScript(
['http://localhost:9010/components/vue-components.js'],
function (result) {
// provideScript 注册的是整个 components 对象,
// 这里 result 即 { Test, Message },可直接作为组件使用
setState(result)
}
)
}, [])
之后按组件名渲染即可(此处 Test 即第二步回调里拿到的组件):
const TestCom = state.Test
// ...
{TestCom && <TestCom value={'12345'} />}
二开交付清单
一次典型的现场二开,交付物就两类,全部无需改动宿主构建:
- 扩展脚本:
extendjs/{domainKey}/{pageKey}_FE.Extend.js,define([], () => ({ doAction })),替换静态资源即生效; - 自定义组件:JSX 组件打包成
components.js,provideScript(components)注册,宿主injectScript([url], cb)加载使用。
宿主接入:initViewModel 与 params.fileRequire
模型层(utils.models.initViewModel)负责把扩展脚本装配进 VM 生命周期。它不再自己 import 加载器,
而是由宿主把单例传进来 —— 这是必须显式传的必填参数:
// ESM 宿主:import { initViewModel } from 'devfab/dist/common/utils.esm.js'
// 或直接用 UMD 挂载的门面:
const { initViewModel } = window.__devfab.utils.models
initViewModel(meta, {
fileRequire: window.__devfab.FileRequire, // ← 必填
extendScriptParams: {
enableJs: true, // 页面级:是否加载 *_FE.Extend.js
enableExtJs: true, // 数据级:是否加载 extscripturls 里的脚本
filepath: ['extendjs', 'frontend'], // 域前缀,与 require(path, files) 的 path 一致
scriptUrl: 'frontend_view_list_FE.Extend.js',
exturlsField: 'extscripturls', // 可选,默认 extscripturls
},
}, (vm) => {
// extendReady 之后回调
})
为什么不让模型层自己 import:那样 dist/common/models.js 会内联一份 file.js + policy.js,
于是同一个页面出现两个加载器单例 —— 宿主在 window.__devfab.FileRequire 上配的清单 / 版本锁,
扩展脚本主链路完全看不到(配了等于没配)。参数注入之后模型层不再反向依赖加载器。
从旧版迁移(唯一需要改代码的地方):
- initViewModel(meta, { requirejs: window.__devfab_requirejs, extendScriptParams })
+ initViewModel(meta, { fileRequire: window.__devfab.FileRequire, extendScriptParams })
漏传的后果是可诊断的,不是崩溃:loadScripts 会回
{ code: 404, message: 'params.fileRequire is required(…),the files: xxx.js', data: [], warnings: [] }
并且一定会回调 —— initExtendScripts 只有拿到回调才会 execute('extendReady'),
不回调就等于 ready 事件永远不发,宿主会卡在 loading。
开发与构建
构建
npm run build # 生产构建(clean + rollup,压缩产物)
npm run dev # 开发构建(clean + rollup,保持可读)
产物分布:
| 路径 | 内容 |
|---|---|
dist/index.js / dist/index.esm.js / dist/index.umd.js |
库主体(CJS / ESM / UMD) |
dist/common/require.js / require.esm.js |
FileRequire 独立产物 |
lib/devfab.js / lib/devfab.min.js |
宿主 <script> 直引的 UMD 运行时(可读 / 压缩) |
lib/devfab.require.js / .min.js |
FileRequire 的 UMD 运行时 |
lib/devfab.js刻意不压缩,便于宿主排查;压缩版见lib/devfab.min.js。宿主只需<script>引入它,即可拿到window.devfab/window.__devfab。
脚本
| 命令 | 说明 |
|---|---|
npm run smoke |
零依赖冒烟测试 |
npm run manifest -- <目录> --version 1.0.0 |
生成二开包清单 manifest.json(含 sha256),加 --check 只校验不写盘 |
npm run example:vue3 |
启动 Vue 3 示例 |
npm run example:react |
启动 React 示例 |
npm run example:html5 |
启动 jQuery + html5 示例 |
技术栈
- 构建:Rollup + Babel(JSX 双编译:React 走
@babel/preset-react,Vue 走@vue/babel-plugin-jsx) - 宿主框架:Vue 3 / React / jQuery(
example/html5不经过构建,浏览器直接跑 ES Module) - 语言:JavaScript(
.js/.jsx)
License
Related Packages
A package to avoid repetition with Routes, Links and URLs, and reduce boilerplat...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 2.3.0 | ^3.4.0 | ISC | |
| 2.2.1 | ^3.4.0 | ISC | |
| 2.2.0 | ^3.4.0 | ISC | |
| 2.1.0 | ^3.4.0 | ISC | |
| 2.0.0 | ^3.4.0 | ISC | |
| 1.5.0 | >=3.0 | ISC | |
| 1.2.0 | >=3.0 | ISC | |
| 1.1.0 | >=3.0 | ISC | |
| 1.0.0 | >=3.0 | ISC |