Files
TankIC/tools/split.js
T
TermiNexus 90c377ad79 feat(3dtank): 本地镜像与 hornet 工具链迁到新版 bundle main.6469b966.js
旧 main.b7114b43.js 已被新构建替换(10,011,039 字节 / 1335 模块),本地镜像整体重切到新版并清掉旧产物;hornet 工具链按 tools/map_symbols.js 生成的新旧符号映射改名后重新打包(3dtank_bundle.js 与 unpacked/hornet_bundle.js 同步)。tools/symbol_map.json(8.9MB 映射产物)与切割解包目录不进库,可用 map_symbols.js / split.js 重新生成。
2026-09-17 19:59:40 +08:00

346 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// split.js - 将 main.<hash>.js 切割为多文件加载结构
// 优化: 资源模块(标准 t.exports=i.p+"..." 格式)用 JSON + 模板替代,不生成独立文件
// 用法: node split.js [bundle路径] [输出目录] [--clean] [--no-hornet]
// 未指定 bundle 时自动取项目根目录下最新的 main.<hash>.js
// 未指定输出目录时默认 3dtank.com/play/static/js
// --clean 先删除旧产物(unpacked/module_*.js、resources.json、旧 main.*.js;保留 hornet_*.js)
// --no-hornet 不在主文件末尾注入 hornet_bundle 自动加载
// 输出:
// <输出目录>/main.<hash>.js (主文件: 加载器 + runtime)
// <输出目录>/unpacked/resources.json (资源模块 ID→路径映射)
// <输出目录>/unpacked/module_<id>.js (仅代码模块)
const fs = require('fs');
const path = require('path');
const acorn = require('acorn');
const walk = require('acorn-walk');
const prettier = require('prettier');
const ROOT = path.resolve(__dirname, '..');
// 自动取最新的 main.<hash>.js(排除输出目录里的加载器)
function AutoDetectBundle() {
const cands = fs.readdirSync(ROOT)
.filter((f) => /^main\.[0-9a-f]+\.js$/.test(f))
.map((f) => path.join(ROOT, f))
.filter((f) => fs.statSync(f).isFile())
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
return cands[0] || null;
}
const argv = process.argv.slice(2);
const flags = argv.filter((a) => a.startsWith('--'));
const positional = argv.filter((a) => !a.startsWith('--'));
const CLEAN = flags.indexOf('--clean') >= 0;
const WITH_HORNET = flags.indexOf('--no-hornet') < 0;
const LOADER_ONLY = flags.indexOf('--loader-only') >= 0; // 只重生成主文件,复用已有模块文件
const autoBundle = positional[0] ? null : AutoDetectBundle();
const BUNDLE_PATH = path.resolve(positional[0] || autoBundle || '');
const OUT_DIR = path.resolve(positional[1] || path.join(ROOT, '3dtank.com', 'play', 'static', 'js'));
const UNPACKED_DIR = path.join(OUT_DIR, 'unpacked');
const MAIN_PATH = BUNDLE_PATH ? path.join(OUT_DIR, path.basename(BUNDLE_PATH)) : '';
const RESOURCES_JSON_PATH = path.join(UNPACKED_DIR, 'resources.json');
// UMD 包装模板
const UMD_PREFIX = `var __script_base_url__=(typeof document!=="undefined"&&document.currentScript&&document.currentScript.src)?document.currentScript.src.replace(/[^\\/]+$/,""):"./";!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.Launcher=n():t.Launcher=n()}(globalThis,async()=>{`;
const UMD_SUFFIX = (bundlePath) => `})//# sourceMappingURL=${path.basename(bundlePath)}.map\n`;
// 资源模块识别:标准格式 (t,n,i)=>{"use strict";t.exports=i.p+"path"}
// 提取 path
function tryExtractResource(valueSrc) {
// 标准格式: t.exports = i.p + "..."
const m = valueSrc.match(
/\{\s*"use strict";\s*(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?\s*\}/
);
if (m) return { type: 'std', path: m[1] };
// 变体: t.exports = i.p + "..." (无 use strict 或无大括号)
const m2 = valueSrc.match(
/(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?/
);
if (m2 && valueSrc.length < 200) return { type: 'std', path: m2[1] };
return null;
}
(async () => {
if (!BUNDLE_PATH || !fs.existsSync(BUNDLE_PATH)) {
console.error('找不到待切割的 bundle:' + (BUNDLE_PATH || '(未自动探测到 main.<hash>.js)'));
process.exit(1);
}
if (!fs.existsSync(UNPACKED_DIR)) fs.mkdirSync(UNPACKED_DIR, { recursive: true });
if (CLEAN) {
console.log('[0/7] 清理旧产物...');
let removed = 0;
for (const dir of [UNPACKED_DIR, OUT_DIR]) {
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir)) {
const isModule = /^module_\d+\.js$/.test(f);
const isResources = f === 'resources.json';
const isOldMain = /^main\.[0-9a-f]+\.js$/.test(f) && path.join(dir, f) !== MAIN_PATH;
if (isModule || isResources || isOldMain) {
fs.unlinkSync(path.join(dir, f));
removed++;
}
}
}
console.log(` 已删除 ${removed} 个旧文件(保留 hornet_*.js)`);
}
console.log('[1/7] 读取原 bundle...');
console.log(` 文件: ${BUNDLE_PATH}`);
const source = fs.readFileSync(BUNDLE_PATH, 'utf8');
console.log(` 大小: ${(source.length / 1024 / 1024).toFixed(2)} MB`);
console.log('[2/7] 解析 AST...');
const ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'script',
allowReturnOutsideFunction: true,
});
let modulesDecl = null;
walk.simple(ast, {
VariableDeclarator(node) {
if (node.id && node.id.name === '__webpack_modules__' && node.init && node.init.type === 'ObjectExpression') {
modulesDecl = node;
}
},
});
if (!modulesDecl) throw new Error('未找到 __webpack_modules__');
const objStart = modulesDecl.init.start;
const objEnd = modulesDecl.init.end;
console.log('[3/7] 分类提取模块 (资源模块 vs 代码模块)...');
const props = modulesDecl.init.properties;
console.log(` 共 ${props.length} 个模块`);
const resourceMap = {}; // id -> path (资源模块)
const codeModuleIds = []; // 代码模块 ID 列表
let idx = 0;
let failCount = 0;
let resourceCount = 0;
let codeCount = 0;
for (const prop of props) {
idx++;
// 计算 ID
let keyRaw;
let keyNum;
if (prop.key.type === 'Identifier') {
keyRaw = prop.key.name;
keyNum = Number(keyRaw);
if (isNaN(keyNum)) keyNum = keyRaw;
} else if (prop.key.type === 'Literal') {
keyRaw = String(prop.key.value);
keyNum = typeof prop.key.value === 'number' ? prop.key.value : keyRaw;
} else {
keyRaw = `unknown_${idx}`;
keyNum = keyRaw;
}
const valueSrc = source.slice(prop.value.start, prop.value.end);
// 尝试识别为资源模块
const resource = tryExtractResource(valueSrc);
if (resource) {
// 资源模块:存入 resourceMap,不生成独立文件
const idKey = typeof keyNum === 'number' ? keyNum : keyRaw;
resourceMap[idKey] = resource.path;
resourceCount++;
continue;
}
// 代码模块:美化并写入独立文件
const paddedId = typeof keyNum === 'number' ? String(keyNum).padStart(5, '0') : keyRaw;
const idLiteral = typeof keyNum === 'number' ? keyNum : JSON.stringify(keyRaw);
if (LOADER_ONLY && fs.existsSync(path.join(UNPACKED_DIR, `module_${paddedId}.js`))) {
codeModuleIds.push(keyNum);
codeCount++;
continue;
}
let beautified = valueSrc;
try {
beautified = await prettier.format('(' + valueSrc + ')', {
parser: 'babel',
semi: true,
singleQuote: false,
printWidth: 110,
tabWidth: 2,
});
beautified = beautified.trim();
if (beautified.startsWith('(') && beautified.endsWith(')')) {
beautified = beautified.slice(1, -1).trim();
}
while (beautified.endsWith(';')) beautified = beautified.slice(0, -1).trimEnd();
} catch (e) {
failCount++;
}
const filePath = path.join(UNPACKED_DIR, `module_${paddedId}.js`);
const fileContent =
`// ============================================================\n` +
`// Module ID: ${keyRaw}\n` +
`// ============================================================\n` +
`;(window.__modules__ = window.__modules__ || {})[${idLiteral}] = ${beautified};\n`;
fs.writeFileSync(filePath, fileContent, 'utf8');
codeModuleIds.push(keyNum);
codeCount++;
if (idx % 200 === 0) {
console.log(` 已处理 ${idx}/${props.length} (资源 ${resourceCount}, 代码 ${codeCount})`);
}
}
console.log(` 完成。资源模块: ${resourceCount}, 代码模块: ${codeCount}, 美化失败: ${failCount}`);
console.log('[4/7] 写入 resources.json...');
if (LOADER_ONLY && fs.existsSync(RESOURCES_JSON_PATH)) {
console.log(' --loader-only:复用已有 resources.json');
} else {
fs.writeFileSync(
RESOURCES_JSON_PATH,
JSON.stringify(
{
type: 'webpack-resource-map',
generatedAt: new Date().toISOString(),
totalResources: resourceCount,
resourceMap,
},
null,
0
),
'utf8'
);
}
console.log(` resources.json 大小: ${(fs.statSync(RESOURCES_JSON_PATH).size / 1024).toFixed(1)} KB`);
console.log('[5/7] 提取原 webpack runtime...');
const runtimeStart = objEnd;
const returnIdx = source.lastIndexOf('return __webpack_exports__');
if (returnIdx === -1) throw new Error('找不到 return __webpack_exports__');
const returnEnd = returnIdx + 'return __webpack_exports__'.length;
let runtimeCode = source.slice(runtimeStart, returnEnd);
if (runtimeCode.startsWith(',')) {
runtimeCode = ';var ' + runtimeCode.slice(1);
}
console.log(` runtime 代码长度: ${runtimeCode.length} 字符`);
console.log(`[6/7] 生成主文件 ${path.basename(BUNDLE_PATH)}...`);
const codeIdsArrayLiteral = codeModuleIds
.map((id) => (typeof id === 'number' ? id : JSON.stringify(id)))
.join(',');
const loaderCode = `
// ===== 切割版模块加载器 =====
window.__modules__ = {};
const __base_url__ = window.__script_base_url__ || './';
// 6.1 加载资源模块 (从 resources.json 批量注册)
const __resource_resp__ = await fetch(__base_url__ + 'unpacked/resources.json');
if (!__resource_resp__.ok) throw new Error('resources.json load failed: HTTP ' + __resource_resp__.status);
const __resource_data__ = await __resource_resp__.json();
const __resource_map__ = __resource_data__.resourceMap;
for (const __id__ in __resource_map__) {
if (!Object.prototype.hasOwnProperty.call(__resource_map__, __id__)) continue;
const __path__ = __resource_map__[__id__];
const __id_num__ = Number(__id__);
window.__modules__[__id_num__] = (function(__p__) {
return (t, n, i) => {
"use strict";
t.exports = i.p + __p__;
};
})(__path__);
}
// 6.2 加载代码模块 (fetch + eval 独立文件)
const __code_ids__ = [${codeIdsArrayLiteral}];
await Promise.all(__code_ids__.map(async (__id__) => {
const __padded__ = typeof __id__ === 'number' ? String(__id__).padStart(5, '0') : __id__;
const __url__ = __base_url__ + 'unpacked/module_' + __padded__ + '.js';
const __resp__ = await fetch(__url__);
if (!__resp__.ok) throw new Error('Module ' + __id__ + ' load failed: HTTP ' + __resp__.status + ' @ ' + __url__);
const __code__ = await __resp__.text();
(0, eval)(__code__);
}));
var __webpack_modules__ = window.__modules__;
`;
// 6.3 hornet 工具链自动加载(注入在 runtime 入口模块加载之后、return 之前)
const hornetCode = WITH_HORNET
? `
// ===== 暴露 webpack require(hornet 工具链 hook_gamemode/hornet_lib 依赖)=====
window.__wp_require__ = __webpack_require__;
// ===== 加载 hornet_bundle(默认关闭:URL 加 ?hornet=1 才加载,避免日志遮挡)=====
if (/[?&]hornet=1(&|$)/.test(location.search)) {
try {
const __hornet_url__ = __base_url__ + 'unpacked/hornet_bundle.js';
const __hornet_resp__ = await fetch(__hornet_url__);
if (__hornet_resp__.ok) {
const __hornet_code__ = await __hornet_resp__.text();
(0, eval)(__hornet_code__);
console.log('[Hornet] bundle 已加载');
} else {
console.warn('[Hornet] bundle 加载失败: HTTP ' + __hornet_resp__.status);
}
} catch (__hornet_err__) {
console.error('[Hornet] bundle 加载异常:', __hornet_err__);
}
}
`
: '';
let runtimeWithHornet = runtimeCode;
if (WITH_HORNET) {
const retIdx = runtimeWithHornet.lastIndexOf('return __webpack_exports__');
if (retIdx >= 0) {
runtimeWithHornet = runtimeWithHornet.slice(0, retIdx) + hornetCode + runtimeWithHornet.slice(retIdx);
} else {
console.warn(' [WARN] 未找到 return __webpack_exports__,跳过 hornet 注入');
}
}
const newBundle =
UMD_PREFIX +
'\n' +
loaderCode +
'\n// ===== 原 webpack runtime (一字未改) =====\n' +
runtimeWithHornet +
';\n' +
UMD_SUFFIX(BUNDLE_PATH);
fs.writeFileSync(MAIN_PATH, newBundle, 'utf8');
console.log(` 主文件大小: ${(newBundle.length / 1024).toFixed(1)} KB`);
console.log('[7/7] 验证语法...');
try {
acorn.parse(newBundle, {
ecmaVersion: 'latest',
sourceType: 'script',
allowReturnOutsideFunction: true,
});
console.log(' ✓ 主文件语法正确');
} catch (e) {
console.error(` ✗ 语法错误: ${e.message}`);
process.exit(1);
}
console.log('\n=== 完成 ===');
console.log(` 总模块数: ${props.length}`);
console.log(` 资源模块: ${resourceCount} (存入 resources.json)`);
console.log(` 代码模块: ${codeCount} (独立文件)`);
console.log(` 美化失败: ${failCount}`);
console.log(` 主文件: ${MAIN_PATH}`);
console.log(` 资源 JSON: ${RESOURCES_JSON_PATH}`);
console.log(` 代码模块目录: ${UNPACKED_DIR}\\`);
})().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});