从零开始,采用Vue的思想,开发一个本身的JS框架(二):首次渲染

题外话

对咱们程序猿/媛来讲,仅代码方面,看再多不如本身敲一遍。因此我才打算写这一系列,也算是给本身的所学作一个总结吧,若是有哪里写的很差的地方,还请你们多多指正。本系列文章更新速度不会很快,由于我是代码写到哪里就更到哪里。平时和你们同样也都得上班,因此时间上来讲也不会不少。目前预期更新进度大概一周一更或两更。node

关于本章内容,其实原本是打算和下一章节diff的内容合在一块儿,但由于感受篇幅可能会比较长,因此分为两部分。下一节内容大概在双休日的时候进行更新。git

目标

按照惯例,咱们先明确这一节的目标,咱们的目标是要将生成的DOM插入容器div(#app)中。让咱们先从目标触发,自底向上地完成这一过程。github

  1. 为了插入DOM,咱们必需要有一个mount方法,其应该接受两个参数,第一个目标容器(#app),第二个为DOM树,相似以下:
mount('#app', domTree);
复制代码
  1. 那么这个domTree怎么来?它应该经过咱们的Virtual DOM来生成。
domTree = createDOMTree(vnodeTree)
复制代码
  1. 同上的问题,vnodeTree应该由jsx解析后生成
vnodeTree = parse(jsx)
复制代码

既然已经明确了目标,咱们就开始逐步实现这一过程数组

从解析开始

以前咱们的经过上一章节咱们的解析函数对jsx进行解析。它返回给咱们的是一个包含tag、attr、children的对象,咱们首先就是对这个对象进行解析,这个过程应该是在调用beforeMount生命周期钩子以后。缓存

xm._callHook.call(xm, 'beforeMount');
    
// 生成vnode,并缓存到实例上
xm.$vnodeTree = parseJsxObj(xm.$render());
复制代码

那么parseJsxObj这个函数应该返回一个vnode,因此咱们须要一个VNode类:架构

class VNode {
  constructor(tagMsg) {
    // 若是是JSXObj对象,则进行解析
    if(tagMsg instanceof JSXObj) {
      this.tag = tagMsg.tag;
      this.children = [];
      this.attrs = {};
      this.events = {};
      // 判断是不是原生标签
      // NativeTags是一个包含原生标签的数组,
      // 除此之外,我还在原型上扩展了一个方法,用于让用户自定义地去扩展NativeTags,其实就是调用NativeTags.push()而已
      if(NativeTags.includes(this.tag)) this.isNativeTag = true;
      // 若是不是,则进行组件化处理
      else {
        // 这里会对其是否为组件进行判断,这一块先暂时跳过
        this.isNativeTag = false;
      }
      // 对attrs进行处理,分离出属性和事件
      tagMsg.attrs && Object.entries(tagMsg.attrs).forEach(([key, value]) => {
        // 以on+大写字母开头的字符串为事件
        if(key.match(/on[A-Z][a-zA-Z]*/)) {
          const eventName = key.substring(2, 3).toLowerCase() + key.substring(3);
          this.events[eventName] = value;
        }
        // 不然,其为属性
        else this.attrs[key] = value;
      })
    }
    // 对null节点的处理
    // 对于条件渲染,对不显示的项,须要在jsx中返回null
    else if(tagMsg === null) {
      this.tag = null;
    }
    // 若是不是,则默认当作文本节点处理,文本节点的tag属性为空字符串
    else {
      this.tag = '';
      this.text = tagMsg;
    }

  }
  // 不在构造函数里对子节点进行处理,经过实例主动调用此方法添加子节点
  addChild(child) {
    this.children.push(child);
  }
  // 添加真实DOM属性,用来缓存真实DOM
  addElement(el) {
    this.el = el;
  }
}
复制代码

既然已经有了VNode类,就能够完成parseJsxObj方法了app

// 解析JSX,返回VNodeTree
// 参数jsxObj可能为对象(普通节点),也可能为字符串(文本节点),也可能为null
export const parseJsxObj = function(jsxObj) {
  const vnodeTree = new VNode(jsxObj);
  // 这里经过递归的方式将子节点插入至父节点中
  jsxObj && jsxObj.children && jsxObj.children.forEach(item => vnodeTree.addChild(parseJsxObj(item)));
  return vnodeTree;
}
复制代码

到这里,咱们就已经生成了咱们的vnodeTree,接下来进入下一步。框架

生成DOM树

咱们已经有了咱们的vnodeTree,接下来就根据vnodeTree来生成DOM树,那么createDOMTree方法应该返回一个DOM对象或者存有DOM的对象dom

// 第一个参数为Xue实例,传入的目的是为了在绑定事件的过程当中,给事件方法绑定this,使this指向实例
const element = createDOMTree(xm, xm.$vnodeTree)
复制代码

基于此,咱们就须要一个Element类来进行DOM相关的操做,咱们以后对DOM的操做都会经过这个类来实现异步

class Element {
  // 传入xm的做用同上
  constructor(vnode, xm) {
    this.xm = xm;
    // 若是为null的话,则不作任何处理
    if(vnode.tag === null) return;
    // 非文本节点
    if(vnode.tag !== '') {
      this.el = document.createElement(vnode.tag);
      // 绑定属性
      Object.entries(vnode.attrs).forEach(([key, value]) => {
        this.addAttribute(key, value);
      });
      // 绑定事件
      Object.entries(vnode.events).forEach(([key, value]) => {
        this.addEventListener(key, value.bind(xm));
      });
    }
    // 文本节点
    else this.el = document.createTextNode(vnode.text);

  }
  // 不在构造函数里对子节点进行处理,经过外部主动调用此方法添加子节点
  appendChild(element) {
    this.el.appendChild(element.el);
  }
  // 添加属性,对className和style作特殊处理
  // class是保留字,style接受一个对象
  addAttribute(name, value) {
    if(name === 'className') {
      this.el.setAttribute('class', value);
    }
    else if(name === 'style') {
      Object.entries(value).forEach(([styleKey, styleValue]) => {
        this.el.style[styleKey] = styleValue;
      })
    }
    else {
      this.el.setAttribute(name, value);
    }
  }
  // 添加事件监听
  addEventListener(name, handler) {
    this.el.addEventListener(name, handler);
  }
  // 移除事件监听
  removeEventListener(name, handler) {
    this.el.removeEventListener(name, handler);
  }
}
复制代码

生成Element的思路基本上和生成VNode的思路是同样的,看看update方法的实现:

// 这里涉及到了更新操做,对于首次渲染而言,它其实只接受一个参数,逻辑上和上面生成VNode的思路是同样的
export const createDOMTree = function(xm, vnodeTree) {
  const elementTree = new Element(vnodeTree, xm);
  // 递归调用添加子节点
  vnodeTree.children.forEach(item => elementTree.appendChild(createDOMTree(xm, item)));
  // 把当前的DOM对象缓存到VNode中,能够在diff的过程当中,找到差别后直接对DOM进行修改
  vnodeTree.addElement(elementTree);
  return elementTree;
}
复制代码

到这里,咱们的DOM也有了,接下来就是实现mount方法,将DOM挂载至咱们的页面当中

// 这里我把_mount方法挂到了原型上
Xue.prototype._mount = function(dom) {
  const root = this.$options.root;
  // 若是是字符串,此时对应的就是咱们的根节点
  if(typeof root === 'string') this.$el = document.querySelector(root);
  // 这里对应的是组件化部分的逻辑
  else if(root instanceof HTMLElement) this.$el = root;
  this.$el.appendChild(dom);
}
复制代码

本章总结

最后,在咱们的init函数中,咱们此次新增的内容其实就是这部分:

xm._callHook.call(xm, 'beforeMount');

// 生成vnode
xm.$vnodeTree = parseJsxObj(xm.$render());

// 生成并挂载DOM
xm._mount.call(xm, createDOMTree(xm, xm.$vnodeTree).el);

xm._callHook.call(xm, 'mounted');
复制代码

到这里为止,本章的内容其实已经结束了,也成功完成了组件的初次挂载。接下来,咱们须要完成组件更新,如下内容为对下一章节update的预告,即为update作一些准备工做。

对update作一些准备工做

首先,update方法是和Wacther直接相关的,因此先完善一下咱们以前的Watcher类

// Watcher类
let id = 0;
class Watcher {
  // cb为watcher执行后的回调,type表示watcher的类型:render或者user
  // 先只考虑render的部分,user以后再实现
  constructor(cb, type) {
    this.id = id++;
    this.deps = [];
    this.type = type;
    this.cb = cb;
  }
  addDep(dep) {
    const depIds = this.deps.map(item => item.id);
    if(dep && !depIds.includes(dep.id)) this.deps.push(dep);
  }
  run() {
    this.cb();
  }
}
复制代码

而后咱们再init中,修改一下new Watcher的过程当中传入的参数

// init函数中传入的参数
new Watcher(() => {
  // 调用beforeUpdate钩子
  xm._callHook.call(xm, 'beforeUpdate');
  // 生成新的vnode
  const newVnodeTree = parseJsxObj(xm.$render());
  // 更新后返回一个新的vnode
  // 这里须要传入xm,为的是在更新后,this仍是指向Xue实例,主要应用在事件的处理函数上
  xm.$vnodeTree = update(xm, newVnodeTree, xm.$vnodeTree);
}, 'render');
复制代码

Watcher的部分更新完了,接下来就是执行Watcher的部分,上一章节咱们在派发更新的时候,用queue对Watcher数组进行保存,可是咱们在触发更新的时候,是同步处理的,这样会形成咱们每次更新依赖项,都会从新跑一遍render和diff的过程,这是十分浪费性能的,因此咱们要将其改成异步。其实就是一个nextTick的过程,因此就要先对nextTick进行封装:

// 返回一个结果为resolve的Promise
function nextTick() {
  // 为何不用setTimeOut,若是使用setTimeOut,由于setTimeOut是宏任务,咱们的更新过程则会被其余微任务所阻塞,这是十分影响性能的
  // 对于更新而言,其应该在主线程执行完以后当即执行,而不该该被阻塞
  return Promise.resolve();
}
export default nextTick;
复制代码

有了咱们的nextTick,那么就须要从新改一下queue相关的代码了:

let queue = [];
let waiting = false;
export const addUpdateQueue = function(watchers) {
  const queueSet = new Set([...queue, ...watchers]);
  queue = [...queueSet];
  // 排序是为了保证父组件的watcher先与子组件生成
  // 固然组件部分还没完成,因此这里能够忽略
  queue.sort((a, b) => a.id - b.id);
  // 使用waiting变量控制,使得遍历Watcher的过程只执行一次
  if(!waiting) {
    waiting = true;
    nextTick().then(() => {
      // 这里须要动态获取queue.length,由于在遍历queue执行Watcher的过程当中,可能会发生其余依赖项的变化
      // 这里还未对这种状况进行处理,此类状况会在以后的章节补充说明
      for(let i = 0; i < queue.length; i++) {
        // 执行Watcher的回调
        queue[i].run();
        // 遍历完成后,重置waiting
        if(i === queue.length - 1) waiting = false;
      }
    });
  }
}
复制代码

至此,本章内容就所有结束了,下一章节会对update的过程作详细地说明。敬请期待......

github地址:点此跳转

第一章:从零开始,采用Vue的思想,开发一个本身的JS框架(一):基本架构

第三章:从零开始,采用Vue的思想,开发一个本身的JS框架(一):update和diff

第四章:从零开始,采用Vue的思想,开发一个本身的JS框架(四):组件化和路由组件

相关文章
相关标签/搜索