【荐书】《Vue.js 设计与实现》带我扒源码!

1,457 阅读11分钟

先说结论:这是目前市面上我所读过 Vue 框架的书籍中最值得读的一本进阶类书籍,在看这本书的过程你可以感觉自己被Vue core team 成员带着读源码,同时循序渐进的告诉你是怎么设计框架,同时告诉你为什么要这么设计....

本书推荐阅读人群:

  1. 具备实际Vue2或者Vue3开发经验人群
  2. 阅读过Vue源码
  3. 有MVVM框架开发经验人群
  4. 有开发经验人群,想了解 Vue框架

这本书其实算是比较厚的一本书了,以前遇到类似厚度是书籍都会从头到尾啃穿它...但是实际上这个读法会读的很累...。而且需要大块的时间来阅读,否则经常会出现看到后面忘了前面的情况。 推荐的阅读方式是,目标导向阅读。我们大多数人并不是阅读机器,很多“大部头”没办法从头到尾读完,而且太厚的书籍从头到尾读完需要大量连续的时间块。否则你有机会拿起书的时候,上次看的已经忘得差不多了。

ps:我自己的阅读顺序是 1、2、3、4、6、5、7、8、12、15、16、17、9、10、11... (后面的按需阅读)

开篇部分:1~3章(必读!)

开阔性的讨论了声明式和命令式的差异,比较了两种范式的能耗和优缺点,并且很精炼的总结了 Vue3 作为命令式 UI 框架的特点。然后从框架设计的角度,说明了开发环境和生产环境着重需要注意的设计理念,包括更友好的报错机制、如何更好利用 Tree-Shaking 机制、对副作用的控制等。最后第三章从组件和渲染器的机制展开说了 Vdom 和 Vue 中组件渲染的关系,整个开篇部分用了很简单的语音就把整个 Vue 的设计理念掀开一角展现给读者。

第二篇:响应系统(部分必读)

我们知道 Vue 基于观察者和消息订阅机制实现双向数据绑定实现数据页面响应的过程,第二篇也是本书中最大篇幅讲了 Vue3 的响应机制是如何实现的,有 Vue2 源码经验的读者可以对比一下 Vue3 基于函数式编程副作用来实现的的响应式原理。

这部分的阅读建议,打开IDE,跟着书中的案例代码变思考边敲。

第四章介绍了Vue3的双向绑定的原理,同时解释了computed/watch 在vue3中的实现原理。还有竞态问题的处理方案。

这里贴一个跟着书上第四章案例敲的代码


