不会被渲染的组件,为什么让我的首屏白屏了?(一次前端问题总结)

介绍

在 React 项目的性能优化议题中,useMemo 和 useCallback 往往是讨论的焦点。然而在实际工程实践中,真正因缺失这两个 hook 而导致性能瓶颈的场景并不多见。

开发不论是在学习,还是面试,这两个hook 已经烂熟于心。以至于不论需不需要使用这两个hook,都会习惯性为了性能在日常代码中加上,有时反而增加了代码的认知负担。

从我的个人经历来说,对于大多数常规前端项目而言,碰到的性能问题并不多。主要有以下原因:

  • 底层运行时的高度优化:现代 JavaScript 引擎与 React 框架本身进行了很多优化,足以覆盖绝大多数业务场景的渲染需求。

  • 工程化工具链的兜底保障:主流开发脚手架及框架已内置了路由懒加载、代码分割等最佳实践,在既定架构下编写业务代码,通常无需额外干预即可获得良好的基线性能。

  • 应用上下文的独立性:此处所指的"常规项目"特指独立部署、运行的单体应用,不涉及微前端嵌入等复杂运行时环境,因此规避了许多由宿主容器引发的性能损耗。

所以大多数的时候,大家面试或者自己的日常学习会接触各种关于性能的内容。但是实际上日常开发中,很少会用到。

这两天恰好碰到了项目首屏空白的问题,触发的原因居然是从没在意过的代码加载过程。同大家分享一下。

性能分析

出现问题的第一步,就是去看chrome 的performance 。看一下页面的耗时情况。

业务代码的js 执行时间达到了1s 的时间,这个是纯业务代码。

而首屏的逻辑非常简单,不应该运行这么多的代码。

顺着调用桟看,会发现全是_webapc_require_,反而没有真实的代码运行耗时,而这些_webapc_require_ 加载的模块全都不是首屏需要的代码。

为了理清这些模块加载的问题,接下来看看前端的拆包机制。

前端拆包机制

前端体系中,代码拆分(Code Splitting)是核心策略之一。主要分为动态 import() 语法与组件级动态导入两个层面。

静态导入 vs. 动态导入

在引入第三方库或内部模块时,动态导入与静态导入两种方式下,其打包产物与运行时行为存在本质差异:

静态导入

import X from 'lodash'

打包行为:构建工具(如 Webpack)会将该模块及其依赖直接合并至当前代码块(Chunk)中。

运行时表现:生成同步加载代码(如 webpack_require(moduleId)),模块在脚本执行阶段即被同步解析与初始化。

影响:增加首屏关键路径体积,适用于核心依赖或高频使用模块。

动态导入

import('lodash')

打包行为:构建工具会将该模块及其依赖抽取为独立的 Chunk 文件,并在主 Chunk 中保留异步加载入口。

运行时表现:生成异步加载逻辑(如 webpack_require.e(chunkId).then(...)),通过 JSONP 或 Fetch 按需拉取独立 Chunk,返回一个 Promise。

影响:将非关键依赖从首屏剥离,实现真正的按需加载,有效降低初始包体积。

webpack_require.e 的简化实现如下:

ini 复制代码
__webpack_require__.e = function(chunkId) {

  return new Promise((resolve, reject) => {

    var script = document.createElement('script');

    script.src = chunkId + '.async.js';

    script.onload = resolve;

    script.onerror = reject;

    document.head.appendChild(script);  // 动态插入 script 标签

  });

};

静态导入示例

组件静态导入lodash

csharp 复制代码
import { map, shuffle } from "lodash";
export default function MyPage() {
  const nums = [1, 2, 3, 4, 5];
  const doubled = map(nums, n => n * 2);
  const shuffled = shuffle(doubled);
  return (
    <div>
      <p>doubled: {doubled.join(",")}</p >
      <p>shuffled: {shuffled.join(",")}</p >
    </div>
  );
}

打包后产物大概的样子

javascript 复制代码
__webpack_require__.r(__webpack_exports__);
// EXTERNAL MODULE: ./node_modules/lodash/lodash.js
var lodash = __webpack_require__(81764);
//                     ^^^^^^
//          同步调用:lodash 必须已经下载并执行完,否则这里拿不到模块
function AboutPage() {
  var nums = [1, 2, 3, 4, 5];
  var doubled = (0, lodash.map)(nums, function (n) { return n * 2; });
  var shuffled = (0, lodash.shuffle)(doubled);
  return /* jsx */ createElement("div", null, "doubled: " + doubled.join(","));
}

