我为何要放弃Nextjs的App Router开发应用

本文以Page Router迁移为App Router的视角分析为何我放弃了App Router,如果你有相同的感受或者不同的意见,欢迎补充。 另外本文具有时效性,仅仅针对当前Nextjs的App Router版本做。文章基于Nextjs版本为14.2.2

背景

在项目前期开发时,曾经对Next的App Router和Page Router都做了一些基础的demo试验,通过对比去看那个Router更合适。从试验的demo来看,其实无论是App和Page都是可用的状态的。但是到了使用一些第三方库的时候,比如i18n、redux同步等第三方库时,出现很多一些意料之外的情况。通过排查发现这些第三方库对于App Router的适配并不好,甚至是不支持的情况。

分析得出App Router和Page Router的优缺点:

App Router的优势

  • Nextjs未来的主力方向
  • 基于React的SRC实现
  • 页面性能更好

App Router的劣势

  • 对于App Router的稳定性有保留

  • 社区对于App Router的第三方库支持较

  • 缺少使用App Router开发大型应用的案例

Page Router的优势

  • 社区成熟,有大量的第三方库支持
  • 有大量的基于Page Router的大型应用案例

Page Router的劣势

  • 有可能未来Next官方会放弃维护

  • 不是基于React的SRC来实现,所以性能相对App Router可能会弱一些

所以在项目的前期我们选择使用Page Router进行开发。

在项目开发接近尾声的时候,又开始考虑是否需要更换为App Router。原因很简单,就是担心在未来Page Router可能官方不再更新特性,导致当我们希望使用一些新React特性或者一些第三方库时,不支持Page Router的尴尬情况。而目前整体项目功能大致上已经稳定,所以也可以基于目前项目的功能特性以及未来项目可能出现的一些需求,有针对性的调研App Router在项目中落地的可行性。

SSR和SSG的界限像捅破一层窗纸一样容易

Page Router的SSR和SSG

相信使用过Page Router开发项目的开发者应该很清楚,在Page Router中有3个钩子是非常重要的getStaticProps、getInitialProps和getServerSideProps。在这里我简单说一下这三个钩子与SSR和SSG的关系。

  • SSG:

  • 当_app.tsx没有使用getInitialProps时,页面级index.tsx没有使用getServerSideProps,那么在编译时会进行优化,将React直接在编译时渲染出html。

  • 使用getStaticProps可以使参数静态化,在编译时传入React生成html。

  • SSR:

  • 当_app.tsx使用了getInitialProps,但是页面级index.tsx使用了getServerSideProps,那么将会采取SSR进行渲染。

  • _app.tsx没有使用getInitialProps,但是页面级index.tsx使用了getServerSideProps,那么将会采取SSR进行渲染。

添加图片注释,不超过 140 字(可选)

在使用Page Router的时候如果触发SSG那么切换页面的时候,Next是直接返回html内容,而触发SSP的时候,浏览器会发起一个json请求到Next服务端,然后返回对应的getServerSideProps返回的json,然后传递到react中进行渲染。

App Router的SSR和SSG

而在App Router中,getStaticProps、getInitialProps和getServerSideProps都被取消了。取而代之的是完全不同的开发方式。完成的文档可以查看:server-components

添加图片注释,不超过 140 字(可选)

在App Router中,想实现SSR和Page Router完全不一样,App Router所谓的SSR并非发起一个json请求去获取目标页面依赖的数据进行渲染。App Router推崇的是Server Component,所以标记为Server Component的组件必须在服务渲染完成,并且Next为了确保Server Component必须是安全的,不会泄漏任何服务端的代码到浏览器,所以在App Router的SSR实际上是在服务端执行当前页面所有的Server Component,并进行渲染后会得出一个RSC Payload的数据结构。浏览器仅仅只会得到服务端返回的RSC Payload,至于这个RSC Payload是如何计算出来的,客户端完全不可知。

