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的服务端和客户端逻辑:
- 服务端预取逻辑:首次加载获取初始职位列表
getJobs,迁移至getServerSideProps,在服务端完成数据请求并注入Redux或直接传递给组件。 - 客户端交互逻辑:搜索防抖、筛选条件变化、页面埋点、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;
四、关键重构点说明
- Redux服务端初始化:通过
initializeStore创建服务端独立的store实例,避免请求间数据污染,预取数据后传递给客户端组件。 - 环境判断:添加
typeof window !== 'undefined'判断,确保客户端API(如window.innerWidth、ym埋点)仅在浏览器环境执行。 - SEO优化:使用Next.js的
Head组件设置页面标题、描述和Open Graph标签,提升搜索引擎抓取效果。 - 数组渲染优化:原代码中
map后可能返回null,添加filter(Boolean)过滤无效元素,避免React警告。 - useCallback依赖修正:原
debounced的依赖数组为空,添加dispatch确保函数引用稳定。
五、后续优化建议
- 若职位数据更新频率较低,可将
getServerSideProps替换为getStaticProps并设置revalidate: 3600(每小时重新生成页面),提升性能。 - 集成Next.js的
Image组件优化图片加载,进一步提升页面速度和SEO。 - 添加结构化数据(Schema.org)标记,帮助搜索引擎理解页面内容(如职位列表类型)。
内容的提问来源于stack exchange,提问作者Arthur Khannanov
相关产品推荐
相关产品推荐

