环境:node@18.18.0
技术栈:nuxt3+vue3+less
文章背景:基于现有的老官网(pc/h5)进行项目升级以及seo优化,pc和移动端是两个vue2项目,但每次更新又都是同步更新,所以这次改版决定使用nuxt3+vue3,且把移动和pc放同一个项目里,文章旨在记录整个开发到部署的过程,以及过程中遇到的问题,包含:打包资源分类、静态资源上传阿里oss、快捷脚本createPage、服务端插件自定义等
项目初始化
第一步就卡住了,按照官方文档执行初始化命令一直失败
解决办法:切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
2、isMobile 哪来的
接着看...
设备判断
Pc和Mobile的官网域名不同,Mobile域名中有个mobile前缀,这里我是通过域名判断是Pc还是Mobile的,且在Pc官网的nginx层会根据请求的ua判断,如果是移动端,就转发到移动端域名。
接下来就是定义全局变量 isMobile,因为要做服务端渲染,所以这个变量要可用于服务端和客户端,这里可以创建一个插件
// 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 文件复制成功')
}
})
然后把下面步骤发给运维即可
- 安装依赖:npm install
- 打包:npm run build:prod
- 把打包输出丢到服务器:.output
- 启动服务:进入.output目录执行 pm2 start ecosystem.config.cjs