• 2024年虚拟DOM技术将何去何从?
  • 发布于 2个月前
  • 110 热度
    0 评论
Web开发的起源与jQuery的统治
在Web开发的早期阶段,操作DOM元素主要依赖命令式编程。当时,jQuery因其易用性而广受欢迎。使用jQuery,开发者通过具体的命令操作DOM,比如:
// jQuery 示例
$("ol li").click(function() {});
let li = $("<li>我是一个列表项</li>");
$("ol").append(li);
这种方式虽然直观,但随着应用复杂度的提高,代码管理变得越来越困难。

声明式编程的兴起:React与JSX

2013年,Facebook的Jordan Walke提出了一个革命性的想法:将FaceBook在2010年开发的XHP功能迁移到JavaScript,借助JSX扩展形成新的编码风格。与命令式不同,声明式编程不再关注如何操作DOM,而是描述希望DOM是什么样子。例如:
// React组件示例
const Component = (
  <ul>
    {data.map(item => <MyItem data={item} />)}
  </ul>
);
声明式编程使得组件化开发成为可能,极大地提高了代码的可维护性和可扩展性。

虚拟DOM的出现
React的一个关键概念是虚拟DOM(Virtual DOM)。虚拟DOM是代码与实际DOM操作之间的中间层。这个概念允许代码先修改虚拟DOM树,然后再映射到真实的DOM树上。

虚拟DOM的优点包括:
1.促进函数式UI编程:抽象组件,简化代码维护。
2.跨平台能力:虚拟DOM本质上是JavaScript对象,可用于小程序、iOS、Android等应用的抽象层。
3.数据绑定减少DOM操作:通过合并多个DOM操作为一次操作,减少浏览器重排。例如:
// 利用DocumentFragment优化DOM操作
const fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
  const div = document.createElement('div');
  fragment.appendChild(div);
}
const container = document.getElementById('container');
container.appendChild(fragment);
轻量级JavaScript操作进行DOM差异化(Diffing):避免过度查询和存储实际DOM,提高性能。
通过DOM Diff减少不必要的操作:减少页面重排和重绘。
缓存DOM和保存节点状态:优化DOM更新。
通过这些技术的发展和应用,Web前端开发从繁琐的命令式操作转变为更加高效、可维护的声明式编程。虚拟DOM的概念不仅为React所采纳,也被许多其他框架所使用,从而推动了整个前端生态的发展。

虚拟DOM的现状及其争议
虚拟DOM的挑战
虽然虚拟DOM曾是前端开发的一大创新,但随着技术的发展,一些框架开始质疑并摒弃虚拟DOM。这背后的原因主要包括:
首次渲染性能:虚拟DOM在首次渲染大量DOM时,由于额外的计算开销,可能会比直接使用innerHTML插入慢。
内存占用:维护一份虚拟DOM树的内存副本。
频繁更新的开销:在频繁更新时,虚拟DOM需要更多时间进行计算工作。
大型项目的性能成本:即使现代框架进行了优化,比较和计算虚拟DOM的成本依然存在,特别是在构建虚拟DOM树时。

不同框架的应对策略
Uber:例如Uber,通过广泛且手动使用shouldComponentUpdate来最小化渲染调用。
React Fiber:React 16引入了React Fiber。通过优先级分割的中断机制,改善了因虚拟DOM树深度遍历造成的主进程阻塞问题。
Vue 3:Vue的创始人尤雨溪在“Vue 3的设计”中提到,他致力于寻找突破虚拟DOM的性能瓶颈。
Svelte:Svelte的作者Rich Harris提出了“虚拟DOM纯属开销”的观点,强调在某些情况或频繁更新下,虚拟DOM数据驱动模型带来的不必要开销。

2024年的虚拟DOM:还需要吗?
当前非虚拟DOM框架的主力:Svelte
虚拟DOM的现状
目前,虚拟DOM仍然是主流框架中的主导技术。React持续在迭代中探索更合理的调度模式,而Vue3专注于优化虚拟DOM的diff算法。ivi和Inferno在虚拟DOM框架的性能前沿领先。尽管虚拟DOM在主流框架中仍占主导地位,但像Svelte和Solidjs这样的非虚拟DOM框架开始将它们的新模式引入公众视野。

