Nuxt3踩坑记录

1. 环境变量

按照官方文档的提示,根目录新建一个 .env.xxx (development、test、production,或其他自定义模式) 文件,在 package.json 的启动命令中通过 --dotenv 指定文件

"script": {
  "start": "nuxt dev --dotenv .env.xxx"
}

在代码中打印 .env.xxx 中定义的变量 process.env.xxx,最终发现服务端能正常打印,而客户端始终为 undefined
解决方案

// nuxt.config.ts 中配置
export default defineNuxtConfig({
  runtimeConfig: {
    // 需要写在public里面,否则客户端无法访问
    public: {
      xxx: process.env.xxx
    }
  }
})
// 需要使用的地方
const runtimeConfig = useRuntimeConfig()
runtimeConfig.public.xxx 即可使用

2. 布局与中间件

nuxt 会根据 pages 文件夹的结构自动生成路由,由于某些地方不太方便,于是使用 app/router.options.js 自定义了路由

项目默认是使用 layout/default.vue 作为布局组件的,但极个别页面需要自定义 layout
于是在 layout 文件夹下新建 custom.vue,并按照文档说明在需要使用的地方添加如下代码

definePageMeta({
  layout: 'custom'
})

配置起来挺简单的,但是并没有什么卵用,依然还是使用的默认布局。于是再把官方文档看了一遍,确认没有写错,但就是没有效果,最后在 api 文档的 utils 中看到这样一个方法

果断试了一下

<script setup>
  setPageLayout('custom')
</script>

搞定,问题解决,但依然疑惑为啥 definePageMeta 这种写法无效,直到后来用到了中间件
由于某些页面既有web端又有H5,需要在路由中判断设备类型,如果使用电脑访问H5地址,则需要重定向到web端,反之亦然,于是写了一个路由中间件,在存在双端的页面中使用

definePageMeta({
  middleware: ['redirect']
  // 或 middleware: 'redirect'
})

然而跟配置 layout 一样,根本没有生效,看了一下文档,也没有类似 setPageLayout 这样的方法。
于是各种谷歌百度,最终找到了原因,自定义的路由 definePageMeta 会失效,需要在路由的meta中定义(不知道是看漏了,还是文档确实没写,真的坑)

// app/router.options.js
export default {
  routes: _routes => [
    {
        ...,
        meta: {
          layout: 'custom',
          middleware: 'redirect'
       }
     },
     {...},
     ...
   ]
}

3. echarts 报错

通过 npm 安装后直接在页面中引入

<script setup>
import * as echarts from 'echarts/core'
</script>

结果报错

Cannot use import statement outside a module

不能在模块外使用 import 语句,一脸懵逼,明明是模块内,突然想起之前在 nuxt2 中是不能在服务端引入 echarts 的,于是将上面代码移到了只在客户端执行的插件中,并挂载在nuxtApp上

// plugins/xxx.client.js
import * as echarts from 'echarts/core'
export default defineNuxtPlugin(nuxtApp => {
  nuxtApp.provide('echarts', echarts)
})

接着就可以直接在 vue 文件中使用了

const nuxtApp = useNuxtApp()
const chart = ref(null)
onMounted({
  chart.value = nuxtApp.$echarts.init(document.querySelector('#xxx'))
  chart.value.setOption({...})

  window.addEventListener('resize', () => {
    chart.value.resize()
  })
})

展示完美,没有问题,然而就在我改变窗口宽度,触发 chart.resize() 的时候,问题来了

看不懂,根本看不懂,无奈只好需求谷歌百度的帮助,结果发现居然没人提到这个问题,不知道是还没人在 nuxt3/vue3 中使用过 echarts,还是我太蠢了。最终在 echarts 官方文档中找到了答案

将 const chart = ref(null) 改为 const chart = null 或 const chart = shallowRef(null) 问题解决,如果需要响应式就使用后面一种

4. 路径别名

自定义组件,由于组件内容较少,不想单独搞一个vue文件,采用了下面的方式

<template>
   <div class="test">
    <item name="abc" value="123"></item>
    <item name="xyz" value="345"></item>
    <item name="lmn" value="678"></item>
  </div>
</template>
<script setup>
defineOptions({
  components: {
    item: {
      template: `<div>
        <span>{{ name }}:</span>  
        <span>{{ value }}</span>
      </div>`,
      props: ['name', 'value']
    }
  }
})
</script>

刷新页面时会看到三条记录都渲染出来了,但是紧接着第一条会消失,如下图所示

请求响应的内容
最终看到的效果

可以看出,服务端渲染的时候是没有问题的,但是客户端接管后第一条内容被移除了,并且控制台有警告信息

Component provided template option but runtime compilation is not supported in this build of Vue. Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js".

通过路径别名的方式修改vue为完整版本

// 这是 nuxt3 官网文档中提供的别名配置方式
export default defineNuxtConfig({
  alias: {
    vue: 'vue/dist/vue.esm-bundler.js'
  }
})

配置完成后,问题解决了,三条记录都被完整的渲染了出来。但是,新的问题来了

Could not load F:\nuxt3-demo\node_modules\vue\dist\vue.esm-bundler.js\server-renderer (imported by node_modules/nuxt/dist/core/runtime/nitro/renderer.js)

原因是 nuxt 框架中有引入 vue/server-renderer 文件,配置别名后,相当于引入路径变成了 vue\dist\vue.esm-bundler.js\server-renderer,这显示是不对的,于是加一个$匹配结尾

vue$: 'vue/dist/vue.esm-bundler.js

修改后警告又出现了,之前在 nuxt2 中这样写是没问题的,大概 webpack 支持这种写法,但是 vite 不支持,于是又去翻阅了 vite 的配置文档,改成了如下形式

export default defineNuxtConfig({
  alias: [{
    find: /^vue$/,
    replacement: 'vue/dist/vue.esm-bundler.js'
  }]
})

结果ts直接报错,不能将类型“{ find: RegExp; replacement: string; }”分配给类型“string”。
最终正确配置方式如下

export default defineNuxtConfig({
  vite: {
    resolve: {
      alias: [{
        find: /^vue$/,
        replacement: 'vue/dist/vue.esm-bundler.js'
      }]
    }
  }
})

5. 动态引入图片

在项目中,有些本地图片是需要根据接口字段来确定的,因此没办法直接写死路径,只能动态引入
在 webpack 项目中,可以通过 require 来实现,但是 vite 并不支持,而是通过以下方式来实现的

<img :src="imgSrc" />
...
const imgSrc = computed(() => {
  const imgName = data.value.imgName // data 是接口返回的数据
  return new URL(`../assets/images/${imgName}.png`, import.meta.url).href
})

于是在 nuxt3 中尝试了一下,本以为轻松搞定,结果却出人意料

热更新不在服务端渲染
刷新页面服务端渲染

可以看到,客户端渲染跟服务端渲染路径是不一样的,服务端渲染的路径有问题,图片显示不出来
于是又去翻阅了 vite 的官方文档,看到下面这段话

可是它也没说用什么方法来替代,只能去网上找答案了,有人说直接写路径就行,紧接着就试了一下

<img :src="`../../assets/rating/${imgName}.png`" /> // 这里比上面多一个 ../,是因为路由有一个 baseURL
...
const imgName = computed(() => data.value.imgName)

不管是客户端渲染还是服务端渲染,都能完美展示,路径还保持一致,完美

本以为这样就算解决了,直到后面打包发布,发现图片加载不出来,只能继续填坑了(把图片放到 public 里面,直接写路径是可以的,但是不想这样做)
既然 new URL 的方式在客户端可以,直接写路径的方式在服务端可以,那根据环境判断是不是就行了?

const imgSrc = computed(() => {
  const imgName = data.value.imgName
  if (process.server) return `../../assets/rating/${imgName}`
  return new URL(`../assets/rating/${imgName}.png`, import.meta.url).href
})

结果还是没什么用,process.server 的变化并不会触发计算属性的重新执行
突然想到之前在 nuxt2 项目中,某些第三方库不能在服务端导入的时候,可以使用 require 或 import() 在mounted 中导入,既然如此,那这里能不能用 import() 呢?迫不及待的试一下

<img :src="img" />
...
const img = ref('')
watchEffect(async () => {
  const imgName = data.value.imgName
  img.value = (await import(`../assets/rating/${imgName}.png`)).default
})

问题解决,开发环境无论哪端渲染都没有问题,build 后图片也被打包成了 base64(但是对这种方式不太满意,代码有点复杂了)

6. i18n

项目中需要使用国际化,发现 nuxt 有提供一个 @nuxtjs/i18n 模块,于是照着文档三下五除二的就撸完了

结果一运行,报了一堆看不懂的错误

经过我的一番测试,发现是因为在字符串中使用了 p 标签,后面我尝试了其他 html 标签好像都不行,去掉 html 标签后,成功运行,但是结果还是无法让人满意

这他娘的是个什么鬼?我想要的是“超强”,而不是这一坨。字符串中使用 html 标签,以及数组通过下标取值,这两种写法在 vite + vue3 的项目中是没有问题的,不知这里为何如此奇怪,于是我在两个项目中直接打印了 tm('array') 的值

左边是 vite + vue3 打印出来的结果,直接就是一个字符串数组,右边是 nuxt3 打印出来的结果,居然是一个函数数组,同样都是 9.x 的版本,结果却大相径庭

最后经过尝试,发现在 nuxt3 中通过 $t('array[0]') 是可以直接取到值的,但是在实际项目中,这个下标是根据接口返回的数据来确定的,所以最终只能通过模板字符串插入或者字符串拼接的形式,感觉太复杂,而且还不能用 html 标签,很不方便

于是改成了在插件中去创建i18n

export default defineNuxtPlugin(nuxtApp => {
  const i18n = createI18n({
    legacy: false,
    locale: 'cn',
    warnHtmlMessage: false,
    messages: {
      cn: {...}
    }
  })
  nuxtApp.vueApp.use(i18n)
})

完美解决以上两个问题,但是事情还没完,后面有一次在中间件中需要使用 i18n,直接 const {t} = useI18n(),结果报错 Must be called at the top of a `setup` function

只能在 setup 函数中使用,不能在中间件里面用,后面无意中发现 nuxtApp 中有一个 $i18n 的属性,而 useNuxtApp() 是可以在中间件中使用的,这不就解决了吗?结果

Not found 'title' key in 'en-US' locale messages.

这 en-US 是什么东西?locale 明明设置的 cn,然后我打印了一下 useI18n() 跟 nuxtApp.$i18n 的值,发现它们的 id 居然不一样,显然这不是同一个对象

nuxtApp.$i18n 是 @nuxtjs/i18n 帮我们创建并挂载的,同时也挂载到了 vue 上,所以第一种创建方式,它们的 id 是一样的,而当我们在插件中使用 createI18n() 并 nuxtApp.vueApp.use(i18n) 之后,覆盖了原本挂载在 vue 上的对象,所以导致两者不一致

当然也可以直接用 nuxtApp.vueApp.__VUE_I18N__.global,但是太长了,我不喜欢,所以只好来个骚操作,在插件最后面加一句代码

 nuxtApp.provide('i18n', i18n.global)

好家伙,直接报错,Cannot redefine property: $i18n,不能覆盖它原本的 $i18n,那就改个名字吧

 nuxtApp.provide('vueI18n', i18n.global)

然后在其他地方就可以通过 nuxtApp.$vueI18n 去使用了

最终还是无法理解为啥第一种方式不能在字符串中使用 html 标签,以及使用 tm 获取多语言数组然后通过下标取值,严重怀疑 @nuxtjs/i18n 对 vue-i18n 做了一个恶心的封装

问题并没有完全解决,原本有文案 day:“{x} 天”,通过t('day', {x: 2}) 可以输出文案“2天”,然而,打包后却出了问题,{x}并没有被替换成2,直接输出了 “{x} 天”,并伴有如下警告:

The message format compilation is not supported in this build. Because message compiler isn't included. You need to pre-compilation all message format. So translate function
return 'xxx'.

那是因为默认使用的vue-i18n是运行时版本的,需要替换成完整版本,在配置别名的地方添加如下代码即可

{
  find: /^vue-i18n$/,
  replacement: 'vue-i18n/dist/vue-i18n.esm-bundler.js'
}

7. useFetch

用惯了 axios,第一次使用 useFetch 相当的不习惯,途中踩了不少的坑

关于服务端/客户端请求,useFetch/useLazyFetch,是否 await 的一些说明

  1. 未设置 server: false 的请求,只有在页面初次加载时,才是服务端请求,路由跳转时为客户端请求
  2. 服务端请求时,useFetch 与 useLazyFetch 没有区别
  3. 服务端请求时,是否 await 对页面渲染没有影响,只决定后面的代码是否会等待请求完成
  4. 初次加载页面,客户端请求即使 await,后面代码也拿不到数据
  5. 路由跳转时,请求前加上 await,useFetch 后面能拿到数据,useLazyFetch 不行
  6. 路由跳转时,会等待 useFetch 请求完成再切换页面,而 useLazyFetch 则不会
a. 触发多次请求

项目中有个协议页面,多种类型,调用同一个接口,通过 valueType 字段来区分,每次切换的时候修改 params.valueType 的值,然后重新发送请求,主要代码如下

const baseURL = 'http://192.168.x.xx:xxxx/api'
const params = reactive({valueType: 1})
const {data, refresh} = await useLazyFetch(baseURL + '/Global/GetProtocolValue', {params})

const changeType = type => {
  params.valueType = type
  refresh()
}

结果发现每次切换类型,都会重复发送两次请求,并且第一次请求会被取消掉

起初觉得这个 refresh 函数可能有问题,于是把它注释掉看看会怎样,结果居然正常了,就他妈很诡异,切换类型时竟然会自动发请求,后来仔细看了一下文档

问题就出在这里,因为 params 是响应式的,当它改变后就会重新发送请求,所以这里直接修改 params.valueType 的值就行了,没必要再调用 refresh 方法
如果觉得这种方式不好把控,希望自己调用 refresh 方法,可以把 params 改成非响应式的,或者在调用 useFetch 的时候加一个 watch: false 的配置

