前言
付出时间和汗水, 其余交给天意。
现在笑的有多欢,未来就有多惨。
时刻保持紧张和焦虑未必是件坏事。
在这场血雨腥风的战场上,我相信会有曙光降临!
长文预警 , 建议掘友们去github
Download一份源码,建议边review代码边阅读
直接进入主题~
1. Vue中的模板编译是如何实现的?
这个问题主要考察的Vue编译器的理解
大致流程如下:
模板字符串 -> 解析器 -> AST -> 优化器 -> AST -> 代码生成器 -> 渲染函数
从开始的模板字符串到渲染函数中间经历了什么?
platforms有多个入口文件,这里从entry-runtime-with-compiler.js
=>vue.js
分析
这里必须抛开VueCli脚手架,首先分析模板字符串:
vue
<div id="app">
***
</span>
</div>
var app = new Vue({
el: '#app',
})
________________________________
Vue.component('comp', {
template: '<li>test</li>'
})
在entry-runtime-with-compiler.js
文件中$options.template
或者query(el)
获取模板字符串和DOM对象。
到解析器阶段, 它的作用就是将模板字符串解析为AST。
这一切的起点都从createCompiler
函数开始:
js
function createCompilerCreator(baseCompile) {
return function createCompiler(baseOptions) {
function compile(template, options) {
// ...
}
return {
compile,
compileToFunctions
};
};
}
createCompilerCreator
利用了柯里化函数的,灵活传递baseOptions
参数,同时将编译过程compile
函数抽离。
entry-runtime-with-compiler.js
调用compileToFunctions最终执行compile(template, options)
同时调用baseCompile
函数,此时进入template解析阶段即parse
函数,其最终返回为AST语法树。
js
export function parseHTML (html, options) {
const stack = []
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no
let index = 0
let last, lastTag
while (html) {...}
parseEndTag()
function advance (n) {...}
function parseStartTag () {...}
function handleStartTag (match) {...}
function parseEndTag (tagName, start, end) {...}
}
最终parseHTML
函数来解析模板字符串,感觉面试官不会问具体解析细节,如果问可以这样回答,
html
在解析过程中,每当解析到一个开始标签时,就会将该标签压入一个栈中,同时创建一个 AST 元素节点,并将该节点作为当前节点的子节点;
每当解析到一个结束标签时,就会将该标签与栈顶的开始标签匹配,然后将栈顶的开始标签与对应的 AST 元素节点从栈中弹出,并将当前节点设置为栈顶元素的父节点。
当解析完成后,就会返回生成的 AST。
{
tag: 'div', // 标签名
data: {}, // 节点的属性和指令
children: [], // 子节点数组
text: '', // 文本内容
elm: null, // 关联的真实 DOM 元素
context: null, // 组件实例
key: undefined // 唯一键值
...
}
接下来到优化器阶段 ,回到baseCompile
函数,进入到optimize
函数,该优化过程包含两个部静态节点标记、静态属性提升
js
markStatic(root)
markStaticRoots(root, false)
markStatic(1)
遍历AST节点判断是否为静态节点,通过递归调用的形式进行标记。这样有助于提高组件的渲染性能,因为它们的值在渲染过程中不会发生变化,可以被视为常量,从而减少了对虚拟 DOM 的比对和重新渲染的开销,提升了性能。
markStaticRoots(2)
静态属性指的是不需要在渲染过程中改变的属性,通过递归调用的形式遍历AST判断当前节点是否为静态节点,如果是则将其所有静态属性提取出来,生成一个单独的对象。这样可以避免在后续更新过程中重新生成这些属性,提高性能。
可以这样回答:
html
optimize,主要是从静态节点标记和静态属性提升两方面优化,将(1),(2)合并
接着到代码生成器 ,继续回到createCompiler函数中,进入到generate
函数。在上面的优化阶段完成之后,该函数会将AST转换为可执行的渲染函数render。
js
return {
render: `with(this){return ${code}}`, //`render: with(this) { return _c('div', null, message); }`
staticRenderFns: state.staticRenderFns
}
最终返回虚拟DOM(VNode)
当然具体细节还有很多staticRenderFns函数主要是生成的静态渲染函数数组。它的作用是存储了所有静态节点的渲染函数。
具体处理细节过了
可以这样回答:
html
生成器将AST转化为可执行的渲染函数的过程。这个过程中,Vue使用了渲染函数生成器。
渲染函数生成器会根据AST节点的类型和属性生成相应的JavaScript代码。
最终,将所有生成的代码组合成一个完整的渲染函数。
2. Vue中的组件是如何实现的?
Vue注册组件有两种方式,一种是全局组件Vue.component
实现,另一种是局部组件 components:{}
以components
为例,他们默认添加到options.components
。
问题来了,Vue中是在哪一步编译组件的呢?上题提到过在编译器阶段会解析模板字符串,当遇到组件标签的时候会生成组件的AST=>Vnode,具体实现是在Vnode => DOM阶段。
回顾一下基础API
js
render: function (createElement) {
return createElement('div', [
...
])
}
render函数的作用是生成Vnode,然而内部实际上是createElement
函数(src\core\vdom\create-element.js)(h , this.$createElement)
当解析到$options.componets
时会调用生成组件的方法:
js
if (
(!data || !data.pre) &&
isDef((Ctor = resolveAsset(context.$options, "components", tag)))
) {
vnode = createComponent(Ctor, data, context, children, tag);
}
接着调用createComponent
函数创建组件。每次创建组件的时候,会调用Vue.extend
函数,然后调用Vue实例的_init
方法进行初始化,将各种属性和生命周期等添加到组件实例中,即一个组件就是一个Vue实例。 补充:_init
会对组件进行内部优化,将Vue实例添加到所有子组件实例的$root
属性中。
最终返回组件的Vnode。
其实重点就是 createElement => createComponent => Vue.extend => _init => Ctor:Class<Component> | Function | Object | void
3.Vue的虚拟DOM是什么?请说明虚拟DOM的实现原理。
虚拟DOM(Virtual DOM)是Vue中一种用于高效更新和渲染UI的机制,上面多次提到过VNode虚拟节点,虚拟DOM是通过VNode来表示真实DOM,通过对比新旧VNode来进行DOM更新,以减少对实际DOM的直接操作次数,提高性能和更新效率。
虚拟DOM是一种思想。
这个问题考察的是AST => VNode => diff => DOM
虚拟DOM树的结构与实际的DOM树相似,它由一系列的JavaScript对象(VNode)组成,每个VNode对象代表一个DOM节点。
当组件的状态发生变化时,Vue会生成一个新的虚拟DOM树,与旧的虚拟DOM树进行比较。通过Diff算法对比新旧虚拟DOM树的差异,对比其属性、子节点等信息,找出需要进行更新的部分。
当找到需要更新的节点后,Vue会根据差异信息进行相应的DOM操作,如创建新节点、更新属性、删除节点等。 更新实际DOM时,Vue会尽量进行批量操作,以减少对实际DOM的直接操作次数,提高性能。
面试官:那说一说Diff算法吧!
Vue2中采用的是双端(双指针)Diff算法。
从基础的Diff算法到双端Diff算法进行分析:
千万不要搞混,path是对Vnode节点进行进行更新,而diff是在比较新旧节点中何时触发path逻辑,它对应的源码函数是updateChildren
中间涉及到一个问题: **添加key真的可以提升性能吗? **
源码位置: \src\core\vdom\patch.js
基础Diff算法
js
//测试数据
const oldVnode = {
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: '3', key: 3 },
],
};
const newVnode = {
type: 'div',
children: [
{ type: 'p', children: '4', key: 3 },
{ type: 'p', children: '5', key: 1 },
{ type: 'p', children: '6', key: 2 },
],
};
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
//省略代码
} else if (Array.isArray(n2.children)) {
//重新实现两组子节点的更新方式
//新旧 children
const oldChildren = n1.children;
const newChildren = n2.children;
//用来存储寻找过程中遇到的最大索引值
let lastIndex = 0;
//遍历新的children
for (let i = 0; i < newChildren.length; i++) {
const newVnode = newChildren[i];
//在第一层循环中,定义变量find, 代表是否在旧的一组子节点中找到可复用的节点
//初始值为false, 代表没找到
let find = false;
//遍历旧的children
for (let j = 0; j < oldChildren.length; j++) {
const oldVnode = oldChildren[j];
//如果找到了具有相同的key值的两个节点,说明可以复用,但仍然需要调用patch函数更新
if (newVnode.key === oldVnode.key) {
//一旦找到可复用的节点,则将变量find的值设为true
find = true;
patch(oldVnode, newVnode, container);
//在旧children中寻找具有相同key值的节点过程中,遇到的最大索引值。
//如果 在后续寻找的过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动
if (j < lastIndex) {
//如果当前找到的节点在旧Children中的索引小于最大索引值 lastIndex,
//说明该节点对应的真是DOM需要移动
//先获取newVnode的前一个vnode 即 prvVnode
const prevVnode = newChildren[i - 1];
//如果prevVnode不存在说明当前Vnode是第一个节点不需要移动
if (prevVnode) {
//由于我们需要建newVnode对应的真是DOM移动到prevVNode所对应的真是DOM后面,
//所以我们需要获取prevNode对应的真是DOM的下一个兄弟节点,并将其作为描点
const anchor = prevVnode.el.nextSibling;
//调用insert方法将newVnode对应的真是DOM插入到描点元素前面, 也就是prevVnode对应的真是DOM后面
insert(newVnode.el, container, anchor);
}
} else {
//如果当前找到的节点在旧children中的索引不小于最大索引值
//则更新lastIndex的值
lastIndex = j;
}
break; //这里需要break?
}
}
//如果代码运行到这里,find 仍然为 false
//说明当前,newVnode没有在旧的一组子节点找到可复用的节点
//也就是说当前newVnode是新增节点,需要挂载
if (!find) {
const prevVnode = newChildren[i - 1];
let anchor = null;
if (prevVnode) {
//如果有前一个vNode节点,则使用它的下一个兄弟节点作为描点元素
anchor = prevVnode.el.nextSibling;
} else {
anchor = container.firstChild;
}
patch(null, newVnode, container, anchor);
}
//移除不存在的元素 , 上面更新完毕后,再次遍历旧的节点
for (let i = 0; i < oldChildren.length; i++) {
const oldVnode = oldChildren[i];
const has = newChildren.find((vnode) => vnode.key === oldVnode.key);
if (!has) {
//如果没找到 需要删除
unmount(oldVnode)
}
}
}
} else {
//省略代码
}
}
// 移动节点指的是,移动一个虚拟节点所对应的真实的DOM节点,并不是移动虚拟节点本身
function patchElement(n1, n2) {
//新的vnode也引用了真是的DOM元素
const el = (n2.el = n1.el);
}
function patch(n1, n2, container, anchor) {}
快速diff, 通过定义lastIndex索引, 来判断Key值相同的Vnode节点是否移动。如果新节点中某个节点在旧节点中没有匹配key值相同的节点, 则说明这个节点是新增节点。最后再次遍历旧节点,如果当前旧子节点在新节点中不存在(key不匹配)则卸载此旧节点
双端Diff算法(Vue2)
js
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
//省略代码
} else if (Array.isArray(n2.children)) {
//封装函数 patchKeyedArray 函数处理两组子节点
} else {
//省略代码
}
}
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children;
const newChildren = n2.children;
//四个索引值
let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;
//四个索引值对应的vnode节点
let oldStartVnode = oldChildren[oldStartIdx];
let oldEndVnode = oldChildren[oldEndIdx];
let newStartVnode = newChildren[newStartIdx];
let newEndVnode = newChildren[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
//增加两个判断分支,如果头尾部为undefined, 则说明该节点被处理过,直接跳到下一个位置
if (!oldStartVnode) {
oldStartVnode = oldChildren[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldChildren[--oldEndVnode];
} else if (oldStartVnode.key === newStartVnode.key) {
//第一步
//只需要补丁
patch(oldStartVnode, newStartVnode, container);
oldStartVnode = oldChildren[++oldStartIdx];
newStartVnode = newChildren[++newStartIdx];
} else if (oldStartVnode.key === newEndVnode.key) {
//第二步
//调用 patch函数 在oldstartVnode 和 newStartVnode 之间打补丁
patch(oldStartVnode, newEndVnode, container);
insert(oldStartVnode.el, container, oldEndVnode.el.nextSibling);
oldStartVnode = oldChildren[++oldStartIdx];
newEndVnode = newChildren[--newEndIdx];
} else if (oldEndVnode.key === newEndVnode.key) {
//第三步
//节点在新的顺序中仍然处于尾部,不需要移动,但需要打补丁
patch(oldEndVnode, newEndVnode, container);
//更新索引和头尾节点变量
oldEndVnode = oldChildren[--oldEndIdx];
newEndVnode = newChildren[--newEndIdx];
} else if (oldEndVnode.key === newStartVnode.key) {
//第四步
//首先需要调用patch函数进行补丁
patch(oldEndVnode, newStartVnode, container);
//移动DOM操作
// oldEndVnode.el 移动到oldStartVnode 前面
insert(oldEndVnode.el, container, oldStartVnode);
oldEndVnode = oldChildren[--oldEndIdx];
newStartVnode = newChildren[++newStartIdx];
} else {
//遍历旧的一组子节点,视图寻找于newStartVnode拥有相同Key值的节点
//idxInOld 就是新的一组子节点的头部节点在旧的一组节点中的索引
const idxInOld = oldChildren.findIndex(
(node) => node.key === newStartVnode.key,
);
//如果idxInOld大于0 说明已经找到了可以复用的节点, 并且需要将其对应的DOM移动到头部
if (indxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld];
//不要忘记移除移动操作外还应该打补丁
patch(vnodeToMove, newStartVnode, container);
//将vnodetoMove。el 移动到头部节点oldStartVnode.el之前 因此需要后者作为描点
insert(vnodeToMove.el, container, oldStartVnode.el);
oldChildren[idxInOld] = undefined;
// //更新 newStartIdx到下一个位置
// newStartVnode = newChildren[++newStartIdx]
} else {
//添加元素
// 将newStartVnode作为新的节点挂载到头部,使用当前头部节点oldStartVnode.el作为描点
patch(null, newStartVnode, container, oldStartVnode.el);
}
//更新 newStartIdx到下一个位置
newStartVnode = newChildren[++newStartIdx];
}
}
//添加新节点
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
//如果满足条件说明有新增的节点遗漏,需要挂载他们
for (let i = newStartIdx; i < newEndIdx; i++) {
patch(null, newChildren[i], container, oldStartVnode.el);
}
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
//删除操作
for (let i = oldStartIdx; i < oldEndIdx; i++) {
unmount(oldChildren[i]);
}
}
}
所谓的双端Diff,无非就是定义双指针,通过while循环对新旧节点开始/结束标签key值判断,进行path。
同理通过判断新旧节点的Idx进行新增和删除操作。
快速diff算法
js
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children;
const oldChildren = n1.children;
//处理相同的前置节点
//索引 j 指向新旧两组子节点的开头
let j = 0;
let oldVnode = oldChildren[j];
let newVnode = newChildren[j];
//while循环向后遍历, 直到遇到拥有不同key值的节点为止
while (oldVnode.key === newVnode.key) {
//调用patch进行更新
patch(oldVnode, newVnode, container);
//更新J ,让其递增
j++;
oldVnode = oldChildren[j];
newVnode = newChildren[j];
}
//更新相同的后置节点
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
oldVnode = oldChildren[oldEnd];
newVnode = newChildren[newEnd];
//while 循环从后往前遍历, 直到遇到不同key值为止
while (oldVnode.key === newVnode.key) {
//调用patch进行更新
patch(oldVnode, newVnode, container);
//递减
oldEnd--;
newEnd--;
oldVnode = oldChildren[oldEnd];
newVnode = newChildren[newEnd];
}
//预处理完毕之后, 如果满足如下条件, 则说明 j --> nextEnd之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
//描点的索引
const anchorIndex = newEnd + 1;
const anchor =
anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null;
//采用 while循环,调用patch函数 逐个挂载新增节点
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor);
}
} else if (j > newEnd && j <= oldEnd) {
// j => oldEnd之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++]);
}
} else {
//处理 非理想情况
// 构建 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1);
//oldStart newStart 分别为其实索引,即 j
const oldStart = j;
const newStart = j;
//新增两个变量
let moved = false;
let pos = 0; // 遍历过程中遇到的最大索引值key
// 构建索引表
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
//新增 patched 变量,代表更新过的节点数量
let patched = 0;
//遍历旧的子节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVnode = oldChildren[i];
if (patched <= count) {
//通过索引表快速找到新的一组子节点具有相同key值的节点位置
const k = keyIndex[oldVnode.key];
if (typeof k !== 'undefined') {
const newVnode = newChildren[k];
patch(oldVnode, newVnode, container);
//没更新一个节点 都将patched 变量 + 1
patched++;
source[k - newStart] = i;
//判断节点是否需要移动
if (k < pos) {
moved = true;
} else {
pos = k;
}
} else {
//没找到
unmount(oldVnode);
}
} else {
//如果更新过的节点数量 大于需要更新的节点数量 则卸载多余的节点
unmount(oldVnode);
}
}
if (moved) {
//如果moved 为true 则需要移动dom
const seq = lis(source);
//S 指向 最长递归子序列的最后一个元素
let s = seq.length - 1;
//i 指向新的一个子节点的最后一个元素
let i = count - 1;
//for 循环使得I递减,
for (i; i >= 0; i--) {
if (source[i] === -1) {
//说明索引为I的节点为全新的节点, 应该将其挂载
//获取该节点在新的children中的真实位置索引
const pos = i + newStart;
const newVnode = newChildren[pos];
//该节点的下一个节点的位置索引
const nextPos = pos + 1;
//描点
const anchor =
nextPos < newChildren.length ? newChildren[nextPos].el : null;
//挂载
putch(null, newVnode, container, anchor);
} else if (i !== seq[s]) {
//如果节点的索引 i,不等于seq[s] 的值,说明该节点需要移动
// 获取新的节点的真实位置
const pos = i+newStart
const newVnode = newChildren[pos]
//该节点的下一个节点的位置索引
const nextPos = pos + 1
//描点
const anchor = nextPos < newChildren.length?newChildren[nextPos].el : null
// 移动
insert(newVnode.el,container,anchor)
} else {
//如果节点的索引 i,等于seq[s] 的值,说明该节点不需要移动
//只需要让S指向下一个指针
s--;
}
}
}
}
}
快速diff算法与双端diff算法不同之处是, 快速diff对相同的前置/后置节点进行了path处理。定义标识J和新旧节点长度判断新增/删除节点,对节点中剩余未处理节点计算出最长递归地序列辅助完成path操作。
4.Vue的虚拟DOM是什么
Virtual DOM 是一棵以 JavaScript 对象作为基础的树,每一个节点称为 VNode。虚拟DOM的主要思想是通过在内存中维护一个虚拟的DOM树,将对真实DOM的操作转化为对虚拟DOM的操作,然后再将虚拟DOM的变化映射到真实DOM上,从而减少对真实DOM的直接操作,提高性能和渲染效率。
虚拟DOM贯穿整个框架 , 更多体现在Vnode , 一个Vnode节点(VNode类:\src\core\vdom\vnode.js
)包含很多属性。
工作流程如下:
-
创建初始虚拟DOM:将模板解析成初始的虚拟DOM树。
-
数据更新:当应用状态发生改变,组件会重新渲染。
-
创建新虚拟DOM:基于新的状态,创建一个新的虚拟DOM树。
-
比较新旧虚拟DOM树:通过Diff算法,对比新旧虚拟DOM树的差异。
-
生成DOM操作指令:根据差异,生成一系列DOM操作指令,这些指令描述了如何更新真实DOM。
-
批量更新:将DOM操作指令批量应用到真实DOM上,这样可以减少DOM操作的次数。
-
视图更新:真实DOM被更新,用户可以看到更新后的视图。
5.Vue的事件机制是如何实现的?
可以转换一下这个问题: EventBus是如何实现的?这是一道经典的手写题
EventBus不是Vue特有的 , 在Vue3中作者移除了相关API , 大概也是这个原因吧~
js
class EventBus {
constructor() {
// 存储事件和对应的回调函数
this.events = {};
}
// 监听事件
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
}
// 触发事件
emit(eventName, data) {
const eventCallbacks = this.events[eventName];
if (eventCallbacks) {
eventCallbacks.forEach(callback => {
callback(data);
});
}
}
// 取消监听事件
off(eventName, callback) {
const eventCallbacks = this.events[eventName];
if (eventCallbacks) {
if (!callback) {
// 没有提供回调函数,取消该事件的所有监听
delete this.events[eventName];
} else {
// 取消特定回调函数的监听
const index = eventCallbacks.indexOf(callback);
if (index !== -1) {
eventCallbacks.splice(index, 1);
}
}
}
}
}
// 创建一个EventBus实例
const eventBus = new EventBus();
// 监听事件
eventBus.on('custom-event', (data) => {
console.log('Custom Event Received:', data);
});
// 触发事件
eventBus.emit('custom-event', 'Hello from EventBus!');
// 取消事件监听
const callback = (data) => {
console.log('Callback:', data);
};
eventBus.on('another-event', callback);
eventBus.emit('another-event', 'This should be logged.');
eventBus.off('another-event', callback);
eventBus.emit('another-event', 'This should not be logged.');
当前可以将events转换为Map数据结构
6.Vue中的异步更新是如何实现的?
这个问题第一时间想到了nextTick , 可以看看之前写的一篇文章关于nextTick的理解 , 21年初写的😝。
回到源码\src\core\observer\scheduler.js 和 \src\core\observer\watcher.js
,重点关注 queueWatcher
、flushSchedulerQueue
和update
三个函数。
当侦听的数据发生改变的时候,Dep会通知Watcher对象update,执行queueWatcher函数,它的作用就是将对象放入到queue中。
需要注意的是Watcher中的id是number类型,watcher.id非常重要, 它确定了在queue中的所在位置,以及后续的升序排列, 确保了父子组件更新顺序(父组件永远先于子组件更新)等。然后依次执行队列中watcher。
js
export default class Watcher {
...
id: number;
queueWatcher
还有一个waiting标识,它避免了重复更新的操作,优化性能,当前循环更新完毕后,重置参数resetSchedulerState,开启下一事件循环。
需要注意的是,循环执行中由于新的
watcher
被不断添加到队列中,同步更新即调用flushSchedulerQueue函数会开启一个事件循环,所有的watcher操作,都会在当前循环中进行。 而异步更新nextTick,会把新的watcher,放入到下一队列中执行。 同步更新可能会导致频繁的计算和界面重绘,影响页面的响应性和性能,甚至阻塞事件循环或者更新顺序的问题,而异步更新解决了这些问题
js
...
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
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 (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
...
function resetSchedulerState () {
index = queue.length = activatedChildren.length = 0
has = {}
if (process.env.NODE_ENV !== 'production') {
circular = {}
}
waiting = flushing = false
}
当然生产环境中用的是nextTick更新的, 后续在第16节进行详细解释。
7.Vue中的指令和过滤器是如何实现的?
无非说的就是V-***
和filters
对于指令语法,肯定是解析模板时,识别到特定的 v- 的语法,并在解析过程生成对应的操作。
在这里列举一下常用的 v-if 和 v-bind 操作:
由于我们确认是发生在模板解析阶段, 所以我们直接来到源码\src\compiler\parser\index.js
,找到 parse 函数。它会将AST对象匹配if、else 、elseif , 按照指定条件需要渲染的代码"片段"添加到数组Conditions Array中。最后在代码生成器generate
中,通过递归取出数组中每一项condition AST对象解析,最终返回render code。
v-bind
解析阶段和if
有些不同:
js
export function getRawBindingAttr (
el: ASTElement,
name: string
) {
return el.rawAttrsMap[':' + name] ||
el.rawAttrsMap['v-bind:' + name] ||
el.rawAttrsMap[name]
}
反过来思考, 这段代码整合是对应v-bind的编写方式 thundetchen="clearlove" thundetchen="77777" thundetchen
。
那么如何做到响应式的呢?
不过只要了解过Vue的响应式系统, 基本上就能反推指令属性值响应式更新实现原理。
processAttrs
函数部分代码
js
function(el) {
const list = el.attrsList
...
name = rawName = list[i].name
value = list[i].value
if (bindRE.test(name)) { // v-bind
name = name.replace(bindRE, '')
value = parseFilters(value)
isDynamic = dynamicArgRE.test(name)
if (isDynamic) {
...
}
if (modifiers) {
...
}
if ((modifiers && modifiers.prop) || (
!el.component && platformMustUseProp(el.tag, el.attrsMap.type, name)
)) {
...
} else {
addAttr(el, name, value, list[i], isDynamic)
}
}
name | value
分别代表v-bind
对应的属性名和属性值 list[i]
代表指令名称与属性值的数组对象:
js
[
{ name: 'v-bind:id', value: 'dataId' },
{ name: 'v-bind:class.prop', value: 'dataClass' },
// ...
]
重点是addAttr
函数用来将属性添加到 AST 元素节点中的。这个函数的作用是将属性添加到 AST 节点的属性数组中,以便后续的编译过程可以获取这些属性信息,并进行相应的处理。这个函数的关键在于将属性添加到正确的数组中,以及设置一些标志,例如 el.plain
,用来表示这个元素是否是一个纯静态元素,可以进行优化。
js
export function addAttr (el: ASTElement, name: string, value: any, range?: Range, dynamic?: boolean) {
const attrs = dynamic
? (el.dynamicAttrs || (el.dynamicAttrs = []))
: (el.attrs || (el.attrs = []))
attrs.push(rangeSetItem({ name, value, dynamic }, range))
el.plain = false
}
根据 dynamic
参数的值,addAttr
会将属性添加到不同的属性数组中。如果属性是动态绑定的,会将属性添加到 el.dynamicAttrs
数组中,否则添加到 el.attrs
数组中。动态属性绑定会触发 Vue 的响应式系统,使属性在数据变化时能够更新。静态属性(el.plain=false
)则是不变的,不需要响应式更新。
自定义执行解析也是如此。
那么自定义指令如何实现的呢?
Vue.directive
是全局API,在初始化的时候就已经将全局API挂在原型上了:
js
if (type === 'directive' && typeof definition === 'function') {
definition = { bind: definition, update: definition }
}
this.options[type + 's'][id] = definition
definition是我们传入函数或者对象两种形式:
js
Vue.directive('my-directive', (el, binding) => {
// 指令逻辑
});
Vue.directive('my-directive', {
bind: (el, binding) => {
// 指令绑定时的逻辑
},
update: (el, binding) => {
// 指令更新时的逻辑
}
});
这就是自定义指令的过程, 看起来是不是很简单呢~
然后回到上面就是解析的过程,正常解析就好啦~
过滤器同理~
8.Vue中的动态组件是如何实现的?
动态组件:
js
<component :is="currentComponent"></component>
它会根据即:is
属性的值来动态地选择要渲染的组件。
此过程还是在编译阶段
js
...
if ((binding = getBindingAttr(el, 'is'))) {
el.component = binding
}
...
...
function genComponent (
componentName: string,
el: ASTElement,
state: CodegenState
): string {
const children = el.inlineTemplate ? null : genChildren(el, state, true)
return `_c(${componentName},${genData(el, state)}${
children ? `,${children}` : ''
})`
}
...
根据 :is 获取组件的名称,通过代码生成器,调用genComponent
,根据组件的名称匹配当前组件和组件的元素属性生成一个组件渲染函数的字符串。
生成最终的渲染函数,然后调用缓存的mount方法进行渲染
是不是很简单?
9.Vue中的keep-alive是如何实现的?
源码位置:\src\core\components\keep-alive.js
<keep-alive>
无非就是一个特殊的组件罢了,里面有属性和自定义的组件是一样的。
通过JSX语法,手写render函数,将include
中的组件名称缓存到cache中, 当组件被切换出去时,它的状态会被保存在内存中。当组件被切换时,会先检查缓存中是否存在对应的实例,如果存在则直接从缓存中获取,否则会创建一个新的实例并缓存起来。
那么你对activated
和 deactivated
生命周期了解吗?
具体实现略,了解基础用法即可
10.Vue中的mixin是如何实现的?
js
...
Vue.mixin = function (mixin: Object) {
this.options = mergeOptions(this.options, mixin)
return this
}
...
其实就是一个mergeOptions的过程 , this.options
是当前组件的实例配置,Mixin
是我们自定义编写的属性和方法。
最终返回新的options对象。
需要注意的是: data
、methods
、computed
、watch
、props
、provide
、inject
等,组件中的属性会优先mixin中的属性
而生命周期mixin会优先组件。
11.Vue中的插件是如何实现的?
这个问题,答一答自定义插件即可
源码位置:\src\core\global-api\use.js
js
...
const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
if (typeof plugin.install === 'function') {
plugin.install.apply(plugin, args)
} else if (typeof plugin === 'function') {
plugin.apply(null, args)
}
installedPlugins.push(plugin)
...
当调用 Vue.use()
安装插件时,它会检查插件是否已经被安装过。如果没有安装过,它会调用插件的 install
方法或直接执行插件函数,将 Vue 构造函数和其他参数传递进去。
对于对象形式的插件,会检查 install
方法并调用。对于函数形式的插件,会直接执行这个函数。
安装过的插件会被记录在 _installedPlugins
数组中,以避免重复安装。
12.Vue中的异步组件是如何实现的?
什么是 异步组件?
js
new Vue({
// ...
components: {
'my-component': () => import('./my-async-component')
}
})
或
Vue.component('async-example', function (resolve, reject) {
setTimeout(function () {
resolve({
template: '<div>I am async!</div>'
})
}, 1000)
})
源码位置:\src\core\vdom\helpers\resolve-async-component.js
此过程发生在获取组件的Vnode阶段,
js
....
vnode = createComponent(Ctor, data, context, children, tag);
...
let asyncFactory
if (isUndef(Ctor.cid)) {
asyncFactory = Ctor
Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
if (Ctor === undefined) {
return createAsyncPlaceholder(
asyncFactory,
data,
context,
children,
tag
)
}
}
...
cid
,是组件的唯一标识,如果组件的构造函数中没有cid
则说明是异步组件。
我们知道定义异步组件有多种方式,这个asyncFactory
工厂函数即Ctor
其实就是上面代码中的异步函数,或者是Vue.component
第二个参数,内部对工厂函数添加了resolve,reject方法 。现在你明白了吗?到这里应该可以想到,resolveAsyncComponent
肯定是根据你传入的异步函数形式,进行特殊的处理~,最终返回异步组件的构造函数
asyncFactory
函数的两个参数,在源码中实现方式就不展开了
resolve
函数会在异步组件加载成功后被调用,它将解析得到的组件构造函数保存到factory.resolved
属性中,并在适当的时机触发重新渲染。如果是同步加载的情况,会直接更新owners
数组,如果是异步加载,则通过forceRender
强制触发渲染。reject
函数会在异步组件加载失败后被调用,它会在开发环境下输出警告信息,然后检查是否有错误组件,如果有的话,将factory.error
标志设置为true
,然后触发重新渲染。
总之它会根据你传入的异步函数进行解析, 如果异步函数返回的是Promise,那么会调用then方法,参数是上面定制的1和2
13.Vue中的transition组件是如何实现的?
略
14.Vue中的slot是如何实现的?
slot也是特殊组件
编译阶段:
processSlotContent
会对模板中的template标签
、slot-scope属性
以及slot标签
等进行解析,当解析到slot标签时会生成一个对应的 slot
节点对象。这个节点对象会包含插槽的名称、类型(作用域插槽或默认插槽)等信息。最终生成渲染函数代码。
在渲染阶段:
渲染函数会根据编译阶段生成的节点对象来处理插槽。 当渲染函数执行到一个插槽节点时,会根据插槽的类型和名称,找到父组件传递给子组件的内容如果是作用域插槽,还会将父组件传递的数据传递给作用域插槽的渲染函数。
渲染函数会将插槽的内容插入到组件模板的相应位置。对于默认插槽,如果父组件没有传递内容,渲染函数会使用插槽中的默认内容。
15.Vue中的响应式更新是如何实现的?
换句话说是Vue的响应式原理。
Vue是通过数据劫持结合发布订阅者模式实现的。
创建 Vue 实例的过程中,Vue 会对传入的 data
选项执行响应式处理。通过遍历对象的属性,使用 Object.defineProperty
添加 getter
和 setter
。这些 getter
和 setter
会被 Vue 用来追踪属性的读取和修改操作。
在模板编译的过程中,模板中使用的属性会被当做"依赖"收集起来。每个属性会有一个对应的 Dep
实例,用于管理相关的依赖。
在组件渲染过程中,Vue 会创建一个 Watcher
实例。每个 Watcher
实例会记录当前组件渲染所用到的依赖。
当属性被修改时,setter
会被调用。在这个时候,setter
会通知对应的 Dep
实例,然后 Dep
实例会通知所有依赖的 Watcher
更新。
Watcher
接收到更新通知后,会调用 update
方法,然后调用组件的 render
函数重新生成虚拟 DOM,进而更新视图。
16.Vue中的nextTick是如何实现的?
nextTick
用于在下一次DOM更新循环结束之后执行延迟回调函数
根据当前的执行环境,对宏微任务选择性应用
- 首先,Vue会尝试使用原生的
Promise
来创建一个微任务,并将一个空函数放入微任务队列,以确保当前微任务队列执行完成后执行nextTick
回调。 - 如果原生
Promise
不可用,Vue会检查是否支持MutationObserver
,如果支持,它会创建一个MutationObserver
实例,监听DOM变化,然后将一个文本节点插入到DOM中,这个文本节点的回调函数会在DOM更新后执行,从而实现微任务。 - 如果既没有原生
Promise
也没有MutationObserver
,Vue会回退到使用宏任务,通过setImmediate | setTimeout
将回调函数放入宏任务队列中,确保在下一个事件循环执行。
总结
上面的问题都是基于Vue2描述的,当然,Vue3中使用的是Proxy。