vue3 从入门到实战(中)

1,367 阅读15分钟

前言

本篇是vue3从入门到实战中篇,主要讲一些vue3中的简单原理,如果你还未接触过vue3,可以观看我的vue3从入门到实战上篇:juejin.cn/post/686968…

由于笔者只是学习了前端11个月的小白,对vue3很多原理并不了解,如dom-diff,虚拟dom,模板编译等,这些知识笔者大多只是知其然不知其所以然,本篇主要写一些vue3的简单原理,如computed,reactive,watchEffect,vuex等,然后稍微简单分析一下vite。笔者能力有限,也许写的不会太好,不过本文会尽可能的讲的详细,会将代码上传自github,喜欢的小伙伴可以去github上自取,然后在本地进行调试和学习。

vuex-ts 超简易源码 github.com/1131446340a…

vue3-响应式 超简易源码 希望各位读者大大赏一个小赞。 github.com/1131446340a…

公用方法和接口简介

这些过于简单,只是为了让大家在之后的阅读能了解数据类型,因此这些只写一下代码而不做过多解释 #、# util.ts 写了两个函数判断是否是对象和函数

export const isObject = (target:any) => !!(target && typeof target === 'object')
export const isFunction =(target:any)=>(typeof target ==='function')

type/index.ts 文件定义了一些类型

import { DefineProperty } from './../interface';
export type strNumSym = string | number | symbol
export type isFunOrObject = Function | undefined | null
export type effectTypeGet = 'get'
export type effectTypeSet = 'set' | 'add'
export type computedOptiobs = DefineProperty | Function
export type _Function = <T extends object>() =>T

interface.ts

import { DefineProperty } from './../interface';
export type strNumSym = string | number | symbol
export type isFunOrObject = Function | undefined | null
export type effectTypeGet = 'get'
export type effectTypeSet = 'set' | 'add'
export type computedOptiobs = DefineProperty | Function
export type _Function = <T extends object>() =>T

reactive简单原理

reactive.ts文件

说明:要求了解es6 Proxy,Reflect

import { isObject } from "./util"
import { handle } from './basehandles'

export const reactive = <T extends object>(target: T) => {
  if (isObject(target)) {
    return new Proxy(target, handle)
  }
  return target
}

这段代码超级简单,只是定义了个泛型函数,其作用就是如过参数是对象,则对其使用Proxy代理,否则直接返回。

大家可以看到,如果是对象,使用了handle对其进行处理,handle是在basehandles.ts中引入的,先让我们来看一下它的代码

import { strNumSym } from './types/index';
import { reactive } from './reactive'
import { isObject } from './util';
function get<T extends object>(target: T, key: strNumSym, receiver: T) {
  let res = Reflect.get(target, key, receiver)
  return isObject(target[key]) ? reactive(target[key]) : res;
}

function set<T>(target: any, key: strNumSym, value: T, receiver: object) {
  let hasKey = Object.prototype.hasOwnProperty.call(target, key)
  let oldval = target[key]
  let res = Reflect.set(target, key, value, receiver)
  if (!hasKey) {
    // do something...
  }
  else if (value !== oldval) {
   //  do something....
  }
  return res
}

export const handle = {
  get, set
}

没错,为了简单,我这里都handle只是一个对象,只有get参数和set参数。 get函数相对简单,如果进行取值操作,触发get函数,如果target[key] 是对象则进行深度代理,否则直接返回target[key]。

set函数也比较简单,如果进行了改值操作,则触发set函数。将target[key]改为 新值。

不过改值有添加属性和修改属性值两种,对这两种分别做一个判断,做一些其他操作即可。

值得注意对是,对于数组而言,如果push不但会增加一个属性还会修改数组的length属性。对于这两个分别做了什么操作稍后再分析。

我们先简单分析一下代码,就会发现只有在读值的时候才会进行递归操作使数据变成响应式,而不是一上来就深度递归使所有数据进行响应式。

Effcet简单原理。

Effect 有点长,因此打算分三部分写完

首先是第一部分

import { strNumSym, effectTypeSet, effectTypeGet } from './types';
import { Effect, EffectOptions }from './interface'

export const effect = (fn: Function, options:EffectOptions= {
  lazy: false
}) => {
  let effect = createEffect(fn,options)
  if (!options.lazy) {
      effect()
  }
  return effect
}

先看effect函数,接受两个参数,第一个是回调函数,第二个是options函数,关于options有那些类型可以去看一EffectOptions接口。目前只传了一个lazy属性。 如果大家知道watchEffect方法的话,就会知道watchEffect中的回调函数在项目中启动的时候就会立即执行一次。因此,如果options.lazy为false的话,则调用一下回调函数,但是我们还要做一些其他的操作,因此我们来看一下 createEffect 函数

