Vue全家桶+SSR+Koa2全栈开发美团网 笔记

2,051 阅读6分钟

前言

最近学习Vue全家桶+SSR+Koa2全栈开发美团网课程,主讲以Vue SSR+Koa2全栈技术为目标,最终实现美团网项目。

  • 前端 :Nuxt.js/vue-router/ Vuex/ ELement-ui
  • 后端 :Node.js/Koa2/Koa-router/Nodemailer/Passport
  • HTTP通讯 :Axios
  • 数据支撑 :Mongoose/Redis/高德地图web服务api接口

源码链接:github.com/zhanglichun…

美团首页

城市定位

根据用户的ip地址获取城市(高德地图Web服务API接口 lbs.amap.com/)

  1. 在高德官网,申请”web服务 API”密钥(Key),得到api接口
  2. 运用Vuex全局状态管理,将city存在state里,因为美团整个服务(如外卖)都是围绕用户的城市展开,这样所有的组件都可以获取城市数据。
const state = () => ({
  position: {},
})

const mutations = {
  setPosition(state, position) {
    state.position = position
  },
  setCity(state, city) {
    state.position.city = city
  },
  setProvince(state, province) {
    state.position.province
  },
}

const actions = {
  setPosition: ({commit}, position) => {
    commit('setPosition', position)
  },
  setCity: ({commit}, city) => {
    commit('setPosition', city)
  },
  setProvince: ({commit}, province) => {
    commit('setPosition', province)
  },
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

2.因为store里的数据是保存在运行内存中的,当刷新网页后,保存在vuex实例store里的数据会丢失(即页面会重新加载vue实例,store里面的数据就会被重新赋值。)

nuxt提供的fetch钩子和nuxtServerInit(均运行在服务端)起作用了,都能帮助我们在页面渲染(组件加载 )前快速操作store

这样无论如何跳转页面,state的city数据都不会丢失

参考文章: nuxt - nuxtServerInit & 页面渲染前的store处理 & context

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

import geo from './models/geo'

Vue.use(Vuex)

const store = () => 
  new Vuex.Store({
    modules: {
      geo
    },
    actions: {
      async nuxtServerInit({commit}, {req, app}) {
        const {status, data:{province, city}} = await app.$axios.get('https://restapi.amap.com/v3/ip?key=b598c12de310236d9d40d3e28ea94d03')
        commit('geo/setPosition', status === 200 ? {province, city} : {province: '', city: ''})
      }
    }
  })

export default store
  • 各组件获取当前城市数据
{{$store.state.geo.position.city}}

search搜索

根据当前城市进行用POI的关键字进行条件搜索

  • 获取高德的搜索POI接口
  • @input监听事件,当input聚焦和输入的值变化了就会触发input函数,向服务端发出ajax请求高德的搜索POI接口,获得数据通过v-if渲染页面。
<div class="wrapper">
    <input v-model="search" placeholder="搜索商家或地点" @focus="focus" @blur="blur" @input="input"/>
    <button class="el-icon-search"></button>
</div>
<dl class="searchList" v-if="isSearchList">
    <dd v-for="(item, i) in searchList" :key="i">{{ item.name }}</dd>
</dl>
export default {
  data() {
    return {
      search: '',
      isFocus: false,
      searchList: []
    }
  },
  computed: {
    isSearchList() {
      return this.isFocus && this.search
    }
  },
  methods: {
    focus() {
      this.isFocus = true
    },
    blur() {
      this.isFocus = false
    },
    input: _.debounce(async function () {
      const { data: { pois } } = await this.$axios.get(`https://restapi.amap.com/v3/place/text?keywords=${this.search}&city=${this.$store.state.geo.position.city}&offset=7&page=1&key=a776091c1bac68f3e8cda80b8c57627c&extensions=base`)
      this.searchList = pois
    })
  },
}

产品列表

也是根据当前城市进行用POI的关键字进行条件搜索

  • 获取高德的搜索POI接口
  • 挂载dom后,在mounted生命周期函数,请求高德的搜索POI接口,根据keywords="美食”。
  • 当鼠标经过dl的某个dd元素,触发over函数,获取dd元素上的keywords属性,根据该属性,请求高德的搜索POI接口。
<template>
  <div class="m-container">
    <div class="scenes-container">
      <dl @mouseover="over">
        <dt class="dt">有格调</dt>
        <!-- <dd keywords="美食|spa|电影|酒店" kind="all">全部</dd> -->
        <dd keywords="美食">约会聚餐</dd>
        <dd keywords="SPA">丽人SPA</dd>
        <dd keywords="电影">电影演出</dd>
        <dd keywords="酒店">品质出游</dd>
      </dl>
      <div class="detial">
        <nuxt-link to="item.url" v-for="(item, i) in list" :key="item.name">
          <img :src='item.photos[0].url' alt="美团">
          <ul>
            <li class="title">{{ item.name }}</li>
            <li class="other">{{ item.adname }}&nbsp;&nbsp;&nbsp;{{ item.address }}</li>
            <li class="price">
              <span>¥{{ item.biz_ext.cost.length?item.biz_ext.cost:'暂无' }}</span>
            </li>
          </ul>
        </nuxt-link>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      kind: 'all',
      keywords: '',
      list: []
    }
  },
  methods: {
    async over(e) {
      const current = e.target.tagName.toLowerCase()
      if (current === 'dd') {
        this.keywords = e.target.getAttribute('keywords')
        const {status, data: {pois}} = await this.$axios.get(`https://restapi.amap.com/v3/place/text?keywords=${this.keywords}&city=${this.$store.state.geo.position.city}&offset=10&page=1&key=b598c12de310236d9d40d3e28ea94d03&extensions=all`)
        if (status === 200) {
          const r = pois.filter(item => item.photos.length)
          this.list= r.slice(0, 6)
        } else {
          this.list = []
        }
      }
    }
  },
  async mounted() {
    const {status, data: { pois }} = await this.$axios.get(`https://restapi.amap.com/v3/place/text?keywords=美食&city=${this.$store.state.geo.position.city}&offset=100&page=1&key=b598c12de310236d9d40d3e28ea94d03&extensions=all`)
    if (status === 200) {
      const r = pois.filter((item) => item.biz_ext.cost.length && item.photos.length)
      this.list = r.slice(0, 6)
    } else {
      this.list = []
    }
  }
}
</script>

注册

1.先开启腾讯邮箱QQ的POP3/SMTP服务,获取授权码
2.在后端设置两个数据接口,一个是给客户发邮箱获取验证码的接口(Nodemail),另一个是注册接口

/**
 * -----给客户发邮箱获取验证码接口-----
 */
router.post("/verify", async (ctx) => {
  let username = ctx.request.body.username;
  //验证请求是否过于频繁
  const saveExpire = await Store.hget(`nodemail:${username}`, "expire")
  if (saveExpire && new Date().getTime() - saveExpire < 0) {
    ctx.body = {
      code: -1,
      msg: "验证请求过于频繁,1分钟内1次"
    }
    return false
  }
  //用Nodemail给用户发邮箱获取验证码
  let transporter = nodeMailer.createTransport({
    host: Email.smtp.host,
    port: 587,
    secure: false,
    auth: {
      user: Email.smtp.user,
      pass: Email.smtp.pass
    }
  })
  let ko = {
    code: Email.smtp.code(),
    expire: Email.smtp.expire(),
    email: ctx.request.body.email,
    user: ctx.request.body.username
  }
  let mailOptions = {
    from: `认证邮件<${Email.smtp.user}>`,
    to: ko.email,
    subject: "美团注册码",
    html: `您在美团注册,您的邀请码是${ko.code}`
  }
  await transporter.sendMail(mailOptions, (err, info) => {
    if (err) {
      return console.log(err);
    } else {
      Store.hmset(`nodemail:${ko.user}`, 'code', ko.code, 'expire', ko.expire, 'email', ko.email)
    }
  })
  ctx.body = {
    code: 0,
    msg: "验证码已经发送,可能会有延时,有效期1分钟"
  }
})
/**
 * -----注册接口-----
 */
router.post("/signup", async (ctx) => {
  const {
    username,
    password,
    email,
    code
  } = ctx.request.body;//post方式

 //验证码是否正确?正确了,验证码是否已过期
  const saveCode = await Store.hget(`nodemail:${username}`, "code");
  const saveExpire = await Store.hget(`nodemail:${username}`, "expire");
  if (code === saveCode) {
    if (new Date().getTime() - saveExpire > 0) {
      ctx.body = {
        code: -1,
        msg: "验证码已过期,请重新获取"
      }
      return false;
    }
  } else {
    ctx.body = {
      code: -1,
      msg2: "请输入正确的验证码"
    }
    return false
  }
//查询mongoose数据库,是否存在用户名。存在,用户名已注册,不存在,写入数据库
  let user = await User.find({
    username
  })
  console.log(user)
  if (user.length) {
    ctx.body = {
      code: -1,
      msg1: "用户名已被注册"
    }
    return false
  }
  let nuser = await User.create({
    username,
    password,
    email
  })
  if (nuser) {
    ctx.body = {
      code: 0,
      msg: "注册成功",
    }
  } else {
    ctx.body = {
      code: -1,
      msg: "注册失败"
    }
  }
})

