第1-3章总体概览
vue的3大核心模块
3.3关于组件
组件的本质是一组DOM元素的封装
可以暂定一个函数代表组件,函数的返回值就是组件要渲染的内容,也是虚拟DOM
const MyComponent = function(){
return {
tag: "div",
props: {
onClick: () => alert("component")
},
children: "click"
}
}
通过这样定义成函数,就可以在renderer渲染器中通过typeof进行判断,类型是组件还是元素。
function renderer(vnode, container){
if(typeof vnode.tag === 'string'){
// vnode描述的是标签元素
mountElement(vnode, container)
} else if(typeof vnode.tag === 'function'){
// 说明 vnode此时是组件
mountComponent(vnode, container)
}
}
mountElement创建元素
packages/runtime-core/src/renderer.ts
const mountElement = (vnode, container, anchor = null) => {
const { props, shapeFlag, type, children } = vnode;
let el = (vnode.el = hostCreateElement(type));
// 渲染元素属性
if (props) {
for (let prop in props) {
hostPatchProp(el, prop, null, props[prop]);
}
}
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, children); //文本节点的创建
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 数组,需要进行对children创建挂载
mountChildren(children, el);
}
hostInsert(el, container, anchor);
};
mountElement语意化直白实现
function mountElement(vnode, container) {
// 使用vnode.tag标签创建DOM元素
const el = document.createElement(vnode.tag)
// 遍历vnode.props,将事件和属性添加到DOM元素上
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 对以on开头的事件做处理,onClick --> click; vnode.props[key]是事件处理函数
el.addEventListener(key.substr(2).toLowerCase(), vnode.props[key])
} else{
// 其他的属性,直接进行setAttribute设置
el.setAttribute(key, vnode.props[key]);
}
}
// 处理children
if(typeof vnode.children === "string") {
// 如果vnode.children是字符串,说明是文本节点
el.appendChild(document.createTextNode(vnode.children))
}else if(Array.isArray(vnode.children)){
// 是数组,递归调用mountElement函数,渲染出子节点
vnode.children.forEach(child => mountElement(child, el))
}
container.appendChild(el)
}
mountComponent挂载组件
由于vnode.tag是函数,返回值是虚拟DOM,首先获取到该函数的值const subTree = vnode.tag();这样subTree也是虚拟dom。再次递归调用renderer渲染器
function mountComponent(vnode, container){
// 调用组件函数,获取到函数的返回值,即虚拟DOM
const subTree = vnode.tag();
// 递归调用renderer渲染 subTree
renderer(subTree, container)
}
3.4编译器-处理模版
vue的一大核心就是可以编写template模版,便于开发。
编译器是处理模版,让模板编译成渲染函数。以.vue文件为例
编译器把template模版的内容编译出渲染函数,并添加到script标签块的组件对象上。
无论是模板还是渲染函数render,对于一个组件来说,渲染的内容都是通过渲染函数产生。然后渲染器把虚拟DOM渲染为真实DOM。
第4-6章响应式
4.响应式系统
4.1-4.3副作用effect
effect副作用函数,会直接或间接影响其他函数的执行
响应式数据,当更新该数据后,依赖该数据进行显示的都会同步更新。那么这个数据就是响应式的,在vue2中使用Object.defineProperty(只能代理对象上的属性)拦截get/set方法进行依赖的收集和派发。vue3中采用Proxy,可以代理整个对象。
全局变量activeEffect
为了解决副作用函数命名,定义了个全局变量activeEffect(初始值为undefined),作用是存储被注册的副作用函数。
// -----------定义effect-----------
let activeEffect = undefined;
function effect(fn){
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
// -----------使用 effect函数-----------
// 用一个匿名的副作用函数作为effect函数的参数
effect(()=>{
document.body.innerText = 'hello'
})
target/key/effect对应关系
此时存在问题,如果更改了响应式对象obj.other属性,那么effect也会再次执行,显然不符合逻辑。
需要建立三个角色的对应关系
- target:被代理的对象
- key:被操作的属性
- effect:要执行的副作用函数
对应关系为
根据上图对应关系,构建出数据结构,我们分别使用WeakMap存target,用Map存key,用Set存effect
- WeakMap 由 target —-> Map 构成
- Map 由 key —-> Set 构成
new Proxy(data, {
get(target, key) {
// 将 activeEffect 存储的副作用函数收集到deps中
if (!activeEffect) return target[key];
// 获取 target为索引的 depsMap,它是Map类型: key --> effects 结构
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
return target[key];
},
set(target, key, value) {
target[key] = value;
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
return true;
}
});
4.4分支切换和cleanup
const data = {ok: true, text: "hello vue3!"}
const obj = new Proxy(data, { ... })
effect(()=>{
// 没有第9行的清理,effect run会被打印3次
console.log("effect run");
document.getElementById("app").innerText = obj.ok ? obj.text : "not";
})
代码链接
当effect函数内存在三元表达式,分支切换可能会遗留下副作用函数。
解决方案:在每次副作用函数执行时,先把它从所有的关联依赖集合中删除。
- 要将副作用函数[activeEffect]从所有与之关联的依赖集合[deps]移除,需要知道哪些依赖集合[deps]包含它
- 重新设计副作用函数,在副作用函数内部,添加deps属性[是数组]用来存储该副作用的相关联依赖集合
let activeEffect = undefined;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
// 执行副作用函数
fn();
};
// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
// 清除副作用相关联的依赖集合deps
function cleanup(effectFn) {
// effectFn.deps是数组类型
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]; // deps是集合Set类型
deps.delete(effectFn); // 清除掉关联依赖集合内的所有副作用
}
// 然后才能设置deps为[]
effectFn.deps.length = 0;
}
cleanup函数接收副作用函数作为参数,遍历副作用函数的effect.deps数组,该数组的每项都是依赖集合deps。
然后将该副作用从依赖集合中移除。
处理trigger内部的无限循环执行
trigger函数内部,遍历effects集合,里面存放着副作用函数,当副作用函数执行时,会调用cleanup清除effects集合中的当前执行的副作用函数。但是副作用函数的执行会导致activeEffect重新被收集到集合中。
在调用forEach遍历Set集合时,如果一个值已经被访问过,但该值被删除并重新添加到集合,此时forEach遍历还没有结束,那么该值会被重新访问。forEach遍历会无限循环
const s = new Set([1]);
s.forEach(item=>{
s.delete(1);
s.add(1);
console.log('run')
})
const s = new Set([1]);
const newS = new Set(s);
newS.forEach(item=>{
s.delete(1);
s.add(1);
console.log('run')
})
所以就有了trigger函数中的72,73行代码
代码参考
4.5嵌套的effect【effect栈结构】
effect是可以嵌套的
const data = {foo:true, bar: true, text: 'hello vue3'}
effect(function effect1() {
console.log("effect1 run");
effect(function effect2() {
console.log("effect2 run");
temp2 = obj.bar;
});
temp1 = obj.foo;
});
当修改obj.foo的值时,会输出结果:
"effect1 run"
"effect2 run"
"effect2 run"
effect2被执行2次,显然不符合预期。
代码示例
问题出现在effect和activeEffect的关系上,使用activeEffect来存储effect函数注册的副作用函数,意味着同一个时刻只能有一个activeEffect,当副作用发生嵌套,内层副作用effect会覆盖activeEffect,并且不会恢复原值。即使再有响应式数据进行依赖收集,收集的副作用函数也是内层的副作用函数。
为了解决effect嵌套问题,需要建立个副作用函数栈effectStack,在副作用函数执行时,将前副作用函数压入栈中,副作用执行完毕将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。
let activeEffect = undefined;
// effect栈结构,存在effect
let effectStack = []
function effect(fn) {
const effectFn = () => {
// 调用cleanup函数 完成清除副作用相关联的依赖集合deps
cleanup(effectFn);
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
effectStack.push(effectFn);
// 执行副作用函数
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length -1]
};
// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
4.6避免无限递归循环
const data = {count:1}
const obj=new Proxy(data, {})
effect(() => obj.count++ ) // 副作用的自增操作,会引起栈溢出
问题出现:数据的读取和设置操作在同一个副作用函数内进行。此时无论是track收集的副作用函数,还是trigger是触发执行的副作用函数,都是activeEffect。
如果在trigger触发执行副作用函数与当前正在执行的副作用函数相同,则不触发执行。
// 更新依赖
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
//为了避免重复添加删除,造成死循环
const effectsToRun = new Set();
effects && effects.forEach(item=>{
// trigger触发的副作用函数 与 当前正在执行的副作用函数相同,则不触发更新
if(item !== activeEffect){
effectsToRun.add(item)
}
})
effectsToRun.forEach((fn) => fn());
}
⭐️4.7scheduler 调度执行
- 目前副作用的执行不受控制,现在会离开执行,并且会重复执行,为了解决这个问题,使用scheduler
- 可调度性是响应式系统非常重要的特性。
vue中的computed和watch实现都依赖scheduler。
effect(() => {
console.log("run effect", obj.count);
});
obj.count++;
console.log("over");
// 打印出
//run effect 1
//run effect 2
//over
如果希望over打印显示在第二行,此时只能用户端调整打印顺序到effect上边。
代码演示控制执行时机
通过给effect副作用函数添加options,设置scheduler调度
function effect(fn, options = {}) {
const effectFn = () => {
// 调用cleanup函数 完成清除副作用相关联的依赖集合deps
cleanup(effectFn);
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
effectStack.push(effectFn);
// 执行副作用函数
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 可以自定义执行规则,添加scheduler
effectFn.options = options;
// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
然后在trigger触发更新时判断是否有调度规则,如果有,则执行调度函数,并把副作用effec作为参数传递
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
//为了避免重复添加删除,造成死循环
const effectsToRun = new Set();
effects &&
effects.forEach((effect) => {
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
effectsToRun.forEach((fn) => {
// 如果用户使用的effect有 scheduler 配置,则走调度逻辑
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
});
}
经过设置,再次调用effect,就可以随意控制后续effect的执行时机
effect(
() => {
console.log("run effect", obj.count);
},
{
scheduler(fn) {
setTimeout(fn);
}
}
);
obj.count++;
console.log("over");
控制执行次数
下面的副作用执行,会重复执行4次,但是中间2次只是过度过程,用户并不关心。可以通过scheduler控制中间的过程不显示。
effect(() => {
console.log("run effect", obj.count);
});
obj.count++;
obj.count++;
obj.count++;
创建一个任务执行队列
const jobQueue = new Set();
const p = Promise.resolve();
let isFlushing = false;
function flushJob(){
if(isFlushing) return;
isFlushing = true;
p.then(()=>{
jobQueue.forEach(job => job())
}).finally(()=>{
isFlushing = false;
})
}
effect(
() => {
console.log("run effect", obj.count);
},
{
scheduler(fn) {
jobQueue.add(fn);
flushJob();
}
}
);
通过定义jobQueue任务队列,将正在执行的副作用添加到任务队列中,利用Promise的微任务执行,可以等到所有的effect副作用都添加完毕后,在一次执行所有的副作用函数。由于jobQueue时Set数据结构,所以存储的只有一个effect,就是当前执行的副作用函数。这样就简单实现多个同步任务,只执行最后一次。
⭐️4.8计算属性computed
处理立即执行问题
目前创建的effect副作用都是立即执行的,如果有些场景不希望立即执行,而是在它需要的时候才执行。例如计算属性,只有被依赖的值发生变化,副作用才会执行。这时就可以通过effect的options中的lazy属性完成。
effect(()=>{
console.log(obj.foo);
}, {
lazy: true // 通过指定lazy属性,设置effect不立即执行
})
修改effect的实现逻辑
let activeEffect = undefined;
// effect栈结构,存在effect
let effectStack = [];
function effect(fn, options = {}) {
const effectFn = () => {
// 调用cleanup函数 完成清除副作用相关联的依赖集合deps
cleanup(effectFn);
// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
activeEffect = effectFn;
effectStack.push(effectFn);
// 执行副作用函数
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 可以自定义执行规则,添加scheduler
effectFn.options = options;
// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
effectFn.deps = [];
/** 计算属性 🇭相关**/
if (!options.lazy) {
// 只有在非lazy属性时,才会执行effectFn
effectFn();
}
// 默认情况下,只返回副作用函数,并不会执行
return effectFn;
}
修改effect后,如果传递的options中有参数lazy:true,则不立即执行。
代码演示 默认只有effect第一次执行,后边需要手动调用。
如果仅仅满足手动执行副作用,也没太大用途。可以把effect内的函数作为一个getter,这个getter函数可以返回任何值。
调整effect函数,通过对effectFn进行包装,effectFn是包装后的副作用,此包装副作用的返回值才是真正的副作用。代码第16行。如果是lazy的情况下,只是返回副作用的函数第29行,并不会执行第26行。只有在非lazy的情况下,才能返回包装副作用effectFn函数的执行结果,从而才能执行真正的副作用函数fn。 ```typescript let activeEffect = undefined; // effect栈结构,存在effect let effectStack = []; function effect(fn, options = {}) { // 通过effectFn进行了对fn的一层包装,可以处理不立即执行的情况。 const effectFn = () => { // 调用cleanup函数 完成清除副作用相关联的依赖集合deps cleanup(effectFn); // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect activeEffect = effectFn; effectStack.push(effectFn); // 将fn的执行结果存储到res中 const res = fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; // 将res作为effectFn的返回值。 return res; }; // 可以自定义执行规则,添加scheduler effectFn.options = options; // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合 effectFn.deps = [];/ 计算属性 🇭相关/ if (!options.lazy) { // 只有在非lazy属性时,才会执行effectFn effectFn(); } // 默认情况下,只返回副作用函数,并不会执行 return effectFn; }
// computed计算属性的定义 function computed(getter) { // 把getter作为副作用函数,创建个lazy的effect const effectFn = effect(getter, { lazy: true }); const obj = { // 当读取value 值时,才执行effectFn get value() { return effectFn(); } }; return obj; }
使用computed
```typescript
const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {
/** */
})
const sum = computed(() => obj.foo + obj.bar);
console.log(sum, "sum");
处理缓存问题
如果多次访问sum.value的值,即使obj.foo和obj.bar没有变化,也会导致effectFn进行多次计算。为了解决这个问题,需要在computed函数添加对值的缓存功能。
// computed计算属性的定义
function computed(getter) {
// 用value缓存上次计算的值
let value;
// 判断是否需要重新计算,dirty为true才重新计算
let dirty = true;
// 把getter作为副作用函数,创建个lazy的effect
const effectFn = effect(getter, {
lazy: true,
scheduler() {
// 在调度器中将dirty 设置为true
dirty = true;
}
});
const obj = {
// 当读取value 值时,才执行effectFn
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
}
};
return obj;
}
通过设置dirty,控制是否重新执行effectFn。然后又在effect的options中添加scheduler调度函数,该调度函数会在所依赖的响应式数据变化时执行,同时将dirty设置为true,下次进行计算就能获取到最新值。
代码示例
⭐️4.9watch属性
watch本质是观察一个响应式数据,当数据发生变化,执行对应的回调函数。
利用effect和options.scheduler选项实现
effect(()=>{
console.log(obj.foo)
},{
scheduler(){
// 当obj.foo发生变化,会执行这里的内容
}
})
watch的实现就是依赖effect中的scheduler,当响应式数据发生变化,如果副作用函数存在scheduler选项,则触发scheduler函数执行,而不是直接触发副作用函数执行。 依据这一特性,简单实现watch
// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
function watch(source, cb){
effect(
()=> source.foo,
{
scheduler(){
cb()
}
}
)
}
const data = {foo:1}
const obj = new Proxy(data, { /* */})
watch(obj, ()=>{
console.log("foo的数据变化了")
})
obj.foo++;
观察对象的属性
上面通过source.foo硬编码实现对对象foo的监测,为了让watch具有通用行,需要封装一个通用的读取操作:
// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
function watch(source, cb) {
let getter;
// 如果第一个参数是函数,直接执行函数,读取返回值
if (typeof source === "function") {
getter = source;
} else {
// 不是函数,则读取对象的多个属性
getter = () => traverse(source);
}
// 通过traverse来读取source的值
effect(() => getter(source), {
scheduler() {
// 当数据变化时,执行cb
cb();
}
});
}
function traverse(value, seen = new Set()) {
// 如果是原始数据 或者已经读取过, 不进行处理
if (typeof value !== "object" || value === null || seen.has(value)) return;
// 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环
seen.add(value);
// 假设观察的是对象,使用for in 读取数据的每各属性值,递归调用traverse
for (let key in value) {
traverse(value[key], seen);
}
return value;
}
通过traverse函数,对传入的第一个对象进行监听。如果第一个参数传入的是函数,只监听该函数返回值;如果传入的是对象,则监听对象上的所有属性,通过traverse递归操作。
代码实例获取新值newval和旧值oldval
在使用watch时,经常使用newValue和oldValue值做对比,然后再进行下一步操作。但是上面的cb回调函数并没有传递任何参数。接下来就将newValue和oldValue通过cb传递给用户端使用。
修改watch的实现:在14行,将effect副作用函数保存为effectFn变量,第26行,手动执行effectFn函数得到的返回值就是oldval,即第一次执行的结果。// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
function watch(source, cb) {
let getter;
// 如果第一个参数是函数,直接执行函数,读取返回值
if (typeof source === "function") {
getter = source;
} else {
// 不是函数,则读取对象的多个属性
getter = () => traverse(source);
}
let newVal, oldVal;
// 通过traverse来读取source的值
// 通过options的lazy属性,把返回值存储到effectFn中
const effectFn = effect(() => getter(), {
lazy: true,
scheduler() {
// scheduler重新执行副作用函数,得到最新值
newVal = effectFn();
// 将新值和旧值作为cb的参数
cb(newVal, oldVal);
// 更新下 旧值,否则下次得到的旧值会是错误的
oldVal = newVal;
}
});
// 手动调用副作用函数,得到旧值
oldVal = effectFn();
}
function traverse(value, seen = new Set()) {
// 如果是原始数据 或者已经读取过, 不进行处理
if (typeof value !== "object" || value === null || seen.has(value)) return;
// 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环
seen.add(value);
// 假设观察的是对象,使用for in 读取数据的每各属性值,递归调用traverse
for (let key in value) {
traverse(value[key], seen);
}
return value;
}
watch(
() => obj.foo,
(nv, ov) => {
console.log("foo的数据变化了", nv, ov); // 2 1
}
);
obj.foo++;
4.10立即执行 watch
立即执行回调函数
- 回调函数的执行时机
watch的实现,使用了options的lazy属性,所以不会立即执行。为了能够让watch的回调函数在创建时立刻执行一次,可以给watch添加第三个参数 immediate: true;
/ watch函数接收3个参数,
// source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机
function watch(source, cb, options = {}) {
let getter;
// 如果第一个参数是函数,直接执行函数,读取返回值
if (typeof source === "function") {
getter = source;
} else {
// 不是函数,则读取对象的多个属性
getter = () => traverse(source);
}
let newVal, oldVal;
// 将scheduler调度函数,封装成 job 函数
const job = () => {
newVal = effectFn();
// 当数据变化时,执行cb
cb(newVal, oldVal);
oldVal = newVal;
};
// 通过traverse来读取source的值
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: job
});
if (options.immediate) {
// immediate属性为真,自动执行下job任务
job();
} else {
oldVal = effectFn();
}
}
除了给watch的第三个参数options设置immediate还可设置flush来控制回调函数的执行时机。
watch(
() => obj.foo,
(nv, ov) => {
console.log("foo的数据变化了", nv, ov); // 2 1
},
{
// immediate : true,回调函数会立即执行一次
flush: 'post'
}
);
obj.foo++;
- post : 回调函数需要将副作用函数放到微任务队列中
- sync:实现同步执行
pre: 组件更新前执行 ```typescript function watch(source, cb, options = {}) { let getter; // 如果第一个参数是函数,直接执行函数,读取返回值 if (typeof source === “function”) { getter = source; } else { // 不是函数,则读取对象的多个属性 getter = () => traverse(source); } let newVal, oldVal; // 将scheduler调度函数,封装成 job 函数 const job = () => { newVal = effectFn(); // 当数据变化时,执行cb cb(newVal, oldVal); oldVal = newVal; };
// 通过traverse来读取source的值 const effectFn = effect(() => getter(), { lazy: true, scheduler: () => {
if (options.flush === "post") {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
} });
if (options.immediate) { // immediate属性为真,自动执行下job任务 job(); } else { oldVal = effectFn(); } }
/ 使用watch / watch( () => obj.foo, (nv, ov) => { console.log(“foo的数据变化了”, nv, ov); }, { flush: “post” //添加上,则在out 之后打印 } ); obj.foo++; console.log(“out 同步执行函数”);
<a name="vmzwu"></a>
### 4.11过期的副作用,可以被取消
正在执行的副作用,要能够被取消,否则会发生[“竞态”问题](https://juejin.cn/post/6844903863749705741)。该问题可以在原始的xhr的abort中解决,也可在axios封装的 isCancel 中取消请求。<br />![](https://cdn.nlark.com/yuque/0/2022/jpeg/737887/1652515510903-5ad8aa27-559a-40ae-9659-4102504fe0cb.jpeg)<br />因此需要一种让副作用过期的技术。<br />watch的**回调函数**现在接收到newValue和oldvalue2个参数,通过**设置第3个参数 onInvalidate 函数**,这个函数类似事件监听器。使用onInvalidate注册回调函数,该回调函数在当前副作用函数过期时执行。
```typescript
function delay(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(time);
}, time);
});
}
// watch函数接收3个参数,
// source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机
function watch(source, cb, options = {}) {
let getter;
// 如果第一个参数是函数,直接执行函数,读取返回值
if (typeof source === "function") {
getter = source;
} else {
// 不是函数,则读取对象的多个属性
getter = () => traverse(source);
}
let newVal, oldVal;
// cleanup 存储用户注册的过期回调
let cleanup;
// onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调 存储到cleanup
cleanup = fn;
}
// 将scheduler调度函数,封装成 job 函数
const job = () => {
newVal = effectFn();
if (cleanup) {
cleanup();
}
// 将onInvalidate作为回调的第3个参数,以便用户使用
cb(newVal, oldVal, onInvalidate);
oldVal = newVal;
};
// 通过traverse来读取source的值
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
}
});
if (options.immediate) {
// immediate属性为真,自动执行下job任务
job();
} else {
oldVal = effectFn();
}
}
在watch中首先定义cleanup变量,用来存储用户通过onInvalidate函数注册的回调。
在job函数内,每次执行回调函数cb之前,先检查是否存在过期的回调,如果存在,则执行过期回调函数cleanup
最后把onInvalidate回调函数作为第三个参数传递给cb。
测试onInvalidate,
- 通过模拟发生接口请求,后边的接口请求返回速度快,
- 如果没有onInvalidate函数,则结果会显示为前一次接口返回的结果。这是错误的
- 通过设置onInvalidate函数,把上次的副作用函数给取消掉,就不会发生前次接口值覆盖最新接口值的情况。 ```typescript
let finalData; let initTime = 2200; watch( () => obj.foo, async (newVal, oldVal, onInvalidate) => { console.log(“foo的数据变化了”); let flag = false;
onInvalidate(() => {
flag = true;
});
// 模拟后边发送接口请求,比上次的提前返回
initTime = initTime - 1000;
const res = await delay(initTime);
if (!flag) {
finalData = res;
// onInvalidate生效,则会显示第二次请求的结果,不会被前一次结果覆盖
// 如果注释掉 onInvalidate,则最终会显示 第一次发生的请求
document.getElementById("app").innerHTML = finalData;
}
console.log("watch 内 finalData", finalData);
} ); obj.foo++; obj.foo++;
[代码实例](https://codesandbox.io/s/vue-design-15bzo9?file=/4part/4.11.js)
<a name="fr1eR"></a>
## 5.对象类型的响应式方案reactive/proxy
<a name="MmNBi"></a>
### 5.1理解Proxy和Reflect对象
![](https://cdn.nlark.com/yuque/0/2022/jpeg/737887/1652668581615-0c23370c-5f76-4fac-9246-89eec8d8a486.jpeg)
- Proxy只能代理对象类型
- 代理是指,能够对对象的基本操作进行拦截,通过上面虚线定义的那些方法处理对象。
- Proxy只能拦截对象的基本操作。复合操作处理不了,如obj.foo();
```typescript
function fn(name) {
console.log(`my name is ${name}, ${this.name}`);
}
const p = new Proxy(fn, {
// 使用apply 拦截函数的调用
apply(target, thisArg, argArray) {
console.log(thisArg, argArray, "apply调用函数");
target.call(thisArg, argArray);
}
});
p.call({ name: "CallName" }, "北鸟南游"); // my name is 北鸟南游, CallName
Reflect下的方法和Proxy的拦截器方法名称相同,任何通过Proxy拦截的方法都能在Reflect中找到。Reflect的重要意义在于receiver参数,可以理解为函数调用过程中的this。通过改变receiver,可以调整getter中的this。
Reflect对象中的receiver重要性
const Obj = {
get count() {
return this.c;
}
};
console.log(Reflect.get(Obj, "count", { c: 99 })); //99
const po = new Proxy(Obj, {
get(target, key, receiver) {
if (key === "c") return 6;
// return target[key]; //获取不到count的值,target找不到c
return Reflect.get(target, key, receiver); //通过recevier可以改变属性访问器getter的this
}
});
console.log(po.count, "count"); // 6
在getter属性访问器内,通过target[key]返回属性值,此时target是原始对象Obj,key是count,第11行相当于获取Obj.count。当打印po.count即访问count属性时,getter内的this指向原来的Obj对象,此时Obj下不存在属性c。所以用第11行,结果返回的是undefined。
当使用Reflect,并且要传递第三个参数receiver。那么此时的po.count,访问po代理对象的count属性时,recever就是po,访问器属性count的getter函数内的this就是代理对象po。当key为c时结果就会返回 6
5.2js对象及Proxy工作原理
js对象分为:常规对象(ordinary object)和异质对象(exotic object);
在js中对象的实际语意是由对象的内部方法(internalmethod)指定的,内部方法是当对一个对象进行操作时,在引擎内部调用的方法,这些方法对于我们使用者不可见。
以上2个表中定义了14个内部方法,ECMAScript 定义的内部方法。
在js中,一个对象必须包括table5中的12个必要的内部方法。table6中的 [[Call]] 和 [[Construct]]是对象作为函数调用必须包含的内部方法。
- 常规对象是内部方法必须是9.1表中定义实现。
- 对象的内部方法有重新改写定义9.2-9.5定义的对象,则是异质对象。
- Proxy对象的内部方法[[Get]]就有新定义,所以是异质对象。
创建代理对象时的拦截方法,实质上是自定义代理对象本身的内部方法和行为。
const obj = { foo: 1 };
const po = new Proxy(obj, {
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key);
}
});
console.log(po.foo); // 1
delete po.foo;
console.log(po.foo); // undefined
5.3如何代理对象
前面一直使用get拦截对象属性的读取,但在响应系统中,读取是一个很宽泛概念,使用in操作符检查对象上是否具有给定的key也是读取操作。一个普通对象的所有读取操作可能有:
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的key: key in obj
- 使用for… in 遍历对象: for(const key in obj) {}
第一种情况,可以直接使用get进行拦截。如果使用了in操作符,就需要查看对应的拦截函数。
可以看到in操作符运算结果是通过HasProperty的抽象方法得到。关于HasProperty 抽象方法可以看到内部对应的拦截函数是has。
in操作符使用has进行拦截。
通过查找for… in的规范,可以看到是通过ownKeys进行拦截。
function* enumerate(obj) {
let visited=new Set;
for (let key of Reflect.ownKeys(obj)) {
if (typeof key === "string") {
let desc = Reflect.getOwnPropertyDescriptor(obj,key);
if (desc) {
visited.add(key);
if (desc.enumerable) yield key;
}
}
}
let proto = Reflect.getPrototypeOf(obj)
if (proto === null) return;
for (let protoName of Reflect.enumerate(proto)) {
if (!visited.has(protoName)) yield protoName;
}
}
拦截ownKeys操作即可间接拦截for…in循环。
由于ownKeys,只能获取到目标对象target,没有传入key参数。 在track函数中需要key值,通过 const ITERATE_KEY = Symbol(); 作为key值。
const obj = { count: 1 };
const po = new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return Reflect.get(target, key, receiver);
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
trigger(target, key);
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
//将副作用函数 与 ITERATE_KEY 关联
track(target, ITERATE_KEY);
return Reflect.ownKeys(target);
},
deleteProperty(target, key) {
// 检查被删除的属性是否是对象自身的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
// 使用Reflect.deleteProperty 完成属性删除
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key, "DELETE");
}
}
});
effect(() => {
// console.log(po);
for (const key in po) {
console.log("key", key);
}
});
po.bar = 2;
po原来只有count属性,因此for…in循环一次,第42行给它添加了新属性bar,所以for…in循环就会由执行1次变成2次。也就是说当为对象添加属性时,需要触发ITERATE_KEY相关联的副作用重新执行。
给trigger方法添加 ITERATE_KEY相关的副作用函数
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
//为了避免重复添加删除,造成死循环
const effectsToRun = new Set();
effects &&
effects.forEach((effect) => {
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
// 删除操作会影响for...in循环次数
// if (type === "ADD" || type === "DELETE") {
// 取到与 ITERATE_KEY 相关的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
// }
effectsToRun.forEach((fn) => {
// 如果用户使用的effect有 scheduler 配置,则走调度逻辑
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
});
}
区分是新增属性还是更新设置属性?
按照上面给po新增了bar属性,effect副作用内的for…in会重新执行。但是更新po.count =2时,for…in也会重新执行。这样违背了修改属性不会对for…in循环产生影响。
在更新属性时,不需要多for…in产生影响,应该在Proxy的set方法中进行判断,是新增属性还是设置属性。
const type = Object.prototype.hasOwnProperty.call(target, key)
? "SET"
: "ADD";
检查当前操作属性key是否存在目标对象上,如果存在,则是“SET”修改属性,否则是新增属性。可以把该参数传递给trigger。
// 更新依赖
function trigger(target, key, type) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
//为了避免重复添加删除,造成死循环
const effectsToRun = new Set();
effects &&
effects.forEach((effect) => {
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
// 删除操作会影响for...in循环次数
if (type === "ADD") {
// 取到与 ITERATE_KEY 相关的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach((fn) => {
// 如果用户使用的effect有 scheduler 配置,则走调度逻辑
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
});
}
只有在“ADD”时,才触发与 ITERATE_KEY 相关的副作用函数重新执行。代码实例
代理对象的删除操作
删除对象自身的属性,如果删除成功,则会影响for…in的遍历,也会触发effect副作用。
因此需要检查被删除的属性是否属于自身const hadKey=Object.prototype.hasOwnProperty.call(target, key);
,然后调用Reflect.deleteProperty(target, key);
完成属性的删除。
const po = new Proxy(obj, {
deleteProperty(target, key) {
// 检查被删除的属性是否是对象自身的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
// 使用Reflect.deleteProperty 完成属性删除
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key, "DELETE");
}
return res;
}
});
操作类型type为“DELETE”也应该触发与 ITERATE_KEY 相关联的副作用函数重新执行。
function trigger(target, key, type) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
//为了避免重复添加删除,造成死循环
const effectsToRun = new Set();
effects &&
effects.forEach((effect) => {
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
console.log(type, key);
// 删除操作会影响for...in循环次数
if (type === "ADD" || type === "DELETE") {
// 取到与 ITERATE_KEY 相关的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach((fn) => {
// 如果用户使用的effect有 scheduler 配置,则走调度逻辑
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
});
}
最后可以测试,删除自身属性foo及非自身属性bar的区别。删除bar不会再次触发effect副作用函数执行。
代码实例
5.4合理的触发响应
NaN引起的不必要更新
为了监听更新而触发副作用,以上解决方法面临第一个问题,设置的值没有变化,也触发副作用。
const obj = {foo:1};
const p = new Proxy(obj, { //...
})
effect(()=>{
console.log(p.foo)
})
p.foo = 1; //设置值,但是没更新,仍然触发了effect副作用执行。
代码示例
为了解决这个问题,可以修改set拦截函数的代码,在调用trigger函数触发响应前,判断值是否发生变化。
const p = new Proxy(obj, {
//...
set(target, key, newVal, receiver) {
// 先存储下旧值
const oldVal = target[key];
const res = Reflect.set(target, key, newVal, receiver);
// 比较新值和旧值,只有在不相等的时候才触发响应
if (oldVal !== newVal) {
trigger(target, key);
}
return res;
},
//...
})
代码示例, 经过改造后,设置的值没有变化,就不触发effect更新。
上面使用了全等进行对比,在处理NaN时会有bug,因为NaN永远不等NaN,那么也会进行更新。所以还需要排除掉NaN数据。
NaN === NaN; // false
NaN !== NaN; // true
继续修改setter操作函数。
const p = new Proxy(obj, {
//...
set(target, key, newVal, receiver) {
// 先存储下旧值
const oldVal = target[key];
const res = Reflect.set(target, key, newVal, receiver);
// 比较新值和旧值,只有在不相等的时候才触发响应;并且不是NaN
if (oldVal !== newVal && (oldVal ===oldVal || newVal === newVal) ) {
trigger(target, key);
}
return res;
},
//...
})
屏蔽原型链引起副作用更新
先把创建代理对象封装成通用的方法 reactive。这样可以方便创建多个代理对象。
const obj = { foo: 1 };
const child = reactive(obj);
const parent = reactive({ bar: 2 });
//设置parent 为child的原型
Object.setPrototypeOf(child, parent);
console.log("判断obj的原型是不是parent", Object.getPrototypeOf(obj) === parent);
effect(() => {
console.log(child.bar);
});
child.bar = 3; //这里的修改,会触发2次effect的执行
- 给child设置了parent作为原型。
- child和parent都是响应式对象
- 修改child.bar属性,由于child自身上没有bar属性,会找到原型对象parent上。parent也是响应式对象,从而就触发了2次effect。
代码示例
解决办法:既然是执行2次,那么只要屏蔽掉一次就可以。两次更新都是在set拦截函数中触发,因此需要在拦截函数set中设置触发更新的条件。
// child 的set拦截函数
set(target, key, newVal, receiver){
// target是原始对象 obj
// receiver是代理对象 child
}
// parent 的set拦截函数
set(target, key, newVal, receiver){
// target是原始对象 原型proto 即parent
// receiver是代理对象 child
}
可以发现,target在两次代理过程中是发生变化的,receiver是不变的。可以通过给receiver设置一个”raw”属性让它为原来的对象obj;
child.raw === obj; //true
parent.raw === obj; // false
修改reactive的getter和setter拦截函数
function reactive(obj) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === "raw") { // 设置raw属性,访问该属性时,获取到被代理的原始值
return target;
}
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return Reflect.get(target, key, receiver);
},
// 拦截设置操作
set(target, key, newVal, receiver) {
const oldVal = target[key];
// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
const type = Object.prototype.hasOwnProperty.call(target, key)
? "SET"
: "ADD";
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver);
console.log(target === receiver.raw);
if (target === receiver.raw) { // 排除掉原型链上属性的更新
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type);
}
}
return res;
}
// ...
}
5.5浅响应和深响应
以前创建的代理,只能代理对象的一层。
const obj = reactive({ foo : { bar: 0}});
effect(()=>{
console.log(obj.foo.bar)
});
// 修改obj.foo.bar的值,不能触发effect
obj.foo.bar = 2;
由于在get拦截函数中,Reflect.get函数返回的是obj.foo的结果 {bar: 0}。这是一个普通对象,并不是响应式对象,所以不能建立响应。改造get拦截函数
function reactive(obj){
return new Proxy(obj, {
get(target, key, receiver){
if(key === "raw"){
return target
}
track(target, key);
// 得到返回结果
const res = Reflect.get(target, key, receiver);
if(typeof res === "object" && res !== null){
//如果是对象类型,并且不是null,继续调用reactive
return reavtive(res)
}
return res;
}
})
}
这样就可实现对象的深层次代理。修改obj.foo.bar的值,也能触发effect的更新。
代码实例
但是并不是所有情况都希望深度代理,这就产生了shallowReactive浅响应。
const obj = shallowReactive({foo: {bar: 1}})
effect(()=>{
console.log(obj.foo.bar)
})
// obj.foo是响应的,可以触发effect执行
obj.foo = {bar: 32}
// obj.foo.bar不是响应的,不能触发effect函数重新执行
obj.foo.bar = 2
使用函数柯里化,继续封装一层createReactive函数,将创建不同类型的响应式数据通过参数创建。
function createReactive(obj, isShallow = false){
return new Proxy(obj, {
// 拦截get
get(target, key, receiver){
if(key === "raw"){
return target
}
const res = Reflect.get(target, key, receiver);
track(target, key);
// 如果isShallow为真, 浅代理,直接返回res对象
if(isShallow){
return res
}
if(typeof res === "object" && res !== null){
return reactive(res)
}
return res;
}
})
}
function reactive(obj){
return createReactive(obj); //深代理
}
function shallowReactive(obj){
return createReactive(obj, true); //浅代理
}
5.6只读和浅只读
有时希望对数据进行保护,给数据设置为只读。当用户修改值或删除值时都发出警告。
const obj = readOnly({foo:1});
// 当修改数据,会弹出警告
obj.foo = 2
可以看出只读也是对数据的代理操作,在setter拦截函数中进行设置。给createReactive传递第3个参数
function createReactive(obj, isShallow = false, isReadonly = false){
return new Proxy(obj, {
// 设置的拦截
set(target, key, newVal, receiver){
// 如果是只读, isReadonly为真
if(isReadonly){
console.warn(`${key} 是只读的,不能修改`)
return true;
}
const oldVal = target[key];
// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
const type = Object.prototype.hasOwnProperty.call(target, key)
? "SET"
: "ADD";
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver);
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type);
}
}
return res;
},
deleteProperty(target, key) {
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`);
return true;
}
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key, "DELETE");
}
return res;
}
}
})
}
设置和删除属性时,都会有警告提示。
如果一个数据是只读,那么就无法修改它,也就没必要建立响应联系。修改getter拦截函数,只有非只读情况下才建立响应式track。
function createReactive(obj, isShallow = false, isReadonly = false){
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === "raw") {
return target;
}
// 非只读的时候才需要建立响应联系
if (!isReadonly) {
track(target, key);
}
const res = Reflect.get(target, key, receiver);
if (isShallow) {
return res;
}
if (typeof res === "object" && res !== null) {
// 深响应
return reactive(res);
}
return res;
}
}
}
此时实现的readonly只读函数,只是浅只读shallowReadonly,还没有做深度处理。
如果要对数据做深度的只读处理,通过给createReactive传递第3个参数,设置为真。
function createReactive(obj, isShallow = false, isReadonly = false){
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === "raw") {
return target;
}
// 非只读的时候才需要建立响应联系
if (!isReadonly) {
track(target, key);
}
const res = Reflect.get(target, key, receiver);
if (isShallow) {
return res;
}
if (typeof res === "object" && res !== null) {
// 深只读和深响应
return isReadonly ? readonly(res) : reactive(res);
}
return res;
}
}
}
function readonly(obj){
return createReactive(obj, false, true)
}
// 只需要修改第二个参数即可,浅响应,并且做了只读处理
function shallowReadonly{
return createReactive(obj, true, true)
}
5.7数组 5.8Map和Set
6.原始值类型响应式方案ref的实现,getter/setter
第5章实现的响应式方案是建立在非原始值的对象上。如果是原始值基本类型:Boolean、Number、String、null、undefined、BigInt、Symbol类型的值。原始值是按值传递,而非引用传递,如果函数接收原始值作为参数,那么形参和实参直接没有关系,代理也就没意义。
JavaScript中的Proxy无法对原始值进行代理。
引入ref概念
原始值无法响应代理,通过包裹一层属性,变成对象类型。
// 封装ref函数
function ref(val){
//在ref内创建包裹对象
const wrapper={
value: val
}
// 将包裹对象变成响应式
return reactive(wrapper)
}
现在通过ref就可以给原始值创建响应式数据
const refVal = ref(1);
effect(()=>{
//在副作用内通过value属性读原始值
console.log(refVal.value);
})
// 修改值能触发副作用effect函数重新执行
refVal.value = 2
为了区分ref创建的响应式数据还是reactive创建的,需要在创建ref是添加__v_isRef属性
// 封装ref函数
function ref(val){
//在ref内创建包裹对象
const wrapper={
value: val
}
//使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
Object.defineProperty(wrapper, "__is_Ref", {
value: true
})
// 将包裹对象变成响应式
return reactive(wrapper)
}
转换ref的方法toRef和toRefs
使用上面方法创建的响应式数据,无法进行展开,展开后响应式就会丢失。
export defalut{
setup(){
const obj = reactive({foo:1, bar:2});
return { ...obj }
}
}
// 使用展开运算符(...)导致响应丢失,相当于导出的是
return {
foo:1,
bar:2
}
为了解决响应式丢失问题,可以创建个newObj对象,在该对象下具有与obj的同名属性。每个属性值又是对象
const obj = reactive({foo:1, bar:2});
// newObj对象下具有obj对象的同名属性,每个属性值都是对象
const newObj = {
foo: {
get value(){
return obj.foo
}
},
bar: {
get value(){
return obj.bar
}
}
}
effect(()=>{
console.log(newObj.foo)
})
obj.foo = 3
从newObj对象可以看出,结构存在相似。因此可以抽象出来,封装成函数toRef。
function toRef(obj, key){
const wrapper={
get value(){
return obj[key]
}
}
//使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
Object.defineProperty(wrapper, "__is_Ref", {
value: true
})
return wrapper
}
toRef函数接收2个参数,第1个参数obj是响应数据,第2个是obj对象的一个键。该函数会返回类似ref结构的wrapper对象。
toRef只能一次解决对象的一个key,可以在做一次封装,将所有key都做代理,封装成toRefs函数
function toRefs(obj){
const ret = {};
// for in循环遍历
for(const key in obj){
// 循环调用 toRef 完成转换
ret[key]=toRef(obj, key)
}
return ret
}
// 这样只需一步操作,可完成整个对象的响应式转换
const newObj = {...toRefs(obj)}
现在通过toRef和toRefs方法,实现了将基本类型转成响应式。
此时toRef只实现了value属性的getter,还需要实现setter,增加设置时触发effect响应
function toRef(obj, key){
const wrapper={
get value(){
return obj[key]
},
// 可以设置值
set value(val){
obj[key] = val;
}
}
//使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
Object.defineProperty(wrapper, "__is_Ref", {
value: true
})
return wrapper
}
自动脱ref方法proxyRefs
toRef函数转化解决响应丢失问题,但是带来新的问题,使用时必须通过value属性访问值,增加使用麻烦。
因此对包含有__v_isRef属性的数据做特殊处理,使用时自动去掉value属性
function proxyRefs(target){
return new Proxy(target, {
get(target, key, receiver){
const value = Reflect.get(target, key, receiver);
//如果是Ref,则获取的是 value 值
return value.__v_isRef ? value.value : value;
},
set(target, key, newVal, receiver){
const value = target[key];
// 如果是Ref,则设置其对应的 value 属性值
if(value.__v_isRef){
value.value = newValue;
return true
}
return Reflect.set(target, key, newVal, receiver)
}
})
}
- 第6行,设置getter的去value属性
- 第13行,设置setter的去value属性
第7-11章 渲染器
7实现自定义渲染器
渲染器是执行渲染任务。vue3渲染器不仅包括Diff算法,还包含特有的快捷路径更新策略,充分结合编译器实现性能优化。
7.1渲染器与响应式数据结合
最基本的渲染器,就是一个函数
function renderer(domString, container){
container.innerHTML = domString;
}
// 使用方法
renderer("<h1>vue3 renderer</h1>", document.getElementById("app"))
以上就实现了一个渲染器,并将h1标签的内容,插入到页面id为app内。
在vue中结合响应式数据。
function renderer(domString, container){
container.innerHTML = domString;
}
let count = ref(1);
// 使用方法
effect(()=>{
renderer(`<h1>vue3 renderer, ${count}</h1>`, document.getElementById("app"))
})
count.value++;
- 定义响应式数据count
- 在副作用函数effect中调用渲染器renderer函数执行
- count数据发生变化,渲染器重新执行,更新页面内容。
可以使用vue的reactive.global.js模拟上述过程
<script src="https://unpkg.com/@vue/reactivity@3.2.35/dist/reactivity.global.js"></script>
<script>
const {effect, ref} = VueReactivity;
function renderer(domString, container){
container.innerHTML = domString;
}
let count = ref(1);
// 使用方法
effect(() => {
renderer(
`<h1>vue3 renderer, ${count.value}</h1>`,
document.getElementById("app")
);
});
setTimeout(() => {
count.value++;
}, 400);
</script>
7.2渲染器基本概念
renderer是渲染器,名词。render是渲染,动词。渲染器把虚拟DOM渲染成真实DOM元素,这个过程叫挂载。
渲染器要接收一个挂载点作为参数,用来指定挂载的位置。
使用一个函数createRenderer来创建渲染器
function createRenderer(){
function render(vnode, container){
}
function hydrate(vnode, container){
}
return { render, hydrate }
}
渲染器不仅包含render函数,还包含hydrate函数(和服务端渲染相关)。
用渲染器执行任务
const renderer = createRenderer();
// 渲染任务
renderer.render(vnode, container)
// 第二次渲染
renderer.render(newVnode, container)
- 首先用createRenderer创建一个渲染器renderer,接着调用render函数进行渲染工作。
渲染器除了挂载节点外,还有多次渲染的更新动作。更新节点即patch的过程
function createRenderer(){
function render(vnode, container){
if(vnode){ //vnode存在,进行挂载动作
// vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
patch(container._vnode, vnode, container)
} else {//vnode不存在
if(container._vnode){ //container._vnode存在,说明是卸载过程
//需要将container内的DOM清空
container.innerHTML = "";
}
}
container._vnode = vnode;
}
function patch(n1, n2, container){}
return { render }
}
patch函数的三个参数
n1:旧vnode
- n2:新vnode
- 第三个参数container:挂载容器
在首次渲染时,容器元素container._vnode属性不存在,为undefined。意味着首次渲染传递给patch函数的第一个参数n1是undefined。
演示连续调用3次的过程
const renderer = createRenderer();
// first
renderer.render(vnode1, container);
// second
renderer.render(vnode2, container);
// third
renderer.render(null, container);
7.3自定义渲染器
渲染器可以通过配置特定API,可实现渲染到任意平台的目标。
创建一个以浏览器为渲染目标平台的渲染器,然后可以将浏览器API进行抽象,即可转换为通用渲染器。
定义一个h1的vnode对象
const vnode = {
type: "h1",
children: "hello"
}
用type属性来描述vnode类型,当type是字符串,可认为是普通标签,并将type作为标签名。
使用renderer渲染vnode
const vnode = {
type: "h1",
children: "hello"
}
const renderer = createRenderer();
renderer.render(vnode, container);
function createRenderer(){
function patch(n1, n2, container){
if(!n1){
mountElement(n2, container)
} else {
// n1存在,进行更新操作
}
}
funtion mountElement(vnode, container){
// 创建DOM元素
let el = document.createElement(vnode.type);
// 处理子节点,如果子节点是字符串,代表元素具有文本节点
if(typeof vnode.children === "string"){
//设置元素的textContent属性即可
el.textContent = vnode.children;
}
//将元素添加到容器中
container.appendChild(el)
}
function render(vnode, container){
if(vnode){ //vnode存在,进行挂载动作
// vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
patch(container._vnode, vnode, container)
} else {//vnode不存在
if(container._vnode){ //container._vnode存在,说明是卸载过程
//需要将container内的DOM清空
container.innerHTML = "";
}
}
container._vnode = vnode;
}
return {
render
}
}
以上过程先调用document.createElement函数,用vnode.type作为标签名创建新DOM元素,接着处理vnode.children.如果是字符串,则将内容设置为元素的textContent属性,最后完成appendChild操作。
这是挂载一个普通标签元素的流程。我们的目标是设计一个不依赖浏览器平台的通用渲染器。只需将mountElement函数依赖的浏览器特有API进行抽离。
function createRenderer(options){
//通过options传入特定API
const {createElement, insert, setElementText} = options;
//在mountElement函数中,使用特定API
function mountElement(vnode, container){
// 调用createElement函数创建元素
const el = createElement(vnode.type)
if(typeof vnode.children === "string"){
//调用setElementText设置元素的文本节点
setElementText(el, vnode.children)
}
//调用insert函数将元素插入到容器
insert(el, container)
}
}
// 自定义传入打印流程API
const renderer = createRenerer({
createElement(tag){
console.log("创建元素",tag)
return {tag}
},
setElementText(el, text){
console.log(`设置${JSON.stringify(el)} 的文本内容: ${text}`)
el.text = text;
},
insert(el, parent, anchor=null){
console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
parent.children = el
}
})
通过给createRenderer传入不同的配置项,这样就可以实现自定义的渲染器。
代码示例
自定义渲染器案例项目
8.挂载和更新
8.1处理子节点和元素属性
子节点可能包含多个,所以需要设置成数组类型;即将children设置成数组
const vnode = {
type: 'div',
children: [{},{}]
}
定义成数组类型,然后就需要修改mountElement方法,增加对数组类型处理。
function mountedElement(vnode, container){
const el= createElement(vnode.type);
// 处理vnode 的children属性
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
+ vnode.children.forEach((child) => {
+ patch(null, child, el);
+ });
}
}
vnode.children是数组类型,则进行循环遍历操作。执行patch函数,在patch函数内部,挂载阶段会递归调用mountedElement方法。
处理过子节点后,开始处理props属性。
function mountedElement(vnode, container){
const el= createElement(vnode.type);
// 处理vnode 的children属性
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
// 处理vnode 的props属性
+ if (vnode.props) {
+ for (let key in vnode.props) {
+ el.setAttribute(key, vnode.props[key]);
+ }
+ }
}
这里简单的用setAttribute进行元素属性的设置。
为元素设置属性需要处理很多边界条件,在后边会单独分析。
挂载元素的流程
8.2HTML Attributs 和DOM Properties
理解HTML Attributes和DOM Properties差异,能正确的设计虚拟节点的结构,正确的为元素设置属性。<input id="my-input" type="text" value="foo"/>
以上这段html代码,其中标签上的属性 id=”my-input”、 type=”text”、value=”foo”就是HTML Attributes。
当用js获取这段html代码时,得到的对象就是DOM对象,dom对象的属性就是 Properties。const el = document.querySelector("my-input")
- DOM Properties 和HTML Attributes的名称不是一一对应,比如样式class在html中是class,在dom中用className表示。
- 不是所有的DOM Properties都有对应的HTML Attributes。比如可以使用el.textContent给元素设置文本内容,但是HTML Attributes没有对应的属性。
关于值的变化
在input标签中,如果用户没有修改文本框的内容,那么通过el.value和el.getAttributes都是获取的foo。
如果用户修改了文本框的内容为bar。console.log(el.value); // "bar"
console.log(el.getAttributes); // 仍是 "foo"
文本框内容的修改不会影响el.getAttributes的返回值,该值表示HTML Attributes的意义。 DOM Properties始终存储的是当前最新值。
仍然可以通过defaultValue获取到默认值, console.log(el.defaultValue);
⭐️⭐️⭐️⭐️核心关系:HTML Attributes的作用是设置DOM Properties的初始值。
8.3正确的设置元素属性
默认情况下浏览器会自动分析html attributes并设置合适的dom properties,但是在使用vue模版时,就不能被浏览器解析,所以这部分设置属性工作需要vue框架来完成。
以设置按钮禁用属性为例<button disabled>button</button>
, 浏览器解析html时会设置一个disabled的属性给html attributes。并将el.disabled的DOM Properties值设置为true。
同样代码在vue模版中会被编译成vnode;
vnode的props.disabled值为空字符串,如果在渲染器中调用setAttribute函数设置属性:el.setAttribute("disabled", "")
,这样可以给按钮设置禁用状态。
但是当用户设置<button :disabled="false">不禁用按钮</button>
时,经过转换为vnode后
const button = {
type: "button",
props: {
disabled: false // 不禁用按钮
}
}
渲染器使用el.setAttribute函数设置属性,那么按钮就被禁用了 ,因为使用el.setAttribute函数时,总是会被字符串化,结果为el.setAttribute(“disabled”, “false”); 只要disabled属性存在,按钮就会被禁用;
为了解决这个问题,需要在vue框架中特殊处理
- 优先设置元素DOM Properties
- 当值为空字符串时,要手动改正为true。
```javascript
function mountElement(vnode, container) {
const el = createElement(vnode.type);
// 处理vnode 的children属性
if (typeof vnode.children === “string”) {
} else if (Array.isArray(vnode.children)) {setElementText(el, vnode.children);
} // 处理vnode 的props属性 if (vnode.props) {console.log("child", vnode.children);
vnode.children.forEach((child) => {
patch(null, child, el);
});
- for (let key in vnode.props) {
- // 先设置 properties属性
- if (key in el) {
- const type = typeof el[key];
- const value = vnode.props[key];
- //如果是boolean类型,且值为空,手动修复为 true
- if (type === “boolean” && value === “”) {
- el[key] = true;
- } else {
- el[key] = value;
- }
- } else {
- // 如果没有对应的dom properties,则使用setAttribute函数设置属性
- el.setAttributes(key, vnode.props[key]);
- }
- }
}
// 将生成的el元素插入到container中
insert(el, container);
}
代码示例[代码示例](https://codesandbox.io/s/vue-design-15bzo9?file=/8part/8.3.js)
<a name="Mbi4J"></a>
#### 处理特殊属性,只能用setAttribute
但是这样处理还是有问题,有一些DOM Properties属性是只读的。 `<input form="form1" />`,input标签的form属性(HTML Attributes),它对应的DOM Properties是el.form,但是el.form是只读属性,那么就只能通过setAttribute函数来设置它。
```javascript
function shouldSetProps(el, key, value) {
// 特殊处理只能通过setAttribute函数设置的属性
if (key === "form" && el.tagName === "INPUT") return false;
return key in el;
}
function mountedElement(vnode, container){
// ...
// 处理vnode 的props属性
if (vnode.props) {
for (let key in vnode.props) {
const value = vnode.props[key];
// 先设置 properties属性
// 通过shouldSetProps方法进行判断,排除掉一些只能用setAttribute设置的属性
if (shouldSetProps(el, key, value)) {
const type = typeof el[key];
//如果是boolean类型,且值为空,手动修复为 true
if (type === "boolean" && value === "") {
el[key] = true;
} else {
el[key] = value;
}
} else {
// 如果没有对应的dom properties,则使用setAttribute函数设置属性
el.setAttributes(key, vnode.props[key]);
}
}
}
//...
}
将属性处理方法抽离为与平台无关
将属性的设置操作提取到渲染器选项中,通过创建renderer实例的options进行设置处理。增加了灵活性。
代码示例const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag)
},
setElementText(el, text) {
el.textContent = text
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
patchProps(el, key, preValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
})
8.4class属性设置
在vue框架中对class属性做了增强。序列化处理class
方式1:指定class为字符串值
方式2:指定class为对象
方式3:class可以包含上面2中类型的数组
class可以包含多种类型值,需要使用normalizeClass函数将不同类型的class值转为正常的字符串。
通过normalizeClass转换vnode的class值
设置class属性
作者对比3种设置class方式【el.className, el.setAttributes, classList】的性能,发现el.className性能最佳。
调整patchProps函数 ```javascript const renderer = createRenderer({ //… patchProps(el, key, prevValue, nextValue){ - if (key === “class”) {
- // 对class属性进行处理
- el.className = nextValue || “”;
} else if (shouldSetProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === “boolean” && nextValue === “”) {
el[key] = true;
} else {
el[key] = nextValue;
}
} else {
el.setAttribute(key, nextValue);
}
}
})
当给render的第一个参数设置为null,就是执行的卸载。[完整代码示例](https://codesandbox.io/s/vue-design-15bzo9?file=/8part/8.4.js)<br />其实处理class需要特殊格式化处理,还有style也需要类似的处理,详情可以查看[vue源码](https://github.com/shenshuai89/core/blob/main/packages/shared/src/normalizeProp.ts#L6)
<a name="WMtUp"></a>
### 8.5卸载操作
前面4节介绍了挂载操作,这节介绍卸载操作。<br />卸载发生在更新阶段,更新指的是在初次挂载完成后,后续渲染触发的属性或值的变化。
```javascript
// 初次挂载
renderer.render(vnode, document.querySelector("#app"));
// 更新
renderer.render(newVnode, document.querySelector("#app"))
// 卸载
renderer.render(null, document.querySelector("#app"));
在前面mountElement函数中的render方法,如果container._vnode不存在,则直接container.innerHTML = “”;
这么做是不严谨的,主要原因有:function render(vnode, container){
if(vnode){ //vnode存在,进行挂载动作
// vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
patch(container._vnode, vnode, container)
} else {//vnode不存在
if(container._vnode){ //container._vnode存在,说明是卸载过程
//需要将container内的DOM清空
container.innerHTML = "";
}
}
container._vnode = vnode;
}
- 容器的内容可能有某个或多个组件渲染的,当卸载操作发生时,应当正确的调用这些组件的beforeUnmount、unmounted等生命周期的函数
- 还有些元素存在自定义指令,应该在卸载的时候正确执行对应的指令钩子。
- 使用innerHTML清空容器元素,不会移除绑定在DOM元素上的事件处理函数。
正确的卸载办法: 根据vnode对象获取与之相关联的真实DOM元素,然后使用DOM操作方法,将该DOM移除。 因此需要建立vnode和真实DOM元素之间的关系。 const el = vnode.el = createElement(vnode.type)
function mountElement(vnode, container){
//...
function render(vnode, container) {
console.log("render", vnode, container);
// vnode存在,说明是挂在创建阶段
if (vnode) {
patch(container._vnode, vnode, container);
} else {
// 新vnode节点不存在,并且判断下旧的_vnode存在,说明是卸载阶段
if (container._vnode) {
// 重新调整卸载操作,根据vnode.el值 移除真实DOM内容
const el = container._vnode.el;
// 获取el的父元素
const parent = el.parentNode;
if (parent) parent.removeChild(el);
}
}
// 把 vnode 存储到 container._vnode 下,作为后续渲染中的旧 vnode节点存在
container._vnode = vnode;
}
//...
}
container._vnode代表旧vnode,要被卸载的vnode,然后通过container._vnode.el取得真实DOM元素,并调用removeChild函数将其从父元素中移除。
由于卸载操作是比较常见的基本操作,可以单独封装到unmount函数中。
function unmount(vnode){
const parent = vnode.el.parentNode;
if(parent){
parent.removeChild(vnode.el);
}
}
8.6区分vnode类型
在patch函数中,对比n1和n2元素进入打补丁操作。
function patch(n1, n2, container){
if(!n1){
mountElement(n2, container);
} else {
// update
}
}
在更新操作时,先对比n1和n2 的type是否相同。如果不同,就没有patch的意义,可以直接将n1卸载。
function patch(n1, n2, container){
if(n1 && n1.type !== n2.type){
// 新旧节点的类型不同,直接将旧的vnode节点n1卸载
unmount(n1);
n1 = null;
}
if(!n1){
mountElement(n2, container);
}else {
// update
}
}
vnode.type的类型不同,需要进行的操作处理不同,因此需要调整patch进行不同类型的处理
function mountElement(vnode, container){
//...
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
// 根据不同type类型,分情况处理,如果是string,直接更新element,如果是对象,则更新组件
if (typeof type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === "object") {
//如果n2.type的值的类型是对象,表示的是组件
} else if (type === "xxx") {
// 处理其它类型的值
}
}
// ...
}
8.7事件处理
像处理普通属性一样处理事件
把事件当作一种特殊的属性,可以按照约定,在vnode.props对象中,凡是以字符串on开头的属性都是事件。
const vnode = {
type: "p",
props: {
onClick: ()=>{
alert("clicked");
}
},
children: 'text'
}
解决了事件在虚拟节点层面的问题,接下来处理如何将事件添加到DOM元素上,调整patchProps,增加addEventListener函数绑定事件。
function patchProps(el, key, prevValue, nextValue){
// 匹配以on开头的属性
if(/^on/.test(key)){
const eventName = key.slice(2).toLowerCase();
el.addEventListener(eventName, nextValue);
}else if(key === "class"){
// ...
}
//...
}
那么更新事件呢,按照处理props属性的方式,先移除之前的,再添加新的。
function patchProps(el, key, prevValue, nextValue){
// 匹配以on开头的属性
if(/^on/.test(key)){
const eventName = key.slice(2).toLowerCase();
// 移除之前的事件函数
prevValue && el.removeEventListener(eventName, prevValue);
// 设置最新的事件函数
el.addEventListener(eventName, nextValue);
}else if(key === "class"){
// ...
}
//...
}
处理特殊事件属性
可以伪造一个绑定事件处理函数invoker,然后把真正的事件处理函数设置为invoker.value属性的值。这样当更新事件的时候,将不再需要调用removeEventListener函数来移除上次绑定的事件。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
// el._evi设置成对象
invoker = el._vei[key] = (e) => {
// 一个事件类型还可以绑定多个事件处理函数。因此在vnode的props中存在数组情况
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
由于一个元素上可以绑定多个事件,为了避免事件覆盖,需要将el._evi的数据结构设置为对象,它的键是事件名称,它的值是对应的事件处理函数。
同一个类型的事件,还可以绑定多个事件处理函数。
const vnode = {
type: "p",
props: {
onClick:[
()=>{
alert("111")
},
()=>{
alert("222")
}
]
},
children: "text"
}
8.8事件冒泡和更新时机
主要目的是:屏蔽到所有绑定时间【attached】晚于事件触发时间【timeStamp】的所有事件执行。 原因很简单,点击时事件还没进行绑定的事件,一律不执行。否则会引发错误。
更新patchProps方法
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
+ console.log(e.timeStamp) // 事件触发时间
+ console.log(invoker.attached) //事件绑定时间
+ if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
+ invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
8.9 更新子节点
前面所有示例都只是实现挂载操作,并没进行更新处理。在挂载子节点时,首先区分其类型。
- 如果vnode.children是字符串,说明元素是文本子节点
- 如果vnode.children是数组,说明元素具有多个子节点
子节点类型的规范化,有利于处理更新逻辑。
对于元素的更新,主要有以下3种情况
<!--没有子节点-->
<div></div>
<!--文本子节点-->
<div>123</div>
<!--多个子节点-->
<div>
<p></p>
<h1></h1>
</div>
- 没有子节点,vnode.children的值是null
- 具有文本子节点,vnode.children的值是字符串,代表文本内容
- 其他情况,无论是单个元素子节点,还是多个子节点,都可以用数组来表示
一个vnode的子节点有3种可能,那么当渲染器更新时,新旧子节点都分别是3种可能。
用代码实现更新的过程
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 更新props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
// 更新children,是对一个元素进行patch打补丁的最后一步操作
patchChildren(n1, n2, el)
}
新的children类型是字符串
function patchChildren(n1, n2, container){
// 判断新子节点的类型是否是文本节点
if(typeof n2.children === "string"){
// 旧的子节点有三种类型可能:只有当是一组节点时才需要逐个卸载
if(Array.isArray(n1.children)){
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
}
}
以上代码表示,首先检测新节点类型是否是文本节点,如果是则要检查旧子节点的类型。旧子节点类型有三种可能,只有旧子节点是一组子节点时,需要循环遍历他们,并逐个调用unmount函数进行卸载。其他2种情况不需要任何操作处理。
新的子节点类型是数组
如果新子节点不是文本,再增加新的处理逻辑分支
function patchChildren(n1, n2, container){
// 判断新子节点的类型是否是文本节点
if(typeof n2.children === "string"){
// 旧的子节点有三种类型可能:只有当是一组节点时才需要逐个卸载
if(Array.isArray(n1.children)){
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
}// 以下为新增
else if(Array.isArray(n2.children)){// 新元素子节点类型是数组
//判断旧子节点n1的children是否也是一组子节点
if(Array.isArray(n1.children)){
// 新旧子节点都是一组子节点,这里涉及到了核心的Diff算法,后续进行处理
// todo
}else{
// 旧的子节点要么是文本子节点,要么不存在
// 无论哪种情况,都只需要将容器清空,然后将新的一组子节点逐个挂载
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}
}
以上代码新增了对n2.children类型判断,检测它是否为一组子节点,如果是则接着判断旧子节点的类型。
- 旧子节点是一组子节点,涉及到新旧两组子节点对比,就是vue的diff算法。后续进行详细分析,这里可以采用简单的处理方式:把旧节点全部卸载,再将新的一组子节点进行挂载。
- 如果旧子节点是没有子节点或只是文本节点,只需要将容器元素清空,然后再逐个将新的一组子节点挂载到容器中即可。
```javascript
function patchChildren(n1, n2, container) {
if (typeof n2.children === ‘string’) {
if (Array.isArray(n1.children)) {
} setElementText(container, n2.children) } else if (Array.isArray(n2.children)) { if (Array.isArray(n1.children)) {n1.children.forEach((c) => unmount(c))
- n1.children.forEach(c => unmount(c))
- n2.children.forEach(c => patch(null, c, container))
} else {
setElementText(container, ‘’)
n2.children.forEach(c => patch(null, c, container))
}
}
}
```
最后一个情况,新的子节点为null
```javascript function patchChildren(n1, n2, container) { if (typeof n2.children === ‘string’) { if (Array.isArray(n1.children)) { n1.children.forEach((c) => unmount(c)) } setElementText(container, n2.children) } else if (Array.isArray(n2.children)) { if (Array.isArray(n1.children)) { n1.children.forEach(c => unmount(c)) n2.children.forEach(c => patch(null, c, container)) } else { setElementText(container, ‘’) n2.children.forEach(c => patch(null, c, container)) } } else { // 新的子节点不存在 - if (Array.isArray(n1.children)) { // 旧的子节点是一组,需要逐个卸载
- n1.children.forEach(c => unmount(c))
- } else if (typeof n1.children === ‘string’) { // 旧的子节点是文本,直接清空
- setElementText(container, ‘’)
- }
- } } ``` 最后走到else分支,说明新的子节点不存在。这是仍需要判断旧的子节点类型;
- 如果旧子节点不存在,什么都不需要做
- 旧的子节点是文本节点,则清空文本内容
- 旧的子节点是一组节点,则逐个卸载。
8.10文本节点和注释节点
使用虚拟DOM描述多种类型的真实DOM,最常见的两种节点类型是文本节点和注释节点。
vnode.type属性代表一个vnode的类型,如果vnode.type的值是字符串,则表示描述的是普通标签,并且该值就是标签的名称,如div,p; 但是注射节点和文本解读不同于普通标签节点,它没有标签,因此需要创造出唯一的标识,来表示注释节点和文本节点的type属性值:
// 文本节点的type标识
const Text = Symbol();
const TextVnode = {
type: Text;
children: "text text"
}
// 注释节点的type标识
const Comment = Symbol();
const commentVnode = {
type: Comment,
children: "commentVnode"
}
有了文本节点和注释节点的vnode对象后,就可以使用渲染器来渲染他们。
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
if (!n1) {
// 创建文本节点
const el = n2.el = document.createTextNode(n2.children)
// 将文本节点插入到容器中
insert(el, container)
} else {
// 如果旧vnode存在,只需要更新旧节点的内容
const el = n2.el = n1.el
if (n2.children !== n1.children) {
el.nodeValue = n2.children;
}
}
}
}
patch函数依赖平台特有API,可以通过createTextNode和setText方式实现更新。
在创建renderer实例时,给options新增createTextNode和setText方法
const renderer = createRenderer({
//...
createTextNode(text){
return document.createTextNode(text)
},
setText(){
el.nodeValue = text;
}
//...
})
修改patch中的操作,使用特定的平台API;
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
if (!n1) {
const el = n2.el = createText(n2.children)
insert(el, container)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
setText(el, n2.children)
}
}
}
}
注释节点的处理和文本节点处理方式类似,只需使用document.createComment函数创建注释节点元素
代码示例:
8.11 Fragment多根节点标签
Fragment是vue3新增的节点标签,也需要创建单独的type类型。Fragment主要是为了解决多根元素节点的标签。
<template>
<li>1</li>
<li>1</li>
<li>1</li>
</template>
// 对应的虚拟节点 vnode
const vnode = {
type: Fragment,
children: [
{type: "li", children: "1"},
{type: "li", children: "2"},
{type: "li", children: "3"},
]
}
增加了Fragment标签,调整渲染器的渲染逻辑处理,渲染Fragment标签本身不会渲染任何内容,所以只会渲染Fragment子节点内容。
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
if (!n1) {
const el = n2.el = createText(n2.children)
insert(el, container)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
setText(el, n2.children)
}
}
+ } else if (type === Fragment) {
+ if (!n1) {
+ n2.children.forEach(c => patch(null, c, container))
+ } else {
+ patchChildren(n1, n2, container)
+ }
+ }
}
在patch函数中增加了Fragment类型虚拟节点的处理,在卸载时也需要支持Fragment类型的卸载
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}