Галоўная / Артыкулы / Узнікненне адмінскіх багоў у JavaScript за счытком мутацыі кансалтацый

Узнікненне адмінскіх багоў у JavaScript за счытком мутацыі кансалтацый

Дазвольце дазнацься, чаму змены об’ектаў і масэв праз кансэкт нарабляюць проблемы з перзапісваў у React, чаму функцыя spread копіюе толькі паверхневыя элементы, і як безпечна ствараць глыбокую копію стану.

1565 слоў

Пользоватар ачывае вікно налаштоўкаў абліку ў вашым продакце SaaS, зменшае назву рабочай прасторы з "Acme Marketing" на "Acme Global", а потым пераканаецца і натыкаецца на кнопку «Анулюваць».

Вікно зникае. Але назва рабочай прасторы, якая паказваецца ў верхній панелі навігацыі, тады таксама стае "Acme Global".

Пользоватар перазавантажвае сторунку, збялкаваны. Назва знову стае "Acme Marketing".

Калі вы розбіраецеся, вы адкрываеце, што стан формы зберагаліся ў локальным стане компонента, і натыканне на «Анулюваць» ніколі не выклікала жадных сетавых запитоў. Так як жа змяны, якія ніколі не былі запісаны, паўтарыліся ў глобальным заголовку?

Прычына — два на першы погляд простыя рядкі коду:

// In the modal component
const formState = currentUser.workspace;
formState.name = newName; // Direct object reference mutation!

Это класычны і лёгкі да працяжэння спосаб збою ў додатках на JavaScript: выпадковая мутацыя через спакушаныя рэферэнсы.

Пакалькі, што об’екты і масівы ў JavaScript адмаўляюцца па ссылцы, а не па значэнні, змена об’екта ў адным компаненте можа тыхамо зменіць тэты ж базовы дадзеныя ў всіх іншых месцах, дзе яны выкорыстоўваюцца ў праекте — адхіляючы вашу систему калекцыявання статаусу, паспрычыніваючы проблемы з рашэнням React, калі трэба перераскладзіць элементы, і заставляючы вас стаяць пры багу без явнай прычыны.

1. Равнасць па ссылцы: як JavaScript бачыць вашы дадзеныя

Ёсць сэнс разумець, як дварчык JavaScript фактычна зберагае вашы значэнні ў памяці, каб зрозумець, чаму мутацыя вызываюць столькі проблем.

Прімітывныя значэнні — цыфры, строкі, логічныя значэнні, null, undefined — копіююцца па значэнні:

let a = 10;
let b = a;
b = 20;console.log(a); // 10 (unchanged)

Об’екты, масівы і функцыі працуюць не аднакова: яны зберагаюцца па ссылцы. Зменная, якая храніць об’ект, не мае ў сабе дакладна данных гэтага об’екта — яна храніць паказвач да месца ў памяці, дзе насправды знаходзяцься гэтыя даны:

const userA = {
  name: "Sarah",
  role: "Admin"
};
const userB = userA; // userB points to the EXACT same memory address!userB.name = "Alex";console.log(userA.name); // "Alex" — userA was mutated!

Сучасныя фреймворкі UI, такія як React, активна выкарыстоўваюць паверхневыя пераказанні пра роўнасць (за дапамогою Object.is або ===) каб выявіць, чы рэальна трэба перераскладзіць компонент.

Таму, якщо вы зменяеце існуючы об’ект безпасэродна, а пасля перадаеце яго назад у setState:

// BAD: Mutating existing state directly
const [user, setUser] = useState({
  name: "Sarah",
  age: 30
});
function updateAge() {
  user.age = 31; // Direct mutation
  setUser(user); // Passes the SAME memory reference!
}

React парабягае паказвач на поперадні стан з паказвачам на новы. Паколькі ў памяці яны фактычна є тым самым об’ектам, React вырашае, што нічога не змянілася, і абоўсумова прыпінае перераскладзенне.

Асалевыя даныя былі апдэйтаваны, ал экран застаецца заморожаным у своем старым стане.

2. Ілюзія аператара распрасцеўвання

Ёжы бачыць працэз безпаснай мутацыі, багато разработчыкаў выкорыстоўваюць аператар распрасцеўвання (...) для об’ектаў. Це корыстны інструмент, але распалоўчываючая памылка — гэта дае цэлкавую глęбокую копію.

Цього не выходзіць.

Аператар распрасцеўвання копіюе толькі верхні ўрадзень об’екта. Будзь-якія внутранія об’екты чы рамкі застаюцца спакушаныя з адной лінкай з первасным об’ектам.

