[Nuxt系列02]项目开发过程中遇到的问题们

·  阅读 1387
[Nuxt系列02]项目开发过程中遇到的问题们

两个月前写的东西,现在再来看竟然感觉有点……一言难尽,索性不做大的修改了。本篇主要介绍在曾经的开发过程中带给我困扰的 4 个问题:对于移动|pc 的样式适配;vuex 数据持久化;用户登录态保持;通过环境变量划分从开发到生产环境。

一、只有一个域名的我们,怎样区分终端加载移动|pc两套样式?

其实刚拿到开发任务的时候,我们只是打算做一套自适应的页面,以后就可以“轻轻松松”维护一套代码。然而,当见到设计稿的那一刻,我理解了那句话,“不要你觉得,要我觉得”。移动和pc的差异化过大导致自适应的想法不再可行,后来随着项目的发展,也确实证明那个“愉快的想法”并不可行。(其实通过媒体查询实现也不是不行,总之,两套样式。)

相信同行的小伙伴看到标题的那一刻心中已有答案,根据浏览器的 userAgent 判断是何种终端不就可以了吗?中心思想当然没有问题,这里先贴一下公共方法:

/**
 * 检查客户端环境
 * @param {String} ua 
 * 判断优先级 企业微信/微信/安卓/ios/pc
 */
