Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản12 phút đọc

Sao chép mảng không sao chép các đối tượng của nó

Cập nhật checkbox và metadata mà vẫn giữ trạng thái gốc; kiểm tra từng tham chiếu, thao tác không đổi giá trị, sao chép sâu và giới hạn của freeze.

Mô hình giấy màu ô liu và be về trang trình duyệt máy tính cùng bố cục di động hẹp hơn

Sao chép mảng tạo một vùng chứa khác. Nó không nhất thiết tạo đối tượng mới cho từng phần tử. Nếu hai mảng giữ tham chiếu đến cùng một việc cần làm, gán thuộc tính done sẽ đổi đối tượng mà cả hai mảng cùng trỏ tới. Gán một đối tượng khác vào ô của mảng là thao tác khác.

Nếu bạn đã đọc được mảng, object literal và event handler của DOM, ví dụ này sẽ giúp phân biệt giá trị với tham chiếu. Ta giữ một danh sách gốc rồi cập nhật danh sách hiện tại, sau đó kiểm tra cả hai. Dữ liệu là mảng thường không có ô trống, ID chuỗi duy nhất và các bản ghi đơn giản được tạo cục bộ. Đây không phải bộ kiểm tra dữ liệu máy chủ tùy ý, mảng thưa, proxy hay instance của lớp.

Theo phép gán, đừng dựa vào tên biến

Chạy đoạn độc lập sau trong console trình duyệt:

(() => {
  const before = [{ id: 'a', done: false }];
  const after = [...before];
  after[0].done = true;
  console.log(after === before, after[0] === before[0], before[0].done);
  after[0] = { id: 'a', done: false };
  console.log(after[0] === before[0], before[0].done);
})();

Dòng đầu in false, true, true. Hai mảng bên ngoài khác nhau nhưng phần tử đầu của chúng tham chiếu cùng đối tượng. Vì vậy, ghi qua after[0] làm thay đổi giá trị đọc qua before[0]. Đặt tên biến là after, copy hay snapshot không tự tạo ra ranh giới sở hữu.

Dòng sau in false, true. Thay phần tử trong after khiến ô đó trỏ tới đối tượng mới. Nó không thay ô tương ứng trong before, cũng không hoàn tác lần sửa đối tượng dùng chung trước đó. Điều này giải thích vì sao gán số mới vào một mảng số đã sao chép không đổi ô tương ứng của mảng gốc: phép gán thay phần tử của mảng ấy, thay vì sửa một đối tượng được truy cập qua phần tử.

Hai mảng khác nhau ban đầu trỏ tới cùng một việc có done bị đổi thành true; thay phần tử của mảng sao chép khiến nó trỏ tới việc mới có done false, còn mảng gốc vẫn trỏ tới việc có done trueMở ảnh kích thước đầy đủ ↗

Mũi tên biểu diễn định danh, không phải cơ chế đồng bộ. Thay phần tử đổi một mũi tên; gán thuộc tính đổi đối tượng ở đầu mũi tên.

Tài liệu Array của MDN phân biệt sao chép tham chiếu đối tượng với sao chép giá trị nguyên thủy. Các phép sao chép mảng thông thường chỉ sao chép nông. Cú pháp spread cũng chỉ đi một mức; trải một bản ghi việc cần làm vẫn giữ chung tham chiếu metadata lồng trong nó.

Sao chép đường dẫn có thay đổi

Đây là hàm tạo dữ liệu và các hàm cập nhật. Đặt phần JavaScript này trước mã hành vi trang ở phía sau.

function makeTodos() {
  return [
    { id: 'a', done: false, meta: { priority: 'normal' } },
    { id: 'b', done: false, meta: { priority: 'normal' } },
  ];
}

function replaceTodo(rows, id, revise) {
  const index = rows.findIndex(todo => todo.id === id);
  if (index === -1) return rows;
  const updated = revise(rows[index]);
  if (updated === rows[index]) return rows;
  const next = rows.slice();
  next[index] = updated;
  return next;
}

function setDone(rows, id, done) {
  return replaceTodo(rows, id, todo =>
    todo.done === done ? todo : { ...todo, done }
  );
}

function setPriority(rows, id, priority) {
  return replaceTodo(rows, id, todo =>
    todo.meta.priority === priority
      ? todo
      : { ...todo, meta: { ...todo.meta, priority } }
  );
}

setDone tạo bản ghi việc mới với Boolean mới, đồng thời giữ tham chiếu meta đang có. setPriority tạo cả metadata mới lẫn bản ghi việc mới. replaceTodo chỉ tạo mảng bên ngoài mới khi callback trả về bản ghi khác. Nó gán vào mảng cục bộ vừa tạo, chưa được trả ra; mảng đã lưu không bị sửa.

