手写一个乞丐版vue3

330 阅读5分钟

begger-vue组成

主要为以下几个核心函数:

  • renderh
  • mount
  • patch
  • reactive
  • watchEffect

vue最大的特点是数据响应式,数据驱动视图更新。意思是我每次修改了数据,能自动给我更新dom

renderh函数

我们在单文件组件定义下面template

<div>
    <span id="foo">foo</span>
</div>

template经过vue-loadervue-template-compiler的解析后会变成一个render函数。大概是这样子

function render () {
    return h('div', {}, [h('span', { id: 'foo' },  'foo')]);
}

这里我们定义的h函数主要是用js描述了这个节点的结构:

function h(type, props, children) {
    return {
        type,
        props,
        children: Array.isArray(children) ? children : String(children),
    };
}

那么上面的render函数执行得到的一个结构是这样的,也就是我们所说的vnode。当然实际上vnode还有其他属性,我们的乞丐版不考虑。

{
    "type": "div",
    "props": {},
    "children": [
        {
            "type": "span",
            "props": {
                "id": "foo"
            },
            "children": "foo"
        }
    ]
}

自此我们就生成了vnode,接下来是怎么把他渲染到页面上。

mount

拿到了vnode,通过mount函数渲染到页面,mount主要是创建真实的dom,处理属性,和递归遍历children

function mount(vnode: VNode, el: HTMLElement) :void {
    const { type, props, children } = vNode;
    // 把真实的dom元素记录在el属性上
    const el = (vNode.el = document.createElement(type));
    // 链接父元素
    el.parent = container;
    // 处理属性,事件或普通属性
    Object.keys(props).forEach((key) => {
        if (isEvent(key)) {
           el.addEventListener(key.toLowerCase().substring(2), props[key]);
        } else {
           el.setAttribute(key, props[key]);
        }
    });
    // 子元素是字符串,直接设置为文本
    if (typeof children === "string") {
        el.textContent = children;
    } else {
        children.forEach((child) => mount(child, el));
    }
    container.appendChild(el);
}

有了mount函数,我们就可以挂载了。

mount(render(), document.getElementById('app'));

image.png

patch

patch函数主要是深度遍历新旧vnode,当比较发现有差异的时候会真实的操作dom,根据差异来修改视图。这里不考虑diff算法和节点复用,同层对比即可。

有如下两个新旧节点

// old vnode
{
    "type": "div",
    "props": {},
    "children": [
        {
            "type": "span",
            "props": {
                "id": "foo"
            },
            "children": "foo"
        }
    ]
}

// new vnode
{
    "type": "div",
    "props": {},
    "children": [
        {
            "type": "span",
            "props": {
                "id": "baz"
            },
            "children": "baz"
        }
    ]
}


可以看得出是span标签的idtextContent发生了变化,那么patch函数实现主要关注点如下:

  • 如果标签不一致,那么移除旧元素,直接通过mount函数生成新的节点
  • 如果属性不一致,需要区分事件和普通属性,逻辑分之有增删改操作
  • 如果新vnodechidlren是字符串属性,删除原来子节点,改为设置textContent,否则对比新旧children,逻辑分之有处理新增子节点、删除子节点和修改子节点。

  function isEvent(prop) {
    return prop.startsWith("on");
  }
  function isProp(prop) {
    return !isEvent(prop);
  }
  function patch(oldVnode, vnode) {
    const {
      type: oldType,
      props: oldProps,
      children: oldChildren,
      el,
    } = oldVnode;
    const { type, props, children } = vnode;
    vnode.el = el;
    vnode.parent = vnode.parent;

    // 完全不同的节点
    if (oldType !== type) {
      el.parent.removeChild(el);
      mount(vnode, el.parent);
    } else {
      // 移除属性
      Object.keys(oldProps)
        .filter(isProp)
        .filter((key) => !(key in props))
        .forEach((key) => {
          el.removeAttribute(key);
        });

      // 新增属性或者修改属性
      Object.keys(oldProps)
        .filter(isProp)
        .filter((key) => oldProps[key] !== props[key])
        .forEach((key) => {
          el.setAttribute(key, props[key]);
        });

      // 移除事件
      Object.keys(oldProps)
        .filter(isEvent)
        .filter((key) => !(key in props) || oldProps[key] !== props[key])
        .forEach((key) => {
          const eventType = key.toLowerCase().substring(2);
          el.removeEventListener(eventType, oldProps[key]);
        });
      // 新增
      Object.keys(oldProps)
        .filter(isEvent)
        .filter((key) => oldProps[key] !== props[key])
        .forEach((key) => {
          const eventType = key.toLowerCase().substring(2);
          el.addEventListener(eventType, props[key]);
        });
      // 处理children
      if (Array.isArray(children)) {
        if (typeof oldChildren === "string") {
          el.innerHTML = "";
          children.forEach((child) => mount(child, el));
        } else {
          const commonLen = Math.min(children.length, oldChildren.length);
          for (let i = 0; i < commonLen; i++) {
            patch(oldChildren[i], children[i]);
          }

          // 删除多余节点
          oldChildren.slice(commonLen).forEach(({ el, parent }) => {
            parent.removeChild(el);
          });
          // 新增节点
          children.slice(commonLen).forEach((newVnode) => {
            mount(newVnode, el);
          });
        }
      } else {
        el.innerHTML = "";
        el.textContent = children;
      }
    }
  }

