菜单导航

开发故事
Next.js 中添加 Umami 统计代码的正确方法
开发故事

Next.js 中添加 Umami 统计代码的正确方法

野盐
2025-05-28

一不小心就遇到错误VM12468:1 Uncaught SyntaxError: Failed to execute 'appendChild' on 'Node': Unexpected token '<',原本在layout.tsx中是这样的方式,直接调用后台文本框输入的统计代码:

        {/* 从后台设置注入统计代码 - 直接使用dangerouslySetInnerHTML */}
        {settings.analytics.trackingCode && (
          <Script
            id="analytics-code"
            dangerouslySetInnerHTML={{ __html: settings.analytics.trackingCode }}
            strategy="afterInteractive"
          />
        )}

这里的问题是 next/scriptScript 组件不支持 dangerouslySetInnerHTML 属性,是遇到错误的根本原因。

(最简单的方法):将 Script 改为普通的 div 元素:

{settings.analytics.trackingCode && (
  <div
    dangerouslySetInnerHTML={{ __html: settings.analytics.trackingCode }}
  />
)}

没想到后续不小心有犯了以下低级错误:

Error: In HTML, <div> cannot be a child of <head>. This will cause a hydration error.

问题很明确:<div> 不能作为 <head> 的子元素。统计代码被放在了 <head> 部分。需要将其移到 <body> 中,或者使用正确的方式处理。

为什么会出现这个错误

  • HTML 规范中,<head> 只能包含特定元素(<title>, <meta>, <link>, <script>, <style> 等)

  • <div> 不能直接作为 <head> 的子元素

  • Next.js 的水合过程会检查这种违规行为

所以修复过程中,思考了几种方案来解决:

// 解决方案1:将统计代码移到 body 中(推荐)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <head>
        {/* head 中只放 meta 标签、title、link 等 */}
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        {/* 其他 head 内容 */}
      </head>
      <body>
        {children}
        
        {/* 统计代码放在 body 底部 */}
        {settings.analytics.trackingCode && (
          <div
            dangerouslySetInnerHTML={{ __html: settings.analytics.trackingCode }}
          />
        )}
      </body>
    </html>
  )
}

// 解决方案2:如果统计代码必须在 head 中,需要解析后正确处理
import { Metadata } from 'next'

// 解析统计代码中的脚本
function parseScriptFromTrackingCode(trackingCode: string) {
  const scriptRegex = /<script([^>]*)>(.*?)<\/script>/is
  const match = trackingCode.match(scriptRegex)
  
  if (match) {
    const attributes = match[1]
    const content = match[2]
    
    // 解析属性
    const srcMatch = attributes.match(/src=["']([^"']+)["']/)
    const deferMatch = attributes.includes('defer')
    const asyncMatch = attributes.includes('async')
    
    return {
      src: srcMatch ? srcMatch[1] : null,
      content: content.trim(),
      defer: deferMatch,
      async: asyncMatch,
      attributes
    }
  }
  
  return null
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const scriptInfo = settings.analytics.trackingCode 
    ? parseScriptFromTrackingCode(settings.analytics.trackingCode)
    : null

  return (
    <html lang="zh-CN">
      <head>
        {/* 正确处理外部脚本 */}
        {scriptInfo?.src && (
          <script
            src={scriptInfo.src}
            defer={scriptInfo.defer}
            async={scriptInfo.async}
          />
        )}
        
        {/* 正确处理内联脚本 */}
        {scriptInfo?.content && !scriptInfo.src && (
          <script
            dangerouslySetInnerHTML={{ __html: scriptInfo.content }}
          />
        )}
      </head>
      <body>
        {children}
      </body>
    </html>
  )
}

// 解决方案3:使用客户端组件处理复杂情况
'use client'
import { useEffect } from 'react'

interface DynamicAnalyticsProps {
  trackingCode: string
}

function DynamicAnalytics({ trackingCode }: DynamicAnalyticsProps) {
  useEffect(() => {
    if (!trackingCode) return

    // 创建临时容器解析 HTML
    const parser = new DOMParser()
    const doc = parser.parseFromString(trackingCode, 'text/html')
    const scripts = doc.querySelectorAll('script')

    scripts.forEach(script => {
      const newScript = document.createElement('script')
      
      // 复制所有属性
      Array.from(script.attributes).forEach(attr => {
        newScript.setAttribute(attr.name, attr.value)
      })
      
      // 复制内容
      if (script.textContent) {
        newScript.textContent = script.textContent
      }
      
      // 添加到 head 或 body
      if (script.hasAttribute('defer') || script.hasAttribute('async')) {
        document.head.appendChild(newScript)
      } else {
        document.body.appendChild(newScript)
      }
    })

    // 清理函数
    return () => {
      // 清理动态添加的脚本(可选)
      document.querySelectorAll('script[data-dynamic="true"]').forEach(s => s.remove())
    }
  }, [trackingCode])

  return null
}

// 在 layout.tsx 中使用
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <head>
        {/* head 内容 */}
      </head>
      <body>
        {children}
        
        {/* 使用客户端组件 */}
        {settings.analytics.trackingCode && (
          <DynamicAnalytics trackingCode={settings.analytics.trackingCode} />
        )}
      </body>
    </html>
  )
}

