vue项目心得之相册上传

1,161 阅读9分钟

基于相册上传的VUE项目梳理在该项目的收获心得

项目文件结构规划思想

  • 一个好的项目一定少不了对项目文件结构进行规划,项目结构清晰了,前后端交流更加方便,项目开发效率就提升了。
  • 在实现相册上传的项目中我们对后端文件进行了如下的结构规划:
  • 在对后端的项目进行了如此的文件规划后,来看server.js(主入口文件)的代码:
const Koa = require("koa");
//托管静态资源文件服务代理
const serve = require("koa-static");
//对从服务器中post请求到服务器的数据进行封装以便使用的中间件
const koaBody = require("koa-body");
//可以用来处理动态路由
const Router = require("koa-router");

//后端上传文件的逻辑 module.exports=async ctx=>{//todo..}
//功能是保存图片到本地和保存到数据库
const upload = require("./lib/upload");

//后端用户登录的逻辑 module.exports=async ctx=>{//todo..}
//功能是判断用户输入的账号密码是否与数据库一致
//如果一致则登录成功并使用第三方库生成了一个可以鉴权的token
const login = require("./lib/login");

//后端获取数据库中全部照片的逻辑
const getPhotos = require("./lib/getPhotos");

//后端获取数据库中对应的照片的逻辑
//SELECT * FROM photos WHERE uId=? AND id=?
const getPhoto = require("./lib/getPhoto");

//后端连接数据库和拉取数据库数据的逻辑
const db = require("./lib/db");

//处理鉴权的第三方库
const koaJwt = require("koa-jwt");
//存放密钥用于鉴权的使用
const { SECRET } = require("./lib/config");

//初始化数据库
db.initDB();

const app = new Koa();

//解决接收二进制文件的问题(这里指的是图片)
app.use(
  koaBody({
    multipart: true
  })
);

//托管静态资源文件服务代理
app.use(serve(__dirname + "/static"));

//会自己去判断token是否是伪造的 因为每个请求都会来走一遍中间件
app.use(koaJwt({ secret: SECRET }).unless({ path: [/^\/login/] }));

const router = new Router();


//向后端发起请求时的对应逻辑
router.post("/upload", upload);
router.post("/login", login);
router.get("/getPhotos", getPhotos);
router.get("/getPhoto", getPhoto);



app.use(router.routes());
//后端服务器的端口
app.listen(8081);
  • 我们可以发现此时的主入口文件(server.js)的代码意图是十分简洁明了的,这得益于我们的项目文件结构规划思想。

任务拆分的思想

  • 一个项目的完成一定少不了任务拆分的思想,做项目就像是拼拼图,只要找到每一块小拼图,就能够完成一整张大拼图。借助这个思想我们在项目实现中应该是一种小步快走的状态。
  • 在实现相册上传的项目中我们使用了vue框架,我们需要完成如下图的登录功能:
  • 拿到功能需求后,我们不应该立马着手书写业务代码,更好的方法是结合任务拆分的思想绘制简易的逻辑代码流程图,然后再来书写业务代码,这里我书写的流程图如下:

功能一:用户输入账户密码的身份验证

总心得:解决请求过程中遇到的跨域问题、基于axios封装一个myAxios请求函数、使用axios请求拦截器、使用Vuex时页面刷新后store中的数据被重新赋值导致数据丢失的问题、

  • 心得一:解决请求过程中遇到的跨域问题!
  • 在实现相册上传的项目中我们使用了axios第三方库向后端发送请求,请求如:axios.post('localhost:8081/login',{username,password})
  • 我们所处localhost:8080/login向后端localhost:8081/login发送了post请求,被无情的跨域问题拒绝了在了千里之外。
  • 我们在脚手架官网可以找到解决方案,在vue.config.js文件中配置devServer.proxy,该项目的配置如下:
