仿照网站:卖座网
卖座电影
说明:前端:moive: 基于Vue结合Vant组件库的仿电影APP
后台:moive-server: 基于Node.js电影项目moive的后台
熟悉一个项目从0-1流程?
面试会问的问题:之前的团队的人员配置。
答:这个回答需要取决公司的规模,比方说,以美团为例,研发岗170个人。前端:40个,后端:50个,运维:30个,设计:30个,产品经理:10个,测试:10。
如果是小规模,则例如20个人总共,前端可以为5个,后端,5个人,运维2,测试2个,产品2-3个人,UI:3-4人。
开发工具:vs code并安装vue开发插件:Vetur
开发环境:windows / mac (mac重度患者优先考虑)
项目运行环境:node v10+
Vue脚手架: vue-cli 4+
代码版本工具:Git/Svn(乌龟SVN),Gitee/GitLab/Github,乌龟Git(基于可视化界面的git工具)
以Github为例:https://github.com/
仓库名称自行决定。
以gitee为例:Gitee - 企业级 DevOps 研发效能平台
vue-cli
脚手架创建项目vue create i-moive
项目名称
i-moive
可以根据自己的情况进行替换
使用历史路由模式,在上线部署的时候需要做服务端的重写配置,否则项目不支持刷新,一刷新就404。
其实vue也支持界面化的方式管理项目(与vue create
命令二选一):
vue ui
# i-moive,根据自己的项目名称进行替换
cd i-moive
git remote add origin 远程仓库地址
# 将本地当前的分支代码上传到远程的master分支中
git push -u origin master
dev
(用于做测试的分支,等测试代码没问题之后再往主分支上合并)以后实际工作是master分支为最终稳定运行的版本的代码,而在开发期间提交的代码一般会提交给开发分支,待后期测试没问题,再与master分支进行合并(pull request)。
git branch dev
git checkout dev
git push -u origin dev
后续操作开发就在dev分支上开发,等全部代码编写完毕,再与master分支合并。
如果每次提交都提示输入帐号密码,则可以做此步骤。
修改当前项目中的
.git/config
文件
将配置:
[remote "origin"]
url = https://github.com/......
修改为:
[remote "origin"]
url = https://用户名:密码@github.com/......
后续每天工作使用Git指令是什么??
# 将远程仓库的代码拉取到本地
git pull
# 写代码的环节
# 写好代码
git add .
git commit -m "注释"
# 下班
git push
# 打卡下班
在后续开始之前先对项目进行一个清理,删除不需要的东西:
src/assets/logo.png
文件src/components/HelloWorld.vue
文件src/views/Home.vue
和src/views/About.vue
文件src/router/index.js
,删除对Home.vue
和About.vue
的引用
Home.vue
de 的引入/about
路由规则src/App.vue
文件
id="nav"
的div元素style
标签之间的所有的样式最终清理完毕的标志:页面是白的,控制台没有任何报错。
————————————————————————————————————————————
回顾:路由知识点
a. 路由的概念:最早接触该概念在nodejs处,正常的说,请求与响应构成了项目开发的一个闭环。在请求时,服务器知道响应我们什么的东西,它是通过请求地址知道的,这个请求地址就是路由中的一部分。专业的来讲,路由就是一种对应关系:请求的地址与响应的资源的一种对应关系。
b. 在vue中,要使用路由需要借助路由管理器:vue-router
c. 在这里(路由)可能会用到的知识点:
src/router/index.js
文件中的routes
数组中定义router-view
==————————————————————————————————————————————
如果项目中所有的路由都写在入口文件中,那么将不便于编写项目和后期维护。因此路由需要进行模块化处理。
可以先行添加以下几个空的路由模块(先不拆,实现效果之后确保没有问题,则再进行拆分):
如果后续还有其他模块,届时再进行增加即可。
创建各个模块对应的视图组件文件
在
src/views
目录下创建对应的文件夹与文件,同时,可以删除自带的Home.vue
与About.vue
文件创建每个视图组件后在其中书写好基本内容
XXXX
src/views
├─Center (个人中心)
│ └─Index.vue
│
├─Cinemas (电影院)
│ └─Index.vue
│
├─News (电影院)
│ └─Index.vue
│
└─Films (电影)
│ Index.vue
│ NowPlaying.vue
└─ComingSoon.vue
创建模块化的目录及路由文件
在每个路由模块文件中注册好对应的路由及各自所使用的视图组件
src/router
├─index.js
│
└─modules
│ center.js
│ cinemas.js
│ city.js
| common.js
└─films.js
在剔除router/index.js
中无用代码后,示例代码如下
import Vue from "vue";
import VueRouter from "vue-router";
// 导入需要的路由模块
import commonRoutes from "./modules/common";
import filmsRoutes from "./modules/films";
import cinemasRoutes from "./modules/cinemas";
import centerRoutes from "./modules/center";
import cityRoutes from "./modules/city";
Vue.use(VueRouter);
const routes = [
// 路由:请求地址与响应资源的对应关系
// 实现步骤:
// a. 创建地址对应的组件,组件暂时不考虑布局等因素,输出不同内容即可;
// b. 导入组件
// c. 书写路由规则(不拆分)
// d. 拆分成模块化的形式
// 通用模块、电影模块、影院模块、城市模块、个人中心模块
// 展开路由模块的数组,将所有的对象元素放在routes数组中
...commonRoutes,
// 电影模块的路由
...filmsRoutes,
// 电影院模块
...cinemasRoutes,
// 我的模块
...centerRoutes,
// 城市列表
...cityRoutes,
];
const router = new VueRouter({
mode: "history",
base: process.env.BASE_URL,
routes,
});
export default router;
回顾:网络请求
a. 什么是跨域?(什么是同源策略?)
b. 为什么存在跨域问题?
浏览器为了安全考虑,所以存在这样的限制
c. 如何解决跨域问题?
在vue中,脚手架为了方便让我们解决跨域问题,其已经集成了nodejs代理解决跨域操作,我们需要做的是,设置代理的相关配置信息。
————————————————————————————————————————————
为什么需要配置:接口有很多都是存在跨域问题的,但是cors、jsonp的方式都是依赖后端去解决支持问题,所以只能考虑代理操作。
配置vue的代理操作需要注意以下几点:
module.exports = {
// 开发服务器设置
devServer: {
open: true,
// 设置 npm run serve 启动后的端口号
port: 3000,
// 如果你开始了eslint,不要让eslint在页面中遮罩,它错误会在console.log控制台打印
overlay: false,
// vue项目代理请求
proxy: {
// 规则
// axios中相对地址开头的字符串 匹配请求uri中的前几位
"/api": {
// 把相对地址中的域名 映射到 目标地址中
// localhost:3000 => https://api.iynn.cn/film/api/v1/
target: "https://api.iynn.cn/film/api/v1",
// 修改host请求的域名为目标域名
// changeOrigin: false,
changeOrigin: true,
// 请求uri和目标uri有一个对应关系
// 请求/api/login ==> 目标 /v1/api/login
pathRewrite: {
"^/api": "",
},
},
},
},
};
回顾:网络请求
语法:
// 下载axios.js文件
// get语法
axios.get(url).then(ret => {
// ret是响应的一个聚合对象,有这样一些属性
// headers、config、request、status、statusText、data
// data属性:响应的内容
});
// post语法
// 发送普通表单提交的
// 请求头content-type: application/x-www-form-urlencoded
// 请求体是form data
axios.post(url,"查询字符串").then(ret => {
});
// 发送的是json格式的数据
// 请求头content-type: application/json
// 请求体是request payload
axios.post(url,普通的对象).then(ret => {
});
// 复杂语法:类似于$.ajax()
axios({
url,
method,
timeout,
....
});
在封装前请先安装axios
npm i -S axios
步骤:
请求地址文件配置
// 作用:对请求地址的配置,简化原本很长的地址写法
let prefix = "/api/";
export default {
// 声明各个请求地址
// 城市信息
getCities: prefix + "getCitiesInfo",
// 正在热映
getNowList: prefix + "getNowPlayingFilmList",
// 即将上映
getSoonList: prefix + "getComingSoonFilmList",
// 。。。
};
封装请求文件
import axios from "axios";
// 可以对axios进行封装
// 以往在学习使用axios的时候每次取获取数据的结果都是从ret.data中获取
// 这种写法很是不方便,我们可以在此处对axios进行改造,让返回的ret就等同于以前的ret.data
// 拦截器:此处是对返回结果其实就是响应进行处理,所以得使用响应拦截器
// var a = 'index.php?'
// a + 'username=zhangsan'
axios.interceptors.response.use((ret) => {
// 将ret.data换成ret
return ret.data || ret;
// if (ret.data) {
// return ret.data;
// } else {
// return ret;
// }
});
// 请求拦截器
// axios.interceptors.request.use();
export default axios;
注册axios到vue实例上
this
,注册到实例上后续可以直接通过this调用,而不再需要每次都importhttp.js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
// 导入axios
import axios from "./api/http";
// 将axios注册到vue实例上(原型上)
Vue.prototype.$http = axios;
Vue.config.productionTip = false;
new Vue({
router,
store,
render: (h) => h(App),
}).$mount("#app");
测试可用性
官网:Vant 4 - A lightweight, customizable Vue UI library for mobile web apps.
Vant 是有赞前端团队开源的移动端组件库,于 2017 年开源,已持续维护 4 年时间。Vant 对内承载了有赞所有核心业务,对外服务十多万开发者,是业界主流的移动端组件库之一。
配置使用步骤
安装
npm i -S vant
引入组件
npm i -D babel-plugin-import
// 对于使用 babel7 的用户,可以在 /babel.config.js 中配置
module.exports = {
// 原先自带的
presets: ["@vue/cli-plugin-babel/preset"],
// 从vant文档中复制的
plugins: [
[
"import",
{
libraryName: "vant",
libraryDirectory: "es",
style: true,
},
"vant",
],
],
};
导入&使用UI组件
该功能实现需要是的vant组件是:Vant 4 - A lightweight, customizable Vue UI library for mobile web apps.
底部导航组件名称:Footer.vue
导航文件位置:src/components/Nav/Footer.vue
实现步骤
a. 将Footer.vue组件设置为全局使用的组件(将Footer.vue在App.vue中去使用)
b. 将vant的导航组件tabBar在Footer.vue中去使用(参考vant文档来使用)
具体实现
a. 将Footer.vue在App.vue中进行使用
注意点:
b. 将vant的导航组件tabBar在Footer.vue中去使用(参考vant文档来使用)
解决字体图标问题
从阿里矢量图上下载合适的图标
将解压缩的文件夹放到src/assets目录下,为了方便并命名为iconfont
在Footer.vue组件中去使用iconfont.css
import "@/assets/iconfont/iconfont.css"
在Footer.vue中去使用阿里矢量图(难)
导航跳转的问题
细小bug需要解决
电影
影院
我的
使用的vant组件是:Vant 4 - A lightweight, customizable Vue UI library for mobile web apps.
底部导航组件名称:Header.vue
导航文件位置:src/components/Nav/Header.vue
实现步骤
a. 在Films.vue组件中去使用Header.vue组件
b. 完成Header.vue的代码编写
具体实现
a. 在Films.vue组件中去使用Header.vue组件
b. 完成Header.vue的代码编写
需求点
基本信息
涉及的组件:src/views/Films/NowPlaying.vue
涉及的vant组件:
目标1:实现列表的基本展示
a. 先获取接口的数据
b. 循环展示列表
在做列表展示的时候需要注意,卡片组件Card有一种奇怪的用法,代码如下:
标签 在Card组件中,有一些属性例如“desc”,可能由于内容比较多或者其它原因需要使用样式(自定义),则以属性形式不好操作;vant支持我们使用“插槽”在
template
中去自定义展示,例如上述代码的“#tags”插槽,其实展示的内容就是Card组件的tags属性中的内容。以此类推,如果需要自定义“desc”属性,则可以这么写:自己定义的desc属性中的内容
在用插槽的形式之后,要删除属性的写法。
目标2:实现顶部导航的吸顶效果
吸顶效果与滚动条距离顶端的高度有关,肯定是需要获取滚动条的位置的。
修改的组件:src/components/Nav/Header.vue
小步骤:
// mounted周期中获取滚动条高度
mounted() {
// 监听滚动条的滚动事件
addEventListener("scroll", () => {
// 获取滚动条距离顶部的高度
let scrollTop = document.documentElement.scrollTop;
if (scrollTop >= 300) {
// 具备吸顶效果
this.isSticky = true;
} else {
// 不具备吸顶效果
this.isSticky = false;
}
});
},
/* 吸顶样式 */
.sticky {
position: fixed;
z-index: 999;
width: 100%;
}
目标3:实现列表的加载更多
请注意,在使用List组件的时候,务必要留意,需要使用list组件包裹我们已经实现的Card组件,不要连同官网手册中Cell组件一起复制。
==完整参考代码==
观众评分:{{ item.grade }}
主演:{{ item.actors | parseActors }}
{{ item.nation }} | {{ item.runtime }}分钟
{{ item.name }}
购票
作业
premiereAt
,该字段是时间戳,单位是秒,注意转化涉及需要修改的组件:
实现步骤
a. 需要在列表上给每个电影的条目添加点击事件,点完之后需要携带电影的id号去详情页面(动态路由参数);
b. 详情页面需要获取电影id号,然后根据电影的id号去查询获取电影的信息并且展示,细节有:
具体实现
a. 需要在列表上给每个电影的条目添加点击事件,点完之后需要携带电影的id号去详情页面(动态路由参数);
给Card组件添加点击事件:
添加事件处理程序:
// 事件处理程序:去详情页
goDetail(filmId) {
// 编程式导航
this.$router.push("/film/" + filmId);
},
b. 详情页面需要获取电影id号,然后根据电影的id号去查询获取电影的信息并且展示,细节有:
步骤1:获取id,根据id获取电影的基本信息
// 逻辑部分
import uri from "@/config/uri";
export default {
data() {
return {
filmInfo: {},
};
},
// 获取数据
created() {
// 获取电影的id号(获取时参数名称要与路由规则中声明的一致)
let filmId = this.$route.params.film_id;
this.$http.get(uri.getDetail + "?filmId=" + filmId).then((ret) => {
// console.log(ret.data.film);
this.filmInfo = ret.data.film;
});
},
};
步骤2:需要将刚才步骤1获取到的数据在组件视图部分展示出来(不考虑图片滑动区域、底部导航的隐藏)
关于项目中时间戳的格式化:
- 自己去使用Date对象去做格式化
- 使用三方的模块去处理格式化(推荐)
- moment包
- 使用方法
- 安装:
npm i -S moment
- 官网手册:Moment.js 中文网
- 语法:
moment(可选的时间戳).format(指定输出的格式);
- moment()方法的参数如果没有,则解析当前时间戳,如果有指定那就解析指定的时间戳。
- format()表示想要输出的格式,例如如果我们想输出“2021-05-20 10:00:00”,则可以通过以下形式指定:
YYYY-MM-DD HH:mm:ss
- moment作为js包,这里时间戳处理单位为毫秒
视图的展示:
返回
{{ filmInfo.name }}
{{ filmInfo.name }}
{{ filmInfo.grade }}
{{ filmInfo.category }}
{{ filmInfo.premiereAt | parseTime }}上映
{{filmInfo.nation}} | {{filmInfo.runtime}}分钟
{{filmInfo.synopsis}}
演职人员
逻辑代码中针对时间戳的格式化与返回上一页的代码:
// 导入moment
import moment from "moment";
export default {
methods: {
// 返回上一页
goBack() {
this.$router.go(-1);
},
},
// 过滤器
filters: {
// 修饰上映时间
parseTime(timestamp) {
// moment作为js包,这里时间戳处理单位为毫秒
return moment(timestamp * 1000).format("YYYY-MM-DD");
},
},
};
步骤3:实现图片的滑动显示
使用插件:Swiper
官网:Swiper中文网-轮播图幻灯片js插件,H5页面前端开发
确定使用的案例是:Swiper demo
安装Swiper:
npm i -S swiper
使用步骤:
- 引入css和js
- 定义容器
- 实例化
在vue中,存在数据是异步请求,但是后面的渲染需要用到数据的情况,这个时候我们往往想到的是定时器去解决这个问题,这个不是最优的方法。vue提供了一个异步渲染的方法:$nextTick,语法如下:
this.$nextTick(() => { // 写需要等待异步结束之后再去做的操作 });
视图部分的结构:
演职人员
逻辑部分:
// 导入swiper相关的外部文件
import Swiper from "swiper";
import "swiper/swiper-bundle.min.css";
// 获取数据
export default {
created() {
// 获取电影的id号(获取时参数名称要与路由规则中声明的一致)
let filmId = this.$route.params.film_id;
this.$http.get(uri.getDetail + "?filmId=" + filmId).then((ret) => {
// console.log(ret.data.film);
this.filmInfo = ret.data.film;
// 产生滑动图片组
this.$nextTick(() => {
new Swiper(".swiper-container", {
slidesPerView: 4,
spaceBetween: 30,
});
});
});
},
}
步骤4:实现底部导航适时的隐藏和展示(eventBus)
实现思路:进入到详情组件的时候会通知底部导航隐藏,离开详情组件的时候会通知底部导航显示。
涉及到的知识点:
emit
和on
】将事件中心建立在vue的原型上(修改的是main.js文件)
// 将事件中心建立好之后放到vue原型上,避免后续再使用的时候频繁去new Vue()
Vue.prototype.$eventBus = new Vue();
在App.vue中监听自定义事件toggleFooter
在Detail.vue中去通知显示和隐藏
// 获取数据
created() {
// 通知事件中心隐藏底部导航
this.$eventBus.$emit("toggleFooter", false);
//....
},
beforeDestroy() {
// 在将要离开该组件的时候通知事件中心将底部导航放出来
this.$eventBus.$emit("toggleFooter", true);
},
vuex是一种项目中数据共享的方式。
其具有以下优势:
什么样的数据适合存储在Vuex中?
一般情况下,只有组件之间共享的数据才有必要存储到vuex中,对于组件中私有的数据依旧存储在组件自身的data中即可。
vuex不是脚手架在安装项目的时候自带的,是一个选配的功能,默认是不被安装的(需要自己根据需要选择)。因此其安装和配置存在两种情况:
情况1:在通过vue脚手架vue create xxxx
的命令的时候,手动选择安装vuex【极力推荐】。好处在于不需要自己手动创建store
目录及目录下的index.js
文件。
情况2:在通过vue脚手架vue create xxxx
的命令的时候,可能没有选择安装vuex,则这个时候我们有两种选择:
store
目录和其下的index.js
文件
// 在组件中访问state数据的第一种方式(单个)
this.$store.state.全局数据名称
// 在组件中访问state数据的第二种方式(批量)
// 按需导入mapState函数
import {mapState} from 'vuex'
// 将全局函数映射为当前组件的计算属性
computed: {
...mapState(['count'])
}
第二种方式映射过来的情况,其数据的使用方式如同在当前组件中使用自身的data数据一样(下同)。
- 在视图中,就直接插值表达式
- 在js中就
this.xxxx
// 定义mutations
const sotre = new Vuex.Store({
state: {
count: 0
},
mutations: {
add(state[,arg]){
// 变更状态
state.count++
}
}
})
// 组件中触发mutation的第一种方式
methods:{
handle(){
this.$store.commit('add'[,arg])
}
}
// 组件中触发mutation的第二种方式
import {mapMutations} from 'vuex'
methods:{
...mapMutations(['add','reduce']),
handle1(){
this.add()
},
handle2(){
this.reduce([arg])
}
}
==不要在mutation中写异步的代码==
在mutation中混合异步调用会导致你的程序很难调试。每个mutation执行完成后都会对应到一个新的状态变更,这样devtools就可以打个快照存下来,然后就可以实现 time-travel 了。如果mutation支持异步操作,就没有办法知道状态是何时更新的,无法很好的进行状态的追踪,给调试带来困难。
// 声明action
const store = new Vuex.Store({
// 省略其他代码
mutations: {
add(state){
state.count++
}
},
actions: {
addAsync(context[,arg]){
setTimeout(() => {
context.commit('add'[,arg])
},1000)
}
}
})
// 组件中触发action
methods: {
handle(){
this.$store.dispatch('addAsync'[,arg])
}
}
action也是支持如同state、mutation一样的按需导入mapActions方式进行触发。
// 定义getter
....
getters: {
showNum: state => {
return '当前最新的数量是【' + state.count + '】'
}
}
// 在组件中访问getters数据的第一种方式
this.$store.getters.全局数据名称
// 在组件中访问getters数据的第二种方式
// 按需导入mapGetters函数
import {mapGetters} from 'vuex'
// 将全局函数映射为当前组件的计算属性
computed: {
...mapGetters(['showNum'])
}
为什么有状态的模块化?
使用步骤
注意点1(了解):
命名空间
注意点2:由于模块使用了命名空间,所以之前没有模块化的使用方式(this、map系列)在模块化之后都要发生对应的变化
a. 先要定义默认的数据源(store/modules/common.js)
// 拆分后的模块,基本保留了原先的核心属性(除了modules)
// 该模块由张三负责
// 请注意:在实际开发项目的时候,每个人可能负责不同的模块,鉴于在写代码的时候他们并不可能每次都坐在一起商量vuex中的变量、方法的命名,则可能会出现名称冲突的情况。对于冲突情况vuex会帮我们自动解决,它有自己的合并策略,but对于getters没有合并策略,遇到重名的getters就会报错。这个问题需要解决。
// 解决方案:采用模块命名空间(思想来自于后端)
// 只要给导出的成员加上namespaced属性,值设置为true即可
// 原理:在往index.js中合并vuex各个模块的时候,会先产生一个以模块名称为名的属性,然后才会把模块中的变量和方法放进去。
// 空间名是指在index.js的modules对象中的属性名
// Object.common.isShow
// Object.user.isShow
export default {
// 开启命名空间,防止命名冲突
namespaced: true,
// 默认的数据源
state: {
// 是否限时底部导航
isShow: true,
},
// 同步修改数据的方法集合
mutations: {
setIsShow(state, arg) {
state.isShow = arg;
},
},
// 异步修改数据的方法集合
actions: {
setIsShowAsync(context, arg) {
setTimeout(() => {
context.commit("setIsShow", arg);
}, 500);
},
},
// 数据修饰处理方法集合
getters: {
getIsShow(state) {
return state.isShow ? "显示" : "隐藏";
},
},
};
b. 去除在main.js中对eventBus的原先挂载操作
c. 去除App.vue中的事件监听,换成直接使用store对象的写法
d. 在详情组件进入和离开的时候使用vuex修改数据源
进入组件(created生命周期):
this.$store.commit("common/setIsShow", false);
离开组件(beforeDestroy生命周期):
this.$store.commit("common/setIsShow", true);
前后端分离式开发需要进行数据交互,传输的数据被偷窥、被抓包、被伪造时有发生,那么如何设计一套比较安全的API接口方案呢?
并不是所有的接口都需要考虑安全的,有些接口是公开的,任何人只要知道地址都可以调用,对于一些项目中需要用户登录才能访问的接口才需要考虑安全问题。
一般解决的方案有以下几类:
关于
JWT
:
Json web token(JWT),是基于token的鉴权机制,类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息,为应用的扩展提供了便利。JWT具备以下几个优点:
因json的通用性,所以JWT是可以进行跨语言
JWT可以在自身存储一些其他业务逻辑所必要的非敏感信息
便于传输,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的
它不需要在服务端保存会话信息,所以它非常适合应用在前后端分离的项目上
使用JWT进行鉴权的工作流程如下(重点):
JWT是由三段信息构成的(头部、载荷、签名),将这三部分使用.
连接在一起就组成了JWT字符串,形如:(“头部.载荷.签名”)
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjNmMmc1N2E5MmFhIn0.eyJpYXQiOjE1NTk1Mjk1MjksImlzcyI6Imh0dHA6XC9cL3d3dy5weWcuY29tIiwiYXVkIjoiaHR0cDpcL1wvd3d3LnB5Zy5jb20iLCJuYmYiOjE1NTk1Mjk1MjgsImV4cCI6MTU1OTUzMzEyOSwianRpIjoiM2YyZzU3YTkyYWEiLCJ1c2VyX2lkIjoxfQ.4BaThL6_TbIMBGLIWZgpnoDQ-JlAjzbiK3y3BcvNiGI
其中:
一个完整的头部就像下面的JSON:
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64加密/编码(该加密是可以对称解密的),这就得到了jwt的第一部分。
定义一个payload:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
依旧进行base64加密,这就得到了jwt的第二部分。
例如:
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret');
这样就得到了jwt的第三部分。
var jwt = encodedString + '.' + base64UrlEncode(signature);
最终将三部分信息通过.
进行连接就得到了最终的jwt字符串。后续不需要自己去写jwt怎么生成的。因此,此流程理解即可。
需要注意的是
- secret是保存在服务器端的
- jwt的签发生成也是在服务器端的
- secret是用来进行jwt的签发和jwt的验证
所以,secret它就是服务端的私钥,在任何场景都不应该泄露出去。一旦其他人(包括客户端的用户)得知这个secret,那就意味着他们可以自我签发jwt,接口就没有安全性可言了。
①新建一个空文件夹,在其中初始化NodeJS项目
npm init -y
npm i -S express md5 mongoose jsonwebtoken body-parser cors
②新建http.js
文件,创建一个express服务器
const express = require("express");
const app = express();
const port = 3000;
const path = require("path");
const fs = require("fs");
const md5 = require("md5");
const bodyParser = require("body-parser");
const jwt = require("jsonwebtoken");
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json())
app.get("/", (req, res) => res.send("Hello World!"));
app.listen(port, () => console.log(`Server is running at http://127.0.0.1:${port}!`));
③使用md5
模块来编写一个对密码加密的中间件
// 密码加密中间件
const passwdCrypt = (req, res, next) => {
// 明文密码字段为password
// 加盐加密或加料加密
const passwd = md5(req.body.password + md5(req.body.password).substr(9, 17));
// 将加密的密码挂载到req的body上(覆盖原密码)
req.body.password = passwd;
// 继续
next();
};
app.use(passwdCrypt);
④调用写好的密码加密中间件生成一个用户的初始密码用于后面做登录使用
// 接口3:获取初始的数据库中用户的密码加密结果(一次性接口)
app.post("/api/v1/user/passwdInit", (req, res) => {
res.send("您的初始密码为123456,加密结果为:" + req.body.password);
});
POST形式访问
/init
获得密码后就得到了一个完整的用户数据,此时可以将数据写入到MongoDB中。如果我们自己的加密方式与讲义的代码不一样,请根据自己加密得到的密码来实际替换下面的password字段的值。
{
userId: 31167509,
mobile: '18512345678',
password: '66b044ec6d334ad42eca2a4c164bde17',
headIcon: 'https://mall.s.maizuo.com/4f0b29878f62f5e298a89a4654f0e8f0.jpg',
gender: 0,
}
将模拟好的数据,写入到数据库中,以便后面做登录操作:
⑤配置jsonwebtoken
模块需要用的secret
,并在代码中读取供后续使用
在node项目目录中创建一个.env(Linux以.开头都为隐藏文件)并在此文件中写入jwt加密所需要的秘钥。同时,.env文件不要上传到Github上(.gitignore文件中声明忽略)。
在代码中读取secret
// 读取secret
const secret = fs.readFileSync(path.join(__dirname,"../",".env"),"utf-8");
⑥引入mongoose
// 引入mongoose
const mongoose = require("mongoose");
mongoose.connect("mongodb://localhost:27017/maizuo", {
useNewUrlParser: true,
useUnifiedTopology: true,
});
const UserSchema = new mongoose.Schema({
userId: {
type: Number,
required: true,
},
mobile: {
type: String,
required: true,
},
password: {
type: String,
required: true,
},
headIcon: String,
gender: Number,
});
const Model = mongoose.model("User", UserSchema, "users");
⑦创建登录路由/api/v1/user/login
实现用户名密码校验,并判断校验结果做出响应
// 登录验证接口
app.post("/api/v1/user/login", (req, res) => {
// 获取手机号与加密之后的密码
let data = req.body;
// 去数据库中去查询上是否存在对应的记录
// 注意:mongoose提供的方法都是异步的
Model.findOne(data).then((ret) => {
// findOne:查不到返回null,查到就返回对应的文档(建议)
// find:查不到返回空数组,查到了返回包括文档的数组
if (ret) {
// 查到了,签发令牌
// 语法:jsonwebtoken.sign(载荷对象,secret)
let _token = jsonwebtoken.sign(
{
userId: ret.userId,
},
secret
);
res.send({
error: 0,
msg: "登录成功!",
_token,
});
} else {
// 没查到
res.send({
error: 1,
msg: "手机号或密码错误。",
});
}
});
});
最终输出
登录成功则输出:
{
"error": 0,
"msg": "登录成功!",
"_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjMxMTY3NTA5LCJpYXQiOjE2MjA3MDI0MDh9.KA0ANNgUvZoZQmftacFvsUxia0_q0Ofx3ZRL9TJhdaE"
}
登录失败则输出:
{
"error": 1,
"msg": "手机号或密码错误。"
}
个人中心的信息是用户登录成功后才能进行的页面展示,在请求数据时,后台接口一定要判断当前请求是否有token,且token解密后一定是一个合法数据。
**接口需求:**依据客户端传递给服务端的用户编号userId
,在验证通过jwt
后输出对应用户信息
注意点:
有些企业提供的接口jwt所返回的token格式可能会在原有token之前拼接一个
持有者(空格)
的信息,例如用户zhangsan
获取到的token:
zhangsan eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjMxMTY3NTA5LCJtb2JpbGUiOiIxODUxMjM0NTY3OCIsImlhdCI6MTYwMjY0OTg2NX0.tVByVZYu4s5dgzLZwR00HHW7QZ0gkYpVXaVNhCdawbU
如果是上述在接收token的时候需要注意,别获取错误了。注意,验证是否合法只用token,前面的持有者不用。
// 接口2:获取用户信息
app.get("/api/v1/user/getUserInfo", (req, res) => {
// 1. 认证token(在认证的过程中,如果认证失败,程序会抛异常)
try {
let tokenStr = req.headers["authorization"];
let arr = tokenStr.split(" ");
// 最后一个元素即token
let token = arr[arr.length - 1];
// 开始验证令牌
// 语法:jsonwebtoken.verify(令牌字符串,secret)
// 验证成功返回载荷,验证失败抛异常
const ret = jsonwebtoken.verify(token, secret);
// (可选)验证是否过期【约定过期时间2小时,7200s】
if (Date.now() / 1000 - ret.iat > 7200) {
// 过期了
res.send({
error: 3,
msg: "token令牌已经过期。",
token: "",
});
} else {
// 没过期
// 判断是否马上要过期了,如果是自动给它生成新的token
if (Date.now() / 1000 - ret.iat > 5400) {
token = jsonwebtoken.sign(
{
userId: ret.userId,
},
secret
);
}
// 获取数据
Model.findOne({ userId: ret.userId }).then((ret) => {
// 2. 返回(失败或成功)
if (ret) {
// 取到信息了,则返回
res.send({
error: 0,
msg: "用户信息获取成功!",
token: token,
data: {
userId: ret.userId,
mobile: ret.mobile,
headIcon: ret.headIcon,
gender: ret.gender,
},
});
} else {
// 账号已经已经没了
res.send({
error: 4,
msg: "你号没了。",
token: "",
});
}
});
}
} catch (error) {
// 抛异常了
res.status(500).send({
error: 2,
msg: "token令牌验证失败。",
});
}
});
①先给登录按钮绑定点击事件,点击之后去往登录页面
立即登录
事件处理程序
methods: {
goLogin() {
// 编程式导航
this.$router.push("/login");
},
},
②需要在登录组件Login.vue中设置底部导航的适时显示和隐藏(vuex)
created() {
// 进入该组件隐藏底部导航
this.$store.commit("common/setIsShow", false);
},
beforeDestroy() {
// 离开该组件显示底部导航
this.$store.commit("common/setIsShow", true);
},
③在Login.vue中发起登录请求
// 表单提交按钮触发的处理程序
onSubmit(values) {
// 发起网络请求请求nodejs
// 如果参数直接就是values,则该请求为post形式json请求
this.$http.post("http://127.0.0.1:8000/api/v1/user/login", values).then((ret) => {
if (ret.error > 0) {
// 出错了
Toast.fail(ret.msg);
} else {
Toast.success(ret.msg);
setTimeout(() => {
this.$router.push("/center");
}, 2000);
}
});
},
④在模块化的vuex中(common.js)设置针对token的配置项
state: {
isShow: true,
_token: "",
},
mutations: {
setIsShow(state, arg) {
state.isShow = arg;
},
// 设置token的值
setToken(state, arg) {
state._token = arg;
// 存储到localStorage中
localStorage.setItem("_token", arg);
},
}
⑤在响应拦截中统一保存token(http.js)
// 在非vue文件中无法使用`this.$store`对象
// 导入store获取store对象
import store from "@/store/index"
// 响应拦截器
// 使用场景:用于对响应结果进行加工处理再返回
axios.interceptors.response.use((ret) => {
// 为了方便,在拦截器中判断是否有token,如果有则直接存储(复用)
if (ret.data._token) {
// 存储到vuex中
store.commit("common/setToken", ret.data._token);
}
// 再简写(短路运算)
return ret.data || ret;
});
这个时候会出现一个问题,当页面刷新的时候vuex中的数据就会被重新初始化。但是localStorage中的数据依旧还在,所以需要将数据再次反向同步一下。时机:在页面加载的时候。
⑥在入口main.js文件中去做本地存储与vuex的同步
// 在这里统一从localStorage中获取数据赋值给vuex
let _token = localStorage.getItem("_token");
if (_token) {
store.commit("common/setToken", _token);
}
流程:在进入到个人中心组件后应先判断下当前用户是否登录(是否有token),如果有尝试使用token去调用接口获取个人信息,取到了再展示。
①在请求拦截器中添加token的请求
// 导入store获取store对象
import store from "@/store/index";
// 请求拦截器:
// 使用场景:需要在请求的时候设置全局的超时时间、设置比较统一的请求头等
axios.interceptors.request.use((config) => {
// 拦截下来
// 在这里增加上额外需要的处理,比如加头信息、加全局配置。。。
// 追加头信息
let _token = store.state.common._token;
if (_token) {
// 加请求头
config.headers["Authorization"] = _token;
}
// 放行
return config;
});
②在个人中心组件发送请求获取个人信息
data() {
return {
// 初始化个人信息对象
userInfo: {},
};
},
created() {
let _token = this.$store.state.common._token;
if (_token) {
this.$http.get("http://127.0.0.1:8000/api/v1/user/getUserInfo").then((ret) => {
if (ret.error == 0) {
// 赋值给初始可变数据
this.userInfo = ret.data;
}
});
}
},
③在视图中展示数据
针对是否登录的测试,通过浏览器的无痕模式(Ctrl+Shift+N),在该模式下,不会与普通模式共享任何会话信息。
知识点:路由守卫
含义:防止用户绕过登录页面,通过直接在地址栏中输入地址去访问原本需要登录才能访问的页面。
①例如:给个人中心的“余额”按钮绑定点击事件,点击之后去往余额组件
事件处理程序:
methods: {
// 去账户余额页面
goAccount() {
this.$router.push("/account");
},
},
②在无痕模式下直接访问余额地址也可以访问到页面内容(不合理)
在实际开发的时候对于操作,前后端应该统一战线,与“行为”不共戴天。也就是说不管做前端也好,还是做后端也罢,都需要解决用户的行为。只不过以前只要后端做就可以了,现在前后端都要做。先触发前端的防;如果前端拦不住,后端再上。
③使用路由守卫
分为全局守卫和局部守卫。
修改router/index.js文件,添加全局的前置路由守卫
router.beforeEach((to, from, next) => {
// 需要定义一组数据(路由),这组路由可能是需要登录才能访问的(当然也可能是不需要登录就能访问的),当我们获取到目标路由的时候,可以去数组中进行判断,符合特定条件就继续,否则不允许访问。
// 例如:下面的数组需要登录才能访问
let needLogin = ["/account", "/order", "/settings"];
if (needLogin.includes(to.path)) {
// 判断是否有token
let token = store.state.common._token;
if (token) {
// 登录了
next();
} else {
// 没登录,去登录页面
router.push("/login?goto=" + to.path);
}
} else {
// 继续
next();
}
});
④可以设置指定返回的地址
当用户登录成功之后,设置跳转到指定的地址(在登录的vue组件中):
// 表单提交按钮触发的处理程序
onSubmit(values) {
// 发起网络请求请求nodejs
// 如果参数直接就是values,则该请求为post形式json请求
this.$http.post("http://127.0.0.1:8000/api/v1/user/login", values).then((ret) => {
if (ret.error > 0) {
// 出错了
Toast.fail(ret.msg);
} else {
Toast.success(ret.msg);
setTimeout(() => {
// 判断是否有来源地址
if (this.$route.query.goto) {
this.$router.push(this.$route.query.goto);
} else {
this.$router.push("/center");
}
}, 2000);
}
});
},
六、项目上线流程(记忆)
1、项目上线的要素
- 辅助软件
- 连接服务器的软件
- 文件传输工具(FileZilla或其他替代方案)/ Git
- 服务器(购买)
- 选型:Linux(性能好,安全性高)
- 配置环境(难度大,命令行)
- 域名(可选)
- 好记
- 在大陆地区使用大陆的服务器,需要对域名进行备案(15天)+ 公安备案
- 代码
2、Vue项目上线发布
2.1、购买云服务器
设置安全组/防火墙:
2.2、云服务器操作基础
①使用cmder等终端工具连接远程服务器
ssh root@服务器公网IP地址
在首次连接时会询问是否连接,输入yes
按下回车。随后输入密码,在输入密码的时候没有任何提示,确认正确输入后按下回车。
退出的方式有2种:
- 简单粗暴关闭连接工具
- Ctrl + D
服务器旨在长期稳定的给用户提供服务,因此没有特殊需求,一般是不用关机的。因此,上述2个退出操作并不会让服务器关机。
附:基本操作命令
-
pwd:(print working directory)输出当前命令行所在的工作路径
-
cd:(change directory)更改当前命令行所在的工作路径
- cd 路径
- 路径支持相对路径与绝对路径,需要注意Linux系统没有盘符的概念
-
ls:(list,列出)列出指定(默认为当前)路径下的文档结构
- ls 选项 路径
- 选项:
- -a:列出所有(包含隐藏文档)的文档
- -l:(list,列表)以列表的形式列出详细信息
- 选项可以合在一起写(仅支持单个字母的选项),让多个选项公用一个“-”
-
mkdir:(make directory)创建文件夹
- mkdir 文件夹路径
- 选项:
- -p:(parent)在创建文件夹的时候连同其父级文件夹一起创建
-
touch:创建普通文件
- touch 文件路径
- 路径要求目录必须存在(touch没有mkdir类似的流氓
-p
选项)
-
cp:(copy)复制文档
- cp 选项 需要复制的文档路径 复制到的位置
- 选项:
- -r:递归(如果是复制的是文件夹,则一定要递归)
-
mv:(move)移动/剪切&重命名文档
- mv 需要操作的文档路径 保存的文档路径
-
rm:(remove)删除文档
- rm 选项 文档路径
- 选项:
- -r:递归
- -f:强制(不提示是否删除,静默模式)
②本地⇋服务器
的文件传输
文件传输可以借助可视化的辅助工具,如:FileZilla
2.3、项目运行环境部署
后续操作会用到不少相对路径,为了保证大家的操作正确,此处统一先切换当前工作路径:
cd /usr/local/src
# 该地址是已经存在的,不需要自己创建
①安装mongoDB
下载地址:Download MongoDB Community Server | MongoDB
可以选择Copy Link
随后去服务器中对应的目录执行命令下载:
wget https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel70-4.4.4.tgz
# 或
curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel70-4.4.4.tgz
也可以直接用今日docs
目录下已经下载好的压缩包,使用FileZilla进行上传到服务器的/usr/local/src
目录。
.tgz
格式文件是压缩包文件格式的一种,需要使用其中的内容得先解压,解压命令为:
tar -zxvf mongodb-linux-x86_64-rhel70-4.4.4.tgz
# 或
tar -xvf mongodb-linux-x86_64-rhel70-4.4.4.tgz
# -z:表示指定解压缩所使用的方式,表示使用gz格式进行解压
# 如果不指定使用什么方式解压,则tar会自己判断
解压后会得到mongoDB的解压目录:
Linux系统下对于第三方软件的安装一般存放在/usr/local
下,此处建议将解压后得到的目录中的bin
目录进行转移,转移的同时需要创建mongoDB的数据文件夹和日志文件夹,命令如下:
mkdir -p /usr/local/mongodb/data
mkdir /usr/local/mongodb/log
cp -r /usr/local/src/mongodb-linux-x86_64-rhel70-4.4.4/bin /usr/local/mongodb/
# 建立mongodb需要使用的日志文件
touch /usr/local/mongodb/log/logfile
上述指令执行完毕后可以通过ls
进行列出检查,查看是否有以下文档结构:
ls /usr/local/mongodb/
随后,就可以通过以下命令去启动mongoDB了:
需要注意,此种方式的mongoDB为绿色软件,默认不会开机自动启动,不再需要使用的时候直接删除/usr/local/mongodb
目录即可卸载软件。
/usr/local/mongodb/bin/mongod --dbpath=/usr/local/mongodb/data --logpath=/usr/local/mongodb/log/logfile --bind_ip=127.0.0.1 --fork
# --dbpath:指定数据库文件夹位置
# --logpath:指定日志文件位置
# --bind_ip:绑定监听的网卡ip地址
# --fork:以后台服务的形式运行
注意:logpath
配置项的值一定是一个文件(可以不存在),不能是文件夹。
至此,mongoDB已经可以使用了,可以通过运行mongoDB连接工具进行测试,如果有以下输出则一切正常:
此时可以在其中创建好maizuo
数据库,以及往库中写入users
表中的数据了。
②安装nodejs
文档地址:https://github.com/nodesource/distributions/blob/master/README.md
复制好对应的指令后在终端中去执行(这个命令会在我们服务器上安装一个nodejs的镜像源以告诉包管理工具去哪里下载nodejs):
curl -sL https://rpm.nodesource.com/setup_14.x | bash -
随后运行以下命令安装nodejs:
yum类似于npm的感觉,是用于管理centos下的软件包的。
sudo yum install -y nodejs
使用sudo
开头的命令可能会提示让输入密码,如果有则输入当前用户的密码即可。
安装好nodejs后,可以通过命令测试是否安装成功nodejs:
node -v
最后,可以继续安装一些可选的全局包以方便后面使用:
# 安装nrm,并切换npm镜像源为淘宝
npm i -g nrm
nrm use taobao
# 安装nodemon
npm i -g nodemon
# 安装pm2(让node在后端运行的工具,这样可以在配置完毕之后关闭终端窗口)
npm i -g pm2
到此,nodejs环境安装完毕!
上传node服务端的代码到远程服务器,位置可以随意(因为代码是node运行的,不是nginx):
接下来进入node代码的目录/usr/local/src/http
,运行安装所需模块的指令:
npm install
此时即便运行了node服务器,也会出现无法访问的情况,需进入阿里云的控制台添加允许3000端口通过。(针对专有网络只有“入方向”需要配置,针对经典网络只有“公网入方向”需要配置)
最后,让node在后台执行http.js文件(根据需要换成自己的文件名),此处需要用到前面安装的pm2工具:
# 先进入项目目录
pm2 start index.js
## 重启
pm2 restart index.js
## 停止
pm2 stop index.js
如果成功,则会看到如下效果:
③安装nginx
Nginx是一款轻量级服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。
软件官网:nginx news
傻瓜式包管理工具安装方式说明参考地址:nginx: Linux packages
按照上述提示,在服务器上指定的位置/etc/yum.repos.d/nginx.repo
新建一个文件,文件内容如下:
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
[nginx-mainline]
name=nginx mainline repo
baseurl=http://nginx.org/packages/mainline/centos/$releasever/$basearch/
gpgcheck=1
enabled=0
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
对于文件的创建和修改,可以考虑咋windows上进行,写完毕之后再通过文件传输工具,将文件上传到指定的位置即可。
随后运行nginx的安装命令:
sudo yum install -y nginx
在nginx完成安装后,可以通过以下几个命令来管理nginx服务:
# 启动nginx
systemctl start nginx
# 停止nginx
systemctl stop nginx
# 重启nginx
systemctl restart nginx
# 设置nginx开机自启动
systemctl enable nginx
# 设置nginx开机不自启动
systemctl disable nginx
接下来启动nginx:
systemctl start nginx
请注意,后续每次修改了nginx的配置文件都需要对nginx服务进行重启,否则新的配置不会生效。
nginx相关的目录位置:
- 配置文件
- 主配置文件nginx.conf:/etc/nginx/nginx.conf
- 从配置文件‘*.conf’:可以是任意位置,以主配置文件声明为准,比较常用针对站点的从配置文件在
/etc/nginx/conf.d/
目录下
- 默认站点目录
- /usr/share/nginx/html(等于PHPstudy中的WWW目录,回头代码得放到这个里面去)
④域名解析(如果有域名的话)
如果是大陆服务器使用,则域名一定要通过了ICP备案才可以。
以阿里云为例,先进入域名控制台,在需要使用的域名后面点击解析
按钮进入解析页面,随后点击添加记录
按钮并按照自身需求填写解析信息:
设置完成后一般1分钟内即可生效,可以在本机windows
上通过ping
命令进行测试:
# 以刚才设置的域名为例
ping sh2008.lynnn.cn
⑤项目代码部署
a. 修改项目中的所有请求地址,将其都改成线上模式的地址,随后打包。将打包好的vue代码上传到Nginx默认的站点下,目录地址为/usr/share/nginx/html
vue项目打包命令:
npm run build
b. 解决nginx下,vue路由模式history
失效的问题
方案1:不使用history
模式的路由
不使用istory
模式,则得用hash模式,该模式下地址栏会有#
方案2:配置nginx,让nginx支持history
模式的路由
try_files $uri $uri/ /index.html;
将上述的代码放到/etc/nginx/conf.d/default.conf
中
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# 以下是新增的一行
try_files $uri $uri/ /index.html;
}
随后重启nginx:
systemctl restart nginx
Q.E.D.