Vue搭建SSR全栈平台,Nuxt才是Top 1!

前言

什么是SSR(Server-Side Rendering)?类似于用Java、ASP.NET、php开发前端页面,服务端准备数据并执行页面渲染,然后把完整的HTML发送给客户端。现代化的SSR通常和Vue、React等前端框架强关联,通过Node.js在服务端提前将page、component使用同构方式渲染为html代码,其目的是在实现SSR时尽量复用一套渲染机制。例如Vue框架就同时支持了CSR(Client-Side Rendering)和SSR。

SSR能解决什么问题?

  • SEO: Google百度YandexBing或者Yahoo等搜索引擎会通过网络爬取你的页面并建立索引,如果的信息越完整,那么你的网站就更容易被检索到。使用SSR技术,让页面在服务端提前渲染好再返回给客户端,这样各类搜索引擎就能拿到比较完整的页面信息,提升检索质量。
  • 首屏渲染:SPA(Single Page Application)将页面渲染放到客户端执行,并且在渲染之前要加载大量的Javascript代码,所以首屏渲染需要花费较长时间。而SSR直接在服务端渲染完再返回客户端,使用户能够快速看到页面内容。

「有哪些框架支持SSR?」

开源库 支持语言 star数 描述
Nuxt Vue 54.9k 快速构架、类型安全、高性能、易扩展
Next React 127k 老牌框架、社区完善、Data Fetch、Styling
quasar Vue 26k 丰富的UI、支持桌面端和移动端
Remix React 29.9k 极致的用户体验、开箱即用

什么是Nuxt

图片

基于Vue、React、Angular实现的full-stack框架数不胜数,但就Vue框架领域内,应该数Nuxt最为Top 1。Nuxt保持了Vue生态的“开箱即用”特性,类似于Vite,几条指令就可直接run起来。如果你熟悉Vue,那么基于Nuxt写应用,体验上和写Vue客户端应用极其相似。

Nuxt提前预制了一套目录结构,并自动处理路由、导入、数据获取,开发人员仅需按约定的规则实现页面、组件、数据API即可,极大降低开发人员学习成本。Nuxt的特性可总结为:

  • 基于文件的路由:  页面统一添加到pages/目录下,例如添加index.vue、about.vue、contact.vue,就可以通过/访问首页,通过/about返回关于页面;
  • 代码分割:得益于Vue的Virtual Node,Nuxt根据目录规则提前生成完整的依赖树,这样能够将所有code拆分为最小单元的chunks, 从而减少应用初始化加载时间;
  • 开箱即用的SSR:Nuxt内部使用Vue的SSR,除了Vue实现SSR、CSR本身的差异,可以像开发CSR的Vue应用一样来开发SSR,降低研发心智负担;
  • 自动导入:在实现页面或组件时,不需要像CSR手动导入(import Card from './Card.Vue')依赖,当Nuxt识别到有使用外部组件,bundle过程会自动识别;
  • 数据获取工具:提供一套组合式FetchAPI,实现client、server端的同构;
  • 配置好的构建工具:「默认使用Vite构建,同时支持Webpack、Rspack」

Nuxt的架构设计和vue/core很相似,将整体功能拆分为独立的package:

  • 核心引擎:nuxt;
  • bundlers: 打包器包含@nuxt/vite-buildenuxt/webpack-builder@nuxt/rspack-builder
  • 命令行工具:nuxi;
  • 服务端引擎:nitro;
  • 开发套件:@nuxt/kit;

上手体验

初始化项目

使用nuxi提供的指令初始化项目:

npx nuxi@latest init nuxt-learn-examples

如果安装过程下载失败,可能需要配置hosts:

185.199.108.133 raw.githubusercontent.com

「最新nuxt版本3.14初始化的项目目录比较简单,少了pages、plugins等目录」,入口为app.vue

图片
image.png

nuxt默认在app.vue中添加了两个demo组件,需要手动删除,并安需添加路由组件:

<template>  <NuxtPage /></template>

「如果要使用三方的UI组件,例如element-ui,先创建plugins目录,并添加element-ui.ts,nuxt框架在构建过程会自动加载plugins目录下的所有插件。

import Vue from 'vue'import Element from 'element-ui'import locale from 'element-ui/lib/locale/lang/en'
Vue.use(Element, { locale })

路由

