【JavaScript设计模式】增强版发布订阅模式——Webpack的核心Tapable(一)

news/2024/5/21 4:08:48/文章来源:https://blog.csdn.net/pfourfire/article/details/126889520

Tapable简介

Webpack整体架构的实现就是靠它的插件系统,其中Compiler和Compilation负责管理整个构建流程,同时暴露出一些Hook,然后由不同职责的插件来监听这些Hook,并在合适的时机完成具体的工作。Tapable是整个Webpack插件系统的核心,Webpack中的所有插件都继承了Tapable类(Webpack5中已不再继承)。而从Tapable的一些特性中可以看出,Tapable其实是一种增强版的发布订阅模式。先看看Tapable提供了哪些Hook

序号Hook类型Hook名称监听方法是否可并行
1SyncHook同步钩子tap
2SyncBailHook同步熔断钩子tap
3SyncWaterfallHook同步瀑布钩子tap
4SyncLoopHook同步循环钩子tap
5AsyncParallelHook异步并行钩子taptapAsync
6AsyncParallelBailHook异步并行熔断钩子taptapAsync
7AsyncSeriesHook异步串行钩子taptapAsync
8AsyncSeriesBailHook异步串行熔断钩子taptapAsync
9AsyncSeriesLoopHook异步串行循环钩子taptapAsync
10AsyncSeriesWaterfallHook异步串行瀑布钩子taptapAsync

Sync:同步。> Async:异步。> Bail:当一个hook注册了多个回调方法,若任意一个回调方法返回了不为undefined的值,就不再执行后面的回调方法。> Waterfall:当一个hook注册了多个回调方法,前一个回调执行完了才会执行下一个回调,而前一个回调的执行结果会作为参数传给下一个回调函数。> Loop:当一个hook注册了回调方法,如果这个回调方法返回了true就重复循环这个回调,只有当这个回调返回undefined才执行下一个回调。> Parallel:当一个hook注册了多个回调方法,这些回调同时开始并行执行。> Series:当一个hook注册了多个回调方法,前一个执行完了才会执行下一个。

下载Tapable(v2.2.1)的源码,打开/index.js:

exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");
exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook"); 

可以看到,Tapable导出了10个钩子函数和2个工具类。其中每一个…Hook.js文件都对应一个钩子函数的实现。而HookMap.js和MultiHook.js分别定义了Tapable的两个工具类:HookMap和MultiHook。余下两个文件:Hook.js和HookCodeFactory.js分别定义了Tapable的核心类:HookHookCodeFactoryTapable中的所有钩子函数都继承自这两个类

什么是发布订阅模式?

在软件架构中,发布订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

SyncHook应用示例

SyncHookTapable中最基础的一个钩子,也最接近发布订阅模式的一个钩子。其余的钩子除了实现发布订阅外,还包含了不同类型的流程控制。

我们即将通过一个SyncHook钩子的简单应用示例来了解SyncHook的内部实现,先来看一个简单的应用示例:

应用示例

const { SyncHook } = require('tapable');
// 实例化
const hook = new SyncHook(['width', 'height']);
const options = { name: "synchook" };
// 订阅
hook.tap(options, (width, height) => {console.log('callback1', width, height);
});
// 订阅
hook.tap(options, (width, height) => {console.log('callback2', width, height);
});
// 订阅
hook.tap(options, (width, height) => {console.log('callback3', width, height);
});
// 执行
hook.call(100, 200);
console.log(hook.call) 

输出结果:

callback1 100 200
callback2 100 200
callback3 100 200ƒ anonymous(width, height) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(width, height);
var _fn1 = _x[1];
_fn1(width, height);
var _fn2 = _x[2];
_fn2(width, height);
} 

SyncHook源码

一、分析hook.tap()

以上的订阅过程发生了什么?我们来看/SyncHook.js文件的源码:

