nuxt3服务端渲染开发、Pc和Mobile、部署和常见问题

1,847 阅读8分钟

环境:node@18.18.0

技术栈:nuxt3+vue3+less

文章背景:基于现有的老官网(pc/h5)进行项目升级以及seo优化,pc和移动端是两个vue2项目,但每次更新又都是同步更新,所以这次改版决定使用nuxt3+vue3,且把移动和pc放同一个项目里,文章旨在记录整个开发到部署的过程,以及过程中遇到的问题,包含:打包资源分类、静态资源上传阿里oss、快捷脚本createPage、服务端插件自定义等

项目初始化

官方文档:nuxt.com.cn/docs/gettin…

第一步就卡住了,按照官方文档执行初始化命令一直失败

解决办法:切taobao源,并且包管理使用npm

npx nuxi@latest init <project-name>

修改打包命令,设置打包环境变量,后面会用

{
  scripts: {
    "build:prod": "cross-env SERVER_ENV=production nuxi build",
    "build:test": "cross-env SERVER_ENV=test nuxi build",
  }
}

项目目录

初始化完是一个空项目,只有public和server两个目录,不用担心,官方有推荐的目录结构介绍

官方文档(必看):nuxt.com.cn/docs/guide/…

我基本上是按照这个目录创建的,没什么问题

怎么处理Pc和移动的代码

