用 Arrow-js 构建响应式Web 应用,一个接近原生免编译,快速维护jsp的解决方案

267 阅读5分钟

大家好,我是梦兽。一个 WEB 全栈开发和 Rust 爱好者。如果你对 Rust 非常感兴趣,可以关注梦兽编程公众号获取群,进入和梦兽一起交流。 传统服务渲染的应用,

JavaScript 模板字符串是一个非常强大的工具。这个小小的反引号允许创建多行字符串、嵌入表达式以及标记模板。标记模板?是的,就是那种让你能够这样写的小小魔法:

例如,你可以使用模板字符串来轻松地构建带有变量的字符串,如下所示:

await sql`SELECT email, name, prefs FROM user WHERE id=${user.id}`

或者

html`hello ${() => user.name}`

user.name 发生变化时,HTML 函数会重新渲染!

sql 示例是在一个 bun.sh 服务器上使用了 @neondatabase/serverless。html 示例来自 arrow-js,这是一个小(约 2k 大小)的客户端库,支持响应式和模板功能。

让我们来分解一下第二个示例:

  1. ${} 表示计算这部分内容。
  2. ()=> 这是一个函数。
  3. user.name 这是一个响应式状态。

通过将状态封装在一个函数中,就可以设置一个重新评估的点。库可以看到哪些状态被访问了以及函数渲染在 DOM 中的位置。所以,如果你只是想输出数据的话:

这种机制使得当状态改变时,只有相关的部分会被重新计算和更新,从而实现局部刷新而不是整个页面的重绘。这样可以提高应用的性能和用户体验。

html`${user.name}`

没有函数包装的话,就不会有其使用的记录,因此也就不会有重新评估。

那么为什么是 Arrow-js 呢?如果你看看 JavaScript 从最初的小型客户端交互性发展到庞大的服务器端库领域和 npm——我们现在已经有了大量的客户端和服务器端库,一种类型安全的变体,以及如此多的框架。这一切都是为了让这门小巧的语言成为今天撼动世界的语言。我们必须通过 JavaScript 进行开发——它实际上存在于每一台桌面电脑上,握在每个人的手中!话虽如此,所有这些历史负担都需要进行模块优化。随着语言的发展,框架要么改变要么被替换。我最近写了一篇关于 vanjs 的文章。我在渲染列表时遇到了一些问题,所以我又开始寻找替代方案。就是在那时我发现的 arrow-js。

Arrow-js 提供了三个函数:reactive、watch 和 html。Reactive 允许你包装状态——这种状态会记录访问并发出通知。

import { reactive } from '@arrow-js/core'

const state = reactive({
   status: `connecting`,
   error: null,
   profile: null,
   broadcast: null,
})

这是我的 WebSocket 的状态。它将保存连接状态、用户的个人资料以及需要由组件处理的广播消息。

const chat = reactive({transcript:{}})
watch(
   () => state.broadcast,
   (value)=>{
     if (value?.channel == 'chat') {
       value.payload.created = new Date(value.payload.id)
       this.chat.transcript[value.payload.id] = value.payload
     }
   })
})

然后我们可以使用一个 HTML 模板来显示我们的聊天记录。

html`<ul class="transcript">
   ${() =>
       Object.values(this.chat.transcript)
         .sort((a, b) => a.id - b.id)
         .map((item) => {
             return html`<li class="${speaker_class(item)}">
         ${item.content}<br />
         <span class="speaker">${speaker_label(item)}</span>
         </li>`.key(item.id)
    })}
</ul>`

为什么要有 speaker_classspeaker_label —— 这些提供了气泡的颜色和标签——你知道你是谁!所以在这里,我对 ul 有一个评估,并且在其中嵌入了对 li 的评估。两者都返回 HTML 函数,但是 li 后面跟着一个对 key() 的调用,这使得 DOM 更新变得更小、更精确。最奇妙的是,这仅仅是 JavaScript。

以上就是全部内容——三个动词:reactive、watch 和 html。虽然每一个看起来都很简单,但它们共同代表了一个真正的响应式库。如果你遇到问题,别忘了把它变成一个函数 ()=>。如果你不明白我在说什么,去试试看就知道了。

然后我的 HTML 看起来是这样的:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <link
            rel="icon"
            type="image/svg"
            href="{{ static_url('message-circle.svg') }}"
        />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Chatty</title>
        <link rel="stylesheet" href="site.css" />
        <script type="module" src="site.js"></script>
    </head>
    <body>
    </body>
</html>

static_url 函数是为了让 Tornado 进行缓存和响应。

现在 site.js 包含一个名为 App 的类,该类有一个 connect 函数用于创建 WebSocket。connectsend 函数都返回 Promise。还有四个显示函数,它们返回 arrow-js 的 HTML 函数。

import { reactive, watch, html } from 'https://esm.sh/@arrow-js/core' //'./arrow/index.mjs'

