Loading...

为什么我选择Nuxt

2025-04-07 · 18min

省流:除了 React 性能优化外基本毫无营养。

之前在 《Nuxtjs搭建个人博客》中提到过,我将我的 blog 从 Nextjs 转到 Nuxtjs 进行开发。转的原因其实也工作中主观地感受到, Vue3 在开发体验以及性能上,确实要比 React 要舒服要好。

我的 blog 其实还有过 vuepress 、vitepress 和 Docusaurus ,加上 Next 和现在的 Nuxt 我其实开发过5个版本的 blog了。

早期还在上学的时候,因为当年还是 vue2 的年代,所以自然也就用了 vuepress 。

到了20年,vue3 正式发布,伴随的还有beta版本的 vitepress,于是便尝鲜切换到了 vitepress ,结果 vitepress 似乎是在去年才正式发布???

后面因为工作的原因使用 react, 我也开始寻找类似 vitepress 的 React 框架了,最后找到了 Docusaurus 。

去年因为不满意 Docusaurus 这类文档框架的样式,虽然可以自己自定义开发主题,但是其开发规则非常复杂,文档内容也很多没讲清楚,于是干脆学习 Next 自己从零开始搭建自己的 blog了。

但是 Next 的开发体验,我个人来说只能说非常糟糕,以至于我仅仅因为好奇 Nuxt,接触20分钟不到立刻当机立断换到 Nuxt 开发 blog。

与 Nuxt Modules 的对比

Next 开发体验对我来说糟糕的地方最终就是在于配套生态上,Nuxt 存在 Nuxt Modules 这样官方维护的插件模块,提供了统一的安装、配置以及管理方式。官方已经将模块的复杂配置进行封装,开发者只需要通过 nuxt.config.ts 进行简单配置就可以启用需要的功能。

反观 Next 这边生态更大说依赖社区维护的第三方库,并没有官方统一管理,插件的质量、文档说明完善度,重复造轮子等,都严重影响了我的开发体验。我觉得这个现象跟国内 Vue 生态蛮像的,生态庞大但混乱,重复造轮子的事情并不是只有国内才有,国外同样也有。

我知道 Next 设计初衷就是允许开发者自由选择工具链进行开发,但是官方并没有起到一个很好的带头或管理的作用,导致了生态过于碎片化,没能在灵活性与统一性之间做到一个平衡。

就从结果来说,Nuxt 与 Next 在国内 ssr 市场都不大的情况下,Nuxt 开发体验明显是要更好的。

React 的重复渲染

我一直觉得无论是 React 还是 Vue ,说到底对于我们开发者来说都只是一个开发工具而已,自己觉得哪个好用就用哪个就行。

直到去年我开始关注 React 代码层面的性能优化,我发现了这么几种情况:

重复渲染

function OtherComponent() {
  console.log("Other Component is rendered");

  return <p>I'm other component</p>;
}

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
      </div>
      <OtherComponent />
    </>
  );
}

以上面这段代码为例,OtherComponent 与 count 无关的情况下,当 count 发生改变时,仍然会导致其跟着重复渲染。

image-20250406163705618

当然解决办法也是有的,把它放到 memo 中不就好了吗?

function App() {
  const [count, setCount] = useState(0);

  const MemoComponent = useMemo(() => {
    console.log("memo Component is rendered");

    return <p>I'm memo component</p>;
  }, []);

  return (
    <>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
      </div>
      {MemoComponent}
    </>
  );
}

这样确实不会导致子组件重复渲染了,但这里只是一个子组件,实际业务中可能有很多大量子组件,如果每个都去套一遍 memo,工作量不仅繁琐,而且写法还非常不美观,代码可读性也非常糟糕。

状态下移

这里我提供一个很多人可能日常写代码时不经意用到过,但是没有注意到的方法,那就是“状态下移”。

只需要将 count 相关的另外封装成一个组件,变成 OtherComponent 的兄弟组件,这样 count 变化时,就不会导致 OtherComponent 重复渲染了。

function OtherComponent() {
  useEffect(() => {
    console.log("Other Component is mounted");

    return () => console.log("Other Component is unmounted");
  }, []);

  console.log("Other Component is rendered");

  return <p>I'm other component</p>;
}

function CountComponent() {
  const [count, setCount] = useState(0);

  return (
    <div className="card">
      <button onClick={() => setCount((count) => count + 1)}>
        count is {count}
      </button>
    </div>
  );
}

function App() {
  return (
    <>
      <CountComponent />
      <OtherComponent />
    </>
  );
}

这个思路确实非常简单,但在我工作5年来,发现很多人代码确实存在这种写法,但是又在可以用这种方法的地方,用了大量的 memo 去包裹子组件。

我觉得这很大程度是受国内市场环境影响吧,大家更倾向于快速实现功能,性能优化都是往后再谈,加上需求变更频繁,导致自己其实并没有意识到,上面这样的写法,其实也是一种 React 的代码优化思路,而且比起官方推荐的 memo 可读性要更强。