const {data, refresh} = await useLazyFetch(..., {params, watch: false})
b. await 无效

之前用 axios 时,习惯在前面加一个 await, 然后在之后的代码中处理获取到的数据,然而在使用 useFetch 的时候,似乎出了点问题
当使用服务端渲染的时候,这种做法是可行的,如果换成客户端渲染,结果数据变成了 null

const {data, refresh} = await useFetch(baseURL + '/Global/GetProtocolValue', {params, server: false})
console.log(data.value) // 打印结果为 null

文档中对此同样也有说明

客户端请求时,即使加了 await,后面的代码的执行也不会等待请求完成,如果需要对数据进行处理,可以配置 transform 参数,或者使用 watch 去监听数据的变化再做处理

watch(data, () => {
  if (data.value) {
    // 在这里对数据进行处理
  }
})
c. 数据类型不对

在使用 useFetch 时,api 地址的设置方式有两种,一种是直接拼在 url 前面,另一种则是通过 baseURL 配置

// 方式一
const {data} = await useFetch('https://test.api.com/h5/getUserInfo')
// 方式二
const {data} = await useFetch('/h5/getUserInfo', {baseURL: 'https://test.api.com'})

刚开始是把 api 地址直接拼在 url 前面的,这种写法用起来完全ok,后来尝试了一下设置为 baseURL,结果刷新页面后数据居然没了,没了......

经过我的各种测试,发现了一个奇怪的问题,当客户端请求时,两种方式没有区别,但是服务端请求时,baseURL 方式返回的数据类型有问题,它本该是 object 类型的,但结果它却是 string 类型的

于是加了一个请求拦截器,在请求之前打印了一下请求信息,发现服务端请求比客户端请求多了一坨奇怪的东西

而问题就出在这个 accept 上,它的作用就是告诉服务器,客户端这边想要什么类型的数据,从图中可以看出,它被设置成了 text/html,因此返回的数据类型就变成了 string
手动给 accept 设置一个值,问题就可以解决了

const {data} = await useFetch('/h5/getUserInfo', {
  baseURL: 'https://test.api.com',
  headers: {accept: 'application/json'} // 也可以设置成 */*
})

以上这个问题取决于服务器代码逻辑,如果服务器不判断 accept 字段,固定返回 json 格式,就没有这个情况

d. 错误处理

公司接口返回的数据格式如下所示

{
  bodyMessage: {} // 前端需要的数据
  code: 0 // 状态码 0 表示成功,-1 表示鉴权失败
  subCode: 'BF11800' // 子状态码 最后两位是 00 表示成功,其他表示失败
  message: '' // 错误信息
}

因此每个请求都需要对 code 及 subCode 进行判断,如果成功,则处理 bodyMessage 里面的数据,失败则根据不同的 subCode 做不同的处理

为了方便,对 useFetch 做了一个二次封装,代码如下

export default (url, options) => {
  const rtConfig = useRuntimeConfig()
  return useFetch(url, {
    baseURL: rtConfig.public.apiUrl,
    onRequest({options}) {...},
    onResponse({response}) {
        const {code, subCode, bodyMessage} = response._data
        if (!code && subCode.endsWith('00')) {
          return (response._data = bodyMessage) // 如果成功,就把 bodyMessage 赋值给 data
        }
        return Promise.reject(response._data) // 如果失败,就把整个 data 抛出去
    }
  })
}

这种写法在 axios 拦截器是没有问题的,但这里不行

const {data, error} = await fetch(...)
console.log(error.value)

打印了一下 error.value, 本以为会是整个对象,结果

经过一番尝试,最终得出如下结论

  1. 无法修改返回的 errorr 对象
  2. 如果 reject 一个字符串,那么 error 的 message 就是这个字符串
  3. 如果 reject 一个带 message 字段的对象,那么 error 的 message 就是这个字段
  4. 如果 reject 一个不带 message 的对象,那么 error 的 message 为空

所以就可以先使用 JSON.stringify 将对象转成字符串,然后通过 JSON.parse(error.value.message) 拿到这个对象

8. 第三方字体(未解决)

项目中用到了第三方字体,将字体文件放到 assets/font 目录下,然后在全局 css 文件中设置

@font-face {
  font-family: "GoogleSans";
  src: url("../font/GoogleSans-Regular.ttf");
  font-display: swap;
  font-weight: 400;
}

本地开发没有任何问题,但是打包后字体文件不见了,之后又把路径改成绝对路径,问题依然存在
放在 public 里面可解决,但是不想什么都往 public 里面塞,就想放在 assets 里面
之前在 nuxt2、vue2、vue3 中都是这么写的,完全没问题,这坑爹的 nuxt3

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容