class App {
    ws = null
    state = reactive({
        status: `connecting`,
        error: null,
        profile: null,
        broadcast: null,
    })
    chat = reactive({
        transcript: {},
    })
    entry = reactive({
        speaker: 'peterb',
        spoken: '',
    })
    constructor() {
        watch(
            () => this.state.broadcast,
            (value) => {
                if (value?.channel == 'chat') {
                    value.payload.created = new Date(value.payload.id)
                    this.chat.transcript[value.payload.id] = value.payload
                }
            }
        )
    }
    connect() {
        return new Promise((resolve, reject) => {
            const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'
            const ws = new WebSocket(
                `${protocol}${location.hostname}:${location.port}/ws`
            )
            ws.onopen = () => {
                this.ws = ws
                this.state.status = 'connected'
                resolve(this)
            }
            ws.onerror = (err) => {
                this.set_error(err)
                if (this.ws != null) {
                    reject(err)
                }
            }
            ws.onclose = () => {
                this.state.status = 'disconnected'
                this.ws = null
            }
            ws.onmessage = (evt) =>
                (this.state.broadcast = JSON.parse(evt.data))
        })
    }
    disconnect() {
        if (this.ws) {
            this.ws.close()
        }
    }
    set_error(err) {
        this.state.error = err.message || err
    }
    send_chat(speaker, content) {
        return new Promise((resolve, reject) => {
            const message = {
                channel: 'chat',
                payload: {
                    speaker: speaker,
                    content: content,
                    id: new Date().getTime(),
                },
            }
            try {
                this.ws.send(JSON.stringify(message))
                resolve(message)
            } catch (err) {
                reject(err)
            }
        })
    }
    display_status() {
        return html`${() => (this.state.status == 'connected' ? '🫡' : '🫥')}`
    }
    display_error() {
        return html`<div
            @click="${() => (this.state.error = null)}"
            visible="${this.state.error != null}"
        >
            ${() => this.state.error}
        </div>`
    }
    display_entry() {
        return html`<form
            @submit="${async (e) => {
                e.preventDefault()
                try {
                    await this.send_chat(this.entry.speaker, this.entry.spoken)
                    this.entry.spoken = ''
                } catch (err) {
                    this.set_error(err)
                }
                return false
            }}"
        >
            <input
                type="text"
                @input="${(e) => (this.entry.speaker = e.target.value)}"
                value="${this.entry.speaker}"
                placeholder="your name"
                name="speaker"
                required
            />
            <input
                type="text"
                @input="${(e) => (this.entry.spoken = e.target.value)}"
                value="${() => this.entry.spoken}"
                placeholder="say something"
                name="spoken"
                required
                autofocus
            />
            <button type="submit">Say</button>
        </form>`
    }
    display_transcript() {
        const speaker_class = (item) => {
            return item.speaker == this.entry.speaker ? 'from-me' : 'from-them'
        }

        const speaker_label = (item) => {
            return item.speaker == this.entry.speaker ? '' : item.speaker
        }

        return html`${() =>
            Object.values(this.chat.transcript)
                .sort((a, b) => a.id - b.id)
                .map((item) => {
                    return html`<p class="${speaker_class(item)}">
                        ${item.content}<br />
                        <span class="speaker">${speaker_label(item)}</span>
                    </p>`.key(item.id)
                })}`
    }
}

const app = (window.app = new App())
window.addEventListener('load', async () => {
    try {
        await app.connect()
        document.body.innerHTML = ''
        html`<div class="status"></div>
            <div class="content">
                <div class="entry"></div>
                <div class="error"></div>
                <div class="transcript"></div>
            </div>`(document.body)
        await app.send_chat('foo', 'bar is a three letter word.')
    } catch (err) {
        app.set_error(err)
    }
    app.display_status()(document.querySelector('.status'))
    app.display_error()(document.querySelector('.error'))
    app.display_entry()(document.querySelector('.entry'))
    app.display_transcript()(document.querySelector('.transcript'))
})

这是我目前使用 arrow-js 所达到的程度。它似乎能做我想让它做的所有事情。我相信你能看出 JavaScript 的时代感——是的,我使用了一个类,哦,还有 querySelector

自从 knockout-js 以来,出现了许多小型的响应式库。它们让这个世界变得更加易于生活。过去我们把模板声明为隐藏的头部元素,而现在它们直接在 JavaScript 中,无需预编译器。

构成网页的有三种语言:HTML、CSS 和 JavaScript。它们各自争夺着各自的领地。想想看,模态对话框是如何从 JavaScript 移动到 CSS 再到现在移到 HTML 的。Arrow-js 加入了一个竞争激烈的领域,并且它还没有离开 alpha 阶段!我认为它的功能及其实现方式是非常值得注意的。它不是 Vue(我的首选),当然也不是 Angular,尽管有点类似于 React,但它值得探索。

如果你尝试过 Arrow-js,请在评论中告诉我们你的看法。

结束语

感谢阅读!感谢您的时间,并希望您觉得这篇文章有价值。在您的下一个 JavaScript 项目中尝试使用柯里化,并在下面的评论中告诉我它如何改善了您的编码体验!

创建和维护这个博客以及相关的库带来了十分庞大的工作量,即便我十分热爱它们,仍然需要你们的支持。或者转发文章。通过赞助我,可以让我有能投入更多时间与精力在创造新内容,开发新功能上。赞助我最好的办法是微信公众号看看广告。