let uid = 0
let activeEffect: Effect
let effectStack: Effect[] = []
function createEffect(fn: Function, options:EffectOptions = {}) {
  let effect: Effect = function effectReactive() { 
    if (!effectStack.includes(effect)) {
      try {
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
      }
 }
  }
  effect.id = uid++
  effect.options = options
  effect.deps=[]
  return effect
}

这段代码uid是一个id标志,activeEffect见名知意,暂时还没有用到,主要是为了将来进行依赖收集,我们暂时不用去管它。最核心的代码就是创建了一个effectStack队列,其主要是为了确认当前活跃Effect。然后执行我们传进入的回调函数,然后删除队列中的最后一个并修改活跃的activeEffect。 好了,我们目前做的仅仅只是让传入的effect回调在执行时就调用函数,它的另外一个功能就是在改值时重新执行一遍函数,那么我们怎么做怎么做?

那就要对其进行依赖收集和触发依赖了 首先看track收集依赖

let targetMap: WeakMap<object, Map<strNumSym, Set<Effect>>> = new WeakMap()
export const track = <T extends object>(target: T, type: effectTypeGet, key: strNumSym) => {
  if (activeEffect === undefined) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

我们先看一下effect的用法

const state = reactive({
	a:3
})
effect(()=>{
console.log(state.a)	
})
state.a = 4;

执行上面这段代码会打印3和4

我们可以看到我们读了state.a。那么就会触发get函数,因此我们在get函数中添加一行代码,就是执行track函数

function get<T extends object>(target: T, key: strNumSym, receiver: T) {

  let res = Reflect.get(target, key, receiver)
  track(target, 'get', key)

  return isObject(target[key]) ? reactive(target[key]) : res;
}

track函数很简单,无非就是收集依赖到targetMap。我们看一下上面代码执行后targetMap的样子。 targetMap是一个weakMap,键名是target,在上例中即{a:3},值是一个map数据结构。map数据结构的键名是key值,也就是a,值为一个set数据结构,其中每一项是Effect

现在我们可以看到执行完effect后,会根据effect函数中读的属性创建一个收集到的依赖targetMap

那么下面就是触发依赖了

我们看过vue2原理的都知道,在set函数中触发依赖。我们可以看到,我在set函数中写的是do something,现在我们把do something 改成触发依赖

if (!hasKey) {
    trigger(target, 'add', key, res)
  }
  else if (value !== oldval) {
    trigger(target, 'set', key, res)
  }

在修改值的适合也就是我上面写的state.a 会触发set函数,调用trigger 函数,我们来看一下trigger函数

export const trigger = <T extends object>(target: T,
type: effectTypeSet, key: strNumSym, value?: any) => {
  let depsMap = targetMap.get(target)
  if (!depsMap) return
 
 
  const run = (effects: Set<Effect>) => {
    effects.forEach(effect => {
        effect()
    }
  }
  if (type === 'add') {
    run(depsMap.get(Array.isArray(target) ? 'length' : ''))
  }
  run(depsMap.get(key))

}

trigger函数有四个参数,分别是代理的对象,类型,代理对象的属性,代理对象对应键的值

let depsMap = targetMap.get(target)

targetMap是收集到的依赖,这步操作很简单,我们现在拿到一个map数据depsMap,key是代理的属性,value是effect Set集合 。

run方法接受一个Effect 集合,现在我们取depsMap的key值对应对value就是一个Effect集合,注意的是,我们在数组中,收集依赖的是length属性。现在我们对set集合中对Effect遍历执行即可,这样一重新修改值,我们的回调函数就会再执行一遍。

computed简单原理

export function computed(options: computedOptiobs) {
  let get: Function
  let set: <T extends object>(key?: T) => any
  if (typeof options === 'function') {
    get = options
    set = () => { }
  }
  else {
    get = options.get
    set = options.set
  }
  let computed: GetValue
  let value: any
  let dirty = true
  let runner = effect(get, {
    lazy: true,
    computed: true,
    scheduler() {
      if (!dirty) {
        dirty = true
      }
      trigger(computed, 'set', 'value')
    }
  })


  return computed = {
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
        track(computed, 'get', 'value')
      }
      return value
    },
    set value(val) {
      set(val)
    }
  }
}

我们大家都知道computed可以传一个函数也可以传一个对象,所以我们先对参数进行一下判断是函数还是对象。大家都知道computed可以对值进行缓存,所以我们定义一个dirty属性用来判断需不需要缓存。在vue3中,我们使用计算属性返回一个对象,对象的value属性是计算后的结果,和ref一样。 set函数是用户自定义的,我们不多做管理。 当我们执行如下代码

