software-development
January 26

React и жизнь после сборки бандла

Бывает что на руках есть лишь «бинарная» сборка сайта на модном фреймворке вроде React, в которой «срочно надо что-то поправить». А исходного кода нет. Есть лишь вы, «бандл» с обфрусцированным JavaScript-кодом внутри и горящие сроки. Рассказываю что с этим делать.

Помимо Путина, кормящего ручного медведя тут еще показан процесс восстановления исходников из файлов "source map".

Проблема

Как-то так получилось, что связка из Typescript и упаковщиков вроде Webpack захватила современную веб-разработку практически целиком, а модель построения веб-приложений «Single Page Application» (SPA) стала применяться для всего вообще — от простейших лендингов и сайтов-визиток до сложных CRM-систем с динамической подгрузкой данных.

Из-за того что Typescript является компилируемым языком, в котором существует разделение на исходный код и конечный код, обфрусцированный и упакованный в специальные «бандлы» — произошло некое смешение смыслов:

Многие заказчики понятия не имеют и в душе не #бут что ныне даже у статичных лендингов и сайтов-визиток есть исходники

Особенно если пришли из разработки начала 2000х, когда был кругом статичный HTML, а весь JavaScript-код был простым, клиентским и вставлялся прямо на страницу.

Более того, поскольку результат сборки с помощью Webpack это внезапно тоже код, некоторые особо коварные п#дорасы хитрые джентельмены умудрялись сдавать проекты в виде конечной сборки и набора бандлов, без продоставления реальных исходников, мотивируя тем что «это и есть рабочий исходный код».

И с точки зрения пунктов договора на разработку они вообщем-то были правы.

Вот вам небольшой пример такой сборки, взятый с сайта JHipster, чтобы было понятно о чем речь:

(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e](t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,f,o)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,f,o]=e[n],c=!0,u=0;u<t.length;u++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[u]))?t.splice(u--,1):(c=!1,o<a&&(a=o));if(c){e.splice(n--,1);var l=f();void 0!==l&&(i=l)}}return i}o=o||0;for(var n=e.length;n>0&&e[n-1][2]>o;n--)e[n]=e[n-1];e[n]=[t,f,o]},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{127:"3939584411b29233",146:"9e6e63e24ba057f5",462:"3c011262c4aafd67",592:"1a0b39952c0d48d5",679:"dc91bdcb440d5d58",792:"f4d5b583a515fb1b",848:"b617a814d1d8d8d1",905:"e873b5c1adf6b3c3",920:"a0a741fb2015a1e4",972:"bd8f95f56699519f",994:"4c7d5415c98549f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="jhonline:";r.l=(t,f,o,n)=>{if(e[t])e[t].push(f);else{var a,c;if(void 0!==o)for(var u=document.getElementsByTagName("script"),l=0;l<u.length;l++){var d=u[l];if(d.getAttribute("src")==t||d.getAttribute("data-webpack")==i+o){a=d;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+o),a.src=r.tu(t)),e[t]=[f];var b=(g,p)=>{a.onerror=a.onload=null,clearTimeout(s);var h=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),h&&h.forEach(y=>y(p)),g)return g(p)},s=setTimeout(b.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=b.bind(null,a.onerror),a.onload=b.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:i=>i},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(f,o)=>{var n=r.o(e,f)?e[f]:void 0;if(0!==n)if(n)o.push(n[2]);else if(666!=f){var a=new Promise((d,b)=>n=e[f]=[d,b]);o.push(n[2]=a);var c=r.p+r.u(f),u=new Error;r.l(c,d=>{if(r.o(e,f)&&(0!==(n=e[f])&&(e[f]=void 0),n)){var b=d&&("load"===d.type?"missing":d.type),s=d&&d.target&&d.target.src;u.message="Loading chunk "+f+" failed.\n("+b+": "+s+")",u.name="ChunkLoadError",u.type=b,u.request=s,n[1](u)}},"chunk-"+f,f)}else e[f]=0},r.O.j=f=>0===e[f];var i=(f,o)=>{var u,l,[n,a,c]=o,d=0;if(n.some(s=>0!==e[s])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(c)var b=c(r)}for(f&&f(o);d<n.length;d++)r.o(e,l=n[d])&&e[l]&&e[l][0](),e[l]=0;return r.O(b)},t=self.webpackChunkjhonline=self.webpackChunkjhonline||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();

Это «стандартный» загрузчик модулей после работы Webpack.

А вот так выглядит этот же код после частичного восстановления декомпилятором:

(() => {
    "use strict";
    var e, v = {},
        m = {};
    function r(e) {
        var i = m[e];
        if (void 0 !== i) return i.exports;
        var t = m[e] = {
            exports: {}
        };
        return v[e](t, t.exports, r), t.exports
    }
    r.m = v, e = [], r.O = (i, t, f, o) => {
        if (!t) {
            var a = 1 / 0;
            for (n = 0; n < e.length; n++) {
                for (var [t, f, o] = e[n], c = !0, u = 0; u < t.length; u++)(!1 & o || a >= o) && Object.keys(r.O).every(p => r.O[p](t[u])) ? t.splice(u--, 1) : (c = !1, o < a && (a = o));
                if (c) {
                    e.splice(n--, 1);
                    var l = f();
                    void 0 !== l && (i = l)
                }
            }
            return i
        }
        o = o || 0;
        for (var n = e.length; n > 0 && e[n - 1][2] > o; n--) e[n] = e[n - 1];
        e[n] = [t, f, o]
    }, r.d = (e, i) => {
        for (var t in i) r.o(i, t) && !r.o(e, t) && Object.defineProperty(e, t, {
            enumerable: !0,
            get: i[t]
        })
    }, r.f = {}, r.e = e => Promise.all(Object.keys(r.f).reduce((i, t) => (r.f[t](e, i), i), [])), r.u = e => (592 === e ? "common" : e) + "." + {
        127: "3939584411b29233",
        146: "9e6e63e24ba057f5",
        462: "3c011262c4aafd67",
        592: "1a0b39952c0d48d5",
        679: "dc91bdcb440d5d58",
        792: "f4d5b583a515fb1b",
        848: "b617a814d1d8d8d1",
        905: "e873b5c1adf6b3c3",
        920: "a0a741fb2015a1e4",
        972: "bd8f95f56699519f",
        994: "4c7d5415c98549f2"
    } [e] + ".js", r.miniCssF = e => {}, r.o = (e, i) => Object.prototype.hasOwnProperty.call(e, i), (() => {
        var e = {},
            i = "jhonline:";
        r.l = (t, f, o, n) => {
            if (e[t]) e[t].push(f);
            else {
                var a, c;
                if (void 0 !== o)
                    for (var u = document.getElementsByTagName("script"), l = 0; l < u.length; l++) {
                        var d = u[l];
                        if (d.getAttribute("src") == t || d.getAttribute("data-webpack") == i + o) {
                            a = d;
                            break
                        }
                    }
                a || (c = !0, (a = document.createElement("script")).type = "module", a.charset = "utf-8", a.timeout = 120, r.nc && a.setAttribute("nonce", r.nc), a.setAttribute("data-webpack", i + o), a.src = r.tu(t)), e[t] = [f];
                var b = (g, p) => {
                        a.onerror = a.onload = null, clearTimeout(s);
                        var h = e[t];
                        if (delete e[t], a.parentNode && a.parentNode.removeChild(a), h && h.forEach(y => y(p)), g) return g(p)
                    },
                    s = setTimeout(b.bind(null, void 0, {
                        type: "timeout",
                        target: a
                    }), 12e4);
                a.onerror = b.bind(null, a.onerror), a.onload = b.bind(null, a.onload), c && document.head.appendChild(a)
            }
        }
    })(), r.r = e => {
        typeof Symbol < "u" && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, {
            value: "Module"
        }), Object.defineProperty(e, "__esModule", {
            value: !0
        })
    }, (() => {
        var e;
        r.tt = () => (void 0 === e && (e = {
            createScriptURL: i => i
        }, typeof trustedTypes < "u" && trustedTypes.createPolicy && (e = trustedTypes.createPolicy("angular#bundler", e))), e)
    })(), r.tu = e => r.tt().createScriptURL(e), r.p = "", (() => {
        var e = {
            666: 0
        };
        r.f.j = (f, o) => {
            var n = r.o(e, f) ? e[f] : void 0;
            if (0 !== n)
                if (n) o.push(n[2]);
                else if (666 != f) {
                var a = new Promise((d, b) => n = e[f] = [d, b]);
                o.push(n[2] = a);
                var c = r.p + r.u(f),
                    u = new Error;
                r.l(c, d => {
                    if (r.o(e, f) && (0 !== (n = e[f]) && (e[f] = void 0), n)) {
                        var b = d && ("load" === d.type ? "missing" : d.type),
                            s = d && d.target && d.target.src;
                        u.message = "Loading chunk " + f + " failed.\n(" + b + ": " + s + ")", u.name = "ChunkLoadError", u.type = b, u.request = s, n[1](u)
                    }
                }, "chunk-" + f, f)
            } else e[f] = 0
        }, r.O.j = f => 0 === e[f];
        var i = (f, o) => {
                var u, l, [n, a, c] = o,
                    d = 0;
                if (n.some(s => 0 !== e[s])) {
                    for (u in a) r.o(a, u) && (r.m[u] = a[u]);
                    if (c) var b = c(r)
                }
                for (f && f(o); d < n.length; d++) r.o(e, l = n[d]) && e[l] && e[l][0](), e[l] = 0;
                return r.O(b)
            },
            t = self.webpackChunkjhonline = self.webpackChunkjhonline || [];
        t.forEach(i.bind(null, 0)), t.push = i.bind(null, t.push.bind(t))
    })()
})();

Думаю очевидно что попытка сопровождать такой код вашими хилыми силами это примерно как попытка участия моей 100кг-туши в балете — и то и другое хотя и теоретически возможно, но врядли продлится долго.

Другой пример:

вы сделали сайт-визитку для стартапа, через полгода стартап внезапно выстреливает и идет волна заказов.

Теперь сайт по-хорошему надо делать заново, но времени нет, а старый (он же текущий) при этом отключать нельзя — надо туда постоянно вносить мелкие правки текста: новые контакты, правила, адреса, ссылки и т.д и тп.

И правок этих миллион.

А исходников нет. Забыли передать, про#бали потеряли в хаосе — кто работал в стартапах тот поймет о чем я и как такое происходит.

Проблема на самом деле существует, коль уж даже мне столько лет приходится иметь дело с этими самыми «бинарными бандлами» и задачами про «надо немного поправить», со сроком выполнения «вчера».

Тестовый пример

К сожалению не получится использовать какой-то реальный проект в качестве примера для статьи — Webpack генерирует чудовищного размера сборки для более-менее объемного проекта, разбираться в которых будет слишком уж долго в масштабах статьи.

Так что я взял первый попавшийся шаблон лендинга на React, посвежее и более менее похожий на то что бывает в реальности. И сейчас покажу на нем что и как можно сделать в столь печальной ситуации.

Выглядит не без изысков вот так:

Цвет фона медленно меняется а звезды двигаются - автор хотел показать свое мастерство.

Сам проект технически вообщем-то тривиален,его cборка максимально упрощена:

git clone https://github.com/chenkoufan/HashirPortfolio.git
npm install
npm run build

И собственно.. все, в каталоге build будет готовая сборка сайта.

Единственное что я добавил в проект — указание на корневой путь «/», поскольку по-умолчанию в шаблоне используется «/home».

Для этого создаете файл .env в корне проекта и вставляете:

PUBLIC_URL=/

Больше про эту настройку тут.

В папке build будет релизная сборка, а в build/js — те самые бандлы.

Каталог build со  всем содержимым и будет выступать нашим тестовым стендом, на котором я буду показывать все чудеса софистики эквилибристики с декомпиляторами и деобфрускаторами.

Но начнем мы все же с немного другого, поскольку самый короткий путь — часто самый лучший.

Восстановление исходников из .git

Да, на самом деле очень и очень многие современные разработчики — малограмотные долбо#бы в третьем поколении, именно по этой банальной причине они не могут как следует поднасрать даже если очень хочется.

Поэтому действительно на практике случаются ситуации экстремального дебилизма, хорошо показанные в фильмах Гая Ричи.

Например история вот этих парней:

Нет, они даже умнее некоторых моих коллег по отрасли, честно.

Да, я про то самое — про сохранение каталога .git одновременно с попыткой удаления файлов исходников. Такое тоже бывает в жизни, причем чаще чем вы думаете.

Если кто вдруг еще не знает — сообщаю:

в каталоге .git хранится полная копия всего вашего исходного кода, еще и с историей всех изменений

Потому что это часть системы контроля версий, так она работает.

Было:

Я взял и удалил все папки с исходниками, оставив лишь финальную сборку и каталог .git

Теперь запускаем восстановление:

git reset --hard

Стало:

Вуаля! Все вернулось из небытия.

Так что если видите папку .git на сервере или в архиве с вашим сайтом — скорее всего жизнь не так плоха и печальна.

«Скорее всего» тут не просто так, к сожалению могут быть варианты когда git используется не по прямому назначению, а например для передачи готовых сборок на сервер через сервис CI.

В этом случае фиксироваться будут только изменения в бандлах, а исходники останутся на уровне CI. Но это уже история не про лендинги и сайты-визитки, а про что-то большое, где полная утеря исходников маловероятна.

Восстановление из файлов «source maps»

Следующий рабочий вариант как вытащить исходники React-приложения из небытия — попытаться восстановить их из «source maps» файлов.

Подробно про технологию «source map» можно почитать вот тут в оригинале или тут на русском в переводе Гоблина.

Если кратко, то это такие специальные файлы, генерирующиеся при сборке проекта и содержащие метаданные по исходному коду, которые при выполнении позволяют формировать корректную трассировку исключений: с номерами строк и читаемыми названиями методов и классов.

Вот так выглядит небольшая часть «source map» файла:

{
"version":3,
"file":
"static/js/main.2911d091.js",
"mappings":";wCAAAA,EAAOC,QAAU,EAAjBD,yCCEA,EAAOmB,KAAKE,SA.."
}

Технически это просто большой JSON, с кучей вложенных объектов и закодированных частей.

Оказалось что метаданных из файлов «source maps» достаточно для восстановления оригинального исходного кода

Сейчас покажу как это работает.

Инструментов для восстановления исходников из «source map» файлов много разных, я использовал вот такой.

В первую очередь из-за того что он сохраняет сразу на диск все найденное.

Еще по этой ссылке находится статья от автора, с описанием работы.

Забираем:

git clone https://github.com/rarecoil/unwebpack-sourcemap.git

запускаем:

python unwebpack_sourcemap.py -d --make-directory https://hashirshoaeb.com/home/ out

Вариант запуска выше восстановит исходник непостредственно с внешнего сайта автора, а вот так — с локально запущенной копии:

python unwebpack_sourcemap.py -d --make-directory http://localhost:8081/ ou2

Я использовал NPM-пакет http-server для эмуляции отдачи статики, поскольку он не связан с отладочным режимом Webpack (когда работает перекомпиляция на лету) и может отдавать только статический контент — получается полная симуляция старого доброго HTTP-сервера для отдачи статики, вроде Apache или Nginx.

Только без необходимости их установки и настройки.

Запускается вот так:

http-server ./build

По-умолчанию отдает контент на порту 8081 и использует путь «/», а аргумент «./build» это указание на каталог со статикой, все просто.

Теперь посмотрим что же удалось вытащить из source maps:

