devfab

Devfab is a library for developer building low code factory
692
Install
npm install devfab
Latest Version:2.3.0
Vue:3
License:ISC
Last Updated:Oct 23, 2026
Links: npm
Maintainer: qingniaofly

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'} />}

二开交付清单

一次典型的现场二开,交付物就两类,全部无需改动宿主构建:

  1. 扩展脚本:extendjs/{domainKey}/{pageKey}_FE.Extend.js,define([], () => ({ doAction })),替换静态资源即生效;
  2. 自定义组件: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

ISC

Related Packages

devfty

Devfty is a library for developer building low code factory

888
abc-cli

一个基于 webpack 套件,组件开发全生命周期管理,包含生成器和构建器

4,759
vuex-typex

A TypeScript pattern for strongly-typed access to Vuex Store modules

103,335 190
app-location

A package to avoid repetition with Routes, Links and URLs, and reduce boilerplat...

34,498 19

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