一个辅助v-model的自定义指令

有这样的需求,一个input输入框只能输入特定长度的内容,超过则无法输入。你可能会说不是有maxlength之类的属性嘛。但是maxlength只能在input[type=text]中使用,如果只能输入数字maxlength便会失效。

有没有办法干预v-model的默认行为,v-model有一些修饰符(lazy、number、trim),但是这些远远不够,vue2中也没有提供自定义修饰符的功能。怎么办呢?可以写一个自定义指令来辅助v-model,就叫它v-limit-input。

首先想到的是监听keydown事件,然后通过e.preventDefault()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div id="app">
<input type="text" v-model="value" @keydown="keyDown">
</div>
</template>
<script >
var app = new Vue({
el:"#app",
data(){
return {
value:"123"
}
},
methods:{
keyDown(e){
e.preventDefault()
}
}
})
</script>

这段代码在pc上完美运行,但是在移动端是没有用的,原因是移动端keydown事件触发时值已经输入到输入框里面了,这时再preventDefault已经晚了。所以只能替换input.value 来限制输入。

1
2
3
4
5
onKeyDown(e){
if(e.target.value.length >= this.limit){
this.el.value = this.el.value.slice(0,this.limit)
}
}

这段代码在pc上也是完美运行,但是在移动端安卓手机上,在keydown事件中立马改input.value是不生效的,需要延迟更改。

1
2
3
4
5
6
7
onKeyDown(e){
if(e.target.value.length >= this.limit){
setTimeout(()=>{
this.el.value = this.el.value.slice(0,this.limit)
},100)
}
}

这样就可以了。但是这样做有个新问题,我们是通过手动更改dom的方式修改input.value,并且是延时更改,完美绕过了vue的响应式系统,vue并不知道input.value已经改变了。

就在我就要放弃时,忽然灵机一动,可以使用自定义事件呀。

1
2
3
4
5
6
7
8
9
onKeyDown(e){
if(e.target.value.length >= this.limit){
setTimeout(()=>{
this.el.value = this.el.value.slice(0,this.limit)
const event = new Event('input')
this.el.dispatchEvent(event)
},100)
}
}

这样就能触发vue的响应式系统了。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<template>
<div id="app">
<input type="number" v-model="value" v-limit-input="5">
</div>
</template>
<script >
class LimitInput{
constructor(el,limit=3){
this.el = el
this.limit = parseInt(limit)
this.event = new Event('input')

this.onKeyDown = this.onKeyDown.bind(this)
this.el.addEventListener('keydown',this.onKeyDown)
}
onKeyDown(e){
if(e.target.value.length >= this.limit){
setTimeout(()=>{
this.el.value = this.el.value.slice(0,this.limit)

this.el.dispatchEvent(this.event)
},100)
}
}
destroy(){
this.el.removeEventListener('keydown',this.onKeyDown)
}
}

LimitInput.instances = new Map()

LimitInput.setInstance = (time,instance)=>{
LimitInput.instances.set(time,instance)
}

LimitInput.getInstance = (time)=>{
LimitInput.instances.get(time)
}

export default {
el:"#app",
data(){
return {
value:"123"
}
},
methods:{

},
directives:{
'limit-input':{
bind(el,{value}){
const ins = new LimitInput(el,value)
const time = Date.now()
el.dataset.time = time
LimitInput.setInstance(time+'',ins)
},
unbind(el){
const time = el.dataset.time
const ins = LimitInput.getInstance(time)
if(ins) ins.destroy()
}
}
}
}
</script>

真是希望越大,失望就越大,this.el.value = this.el.value.slice(0,this.limit) 这句代码有问题。假如用户在输入框的前面或者中间输入值,替换的结果就很令人费解。卒。

关于小程序web组件与原生组件的层级问题

最近做小程序,有一些动画需求,固然小程序有自己的动画api,但是,考虑到时间问题,直接用lottie动画了。

lottie-miniprogram
虽然一些复杂动画,渲染出来效果略差,但是大多数场景下效果还可以。跟web上使用方法差不多,只能用canvas渲染。

但是这样做有个问题,组件的层级问题,在lottie动画之上,很可能出现modal、toast之类的自定义组件(小程序自带的太难看),而canvas作为原生组件,层级是高于web组件的,为了解决这个问题,微信小程序推出了同层渲染,懂原生的可以去网上搜一下相关技术,貌似android/ios各自有各自的实现。

jsx
1
2
3
4
5
<Canvas type={'2d'} canvasId={'xxx'}>
<View>
<Text>cover</Text>
</View>
</Canvas>

写了一段Taro的伪代码,像上面那样,cover会覆盖在原生组件canvas之上。但是大多数场景,基于代码的可读性,我不想把某个modal写在一个毫不相干的canvas内部,还有没有其他办法,当然有。

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Canvas type={'2d'} id={'animate'} canvasId={'animate'} ref={ref=>{
// 初始化lottie
}}>

</Canvas>
<Canvas type={'2d'} id={'xxx'} canvasId={'xxx'}>
<Modal>
<View>
<Text>
modal content
</Text>
</View>
</Modal>
</Canvas>

