强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

TypeScript 开发指南 / 17 - DOM 操作

DOM 操作

TypeScript 为浏览器 DOM API 提供了完整的类型定义,让 DOM 操作更加安全。

获取 DOM 元素

基本查询

// getElementById 返回 HTMLElement | null
const element = document.getElementById("app");
// element: HTMLElement | null

// 需要空值检查
if (element) {
  element.innerHTML = "Hello";
}

// 使用非空断言(当你确定元素存在时)
const app = document.getElementById("app")!;
app.innerHTML = "Hello";

// querySelector 返回更精确的类型
const button = document.querySelector("button");
// button: HTMLButtonElement | null

const input = document.querySelector<HTMLInputElement>("#username");
// input: HTMLInputElement | null

const div = document.querySelector<HTMLDivElement>(".container");
// div: HTMLDivElement | null

元素类型层级

EventTarget
└── Node
    ├── Element
    │   ├── HTMLElement
    │   │   ├── HTMLInputElement
    │   │   ├── HTMLButtonElement
    │   │   ├── HTMLDivElement
    │   │   ├── HTMLFormElement
    │   │   ├── HTMLAnchorElement
    │   │   ├── HTMLImageElement
    │   │   ├── HTMLSelectElement
    │   │   ├── HTMLTextAreaElement
    │   │   └── ...更多
    │   └── SVGElement
    ├── Document
    ├── DocumentFragment
    └── CharacterData
        ├── Text
        └── Comment

类型断言获取精确类型

// 使用 as 断言
const input = document.getElementById("username") as HTMLInputElement;
input.value = "Alice";

// 使用泛型参数
const input = document.querySelector<HTMLInputElement>("#username");
if (input) {
  input.value = "Alice";
}

// 使用 satisfies(TypeScript 4.9+)
const config = {
  container: document.getElementById("app") as HTMLDivElement,
  input: document.getElementById("search") as HTMLInputElement
};

创建 DOM 元素

// createElement 返回精确类型
const div = document.createElement("div");
// div: HTMLDivElement

const button = document.createElement("button");
// button: HTMLButtonElement

const input = document.createElement("input");
// input: HTMLInputElement

// 设置属性
div.className = "container";
div.id = "main";
div.setAttribute("data-role", "content");

button.textContent = "Click me";
button.disabled = false;

input.type = "text";
input.placeholder = "Enter your name";
input.value = "";

// 添加子元素
document.body.appendChild(div);
div.appendChild(button);
div.appendChild(input);

DOM 属性操作

const element = document.getElementById("myElement")!;

// 常用属性
element.id = "newId";
element.className = "class1 class2";
element.classList.add("new-class");
element.classList.remove("old-class");
element.classList.toggle("active");
element.classList.contains("active"); // boolean

// 样式
element.style.color = "red";
element.style.fontSize = "16px";
element.style.backgroundColor = "#fff";

// 内容
element.textContent = "Hello World";       // 纯文本
element.innerHTML = "<strong>Hello</strong>"; // HTML

// 数据属性
element.dataset.userId = "123";
element.dataset.role = "admin";

元素尺寸和位置

const element = document.getElementById("box")!;

// 元素尺寸(只读)
const rect = element.getBoundingClientRect();
console.log(rect.width, rect.height);
console.log(rect.top, rect.left);

// 包含 padding 的尺寸
console.log(element.clientWidth, element.clientHeight);

// 包含 border 和 padding 的尺寸
console.log(element.offsetWidth, element.offsetHeight);

// 滚动尺寸
console.log(element.scrollHeight, element.scrollWidth);

// 滚动位置
element.scrollTop = 100;
element.scrollLeft = 0;

事件处理

基本事件绑定

const button = document.querySelector<HTMLButtonElement>("#myButton")!;

// addEventListener
button.addEventListener("click", (event) => {
  // event: MouseEvent
  console.log(event.clientX, event.clientY);
  console.log(event.target); // EventTarget | null
  console.log(event.currentTarget); // EventTarget | null
});

// 使用类型参数指定事件类型
button.addEventListener("click", (event: MouseEvent) => {
  console.log(event.button);  // 鼠标按键
  console.log(event.buttons); // 按下的按键掩码
});

常见事件类型

// 鼠标事件
element.addEventListener("click", (e: MouseEvent) => {});
element.addEventListener("dblclick", (e: MouseEvent) => {});
element.addEventListener("mousedown", (e: MouseEvent) => {});
element.addEventListener("mouseup", (e: MouseEvent) => {});
element.addEventListener("mousemove", (e: MouseEvent) => {});
element.addEventListener("mouseenter", (e: MouseEvent) => {});
element.addEventListener("mouseleave", (e: MouseEvent) => {});

// 键盘事件
document.addEventListener("keydown", (e: KeyboardEvent) => {
  console.log(e.key);     // 按键名
  console.log(e.code);    // 按键码
  console.log(e.ctrlKey); // 是否按下 Ctrl
  console.log(e.shiftKey);
  console.log(e.altKey);
});

// 输入事件
input.addEventListener("input", (e: Event) => {
  const target = e.target as HTMLInputElement;
  console.log(target.value);
});

input.addEventListener("change", (e: Event) => {
  const target = e.target as HTMLInputElement;
  console.log(target.value);
});

// 表单事件
form.addEventListener("submit", (e: Event) => {
  e.preventDefault();
  const formData = new FormData(form);
});

// 焦点事件
input.addEventListener("focus", (e: FocusEvent) => {});
input.addEventListener("blur", (e: FocusEvent) => {});

