Next.JS - 在呈现页面之前访问 `localStorage`

Next.JS - Access `localStorage` before rendering page

假设我有一个用户的帐户信息存储在 localStorage(客户端)中。我需要我的 Next.JS 应用程序根据 localStorage(登录或注销按钮)中存储的内容呈现网页的导航栏。如何先从客户端获取值,然后然后渲染页面?或者这甚至不是 Next.JS 的本意?

你可以这样做:

  1. 在状态中使用一个变量来防止页面被渲染
  2. 使用componentDidMountlocalStorage
  3. 加载数据
  4. 加载数据时,设置状态以允许呈现组件。

这是一个反应问题,而不是 next.js 问题。 您可以在第 1 步中使用 Conditional rendering。 还有 read up on state here, and lastly componentDidMount.

更新:

如今,我会选择 React hooks 实现,但这个想法仍然有效。 useEffect 在某些情况下可以通过一些细微差别在很大程度上实现这一点。

我还意识到 NextJS 和 SSR 逻辑可能存在一些特殊的注意事项,因此此响应可能不够充分。在这种情况下,我还会查看下面的其他一些回复。

本地存储在服务器上不可用,有两个选项可以解决这个问题 1:创建HOC或自定义钩子来检查本地存储是否有数据(这是正常的反应方式) 2:你可以使用cookies在客户端和服务器端存储数据,然后可以使用getServerSideProps提取数据,然后你可以使用这些数据在初始渲染上相应地显示信息。

所述,我无法在第一次呈现之前真正获得 localStorage,直到发生这种情况才显示后备页面。

根本问题是 Next.js 将一个 URL 映射到一个预渲染。反应水合作用 requires the initial server HTML to match the JavaScript structure:

React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them. In development mode, React warns about mismatches during hydration. There are no guarantees that attribute differences will be patched up in case of mismatches. This is important for performance reasons because in most apps, mismatches are rare, and so validating all markup would be prohibitively expensive.

该引用不是很清楚纯文本更改是否有效,但下面的最小测试表明它在这种情况下会发出警告,因此您不想使用它。

因此,唯一可靠的方法是使用 useEffect 来更新页面。

然而,当我测试时,localStorage 的正确渲染显示得如此之快以至于中间的渲染根本不明显,我不确定它是否会发生。唯一的问题是,如果您对每种情况进行不同的 API 调用,请参阅下面的“区分“未登录”和“尚未决定”以避免进行额外的 API 调用”部分例子。

我想做的是对那个答案中提到的内容给出一个更具体的想法。

SWR 示例

这是一个完整的可运行示例,其中导航栏显示登录状态:https://github.com/cirosantilli/node-express-sequelize-nextjs-realworld-example-app That repository is a fork of this one, both of which are Next.js implementations of the awesome realworld project

这种情况下的回退只是博客页面的注销视图,其中已经包含用户可能希望看到的关键信息,并且可以缓存,例如有 ISR。

该演示使用 SWR 使代码稍微简单一些。关键部分是:

代码的关键部分有:

导航栏:

import useSWR from "swr";

