21天学会写个仿Vue3的轮子:(二)第一次渲染虚拟树__Vue.js
发布于 3 年前 作者 banyungong 931 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

原生js的痛

假设我们要删除bar节点,无脑的办法就是分别找到bar和父节点。

<div id='foo'>
  <span id='bar'>aaa</span>
  <span id='baz'>bbb</span>
</div>
var foo = document.getElementById('foo')
var bar = document.getElementById('bar')
foo.removeChild(bar)

但是为了删除一个节点,我们还得先找到节点的爸爸是谁,才能把它干掉。每次删除都要进行“找爸爸”这种重复劳动。

<div>
  <span>count: 0</span>
</div>

其次,没有数据绑定,count的值变化了,可能每次都还得手动选中节点更新。

最后往往业务逻辑里,混杂了一大堆DOM API的操作。

为了解决以上痛点,你能想到哪些方案?

比如,原生js的api设计的不太合理,名字又臭又长,经常重复操作DOM的话,把api改漂亮点能省不少事。

嗯jQuery也是这么想的。

但是这没从根本上解决,数据层和视图层的鸿沟,数据层变化了得手动更新视图层的问题。

进一步想,为什么不做一个,语法接近HTML的模板语言以及对应的Compiler呢,发明几个指令(directive)来指示此处该与对应数据绑定?

// 假设有个data object存着count
const data = {
  count: 0
}

// 模板
<div>
  <span id='count'>count: {{ data.count }}</span>
</div>

将模板输入到Compiler里,当解析到<span>,Compiler发现有特殊指令{{ }}(双花括号)的存在,就知道这里有需要绑定的数据,立刻监视data.count。

一旦data.count发生了改变,就更新这个<span>

用伪代码近似表示下:

// 第一个参数监视数据,当数据变化,第二个callback就会执行,更新视图层的dom元素
watch(data.count, (el, data)=>{el.innerHTML = data.count})

这样,不管count变化多少次,我们只用关心data.count本身,而不用再手动写document.getElementById('count')之类的,直接操作DOM的代码。

好了,你想出来的这个模板 + 指令 + 监视数据, 更新DOM的方案,就很接近Vue1.x的思路了。

另一种脑洞

还有另一种方案,是比较难想到的,所以我比较佩服react作者的脑洞,那就是抽象出个Virtual DOM。

你不是说DOM操作起来繁琐,而且和数据分离开了吗?那你就当它不存在吧。

框架的用户只用和虚拟DOM打交道,剩下的事情交给框架。

具体来说,我们可以用javascript里的Object重新建立一个树形的数据结构,把DOM的信息进行抽象和存储,变成了由一个个Virtual Node组成的Virtual Tree。

一个dom元素无非有以下信息:

// div is a tag
// id is a prop
// span element is a child of div
<div id='foo'>
  <span> 0 </span>
</div>

所以设计对应以上结构的vnode的话,我们可以先简单的设计三个属性,type,props,children

const vnode = {
  type: 'div',
  props: {id: 'foo'},
  children: [
    type: 'span',
    props: null,
    children: data.count  // count is 0 now
  ]
}

因为vnode本身就是原生js构造的,所以我们可以在js里写vnode。

然后框架负责把vnode渲染成真正的DOM到页面。

// <div id='foo'>
//      <span>data.count</span>
// </div>
render() {
  // will return one root vnode
  return createVNode('div', {id: 'foo'}, [
    createVNode('span', {}, data.count)
  ]);
}

这样我们就完全不用触碰真实的DOM,不用手动调用DOM API。

完全可以在render function里创建vnode描述视图,然后框架根据render function里的vnode进行最终真实DOM的生成。

当然,为了更新数据时也能自动更新视图,框架需要提供一个特殊的函数,就叫他setState吧。它特殊在每次被调用,就会通知render()。

// change count to 1
setState({
    data.count: 1
});

用户保证更改数据时用setState更改,这样才能确保数据变化后会再次调用render()。

从而根据新的数据重新生成新的vnode,然后比较新旧vnode,更新真实DOM。

这个方案可以说是最早React的思路。真的是脑洞大开,把HTML视图层抽象到了javascript里,用户只用和vnode打交道,剩下的交给react。

融合

Vue1.0那样,在模板里,每个数据都进行一次绑定,细粒度太细了。

如果Web App复杂点,每个数据都进行watch,可能占用的内存会很大。

另外,将DOM用js object进行一次抽象,生成vnode tree确实好处很大,比如可以跨平台。

业务逻辑上写vnode,在不同平台,只需把框架的渲染器(renderer)稍微订制下,就可以在不同平台渲染了(理想都很丰满~)。

综上,从vue2.x开始,就引入了虚拟dom概念,并且更新也是以组件为单位进行更新。