像上面那样,既然原生组件的层级高于web组件,那么就把modal组件变成原生组件(基于同层渲染),这样做可以解决一部分问题,但又迎来了新问题,套在canvas内部的modal组件在出现时是没有动画的。

假如是裸的modal组件,出现时该怎么添加动画,可以参照taro-ui的做法。

modal.scss

出乎意料,用的是css的transition,挺简洁的思路。这样做,在android上是没有问题的。但是在iOS上,给canvas设置了visibility: hidden,canvas并不会消失,而是像幽灵一样盖在页面上,使页面上的其他元素不可交互(可能是android/ios实现同层渲染的技术细节不一样导致的)。

这可怎么办,最后还是利用css解决了。办法就是给Modal外面的Canvas绝对定位,然后宽高都为0,使其在视觉上消失,但在template代码层次上,还是符合同层渲染的规则。这样就解决了以上问题。

偶遇一段js代码

最近在学java(进度真龟速),看到java的try catch,发现有段代码很奇怪,用js写出来是这样的

1
2
3
4
5
6
7
8
9
10
11
12
function test(a){
try {
a == 1
return 1
} catch (e){
return 2
} finally {
return 3
}
}

console.log(test(1));

结果是3,这还真是我曾经遇到的一道面试题。这么多年过去了,我不知道这道题的答案对我的技术有啥影响。事实上,我根本就不会写出这样的代码,想都想不到,这些出题人的出题角度真奇怪。不问真正有用的知识,反而是通过一些奇奇怪怪的东西来“筛选”人。

突然想到一个面试题

前几天写小程序有一场景是这样的:首页分为好几个模块,每个模块的数据分别由不同的接口负责,这种场景很自然的想到Promise.all,速度快,还能很好的控制loading。但是Promise.all有个特性就是一旦某个promise reject了,整个all任务都会reject,在这种场景下Promise.all的这种特性显然是不能接受的。

试想一下,假设有10个promise,第二个reject了,剩下8个全不执行了,这样的体验确实不太好。那怎么改进呢,我们需要自己造一个Promise.all,需要达到这样的效果,假设某个promise reject了,不结束整个任务,但是记录错误信息。

这让我想起了一道面试题,面试官的意思大概是:有一个批量上传的tasks,假设某个任务上传失败了,不影响其他的任务,等上传结束后,需要知道成功与失败的情况。

上代码

1
2
3
4
5
6
7
8
9
10
Promise.friendlyAll = (promises=[])=>{
const newPromises = promises.map((promise,index)=>{
return promise.then(res=>{
return Promise.resolve({err:null,data:res})
}).catch(err=>{
return Promise.resolve({err:err,data:null})
})
})
return Promise.all(newPromises)
}

java学习笔记

初始化arrayList

java里的arrayList其实就是js里的“数组“,js是没有真正意义的数组的。那java怎样初始化一个arrayList,标椎的语法是这样的

1
2
3
4
List<String> arrayList = new ArrayList<>();
arrayList.add("a");
arrayList.add("b");
System.out.println(arrayList);

好难受,就不能像js那样 const array = [“a”,”b”,”c”] 在定义变量的时候初始化一堆元素吗?经过一番搜索还真有这样的api,需要这样写

1
2
List<String> arrayList = Arrays.asList("a","b");
System.out.println(arrayList);

看起来没什么问题,但是只要试图操作arrayList就会报错

1
2
3
List<String> arrayList = Arrays.asList("a","b");
arrayList.remove("b");
System.out.println(arrayList);

6e17ef81-83cb-465b-a455-90166d5b4404-image.png

意思是说,Arrays.asList返回一个固定大小的列表,所以必须这样写

1
2
3
List<String> arrayList = new ArrayList<>(Arrays.asList("a","b"));
arrayList.remove(1);
System.out.println(arrayList);

不小心创建的git submodule

事情是这样的。一个项目本来是我独立维护的(项目A,有自己的.git),结果被告知需要放到别的项目内部,然后copy大法,整个目录搬过去,想都没想git add,后果就是这个目录会被add成一个git submodule,然后病急乱投医,下意识的删掉项目A下的.git目录。

这样问题就出现了。如果不执行其他的操作,这样push上去的代码项目A还是一个submodule,需要执行git rm –cached 子模块名称,注意子模块名称要写完整路径(假如项目A目录嵌套较深的话),执行完这条命令后,git会认为项目A是个正常的目录,而不是submodule,但是webstorm还是会把它当成submodule,需要打开Settings -> Version Control -> Directory Mappings 删掉多余的映射,这样webstorm也正常了。

上述做法由于直接删除了项目A的.git目录,所以项目A以前的 git history会全部丢失,如果不想丢失,参见这个问题

微信小程序线上出问题了

事情是这样的。还是小程序(Taro写的),有这样一个场景,小程序冷启动时需要先调一个接口(login)拿用户的权限以及其他必要的信息(类似于自动登录),后续接口http header里需要带上这些信息。这在网页里太正常不过了,但是小程序和普通的单页面web应用不一样,小程序需要配一个入口页,假设是pages/index/index,冷启动时app.js和index几乎是同时执行的。