let y = computed(()=>{
	return state.a+1
})


当我们执行到state.a时,就会触发get函数,如果dirty为false,直接返回value即可。 否则我们让value = runner(),同时让dirty为false,除此之外,我们的computed也应该是响应式的,因此我们也要对computed进行依赖追踪。

runner 其实就是effect函数的返回值,和一开始相比,我们的effect函数只是多传入了几个参数,也就是computed和scheduler函数,computed只是一个是否是computed的标记。

现在我们返回去看 effect()做了什么

export const effect = (fn: Function, options: EffectOptions = {
  lazy: false
}) => {
  let effect = createEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}

由于我们的lazy为true,可以看到我们只干了一件事,就是执行createEffect函数并将其结果返回。

那么现在就很简单了,所以我们的runner等于这段代码

let effect: Effect = function effectReactive() {
    if (!effectStack.includes(effect)) {
      try {
        effectStack.push(effect)
        activeEffect = effect
        return fn()
      } finally {
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
      }
    }
  }

现在执行runner 我们发现返回的是什么??就是fn(),也就是执行

()=>{
	return state.a+1
}

我们都知道,一旦我们重新修改state.a的值,dirty要重新变成true,同时会触发trigger函数, 因此我们对trigger函数做一个简单的修改。

 let ComputedRunner: Set<Effect> = new Set()
  let effectRunner: Set<Effect> = new Set()
  const run = (effects: Set<Effect>) => {
    if (effects) {
      effects.forEach(effect => {
        if (effect.options.computed) {
          ComputedRunner.add(effect)
        } else {
          effectRunner.add(effect)
        }
      })
    }
  }
  if (type === 'add') {
    run(depsMap.get(Array.isArray(target) ? 'length' : ''))
  }
  run(depsMap.get(key))
  effectRunner.forEach(effect => {
    if (effect.options.scheduler) {
      effect.options.scheduler()
    }
  })
  effectRunner.forEach(effect => {
    effect()
  })

很简单,我们将computed和普通effect进行分组,然后再分别遍历执行即可。注意的是,computed我们执行的是scheduler函数。

schedule函数很简单,我们假设我们修改了state.a =4 。现在修改了值,我们就不能继续取缓存中的值,所以先让dirty 变成true。注意的是,我们也可以在effect函数中读取computed的值,computed变了,effect也要重新执行,所以我们还要对computed进行一次依赖触发。

vuex4.0 简易源码

首先我们先看一下Vuex4.0怎么使用

import Vuex from '../vuex'

export default Vuex.createStore({
  state: {
    
  },
  getters: {
   
  },
  mutations: {
    
  },
  actions: {
    
  },
  modules: {
    a: {}
  })

和以往不一样的是,在4.0中我们是使用vuex.createStore方法创建一个仓库

然后在使用的地方调用vuex.useStore方法使用即可

const {state,commit,dispatch,getters,actions} = Vuex.useStore()

然后其他使用方法和vue2基本一致,在vuex4.0中,是基于provide和inject实现的,我实现了其最基本功能,加上ts接口差不多100多行代码。

class Store {
	install = (app: App) => {
    let _this = this
    app.provide('store', _this)
  }
}
const createStore = <T extends StoreOpts>(opts: T) => {
  return new Store(opts)
}

const useStore = (): Store => {
  return inject('store') as Store
}
}

首先createStore相当简单,就是new 了一下Store。

useStore也相当简单,就是注入了一下store,我们在install方法Provide('store',_this)),源码中是使用Symbol代替字符串,这里为了简单使用字符串。

我们先看一下接口

interface _ObjectFun {
  [key: string]: ((...params: any[]) => any)[]
}
interface _ObjectGetters {
  [key: string]: (getter: object) => any
}
interface StoreOpts {
  getters?: { [key: string]: Function }
  state?: { [key: string]: any }
  mutations?: { [key: string]: Function }
  actions?: { [key: string]: Function }
  modules?: { [key: string]: StoreOpts }
}
interface ModulesRoot {
  [key: string]: Modules
}
interface Modules {
  _raw: StoreOpts,
  state: object,
  _children: ModulesRoot
}

现在我们来一步步看Store类做了什么操作