因为pc和移动是两套代码,所以需要在每个 页面和组件下创建Pc和Mobile文件夹,分别放对应端的代码,所以我的页面目录是这样的(看的过程中心中有疑问的话 别急先往下看

  • pages/home
    • pages/home/index.vue
    • pages/home/Pc/index.vue
    • pages/home/Mobile/index.vue

那么pages/home/index.vue的内容就很关键了,这是我的代码

// pages/home/index.vue
<script setup>
import Pc from './Pc'
import Mobile from './Mobile'
</script>

<template>
  <PlatformComponent>
    <template #mobile>
      <Mobile />
    </template>
    <template #pc>
      <Pc />
    </template>
  </PlatformComponent>
</template>
// components/PlatformComponent.vue
<script setup>
const isMobile = useNuxtApp().$isMobile
</script>

<template>
  <slot v-if="isMobile && $slots.mobile" name="mobile" />
  <slot v-else-if="!isMobile && $slots.pc" name="pc" />
  <div v-else>请传入{{ isMobile ? 'mobile' : 'pc' }}组件</div>
</template

到这里就有两个问题:

1、为什么没看到index.vue中没有引入PlatformComponent,而直接使用了

因为nuxt有自动导入的概念,会自动导入组件、组合式函数、辅助函数和Vue API

官网文档:nuxt.com.cn/docs/guide/…

2、isMobile 哪来的

接着看...

设备判断

Pc和Mobile的官网域名不同,Mobile域名中有个mobile前缀,这里我是通过域名判断是Pc还是Mobile的,且在Pc官网的nginx层会根据请求的ua判断,如果是移动端,就转发到移动端域名。

接下来就是定义全局变量 isMobile,因为要做服务端渲染,所以这个变量要可用于服务端和客户端,这里可以创建一个插件

插件文档:nuxt.com.cn/docs/guide/…

// plugins/device-type.js
import { defineNuxtPlugin, useRequestURL } from 'nuxt/app';
export default defineNuxtPlugin((nuxtApp) => {
  const { origin } = useRequestURL()
  const isMobile = origin.includes('mobile.')
  nuxtApp.provide('isMobile', isMobile)
});

写完这个插件,就可以全局使用 useNuxtApp().$isMobile 来判断设备类型了

px自动转vw、rem

Pc和移动端的代码分好了,接下来就是做屏幕适配了,由于老官网的Pc端使用的postcss-px-to-viewport,Mobile端使用postcss-pxtorem,我也不打算改,直接照搬

安装这两个包:

npm install -D postcss-px-to-viewport postcss-pxtorem

在nuxt.config.js中配置postcss,具体配置看自己实际情况

export default {
  postcss: {
    plugins: {
      'postcss-pxtorem': {
        rootValue: 75,
        propList: ['*', '!box-shadow'],
        include: [/Mobile(\/.*)?$/],
        exclude: [/node_modules/, /Pc(\/.*)?$/]
      },
      'postcss-px-to-viewport': {
        viewportWidth: 1920,
        landscapeWidth: 1920,
        include: [/Pc(\/.*)?$/],
        exclude: [/node_modules/, /Mobile(\/.*)?$/]
      }
    }
  }
}

现在就适配已经做好了,可以开发页面了

资源分类

nuxt打包默认使用vite,打包输出的文件不会自动分门别类,这里就需要修改一下打包配置

export default {
  vite: {
    build: {
      rollupOptions: {
        output: {
          chunkFileNames: `${assetsDir}/js/[name].[hash].js`,
          assetFileNames: getAssetFileName(assetsDir)
        }
      }
    },
  }
}
export const getAssetFileName = (assetsDir) => (assetInfo) => {
  const fileTypes = {
    '\\.(mp4|webm|ogg|wav|flac|aac)$': `${assetsDir}/video/[name].[hash].[ext]`,
    '\\.(less|css)$': `${assetsDir}/css/[name].[hash].[ext]`,
    '\\.(jpg|jpeg|png|gif)$': `${assetsDir}/img/[name].[hash].[ext]`,
    '\\.(woff|otf)$': `${assetsDir}/fonts/[name].[hash].[ext]`
  }

  for (const fileType in fileTypes) {
    if (new RegExp(fileType).test(assetInfo.name)) {
      return fileTypes[fileType]
    }
  }

  return `${assetsDir}/[name]-[hash].[ext]`
}

这样改完发型css文件中引用的图片打包出来后,找不到图片,因为层级修改了,这里写个hook修改一下

nuxt生命周期钩子:nuxt.com.cn/docs/api/ad…

import buildFn from './build/hooks/build'

export default {
  hooks: {
    build: isDev ? {} : buildFn(config)
  },
}
import buildBefore from './buildBefore'
import buildDone from './buildDone'

export default (config) => {
  return {
    done: buildDone
  }
}
// build/hooks/buildDone.js

/**
 * 修改CSS中图片地址
 */
const modifyImgPath = () => {
  // 指定CSS文件的目录
  const cssDir = path.join(buildOutput, envConfig.BUILD_ASSETS_DIR, 'css')
  console.log('===========修改css中图片地址:begin==========')

  const files = fs.readdirSync(cssDir)

  for (const file of files) {
    // 只处理CSS文件
    if (path.extname(file) === '.css') {
      const filePath = path.join(cssDir, file)
      const data = fs.readFileSync(filePath, 'utf8')
      // 替换URL ./ => ../
      const result = data.replace(/url\(.\//g, 'url(../')
      // 写入新的文件内容
      fs.writeFileSync(filePath, result, 'utf8')
    }
  }
  console.log('===========修改css中图片地址:done==========')
}

/**
 * build:done 钩子
 */
export default async () => {
  // 修改CSS中图片地址
  modifyImgPath()
}

ok,这样打包出来的文件就被归类到js、css、img、video目录了

上传ali-oss

静态资源上传cdn,这应该是C端项目的基本要求了,使用cdn有很多好处,比如

  • 更快的加载速度
  • 减轻源服务器负担
  • 提高可用性和稳定性
  • 降低带宽成本
  • 提高安全性
  • 增加并发连接数
  • 节省成本

网上找了下好像没有vite ali-oss的包可以用,那就看看nuxt生命周期钩子 自己实现一个

生命周期钩子:nuxt.com.cn/docs/api/ad…

import fs from 'fs'
import path from 'path'
import aliOss from 'ali-oss'
import config from '../config'

const SERVER_ENV = process.env.SERVER_ENV || 'development'
const envConfig = config.envConfig[SERVER_ENV]

// nuxt打包输出目录
const buildOutput = path.resolve(process.cwd(), '.nuxt/dist/client')
// oss 上传目录
const ossDir = `${config.ossConfig.projectName}/${envConfig.BUILD_ASSETS_DIR}`
// 本地静态资源目录
const localAssetsDir = path.join(buildOutput, envConfig.BUILD_ASSETS_DIR)

const uploadDirectoryToOss = async (client, localDir) => {
  // 读取目录
  const files = fs.readdirSync(localDir)
  const uploadPromises = files.map((file) => {
    const localFilePath = path.join(localDir, file)
    const stats = fs.statSync(localFilePath)

    if (stats.isFile()) {
      // oss 上传文件路径
      const ossFilePath = `${ossDir}/${path
        .relative(localAssetsDir, localFilePath) // 获取相对路径
        .replace(/\\/g, '/')}`

      return client
        .put(ossFilePath, localFilePath)
        .then(() => {
          // console.log(`♪(^∇^*)♪ ${ossFilePath}上传成功`)
        })
        .catch((err) => {
          console.log(`${ossFilePath}上传失败:`, err)
        })
    } else if (stats.isDirectory()) {
      return uploadDirectoryToOss(client, localFilePath)
    }
  })

  return Promise.all(uploadPromises)
}

/**
 * 上传静态资源
 */
const uploadAlioss = async () => {
  console.log('===========上传静态资源:begin==========')

  const client = new aliOss(config.ossConfig)

  await uploadDirectoryToOss(client, localAssetsDir)
  console.log('===========上传静态资源:done==========')
}

/**
 * 删除静态资源目录
 * @param {*} dirPath
 */
const deleteAssetsDir = (dirPath) => {
  if (fs.existsSync(dirPath)) {
    fs.readdirSync(dirPath).forEach((file) => {
      const curPath = path.join(dirPath, file)
      if (fs.lstatSync(curPath).isDirectory()) {
        // if it's a directory, recurse
        deleteAssetsDir(curPath)
      } else {
        // if it's a file, delete it
        fs.unlinkSync(curPath)
      }
    })
    // after deleting all files in the directory, delete the directory itself
    fs.rmdirSync(dirPath)
  }
}

/**
 * build:done 钩子
 */
export default async () => {
  // 修改CSS中图片地址
  modifyImgPath()

  // 上传静态资源
  await uploadAlioss()

  // 删除静态资源目录
  deleteAssetsDir(localAssetsDir)
}

同时需要修改nuxt.config.js配置

export default {
  app: {
    cdnURL: envConfig.CDN_URL,
    buildAssetsDir: assetsDir
  },
}

代码中的配置信息修改成自己的配置,这样就可以在打包时把静态资源上传阿里oss了

我的 nuxt.config.js 相关功能配置

import ...

export default {
  devtools: { enabled: false },
  css: ['~/assets/styles/global.less'], // 全局样式
  modules: ['@pinia/nuxt', '@vant/nuxt'], // 注入pinia 和 vant模块
  postcss: {
    plugins: {
      'postcss-pxtorem': {
        rootValue: 75,
        propList: ['*', '!box-shadow'],
        include: [/Mobile(\/.*)?$/],
        exclude: [/node_modules/, /Pc(\/.*)?$/]
      },
      'postcss-px-to-viewport': {
        viewportWidth: 1920,
        landscapeWidth: 1920,
        include: [/Pc(\/.*)?$/],
        exclude: [/node_modules/, /Mobile(\/.*)?$/]
      }
    }
  },
  app: {
    head: { // 插入页面头部的script代码
      script: [
        {
          innerHTML: flexibleScript
        }
      ],
      link: [{ rel: 'icon', type: 'image/png', href: '/favicon.png' }]
    },
    cdnURL: envConfig.CDN_URL, // cdn 地址
    buildAssetsDir: assetsDir // 静态资源存放目录
  },
  vite: {
    build: {
      rollupOptions: {
        output: { // 资源分类
          chunkFileNames: `${assetsDir}/js/[name].[hash].js`,
          assetFileNames: getAssetFileName(assetsDir)
        }
      }
    },
    css: {
      preprocessorOptions: {
        less: {
          // 引入全局less变量和mixin
          additionalData: `@import "@/assets/styles/mixins.less";`
        }
      }
    }
  },
  devServer: {
    port: 3002
  },
  nitro: {
    prerender: { // 预渲染路由
      routes: config.prerenderRoutes
    },
    devProxy: {
      '/api': {
        target: envConfig.API_BASE_URL,
        changeOrigin: true
      }
    },
    routeRules: {
      '/api/**': { // 服务端接口代理
        proxy: `${envConfig.API_BASE_URL}/**`
      }
    }
  }, 
  runtimeConfig: envConfig, // 运行时环境变量
  hooks: { // 打包钩子
    build: isDev ? {} : buildFn(config)
  }
}

分别预渲染pc和mobile

本以为大功告成了,却发现访问移动端页面时会先出现pc的样式,闪一下才出现移动端样式

原来预渲染时,nuxt会本地跑一遍需要预渲染的路由,并且只会生成一个pc端的html

修改nuxt.config.js中的预渲染路由

export default {
  nitro: {
    prerender: {
      routes: [
        'about/pc',
        'about/mobile',
        ...
      ]
    }
  }
}

这样是可以预渲染出pc.html和mobile.html了,但是mobile.html中的内容还是pc端的样式

因为nuxt预渲染时本地运行,这时候的域名是localhost,还记的代码里的 isMobile 的判断条件是域名中是否包含mobile,这该怎么办呢?

研究一下 device-type 插件发现,预渲染时也会执行,且会接收 pathname 参数(预渲染时代表访问的文件路径),那么在这里判断pathname中是否包含mobile即可知识是移动端还是pc端了,修改插件

// plugins/device-type.js

import { defineNuxtPlugin, useRequestURL } from 'nuxt/app'

/**
 * 这个插件会在预渲染和服务被访问时执行
 */
export default defineNuxtPlugin((nuxtApp) => {
  const { origin, pathname } = useRequestURL()
  // 打包预渲染阶段走这个,针对mobile下的文件,设置isMobile为true
  // 页面正常访问时不会带有 pathname 不会带 /mobile
  if (pathname.includes('/mobile')) {
    nuxtApp.provide('isMobile', true)
    return
  }
  const isMobile = origin.includes('mobile.')
  nuxtApp.provide('isMobile', isMobile)
})

ok,这样预渲染出来的html就正常了

预渲染正常了,那用户怎么访问呢,直接访问/about/pc或者/about/mobile是可以访问到的,但这不是我想要的,我想要的是用户还是访问/about即可,具体返回pc还是mobile下的html,在服务端处理

服务端插件

新建服务端插件,在服务端处理这个情况,但是服务端插件中无法访问项目中定义的常量,因为服务端插件是打包之后,服务运行时执行的,也就说访问不到需要预渲染的路由,如果在插件中写死,那维护起来太麻烦了,于是我在打包的时候把路由动态写到server目录下

// build/hooks/buildBefore.js

import fs from 'fs'
import path from 'path'

/**
 * build:before 钩子,把路由写到server下
 */
export default async (config) => {
  const serverPath = path.resolve(
    process.cwd(),
    'server/__data__/prerenderRoutes.json'
  )
  const prerenderRoutes = config.prerenderRoutes

  fs.writeFileSync(serverPath, JSON.stringify(prerenderRoutes))
}

import buildBefore from './buildBefore'
import buildDone from './buildDone'

/**
 * build 钩子
 */
export default (config) => {
  return {
    before: () => buildBefore(config),
    done: buildDone
  }
}

然后就可以开始写服务端插件custom-resonse了

// server/plugins/customResponse.js
import fs from 'fs'
import path from 'path'

let prerenderRoutes = null
let prerenderRootRoutes = []

// nuxt预渲染时的 host
const PRERENDER_HOST = 'localhost'

// 处理 pc | mobile 路由,由于预渲染时是按照pc|mobile路由预渲染的
// 所以 nuxt 会重定向到 pc|mobile 子路由,这里需要处理一下
const handlePcMobileRoute = (htmlStr, pathname, isMobile) => {
  const routeWithoutDeviceSuffix =
    pathname === '/' ? '' : pathname.replace(/\/(pc|mobile)/, '')

  const oldValue = `"${routeWithoutDeviceSuffix}${
    isMobile ? '/mobile' : '/pc'
  }",`
  const newValue = `"${routeWithoutDeviceSuffix || '/'}",`

  try {
    const ret = htmlStr.replace(new RegExp(`${oldValue}`, 'g'), newValue)
    return ret
  } catch (error) {
    console.log('handlePcMobileRoute', error)
  }
}

// 获取html
const getHtml = (publicDir, host, pathname) => {
  let filePath = ''
  let htmlStr = ''
  let isMobile = host.includes('mobile.')

  if (isMobile) {
    filePath = path.join(publicDir, pathname, 'mobile/index.html')
    fs.existsSync(filePath) && (htmlStr = fs.readFileSync(filePath, 'utf8'))
  } else {
    // 匹配 index.html 或者 pc/index.html
    const indexHtmlPath = path.join(publicDir, pathname, 'index.html')
    if (fs.existsSync(indexHtmlPath)) {
      htmlStr = fs.readFileSync(indexHtmlPath, 'utf8')
    } else {
      filePath = path.join(publicDir, pathname, 'pc/index.html')
      fs.existsSync(filePath) && (htmlStr = fs.readFileSync(filePath, 'utf8'))
    }
  }

  htmlStr = handlePcMobileRoute(htmlStr, pathname, isMobile)
  return htmlStr
}

// 读取预渲染路由
const getPrerenderRoutes = async () => {
  const __routes = await import('../__data__/prerenderRoutes.json')
  prerenderRoutes = __routes.default

  // 预渲染根路由
  prerenderRootRoutes = prerenderRoutes.map((route) => {
    const match = route.match(/(\/.*?)(\/pc|\/mobile)?$/)
    let result = match ? match[1] : route
    if (result === '/pc' || result === '/mobile') {
      result = '/'
    }
    return result
  })

  prerenderRoutes = null
}

/**
 * request 钩子在 预渲染 和 服务被访问时 都会执行
 * https://nitro.unjs.io/guide/plugins#nitro-runtime-hooks
 */
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', async (event) => {
    const pathname = event.req.url
    const { host } = event.req.headers

    // 服务启动后,被第一次请求时获取预渲染路由
    if (!prerenderRootRoutes.length) {
      await getPrerenderRoutes()
    }

    // 预渲染时不处理
    if (host === PRERENDER_HOST) {
      return
    }

    // 不属于预渲染范围不处理
    if (!prerenderRootRoutes.includes(pathname)) {
      return
    }

    const publicDir = path.join(process.cwd(), 'public')
    const htmlStr = getHtml(publicDir, host, pathname)

    if (htmlStr) {
      event.res.writeHead(200, { 'Content-Type': 'text/html' })
      event.res.end(htmlStr)
    }
  })
})