网上推荐的做法都是在app.js的onLaunch里自动login,通过上面的分析可以得出,app.js和index的执行顺序是无法保证的。有没有其他办法呢,当然有,你可能需要一个空页面充当“路由器”(就叫launch好了),把入口页换成这个,在这个页面里自动登录然后再redirectTo。实际执行过程中这个页面会一闪而过,时间取决于login接口的快慢,所以最好在上面放点东西装饰装饰。

有一些场景是必须保证顺序的,比如通过外部的带参小程序码跳入某一特定页面(就叫pages/foo/foo),以前都是直接配置二维码跳转链接为pages/foo/foo,现在全都先进到launch,再由launch通过二维码参数决定进到哪个页面。

小程序有个比较坑的地方,就是小程序没有发布,通过微信自带的扫一扫不能打开小程序。为此,我们专门提供了内部扫码,即先通过wx.scanCode,然后还是跳转到launch,这样进入launch的方式就有两种,在launch的onShow周期里我写了这样一段代码

经过后来仔细的看文档,发现未发布的小程序也能扫码进入,只不过后端生成二维码时需要指定env_version=trial,文档在这里

1
2
const {query} = Taro.getLaunchOptionsSync()
const {scene,page} = Object.keys(query).length === 0 ? Taro.getCurrentInstance().router.params : query

乍一看,没啥问题,优先检查getLaunchOptionsSync有没有参数,如果没有,再检查launch的路由信息。实际上Taro会缓存getLaunchOptionsSync的参数,只要没有再launch,后续再访问Taro.getLaunchOptionsSync().query都是有值的,所以把逻辑反过来就可以了。

1
2
3
const {query} = Taro.getLaunchOptionsSync()
const {params} = Taro.getCurrentInstance().router
const {scene,page} = Object.keys(params).length === 0 ? query : params

尝鲜android jetpack compose

听说android那边也开始流行声明式UI了,而且强制kotlin,不管怎样,先找个demo跑起来。网上偶遇一大神的TodoList demo,可惜没有源码。所以只能照着人家的视频,咔咔咔手敲代码,经过一番思想斗争(kotlin语法还是太陌生),代码终于没有错误了,但是却编译不通过

816213ac-3549-43a2-a347-fec82e603e35-2043bb6b9acddefcd9a6a2d7e1f2196.jpg

看错误,好像说是什么依赖的版本不一致,但是作为新手,我又不知道在什么地方改,此处省略一万字。最后发现我机器上的kotlin版本和build.gradle plugins里声明的kotlin版本不一致,改成一样就可以了。

3e38965e-1857-4175-a631-802e9198140c-image.png
7ea4dfa1-43ea-4f78-bbcf-7e5c5c177d20-image.png

这时又报了另一个错,说是compose版本与kotlin版本不一致,有了前车之鉴,这次顺利多了,于是搜到这个网站 ,问题解决。

如何在小程序里(taro)使用antv f2

为什么要用antv f2,其他的像echarts、ucharts之类的不行吗?相比于后两个,f2的文档友好,专为移动端而生,并且体积也小。

f2官网有个“如何在小程序中使用”的示例,不过这个指的是在原生小程序中如何使用f2,和taro的用法还不太一样。网上有人写了一个兼容taro的库,taro-f2 ,看起来挺美好的,但是看commit三年都没更新了,issues里也是各种与taro3不兼容的声音。

抱着试一试的态度,下载taro-f2并运行,报了各种错,抱着不见黄河不落泪的态度,去看F2Canvas的实现。

代码不是很长,如果不考虑h5,代码更短。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import Taro  from '@tarojs/taro'
import {Canvas} from '@tarojs/components'
import PropTypes from 'prop-types';
import Renderer from './lib/renderer'; // 由于f2被设计是运行在浏览器环境里的,这个类的作用是抹平小程序环境与浏览器的差异,使f2能运行在小程序环境。
import './f2-canvas.css';

interface F2CanvasPropTypes {
onCanvasInit: (canvas: any, width: number, height: number, $scope: any) => any,
}

function randomStr (long: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const maxPos = chars.length;
var string = '';
for (var i = 0; i < long; i++) {
string += chars.charAt(Math.floor(Math.random() * maxPos));
}
return string;
}