以下是一个简单的例子,一个Server Component和对应的RSC Playload的对应关系。

typescript 复制代码
export default function Page() {
  const data = await fetch('/api/user', { cache: 'no-store' });
  return <div><h1>{`${Hello, User Page! ${data.name}`}</h1></div>;
}
kotlin 复制代码
1:HL["/_next/static/css/6d786301c7df2fd2.css","style"]
0:["jRKxBqBsVJW4VyJcHFuxz",[[["",{"children":["user",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],"$L2",[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/6d786301c7df2fd2.css","precedence":"next","crossOrigin":"$undefined"}]],"$L3"]]]]
4:I[9305,[],""]
5:I[5236,[],""]
7:I[3843,["797","static/chunks/app/user/page-653d5a80347da9ba.js"],""]
2:[null,["$","html",null,{"lang":"en","children":[["$","head",null,{"children":[["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1,minimum-scale=1, maximum-scale=1, user-scalable=no"}],["$","meta",null,{"name":"apple-mobile-web-app-capable","content":"yes"}],["$","meta",null,{"name":"apple-mobile-web-app-status-bar-style","content":"black"}],["$","link",null,{"rel":"icon","type":"image/svg+xml","href":"/images/favicon.svg"}],["$","link",null,{"rel":"icon","type":"image/png","href":"/images/favicon.png"}],["$","link",null,{"rel":"preload","href":"/fonts/Hack-Regular.ttf","as":"font","type":"font/ttf","crossOrigin":""}],["$","link",null,{"rel":"preload","href":"/fonts/Hack-Bold.ttf","as":"font","type":"font/ttf","crossOrigin":""}],["$","link",null,{"rel":"preload","href":"/fonts/Hack-Italic.ttf","as":"font","type":"font/ttf","crossOrigin":""}],["$","link",null,{"rel":"preload","href":"/fonts/Hack-BoldItalic.ttf","as":"font","type":"font/ttf","crossOrigin":""}]]}],["$","body",null,{"className":"pc","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"initialChildNode":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","user","children"],"loading":"$undefined","loadingStyles":"$undefined","loadingScripts":"$undefined","hasLoading":false,"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","initialChildNode":["$L6",["$","div",null,{"children":[["$","h1",null,{"children":"Hello, User Page! Bapelin"}],["$","$L7",null,{}]]}],null],"childPropSegment":"__PAGE__","styles":null}],"childPropSegment":"user","styles":null}]}]]}],null]
3:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Create Next App"}],["$","meta","3",{"name":"description","content":"Generated by create next app"}]]
6:null

在App Router中,SSG和SSR的边界是非常容易被破坏的,可能一不小心就变成SSG了,其实这也是符合官方所希望的。但是实际情况是很多时候我们的应用是和用户的状态、等级、角色等等挂钩的。所以页面是不具备静态化的要求的。而在App Router中,官方无时无刻得希望将项目静态化处理,会让一些动态化要求很高的应用非常难受。

以上面的例子为例,其实只需要修改一个属性,这个页面就会变为SSG:

typescript 复制代码
export default function Page() {
  // SSR
  // const data = await fetch('/api/user', { cache: 'no-store' });
  
  // SSG
  const data = await fetch('/api/user');
  return <div><h1>{`${Hello, User Page! ${data.name}`}</h1></div>;
}

没错,就是这么简单,当然决定是SSR还是SSG肯定不止这么一个属性可以更改。具体可以看这两个文档:full-route-cachedata-cache。实际上这里就是告诉Next这个页面所依赖的数据,不能缓存。那么这个页面所依赖的数据就不能被静态化,所以编译的时候,该页面就不能输出为一个html。

Page Router和App Router对于SSG和SSR的定义和实现都有很大的差别。App Router对于SSG和SSR之间的界限相比Page Router而言,相对来说比较薄弱,依靠用户对于一些fetch缓存策略,或者是否需要操作请求上下午的API调用情况来自主判断当前页面能否优化为SSG。这会让开发人员无法有意识的去决定页面的类型。

多层缓存策略可能是个巨坑

上面说到SSR和SSG的区分其中一部分靠的是在编译时看fetch的数据是否能缓存,那么Next内部一定会维护一个针对网络请求的缓存机制。当然Next也给出了很少的参数来控制fetch的数据的时效性和重新拉取的机制参数,而Next也会对不同的缓存策略做出不一样的编译结果来决定当前页面所有依赖的数据能否被静态化,或者定时更新数据持久化静态化。

当页面是一个完全静态化的页面,缓存规则如下图:

添加图片注释,不超过 140 字(可选)

当页面依赖数据,并且不能完全静态化时,缓存的机制如下图:

添加图片注释,不超过 140 字(可选)

整体看似很美好,客户端、服务端和fetch都有一层缓存机制,用来加速我们重复资源的加载速度。但是回到同样的问题,当我们的应用需要依赖大量的用户角色或者权限相关来进行渲染时,或者对应资源具有较高的时效性时,那么结果会如何?或许我们需要大量的使用no-store来控制数据必须每次去拉取新数据,绝对不能缓存。

在高时效性的项目中,这样多层的缓存策略可能会为开发人员带来更多的心智负担,可能一不小心就将数据缓存,导致数据无法获取最新状态。 同时fetch的缓存与其说是用来缓存数据加速渲染,不如说是Next为了知道数据是否可持久化,来到达将页面编译为SSG的目的更贴切。

编译时会发起fetch请求。

在使用Page Router开发时,当我们对于渲染的数据有明确时,一般采取以下4种方式来拉取数据,用于渲染页面。

  • getInitialProps:用于整个应用运行依赖的数据,例如用户的状态,权限,功能开关等数据。

  • getServerSideProps:用于SSR或SSP拉取数据拉取数据,从而达到无论是SSR还是SSP时,拉取数据的方式一致来减少不用情况下渲染导致的异化情况。

  • getStaticProps:当没有使用getInitialProps时,用于静态化数据的拉取,来实现Next对页面优化编译为SSG。

  • 组件内调用fetch:一般通过useEffect等异步行为,使得数据的加载在客户端执行而并非服务端。

而在App Router中,所有的fetch行为都不再使用任何钩子包裹,而是与普通使用React开发一样,在组件内获取。有一点不同的是,为了达到SSR或者SSG的相同渲染行为,官方建议我们使用同步fetch的方式在组件内拉取数据使用。

typescript 复制代码
export default function Page() {
  const data = await fetch('/api/user');
  return <div><h1>{`${Hello, User Page! ${data.name}`}</h1></div>;
}

在调研的过程中,发现一个不符合预期的情况。例如像上面的例子,/api/user没有使用no-store来强制不缓存,那么应该是像是getStaticProps一样在编译的时候发起请求,并将数据用于渲染成SSG。但是当使用no-store来强制不缓存时,编译时依然会触发接口发起请求。而且截止到发文为止,并没有找到合适的方式来解决这一怪异的行为。就目前的情况来看,正常的情况下,Server Component中的fetch行为,无论在编译时还是运行时都会触发。

而这样的行为会有以下问题:

  • 渲染所依赖的接口无论编译时或者运行时,都发起请求,并不能很好去区分数据是否可静态化。

  • 一般项目在编译时,处于本地或CI环境,该环节一般没有与后端接口服务通信,对于一些需要在SSR渲染的数据,会存在拉取失败,而且就算数据能被拉取成功,一般依赖SSR的数据可能会与用户的信息相关,那么在编译时拉取的数据将毫无意义。

  • 基于容器化部署的时候,一般从测试环境->预发环境->生产环境的顺序进行部署,而镜像应该始终使用同一个镜像。这种情况下,当数据在编译时发起,那么请求的数据应该源于那个环境?当然该问题在Page Router时一样会存在。

