前端框架渲染DOM的的方式你知道多少?

文章首发于公众号 code进化论,欢迎关注。

前言

近些年来,前端已经涌现出数十个框架,例如 Vue、React、SoildJS、Svelte、lit等,虽然框架层出不穷但是他们的渲染 DOM 的原理却几乎没有变化,我总结了以下三种渲染 DOM 的方式:

  • Dirty Checking(脏检查,典型代表有 Angular、lit
  • Virtual DOM(虚拟DOM,典型代表有Vue、React)
  • Fine-Grained(细力度更新,典型代表有SoildJS)

对于开发者来说理解框架的 DOM 渲染机制就能轻松理解框架的整体工作原理,下面将会通过手动实现这三种 DOM 渲染方式来理解其工作原理。

什么是模板(Template)?

在 JavaScript 前端框架中都会有模板(template) 这个概念,模板 是用来描述 UI 结构的声明性语法。它就像是"组件的蓝图",其中包含了静态 HTML 和动态绑定数据的"占位符"(即 holes)。框架通过模板生成最终的 DOM 内容,实现数据和视图的同步,而开发者只需要关注数据和数据的变化。

JavaScript 框架的模板语法经历了一个漫长的演进过程。最早的模板方式就是通过字符串拼接或模板替换将数据嵌入 HTML,例如早期的模板引擎 Handlebars 和 EJS(Embedded JavaScript Templates):

jsx 复制代码
// EJS 示例
<h1><%= title %></h1>
<% if (items.length > 0) { %>
  <ul>
    <% items.forEach(function(item) { %>
      <li><%= item %></li>
    <% }); %>
  </ul>
<% } %>

这种方式最大的局限在于如何高效的进行字符串的拼接,然后逐步发展出更复杂、更高效的模板引擎和响应式编译机制,例如在 Vue 中,其模板语法如下:

jsx 复制代码
<template>
  <div class="card">
    <h1>Welcome</h1> // 静态
    <p>i am {{ name }}</p> // 动态
  </div>
</template>

<script>
	export default {
	  data() {
	    return { name: 'Vue' }
	  }
	}
</script>

在 React 中,其 JSX 模板语法如下:

jsx 复制代码
function App() {
  const [name, setName] = useState('react');
  return (
    <div>
	    <h1>Welcome</h1> // 静态
      <p>Hello, {name}</p> // 动态
    </div>
  );
}

虽然不同框架其模板语法会有所差异,但其结构还是一致,即由静态部分和动态部分组成,最终交由渲染引擎进行渲染,因此我们所要关注的是如何将这些模板数据渲染出来。

DOM渲染

Render by Replacement(替换渲染)

替换渲染最典型的例子就是模板引擎 EJS,它会将模板转换成 HTML 并通过 innerHTML 将拼接好的子字符串嵌入到页面中:

jsx 复制代码
<h1><%= title %></h1>
<% if (items.length > 0) { %>
  <ul>
    <% items.forEach(function(item) { %>
      <li><%= item %></li>
    <% }); %>
  </ul>
<% } %>

下面是一个完成的实例:

jsx 复制代码
import { Framework } from "./framework"

class App extends Framework {
  constructor() {
    super({ count: 0 });
    setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
  }
  template() {
    return `<div>${this.state.count}</div>`;
  }
}

在构造函数中通过定时器该修改状态,在 template 中读取状态并通过模板字符串的方式进行拼接,下面是其核心原理:

jsx 复制代码
export class Framework {
  state: Record<string, any> = {};
  constructor(initState: Record<string, any>) {
    this.state = initState;
    this._template();
  }
  template(): string {
    throw new Error("must be implemented");
  }
  setState(newState: Record<string, any>) {
    this.state = newState;
    this._template();
  }
  _template() {
    document.getElementById("app")!.innerHTML = this.template();
  }
}

更新状态后会再次调用 template 获取返回的模板字符串,在通过 innerHTML 将内容嵌入到页面,可在线调试

虽然这种更新方式有效,但是其性能表现上是非常的糟糕的,通过 innerHTML 更新 DOM 意味着他会先卸载父节点下面的所有子节点,再将新的子节点添加进去,如果模板内容越多,那么每次更新状态后卸载和创建的 DOM 元素就越多,这并不高效。

那我们该怎么避免 DOM 的反复创建和卸载呢?有些同学可能就想到了 diff,我们可以检查 DOM 元素是否有值并和新的值对比,如果变了我们再更新它。 但事实证明,这种方式也可能导致性能问题,因为 DOM 上有很多属性一旦读取它们,就会触发重绘(paint)或布局计算(layout),例如 offsetWidth、clientHeight、scrollTop等,因为这些属性在读取时浏览器需要保证获取到的是最新的。

所以我们也能得出了一个结论,DOM 操作本身不慢,因为所有框架最终都得操作 DOM,慢的原因是不正当的访问。那如果我们既不想重建 DOM,又不想频繁读取 DOM,那我们该怎么办?

Dirty Checking(脏检查)

Dirty Checking 不是一种响应式的更新机制,而是显示检测变化的机制,每次状态发生变化之后,需要遍历所有动态绑定,逐个比较旧值与新值,判断哪些需要更新。

下面是一个基于 lit 的完整示例:

jsx 复制代码
import { html, Framework } from "./framework";

class App extends Framework {
  constructor() {
    super({ count: 0 });
    setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
  }
  template() {
	  // 使用html函数来处理模板数据,收集动态绑定数据
    return html`<div>${this.state.count}</div>`;
  }
}
jsx 复制代码
export class Framework {
  state: Record<string, any> = {};
  constructor(initState: Record<string, any>) {
    this.state = initState;
    document.getElementById("app")!.append(this.template());
  }
  template(): Node {
    throw new Error("must be implemented");
  }
  setState(newState: Record<string, any>) {
    this.state = newState;
    this.template();
  }
}

type Binding = { type: string; ref: Node; value: any };
/** 用来缓存每个DOM节点及其绑定的动态数据信息 */
const cache = new Map<
  TemplateStringsArray,
  {
    node: Node;
    bindings: Binding[];
  }
>();

export function html(template: TemplateStringsArray, ...holes: any[]) {
  let prev;
  // 如果是首次渲染,需要缓存模板及其节点信息,用于下次更新时做比对
  if (!(prev = cache.get(template))) {
    const t = document.createElement("template");
    /**
	    template.join("<!>")的意思是使用注释节点来占位动态绑定的数据,例如例子中传进来的
	    template是['<div>', '</div>'],转换后变成<template><div><!----></div></template>
	    便于后续更新
	  */
    t.innerHTML = template.join("<!>");
    cache.set(
      template,
      (prev = {
        node: t.content.firstChild!, // node是DOM节点
        bindings: createBindings(t.content.firstChild!), // bindings是当前dom节点的信息描述,其中包括绑定的动态数据
      }),
    );
  }
  // 遍历所有的动态绑定数据,查看是否发生变化
  diff(holes, prev.bindings);
  return prev.node;
}

// 获取节点的所有动态绑定数据
function createBindings(element: Node) {
  const bindings = [];
  let tw = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT, null),
    comment;
  /** 遍历所有的注释节点,首次渲染时value默认设置为undefined */
  while ((comment = tw.nextNode())) {
    bindings.push({ type: "insert", ref: comment, value: undefined });
  }

  return bindings;
}