const Navbar = () => {
  const { data: currentUser } = useSWR("user", key => {
    const value = localStorage.getItem(key);
    return !!value ? JSON.parse(value) : undefined;
  });

登录:

import { mutate } from "swr";

const LoginForm = () => {
  const handleSubmit = async (e) => {
    // Get `user` data structure from API.
    mutate("user", data?.user);

我们看到当用户登录时,我们在 "user" 全局标识符上调用 mutate

这将重绘包含该挂钩的所有组件,其中包括导航栏,因为它使用 useSWR 调用设置挂钩。

这样,login先重绘navbar,然后再跳转到home,这样首页马上就有重绘的navbar了。如果没有 mutate,只会重绘页面主体,不会重绘导航栏。

使用此设置:

  • 如果你把 console.log(currentUser) 放在 useSWR 下面,你会看到它被调用了两次。

    所以发生的事情是它首先 return 立即使用缓存值 (undefined) 并且第一个渲染开始。

    然后开始对缓存的异步调用,当 returns 时,钩子触发组件的重新渲染,并再次使用当前用户值进行打印。

    这只发生在 refresh/first 命中期间的初始水合作用。在内部页面更改期间,只有一个渲染。

    所有这一切发生得如此之快,以至于我根本无法在没有 hte 本地存储的情况下看到页面绘制,即使使用 Chromium 调试器时间轴框架检查也看不到。

  • 如果我们在 localStorage getter 中添加 2 秒延迟但是:

    const { data: currentUser } = useSWR("user", async (key) => {
      await new Promise(resolve => setTimeout(resolve, 2000))
      const value = localStorage.getItem(key);
      return !!value ? JSON.parse(value) : undefined;
    });
    

    我们确实观察到用户注销时的中间页面状态,因此理论上它可能会发生。

没有 SWR 会是什么样子

当然,我们不需要使用 SWR 来实现。

SWR 文档为我们提供了没有 SWR https://swr.vercel.app/getting-started 来激励他们的图书馆的事情的基本原理。

您需要将状态向上移动到登录表单 + 导航栏的共同父级,或者您可以使用 Context.

function Page () {
  const [user, setUser] = useState(null)

  // fetch data
  useEffect(() => {
    const value = localStorage.getItem(key);
    const user = !!value ? JSON.parse(value) : undefined;
    setUser(user)
  }, [])

  // global loading state
  if (!user) return <Spinner/>

  return <div>
    <Navbar user={user} />
    <Content user={user} />
  </div>
}

// child components

function Navbar ({ user }) {
  return <div>
    ...
    <Avatar user={user} />
  </div>
}

function Content ({ user }) {
  return <h1>Welcome back, {user.name}</h1>
}

function Avatar ({ user }) {
  return <img src={user.avatar} alt={user.name} />
}

所述,useEffect 是类似于 componentDidMount 的钩子。

检查 typeof localStorage === 'undefined' 会导致警告

React 不喜欢这样,并发出类似这样的警告:

Expected server HTML to contain a matching"

因为它注意到水化和非水化页面之间的区别:

在 Next.js 10.2.2.

上测试

最小可重现示例

只是想看看到底发生了什么:

pages/index.js

import Link from 'next/link'
import React from 'react'

export default function IndexPage() {
  console.error('IndexPage');
  let [n, setN] = React.useState(0)
  if (typeof localStorage === 'undefined') {
    n = '0'
  } else {
    n = parseInt(localStorage.getItem('n') || '0', 10)
  }
  return <>
    <Link href="/notindex">notindex</Link>
    <div
      onClick={() => {
        localStorage.setItem('n', n + 1)
        setN(n + 1)
      }}
    >increment</div>
    <div
      onClick={() => {
        localStorage.removeItem('n')
        setN(0)
      }}
    >reset</div>
    <div>{n}</div>
  </>
}

pages/notindex.js

import Link from 'next/link'

export default function NotIndexPage() {
  return <Link href="/">index</Link>
}

package.json

{
  "name": "test",
  "version": "1.0.0",
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "12.0.7",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  }
}

运行:

npm install
npm run dev

现在,如果您:

  • 打开/
  • 增量
  • 刷新页面

react 发出警告,因为它注意到 0 文本已更改为 1:

Warning: Text content did not match. Server: "0" Client: "1"

如果我们点击指向 notindex 的内部链接并返回,则不会看到警告。这是因为水化只在初始页面刷新时完成,进一步的更改仅在 Js 中完成。

我们要做的是这样的:

import Link from 'next/link'
import React from 'react'

export default function IndexPage() {
  console.error('IndexPage');
  let [n, setN] = React.useState(0)
  React.useEffect(() => {
    console.error('useEffect');
    setN(parseInt(localStorage.getItem('n') || '0', 10))
  }, [])
  return <>
    <Link href="/notindex">notindex</Link>
    <div
      onClick={() => {
        setN(n + 1)
        localStorage.setItem('n', n + 1)
      }}
    >increment</div>
    <div
      onClick={() => {
        localStorage.removeItem('n')
        setN(0)
      }}
    >reset</div>
    <div>{n}</div>
  </>
}

区分“未登录”和“尚未决定”以避免进行额外的 API 调用

好的,我遇到了另一个问题:我进行了不必要的 API 调用,因为页面首先认为用户已注销,然后又认为它已登录,并且每个都需要做不同的 API 调用。

与开始呈现错误页面不同,这实际上会导致服务器负载后果,因此这是不可接受的。

我使用的解决方案是区分:

  • undefined: 还没决定
  • null: 未登录

并且不对未定义的任何请求。

这是一个非最小化的演示:

稍后我会尽量减少它。

另一种解决方法:只做SSR

一般来说,SSR 比 ISR 简单多了,因为你不用担心这个 get page/ask for data/get data/update page dance from Hell.

ISR 是一种优化,只有在性能优势得到证实时才应使用。

请记住,Next.js 中的 SSR 也非常有效,因为 Next.js return 在页面切换时只有 .json 来自 getServerSideProps,基本上和 API 完全一样。

然后您可以使用 cookie 从 getServerSideProps 进行身份验证,然后 return 直接访问正确的页面。

我就是这样做的。

const setSession = (accessToken) => {
   if (typeof window !== 'undefined')
     localStorage.setItem('accessToken', accessToken);
};

const getAccessToken = () => {
   if (typeof window !== 'undefined') 
      return localStorage.getItem('accessToken');
};

这是我调用它们来处理登录和获取访问令牌的地方:

const loginWithEmailAndPassword = async (email, password) => {
  const { data } = await axios.post(`${apiUrl}/login`, { email, password });
  const { user, accessToken } = data;

  if (user) {
    setSession(accessToken);
    return user;
  }
};

const accessToken = getAccessToken();

你可以使用 useEffect hook 和 useState,这样当组件加载时,useEffect 将最后触发,从 localStorage 中提取数据并将其分配给 useState 中的 STATE。

然后您可以从 useState, states 访问您的数据。如果说得通。

最重要的是,useEffect 允许轻松地从 localStorage 中提取数据,这样您就可以用它做任何您想做的事了。

const [userData, setUserData] = useState({});
console.log(userData); 


useEffect(()=> {
    setUserData(localStorage.getItem('userSession'));
}, [])

在服务器端发生的第一个渲染无法访问 localStorage 并抛出错误。为防止这种情况,请使用

添加额外的防御层
if (typeof window !== 'undefined') {
// run logic that read/write localStorage
}

然后它应该在服务器上跳过逻辑,运行在客户端加载时