目前在社区中找到一种规避方式,就是将fetch包裹起来,防止在编译时因为网络环境等问题发生错误导致编译失败。

typescript 复制代码
async function getUser(){
  try{
    return await fetch('/api/user');
  } catch(err) {
  	// ...
    return null;
  }
}

export default function Page() {
  const data = await getUser();
  return <div><h1>{`${Hello, User Page! ${data?.name}`}</h1></div>;
}

这种情况或许与Next设计相关,RSC最终目标可能更想实现前后端完全一体实现,依赖于RSC的标准,服务端返回的是一个RSC Payload,不会携带任何服务端代码,所以相对安全。而服务端直接提供api进行数据库操作。所以Next才会如此设计。

相关问题:

stackoverflow.com/questions/7...

无法使用Context为应用添加全局数据

以我们项目为例,整个应用都高度依赖用户的状态和权限,所以在原本的项目设计中,对于用户的AuthInfo会从jwt解析后存放在全局的store中,便于各个页面或者组件直接通过Context来获取状态信息进行异化处理。

在App Router中,对于Server Component和Client Component能使用什么api是有强制规定的。

添加图片注释,不超过 140 字(可选)

nextjs.org/docs/app/bu...

react.dev/reference/r...

而Context很不幸也包括其中,使用Context只能在浏览器使用,无法在Server Component中使用。当然该问题也是可以解决的,只不过只能通过每个页面的Server Component中调用一个封装的函数(Server Action),该函数获取当前的header,并对header中携带的jwt信息进行解析,最终将解析结果返回。而页面的Server Component获取该数据后,通过props传递下去子组件中。子组件通过props获取数据使用。

添加图片注释,不超过 140 字(可选)

为了避免频繁解析jwt带来额外的性能开销,应该统一在每个页面的最外层Server Component获取,而并非每个组件要使用就调用该函数。 Server Action只能在Server Component中使用。

当然也可以在最外层的Server Component中获取AuthInfo后,在子级组件中使用一个Client Component来包裹一个Context来达到与Page Router使用Context提供全局状态的目录。(需要注意该全局状态并非应用全局状态,而是该页面的全局状态)。如果使用这种方法,那么在SPA应用进行切换的时候,就可能会存在多个同级的Context。这样也会容易造成数据混乱,数据流向不一致的问题。

组件过度拆分

因为App Router中对于Server Component和Client Component能使用什么api是有强制规定的。所以我们必须对展示组件和行为组件分开实现,我们可以看看一个很简单的例子,就以一个非常常见的卡片组件作为例子,分别看看在App Router和Page Router中的实现。

这是一个简单卡片,除了渲染常规的header、content和footer,还有两个事件对两个提供交互行为。在Page Router中,这个组件可以很好的工作,在服务端会跳过事件绑定的行为,在到浏览器时进行hydrate时,才会对事件进行绑定。

typescript 复制代码
export function Card({title, content, subContent}) {


    const likeHandler =  useCallback(() => {
      // ...
    }, []);
  
    const followHandler =  useCallback(() => {
      // ...
    }, []);
  
	return (
      <div>
        // header
      	<div>{title}</div>
        // content
        <div>
          <p>{content}</p>
          <p>{subContent}</p>
        </div>
        // footer
        <div>
          <button onClick={likeHandler}>点赞</button>
          <button onClick={followHandler}>关注</button>
        </div>
      </div>
    );

}

如果该组件放到App Router中,就会出现以下报错:

添加图片注释,不超过 140 字(可选)

详细原因可以参考

简单来说,Server Component并不会跳过一些本该在浏览器执行的行为,而是直接报错。当需要使用浏览器行为的api或者参数时,必须使用Client Component。那么我们看看如果要将上面这个简单的卡片改成一个能在App Router中使用的组件需要如何实现。

typescript 复制代码
// link.tsx
"use client"
export function CardLinkButton() {
  const likeHandler =  useCallback(() => {
      // ...
    }, []);
  return (
    <button onClick={likeHandler}>点赞</button>
  );
}

