Web全栈架构师(一)——Vue2.5学习笔记(2)

电商项目实战

创建项目 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支持
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

讲文明的喜羊羊拒绝pua

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值