FineUI 框架设计分析
AI 摘要FineUI 为什么会选择 JSON、继承和 IoC,以及这些选择在长期演进里帮了什么、又留下了什么。
文章目录6 节
FineUI 是 FineReport / FineBI 底下的 UI 框架。拿它和今天新建的 React、Vue 项目横着比,很容易得到一个简单结论:全局命名空间、字符串注册、OOP 继承、直接操作 DOM,都显得老。这个结论没有错,但解释不了为什么这些东西能在同一套框架里稳定存在这么久。
它面对的场景从一开始就不太像普通 Web 应用。页面主要由可视化设计器生成,描述必须能序列化;一个仪表板里可能有数百个运行时组件,结构由用户配置决定;报表布局要求精确,并且要跨分辨率;早期二次开发者有很强的 Java 背景;客户又需要在不 fork 源码的情况下改组件默认行为。再往前追,还有 IE9 的兼容要求。
这些条件分别落成了今天看到的 JSON 配置、createWidget、内置布局和 pixRatio、继承与 IoC、BI.config / BI.point,以及大量 IIFE、var 和自制继承工具。它们不是一张架构图里凭空选出来的,更像是在产品约束下逐层沉淀的。
先不要急着记模块名。点下面五个产品约束,看它们各自在框架里留下了什么:左边是当年必须解决的问题,右边既有由此得到的能力,也有今天要继续偿还的成本。这张映射图就是全文的阅读索引。
架构地图 · 点击探索
产品约束怎样长成框架设计
先看“为什么”,再判断十年后的技术选择是否合理。
设计器生成页面
JSON 配置 + createWidget
得到:编辑、存储、传输、渲染共用一棵可序列化的树。
代价:类型推断弱,错误延迟到运行时,也失去编译期优化空间。
从加载顺序就能看出框架的重心
核心源码用数字前缀控制加载次序。这个做法现在不算漂亮,不过依赖关系很直白:
src/core/
├── 0.foundation.js BI命名空间初始化
├── 1.lodash.js lodash工具库集成
├── 2.base.js BI.inherit / createWidget / 集合工具
├── 3.ob.js OB基类(事件系统,207行)
├── 4.widget.js Widget超类(生命周期 + 响应式,1029行)
├── 5.inject.js IoC容器(shortcut/store/service/provider/config/point)
├── h.js JSX/虚拟DOM辅助
├── plugin.js 插件系统
├── wrapper/
│ ├── layout.js BI.Layout 布局超类
│ └── layout/ HTape/VTape/Center/Float/Grid/Absolute/...
└── ...
src/base/ 基础组件层(Button/Editor/Combo/List/Tree/Pager/...)
src/case/ 业务场景组件
src/component/ 复合组件
src/widget/ 高级组件
typescript/ TypeScript类型声明
最底下的 BI.OB 只管事件,没有 DOM;BI.Widget 在它上面接管组件生命周期和响应式;布局容器继承 BI.Layout,带 loading / empty 状态的组件通常再走 BI.Pane:
BI.OB
└── BI.Widget
├── BI.Layout
└── BI.Pane
├── BI.BasicButton
├── BI.Editor
└── ...
Widget 是真正的运行时中心。历史提交也能印证这一点。早期的 1cc0be8f3 先补 Widget 生命周期,随后两次围绕 beforeMount、render、mounted 的先后关系做修正。这个阶段先把所有组件的启动和销毁顺序钉稳,组件生态才继续往上长。
自动 watch 是后面一点点加厚的。历史里连续出现五次“支持自动watch”,最后再同步 hook;不像先设计一套完整响应式系统再接 UI,更像已有 Widget 在交互需求推动下长出了依赖追踪。今天的 __watch + Fix 正是这个过程留下的形态。
TypeScript 更晚:a00b6aff0 搭 TS 环境,随后导出 BI、补 Widget 类和声明适配。工程侧又依次做了 inject 的 ES6 化、减少 jQuery 之外的全局变量、用 monorepo 验证按需引入,最后才把 ES6 分支合回主线。这条顺序很务实,先松模块和构建的绑,再逐步碰核心运行时。
所以今天的响应式和 TS 支持带着明显的“外挂层”痕迹:核心先稳定下来,新能力再接到旧运行时上。这也解释了 FineUI 的现代化为什么一直是局部替换,不可能像新框架那样选一个版本全部重来。它长期维护多条发布线,还得照顾数万企业客户的存量页面和二次开发接口。
JSON 不只是一种组件写法
FineUI 用普通对象描述 UI,BI.createWidget 在运行时解析配置树并递归创建实例:
{
type: "bi.border",
items: {
north: { el: { type: "bi.label", text: "标题" }, height: 40 },
center: { el: { type: "bi.chart", chartType: "bar", dataSource: "ds1" } }
}
}
在纯编码页面里,这种写法确实不如 JSX 或 Vue Template。条件、循环要先用 JavaScript 拼对象;type 和属性都是字符串 key,IDE 很难推断;异常经常落到 createWidget 内部,源码定位也差。由于配置直到运行时才出现,Vue 的静态提升、Patch Flags 一类编译优化没有施力点。
但对设计器来说,JSON 同时是编辑格式、存储协议和渲染输入。设计器产出一棵树,服务端原样保存,浏览器再消费同一棵树。JSX 是函数调用,Template 最终也要编译成 render function;要跨进程保存,它们仍然需要另外定义一份协议。FineUI 直接把这层协议放到了最前面。
这里的取舍不能只看“哪种语法好写”。JSX 能直接使用完整 JavaScript,类型和 source map 也能一路追到源码;Vue Template 用受限语法换编译器分析空间。FineUI 的 JSON 两边都让了一步:人的表达力弱一些,编译器也看不到静态树,换来的是机器可以直接编辑、传输和保存。对低代码设计器,这是架构能力;对手写业务代码,就是开发体验债。
这也是我不赞成“把 JSON 全换成 JSX”的原因。比较现实的方向是双轨:保留 JSON 作为序列化边界,上面提供有类型、可编译的 DSL。仓库已经有 h.js 和 JSX 辅助,只是目前仍然是嫁接到 createWidget 上,并没有改变底层运行方式。
Widget 这套运行时,强项和负担挨得很近
FineUI 的组件复用主要靠继承。核心的 BI.inherit 是寄生组合式继承:
BI.inherit = function (sp, overrides) {
var sb = function () { return sp.apply(this, arguments); };
var F = function () {};
F.prototype = sp.prototype;
sb.prototype = new F();
sb.superclass = sp.prototype;
_.extend(sb.prototype, overrides, { superclass: sp });
return sb;
};
它不会重复调用父类构造函数,原型链和 superclass 引用也都保留,并且能跑在不支持 ES6 class 的浏览器里。对 2012 年左右、Java 背景较强的开发者来说,Combo → SearchCombo → TreeSearchCombo 这样的层级很好理解,新增一个变体也快。
问题会在组件足够多以后出现。修改 BI.Pane 默认 loading 行为,曾让数十个下游组件一起回归;新组件到底应该归到 Widget、Pane、Single 还是 Combo,也会越来越难判断。“可拖拽”这种横跨 Button、Panel、Tree 的能力没法放进一棵单继承树,最后只能交给 behavior 或 AOP。React 的 Hooks、HOC 和 Vue composable 用组合解决这类复用,恰好绕开了脆弱基类和分类困境。对 FineUI 来说,继承体系是最深的一笔架构债,但也是最难一次性动的部分。
继承树的根 BI.OB 只有 207 行,事件 API 很朴素:
on: function (eventName, fn) {
eventName = eventName.toLowerCase();
// ...
fns.push(fn);
return function () { self.un(eventName, fn); };
},
fireEvent: function () {
for (var i = 0; i < fns.length; i++) {
if (fns[i].apply(this, args) === false) return false;
}
return true;
},
destroy: function () {
this.destroyed && this.destroyed();
this._purgeRef();
this.purgeListeners();
}
on() 直接返回取消订阅函数,destroy 时统一清掉 listener。事件名先转小写,在没有类型检查的年代能挡住一批大小写错误。这些细节很实用,而且比后来 Hooks 的 cleanup 习惯早很多年。
它的边界也清楚:没有 DOM 那样的冒泡,没有通配符,事件名仍是无类型字符串。React 用合成事件和事件委托处理浏览器事件,Vue 的 $emit 也能在模板层做类型推导;BI.OB 更接近一个轻量 EventEmitter。
4.widget.js 则把组件从创建到销毁的过程全部接管:
_initProps → _initRoot → _constructed(setup) → _init
→ beforeInit(异步) → beforeRender(异步) → beforeCreate
→ render(返回JSON树) → created → _initEffects(响应式绑定)
→ beforeMount → 递归挂载子组件 → mounted
更新:beforeUpdate → update → updated
销毁:beforeDestroy → 递归销毁 → destroyed
一共 12 个钩子,而且类定义和 options 配置是两条声明通道,两边都会执行。这里最特别的是 beforeInit / beforeRender 可以接受 callback 或 Promise,并阻塞后续渲染。BI 表格常常要先拿到服务端列定义;如果先画空壳再更新,数百组件一起启动时会有连续的布局抖动。FineUI 选择“准备好再出现”,是对这个场景的直接回应。
但 callback 漏调以后,组件会永远停在那里,也没有天然的错误边界。这是很难排查的沉默失败。我参与加过超时检测,能让问题暴露出来,却没有消除“阻塞式异步初始化”本身的脆弱性。React / Vue 的 Suspense 把等待状态变成声明式边界,处理这类问题更自然;存量 FineUI 组件要切过去,成本又不只是在 API 上换个名字。
另一个容易忽略的点是“双通道”。生命周期既能写在类原型上,也能从 options 传入,两边都执行。这对设计器注入行为很方便,却会让调用顺序不只由组件源码决定。排查一个 mounted 为什么跑了两段逻辑时,还得检查创建配置;这类动态能力和 DevTools 的缺失叠在一起,调试体验会明显落后于 React / Vue。
响应式也是后来装进 Widget 的。核心入口是 __watch,依赖外部 Fix:
__watch: function (getter, handler, options) {
if (_global.Fix) {
var watcher = new Fix.Watcher(null,
function () { return getter.call(self, self); },
function (v) { handler.call(self, self, v); },
BI.extend({ deep: true }, options)
);
this._watchers.push(function () { watcher.teardown(); });
return watcher.value;
} else {
return getter();
}
}
options 里传函数,就会被当成响应式计算属性:
{
type: "bi.label",
text: function () { return store.getModel().count; },
invisible: function () { return store.getModel().count > 10; }
}
Fix 和 Vue 的响应式内核一样按属性收集依赖,更新不需要显式 setState;这一点比 React 的组件级重执行更细。不过它完全在运行时工作,没有模板编译可以提供的静态信息,也没有统一的微任务批处理。多个属性同步变化,可能触发多次 watcher 和独立 DOM 更新。
如果运行环境没有 Fix,__watch 会直接执行 getter 并返回当前值。这种降级保证老环境不至于挂掉,响应式也随之静默消失。兼容性不错,行为差异却很难从组件表面看出来,还是同一个问题:运行时越灵活,越需要工具把实际状态展示出来。
另外,Fix 没有独立文档和 npm 包,和 Widget 生命周期绑得很紧。Vue 把 @vue/reactivity 抽成可单独使用的包之后,composable 可以离开组件上下文;FineUI 目前还做不到这一步。
IoC 和布局,是最有产品背景的两组能力
5.inject.js 不是一个简单注册表,它放进了一套很像 Spring 的容器:
| API | 用途 | 生命周期 |
|---|---|---|
BI.shortcut(type, cls) | 注册 UI 组件 | 每次创建 |
BI.store(type, cls) | 注册 Store | 单例 |
BI.service(type, cls) | 注册 Service | 单例 |
BI.provider(type, cls) | 注册 Provider | 单例 |
BI.config(type, fn) | 改已注册组件的默认配置 | 创建时执行 |
BI.point(type, action, fn) | 给方法挂 AOP 切面 | 调用时执行 |
// 全局修改所有按钮默认高度,不改组件源码
BI.config("bi.button", function (props) {
props.height = 32;
return props;
});
// 在所有按钮点击前加入埋点
BI.point("bi.button", "doClick", function () {
analytics.track("button_click", { type: this.options.type });
});
BI.config 和 BI.point 对普通业务项目可能显得过重,对帆软的二次开发生态却很关键。客户可以覆盖默认配置、插入埋点或调整行为,同时继续跟随产品升级,不必维护自己的 framework fork。React Context 和 Vue provide/inject 解决的是依赖传递,没有直接提供全局配置后处理和 AOP。
当然,容器里的 type 还是字符串,没有命名空间隔离;依赖在运行时解析,静态工具也追不到。系统规模越大,命名冲突和“某个行为到底在哪里被 point 过”的排查成本越高。
布局也被框架收进了 JSON 协议。Absolute、Vertical、HTape、VTape、Center、LeftRight、Border、Grid、Fill 等十多种布局都继承 BI.Layout。早年要兼容旧浏览器和报表的像素语义,不能假定用户熟悉或直接控制 Flexbox / Grid,于是框架把排列、间距和分辨率适配一起包掉。
这些 type 各自带着明确语义。Absolute 直接做绝对定位,Vertical 纵向排列,HTape / VTape 负责水平或垂直的尺寸分配,CenterAdapt 处理双轴居中,LeftRight 把两组内容推到两端,Border 提供上、下、左、右、中五区,Grid 管网格,Fill 吃掉剩余空间。它们都能被设计器当作普通 JSON 节点创建。
相比直接写 CSS,FineUI Layout 的自由度小得多,但序列化和分辨率适配是现成的。一个二开开发者不必同时理解 flex basis、旧浏览器前缀和设备像素,只要选对 type。框架替调用方拿走了复杂度,也把复杂度集中到了布局类和兼容代码里。
两个实现细节很能说明它的取向:
// 先在 Fragment 中组装子节点,最后一次插入
_mountChildren: function () {
var frag = BI.Widget._renderEngine.createFragment();
for (var key in this._children) {
frag.appendChild(child.element[0]);
}
this.appendFragment(frag);
}
// 0 到 1 之间按百分比,其余按 pixRatio 换算
_optimiseGap: function (gap) {
return (gap > 0 && gap < 1)
? (gap * 100).toFixed(1) + "%"
: gap / BI.pixRatio + BI.pixUnit;
}
DocumentFragment 是为了少做重排;_optimiseGap 则让同一个 JSON 同时表达百分比和设备像素。调用者只查布局 API,不必自己处理旧浏览器差异和 pixRatio。
这套东西的代价是类型越来越多,left_right_vertical_adapt 一类名字也越来越难记。CSS Flexbox / Grid 可以自由组合,FineUI 只能在预设集合里选;后来做 Flexbox 迁移时,也不能简单删掉旧 type,因为大量已保存的报表仍然引用它们。
我参与的几段现代化改造
FineUI 适合渐进改造,原因很现实:JSON 是存量协议,Widget 是所有组件的根,BI.* 引用数以万计。大爆炸式重写会同时碰到存储格式、二开 API 和运行时行为,风险没法收口。
我先做过 TypeScript 类型补强。原来的 BI.createWidget({ type: "bi.button" }) 只能返回宽泛类型,IDE 不知道 button 支持哪些 options。做法是建一张字符串字面量到 options / instance 的映射:
interface WidgetTypeMap {
"bi.button": ButtonOptions;
"bi.label": LabelOptions;
"bi.vertical": VerticalLayoutOptions;
// ...
}
function createWidget<T extends keyof WidgetTypeMap>(
config: { type: T } & WidgetTypeMap[T]
): WidgetInstanceMap[T];
这能明显改善补全和错误提示,也有一个绕不过去的问题:映射表要手工维护,新组件漏登记就回到 any。Vue 3 从源码开始使用 TypeScript,类型能顺着实现自然流出来;这里是在动态注册系统外面补一圈编译期约束,收益真实,天花板也很清楚。
setup() 在我加入前已经进了 4.widget.js,但实际使用不多。我做的更多是把它推到新代码里,并把用法整理下来。实现本身不复杂:
_constructed: function () {
if (this.setup) {
pushTarget(this);
var delegate = this.setup(this.options);
if (BI.isPlainObject(delegate)) {
BI.extend(this, delegate);
} else {
this.render = delegate;
}
popTarget();
}
}
setup 返回对象时混进实例,返回函数时直接替换 render。一个计数器能从手工保存组件引用、命令式更新:
BI.shortcut("my.counter", BI.inherit(BI.Widget, {
props: { count: 0 },
render: function () {
var self = this;
return {
type: "bi.vertical",
items: [
{ type: "bi.label", ref: function (r) { self._label = r; },
text: this.options.count },
{ type: "bi.button", text: "+1",
handler: function () {
self.options.count++;
self._label.setText(self.options.count);
}}
]
};
}
}));
改成响应式引用:
BI.shortcut("my.counter", BI.inherit(BI.Widget, {
setup: function () {
var count = Fix.ref(0);
return function () {
return {
type: "bi.vertical",
items: [
{ type: "bi.label",
text: function () { return count.value; } },
{ type: "bi.button", text: "+1",
handler: function () { count.value++; } }
]
};
};
}
}));
逻辑确实内聚了不少,但最外面仍然是 BI.inherit(BI.Widget, { setup: ... })。组合式 API 被装在继承式组件里,Fix.ref / Fix.computed 又依赖 Widget 的 target 和销毁过程,没法像 Vue composable 那样在任意上下文复用。下一步如果要继续推进,应该先把 Fix 的响应式原语和调度器从 Widget 拆开。
布局迁到 Flexbox 以后,FineUI 仍然没有改成 VDOM 框架。它的性能特征和 React / Vue 不一样:首次渲染是 JSON 解析后直接创建 DOM,没有虚拟树;低频、静态页面少一层 diff,在“创建一次、展示很久”的 BI 仪表板里很合适。筛选联动、实时数据和频繁状态更新一多,开发者或 watcher 就要自己安排每次 DOM 操作,此时 React 的统一重渲染模型、Vue 的属性追踪与编译优化更省心。
三种路径各有自己的开销。React 状态变化后重跑组件函数,再用 VDOM diff 找最小 DOM 修改;Vue 既追踪属性依赖,又通过模板编译跳过静态子树;FineUI 没有中间树,首次创建很直接,更新时却要由 setter 或 watcher 明确调用 DOM API。前两者把更新策略交给框架,后者把更多责任留给组件作者。
这也是为什么“低频静态场景 FineUI 更省、高频更新场景 React / Vue 更稳”只能算性能画像,不能当成不测量就成立的跑分结论。组件数量、DOM 复杂度、watcher 写法都会改变结果。它至少指出了改造优先级:FineUI 真正缺的是更新调度,不是首次渲染再多套一个抽象。
这也是现代化时容易走偏的地方。直接 DOM 并不天然落后,得看页面是否真的高频变化;真正该补的是批处理、可观测性和类型边界,而不是为了形式统一先塞一层 VDOM。
接下来最值得还的几笔债
目前最扎眼的是全局 BI。它承载注册表、工具、组件和运行时,阻断 tree-shaking,也让模块依赖藏在全局读取里。ES Module 化应该继续做,但适合从边缘模块和新包开始,不适合机械替换数万处引用。
类型问题也类似。映射表可以先保护调用侧,真正稳定的目标仍然是让核心源码逐步 TypeScript 化,并明确区分服务端协议、Widget options、实例和响应式派生状态。否则所有类型最后都只能停在声明文件里。
真正难处理的是运行时注册。一个类可以被 shortcut 换名字、被 config 改 options、再被 point 改方法行为,静态类型只能描述其中一部分。如果不先划清动态扩展的边界,源码改成 .ts 也会留下大片断言和 any。
布局收敛同样不能只看源码。旧 type 已经写进大量保存的 JSON,删一个类等于让历史报表无法打开。更合适的方式是让旧 type 继续做兼容适配,新代码只暴露统一参数模型,再用迁移工具逐步减少存量。这里的现代化目标应当是停止新增债务,而不是先制造一次大规模破坏。
我会把另外几件事排在一次“大重构”前面:
- 给 Fix.Watcher 加统一的微任务调度,同一轮里的多次变化合并更新。
- 把 Fix 做成有文档、可独立测试的包,先解开它和 Widget 生命周期的硬绑定。
- 做一个最小的 DevTools,至少能看组件树、options、watcher 和 config / point 来源。现在主要靠 console 和 DOM Inspector,排动态注入问题太费时间。
- 收敛新增布局到一套 Flex 参数模型,同时继续兼容旧 type;不要再为每种排列发明一个长名字。
- 文档除了列 API,也要把 JSON、异步生命周期和 IoC 为什么存在写清楚。否则新成员只会把这些设计当成过时习惯。
回头看 FineUI,我很难用“好”或“旧”给它下一个总评。JSON 配置牺牲了编码体验,却撑起了设计器到存储再到渲染的完整链路;继承让早期二开很顺手,也把基类风险扩散到了整个组件树;IoC 和 AOP 增加了隐式行为,却让客户定制可以脱离 fork;直接 DOM 在静态仪表板里省开销,交互密集后又暴露出调度不足。
这些取舍都已经写进存量协议,不能靠换框架名消失。现代化真正难的地方,是分清哪些是时代包袱,哪些仍然在解决产品问题,然后只替换前者。