dumi 热更新后滚动到原先位置

993 阅读4分钟

背景

最近使用 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,
});