export default class F2Canvas extends Component<F2CanvasPropTypes> {
static defaultProps = {
onCanvasInit: () => {},
};
static propTypes = {
onCanvasInit: PropTypes.any,
};

state = {
width: '100%',
height: '100%;',
};

id: string = 'f2-canvas-' + randomStr(16);
canvas: any;
static fixF2: (F2: any) => any;

componentWillMount () { // 核心代码片段
if (process.env.TARO_ENV !== 'h5' ) {
setTimeout(()=>{
const query = Taro.createSelectorQuery()
query.select('#'+this.id).boundingClientRect().exec(res => {
const ctx = Taro.createCanvasContext(this.id, this.$scope); // 等canvas渲染出来后,拿到context对象
const canvasWidth = res[0].width;
const canvasHeight = res[0].height;
const canvas = new Renderer(ctx, process.env.TARO_ENV); // 包装context
this.canvas = canvas;
this.props.onCanvasInit(canvas, canvasWidth, canvasHeight, this.$scope); // 调用onCanvasInit方法初始化chart
});
},1)
}
}

componentDidMount () { }

componentWillUnmount () { }

componentDidShow () { }

componentDidHide () { }

touchStart(e){
if (this.canvas) {
this.canvas.emitEvent('touchstart', [e]);
}
}
touchMove(e){
if (this.canvas) {
this.canvas.emitEvent('touchmove', [e]);
}
}
touchEnd(e){
if (this.canvas) {
this.canvas.emitEvent('touchend', [e]);
}
}
press(e){
if (this.canvas) {
this.canvas.emitEvent('press', [e]);
}
}

htmlCanvas(canvas){
if(!canvas) return;
setTimeout(() => {
this.canvas = canvas;
this.props.onCanvasInit(canvas, canvas.offsetWidth, canvas.offsetHeight, this.$scope)
}, 1)
}

render () {
const id = this.id;
if (process.env.TARO_ENV === 'h5') {
return <canvas ref={this.htmlCanvas.bind(this)} style={{ width: this.state.width, height: this.state.height }} className={'f2-canvas ' + id}></canvas>
}
if (process.env.TARO_ENV !== 'h5') {
return <Canvas style={'width: '+this.state.width+'; height:'+this.state.height}
className='f2-canvas' canvasId={id}
id={id}
onTouchStart={this.touchStart.bind(this)}
onTouchMove={this.touchMove.bind(this)}
onTouchEnd={this.touchEnd.bind(this)}
onLongPress={this.press.bind(this)}
/>;
}
}
}

明白了原理,再来看为什么报错,首先我用的是taro vue语法,他这个组件明显是react写的,很明显不兼容,而且即使是react,他这个写法也早就不兼容了,所以我把f2-canvas.tsx大概翻译了一下,搞了个f2-canvas.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<template>
<canvas
:style="{width,height}"
class='f2-canvas'
:canvasId="id"
:id="id"
:onTouchStart="touchStart"
:onTouchMove="touchMove"
:onTouchEnd="touchEnd"
:onLongPress="press"
/>
</template>
<script>
import Taro from '@tarojs/taro'
import Renderer from "./renderer";

function randomStr (long) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const maxPos = chars.length;
var string = '';
for (var i = 0; i < long; i++) {
string += chars.charAt(Math.floor(Math.random() * maxPos));
}
return string;
}

export default {
data(){
return {
width: '100%',
height: '100%;',
id: 'f2-canvas-' + randomStr(16),
TARO_ENV: process.env.TARO_ENV
}
},
props:{
onCanvasInit:{
type: Function,
default: ()=>{
return ()=>{}
}
}
},
beforeMount() {
if (process.env.TARO_ENV !== 'h5' ) {
setTimeout(() => {
const query = Taro.createSelectorQuery()
query.select('#' + this.id).fields({node:true,size:true}).exec(res => {

const ctx = Taro.createCanvasContext(this.id);
const canvasWidth = res[0].width;
const canvasHeight = res[0].height;
const canvas = new Renderer(ctx, process.env.TARO_ENV);
this.canvas = canvas;
this.onCanvasInit(canvas, canvasWidth, canvasHeight);
});
}, 300)
}
},
methods:{
touchStart(e){
if (this.canvas) {
this.canvas.emitEvent('touchstart', [e]);
}
},
touchMove(e){
if (this.canvas) {
this.canvas.emitEvent('touchmove', [e]);
}
},
touchEnd(e){
if (this.canvas) {
this.canvas.emitEvent('touchend', [e]);
}
},
press(e){
if (this.canvas) {
this.canvas.emitEvent('press', [e]);
}
}
}
}
</script>
<style>
:host, .f2-canvas{
width: 100%; height: 100%;
}
</style>

renderer.ts

1
2
3
4
5
6
7
8
9
10
11
import Renderer from 'taro-f2/dist/weapp/components/f2-canvas/lib/renderer'

export default class CustomRenderer extends Renderer {
addEventListener(t,e){
this.addListener(t,e)
}
removeEventListener(t,e){
this.removeListener(t,e)
}
}

这样就可以像taro-f2例子里那样用了,还有一点要注意,文档里有这一行:

1
2
3
// ⚠️ 别忘了这行
// 为了兼容微信与支付宝的小程序,你需要通过这个命令为F2打补丁
fixF2(F2);

看一下这个fixF2做了什么,源码

看起来像是打了一些补丁,查阅小程序文档,貌似从基础库2.9.0开始缺失的方法已经补齐了。所以不加fixF2也可以。

这样f2就可以在小程序里用了,不过这种方法也有瑕疵,f2-canvas中的canvas组件不能用2d模式,略遗憾。

2022.09.22 补充2d模式

