为什么我选择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 发生改变时,仍然会导致其跟着重复渲染。

当然解决办法也是有的,把它放到 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 我也不会有什么排斥。