一步一步手动实现虚拟 DOM 和 diff 算法(上)|8月更文挑战

437 阅读5分钟

前言

做过 web 的,都知道在 web 开发通常都是简单用户交互,根据用户对页面操作,向远程服务器发起请求获取数据,然后将数据进行解析后渲染到界面上呈现给用户。所以 web 前端大部分工作就是更新 DOM,移除 DOM 或者是添加 DOM 对 DOM 进行操作。

早些年的 JQuery 之所以流行,提供了方便 API 便于我们在 DOM tree 上自由移动,来操作 DOM。随着 AngularJS 出现打破一切,提出数据绑定,通过单向绑定或者双向绑定,为数据和界面建立一个桥梁,让前端开发从 MVC 到了 MVVM 时代,数据驱动开发,这些好的 idea 随后在 react 和 vue 所保留,然后 react 这个 UI js 库第一次提出了虚拟 DOM,那么什么是虚拟 DOM 呢?

什么是虚拟 DOM

Virtual dom, 也就是虚拟 DOM 节点。通过 JS 的普通 Object 对象模拟 DOM 中的节点,然后再通过特定的 render 方法将其渲染成真实的 DOM 节点。

虚拟DOM 的好处是什么

虚拟 DOM 的出现解决了什么样问题

在浏览器中 DOM 引擎、JS 引擎是相互独立的,但又工作在同一个主线程上。当 JS 调用 DOM API 则会挂起 JS 引擎、转换传入参数数据、激活 DOM 引擎,DOM 重绘后再转换可能有的返回值,最后将主线程交还给 JS 引擎。对于频繁的 DOM API 调用,因为浏览器厂商不做“批量处理”的优化,重新计算布局、重新绘制图像会引起更大的性能消耗,这也是虚拟 DOM 出现原因

虚拟DOM 与真实 DOM 的区别

虚拟 DOM 不会立马进行排版与重绘操作,虚拟 DOM 进行频繁修改,然后一次性比较并修改真实 DOM 中需要改的部分,最后在真实 DOM 中进行排版与重绘,减少过多DOM节点排版与重绘损耗。虚拟 DOM 有效降低对 DOM 的重绘与排版,因为最终与真实 DOM 比较差异,可以只渲染局部

Virtual Dom有patch算法,根据新旧vnode比较经过优化查找到不同的节点修补、更新。不会暴力的直接覆盖DOM。

实现

  • 实现创建虚拟 DOM
  • 渲染虚拟DOM
  • 将虚拟 DOM 绑定到页面
  • 实现 diff 算法
  • 实现 patch 算

搭建环境

  • 创建项目 vdom
  • 初始化项目 npm init -y
  • 安装项目构建工具依赖 npm install parcel-bundler (可选) 也可以直接用 live-server 工具启动一个服务
  • 在目录下创建 src 文件夹,里面用于放置项目文件,然后创建 index.html 和 main.js, 在 index.html 引用一下 main.js
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app"></div>
    <script src="main.js"></script>
</body>
</html>

在 main.js 创建一个虚拟 DOM 对象,就是一个普通的 javascript 对象。

const vApp = {
  tagName: 'div',
  attrs: {
    id: 'app',
  },
};

console.log(vApp);
  • 配置项目 json 文件,添加一个脚步,用于运行启动项目
{
  ...
  "scripts": {
    "dev": "parcel src/index.html", 
  }
  ...
}
  • 启动项目
npm run dev

实现 createElement 方法

src/vdom/createElement.js
export default (tagName, { attrs = {}, children = [] } = {}) => {
  return {
    tagName,
    attrs,
    children,
  };
};

现在我们创建虚拟 DOM 工作交个createElement去做,

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
});

console.log(vApp);
import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    createElement('div', {
      attrs: {
        id:"container"
      },
    }),
  ],
});

console.log(vApp);

字面形式创建 { a: 3 } 对象,自动会继承 Object,这意味着创建的对象将拥有 Object.prototype 中定义的方法,如 hasOwnProperty、toString 等。我们希望创建用于表示虚拟 DOM javascript 对象更加"纯粹 "一点,所以才有了下面的代码,

通过 Object.create(null) 创建一个普通对象,就不会不继承于Object,而是继承于null。

export default (tagName, { attrs, children }) => {
  const vElem = Object.create(null);

  Object.assign(vElem, {
    tagName,
    attrs,
    children,
  });

  return vElem;
};

也可以将虚拟DOM 用 JSX 语法来写,是一个 JavaScript 的语法扩展。JSX 可以很好地描述 UI,也就是写起来更加直观,JSX 可能会使人联想到模板语言,但 JSX 具有 JavaScript 的全部功能。

const element = (
	<div id="app">
		<div id="container"></div>
	</div>
);

写一个解析器,编译是将 JSX 的 <div id="id"></> 成 `createElement("div",{attrs:"id"})

实现 render 方法

render 方法是用来渲染虚拟元素。已经定义好了生成虚拟 DOM 的函数。接下来实现一个方法一将创建好的虚拟 DOM 转换为真实的 DOM。定义render (vNode),方法接收虚拟节点作为参数并返回相应的 DOM。

const render = (vNode) => {
  // 例如创建好的 element
  //   e.g. <div></div>
  const $el = document.createElement(vNode.tagName);

  // 将虚拟 DOM 的 attrs 添加到元素上
  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(vNode.attrs)) {
    $el.setAttribute(k, v);
  }

  // 添加子节点
  //   e.g. <div id="app"><div></div>
  for (const child of vNode.children) {
    $el.appendChild(render(child));
  }

  return $el;
};

export default render;

上面的代码应该比较清晰,就不做过多解释了

ElementNode和TextNode

在真实的 DOM 中,共有有 8 种类型的节点,今天我们仅考虑其中两种类型。ElementNode,如

和文本结点 TextNode,普通文本。的虚拟元素结构,{ tagName, attrs, children },为了兼容 TextNode 我们还需要在 render 方法做一些额外工作。

import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world', // 表示 TextNode
    createElement('div', {
      attrs: {
        id:"container"
      },
    }),  // represents ElementNode
  ],
}); // represents ElementNode

console.log(vApp);

重新定义 render(vNode)。首先需要判断 vNode 是否是字符串。如果是的字符串,则使用 document.createTextNode(string) 来渲染 textNode,否则调用renderElem(vNode)

const renderElem = ({ tagName, attrs, children}) => {
  //   e.g. <div></div>
  const $el = document.createElement(tagName);

  //   e.g. <div id="app"></div>
  for (const [k, v] of Object.entries(attrs)) {
    $el.setAttribute(k, v);
  }

  //   e.g. <div id="app"><img></div>
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
};

const render = (vNode) => {
  if (typeof vNode === 'string') {
    return document.createTextNode(vNode);
  }
  return renderElem(vNode);
};

export default render;
import createElement from './vdom/createElement';

const vApp = createElement('div', {
  attrs: {
    id: 'app',
  },
  children: [
    'Hello world', // 表示 TextNode
    createElement('div', {
      attrs: {
        id:"container"
      },
    }),  // represents ElementNode
  ],
}); // represents ElementNode

const $app = render(vApp);
console.log($app);

开始渲染

<div id="app">
  Hello world
  <div id="container"></div>
</div>