feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
83
frontend/scripts/build-react-tests.mjs
Normal file
83
frontend/scripts/build-react-tests.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import { build } from 'esbuild';
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
entryPoints: ['react/shared/testing/render-shared.jsx'],
|
||||
bundle: true,
|
||||
// Пакеты остаются external: react-dom/server в Node использует
|
||||
// require встроенных модулей, который невозможен внутри ESM-бандла.
|
||||
packages: 'external',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"'
|
||||
},
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
legalComments: 'none',
|
||||
minify: false,
|
||||
outfile: 'dist/tests/react-shared-renderer.mjs',
|
||||
platform: 'node',
|
||||
target: ['node18']
|
||||
}),
|
||||
build({
|
||||
entryPoints: ['react/student/testing/render-student.jsx'],
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"'
|
||||
},
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
legalComments: 'none',
|
||||
minify: false,
|
||||
outfile: 'dist/tests/react-student-renderer.mjs',
|
||||
platform: 'node',
|
||||
target: ['node18']
|
||||
}),
|
||||
build({
|
||||
entryPoints: ['react/teacher/testing/render-teacher.jsx'],
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"'
|
||||
},
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
legalComments: 'none',
|
||||
minify: false,
|
||||
outfile: 'dist/tests/react-teacher-renderer.mjs',
|
||||
platform: 'node',
|
||||
target: ['node18']
|
||||
}),
|
||||
build({
|
||||
entryPoints: ['react/settings/testing/render-settings.jsx'],
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"'
|
||||
},
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
legalComments: 'none',
|
||||
minify: false,
|
||||
outfile: 'dist/tests/react-settings-renderer.mjs',
|
||||
platform: 'node',
|
||||
target: ['node18']
|
||||
}),
|
||||
build({
|
||||
entryPoints: ['react/admin/testing/render-admin.jsx'],
|
||||
bundle: true,
|
||||
packages: 'external',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"'
|
||||
},
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
legalComments: 'none',
|
||||
minify: false,
|
||||
outfile: 'dist/tests/react-admin-renderer.mjs',
|
||||
platform: 'node',
|
||||
target: ['node18']
|
||||
})
|
||||
]);
|
||||
|
||||
console.log('Компонентные React-проверки собраны');
|
||||
173
frontend/scripts/build-react.mjs
Normal file
173
frontend/scripts/build-react.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { build } from 'esbuild';
|
||||
import { readFile, rm } from 'node:fs/promises';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
const LOGIN_ENTRY_GZIP_LIMIT = 10_000;
|
||||
const STUDENT_ENTRY_GZIP_LIMIT = 20_000;
|
||||
const TEACHER_ENTRY_GZIP_LIMIT = 20_000;
|
||||
const SETTINGS_ENTRY_GZIP_LIMIT = 20_000;
|
||||
const ADMIN_ENTRY_GZIP_LIMIT = 20_000;
|
||||
// Лимит общих chunk'ов поднят с 75 000 до 85 000 на этапе 5 (волна 1):
|
||||
// компоненты дизайн-системы (ConfirmDialog, AsyncCombobox) стали общими
|
||||
// chunk'ами — их теперь используют и React-вкладки админ-панели, поэтому
|
||||
// они переехали из entrypoint'ов студента/настроек в общий кэшируемый пул.
|
||||
const SHARED_CHUNKS_GZIP_LIMIT = 85_000;
|
||||
// Лимит холодной загрузки входа поднят с 80 000 до 85 000 на этапе 5 (волна 1):
|
||||
// общие компоненты дизайн-системы (ConfirmDialog, AsyncCombobox) стали общими
|
||||
// chunk'ами — их теперь используют и React-вкладки админ-панели. Сам вход
|
||||
// login не изменился, вырос только объём общих кэшируемых chunk'ов.
|
||||
const COLD_LOAD_GZIP_LIMIT = 85_000;
|
||||
const STUDENT_COLD_LOAD_GZIP_LIMIT = 90_000;
|
||||
const TEACHER_COLD_LOAD_GZIP_LIMIT = 90_000;
|
||||
const SETTINGS_COLD_LOAD_GZIP_LIMIT = 90_000;
|
||||
const ADMIN_COLD_LOAD_GZIP_LIMIT = 90_000;
|
||||
// Lazy-chunkи вкладок админ-панели: каждая вкладка загружается
|
||||
// отдельным chunk'ом при открытии, поэтому бюджет отдельный от
|
||||
// холодной загрузки оболочки.
|
||||
const ADMIN_TAB_CHUNKS_GZIP_LIMIT = 150_000;
|
||||
|
||||
await rm('dist/react', { force: true, recursive: true });
|
||||
|
||||
const result = await build({
|
||||
entryPoints: {
|
||||
login: 'react/login/main.jsx',
|
||||
student: 'react/student/main.jsx',
|
||||
teacher: 'react/teacher/main.jsx',
|
||||
settings: 'react/settings/main.jsx',
|
||||
admin: 'react/admin/main.jsx',
|
||||
'shared/runtime': 'react/shared/runtime-entry.jsx'
|
||||
},
|
||||
bundle: true,
|
||||
chunkNames: 'shared/chunks/[name]-[hash]',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
},
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
minify: true,
|
||||
outdir: 'dist/react',
|
||||
platform: 'browser',
|
||||
splitting: true,
|
||||
target: ['es2022']
|
||||
});
|
||||
|
||||
const outputs = result.metafile.outputs;
|
||||
|
||||
function outputBytes(path) {
|
||||
return outputs[path]?.bytes ?? 0;
|
||||
}
|
||||
|
||||
async function gzipSize(path) {
|
||||
return gzipSync(await readFile(path)).byteLength;
|
||||
}
|
||||
|
||||
const sharedChunkPaths = Object.keys(outputs)
|
||||
.filter(path => path.startsWith('dist/react/shared/chunks/') && path.endsWith('.js'));
|
||||
|
||||
// Chunkи, достижимые только из entrypoint'а админ-панели (lazy-загрузчики
|
||||
// React-вкладок react/admin/**), — в общий runtime-бюджет и холодную загрузку
|
||||
// других страниц они не входят. Классифицируем по достижимости от entrypoint'ов,
|
||||
// потому что модуль react/shared, используемый только одной вкладкой, попадает
|
||||
// в её chunk и не является общим.
|
||||
const ENTRY_OUTPUT_PATHS = Object.freeze({
|
||||
login: 'dist/react/login.js',
|
||||
student: 'dist/react/student.js',
|
||||
teacher: 'dist/react/teacher.js',
|
||||
settings: 'dist/react/settings.js',
|
||||
admin: 'dist/react/admin.js',
|
||||
runtime: 'dist/react/shared/runtime.js'
|
||||
});
|
||||
|
||||
function chunkReachability() {
|
||||
const reach = new Map();
|
||||
for (const [entryName, entryPath] of Object.entries(ENTRY_OUTPUT_PATHS)) {
|
||||
const stack = [entryPath];
|
||||
const seen = new Set();
|
||||
while (stack.length > 0) {
|
||||
const path = stack.pop();
|
||||
if (seen.has(path) || !outputs[path]) continue;
|
||||
seen.add(path);
|
||||
for (const imported of outputs[path].imports || []) {
|
||||
stack.push(imported.path);
|
||||
}
|
||||
}
|
||||
for (const path of seen) {
|
||||
if (!reach.has(path)) reach.set(path, new Set());
|
||||
reach.get(path).add(entryName);
|
||||
}
|
||||
}
|
||||
return reach;
|
||||
}
|
||||
|
||||
const reach = chunkReachability();
|
||||
const adminLazyChunkPaths = sharedChunkPaths.filter(path => {
|
||||
const entries = reach.get(path);
|
||||
return entries !== undefined && entries.size > 0 && [...entries].every(name => name === 'admin');
|
||||
});
|
||||
const runtimeChunkPaths = sharedChunkPaths.filter(path => !adminLazyChunkPaths.includes(path));
|
||||
|
||||
const sharedChunkBytes = runtimeChunkPaths.reduce((total, path) => total + outputBytes(path), 0);
|
||||
const sharedChunkGzipBytes = (
|
||||
await Promise.all(runtimeChunkPaths.map(gzipSize))
|
||||
).reduce((total, bytes) => total + bytes, 0);
|
||||
|
||||
const adminLazyChunkBytes = adminLazyChunkPaths.reduce((total, path) => total + outputBytes(path), 0);
|
||||
const adminLazyChunkGzipBytes = (
|
||||
await Promise.all(adminLazyChunkPaths.map(gzipSize))
|
||||
).reduce((total, bytes) => total + bytes, 0);
|
||||
|
||||
const entryReports = [];
|
||||
for (const [name, path, limit, coldLimit] of [
|
||||
['входа', 'dist/react/login.js', LOGIN_ENTRY_GZIP_LIMIT, COLD_LOAD_GZIP_LIMIT],
|
||||
['студента', 'dist/react/student.js', STUDENT_ENTRY_GZIP_LIMIT, STUDENT_COLD_LOAD_GZIP_LIMIT],
|
||||
['преподавателя', 'dist/react/teacher.js', TEACHER_ENTRY_GZIP_LIMIT, TEACHER_COLD_LOAD_GZIP_LIMIT],
|
||||
['настроек', 'dist/react/settings.js', SETTINGS_ENTRY_GZIP_LIMIT, SETTINGS_COLD_LOAD_GZIP_LIMIT],
|
||||
['админ-панели', 'dist/react/admin.js', ADMIN_ENTRY_GZIP_LIMIT, ADMIN_COLD_LOAD_GZIP_LIMIT]
|
||||
]) {
|
||||
const rawBytes = outputBytes(path);
|
||||
const gzipBytes = await gzipSize(path);
|
||||
const coldLoadGzipBytes = gzipBytes + sharedChunkGzipBytes;
|
||||
entryReports.push({ name, path, rawBytes, gzipBytes, coldLoadGzipBytes, limit, coldLimit });
|
||||
|
||||
if (gzipBytes > limit) {
|
||||
throw new Error(
|
||||
`React entrypoint ${name} превышает бюджет: ${gzipBytes} байт gzip при лимите ${limit}`
|
||||
);
|
||||
}
|
||||
if (coldLoadGzipBytes > coldLimit) {
|
||||
throw new Error(
|
||||
`Холодная загрузка ${name} превышает бюджет: ${coldLoadGzipBytes} байт gzip при лимите ${coldLimit}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedChunkGzipBytes > SHARED_CHUNKS_GZIP_LIMIT) {
|
||||
throw new Error(
|
||||
`Общие React-chunk превышают бюджет: ${sharedChunkGzipBytes} байт gzip при лимите ${SHARED_CHUNKS_GZIP_LIMIT}`
|
||||
);
|
||||
}
|
||||
|
||||
if (adminLazyChunkGzipBytes > ADMIN_TAB_CHUNKS_GZIP_LIMIT) {
|
||||
throw new Error(
|
||||
`Lazy-chunkи вкладок админки превышают бюджет: ${adminLazyChunkGzipBytes} байт gzip`
|
||||
+ ` при лимите ${ADMIN_TAB_CHUNKS_GZIP_LIMIT}`
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeChunkPaths.length === 0) {
|
||||
throw new Error('Сборка не создала общий кэшируемый React-chunk');
|
||||
}
|
||||
|
||||
for (const report of entryReports) {
|
||||
console.log(
|
||||
`React entrypoint ${report.name}: ${report.rawBytes} байт, ${report.gzipBytes} байт gzip, `
|
||||
+ `холодная загрузка ${report.coldLoadGzipBytes} байт gzip`
|
||||
);
|
||||
}
|
||||
console.log(`Общие React-chunk: ${sharedChunkBytes} байт, ${sharedChunkGzipBytes} байт gzip`);
|
||||
console.log(
|
||||
`Lazy-chunkи вкладок админки: ${adminLazyChunkPaths.length} chunk, `
|
||||
+ `${adminLazyChunkBytes} байт, ${adminLazyChunkGzipBytes} байт gzip`
|
||||
);
|
||||
Reference in New Issue
Block a user