function diff(holes: any[], bindings: Binding[]) {
  // 遍历所有的动态绑定数据,查看是否发生变化
  for (let i = 0; i < holes.length; i++) {
    const binding = bindings[i];
    // 比较新旧值
    if (holes[i] !== binding.value) {
      if (binding.type === "insert") {
	      // 如果是首次渲染,则插入新的文本节点
        if (binding.value == null) {
          binding.ref.parentNode!.insertBefore(
            document.createTextNode(holes[i]),
            binding.ref,
          );
        } else {
		       // 如果是更新状态,则直接更新节点的值
	         binding.ref.previousSibling!.nodeValue = holes[i]
        };
      }
      // other cases;
      binding.value = holes[i];
    }
  }
}

在这个例子中我们用了注释节点来作为占位符,描述动态绑定的位置,便于后续比对更新。

在 Dirty Checking 模式下,首先会收集 Template 中的所有动态绑定的状态,当下次发生状态更新时会去遍历所有的动态绑定的状态,去和新的状态比对,如果发生变化则更新 DOM,可在线调试

Virtual DOM(虚拟DOM)

虚拟 DOM(Virtual DOM)本质上是 JS 和 DOM 之间的一个映射缓存,它在形态上表现为一个能够描述 DOM 结构及其属性信息的 JS 对象。如下图所示:

当状态更新时,框架底层会借助算法先对比出具体哪些真实 DOM 需要被改变,然后再将这些改变作用于真实 DOM。下面是一个简单的例子:

jsx 复制代码
import { h, Framework } from "./framework"

class App extends Framework {
  constructor() {
    super({ count: 0 });
    setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
  }
  template() {
    return h("div", this.state.count);
  }
}

这里的 template 参考了 React,例如下面 React 的 jsx 语法:

jsx 复制代码
<div className='class' title='title'>
  <p>xiling</p>
</div>

编译之后如下(在React17 之后已经用了新的 JSX 转换方式):

jsx 复制代码
React.createElement("div", {
  className: "class",
  title: "title"
},React.createElement("p", null, "xiling"));

Framework 类的实现如下,核心功能就是 h 和 diff 方法,一个是虚拟 DOM 的构造方法,一个是常说的 dif 算法。

jsx 复制代码
type VNode = {
  type: string;
  attrs?: Record<string, string>;
  children?: VNode[];
  value?: string;
  _el?: Node;
};

export class Framework {
  state: Record<string, any> = {};
  _node: VNode | undefined;
  constructor(initState: Record<string, any>) {
    this.state = initState;
    this._diff(this.template());
  }
  template(): VNode {
    throw new Error("must be implemented");
  }
  setState(newState: Record<string, any>) {
    this.state = newState;
    this._diff(this.template());
  }
  _diff(newNode: VNode) {
    diff(this._node, newNode, document.getElementById("app")!);
    this._node = newNode;
  }
}

export function h(...args: any[]) {
  let node: VNode | null = null;
  function item(value: any) {
    if (value == null) {
    } else if ("string" === typeof value) {
	    /** value类型为字符串要么是 DOM 节点,要么是 DOM 节点的文本内容,需要作为其子节点 */
      if (!node) node = { type: value, attrs: {}, children: [] };
      else node.children!.push({ type: "#text", value });
    } else if (
      "number" === typeof value ||
      "boolean" === typeof value ||
      value instanceof Date
    ) {
	    /** 将数字或者日期类型的数据作为dom节点的子节点处理 */
      node!.children!.push({ type: "#text", value: value.toString() });
    } else if (Array.isArray(value)) value.forEach(item);
    else if ("object" === typeof value) {
	    /** 对于object类型的值,可能是子dom节点,可能是dom节点的属性 */
      if (value.type) {
        node!.children!.push(value);
      } else {
        // attributes
        for (var k in value) {
          node!.attrs![k] = value[k];
        }
      }
    }
  }
  while (args.length) item(args.shift());
  return node!;
}

/** 查找变更点并更新真实DOM */
function diff(node: VNode | undefined, newNode: VNode, root: Node) {
  let element;
  // diff element
  if (!node || node.type !== newNode.type) {
    if (node && node._el) (node._el as Element).remove();
    element =
      newNode.type === "#text"
        ? document.createTextNode(newNode.value!)
        : document.createElement(newNode.type);
    (root as Element).append(element);
  } else element = node._el!;
  newNode._el = element;
  if (newNode.type === "#text") {
    element.textContent = newNode.value!;
    return;
  }

  // diff attributes
  for (let k in newNode.attrs) {
    if (node?.attrs![k] !== newNode.attrs[k]) {
      (element as Element).setAttribute(k, newNode.attrs[k]);
    }
  }
  // diff children
  if (newNode.children) {
    for (let i = 0; i < newNode.children.length; i++) {
      diff(node?.children?.[i], newNode.children[i], element);
    }
  }
}

