Vue全家桶+SSR+Koa2全棧開發美團網 筆記

前言

最近學習Vue全家桶+SSR+Koa2全棧開發美團網課程,主講以Vue SSR+Koa2全棧技術爲目標,最終實現美團網項目。html

  • 前端 :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裏面的數據就會被從新賦值。)vue

nuxt提供的fetch鉤子和nuxtServerInit(均運行在服務端)起做用了,都能幫助咱們在頁面渲染(組件加載 )前快速操做storenode

這樣不管如何跳轉頁面,state的city數據都不會丟失ios

參考文章: nuxt - nuxtServerInit & 頁面渲染前的store處理 & contextgit

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數據庫。github

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權限認證中間件web

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中間件ajax

app.use(passport.initialize()) 
app.use(passport.session())
複製代碼

3.在後臺設置登陸接口redis

/**
 * -----登陸接口-----
 */
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數據丟失的解決方案

相關文章
相關標籤/搜索