新的 Vue Function-based API 当中的看到的 Clojure Atom 的影子

3

这次 Vue 大会看到了 Vue 新的 API 设计, 中间有一些觉得眼熟的写法,
后面也看到了工业聚的一些解读, 大致知道是什么样的用法吧..
当然现场演讲过 Vue 具体实现的优化是更复杂的, 比这个 API 要多..

其中比较让我觉得眼熟的是 value(0) 还有特别是 state({count: 0}) 的用法,

function useMouse() {
  const x = value(0)
  const y = value(0)
  const update = e => {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}
value() 返回的是一个 value wrapper (包装对象)。一个包装对象只有一个属性:.value ,该属性指向内部被包装的值。

这是因为当包装对象被暴露给模版渲染上下文,或是被嵌套在另一个响应式对象中的时候,它会被自动展开 (unwrap) 为内部的值。

const count = value(0)
const obj = state({
  count
})

console.log(obj.count) // 0

obj.count++
console.log(obj.count) // 1
console.log(count.value) // 1

count.value++
console.log(obj.count) // 2
console.log(count.value) // 2

作为一个 ClojureScript 用户我就想着大致对应 Clojure Atom 了.

有点特别吧, 在 Clojure 里面数据(value)和状态(state)是不同的表示,
一般的都是 value, 比如数字, 字符串, 数组, 哈希表, 都是数据, 而且默认不可以修改.
跟 js 很不一样的, 比如说 [1 2 3], 数组, 这个是不可以修改的,
如果修改其中元素了比如说加一个 4 得到 [1 2 3 4] 就一定是新的引用了.

如果在 ClojureScript 当中要表示一个状态, 就需要使用 Atom(来自 Atomic, 原子性),
因为是引用而不是数据, Clojure 里的习惯是用 * 作为前缀来标示的,
通过 atom 函数可以定义一个 Atom, 是一个引用, 里面包裹了一个数据,
包裹在里面的数据可以是简单的值(1, true, "str"), 也可以是复合的数据(HashMap, Vector),

cljs.user=> (def *a (atom [1 2 3]))
#'cljs.user/*a
cljs.user=> *a
#object [cljs.core.Atom {:val [1 2 3]}]

这只是一个引用, 而且没有 js 里面那种赋值语法可以直接去修改当中的数据,
需要操作数据的时候, 要通过特定的函数, 比如 reset! 或者 swap!

cljs.user=> (swap! *a conj 4)
[1 2 3 4]
cljs.user=> (reset! *a [1 2 3 4])
[1 2 3 4]

你也不能直接读取数据了, 直接去读, 拿到的是一个引用, 而不是实际的值,
这时候需要一个 "dereference" 的操作, 就是函数 deref, 或者直接用 @ 前缀:

cljs.user=> *a
#object [cljs.core.Atom {:val [1 2 3 4]}]
cljs.user=> @*a
[1 2 3 4]
cljs.user=> (deref *a)
[1 2 3 4]

参考: https://www.braveclojure.com/...

再回来看 js 这边, js 对象没有专门的语法来区分引用不引用的概念, 对象上的 key 都是引用,
不过, 通过 Proxy 劫持掉赋值操作, 可以在内部插入一系列的逻辑.
而这个例子当中的 state 函数, 就跟 Clojure 当中的 atom 比较像了.

import { state } from 'vue'

const object = state({
  count: 0
})

object.count++

这个状态会发生修改, 也就需要 watch 的操作来处理数据更新的情况,

watch(
  // getter
  () => count.value + 1,
  // callback
  (value, oldValue) => {
    console.log('count + 1 is: ', value)
  }
)
// -> count + 1 is: 1

count.value++
// -> count + 1 is: 2

以及取消监听:

const stop = watch(...)
// stop watching
stop()

在 Clojure 当中 atom 也有对应的 API 来添加监听和取消监听,
取消一般用一个 Keyword 来标记的, 比如这个例子通过 :logger 来取消.

(def a (atom nil))

;; The key of the watch is `:logger`
(add-watch a :logger #(println %4))

(reset! a [1 2 3])

;; Deactivate the watch by its assigned key
(remove-watch a :logger)

另外, 虽然 js 数据本身是可变的, 在 state 当中也是允许赋值的,
但是我注意到部分场景, 框架作者并不希望用户任意去修改数据, 特别是传递过的数据

Note this props object is reactive - i.e. it is updated when new props are passed in, and can be observed and reacted upon using the watch function introduced later in this RFC. However, for userland code, it is immutable during development (will emit warning if user code attempts to mutate it).

https://github.com/vuejs/rfcs...

这个行为跟 Clojure 最初的设计思路比较相似, Clojure 要求数据是不可的.

ClojureScript 的不可变数据虽然在前端用使用场景刚好适用, 特别是 React 当中.
但是最初 Clojure 选择了不可变数据, 设计了 Atom 的概念, 是为了并发编程考虑的.
状态可能会被多个线程共享, 所以需要 ref(引用)的改变, 让多个线程能修改这个状态.

我们知道 value(数据)被一个进程拿到, 是不应该被另一个进程偷偷修改掉的.
在 js 当中我们也会遇到, 一个对象如果传给另一方, 最好先拷贝一份,
如果把原对象传过去, 别人随意修改了, 己方的逻辑可能遇到异常情况而出错.
Clojure 选择的方案是, 把数据设计成不可变的, 这样任意传递, 都不会遇到不一致的问题.
如果需要能被修改, 那就是 state(状态)了, 就需要用 atom 封装了, 然后再传过去.

这样一比较, 就会觉得 Clojure 对比 JavaScript 数据, 就是故意反过来设计的,
js 当中对象和数组默认就是可以任意被修改的, 需要的时候 freeze 掉, 或者加上 watch 监听.
Clojure 当中 HashMap 跟 Vector 默认是不可变的, 需要状态的时候放在 Atom 里去,
而 Atom 就是可以通过修改引用来修改的, 也能被监听. 只是说里边的数据依然是不可变的.

除了这种共享状态是用 Atom 的, Clojure 也把 Atom 用在性能优化的地方,
比如计算 fibonacci 的时候, 需要缓存, 就会用 atom 存一个可以随时修改的数据,

(defn memoize [f]
  (let [mem (atom {})]
    (fn [& args]
      (if-let [e (find @mem args)]
        (val e)
        (let [ret (apply f args)]
          (swap! mem assoc args ret)
          ret)))))

(defn fib [n]
  (if (<= n 1)
    n
    (+ (fib (dec n)) (fib (- n 2)))))

(time (fib 35))

; user=> "Elapsed time: 941.445 msecs"

(def fib (memoize fib))

(time (fib 35))

; user=> "Elapsed time: 941.445 msecs"

https://clojure.org/reference...

这种可变状态在 Clojure 当中一般被放在局部使用,
这也是函数式编程惯用的套路, 函数式编程认为状态就应该是被隔离的.
这个习惯跟 js 那边也是不一样...
js 大家用 Vue 或者 Mobx 习惯了, 就会习惯到处用 observable 解决问题.

比如 Svelte 3 当中有一个例子, doubled = count * 2,
这是一个 Reactive 的值, count 改变, doubled 跟着改变, 最后界面也改变,
Svelte 当中用这样的代码来表示:

<script>
    let count = 0;
    $: doubled = count * 2;

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

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

<p>{count} doubled is {doubled}</p>

参考: https://svelte.dev/tutorial/r...

函数式编程当中值是不会发生改变的, 所以没法对值进行监听,
那么, 函数式编程就会引入一个 wrapper 的概念, 比如说用 Monad 设计一个...
在 Clojure 当中, Atom 是一个, 或者用 Channel 来包裹这个随时间改变的数据...

在新的 Vue API 当中有了一个 computed 的概念,
这个 computed 封装过的数据, 就有个 .value 的引用, 表示最新的值:

import { value, computed } from 'vue'

const count = value(0)
const countPlusOne = computed(() => count.value + 1)

console.log(countPlusOne.value) // 1
count.value++
console.log(countPlusOne.value) // 2

这个写法看着就跟 Clojure 当中的 ref, 用 Atom 包裹一个值很像了.
所以就感觉想法可能越来越像了.. 特别是引入不可变数据又需要数据被监听同时被传递的时候...
js 原始的 Object 模拟的就是一块内存, 内存某个位置可以被修改,
对于 Reactive System 来说, 这个结构过于简单了... 业务当中又希望不能被随便修改, 又要能替换又能监听...
函数式编程在这方面有不少思考... 相互借鉴...


留一个我厂(积梦)招聘的链接 https://github.com/jimengio/h... 我们前端整体都是用 Immer 优化 React 的.


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

elven · 6月13日

Vue越来越花里胡哨,语法上不习惯

回复

0

有的人还是喜欢功能繁复的语法的. 虽然我打喜欢. 具体到业务来说, React 也是会越来越复杂的, 只是在别的地方.

题叶 作者 · 6月13日
载入中...