Svelte的创新
Rich Harris,Svelte和rollup的作者,将他在代码打包策略上的专长带入了JavaScript框架领域。
理念:“最好的API是根本没有API” —— Rich Harris
Svelte3:Svelte3经过重大改变,成为一个更轻量级、语法更简洁、代码量更少的JavaScript框架,用于实现响应性。
Svelte在编译阶段,直接将声明式代码转换为更高效的命令式代码,并减少运行时代码。例如:
<!-- 堆代码 duidaima.com -->
<script>
 let count = 0;

 function handleClick() {
  count += 1;
 }

 $: {
  console.log(`当前计数为 ${count}`);
 }
</script>

<div class="x-three-year" on:click={handleClick}>
  <div class="no-open" style={{ color: 'blue' }}>{`计数: ${count}`}</div>
</div>
在这个示例中,我们通过基本声明获得了一个响应性变量。然后,通过将其绑定到点击事件,我们得到了一个通过点击驱动视图数据的普通组件。
编译后,Svelte会自动标记响应式数据,例如:
function instance($$self, $$props, $$invalidate) {
 let count = 0;

 function handleClick() {
  $$invalidate(0, count += 1);
 }

 $$self.$$.update = () => {
  if ($$self.$$.dirty & /*count*/ 1) {
   $: {
    console.log(`当前计数为 ${count}`);
   }
  }
 };

 return [count, handleClick];
}
Svelte的优势
Svelte的主要优势在于:
编译时优化:它在构建时而不是运行时处理组件逻辑,将声明式代码编译为高效的命令式代码,从而减少了运行时的开销。
更少的代码:由于编译时优化,Svelte能够生成更少的代码来实现相同的功能。
无需虚拟DOM:Svelte避免了虚拟DOM的使用,直接在编译时将组件转换为优化的JavaScript代码,这减少了运行时的性能开销。

总结来说,Svelte代表了一种新的前端开发范式,通过编译时优化和减少代码量,提供了一种高效、简洁的开发体验。这对于追求性能和简洁性的开发者来说,是一个有吸引力的选择。

Vue的蒸汽模式(Vapor Mode)
概述
Vue3引入了一种新的编译优化策略,称为“蒸汽模式”(Vapor Mode),这是对Svelte预编译概念的响应。蒸汽模式利用编译时信息优化虚拟DOM。这种模式主要体现在编译阶段为一些静态节点附加编译信息,从而在遍历虚拟DOM树时减少不必要的开销,并在一定程度上优化了虚拟DOM带来的问题。

优化的关键点
静态节点优化:在编译阶段,Vue能够识别出模板中的静态节点,并为它们添加特定的编译信息。这意味着在组件更新时,Vue可以跳过这些静态节点的重新渲染,因为它们不会改变。

减少运行时开销:通过在编译时就处理一部分工作,Vue减少了虚拟DOM在运行时的负担。这使得组件在更新时更快,尤其是在处理大型或复杂的DOM结构时。

性能和体积方面的考虑
性能提升:蒸汽模式通过优化静态节点的处理,提高了整体渲染性能。
体积轻量化:这种模式不仅关注性能,也注重于减轻最终打包文件的体积。通过在编译时进行优化,Vue能够生成更加精简的代码。

对前端框架未来的探索
Vue通过引入蒸汽模式,展示了前端框架未来的一个可能趋势:更多地依赖编译时优化,而不仅仅是运行时的动态处理。这种方法不仅提高了性能,还降低了框架的体积,为开发者提供了更高效的开发体验。此外,这也表明了前端技术的不断进步,以及框架开发者对于提高Web应用性能和用户体验的持续追求。

总的来说,Vue的蒸汽模式是对传统虚拟DOM概念的一种重要补充和优化,它在保持虚拟DOM带来的好处的同时,减少了其带来的一些性能开销。这种模式对于那些追求高性能且关注应用大小的项目尤为有益。

Solidjs:一种基于编译的响应式系统
1、Solidjs概述
Solidjs(或称为Solid)是一个类似于Svelte的现代前端框架。它们都基于编译的响应式系统,但在响应性的实现方式上有所不同。Solidjs通过数据驱动的发布-订阅模式来实现细粒度的响应。它因其卓越的性能而闻名,甚至在js-framework-benchmark中排名第一。与此同时,Solidjs的语法更接近于React,对于习惯使用React的开发者而言更为友好。