class Store {
  getters: _ObjectGetters
  state: object
  mutations: _ObjectFun
  actions: _ObjectFun
  modules: collectionModules
  constructor(opts: StoreOpts) {
    if (opts === void 0) opts = {}
    this.getters = Object.create(null)
    this.mutations = Object.create(null)
    this.actions = Object.create(null)
    this.modules = new collectionModules(opts)
    this.state = reactive(opts.state)
    installModules(this, this.state, [], this.modules._root)
  }
  commit = (type: string, ...params: any[]) => {
    
  }
  dispatch = (type: string, ...params: any[]) => {
    
  }
  install = (app: App) => {
    let _this = this
    app.provide('store', _this)
  }
}

Store类首先先初始化state,getters等,注意state使用reactive包裹一下使其成为响应式,

我们都知道,使用vuex中modules下的a仓库中的b数据不是 store.modules.a.state.b 而是store.state.a.b这样使用,因此我们对modules要做一下其他操作。

我们来看一下 collectionModules类

class collectionModules {

  _root: Modules
  constructor(opts: StoreOpts) {

    this._root = {
      _raw: {},
      state: {},
      _children: {}
    }
    this.register([], opts)
  }
  register(path: string[], rootModules: StoreOpts) {
    let newModules = {
      _raw: rootModules,
      state: rootModules.state || Object.create(null),
      _children: Object.create(null)
    }
    if (path.length === 0) {
      this._root = newModules
    } else {
      let parent = path.slice(0, -1).reduce((root: Modules, current: string): Modules => {
        return root._children[current]
      }, this._root)
      parent._children[path[path.length - 1]] = newModules
    }
    if (rootModules.modules) {
      Object.keys(rootModules.modules).forEach((moduleName: string) => {
        this.register(path.concat(moduleName), rootModules.modules[moduleName])
      })
    }
  }
}

collectionModules 类最重要的是调用register函数,path和rootModules。

path其实就是一个保留父子关系的数组。如path为['a','b','c']则代表模块a下有模块b,模块b下有模块c如果未空数组,则代码没有模块。rootModules就是当前模块小的一个小仓库。

newModules有三个属性,_raw是当前小仓库,state是当前仓库的state,children是一个对象,键名为模块名,键值为小仓库。

我们慢慢分析,如果我们的store没有模块,那么register函数只做了一件事,那就是初始化_root属性。毫无疑问,我们接下来就是将所有模块递归插入到_children属性中。

Object.keys(rootModules.modules).forEach((moduleName: string) => {
        this.register(path.concat(moduleName), rootModules.modules[moduleName])
      })

遍历对象中的模块,递归调用register()函数,构建path数组父子关系并将模块中的小仓库传入进去。 我们现在来看else部分,我们有了path保存了父子关系,那么我们可以很简单的照到其父亲模块名。 然后将小仓库作为键值,仓库名作为键名加入到父modules下的_children对象中即可。

现在我们只是收集好了模块之间的关系。

我们都知道对于getters,不管是那个模块下的getters,我们只要使用getters.xxx。而不是getters.moduleName.xxx。当我们dispath或者commit一个方法,所有模块下的同名方法都会执行。我们现在来看最后一个核心方法installModules

我们在construction中调用

installModules(this, this.state, [], this.modules._root)

前面两个参数很好理解,就是store,和其state。并且在后面的递归调用中这两个参数一直是同一个,也就是说,是整个Store和最外层的那个state。第三个参数是path和收集模块中的path一个作用, this.modules._root就是我们收集到的没一个模块,在往后的递归调用中是我们收集到的_children中的一项。先看代码。

const installModules = (store: Store, state: object, path: string[], rootModules: Modules) => {
  if (path.length > 0) {
    let parent = path.slice(0, -1).reduce((state, current): object => {
      return state[current]
    }, state)
    parent[path[path.length - 1]] = rootModules.state
  }
  let { getters, mutations, actions } = rootModules._raw
  getters && (Object.keys(getters).forEach((getter: string) => {
    Object.defineProperty(store.getters, getter, {
      get() {
        return getters[getter](rootModules.state)
      }
    })
  }))
  
  mutations && (Object.keys(mutations).forEach(mutation => {
    let arr = store.mutations[mutation] || (store.mutations[mutation] = [])
    arr.push((...params: any[]) => { mutations[mutation](rootModules.state, ...params) })
  }))
  actions && (Object.keys(actions).forEach(action => {
    let arr = store.actions[action] || (store.actions[action] = [])
    arr.push((...params: any[]) => { actions[action](store, ...params) })
  }))
  if (rootModules._children) {
    Object.keys(rootModules._children).forEach(moduleName => {
      installModules(store, state, path.concat(moduleName), rootModules._children[moduleName])
    })
  }
}


如果没有子模块,state不需要做任何操作。 getters模块也相对简单,就是遍历getters将其他getters中的数据劫持到最外层到getters上。

