发布于 2026-01-06 1 阅读
0

Quasar框架——一款支持动态数据的SSR+PWA应用。目录

Quasar Framework——一款具有动态数据的 SSR+PWA 应用。

目录

目录

1 引言

我们将构建一个 SSR 应用来处理一些简单的 CRUD 操作,但整个 CRUD 过程都可以在离线状态下完成。为了实现这一点,我们将使用 CouchDB 将所有数据持久化到客户端浏览器。然后,在服务器端,我们将直接查询 CouchDB。

我们将使用 Quasar 应用扩展来帮助我们创建所需的商店和页面。如果您想了解更多关于应用扩展的信息,请查看以下链接:Quasar - Utility Belt 应用扩展,加速 SSR 和离线优先应用的开发。

2 CouchDB

第一步是安装 CouchDB 实例。请访问CouchDB 主页并按照说明操作。

安装 CouchDB 的具体步骤取决于您的操作系统。如果您使用的是 Windows 系统Windows,安装过程将非常简单,只需按照next > next > finish向导操作即可。如果您使用的是 Windows 系统Linux,则需要在终端中执行一些命令。这会花费一些时间,但您应该已经习惯了。

要检查一切是否按预期运行,您可以访问:http://localhost:5984/_utils,将会出现如下所示的页面。

福克斯顿

3 类星体计划

首先,我强烈建议您使用yarn来管理本地软件包,npm使用 来管理全局软件包,但您也可以自由使用您喜欢的软件包管理器。

第一步是确保已@quasar/cli安装up-to-date,因此即使您已经安装了 cli,也请运行以下命令。

$ npm i -g @quasar/cli@latest
Enter fullscreen mode Exit fullscreen mode

更新 Quasar CLI

现在我们可以创建一个新项目,运行以下命令:

$ quasar create quasar-offline
Enter fullscreen mode Exit fullscreen mode

以下是我选择的:

? Project name (internal usage for dev) quasar-offline
? Project product name (official name; must start with a letter if you will build mobile apps) Quasar App
? Project description A Quasar Framework app
? Author Tobias de Abreu Mesquita <tobias.mesquita@gmail.com>
? Check the features needed for your project: (Press <space> to select, <a> to toggle all, <i> to invert selection)ESLint, Vuex, Axios, Vue-i18n
? Pick an ESLint preset Standard
? Cordova id (disregard if not building mobile apps) org.cordova.quasar.app
? Should we run `npm install` for you after the project has been created? (recommended) yarn
Enter fullscreen mode Exit fullscreen mode

除了 Vuex 功能之外,您不必拘泥于这些选项,所以您可以随意选择您通常会做的事情。

创建一个新项目

4 准备

4.1 工具腰带应用程序扩展

$ quasar ext add "@toby.mosque/utils"
Enter fullscreen mode Exit fullscreen mode

4.2 安装依赖项

由于我们计划使用 PouchDB 在客户端持久化所有内容,因此我们需要安装所需的软件包。

$ yarn add pouchdb pouchdb-find relational-pouch worker-pouch
Enter fullscreen mode Exit fullscreen mode

安装依赖项

4.3 设置

我们需要对项目进行一些小的改动(好吧,我们会采取一些变通方法/权宜之计)。

修改你的文件./babel.config.js,使其看起来像这样:

module.exports = {
  presets: [
    '@quasar/babel-preset-app'
  ]
}
Enter fullscreen mode Exit fullscreen mode

打开你的./quasar.conf.jswebpack 文件,并添加以下代码:

cfg.resolve.alias['pouchdb-promise'] = path.join(__dirname, '/node_modules/pouchdb-promise/lib/index.js')
Enter fullscreen mode Exit fullscreen mode

以下是简化后的视图./quasar.conf.js

