本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
前言
使用 Next.js 开发项目,经常会在引入三方库(比如 antv)的时候,遇到 “document is not defined”
这样的报错:
错误不止这些,也有可能是 “window is not defined”
:
或者是其他各种各样的报错,但我们隐约知道这是使用了客户端 API 导致,但具体该怎么解决呢?怎么解决会更好呢?这就是本篇文章要讲解的问题。
PS:其实这个问题在开发 Next.js 项目的时候基本都会遇到,所以点赞收藏这篇文章,以防日后用到的时候却找不到……
本篇已收录到掘金专栏《Next.js 开发指北》
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。
1. 使用客户端组件
我们的第一反应往往是改为使用客户端组件,这有的时候也确实能解决问题。
我们举个轮播图的例子。运行 npx create-next-app@latest
创建一个 Next.js 项目,安装轮播图功能用到的依赖项:
npm install react-slick slick-carousel --save
新建 app/slide/page.js
,代码如下:
import React from "react";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
export default function SimpleSlider() {
var settings = {
dots: true,
infinite: true,
speed: 500,
slidesToShow: 1,
slidesToScroll: 1,
};
return (
<>
<div className="h-20 flex items-center justify-center">Import Third Library Example By YaYu</div>
<div className="slider-container">
<Slider {...settings}>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-400 rounded-md text-white">1</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-500 rounded-md text-white">2</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-600 rounded-md text-white">3</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-700 rounded-md text-white">4</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-800 rounded-md text-white">5</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-900 rounded-md text-white">6</div>
</div>
</Slider>
</div>
</>
);
}
如果像这样直接引入使用,会出现报错:
错误看起来有些莫名其妙,但本质还是在服务端使用了客户端 API 导致。在文件顶部添加 "use client"
指令,将其改为客户端组件,此时便能正常运行,交互效果如下:
不过从最佳实践的角度来看,我们应该尽可能减少客户端组件的范围(对应组件树中的位置下移),所以建议改成以下这种形式:
新建 app/slider/slider.js
,代码如下:
'use client'
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
export default function SliderComponent() {
var settings = {
dots: true,
infinite: true,
speed: 500,
slidesToShow: 1,
slidesToScroll: 1,
};
return (
<div className="slider-container">
<Slider {...settings}>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-400 rounded-md text-white">1</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-500 rounded-md text-white">2</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-600 rounded-md text-white">3</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-700 rounded-md text-white">4</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-800 rounded-md text-white">5</div>
</div>
<div className="px-4">
<div className="h-60 flex items-center justify-center text-3xl bg-teal-900 rounded-md text-white">6</div>
</div>
</Slider>
</div>
)
}
app/slider/page.js
,代码修改为:
import React from "react";
import Slider from './slider';
export default function SimpleSlider() {
return (
<>
<div className="h-20 flex items-center justify-center">Import Third Library Example By YaYu</div>
<Slider />
</>
);
}
此时效果不变,但相比之前的实现,标题部分会使用服务端渲染,Slider 则继续走客户端渲染。
注:其实对这个例子而言,全部使用客户端组件和部分使用客户端组件区别并不大,但如果标题部分使用了诸如 dayjs 等这样的时间库,使用服务端渲染不会将 dayjs 打包到客户端 bundle 中。所以理论上应该尽可能减少客户端组件的范围。
轮播图组件本应该直接写在 app/slide/page.js
中,但却新建了一个组件,写在了 app/slider/slider.js
,轮播图组件在 React 组件树中的位置下移了。这就是“客户端组件树下移”,Next.js 官方文档会建议 “Moving Client Components Down the Tree”,其实就是这个意思。
2. 动态导入
如果总是能够这么简单的解决就好了!
有的时候,即使添加 use client
指令还是会出现问题,比如当我们使用 ant-desgin 的图表 @ant-design/charts 时……
让我们举个实战例子,安装依赖项:
npm install @ant-design/charts --save
新建 app/chart/page.js
,代码如下:
import React from 'react';
import { Line } from '@ant-design/charts';
const Page = () => {
const data = [
{ year: '1991', value: 3 },
{ year: '1992', value: 4 },
{ year: '1993', value: 3.5 },
{ year: '1994', value: 5 },
{ year: '1995', value: 4.9 },
{ year: '1996', value: 6 },
{ year: '1997', value: 7 },
{ year: '1998', value: 9 },
{ year: '1999', value: 13 },
];
const config = {
data,
height: 400,
xField: 'year',
yField: 'value',
};
return <Line {...config} />;
};
export default Page;
打开 http://localhost:3000/chart
,此时会出现报错:
我们很容易的想到是使用了客户端 API 的缘故,那就改为客户端组件试试。添加 "use client"
指令后,页面确实不报错了,能够正常展示出来:
但是,如果你查看页面请求,会发现页面显示 500 错误:
查看命令行中的输出,依然有 document is not defined
错误:
虽然开发者模式看起来能够正常运行,但构建的时候(npm run build)因为有这个报错,最终会构建失败:
所以这个问题还是要解决。
首先我们要明白为什么会出现这个问题,明明我们都已经声明为客户端组件了。
这是因为客户端组件也会进行预渲染。你可以简单粗暴的理解为“客户端组件 = SSR + 水合 + CSR”,也就是说,客户端组件会在构建或者请求的时候在服务端预渲染一次内容,然后在客户端进行水合,最后根据交互进行客户端渲染。
这个问题就出现在当客户端组件在服务端进行预渲染的时候,错误的使用了 document 等客户端 API。
那如何取消客户端组件的预渲染呢?
据我所知,目前只有一种方法,那就是使用动态加载,基本用法如下:
import dynamic from 'next/dynamic'
const WithCustomLoading = dynamic(
() => import('../components/WithCustomLoading'),
{
loading: () => <p>Loading...</p>,
}
)
export default function Page() {
return (
<div>
<WithCustomLoading />
</div>
)
}
动态加载本质上是 Suspense 和 React.lazy 的复合实现,Next.js 设计了 dynamic 函数专门用于加载客户端组件。不过加载客户端组件的时候,默认还是会进行预渲染,如果你要取消掉预渲染,需要单独设置 ssr 选项:
const Component = dynamic(() => import('../components/c.js'), { ssr: false })
需要注意的是,当遇到命名导出组件的时候,写法会有些不一样:
'use client'
// components/hello.js
export function Hello() {
return <p>Hello!</p>
}
// app/page.js
import dynamic from 'next/dynamic'
const ClientComponent = dynamic(() =>
import('../components/hello').then((mod) => mod.Hello)
)
像这个例子中引用 @ant-design/charts
便需要命名导出。修改 app/chart/page.js
,代码如下:
'use client'
import dynamic from 'next/dynamic'
const Line = dynamic(() =>
import('@ant-design/charts').then((mod) => mod.Line), { ssr: false }
)
const Page = () => {
const data = [
{ year: '1991', value: 3 },
{ year: '1992', value: 4 },
{ year: '1993', value: 3.5 },
{ year: '1994', value: 5 },
{ year: '1995', value: 4.9 },
{ year: '1996', value: 6 },
{ year: '1997', value: 7 },
{ year: '1998', value: 9 },
{ year: '1999', value: 13 },
];
const config = {
data,
height: 400,
xField: 'year',
yField: 'value',
};
return <Line {...config} />;
};
export default Page;
此时页面能够正常渲染,也不会报错:
3. 使用 window、document 等客户端 API 时
不止引入三方库,当我们在自己的项目代码中使用 window、document 等客户端 API 的时候,也要小心使用。
举个例子,新建 app/window/page.js
,代码如下:
export function Page() {
return (
<div className="p-5">
innerWidth: {window.innerWidth}
</div>
)
}
很明显,此时会出现编译错误:
添加 "use client"
指令,此时页面虽然能够运行,但其实页面已经出现了 500 错误:
命令行中依然还有 window is not defined
错误提示:
相同的错误场景,出错原理也跟上节是一致的:客户端组件在预渲染的时候出错。
3.1. useEffect
要想避免这个错误,第一种方式是使用 useEffect:
'use client'
import { useState, useEffect } from "react";
export default function Page() {
const [width, setWidth] = useState(0)
useEffect(() => {
setWidth(window.innerWidth)
}, [])
return (
<div className="p-5">
innerWidth: {width}
</div>
)
}
当代码放在 useEffect 中的时候,不会在客户端组件预渲染的时候执行。可以看到,此时页面正常渲染:
3.2. typeof window
第二种方式是进行 typeof window
进行判断,新建 app/window2/page.js
,代码如下:
'use client'
export default function Page() {
if (typeof window !== 'undefined') {
window.addEventListener("resize", () => {
console.log(window.innerWidth)
})
}
return (
<div className="p-5">
addEventListener resize
</div>
)
}
交互效果如下:
3.3. dynamic
第三种方式是使用 dynamic,跟上节讲的动态导入方法一致。新建 app/window3/width.js
,代码如下:
export default function With() {
return (
<div className="p-5">
innerWidth: {window.innerWidth}
</div>
)
}
新建 app/window3/page.js
代码如下:
'use client'
import dynamic from 'next/dynamic'
const Width = dynamic(() =>
import('./width'), { ssr: false }
)
export default function Page() {
return <Width />;
};
虽然我们在 <Width />
组件中直接使用了 window 对象,但通过动态导入的方式,页面依然可以正常运行:
3.4. useMounted hook
第四种方式是写一个 useMounted hook,监听页面是否成功挂载。新建 app/window4/useMounted.js
,代码如下:
'use client'
import { useState, useEffect } from 'react'
export function useMounted() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return mounted
}
新建 app/window4/page.js
,代码如下:
'use client'
import { useMounted } from './useMounted'
export default function Page() {
const mounted = useMounted()
if (!mounted) return null
return (
<div className="p-5">
innerWidth: {window.innerWidth}
</div>
)
}
当页面挂载成功的时候,肯定也可以正常获取 window 对象。此时此时页面也能够正常运行:
总结
之所以会出现 window is not defined
、document is not defined
这类错误,本质原因是在服务端调用了客户端 API 导致报错。但使用 'use client'
指令并不一定能够解决问题,'use client'
指令只能说明组件可以运行在客户端,但并不说明组件只运行在客户端。客户端组件会在服务端进行预渲染,如果要取消掉这个预渲染,可以使用 dynamic 这个函数动态加载客户端组件。
同时,在自己的项目中使用客户端 API 如 window、document 的时候,也要注意避免出现这类错误。可以使用 useEffect、typeof window、dynamic、useMounted 等方式进行妥善处理。
本篇内容完整的代码仓库地址为:github.com/mqyqingfeng…
Next.js 系列
本篇已收录在掘金专栏 《Next.js 开发指北》,该系列一共 24 篇。
系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!
此外我还写过 JavaScript 系列、TypeScript 系列、React 系列、Next.js 系列、冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…
通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。