动态导入示例

组件在useEffect 中动态导入lodash

typescript 复制代码
import { useState, useEffect } from "react";
export default function MyPage() {
  const [data, setData] = useState<{ doubled: number[]; shuffled: number[] } | null>(null);
  useEffect(() => {
    // 调用时才发起请求加载 lodash chunk
    import("lodash").then(({ map, shuffle }) => {
      const nums = [1, 2, 3, 4, 5];
      const doubled = map(nums, n => n * 2);
      const shuffled = shuffle(doubled);
      setData({ doubled, shuffled });
    });
  }, []);
  return (
    <div>
      <p>doubled: {data?.doubled.join(",") ?? "loading..."}</p >
      <p>shuffled: {data?.shuffled.join(",") ?? "loading..."}</p >
    </div>
  );
}

打包后产物大概的样子

ini 复制代码
__webpack_require__.r(__webpack_exports__);
var react = __webpack_require__(75271);  // 同步引用 React(小,进主包)
function AboutPage() {
  var _useState = (0, react.useState)(null),
      data = _useState[0],
      setData = _useState[1];
  (0, react.useEffect)(function () {
    // 动态导入:返回 Promise,调用时才下载 chunk
    __webpack_require__.e(/* import() | lodash */ 764)              // ① 异步下载 chunk 764
      .then(__webpack_require__.t.bind(__webpack_require__, 81764, 23))  // ② 然后取模块 81764
      .then(function (_ref) {
        var map = ref.map, shuffle = ref.shuffle;
        var nums = [1, 2, 3, 4, 5];
        var doubled = map(nums, function (n) { return n * 2; });
        var shuffled = shuffle(doubled);
        setData({ doubled: doubled, shuffled: shuffled });
      });
  }, []);
  return /* jsx */ createElement(
    "div", null,
    "doubled: " + (data === null ? "loading..." : data.doubled.join(","))
  );
}

两段代码的关键差异:

维度 改造前(同步) 改造后(动态)
调用形式 webpack_require(id) webpack_require.e(chunkId).then(...)
执行时机 模块加载时立即执行 useEffect 触发时执行
是否阻塞渲染 阻塞(必须等 lodash 就绪) 不阻塞(先渲染页面壳)

组件导入

某个重组件(如 HeavyComponent)内部同步引入了一个体积庞大的第三方库(例如 lodash),导致主包体积膨胀。然而,出于稳定性或维护成本的考量,我们往往不希望修改组件内部的 import 语句。

HeavyComponent.tsx 复制代码
import { map, shuffle } from "lodash";
export default function HeavyComponent() {
  const doubled = map([1, 2, 3], n => n * 2);
  const shuffled = shuffle(doubled);
  return <div>{shuffled.join(",")}</div>;
}
AboutPage.tsx 复制代码
import HeavyComponent from "@/components/HeavyComponent";  // 同步导入
export default function AboutPage() {
  return <HeavyComponent />;
}

只需将组件的引入方式改为 React.lazy(() => import('./HeavyComponent'))

AboutPage.tsx 复制代码
import { lazy, Suspense } from "react";
const HeavyComponent = lazy(() => import("@/components/HeavyComponent"));
export default function AboutPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

构建工具便会自动执行以下操作:

创建独立 Chunk:为 HeavyComponent 生成专属的代码块。

递归切割依赖:该组件内部所有的同步依赖(包括 lodash 等大库)会被一并移入该独立 Chunk,而不会残留在主入口文件中。

零代码改动:HeavyComponent 内部的任何 import 语句均无需调整,完全保持原样。

前端代码一般都会使用这种方式完成代码拆分和懒加载的功能。

比如umijs 的路由,里面的代码就是使用组件的动态import 来进行拆包。

问题排查:Context 策略模式引发的首屏白屏

基于前文所述的拆包原理,我们定位到了首屏白屏的根本原因:静态导入导致的依赖链全量加载。

比如组件 A 在代码开始使用了import 的方式,静态导入了组件B。即使组件A 不会渲染组件 B,但只要存在顶层的静态 import 语句,构建工具就会将组件 B 及其整个子依赖树(包括嵌套的 node_modules 库)同步打入主 Chunk。

加载组件A 时,整条依赖链上的代码都会同步加载。

那么,为什么存在这种"导入了却不渲染"的冗余代码?

比如在我们项目中的下面代码。首页完全不会使用editor 相关代码。

