常见笔试题:通过 VDOM 结构,实现简单版 render 函数

57 阅读2分钟

前言

在现在主流框架中,大多都有 visual dom 的实现,这里通过常见的笔试题来实现个最简单的 render 函数。

开始实现简版

// dom 结构 ul>li*2
const root = {
  tagName: 'ul',
  props: {
    className: 'list',
  },
  children: [
    {
      tagName: 'li',
      children: ['A'],
    },
    {
      tagName: 'li',
      children: ['B'],
    }
  ]
};

这个编程题一是考察一下树结构的遍历,其实同时也会考察怎么更高效点渲染一个 dom tree。如果是频繁的进行 dom 的变更,性能肯定是最差的,所以这里实现 2 种:

  1. 基于字符串拼接,其实字符串拼接经过浏览器的优化,是很高效的结构,就是阅读性会差一些
  2. 基于 Fragment 的结构来实现,类似以前 jQuery 中缓存 dom 结构最后统一 appendChild 的形式
// 缓存下 document 的访问链,这里默认浏览器环境
const doc = document;

// 实现 1,通过字符串去拼接
function renderString(el, root) {
  let html = '';

  iter(root);

  function iter(node) {
    if (node === null) {
      html += `<span></span>`;
      return;
    }

    const { props = {}, children = [], tagName } = node;

    html += `<${tagName}`;
    for (const prop in props) {
      const attr = prop === 'className' ? 'class' : prop;
      html += ` ${attr}="${props[prop]}"`;
    }
    html += '>';

    for (const child of children) {
      if (typeof child === 'string') {
        html += child;
      } else {
        iter(child);
      }
    }
    html += `</${tagName}>`;
  }

  const tmp = document.createElement('div');
  tmp.innerHTML = html;

  el.appendChild(tmp);
}

// 实现 2,通过 Fragment 去拼接
function renderFragment(el, root) {
  const fragment = doc.createDocumentFragment();

  iter(root);

  el.appendChild(fragment);

  function iter(node) {
    // 如果 node 为空,也认为是一个可识别的节点
    if (node === null) {
      // 空节点,默认可以是空串,也可以是 span
      const emptyNode = doc.createElement('span');
      fragment.appendChild(emptyNode);
      return emptyNode;
    }

    const element = doc.createElement(node.tagName);
    fragment.appendChild(element);

    // props
    const props = node.props ?? {};
    for (let prop in props) {
      // 这里暂时不考虑更多 attr, prop 的区别
      const attr = prop === 'className' ? 'class' : prop;
      element.setAttribute(attr, props[prop]);
    }

    // children
    const children = node.children || [];
    for (let child of children) {
      if (typeof child === 'string') {
        const textElement = doc.createTextNode(child);
        element.appendChild(textElement);
      } else {
        element.appendChild(iter(child));
      }
    }

    return element;
  }
}

function render(el, root, options = {}) {
  // platform 可以预留处理不同平台的渲染模式,不过耦合到 render 这个时候不是特别好,可以参考 JVM 的思路,直接从平台做大的实现切分会好一些
  // mode 默认 string 类型,此类型兼容性是最好的
  const { platform = 'web', mode = 'string' } = options;

  if (mode === 'string') {
    return renderString(el, root);
  }

  if (mode === 'fragment') {
    return renderFragment(el, root);
  }
}

render(document.body, root, { mode: 'string' });
render(document.body, root, { mode: 'fragment' });

这里其实可以看到,string 模式新增了一层 div 结构来保证代码的便利性,其实这也能间接反想一下,为什么以前框架(react/vue)第一版本在写组件的时候,需要我们必须包裹一个元素。

还可以思考,如果是一个较完整的框架实现,是否增加 Root 节点和子节点,会进行分开初始化呢?

React 的 Fiber 版本和非 Fiber 版本的 render 流程有什么不同吗?