Как видите откровенно д#хуя немало, настолько немало, что некоторые после демонстрации такого восстановления хватаются за сердце и срочно убирают генерацию файлов «source maps» из своих продуктовых сборок.

Вот вам для сравнения оригинальный каталог с исходниками:

Нет лишь статики (картинок и стилей оформления), которая просто выносится при сборке в отдельный каталог:

Теперь посмотрим внимательно на востановленный исходный код: что именно и до какой степени в нем восстановилось.

Перед вами восстановленная копия стартового скрипта нашего тестового веб-приложения на React:

А вот так выглядит оригинальный файл:

Что тоже в легком ах#е удивлении?

В заголовке окна показывается полный путь к файлу, если вы вдруг подумали что я ошибся и дваджы открыл один и тот же файл ;)

Но это еще не все, следующий файл будет с JSX-шаблоном компонента — специально прокрутил до места где он начинается.

Восстановленная копия:

А теперь оригинал:

Как видите восстановилось фактически вообще все.

Все исходные файлы, вся структура каталогов, весь исходный код включая комментарии и форматирование — спокойно восстанавливается из файлов source maps

Прекрасный новый мир современных вебтехнологий!

Ложка говна дегтя

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

Тем не менее, такое восстановление исходников из файлов «source maps» это отлично работающий вариант для мелких лендингов и сайтов-визиток, за каким-то хером по недосмотру реализованными на компилируемом языке и столь сложном фреймворке.

Теперь наконец переходим к настоящей жести.

Работа с обфрусцированным бандлом

Да, это именно тот самый вариант «полного пэ», на который меня обычно и зовут. Каталога .git нет, никаких «source maps» тоже нет, есть лишь статика в виде набора файлов, а index.html выглядит как-то так:

<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><link href="https://use.fontawesome.com/releases/v5.4.1/css/all.css" rel="stylesheet"/><meta name="theme-color" content="#000000"/><meta name="description" content="My name is Hashir Shoaib. I’m a graduate of 2020 from National University of Sciences and Technology at Islamabad with a degree in Computer Engineering. I'm most passionate about giving back to the community, and my goal is to pursue this passion within the field of software engineering. In my free time I like working on open source projects."/><link rel="apple-touch-icon" href="logo192.png"/><link rel="manifest" href="/manifest.json"/><meta property="twitter:image" content="/social-image.png"/><meta property="og:image" content="/social-image.png"/><title>Hashir Shoaib</title><script defer="defer" src="/static/js/main.2911d091.js"></script><link href="/static/css/main.fe927caf.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

Ну и сами бандлы, прошедшие через Tree Shaking, минификацию и обфрускацию.

Задачи

Давайте начнем с типичного набора задач, которые ставились лично мне в таких случаях:

  • изменить текст в нужном месте;
  • добавить пункт меню в шапке;
  • добавить кнопку или изменить поведение существующей;
  • скрыть существующий или добавить блок данных.

Все это напоминаю надо проделать в обфрусцированном бандле, сгенерированным с помощью упаковщика Webpack и без доступа к исходникам.

Немного матчасти

Есть ряд вещей, которые необходимо знать о внутреннем устройстве «упакованной» версии веб-приложения на React прежде чем мы продолжим.

Первое и самое главное:

весь контент, все что вы видите на странице это один сплошной Javascript, никакого HTML кроме стартового index.html в таком веб-приложении нет.

Для иллюстрации возьмем вот эту главную кнопку:

Вот так выглядит код прогнанный через несколько разных деобфрускаторов, который ее создает и показывает:

..
(0, Ge.jsx)("div", {
                className: "p-5",
                children: o.map((function (e, n) {
                    return (0, Ge.jsx)("a", {
                        target: "_blank",
                        rel: "noopener noreferrer",
                        href: e.url,
                        "aria-label": "My ".concat(e.image.split("-")[1]),
                        children: (0, Ge.jsx)("i", {
                            className: "fab ".concat(e.image, "  fa-3x socialicons")
                        })
                    }, "social-icon-".concat(n))
                }))
            }), (0, Ge.jsx)("a", {
                className: "btn btn-outline-light btn-lg ",
                href: "#aboutme",
                role: "button",
                "aria-label": "Learn more about me",
                children: "More about me"
            })]
        })]
        ..

Как видите к нормальному HTML это отношения не имеет, а правка такого п#здеца — не имеет ничего общего с обычной версткой.

Второе:

веб-приложение на React максимально изолировано от внешнего окружения

Отправлять и получать события из кода вне React хоть и возможно но очень сложно и требует определенных действий в самом приложении.

Помимо этого, приложение на React поддерживает внутреннее состояние всех компонентов и не реагирует (по-умолчанию) на изменения в DOM-дереве страницы, произведенные снаружи приложения.

Это одновременно и хорошо и плохо.

Хорошо, потому что дает возможность дурить производить манипуляции c DOM не влезая внутрь приложения.

Плохо, потому что ваши изменения могут быть легко затерты приложением, когда оно решит что «время пришло» и надо обновлять компонент. А обновлять его оно будет разумеется из своего внутреннего состояния.

Вообщем лучший подход это минимальное вмешательство во внутренности таких приложений и решение задачи малой кровью — снаружи.

Что я сейчас и покажу.

Начнем с самого простой, но самой частой задачи — с удаления элемента на странице.

Задача 1: Уберите «это говно»

