次世代前端视图框架都在卷啥?

2023-10-20 15:53:41 浏览数 (1)

state of JavaScript 2022 满意度排名

上图是 State of JavaScript 2022 前端框架满意度排名。前三名分别是 SolidSvelteQwik。我们可以称他们为次世代前端框架的三大代表,前辈是 React/Angular/Vue。 目前 React/Augular/Vue 还占据的主流的市场地位, 现在我们还不知道下一个五年、十年谁会成为主流,有可能前辈会被后浪拍死在沙滩上, 也有可能你大爷还是你大爷。

就像编程语言一样,尽管每年都有新的语言诞生,但是撼动主流编程语言的地位谈何容易。在企业级项目中,我们的态度会趋于保守,选型会偏向稳定、可靠、生态完善的技术,因此留给新技术的生存空间并不多。除非是革命性的技术,或者有大厂支撑,否则这些技术或框架只会停留小众圈子内。

比如有一点革命性、又有大厂支撑的 Flutter。

那么从更高的角度看,这些次时代的前端视图框架在卷哪些方向呢?有哪些是革命性的呢?

先说一下本文的结论:

  • 整体上视图编程范式已经固化
  • 局部上体验上内卷

视图编程范式固化

从 JQuery 退出历史舞台,再到 React 等占据主流市场。视图的编程范式基本已经稳定下来,不管你在学习什么视图框架,我们接触的概念模型是趋同的,无非是实现的手段、开发体验上各有特色:

  • 数据驱动视图。数据是现代前端框架的核心,视图是数据的映射, View=f(State) 这个公式基本成立。
  • 声明式视图。相较于上一代的 jQuery,现代前端框架使用声明式描述视图的结构,即描述结果而不是描述过程。
  • 组件化视图。组件是现代前端框架的第一公民。组件涉及的概念无非是 props、slots、events、ref、Context…

局部体验内卷

回顾一下 4 年前写的 浅谈 React 性能优化的方向,现在看来依旧不过时,各大框架无非也是围绕着这些「方向」来改善。

当然,在「框架内卷」、「既要又要还要」时代,新的框架要脱颖而出并不容易,它既要服务好开发者(开发体验),又要服务好客户(用户体验) , 性能不再是我们选择框架的首要因素。

以下是笔者总结的,次世代视图框架的内卷方向:

  • 用户体验
    • 性能优化
      • 精细化渲染:这是次世代框架内卷的主要战场,它们的首要目的基本是实现低成本的精细化渲染
        • 预编译方案:代表有 Svelte、Solid
        • 响应式数据:代表有 Svelte、Solid、Vue、Signal(不是框架)
        • 动静分离
    • 并发(Concurrent):React 在这个方向独枳一树。
    • 去 JavaScript:为了获得更好的首屏体验,各大框架开始「抛弃」JavaScript,都在比拼谁能更快到达用户的眼前,并且是完整可交互的形态。
  • 开发体验
    • Typescript 友好:不支持 Typescript 基本就是 ca
    • 开发工具链/构建体验: Vite、Turbopack… 开发的工具链直接决定了开发体验
    • 开发者工具:框架少不了开发者工具,从 Vue Devtools 再到 Nuxt Devtools,酷炫的开发者工具未来可能都是标配
    • 元框架: 毛坯房不再流行,从前到后、大而全的元框架称为新欢,内卷时代我们只应该关注业务本身。代表有 Nextjs、Nuxtjs

精细化渲染

预编译方案

React、Vue 这些以 Virtual DOM 为主的渲染方式,通常只能做到组件级别的精细化渲染。而次世代的 Svelte、Solidjs 不约而同地抛弃了 Virtual DOM,采用静态编译的手段,将「声明式」的视图定义,转译为「命令式」的 DOM 操作

Svelte

代码语言:javascript复制
<script>
  let count = 0

  function handleClick() {
    count  = 1
  }
</script>

<button on:click="{handleClick}">Clicked {count} {count === 1 ? 'time' : 'times'}</button>

编译结果:

代码语言:javascript复制
// ....
function create_fragment(ctx) {
  let button
  let t0
  let t1
  let t2
  let t3_value = /*count*/ (ctx[0] === 1 ? 'time' : 'times')   ''
  let t3
  let mounted
  let dispose

  return {
    c() {
      button = element('button')
      t0 = text('Clicked ')
      t1 = text(/*count*/ ctx[0])
      t2 = space()
      t3 = text(t3_value)
    },
    m(target, anchor) {
      insert(target, button, anchor)
      append(button, t0)
      append(button, t1)
      append(button, t2)
      append(button, t3)

      if (!mounted) {
        dispose = listen(button, 'click', /*handleClick*/ ctx[1])
        mounted = true
      }
    },
    p(ctx, [dirty]) {
      if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0])
      if (
        dirty & /*count*/ 1 &&
        t3_value !== (t3_value = /*count*/ (ctx[0] === 1 ? 'time' : 'times')   '')
      )
        set_data(t3, t3_value)
    },
    i: noop,
    o: noop,
    d(detaching) {
      if (detaching) {
        detach(button)
      }

      mounted = false
      dispose()
    },
  }
}

function instance($$self, $$props, $$invalidate) {
  let count = 0

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

  return [count, handleClick]
}

class App extends SvelteComponent {
  constructor(options) {
    super()
    init(this, options, instance, create_fragment, safe_not_equal, {})
  }
}

