You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React-Redux应用迁移至Next.js的SEO适配及首渲染逻辑咨询

React-Redux应用迁移至Next.js(SEO友好生产级重构)

一、数据获取方案选型

  • 放弃getInitialProps:该API在Next.js 13+已被标记为过时,它会混合客户端/服务端渲染逻辑,不利于维护和SEO优化。
  • 优先使用getServerSideProps:你的页面需要动态获取招聘数据,且要求SEO友好,getServerSideProps会在每次请求时服务端渲染,确保搜索引擎能抓取到完整内容。如果后续数据更新频率低,可考虑切换为getStaticProps+revalidate实现增量静态生成,进一步优化性能。

二、原useEffect逻辑拆分迁移

原组件中的useEffect可分为两类,分别适配Next.js的服务端和客户端逻辑:

  1. 服务端预取逻辑:首次加载获取初始职位列表getJobs,迁移至getServerSideProps,在服务端完成数据请求并注入Redux或直接传递给组件。
  2. 客户端交互逻辑:搜索防抖、筛选条件变化、页面埋点、DOM事件监听等,保留在客户端useEffect中,和原React组件逻辑一致。

三、重构后的完整代码示例

import { useEffect, useRef, useState, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import debounce from 'lodash.debounce';
import moment from 'moment';
import { GetServerSideProps } from 'next';
import Head from 'next/head';
import { RootState } from '../store';
import { jobActions, jobEffect } from '../store/slices/jobsSlice';
import Finder from '../components/Finder';
import FinderAutocomplete from '../components/FinderAutocomplete';
import CategoryList from '../components/CategoryList';
import SelectedSkills from '../components/SelectedSkills';
import JobCard from '../components/JobCard';
import Spinner from '../components/Spinner';
import EmailSubscribe from '../components/EmailSubscribe';
import { initializeStore } from '../store';

const MainPage = ({ initialJobs }) => {
  const dispatch = useDispatch();
  const refBannerControl = useRef<HTMLDivElement | null>(null);
  const vacancyBlockRef = useRef(null);
  const [isCategorySelected, setIsCategorySelected] = useState(false);

  const {
    skills,
    searchSkillValue,
    selectedFilters,
    loadingVacancy,
    loadingSkills,
    jobCards,
    currentPage,
  } = useSelector((state: RootState) => state.jobs);

  // 初始化服务端预取的职位数据
  useEffect(() => {
    if (jobCards.length === 0) {
      dispatch(jobActions.setJobs(initialJobs));
    }
  }, [initialJobs, dispatch]);

  const getSkills = () => {
    if (searchSkillValue.length > 0) {
      debounced();
    }
  };

  const onFocusFinder = () => {
    getSkills();

    const isMobile = typeof window !== 'undefined' && window.innerWidth < 700;
    if (refBannerControl.current && isMobile) {
      refBannerControl.current.scrollIntoView({ behavior: 'smooth' });
    }
  };

  const debounced = useCallback(
    debounce(() => {
      dispatch(jobEffect.getSkills());
    }, 300),
    [dispatch]
  );

  // 搜索值变化时获取技能列表
  useEffect(() => {
    getSkills();
  }, [searchSkillValue]);

  // 筛选条件变化时获取对应职位
  useEffect(() => {
    if (selectedFilters.length > 0) {
      dispatch(jobActions.setVacancyLoading(true));
      const lastSelected = selectedFilters[selectedFilters.length - 1];
      dispatch(jobEffect.getVacanciesBySkill(lastSelected.value));
    }
  }, [selectedFilters, dispatch]);

  // 页面访问埋点
  useEffect(() => {
    if (typeof window !== 'undefined') {
      (window as any).ym('reachGoal', 'mainPageVisit');
    }
  }, []);

  // 加载更多职位
  const handleLoadMoreJobs = () => {
    if (!loadingVacancy) {
      dispatch(jobActions.setVacancyLoading(true));
      if (isCategorySelected) {
        dispatch(jobEffect.getJobsByCategory(currentPage + 1));
      } else {
        dispatch(jobEffect.getJobs(currentPage + 1));
      }
    }
  };

  // 点击文档清空技能列表
  const documentClick = (event: MouseEvent) => {
    dispatch(jobActions.setSkills([]));
  };

  useEffect(() => {
    if (typeof window !== 'undefined' && skills.length > 0) {
      document.addEventListener('click', documentClick);
    } else if (typeof window !== 'undefined') {
      document.removeEventListener('click', documentClick);
    }

    return () => {
      if (typeof window !== 'undefined') {
        document.removeEventListener('click', documentClick);
      }
    };
  }, [skills]);

  // 分类选择逻辑
  const onSelectCategory = (key: string) => {
    if (key === 'all') {
      setIsCategorySelected(false);
      dispatch(jobEffect.getJobs(currentPage, true));
    } else {
      setIsCategorySelected(true);
      dispatch(jobActions.setVacancyLoading(true));
      dispatch(jobEffect.getJobsByCategory(currentPage + 1, key));
    }
  };

  // 职位按时间分组
  const dateNow = moment();
  const startToday = moment().subtract(1, 'days');
  const startWeek = moment().subtract(7, 'days');
  const startMonth = moment().subtract(30, 'days');

  let day = false;
  let week = false;
  let month = false;
  let other = false;

  const jobListDay = jobCards.map((item) => {
    if (moment(item.createdAt).isBetween(startToday, dateNow, undefined, '[]')) {
      day = true;
      return <JobCard key={item._id} job={item} />;
    }
    return null;
  }).filter(Boolean);

  const jobListWeek = jobCards.map((item) => {
    if (moment(item.createdAt).isBetween(startWeek, startToday, undefined, '[]')) {
      week = true;
      return <JobCard key={item._id} job={item} />;
    }
    return null;
  }).filter(Boolean);

  const jobListMonth = jobCards.map((item) => {
    if (moment(item.createdAt).isBetween(startMonth, startWeek, undefined, '[]')) {
      month = true;
      return <JobCard key={item._id} job={item} />;
    }
    return null;
  }).filter(Boolean);

  const jobListOther = jobCards.map((item) => {
    if (moment(item.createdAt).isBefore(startMonth)) {
      other = true;
      return <JobCard key={item._id} job={item} />;
    }
    return null;
  }).filter(Boolean);

  return (
    <>
      <Head>
        <title>IWORKREMOTELY - ДОСТОЙНЫМ ЛЮДЯМ - ДОСТОЙНАЯ РАБОТА</title>
        <meta name="description" content="Найдите удаленную работу для достойных людей - вакансии в разных категориях, обновленные регулярно." />
        <meta property="og:title" content="IWORKREMOTELY - Удаленная работа для достойных людей" />
        <meta property="og:description" content="Поиск удаленных вакансий, фильтрация по навыкам и категориям, актуальные предложения." />
      </Head>
      <div className="main">
        <div className="container">
          <section className="banner">
            <div className="banner-control" ref={refBannerControl}>
              <div className="banner-info">
                <h1 className="banner-info__title">IWORKREMOTELY</h1>
                <h2 className="banner-info__sub-title">ДОСТОЙНЫМ ЛЮДЯМ - ДОСТОЙНАЯ РАБОТА</h2>
              </div>
              <div onClick={(e) => e.stopPropagation()}>
                <Finder
                  loading={loadingSkills}
                  value={searchSkillValue}
                  onChange={(value) => dispatch(jobActions.setSearchValue(value))}
                  onFocus={onFocusFinder}
                />
                <FinderAutocomplete
                  items={skills}
                  onSelect={(skill) => dispatch(jobActions.selectSkill(skill))}
                />
              </div>
            </div>
          </section>

          <section className="section">
            <CategoryList onSelect={onSelectCategory} />
          </section>

          {selectedFilters.length > 0 && (
            <SelectedSkills
              items={selectedFilters}
              onDelete={(id) => dispatch(jobActions.deleteSelectSkill({ id }))}
            />
          )}

          <section className="section vacancy" ref={vacancyBlockRef}>
            {day && <div className="vacancy__dateBlock">Сегодня</div>}
            {jobListDay}
            {week && <div className="vacancy__dateBlock">На этой неделе</div>}
            {jobListWeek}
            {month && <div className="vacancy__dateBlock">В этом месяце</div>}
            {jobListMonth}
            {other && <div className="vacancy__dateBlock">В этом году</div>}
            {jobListOther}

            {loadingVacancy && <Spinner />}

            <div className="loadMore">
              <button className="btn btn_accent loadMore__button" onClick={handleLoadMoreJobs}>
                Загрузить еще
              </button>
            </div>
          </section>
        </div>

        <div>
          <EmailSubscribe />
        </div>
      </div>
    </>
  );
};

// 服务端预取初始职位数据
export const getServerSideProps: GetServerSideProps = async () => {
  const store = initializeStore();
  await store.dispatch(jobEffect.getJobs());
  const { jobCards } = store.getState().jobs;

  return {
    props: {
      initialJobs: jobCards,
    },
  };
};

export default MainPage;

四、关键重构点说明

  1. Redux服务端初始化:通过initializeStore创建服务端独立的store实例,避免请求间数据污染,预取数据后传递给客户端组件。
  2. 环境判断:添加typeof window !== 'undefined'判断,确保客户端API(如window.innerWidth、ym埋点)仅在浏览器环境执行。
  3. SEO优化:使用Next.js的Head组件设置页面标题、描述和Open Graph标签,提升搜索引擎抓取效果。
  4. 数组渲染优化:原代码中map后可能返回null,添加filter(Boolean)过滤无效元素,避免React警告。
  5. useCallback依赖修正:原debounced的依赖数组为空,添加dispatch确保函数引用稳定。

五、后续优化建议

  • 若职位数据更新频率较低,可将getServerSideProps替换为getStaticProps并设置revalidate: 3600(每小时重新生成页面),提升性能。
  • 集成Next.js的Image组件优化图片加载,进一步提升页面速度和SEO。
  • 添加结构化数据(Schema.org)标记,帮助搜索引擎理解页面内容(如职位列表类型)。

内容的提问来源于stack exchange,提问作者Arthur Khannanov

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 01:31:08