232020.11

等等,这样讨论vue ref-sugar是不讲武德的!

我们讨论ref-sugar本质上在讨论三件事:设计原则,开发体验,实现魔法

我们现在在该问题上争论,很多人直接从第三点出发,认为实现魔法不好,但实际上,根源在于第一点,vue3发布的ref没有遵循vue原有的设计原则,使得变量的使用在script和template中呈现了两种形态。

export default {
  setup() {
    const count = ref(0)
    const plus = (inc) => count.value += inc // 脚本中使用 count.value
    return { count, plus }
  }
}

<div>{{ count }}</div> // 模板中使用 count

设计原则

Vue的设计原则包含两个基本点:1.通过直接修改状态变量而非调用接口完成响应式,2.接近原始js变量操作而非复杂操作的0成本理解。简单说就是,vue帮你封装好响应式,你只要无脑使用,做到真正0心智模型。但在ref这个点上,0心智失效了,因为我们在脚本中经常忘记.value。

对问题的解决,不能超出设计原则,这是vue所坚守的,超出原则的设计,比如这篇文章,虽然可以解决问题,但是一旦泛滥,那么vue就没啥价值了。

既然在书写时,你经常忘记写.value,那我接下来就让你忘记变得合法合理,所以新的ref提案的目标,就是隐藏.value,让你继续0心智。那就涉及到一个开发体验的问题了,究竟怎么让开发者写代码,才爽呢?

开发体验

在开发体验道路上分出了两条岔路,AoT和Runtime。前者把活交给编译过程,能力实现全靠工具,但是可以随意发明语法,想怎么玩都可以,爽,缺点就是脱离了那套编译工具跑不了,典型就是svelte。后者需要自己写一套基于原始js能力的系统实现效果,提供接口,按照接口文档用就完事了,浏览器里面随便跑,缺点是运行时代码加大,心智负担加剧(要记住用法)。

vue想尽一切办法在两者之间做平衡,几年来,长期坚持能够纯runtime实现,还是干不过js语言本身的限制。所以,新提案ref-sugar妥协了,是纯粹的AoT实现方案。当然,AoT也可以在浏览器端搞编译,就像当年less搞的一样,但很明显,这是歧途,自己在浏览器里面解释执行js是不明智的,太不安全了。

对该方案的喷点,多多少少是由于这个原因。因为如果把解决问题的方法丢给编译器去解决,那还有啥是编译器不能解决的?

所以,能够平息这次风波的根本解决办法,一定是:0心智负担+runtime的方案。连尤大都掉坑的问题,是不是无解了呢?接下来看看实现魔法。

实现魔法

我想,尤大掉坑的根源,可能是对设计原则的误解,认为vue必须是基于单一方案实现响应式,所谓单一方案,就是vue里面只能有数据拦截这一套方案。但是,响应式方案真不止数据拦截这一条路。vue1,2采用了defineProperty方案实现响应式,而vue3采用了Proxy,开发者的心智没有任何负担,反而叫好,这说明,问题的根源不在于实现响应式的内部细节,而在最终表现出来的效果。

