PAYATHON 2026

Vanilla JS 如何同步页面与 fetch 加载的侧边栏状态?

数据阿明

可以用共享状态模块保存用户资料,资料更新后通过 CustomEvent 通知各个视图。侧边栏订阅变化并更新自己的 DOM,Profile 页面的保存逻辑负责提交资料和更新共享状态。

侧边栏加载完成后,还需要主动读取一次最新状态。事件只能通知后续变化,不能替代初始化。导航高亮可以根据当前页面的 URL 计算。

为什么侧边栏不会自动更新?

通过 innerHTML 插入的侧边栏属于当前页面的 DOM,没有独立的渲染环境。它是一组普通元素,修改 sessionStorage 不会自动更新这些元素。

同一页面执行 sessionStorage.setItem() 时,也不会在该页面触发 storage 事件,因此不能依靠它通知当前页面中的侧边栏。

要让状态变化反映到页面上,需要明确调用渲染函数或订阅事件。

1. 建立共享用户状态模块

这里约定用户资料使用 displayName 和 avatarUrl 两个字段,存储键为 user。这些是示例约定,需要与现有接口和存储结构保持一致。

// user-store.js
const events = new EventTarget();
const STORAGE_KEY = 'user';

function normalizeUser(value) {
  return {
    displayName: typeof value?.displayName === 'string'
      ? value.displayName.trim()
      : '',
    avatarUrl: typeof value?.avatarUrl === 'string'
      ? value.avatarUrl.trim()
      : ''
  };
}

function readStoredUser() {
  try {
    return normalizeUser(
      JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
    );
  } catch {
    return normalizeUser(null);
  }
}

let currentUser = readStoredUser();

export function getUser() {
  return { ...currentUser };
}

export function setUser(value) {
  currentUser = normalizeUser(value);

  // 存储不可用时,仍允许当前页面同步视图。
  try {
    sessionStorage.setItem(STORAGE_KEY, JSON.stringify(currentUser));
  } catch (error) {
    console.warn('用户资料未能写入 sessionStorage', error);
  }

  events.dispatchEvent(new CustomEvent('user:changed', {
    detail: getUser()
  }));
}

export function subscribeUser(listener) {
  const handler = () => listener(getUser());
  events.addEventListener('user:changed', handler);

  return () => {
    events.removeEventListener('user:changed', handler);
  };
}

所有修改用户资料的入口都应调用 setUser()。如果某处仍直接写入 sessionStorage,那次修改就不会产生这里定义的通知。

setUser() 接收完整的用户视图数据。只修改姓名时,也需要保留已有的 avatarUrl,避免意外清空头像。

2. 让侧边栏管理自己的 DOM

可以在 sidebar.html 中用 data-* 属性标记需要更新的位置:

<aside>
  <div data-user-avatar></div>
  <span data-user-name></span>

  <nav>
    <a href="../pages/profile.html" data-nav>个人资料</a>
    <a href="../pages/settings.html" data-nav>设置</a>
  </nav>
</aside>

链接路径仅供示例,应替换为项目实际地址。通过 innerHTML 插入时,链接的相对路径按宿主页面的基准 URL 解析,不会自动以 sidebar.html 的地址为基准。如果页面目录层级不同,可以使用适合部署路径的统一链接地址。

侧边栏行为放在独立的 JavaScript 模块中:

// sidebar.js
import { getUser, subscribeUser } from './user-store.js';

function renderUser(root, user) {
  const name = root.querySelector('[data-user-name]');
  const avatar = root.querySelector('[data-user-avatar]');
  const displayName = user.displayName || '用户';

  name.textContent = displayName;

  const fallback = document.createElement('span');
  fallback.textContent = Array.from(displayName)
    .slice(0, 2)
    .join('')
    .toUpperCase();

  avatar.replaceChildren(fallback);

  if (!user.avatarUrl) return;

  const img = document.createElement('img');
  img.alt = '用户头像';
  img.hidden = true;

  img.addEventListener('load', () => {
    img.hidden = false;
    fallback.hidden = true;
  }, { once: true });

  img.addEventListener('error', () => {
    img.remove();
  }, { once: true });

  avatar.append(img);
  img.src = user.avatarUrl;
}

function updateActiveLinks(root) {
  const current = new URL(window.location.href);

  root.querySelectorAll('a[data-nav]').forEach(link => {
    const target = new URL(link.href);
    const active = target.origin === current.origin
      && target.pathname === current.pathname;

    link.classList.toggle('active', active);

    if (active) {
      link.setAttribute('aria-current', 'page');
    } else {
      link.removeAttribute('aria-current');
    }
  });
}

