给项目组实习生做技术培训的教案 -(Vue、Gitlab、Vue3、demo)

628 阅读11分钟

第三阶段培训

10月上旬的时候,对公司新进的一批实习生组织了培训。这是其中的一个阶段的教案内容。

内容:

  • 项目搭建
    • 项目创建
    • 项目配置
  • 提升开发质量和开发幸福度的 vue 功能
    • directives
    • transition (实现简单的列表过渡效果)
    • plugin (实现一个表单校验的 plugin)
  • git
    • 工作流
      • git flow
      • github flow
      • gitlab flow
  • vue 原理
    • 响应式
    • 虚拟 dom
  • vue 3
    • vite
    • composition API
  • vue 生态周边推荐
    • ui 框架
    • css reset
    • nuxt
  • demo 上手
    • real world
    • 目标
      • 搭建 vue csr 项目
      • 配置项目、router、axios 等
      • 使用合适的 git 工作流管理项目
      • 完成项目功能(页面、交互、接口对接、自测)

1. 项目搭建

1.1. 创建

vue create a-new-demo

Please pick a preset,选择Manually select features

? Check the features needed for your project:
>(*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support
 (*) Router
 (*) Vuex
 (*) CSS Pre-processors
 (*) Linter / Formatter
 (*) Unit Testing # 单元测试,目前用到的不多,这里做一个了解
 ( ) E2E Testing
👇
? Check the features needed for your project: Babel, Router, Vuex, CSS Pre-processors, Linter, Unit
👇
? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) y
👇
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default):
  Sass/SCSS (with dart-sass)
> Sass/SCSS (with node-sass) # dart在web的某些场景下性能不如node,具体可以搜搜
  Less
  Stylus
👇
? Pick a linter / formatter config:
  ESLint with error prevention only
  ESLint + Airbnb config
  ESLint + Standard config
> ESLint + Prettier # eslint结合prettier,规范参考大礼包里面的文档
👇
? Pick additional lint features:
>(*) Lint on save
 (*) Lint and fix on commit # 代码符合规范才给提交
👇
? Pick a unit testing solution:
  Mocha + Chai
> Jest # 使用jest作为单元测试工具
👇
? Where do you prefer placing config for Babel, ESLint, etc.? (Use arrow keys)
> In dedicated config files
  In package.json

进入项目之后,使用vue add安装插件

vue add element
👇
? How do you want to import Element?
  Fully import
> Import on demand # 把全局引入抠掉
👇
vue add axios
👇
vue add lodash # 先装上吧,好用

增加.editorconfig 文件

root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

增加.prettierrc 文件

{
  "semi": false, // 语句结尾不写 ;
  "singleQuote": true, // 使用 '' 不使用 ""
  "jsxBracketSameLine": true
}

增加 eslint 规则

rules: {
  "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
  "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
  indent: ['error', 2], // 缩进:2个空格
  eqeqeq: 'warn', // 相等运算符:强制使用===
  'space-before-function-paren': ['error', 'never'], // 函数名与()之间的空格:不允许
  'no-mixed-spaces-and-tabs': 'error', // 对象文字的大括号内间距:强制在{}两侧留空格
  'object-curly-spacing': ['error', 'always'], // 生产环境中不允许console语句和debugger语句
  camelcase: 'warn'
},

针对初始化项目时出现的样式问题,使用npm run lint解决

最后: npm install

思考:如何解决给多个项目进行重复配置的机械劳动问题?