const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");class SyncHookCodeFactory extends HookCodeFactory {content({ onError, onDone, rethrowIfPossible }) {return this.callTapsSeries({onError: (i, err) => onError(err),onDone,rethrowIfPossible});}
}const factory = new SyncHookCodeFactory();const TAP_ASYNC = () => {throw new Error("tapAsync is not supported on a SyncHook");
};const TAP_PROMISE = () => {throw new Error("tapPromise is not supported on a SyncHook");
};const COMPILE = function (options) {factory.setup(this, options);return factory.create(options);
};function SyncHook(args = [], name = undefined) {const hook = new Hook(args, name);hook.constructor = SyncHook;hook.tapAsync = TAP_ASYNC;hook.tapPromise = TAP_PROMISE;hook.compile = COMPILE;return hook;
}SyncHook.prototype = null;module.exports = SyncHook; 

可以看到,SyncHook函数的tap方法继承自Hook类,所以打开/hook.js,找到Hooktap方法:

tap()

tap(options, fn) {this._tap("sync", options, fn);
} 

Hook中的tap方法调用_tap方法并传入三个参数,其中"sync"表示当前订阅的函数为同步。接下来看_tap的内容:

_tap()

_tap(type, options, fn) {if (typeof options === "string") {options = {name: options.trim()};} else if (typeof options !== "object" || options === null) {throw new Error("Invalid tap options");}if (typeof options.name !== "string" || options.name === "") {throw new Error("Missing name for tap");}if (typeof options.context !== "undefined") {deprecateContext();}options = Object.assign({ type, fn }, options);options = this._runRegisterInterceptors(options);this._insert(options);
} 

_tap做了以下工作:

  • 接收三个参数:type、options和fn;
  • 验证并处理options:可知options为必传,只接收string|object格式。若为object格式,name属性必传且必须是非空字符串;
  • 处理options.context;
  • 将type和fn合并到options对象上;
  • 调用_runRegisterInterceptors注册拦截器(本例不用考虑);
  • 调用_insert方法;

那么_insert做了什么工作呢?来看_insert的内部代码:

_insert()

_insert(item) {this._resetCompilation();let before;if (typeof item.before === "string") {before = new Set([item.before]);} else if (Array.isArray(item.before)) {before = new Set(item.before);}let stage = 0;if (typeof item.stage === "number") {stage = item.stage;}let i = this.taps.length;while (i > 0) {i--;const x = this.taps[i];this.taps[i + 1] = x;const xStage = x.stage || 0;if (before) {if (before.has(x.name)) {before.delete(x.name);continue;}if (before.size > 0) {continue;}}if (xStage > stage) {continue;}i++;break;}this.taps[i] = item;
} 

_insert方法做了以下工作:

  • 重置call、callAsync和promise;
  • 定义before对象和stage,用来获取当前传入的options的插入位置;
  • 循环判断,将options插入到this.taps中的恰当位置保存;

控制台输出hook.taps,可以看到this.taps存储的内容如下:

console.log(hook.taps)
// 输出:
[{ "type": "sync", "name": "synchook", fn: ƒ },{ "type": "sync", "name": "synchook", fn: ƒ },{ "type": "sync", "name": "synchook", fn: ƒ }
] 

到此,hook函数的一个订阅过程就基本完成了。

二、分析hook.call()

hook的执行过程即hook.call()调用后发生了什么?从上文/SyncHook.js文件的源码中我们可以看到,SyncHook函数的call方法继承自Hook类。

call()

打开/Hook.js文件查看源码,找到Hookcall方法:

const CALL_DELEGATE = function (...args) {this.call = this._createCall("sync");return this.call(...args);
};class Hook {constructor(args = [], name = undefined) {this._args = args;this.name = name;this.taps = [];this.interceptors = [];// 省略部分代码...this.call = CALL_DELEGATE;// 省略部分代码...}// 省略部分代码..._createCall(type) {return this.compile({taps: this.taps, // 保存的是此前的订阅s,即hook.tapsinterceptors: this.interceptors, // 拦截器args: this._args, // 创建实例时传入的参数:['width', 'height']type: type // 'sync'});}// 省略部分代码...
} 

由此可知,call()内部调用了hookcompile方法,接下来看compile方法的内容:

compile()

compile方法实际在/SyncHook.js文件中被重写,所以看/SyncHook.js文件的源码:

function SyncHook(args = [], name = undefined) {// 省略部分代码...hook.compile = COMPILE;// 省略部分代码...
}const factory = new SyncHookCodeFactory();
const COMPILE = function (options) {factory.setup(this, options);return factory.create(options);
}; 

可以看到,compile做了两件事:

1.factory.setup(this, options);
2.return factory.create(options);

factory.setup()

// 此段代码来自源文件/HookCodeFactory.js
setup(instance, options) {instance._x = options.taps.map(t => t.fn);
} 

将保存在taps上的fn提取并保存到this._x上。打印结果:

console.log(hook._x)
// 输出:
(3) [ƒ, ƒ, ƒ]0: ƒ (width, height)1: ƒ (width, height)2: ƒ (width, height) 

factory.create()

create方法的作用是根据type不同,拼接出不同的代码字符串并且通过new Function创建一个函数,赋值给fn,最后返回fn。从上文可知this.options.type的值为'sync',所以我们现在只需要关注type是'sync'的case分支:

// 此段代码来自源文件/HookCodeFactory.js
create(options) {this.init(options);let fn;switch (this.options.type) {case "sync":fn = new Function(this.args(),'"use strict";\n' +this.header() +this.contentWithInterceptors({onError: err => `throw ${err};\n`,onResult: result => `return ${result};\n`,resultReturns: true,onDone: () => "",rethrowIfPossible: true}));break;case "async":// 省略部分代码...break;case "promise":// 省略部分代码...break;}this.deinit();return fn;
} 

为了便于理解create方法,我们先了解新建函数的语法:

  • let fn = new Function ([arg1[, arg2[, ...argN]],] functionBody)

可见create中的this.args()对应返回新建函数fn的参数,this.header() + this.contentWithInterceptors()对应返回新建函数fn的函数体字符串。其中this.args()this.header()的逻辑比较简单:

args()

args({ before, after } = {}) {let allArgs = this._args;if (before) allArgs = [before].concat(allArgs);if (after) allArgs = allArgs.concat(after);if (allArgs.length === 0) {return "";} else {return allArgs.join(", ");}
} 

args方法将传入的参数用,拼接为字符串并返回。

header()

header() {let code = "";if (this.needContext()) {code += "var _context = {};\n";} else {code += "var _context;\n";}code += "var _x = this._x;\n";if (this.options.interceptors.length > 0) {code += "var _taps = this.taps;\n";code += "var _interceptors = this.interceptors;\n";}return code;
} 

header方法做了以下工作:

  • 先判断是否需要context对象。如果需要就定义context为一个空对象,不需要就定义context为undefined。
  • 定义_x是this._x,即订阅函数fn组成的数组
  • 判断是否有拦截器,如果有则定义并缓存相应的_taps和_interceptors。
  • 返回拼接的字符串code。

contentWithInterceptors()

contentWithInterceptors主要为了处理拦截器。因为本例不含拦截器,所以略过,直接进入下一步。

contentWithInterceptors(options) {if (this.options.interceptors.length > 0) {// 省略部分代码...} else {return this.content(options);}
} 

content()

content方法见/SyncHook.js文件,在HookCodeFactory的子类SyncHookCodeFactory中被定义:

class SyncHookCodeFactory extends HookCodeFactory {content({ onError, onDone, rethrowIfPossible }) {return this.callTapsSeries({onError: (i, err) => onError(err),onDone,rethrowIfPossible});}
} 

content方法调用并返回callTapsSeries(),接下来分析callTapsSeries()

callTapsSeries()

callTapsSeries({onError,onResult,resultReturns,onDone,doneReturns,rethrowIfPossible}) {if (this.options.taps.length === 0) return onDone();const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");const somethingReturns = resultReturns || doneReturns;let code = "";let current = onDone;let unrollCounter = 0;for (let j = this.options.taps.length - 1; j >= 0; j--) {const i = j;const unroll =current !== onDone &&(this.options.taps[i].type !== "sync" || unrollCounter++ > 20);if (unroll) {unrollCounter = 0;code += `function _next${i}() {\n`;code += current();code += `}\n`;current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;}const done = current;const doneBreak = skipDone => {if (skipDone) return "";return onDone();};const content = this.callTap(i, {onError: error => onError(i, error, done, doneBreak),onResult:onResult &&(result => {return onResult(i, result, done, doneBreak);}),onDone: !onResult && done,rethrowIfPossible:rethrowIfPossible && (firstAsync < 0 || i < firstAsync)});current = () => content;}code += current();return code;} 

callTapsSeries方法做了以下工作:

1.判断是否存在订阅函数,若不存在则直接返回空字符串;
2.定义函数current,并将其赋值为初始传入的onDone: () => “”;
3.倒序遍历taps,将current赋值给函数done;
4.将done传入this.callTap()并执行,再将this.callTap的返回值重新赋值给current;
5.重复步骤3-4;
6.执行current并返回拼接的字符串code。

也就是说函数current和done的作用就是为了缓存前一次循环拼接出的字符串并将此次拼接的字符串传递到下一次循环。

那么,想知道在循环中拼接字符串的具体内容?重点看下面的callTap方法:

callTap()

由于本例中的type的值为'sync',且不存在拦截器,所以重点看以下相关代码即可:

callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {let code = "";let hasTapCached = false;for (let i = 0; i < this.options.interceptors.length; i++) {// 省略部分代码...}code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;const tap = this.options.taps[tapIndex];switch (tap.type) {case "sync":if (!rethrowIfPossible) {code += `var _hasError${tapIndex} = false;\n`;code += "try {\n";}if (onResult) {code += `var _result${tapIndex} = _fn${tapIndex}(${this.args({before: tap.context ? "_context" : undefined})});\n`;} else {code += `_fn${tapIndex}(${this.args({before: tap.context ? "_context" : undefined})});\n`;}if (!rethrowIfPossible) {code += "} catch(_err) {\n";code += `_hasError${tapIndex} = true;\n`;code += onError("_err");code += "}\n";code += `if(!_hasError${tapIndex}) {\n`;}if (onResult) {code += onResult(`_result${tapIndex}`);}if (onDone) {code += onDone();}if (!rethrowIfPossible) {code += "}\n";}break;// 省略余下代码...}return code;
} 

callTap方法做了以下工作(以第一个tap为例):

1.初始变量code为空字符串;
2.拼接当前订阅函数的定义:var _fn0 = _x[0];
3.进入case 'sync’分支;
4.判断rethrowIfPossible,值为false则拼接var _hasError0 = false;try {
5.判断是否存在onResult,为true则拼接var _result0 = _fn0(args);,为false则拼接_fn0(args);
6.判断rethrowIfPossible,值为false则拼接} catch(_err) {_hasError0 = true;throw _err;}if(!_hasError0) {
7.判断是否存在onResult,为true则拼接return _result0;
8.判断是否存在onDone,为true则拼接done,即上一次循环缓存的current;
9.判断rethrowIfPossible,值为false则拼接},即闭合代码块;
10.返回拼接的字符串code;

从上文的内容可知,本例中的rethrowIfPossible == true;onResult == undfined;,因此步骤4-6-7-9均不会执行。

那么重新梳理逻辑可知本例中的callTap方法实际做了以下工作(以第一个tap为例):

1.初始变量code为空字符串;
2.拼接当前订阅函数的定义:var _fn0 = _x[0];
3.进入case 'sync’分支;
4.判断onResult,拼接_fn0(args);
5.判断是否存在onDone,为true则拼接done,即上一次循环缓存的current;
6.返回拼接的字符串code;

**以上便是完整的拼接流程,最终的拼接结果将通过new Function创建出一个新的函数并赋值给call方法。**最后,整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_9095.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

CentOS二进制安装Containerd

Containerd有两种安装包∶ 1>. 第一种是containerd-xxx&#xff0c;这种包用于单机测试没问题&#xff0c;不包runC&#xff0c;需要提前安装。 2>. 第二种是cri-containerd-cni-xxx&#xff0c;包含runC和k8s里的所需要的相关文件。k8s集群里需要用到此包&#xff0c;…

Qt5.12.2添加mqtt模块

Qt5.12.2添加mqtt模块下载 mqtt Qt 子模块使用 Qt 编译模块并install 生成动态库qtcreator 打开解压后的 qtmqtt源码下的 pro 工程文件下载 mqtt Qt 子模块 https://download.qt.io/official_releases/qt/5.15/5.15.4/submodules/ 下载后解压&#xff1a; 使用 Qt 编译模块并…

Go语言实现网盘系统(上)

该项目将基于go-zero和xorm go-zero中文文档: https://legacy.go-zero.dev/cn/ Xorm中文文档: http://xorm.topgoer.com/ 功能划分 整个项目可以分为3个模块: 用户模块、存储池模块和文件共享模块数据库设计 用户是一个实体,建立对应的表user_basic,存储了用户信息,DDL如下:…

ViLBERT—(NeurIPS-2019)

ViLBERT(Vision-and-Language BERT)是发表于2019年的论文&#xff0c;在功能上实现了文本图像的多模态特征提取与分类。改论文的特点是使用了双流模型&#xff0c;即先各个模态特征单独自注意力&#xff0c;再经过transformer交叉注意力。单流是将不同模态特征序列先拼接起来&a…

FluentCRM 2.5 – 大量新功能,自动化你的业务!

大家好&#xff01; 等待结束了&#xff01;我知道你们都在热切地等待另一个 FluentCRM 更新并且它已经上线了。 这一次&#xff0c;我很高兴地宣布我们开发了一些史诗级、最令人期待的功能。让我们开始了解 FluentCRM 2.5&#xff01; 客户关系管理是关于从您的潜在客户、顾…

ASO优化之手游该如何获得巨量新增(上)

现今各家应用商店里的应用和手游数量都非常多&#xff0c;那么提高曝光和获得自然新增是一项非常困难的工作。所以&#xff0c;应用产品ASO优化已经是所有同行都必做的功课之一。且每天都有很多款新游戏发布&#xff0c;所以想让你的手游脱颖而出就更加困难。手机游戏现已经成为…

多线程---同步方法及同步块(解决线程不安全)

同步方法 注意:锁的量是变化的量,需要增删改的对象 三大不安全案例解决方案: 案例1(火车站买票) package com.mokuiran.thread.synchronizedtest;​//不安全买票//线程不安全,将会输出负数public class UnsafeBuyTicket{​ public static void main(String[] args) { …

LayaAir 2.12.2新版本已发布,即将进入3.0时代

2.13.2这个LayaAir引擎小版本&#xff0c;修复了若干IDE与引擎的BUG&#xff0c;新增了一些2D的功能&#xff0c;3D也有所优化。这个小版本将成为LayaAir 2.0系列引擎的一个重要里程碑版本。自此开始&#xff0c;2.x引擎如果没有平台适配的新功能&#xff0c;将不会再出现beta版…

【博客498】k8s kubelet device-plugins

k8s kubelet device-plugins 场景&#xff1a; 对于云的用户来说&#xff0c;在 GPU 的支持上&#xff0c;他们最基本的诉求其实非常简单&#xff1a;我只要在 Pod 的 YAML 里面&#xff0c;声明某容器需要的 GPU 个数&#xff0c;那么 Kubernetes 为我创建的容器里就应该出现…

2022Google开发者大会—我的首次参会体验

一、大会简介 Google 开发者大会 (Google Developer Summit) 是 Google 面向开发者和科技爱好者展示最新产品和平台的年度盛会。2022 年&#xff0c;Google 开发者大会以 “共码未来” 为主题&#xff0c;携手开发者与合作伙伴&#xff0c;以科技之力&#xff0c;突破想象&…

Petalinux配置

目录 一、设计流程 1.Petalinux环境变量 2.创建petalinux工程 3.配置 petalinux 工程 4.配置 Linux 内核 5.配置 Linux 根文件系统 6.配置设备树 7.编译工程 8.制作BOOT.BIN启动文件 9.启动下载 三、配置详解 ①使能环境变量 ②创建petalinux工程 ③配置petalinu…

数据通信 路由交换

数据通信 路由交换 ISP :运营商提供 设备 device 介质 Media 消息/报文 message/data 协议 protocol 发送方/信息源 sender/source 接收方/信息方 receiver/destination PDU 协议数据单元 分为 (传输层)段 (网络层)包 (数据链路层)帧 (物理层)比特 数据字段的以太网数…

归并排序(MergeSort)

文章目录思路分析两个有序数组的归并一个无序数组的拆分和归并代码实现递归实现非递归版本复杂度和稳定性空间复杂度时间复杂度稳定性思路分析 两个有序数组的归并 现在给你两个有序数组&#xff0c;让你进行归并成一个大的有序数组。 nums1 [1,2,3] nums2 [2,5,6] > n…

搭建 Sentry 服务

Sentry 提供并维护了一个基于 Docker 和 Docker Compose 的开箱即用的简单用例&#xff0c;直接通过运行 bash 脚本就可以快速搭建出一个 Sentry 服务。 准备工作 我是用的是CentOS 7的云服务器&#xff08;sentry 推荐最低配置4核8G&#xff09;&#xff0c;首先我们要安装 d…

论EDAG浏览器插件对IDEA上自己编写的HTML的影响

当使用IDEA启动HTML界面用EDAG浏览器访问时,此时如果浏览器有安装拓展会直接影响源代码解决方法:关闭拓展,刷新后此行自动删除,事件执行正常

3D Slicer学习记录(0)--利用OpenIGTLink实现数据发送接收

1、前记&#xff1a; 最近在上手学习3D Slicer&#xff0c;作为开源医学图像软件在临床和学术研究中应用广泛&#xff0c;基于openIGTLink协议为框架实现了很多手术导航系统。然而要从头实现手术导航系统并非易事&#xff0c;利用Slicer可以加速这一进程。要了解这部分可以在官…

Tkinter模块学习

Tkinter主窗口和位置大小 通过geometry(wxhxy)进行设置&#xff0c;w为宽度&#xff0c;h为高度&#xff0c;x表示距离屏幕左边的距离&#xff0c;-x表示距离屏幕右边的距离&#xff0c;y表示距离屏幕上边的距离&#xff0c;-y表示屏幕下边的距离 # -*- coding: UTF-8 -*- Da…

Spring Boot 整合Hibernate Validator

Spring Boot 整合Hibernate Validator依赖<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframew…

线程安全问题的原因和解决方案大杂烩

1.抢占式执行&#xff08;线程不安全的根本原因&#xff09;&#xff0c;多个线程在调度执行过程中是随机的。这是内核决定的&#xff0c;我们无法改变。 2.多个线程同时修改同一个变量。一个线程修改一个变量&#xff0c;安全&#xff1b;多个线程读一个变量&#xff0c;安全…

Java 快速开发几 MB 独立 EXE,写图形界面很方便

Java 写的桌面软件带上运行时只有 6 MB,而且还是独立 EXE 文 件,是不是难以置信?想一想 Electron 没写多少功能就可能超过百 MB 的体积,Java 写的桌面软件算不算得上小、轻、快呢?Java 写的桌面软件带上运行时只有 6 MB,而且还是独立 EXE 文 件,是不是难以置信? 想一想…