前言
本篇是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写的一个小项目,希望大家能点一个小赞。