2、编译阶段的转换
在Solidjs的官方playground中,我们可以看到框架在编译阶段将JSX转换为HTML的输出结果。这一过程体现了Solidjs如何将声明式的代码编译为能够直接操作DOM的命令式代码,从而提高运行时性能。

3、“真正的响应式”
Solidjs在其官网上被标榜为“真正的响应式”。这种响应式并非指React中的虚拟DOM基于状态变化进行修改和重新渲染,而是指Solidjs和Svelte在数据层面上具有更细粒度的响应。相比之下,React是在组件层面上进行响应的。

4、Solidjs的“细粒度响应”设计与实现
Solidjs的“细粒度响应”是指它能够精确地跟踪和响应每个独立的状态变化,而不是整个组件树的变化。这种方法减少了不必要的组件更新和重新渲染,从而提高了性能。

例如,在Solidjs中,当一个状态值改变时,只有依赖于这个状态的部分会重新计算和渲染,而不会影响其他不相关的组件或状态。这种方式使得Solidjs在处理大型应用或复杂交互时具有更高的效率和更好的性能。

5、createSignal 详解
在Solidjs中,createSignal 是实现状态管理和响应式更新的核心。与一些文章误解的不同,Solidjs并不完全基于Proxy来实现响应式,而是依赖类似于Knockout的发布-订阅数据响应系统。createSignal 的关键在于两种角色:SignalState(信号状态)和Computation(计算)。
export function createSignal<T>(
  value?: T,
  options?: SignalOptions<T | undefined>
): Signal<T | undefined> {
  options = options ? Object.assign({}, signalOptions, options) : signalOptions;

  const s: SignalState<T | undefined> = {
    value,
    observers: null,
    observerSlots: null,
    comparator: options.equals || undefined
  };

  if ("_SOLID_DEV_" && !options.internal) {
    if (options.name) s.name = options.name;
    registerGraph(s);
  }

  const setter: Setter<T | undefined> = (value?: unknown) => {
    if (typeof value === "function") {
      if (Transition && Transition.running && Transition.sources.has(s)) value = value(s.tValue);
      else value = value(s.value);
    }
    return writeSignal(s, value);
  };

  return [readSignal.bind(s), setter];
}
SignalState 和 Computation 的角色
SignalState:主要存储在类型为SignalState的对象中。它包含当前值(value)、观察者数组(observers,类型为Computation)、观察者在数组中的位置(observerSlots)和比较器(comparator,用于比较变化,默认为浅比较)。
export interface SignalState<T> extends SourceMapValue {
  value: T;
  observers: Computation<any>[] | null;
  observerSlots: number[] | null;
  tValue?: T;
  comparator?: (prev: T, next: T) => boolean;
}
Computation:在全局作用域中,有一个Listener用来临时存储类型为Computation的观察者。在组件渲染(createRenderEffect)或调用createEffect时,通过updateComputation方法为全局Listener赋值,为后续的依赖跟踪打下基础。
let Listener: Computation<any> | null = null;
export interface Computation<Init, Next extends Init = Init> extends Owner {
  fn: EffectFunction<Init, Next>;
  state: ComputationState;
  tState?: ComputationState;
  sources: SignalState<Next>[] | null;
  sourceSlots: number[] | null;
  value?: Init;
  updatedAt: number | null;
  pure: boolean;
  user?: boolean;
  suspense?: SuspenseContextType;
}
function updateComputation(node: Computation<any>) {
  if (!node.fn) return;
  cleanNode(node);
  const owner = Owner,
    listener = Listener,
    time = ExecCount;
  Listener = Owner = node;
  runComputation(
    node,
    Transition && Transition.running && Transition.sources.has(node as Memo<any>)
      ? (node as Memo<any>).tValue
      : node.value,
    time
  );
//...
  Listener = listener;
  Owner = owner;
}
由于信号的读取,通过函数调用获取数据。
 <div class="no-open" style={{ color: 'blue' }}>{`count: ${count()}`}</div>
