电商项目实战
创建项目 vue-mart
选择一个合适的UI库
我们要做的是一个移动端电商项目,所以先看下基于Vue的移动端UI组件库,如下,最终选择Cube-ui
- 安装:vue add cube-ui
V1.0版本
- 登录/注销
App.vue
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link> |
<router-link to="/login" v-if="!isLogin">Login</router-link>
<a @click="logout" v-if="isLogin">logout</a>
</div>
<router-view/>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
methods: {
logout() {
this.$http.get("/api/logout");
}
},
computed: {
...mapGetters(["isLogin"])
}
};
</script>
Login.vue
<template>
<div>
<cube-form :model="model"
:schema="schema"
@submit="handleLogin"
@validate="handleValidate"></cube-form>
</div>
</template>
<script>
export default {
data() {
return {
model: {
// 数据模型
username: "",
password: ""
},
schema: {
// 表单结构模型
fields: [
{
type: "input",
modelKey: "username",
label: "用户名",
props: {
placeholder: "请输入用户名"
},
rules: {
// 校验规则
required: true
},
trigger: "blur",
messages: {
required: "用户名为必填项"
}
},
{
type: "input",
modelKey: "password",
label: "密码",
props: {
placeholder: "请输入密码",
type: "password",
eye: { open: false }
},
rules: {
// 校验规则
required: true
},
trigger: "blur",
messages: {
required: "密码为必填项"
}
},
{
// 登录按钮
type: "submit",
label: "登录"
}
]
}
};
},
methods: {
async handleLogin(e) {
e.preventDefault();
console.log("登录");
const res = await this.$http.get("/api/login", {
params: {
username: this.model.username,
password: this.model.password
}
});
console.log(res);
const { code, token, message } = res.data;
if (code == 0) {
// 登录成功
localStorage.setItem("token", token); // 缓存至本地
this.$store.commit("setToken", token); // 存入store
// 回跳
// const { redirect } = this.$route.query || "/";
const redirect = this.$route.query.redirect || "/";
this.$router.push(redirect);
} else {
// 登录失败
const toast = this.$createToast({
time: 2000,
txt: message || "登录失败",
type: "error"
});
toast.show();
}
},
handleValidate(ret) {
console.log("校验:" + ret);
}
}
};
</script>
main.js
import Vue from 'vue'
import './cube-ui'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from 'axios'
import interceptor from './http-interceptor'
Vue.config.productionTip = false
Vue.prototype.$http = axios;
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import store from './store'
Vue.use(Router)
const router = new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/login',
name: 'login',
component: () => import('./views/Login')
},
{
path: '/about',
name: 'about',
meta: {auth: 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: () => import(/* webpackChunkName: "about" */ './views/About.vue')
}
]
})
router.beforeEach((to, from, next) => {
if(to.meta.auth){
// 需要认证,则检查令牌
if(store.state.token){// 已登录
next();
}else{// 去登陆
next({path: '/login', query: {redirect: to.path}})
}
}else{
next();
}
});
export default router;
store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
token: localStorage.getItem('token') || ''
},
mutations: {
setToken(state, token){
state.token = token
}
},
actions: {
},
getters: {
isLogin: state => {
return !!state.token; // 转换为布尔值
}
}
})
http拦截器
// 拦截axios所有http请求,预先放入token请求头
import axios from "axios";
import store from "./store";
import router from "./router";
axios.interceptors.request.use(config => {
if (store.state.token) {
// 若存在令牌,则放入请求头
config.headers.token = store.state.token;
}
return config;
});
// 响应拦截器,提前预处理响应
axios.interceptors.response.use(
response => {
// 如果code是-1,说明用户已注销或者token已过期
// 此时需要重新登录,并且还要清楚本地缓存信息
if (response.status == 200) {
const data = response.data;
if (data.code == -1) {
clearHandler()
}
}
return response;
},
err => {
if (err.response.status === 401) { // 未授权
clearHandler()
}
}
);
function clearHandler() {
// 清空缓存
store.commit("setToken", "");
localStorage.removeItem("token");
// 跳转至登录页
router.push({
path: "/login",
query: {
redirect: router.currentRoute.path
}
});
}
vue.config.js
module.exports = {
css: {
loaderOptions: {
stylus: {
'resolve url': true,
'import': [
'./src/theme'
]
}
}
},
pluginOptions: {
'cube-ui': {
postCompile: true,
theme: true
}
},
configureWebpack: {
devServer: {
before(app) {
// 中间件
app.use(function (req, res, next) {
// 检查token
if (/^\/api/.test(req.path)) { // 之校验/api开头的请求
if (req.path == '/api/login' || req.headers.token) {
next();
} else {
res.sendStatus(401); // 错误状态提示用户需要登录
}
}else{
next();
}
})
app.get("/api/goods", function (req, res) {
res.json({
code: 0,
list: [
{ id: 1, text: "Web全栈架构师", price: 1000 },
{ id: 2, text: "Python架构师", price: 1000 }
]
});
});
app.get("/api/login", function (req, res) {
const { username, password } = req.query;
if (username === "jerry" && password === "123") {
res.json({
code: 0,
token: "jilei"
});
} else {
res.json({
code: 1,
message: "用户名或密码错误"
});
}
});
app.get('/api/logout', function (req, res) {
res.json({ code: -1 })
})
}
}
}
}
作业
利用post请求方式完成登录?
// POST请求方式登录
app.post("/api/login", function (req, res){
let body = [];
req.on('data', chunk => {
// 接收一部分数据
console.log(chunk); // chunk是Buffer对象
body.push(chunk);
}).on('end', () => {
// 数据接收完毕,将body转换为完整的buffer
body = Buffer.concat(body).toString();
const {username, password} = JSON.parse(body); // {name:'aaa',age:20}
if (username === "jerry" && password === "123") {
res.json({
code: 0,
token: "jilei"
});
} else {
res.json({
code: 1,
message: "用户名或密码错误"
});
}
});
});
token过期如何验证?
设置一个新接口,在about页面做校验
const API_KEY = 'kaikebazhenbucuo';
app.get('/api/authapi', function(req, res){
const {token} = req.headers;
if(!token){
return res.json({code: -1});
}
const [key, expires] = token.split('-');
const now = new Date().getTime();
if(key==API_KEY && expires>now){
return res.json({
code: 0,
data: '通过校验'
})
}else{
return res.json({
code: -1,
message: '登录授权过期'
});
}
})
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<script>
export default {
created(){
this.$http.get('/api/authapi');
}
}
</script>
表单异步校验?
校验规则rules字段可以返回一个函数
rules: {
type: 'string',
required: true,
min: 3,
max: 5,
usercheck: (val) => {
return (resolve) => {
axios.get('/api/check?username='+val).then(res=>{
resolve(res.code === 0)
})
}
}
},
trigger: 'blur',
messages: {
required: '用户名不能为空',
min: '用户名不得小于3个字符',
max: '用户名不得大于15个字符',
usercheck: '用户名不存在'
}
V2.0版本
tab导航
App.vue 中使用cube-tab-bar实现导航,文档地址:https://didi.github.io/cube-ui/#/zh-CN/docs/tab-bar
<cube-tab-bar show-slider
v-model="selectLabel"
:data="tabs"
@click="changeHandler"></cube-tab-bar>
<style>
.cube-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #edf0f4;
}
.cube-tab-bar-slider{
top: 0;
}
/* 页面滑动动画 */
/* 入场前 */
.route-move-enter{
transform: translate3d(-100%, 0, 0);
}
/* 出场后 */
.route-move-leave-to {
transform: translate3d(100%, 0, 0);
}
.route-move-enter-active,
.route-move-leave-active {
transition: transform 0.3s;
}
.child-view {
position: absolute;
left: 0;
top: 0;
width: 100%;
padding-bottom: 36px;
}
</style>
data() {
return {
selectLabel: "/", // 默认页签
tabs: [
{ label: "Home", value: "/", icon: "cubeic-home" },
{ label: "Cart", value: "/cart", icon: "cubeic-mall" },
{ label: "Me", value: "/login", icon: "cubeic-person" }
]
};
},
- Vue过渡动画:https://cn.vuejs.org/v2/guide/transitions.html
轮播图
Home.vue 中使用cube-slide实现轮播图,文档地址:https://didi.github.io/cube-ui/#/zh-CN/docs/slide
<!-- 轮播图 -->
<cube-slide :data="slider" :interval="5000">
<cube-slide-item v-for="item in slider" :key="item.id">
<router-link :to="`/detail/${item.id}`">
<img :src="item.img" alt="" class="slider">
</router-link>
</cube-slide-item>
</cube-slide>
data(){
return {
slider: [],
goods: [], // 所有商品列表
selectedKeys: [], // 分类过滤时使用
keys: [] // 分类
}
},
async created(){
const {
data: {data: goods, slider, keys}
} = await this.$http.get('/api/goods');
console.log(goods, slider);
this.slider = slider;
this.goods = goods;
this.keys = keys;
this.selectedKeys = [...this.keys]; // 默认选中全部分类
}
商品列表
GoodList.vue循环遍历显示商品
<div>
<div class="item" v-for="item in goods" :key="item.id">
<router-link :to="`detail/${item.id}`">
<div class="left">
<!-- 点击图片显示预览图 -->
<img :src="item.img" alt @click.stop.prevent="imgPreview(item.img)">
</div>
<div class="right">
<div class="title">{{item.title}}</div>
<div class="info">
<i class="cubeic-add" @click.stop.prevent="addCart(item)"></i>
<span>{{item.count}}人购买</span>
</div>
</div>
</router-link>
</div>
</div>
.item {
padding: 10px;
overflow: hidden;
.left {
width: 100px;
float: left;
img {
width: 100%;
}
}
.right {
margin-left: 120px;
text-align: left;
.title {
line-height: 30px;
}
.cubeic-add {
font-size: 22px;
}
}
}
export default {
props: ["goods"],
methods: {
addCart(item) {
// 加购物车
this.$store.commit('addCart', item);
},
imgPreview(img){
// 调用cube-ui全局api动态添加图片预览组件
this.$createImagePreview({
imgs: [img]
}).show();
}
}
}
<style lang="stylus">
.good
padding 10px
text-align left
.right
float right
i
font-size 18px
</style>
购物车
购物车是一个全局的组件,数据用vuex管理
<template>
<div>
<div class="good" v-for="(item,index) in cart" :key="item.id">
{{item.title}}
<div class="right">
<i class="cubeic-remove" @click="countMinus(index)"></i>
<span>
{{item.cartCount}}
</span>
<i class="cubeic-add" @click="countAdd(index)"></i>
</div>
</div>
<div>
总价 {{total}}
</div>
<cube-button :disabled="true" v-if="total<minTotal">
还差{{minTotal-total}}可以购买
</cube-button>
<cube-button v-else>
下单
<span v-if="!token">
(需要登录)
</span>
</cube-button>
</div>
</template>
import { mapState, mapGetters } from "vuex";
export default {
data() {
return {
minTotal: 1000
};
},
computed: {
...mapState({
cart: state => state.cart,
token: state => state.token
}),
...mapGetters({
total: "total"
})
},
methods: {
countAdd(index) {
this.$store.commit("countAdd", index);
},
countMinus(index) {
this.$store.commit("countMinus", index);
}
}
};
- store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const store = new Vuex.Store({
state: {
token: localStorage.getItem('token') || '',
cart: JSON.parse(localStorage.getItem('cart')) || []
},
mutations: {
setToken(state, token){
state.token = token
},
addCart(state, item) {
const good = state.cart.find(v => v.id == item.id);
if (good) {
good.cartCount += 1;
} else {
state.cart.push({
...item,
cartCount: 1
});
}
},
countMinus(state, index) {
const item = state.cart[index];
if (item.cartCount > 1) {
item.cartCount -= 1;
} else {
state.cart.splice(index, 1);
}
},
countAdd(state,index) {
state.cart[index].cartCount += 1;
}
},
actions: {
},
getters: {
isLogin: state => {
return !!state.token; // 转换为布尔值
},
cartTotal: state => { // 计算购物车中项目总数
let num = 0;
state.cart.forEach(v => {
num += (v.cartCount||0);
});
console.log(state.cart)
return num;
},
total: state => state.cart.reduce((num, v) => num += v.cartCount*v.price, 0)
}
})
// 订阅store变化
store.subscribe((mutation, state) => {
switch(mutation.type){
case 'setToken':
localStorage.setItem('token', JSON.stringify(state.token));
break;
case 'addCart':
localStorage.setItem('cart', JSON.stringify(state.cart));
break;
}
});
export default store;
UI展示
- Home页面
- 图片预览
- 分类选择
- 购物车页面
小结
- cube-tab-bar:导航tab页签切换组件
- cube-tab: cube-tab-bar中匿名插槽,显示在导航栏里,显示购物车商品的总量
- cube-slide:轮播图组件
- cube-button:按钮组件
- cube-drawer:侧边栏商品列表选择
- this.$createImagePreview({
imgs: [img]
}).show();:cube-ui全局api动态添加图片预览组件 - store.subscribe:注册监听 store 的 mutation 变化
V3.0版本
Header组件
- 需求:显示标题;返回按钮,历史记录返回路由;页面切换动画
- Header.vue
<style scoped lang="stylus">
.header {
position: relative;
height: 44px;
line-height: 44px;
text-align: center;
background: #edf0f4;
.cubeic-back {
position: absolute;
top: 0;
left: 0;
padding: 0 15px;
color: #fc915b;
}
.extend {
position: absolute;
top: 0;
right: 0;
padding: 0 15px;
color: #fc915b;
}
}
</style>
<template>
<div class="header">
<h1>{{title}}</h1>
<i v-if="$routerHistory.canBack()" @click="back" class="cubeic-back"></i>
<div class="extend">
<slot></slot>
</div>
</div>
</template>
export default {
props: {
title: {
type: String,
default: ''
},
},
methods: {
back(){
this.$router.goBack();
}
}
}
- main.js 全局引入Header.vue
import KHeader from './components/Header.vue'
// 全局引入Header.vue组件
Vue.component('k-header', KHeader)
- 历史管理插件history.js
const History = {
_history: [], // 历史记录堆栈
install(Vue) {
// 提供Vue插件所需安装方法
Object.defineProperty(Vue.prototype, '$routerHistory', {
get() {
return History;
}
});
},
push(path){ // 入栈
this._history.push(path);
},
pop(){ // 出栈
this._history.pop();
},
canBack(){
return this._history.length>1;
}
}
export default History
- router.js 扩展
import History from './utils/history';
Vue.use(Router)
Vue.use(History)
// 实例化之前,扩展Router
Router.prototype.goBack = function(){
this.isBack = true;
this.back();
}
const router = new Router({ ... })
// 在afterEach记录历史记录
router.afterEach((to, from) => {
if(router.isBack){
// 后退
History.pop();
router.isBack = false;
router.transitionName = 'route-back';
}else{
History.push(to.path);
router.transitionName = 'route-forward';
}
});
设置加购物车动画
- 思路:
1)js动画
2)<transition @before-enter @enter @after-enter> - 在GoodsList.vue 组件中点击添加购物车时派发出事件
<i class="cubeic-add" @click.stop.prevent="addCart($event, item)"></i>
addCart($event, item) {
// 加购物车
this.$store.commit('addCart', item);
// 把点击事件派发出去
this.$emit('addCart', $event.target);
},
- 在Home.vue组件中处理
addCart
事件,以及加购物车时小球动画
<!-- 样式 -->
.ball-wrap {
.ball {
position: fixed;
left: 50%;
bottom: 10px;
z-index: 100000;
color: red;
transition: all 0.5s cubic-bezier(0.49, -0.29, 0.75, 0.41);
.inner {
width: 16px;
height: 16px;
transition: all 0.5s linear;
.cubeic-add {
font-size: 22px;
}
}
}
}
<!-- 加购动画载体 -->
<div class="ball-wrap">
<transition @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter">
<div class="ball" v-show="ball.show">
<div class="inner">
<div class="cubeic-add"></div>
</div>
</div>
</transition>
</div>
onAddCart(el){
this.ball.el = el;
this.ball.show = true; // 触发动画钩子
},
beforeEnter(el){
// 设置小球初始位置
// 小球移动到点击的位置
// 1. 获取点击的dom位置
const dom = this.ball.el;
const rect = dom.getBoundingClientRect();
console.log(rect.top, rect.left);
// 2. 把小球移动到点击的位置
const x = rect.left - window.innerWidth / 2;
const y = -(window.innerHeight - rect.top - 10 - 20);
el.style.display = 'block';
// ball 之移动y
el.style.transform = `translate3d(0, ${y}px, 0)`;
const inner = el.querySelector(".inner");
// inner只移动x
inner.style.transform = `translate3d(${x}px,0,0)`;
},
enter(el, done){
// 把小球移动到初始位置 加上动画
// 获取offsetHeight就会重绘,前面的变量名随意 主要为了eslint校验
document.body.offsetHeight;
el.style.transform = `translate3d(0, 0, 0)`;
const inner = el.querySelector('.inner');
inner.style.transform = `tanslate3d(0, 0, 0)`;
el.addEventListener('transitionend', done);
},
afterEnter(el){
// 结束 隐藏小球
this.ball.show = false;
el.style.display = 'none';
}
全局组件的调用
Notice.vue
- 组件实现:和一般的.vue组件没有什么区别
<style scoped lang="stylus">
.alert {
position: fixed;
width: 100%;
top: 30px;
left: 0;
text-align: center;
.alert-content {
display: inline-block;
padding: 8px;
background: #fff;
margin-bottom: 10px;
}
}
</style>
<template>
<div class="alert">
<div class="alert-container" v-for="item in alerts" :key="item.id">
<div class="alert-content">{{item.content}}</div>
</div>
</div>
</template>
<script>
export default {
name: 'notice',
data(){
return {
alerts: []
}
},
created(){
// id 自增控制
this.id = 0;
},
methods: {
add(options){
const id = `id_${this.id++}`;
const _alert = {...options, id};
this.alerts.push(_alert);
// 自动关闭
const duration = options.duration || 1; // 单位:秒
setTimeout(() => {
this.remove(id);
}, duration * 1000);
},
remove(id){
const index = this.alerts.findIndex(v => v.id===id);
if(index > -1){
this.alerts.splice(index, 1);
}
}
}
}
</script>
- 如何全局调用该组件
1)方案一:使用cube-ui的create-api
// 1. main.js中引入createAPI,并挂载Notice组件
import {createAPI} from 'cube-ui';
import Notice from './components/Notice.vue';
// 创建$createNotice API
createAPI(Vue, Notice, true); // 参数3(true)表示单例模式
// 2. 在组件中使用
// cube-ui方式
const notice = this.$createNotice(); // 创建Notice实例
notice.add({ content: "lalala", duration: 2 });
2)方案二:自己实现全局挂载方法
// notice.js
import Notice from '@/components/Notice.vue'
import Vue from 'vue'
// 给Notice添加一个创建组件实例的方法,可以动态编译自身模板并挂载
Notice.getInstance = props => {
// 创建一个Vue实例
const instance = new Vue({
render(h) {
// 渲染函数:用于渲染指定模板为虚拟dom
// <Notice foo="bar">
return h(Notice, { props });
}
}).$mount(); // 执行挂载,若不指定选择器,则模板将被渲染为文档之外的元素
// 必须使用原生dom api把它插入文档中
// $el指的是渲染的Notice中真实dom元素
document.body.appendChild(instance.$el);
// 获取notice实例,$children指的是当前Vue实例中包含的所有组件实例
const notice = instance.$children[0];
return notice;
}
// 设计单例模式,全局范围唯一创建一个Notice实例
let msgInstance = null;
function getInstance(){
msgInstance = msgInstance || Notice.getInstance();
return msgInstance;
}
// 暴露接口
export default {
info({duration = 2, content = ''}){
getInstance().add({
content,
duration
});
}
}
使用:
// main.js中引入notice.js,并放在Vue原型对象上
import notice from '@/services/notice'
Vue.prototype.$notice = notice;
// 组件中使用
// 自定义方式
this.$notice.info({
duration: 3,
content: '一些消息内容'
});
小球动画封装
- BallAnim
<style scoped lang="stylus">
.ball-wrap {
.ball {
position: fixed;
left: 50%;
bottom: 10px;
z-index: 100000;
color: red;
transition: all 0.5s cubic-bezier(0.49, -0.29, 0.75, 0.41);
.inner {
width: 16px;
height: 16px;
transition: all 0.5s linear;
.cubeic-add {
font-size: 22px;
}
}
}
}
</style>
<template>
<div class="ball-wrap">
<transition @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter">
<div class="ball" v-show="ball.show">
<div class="inner">
<div class="cubeic-add"></div>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: "ballAnim",
props: ["el"], // el:点击加购物车的按钮
data() {
return {
ball: {
show: false, // 显示控制
el: this.el // 目标dom引用
}
};
},
methods: {
start() {
this.ball.show = true;
},
beforeEnter(el) {
// 设置小球初始位置
// 小球移动到点击的位置
// 1. 获取点击的dom位置
const dom = this.ball.el;
const rect = dom.getBoundingClientRect();
console.log(rect.top, rect.left);
// 2. 把小球移动到点击的位置
const x = rect.left - window.innerWidth / 2;
const y = -(window.innerHeight - rect.top - 10 - 20);
el.style.display = "block";
// ball 之移动y
el.style.transform = `translate3d(0, ${y}px, 0)`;
const inner = el.querySelector(".inner");
// inner只移动x
inner.style.transform = `translate3d(${x}px,0,0)`;
},
enter(el, done) {
// 把小球移动到初始位置 加上动画
// 获取offsetHeight就会重绘,前面的变量名随意 主要为了eslint校验
document.body.offsetHeight;
el.style.transform = `translate3d(0, 0, 0)`;
const inner = el.querySelector(".inner");
inner.style.transform = `tanslate3d(0, 0, 0)`;
el.addEventListener("transitionend", done);
},
afterEnter(el) {
// 结束 隐藏小球
this.ball.show = false;
el.style.display = "none";
// 派发动画结束事件
this.$emit('transitionend');
}
}
};
</script>
动画调用之’cube-ui’——createAPI
- main.js中
import BallAnim from './components/BallAnim.vue'
createAPI(Vue, BallAnim, ['transitionend']); // 小球动画多实例时,要及时记得销毁
- Home.vue中
methods: {
onAddCart(el) {
// 创建一个小球动画实例
const anim = this.$createBallAnim({
el, onTransitionend(){
// 销毁当前实例,避免内存泄漏
anim.remove();
}
});
anim.start();
}
}
动画调用之自己实现——createAPI
- create.js
import Vue from 'vue'
// 给Notice添加一个创建组件实例的方法,可以动态编译自身模板并挂载
function create(Component, props) {
// 创建一个Vue实例
const instance = new Vue({
render(h) {
// 渲染函数:用于渲染指定模板为虚拟dom
return h(Component, { props });
}
}).$mount(); // 执行挂载,若不指定选择器,则模板将被渲染为文档之外的元素
// 必须使用原生dom api把它插入文档中
// $el指的是渲染的Notice中真实dom元素
document.body.appendChild(instance.$el);
// 获取notice实例,$children指的是当前Vue实例中包含的所有组件实例
const comp = instance.$children[0];
comp.remove = () => {
instance.$destroy(); // 销毁实例,释放内存
}
return comp;
}
// 暴露接口
export default create;
- Home.vue中
import BallAnim from '@/components/BallAnim.vue'
import create from '@/services/create'
methods: {
onAddCart(el) {
// 手动创建组件实例
const anim = create(BallAnim, {el});
anim.start();
anim.$on('transitionend', () => {
anim.remove();
});
}
}
Vuejs原理解析
为什么要懂得原理?
修炼内功
Vue工作机制
初始化
- 在 new Vue() 之后,Vue会进行初始化:初始化生命周期、事件、props、methods、data、computed和watch等。其中最重要的是通过 Object.defineProperty 设置 setter 与 getter,用来实现【响应式】以及【依赖收集】。
- 初始化之后调用 $mount 挂载组件
编译
编译模块分为三个阶段:
- parse(解析):使用正则解析 template 中的 Vue 指令(v-xxx)变量等,形成语法树 AST
- optimize(优化):标记一些静态节点,用作后面的性能优化,在 diff 的时候直接略过
- generate(生成):把第一步生成的 AST 转化为渲染函数(render)
响应式
- 这一块是Vue最核心的内容
- getter 和 setter 待会演示,初始化的时候通过 defineProperty 进行绑定,设置通知的机制
- 当编译生成的渲染函数被实际渲染的时候,会触发 getter 进行依赖收集,在数据变化的时候,触发 diff 进行更新
虚拟DOM
Virtual DOM是React首创,Vue2开始支持,就是用JavaScript对象来描述 DOM 结构,数据修改的时候,我们先修改虚拟 DOM 中的数据,然后数组做 diff,最后再汇总所有的 diff,力求做最少的 DOM 操作,毕竟 js 里比较快,而真实的 DOM 操作太慢
<div name="kkb" style="color:red" @click="xx">
<a>click me</a>
</div>
// VDOM
{
tag: 'div',
props:{
name: 'kkb',
style: {color: red},
onClick: xx
},
children: [
{
tag: 'a',
text: 'click me'
}
]
}
更新视图
数据修改触发 setter,然后监听器会通知进行修改,通过对比两个 DOM 树,得到改变的地方,就是 patch,然后只需要把这些差异修改即可
Object.defineProperty
Vue2 响应式的原理
小试牛刀
<body>
<div id="app">
<p>你好,<span id="name"></span></p>
</div>
<script>
var obj = {};
Object.defineProperty(obj, "name", {
get: function() {
return document.getElementById('name').innerHTML;
},
set: function(inner) {
document.getElementById('name').innerHTML = inner;
}
});
console.log(obj.name);
obj.name = '乔峰';
console.log(obj.name);
</script>
</body>
- 接下来,我们自定义一个KVue类来进行模拟
class KVue {
constructor(options){
this.$data = options.data;
// 执行响应式
this.observe(this.$data);
}
observe(obj){
if(!obj || typeof obj !== 'object'){
return;
}
// 遍历data选项
Object.keys(obj).forEach(key => {
// 为每一个 key 定义响应式
this.defineReactive(obj, key, obj[key]);
});
}
defineReactive(obj, key, val){
// 递归查找嵌套属性
this.observe(val);
// 为data对象定义属性
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true, // 可修改或删除
get(){
return val;
},
set(newVal){
if(newVal == val){return;}
console.log('数据发生变化啦');
}
});
}
}
let kvue = new KVue({
data:{
test: 'I am test'
}
});
kvue.$data.test = 'Hello, KVue!!!';
依赖收集与追踪
class Dep{
constructor(){
// 存储所有的依赖
this.deps = [];
}
// 在deps中添加一个监听器对象
addDep(dep){
this.deps.push(dep);
}
// 通知所有监听器去更新视图
notify(){
this.deps.forEach((dep) => {
dep.update();
});
}
}
class Watcher{
constructor(){
// 在new一个监听器对象时将该对象赋值给Dep.target,在get中会用到
Dep.target = this;
}
// 更新视图的方法
update(){
console.log('视图更新了');
}
}
- 我们增加一个Dep类的对象,用来收集 Watcher 对象,读取数据的时候,会触发reacticeGetter函数把当前的 Watcher对象(存放在Dep.target中)收集到Dep类中。
- 写数据的时候,则会通知reactiveSetter方法,通知Dep类调用 notify 来触发Watcher对象的update方法更新对应的视图。
class KVue {
constructor(options){
// 保存 data 选项
this.$data = options.data;
// 执行响应式
this.observe(this.$data);
// Test
new Watcher();
console.log('模拟compile', this.$data.test);
}
observe(obj){
if(!obj || typeof obj !== 'object'){
return;
}
// 遍历data选项
Object.keys(obj).forEach(key => {
// 为每一个 key 定义响应式
this.defineReactive(obj, key, obj[key]);
});
}
defineReactive(obj, key, val){
// 递归查找嵌套属性
this.observe(val);
// 创建Dep
const dep = new Dep();
// 为data对象定义属性
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true, // 可修改或删除
get(){
// 将Dep.target(即当前的Watcher对象)存入Dep的deps中
Dep.target && dep.addDep(Dep.target);
console.log(dep.deps)
return val;
},
set(newVal){
if(newVal == val){return;}
val = newVal;
// 在set的时候触发dep的notify来通知所有的Watcher对象更新视图
dep.notify();
}
});
}
}
检查点
首先 observer 进行依赖收集,把 Watcher 放在 Dep 中,数据变化的时候调用 Dep 的 notify 方法通知 watcher 进行视图更新
编译compile
核心逻辑:获取DOM,遍历DOM,获取{{}}格式的变量,以及每个DOM的属性,截获k-和@开头的,设置响应式
自定义KVue 1.0版本
compile.js
// 扫描模板中所有依赖,创建更新函数和Watcher
class Compile {
// el: 宿主元素或其他选择器
// vm: 当前Vue的实例
constructor(el, vm) {
this.$vm = vm;
// 默认是选择器
this.$el = document.querySelector(el);
if (this.$el) {
// 将 DOM 节点转换为Fragmet,提高执行效率
this.$fragment = this.node2Fragment(this.$el);
// 执行编译
this.compile(this.$fragment);
// 将生成的结果追加之宿主元素
this.$el.appendChild(this.$fragment);
}
}
node2Fragment(el) {
// 创建一个新的Fragment
const fragment = document.createDocumentFragment();
let child;
// 将原生节点拷贝至fragment
while ((child = el.firstChild)) {
// fragment是移动操作(剪切复制)
fragment.appendChild(child);
}
return fragment;
}
// 编译指定片段
compile(el) {
let childNodes = el.childNodes;
Array.from(childNodes).forEach(node => {
// 判断node类型,做响应处理
if (this.isElementNode(node)) {
// 元素节点要识别 k-xx 或 @xx
this.compileElement(node);
} else if (
this.isTextNode(node) &&
/\{\{(.*)\}\}/.test(node.textContent)
) {
// 文本节点,只关心{{xx}}格式
this.compileText(node, RegExp.$1); // RegExp.$1:匹配的内容
}
// 遍历可能存在的子节点
if (node.childNodes && node.childNodes.length) {
this.compile(node); // 递归
}
});
}
// 编译元素节点
compileElement(node) {
console.log("编译元素节点");
// 例如:<div k-text="test" @click="onClick">
const attrs = node.attributes;
Array.from(attrs).forEach(attr => {
// 规定指令 k-xx,例如k-text="test"
const attrName = attr.name; // 属性名:k-text
const exp = attr.value; // 属性值:test
if (this.isDirective(attrName)) { // 指令
const dir = attrName.substr(2);
this[dir] && this[dir](node, this.$vm, exp);
} else if(this.isEventDirective(attrName)) { // 事件
// 例如@click="onClick"
const dir = attrName.substr(1); // click
this.eventHandler(node, this.$vm, exp, dir);
}
});
}
// 编译文本节点
compileText(node, exp) {
console.log("编译文本节点");
this.text(node, this.$vm, exp);
}
// 处理文本
text(node, vm, exp){
this.update(node, vm, exp, 'text');
}
// 处理html
html(node, vm, exp){
this.update(node, vm, exp, 'html');
}
// 处理双向绑定
model(node, vm, exp){
this.update(node, vm, exp, 'model');
let val = vm.exp;
// 双向绑定还要处理视图对模型的更新
node.addEventListener('input', e => {
vm[exp] = e.target.value;
val = e.target.value;
});
}
// 更新函数
update(node, vm, exp, type){
let updateFn = this[type+'Updater'];
// 一开始立即执行
updateFn && updateFn(node, vm[exp]); // 执行更新,get
// 发生改变后再执行
new Watcher(vm, exp, (value) => {
updateFn && updateFn(node, value); // 执行更新
});
}
// 文本更新器
textUpdater(node, value){
node.textContent = value;
}
// html更新器
htmlUpdater(node, value){
node.innerHTML = value;
}
// model更新器
modelUpdater(node, value){
node.value = value;
}
// 事件处理器
eventHandler(node, vm, exp, dir){
let fn = vm.$options.methods && vm.$options.methods[exp];
if(dir && fn){
node.addEventListener(dir, fn.bind(vm), false);
}
}
isElementNode(node) {
return node.nodeType == 1; // 元素节点
}
isTextNode(node) {
return node.nodeType == 3; // 文本节点
}
isDirective(name){
return name.indexOf('k-') == 0;
}
isEventDirective(name){
return name.indexOf('@') == 0;
}
}
入口文件KVue
class KVue {
constructor(options){
// 保存 options
this.$options = options;
// 保存 data 选项
this.$data = options.data;
// 执行响应式
this.observe(this.$data);
// // Test
// new Watcher();
// console.log('模拟compile', this.$data.test);
this.$compile = new Compile(options.el, this);
}
observe(obj){
if(!obj || typeof obj !== 'object'){
return;
}
// 遍历data选项
Object.keys(obj).forEach(key => {
// 为每一个 key 定义响应式
this.defineReactive(obj, key, obj[key]);
// 为Vue的data做属性代理
this.proxyData(key);
});
}
defineReactive(obj, key, val){
// 递归查找嵌套属性
this.observe(val);
// 创建Dep
const dep = new Dep();
// 为data对象定义属性
Object.defineProperty(obj, key, {
enumerable: true, // 可枚举
configurable: true, // 可修改或删除
get(){
Dep.target && dep.addDep(Dep.target);
console.log(dep.deps)
return val;
},
set(newVal){
if(newVal == val){
return;
}
val = newVal;
// console.log('数据发生变化啦');
// 在set的时候触发dep的notify来通知所有的Watcher对象更新视图
dep.notify();
}
});
}
// 代理 data 选项中的数据
proxyData(key){
Object.defineProperty(this, key, {
get() {
return this.$data[key];
},
set(newVal){
this.$data[key] = newVal;
}
});
}
}
依赖收集Dep
// 依赖管理器:负责将视图中所有依赖收集管理,包括依赖添加和通知
class Dep{
constructor(){
// deps里面存放的是Watcher的实例
this.deps = [];
}
// 添加依赖
addDep(dep){
this.deps.push(dep);
}
// 通知所有 Watcher 执行更新
notify(){
this.deps.forEach(dep => {
dep.update();
});
}
}
监听器
// Watcher:具体的更新执行者
class Watcher{
constructor(vm, key, cb){
this.vm = vm;
this.key = key;
this.cb = cb;
// 将来new一个监听器时,将当前Watcher的实例附加到Dep.target
Dep.target = this;
// 读一下,触发get
this.vm[this.key];
Dep.target = null; // 避免重复添加
}
// 更新
update(){
console.log('视图更新啦');
this.cb.call(this.vm, this.vm[this.key]);
}
}
测试
<body>
<div id="app">
{{test}}
<p k-text="test"></p>
<p k-html="html"></p>
<p >
<input type="text" k-model="test">
</p>
<p>
<button @click="onClick">按钮</button>
</p>
</div>
<script src="./KVue.js"></script>
<script src="./compile.js"></script>
<script>
const kvue = new KVue({
el: '#app',
data: {
test: 'balabala',
html: '<a href="http://www.baidu.com">百度</a>',
foo: { bar: 'bar' }
},
methods: {
onClick(){
alert('onClick')
}
}
});
</script>
</body>
vue3.0展望
- 重写虚拟DOM
- 静态树提升
- 使用Proxy观察者机制取代Object.defineProperty
- 体积更小,压缩后大概10KB
- 可维护性,很多包解耦
- 全面支持TS
- 实验性知的Time Slicing和hooks支持