Skip to content

17. 示例 [nuxt-20]:基于示例 [vuejs-22] 的移植

17.1. Présentation

本文旨在将示例 [vuejs-22](该示例是类型为 SPA 的应用程序 [vue.js])移植到 [nuxt] SSR 环境中。 [vuejs-22]是税务计算服务器的客户端应用程序,包含以下视图:

第一个视图是身份验证视图:

Image

第二个视图是税款计算视图:

Image

第三个视图是显示用户所做模拟列表的视图:

Image

上图显示可以删除第1个模拟。随后将显示以下界面:

Image

如果现在删除最后一个模拟,将显示如下新视图:

Image

我们将逐步将应用程序 [vuejs-22] 迁移至应用程序 [nuxt-20]。此处不再赘述 [vuejs-22] 的代码。 建议读者重新阅读文档《通过示例了解 VUE.JS 框架》。各个步骤将展示 [vuejs] 应用程序与 [nuxt] 应用程序之间的差异。

17.2. 步骤 1

[nuxt-20] 项目最初是通过复制 [nuxt-12] 项目获得的。该项目确实是一个很好的起点:

  • 它能够与税务计算服务器进行交互;
  • 能正确处理服务器返回的错误;
  • [nuxt]的客户端和服务器能够通过[nuxt]会话进行通信;

因此我们拥有良好的初始基础设施。我们的主要工作应是修改:

  • 页面。我们将采用 [vuejs-22] 项目中的页面,并需将其适配至新环境;
  • 商店管理功能。部分信息(如模拟列表)需新增,而其他信息可能不再需要;
  • 客户端和服务器路由管理(项目[nuxt]);

因此,首先通过复制项目 [nuxt-12] 来创建项目 [nuxt-20]:

Image

然后删除不再需要的页面和组件 [2]:

  • 组件 [components/navigation] 消失;
  • 布局 [layout/default] 消失;
  • 页面 [index, authentification, get-admindata, fin-session] 消失;

然后将 [vuejs-22] 和 [3] 中的元素整合到 [nuxt-20] 中:

  • 应用程序 [vuejs-22] 中的三个页面 [Authentification, CalculImpot, ListeSimulations] 移至文件夹 [pages];
  • 应用程序 [vuejs-22] 中的组件 [FormCalculImpot, Menu, Layout] 移至文件夹 [components];
  • [vuejs-22] 中的页面 [Main](此前作为 [layout] 服务于应用程序 [vuejs-22])将移至文件夹 [layouts];

将嵌入元素 [4] 重命名为:

Image

  • 在 [layouts] 中,[Main] 已更名为 [default],因为这是应用程序 [nuxt] 布局的默认名称;
  • 在 [pages] 中,页面 [Authentification] 已变为 [index],因为在应用程序 [vuejs-22] 中,[Authentification] 承担了这一角色;

此时,我们可以编译该项目以查看最初的错误。修改示例 [nuxt-12] 中的文件 [nuxt.config],以便现在执行 [nuxt-20]:


export default {
  mode: 'universal',
  /*
   ** Headers of the page
   */
  head: {
    title: 'Introduction à [nuxt.js]',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      {
        hid: 'description',
        name: 'description',
        content: 'ssr routing loading asyncdata middleware plugins store'
      }
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
  },
  /*
   ** Customize the progress-bar color
   */
  loading: false,

  /*
   ** Global CSS
   */
  css: [],
  /*
   ** Plugins to load before mounting the App
   */
  plugins: [
    { src: '@/plugins/client/plgSession', mode: 'client' },
    { src: '@/plugins/server/plgSession', mode: 'server' },
    { src: '@/plugins/client/plgDao', mode: 'client' },
    { src: '@/plugins/server/plgDao', mode: 'server' },
    { src: '@/plugins/client/plgEventBus', mode: 'client' }
  ],
  /*
   ** Nuxt.js dev-modules
   */
  buildModules: [
    // 文档:https://github.com/nuxt-community/eslint-module
    '@nuxtjs/eslint-module'
  ],
  /*
   ** Nuxt.js modules
   */
  modules: [
    // 文档:https://bootstrap-vue.js.org
    'bootstrap-vue/nuxt',
    // 文档:https://axios.nuxtjs.org/usage
    '@nuxtjs/axios',
    // https://www.npmjs.com/package/cookie-universal-nuxt
    'cookie-universal-nuxt'
  ],
  /*
   ** Axios module configuration
   ** See https://axios.nuxtjs.org/options
   */
  axios: {},
  /*
   ** Build configuration
   */
  build: {
    /*
     ** You can extend webpack config here
     */
    extend(config, ctx) {}
  },
  // 源代码目录
  srcDir: 'nuxt-20',
  // 路由器
  router: {
    // 应用程序的 URL 根目录
    base: '/nuxt-20/',
    // 路由中间件
    middleware: ['routing']
  },
  // 服务器
  server: {
    // 服务端口,默认 3000
    port: 81,
    // 监听的网络地址,默认 localhost:127.0.0.1
    // 0.0.0.0 = 该机器的所有网络地址
    host: 'localhost'
  },
  // 环境
  env: {
    // axios 配置
    timeout: 2000,
    withCredentials: true,
    baseURL: 'http://localhost/php7/scripts-web/impots/version-14',
    // 会话cookie配置 [nuxt]
    maxAge: 60 * 5
  }
}

随后对该项目生成 [build]:

Image

报告的错误如下:

1
2
3
4
5
6
7
Module not found: Error: Can't resolve '../assets/logo.jpg' @ ./nuxt-20/layouts/default.vue?...
Module not found: Error: Can't resolve './FormCalculImpot' @ ./nuxt-20/pages/calcul-impot.vue?...
Module not found: Error: Can't resolve './Layout' @ ./nuxt-20/pages/_.vue?...
Module not found: Error: Can't resolve './Layout' @ ./nuxt-20/pages/liste-des-simulations...
Module not found: Error: Can't resolve './Layout' @ ./nuxt-20/pages/calcul-impot.vue?...
Module not found: Error: Can't resolve './Menu' @ ./nuxt-20/pages/_.vue?...
Module not found: Error: Can't resolve './Menu' @ ./nuxt-20/pages/calcul-impot.vue
  • 第 1 行的错误表明引用了一张不存在的图片。我们将在 [vuejs-22] 中获取该图片;
  • 第 2 行错误显示组件 [./FormCalculImpot] 不存在。实际上,该组件现已移至 [@/components/form-calcul-impot];
  • [3-5] 行中的错误表明组件 [./Layout] 不存在。实际上,该组件现已移至 [@/components/layout] 中;
  • [6-7] 行中的错误表明组件 [./Menu] 不存在。确实,它现在名为 [@/components/menu];

将图像 [assets/logo.jpg] 添加到项目 [nuxt-20] 中:

Image

此外,我们将修正所有页面中组件的路径。以页面 [calcul-impot] 为例:


<!-- 视图定义 HTML -->
<template>
  <div>
    <Layout :left="true" :right="true">
      <!-- 右侧税款计算表单 -->
      <FormCalculImpot slot="right" @resultatObtenu="handleResultatObtenu" />
      <!-- 左侧导航菜单 -->
      <Menu slot="left" :options="options" />
    </Layout>
    <!-- 表单下方的税额计算结果显示区 -->
    <b-row v-if="résultatObtenu" class="mt-3">
      <!-- 三列空白区域 -->
      <b-col sm="3" />
      <!-- 九列区域 -->
      <b-col sm="9">
        <b-alert show variant="success">
          <span v-html="résultat"></span>
        </b-alert>
      </b-col>
    </b-row>
  </div>
</template>

<script>
// 导入
import FormCalculImpot from './FormCalculImpot'
import Menu from './Menu'
import Layout from './Layout'

