用Algeb这个新轮子管理前端数据请求资源
一个前端项目需要管理一堆前端数据请求,现代前端应用,几乎没见过将数据请求直接写在业务代码中,大部分时候,我们都会将这些请求逻辑从业务代码中抽出来,集中管理。但随着业务开发的反复进行,我们会逐渐发现一些现象,我们对后端吐给我们的数据开始提出一些具体细节上的要求,就我个人而言,我总结出如下要求:
- 如何避免同一个请求被多次发起?
- 如何在某处发起请求,当数据回来后,另外一处使用了该请求数据的组件自动更新?
- 如何在第一次渲染的时候就可以正常渲染?
- 如何提供更优秀的编程体验和管理方式?
我在几年前写过一个库databaxe,提出一种新型的数据源理念,这种理念让我们可以写同步代码,把请求过程和数据进行分离,对前端而言,请求本身是不可见的。前端只需要从仓库中读取数据即可。但当时采用了具名方式规定每一个数据源的名称,获取参数对应关系比较复杂,需要监听,而且内置了axios作为数据请求器,对开发者而言是不开放的。
为了继续实践这种写同步代码的方式,同时使数据请求本身更开放,我写了algeb这个库
它的源码比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提供宿主,实现另一种风格的响应式。如果你认为这种抽象能激发起你一点点兴趣,不妨到仓库中一起讨论,写码。
一种防抓取防复制的网页内容展示方法
https://codepen.io/tangshuang/pen/OJyxWjy
今天想到了这个方法,原理也很简单,就是将内容使用css的pseudo-elements保存,这种元素的conten无法被复制。
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 的属性名列表。
Photo Copy 是一个用于给照片打水印的小软件,基于盲水印的实现原理,利用 electron 对 nodejs 的支持,利用 opencv 实现了盲水印。
上个月我发布了第一个预览版,没什么人用,但我自己还是很想把它做好,所以我在原来的基础上,重新梳理了一些点。其中最重要的一个点,是对原来垂直的图片进行了处理,之前垂直的图片放进去之后,直接横着打水印,导致水印不全,于是我想了一个办法,把图片竖过来,打完水印再翻回来,这样水印就可以完整的显示出来了。另外,之前没有支持拖拽上传,现在也支持了。你可以在这里下载。
解决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 受控组件是一个大坑。
-
赞#974 labike 2020-10-24 11:08
-
发现第一次输入之后,鼠标移开的话,文字会消失,第二次移入又出现#988 Nelson 2020-12-10 18:18
-
有代码吗?给个代码看下#989 回复给#988 否子戈 2020-12-13 20:55
-
已经解决了,是我自己的问题#990 回复给#989 Nelson 2020-12-15 12:39
-
!!!太感谢了,真的好用,困扰了我一天的问题,您的讲解真的是太清楚明朗了!
基于jQuery的500行小型响应式框架jQvm
因为做一个用于生成水印的小型工具,使用到electron作为壳,在选ui框架的时候,当然是首先想到vue,但是经过一会儿使用后,发现vue机制在electron下面无法直接使用脚本引入方式,有些遗憾,于是打算直接使用jquery。但是写了一会儿之后,又觉得别扭,写react和vue太久,通过修改状态来触发界面重绘,这种方式实在太顺手了,现在去写jquery的代码,实在有些回不去的感觉。
于是乎,我自己写了一个基于jquery的响应式插件,也可以把它当作是一个小框架。
具体使用方法如下:
<script src="https://unpkg.com/jquery/dist/jquery.min.js"></script>
<script src="https://unpkg.com/jqvm/dist/jqvm.min.js"></script>
<template id="app">
<span>{{name}}</span>
<span>{{age}}</span>
<button>grow</button>
</template>
<script>
$('#app')
.vm({
name: 'tomy',
age: 12,
})
.on('click', 'button', state => {
state.age ++
})
.mount()
</script>
以上就是最简单的用法。模板定义和vue差不多,但是有个大的区别,vue直接在模板里面绑定事件,但是在jqvm中通过传统的jquery的on绑定事件。
它有一些内置的directive(指令),最常用的应该是jq-if了。
<template>
<div jq-if="isTouched">xxx</div>
</template>
还可以在mount 之前注册一些component和directive。
const { component, directive } = $.vm
component('icon', function(el, attrs) {
const { type } = attrs
return `<i class="icon icon-${type}"></i>`
})
directive('jq-link', function(el, attrs) {
const link = attrs['jq-link']
const to = this.parse(link) // this.parse 是一个内置服务,用来将字符串解析成 state 上的对应值
el.attr('href', to)
})
注册好之后,在模板里面使用。
<template>
<icon type="search"></icon>
<a jq-link="xxx">jump</a>
</template>
再来说说设计理念,我的想法,就是“简单”。使用简单,也别整特别多概念,虽然我前面提到vm, state之类的,一看基本上都能明白咋回事,拿过来就撸,别想那么多。不过还是需要再解释一下on那里怎么绑定事件,第三个参数是一个函数,这个函数接收state,这个state被修改就会重新渲染,这个函数返回一个函数,返回的这个函数就是正常情况下我们用 jquery.fn.on绑定的时候传入的那个函数。
$('#app')
.vm({ ... })
.on('click', 'button', state => function(e) {
const color = $(this).css('color')
state.color = color
// ...
})
这个小框架打包压缩之后,总体体积不到50k,可以说麻雀虽小,五脏俱全。也没有啥特别新潮的东西,在一些需要1分钟内开始界面编程的场景,再适合不过了。
如果你觉得这个项目有点意思,给个star吧。
-
牛逼!#1297 无 2023-11-12 02:31
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();
}
});
```