信号的读取和写入
读取信号:在任何地方读取SignalState时,都会调用readSignal函数。当前临时存储在全局上下文中的“观察者”Listener(指引用SignalState的地方)将被放入其观察者数组中,观察者源将指向当前信号,实现数据绑定。最后,返回相应的SignalState值。
export function readSignal(this: SignalState<any> | Memo<any>) {
  
  const runningTransition = Transition && Transition.running;
  if (
    (this as Memo<any>).sources &&
    (runningTransition ? (this as Memo<any>).tState : (this as Memo<any>).state)
  ) {
    if ((runningTransition ? (this as Memo<any>).tState : (this as Memo<any>).state) === STALE)
      updateComputation(this as Memo<any>);
    else {
      const updates = Updates;
      Updates = null;
      runUpdates(() => lookUpstream(this as Memo<any>), false);
      Updates = updates;
    }
  }
  
  if (Listener) {
    const sSlot = this.observers ? this.observers.length : 0;
    if (!Listener.sources) {
      Listener.sources = [this];
      Listener.sourceSlots = [sSlot];
    } else {
      Listener.sources.push(this);
      Listener.sourceSlots!.push(sSlot);
    }
    if (!this.observers) {
      this.observers = [Listener];
      this.observerSlots = [Listener.sources.length - 1];
    } else {
      this.observers.push(Listener);
      this.observerSlots!.push(Listener.sources.length - 1);
    }
  }
  if (runningTransition && Transition!.sources.has(this)) return this.tValue;
  return this.value;
}
写入信号:写入信号时,调用writeSignal函数。在闭包内更改当前SignalState后,遍历在readSignal阶段收集的观察者数组,并将观察者推入当前Effect执行列表。
export function writeSignal(node: SignalState<any> | Memo<any>, value: any, isComp?: boolean) {
  let current =
    Transition && Transition.running && Transition.sources.has(node) ? node.tValue : node.value;
  if (!node.comparator || !node.comparator(current, value)) {
    if (Transition) {
      const TransitionRunning = Transition.running;
      if (TransitionRunning || (!isComp && Transition.sources.has(node))) {
        Transition.sources.add(node);

        .tValue = value;
      }
      if (!TransitionRunning) node.value = value;
    } else node.value = value;
    if (node.observers && node.observers.length) {
      runUpdates(() => {
        for (let i = 0; i < node.observers!.length; i += 1) {
          const o = node.observers![i];
          const TransitionRunning = Transition && Transition.running;
          if (TransitionRunning && Transition!.disposed.has(o)) continue;
          if (TransitionRunning ? !o.tState : !o.state) {
            if (o.pure) Updates!.push(o);
            else Effects!.push(o);
            if ((o as Memo<any>).observers) markDownstream(o as Memo<any>);
          }
          if (!TransitionRunning) o.state = STALE;
          else o.tState = STALE;
        }
        if (Updates!.length > 10e5) {
          Updates = [];
          if ("_SOLID_DEV_") throw new Error("Potential Infinite Loop Detected.");
          throw new Error();
        }
      }, false);
    }
  }
  return value;
}
消息重新分发:此时,Effect列表保存了当时的观察者。然后遍历并执行runEffects来重新分发消息。在相应的节点(Computation)中,重新执行readSignal函数,此时可以获取最新的数据结果。

6、组件更新机制解析
在Solidjs中,组件的更新和createEffect类似,但组件的引用通过createRenderEffect和updateComputation来进行处理。
示例:App 组件
function App() {
    const [count, setCount] = createSignal(0);

    return (
        <div class="x-three-year" onClick={() => setCount((pre) => pre + 1)}>
            <div class="no-open">foobar</div>
            <div class="no-open">{count()}</div>
        </div>
    );
}
在这个例子中,我们有一个计数器,每次点击时count会增加。这是通过setCount函数实现的,它是createSignal的一部分。

点击事件触发更新过程
当点击事件发生时,会触发setCount,进而触发writeSignal的行为,如之前所述。这会导致updateComputation被触发,接着进行readSignal以获取SignalState。整个调用栈过程如下:

7、Solid中需注意的几点
属性的解构和合并
在Solid中,有一些特别需要注意的地方,特别是关于属性(props)的处理:
不能直接解构和合并响应式属性:不能直接使用剩余(rest)和展开(spread)语法来解构和合并响应式属性数据。这是因为通过浅拷贝形式的解构(同样Object.assign方法也不可用)进行的拷贝会割断信号的更新,导致其失去响应性并脱离跟踪范围。

解构会导致失去响应性:直接解构会导致解构出的值失去响应性,并从跟踪中分离。通常,在Solid的原语或JSX之外访问props对象上的属性可能导致失去响应性。此外,像展开操作符和Object.assign这样的函数也可能导致失去响应性。