与 memo 相比

上面只是一种思路,实际上业务中确实并不是任何情况都可以适用的,比如一个页面里面存在两个图表,但是数据都通过一个接口返回,这种情况下我不可能两个图表都去请求一边接口的,只能是在父组件中请求好数据后,将各自所需的数据通过 prop 传递给图表组件。

所以这种情况下,memo 确实是更好的解决办法(不过我实际开发过程中是会利用 alova 的合并请求功能,在两个图表组件中各自进行请求,但最终上只会发送一个请求)。

但我还是觉得不够满意,如果图表依赖的数据过于复杂,或者数据存在频繁变化的情况下,memo 对依赖项进行浅比较,是不是会存在 memo 反而是负优化的情况呢?

显然是会出现的。而且这种情况是否出现负优化,我们其实根本无法从日常开发的过程察觉出来,因为我们根本没办法判断导致 memo 负优化的数据类型到底是多复杂,更新频率是多少,才会出现。

状态管理

那既然 memo 可能导致负优化,那把数据存到状态管理中行不行呢?

当然可以,我这里以 zustand 为例:

store/global.ts

import { create } from "zustand";

type ChartDataType = Array<[number, number]>;

interface GloablState {
  lineData: ChartDataType;
  setLineData: (lineData: ChartDataType) => void;
  barData: ChartDataType;
  setBarData: (barData: ChartDataType) => void;
}
const useGlobalStore = create<GloablState>((set) => ({
  lineData: [],
  setLineData: (lineData: ChartDataType) => {
    set({
      lineData,
    });
  },
  barData: [],
  setBarData: (barData: ChartDataType) => {
    set({
      barData,
    });
  },
}));


export default useGlobalStore

App.tsx

import { useCallback, useEffect } from "react";
import "./App.css";
import { useShallow } from "zustand/shallow";
import useGlobalStore from "./store/global";

function LineChart() {
  const { lineData } = useGlobalStore(
    useShallow(({ lineData }) => ({ lineData }))
  );

  console.log("Line chart update");

  return <p>{lineData.toString()}</p>;
}

function BarChart() {
  const { barData } = useGlobalStore(
    useShallow(({ barData }) => ({ barData }))
  );

  console.log("Bar chart update");

  return <p>{barData.toString()}</p>;
}

function App() {
  const { setLineData, setBarData } = useGlobalStore(
    useShallow(({ setLineData, setBarData }) => ({ setLineData, setBarData }))
  );
  useEffect(() => {
    setTimeout(() => {
      setLineData(new Array(10).fill([0, 0]));
      setBarData(new Array(10).fill([1, 1]));
    }, 500);
  }, []);

  const handleUpdateCallback = useCallback(() => {
    setLineData(new Array(10).fill([0, Math.random()]));
  }, []);

  console.log("App is rendered");
  

  return (
    <>
      <div className="card">
        <button onClick={handleUpdateCallback}>Update Line Data</button>
      </div>
      <LineChart />
      <BarChart />
    </>
  );
}

export default App;

这样做确实可以避免当 lineData 更新时,意外导致 BarChart 组件重复渲染,但这样做我觉得代码的可读性又变差了。

我理想情况下,一份数据用一个组件渲染,一个组件只用一个文件编写代码。这样从可以方便后续功能修改以及维护。

而用上了状态管理,我的数据获取是在 App 组件中获取,数据是存储在 store 文件中,数据渲染才是在组件文件里,我觉得这样会导致项目的目录结构非常混乱,我更倾向于状态管理用来控制全局性的公共状态数据,而不是用来解决渲染优化的问题。

内容提升

但是即便用上状态管理,依然会存在这么一种情况。

function OtherComponent() {
  console.log("Other component rendered");

  return <p>I'am ohter component</p>;
}

function App() {
  const [background, setBackground] = useState<string>("#000");

  return (
    <div style={{ background }}>
      <input
        value={background}
        onChange={(e) => setBackground(e.target.value)}
      />
      <OtherComponent />
    </div>
  );
}

这种情况下,容器组件的样式是被 background 控制的,就只能通过 memo 来保证 OtherComponent 不重复渲染。

但是前面我也提了,如果存在大量的子组件的话,不可能每个子组件都去套一层 memo的。

那有没有其他方案可以解决呢?当然有,只需要内容提升就行,也就是把容器封装成一个组件即可。

function OtherComponent() {
  console.log("Other component rendered");

  return <p>I'am ohter component</p>;
}

function Container({ children }: { children?: ReactNode }) {
  const [background, setBackground] = useState<string>("#000");

  return (
    <div style={{ background }}>
      <input
        value={background}
        onChange={(e) => setBackground(e.target.value)}
      />
      {children}
    </div>
  );
}

function App() {
  return (
    <Container>
      <OtherComponent />
    </Container>
  );
}

这样也可以在不使用 memo 的情况下,避免 OtherComponent 的重复渲染,原因是因为此时 OtherComponent 是在 App 中作为 children 静态传递给 container的,container 的内部变化不应影响到 OtherComponent。