// follow.tsx
"use client"
export function CardFollowButton() {
  const followHandler =  useCallback(() => {
      // ...
    }, []);
  return (
    <button onClick={followHandler}>关注</button>
  );
}

// card.tsx
import CardFollowButton from './follow';
import CardLinkButton from './link';

export function Card({title, content, subContent}) {  
	return (
      <div>
        // header
      	<div>{title}</div>
        // content
        <div>
          <p>{content}</p>
          <p>{subContent}</p>
        </div>
        // footer
        <div>
          <CardLinkButton/>
          <CardFollowButton/>
        </div>
      </div>
    );

}

可以发现我们需要将一个组件进行动静分离,以卡片组件为例,其实就是将需要绑定onClick行为的组件单独封装出去,并且使用"use client"来声明为一个客户端组件。其他保持一致。在这个例子看上去好像挺简单的,那么试想一下一个更为复杂的组件会如何?例如一个带有动态效果的组件,需要JavaScript去计算一些动画样式的。或者一个组件有很多的交互行为,例如表单。这时候这个组件将要拆分得多细去实现。

另外这种拆分组件都是为了满足上层组件使用的私有组件,往往这些组件可能无法复用。例如某个组件原本只是一个Server Component,但是为了满足另外一个页面的使用,可能要修改为Client Component。当然你可以不复用,自己写的一个。那么问题就是这样的情况必然会发生,而且数量不少,那么是否就违背了React对于组件的定义呢?

对于Server Component和Client Component这种强制性的拆分,当页面有很多一些异步行为需要用到一些useEffect,或者应用级别做一些异步行为去检查一些应用状态,就必须将在Root Layout中拆分出一个叶子结点为Client Component来实现。那么就会变成组件会过渡拆分,虽然组件的粒度是变小了,但是维护的成本也提高。

总结

App Router和Page Router我认为并不是一个简单的升级。而是一次完全革命性的改变。在对App Router的使用和对比中发现,App Router有自己的理念,基于RSC实现,或许未来是一个使用React开发的方向。但整体看来,App Router的方案尚未成熟,我看到更多的是Next对React的RSC的一种实现,而并非一个基于RSC的成熟SSR成熟解决方案。基于文章的内容,大致总结几个点:

  • App Router基于RSC实现,服务端和客户端交互通过RSC Payload进行通信,确实大大提高了服务端代码的安全性。
  • Server Component和Client Component的划分思想是美好的,但是实际上开发体验并不好。需要大量关注组件的特性,拆分是渲染组件还是行为组件。
  • 构成一个应用可以说粗略分为渲染组件和应为组件,但是有很多比较模糊的情况。例如管理状态的组件、定时器组件、全局行为组件等。从这点看来,App Router并没有得到社区大规模应用的验证。有很多细节过于理想化。
  • 现阶段如果要使用App Router来进行开发,应该认真考虑自身项目的特性,如果偏向于静态化的站点,或者偏向于门户类以信息展示为主的项目,App Router大概率可以支持好开发工作。但是如果遇到一些大型应用,例如电商、社交、工具或者管理后台等,对于数据时效性比较高,并且渲染结果与用户角色权限挂钩的项目,需要好好考虑,或者App Router为你带来的只有苦恼而并非快乐。
相关推荐
魏大帅。几秒前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax
花花鱼7 分钟前
vue3 基于element-plus进行的一个可拖动改变导航与内容区域大小的简单方法
前端·javascript·elementui
k093311 分钟前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
EricWang135832 分钟前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning32 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
晴天飛 雪42 分钟前
React 守卫路由
前端框架·reactjs
web行路人42 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00143 分钟前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
子非鱼9211 小时前
【Ajax】跨域
javascript·ajax·cors·jsonp
超雄代码狂1 小时前
ajax关于axios库的运用小案例
前端·javascript·ajax