在组件的render function中定义了这个组件包含的vnode。

下面这段代码其实就比较接近Vue模板被编译后,生成的render函数写法。

// h is createVNode actually
import h from 'balbalabla...';

const simpleComponent = {
  data() {
    return {
      count: 0
    }
  },
  render() {
    return h('div', {id: 'foo'}, [
      h('span', {}, data.count)
    ]);
  }
}

虽然大部分时候写Vue,都是写模板居多。其实模板里的组件编译后,都会变成这样一个个带render function的组件。

我们今天的目标就是写个hello world,并且渲染到页面上。

第一次渲染

要渲染的例子在playground/main.js下面,并且我已经把今天完成的代码,作为新的branch(02),上传到了github(https://github.com/yangjiang3973/vheel) 上了:

import { createApp, createVNode as h } from '../packages/runtime-dom/src/index';

const app = createApp({
  data() {
    return {
      title: 'Hello world!',
    };  
  },
  render() {
    // <div>
    //     <span>“Hello world!”</span>
    // </div>
    // equivalence vnode:
    return h('div', null, [h('span', null, [this.title])]);
  },
});

app.mount('#app');

如果你想跟着文章的思路一起写,把repo的上个branch(我只搭好了环境)clone到本地,然后npm install一下。

就可以直接在里面写了。写好了npm run dev可以在浏览器里渲染出Hello world!

开始写前我们要先理下思路,需要实现哪些函数到我们的轮子里。

  1. 我们创建vnode需要一个createVNode函数(简写为h),用在组件的render function里,来描述这个组件的样子。

  2. 组件最终返回根vnode,需要从该vnode出发,创建出真实的DOM子树

  3. 将该生成的DOM tree插入到页面, 也就是app.mount('#app')中选中的id为"app"的节点。在这里插入。

我在index.html里已经预先写好了HTML:

<body>
  <div id="app">  
    /*insert into here*/
  </div>
</body>

这是个仿Vue3的轮子,所以能模仿Vue的api我都是尽量起一样的名字,方便读者看Vue3源码的时候熟悉点。

我们先在packages下面创建个runtime-dom文件夹,开始写入口函数createApp。(以后文件结构不再重复解释,直接看我在github上传的repo)

// packages/runtime-dom/src/index.ts
export const createApp = (rootComponent) => {
  const app = {
    _component: rootComponent,
  };
  //* here to add mount method
  app.mount = (containerOrSelector) => {
    // just make sure the container passed in is valid
    const container = normalizeContainer(containerOrSelector);
    if (!container) return;
    const component = app._component;
    // build a virtual node for this component
    const vnode = createVNode(component);
    render(vnode, container);
    };
    return app;
};


function normalizeContainer(container) {
  if (isString(container)) {
    const res = document.querySelector(container);
    if (!res && __DEV__) {
      console.error('Cannot find the target container');
    }
    return res;
  }
}

目前入口很简单,只要创建个app对象,保存传入的根组件,并且挂上mount方法。

重要的是mount方法,负责生成vnode,渲染到页面。所以需要再分别实现createVNoderender这两个方法。

创建VNode

抽象出来的的,通用的vnode相关的代码,放在另外个runtime-core文件夹下。

我们先来写createVNode, 目前阶段逻辑非常简单。

我们只要创建vnode然后返回就行,注意的是,除了type,props和children,我还额外添加了两个属性。

随着后面继续开发vnode的属性会越来越多。

export function createVNode(type, props?, children?) {
  const vnode: VNode = {
    __v_isVNode: true,
    type,
    props,
    children,
    el: null,
  };
  return vnode;
}

渲染器

注意,我之前提过的render,是组件的里render,用户在这个约定好的地方定义vnode。

而此处我说的render是框架的渲染器的render,负责将用户在组件里声明的vnode渲染到真实DOM里去。

渲染有两种情况:

  1. 第一次渲染,没有旧的vnode,只需根据新vnode创建DOM(mount)

  2. 后续数据变化引发的渲染,需要比较新旧vnode来更新DOM(update)

我们目前只关心第一种情况,所以patch的第一个参数为null。

export function render(vnode, container) {
  // first time mount, no oldVNode
  patch(null, vnode, container);  
}

function patch(oldVNode, newVNode, container) {
  // mount or update
}

patch怎么设计, 这得看看目前的vnode有哪些可能,分别是:

  1. 创建app时(createApp),把根组件作为对象传入createVNode, 所以vnode里type属性是个组件对象。

  2. 创建<div>或者<span>, type是String类型。

  3. <span>Hello</span>,Hello是个文字节点,在的vnode里它是children

最好把文字也单独变成一个vnode,type是Text,这样以后patch或者update更方便,都是vnode。

function patch(oldVNode, newVNode, container) {
  const { type } = newVNode;
  if (isObject(type)) {
    processComponent(oldVNode, newVNode, container);
  } else if (isString(type)) {
    processElement(oldVNode, newVNode, container);
  } else if (type === Text) {
    processText(oldVNode, newVNode, container);
  }
}

接下来我们开始处理今天最麻烦的根组件:processComponent

目前只考虑第一次插入的情况,更新暂时不考虑,写个TODO flag占位。

// n1=oldVnode, n2=newVNode
function processComponent(n1, n2, container) {
  if (!n1) mountComponent(n2, container);
  // else {
  //     TODO:
  //     updateComponent(n1, n2);
  // }
}

在mountComponent主要需要做三件事情,

  1. 我们需要根据传入的组件vnode,来生成组件的实例。

在写Vue时,我们经常用到this.keyName来取data或者props,this就是指向这个实例。

  1. setup这个实例,比如把data挂到实例上。

  2. 最后调用组件里的render方法,生成VNode subtree。

function mountComponent(compVNode, container) {
  // init component instance
  const instance = {
    type: compVNode.type,
    vnode: compVNode,
    data: {},
    proxy: {},
  };
  compVNode.component = instance;
  // setup component, such as props
  setupComponent(instance);
  // generate component's root vnode tree, then patch again
  setupRenderEffect(instance, compVNode, container);
}

如果你以前有使用Vue的经验,你肯定是直接通过this.count或者this.title来直接获取数据,

而不是this.data.title

这是因为数据直接被挂在了instance上,方便使用。

这里就用Proxy来把数据代理到instance上。

暂时不了解Proxy不要紧,可以去MDN上看文档,也可以等下一篇重点用到Proxy的时候我再介绍。

function setupComponent(instance) {
  instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers);
  
  const Component = instance.type;
  instance.render = Component.render || (() => {});
  if (isFunction(Component.data)) {
    const dataFn = Component.data;
    const data = dataFn.call(instance.proxy);
    instance.data = data;
  }
}