React 与 Vue 对比

我最开始学习前端的时候就是学习 Vue2的,直到我工作后才转成 React,而在我工作半年后 Vue3 也才是刚刚发布,但是那个时候 Vue3 的问题还很多,像 Typescript 的适配还非常不友好,加上初学 React ,对其灵活性非常喜爱,以至于我后面工作5年几乎都在使用 React。

直到去年工作有需要用 Vue 的情况,我才开始关注回 Vue3,这个时候发现 Vue3 的发展远远超出了我的预料。

将上面的两种情况,转换成 Vue3 后:

OtherComponent.vue

<template>
<p>{{ Date.now() }}</p>
</template>

<script setup lang="ts">
import { onUpdated } from 'vue';

onUpdated(() => {
  console.log(`other componend is updated`);
})
</script>

<style scoped>

</style>

App.vue

<template>
  <div>
    {{ count }}
    <button @click="() => count++">Add Count</button>
  </div>
  <OtherComponent />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import OtherComponent from './components/OtherComponent.vue';

const count = ref<number>(0);
</script>

<style>
#app {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

该情况下,count 改变不会引起 OtherComponent 重复渲染。

变量控制容器状态的情况,只需要修改 App.vue即可:

<template>
  <div :style="{ background }">
    <input v-model="background" />
    <OtherComponent />
  </div>

</template>

<script setup lang="ts">
import { ref } from 'vue';
import OtherComponent from './components/OtherComponent.vue';

const background = ref<string>("#000")
</script>

<style>
#app {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

一样修改 background 不会引起 OtherComponent 的重复渲染。

设计差异

会导致这样的区别,主要是因为 React 与 Vue 的设计差异导致的。

响应式差异:

  • Vue3 的响应式系统是基于 Proxy,自动追踪数据依赖关系,当数据变化时,能够精确的知道哪些组件需要更新。
  • React 的更新则是基于状态快照和虚拟DOM 递归对比。当父组件状态变化时,默认会递归所有子组件进行,从而导致重复渲染,除非开发者手动通过 memo 和 useCallback 进行优化。

模板差异:

  • Vue 的模板编译时是会经过优化的,编译时进行如下操作:
    • 静态提升(Static Hoisting):将模板中的静态内容(即不依赖响应式数据的部分)在编译阶段提取为常量,避免每次渲染时重复创建虚拟节点;如果整个模板的根节点是静态的,则直接跳过其子树的虚拟 DOM 对比。
    • 补丁标记(Patch Flags):在编译时为动态节点添加标记(Patch Flags),指示虚拟 DOM 对比时需检查哪些属性或内容。例如:
      • 文本内容变化(如 {{value}})
      • 动态属性(如 :class )
      • 动态子节点(如 v-if 切换块)
    • 块树扁平化(Block Tree Flattening):将嵌套的模板结构转换为扁平化的块(Block),减少虚拟 DOM 树的层级深度。每个块包含动态子节点的引用,静态子节点被提升到外层。
    • 动态属性缓存(Dynamic Attribute Caching):缓存动态属性的键名(如 :class、:style),避免每次渲染时重新解析属性名。
    • 事件处理函数缓存(Event Handler Caching):缓存内联事件处理函数(如 @click="handler"),避免每次渲染时重新创建函数。
  • React 的 JSX 会被转换为 React.createElement 函数调用,生成虚拟 DOM 节点。React 的优化更多依赖运行时 Fiber 架构的调度算法,而非编译时。

总结

我写这篇文章并不是为了踩 React 捧 Vue,只是想具体指出 React 与 Vue 两者之间的开发体验的差别,并且找到导致两者差异的原因到底是什么。

开发体验这个事情其实是一件很主观的事情。就我目前个人的感受来讲,React 虽然提供了 memo 和 useCallback 进行优化,我上面也提供了两种不需要使用 memo 的优化方式,但这些确实都是需要开发者主动去避免的。

结合到实际开发过程中,开发者即便是有良好开发习惯的人,也仍然有可能忙中出错。而且即便主动避免了,Diff 的差异也依然是导致性能差异的原因之一。

与其内耗自己,不如让框架自己去解决这些问题,而 Vue 3 目前就做到这点。

但无论是 React 还是 Vue 其实对我来说都只是一种工具。

“懒”对开发者并不是什么羞耻的事情,许多开发工具不正是因为“懒”,而被开发出来希望一劳永逸吗?而开发者正是这些开发工具的用户,既然是用户就应该有用户体验的评价。

虽然我现在更倾向于使用 Vue 去开发自己的个人项目,但是不代表我会放弃 React,像 React Naive 这类框架,在 Vue 中似乎就没找到类似的。

而且正是 React 中这些问题的存在,才让我自己学会思考研究问题,解决问题。“思路”对于开发者来说,远远比熟练一种工具重要的多。所以日后工作如果要求用 React 我也不会有什么排斥。