export default {
  // 所用组件
  components: {
    Layout,
    FormCalculImpot,
    Menu
  },
  

第26至28行中的三个[import]将修改为:


// 导入
import FormCalculImpot from '@/components/form-calcul-impot'
import Menu from '@/components/menu'
import Layout from '@/components/layout'

通过这种方式,检查并修正所有组件、布局和页面中的 [import]。完成这些修正后,可以尝试生成新的 [build]。通常此时不会再出现错误。

此时可以尝试执行:

Image

此时会出现以下错误:

1
2
3
4
5
6
Module Error (from ./node_modules/eslint-loader/dist/cjs.js):

c:\Data\st-2019\dev\nuxtjs\dvp\nuxt-20\pages\index.vue
   92:29  error  Expected '!==' and instead saw '!='  eqeqeq
  129:27  error  Expected '!==' and instead saw '!='  eqeqeq
150:28  error  Expected '!==' and instead saw '!='  eqeqeq

可见,[dev]命令与[eslint]模块结合使用时,在语法层面比[build]命令更为严格。 在此,系统要求将比较运算符 [!=] 改写为 [!==],后者是一个更严格的运算符(它还会检查操作数的类型)。这些错误出现在页面 [index.vue] 中。

修正上述错误后,重新运行项目。此时,[eslint] 模块发出警告:

c:\Data\st-2019\dev\nuxtjs\dvp\nuxt-20\components\menu.vue
14:5  warning  Prop 'options' requires default value to be set  vue/require-default-prop

Image

通过[eslint]和[2]模块中的[Quick fix]修正此错误。

重新运行项目。此时不再出现编译错误。随后通过浏览器调用 URL 和 [http://localhost:81/nuxt-20/]。结果出现运行错误:

Image

错误出现在 [index.vue] [2] 中。错误 [1] 的原因在于,在 [vuejs-22] 中, [dao] 层在 [this.$dao] 中可用,而在我们采用其架构的 [nuxt-12] 中,该层在 [this.$dao()] 函数中可用。

错误出现在页面生命周期 [index] 的函数 [created] 中:

Image

目前,我们只需将 [created] 重命名为 [created2],以避免执行生命周期函数 [created](即 [3])。

保存修改后,在浏览器中重新加载页面 [index]。这次成功了:

Image

17.3. 步骤 2

项目中的页面 [vuejs-22] 使用了以下注入元素:

  • $dao:用于客户端 [vue.js] 的 [dao] 层;
  • $session:用于存储在浏览器 [localStorage] 中的会话;

在我们复制的 [nuxt-12] 项目架构中,这些元素已不复存在:

  • 现在有两个 [dao] 层,一个用于客户端 [nuxt],另一个用于服务器 [nuxt]。 这两个层均可通过名为 [$dao] 的注入函数访问。这意味着在应用程序的页面中,[this.$dao] 必须替换为 [this.$dao()];
  • 由应用程序 [nuxt-20] 管理的会话 [nuxt] 已与应用程序 [$session] 中的,该应用程序中并不存在会话cookie的概念。尽管如此,它们具有相似的功能:在用户操作过程中存储持久性信息。会话 [nuxt] 将信息存储在存储器中,而非直接存储在会话中。 在应用程序的页面中,当需要将信息存储在会话中时,应将 [this.$session] 替换为 [this.$store];当需要操作会话本身时,则应替换为 [this.$session()];
  • 若要查询存储器中属性 P 的状态,需使用 [this.$store.state.P];
  • 若要修改存储器的属性 P,需编写 [this.$store.commit(‘replace’, {P:value}];

我们在页面 [index] 中进行这些修改:


<!-- 视图定义 HTML -->
<template>
  <Layout :left="false" :right="true">
    <template slot="right">
      <!-- 表单 HTML - 使用操作 [authentifier-utilisateur] 提交其值 -->
      <b-form @submit.prevent="login">
        <!-- 标题 -->
        <b-alert show variant="primary">
          <h4>Bienvenue. Veuillez vous authentifier pour vous connecter</h4>
        </b-alert>
        <!-- 第一行 -->
        <b-form-group label="Nom d'utilisateur" label-for="user" label-cols="3">
          <!-- 用户输入区 -->
          <b-col cols="6">
            <b-form-input id="user" v-model="user" type="text" placeholder="Nom d'utilisateur" />
          </b-col>
        </b-form-group>
        <!-- 第二行 -->
        <b-form-group label="Mot de passe" label-for="password" label-cols="3">
          <!-- 密码输入框 -->
          <b-col cols="6">
            <b-input id="password" v-model="password" type="password" placeholder="Mot de passe" />
          </b-col>
        </b-form-group>
        <!-- 第三行 -->
        <b-alert v-if="showError" show variant="danger" class="mt-3">L'erreur suivante s'est produite : {{ message }}</b-alert>
        <!-- 第三行上的 [submit] 类型按钮 -->
        <b-row>
          <b-col cols="2">
            <b-button :disabled="!valid" variant="primary" type="submit">Valider</b-button>
          </b-col>
        </b-row>
      </b-form>
    </template>
  </Layout>
</template>

<!-- 视图动态 -->
<script>
/* eslint-disable no-console */
import Layout from '@/components/layout'
export default {
  // 使用的组件
  components: {
    Layout
  },
  // 组件状态
  data() {
    return {
      // 用户
      user: '',
      // 其密码
      password: '',
      // 控制错误消息的显示
      showError: false,
      // 错误消息
      message: ''
    }
  },

  // 计算属性
  computed: {
    // 有效输入
    valid() {
      return this.user && this.password && this.$store.state.started
    }
  },
  // 生命周期:组件刚被创建
  mounted() {
    // eslint-disable-next-line
    console.log("Authentification mounted");
    // 用户能否进行模拟?
    if (this.$store.state.started && this.$store.state.authenticated && this.$métier.taxAdminData) {
      // 那么用户可以进行模拟
      this.$router.push({ name: 'calculImpot' })
      // 返回事件循环
      return
    }
    // 如果会话 jSON 已启动,则不再重新启动
    if (!this.$store.state.started) {
      // 开始等待
      this.$emit('loading', true)
      // 与服务器初始化会话 - 异步请求
      // 使用 [dao] 层方法返回的 Promise
      this.$dao()
        // 初始化会话 jSON
        .initSession()
        // 已获取响应
        .then((response) => {
          // 等待结束
          this.$emit('loading', false)
          // 分析响应
          if (response.état !== 700) {
            // 显示错误
            this.message = response.réponse
            this.showError = true
            // 返回事件循环
            return
          }
          // 会话已启动
          this.$store.commit('replace', { started: true })
          console.log('[authentification], session=', this.$session())
        })
        // 发生错误时
        .catch((error) => {
          // 将错误上报至视图 [Main]
          this.$emit('error', error)
        })
        // 无论何种情况
        .finally(() => {
          // 保存会话
          this.$session().save()
        })
    }
  },

  // 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      try {
        // 开始等待
        this.$emit('loading', true)
        // 尚未通过身份验证
        this.$store.commit('replace', { authenticated: false })
        // 向服务器进行阻塞式身份验证
        const response = await this.$dao().authentifierUtilisateur(this.user, this.password)
        // 加载完成
        this.$emit('loading', false)
        // 分析服务器响应
        if (response.état !== 200) {
          // 显示错误
          this.message = response.réponse
          this.showError = true
          // 返回事件循环
          return
        }
        // 无错误
        this.showError = false
        // 身份验证通过
        this.$store.commit('replace', { authenticated: true })
        // --------- 现在请求税务管理数据
        // 初始时无数据
        this.$métier.setTaxAdminData(null)
        // 开始等待
        this.$emit('loading', true)
        // 向服务器发送阻塞请求
        const response2 = await this.$dao().getAdminData()
        // 加载完成
        this.$emit('loading', false)
        // 响应分析
        if (response2.état !== 1000) {
          // 显示错误
          this.message = response2.réponse
          this.showError = true
          // 返回事件循环
          return
        }
        // 无错误
        this.showError = false
        // 将接收到的数据存储在 [métier] 层中
        this.$métier.setTaxAdminData(response2.réponse)
        // 可以进行税款计算
        this.$router.push({ name: 'calculImpot' })
      } catch (error) {
        // 将错误上报至主组件
        this.$emit('error', error)
      } finally {
        // 更新存储
        this.$store.commit('replace', { métier: this.$métier })
        // 保存会话
        this.$session().save()
      }
    }
  }
}
</script>

