文章摘要
加载中...|
此内容根据文章生成,并经过人工审核,仅用于文章内容的解释与总结 投诉

一、pageExtensions 存在的意义

Next.js 的页面路由系统(无论是传统的 pages 目录还是 App Router 的 app 目录)均基于文件系统实现。其核心需求是精准识别项目中用于生成路由的 "页面组件",而 pageExtensions 配置的核心作用就是:明确告知 Next.js 哪些扩展名的文件可能是页面文件

这一机制从根源上解决了 "如何区分业务代码与路由组件" 的问题,确保框架能高效定位路由相关文件。

二、pageExtensions 的工作原理

在 Next.js 的构建流程(next build)中,扫描并处理页面文件是核心步骤之一(包括生成静态 HTML、服务端代码、中间件等),最终需要建立页面路径与组件的映射关系。

pageExtensions 的作用机制可概括为:

  • 构建时,Next.js 会仅扫描带有指定扩展名的文件,直接过滤掉不在列表中的文件(如 .json.css 等)
  • 减少无意义的文件检查(如判断是否导出默认组件、是否符合路由规则等),降低计算成本
  • 若不配置,Next.js 会对所有默认扩展名文件进行检查,可能导致构建效率下降

三、默认配置值

TypeScript
// packages/next/src/server/config-shared.ts
export const defaultConfig = {
  ...
  pageExtensions: ['tsx', 'ts', 'jsx', 'js'],  // pageExtensions 默认值
  ...
}

默认情况下,Next.js 会将 .tsx.ts.jsx.js 四种扩展名的文件视为潜在的页面组件。

四、源码层面的实现逻辑

1. 配置解析流程

TypeScript
// packages/next/src/server/config-shared.ts
export async function normalizeConfig(phase: string, config: any) {
  if (typeof config === 'function') {
    config = config(phase, { defaultConfig })
  }
  // Support `new Promise` and `async () =>` as return values of the config export
  return await config
}

// packages/next/src/server/config.ts
const loadedConfig = Object.freeze(
  (await normalizeConfig(
    phase,
    interopDefault(userConfigModule)
  )) as NextConfig
)

配置解析时,pageExtensions 会与其他配置项一起被规范化处理,最终整合到 Next.js 的完整配置对象中,供后续流程使用。

2. 整合 Layout 静态信息