// 滚动事件
window.addEventListener("scroll", (e: Event) => {
  console.log(window.scrollY);
});

// 拖拽事件
element.addEventListener("dragstart", (e: DragEvent) => {});
element.addEventListener("dragover", (e: DragEvent) => {});
element.addEventListener("drop", (e: DragEvent) => {});

事件委托

const list = document.querySelector<HTMLUListElement>("#todoList")!;

list.addEventListener("click", (event: MouseEvent) => {
  const target = event.target as HTMLElement;

  // 检查点击的元素
  if (target.matches(".delete-btn")) {
    const item = target.closest("li");
    if (item) {
      item.remove();
    }
  }

  if (target.matches(".edit-btn")) {
    // 编辑逻辑
  }
});

自定义事件

// 创建自定义事件
interface UserEventDetail {
  userId: number;
  action: "login" | "logout";
}

const event = new CustomEvent<UserEventDetail>("userAction", {
  detail: { userId: 1, action: "login" },
  bubbles: true,
  cancelable: true
});

// 监听自定义事件
document.addEventListener("userAction", ((e: CustomEvent<UserEventDetail>) => {
  console.log(e.detail.userId);
  console.log(e.detail.action);
}) as EventListener);

// 派发事件
document.dispatchEvent(event);

事件处理器的类型安全

// 封装类型安全的事件绑定
function addTypedEventListener<K extends keyof HTMLElementEventMap>(
  element: HTMLElement,
  type: K,
  handler: (event: HTMLElementEventMap[K]) => void
): void {
  element.addEventListener(type, handler as EventListener);
}

// 使用
const button = document.querySelector<HTMLButtonElement>("#btn")!;
addTypedEventListener(button, "click", (e) => {
  // e: MouseEvent(自动推断)
  console.log(e.clientX);
});

addTypedEventListener(button, "mouseenter", (e) => {
  // e: MouseEvent
});

表单操作

const form = document.querySelector<HTMLFormElement>("#myForm")!;
const nameInput = form.querySelector<HTMLInputElement>("#name")!;
const emailInput = form.querySelector<HTMLInputElement>("#email")!;
const selectBox = form.querySelector<HTMLSelectElement>("#role")!;
const checkbox = form.querySelector<HTMLInputElement>("#agree")!;

// 读取表单值
const name = nameInput.value;
const email = emailInput.value;
const role = selectBox.value;
const agreed = checkbox.checked;

// 设置表单值
nameInput.value = "Alice";
emailInput.value = "alice@example.com";
selectBox.value = "admin";
checkbox.checked = true;

// 表单提交
form.addEventListener("submit", (e: SubmitEvent) => {
  e.preventDefault();

  // FormData
  const formData = new FormData(form);

  // 转换为对象
  const data = Object.fromEntries(formData.entries());
  console.log(data);
});

// 表单验证
nameInput.addEventListener("input", () => {
  if (nameInput.validity.valueMissing) {
    nameInput.setCustomValidity("请输入姓名");
  } else if (nameInput.validity.tooShort) {
    nameInput.setCustomValidity("姓名太短");
  } else {
    nameInput.setCustomValidity("");
  }
});

虚拟 DOM 类型

// 简化的虚拟 DOM 类型
interface VNode {
  type: string;
  props: Record<string, any>;
  children: (VNode | string)[];
}

function h(
  type: string,
  props: Record<string, any> | null,
  ...children: (VNode | string)[]
): VNode {
  return { type, props: props || {}, children };
}

// 类型安全的 JSX 工厂
const vnode = h("div", { class: "container" },
  h("h1", null, "Hello"),
  h("p", null, "World")
);

业务场景:拖拽排序

interface DraggableOptions {
  container: HTMLElement;
  itemSelector: string;
  handleSelector?: string;
  onReorder: (items: HTMLElement[]) => void;
}

function initDraggable(options: DraggableOptions): void {
  const { container, itemSelector, handleSelector, onReorder } = options;
  let draggedItem: HTMLElement | null = null;

  container.addEventListener("dragstart", (e: DragEvent) => {
    const target = e.target as HTMLElement;
    if (!target.matches(itemSelector)) return;

    draggedItem = target;
    target.classList.add("dragging");
    e.dataTransfer!.effectAllowed = "move";
  });

  container.addEventListener("dragover", (e: DragEvent) => {
    e.preventDefault();
    e.dataTransfer!.dropEffect = "move";

    const target = (e.target as HTMLElement).closest(itemSelector) as HTMLElement;
    if (target && target !== draggedItem) {
      const rect = target.getBoundingClientRect();
      const midY = rect.top + rect.height / 2;

      if (e.clientY < midY) {
        container.insertBefore(draggedItem!, target);
      } else {
        container.insertBefore(draggedItem!, target.nextSibling);
      }
    }
  });

  container.addEventListener("dragend", () => {
    if (draggedItem) {
      draggedItem.classList.remove("dragging");
      draggedItem = null;

      const items = Array.from(container.querySelectorAll(itemSelector));
      onReorder(items as HTMLElement[]);
    }
  });
}

注意事项

  1. getElementById 返回 HTMLElement | null——使用 querySelector<T> 获取更精确的类型
  2. 事件处理函数的类型——TypeScript 会根据事件名自动推断事件类型
  3. event.targetEventTarget | null——需要类型断言才能访问特定元素的属性
  4. 空值检查——DOM 查询可能返回 null,始终进行检查
  5. 使用 closest 进行事件委托——确保事件处理正确的元素

扩展阅读