export async function mountSidebar(root, fragmentUrl) {
  const response = await fetch(fragmentUrl);

  if (!response.ok) {
    throw new Error(`侧边栏加载失败:${response.status}`);
  }

  root.innerHTML = await response.text();

  const unsubscribe = subscribeUser(user => {
    renderUser(root, user);
  });

  // 在 HTML 插入后读取最新状态,覆盖加载期间发生的修改。
  renderUser(root, getUser());
  updateActiveLinks(root);

  return unsubscribe;
}

Profile 页面不需要知道侧边栏内部使用了哪些 ID,侧边栏的选择器和渲染细节都留在 sidebar.js 中。

事件订阅与首次渲染之间没有异步等待。即使用户在侧边栏的 fetch 请求完成前修改了资料,挂载后的 getUser() 也能取得最新值。

3. 保存成功后更新共享状态

在宿主页面中使用模块脚本:

<div id="sidebar-placeholder"></div>
<script type="module" src="./profile.js"></script>

页面模块负责挂载侧边栏并提供保存流程:

// profile.js
import { mountSidebar } from '../components/sidebar.js';
import { setUser } from '../components/user-store.js';

mountSidebar(
  document.getElementById('sidebar-placeholder'),
  '../components/sidebar.html'
).catch(error => {
  console.error(error);
});

// persistProfile 是项目已有的 PUT 请求函数。
// 它需要在请求失败时抛出异常,并返回完整、已统一字段名的用户资料。
export async function saveProfile(changes, persistProfile) {
  const savedUser = await persistProfile(changes);
  setUser(savedUser);
  return savedUser;
}

将现有表单保存逻辑接到 saveProfile()。persistProfile() 的返回值应类似:

{
  displayName: '新的显示名称',
  avatarUrl: 'https://res.cloudinary.com/...'
}

如果后端只返回修改过的字段,需要先与已有资料合并,再调用 setUser()。接口返回的字段若叫 display_name、avatar_url,也应先转换为约定的字段名。

等待 PUT 成功后再更新共享状态,可以避免请求失败后界面仍显示“已保存”的资料。如果采用乐观更新,还需要补上失败回滚逻辑。

Profile 页面的姓名和头像也可以订阅同一个状态模块,并使用自己的渲染函数。它与侧边栏会收到同一次更新通知。

4. 头像刷新后变回首字母,应检查什么?

仅凭现有描述,无法确定头像问题的具体原因。保存了 Cloudinary URL,也不能保证页面初始化时一定读取并使用了它。可以按以下顺序排查:

  1. 检查字段与存储键:保存和读取是否使用相同的键?是否一处写入 avatar_url,另一处读取 avatarUrl?
  2. 检查初始化顺序:是否先恢复了头像,随后又被默认资料或较旧的接口响应覆盖?加载当前用户资料后,也应通过同一个 setUser() 更新状态。
  3. 检查条件分支:应在有效头像 URL 为空或图片加载失败时显示首字母,避免初始化时无条件切回占位内容。
  4. 检查图片请求:在 Network 面板确认 URL 是否正确、是否返回成功,并查看 Console 是否有 CSP 或其他加载错误。不要仅因图片来自其他域名就判定为 CORS 问题。

上面的头像渲染代码会先显示首字母,图片加载成功后再切换到 <img>;图片加载失败则继续显示首字母。排查时仍需查看失败的具体原因。

sessionStorage 适合保存当前标签页会话中的缓存,通常会在刷新后保留,但不应作为用户资料的长期唯一来源。进入新会话时,需要从后端恢复资料。

5. active 链接如何匹配?

示例按照同源 URL 的 pathname 精确匹配,忽略 query 和 hash。对于每个页面对应一个 HTML 文件的多页面应用,这通常足够。

如果多个导航项通过 query 区分,或者 /settings/profile.html 也需要高亮“设置”,就需要增加对应的匹配规则。目录匹配应考虑路径边界,避免把 /user 与 /users 误判为同一栏目。

普通多页面跳转会重新执行侧边栏初始化。如果当前页面通过 History API 修改地址,则需要在路由变化后再次调用导航高亮逻辑。

还有两点实现细节:通过 innerHTML 插入的 <script> 不会按普通页面脚本的方式执行,因此组件行为应由宿主页面加载的模块初始化;如果在同一文档中卸载侧边栏,应调用 mountSidebar() 返回的取消订阅函数,避免残留监听器。

备注:内容仅供参考。