请注意以下几点:

  • 第 69 行:函数 [created2] 已重命名为 [mounted], 此举是为了避免 [nuxt] 服务器执行该函数(该服务器既不执行 [beforeMount],也不执行 [mounted])。 只有客户端 [nuxt] 会执行它,这与示例 [vuejs-22] 的情况相同;
  • 第 73 行:引用了 [this.$métier],但该客户端目前尚不存在;
  • 第75行:我们在[nuxt]应用程序中从未使用过此方法。需要验证它在[nuxt]环境中是否有效;
  • 第112行、第172行:在[vuejs-22]中,项目会话是按此方式保存的。对于[nuxt-20]项目,[save]方法必须接收当前上下文。 已知在页面 [nuxt] 中,对象 [context] 在 [this.$nuxt.context] 中可用;

因此,第 112 行和第 172 行重写如下:


this.$session().save(this.$nuxt.context)

需要注意的是,此代码尚未经过优化。与其多次调用函数 [this.$session()],不如改写为:


const session=this.$session()

随后使用变量 [session]。对于函数 [this.$dao()],也可采用同样的思路。

完成这些修正后,我们可以使用浏览器重新加载 URL 和 [http://localhost:81/nuxt-20/]。我们得到的页面与之前完全相同:

Image

让我们查看浏览器的日志:

Image

日志 [1] 是客户端生成的最后一条日志,其前一条为 [nuxt]。在 [2] 中, 可以看到属性 [started] 指向 [vrai],这意味着函数 [mounted] 已成功与税费计算服务器建立会话 jSON。 我们还看到该存储具有一些属性,这些属性需要被放弃或重命名。请注意,我们使用的是示例中的存储 [nuxt-12]。

现在,在税费计算服务器未启动的情况下,重新请求 URL 和 [http://localhost:81/nuxt-20/]。首先,请务必删除 [nuxt] 会话的 Cookie:

Image

上图是 Chrome 的屏幕截图。完成上述操作后,URL [http://localhost:81/nuxt-20/] 显示的结果如下:

Image

该错误在 [vuejs-22] 项目中已得到正确处理。在 [nuxt-20] 项目中,该错误仍得到正确处理。

17.4. 步骤 3

现在我们已经有了身份验证页面,接下来需要查看用户点击 [Valider] 按钮时执行的代码:


// 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      try {
        // 开始等待
        this.$emit('loading', true)
        // 尚未通过身份验证
        this.$store.commit('replace', { authenticated: false })
        // 正在向服务器进行阻塞式身份验证
        const response = await this.$dao().authentifierUtilisateur(this.user, this.password)
        // 加载完成
        this.$emit('loading', false)
        // 分析服务器响应
        if (response.état !== 200) {
          // 显示错误
          this.message = response.réponse
          this.showError = true
          // 返回事件循环
          return
        }
        // 无错误
        this.showError = false
        // 身份验证通过
        this.$store.commit('replace', { authenticated: true })
        // --------- 现在请求税务管理数据
        // 初始时无数据
        this.$métier.setTaxAdminData(null)
        // 开始等待
        this.$emit('loading', true)
        // 向服务器发送阻塞请求
        const response2 = await this.$dao().getAdminData()
        // 加载完成
        this.$emit('loading', false)
        // 响应分析
        if (response2.état !== 1000) {
          // 显示错误
          this.message = response2.réponse
          this.showError = true
          // 返回事件循环
          return
        }
        // 无错误
        this.showError = false
        // 将接收到的数据存储在 [métier] 层中
        this.$métier.setTaxAdminData(response2.réponse)
        // 可以进行税额计算
        this.$router.push({ name: 'calculImpot' })
      } catch (error) {
        // 将错误上报至主组件
        this.$emit('error', error)
      } finally {
        // 更新存储
        this.$store.commit('replace', { métier: this.$métier })
        // 保存会话
        this.$session().save(this.$nuxt.context)
      }
    }
  }

这里的主要问题似乎是缺少 [this.$métier] 数据。为了解决这个问题,我们将:

  • 将示例 [vuejs-22] 中的类 [Métier] 引入;我们将该类放置在 [api] 文件夹中;
  • 在客户端 [nuxt] 的上下文中注入函数 [$métier],以便访问该类;

首先将类 [Métier] 复制到文件夹 [api] 中:

Image

当类 [Métier] 已存在于项目中后,为客户端 [nuxt] 创建一个新插件。 该插件名为 [pluginMétier],将注入一个名为 [$métier] 的函数,该函数将提供对类 [Métier] 的访问:


/* eslint-disable no-console */
// 创建通往 [métier] 层的入口
import Métier from '@/api/client/Métier'
export default (context, inject) => {
  // 实例化 [métier] 层
  const métier = new Métier()
  // 将函数 [$métier] 注入上下文
  inject('métier', () => métier)
  // 日志
  console.log('[fonction client $métier créée]')
}

完成上述操作后,我们可以修改页面 [index]:


// 生命周期:组件刚刚创建
  mounted() {
    // eslint-disable-next-line
    console.log("Authentification mounted");
    // 用户能否进行模拟?
    if (this.$store.state.started && this.$store.state.authenticated && this.$métier().taxAdminData) {
      // 那么用户可以进行模拟
      this.$router.push({ name: 'calcul-impot' })
      // 返回事件循环
      return
    }
    // 如果会话 jSON 已启动,则不再重新启动
    ...
  },

  // 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      try {
        // 开始等待
        this.$emit('loading', true)
        // 尚未通过身份验证
        this.$store.commit('replace', { authenticated: false })
        // 服务器端阻塞式身份验证
        const response = await this.$dao().authentifierUtilisateur(this.user, this.password)
        // 加载完成
        this.$emit('loading', false)
        // 分析服务器响应
        if (response.état !== 200) {
          // 显示错误
          this.message = response.réponse
          this.showError = true
          // 返回事件循环
          return
        }
        // 无错误
        this.showError = false
        // 身份验证通过
        this.$store.commit('replace', { authenticated: true })
        // --------- 现在请求税务管理数据
        // 初始时无数据
        this.$métier().setTaxAdminData(null)
        // 开始等待
        this.$emit('loading', true)
        // 向服务器发送阻塞请求
        const response2 = await this.$dao().getAdminData()
        // 加载完成
        this.$emit('loading', false)
        // 响应分析
        if (response2.état !== 1000) {
          // 显示错误
          this.message = response2.réponse
          this.showError = true
          // 返回事件循环
          return
        }
        // 无错误
        this.showError = false
        // 将接收到的数据存储在 [métier] 层中
        this.$métier().setTaxAdminData(response2.réponse)
        // 可以进行税款计算
        this.$router.push({ name: 'calcul-impot' })
      } catch (error) {
        // 将错误上报至主组件
        this.$emit('error', error)
      } finally {
        // 更新存储
        this.$store.commit('replace', { métier: this.$métier() })
        // 保存会话
        this.$session().save(this.$nuxt.context)
      }
    }
  }
  • 第 43、61、69 行,[this.$métier] 已被替换为 [this.$métier()];
  • 第8、63行:项目[vuejs-22]中的页面[CalculImpot]已更名为项目[nuxt-20]中的页面[calcul-impot];

完成这些修正后,可以尝试验证身份验证页面:

Image

生成的页面如下:

Image

我们确实成功获取了税款计算页面。现在来看一下日志:

Image

在 [2] 中,可以看到身份验证状态已被正确记录。 在 [3-4] 中,可以看到已获取 [taxAdminData] 数据,该数据通过类 [Métier] 实现了税款计算。

17.5. 步骤 4

让我们查看生成的页面 [calcul-impot]:


<!-- 视图定义 HTML -->
<template>
  <div>
    <Layout :left="true" :right="true">
      <!-- 右侧税款计算表单 -->
      <FormCalculImpot slot="right" @resultatObtenu="handleResultatObtenu" />
      <!-- 左侧导航菜单 -->
      <Menu slot="left" :options="options" />
    </Layout>
    <!-- 表单下方的税款计算结果显示区 -->
    <b-row v-if="résultatObtenu" class="mt-3">
      <!-- 三列空白区域 -->
      <b-col sm="3" />
      <!-- 九列区域 -->
      <b-col sm="9">
        <b-alert show variant="success">
          <span v-html="résultat"></span>
        </b-alert>
      </b-col>
    </b-row>
  </div>
</template>

<script>
// 导入
import FormCalculImpot from '@/components/form-calcul-impot'
import Menu from '@/components/menu'
import Layout from '@/components/layout'

export default {
  // 使用的组件
  components: {
    Layout,
    FormCalculImpot,
    Menu
  },
  // 内部报表
  data() {
    return {
      // 菜单选项
      options: [
        {
          text: 'Liste des simulations',
          path: '/liste-des-simulations'
        },
        {
          text: 'Fin de session',
          path: '/fin-session'
        }
      ],
      // 税款计算结果
      résultat: '',
      résultatObtenu: false
    }
  },
  // 生命周期
  created() {
    // eslint-disable-next-line
    console.log("CalculImpot created");
  },
  // 事件管理方法
  methods: {
    // 税款计算结果
    handleResultatObtenu(résultat) {
      // 将结果构建为字符串HTML
      const impôt = "Montant de l'impôt : " + résultat.impôt + ' euro(s)'
      const décôte = 'Décôte : ' + résultat.décôte + ' euro(s)'
      const réduction = 'Réduction : ' + résultat.réduction + ' euro(s)'
      const surcôte = 'Surcôte : ' + résultat.surcôte + ' euro(s)'
      const taux = "Taux d'imposition : " + résultat.taux
      this.résultat = impôt + '<br/>' + décôte + '<br/>' + réduction + '<br/>' + surcôte + '<br/>' + taux
      // 显示结果
      this.résultatObtenu = true
      // ---- 更新存储 [Vuex]
      // 进行一次加法模拟
      this.$store.commit('addSimulation', résultat)
      // 保存会话
      this.$session.save()
    }
  }
}
</script>
  • 第44行和第48行:导航菜单的链接正确。页面[/fin-session]不存在。项目[vuejs-22]通过路由设置解决了此问题。我们将对项目[nuxt-20]采取同样的处理方式;
  • 第76行:引用了目前尚不存在的变更[addSimulation]。我们将创建该变更;
  • 第78行:与[index]页面一样,应写为[this.$session().save(this.$nuxt.context)];

修改存储 [store/index]。该存储继承自项目 [nuxt-12],目前内容如下:


/* eslint-disable no-console */

// 存储状态
export const state = () => ({
  // jSON 会话已启动
  jsonSessionStarted: false,
  // 用户已认证
  userAuthenticated: false,
  // PHP 会话 Cookie
  phpSessionCookie: '',
  // adminData
  adminData: ''
})

// 存储变更
export const mutations = {
  // 状态更新
  replace(state, newState) {
    for (const attr in newState) {
      state[attr] = newState[attr]
    }
  },
  // 存储器重置
  reset() {
    this.commit('replace', { jsonSessionStarted: false, userAuthenticated: false, phpSessionCookie: '', adminData: '' })
  }
}

// 存储器操作
export const actions = {
  nuxtServerInit(store, context) {
    // 谁在执行这段代码?
    console.log('nuxtServerInit, client=', process.client, 'serveur=', process.server, 'env=', context.env)
    // 初始化会话
    initStore(store, context)
  }
}

function initStore(store, context) {
  // store 是待初始化的存储
  // 获取会话
  const session = context.app.$session()
  // 会话是否已初始化?
  if (!session.value.initStoreDone) {
    // 启动一个新的存储器
    console.log("nuxtServerInit, initialisation d'un nouveau store")
    // 将存储器放入会话中
    session.value.store = store.state
    // 存储区现已初始化
    session.value.initStoreDone = true
  } else {
    console.log("nuxtServerInit, reprise d'un store existant")
    // 使用会话中的存储器更新当前存储器
    store.commit('replace', session.value.store)
  }
  // 正在保存会话
  session.save(context)
  // 日志
  console.log('initStore terminé, store=', store.state)
}
  • 第3-27行:我们将沿用应用程序[vuejs-22]的状态及其变更(参见文档[3]):

// 卷帘状态
export const state = () => ({
  // jSON 会话已启动
  started: false,
  // 用户已通过身份验证
  authenticated: false,
  // 会话cookie PHP
  phpSessionCookie: '',
  // 模拟列表
  simulations: [],
  // 最后一次模拟的编号
  idSimulation: 0,
  // 图层 [métier]
  métier: null
})

// 百叶窗状态变化
export const mutations = {
  // 状态替换
  replace(state, newState) {
    for (const attr in newState) {
      state[attr] = newState[attr]
    }
  },
  // 存储器重置
  reset() {
        this.commit('replace', { started: false, authenticated: false, phpSessionCookie: '', idSimulation: 0, simulations: [], métier: null })
  },
  // 删除索引号为 n 的行
  deleteSimulation(state, index) {
    // eslint-disable-next-line no-console
    console.log('mutation deleteSimulation')
    // 删除第 [index] 行
    state.simulations.splice(index, 1)
    console.log('store simulations', state.simulations)
  },
  // 添加模拟
  addSimulation(state, simulation) {
    // eslint-disable-next-line no-console
    console.log('mutation addSimulation')
    // 模拟编号
    state.idSimulation++
    simulation.id = state.idSimulation
    // 将模拟添加到模拟表中
    state.simulations.push(simulation)
  }
}
  • 第 4 行和第 6 行:引入已使用的属性;
  • 第 8 行:我们保留会话 Cookie PHP。这对于确保客户端和服务器 [nuxt] 与税款计算服务器拥有相同的会话 PHP 至关重要;
  • 第 10 行:用户已进行的模拟列表;
  • 第12行:用户最近一次模拟的编号;
  • 第14行:[métier]层;
  • 第30-47行:项目[vuejs-22]存储中存在的变更,且被应用程序页面引用。项目[vuejs-22]曾有一个名为[clear]的变更,该变更会清空模拟列表。 我们不将其纳入,因为已存在的变体 [reset] 应该能满足需求;
  • 第26-28行:修改了变体[reset],以适应状态的新内容;

页面 [calcul-impot] 使用以下组件 [form-calcul-impot]:


<!-- 视图定义 HTML -->
<template>
  <!-- 表单 HTML -->
  <b-form @submit.prevent="calculerImpot" class="mb-3">
    <!-- 12列蓝色背景消息 -->
    <b-row>
      <b-col sm="12">
        <b-alert show variant="primary">
          <h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
        </b-alert>
      </b-col>
    </b-row>
    <!-- 表单元素 -->
    <!-- 第一行 -->
    <b-form-group label="Etes-vous marié(e) ou pacsé(e) ?">
      <!-- 5列单选按钮-->
      <b-col sm="5">
        <b-form-radio v-model="marié" value="oui">Oui</b-form-radio>
        <b-form-radio v-model="marié" value="non">Non</b-form-radio>
      </b-col>
    </b-form-group>
    <!-- 第二行 -->
    <b-form-group label="Nombre d'enfants à charge" label-for="enfants">
      <b-form-input id="enfants" v-model="enfants" :state="enfantsValide" type="text" placeholder="Indiquez votre nombre d'enfants"></b-form-input>
      <!-- 可能出现的错误信息 -->
      <b-form-invalid-feedback :state="enfantsValide">Vous devez saisir un nombre positif ou nul</b-form-invalid-feedback>
    </b-form-group>
    <!-- 第三行 -->
    <b-form-group label="Salaire annuel net imposable" label-for="salaire" description="Arrondissez à l'euro inférieur">
      <b-form-input id="salaire" v-model="salaire" :state="salaireValide" type="text" placeholder="Salaire annuel"></b-form-input>
      <!-- 可能出现的错误信息 -->
      <b-form-invalid-feedback :state="salaireValide">Vous devez saisir un nombre positif ou nul</b-form-invalid-feedback>
    </b-form-group>
    <!-- 第四行,按钮 [submit] -->
    <b-col sm="3">
      <b-button :disabled="formInvalide" type="submit" variant="primary">Valider</b-button>
    </b-col>
  </b-form>
</template>

<!-- 脚本 -->
<script>
export default {
  // 内部状态
  data() {
    return {
      // 已婚或未婚
      marié: 'non',
      // 子女数量
      enfants: '',
      // 年薪
      salaire: ''
    }
  },
  // 计算出的内部状态
  computed: {
    // 表单验证
    formInvalide() {
      return (
        // 工资无效
        !this.salaire.match(/^\s*\d+\s*$/) ||
        // 或子女信息无效
        !this.enfants.match(/^\s*\d+\s*$/) ||
        // 或未获取税务数据
        !this.$métier.taxAdminData
      )
    },
    // 工资验证
    salaireValide() {
      // 必须为数字且大于等于0
      return Boolean(this.salaire.match(/^\s*\d+\s*$/) || this.salaire.match(/^\s*$/))
    },
    // 子女信息验证
    enfantsValide() {
      // 必须为数字且大于等于0
      return Boolean(this.enfants.match(/^\s*\d+\s*$/) || this.enfants.match(/^\s*$/))
    }
  },
  // 生命周期
  created() {
    // 日志
    // eslint-disable-next-line
    console.log("FormCalculImpot created");
  },
  // 事件管理器
  methods: {
    calculerImpot() {
      // 使用 [métier] 层计算税款
      const résultat = this.$métier.calculerImpot(this.marié, Number(this.enfants), Number(this.salaire))
      // eslint-disable-next-line
      console.log("résultat=", résultat);
      // 补充结果
      résultat.marié = this.marié
      résultat.enfants = this.enfants
      résultat.salaire = this.salaire
      // 触发事件 [resultatObtenu]
      this.$emit('resultatObtenu', résultat)
    }
  }
}
</script>
  • 第 65、89 行:[this.$métier] 的引用应更改为 [this.$métier()];

完成这些修正后,可以尝试进行模拟:

Image

得到以下响应:

Image

查看日志:

Image

  • 在 [9-10] 中,可以看到第一次模拟确实位于 [store] 中;
  • 在[5]中,上次模拟的编号确实已递增;

17.6. 步骤 5

现在我们已经完成了一次模拟,点击链接 [Liste des simulations]。我们将看到以下页面:

Image

客户端 [nuxt] 的路由已正确完成。让我们查看页面 [liste-des-simulations] 的代码:


<!-- 视图定义 HTML -->
<template>
  <div>
    <!-- 版面设计 -->
    <Layout :left="true" :right="true">
      <!-- 右侧栏中的模拟 -->
      <template slot="right">
        <template v-if="simulations.length == 0">
          <!-- 无模拟 -->
          <b-alert show variant="primary">
            <h4>Votre liste de simulations est vide</h4>
          </b-alert>
        </template>
        <template v-if="simulations.length != 0">
          <!-- 有模拟 -->
          <b-alert show variant="primary">
            <h4>Liste de vos simulations</h4>
          </b-alert>
          <!-- 模拟图表 -->
          <b-table :items="simulations" :fields="fields" striped hover responsive>
            <template v-slot:cell(action)="data">
              <b-button @click="supprimerSimulation(data.index)" variant="link">Supprimer</b-button>
            </template>
          </b-table>
        </template>
      </template>
      <!-- 左侧栏导航菜单 -->
      <Menu slot="left" :options="options" />
    </Layout>
  </div>
</template>

<script>
// 导入
import Layout from '@/components/layout'
import Menu from '@/components/menu'
export default {
  // 组件
  components: {
    Layout,
    Menu
  },
  // 内部状态
  data() {
    return {
      // 导航菜单选项
      options: [
        {
          text: "Calcul de l'impôt",
          path: '/calcul-impot'
        },
        {
          text: 'Fin de session',
          path: '/fin-session'
        }
      ],
      // 表参数 HTML
      fields: [
        { label: '#', key: 'id' },
        { label: 'Marié', key: 'marié' },
        { label: "Nombre d'enfants", key: 'enfants' },
        { label: 'Salaire', key: 'salaire' },
        { label: 'Impôt', key: 'impôt' },
        { label: 'Décôte', key: 'décôte' },
        { label: 'Réduction', key: 'réduction' },
        { label: 'Surcôte', key: 'surcôte' },
        { label: '', key: 'action' }
      ]
    }
  },
  // 计算出的内部状态
  computed: {
    // 从 Vuex 存储中获取的模拟列表
    simulations() {
      return this.$store.state.simulations
    }
  },
  // 生命周期
  created() {
    // eslint-disable-next-line
    console.log("ListeSimulations created");
  },
  // 方法
  methods: {
    supprimerSimulation(index) {
      // eslint-disable-next-line
      console.log("supprimerSimulation", index);
      // 删除模拟编号 [index]
      this.$store.commit('deleteSimulation', index)
      // 保存会话
      this.$session.save()
    }
  }
}
</script>
  • 第 47-56 行:导航菜单的目标链接正确;
  • 第 75 行:卷帘窗的引用正确;
  • 第 89 行:使用了我们在上一步骤中集成的 [deleteSimulation] 变体;
  • 第 91 行:该行应重写为 [this.$session().save(this.$nuxt.context)];

我们进行必要的修改,然后尝试删除当前显示的模拟:

Image

随后将显示以下页面:

Image

查看日志:

Image

  • 在 [6] 中,可以看到模拟表格为空;

现在回到税款计算表单:

Image

我们得到以下页面:

Image

因此路由功能已正常运行。

17.7. 步骤 6

接下来我们需要处理导航菜单中的导航选项 [Fin de session]:


// 菜单选项
      options: [
        {
          text: 'Liste des simulations',
          path: '/liste-des-simulations'
        },
        {
          text: 'Fin de session',
          path: '/fin-session'
        }
]
  • 第 9 行,页面 [/fin-session] 不存在。项目 [vuejs-22] 通过 [router.js] 文件中的路由规则处理了这种情况:

// 导入
import Vue from 'vue'
import VueRouter from 'vue-router'
// 视图
import Authentification from './views/Authentification'
import CalculImpot from './views/CalculImpot'
import ListeSimulations from './views/ListeSimulations'
import NotFound from './views/NotFound'
// 会话
import session from './session'

// 路由插件
Vue.use(VueRouter)

// 应用程序路由
const routes = [
  // 身份验证
  { path: '/', name: 'authentification', component: Authentification },
  { path: '/authentification', name: 'authentification2', component: Authentification },
  // 税款计算
  {
    path: '/calcul-impot', name: 'calculImpot', component: CalculImpot,
    meta: { authenticated: true }
  },
  // 模拟列表
  {
    path: '/liste-des-simulations', name: 'listeSimulations', component: ListeSimulations,
    meta: { authenticated: true }
  },
  // 结束会话
  {
    path: '/fin-session', name: 'finSession'
  },
  // 未知页面
  {
    path: '*', name: 'notFound', component: NotFound,
  },
]

// 路由器
const router = new VueRouter({
  // 路由
  routes,
  // URL的显示模式
  mode: 'history',
  // 应用程序的基础URL
  base: '/client-vuejs-impot/'
})

// 路由验证
router.beforeEach((to, from, next) => {
  // eslint-disable-next-line no-console
  console.log("router to=", to, "from=", from);
  // 该路由仅限经过身份验证的用户访问?
  if (to.meta.authenticated && !session.authenticated) {
    next({
      // 进入身份验证
      name: 'authentification',
    })
    // 返回事件循环
    return;
  }
  // 会话结束的特殊情况
  if (to.name === "finSession") {
    // 清理会话
    session.clear();
    // 跳转至视图 [authentification]
    next({
      name: 'authentification',
    })
    // 返回事件循环
    return;
  }
  // 其他情况 - 路由的下一个常规视图
  next();
})

// 路由器导出
export default router
  • 第 64-76 行处理了路由到路径 [/fin-session] 的特殊情况;
  • 第 66 行:清空当前会话;
  • 第 68-70 行:显示视图 [authentification];

我们将尝试在客户端路由文件 [nuxt] 中实现类似的功能:

Image

脚本 [client/routing.js] 变为如下:


/* eslint-disable no-console */
export default function(context) {
  // 谁在执行这段代码?
  console.log('[middleware client], process.server', process.server, ', process.client=', process.client)
  // 浏览器中 PHP 会话 Cookie 的管理
  // 浏览器中的会话 Cookie PHP 必须与 Nuxt 会话中找到的 Cookie 完全一致
  // 操作 [fin-session] 接收一个新的 Cookie PHP(服务器作为 Nuxt 客户端)
  // 如果是由服务器接收该cookie,客户端必须将其转发给浏览器
  // 以便其自身与服务器 PHP 进行通信
  // 此处属于客户端路由

  // 获取会话cookie PHP
  const phpSessionCookie = context.store.state.phpSessionCookie
  if (phpSessionCookie) {
    // 如果存在,则将会话cookie PHP 分配给浏览器
    document.cookie = phpSessionCookie
  }

  // 接下来该去哪里?
  const to = context.route.path
  if (to === '/fin-session') {
    // 清理会话
    const session = context.app.$session()
    session.reset(context)
    // 重定向至首页
    context.redirect({ name: 'index' })
  }
}
  • 我们在现有代码中添加了 [19-27] 相关行;
  • 第 20 行:从当前路由的目标中获取 [path];
  • 第 21 行:检查是否为 [/fin-session]。如果是:
    • 第23-24行:重置会话;
    • 第 26 行:将客户端 [nuxt] 重定向至首页;

该会话的 [session.reset(context)] 方法(第 24 行)如下:


// 重置会话
  reset(context) {
    console.log('nuxt-session reset')
    // 重置存储
    context.store.commit('reset')
    // 将新存储区保存到会话中并保存会话
    this.save(context)
}

[context.store.commit('reset')]方法(第5行)如下:


// 重置存储
  reset() {
        this.commit('replace', { started: false, authenticated: false, phpSessionCookie: '', idSimulation: 0, simulations: [], métier: null })
}

现在使用链接 [Fin de session] 时,将显示主页并生成以下日志:

Image

  • 在 [3] 中,可以看到已不再处于认证状态;
  • 在 [4] 中,可见 jSON 会话已启动;
  • 在 [6] 中,[métier] 层已不在存储中(但在包含 [this.$métier()] 的页面中仍存在);
  • 在 [5, 7] 中,已无模拟;

必须清楚理解会话结束时发生的情况:

  • [nuxt]会话被重置:存储库的[started]属性变为[false];
  • 系统将重定向至页面 [index];
  • 执行页面 [index] 上的方法 [mounted]。该方法会与税费计算服务器建立新的会话 jSON。 如果操作成功,存储的 [started] 属性将更改为 [true];

17.8. 步骤 7

至此,[nuxt-20] 应用程序已具备 [vuejs-22] 应用程序的所有功能。移植工作似乎已完成。

我们将以 [nuxt] 的思路进一步深入。[index] 页面中的 [mounted] 方法存在问题。它会启动一项异步操作,而搜索引擎不会等待该操作结束。 我们知道,在这种情况下,必须将异步操作封装在函数 [asyncData] 中,因为这样,执行该操作的服务器 [nuxt] 才会等待其完成,然后才将页面交付给搜索引擎。

这里我们借助在应用程序 [nuxt-12] 中为页面 [index] 编写的函数 [asyncData]:


export default {
  name: 'InitSession',
  // 使用的组件
  components: {
    Layout,
    Navigation
  },
  // 异步数据
  async asyncData(context) {
    // 日志
    console.log('[index asyncData started]')
    // 如果页面已被请求过,则不再重复处理
    if (process.server && context.store.state.jsonSessionStarted) {
      console.log('[index asyncData canceled]')
      return { result: '[succès]' }
    }
    try {
      // 启动会话jSON
      const dao = context.app.$dao()
      const response = await dao.initSession()
      // 日志
      console.log('[index asyncData response=]', response)
      // 获取会话cookie PHP 以供后续请求使用
      const phpSessionCookie = dao.getPhpSessionCookie()
      // 将会话cookie PHP 存储在会话 [nuxt] 中
      context.store.commit('replace', { phpSessionCookie })
      // 是否发生错误?
      if (response.état !== 700) {
        // 错误位于 response.réponse
        throw new Error(response.réponse)
      }
      // 请注意会话 jSON 已启动
      context.store.commit('replace', { jsonSessionStarted: true })
      // 输出结果
      return { result: '[succès]' }
    } catch (e) {
      // 日志
      console.log('[index asyncData error=]', e)
      // 记录 jSON 会话未启动
      context.store.commit('replace', { jsonSessionStarted: false })
      // 报告错误
      return { result: '[échec]', showErrorLoading: true, errorLoadingMessage: e.message }
    } finally {
      // 保存存储区
      const session = context.app.$session()
      session.save(context)
      // 日志
      console.log('[index asyncData finished]')
    }
  },
  // 生命周期
  beforeCreate() {
    console.log('[index beforeCreate]')
  },
  created() {
    console.log('[index created]')
  },
  beforeMount() {
    console.log('[index beforeMount]')
  },
  mounted() {
    console.log('[index mounted]')
    // 仅限客户端
    if (this.showErrorLoading) {
      console.log('[index mounted, showErrorLoading=true]')
      this.$eventBus().$emit('errorLoading', true, this.errorLoadingMessage)
    }
}
  • 第 13、33、40 行:需将属性 [jsonSessionStarted] 更改为 [started];
  • 第 13 行:在应用程序 [nuxt-12] 中,只有服务器 [nuxt] 执行页面 [index] 及其函数 [asyncData]。 客户端 [nuxt] 仅在从服务器 [nuxt] 接收页面 [index] 后才执行该页面,因此未执行函数 [asyncData]。 而在 [nuxt-20] 中情况不同:链接 [Fin de session] 将在客户端 [nuxt] 的环境中显示页面 [index]。 此时将执行函数 [asyncData]。然而,当通过这种方式到达页面 [index] 时, [nuxt] 会话在此期间已被重置,且存储中的 [started] 属性值变为 [false],因此第 13 行的条件必然为假。 因此可以保留 [process.server],这样客户端 [nuxt] 就不会进行此检测;
  • 第15、35、42行:将属性[result]放入页面[index]的属性[data]中。 在 [nuxt-20] 中,该属性将不会被使用,因此我们将从函数返回的结果中移除它;
  • 第 61-67 行:必须保留此 [mounted] 方法,因为正是它使客户端 [nuxt] 能够显示错误消息。不过,错误处理方式将进行修改;

在当前的 [index] 页面中,我们将上述的 [asyncData] 函数替换掉旧的 [mounted] 函数,并新增一个 [mounted] 函数。 示例 [nuxt-20] 中页面 [index] 的代码将变为如下所示:


...

<!-- 视图动态 -->
<script>
/* eslint-disable no-console */
import Layout from '@/components/layout'
export default {
  // 使用的组件
  components: {
    Layout
  },
  // 组件状态
  data() {
    return {
      // 用户
      user: '',
      // 其密码
      password: '',
      // 错误显示
      showError: false
    }
  },

  // 计算属性
  computed: {
    // 有效输入
    valid() {
      return this.user && this.password && this.$store.state.started
    }
  },
  // 异步数据
  async asyncData(context) {
    // 日志
    console.log('[index asyncData started]')
    // 如果页面已被请求,则不重复处理
    if (process.server && context.store.state.started) {
      console.log('[index asyncData canceled]')
      return
    }
    try {
      // 开始一个会话jSON
      const dao = context.app.$dao()
      const response = await dao.initSession()
      // 日志
      console.log('[index asyncData response=]', response)
      // 获取会话cookie PHP 以供后续请求使用
      const phpSessionCookie = dao.getPhpSessionCookie()
      // 将会话cookie PHP 存储在会话 [nuxt] 中
      context.store.commit('replace', { phpSessionCookie })
      // 是否发生错误?
      if (response.état !== 700) {
        // 错误位于 response.réponse
        throw new Error(response.réponse)
      }
      // 请注意,会话 jSON 已启动
      context.store.commit('replace', { started: true })
      // 无结果
      return
    } catch (e) {
      // 日志
      console.log('[index asyncData error=]', e.message)
      // 记录:会话 jSON 未启动
      context.store.commit('replace', { started: false })
      // 报告错误
      return { showErrorLoading: true, errorLoadingMessage: e.message }
    } finally {
      // 正在保存存储区
      const session = context.app.$session()
      session.save(context)
      // 日志
      console.log('[index asyncData finished]')
    }
  },
  // 生命周期
  beforeCreate() {
    console.log('[index beforeCreate]')
  },
  created() {
    console.log('[index created]')
  },
  beforeMount() {
    // 仅限客户端
    console.log('[index beforeMount]')
    // 潜在错误处理
    if (this.showErrorLoading) {
      // 日志
      console.log('[index beforeMount, showErrorLoading=true]')
      // 将错误上报给主组件 [default]
      this.$emit('error', new Error(this.errorLoadingMessage))
    }
  },
  mounted() {
    console.log('[index mounted]')
  },

  // 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      ...
}
</script>
  • 第 58、65 行:函数 [asyncData] 不再在此处使属性 [result] 闲置;
  • 第81行:客户端[nuxt]的[beforeMount]方法。该方法被选中以替代[mounted]方法,用于处理[asyncData]可能出现的错误;
  • 第85行:检测属性[errorLoading]是否已被设置。该属性只能由函数[asyncData]进行设置;
  • 第 85-90 行:如果函数 [asyncData] 报告了错误,则通过事件 [error] 将其传递给页面 [default]。 这就是我们刚刚替换的旧函数 [created] 处理潜在错误的方式;

让我们进行一些测试。

首先,如果存在,我们将删除会话 Cookie [nuxt] 和 PHP。 随后,在税费计算服务器未启动的情况下请求页面 [http://localhost:81/nuxt-20/]。我们得到以下页面:

Image

在启动税费计算服务器后,我们重新加载同一页面:

Image

查看日志:

Image

  • 在 [2-3] 中,可以看到会话 jSON 已被启动;
  • 在 [4] 中,可以看到会话 Cookie PHP,该 Cookie 是服务器 [nuxt] 在与税费计算服务器通信时获取的。 客户端 [nuxt] 接下来将使用该 Cookie;

现在我们进行身份验证:

Image

我们得到以下页面:

Image

在 [1] 中,我们收到了一条错误信息。这意味着浏览器未发送正确的会话 Cookie,该会话由服务器 [nuxt] 在上一步中启动,会话 ID 为 PHP。 在 [nuxt-12] 中, PHP会话cookie从服务器[nuxt]传递给客户端[nuxt]的过程,是在脚本[middleware/client/routing]的客户端[nuxt]路由中完成的 :


/* eslint-disable no-console */
export default function(context) {  // 谁在执行这段代码?
  console.log('[middleware client], process.server', process.server, ', process.client=', process.client)
  // 浏览器中 PHP 会话 Cookie 的管理
  // 浏览器中的会话 Cookie PHP 必须与 Nuxt 会话中找到的完全一致
  // 操作 [fin-session] 接收一个新的 PHP Cookie(服务器作为 Nuxt 客户端)
  // 如果是由服务器接收该cookie,客户端必须将其转发给浏览器
  // 以便其自身与服务器 PHP 进行通信
  // 此处属于客户端路由

  // 获取会话cookie PHP
  const phpSessionCookie = context.store.state.phpSessionCookie
  if (phpSessionCookie) {
    // 如果存在,则将会话cookie PHP 分配给浏览器
    document.cookie = phpSessionCookie
  }

  // 接下来该去哪里?
  const to = context.route.path
  if (to === '/fin-session') {
    // 清理会话
    const session = context.app.$session()
    session.reset(context)
    // 重定向至首页
    context.redirect({ name: 'index' })
  }
}

正是第13至17行代码,使得客户端[nuxt]能够从服务器[nuxt]获取会话PHP的cookie。

这里的问题在于,当点击按钮 [Valider] 时,客户端 [nuxt] 没有进行路由。因此,其路由函数未被调用。 解决方法是将第 12-17 行复制到页面 [index] 的身份验证方法开头:


// 事件处理程序
  methods: {
    // ----------- 身份验证
    async login() {
      // 从存储中检索会话cookie PHP
      const phpSessionCookie = this.$store.state.phpSessionCookie
      if (phpSessionCookie) {
        // 如果存在,则将会话cookie PHP 分配给浏览器
        document.cookie = phpSessionCookie
      }
      try {
        // 开始等待
        this.$emit('loading', true)
        // 尚未通过身份验证

第5-10行,从存储中获取由服务器[nuxt]发起的会话PHP的Cookie。完成此修改后,成功获取了税款计算页面,这表明身份验证已成功。

17.9. 步骤 8

我们现在拥有了一个功能完备且符合[nuxt]设计理念的应用程序。与处理[nuxt-13]应用程序时一样,我们将重点关注[nuxt]服务器的导航机制。 如前所述,用户无需手动输入应用程序的 URL 代码。 用户应使用系统提供的链接,这些链接由运行在SPA模式下的[nuxt]客户端执行。尽管如此,我们将确保[nuxt]服务器的导航始终能使应用程序保持在稳定状态。

根据针对 [nuxt-13] 进行的研究(参见链接段落),我们知道需要:

  • 修改脚本 [midleware/routing];
  • 添加脚本 [middleware/server/routing];

Image

脚本 [middleware/routing] 修改如下:


/* eslint-disable no-console */

// 导入服务器和客户端的中间件
import serverRouting from './server/routing'
import clientRouting from './client/routing'

export default function(context) {
  // 谁在执行这段代码?
  console.log('[middleware], process.server', process.server, ', process.client=', process.client)
  if (process.server) {
    // 服务器路由
    serverRouting(context)
  } else {
    // 客户端路由
    clientRouting(context)
  }
}
  • 第 4 行:导入服务器 [nuxt] 的路由脚本;
  • 第10-12行:如果由服务器[nuxt]执行代码,则使用其路由功能;

脚本 [middleware/server/routing] 如下:


/* eslint-disable no-console */
export default function(context) {
  // 谁在执行这段代码?
  console.log('[middleware server], process.server', process.server, ', process.client=', process.client)

  // 我们从这里那里收集了一些信息
  const store = context.store
  // 我们来自哪里?
  const from = store.state.from || 'nowhere'
  // 我们要去哪里?
  let to = context.route.name
  
  // /fin-session 没有属性的特例 [name]
  if (context.route.path === '/fin-session') {
    to = 'fin-session'
  }
  
  // 可能的重定向
  let redirection = ''
  // 路由管理完成
  let done = false
  
  // 是否已处于服务器重定向中 [nuxt]?
  if (store.state.serverRedirection) {
    // 无需操作
    done = true
  }
  
  // 是否为页面刷新?
  if (!done && from === to) {
    // 无需操作
    done = true
  }
  
  // 服务器导航控制 [nuxt]
  // 参照客户端的导航菜单

  // 首先处理会话结束的情况
  if (!done && store.state.started && store.state.authenticated && to === 'fin-session') {
    // 清理会话
    const session = context.app.$session()
    session.reset(context)
    // 重定向至首页
    redirection = 'index'
    // 任务完成
    done = true
  }

  // PHP 会话未启动的情况
  if (!done && !store.state.started && to !== 'index') {
    // 重定向至 [index]
    redirection = 'index'
    // 任务已完成
    done = true
  }

  // 用户未通过身份验证的情况
  if (!done && store.state.started && !store.state.authenticated && to !== 'index') {
    redirection = 'index'
    // 任务已完成
    done = true
  }

  // 未获取 [adminData] 的情况
  if (!done && store.state.started && store.state.authenticated && !store.state.métier.taxAdminData && to !== 'index') {
    // 重定向至 [index]
    redirection = 'index'
    // 任务已完成
    done = true
  }

  // 已获取 [adminData] 的情况
  if (
    !done &&
    store.state.started &&
    store.state.authenticated &&
    store.state.métier.taxAdminData &&
    to !== 'calcul-impot' &&
    to !== 'liste-des-simulations'
  ) {
    // 保持在同一页面
    redirection = from
    // 工作完成
    done = true
  }

  // 通常已完成所有检查 ---------------------
  // 重定向?
  if (redirection) {
    // 在商店中记录重定向
    store.commit('replace', { serverRedirection: true })
  } else {
    // 无重定向
    store.commit('replace', { serverRedirection: false, from: to })
  }
  // 将存储数据保存至会话中[nuxt]
  const session = context.app.$session()
  session.value.store = store.state
  session.save(context)
  // 执行服务器的重定向(如有)[nuxt]
  if (redirection) {
    context.redirect({ name: redirection })
  }
}
  • 在此脚本中,我们沿用了应用程序 [nuxt-13] 中服务器 [nuxt] 路由中已开发并使用的思路;
  • 我们在应用程序的存储中添加了两个属性:
    • [from]:上次显示的页面名称。我们知道客户端 [nuxt] 拥有此信息,但服务器 [nuxt] 则没有。 我们将通过在每次 [nuxt] 服务器路由时,将即将显示的页面名称存储到存储中,来为其添加此信息。 在客户端 [nuxt] 每次路由时,也将采取同样的操作。因此,在服务器 [nuxt] 的下一次路由中,它将在存储中找到应用程序最后显示的页面名称;
    • [serverRedirection]:当路由目标被服务器 [nuxt] 拒绝时,该服务器将执行重定向。随后,它会在存储中标记服务器 [nuxt] 的下一个目标为重定向页面。 此重定向将触发服务器 [nuxt] 路由器的重新执行。若该路由器发现当前目标源自重定向,则允许其继续;
  • 第 6-11 行:获取路由所需的信息;
  • 第 13-16 行:目标 [/fin-session] 未与名为 [fin-session] 的页面关联。因此它没有名称。为其分配一个名称;
  • 第19行:潜在重定向的目标;
  • 第21行:路由测试完成后,目标为[done=true];
  • 第23-27行:如前所述,如果当前路由源于重定向,则无需采取任何操作。因为在前一次路由过程中,路由器已决定将客户端浏览器重定向。没有必要重新考虑这一决定;
  • 第29-33行:如果是页面刷新,则放任其进行。这并非适用于所有[nuxt]应用程序的定理:必须针对每页单独评估刷新带来的影响。 在此情况下,[index, calcul-impot, liste-des-simulations]页面的刷新并未引发不良影响;
  • 第35-85行:服务器[nuxt]的路由设置沿用了客户端[nuxt]的路由配置。 当处于某页面时,服务器 [nuxt] 的路由必须反映客户端 [nuxt] 在该页面上提供的导航菜单;
  • 第 38-47 行:首先处理目标 [fin-session] 不对应现有页面的情况。如果满足条件(会话已启动、用户已认证),则清除会话并将用户重定向至页面 [index];
  • 第49-55行:如果与税费计算服务器的会话jSON尚未启动,则唯一可能的跳转目标是页面[index];
  • 第57-62行:如果jSON会话已启动,且用户未通过身份验证也未请求身份验证页面,则重定向至身份验证页面(即[index]);
  • 第64-70行:如果用户已通过身份验证,但未获取数据[adminData],则重定向至身份验证页面。 身份验证包含两步:首先进行身份验证,若验证成功,则进一步要求提供数据 [adminData]。若未获取到该数据,则需重新进行身份验证;
  • 第72-85行:若已获取[adminData]数据,则唯一可能的目标仅为[calcul-impot]和[liste-des-simulations]。否则,拒绝路由;
  • 第88-95行:根据是否进行重定向来更新存储;
  • 第 94 行:没有重定向。因此,当前的 [to] 将成为下一次路由的 [from];
  • 第96-99行:将存储信息保存到会话cookie [nuxt] 中;
  • 第100-103行:如果需要进行重定向,则执行重定向;

进行测试时,需确保从空白状态开始,通过税费计算服务器删除会话cookie [nuxt] 和 PHP:

Image

为测试服务器 [nuxt] 的路由功能,请在每个页面上尝试所有可能的 URL 和 [/, /calcul-impot, /liste-des-simulations] 值。每次操作后,应用程序都应保持一致状态。

17.10. 步骤 9

步骤 9 是部署 [nuxt-20] 应用程序。这需要一个提供 [node.js] 环境的托管服务来运行 [nuxt] 服务器。 我没有这样的环境。读者可以按照“链接”段落中描述的步骤,在自己的开发机器上部署应用程序 [nuxt-20],并使用协议 HTTPS 对其进行安全加固。

17.11. Conclusion

[vuejs-22] 应用程序向 [nuxt-20] 应用程序的移植现已完成。让我们回顾一下此次移植的几个要点:

  • [vuejs-22] 的页面已保留;
  • [vuejs-22]页面中原有的异步操作已迁移至[asyncData]函数中;
  • 在 [nuxt-20] 中需要管理两个实体:客户端 [nuxt] 和服务器 [nuxt]。后者在 [vuejs-22] 中并不存在。 为保持两个实体的一致性,我们需要一个 [nuxt] 会话;
  • 我们还需管理服务器 [nuxt] 的路由;

实际上,直接基于 [nuxt] 架构开始开发,无疑比先构建 [vue.js] 架构,再将其移植到 [nuxt] 环境中更为可取。