手寫虛擬Dom核心方法

簡介

虛擬dom概念最早是facebook提出的, 並運用於react框架. 在dom元素更新的環節上, 使用虛擬dom, 結合diff算法. 能夠很大的提高性能. 在vue2.0上, 也一樣使用了虛擬dom. 虛擬dom, 能夠是一個比較獨立的, 能夠不依賴於任何的框架. 目前市面上也有一些虛擬dom的類庫. 那麼, 它最核心的API有哪些呢?javascript

核心API

  • createElement: 用於建立虛擬dom對象
  • render: 用於渲染虛擬dom; 虛擬dom數據有變化時,結合diff算法, 更新渲染.

定義變量

/** * 定義, 虛擬節點的類型. */
export const vNodeType = {
  HTML:'HTML',
  TEXT: 'TEXT',
  COMPONENT: 'COMPONENT'
};

/** * 定義子元素的類型. */
export const vChildType = {
  EMPTY: 'EMPTY',
  SINGLE: 'SINGLE',
  MULTI: 'MULTI'
}
複製代碼

CreateElement實現.

import { vNodeType, vChildType } from './strings';

/** * 建立文本元素. * @param {String} text */
const createTextVNode = text => {
  return {
    // 虛擬dom的類型. TEXT, HTML. COMPONENT等.
    nodeType: vNodeType.TEXT,

    // 節點標籤: div, p等
    tag: null,

    // 虛擬dom節點的屬性: {style: {color: 'red'}, key: 'xxx'}
    props: null,

    // 虛擬dom渲染後的真實的dom節點.
    el: null,

    children: text,
    childType: vChildType.EMPTY
  };
}

/** * 建立虛擬dom. * @param {String} tag 標籤名稱. div, function, null等 * @param {Object} props 虛擬元素的屬性對象. * @param {Array} children 虛擬元素的子元素. */
const createElement = (tag, props, children) => {
  let nodeType;
  let childType;

  // 根據傳入的tag, 設置虛擬元素的類型.
  switch (typeof tag) {
    case 'string': {
      nodeType = vNodeType.HTML;
      break;
    }
    case 'function': {
      nodeType = vNodeType.COMPONENT;
      break;
    }
    default: {
      nodeType = vNodeType.TEXT;
      break;
    }
  }

  // 根據傳入的children, 設置子元素的標誌, 方便後期使用.
  if (!children) {
    childType = vChildType.EMPTY;
  } else if (Array.isArray(children)) {
    if (!children.length) {
      childType = vChildType.EMPTY;
    } else {
      childType = children.length > 1 ? vChildType.MULTI : vChildType.SINGLE;
    }
  } else {
    // 文本
    childType = vChildType.SINGLE;
    children = createTextVNode(children);
  }

  return {
    // 虛擬dom渲染後的真實的dom節點.
    el: null,

    // 虛擬dom的類型. TEXT, HTML. COMPONENT等.
    nodeType,

    // 節點標籤: div, p等
    tag,

    // 虛擬dom節點的屬性: {style: {color: 'red'}, key: 'xxx'}
    props,

    // 虛擬dom的子節點
    children,

    // 虛擬dom子節點的類型: empty, single, multipy。
    // 不一樣的類型,在掛載和更新時, 會有不一樣的處理邏輯.
    childType
  };
}

export default createElement;
複製代碼

render實現

區分首次渲染仍是更新操做.html

/** * 渲染或更新虛擬dom * @param {Object} vNode * @param {HTMLElement} container */
const render = (vNode, container) => {
  const isFirstRender = !container.vNode;

  // 首次渲染
  if (isFirstRender) {
    mount(vNode, container);
  } else {
    // 更新操做
    patch(container.vNode, vNode, container);
  }

  // 保存起來, 用來區分是否爲首次渲染
  container.vNode = vNode;
};
複製代碼

render.js的完整代碼實現: 重點要關注: patchProps和patchChildren兩個方法.vue

import { vNodeType, vChildType } from './strings';

/**
 * 更新子節點. 是虛擬dom更新時, 最核心的方法. 涉及到diff比較.
 * @param {String} preChildType 上一個子節點的類型
 * @param {String} nextChildType 待更新的子節點的類型
 * @param {Object} preChildren 上一個子節點的虛擬dom
 * @param {Object} nextChildren 待更新子節點的虛擬dom
 * @param {HTMLElement} container 掛載的容器. 
 */