Разумеется физически удалять из DOM-дерева ничего не стоит (помним про внутренее состояние React-приложения), есть вариант куда проще — скрытие ненужного элемента через CSS-стили.

Допустим, нам надо убрать пункт меню «About» из шапки страницы нашего тестового проекта.

Открываем index.html и добавляем новый блок <style></style> внутрь тега <head>, пишем :

#basic-navbar-nav > div.navbar-nav > a:nth-of-type(3)  {
           display: none; 
}

Все вместе должно выглядеть вот так:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="https://use.fontawesome.com/releases/v5.4.1/css/all.css" rel="stylesheet" />
    <meta name="theme-color" content="#000000" />
    <meta name="description"
        content="My name is Hashir Shoaib. I’m a graduate of 2020 from National University of Sciences and Technology at Islamabad with a degree in Computer Engineering. I'm most passionate about giving back to the community, and my goal is to pursue this passion within the field of software engineering. In my free time I like working on open source projects." />
    <link rel="apple-touch-icon" href="logo192.png" />
    <link rel="manifest" href="/manifest.json" />
    <meta property="twitter:image" content="/social-image.png" />
    <meta property="og:image" content="/social-image.png" />
    <title>Hashir Shoaib</title>
    <script defer="defer" src="/static/js/main.2911d091.js"></script>
    <link href="/static/css/main.fe927caf.css" rel="stylesheet">
    <!-- наша коварная вставка -->
    <style>
        #basic-navbar-nav > div.navbar-nav > a:nth-of-type(3)  {
           display: none; 
        }
    </style>
</head>
<body><noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
</body>
</html>

Результат:

Внимание на пункты меню вверху

Теперь рассказываю как и почему это работает.

Если открыть «Developer Tools» в любимом Хроме (клавиша F12) и перейти на вкладку «Elements», то можно увидеть что внутри пустого тега <div id="root"></div> появилась жизнь — все что вы визуально можете наблюдать на странице в браузере находится внутри этого самого тега:

Рабочие будни

То что вы наблюдаете — результат работы рендера React, который «отрисовывает» каждый компонент приложения внутри корневого элемента согласно его текущему состоянию.

И до тех пор пока в каком-то из скрываемых нами компонентов не произойдет изменения свойства «display» — он так и будет невидимым.

Что касается самого CSS, то используется достаточно сложный селектор, где происходит одновременно выборка по id элемента

#basic-navbar-nav

затем обращение по цепочке ко вложенным элементам

> div.navbar-nav > a

а потом еще и обращение по порядковому номеру

a:nth-of-type(3)

Селектор a:nth-of-type(3) означает что запрашивается третий по порядку элемент <a>. Обращение по уникальному id работает поскольку он был указан в исходном компоненте React:

<Navbar.Collapse id="basic-navbar-nav">

который после стадии рендеринга попадает и в конечный DOM-элемент:

<div class="navbar-collapse collapse" 
    id="basic-navbar-nav">
      <div class="navbar-nav mr-auto navbar-nav">
      ..
      </div>
</div>

Еще один пример для закрепления знаний:

#home > div.container > div.text-center > h1.display-1  {
           display: none; 
}

скроет самую большую надпись на странице с именем автора:

Огромной надписи выше строки "Passionate about.." больше нет.

Думаю вы поняли, работает на ура и честно говоря большая часть технической работы с подобными задачами — вот такое банальное скрытие элементов: ссылок, кнопок, пунктов меню или просто разделов с данными.

Да, за это платят деньги, причем от такой работы обычно отказываются «нормальные разработчики», узнав про обфрусцированный код и бандлы.

Но разумеется это лишь начало погружения, поэтому едем дальше в лес.

Задача 2: Изменить текст

Следущая стадия «трэша и угара» это скромная просьба «немного изменить текст на странице» (напоминаю что речь про обфрусцированный и минимизированный бандл, созданный с помощью упаковщика), но и таких мы #бем имея определенный опыт такое тоже можно провернуть.

Тут уже начинается Javascript, поэтому добавляем тег <script></script> и помолясь начинаем ваять:

let intervalID;

function makeWhenReady() {       
   let el3 =  document.querySelector('span.react-loading-skeleton');
   if (!el3) {
      let el = document.querySelector('#home > div.container > div.text-center > h1.display-1');
      el.innerHTML = "Да здраствует хардкор!";
      el.style.display='block';
      clearInterval(intervalID);
   }     
}
window.addEventListener('load', function() {            
    intervalID = setInterval(makeWhenReady, 500);
});

Вот так выглядит результат работы:

Результат правки

Теперь немного расскажу о том как это все работает.

Первое и самое главное:

React-приложение имеет свою собственную логику загрузки, поэтому стандартные обработчики вроде window.addEventListener() или DOMContentLoaded не cработают.

Попытка поработать с DOM-деревом непосредственно из такого обработчика приведет к тому что вместо данных вы увидите фигу шаблон их загрузки — в проекте используется модуль react-loading-skeleton для создания таких шаблонов.

Поэтому мы поступаем хитрее: устанавливаем свой собственный обработчик на событие load у «окна» браузера:

window.addEventListener('load', function() {            
    intervalID = setInterval(makeWhenReady, 500);
});

а внутри обработчика уставливаем еще и фоновую обработку — функцию, которая будет вызываться с определенной переодичностью (в нашем случае раз в 500 миллисекунд):

intervalID = setInterval(makeWhenReady, 500);

intervalID как нетрудно догадаться это ее идентификатор, который будет нужен далее для остановки этой фоновой функции.

Внутри этой фоновой функции мы делаем проверку на наличие в DOM-дереве элементов <span> с классом react-loading-skeleton — сие означает что еще не все компоненты загружены:

function makeWhenReady() {       
   let el3 =  document.querySelector('span.react-loading-skeleton');
   if (!el3) {
      ..
   }     
}

Если таких элементов не было найдено — считаем что все компоненты загрузились, производим наши манипуляции с данными и останавливаем фоновую функцию:

if (!el3) {
      let el = document.querySelector('#home > div.container > div.text-center > h1.display-1');
      el.innerHTML = "Да здравствует хардкор!";
      el.style.display='block';
      clearInterval(intervalID);
}    

Поскольку между рендером оригинала и нашими изменениями есть видимая задержка — изменяемый текст будет «моргать»:

сначала будет отображен оригинал, который через какое-то время изменится на нашу версию.

И пользователи это увидят. Чтобы их не смущать, необходимо спрятать изменяемый блок через CSS:

#home > div.container > div.text-center > h1.display-1  {
           display: none; 
}

а затем (после изменения DOM-дерева) его отобразить:

el.innerHTML = "Да здраствует хардкор!";
el.style.display='block';

В таком же точно стиле работает и добавление новых элементов, даже целых новых блоков:

// выбор элемента по XPath выражению
let el2 = document.querySelector('#projects > div.container > div.container > div.row'); 
// вставляемый шаблон, в виде строки
let tpl = `<div class="col-md-6"> 
        <div class="card shadow-lg p-3 mb-5 bg-white rounded card"> 
            <div class="card-body">
    <h5 class="card-title">кокой-то проект</h5>
    <div class="d-grid gap-2 d-md-block"><a
            href="https://github.com/hashirshoaeb/sometime-next-no-pwa/archive/master.zip"
            class="btn btn-outline-secondary mx-2">
            <i class="fab fa-github"></i>Клонировать</a>
        <a href="https://github.com/hashirshoaeb/sometime-next-no-pwa" target=" _blank"
            class="btn btn-outline-secondary mx-2">
            <i class="fab fa-github"></i> Репозиторий</a>
    </div>
    <hr>
    <div class="pb-3">Языки:
        <a class="card-link" href="https://github.com/hashirshoaeb/sometime-next-no-pwa/search?l=JavaScript"
            target=" _blank" rel="noopener noreferrer">
            <span class="badge bg-light text-dark">JavaScript: 142.8 %</span>
        </a>
        <a class="card-link" href="https://github.com/hashirshoaeb/sometime-next-no-pwa/search?l=CSS" target=" _blank"
            rel="noopener noreferrer">
            <span class="badge bg-light text-dark">CSS: 27.1 %</span>
        </a>
    </div>
    <p class="card-text">
        <a href="https://github.com/hashirshoaeb/sometime-next-no-pwa/stargazers" target=" _blank"
            class="text-dark text-decoration-none">
            <span class="text-dark card-link mr-4">
            <i class="fab fa-github"></i>
                Звезды <span class="badge badge-dark">0</span>
            </span>
        </a>
        <small class="text-muted">Обновлен 22,2024</small>
    </p>
    </div>
   </div>
</div>`;
// сама вставка      
el2.insertAdjacentHTML('beforeend', tpl);

Результат:

Причем на вставленный таким образом блок будут распространяться и все эффекты оригинальных элементов:

тень и «подпрыгивание» при наведении мыши.

С этой задачей разобрались, наконец переходим к самой жести.

Задача 3: Изменить обработку

Разумеется есть и нормальные способы организовать взаимодействие с React-приложением в обе стороны — из стороннего JavaScript-кода вызывать React-компонент и из такого компонента вызывать сторонний JavaScript-код.

Но к сожалению все они требуют внутренних изменений в приложении, вроде регистрации компонента в контексте window, которые очевидно никто для вас вносить не станет.

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

Поэтому будем применять нестандартные, как обычно.

Для лучшей иллюстрации я добавил в проект простую форму с логикой валидации:

Оригинал вот тут, если кому интересно.

Тестовая форма

Визуально это что-то вроде формы регистрации, ключевой компонент React с реализацией формы был немного изменен по сравнению с оригиналом, выглядит так:

import { FC } from 'react';
import { useForm } from '../hooks/useForm';
import './Registration.scss';
import {
  Container,
} from "react-bootstrap";

type Gender = 'МУЖ' | 'ЖЕН' | 'РОБОТ';

interface User {
  name: string;
  age: number;
  email: string;
  gender: Gender;
  password: string;
}

const Registration: FC = () => {   
  const { handleSubmit, handleChange, data: user, errors } = useForm<User>({
    validations: {
      name: {
        pattern: {
          value: '^[A-Za-z]*#x27;,
          message:
            "You're not allowed to use special characters or numbers in your name.",
        },
      },
      age: {
        custom: {
          isValid: (value) => parseInt(value, 10) > 17,
          message: 'You have to be at least 18 years old.',
        },
      },
      password: {
        custom: {
          isValid: (value) => value?.length > 6,
          message: 'The password needs to be at least 6 characters long.',
        },
      },
    },
    onSubmit: () => alert('User submitted!'),
  });

  return (
    <section className="section p-3">
    <Container>
    <form className="registration-wrapper" onSubmit={handleSubmit}>
      <h1>Тестовая форма</h1>
      <input
        placeholder="ФИО*"
        value={user.name || ''}
        onChange={handleChange('name')}
        required
      />
      {errors.name && <p className="error">{errors.name}</p>}
      <input
        placeholder="Возраст"
        type="number"
        value={user.age || ''}
        onChange={handleChange('age', (value) => parseInt(value, 10))}
      />
      {errors.age && <p className="error">{errors.age}</p>}
      <input
        placeholder="Email*"
        type="email"
        value={user.email || ''}
        onChange={handleChange('email')}
      />
      <input
        placeholder="Пароль*"
        type="password"
        value={user.password || ''}
        onChange={handleChange('password')}
      />
      {errors.password && <p className="error">{errors.password}</p>}
      <select onChange={handleChange('gender')} required>
        <option value="" disabled selected>
          Пол*
        </option>
        <option value="male" selected={user.gender === 'МУЖ'}>
          Мальчик
        </option>
        <option value="female" selected={user.gender === 'ЖЕН'}>
          Девочка
        </option>
        <option value="non-binary" selected={user.gender === 'РОБОТ'}>
          Боевая машина уничтожения
        </option>
      </select>
      <button type="submit" className="submit">
        Отправить
      </button>
    </form>
    </Container>
    </section>
  );
};
export default Registration;

Помещен он был рядом с другими комонентами, с сохранением принципов их именования.

Стили не менялись и были взяты из оригинального проекта «как есть»:

.registration-wrapper {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  margin: auto;
  max-width: 600px;
  padding: 30px;
  border-radius: 5px;
  border: 1px solid #16324f69;
  background-color: #fff;

  h1, input, select {
    margin: 15px auto;
    flex: 1 0 100%;
  }

  select {
    opacity: 0.8;
    height: 30px;
    background-color: #16324f;
    color: #fff;
    padding: 5px 15px;
  }

  input, select {
    border: none;
    outline: none;
  }

  input {
    padding-bottom: 5px;
    border-bottom: 1px solid rgba(22, 50, 79, 0.41);
  }

  input:focus {
    border-bottom: 1px solid rgb(22, 50, 79);
  }

  .submit {
    cursor: pointer;
    outline: none;
    flex: 0 0 170px;
    margin: 15px auto;
    background-color: #16324f;
    color: #fff;
    border: none;
    height: 30px;
    border-radius: 5px;
    font-size: 15px;
  }

  .error {
    width: 100%;
    text-align: left;
    font-size: 12px;
    color: #db222a;
  }
}

Подключается компонент вот так в файле App.js (фрагмент):

