← 中文资源

简体中文版本

脚本加载:async 与 defer

async 与 defer 都让 HTML 解析与脚本下载并行,但执行顺序保证不同:defer 在解析完成后按文档顺序执行,async 一就绪就执行、顺序不定。

阅读英文原文

概念定义

不带属性的经典 script 标签是解析器的停车牌:HTML 构建停到代码取回并执行完。两个属性都撤掉下载期间的停车牌,却在执行时机上分道扬镳:defer 保留文档顺序与解析好的 DOM,async 为了尽早执行放弃两者。

工作机制

defer 脚本在文档解析完成后、DOMContentLoaded 触发前按顺序执行;async 脚本下载一完成立即执行,顺序取决于网络,可能在 DOMContentLoaded 前后任意时刻。module 脚本默认 defer 语义,动态 import 则是 async 语义。

使用场景

应用 bundle 与所有触碰 DOM 的脚本用 defer;独立的行为追踪、打点信标用 async。文档声明了加载顺序的第三方代码片段必须用 defer,async 会在网络波动下静默乱序。

常见误区

async 的乱序竞态只在“第一个慢、后面的快”的节流环境才暴露,测试要用限速档案。defer 只等解析不等图片,DOMContentLoaded 在图片完成前触发;deferred 代码仍会阻塞 DOMContentLoaded,繁重启动工作照样推迟可交互状态。

代码示例

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Accessible confirmation dialog</title>
  <style>
    :root {
      color-scheme: light dark;
      font-family: system-ui, sans-serif;
    }

    body {
      margin: 0;
      padding: 2rem;
    }

    button {
      font: inherit;
      padding: 0.6rem 1rem;
      border: 1px solid currentColor;
      border-radius: 0.4rem;
      background: canvas;
      color: canvastext;
      cursor: pointer;
    }

    button:focus-visible {
      outline: 3px solid #4f46e5;
      outline-offset: 2px;
    }

    dialog {
      max-width: 32rem;
      border: 1px solid gray;
      border-radius: 0.75rem;
      padding: 1.5rem;
    }

    dialog::backdrop {
      background: rgb(0 0 0 / 55%);
    }

    .actions {
      display: flex;
      gap: 0.75rem;
      justify-content: flex-end;
      margin-top: 1.5rem;
    }
  </style>
</head>
<body>
  <main>
    <h1>Delete project</h1>
    <p>Deleting a project removes its deployments and environment variables.</p>
    <button type="button" id="open-dialog">Delete project…</button>
    <p id="status" role="status" aria-live="polite"></p>
  </main>

  <dialog id="confirm-dialog" aria-labelledby="dialog-title" aria-describedby="dialog-desc">
    <form method="dialog">
      <h2 id="dialog-title">Delete “Atlas”?</h2>
      <p id="dialog-desc">
        This permanently removes the project and its 12 deployments. This action cannot be undone.
      </p>
      <div class="actions">
        <button value="cancel" autofocus>Cancel</button>
        <button value="confirm">Delete permanently</button>
      </div>
    </form>
  </dialog>

  <script>
    const dialog = document.querySelector('#confirm-dialog');
    const openButton = document.querySelector('#open-dialog');
    const status = document.querySelector('#status');

    openButton.addEventListener('click', () => {
      dialog.returnValue = '';
      dialog.showModal();
    });

    dialog.addEventListener('close', () => {
      status.textContent = dialog.returnValue === 'confirm'
        ? 'Project deleted.'
        : 'Deletion cancelled.';
      openButton.focus();
    });

    dialog.addEventListener('click', (event) => {
      if (event.target === dialog) {
        dialog.close('cancel');
      }
    });
  </script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Signup with an accessible error summary</title>
  <style>
    body {
      margin: 0;
      padding: 2rem;
      font-family: system-ui, sans-serif;
    }

    form {
      max-width: 28rem;
      display: grid;
      gap: 1.25rem;
    }

    .field {
      display: grid;
      gap: 0.35rem;
    }

    input {
      font: inherit;
      padding: 0.5rem;
      border: 1px solid gray;
      border-radius: 0.35rem;
    }

    input:focus-visible,
    button:focus-visible,
    .error-summary:focus-visible {
      outline: 3px solid #4f46e5;
      outline-offset: 2px;
    }

    .error-summary {
      border: 2px solid #b91c1c;
      border-radius: 0.5rem;
      padding: 1rem;
    }

    .error-summary h2 {
      margin-top: 0;
      font-size: 1.15rem;
    }

    .error-summary a {
      color: inherit;
    }

    .field-error {
      margin: 0;
      color: #b91c1c;
    }

    input[aria-invalid="true"] {
      border-color: #b91c1c;
    }

    fieldset {
      border: 0;
      padding: 0;
      display: grid;
      gap: 0.35rem;
    }
  </style>