const patchChildren = (preChildType, nextChildType, preChildren, nextChildren, container) => {
  // 更新的場景.
  // - 1. 老的節點
  // - 老的是一個
  // - 老的是空
  // - 老的是多個
  // 2. 新的節點.
  // - 新的是一個
  // - 新的是空
  // - 新的是多個
  // 組合起來, 共有9中狀況.
  switch (preChildType) {
    case vChildType.SINGLE: {
      switch (nextChildType) {
        case vChildType.SINGLE: {
          // 都是單個. 執行更新操做
          patch(preChildren, nextChildren, container);
          break;
        }
        case vChildType.EMPTY: {
          // 新的是空. 移除老的節點.
          container.removeChild(preChildren.el);
          break;
        }
        case vChildType.MULTI: {
          // 老的是單個. 新的是多個.
          // 先刪除老的節點. 而後在逐個掛載每個新的節點.
          container.removeChild(preChildren.el);
          for (let i = 0; i < nextChildren.length; i++) {
            mount(nextChildren[i], container);
          }
          break;
        }
      }
      break;
    }
    case vChildType.EMPTY: {
      switch (nextChildType) {
        case vChildType.SINGLE: {
          // 老的是空, 新的是單個. 直接掛載.
          mount(nextChildren, container);
          break;
        }
        case vChildType.EMPTY: {
          // 兩個都是空的狀況. 無需任何操做.
          break;
        }
        case vChildType.MULTI: {
          // 老的是空,新的是多個. 
          // 逐個掛載新的每個節點.
          for (let i = 0; i < nextChildren.length; i++) {
            mount(nextChildren[i], container);
          }
          break;
        }
      }
      break;
    }
    case vChildType.MULTI: {
      switch (nextChildType) {
        case vChildType.SINGLE: {
          // 老的是多個, 新的是單個.
          // 先逐個刪除老的, 而後掛載新的.
          for (let i = 0; i < preChildren.length; i++) {
            container.removeChild(preChildren[i].el);
          }
          mount(nextChildren, container);
          break;
        }
        case vChildType.EMPTY: {
          // 老的是多個, 新的是空.
          // 先逐個刪除老的.
          for (let i = 0; i < preChildren.length; i++) {
            container.removeChild(preChildren[i].el);
          }
          break;
        }
        case vChildType.MULTI: {
          // 不一樣的虛擬dom實現, 就在這裏區分, 不一樣的類庫優化策略不同.
          // 老的是數組, 新的也是數組.
          // 實現策略. 查看相對位置.
          // - 老的是[a,b,c],新的也是[a,b,c]:節點的相對位置是遞增的. 元素不須要移動.
          // - 老的是[a,b,c], 新的是[x,e,a,h,b,e,c]: 節點a,b,c的相對位置也是遞增的.元素不須要移動.
          // - 老的是[a,b,c], 新的是[b,a,c]: 那麼節點b和a的相對位置, 發生改變,但接到a和c的相對位置仍是遞增的.
          let lastIndex = 0;

          for (let i = 0; i < nextChildren.length; i++) {
            let isFind = false;
            let nextVNode = nextChildren[i];
            let j = 0;
            for (j; j < preChildren.length; j++) {
              let preVNode = preChildren[j];

              // 1. 若是key相同, 咱們認爲是同一個元素.
              if (preVNode.props.key === nextVNode.props.key) {
                isFind = true;
                patch(preVNode, nextVNode, container);

                // 若是j小於lastIndex, 則相對位置發生變化.
                // 認爲須要移動.
                if (j < lastIndex) {
                  // insertBefore移動元素.
                  // abc, a想移動到b以後. abc的父元素.insertBefore()
                  const flagElement = nextChildren[i - 1].el.nextSibling;
                  container.insertBefore(preVNode.el, flagElement);
                  break;
                } else {
                  lastIndex = j;
                }
              }
            }

            // 在老的中沒有找到. 須要新增.
            if (!isFind) {
              const flagNode = i == 0 ? preChildren[0].el : nextChildren[i - 1].el.nextSibling;
              mount(nextVNode, container, flagNode);
            }
          }

          // 刪除老的中存在, 新的中不存在的節點.
          for (let i = 0; i < preChildren.length; i++) {
            const preVNode = preChildren[i];
            const has = !!nextChildren.find(m => m.props.key === preVNode.props.key);

            if (!has) {
              container.removeChild(preVNode.el);
            }
          }
          break;
        }
      }
      break;
    }
    default: {
      break
    }
  }
};

/**
 * 更新HTML類型的虛擬節點.
 */
const patchHTML = (pre, next, container) => {
  // pre是div, next是p
  if (pre.tag !== next.tag) {
    return replaceVNode(pre, next, container);
  }

  // 1. 更新節點的props.
  const { el, props: preProps } = pre;
  const { props, children } = next;

  // 更新新的props.
  for (const key in props) {
    if (props.hasOwnProperty(key)) {
      patchProps(el, key, preProps[key], props[key]);
    }
  }

  // 刪除老的props中存在, 但新的props中不存在的屬性
  for (const key in preProps) {
    if (preProps.hasOwnProperty(key) && !props.hasOwnProperty(key)) {
      // 第四個參數, 表示新的props中值沒有.
      patchProps(el, key, preProps[key], null);
    }
  }

  // 2. 更新子節點
  patchChildren(pre.childType, next.childType, pre.children, next.children, el);

  next.el = el;
};