Thứ tự thuộc tính có ý nghĩa. Trong { ...todo, done }, done ở sau thay thuộc tính được sao chép trước. Đảo thứ tự sẽ khiến giá trị cũ ghi đè cập nhật vừa yêu cầu. Spread lồng bên trong áp dụng cùng quy tắc cho priority.

Không tìm thấy ID thì trả lại chính mảng cũ. Giá trị đã bằng giá trị yêu cầu cũng trả lại mảng cũ, tránh đổi định danh cho thao tác không làm thay đổi gì theo cam kết này. Đây là quy tắc riêng của helper, không phải đặc tính của mọi cách cập nhật bất biến. Một biểu thức map có thể tạo mảng mới dù mọi phần tử trả về đều giữ nguyên.

findIndex chọn kết quả khớp đầu tiên. Nếu trùng ID, helper chỉ cập nhật bản ghi khớp đầu tiên, vì vậy ID duy nhất là điều kiện đầu vào. Hai setter nhận Boolean và chuỗi mức ưu tiên do ví dụ này cung cấp; chúng không xác thực giá trị bên ngoài. Callback revise nội bộ phải giữ nguyên bản ghi trước đó. Nếu callback sửa đầu vào rồi trả chính nó, cam kết đã bị phá trước khi replaceTodo có thể giúp được gì.

Giữ trạng thái gốc khi dùng điều khiển gốc của trình duyệt

Tạo trang HTML UTF-8 có viewport đáp ứng. Đặt HTML sau trong body, CSS tiếp theo trong phần tử style, rồi đặt hàm tạo/helper cùng mã hành vi phía dưới HTML. Checkbox và nút bắt đầu ở trạng thái vô hiệu hóa cho đến khi hành vi được cài đặt.

<main class="copy-demo" data-same="giữ nguyên" data-changed="đã đổi">
  <h1>Cập nhật việc cần làm, kiểm tra tham chiếu</h1>
  <label><input type="checkbox" data-id="a" disabled> Hoàn tất việc thứ nhất</label>
  <label><input type="checkbox" data-id="b" disabled> Hoàn tất việc thứ hai</label>
  <p>Mức ưu tiên của việc thứ nhất: <span data-priority>normal</span></p>
  <button id="priority-high" type="button" disabled>Đặt ưu tiên việc thứ nhất thành high</button>
  <button id="restore-baseline" type="button" disabled>Khôi phục trạng thái gốc đã lưu</button>
  <h2>Định danh so với trạng thái gốc đã lưu</h2>
  <dl>
    <dt>Mảng</dt><dd data-ref="array">giữ nguyên</dd>
    <dt>Việc thứ nhất</dt><dd data-ref="first">giữ nguyên</dd>
    <dt>Metadata thứ nhất</dt><dd data-ref="firstMeta">giữ nguyên</dd>
    <dt>Việc thứ hai</dt><dd data-ref="second">giữ nguyên</dd>
    <dt>Metadata thứ hai</dt><dd data-ref="secondMeta">giữ nguyên</dd>
  </dl>
  <p>Trạng thái gốc: <span data-baseline>false, false; normal</span></p>
  <a href="#copy-notes">Đọc ghi chú về định danh</a>
  <h2 id="copy-notes" tabindex="-1">Cùng giá trị vẫn có thể khác định danh</h2>
  <p>Chọn rồi bỏ chọn việc thứ nhất sẽ trả về giá trị ban đầu nhưng tạo tham chiếu mới. Khôi phục trạng thái gốc dùng lại các tham chiếu đã lưu.</p>
  <noscript><p>Cần bật JavaScript để thay đổi ví dụ; trạng thái ban đầu vẫn đọc được.</p></noscript>
</main>

Các chuỗi normal, high, false và true là dữ liệu literal của ví dụ. Dòng trạng thái gốc báo hai giá trị hoàn tất và mức ưu tiên của việc thứ nhất. Danh sách định nghĩa so sánh năm tham chiếu với trạng thái gốc đã lưu, không phải với lần cập nhật ngay trước đó.