在虚拟 DOM 的模式下,首先会将模板内容转换成虚拟 DOM,当下次状态发生变化时会将新旧虚拟 DOM 进行对比,通过算法找到最小的更新集,最终更新真实 DOM,可在线调试

Fine-Grained(细力度更新)

Fine-Grained(细粒度) 更新策略是指在状态更新时,不对整个组件进行整体重新渲染,例如像 React 这种基于虚拟 DOM 的方案,当状态发生变化时需要重新执行 template 方法获取新的虚拟 DOM,而 Fine-Grained 是只精确更新那些真正发生变化的部分。这种策略提高了性能,减少了不必要的计算和重绘,这才实现了真正意义上的响应式。

下面是根据 soildjs 实现的一个简单的例子,我们可以先看一下 soildjs 的实现:

soildjs 官方提供了一个 playground 支持开发者在线调试 soildjs 代码,并在右侧的 output 中可以直接查看编译后的结果。

从编译结果中可以看出,soildjs 组件返回的 jsx 会被提取到组件外,并调用 template 函数返回对应的 DOM 节点,在函数内部通过 insert 将动态数据插入到节点中,从而建立绑定关系。下面是对其简单的实现:

jsx 复制代码
import { createSignal, createEffect } from "./framework"

function App() {
  const [count, setCount] = createSignal(0);
  setInterval(() => setCount(count() + 1), 1000);
  
  const el = document.createElement("div");
  
  /** $insert方法内部的核心代码,当触发setCount操作后会执行该回调函数 */
  createEffect(() => {
    el.textContent = String(count());
  });
  return el;
}

document.getElementById("app")!.append(App());
jsx 复制代码
type Observer = () => void;

let Observer: Observer | null = null;

export function createSignal<T>(value: T): [() => T, (v: T) => void] {
  const observers = new Set<Observer>();
  return [
    () => {
      if (Observer) observers.add(Observer);
      return value;
    },
    (v: T) => {
      value = v;
      for (let o of observers) {
        o();
      }
    },
  ];
}

export function createEffect(fn: () => void) {
  function compute() {
    try {
      /** */
      Observer = compute;
      fn();
    } finally {
      Observer = null;
    }
  }
  return compute();
}j

createSignal 会创建一个 observers 集合,用来存储该变量的依赖者,当更新变量时通知所有的依赖者进行更新,对应到代码里就是 el.textContent = String(count()) 操作,从而实现细粒度的更新,可在线调试

总结

本篇文章主要介绍了当前前端框架中所使用的三种 DOM 渲染方式,显然每种 DOM 渲染方式都有其优势和劣势,像虚拟 DOM 一个最大的优势就是支持跨平台,而 Fine-Grained 在性能上表现突出,开发者最终选择哪种框架也需要结合自己的实际场景。

相关推荐
阿阳微客7 分钟前
Steam 搬砖项目深度拆解:从抵触到真香的转型之路
前端·笔记·学习·游戏
德育处主任Pro35 分钟前
『React』Fragment的用法及简写形式
前端·javascript·react.js
CodeBlossom1 小时前
javaweb -html -CSS
前端·javascript·html
CodeCraft Studio1 小时前
【案例分享】如何借助JS UI组件库DHTMLX Suite构建高效物联网IIoT平台
javascript·物联网·ui
打小就很皮...2 小时前
HBuilder 发行Android(apk包)全流程指南
前端·javascript·微信小程序
集成显卡3 小时前
PlayWright | 初识微软出品的 WEB 应用自动化测试框架
前端·chrome·测试工具·microsoft·自动化·edge浏览器
前端小趴菜053 小时前
React - 组件通信
前端·react.js·前端框架
Amy_cx4 小时前
在表单输入框按回车页面刷新的问题
前端·elementui
dancing9994 小时前
cocos3.X的oops框架oops-plugin-excel-to-json改进兼容多表单导出功能
前端·javascript·typescript·游戏程序
后海 0_o4 小时前
2025前端微服务 - 无界 的实战应用
前端·微服务·架构