taro-f2小程序端的实现完全照搬了wx-f2,而后者github上的demo是支持2d模式的,代码 。再来看看初始化chart的代码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ready() {
const query = wx.createSelectorQuery().in(this);
query.select('.f2-canvas')
.fields({
node: true,
size: true
})
.exec(res => {
const { node, width, height } = res[0];
const context = node.getContext('2d');
const pixelRatio = wx.getSystemInfoSync().pixelRatio;
// 高清设置
node.width = width * pixelRatio;
node.height = height * pixelRatio;

const config = { context, width, height, pixelRatio };
const chart = this.data.onInit(F2, config);
if (chart) {
this.chart = chart;
this.canvasEl = chart.get('el');
}
});
}
//......

onInitChart(F2, config){
const chart = new F2.Chart(config);
//....
}

看到没有,初始化Chart时只需提供context即可,这样连Renderer都省了,这样就可以摆脱taro-f2,最终的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<template>
<view :style="{height:'200px'}">
<canvas
:style="{width,height}"
class='f2-canvas'
:id="id"
type="2d"
/>
</view>
</template>
<script>
import Taro from "@tarojs/taro";
import F2 from "@antv/f2/lib/index-all";

function randomStr (long) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const maxPos = chars.length;
var string = '';
for (var i = 0; i < long; i++) {
string += chars.charAt(Math.floor(Math.random() * maxPos));
}
return string;
}
export default {
data(){
return {
width: '100%',
height: '100%;',
id: 'f2-canvas-' + randomStr(16),
}
},
onReady(){
const query = Taro.createSelectorQuery()
query.select('#' + this.id).fields({node:true,size:true}).exec(res => {

const { node, width, height } = res[0];
const context = node.getContext('2d');
const pixelRatio = Taro.getSystemInfoSync().pixelRatio;

node.width = width * pixelRatio;
node.height = height * pixelRatio;

const config = { context, width, height, pixelRatio };

this.drawPie(F2, config);
});
},
methods:{
drawPie(F2,config){
const data = [
{
category:'蛋白质',
value: 62.8,
proportion: 17.7,
recommended_ratio: '10-15',
a:'1'
},
{
category:'脂肪',
value: 22.5,
proportion: 6.4,
recommended_ratio: '10-15',
a:'1'
},
{
category:'碳水化合物',
value: 268.8,
proportion: 75.9,
recommended_ratio: '10-15',
a:'1'
}
]
const chart = new F2.Chart(config);
chart.source(data)
chart.coord('polar',{
transposed: true
})
chart.legend(false)
chart.axis('value',{
label(){}
})
chart.pieLabel({
sidePadding: 40,
label1(data,color){
return {
text: data.value,
color
}
},
label2(data,color){
return {
text: data.category,
color
}
}
})
chart.interval().position('a*value').adjust('stack').color('category',['#1890FF', '#13C2C2', '#2FC25B'])

chart.render()
},
}
}
</script>

完美。

2022.11.13 补充高版本antv f2用法

之前的实现看起来挺完美的,实际上有两个隐患:

隐患1:上述用法只适用于3.x版本的f2,而最新的版本都是4.x,低版本的bug官方不再维护了。

隐患2:基于隐患1,确实发现了一些问题,所有touch相关的操作都有bug,例如tooltip功能。

所以又抱着试一试的态度,尝试在微信小程序里引入4.x版本的f2。经过几天的挣扎,终于成功了。先吐槽一下阿里,f2实际上是支持小程序的,只不过文档混乱,3.x版本与4.x版本关于小程序的兼容竟然是两份代码,并且处在不同的仓库,也没有任何说明。

3.x

4.x

4.x关键代码 , 只看这一行,其他的和3.x差不多。作者封装了个原生组件,如果要在taro内部使用,直接引这个原生组件就可以了。4.x最大的亮点就是支持jsx,我们要在上层代码中通过onRender把jsx转换成可运行js,wx-f2调用onRender然后生成canvas即可。

antv/f2暴露出一个createElement方法,然后我们在上层代码中这样写就可以了

1
2
3
4
5
<f2 onRender={()=>{
return createElement(Line,{
data:data
})
}}>

实际上这种写法不太行,有两个原因,一个是数据都是异步返回的(除非data是写死的),f2 ready执行时data基本上是空的,另一个原因是,在taro里面引入原生组件props是不支持函数的,参见这个issues 。所以要么像issue里面那样欺骗编译器,在运行时动态传递函数,要么用自定义事件通讯。我觉得后者好一点,来看代码

f2原生组件的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import { Canvas } from '@antv/f2';

function wrapEvent(e) {
if (!e) return;

if (!e.preventDefault) {
e.preventDefault = function () {};
}

return e;
}

Component({
/**
* 组件的属性列表
*/

/**
* 组件的初始数据
*/
data: {},
ready: function ready() {
var _this = this;

var query = wx.createSelectorQuery().in(this);
query.select('.f2-canvas').fields({
node: true,
size: true
}).exec(function (res) {
var _res$ = res[0],
node = _res$.node,
width = _res$.width,
height = _res$.height;
var context = node.getContext('2d');
var pixelRatio = wx.getSystemInfoSync().pixelRatio; // 高清设置

node.width = width * pixelRatio;
node.height = height * pixelRatio;

// var children = _this.data.onRender(_this.data);

var canvas = new Canvas({
pixelRatio: pixelRatio,
width: width,
height: height,
context: context,
// children: children
});
canvas.render();
_this.canvas = canvas;
_this.canvasEl = canvas.canvas.get('el');
_this.triggerEvent("init", {
canvas
}); //加了这一行,canvas初始化完成后,通过triggerEvent往外抛事件。
});
},
observers: {
// 处理 update
'**': function _() {
var canvas = this.canvas,
data = this.data;
if (!canvas) return;
var children = data.onRender(data);
canvas.update({
children: children
});
}
},
lifetimes: {
detached: function detached() {
var canvas = this.canvas;
if (!canvas) return;
canvas.destroy();
}
},

/**
* 组件的方法列表
*/
methods: {
click: function click(e) {
var canvasEl = this.canvasEl;

if (!canvasEl) {
return;
}

var event = wrapEvent(e); // 包装成 touch 对象

event.touches = [e.detail];
canvasEl.dispatchEvent('click', event);
},
touchStart: function touchStart(e) {

var canvasEl = this.canvasEl;

console.log(e,canvasEl)

if (!canvasEl) {
return;
}

canvasEl.dispatchEvent('touchstart', wrapEvent(e));
},
touchMove: function touchMove(e) {
var canvasEl = this.canvasEl;

if (!canvasEl) {
return;
}

canvasEl.dispatchEvent('touchmove', wrapEvent(e));
},
touchEnd: function touchEnd(e) {
var canvasEl = this.canvasEl;

if (!canvasEl) {
return;
}
canvasEl.dispatchEvent('touchend', wrapEvent(e));
}
}
});

上游代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default ()=>{
return (
<View style={{
height: Taro.pxTransform(300)
}}>
<f2 onInit={({detail})=>{
const {canvas} = detail //也可以保存canvas,在必要的时候update
canvas.update({
children: createElement(Line,{
data: chartData
})
});
}}/>
</View>
)
}

上游line组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {Chart, Axis, Line, Legend,Tooltip} from '@antv/f2';

export default (props) => {
const { data } = props;
return (
<Chart data={data}>
<Axis
field="date"
tickCount={5}
type={'timeCat'}
/>
<Axis field="cost" tickCount={5} />
<Line x="date" y="cost" color="type" />
<Legend
position="bottom"
clickable={true}
/>
<Tooltip/>
</Chart>
);
};

比上次更完美一点。

2022.11.14 针对昨天的成果进行补充

昨天的做法看起来挺好的,但是目前只能用于Taro react,原因就是编译jsx出现了问题,react是用babel编译jsx的,而vue为了兼容jsx,搞了个@vue/babel-preset-jsx,这玩意会把line组件编译成functional component,编译结果大概长这样

1
2
3
4
{
"functional": true,
"render": ()=>{}
}

把这玩意再交给f2的createElement处理,内部直接报错了。所以要么不要在taro vue中用f2,要么不要用jsx。

有没有更好的解决方案,当然有,我们可以写两份babel config,一份针对普通的js文件,一份针对chart函数,为了把chart函数与普通的js文件彻底分开,自定义一个chart文件,比如上面的line组件(line.js),现在就叫line.chart。然后改一下根目录的babel.config.js

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
presets: [
['taro', {
framework: 'vue',
ts: true
}]
],
exclude:[
/\.chart$/ // 针对普通js的配置,排除chart文件
]
}

config/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const config = {
//...
mini:{
webpackChain(chain,webpack){
chain.merge({
module: {
rule: {
chart: {
test: /\.chart$/,
use: [{
loader: 'babel-loader',
options: {
presets: [
['taro', {
framework: 'vue',
ts: true,
vueJsx:false //禁用@vue/babel-preset-jsx
}]
],
plugins:[
[
'@babel/plugin-transform-react-jsx', //改用@babel/plugin-transform-react-jsx编译chart文件中的jsx
{
"runtime": "automatic",
"importSource": "@antv/f2"
}
]
]
}
}]
}
}
}
})
},
}
//...
}

这样就好了。

2022.11.20 更新

目前来说,在微信小程序中使用antv f2解决方案已经稳定了,考虑过写一个npm 包来给taro使用,毕竟网上那个已经n久不更新了,但是回头又想写这个东西意义不大,毕竟f2官方已经给出了原生组件demo,把这玩意再翻译成taro代码,taro还分vue与react版本,最后写出来一大堆重复代码,所以我决定仅仅贴一下我目前的代码可能更好。

原生组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import { Canvas } from '@antv/f2';

function wrapEvent(e) {
if (!e) return;

if (!e.preventDefault) {
e.preventDefault = function () {};
}

return e;
}