.copy-demo, .copy-demo * { box-sizing: border-box; }
.copy-demo {
  max-inline-size: 48rem;
  margin-inline: auto;
  padding: 1.5rem;
  color: #303326;
  background: #f4f0e6;
  font: 1rem/1.6 system-ui, sans-serif;
  overflow-wrap: anywhere;
}
.copy-demo label { display: block; padding-block: .5rem; }
.copy-demo input { inline-size: 1.1em; block-size: 1.1em; }
.copy-demo button {
  max-inline-size: 100%;
  margin-block: .5rem;
  padding: .5rem .75rem;
  border: 2px solid currentColor;
  color: inherit;
  background: transparent;
  font: inherit;
  overflow-wrap: anywhere;
}
.copy-demo dl { display: grid; grid-template-columns: minmax(0, 1fr); }
.copy-demo dt { font-weight: 650; }
.copy-demo dd { margin: 0 0 .75rem; }
.copy-demo a { color: inherit; text-underline-offset: .2em; }
.copy-demo :where(input, button, a, [tabindex]) { scroll-margin-block: 1rem; }
.copy-demo :where(input, button, a, [tabindex]):focus {
  outline: 3px solid #1c51a0;
  outline-offset: 2px;
}

Nhãn giữ checkbox ở dạng điều khiển gốc. Phím Space đổi checkbox đang có focus; trình duyệt cung cấp trạng thái checked, còn change handler tạo dữ liệu tiếp theo. Các nút xử lý riêng việc đổi ưu tiên và khôi phục. Hàm render đổi thuộc tính và văn bản trên các phần tử sẵn có nên không thay thế checkbox đang có focus.

const root = document.querySelector('.copy-demo');
const baseline = makeTodos();
let current = baseline;
const inputs = [...root.querySelectorAll('input[data-id]')];

function render() {
  for (const input of inputs) {
    input.checked = current.find(todo => todo.id === input.dataset.id).done;
  }
  root.querySelector('[data-priority]').textContent = current[0].meta.priority;
  const same = {
    array: current === baseline,
    first: current[0] === baseline[0],
    firstMeta: current[0].meta === baseline[0].meta,
    second: current[1] === baseline[1],
    secondMeta: current[1].meta === baseline[1].meta,
  };
  for (const cell of root.querySelectorAll('[data-ref]')) {
    cell.textContent = same[cell.dataset.ref] ? root.dataset.same : root.dataset.changed;
  }
  root.querySelector('[data-baseline]').textContent =
    `${baseline[0].done}, ${baseline[1].done}; ${baseline[0].meta.priority}`;
}

for (const input of inputs) {
  input.addEventListener('change', () => {
    current = setDone(current, input.dataset.id, input.checked);
    render();
  });
}
root.querySelector('#priority-high').addEventListener('click', () => {
  current = setPriority(current, 'a', 'high');
  render();
});
root.querySelector('#restore-baseline').addEventListener('click', () => {
  current = baseline;
  render();
});
render();
for (const control of root.querySelectorAll('input, button')) control.disabled = false;

Chọn việc thứ nhất một lần. Mảng và bản ghi việc thứ nhất khác trạng thái gốc, trong khi metadata thứ nhất và toàn bộ nhánh thứ hai vẫn dùng chung. Dòng trạng thái gốc vẫn là false, false; normal. Chọn việc thứ hai sẽ đổi bản ghi của nó một cách độc lập.

Bây giờ đặt ưu tiên việc thứ nhất thành high. Metadata của nó cũng cần định danh mới. Bấm lại nút đó trả về đúng mảng hiện tại vì ưu tiên yêu cầu đã khớp. Các ô so sánh vẫn có thể ghi “đã đổi”: chúng so với trạng thái gốc, không phải giá trị cách đây một thao tác. Kết quả ổn định ở thao tác mới nhất không có nghĩa nó cùng định danh với mọi phiên bản trước.

Bảng so sánh tham chiếu cho biết mảng, bản ghi việc và metadata nào khác trạng thái gốc sau khi chọn rồi bỏ chọn, đổi ưu tiên, lặp lại cùng ưu tiên và khôi phụcMở ảnh kích thước đầy đủ ↗

Mọi cột đều lấy trạng thái gốc làm mốc. Lặp lại thao tác đặt high không đổi gì so với hàng trước; khôi phục dùng lại đồ thị đối tượng ban đầu.

Cùng nội dung không tái tạo định danh cũ

Bắt đầu từ trạng thái gốc, chọn rồi bỏ chọn việc thứ nhất trước khi đổi bất kỳ mức ưu tiên nào. Các trường lại chứa giá trị ban đầu. Tuy nhiên, mảng và bản ghi thứ nhất vẫn khác trạng thái gốc vì mỗi cập nhật có thay đổi đều tạo đối tượng mới. JavaScript không tìm trong các phiên bản trước một đối tượng bằng giá trị rồi tự dùng lại nó.