// simple proxy handler to access data on instance directly
const PublicInstanceProxyHandlers = {
  get: function (target, key) {
    if (hasOwn(target.data, key)) 
      return Reflect.get(target.data, key);
    },
};

mount Component的最后一步,就是调用组件的render,生成vnode。

function setupRenderEffect(instance, initialVNode, container) {
  const { proxy, render } = instance;
  const subTree = render.call(proxy);
  patch(null, subTree, container);
}

这里的subTree就是组件里,我们定义的根vnode。在要跑通的例子里,此处是包裹着的根节点

此时再次调用patch,对组件的vnode tree进行mount。

处理一般DOM Element

这种vnode属于我们在patch中设计的第二种情况。忘记了的回去重看patch。

处理一般的节点使用processElement, 同样只是mount,不管更新

function processElement(n1, n2, container) {
  if (!n1) mountElement(n2, container);
  // TODO:
  // else patchElement(n1,n2,container)
}

这里很清楚的表明,从虚拟DOM到真实DOM的创建,都是由框架干的活。

框架负责进行DOM API的调用来生成真实页面。

function mountElement(vnode, container) {
  const el = (vnode.el = document.createElement(vnode.type));
  if (vnode.children) {
    mountChildren(vnode.children, el);
  }
  container.appendChild(el);
}

一般的element如果有children,就递归式的再次调用patch,处理child。

这里有个特例,就是child是字符串。

比如<span>Hello world</span>中,“Hello world”会作为字符串类型的child。

我们需要把它重新生成一个type为Text类型的vnode,传入patch再进行mount。

function mountChildren(children, container) {
  for (let i = 0; i < children.length; i++) {
    let child = children[i];
    // TODO: should normalize all possible child types
    if (isString(child)) {
      child = createVNode(Text, null, child);
    }
    patch(null, child, container);
  }
}

此时进入patch函数里的第三种情况,处理文字节点。

function processText(n1, n2, container) {
  if (!n1) {
    n2.el = document.createTextNode(n2.children);
    container.append(n2.el);
  }
  // TODO:
  // else
}

以上就是第一次渲染的整个流程。

期间用到了一些helper function, 比如 isString, isObject, hasOwn, isFunction

我就不在主线内容里提这些函数是干嘛的了,看名字就知道。

这些常用的helper function放在packages/shared/src/index.ts里,不想自己写的,可以去github上看。

下一篇的任务是,让渲染出的内容可以变换,也就是自动把数据的变换更新到视图。

债见~

【首发在公众号:奔三程序员Club】

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: yangjiang3973 原文链接:https://juejin.im/post/6944644028759539748

回到顶部