module.exports = {
  devServer: {
    proxy: {
      "/api": {
        target: "http://localhost:8081",
        pathRewrite: {
          "^/api": ""
        }
      }
    }
  }
};
  • 在配置完devServer.proxy后我们的请求也应该相应将请求修改为:axios.post('localhost:8081/api/login',{username,password}),此时我们已经成功的解决了跨域问题。

  • 心得二:基于axios封装一个myAxios请求函数
  • 我们会发现之后的每次请求都额外需要加上/api这几个字符,代码显得不太优雅。我们希望封装一个myAxios请求函数可以在每一次的请求中自动的添加上URL前缀/api,我们只需要关注向哪里发送请求即可。
  • axios官网里我们可以在Request Config配置选项中发现baseURL可以解决我们上方提到的问题,可以参考如下代码:
//下面的代码是写在了http.js文件中
//外部导入时只需要import http from "../http";

import axios from "axios";
const myAxios = axios.create({
  baseURL: "/api"
});
export default myAxios;
  • 在封装完myAxios请求函数后并导出该函数,在需要的文件中导入http.js文件即可使用该函数发送请求,代码参考如下:
import http from "../http";

//自动的拼接URL前缀即/api/login
//拼接URL前缀'/api'后发送请求时会自动触发devServer.proxy的配置
http.post("/login", {
      username,
      password
    })
  • 我们需要在上面代码的基础上进行封装,导出一个可以传入用户账户和密码参数后向后端/login发送请求的函数,代码参考如下:
//我们依照项目文件结构规划思想
//将所有封装好的请求接口放在统一的目录下
//该项目统一放在api/index.js文件中

export function apiLogin({ username, password }) {
  //抽离掉具体的业务逻辑
  //只返回一个Promise对象
  //导入该函数调用then获取result再做相应逻辑处理
  //导入该函数调用async/await获取result再做相应逻辑处理
  return (
    http
      .post("/login", {
        username,
        password
      })
  );
}
  • 注意点:每次修改vue.config.js等配置文件记得重启服务器。

  • 心得三:使用Vuex时页面刷新后store中的数据被重新赋值导致数据丢失的问题
  • 我们来一步步复现该问题的发生的场景,场景流程图如下:
  • 场景流程图一:
  • 场景流程图二:
  • 在实现相册上传的项目中我们对登录页面组件(views/Login.vue)的登录按钮进行点击事件监听,当点击登录按钮后触发login方法,在该login方法里我们dispatchstoreactions里的login方法并携带了用户填写的账户密码数据信息。代码参考如下:
methods: {
 login() {
     this.$store
      .dispatch("login", {
      username: this.username,
      password: this.password
      })
 }
}
  • storeactions里的login方法会根据接收到数据信息调用apiLogin方法向后端/login发送请求,然后commitstoremutations里的login方法,并携带了后端返回的token数据信息(用户登录失败token无值)。代码参考如下:
import Vue from "vue";
import Vuex from "vuex";
import { apiLogin } from "../api";

Vue.use(Vuex);


export default new Vuex.Store({
  state: {
    token: "",
  },
  mutations: {
  },
  actions: {
    async login({ commit }, payload) {
      const { username, password } = payload;
      const res = await apiLogin({ username, password });
      const token = res.data.data.token;
      commit("login", { token });
    }
  }
  
  
});

  • storemutations里的login方法会根据接收到的token数据存储在state中的token中。代码参考如下:
import Vue from "vue";
import Vuex from "vuex";
import { apiLogin } from "../api";

Vue.use(Vuex);


export default new Vuex.Store({
  state: {
    token: "",
  },
  mutations: {
    login(state, payload) {
      state.token = payload.token;
      // console.log(state.token,'token');
    },
  },
  actions: {
    async login({ commit }, payload) {
      const { username, password } = payload;
      const res = await apiLogin({ username, password });
      const token = res.data.data.token;
      commit("login", { token });
    }
  }
});
  • 在完成以上的操作后我们需要让页面跳转至http://localhost:8080/photo页面,代码参考如下:
//这里是Login.vue的代码

 methods: {
    login() {
      this.$store
        .dispatch("login", {
          username: this.username,
          password: this.password
        })
        .then(() => {
          // 跳转至photo页面
          console.log('我要跳转了');
          this.$router.push({
            name: "Photo"
          });
        });
    }
  }
  • 跳转至http://localhost:8080/photo页面需要做的准备流程如下:
  • 我们在Photo.vue中实现需求一的时候(即每次发送请求时携带上token数据信息),我们会发现一个问题,store中的数据是保存在运行内存中的,页面刷新时会重新加载vue实例,store中的数据就会被重新赋值,因此数据就丢失了,在这里体现为toke数据丢失。
  • 如果我们无法携带上token数据信息发送请求,那么会出现即使后端已经向登录过的用户生成token发送给前端了,因为前端在请求时token数据丢失,导致登录过的用户无法正常的访问浏览页面、获取相关数据。代码参考如下:
//利用axios请求拦截器
//在token数据存在的情况下添加头信息进行鉴权
myAxios.interceptors.request.use(config => {
  const token = store.state.token;

  
  if (token) {
    config.headers.authorization = "Bearer " + token;
  }

  return config;
});
  • 解决问题:利用localStorage持久化缓存token数据,流程图和参考代码如下:
import Vue from "vue";
import Vuex from "vuex";
import { apiLogin } from "../api";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    token: localStorage.getItem("token") || "",
  },
  mutations: {
    login(state, payload) {
      state.token = payload.token;
      localStorage.setItem("token", state.token);
    }
  },
  actions: {
    async login({ commit }, payload) {
      const { username, password } = payload;
      const res = await apiLogin({ username, password });
      const token = res.data.data.token;
      commit("login", { token });
    }
  }
});

Photo组件页面的功能实现

总心得:Vuex的全局状态管理、列表渲染、axios请求处理(响应、请求拦截)、组件通信、任务拆分思想、组件拆分思想、FileList、FileReader.readAsDataURL()、FormData、计算属性、重构代码、异步请求、全局前置路由守卫、路由懒加载、编程式导航、声明式导航、使用 props 将组件和路由解耦

  • 功能一:用户成功登录后向后端发送'/getPhotos'请求,根据请求回来的数据渲染Photo初始化页面,效果如动图所示:
  • 心得一:学会任务拆分的思想、Vuex的全局状态管理、列表渲染、axios请求处理
  • 我们再次基于任务拆分的思想绘制出实现功能一的简易流程图:
  • Photo.vue中针对功能一实现的核心代码参考如下:
export default {

  //在组件的什么时机去请求后端的/getPhotos接口呢?
  async created() {
    this.updatePhotos();
  },
  methods: {
    async updatePhotos() {
      this.$store.dispatch("updatePhotos");
    }
  }
};
  • store/index.js(Vuex状态管理文件)中针对功能一实现的核心代码参考如下:
import Vue from "vue";
import Vuex from "vuex";

//用于实现获取数据库数据以实现photo页面初始化渲染的接口
import { apiGetPhotos } from "../api";
/*  接口实现函数参考代码如下 

export function apiGetPhotos() {
  //基于上文自己封装的myAxios
  return http.get("/getPhotos");
}

*/
Vue.use(Vuex);


export default new Vuex.Store({
  state: {
    photos: []
  },
  mutations: {
    updatePhotos(state, payload) {
      state.photos = payload.photos;
    }
  },
  actions: {
  
    async updatePhotos({ commit }) {
      const res = await apiGetPhotos();
      commit("updatePhotos", {
        photos: res.data.data.photos
      });
    }
  }
});
  • 我们已经成功的将photos数据做为共享状态交由Vuex来管理,我们现在可以基于photos数据来初始化渲染Photo页面组件的视图,核心实现代码参考如下:
<template>
  <!--配合计算属性渲染视图-->
  <template v-for="photo in photos">
    <div class="photoItem" :key="photo.id">
      <img :src="photo.imgUrl" />
      <span>
      	 {{ photo.name }}
      </span>
    </div>
  </template>
</template>


