feat(runtime): 全站前端脚本统一接入 AksrtFrontend 全局 DOM 生命周期

- rehydrate.js 新增 boot()/aksrt:ready 事件,支持 boot 后晚注册立即以 init 触发
- aksrt-runtime.js 初始加载改走 AksrtFrontend.boot,tooltip/倒计时注册为组件
- 约 30 个前端脚本(首页、评论、支付、路由、页面叶子、widgets)
  从直接 DOMContentLoaded 迁移为 AksrtFrontend.register,并加幂等标记
- section-navs 依赖改为 aksrt-runtime,保证注册时序
- 清理死代码:删除孤儿 loader.js/main.js 及 runtime/page-navs 遗留副本
- loader.js 移除已迁移模块的 tbquire 路径映射,main.js 相应瘦身
- build-public-assets.mjs 增加 runtime/main.min.js 自动压缩步骤
- 新增 4 个契约测试(homepage-dom/rehydrate-runtime/global-dom-init/
  public-bundle-parity),3 个既有 harness 适配注册表初始化方式
This commit is contained in:
2026-09-02 21:48:56 +08:00
parent 5984d4e42d
commit 1f4a39d8f8
60 changed files with 1810 additions and 1742 deletions
@@ -72,7 +72,11 @@ function createHarness() {
});
let providerCallback;
let captchaInit = null;
const window = {
AksrtFrontend: {
register(_name, init) { captchaInit = init; },
},
fetch(url) {
if (url === '/challenge') return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { challenge: 'challenge-token' } }) });
if (url === '/exchange') return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { proof: 'proof-token' } }) });
@@ -87,6 +91,7 @@ function createHarness() {
Promise,
};
vm.runInNewContext(runtime, { window, document, Promise, MutationObserver: undefined, setTimeout, clearTimeout, console });
if (captchaInit) captchaInit(document, 'init');
return { api: window.AKSRTCaptcha, container };
}
@@ -0,0 +1,117 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const publicSources = [
'assets/js/core/comment-fixed-input.js',
'assets/js/core/misc.js',
'assets/js/follow.js',
'assets/js/enhancements.js',
'assets/js/enhancements2.js',
'assets/js/color-extract.js',
'assets/js/effects.js',
'assets/js/captcha-front.js',
'assets/widgets/frontend.js',
];
const routedSources = [
'assets/js/auth.js',
'assets/js/author.js',
'assets/js/routes/user-center.js',
'assets/js/order-center.js',
'assets/js/credits.js',
'assets/js/signin.js',
'assets/js/reward.js',
'assets/js/report.js',
'assets/js/page-navs.js',
'assets/js/section-navs.js',
'assets/js/posts-navs-page.js',
'assets/js/front-submit.js',
'assets/js/mini-upload.js',
'assets/js/page-template.js',
'assets/js/page-edit.js',
'assets/js/precode.js',
'assets/js/post-share.js',
'assets/js/poster-share.js',
'assets/js/pay-vip.js',
'assets/js/video-player.js',
'assets/js/download-status.js',
'assets/js/ban-appeal-page.js',
'assets/js/docs-navigation.js',
'assets/js/input-expand.js',
'assets/js/waterfall.js',
'assets/js/yiyan.js',
'assets/js/messages.js',
'assets/js/site-notice.js',
'assets/js/user-badge-actions.js',
];
const readyPattern = /(?:document\.addEventListener\(\s*['"]DOMContentLoaded['"]|document\.readyState\s*===|jQuery\(document\)\.ready\s*\()/;
test('global DOM boot is the only initial page lifecycle owner', () => {
const runtime = read('assets/js/aksrt-runtime.js');
const registry = read('assets/js/rehydrate.js');
assert.match(registry, /boot:\s*function/);
assert.match(registry, /register:[\s\S]*?_booted/);
assert.match(runtime, /AksrtFrontend\.boot\(root\)/);
assert.match(runtime, /aksrt\.autoFun\(document, 'init'\)/);
assert.match(runtime, /reason === 'init'[\s\S]*?AksrtFrontend\.boot\(root\)[\s\S]*?else[\s\S]*?AksrtFrontend\.rehydrate\(root, reason\)/);
});
test('public bundle components register instead of owning DOM ready', () => {
for (const file of publicSources) {
const source = read(file);
assert.doesNotMatch(source, readyPattern, `${file} still owns a DOM-ready boot`);
assert.match(source, /AksrtFrontend\.register\(/, `${file} must register with the global lifecycle`);
}
});
test('active routed UI scripts register with the global lifecycle', () => {
for (const file of routedSources) {
const source = read(file);
if (/API|coordinator|runtime/i.test(source.slice(0, 500)) && !/querySelector|addEventListener/.test(source)) continue;
assert.doesNotMatch(source, readyPattern, `${file} still owns a DOM-ready boot`);
assert.match(source, /AksrtFrontend\.register\(/, `${file} must register with the global lifecycle`);
}
});
test('high-risk routed components remain idempotent under global rehydrate', () => {
const pageNavs = read('assets/js/page-navs.js');
const sectionNavs = read('assets/js/section-navs.js');
const poster = read('assets/js/poster-share.js');
assert.match(pageNavs, /__aksrtPageNavInitialized/);
assert.match(pageNavs, /var grid = self\.root\.querySelector\('\.aksrt-doc-grid'\)/);
assert.match(sectionNavs, /if \(scrollspyBound\) return/);
assert.match(sectionNavs, /if \(autoHashHandled \|\| !window\.location\.hash\) return/);
assert.match(poster, /triggers\.filter\(function \(el\) \{ return el\.dataset\.posterInit !== '1'; \}\)/);
assert.doesNotMatch(poster, /triggers\.some\(function \(el\) \{ return el\.dataset\.posterInit === '1'; \}\)/);
});
test('resource and SDK load listeners are not mistaken for page boot', () => {
const captcha = read('assets/js/captcha-front.js');
const color = read('assets/js/color-extract.js');
assert.match(captcha, /\.onload\s*=|addEventListener\(['"]load['"]/);
assert.match(color, /addEventListener\(['"]load['"]/);
assert.doesNotMatch(captcha, /DOMContentLoaded/);
assert.doesNotMatch(color, /DOMContentLoaded/);
});
test('route scripts that use the registry load after the public runtime', () => {
const scripts = read('src/Core/ScriptsPart1b.php') + read('src/Core/ScriptsPart1c.php');
const files = ['section-navs.js', 'page-navs.js', 'posts-navs-page.js', 'author.js', 'auth.js'];
for (const file of files) {
const uri = scripts.indexOf("/assets/js/" + file);
assert.notEqual(uri, -1, `${file} must be enqueued`);
const blockStart = scripts.lastIndexOf('wp_enqueue_script(', uri);
const blockEnd = scripts.indexOf(');', uri);
const block = scripts.slice(blockStart, blockEnd + 2);
assert.match(block, /aksrt-runtime/, `${file} must depend on aksrt-runtime`);
}
});
@@ -0,0 +1,85 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('homepage uses the global DOM lifecycle without a second ready path', () => {
const runtime = read('assets/js/aksrt-runtime.js');
const homepage = read('assets/js/homepage-fixed.js');
assert.match(runtime, /aksrt\.autoFun\s*=\s*function\s*\(root, reason\)/);
assert.match(runtime, /function initRuntime\(\)\s*\{\s*aksrt\.autoFun\(document, 'init'\);/s);
assert.doesNotMatch(runtime, /function initRuntime\(\)\s*\{\s*aksrt\.autoFun\(document\);/s);
assert.match(homepage, /window\.AksrtFrontend\.register\('aksrt-homepage', init\)/);
assert.doesNotMatch(homepage, /DOMContentLoaded/);
assert.doesNotMatch(homepage, /document\.querySelectorAll\('\[data-sort-dropdown-trigger\]'\)/);
assert.doesNotMatch(homepage, /document\.querySelectorAll\('\.aksrt-homepage__stream'\)/);
assert.doesNotMatch(homepage, /document\.querySelectorAll\('\.aksrt-homepage__tabs\.home-tab-nav-box'\)/);
assert.match(homepage, /dataset\.aksrtHomeTabsBound === '1'/);
assert.match(homepage, /dataset\.aksrtSortDropdownBound === '1'/);
assert.match(homepage, /dataset\.aksrtSortbarBound === '1'/);
assert.match(homepage, /dataset\.aksrtStickyTabsBound === '1'/);
});
test('homepage initializer is registered once and remains idempotent across rehydrate calls', () => {
const source = read('assets/js/homepage-fixed.js');
const registrations = [];
let sortbarClickBindings = 0;
const sortbar = {
dataset: {},
getAttribute() {
return null;
},
addEventListener(type) {
if (type === 'click') sortbarClickBindings++;
},
};
const stream = {
querySelector(selector) {
return selector === '.aksrt-homepage__sortbar' ? sortbar : null;
},
};
const scope = {
querySelectorAll(selector) {
return selector === '.aksrt-homepage__stream' ? [stream] : [];
},
};
const context = {
window: {
AksrtFrontend: {
register(name, init) {
registrations.push({ name, init });
},
},
},
document: scope,
console,
URLSearchParams,
URL,
setTimeout,
clearTimeout,
};
context.window.window = context.window;
context.window.document = scope;
vm.runInNewContext(source, context, { filename: 'homepage-fixed.js' });
assert.equal(registrations.length, 1);
assert.equal(registrations[0].name, 'aksrt-homepage');
assert.equal(typeof registrations[0].init, 'function');
registrations[0].init(scope, 'init');
registrations[0].init(scope, 'ajax');
assert.equal(sortbarClickBindings, 1);
assert.equal(sortbar.dataset.aksrtSortbarBound, '1');
});
test('homepage AJAX content continues to use the shared ajax rehydrate contract', () => {
const homepage = read('assets/js/homepage-fixed.js');
assert.match(homepage, /window\.AksrtFrontend\.rehydrate\(panel, 'ajax'\)/);
});
@@ -149,7 +149,9 @@ test('OAuth result handler maps safe errors and preserves unrelated URL state',
activeElement: null,
body: { classList: { add() {}, remove() {} } },
}
let authInit = null
const window = {
AksrtFrontend: { register: (_name, init) => { authInit = init } },
aksrtData: {},
URL,
location,
@@ -175,6 +177,7 @@ test('OAuth result handler maps safe errors and preserves unrelated URL state',
Math,
Promise,
})
authInit(document, 'init')
assert.deepEqual(notices, [{ message: '第三方授权失败,请重试', type: 'danger' }])
assert.equal(replaced, '/sign/?keep=1#section')
assert.equal(replaced.includes('script'), false)
@@ -0,0 +1,44 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('public bundle has one source list and one generated legacy main artifact', () => {
const build = read('tools/build-public-assets.mjs');
assert.match(build, /'assets\/js\/rehydrate\.js'[\s\S]*?'assets\/js\/aksrt-runtime\.js'/);
assert.match(build, /'assets\/js\/runtime\/main\.min\.js'/);
assert.match(build, /async function buildRuntimeMainMin\(\)/);
assert.match(build, /assets\/js\/runtime\/main\.js/);
assert.match(build, /await buildRuntimeMainMin\(\)/);
});
test('global lifecycle source precedes every registered public component', () => {
const build = read('tools/build-public-assets.mjs');
const registry = build.indexOf("'assets/js/rehydrate.js'");
const runtime = build.indexOf("'assets/js/aksrt-runtime.js'");
const legacy = build.indexOf("'assets/js/runtime/main.min.js'");
assert.ok(registry >= 0 && registry < runtime && runtime < legacy);
});
test('deleted orphan entry files do not return to enqueue or build graphs', () => {
const graph = [
read('tools/build-public-assets.mjs'),
read('src/Core/ScriptsPart1b.php'),
read('src/Core/ScriptsPart1c.php'),
].join('\n');
assert.equal(fs.existsSync(path.join(root, 'assets/js/main.js')), false);
assert.equal(fs.existsSync(path.join(root, 'assets/js/loader.js')), false);
assert.doesNotMatch(graph, /assets\/js\/(?:main|loader)\.js/);
});
test('built public JavaScript contains the canonical global boot', () => {
const bundle = read('assets/js/build/public.js');
assert.match(bundle, /AksrtFrontend\.boot\(root\)/);
assert.match(bundle, /aksrt\.autoFun\(document, 'init'\)/);
assert.match(bundle, /aksrt:ready/);
});
@@ -0,0 +1,97 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const source = fs.readFileSync(path.join(root, 'assets/js/rehydrate.js'), 'utf8');
function createRuntime() {
const events = [];
const document = {
dispatchEvent(event) {
events.push(event);
return true;
},
};
function CustomEvent(type, options) {
this.type = type;
this.bubbles = Boolean(options && options.bubbles);
this.detail = options && options.detail;
}
const errors = [];
const context = {
window: {},
document,
CustomEvent,
console: {
error(...args) {
errors.push(args);
},
},
};
context.window.window = context.window;
context.window.document = document;
vm.runInNewContext(source, context, { filename: 'rehydrate.js' });
return { api: context.window.AksrtFrontend, document, events, errors };
}
test('global DOM boot runs registered components once with init reason', () => {
const runtime = createRuntime();
const calls = [];
runtime.api.register('first', (rootNode, reason) => calls.push([rootNode, reason]));
runtime.api.boot(runtime.document);
runtime.api.boot(runtime.document);
assert.equal(runtime.api.isBooted(), true);
assert.deepEqual(calls, [[runtime.document, 'init']]);
assert.deepEqual(runtime.events.map((event) => event.type), ['aksrt:rehydrated', 'aksrt:ready']);
assert.equal(runtime.events[0].detail.reason, 'init');
assert.equal(runtime.events[1].detail.reason, 'init');
});
test('late registrations initialize immediately after global boot', () => {
const runtime = createRuntime();
const calls = [];
runtime.api.boot(runtime.document);
runtime.api.register('late', (rootNode, reason) => calls.push([rootNode, reason]));
assert.deepEqual(calls, [[runtime.document, 'init']]);
assert.deepEqual(Array.from(runtime.api.getRegistered()), ['late']);
});
test('same-name registration replaces the owner without duplicating it', () => {
const runtime = createRuntime();
const calls = [];
runtime.api.register('owner', () => calls.push('old'));
runtime.api.register('owner', () => calls.push('new'));
runtime.api.boot(runtime.document);
assert.deepEqual(Array.from(runtime.api.getRegistered()), ['owner']);
assert.deepEqual(calls, ['new']);
});
test('rehydrate preserves root and reason while isolating component failures', () => {
const runtime = createRuntime();
const fragment = {
dispatchEvent(event) {
runtime.events.push(event);
return true;
},
};
const calls = [];
runtime.api.register('broken', () => { throw new Error('broken'); });
runtime.api.register('healthy', (rootNode, reason) => calls.push([rootNode, reason]));
runtime.api.rehydrate(fragment, 'modal');
assert.deepEqual(calls, [[fragment, 'modal']]);
assert.equal(runtime.errors.length, 1);
assert.equal(runtime.events.at(-1).type, 'aksrt:rehydrated');
assert.equal(runtime.events.at(-1).detail.reason, 'modal');
assert.equal(runtime.events.at(-1).bubbles, true);
});
+4 -3
View File
@@ -240,7 +240,8 @@ test('balance center actions use direct fixed-id notyf flows', () => {
assert.match(signinController, /querySelectorAll\('\[data-signin-btn\]'\)/);
assert.match(signinController, /freshButtons\.forEach[\s\S]*?addEventListener\('click'/);
assert.match(credits, /onTimeout:[\s\S]*?notyf\([^;]+?'danger'[^;]+?'aksrt-balance-charge-payment-toast'\)/);
assert.match(credits, /register\('credits',[\s\S]*?initSigninGoto\(\);[\s\S]*?initBalanceCharge\(\);[\s\S]*?initTransfer\(\);[\s\S]*?initPointsExchange\(\);[\s\S]*?initWithdraw\(\);/);
assert.match(credits, /function bootAssets\(root, reason\)[\s\S]*?initSigninGoto\(root\);[\s\S]*?initBalanceCharge\(root\);[\s\S]*?initTransfer\(root\);[\s\S]*?initPointsExchange\(root\);[\s\S]*?initWithdraw\(root\);/);
assert.match(credits, /register\('credits', bootAssets\)/);
assert.doesNotMatch(credits, /function initSignin\(|\/signin\/today|POST \/signin/);
assert.match(signinController, /aksrtSigninInitialized/);
assert.match(credits, /aksrtPointsExchangeInitialized[\s\S]*?aksrtWithdrawInitialized/);
@@ -321,7 +322,7 @@ test('profile save uses AJAX rehydrate and a fixed-id notyf flow without query f
const scripts = read('src/Core/ScriptsPart1b.php');
assert.match(profile, /function initProfileForm\(root\)[\s\S]*?aksrtProfileInitialized[\s\S]*?addEventListener\('submit'/);
assert.match(profile, /register\('aksrt-profile-form',[\s\S]*?initProfileForm\(root\)/);
assert.match(profile, /register\('aksrt-user-center',[\s\S]*?initProfileForm\(root\)/);
assert.match(profile, /notyf\([^;]+?'load'[^;]+?'aksrt-profile-save-toast'\);/);
assert.match(profile, /notyf\([^;]+?'success'[^;]+?'aksrt-profile-save-toast'\);/);
assert.match(profile, /notyf\([^;]+?'danger'[^;]+?'aksrt-profile-save-toast'\);/);
@@ -350,7 +351,7 @@ test('settings actions use rehydrated fixed-id notyf flows', () => {
assert.match(userCenter, /notyf\([^;]+?'load'[^;]+?toastId\);/);
assert.match(userCenter, /notyf\([^;]+?'success'[^;]+?toastId\);/);
assert.match(userCenter, /notyf\([^;]+?'danger'[^;]+?toastId\);/);
assert.match(userCenter, /register\('aksrt-settings',[\s\S]*?initSettings\(root\);[\s\S]*?initBindPhone\(root\);/);
assert.match(userCenter, /register\('aksrt-user-center',[\s\S]*?initSettings\(root\);[\s\S]*?initBindPhone\(root\);/);
assert.match(userCenter, /aksrtSettingsInitialized/);
assert.match(userCenter, /oauth_action[\s\S]*?notyf\(message, type\)[\s\S]*?history\.replaceState/);
assert.doesNotMatch(userCenter, /oauth_unbound/);