theme: smartblue
最近公司立项了一个新项目,因为是to C 的,所以对SEO是有较高需求的,由于公司前端技术栈统一用的VUE,顺理成章的就选择了nuxt这个全栈框架。项目立项之后我就被安排了负责前端项目框架的搭建,从搭建过程的体验来看,技术栈切换到nuxt还是有门槛的,所以这里我就把经过我打磨好的nuxt完整项目框架分享出来,大家即拿即用,童叟无欺。顺嘴提一句,欢迎大家关注我的微信公众号fever code
,获取最新技术分享。
项目结构
- api
- assets
-- images
-- lang
---- en_us.json
---- zh_cn.json
-- scss
---- constants.scss
---- index.scss
- components
- composables
-- store
-- pinia
-- locale.js
-- auth.js
-- toast.js
- configs
- constants
-- auth.js
- layouts
-- default.vue
-- login.vue
- middleware
-- default.global.js
- pages
- plugins
-- pinia.js
- public
- server
-- api
---- list.js
-- middleware
---- request.js
- utils
-- http.js
- .env.development
- .env.local
- .env.production
- .eslintrc.cjs
- .gitignore
- .prettierrc.json
- app.vue
- error.vue
- i18n.config.js
- nuxt.config.ts
- package.json
NUXT项目配置
下面贴一下nuxt项目配置的代码。因为UI设计师选用的样式风格是arco-design
组件库的风格,所以项目中也集成的acro-design
。项目采用的适配方案是px-to-vw
,状态管理工具是nuxt自带的useState
和pinia
,简单的状态管理采用useState
就够了,复杂的状态就决定使用pinia
分模块化管理。项目的受众群体囊括了海内外的业内人士,所以在项目中也做了国际化的处理,环境变量使用的是VITE_
开头,客户端可以使用import.env.meta
访问,也可以使用useRuntimeConfig
获得。
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: false },
app: {
head: {
titleTemplate: '%s - 京东商城',
title: '京东商城',
charset: 'utf-8',
htmlAttrs: {
lang: 'zh-CN'
},
meta: [
{ name: 'keywords', content: '网上购物,网上商城,家电,手机,电脑,服装,居家,母婴,美妆,个护,食品,生鲜,京东' },
{
name: 'description',
content:
'京东JD.COM-专业的综合网上购物商城,为您提供正品低价的购物选择、优质便捷的服务体验。商品来自全球数十万品牌商家,囊括家电、手机、电脑、服装、居家、母婴、美妆、个护、食品、生鲜等丰富品类,满足各种购物需求。'
}
]
}
},
// 注入全局样式
css: ['~/assets/scss/index.scss'],
modules: [
// arco-design UI组件库
'arco-design-nuxt-module',
// 国际化插件
'@nuxtjs/i18n',
[
// pinia状态管理库
'@pinia/nuxt',
{
// 项目中自动导入pinia的defineStore方法
autoImports: ['defineStore']
}
]
],
// arco-design UI组件库配置
arco: {
importPrefix: 'A',
hookPrefix: 'Arco',
locales: ['getLocale'],
localePrefix: 'Arco'
},
// 国际化插件配置
i18n: {
strategy: 'no_prefix', // 添加路由前缀的方式
locales: ['en', 'zh'], //配置语种
defaultLocale: 'zh', // 默认语种
vueI18n: 'i18n.config.js' // 通过vueI18n配置
},
// nuxt组件库配置
components: [
{
path: '~/components',
extensions: ['.vue'],
pathPrefix: false
}
],
imports: {
dirs: [
// 扫描composables目录中的所有(包括子文件夹)模块
'composables/**'
]
},
// 发服务器配置
devServer: {
port: 3001
},
build: {
// 在开发环境和生产环境对es包使用babel进行语法转换
transpile: ['element-plus/es']
},
vite: {
css: {
preprocessorOptions: {
scss: {
// 全局引入scss常量,供全局使用
additionalData: '@import "assets/scss/constant.scss";'
}
}
}
},
postcss: {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 1920 /** 设计稿的视口宽度 */,
unitToConvert: 'px' /** 需要转换的单位,默认为"px" */,
unitPrecision: 5 /** 单位转换后保留的精度 */,
propList: ['*'] /** 能转化为vw的属性列表 */,
viewportUnit: 'vw' /** 希望使用的视口单位 */,
fontViewportUnit: 'vw' /** 字体使用的视口单位 */,
selectorBlackList: [] /** 需要忽略的CSS选择器 */,
minPixelValue: 1 /** 设置最小的转换数值 */,
mediaQuery: false /** 媒体查询里的单位是否需要转换单位 */,
replace: true /** 是否直接更换属性值,而不添加备用属性 */,
exclude: undefined /** 忽略某些文件夹下的文件或特定文件 */,
include: undefined /** 设置将只有匹配到的文件才会被转换 */,
landscape: false /** 是否添加根据 landscapeWidth 生成的媒体查询条件 @media */,
landscapeUnit: 'vw' /** 横屏时使用的单位 */,
landscapeWidth: undefined /** 横屏时使用的视口宽度 */
}
}
},
runtimeConfig: {
mode: process.env.VITE_MODE,
base_url: process.env.VITE_BASE_URL,
app: {
mode: process.env.VITE_MODE,
base_url: process.env.VITE_BASE_URL,
}
}
})
客户端请求HTTP模块封装
Nuxt作为一个全栈框架,是有客户端和服务端的概念的。在使用nuxt开发的过程中,很多人有一个误区,认为前端所有的请求都要发送到nuxt服务端,nuxt服务端请求后端接口后返回前端。其实不是这样的,只有需要SSR(服务端渲染)的内容才需要这样做,也就是需要渲染的数据的GET请求才需要这样做,对于增加、删除、修改这一类请求完全可以直接在客户端向后端发请求。这里贴一下我封装HTTP模块(请求库用的是nuxt自带的$fetch)。
/**
* @description http模块
*/
import { jumpToLogin } from '@/utils/utils'
// 接口基地址
const BASE_URL = import.meta.env.VITE_BASE_URL
// 环境
const MODE = import.meta.env.VITE_MODE
// 生产环境
const MODE_PRODUCTION = 'production'
// GET请求方法
const METHOD_GET = 'GET'
// 成功状态
const SUCCESS_STATUS_TEXT = 'OK'
// 响应类型
const RESPONSE_TYPE = ['blob', 'stream']
// 请求拦截器
const requestInterceptor = (config) => {
if (config.options.meta?.needAuth) {
const { getToken, getUid } = useAuth()
const token = getToken()
const uid = getUid()
const method = config.options.method?.toUpperCase()
if (method === METHOD_GET) {
const query = config.options.query || {}
config.options.query = { ...query, token, uid }
} else {
const body = config.options.body || {}
config.options.body = { ...body, token, uid }
}
}
return config
}
// 响应拦截器
const responseInterceptor = (response) => {
const res = response.response
if (res.status === 200 &&
res.statusText === SUCCESS_STATUS_TEXT &&
res._data.data &&
RESPONSE_TYPE.includes(res?.type)
) {
return response
}
if (MODE !== MODE_PRODUCTION) {
console.log(res.url, {
code: res._data.code,
data: res._data.data,
res: res._data,
params: response.options,
resHeaders: res.headers
})
}
if (res._data.code === 0 || res._data.code === 200) {
return response
} else if (res._data.code === -50) {
// token过期或失效
const routeMeta = useRouteMeta()
const { removeToken, removeUid } = useAuth()
const userInfo = useUserInfo()
removeToken()
removeUid()
// 清空用户信息
userInfo.value = {}
if (routeMeta.value.needAuth) {
// 当前页面需要权限的话,登录失效即跳转登录页
jumpToLogin()
}
return Promise.reject(res._data)
}
return Promise.reject(res._data)
}
// 错误拦截器
const errorInterceptor = (err) => {
return Promise.reject(err.error)
}
const httpInstance = $fetch.create({
baseURL: BASE_URL,
onRequest: requestInterceptor,
onResponse: responseInterceptor,
onRequestError: errorInterceptor
})
export default httpInstance
api接口管理模块
// /api/common.js
/**
* @description 项目中公共请求api
*/
import http from '@/utils/common'
// 获取上传临时密钥
export function getCommonList() {
return http('/api/common/getList', {
method: 'get',
meta: {
// 标记该接口是否需要权限校验,需要则会在请求拦截器那里做请求拦截相关逻辑处理
needAuth: true
}
})
}
NUXT服务端请求
nuxt服务端接口开发,请求流程:前端 --> nuxt服务端 --> java等服务端接口
// /server/api/list.js
import { readRawBody, getQuery, getMethod } from 'h3'
export default defineEventHandler(async (event) => {
// const res = await useFetchData('/user-center/user/getUserInfo', {
// method,
// baseURL: event.context.baseUrl,
// headers: event.context.headers,
// params: getQuery(event),
// body
// })
const method = getMethod(event).toUpperCase()
let body
if (method !== 'GET') body = await readRawBody(event)
const res = await $fetch('/user-center/user/getUserInfo', {
method,
baseURL: event.context.baseUrl,
headers: event.context.headers,
params: getQuery(event),
body
})
return res || { userInfo: {} }
})
nuxt服务端中间件处理请求(所有通往nuxt服务端的请求,以及nuxt服务端响应前端过程都会在这里被拦截处理)
import { getHeaders } from 'h3'
export default defineEventHandler((event) => {
const reqHeaders = getHeaders(event)
const ssrHeader = new Headers()
const { app } = useRuntimeConfig()
ssrHeader.set('cookie', reqHeaders.cookie)
// 往nuxt请求注入请求基地址
event.context.baseUrl = app.base_url
// 往nuxt请求注入请求头
event.context.headers = ssrHeader
})
前使用useFetch调用nuxt接口即可
<srcipt setup>
const { data: result } = await useFetch('/api/list')
</script>
路由守卫
nuxt中的路由守卫是在客户端middleware
使用defineNuxtRouteMiddleware
路由中间件功能实现的。nuxt中设计的客户端的middleware
主要是用来拦截路由的,即客户端的路由切换会通过客户端的middleware
。如果客户端向nuxt服务端发请求,则请求都会被服务端的middleware
拦截处理。客户端的中间件文件名如果使用.global.js
,则nuxt会识别为这是一个全局生效的中间件。由于我们这里实现的是全局路由守卫功能,所以文件名命名为default.global.js
。
// /middleware/default.global.js
/**
* @description 全局路由守卫
*/
import { jumpToLogin } from '@/utils/utils'
export default defineNuxtRouteMiddleware((to, from) => {
const routeMeta = useRouteMeta()
const { getToken, getUid } = useAuth()
if (to.meta.needAuth && (!getToken() || !getUid())) {
// 需要授权的页面验证是否登录
jumpToLogin()
return abortNavigation()
}
// 记录当前要访问页面的元信息(必须置于鉴权逻辑之后),供http模块鉴权之用
routeMeta.value = to.meta || {}
})
状态管理
项目中的状态管理我同时使用了nuxt集成的轻量级的useState
和vue官方推荐的pinia
使用useState
封装的hook直接放到/composables/store
文件夹下
// composables/store/base.js
/**
* @description 项目基础状态管理模块
*/
import { USER_INFO, ROUTE_META, LOCALE_TYPE } from '@/constants/state'
// 用户信息管理
export const useUserInfo = () => useState(USER_INFO, () => {
return {}
})
// 路由元数据
export const useRouteMeta = () => useState(ROUTE_META, () => {
return {}
})
// 国际化 - 本地语言类型
export const useLocale = () => useState(LOCALE_TYPE, () => {
return 'zh'
})
pinia
模块直接放到/composables/pinia
文件夹下管理维护。因为nuxt中pinia存在,刷新状态丢失的情况,所以在客户端的plugins中集成了pinia-plugin-persistedstate
插件,实现状态数据持久化能力。
// plugins/pinia.js
// https://prazdevs.github.io/pinia-plugin-persistedstate/zh/guide/config.html
/**
* @description pinia数据持久化插件
*/
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.$pinia.use(piniaPluginPersistedstate)
})
国际化
项目中使用的国际化插件是vue-i18n
,配置如下
// i18n.config.js
import en from "assets/lang/en_us.json";
import zh from "assets/lang/zh_cn.json";
export default defineI18nConfig(() => ({
legacy: false, // 是否兼容之前
fallbackLocale: 'en', // 区配不到的语言就用en
messages: {
en,
zh
}
}))
// /assets/lang/en_us.json
{
"home": "Home"
}
// /assets/lang/zh_cn.json
{
"home": "主页"
}
为了使用方便,我特地在文件夹下封装了一个hook
/**
* @description 提供国际化功能的hook
*/
export function useI18nHook() {
const { locale, setLocale } = useI18n()
const localeLang = useLocale()
// 初始化本地语言类型
localeLang.value = locale
const setLocaleLang = (type) => {
setLocale(type)
localeLang.value = type
}
return {
locale: localeLang,
setLocale: setLocaleLang
}
}
在.vue
模版中使用直接如下
<template>
<div>{{ $t('home') }}
</template>
不仅页面代码需要国际化处理,UI组件库也需要国际化处理,acro-design
UI库使用a-config-provider
标签在App.vue
文件中全局注入本地语言
<template>
<div>
<a-config-provider :locale="locale">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</a-config-provider>
</div>
</template>
<script setup>
import enUS from '@arco-design/web-vue/es/locale/lang/en-us'
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn'
const locales = {
zh: zhCN,
en: enUS
}
const { locale: langLocale } = useI18nHook()
const locale = computed(() => {
return locales[langLocale] || zhCN
})
useHead({
link: [
{ rel: 'shortcut icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', href: '/favicon.ico' }
]
})
</script>
其他配置
Layout
// /layout/default.vue
<template>
<div class="layout-container">
<Header />
<main class="content">
<slot />
</main>
<Footer />
</div>
</template>
<style scoped lang="scss">
.layout-container {
min-height: 100vh;
.content {
min-height: calc(100vh - 447px);
background: #f7f8fa;
}
}
</style>
权限管理HOOK
// /composables/auth.js
/**
* @description 提供权限管理功能
*/
import { TOKEN_KEY, UID_KEY } from '@/constants/auth'
const MODE = import.meta.env.VITE_MODE
const DomainMap = {
development: 'demo.com',
production: 'demo.cn',
}
export function useAuth() {
const cookieToken = useCookie(TOKEN_KEY, { domain: DomainMap[MODE] }
const cookieUID = useCookie(UID_KEY, { domain: DomainMap[MODE] })
const getToken = () => {
return cookieToken.value
}
const setToken = (token) => {
cookieToken.value = token
}
const removeToken = () => {
cookieToken.value = undefined
}
const getUid = () => {
return cookieUID.value
}
const setUid = (uid) => {
cookieUID.value = uid
}
const removeUid = () => {
cookieUID.value = undefined
}
return {
getToken,
setToken,
removeToken,
getUid,
setUid,
removeUid,
}
}
全局Toast HOOK
toast提示使用的是vue-toast-notification
插件
// /composables/toast.js
/**
* @description 全局用toast提示方法
*/
import { useToast as useToastNotification } from 'vue-toast-notification'
export function useToast() {
return useToastNotification()
}
Eslint配置
配置里继承的@nuxt/eslint-config
的规则较为严格,不用也可以去掉
module.exports = {
root: true,
extends: ['@nuxt/eslint-config'],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
'vue/multi-word-component-names': 0/**针对单个单词组件报错的规则关闭 */,
'no-undef': 0, /**关闭变量未定义检查 */
'no-debugger': 2 /**禁用 debugger */,
'no-dupe-args': 2 /**禁止 function 定义中出现重名参数 */,
'no-dupe-keys': 2 /**禁止对象字面量中出现重复的 key */,
'no-empty': 1 /**禁止出现空语句块 */,
'no-ex-assign': 1 /**禁止对 catch 子句的参数重新赋值 */,
'no-extra-boolean-cast': 1 /**禁止不必要的布尔转换 */,
'no-extra-parens': 1 /**禁止不必要的括号 */,
'no-extra-semi': 1 /**禁止不必要的分号 */,
'no-func-assign': 1 /**禁止对 function 声明重新赋值 */,
'no-irregular-whitespace': 1 /**禁止在字符串和注释之外不规则的空白 */,
'no-unexpected-multiline': 1 /**禁止出现令人困惑的多行表达式 */,
'no-unreachable': 1 /**禁止在return、throw、continue 和 break语句之后出现不可达代码 */,
'use-isnan': 1 /**要求使用 isNaN() 检查 NaN */,
'dot-location': 1 /**强制在点号之前和之后一致的换行 */,
'eqeqeq': 2 /**要求使用 === 和 !== */,
'no-alert': 1 /**禁用 alert、confirm 和 prompt */,
'no-case-declarations': 1 /**不允许在 case 子句中使用词法声明 */,
'no-else-return': 1 /**禁止 if 语句中有 return 之后有 else */,
'no-empty-function': 1 /**禁止出现空函数 */,
'no-eq-null': 1 /**禁止在没有类型检查操作符的情况下与 null 进行比较 */,
'no-eval': 1 /**禁用 eval() */,
'no-fallthrough': 1 /**禁止 case 语句落空 */,
'no-lone-blocks': 1 /**禁用不必要的嵌套块 */,
'no-redeclare': 1 /**禁止使用 var 多次声明同一变量 */,
'no-self-assign': 1 /**禁止自我赋值 */,
'no-self-compare': 1 /**禁止自身比较 */,
'no-unmodified-loop-condition': 1 /**禁用一成不变的循环条件 */,
'vars-on-top': 1 /**要求所有的 var 声明出现在它们所在的作用域顶部 */,
'eol-last': 1 /**强制文件末尾至少保留一行空行强制文件末尾至少保留一行空行 */,
}
}
Premitter配置
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none",
"useTabs": true
}
package.json
{
"name": "app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build --dotenv .env.production",
"build:dev": "nuxt build --dotenv .env.development",
"serve": "nuxt dev --dotenv .env.local",
"generate": "nuxt generate",
"preview": "nuxt preview --dotenv .env.production",
"postinstall": "nuxt prepare",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier"
},
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"arco-design-nuxt-module": "^0.1.0",
"blueimp-md5": "^2.19.0",
"clipboard": "^2.0.11",
"cos-js-sdk-v5": "^1.8.1",
"dayjs": "^1.11.11",
"nuxt": "^3.12.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"vue": "^3.4.29",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.3",
"vue-toast-notification": "^3.1.2"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.3.13",
"@nuxtjs/i18n": "^8.3.1",
"postcss-aspect-ratio-mini": "^1.1.0",
"postcss-px-to-viewport": "^1.1.1",
"prettier": "^3.3.2",
"sass": "^1.77.6",
"sass-loader": "^14.2.1"
}
}
环境变量
环境变量定义了.local.env
、.production.env
、.development.env
这里只单列一个
# 环境变量
VITE_MODE=development
# 接口基地址
VITE_BASE_URL=https://demo.cn
写在最后
扫码关注作者微信公众号fever code
,获取一手技术分享