TypeScript
export async function getStaticInfoIncludingLayouts({
  isInsideAppDir,
  pageExtensions,
  pageFilePath,
  appDir,
  config: nextConfig,
  isDev,
  page,
}: {
  isInsideAppDir: boolean
  pageExtensions: PageExtensions
  pageFilePath: string
  appDir: string | undefined
  config: NextConfigComplete
  isDev: boolean | undefined
  page: string
}): Promise<PageStaticInfo> {
  ...

  // inherit from layout files only if it's a page route
  if (isAppPageRoute(page)) {
    const layoutFiles = []
    const potentialLayoutFiles = pageExtensions.map((ext) => 'layout.' + ext)
    // 这里将扩展名映射为了可以支持的 layout.[ext] 文件名,例如配置tsx,jsx;那么这个集合包含了「layout.tsx, layout.jsx」
    let dir = dirname(pageFilePath)

    ...
}

在处理 Layout 相关信息时,pageExtensions 会被用于生成可能的布局文件名称(如 layout.tsxlayout.js 等),确保框架能正确识别布局组件。

3. 页面文件路径生成

TypeScript
/**
 * Calculate all possible pagePaths for a given normalized pagePath along with
 * allowed extensions. This can be used to check which one of the files exists
 * and to debug inspected locations.
 *
 * For pages, map `/route` to [`/route.[ext]`, `/route/index.[ext]`]
 * For app paths, map `/route/page` to [`/route/page.[ext]`] or `/route/route`
 * to [`/route/route.[ext]`]
 *
 * @param normalizedPagePath Normalized page path (it will denormalize).
 * @param extensions Allowed extensions.
 */
export function getPagePaths(
  normalizedPagePath: string,
  extensions: string[],
  isAppDir: boolean
) {
  const page = denormalizePagePath(normalizedPagePath)

  let prefixes: string[]
  // 两种路由模式不同的处理
  /*
  *如果是 app 目录,只用 page 本身作为前缀。
  *如果是 pages 目录且路径以 /index 结尾,只用 page/index 作为前缀。
  *否则,既用 page 也用 page/index 作为前缀。
  */
  if (isAppDir) {
    prefixes = [page]
  } else if (normalizedPagePath.endsWith('/index')) {
    prefixes = [path.join(page, 'index')]
  } else {
    prefixes = [page, path.join(page, 'index')]
  }

  const paths: string[] = []
  for (const extension of extensions) {
    for (const prefix of prefixes) {
      paths.push(`${prefix}.${extension}`)
    }
  }

  // ⬆️这里最后会返回包含符合pageExtensions的可能存在的页面路径数组
  return paths
}

该函数根据 pageExtensions 生成所有可能的页面文件路径(如 /about.tsx/about/index.js 等),为后续的文件存在性检查提供候选列表。

4. 页面文件匹配与校验(核心优化逻辑)

TypeScript
/**
 * Finds a page file with the given parameters. If the page is duplicated with
 * multiple extensions it will throw, otherwise it will return the *relative*
 * path to the page file or null if it is not found.
 *
 * @param pagesDir Absolute path to the pages folder with trailing `/pages`.
 * @param normalizedPagePath The page normalized (it will be denormalized).
 * @param pageExtensions Array of page extensions.
 */
export async function findPageFile(
  pagesDir: string,
  normalizedPagePath: string,
  pageExtensions: PageExtensions,
  isAppDir: boolean
): Promise<string | null> {
  // 第一步这里拿到的就是 1.4.3 最后返回的符合 pageExtensions 的可能存在页面组件路径
  const pagePaths = getPagePaths(normalizedPagePath, pageExtensions, isAppDir)

  // 这里的判空逻辑蛮优雅的,数组解构拿到了第一项,然后下面都是一系列的验证这个可能路径上的文件是否存在,当然如果存在的话,就是页面组件了
  const [existingPath, ...others] = (
    await Promise.all(
      pagePaths.map(async (path) => {
        const filePath = join(pagesDir, path)
        try {
          return (await fileExists(filePath)) ? path : null
        } catch (err: any) {
          if (!err?.code?.includes('ENOTDIR')) throw err
        }
        return null
      })
    )
  ).filter(nonNullable)

  if (!existingPath) {
    return null
  }

  if (!(await isTrueCasePagePath(existingPath, pagesDir))) {
    return null
  }

  // 在上面,我们已经排空了,那么如果同一目录下,如果出现多个pageExtensions支持的页面组件文件,
  // 例如:同文件夹下存在「page.tsx, page.jsx」,那么,nextjs会直接报出下面的警告,「会有两个文件被解析成同一个路由」。
  if (others.length > 0) {
    warn(
      `Duplicate page detected. ${cyan(join('pages', existingPath))} and ${cyan(
        join('pages', others[0])
      )} both resolve to ${cyan(normalizedPagePath)}.`
    )
  }

  return existingPath
}

这是 pageExtensions 实现优化的核心逻辑:通过过滤出仅符合指定扩展名的文件,大幅减少需要检查的文件数量,同时处理了文件重复定义等异常情况。

5. 元数据路由文件匹配

TypeScript
// packages/next/src/lib/metadata/is-metadata-route.ts
/**
 * Determine if the file is a metadata route file entry
 * @param appDirRelativePath the relative file path to app/
 * @param pageExtensions the js extensions, such as ['js', 'jsx', 'ts', 'tsx']
 * @param strictlyMatchExtensions if it's true, match the file with page extension, otherwise match the file with default corresponding extension
 * @returns if the file is a metadata route file
 */
export function isMetadataRouteFile(
  appDirRelativePath: string,
  pageExtensions: PageExtensions,
  strictlyMatchExtensions: boolean
) {
  // End with the extension or optional to have the extension
  // When strictlyMatchExtensions is true, it's used for match file path;
  // When strictlyMatchExtensions, the dynamic extension is skipped but
  // static extension is kept, which is usually used for matching route path.
  const trailingMatcher = (strictlyMatchExtensions ? '' : '?') + '$'
  // Match the optional variants like /opengraph-image2, /icon-a102f4.png, etc.
  const variantsMatcher = '\\d?'
  // The -\w{6} is the suffix that normalized from group routes;
  const groupSuffix = strictlyMatchExtensions ? '' : '(-\\w{6})?'

  const suffixMatcher = `${variantsMatcher}${groupSuffix}`

  // 这里生成一些元数据路由文件扩展名与 pageExtensions 中的扩展名的正则集合
  const metadataRouteFilesRegex = [
    new RegExp(
      `^[\\\\/]robots${getExtensionRegexString(
        pageExtensions.concat('txt'),
        null
      )}${trailingMatcher}`
    ),
    new RegExp(
      `[\\\\/]${STATIC_METADATA_IMAGES.twitter.filename}${suffixMatcher}${getExtensionRegexString(
        STATIC_METADATA_IMAGES.twitter.extensions,
        pageExtensions
      )}${trailingMatcher}`
    ),
    ...
  ]

  const normalizedAppDirRelativePath = normalizePathSep(appDirRelativePath)

  const matched = metadataRouteFilesRegex.some((r) =>
    r.test(normalizedAppDirRelativePath)
  )
  // 最后得到的 matched 就是匹配成功的元数据路由文件
  return matched
}

在处理元数据路由(如 robots.txtopengraph-image.png 等)时,pageExtensions 会与元数据文件特有的扩展名结合,确保框架能正确识别这类特殊路由文件。

五、社区讨论(配置思路和可能存在的风险)

1、团队规范

两种路由模式配置相关

JavaScript
// ⬇️ App router 基于文件系统的约定,它并不需要进行 pageExtensions 配置
// 除非你需要使用 MDX OR 路由模式混合(同时使用app router,pages router)
module.exports = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"]
}



