Очень краткий рассказ про семь проектов, построенных одним инженером на унаследованной платформе
OpenCart 3. Много дополнительных модулей. Очень медленная загрузка страниц. Визуально сайт выглядит морально устаревшим. Мобильного приложения нет. Пользователи есть, пользуются, но страдают (с большими экранами очень страдают).
Хотелось не просто ускорить метрики, а выстроить полноценную систему, которая бы работала для пользователя как обычное SPA-приложение: быстро, плавно, визуально современно, а главное с уважением к пользователям.
Сначала занялся серверной частью. Выстроил четырёхуровневую систему кэширования из:
OPcache с huge_code_pages=1 и validate_timestamps=0
In-memory cache на базе Opencart Lightning (сторонний модуль)
Brotli + fallback GZIP
Service Worker с DYNAMIC_PAGE_CACHE (реализован на этапе PWA-Layer, но планировал изначально)
Где продолжительное время заняла работа с OPcache:
; /home/site/etc/php71w/php.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=2048
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=100000
opcache.revalidate_freq=0
opcache.validate_timestamps=0
opcache.fast_shutdown=1
opcache.huge_code_pages=1
// спойлер opcache_info.php
Array
(
[opcache_enabled] => 1
[cache_full] =>
[restart_pending] =>
[restart_in_progress] =>
[memory_usage] => Array
(
[used_memory] => 96440856
[free_memory] => 2051042792
[wasted_memory] => 0
[current_wasted_percentage] => 0
)
[interned_strings_usage] => Array
(
[buffer_size] => 33554432
[used_memory] => 3409360
[free_memory] => 30145072
[number_of_strings] => 51830
)
[opcache_statistics] => Array
(
[num_cached_scripts] => 941
[num_cached_keys] => 1069
[max_cached_keys] => 130987
[hits] => 8442152
[start_time] => 1757949600
[last_restart_time] => 0
[oom_restarts] => 0
[hash_restarts] => 0
[manual_restarts] => 0
[misses] => 978
[blacklist_misses] => 0
[blacklist_miss_ratio] => 0
[opcache_hit_rate] => 99.988416618008
)Сюда же и развёртывание Redis в Docker. Чуть после, принятие того что Redis при небольшом количестве пользователей сайта избыточен - кэш "холодный" даже если мы его жарим кроном warm-cache.php и preload_pages.sh. И противостояние GZIP vs Brotli, когда выигрыш от Brotli по объёму не перекроет проигрыш во времени на сжатие, а главное - скорость отдачи кэшированного контента, а не степень сжатия. Таблицы на MyISAM, а нам нужен хотя бы InnoDB c buffer pool, да и желательно все оставлять в RAM, и с настройками .htaccess что-то не то, с SSL какая-то беда. Под раздачу попало и железо VPS. А впереди ещё LT, PT, тут же где-то промелькнул proxy nginx... В общем работаем дальше.

В процессе настройки серверной части стало ясно, что одного TTFB недостаточно. Даже с быстрым ответом сервера каждый клик ведёт к полной перезагрузке страницы (хоть уже и быстрой).
Скорость мы получили, так почему бы нам её не положить в кэш пользователей, да и вообще не управлять этим кэшем - точно пригодится.
Приступил к внедрению навигации, основанной на поведении реальных пользователей. Реализовал сбор цепочек переходов: tracker.js, user-paths.log и анализ логов:
// analyze-paths.php — анализ последних 500 цепочек
$lines = array_slice(file('/storage/logs/user-paths.log'), -500);
$paths = [];
foreach ($lines as $line) {
if (preg_match('/\| ([^|]+) \|/', $line, $matches)) {
$chain = explode(' → ', $matches[1]);
$next = $chain[1] ?? null;
if ($next && !in_array($next, ['/cart', '/checkout'])) {
$paths[$next] = ($paths[$next] ?? 0) + 1;
}
}
}
arsort($paths);
$topUrls = array_keys(array_slice($paths, 0, 20));
file_put_contents('/cron/top-paths.json', json_encode(['urls' => $topUrls]));
Навигация стала учиться и перезагружать страницы на основе реального поведения пользователей, есть приоритет А, приоритет Б... - но это не так интересно.
На клиенте - Service Worker подхватывает top-paths.json через postMessage и предзагружает HTML в кэш - а вот это уже интересней:
async function precacheDynamicPage(url) {
if (isExcluded(url)) return;
try {
const response = await fetch(url);
if (!response.ok) return;
const html = await response.text();
const cache = await caches.open(DYNAMIC_PAGE_CACHE);
await cache.put(url, new Response(html, { headers: response.headers }));
} catch (e) {
if (e.name !== 'AbortError') {
console.warn('Ошибка при предзагрузке:', url, e);
}
}
}Ускорили, положили в кэш, много раз положили. Но можно, нужно б��стрее.
К этому моменту уже понял, что настало время мобильного приложения. Не просто быстро грузиться, а устанавливаться и открываться прямо с экрана.
Тем более чаще витали вопросы из разряда: "А у вас есть мобильное приложение?". Пришло время добавить солидности и ещё чуть-чуть скорости.
Реализовал PWA-слой:
manifest.json + splash-экраны (про IOS лучше помолчим)
Два Service Worker: sw.js (PWA), sw-browser.js (браузер мобильные и ПК) - реально надо
DYNAMIC_PAGE_CACHE — предзагрузка по top-paths.json (управляем уже?)
Был ещё DYNAMIC_IMAGE_CACHE, но есть LazyLoad, а может только первые 5 в DOM, параллельно рефакторим контроллеры, твиги (везде хотим Lazy), не зацепить бы data-src... Ладно, идем дальше.

Потом взялся за поддержку. Операторы тратили очень, очень много времени на шаблонные ответы, допускали ошибки, а процесс обучения должен занимать значительно меньше времени, ни разу не SLA. Собрал расширение для браузера с семантической вставкой шаблонов, адаптивным форматированием и сохранением состояния (MutationObserver наше все).
Очень хотелось чтобы в итоге операторы из "печатальшиков" стали управляющими диалога. На мой взгляд это элементарное уважение к человеку, который работает с однотипными данными большое количество часов ежедневно.
Да и пользователям долго ждать ответ на типовой вопрос такое себе.
// formatPhone — нормализация ввода
const formatPhone = (rawPhone) => {
let cleaned = rawPhone.replace(/\D/g, '');
if (cleaned.startsWith('8')) cleaned = '7' + cleaned.slice(1);
if (cleaned.startsWith('7')) cleaned = cleaned.slice(1);
if (cleaned.length === 10) {
return cleaned.replace(/(\d{3})(\d{3})(\d{2})(\d{2})/, '$1 $2 $3 $4');
}
return rawPhone;
};
Уже быстро, очень быстро. Солидно. И уважили наших операторов.
Нужно больше уважения. Перешёл к памяти. Наконец снимем шаблоны с операторов. Построил диалоговое ядро с 500+ узлами, семантической памятью, через визуальный редактор (в основном Graph, но иногда и Code). Интегрировал с SaluteBot, Jivo, Telegram, ВКонтакте.
И получил на выходе:
Скорость загрузки страниц до 26 мс (среднее TTFB для "горячих" страниц после прогрева кэша с предзагрузкой)
~ 96% запросов пользователей ушло на бота (показала статистика спустя пол года работы)

После продолжительных калибровок результат стал удовлетворять, и я поставил цель полностью переписать визуальную основу интерфейса. Хотелось, чтобы сайт "говорил" с пользователем. Реализовал:
Пульсирующую сферу как присутствие оператора
Уведомления и подсказки от лица оператора
Автослайдеры, скан-анимации, игровые механики

Получилось. Но вот основной вид: невзрачные шаблонные цвета, прямоугольники-кнопки, мобильное меню сверху экрана (большие экраны почувствовали боль), статика помноженная на статику, хочешь поменять количество - вводи циферки, хочешь посмотреть больше товаров - иди на другую страницу, а на чекауте мы будем вставлять палки в колеса пока ты не закроешь сайт... и этот список можно продолжать до бесконечности. И не забыть про интерфейс операторов, у них уже быстро, осталось красиво.
Полностью переписал весь CSS...

добавил чуть-чуть canvas JS...
function drawSpark(x, y, length, angle, depth) {
if (depth > 2) return;
const steps = 2 + Math.floor(Math.random() * 3);
ctx.beginPath();
let cx = x; let cy = y;
ctx.moveTo(cx, cy);
for (let i = 0; i < steps; i++) {
const dx = Math.cos(angle + (Math.random() - 0.5) * 0.6) * (length / steps);
const dy = Math.sin(angle + (Math.random() - 0.5) * 0.6) * (length / steps);
cx += dx; cy += dy;
cx = Math.max(2, Math.min(canvas.width - 2, cx));
cy = Math.max(2, Math.min(canvas.height - 2, cy));
ctx.lineTo(cx, cy);
}
ctx.lineWidth = 0.5 + Math.random() * 0.4;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = 'hsla(270,100%,95%,0.8)';
ctx.shadowColor = 'white'; ctx.shadowBlur = 3;
ctx.stroke();
ctx.lineWidth = 0.8 + Math.random() * 0.6;
ctx.strokeStyle = 'hsla(275,85%,80%,0.6)';
ctx.shadowColor = `hsl(${270 + Math.random() * 20},100%,70%)`;
ctx.shadowBlur = 10 + Math.random() * 8;
ctx.stroke();
ctx.lineWidth = 0.5 + Math.random() * 0.3;
ctx.shadowColor = 'hsl(280,100%,60%)';
ctx.shadowBlur = 14 + Math.random() * 6;
ctx.strokeStyle = 'hsla(280,100%,65%,0.3)';
ctx.stroke();
if (depth < 2 && Math.random() < 0.4) {
const branchAngle1 = angle + 0.8 + Math.random() * 0.5;
const branchAngle2 = angle - 0.8 - Math.random() * 0.5;
const branchLength = length * (0.5 + Math.random() * 0.3);
setTimeout(() => drawSpark(cx, cy, branchLength, branchAngle1, depth + 1), 10 + Math.random() * 20);
setTimeout(() => drawSpark(cx, cy, branchLength, branchAngle2, depth + 1), 15 + Math.random() * 25);
}
} // потом минифицируем...и немного (ага) поработал с twig, php.
twig
<!-- Где-то выбираем циферки -->
<select name="quantity">
{% for i in 1..10 %}<option value="{{ i }}">{{ i }}</option>{% endfor %}
</select>
php
// А где-то нормализуем отправку в MySQL - 8911... или 911... в +7911...
private function formatPhoneNumber($number) {
$clean = preg_replace('/[^0-9]/', '', $number);
if (strlen($clean) === 11 && $clean[0] === '8') {
$clean = '7' . substr($clean, 1);
}
return strlen($clean) === 11 ? '+' . $clean : $number;
}Что на выходе:


Страницы грузятся за ~25 мс (среднее, в идеальной среде)
96% запросов на боте
Операторы отвечают в 5 раз быстрее, а процесс обучения сократился до 1 дня
Современный сайт и
Скоростное мобильное приложение для пользователей