设置脚本,自动化为项目进行相关配置(batbashnode

  • editorconfig
  • prettierrc
  • eslint
  • babel

1.2. 配置

项目结构

src
├─api # api
├─assets # 静态资源 css/image/font
├─components # 组件
├─libs # 公共方法
├─plugins # 插件
├─router # 路由
| └─routes # 路由组件
├─store # 状态
| └─modules # 模块
└─views # 视图 (与路由结构保持一致)

1.2.1. router

1.2.1.1. 导航守卫
router.beforeEach((to, from, next) => {
  if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' })
  else next()
})
  1. 未登录状态和登录状态的路由权限
  2. 不同账号的路由权限
  3. 配置页面跳转时的 loading bar

不同账号的路由权限的配置可以采取两种方法

  1. 使用router.addRoutes(routes: Array<RouteConfig>),将后端回传的权限信息添加到路由配置种
  2. 使用router.beforeEach中的to,对比权限信息中是否有该路由的权限

1.2.2. vuex

  1. 统一使用 action 触发 axios 请求获取后端数据 (特殊情况除外,如文件下载 api 等特殊 api)

1.2.3. axios

1.2.3.1. 请求拦截器
1. 阻止请求重复发送
    1. 当请求发起时,在一个array中缓存这个请求
    2. 如果请求完成,清除该缓存
    3. 当一个新请求发起时,如果缓存中有相同的请求,则阻止这个新请求
const _axios = axios.create(config)
let reqList = [] // * 存放正在发送的请求
// 请求
_axios.interceptors.request.use(
  function (config) {
    // * 阻止请求重复发送
    let _cancel
    config.cancelToken = new axios.CancelToken(function (cancel) {
      _cancel = cancel
    })
    if (reqList.indexOf(config.url) === -1) {
      reqList.push(config.url)
    } else {
      _cancel('opps...')
    }
    return config
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error)
  }
)
// 响应
_axios.interceptors.response.use(
  function (response) {
    // Do something with response data
    reqList.splice(reqList.indexOf(config.url), 1)
    return response
  },
  function (error) {
    // Do something with response error
    return Promise.reject(error)
  }
)
1.2.3.2. 响应拦截器
1. 状态码处理
_axios.interceptors.response.use(
  function (response) {
    // Do something with response data
    return response
  },
  function (error) {
    // Do something with response error
    try {
      if (error.response.status === 400) {
        Message.error('请求参数错误')
      }
      if (error.response.status === 401) {
        Message.error('登录超时/未登录')
        router.push({ name: 'Login' })
      }
      if (error.response.status === 403) {
        Message.error('请求被远程服务禁止')
      }
      if (error.response.status === 405) {
        Message.error('请求方法不能获取指定资源')
      }
      if (error.response.status === (422 || 421)) {
        Message.error('当前请求链接超负荷,请稍后再试')
      }
      if (error.response.status === 500) {
        Message.error('服务器端错误')
      }
    } catch (err) {
      Message.error(err.toString())
    }
    return Promise.reject(error)
  }
)

2. 提升开发质量和开发幸福度的 vue 功能

2.1. directive

作用:多用于组件

  • 可以拿到 DOM 元素,直接对 DOM 进行操作
  • 传递参数

基础用法

directives: {
  yourDir: {
    bind: function (el) {
      // 只调用一次,一般用于初始化
    },
    inserted: function (el) {
      // 被绑定元素插入父节点中,一般来讲这个钩子下元素已被渲染
    },
    update: function (el) {
      // 触发更新时调用,能发生在其子 VNode 更新之前
    },
    componentUpdated: function (el) {
      // 所在组件的 VNode 及其子 VNode 全部更新后调用
    },
    unbind: function (el) {
      // 解绑时调用
    }
  }
}

钩子函数的参数

  • el DOM 元素
  • binding 包含指令信息
  • vnode 当前 virtual DOM 节点
  • oldVnode 更新前的 virtual DOM 节点

binding 内部

  • name:指令名,不包括 v- 前缀。
  • value:指令的绑定值,例如:v-my-directive="1 + 1" 中,绑定值为 2。
  • oldValue:指令绑定的前一个值,仅在 update 和 componentUpdated 钩子中可用。无论值是否改变都可用。
  • expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1"。
  • arg:传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo"。
  • modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }。

VNode 的内容可以参考snabbdom

2.2. provide 和 inject

在父组件中使用provide配置项为后代组件提供data和method,在子组件中使用inject注入依赖

不过一般来讲,要谨慎使用这种依赖注入的方式,这会使得父子组件的耦合加深,增加维护成本。