ok,到这里就大功告成了

项目部署

使用pm2启动服务,需要创建ecosystem.config.cjs文件

module.exports = {
  apps: [
    {
      name: 'project name',
      port: '3002',
      script: './server/index.mjs',
      instances: 1, // 多进程
      // exec_mode: 'cluster', // 集群模式
      autorestart: true,
      max_memory_restart: '1G',
      watch: false,
      log_type: 'json',
      log_date_format: 'YYYY-MM-DD HH:mm Z',
      error_file: '/www/webroot/project name/logs/err.log', // 错误日志文件路径问运维
      out_file: '/www/webroot/project name/logs/out.log' // 日志文件路径问运维
    }
  ]
}

修改打包命令,把ecosystem.config.cjs复制到.output下

{
  scripts: {
    "build:prod": "cross-env SERVER_ENV=production nuxi build && node ./build/copy.cjs",
    "build:test": "cross-env SERVER_ENV=test nuxi build && node ./build/copy.cjs",
  }
}
const fs = require('fs')
const path = require('path')

const sourcePath = path.join(process.cwd(), 'ecosystem.config.cjs')
const destinationPath = path.join(
  process.cwd(),
  '.output',
  'ecosystem.config.cjs'
)

fs.copyFile(sourcePath, destinationPath, (err) => {
  if (err) {
    console.error('ecoststem.config.js 文件复制失败:', err)
  } else {
    console.log('ecoststem.config.js 文件复制成功')
  }
})

然后把下面步骤发给运维即可

  1. 安装依赖:npm install
  2. 打包:npm run build:prod
  3. 把打包输出丢到服务器:.output
  4. 启动服务:进入.output目录执行 pm2 start ecosystem.config.cjs