// ⬇️ Pages router
module.exports = {
  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js']
}
// 或者 区分服务端组件、客户端组件和API
module.exports = {
  pageExtensions: ['server.tsx', 'client.tsx', 'api.ts']
};

2、How do I render a table with next mdx? #77961

在配置pageExtensions中加入["md", "mdx"]想要使用mdx-components时,在NextJS15的开发模式使用turbopack的情况下,会导致报错,原因看上去是turbopack与mdxRs有冲突,至今未解决的discussion.

JavaScript
import remarkGfm from "remark-gfm";
import createMDX from "@next/mdx";

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};

const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [],
  },
});

export default withMDX(nextConfig);

六、Demo Show

项目 Demo:配置 pageExtensions 支持 mdx 页面组件 https://github.com/indulgeback/telos/commit/a781f0777289c2676bb41696850c8d55dff341ff App router 模式必须配置 mdx-components.tsx,与 /app 同级(也可在 next.config.ts 做如下配置)

ts
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import createMDX from "@next/mdx";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";

const withNextIntl = createNextIntlPlugin();

const nextConfig: NextConfig = {
  pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
};

const withMDX = createMDX({
  extension: /\.(md|mdx)$/,
  options: {
    providerImportSource: "@/components/mdx-components",
    remarkPlugins: [remarkGfm],
    rehypePlugins: [rehypePrismPlus],
  },
});

export default withMDX(withNextIntl(nextConfig));

参考文档

https://nextjs.org/docs/14/app/api-reference/next-config-js/pageExtensions

https://github.com/vercel/next.js/blob/canary/packages/next/src/build/entries.ts

https://github.com/vercel/next.js/blob/canary/packages/next/src/build/utils.ts

https://www.meje.dev/blog/page-extensions-in-nextjs

https://stackoverflow.com/questions/76715822/set-next-js-pageextensions-to-not-build-dev-pages

赞赏博主
评论 隐私政策