// 父组件
export default {
  name: 'parentComp',
  data() {
    return {
      age: 18
    }
  },
  methods: {
    getAge() {
      console.log(this.age)
    }
  },
  provide() {
    return {
      age: this.age,
      getAge: this.getAge
    }
  }
}
// 子组件
export default {
  name: 'childComp',
  inject: ['getAge']
}

2.3. 渲染函数

template 模板在构建前会编译为 render 函数,因此,render 函数与编译后的 vue 代码关系更近,帮助我们更加深入的了解 vue 的视图渲染机制。vue 2 的虚拟 DOM 来源于snabbdom

  • 一般来讲优先使用template,这样利于视图代码与逻辑代码的解耦和维护
  • 但是某些情况,使用render函数会更加的简洁优雅
  • 比如现在有一个需求,页面渲染<h1><h6>标签
  • 需求发生了一些变化👉每级h标签里面的数字加1/只渲染奇数层级/奇偶层级内部元素不同
<h1>1</h1>
<h2>2</h2>
<h3>3</h3>
<h4>4</h4>
<h5>5</h5>
<h6>6</h6>
new Vue({
  render(h) {
    return h(
      'div',
      [1, 2, 3, 4, 5, 6].map(function (item) {
        return h(`h${item}`, item)
      })
    )
  },
}).$mount('#app')

2.3.1. 用法

2.3.2. 案例

iview Message 组件 iview

2.4. keep-alive

用于保留组件状态或避免重新渲染

  • 在多标签页场景下较为实用
  • include 包括xx组件
  • exclude 排除xx组件
  • max 最大缓存组件数量
<keep-alive include="a,b" :exclude="/c|d/" :max="10">
  <router-view></router-view>
</keep-alive>

2.5. vue 过渡、动画

在vue3小demo中简单说明

2.6 跨组件通信

一个使用发布订阅者模式的简单尝试

// 组件 pubsub.js
import Vue from 'vue'

const vm = new Vue()

// 发布
export function pub(eventName) {
  vm.$emit(eventName, ...Array.from(arguments).slice(1))
}
// 订阅
export function sub(eventName, callback) {
  vm.$on(eventName, callback)
}
// 订阅 once
export function subOnce(eventName, callback) {
  vm.$once(eventName, callback)
}
// 发布的组件
import { pub } from 'pubsub'

export default {
  methods: {
    foo() {
      pub('someEvent', 1, 2, 3)
    }
  }
}
// 订阅的组件
import { sub, subOnce } from 'pubsub'

export default {
  methods: {
    bar() {
      // ...
    }
  },
  created() {
    sub('someEvent', bar)
  }
}

2.7 v-slot

  • 2.6版本新增新增指令,替代了slotslot-scopescope
<base-layout>
  <template v-slot:header>
    具名插槽 header
  </template>

  default slot的内容

  <template v-slot:footer="slotProps">
    具名插槽 footer
    接收参数 slotProps
    {{ slotProps.name }}
    {{ slotProps.age }}
  </template>
</base-layout>

3. 基本原理

3.1. 响应式

  • vue 2 👉 defineProperty
  • vue 3 👉 proxy
const vm = {}
const data = {
  name: '',
  age: 0
}
// vue 2
function reactivity2() {
  Object.keys(data).forEach(key => {
    Object.defineProperty(vm, key, {
      // 可枚举
      enumerable: true,
      // 可配置 (使用delete删除, 通过defineProperty重新定义)
      configurable: true,
      get () {
        console.log('get: ', data[key])
        return data[key]
      },
      set (newValue) {
        console.log('set: ', newValue)
        if (newValue === data[key]) {
          return
        }
        data[key] = newValue
      }
    })
  })
}
// vue 3
function reactivity3(data) {
  vm = new Proxy(data, {
    get (target, key) {
      console.log('get: ', key, target[key])
      return target[key]
    },
    set (target, key, newValue) {
      console.log('set: ', key, newValue)
      if (target[key] === newValue) {
        return
      }
      target[key] = newValue
      return target[key]
    }
  })
}

