简介:这份《Delphi Web前端开发教程——基于TMS WEB Core框架》PDF面向希望用Delphi或Lazarus进入Web前端与跨平台应用开发的初学者和进阶开发者,重点解决从传统桌面开发转向纯HTML/JS Web应用时的概念、工具链与项目配置问题。压缩包内仅含1个PDF文件,约3.84MB,便于离线阅读和随身查阅。教程从Delphi Web开发框架概览、IntraWEB/uniGUI/TMS WEB Core对比、Web基础知识讲起,延伸到TMS WEB Core环境配置、Lazarus安装、项目设置与自动化版本管理,并覆盖Web应用、渐进式Web应用、Electron及Miletus桌面应用的创建流程。目录还包含调试、Pas2JS编译、RTL运行时库、预处理器、命令行编译、页面设计与绝对定位等专题,适合按章节系统学习或针对具体环节查阅。目前已有5029人学习,可作为Delphi开发者快速理解TMS WEB Core开发范式的参考读物。
1. 一份没有二进制的 Delphi Web 前端:浏览器到底执行了什么
把一段 Object Pascal 编译成网页,多数人的第一反应是「服务端跑个 exe,浏览器只是显示层」。TMS WEB Core 走的是另一条路:Delphi Web 前端的产物里找不到可执行文件,交付到服务器上的就是 .html、.js、.css 和图片,浏览器从静态托管里取到的就是这几样东西。部署环节没有运行时、没有动态库依赖、没有进程要守护,把目录拷过去就能跑。
反直觉的地方在于:这些 .js 不是「打包」出来的,是「转译」出来的。源码里的类、属性、事件、异常、字符串操作、集合遍历,全都要在浏览器里重新获得语义。搞懂这条转译链,你才知道哪些 VCL 时代的经验可以照搬、哪些搬过去会当场翻车——这正是那份基于 TMS WEB Core 框架的教程花大篇幅讲 Pas2JS 和 RTL 的原因。
2. Pas2JS 转译链:Object Pascal 的类是怎么在 JavaScript 里活下来的
2.1 编译产物长什么样:从 .pas 到 project.js 的四个阶段
在 IDE 里按一次编译,背后发生的事情比「生成 exe」要细。常见的实现思路是把流程拆成四段:词法语法解析 → 符号表与类型检查 → 中间表示与优化 → 目标代码生成。前三段和传统编译器没有本质区别,真正的分水岭在第四段:目标不是机器码,而是 JavaScript 源码。
unit Unit1; {$mode objfpc}{$H+} interface uses System.SysUtils, WebLib.Forms, WebLib.StdCtrls; type TForm1 = class(TWebForm) btnHello: TWebButton; procedure btnHelloClick(Sender: TObject); end; implementation procedure TForm1.btnHelloClick(Sender: TObject); var i: Integer; s: string; begin s := ''; for i := 1 to 3 do s := s + IntToStr(i) + ','; btnHello.Caption := s; // 属性赋值在 JS 侧会变成 setter 调用 end; end.上面这段代码编译之后,产出的 JS 形态大致是这样(形态近似,不是逐行还原):
// project.js 中属于 Unit1 的片段 rtl.module("Unit1", ["System.SysUtils", "WebLib.Forms"], function () { var $mod = this; $mod.TForm1 = function() { /* 构造:初始化字段、挂事件 */ }; $mod.TForm1.prototype = Object.create($mod.TWebForm.prototype); $mod.TForm1.prototype.btnHelloClick = function(Sender) { var i, s; s = ""; for (i = 1; i <= 3; i++) { // to 的上界被改写成 <= 比较 s = s + rtl.intToStr(i) + ","; // IntToStr 落到 RTL 里的函数 } this.btnHello.setCaption(s); // Caption 属性 → setter }; });rtl是全局运行时对象,所有单元都往它上面注册;rtl.module的第二个参数是依赖列表,框架靠它保证初始化顺序;$mod相当于这个单元的命名空间,避免不同单元的同名类互相污染。for ... to被改写成<=不是随意的——Pascal 的上界在进入循环时求值一次,JS 的for条件每轮都求值,编译器要么提升上界变量,要么就得接受语义差异,主流做法是前者。
这里有个工程上的连锁反应:产物是纯文本 JS,所以它天然可以被浏览器缓存、被 CDN 分发、被 gzip 压缩;但也意味着「版本」这件事必须由你自己管。那份教程里专门有一节讲自动化版本管理,本质就是在解决「文件名或查询串不带版本号,用户浏览器就会一直拿旧产物」这个老问题。
2.2 RTL 运行时库:字符串、动态数组、异常在 JS 上靠什么撑住
转译只是把语法搬过去,语义得有人兜底。兜底的就是 RTL 运行时库——它在 JS 之上重新实现了一套 Pascal 语义的对象模型。
最典型的是类与虚方法。Pascal 的类在 JS 里落成一个构造函数加原型链,虚方法调度依赖原型上的方法查找,inherited被改写成显式的父类原型调用。类引用(TClass)这种在原生编译里是元数据指针的东西,在 JS 里就是一个函数对象本身,ClassName靠附加在构造函数上的字符串属性提供。这意味着ClassType、is、as这些运算符在运行期是字符串比对加原型链回溯,比原生慢,但语义能对上。
字符串更容易踩坑。JS 的字符串是不可变的 UTF-16 序列,下标从 0 开始;Pascal 的字符串习惯是 1 基。编译器不会把每一次s[i]都改写成s[i-1]了事——它会保留 1 基的书写习惯,由 RTL 提供的访问函数做偏移。一旦你绕过字符串运算符直接操作底层 JS 数组,两种索引体系就会混在一起,出现「少一个字符」或者「首字符丢失」的现象。
动态数组、集合、记录各有各的处理。动态数组映射到 JS 数组,SetLength通过改写length实现,多余元素置空;记录在 JS 里是对象字面量,但因为 Pascal 的记录是值语义,编译器会在赋值点插入拷贝,这一点是新手最容易忽略的——你以为什么都没发生,实际上每次赋值都产生了一份新对象。
异常处理落到try/catch,Exception类由 RTL 定义,raise变成throw,finally的位置和原生编译一致。但要注意栈信息来自 JS 引擎,不是 Pascal 的调用栈,除非开了 source map。
提示:在浏览器里
Free不等于立即回收。RTL 会照常调用Destroy,你在析构里写的解绑事件、清定时器、断订阅仍然要写,否则就是幽灵回调。
2.3 调试与 source map:栈里看到的 Pascal 行号是怎么来的
产物是 JS,但调试体验不能是 JS。做法是生成 source map:.js.map文件里记录了每一段生成的 JS 对应回哪个.pas文件的哪一行哪一列,浏览器的开发者工具读到它之后,断点、单步、调用栈都显示 Pascal 源码。
这件事的价值不在于「好看」,而在于它改变了排查路径。一个运行时异常抛出来,栈顶显示的是Unit1.pas:23,而不是project.js:14872,你不需要在压缩过的 JS 里大海捞针。代价是 source map 会暴露源码结构,生产环境要不要带它,是个需要权衡的决定——不带,线上问题只能靠日志;带上,源码等于公开。
另一条调试路径是命令行编译。pas2js本身可以作为独立命令调用,指定目标平台(面向浏览器或面向 node 环境)、输出路径、优化级别,这意味着编译可以脱离 IDE 进到持续集成流水线里。对于多人协作的项目,这条路径比「每个人本机按 F9」可靠得多,至少能保证大家编出来的是同一份东西。
预处理器在整条链里负责「一份源码多个目标」。{$IFDEF}之类的条件编译在转译期就展开,最终 JS 里不会留下任何分支判断。框架靠这个把只在不同宿主环境下成立的代码隔离开——同一套窗体代码,跑在浏览器里和跑在桌面壳容器里,差异部分由条件符号裁剪掉。
3. 组件即 DOM:TWebButton 为什么不是「画」出来的按钮
3.1 控件的本质是一个 HTML 元素的代理对象
VCL 时代的按钮是真的被画出来的:canvas、GDI、自绘。很多人第一次用 TMS WEB Core 会下意识沿用这个心智模型,以为TWebButton也是在某个画布上渲染出的一块区域。不是。
TWebButton是一个 Pascal 对象,它持有一个真实 DOM 节点的引用,属性赋值最终落到那个节点的属性和样式上。Caption写进去的是节点的文本内容,Enabled改的是disabled属性和相应的 CSS 类,Visible改的是display。也就是说,屏幕上那个按钮是浏览器自己渲染的<button>,不是框架画的。
// 控件的 DOM 相关属性,直接映射到 HTML 元素上 btnHello.ElementID := 'btn-hello'; // 对应 DOM 的 id btnHello.ElementClassName := 'btn btn-primary'; // 对应 class,直接吃现成 CSS 框架 btnHello.ElementFont.Size := 14; // 由 RTL 转成内联样式 btnHello.Caption := '确定'; // 落到元素文本ElementID决定了你在 CSS 和 JS 里怎么找到它;ElementClassName是打通第三方 CSS 框架的关键——把 Bootstrap 之类的类名挂上去,样式立刻生效,一行 CSS 都不用自己写;ElementFont这类属性最终会被翻译成内联style上的具体值。这套设计的好处是组件不吃掉浏览器的原生能力:表单提交、键盘焦点、无障碍朗读、右键菜单,全都还在。坏处是你得接受 DOM 的规则,比如某些属性改了不会立刻触发重排,得等下一帧。
控件的ElementHandle属性暴露了底层那个 JS 元素对象。它是一个逃生舱:框架没封装的 DOM API,你可以拿它直接调。但逃生舱用多了,代码就变成了「一半 Pascal 一半 JS」,跨平台能力会被慢慢掏空。
3.2 HTML 模板标签:设计期模板和运行期 DOM 的同步机制
纯靠属性拼出来的界面,复杂一点就难看了。真正的做法是让设计期的 HTML 模板参与进来。
机制是这样:你给控件指定一段 HTML 模板,模板里用占位结构描述这个控件的内部构造——比如一个带图标和文字的按钮,外层是<button>,里面有<i>和<span>。运行期控件实例化时,把这段模板克隆成真实 DOM,然后按约定抓取其中的子元素,绑定成自己的内部引用。此后你改Caption,改的是模板里那个<span>的文本,而不是整个按钮。
同步是双向的。设计期改模板,控件的属性面板跟着变;改属性,模板里的对应位置也更新。这套机制让「像素级还原设计稿」变得可行——设计给的是一段 HTML/CSS,你把它塞进模板,剩下的逻辑仍然用 Pascal 写。教程里把「使用 HTML 模板」和「自动同步 HTML 模板」分成两节,说明这里的同步逻辑本身就有一定复杂度,不是简单的字符串替换。
注意:模板注入发生在控件渲染阶段,不是构造阶段。在构造函数里访问模板内部的子元素,拿到的很可能是空引用。
3.3 绝对定位与相对定位:两套布局模型的 DOM 代价
VCL 程序员习惯的布局是「拖到哪就在哪」,本质是绝对坐标。Web 的默认布局是文档流,元素一个挨一个往下排。这两套模型没法调和,只能选。
绝对定位模式下,窗体容器被设成相对定位的参照系,每个控件用left/top精确落位。好处是迁移成本极低,几十个控件的旧窗体拖过来基本不用改;坏处是响应式能力接近于零——窗口一窄,控件该重叠还是重叠,不会自己让位。屏幕上看着没问题的界面,在另一台设备上可能直接错位。
相对定位模式下,控件按文档流排列,用宽度、高度、外边距参与布局。这时你失去了「精确坐标」的确定性,换来的是容器变化时布局能自己调整。代价是你得理解盒模型、理解flex的行为,写惯 VCL 的人第一次接触会觉得「怎么什么都对不齐」。
真实的项目往往是混合的:整体框架用相对定位保证能适应不同宽度,局部的小面板用绝对定位做精细控制。选型的分界线很清楚——界面要不要在手机浏览器里看。要,就别指望绝对定位能救你;不要,绝对定位的开发效率确实高。
4. 应用对象与窗体生命周期:浏览器里根本没有「进程」这个东西
4.1 Application、Form、Frame 三层对象各管什么
桌面程序的生命周期是:进程启动 → 初始化 → 消息循环 → 进程退出。浏览器里没有前三步中的任何一步,页面加载完就开始跑,标签页关掉就没了,中间没有「退出」这个可拦截的节点。
Application对象承担的是「全局单例」的角色:它管启动参数、管当前活动的窗体、管全局异常兜底。它的Run不是进入一个阻塞的消息循环——真这么做浏览器会直接假死——而是把控制权交还给浏览器的事件循环,之后所有交互都由 DOM 事件驱动回调。理解这一点很关键:Pascal 代码在浏览器里没有「主流程」这个概念,只有一串被事件触发的回调。
TForm对应一个 DOM 容器。Show做的事情本质上是把这个容器插进文档、把display置成可见;Hide反过来。窗体不是窗口,浏览器不给你开新窗口的权限(弹窗拦截器也不会答应),所以「多窗体应用」在 Web 上的真实形态是「多个容器切换显示」。
Frame是可复用的子界面单元,作用类似桌面端的 Frame:把一块界面的控件和逻辑打包,嵌到不同窗体里复用。它在 Web 场景里更有价值,因为页面本来就是拼装的,把「搜索栏」「分页器」做成 Frame,比在每个窗体里重复摆放控件要干净得多。
运行期创建窗体和在其它控件里托管窗体这两个能力,是这套对象模型的直接推论:既然窗体就是个容器,那它当然可以在代码里Create出来,也当然可以被塞进另一个控件内部当子区域用。这条路径在做「主界面 + 弹出面板」时特别顺手。
4.2 ShowModal 的语义漂移:阻塞调用栈是不可能的
桌面端ShowModal的行为很好理解:调用处停住,直到窗体关闭才返回,返回值告诉你用户点了哪个按钮。这段代码写起来像同步的顺序逻辑。
浏览器里做不到。JS 是单线程的,阻塞调用栈等于冻结整个页面,用户点不动、页面不重绘、连关闭按钮都失效。所以模态在 Web 上只能被拆成两半:视觉上盖一层遮罩挡住后面的内容,逻辑上用回调或者await把「关闭之后要做什么」挂上去。
// 模态在 Web 上的典型写法:调用不阻塞,结果走回调 procedure TForm1.AskConfirm; begin if frmConfirm = nil then frmConfirm := TfrmConfirm.Create(Application); frmConfirm.OnClosed := @ConfirmClosed; // 关闭时才回调 frmConfirm.Show; // 立刻返回,不等待 end; procedure TForm1.ConfirmClosed(Sender: TObject; AResult: Integer); begin if AResult = mrOk then DoSomething; // 后续逻辑必须放在这里,不能写在 Show 后面 end;OnClosed是关闭时的回调入口,AResult是模态结果,Show调用后立刻返回。这段代码的形态和桌面端差别不大,但执行顺序完全不同:桌面端ShowModal之后的语句在关闭后才执行,这里Show之后的语句会在窗体还开着的时候就执行完。把桌面代码直接搬过来,最常见的表现就是「明明还没点确定,后续动作已经跑了」。
这个语义漂移会传染。凡是从「同步等待」派生的写法——循环等某个标志位变成真、while not Done do、Sleep之后检查——在浏览器里全是错的,轻则界面卡住,重则整页无响应。
4.3 数据只能走 REST:浏览器不给你连数据库的权限
桌面程序直连数据库天经地义:TCP 连接、驱动、认证,一套下来很自然。浏览器里这条路是封死的——没有任意 TCP 能力,只有 HTTP 系列协议,而且受同源策略约束。
所以数据访问的形态被强制改成 REST。前端发一个 HTTP 请求到某个 API,拿回 JSON,自己在客户端解析成对象。这个 API 后面接什么——传统数据库、云上的托管数据库、还是别的语言写的服务——对前端是透明的。教程里把「支持 REST API 的传统数据库方案」「云数据库方案」「其它语言的 REST API」并列成三节,本质上是在说同一件事:前端只认 HTTP 契约,后端随便。
procedure TForm1.LoadOrders; var req: TWebHttpRequest; begin req := TWebHttpRequest.Create(nil); req.URL := 'https://api.example.invalid/orders'; // 必须同源,或服务端开了 CORS req.OnResponse := @OrdersLoaded; // 响应到达时由事件循环调度 req.Execute; // 立即返回,不阻塞 end; procedure TForm1.OrdersLoaded(Sender: TObject; AResponse: string); begin // AResponse 是原始文本,通常是 JSON,需要自己反序列化 mmoLog.Lines.Text := AResponse; end;URL必须是同源地址,或者目标服务端明确允许跨域,否则浏览器会在请求发出后拦住响应;OnResponse是异步回调,不是同步返回值;Execute返回时数据还没到。这套写法逼着你把「请求」和「处理结果」在代码结构上分开,一开始别扭,习惯之后反而比同步调用更清晰——因为你能明确知道哪些代码在等网络、哪些不等。
5. 避坑与排查:转译模型带来的五类典型翻车
5.1 改了代码不生效:缓存和增量产物
现象:明明改了 Pascal 源码,刷新页面行为还是旧的;有时候换个浏览器就对了。
原因通常有两层。浅层是浏览器缓存了旧的.js,产物文件名没变,浏览器就认为不需要重新下载。深层是编译产物本身没更新——增量编译在某些情况下跳过了重新生成,或者输出目录和服务器上托管的目录不是同一个。
解决:产物文件名带内容指纹或者版本查询串,让 URL 变化强制失效;调试期在开发者工具里勾上禁用缓存;排查时先看 Network 面板里那个.js的响应大小和日期,再看磁盘上文件的时间戳,两头对齐了再怀疑代码。教程里把「自动化版本管理」单独列一节,就是把这件苦力活自动化掉。
5.2 界面冻住:把 Sleep 和忙等搬进主线程
现象:点一下按钮,页面白屏几秒,转圈动画停住,什么都不响应。
原因:单线程事件循环被占住了。桌面端写Sleep(3000)只是让当前线程睡,界面线程还能刷;浏览器里没有第二个线程帮你重绘,主线程一睡,整个页面就停摆。同理,while轮询某个异步结果,也是死循环。
解决:把所有等待改成异步。延时用定时器回调,轮询改成事件驱动,重活拆成分片让出主线程。判断标准很简单——任何一段代码如果耗时超过几十毫秒,就不能放在事件回调里直接跑完。
5.3 ElementHandle 为 nil:模板注入时序
现象:构造函数或者Create之后立刻访问控件内部元素,报空引用;换到按钮点击事件里访问就正常。
原因:控件对象先被创建,DOM 元素在渲染阶段才挂上去,模板里的子元素更是要等模板实例化之后才存在。在渲染之前取ElementHandle,拿到的当然是空。
解决:把「需要 DOM 的操作」挪到渲染完成之后,用框架提供的渲染后钩子,或者在访问前统一判空兜底。这条规律对所有 Web 前端框架都成立,不是这一家的问题。
5.4 数字和日期格式化对不上
现象:同一份数据,在开发机上显示1,234.56,在另一台机器上变成1 234,56;日期格式也各不相同。
原因:格式化最终落到 JS 的本地化能力上,而本地化取决于浏览器语言设置和系统区域。Pascal 的Format默认行为在两边不完全等价。
解决:凡是给用户看的数字和日期,显式指定格式串,不要依赖默认值;凡是给机器读的(接口参数、存储),统一用不受区域影响的表示法,别用格式化后的字符串。这类问题在开发机上测不出来,只在别人的浏览器里出现。
5.5 Form.Free 之后定时器还在跑
现象:窗体关掉了,控制台还在报错说找不到元素;或者后台请求还在发,回调里访问已经销毁的控件。
原因:Free触发了Destroy,但 JS 的定时器、事件监听、未完成的网络请求都不会自动跟着消失。它们持有的闭包还引用着旧对象,回调照常执行。
解决:把「清理」当成析构的固定动作——停掉所有定时器、解绑事件、把异步回调里的目标对象判空。这条习惯在桌面端也有,但在浏览器里更致命,因为对象不会被立刻回收,错误会以「幽灵行为」的形式持续出现。
6. 状态放服务端还是放浏览器:三条 Web 路线的取舍
Delphi 生态里做 Web 有三条主流路线,它们的根本分歧只有一个:界面状态存在哪。
| 维度 | 服务端渲染型(IntraWEB 一类) | 组件库型(uniGUI 一类) | 客户端转译型(TMS WEB Core) |
|---|---|---|---|
| 状态位置 | 服务端会话 | 服务端会话 | 浏览器内存 |
| 部署产物 | 需要常驻服务进程 | 需要常驻服务进程 | 纯静态文件 |
| 首屏 | 服务端拼好再下发 | 服务端拼好再下发 | 前端自己渲染 |
| 断网/离线 | 基本不可用 | 基本不可用 | 已加载部分可继续操作 |
| 扩容方式 | 会话粘滞或共享会话 | 会话粘滞或共享会话 | 静态托管横向扩 |
| 迁移成本 | 最低,几乎照搬 VCL | 低,组件换一套 | 中,要理解异步和 DOM |
服务端渲染那两条路线的诱惑力在于「几乎不用改代码」:窗体、事件、状态全在服务端,浏览器只是个瘦客户端,回发一次拿一次新界面。代价是每个用户都占一份服务端资源,进程一挂全挂,扩容时要处理会话粘滞。客户端转译这条路线把这些负担换成了前端复杂度:状态在浏览器里,服务端只提供静态文件和 API,扩容就是加一台静态服务器的事,但你必须接受异步、接受没有模态阻塞、接受手动管缓存。
怎么验证自己选对了?有个很土但有效的办法:打开开发者工具,清空缓存,勾上禁用缓存,刷新一次,把 Network 面板里的请求数一遍。如果首屏几十个请求里有大半是去取数据的,说明这是个「前端渲染 + API」的应用,静态托管就够了;如果只有一个 HTML 请求,内容全在里面,那状态就在服务端。再看一眼断网之后页面还能不能操作——能,说明逻辑在浏览器里;不能,说明每次交互都在等服务器。
我自己在这上面栽过一次:把一个内部工具按桌面端的思路写成同步等待,每个操作都假设「调用返回时结果已经有了」,本地跑没问题,部署到网络稍差的网络环境里,用户点一下要愣三秒才反应过来,还以为是卡死了。后来把整条链路改成回调式,代码量多了三成,但体验立刻正常。从那以后我每接一个 Web 项目,都强制自己先画一遍「哪些状态在浏览器、哪些在服务端」的边界,再动手写第一行界面代码。希望帮到你。
本文还有配套的精品资源,点击获取