Next.js _document.tsx中window对象undefined 条件渲染GTM问题
我在_document.tsx文件的<Head>组件内引入了Google Tag Manager脚本标签,实现代码如下:
class MyDocument extends Document { render() { return ( <Html> <Head> <script dangerouslySetInnerHTML={{ __html: `<!-- Google Tag Manager -->`, }} /> </Head> <body> <noscript dangerouslySetInnerHTML={{ __html: `<!-- Google Tag Manager (noscript) -->`, }} /> <style dangerouslySetInnerHTML={{ __html: (this.props as any).globalStyles, }} ></style> <Main /> <NextScript /> </body> </Html> ) } }
站点内面向国际用户的页面URL末尾均带有--INTL标识(示例地址:https://www.mystore.com/product--INTL)。需要实现当URL包含--INTL字符串时不渲染Google Tag Manager脚本,理想方案是将GTM脚本封装为独立组件,根据当前页面URL实现条件渲染。
我编写了GTMComponent组件,尝试在componentDidMount生命周期中判断window对象是否存在后获取当前页面URL,再根据URL是否包含--INTL决定是否渲染script标签,随后将该组件引入_document.tsx的Head组件中使用,相关代码如下:
GTM组件代码:
class GTMComponent extends React.Component { let currentUrl; componentDidMount(){ if(typeof window !== "undefined"){ currentUrl = window.location.href; } } render(){ if(!currentUrl.contains("--INTL")){ return ( <script dangerouslySetInnerHTML={{__html: `<!-- Google Tag Manager Script -->`}}/> ) } else { return ( <></> ) } } } export default GTMComponent
引入GTMComponent后的_document.tsx代码:
import GTMComponent from '@components/common/GTMComponent' class MyDocument extends Document { render(){ return( <HTML> <Head> <GTMComponent/> </Head> <body> {/* body code */} </body> </HTML> ) } } export default MyDocument
由于Next.js服务端渲染(SSR)机制,服务端执行代码时不存在window对象,无法直接访问window;同时_document.tsx为类组件无法使用useEffect钩子,且componentDidMount生命周期在_document.tsx中无法正常生效,导致无法完成URL判断的条件渲染逻辑。
_document.tsx仅在服务端执行,不会触发客户端组件生命周期,也无法响应客户端路由变化,靠componentDidMount拿window的方案从逻辑上就行不通,按以下方式修改即可:
- 首屏渲染的路径判断放在
_document.tsx的getInitialProps里做,直接从请求上下文拿URL,不需要访问window对象,首屏就能决定要不要注入GTM脚本,也不会出现SSR和客户端注水内容不匹配的问题 - 客户端路由跳转的判断结合Next.js路由事件实现,监听路径变化动态控制GTM的加载
具体实现步骤
- 修改
_document.tsx,在服务端拿到请求路径,作为参数传给GTM组件:
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document' import GTMComponent from '@components/common/GTMComponent' class MyDocument extends Document { static async getInitialProps(ctx: DocumentContext) { const initialProps = await Document.getInitialProps(ctx) // 服务端直接从请求对象取当前路径,无需访问window const currentPath = ctx.req?.url || '' return { ...initialProps, currentPath } } render() { return ( <Html> <Head> <GTMComponent serverPath={this.props.currentPath} /> </Head> <body> <noscript dangerouslySetInnerHTML={{ __html: `<!-- Google Tag Manager (noscript) 对应代码 -->`, }} /> <style dangerouslySetInnerHTML={{ __html: (this.props as any).globalStyles, }} ></style> <Main /> <NextScript /> </body> </Html> ) } } export default MyDocument
- 重写GTM组件,同时兼容首屏服务端判断和客户端路由跳转判断。注意原有代码存在两个基础错误:一是类组件顶层不能直接写
let currentUrl声明变量,二是JS字符串判断包含的方法是includes(),不存在contains()方法:
import { useRouter } from 'next/router' import { useEffect, useState } from 'react' const GTMComponent = ({ serverPath }: { serverPath: string }) => { const router = useRouter() // 初始状态直接用服务端传入的路径判断,避免注水不匹配 const [loadGTM, setLoadGTM] = useState(!serverPath.includes('--INTL')) useEffect(() => { const handleRouteChange = (url: string) => { const isIntlPage = url.includes('--INTL') setLoadGTM(!isIntlPage) // 非国际页且GTM未初始化时,动态注入脚本 if (!isIntlPage && !(window as any).gtmInited) { // 此处写入实际的GTM脚本注入逻辑 ;(window as any).gtmInited = true } } // 监听客户端路由切换 router.events.on('routeChangeComplete', handleRouteChange) return () => router.events.off('routeChangeComplete', handleRouteChange) }, [router.events]) if (!loadGTM) return null return ( <script dangerouslySetInnerHTML={{ __html: `<!-- 写入实际的Google Tag Manager初始化脚本 -->` }} /> ) } export default GTMComponent
noscript标签的条件渲染逻辑和script标签一致,根据
loadGTM状态判断是否输出即可。
内容的提问来源于stack exchange,提问作者bryanyi