3.2. vitural dom

4. 插件

4.1. 方法

官方文档

作用

  • 添加全局方法或者 property。如:vue-custom-element
  • 添加全局资源:指令/过滤器/过渡等。如 vue-touch
  • 通过全局混入来添加一些组件选项。如 vue-router
  • 添加 Vue 实例方法,通过把它们添加到 Vue.prototype 上实现。
  • 一个库,提供自己的 API,同时提供上面提到的一个或多个功能。如 vue-router

注册插件

在Vue实例初始化之前调用

// 调用 `MyPlugin.install(Vue)`
Vue.use(MyPlugin, options)

new Vue({
  // ...组件选项
})
// ✨ install 方法
MyPlugin.install = function (Vue, options) {
  // 1. 添加全局方法或 property
  Vue.myGlobalMethod = function () {
    // 逻辑...
  }

  // 2. 添加全局资源
  Vue.directive('my-directive', {
    bind (el, binding, vnode, oldVnode) {
      // 逻辑...
    }
    ...
  })

  // 3. 注入组件选项
  Vue.mixin({
    created: function () {
      // 逻辑...
    }
    ...
  })

  // 4. 添加实例方法
  Vue.prototype.$myMethod = function (methodOptions) {
    // 逻辑...
  }
}

4.2. 实现一个简易的表单校验插件

4.2.1. 思路

// vue 组件
export default {
  data: {
    username: '',
    password: ''
  },
  // 👇在这里添加校验规则
  rules: {
    username: {
      validate: value => {
        /^[A-z]{6, 10}$/.test(value)
      },
      message: '请填写用户名',
      require: true,
      trigger: 'blur'
    },
    password: {
      validate: value => {
        /^[A-z0-9\s]{8, 20}$/.test(value)
      },
      message: '请填写用户名'
      require: true,
      trigger: 'input'
    }
  }
}

4.2.2. 实现 (一个不太好的实现方法)

// validate.js
import Vue from 'vue'

const RulesPlugin = {
  install (Vue) {
    Vue.mixin({
      created () {
        if (this.$options.hasOwnProperty('rules')) {
          const rules = this.$options.rules
          Object.keys(rules).forEach(key => {
            const rule = rules[key]
            this.$watch(key, newValue => {
              const result = rule.validate(newValue)
              if (!result) {
                console.log(rule.message)
              }
            })
          })
        }
      }
    })
  }
}

Vue.use(RulesPlugin)

5. Vue 3

vite

v3文档

Vite is an opinionated web dev build tool that serves your code via native ES Module imports during dev and bundles it with Rollup for production.

  • Lightning-fast cold server start
  • Instant hot module replacement (HMR)
  • True on-demand compilation
  • More details in How and Why

5.1. 搭建项目

# 初始化项目
npm init vite-app vue3-composition-api-demo
# 进入目录
cd vue3-composition-api-demo
# 安装依赖
npm install
# 运行项目
npm run dev

引入第三方库

npm install lodash
yarn add lodash

目前社区中大部分模块都没有设置默认导出 esm,基本上还都是 cjs。所以直接引入lodash会报错

// vite.config.js
module.exports = {
  optimizeDeps: {
    include: ["lodash"]
  }
}

// 引入lodash
import lodash from 'lodash'
// 下面的引入方式会报错
import { random, shuffle } from 'lodash'
import random from 'lodash/random'
import shuffle from 'lodash/shuffle'

5.2. 尝试Composition API

Composition API在Vue 3中开箱即用,如果想在Vue 2中使用Composition API,可以在项目中添加@vue/composition-api插件

v3文档

  • demo使用了两个data变量capacity和attending,一个computed属性spacesLeft和几个相关的函数。
