手写Vue
项目初始化
初始化项目
npm init -y
安装依赖 rollup 打包和 babel 转译
npm i rollup @babel/core rollup-plugin-babel bel @babel/preset-env -D
- @babel/preset-env 是 babel 插件集合,core 的 transfer 会调用这些插件来转化为低级语法。
在根目录下新建 src 目录,并在这个目录新建
index.js
,之后再根目录新建rollup.config.js
// index.js function Vue() { } export default Vue <!--0-->
修改 package.json 下的 script, 加入
"dev": "rollup -c -w"
,-c 表示读取根目录下的 rollup.config.js, -w 表示监控文件改变执行
npm run dev
后,会发现 dist 目录下新增了 vue.js 和 vue.js.map ,在这个目录下新建 index.html,并引入生成的 vue.js,之后在 script 标签内console.log(Vue)
会发现把 Vue 这个函数打印出来了。
Vue 的调用
我们知道,我们在 Vue2 中,是通过 new Vue(options) 来实现 Vue 挂载的。所以我们先在 html 文件中写上一个 id 为 app 的 div,之后 new Vue({el: '#app', data: { msg: 'newVue' } })
,这就是正常的 Vue 调用。
初始化
Vue
在初始化时,调用了很多 mixin
方法,比如initMixin
、renderMixin
、lifeCycleMixin
等,将功能方法拆分了,直接在原型上添加方法,比如常见的 $mount
、$nextTick
等。
options
和 $options
, options
是 Vue 这个 function
(构造函数) 上的属性,$options
是 new Vue()
出现的实例上的属性。
另一个容易弄混的是 vm._vnode
和 vm.$vnode
,假如我页面中有一个组件叫做 <my-button />
,渲染结果是 <button>按钮</button>
,则 vm._vnode
指的是 <button>按钮</button>
的 vnode,到时候会用来做 diff,vm.$vnode
是 <my-button />
的 vnode,即 options.parentVnode。
观察者
Dep
与 Watcher
,dep
为 dependence
的缩写,表示依赖。一个属性上对应一个 Dep
,它上面又一个 subs
数组,表示这个属性被多处视图依赖,一个视图上存在一个 watcher
(渲染 watcher
,在组件 mounted
时 new
出一个 watcher
原型,会触发它的 get
,在这个 get
方法上,把 Dep.target
指向自己,之后调用 getter
,这个 getter
是传入的参数,这个 getter
方法会调用页面渲染的方法,即 vm._update(vm._render())
,因此会走到 defineReactive()
方法,触发 getter 方法),一个 watcher
可以对应多个 dep(即 deps 数组)
,因为一个视图可能依赖多个属性。可以把视图理解为组件,更方便理解。在它们之上,还有一个 watcher
,是用于观察 watcher
的全局 watcher
,仅有一个,用于触发 update()
方法, Vue
在此之上,监听到数据改变就会触发 update
。
Dep
上挂载了一个 target
,target
为 null
或 Watcher
实例,初始值为 null
,一旦页面上的视图依赖了某个属性,就会走到 observe()
方法内的 Object.defineProperty
的 getter
装饰器,如果存在 Dep.target
,就会触发 dep.depend()
,会触发 Dep.target.addDep
(这是一个 watcher
实例),把 Watcher
的 deps
数组加进这个 dep
,把 dep
实例的 subs
加入这个 watcher
(即当前的 Dep.target
) 实例。
为了防止 dep
的 subs
和 watcher
的 deps
互相重复添加,vue2
里在判断浏览器不支持 Set 的时候,自己生成了一个 SimpleSet
,非标准规范,用于防止添加重复的 sub
或 dep
。
当监听到某个属性变化时,会走到 setter
装饰器,调用 dep.notify()
,通知这个 dep
上的 subs 数组中所有的 watcher
,调用这些 watcher
的 update()
方法,这个 update
又调用了dep.get()
来重新渲染页面。
组件
Vue.component(name, options)
的第二个参数,如果是对象,就会调用 Vue.extend
返回组件的构造函数,这个构造函数的原型继承自 Vue
的实例。
组件在初始化时,会调用 Vue.extend
(会调用一次 _init
,可以合并 options 和响应式处理数据)来生成 Vue
的实例作为组件的原型,同时通过 mergeOptions
把 Vue 上的全局 options
放到自己的 options
上(期间涉及到同名属性合并策略,同名生命周期默认都会保存,同名属性默认自己的将会覆盖全局。在 Vue
中,合并策略是允许自定义的),Vue
(这里写的是 Vue 方便理解,实际上是当前组件的父组件)上会多出一个 components
的属性用于存放组件。Vue.component
至此结束。
在创造真实节点时,内部会对模板进行编译操作,调用 _c('组件名')
(_c
即 createElement
用于创造虚拟节点),通过 isReservedTag
方法来判断是否为组件,如果是组件,调用 createComponent
来生成 vnode
,组件的虚拟节点上存在 componentOptions
属性(见下)。如果 componentOptions
里的 Ctor
(如果是构造函数,说明已经被 Vue.extend
过了,可以直接使用。如果是对象,则需要将其转为构造函数,通过 Vue.extend
生成,就是 Vue 原型的实例,同时也作为组件的构造函数)。之后挂载上组件的 hook
后就创造了一个组件的虚拟节点并返回。
在通过 createElm
创造真实节点的时候,如果是组件(通过 createComponent 来判断,不过这个方法和上面创造虚拟节点的方法重名了,这里的目的调用上面同名方法定义的 init hook,生成 vnode.componentInstance)
,就会调用组件的 hook
,里面的 init
方法会new Ctor({})
创造一个组件实例后,并放到组件虚拟节点的 vnode.componentInstance
属性上,之后调用 $mount
生成一个 $el
,对应组件模板渲染的结果。
组件在 new Ctor()
时会进行组件节点的初始化,给组件再次添加一个独立的渲染 watcher
,每个组件都有自己的 watcher
,更新时,只需要更新自己组件对应的渲染 watcher
,因为组件渲染时,组件对应的属性只会收集自己的渲染 watcher
。
*所有的组件实例都是通过 Vue.extend 来实现的。组件上的 componentOptions 里放着组件的所有内容,属性,事件,插槽,Ctor,children 等。 而 componentInstance 则是 new Ctor() 产生的,存放着 $el *
1 | Vue.extend = function() { |
流程图如下
1 | graph TD |
函数式组件
1 | // 没有状态,没有 this,没有 new,没有生命周期,不会有 Watcher |
函数式组件主要逻辑
异步组件原理,先弄个占位符,加载完毕后重新渲染。
Vue.Component
调用 createComponent
如果传入的第二个参数不是对象,而是函数(则会被当成 factory 函数),同时没有 Ctor.cid
(Ctor 是第一个参数,可能是类组件,函数或对象或 void,对象会走 Vue.extend 生成一个类组件,cid
是在组件被调用 Vue.extend
后会添加在组件上的属性) ,则触发异步组件逻辑,调用 resolveAsyncComponent 函数。
会往这个函数中传入 Vue 自己实现的 resolve 和 reject 方法,如果返回值是 Promise,则对 .then 传入 resolve 和 reject,resolve 会调用 once((res) => { factory.resolved = ensureCtor(res, baseCtor); !sync && forceRender(true) })
ensureCtor 就是 Vue.extend,之后强制渲染。resolveAsyncComponent 函数最后返回 factory.loading ? factory.loadingComp : factory.resolved
,即 factory 是否为 loading 状态,是就返回 loading 组件,没有 loading 就返回 undefined,否则返回 factory.resolved (调用 resolve 后会把组件挂在到 factory.resolved 上)。然后根据返回值是否为 undefined 决定渲染占位符还是真是组件,如果 loadingComp 为 undefined,占位符是 <!---->
这种 HTML 注释。如果调用 resolveAsyncComponent 函数时, factory.resolved 有值,则直接返回 factory.resolved。因此第一次会显示占位符,第二次会显示组件,因为在第一次过后,组件异步更新完成时, factory.resolved 已经有值了,并且调用 forceRender(true) 重新渲染组件。
通信
bus、provide / inject 它们的缺点都是无法在当前组件找到通信得到的值是哪个组件传递的。
props
组件在初始化(创造 Vnode 后调用 initInternalComponent)时,会生成一个 vnodeComponentOptions 的属性,这个属性上存在 propsData、_listeners(事件 on:如点击事件)、children(插槽 slots)、tag 几个属性,当从父组件传入 props 时,会在子组件状态初始化时(initState 中调用 initProps)将父组件传入的 props (propsData)和子组件上定义的 propsOptions 做校验,之后将通过校验的属性放到 vm._props 上,并 proxy 到外层。根据是否为根元素决定 _props 属性决定 shouldObserve 的值,来决定是否需要响应式 props,仅在 isRoot 为 true 时,响应式 props,因为父组件和子组件的 props 只要有一个响应式就够了,因此都让父组件的 props 响应式,根元素没有父组件,因此自己响应式。
1
2
3
4
5
6
7
8
9
10
11// 父组件生成的就是 vm.$options.propsData
<my-app a="1" b="2" />
// 子组件生成的就是 propsOptions
export default {
props: {
a: String,
b: Number
}
}bus
新建一个 Vue 实例,通过 on 和 emit 实现跨层通信,on 会注册到 vm._events 中, emit 会从 _events 中寻找并调用,实际上是个发布订阅模式。
1
2
3let bus = new Vue({})
vm.$on('data', function () {})
vm.$emit('data', function () {})emit listeners
通过 $emit 调用绑定在自己身上的事件,如下,fn 这个属性是被绑定在子组件中的(vm.$options._parentListeners),但是 handler 是父组件的方法,通过 $emit 调用自身的事件上绑定的父组件的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 父,其实相当于子组件 this.$on('fn', handler)
<my-app @fn="handler" />
export defaukt {
methods: {
handler(params) {}
}
}
// 子
<div @click="fn">
</div>
export default {
methods: {
fn() {
const parmas = {}
this.$emit('fn', params)
}
}
}provide / inject
在初始化时,会调用 initInjections 获取父组件的数据注入到自己身上,之后调用 initProvide 向子组件提供数据。写法如下,initProvide 会将 provide 挂到 vm.$options._provided 上,initInjections 会用 inject 遍历得到 key 值并在 vm.$options._provided 中寻找并注入,如果当前的父组件没有这个属性,不停地向上查找 $parent,即父组件的父组件,直到找到或者到根组件为止。
1
2
3
4
5
6
7
8
9var Provider = {
provide: {
foo: 'bar'
}
}
var Child = {
inject: ['foo']
}$parent / $children
$parent 得到父组件,$children 得到所有的子组件,可以获取到它们身上的属性。在 initLifecycle 中初始化这两个属性。
attrs / listeners
组件的所有事件都定义在 $listeners 上,而且它是响应式的。组件的所有属性被定义在 attrs 上,它也是响应式的。
<my-button a=1 b=2 @click="handleClick">
attrs 为 a, b , listeners 为 clickref
在 patch.js 中通过调用 invokeCreateHooks,依次把 cbs(将 modules 目录里的文件的生命周期加入 cbs,即 ref 和 directives,cbs 就变成 cbs[‘create’] = [updateDirectives(directives.js 的 create), registerRef(ref.js 的 create)]),调用 registerRef 的时候,会判断这个传入的 vnode.componentInstance 要么是组件要么是 dom 元素,然后在 vm.$refs 中加入这个 ref,如果这个 ref 被 v-for 包裹,会将这个 ref 的值转换为一个数组($refs.xx = [sameRef1, sameRef2])。
observable
vue2.6 新加入的 api,小型 vuex,作用生成一个全局的响应式对象。一般不用,因为在所有组件中都可以使用,如果在某个组件中修改了数据,可能会造成数据混乱
组件可以增加一个 abstract 属性,表示抽象组件,不记录到父子关系中。如 keep-alive 组件
v-model
组件的 v-model
和原生标签的 v-model
是不一样的,虽然我们经常说 v-model
就是 :value + @input
,但在原生标签中,经过了一步转化,为了在输入中文的时候,那些输入法的字符不显示在表单元素中。
比如在 <input v-model="val" />
和 <input :value="val" @input="e => val = e.target.value">
,在这两个表单中输入 ”再“ 这字时,第一个表单元素不会出现 zai
这三个英文字符,第二个会。实现原理是监听 compressstart
和 compressend
队列
Vue 中当值触发 set
时,将使用 dep.notify()
通知 watcher
更新,但这些 watcher
中可能会存在相同的 watcher
被重复通知,因此,Vue
建立了一个 watcher
队列,用于去重(利用 watcher.id
)并缓存所有 watcher
,等待下次空闲时加入事件队列。
Vue 中有个很常用的方法叫做 nextTick
,它把所有同步的事件存入一个队列中,在空闲时依次将队列中的所有事件一一调用,并将队列清空。队列的使用在 Vue
中非常频繁,在创建全局 api
的时候,合并策略也是通过队列来实现的。
性能优化
由于 Vue 中 data 的所有属性都被响应式处理,每次使用 this.xxx 调用时,都会触发它的 get() 造成行能浪费,因为 get 中绑定了很多操作,同理 set 也是,如
1 | for (let i = 0; i < 10000; i++) { |
Vue2
的 diff
是递归比较的,性能消耗比较大,在 v-if
时,由于 vnode
树不管这个节点在多深的节点,都会从根节点开始递归,因此性能不好。在 vue3
中,会把可能会更新的节点存入数组,发现更新了就遍历一遍数组,而不是递归,性能比 vue2
好很多。
在 v-for 循环时,如果用 index 指定为 key
,那么在 ABCD
节点变成 DCBA
时,会触发四次更新(相当于四个节点标签复用了,它们的子节点没复用,如果四个标签是 input 标签,就会保留原标签的值,只是改变标签的属性,会出现问题),因为老节点的 A
和新节点的 D
key 值一样,标签一样,因此触发节点修改而不是节点移动,因此 ABCD 就触发了四次更新,而不是三次移动,每次更新页面重新渲染,性能不好。
优化原则
不要把所有数据放再在 data 中,因为所有的数据
不要把数据层级写的太深,因为如果拿到的值是对象,会对其递归调用 observe
不要频繁获取数据
如果数据不需要响应式,可以用 Object.freeze 冻结属性