1
头图
2023 年了,各大框架纷纷放弃兼容 IE11,如果不考虑 IE11 的情况,便可充分利用浏览器当前全新的特性,那么前端框架该设计成什么样子?

OMI 加入 Signal 之后,现在的 API 设计是我心中最理想的前端框架该有的样子,基于 Tailwind CSS 原子CSS 的 OmiElements 也是我心中理想的前端元素库的形态。基于元素库之上的组件库,和基于组件库OMI 低代码平台未来也会跟大家见面,敬请期待。我们也对主站和各个子站点进行了全新升级改版。

下面从这些关键字一窥 OMI 的设计思路和一些选择的取舍:

  • Signal
  • Proxy
  • Web Components
  • JSX
  • Tailwind CSS
  • Constructable Stylesheets
  • OMI Router
  • OMI Suspense
  • OOP & DOP
  • OMI SPA
  • OMI 低代码

其中 OMI Elements 和 OMI Tutorial 使用上面这些技术里的大部分能力搭建而成:

两个项目可以在 https://github.com/Tencent/omi 找到。

信号 Signal 驱动的响应式编程

在刘慈欣的科幻小说《三体》中,叶文洁在收到来自三体世界的信号后,又收到了另一个警告信号。

不要回答!不要回答!不要回答!

叶文洁收到的是信号,信号值是不要回答!不要回答!不要回答!。信号是信号,信号值是信号值,信号 不等于 信号值信号值 等于 信号.value。理解了这个概念,就理解了信号的核心思想。举例说明:

import { render, signal, tag, Component, h } from 'omi'

const count = signal(0)

function add() {
  count.value++
}

function sub() {
  count.value--
}

@tag('counter-demo')
class CounterDemo extends Component {
  render() {
    return (
      <>
        <button onClick={sub}>-</button>
        <span>{count.value}</span>
        <button onClick={add}>+</button>
      </>
    )
  }
}

render(<counter-demo />, document.body)

上面代码中count 是信号,在render方法中读取信号值 count.value,这个时候信号会被收集到当前组件作为信号的依赖,当信号值发生变化时,会自动触发依赖该信号值的组件更新,从而实现了响应式。再强调一次,读取信号的值(也就是.value),读取信号的组件就会被信号收集为依赖,以后信号值变化,信号依赖的组件就会自动更新。

怎么做到的呢?下面介绍一下 OMI Signal 的实现原理。

Proxy:JavaScript 中的强大代理 API

在 JavaScript 中,Proxy 提供了非常强大的元编程能力,它允许开发者在访问、修改对象属性时进行拦截和处理。Proxy 的出现极大地拓展了 JavaScript 编程的可能性,为开发者提供了更多的灵活性和控制力。

Proxy的本质是一个对象,它接受两个参数:目标对象(target)和处理器对象(handler)。目标对象是需要被代理的对象,而处理器对象则包含了一系列用于拦截和处理目标对象操作的方法。

const target = { foo: 'bar' };

const proxy = new Proxy(target, {
  get(target, property) {
    console.log(`Getting ${property}`);
    return target[property];
  }
};);
console.log(proxy.foo); // 输出 "Getting foo" 和 "bar"

在这个例子中,我们创建了一个简单的Proxy对象,它在访问目标对象的属性时会输出一条日志。当我们通过代理对象访问foo属性时,处理器对象的get方法会被触发,输出日志并返回属性值。

Proxy支持多种拦截方法,这些方法可以拦截并处理目标对象的各种操作。以下是一些常用的拦截方法:

  • get(target, property, receiver): 当访问代理对象的属性时触发。
  • set(target, property, value, receiver): 当设置代理对象的属性值时触发。
  • has(target, property): 当使用in操作符检查代理对象的属性时触发。
  • deleteProperty(target, property): 当删除代理对象的属性时触发。

这些拦截方法可以根据需要自由组合,实现各种复杂的逻辑控制。需要注意的是,不是所有的拦截方法都需要实现,未实现的拦截方法将直接访问或操作目标对象。

Proxy 在实际开发中有很多应用场景,数据绑定和响应式更新是最常见的,OMI 框架的信号 Signal 就是基于 Proxy 实现:通过拦截对象属性的访问(收集依赖)和修改操作(更新依赖),实现数据与视图的同步更新。这不是什么新鲜的技术,mobx 很早就使用可观察状态、计算值、依赖跟踪、响应式更新和 action,帮助你更轻松地管理和更新应用状态,它作为独立的状态管理库,OMI 直接内置集成了这些能力,开箱即用。

Web Components