<template>
  <div>
    <p>Spaces left: {{ spacesLeft }} out of {{ capacity }}</p>
    <h2>Attending</h2>
    <div>
      <button @click="increaseCapacity">Increase Capacity</button>
    </div>
    <div>
      <button @click="addAttending">addAttending</button>
    </div>
    <div>
      <button @click="sort">sort</button>
    </div>
    <!-- 使用vue的过渡动画组件演示 -->
    <transition-group name="flip-list" tag="ul" :style="{width: '200px', margin: '20px auto'}">
      <li v-for="item in attending" :key="`Attending${item}`">
        {{ item }}
      </li>
    </transition-group>
  </div>
</template>

写法一

// 引入ref和computed
import { ref, computed } from "vue"
export default {
  setup() {
    const capacity = ref(4)
    const attending = ref(["Tim", "Bob", "Joe"])
    // computed计算属性的声明方式
    const spacesLeft = computed(() => {
      // 注意,通过ref声明的响应式数据需要通过.value拿到该值
      return capacity.value - attending.value.length
    })
    // 代替methods声明方法
    function increaseCapacity () {
      capacity.value++
    }
    function sort () {
      attending.value = lodash.shuffle(attending.value)
    }
    function addAttending () {
      attending.value.push(randomName(lodash.random(3, 7)))
    }
    // 通过一个对象暴露以上变量和方法
    return {
      capacity,
      attending,
      spacesLeft,
      increaseCapacity
    }
  }
}

其中,ref()接收一个value值作为入参,基本类型的入参将被包裹在一个object中(如capacity),通过proxy实现响应式(这很Vue 3)。computed计算属性,是通过computed()方法声明的(全部替换成了函数式的声明),注意computed中,我们需要调用capacity.valueattending.value来获取数据的值。最后,所有在template中使用的变量和方法通过return暴露出去。

写法二

import { reactive, computed, toRefs } from "vue"
export default {
// 将需要使用的变量添加到event的属性中
  setup(props, context) {
    const event = reactive({
      capacity: 4,
      attending: ['Tim', 'Bob', 'Joe'],
      spacesLeft: computed(() => { return event.capacity - event.attending.length })
    })
    // 方法的声明方法不变
    function increaseCapacity () {
      event.capacity++
    }
    function sort () {
      event.attending = lodash.shuffle(event.attending)
    }
    function addAttending () {
      event.attending.push(randomName(lodash.random(3, 7)))
    }
    // toRefs()
    return {...toRefs(event), increaseCapacity, addAttending, sort}
  }
}

改变的地方是toRefs()toRefs()方法接受一个object作为入参,返回一个响应式的object。在setup()函数体内直接通过event属性拿到相关变量值,不需要额外加上.value。两种写法在使用场景上略有区别。我个人更喜欢第二种写法,return更加简洁优雅👍。

5.2.1. setup()

setup(props, context) {
  // Attributes (Non-reactive object)
  console.log(context.attrs)

  // Slots (Non-reactive object)
  console.log(context.slots)

  // Emit Events (Method)
  console.log(context.emit)
}

setup()方法接受2个参数,第一个参数props,顾名思义就是Vue的propssetup()执行时还没有vue实例,所以在setup()函数体内拿不到datacomputedmethods,所以需要借助第二个参数context拿到vue实例相关的数据,其中包含attrsslotsemit

setup生命周期hooks

对比打印一下新旧两版生命周期hooks:

import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onUnmounted
} from "vue"

function printLifeCycle () {
  onBeforeMount(() => {console.log('onBeforeMount')})
  onBeforeUpdate(() => {console.log('onBeforeUpdate')})
  onMounted(() => {console.log('onMounted')})
  onUpdated(() => {console.log('onUpdated')})
  onUnmounted(() => {console.log('onUnmounted')})
}

export default {
  // setup内部生命周期hooks
  setup(props, context) {
    printLifeCycle()
  },
  // 老生命周期hooks
  beforeCreate() {
    console.log('beforeCreate')
  },
  created() {
    console.log('created')
  },
  beforeMount() {
    console.log('beforeMount')
  },
  mounted() {
    console.log('mounted')
  },
  beforeUpdate() {
    console.log('beforeUpdate')
  },
  updated() {
    console.log('updated')
  }
}