这源于项目使用到的 React Context 策略模式:

app.tsx - 问题代码示例

javascript 复制代码
import { AProvider } from './AProvider'; // ❌ 静态导入,无论条件如何都会被加载
import { BProvider } from './BProvider'; // ❌ 同上

const App = () => {
  const dependencyMap = useMemo(() => ({
    [Condition.A]: AProvider,
    [Condition.B]: BProvider,
  }), []);

  return (
    <DependencyContext.Provider value={dependencyMap}>
      <Outlet />
    </DependencyContext.Provider>
  );
};

在这种模式下,AProvider 和 BProvider 作为对象值被注入 Context,供底层业务组件按需消费。但问题在于,Context 只控制了"运行时渲染",却无法控制"构建时打包"。 当 app.tsx 作为入口文件被加载时,两个 Provider 及其背后的重组件链已全部同步执行,即便当前条件仅命中其一,另一个也已完成加载与初始化,直接导致首屏关键路径过长,触发白屏。

解决方案:动态导入切断同步依赖链

既然问题出在静态导入,解决思路便十分明确------将策略映射表中的组件改为动态导入,使构建工具为每个 Provider 生成独立 Chunk:

app.tsx - 优化后代码

javascript 复制代码
import { lazy } from 'react';

const AProvider = lazy(() => import('./AProvider')); // ✅ 独立 chunk,按需加载
const BProvider = lazy(() => import('./BProvider')); // ✅ 独立 chunk,按需加载

const App = () => {
  const dependencyMap = useMemo(() => ({
    [Condition.A]: AProvider,
    [Condition.B]: BProvider,
  }), []);

  return (
    <DependencyContext.Provider value={dependencyMap}>
      <Outlet />
    </DependencyContext.Provider>
  );
};

Context 策略模式解决了运行时的条件分发问题,但不能替代构建时的代码拆分。当策略选项本身是重组件时,必须显式使用 lazy() 建立异步边界,否则"按需渲染"只是视觉上的按需,而非网络层面的按需。

修改后,项目的加载时间降到了40 毫秒,直接提升了25 倍。

SplitChunks

SplitChunks 也是拆包,他和上面的import 拆包有什么区别呢?

SplitChunksPlugin 是物理层面的文件拆分,而非逻辑层面的加载方式变更。被抽离的模块如果原本是被同步引用的,拆分后依然是同步加载------只是从主 Chunk 移到了另一个同步 Chunk 中。浏览器仍需在该同步点等待新 Chunk 就绪,才能继续执行。

即使 SplitChunks 将大模块从主包中抽出,若该模块处于同步调用链上,浏览器仍会在执行到 webpack_require 时阻塞后续逻辑,直到对应 Chunk 下载并解析完成。

所以不能简单认为拆包越细,效率越高。

总结

在前端性能优化的讨论中,大家面试或者学习时往往高度集中于渲染层,如减少重排重绘、优化虚拟 DOM diff、避免不必要的组件更新等。这些固然重要,但它们仅覆盖了性能问题的"后半程"。

事实上,代码加载与模块初始化本身就是不可忽视的性能瓶颈。webpack_require 及其背后的 Chunk 加载机制,也可能更严重的性能瓶颈。

这件事情让我想起来了,几年看过的文章《the cost of small modules》。这里面发现webpack 的打包机制存在性能问题。由于这个原因,大佬rich harris 开发了rollup 来解决这个问题,后面又去开发了svelte。要是有兴趣,后面再写一下这个故事。

相关推荐
烬羽1 小时前
《memo 明明加了,为什么子组件还是渲染了?》
react.js·性能优化·全栈
码上成长1 小时前
微前端 Invalid hook call?先把「window 注入 + externals」整明白
前端·react.js·前端框架
宿6741 小时前
vue3-DOM树
前端·javascript·vue.js
白狐_7982 小时前
408数据结构:中缀转后缀与操作符栈容量——真题精讲
前端·javascript·数据结构
谷哥的小弟2 小时前
TypeScript在Vue3中的常见写法
前端·javascript·typescript
人间凡尔赛2 小时前
2026 前端全栈新范式:Server Actions + Edge Runtime,告别传统 API 路由
前端·全栈·next.js
码林鼠5 小时前
2026前端面试题(二)
前端
满栀5856 小时前
请求拦截、响应拦截、业务错误统一处理
前端·typescript·anti-design-vue
宿6747 小时前
tsconfig.node.json
前端·javascript·vue.js