export default {
  async created() {
    this.updatePhotos();
  },
  computed: {
    photos() {
      return this.$store.state.photos;
    }
  },
  methods: {
    async updatePhotos() {
      this.$store.dispatch("updatePhotos");
    }
  }
};

  • 功能二:实现在点击上传按钮后显示出上传照片弹框,效果图如下:
  • 心得二:根据组件思想和功能需求细分组件、用sync的方式解决父子组件通信(适用vue2.x)
  • 在实现Photo页面组件的时候我们可以利用组件的思想将整体页面再次细分为各个组件页面,可以以功能的需求为切入点来细分组件,参考Vue官网组件基础的思想:
  • 我们将弹框页面拆分做为一个页面组件(即UploadPhotoView.vue页面组件),在我们点击上传照片按钮时显示弹框页面,实现该需求的简易流程图如下:
  • 功能二在父组件Photo.vue中核心的实现代码参考如下:
//下面是在父组件Photo.vue的核心实现代码:

<template>

//点击上传照片按钮显示弹框
<button class="mybtn" @click="showUploadPhotoView = true">

<UploadPhotoView
  :visible.sync="showUploadPhotoView"
></UploadPhotoView>
</template>

<script>
import UploadPhotoView from "../components/UploadPhotoView";

export default {
  components: {
    UploadPhotoView
  },
  data() {
    return {
      showUploadPhotoView: false
    };
  }
};
</script>
  • 功能二在子组件UploadPhotoView.vue中核心的实现代码参考如下:
//下面是在子组件UploadPhotoView.vue的核心实现代码:

<template>
<div class="masking" v-if="visible">
	<span class="close" @click="close"></span>
	<!-- 省略其他业务代码 -->
</div>
</template>

<script>
export default {
  props: ["visible"],
  close() {
    this.$emit("update:visible", false);
  }
};
</script>

  • 功能三:实现在子组件UploadPhotoView.vue中点击上传图片后显示出上传的预览图(显示预览图的同时隐藏上传图片背景框(互斥关系)),效果图如下:
  • 心得三:FileList(详情可参考官网)、FileReader.readAsDataURL()(详情可参考官网)、计算属性的使用
  • 我们先来解决显示上传图片背景框和显示待上传图片预览图的互斥问题,核心实现代码如下:
<template>
  <!--上传图片背景框-->
<div class="showContainer" v-show="showAddContainer">
  <span>上传图片</span>
  <input
    class="imgFile"
    type="file"
    name=""
    multiple="multiple"
    @input="addWantShowPhotos"
  />
  <!--省略其他业务代码-->
</div>

  <!--显示待上传图片预览图-->
<div class="loadContainer" v-show="showWaitUploadContainer">
	
    <!--将上传图片预览图的HTML再次抽离为一个页面组件-->
    <template v-for="(item, index) in wantUploadPhotos">
    	<UploadPhotoItem :item="item" :key="index"></UploadPhotoItem>
    </template>
    
  <!--省略其他业务代码-->
</div>
</template>


<script>
export default {
  data() {
    return {
      wantUploadPhotos: []
    };
  },
  computed: {
    showAddContainer() {
      return this.wantUploadPhotos.length === 0;
    },
    showWaitUploadContainer() {
      return this.wantUploadPhotos.length > 0;
    }
  },
  methods: {
    addWantShowPhotos(e) {
      //一个 FileList 对象通常来自于一个 HTML <input> 元素的 files 属性
      // e.target.files可以获取FileList类数组
      this.wantUploadPhotos.push(...Array.from(e.target.files));
    }
  }
};
</script>
  • 我们再来解决如何在点击上传图片并选择相关图片打开后页面上相应的显示出上传图片预览图的效果(记住我们此时还没有将图片上传到后端),核心实现的代码如下:
  • 注意点:FileReader.readAsDataURL()方法会读取指定的 Blob 或 File 对象(这里我们已经通过e.target.files获取到File对象),读取操作完成的时候,readyState 会变成已完成DONE,并触发 loadend 事件,同时 result 属性将包含一个data:URL格式的字符串(base64编码)以表示所读取文件的内容,利用此赋值给img.src正好解决显示图片预览图的效果。
//我们又根据功能将上传照片预览图的HTML拆分为一个页面组件
//这里是UploadPhotoItem.vue的页面组件的核心实现代码

<template>
  <div class="uploadPhotoItem">
    <img :src="imgSrc" />
    <span class="pictureName">
      {{ item.name }}
    </span>
  </div>
</template>


<script>
export default {
  //接收的是File对象
  props: ["item"],
  data() {
    return {
      imgSrc: ""
    };
  },
  mounted() {
    const fileReader = new FileReader();
    //异步
    fileReader.onload = () => {
      this.imgSrc = fileReader.result;
    };
    fileReader.readAsDataURL(this.item);
  }
};
</script>

  • 功能四:实现在子组件UploadPhotoView.vue中点击开始上传按钮后将选中的图片上传至后端服务器目录下,前端会在上传成功后再次拉取后端图片数据渲染页面,效果图如下:
  • 心得四:学会FormData详情可参考官网、父子通信(详情可参考官网)、重构代码(表达出代码的意图和不要写有重复的代码)、异步请求的运用
  • 我们先来解决在子组件UploadPhotoView.vue中点击开始上传按钮后将选中的图片上传至后端服务器目录下的问题,核心实现代码参考如下:
<template>
  <span class="uploadBtn"
  @click="uploadFile"
  >
  	开始上传
  </span>
</template>



<script>

//用于将图片文件上传至后端服务器的请求接口
import { apiUpload } from "../api";
/*
export function apiUpload(file) {
  const formData = new FormData();
  //后端通过const { img } = ctx.request.files来解构得到文件数据
  formData.append("img", file);
  //返回一个Promise对象
  //可以自己通过.then(res=>res)验证是否上传成功
  return http.post("/upload", formData)
}
*/



export default {
  data() {
    return {
      wantUploadPhotos: []
    };
  },
  computed: {
    showAddContainer() {
      return this.wantUploadPhotos.length === 0;
    },
    showWaitUploadContainer() {
      return this.wantUploadPhotos.length > 0;
    }
  },
  methods: {
    async uploadFile() {
      for (const item of this.wantUploadPhotos) {
        // 上传是异步的
        // 得先有结果才能继续下一步操作
        await apiUpload(item);
      }

      //代码重构
      //这里分为三步使得代码意图更加明显
      this.uploadPhotosCompleted();
    },

    uploadPhotosCompleted() {
      this.reset();
    },

    reset() {
      //上传成功后清空照片预览图
      this.wantUploadPhotos = [];
    },
    addWantShowPhotos(e) {
      //一个 FileList 对象通常来自于一个 HTML <input> 元素的 files 属性
      // e.target.files可以获取FileList类数组
      this.wantUploadPhotos.push(...Array.from(e.target.files));
    }
  }
};
</script>
  • 我们再来解决前端在上传成功后再次拉取后端图片数据渲染页面的问题,我们所处UploadPhotoView.vue子组件中,而拉取后端图片数据渲染页面的请求我们之前已经封装好了并在父组件Photo.vue的初次渲染中得到了使用,我们可以利用父子通信,在子组件成功上传图片至后端后通知父组件再次触发请求拉取数据渲染页面,核心实现代码参考如下:
//下面是UploadPhotoView.vue的核心实现代码:

export default {
  data() {
    return {
      wantUploadPhotos: []
    };
  },
  computed: {
    showAddContainer() {
      return this.wantUploadPhotos.length === 0;
    },
    showWaitUploadContainer() {
      return this.wantUploadPhotos.length > 0;
    }
  },
  methods: {
    async uploadFile() {
      //一个一个的上传文件 串行的
      for (const item of this.wantUploadPhotos) {
        await apiUpload(item);
      }
      this.uploadPhotosCompleted();
    },

    uploadPhotosCompleted() {
      //父子通信之通知父组件重新发送请求拉取数据
      this.$emit("upload-completed");
      this.reset();
    },
    reset() {
      this.wantUploadPhotos = [];
    },

    close() {
      this.$emit("update:visible", false);
    },
    addWantShowPhotos(e) {
 	this.wantUploadPhotos.push(...Array.from(e.target.files));
    }
  }
};
</script>

-------------------------------------------

//下面是Photo.vue中核心实现代码:
<template>
    <UploadPhotoView
      :visible.sync="showUploadPhotoView"
      @upload-completed="handleUploadCompleted"
    ></UploadPhotoView>