Web Components是一组浏览器原生支持的技术,用于实现可重用、封装良好的自定义 HTML 元素。它为前端开发带来了组件化的革命,使得开发者可以更加高效地构建复杂的 Web 应用。
一些大厂的案例有:

  • Adobe Photoshop online 完全是基于 Web Components 进行搭建
  • 微软新的 web 版本 windows 商店使用 webcomponents
  • 还有 Twitter 嵌入式推文、YouTube、Electronic Arts、Adobe Spectrum等都基于 Web Components 搭建

Web Components包括以下三个主要技术:

  • Custom Elements:允许开发者创建自定义的HTML元素,并定义它们的行为。
  • Shadow DOM:为自定义元素提供封装的DOM结构,使其与主文档隔离,避免样式和脚本的冲突。
  • HTML Templates:提供一种创建HTML模板的方法,这些模板可以在运行时被克隆和填充,提高渲染性能。

比如不使用任何框架实现一个自定义元素:

class MyElement extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const div = document.createElement('div');
    div.textContent = 'Hello, Shadow DOM!';
    shadowRoot.appendChild(div);
  }
}

customElements.define('my-element', MyElement);

在这个例子中,我们创建了一个自定义元素my-element,并在其内部创建了一个 Shadow DOM。当浏览器遇到<my-element>标签时,会自动创建一个MyElement实例,并将其附加到文档中。<my-element>是框架无关的,任何框架都可以使用该元素。

其中 OMI 框架使用了其中两种:Custom ElementsShadow DOM,而 HTML Templates 则由编程体验更好 JSX 语法来代替来实现。比如上面的例子,OMI 实现:

import { tag, Component, h } from 'omi'

@tag('my-element')
class MyElement extends Component {
  render() {
    return <div>Hello, Shadow DOM!</div>
  }
}

JSX 编程体验更好,更短,tag function里模板字符串使用反引号包围,插值需要$包围。

未来很长一段时间,我们不会使用 HTML Templates或者是 tag function + 真实DOM,而是使用 JSX + 虚拟DOM 语法来实现,因为它的编程体验更好。未来会不会支持 tag function + 真实DOM,同时支持两种语法?,这里我也不敢保证。可以确定的是,除非遇到明显的性能瓶颈,未来很长一段时间都会保持 JSX/TSX + 虚拟DOM。

Tailwind CSS

给变量取名是编程任务中费时的事情之一,比如给一个 HTML 标签想一个精准 class 名称,是非常困难的,千人千面,Tailwind CSS 是解药,帮前端消灭了一门语言,不用考虑命名,不用担心 css 互相影响,不用担心体积膨胀,不用担心项目过大样式文件快速腐化,其收益远大于它的损失,一脚踏进了原子化 CSS 的大门,就再也回不去了。

Tailwind CSS 是用于构建用户界面的 utility-first 的 CSS 框架。致力于通过提供一系列可组合的预设 class 来帮助开发人员快速创建响应式设计。Tailwind CSS 遵循移动优先的设计原则,因此你可以轻松地为不同的设备和屏幕尺寸创建响应式设计。Tailwind CSS 在构建生产版本时,会自动删除未使用的 CSS,从而减小最终文件的大小。

在前年的时候,我就问过 OMI 团队小伙伴,使用原子 CSS 构建组件库可行不可行,最后大家觉得不可行。直到大家看到了 tw-elements 这个项目,原来它是可行的。

在 OMI WebComponents 使用中 Tailwind CSS:

import { tag, Component } from 'omi'
import { tailwind } from '@/tailwind'

@tag('my-element')
export class MyElement extends Component {
  static css = [tailwind]

  render() {
    return (
      <figure class="md:flex bg-slate-100 rounded-xl p-8 md:p-0 dark:bg-slate-800">
        <img class="w-24 h-24 md:w-48 md:h-auto md:rounded-none rounded-full mx-auto" src="/sarah-dayan.jpg" alt="" width="384" height="512" />
        <div class="pt-6 md:p-8 text-center md:text-left space-y-4">
          <blockquote>
            <p class="text-lg font-medium">
              “Tailwind CSS is the only framework that I've seen scale
              on large teams. It’s easy to customize, adapts to any design,
              and the build size is tiny.”
            </p>
          </blockquote>
          <figcaption class="font-medium">
            <div class="text-sky-500 dark:text-sky-400">
              Sarah Dayan
            </div>
            <div class="text-slate-700 dark:text-slate-500">
              Staff Engineer, Algolia
            </div>
          </figcaption>
        </div>
      </figure>
    )
  }
}