</head>
<body>
  <main>
    <h1>Create your account</h1>

    <form id="signup" novalidate>
      <div class="error-summary" id="error-summary" role="alert" tabindex="-1" hidden>
        <h2>There is a problem</h2>
        <ul id="error-list"></ul>
      </div>

      <div class="field">
        <label for="email">Email address</label>
        <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-error">
        <p class="field-error" id="email-error" hidden></p>
      </div>

      <div class="field">
        <label for="password">Password</label>
        <input id="password" name="password" type="password" autocomplete="new-password" minlength="8" required aria-describedby="password-error">
        <p class="field-error" id="password-error" hidden></p>
      </div>

      <fieldset aria-describedby="plan-error">
        <legend>Plan</legend>
        <label><input type="radio" name="plan" value="starter" id="plan-starter" required> Starter</label>
        <label><input type="radio" name="plan" value="pro" id="plan-pro"> Pro</label>
        <p class="field-error" id="plan-error" hidden></p>
      </fieldset>

      <div class="field">
        <label for="terms">
          <input id="terms" name="terms" type="checkbox" required aria-describedby="terms-error">
          I accept the terms of service
        </label>
        <p class="field-error" id="terms-error" hidden></p>
      </div>

      <button type="submit">Create account</button>
      <p id="status" role="status" aria-live="polite"></p>
    </form>
  </main>

  <script>
    const form = document.querySelector('#signup');
    const summary = document.querySelector('#error-summary');
    const errorList = document.querySelector('#error-list');
    const status = document.querySelector('#status');

    const email = document.querySelector('#email');
    const password = document.querySelector('#password');
    const planInputs = [...document.querySelectorAll('input[name="plan"]')];
    const terms = document.querySelector('#terms');

    const fields = [
      {
        input: email,
        error: document.querySelector('#email-error'),
        validate() {
          const value = email.value.trim();
          if (!value) return 'Enter your email address.';
          if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            return 'Enter an email address in the format [email protected].';
          }
          return '';
        }
      },
      {
        input: password,
        error: document.querySelector('#password-error'),
        validate() {
          if (!password.value) return 'Enter a password.';
          if (password.value.length < 8) return 'Use at least 8 characters for your password.';
          return '';
        }
      },
      {
        inputs: planInputs,
        input: planInputs[0],
        error: document.querySelector('#plan-error'),
        validate() {
          return document.querySelector('input[name="plan"]:checked') ? '' : 'Choose a plan.';
        }
      },
      {
        input: terms,
        error: document.querySelector('#terms-error'),
        validate() {
          return terms.checked ? '' : 'Accept the terms to continue.';
        }
      }
    ];

    function targetsFor(field) {
      return field.inputs ?? [field.input];
    }

    function clearError(field) {
      field.error.textContent = '';
      field.error.hidden = true;
      for (const target of targetsFor(field)) {
        target.removeAttribute('aria-invalid');
      }
      if (!fields.some((candidate) => !candidate.error.hidden)) {
        summary.hidden = true;
        errorList.replaceChildren();
      }
    }

    for (const field of fields) {
      for (const target of targetsFor(field)) {
        target.addEventListener('input', () => clearError(field));
        target.addEventListener('change', () => clearError(field));
      }
    }

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      errorList.replaceChildren();
      const invalid = [];

      for (const field of fields) {
        const message = field.validate();
        field.error.textContent = message;
        field.error.hidden = message === '';
        for (const target of targetsFor(field)) {
          if (message) target.setAttribute('aria-invalid', 'true');
          else target.removeAttribute('aria-invalid');
        }
        if (message) invalid.push({ field, message });
      }

      if (invalid.length > 0) {
        for (const { field, message } of invalid) {
          const item = document.createElement('li');
          const link = document.createElement('a');
          link.href = '#' + targetsFor(field)[0].id;
          link.textContent = message;
          link.addEventListener('click', (clickEvent) => {
            clickEvent.preventDefault();
            targetsFor(field)[0].focus();
          });
          item.append(link);
          errorList.append(item);
        }
        summary.hidden = false;
        summary.focus();
        status.textContent = '';
        return;
      }

      summary.hidden = true;
      status.textContent = 'Account created. Check your inbox to confirm your email.';
      form.reset();
    });
  </script>
</body>
</html>