所以,为什么vue只能基于单一方案实现响应式?为什么不能同时用两套响应式方案?在ref这个点上,脏检查机制就是一个赠送的方案。(这想法不是我提的

let a = ref(0, () => a) 
function handle() { 
    a = a + 1 
}
<div @click="handle">{{ a }}</div>
 上面代码里面的关键点在于ref的第二个参数,这个参数返回a,这样,每次在进行重新计算(如被依赖的情况下)时,都读取a这个变量,将采用a最新的值,包括watch,也会使用这第二个函数作为被watch对象是否变化的依据。

另外,js里面有一些魔法可以用上了,主要是valueOf和Symbol.toPrimitive,这两个属性可以帮助我们完成对象的值运算。我简单举个例子你就能明白其中的道理。

var a = {
  value: 1,
  valueOf() { return this.value },
}

var b = {
  value: 2,
  valueOf() { return this.value },
}

接下来我们做这个运行:a + b得到的结果是3.我们还可以修改a.value = 2,再执行a + b结果是4. 道理就是这么简单。let a = ref(0, () => a) 中,a的结果是一个如上结构的对象,所以参与运行是OK的,对a.value进行改造,就可以做依赖收集了。比如:

let c = computed(() => a + b)

由于上面式子中会调用a.value和b.value,所以可以知道c对a,b有依赖。下面是最关键的:

a = a + 1

对a重新赋值,此时a的数据类型发生了变化,从对象变成了数字。但是没有关系,在computed进行依赖收集时,我们已经得到了一个计算公式:

c = (() => a)() + (() => b)()

也就是说,无论a, b是什么数据类型,计算后得到的c的结果是确定的。而关键的问题在于,一旦a丢失了object类型,就没有办法依靠vue的响应式设计完成响应式效果。此时,脏检查就来了。

  • 用callback包装handle(效果类似computed,主要做依赖收集): handle = callback(() => a = a + 1)
  • 执行handle时,a.value被收集,代表handle函数有可能对a产生副作用,每次执行handle,可能收集到的ref变量不同,由if...else决定,根据这个情况,还可以做一些优化
  • 当handle被调用结束时,对收集到的所有依赖进行检查,检查依据是上一个值和这一次的() => a结果
  • 当发现值不相等时,表示依赖被更新了,此时回溯所有computed,如果发现对a有依赖,那么对应computed属性需要被更新

和angularjs的脏检查不同,因为这里我可以明确知道自己依赖了哪些变量,所以我做的检查范围会小很多。而且由于nextTick机制的存在,脏检查也仅限于数据层面,不会触发DOM的更新。

结束

你看,仅仅给ref加了第二个参数,就可以把这个issue给结了。但是,你可能会说,我() => a真不想写啊!!!要解决这个问题,最好的办法,就是你去做TC39主席,然后强推PrimitiveProxy,并且让Proxy完全适用于PrimitiveProxy,这下世界安静了。

10:30:49 已有0条回复
182020.10

用Algeb这个新轮子管理前端数据请求资源

一个前端项目需要管理一堆前端数据请求,现代前端应用,几乎没见过将数据请求直接写在业务代码中,大部分时候,我们都会将这些请求逻辑从业务代码中抽出来,集中管理。但随着业务开发的反复进行,我们会逐渐发现一些现象,我们对后端吐给我们的数据开始提出一些具体细节上的要求,就我个人而言,我总结出如下要求:

  • 如何避免同一个请求被多次发起?
  • 如何在某处发起请求,当数据回来后,另外一处使用了该请求数据的组件自动更新?
  • 如何在第一次渲染的时候就可以正常渲染?
  • 如何提供更优秀的编程体验和管理方式?

我在几年前写过一个库databaxe,提出一种新型的数据源理念,这种理念让我们可以写同步代码,把请求过程和数据进行分离,对前端而言,请求本身是不可见的。前端只需要从仓库中读取数据即可。但当时采用了具名方式规定每一个数据源的名称,获取参数对应关系比较复杂,需要监听,而且内置了axios作为数据请求器,对开发者而言是不开放的。

为了继续实践这种写同步代码的方式,同时使数据请求本身更开放,我写了algeb这个库

项目地址:gitee.com/frustigor/a…

它的源码比databaxe少了n倍,使用方法简单了n倍。让我们来看看,我是如何做到的。

数据源

我们大多情况下是通过请求后端API获取数据,但API并不是唯一的数据源,在前端编程中,客户端持久化数据(例如存在indexedDB中的数据),websocket推送的数据,都是重要的数据来源。因此,我们要寻找一种编程方式,可以兼容不同形式的数据源,将不同形式来源的数据,通过一套方式进行管理。

Algeb的方式是,将数据源和数据使用进行隔离,如何从数据源获取数据不在Algeb的管辖范围内,但是开发者需要将一个函数托管给它,这个函数从数据源得到该数据源的数据。也就是说,它不关心获取的过程,只关心结果,也就是这个函数的返回值就是我需要的最终数据。

import { source } from 'algeb'

const Some = source(function() {
  // ... 获取数据的函数,返回值即为被管辖的数据源数据
}, {
  name: '',
  age: 0,
})
复制代码

但是有一个非常常见的问题,我们管辖一个数据源,却可能通过不同参数获得不同对象。例如:

async function getBook(id) {
  return fetch(`/api/v2/books/${id}`).then(res => res.json()).then(body => body.data)
}
复制代码

这是我们常见的一个用于获取一本书详细信息的函数。我们经常会传入id来决定获取哪一本书的信息。而面对这种情况,我们怎么去用Algeb管理呢?难道要为每一本书建立一个源?

当然不需要,Algeb所认为的数据源,并非指单一数据,而是获取形式相同数据的方法(也就是这个函数),并且以该函数的参数作为标记记录该源所有被使用到的具体数据颗粒。这个逻辑是内部实现的,开发者不需要关心,只需要记住一点,数据源函数参数最好越简单越好,这样有利于对参数进行计算,作为识别具体数据的依据。

const Book = source(getBook, { 
  title: '',
  price: 0,
})
复制代码

source函数的第二个参数是该源的默认值,我所崇尚的同步代码书写方式要求代码在执行一开始就是OK的,不报错的,所以,这个默认值非常关键,同时,通过这个默认值,也可以告诉团队其他成员了解一个数据源将获取到的数据的基本格式。

你可能会问,websockt推送的数据怎么办呢?由于algeb只关心获取数据的结果,所以开发者怎么从websockt获取数据我们并不关心。我自己想到一种方式是,用一个全局变量保管不同数据源来自websockt的数据,然后在数据源函数中,读取该全局变量上的属性返回。

组合

通常情况下,我们现有的数据源管理器只是简单的读写逻辑,并没有规定数据缓存的逻辑。我希望通过更抽象的方式,让开发者自己来规定数据再次请求的逻辑。通过Algeb的compose方法,可以组合一个或多个数据源,并附增特殊逻辑进去。

import { compose, query, affect } from 'algeb'

const Order = compose(function(bookId, photoId) {
  const [book, refetchBook] = query(Book, bookId)
  const [photo, refetchPhoto] = query(Photo, photoId)
  
  affect(function() {
    const timer = setInterval(() => {
      refetchBook()
      refetchPhoto()
    }, 5000)
    return () => clearInterval(timer)
  }, [book, photo])
  
  const total = book.price + photo.price
  
  return { book, photo, total }
})
复制代码

这是compose的一个例子。它通过组合book和photo两个对象,并附加算出这个订单的总价格,作为一个新的数据源返回。从“数据源”的定义上,Book, Photo, Order都是数据源,本质相同,只是类型不同而已。

有一个约定,虽然compose的返回值可以是任意的,但是它一定是同步执行完后返回,所以compose不接受async函数。

但凡是数据源,就可以在环境中(compose/setup)使用query读取,query函数接收第一个参数为一个数据源对象,后面的参数将作为数据源函数的参数进行透传。它的返回值是一个两个元素的数组,第一个元素是数据源根据该参数返回的值,第二个参数是刷新数据源数据的触发器(非请求器)。

在环境中,还可以使用affect等hooks函数,这些函数在环境中执行,例如上面这段代码中,通过affect规定了Order这个数据源一旦被查询,就会每隔5秒钟再查一次。这样,我们通过compose,实际上定义了一个不仅可以获取值的数据源,还定义了该数据源刷新数据的方式。

compose让我们可以在获取一个值的同时,还会触发其他源的更新。这在一些场景下极其好用。例如,我们有A、B两个源,当我们提交对A的更新后,需要同时重新拉取A、B的新值。我们可以通过compose来处理。

const UpdateBook = compose(function(bookId, data, photoId) {
  const [book, refetchBook] = query(Book, bookId)
  const [_, refetchPhoto] = query(Photo, photoId)
  
  affect(function() {
    updateBook(bookId, data).then(() => {
      refetchBook() // 重新获取该书信息
      refetchPhoto() // 重新获取图像信息
    })
  })
})
复制代码

这个组合源只用于发送数据到服务端,发送成功后会同时抓取两个数据源的新数据。一旦新数据获取成功,所有依赖于对应数据颗粒(Book:bookId, Photo:photoId)的环境,全部都会被更新。

再算一次!

响应式应用框架的特征是自动将数值的变化反应为界面的变化。但如果你仔细观察我上述描述,就会发现,怎么实现响应式呢?这涉及到我们怎么去设计当数据源发生变化时,将这一变化产生的副作用即时反馈。

和常见的“观察者模式”不同,我借鉴的是react hooks的响应式方案,即基于代数效应的依赖响应。我们看react的functional组件,你会发现,它的响应式副作用,是“再算一次”!

再算一次!也就是组件function再执行一次,每次state被更新时,组件function被再次执行,得到新的组件树。神奇的“再算一次”特效,理论上会消耗更多性能,却让我们可以像撰写同步代码一样,从顶向底书写逻辑,并通过useEffect来执行副作用。

在Algeb中,我也是基于这种思路,但由于这是一个通用库,它不依赖框架,要去适应不同框架的差异,因此,我提供了一个setup提供执行上下文。

import { setup } from 'algeb'

setup(function() {
  const [some, refetchSome] = query(Some)
  
  affect(function() {
    console.log(some.price)
  }, [some.price])
  
  render(`<div>${some.price}</div>`)
})
复制代码

setup是所有algeb应用的入口,在setup之外使用algeb定义的源没有意义,甚至会报错。它接收的函数被成为执行宿主,这个宿主函数会被反复执行,它内部一定是会有副作用的,例如,上面这段代码,副作用就是render。当被query的数据颗粒获得新数据时,宿主函数会被再次执行,这样,就会产生新的副作用,从而反馈到界面上。

数据颗粒是指基于query参数的数据源状态之一,比如前面的Book这个源,每一个bookId会对应一个数据颗粒,每个数据颗粒保存着当前时刻该bookId的book的真实信息,一旦有任何一个地方触发了数据更新,那么就会让源函数再次执行,去获得新的数据,新数据回来之后,通过内部对比发现数据发生了变化,宿主函数就会再次执行,从而副作用生效。

如此循环往复,就会给人一种响应式的编程的感觉,而这种感觉,和传统的通过观察者模式实现的响应式具有非常大的感官差异,而这个差异,就是react践行的代数效应所带来的。

为了适应不同框架中更好的结合使用,我在库中提供了不同框架的使用。

React中使用

import { useQuery } from 'algeb/react'

function MyComponent(props) {
  const { id } = props
  const [some, fetchSome] = useQuery(SomeSource, id)
  // ...
}
复制代码

Vue中使用

import { useQuery } from 'algeb/vue'

export default {
  setup(props) {
    const { id } = props
    const [someRef, fetchSome] = useQuery(SomeSource, id)
    const some = someRef.value
    // ...
  }
}
复制代码

Angularjs中使用

const { useQuery } = require('algeb/vue')

module.exports = ['$scope', '$stateParams', function($scope, $stateParams) {
  const { id } = $stateParams
  const [someRef, fetchSome] = useQuery(SomeSource, id)($scope)
  $scope.some = someRef // { value }
  // ...
}]
复制代码

Angular中使用

import { Algeb } from 'algeb/angular' // ts

@Component()
class MyComponent {
  @Input() id

  constructor(private algeb:Algeb) {
    const [someRef, fetchSome] = this.algeb.useQuery(SomeSource, this.id)
    this.some = someRef // { value }
  }
}
复制代码

结语

在前端应用层和后端、持久化存储、websockt等原始数据交互时,对于前端而言,这种交互过程都是没有必要的,是和业务本身无关的副作用。Algeb这个库,试图用代数效应,参考react hooks的使用方法,实现前后端中间服务层的抽象。通过对数据源的定义和组合,以setup提供宿主,实现另一种风格的响应式。如果你认为这种抽象能激发起你一点点兴趣,不妨到仓库中一起讨论,写码。

12:30:54 已有4条回复
  1. 当props里的id 为undefined时, 请求也会发起.这种情况怎么处理比较好?
    #975 ID_1188 2020-11-03 16:01 回复
  2. source函数里面拦截,返回undefined对应的值,能否请求是数据源函数的任务,与外部环境无关
    #976 回复给#975 否子戈 2020-11-03 16:58 回复
  3. 偶尔会出现以下报错,应该是漏了forEach的参数i.

    index.js:89 Uncaught (in promise) ReferenceError: i is not defined
    at index.js:89
    at Array.forEach ()
    at index.js:86

    ```
    atom.hosts.forEach(host => {
    if (host.end) {
    // the host is destoryed
    atom.hosts.splice(i, 1);
    } else {
    host.next();
    }
    });
    ```
    #977 ID_1188 2020-11-04 10:31 回复
  4. 确实是个bug,我修下发个版本
    #978 回复给#977 否子戈 2020-11-04 11:09 回复
092020.10

可旋转的table(3D table模型)

302020.9

一种防抓取防复制的网页内容展示方法

https://codepen.io/tangshuang/pen/OJyxWjy

今天想到了这个方法,原理也很简单,就是将内容使用css的pseudo-elements保存,这种元素的conten无法被复制。

292020.9

react状态管理器stook

222020.9

js里面的Object.keys, getOwnPropertyNames, in 和 for...in

今天遇到一个bug,实在有些困惑,就去查了一下MDN。事情的起源是这样:

class Some {
get field() {}
}

然后我在一个遍历中,做了如下判断:

if (key in some) ...

我自以为是的认为 key in some, key = 'field' 应该是 false,然而,返回结果是 true。然后我又分别 Object.keys, getOwnPropertyNames 去看 some 的 keys,都没有 'field' 呀?

问题的根源在于 class getter 是定义在原型链上,也就是说 'field' 并不在 some 这个实例上面,而是定义在原型链上的 getter 方法,所以 Object.keys, getOwnPropertyNames 当然都看不到这个属性。

另外,我把 in 和 for...in 中的 in 搞混,导致按错误的理解执行错误的操作还是没有得到预期答案。for...in 会遍历对象以及对象原型链上 enumerable 为 true 的属性,从整条原型链查找这一点和 in 一致,但 in 判断时忽略 enumerable。

Object.keys, Object.getOwnPropertyNames 的区别也很简单,Object.getOwnPropertyNames 返回自己的属性列表,不包含原型链上游,而 Object.keys 最严格,只返回不包含原型链上游且 enumerable 为 true 的属性名列表。

17:34:29 已有0条回复
182020.9

探索类型系统的底层 - 自己实现一个 TypeScript

222020.8

《数字水印原理与技术》

Photo Copy 是一个用于给照片打水印的小软件,基于盲水印的实现原理,利用 electron 对 nodejs 的支持,利用 opencv 实现了盲水印。

上个月我发布了第一个预览版,没什么人用,但我自己还是很想把它做好,所以我在原来的基础上,重新梳理了一些点。其中最重要的一个点,是对原来垂直的图片进行了处理,之前垂直的图片放进去之后,直接横着打水印,导致水印不全,于是我想了一个办法,把图片竖过来,打完水印再翻回来,这样水印就可以完整的显示出来了。另外,之前没有支持拖拽上传,现在也支持了。你可以在这里下载。

19:40:24 已有0条回复
132020.8

解决react input受控组件无法输入中文

刚遇到 react input 受控组件无法输入中文的情况:

<input value={value} onChange={onChange} />

在网上搜索了很多,知道了问题原因,但是大部分文献都实际上没有解决问题。网上大部分文献,只解决了 onChange 的问题,但是没有解决 value 和 onChange 一起用的问题。value 属性导致 input 成为一个完完全全的受控组件。value 的值决定当前 input 的展示内容。因此,网上的文献只提供了一些参考,并不能解决问题本质。

因 value 属性导致无法输入中文的原因在于,react 会用 onChange 这个接口响应任意输入,而如果在此时修改 value 值,就会导致 DOM 的重绘(react 通过实时处理 input 的 DOM 节点的 value 属性值来保证重绘后使用正确的 value,你可以通过开发者工具查看这个 input 节点在输入时的变化),直接导致中文输入法框消失,输入框内变成刚才输入的英文字符(甚至带空格)。onChange 接管 input 的一系列属性,它不是单纯的 change 或 input 事件。当然,在 type=text 上,它主要功能是接管 input,但是 onInput 实际上还是有效。

我在 jqvm 中实际上也遇到这个问题。当时发现通过响应式方法替换真实 DOM 之后,原有的输入态会消失(没有 focus),所以后来解决办法是,保存原始真实 DOM,每次更新后用原来的 DOM 把新绘入的 DOM 给替换掉,再做一些输入态的处理(例如光标位置)。

显然,react 的处理方式不是这样,react 也保存了真实 DOM,但是每次更新 value 之后,会对 DOM 节点的 value 进行复杂加工。不过理论上,react 面临同样的问题,所以它应该也要解决这个输入态问题,这还会牵扯到 focus 和 blur,下面会提到。总而言之,要解决无法输入中文的问题,还是要从真实 DOM 入手解决。

import React, { useRef } from 'react'

export function Input(props) {
  const el = useRef(null)
  const { onChange, onFocus, onBlur, value, defaultValue, ...attrs } = props
  const _value = ('value' in props) ? value : ('defaultValue' in props) ? defaultValue : null
  const handleChange = (e) => {
    if (onChange) {
      onChange(e)
    }
  }
  const forceSetValue = () => {
    if ('value' in props && el.current) {
      const input = el.current
      input.value = value
      input.setAttribute('value', value)
    }
  }

  let inputing = false

  return (
    <input
      {...attrs}
      defaultValue={_value}
      ref={(input) => {
        if (!input) {
          return
        }

        el.current = input
        forceSetValue()
      }}

      // react 在focus/blur时会重新设值,如果没有下面的操作,会导致focus/blur之后,变空
      // TODO 由于是异步操作,会导致文字闪动,光标定位到最末尾
      onFocus={(e) => {
        setTimeout(forceSetValue, 10)
onFocus && onFocus(e) }} onBlur={(e) => { setTimeout(forceSetValue, 150)
onBlur && onBlur(e) }} onCompositionStart={() => { inputing = true }} onCompositionEnd={(e) => { inputing = false handleChange(e) }} onChange={(e) => { if (!inputing) { handleChange(e) } }} /> ) } export default Input

首先第一步,就是借鉴网上资料提供的方法,干掉 value,使用 defaultValue。完成这一步之后,就可以实现正常输入中文了(从这里可以看出无法输入中文的罪魁祸首还是受控机制)。接下来的问题就是

  • 既要能够通过 onChange 把修改后的值传出去,
  • 同时又要能保证 value 属性和真实 DOM 的 value 是同步的。

这里有个问题,input 组件的 defaultValue 属性会带来新的问题,如果由于切换视图当前组件被销毁了,再切换回来,此时由于 defaultValue 属性的逻辑,input 的显示内容不会变成新传入的 defaultValue 的值,而是仍然采用之前的值。假如你一进入界面的时候,defaultValue 是空的(defaultValue={value} value 为空),那么当你输入了一波,修改了 value,然后切走了,但是你想切回来重新继续输入(defaultValue={value} 此时 value 不为空),你会发现 input 内容空空如也。这是 defaultValue 的弊端,它让 input 是非受控的。这也是为什么,我们无论用 value 还是 defaultValue 都无法满足我们正常输入中文的问题。

通过 onChange 把修改后的值传出去这个好办。我在代码里面加了 compositions 控制,但是实际上不需要也无所谓,大不了就是 onChange 传出去的值是不怎么好看的字符串(带空格的拼音),加了 compositions 控制,保证每次 onChange 出去的都是完整的中文字符串,在拼音输入期间并没有触发 onChange,外部也不会接收到。所以,本质上,无法输入中文的问题,和 compositions 无关,这个结论可能和网上大部分资料的说法都相反。

保证当前这个 Input 组件的 value 和真实 DOM 的 value 的同步,则是我这个解决办法的核心,依托真实 DOM,直接将真实 DOM 节点的 value 和 value attribute 都修改为和外部传入的 value 值相等,这样这个组件虽然还是 react 组件,但是已经在 DOM 层面被我魔改了。通过这个魔改,解决了上面所说的 defaultValue 无法控制 input 的问题。现在,在形式上,Input 组件接收 value 和 onChange 两个 prop,它是一个完全支持中文输入的受控组件了。

不过 react 又干了些多余的事,在每次 focus 和 blur 时,都会去检查组件受控情况和 value 值,导致非受控组件的内部状态每次都是初始值(当第一次进来 defaultValue 为空时,那么这个 input 就一直都是空)。所以,我又加了 onFocus 和 onBlur 两个属性,在里面通过一个延时操作,在 react 完成自己的检查之后,我再去修改真实 DOM,解决这个问题。

简单总结,react 受控组件是一个大坑。

13:09:54 已有1条回复
  1. #974 labike 2020-10-24 11:08 回复