/**
 * 更新文本類型的虛擬節點.
 */
const patchText = (pre, next) => {
  const { el } = pre;

  // 更新文本節點的值
  if (next.children !== pre.children) {
    el.nodeValue = next.children;
  }

  // 保存真實節點到虛擬dom中.
  next.el = el;
};

/**
 * 替換虛擬dom節點
 */
const replaceVNode = (pre, next, container) => {
  // 刪除原來的
  container.removeChild(pre.el);

  // 掛載最新的.
  mount(next, container);
};

/**
 * 更新元素. 是虛擬dom中最核心的方法.
 * @param {Object} preVNode 上一次的虛擬dom
 * @param {Object} nextVNode 最新的虛擬dom
 * @param {HTMLElement} container 要掛載的節點容器.
 */
const patch = (preVNode, nextVNode, container) => {
  const {
    nodeType: preNodeType
  } = preVNode;
  const {
    nodeType
  } = nextVNode;

  // 1. prv是文本, next是html(好比div). 直接替換操做. 沒有優化的空間.
  if (preNodeType !== nodeType) {
    replaceVNode(preVNode, nextVNode, container);
  } else if (nodeType === vNodeType.HTML) {
    patchHTML(preVNode, nextVNode, container);
  } else if (nodeType === vNodeType.TEXT) {
    patchText(preVNode, nextVNode, container);
  }
}

/**
 * 更新節點屬性.
 * @param {HTMLElement} el 
 * @param {any} key 
 * @param {Object} pre 上一次的屬性對象
 * @param {Object} next 待更新的屬性對象
 */
const patchProps = (el, key, pre, next) => {
  switch (key) {
    case 'style': {
      // 更新新的props
      for (const k in next) {
        if (next.hasOwnProperty(k)) {
          el.style[k] = next[k];
        }
      }

      // 刪除老的props上有, 但在新的props上沒有的屬性
      for (const k in pre) {
        if (pre.hasOwnProperty(k) && next && !next.hasOwnProperty(k)) {
          el.style[k] = '';
        }
      }
      break;
    }
    case 'class': {
      el.className = next;
      break;
    }
    default: {
      // 事件
      if (key[0] === '@') {
        const eventType = key.slice(1);

        if (pre) {
          el.removeEventListener(eventType, pre);
        }

        if (next) {
          el.addEventListener(eventType, next);
        }
      } else {
        el.setAttribute(key, next);
      }
      break;
    }
  }
};

/**
 * 掛載虛擬dom到指定的容器上.
 * @param {Object} vNode 虛擬dom對象
 * @param {HTMLElement} container 掛載的容器
 * @param {HTMLElement} flagNode 元素掛載時調用insertBefore方法時的參考元素. 主要用於元素更新時. 
 */
const mountElement = (vNode, container, flagNode) => {
  const {
    nodeType,
    tag,
    props,
    el,

    children,
    childType
  } = vNode;

  // 建立dom節點 
  const dom = document.createElement(tag);
  vNode.el = dom;

  // 掛載props
  if (props) {
    for (const key in props) {
      if (props.hasOwnProperty(key)) {
        const data = props[key];

        // 節點, key, 老值, 新值.
        patchProps(dom, key, null, data);
      }
    }
  }

  // 掛載子元素.
  if (childType !== vChildType.EMPTY) {
    if (childType === vChildType.SINGLE) {
      mount(children, dom);
    } else if (childType === vChildType.MULTI) {
      children.forEach(node => {
        mount(node, dom);
      })
    }
  }

  flagNode ? container.insertBefore(dom, flagNode) : container.appendChild(dom);
};

/**
 * 掛載文本類型的虛擬dom
 * @param {Object} vNode 
 * @param {HTMLElement} container 
 */
const mountText = (vNode, container) => {
  vNode.el = document.createTextNode(vNode.children);
  container.appendChild(vNode.el);
};

/**
 * 首次渲染.
 */
const mount = (vNode, container, flagNode) => {
  const { nodeType } = vNode;

  switch (nodeType) {
    case vNodeType.HTML: {
      mountElement(vNode, container, flagNode);
      break;
    }
    case vNodeType.TEXT: {
      mountText(vNode, container);
      break;
    }
    default: break;
  }
};

/**
 * 渲染或更新虛擬dom
 * @param {Object} vNode 
 * @param {HTMLElement} container 
 */
const render = (vNode, container) => {
  const isFirstRender = !container.vNode;

  // 首次渲染
  if (isFirstRender) {
    mount(vNode, container);
  } else {
    // 更新操做
    patch(container.vNode, vNode, container);
  }

  // 保存起來, 用來區分是否爲首次渲染
  container.vNode = vNode;
};

export default render;
複製代碼

完整代碼:

codejava

相關文章
相關標籤/搜索