const regWxwork = /wxwork/iu;
const regWx = /MicroMessenger/iu;
const regAndroid = /Android|Adr/iu;
const regIOS = /\(i[^;]+;( U;)? CPU.+Mac OS X/iu;
export const checkEnv = function(ua) {
  if(regWxwork.test(ua)) {
    return 'wxwork';
  }else if(regWx.test(ua)) {
    return 'micromessenger';
  }else if(regAndroid.test(ua)) {
    return 'android';
  }else if(regIOS.test(ua)) {
    return 'ios';
  }else {
    return 'pc'
  }
}
复制代码

上述方法中,之所以要判断企业微信和微信环境,是为了方便在某些页面适配这两个环境不同的分享代码。有了方法,只要拿到ua,区分终端的问题便迎刃而解,只要配合 v-if | v-else 便可以实现加载不同模板的目的了。

然而,终究好事多磨,按照传统的思路,我们定义一个 isMobile 的响应式数据,初始值不论是 false 还是 true 都将面临一个先有鸡还是先有蛋的问题,假如 isMobile 初始值为 true,却在 pc 浏览器中访问,则会出现一定程度的闪屏现象,影响用户体验。反之亦然。同时,这还将造成客户端和服务端渲染不匹配,一定程度上使服务端渲染失去了意义。开发阶段可能有如下报错:

[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.

复制代码

于是目标便是在服务端或者说在页面渲染前确定 isMobile 变量的确切值。接下来遍历文档,我们将目光瞄向 asyncData 方法,它是 Nuxt 对 Vue 页面组件的扩展增强,文档中这样描述它:...可以在服务端或路由更新之前被调用...被调用的时候,第一个参数被设定为当前页面的上下文对象...用 asyncData方法来获取数据并返回给当前组件...。而它的参数“上下文对象”中有什么呢?我们很欣喜地发现了 req 的身影,于是通过取值 req.headers['user-agent'] 便愉快难道了 userAgent 字符串。这里需要注意的是,asnycData 只在首屏加载时运行于服务端,其他时候我们是拿不到 req 的。于是最终有如下代码:

// ...
asyncData({ req, }) {
  const ua = process.server 
    ? req.headers['user-agent'] 
    : navigator.userAgent;
  const isMobile = checkEnv(ua) !== 'pc'
  return isMobile;
},
// ...

复制代码

于是继续吭哧吭哧写页面,一个两个三个...终于,怎么每个页面都得来这一套(吐槽)?!于是想到将结果存入 vuex 状态树中,但这样依然还是每个页面都要来一遍。这时我们又看到了 nuxtServerInit 方法,文档中这样描述它:如果在状态树中指定了 nuxtServerInit 方法,Nuxt.js 调用它的时候会将页面的上下文对象作为第2个参数传给它(服务端调用时才会酱紫哟)。当我们想将服务端的一些数据传到客户端时,这个方法是灰常好用的。于是又有了如下代码:

// store/action.js
nuxtServerInit({ commit }, { req }) {
  const ua = req.headers['user-agent'];
  const browserEnv = checkEnv(ua);
  const isMobile = browserEnv !== 'pc';
  commit({ type: 'browserEnv', params: { isMobile, browserEnv } });
},
// store/mutation.js
browserEnv(state, payload) {
  state.isMobile = payload.params.isMobile;
  state.browserEnv = payload.params.browserEnv;
},
// pages/somepage.vue
computed: {
  isMobile() { return this.$store.state.isMobile; },
},

复制代码

基于以上,我们在相同域名和路由的情况下,可以适配移动和 pc 两套样式,并使用相同的 js 代码;同时弊端也很明显,在一个页面中会同时存在移动和 pc 两套样式,一定程度上损耗了页面的加载效率。

<template>
  <div v-if="isMobie">mobile</div>
  <div v-else>pc</div>  
</template>

复制代码

二、vuex 数据的持久化问题

通常,vuex 里的数据是写入内存的,所以当我们刷新浏览器的时候,咦,数据不见了!这是无法接受的,所以聪明的开发者们造出了各种轮子来解决这个问题,比如我们在本项目中使用了插件 vuex-persistedstate 来解决部分数据丢失的问题。而其原理,无非是监听当前页面将要离开(主要是刷新页面)的时候,将存在于 store 中的数据存储到 cookie | sessionStorage | localStorage,总之,选择一个你认可的存储方式,使我们稍后能够将数据再次写入 vuex store 中。像这样简单的实现:

if (sessionStorage.getItem("store")) {
  this.$store.replaceState(Object.assign({}, this.$store.state, JSON.parse(sessionStorage.getItem("store"))))
}
window.addEventListener("beforeunload", () => {
  sessionStorage.setItem("store", JSON.stringify(this.$store.state))
})

复制代码

其实,在项目一开始的时候,我们并不太依赖于 vuex 数据的持久化,直到有了某个新的需求:

我们有很多的推广页面,这些链接会通过查询字符串的形式携带一些参数,标记用户来源以及其他的一些信息,这些信息需要在用户于站内访问的整个周期得到记录,一旦用户产生了提交表单的操作,那这些信息也就派上了用场,汇总至后台方便产品或运营分析数据,从而结束了它们的光荣使命。

以及后来的更多新的需求~

怎么办呢?

  • 将数据在路由改变(用户访问)的过程中层层传递(傻子才这么干吧,费力不讨好还容易出错);
  • 将数据存到 localStorage 中,当需要的时候取出来,销毁(基本满足需求);
  • 将数据存入 vuex store 中,but,一旦有刷新页面的行为,数据丢失;

虽然第二种方法已经可以满足需求,但 vuex 对状态的中心化管理显然更加优雅,东西就摆在眼前为啥不用呢?所以我们需要解决掉数据丢失的问题。

上文中提到了 vuex-persistedstate,它是一个 vuex 插件,其核心原理用到了 store 实例的 replaceState 方法和 subscribe 方法。先扔一个传送门,然后贴上其核心代码(全部代码总共不到80行):

return function(store) {
  const savedState = shvl.get(options, 'getState', getState)(key, storage);

  if (typeof savedState === 'object' && savedState !== null) {
    store.replaceState(merge(store.state, savedState, {
      arrayMerge: options.arrayMerger || function (store, saved) { return saved },
      clone: false,
    }));
  }

  (options.subscriber || subscriber)(store)(
    function(mutation, state) {
      if ((options.filter || filter)(mutation)) {
        (options.setState || setState)(
          key,
          (options.reducer || reducer)(state, options.paths || []),
          storage
        );
      }
    }
  );
};
复制代码

它通过 subcribe 方法订阅 mutation,从而能够在每次 mutation 调用时完成对新的 state 数据的存储;在 vuex 初始化时,通过 replaceState 方法将持久化的数据重新写入 store 中。并且它提供了对 Nuxt 的支持,支持可配置的持久化数据从而只对那些你认为需要的数据进行持久化,并给出了使用示例。

但喜闻乐见,并不能在服务端使用,而这也正是一个需要注意的点,确保不对将在 asyncData 等可能在服务端的执行过程中更新的 state 进行持久化配置,因为一旦这样做了,插件在客户端进行 replaceState 操作时将会把在服务端更新过的数据再次覆盖回旧的数据,造成莫名其妙的 bug。换个方向说,不要把配置了持久化的数据项在服务端进行更新!

下面上代码:

// plugin/vuex-persistedstate.js
import createPersistedState from 'vuex-persistedstate';

const paths = [
  'sourceMark',
  'test',
];

const storage = sessionStorage;
export default ({ store }) => {
  createPersistedState({ key: 'vuexpersisted', paths, storage })(store)
}

// nuxt.config.js
plugins: [
  { src: '~/plugins/vuex-persistedstate.js', ssr: false },
],

复制代码

于是,只有当 sourceMarktest 在经过 mutation 更新时,插件才会帮你把这两组数据存储至 sessionStorage 中。

三、保持用户的登录状态

在前后端分离的项目中,一般情况下,我们将用户登录后获取到的 token (这里以 token 为例)保存在 cookie 或 localStorage 中并在之后与服务器通信的过程中携带它,以此确认用户的身份。很显然在 Nuxt 项目中,作为选项之一的 localStorage 就被 pass 掉了,因为服务端无法进行 localStorage 的读写啊。

首先定义一个 cookie 的存取方法:

const setCookie = function(cName, value, expireHours) {
  let cookieVal = `${cName}=${encodeURI(value)};path=/`;
  if (expireHours > 0) {
    const date = new Date();
    date.setTime(date.getTime() + expireHours * 3600 * 1000);
    cookieVal = `${cookieVal};expires=${date.toGMTString()}`;
  }
  document.cookie = cookieVal;
}
const getCookie = function(cName, cookie) {
  if (!cookie) { return ''; }
  const arrCookie = cookie.split('; ');
  for (let i = 0; i < arrCookie.length; i++) {
    const arr = arrCookie[i].split('=');
    if (arr[0] === cName) { return decodeURI(arr[1]); }
  }
  return '';
}

复制代码

这样,用户登陆后,将其信息存入cookie,在需要使用的页面的 asyncData 方法中取出,并在登出或过期后销毁:

// somepage.vue
asyncData ({ req }) {
  const cookie = process.server ? req.headers.cookie : document.cookie;
  const token = getCookie('token', cookie);
  // ...
},

复制代码

如上基本能够满足需求,但我们仍然希望状态能够在 vuex 中统一管理和分发。这里提供两种思路:

  1. 结合 vuex-persistedstate 的方案。基于上述已有思路,在登录后直接将用户信息写入 store 中,然后设置用户信息为需要持久化的数据。这里需要注意,插件的 storage 项配置为 cookie,像这样(这里用到了 js-cookie 插件,当然也可以自己封装):
// plugins/vuex-persistedstate.js
createPersistedState({
  storage: {
    getItem: key => Cookies.get(key),
    setItem: (key, value) =>
      Cookies.set(key, value, { expires: 3, secure: true }),
    removeItem: key => Cookies.remove(key),
  },
})

复制代码

同时,不要在服务端对配置了持久化的用户信息字段进行更新,这在上篇中的第二节中也提到过。所以,当在页面的 asyncDatafetch 方法中用户登录态失效时,可以考虑跳转到登录页面(链接中携带一个查询字符串标志需要销毁用户信息,比如 /login?destory=y)后通过 commit 一个 mutation 删除 store 中的用户信息,vuex-persistedstate 也就同时清除了之前保存在 cookie 中的用户信息了。

  1. 第二种思路完全摒弃上述已有思路,通过结合 express-session + connect-redis + nuxtServerInit 方法 实现 session 的持久化并存取用户的登录状态。之所以引入 redis,就是为了解决 session 丢失的问题,毕竟一旦 node 崩了,或者会话结束及其他各种复杂的线上情况都会使用户的登录状态丢失。而 connect-reids 帮我们自动地将 session 数据迁移至 redis 数据库中,从此再也不用担心会话状态丢失的问题~

我们的项目中,线上环境使用了阿里云的 redis 服务,开发环境则在自己的电脑上简单配置 redis ,并设置密码为 123456,可以在下面的代码中看到。所以如果你没有一个现成的 reids 服务器的话,就需要单独安装配置了。

// server/index.js
const express = require('express');
const session = require('express-session');
const redisStore = require('connect-redis')(session);
const app = express();
const sessionConfig = {
  cookie: {
    secure: true,
    maxAge: 1000 * 60 * 60 * 24 * 30,
  },
  sessionStore: {
    host: '127.0.0.1',
    port: 6379,
    pass: '123456',
    db: 15,
    logErrors: true,
  },
};
app.use(session({
  secret: 'demo',
  resave: false,
  saveUninitialized: false,
  store : new redisStore(sessionConfig.sessionStore),
  cookie: sessionConfig.cookie,
}));

// store/action.js
// 需要在 Nuxt 的 express 中定义登录和登出接口(loginExp|logoutExp),以通过 session 存取和销毁用户信息
nuxtServerInit({ commit }, { req }) {
  if(req.session && req.session.userinfo) {
    commit({ type: 'userinfo', userinfo: req.session.userinfo });
  }
},
async userLogin({ commit }, payload) {
  try {
    const { data } = await loginExp(payload.userinfo);
    commit({ type: 'userinfo', userinfo: data.data });
  } catch (error) {
    // ...
  }
},
async userLogout({ commit }) {
  try {
    await logoutExp();
    commit({ type: 'userinfo', userinfo: null });
  }catch(error) {
    // ...
  }
},

// store/getter.js
export default {
  token: (state) => state.userinfo.token,
};

复制代码

附上 connect-redis 的传送门。

这样,在 asyncData 方法中或者计算属性中都可以很方便地从 store 中拿到 token 了;redis 的引入也方便了以后继续对 server 端代码做更多的扩展。

四、开发环境、测试环境和生产环境的划分

我们希望在生产环境的资源是压缩过的,而开发环境不需要;我们希望在生产环境的 css 是从 html 中剥离的,而测试环境不需要;我们希望在生产环境和开发环境加载不同的配置文件;我们希望生产、测试、开发的打包资源输出目录相互独立……

凡此种种,不论是传统的 vue 项目开发还是 Nuxt 项目,都面临着这样的需求。这就需要我们通过某些变量对这些需求进行控制。其实在项目初始化的时候已经给出了一个 NODE_ENV 环境变量,根据 development | production 来做简单的划分,webpack 对与生产环境和开发环境的划分也正是基于此。初始化的 package.json 文件:

{
  "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
  "build": "nuxt build",
  "start": "cross-env NODE_ENV=production node server/index.js"
}
复制代码

所以我们在此基础上定义一个我们自己的环境变量,比如就叫它 DEMO_ENV,并为它提供三个值 prod | test | dev,来细粒度区分项目在三个不同环境下的差异化配置。修改之后的 package.json 文件:

{
  "dev": "cross-env DEMO_ENV=dev NODE_ENV=development nodemon server/index.js --watch server",
  "build": "cross-env DEMO_ENV=prod NODE_ENV=production nuxt build",
  "start": "cross-env DEMO_ENV=prod NODE_ENV=production node server/index.js",
  "buildtest": "cross-env DEMO_ENV=test NODE_ENV=production nuxt build",
  "starttest": "cross-env DEMO_ENV=test NODE_ENV=production node server/index.js",
}
复制代码

但这样还不够,我们需要在 nuxt.config.js 中进行配置,使我们定义的环境变量能够映射到 process.env 中(见 Nuxt文档的 env 配置部分),以保证加入的环境变量在项目中随处可用:

// nuxt.config.js
module.exports = {
  env: {
    DEMO_ENV: process.env.DEMO_ENV,
  },
  buildDir: process.env.DEMO_ENV === 'prod' ? 'nuxtdist' : `nuxt${process.env.DEMO_ENV}`,
  extractCSS: process.env.DEMO_ENV === 'prod',
  publicPath: process.env.DEMO_ENV === 'prod' ? 'https://static.xxxcdn.com' : '/_nuxt/',
}

// somepage.vue
export default {
  asyncData({ query, env }) {
    const siteOrigin = env.DEMO_ENV === 'test' ? 'https://test.xxx.com' : 'https://xxx.com';
  },
}

// ...

复制代码

以上举例了几个应用场景,通过定义环境变量,就可以很方便地对各个环境进行细粒度的差异化配置,不必再手动改来改去,麻烦又容易出错。


原文链接

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改