mutations 和 actions几乎一样,就是将所有模块中的同名函数放到一个队列中,所以我们的store.mutations和store.actions的每一项都转化为一个数组。数组的每一项是一个函数。

我们先把这个放在一边,我们来看有modules的情况,将子模块的state合并到最外层到state上。 也很简单,我们找到父模块的state给其加一个键名为模块名,键值为模块的state即可。 现在,我们调用Vuex.commit('actionName')和vuex.dispatch('mutationname')即可。

现在我们来补充完这两个函数

  commit = (type: string, ...params: any[]) => {
    this.mutations[type].forEach(callback => {
      callback(...params)
    })
  }
  dispatch = (type: string, ...params: any[]) => {
    this.actions[type].forEach(callback => {
      callback(...params)
    })
  }

我们刚刚说了,我们中的队列每一项都是一个方法,现在我们直接遍历队列进行调用函数即可。

每一个函数都是我们自己写的mutations和actions。注意的是mutation中的函数第一个参数是当前模块的state,而actions中的函数第一个参数是store。

现在我们就完成了一个简单的vuex。完整的源代码大家可以去github上自取

vite 简要分析。

大家都知道,vue3可以使用脚手架和vite两种方式创建,说真的,vite的构建速度和脚手架使用webpack构建速度不是一个量级的。在我刚刚使用vue3时,vite还不支持less等预处理语言,不过现在vite对less基本开箱即用,只要安装less和less-loader即可,不用再进行其他配置。vite简单来看就是使用koa2搭建的一个服务器。

首先我们看index.html

<script type="module" src="/src/main.js"></script>

我们发现script 中type属性等于 module。使用es6模块,天生按需加载。

我们都知道,module模块,只支持./ ../ /开头的引入方式

但是我们 import { createApp ,provide} from 'vue' 并不是这三种之一的开头啊,那么在vite是如何加载的呢?

现在我们打开浏览器的network 面板,我们发现我们的请求变成了 @modules/vue.js,这时候我们就恍然大悟了,我们在我们请求的模块上加上/@modules即可,这个时候他就是/开头的了。我们于没有./ / ../的模块自动加上 /@modules即可,然后我们再根据一定的映射关系找到对应的模块。

比如我们请求的是 'vue' ,这个时候我们就会转变成去请求 '@modules/vue' 然后 有一个对象对应的键名'vue' 对应的键值是 './node_modules/@vue/compiler-dom/dist/compiler-dom.esm-bundler.js'

现在我们就可以很快乐的和以前一样请求非/ ./ ../ 开头的文件了,但是我们的.vue 文件又如何请求呢??? 我们首先看一下network中请求的app.vue变成了什么

import HelloWorld2 from "/src/components/HelloWorld.vue";
import store from "/src/vuex/index.ts";
const __script = {
  name: "App",
  components: {
    HelloWorld: HelloWorld2
  },
  setup() {
    const {useStore} = store;
    console.log(useStore());
    const {state, commit, dispatch, getters, actions} = useStore();
    const change = () => {
      commit("increment", 4);
    };
    return {
      state,
      getters,
      change
    };
  }
};

import { render as __render } from "/src/App.vue?type=template"

__script.render = __render
__script.__hmrId = "/src/App.vue"
__script.__file = "/Users/huanglihao/learn/vuex4.0/vuex/src/App.vue"
export default __script

主要做了两个操作:1 将script标签中的内容放入__script变量中并导出。2:将请求内容加上type === 'template'

将.vue 文件改写成上面那种很简单,只要做适当的正则和字符串就能写出来。

对于 模版我们做如下操作

if (ctx.query.type === 'template') {
      ctx.type = "js";
      let content = descriptor.template.content
      const { code } = compileTemplate({ source: content })
      ctx.body = code 
    }

调用vue3中自带的compileTemplate函数将template转化为虚拟dom即可

当然除了这些,还有很多关键的代码,比如通过创建webSocket服务进行热更新操作。 其主要原理监听整个文件夹是否有内容发生改变,然后记录发生改变的文件,如果有文件发生的话则通过websocket进行事实通信后调用locatio.reload方法进行页面更新。

结语:vue3中还有很多还需要琢磨的东西,比如vue-router,createApp函数,深入研究vite原理等。特别是vue3 中的vue-router感觉和vue2中区别有点大,笔者尝试用一天时间去写一个简单版的,居然写出了bug,简直菜哭了。emmm,然后后面就干其他事情去了,然后就没有再尝试了。以后有时间会再尝试一下的。下一篇会简单介绍一下拿vue3写的一个小项目,希望大家能点一个小赞。