第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'){
mountElement(vnode, container)
} else if(typeof vnode.tag === 'function'){
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) {
mountChildren(children, el);
}
hostInsert(el, container, anchor);
};
mountElement语意化直白实现
function mountElement(vnode, container) {
const el = document.createElement(vnode.tag)
for (const key in vnode.props) {
if (/^on/.test(key)) {
el.addEventListener(key.substr(2).toLowerCase(), vnode.props[key])
} else{
el.setAttribute(key, vnode.props[key]);
}
}
if(typeof vnode.children === "string") {
el.appendChild(document.createTextNode(vnode.children))
}else if(Array.isArray(vnode.children)){
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){
const subTree = vnode.tag();
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),作用是存储被注册的副作用函数。
let activeEffect = undefined;
function effect(fn){
activeEffect = fn;
fn();
}
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) {
if (!activeEffect) return target[key];
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(()=>{
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)
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
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;
let effectStack = []
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length -1]
};
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=>{
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");
如果希望over打印显示在第二行,此时只能用户端调整打印顺序到effect上边。 代码演示
控制执行时机
通过给effect副作用函数添加options,设置scheduler调度
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
effectFn.options = options;
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) => {
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
})
修改effect的实现逻辑
let activeEffect = undefined;
let effectStack = [];
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) {
effectFn();
}
return effectFn;
}
修改effect后,如果传递的options中有参数lazy:true,则不立即执行。 代码演示 默认只有effect第一次执行,后边需要手动调用。 如果仅仅满足手动执行副作用,也没太大用途。可以把effect内的函数作为一个getter,这个getter函数可以返回任何值。 调整effect函数,通过对effectFn进行包装,effectFn是包装后的副作用,此包装副作用的返回值才是真正的副作用。代码第16行。如果是lazy的情况下,只是返回副作用的函数第29行,并不会执行第26行。只有在非lazy的情况下,才能返回包装副作用effectFn函数的执行结果,从而才能执行真正的副作用函数fn。
let activeEffect = undefined;
let effectStack = [];
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
const res = fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res;
};
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) {
effectFn();
}
return effectFn;
}
function computed(getter) {
const effectFn = effect(getter, { lazy: true });
const obj = {
get value() {
return effectFn();
}
};
return obj;
}
使用computed
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函数添加对值的缓存功能。
function computed(getter) {
let value;
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
scheduler() {
dirty = true;
}
});
const obj = {
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(){
}
})
watch的实现就是依赖effect中的scheduler,当响应式数据发生变化,如果副作用函数存在scheduler选项,则触发scheduler函数执行,而不是直接触发副作用函数执行。 依据这一特性,简单实现watch
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具有通用行,需要封装一个通用的读取操作:
function watch(source, cb) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
effect(() => getter(source), {
scheduler() {
cb();
}
});
}
function traverse(value, seen = new Set()) {
if (typeof value !== "object" || value === null || seen.has(value)) return;
seen.add(value);
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,即第一次执行的结果。
function watch(source, cb) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let newVal, oldVal;
const effectFn = effect(() => getter(), {
lazy: true,
scheduler() {
newVal = effectFn();
cb(newVal, oldVal);
oldVal = newVal;
}
});
oldVal = effectFn();
}
function traverse(value, seen = new Set()) {
if (typeof value !== "object" || value === null || seen.has(value)) return;
seen.add(value);
for (let key in value) {
traverse(value[key], seen);
}
return value;
}
代码示例
watch(
() => obj.foo,
(nv, ov) => {
console.log("foo的数据变化了", nv, ov);
}
);
obj.foo++;
4.10立即执行 watch
watch的实现,使用了options的lazy属性,所以不会立即执行。为了能够让watch的回调函数在创建时立刻执行一次,可以给watch添加第三个参数 immediate: true;
/ watch函数接收3个参数,
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let newVal, oldVal;
const job = () => {
newVal = effectFn();
cb(newVal, oldVal);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: job
});
if (options.immediate) {
job();
} else {
oldVal = effectFn();
}
}
除了给watch的第三个参数options设置immediate还可设置flush来控制回调函数的执行时机。
watch(
() => obj.foo,
(nv, ov) => {
console.log("foo的数据变化了", nv, ov);
},
{
flush: 'post'
}
);
obj.foo++;
- post : 回调函数需要将副作用函数放到微任务队列中
- sync:实现同步执行
- pre: 组件更新前执行
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let newVal, oldVal;
const job = () => {
newVal = effectFn();
cb(newVal, oldVal);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
}
});
if (options.immediate) {
job();
} else {
oldVal = effectFn();
}
}
watch(
() => obj.foo,
(nv, ov) => {
console.log("foo的数据变化了", nv, ov);
},
{
flush: "post"
}
);
obj.foo++;
console.log("out 同步执行函数");
4.11过期的副作用,可以被取消
正在执行的副作用,要能够被取消,否则会发生“竞态”问题。该问题可以在原始的xhr的abort中解决,也可在axios封装的 isCancel 中取消请求。 因此需要一种让副作用过期的技术。 watch的回调函数现在接收到newValue和oldvalue2个参数,通过设置第3个参数 onInvalidate 函数,这个函数类似事件监听器。使用onInvalidate注册回调函数,该回调函数在当前副作用函数过期时执行。
function delay(time) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(time);
}, time);
});
}
function watch(source, cb, options = {}) {
let getter;
if (typeof source === "function") {
getter = source;
} else {
getter = () => traverse(source);
}
let newVal, oldVal;
let cleanup;
function onInvalidate(fn) {
cleanup = fn;
}
const job = () => {
newVal = effectFn();
if (cleanup) {
cleanup();
}
cb(newVal, oldVal, onInvalidate);
oldVal = newVal;
};
const effectFn = effect(() => getter(), {
lazy: true,
scheduler: () => {
if (options.flush === "post") {
const p = Promise.resolve();
p.then(job);
} else {
job();
}
}
});
if (options.immediate) {
job();
} else {
oldVal = effectFn();
}
}
在watch中首先定义cleanup变量,用来存储用户通过onInvalidate函数注册的回调。 在job函数内,每次执行回调函数cb之前,先检查是否存在过期的回调,如果存在,则执行过期回调函数cleanup 最后把onInvalidate回调函数作为第三个参数传递给cb。 测试onInvalidate,
- 通过模拟发生接口请求,后边的接口请求返回速度快,
- 如果没有onInvalidate函数,则结果会显示为前一次接口返回的结果。这是错误的
- 通过设置onInvalidate函数,把上次的副作用函数给取消掉,就不会发生前次接口值覆盖最新接口值的情况。
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;
document.getElementById("app").innerHTML = finalData;
}
console.log("watch 内 finalData", finalData);
}
);
obj.foo++;
obj.foo++;
代码实例
5.对象类型的响应式方案reactive/proxy
5.1理解Proxy和Reflect对象
- Proxy只能代理对象类型
- 代理是指,能够对对象的基本操作进行拦截,通过上面虚线定义的那些方法处理对象。
- Proxy只能拦截对象的基本操作。复合操作处理不了,如obj.foo();
function fn(name) {
console.log(`my name is ${name}, ${this.name}`);
}
const p = new Proxy(fn, {
apply(target, thisArg, argArray) {
console.log(thisArg, argArray, "apply调用函数");
target.call(thisArg, argArray);
}
});
p.call({ name: "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 }));
const po = new Proxy(Obj, {
get(target, key, receiver) {
if (key === "c") return 6;
return Reflect.get(target, key, receiver);
}
});
console.log(po.count, "count");
在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)指定的,内部方法是当对一个对象进行操作时,在引擎内部调用的方法,这些方法对于我们使用者不可见。 ![image.png](https://img-blog.csdnimg.cn/img_convert/145aa1f7f43d14e08c7daaed83178778.png#clientId=u0b012927-410b-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=857&id=u1c885f18&margin=[object Object]&name=image.png&originHeight=943&originWidth=650&originalType=binary&ratio=1&rotation=0&showTitle=false&size=231890&status=done&style=none&taskId=u781bca26-21d2-4b9b-ab07-382b24a35c3&title=&width=591)
![image.png](https://img-blog.csdnimg.cn/img_convert/2fcf14e8014b6462cfaddfbee04710f5.png#clientId=u0b012927-410b-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=256&id=u97b73905&margin=[object Object]&name=image.png&originHeight=281&originWidth=648&originalType=binary&ratio=1&rotation=0&showTitle=false&size=75268&status=done&style=none&taskId=u7d0e4c3e-e305-45cd-ba74-e468bac5dad&title=&width=590) 以上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);
delete po.foo;
console.log(po.foo);
5.3如何代理对象
前面一直使用get拦截对象属性的读取,但在响应系统中,读取是一个很宽泛概念,使用in操作符检查对象上是否具有给定的key也是读取操作。一个普通对象的所有读取操作可能有:
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的key: key in obj
- 使用for… in 遍历对象: for(const key in obj) {}
第一种情况,可以直接使用get进行拦截。如果使用了in操作符,就需要查看对应的拦截函数。 ![image.png](https://img-blog.csdnimg.cn/img_convert/2435c96bf3e1f7021325d1a6716a0649.png#clientId=u0b012927-410b-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=272&id=ub7f438a3&margin=[object Object]&name=image.png&originHeight=544&originWidth=1250&originalType=binary&ratio=1&rotation=0&showTitle=false&size=150840&status=done&style=none&taskId=u70451739-2e1c-428e-a943-afed8ce46b2&title=&width=625) 可以看到in操作符运算结果是通过HasProperty的抽象方法得到。关于HasProperty 抽象方法可以看到内部对应的拦截函数是has。 ![image.png](https://img-blog.csdnimg.cn/img_convert/6c79a1acd33607b789b97295fa72bc68.png#clientId=u0b012927-410b-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=694&id=u55cf2adc&margin=[object Object]&name=image.png&originHeight=1388&originWidth=1728&originalType=binary&ratio=1&rotation=0&showTitle=false&size=265679&status=done&style=none&taskId=u2d86f858-085e-4d9f-b120-74add27c1bb&title=&width=864) in操作符使用has进行拦截。 通过查找for… in的规范,可以看到是通过ownKeys进行拦截。 ![image.png](https://img-blog.csdnimg.cn/img_convert/8483529456fa406569e1c98e6e98a7b0.png#clientId=u0b012927-410b-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=508&id=u4bbbbf74&margin=[object Object]&name=image.png&originHeight=1016&originWidth=1896&originalType=binary&ratio=1&rotation=0&showTitle=false&size=362915&status=done&style=none&taskId=ua3fa54dd-421a-42b7-b4ee-1e2d39e8898&title=&width=948)
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) {
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) {
track(target, ITERATE_KEY);
return Reflect.ownKeys(target);
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key, "DELETE");
}
}
});
effect(() => {
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);
}
});
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach((fn) => {
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);
}
});
if (type === "ADD") {
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach((fn) => {
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);
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);
if (type === "ADD" || type === "DELETE") {
const iterateEffects = depsMap.get(ITERATE_KEY);
iterateEffects &&
iterateEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach((fn) => {
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;
代码示例 为了解决这个问题,可以修改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;
NaN !== NaN;
继续修改setter操作函数。
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 && (oldVal ===oldVal || newVal === newVal) ) {
trigger(target, key);
}
return res;
},
})
代码示例
屏蔽原型链引起副作用更新
先把创建代理对象封装成通用的方法 reactive。这样可以方便创建多个代理对象。
const obj = { foo: 1 };
const child = reactive(obj);
const parent = reactive({ bar: 2 });
Object.setPrototypeOf(child, parent);
console.log("判断obj的原型是不是parent", Object.getPrototypeOf(obj) === parent);
effect(() => {
console.log(child.bar);
});
child.bar = 3;
- 给child设置了parent作为原型。
- child和parent都是响应式对象
- 修改child.bar属性,由于child自身上没有bar属性,会找到原型对象parent上。parent也是响应式对象,从而就触发了2次effect。
代码示例 解决办法:既然是执行2次,那么只要屏蔽掉一次就可以。两次更新都是在set拦截函数中触发,因此需要在拦截函数set中设置触发更新的条件。
set(target, key, newVal, receiver){
}
set(target, key, newVal, receiver){
}
可以发现,target在两次代理过程中是发生变化的,receiver是不变的。可以通过给receiver设置一个"raw"属性让它为原来的对象obj;
child.raw === obj;
parent.raw === obj;
修改reactive的getter和setter拦截函数
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === "raw") {
return target;
}
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 = 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){
return reavtive(res)
}
return res;
}
})
}
这样就可实现对象的深层次代理。修改obj.foo.bar的值,也能触发effect的更新。 代码实例 但是并不是所有情况都希望深度代理,这就产生了shallowReactive浅响应。
const obj = shallowReactive({foo: {bar: 1}})
effect(()=>{
console.log(obj.foo.bar)
})
obj.foo = {bar: 32}
obj.foo.bar = 2
使用函数柯里化,继续封装一层createReactive函数,将创建不同类型的响应式数据通过参数创建。
function createReactive(obj, isShallow = false){
return new Proxy(obj, {
get(target, key, receiver){
if(key === "raw"){
return target
}
const res = Reflect.get(target, key, receiver);
track(target, key);
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){
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
这2种对象处理的边界情况太多太复杂,还是要作者原书的描述。
6.原始值类型响应式方案ref的实现,getter/setter
第5章实现的响应式方案是建立在非原始值的对象上。如果是原始值基本类型:Boolean、Number、String、null、undefined、BigInt、Symbol类型的值。原始值是按值传递,而非引用传递,如果函数接收原始值作为参数,那么形参和实参直接没有关系,代理也就没意义。 JavaScript中的Proxy无法对原始值进行代理。
引入ref概念
原始值无法响应代理,通过包裹一层属性,变成对象类型。
function ref(val){
const wrapper={
value: val
}
return reactive(wrapper)
}
现在通过ref就可以给原始值创建响应式数据
const refVal = ref(1);
effect(()=>{
console.log(refVal.value);
})
refVal.value = 2
为了区分ref创建的响应式数据还是reactive创建的,需要在创建ref是添加__v_isRef属性
function ref(val){
const wrapper={
value: val
}
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});
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", {
value: true
})
return wrapper
}
toRef函数接收2个参数,第1个参数obj是响应数据,第2个是obj对象的一个键。该函数会返回类似ref结构的wrapper对象。 toRef只能一次解决对象的一个key,可以在做一次封装,将所有key都做代理,封装成toRefs函数
function toRefs(obj){
const ret = {};
for(const key in obj){
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", {
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);
return value.__v_isRef ? value.value : value;
},
set(target, key, newVal, receiver){
const value = target[key];
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){
patch(container._vnode, vnode, container)
} else {
if(container._vnode){
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();
renderer.render(vnode1, container);
renderer.render(vnode2, container);
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 {
}
}
funtion mountElement(vnode, container){
let el = document.createElement(vnode.type);
if(typeof vnode.children === "string"){
el.textContent = vnode.children;
}
container.appendChild(el)
}
function render(vnode, container){
if(vnode){
patch(container._vnode, vnode, container)
} else {
if(container._vnode){
container.innerHTML = "";
}
}
container._vnode = vnode;
}
return {
render
}
}
以上过程先调用document.createElement函数,用vnode.type作为标签名创建新DOM元素,接着处理vnode.children.如果是字符串,则将内容设置为元素的textContent属性,最后完成appendChild操作。 这是挂载一个普通标签元素的流程。我们的目标是设计一个不依赖浏览器平台的通用渲染器。只需将mountElement函数依赖的浏览器特有API进行抽离。
function createRenderer(options){
const {createElement, insert, setElementText} = options;
function mountElement(vnode, container){
const el = createElement(vnode.type)
if(typeof vnode.children === "string"){
setElementText(el, vnode.children)
}
insert(el, container)
}
}
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);
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);
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
+ 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") ![image.png](https://img-blog.csdnimg.cn/img_convert/a41d48b787e1eda23a37769510113521.png#clientId=ud81a9f6e-f3ed-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=362&id=ud12170e4&margin=[object Object]&name=image.png&originHeight=724&originWidth=830&originalType=binary&ratio=1&rotation=0&showTitle=false&size=87926&status=done&style=none&taskId=ud8b96914-3cab-4497-8661-03c450ab3f2&title=&width=415)
- 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。
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
console.log("child", vnode.children);
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
if (vnode.props) {
+ for (let key in vnode.props) {
+
+ if (key in el) {
+ const type = typeof el[key];
+ const value = vnode.props[key];
+
+ if (type === "boolean" && value === "") {
+ el[key] = true;
+ } else {
+ el[key] = value;
+ }
+ } else {
+
+ el.setAttributes(key, vnode.props[key]);
+ }
+ }
}
insert(el, container);
}
代码示例
处理特殊属性,只能用setAttribute
但是这样处理还是有问题,有一些DOM Properties属性是只读的。 <input form="form1" /> ,input标签的form属性(HTML Attributes),它对应的DOM Properties是el.form,但是el.form是只读属性,那么就只能通过setAttribute函数来设置它。
function shouldSetProps(el, key, value) {
if (key === "form" && el.tagName === "INPUT") return false;
return key in el;
}
function mountedElement(vnode, container){
if (vnode.props) {
for (let key in vnode.props) {
const value = vnode.props[key];
if (shouldSetProps(el, key, value)) {
const type = typeof el[key];
if (type === "boolean" && value === "") {
el[key] = true;
} else {
el[key] = value;
}
} else {
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函数
const renderer = createRenderer({
patchProps(el, key, prevValue, nextValue){
+ if (key === "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);
}
}
})
完整代码示例 其实处理class需要特殊格式化处理,还有style也需要类似的处理,详情可以查看vue源码
8.5卸载操作
前面4节介绍了挂载操作,这节介绍卸载操作。 卸载发生在更新阶段,更新指的是在初次挂载完成后,后续渲染触发的属性或值的变化。
renderer.render(vnode, document.querySelector("#app"));
renderer.render(newVnode, document.querySelector("#app"))
renderer.render(null, document.querySelector("#app"));
当给render的第一个参数设置为null,就是执行的卸载。 在前面mountElement函数中的render方法,如果container._vnode不存在,则直接container.innerHTML = “”;
function render(vnode, container){
if(vnode){
patch(container._vnode, vnode, container)
} else {
if(container._vnode){
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);
if (vnode) {
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
const el = container._vnode.el;
const parent = el.parentNode;
if (parent) parent.removeChild(el);
}
}
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 {
}
}
在更新操作时,先对比n1和n2 的type是否相同。如果不同,就没有patch的意义,可以直接将n1卸载。
function patch(n1, n2, container){
if(n1 && n1.type !== n2.type){
unmount(n1);
n1 = null;
}
if(!n1){
mountElement(n2, container);
}else {
}
}
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;
if (typeof type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === "object") {
} 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){
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){
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) {
invoker = el._vei[key] = (e) => {
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
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)
}
}
patchChildren(n1, n2, el)
}
接下来实现patchChildren函数。
新的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)){
if(Array.isArray(n1.children)){
}else{
setElementText(container, '')
n2.children.forEach(c => patch(null, c, container))
}
}
}
以上代码新增了对n2.children类型判断,检测它是否为一组子节点,如果是则接着判断旧子节点的类型。
- 旧子节点是一组子节点,涉及到新旧两组子节点对比,就是vue的diff算法。后续进行详细分析,这里可以采用简单的处理方式:把旧节点全部卸载,再将新的一组子节点进行挂载。
- 如果旧子节点是没有子节点或只是文本节点,只需要将容器元素清空,然后再逐个将新的一组子节点挂载到容器中即可。
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))
}
}
}
最后一个情况,新的子节点为null
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属性值:
const Text = Symbol();
const TextVnode = {
type: Text;
children: "text text"
}
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 {
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>
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)
}
}
代码示例链接
vue设计与实现是本对技术讲解非常细致的书,小伙伴们可以支持下创作者。文章内容基本是书中内容,记录的没有书中详细。更详细的了解请阅读原书
|