setup()内部的hooks执行早于vue实例上的老hooks

5.3. vue过渡

官方文档

  • Vue 2

过渡动画状态 vue 2

  • Vue 3

过渡动画状态 vue 3

注意!vue 3使用v-enter-from 而不是 v-enter

.flip-list-move {
  transition: transform 1s;
}
.flip-list-enter-active, .flip-list-leave-active {
  transition: all 1s;
}
.flip-list-enter-from, .flip-list-leave-to
/* .list-leave-active for below version 2.1.8 */ {
  opacity: 0;
  transform: translateY(30px);
}

6. Git

阮一峰的git flow等介绍

饿了么的github flow

推荐实践方法

  1. 在gitlab中,项目组group中,创建项目主仓库
    • 主仓库有masterdevelop两个分支
    • 假设主仓库地址为git@github.com:group/test-project.git
  2. 在gitlab中将主仓库fork到个人仓库
    • Fork 出来的仓库完全属于你自己,你可以任意修改该仓库的代码及配置,但是除非你向项目主仓库提交 pull request,并且被接受通过,你才可以将你fork 仓库修改的代码合并到主仓库,否则不会对主仓库产生任何影响。
    • 此时,个人的仓库地址为git@github.com:person/test-project.git
  3. clone个人仓库到本地,并且在remote配置中upstream
    • upstream指向远程主仓库地址
    • origin指向远程个人仓库地址
    git remote add upstream git@github.com:group/test-project.git
    # 执行 git remote -v 可以看到remote中有origin和upstream
    git remote -v
    
  4. 保持个人仓库和upstream同步
    • masterdevelop都要同步
    # 同步upstream到本地
    git fetch upstream
    git checkout master
    git merge upstream/master
    # 本地与远程个人仓库同步
    git pull origin master
    
  5. 开发
    • 开发新功能 develop上新建feature/new-feature 临时 分支
    • bug修复 master上新建hotfix/new-bug 临时 分支
    # 新功能
    git checkout -b feature/new-feature develop
    # 新bug
    git checkout -b hotfix/new-bug master
    
  6. 提交代码到个人远程仓库
    • 开发完成后,提交代码前,检查本地feature分支是否落后于upstreamdevelop分支(在你提交代码前,可能有其他的PR合并到了upstreamdevelop分支)
    git checkout develop
    git pull upstream develop
    # 检查
    git log feature/new-feature..develop
    # 假设控制台有输出(如下),说明本地feature分支落后于upstream的develop分支
    commit 58fae9f9355a820da713e830b0b4c8100bcb6dab (upstream/develop, develop)
    Author: overlyDramatic <maxjunedown@live.com>
    Date:   Sun Sep 27 15:13:42 2020 +0800
    
        code2
    
    • 如果落后了,则进行以下操作
    git checkout feature/new-feature
    # 第一种使用rebase 更方便但是更加危险❗
    git rebase develop
    # 第二种使用merge 更多的操作空间(--no-ff指禁止fast forward)
    git merge develop --no-ff --edit
    
    • 之后就可以提交到远程个人仓库分支啦💪
    git push origin HEAD
    # 或者
    git push origin feature/new-feature
    
  7. 发起 pull request 或 merge request
    • github 👉 pull request
    • gitlab 👉 merge request
    1. gitlab.hedongli.com/个人仓库页面的左边菜单点击merge request (👋记住是个人仓库)
    2. 点击绿色按钮new merge request
    3. 左边的Source branch选择feature/new-feature分支
    4. 右边的Target branch选择仓库的develop分支 (👋记住是仓库)
    5. 之后点击assignee选择你的名字
    6. 点击发布你的PR
  8. 其他人查看你的PR
    1. 在当前PR页面点击check out branck按钮
    2. 按照步骤在本地检出该PR
    3. 如果需要修改,则执行2,3,4步(本人修改则在他本地进行)
    4. 如果测试通过,就点击merge按钮或者执行上面的两步