每个组件都携带了 tailwind,那么是不是需要非常大的内存开销?这里就需要提到 constructable stylesheets,可构造的样式表。

Constructable Stylesheets

Constructable Stylesheets 是 Web Components 的黄金搭档。在使用 Shadow DOM 时创建和分布可重复使用的样式的一种方式,既降低了尺寸,还能提高性能。

在介绍 Constructable Stylesheets 之前,我们先来看一下传统的样式应用方式。在传统的 Web 开发中,我们通常会通过以下几种方式来应用样式:

内联样式

直接在 HTML 元素的 style 属性中写样式。这种方式简单直接,但是样式不能复用,而且难以管理。

<style> 标签:在 HTML 文档的 <head> 中使用 <style> 标签来写样式。这种方式可以应用到整个文档,但是样式仍然不能复用,而且如果样式代码较多,可能会影响 HTML 文档的可读性。

外部样式表

通过 <link rel="stylesheet" href="..."> 来引入外部的 CSS 文件。这种方式可以复用样式,而且可以将样式代码和 HTML 代码分离,使得代码更易于管理。然而,每个外部样式表都需要一个 HTTP 请求,如果样式表较多,可能会影响页面的加载性能。

以上这些方式在大多数情况下都能工作得很好,但是在一些特殊的场景下,它们可能会遇到一些问题。例如,在使用 Web Components 时,我们通常需要在每个组件的 Shadow DOM 中应用样式。由于 Shadow DOM 是隔离的,我们不能直接使用外部样式表或者 <style> 标签,而必须在每个 Shadow DOM 中单独写样式。这不仅使得样式难以复用,而且如果组件数量较多,可能会导致大量的样式代码被重复加载,从而影响性能。

为了解决这些问题,Constructable Stylesheets API 提供了一种新的方式来创建、存储和应用样式。这个 API 主要包含以下几个部分:

  • CSSStyleSheet 类:这个类代表一个样式表。我们可以通过 new CSSStyleSheet() 来创建一个新的样式表,

然后通过 sheet.replace(text) 或者 sheet.replaceSync(text) 来设置样式表的内容。这里的 text 是一个包含 CSS 代码的字符串。

  • adoptedStyleSheets 属性:这个属性存在于 Document 和 ShadowRoot 对象上。

我们可以通过 document.adoptedStyleSheets = [sheet1, sheet2, ...] 或者 shadowRoot.adoptedStyleSheets = [sheet1, sheet2, ...] 来应用样式表。这里的 sheet1, sheet2, ... 是 CSSStyleSheet 对象。

使用 Constructable Stylesheets,我们可以在 JavaScript 中创建和管理样式表,然后在需要的地方动态地应用样式表。这样,我们就可以复用样式,而且只需要加载一次样式代码,从而提高性能。

例如,我们可以创建一个样式表,然后在多个 Shadow DOM 中应用这个样式表,这里举一个不使用 OMI 框架原生使用 Constructable Stylesheets 的例子:

const sheet = new CSSStyleSheet();
sheet.replaceSync('p { color: red; }');

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
  }
  connectedCallback() {
    this.shadowRoot.adoptedStyleSheets = [sheet];
    this.shadowRoot.innerHTML = '<p>Hello, world!</p>';
  }
});

在这个例子中,我们创建了一个样式表 sheet,然后在 my-element 组件的 Shadow DOM 中应用了这个样式表。无论我们创建了多少个 my-element 组件,样式表的代码都只需要加载一次。

SPA & OMI Router & OMI Suspense

SPA(Single Page Application,单页应用)是一种 Web 应用程序开发模式,其特点是在单个 HTML 页面上通过 JavaScript 动态更新和渲染内容,而无需重新加载整个页面。优点包括: 用户体验、响应速度、网络流量减少、前后端分离、离线支持、易于调试和维护。

React Router 使用了下面的方式定义路由,每个 path 指定一个 element,可以支持嵌套路由。

<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<Home />} />
    <Route path="about" element={<About />} />
    <Route path="dashboard" element={<Dashboard />} />
    <Route path="*" element={<NoMatch />} />
  </Route>
</Routes>

OMI Router 使用了扁平路由设计进行 SPA 搭建,结合 OMI Suspense 和 浏览器原生支持的 Web Components元素自动升级特性,可以逐步显示 Web 区域的内容:

export const routes = [{
    path: '/user/:id/profile',
    render(router: Router) {
      return (
        <o-suspense
          imports={[
            import('./components/user-info'),
          ]}
        >
          <div slot="pending">Loading user...</div>
          <div slot="fallback">Failed to load user</div>
          <user-info>
            <o-suspense
              imports={[
                import('./components/user-profile'),
              ]}
              data={async () => {
                return await fetchUserProfile(router?.params.id as string)
              }}
              onDataLoaded={(event: CustomEvent) => {
                userProfile.value = event.detail
              }}
            >
              <div slot="pending">Loading user profile...</div>
              <div slot="fallback">Failed to load user profile</div>
              <user-profile></user-profile>
            </o-suspense>
          </user-info>
        </o-suspense>
      )
    }
  }
]

Suspense 是一种用于处理异步加载组件的机制。通过使用Suspense,开发者可以为异步加载的组件提供一个“占位符”,在组件和组件依赖的数据加载完成之前显示给用户。这样一来,用户就不会在等待组件加载时看到空白页面,从而提高用户体验。

一个 path 就对应 一个界面,易于理解和管理。虽然路由是扁平的,但是你在每个路由的 render 函数中使用了嵌套的组件。这是一种常见的模式,可以让你在保持路由扁平的同时,利用组件的嵌套来复用代码和表示层级关系。只是页面有重复的头部和侧边栏的时候需要一些重复代码,后续我们可以考虑支持声明 children 来支持嵌套的形式,但是它也只是扁平结构的语法糖,最后运行的效果是一样的。

OOP & DOP

这里使用 TodoApp举例子说明 OMI 中 Signal 类 和 signal 响应是函数两种响应式编程的方式。

TodoApp 使用响应式函数

数据驱动编程

在数据驱动编程中,我们将重点放在数据本身和对数据的操作上,而不是数据所在的对象或数据结构。这种编程范式强调的是数据的变化和流动,以及如何响应这些变化。基于响应式函数的 TodoApp 就是一个很好的例子,它使用了响应式编程的概念,当数据(即待办事项列表)发生变化时,UI 会自动更新以反映这些变化。

import { render, signal, computed, tag, Component, h } from 'omi'

const todos = signal([
  { text: 'Learn OMI', completed: true },
  { text: 'Learn Web Components', completed: false },
  { text: 'Learn JSX', completed: false },
  { text: 'Learn Signal', completed: false }
])

const completedCount = computed(() => {
  return todos.value.filter(todo => todo.completed).length
})

const newItem = signal('')

function addTodo() {
  // api a,不会重新创建数组
  todos.value.push({ text: newItem.value, completed: false })
  todos.update() // 非值类型的数据更新需要手动调用 update 方法

  // api b, 和上面的 api a 效果一样,但是会创建新的数组
  // todos.value = [...todos.value, { text: newItem.value, completed: false }]

  newItem.value = '' // 值类型的数据更新需会自动 update
}

function removeTodo(index: number) {
  todos.value.splice(index, 1)
  todos.update() // 非值类型的数据更新需要手动调用 update 方法
}

@tag('todo-list')
class TodoList extends Component {
  onInput = (event: Event) => {
    const target = event.target as HTMLInputElement
    newItem.value = target.value
  }

  render() {
    return (
      <>
        <input type="text" value={newItem.value} onInput={this.onInput} />
        <button onClick={addTodo}>Add</button>
        <ul>
          {todos.value.map((todo, index) => {
            return (
              <li>
                <label>
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onInput={() => {
                      todo.completed = !todo.completed
                      todos.update()
                    }}
                  />
                  {todo.completed ? <s>{todo.text}</s> : todo.text}
                </label>
                {' '}
                <button onClick={() => removeTodo(index)}>❌</button>
              </li>
            )
          })}
        </ul>
        <p>Completed count: {completedCount.value}</p>
      </>
    )
  }
}

render(<todo-list />, document.body)

TodoApp 使用信号类 Signal

面向对象编程

在面向对象编程中,我们将重点放在对象上,对象包含了数据和操作数据的方法。这种编程范式强调的是对象之间的交互和协作,以及如何通过对象的封装、继承和多态性来组织和管理代码。基于响应式函数的 TodoApp 也可以使用面向对象的方式来实现,例如,我们可以创建一个 TodoList 类,这个类包含了待办事项列表的数据和操作这些数据的方法,以及一个 update 方法来更新 UI。

import { render, Signal, tag, Component, h, computed } from 'omi'

type Todo = { text: string, completed: boolean }

class TodoApp extends Signal<{ todos: Todo[], filter: string, newItem: string }> {
  completedCount: ReturnType<typeof computed>

  constructor(todos: Todo[] = []) {
    super({ todos, filter: 'all', newItem: '' })
    this.completedCount = computed(() => this.value.todos.filter(todo => todo.completed).length)
  }

