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-configexport 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.responseif (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.jsimport { 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 bodyif (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.jsimport en from "assets/lang/en_us.json";
import zh from "assets/lang/zh_cn.json";export default defineI18nConfig(() => ({legacy: false, // 是否兼容之前fallbackLocale: 'en', // 区配不到的语言就用enmessages: {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 = localeconst 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_MODEconst 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
,获取一手技术分享