使用 Object.defineProperty 来创建一个简易的响应式数据系统
function reactive(obj) {
const buckets = new Map();
buckets.set("name", Math.floor(Math.random() * 10000000000));
const defineReactive = (obj, key, value) => {
let child = typeof value === 'object' && value !== null
? reactive(value) //递归调用使所有的对像都经过响应式
: value;
Object.defineProperty(obj, key, {
get() {
if (currentEffect) {
if (!buckets.has(key)) {
buckets.set(key, new Set());
}
buckets.get(key).add(currentEffect);
}
return child;
},
set(newValue) {
if (newValue !== child) {
// 设置新值
child = typeof newValue === "object" && newValue !== null
? reactive(newValue)
: newValue;
if (buckets.has(key)) {
buckets.get(key).forEach(effect => effect()); // 触发所有监听器
}
}
}
})
}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
defineReactive(obj, key, obj[key])
}
}
return obj;
}
let currentEffect = null;
function effect(fn) {
currentEffect = fn;
fn();
currentEffect = null;
}
const state = reactive({
count: 2,
nested: {
count: 10
}
});
effect(function hooks1() {
console.log(`count is => `, state.count);//count is => 2
})
effect(function hooks2() {
console.log(`nested count is => `, state.nested.count);//nested count is => 10
})
console.log("=====================");
// 修改 count 属性
state.count++; // 输出: count is => 3
// 修改 nested.count 属性
state.nested.count += 5; // 输出:nested count is => 15
总结归纳:
- 当初发 get 时收集 effect,这个收集时在触发 get 之前把函数放到一个全局变量,收集后再置为 null
- 当对象内还有对象时,递归调用响应式方法创建响应式数据
- 每次递归,都会在当前的 reactive 方法中创建这一层的 buckets,不会影响其他层。
- 最后在 set 时触发 effect 回掉函数。
使用 Proxy 来创建一个简易的响应式数据系统
function reactive(obj) {
const buckets = new Map();
const proxyObj = (data) => {
return new Proxy(data, {
get(target, key, reciver) {
const value = Reflect.get(target, key, reciver);
if (currentEffect) {
if (!buckets.has(key)) {
buckets.set(key, new Set())
}
buckets.get(key).add(currentEffect);
}
return typeof value === 'object' && value !== null ? proxyObj(value) : value;
},
set(target, key, newValue, reciver) {
const oldValue = target[key];
const result = Reflect.set(target, key, newValue, reciver);
if (oldValue !== newValue) {
if (buckets.has(key)) {
buckets.get(key).forEach(effect => effect());
}
}
return result;
}
})
}
return proxyObj(obj);
}
let currentEffect = null;
function effect(fn) {
currentEffect = fn;
fn();
currentEffect = null;
}
const data = reactive({
name: 'text',
child: {
age: 100,
company: {
title: 'Hello'
}
}
})
effect(function hooks1() {
console.log(`data.name is => `, data.name);
})
effect(function hooks2() {
console.log(`data.child.age is => `, data.child.age);
console.log(`data.child.company.title is => `, data.child.company.title);
})
data.name = "xiaoming";
data.child.age++;
data.child.company.title = "World";
//data.name is => text
//data.child.age is => 100
//data.child.company.title is => Hello
//data.name is => xiaoming
//data.child.age is => 101
//data.child.company.title is => Hello
//data.child.age is => 101
//data.child.company.title is => World
总结:使用 proxy 拦截对象,并根据 key 来作为收集依赖的键
实现数据响应式的思路
Vue3 中基于 Proxy 实现了数据响应式。
首先,将需要实现响应式的数据 data,用 proxy 代理,返回的 state 即可以响应数据变化。那么是怎么操作呢?
首先明确,响应数据变化的意思是,当数据发生改变时,依赖这些数据的一些“副作用”函数可以得到调用,从而触发依赖这些数据的地方得到渲染或者通知。
那么在 proxy 中,需要拦截的操作就是“读”和“修改”。当“读”数据时,把当前依赖这个数据的“副作用”保存起来,等到修改这个数据时,再把这些“副作用”函数依次调用就 ok 了。
那么再来说“副作用”。“副作用”函数就是执行后可能会对其他函数或者变量产生影响的函数。例如:
function effect(){
document.title = "hello world";
}
当执行这个 effect 函数后,会影响当前文档的标题,这就是“副作用”。在 vue 中,对“副作用”一般叫做 effect 函数。
那么,如何收集 effect 函数呢?
const data = {
ok: true,
title: 'Hello World',
foot: true,
bar: true,
count: 1,
}
const bucket = new WeakMap();
let activeEffect;
const state = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
}
});
function track(target, key) {
if (!activeEffect) {
return;
}
if (!bucket.has(target)) {
bucket.set(target, new Map()); //WeekMap->{target: Map}
}
let depsMap = bucket.get(target);
if (!depsMap.has(key)) {
depsMap.set(key, new Set()); //Map->{key: Set}
}
let deps = depsMap.get(key);
deps.add(activeEffect);
}
function trigger(target, key) {
if (!bucket.has(target)) {
return;
}
const depsMap = bucket.get(target);
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn => {
effectFn();
});
}
function effect(fn) {
activeEffect = fn;
fn();
}
effect(function MyEffect() {
console.log(state.count, '===来自myeffect');
})
state.count++;
这就是最简单的一个数据响应式。当 MyEffect 函数内部访问 state.count 时,会触发 proxy 上 get 方法,在 get 方法中,我们调用 tarck 函数,将当前的 activeEffect 放到我们提前放置好的数据结构中(这个数据结构是一个树状数据结构,以 WeakMap 结构开始,以代理的对象 obj 为 key,value 为一个 Map,Map 中存放的 key 就是当前 get 方法中触发的 key,value 为一个 Set 数组,里面存放这依赖这个 key 的 effect 数组。)。当最后调用 state.count++时,会触发 set 方法,通过 trigger 方法,将 state 上依赖 count 变化的所有 Set 数组中的 effect 函数回调一遍,这时,MyEffect 就会被触发。至此,数据响应完成。
当然这里面还有很多问题未解决,但是已经是一个最简单的数据响应系统了。
如果 effect 中有判断语句,如何解决 effect 重复触发问题?
effect(function MyEffect() {
console.log(state.bar ? state.count : "no need");
})
如上,如果 effect 内有条件判断,按道理,如果 state.bar 为 true 情况下,我们需要关心 state.count 的变化,但如果 state.bar 修改为了 false,那么我们就不需要再关心 state.count 了。但按照原来的逻辑,即使 state.bar 变成了 false,state.count 变化后还是会触发 MyEffect 函数。
原因是因为我们只在 track 中收集了 effect,并不会根据条件移出 count 属性中的 MyEffect 回调函数。因此当 state.count 变化时,还是触发了 MyEffect 函数。
那么怎么解决这个问题呢?Vue 提供的思路是,使用 cleanup 函数,每次执行 effect 之前,先把所有这个 effect 从所有与它关联的集合中去掉。在执行 effect 过程中再收集新的依赖。
如上例,执行 MyEffect 过程前,先把所有 Set 集合中包含 MyEffect 的删掉,然后执行 MyEffect,发现如果 state.bar 为 false,就不会读取 state.count,也就不会往 count 的数组中添加 MyEffect,这样后续也就不会重复触发 MyEffect。
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
function effect(fn) {
// activeEffect = fn;
// fn();
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
}
effectFn.deps = new Array();
effectFn();
}
这里在 effectFn 上增加了一个 deps 数组,用来反向的从副作用函数上找到它原来的 Set 数组,用来方便的从 Set 数组中删除自己。
这样,当副作用函数中有条件判断这样的分支时,就可以避免不必要的触发。
如果 effect 内嵌套 effect,要如何处理?
effect 嵌套场景一般出现在组件嵌套之中,如
// Bar 组件
const Bar = {
render() { /* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}
相当于:
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})
怎么解决呢?
使用 effectStack 来解决。
function effect(fn, options = {}) {
// activeEffect = fn;
// fn();
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = new Array();
effectFn.options = options;
effectFn();
}
当执行外层 effect 时,先把 effect 进栈,然后执行内层 effect,此时,内层 effect 也会进栈,栈顶是当前正在执行的 effect,get 中会收集 activeEffect。执行完内层 effect 后,出栈,然后栈顶是外层 effect,收集到的也是外层 effect。这样保证了每个 effect 都能收集到正确的 effect。
有一个要注意的点是:
effect(() => {
// 语句
obj.foo = obj.foo + 1
})
当一个 effect 既有 get 操作又有 set 操作时,会导致 trigger 陷入死循环。因为不断地在收集依赖,修改依赖,又触发新的收集。。。
解决方法就是 trigger 中,当把当前 effects 拷贝一份到新的数组集合是,不要添加和当前 activeEffect 一样的副作用。也就是说过滤到正在 get 的 effect,避免重复死循环。
function trigger(target, key) {
if (!bucket.has(target)) {
return;
}
const depsMap = bucket.get(target);
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {//添加到执行集合中
effectsToRun.add(effectFn);
}
})
effectsToRun.forEach(effectFn => {
effectFn();
});
}
Vue 中如何实现调度 effect 的执行顺序?
在 effect 上添加一个新属性叫 options,其中有一个 scheduler,如果设置了 scheduler,那么在 trigger 触发时就把执行权交给 scheduler 去执行,否则就默认直接执行。
scheduler 就可以把 effect 放到 promise 或者 setTimeout 中,利用微任务或者宏任务,去改变任务执行顺序。
Vue 中,如果连续触发修改数据操作,如何优化调度逻辑?
如下:
state.count++;
state.count++;
当对 state.count 连续做出修改时,我们关心的并不是要出发两次 effect 函数,而是只关心最后一次的结果,并将其调用 effect。
那么,可以利用调度器去改变执行次数:
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(function MyEffect() {
console.log(state.count, '===来自myeffect');
},
{
scheduler(fn) {
jobQueue.add(fn);
// setTimeout(fn);
flushJob();
}
}
)
state.count++;
state.count++;
第一次 get 操作会把 MyEffect 放入到队列中。
当连续两次触发 state.count++时,会触发两次 MyEffect 调用,也就是会调用 2cischeduler,而此时由于 fn 都是 MyEffect,因此队列中只会放入一个 MyEffect。利用 promise 的微任务特性,会在 state.count++这两个操作执行完之后,再去回调 MyEffect.此时 count 已经改为了 3,那么 MyEffect 打印出来的自然就是最后执行完的 3,减少了重读调用。
Vue 中如何实现 couputed 计算属性?
首先理清楚什么是 computed
const sum = computed(()=> {
return state.count + state.total;
})
computed 接受一个函数作为参数,其实就是一个 effect。当 effect 依赖的变量变化时,重新运行这个 effect 并返回结果。若依赖变量未发生改变,name 会缓存结果并返回。
那么如何使用 effect 来改造呢?
const effectFn = effect(()=> {
return state.count + state.total;
}, {lazy:true});
我们知道,当设置 lazy 为 true 时,effect 函数会返回一个 effectFn,把执行的时机交到用户手上。而 effect 接收的这个()=> { return state.count + state.total;}
,本质上就是一个 getter 函数,那么如果我们定义一个 computed 函数,可以接收一个 getter,
function computed(getter){
const effectFn = effect(getter,{lazy:true});
const obj = {
get value(){
return effectFn()
}
}
return obj;
}
这样就利用 effect 函数封装成了 computed 函数。当访问 computed 函数的返回值中的 value 属性时,就会调用副作用函数(也叫 getter 函数),获取返回值。
但这样还没做到缓存返回值,毕竟 computed 最大的作用是希望缓存返回值,避免重复渲染,而现在每次都需要重新调用 effectFn 来计算。
function computed(getter){
let value;//缓存上一次返回值
let dirty = true;//为true说明需要重新计算
const effectFn = effect(getter,{lazy:true});
const obj = {
get value(){
if(dirty){
value = effectFn();
dirty = false;
}
return value;
}
}
return obj;
}
增加两个变量,一个来缓存值,一个来控制是否重新计算。但是现在有个问题,当我们改变了 getter 中依赖的变量时,computed 是不会重新计算的。例如 getter 是()=> { return state.count + state.total;}
,当我们修改 state.count 时,computed 并没有触发,这肯定不行。
那么怎么解决呢?
思路就是当依赖的属性变化时,要告诉 computed 中的 effect 要重新计算了。这就用到了 effect 函数的 scheduler 了。也就是说,当依赖变化时,会触发 effect 第二个参数中的 scheduler,这时,把 dirty 变为 true,就意味着要重新计算了。
function computed(getter){
let value;//缓存上一次返回值
let dirty = true;//为true说明需要重新计算
const effectFn = effect(getter,{
lazy:true,
scheduler(){
dirty = true;
}
});
const obj = {
get value(){
if(dirty){
value = effectFn();
dirty = false;
}
return value;
}
}
return obj;
}
这样当我们修改依赖时,就不会使用 computed 的缓存值,而是重新调用 effectFn 来获取新的返回值了。
至此 computed 已基本完成。但是还有个问题如下,如果我们在别的 effect 中使用到了 sumRes.value 值,但是当依赖更新后,sumRes 也更新了,但是 effect 中使用到的 sumRes.value 却没有触发。
01 const sumRes = computed(() => obj.foo + obj.bar)
02
03 effect(() => {
04 // 在该副作用函数中读取 sumRes.value
05 console.log(sumRes.value)
06 })
07
08 // 修改 obj.foo 的值
09 obj.foo++
这是因为我们读取 sumRes.value 时,sumRes 时 computed 返回的,这个对象并不像之前的 state 一样经过 proxy 代理,会在 get 和 set 时收集依赖和触发依赖。
那么,我们需要手动在 computed 内部返回的 obj 上收集一下依赖。
function computed(getter){
let value;//缓存上一次返回值
let dirty = true;//为true说明需要重新计算
const effectFn = effect(getter,{
lazy:true,
scheduler(){
if(!dirty){
dirty = true;
trigger(obj,"value");//当effect的依赖发生变化时,同时手动触发一下obj的依赖更新
}
}
});
const obj = {
get value(){
if(dirty){
value = effectFn();
dirty = false;
}
track(obj,"value");//有读取computed返回值的value时,手动触发收集依赖
return value;
}
}
return obj;
}
这样,当有 effect 使用了 computed 返回值的依赖时,也会触发依赖的收集和更新调用。
effect(function effectFn() {
console.log(sumRes.value)
})
至此,完成 computed 完成。
Vue 中 watch 如何实现?
watch 原理其实就是对一个 object 进行观测,当有属性发生变化后,触发回调函数。
function watch(source, cb) {
effect(() => source.count, {
scheduler() {
cb(source);
}
});
}
watch(state, () => {
console.log('state.count changed');
});
state.count++;
上面就是个简单例子,但是有点问题是,在 watch 函数中,硬编码了 source.count,也就是说手动订阅了 count 属性的监测。这肯定时不行的。
现在需要把 source 对象整个都监控起来。使用一个 traverse 函数,递归遍历 source 上所有的 key,读取 source[key]时,会触发 get 操作,从而收集依赖。
这样,当修改 state 上的任意值时,都会触发 watch 函数。
function traverse(value, seen = new Set()) {
// 如果value是对象或者null,并且已经遍历过,则返回
// seen是一个Set,用来存储已经遍历过的对象,为了防止死循环
if (typeof value !== 'object' || value === null || seen.has(value)) {
return;
}
seen.add(value);//避免死循环
// 暂时不考虑数组等其他数据结构
for (const key in value) {
traverse(value[key], seen);//这里就是读取每个对象的属性,递归遍历.value[key]会触发收集依赖
}
return value;
}
watch(state, () => {
console.log('state changed');
});
state.count++;
state.pre++;
// state changed
// state changed
如果 watch 不想传入 state 对象,而是想传入一个 getter 函数,如
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
这个跟我们第一个例子一样,只需要增加一下判断就好了。收集属性少,反而更简单了,不用调用 traverse 了。
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source;//如果source是函数,则直接赋值给getter
} else {
getter = () => traverse(source);//按照原来的方式遍历state递归读取
}
effect(
() => getter(),
{
scheduler() {
cb(source);
}
}
);
}
此时,还有一个问题,正常 watch 回调函数时候返回值的,一个新值一个旧值。
watch(state, (oldValue,newValue) => {
console.log('state changed');
});
那么就需要继续改造 watch 了。
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source;//如果source是函数,则直接赋值给getter
} else {
getter = () => traverse(source);//按照原来的方式遍历state递归读取
}
let oldValue, newValue;
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
newValue = effectFn();//此时依赖已经更新,再调用副作用函数拿到最新值
cb(newValue, oldValue);
oldValue = newValue;
}
}
);
oldValue = effectFn();//手动调用副作用函数,拿到的值时旧值
}
通过使用 lazy 属性,手动调用 effectFn,先获取旧值,然后再 scheduler 回调时,此时再调用 effectFn,获取到新值,就可以返回了。
Vue 中 watch 立即执行的实现原理
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source;//如果source是函数,则直接赋值给getter
} else {
getter = () => traverse(source);//按照原来的方式遍历state递归读取
}
let oldValue, newValue;
const job = () => {
newValue = effectFn();//此时依赖已经更新,再调用副作用函数拿到最新值
cb(newValue, oldValue);
oldValue = newValue;
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: job,
}
);
if (options.immediate) {
job();
} else {
oldValue = effectFn();//手动调用副作用函数,拿到的值时旧值
}
}
这个就很简单了,根据 immediate 的值,如果立即执行,就立马调用 job,否则就还按之前的。这里需要的是把原来的 scheduler 抽象为 job 函数方便调用。
Vue 中 watch 的 flush 参数如何实现 post/sync/pre ?
flush 参数的三个值分别代表:post:DOM 更新结束后再更新;sync:直接执行回调函数;pre:DOM 更新前执行,暂时无法模拟。
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source;//如果source是函数,则直接赋值给getter
} else {
getter = () => traverse(source);//按照原来的方式遍历state递归读取
}
let oldValue, newValue;
const job = () => {
newValue = effectFn();//此时依赖已经更新,再调用副作用函数拿到最新值
cb(newValue, oldValue);
oldValue = newValue;
}
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {// post是DOM更新结束后再更新,实现原理是放到一个微任务队列中
const p = Promise.resolve();
p.then(job);
} else {
job();
}
},
}
);
if (options.immediate) {
job();
} else {
oldValue = effectFn();//手动调用副作用函数,拿到的值时旧值
}
}
其实主要要实现的时“post”,也就是将任务放入到一个微任务中,等 DOM 更新完了再去执行。
Vue 中,如果if(key in obj){}
或者for(const key in object){}
怎么触发 proxy 的拦截?
key in obj
操作符不走 proxy 的 get 拦截,因此,根据规范,其实际触发的时 has 拦截。
const obj = { name: "John", age: 30, city: "New York" };
const state = new Proxy(obj, {
get: function (target, prop, receiver) {
console.log(`Getting ${prop}`);
return target[prop];
},
has: function (target, prop) {
console.log(`Checking if ${prop} exists`);
return prop in target;
},
});
console.log(state.name);
console.log('===================');
console.log('name' in state);
// Getting name
// John
// ===================
// Checking if name exists
// true
由此可见,在 has 上也要像 get 一样做拦截操作。
for(const key in object){}
for in 操作,也不走 proxy 的 get 拦截,根据规范,其触发的时 ownKeys 操作。
const obj = { name: "John" };
const state = new Proxy(obj, {
get: function (target, prop, receiver) {
console.log(`Getting ${prop}`);
return target[prop];
},
has: function (target, prop) {
console.log(`Checking if ${prop} exists`);
return prop in target;
},
ownKeys: function (target) {
console.log("Getting ownKeys");
return Reflect.ownKeys(target);
},
});
for (const key in state) {
console.log(key);
}
// Getting ownKeys
// name
综上,需要改造 proxy 方法和 trigger 方法,来增加 has 和 ownKey 的支持。
const ITERATE_KEY = Symbol(); //用来标识ownKeys的key
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}
const state = new Proxy(data, {
get(target, key, receiver) {
track(target, key);//追踪依赖
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD;
const res = Reflect.set(target, key, value, receiver);
trigger(target, key, type);//触发依赖
return res;
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
track(target, ITERATE_KEY);//将副作用与ITERATE_KEY关联
return Reflect.ownKeys(target);
},
});
// 触发依赖
function trigger(target, key, type) {
if (!bucket.has(target)) {
return;
}
const depsMap = bucket.get(target);
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
// 只有当操作类型为ADD时,才需要将与ITERATE_KEY关联的副作用也加入到effectsToRun中,也就是说,只有新增属性时,才会触发与ITERATE_KEY关联的副作用函数执行.此时包含for in 的副作用函数才会重新执行
if (type === TriggerType.ADD) {
const iterateEffects = depsMap.get(ITERATE_KEY);//获取与ITERATE_KEY关联的副作用
// 将与ITERATE_KEY关联的副作用也加入到effectsToRun中
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
这里有个要注意的点,我们新增了 ITERATE_KEY 来追踪 ownKeys 的 key。这也很明显,这个拦截器参数里没有 key,只能自己新造一个来表示这个类型的 key。
在 trigger 中触发时,把 ITERATE_KEY 类型的 key 的所有依赖函数也取出来放到待执行的 effectsToRun 里面就行了。
但问题是,假如我们在 effect 中使用了 for in ,正常我们只关心新属性增加了之后才触发这个 effect,如下:
const obj = { foo: 1 }
const p = new Proxy(obj, {/* ... */})
effect(() => {
// for...in 循环
for (const key in p) {
console.log(key) // foo
}
})
如果修改 p.foo,不应该执行 effect,因为修改已有的属性不是这个 effect 关心的。但是现在没有区别到底是新增属性还是修改已有属性的值。所有,新增了一个 TriggerType 来区分。
通过在 set 时判断当前触发的 key 是否在原来对象上,来区分时 Add 还是 Set 操作。然后再 trigger 时,根据这个 type 来决定是否要执行 iterateEffects。从而达到区分时 set 还是 add 的作用。
Vue 中,delete 操作符如何触发 proxy 拦截?
这个就是使用到 deleteProperty 拦截器。
const state = new Proxy(data, {
get(target, key, receiver) {
track(target, key);//追踪依赖
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD;
const res = Reflect.set(target, key, value, receiver);
trigger(target, key, type);//触发依赖
return res;
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
track(target, ITERATE_KEY);//将副作用与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, TriggerType.DELETE);//触发依赖
}
return res;
}
});
要求必须时自己对象上的属性并且删除成功时,才会触发回调。
在 trigger 上增加在 ADD 和 DELETE 时触发 iterateEffects 就可以了。
Vue 中如果深层次嵌套对象,如何触发响应式?
递归!Vue 中有两种代理方式,一种是正常的响应式,如果有嵌套子属性也是对象,那么会递归的将所有子属性都变为 proxy 响应式对象。还有一种是浅层的响应式,只响应最外层第一层的属性响应,深层次的对象不会触发响应式。
因此,vue 有一个 createReactive 函数,用来创建 不同类型的响应式数据。
// 为了区分创建深层次响应还是浅层次响应,这里创建了一个createReactive函数
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
get(target, key, receiver) {
// 代理的对象可以通过 p.raw属性访问原始数据
if (key === 'raw') {
return target;
}
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;
},
set(target, key, newValue, receiver) {
const oldValue = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD;
const res = Reflect.set(target, key, newValue, receiver);
// target === receiver.raw说明receiver就是target的代理对象,因为p.raw = target,而p是代理后的对象
// 这个要理清思路:get时,为代理后的对象增加了一个raw的key,等于target.也就是说p.raw = target。
// 而如果target === receiver.raw,说明操作的是代理对象,而不是从哪里继承过来的
if (target === receiver.raw) {
if (
oldValue !== newValue
&& (oldValue === oldValue || newValue === newValue)//防止NaN !== NaN 这种情况
) {//只有值发生变化才触发依赖,防止如state.count = 1这种值没变化,但是依赖却触发的情况
trigger(target, key, type);//触发依赖
}
}
return res;
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
track(target, ITERATE_KEY);//将副作用与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, TriggerType.DELETE);//触发依赖
}
return res;
}
});
}
核心点就在于,当 get 请求的返回值是对象是,是递归的继续创建,还是直接返回。
if (isShallow) {//如果是浅层次响应,则不做任何处理
return res;
}
// 递归遍历子属性,将子属性也转为响应式对象
if (typeof res === 'object' && res !== null) {
return reactive(res);
}
如何代理数组响应?
数组的读取操作分为以下几种:
- arr[0]这种通过索引访问元素
- arr.length 访问数组长度
- for...in 这种遍历方式访问
- for...of 迭代访问数组
- find/findIndex/includes/every/some 等这些数组上的方法
数组的设置操作有以下几种:
- arr[1] = 2
- arr.length = 0
- push/pop/shift/unshift 数组上的栈方法
- splice/fill/sort 等修改原数组的原型方法
由于数组本质上也是对象,所有基本的代理方法都能用。额外需要处理如直接访问 length,这样需要而外处理
// 为了区分创建深层次响应还是浅层次响应,这里创建了一个createReactive函数
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
console.log('get: ', key);
// 代理的对象可以通过 p.raw属性访问原始数据
if (key === 'raw') {
return target;
}
// 为了解决 const o = {}; const arr = reactive([o]); arr.includes(o) === false;问题
if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver);
}
// 非只读时才需要建立响应联系
// key === symbol时,不建立响应联系,因为symbol属性不能被set,delete等操作
if (!isReadonly && typeof key !== 'symbol') {
track(target, key);//追踪依赖
}
const res = Reflect.get(target, key, receiver);
if (isShallow) {//如果是浅层次响应,则不做任何处理
return res;
}
// 递归遍历子属性,将子属性也转为响应式对象
if (typeof res === 'object' && res !== null) {
// 如果数据为只读,则递归调用readonly方法,否则调用reactive方法
return isReadonly ? readonly(res) : reactive(res);
}
return res;
},
set(target, key, newValue, receiver) {
if (isReadonly) {
console.warn(`Set operation on key "${key}" failed: target is readonly.`);
return true;
}
const oldValue = target[key];
const type = Array.isArray(target)
? Number(key) < target.length ? TriggerType.SET : TriggerType.ADD
: Object.prototype.hasOwnProperty.call(target, key) ? TriggerType.SET : TriggerType.ADD;
const res = Reflect.set(target, key, newValue, receiver);
// target === receiver.raw说明receiver就是target的代理对象,因为p.raw = target,而p是代理后的对象
// 这个要理清思路:get时,为代理后的对象增加了一个raw的key,等于target.也就是说p.raw = target。
// 而如果target === receiver.raw,说明操作的是代理对象,而不是从哪里继承过来的
if (target === receiver.raw) {
if (
oldValue !== newValue
&& (oldValue === oldValue || newValue === newValue)//防止NaN !== NaN 这种情况
) {//只有值发生变化才触发依赖,防止如state.count = 1这种值没变化,但是依赖却触发的情况
trigger(target, key, type, newValue);//触发依赖
}
}
return res;
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY);//将副作用与length或ITERATE_KEY关联
return Reflect.ownKeys(target);
},
deleteProperty(target, key) {
if (isReadonly) {
console.warn(`Delete operation on key "${key}" failed: target is readonly.`);
return true;
}
// 检查被操作的属性是否是对象自己的属性,防止操作的时对象继承的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {//只有是操作的自己的属性而且删除成功才触发依赖
trigger(target, key, TriggerType.DELETE);//触发依赖
}
return res;
}
});
}
set 时,如果源对象是个数组,并且索引值超出了数组长度,说明就是新增,走 ADD 逻辑,如果是在数组长度范围之内,那就是修改数据,走 SET 逻辑。
在 trigger 时,根据 type,如果是 ADD 并且原对象是数组,那么,额外把 length 作为 key,将所有依赖拿出来放到待执行的副作用函数队列中去。
// 触发依赖
function trigger(target, key, type, newValue) {
if (!bucket.has(target)) {
return;
}
const depsMap = bucket.get(target);
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
// 只有当操作类型为ADD时,才需要将与ITERATE_KEY关联的副作用也加入到effectsToRun中,也就是说,只有新增属性时,才会触发与ITERATE_KEY关联的副作用函数执行.此时包含for in 的副作用函数才会重新执行
if (type === TriggerType.ADD || type === TriggerType.DELETE) {
const iterateEffects = depsMap.get(ITERATE_KEY);//获取与ITERATE_KEY关联的副作用
// 将与ITERATE_KEY关联的副作用也加入到effectsToRun中
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
if (type === TriggerType.ADD && Array.isArray(target)) {
const lengthEffects = depsMap.get('length');//获取与length关联的副作用
// 将与length关联的副作用也加入到effectsToRun中
lengthEffects && lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
// 对于操作目标是数组,并且修改了数组的length
if (Array.isArray(target) && key === 'length') {
// Map.foreach方法回调函数第一个参数是value,第二个参数是key,第三个参数是map本身
// 对于索引大于等于新的length的元素
// 需要把所有关联的副作用函数加入到effectsToRun中
// 比如:state.length = 2, 则需要触发索引2-1之后的所有副作用函数,因为相当于他们被删除了
depsMap.forEach((effects, key) => {
if (key >= newValue) {
effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应.
对于 for...in 这种访问,触发的也是 ownKeys 拦截,所有触发 tarck 时,如果是数组,把‘length’当做 key 保存到 map 中。
其他如数组原型上的方法,如 includes,本质也是通过索引访问数组元素,需要额外处理 includes 中代理对象和原始对象的处理。因此,在 get 拦截时,通过 reactiveMap 来存储原始对象到代理对象的映射。
const reactiveMap = new Map(); //用来存储响应式对象, key为原始对象, value为代理对象
// 创建深层次响应式对象
function reactive(obj) {
const existionReactive = reactiveMap.get(obj);
if (existionReactive) {
return existionReactive;
}
// 保存proxy对象到reactiveMap中,以便后续获取
const proxy = createReactive(obj, false, false);
reactiveMap.set(obj, proxy);
return proxy;
}
对于这种 includes/indexOf/lastIndexOf 这种数组方法,因为它们都属于根据给定的值返回查找结果的方法,所以需要重写数组原型上的方法。
对于隐式修改数组长度的原型方法如 push/pop/shift/unshift/spilice 这种,也需要重写方法。
// 重写数组的方法
const arrayInstrumentations = {}
Array(['includes', 'indexOf', 'lastIndexOf']).forEach(method => {
const originMethod = Array.prototype[method];
arrayInstrumentations[method] = function (...args) {
let res = originMethod.apply(this, args);//现在代理对象上找
// 如果没找到,再去原始数组中查找
if (res === false || res === -1) {
res = originMethod.apply(this.raw, args);
}
return res;
}
});
let shouldTrack = true; //是否需要追踪依赖
Array(['push', 'pop', 'shift', 'unshift', 'splice']).forEach(method => {
const originMethod = Array.prototype[method];
arrayInstrumentations[method] = function (...args) {
// 在调用原始方法之前,禁止追踪
shouldTrack = false;
const res = originMethod.apply(this, args);
shouldTrack = true;
return res;
}
});
当对这些处理完成后,就可以正常响应式数据了。
如何处理原始值的响应式
proxy只能处理对象的拦截,所以无法处理原始值的拦截。那么怎么解决呢?很直接的解决方法就是用object包一下,也就是ref。同时为了区分ref返回值是ref还是普通对象,在内部增加了一个__v_isRef
来标识。
function ref(val) {
const wrapper = {
value: val
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return reactive(wrapper)
}
但是这样的对象,无法解决展开操作符...
的引用。如果在一个新对象里使用...
展开ref返回值,那么是不会触发数据响应的。
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })
// 将响应式数据展开到一个新的对象 newObj
const newObj = {
...obj
}
effect(() => {
// 在副作用函数内通过新的对象 newObj 读取 foo 属性值
console.log(newObj.foo)
})
// 很显然,此时修改 obj.foo 并不会触发响应
obj.foo = 100
为什么呢?因为本质上展开操作符是将属性放到了一个新对象上,而这个新对象是不具备响应式的。所以不会触发响应。如何解决呢?通过对新对象的访问属性进行拦截实现:
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })
// newObj 对象下具有与 obj 对象同名的属性,并且每个属性值都是一个对象,
// 该对象具有一个访问器属性 value,当读取 value 的值时,其实读取的是 obj 对象下相应的属性值
const newObj = {
foo: {
get value() {
return obj.foo
}
},
bar: {
get value() {
return obj.bar
}
}
}
effect(() => {
// 在副作用函数内通过新的对象 newObj 读取 foo 属性值
console.log(newObj.foo.value)
})
// 这时能够触发响应了
obj.foo = 100
当访问value时,将其指向代理对象的值,这样就触发了响应。但是,每次都这么写一遍很麻烦,可以写一个工具函数来处理一下:
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key];
},
set value(newVal) {
obj[key] = newVal;
}
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true,
});
return wrapper;
}
function toRefs(obj) {
const res = {};
for (const key in obj) {
res[key] = toRef(obj, key);
}
return res;
}
还有个问题是每次获取值,都需要 newObj.foo.value 来获取,每次都.value一下很麻烦也很反直觉。为了去掉他,我们可以使用proxy自动脱ref。
// 自动脱掉ref.value,支持直接访问响应对象
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return res.__v_isRef ? res.value : res;
},
set(target, key, newValue, receiver) {
const value = target[key];
if (value && value.__v_isRef) {
value.value = newValue;
return true;
}
return Reflect.set(target, key, newValue, receiver);
}
})
}
这样就很自然的可以读取值并且可以触发响应。
Vue 简单diff算法逻辑
diff发生在当新节点和旧节点的子节点都是一个数组的情况下(数组中可以只有1个子节点)。
那么怎么对比呢?
加入新节点的子节点为:NEW【n-3,n-1,n-4,n-2】,旧节点的子节点为:OLD【o-1,o-2,o-3,o-5】。n-x和o-x表示新节点还是旧节点
两层遍历,先从新节点的子节点开始遍历,然后内层遍历旧节点的子节点,主要是为了NEW上子节点的key在OLD的子节点上是否存在。
新节点n-3的key为3,在旧节点上找到了o-3,n-3节点索引0小于o-3的索引2,所以位置不变。findeIndex = 2。此时DOM顺序为:【D1,D2,D3,D5】。
新节点n-1的key为1,在旧节点上找到了o-1,o-1的索引为0,小于lastIndex=2。所以o-1的节点需要移动。从新节点n-1找到它的前一个节点n-3,从n-3找到o-3的DOM元素el,o-3的el.nextSibling找到了o-3真实DOM的下一个位置,调用container.inserBefore(n-1.el,o-3.el.nextSibling)来移动位置。此时DOM顺序为:【D2,D3,D1,D5】。
n-4在旧节点找不到key为4的子节点,所以是需要新增的节点。逻辑跟上面一下,找到n-1.el = o-1.el,发现o-1.el.nextSibling,然后调用container.inserBefore(n-4,o-1.el.nextSibling)来移动位置。此时DOM顺序为:【D2,D3,D1,D4,D5】。
n-2节点找到旧节点o-2,o-2索引为1,小于lastIndex=2,所有o-2需要移动。移动到n-4.el.nextSibling。调用container.inserBefore(n-2.el,n-4.el.nextSibling)来移动位置。此时DOM顺序为:【【D3,D1,D4,D2,D5】。
新节点的子节点已经循环完,最后再遍历一遍旧节点的子节点,如果旧节点中出现的key不在新节点中,那么就卸载它。因此找到o-5,调用卸载方法移除o-5.el就好了。此时DOM顺序为:【【D3,D1,D4,D2】。
Vue的双端Diff算法逻辑
简单diff算法的缺点在于,只能从前往后一个一个找,如果出现最后一个移动到第一个的情况,那么除了之前的最后一个,其他都要移动。这样性能很不好。
双端diff的逻辑是:
构建4个变量,newStartIdx,newEndIdx,oldStartIdx,oldEndIdx。分别指向新子节点的首尾和旧子节点的首尾。
依次比较:
旧子节点的头结点和新子节点的头结点
旧子节点的尾节点和新子节点的尾节点
旧子节点的头结点和新子节点的尾节点
旧子节点的尾节点和新子节点的头结点
四个首尾互相比较还没命中,那么以新子节点的头结点作为基础去旧子节点中查找有没有key相同的
- 如果找到了,旧子节点中的这个key节点移动到旧子节点的头部,然后新子节点的头结点索引+1。旧子节点的位置置为undefined。为了过滤undefined,因此在循环比较浅,要把旧节点的头部节点和尾部节点都过滤一下undefined。
- 如果没找到,说明新子节点的头部节点需要新增,那么把他插入到旧子节点的头部,然后新子节点的头结点索引+1
当这个while循环比较执行完了,就要处理剩余元素了。因为新节点和旧节点列表都有可能有剩余,那么
- 判断索引,如果oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx,说明新节点有剩余,循环添加
- 如果newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx,说明旧节点有剩余,循环卸载
最后整体就完成了。
Vue如何从模板编译成执行代码?
Vue的编译过程,是从模板最终生成包含h函数的渲染函数代码。
那么怎么转换呢?
第一步,将源代码进行词法分析,转换成token列表。词法分析就是根据源代码一个字符一个字符的分析,遇到<
该怎么处理,遇到字母该怎么处理等等。
function tokenize(str) {
let currentState = State.initial;//状态机的当前状态
const chars = [];//用于缓存字符
const tokens = [];//生成的Token会存储到tokens数组中,并作为函数的返回值返回
// 使用while循环开启自动机,只要模板字符串没被消费完,自动机就会一直运行
while (str) {
// 查看第一个字符,注意只是查看,没有消费
const char = str[0];
switch (currentState) {
case State.initial:
if (char === '<') {
// 1.状态机进入标签开始状态
currentState = State.tagOpen;
// 2.消费字符 <
str = str.slice(1);
} else if (isAlpha(char)) {
// 1.遇到字母,切换到文本状态
currentState = State.text;
// 2.缓存字符
chars.push(char);
// 3.消费字符
str = str.slice(1);
}
break;
case State.tagOpen:
if (isAlpha(char)) {
// 1.遇到字母,切换到标签名状态
currentState = State.tagName;
// 2.缓存字符
chars.push(char);
// 3.消费字符
str = str.slice(1);
} else if (char === '/') {
// 1.遇到 /,切换到标签结束状态的标签名状态
currentState = State.tagEnd;
// 2.消费字符
str = str.slice(1);
}
break;
case State.tagName:
if (isAlpha(char)) {
// 1.遇到字母,继续缓存字符
chars.push(char);
// 2.消费字符
str = str.slice(1);
} else if (char === '>') {
// 1.遇到 >,切换到标签初始状态
currentState = State.initial;
// 2.生成Token
tokens.push({
type: 'tag',
name: chars.join(''),
});
// 3.清空缓存
chars.length = 0;
// 4.消费字符>
str = str.slice(1);
}
break;
case State.text:
if (isAlpha(char)) {
// 1.遇到字母,继续缓存字符
chars.push(char);
// 2.消费字符
str = str.slice(1);
} else if (char === '<') {
// 1.遇到 <,切换到标签开始状态
currentState = State.tagOpen;
// 2.生成Token
tokens.push({
type: 'text',
content: chars.join(''),
});
// 3.清空缓存
chars.length = 0;
// 4.消费字符<
str = str.slice(1);
}
break;
case State.tagEnd:
if (isAlpha(char)) {
// 1. 遇到字母,切换到结束标签名称状态
currentState = State.tagEndName
// 2.缓存字符
chars.push(char);
// 3.消费字符
str = str.slice(1);
}
break;
case State.tagEndName:
if (isAlpha(char)) {
// 1.遇到字母,继续缓存字符
chars.push(char);
// 2. 消费当前字符
str = str.slice(1);
} else if (char === '>') {
// 1.遇到 >,切换到标签初始状态
currentState = State.initial;
// 2.生成Token
tokens.push({
type: 'tagEnd',
name: chars.join(''),
});
// 3.清空缓存
chars.length = 0;
// 4.消费字符>
str = str.slice(1);
}
break;
}
}
return tokens;
}
通过不断的遍历循环和消耗字符串,最终将字符转转换成类似于下面的数组。在词法分析过程中,通过不断切换状态,来将字符拼成有意义的token,然后push到数组中。
const tokens1 = tokenize(`<div><p>Vue</p><p>Template</p></div>`)
console.log(tokens1);
[
{ type: 'tag', name: 'div' },
{ type: 'tag', name: 'p' },
{ type: 'text', content: 'Vue' },
{ type: 'tagEnd', name: 'p' },
{ type: 'tag', name: 'p' },
{ type: 'text', content: 'Template' },
{ type: 'tagEnd', name: 'p' },
{ type: 'tagEnd', name: 'div' }
]
有了token数组后,就需要将token数组转换成模板的AST语法。这个模板AST语法由vue来设计。原理也很简单,就是有个叫Root的根节点,然后通过使用一个栈,遍历循环token列表来不断的进栈和出栈,将token转换为AST树上的节点。比如遇到tag就进栈,遇到tagEnd就出栈,并退入到AST树上父节点的children数组中。
function parse(str) {
const tokens = tokenize(str);//解析Token
// 创建AST树Root节点
const root = {
type: 'Root',
children: [],
}
// 创建elementStack栈,起初只有Root节点
const elementStack = [root];
// 开启while循环扫描tokens,直到所有Token都被消费完
while (tokens.length) {
const parent = elementStack[elementStack.length - 1];//获取栈顶元素
const t = tokens[0];//获取第一个Token
switch (t.type) {
case 'tag':
// 1.创建元素节点
const elementNode = {
type: 'Element',
tag: t.name,
children: [],
}
// 2.将元素节点添加到父节点的children数组中
parent.children.push(elementNode);
// 3.将元素节点推入栈中
elementStack.push(elementNode);
break;
case 'text':
// 1.创建文本节点
const textNode = {
type: 'Text',
content: t.content,
}
// 2.将文本节点添加到父节点的children数组中
parent.children.push(textNode);
break;
case 'tagEnd':
// 1.弹出栈顶元素
elementStack.pop();
break;
}
// 4.消费Token
tokens.shift();
}
return root;
}
最终,会生成一个如下的模板AST树:
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
{
"type": "Root",
"children": [
{
"type": "Element",
"tag": "div",
"children": [
{
"type": "Element",
"tag": "p",
"children": [
{
"type": "Text",
"content": "Vue"
}
]
},
{
"type": "Element",
"tag": "p",
"children": [
{
"type": "Text",
"content": "Template"
}
]
}
]
}
]
}
当有了模板AST树之后,就需要对AST进行操作和转换。这个就根据自己的需求操作了,比如要把Text节点转换为Span等。
function traverseNode(ast, context) {
context.currentNode = ast;
//1. 存储退出函数的数组
const exitFns = [];
const transforms = context.nodeTransforms;
for (let i = 0; i < transforms.length; i++) {
// 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的函数
const onExit = transforms[i](context.currentNode, context);
if (onExit) {
exitFns.push(onExit);
}
if (!context.currentNode) return;
}
const children = context.currentNode.children;
if (children) {
for (let i = 0; i < children.length; i++) {
context.parent = context.currentNode;
context.childIndex = i;
traverseNode(children[i], context);
}
}
// 在节点最后阶段执行退出函数
// 注意:退出函数的执行顺序与注册顺序相反
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
function transform(ast) {
const context = {
currentNode: null,// 增加 currentNode,用来存储当前正在转换的节点
childIndex: 0,// 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引
parent: null,// 增加 parent,用来存储当前节点的父节点
replaceNode(node) {
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndex
// 然后使用新节点替换即可
context.parent.children[context.childIndex] = node
// 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
context.currentNode = node
},
removeNode() {
if (context.parent) {
context.parent.children.splice(context.childIndex, 1);
context.currentNode = null;
}
},
nodeTransforms: [
transformRoot,
transformElement,
transformText,
]
}
traverseNode(ast, context);
}
function transformText(node, context) {
if (node.type !== 'Text') {
return;
}
node.jsNode = createStringLiteral(node.content);
}
function transformElement(node) {
return () => {
if (node.type !== 'Element') {
return
}
const callExp = createCallExpression('h', [
createStringLiteral(node.tag),
]);
node.children.length === 1
? callExp.arguments.push(node.children[0].jsNode)
: callExp.arguments.push(createArrayExpression(node.children.map(c => c.jsNode)));
node.jsNode = callExp;
}
}
function transformRoot(node) {
return () => {
if (node.type !== 'Root') {
return
}
const vnodeJSAST = node.children[0].jsNode;
node.jsNode = {
type: 'FunctionDecl',
id: { type: 'Identifier', name: 'render' },
params: [],
body: [
{
type: 'ReturnStatement',
return: vnodeJSAST,
},
],
};
}
}
当然,为了转换过程抽象,还设计了context,上面挂在了一些属性和方法用来在递归转换过程中传递数据。
除了部分转换修改外,最重要的是,将模板AST转换为JavaScript AST。Vue中将其挂在了jsNode属性上。
当转换完成后,如下结构:
transform(ast);
{
"type": "Root",
"children": [
{
"type": "Element",
"tag": "div",
"children": [
{
"type": "Element",
"tag": "p",
"children": [
{
"type": "Text",
"content": "Vue",
"jsNode": {
"type": "StringLiteral",
"value": "Vue"
}
}
],
"jsNode": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "p"
},
{
"type": "StringLiteral",
"value": "Vue"
}
]
}
},
{
"type": "Element",
"tag": "p",
"children": [
{
"type": "Text",
"content": "Template",
"jsNode": {
"type": "StringLiteral",
"value": "Template"
}
}
],
"jsNode": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "p"
},
{
"type": "StringLiteral",
"value": "Template"
}
]
}
}
],
"jsNode": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "div"
},
{
"type": "ArrayExpression",
"elements": [
{
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "p"
},
{
"type": "StringLiteral",
"value": "Vue"
}
]
},
{
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "p"
},
{
"type": "StringLiteral",
"value": "Template"
}
]
}
]
}
]
}
}
],
"jsNode": {
"type": "FunctionDecl",
"id": {
"type": "Identifier",
"name": "render"
},
"params": [
],
"body": [
{
"type": "ReturnStatement",
"return": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "div"
},
{
"type": "ArrayExpression",
"elements": [
{
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "p"
},
{
"type": "StringLiteral",
"value": "Vue"
}
]
},
{
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "h"
},
"arguments": [
{
"type": "StringLiteral",
"value": "p"
},
{
"type": "StringLiteral",
"value": "Template"
}
]
}
]
}
]
}
}
]
}
}
最后,有了JS AST之后,就可以将AST转化为我们想要的目标代码了。
function generate(node) {
const context = {
code: '',
push(code) {
context.code += code;
},
currentIndet: 0,
newline() {
context.code += '\n' + ` `.repeat(context.currentIndet);
},
indent() {
context.currentIndet++;
context.newline();
},
deIndent() {
context.currentIndet--;
context.newline();
},
}
genNode(node, context);
return context.code;
}
function genNode(node, context) {
switch (node.type) {
case 'FunctionDecl':
genFunctionDecl(node, context);
break;
case 'ReturnStatement':
genReturnStatement(node, context);
break;
case 'CallExpression':
genCallExpression(node, context);
break;
case 'StringLiteral':
genStringLiteral(node, context);
break;
case 'ArrayExpression':
genArrayExpression(node, context);
break;
}
}
// function render() {
// ...函数体
// }
function genFunctionDecl(node, context) {
const { push, indent, deIndent } = context;
push(`function ${node.id.name} `);
push(`(`);
genNodeList(node.params, context);
push(`) `);
push(`{`);
indent();
node.body.forEach(n => genNode(n, context));
deIndent();
push(`}`);
}
function genNodeList(nodes, context) {
const { push } = context;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
genNode(node, context);
if (i < nodes.length - 1) {
push(', ');
}
}
}
function genReturnStatement(node, context) {
const { push } = context;
push('return ');
genNode(node.return, context);
}
function genCallExpression(node, context) {
const { push } = context;
const { callee, arguments: args } = node;
push(`${callee.name}(`);
genNodeList(args, context);
push(')');
}
function genStringLiteral(node, context) {
const { push } = context;
push(`'${node.value}'`);
}
function genArrayExpression(node, context) {
const { push } = context;
push('[');
genNodeList(node.elements, context);
push(']');
}
原理说白了就是递归遍历js AST,将其生成为代码字符串。其中包含了如string 节点怎么拼接,数组表达式怎么拼接等等。
最终就生成了目标代码:
const code = generate(ast.jsNode);
// function render() {
// return h('div', [h('p', 'Vue'), h('p', 'Template')])
// }