import { flushJob, jobQueue } from "./jobQueue";
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
const effectStack = []; // 副作用栈,防止当前副作用多个连带依赖影响执行依赖链上的副作用
function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn); // 清除原有的依赖
    // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
    activeEffect = fn;
    // 在调用副作用函数之前,将当前副作用函数压入栈中
    effectStack.push(effectFn);
    // 执行副作用函数, res 承接fn() 结果并在最后副作用函数结束后返回
    const res = fn();
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    return res;
  };
  effectFn.options = options; // options 可以让用户设置调度 options.scheduler ...
  // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  // 计算属性原理,非 lazy 才立即执行
  if (!options.lazy) {
    // 执行副作用函数
    effectFn();
  }
  return effectFn;
}
// 储存副作用的函数桶
const bucket = new WeakMap();
// 原始数据
const data = { text: "hello world" };
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    trigger(target, key);
  },
});
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
  if (!activeEffect) return;
  // 根据 target 从“桶”中取得depsMap,它也是一个Map类型: key --> effects
  let depsMap = bucket.get(target);
  // 如果不存在 depsMap, 那新建一个Map 并与 target 关联(创建依赖收集)
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 再根据key 从 depsMap 中取得 deps, 它是一个 Set 类型,里面存储着左右与当前 key 相关联的副作用函数: effects
  let deps = depsMap.get(key);
  // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  // dpes 就是一个与当前副作用函数存在联联系的依赖集合, 将其添加到 activeEffect.deps 数组中
  activeEffect.deps.push(deps);
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
  // 根据 target 从桶中取得 depsMap, 它是 key --> effects
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  // 根据key 取得所有副作用函数 effects
  const effects = depsMap.get(key);
  //执行副作用函数
  // effects && effects.forEach(fn => fn());
  const effectsToRun = new Set(effects); // Set.prototype.forEach https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Set/forEach
  effects &&
    effects.forEach(effectFn => {
      // 为了避免无线递归调用,从而避免栈溢出 e.g. effect(() => obj.foo++)
      if (effectFn !== activeEffect) {
        // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
        effectsToRun.add(effectFn);
      }
    });
  effectsToRun.forEach(effectFn => {
    // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn);
    } else {
      // 否则执行默认行为
      effectFn();
    }
  });
}
function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i];
    // 将 effectFn 从依赖集合中移除
    deps.delete(effectFn);
  }
  // 最后重置 effectFn .deps数组
  effectFn.deps.length = 0;
}
// 调度案例
effect(
  () => {
    console.log("do someting");
  },
  {
    scheduler(fn) {
      jobQueue.add(fn);
      flushJob();
    },
  }
);
// 计算属性案例
function computed(getter) {
  // value 用来缓存上一次计算的值
  let value;
  // dirty 标志,用来标识是否需要重新计算值,true 则意味着“脏”,需要计算
  let dirty = true;
  // 把 getter 作为副作用函数,创建一个 lazy 的 effect
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true; // 调度器内重置 true 防止缓存锁死返回值;
      // 当计算属性的响应式数据变化时,手动调用 trigger 函数触发响应
      trigger(obj, "value");
    },
  });
  const obj = {
    // 当读取 value 时才执行 effectFn
    get value() {
      // 只有“脏”时才计算值,并将得到的值缓存到 value 中
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      // 当读取 value 时,手动调用 track 函数进行追踪
      track(obj, "value");
      return value;
    },
  };
  return obj;
}
// watch 函数接受 sourc: 响应式数据, cb 是回调函数
function watch(source, cb, options = {}) {
  let getter;
  if (typeof source === "function") {
    getter = source;
  } else {
    getter = () => traverse(source);
  }
  // 定义旧值和新值
  let oldValue, newValue;
  // cleanup 用来储存用户注册的过期回调
  let cleanup;
  function onInvalidate(fn) {
    cleanup = fn;
  }
  const job = () => {
    // c重新执行副作用函数,得到的是新值
    newValue = effectFn();
    // 调用回调cb前, 先用过期回调
    if (cleanup) {
      cleanup();
    }
    // 当数据变化时调用回调函数
    cb(newValue, oldValue, onInvalidate);
    oldValue = newValue; // 更新旧值
  };
  // 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
  const effectFn = effect(
    // 触发递归读取操作,从而建立联系
    () => getter(),
    {
      lazy: true,
      scheduler: () => {
        // 调度函数中判断 flush 是否为 ’post', 如果是,将其放到微任务队列中执行
        if (options.flush === "post") {
          const p = Promise.resolve();
          p.then(job);
        } else {
          job();
        }
      },
    }
  );
  if (options.immediate) {
    // 当 immediate 为 true 时立即执行 job, 从而触发回调执行
    job();
  } else {
    oldValue = effectFn();
  }
}
function traverse(value, seen = new Set()) {
  // 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
  if (typeof value !== "object" || value == null || seen.has(value)) return;
  // 将数据添加到 seen 中,代表遍历读取过了,避免循环引用引起的死循环
  seen.add(value);
  // 暂时不考虑数据等其他结构
  // 假设 value 就是一个对象,使用 for...in 读取对象的没一个值,并递归调用 traverse 进行处理
  for (const k in value) {
    traverse(value[k], seen);
  }
  return value;
}
//add some
export default { effect: effect };

第五第六章分别告诉你引用类型和原始类型的响应方案。Vue2使用经验的同学强烈建议研读第六章,因为Proxy只能代理引用类型,第六章告诉你Vue3是怎么通过Proxy代理Js的原始类型的。

第五章推荐阅读 Object 和 Array 如何被 Proxy代理的。这部分通过大量ES规范产生式的引用查找引用类型内置插槽类似[[call]]匹配 Proxy api。这部分属于对语言和底层封装感兴趣的同学可以选读。

第三篇:渲染

renderer部分(必读)

渲染,是数据响应到视图层最重要的一环。

  • 挂载部分
  • 卸载部分
  • 事件处理:如何在虚拟节点描述事件,以及事件的挂载和更新
diff部分(选读)

diff 这部分内容可以说是掰开和你说了,而且增加了很多图例方便理解,这部分主要分析了diff算法三次迭代各个版本的优缺点,用了三章的篇幅去讲 Diff 算法的实现。

  1. 简单 Diff
  2. 双端 Diff
  3. 快速 Diff

第四篇:组件化(必读)

组件逻辑(必读)

这部分主要讲了组件渲染和更新的处理方式,还有setup api 是如何在 mountComponet 中实现的。可以说是涉及到了很多实际开发中忽视的的隐性问题:

  • 要不要和 Vue2 混写?
  • setup 中如何实现生命周期?
  • 怎么用 setup 实现组件封装?
  • slot emit 等功能是如何封装到组件里的?
// 组件逻辑部分demo
import { effect } from "./bookDemo.js";
import queueJob from "./jobQueue.js";

const vnode = {
  type: MyComponent,
  props: {
    title: "A big title",
    other: this.val,
  },
};