  addTodo = () => {
    // api a
    this.value.todos.push({ text: this.value.newItem, completed: false })
    this.value.newItem = ''
    this.update()

    // api b, same as api a
    // this.update((value) => {
    //   value.todos.push({ text: value.newItem, completed: false })
    //   value.newItem = ''
    // })
  }

  toggleTodo = (index: number) => {
    const todo = this.value.todos[index]
    todo.completed = !todo.completed
    this.update()
  }

  removeTodo = (index: number) => {
    this.value.todos.splice(index, 1)
    this.update()
  }
}

const todoApp = new TodoApp([
  { text: 'Learn OMI', completed: true },
  { text: 'Learn Web Components', completed: false },
  { text: 'Learn JSX', completed: false },
  { text: 'Learn Signal', completed: false }
])

@tag('todo-list')
class TodoList extends Component {
  onInput = (event: Event) => {
    const target = event.target as HTMLInputElement
    todoApp.value.newItem = target.value
  }

  render() {
    const { todos } = todoApp.value
    const { completedCount, toggleTodo, addTodo, removeTodo } = todoApp
    return (
      <>
        <input type="text" value={todoApp.value.newItem} onInput={this.onInput} />
        <button onClick={addTodo}>Add</button>
        <ul>
          {todos.map((todo, index) => {
            return (
              <li>
                <label>
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onInput={() => toggleTodo(index)}
                  />
                  {todo.completed ? <s>{todo.text}</s> : todo.text}
                </label>
                {' '}
                <button onClick={() => removeTodo(index)}>❌</button>
              </li>
            )
          })}
        </ul>
        <p>Completed count: {completedCount.value}</p>
      </>
    )
  }
}

render(<todo-list />, document.body)

这里不讨论哪种方式(DOP,OOP)的好坏,使用 omi 两种方式都可以任意选择,也可以通过分层结合一起使用。

我们提倡使用分层的方式来开发前端。使用分层架构的原因很简单,让UI是UI,非UI是非UI。前端框架真是一把双刃剑,可以快速搭建 UI 的同时,很容易让大家把非UI层的,需要认真进行面向对象分析设计的模块被打散夹杂到 UI 层的各种逻辑里面变成一片混沌无序,快速腐化,项目负责人不可替代性越来越强(无人能接,无人能懂啊),强行在 UI 层进行 MVVM/MVC/MVP 分层是错误的。我们希望发挥和享受 OMI 数据驱动的响应式视图、可以快速搭建 UI 的能力的优势,尽量让用户前端框架超越其职责边界,杜绝用户把所有逻辑都塞进 UI 里。

另外我们使用两层架构三层架构分别写了同一款贪吃蛇游戏,可以发现 OOP & DOP 不冲突,可以通过三层架构一起使用。

源码可以在 http://omijs.org/https://github.com/Tencent/omi 里找到。

这里我们的建议是:

  • 将前端项目划分为不同的层次,如UI层、中间层、Model层等,各层负责不同的功能,降低耦合度,关注点分离。
  • UI层:负责页面布局、样式、动画等视觉表现,杜绝在UI层处理业务逻辑和数据处理。
  • 中间层负责把 UI 层输入转成 Model 层需要的格式,把 Model 层的输出转成 UI 层需要的格式。
  • Model 层包含软件需要做的所有事情,UI 层只是提供了人机交互界面为 Model 层传送指令。

遵循以上建议,可以有效地提高前端项目的可维护性、可扩展性和可读性。

OMI 低代码

万丈高楼平地起,关于低代码,我们积累了大量的实战经验,每天都能迸发出很多创意和想法,后面会体现在我们的低代码产品当中,敬请期待。

总结

在《股票大作手回忆录》里有个圣杯的概念,许多投资者都试图寻找到它。从客观的角度来看,前端框架也有属于它的圣杯,我们站在巨人的肩膀上,持续寻找前端的圣杯。

本文从非常宏观的角度上,大体地介绍了 OMI 的新特性、和相关技术以及相关官方包,包括 OMI Signal、Web Components、TailwindCSS、OMI Router、OMI Suspense、 Proxy、Constructable Stylesheets、OOP & DOP、JSX、SPA等,希望这些内容能够帮助大家更好地理解和应用 OMI 框架,提高 Web 开发的效率和质量,拥抱趋势。更多详情查看 http://omijs.org/ 或者在 11月18日,杭州 FEDAY 前端大会,我会分享《响应式 WebComponents》,深入地探讨一下。


dntzhang
634 声望74 粉丝

腾讯高级工程师