export default App

我们看到,简洁的模板最终被转移成了底层 DOM 操作的命令序列。

我写文章比较喜欢比喻,这种场景让我想到,编程语言对内存的操作,DOM 就是浏览器里面的「内存」:

  • Virtual DOM 就是那些那些带 GC 的语言,使用运行时的方案来屏蔽 DOM 的操作细节,这个抽象是有代价的
  • 预编译方案则更像 Rust,没有引入运行时 GC, 使用了一套严格的所有权和对象生命周期管理机制,让编译器帮你转换出安全的内存操作代码。
  • 手动操作 DOM, 就像 C、C 这类底层语言,需要开发者手动管理内存

使用 Svelte/SolidJS 这些方案,可以做到修改某个数据,精细定位并修改 DOM 节点,犹如我们当年手动操作 DOM 这么精细。而 Virtual DOM 方案,只能到组件这一层级,除非你的组件粒度非常细。

响应式数据

和精细化渲染脱不开身的还有响应式数据

React 一直被诟病的一点是当某个组件的状态发生变化时,它会以该组件为根,重新渲染整个组件子树,如果要避免不必要的子组件的重渲染,需要开发者手动进行优化(比如 shouldComponentUpdatePureComponentmemouseMemo/useCallback) 。同时你可能会需要使用不可变的数据结构来使得你的组件更容易被优化。

在 Vue 应用中,组件的依赖是在渲染过程中自动追踪的,所以系统能精确知晓哪个组件确实需要被重渲染。

近期比较火热的 signal (信号,Angular、Preact、Qwik、Solid 等框架都引入了该概念),如果读者是 Vue 或者 MobX 之类的用户, Signal 并不是新的概念。

按 Vue 官方文档的话说:从根本上说,信号是与 Vue 中的 ref 相同的响应性基础类型。它是一个在访问时跟踪依赖、在变更时触发副作用的值容器。

不管怎样,响应式数据不过是观察者模式的一种实现。相比 React 主导的通过不可变数据的比对来标记重新渲染的范围,响应式数据可以实现更细粒度的绑定;而且响应式的另一项优势是它的可传递性(有些地方称为 Props 下钻(Props Drilling))。

动静分离

Vue 3 就是动静结合的典型代表。在我看来 Vue 深谙中庸之道,在它身上我们很难找出短板。

Vue 的模板是需要静态编译的,这使得它可以像 Svelte 等框架一样,有较大的优化空间;同时保留了 Virtual DOM 和运行时 Reactivity,让它兼顾了灵活和普适性。

基于静态的模板,Vue 3 做了很多优化,笔者将它总结为动静分离吧。比如静态提升、更新类型标记、树结构打平,无非都是将模板中的静态部分和动态部分作一些分离,避免一些无意义的更新操作。

更长远的看,受 SolidJS 的启发, Vue 未来可能也会退出 Vapor 模式,不依赖 Virtual DOM 来实现更加精细的渲染。

再谈编译时和运行时

编译时和运行时没有优劣之分, 也不能说纯编译的方案就必定是未来的趋势。

这几年除了新的编译时的方案冒出来,宣传自己是未来;也有从编译时的焦油坑里爬出来, 转到运行时方案的,这里面的典型代表就是 Taro。

Taro 2.0 之前采用的是静态编译的方案,即将 ’React‘ 组件转译为小程序原生的代码:

但是这个转译工作量非常庞大,JSX 的写法千变万化,非常灵活。Taro 只能采用 穷举 的方式对 JSX 可能的写法进行了一 一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法。这也是 Taro 官方放弃这种架构的原因。

也就是说 Taro 也只能覆盖我们常见的 JSX 用法,而且我们必须严格遵循 Taro 规范才能正常通过。

有非常多的局限:

  • 静态的 JSX
  • 不支持高阶组件
  • 不支持动态组件
  • 不支持操作 JSX 的结果
  • 不支持 render function
  • 不能重新导出组件
  • 需要遵循 on、render 约束
  • 不支持 Context、Fragment、props 展开、forwardRef
  • ….

有太多太多的约束,这已经不是带着镣铐跳舞了,是被五花大绑了。

使用编译的方案不可避免的和实际运行的代码有较大的 Gap,源码和实际运行的代码存在较大的差别会导致什么?

  • 比较差的 Debug 体验。
  • 比较黑盒。

我们在歌颂编译式的方案,能给我们带来多大的性能提升、带来多么简洁的语法的同时。另一方面,一旦我们进行调试/优化,我们不得不跨越这层 Gap,去了解它转换的逻辑和底层实现。

这是一件挺矛盾的事情,当我们「精通」这些框架的时候,估计我们已经是一个人肉编译器了。

Taro 2.x 配合小程序, 这对卧龙凤雏, 可以将整个开发体验拉到地平线以下。

回到这些『次世代』框架。React/Vue/Angular 这些框架先入为主, 在它们的教育下,我们对前端视图开发的概念和编程范式的认知已经固化。

比如在笔者看来 Svelte 是违法直觉的。因为 JavaScript 本身并不支持这种语义。Svelte 要支持这种语义需要一个编译器,而作为一个 JavaScript 开发者,我也需要进行心智上的转换。

而 SolidJS 则好很多,目之所及都是我们熟知的东西。尽管编译后可能是一个完全不一样的东西。

0 人点赞