// 解决方案4:最简单的修复 - 仅移动位置
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <head>
        {/* 不要在这里放统计代码的 div */}
      </head>
      <body>
        {children}
        
        {/* 从 head 移动到 body 底部 */}
        {settings.analytics.trackingCode && (
          <div
            dangerouslySetInnerHTML={{ __html: settings.analytics.trackingCode }}
          />
        )}
      </body>
    </html>
  )
}

// 解决方案5:使用 next/head(如果需要在 head 中)
import Head from 'next/head'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <Head>
        {/* 使用 next/head 处理 head 内容 */}
        {settings.analytics.trackingCode && (
          <script
            dangerouslySetInnerHTML={{ __html: settings.analytics.trackingCode.replace(/<\/?script[^>]*>/g, '') }}
          />
        )}
      </Head>
      <body>
        {children}
      </body>
    </html>
  )
}

在 Next.js 中,不建议在 layout.tsx 中使用 DOM 操作方法(如 document.createElement),因为服务端渲染时 document 不存在。以下是 Next.js 中添加 Umami 统计代码的正确方法:

// 方法1:在 layout.tsx 中直接使用 Script 组件(推荐)
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        
        {/* Umami 统计代码 */}
        <Script
          src="https://cloud.umami.is/script.js"
          data-website-id="fdf9dc7b-d730-4230-94a4-39cfa679ab3a"
          strategy="afterInteractive"
          defer
        />
      </body>
    </html>
  )
}

// 方法2:创建单独的组件
// components/Analytics.tsx
import Script from 'next/script'

export default function Analytics() {
  return (
    <Script
      src="https://cloud.umami.is/script.js"
      data-website-id="fdf9dc7b-d730-4230-94a4-39cfa679ab3a"
      strategy="afterInteractive"
      defer
    />
  )
}

// 然后在 layout.tsx 中使用
import Analytics from '@/components/Analytics'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

// 方法3:使用环境变量控制(生产环境才加载)
import Script from 'next/script'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        
        {/* 只在生产环境加载统计代码 */}
        {process.env.NODE_ENV === 'production' && (
          <Script
            src="https://cloud.umami.is/script.js"
            data-website-id="fdf9dc7b-d730-4230-94a4-39cfa679ab3a"
            strategy="afterInteractive"
            defer
          />
        )}
      </body>
    </html>
  )
}

// 方法4:使用 next/head(适用于 pages 目录)
// 如果您使用的是 pages 目录而不是 app 目录
import Head from 'next/head'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Head>
        <script
          defer
          src="https://cloud.umami.is/script.js"
          data-website-id="fdf9dc7b-d730-4230-94a4-39cfa679ab3a"
        />
      </Head>
      {children}
    </>
  )
}

// 方法5:在 _document.tsx 中添加(pages 目录)
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script'

export default function Document() {
  return (
    <Html>
      <Head>
        <script
          defer
          src="https://cloud.umami.is/script.js"
          data-website-id="fdf9dc7b-d730-4230-94a4-39cfa679ab3a"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

推荐方案

  1. App Router(app 目录):使用方法1,直接在 layout.tsx 中使用 next/scriptScript 组件

  2. Pages Router(pages 目录):使用方法4或5,在 _app.tsx_document.tsx 中添加

关键优势

  • next/script 组件会自动处理服务端渲染兼容性

  • strategy="afterInteractive" 确保脚本在页面交互就绪后加载

  • 避免了直接 DOM 操作的问题

  • 更好的性能优化

strategy 选项说明

  • beforeInteractive:在页面交互前加载(阻塞)

  • afterInteractive:在页面交互后加载(推荐)

  • lazyOnload:空闲时加载

  • worker:在 Web Worker 中加载

版权声明

本文为「野盐」原创内容,图片个人摄影 / 手绘 / AIGC,后期 PhotoMator / Procreate,版权归「野盐」所有。未经授权,禁止用于商业用途,禁止抹除水印,禁止转发至小红书等平台。转载请注明出处与链接并保留本声明。

...

评论 (0)

评论将以随机ID显示
野盐

野盐

@wildsalt.me

推荐阅读

Automate reddit video creation with n8n待验证
开发故事

Automate reddit video creation with n8n待验证

野盐 | 2025-06-11

"用n8n自动化工具将Reddit帖子转化为视频的完整工作流定义,包含从内容抓取到视频渲染的每个技术细节"

7
解决 AI 编程助手多轮对话后幻觉和代码乱改问题的策略
开发故事

解决 AI 编程助手多轮对话后幻觉和代码乱改问题的策略

野盐 | 2025-06-06

AI代码助手不是魔法师,而是需要精确引导的学徒——当它开始‘自由发挥’,你需要更严格的对话管理和边界设定。

10
Claude失控,所有源代码被其删除。FUCK!
开发故事

Claude失控,所有源代码被其删除。FUCK!

野盐 | 2025-06-04

技术简化变成粗暴删除:AI的‘最优解’,给我带来一次代码被删的愤怒与反思

7