3.在前端使用element-ui的form表单,在点击发送验证码的时候,会请求给客户发邮箱获取验证码的接口并进行相关的逻辑判断。在点击注册的时候,会请求注册接口并进行相关的逻辑判断,一旦注册成功,就写入mongoose数据库。

     sendMsg() {
        const self = this
        let namePass, emailPass
        //对用户名和密码进行客户端表单校验,是否填写,格式是否正确
        this.$refs['ruleForm'].validateField('username', (valid) => {
          namePass = valid
        })
        if (namePass) {
          return false
        }
        this.$refs['ruleForm'].validateField('email', (valid) => {
          emailPass = valid
        })
        self.statusMsg = ''
        if (!namePass && !emailPass) {
          this.$axios.post('/users/verify', {
            username: encodeURIComponent(self.ruleForm.username),
            email: self.ruleForm.email
          }).then(({ status, data}) => {
            if(status===200 && data && data.code===0) {
              let count = 60
              self.statusMsg = `验证码已发送,剩余${count--}秒`
              self.timerid = setInterval(() => {
                self.statusMsg = `验证码已发送,剩余${count--}秒`
                if (count === 0) {
                  clearInterval(self.timerid)
                  self.statusMsg = '请重新获取验证码'
                }
              }, 1000);
            } else {
              self.statusMsg = data.msg
            }
          })
        }
      }
      register() {
        let self = this
        this.$refs["ruleForm"].validate((valid) => {
          if (valid) {
            this.$axios.post("/users/signup", {
              username: window.encodeURIComponent(this.ruleForm.username),
              password: cryptoJs.MD5(this.ruleForm.pwd).toString(),
              email: this.ruleForm.email,
              code: this.ruleForm.code
            })
              .then(({ status, data }) => {
                if (status === 200) {
                  if (data && data.code === 0) {
                    location.href = "/login"
                  } else {
                    self.statusMsg = data.msg
                    self.error1 = data.msg1
                    self.error2 = data.msg2
                  }
                }
                else {
                  self.error = `服务器出错,错误码:${status}`
                }
              })
          }
        })
      }
    }

登录

1.用passport进行登录验证,先定义策略及session序列化与反序列化操作。这里用的是的是本地认证(passport-local),即用户名和密码。

参考文章: koa-passport学习笔记
koa2 使用passport权限认证中间件

const passport = require('koa-passport')
const LocalStrategy = require('passport-local')
const UserModel = require('../../dbs/models/users.js')

//定义本地登录策略及序列化与反序列化操作
passport.use(new LocalStrategy(async function(username, password, done) {
  let where = {
    username
  };
  //先在mongoose数据库中查询是否有该用户
  let result = await UserModel.findOne(where)
  if (result != null) {
    if (result.password === password) {
      return done(null, result)
    } else {
      return done(null, false, '密码错误')
    }
  } else {
    return done(null, false, '用户不存在')
  }
}))

//session序列化
passport.serializeUser(function(user, done) {
  done(null, user)
})
//session反序列化
passport.deserializeUser(function(user, done) {
  done(null, user)
})

module.exports =  passport

2.应用passport中间件

app.use(passport.initialize()) 
app.use(passport.session())

3.在后台设置登录接口

/**
 * -----登录接口-----
 */
router.post('/signin', async (ctx, next) => {
  let {username, password} = ctx.request.body
  //不存在用户名,存在密码
  if (!username && password !== "d41d8cd98f00b204e9800998ecf8427e") {
    ctx.body = {
      code: -1,
      msg: '请输入用户名'
    }
    return false
  }
  //存在用户名,不存在密码
  if (username && password === "d41d8cd98f00b204e9800998ecf8427e") {
    ctx.body = {
      code: -1,
      msg: '请输入密码'
    }
    return false
  }
  //不存在用户名和密码
  if (!username && password === "d41d8cd98f00b204e9800998ecf8427e") {
    ctx.body = {
      code: -1,
      msg: '请输入用户名和密码'
    }
    return false
  }
  //进行本地登录认证
  return Passport.authenticate("local", function (err, user, info, status) {
    if (err) {
      ctx.body = {
        code: -1,
        msg: err
      }
    } else {
      if (user) {
        ctx.body = {
          code: 0,
          msg: "登录成功",
          user
        }
        return ctx.login(user)
      } else {
        ctx.body = {
          code: 1,
          msg: info
        }
      }
    }
  })(ctx, next)
})

4.在客户端使用element-ui的form表单,在登录注册的时候,会请求登录接口并进行相关的逻辑判断及本地登录验证,一旦登录成功,就写入redis数据库,跳转到首页。

login() {
    this.$axios.post('/users/signin', {
        username: window.encodeURIComponent(this.username),
        password: cryptoJs.MD5(this.password).toString()
    }).then(({ status, data }) => {
        if (status === 200) {
            if (data && data.code === 0) {
               location.href = '/' //成功后跳转页面
            } else {
                this.error = data.msg
            }
        } else {
            this.error = `服务器出错,状态码${status}`
        }
    })
    }

切换城市

只写前一个的切换城市

将省份与城市关联,必须选择省份,才可以选择省份下面的城市

  1. 获取高德web服务api接口的行政区域查询,返回下两级行政区(其中行政区级别包括:国家、省/直辖市、市)

  2. 选用element-ui的组件(select选择器)

<span>按省份选择:</span>
<el-select v-model="pvalue" placeholder="省份">
    <el-option v-for="item in province" :key="item.value" :label="item.label" :value="item.value">
    </el-option>
</el-select>
<!--city.length为空,选择城市下框将禁用-->
<el-select v-model="cvalue" placeholder="城市" :disabled="!city.length" @visible-change="select" ref="currentCity"> 
    <el-option v-for="item in city" :key="item.value" :label="item.label" :value="item.value">
    </el-option>
</el-select>
  1. 挂载dom后,在mounted生命周期函数,请求高德的行政区域查询api接口。根据map映射获得所有的省份,再v-for渲染到第一个下拉框上
export default {
  data() {
    return {
      pvalue: '',
      cvalue: '', 
      search: '',
      public: [],    //所有的数据
      province: [],  //所有的省份
      city: [],      //根据省份获取城市
    }
  },
  //1.获取所有的数据,所有的国家/城市/省份
  async mounted() {
    const {status, data: { districts:[{ districts }]} } = await this.$axios.get('https://restapi.amap.com/v3/config/district?subdistrict=2&key=b598c12de310236d9d40d3e28ea94d03')
    if (status === 200) {
      //获取全部数据
      this.public = districts
      // 获取省份
      this.province = districts.map(item => {
        return {
          value: item.adcode,
          label: item.name
        }
      })
    }
  }
}
</script>
  1. 监听pvalue的变化,点击第一个下拉框选择省份,就可以根据省份获取相关城市
export default {
  watch: {
    //监听pvalue的变化,根据省份获取城市
    pvalue: function (newPvalue) {
      this.city = this.public.filter(item => item.adcode===newPvalue)[0].districts
      this.city = this.city.map(item => {
        return {
          value: item.name,
          label: item.name
        }
      })
    }
  }
}
  1. @visible-change监听到第二个下拉框的出现,触发select函数。选择城市,第二个下拉框下拉框的值不为空,就会触发actions,提交commint给mutations,改变state的city。
import { mapMutations } from 'vuex'
export default {
  methods: {
    ...mapMutations({
      setPosition: 'geo/setPosition'
    }),
    async select () {
      const isSelect = this.$refs.currentCity.value
      if (isSelect) {
        this.$store.commit('geo/setCity', isSelect)
        location.href = '/'
      }
    }
  }
}
  1. 出现下面问题:未刷新前,已经切换城市,但一刷新,又变回根据用户ip地址所获取的城市。

原因:

每一刷新页面,vuex数据丢失,重新加载vue实例,store里面的数据就会被重新赋值。
在前面nuxtServerInit函数中,根据用户ip地址所获取的城市,在页面渲染(组件加载 
)前快速操作了store。所以即使在页面刷新前,改变store的city,页面刷新后,还是变回
根据用户ip地址所获取的城市。

解决方案:

将state里的数据保存一份到本地存储(sessionStorage),来实现数据持久化,因为我们是
只有在刷新页面时才会丢失state里的数据,所以在点击页面刷新时(触发beforeunload事件)
先将state数据保存到sessionStorage,在页面加载时读取sessionStorage里的状态信息。

但每个页面都写入这个,太麻烦。所以我把它放在layouts文件夹的default.vue文件中。

export default {
    mounted () {
      //在页面加载时读取sessionStorage里的状态信息
      if (window.sessionStorage.getItem("store") ) {
          this.$store.replaceState(Object.assign({}, this.$store.state, JSON.parse(sessionStorage.getItem("store"))))
      } 

      //在页面刷新时将vuex里的信息保存到sessionStorage里
      window.addEventListener("beforeunload",()=>{
          window.sessionStorage.setItem("store",JSON.stringify(this.$store.state))
      })
    }
 }

参考文章: vue单页面应用刷新网页后vuex的state数据丢失的解决方案