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,也不能保证页面初始化时一定读取并使用了它。可以按以下顺序排查:
- 检查字段与存储键:保存和读取是否使用相同的键?是否一处写入
avatar_url,另一处读取avatarUrl? - 检查初始化顺序:是否先恢复了头像,随后又被默认资料或较旧的接口响应覆盖?加载当前用户资料后,也应通过同一个
setUser()更新状态。 - 检查条件分支:应在有效头像 URL 为空或图片加载失败时显示首字母,避免初始化时无条件切回占位内容。
- 检查图片请求:在 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() 返回的取消订阅函数,避免残留监听器。
备注:内容仅供参考。