function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type; // type 是个对象....
  const {
    render,
    data,
    props: propsOptions,
    beforeCreate,
    created,
    beforeMount,
    mounted,
    beforeUpdate,
    updated,
  } = componentOptions;

  beforeCreate && beforeCreate();
  const state = reactive(data()); // data 数据响应化
  const [props, attrs] = resolveProps(propsOptions, vnode.props);
  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否被挂载,初始值 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null,
  };

  // 将组件实例设置到 vnode 上,用于后续更细
  vnode.component = instance;

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      // 取得组件自身状态与 props 数据
      const { state, props } = t;
      if (state && k in state) {
        return state[k]; // 尝试先读取自身属性
      } else if (k in props) {
        return props[k];
      } else {
        console.log("不存在");
      }
    },
    set(t, k, v, r) {
      const { state, props } = t;
      if (state && k in state) {
        state[k] = v;
      } else if (k in props) {
        props[k] = v;
      } else {
        console.log("不存在");
      }
    },
  });

  created && created.call(renderContext); // 在这里调用 created 将 renderContex 的 this 调整
  effect(
    () => {
      // 调用组件渲染函数,获得子树
      const subTree = render.call(state, state);
      // 检查组件是否已经被挂载
      if (!instance.isMounted) {
        beforeMount && beforeMount.call(state);
        // 初次挂载,调用 patch 函数第一个参数传递 null
        patch(null, subTree, container, anchor);
        // 重点:将组件实例的 isMounted 设置为 true, 这样当更新发生时就不会再次进行挂载操作,而是会执行更新
        instance.isMounted = true;
        mounted && mounted.call(state);
      } else {
        beforeUpdate && beforeUpdate.call(state);
        // 当 isMounted 为 true ,说明组件已经挂载,只需要完成自更新即可,所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
        patch(instance.subTree, subTree, container, anchor);
        updated && updated.call(state);
      }
      // 更新组件实例的子树
      instance.subTree = subTree;
    },
    { scheduler: queueJob } //这里的 queueJob 调度封装
  );
}

function resolveProps(options, propsData) {
  const props = {};
  const attrs = {};
  // 遍历为组件传递的 props 数据
  for (const key in propsData) {
    if (key in options) {
      // 如果 props 数据在组件自身的 props 选项中有定义,则将其视为合法 props
      props[key] = propsData[key];
    } else {
      attrs[key] = propsData[key];
    }
  }

  return [props, attrs];
}
export default mountComponent;

// 下面是 patch 如何引用 mountComponent
function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }

  const { type } = n2;

  if (typeof type === "string") {
    // ...
  } else if (type === "Text") {
    // ...
  } else if (type === Fragment) {
    // ...
  } else if (typeof type === "object") {
    if (!n1) {
      mountComponent(n2, container, anchor);
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor);
    }
  }
}

function patchComponent(n1, n2, anchor) {
  // 获取组件实例,即 n1.component, 同时让新的组件虚拟节点 n2.component 也指向组件实例
  const instance = (n2.component = n1.component);
  const { props } = instance;

  // 调用 hasPropsChanged 监测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新

  if (hasPropsChanged(n1.props, n2.props)) {
    // 调用 resolveProps 函数重新获取 props 数据
    const [nextProps] = resolveProps(n2.type.props, n2.props);
    // 更新 props
    for (const k in nextProps) {
      props[k] = nextProps[k];
    }
    // 删除不存在的 props
    for (const k in props) {
      if (!(k in nextProps)) delete props[k];
    }
  }
}

function hasPropsChanged(prevProps, nextProps) {
  const nextKeys = Object.keys(nextProps);
  // 如果新旧 props 的数量变了,则说明有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true;
  }
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i];
    // 有不相等 props, 则说明有变化
    if (nextProps[key] !== prevProps[key]) {
      return true;
    }
  }
  return false;
}

异步组件(选读)
函数组件(选读)

第五篇:编译器(选读)

这部分涉及到编译原理,主要是Vue Compiler 核心的实现,同时介绍了模板编译器DSL是如何去做词法分析 -> 语法分析-> 语义分析这套流程的。我对这一部分的看法是,读者根据个人兴趣阅读即可。毕竟编译原理属于开发绕不过去的内功,如果是非科班出身的同学建议通过这一篇入门编译原理。 不过这一篇有个必读点: 17章,主要讲了Vue3 在编译时所做的优化点:

  • 动态节点
  • block
  • 静态提升等

第六篇:服务端渲染(选读)

讲了 SSR 的特点和注意事项。这部分我建议先看总结部分,如果感兴趣可以看看,结合一下 SSR 的框架实践一下。

参考资料

《Vue.js 设计与实现》


企业微信截图_20220411195011.png

以上是我断断续续读了一个月后得出的一些阅读总结和建议,希望这篇文章对你起到一定的帮助~