阅读框架源码方法
刀刀
4/8/2025
0 字
0 分钟
源码阅读
打包入口文件
首先前往 Vue2 的 github
下载源码项目,安装依赖。
然后从代码的目录结构入手,先分析每一个文件夹的功能以及包含的内容:
bechmarks
:性能测试dist
:最终打包出的结果examples
:官方例子demo
flow
:类型检测,Vue2 当初没采用 TS,而是采用这个微软出品的类型检测,不过目前没人使用了packages
:一些写好的包(Vue 源码中包含weex
)scripts
:所有打包的脚本文件src
:源代码目录一般情况下我们的代码都是放在
src
文件夹下,因此它是重点文件夹。打开src
文件夹,分析其下的每个文件夹的功用:compiler
:专门用作模板编译platform
server
:服务端渲染相关sfc
:解析单文件组件,如样式style
、模板template
、脚本script
等(要结合vue-loader
使用)shared
:模板之间的共享属性和方法
找到
package.json
文件,查看命令找到打包的入口,在srcipts
文件夹下的config.js
文件。该文件最后的代码如下:
if(process.env.TARGET) {
module.exports = genConfig(process.env.TARGET)
} else {
exports.getBuild = getConfig
exports.getAllBuilds = () => Object.keys(build).map(genConfig)
}
从代码可以看成它做了一个判断,是否有打包目标 TARGET
,如果有直接使用,没有才通过遍历对象寻找。
查看运行的命令,有两种:运行时如果无法解析 new Vue
传入的 template
,则会走 web-runtime
;否则会执行 web-full
(web-runtime
+ 模板解析)。
它调用了 genConfig
方法,该方法会去 builds
对象中找到对象打包目标对象中找到入口文件,部分源码如下:
const builds = {
// runtime-only build (Browser)
'runtime-dev': {
entry: resolve('web/entry-runtime.ts'),
dest: resolve('dist/vue.runtime.js'),
format: 'umd',
env: 'development',
banner
},
// Runtime+compiler development build (Browser)
'full-dev': {
entry: resolve('web/entry-runtime-with-compiler.ts'),
dest: resolve('dist/vue.js'),
format: 'umd',
env: 'development',
alias: { he: './entity-decoder' },
banner
},
}
其中 entry
是打包的文件文件路径,在 web
文件夹下的相应文件。但是 scripts
以及外部都没有这个 web
文件夹,因此还需要继续寻找。
调用了 resolve
函数,把路径传了过去,源码如下:
const aliases = require('./alias')
const resolve = p => {
const base = p.split('/')[0]
if (aliases[base]) {
return path.resolve(aliases[base], p.slice(base.length + 1))
} else {
return path.resolve(__dirname, '../', p)
}
}
它拿到路径后和 aliases
做了拼接处理,因此根据路径查看 aliases.js
的代码,如下:
module.exports = {
vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
compiler: resolve('src/compiler'),
core: resolve('src/core'),
shared: resolve('src/shared'),
web: resolve('src/platforms/web'),
server: resolve('packages/server-renderer/src'),
sfc: resolve('packages/compiler-sfc/src')
}
可以看到其 web
实际上是 src/platforms/web
路径,现在能够通过拼接,找到对应的打包文件的入口路径了。
查看源码,发现它们都使用了 runtime
:
import Vue from './runtime/index'
而 entry-runtime-with-compiler
的区别是多实现了一个 compile
API,把 template
转化成 render
函数。
下面先来看看这个 compile
API 做了什么处理。源代码如下所示:
// 函数劫持,将原来的mount函数获取到后重写mount函数
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 获取el元素
el = el && query(el)
/* istanbul ignore if */
// 挂载的流程是用一个新生成的dom替换掉老的dom元素
if (el === document.body || el === document.documentElement) {
__DEV__ &&
warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// resolve template/el and convert to render function
if (!options.render) { // 如果有render函数则直接使用用户的render函数
let template = options.template
if (template) { // 如果没有render,查看是否有模板
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
// 考虑到 {template: '#template'} 的情况
template = idToTemplate(template)
/* istanbul ignore if */
if (__DEV__ && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
// 如果给的模板是一个dom元素,则拿到模板中的内容
template = template.innerHTML
} else {
if (__DEV__) {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
// @ts-expect-error
// 如果没有模板,则使用el对应的template
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
mark('compile')
}
// 直接将模板变成render函数
const { render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: __DEV__,
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
},
this
)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
// 调用了挂载
return mount.call(this, el, hydrating)
}
查看其源码不难发现,他只做了一件事情,就是重写了 $mount
函数,将 template
变成 render
函数。这就是 entry-runtime-width-compiler
比 entry-runtime
命令多出来的区别。
接着我们再往上面看,看一下双方都有的 runtime/index
文件内的方法做了什么处理。它是运行时,所谓运行时,就是提供一些 DOM 操作的 API ,比如属性操作、元素操作等,还有一些指令和组件。
其中,部分源码如下所示:
// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives) // 添加平台对应的指令和组件
extend(Vue.options.components, platformComponents)
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop // 渲染时会调用的方法,更新时会调用的方法。noop是一个空函数,如果在服务端渲染,则不需要调用方法,因此给一个空函数即可
// public mount method
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
// 组件的挂载
return mountComponent(this, el, hydrating)
}
主要是为原型上安装一个 __patch__
的方法,判断当前的渲染环境是否在服务器端,如果在服务器端,则执行空函数 noop
。
在原型上挂载一个 $mount
函数,最终 return
导出一个组件的挂载的方法。
但是这还不是入口文件,往上滑发现它从 core/index
文件导入方法,因此前往查看它做了什么处理。
打开 core/index.js
文件,发现它依旧不是入口文件,做了一个导入导出。该文件增加了两个标识:是否是服务端渲染、服务端渲染的上下文,并且初始化 Vue 的全局 API 。部分源码如下:
initGlobalAPI(Vue)
Object.defineProperty(Vue.prototype, '$isServer', {
get: isServerRendering
})
Object.defineProperty(Vue.prototype, '$ssrContext', {
get() {
/* istanbul ignore next */
return this.$vnode && this.$vnode.ssrContext
}
})
根据文件路径继续网上找,查看 core/instance/index
文件,发现他没有外部导入 Vue 方法,而是创建了一个 Vue 的构造函数。其源码如下所示:
function Vue(options) {
if (__DEV__ && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
可以看到其本质上在函数中调用了 _init()
初始化的方法,然后调用了几个函数,在 Vue 的原型 prototype
上挂载方法。作用分别是:
initMixin
:挂载初始化方法_init
stateMixin
:挂载$set
、$delete
、$watch
方法eventsMixin
:挂载$on
、$off
、$emit
方法lifecycleMixin
:挂载_update
、$forceUpdate
强制刷新、$destory
销毁方法、renderMixin
:挂载$nextTick
、_render
方法
总结出一句就是在扩展原型上的方法。
一图流如下所示:
里面每一个具体怎么实现?
- 如果找了核心流程,可以单独打开源码查看
- 如果不清楚流程,可以写一些测试用例和案例来调试。通过为编译命令行添加
--sourcemap
让编译后的源文件可以debuggr
调试
全局API分析
看看它的全局 API 是怎么实现的。找到 src/core/global-api/index.js
文件,里面有一个 initClobalAPI()
函数。
首先它代理了 config
方法,用于配置信息,源码如下:
const configDef: Record<string, any> = {}
configDef.get = () => config
if (__DEV__) {
configDef.set = () => {
warn(
'Do not replace the Vue.config object, set individual fields instead.'
)
}
}
Object.defineProperty(Vue, 'config', configDef)
然后配置了 Vue 中的工具方法,如合并 extend
、合并策略 mergeOptions
、定义响应式 defineReactive
等。还有一些常用的方法,如 set
、delete
、nextTick
等。源码如下:
Vue.util = {
warn,
extend,
mergeOptions,
defineReactive
}
Vue.set = set
Vue.delete = del
Vue.nextTick = nextTick
其中,关于 nextTick
可以查看我的博客文档 nextTick 。
再往下是让一个对象变成响应式的方法,源码如下:
Vue.observable = <T>(obj: T): T => {
observe(obj)
return obj
}
最后在 Vue 方法上通过 Object.create
设置空对象 options
,并通过循环赋值空对象,并让 _base
一直指向 Vue 构造函数。
使用 extend
方法挂载 keep-alive
方法。
再从其他页面导入对应的方法注册,分别是:
- Vue.use
- Vue.mixin
- Vue.extend
- Vue.component、Vue.directive、Vue.filter
源码如下:
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})
// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
Vue.options._base = Vue
extend(Vue.options.components, builtInComponents)
initUse(Vue)
initMixin(Vue)
initExtend(Vue)
initAssetRegisters(Vue)
响应式数组
响应式数据的理解
initData
函数中调用observe
方法回调,对数据作监听。调用defineReactive
方法劫持代理(内部重写了所有属性)。递归增加对象中的对象增加geter
和setter
。如果层级过深,则需要考虑优化(递归代理消耗性能)。如果数据不是响应式的就不要放在
data
中了。在取值的时候要避免多次取值,如果有些对象放到data
内但不是响应式的,可以考虑Object.freeze()
来冻结对象。检测数组变化
Vue2 中检测数组变化没有采用
defineProperty
,因为修改索引的情况不多(如果直接采用defineProperty
会浪费大量性能)。采用重写数组的变异方法来实现(函数劫持)。jsif (isArray(value)) { // 如果是数组,则判断它有没有__proto__对应方法,如果有说明已经代理过了,没有则没代理过,通过拷贝为其赋值 if (!mock) { if (hasProto) { ;(value as any).__proto__ = arrayMethods } else { for (let i = 0, l = arrayKeys.length; i < l; i++) { const key = arrayKeys[i] def(value, key, arrayMethods[key]) } } } if (!shallow) { this.observeArray(value) } } else { const keys = Object.keys(value) for (let i = 0; i < keys.length; i++) { const key = keys[i] defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock) } }
通过阅读源码可以看出其步骤顺序如下:调用
initData
函数,调用observe
回调,对传入的数组进行原型链修改,后续调用的方法都是重写后的方法,对数组中每个对象也再次代理。
依赖收集
所谓的依赖收集,实际上是观察者模式。被观察者指代的是数据(即观察数据的变化);观察者是 watcher
,里面可能有渲染逻辑、计算属性、用户 watcher
。
观察者要观察数据,则需要通过 dep
先收集数据。一个 watcher
中需要先可能对应多个数据,watcher
中还需要保存 dep
。重写渲染的时候可以让属性重新记录 watcher
。
dep
与watcher
是多对多的关系,一个dep
对应多个watcher
,一个watcher
内有多个dep
。默认渲染时会收集依赖,触发
get
方法。数据更新了就找到属性对应的watcher
去触发更新。数据更新了就找到对应的watcher
触发更新。如果不触发
$mount
,就不会render
更新,也就不会收集依赖。
一图流如下:
一个视图渲染的属性有可能不一样,因此每次都会先清理,然后重新收集依赖。如果不清理,改旧的、现在没用到的属性也会造成视图更新。每次重新渲染都会触发 getter
方法,在这个方法收集依赖。
模板编译
用户传递的是 template
属性,需要将这个 template
编译成 render
函数。
- 首先转换成 AST 语法树
- 对语法树进行标记(静态节点)
- AST语法树生成
render
函数
生命周期
在 src/core/util/options.js
文件中的 444 行开始,循环父亲和儿子的所有属性,然后调用 mergeField
函数做合并操作。源码如下:
const options: ComponentOptions = {} as any
let key
// 循环父亲所有属性
for (key in parent) {
mergeField(key)
}
// 循环儿子所有属性
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
// 策略模式
function mergeField(key: any) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
而策略模式中默认如果有儿子的属性方法则采用儿子的,没有才使用父亲的属性方法,源码如下:
const defaultStrat = function (parentVal: any, childVal: any): any {
// 以儿子为主
return childVal === undefined ? parentVal : childVal
}
接下来看看 strats
的 hook
内封装了啥方法,源码如下:
export function mergeLifecycleHook(
parentVal: Array<Function> | null,
childVal: Function | Array<Function> | null
): Array<Function> | null {
// 获取父亲和儿子的
const res = childVal
? parentVal
? parentVal.concat(childVal) // 都有就合并
: isArray(childVal) // 否则看儿子是不是数组,是数组直接用,不是则用数组包裹
? childVal
: [childVal]
: parentVal
return res ? dedupeHooks(res) : res
}
去到 src/core/instance/lifecycle.js
文件,找到生命周期的钩子回调函数,源码如下:
export function callHook(
vm: Component,
hook: string,
args?: any[],
setContext = true
) {
// #7573 disable dep collection when invoking lifecycle hooks
pushTarget()
const prev = currentInstance
setContext && setCurrentInstance(vm)
const handlers = vm.$options[hook]
const info = `${hook} hook`
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
invokeWithErrorHandling(handlers[i], vm, args || null, vm, info)
}
}
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
setContext && setCurrentInstance(prev)
popTarget()
}
实现原理是利用发布订阅模式,将用户写的钩子维护成一个数组,后续依次调用 callHook
。主要依靠的是 mergeOptions
方法。
为什么有些钩子是先子后父,有些是先父后子?
主要是看组件是如何渲染的。看下方代码:
vue<div id="app"> <my-button></my-button> </div>
遇到父组件
#app
,开始渲染父组件,触发其beforeCreate
钩子函数。然后遇到子组件my-button
,开始渲染子组件,触发子组件的beforeCreate
钩子函数。渲染完毕后触发子组件的
mounted
钩子函数,继续往下,渲染父组件,渲染完毕后触发父组件的mounted
。
题外话
一般在哪里发送请求?
beforeCreate
生命周期的数据还未实现响应式,从源码下手证明这一点。
在 src/core/instance/init.js
文件的 59行左右,函数调用顺序如下:
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate', undefined, false /* setContext */)
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
首先调用 initLifecycle
函数,该方法源码如下:
export function initLifecycle(vm: Component) {
const options = vm.$options
// locate first non-abstract parent
let parent = options.parent
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
parent.$children.push(vm)
}
vm.$parent = parent
vm.$root = parent ? parent.$root : vm
vm.$children = []
vm.$refs = {}
vm._provided = parent ? parent._provided : Object.create(null)
vm._watcher = null
vm._inactive = null
vm._directInactive = false
vm._isMounted = false
vm._isDestroyed = false
vm._isBeingDestroyed = false
}
总结下来就是处理组件父子关系,如 $parent
、$children
等。
然后调用了 initEvents
方法函数,该方法主要初始化一些全局方法,如 $on
、$off
、$emit
等。源码如下所示:
export function eventsMixin(Vue: typeof Component) {
const hookRE = /^hook:/
Vue.prototype.$on = function() {}
Vue.prototype.$once = function() {}
Vue.prototype.$off = function() {}
Vue.prototype.$emit = function() {}
}
然后调用了 beforeCreate
生命周期钩子函数,该函数无法获取数据,作用不大,因此 Vue3 把它剔除了。
然后调用了 initInjections
方法,该方法主要是注册初始化 resolve
、injections
、before
、data/props
、inject
等方法。紧接着调用 initState
函数方法,此时劫持数据并做响应式处理。执行完毕后调用 initProvide
函数,主要实现 provide
方法。
再然后才调用 created
生命函数,此时拿到的是响应式的属性(不涉及 DOM 渲染),这个 API 可以在服务端渲染中使用。在 Vue3 中改为 setup
。
然后代码执行到 src/core/instance/lifecycle.js
文件中的 146 行,触发 beforeMount
生命周期回调。源码如下:
vm.$el = el
if (!vm.$options.render) {
// @ts-expect-error invalid type
vm.$options.render = createEmptyVNode
if (__DEV__) {
/* istanbul ignore if */
if (
(vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
vm.$options.el ||
el
) {
warn(
'You are using the runtime-only build of Vue where the template ' +
'compiler is not available. Either pre-compile the templates into ' +
'render functions, or use the compiler-included build.',
vm
)
} else {
warn(
'Failed to mount component: template or render function not defined.',
vm
)
}
}
}
callHook(vm, 'beforeMount')
该方法的意义不大,逻辑可以写到 create
钩子函数内,获取 DOM 元素并操作的方法可以写到 mounted
钩子函数内。
等到 DOM 元素渲染完毕,判断通过,才调用 mounted
回调函数。源码如下:
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
当有数据更新时,就会重新渲染,此时会被 Watcher
监听到,调用 beforeUpdate
钩子函数。源码如下:
const watcherOptions: WatcherOptions = {
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}
执行 watcher.before()
方法调用更新,源码如下:
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
组件更新完毕后调用 updated
钩子函数,源码如下:
function callUpdatedHooks(queue: Watcher[]) {
let i = queue.length
while (i--) {
const watcher = queue[i]
const vm = watcher.vm
if (vm && vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'updated')
}
}
}
如果销毁组件,最好在 beforeDestory
钩子函数上移除定时器等,此时数据还是响应式的。清理完之后调用 destoryed
钩子函数,此时数据没有响应式了,但是真实 DOM 还在。源码如下:
Vue.prototype.$destroy = function () {
const vm: Component = this
if (vm._isBeingDestroyed) {
return
}
// 调用销毁前
callHook(vm, 'beforeDestroy')
vm._isBeingDestroyed = true
// 移除 $children
const parent = vm.$parent
if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
remove(parent.$children, vm)
}
vm._scope.stop()
// 清除数据的响应式
if (vm._data.__ob__) {
vm._data.__ob__.vmCount--
}
vm._isDestroyed = true
// 把最新的虚拟节点标记为null
vm.__patch__(vm._vnode, null)
callHook(vm, 'destroyed')
// 关闭所有事件
vm.$off()
if (vm.$el) {
vm.$el.__vue__ = null
}
if (vm.$vnode) {
vm.$vnode.parent = null
}
}
从上所述,一般最多在 mounted
上发请求。代码是同步执行的,请求是异步的,都会等待 mounted
之后才获取数据,因此不差 created
那点时间。
服务端渲染不都是在 created
中(服务端没有 DOM,也没有 mounted
钩子)。在哪发请求主要看需要做什么操作。
mixin原理及作用
通过 Vue.mixin
实现逻辑的复用,代码如下:
const request = () => {}
Vue.mixin({
beforeCreate() {
this.$request = request()
},
beforeDestory() {
// ...
}
})
new Vue({
el: '#app',
components: {
my: {
template: '<button @click="handle"></button>',
methods: {
handle() {
console.log(this.$request)
}
}
}
}
})
虽然实现了复用,但还是有一点问题:
- 数据来源不明确。全局
mixin
每个组件都可调用,项目大时后续的维护会比较诧异变量的来源 - 声明的时候可能会导致命名冲突
在 Vue3 中,采用高阶组件的形式,规避这一缺点。
从源码入手分析,在 src/core/global-api/mixin.js
文件内,源码如下所示:
export function initMixin(Vue: GlobalAPI) {
Vue.mixin = function (mixin: Object) {
this.options = mergeOptions(this.options, mixin)
return this
}
}
这里的 this
是谁调用就指向谁,由于该 mixin
方法是 Vue 调用的,因此 this
实际上是 Vue。最终将 mixin
对象和 Vue.options
合并在一起。
查看 mergeOptions
函数的源码,如下所示:
export function mergeOptions(
parent: Record<string, any>,
child: Record<string, any>,
vm?: Component | null
): ComponentOptions {
if (__DEV__) {
checkComponents(child)
}
// 合并的是一个组件的构造函数
if (isFunction(child)) {
child = child.options
}
normalizeProps(child, vm)
normalizeInject(child, vm)
normalizeDirectives(child)
if (!child._base) {
// 针对组件的 extends 属性来进行合并
if (child.extends) {
parent = mergeOptions(parent, child.extends, vm)
}
// 针对组件内部的 mixins 来进行合并,即组件通过 mixins: [xxx] 来注册局部 mixin
if (child.mixins) {
for (let i = 0, l = child.mixins.length; i < l; i++) {
parent = mergeOptions(parent, child.mixins[i], vm)
}
}
}
const options: ComponentOptions = {} as any
let key
for (key in parent) {
mergeField(key)
}
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
function mergeField(key: any) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
return options
}
总结下来,mixin
核心就是合并属性(内部采用策略模式进行合并),全局 mixin
与局部 mixin
针对不同的属性有不同的合并策略。
data为什么是函数
Vue 内部调用的是 Vue.extend()
方法,会将用户的选项放到子类上。代码如下所示:
const Vue = {}
Vue.extend = function(options) {
function Sub() {
this.data = this.constructor.options.data
}
Sub.options = options
return Sub
}
let Child = Vue.extend({
data: {
a: 1
}
})
let c1 = new Child
let c2 = new Child
console.log(c1.data.a, c2.data.a)
但是如果多次调用 Vue.extend
方法,不可能每次都返回全新的,应该有一个缓存处理。到源码 src/core/global-api/extend.js
文件 20 行处查看,如下:
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
它通过 ID 查找对应的缓存的值,如果有则取缓存的值。没有才赋新值。
现在返回刚刚我们写的代码,此时输出两个构造函数的 a
都是1,但是会有一个问题,他们共享=了同一个数据对象 data
,因此修改后会干扰到其他构造函数内的数据。
修改为函数返回对象的形式,确保每一个构造函数的 data
都是独立的、互不干扰的数据对象,修改不会造成数据干扰。
Vue 对此做了校验处理,如果是 newVue
说明是根组件,可以用对象的形式;如果是组件的形式,则只能是函数的形式。下面看看他的源码:
export function mergeDataOrFn(
parentVal: any,
childVal: any,
vm?: Component
): Function | null {
if (!vm) {
// in a Vue.extend merge, both should be functions
if (!childVal) {
return parentVal
}
if (!parentVal) {
return childVal
}
return function mergedDataFn() {
return mergeData(
isFunction(childVal) ? childVal.call(this, this) : childVal,
isFunction(parentVal) ? parentVal.call(this, this) : parentVal
)
}
} else {
return function mergedInstanceDataFn() {
const instanceData = isFunction(childVal)
? childVal.call(vm, vm)
: childVal
const defaultData = isFunction(parentVal)
? parentVal.call(vm, vm)
: parentVal
if (instanceData) {
return mergeData(instanceData, defaultData)
} else {
return defaultData
}
}
}
}
这是最开始 newVue
时初始化 data
数据的方法,会判断是对象形式还是函数形式。而组件会判断其 data
的数据形式,源码如下:
strats.data = function (
parentVal: any,
childVal: any,
vm?: Component
): Function | null {
if (!vm) {
if (childVal && typeof childVal !== 'function') {
__DEV__ &&
warn(
'The "data" option should be a function ' +
'that returns a per-instance value in component ' +
'definitions.',
vm
)
return parentVal
}
return mergeDataOrFn(parentVal, childVal)
}
return mergeDataOrFn(parentVal, childVal, vm)
}
由此可以总结一下,对于根实例而言,new Vue
组件是通过同一个构造函数多次创建实例,如果是同一个对象的话那么会被互相影响。每个组件的数据源都是独立的,则每次都调用 data
返回一个新的对象。
nextTick实现原理
nextTick
内部采用了异步任务进行包装(多个 nextTick
调用会被合并成一次,内部会合并回调)。最后在异步任务中批次处理。
主要应用场景就是异步更新(默认调度的时候,就会添加一个 nextTick
任务)用户为了获取最终的渲染结果需要在内部任务执行之后再执行,这时用户需要把对应的逻辑放到 nextTick
中。
每次触发更新都会执行 Watcher
类的 update
方法,源码如下:
update() {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
而方法 queueWatcher
则负责过滤同名 watcher
,并将多个渲染 watcher
去重后放到队列中。源码如下:
export function queueWatcher(watcher: Watcher) {
// 负责过滤同名 `watcher`
const id = watcher.id
if (has[id] != null) {
return
}
if (watcher === Dep.target && watcher.noRecurse) {
return
}
has[id] = true
if (!flushing) {
// 多个渲染 `watcher` 去重后放到队列
queue.push(watcher)
} else {
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
if (!waiting) {
waiting = true
if (__DEV__ && !config.async) {
flushSchedulerQueue()
return
}
// 每一次更新视图后内部会开启一个nextTick
nextTick(flushSchedulerQueue)
}
}
前往文件 src/core/util/next-tick.js
文件中,找到 necttick
函数,源码如下:
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
它还对当前浏览器能否使用 Promise
做了判断,如果可以使用则为微任务,不可使用则改为宏任务。源码如下:
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
了解完源码后下方这道题就浅显易懂了,先看代码:
const vm = new Vue({
data() {
return {
a: 1
}
}
})
vm.$nextTick(() => {
console.log(vm.a)
})
vm.a = 100
vm.$nextTick(() => {
console.log(vm.a)
})
根据源码可知,他会开辟一个数组队列,一次把任务推送到数组队列中。首先推送第一次的 nextTick
,然后推送更新操作,最后推送第二次的 nextTick
。
因此先打印1,然后修改值为100,再打印100。
watch与computed
原理
下面来看看 computed
和 watch
的源码。首先来到 src/core/instance/state.js
文件内,找到 initState
函数,里面有计算属性和侦听器的挂载,源码如下:
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
可以看到计算属性的方法是在侦听器前挂载的,因为侦听器能够侦听计算属性变量的变化,因此需要计算属性方法先执行。
接着执行 initComputed
方法,源码如下:
function initComputed(vm: Component, computed: Object) {
const watchers = (vm._computedWatchers = Object.create(null))
const isSSR = isServerRendering()
for (const key in computed) {
const userDef = computed[key]
const getter = isFunction(userDef) ? userDef : userDef.get
if (__DEV__ && getter == null) {
warn(`Getter is missing for computed property "${key}".`, vm)
}
if (!isSSR) {
// create internal watcher for the computed property.
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
}
if (!(key in vm)) {
defineComputed(vm, key, userDef)
} else if (__DEV__) {
if (key in vm.$data) {
warn(`The computed property "${key}" is already defined in data.`, vm)
} else if (vm.$options.props && key in vm.$options.props) {
warn(`The computed property "${key}" is already defined as a prop.`, vm)
} else if (vm.$options.methods && key in vm.$options.methods) {
warn(
`The computed property "${key}" is already defined as a method.`,
vm
)
}
}
}
}
可以看到它首先创建一个空对象 watchers
,用于后续保存每一项计算属性。循环遍历 computed
计算属性对象,判断其类型是函数型还是对象型,获取相对应的值。
紧接着判断其是否是 dirty
,如果是 dirty
说明该属性已经被修改了,走正常流程更新数据;否则值没变化,用之前缓存的数据即可。源码如下:
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
if (__DEV__ && Dep.target.onTrack) {
Dep.target.onTrack({
effect: Dep.target,
target: this,
type: TrackOpTypes.GET,
key
})
}
watcher.depend()
}
return watcher.value
}
}
}
判断当前的变量是计算属性还是 data
内的变量则通过传值处理,如果是计算属性,底层会传递一个 lazy
的参数,判断 lazy
是否为 true
,如果为真说明当前的变量是计算属性。源码如下:
// 为真说明是计算属性,不立即执行;为假说明是data内的数据,调用get方法劫持代理
this.value = this.lazy ? undefined : this.get()
// ...
update() {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
计算属性的底层逻辑梳理了一遍,现在看看侦听器 watch
的底层逻辑。同样在 state.js
文件中,找到 initWatch
方法,源码如下:
function initWatch(vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
function createWatcher(
vm: Component,
expOrFn: string | (() => any),
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}
它也是循环遍历 watch
侦听器对象,如果是数组,循环创建 watcher
;如果是一个则直接创建 watcher
。
把每一项侦听器方法判断其类型,获取到对应的值后最终走 vm.$watch()
方法。方法源码如下:
Vue.prototype.$watch = function (
expOrFn: string | (() => any),
cb: any,
options?: Record<string, any>
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
const info = `callback for immediate watcher "${watcher.expression}"`
pushTarget()
invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
popTarget()
}
return function unwatchFn() {
watcher.teardown()
}
}
可以看到,首先调用 Watcher
类收集依赖,然后判断是否需要立即执行 immediate
,最后返回一个销毁侦听的事件。deep
属性的实现原理源码在 src/core/observe/watcher.js
文件内,源码如下:
if (this.deep) {
traverse(value)
}
通过递归实现深度监听。
异同点
相同点:二者底层都会创建一个 watcher
(用法的区别,computed
定义的属性可以在模板中使用,watch
不能在视图中使用)
不同点:
computed
默认不会立即执行,只有取值的时候才会执行。内部会有唯一一个dirty
属性,来控制依赖的值是否发生变化,默认计算属性需要同步返回结果(有个包就是让computed
变成异步的)watch
默认用户会提供一个回调函数,数据变化了就调用这个回调。通过监控某个数据的变化,数据变化执行某些操作。
set的实现
Vue.set
方法是 Vue 中的一个补丁方法,正常情况下添加属性是不会触发更新的,数组无法监控到索引和长度的变化。
底层给每个对象都添加了一个 Dep 属性,触发更新时会调用 dep.notify()
方法,更新数据,代码如下:
const vm = new Vue({
data() {
return {
obj: {
a: 1
}
}
}
})
vm.obj.b = 100 // js可以获取,视图不会更新
vm.obj.__ob__.dep.notify() // 实现视图更新
因此 Vue.set
方法无非时通过上方的逻辑思路实现的。源码也是这么处理的,如下所示:
export function set(
target: any[] | Record<string, any>,
key: any,
val: any
): any {
if (__DEV__ && (isUndef(target) || isPrimitive(target))) {
warn(
`Cannot set reactive property on undefined, null, or primitive value: ${target}`
)
}
if (isReadonly(target)) {
__DEV__ && warn(`Set operation on key "${key}" failed: target is readonly.`)
return
}
const ob = (target as any).__ob__
// 如果是数组的修改,如 vm.arr[0] = 100 ,修改为 vm.arr.splice(0,1,100) 因为 splice 方法是重写过的了
if (isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
if (ob && !ob.shallow && ob.mock) {
observe(val, false, true)
}
return val
}
// 已有的属性直接修改即可
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
// 不建议重写整个data对象,如 $set(vm, 'data', {}) 因为性能消耗很严重
if ((target as any)._isVue || (ob && ob.vmCount)) {
__DEV__ &&
warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
// 不是响应式数据,直接添加
if (!ob) {
target[key] = val
return val
}
// 变成响应式数据
defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock)
// 触发视图更新
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.ADD,
target: target,
key,
newValue: val,
oldValue: undefined
})
} else {
ob.dep.notify()
}
return val
}
Vue3 不需要此方法了,修改为
proxy
代理对象本身。
虚拟DOM的意义
实际业务中可能会针对不同的平台来使用不同的标签文本(weex
、web
、小程序),虚拟 DOM 可以跨平台,不需要考虑平台问题。
不用关心兼容性,可以在上层将对应的渲染方法传递过来,通知虚拟 DOM 渲染即可。
Diff 算法针对更新的时候,有了虚拟 DOM 之后可以通过 Diff 算法来找到最后的差异进行修改真实 DOM 。(类似于在真实 DOM 之间做了一个缓存)
diff算法
Diff 算法特点就是平级比较,内部采用了双指针方式进行优化,优化了常见的操作。采用了递归比较的方式。
针对一个节点的diff算法
先拿出根节点来进行比较,如果是同一个节点则比较属性,如果不是同一个节点则直接换成最新的即可。
同一个节点比较属性后,复用老节点。
比较儿子
一方有儿子,另一方没有,则作删除或添加的操作。
两方都有儿子:
- 优先比较头头、尾尾、头尾尾头的交叉对比
- 做一个映射表,用心的去映射表中查找此元素是否存在,存在则移动不存在则插入,最后删除多余的
- 会有多移动的情况。O(n) 复杂度的比较
源码如下:
const oldCh = oldVnode.children // 老的儿子
const ch = vnode.children // 新儿子
// 调用一系列的更新方法
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode)
}
// vnode不是文本
if (isUndef(vnode.text)) {
// 两边都有儿子
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (__DEV__) {
checkDuplicateKeys(ch)
}
// 新的有,老的没有,添加节点
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 如果老的有,新的没有,则移除
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 如果老的是文本,则清空文本
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 文本不一致,则设置文本
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode)
}
题外话
既然数据劫持能够保持数据的响应式,那么为什么还需要虚拟 DOM 进行 Diff 检测差异呢?
如果给每一个属性都增加 watcher
,粒度太小不好控制;降低 watcher
数量(每一个组件都有一个 watcher
,一个变量变化更新该组件的视图)。再通过 Diff 算法优化渲染过程,减少视图更新的消耗,通过 Diff 算法和响应式原理折中处理。
key的原理和作用?
方法 isSameVnode
中会根据 key
来判断两个元素是否是同一个元素,key
不同说明不是同一个 元素。key
在动态列表中不要使用索引,使用 key
尽量保证其唯一性,可以优化 diff
算法。
组件化
特点
组件的优点:组件的复用可以根据数据渲染对应的组件,把组件相关的内容放在一起(方便复用)。合理规划组件,可以做到更新的时候是组件级更新(特性、属性、事件、插槽)。
Vue 中怎么样处理组件?
Vue.extend
根据用户传入的对象生成一个组件的构造函数- 根据组件产生对应的虚拟节点
- 做组件初始化,将虚拟节点转化成真实节点(组件的
init
方法)
渲染流程
先写一段组件化的 Vue 代码,如下:
<div id="app">
<my></my>
</div>
<script>
const vm = new Vue({
el: '#app',
// vm.$options.components = {my: 模板}
components: {
my: {
template: '<div>my-component</div>'
}
}
})
</script>
vm.$options.components = {my: 模板}
- 创建对应的虚拟节点,例如
{tag: 'my', data: {}, componentOptions: {Ctor:Vue.extend({my: 模板})}}
- 调用
createComponent
方法函数创建真实节点,然后调用init
方法挂载 vm.$el
插入到父元素中
更新流程
更新的几种情况:
- 父传子数据更新,收集依赖
- 属性更新,给组件传入属性,属性变化后触发更新
- 插槽变化
重点聊聊属性更新,更新逻辑都在 patch
函数中。去到 src/core/vdom/patch.js
文件,找到 patch
函数,部分源码如下:
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
调用 patchVnode
方法判断新旧子节点的异同,如果父传子的数据发生变化,,则调用 prepatch
方法。源码如下所示:
function isDef<T>(v: T): v is NonNullable<T> {
return v !== undefined && v !== null
}
let i
const data = vnode.data
if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
i(oldVnode, vnode)
}
而 prepatch
方法则非常暴力的做一件事情:把旧的实例赋值给新的实例,实例复用了也就意味着 componsnetInstance.$el
也复用了。其本质的作用就是复用组件的实例,并且可以去更新属性、事件、插槽。
复用完之后把对应的孩子、数据、事件等传参调用 updateChildComponent
方法。源码如下:
prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = (vnode.componentInstance = oldVnode.componentInstance)
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
)
},
updateChildComponent
方法主要做以下几件事情:
- 把变量的响应式先去掉,因为属性是在父组件中定义的,传递给子组件。父组件定义的数据已经是响应式了,数据变化会自动更新
- 获取到父传子的数据对象
- 循环遍历,一一校验所传的属性名和值
- 更新完毕后恢复响应式
总结
- 组件更新会触发组件的
prepatch
方法,会复用组件,并且比较组件的属性、事件、插槽- 父组件给子组件传递的属性
prop
是响应式的,模板中使用会做依赖收集,手机自己的组件watcher
- 稍后组件更新了会重新给
props
赋值,赋值完成后会触发watcher
重新更新
异步组件原理
Vue 中异步组件写法有很多,主要用作大的组件可以异步加载比较大的组件,如 markdown
组件、editor
组件等。
先渲染一个注释标签,等组件加载完毕,最后再重新渲染 forceUpdate
(图片懒加载)。使用异步组件会配合 webpack
。
Vue2 官方文档写法如下:
Vue.component('async-example', function(resolve, reject) {
setTimeout(function() {
resolve({
template: `<div>I am async!</div>`
})
}, 1000)
})
可以看到,其参数二不再是一个对象,而是一个工厂函数,收到一个 resolve
回调,也可以调用 reject
表示调用失败。
也可以在工厂函数中返回一个 Promise
,写法如下:
Vue.component('async-example', () => {
component: import('./my-async-example'),
loading: {
template: '<div>loading</div>'
}, // 加载中的组件
error: ErrorComponent, // 加载失败的组件
delay: 200, // 加载时间
timeout: 3000 // 超时时间
})
从源码入手,梳理整个流程。前面介绍到,Vue 创建组件时,会调用 Vue.extend
方法,那是因为底层代码有做了一个类型判断,如果是对象类型,直接调用 extend
方法。但异步组件是函数型,因此不会走这一步。源码如下:
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor as typeof Component)
}
它会继续往下走,判断是否有 cid
,没有 cid
说明是异步组件,调用 resolveAsyncComponent
方法,返回值赋值给 Ctor
。然后再做判断,如果此时 Ctor
还是 undefined
,则调用 createAsyncPlaceholder
方法。源码如下:
if (isUndef(Ctor.cid)) {
asyncFactory = Ctor
Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
if (Ctor === undefined) {
return createAsyncPlaceholder(asyncFactory, data, context, children, tag)
}
}
resolveAsyncComponent
函数源码如下所示:
export function resolveAsyncComponent(
factory: { (...args: any[]): any; [keye: string]: any },
baseCtor: typeof Component
): typeof Component | void {
// 如果factory.error,就直接渲染错误组件
if (isTrue(factory.error) && isDef(factory.errorComp)) {
return factory.errorComp
}
// 如果factory.resolved,就直接渲染成功
if (isDef(factory.resolved)) {
return factory.resolved
}
const owner = currentRenderingInstance
if (owner && isDef(factory.owners) && factory.owners.indexOf(owner) === -1) {
factory.owners.push(owner)
}
// 如果factory.loading,就直接渲染loading组件
if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
return factory.loadingComp
}
if (owner && !isDef(factory.owners)) {
const owners = (factory.owners = [owner])
let sync = true
let timerLoading: number | null = null
let timerTimeout: number | null = null
owner.$on('hook:destroyed', () => remove(owners, owner))
// 强制更新
const forceRender = (renderCompleted: boolean) => {
for (let i = 0, l = owners.length; i < l; i++) {
owners[i].$forceUpdate()
}
if (renderCompleted) {
owners.length = 0
if (timerLoading !== null) {
clearTimeout(timerLoading)
timerLoading = null
}
if (timerTimeout !== null) {
clearTimeout(timerTimeout)
timerTimeout = null
}
}
}
// 传递给用户的resolve
const resolve = once((res: Object | Component) => {
// cache resolved
factory.resolved = ensureCtor(res, baseCtor)
// invoke callbacks only if this is not a synchronous resolve
// (async resolves are shimmed as synchronous during SSR)
if (!sync) {
forceRender(true)
} else {
owners.length = 0
}
})
// 传递给用户的reject
const reject = once(reason => {
__DEV__ &&
warn(
`Failed to resolve async component: ${String(factory)}` +
(reason ? `\nReason: ${reason}` : '')
)
if (isDef(factory.errorComp)) {
factory.error = true
forceRender(true)
}
})
// 让函数执行,返回resolve或reject
const res = factory(resolve, reject)
if (isObject(res)) {
if (isPromise(res)) {
// 是promise就调用promise.then。如果成功则将获取到的结果赋予给 factory.resolved,即对应的组件
if (isUndef(factory.resolved)) {
res.then(resolve, reject)
}
} else if (isPromise(res.component)) {
// 用户自己写的特殊的组件,是一个promise
res.component.then(resolve, reject)
if (isDef(res.error)) {
factory.errorComp = ensureCtor(res.error, baseCtor)
}
if (isDef(res.loading)) {
// 将组件的loading赋予给factory.loadingComp
factory.loadingComp = ensureCtor(res.loading, baseCtor)
if (res.delay === 0) {
factory.loading = true
} else {
// @ts-expect-error NodeJS timeout type
timerLoading = setTimeout(() => {
timerLoading = null
if (isUndef(factory.resolved) && isUndef(factory.error)) {
factory.loading = true
forceRender(false)
}
}, res.delay || 200)
}
}
if (isDef(res.timeout)) {
timerTimeout = setTimeout(() => {
timerTimeout = null
if (isUndef(factory.resolved)) {
reject(__DEV__ ? `timeout (${res.timeout}ms)` : null)
}
}, res.timeout)
}
}
}
sync = false
// 默认返回一个loading
return factory.loading ? factory.loadingComp : factory.resolved
}
}
总结
异步组件默认不会调用
Vue.extend
方法,所有Ctor
上没有cid
属性,没有cid
属性的就是异步组件。先渲染一个占位符组件,但是如果有
loading
会先渲染loading
,第一轮就结束了。如果用户调用了
resolve
,会将结果赋予给factory.resolved
上面,强制重新渲染。重新渲染时候再次进入到resolveAsyncComponent
中,会直接到factory.resolved
结果来渲染。
函数式组件优势及原理
函数组件与类组件相比,没有 this
,没有所谓的状态,没有 sub
实例,没有生命周期 beforeCreate
、created
等等。优点在于性能好,不需要创建 watcher
。
由上方的描述不难看出,函数式组件是单纯的无状态组件,没有其他的操作,因此其性能高。源码如下:
export function createFunctionalComponent(
Ctor: typeof Component,
propsData: Object | undefined,
data: VNodeData,
contextVm: Component,
children?: Array<VNode>
): VNode | Array<VNode> | void {
const options = Ctor.options
const props = {}
const propOptions = options.props
if (isDef(propOptions)) {
for (const key in propOptions) {
props[key] = validateProp(key, propOptions, propsData || emptyObject)
}
} else {
if (isDef(data.attrs)) mergeProps(props, data.attrs)
if (isDef(data.props)) mergeProps(props, data.props)
}
// 创建属性上下文
const renderContext = new FunctionalRenderContext(
data,
props,
children,
contextVm,
Ctor
)
// 函数式组件对应的vnode
const vnode = options.render.call(null, renderContext._c, renderContext)
// 如果符合,直接返回
if (vnode instanceof VNode) {
return cloneAndMarkFunctionalResult(
vnode,
data,
renderContext.parent,
options,
renderContext
)
} else if (isArray(vnode)) {
const vnodes = normalizeChildren(vnode) || []
const res = new Array(vnodes.length)
for (let i = 0; i < vnodes.length; i++) {
res[i] = cloneAndMarkFunctionalResult(
vnodes[i],
data,
renderContext.parent,
options,
renderContext
)
}
return res
}
}
总结
函数式组件就是调用
render
拿到返回结果渲染,所以性能高。
组件传值的方式及之间区别
props
父传子props
会分类,在父组件传递的参数中,只有子组件内props
声明了的才会归为props
一类,剩下的归为$attrs
一类。如下:vue<div id="app"> <son a=1 b=2 c=3 /> </div> <script> new Vue({ el: '#app', components: { son: { props: ['a', 'b'], template: '<div>son</div>' } } }) </script>
上方示例代码中
a
和b
是props
一类,c
为$attes
一类。源码如下所示:jsexport function extractPropsFromVNodeData( data: VNodeData, Ctor: typeof Component, tag?: string ): object | undefined { const propOptions = Ctor.options.props if (isUndef(propOptions)) { return } const res = {} const { attrs, props } = data if (isDef(attrs) || isDef(props)) { for (const key in propOptions) { const altKey = hyphenate(key) if (__DEV__) { const keyInLowerCase = key.toLowerCase() if (key !== keyInLowerCase && attrs && hasOwn(attrs, keyInLowerCase)) { tip( `Prop "${keyInLowerCase}" is passed to component ` + `${formatComponentName( // @ts-expect-error tag is string tag || Ctor )}, but the declared prop name is` + ` "${key}". ` + `Note that HTML attributes are case-insensitive and camelCased ` + `props need to use their kebab-case equivalents when using in-DOM ` + `templates. You should probably use "${altKey}" instead of "${key}".` ) } } checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false) } } return res }
拿到父传子的数据后循环遍历校验(如转为小写后查看是否存在该属性等),然后分类出
props
和attrs
。接着创建好组件的虚拟节点,有一个属性
{componentOptions: {propsData}}
。初始化的时候就要对propsData
做处理,挂载到vm.$options.propsData
。源码如下:jsconst name = getComponentName(Ctor.options) || tag const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, undefined, undefined, undefined, context, { Ctor, propsData, listeners, tag, children }, asyncFactory ) return vnode
初始化属性时会走到
initProps
方法,拿到propsData
,并声明一个vm._props
,该属性类似于vm._data
。声明一个数组存放所有的key
。判断是不是根属性,只有根属性会被进行观测,其他父组件传递给的不需要观测。循环遍历子组件接收父组件的传值
props
对象,把所有的键名push
放到存放key
的数组内,校验值是否合法。源码如下:jsfunction initProps(vm: Component, propsOptions: Object) { const propsData = vm.$options.propsData || {} const props = (vm._props = shallowReactive({})) // keys 数组用于存放 props 对象的键名key const keys: string[] = (vm.$options._propKeys = []) const isRoot = !vm.$parent // 判断是不是根属性 if (!isRoot) { toggleObserving(false) } // 循环遍历propsOptions对象 for (const key in propsOptions) { // 保存键名 keys.push(key) // 校验键值是否合法 const value = validateProp(key, propsOptions, propsData, vm) /* istanbul ignore else */ if (__DEV__) { const hyphenatedKey = hyphenate(key) if ( isReservedAttribute(hyphenatedKey) || config.isReservedAttr(hyphenatedKey) ) { warn( `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`, vm ) } // 把值变为响应式 defineReactive(props, key, value, () => { if (!isRoot && !isUpdatingChildComponent) { warn( `Avoid mutating a prop directly since the value will be ` + `overwritten whenever the parent component re-renders. ` + `Instead, use a data or computed property based on the prop's ` + `value. Prop being mutated: "${key}"`, vm ) } }) } else { defineReactive(props, key, value) } if (!(key in vm)) { proxy(vm, `_props`, key) } } toggleObserving(true) }
校验成功后调用
defineReactive
方法,该方法把该属性转为响应式。源码如下:jsexport function defineReactive( obj: object, key: string, val?: any, customSetter?: Function | null, shallow?: boolean, mock?: boolean ) { const dep = new Dep() // ... Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter() { const value = getter ? getter.call(obj) : val if (Dep.target) { if (__DEV__) { dep.depend({ target: obj, type: TrackOpTypes.GET, key }) } else { dep.depend() } if (childOb) { childOb.dep.depend() if (isArray(value)) { dependArray(value) } } } return isRef(value) && !shallow ? value.value : value }, set: function reactiveSetter(newVal) { const value = getter ? getter.call(obj) : val if (!hasChanged(value, newVal)) { return } if (__DEV__ && customSetter) { customSetter() } if (setter) { setter.call(obj, newVal) } else if (getter) { return } else if (!shallow && isRef(value) && !isRef(newVal)) { value.value = newVal return } else { val = newVal } childOb = !shallow && observe(newVal, false, mock) if (__DEV__) { dep.notify({ type: TriggerOpTypes.SET, target: obj, key, newValue: newVal, oldValue: value }) } else { dep.notify() } } }) return dep }
设为响应式完毕后把所有属性定义到
vm._props
上,并通过映射实现vm.b => vm._props.b
,因此可以通过vm.b
获取props
内b
的变量数据。父组件的值发生了变化,则去更新
vm._props
,然后更新视图。emit
子传父子传父通过自定义事件传参来实现,代码如下:
html<son @xxx="handler" />
该方法实际上是给子组件绑定一个方法,如下:
jsson.$on('xxx', handler)
这是典型的发布订阅模式。源码中会给组件虚拟节点
data
上的on
属性绑定该函数,效果如下图所示:还有另外一种写法:为组件绑定原生事件,在 Vue2 中,组件想要实现原生事件,需要添加
.native
修饰符,代码如下:html<son @click.native="handler" />
底层逻辑会给组件虚拟节点
data
对象中添加一个nativeOn
属性,并绑定相对应的click
函数,如下图所示:ref
获取组件实例通过
this.$refs.xxx
可以获取 DOM 元素和组件实例(虚拟 DOM 没有 DOM 元素,也没有组件实例,因此没有处理ref
)。底层逻辑会走
createPatchFunction
函数,形参接收到的是节点操作和属性操作,如下图所示:里面的属性包含了针对浏览器的属性和虚拟 DOM 内核中定义的逻辑。把
ref
属性保存到data
中,代码与效果如下:jsexport function createPatchFunction(backend) { let i, j const cbs: any = {} // ... const data = vnode.data }
调用
invokeCreateHooks
函数方法,把相应的数据传过去,jsif (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) }
该方法会循环遍历,找到对应有
ref
相关方法的属性,代码如下:jsfunction invokeCreateHooks(vnode, insertedVnodeQueue) { for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, vnode) } i = vnode.data.hook // Reuse variable if (isDef(i)) { if (isDef(i.create)) i.create(emptyNode, vnode) if (isDef(i.insert)) insertedVnodeQueue.push(vnode) } }
ref
对应执行的方法是registerRef
方法,在src/core/vdom/modules/ref.js
文件内,做如下操作:- 取出
ref
- 拿到实例
- 如果是组件,那么
ref
是组件的实例;如果是元素,ref
是真实 DOM - 获取
vm.$refs
- 判断,如果是删除方法,则把它从数组中删除;否则在
refs
对象内把用户设置的实例名称作为对象的键名,对应的 DOM 作为键值
源码如下:
jsexport function registerRef(vnode: VNodeWithData, isRemoval?: boolean) { const ref = vnode.data.ref if (!isDef(ref)) return const vm = vnode.context const refValue = vnode.componentInstance || vnode.elm const value = isRemoval ? null : refValue const $refsValue = isRemoval ? undefined : refValue if (isFunction(ref)) { invokeWithErrorHandling(ref, vm, [value], vm, `template ref function`) return } const isFor = vnode.data.refInFor const _isString = typeof ref === 'string' || typeof ref === 'number' const _isRef = isRef(ref) const refs = vm.$refs if (_isString || _isRef) { if (isFor) { const existing = _isString ? refs[ref] : ref.value if (isRemoval) { isArray(existing) && remove(existing, refValue) } else { if (!isArray(existing)) { if (_isString) { refs[ref] = [refValue] setSetupRef(vm, ref, refs[ref]) } else { ref.value = [refValue] } } else if (!existing.includes(refValue)) { existing.push(refValue) } } } else if (_isString) { if (isRemoval && refs[ref] !== refValue) { return } refs[ref] = $refsValue setSetupRef(vm, ref, value) } else if (_isRef) { if (isRemoval && ref.value !== refValue) { return } ref.value = value } else if (__DEV__) { warn(`Invalid template ref type: ${typeof ref}`) } } }
- 取出
props
总结
子组件更新原理,即父组件的属性修改后会调用子组件的
prepatch
方法,接收到后更新vm._props.变量 = 新值
。组件重新更新时都会执行
prepatch
方法,在更新时如果是开发环境还会把标识变为true
,源码如下:jsif (__DEV__) { isUpdatingChildComponent = true }
在调用
defineReactive
方法时会做判断,如果是开发环境则报错,不允许直接通过props
修改变量,保持单项数据的规范。源码如下:jsdefineReactive(props, key, value, () => { if (!isRoot && !isUpdatingChildComponent) { warn( `Avoid mutating a prop directly since the value will be ` + `overwritten whenever the parent component re-renders. ` + `Instead, use a data or computed property based on the prop's ` + `value. Prop being mutated: "${key}"`, vm ) } })
属性的原理就是把解析后的
props
,验证后就会将属性定义在当前的实例vm._props
上(这个对象上的属性都是通过defineReactive
来定义的响应式数据。组件在渲染的过程中会去vm
上取值,_props
属性会被代理到vm
上)
emit
总结在创建虚拟节点的时候将所有事件绑定到
listeners
,通过$on
方法绑定事件,通过$emit
解绑事件。源码如下:jsVue.prototype.$on = function ( event: string | Array<string>, fn: Function ): Component { // vm._events.xxx = [fn] const vm: Component = this if (isArray(event)) { for (let i = 0, l = event.length; i < l; i++) { vm.$on(event[i], fn) } } else { ;(vm._events[event] || (vm._events[event] = [])).push(fn) if (hookRE.test(event)) { vm._hasHookEvent = true } } return vm } Vue.prototype.$emit = function (event: string): Component { const vm: Component = this // ... let cbs = vm._events[event] if (cbs) { cbs = cbs.length > 1 ? toArray(cbs) : cbs const args = toArray(arguments, 1) const info = `event handler for "${event}"` for (let i = 0, l = cbs.length; i < l; i++) { invokeWithErrorHandling(cbs[i], vm, args, vm, info) } } return vm }
ref
总结创建
ref
时,会将用户所有的 DOM 操作以及属性,都维护到一个cbs
属性中(create
、update
、insert
、destory
等等)。依次调用cbs
中create
方法,这里就包含ref
相关的操作,会操作ref
并且赋值。
provide与inject
provide
就是在当前组件上通过一个 _provide
属性,对应的就是提供的对象。源码在 src/core/instance/inject.js
文件内,代码如下所示:
export function initProvide(vm: Component) {
const provideOption = vm.$options.provide
if (provideOption) {
const provided = isFunction(provideOption)
? provideOption.call(vm)
: provideOption
if (!isObject(provided)) {
return
}
const source = resolveProvided(vm)
// IE9 doesn't support Object.getOwnPropertyDescriptors so we have to
// iterate the keys ourselves.
const keys = hasSymbol ? Reflect.ownKeys(provided) : Object.keys(provided)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
Object.defineProperty(
source,
key,
Object.getOwnPropertyDescriptor(provided, key)!
)
}
}
}
inject
写法为 inject: ['a']
。在底层逻辑中,以以下步骤实现业务功能:
- 在父组件内找到对应的属性
- 把这个属性定义到自己的身上
源码如下所示:
export function initInjections(vm: Component) {
const result = resolveInject(vm.$options.inject, vm)
if (result) {
toggleObserving(false)
Object.keys(result).forEach(key => {
/* istanbul ignore else */
if (__DEV__) {
defineReactive(vm, key, result[key], () => {
warn(
`Avoid mutating an injected value directly since the changes will be ` +
`overwritten whenever the provided component re-renders. ` +
`injection being mutated: "${key}"`,
vm
)
})
} else {
defineReactive(vm, key, result[key])
}
})
toggleObserving(true)
}
}
上述源码中,方法 resolveInject
就是实现查找父组件上的属性,它的步骤为:
- 声明一个空对象
- 拿着
key
,遍历对象一个一个查找 - 将当前实例作为开头,拿到结果保存至
result
,找到一个就停止 - 向上查找父组件
源码如下:
export function resolveInject(
inject: any,
vm: Component
): Record<string, any> | undefined | null {
if (inject) {
// inject is :any because flow is not smart enough to figure out cached
const result = Object.create(null)
const keys = hasSymbol ? Reflect.ownKeys(inject) : Object.keys(inject)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
// #6574 in case the inject object is observed...
if (key === '__ob__') continue
const provideKey = inject[key].from
if (provideKey in vm._provided) {
result[key] = vm._provided[provideKey]
} else if ('default' in inject[key]) {
const provideDefault = inject[key].default
result[key] = isFunction(provideDefault)
? provideDefault.call(vm)
: provideDefault
} else if (__DEV__) {
warn(`Injection "${key as string}" not found`, vm)
}
}
return result
}
}
总结
provide
在父组件中将属性暴露出来,在父组件中提供数据;inject
在后代组件中注入属性,通过递归向上查找。
$attrs与$listeners
在实际业务中,$attrs
可以接收父组件通过 v-bind
传输的所有变量,$listeners
可以接收父组件通过 v-on
传递的所有方法。
从源码下手查看其底层逻辑,在 src/core/instance/render.js
文件中,代码如下:
if (__DEV__) {
defineReactive(
vm,
'$attrs',
(parentData && parentData.attrs) || emptyObject,
() => {
!isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
},
true
)
defineReactive(
vm,
'$listeners',
options._parentListeners || emptyObject,
() => {
!isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
},
true
)
} else {
defineReactive(
vm,
'$attrs',
(parentData && parentData.attrs) || emptyObject,
null,
true
)
defineReactive(
vm,
'$listeners',
options._parentListeners || emptyObject,
null,
true
)
}
源码主要区分了开发环境与生产环境,把从父节点那里接收到的值分别保存。这就是它的底层逻辑。
下面来看实际业务中它是如何使用的。父组件传递参数,子组件接收数据,此时父组件传递的参数会都保存在子组件的根标签。如果想要在二级以下的标签使用,则需要先设置 inheritAttrs
为 false
,然后再需要使用的标签上获取对应的值。代码如下:
<div id="app">
<son :a="1" @click="handler" />
</div>
<script>
new Vue({
el: '#app',
components: {
inheritAttrs: false,
son: {
template: `<div><input v-bind="${$attrs.a}" /></div>`
}
}
})
</script>
组件中 name 属性的好处与作用
在 Vue 中有 name
属性的组件可以被递归调用(在写模板语法的时候,可以通过 name
属性来递归调用自己)。
在声明组件时通过 Sub.options.components[name] = 组件
的形式,把自己的构造函数放上去。
该属性还可以用来标识组件,通过 name
来找到对应的组件,自己封装跨级通信。
父组件调用子组件的方法
思路如下:
- 遍历组件的
name
是否等于需要的name
组件 - 如果相等,则调用其相应的函数方法
代码如下:
jsVue.prototype.$dispatch = function(componentName, data, fnName) { let vm = this while(vm) { if(vm.$options.name === componentName) { vm[fnName](data) break } vm = vm.$parent } }
- 遍历组件的
子组件给父组件传自定义事件
jsVue.prototype.$broadcast = function(conponentName, data, fnName) { let components = [] let vm = this function findChildren(children) { children.forEach(child => { // 找到符合名称的组件 if(child.$options.name === componentName) { components.push(child) } child.$children && findChildren(child.$children) }) } findChildren(vm.$children) // 遍历符合条件的组件数组,依次调用 emit 方法 components.forEach(child => { child.$emit(fnName, data) }) }
name
属性还可以作 devtool
调试工具来标明具体的组件。
v-if、v-for、v-model
v-if和v-for优先级
前往 template
源码翻译的在线网站,输入对应的 template
结构,翻译成源码如下所示:
由此可以推断出 v-for
的优先级高于 v-if
。在编译的时候,会将 v-for
渲染成 _l
函数,v-if
会渲染成三元表达式。
从源码下手,去往 src/compiler/codegen/index.js
文件,该部分的源码如下所示:
else if (el.for && !el.forProcessed) {
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)
}
可以发现,它是先处理 el.for
循环,然后再执行 el.if
判断。
先来看看 v-for
调用的 genFor
方法的源码,代码如下所示:
export function genFor(
el: any,
state: CodegenState,
altGen?: Function,
altHelper?: string
): string {
const exp = el.for
const alias = el.alias
const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''
if (
__DEV__ &&
state.maybeComponent(el) &&
el.tag !== 'slot' &&
el.tag !== 'template' &&
!el.key
) {
state.warn(
`<${el.tag} v-for="${alias} in ${exp}">: component lists rendered with ` +
`v-for should have explicit keys. ` +
`See https://v2.vuejs.org/v2/guide/list.html#key for more info.`,
el.rawAttrsMap['v-for'],
true /* tip */
)
}
el.forProcessed = true // avoid recursion
return (
`${altHelper || '_l'}((${exp}),` +
`function(${alias}${iterator1}${iterator2}){` +
`return ${(altGen || genElement)(el, state)}` +
'})'
)
}
以上方截图为例,可以看到,该方法首先获取到循环的次数 el.for
,然后获取到循环的每一项 i
、每一项的索引 index
、第三个循环参数。
接着判断是否有 key
变量,没有则弹出错误提示,最后转成 AST 树并返回。
el
变量内拥有的属性如下图所示:
接下来看看 v-if
的源码,调用了 genIf
方法,代码如下:
export function genIf(
el: any,
state: CodegenState,
altGen?: Function,
altEmpty?: string
): string {
el.ifProcessed = true // avoid recursion
return genIfConditions(el.ifConditions.slice(), state, altGen, altEmpty)
}
它调用了 genIfConditions
方法,其源码如下:
function genIfConditions(
conditions: ASTIfConditions,
state: CodegenState,
altGen?: Function,
altEmpty?: string
): string {
if (!conditions.length) {
return altEmpty || '_e()'
}
const condition = conditions.shift()!
if (condition.exp) {
return `(${condition.exp})?${genTernaryExp(
condition.block
)}:${genIfConditions(conditions, state, altGen, altEmpty)}`
} else {
return `${genTernaryExp(condition.block)}`
}
// v-if with v-once should generate code like (a)?_m(0):_m(1)
function genTernaryExp(el) {
return altGen
? altGen(el, state)
: el.once
? genOnce(el, state)
: genElement(el, state)
}
}
该方法的本质是截取出判断的条件,再放到 AST 树内,最后返回出去。
v-if和v-show区别
面试时经常会有人问到,v-if
和 v-show
的区别。网上很多回答都是:v-if
控制组件是否创建,v-show
控制组件 display
属性是否显隐。那么,如果 v-show
从 false
改为 true
,<span>
标签的 display
属性会设置为 block
么?我们从源码入手。
从底层逻辑上我们已经知道了 v-if
实际上是通过三元表达式判断它需不需要编译,而 v-show
则是在编译的时候添加一条指令。如下图所示:
接下来看看 v-show
的源码,在 sec/platforms/web/runtime/directives/show.js
文件内,代码如下所示:
bind(el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
vnode = locateNode(vnode)
const transition = vnode.data && vnode.data.transition
const originalDisplay = (el.__vOriginalDisplay =
el.style.display === 'none' ? '' : el.style.display)
if (value && transition) {
vnode.data.show = true
enter(vnode, () => {
el.style.display = originalDisplay
})
} else {
el.style.display = value ? originalDisplay : 'none'
}
},
可以看到,他会先获取到原来标签的 display
属性,保存到 originalDisplay
变量中,后续切换 v-show
的值为真时则拿到 originalDisplay
变量内的 display
属性值。
题外话
为什么用
display
,不用visbility: hidden
或opacity: 0
呢?这两个方法都会占位,可以触发相应的事件。
v-if实现原理
总结一下:v-if
会编译成三元表达式,如果为真则编译渲染,如果为假则不编译。
v-for实现原理
v-for
是通过 _l
AST 语法树来实现的。下面查看一下 _l
底层源码。来到 src/core/instance/render-helpers/index.js
文件中,代码如下所示:
export function installRenderHelpers(target: any) {
target._o = markOnce
target._n = toNumber
target._s = toString
target._l = renderList
target._t = renderSlot
target._q = looseEqual
target._i = looseIndexOf
target._m = renderStatic
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots
target._g = bindObjectListeners
target._d = bindDynamicKeys
target._p = prependModifier
}
可以看到,它每一个 AST 树的语法标识都有对应的方法。下面查看其对应方法 renderList
。代码如下所示:
export function renderList(
val: any,
render: (val: any, keyOrIndex: string | number, index?: number) => VNode
): Array<VNode> | null {
let ret: Array<VNode> | null = null,
i,
l,
keys,
key
if (isArray(val) || typeof val === 'string') {
ret = new Array(val.length)
for (i = 0, l = val.length; i < l; i++) {
ret[i] = render(val[i], i)
}
} else if (typeof val === 'number') {
ret = new Array(val)
for (i = 0; i < val; i++) {
ret[i] = render(i + 1, i)
}
} else if (isObject(val)) {
if (hasSymbol && val[Symbol.iterator]) {
ret = []
const iterator: Iterator<any> = val[Symbol.iterator]()
let result = iterator.next()
while (!result.done) {
ret.push(render(result.value, ret.length))
result = iterator.next()
}
} else {
keys = Object.keys(val)
ret = new Array(keys.length)
for (i = 0, l = keys.length; i < l; i++) {
key = keys[i]
ret[i] = render(val[key], key, i)
}
}
}
if (!isDef(ret)) {
ret = []
}
;(ret as any)._isVList = true
return ret
}
它做的事情很简单,判断循环条件的类型,如果是数值型,直接循环即可;如果是数组型,则循环其长度;如果是对象型,则通过迭代遍历循环。最后返回出去。
v-model实现原理
绑定到普通元素上
v-model
放在不同的元素上会编译出不同的结果,针对文本来说会处理文本,编译成 value
+ input
+ 指令处理。
代码源码翻译图如下所示:
接下来看它的底层逻辑,源码在 src/platforms/web/compiler/directives/model.js
文件中,代码如下所示:
export default function model(
el: ASTElement,
dir: ASTDirective,
_warn: Function
): boolean | undefined {
warn = _warn
const value = dir.value
const modifiers = dir.modifiers
const tag = el.tag
const type = el.attrsMap.type
if (__DEV__) {
// inputs with type="file" are read only and setting the input's
// value will throw an error.
if (tag === 'input' && type === 'file') {
warn(
`<${el.tag} v-model="${value}" type="file">:\n` +
`File inputs are read only. Use a v-on:change listener instead.`,
el.rawAttrsMap['v-model']
)
}
}
if (el.component) {
genComponentModel(el, value, modifiers)
// component v-model doesn't need extra runtime
return false
} else if (tag === 'select') {
genSelect(el, value, modifiers)
} else if (tag === 'input' && type === 'checkbox') {
genCheckboxModel(el, value, modifiers)
} else if (tag === 'input' && type === 'radio') {
genRadioModel(el, value, modifiers)
} else if (tag === 'input' || tag === 'textarea') {
genDefaultModel(el, value, modifiers)
} else if (!config.isReservedTag(tag)) {
genComponentModel(el, value, modifiers)
// component v-model doesn't need extra runtime
return false
} else if (__DEV__) {
warn(
`<${el.tag} v-model="${value}">: ` +
`v-model is not supported on this element type. ` +
"If you are working with contenteditable, it's recommended to " +
'wrap a library dedicated for that purpose inside a custom component.',
el.rawAttrsMap['v-model']
)
}
// ensure runtime directive metadata
return true
}
可以看到,该方法做了以下几件事情:
- 获取
v-model
双向绑定的内容、标签、类型等 - 判断是否是文件上传组件
<input type="file" />
,如果是则返回报错 - 根据
v-model
绑定的 DOM 元素的类型,调用不同的方法 - 如果不是组件、
input
类型,则报错
下拉框、单选框、复选框的事件都是为元素绑定一个 change
事件,这里不做过多赘述,着重看一下 text
文本框的事件处理。源码如下:
const directive = {
inserted(el, binding, vnode, oldVnode) {
if (vnode.tag === 'select') {
// #6903
if (oldVnode.elm && !oldVnode.elm._vOptions) {
mergeVNodeHook(vnode, 'postpatch', () => {
directive.componentUpdated(el, binding, vnode)
})
} else {
setSelected(el, binding, vnode.context)
}
el._vOptions = [].map.call(el.options, getValue)
} else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
el._vModifiers = binding.modifiers
if (!binding.modifiers.lazy) {
el.addEventListener('compositionstart', onCompositionStart)
el.addEventListener('compositionend', onCompositionEnd)
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
el.addEventListener('change', onCompositionEnd)
/* istanbul ignore if */
if (isIE9) {
el.vmodel = true
}
}
}
},
componentUpdated(el, binding, vnode) {
if (vnode.tag === 'select') {
setSelected(el, binding, vnode.context)
// in case the options rendered by v-for have changed,
// it's possible that the value is out-of-sync with the rendered options.
// detect such cases and filter out values that no longer has a matching
// option in the DOM.
const prevOptions = el._vOptions
const curOptions = (el._vOptions = [].map.call(el.options, getValue))
if (curOptions.some((o, i) => !looseEqual(o, prevOptions[i]))) {
// trigger change event if
// no matching option found for at least one value
const needReset = el.multiple
? binding.value.some(v => hasNoMatchingOption(v, curOptions))
: binding.value !== binding.oldValue &&
hasNoMatchingOption(binding.value, curOptions)
if (needReset) {
trigger(el, 'change')
}
}
}
}
}
function onCompositionEnd(e) {
// prevent triggering an input event for no reason
if (!e.target.composing) return
e.target.composing = false
trigger(e.target, 'input')
}
其中,它会监听中文输入完毕的事件,中文输入完毕后,再把标识变为 false
,触发 input
事件,手动更新。
总结一下, value
和 input
实现双向绑定阻止中文的触发,指令的作用就是处理中文输入完毕后手动触发更新。
绑定到组件上
v-model
绑定到组件上会被编译成一个 model
对象,组件在创建虚拟节点的时候会有这个对象。逻辑如下图所示:
可以看出来,它实质上是 value
和 input
的语法糖。
修饰符 .sync 的作用、用法及原理
作用
一个组件如果想要实现多个组件响应式数据,但是在 Vue2 中不允许一个组件使用多个 v-model
,因此推出 .sync
语法糖,实现子组件改变父组件的变量。
在 Vue3 中该修饰符废除。
用法
父组件在给子组件传递参数时使用 .sync
修饰符;子组件给父组件传递自定义事件时传递 update:父组件传递的变量名称, 需要传递的变量
实现子组件改变父组件的变量。
代码如下所示:
<div id="app">
<my :xx.sync="info" />
</div>
<script>
Vue.component('my', {
props: [xx],
template: `<div>{{xx}}<button @click="$emit('update:xx', world)">click me change xx</button></div>`
})
const vm = new Vue({
el: '#app',
data() {
return {
info: 'hello'
}
}
})
</script>
原理
具体原理如下图所示:
绑定一个属性 xx
,并且生成一个事件 update:xx
。
Vue.use 原理
首先需要知道 Vue.use
方法是干嘛用的,该方法主要用于注册插件,其使用方法代码如下所示:
// 写法1
let plugin1 = function(Vue, options) {
console.log(Vue, options)
}
Vue.use(plugin1, {a: 1, b: 2})
// 写法2
let plugin2 = {
install: function(Vue, options) {
console.log(Vue, options)
}
}
Vue.use(plugin2, {a: 1, b: 2})
接受两个参数,参数一是 Vue 实例,参数二是传的数据参数,如下图所示:
其底层逻辑主要做了以下几步的处理:
- 判断缓存中是否有该插件,如果有则直接使用缓存中的插件
- 调用内部的
toArray
方法把传递的参数从索引为1开始提取出来作为一个数组(即不要索引为0的 Vue 参数) - 在该数组中第一项添加
this
- 判断写法,如果是函数写法直接调用,如果是
install
写法则通过apply
把this
指向改为plugin
- 添加到缓存的数组中
- 返回
源码在文件 src/core/global-api/use.js
文件中,代码如下所示:
export function initUse(Vue: GlobalAPI) {
Vue.use = function (plugin: Function | any) {
const installedPlugins =
this._installedPlugins || (this._installedPlugins = [])
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
// additional parameters
const args = toArray(arguments, 1)
args.unshift(this)
if (isFunction(plugin.install)) {
plugin.install.apply(plugin, args)
} else if (isFunction(plugin)) {
plugin.apply(null, args)
}
installedPlugins.push(plugin)
return this
}
}
export function toArray(list: any, start?: number): Array<any> {
start = start || 0
let i = list.length - start
const ret: Array<any> = new Array(i)
while (i--) {
ret[i] = list[i + start]
}
return ret
}
对于第一个参数要传 Vue 实例或许会有疑问,它是为了处理版本问题而存在的。以刚刚的案例为例,假设 plugin
的 Vue 版本是 1.4, 而 Vue.use 的版本是 2.4,此时版本不一致。
因此传 Vue 的目的就是将 Vue 的构造函数传递给插件中,让所有插件依赖的 Vue 是同一个版本。
总结一下:
- use 方法目的是将 Vue 的构造函数传递给插件中,让所有插件依赖的 Vue 是同一个版本
- 默认调用插件或插件的
install
方法 vuex
和vue-router
里package.json
的依赖里面没有 Vue ,是通过参数传进去的
复习一下
源码中
this
的指向是调用了use()
方法的Vue
构造实例。this
的指向分以下几种:
- 对象.xxx() ,this 指代对象
- xxxx() ,this 指代全局
- new Xxx() ,this 指代的是实例或者构造函数的返回结果
- call、bind 改 this 指向
插槽
如何实现
何时使用
普通插槽
普通插槽的使用方式如下所示:
<div id="app">
<my>
<div>
{{msg}}
</div>
</my>
</div>
<script>
new Vue({
el: '#app',
data: {
msg: 'outer'
},
components: {
my: {
data() {
return {
msg: 'inner'
}
},
template: `<div class="my"><slot></slot></my>`
}
}
})
</script>
此时页面显示的字段是 outer
父组件的变量,可以总结出一点:普通插槽渲染数据采用的是父组件。
为什么会产生这种效果呢?下面我们从原理下手。引入第三方库 vue-template-compiler
,调用其 compile
方法查看 template
模板编译后的 render
长什么样。代码如下:
const templateCompiler. = require('vue-template-compiler')
let result = templateCompiler.compile(`<my>
<div>{{msg}}</div>
</my>`)
console.log(result)
最终打印出来的 render
函数为 _c('my', [_c('div', [_v(_s(msg))])])
。此代码会立即执行,因此会使用父组件的 data
内的变量。
接着拿到组件的孩子,也就是插槽,调用 Vnode
构造函数,代码如下:
new Vnode = {
'tag': 'my',
'componentOptions': {
children: {
tag: 'div', 'hello'
}
}
}
注意
组件的孩子叫插槽,元素的孩子就是孩子
然后创建组件的真实节点,走组件的初始化流程,调用 resolveSlots()
方法,把最终返回结果保存到 vm.$slots
上。该方法的源码如下所示:
export function resolveSlots(
children: Array<VNode> | null | undefined,
context: Component | null
): { [key: string]: Array<VNode> } {
// 判断它有没有孩子
if (!children || !children.length) {
return {}
}
const slots: Record<string, any> = {}
// 循环所有孩子
for (let i = 0, l = children.length; i < l; i++) {
// 拿到每个孩子和其属性
const child = children[i]
const data = child.data
// remove slot attribute if the node is resolved as a Vue slot node
if (data && data.attrs && data.attrs.slot) {
delete data.attrs.slot
}
// named slots should only be respected if the vnode was rendered in the. same context.
// 查看孩子的上下文
if (
(child.context === context || child.fnContext === context) &&
data &&
data.slot != null
) {
const name = data.slot
const slot = slots[name] || (slots[name] = [])
if (child.tag === 'template') {
slot.push.apply(slot, child.children || [])
} else {
slot.push(child)
}
} else {
;(slots.default || (slots.default = [])).push(child)
}
}
// ignore slots that contains only whitespace
// 循环遍历,查看当前插槽是否有名字
for (const name in slots) {
if (slots[name].every(isWhitespace)) {
delete slots[name]
}
}
return slots
}
最终返回一个映射关系 this.$slots = {default: [儿子虚拟节点]}
。
在渲染组件真实节点的时候,会采用组件的模板 _t('default')
,源码如下,看看他具体做了什么:
// 传了名字 name 为 default
export function renderSlot(
name: string,
fallbackRender: ((() => Array<VNode>) | Array<VNode>) | null,
props: Record<string, any> | null,
bindObject: object | null
): Array<VNode> | null {
// 查找作用域插槽的名字,默认default
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) {
// scoped slot
props = props || {}
if (bindObject) {
if (__DEV__ && !isObject(bindObject)) {
warn('slot v-bind without argument expects an Object', this)
}
props = extend(extend({}, bindObject), props)
}
nodes =
scopedSlotFn(props) ||
(isFunction(fallbackRender) ? fallbackRender() : fallbackRender)
} else {
nodes =
this.$slots[name] ||
(isFunction(fallbackRender) ? fallbackRender() : fallbackRender)
}
// 判断有没有slot,有则调用方法,没有直接返回node
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
总结下来,就是在渲染组件时关联插槽。渲染到插槽后,会转为 _t('default')
之类的 AST 树,再去到 this.$slots
对象内,通过名称 name
找到对应的变量,即儿子的虚拟节点。
总结一下
在解析组件时,会将组件的
children
放到componentOptions
上作为虚拟节点的属性。将
children
取出来放到组件的vm.$options._renderChildren
中。做出一个映射表放到
vm.$slots
上,将结果放到vm.$scopedSlots
上。vm.$scopedSlots = {a:fn(),b:fn,default:fn()}
。渲染组件的时候会调用
_t
方法。此时会去vm.$scopedSlots
找到对应的函数来渲染内容。
具名插槽
把上方案例修改一下,代码如下所示:
<div id="app">
<my>
<div>
<p slot="a">{{msg}}</p>
<p slot="b">{{msg}}</p>
{{msg}}
</div>
</my>
</div>
<script>
new Vue({
el: '#app',
data: {
msg: 'outer'
},
components: {
my: {
data() {
return {
msg: 'inner'
}
},
template: `<div class="my"><slot name="a"></slot><slot name="b"></slot></my>`
}
}
})
</script>
解析 slot
时,会根据标签的 slot
属性来设置对应的对象键名,没有设置的则放到 default
中,如下图所示:
返回之后在 vm.$scopedSlots
对象内就能拿到对应的函数,如下图所示:
后续再去渲染的话就能找到对应的函数。
总结一下
与普通插槽操作一样,不同之处在于多了一个名称
name
。
作用域插槽
总结一下
作用域插槽渲染的时候不会作为
children
,将作用域插槽做成一个属性scopedSlots
。制作一个映射关系
$scopedSlots = {default:fn:function({msg}) {return _c('div', {}, [_v(_s(msg))])}}
。即把渲染逻辑放到一个函数中。稍后渲染组件模板的时候,会通过
name
找到对应的函数,将数据传入到函数中,此时才渲染虚拟节点,用这个虚拟节点替换_t('default')
。
普通插槽会在父组件内渲染;作用域插槽会在子组件内渲染。
keep-alive 使用与原理
使用
下面看一个场景:
<div id="app">
<button @click="component = 'aa'">
切换组件a
</button>
<button @click="component = 'bb'">
切换组件b
</button>
<keep-alive>
<component :is="component"></component>
</keep-alive>
</div>
<script>
new Vue({
el: '#app',
data: {
component: 'a'
},
components: {
aa: {
template: `<div>a</div>`
},
bb: {
template: `<div>b</div>`
}
}
})
</script>
原理
其源码如下所示:
export default {
name: 'keep-alive', // 组件名
abstract: true, // 抽象组件,即不会被记录到 $children 和 $parent 上
props: {
include: patternTypes, // 可以缓存哪些组件 ['a','b']
exclude: patternTypes, // 可以排除哪些组件 ['c']
max: [String, Number] // 最大缓存个数 6
},
methods: {
cacheVNode() {
const { cache, keys, vnodeToCache, keyToCache } = this
if (vnodeToCache) {
const { tag, componentInstance, componentOptions } = vnodeToCache
// 缓存中,放置需要缓存的组件。存放组件的实例
cache[keyToCache] = {
name: _getComponentName(componentOptions),
tag,
componentInstance // 组件实例渲染过会有 $el 属性,下次可以直接复用 $el 属性
}
keys.push(keyToCache) // 放入key
// 超过最大长度会移出第一个
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
this.vnodeToCache = null
}
}
},
created() {
this.cache = Object.create(null) // 弄一个缓存区 {}
this.keys = [] // 缓存组件的名字有哪些 []
},
destroyed() {
for (const key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys)
}
},
mounted() {
// 缓存实例
this.cacheVNode()
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},
updated() {
this.cacheVNode()
},
// 走完created接下来走render,先渲染才能挂载
render() {
const slot = this.$slots.default // 取得是默认插槽
const vnode = getFirstComponentChild(slot) // 获取插槽中第一个虚拟节点
const componentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// 该方法返回 opts.Ctor.options.name ,获取组件的名字
const name = _getComponentName(componentOptions)
// 校验是否需要缓存
const { include, exclude } = this
// 以下这些情况不复用
if (
// not included 不包含
(include && (!name || !matches(include, name))) ||
// excluded 排除的
(exclude && name && matches(exclude, name))
) {
return vnode
}
// cache是对象;keys是数组
const { cache, keys } = this
// 生成一个唯一的key
const key =
vnode.key == null
? // same constructor may get registered as different local components
// so cid alone is not enough (#3269)
componentOptions.Ctor.cid +
(componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key
// 这个key是否缓存过
if (cache[key]) {
// 获取缓存的实例
vnode.componentInstance = cache[key].componentInstance
// make current key freshest 把当前的key作为最新的(缓存策略)
remove(keys, key)
keys.push(key)
} else {
// 没缓存过的则缓存当前的vnode和key
this.vnodeToCache = vnode
this.keyToCache = key
}
// 给虚拟节点添加 data: keep-alive
vnode.data.keepAlive = true
}
// 最终返回vnode。vnode上有data.keepAlive 和componentInstance,说明组件是缓存过的
return vnode || (slot && slot[0])
}
}
其中组件缓存的思路如下图所示:
有 a、b、c、d 四个组件,最大缓存数是3:
- 先访问 a、b、c 三个组件,依次缓存,最久的是a,最近一次的是c
- 接下来访问d组件,最久的a被销毁,新增d
- 然后再次访问c,则原先的c销毁,往后新增c,此时从旧到近依次为 b、d、c
- 再次访问d,则原先的d销毁,后面新增d
当一个组件被 keep-alive
包裹,说明该组件需要被缓存,则其 data
对象内会有一个 keepAlive
属性为 true
。此时 render
方法执行完毕。
接着走到 mounted
方法,缓存组件与其 $el
属性,如果缓存过,后续则直接获取对应的 $el
属性与 vnode
使用。
最后在创建组件时会判断,如果实例上有老的属性,说明该实例是可复用的,直接将缓存的 dom
元素插入,代码如下:
function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data
if (isDef(i)) {
const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
if (isDef((i = i.hook)) && isDef((i = i.init))) {
i(vnode, false /* hydrating */)
}
if (isDef(vnode.componentInstance)) {
initComponent(vnode, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}
}
}
被 keep-alive
缓存过的组件有两个新的生命周期,源码如下:
insert(vnode: MountedComponentVNode) {
const { context, componentInstance } = vnode
if (!componentInstance._isMounted) {
componentInstance._isMounted = true
callHook(componentInstance, 'mounted')
}
if (vnode.data.keepAlive) {
if (context._isMounted) {
queueActivatedComponent(componentInstance)
} else {
activateChildComponent(componentInstance, true /* direct */)
}
}
},
destroy(vnode: MountedComponentVNode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
// 之前是销毁组件
componentInstance.$destroy()
} else {
// 现在调用deactived钩子
deactivateChildComponent(componentInstance, true /* direct */)
}
}
}
总结一下
keep-alive
的原理是默认缓存加载过的组件对应的实例,内部采用了 LRU 算法。下次组件切换加载的时候,此时会找到对应缓存的节点来进行初始化,但是会采用上次缓存
$el
来触发。(不需要虚拟节点转化成真实节点)更新和销毁会触发
actived
和deactived
。
自定义指令
源码如下:
function _update(oldVnode, vnode) {
const isCreate = oldVnode === emptyNode // 根据虚拟节点来判断是创建还是删除
const isDestroy = vnode === emptyNode
// 拿到老的指令和新的指令
const oldDirs = normalizeDirectives(
oldVnode.data.directives,
oldVnode.context
)
const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
// 将插入的钩子缓存,等待元素插入后调用
const dirsWithInsert: any[] = []
// 缓存componentUpdated
const dirsWithPostpatch: any[] = []
let key, oldDir, dir
for (key in newDirs) {
oldDir = oldDirs[key]
dir = newDirs[key]
if (!oldDir) {
// 如果没有老的指令,调用bind方法
callHook(dir, 'bind', vnode, oldVnode)
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir) // 将instarted钩子存到队列中
}
} else {
// existing directive, update
dir.oldValue = oldDir.value
dir.oldArg = oldDir.arg
callHook(dir, 'update', vnode, oldVnode) // 调用更新
if (dir.def && dir.def.componentUpdated) {
dirsWithPostpatch.push(dir) // 缓存componentUpdated钩子
}
}
}
if (dirsWithInsert.length) {
const callInsert = () => {
for (let i = 0; i < dirsWithInsert.length; i++) {
callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
}
}
if (isCreate) {
mergeVNodeHook(vnode, 'insert', callInsert)
} else {
callInsert()
}
}
if (dirsWithPostpatch.length) {
mergeVNodeHook(vnode, 'postpatch', () => {
for (let i = 0; i < dirsWithPostpatch.length; i++) {
callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
}
})
}
if (!isCreate) {
// 删除的话调用unbind方法
for (key in oldDirs) {
if (!newDirs[key]) {
// no longer present, unbind
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
}
}
}
}
总结一下
自定义指令就是用户定义好对应的钩子,当元素在不同的状态时会调用对应的钩子(所有钩子会被合并到
cbs
对应的方法上,到时候依次调用)
事件修饰符
修饰符实现主要靠的是模板编译原理,如 .stop
、.prevent
等为 addEventListener( stop, defaultPrevent )
等。编译的时候直接编译到事件内部中。
而 .passive
、.capture
、one
编译的时候会添加标识符。
键盘事件通过源码翻译如下图所示:
可以看出它在编译的时候做了处理,源码如下所示:
const keyCodes: { [key: string]: number | Array<number> } = {
esc: 27,
tab: 9,
enter: 13,
space: 32,
up: 38,
left: 37,
right: 39,
down: 40,
delete: [8, 46]
}
// KeyboardEvent.key aliases
const keyNames: { [key: string]: string | Array<string> } = {
// #7880: IE11 and Edge use `Esc` for Escape key name.
esc: ['Esc', 'Escape'],
tab: 'Tab',
enter: 'Enter',
// #9112: IE11 uses `Spacebar` for Space key name.
space: [' ', 'Spacebar'],
// #7806: IE11 uses key names without `Arrow` prefix for arrow keys.
up: ['Up', 'ArrowUp'],
left: ['Left', 'ArrowLeft'],
right: ['Right', 'ArrowRight'],
down: ['Down', 'ArrowDown'],
// #9112: IE11 uses `Del` for Delete key name.
delete: ['Backspace', 'Delete', 'Del']
}
// #4868: modifiers that prevent the execution of the listener
// need to explicitly return null so that we can determine whether to remove
// the listener for .once
const genGuard = condition => `if(${condition})return null;`
const modifierCode: { [key: string]: string } = {
stop: '$event.stopPropagation();',
prevent: '$event.preventDefault();',
self: genGuard(`$event.target !== $event.currentTarget`),
ctrl: genGuard(`!$event.ctrlKey`),
shift: genGuard(`!$event.shiftKey`),
alt: genGuard(`!$event.altKey`),
meta: genGuard(`!$event.metaKey`),
left: genGuard(`'button' in $event && $event.button !== 0`),
middle: genGuard(`'button' in $event && $event.button !== 1`),
right: genGuard(`'button' in $event && $event.button !== 2`)
}
阅读方法
如果是初次阅读,则先了解其目录结构,知道哪个文件夹主要负责什么功能。
然后通过 debugger
打断点,了解API 执行时走了哪些方法。