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:
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user