Component({
/**
* 组件的属性列表
*/

/**
* 组件的初始数据
*/
properties:{
childrenData:{
type: Object
}
},
data: {
id:Math.random().toString(36).slice(-8)
},
ready: function ready() {
var _this = this;
setTimeout(()=>{
var query = wx.createSelectorQuery().in(this);
query.select(`#f2-canvas-${this.data.id}`).fields({
node: true,
size: true
}).exec(function (res) {
var _res$ = res[0],
node = _res$.node,
width = _res$.width,
height = _res$.height;
var context = node.getContext('2d');
var pixelRatio = wx.getSystemInfoSync().pixelRatio; // 高清设置

node.width = width * pixelRatio;
node.height = height * pixelRatio;
var canvas = new Canvas({
pixelRatio: pixelRatio,
width: width,
height: height,
context: context,
children: _this.data.childrenData
});
canvas.render();
_this.canvas = canvas;

_this.canvasEl = canvas.canvas.get('el');
});
},300)
},
observers: {
// 处理 update
'**': function _() {
if (!this.canvas) return;
this.canvas.update({
children: this.data.childrenData
});
}
},
lifetimes: {
detached: function detached() {
var canvas = this.canvas;
if (!canvas) return;
canvas.destroy();
}
},

/**
* 组件的方法列表
*/
methods: {
click: function click(e) {
const canvasEl = this.canvasEl;
if (!canvasEl) {
return;
}
const event = wrapEvent(e);
const { detail, target } = e;
const { x, y } = detail;
const { offsetLeft = 0, offsetTop = 0 } = target;
// 包装成 touch 对象
event.touches = [{ x: x - offsetLeft, y: y - offsetTop }];
// https://github.com/antvis/F2/issues/1517
canvasEl.dispatchEvent('click', event);
},
touchStart: function touchStart(e) {

var canvasEl = this.canvasEl;

if (!canvasEl) {
return;
}

canvasEl.dispatchEvent('touchstart', wrapEvent(e));
},
touchMove: function touchMove(e) {
var canvasEl = this.canvasEl;

if (!canvasEl) {
return;
}

canvasEl.dispatchEvent('touchmove', wrapEvent(e));
},
touchEnd: function touchEnd(e) {
var canvasEl = this.canvasEl;

if (!canvasEl) {
return;
}

canvasEl.dispatchEvent('touchend', wrapEvent(e));
}
}
});
1
2
3
4
5
6
7
8
9
10
11
12
<canvas
type="2d"
class="f2-canvas"
id="{{'f2-canvas-'+id}}"
bindtap="click"
bindtouchstart="touchStart"
bindtouchmove="touchMove"
bindtouchend="touchEnd"
>
<slot></slot>
<!-- 不加slot会报一个没有slot的警告-->
</canvas>
1
2
3
4
.f2-canvas {
width: 100%;
height: 100%;
}

上游代码(vue),别忘了babel配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<view class="charts">
<f2 :children="charChildren"/>
</view>
</template>
<script>
export default {
data(){
return {
charChildren:null
}
},
created(){
fetch().then(res=>{
this.charChildren = createElement(LineChart,{
data: res
})
})
}
}
</script>

react的写法大同小异。

前两天写chartgpt web clinet,遇到了一点问题

我们知道chartgpt官网有个很好的效果就是,ai回答的文字是一个字一个字蹦出来的,这并不是前端刻意用css模拟的效果。

由于chartgpt不对中国开放,虽然可以科学上网,但是由于使用的人太多了,问答老是error,后来听说用api访问很稳定,事实也是如此。于是咔咔咔经过几天的奋斗终于完成了一个简单的问答界面,结果被同事吐槽响应太慢了。

后端并不是我的强项,然而chartgpt的api是支持流(stream)的,一旦与chartgpt的服务器连接,响应结果会逐个token吐给前端,刚才说的一个字一个字蹦出来的效果就是利用这一特性做的,这样不仅提高了用户体验还加快了访问速度。

请求chartgpt api用到了EventSource 这项技术,简言之,这个api会提供一个message钩子,token就是通过这个逐一吐给前端的。前端实现也很简单,以前是等所有结果全部返回来一次渲染出来,现在需要在message里不停的拼字符串,然后setState。

然而实际使用中发现ai的回答老是丢字,最初以为是markdown渲染器的问题,在换了好几个markdown渲染器,但是问题依然存在后,开始考虑是我代码有问题,毕竟没有开启stream时是没有这个问题的。

后来经过一番挣扎,发现是react setState自动合并导致的问题,我们知道在react里面多次调用setState,react会合并这些更新,然后只执行一次更新,实际项目里也没人会刻意多次调用setState,但是这个stream太快了,在一次event loop里竟然有2到3次setState,这样react为了性能考虑,自然会合并更新,这就是丢字的原因。

知道了原因,解决方案也很简单,就是让setState不发生合并,据观察,同一个event loop里的seState会发生合并,用setTimeout把每次的setState分开,就不会发生合并了(react18之前在setTimeout里面的多个setState是同步执行的,不会发生更新合并,react18即使是setTimeout里面的更新也是会合并的)。但是一次更新只渲染一个字有点太浪费了,有没有办法能控制stream的频率,这种场景rxjs再适合不过了(一直久仰大名,这次终于找到独一无二的应用场景了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
let sub 
const source = new Observable(obsrver=>{
fetchEventSource(`${HOST_URL}/api/chat`,{
...opts,
signal: controller.signal,
onopen(){
obsrver.next({
type:'open'
})
},
onmessage(message){
if (message.data === '[DONE]'){
obsrver.next({
type:'complete',
msgId,
conversationId,
reply
})
obsrver.complete()
controller.abort();

}
else {
const msg = JSON.parse(message.data)
if(typeof msg === 'string'){
obsrver.next({
type:'message',
message: msg,
})
}
else {
msgId = msg.messageId || ''
conversationId = msg.conversationId || ''
reply = msg.response
}
}
},
onerror:obsrver.error,
onclose:()=>{
sub.unsubscribe()
source = null
}
})
})
sub = source.pipe(bufferCount(10)).subscribe({
next:()=>{},
error:()=>{}
})

用bufferCount缓存stream,每10次缓存一次,然后吐给下游。

如何从小米云存储中提取出视频

最近遇到一点事,有个变态和合租房半年前已经搬走的室友有纠纷,关键是这人明知人已经搬走了,还继续骚扰住在这个地址的其他人。骚扰方法就是一半夜的在美团跑腿上这一类平台上下单,随便编个故事让骑手去敲门,很大力的那种,直到里面有人开门,大晚上的把人吵醒开门去给骑手解释,关键是这人脑子有病,都说了他找的人已经搬走了,还让把人找出来,这让人怎么找。况且,我没有义务配合他以这种方式“找人”,要不是多次经历这个事,我绝不相信现在这个所谓的法治社会还存在这样的事情。

你大半夜的爬起来跟这种人出去耗,本来就烦人,过一会他通过同样的方法换个骑手又来了,一晚上能来好几次,有一段时间不来了,在你快把他忘了的时候又来了,简直就是精神折磨,中国的基层民警处理这种事总是让人“眼前一亮”,最后我决定惹不起躲得起,早些走人。

租的自如的房子,合同没到期走人算违约,这种事怎么会是租客违约,最后双方协商提供一些证据看能不能申请无违约金换租,幸好,我们门上有摄像头(以防万一提前挂上去的)。

再说一下这个小米摄像头的云存储,介绍的时候挺华丽的,等你用到它的时候会发现它保存的视频只能在特定app上观看,能下载,但是无法导出,呵呵,不能导出为什么要下载,还好天无绝人之路,在android手机的以下路径可以找到下载的视频:

1
Android/data/com.xiaomi.smarthome/files/1027072538

找到以后发现是一堆类型为ts的视频文件,好吧,只能先弄到电脑上然后合并了。

合并的过程中试了好多软件都失败了,后来发现ts片段里面有个key文件,于是怀疑这些视频是加密的,去问chatgpt如何合并加密的ts视频,chatgpt说用以下命令:

1
ffmpeg -i input1.ts -i input2.ts -encryption_key file:keyfile.key -c copy output.ts

由于我的片段很多,这样一个一个的得写到猴年马月去,于是换了个命令:

1
ffmpeg -f concat -safe 0 -i concat.txt -c copy output.ts

可以写个脚本扫描某文件夹下的文件,生成这条命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');

// Set the folder path and key file name
const folderPath = './sources/92876675426027008';
const keyFileName = '1027072538.key';

// Find the key file path
const keyFilePath = path.join(__dirname,folderPath, keyFileName);
if (!fs.existsSync(keyFilePath)) {
console.error(`Error: Key file ${keyFileName} not found.`);
return;
}

// Create the concat file if it doesn't exist
const concatFilePath = path.join(__dirname,folderPath, 'concat.txt');
fs.writeFileSync(concatFilePath, '');


// Loop through all files in the folder
fs.readdir(folderPath, (err, files) => {
if (err) throw err;

const tsFiles = files.filter((file) => path.extname(file) === '.ts');
if (tsFiles.length < 2) {
console.error('Error: Not enough TS files found.');
return;
}

// Write the TS file paths to the concat file
const concatFileContent = tsFiles.map((file) => `file '${path.join(__dirname,folderPath, file)}'`).join('\n');
fs.writeFileSync(concatFilePath, concatFileContent);

// Generate the FFmpeg command
const outputFilePath = path.join(__dirname,folderPath, 'output.ts');
const ffmpegCommand = `ffmpeg -f concat -safe 0 -i "${concatFilePath}" -encryption_key file:"${keyFilePath}" -c copy "${outputFilePath}"`;

console.log('ffmpegCommand',ffmpegCommand);

// Execute the FFmpeg command
console.log(`Merging ${tsFiles.length} TS files with ${keyFileName}...`);
exec(ffmpegCommand, (error, stdout, stderr) => {
if (error) {
console.error(`Error: ${error.message}`);
return;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
console.log(`Success: ${stdout}`);
});

// Delete the temporary concat file
// fs.unlinkSync(concatFilePath);
});

为了方便沟通,可以把合并完了的ts文件转成mp4

1
ffmpeg -i input.ts -c:v libx264 -preset slow -crf 22 -c:a copy output.mp4

有chatgpt就是方便,以前需要自己去试错的,现在只要给它描述自己的想法,让它去试错,效率大大提升。