const Home = React.forwardRef((props, ref) => {
  return (
    <>
      <MainBody
        gradient={mainBody.gradientColors}
        title={`${mainBody.firstName} ${mainBody.middleName} ${mainBody.lastName}`}
        message={mainBody.message}
        icons={mainBody.icons}
        ref={ref}
      />
      {/* наш компонент с формой. */}
      <Registration />     
      {about.show && (
        <AboutMe
          heading={about.heading}
          message={about.message}
          link={about.imageLink}
          imgSize={about.imageSize}
          resume={about.resume}
        />
      )}
      ..

Эта реализация компонента с формой не использует каких-либо сторонних библиотек для валидации формы — только один чистый React.

Поэтому необходимо добавить еще обработчик формы (файл hooks/useForm.ts):

import { ChangeEvent, FormEvent, useState } from 'react';

interface Validation {
  required?: {
    value: boolean;
    message: string;
  };
  pattern?: {
    value: string;
    message: string;
  };
  custom?: {
    isValid: (value: string) => boolean;
    message: string;
  };
}

type ErrorRecord<T> = Partial<Record<keyof T, string>>;
type Validations<T extends {}> = Partial<Record<keyof T, Validation>>;

export const useForm = <T extends Record<keyof T, any> = {}>(options?: {
  validations?: Validations<T>;
  initialValues?: Partial<T>;
  onSubmit?: () => void;
}) => {
  const [data, setData] = useState<T>((options?.initialValues || {}) as T);
  const [errors, setErrors] = useState<ErrorRecord<T>>({});
  // Needs to extend unknown so we can add a generic to an arrow function
  const handleChange = <S extends unknown>(
    key: keyof T,
    sanitizeFn?: (value: string) => S
  ) => (e: ChangeEvent<HTMLInputElement & HTMLSelectElement>) => {
    const value = sanitizeFn ? sanitizeFn(e.target.value) : e.target.value;
    setData({
      ...data,
      [key]: value,
    });
  };
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const validations = options?.validations;
    if (validations) {
      let valid = true;
      const newErrors: ErrorRecord<T> = {};
      for (const key in validations) {
        const value = data[key];
        const validation = validations[key];
        if (validation?.required?.value && !value) {
          valid = false;
          newErrors[key] = validation?.required?.message;
        }
        const pattern = validation?.pattern;
        if (pattern?.value && !RegExp(pattern.value).test(value)) {
          valid = false;
          newErrors[key] = pattern.message;
        }
        const custom = validation?.custom;
        if (custom?.isValid && !custom.isValid(value)) {
          valid = false;
          newErrors[key] = custom.message;
        }
      }
      if (!valid) {
        setErrors(newErrors);
        return;
      }
    }
    setErrors({});
    if (options?.onSubmit) {
      options.onSubmit();
    }
  };

  return {
    data,
    handleChange,
    handleSubmit,
    errors,
  };
};

Реализация обработчика не менялась, оригинал можете посмотреть вот тут.

Обратите внимание на расширение файла — это Typescript, для обработки которого необходимо добавить файл tsconfig.json с вот таким содержимым:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}

В итоге получается такая форма регистрации, с несколькими типами валицидации вводимых данных:

Тут есть как стандартные обязательные поля HTML-формы, с атрибутом «required»:

Так и сложная валидация, реализованная внутри React-компонента:

После правильного заполнения срабатывает заглушка, вместо которой в реальном проекте будет взаимодействие с бекэндом:

Изменяя обработку

Вот теперь имея такую форму, представьте что вам надо с этой формой что-то сделать, из типовых задач вебразработки.

Но.. без исходников.

Не надо сразу бежать писать заявление на «увольнение по собственному желанию» и менять работу — есть менее радикальные варианты.

Начнем с простого:

отключим валидацию обязательности по признаку required.

Добавим в функцию makeWhenReady (), уже описанную выше вот такой простенький код:

function makeWhenReady() {
  let el3 = document.querySelector('span.react-loading-skeleton');
  if (!el3) {
      ...               
                
   let elInput = document.querySelector('form.registration-wrapper > input');
   elInput.required = false;           
   }
}

И.. этого достаточно для пропуска такой валидации.

Все описанное конечно замечательно и скорее всего закроет большую часть ваших проблем с бинарными бандлами.

Но что же делать с самим React, еще и обфрусцированным?

Не поверите, но и тут есть варианты.

Допустим, надо перехватить обработчик формы и вставить свой код в место отправки формы, отключив обработчики из React.

К сожалению в браузерах нет стандартного API для получения всех обработчиков у DOM-элемента, поэтому такое API придется реализовать.

Подробнее про эту проблему можно прочитать например вот тут.

Нам нужно переопределить метод addEventListener своей реализацией, причем сделать это до того как начнет работать React.

Чтобы этого добиться, вставляем свой блок <script></script> до места подключения бандла с React:

<!-- наш код вставки -->
<script>
 ...
</script>
<script defer="defer" src="/static/js/main.9fb48d54.js"></script>

Код переопределения функции addEventListener выглядит вот так:

EventTarget.prototype._addEventListener = EventTarget.prototype.addEventListener;

EventTarget.prototype.addEventListener = function (a, b, c) {
     if (c == undefined) c = false;
     if (a == 'submit') {
           console.log('hijack listener ', a);
           if (!this.eventListenerList) this.eventListenerList = {};
           if (!this.eventListenerList[a]) this.eventListenerList[a] = [];
           this.eventListenerList[a].push({ listener: b, options: c });
     }
     this._addEventListener(a, b, c);
};

EventTarget.prototype._getEventListeners = function (a) {
     if (!this.eventListenerList) this.eventListenerList = {};
     if (a == undefined) { return this.eventListenerList; }
     return this.eventListenerList[a];
};

В результате его работы, вы увидите в консоли браузера все регистрации обработчиков на отправку формы:

Но главное что у всех DOM-элементов, в которые происходили добавления обработчиков появится новая функция _getEventListeners(), которая будет содержать ссылки на все обработчики.

Зачем это надо?

Например для того чтобы их удалить:

let r = document.querySelector('#root');
console.log('root element:', r);

let rEvents = r._getEventListeners();
console.log('events:', rEvents);

for (let evt of Object.keys(dvevents)) {
    console.log('evt:',evt);
    for (let i = 0; i < dvevents[evt].length; i++) {                    
        dv.removeEventListener(evt,dvevents[evt][i].listener);
    }
}

Код выше необходимо вставить все в ту же функцию makeWhenReady(), которая как вы помните вызывается после полной инициализации React-приложения.

Пару слов про обработчики в React.

Оказалось что все обработчики React регистрируются в родительском DOM-элементе, внутри которого происходит отрисовка всех компонентов React:

<body><noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
</body>

Очевидно что при таком подходе обработчиков там будет очень и очень много — имейте это ввиду.

Наконец для того чтобы добавить наш собственный обработчик для отправки формы, вставляем вот такой код (все также в функцию makeWhenReady()):

let elForm = document.querySelector('form.registration-wrapper');
elForm.addEventListener("submit", function (e) {
       e.preventDefault();
       alert('Hi there!');
});

Результат:

Такие дела.

Эпилог

В общем случае вам действительно стоит отказаться от попыток доработки обфрусцированных бандлов — это «путь в никуда», решение которое невозможно поддерживать долго и рано или поздно оно сломается, похоронив под собой и все ваши доработки.

Но в исключительных случаях, когда действительно горит и надо «поправить вчера» на такое приходится идти.

Под давлением обстоятельств, так сказать.

Еще эта статья тут в качестве иллюстрации, в какой же п#здец временами превращается моя работа и с чем приходится иметь дело на практике.