Nút khôi phục chủ động gán current = baseline. Nó lấy lại cả tham chiếu lẫn giá trị ban đầu, với điều kiện không ai sửa đồ thị đối tượng đã lưu. Đây là ví dụ có một trạng thái gốc, không phải ngăn xếp undo, bộ giải quyết xung đột hay định dạng lưu trữ. Giữ nhiều phiên bản thực tế cũng giữ dữ liệu mà chúng còn tham chiếu tới; ví dụ không đo chi phí bộ nhớ.

Hướng dẫn mảng trong state của React nêu cùng sự phân biệt: sao chép mảng bên ngoài không tách các đối tượng phần tử sẵn có, và cập nhật lồng nhau cần sao chép dọc đường dẫn thay đổi. Trang ở đây dùng API DOM thông thường. Nó không đo số lần React render; chỉ biết định danh đổi chưa đủ kết luận một framework cụ thể sẽ lên lịch hay bỏ qua công việc ra sao.

Sao chép sâu trả lời câu hỏi khác

Một phép sao chép sâu với kiểu dữ liệu được hỗ trợ có thể tạo đồ thị dữ liệu độc lập. Nó cũng thay các nhánh không đổi mà helper ở đây cố ý chia sẻ. Sao chép toàn bộ danh sách trước khi đổi một checkbox có thể là lựa chọn sở hữu hợp lệ, nhưng không bắt buộc để giữ trạng thái gốc này và không tự đảm bảo hiệu năng tốt hơn.

Chạy ví dụ riêng sau:

(() => {
  const shared = { priority: 'normal' };
  const source = { left: shared, right: shared, when: new Date(0) };
  source.self = source;
  const copy = structuredClone(source);
  console.log(copy !== source, copy.left !== shared, copy.left === copy.right);
  console.log(copy.self === copy, copy.when instanceof Date, copy.when.getTime());
  try {
    structuredClone({ run() {} });
  } catch (error) {
    console.log(error.name);
  }
})();

Dòng đầu có ba giá trị true. Bản sao tách khỏi đồ thị gốc, nhưng left và right trong bản sao vẫn trỏ tới cùng đối tượng. Sao chép sâu giữ quan hệ ấy; nó không biến mọi tham chiếu lặp lại thành đối tượng riêng biệt. Dòng thứ hai là true, true, 0: vòng tham chiếu trỏ về bản sao, còn Date đã sao chép giữ dấu thời gian. Giá trị chứa hàm gây DataCloneError.

Window.structuredClone và thuật toán structured clone mô tả hành vi đồ thị được hỗ trợ và các giới hạn. Hàm và DOM node không thể sao chép theo cách này; chuỗi prototype tùy chỉnh và property descriptor cũng không được tái tạo như hành vi thông thường của instance trong ứng dụng. Đừng xem clone là bộ sao chép instance phổ quát. Ví dụ không dùng transfer list và không tách tài nguyên nào khỏi nguồn.

Freeze là biện pháp bảo vệ có phạm vi

const ngăn gán lại biến; nó không đóng băng đệ quy giá trị. Ngay cả gọi freeze tường minh cũng chỉ bảo vệ đối tượng được truyền vào:

(() => {
  'use strict';
  const row = Object.freeze({ done: false, meta: { priority: 'normal' } });
  try {
    row.done = true;
  } catch (error) {
    console.log(error.name);
  }
  row.meta.priority = 'high';
  console.log(row.done, row.meta.priority);
})();

Strict mode khiến phép gán done bị từ chối và ném TypeError. Kết quả sau đó là false, high, vì đối tượng metadata chưa được đóng băng. Object.freeze trả lại đối tượng gốc và áp dụng cho các thuộc tính trực tiếp của nó. Đóng băng đệ quy một đồ thị tùy ý cần xử lý thêm vòng tham chiếu và loại đối tượng được hỗ trợ; bài này không triển khai helper deep-freeze tổng quát.

Bài tập cuối: gọi setPriority(current, 'b', 'high') từ trạng thái gốc mới tạo. Dự đoán tham chiếu trước khi chạy: mảng mới, việc thứ nhất và metadata của nó giữ nguyên, việc thứ hai cùng metadata của nó đều mới. Sau đó yêu cầu lại cùng mức ưu tiên và kiểm tra mảng có đúng cùng định danh hay không. Hãy kiểm tra cả giá trị cũ. Checkbox hiển thị đúng chỉ chứng minh giá trị đang thấy; giữ nguyên phiên bản trước đòi hỏi kiểm tra các đường dẫn đến giá trị ấy.

Nguồn tham khảo & đọc thêm

  1. MDN: Array
  2. MDN: spread syntax
  3. React: updating arrays in state
  4. MDN: structuredClone
  5. MDN: structured clone algorithm
  6. MDN: Object.freeze
← Về trang bài viết

Hình minh họa

100%