從零開始,採用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框架(四):組件化和路由組件

相關文章
相關標籤/搜索