const path = require('path')
module.exports = function (ctx) {
  return {
    build: {
      extendWebpack (cfg) {
        cfg.resolve.alias['pouchdb-promise'] = path.join(__dirname, '/node_modules/pouchdb-promise/lib/index.js')
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

5 配置 PouchdDb

5.1 创建启动文件

按照 Quasar 的理念,要配置任何东西,你都需要创建一个只负责该项配置的启动程序。

$ quasar new boot pouchdb/index
Enter fullscreen mode Exit fullscreen mode

您需要在以下位置注册启动文件:./quasar.conf.js

const path = require('path')
module.exports = function (ctx) {
  return {
    boot: [
      'i18n',
      'axios',
      'pouchdb/index'
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

5.2 安装 PouchDb 插件

我们将把 pouchdb 的插件安装到一个单独的文件中:

创建./src/boot/pouchdb/setup.js并修改它,使其看起来像这样:

import PouchDB from 'pouchdb'
import RelationalPouch from 'relational-pouch'
import PouchDbFind from 'pouchdb-find'
import WorkerPouch from 'worker-pouch'

PouchDB.adapter('worker', WorkerPouch)
PouchDB.plugin(RelationalPouch)
PouchDB.plugin(PouchDbFind)

export default PouchDB
Enter fullscreen mode Exit fullscreen mode

现在,编辑./src/boot/pouchdb/index.js

import PouchDB from './setup'

class Database {
  local = void 0
  remote = void 0
  syncHandler = void 0
  async configure ({ isSSR }) {
    if (isSSR) {
      this.local = new PouchDB('http://localhost:5984/master/')
    } else {
      this.local = new PouchDB('db')
      this.remote = new PouchDB('http://localhost:5984/master/')
    }
  }
}

const db = new Database()
export default async ({ Vue, ssrContext }) => {
  await db.configure({ isSSR: !!ssrContext })
  Vue.prototype.$db = db
}

export { db }
Enter fullscreen mode Exit fullscreen mode

我们在这里要做什么?我们需要代码在客户端运行时与在服务器端运行时表现出略微不同的行为。

在服务器端,应用程序将直接查询 CouchDB 实例。
在客户端,应用程序将仅依赖本地数据库,并在连接可用时进行同步。

5.3 配置数据库架构

PouchDb开发者刚开始使用/时常犯的一个错误CouchDb是,为每种文档类型创建一个表(根据个人经验),但他们很快就会意识到这不是个好主意。每个数据库都需要一个专用的连接才能正确同步。

为了解决这个问题,我们将所有数据持久化到一个单独的表中。我个人认为,以关系型的方式思考数据更容易,所以我们将使用 PouchDB 插件来抽象这一点:relational-pouch。

我们在上一步已经注册了插件,但还需要配置数据库架构。同样,我们将在一个单独的文件中进行配置:

创建./src/boot/pouchdb/create.js并修改它,使其看起来像这样:

import PouchDB from './setup'

export default function (name, options) {
  let db = options !== void 0 ? new PouchDB(name, options) : new PouchDB(name)
  db.setSchema([
    {
      singular: 'person',
      plural: 'people',
      relations: {
        company: { belongsTo: { type: 'company', options: { async: true } } },
        job: { belongsTo: { type: 'job', options: { async: true } } }
      }
    },
    {
      singular: 'company',
      plural: 'companies',
      relations: {
        people: { hasMany: { type: 'person', options: { async: true, queryInverse: 'person' } } }
      }
    },
    {
      singular: 'job',
      plural: 'jobs',
      relations: {
        people: { hasMany: { type: 'person', options: { async: true, queryInverse: 'person' } } }
      }
    }
  ])
  return db
}
Enter fullscreen mode Exit fullscreen mode

再编辑一次./src/boot/pouchdb/index.js

import create from './create'

class Database {
  local = void 0
  remote = void 0
  syncHandler = void 0
  async configure ({ isSSR }) {
    if (isSSR) {
      this.local = create('http://localhost:5984/master/')
    } else {
      this.local = create('db')
      this.remote = create('http://localhost:5984/master/')
    }
  }
}

const db = new Database()
export default async ({ Vue, ssrContext }) => {
  await db.configure({ isSSR: !!ssrContext })
  Vue.prototype.$db = db
}

export { db }
Enter fullscreen mode Exit fullscreen mode

5.4 数据库初始化

现在,让我们用一些数据填充数据库。这只会在服务器端进行。同样,我们会在一个单独的文件中进行操作:

为了生成本文所需的数据,我们将使用FakerJS。

yarn add faker
Enter fullscreen mode Exit fullscreen mode

创建./src/boot/pouchdb/seed.js并修改它,使其看起来像这样:

import uuid from '@toby.mosque/utils'
import faker from 'faker'

export default async function (db) {
  var { people: dbpeople } = await db.rel.find('person', { limit: 1 })
  if (dbpeople && dbpeople.length > 0) {
    return
  }

  faker.locale = 'en_US'
  let companies = []
  for (let i = 0; i < 5; i++) {
    let company = {}
    company.id = uuid.comb()
    company.name = faker.company.companyName()
    companies.push(company)
  }

  let jobs = []
  for (let i = 0; i < 10; i++) {
    let job = {}
    job.id = uuid.comb()
    job.name = faker.name.jobTitle()
    jobs.push(job)
  }

  let people = []
  for (let i = 0; i < 100; i++) {
    let companyIndex = Math.floor(Math.random() * Math.floor(5))
    let jobIndex = Math.floor(Math.random() * Math.floor(10))
    let company = companies[companyIndex]
    let job = jobs[jobIndex]
    let person = {}
    person.id = uuid.comb()
    person.firstName = faker.name.firstName()
    person.lastName = faker.name.lastName()
    person.email = faker.internet.email()
    person.company = company.id
    person.job = job.id
    people.push(person)
  }

  for (let company of companies) {
    await db.rel.save('company', company)
  }

  for (let job of jobs) {
    await db.rel.save('job', job)
  }

  for (let person of people) {
    await db.rel.save('person', person)
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,当服务器端启动运行时,调用种子:

import create from './create'
import seed from './seed'

class Database {
  local = void 0
  remote = void 0
  syncHandler = void 0
  async configure ({ isSSR }) {
    if (isSSR) {
      this.local = create('http://localhost:5984/master/')
      await seed(this.local)
    } else {
      this.local = create('db')
      this.remote = create('http://localhost:5984/master/')
    }
  }
}

const db = new Database()
export default async ({ Vue, ssrContext }) => {
  await db.configure({ isSSR: !!ssrContext })
  Vue.prototype.$db = db
}

export { db }
Enter fullscreen mode Exit fullscreen mode

5.5 同步数据库

最后,我们需要同步远程数据库和本地数据库之间的数据。

应用启动时,首先我们会尝试进行完整的数据复制。为了更清晰地说明这个任务,我们会将复制方法封装在一个 Promise 中:

async replicate ({ source, target }) {
  return new Promise((resolve, reject) => {
    source.replicate.to(target).on('complete', resolve).on('error', reject)
  })
}
Enter fullscreen mode Exit fullscreen mode

我们会验证应用是否在线,并尝试进行完整复制(请记住,客户端必须在线才能执行此操作)。如果出现问题,可能是因为客户端或 CouchDB 离线,但这不会阻止用户访问系统。

if (navigator.onLine) {
  try {
    await this.replicate({ source: this.remote, target: this.local })
    await this.replicate({ source: this.local, target: this.remote })
  } catch (err) {

  }
}
Enter fullscreen mode Exit fullscreen mode

之后,我们将启动实时复制并跟踪任何更改。

this.syncHandler = this.local.sync(this.remote, {
  live: true,
  retry: true
})
this.local.changes({
  since: 'now',
  live: true,
  include_docs: true
}).on('change', onChange)
Enter fullscreen mode Exit fullscreen mode

现在你的启动文件应该如下所示:

import create from './create'
import seed from './seed'

class Database {
  local = void 0
  remote = void 0
  syncHandler = void 0
  async configure ({ isSSR, onChange }) {
    if (isSSR) {
      this.local = create('http://localhost:5984/master/')
      await seed(this.local)
    } else {
      this.local = create('db')
      this.remote = create('http://localhost:5984/master/')
      if (navigator.onLine) {
        try {
          await this.replicate({ source: this.remote, target: this.local })
          await this.replicate({ source: this.local, target: this.remote })
        } catch (err) {

        }
      }
      this.syncHandler = this.local.sync(this.remote, {
        live: true,
        retry: true
      })
      this.local.changes({
        since: 'now',
        live: true,
        include_docs: true
      }).on('change', onChange)
    }
  }
  async replicate ({ source, target }) {
    return new Promise((resolve, reject) => {
      source.replicate.to(target).on('complete', resolve).on('error', reject)
    })
  }
}

const db = new Database()
export default async ({ Vue, ssrContext }) => {
  await db.configure({
    isSSR: !!ssrContext,
    onChange (change) {
      console.log(change)
    }
  })
  if (!ssrContext) {
    var { people } = await db.rel.find('person')
    console.log(people)
  }
  Vue.prototype.$db = db
}

export { db }
Enter fullscreen mode Exit fullscreen mode

5.6 你的项目会是什么样子?

项目概述

6 CouchDB

6.1 从应用程序访问 CouchDB

如果您尝试运行应用程序,会发现 CouchDB 拒绝了来自客户端的任何连接。此时您有两种选择:将您的应用程序配置为 CouchDB 的反向代理,或者配置 CouchDB 实例的 CORS 设置。

6.1.1 方案 1 - 配置 CORS

打开 Fauxton(http://localhost:5984/_utils),进入配置,CORS,并启用它。

启用 CORS

6.1.2 方案二 - 反向代理

安装以下软件包

yarn add --dev http-proxy-middleware
Enter fullscreen mode Exit fullscreen mode

修改你的./src-ssr/extention.js文件,使其看起来像这样:

var proxy = require('http-proxy-middleware')
module.exports.extendApp = function ({ app, ssr }) {
  app.use(
    '/db',
    proxy({
      target: 'http://localhost:5984',
      changeOrigin: true,
      pathRewrite: { '^/db': '/' }
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

SSR 设置

编辑启动文件:

if (isSSR) {
  this.local = create('http://localhost:5984/master/')
  await seed(this.local)
} else {
  this.local = create('db')
  // you can't use a relative path here
  this.remote = create(`${location.protocol}//${location.host}/db/master/`)
}
Enter fullscreen mode Exit fullscreen mode

6.1.3 银弹

不知道该选择哪种方案?那就用反向代理吧,这样能给你更大的自由度。

6.2 测试访问权限

运行你的应用:

$ quasar dev -m ssr
Enter fullscreen mode Exit fullscreen mode

Quasar 应用

现在检查一下你的控制台。如果看到一个包含100人的列表,说明一切运行正常。

7. 集中式数据

7.1 商店

由于这是一个服务器端渲染 (SSR) 应用,我们不希望在服务器端查询整个数据库,但查询领域实体是个好主意。我们将把 job 和 company 实体作为领域实体来处理(因为它们在所有路由中都会用到)。

第一步,我们创建一个 store(使用 Vuex)来保存这两个集合:

src/store/database.js

import { factory } from '@toby.mosque/utils'
import { db } from 'src/boot/pouchdb'
const { store } = factory

const options = {
  model: class PeopleModel {
    companies = []
    jobs = []
  },
  collections: [
    { single: 'company', plural: 'companies', id: 'id' },
    { single: 'job', plural: 'jobs', id: 'id' }
  ]
}

export default store({
  options,
  actions: {
    async initialize ({ commit }) {
      let { companies } = await db.local.rel.find('company')
      let { jobs } = await db.local.rel.find('job')
      commit('companies', companies)
      commit('jobs', jobs) 
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

import database from './database'

Vue.use(Vuex)

export default function () {
  const Store = new Vuex.Store({
    modules: {
      database
    },
    strict: process.env.DEV
  })

  return Store
}
Enter fullscreen mode Exit fullscreen mode

7.2 发射事件

由于我们的数据会与远程数据库实时同步,因此 CRUD 操作将在我们的数据存储之外进行。正因如此,我们需要跟踪这些操作,并在每次操作发生时发出事件来更新我们的集中式数据存储。

为此,我们需要修改启动文件:./src/boot/pouchdb/index.js

// ...

const db = new Database()
export default async ({ Vue, store, router, ssrContext }) => {
  await db.configure({
    isSSR: !!ssrContext,
    onChange (change) {
      let { data, _id, _rev, _deleted } = change.doc
      let parsed = db.local.rel.parseDocID(_id)
      let event = events[parsed.type]

      if (_deleted) {
        router.app.$emit(parsed.type, { id: parsed.id, _deleted })
        router.app.$emit(parsed.id, { _deleted })
        if (event) {
          store.dispatch(event.delete, parsed.id)
        }
      } else {
        data.id = parsed.id
        data.rev = _rev
        router.app.$emit(parsed.type, data)
        router.app.$emit(parsed.id, data)
        if (event) {
          store.dispatch(event.save, data)
        }
      }
    }
  })
  await store.dispatch('database/initialize')
  Vue.prototype.$db = db
}

export { db }
Enter fullscreen mode Exit fullscreen mode

7.3 解释

假设有人更新了某个人的信息,那么变更对象将如下所示:

{
  id: person_2_016d0c65-670c-1d7d-9b96-f3ef340aa681,
  seq: ...,
  changes: [{ ... }, { ... }],
  doc: {
    "_id": "person_2_016d0c65-670c-1d7d-9b96-f3ef340aa681",
    "_rev": "2-0acd99b71f352cca4c780c90d5c23608",
    "data": {
      "firstName": "Mylene",
      "lastName": "Schmitt",
      "email": "Coby83@gmail.com",
      "company": "016d0c65-670a-8add-b10f-e9802d05c93a",
      "job": "016d0c65-670b-37bf-7d79-b23daf00fe58"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

为了正确索引文档,relational-pouch 插件会在保存前修改 ID,附加文档类型和键类型(2 表示键是字符串)。我们需要将其拆解,才能获取文档类型和 ID。

let _id = 'person_2_016d0c65-670c-1d7d-9b96-f3ef340aa681'
let parsed = db.local.rel.parseDocID(_id)
console.log(parsed)
// { id: '016d0c65-670c-1d7d-9b96-f3ef340aa681', type: 'person'}
Enter fullscreen mode Exit fullscreen mode

现在,我们将发出 2 个事件来通知应用程序某些文档已更新。

  1. 第一个消息旨在告知持有记录集合的组件,事件名称即为类型。
  2. 第二条信息旨在告知持有特定记录详细信息的组件,事件名称是记录 ID(在整个应用程序中是唯一的)。
if (_deleted) {
  router.app.$emit('person', { id: '016d0c65-670c-1d7d-9b96-f3ef340aa681', _deleted: true })
  router.app.$emit('016d0c65-670c-1d7d-9b96-f3ef340aa681', { _deleted: true })
} else {
  data.id = parsed.id
  data.rev = _rev
  router.app.$emit('person', data)
  router.app.$emit('016d0c65-670c-1d7d-9b96-f3ef340aa681', data)
}
Enter fullscreen mode Exit fullscreen mode

最后一步是更新集中式数据存储。我们将派发一个操作来更新数据存储:

if (_deleted) {
  if (event) {
    store.dispatch('database/deletePerson', parsed.id)
  }
} else {
  if (event) {
    store.dispatch('database/saveOrUpdatePerson', data)
  }
}
Enter fullscreen mode Exit fullscreen mode

8 构建框架

让我们配置框架以使用 preFetch 和自动发现组件。将 ` config > preFetchto`trueconfig > framework > all`to`设置为'auto'`。以下是简化的视图。./quasar.conf.js

const path = require('path')
module.exports = function (ctx) {
  return {
    build: {
      preFetch: true,
      framework: {
        all: 'auto',
        plugins: [...]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

9. 列出人员

我们已经有一些数据可以正常工作,同步流程也已配置好。接下来我们创建一些页面。但首先,我们需要更新文件,src/router/routes.js使其内容如下所示:

9.1 配置路由

const routes = [
  {
    path: '/',
    component: () => import('layouts/MyLayout.vue'),
    children: [
      { path: '', redirect: '/people/' },
      { path: 'people/', component: () => import('pages/People/Index.vue') },
      { path: 'people/:id', component: () => import('pages/Person/Index.vue') }
    ]
  }
]

// Always leave this as last one
if (process.env.MODE !== 'ssr') {
  routes.push({
    path: '*',
    component: () => import('pages/Error404.vue')
  })
}

export default routes
Enter fullscreen mode Exit fullscreen mode

9.2 创建视图

现在,创建src/pages/People/Index.vue如下所示的文件:

<template>
  <q-page class="q-pa-md">
    <q-table title="People" :data="people" :columns="columns" row-key="id" >
      <template v-slot:top-left>
        <q-btn color="positive" icon="edit" label="create" to="/people/create" />
      </template>
      <template v-slot:body-cell-actions="props">
        <q-td class="q-gutter-x-sm">
          <q-btn round outline color="primary" icon="edit" :to="'/people/' + props.value" />
          <q-btn round outline color="negative" icon="delete" @click="remove(props.row)" />
        </q-td>
      </template>
    </q-table>
  </q-page>
</template>

<style>
</style>

<script src="./Index.vue.js">
</script>
Enter fullscreen mode Exit fullscreen mode

9.3 添加状态容器和空白页面

我们需要创建src/pages/People/Index.vue.js。第一步是创建一个state container空白页面:

import { factory } from '@toby.mosque/utils'
import { db } from 'src/boot/pouchdb'
import { mapGetters, mapActions } from 'vuex'
const { page, store } = factory

const moduleName = 'people'
const options = {
  model: class PeopleModel {
    people = []
  },
  collections: [
    { single: 'person', plural: 'people', id: 'id' }
  ]
}

const storeModule = store({
  options,
  actions: {
    async initialize ({ commit }, { route }) {
      let { people } = await db.local.rel.find('person')
      commit('people', people)
    },
    async remove (context, person) {
      await db.local.rel.del('person', { id: person.id, rev: person.rev })
    }
  }
})

export default page({
  name: 'PeoplePage',
  options,
  moduleName,
  storeModule,
  mounted () { ... },
  destroyed () { ... },
  data () { ... },
  computed: { ... },
  methods: {
    ...mapActions(moduleName, { __remove: 'remove' }),
    ...
  }
})
Enter fullscreen mode Exit fullscreen mode

如果您担心操作remove没有commit生效,这是有意为之。因为我们会监听状态变更,所以一旦有用户被删除(无论删除者是谁、删除地点或删除时间),状态容器中都会反映出来。

9.4 聆听变化

为了监听人员集合的任何变化,我们需要更新 mounted 和 destroyed 钩子,并启用/禁用一些事件监听器。

export default page({
  ...
  mounted () {
    let self = this
    if (!this.listener) {
      this.listener = entity => {
        if (entity._deleted) {
          self.deletePerson(entity.id)
        } else {
          self.saveOrUpdatePerson(entity)
        }
      }
      this.$root.$on('person', this.listener)
    }
  },
  destroyed () {
    if (this.listener) {
      this.$root.$off('person', this.listener)
    }
  }
  ...
})
Enter fullscreen mode Exit fullscreen mode

这样一来,无论修改的来源如何,每次创建、更新或删除人员时,状态容器都会更新。

9.5 表格和列

由于我们使用表格来显示人员,因此我们需要配置列,总共有六列firstName,,,,,lastNameemailjobcompanyactions

但是,` joband`company字段存储的不是描述,而是 ID,我们需要将它们映射到您相应的描述。我们需要编辑这些computed属性,使其看起来像这样:

export default page({
  ...
  computed:  {
    ...mapGetters('database', ['jobById', 'companyById'])
  }
  ...
})
Enter fullscreen mode Exit fullscreen mode

data现在,我们将在钩子内部创建列定义。

export default page({
  ...
  data () {
    let self = this
    return {
      columns: [
        { name: 'firstName', field: 'firstName', label: 'First Name', sortable: true, required: true, align: 'left' },
        { name: 'lastName', field: 'lastName', label: 'Last Name', sortable: true, required: true, align: 'left' },
        { name: 'email', field: 'email', label: 'Email', sortable: true, required: true, align: 'left' },
        {
          name: 'job',
          label: 'Job',
          sortable: true,
          required: true,
          field (row) { return self.jobById(row.job).name },
          align: 'left'
        },
        {
          name: 'company',
          label: 'Company',
          sortable: true,
          required: true,
          field (row) { return self.companyById(row.company).name },
          align: 'left'
        },
        { name: 'actions', field: 'id', label: 'Actions', sortable: false, required: true, align: 'center' }
      ]
    }
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

9.6 行动

现在是时候配置我们的操作了。确切地说,是我们的唯一操作:删除人员。我们将修改方法钩子,使其如下所示:

export default page({
  ...
  methods: {
    ...mapActions(moduleName, { __remove: 'remove' }),
    remove (row) {
      this.$q.dialog({
        color: 'warning',
        title: 'Delete',
        message: `Do u wanna delete ${row.firstName} ${row.lastName}`,
        cancel: true
      }).onOk(async () => {
        try {
          await this.__remove(row)
          this.$q.notify({
            color: 'positive',
            message: 'successfully deleted'
          })
        } catch (err) {
          console.error(err)
          this.$q.notify({
            color: 'negative',
            message: 'failed at delete'
          })
        }
      })
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

9.7 屏幕截图

项目概述

应用预览

10. 编辑人物

10.1 创建视图

创建src/pages/Person/Index.vue文件,并将其编辑成如下所示:

<template>
  <q-page class="q-pa-md">
    <q-card class="full-width">
      <q-card-section>
        Person
      </q-card-section>
      <q-separator />
      <q-card-section class="q-gutter-y-sm">
        <q-input v-model="firstName" label="First Name" outlined />
        <q-input v-model="lastName" label="Last Name" outlined />
        <q-input v-model="email" label="Email" type="email" outlined />
        <q-select v-model="company" label="Company" map-options emit-value option-value="id" option-label="name" outlined :options="companies" />
        <q-select v-model="job" label="Job" map-options emit-value option-value="id" option-label="name" outlined :options="jobs" />
      </q-card-section>
      <q-separator />
      <q-card-actions class="row q-px-md q-col-gutter-x-sm">
        <div class="col col-4">
          <q-btn class="full-width" color="grey-6" label="return" to="/people/" />
        </div>
        <div class="col col-8">
          <q-btn class="full-width" color="positive" label="save" @click="save" />
        </div>
      </q-card-actions>
    </q-card>
  </q-page>
</template>

<style>
</style>

<script src="./Index.vue.js">
</script>
Enter fullscreen mode Exit fullscreen mode

10.2 添加状态容器和空白页面

我们需要创建src/pages/Person/Index.vue.js,第一步是创建一个state container空白页面:

import { factory, store as storeUtils, uuid } from '@toby.mosque/utils'
import { db } from 'src/boot/pouchdb'
import { mapActions } from 'vuex'
const { mapState } = storeUtils
const { page, store } = factory

const options = {
  model: class PersonModel {
    id = ''
    rev = ''
    firstName = ''
    lastName = ''
    email = ''
    job = ''
    company = ''
  }
}

const moduleName = 'person'
const storeModule = store({
  options,
  actions: {
    async initialize ({ dispatch, commit }, { route }) {
      let person = await dispatch('personById', route.params.id)
      commit('id', person.id || uuid.comb())
      commit('rev', person.rev)
      commit('firstName', person.firstName)
      commit('lastName', person.lastName)
      commit('email', person.email)
      commit('job', person.job)
      commit('company', person.company)
    },
    async personById (context, id) {
      let { people } = await db.local.rel.find('person', id)
      let person = people && people.length > 0 ? people[0] : {}
      return person
    },
    async save ({ state }) {
      let current = { ...state }
      delete current['@@']
      await db.local.rel.save('person', current)
    }
  }
})

export default page({
  name: 'PersonPage',
  options,
  moduleName,
  storeModule,
  mounted () { ... },
  destroyed () { ... },
  computed: { ... },
  methods: {
    ...mapActions(moduleName, { __save: 'save', initialize: 'initialize' }),
    ...
  }
})
Enter fullscreen mode Exit fullscreen mode

再次强调,不必担心save。故意省略 `<div>` 标签commit是因为我们会自动监听更改。一旦当前人员信息发生更改(无论更改者是谁、更改地点或更改时间),页面都会收到通知。

10.3 倾听变化

为了监听当前人员的任何变化,我们需要更新已挂载和已销毁的钩子,并启用/禁用一些事件监听器。

但与我们以前的做法不同,我们只会通知应用程序,让用户决定他们想要做什么。

export default page({
  ...
  mounted () {
    if (this.rev && !this.listener) {
      this.listener = entity => {
        if (entity._deleted) {
          // if that person got deleted, the unique option to the user is leave that page.
          this.$q.dialog({
            parent: this,
            color: 'warning',
            title: 'Deleted',
            message: 'Someone deleted this person'
          }).onDismiss(() => {
            this.$router.push('/people/')
          })
        } else {
          // if that person got update, the user will be able to keep the changes or discard them.
          this.$q.dialog({
            parent: this,
            color: 'warning',
            title: 'Deleted',
            cancel: 'No',
            ok: 'yes',
            message: 'Someone updated this person. do u wanna refresh the fields?'
          }).onOk(() => {
            this.initialize({ route: this.$route })
          }).onCancel(() => {
            this.rev = entity.rev
          })
        }
      }
      this.$root.$on(this.id, this.listener)
    }
  },
  destroyed () {
    if (this.rev && this.listener) {
      this.$root.$off(this.id, this.listener)
    }
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

这样一来,无论修改的来源如何,每次当前人员信息被更新或删除时,用户都会收到通知。

10.4 数据来源

和以前一样,` joband`company字段保存的不是描述,而是 ID。但现在我们需要 ` jobsand`的完整集合companies才能获取QSelect选项。

export default page({
  ...
  computed: {
    ...mapState('database', ['jobs', 'companies'])
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

10.5 行动

现在,是时候编写我们的保存方法了。我们将修改方法钩子,使其如下所示:

export default page({
  ...
  methods: {
    ...mapActions(moduleName, { __save: 'save', initialize: 'initialize' }),
    async save () {
      try {
        await this.__save()
        this.$q.notify({
          color: 'positive',
          message: 'successfully saved'
        })
        this.$router.push('/people/')
      } catch (err) {
        this.$q.notify({
          color: 'negative',
          message: 'failure at save'
        })
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

10.6 屏幕截图

项目概述
创造
更新
确认删除
删除时

11. 使用 Worker 封装 PouchDB 实例

到目前为止,所有数据库操作都在主线程中执行,包括查询、更新、删除、同步等。

如果你的数据库很大,并且你经常创建或更新文档,你的用户界面可能会不断卡顿,从而导致糟糕的用户体验。

总之,我强烈建议您将所有数据库操作移到单独的线程中。为此,您需要以下软件包:

yarn add worker-pouch
Enter fullscreen mode Exit fullscreen mode

11.1 Web Worker

这是基本设置。第一步是验证是否已worker adapter配置。只需打开src/boot/pouchdb/setup.js并查找:

import PouchDB from 'pouchdb'
import WorkerPouch from 'worker-pouch'

PouchDB.adapter('worker', WorkerPouch)
export default PouchDB
Enter fullscreen mode Exit fullscreen mode

第二步,配置本地数据库以使用该数据库worker adapter。只需打开src/boot/pouchdb/input.js并替换即可:

async configure ({ isSSR, onChange }) {
  if (isSSR) {
    // ...
  } else {
    this.local = create('db')
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode


async configure ({ isSSR, onChange }) {
  if (isSSR) {
    // ...
  } else {
    this.local = create('db', { adapter: 'worker' })
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

目前已完成,我们所有的数据库操作现在都在一个独立的线程中进行。

11.2 共享工作器

同步过程最大的问题在于,如果打开了多个浏览器标签页,它们都会访问同一个 LocalStorage 实例。如果在其中一个标签页中更新文档,其他标签页不会收到通知。

如果你想让所有标签页都收到通知,你需要使用一个SharedWorker。在这种情况下,你只需要一个工作进程来处理所有标签页。

TODO:等待https://github.com/GoogleChromeLabs/worker-plugin/pull/42合并。

11.3 服务人员

除了本文标题之外,我们的应用目前还不是 PWA。让我们来改变这一点。打开./quasar.conf.js设置ssr > pwatrue

const path = require('path')
module.exports = function (ctx) {
  return {
    ssr: {
      pwa: true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,工作区已配置完毕,我们的应用也拥有了一个 Service Worker,但我们对它的控制力有限,不过我们可以进行一些更改。打开 ./quasar.conf.js 文件,并将 pwa > workboxPluginMode 配置为 InjectManifest:

const path = require('path')
module.exports = function (ctx) {
  return {
    pwa: {
      workboxPluginMode: 'InjectManifest'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,我们需要将其编辑./src-pwa/custom-service-worker.js成如下所示:

/*
 * This file (which will be your service worker)
 * is picked up by the build system ONLY if
 * quasar.conf > pwa > workboxPluginMode is set to "InjectManifest"
 */
/*eslint-disable*/
workbox.core.setCacheNameDetails({prefix: "pouchdb-offline"})

self.skipWaiting()
self.__precacheManifest = [].concat(self.__precacheManifest || [])
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
  "directoryIndex": "/"
})
workbox.routing.registerRoute("/", new workbox.strategies.NetworkFirst(), 'GET')
workbox.routing.registerRoute(/^http/, new workbox.strategies.NetworkFirst(), 'GET')

self.addEventListener('activate', function(event) {
  event.waitUntil(self.clients.claim())
})
Enter fullscreen mode Exit fullscreen mode

为了将数据库操作移到 Webpack 中Service Worker,我们需要配置 Webpack,以便它能够转译一些依赖项。

yarn add --dev serviceworker-webpack-plugin
Enter fullscreen mode Exit fullscreen mode

再编辑./quasar.conf.js一次:

const path = require('path')
module.exports = function (ctx) {
  return {
    build: {
      extendWebpack (cfg, { isServer }) {
        cfg.resolve.alias['pouchdb-promise'] = path.join(__dirname, '/node_modules/pouchdb-promise/lib/index.js')
        cfg.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /node_modules/,
          options: {
            formatter: require('eslint').CLIEngine.getFormatter('stylish')
          }
        })

        if (!isServer) {
          const worker = new ServiceWorkerWebpackPlugin({
            entry: path.join(__dirname, 'src-pwa/pouchdb-service-worker.js'),
            filename: 'pouchdb-service-worker.js'
          })
          cfg.plugins = cfg.plugins || []
          cfg.plugins.push(worker)
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,创建./src-pwa/pouchdb-service-worker.js并编辑您的内容,使其如下所示:

/*eslint-disable*/
let registerWorkerPouch = require('worker-pouch/worker')
let PouchDB = require('pouchdb')

PouchDB = PouchDB.default && !PouchDB.plugin ? PouchDB.default : PouchDB
registerWorkerPouch = registerWorkerPouch.default && !registerWorkerPouch.call ? registerWorkerPouch.default : registerWorkerPouch

self.registerWorkerPouch = registerWorkerPouch
self.PouchDB = PouchDB
Enter fullscreen mode Exit fullscreen mode

最后,修改文件./src-pwa/custom-service-worker.js以导入与 worker-pouch 相关的脚本并注册它们:

/*
 * This file (which will be your service worker)
 * is picked up by the build system ONLY if
 * quasar.conf > pwa > workboxPluginMode is set to "InjectManifest"
 */
/*eslint-disable*/
importScripts(`pouchdb-service-worker.js`)
workbox.core.setCacheNameDetails({prefix: "pouchdb-offline"})

self.skipWaiting()
self.__precacheManifest = [].concat(self.__precacheManifest || [])
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
  "directoryIndex": "/"
})
workbox.routing.registerRoute("/", new workbox.strategies.NetworkFirst(), 'GET')
workbox.routing.registerRoute(/^http/, new workbox.strategies.NetworkFirst(), 'GET')

registerWorkerPouch(self, PouchDB)
self.addEventListener('activate', function(event) {
  event.waitUntil(self.clients.claim())
})
Enter fullscreen mode Exit fullscreen mode

我们需要修改我们的代码./src/boot/pouchdb/index.js,使本地pouchdb实例指向Service Worker

async configure ({ isSSR, onChange }) {
  if (isSSR) {
    // ...
  } else {
    if ('serviceWorker' in navigator) {
      if (!navigator.serviceWorker.controller) {
        await new Promise(resolve => {
          navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })
        })
      }
      this.local = create('db', {
        adapter: 'worker',
        worker () {
          return navigator.serviceWorker
        }
      })
    } else {
      this.local = create('db', { adapter: 'worker' })
    }
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

如果您查看网络选项卡,现在应该如下所示:

网络选项卡

11.4 银弹

不知道该选择哪个工作进程?那就用这个SharedWorker,因为它没有比另一个更糟糕的缺点DedicatedWorker,而且ServiceWorker在应用关闭后也不会保持活动状态。

12. 应用关闭时同步

这只是概述。

该功能Service Worker仅在应用打开时保持活动状态。即使我们将数据库操作移至应用内部运行,Service Worker同步也会在应用关闭后立即停止。

为了即使在应用程序关闭时也能同步数据库,我们需要使用web-push将我们的服务器变成推送服务器,之后,我们需要将客户端签名到推送服务器。

推送配置完成后,我们可以配置一个定时任务定期发送推送(例如每 30 分钟一次),客户端每次收到通知时都会启动同步过程。

13 存储库

您可以在这里查看最终项目:
https://gitlab.com/TobyMosque/quasar-couchdb-offline

文章来源:https://dev.to/tobymosque/quasar-framework-a-ssr-pwa-app-with-dynamic-data-c5p