</template>


<script>
import UploadPhotoView from "../components/UploadPhotoView";
export default {
  components: {
    UploadPhotoView
  },
  async created() {
    //初始化渲染页面发送的请求
    await this.updatePhotos();
  },
  
  methods: {
    async handleUploadCompleted() {
     //子组件通知父组件再次向后端拉取数据
      this.updatePhotos();
    },
    async updatePhotos() {
      //重构代码
      //这里是为了不要写有重复的代码
      this.$store.dispatch("updatePhotos");
    }
  }
};
</script>

  • 功能五:设置在LocalStorage中的token过期后刷新跳转至登录页面的需求(以手动删除token来举例演示),效果图如下:
  • 心得五:学会全局前置路由守卫(详情可参考官网)、axios响应拦截器(详情可参考官网)的运用
  • 注意点:全局前置路由守卫会比axios响应拦截器更早执行
//下面是基于axios自己改写为myAxios的代码
//利用响应拦截器在token过期后依据响应的状态码跳转至登录页面

import axios from "axios";
import store from "./store";
import router from "./router";
const myAxios = axios.create({
  baseURL: "/api"
});
// 响应拦截
myAxios.interceptors.response.use(
  res => res,
  err => {
    // 401 403 ....
    switch (err.response.status) {
      //返回失败的状态码可以和后端约定好
      //如果没有带token请求页面时返回的状态码
      case 401:
        // alert("请去登录页面");
        router.replace({
          name: "Login"
        });
        break;
    }
  }
);

export default myAxios;


-------------------------------------------

//下面是利用全局前置路由守卫对每一个路由页面进行守卫
//在访问需要验证且没有token的路由页面时直接跳转至登录页面
//防止用户直接在url地址栏直接敲入photo浪费一次axios响应拦截器

import Vue from "vue";
import VueRouter from "vue-router";
import Login from "../views/Login.vue";
import Photo from "../views/Photo.vue";
import store from "../store";
Vue.use(VueRouter);
const routes = [
  {
    path: "/login",
    name: "Login",
    component: Login,
    meta: {
      isAuth: false
    }
  },
  {
    path: "/photo",
    name: "Photo",
    component: Photo,
    meta: {
      isAuth: true
    }
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

router.beforeEach((to, from, next) => {
  // login -> 不需要检测
  // photo -> 需要
  if (to.meta.isAuth) {
    // 需要检测有没有 token
    if (store.state.token) {
      next();
    } else {
      next({
        name: "Login"
      });
    }
  } else {
    next();
  }
});

export default router;

  • 功能六:用户在点击图片时跳转至图片详情页,在详情页中点击back按钮回退/photo页面,效果图如下:
  • 心得六:学会路由懒加载的配置(详情可参考官网)和编程式导航、声明式导航的使用(详情可参考官网)和使用 props 将组件和路由解耦(详情可参考官网)
//下面是路由懒加载的核心配置代码

Vue.use(VueRouter);
const routes = [
  // 动态加载
  // 优化
  {
    path: "/detail/:id",
    name: "Detail",
    props: true,

    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      // webpack 打包的时候切分用的
      import(/* webpackChunkName: "detail" */ "../views/Detail.vue")
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});
export default router;

-----------------------------------------------

//下面是Phtot.vue的核心实现代码


<template>
<router-link
  :key="photo.id"
  :to="{ name: 'Detail', params: { id: photo.id } }"
  tag="div"
>
</template>


-------------------------------------------


//下面是Detail.vue的核心实现代码

<template>
  <div>
    <div>
      <img :src="photoInfo.imgUrl" alt="" />
      <p>
        {{ photoInfo.name }}
      </p>
    </div>

    <div>
      <button @click="back">back</button>
    </div>
  </div>
</template>

<script>
export default {
  props: ["id"],
  methods: {
    back() {
      this.$router.back();
    }
  },
  computed: {
    photoInfo() {
      // 没有值的话,你可以单独的去请求后端接口
      // 找到对应的 item 显示
      return this.$store.state.photos.find(item => item.id === this.id);
    }
  }
};
</script>