示例
//no
function Other({count}) {
    return (
    <div>
        <div>{count}</div>
    </div>
    );
}

//yes
function Other(props) {
    return (
    <div>
        <div>{props.count}</div>
    </div>
    );
}

function App() {
    const [count, setCount] = createSignal(0);
    return (
        <div class="x-three-year" onClick={() => setCount((pre: any) => pre + 1)}>
            <div class="no-open">foobar</div>
            <div class="no-open">{count()}</div>
            <Other count={count()}></Other>
        </div>
    );
}
//yes
function Other({count}) {
    return (
        <div>
            <div>{count()}</div>
        </div>
    );
}

function App() {
    const [count, setCount] = createSignal(0);
    return (
        <div class="x-three-year" onClick={() => setCount((pre: any) => pre + 1)}>
            <div class="no-open">foobar</div>
            <div class="no-open">{count()}</div>
            <Other count={count}></Other>
        </div>
    );
}
同时,Solid官方也提供了像mergeProps和splitProps这样的API,用于子组件修改响应式props数据。内部它使用Proxy代理来实现动态跟踪。

依赖跟踪的同步性
Solid的依赖跟踪仅适用于同步跟踪。异步操作中的依赖跟踪问题:如果在createEffect中使用setTimeout来异步直接访问SignalState,将无法跟踪SignalState的更新,如下示例所示:
const [count, setCount] = createSignal(100);

createEffect(() => {
  setTimeout(() => {
    // 这种方式无法跟踪
    console.log('count', count());
  }, 100);
});
这是因为当readSignal函数在这个时候读取Listener时,基本过程已经完成,数据已被清除(Listener = null Owner = null),因此在读取时无法跟踪SignalState。

避免方法:然而,可以通过某些方法避免这种情况。
createEffect(() => {
  const tempCount = count();
  setTimeout(() => {
    console.log('count', tempCount;
  }, 100);
});

前端框架比较
npm下载次数查询网站

目前,State of js 只有 2022 年的数据(仅供参考),但从数据中可以看出,React、Vue、Angular 在使用量上仍然占据主导地位。但从满意度来看,已经出现了两大非虚拟DOM主力。

Svelte和Solid:超越虚拟DOM的前端框架
Svelte和Solid的崛起不仅标志着虚拟DOM的淡出,更多的编译时任务的加入,也展示了开发的新可能性。这两个框架都不使用虚拟DOM,但提供了高效的更新机制和优化的编译过程。

性能比较
根据最新的js-framework-benchmark(Chrome 119 — OSX)数据,Svelte和Solid在性能上相似。在DOM操作时间方面,Solid似乎表现更佳,而Svelte在内存使用和启动时间方面有更好的数据。

与其他框架的对比
这边我提取了 js-framework-benchmark (Chrome 119 — OSX) 的公开状态,选取了 ivi、Inferno、Solid、Svelte、Vue、React 进行整体对比。从结果来看,Svelte 和 Solid 的表现略好于大家熟知的 Vue 和 React。但相比像 ivi、Inferno 这样以性能着称的虚拟 DOM 框架,并没有什么优势。

国外大佬 Ryan Carniato在他的研究《DOM渲染的最快方法》一文中,使用标签模板和HyperScript作为Solid的渲染模板,并将其与其他在js-framework-benchmark上表现良好的JavaScript框架进行了比较。结果显示,虚拟DOM框架和非虚拟DOM框架的性能相似,特别是一些高性能的虚拟DOM框架。

最终结果表明,虚拟DOM框架和非虚拟DOM框架具有相似的性能(严格来说,这是针对一些高性能的虚拟DOM框架)。因此,没有最好的技术。在历史上不断修改和优化的过程中,虚拟 DOM 的速度并不慢。不断探索是对技术最大的尊重。

结束
在2024年的前端框架领域,我们看到了多样化的技术选择和不断的创新。每种技术都有其适用的场景和优势,开发者应根据项目的具体需求和上下文来选择最适合的框架。虚拟DOM的主导地位表明它在许多情况下仍然是一个有效的选择,但Svelte和Solid等新兴框架的出现也为开发者提供了更多的选择和可能性。在前端开发的世界里,不断的学习和适应新技术是每个开发者的必经之路。
用户评论