Возьмім типовы об’ект налашоўкаў, які можна знайсці ў SaaS-прыёмніку:

const defaultSettings = {
  theme: "dark",
  notifications: {
    email: true,
    sms: false
  }
};
// Shallow copy using spread
const userSettings = { ...defaultSettings };// Changing a nested property
userSettings.notifications.email = false;// Disaster: defaultSettings was also mutated!
console.log(defaultSettings.notifications.email); // false!

Паколькі notifications сама ёсць об’ектам, і userSettings.notifications, і defaultSettings.notifications яшчэ паказваюць на той самы блок памяці.

Якщо defaultSettings ўзгадваецца як стацыярная вялічына на рэвэле модуля, практыкаванне настаўкаў аднаго пользователя можа таямніча зіпсаваць стандартную настаўку, якая викорыстоўваецца ў всіх іншых частках прыемлівання.

3. Апасцярожнасці, якія створаюць методы масэў

У JavaScript є калькі вбудованых методаў масэў, якія мەнююць тую масę, на якой ўжоўваюцца, замест таго, каб вернуць новую масę.

Як толькі вы передасте масę, якая прадаўаецца з props або спільнага стану, ў одны з гэтых методаў, вы пачнёте мەткаваць на паслядковы эфекты, якіх не прасілі:

// METHODS THAT MUTATE IN PLACE (Dangerous with state)
array.sort();     // Mutates original array!
array.reverse();  // Mutates original array!
array.splice();   // Mutates original array!
array.push();     // Mutates original array!
array.pop();      // Mutates original array!

Уявіце сабе компонент-тэблу, які адрасавае спіс транзакцый:

// BAD: Direct prop mutation during render
function TransactionTable({
  transactions
}: {
  transactions: Transaction[];
}) {
  // transactions.sort() permanently reorders the array in parent state!
  const sorted = transactions.sort(
    (a, b) => b.amount - a.amount
  );
  return (
    <table>
      {sorted.map((tx) => (
        <tr key={tx.id}>
          <td>{tx.amount}</td>
        </tr>
      ))}
    </table>
  );
}

Кожны раз, калі адрасаваецца TransactionTable, таямніча пераранжуецца маса транзакцый, якая насправды належыць родныму компоненту.

Сучасны спосаб рашэння: Методы масэў, якія не змінююць стан

Няўзначныя версіі ECMAScript адкрылі немутуючыя аналагіі гэтых методаў, кожны з яых вяртае новы масэвак уместо таго, каб змяніць выначальны:

Мутуючы метод (варта ухіліцца) проты яго немутуючага адпраўнікава (варта выбіраць): arr.sort(fn) стае arr.toSorted(fn), arr.reverse() стае arr.toReversed(), arr.splice(start, count) стае arr.toSpliced(start, count), а arr[index] = val стае arr.with(index, val).

Уместа таго, каб мутаваць масэвак transactions на месцы:

// GOOD: Leaves the original transactions array pristine
const sorted = transactions.toSorted(
  (a, b) => b.amount - a.amount
);

4. Савэцкая копіюванне: structuredClone проты трюкаў з JSON

Калі ваша аплікацыя практычна выклеквае савэцкую, абсалютна незалежную копію вялізнага стану, настала час адмовіцца ад старога спосабу JSON.parse(JSON.stringify(obj)).

Этот трюк, базавыся на JSON, мае калькольныя слабасці:

  • Функцыі і значэння undefined тыхаюцца без жадных паведамленняў.
  • Об’екты дат прыводзяцца да звычных стрэнгаў у формате ISO, а не застаюцца экземплярамі класу Date.
  • Об’екты Map, Set, RegExp і ArrayBuffer знішчаюцца пад час гэтага процесу.
  • Цыркулярныя звязкі выклікаюць немедыятывае падбіўанне памяці.

Стандарт: structuredClone()

Усе сучасныя прыгледчыкі і сервісы Node.js маюць вучоную падтрымку для structuredClone():

const originalProject = {
  id: "proj_123",
  metadata: {
    createdAt: new Date(),
    tags: new Set(["frontend", "ui"])
  },
  collaborators: [
    { name: "Sarah" }
  ]
};
// Creates a complete, true deep copy
const clonedProject = structuredClone(originalProject);clonedProject.metadata.tags.add("react");
clonedProject.collaborators[0].name = "Alex";// Original remains completely untouched
console.log(
  originalProject.metadata.tags.has("react")
); // falseconsole.log(
  originalProject.collaborators[0].name
); // "Sarah"console.log(
  originalProject.metadata.createdAt instanceof Date
); // true

