背景
最近使用 dumi 来记录个人笔记,但遇到一个奇怪情况,修改文章内容,触发热更新后,并没有记录滚动位置,导致如果文章内容过长,每次编辑后还得手动滚动到最下面,很麻烦。
优化方案
在页面滚动时,完全可以记录当前的 scroolY,并存储到本地,在下一次 hmr 触发后,去获取 scroolY,再手动让浏览器滚动到指定位置即可。
考虑的点
-
仅开发环境启用
-
需要根据页面地址来设置 scroolY,避免不同页面相互影响
这里可以使用同一个 key 值,放到 localstorage 里,存储一个序列化之后的对象,包含 pathname、scroolY。
{"pathname":"/problem/java-script","scrollY":8660.5712890625} -
需要根据 dumi 提供的 api,套一个外部容器来完成操作
-
路由携带的 hash 也有跳转功能,页面刷新后,会跳转到对应 tag 位置,所以需要及时干掉
比如 problem/java-script#abc,这里在页面加载完成后,会自动跳转到相应位置,所以需要及时清除对应 tag
代码实现
配置调整
首先需要注入对应的环境变量,实现仅在 dev 模式下启用\
// .dumirc.ts
import { defineConfig } from 'dumi';
export default defineConfig({
define: { 'process.env.NODE_ENV': process.env.NODE_ENV },
});
运行时配置
这里功能实现都是在运行时做的,所以需要修改对应的运行时配置,给 dumi 渲染的 children 上套一层组件
// .dumi/app.(js|ts|jsx|tsx)
import { defineApp } from 'dumi';
const Wrapper: React.FC<{
children: JSX.Element;
}> = ({ children }) => {
return (
<div>
<div>123</div>
{children}
</div>
);
};
function rootContainer(container, args) {
return React.createElement(Wrapper, args, container);
}
export default defineApp({
rootContainer,
});
到这一步,可以看到渲染出来的内容,第一行多了个 123,通过 rootContainer 可以实现给外层套一个容器。
事件监听
这里需要在 Wrapper 里监听 scrool 事件,滚动触发时,写入当前滚动位置、pathname 到本地存储,方便后续比较。
useEffect(() => {
// 仅开发模式启用
if (process.env.NODE_ENV !== 'development') return;
window.addEventListener('scroll', throttledSaveScrollPosition);
return () => {
window.removeEventListener('scroll', throttledSaveScrollPosition);
};
}, []);
需要注意,scrool 事件触发频率比较高,而 localstorage 的操作是同步的,这里需要做节流,避免频发触发阻塞 ui 渲染
对应方法实现
// 节流函数
function throttle(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return function (...args: any[]) {
if (timeout === null) {
timeout = window.setTimeout(() => {
timeout = null;
func.apply(this, args);
}, wait);
}
};
}
const saveScrollPosition = () => {
const { pathname, hash } = location;
// 剔除掉hash,避免影响后续刷新
if (hash) {
history.replaceState(null, '', location.pathname + location.search);
}
localStorage.setItem(
'scroll-route',
JSON.stringify({
pathname,
scrollY: window.scrollY,
}),
);
};
// 使用节流来处理滚动事件
const throttledSaveScrollPosition = throttle(saveScrollPosition, 200);
滚动位置设置
同时,在 hmr 触发时,会重新渲染 Wrapper组件,因而 useEffect 也会重新执行,所以可以在这里设置当前的滚动位置。
调整 Warpper 代码
const Wrapper: React.FC<{
children: JSX.Element;
}> = ({ children }) => {
useEffect(() => {
// 仅开发模式启用
if (process.env.NODE_ENV !== 'development') return;
// 增加
onRouteChange();
window.addEventListener('scroll', throttledSaveScrollPosition);
return () => {
window.removeEventListener('scroll', throttledSaveScrollPosition);
};
}, []);
return <div>{children}</div>;
};
onRouteChange 实现 需要注意,这里同步方式来使用 window.scrollTo 会失效,应该是要等页面渲染完,这里每次间隔 50ms 去调用,尝试 5 次。
const onRouteChange = () => {
// 获取当前的路径名
const { pathname } = location;
// 从 localStorage 中获取存储的 scroll-route 字段,并解析成对象
const route = localStorage.getItem('scroll-route') || JSON.stringify({});
const parseRoute = JSON.parse(route);
// 如果存储的路径名和当前路径名一致,说明是热更新(HMR)的情况
// 跳转到存储的滚动位置,这里延迟太短会导致scrollTo不生效
if (parseRoute.pathname === pathname) {
const scrollToPosition = (attemptsLeft: number) => {
if (attemptsLeft <= 0) return;
window.scrollTo(0, parseRoute.scrollY);
if (window.scrollY !== parseRoute.scrollY) {
setTimeout(() => scrollToPosition(attemptsLeft - 1), 50);
}
};
scrollToPosition(5); // 尝试 5 次,每次间隔 50 毫秒
}
// 如果路径名不一致,说明是正常的路由跳转
};
完整代码
.dumirc.ts
需要注入运行时环境变量
define: { 'process.env.NODE_ENV': process.env.NODE_ENV }
.dumi/app.tsx
如果没有,新创建一个即可,可以使用 jsx、tsx、js、ts
import { defineApp } from 'dumi';
import React, { useEffect } from 'react';
// 节流函数
function throttle(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return function (...args: any[]) {
if (timeout === null) {
timeout = window.setTimeout(() => {
timeout = null;
func.apply(this, args);
}, wait);
}
};
}
const saveScrollPosition = () => {
const { pathname, hash } = location;
// 剔除掉hash,避免影响后续刷新
if (hash) {
history.replaceState(null, '', location.pathname + location.search);
}
localStorage.setItem(
'scroll-route',
JSON.stringify({
pathname,
scrollY: window.scrollY,
}),
);
};
// 使用节流来处理滚动事件
const throttledSaveScrollPosition = throttle(saveScrollPosition, 200);
const onRouteChange = () => {
// 获取当前的路径名
const { pathname } = location;
// 从 localStorage 中获取存储的 scroll-route 字段,并解析成对象
const route = localStorage.getItem('scroll-route') || JSON.stringify({});
const parseRoute = JSON.parse(route);
// 如果存储的路径名和当前路径名一致,说明是热更新(HMR)的情况
// 跳转到存储的滚动位置,这里延迟太短会导致scrollTo不生效
if (parseRoute.pathname === pathname) {
const scrollToPosition = (attemptsLeft: number) => {
if (attemptsLeft <= 0) return;
window.scrollTo(0, parseRoute.scrollY);
if (window.scrollY !== parseRoute.scrollY) {
setTimeout(() => scrollToPosition(attemptsLeft - 1), 50);
}
};
scrollToPosition(5); // 尝试 5 次,每次间隔 50 毫秒
}
// 如果路径名不一致,说明是正常的路由跳转
};
const Wrapper: React.FC<{
children: JSX.Element;
}> = ({ children }) => {
useEffect(() => {
// 仅开发模式启用
if (process.env.NODE_ENV !== 'development') return;
onRouteChange();
window.addEventListener('scroll', throttledSaveScrollPosition);
return () => {
window.removeEventListener('scroll', throttledSaveScrollPosition);
};
}, []);
return <div>{children}</div>;
};
function rootContainer(container, args) {
return React.createElement(Wrapper, args, container);
}
export default defineApp({
rootContainer,
});