Nuxt基于Vue-Router实现路由,区别于CSR路由,使用Nuxt仅需要在pages目录下添加页面,并且支持动态路由。例如新增目录:

- pages    - index.vue    - about.vue    - products        - [id].vue

Nuxt会自动将文件转换为Vue-Router的路由配置:

{  "routes": [    {      "path": "/index",      "component": "pages/index.vue"    },    {      "path": "/about",      "component": "pages/about.vue"    },    {      "path": "/products/:id",      "component": "pages/products/[id].vue"    }  ]}

「Nuxt提供<NuxtLink>创建导航连接,例如<NuxtLink to="/about">关于</NuxtLink>,当<NuxtLink>标签在视图范围内可见,则自动预取链接页面的组件,从而加快导航速度。」

在写SPA页面时,可通过router.beforeEach((to, from, next) => {}添加路由验证、拦截。而Nuxt通过路由中间件形式实现路由拦截,在middleware目录下添加auth.ts文件:

export default defineNuxtRouteMiddleware((to, from) => {  if (isAuthenticated() === false) {    return navigateTo('/login')  }})

navigationTo函数重定向到给定的路径,并在服务端发生重定向时设置response code为302。文件名auth也会作为中间件的ID,路由对哪些页面生效,需要在页面添加中间件配置,指明需要使用哪些中间件。」

<script setup lang="ts">definePageMeta({  middleware: 'auth'})</script>

「如果中间件是全局性的,则可以通过添加global后缀标示」,例如setup.global.ts

「当有多个中间件被执行时,按什么顺序执行」?nuxt根据文件名按字母进行排序,可通过如下形式排好执行顺序:

middleware/    01.setup.global.ts    02.analytics.global.ts    auth.ts

「除了通过在middleware目录下添加中间件外,Nuxt提供了动态添加中间件方式addRouteMiddleware,例如在插件中添加。

export default defineNuxtPlugin(() => {  addRouteMiddleware('setup', () => {  }, { global: true })}

SEO和Meta

Nuxt提供了多种设置SEO和head属性的方法,不管是配置或者函数都提供完整的TypeScript支持。

useSEOMeta函数支持通过一个扁平化的Object对象设置SEO相关属性」

<script setup lang="ts">useSeoMeta({  title: '淘贝购物',  ogTitle: '陶贝购物',  description: '我是一个购物网站,比淘宝还厉害。',  ogDescription: '我是一个购物网站,比淘宝还厉害。',  ogImage: 'https://taobei.com/image.png',  twitterCard: 'taobei_summary_large_image',})</script>

除了通过useSEOMeta函数设置SEO属性,Nuxt还提供了<Title><Base><NoScript><Style><Meta><Link><Body><Html><Head>组件可直接在template使用。」 例如在Head组件下添加Title、Meta、Style。

<script setup lang="ts">const title = ref('你好,世界')</script>
<template>  <div>    <Head>      <Title>{{ title }}</Title>      <Meta name="description" :content="title" />      <Style type="text/css" children="body { background-color: green; }" />    </Head>
    <h1>{{ title }}</h1>  </div></template>

除了使用组件形式为head添加Meta信息外,还可以使用useHead函数设置。例如在app.vue添加:

<script setup lang="ts">const description = ref('我的神奇网站。')
useHead({  meta: [    { name: 'description', content: description }  ],})</script>

如果想引入外部css、字体等资源,也可以通过useHead的Link属性设置。

<script setup lang="ts">useHead({  link: [    {      rel: 'preconnect',      href: 'https://fonts.googleapis.com'    },    {      rel: 'stylesheet',      href: 'https://fonts.googleapis.com/css2?family=Roboto&display=swap',      crossorigin: ''    }  ]})</script>

样式使用

在样式化方面,Nuxt 非常灵活。你可以编写自己的样式,或者引用本地和外部样式表。你可以使用 CSS 预处理器、CSS 框架、UI 库和 Nuxt 模块来为你的应用程序添加样式。

本地编写的样式表,可将其放到assets目录下,如果想在组件中引入这些css文件,可通过javascript的import,或者使用css的@import语句。

<script>import '~/assets/css/first.css'</script>
<style>@import url("~/assets/css/second.css");</style>

一些css文件需要全局导入,那么可以在nuxt.config.ts文件添加css文件导入。

export default defineNuxtConfig({  css: ['~/assets/css/main.css']})

如果要使用字体文件,可将字体文件放到public目录下,例如放到public/fonts/FarAwayGalaxy.woff。这样就可以在css样式中使用这些字体了。

// 字体定义@font-face {  font-family: 'FarAwayGalaxy';  src: url('/fonts/FarAwayGalaxy.woff') format('woff');  font-weight: normal;  font-style: normal;  font-display: swap;}
// 字体使用:h1 {  font-family: 'FarAwayGalaxy', sans-serif;}

通过npm安装的样式,例如npm install animate.css,可直接在组件中使用, 可使用javascript的import或者style的@import方式导入。

<script>import 'animate.css'</script>
<style>@import url("animate.css");</style>

如果想把animate.css添加到全局,上文中有介绍在nuxt.config.ts中导入。

export default defineNuxtConfig({  css: ['animate.css']})

引入三方cdn的css资源,一般会添加到head的link中。添加方式包含静态、动态两种。静态方式为在nuxt.cofig.ts的head属性下附加link属性,如下述代码所示。

export default defineNuxtConfig({  app: {    head: {      link: [{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css' }]    }}})

动态方式可使用useHead函数添加三方css资源。

useHead({  link: [{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css' }]})

Nuxt框架下,组件使用样式方式和客户端组件类似。

<script setup lang="ts">const isActive = ref(true)const hasError = ref(false)const classObject = reactive({  active: true,  'text-danger': false})</script>
<template>  <div class="static" :class="{ active: isActive, 'text-danger': hasError }"></div>  <div :class="classObject"></div></template>

像客户端支持的.scss、.sass、.less、.styl 和 .stylus,Nuxt也支持在组件的style标签上设置lang,例如<style lang="less"></style

数据获取

Nuxt 提供了两个组合函数和一个内置库,用于在浏览器或服务器环境中执行数据获取:useFetchuseAsyncData 和 $fetch

「请求数据为什么需要特定的组合函数?一个组件在服务端、客户端都被执行,通过组合函数能解决服务端、客户端的同构问题,让数据请求在两个端都能正常运行,可避免数据重复请求以及异步加载等问题。」

「解决网络重复请求」

useFetch 和 useAsyncData 组合函数确保一旦在服务器上进行了 API 调用,数据将以有效的方式在负载中传递到客户端。只要服务端执行过useFetchuseAsyncData函数,则其结果将被序列化传送给客户端,而客户端通过useNuxtApp().payload访问这些数据。

使用 Nuxt DevTools 在 「Payload 选项卡」 中检查此数据。

图片

「解决数据请求和界面交互同步」

组件支持top level方式请求数据,例如直接在script下使用useFetch获取数据,并且一个页面下可能有多个组件都会请求数据,那何时界面可交互?Nuxt 在底层使用 Vue 的 <Suspense> 组件防止在数据请求完成前进行交互、导航。

<script setup lang="ts">const { data: count } = await useFetch('/api/count')</script>
<template>  页面访问量:{{ count }}</template>

「$ofetch」

Nuxt 包括了 ofetch 库,并且作为全局别名 $fetch 自动导入到应用程序中。它是 useFetch 在幕后使用的工具。

ofetch 库是基于 Fetch API 构建的,并为其添加了便利功能:

  • 在浏览器、Node 或 worker 环境中的使用方式相同
  • 自动解析响应
  • 错误处理
  • 自动重试
  • 拦截器

什么时候使用$fetch?当客户端异步提交数据时,不涉及到页面状态,因此可直接使用$fetch提交数据。

「仅在客户端获取数据」

默认情况下,useFetch组合函数在客户端、服务端都会执行,可通过给第二个参数server:false关闭服务端的请求。对于首次渲染不需要的数据(如非SEO敏感数据),可通过设置lazy: true让首次渲染不用等待该请求。

/* 此调用仅在客户端执行 */const { pending, data: posts } = useFetch('/api/comments', {  lazy: true,  server: false})

「缓存和重新获取数据」

useFetch使用提供的url作为缓存键,也可在最后一个options参数显式指定key作为缓存键。

useAsyncData如果第一个参数是字符串,则将其用作缓存键。如果第一个参数是执行查询的处理函数,则「会为useAsyncData的实例生成一个基于文件名和行号的唯一键」

useFetch将返回的数据转换为响应式,并且提供了手动请求或刷新的方法。」

<script setup lang="ts">const { data, error, execute, refresh } = await useFetch('/api/users')</script>
<template>  <div>    <p>{{ data }}</p>    <button @click="refresh">刷新数据</button>  </div></template>

「想要查询条件变化时自动重新请求数据?」 可在options的watch属性指定监听值。

const id = ref(1)const { data, error, refresh } = await useFetch('/api/users', {  /* 更改id将触发重新获取 */  watch: [id]})

如果「query的参数变化也自动请求,只要传递的响应式」,则useFetch会帮你自动完成监听。

const id = ref(null)const { data, pending } = useLazyFetch('/api/user', {  query: {    user_id: id  }})

状态管理

Nuxt提供了强大的状态管理库和useState组合函数,用于创建响应式且适用于SSR的共享状态。useState用于在组件之间创建响应式且适用于SSR的共享状态。

useState的值将在服务端渲染后保留,并在客户端渲染期间进行水合(hydration),其唯一键在多个组件间共享。由于useState在服务端渲染后要传递给客户端,需进行序列化,所以像类、函数等不支持共享。」

如何共享?例如在app.vue中使用useState定义了key为counter的state,其他组件可通过useState('counter')获取key为counter的state值。

<script setup lang="ts">const counter = useState('counter', () => Math.round(Math.random() * 1000))</script>
<template>  <div>    计数器:{{ counter }}    <button @click="counter++">      +    </button>    <button @click="counter--">      -    </button>  </div></template>

「如何定义全局状态?」

通过使用「自动导入的组合函数」,我们可以定义全局类型安全的状态并在整个应用程序中导入它们。例如添加composables/states.ts文件并附加内容:

export const useCounter = () => useState<number>('counter', () => 0)export const useColor = () => useState<string>('color', () => 'pink
')

那么服务端在渲染每一个页面时都会加载states.ts中的状态,因此可以在组件中直接读取。

<script setup lang="ts">const color = useColor() // 与useState('color')相同</script>
<template>  <p>当前颜色:{{ color }}</p></template>

「使用第三方库」

Nuxt与流行的状态库有多种集成方式:

  • pinia

    npm i pinia @pinia/nuxt
    

    ❝如果你正在使用 npm,你可能会遇到 ERESOLVE unable to resolve dependency tree 错误。如果那样的话,将以下内容添加到 package.json 中:

    "overrides": { 
        "vue": "latest" 
    }
    

    nuxt.config.ts中配置pinia。

      // Nuxt 3
      export default defineNuxtConfig({
          modules: ['@pinia/nuxt'],
      })
    

    配置以后就可以正常使用pinia了,例如新增一个stores/myStore.ts文件,添加Store内容:

      import { defineStore } from "pinia";
    
      interface MyState {
          version: string;
      }
    
      export const useStore = defineStore<'myStore', MyState>('myStore', { 
          state: () => {
              return {
                  version: "1.0"
              }
          }
      })
    

    在组件中使用myStore:

      <script setup lang="ts">
      import { useStore } from '~/stores/myStore'
    
      const store = useStore()
      </script>
    

除此之外,Nuxt还支持了:

  • Harlem - 不可变的全局状态管理库
  • XState - 基于状态机的方法,具有可视化和测试状态逻辑的工具

Nuxt功能不够用?使用Module轻松扩展

250个Module

一个框架好不好用,功能丰不丰富当属核心考量方面。Nuxt通过Module机制能够轻松的扩展其功能。到目前Nuxt已提供「250个Module」「800个贡献者」参与。

图片

如果以上的模块还满足不了你的需求,那可以考虑上手写一个Module。「Nuxt的配置和钩子系统使得可以定制Nuxt的每个方面,并添加任何可能需要的集成(Vue插件、服务器路由、组件、日志记录等)。」

扩展自己的Module

nuxt命令行工具也提供了Module项目快速创建指令。

npx nuxi init -t module my-module

接着使用npm run dev:prepare为项目准备本地文件。

在编写Module时,常常需要一个可运行的程序来测试,而Nuxt在创建module项目时默认会为你创建一个playground目录,其下就包含了可运行的Nuxt程序, 你可以在此基础上添加Module的测试代码。

图片

Module入口文件指定在src/module.tsNuxt提供了多种Module编写方式,但官方推荐使用对象编写方法,并使用meta属性来标识你的模块。

import { defineNuxtModule } from '@nuxt/kit'
export default defineNuxtModule({  meta: {    // 通常是你的模块的npm包名称    name: '@nuxtjs/example',    // `nuxt.config`中保存你的模块选项的键    configKey: 'sample',    // 兼容性约束    compatibility: {      // 支持的Nuxt版本的Semver版本      nuxt: '^3.0.0'    }  },  // 模块的默认配置选项,也可以是返回这些选项的函数  defaults: {},  // 注册Nuxt钩子的简写形式  hooks: {},  // 包含模块逻辑的函数,可以是异步的  setup(moduleOptions, nuxt) {    // ...  }})
 

meta为Module的元数据配置信息,核心扩展逻辑包含在setup函数中。Nuxt的Module几乎可以覆盖Nuxt的方方面面功能,例如组件、Composables、插件、路由、中间件等。而Module中添加相关文件需要放到src/runtime目录下。

下图左侧为pinia实现的@nuxt/pinia模块,runtime目录下包含了composables和Vue相关的插件。图右侧为Vue3的水合插件 plugin.vue3.ts实现代码。

图片

定义了插件和composables,但如何将其添加到Nuxt中?参考@nuxt/pinia实现的module.ts核心代码, 通过addPlugin函数将runtime下定义的插件动态注册到Nuxt,通过addImportsdfineStoreusePiniastoreToRefs函数自动导入,因此在组件中使用时不需要再手动import

const module: NuxtModule<ModuleOptions> = defineNuxtModule<ModuleOptions>({  ...  setup(options, nuxt) {    // configure transpilation    const { resolve } = createResolver(import.meta.url)    const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url))
    // Transpile runtime    nuxt.options.build.transpile.push(resolve(runtimeDir))
    nuxt.hook('prepare:types', ({ references }) => {      references.push({ types: '@pinia/nuxt' })    })
    nuxt.hook('modules:done', () => {      if (isNuxtMajorVersion(2, nuxt)) {        addPlugin(resolve(runtimeDir, 'plugin.vue2'))      } else {        addPlugin(resolve(runtimeDir, 'plugin.vue3'))        addPlugin(resolve(runtimeDir, 'payload-plugin'))      }    })
    addImports([      { from: composables, name: 'defineStore' },      { from: composables, name: 'acceptHMRUpdate' },      { from: composables, name: 'usePinia' },      { from: composables, name: 'storeToRefs' },    ])  },})
export default module
 

Module几乎可以覆盖Nuxt所有功能,你可以为Nuxt添加其支持的任何资源:

  • Vue 组件
  • Composables
  • Nuxt 插件

对于 服务器引擎 Nitro 来说:

  • API 路由
  • 中间件
  • Nitro 插件

或者任何其他你想要注入到用户的 Nuxt 应用程序中的资源:

  • 样式表
  • 3D 模型
  • 图片
  • 等等

总结

Nuxt贯彻了Vue生态的一贯作风,开箱即用,学习成本低,丰富的生态社区。」 和实现客户端SPA应用类似,Nuxt完全复用Vue相关的常用框架,路由使用Vue-Router,状态管理使用Pinia,UI组件可直接使用element-ui等,因此对于习惯了Vue的开发者来说,编写Nuxt完全没有心智负担。

Nuxt的核心难点是如何保持服务端和客户端的同构性」。使用pinia创建的store,如果服务端对其进行了设置,那客户端使用时如何能够获取到设置后的值,而不是再重新初始化一次?使用useFetchuseAsyncFetch获取接口数据后,如何避免客户端重复请求?Nuxt使用的方案都是通过key来标识一次请求,并将请求结果序列化到payload中,客户端在读取store、调用useFetch时,会先判断payload中是否存储有对应的值,有则直接使用。」

一个优秀的框架少不了好的扩展生态,Nuxt通过Module机制扩展其生态,目前已支持了250个Module。Module支持了插件、组件、路由等各个方面的扩展,并且在工程化方面也提供了nuxi@nuxt/module-builder@nuxt/kit@nuxt/test-utils等工具。

参考:

  1. Nuxt Modules官方文档
  2. Nuxt3正式版发布,教你用vite+nuxt+pinia+vueuse搞定前端SSR项目
  3. nitrohttps://juejin.cn/post/7073756223664816142
  4. 优化SPA:使得网站对SEO更友好

来源:前端下饭菜 稀土掘金技术社区

THE END