Выклік structuredClone на originalProject стварае абсалютна самастоятельную копію: змены ў наборе тэгаў копіі чы адкоректаванне імені внутранняга саўершальніка не маюць жадного адзычлівага эфекту на выхідны об’ект, адтолькі ўсе внутранні структуры былі скопіяваны, а не працавалі як звязкі.

5. Калі незменнае становіцца проблемай для выконвальных спэкаў

Незменнае станавіцца прычыной таго, што логіка інтэрфейса застаецца прыемлемай для працы, але якщо з кожным апдэйтом стварацца глыбока копія, это можа паградзіць выконвальныя спэкі, якщо не будзецца аберагаць.

Падумайце пра сетку даных, якая мае 50 000 роўнаў, або пра графік на основе canvas, який выкаанавляе расчыткі 60 разоў за секунду. Глыбока копіяўцае цэлай структуры — чы то за дапамогою structuredClone, чы іншым спосабам — пасля кожной дзеяння стварае великую колькасць непатрэбных дадзеных, якія трэба адчысціваць, і браузер пачынае працаваць з перашкодамі, так как ягоўныя ресурсы постаюць у вядлівым выкарыстанні і звяртанні вялікіх блакоў памяці.

Збалансаванный падход

  1. Копіюйце толькі верхні ўзлок, які вы меняеце: якщо апдэйт стосуецца толькі user.name, дапаможае толькі паверхневая копія верхньага ўзлока:
{ ...user, name: "New Name" }
  1. Каліцеюйтесь на бібліятэкі для структурнага адналежання дадзеных, калі іерархія глыбока: для дрэва стану з многама слоямі інструменты на кшталт Immer дапамагаюць ухіліцца ад павольных глыбокых копіяў. Immer выкарыстоўвае об’екты JavaScript Proxy, каб кланаваць толькі тыя галузі, якія фактычна былі зменены, таму кожная незмененая галузь продовжае адналежаць да свайго первіснага рэферэнсу.
import { produce } from "immer";
// Clean, intuitive mutation syntax with zero reference pollution
const nextState = produce(currentState, (draft) => {
  draft.users[0].preferences.theme = "dark";
});

Это дае вам синтаксіс у стылі мутацый, які чытаецца легка, тады как Immer у тле забезпечвае стварэнне новага, правільна апдэйтаванага об’екта стану, без випадковага адналежання рэферэнсаў.

Падсумак і правілы незменнасці

Ёжы не дапускайце фантомных багоў і таямнай парадкі стану ў вашай базе коду на JavaScript:

  1. Не мутуйце прынтапы чы стан безпасэчна: спрыяйце да таго, каб всі даны, якія прыходзяць званаўні за межы вашага локальнага дыапазона, былі прыйманы як толькі для чытання.
  • Памятаце, што распространэнне ўзора є поверхневым: { ...obj } і [ ...arr ] копіююць толькі верхній рэвэль; вярстаныя об’екты і масі застаюцца спакульнанымі рэферэнсамі.
  • Вядомае прываленне методам масы to...: вжывайце toSorted(), toReversed() і toSpliced() замест ўсіх іх мутуючых аналогаў, такіх як sort().
  • Для справжніх глыбокіх копій вжывайце structuredClone: перастаць павяржацца на JSON.parse(JSON.stringify()), калі працуеце з сложнымі вярстанымі дадзеннямі.
  • Корыстаецеся структурным спакульнаннем: для глыбока вярстанага стану Immer дазволяе чыста апдэйтаваць дадзеныя без неабходнасці копіювання всього.
  • Адказанне за раўнасцю паказаных значэнняў і сяберлівасць у паводзе з незменнасцю адмахвае цэлую категорыю багоў у працоўным сераве — тых, якія інакш выклікалі гадзіны заплутанага адлучэння багоў.

    Спаднія матэрыялы

  • Як браузер адрабатоўвае змест і як у ём размяшваецца React — Дазвольце вам дазнацца, як Critical Rendering Path, процес супрацоўкі дадзеных, Fiber і Scheduler разам працуюць, каб ператварыць апдэйты React у пікселі на экране.
  • Прыдатнасц фронтэнда: ад недастаткаў перагляду коду да паметрак тавару — Дазвольце вам дазнацца, чаму сам перагляд коду недастатні, якія з Core Web Vitals насправды маюць значэнне, і як вимерваць та вылечыць проблемы з прыдатнасцю React у рэальных умовах.