上面逻辑分之很多,不用太关注,只要知道patch会更新dom即可

DepwatchEffect

怎么在数据修改的时候,会自动修改视图呢,也就是每次修改数据会自动执行patch函数。在之前,需要先了解DepwatchEffect,它会告诉你,如果一个属性被修改了,怎么自动触发一段函数。

<script>
let activeEffect = null;

class Dep {
    // 收集副作用
    effects = new Set();
    constructor(val) {
        this._val = val;
    }
    get value() {
        this.depend();
        return this._val;
    }
    set value(newVal) {
        if (newVal !== this.value) {
            this._val = newVal;
            this.notify();
        }
        return true;
    }
    depend() {
        if (activeEffect && !this.effects.has(activeEffect)) {
            this.effects.add(activeEffect);
        }
    }
    notify() {
        this.effects.forEach(efftct => efftct());
    }
}

function ref(val) {
    return new Dep(val);
}

let count = ref(0);

function watchEffect(effect) {
    // 设置为当前运行中effect
    activeEffect = effect;
    // 立刻执行effect,和收集变量的effect
    effect();
    activeEffect = null;
}

watchEffect(() => {
    console.log('count.value changed:', count.value);
})
</script>

简单来说只要劫持某个变量的gettersetter,在getter收集依赖,vue3把依赖叫做effect副作用,然后在setter触发副作用。 上面的代码运行后,控制台的输出:

image.png

每次在控制台更新count.value就会执行effects,每个变量可以收集多个effect

使用Proxy完成reactive

上面还是使用vue2的方式去劫持某个属性的get、set。有以下缺点

  • 需要提前声明劫持属性,这就需要递归变量的每个层级,存在额外的性能开销
  • 无法自动追踪数组的副作用方法,需要重写shift、push、pop等原型方法,且对于arr[0] = xxx这种情况,无法自动跟踪。

基于Object.defineProperty的一些弊端,vue3已经使用Proxy重写响应式部分,因此我们改造下上面的Dep和新增reactive

  let activeEffect = null;

  class Dep {
    effects = new Set();
    depend() {
      activeEffect &&
        !this.effects.has(activeEffect) &&
        this.effects.add(activeEffect);
    }
    notifiy() {
      this.effects.forEach((effect) => effect());
    }
  }
    // 这个map收集了每个响应式对象所有属性的dep
    // 使用weakMap是为了更好的垃圾回收,具体可以参考高程4
  const globalEffectMap = new WeakMap();
  function getDep(target, key) {
    let targetMap = globalEffectMap.get(target);
    // 是否存在该变量的effects集合
    if (!targetMap) {
      targetMap = new Map();
      globalEffectMap.set(target, targetMap);
    }
    let dep = targetMap.get(key);
    // 是否存在该属性的effect
    if (!dep) {
      dep = new Dep();
      targetMap.set(key, dep);
    }
    return dep;
  }

  const proxyHandler = {
    get(target, key, receiver) {
      const dep = getDep(target, key);
      // 收集当前正在运行的effect
      // 也就是watchEffect(effect)时,effect会立刻被执行,变成当前正在运行的effect
      dep.depend();
      return Reflect.get(...arguments);
    },
    set(target, key, value, receiver) {
      const dep = getDep(target, key);
      Reflect.set(...arguments);
      dep.notifiy();
      return true;
    },
  };
  
  // 收集
  function reactive(source) {
    return new Proxy(source, proxyHandler);
  }

使用Proxy劫持get、set后,就可以监听任意层级任意属性的变化了。

组合核心函数

结合上面的每个模块,我们把逻辑全部组合起来。

  function createApp(App, containter) {
    let mounted = false;
    let vnode = null;
    watchEffect(() => {
      // 首次渲染
      if (!mounted) {
        vnode = App.render();
        mount(vnode, containter);
        mounted = true;
      } else {
        const newVnode = App.render();
        patch(vnode, newVnode);
        vnode = newVnode;
      }
    });
  }

  const proxy = reactive({
    count: 1,
  });
  
  const App = {
    render() {
      return h("div", { tag: "div" }, [
        // 每次点击button,新增一个p标签
        h("button", { onClick: () => proxy.count++ }, proxy.count),
        ...Array.from({ length: proxy.count }).map((v, i) =>
          h("p", { tag: i }, i)
        ),
      ]);
    },
  };
  createApp(App, document.getElementById("app"));

来看下效果

点击按钮的时候, 新增一个p标签

output.gif

控制台改变数据的时候,视图也会同步

image.png

至此我们已经完成了一个乞丐版的vue3,完整代码:

<div id="app"></div>
<script>
  function h(type, props, children) {
    return {
      type,
      props,
      children: Array.isArray(children) ? children : String(children),
    };
  }

  function mount(vNode, container) {
    const { type, props, children } = vNode;
    // 把真实的dom元素记录在el属性上
    const el = (vNode.el = document.createElement(type));
    // 链接父元素
    el.parent = container;

    // 处理属性,事件或普通属性
    Object.keys(props).forEach((key) => {
      if (isEvent(key)) {
        el.addEventListener(key.toLowerCase().substring(2), props[key]);
      } else {
        el.setAttribute(key, props[key]);
      }
    });

    if (typeof children === "string") {
      el.textContent = children;
    } else {
      children.forEach((child) => mount(child, el));
    }
    container.appendChild(el);
  }

  function isEvent(prop) {
    return prop.startsWith("on");
  }
  function isProp(prop) {
    return !isEvent(prop);
  }
  function patch(oldVnode, vnode) {
    const {
      type: oldType,
      props: oldProps,
      children: oldChildren,
      el,
    } = oldVnode;
    const { type, props, children } = vnode;
    vnode.el = el;
    vnode.parent = vnode.parent;

    // 完全不同的节点
    if (oldType !== type) {
      const parentNode = el.parent;
      parentNode.removeChild(el);
      mount(vnode, parentNodet);
    } else {
      // 移除属性
      Object.keys(oldProps)
        .filter(isProp)
        .filter((key) => !(key in props))
        .forEach((key) => {
          el.removeAttribute(key);
        });

      // 新增属性或者修改属性
      Object.keys(oldProps)
        .filter(isProp)
        .filter((key) => oldProps[key] !== props[key])
        .forEach((key) => {
          el.setAttribute(key, props[key]);
        });

      // 移除事件
      Object.keys(oldProps)
        .filter(isEvent)
        .filter((key) => !(key in props) || oldProps[key] !== props[key])
        .forEach((key) => {
          const eventType = key.toLowerCase().substring(2);
          el.removeEventListener(eventType, oldProps[key]);
        });
      // 新增
      Object.keys(oldProps)
        .filter(isEvent)
        .filter((key) => oldProps[key] !== props[key])
        .forEach((key) => {
          const eventType = key.toLowerCase().substring(2);
          el.addEventListener(eventType, props[key]);
        });
      // 处理children
      if (Array.isArray(children)) {
        if (typeof oldChildren === "string") {
          el.innerHTML = "";
          children.forEach((child) => mount(child, el));
        } else {
          const commonLen = Math.min(children.length, oldChildren.length);
          for (let i = 0; i < commonLen; i++) {
            patch(oldChildren[i], children[i]);
          }

          // 删除多余节点
          oldChildren.slice(commonLen).forEach(({ el, parent }) => {
            parent.removeChild(el);
          });
          // 新增节点
          children.slice(commonLen).forEach((newVnode) => {
            mount(newVnode, el);
          });
        }
      } else {
        el.innerHTML = "";
        el.textContent = children;
      }
    }
  }

  let activeEffect = null;

  class Dep {
    effects = new Set();
    depend() {
      activeEffect &&
        !this.effects.has(activeEffect) &&
        this.effects.add(activeEffect);
    }
    notifiy() {
      this.effects.forEach((effect) => effect());
    }
  }

  const globalEffectMap = new WeakMap();
  function getDep(target, key) {
    let targetMap = globalEffectMap.get(target);
    if (!targetMap) {
      targetMap = new Map();
      globalEffectMap.set(target, targetMap);
    }
    let keyMap = targetMap.get(key);
    if (!keyMap) {
      keyMap = new Dep();
      targetMap.set(key, keyMap);
    }
    return keyMap;
  }

  const proxyHandler = {
    get(target, key, receiver) {
      const keyMap = getDep(target, key);
      keyMap.depend();
      return Reflect.get(...arguments);
    },
    set(target, key, value, receiver) {
      const keyMap = getDep(target, key);
      Reflect.set(...arguments);
      keyMap.notifiy();
      return true;
    },
  };

  function reactive(source) {
    return new Proxy(source, proxyHandler);
  }

  function watchEffect(effect) {
    activeEffect = effect;
    effect();
    activeEffect = null;
  }

  function createApp(App, containter) {
    let mounted = false;
    let vnode = null;
    watchEffect(() => {
      if (!mounted) {
        vnode = App.render();
        mount(vnode, containter);
        mounted = true;
      } else {
        const newVnode = App.render();
        patch(vnode, newVnode);
        vnode = newVnode;
      }
    });
  }

  const proxy = reactive({
    count: 1,
  });

  const App = {
    render() {
      return h("div", { tag: "div" }, [
        h("button", { onClick: () => proxy.count++ }, proxy.count),
        ...Array.from({ length: proxy.count }).map((v, i) =>
          h("p", { tag: i }, i)
        ),
      ]);
    },
  };
  createApp(App, document.getElementById("app"));
</script>

思考

vue3很大一部分的工作量都在

  • 模板的编译优化,patchFlag的实现, 事件缓存等
  • 更好的diff算法, 和更好的typescript支持
  • Composition-API

在我们的demo通通都没有, 甚至更新都是同步的,但是我们主要是学习核心思路,理解vue的核心思想。后续的可以慢慢拓展

参考: