注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

项目很大,得忍一下

web
背景 常和我们的客户端厮混,也经常陪他们发版,每次发版编译打包都可以在那边玩一局游戏了。一边幸灾乐祸,一边庆幸h5编译还好挺快的,直到我们的项目也发展成了*山,巨石项目。由于线上要给用户查看历史的推广活动,所以很多老的业务项目都还是留在项目中,导致我们的rou...
继续阅读 »

背景


常和我们的客户端厮混,也经常陪他们发版,每次发版编译打包都可以在那边玩一局游戏了。一边幸灾乐祸,一边庆幸h5编译还好挺快的,直到我们的项目也发展成了*山,巨石项目。由于线上要给用户查看历史的推广活动,所以很多老的业务项目都还是留在项目中,导致我们的router层爆炸,打包速度直线下降,开发过程中,开了hmr稍微有点改动也要等个几秒钟,恨不得立刻重启一个新项目。但是现实告诉你,忍住,别吐,后面还有更多的业务活动加进来。那么怎么解决这个问题呢,这个时候mp的思路是个不错的选择。


关键点


打包慢,本质原因是依赖庞大,组件过多。开发过程中,我们开新的业务组件时,往往和其他业务组件是隔离的,那么我们打包的时候是不是可以把那些不相干的业务组件隔离出去,当然可以。打包工具,从入口开始进行扫描,单页面的模块引入基本都是借助router,所以,关键的是如果我们能够控制router的数量,其实就能够控制编译和打包规模了。


问题


router在vue项目中我们常用的是全家桶的库vue-router,vue-router最多提供了懒加载,动态引入功能并不支持。有小伙伴说router的引入路径可不可以动态传入,我只能说小伙子你很机智,但是vue-router并不支持动态的引入路径。因此我们换个思路,就是在入口的位置控制router的规模,通过不同规模的router实例来实现router的动态引入。当然这需要我们对router库进行一定改造,使其变的灵活易用


一般的router


通常的router如下:



// router.js

/*global require*/

const Vue = require('vue')

const Router = require('vue-router')

Vue.use(Router)

const routes = [

{

path: '/routermap',

component: (resolve) => require(['../containers/map.vue'], resolve),

name: 'routermap',

desc: '路由列表'

},

{

path: '/',

component: (resolve) => require(['../containers/index.vue'], resolve)

},

{

path: '*',

component: (resolve) => require(['../containers/nofound.vue'], resolve),

name: 'defaultPage',

desc: '默认页'

}

]

const router = new Router({

mode: 'history',

routes

})

router.afterEach((to, from) => {

///

})

export default router

// 引入 entry.js

import router from './router.js'

router.beforeEach((to, from, next) => {

///

next()

})

router.afterEach(function(to, from) {

///

})

new Vue({

el: '#app',

template: '<App/>',

router,

})


我们可以不断的往routes数组中添加新的router item来添加新的业务组件,这也是我们的项目不断变大的根本,这样既不好维护,也会导致后面的编译效率


易于维护和管理的router


其实好的管理和维护本质就是分门别类,把类似功能的放在一起,而不是一锅粥都放在一起,这样基本就能解决追踪维护的功能,对应router管理其实也不是很复杂,多建几个文件夹就行如下:


router.png


对应routes/index.js代码如下:



import testRouter from './test.js'

const routes = [

{

path: '/map',

component: (resolve) => require(['../containers/map.vue'], resolve),

name: 'map',

desc: '路由列表'

},

{

path: '/',

component: (resolve) => require(['../containers/index.vue'], resolve)

},

...testRouter,

// 可以扩展其他router

{

path: '*',

component: (resolve) => require(['../containers/nofound.vue'], resolve),

name: 'defaultPage',

desc: '默认页'

}

]

// test.js

/**

* 测试相关页面路由映射

*/


/*global require*/

export default [

{

path: '/test/tools',

name: 'testTools',

component: resolve => require(['@test/tools/index.vue'], resolve),

desc: '测试工具'

}

]


我们通过把router分为几个类别的js,然后在通过router item的数组展开合并,就做到了分门别类,虽然看似简单,但是可以把管理和维护效果提升几个数量级。


支持mp的router


虽然上面支持了易于管理和维护,但是实际上我们如果只是单一入口的话,导出的还是一个巨大的router。那么如何支持多入口呢,其实也不用想的过于复杂,我们让类似test.js的router文件既支持router item的数组导出,也支持类似routes/index.js一样的router实例导出即可。所谓既能分也能合才是最灵活的,这里我们可以利用工厂模式做一个factory.js,如下:



/**

* app 内的页面路由映射

*/


/*global require*/

const Vue = require('vue')

const Router = require('vue-router')

Vue.use(Router)

const RouterFactory = (routes) => {

return new Router({

mode: 'history',

routes: [

{

path: '/map',

component: (resolve) => require(['../containers/map.vue'], resolve),

name: 'map',

desc: '路由列表'

},

{

path: '/',

component: (resolve) => require(['../containers/index.vue'], resolve)

},

...routes,

{

path: '*',

component: (resolve) => require(['../containers/nofound.vue'], resolve),

name: 'defaultPage',

desc: '默认页'

}

]

})

}

export default RouterFactory


这个factory.js产出的router实例和routes/index.js一模一样所以我们只需组装一下test.js即可,如下:



/*global require*/

import RouterFactory from './factory'

export const testRouter = [

{

path: '/test/tools',

name: 'testTools',

component: resolve => require(['@test/tools/index.vue'], resolve),

desc: '测试工具'

}

]

export default RouterFactory(developRouter)

// routes/index.js的引入变化一下即可

import testRouter from './test.js'

// 修改为=》

import { testRouter } from './test.js'


那么我们的入口该如何修改呢?也很简单:



// testEntry.js

import router from './routes/test.js'

router.beforeEach((to, from, next) => {

///

next()

})

router.afterEach(function(to, from) {

///

})

new Vue({

el: '#app',

template: '<App/>',

router,

})


我们建立了一个新的入口文件 testEntry.js 这个入口只引入了test相关的模块组件


如何灵活的和编译命令做配合呢


根据上面,我们进行mp改造的基础已经做好,关于如何多入口编译webpack或者其他打包里面都是基础知识,这里就不多赘述。这里主要聊一下如何灵活的配合命令做编译和部署。


既然router我们都可以分为不同的文件,编译文件我们同样可以拆分为不同的文件,这也使得我们的命令可以灵活多变,这里我们以webpack做为示例:


config.png


config1.png


config2.png


config3.png


根据上图示例 我们的webpack的配置文件仅仅改动了entry,我们稍微改造一下build.js,使其能够接受不同的编译命令:



// build.js

let page = 'all'

if (process.argv[2]) {

page = process.argv[2]

}

let configMap = {

'all': require('./webpack.prod.conf'),

'app': require('./webpack.app.conf')

}

let webpackConfig = configMap[page]

// dev-server.js

let page = 'all'

if (process.argv[2]) {

page = process.argv[2]

}

let configMap = {

'all': require('./webpack.dev.conf'),

'app': require('./webpack.app.dev.conf')

}

let webpackConfig = configMap[page]


对应的脚本配置:



// package.json

"scripts": {

"dev": "node build/dev-server.js",

"build": "node build/build.js",

"build:app": "node build/build.js app",

"dev:app": "node build/dev-server.js app"

},


以上app对应test。最后,我们只需要在命令行执行相应命令,即可实现我们可控的router规模的开发,基本随便来新的需求,咱都可以夜夜做新郎,怎么搞都是飞速。当然部署的话我们也可以单独执行一部分页面的部署命令到单独的域名,换个思路也可以作为一种预发测试的部署方法。



#
整体项目的开发编译

npm run dev

#
单独的app,也即test项目的开发编译

npm run dev:app

#
整体项目的部署

npm run build

#
单独的app,也即test项目的部署

npm run build:app


结语


以上,即如何利用mp思路,提高我们的编译开发效率。时常有人会在提高网页性能的时候说到mp,但mp本质上并不能提高页面的性能,比如白屏优化。而路由中使用懒加载其实才是提高部分网页性能的出力者,关于白屏优化,本篇文章不作展开讨论。


作者:CodePlayer
来源:juejin.cn/post/7218866717739696183
收起阅读 »

周末闲来无事,做了一个能动的宣传页

web
最近在用可画(canva),制作一些素材,海报活动页面,这不情人节快到了吗?基于海报模版,设计自己的页面倒是简单,但是都是静态页面,想着能不能让页面的元素都动起来(Everybody跟我一起嗨嗨嗨!!)。 两个方案 纯CSS animate库 CSS基于ani...
继续阅读 »

创建项目

最近在用可画(canva),制作一些素材,海报活动页面,这不情人节快到了吗?基于海报模版,设计自己的页面倒是简单,但是都是静态页面,想着能不能让页面的元素都动起来(Everybody跟我一起嗨嗨嗨!!)。


两个方案


纯CSS animate库


CSS基于animate库



  1. 利用animate动效,给页面上所有的image和text元素加上className,借助--var全局css变量属性,给元素依次加上delay、duration、index序号、初始化信息rotate、offset、easing等等,我会在码上掘金给一个css的demo版本。CSS版本相对简单一些,只需要循环给所有元素加上对应动画,计算执行时间,延迟时间,页面就可以动起来了。


// 定义的数据结构 Image\Text
[{
"id": "Image/Text-xx",
"type": "Image/Text",
"name": "图片/文本",
"css": {
"top": 0,
"left": 0,
"width": 414,
"height": 736,
"zIndex": 1,
"opacity": 1,
"fontSize": 18,
},
"animationObj": {
{
"delay": 1000,
"duration": 3030,
"type": "flipInY",
"easing": '',
"index": 8,
"rotate_angle": -6.6,
"offset": -112.5,
}
},
"value": "文本内容",
"src": "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/935920813a0c4151bbf452ef3c53ab7f~tplv-k3u1fbpfcp-watermark.image"
}]


码上掘金-CSS版


下面是纯css的版本:
code.juejin.cn/pen/7123482…


JS animejs库


animejs库


使用JS的关键就是编写对应帧属性,通过时间轴timeline方法给元素加上动画。现在js版本还只是一个demo中的demo,下次再给jym,感兴趣的jy可以自己想想。


时间轴可让你将多个动画同步在一起。
默认情况下,添加到时间轴的每个动画都会在上一个动画结束时开始。

<div class="demo-content params-inheritance-demo">
<div class="line">
<div class="square shadow"></div>
<div class="square el" style="transform: translateX(0px) scale(1); opacity: 0.5;"></div>
</div>
<div class="line">
<div class="circle shadow"></div>
<div class="circle el" style="transform: translateX(7.22878e-10px) scale(1); opacity: 0.5;"></div>
</div>
<div class="line">
<div class="triangle shadow"></div>
<div class="triangle el" style="transform: translateX(2.30924px) scale(1.00924) rotate(180deg); opacity: 0.5;"></div>
</div>
</div>

<script src="https://lib.baomitu.com/animejs/3.2.1/anime.min.js"></script>


.demo-content {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
width: 290px;
height: 100%;
}
.line {
width: 100%;
padding: 1px 0px;
}
.square,
.circle {
pointer-events: none;
position: relative;
width: 28px;
height: 28px;
margin: 1px;
background-color: #005bb7;
font-size: 14px;
}
.triangle {
pointer-events: none;
position: relative;
width: 0;
height: 0;
border-style: solid;
border-width: 0 14px 24px 14px;
border-color: transparent transparent #005bb7 transparent;
}
.shadow {
position: absolute;
opacity: .2;
}

var tl = anime.timeline({
targets: '.params-inheritance-demo .el',
delay: function(el, i) { return i * 200 },
duration: 500,
easing: 'easeOutExpo',
direction: 'alternate',
loop: true
});

tl
.add({
translateX: 250,
// override the easing parameter
easing: 'spring',
})
.add({
opacity: .5,
scale: 2
})
.add({
// override the targets parameter
targets: '.params-inheritance-demo .el.triangle',
rotate: 180
})
.add({
translateX: 0,
scale: 1
});

code.juejin.cn/pen/7123478…


码上掘金太卡了吧,能不能优化下


作者:一起重学前端
来源:juejin.cn/post/7123482707983613965
收起阅读 »

本地运行的前端代码,如何让他人访问

web
有时候,我前端写好了项目,想要给其他人看一下效果,可以选择将代码部署到test环境,也可以选择让外部通过ip来访问,不过前提是在同一个局域网下(比如连接同一个WiFi),下面介绍第二种方式。 修改dev命令 首先我们需要先修改host地址,此处以vue3项目举...
继续阅读 »

有时候,我前端写好了项目,想要给其他人看一下效果,可以选择将代码部署到test环境,也可以选择让外部通过ip来访问,不过前提是在同一个局域网下(比如连接同一个WiFi),下面介绍第二种方式。


修改dev命令


首先我们需要先修改host地址,此处以vue3项目举例


image.png


页面启动之后如下


image.png


正常情况下,script下的dev命令是不会指定host的,我们可以在下面看到Local的地址为默认的127.0.0.1,此时把这个网址发给别人肯定跑不起来。


所以我们可以指定host,比如0.0.0.0,允许所有ip访问


"dev": "vite --host=0.0.0.0",

修改完host后,windows系统的话,我们还需要关闭防火墙(苹果不需要)。重新启动项目可以看到


QQ截图20230406204123(1)(1).png


Network那里的网址,打马赛克的地方其实就是本机的ip地址,window输入cmd打开命令提示符,然后输入ipconfig即可查到ip地址,苹果的话,点击wifi小图标,同时按住option键即可查到ip地址。


在其他电脑或者手机访问


浏览器中输入url即可看到相关页面,此方法也适用于手机端调试


Screenshot_2023-04-06-20-51-03-21_439a3fec0400f89.jpg


作者:笨笨狗吞噬者
来源:juejin.cn/post/7218916720323706935
收起阅读 »

知道尤雨溪为什么要放弃 $ 语法糖提案么?

web
前言 最近看到一篇文章: 《最新,Vue 中的响应性语法糖已废弃》 本文标题中的 $ 语法糖指的就是上文中的响应式语法糖 (Reactivity Transform),那为什么不写 Reactivity Transform 呢?因为这个名实在是太长了… 看了一...
继续阅读 »

前言


最近看到一篇文章:


《最新,Vue 中的响应性语法糖已废弃》


本文标题中的 $ 语法糖指的就是上文中的响应式语法糖 (Reactivity Transform),那为什么不写 Reactivity Transform 呢?因为这个名实在是太长了…


看了一圈评论发现大家觉得被废弃是因为分不清是正常变量还是响应式变量的居多:



下面这个评论说的有一定道理:



Vue 的官网现在已经变成这样了:



以后会不会变成这样:



23次方,一共8种不一样的写法。不对,无虚拟 DOM 模式只能用 Composition API,所以应该不到 8 种写法,你看这不就分裂了嘛!虽说这几种不同的写法也能看懂吧,但每个人都有不同的偏好不同的写法总归不太好。而且你能保证 Vue 不会又改写法吗?Vue 总是受人启发:受 Angular 启发的双向绑定、受 React 启发的虚拟 DOM、受 React Hooks 启发的 Composition API、受 Svelte 启发的语法糖(一开始用的是 Svelte 的 label 写法)、受 Solid 启发的 Vapor Mode无虚拟 DOM 模式




  • 高情商:集百家之长

  • 低情商:方案整合商




开玩笑的哈~ Vue 还是有很多自己的东西的,不过它确实老是抄袭各种框架受各种框架的启发,太杂糅了。今天受这个框架启发做出来这种新 feature、明天又受那个框架启发做出来了另一种新 feature… 估计等 Vue4 出来的时候肯定又是受到了什么其他框架的启发…


我在《无虚拟 DOM 版 Vue 即将到来》这篇文章下看到这样一条评论:



大家觉得这个人说的有没有道理呢?反正我现在感觉 Vue 的各个方案有点太杂糅了,有点像是方案整合商集百家之长,以后指不定就发展成这样了:



当你去网上搜索一些解决方案时,能看到数十种不同的写法是一种什么体验……


不过这条评论真的是高情商:





  • 低情商:Vue 这是啥流行抄啥

  • 高情商:只用 Vue 就能体会到各种流行的技术趋势




跑题了,咱们来说一说 $ 语法糖,它可绝不只有分不清到底是不是响应式变量这一个缺点,它的缺点比优点多得多,我们来具体分析一下。


分析


我们也不要一上来就说这个语法糖有多么多么的不好,如果真这么不好的话尤总也不至于费这么大劲来推动这个提案了对不?这个语法糖在某些情况下确实会大幅改善我们的开发体验,但在另一些情况下不仅不会帮助我们改善体验,反而会增加我们的心智负担,我们来看下面这个案例:


let x = $(0)
let y = $(0)

const update = e => {
 x = e.x
 y = e.y
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))

$watch([x, y], ([x, y]) => console.log(x, y))

看上去很美好是不是,我们终于不用再写 .value 了。



如果看不明白这种写法的话可能是之前没有对其进行过了解


建议先阅读一下这篇《Vue3又出新语法 到底何时才能折腾完?》



不过像这种逻辑我们通常都会提取出去封装成一个函数,因为有可能有很多个组件都用到了获取鼠标位置这个逻辑,你不想在每个用到该逻辑的组件里都复制一遍相同的逻辑吧?那我们就这样:


// useMouse.js
export const useMouse = (dom = window) => {
  let x = $(0)
  let y = $(0)

  const update = e => {
    x = e.x
    y = e.y
}
  onMounted(() => dom.addEventListener('mousemove', update))
  onUnmounted(() => dom.removeEventListener('mousemove', update))

  return { x, y }
}

import { useMouse } from './useMouse.js'

let { x, y } = useMouse()

$watch([x, y], ([x, y]) => console.log(x, y))

如果这么写你就会惊讶的发现根本不生效,因为编译过后就相当于:


import { ref } from 'vue'

export const useMouse = (dom = window) => {
 let x = ref(0)
 let y = ref(0)

 const update = e => {
   x.value = e.x
   y.value = e.y
}
 onMounted(() => dom.addEventListener('mousemove', update))
 onUnmounted(() => dom.removeEventListener('mousemove', update))

 return {
   x: x.value,
   y: y.value
}
}

这就相当于把一个普通值给 return 出去了,普通值是没法在取值或改值时运行一些其他逻辑的,所以我们还不能把值直接 return 出去,而是把这个响应式变量本身给 return 出去:


import { ref } from 'vue'

export const useMouse = (dom = window) => {
 let x = ref(0)
  let y = ref(0)

  const update = e => {
   x.value = e.x
    y.value = e.y
}
  onMounted(() => dom.addEventListener('mousemove', update))
  onUnmounted(() => dom.removeEventListener('mousemove', update))

  return { x, y }
}

所以编译必须还要有还原的功能,把响应式的值给还原成响应式变量:


export const useMouse = (dom = window) => {
 let x = $(0)
 let y = $(0)

 const update = e => {
   x = e.x
   y = e.y
}
 onMounted(() => dom.addEventListener('mousemove', update))
 onUnmounted(() => dom.removeEventListener('mousemove', update))

 return $$({ x, y })
}

但这样又要写 .value 了:


import { useMouse } from './useMouse.js'

let { x, y } = useMouse()

console.log(x.value, y.value)

因为编译器是分析不出来一个函数的返回值到底是不是响应式变量的,所以就又得引入一个 API 来告诉编译器这个函数的返回值有响应式变量:


import { useMouse } from './useMouse.js'

let { x, y } = $fromRefs(useMouse())

console.log(x, y)

大家不觉得这样很麻烦吗?而且搞出那么多莫名其妙的 $ 、$$ 变量。写一堆这玩意真的没感觉比 .value 好到哪去,而且我们还要随时记得某个变量是响应式的,不然在传递的过程中就有可能失去响应性:


// logValue.js
// 接收一个响应式变量并在其变化时将其打印出来

export const logValue = arg => { // 在提案中并未找到如何用语法糖转换函数的参数
 // 也就是说在这种情况下可能没有什么完美的解决方案 那就又要写 .value 了:
 console.log(arg.value)
 // 不过也不是没有解决方案 我们可以用 $computed 来关联一下:
 let argument = $computed(() => arg.value)
 // 这样就可以不用写 .value 了:
 console.log(argument)
 // 但缺点就是太麻烦了 参数少的时候还可以 参数多的时候还能每个都这么写吗?
 // 而且还要为变量取个不同的名字 这对于我们这些英文不好的人来说简直就是场灾难
 $watch(argument, value => console.log(value))
}

import { logValue } from './logValue.js'

let a = $(0)

logValue(a) // 这么传就错啦
logValue($$(a)) // 一定要写成这样

// 假如有函数是需要响应式变量和普通变量混着传的:
let b = 0
logValue($$(a), b, { a: $$(a), b }) // 写成这样真的很乱

还有需要把 ref 变量传给 reactive 字段的情况:


let a = $(0)

const obj = reactive({ a })

console.log(obj.a) // 0
a++
console.log(obj.a) // 还是 0


// 必须写成这样
const obj = reactive({ a: $$(a) })
console.log(obj.a) // 0
a++
console.log(obj.a) // 1

所以说语法糖只能某些情况下改善我们的开发体验,前提就是你不要把响应式变量传来传去的。但 Vue3 的核心卖点之一不就是 Composition API 么?中文官网管这个叫组合式 API,关键词是组合Vue 还把提取出去的可复用函数叫 Composables,翻译过来就是可组合的,如果不把响应式变量传来传去那还组合个P呀!


这个问题可不是只有 Vue 有,来看下 Solid.js 吧:


import { createSignal } from 'solid'

export const useMouse = (dom = window) => {
 const [x, setX] = createSignal(0)
 const [y, setY] = createSignal(0)

 dom.addEventListener('mousemove', ({ x, y }) => {
   setX(x)
   setY(y)
})

 return {
   x: x(),
   y: y()
}
}

同样会有响应式值与响应式变量的问题,只不过就是把 .value 变成了 ()


// 假如有个响应式变量 a

// 打印的是响应式值
console.log(a.value) // Vue
console.log(a()) // Solid

//打印的是响应式变量
console.log(a) // Vue & Solid

是不是看过很多文章说 Solid.js 和 React Hooks 很像、写起来很舒服、什么比 React 还 react 之类的文章?实际上真的就只是 API 设计的相似而已,只要我们想,我们同样也可以把 Vue 的 API 封装成 React 那样:


import { ref } from 'vue'

const useState = value => {
 const result = ref(value)
 const getter = () => result.value
 const setter = newValue => result.value = newValue
 return [getter, setter]
}

const [num, setNum] = useState(0)
setNum(1)

那是不是这样封装一下,Vue 也变得比 React 还 react 了?应该不难看出这只是在自欺欺人罢了,我们传值时照样还得区分到底应该传的是响应式变量本身还是响应式变量的值。


Vue2 为何没这个问题


不知大家有没有思考过:为什么 Vue2 时代大家从来就没听说过丢失响应性、没听过要出什么语法糖之类的问题呢?听过最多有关于语法糖的可能就是 v-model 的双向绑定功能其实就是 @input="xxx" + :value="xxx" 的语法糖。


这是因为 Vue2 时代用的都是 this.xxx,咱们所有的响应式变量全都挂载到了 this 上。取值时 this.xxx 会触发 getter、改值时 this.xxx = xxx 会触发 setter


你可以简单的理解成这样:


// 用 Vue3 来写一段伪代码
import { reactive, watchEffect } from 'vue'

const this = reactive({
a: 1,
b: 2,
c: 3
})

watchEffect(() => console.log(this.a))
this.a++

当然这只是一段伪代码,真这么写是会报错的:



因为 this 是一个关键字,正因为它是一个关键字所以咱们用 this.xxx 才会显得这么的自然。而我们现在的响应式变量都需要自己起名,自己起的名不是关键字,所以用 xx.xxx 就老觉得麻烦,就老想给它解构:


import { reactive, watchEffect, toRefs } from 'vue'

const user = reactive({
name: 'AngularBaby',
age: 34,
beautiful: true
})

console.log(user.name) // 有些人觉得这样写很麻烦
const { name } = user // 就老想给它解构
console.log(name) // 结果就是失去了响应性

// 想要保持响应性 写法就变得更麻烦了
const { name } = toRefs(user)
console.log(name.value)

而且之前用 this 还有一个显著的好处就是只要写法正确,操作 this 上的属性就不用担心响应式的问题,没有那么多心智负担。甚至有人会简单的理解为只要是 this.xxx 就一定会有响应:


export default {
data () {
return { a: 1 }
},
mounted () {
this.a = 2 // 没有心智负担 因为我们知道自己是在改变 this 上的属性
this.a++ // 正确改变 this 上的属性就会存在响应

let b = 2 // 也没有心智负担 因为我们知道这不是 this 上的属性
b++ // 我们不会期待这段代码会有任何的响应
}
}

这样很容易区分哪些是响应式变量而哪些不是,即使有人真的写成了这样:


export default {
data () {
return { a: 1 }
},
mounted () {
let { a } = this
a++ // 我们不会期待这段代码会有任何的响应
}
}

这里也很容易能够看出来我们这样并没有修改 this 上的属性,所以并不会正确响应也是理所应当的一件事。


还有复用逻辑,Vue2 时代有很多人用 Mixins 来复用逻辑:


import mouse from 'mouse.mixin.js'
import position from 'position.mixin.js'

export default {
mixins: [mouse, position],
mounted () {
this.x // 哪来的 x ?
this.y // 哪来的 y ?
// 除了 xy 还有没有其他的未知 this.xxx ?
}
}

可以看到 Mixins 存在很多的弊端,比方说数据来源不清晰、容易产生冲突变量之类的。如果不去看源码的话谁能知道 this.x 到底是 mouse 中的 x 还是 position 的 x 呢?正是由于 Vue2 没有一个完美的复用机制,所以尤大才下定决心将 Vue3 改造成函数式。但函数式没了 this 就又失去了 Vue2 时期的那种… 我不知该怎么形容 Vue2时期的 this.xxx 哈,舒服?自然?反正我是比较喜欢 this.xxx 这种写法的,虽然这种写法是受 Angular 启发(集百家之长)


而且我还比较喜欢的一点就是一些全局挂载的属性:


this.$el
this.$refs
this.$nextTick(() => { /* ... */ })

直接 this.$xxx 就出来了,不用引,既方便又快捷。当然这种方式也有不少坏处,比方说容易被覆盖、不利于 Tree Shaking 之类的…


但我还真的蛮喜欢这种写法的:


// main.js
import Vue from 'vue'

Vue.prototype.$toast = msg => { /* ... */ }

this.$toast('Success!')

如今就会变得就稍麻烦一些:


import toast from './toast.js'

toast('Success!')

虽说后者其实更好,但有没有这样一种可能:既恢复到 Vue2 时期用 this 的便捷、又能享受到 Vue3 组合式的好处:


// 幻想中的写法

this.$data.a = 1 // 相当于 Vue2 时期的 data: { a: 1 } 最终会挂载到 this 上变成 this.a
this.$computed.b = () => this.a * 2 // 相当于 Vue2 时期的 computed: { b () { return this.a * 2 } } 最终会挂载到 this 上变成 this.b

this.$watch.b = value => console.log(value) // 相当于 Vue2 时期的 watch: { b: value => console.log(value) }

let timer
this.$mounted = () => {
timer = setInterval(() => this.a++, 1000)
}
this.$unMounted = () => clearInterval(timer)

复用逻辑:


// 幻想中的写法

import useMouse from './useMouse.js'

({ x: this.$computed.x, y: this.$computed.y } = useMouse())
this.$effect = () => console.log(this.x, this.y)

// 如果用数组解构将会更加的便捷
[this.$computed.x, this.$computed.y] = useMouse()
this.$effect = () => console.log(this.x, this.y)

这样我们的心智负担就又能回到 this 时期了:只要改变 this 属性就会存在响应,否则就无响应,那这个方案有实现的可能吗?在 ES5 时代无可能,但在 ES6 Proxy 的加持下我认为还是可以实现的,那么接下来我们就来试一下。


实验


首先我们回顾一下 Vue3.0 没有 setup 语法糖时期的写法:


<template></template>

<script>
import { defineComponent } from 'vue'

export default defineComponent({
setup () {
console.log(this) // undefined
}
})
</script>

原版的 this 指向为 undefined,那我们怎么改变它的指向呢?我们可以自己写一个 defineComponent


// defineComponent.js

import { defineComponent, reactive } from 'vue'

export default options => {
const { setup } = options
if (typeof setup === 'function') {
options.setup = setup.bind(reactive({}))
}
return defineComponent(options)
}

这样 setup 的指向就变成了 reactive({}),当我们在操作 this 的时候就相当于在操作 reactive({})。但这样并不能满足我们的需求,我们想要的是当我们 this.$data.a 的时候会在 this 上挂载个 a 属性,所以我们要把 reactive 换成一个 Proxy


// createThis.js
import { defineComponent, reactive } from 'vue'

const createData = target => new Proxy({}, {
get: (_, key) => Reflect.get(target, key),
set (_, key, value) {
if (Reflect.getOwnPropertyDescriptor(target, key)) {
console.error(`this.$data.${key} is already defined!`)
return false
}
return Reflect.set(target, key, value)
}
})

export default () => {
const that = reactive({})
const $data = createData(that)
return new Proxy(that, {
get (target, key) {
if (key === '$data') {
return $data
}
return Reflect.get(target, key)
},
set (target, key, value) {
if (key === '$data') {
return console.warn('this.$data is readonly!')
}
return Reflect.set(target, key, value)
}
})
}

// defineComponent.js

import { defineComponent } from 'vue'
import createThis from './createThis.js'

export default options => {
const { setup } = options
if (typeof setup === 'function') {
const that = createThis()
options.setup = (...args) => {
setup.apply(that, args)
return that
}
}
return defineComponent(options)
}

也就是说我们利用 Proxy 来把 $data 给代理出去了,当我们访问 $data 的时候其实已经是另一个代理对象了,在这个代理对象上设置的属性全部都设置到 this 上。this 现在就相当于 reactive({}),所以 this.$data.a = 1就相当于 reactive({ a: 1 }),我们来试一下:



完美运行,只要你能搞懂上面的那段代码,那么接下来的 $computed$watch$watchEffect$readonly$shallow$nextTick$mounted$unMounted 等一大堆 API 相信你也知道该怎么做了,我就不在这里占用过多的篇幅了。这里直接用码上掘金贴上源码及用法,向大家展示一下可行性:



当然这源码并不是把所有 API 都实现了,目前只实现了 this.$datathis.$computedthis.$watchthis.$mounted 等几个常用的 API 供大家参考,感兴趣的可以去把全部的 API 都实现一下,我这里犯懒就先不实现那么全乎了。



这么好的东西为啥犯懒不实现呢?因为这玩意有一定的弊端。对了,掘金好像在文章中屏蔽了来自码上掘金alert,必须点查看详情才能看到。为了防止大家也犯懒不点进去看,这里直接给大家贴上动图:



我们的写法类似于下面这样:


export default defineComponent({
setup () {
this.$data.count = 0
this.$watch.count = (value, oldValue) => alert(`验证 this.$watch:按钮上的值将会从 ${oldValue} 变为 ${value}`)

this.$computed.doubleCount = () => this.count * 2
this.$watch.doubleCount = value => alert(`验证 this.$computed:${this.count} 的双倍是 ${value}`

this.$mounted = () => alert('验证 this.$mounted:已挂载')
}
})

怎么样,是不是很好玩?我是蛮喜欢这种 this 混合着函数式的写法。但刚刚说了这玩意有一定的弊端,只能拿来当玩具玩玩所以我才懒得实现的那么全乎。那么它究竟有多大的弊端呢?


弊端


Vue3 比 Vue2 更优秀的一个点是支持 tree shaking,在你仅仅只用了 Vue 的某几项功能的情况下打包体积会小很多。但我们刚刚的做法无疑是开了历史的倒车,又回去了!并且随着 Vue3.2 的崛起,setup 语法糖得到了大多数人的认可,因为它确实很方便。但这样我们就无法修改 this 指向了:


<template>
<h1>{{ a }}</h1>
</template>

<script setup>
this.$data.a = 1 // 怎么修改 this 指向
</script>

有人可能会说加个函数不就得了:


<template>
<h1>{{ a }}</h1>
</template>

<script setup>
import setup from './setup.js'

setup(() => {
this.$data.a = 1
})
</script>

这样虽然可以修改 this 指向,但随之而来的就是 <template> 模板里面访问不到 a 这个变量了,除非我们写成这样:


<template>
<h1>{{ a }}</h1>
</template>

<script setup>
import { toRefs } from 'vue'
import setup from './setup.js'

const { a, b, c, d, e, f } = toRefs(setup(() => {
this.$data.a = 1
this.$data.b = 2
this.$data.c = 3
this.$data.d = 4
this.$data.e = 5
this.$data.f = 6
}))
</script>

我相信没人会愿意写成这样,所以我们必须借助 babel 插件来完成编译,思路是把 this 编译成 reactive({}),类似于下面这样:


// 编译前
this.$data.a = 1
this.$data.b = 2
this.$data.c = 3
this.$data.d = 4
this.$data.e = 5
this.$data.f = 6

// 编译后
import { reactive } from 'vue'
import createThis from 'createThis.js'
import createData from 'createData.js'

const that = createThis(reactive({}))
createData(that)

that.$data.a = 1
that.$data.b = 2
that.$data.c = 3
that.$data.d = 4
that.$data.e = 5
that.$data.f = 6

不过这样还是会引入我们刚刚写的那些代码,虽然代码量并不高,但如果压根就不引入任何额外的代码才好,所以如果能编译成这样才是最完美的:


// 编译前
this.$data.a = 1
this.$data.b = 2
this.$data.c = 3
this.$data.d = 4
this.$data.e = 5
this.$data.f = 6

console.log(this.a)

// 编译后
import { reactive } from 'vue'

const that = reactive({
a: 1,
b: 2,
c: 3,
d: 4,
e: 5,
f: 6
})

console.log(that.a)

但如果这样编译的话又有可能发生如下情况:


import useXxx from './useXxx'

this.$data.a = 1

useXxx.call(this)

这样会被编译成:


import { reactive } from 'vue'
import useXxx from './useXxx'

const that = reactive({ a: 1 })

useXxx.call(that)

万一这个 useXxx 里写了这样一段逻辑:


// useXxx.js

expurt default function () {
this.$watch.a = value => console.log(value)
}

这样就不会按照我们所期待方式去运行了,因为在编译后就相当于:


// 伪代码

const obj = reactive({ a: 1 })

useXxx.call(obj)

function useXxx () {
this.$watch.a = value => console.log(value)
}

这样会直接报错,因为 reactive({ a: 1 }).$watch 是 undefinedundefined.a 会报错,所以并没有特别完美的解决方案。最好是检测如果没把 this 作为参数传走或者没有哪个函数用了 fn.call(this) 来把 this 指向当前上下文的话,就按照最完美的方式(不引入任何杂七杂八的代码)编译。否则就引入一点运行时,反正也没多少:


// 编译前
import useMouse from 'useMouse'

this.$data.a = 1
this.$watch.a = value => console.log(value)

this.$mounted = () => window.addEventListener(...)
this.$unmounted = () => window.removeEventListener(...)

[this.$computed.x, this.$computed.y] = useMouse.call(this)

// 编译后
import { reactive } from 'vue'
import createThis from 'createThis'
import createData from 'createData'
import createWatch from 'createWatch'
import createMounted from 'createMounted'
import createUnmounted from 'createUnmounted'

const that = createThis(reactive({
a: 1
}))
createData(that)
createWatch(that)
createMounted(that)
createUnmounted(that)

that.$data.a = 1
that.$watch.a = value => console.log(value)

that.$mounted = () => window.addEventListener(...)
that.$unmounted = () => window.removeEventListener(...)

[that.$computed.x, that.$computed.y] = useMouse.call(that)

但仔细一想还是有可能有 bug,比方说你这个组件里没用到 this.$readonly,但 useMouse 用了的话,那岂不是又要报错。那就在 Vue 组件之外也编译,如果在外面有用到 this.$xxx,那就在相应的位置:


// 编译前
export default function useMouse () {
this.$readonly.a = 1
}

// 编译后
import createReadonly from 'createReadonly'

export default function useMouse () {
createReadonly(this)
this.$readonly.a = 1
}

缺陷


这种写法不仅仅是有弊端,还有一个非常严重的缺陷。虽然刚刚我们设想了一下用编译的方案来解决弊端的可能,但有个最大的缺陷是连编译都无法解决的。这个最大的缺陷就是对 TS 的支持,如果不用 TS 还好,但如果你的项目里有用 TS,那么这种写法就完全没法用:



不知怎么才能让 TS 也支持这种想法,查了国内外很多资料,最后找到了这两篇文章:



《TypeScript plugin 实践 —— 类型,编辑器与业务价值》


《基于 TypeScript 的开发者体验增强 - 朝夕相处却始终被忽视的领域》



也不知道这个 TS Language Service 有没有可能能够实现我们这种语法,感兴趣的小伙伴可以好好研究一下。我们目前只实现了运行时方案,但编译方案才是未来。写这篇文章的目的是希望给大家提供一个思路,看看大家觉得这个想法怎么样。万一大家觉得这个想法非常好,把它推给官方,官方实现了呢?



当然上述的那些话也可能仅仅只是过于美好的想象,现实很有可能是压根儿就没有人对这个想法感兴趣,官方也认为这是在开历史的倒车并且对 TS 支持不好不予实现。



往期精彩文章



作者:Veev
来源:juejin.cn/post/7222874734185922597
收起阅读 »

Low-Code,一定“low”吗?

web
作者:京东保险 吴凯 前言 低代码是一组数字技术工具平台,基于图形化拖拽、参数化配置等更为高效的方式,实现快速构建、数据编排、连接生态、中台服务。通过少量代码或不用代码实现数字化转型中的场景应用创新。本文将重点介绍低代码相关知识,包括低代码的定义与意义、相关概...
继续阅读 »

作者:京东保险 吴凯


前言


低代码是一组数字技术工具平台,基于图形化拖拽、参数化配置等更为高效的方式,实现快速构建、数据编排、连接生态、中台服务。通过少量代码或不用代码实现数字化转型中的场景应用创新。本文将重点介绍低代码相关知识,包括低代码的定义与意义、相关概念、行业发展等,同时介绍京东的低代码工具,期望能帮助大家更好地认识与理解低代码。


一、低代码介绍


2014年,Forrester(著名研究咨询机构)提出“低代码”的术语,定义为“利用很少或几乎不需要写代码就可以快速开发应用,并可以快速配置和部署的一种技术和工具”。或者说是“(能力)多(出品)快(质量)好(功夫)省”。



这个定义体现出低代码的核心价值:


1、低代码开发平台能够实现业务应用的快速交付。低代码开发的重点是开发应用快,不像传统意义上仅仅是一个应用的开发,而是通过可视化的开发,达到“设计及交付”的目的,提高开发效率。


2、低代码开发平台能够降低业务应用的开发成本。低代码开发投入更低,主要体现在开发时间短,可以快速配置和部署,同时也更容易使非开发人员上手。


二、我们为什么用低代码


低代码可以降本增效,一方面低代码的出现避免了“反复造轮子”的问题,其通过可视化的编程方式实现“千人千面”的效果,驱使技术回归本源--支持业务。另一方面低代码的生命周期贯穿整个软件开发周期(设计、开发、测试、交付),周期上的各角色都可以在同一个低代码开发平台上紧密协作,由传统的开发方式变为敏捷开发,实现了快速交付的目的。


低代码的使用场景:


1、构建新的SaaS应用,而借助低代码平台可以快速有效地构建、测试和推出应用。低代码与SaaS的结合,可以为企业提供独特的业务解决方案。


2、基于Web的门户网站是提供自助服务的数字化工具。使用低代码开发平台,更简单、更快速地构建个性化应用,打造数字化平台。


3、历史系统的迁移或升级。基于低代码技术:一方面,最大限度地保留遗留系统的代码,保留其“公共数据服务”;另一方面,基于遗留系统的开发环境和能力构建相应的“功能适配器”,然后在此基础上,通过低代码技术快速定制新业务和流程的交互式UI与业务逻辑。


4、应用复杂性低,业务流程相对简单,95%的应用场景可以通过低代码完成。



三、低代码会使程序员失业吗


回答这个问题,我们首先需要搞明白:低代码和零代码的区别。作为程序员,大家都会把低代码认为是零代码,这也是会被误解程序员失业的原因之一。


低代码,意味着反复迭代的代码质量高,在必要的时候,也会进行代码的编写;BUG更少,减少了测试环节的工作量。


零代码,字面意思:完全不需要任何代码即可完成应用开发,从软件开发效率看,**零代码是低代码的最终形态。**零代码平台由于采用全部都是封装模块进行搭建,所有控件都已经被固化了,所以用零代码平台搭建的系统想要进行扩展是有些困难的。


现实是,编码的最终目的是支持业务,业务逻辑的复杂与否依旧需要人来掌握,低代码只是写的少,并不是不写代码,这并不会导致程序员的失业



四、低代码的行业现状


2021年11月11日,Forrester发布《The State Of Low-Code Platforms In China》,这是低代码概念提出者第一次将视角聚焦在中国。Forrester认为,低代码目前在国内主要应用于银行、保险、零售、医疗、政府、制造、电信和建筑行业。比如,为了针对各个业务单元量身定制各种业务需求,中国建设银行采用云枢为其分布式开发团队构建统一的低代码开发平台(LCDP)。另外,报告指出:中国企业数字化转型过程中,有58%的决策者正在采用低代码工具进行软件构建,另有16%的决策者计划采用低代码。


目前,国内的低代码开发平台不断涌现,Forrester划分了9类低代码平台厂商:


▪数字流程自动化(BPM):炎黄盈动(AWS PaaS)、奥哲(云枢)


▪公有云:阿里巴巴(宜搭)、百度(爱速搭)、华为(应用魔方)、微软(Power Platform)、腾讯(微搭)


▪面向专业开发者的低代码开发平台:ClickPaaS、葡萄城(活字格)、Mendix、Outsystems


▪面向业务开发者的低代码开发平台:捷德(Joget DX)、轻流


▪AI/机器学习:第四范式(HyperCycle)


▪BI:帆软(简道云)


▪协作管理:泛微(E-Builder)


▪流程自动化机器人(RPA):云扩(ViCode)、来也(流程创造者)


▪数字化运营平台:博科(Yigo)、金蝶(金蝶云·苍穹)、浪潮(iGIX)、用友(YonBIP)


由此可知,中国的低代码市场正在飞速发展,各种低代码工具的发布问世,也意味着低代码未来将成为主流的开发方式。


五、业内的低代码平台


1、Out-System


OutSytems 作为国外著名的低代码开发平台,出发点就是简化整个应用开发和交付的过程,让开发人员可以快速响应市场的需求变化。通过可视化和模型驱动的开发方式,大幅减少时间和成本。并通过预构建的连接器加速集成后端系统,同时还提供了一个集中式的控制台来管理应用的版本、发布以及部署。


OutSytems 生成的应用可以不依赖于 OutSytems 运行。数据是直接存储到数据库,这样就可以通过任何标准的 ETL、 BI或其他第三方数据工具来访问数据。


官网:

http://www.outsystems.com/demos/


2、阿里-云凤蝶


云凤蝶是蚂蚁金服体验技术部的重点研发项目,是面向中后台产品的快速研发平台,主要用户面向工程师,使用场景专注在标准化的中后台产品研发,目标是为了提高效率。


云凤蝶的核心思路是将组件生产和组件组装这两部分工作进行职责分离,通过建立一条组件组装流水线,打通 npm 组件的一键导入流程,从而完成一条产业链式的分工协作,最终实现规模化的快速生产。


淘系的“乐高”系统以及蚂蚁金服的“金蝉”系统、“云凤蝶”系统成微阿里系主要的低代码开发工具。


3、京东-星链


星链是京东科技消金基础研发部开发的一款研发效能提升工具,主要为面向后端服务研发需求,因此前端简洁可视化开发界面需要满足极致的细节,并依赖其自身后端的能力来实现用户的低代码。


核心概念:


VMS可视化微服务应用,是星链的基本单元,同时VMS也是一种模型,各种配置均在模型中。支持京东中间件(JSF、定时任务、JMQ,缓存服务、分布式配置等),服务流程编排,DEBUG调试等;


Serverless部署,星链的部署及配置均由系统自动分配。用户只需关注系统的开发,资源的使用情况。


地址:jddlink.jd.com/


结论


低代码,一定不“low”,却更low-code。


参考:


2021年低代码平台中国市场现状分析报告

http://www.authine.com/report/56.h…



作者:京东云开发者
来源:juejin.cn/post/7217449801633808439
收起阅读 »

使用fabric从零开始打造互动白板(一)

web
最近由于公司业务需要,原本使用第三方的互动白板功能,准备自行实现。一方面为了节省开支,另一方面自行实现定制化也更强一些,能够和现有业务更好的结合,用以满足第三方互动白板无法实现的一些功能。 一、功能整理 既然需求明确了,于是就开始着手整理白板所需的功能。由于...
继续阅读 »

最近由于公司业务需要,原本使用第三方的互动白板功能,准备自行实现。一方面为了节省开支,另一方面自行实现定制化也更强一些,能够和现有业务更好的结合,用以满足第三方互动白板无法实现的一些功能。



一、功能整理


既然需求明确了,于是就开始着手整理白板所需的功能。由于我们的直播课都是大班课,只需要讲师在白板上操作,用户端只用来展示,也就不需要太多复杂的功能,结合几个第三方互动白板,归纳整理出了如下几个需要实现的功能点:



  • 自由画笔

  • 文字书写

  • 橡皮擦

  • 画三角、圆形、矩形

  • 画直线和箭头

  • 清空画布

  • 撤销重做

  • 画布缩放

  • 插入PPT图片及切换控制


二、技术选择


观察了现有的互动白板,都是在Canvas进行操作,为了节约开发时间于是找到了fabric这个库,这个库本身就实现了不少功能,可以大大的减少开发时间。


结合我熟悉的技术栈,最终选定了使用Vite+Vue3+TypeScript进行demo版本的构建。


相关代码放在github上,链接地址:使用vite+typescript+fabric创建的互动白板项目


三、页面结构


参考其他白板的布局进行了页面结构的搭建。白板使用一个容器进行包裹,左侧是工具区域,包括画笔、橡皮擦、画线、清空画布等各种绘制工具;左下角是撤销、重做和画布缩放控制区域;右上角是插入PPT文件控制区域;右下角是PPT控制区域。最后提供了一个容器进行的白板预览。


效果图如下:


demo.png


页面结构代码如下:


<template>
<div>
<div class="canvas-wrap">
<div class="tool-box-out">
<ToolBox></ToolBox>
</div>
<div class="redo-undo-box">
<RedoUndo></RedoUndo>
</div>
<div class="zoom-controller-box">
<ZoomController></ZoomController>
</div>
<div class="room-controller-box" v-show="!isPreviewShow">
<div class="page-controller-mid-box">
<div className="page-preview-cell" @click="insertPPT">
<img style="width: 28px" :src="folder" alt="文件"/>
</div>
</div>
</div>
<div class="page-controller-box" v-show="isShowPPTControl">
<div className="page-controller-mid-box">
<PageController></PageController>
<div className="page-preview-cell" @click="handlePreviewState(true)">
<img :src="pages" alt="PPT预览"/>
</div>
</div>
</div>
<div class="preview-controller-box" v-show="isShowPPTControl&&isPreviewShow">
<PreviewController @handlePreviewState="handlePreviewState"></PreviewController>
</div>
<canvas id="canvas" width="800" height="450"></canvas>
</div>
<div class="canvas-wrap">
<canvas id="canvas2" width="800" height="450"></canvas>
</div>
</div>
</template>

四、初始化白板


为了方便后续使用,这里对fabric进行封装,后续拓展也能更加灵活。相关代码如下:


import { fabric } from "fabric";

class FabricCanvas {
constructor(canvasId: string) {

// 初始化画布,默认可绘制
this.canvas = new fabric.Canvas(canvasId, {
isDrawingMode: true,
selection: false,
includeDefaultValues: false, // 转换成json对象,不包含默认值
});
}
}

使用示例:


const canvas = new FabricCanvas('canvas');

五、工具栏相关功能实现


页面框架搭建完成之后,就开始各种功能的开发。这里将fabric封装成一个类,所有需要实现的方法在类中实现,方便后续灵活调用。


选择


选择功能实现比较简单,只需要关闭绘制模式,然后将画布设置可选中。相关代码如下:


this.canvas.isDrawingMode = false;
this.canvas.selection = true;
// 设置鼠标光标
this.canvas.defaultCursor = 'auto';

自由画笔


fabric提供了各种丰富的笔刷,实现自由绘制这里调用了PencilBrush类,并将isDrawingMode设置为true即可。相关代码如下:


  public drawFreeDraw() {
this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas);
this.canvas.freeDrawingBrush.color = '#ff0000'
this.canvas.freeDrawingBrush.width = 5
this.canvas.freeDrawingCursor = 'default'
this.canvas.isDrawingMode = true;
}

使用示例:


const canvas = new FabricCanvas('canvas');
// 自由画笔
canvas.drawFreeDraw();

文字书写


文字输入使用fabric提供的IText方法,实现文字编辑和修改,并且让输入框自动获取焦点,方便输入。相关代码如下:


  public drawText(text: string, options?: ITextOptions): void {
const textObj = new fabric.IText(text, {
editingBorderColor: '#ff0000',
padding: 5,
...options
});
this.canvas.add(textObj);
this.canvas.defaultCursor = 'text'
this.currentShape = textObj;
// 文本打开编辑模式
textObj.enterEditing();
// 文本编辑框获取焦点
textObj.hiddenTextarea.focus()
this.setActiveObject(textObj);
}

使用示例:


const canvas = new FabricCanvas('canvas');
// 绘制文本
canvas.drawText('Hello World!', { left: 50, top: 250, fontSize: 24, fill: 'red' })

橡皮擦


fabric内置了EraserBrush用来实现橡皮擦功能,不过默认构建排除了该功能,避免库文件过大。如需使用,需要进入node_modules/fabric目录执行下面的命令重新构建:


node build.js modules=ALL exclude=gestures,accessors requirejs minifier=uglifyjs

构建完成之后就可以使用EraserBrush来实现橡皮擦功能了,相关代码如下:


public eraser(options?: any): void {
this.canvas.freeDrawingBrush = new fabric.EraserBrush(this.canvas, options);
this.canvas.freeDrawingBrush.width = 10
this.canvas.freeDrawingCursor = 'default'
this.canvas.isDrawingMode = true;
}

使用示例:


const canvas = new FabricCanvas('canvas');
// 橡皮擦
canvas.erase({ width: 10 });

画三角、圆形、矩形


画三角形、圆形、矩形方法相似,直接调用fabric封装的对应方法即可。


这里以绘制矩形为例,相关代码实现如下:


public drawRect(options: IRectOptions): void {
const rect = new fabric.Rect({ ...this.options, ...options });
this.canvas.add(rect);
this.currentShape = rect;
this.canvas.defaultCursor = 'crosshair'
}

使用示例:


const canvas = new FabricCanvas('canvas');
// 绘制矩形
canvas.drawRect({ left: 50, top: 150, width: 100, height: 50, fill: 'green', stroke: 'black' });

画直线和箭头


画直线功能fabric直接内置了,调用对应的方法即可,这里重点讲画箭头。画箭头的本质是在直线的一端加上一个三角形,根据起始点使用三角函数计算好三角形的方向,这样就组合成一个箭头了。这里搬运网友的画箭头方法,直接对画直线功能进行拓展,并封装成fabric中的功能模块,方便后续调用。相关代码如下:


import { fabric } from 'fabric';

fabric.Arrow = fabric.util.createClass(fabric.Line, {
type: 'arrow',
superType: 'drawing',
initialize(points: number[], options: any) {
if (!points) {
const { x1, x2, y1, y2 } = options;
points = [x1, y1, x2, y2];
}
options = options || {};
this.callSuper('initialize', points, options);
},
_render(ctx: any) {
this.callSuper('_render', ctx);
ctx.save();
const xDiff = this.x2 - this.x1;
const yDiff = this.y2 - this.y1;
const angle = Math.atan2(yDiff, xDiff);
ctx.translate((this.x2 - this.x1) / 2, (this.y2 - this.y1) / 2);
ctx.rotate(angle);
ctx.beginPath();
// Move 5px in front of line to start the arrow so it does not have the square line end showing in front (0,0)
ctx.moveTo(5, 0);
ctx.lineTo(-5, 5);
ctx.lineTo(-5, -5);
ctx.closePath();
ctx.fillStyle = this.stroke;
ctx.fill();
ctx.restore();
},
});

fabric.Arrow.fromObject = (options: any, callback: any) => {
const { x1, x2, y1, y2 } = options;
return callback(new fabric.Arrow([x1, y1, x2, y2], options));
};

export default fabric.Arrow;

封装好的代码,直接导入调用即可。相关代码如下:


import Arrow from "./objects/Arrow";
// 绘制箭头
public drawArrow(x1: number, y1: number, x2: number, y2: number, options?: ILineOptions) {
const arrow = new Arrow([x1, y1, x2, y2], { ...this.options, ...options });
this.canvas.add(arrow);
this.currentShape = arrow;
this.canvas.defaultCursor = 'crosshair'
}

使用示例:


const canvas = new FabricCanvas('canvas');
// 绘制矩形
canvas.drawArrow(10, 50, 100, 50, { stroke: 'blue', strokeWidth: 2 })

通过鼠标绘制图形


实际使用白板过程中,上面这些图形、线条的绘制都是通过鼠标拖动进行,这样更加灵活一些。


通过鼠标绘制图形,需要对鼠标的mouse:downmouse:movemouse:up事件进行监听,相关代码如下:


// 监听鼠标事件
this.canvas.on("mouse:down", this.onMouseDown.bind(this));
this.canvas.on("mouse:move", this.onMouseMove.bind(this));
this.canvas.on("mouse:up", this.onMouseUp.bind(this));

这里以绘制矩形为例,实现通过通过鼠标绘制一个矩形框。



  1. 当鼠标按下时,在鼠标按下的地方绘制一个宽高为0的矩形。相关代码如下:


// 是否处于绘制状态
private isDrawing = false;
// 鼠标起点坐标x
private startX = 0;
// 鼠标起点多表y
private startY = 0;

// 鼠标按下事件处理函数
private onMouseDown(event: IEvent) {
// 如果当前有活动的元素则不进行后续绘制
const activeObject = this.canvas.getActiveObject();
if (!event.pointer || activeObject) return;

// 切换成绘制状态
this.isDrawing = true;
// 记录当前坐标点
const { x, y } = event.pointer;
this.startX = x;
this.startY = y;

// 在当前坐标绘制一个矩形
this.drawRect({
left: x,
top: y,
width: 0,
height: 0,
});
}


  1. 在鼠标移动的过程中,动态的修改矩形的宽高,并实时渲染。相关代码如下:


// 鼠标移动事件处理函数
private onMouseMove(event: IEvent) {
if (!this.isDrawing || !event.pointer || !this.currentShape) return;

// 计算宽高
const { x, y } = event.pointer;
const width = x - this.startX;
const height = y - this.startY;

// 设置宽高
this.currentShape.set({
width,
height,
});

// 更新画布
this.canvas.renderAll();
}


  1. 当鼠标抬起后,改变绘制状态。相关代码如下:


// 鼠标抬起事件处理函数
private onMouseUp() {
this.isDrawing = false;
this.currentShape = null;
}

如果想要更加灵活的在各种图形和线条中自由的进行切换,并通过鼠标绘制,在提供的demo中也进行了对应的封装。
相关代码请在github中进行查看,对fabric的各种功能封装


清空画布


清空画布直接调用画布的清除方法即可,相关代码如下:


// 清空画布
public clearCanvas() {
this.canvas.clear();
}

不过该方法会清除画布上包含背景的所有内容,如果不想画布背景也被清除,可以遍历画布上的所有对象进行移除。相关代码如下:


// 移除所有对象
public removeAllObject() {
this.canvas.getObjects().forEach((obj) => {
this.canvas.remove(obj);
});
}

六、工具栏布局
将工具栏封装成ToolBox组件,并在组件中实现各种工具的切换。


工具栏显示效果
组件布局代码如下:


<template>
<div class="tool-mid-box-left">
<div class="tool-box-cell-box-left" v-for="item in tools" :key="item.shapeType">
<div class="tool-box-cell"
@click="clickAppliance(item.shapeType)">
<img :src="item.shapeType === currentShapType ? item.iconActive : item.icon" :alt="item.name"/>
</div>
</div>
<div class="tool-box-cell-box-left">
<div class="tool-box-cell"
@click="clickClear">
<img :src="clear" alt="清屏"/>
</div>
</div>
</div>
</template>

相关功能事件实现的代码如下:


const currentShapType = ref<string>("pencil")

// 设置当前工具
function clickAppliance(type: DrawingTool) {
currentShapType.value = type;
canvas?.value.setDrawingTool(type)
}

// 清屏事件处理
function clickClear() {
canvas?.value.clearCanvas()
}

设置当前绘制工具


// 设置绘图工具
public setDrawingTool(tool: DrawingTool) {
if(this.drawingTool === tool) return;
this.canvas.isDrawingMode = false;
this.canvas.selection = false;

this.drawingTool = tool;
if (tool === "pencil") {
this.drawFreeDraw();
} else if (tool === "eraser") {
this.eraser();
} else if (tool === "select") {
this.canvas.selection = true;
this.canvas.defaultCursor = 'auto'
}
}

其他功能说明


为避免文章太长,撤销重做、画布缩放、插入PPT图片及切换控制等功能的实现在后续文章中介绍。


如果等不及,可以直接在github上查看相关代码实现:使用vite+typescript+fabric创建的互动白板项目


六、参考资料



作者:江阳小道
来源:juejin.cn/post/7221348552513077305
收起阅读 »

new 一个对象时,js 做了什么?

web
前言 在 JavaScript 中, 通过 new 操作符可以创建一个实例对象,而这个实例对象继承了原对象的属性和方法。因此,new 存在的意义在于它实现了 JavaScript 中的继承,而不仅仅是实例化了一个对象。 new 的作用 我们先通过例子来了解 n...
继续阅读 »

前言


在 JavaScript 中, 通过 new 操作符可以创建一个实例对象,而这个实例对象继承了原对象的属性和方法。因此,new 存在的意义在于它实现了 JavaScript 中的继承,而不仅仅是实例化了一个对象。


new 的作用


我们先通过例子来了解 new 的作用,示例如下:


function Person(name) {
this.name = name
}
Person.prototype.sayName = function () {
console.log(this.name)
}
const t = new Person('小明')
console.log(t.name) // 小明
t.sayName() // 小明

从上面的例子中我们可以得出以下结论:





  • new 通过构造函数 Person 创建出来的实例对象可以访问到构造函数中的属性。




  • new 通过构造函数 Person 创建出来的实例可以访问到构造函数原型链中的属性,也就是说通过 new 操作符,实例与构造函数通过原型链连接了起来。





构造函数 Person 并没有显式 return 任何值(默认返回 undefined),如果我们让它返回值会发生什么事情呢?


function Person(name) {
this.name = name
return 1
}
const t = new Person('小明')
console.log(t.name) // 小明

在上述例子中的构造函数中返回了 1,但是这个返回值并没有任何的用处,得到的结果还是和之前的例子完全一样。我们又可以得出一个结论:



构造函数如果返回原始值,那么这个返回值毫无意义。



我们再来试试返回对象会发生什么:


function Person(name) {
this.name = name
return {age: 23}
}
const t = new Person('小明')
console.log(t) // { age: 23 }
console.log(t.name) // undefined

通过上面这个例子我们可以发现,当返回值为对象时,这个返回值就会被正常的返回出去。我们再次得出了一个结论:



构造函数如果返回值为对象,那么这个返回值会被正常使用。



总结:这两个例子告诉我们,构造函数尽量不要返回值。因为返回原始值不会生效,返回对象会导致 new 操作符没有作用。


实现 new


首先我们要清楚,在使用 new 操作符时,js 做了哪些事情:



  1. js 在内部创建了一个对象

  2. 这个对象可以访问到构造函数原型上的属性,所以需要将对象与构造函数连接起来

  3. 构造函数内部的this被赋值为这个新对象(即this指向新对象)

  4. 返回原始值需要忽略,返回对象需要正常处理


知道了步骤后,我们就可以着手来实现 new 的功能了:


function _new(fn, ...args) {
const newObj = Object.create(fn.prototype);
const value = fn.apply(newObj, args);
return value instanceof Object ? value : newObj;
}

测试示例如下:


function Person(name) {
this.name = name;
}
Person.prototype.sayName = function () {
console.log(this.name);
};

const t = _new(Person, "小明");
console.log(t.name); // 小明
t.sayName(); // 小明

以上就是关于 JavaScript 中 new 操作符的作用,以及如何来实现一

作者:codinglin
来源:juejin.cn/post/7222274630395379771
个 new 操作符。

收起阅读 »

CSS链接悬停效果的的小创意

web
前言 每次写a标签的时候我都烦躁,为什么默认是蓝色的,就跟奇怪,关键他那个颜色也用不上,今天想让平凡的a标签也能做出令人眼前一亮的效果,大家以后直接cv多好 悬停滑动高亮链接效果 鼠标悬停后,链接有一个过渡动画,填充背景色,我们从链接周围的填充开始,然后添加相...
继续阅读 »

前言


每次写a标签的时候我都烦躁,为什么默认是蓝色的,就跟奇怪,关键他那个颜色也用不上,今天想让平凡的a标签也能做出令人眼前一亮的效果,大家以后直接cv多好


悬停滑动高亮链接效果


鼠标悬停后,链接有一个过渡动画,填充背景色,我们从链接周围的填充开始,然后添加相同值的负边距以防止填充破坏文本流。我们将使用box-shadow而不是 background 属性,因为它允许我们转换。


a { 
box-shadow: inset 0 0 0 0 #54b3d6;
color: #54b3d6;
margin: 0 -.25rem;
padding: 0 .25rem;
transition: color .3s ease-in-out, box-shadow .3s ease-in-out;
}
a:hover {
box-shadow: inset 100px 0 0 0 #54b3d6;
color: white;
}


悬停链接文本交换效果


我们在悬停时将链接的文本与其他一些文本交换。将鼠标悬停在文本上,链接的文本会随着新文本的滑入而滑出。


 <p><a href="#" data-replace="给个三连,好不好嘛"><span>鼠标放到这里试一试</span></a></p>

让我们给链接一些基本样式。我们需要给它相对定位来固定伪元素,确保它的显示是inline-block为了获得盒子元素样式的可供性,并隐藏伪元素可能导致的任何溢出。


  a {
overflow: hidden;
position: relative;
display: inline-block;
}

::before,::after设置为链接的全宽,左侧位置为零,并且绝对定位。


a::before,
a::after {
content: '';
position: absolute;
width: 100%;
left: 0;
}

::after伪元素从 HTML 标记中的链接数据属性获取内容:


a::after {
content: attr(data-replace);
}

transform: translate3d()::after伪元素元素向右移动 200%,悬停再回到以前的位置。


a::after {
content: attr(data-replace);
top: 0;
transform-origin: 100% 50%;
transform: translate3d(200%, 0, 0);
}

a:hover::after,
a:focus::after {
transform: translate3d(0, 0, 0);
}

我们使用transform: scaleX(0)::before伪元素,因此默认情况下它是隐藏的。悬停后我们将使它显示出来,就像2px高度一样,并将其固定到 上bottom,使其看起来像文本上的下划线那种感觉,看一下代码就理解我说的意思了


a::before {
background-color: #54b3d6;
height: 2px;
bottom: 0;
transform-origin: 100% 50%;
transform: scaleX(0);
}

a:hover::before,
a:focus::before {
transform-origin: 0% 50%;
transform: scaleX(1);
}

随后加入了transform效果、一些颜色等等以获得完整的效果。

作者:前端高级工程师宋
来源:juejin.cn/post/7143596588579946503
an>

收起阅读 »

五分钟实现一个chatGPT打字效果

web
由于chatGPT最近大火,甲方爸爸觉得这样的打字效果很酷,必须要在项目中安排一下,所以动手实现了这个效果 打字状态分析 loading - 在等待打字内容的时候光标会一直显示且闪烁 tyeing - 在打字中光标会显示但不闪烁 end - 在打字结束后光标...
继续阅读 »

由于chatGPT最近大火,甲方爸爸觉得这样的打字效果很酷,必须要在项目中安排一下,所以动手实现了这个效果


打字状态分析



  1. loading - 在等待打字内容的时候光标会一直显示且闪烁

  2. tyeing - 在打字中光标会显示但不闪烁

  3. end - 在打字结束后光标隐藏


样式


// 光标字符显示
.typing::after {
content: '▌';
}
// 光标闪烁动画
.blinker::after {
animation: blinker 1s step-end infinite;
}
@keyframes blinker {
0% {
visibility: visible;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}

内容打印功能实现


结合定时器和光标样式设置


**
* @description:
* @param {HTMLElement} dom - 打印内容的dom
* @param {string} content - 打印文本内容
* @param {number} speed - 打印速度
* @return {void}
*/
function printText(dom, content, speed = 50) {
let index = 0
setCursorStatus(dom, 'typing')
let printInterval = setInterval(() => {
dom.innerText += content[index]
index++
if (index >= content.length) {
setCursorStatus(dom, 'end')
clearInterval(printInterval)
}
}, speed)
}

/**
* @description: 设置dom的光标状态
* @param {HTMLElement} dom - 打印内容的dom
* @param {"loading"|"typing"|"end"} status - 打印状态
* @return {void}
*/

function setCursorStatus(dom, status) {
const classList = {
loading: 'typing blinker',
typing: 'typing',
end: '',
}
dom.className = classList[status]
}

效果预览


作者:chansee97
来源:juejin.cn/post/7221368910139113531
an>

收起阅读 »

同一页面多次调用图形验证码

缘由一个页面需要两个验证码,使用同一个验证码调用两次会导致有前一个失效。那么我们需要创建不同的两个验证码,分别做验证。截图展示具体实现同时引入多个KgCaptcha的js。引入多个JS时,请定义 plural 参数;通过该参数区分定义对象名...
继续阅读 »

缘由

一个页面需要两个验证码,使用同一个验证码调用两次会导致有前一个失效。那么我们需要创建不同的两个验证码,分别做验证。


截图展示



具体实现

  • 同时引入多个KgCaptcha的js。
  • 引入多个JS时,请定义 plural 参数;通过该参数区分定义对象名,如plural=1,则对象名为kg1,以此类推。
<script src="captcha.js?appid=XXX&plural=1" id="KgCaptcha1"></script>
<script src="captcha.js?appid=XXX&plural=2" id="KgCaptcha2"></script>
  • 初始化验证码
<script type="text/javascript">

// 第一个验证码
kg1.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox1",
// 验证成功事务处理
success: function(e) {
console.log(e);
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});

// 第二个验证码
kg2.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox2",
// 验证成功事务处理
success: function(e) {
console.log(e);
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});

</script>

  • 创建验证码框区域
<!-- 第一个验证码 -->
<div id="captchaBox1"></div>
<!-- 第二个验证码 -->
<div id="captchaBox2"></div>


总结

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/

收起阅读 »

一个Node.js图形验证码的生成

效果图准备访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppId、AppSecret。提供后端SDK来校验token(即安全凭据)是否合法 ,目前支持PHP版、Python版、Java/JSP版、...
继续阅读 »

效果图


准备

  • 访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppId、AppSecret。
  • 提供后端SDK来校验token(即安全凭据)是否合法 ,目前支持PHP版、Python版、Java/JSP版、.Net C#版。
  • 访问Node.js官网,下载Node.js运行环境,访问Vue.js中文官网,安装下载Vue.js,创建一个Vue项目,具体操作请查看Vue.js中文官网。

项目目录


index.html

项目根目录index.html文件,头部引用KgCaptcha的js。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!--引入凯格行为验证码js-->
<script id="KgCaptcha" src="captcha.js?appid=XXX"></script>
<!--引入凯格行为验证码js-->
</head>
<body>
<!--Vue主体-->
<div id="app"></div>
<!--Vue主体-->
</body>
</html>

main.js

src/main.js文件中,配置路由。

import Vue from 'vue'
import App from './App'
import router from './router'
Vue.config.productionTip = false
// 配置全局路由、组件
new Vue({
el: '#app',
router,
components: { App },
template: ''
})

App.vue

src/App.vue文件中,定义html。

<template>
<div id="app">
<!--自定义组件、内容-->
<form id="form">
token: <input name="token" _cke_saved_name="token" _cke_saved_name="token" _cke_saved_name="token" id="token">
<!--凯格行为验证码组件-->
<div id="captchaBox"></div>
<!--凯格行为验证码组件-->
<button type="submit">提交</button>
</form>
<!--自定义组件、内容-->
</div>
</template>

<script>
export default {
name: 'App',
}
//初始化凯格行为验证码
kg.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox",
// 验证成功事务处理
success: function(e) {
console.log(e);
kg.$('#token').value = e['token']
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});
</script>


总结

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/


收起阅读 »

Vue.js 滑动拼图验证码实现笔记

背景关于验证码的使用场景还是非常多的,很多网站上的验证码可谓是五花八门,下面是我使用Vue.js实现滑动拼图验证码做的一个笔记。效果展示准备工作访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppI...
继续阅读 »

背景

关于验证码的使用场景还是非常多的,很多网站上的验证码可谓是五花八门,下面是我使用Vue.js实现滑动拼图验证码做的一个笔记。

效果展示



准备工作

  • 访问KgCaptcha网站,注册账号后登录控制台,访问“无感验证”模块,申请开通后系统会分配给应用一个唯一的AppId、AppSecret。
  • 提供后端SDK来校验token(即安全凭据)是否合法 ,目前支持PHP版、Python版、Java/JSP版、.Net C#版。
  • 访问Vue.js中文官网,复制Vue.js插件链接。
  • 注意:先HTML头部初始化行为验证码,然后HTML底部初始化Vue.js,否则KgCaptcha的js部分函数与被Vue.js发生冲突,导致失效。

实现代码

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!--头部引入Vue.js插件-->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<!--头部引入Vue.js插件-->
<!--头部引入行为验证码js插件-->
<script id="KgCaptcha" src="captcha.js?appid=XXX"></script>
<script>
kg.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox",
// 验证成功事务处理
success: function(e) {
console.log(e);
kg.$('#token').value = e['token'];
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});
</script>
<!--头部引入行为验证码js插件-->
</head>

<body>
<div id="app">
<!--自定义内容、Vue组件-->
token: <input name="token" id="token" />
<!--行为验证码组件-->
<div id="captchaBox"></div>
<!--行为验证码组件-->
<button type="button">提交</button>
<!--自定义内容、Vue组件-->
</div>
</body>

<!--底部运行Vue.js代码-->
<script>
var app = new Vue({
el: '#app',
})
</script>
<!--底部运行Vue.js代码-->

</html>


最后

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/

收起阅读 »

整个活儿~永远加载不满的进度条

web
前言各位开发大佬,平时肯定见到过这种进度条吧,一直在加载,但等了好久都是在99% 如下所示: 有没有好奇这个玩意儿咋做的呢? 细听分说 (需要看使用:直接看实践即可)fake-progress如果需要实现上面的这个需求,其实会涉及到fake-progre...
继续阅读 »

前言

各位开发大佬,平时肯定见到过这种进度条吧,一直在加载,但等了好久都是在99%

如下所示:

有没有好奇这个玩意儿咋做的呢?
细听分说 (需要看使用:直接看实践即可)

fake-progress

如果需要实现上面的这个需求,其实会涉及到fake-progress这个库,具体是干嘛的呢?
这个库会提供一个构造函数,创建一个实例对象后,里面的属性会给我们进度条需要的数据等信息。
如图所示:


fake-progress库的源码如下:

/**
* Represents a fakeProgress
* @constructor
* @param {object} options - options of the contructor
* @param {object} [options.timeConstant=1000] - the timeConstant in milliseconds (see https://en.wikipedia.org/wiki/Time_constant)
* @param {object} [options.autoStart=false] - if true then the progress auto start
*/

const FakeProgress = function (opts) {
 if (!opts) {
   opts = {};
}
 // 时间快慢
 this.timeConstant = opts.timeConstant || 1000;
 // 自动开始
 this.autoStart = opts.autoStart || false;
 this.parent = opts.parent;
 this.parentStart = opts.parentStart;
 this.parentEnd = opts.parentEnd;
 this.progress = 0;
 this._intervalFrequency = 100;
 this._running = false;
 if (this.autoStart) {
   this.start();
}
};

/**
* Start fakeProgress instance
* @method
*/

FakeProgress.prototype.start = function () {
 this._time = 0;
 this._intervalId = setInterval(
   this._onInterval.bind(this),
   this._intervalFrequency
);
};

FakeProgress.prototype._onInterval = function () {
 this._time += this._intervalFrequency;
 this.setProgress(1 - Math.exp((-1 * this._time) / this.timeConstant));
};

/**
* Stop fakeProgress instance and set progress to 1
* @method
*/

FakeProgress.prototype.end = function () {
 this.stop();
 this.setProgress(1);
};

/**
* Stop fakeProgress instance
* @method
*/

FakeProgress.prototype.stop = function () {
 clearInterval(this._intervalId);
 this._intervalId = null;
};

/**
* Create a sub progress bar under the first progres
* @method
* @param {object} options - options of the FakeProgress contructor
* @param {object} [options.end=1] - the progress in the parent that correspond of 100% of the child
* @param {object} [options.start=fakeprogress.progress] - the progress in the parent that correspond of 0% of the child
*/

FakeProgress.prototype.createSubProgress = function (opts) {
 const parentStart = opts.start || this.progress;
 const parentEnd = opts.end || 1;
 const options = Object.assign({}, opts, {
   parent: this,
   parentStart: parentStart,
   parentEnd: parentEnd,
   start: null,
   end: null,
});

 const subProgress = new FakeProgress(options);
 return subProgress;
};

/**
* SetProgress of the fakeProgress instance and updtae the parent
* @method
* @param {number} progress - the progress
*/

FakeProgress.prototype.setProgress = function (progress) {
 this.progress = progress;
 if (this.parent) {
   this.parent.setProgress(
    (this.parentEnd - this.parentStart) * this.progress + this.parentStart
  );
}
};

我们需要核心关注的参数只有timeConstant,autoStart这两个参数,通过阅读源码可以知道timeConstant相当于分母,分母越大则加的越少,而autoStart则是一个开关,如果开启了直接执行start方法,开启累计的定时器。
通过这个库,我们实现一个虚拟的进度条,永远到达不了100%的进度条。
但是如果这时候像接口数据或其他什么资源加载完了,要到100%了怎么办呢?可以看到代码中有end()方法,因此显示的调用下实例的end()方法即可。

实践

上面讲了这么多下面结合圆形进度条(后面再出个手写圆形进度条)来实操一下,效果如下:


代码如下所示:

<template>
 <div ref="main" class="home">
   </br>
   <div>{{ fake.progress }}</div>
   </br>
   <Progress type="circle" :percentage="parseInt(fake.progress*100)"/>
   </br></br>
   <el-button @click="stop">停止</el-button>
   </br></br>
   <el-button @click="close">关闭</el-button>
 </div>
</template>

<script>
import FakeProgress from "fake-progress";

export default {
 data() {
   return {
     fake: new FakeProgress({
       timeConstant : 6000,
       autoStart : true
    })
  };
},
 methods:{
   close() {
     this.fake.end()
  },
   stop() {
     this.fake.stop()
  }
},
};
</script>

总结

如果需要实现一个永远不满的进度条,那么你可以借助fake-progress
核心是1 - Math.exp((-1 * this._time) / this.timeConstant) 这个公式
涉及到一个数据公式: e的负无穷次方 趋近于0。所以1-e^-x永远到不了1,但趋近于1

核心原理就是:用时间做分子,传入的timeConstant做分母,通过Math.exp((-1 * this._time) / this.timeConstant) 可知,如果时间不断累积且为负值,那么Math.exp((-1 * this._time) / this.timeConstant) 就无限趋近于0。所以1 - Math.exp((-1 * this._time) / this.timeConstant) 就可以得到无限趋近于1 的值

总结,如果需要使用的话,在使用的地方创建一个实例即可(配置autoStart之后就会自动累加):

new FakeProgress({
   timeConstant : 6000,
   autoStart : true
})

如果需要操作停止或介绍使用其实例下的对应方法即可

this.fake.end()
this.fake.stop()

作者:前端xs
来源:juejin.cn/post/7219195850539057212

收起阅读 »

低代码开发,是稳扎稳打还是饮鸩止渴?

web
2023年,从业者对低代码的发展充满了想象,人们认为,未来低代码它的商业价值不可估量。据Gartner的最新报告显示,到2023年,全球低代码开发技术市场规模预计将达到269亿美元,比2022年增长19.6%。 随着数字化进入深水区,企业碎片化、个性化、临时...
继续阅读 »

2023年,从业者对低代码的发展充满了想象,人们认为,未来低代码它的商业价值不可估量。据Gartner的最新报告显示,到2023年,全球低代码开发技术市场规模预计将达到269亿美元,比2022年增长19.6%。



随着数字化进入深水区,企业碎片化、个性化、临时化的需求不断涌现,而无论传统应用还是SaaS服务,都无法满足企业的全部需求,企业组织越来越多地转向低代码开发技术,以满足对快速应用交付和高度定制的自动化工作流程不断增长的需求。


image.png


中小企业的IT基础薄弱,人才有限,自研难度很大;中大型企业虽然有专门的IT部门,但审核流程长,业务部门的需求也无法立马满足。而低代码开发,只需编写少量代码或无需代码,就可以快速生成应用程序,在理论上刚好是解决这类问题的钥匙。


全民开发


低代码确实可以满足企业大部分IT需求,普通的业务人员也能进行应用搭建,成为平台的最终用户,写更少的代码,花更少的钱,干更多的事。就算是拥有独立IT部门的中大型企业,也会存在大量临时性边缘的业务需求,低代码可以很好的应对。


image.png


目前市场上有三种类型的低代码厂家:原生厂商、应用软件厂商、云厂商。随着低代码玩家越来越多,整个赛道的竞争将越来越激烈,有从业者发出呐喊:低代码产品未来到底是继续加功能,让更多开发者进来,以此满足客户普遍需求?还是通过一些其他模块或者应用市场的方式来解决客户专业需求?


一些厂商认为应该细分领域,比如深耕CRM、进销存、OKR、人事管理等热门应用模板;还有一部分厂商认为低代码的发展应该要走一条农村包围城市的路,从小处着眼,走普遍路线,主协作,帮助产研内部进行更高效的协同和项目管理,帮助IT部门更好地与业务部门建立起协作关系即可。


image.png


所以,在低代码赛道上,未来的“分流”趋势或将越来越明显。以JNPF为代表的“轻应用”派,由表单所驱动,重视数据处理能力、快速开发能力、低门槛等。


JNPF,立足于低代码开发技术,采用主流的两大技术Java/.Net开发,专注低代码开发,有拖拽式的代码生成器,灵活的权限配置、SaaS服务,强大的接口对接,随心可变的工作流引擎。支持多端协同操作,100%提供源码,支持多种云环境部署、本地部署。


image.png


基于代码生成器,可一站式开发多端使用Web、Android、IOS、微信小程序。代码自动生成后可以下载本地,进行二次开发,有效提高整体开发效率。


开源入口:http://www.yinmaisoft.com/?from=jeuji…


已经覆盖零售、医疗、制造、银行、建筑、教育、社会治理等主流行业,一站式搭建:生产管理系统、项目管理系统、进销存管理系统、OA办公系统、人事财务等等。可以节省开发人员80%时间成本,并且有以构建业务流程、逻辑和数据模型等所需的功能。



这是看得见的价值,但也有看不见的顾虑


有人认为,低代码应用是一种“饮鸩止渴”的行为,会让部分企业觉得,数字化转型就那样,哪些业务需要,就采用低代码应用“缝缝补补”即可,最终浅尝辄止,公司的整个数字化转型停在半道,欠缺完备性、统一性以及系统性。类似的问题,或许在未来会出现,也可能会在低代码应用的迭代过程中被解决。



2023,行至水深处,低代码的路会越来越难走,但这也是黎明前必经的黑暗。稻盛和夫曾说,人生如粥,熬出至味,相信在穿过重重迷雾后,2023年低代

作者:jnpfsoft
来源:juejin.cn/post/7220696541308436541
码也将迎来新的发展。

收起阅读 »

产品说要让excel在线编辑,我是这样做的。

web
背景 最近公司项目有需求, 某导入功能, 想让客户选完excel文件, 直接将加载到web的excel编辑器中, 修改、确认, 之后上传导入。 效果查看 选择 Luckysheet(dream-num.github.io/LuckysheetD…) ,一款...
继续阅读 »

背景


最近公司项目有需求, 某导入功能, 想让客户选完excel文件, 直接将加载到web的excel编辑器中, 修改、确认, 之后上传导入。


效果查看


Kapture 2023-04-13 at 13.37.05.gif


选择



就看到了这两个, 最后选择了Luckysheet, 看他的star比较多, 哈哈。


需求实现分析


分析一下整个流程。


其实大体就两步, 搞进去,抽离出来。


一、加载本地excel到web编辑器中


1、拿到本地excel文件流


2、转换为 Luckysheet 要的格式


3、new 一个 Luckysheet 实例, 挂在到对应标签上


完成以上就把excel加载进去了, 显示出来了。


在线编辑的事就是这个库帮咱们搞定了.


二、 从web编辑器导出文件流 上传


等客户在线编辑完成, 就需要点击一个按钮, 导出文件流, 确认并调接口上传


1、获取 Luckysheet里工作表的数据


image.png


luckysheet.getAllSheets()

2、将数据加工并使用xlsx或者exceljs导出文件流


导出为为arrayBuffer, 再将arrayBuffer转为Blob


3、调后端接口上传


开发实践


一、引入 lucky-sheet


有两种方式


1、官方文档里的cdn


这种加载有点慢


<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css' />
<script src="https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js"></script>

2、自己打包, 传到oss, 引入(推荐)



第一种第三方的cdn不稳定, 有时候很慢,还是建议,拉他的仓库,然后打个包,传到自己静态资源库, 来使用



npm run builddist 传上去使用


二、指定容器


<div id="luckysheet"></div>

三、导入本地文件


1、 用elment的上传文件组件 选择文件


但是这里不上传,仅仅是用它选择文件拿到文件对象File


<div class="import-okr">
<!-- ,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet -->
<el-upload
v-model:file-list="fileList"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
class="upload-demo"
:before-upload="beforeUpload"
action=""
:show-file-list="false"
>
<button @click="uploadFile">上传数据</button>
</el-upload>
</div>

2、beforeUpload 方法拿到文件


const beforeUpload = (file) => {
console.log(file)
}

image.png


3、将文件流转换为lucky要的格式


github.com/dream-num/L…


安装转换工具


npm install luckyexcel

使用


// After getting the xlsx file
LuckyExcel.transformExcelToLucky(file,
function(exportJson, luckysheetfile){
// exportJson就是转换后的数据
},
function(error){
// handle error if any thrown
}

4、将转换后的数据创建表格


// 将拿到的数据创建表格
luckysheet.create({
container: 'luckysheet', // luckysheet is the container id
data:exportJson.sheets,
title:exportJson.info.name,
userInfo:exportJson.info.creator,
lang: 'zh', // 设定表格语言
myFolderUrl: window.location.href,
showtoolbarConfig: {
pivotTable: false, //'数据透视表'
// protection: false, // '工作表保护'
print:false, // '打印'
image: false, // 插入图片
},
showinfobar: false,
options: {
// 其他配置
userImage:'http://qzz-static.forwe.store/public-assets/pgy_kj_pic_logo.png?x-oss-process=image/resize,m_fill,w_72,h_72', // 头像url
userName:'Lucky', // 用户名
}
});

完整代码


const beforeUpload = (file) => {
console.log(file)
// 转换工具, 将文件流转换为lucky要的格式
LuckyExcel2.transformExcelToLucky(
file,
function(exportJson, luckysheetfile){
isShowExcel.value = true
console.log(exportJson)
nextTick(() => {
window.luckysheet.destroy();
// 将拿到的数据创建表格
luckysheet.create({
container: 'luckysheet', // luckysheet is the container id
data:exportJson.sheets,
title:exportJson.info.name,
userInfo:exportJson.info.creator,
lang: 'zh', // 设定表格语言
myFolderUrl: window.location.href,
showtoolbarConfig: {
pivotTable: false, //'数据透视表'
// protection: false, // '工作表保护'
print:false, // '打印'
image: false, // 插入图片
},
showinfobar: false,
options: {
// 其他配置
userImage:'http://qzz-static.forwe.store/public-assets/pgy_kj_pic_logo.png?x-oss-process=image/resize,m_fill,w_72,h_72', // 头像url
userName:'Lucky', // 用户名
}
});
})
},
function(err){
logger.error('Import failed. Is your fail a valid xlsx?');
});
}

四、导出


1、利用 luckysheet.getAllSheets() 获取表数据


console.log(luckysheet.getAllSheets())

image.png


2、exceljs将上述对象转换为excel文件流


import Excel  from 'exceljs'
// 导出excel
const exportExcel = async function (luckysheet) { // 参数为luckysheet.getluckysheetfile()获取的对象
// 1.创建工作簿,可以为工作簿添加属性
const workbook = new Excel.Workbook()
// 2.创建表格,第二个参数可以配置创建什么样的工作表
luckysheet.every(function (table) {
if (table.data.length === 0) return true
const worksheet = workbook.addWorksheet(table.name)
// 3.设置单元格合并,设置单元格边框,设置单元格样式,设置值
setStyleAndValue(table.data, worksheet)
setMerge(table.config.merge, worksheet)
setBorder(table.config.borderInfo, worksheet)
return true
})
// 4.写入 buffer
const buffer = await workbook.xlsx.writeBuffer()
return buffer
}

3、 写个方法,执行上述两步


// 保存文件
const onClickSaveFile = async ( ) => {
console.log(luckysheet.getAllSheets())
const buf = await exportExcel(luckysheet.getAllSheets())
const blob = new Blob([buf]);
// $emit('file', blob)
handleUpload(blob)
}

4、上传方法


利用formData, 将生成的文件二进制流发给后端


const handleUpload = async(file) => {
// isShowExcel.value = false
const loading = ElLoading.service({
fullscreen: true,
text: '上传中,请稍等',
background: 'rgba(0,0,0,0.1)'
});
try {
const formData = new FormData()
formData.append('file', file)
const {code, data, message } = await IMPORT_OKR(formData)
if(code === 1) {
//...
}
loading.close()
} catch (error) {
console.log(error)
loading.close()
}
}

遇到问题


1、iconfont冲突


lucky-sheet这个项目里的iconfont类名和我项目里一样,导致有些被覆盖了.
image.png


解决: 将他项目里 iconfont 换成 lucky-sheet, 相关类名也全部替换, 然后重新打包,再引入,即可解决


2、lucky-sheet层级不够高,无法编辑


image.png


elmentui和antd的一些组件层级比较高,所以, 让kucky的层级更高即可


解决: 增加下述css即可


.luckysheet-input-box { z-index: 2000; } .luckysheet-cols-menu { z-index: 2001; }

最后


妥妥的都是站在巨人的肩膀上


求赞


作者:浏览器API调用工程师
来源:juejin.cn/post/7221368910139342907
收起阅读 »

KgCaptcha滑动拼图验证码在搜索中的作用

开头验证码应用于我们生活、工作的方方面面,比如注册登录账号、支付订单、修改密码等。下面我是在一次项目中利用滑动拼图验证码和搜索功能“合作共赢”的记录。验证码展示具体实现前端代码// 引入js<script src="captcha.js?appid=XX...
继续阅读 »

开头

验证码应用于我们生活、工作的方方面面,比如注册登录账号、支付订单、修改密码等。下面我是在一次项目中利用滑动拼图验证码和搜索功能“合作共赢”的记录。

验证码展示



具体实现

前端代码
// 引入js
<script src="captcha.js?appid=XXX"></script>
<script>
kg.captcha({
// 绑定弹窗按钮
button: "#captchaButton",

// 验证成功事务处理
success: function (e) {
// 验证成功,直接提交表单
// form1.submit();
console.log(e);
},

// 验证失败事务处理
failure: function (e) {
console.log(e);
},

// 点击刷新按钮时触发
refresh: function (e) {
console.log(e);
}
});
</script>

<a id="captchaButton"></a>



验证结果说明

 

字段名
数据类型描述
 

code
 

number
 

返回code信息
 

msg
 

string
 

验证结果信息
 

rid
 

number
 

用户的验证码应用id
 

sense
 

number
 

是否开启无感验证,0-关闭,1-开启
 

token
 

string
 

验证成功才有:token
 

weight
 

number
 

错误严重性,0正常错误,可以继续操作,1一般错误,刷新/重新加载拼图,2严重错误,错误次数过多拒绝访问


Python代码

from wsgiref.simple_server import make_server
from KgCaptchaSDK import KgCaptcha
def start(environ, response):
# 填写你的 AppId,在应用管理中获取
AppID = "AppId"
# 填写你的 AppSecret,在应用管理中获取
AppSecret = "AppSecret"
request = KgCaptcha(AppID, AppSecret)
# 填写应用服务域名,在应用管理中获取
request.appCdn = "https://cdn.kgcaptcha.com"
# 请求超时时间,秒
request.connectTimeout = 10
# 用户id/登录名/手机号等信息,当安全策略中的防控等级为3时必须填写
request.userId = "kgCaptchaDemo"
# 使用其它 WEB 框架时请删除 request.parse,使用框架提供的方法获取以下相关参数
parseEnviron = request.parse(environ)
# 前端验证成功后颁发的 token,有效期为两分钟
request.token = parseEnviron["post"].get("kgCaptchaToken", "") # 前端 _POST["kgCaptchaToken"]
# 客户端IP地址
request.clientIp = parseEnviron["ip"]
# 客户端浏览器信息
request.clientBrowser = parseEnviron["browser"]
# 来路域名
request.domain = parseEnviron["domain"]
# 发送请求
requestResult = request.sendRequest()
if requestResult.code == 0:
# 验证通过逻辑处理
html = "验证通过"
else:
# 验证失败逻辑处理
html = f"{requestResult.msg} - {requestResult.code}"
response("200 OK", [("Content-type", "text/html; charset=utf-8")])
return [bytes(str(html), encoding="utf-8")]
httpd = make_server("0.0.0.0", 8088, start) # 设置调试端口 http://localhost:8088/
httpd.serve_forever()


最后

SDK开源地址:KgCaptcha (KgCaptcha) · GitHub,顺便做了一个演示:凯格行为验证码在线体验

收起阅读 »

Java实现KgCaptcha短信验证码

背景Java是一种流行的编程语言,验证码是一种常用的网络安全技术。Java发展至今,网上也出现了各种各样的验证码,本人初学Java,下面是我用Java实现短信验证码的总结。截图展示实现代码后台接收前台的kgCaptchaToken进行验证,验证成功执行成功处理...
继续阅读 »

背景

Java是一种流行的编程语言,验证码是一种常用的网络安全技术。Java发展至今,网上也出现了各种各样的验证码,本人初学Java,下面是我用Java实现短信验证码的总结。

截图展示



实现代码

后台接收前台的kgCaptchaToken进行验证,验证成功执行成功处理,验证失败返回错误代码及信息。

package com.kyger;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;

public class demo extends HttpServlet {
private static final long serialVersionUID = 1L;

public demo() {
super();
}

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

// 编码
request.setCharacterEncoding("utf-8");
response.setCharacterEncoding("utf-8");;
response.setContentType("text/html; charset=utf-8");

// 后台处理
if (request.getMethod().equals("POST")){
String html, appId, appSecret, Token;

// 设置 AppId 及 AppSecret,在应用管理中获取
appId = "appId";
appSecret = "appSecret";

// 填写你的 AppId 和 AppSecret,在应用管理中获取
KgCaptchaSDK KgRequest = new KgCaptchaSDK(appId, appSecret);


// 前端验证成功后颁发的 token,有效期为两分钟
KgRequest.token = request.getParameter("kgCaptchaToken");
// System.out.print(KgRequest.token);

// 填写应用服务域名,在应用管理中获取
KgRequest.appCdn = "https://cdn.kgcaptcha.com";

// 请求超时时间,秒
KgRequest.connectTimeout = 5;

// 用户登录或尝试帐号,当安全策略中的防控等级为3时必须填写,一般情况下可以忽略
// 可以填写用户输入的登录帐号(如:request.getParameter("username"),可拦截同一帐号多次尝试等行为
KgRequest.userId = "kgCaptchaDemo";

// request 对象,当安全策略中的防控等级为3时必须填写,一般情况下可以忽略
KgRequest.request = request;
// java 环境中无法提供 request 对象,请分别定义:clientIp|clientBrowser|domain 参数,即:
// KgRequest.clientIp = "127.0.0.1"; // 填写客户端IP
// KgRequest.clientBrowser = ""; // 客户端浏览器信息
// KgRequest.domain = "http://localhost"; // 你的授权域名或服务IP

// 发送验证请求
Map requestResult = KgRequest.sendRequest();
if("0".toString().equals(requestResult.get("code"))) {
// 验签成功逻辑处理 ***

// 这里做验证通过后的数据处理
// 如登录/注册场景,这里通常查询数据库、校验密码、进行登录或注册等动作处理
// 如短信场景,这里可以开始向用户发送短信等动作处理
// ...

html = "";
} else {
// 验签失败逻辑处理
html = "";
}

response.getWriter().append(html);
} else {
response.sendRedirect("index.html");
}
}

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
doGet(request, response);
}

}


后端检测

后台接收数据,同时对来源及应用进行检测。

# 服务器黑名单检测
if self.auth.client_blacklist():
return self.r_code(20017) # 服务器黑名单

# 验签次数限制检测
excess = self.auth.excess(2)
if excess:
return self.r_code(code=[20020, 20021, 20022][excess - 1])

# 来路域名检测
if not self.kg["HTTP_REFERER"]: return self.r_code(20004) # 域名不合法,无法获取来路域名
if not self.auth.domain_auth(): return self.r_code(20005) # 来源域名未授权

# 应用有效时间检测
validity = self.auth.app_validity()
if validity[0] == 1: return self.r_code(20006) # 授权未开始
if validity[0] == 2: return self.r_code(20007) # 授权已结束

if self.auth.app_state(): return self.r_code(20008) # 当前应用/域名被禁用


结尾

SDK开源地址:KgCaptcha (KgCaptcha) · GitHub,顺便做了一个演示:凯格行为验证码在线体验

收起阅读 »

🚀 我用一小时实现的娃娃机,你敢信?

web
生活不止眼前的苟且,还有诗和远方 掘友们,大家好我是前端奶爸,入行5年的前端小学生🥜~ 工作八小时摸鱼四小时,喜欢跑步但不是为了让自己更瘦,而是为了让自己活得更久~ 活到九十九,卷到九十九~ 前言 前段时间去商场吃饭的时候看到一个有趣的娃娃机,一个密封的机...
继续阅读 »

生活不止眼前的苟且,还有诗和远方



掘友们,大家好我是前端奶爸,入行5年的前端小学生🥜~

工作八小时摸鱼四小时,喜欢跑步但不是为了让自己更瘦,而是为了让自己活得更久~

活到九十九,卷到九十九~



前言


前段时间去商场吃饭的时候看到一个有趣的娃娃机,一个密封的机器里底部放着一些被捆绑好的龙虾,可以买币去抓龙虾,抓到以后可以初加工费找附近的商家给做成龙虾大餐,感觉很有意思,把抓抓玩出了一个新的高度~


主要是抓到以后还可以出手工费进行烹饪,很吸引人,周边围观的人也很多,观察了一会发现。爪子的抓力不够,龙虾在水里还能移动,而且感觉每一个个头都不小,那小爪感觉根本抓不起来~~


到家后孩子就说爸爸你可不可以做一个娃娃机呢?


身为一个程序员,这点要求我感觉还是难不倒我,然后就突发奇想,给孩子在手机上做一个简易娃娃机。起初的想法是哄她开心,看到掘金最近有小游戏的活动,顺便分享给大家~~


效果


简易娃娃机.gif


如上图,一个移动的抓手,以及几个礼物样品,还有左右移动,抓起按钮,素材很简单,但是做出来的效果还是有娃娃机的感觉的~


地址


代码托管地址在:github在线预览地址资源路径不对无法访问,如果有需要源码的同学可以自行去git仓库获取~


布局


布局部分比较简单,直接贴代码了。可以根据自己的需求不同自定义即可~


<div class="page-portrait" id="page-portrait">
<div id="pageContainer" class="page-container game-box">
<div class="poster-main">
<ul class="poster-list">
<li class="item lw1"><img src="images/dx-lw1.png" alt=""></li>
<li class="item lw2"><img src="images/dx-lw2.png" alt=""></li>
<li class="item lw3"><img src="images/dx-lw3.png" alt=""></li>
<li class="item lw4"><img src="images/dx-lw4.png" alt=""></li>
<li class="item lw5"><img src="images/dx-lw5.png" alt=""></li>
<li class="item lw6"><img src="images/dx-lw6.png" alt=""></li>
</ul>
</div>
<div id="stop" class="button"></div>
<div id="left" class="left-btn"></div>
<div id="right" class="right-btn"></div>
<div class="zhua-top">
<span class="zhua-zuo"></span>
<span class="zhua-zhu"></span>
<div class="zhua zhuamove"></div>
</div>
</div>
</div>

css用到了几个运动处理了爪子的动效,如下方代码所示


@keyframes run {
0% {
background-image: url(../images/dx-zhua3.png);
}
25% {
background-image: url(../images/dx-zhua2.png);
}
50% {
background-image: url(../images/dx-zhua1.png);
}
75% {
background-image: url(../images/dx-zhua2.png);
}
100% {
background-image: url(../images/dx-zhua3.png);
}
}
@keyframes zhuashou {
0% {
top: 360px;
background-image: url(../images/dx-zhua1.png);
}
100% {
top: 360px;
background-image: url(../images/dx-zhua2.png);
}
}
@keyframes zhuadown {
0% {
top: 138px;
background-image: url(../images/dx-zhua1.png);
}
100% {
top: 360px;
background-image: url(../images/dx-zhua1.png);
}
}
@keyframes zhua-slideUp {
0% {
top: 360px;
background-image: url(../images/dx-zhua2.png);
}
100% {
top: 138px;
background-image: url(../images/dx-zhua2.png);
}
}
@keyframes img-slideUp {
0% {
top: 23px;
}
100% {
top: -200px;
}
}

js代码创建了一个控制器类,处理事件以及动画效果的交替等。


var Carousel = {
data: {
result: 1
},
init: function () {
Carousel.control();
},
stop: function () {
$(".zhua").removeClass("zhuamove").addClass("zhuadown");
$(".zhua-zhu").addClass("zhudown");
var timer01 = setTimeout(function () {
$(".zhua").removeClass("zhuadown").addClass("zhuashou");
var timer03 = setTimeout(function () {
$(".zhua").removeClass("zhuashou").addClass("zhuaup");
$(".zhua-zhu").removeClass("zhudown").addClass("zhuup");
$(".poster-list .lw" + (Carousel.data.result + 1)).addClass("img-slideUp");
clearTimeout(timer03);
timer03 = null;
}, 800);
var timer02 = setTimeout(function () {
$(".zhua").removeClass("zhuaup").removeClass("zhuaup1");
$(".zhua-zhu").removeClass("zhuup");
clearTimeout(timer02);
timer02 = null;
alert("恭喜您抽中一等奖~");
Carousel.start();
}, 2500);
clearTimeout(timer01);
timer01 = null;
}, 1000);
},
start: function () {
$(".zhua").addClass("zhuamove");
$(".zhua").removeClass("zhuadown").removeClass("zhuaup1").removeClass("zhuaup");
$(".poster-list .item").removeClass("img-slideUp").removeClass("img-slideOutUp");
},
zhuaMove: function (num) {
switch (num) {
case 0:
$(".zhua-top").animate({
left: -145,
},300);
break;
case 1:
$(".zhua-top").animate({
left: 0,
},300);
break;
case 2:
$(".zhua-top").animate({
left: 145,
},300);
break;
}
},
control: function () {
$("#left").on("click", function () {
Carousel.data.result--;
if (Carousel.data.result <= 0) {
Carousel.data.result = 0;
}
Carousel.zhuaMove(Carousel.data.result);
});
$("#stop").click(Carousel.stop);
$("#right").on("click", function () {
Carousel.data.result++;
if (Carousel.data.result >= 2) {
Carousel.data.result = 2;
}
Carousel.zhuaMove(Carousel.data.result);
});
},
};

总结


css现在有很多的新的特性可以解决我们工作中遇到的动效以及兼容问题,有心的同学可以多多查阅文档,写一写自己感兴趣的小demo,或者给孩子做一个小游戏来玩,何尝不是一件有成就的事呢~


我是奶爸,喜欢我的可以关注我,有什么新的想法或者意见也可以在评论区留言,我们共同学习,共同进步~



最后希望疫情早早结束,微风袭来,春暖花开~~~



作者:前端奶爸
来源:juejin.cn/post/7089371535588196366
收起阅读 »

【404】你访问的页面需要关灯后查看!

web
前言 今天在掘金首页刷到一篇文章,就是那种文字根据不同的色块显示不同的颜色,我想着能不能做一个探照灯似的 404 页面呢。毕竟也可以根据不同的白色光照来改变文字颜色的。 为了酷炫一点,先来个背景 👉 背景相对来说比较简单了,就是一些纯粹的漂浮点 <div...
继续阅读 »

前言


今天在掘金首页刷到一篇文章,就是那种文字根据不同的色块显示不同的颜色,我想着能不能做一个探照灯似的 404 页面呢。毕竟也可以根据不同的白色光照来改变文字颜色的。


为了酷炫一点,先来个背景


👉 背景相对来说比较简单了,就是一些纯粹的漂浮点


<div>
<div class="starsec"></div>
<div class="starthird"></div>
<div class="starfourth"></div>
<div class="starfifth"></div>
</div>

👉 为了显得与众不同,我们就用四个不同的 div 元素来写样式


.starsec {
content: " ";
position: absolute;
width: 3px;
height: 3px;
background: transparent;
box-shadow: 571px 173px #00BCD4, 1732px 143px #00BCD4, 1745px 454px #FF5722, 234px 784px #00BCD4, 1793px 1123px #FF9800, 1076px 504px #03A9F4, 633px 601px #FF5722, 350px 630px #FFEB3B, 1164px 782px #00BCD4, 76px 690px #3F51B5, 1825px 701px #CDDC39, 1646px 578px #FFEB3B, 544px 293px #2196F3, 445px 1061px #673AB7, 928px 47px #00BCD4, 168px 1410px #8BC34A, 777px 782px #9C27B0, 1235px 1941px #9C27B0, 104px 1690px #8BC34A, 1167px 1338px #E91E63, 345px 1652px #009688, 1682px 1196px #F44336, 1995px 494px #8BC34A, 428px 798px #FF5722, 340px 1623px #F44336, 605px 349px #9C27B0, 1339px 1344px #673AB7, 1102px 1745px #3F51B5, 1592px 1676px #2196F3, 419px 1024px #FF9800, 630px 1033px #4CAF50, 1995px 1644px #00BCD4, 1092px 712px #9C27B0, 1355px 606px #F44336, 622px 1881px #CDDC39, 1481px 621px #9E9E9E, 19px 1348px #8BC34A, 864px 1780px #E91E63, 442px 1136px #2196F3, 67px 712px #FF5722, 89px 1406px #F44336, 275px 321px #009688, 592px 630px #E91E63, 1012px 1690px #9C27B0, 1749px 23px #673AB7, 94px 1542px #FFEB3B, 1201px 1657px #3F51B5, 1505px 692px #2196F3, 1799px 601px #03A9F4, 656px 811px #00BCD4, 701px 597px #00BCD4, 1202px 46px #FF5722, 890px 569px #FF5722, 1613px 813px #2196F3, 223px 252px #FF9800, 983px 1093px #F44336, 726px 1029px #FFC107, 1764px 778px #CDDC39, 622px 1643px #F44336, 174px 1559px #673AB7, 212px 517px #00BCD4, 340px 505px #FFF, 1700px 39px #FFF, 1768px 516px #F44336, 849px 391px #FF9800, 228px 1824px #FFF, 1119px 1680px #FFC107, 812px 1480px #3F51B5, 1438px 1585px #CDDC39, 137px 1397px #FFF, 1080px 456px #673AB7, 1208px 1437px #03A9F4, 857px 281px #F44336, 1254px 1306px #CDDC39, 987px 990px #4CAF50, 1655px 911px #00BCD4, 1102px 1216px #FF5722, 1807px 1044px #FFF, 660px 435px #03A9F4, 299px 678px #4CAF50, 1193px 115px #FF9800, 918px 290px #CDDC39, 1447px 1422px #FFEB3B, 91px 1273px #9C27B0, 108px 223px #FFEB3B, 146px 754px #00BCD4, 461px 1446px #FF5722, 1004px 391px #673AB7, 1529px 516px #F44336, 1206px 845px #CDDC39, 347px 583px #009688, 1102px 1332px #F44336, 709px 1756px #00BCD4, 1972px 248px #FFF, 1669px 1344px #FF5722, 1132px 406px #F44336, 320px 1076px #CDDC39, 126px 943px #FFEB3B, 263px 604px #FF5722, 1546px 692px #F44336;
animation: animStar 150s linear infinite;
}

👉 颜色阴影部分都是一样的,不一样的地方就在于宽高和动画时长。


👉 大家可以根据自己的想法去修改不同的宽高和时长哦


👉 动画效果需要额外写一下的哦


@keyframes animStar {
0% {
transform: translateY(0px);
}

100% {
transform: translateY(-2000px);
}
}

screenshots.gif


画灯杆(电线)


👉 一般探照灯都是在顶上的,所以就需要用一根电线连接在顶部


<div class="lamp__wrap">
<div class="lamp">
<div class="cable"></div>
</div>
</div>


  • 后面的灯元素相关内容都会在 lamp 样式标签下面哦!


.lamp__wrap {
max-height: 100vh;
overflow: hidden;
max-width: 100vw;
}
.lamp {
position: absolute;
left: 0px;
right: 0px;
top: 0px;
margin: 0px auto;
width: 300px;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: center top;
animation-timing-function: cubic-bezier(0.6, 0, 0.38, 1);
animation: move 5.1s infinite;
}


  • 在处理动画的时候,使用了一个 cubic-bezier 方法,它是用来定义贝塞尔曲线的


@keyframes move {
0% {
transform: rotate(40deg);
}

50% {
transform: rotate(-40deg);
}

100% {
transform: rotate(40deg);
}
}


  • 动画效果就是将灯杆旋转不同的角度



注意一下,动画效果是在整个灯的样式中完成的,所以后面的都只需要写各自的样式就行了,不需要补充动画效果。



.cable {
width: 8px;
height: 248px;
background-image: linear-gradient(rgb(32 148 218 / 70%), rgb(193 65 25)), linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7));
}


  • 灯杆给了一个渐变色的样式效果


screenshots.gif


画灯罩


👉 灯杆已经有了,那就加一个灯罩就行了


<div class="cover"></div>

.cover {
width: 200px;
height: 80px;
background: #0bd5e8;
border-top-left-radius: 50%;
border-top-right-radius: 50%;
position: relative;
z-index: 200;
}


  • 灯罩是通过不同的 border-radius 的效果画出来的


screenshots.gif


画灯泡


👉 灯泡也是比较简单的样式,一个半圆加一部分阴影即可


<div class="in-cover">
<div class="bulb"></div>
</div>

.in-cover {
width: 100%;
max-width: 200px;
height: 20px;
border-radius: 100%;
background: #08ffff;
position: absolute;
left: 0px;
right: 0px;
margin: 0px auto;
bottom: -9px;
z-index: 100;
}

.in-cover .bulb {
width: 50px;
height: 50px;
background-color: #08fffa;
border-radius: 50%;
position: absolute;
left: 0px;
right: 0px;
bottom: -20px;
margin: 0px auto;
-webkit-box-shadow: 0 0 15px 7px rgba(0, 255, 255, 0.8), 0 0 40px 25px rgba(0, 255, 255, 0.5), -75px 0 30px 15px rgba(0, 255, 255, 0.2);
box-shadow: 0 0 25px 7px rgb(127 255 255 / 80%), 0 0 64px 47px rgba(0, 255, 255, 0.5), 0px 0 30px 15px rgba(0, 255, 255, 0.2);
}

screenshots.gif


来一束追光效果吧


👉 追光就是通过一个边框线画出来的


<div class="light"></div>

.light {
width: 200px;
height: 0px;
border-bottom: 900px solid rgb(44 255 255 / 24%);
border-left: 50px solid transparent;
border-right: 50px solid transparent;
position: absolute;
left: 0px;
right: 0px;
top: 270px;
margin: 0px auto;
z-index: 1;
border-radius: 90px 90px 0px 0px;
}


  • 给边框的宽度和背景透明色就可以看出追光的效果了。


screenshots.gif


文字


👉 文字通过定位居中之后,刚好显示在灯光动画效果范围之内


<section class="error">
<div class="error__content">
<div class="error__message message">
<h1 class="message__title">掘金错误页面</h1>
<p class="message__text">不好意思,你访问的页面不存在,请关灯后重新尝试</p>
</div>
</div>
</section>

👉 文字颜色和背景色一致之后,通过灯光的透明度效果就可以实现文字显隐了。


.error {
min-height: 100vh;
position: relative;
padding: 240px 0;
box-sizing: border-box;
width: 100%;
height: 100%;
text-align: center;
margin-top: 70px;
}

.error__overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}

.error__content {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}

.error__message {
text-align: center;
color: #181828;
}

.message__title {
font-family: 'Montserrat', sans-serif;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 5px;
font-size: 5.6rem;
padding-bottom: 40px;
max-width: 960px;
margin: 0 auto;
}

.message__text {
font-family: 'Montserrat', sans-serif;
line-height: 42px;
font-size: 18px;
padding: 0 60px;
max-width: 680px;
margin: auto;
}

screenshots.gif


码上掘金查看效果



作者:蜡笔小心_
来源:juejin.cn/post/7150950812489875469
收起阅读 »

记录一次机器学习模型部署

web
简介:做了一个语音识别心脏病的机器学习模型,想要实现前后端简单的部署 用到的技术栈:Python、Flask、uni-app 前端 使用uni-app做一个小程序。需要具有语音采集、录音回放、录音上传等功能。使用uni.getRecorderManager()...
继续阅读 »

简介:做了一个语音识别心脏病的机器学习模型,想要实现前后端简单的部署


用到的技术栈:Python、Flask、uni-app


前端


使用uni-app做一个小程序。需要具有语音采集、录音回放、录音上传等功能。使用uni.getRecorderManager()实现对录音全局的控制。


屏幕截图 2023-04-04 155350.png


这里给出实现上传录音并接收请求结果的主要代码


upload() {
console.log(this.voicePath)
uni.uploadFile({
url: 'http://202.115.52.33:9500/process_data',
filePath: this.voicePath,
name: 'file',
fileType: "audio", //文件类型
success: (res) => {
console.log('success',res.data)
uni.showToast({
title: '上传成功',
icon: 'success',
});
if(res.data*1 < 0.35){
uni.showModal({
title: '检测结果',
content: '您患心脏病概率为:'+ Number(res.data*100).toFixed(2) + '%'+',心脏健康请继续保持',
success: function (res) {
if (res.confirm) {
console.log('用户点击确定');
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
}else{
uni.showModal({
title: '检测结果',
content: '您患心脏病概率为:'+ Number(res.data*100).toFixed(2) + '%'+',请及时到医院检查',
success: function (res) {
if (res.confirm) {
console.log('用户点击确定');
} else if (res.cancel) {
console.log('用户点击取消');
}
}
});
}

},
fail: (err) => {
console.log((err))
uni.showToast({
title: '上传失败',
icon: 'none',
});
},
});
}

这里有个坑需要注意,微信开发者工具模拟器录音上传到服务器,服务器无法正常使用录音(一直以为是前端上传语音的问题)。开发者工具录音文件为silk格式,说是silk其实是base64加密后的webm格式,不是普通的wav格式(貌似只能用chrome浏览器打开)。可以参考这篇文章微信小程序-录音文件无法播放问题 - 知乎 (zhihu.com),真机调试则不会出现这个问题。


后端


采用Flask来进行机器学习或者深度学习模型的部署。


# app.py
from flask import Flask, request
from predict import predict

app = Flask(__name__)

@app.route('/process_data', methods=['POST'])
def process_data():
    # 从前端接收音频文件
    fileStorage = request.files['file']  # 视频文件
    buffer_data = fileStorage.read()
    filename = request.files['file'].filename
    temp_path = 'upload/'+filename
    with open(temp_path, 'wb+') as f:
        f.write(buffer_data)  # 二进制转为音频文件
    # 模型推理
    predict_outcome = round(predict(temp_path), 4)
    # 返回结果
    return str(predict_outcome)


if __name__ == "__main__":
    app.run()

部署


使用宝塔面板实现Flask项目快速部署。



  1. 在宝塔面板中安装Python项目管理器软件


屏幕截图 2023-04-04 162932.png



  1. 上传Flask项目到服务器相应目录

  2. 在Python项目管理选择Flask框架,安装Flask项目中需要的第三方包
    这里有个需要注意的问题,我修改了第三方包的源码,下载的第三方包存放目录:上传项目文件夹/一串数字_venv/lib/python3.7/site-packages,在这里修改源码重启Python服务才能生效。

  3. Python项目管理器配置参考


bind = '0.0.0.0:5000'
user = 'scu611'
workers = 1
threads = 2
backlog = 512
daemon = True
chdir = '/www/server/phpmyadmin/heartbroken'
access_log_format = '%(t)s %(p)s %(h)s "%(r)s" %(s)s %(L)s %(b)s %(f)s" "%(a)s"'
loglevel = 'info'
worker_class = 'geventwebsocket.gunicorn.workers.GeventWebSocketWorker'
errorlog = chdir + '/logs/error.log'
accesslog = chdir + '/logs/access.log'
pidfile = chdir + '/logs/heartbroken.pid'

作者:用户7850680667062
来源:juejin.cn/post/7218098727608549432
收起阅读 »

GeoJSON:地理信息的JSON表示法

web
简介 GeoJSON 是一种使用 JSON 来编码各种地理数据结构的格式,是一种轻量级的数据交换格式,可以用来表示几何对象、属性数据、空间参考系统等信息 由两种对象组成:Geometry(几何对象)和 Feature(空间行状) 几何对象用来描述地理空间中的...
继续阅读 »

简介


GeoJSON 是一种使用 JSON 来编码各种地理数据结构的格式,是一种轻量级的数据交换格式,可以用来表示几何对象、属性数据、空间参考系统等信息


由两种对象组成:Geometry(几何对象)和 Feature(空间行状)



  • 几何对象用来描述地理空间中的点、线、面等几何形状

  • 空间行状用来描述一个有界的实体,包括几何对象和其他属性信息


几何对象类型有:



  • 点:Point

  • 多点:MultiPoint

  • 线:LineString

  • 多线:MultiLineString

  • 面:Polygon

  • 多面:MultiPolygon

  • 几何集合:GeometryCollection


空间行状类型有:



  • 空间行状:Feature

  • 空间形状集合:FeatureCollection


举例


几何对象和空间行状可以相互嵌套


const GeoJSON = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [121.4737, 31.2304] },
properties: { id: 1 },
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [121.4837, 31.2504] },
properties: { id: 2 },
},
],
};

空间行状


FeatureCollection


FeatureCollectionFeature 对象的集合,用来表示一组 Feature 对象


typefeatures 两个属性组成:



  • type 属性的值为 FeatureCollection

  • features 属性的值为 Feature 对象的数组


const FeatureCollectionJSON = {
type: "FeatureCollection",
features: [feature],
};

Feature


Feature 对象用来表示几何对象的属性信息


typegeometryproperties 三个属性组成:



  • type 属性的值为 Feature

  • geometry 属性的值为 Geometry 几何对象

  • properties 属性的值为属性对象(可选)


const FeatureJSON = {
type: "Feature",
geometry: { type: "Point", coordinates: [121.4737, 31.2304] },
properties: { id: 1 },
};

几何对象


Point


Point 用来表示一个点


typecoordinates 两个属性组成:



  • type 属性的值为 Point

  • coordinates 属性的值为一个数组,数组的第一个元素为经度,第二个元素为纬度


const PointJSON = {
type: "Point",
coordinates: [121.4737, 31.2304],
};

MultiPoint


MultiPoint 用来表示多个点


typecoordinates 两个属性组成:



  • type 属性的值为 MultiPoint

  • coordinates 属性的值为一个数组,数组的每个元素都是一个点的坐标


const MultiPointJSON = {
type: "MultiPoint",
coordinates: [
[121.4737, 31.2304],
[121.4837, 31.2504],
],
};

LineString


LineString 用来表示一条线


typecoordinates 两个属性组成:



  • type 属性的值为 LineString

  • coordinates 属性的值为一个数组,数组的每个元素都是一个点的坐标


const LineStringJSON = {
type: "LineString",
coordinates: [
[121.4737, 31.2304],
[121.4837, 31.2504],
],
};

MultiLineString


MultiLineString 用来表示多条线


typecoordinates 两个属性组成:



  • type 属性的值为 MultiLineString

  • coordinates 属性的值为一个数组,数组的每个元素都是一个线的坐标数组


const MultiLineStringJSON = {
type: "MultiLineString",
coordinates: [
[
[121.4737, 31.2304],
[121.4837, 31.2504],
],
[
[121.4727, 31.2314],
[121.4827, 31.2514],
],
],
};

Polygon


Polygon 用来表示一个面


typecoordinates 两个属性组成:



  • type 属性的值为 Polygon

  • coordinates 属性的值为一个数组,数组的第一个元素为外环的坐标数组,后面的元素为内环的坐标数组


polygon 的坐标数组的第一个元素和最后一个元素是相同的,表示闭合


const PolygonJSON = {
type: "Polygon",
coordinates: [
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
[
[121.4717, 31.2314],
[121.4827, 31.2524],
[121.4937, 31.2334],
[121.4757, 31.2344],
],
],
};

MultiPolygon


MultiPolygon 用来表示多个面


typecoordinates 两个属性组成:



  • type 属性的值为 MultiPolygon

  • coordinates 属性的值为一个数组,数组的每个元素都是一个面的坐标数组


const MultiPolygonJSON = {
type: "MultiPolygon",
coordinates: [
[
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
],
[
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
[
[121.4737, 31.2304],
[121.4837, 31.2504],
[121.4937, 31.2304],
[121.4737, 31.2304],
],
],
],
};

GeometryCollection


GeometryCollection 用来表示几何对象的集合


typegeometries 两个属性组成:



  • type 属性的值为 GeometryCollection

  • geometries 属性的值为几何对象的数组


const GeometryCollectionJSON = {
type: "GeometryCollection",
geometries: [
{ type: "Point", coordinates: [121.4737, 31.2304] },
{
type: "LineString",
coordinates: [
[121.4737, 31.2304],
[121.4837, 31.2504],
],
},
],
};

可选属性


这些属性都是 GeoJSON 的扩展属性,不是 GeoJSON 规范的一部分



  • id 属性,用来描述 FeatureCollection 的唯一标识

  • bbox 属性,用来描述 FeatureCollection 的边界框

    • 四至坐标,一般用来做数据裁剪

    • 这是一组左上角和右下角的坐标,示例:[minLon, minLat, maxLon, maxLat]



  • properties 属性,用来描述 FeatureCollection 的属性

  • crs 属性,用来描述坐标参考系


其他


coordinate


coordinate 是一个数组,表示一个点的坐标,数组的长度表示坐标的维度,一般是 2 维或 3



  • 2 维:[lon, lat]

  • 3 维:[lon, lat, height]


coordinate 的第一个元素表示经度,第二个元素表示纬度,第三个元素表示高度


坐标顺序是 [lon, lat],这个是推荐顺序,可由 crs 属性指定


coordinates 是多维数组:



  • 点:[lon, lat]

  • 线:[[lon, lat], [lon, lat]]

  • 面:[[[lon, lat], [lon, lat]]]

  • 多面:[[[[lon, lat], [lon, lat]]]]


坐标参考系


最常使用的坐标系是 EPSG:4326EPSG:3857



  • EPSG:4326WGS84(CGCS2000,大地) 坐标系,是 GeoJSON 规范的默认坐标系

  • EPSG:3857Web Mercator(墨卡托) 坐标系,是 OpenLayers 的默认坐标系


它们的区别:



  • EPSG:4326 是经纬度坐标系,EPSG:3857 是投影坐标系

  • EPSG:4326 的坐标范围是 [-180, -90, 180, 90]EPSG:3857 的坐标范围是 [-20037508.342789244, -20037508.342789244, 20037508.342789244, 20037508.342789244]

  • EPSG:4326 的坐标单位是度,EPSG:3857 的坐标单位是米

  • EPSG:4326 的坐标原点是 [0, 0]EPSG:3857 的坐标原点是 [-20037508.342789244, -20037508.342789244]

  • EPSG:4326 的坐标轴方向是 [x, y]EPSG:3857 的坐标轴方向是 [x, -y]


在 ts 中使用


为了在 ts 使用 GeoJSON 能够有类型约束,我整理整理了一些 GeoJSONts 类型定义和创建 GeoJSON 的方法:



举例:




  1. 表示一个点和多个点的 GeoJSON 集合:


    使用geojson.d.ts


    type PointType = FeatureCollection<Point | MultiPoint, GeoJsonProperties<T>>;

    const point2Geojson: PointType<{ id: string; name?: string }> = {
    type: "FeatureCollection",
    features: [
    {
    type: "Feature",
    geometry: {
    type: "Point",
    coordinates: [120.4737, 31.2304],
    },
    properties: { id: "12", name: "uccs" },
    },
    {
    type: "Feature",
    geometry: {
    type: "MultiPoint",
    coordinates: [
    [121.4737, 31.2304],
    [111.4737, 31.2204],
    ],
    },
    properties: { id: "1" },
    },
    ],
    };



  2. 创建一个几何对象


    使用geojson.helper.ts


    const pointGeometry = point<{ id: string }>([120.4737, 31.2304], {
    id: "1",
    });
    const featureGeoJSON = feature<Point>(pointGeometry);



参考


收起阅读 »

css是你永远学不会的语言

web
在网上冲浪的时候,看到有这么一个网页效果;如下图: 分析: 从图中我们可以看出,当我们鼠标移入的时候,下划线冲左侧开始展示;当鼠标移出的时候,下划线从左侧开始消失 既然我们知道了大致的效果,那我们就得想想怎么实现这个效果了。 我是没有想出来的,我用F12查看...
继续阅读 »

在网上冲浪的时候,看到有这么一个网页效果;如下图:


20230330_10:43:33_1.gif


分析:


从图中我们可以看出,当我们鼠标移入的时候,下划线冲左侧开始展示;当鼠标移出的时候,下划线从左侧开始消失


既然我们知道了大致的效果,那我们就得想想怎么实现这个效果了。


我是没有想出来的,我用F12查看了一下;如下图代码:


未移入样式:


微信图片_20230330105126.png


移入样式(hover):


微信图片_20230330105126.png


代码分析



  • 背景色渐变以及方向(移入移出)

  • 背景大小

  • 过度时间


示列代码


解释:



  • 我们在css中写span的样式时;background需要禁止平铺,然后是靠右(right)并且是底部的;原因是以为收回去的时候需要方向是右侧(right);然后background-size需要将宽度设置为0 高度为2(你可以根据自己的需要设置);最后是给background-size一个过度效果既可以

  • 然后hover事件的时候需要将定位给到左侧(left)并且将background-size宽度百分之百;这样就会根据过度时间显示完成


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.title {
color: #333;
line-height: 2px;
}

.title span {
background: linear-gradient(to right, #ec695c,#61c454) no-repeat right bottom;
background-size: 0 2px;
transition: background-size 1300ms;
}
.title span:hover {
background-position-x: left;
background-size: 100% 2px;
}
</style>
</head>
<body>
<h2 class="title">
<span>
Migrating From MySQL to YugabyteDB Using YugabyteDB Voyager</span>
</h2>
</body>
</html>

以上就是今天的全部内容了!大家可以复制以上代码,即可展现效果。


当我做了以后发现,css真的是我的弱势;或者说大部分人都不怎么关注css;毕竟面试的时候大部分公司都不是很要求这个,从而我们就忽略了个语言;css真的我永远学不会的语言啊


后面我也就开一个专辑,我所遇到的css相关的一些东西


往期文章



作者:雾恋
来源:juejin.cn/post/7216163778059550757
收起阅读 »

纯前端就可以实现的两个需求

web
一:多文件下载并压缩成zip形式   我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0....
继续阅读 »

一:多文件下载并压缩成zip形式


  我们一般看到这个需求,大多前端都会觉得这不就是一个接口的事嘛,咱们直接window.open()就搞定了,emm...,我之前也是这样想的,可惜现在是在三线城市的小互联网公司,人家后端说服务器不行,带宽不够,出不来接口0.0,所以我就尝试着寻找……最终找到了解决办法——


  前端可以直接从cos资源服务器中下载多个文件,并放进压缩包中哦,这样就省去了后端在中间中转的那个环节,实现方式如下:


1.安装依赖


  我们需要用到两个第三方依赖哦,分别执行以下安装


npm i jszip
npm i file-saver

2.引入 


  在需要使用的时候,我们引入


import JSZip from "jszip";
import FileSaver from "file-saver";

3.实现过程


  我这里是在vue框架中,所以方法是在methods中哦,先在data里声明一个文件demo数据


data () {
return {
fileList: [ //这里的数据 实际中 应该是从后端接口中get { name: "test1.doc", url: "https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" }, { name: "test2.doc", url:"https://collaboration-pat-1253575580.cos.ap-shenzhen-fsi.myqcloud.com/baseFile/202303/bdf4303f-4e8d-4d95-977c-4fd5ef28d354/new.doc" } ],
}
}

methods中的方法:


handleClickDownAll () { //实现多文件压缩包下载
let _this = this
let zip = new JSZip()
let cache = {}
let promises = []
for(let item of this.fileList) {
const promise = _this.getFileArrBuffer(item.url).then(res => {
//下载文件, 并存成ArrayBuffer对象(blob)
zip.file(item.name,res,{binary:true}) //逐个添加文件
cache[item.name] = data
})
promises.push(promise) }
Promese.all(promises).then(() => {
zip.generateAsync({type:'blob'}).then(content => {
FileSaver.saveAs(content,"压缩包名字") //利用file-saver保存文件 自定义文件名
})
})
},
getFileArrBuffer(url) {
return new Promise((resolve) => {
let xmlhttp = new XMLHttpRequest()
xmlhttp.open('GET',url,true)
xmlhttp.responseType = 'blob'
xml.onload = function () {
resolve(this.response)
}
xmlhttp.send()
})
}

二:electron-vue中,生成二维码,并支持复制二维码图片


要实现的功能如下,就是点击这个“复制二维码”,可以直接把二维码图片复制下来



1.安装依赖


npm i qrcodejs2

2.引入


import QRCode from 'qrcodejs2';
import { clipboard, nativeImage} = require('electron')

3.实现


  要先在template中写一个这样的元素,用来显示二维码图片框


<div id="qrcodeImg" class="qrcode" style="height: 120px"></div>

然后再写一个画二维码的方法,如下:


drawQrcode() {
new QRCode("qrcodeImg",{
width:120,
height:120,
text:"http://www.baidu.com",
colorDark:"#000",
colorLight:"#fff"
})
}

复制二维码的方法如下:


copyCode() {
let src = document.getElementById("qrcodeImg").getElementsByTagName("img")[0].src
const image = nativeImage.createFromDataURL(src)
clipboard.writeImage(image)
this.$Message.success('复制成功')
}

4.使用


要先确保dom元素已经有了,所以在mounted中调用drawQrcode()这个方法,然后点击“复制二维码”时,调用 copyCode()这个方法就可以实现啦




作者:wenLi
来源:juejin.cn/post/7213983712732348474
收起阅读 »

再也不用手动改package.json的版本号

web
本文的起因是有在代码仓库发包后,同事问我“为什么package.json 里的版本还是原来的,有没有更新?”,这个时候我意识到,我们完全没有必要在每次发布的时候还特意去关注这个仓库的版本号,只要在发布打tag的时候同步一下即可,于是有了本文的实践。 node....
继续阅读 »

本文的起因是有在代码仓库发包后,同事问我“为什么package.json 里的版本还是原来的,有没有更新?”,这个时候我意识到,我们完全没有必要在每次发布的时候还特意去关注这个仓库的版本号,只要在发布打tag的时候同步一下即可,于是有了本文的实践。


node.js 部分,我们得有一个更改仓库代码的脚本留给ci执行


我们首先需要在工程目录中的 ./script/..目录下增加一个 update-version.js脚本



//update-version.js

const path = require('path');
const fs = require('fs');
const newVersion = process.argv[2].replace(/^v/, '');; // 获取命令行参数中的新版本号,并过滤v字头

if (!newVersion) {
console.log('请传入新版本号,版本号遵循semver规范 .eg: 1.0.0, 1.0.1, 1.1.0');
process.exit(1);

}

// 获取当前命令行上下文路径

const currentDirectory = process.cwd();

// 获取 package.json 文件中的版本号
const packageJsonPath = path.join(currentDirectory, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
const currentVersion = packageJson.version;

// 更新 package.json 文件中的版本号

packageJson.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
console.log(`版本号已从 ${currentVersion} 更新为 ${newVersion}`);


接下来在 package.json script 配置后可以直接使用 npm run version <version> 中触发变更版本号脚本。当然这个前提是想要让这个脚本保留给开发者命令行使用。



{

"name": "version workflow",
"version": "1.0.0",
"description": "version update demo",
"main": "index.js",
"scripts": {
//...
"version": "node ./scripts/update-version.js"
},
//...

}


CI :如何让发布包的行为直接和代码仓库中的版本号同步?


接下来算重头戏,如何让发布包的行为直接和代码仓库中的版本号同步?这里我们使用的是github 提供的github action,具体操作和语法可以查看一下官方文档,本文就不过多展开。


我们需要在仓库根目录增加如下路径的文件 .github/workflows/update-action.yml



name: Update Package Version

on:
release:
types: [released]

jobs:
update:
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v3

- name: Update package.json
run: |
node ./scripts/update-version.js ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit changes
run: |
git config user.name "Your github name"
git config user.email "your github email"
git add .
git commit -m "Update version to ${{ github.event.release.tag_name }} for release ${{ github.ref }}"

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}


我们在 release hook 中的 released 状态下增加了一个 update job。 它会做下面几件事情(在脚本步骤中有)



  1. 【Checkout code】 切出新的代码分支;

  2. 【 Update package.json】在新分支执行 update-version.js 传入tag_name更新我们的工程版本号;

  3. 【Commit changes】以你定制的 git config user 信息创建一个新提交;

  4. 【Push changes】推送变更回到主干;


ps:正确来说应该在发布执行动作前prereleased执行我们的 job 但是没用这个的原因如下:



Note:  The prereleased type will not trigger for pre-releases published from draft releases, but the published type will trigger. If you want a workflow to run when stable and pre-releases publish, subscribe to published instead of released and prereleased.



当这个脚本推送后,执行发布后自动更新版本,不用在关注这个版本修改问题。
你会得到下面的效果。


在你的仓库发布界面填写正确tag后发布
image.png


触发update job 更改完成
image.png


你可能遇到最多的坑



  1. action 执行失败



Process completed with exit code 129." Node.js 12 actions are deprecated. Please update the following actions to use Node.js 16: actions/checkout@v2. For more information, see https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/.



这是由于默认action job 执行环境的nodejs 版本与actions 包中执行脚本不匹配导致,所以一定要使用 checkout@v3 版本 actions/checkout@v3



  1. 各种不熟悉 action 语法取值导致的问题


可以优化的地方


我们前面提交的这个流程发布还是有个问题,你永远有个更超前的 commit hash 在你发布的 tag 之后


image.png
所以这个action 还有需要继续优化的地方,那就是同步更新tag hash


name: Update Package Version

on:
release:
types: [released]

jobs:
update:
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v3

- name: Update package.json
run: |
node ./scripts/update-version.js ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit changes
run: |
git config user.name "Your github name"
git config user.email "your github email"
git add .
git commit -m "Update version to ${{ github.event.release.tag_name }} for release ${{ github.ref }}"
git_hash=$(git rev-parse --short HEAD)

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Tag Push changes
run: |
git tag -f ${{ github.event.release.tag_name }} $git_hash
git push --force origin ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


这里相比之前的版本增加了
Tag Push changes 这个步骤,在最后获取这个版本更新产生的 $git_hash强制更新到发布的 tag 上。


我们看看效果
image.png


最后我们看版本发布管理中的 tag hash
image.png
搞定!


可以再优化的地方


现在我们还有个问题,就是在执行 Commit changes 这个步骤时每次 git config user.name "Your github name" git config user.email "your github email" 这里是写死的,我们可以根据 GitHub Actions 中有一些预设的环境变量可以读取到当前用户的账号和邮箱信息。通过 ${{ env.GITHUB_ACTOR }} 获取到当前执行的 Actions 的用户账号,通过 ${{ env.GITHUB_ACTOR }}@users.noreply.github.com 获取到当前执行的 Actions 的用户邮箱(该邮箱为 noreply 邮箱,用于 GitHub 的通知,无法发送邮件)。注意,该邮箱不一定是用户本身的真实邮箱,可能是 GitHub 默认的邮箱。



如果需要获取当前 GitHub 账号的真实邮箱地址,可以通过 GitHub REST API 进行查询,具体可以参考官方文档:



这样我们就需要在Commit Changes之前再加一个Set Git user步骤


- name: Set Git user
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EMAIL: ${{ github.actor }}@users.noreply.github.com
run: |
git config --global user.name "${{ env.GITHUB_ACTOR }}"
git config --global user.email "${{ env.GITHUB_EMAIL }}"

这样我们最终的 Github action 脚本长这样



name: Update Package Version

on:
release:
types: [released]

jobs:
update:
runs-on: ubuntu-latest
steps:

- name: Checkout code
uses: actions/checkout@v3

- name: Update package.json
run: |
node ./scripts/update-version.js ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Set Git user
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EMAIL: ${{ github.actor }}@users.noreply.github.com
run: |
git config --global user.name "${{ env.GITHUB_ACTOR }}"
git config --global user.email "${{ env.GITHUB_EMAIL }}"

- name: Commit changes
run: |
git add .
git commit -m "Update version to ${{ github.event.release.tag_name }} for release ${{ github.ref }}"
git_hash=$(git rev-parse --short HEAD)

- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Tag Push changes
run: |
git tag -f ${{ github.event.release.tag_name }} $git_hash
git push --force origin ${{ github.event.release.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


最后


如果我的文章对你有帮助欢迎点赞+收藏支持


作者:Jervis_cen
来源:juejin.cn/post/7220164534316433467
收起阅读 »

nginx带宽限制 limit_rate limit_rate_after

web
知识梳理 在高负载的网络环境下,为了保持服务的稳定性,限速 (download rate) 是一种必要的控制访问量的手段。Nginx 是一款高性能的 Web 服务器和反向代理服务器,可以使用 limit_rate_after 和 limit_rate 两个主要...
继续阅读 »

知识梳理


在高负载的网络环境下,为了保持服务的稳定性,限速 (download rate) 是一种必要的控制访问量的手段。Nginx 是一款高性能的 Web 服务器和反向代理服务器,可以使用 limit_rate_after 和 limit_rate 两个主要指令来完成流量控制和限速。


limit_rate_after 指令


指令 limit_rate_after 会在客户端成功建立连接之后,指定的大小后开始限制发送速度。这个指令的含义就是在连接建立后的 limit_rate_after 大小之后,数据发送速率将被限制。


以下是limit_rate_after 的语法和示例:


Syntax:	limit_rate_after size;
Default:
limit_rate_after 0;
Context: http, server, location, if in location

limit_rate_after 50m;

这个指令可以帮助您限制连接的初始流量,以便于服务器的带宽资源分配更为合理。


limit_rate 指令


limit_rate 指令是用来控制发送至客户端的数据传输速度的,它可以限制整个连接的流量,也可以限制单个客户端访问速度。


以下是 limit_rate 的语法和示例:


syntax:		limit_rate rate;
default: —
context: http, server, location

limit_rate 1k;

这个配置的作用是:在与客户端建立连接之后的 10 秒内,限制每秒发送的数据量不超过 50kB;之后如果连接仍然打开,则限制与该客户端的速率为 50kB/s。


需要提醒的一点是,尽管 limit_rate 可以一定程度上保护服务器资源,但是并不足以完全阻止恶意饱和攻击。因此,在考虑流量控制和限速的同时,还应该结合其他安全和防护机制来更好地保护服务器。


实验


配置传输速度为 1k



  • nginx配置


location / {
limit_rate 1k;
root html;
}



配置下载50m后开始限制传输速度



  • nginx配置


location / {
limit_rate_after 50m;
limit_rate 1k;
root html;
}


可以看到开始下载速度很快


在这里插入图片描述
在下载50m后,速度限制在1k以内
在这里插入图片描述


我遇到的坑



因为我的portal.tar文件没有读的权限,导致浏览器下载报403,使用 chmod 755 portal.tar 修改portal.tar文件的权限,如下图:


在这里插入图片描述


总结


Nginx 的限速功能对于控制访问量、防止恶意攻击具有很高的研究价值和实际意义。limit_rate 和 limit_rate_after 是 Nginx 常见的两个限速指令,它们可以配置在 http、server、location 等区块中,实现不同级别的流量限制和控制。一般情况下为了取得更好的限速效果,我们会同时使用两个指令,通过多事件流的限速进行灵活的控制。


希望这篇文章能够对 Nginx 限速功能有更深入的理解,帮助开发者在实际的生产环境中使用它来进行更好的流量控制和管理。


参考


nginx.org/en/docs/htt…
nginx.org/en/docs/htt…


作者:黄娟
来源:juejin.cn/post/7219889814115811388
收起阅读 »

藏在微信里的温度,无障碍开发框架分享

web
👉 腾小云导读 现我国现有4471w视障/听障人士,60岁及以上人群达2.6亿规模。微信作为国民级应用,实现无障碍迫在眉睫。为了帮助他们更好地使用微信 App,Android微信完成了适老化及无障碍改造。本文主要介绍Android 微信开发团队根据适老化及无障...
继续阅读 »

图片


图片


👉 腾小云导读


现我国现有4471w视障/听障人士,60岁及以上人群达2.6亿规模。微信作为国民级应用,实现无障碍迫在眉睫。为了帮助他们更好地使用微信 App,Android微信完成了适老化及无障碍改造。本文主要介绍Android 微信开发团队根据适老化及无障碍需求,完成的一个协助业务侧进行无障碍功能开发的框架。希望能给广大开发爱好者带来帮助和启发!




👉 看目录,点收藏


1 无障碍需求框架背景


1.1 无障碍需求


1.2 框架简介


2 无障碍开发基础知识


2.1 读屏软件识别View原理


2.2 读屏软件后的事件分发原理


3 框架实现的整体流程和执行原理


3.1 整体流程


3.2 执行原理


4 核心说明:全局热区补足机制


4.1 背景说明


4.2 具体实现


4.3 额外说明


5 走查工具


6 总结


01、无障碍需求框架


目前,业界已经有共识性的无障碍开发守则。例如 Web Content Accessibility Guidelines (WCAG) 2.0,它是由互联网的主要国际标准组织万维网联盟 (W3C) 的Web可访问性倡议 (WAI) 发布的一系列 Web 可访问性指南的一部分。


此外,WAI-ARIA(可访问的富Internet应用程序套件)是由万维网联盟(W3C)发布的一项关于 A11 Y技术应用规范。该规范定义了一种使残障人士更易于访问 Web 内容和 Web 应用程序的方法,增加 HTML、JavaScript 和相关技术开发的网站动态内容以及用户界面组件的可访问性。


目前,Android没有官方统一、方便的框架,官方提供的原生api并不是特别好用,所以微信团队对其进行参考,开发了一个无障碍框架,基于原生的api进行了再封装,将繁琐的无障碍适配逻辑封装在底层,以声明式接口的形式,让上层业务能以更简便更解耦的代码,完成无障碍的适配。接下来我们进行分享:


1.1无障碍需求


本框架主要具备以下特性:



  • 可感知性 :包括大字体适配,颜色对比度等 。

  • 可操作性 :主要是过小热区的放大,提高老年人/残疾人的交互体验 。

  • 可理解性 :微信应提供读屏文案等信息,帮助盲人在开启 Talkback 等读屏软件的情况下,正常使用微信。


下面给出一些较为典型的需求:



  • 需求1:过小热区的放大


需求是要求微信内的所有可交互控件,可点击范围不得低于 44dp * 44dp。


大小不合规的控件,如果一个个进行排查、布局修改。工程量庞大。



  • 需求2:响应区域会随无障碍开关发生变化


图片


该 Item 由一个 SwitchButton + TextView 组成。


开启 Talkback 时,整个 Item 识别为一个焦点,选中双击是触发点击 switch的逻辑。在无障碍模式下,选中双击是直接触发相应控件的 Click 事件。但是在不开 Talkback 的情况下点击 Item 又无需响应,只响应 SwitchButton 。也就是点击区域会随 Talkback 开关发生变化。


实现可能是:在 ItemClick 中进行 if 判断。但这样写侵入性高,难维护。



  • 需求3:读屏文案由其他的控件的值组合


图片


选中头像,读屏文案:腾讯行政的头像,有 2 条未读消息。需要读出列表中其他关联内容,这种只能把适配代码侵入到 Adapter中。


1.2 框架简介


框架将不同的无障碍需求的实现进行封装,抽象成不同的规则。


业务侧可以将一个页面/业务的无障碍需求,在一个配置类里使用规则表达出来,再由框架进行处理。实现相应的效果。


class ChatAccessibility(activity: AppCompatActivity) :  
BaseAccessibilityConfig(activity) {
  override fun initConfig() {
        // 设置 contentDesc
   view(rootId,viewId).desc(R.string.send_smiley)
        // ...
  }
}

框架基类 BaseAccessibilityConfig 提供了一系列用于表达规则的 api,包括但不限于如下功能:




  • 通过配置统一设置 contentDescription




  • 支持把多个 View 组合成一体进行读屏




  • 通过配置禁用某个View被 Talkback 聚焦的能力




  • 支持按指定顺序进行读屏,支持局部控制 Talkback 聚焦顺序




  • 支持设定在 Activity 启动后的第一个读屏控件




  • 支持对某个父 View 的 disableChildren 功能




  • 在某个 View 满足条件时,对其进行读屏,但不聚焦




  • 在某个 View 满足条件时,读出提前设定的 string,但不聚焦




  • 全局热区宽高补齐至 44dp,并提供自定义热区放大/禁用热区放大的功能 ...




02、无障碍开发基础知识


在深入了解框架的设计前,先来介绍一些无障碍功能开发的基础知识。


2.1 基础知识1:读屏软件识别 View 原理


图片


读屏软件无法直接识别到View,只能识别到View提供的虚拟节点「Node」,View 和虚拟节点一般是一一对应的。当页面内容发生变化,比如 View 被设值,或者发生滚动等情况,View 会向无障碍系统发送一个事件,通知系统。


然后系统就回头向 View 索取节点,组成页面更新后新的节点树,而 「节点树 和 ViewTree 是一一对应的」。此时读屏软件拿到的就是新的内容了。


2.2 基础知识2:读屏软件后的事件分发流程


分为上下两部分:读屏软件拦截处理行为、读屏软件接受事件。


图片


流程如下:




  • 读屏软件拦截用户 Touch 事件,根据事件的坐标去定位到目标节点。




  • 将 Touch 事件解释为节点行为,这里以触摸选中为例,那么就是聚焦行为。




  • 读屏软件通过该节点向无障碍系统发送,无障碍系统又转发给View(聚焦产生的绿框就是在View的内部处理里去绘制的)。




  • 生成新的虚拟节点并提供给读屏软件后,读屏软件组合信息,通过 TTS 语音引擎的 api 读出。




读屏软件展示给用户的所有信息,全部来自虚拟节点。可以在节点生成的过程中,修改节点的信息,所以这里是一个绝佳的**「信息自定义」**的地方。


采用将所有的 View 都 「Wrap 一层 AccessibilityDelegate」 的方式,「在 onInitializeAccessibilityNodeInfo 方法中修改节点信息」。


03、框架实现整体流程与执行原理


3.1 整体流程


图片




  1. 业务侧实现规则配置类,编写的规则会进入配置池。




  2. 框架在View生成节点给系统的时候进行拦截 「(onInitializeAccessibilityNodeInfo)」




  3. 在配置池中寻找匹配的规则。




  4. 根据匹配的规则对节点进行修改。




  5. 最后生成的节点就会由系统交由给读屏软件进行读屏。




3.2 执行原理


图片


核心原理:采用基于责任链的流水线来处理。整体流程主要分为两部分:




  • View 预处理责任链(图示左边):执行预出来操作,如异步生成缓存、View标记等;




  • 节点处理责任链(图示右边):节点处理的同时会同步查找规则进行设置。




接下来主要简单介绍下框架的一个核心功能的实现:「全局热区补足机制」 (位于框架流程中的预处理责任链中的一环)。


04、核心说明:全局热区补足机制


4.1 背景说明



  • 需求说明


过小热区放大,即微信内的所有可交互控件可点击范围不得低于 44dp * 44dp,像一些大小不合规的控件,如果一个个进行排查、布局修改,工程量太庞大。还有热区其他一些需求 etc。



  • 问题难点


一般会选择直接修改 padding,有些甚至需要改动相应布局,但这样的改动工作量太大且容易影响原来视图布局。



  • 解决方案


需要一个全局的热区补足机制,将过小热区补足至规范。


4.2 具体实现


「创建 View 的统一入口」 去设置 TouchDelegate 代理,由父 View 作为TouchDelegate 的承载 View 去代理 Touch 事件,这里有几个问题需要解决:




  • 如何找到合适的承载View




  • 热区及时更新




  • 性能优化




  • 读屏模式下的热区扩大




下面我们分别展开讲。




  • 重点问题1:如何找到合适的承载 View




从目标 View 向上冒泡,找到一个合适的父 View。那么需要 「冒泡终止条件」。 首先条件一肯定是 「足够大」。当前 View 够大了就没必要再往上冒了。


图片


但是这样会存在问题:子 View 的 Click优先级高于父View的TouchDelegate。事件派发机制:


从父 View 往子 View 派发,从子 View 向上处理。View 的事件处理顺序是先 OnTouchListener,然后是 TouchDelegate,再是Click、LongClick。


所以会导致下图的情况:


图片


目前进行了折中处理,相比上图,显然是下图的放大后的体验更佳:


图片


同时加入了条件二:「该承载 View 是 Clickable、LongClickable」。最终方案流程确定如下:


图片




  • 重点问题2:热区及时更新




背景: 承载 View 的 TouchDelegate 需要的参数包含一个 Rect,也就是对扩大的热区进行响应。


问题: 这个矩阵是提前传入,且和 小 View 没有直接的关系。如果小 View 的布局发生变动,会导致扩大后热区没有及时跟上变化。导致热区错位。


解决方案: 在 小 View 的 onLayoutChange 中重新进行一遍 ·View 扩大方案· 的处理。同时为了防止 onLayoutChange  执行过于频繁,将 onLayoutChange 包装成 View 的一个事件。如果短时间内多次 onLayoutChange  ,则只在最后一次 onLayoutChange 的时候进行  「View扩大方案」处理。



  • 重点问题3:性能优化


背景 :最初的 View 扩大方案执行时机是在创建 View 的统一入口,也就是在 LayoutInflate 的 onCreateView 中同步执行,每个 View 都得执行。


问题:由于 View 数量较为庞大,所以存在较大的性能隐患。


解决方案:采用了异步方案并同时对 View 处理任务进行收拢。将执行时机提前到 LayoutInflate.inflate 并异步处理,在异步任务中去遍历该 inflate 的根 View的所有子 View。尽量不去阻塞主线程的运行。




  • 重点问题4:读屏模式下的热区扩大




通过上面的实现,点击热区确实是扩大了。但是在读屏模式下选中的时候,选中的框并没有扩大。那么首先需要知道,选中时的框是以什么作为 Bound。


绿框的绘制核心逻辑位于 ViewRootImpl 中的一个 drawAccessibilityFocusedDrawableIfNeeded(),该方法的调用时机是用户触摸选中某个View后,传递到 ViewRootImpl 时进行调用,也就是读屏选中的绿框是由系统绘制的,而不是由读屏软件绘制的。从源码中能够得知的是,绿框的Bound 根据是否有虚拟节点,分为两种情况:


private void drawAccessibilityFocusedDrawableIfNeeded(Canvas canvas) {  
    final Rect bounds = mAttachInfo.mTmpInvalRect;
    if (getAccessibilityFocusedRect(bounds)) {
        final Drawable drawable = getAccessibilityFocusedDrawable();
        if (drawable != null) {
            drawable.setBounds(bounds);
            drawable.draw(canvas);
        }
    } else if (mAttachInfo.mAccessibilityFocusDrawable != null) {
        mAttachInfo.mAccessibilityFocusDrawable.setBounds(0000);
    }
}

private boolean getAccessibilityFocusedRect(Rect bounds) {
    ...
    final AccessibilityNodeProvider provider = host.getAccessibilityNodeProvider();
    if (provider == null) {
        host.getBoundsOnScreen(bounds, true);
    } else if (mAccessibilityFocusedVirtualView != null) {
        mAccessibilityFocusedVirtualView.getBoundsInScreen(bounds);
    } else {
        return false;
    }
  ...
    return !bounds.isEmpty();
}

经过跟踪源码发现,这是因为 「绿框的绘制」 是根据 View.getBoundInScreen 获取的矩阵来做到的。而 TouchDelegate 的设置无法改变 View.getBoundInScreen 获取到的矩阵。在使用虚拟节点的情况下,才会使用虚拟节点的Bound进行绘制。


对于这个问题,我们的解决思路是:




  • 对每个 View 设置自定义的 AccessibilityDelegate, 并实现其中的 getAccessibilityNodeProvider 方法。




  • 如果判断 View 需要扩大,在 getAccessibilityNodeProvider 中返回自定义的 Provider。




  • 在自定义的 Provider 中,计算 View 的扩大后的矩阵在屏幕上的位置。




  • 将矩阵设置给虚拟节点,并返回给系统。




4.3 额外说明



  • 如何匹配规则与View?


框架将配置池按 Activity 划分,极大减少冲突概率,同时减少配置池大小,加快查找规则的速度,提供 layoutId + viewId,rootId + viewId 两种形式的 View 定位机制。由两个 Id 确定一个 View,减少冲突。



  • 查找规则时间长可能导致的主线程卡顿?


由于查找规则的时机是在生成节点,是由系统触发且无法异步。在查找规则的过程中,使用预处理的时候提前生成的缓存进行查找,尽可能减少耗时。


05、走查工具


5.1 背景


当完成无障碍需求的开发后,需进行验证。在验证过程中发现开启验证效率低下,需开启读屏软件后,逐个元素验证。


5.1.1 解决方案与原理


基于无障碍服务(AccessibilityService)开发、集成了在不开启 Talkback 的情况下能展现读屏区域一个无障碍功能走查工具,无需开启 Talkback 逐个手动触摸,就能高效检查无障碍适配情况。


图片


实现原理如下:




  • 自定义实现一个 AccessibilityService 用于获取到当前活跃窗口的根节点。




  • 每隔 0.5s 进行一次节点的获取:从当前活跃窗口的根节点遍历所有的节点,逐个进行判断是否会被聚焦。




  • 对通过允许聚焦的节点进行信息收集,在一次遍历完成后通知到 DrawService。




  • 提前在window中添加一个 View 用于绘制信息,由 DrawService 进行绘制。




5.2 具体实现


关键实现:如何判断一个节点能否被聚焦,即需理解 Talkback 是如何聚焦,流程如下:


1、如果是支持 WebView 中 Html 无障碍,特殊判断。


2、如果不可见,则不聚焦。


3、判断是否是画中画,像下图的红框这种就是画中画,如果是画中画,这个就是焦点。


图片


4、该节点是否和 window 边界重合等大。对于这种和 window 等大的节点,Talkback 选择不做聚焦。


5、检查该节点是否 clickable/longClickable/focusable 或者是列表的“会说话的” 顶层视图(满足->6 不满足->7)列表(ListView/RecycleView)的顶层视图例子如下:


图片


但是聚焦的前提是“会说话的”。“会说话的”包括以下几个条件:




  • HasText:包括 contentDescription、text、hintText(包括 Button 的 Text)。




  • hasStateDescription:包括 CheckBox 的已选未选状态、进度条的进度状态等。




  • hasNonActionableSpeakingChildren:含有无法聚焦、点击但是 HasText 的子 View(如上图通讯录中的 “新的朋友” TextView,就是无法聚焦、点击但是 HasText 的子 View)。




6、基本上满足了步骤5就可以视为可聚焦了,但是有一些View仅仅是 Focusable,但是却 ”什么话都没得说“ ,对于这种 View 应该是要排除的。故按如下步骤做判断:只要是没有子节点的 focusable/clickable/longclickable 的 View,全部聚焦 、“会说话的” 全部聚焦 6.3 剩下的就不聚焦了(“不会说话”、“有子节点”)。


7、能到这一步,说明步骤 5 不满足,即该节点是普通的不可聚焦的 View。但是防止错过一些没有点击事件的 TextView 之类的需要聚焦,需要再最后做一步判断(这一步也是啥为了保证所有的信息都可以不遗漏);如果没有可聚焦父节点,但仍然 hasText 或 hasStateDescription,聚集该节点。


8、一路闯关到这的 View,就终于逃离 TalkBack 的聚焦了。


06、总结


为了帮助老年人、视障/听障人群等更好地使用微信 App,Android微信完成了适老化及无障碍改造如上。本文主要介绍 Android 微信开发团队根据适老化及无障碍需求,完成的一个协助业务侧进行无障碍功能开发的框架。我们在介绍了无障碍开发所涉及的2大重点基础知识(读屏识别View原理和读屏软件后的事件分发原理)之后,为各位展开回顾了我们框架具体细节和方法。


以上是本次分享全部内容,欢迎大家在评论区分享交流。如果觉得内容有用,欢迎转发~


-End-


原创作者|许怀鑫


技术责编|许怀鑫


图片


现我国现有4471w视障/听障人士,60岁及以上人群达到2.6亿规模。信息无障碍(Web Accessibility)的概念在近几年受到关注。 信息无障碍是指通过信息化手段弥补身体机能、所处环境等存在的差异,使任何人(无论是健全人还是残疾人、无论是年轻人还是老年人)都能平等、方便、安全地获取、交互、使用信息。微信、QQ、腾讯新闻和腾讯地图等应用加适老化元素,配备为老人而设的“关怀模式”;搜狗输入法推出为视障群体量身打造的“保益盲人输入法”......


当说到无障碍,大家第一反应是弱势群体。实际上,无障碍是适用于全民的。每个人都可能有遇障时刻。当你手提重物或受伤时,你可能会选择乘坐无障碍电梯;当你处在嘈杂的环境下看视频时,你可能需要通过字幕获取信息……每个人都是无障碍环境的受益者,视障、听障人群、含残疾人、老年人是信息无障碍的重点受益群体。


事件分享:你还见到过哪些让你眼前一亮的信息无障碍案例?


脑洞时刻:程序员还可以为信息无障碍做些什么?


欢迎在公众号评论区聊一聊你的看法。在4月10日前将你的评论记录截图,发送给腾讯云开发者公众号后台,可领取腾讯云「开发者春季限定红包封面」一个,数量有限先到先得😄。我们还将选取点赞量最高的1位朋友,送出腾讯QQ公仔1个。4月10日中午12点开奖。快邀请你的开发者朋友们一起来参与吧!


回复「微信」,领取更多微信的技术case和论文资源


图片


阅读原文


作者:腾讯云开发者
来源:juejin.cn/post/7218015602769133625
收起阅读 »

接地气的前端代码规范

web
背景: 技术栈为 vue全家桶 更细节、更符合公司现状的一些约定、规范 优先级 A:必要的 这些规则会帮你规避错误,减少可能会产生的缺陷或者性能隐患。 JavaScript 在使用变量前,必须进行判空,必要时还需进行类型判断;若是对象,建议使用可选链 我们...
继续阅读 »

背景:



  • 技术栈为 vue全家桶

  • 更细节、更符合公司现状的一些约定、规范


优先级 A:必要的


这些规则会帮你规避错误,减少可能会产生的缺陷或者性能隐患。


JavaScript


在使用变量前,必须进行判空,必要时还需进行类型判断;若是对象,建议使用可选链


我们经常会遇到这样的情况:在定义变量时未赋默认值;根据接口返回值进行赋值,因数据等问题导致字段有缺失。若我们在使用这些变量时,未进行必要的判断,理所当然地去使用变量的属性、方法等,轻则导致console上出现一些error信息,再则出现功能无法正确运行,重则直接出现整个系统白屏!
注:在<template>中使用的变量,出现undefined而未进行判空,会导致系统白屏。目前vue@2.6.x还未支持<template>中使用可选链,后续会考虑是否升级到2.7.x。


// 反例
let a, b, c;

a = res.data.data.a;

b = JSON.parse(a);

c = b.includes("1");

// 正例
let a, b, c;

a = res?.data?.data?.a;

if (!!a) {
b = JSON.parse(a);
}

if (Array.isArray(b)) {
c = b.includes("1");
}

必须对接口报错进行处理,至少需进行错误提示


目前系统中对接口错误状态码、错误提示的处理良莠不齐,导致部分接口一旦出错,页面无任何反应,对用户很不友好。



  • 针对接口出现一些错误状态码(如status: 500),后续会在组件库的interceptor中对所有axios进行统一处理,给出错误提示,并往外抛。各个业务层可以对组件库抛出的信息进行进一步的处理,如关闭loading,回退处理等等。

  • 针对接口status: 200``success: false,需要在各个调用接口的地方给出提示语。优先以后端返回为准,否则提示语默认为:系统异常,请联系管理员。

  • 针对接口返回blob文件或其他可能会出现异常的情况,建议使用try...catch来捕获异常。


// 反例
function fetchUser (userId) {
return fetch(`/xxx/xxx/${userId}`);
}

// Promise的实现
function updateUserInfo (userId) {
fetchUser(userId).then(res => {
if (res.data.success) {
doSuccessAction();
}
})
}

// async/await的实现
async function updateUserInfo (userId) {
const res = await fetchUser(userId);
if (res.data.success) {
doSuccessAction();
}
}

// 正例
function fetchUser (userId) {
return fetch(`/xxx/xxx/${userId}`);
}

// Promise的实现
function updateUserInfo (userId) {
fetchUser(userId).then(res => {
if (res.data.success) {
doSuccessAction();
} else {
const errorInfo = res.data.error || "系统异常,请联系管理员";
this.$Message.error(errorInfo);
}
}).catch (error => {
this.$Message.error(error);
});
}

// async/await的实现
async function updateUserInfo (userId) {
try {
const res = await fetchUser(userId);
if (res.data.success) {
doSuccessAction();
} else {
const errorInfo = res.data.error || "系统异常,请联系管理员";
this.$Message.error(errorInfo);
}
} catch (error) {
this.$Message.error(error);
}
}

禁止频繁调用同一个接口,包括循环、监听、或未做节流防抖的按钮等情况下调用接口


频繁调用接口,会产生很多问题,列举如下:



  • 接口耗时长,页面白屏,用户体验不好

  • 对后端服务器造成一定压力

  • 同一个接口,在短时间内同时发出,因为网络延迟等因素,会造成接口返回不一定按照接口发起的顺序,导致最终结果与预期不符


目前代码中会有这些常见情况导致频繁调用,以下给出对应的解决方法:



  • 循环中调用:进行接口聚合,比如原先是每一次给后端一个key,后端返回对应的枚举值,可以改为将这些key组合成数组,一次性请求,获取所有对应的枚举值。

  • 监听中调用:这种情况最大的问题是对watch或者computed的触发场景或次数未知。这个没有统一的解法,需要具体情况具体分析。

  • 按钮中调用:点击按钮后调用接口,是一个特别常见的场景,一般情况下我们不会主动去在接口点击后频繁调用同一个接口,但是要防止用户频繁点击按钮。我们需要在按钮点击后,进入loading状态,或者加上节流或防抖,以避免上述用户操作导致的问题。

  • 表单中调用:在input、select、cascader组件的on-change 事件中调用接口,可以改为在输入框失焦,下拉面板收起时触发,即on-blur、on-open-change、visible-change。


Prop 定义应该尽量详尽,至少指定类型


细致的 prop 定义有两个好处:



  • 它们写明了组件的 API,所以很容易看懂组件的用法;

  • 在开发环境下,如果向一个组件提供格式不正确的 prop,Vue 将会告警,以帮助你捕获潜在的错误来源。


// 这样做只有开发原型系统时可以接受
props: ['status']

props: {
status: String
}

// 更好的做法!
props: {
status: {
type: String,
required: true,
validator: function (value) {
return [
'syncing',
'synced',
'version-conflict',
'error'
].indexOf(value) !== -1
}
}
}

拒绝硬编码值;拒绝魔法数字和字符串;


硬编码值和魔法数字和字符串在编程中往往代表着不好的编码习惯,缺点也很明显:



  1. 值的意义难以了解。

  2. 值需要变动时,需要频繁变更,而且可能要改不只一个地方。


// 反例
for (let i = 0; i < 10; i++) {
//...
}

// 正例
const numApples = 10;
for (let i = 0; i < numApples; i++) {
//...
}


// 反例
<template>
<section class="demo-page">
<span v-if="status === '0'">待付款</span>
<span v-if="status === '1'">待发货</span>
<span v-if="status === '2'">待收货</span>
<span v-if="status === '3'">待评价</span>
</section>
</template>
<script>
export default {
data() {
return {
status: "0",
};
},
...
}
</script>

// 正例
<template>
<section class="demo-page">
<span>{{ statusMap[status] }}</span>
</section>
</template>
<script>
import { getStatusMapApi } from "@/api/index";
export default {
data() {
return {
status: "0",
statusMap:{}
};
},
mounted() {
getStatusMapApi().then(res => {
/* {
"0": 待付款,
"1": 待发货,
"2": 待收货,
"3": 待评价,
} */
this.statusMap = ...
})
}
}
</script>

禁止使用refs.children[i]获取子组件,建议用ref属性;不建议使用ref直接调用子组件的api,以保持组件的独立性


refs是一个对象,持有注册过 ref attribute 的所有 DOM 元素和组件实例。children是当前实例的直接子组件,它并不保证顺序,也不是响应式的。因此使用refs.children[i]获取子组件,是一种不稳定的操作。
ref属性可以访问子组件实例或子元素,但这仅仅是一个直接操作子组件的应急方案;为了保持组件的独立性、稳定性,建议不要直接使用子组件的方法、变量等。


禁止在watch和computed中用$route


由于我们目前都是keep-alive模式,若是在watch和computed中用$route,那么在包括tab页签打开、切换等操作在内的每一次路由变化,都会触发watch和computed,不论是否跟本页面本组件有关系。这样子带来了巨大的性能损耗和一些奇奇怪怪的缺陷产生。


禁止增删改JavaScript 对象或Vue的原型,造成原型污染


原型上的属性可以通过遍历访问到的,原型污染会引起性能消耗或意外BUG。
实际上,大多数在写业务代码的场景下,修改原型的方式都可以采用别的方式替代。


注释要保证详细、完整


推荐使用vscode的koroFileHeader插件,进行快捷注释操作。
文件注释去掉,可以留一个description;
代码有更新,注释记得也要更新;


/**
* @description 这个方法是干嘛用的
* @param {*}
* @return {*}
*/


/**
* @description 这个接口是干嘛用的
* @param {*}
* @see yapi地址
*/


// 这个变量是干嘛用的

工程目录、文件(夹)命名、组件内部命名等需遵循以下内部规范


因篇幅过长,单独整理


CSS


必须为组件样式设置作用域,建议采用scoped属性或者class策略。


设置样式的作用域可以有效确保你的样式只会运用在你想要作用的组件上,而不会造成”污染“。



  • scoped 属性:控制CSS 只作用于当前组件中的元素,需要给<style> 标签加上 scoped属性。

  • class策略:不止要使用 scoped属性,使用唯一的 class 名可以帮你确保那些三方库的 CSS或者其他组件的CSS 不会运用在你自己的 HTML 上。


// 反例
<template>
<span class="title">xxxxxxxxxx</span>
</template>

<style>
.title {
color: red;
}
</style>

// 正例
<template>
<div class="xxx-mgr-page">
<span class="title">xxxxxxxxxx</span>
</div>
</template>

<!-- 使用 `scoped` attribute -->
<style scoped>
.xxx-mgr-page{
.title {
color: red;
}
}
</style>

禁止使用全局选择器、类型选择器等作用范围太大的选择器添加css规则,推荐使用类选择器进行精细化控制。


简单说一下这两种被禁止的选择器:



  • 全局选择器,是由一个星号(*)代指的,它选中了文档中的所有内容。

  • 类型选择器,也叫做”标签名选择器“或者是”元素选择器“,因为它在文档中选择了一个 HTML 标签/元素的缘故。


使用他们添加css规则,会造成以下影响:



  • 作用范围太大,会造成一些不想作用的地方却误伤到了

  • 从性能角度考虑,标签选择器的性能比类选择器要慢


禁止通过css选择器的权重和优先规则来覆盖样式


在项目中,可能一个简单的按钮,它的样式会取决于很多地方很多层:组件库为它定义了最底层、最基本的外观 -> 业务项目中的公共样式为它定义了本项目中的统一样式 -> 页面样式为它定义了布局 -> 具体到这个按钮的样式定义了它的独特样式。正是由于这么多层这么复杂的样式组成,导致在需要更新样式的时候,会出现一些很”偷懒“的做法——通过直接覆盖样式,而不是去找到原先写样式的地方去修改。


// 反例
<template>
<button class="ivu-btn btn-close" style="color: white;">X</button>
</template>

<style>
.btn-close {
color: red;
}
</style>

// 正例
<template>
<button class="ivu-btn btn-close">X</button>
</template>

<style>
.btn-close {
color: white;
}
</style>

使用不常用的js api 和 css attribute,注意确认下浏览器兼容性


本条推荐理由很简单。我们推荐使用了chrome浏览器版本号为80+,那兼容性就需要考虑。常用属性已经验证过了没问题,不常用的就需要自行验证。建议可以通过mdn web docs(developer.mozilla.org/zh-CN/docs/…)来查询。
image.png


超长溢出统一用title,而不是tooltip,以提高性能


推荐理由如下:



  • 由于我们全平台中产品设计倾向于单行文本显示,包括标题文本、下拉表单项、表格单元格等等,一个页面中有可能就有上千个。

  • tooltip是iview组件,样式美观可调整,但包含了多个DOM节点;title是HTML属性,样式无法变更。两者性能差异大。


因为涉及范围之广、两者性能差异之大,所以我们建议用title来处理超长溢出。




优先级 B:推荐的


这些规则能够在绝大多数工程中改善可读性和开发体验。即使你违反了,代码还是能照常运行,但例外应该尽可能少且有合理的理由。


禁止单个vue文件超过1000行,尽量500行;禁止复制黏贴超20行的代码


在平常项目开发中,大家都深深体会到了:一个文件太长,维护起来头很大,开发模式下编译时间也很长;大段相似的代码,很不优雅,若产生问题也很容易只改一处,造成缺陷。
之所以限制1000行、500行、20行,凭以往经验决定;只要有充分理由,可以灵活应变。不断地去抽象,去提炼,去封装,也是很考验开发者的功力,很有助于我们的成长。
注:后续会考虑通过eslint+git-hooks阻止超过1000行的文件被提交。


建议不要在html中有超过两个条件的逻辑判断;在js中不要超过两个并列的if,可以考虑优雅的if-else


html中,不要有超过两句话(尽量一个操作符)的逻辑,否则就用computed
js中,一段逻辑不要超过两个if(如果你是第三个应该评估优化一下),优雅的维护if-else。嵌套的if尽可能减少或者注释清楚判断逻辑


代码优化之后,确定不需要的代码建议直接删除,不确定的代码进行注释并写明注释原因;注释或删除一段代码,要把相关的代码一并处理干净


现有情况是存在很多大段注释的代码,太过冗余杂乱,影响代码阅读,因此建议不需要的代码直接删除。
但又存在部分情况是产品提出的要求暂时隐藏某个功能,后续可能会重新启用,因此只需进行注释即可。建议这种情况下,写明注释原因,供他人后续阅读代码或者优化代码提供指引。
注释或删除一段代码时,现在会存在部分情况下,只删除直接相关代码,其他相关代码放任不管。举个例子,比如产品要求隐藏”保存并启用“功能,最差最直接的做法是隐藏这个按钮就完成,但是发现要获取这个按钮权限,需要watch中调用接口,因此导致这个功能被隐藏了,但是接口调用仍在频繁调用。


布局嵌套尽量不要层级太深;不加没有必要的DOM节点;




优先级 C:小tips


这个分类下的是一些项目开发的小技能、小知识点或业务相关的点。


路由组件一定要有name ,以确保keep-alive生效


<keep-alive>includeexclude prop 允许组件有条件地缓存。匹配首先检查组件自身的 name 选项,如果 name 选项不可用,则匹配它的局部注册名称 (父组件 components 选项的键值)。匿名组件不能被匹配。


弹窗或其他未激活的tab别在mounted阶段加载,以减小白屏时间


操作闭环、逻辑闭环考虑


举个栗子:



  • 比如弹窗要考虑确定、取消、关闭、重新打开等一系列闭环操作的正确性;

  • 比如详情的新建、查看、编辑;

  • 比如表格分页,考虑页码跳转,分页器大小变化,过滤条件变化时初始化页码等;

  • 比如v-if v-else-if v-else;


使用every和some方法时,记得排除空数组;every返回始终为true,some始终false


let a = [];
a.every(i => { ... }) // true
a.some(i => { ... }) // false

let a = [];
if (a.length > 0) {
a.every(i => { ... }) // true
a.some(i => { ... }) // false
} else {
...
}

对象浅拷贝时,分清对象展开运算符和Object.assign的区别


let aa = { a : 1, b : 2, c : 3};
let bb = Object.assign(aa, {d : 4});

// 修改aa
delete aa.a;
// 结果bb也发生了变化
console.log(bb); // {b: 2, c: 3, d: 4}

let aa = { a : 1, b : 2, c : 3};
// 解法1
let bb = Object.assign({}, aa, {d : 4});
// 解法2
let cc = {...aa, d : 4};
// 修改aa
delete aa.a;
console.log(bb); // {a: 1, b: 2, c: 3, d: 4}
console.log(cc); // {a: 1, b: 2, c: 3, d: 4}

在mounted或created阶段获取路由信息


连续调用接口的方法中如果有路由这种会变化的传参时,不能使用this.$route获取路由,避免执行方法时用户通过点击页签切换路由导致后续接口报错


样式尽可能考虑不同分辨率的自适应,如1366、1920


作者:是秋天啊
来源:juejin.cn/post/7216526817371504697
收起阅读 »

前端正确处理“文字溢出”的思路

web
前言: 最近在项目中需要做到类似于 Mac 下这种,当屏幕宽度足以容下当前文件名称的时候,文件名称全部展示,不做省略。 然而当用户缩放浏览器显示的尺寸时,我们需要做到省略中间的文字,选择保留后缀这种方案。如下图所示: 我个人也是感觉这个方案是最好的,因为大...
继续阅读 »

前言: 最近在项目中需要做到类似于 Mac 下这种,当屏幕宽度足以容下当前文件名称的时候,文件名称全部展示,不做省略。

image.png

然而当用户缩放浏览器显示的尺寸时,我们需要做到省略中间的文字,选择保留后缀这种方案。如下图所示:

1.gif


我个人也是感觉这个方案是最好的,因为大部分情况下,用户更关心的是这个文件的类型,而后缀名的保留往往是最佳的选择。我个人也查阅了很多相关文章,并且借鉴了一些已有轮子的代码思路,实现了一个符合我们项目中需求的一个组件。




一.组件效果预览




  1. 单行文字溢出时自动省略,并且不保留后缀。

    image.png




  2. 单行文字溢出时自动省略,并且保留后缀。

    image.png




  3. 多行文字溢出时,然后再开始省略。这个情况是我们项目中比较特殊的场景。简单来说就是假设我现在想让文字显示两行,如果两行的时候没有溢出,那么正常显示。如果两行情况下还是溢出了,那么我再去处理溢出的文字。
    假设这是没有做任何操作的的效果:

    image.png

    使用我们的组件以后的效果:

    image.png

    (tips:不一定必须是两行,三行,四行都是可以的。我们接下来实现的组件会让你高度自定义去处理文字溢出的场景。)




  4. 如果你想自己先尝试一下效果,那么你可以快速使用 npm 安装一下。




    • npm i auto-ellipsis-text




    • pnpm i auto-ellipsis-text




    • yarn add auto-ellipsis-text




    原仓库地址: 🫱AutoEllipsisTxt自动省略文字




  5. 使用起来也非常简单,你只需要包裹住你的文字即可
    image.png
    image.png




  6. 话回正题,接下来我会一步一步讲解我实现这个组件的思路,我写的这个组件不一定是最优的,你需要做到知其然并知其所以然,然后完善我写的组件的不足之处,你可以实现自己的自动省略文本方案,才是本文的目的。




二. 单行溢出的处理




  1. 我们先只考虑单行的情况。通常我们在自己的应用中展示很多文件信息的时候,往往选择的布局方式就是高度是一定的,说白了就是高度其实我们是定死的,宽度我们不确定,因为用户有可能会在某些情况下拖动浏览器,造成宽度发生变化,但是总会给宽度一个最小值和一个最大值来保障排版的统一性。

    image.png




  2. 样式方面,在这里我使用的是 UnoCSS ,将样式內联在了标签里,如果你还不了解这种写法,你可以点击下方的文章学习。不过即使你之前从未了解过 UnoCSS ,也不会影响你下面的阅读,因为样式不是本文的重点,并不影响整体阅读。

    🫱手把手教你如何创建一个代码仓库




  3. 让我们先创造一个简单的溢出场景,代码很简单,容器是一个 width 最大值为 200pxheight 为固定 30pxdiv

    image.png

    现在页面上的效果如下图:

    image.png




  4. 可以很清晰的看出,由于我们文字在容器内放不下,但是我们又没对溢出这一特殊场景做出处理,所以就造成了当前页面的效果。先别急,我们一步一步来。




  5. 最开始我去查阅 MDN 的时候,查阅到了一个 “确认过眼神,你就是我要找到人” 的属性。

    image.png




  6. 什么?text-overflow,我们要找到不就是文字溢出时候的处理吗?我兴奋的赶快添加到了我的组件上。

    image.png

    效果如下:

    image.png

    然后看着毫无变化的页面,开始怀疑我自己是不是单词拼错了,然后一个字母字母的比对,排除了单词打错字的情况,但页面还是没有变化。🤔




  7. 于是我又返回 MDN 去查看自己是否遗漏了哪些东西,发现了这样一段文字。

    image.png

    这里直接说结论,其实 text-overflow 这个属性不会为了让文字省略而去创造省略的场景。它其实是在你处理过溢出场景之后,帮你做对于文字溢出的的二次特殊处理。当你对于页面溢出做没有任何操作时,这个属性其实是无效的。 (注意:它仅仅只处理文字溢出的场景。)




  8. 既然你说了,让我们添加额外的属性:overflow-hiddenwhite-space,那么我们就自己添加。我们先只添加一个 overflow-hidden 来看看会发生什么。

    image.png

    我们发现,下面多出去的文字倒是被省略了,但是我们的省略号呢??我就不卖官子了,其实造成这个的原因的答案就是下面这句话:

    image.png




  9. 我们仔细看上面我们溢出的场景。

    image.png

    我们下面两行文字其实是溢出在了盒子下方,正好对应了上面 text-overflow 的介绍,“无法在盒子下面溢出” 这句话。




  10. 在这里我们就需要制造一个让文字强制不换行的场景。那么就需要用到我们另外一个十分重要的属性,white-space

    image.png

    我们本节只需要关系 nowrap 这一个值即可。剩下的值如果读者有兴趣可以自行了解,我们不过多解释。




  11. 首先你要知道,其实我们 web 页面的换行,并不是毫无意义的自己就换行了,而是都有一个隐藏的换行符,你可以把这个隐藏的换行符浅浅的理解为 white-space(空格)

    image.png




  12. 理解了上面那段话,那我们的属性 white-space:nowrap 的中文含义就十分明显了。white-space对应空格no-wrap 代表不换行。连起来的意思就是,遇到空格不换行。而我们的换行其实有一个隐藏的 white-space ,那么我们添加这个属性以后,就会造成一个不会换行的场景。




  13. 让我们先把 text-ellipsisoverflow-hidden 属性删除,只添加 white-space:nowrap 看看页面效果会是怎么样。

    image.png

    效果如下:

    image.png

    可以看到,我们省略了那个隐藏的换行符,所以文字不会自动换行了,那么整段文字都显示到了一行上。此时我们再加上我们的两个属性,overflow-hiddentext-ellipsis,神奇的一幕就发生了。

    image.png

    我们仅仅只使用了几个 CSS 属性就完成了单行情况下不保留后缀的文字溢出处理。




三. 前期准备




  1. 首先你需要准备一个 autoEllipsis.vue 文件,首先写出下面的代码,来和我一起完成这个组件。


    <template>
    <div id="autoEllipsisWrapper" ref="container" v-bind="$attrs">
    <span ref="text">
    <slot />
    </span>
    </div>
    </template>




  2. 请注意这个 id 叫做 containerdiv 元素将在接下来的内容中起到至关重要的作用。




  3. 接下来使用 ref 分别去拿到这两个 dom 元素。

    image.png




  4. 最后我们需要设计一个函数,在组件挂载以后,让它去正确处理我们文字溢出的场景。

    image.png




  5. 接下来的需求就是,这个 autoEllipsis 函数如何去实现。别着急写代码,我知道你现在有可能还是一头雾水无从下手,让我先带你理清思路然后再开始写代码。




四. 理清思路




  1. 首先我们因为要做到通用性所以, container 的宽度是不能确定的,它的宽度需要根据它外层的父元素来决定,也就是上文中我们提到的有一个最大值最小值宽度的元素。

    image.png

    换句话说,我们这个 container 要去动态的拿到外层父元素的宽度。




  2. 我们先不讲代码如何实现,我们假设现在我们已经拿到了,就叫做 fatherWidth。然后我们再通过刚刚的 ref 获取到的 text dom 元素去拿到外面传进来的文字内容。通过拿到这个 span 元素的 offsetWidth ,就可以拿到文字的长度。通过判断文字的 offsetWidth 是否大于 fatherWidth 。然后我们通过两个宽度相减,可以得出我们到底溢出的文字宽度为多少。

    image.png




  3. 拿到溢出的宽度以后,那么我们就可以用溢出宽度来除以文字大小,(overWidth/fontSize) ,就可以算出我们到底溢出了多少文字。




  4. 假设现在我们现在溢出宽度为 200px。我们的文字大小为 20px,那么 200/20 就算出我们现在溢出了 10 个字。




  5. 我们并且一开始就拿到了总的文字内容,假如我们之前的文字总数为 30 个。那么在这个情况下我们屏幕上只展示了 20 个文字,因为有 10 个字溢出被我们忽略了。




  6. 到这里之后,我们要做的事情就非常简单了,我们只需要从原来 30 个字的中间开始做切割。一边去掉 5 个,那么此时容器恰好可以容下 20 个字。中间我们再手动加上 “...” 省略号不就完美达成了吗?




  7. 上面想表达的意思用大白话来讲,其实也就是去掉中间的10个文字,然后随便再找一个字替换成字符串三个点 ...




五. 完成 autoEllipsis 函数




  1. 第一步就是为了拿到我们放入的文字宽度。注释已经写的很清楚了,就不过多赘述。

    image.png




  2. 然后我们再去拿外面父元素的宽度。此时会出现第一个分支, container 的宽度小于父元素的宽度,很容易可以猜到现在我们的文字内容是完全可以容纳的,不需要做特殊处理。

    image.png




  3. 第二个分支,当我们的 container 宽度大于了父亲元素的宽度,那么我们可以通过传递 props 来区分是否需要保留后缀,如果不需要保留后缀,我们直接给 container设置我们第二个标题讲解的知识就OK了。

    image.png




六. 保留后缀的实现




  1. 如果看到这里,你还没有正确的保留后缀思路,我建议你重新去观看一下标题四,这里我们大致的思路就是为了拿到父元素可以容纳多少文字。

    image.png




  2. 这里我们的思路其实就是计算出得出我们需要删除多少个文字

    image.png




  3. 很简单的思路,就是字符串使用 slice 切割我们上面计算得出的,两边需要删除多少文字。

    image.png




  4. 最后的关键一步,我们需要把 containerwhite-space 属性设置为 normal,因为我们已经正确的处理了文字数量,现在的 container 已经不会溢出了。

    image.png




七. 源码


下面是本组件的核心代码 autoEllipsis 函数的源码


function autoEllipsis(container: HTMLElement, textNode: HTMLSpanElement) {
const str = premitiveText; //1.拿到的所有文字信息
textNode.textContent = str; //2.将所有文字放入到我们的 span 标签中
container.style.whiteSpace = "nowrap"; //3.先将文字全部放入到《一行》中,为了计算整体宽度
container.style.width = "fit-content"; //4. 给 container 设置 fit-content 属性,就可以拿到正确的内容宽度
const containerWidth = container.clientWidth; //5. 拿到了 container 的宽度

const parent = container.parentElement; // 拿到外部父元素的宽度
const parentWidth = parent!.clientWidth || parent!.offsetWidth;
if (containerWidth <= parentWidth) {
//如果container 的宽度《小于》父元素的宽度,不做任何处理
textNode.textContent = str;
return;
} else if (cssEntirely.value) {
container.style.width = parentWidth + "px";
container.style.whiteSpace = "nowrap";
container.style.textOverflow = "ellipsis";
container.style.overflow = "hidden";
return;
} else {
const textWidth = textNode.offsetWidth; //1. 拿到文字节点的宽度
const strNumer = str.length; //2. 拿到文字的数量
const avgStrWidth = textWidth / strNumer; //3. 拿到平均每个文字多少宽度
const canFitStrNumber = Math.floor(
(parentWidth * props.startEllipsisLine) / avgStrWidth //4. 根据父元素的宽度来计算出可以容纳多少文字
);

const shouldDelNumber = strNumer - canFitStrNumber + 1.5; //1. 算出需要删除几个文字(1.5是为了省略号的宽度
const delEachSide = shouldDelNumber / 2; //2. 因为要保留中间,所以我们不能只从开头删除,也需要从两头删除
const endLeft = Math.floor(strNumer / 2 - delEachSide); //3. 因为下面要用到 slice 所以需要计算出 index
const startRight = Math.ceil(strNumer / 2 + delEachSide); //4. 和上面同理

switch (props.suffix) {
case true: {
textNode.textContent =
str.slice(0, endLeft) + "..." + str.slice(startRight);
break;
}
case false: {
textNode.textContent = str.slice(0, -shouldDelNumber) + "...";

break;
}
}
container.style.wordBreak = "break-all";
container.style.whiteSpace = "normal";
}
}


八. 优化点


这个组件目前在 ... 省略号的文字占用上,并不能准确的根据文字大小调整所需的字数。也就是下面的 1.5 这个数字无法精确的算出,但是目前我们项目的文字大小是确定的,所以我也就没有再优化了,还希望各位可以提交 Pr 来一起完善这个组件。

image.png


原仓库地址: 🫱AutoEllipsisTxt自动省略文字


作者:韩振方
来源:juejin.cn/post/7218411904699924540
收起阅读 »

孤独的游戏少年

web
本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。 楔子 又是一个闲暇的周末,正读一年级的我坐在床上,把象棋的旗子全部倒了出来,根据...
继续阅读 »

本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。



楔子


又是一个闲暇的周末,正读一年级的我坐在床上,把象棋的旗子全部倒了出来,根据颜色分为红色和绿色阵营,旗子翻盖为建筑,正面为单位,被子和枕头作为地图障碍,双手不断的移动着双方阵营的象棋,脑海中演练着星级争霸的游戏画面,将两枚不同阵营的旗子进行碰撞后,通过我对两枚旗子主观的判断,一方阵营的旗子直接被销毁,进入回收,一方的单位对建筑物全部破坏取得游戏胜利。因为我的父亲酷爱玩这款游戏,年少的我也被其玩法、画面所深深吸引,不过最主要的还是父亲获胜或者玩累后,能幸运的奖励我玩上几把。星际争霸成了我第一个启蒙游戏,那时候怎么样都获胜不了,直到发现了show me the money。因为Blizzard这个英文单词一直在游戏的启动界面一闪一闪,那时候还以为这款游戏的名字叫Blizzard,最后才发现,其实Blizzard是魔鬼的意思。


纸笔乐趣


小学一二年级的时候家里管得严,电视也不给我看,电脑直接上锁,作为家里的独生子女,没有同龄人的陪伴,闲暇时间要不就看《格林童话》、《安徒生童话》、《伊索寓言》,要不就打开这副象棋在那里自娱自乐。



起源



在某一天的音乐课上,老师喉咙不舒服,在教室播放猫和老鼠给我们观看,正当我看的津津有味,前面的同学小张突然转过头来,问我“要不要跟我玩个游戏”。小孩子一般都比较贪玩,我直接拒绝了他,“我想看猫和老鼠”。小张满脸失落转了回去,因为我是个特别怕伤害别人情绪的人,所以不忍心又用铅笔的橡皮端戳了戳他,问他“什么游戏,好玩不”。他顿时也来了精神,滔滔不绝的介绍起了这款游戏。


“游戏的规则非常的简单~~在一张纸上中间画一条分割线,双方有各自的基地,每人基地都有5点血量,双方通过猜拳,获胜的一方可以在自己阵营画一个火柴人,火柴人可以攻击对面的火柴人和基地,基地被破坏则胜利。管你听没听懂,试一把就完了!”



我呆呆的看着他,“这么无聊,赢了又怎样?”。他沉默了一会,好像我说的的确有点道理,突然想到了一个好办法,“谁获得胜利扇对方一巴掌”。我顿时来了兴趣。随着游戏逐渐深入,我们的猜拳速度和行动力也越来越快,最后发现扇巴掌还是太狠,改为扇对方的手背,这节课以双方手背通红结束了。


游戏改良


这个《火柴人对战》小游戏在班里火了一会儿,但很快就又不火了,我玩着玩着也发现没啥意思,总是觉得缺少点什么,但毕竟我也只是个没有吃APTX4869的小学生,想不出什么好点子。时间一晃,受到九年义务教育的政策,我也成功成为了一名初中生。在一节音乐课上,老师想让我们放松放松,给我们班看猫和老鼠,隔壁同桌小王撕了一张笔记本的纸,问我有没有玩过《火柴人对战》游戏,只能说,熟悉的配方,熟悉的味道。


当天晚上回到家,闲来无事,我在想这个《火柴人游戏》是不是可以更有优化,这种形式的游戏是不是可以让玩家更有乐趣。有同学可能会问,你那个年代没东西玩的吗?既然你诚心诚意发问,那我就大发慈悲的告诉你。玩的东西的确很多,但是能光明正大摆在课桌上玩的基本没有,一般有一个比较新鲜的好玩的东西,都会有一群人围了过来,这时候老师会默默站在窗户旁的阴暗角落,见一个收一个。


坐在家里的椅子上,我整理了一下思绪,突然产生了灵感,将《魔兽争霸》《游戏王》这两个游戏产生化学反应,游戏拥有着资源,单位,建筑,单位还有攻击力,生命值,效果,攻击次数。每个玩家每回合通过摇骰子的方式获得随机能源点,能源能够解锁建筑,建筑关联着高级建筑和单位,通过单位进行攻击,直至对方玩家生命值为0,那么如何在白纸上面显示呢?我想到比较好的解决方案,单位的画像虽然名字叫骷髅,但是在纸上面用代号A表示,建筑骷髅之地用代号1表示。我花了几天时间,弄了两个阵营,不死族和冰结界。立刻就拿去跟同桌试玩了一下,虽然游戏很丰富,但是有一个严重的弊端就是玩起来还挺耗费时间的,而且要人工计算单位之间的扣血量,玩家的剩余生命,在纸片上去完成这些操作,拿个橡皮擦来擦去,突然觉得有点蠢,有点尴尬,突然明白,一张白纸的承受能力是有限的。之后,我再也没有把游戏拿出来玩过,但我没有将他遗忘,而是深深埋藏在我的心里。


筑梦


直到大学期间《炉石传说》横空出世,直到《游戏王》上架网易,直到我的项目组完成1.0后迎来空窗期一个月,我再也蚌埠住了,之前一直都对微信小游戏很有兴趣,每天闲着也是闲着,所以我有了做一个微信小游戏的想法。而且,就做一款在十几年前,就已经被我设计好的游戏。


但是我不从来不是一个好学的人,领悟能力也很低,之前一直在看cocos和白鹭引擎学习文档,也很难学习下去,当然也因为工作期间没有这么多精力去学习,所以我什么框架也不会,不会框架,那就用原生。我初步的想法是,抛弃所有花里胡哨的动效,把基础的东西做出来,再作延伸。第一次做游戏,我也十分迷茫,最好的做法肯定是打飞机————研究这个微信项目如何用js原生,做出一个小游戏。



虽然微信小游戏刚出来的时候看过代码,但是也只是一扫而过,而这次带着目标进行细细品味,果然感觉不一样。微信小游戏打飞机这个项目是js原生使用纯gL的模式编写的,主要就是在canvas这个画布上面作展示和用户行为。


  // 触摸事件处理逻辑
touchEventHandler(e) {
e.preventDefault()

const x = e.touches[0].clientX
const y = e.touches[0].clientY

const area = this.gameinfo.btnArea

if (x >= area.startX
&& x <= area.endX
&& y >= area.startY
&& y <= area.endY) this.restart()
}

点击事件我的理解就是用户点击到屏幕的坐标为(x, y),如果想要一个按钮上面做处理逻辑,那么点击的范围就要落在这个按钮的范围内。当我知道如何在canvas上面做点击行为时,我感觉我已经成功了一半,接下来就是编写基础js代码。


首先这个游戏确定的元素分别为,场景,用户,单位,建筑,资源(后面改用能源替代),我先将每个元素封装好一个类,一边慢慢的回忆着之前游戏是如何设计的,一边编程,身心完全沉浸进去,已经很久很久没有试过如此专注的去编写代码。用了大概三天的时间,我把基本类该有的逻辑写完了,大概长这个样子



上面为敌方的单位区域,下方为我方的单位区域,单位用ABCDEFG表示,右侧1/1/1 则是 攻击力/生命值/攻击次数,通过点击最下方的icon弹出创建建筑,然后创建单位,每次的用户操作,都是一个点击。


一开始我设想的游戏名为想象博弈,因为每个单位每个建筑都只有名称,单位长什么样子的就需要玩家自己去脑补了,我只给你一个英文字母,你可以想象成奥特曼,也可以想象成哥斯拉,只要不是妈妈生的就行。



湿了


游戏虽然基本逻辑都写好了,放到整个微信小游戏界别人一定会认为是依托答辩,但我还是觉得这是我的掌上明珠,虽然游戏没有自己的界面,但是它有自己的玩法。好像上天也认可我的努力,但是觉得这个游戏还能更上一层楼,在某个摸鱼的moment,我打开了微信准备和各位朋友畅谈人生理想,发现有位同学发了一幅图,上面有四个格子,是赛博朋克风格的一位篮球运动员。他说这个AI软件生成的图片很逼真,只要把你想要的图片告诉给这个AI软件,就能发你一幅你所描绘的图片。我打开了图片看了看,说实话,质感相当不错,在一系列追问下,我得知这个绘图AI软件名称叫做midjourney



midjourney



我迫不及待的登录上去,询问朋友如何使用后,我用我蹩脚的英格力士迫不及待的试了试,让midjourney帮我画一个能源的icon,不试不要紧,一试便湿了,眼睛留下了感动地泪水,就像一个阴暗的房间打开了一扇窗,一束光猛地照射了进来。


WechatIMG35.jpeg


对比我之前在iconfont下载的免费图标,midjourney提供这些图片简直就是我的救世主,我很快便将一开始的免费次数用完,然后氪了一个30美刀的会员,虽然有点肉痛,但是为了儿时的梦想,这点痛算什么


虽然我查找了一下攻略,别人说可以使用gpt和midjourney配合起来,我也试了一下,效果一般,可能姿势没有对,继续用我的有道翻译将重点词汇翻译后丢给midjourney。midjourney不仅可以四选一,还可以对图片不断优化,还有比例选择,各种参数,但是我也不需要用到那么多额外的功能,总之一个字,就是棒。


但当时的我突然意识到,这个AI如此厉害,那么会不会对现在行业某些打工人造成一定影响呢,结果最近已经出了篇报道,某公司因为AI绘图工具辞退了众多插画师,事实不一定真实,但是也不是空穴来风,结合众多外界名人齐心协力抵制gpt5.0的研发,在担心数据安全之余,是否也在担心着AI对人类未来生活的各种冲击。焦虑时时刻刻都有,但解决焦虑的办法,我有一个妙招,仍然还是奖励自己


门槛


当我把整个小游戏焕然一新后,便兴冲冲的跑去微信开放平台上传我的伟大的杰作。但微信突然泼了我一盆冷水,上传微信小游戏前的流程有点出乎意外,要写游戏背景、介绍角色、NPC、介绍模块等等,还要上传不同的图片。我的小游戏一共就三个界面,有六个大板块要填写,每个板块还要两张不同的图片,我当时人就麻了。我只能创建一个单位截一次图,确保每张图片不一样。做完这道工序,还要写一份自审自查报告。


就算做完了这些前戏,我感觉我的小游戏还是难登大雅之堂,突然,我又想到了这个东西其实是不是也能运行在web端呢,随后我便立刻付诸行动,创建一个带有canvas的html,之前微信小游戏是通过weapp-adapter这个文件把canvas暴露到全局,所以在web端使用canvas的时候,只需要使用document.getElementById('canvas')暴露到全局即可。然后通过http-server对应用进行启动,这个小游戏便以web端的形式运行到浏览器上了,终于也能理解之前为啥微信小游戏火起来的时候,很多企业都用h5游戏稍微改下代码进行搬运,原来两者之间是有异曲同工之妙之处的。


关于游戏


龙族.png


魔法学院.png
上面两张便是两个种族对应的生产链,龙族是我第一个创建的,因为我自幼对龙产生好感和兴趣,何况我是龙的传人/doge。魔法学院则是稍微致敬一下《游戏王》中黑魔导卡组吧。


其实开发难度最难的莫过于是AI,也就是人机,如何让人机在有限的资源做出合理的选择,是一大难题,也是我后续要慢慢优化的,一开始我是让人机按照创建一个建筑,然后创建一个单位这种形式去做运营展开,但后来我想到一个好的点子,我应该可以根据每个种族的特点,走一条该特点的独有运营,于是人机龙族便有了龙蛋破坏龙两种流派,强度提升了一个档次。


其实是否能上架到微信小游戏已经不重要了,重要的是这个过程带给我的乐趣,一步一步看着这个游戏被创建出来的成就感,就算这个行业受到什么冲击,我需要被迫转行,我也不曾后悔,毕竟是web前端让我跨越了十几年的时光,找到了儿时埋下的种子,浇水,给予阳光,让它在我的心中成长为一棵充实的参天大树


h5地址:hslastudio.com/game/


github地址: github.com/FEA-Dven/wa…


作者:很饿的男朋友
来源:juejin.cn/post/7218570025376350263
收起阅读 »

【干货】验证码的常见类型总结

前言验证码是一种区分用户是计算机和人的公共全自动程序。简单来说,验证码就是验证操作是人还是机器。下面我就总结一下常见的验证码类型都有哪些?数字、字母组合这种形式最为常见,也很简单。有的是单独使用这两种,也有的是数字、字母混合而成,为了提高识别难度,有的会添加干...
继续阅读 »

前言

验证码是一种区分用户是计算机和人的公共全自动程序。简单来说,验证码就是验证操作是人还是机器。下面我就总结一下常见的验证码类型都有哪些?



数字、字母组合

这种形式最为常见,也很简单。有的是单独使用这两种,也有的是数字、字母混合而成,为了提高识别难度,有的会添加干扰线,如在背景中添加干扰线。



代码如下:

<?php 
// 丢弃输出缓冲区的内容 **
ob_clean();

// 创建画布
$image = imagecreatetruecolor(110, 30);

// 设置白色底
$bgColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $bgColor);

// 添加四个随机数字字母
for($i=0;$i<4;$i++) {
$fontSize = 6;
// 随机分配颜色
$fontColor = imagecolorallocate($image, rand(0, 120), rand(0, 120), rand(0, 120));
// 生成内容
$data = "abcdefghijkmnpqrstuvwxy3456789";
// 如果内容为空,重新输出1
do {
$fontCont = substr($data, rand(0, strlen($data)), 1);
} while ($fontCont == '');
// 设置范围
$x = ($i*110/4)+rand(5, 10);
$y = rand(5, 10);
// 图片加入数字
imagestring($image, $fontSize, $x, $y, $fontCont, $fontColor);
}

// 添加干扰点元素
for($j=0;$j<200;$j++) {
// 点颜色
$pointColor = imagecolorallocate($image, rand(50, 200), rand(50, 200), rand(50, 200));
imagesetpixel($image, rand(1, 99), rand(1, 29), $pointColor);
}

// 添加干扰线元素
for($z=0;$z<4;$z++) {
// 生成颜色线
$lineColor = imagecolorallocate($image, rand(80, 220), rand(80, 220), rand(80, 220));
imageline($image, rand(1, 99), rand(1, 29), rand(1, 99), rand(1, 29), $lineColor);
}

header("Content-type:image/png");
// 输出图片
imagepng($image);
// 销毁内存中的图片
imagedestroy($image);

?>


短信验证码

随着手机的普及,很多APP都是用手机号注册的。为了验证手机号码的真实性,防止恶意注册,通常会向手机发送验证码。网上有专门的短信发送平台,向电信运营商支付短信费用,接入即可使用。



图片识别

根据提示,点击对应的元素。逻辑解题能力结合图形符号等元素识别能力。适用于安全要求超高的业务场景。

使用KgCaptcha,在用户控制台设置验证类型,多种类型选择,如滑动拼图、文字点选、语序点选、字体识别、空间推理。

<script src="captcha.js?appid=xxx"></script>
<script>
kg.captcha({
// 绑定元素,验证框显示区域
bind: "#captchaBox2",
// 验证成功事务处理
success: function(e) {
console.log(e);
},
// 验证失败事务处理
failure: function(e) {
console.log(e);
},
// 点击刷新按钮时触发
refresh: function(e) {
console.log(e);
}
});
</script>
<div id="captchaBox2">载入中 ...</div>

最后

SDK开源地址:https://github.com/KgCaptcha,顺便做了一个演示:https://www.kgcaptcha.com/demo/

收起阅读 »

环信web、uniapp、微信小程序sdk报错详解---注册篇(二、三)

项目场景:记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。注册篇(二)注册用户报错400原因分析:从console控制台输出`及`network请求返回入手分析可以看到...
继续阅读 »

项目场景
记录对接环信sdk时遇到的一系列问题,总结一下避免大家再次踩坑。这里主要针对于web、uniapp、微信小程序在对接环信sdk时遇到的问题。
注册篇(二)
注册用户报错400


原因分析:

console控制台输出`及`network请求返回入手分析
可以看到报错描述user requires that property named username be unique, value of chai exists,翻译一下可以知道是用户名必须唯一,该用户已存在

解决方案:

在知道是因为用户名重复导致的报错,那么在注册时就要确保用户名唯一

注册篇(三)

注册用户报错429


原因分析:

同样从console控制台输出`及`network请求返回入手分析

可以看到报错描述You have exceeded the limit of the community edition,Please upgrade to the enterprise edition,大概翻译一下可以看到是您已超过社区版的限制,请升级到企业版

解决方案:
联系商务经理将appkey版本升级到企业版即可,免费版的appkey注册用户数只有100个,在超过100个之后就会报错429

拓展:
有些同学在调用api时也会出现429的报错情况,但是报错描述为Too Many Requests: [{"exception":"com.easemob.flow.exceptions.ReachLimitException","duration":0,"error":"reach_limit","error_description":"This request has reached api limit.","timestamp":1660188532229}]

这种情况是因为超过了API 调用频率限制,可以看一下环信关于Restful API 调用频率限制的文档,https://docs-im-beta.easemob.com/document/server-side/limitationapi.html。超限之后可以暂停一会再继续调用,或者可以联系商务经理调整该限制

需要注意一下,两处429的报错描述有所区别,大家需要仔细甄别一下~

收起阅读 »

为什么你永远不应该在CSS中使用px来设置字体大小

web
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。 在Josh Collinsworth的博客文章“永远不要用px作为字体大小”中,作者讨论了为什么不应...
继续阅读 »

image.png


代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug


在Josh Collinsworth的博客文章“永远不要用px作为字体大小”中,作者讨论了为什么不应该使用像素(px)作为网页字体大小的单位[1]。作者指出,相对于容器、浏览器或用户的字体大小,px值是静态的。无论用户的字体偏好设置如何,当我们以静态像素设置值时,它将覆盖用户的选择,以我们指定的确切值替代。这意味着,如果我wu7的样式表使用像素单位,可能导致访问网站的用户难以阅读。


因此,作者建议使用相对单位,如em、rem或百分比,而不是像素。这些单位是基于用户的字体大小偏好设置进行缩放的,从而提供了更好的可访问性和可读性。尤其是在设计响应式网站时,相对单位能够提高跨设备的兼容性。通过使用相对单位,设计师可以确保网站在不同设备和浏览器中以合适的字体大小显示[1]。


下面是正文:


在 Web 开发领域中,有很多误解流传,即使它们被反驳了很多次也仍然存在。"外部链接应该总是在新标签页中打开" 就是一个很好的例子。CSS Tricks 在将近十年前就对此进行了详细的解释(简而言之:大多数情况下是错误的),但它似乎仍然在某些角落中存在。


案例证明:在CSS中, pxemrem 单位之间没有功能上的区别的想法是一个我一遍又一遍听到的误解,因此我想在这里发帖来解决这个问题。


我们要非常清楚:在CSS中使用的单位绝对很重要。并且在设置时 font-size 应尽可能避免使用 px


我们在谈论什么单位,它们是做什么的?


在我们讨论为什么应该避免使用 px 作为 font-size 之前,让我们确保我们都清楚我们正在谈论哪些单位,以及它们的一般行为。


px


px 是像素的缩写……虽然现在大多数情况下它不再是一个真正的像素。在显示器通常是一个相对可预测的低分辨率像素比例,比如1024×768的时代, 1px 通常等于屏幕上的一个实际像素。



屏幕使用称为像素的彩色光点阵来显示图像。一个像素是显示器上的一个彩色光点;硬件能够呈现的最小可能的“点”。这就是我在本节中所说的“字面上的”、“实际的”或“设备”像素;物理世界中的一个像素。



然而,当高分辨率(有时称为“视网膜”)屏幕出现时,设备开始将更多的像素压缩到更小的空间中,这些物理设备像素变得非常微小。在高分辨率屏幕上浏览网页,如果CSS中的 1px 仍然对应于一个字面设备像素,那么甚至阅读任何内容都将非常困难,因为像素本身正在迅速缩小。毕竟,现代智能手机的分辨率甚至比高清电视还要高。


所以现在, 1px 通常对应于放大的“缩放”像素的大小,而不是实际硬件上的字面像素。在我们的 CSS 中, 1px 的东西可能会占用多个物理硬件像素,而我们没有任何纯 CSS 的方法来指定一个字面设备像素。但这没关系,因为它们通常太小了,我们不想去处理它们。



一个例子:iPhone 14 Pro 上的像素非常微小,16px 在字面上的设备像素大小大约相当于2pt字号的印刷字体大小。好在浏览器为我们缩放了它们!



大多数情况下,这些并不在本讨论的语境中真正重要,但我认为了解这些还是很好的。重要的部分是: 1px 等于浏览器视为单个像素的任何内容(即使在硬件屏幕上它不是真正的像素)。


em 和 rem


这就带我们来到了 emrem ,它们彼此相似。继续讲述不严格相关但仍然有趣的小知识: "em" 是一个排版术语,实际上比计算机早了几十年。在排版上,一个 em 等于当前字体大小。


如果你将字体大小设置为 32pt(“pt”是另一个仍然有时使用的旧排版术语),那么 1em 就是32pt。如果当前字体大小为 20px ,那么 1em = 20px


在网页上,默认字体大小为 16px 。一些用户从不更改默认设置,但许多人会更改。但默认情况下, 1em1rem 都将等于 16px 。



“Em” 最初是指 “M” 字符的宽度,这也是名称的由来。但现在它指的是当前字体大小,而不是特定字形的尺寸。



EM 和 REM 之间的区别


为了区分这两者: 1rem 始终等于浏览器的字体大小,或者更准确地说是 html 元素的字体大小。 rem 代表“根em”,而网页的根是标签。因此, 1rem = document 字体大小。(默认情况下,这是 16px ,但可以被用户覆盖。)


另一方面,em是当前元素的字体大小。看下面的CSS:


.container {
font-size: 200%;
}

p {
font-size: 1em;
}

考虑到上述 CSS, .container 元素内的段落将会变成原来的两倍大小。这是因为 1em 表示“当前字体大小”,在 .container 内,它是200%1em × 200% = 2em (默认为 32px )。


然而, .container 元素外的段落仍将是 1em 的正常字体大小(默认为 16px )。


如果我们在上面的CSS中将 em 更改为 rem ,那么所有段落标签的字体大小将始终是浏览器的默认大小,无论它们在哪里。



font-size: 1em 等同于 font-size: 100% 。
em 和 % 单位在其他情况下并不总是等价的;例如, width: 1emwidth: 100% 很可能会非常不同,因为此时百分比是基于父容器的宽度而不是其字体大小。但是,就 font-size 属性而言, %em 是相同的。



总结一下:




  • 1em 是当前元素的字体大小。




  • 1rem (根em)是文档的字体大小(即浏览器的字体大小)。




好的,那就是单位的含义和来源。现在让我们回答为什么使用哪个单位很重要。


为什么这一切都很重要


再次强调的误解是:既然 1em16px 相等,那么选择哪个单位并不重要。这似乎是合理的;如果 16px = 1rem ,那么选择哪种方式输入似乎并不重要。


记住, emrem 是相对的;默认情况下,它们都(最终)基于浏览器的字体大小。


2rem 是浏览器字体大小的两倍; 0.5rem 是其一半,依此类推。因此,如果用户更改其首选字体大小,如果使用 emrem ,则网站上的所有文本都会相应更改,就像应该的那样。 2rem 仍然是该字体大小的两倍; 0.5rem 仍然是其一半。


相比之下, px 值是静态的。无论容器、浏览器或用户的字体大小如何, 20px 只是 20px 。当设置静态像素值时,无论用户的字体偏好大小如何,它都会覆盖该选择并使用指定的确切值。


批判性地说,这意味着如果你的样式表使用 px 在任何地方设置 font-size ,那么基于该值的任何文本都将无法由用户更改。


那是非常糟糕的事情。它是不可访问的,甚至可能会阻止某人完全使用该网站。


因此,虽然可能存在一些有效的用例来解释这种行为,但它绝对不是你想要的默认行为。



这也是避免使用视口单位(如 vw 或 vh )设置字体大小的非常好的理由。它们也是静态的,用户无法覆盖。
最多,像 calc(1rem + 1vw) 这样的值可能是可以接受的,因为它仍然包含 rem 作为基础。即便如此,我仍建议使用 clamp() 或媒体查询来设置最小和最大值,因为屏幕尺寸往往远远超出我们所期望或测试的范围。



超出字体大小的差异


好的,现在让我们谈谈当我们不特别处理 font-size 属性时, px em / rem 如何变化。


开发人员通常通过缩放页面来进行测试,我认为这就是本文中心误解的来源。当你缩放时,所有内容都会被缩放(放大或缩小),在这种情况下,选择 pxem / rem 作为你的CSS单位通常并不重要。就缩放而言,两者的行为方式相同。而且,大多数视力良好的开发人员可能不会意识到其中还有更多内容。然而,棘手的问题是:


即使超出 font-sizepx 的行为也与 emrem 不同。


px 单位仍然与屏幕上像素的缩放值相关联。 emrem 与文档的字体大小相关联,而不是页面的缩放或比例。


为了演示,请看这个 CodePen:


codepen.io/collinswort…


HTML CSSResult Skip Results Iframe
EDIT ON
<p>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Nam eum aliquam eveniet.p>
<p>Sapiente delectus in ab excepturi, commodi placeat quaerat saepe voluptas sunt numquam.p>
<p>Rerum veniam, quidem voluptatibus deleniti nihil consequatur blanditiis explicabo eum quos. Nam.p>
<p>Natus necessitatibus delectus neque tenetur sint illum obcaecati similique sequi doloribus eligendi?p>
<p>Eos quidem iure debitis dolorum repellendus ab incidunt ipsam suscipit, autem consequuntur?p>

p {
border-bottom: 2px solid black;
margin-top: 0;
margin-bottom: 20px;
}

我们有几个段落,每个段落底部有 2px 边框,并且它们之间有 20px 边距。请注意,我们对两者都使用 px 单位。


如果你放大或缩小,元素的大小和距离保持相对不变。也就是说:你放大得越多,那条线就越粗,段落之间的间距就越大。


为了方便起见,这里有一张截图,显示了同一支笔的400%缩放。文本、线条和间距都变大了4倍;它们相对于彼此的大小保持不变:


image.png


当涉及到缩放时, pxemrem 之间没有真正的区别。但缩放并不是用户使网站更易用的唯一方法。


如前所述,用户还可以指定默认和/或最小字体大小。当他们这样做时,功能开始分歧。


在下面的截图中,我已将Firefox的默认字体大小设置为 64px 。看一下:


image.png


将屏幕截图中的文本与其上方的文本进行比较。请注意,这一次,行并没有变粗,段落之间的边距也没有成比例增加。只有文本本身变大了。因为边框宽度和边距都是在 px 中设置的,它们保持不变,不会缩放。


但是请注意,如果将CSS中的 px 更改为相应的 rem 值,会发现线条和间距确实变大了! (zh-Hans)


image.png


所以,这里的总结是:



  • 当用户更改字体大小时, px 值不会缩放。

  • em 和 rem 的值会随字体大小成比例调整。


如果你想要一个交互式演示,将所有这些内容联系在一起,请查看最终的 CodePen;调整顶部的滑块以查看修改文档字体大小对各种元素的影响,基于它们使用的 CSS 单位。
codepen.io/collinswort…


选择哪一个


因此,知道 emrem 会随字体大小缩放,但 px 值不会,那么我们该怎么办?我们应该永远不使用 px 吗?


虽然我认为如果你选择这条路,你可能会没事,但我仍然认为 px 有其存在的意义。


我们知道当用户调整字体大小时 px 值不会改变,这意味着像素单位实际上是某些美学元素的不错选择。也许我们有一定的间距,我们不希望在字体大小变大时变得更大。(如果默认情况下是一个大块的负空间,也许允许它缩放到更大的尺寸是没有意义的。)


也许有一些边框大小我们不想改变,或者页面上有用 CSS 创建的装饰元素,在更大的字体大小下看起来效果不佳。也许我们不希望填充随着字体大小的增加而膨胀。在所有这些情况下, px 仍然是一个不错的选择。


我个人建议使用 rem 来设置所有的大小。我只在想要与当前字体大小成比例的东西(例如,与一些文本旁边的图标应该与字符的高度完全相同,并且在一侧有半个字符的情况)中添加 em 。我不会在任何地方使用 px ,除非是明确不想随字体大小缩放的设计元素。


永远不要用 px 单位中设置 font-size ,除非你非常确定你在做什么,它会如何行动,以及在你这样做时它是否仍然可访问。


关于媒体查询的重要说明


出于与上述所有原因相同的原因,重要的是要避免在 @media 查询中使用 px ;当用户缩放时,它将正常工作,但是使用 px 的媒体查询将在用户自己设置更大的字体大小时失败。


@media (min-width: 800px) {
/* Changing font size does NOT affect this breakpoint */
}

@media (min-width: 50rem) {
/* Changing font size DOES affect this breakpoint */
}

这是因为随着字体大小的增加, 50rem 会根据用户的偏好变成不同的值,而 800px 则不会。


很可能,当我们为较大的断点编写CSS时,我们认为有足够的屏幕空间让元素扩展。如果用户设置了非常大的字体大小,则可能不是这种情况,将媒体查询设置为 rem 而不是 px 可以帮助我们避免这种假设并响应用户的偏好。


我在这个网站上遇到了这个问题;我把所有的断点都设置在 px 上。然而,当我将默认字体大小设置得更大时,我的媒体查询没有响应,因为它们仍然只查看屏幕的像素宽度。因此,我仍然有一个微小的侧边栏,里面塞满了难以辨认的巨大文本,因为我没有考虑用户的偏好。在那之后,我立即改为 rem ,问题得到了解决。


简而言之:在媒体查询中,除非您确定自己知道在浏览器中设置自己的字体大小会对用户产生什么影响,否则一定要避免使用 px


原文:joshcollinsworth.com/blog/never-…


作者:王大冶
来源:juejin.cn/post/7218013539675750437
收起阅读 »

预测2024年之后的前端开发模式

web
大家好,我卡颂。 最近AIGC(AI Generated Content,利用AI生成内容)非常热,技术圈也受到了很大冲击。目前来看,利用LLM(Large Language Model,大语言模型)辅助开发还停留在非常早期的阶段,主要应用是辅助编码,即用自然...
继续阅读 »

大家好,我卡颂。


最近AIGC(AI Generated Content,利用AI生成内容)非常热,技术圈也受到了很大冲击。目前来看,利用LLM(Large Language Model,大语言模型)辅助开发还停留在非常早期的阶段,主要应用是辅助编码,即用自然语言输入需求,模型输出代码。更近一步的探索也仅仅是在此基础上的一层封装(比如copilot Xcursor)。


但即使在如此早期阶段,也对开发者的心智产生极大震撼,AI让程序员失业这样的论调甚嚣尘上。


LLM的爆发对前端意味着什么?本文尝试预测一波2024年之后的前端开发模式,这个预测遵循如下原则:




  • 尊重技术客观发展规律。以当前已有技术为基础预测,而不是将预测建立在某种虚无缥缈的高端技术,或者假想某些技术突破重大瓶颈




  • 尊重人性。程序员只是谋生的职业,新的开发模式即使再厉害,如果让程序员赚不到钱,那也是很难推广开的




欢迎加入人类高质量前端交流群,带飞


范式迁移的本质


为了预测未来,先看看我们是如何走到现在的。


在前端开发领域,我们经历了从jQuery为代表的面向过程编程向前端框架为代表的状态驱动模式的迁移。


当问到该选Vue还是React开发?,这样的问题会引起很大争议,但如果问到该选jQuery还是框架开发?,这样的问题就不会有太多争议。


为什么前端领域普遍接受了这种范式的迁移?在我看来,有两个原因:


1. 开发效率提高


这一点毋需多言,相信前端同学都有体会。


2. 门槛提高


面向过程编程是非常浅显易懂的开发模式。君不见,曾经的前端靠一本锋利的jQuery就能打天下。相比之下,状态驱动就有一定学习门槛。



当一项有一定门槛的技术(这里指前端框架)变为行业事实上的标准时,行业门槛就提升了,这为从业者构筑了行业壁垒。


事实上,正是由于:




  1. web应用复杂度提高




  2. 前端框架的流行




才让后端工程师工作职责中的view层,分化出前端工程师这一职业。


对于前端领域来说,只有同时平衡了提效提高门槛的技术,才会被市场(这里的消费者指前端工程师)接受。


举个反例,Angular全家桶的模式虽然提高了开发效率,但是同时,门槛提高太多了。


而且更糟的是,Angular中的很多概念都是从后端迁移而来,作为一款前端框架,对后端更亲和且门槛高,这对本身就是从后端view层中分化出的前端工程师来说,是比较排斥的。


再举个反例 —— Vue。有同学会说,Vue这么流行的前端框架,你说他是反例?


还是从提效提高门槛的角度看,Vue提效的同时,由于其模版语法、响应式更新等特性,他是降低了开发门槛的,这意味着使用Vue时:




  1. 同样是开发业务,老前端与新前端差距不大




  2. 必要时后端经过简单的学习,也能接手部分需求




重申一下,我并不是说Vue不好,相反,他是很优秀的前端框架。这里只是从人性的角度分析,并且这个分析很有可能是主观、带有偏见的。


再看个正面例子 —— React HooksHooks对开发效率、组件复用性以及他对React未来发展的影响这里不赘述了。主要聊聊提高门槛




  1. 一方面,什么时候封装自定义Hook,如何封装自定义Hook,如何规避Hook的坑,老前端与新前端有比较大的差异




  2. 更重要的是,后端改改JSX还行,要改基于Hooks的组件逻辑,是有一定难度的




既提效,又提高门槛,我认为这才是Hooks在前端领域火热的原因。



同样的原因,从人性的角度,我很看好Vue Composition API



所以,前端编程范式迁移的本质是:把握提高效率提高门槛之间的平衡。


这个结论会成为后面预测未来开发模式的依据。


当范式无法再迁移时


当前端框架成为事实上的标准后很长一段时间,业界也在不断探索新的开发范式。


有一种开发模式每过几年都会被搬出来炒一遍,他就是低代码。用我们上面的结论来分析下:在市场选择的情况下,先抛开低代码是否能提高效率不谈,显然他的目的是降低门槛


从人性的角度出发,他就很难在程序员群体中自发传播开。


那么,如果没有新的范式出现,会发生什么事情?会内卷。


我们会发现,这几年前端的发展轨迹,就是在重复一件事:




  1. 围绕前端框架周边,不断探索各细分领域的最佳实践




  2. 当探索出最佳实践后,就把他集成到框架中




举个例子,React Router作为React技术栈中路由这一细分领域的一个开源库,经过长期迭代,逐渐成为主流路由方案之一。


React Router团队基于React Router开发出Remix这一React框架。



这么做,在没有新的范式出现前,也能基于当前范式(前端框架),达到上述2个目的:




  • 提高效率:框架集成了最佳实践,开发效率更高




  • 提高门槛:除了学习React,还得学习新的上层框架




类似的,各种CSS解决方案(比如tailwind css)也是同样的道理:




  • 提高效率:提高CSS编写效率




  • 提高门槛:新的概念、语法需要学习




那么,未来围绕提高效率提高门槛的平衡,前端开发模式会如何发展呢?


从考虑范式到考虑流程


首先,我认为,在有限的未来,不会出现新的更先进的范式能让前端领域普遍认可并大规模迁移(就像从jQuery到前端框架的迁移)。


那么,为了提高效率,除了改变范式范式内 内卷两个选择外,还有个选择 —— 让整个开发流程提效。


从需求文档到最终代码,存在4级抽象:




  1. PM用自然语言编写的需求文档




  2. 需求评审时,PM给开发描述需求后,开发脑海里形成的业务逻辑




  3. 开发根据业务逻辑划分各个模块或组件




  4. 开发实现各个模块或组件的具体代码




当前我们使用LLM辅助编程时(比如以chatGPT为例),主要是用自然语言输入模块或组件业务逻辑,再让模型输出具体代码。也就是借助模型自动完成从3到4级抽象的转变。


比如说下图我们让chatGPT实现一个计时器:



这个计时器可能是我们需求中的某个模块,在此chatGPT帮我们完成了从抽象3(实现一个计时器组件)到抽象4(计时器组件的代码)。


如果仅仅到这一步,只能说这是个更高效的辅助工具,并不能达到整个开发流程提效的程度。为了达到这种程度,我们需要让LLM帮我们完成从抽象1到4的整个过程。


LLM如何完成4级抽象转换


接下来我们来看,基于当前已有的模型,如何完成抽象1到抽象4的自动转换。


首先,来看抽象1(PM用自然语言编写的需求文档)。chatGPT当前已经掌握基础的理解能力,所以他是能够理解需求文档的含义的。


下图是我从网上找的某需求文档中的登录功能流程图:



以当前主流的GPT-3.5举例,虽然GPT-3.5不能理解图片(不能理解需求文档中的流程图),但我们可以将流程图用文字描述出来(最新的GPT-4已经拥有理解图片含义的能力)。


上述登录功能流程图可以用文字概括为:



  1. 打开App后有3个选项,分别是“账号密码登录”、“快捷登录”、“第三方登录”

  2. 选择“第三方登录”,进入第三方,同意授权后登录成功

  3. 选择“快捷登录”,输入手机号和验证码并选择身份,点击登录后登录成功

  4. 选择“账号密码登录”,输入手机号,如果已注册,输入密码,点击登录后登录成功

  5. 选择“账号密码登录”,输入手机号,如果未注册,进入注册页,输入手机号,如果手机号已注册,回到“账号密码登录”

  6. 选择“账号密码登录”,输入手机号,如果未注册,进入注册页,输入手机号,如果手机号未注册,填写手机号、验证码、密码、姓名、选择身份,点击注册,完毕


抽象1到抽象2


如何完成从抽象1到抽象2(业务逻辑)的转变呢?换句话说,如何用一种介于自然语言与实际代码之间的规范描述业务逻辑?


这种规范应该拥有完备的数据结构(类似JSONXML),因为这样会带来很多好处:




  • 相比于自然语言,用规范的数据结构表示的业务逻辑能够传达更准确的意图




  • 业务需求的不断增多,仅仅对应数据结构体积的增大,即使再复杂的业务,只需要分批将业务逻辑代表的数据结构投喂给模型,模型就能完全理解我们的业务




  • 数据结构可以保存在变量中,通过变量名就能指代业务逻辑,无需再用自然语言大段的向模型描述业务逻辑




我们可以利用SCXML(State Chart XML)格式。SCXML是由W3C定义的一种表示状态机XML格式,他能够表示状态之间的变化。


前端应用的本质其实就包括两部分:




  • 状态的变化




  • 状态到视图的映射




其中状态到视图的映射框架已经帮我们做了。所以,只要能表示状态的变化,其实就能表示业务逻辑。


现在,我们让chatGPT将流程图翻译为SCXML格式:



得到如下结构(你不用细看,了解个大概就行):


<scxml version="1.0" xmlns="http://www.w3.org/2005/07/scxml">
<state id="mainMenu">
<transition event="THIRD_PARTY_LOGIN" target="thirdPartyLogin" />
<transition event="QUICK_LOGIN" target="quickLogin" />
<transition event="ACCOUNT_LOGIN" target="accountLogin" />
</state>

<state id="thirdPartyLogin">
<onentry>
<send event="AGREE_AUTHORIZATION" />
<send event="LOGIN_SUCCESS" />
</onentry>
</state>

<state id="quickLogin">
<onentry>
<send event="INPUT_PHONE_NUMBER" />
<send event="INPUT_VERIFICATION_CODE" />
<send event="SELECT_IDENTITY" />
<send event="LOGIN_SUCCESS" />
</onentry>
</state>

<state id="accountLogin">
<state id="inputPhoneNumber">
<transition event="PHONE_NUMBER_SUBMITTED" target="checkRegistered" />
</state>

<state id="checkRegistered">
<onentry>
<send event="CHECK_REGISTERED" />
</onentry>

<transition event="REGISTERED" target="inputPassword" />
<transition event="UNREGISTERED" target="register" />
</state>

<state id="inputPassword">
<onentry>
<send event="INPUT_PASSWORD" />
<send event="LOGIN_SUCCESS" />
</onentry>
</state>

<state id="register">
<state id="inputPhoneNumber">
<transition event="PHONE_NUMBER_SUBMITTED" target="checkRegistered" />
</state>

<state id="checkRegistered">
<onentry>
<send event="CHECK_REGISTERED" />
</onentry>

<transition event="REGISTERED" target="inputPassword" />
<transition event="UNREGISTERED" target="inputUserInfo" />
</state>

<state id="inputUserInfo">
<onentry>
<send event="INPUT_PHONE_NUMBER" />
<send event="INPUT_VERIFICATION_CODE" />
<send event="INPUT_PASSWORD" />
<send event="INPUT_NAME" />
<send event="SELECT_IDENTITY" />
<send event="REGISTER_SUCCESS" />
</onentry>
</state>
</state>
</state>
</scxml>

至此,我们完成了抽象1到抽象2的转变。


抽象2到抽象3


SCXML格式没法直接在JS代码中使用。为了用代码实现逻辑,我们需要使用遵循SCXML规范的库。xstateJS中比较流行的状态机开源库。



所以接下来我们让chatGPT将上述SCXML格式转换为xstate语法:



得到结果(同样,具体代码你不用在意,了解我想表达的转换意思就行):


import { Machine, sendParent } from 'xstate';

const loginMachine = Machine({
id: 'login',
initial: 'mainMenu',
states: {
mainMenu: {
on: {
THIRD_PARTY_LOGIN: 'thirdPartyLogin',
QUICK_LOGIN: 'quickLogin',
ACCOUNT_LOGIN: 'accountLogin'
}
},
thirdPartyLogin: {
onEntry: [
sendParent('AGREE_AUTHORIZATION'),
sendParent('LOGIN_SUCCESS')
]
},
quickLogin: {
onEntry: [
sendParent('INPUT_PHONE_NUMBER'),
sendParent('INPUT_VERIFICATION_CODE'),
sendParent('SELECT_IDENTITY'),
sendParent('LOGIN_SUCCESS')
]
},
accountLogin: {
initial: 'inputPhoneNumber',
states: {
inputPhoneNumber: {
on: {
PHONE_NUMBER_SUBMITTED: 'checkRegistered'
}
},
checkRegistered: {
onEntry: sendParent('CHECK_REGISTERED'),
on: {
REGISTERED: 'inputPassword',
UNREGISTERED: 'register.inputPhoneNumber'
}
},
inputPassword: {
onEntry: [
sendParent('INPUT_PASSWORD'),
sendParent('LOGIN_SUCCESS')
]
},
register: {
initial: 'inputPhoneNumber',
states: {
inputPhoneNumber: {
on: {
PHONE_NUMBER_SUBMITTED: 'checkRegistered'
}
},
checkRegistered: {
onEntry: sendParent('CHECK_REGISTERED'),
on: {
REGISTERED: 'inputPassword',
UNREGISTERED: 'inputUserInfo'
}
},
inputPassword: {
onEntry: [
sendParent('INPUT_PASSWORD'),
sendParent('REGISTER_SUCCESS')
]
},
inputUserInfo: {
onEntry: [
sendParent('INPUT_PHONE_NUMBER'),
sendParent('INPUT_VERIFICATION_CODE'),
sendParent('INPUT_PASSWORD'),
sendParent('INPUT_NAME'),
sendParent('SELECT_IDENTITY'),
sendParent('REGISTER_SUCCESS')
]
}
}
}
}
}
}
});

export default loginMachine;

这段代码我们可以直接粘贴到xstate的可视化编辑器中查看:



图中初始状态可以转移到3个状态(这些状态都是chatGPT生成的),其中:




  • QUICK_LOGIN —— 快捷登录




  • ACCOUNT_LOGIN —— 账号密码登录




  • THIRD_PARTY_LOGIN —— 第三方登录




每个状态接下来的变化逻辑都清晰可见。比如,当进入ACCOUNT_LOGIN状态后,后续会根据是否登录(UNREGISTEREDREGISTERED)进入不同逻辑:



也就是说,chatGPT理解了需求文档想表达的业务逻辑后,将业务逻辑转换成代码表示。



读者可将上述xstate代码复制到可视化编辑器中看到效果



抽象3到抽象4


接下来,我们只需要让chatGPT根据上述xstate状态机生成组件代码即可。


这时有同学会问:chatGPT对话有token限制,没法生成太多代码怎么办?


实际上,这可能并不是坏事。在我曾经供职的一家公司,前端团队有条不成文的规矩 —— 如果一个组件超过200行,那你就应该拆分他。


同样的,如果chatGPT生成的组件超过了token限制,那么应该让他拆分新的组件。


拆分组件的前提是 —— chatGPT需要懂业务逻辑。显然,他已经懂了xstate数据结构所代表的业务逻辑。


更妙的是,我们可以让chatGPTSCXML格式转换而来的xstate数据结构保存在一个变量中,在后续对话中,我们用一个变量名就能指代他背后所表示的业务逻辑(这里保存在变量m中)。



当我们要生成业务组件代码时,让chatGPT从模块中导出m实现组件逻辑:



对于实际场景下比较复杂的需求,经过从抽象1到抽象3的转换,我们会得到代表业务逻辑的不同变量,比如:




  • signin变量代表登录逻辑




  • login变量代表注册逻辑




  • PopupAD变量代表弹窗广告逻辑




如果弹窗广告的逻辑和是否登录相关,那么要实现弹窗广告组件代码只需要告诉chatGPT


根据signinPopupAD实现弹窗广告的react组件,其中signin变量由xxx模块导出,PopupAD变量由yyy导出。


如果你司使用其他框架,只需将其中react换成其他框架名即可。当大家还在争论哪个框架更优秀时,LLM已经悄悄帮开发者实现了框架自由


新开发模式的优势


让我们从提高效率提高门槛的角度分析这种新开发模式的优势。


提高效率


首先,这种新模式能显著提高开发效率。本质来说,他将前端工程师从实现需求的角色转变为review代码的角色。


极端的讲,当需求评审会结束的那一刻,第一版前端代码就生成了。


其次,他能解放部分测试同学的生产力(抢部分测试同学的活儿)。对于维护过屎山代码的同学,肯定遇到过这样的场景:明明只是改动一个小需求,测试问你改动影响的范围,你自己都不清楚会有多大影响,为了稳妥起见只能让测试覆盖更大的回归测试范围。


在使用基于状态机的开发模式后,任何改动会造成的影响在状态图中都清晰可见。同时,由于代码逻辑的实现基于状态机,可以据此自动生成端到端的测试用例,模型也能根据状态机描述的逻辑自己补足其他单测。


提高门槛


接下来,我们从提高门槛的角度分析。


首先,能够对模型生成的代码进行查漏补缺本身就要求开发者有一定前端开发水平。


其次,这种开发模式引入了新的抽象层 —— 状态机,这无疑会增加上手门槛。


但这都不是最重要的,最重要的是 —— 这套模式强迫前端开发需要更懂业务。


以前,拿到产品的需求文档后,你可以在做的过程中遇到不懂的再问产品。使用新的开发模式后,你必须很懂业务,做到在需求评审时就能指出需求文档中不合理的地方


因为当需求评审结束后,你会将这份需求文档投喂给模型直接生成业务代码(中间会经历生成SCXML生成xstate数据结构保存xstate变量、使用变量生成组件代码)。


当大家技术水平旗鼓相当时,懂业务才是前端的核心竞争力。


综上,这套开发模式在极大提高效率的同时提高了门槛,我认为在未来很有可能成为主流前端开发模式。


作者:魔术师卡颂
来源:juejin.cn/post/7216182763237818425
收起阅读 »

快速入门 GraphQL:一个接口实现所有 CRUD

web
作为前端开发,想必经常做的事情就是:调接口、画页面、调接口、画页面... 调用的接口大概率是 restful 的,也就是类似这种: /students 查询所有学生信息 /student/1 查询 id 为 1 的学生信息 上面说的是 get 请求。 如果对 ...
继续阅读 »

作为前端开发,想必经常做的事情就是:调接口、画页面、调接口、画页面...


调用的接口大概率是 restful 的,也就是类似这种:


/students 查询所有学生信息


/student/1 查询 id 为 1 的学生信息


上面说的是 get 请求。


如果对 /student/1 发送 POST、PUT、DELETE 请求,就分别代表了新增、修改、删除。


这就是 restful 风格的 web 接口。


这种接口返回什么信息是服务端那边决定的,客户端只是传一下参数。


而不同场景下需要的数据不同,这时候可能就得新开发一个接口。特别是在版本更新的时候,接口会有所变动。


这样就很容易导致一大堆类似的接口。


facebook 当时也遇到了这个问题,于是他们创造了一种新的接口实现方案:GraphQL。


用了 GraphQL 之后,返回什么数据不再是服务端说了算,而是客户端自己决定。


服务端只需要提供一个接口,客户端通过这个接口就可以取任意格式的数据,实现 CRUD。


比如想查询所有的学生,就可以这样:



想再查询他们的年龄,就可以这样:



想查询老师的名字和他教的学生,就可以这样:



而这些都是在一个 http 接口里完成的!


感受了 GraphQL 的好处了没?


一个 http 接口就能实现所有的 CRUD!


那这么强大的 GraphQL 是怎么实现的呢?


我们先写个 demo 快速入门一下:


facebook 提供了 graphql 的 npm 包,但那个封装的不够好,一般我们会用基于 graphql 包的 @apollo/server 和 @apollo/client 的包来实现 graphql。


首先引入这个包:


import { ApolloServer } from '@apollo/server';

然后写一段这样的代码:


import { ApolloServer } from '@apollo/server';

const typeDefs = `
type Student {
id: String,
name: String,
sex: Boolean
age: Int
}

type Teacher {
id: String,
name: String,
age: Int,
subject: [String],
students: [Student]
}

type Query {
students: [Student],
teachers: [Teacher],
}

schema {
query: Query
}
`
;

比较容易看懂,定义了一个 Student 的对象类型,有 id、name、sex、age 这几个字段。


又定义了一个 Teacher 的对象类型,有 id、name、age、subject、students 这几个字段。students 字段是他教的学生的信息。


然后定义了查询的入口,可以查 students 和 teachers 的信息。


这样就是一个 schema。


对象类型和对象类型之间有关联关系,老师关联了学生、学生也可以关联老师,关联来关联去这不就是一个图么,也就是 graph。


GraphQL 全称是 graph query language,就是从这个对象的 graph 中查询数据的。


现在我们声明的只是对象类型的关系,还要知道这些类型的具体数据,取数据的这部分叫做 resolver。


const students = [
{
id: '1',
name: async () => {
await '取数据';
return '光光'
},
sex: true,
age: 12
},
{
id: '2',
name:'东东',
sex: true,
age: 13
},
{
id: '3',
name:'小红',
sex: false,
age: 11
},
];

const teachers = [
{
id: '1',
name: '神光',
sex: true,
subject: ['体育', '数学'],
age: 28,
students: students
}
]

const resolvers = {
Query: {
students: () => students,
teachers: () => teachers
}
};

resolver 是取对象类型对应的数据的,每个字段都可以写一个 async 函数,里面执行 sql、访问接口等都可以,最终返回取到的数据。


当然,直接写具体的数据也是可以的。


这里我就 student 里那个 name 用 async 函数的方式写了一下。


这样有了 schema 类型定义,有了取数据的 resovler,就可以跑起 graphql 服务了。


也就是这样:


import { startStandaloneServer } from '@apollo/server/standalone' 

const server = new ApolloServer({
typeDefs,
resolvers,
});

const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});

console.log(`🚀 Server ready at: ${url}`);

传入 schema 类型定义和取数据的 resolver,就可以用 node 把服务跑起来。



有同学可能问了,node 可以直接解析 esm 模块么?


可以的。只需要在 package.json 中声明 type 为 module:



那所有的 .js 就都会作为 esm 模块解析:



跑起来之后,浏览器访问一下:


就可以看到这样的 sandbox,这里可以执行 graphql 的查询:



(graphql 接口是监听 POST 请求的,用 get 请求这个 url 才会跑这个调试的工具)


我查询所有学生的 id、name、age 就可以这样:




这里 “光光” 那个学生是异步取的数据,resolver 会执行对应的异步函数,拿到最终数据:



取老师的信息就可以这样:



这样我们就实现了一个 graphql 接口!


感觉到什么叫客户端决定取什么数据了么?


当然,我们这里是在 sandbox 里测的,用 @apollo/client 包也很简单。


比如 react 的 graphql 客户端是这样的:



一个 gql 的 api 来写查询语言,一个 useQuery 的 api 来执行查询。


学起来很简单。


我们之后还是直接在 sandbox 里测试。


有的同学可能会说,如果我想查询某个名字的老师的信息呢?


怎么传参数?


graphql 当然是支持的,这样写:


type Query {
students: [Student],
teachers: [Teacher],
studentsbyTeacherName(name: String!): [Student]
}

新加一个 query 入口,声明一个 name 的参数。(这里 String 后的 ! 代表不能为空)


然后它对应的 resolver 就是这样的:


const resolvers = {
Query: {
students: () => students,
teachers: () => teachers,
studentsbyTeacherName: async (...args) => {
console.log(args);

await '执行了一个异步查询'
return students
}
}
};

studentsbyTeacherName 字段的 resolver 是一个异步函数,里面执行了查询,然后返回了查到的学生信息。


我们打印下参数看看传过来的是什么。


有参数的查询是这样的:



传入老师的 name 参数为 111,返回查到的学生的 id、name 信息。


可以看到返回的就是查询到的结果。


而服务端的 resolver 接收到的参数是这样的:



其余的几个参数不用管,只要知道第二个参数就是客户端传过来的查询参数就好了。


这样我们就可以根据这个 name 参数实现异步的查询,然后返回数据。


这就实现了有参数的查询。


不是说 graphql 能取代 restful 做 CRUD 么?那增删改怎么做呢?


其实看到上面的有参数的查询应该就能想到了,其实写起来差不多。


在 schema 里添加这样一段类型定义:


type Res {
success: Boolean
id: String
}

type Mutation {
addStudent(name:String! age:Int! sex:Boolean!): Res

updateStudent(id: String! name:String! age:Int! sex:Boolean!): Res

deleteStudent(id: String!): Res
}

schema {
mutation: Mutation
query: Query
}

和有参数的查询差不多,只不过这部分增删改的类型要定义在 mutation 部分。


然后 resolver 也要有对应的实现:


async function addStudent (_, { name, age, sex }) {
students.push({
id: '一个随机 id',
name,
age,
sex
});
return {
success: true,
id: 'xxx'
}
}

async function updateStudent (_, { id, name, age, sex }) {

return {
success: true,
id: 'xxx'
}
}

async function deleteStudent (_, { id }) {
return {
success: true,
id: 'xxx'
}
}

const resolvers = {
Query: {
students: () => students,
teachers: () => teachers,
studentsbyTeacherName: async (...args) => {
console.log(args);

await '执行了一个异步查询'
return students
}
},
Mutation: {
addStudent: addStudent,
updateStudent: updateStudent,
deleteStudent: deleteStudent
}
};


和 query 部分差不多,只不过这里实现的是增删改。


我只对 addStudent 做了实现。


我们测试下:


执行 addStudent,添加一个学生:



然后再次查询所有的学生:



就可以查到刚来的小刚同学。


这样,我们就可以在一个 graphql 的 POST 接口里完成所有的 CRUD!


全部代码如下,感兴趣可以跑一跑(注意要在 package.json 里加个 type: "module")


import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone'

const typeDefs = `
type Student {
id: String,
name: String,
sex: Boolean
age: Int
}

type Teacher {
id: String,
name: String,
age: Int,
subject: [String],
students: [Student]
}

type Query {
students: [Student],
teachers: [Teacher],
studentsbyTeacherName(name: String!): [Student]
}

type Res {
success: Boolean
id: String
}

type Mutation {
addStudent(name:String! age:Int! sex:Boolean!): Res

updateStudent(id: String! name:String! age:Int! sex:Boolean!): Res

deleteStudent(id: String!): Res
}

schema {
mutation: Mutation
query: Query
}
`;

const students = [
{
id: '1',
name: async () => {
await '取数据';
return '光光'
},
sex: true,
age: 12
},
{
id: '2',
name:'东东',
sex: true,
age: 13
},
{
id: '3',
name:'小红',
sex: false,
age: 11
},
];

const teachers = [
{
id: '1',
name: '神光',
sex: true,
subject: ['体育', '数学'],
age: 28,
students: students
}
]

async function addStudent (_, { name, age, sex }) {
students.push({
id: '一个随机 id',
name,
age,
sex
});
return {
success: true,
id: 'xxx'
}
}

async function updateStudent (_, { id, name, age, sex }) {

return {
success: true,
id: 'xxx'
}
}

async function deleteStudent (_, { id }) {
return {
success: true,
id: 'xxx'
}
}

const resolvers = {
Query: {
students: () => students,
teachers: () => teachers,
studentsbyTeacherName: async (...args) => {
console.log(args);

await '执行了一个异步查询'
return students
}
},
Mutation: {
addStudent: addStudent,
updateStudent: updateStudent,
deleteStudent: deleteStudent
}
};

const server = new ApolloServer({
typeDefs,
resolvers,
});

const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});

console.log(`🚀 Server ready at: ${url}`);

完成了 graphql 的入门,我们再稍微思考下它的原理。graphql 是怎么实现的呢?


回顾整个流程,我们发现涉及到两种 DSL(领域特定语言),一个是 schema 定义的 DSL,一个是查询的 DSL。


服务端通过 schema 定义的 DSL 来声明 graph 图,通过 resolver 来接受参数,执行查询和增删改。


客户端通过查询的 DSL 来定义如何查询和如何增删改,再发给服务端来解析执行。


通过这种 DSL 实现了动态的查询。


确实很方便很灵活,但也有缺点,就是 parse DSL 为 AST 性能肯定是不如 restful 那种直接执行增删改查高的。


具体要不要用 graphql 还是要根据具体场景来做判断。


总结


restful 接口是 url 代表资源,GET、POST、PUT、DELETE 请求代表对资源的增删改查。


这种接口返回什么数据完全由服务端决定,每次接口变动可能就得新加一种接口。


为了解决这种问题,facebook 创造了 graphql,这种接口返回什么数据完全由客户端决定。增删改查通过这一个接口就可以搞定。


graphql 需要在服务端定义 schema,也就是定义对象类型和它的字段,对象类型和对象类型之间会有关联,也就是一个 graph,查询就是从这个 graph 里查询数据。


除了 schema 外,还需要有 resolver,它负责接受客户端的参数,完成具体数据的增删改查。


graphql 会暴露一个 post 接口,通过查询语言的语法就可以从通过这个接口完成所有增删改查。


本地测试的时候,get 请求会跑一个 sandbox,可以在这里测试接口。


整个流程涉及到两种新语言: schema 定义语言和 query 查询语言。入门之后向深入的话就是要学下这两种 DSL 的更多语法。


感受到 graphql 的强大之处了么?一个接口就可以实现所有的 CRUD!


作者:zxg_神说要有光
来源:juejin.cn/post/7218396786187042853
收起阅读 »

打造你自己的 JavaScript 运行时

web
原文:deno.com/blog/roll-y… 译者:李瑞丰 在这篇文章中,我们将介绍如何创建自定义 JavaScript 运行时。我们称之为 runjs。想象一下,我们正在构建一个(更)简化的 deno 版本。这篇文章的目标是创建一个 CLI,可以执行本...
继续阅读 »

原文:deno.com/blog/roll-y…
译者:李瑞丰



在这篇文章中,我们将介绍如何创建自定义 JavaScript 运行时。我们称之为 runjs。想象一下,我们正在构建一个(更)简化的 deno 版本。这篇文章的目标是创建一个 CLI,可以执行本地 JavaScript 文件,读取文件,写入文件,删除文件,并具有简化的 console API。


让我们开始吧。


前提


这篇教程假设读者具有以下知识:



  • Rust 基础知识

  • JavaScript 事件循环基础知识


确保你的机器上安装了 Rust(以及 cargo),并且它至少是 1.62.0 版本。访问 rust-lang.org 安装 Rust 编译器和 cargo


确保我们已经准备好了:


$ cargo --version
cargo 1.62.0 (a748cf5a3 2022-06-08)

Hello, Rust!


首先,让我们创建一个新的 Rust 项目,它将是一个名为 runjs 的二进制 crate:


$ cargo init --bin runjs
Created binary (application) package

让我们进入 runjs 目录并在编辑器中打开它。确保一切都设置正确:


$ cd runjs
$ cargo run
Compiling runjs v0.1.0 (/Users/ib/dev/runjs)
Finished dev [unoptimized + debuginfo] target(s) in 1.76s
Running `target/debug/runjs`
Hello, world!

很好!现在让我们开始创建我们自己的 JavaScript 运行时。


依赖


接下来,让我们将 deno_coretokio 依赖项添加到我们的项目中:


$ cargo add deno_core
Updating crates.io index
Adding deno_core v0.142.0 to dependencies.
$ cargo add tokio --features=full
Updating crates.io index
Adding tokio v1.19.2 to dependencies.

我们更新后的 Cargo.toml 文件应该如下所示:


[package]
name = "runjs"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
deno_core = "0.142.0"
tokio = { version = "1.19.2", features = ["full"] }

deno_core 是 Deno 团队的一个 crate,它抽象了与 V8 JavaScript 引擎的交互。V8 是一个复杂的项目,有成千上万的 API,因此为了简化使用它们,deno_core 提供了一个 JsRuntime 结构体,它封装了一个 V8 引擎实例(称为 Isolate),并允许与事件循环集成。


tokio 是一个异步的 Rust 运行时,我们将使用它作为事件循环。Tokio 负责与操作系统抽象(如网络套接字或文件系统)进行交互。deno_coretokio 一起,允许 JavaScript 的 Promise 映射到 Rust 的 Future


拥有 JavaScript 引擎和事件循环,使我们能够创建 JavaScript 运行时。


Hello, runjs!


让我们从编写一个异步的 Rust 函数开始,该函数将创建一个 JsRuntime 实例,该实例负责 JavaScript 执行。


// main.rs
use std::rc::Rc;
use deno_core::error::AnyError;

async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module = deno_core::resolve_path(file_path)?;
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});

let mod_id = js_runtime.load_main_module(&main_module, None).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(false).await?;
result.await?
}

fn main() {
println!("Hello, world!");
}

这里有很多东西要解释。异步的 run_js 函数创建了一个新的 JsRuntime 实例,该实例使用基于文件系统的模块加载器。之后,我们将模块加载到 js_runtime 运行时中,对其进行评估,并运行一个事件循环直到完成。


这个 run_js 函数封装了我们的 JavaScript 代码将要经历的整个生命周期。但是在我们能够这样做之前,我们需要创建一个单线程的 tokio 运行时,以便能够执行我们的 run_js 函数:


// main.rs
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
if let Err(error) = runtime.block_on(run_js("./example.js")) {
eprintln!("error: {}", error);
}
}

让我们尝试执行一些 JavaScript 代码!创建一个 example.js 文件,它将打印 "Hello runjs!":


// example.js
Deno.core.print("Hello runjs!");

注意,我们使用的是 Deno.core 中的 print 函数 - 这是一个全局可用的内置对象,由 deno_core Rust crate 提供。


现在运行它:


cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/runjs`
Hello runjs!⏎

成功!在仅 25 行 Rust 代码中,我们创建了一个简单的 JavaScript 运行时,可以执行本地文件。当然,此时此运行时不能做太多事情(例如,console.log 还不能工作 - 尝试一下!),但是我们已经将 V8 JavaScript 引擎和 tokio 集成到我们的 Rust 项目中。


添加 console API


让我们开始处理 console API。首先,创建 src/runtime.js 文件,该文件将实例化并使 console 对象全局可用:


// runtime.js
((globalThis) => {
const core = Deno.core;

function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}

globalThis.console = {
log: (...args) => {
core.print(`[out]: ${argsToMessage(...args)}\n`, false);
},
error: (...args) => {
core.print(`[err]: ${argsToMessage(...args)}\n`, true);
},
};
})(globalThis);

函数 console.logconsole.error 将接受多个参数,将它们转换为 JSON(以便我们可以检查非原始 JS 对象)并在每个消息前加上 logerror 前缀。这是一个“普通的” JavaScript 文件,就像我们在 ES 模块之前在浏览器中编写 JavaScript 一样。


为了确保我们不会污染全局作用域,我们在 IIFE 中执行此代码。如果我们没有这样做,那么 argsToMessage 辅助函数将在我们的运行时中全局可用。


现在,让我们将此代码包含在我们的二进制文件中,并在每次运行时执行:


let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
..Default::default()
});
+ js_runtime.execute_script("[runjs:runtime.js]", include_str!("./runtime.js")).unwrap();

最后,让我们使用我们的新 console API 更新 example.js


- Deno.core.print("Hello runjs!");
+ console.log("Hello", "runjs!");
+ console.error("Boom!");

再次运行它:


cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[err]: "Boom!"

它起作用了!现在让我们添加一个 API,它将允许我们与文件系统进行交互。


添加一个基本的文件系统 API


让我们从更新我们的 runtime.js 文件开始:


};

+ core.initializeAsyncOps();
+ globalThis.runjs = {
+ readFile: (path) => {
+ return core.ops.op_read_file(path);
+ },
+ writeFile: (path, contents) => {
+ return core.ops.op_write_file(path, contents);
+ },
+ removeFile: (path) => {
+ return core.ops.op_remove_file(path);
+ },
+ };
})(globalThis);

我们刚刚添加了一个新的全局对象,称为 runjs,它有三个方法:readFilewriteFileremoveFile。前两个方法是异步的,而第三个是同步的。


你可能想知道这些 core.ops.[op name] 调用是什么 - 它们是 deno_core crate 中用于绑定 JavaScript 和 Rust 函数的机制。当你调用其中任何一个时,deno_core 将查找具有 #[op] 属性和匹配名称的 Rust 函数。


让我们通过更新 main.rs 来看看它的作用:


+ use deno_core::op;
+ use deno_core::Extension;
use deno_core::error::AnyError;
use std::rc::Rc;

+ #[op]
+ async fn op_read_file(path: String) -> Result<String, AnyError> {
+ let contents = tokio::fs::read_to_string(path).await?;
+ Ok(contents)
+ }
+
+ #[op]
+ async fn op_write_file(path: String, contents: String) -> Result<(), AnyError> {
+ tokio::fs::write(path, contents).await?;
+ Ok(())
+ }
+
+ #[op]
+ fn op_remove_file(path: String) -> Result<(), AnyError> {
+ std::fs::remove_file(path)?;
+ Ok(())
+ }

我们刚刚添加了三个可以从 JavaScript 调用的 ops。但是,在这些 ops 可用于我们的 JavaScript 代码之前,我们需要通过注册“扩展”来告诉 deno_core


async fn run_js(file_path: &str) -> Result<(), AnyError> {
let main_module = deno_core::resolve_path(file_path)?;
+ let runjs_extension = Extension::builder("runjs")
+ .ops(vec![
+ op_read_file::decl(),
+ op_write_file::decl(),
+ op_remove_file::decl(),
+ ])
+ .build();
let mut js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
+ extensions: vec![runjs_extension],
..Default::default()
});

Extensions 允许你配置你的 JsRuntime 实例,并将不同的 Rust 函数暴露给 JavaScript,以及执行更高级的操作,如加载其他 JavaScript 代码。


让我们再次更新我们的 example.js


console.log("Hello", "runjs!");
console.error("Boom!");
+
+ const path = "./log.txt";
+ try {
+ const contents = await runjs.readFile(path);
+ console.log("Read from a file", contents);
+ } catch (err) {
+ console.error("Unable to read file", path, err);
+ }
+
+ await runjs.writeFile(path, "I can write to a file.");
+ const contents = await runjs.readFile(path);
+ console.log("Read from a file", path, "contents:", contents);
+ console.log("Removing file", path);
+ runjs.removeFile(path);
+ console.log("File removed");
+

再次运行它:



$ cargo run
Compiling runjs v0.1.0 (/Users/ib/dev/runjs)
Finished dev [unoptimized + debuginfo] target(s) in 0.97s
Running `target/debug/runjs`
[out]: "Hello" "runjs!"
[err]: "Boom!"
[err]: "Unable to read file" "./log.txt" {"code":"ENOENT"}
[out]: "Read from a file" "./log.txt" "contents:" "I can write to a file."
[out]: "Removing file" "./log.txt"
[out]: "File removed"


恭喜,我们的 runjs 运行时现在可以与文件系统一起工作!注意,从 JavaScript 调用 Rust 代码所需的代码量非常少 - deno_core 负责在 JavaScript 和 Rust 之间传递数据,因此我们不需要自己进行任何转换。


总结


在这个简短的例子中,我们开始了一个集成了强大的 JavaScript 引擎(V8)和高效的事件循环实现(tokio)的 Rust 项目。


本文由 李瑞丰 翻译,原文地址:deno.com/blog/roll-y…


此教程的第二部分已经发布,实现了 fetch-like API 并添加了 TypeScript 转译功能。


完整的示例代码可以在 denoland 的 GitHub。也可以在译者的仓库查看第一部分代码



作者:李瑞丰_liruifengv
来源:juejin.cn/post/7218466428766453817


收起阅读 »

看了antfu大佬的v-lazy-show,我学会了怎么编译模板指令

web
前言 一开始关注到 antfu 是他的一头长发,毕竟留长发的肯定是技术大佬。果不其然,antfu 是个很高产、很 creative 的大佬,我也很喜欢他写的工具,无论是@antfu/eslint-config、unocss、还是vitest等等。 而这篇文章故...
继续阅读 »

前言


一开始关注到 antfu 是他的一头长发,毕竟留长发的肯定是技术大佬。果不其然,antfu 是个很高产、很 creative 的大佬,我也很喜欢他写的工具,无论是@antfu/eslint-configunocss、还是vitest等等。


而这篇文章故事的起源是,我今天中午逛 github 的时候发现大佬又又又又开了一个新的 repo(这是家常便饭的事),v-lazy-show


image.png


看了下是两天前的,所以好奇点进去看看是什么东东。


介绍是:A compile-time directive to lazy initialize v-show for Vue. It makes components mount after first truthy value (v-if), and the DOM keep alive when toggling (v-show).


简单的说,v-lazy-show 是一个编译时指令,就是对 v-show 的一种优化,因为我们知道,v-show 的原理只是基于简单的切换 display none,false则为none,true则移除


bite-me-i-dare-you.gif


但即使在第一次条件为 falsy 的时候,其依然会渲染对应的组件,那如果该组件很大,就会带来额外的渲染开销,比如我们有个 Tabs,默认初始显示第一个 tab,但后面的 tab 也都渲染了,只是没有显示罢了(实际上没有必要,因为可能你点都不会点开)。


那基于此种情况下,我们可以优化一下,即第一次条件为 falsy 的情况下,不渲染对应的组件,直到条件为 truthy 才渲染该组件。


将原本的 v-show 改为 v-lazy-show 或者 v-show.lazy


<script setup lang="ts">
import { ref } from 'vue'
import ExpansiveComponent from './ExpansiveComponent.vue'

const enabled = ref(false)
</script>

<template>
<button @click="enabled = !enabled">
Toggle
</button>

<div class="hello-word-wrapper">
<ExpansiveComponent v-lazy-show="enabled" msg="v-lazy-show" />
<ExpansiveComponent v-show.lazy="enabled" msg="v-lazy.show" />

<ExpansiveComponent v-show="enabled" msg="v-show" />

<ExpansiveComponent v-if="enabled" msg="v-if" />
</div>
</template>

<!-- ExpansiveComponent.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'

const props = defineProps({
msg: {
type: String,
required: true,
},
})

onMounted(() => {
console.log(`${props.msg} mounted`)
})
</script>

<template>
<div>
<div v-for="i in 1000" :key="i">
Hello {{ msg }}
</div>
</div>
</template>

2023-04-03 15.55.15.gif



ExpansiveComponent 渲染了 1000 行 div,在条件 enabled 初始为 false 的情况下,对应 v-show 来说,其依然会渲染,而对于 v-lazy-show 或 v-show.lazy 来说,只有第一次 enabled 为 true 才渲染,避免了不必要的初始渲染开销



如何使用?


国际惯例,先装下依赖,这里强烈推荐 antfu 大佬的 ni


npm install v-lazy-show -D
yarn add v-lazy-show -D
pnpm add v-lazy-show -D
ni v-lazy-show -D

既然是个编译时指令,且是处理 vue template 的,那么就应该在对应的构建工具中配置,如下:


如果你用的是 vite,那么配置如下


// vite.config.ts
import { defineConfig } from 'vite'
import { transformLazyShow } from 'v-lazy-show'

export default defineConfig({
plugins: [
Vue({
template: {
compilerOptions: {
nodeTransforms: [
transformLazyShow, // <--- 加在这里
],
},
},
}),
]
})

如果你用的是 Nuxt,那么应该这样配置:


// nuxt.config.ts
import { transformLazyShow } from 'v-lazy-show'

export default defineNuxtConfig({
vue: {
compilerOptions: {
nodeTransforms: [
transformLazyShow, // <--- 加上这行
],
},
},
})

那么,该指令是如何起作用的?


上面的指令作用很好理解,那么其是如何实现的呢?我们看下大佬是怎么做的。具体可见源码


源码不多,我这里直接贴出来,再一步步看如何实现(这里快速过一下即可,后面会一步步分析):


import {
CREATE_COMMENT,
FRAGMENT,
createCallExpression,
createCompoundExpression,
createConditionalExpression,
createSequenceExpression,
createSimpleExpression,
createStructuralDirectiveTransform,
createVNodeCall,
traverseNode,
} from '@vue/compiler-core'

const indexMap = new WeakMap()

// https://github.com/vuejs/core/blob/f5971468e53683d8a54d9cd11f73d0b95c0e0fb7/packages/compiler-core/src/ast.ts#L28
const NodeTypes = {
SIMPLE_EXPRESSION: 4,
}

// https://github.com/vuejs/core/blob/f5971468e53683d8a54d9cd11f73d0b95c0e0fb7/packages/compiler-core/src/ast.ts#L62
const ElementTypes = {
TEMPLATE: 3,
}

// https://github.com/vuejs/core/blob/f5971468e53683d8a54d9cd11f73d0b95c0e0fb7/packages/shared/src/patchFlags.ts#L19
const PatchFlags = {
STABLE_FRAGMENT: 64,
}

export const transformLazyShow = createStructuralDirectiveTransform(
/^(lazy-show|show)$/,
(node, dir, context) => {
// forward normal `v-show` as-is
if (dir.name === 'show' && !dir.modifiers.includes('lazy')) {
return () => {
node.props.push(dir)
}
}

const directiveName = dir.name === 'show'
? 'v-show.lazy'
: 'v-lazy-show'

if (node.tagType === ElementTypes.TEMPLATE || node.tag === 'template')
throw new Error(`${directiveName} can not be used on <template>`)

if (context.ssr || context.inSSR) {
// rename `v-lazy-show` to `v-if` in SSR, and let Vue handles it
node.props.push({
...dir,
exp: dir.exp
? createSimpleExpression(dir.exp.loc.source)
: undefined,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'if',
})
return
}

const { helper } = context
const keyIndex = (indexMap.get(context.root) || 0) + 1
indexMap.set(context.root, keyIndex)

const key = `_lazyshow${keyIndex}`

const body = createVNodeCall(
context,
helper(FRAGMENT),
undefined,
[node],
PatchFlags.STABLE_FRAGMENT.toString(),
undefined,
undefined,
true,
false,
false /* isComponent */,
node.loc,
)

const wrapNode = createConditionalExpression(
createCompoundExpression([`_cache.${key}`, ' || ', dir.exp!]),
createSequenceExpression([
createCompoundExpression([`_cache.${key} = true`]),
body,
]),
createCallExpression(helper(CREATE_COMMENT), [
'"v-show-if"',
'true',
]),
) as any

context.replaceNode(wrapNode)

return () => {
if (!node.codegenNode)
traverseNode(node, context)

// rename `v-lazy-show` to `v-show` and let Vue handles it
node.props.push({
...dir,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'show',
})
}
},
)

createStructuralDirectiveTransform


因为是处理运行时的指令,那么自然用到了 createStructuralDirectiveTransform 这个函数,我们先简单看下其作用:


createStructuralDirectiveTransform 是一个工厂函数,用于创建一个自定义的 transform 函数,用于在编译过程中处理特定的结构性指令(例如 v-for, v-if, v-else-if, v-else 等)。


该函数有两个参数:




  • nameMatcher:一个正则表达式或字符串,用于匹配需要被处理的指令名称。




  • fn:一个函数,用于处理结构性指令。该函数有三个参数:



    • node:当前节点对象。

    • dir:当前节点上的指令对象。

    • context:编译上下文对象,包含编译期间的各种配置和数据。




createStructuralDirectiveTransform 函数会返回一个函数,该函数接收一个节点对象和编译上下文对象,用于根据指定的 nameMatcher 匹配到对应的指令后,调用用户自定义的 fn 函数进行处理。


在编译过程中,当遇到符合 nameMatcher 的结构性指令时,就会调用返回的处理函数进行处理,例如在本例中,当遇到 v-show 或 v-lazy-show 时,就会调用 transformLazyShow 处理函数进行处理。


不处理 v-show


if (dir.name === 'show' && !dir.modifiers.includes('lazy')) {
return () => {
node.props.push(dir)
}
}

因为 v-show.lazy 是可以生效的,所以 v-show 会进入该方法,但如果仅仅只是 v-show,而没有 lazy 修饰符,那么实际上不用处理


这里有个细节,为何要将指令对象 push 进 props,不 push 行不行?


原先的表现是 v-show 条件为 false 时 display 为 none,渲染了节点,只是不显示:


image.png


而注释node.props.push(dir)后,看看页面表现咋样:


image.png


v-show 的功能没了,也就是说指令的功能会添加到 props 上,所以这里要特别注意,不是单纯的返回 node 即可。后来还有几处node.props.push,原理跟这里一样。


服务端渲染目前是转为 v-if


if (context.ssr || context.inSSR) {
// rename `v-lazy-show` to `v-if` in SSR, and let Vue handles it
node.props.push({
...dir,
exp: dir.exp
? createSimpleExpression(dir.exp.loc.source)
: undefined,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'if',
})
return
}

将 v-lazy-show 改名为 v-if,且过滤掉修饰符


createVNodeCall 给原先节点包一层 template


顾名思义,createVNodeCall 是 用来创建一个 vnode 节点的函数:


const body = createVNodeCall(
/** 当前的上下文 (context) 对象,即 CodegenContext */
context,
/** helper 函数是 Vue 内部使用的帮助函数。FRAGMENT 表示创建 Fragment 节点的 helper 函数 */
helper(FRAGMENT),
/** 组件的 props */
undefined,
/** 当前节点的子节点数组,即包含有指令的节点本身 */
[node],
/** 表示该节点的 PatchFlag,指明了该节点是否稳定、是否具有一些特定的更新行为等。STABLE_FRAGMENT 表示该 Fragment 节点是一个稳定的节点,即其子节点不会发生改变 */
PatchFlags.STABLE_FRAGMENT.toString(),
/** 该节点的动态 keys */
undefined,
/** 该节点的模板引用 (ref) */
undefined,
/** 表示该节点是否需要开启 Block (块) 模式,即是否需要对其子节点进行优化 */
true,
/** 表示该节点是否是一个 Portal 节点 */
false,
/** 表示该节点是否是一个组件 */
false /* isComponent */,
/** 该节点在模板中的位置信息 */
node.loc,
)

参数含义如下,简单了解即可(反正看了就忘)


也就是说,其会生成如下模板:


<template>
<ExpansiveComponent v-lazy-show="enabled" msg="v-lazy-show" />
</template>

关键代码(重点)


接下来这部分是主要原理,请打起十二分精神。


先在全局维护一个 map,代码中叫 indexMap,是一个 WeakMap(不知道 WeakMap 的可以去了解下)。然后为每一个带有 v-lazy-show 指令的生成一个唯一 key,这里叫做_lazyshow${keyIndex},也就是第一个就是_lazyshow1,第二个是_lazyshow2...


  const keyIndex = (indexMap.get(context.root) || 0) + 1
indexMap.set(context.root, keyIndex)

const key = `_lazyshow${keyIndex}`

然后将生成的key放到渲染函数的_cache上(渲染函数的第二个参数,function render(_ctx, _cache)),即通过_cache.${key}作为辅助变量。之后会根据 createConditionalExpression 创建一个条件表达式


const wrapNode = createConditionalExpression(
createCompoundExpression([`_cache.${key}`, ' || ', dir.exp!]),
createSequenceExpression([
createCompoundExpression([`_cache.${key} = true`]),
body,
]),
// 生成一个注释节点 `<!--v-show-if-->`
createCallExpression(helper(CREATE_COMMENT), [
'"v-show-if"',
'true',
]),
)

也就是说, v-lazy-show 初始传入的条件为 false 时,那么会为你创建一个注释节点,用来占位:


createCallExpression(helper(CREATE_COMMENT), [
'"v-show-if"',
'true',
])

image.png



这个跟 v-if 一样



直到第一次条件为真时,将 _cache.${key} 置为 true,那么以后的行为就跟 v-show 一致了,上面的 dir.exp 即指令中的条件,如


<div v-show="enabled"/>

enabled 即 exp,表达式的意思。


readme给出的转换如下:


<template>
<div v-lazy-show="foo">
Hello
</div>
</template>

会转换为:


import { Fragment as _Fragment, createCommentVNode as _createCommentVNode, createElementBlock as _createElementBlock, createElementVNode as _createElementVNode, openBlock as _openBlock, vShow as _vShow, withDirectives as _withDirectives } from 'vue'

export function render(_ctx, _cache) {
return (_cache._lazyshow1 || _ctx.foo)
? (_cache._lazyshow1 = true, (_openBlock(),
_withDirectives(_createElementVNode('div', null, ' Hello ', 512 /* NEED_PATCH */), [
[_vShow, _ctx.foo]
])))
: _createCommentVNode('v-show-if', true)
}

你可以简单理解为会将<ExpansiveComponent msg="v-lazy-show" v-lazy-show=""enabled"/>转为下面:


<template v-if="_cache._lazyshow1 || enabled">
<!-- 为true时会把_cache._lazyshow1置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-lazy-show" v-lazy-show="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>

<template v-if="_cache._lazyshow2 || enabled">
<!-- 为true时会把_cache._lazyshow2置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-lazy-show" v-show.lazy="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>

然后将原先节点替换为处理后的 wrapperNode 即可


context.replaceNode(wrapNode)

最后将 v-lazy-show | v-shouw.lazy 处理为 v-show


因为 vue 本身是没有 v-lazy-show 的,v-show 也没有 lazy 的的修饰符,那么要让指令生效,就要做到两个:



  1. 将原先的 show-lazy 改名为 show

  2. 过滤掉 lazy 的修饰符


node.props.push({
...dir,
modifiers: dir.modifiers.filter(i => i !== 'lazy'),
name: 'show',
})

也就变成这样啦:


<template v-if="_cache._lazyshow1 || enabled">
<!-- 为true时会把_cache._lazyshow1置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-lazy-show" v-show="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>


<template v-if="_cache._lazyshow2 || enabled">
<!-- 为true时会把_cache._lazyshow2置为true,那么以后的v-if就用于为true了 -->
<ExpansiveComponent msg="v-show.lazy" v-show="enabled"/>
</template>
<template v-else>
<!--v-show-if-->
</template>

小结一下:




  1. 为每一个使用 v-lazy-show 分配唯一的 key,放到渲染函数内部的_cache上,即借助辅助变量_cache.${key}



    • 当初始条件为 falsy 时不渲染节点,只渲染注释节点 <!--v-show-if-->

    • 直到条件为真时将其置为 true,之后的表现就跟 v-show 一致了





  1. 由于 vue 不认识 v-lazy-show,v-show.lazy,使用要将指令改回 v-show,且过滤掉 lazy 修饰符(如果使用 v-show.lazy 的话)


最后


以上就是我对该运行时编译插件的认识了,可以将 repo 拉下来,上面有个 playground,可以自己调试调试,说不定有新的认识。


好了,文章到此为止,你今天学废了吗?


image.png



作者:暴走老七
来源:juejin.cn/post/7217836890119995450
收起阅读 »

给轮播图做一个自适应的高度。

web
不知道大家有没有遇到这样的需求或者说看到类似的效果,就是列表进去详情看轮播图的时候,当手指滚动轮播图时轮播的高度容器会自适应,这样下面的内容就向上挤,滑动的过程会计算高度,释放的时候也会滚到下一张,也会计算对应图片的高度,然后做一个缓动的动画效果。就像下面这张...
继续阅读 »

不知道大家有没有遇到这样的需求或者说看到类似的效果,就是列表进去详情看轮播图的时候,当手指滚动轮播图时轮播的高度容器会自适应,这样下面的内容就向上挤,滑动的过程会计算高度,释放的时候也会滚到下一张,也会计算对应图片的高度,然后做一个缓动的动画效果。就像下面这张图的样子。


1.gif


可以看到上面的图片内容文字,随着轮播的滑动高度也在变化。费话不多说直接上代码。


实现方法


可以通过监听鼠标mounse 或者手指的滑动 touch 事件来控制图片,这里本文只说一下轮播的功能实现思路,重点说的是怎么实现高度的自适应。


直接开始正文,先看 html 代码结构。


html 结构


<div class="container">
 <div class="wrapper">
   <div class="swiper">
     <div class="item">
       <img src="https://ci.xiaohongshu.com/776d1cc7-ff36-5881-ad8f-12a5cd1c3ab3?imageView2/2/w/1080/format/jpg" alt="">
     </div>
     <div class="item">
       <img src="https://ci.xiaohongshu.com/b8e16620-66a0-79a5-8a4b-5bfee1028554?imageView2/2/w/1080/format/jpg" alt="">
     </div>
     <div class="item">
       <img src="https://ci.xiaohongshu.com/e12013c2-3c46-a2cc-7fda-1e0b20b36f3d?imageView2/2/w/1080/format/jpg" alt="">
     </div>
   </div>
 </div>
 <div class="content">这是一段内容</div>
</div>

css 样式


.container {
 width: 100%;
 overflow: hidden;
}
.wrapper {
 width: 100%;
}
.swiper {
 font-size: 0;
 white-space: nowrap;
}
.item {
 display: inline-block;
 width: 100%;
 vertical-align: top; // 一定要使用顶部对齐,不然会出现错位的情况
}
.item img {
 width: 100%;
 height: auto;
 display: block;
}
.content {
 position: relative;
 z-index: 9;
 font-size: 14px;
 text-align: center;
 padding-top: 20px;
 background-color: #fff;
 height: 200px;
}

值得注意的地方有几点;



  1. 在使用父级 white-space 时,子集元素设置 display: inline-block 会出现高度不同的排列错位,解决办法就是加上一句 vertical-align: top ,具体什么原因我也不细讲了。

  2. 另外父级还要设置 font-size: 0 ,如果没加上的话,就会出现两个子集有空隙出现,加上之后空隙就会去掉。

  3. img 图片最好设置成高度自适应,宽度100% 还要加上 display: block ,没有的话底部就会出现间隙。


写好上面的 html容器部分和 样式,下面就看一下 js 上是怎么处理的。


Js 实现


开始之前我们先思考一下去怎么实现这个轮播以及高度的自适应问题,分为几步操作;



  1. 鼠标按下时,需要记录当前的位置和一些其他初始化的信息,并且给当前的父元素添加相应的鼠标事件。

  2. 鼠标移动时,需要通过当前实时移动时点位和按下时点位的相减,得到移动的距离位置,然后再赋值给父元素设置其样式 transform 位置,中间还做其他的边界处理,当然还有高度的变化。

  3. 鼠标释放是,通过移动时记录的距离信息判断是左滑还是右滑,拿到其对应的索引,通过索引就可以计算到滚动下一张的距离,释放之后设置 transition 过渡动画即可。


按照我们试想的思路,开始正文;


初始化数据


const data = {
 ele: null,
 width: 0,
 len: 0,
 proportion: .3,
 type: false,
 heights: [500, 250, 375],
 currentIndex: 0,
 startOffset: 0,
 clientX: 0,
 distanceX: 0,
 duration: 30,
 touching: false
}

const wrapper = data.ele = document.querySelector('.wrapper')
const items = document.querySelectorAll('.item')
data.width = wrapper.offsetWidth
data.len = items.length - 1
wrapper.addEventListener('touchstart', onStart)
wrapper.addEventListener('mousedown', onStart)

注意,这里在做高度之前,我们需要等图片加载完成之后才能拿到每一个元素的高度,我这里为了省懒就没写具体代码,上面的 heights 对应的是每个图片在渲染之后的高度,一般情况下最好让后端传回来带宽高,这样就不需要用 onload 再去处理这个。


鼠标按下时


function onStart(event) {
 if (event.type === 'mousedown' && event.which !== 1) return
 if (event.type === 'touchstart' && event.touches.length > 1) return
 data.type = event.type === 'touchstart'
 const events = data.type ? event.touches[0] || event : event

 data.touching = true
 data.clientX = events.clientX
 data.startOffset = data.currentIndex * -data.width

 data.ele.style.transition = `none`
 window.addEventListener(data.type ? 'touchmove' : 'mousemove', onMove, { passive: false })
 window.addEventListener(data.type ? 'touchend' : 'mouseup', onEnd, false)
}

上面的代码里面我做了PC和移动端的兼容,跟计划的一样,保存一下 clientX 坐标和一个初始的坐标 startOffset 这个由当前索引和父级宽度计算得到,场景是当从第二张图片滚动到第三张图片时,会把之前的第一张图片的距离也要加上去,不然就计算错误,看下面滑动时的代码。


另外在做监听移动的时候加上了 passive: false 是为了在移动端兼容处理。


鼠标移动时


function onMove(event) {
 event.preventDefault()
 if (!data.touching) return
 const events = data.type ? event.touches[0] || event : event

 data.distanceX = events.clientX - data.clientX

 let translatex = data.startOffset + data.distanceX
 if (translatex > 0) {
   translatex = translatex > 30 ? 30 : translatex
} else {
   const d = -(data.len * data.width + 30)
   translatex = translatex < d ? d : translatex
}

 data.ele.style.transform = `translate3d(${translatex}px, 0, 0)`
 data.ele.style.webkitTransform = `translate3d(${translatex}px, 0, 0)`
}

做了一个边界处理的,超了 30 的距离就不让继续滑动了,加上之前保存的 startOffset 的值,得到的就是具体移动的距离了。


鼠标释放时


function onEnd() {
 if (!data.touching) return
 data.touching = false

 // 通过计算 proportion 滑动的阈值拿到释放后的索引
 if (Math.abs(data.distanceX) > data.width * data.proportion) {
   data.currentIndex -= data.distanceX / Math.abs(data.distanceX)
}
 if (data.currentIndex < 0) {
   data.currentIndex = 0
} else if (data.currentIndex > data.len) {
   data.currentIndex = data.len
}
 const translatex = data.currentIndex * -data.width

 data.ele.style.transition = 'all .3s ease'
 data.ele.style.transform = `translate3d(${translatex}px, 0, 0)`
 data.ele.style.webkitTransform = `translate3d(${translatex}px, 0, 0)`

 window.removeEventListener(data.type ? 'touchmove' : 'mousemove', onMove, { passive: false })
 window.removeEventListener(data.type ? 'touchend' : 'mouseup', onEnd, false)
}

通过计算 proportion 滑动的阈值拿到释放后的索引,也就是超过父级宽度的三分之一时释放就会滚动到下一张,拿到索引之后就可以设置需要移动的最终距离,记得加上 transition 做一个缓动效果,最后也别忘记移除事件的监听。


至此上面的简单的轮播效果就大功告成了,但是还缺少一点东西,就是本篇需要讲的自适应高度,为了方便理解就单独拿出来说一下。


高度自适应


在移动时就可以在里面做相关的代码整理了, onMove 函数里加上以下代码,来获取实时的高度。


const index = data.currentIndex
const currentHeight = data.heights[index]
   
// 判断手指滑动的方向拿到下一张图片的高度
let nextHeight = data.distanceX > 0 ? data.heights[index - 1] : data.heights[index + 1]
let diffHeight = Math.abs((nextHeight - currentHeight) * (data.distanceX / data.width))
let realHeight = currentHeight + (nextHeight - currentHeight > 0 ? diffHeight : -diffHeight)

data.ele.style.height = `${realHeight}px`

这里是移动时的高度变化,另外还需要在释放时也要处理, onEnd 函数里加上以下代码。


// ... 因为上面已经拿到了下一张的索引 currentIndex
const currentHeight = data.heights[data.currentIndex]

data.ele.style.height = `${currentHeight}px`

因为上面已经拿到了下一张的索引 currentIndex 所以再滚动到下一张是就直接通过数据获取就可以了。


可以在线预览一下效果。


作者:ZHOUYUANN
来源:juejin.cn/post/7213654163317162045
收起阅读 »

简析无感知刷新Token

web
在前后端分离的应用中,使用Token进行认证是一种较为常见的方式。但是,由于Token的有效期限制,需要不断刷新Token,否则会导致用户认证失败。为了解决这个问题,可以实现无感知刷新Token的功能,本文将介绍如何实现无感知刷新Token。 Token认证的...
继续阅读 »

在前后端分离的应用中,使用Token进行认证是一种较为常见的方式。但是,由于Token的有效期限制,需要不断刷新Token,否则会导致用户认证失败。为了解决这个问题,可以实现无感知刷新Token的功能,本文将介绍如何实现无感知刷新Token。


Token认证的原理


在Web应用中,常见的Token认证方式有基于Cookie和基于Token的认证。基于Cookie的认证方式是将认证信息保存在Cookie中,每次请求时将Cookie发送给服务器进行认证;而基于Token的认证方式是将认证信息保存在Token中,每次请求时将Token发送给服务器进行认证。


在基于Token的认证方式中,客户端将认证信息保存在Token中,而不是保存在Cookie中。在认证成功后,服务器将生成一个Access Token和一个Refresh Token,并将它们返回给客户端。Access Token用于访问受保护的API,Refresh Token用于获取新的Access Token。


什么是无感知刷新Token


无感知刷新Token是指,在Token过期之前,系统自动使用Refresh Token获取新的Access Token,从而实现Token的无感知刷新,用户可以无缝继续使用应用。


在实现无感知刷新Token的过程中,需要考虑以下几个方面:



  • 如何判断Token是否过期?

  • 如何在Token过期时自动使用Refresh Token获取新的Access Token?

  • 如何处理Refresh Token的安全问题?


下面将介绍如何实现无感知刷新Token的具体步骤。


实现步骤


步骤一:获取Access Token和Refresh Token


在认证成功后,需要将Access Token和Refresh Token发送给客户端。Access Token用于访问受保护的API,Refresh Token用于获取新的Access Token。可以使用JWT(JSON Web Token)或OAuth2(开放授权)等方式实现认证。


在JWT中,可以使用如下代码生成Access Token和Refresh Token:


const accessToken = jwt.sign({userId: '123'}, 'ACCESS_TOKEN_SECRET', {expiresIn: '15m'});
const refreshToken = jwt.sign({userId: '123'}, 'REFRESH_TOKEN_SECRET', {expiresIn: '7d'});

步骤二:在请求中携带Access Token


在每个需要认证的API请求中,需要在请求头中携带Access Token,如下所示:


GET /api/user HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

在前端中,可以使用Axios等库设置请求头:


axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;

步骤三:拦截401 Unauthorized响应


在服务器返回401 Unauthorized响应时,说明Access Token已经过期,需要使用Refresh Token获取新的Access Token。可以使用Axios拦截器或Fetch API的中间件实现拦截。


在Axios中,可以使用如下代码实现拦截器:


axios.interceptors.response.use(response => {
return response;
}, error => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; //防止无限调用
return axios.post('/api/refresh_token', {refreshToken})
.then(response => {
const { access_token, refresh_token } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return axios(originalRequest);
});
}
return Promise.reject(error);
});

在Fetch中,可以使用如下代码实现中间件:


function authMiddleware(request) {
const access_token = localStorage.getItem('access_token');
if (access_token) {
request.headers.set('Authorization', `Bearer ${access_token}`);
}
return request;
}

function tokenRefreshMiddleware(response) {
if (response.status === 401) {
const refreshToken = localStorage.getItem('refresh_token');
return fetch('/api/refresh_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
}).then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Refresh Token failed');
}).then(data => {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return Promise.resolve('refreshed');
}).catch(error => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
return Promise.reject(error);
});
}
return Promise.resolve('ok');
}

fetch('/api/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
middleware: [authMiddleware, tokenRefreshMiddleware]
}).then(response => {
console.log(response);
}).catch(error => {
console.error(error);
});

在上述代码中,使用Axios或Fetch拦截器拦截401 Unauthorized响应,如果发现Access Token已经过期,则发送Refresh Token请求获取新的Access Token,并将新的Access Token设置到请求头中,重新发送请求。


步骤四:服务器处理Refresh Token请求


在服务器端,需要编写API处理Refresh Token请求,生成新的Access Token,并返回给客户端。


JWT中,可以使用如下代码生成新的Access Token:


const accessToken = jwt.sign({userId: '123'}, 'ACCESS_TOKEN_SECRET', {expiresIn: '15m'});

在刷新Token时,需要验证Refresh Token的合法性,可以使用如下代码验证Refresh Token:


try {
const payload = jwt.verify(refreshToken, 'REFRESH_TOKEN_SECRET');
const accessToken = jwt.sign({userId: payload.userId}, 'ACCESS_TOKEN_SECRET', {expiresIn: '15m'});
const refreshToken = jwt.sign({userId: payload.userId}, 'REFRESH_TOKEN_SECRET', {expiresIn: '7d'});
res.json({access_token: accessToken, refresh_token: refreshToken});
} catch (err) {
res.sendStatus(401);
}

在上述代码中,使用JWT的verify方法验证Refresh Token的合法性,如果验证成功,则生成新的Access Token和Refresh Token,并返回给客户端。


步骤五:设置定时刷新Token


为了避免Access Token过期时间太长,可以设置定时刷新Token的功能。可以使用定时器或Web Workers等方式实现定时刷新Token。在每次刷新Token时,需要重新获取新的Access Token和Refresh Token,并保存到客户端。


function refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
axios.post('/api/refresh_token', {refreshToken})
.then(response => {
const { access_token, refresh_token } = response.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
})
.catch(error => {
console.error(error);
});
}

setInterval(refreshToken, 14 * 60 * 1000); // 每14分钟刷新Token

在上述代码中,使用定时器每14分钟刷新Token。在刷新Token成功后,将新的Access Token和Refresh Token保存到客户端,并将新的Access Token设置到请求头中。


安全性考虑


在实现无感知刷新Token的过程中,需要考虑到Refresh Token的安全性问题。因为Refresh Token具有长期的有效期限,一旦Refresh Token被泄露,攻击者就可以使用Refresh Token获取新的Access Token,从而绕过认证机制,访问受保护的API。


为了增加Refresh Token的安全性,可以考虑以下几种措施:



  • 将Refresh Token保存在HttpOnly Cookie中,可以避免在客户端被JavaScript获取;

  • 对Refresh Token进行加密或签名,可以增加其安
    作者:XinD
    来源:juejin.cn/post/7215569601161150522
    全性。

收起阅读 »

从0搭建nestjs项目并部署到本地docker

web
开发目标:快速搭建nestjs项目本地环境,并测试本地打包方便后期部署到服务器。 项目准备:node环境、npm依赖、docker 创建项目并启动 使用typeorm连接mysql 使用class-validate校验入参 使用全局filter处理异常,使用...
继续阅读 »

开发目标:快速搭建nestjs项目本地环境,并测试本地打包方便后期部署到服务器。


项目准备:node环境、npm依赖、docker



  1. 创建项目并启动

  2. 使用typeorm连接mysql

  3. 使用class-validate校验入参

  4. 使用全局filter处理异常,使用全局interceptor处理成功信息

  5. 使用ioredis连接redis

  6. 使用swaager文档

  7. 使用docker-compose打包并运行

  8. 总结


一、创建项目并启动


1、全局安装nestjs并创建项目

npm i -g @nestjs/cli
nest new nest-demo

2、使用热更新模式运行项目

npm run start:dev

此时访问 http://localhost:3000就可以看到 Hello World!


3、使用cli一键生成一个user模块

nest g resource system/user

选择REST API和自动生成CURD


4、设置全局api前缀

src/main.ts


async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // 设置全局api前缀
await app.listen(3000);
}
bootstrap();

更多nestjs入门教程查看:# 跟随官网学nestjs之入门


二、使用typeorm连接并操作mysq


1、安装依赖

npm i @nestjs/typeorm typeorm mysql @nestjs/config -S

2、在src下创建 config/env.ts 用来判断当前环境,抛出配置文件地址

src/config/env.ts


import * as fs from 'fs';
import * as path from 'path';
const isProd = process.env.NODE_ENV == 'prod';

function parseEnv() {
const localEnv = path.resolve('.env');
const prodEnv = path.resolve('.env.prod');

if (!fs.existsSync(localEnv) && !fs.existsSync(prodEnv)) {
throw new Error('缺少环境配置文件');
}

const filePath = isProd && fs.existsSync(prodEnv) ? prodEnv : localEnv;
return { path: filePath };
}
export default parseEnv();

3、在src下创建.env配置文件

src/.env


# default
PORT=9000

# database
DB_HOST=localhost
DB_PORT=3306
DB_USER=demo_user
DB_PASSWD=123456
DB_DATABASE=demo_db


4、在app.module内挂载全局配置和mysql

src/app.module.ts


import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService, ConfigModule } from '@nestjs/config';
import envConfig from './config/env';
import { AppService } from './app.service';
import { UserModule } from './system/user/user.module';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 设置为全局
envFilePath: [envConfig.path],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DB_HOST', 'localhost'), // 主机,默认为localhost
port: configService.get<number>('DB_PORT', 3306), // 端口号
username: configService.get('DB_USER', 'root'), // 用户名
password: configService.get('DB_PASSWORD', '123456'), // 密码
database: configService.get('DB_DATABASE', 'test_db'), //数据库名
entities: ['dist/**/*.entity{.ts,.js}'],
timezone: '+08:00', //服务器上配置的时区
synchronize: true, //根据实体自动创建数据库表, 生产环境建议关闭
autoLoadEntities: true,
}),
}),
UserModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

5、定义userEntity实体

src/system/user/entities/user.entity.ts


import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user_tb')
export class UserEntity {
@PrimaryGeneratedColumn()
s_id: string;

@Column({ type: 'varchar', length: 20, default: '', comment: '名称' })
s_name: string;

@Column({ type: 'int', default: 0, comment: '年龄' })
s_age: number;
}

6、user.module内引入entity实体

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
// 引入typeorm和Enetiy实例
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';

@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [UserService],
}
)

export class UserModule {}

7、在控制器user.controller修改api地址

@Post('create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}

地址拼接为:全局前缀api+模块user+自定义create = localhost:3000/api/user/crtate


image.png


image.png


三、使用class-validato校验入参


1、安装依赖

npm i class-validator class-transformer -S

2、配置校验规则

src/system/user/dto/create-user.dto.ts


import { IsNotEmpty } from 'class-validator';

export class CreateUserDto {
@IsNotEmpty({ message: '名称不能为空' })
readonly s_name: string;
}

image.png


更多校验规则查看:git文档


四、使用filter全局错误过滤、interceptor全局成功过滤


1、使用cli自动生成过滤器


nest g filter common/http-exception
nest g interceptor common/transform

2、编写过滤器


src/common/http-exception/http-exception.filter.ts


import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取请求上下文中的 response对象
const status = exception.getStatus(); // 获取异常状态码

let resultMessage = exception.message;

// 拦截class-validate错误信息
try {
const exceptionResponse = exception.getResponse() as any;
if (Object.hasOwnProperty.call(exceptionResponse, 'message')) {
resultMessage = exceptionResponse.message;
}
} catch (e) {}

const errorResponse = {
data: null,
message: resultMessage,
code: '9999',
};

// 设置返回的状态码, 请求头,发送错误信息
response.status(status);
response.header('Content-Type', 'application/json; charset=utf-8');
response.send(errorResponse);
}
}

src/common/transform/transform.interceptor.ts


import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
return {
data,
code: '0000',
msg: '请求成功',
};
}),
);
}
}

3、在main.ts里挂载


import { HttpExceptionFilter } from './common/http-exception/http-exception.filter';
import { TransformInterceptor } from './common/transform/transform.interceptor';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter()); // 全局注册错误的过滤器(错误异常)
app.useGlobalInterceptors(new TransformInterceptor()); // 全局注册成功过滤器
await app.listen(3000);
}
bootstrap();

手动抛出异常错误只需在service的方法里


throw new HttpException('message', HttpStatus.BAD_REQUEST)


五、使用idredis连接redis


1、安装依赖

npm i ioredis -S

2、在.env文件添加reids配置

# redis
REDIS_HOST=localhost
REIDS_PORT=6379
REIDS_PASSWD=
REIDS_DB=3

3、在common目录下创建cache模块,连接redis

nest g mo cache common && nest g s cache common

src/common/cache/cache.service.ts


import { Injectable, Logger } from '@nestjs/common';
import { Redis } from 'ioredis';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class CacheService {
public client;
constructor(private readonly configService: ConfigService) {
this.getClient();
}

async getClient() {
const client = new Redis({
host: this.configService.get('REDIS_HOST', 'localhost'), // 主机,默认为localhost
port: this.configService.get<number>('REIDS_PORT', 6379), // 端口号
password: this.configService.get('REIDS_PASSWD', ''), // 密码
db: this.configService.get<number>('REIDS_DB', 3),
});
// 连接成功提示
client.on('connect', () =>
Logger.log(
`redis连接成功,端口${this.configService.get<number>(
'REIDS_PORT',
3306,
)}
`
,
),
);
client.on('error', (err) => Logger.error('Redis Error', err));

this.client = client;
}

public async set(key: string, val: string, second?: number) {
const res = await this.client.set(key, val, 'EX', second);
return res === 'OK';
}

public async get(key: string) {
const res = await this.client.get(key);
return res;
}
}

在cache.module内抛出service
src/common/cache/cache.module.ts


@Module({
providers: [CacheService],
exports: [CacheService],
})

4、在user.module内引入cacheModule并在user.service内使用

src/system/user/user.module.ts


import { CacheModule } from 'src/common/cache/cache.module';
@Module({
imports: [CacheModule],
controllers: [UserController],
providers: [UserService],
})

export class UserModule {}

src/system/user/user.service.ts


import { CacheService } from '@src/common/cache/cache.service';

@Injectable()
export class UserService {
constructor(
private readonly cacheService: CacheService,
) {}

async create(createUserDto: CreateUserDto) {
const redisTest = await this.cacheService.get('redisTest');

Logger.log(redisTest, 'redisTest');
if (!redisTest) {
await this.setRedis();
return this.create(createUserDto);
}

...
}
async setRedis() {
const res = await this.cacheService.set(
'redisTest',
'test_val',
12 * 60 * 60,
);
if (!res) {
Logger.log('redis保存失败');
} else {
Logger.log('redis保存成功');
}
}
}

image.png


image.png


六、使用swagger生成文档


1、安装依赖

npm i @nestjs/swagger swagger-ui-express -S

2、在main.ts引入并配置

import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 设置swaager
const options = new DocumentBuilder()
.setTitle('nest-demo example')
.setDescription('The nest demo API description')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('swagger', app, document);

...
}
bootstrap();

此时访问http://wwww.localhost:9000/swagge就可以看到文档


image.png


3、在控制器为业务模块和api打上标签

src/system/user/user.controller.ts


import { ApiTags, ApiOperation } from '@nestjs/swagger';

@ApiTags('user')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}

@ApiOperation({
summary: '创建用户',
})
@Post('create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
}

4、在dto内为字段设置名称

src/system/user/dto/create-user.dto.ts


import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
@ApiProperty({ type: 'string', example: '用户名称' })
@IsNotEmpty({ message: '名称不能为空' })
readonly s_name: string;

@ApiProperty({ type: 'number', example: '用户年龄' })
readonly s_age: number;
}

这时刷新浏览器,就能看到文档更新了


image.png


更多swaager配置查看:官方文档


七、使用docker-compose自动部署到本地docker


1、在根目录下创建docker-compose.yml

version: "3.0"

services:
# docker容器启动的redis默认是没有redis.conf的配置文件,所以用docker启动redis之前,需要先去官网下载redis.conf的配置文件
redis_demo: # 服务名称
container_name: redis_demo # 容器名称
image: daocloud.io/library/redis:6.0.3-alpine3.11 # 使用官方镜像
# 配置redis.conf方式启动
# command: redis-server /usr/local/etc/redis/redis.conf --requirepass 123456 --appendonly yes # 设置redis登录密码 123456、--appendonly yes:这个命令是用于开启redis数据持久化
# 无需配置文件方式启动
command: redis-server --appendonly yes # 开启redis数据持久化
ports:
- 6379:6379 # 本机端口:容器端口
restart: on-failure # 自动重启
volumes:
- ./deploy/redis/db:/data # 把持久化数据挂载到宿主机
- ./deploy/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf # 把redis的配置文件挂载到宿主机
- ./deploy/redis/logs:/logs # 用来存放日志
environment:
- TZ=Asia/Shanghai # 解决容器 时区的问题
networks:
- my-server_demo

mysql_demo:
container_name: mysql_demo
image: daocloud.io/library/mysql:8.0.20 # 使用官方镜像
ports:
- 3306:3306 # 本机端口:容器端口
restart: on-failure
environment:
MYSQL_DATABASE: demo_db
MYSQL_ROOT_PASSWORD: 123456
MYSQL_USER: demo_user
MYSQL_PASSWORD: 123456
MYSQL_ROOT_HOST: '%'
volumes:
- ./deploy/mysql/db:/var/lib/mysql # 用来存放了数据库表文件
- ./deploy/mysql/conf/my.cnf:/etc/my.cnf # 存放自定义的配置文件
# 我们在启动MySQL容器时自动创建我们需要的数据库和表
# mysql官方镜像中提供了容器启动时自动docker-entrypoint-initdb.d下的脚本的功能
- ./deploy/mysql/init:/docker-entrypoint-initdb.d/ # 存放初始化的脚本
networks:
- my-server_demo

server_demo: # nestjs服务
container_name: server_demo
build: # 根据Dockerfile构建镜像
context: .
dockerfile: Dockerfile
ports:
- 9003:9003
restart: on-failure # 设置自动重启,这一步必须设置,主要是存在mysql还没有启动完成就启动了node服务
networks:
- my-server_demo
depends_on: # node服务依赖于mysql和redis
- redis_demo
- mysql_demo

# 声明一下网桥 my-server。
# 重要:将所有服务都挂载在同一网桥即可通过容器名来互相通信了
# 如nestjs连接mysql和redis,可以通过容器名来互相通信
networks:
my-server_demo:

2、在根目录创建Dockerfile文件

FROM daocloud.io/library/node:14.7.0

# 设置时区
ENV TZ=Asia/Shanghai \
DEBIAN_FRONTEND=noninteractive
RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && dpkg-reconfigure --frontend noninteractive tzdata && rm -rf /var/lib/apt/lists/*

# 创建工作目录
RUN mkdir -p /app

# 指定工作目录
WORKDIR /app

# 复制当前代码到/app工作目录
COPY . ./

# npm 源,选用国内镜像源以提高下载速度
RUN npm config set registry https://registry.npm.taobao.org/

# npm 安装依赖
COPY package.json /app/package.json
RUN rm -rf /app/package-lock.json
RUN cd /app && rm -rf /app/node_modules && npm install

# 打包
RUN cd /app && rm -rf /app/dist && npm run build

# 启动服务
# "start:prod": "cross-env NODE_ENV=production node ./dist/src/main.js",
CMD npm run start:prod

EXPOSE 9003

3、修改.env.prod正式环境配置

# default
PORT=9003
HOST=localhost

# database
DB_HOST=mysql_demo #使用容器名称连接
DB_PORT=3306
DB_USER=demo_user
DB_PASSWD=123456
DB_DATABASE=demo_db

# redis
REDIS_HOST=redis_demo #使用容器名称连接
REIDS_PORT=6379
REIDS_PASSWD=
REIDS_DB=3

4、修改main.ts启动端口

import { ConfigService } from '@nestjs/config';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService); // 获取全局配置
const PORT = configService.get<number>('PORT', 9000);
const HOST = configService.get('HOST', 'localhost');
await app.listen(PORT, () => {
Logger.log(`服务已经启动,接口请访问:http://wwww.${HOST}:${PORT}`);
});
}
bootstrap();

5、前台运行打包

docker-compose up


运行完成后大概率会报错,因为我们使用的mysql账号没有权限,所以需要进行设置


image.png


// 进入mysql容器命令
docker ecex -it mysql_demo /bin/bash
// 登录mysql
mysql -uroot -p123456
// 查询数据库后进入mysql查询数据表
show databases;
use mysql;
show tables;
// 查看user表中的数据
select User,Host from user;
// 刚创建的用户表没有我们设置连接的用户和host,所以需要创建
CREATE USER 'demo_user'@'%' IDENTIFIED BY '123456';
// 给创建的用户赋予权限
GRANT ALL ON *.* TO 'demo_user'@'%';
// 刷新权限
flush privileges;

如果还报错修改下密码即可
Pasted Graphic 1.png


ALTER USER 'demo_user'@'%' IDENTIFIED WITH mysql_native_password BY '123456';

此时项目应该能正常启动并成功访问


image.png


image.png


6、切换后台运行

// Ctrl+C 终止程序后执行后台运行命令
docker-compose up -d

八、总结


docker-compose up正常用来测试本地打包,和第一次构建redismysql容器,后续需要在本地运行开发模式只需保证redismysql容器正常运行即可,如需再次打包,删除server容器和镜像再次执行即可


docker ps -a // 查询docker容器
docker rm server_demo // 删除server容器
docker images // 查询镜像
docker rmi nest-demo_server_demo // 删除server镜像, server镜像名称:项目名称_容器名称
docker-compose up -d // 重新打包

本地开发模式只需关闭server容器,然后在项目内只需 start:dev即可


docker stop server_demo
npm run start:dev

作者:jjggddb
来源:juejin.cn/post/7215844385614528549
收起阅读 »

keepAlive模式下切换页面时缓存页面中el-select已展开的选项框无法自动关闭解决方案

web
问题描述 如下图,在keepAlive缓存的页面中使用element中的select选择器,打开弹出框后不手动关闭,直接切换页面,会出现弹出框仍然展示在页面上的现象。 问题原因 select选择器提供一个属性 popper-append-to-body 为...
继续阅读 »

问题描述


如下图,在keepAlive缓存的页面中使用element中的select选择器,打开弹出框后不手动关闭,直接切换页面,会出现弹出框仍然展示在页面上的现象。


select-bug.gif


问题原因



  1. select选择器提供一个属性 popper-append-to-body 为false时,弹出框是放置在select选择器所在层级中,为true时,允许将弹出框插入至body元素中。


image.png


image.png



  1. 本页面被keepAlive缓存后 再切出本页面时不会触发select选择器组件的blur事件


所以当弹出框被插入至body元素中时,切出缓存页面 无法触发select选择器组件的blur事件,弹出框在body中无法隐藏


解决方法1


设置属性 popper-append-to-body为false,弹出框不会直接插入至body元素中,页面切换后弹出框也会被隐藏


局限性:
某些场景需要设置select选择器上级元素超出隐藏,弹出框如果超出上级元素的范围则无法完全展示


image.png


解决方法2


elementselect选择器源码中弹出框开启关闭由变量visible控制,将elselect组件包装一下,在deactivated生命周期钩子里设置弹出框关闭,注册组件时


image.png


// SelectWrapper 组件
<script lang="ts">
import { Mixins, Component, Watch } from 'vue-property-decorator';
import { Select } from 'element-ui';

@Component({
name: 'ElSelect',
})
export default class ElSelect extends Mixins(Select) {
visible: boolean | undefined;

deactivated() {
this.visible = false;
}
}
</script>

入口文件全局注册新的SelectWrapper组件,替换掉elementselect选择器,这样可以做到在业务组件中无感使用


 app.component('el-select', SelectWrapper);

作者:Eden的前端笔记
来源:juejin.cn/post/7215855138812461115
收起阅读 »

iframe之间的通信

web
前言 iframe 想必大家都挺熟悉的了,就不多说了👍👍。写这篇文章的初衷主要是丰富自己的知识和解决遇到的问题。因为我基本上没接触过 iframe ,所以对它的通信方式不是很了解。前几天,跟我的一个朋友(在下杨公子)聊天时,他提到了 iframe 的通信方式,...
继续阅读 »

前言


iframe 想必大家都挺熟悉的了,就不多说了👍👍。写这篇文章的初衷主要是丰富自己的知识和解决遇到的问题。因为我基本上没接触过 iframe ,所以对它的通信方式不是很了解。前几天,跟我的一个朋友(在下杨公子)聊天时,他提到了 iframe 的通信方式,我觉得很有意思,就开始了解和学习。在这篇文章中,我将分享我所学到的内容,希望对大家有所帮助🤪🤪。


接下来我们就一起来学习一下关于 iframe 通信的相关知识吧😁


iframe通信的几种方式😶‍🌫️😶‍🌫️



  1. URL 传参:父窗口可以通过在 iframe 的 src 属性后添加参数来向子窗口传递数据,子窗口可以通过 location.searchlocation.hash 来获取参数✨✨。



  • 使用 ? 拼接参数,子页面使用 location.search 接收参数


// parent.html
<iframe id="iframe1" src="./child1.html?name=来自parent的消息" frameborder="0"></iframe>

// child1.html
<script>
console.log(window.decodeURIComponent(location.search)) // ?name=来自parent的消息
</script>



  • 使用 # 拼接参数,子页面使用 location.hash 接收参数,同时还可以使用 window.onhashchange 来监听参数的变化。


// parent.html
<iframe id="iframe1" src="./child1.html#name=来自parent的消息" frameborder="0"></iframe>
<script>
const iframe1 = document.getElementById('iframe1')
// 在2s后更改hash
setTimeout(() => {
iframe1.src = './child1.html#age=12'
}, 2000)
</script>


// child1.html
<script>
console.log('hash', window.decodeURIComponent(location.hash)) // #name=来自parent的消息
window.onhashchange = () => {
console.log('hashchange', window.location.hash) // #age=12
}
</script>



⚡⚡需要注意的是通过 URL 传参 的时候,传输携带中文的话,记得使用 decodeURIComponent 进行解码。




  1. window.postMessage:安全、可靠且支持跨域的 iframe 通信方式,它可以在两个窗口之间异步传递消息✨✨✨✨✨。



  • 在发送方中,使用 window.postMessage() 方法向另一个窗口发送消息。该方法接收两个参数:要发送的消息和目标窗口的源(例如,"http://127.0.0.1:5500/child.html" 或 "*")。


window.postMessage('Hello world!', 'http://127.0.0.1:5500/child.html')


  • 在接收方中,使用 window.addEventListener() 方法监听 message 事件。该事件对象包含三个属性:data 表示接收到的数据,origin 表示发送方的源,source 表示发送方窗口的引用。


window.addEventListener('message', function(event) {
// 判断消息是否来自可信任的源
if (event.origin === 'http://127.0.0.1:5500/child.html') {
console.log('message: ' + event.data)
}
})

兼容性,来自 window.postMessage | MDN


image.png



  1. window.name:可以使用一个隐藏的iframe和window.name属性在不同的窗口之间共享数据✨✨。



  • 在子页面中,将要传递给父页面的数据保存在 window.name 属性中。


例如


window.name = 'Hello Parent!';


  • 在父页面中,创建一个隐藏的 iframe 元素,并且将其源设置为子页面的 URL


const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'http://127.0.0.1:5500/child1.html';
document.body.appendChild(iframe);


  • 在父页面中,等待 iframe 加载完成后,通过访问 iframe.contentWindow.name 属性来获取子页面中保存的数据。


iframe.onload = function() {
const childData = iframe.contentWindow.name;
onsole.log('message:', childData); // 输出:message: Hello Parent!
};


⚡⚡注意:使用 window.name 进行跨域 iframe 通信存在安全性问题,因为所有具有相同名称的窗口都可以访问和修改 window.name




  1. 服务器端转发:可以将消息从一个iframe发送到服务器,然后再由服务器将其转发到另一个iframe。✨✨✨



博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。



作者:树深遇鹿
来源:juejin.cn/post/7215854856731934781
收起阅读 »

女朋友想学webGL修图,安排!

web
前言 看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。 之前讲了简单的webgl的原理与点的绘制、...
继续阅读 »

前言


看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。


之前讲了简单的webgl的原理与点的绘制、以及webgl在vscode需要注意的点,本文将接着介绍如何做个简单的修图功能,由于篇幅有限,只讲基本的语法、多边形绘制、缓冲区、帧缓存、纹理uv等。


预览


chrome-capture-2023-2-30.gif


canvas也可以更简单的实现,getImageData可以得到点的集合,然后putImageData绘制就行了。但是一些复杂的算法,例如高斯模糊、雕刻效果,貌似就没有webgl灵活了。


createBuffer 缓冲区


缓冲区你可以理解canvas的save()保存状态,但是这里我们一般是点的集合,这里我不会讲具体的api细节,但是知道具体的代码流程就行,就是创建buffer及数据 -> 绑定数据 -> 如何加载


let bufferOrigin = gl.createBuffer()
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOrigin);

gl.enableVertexAttribArray(positionAttributeLocation); // 告诉缓冲区怎么加载
gl.vertexAttribPointer(positionAttributeLocation, size, type, normalize, stride, offset);

Program 对象


const canvas = document.querySelector("#canvas");
image.width = 540
image.height = 720

canvas.style.width = 540 + 'px'
canvas.style.height = 720 + 'px'

const gl = canvas.getContext("webgl");
if (!gl) {
return;
}
const program = webglUtils.createProgramFromScripts(gl, ["vertex-shader-2d", "fragment-shader-2d"]);

image加载的dom对象,设置宽高,webglUtils是封装的方法,其实就是之前的初始化的着色器,返回program程序对象。


shader 着色器


先看看着色器源码


<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position; // attribute在顶点着色器处理
attribute vec2 a_texCoord; // 纹理参数
uniform vec2 u_resolution; // 页面的坐标
attribute vec4 a_composeColor; // 纹理增强的向量
varying vec4 v_composeColor;

void main() {
// 屏幕坐标 -> 裁剪坐标
vec2 zeroToOne = a_position / u_resolution;
vec2 zeroToTwo = zeroToOne * 2.0;

vec2 clipSpace = zeroToTwo - 1.0;

gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
v_composeColor = a_composeColor;
v_texCoord = a_texCoord;
}
</script>

attribute类型用于顶点着色器的属性,一般可以在后期动态添加一些控制,但是uniform是只能静态编译的时候就决定了,所以一般用于控制材质、光照等确定的值。为了能控制到片元着色器,那么一定要使用varying这个类型,一般通过变量传递给片元着色器做动态的渲染,所以一般会配合attriubute + varying


<script id="fragment-shader-2d" type="x-shader/x-fragment">
precision mediump float;

uniform sampler2D u_image;
uniform vec2 u_textureSize;
uniform float u_kernel[9];
uniform float u_kernelWeight;
varying vec2 v_texCoord;
varying vec4 v_composeColor;

void main() {
vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
// 卷积内核的前置处理,u_kernel我们传递的核心数据
vec4 colorSum =
texture2D(u_image, v_texCoord + onePixel * vec2(-1, -1)) * u_kernel[0] +
texture2D(u_image, v_texCoord + onePixel * vec2( 0, -1)) * u_kernel[1] +
//.....
// 计算最终的颜色结果
gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;
}
</script>


这里返回的结果gl_FragColor就是最终绘制的颜色。注意颜色范围是0到1需要做个转换,这里的最核心的也就是卷积的算法


卷积


卷积就是一个 3×3 的矩阵, 矩阵中的每一项代表当前处理的像素和周围8个像素的乘法因子, 相乘后将结果加起来除以内核权重(内核中所有值的和或 1.0 ,取二者中较大者)


image.png


像素矩阵 * 修改矩阵 = 赋值于内核也就是中心位置


这也就是我们能处理模糊、锐化等特效的原理, 下面是简单的计算


// 将周围八个点相加用于平均数相除
function computeKernelWeight(kernel) {
const weight = kernel.reduce(function(prev, curr) {
return prev + curr;
});
return weight <= 0 ? 1 : weight;
}

 gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;

滤镜


 const kernelsFilter = {
sharpness: {
name: '锐度',
data: [
0, -1, 0,
-1, 5, -1,
0, -1, 0
],
},
gaussianBlur: {
name: '高斯模糊',
data: [
0, 1, 0,
1, 1, 1,
0, 1, 0
],
},
edgeDetect2: {
name: '反相',
data: [
-1, -1, -1,
-1, 8, -1,
-1, -1, -1
],
},
emboss: {
name: '浮雕效果',
data: [
-2, -1, 0,
-1, 1, 1,
0, 1, 2
],
},
};

// 向量乘积的滤镜
const composeFilter = {
light: {
name: '曝光',
data: new Float32Array([1.2, 1.2, 1.2, 1])
},
langmanmeigui: {
name: '浪漫玫瑰',
data: new Float32Array([1.1, 1, 1, 1])
},
// ....
}

将上面的参数传入对上面的着色器,然后通过卷积赋值于gl_FragColor,这样简单的修图工具就大功告成了。


texcoord 纹理


const texcoordLocation = gl.getAttribLocation(program, "a_texCoord");
// ...
gl.vertexAttribPointer(texcoordLocation, size2, type2, normalize2, stride2, offsetVal2);

这里用缓冲区处理,本质图片也就是4个点的矩形,因为每个点其实对应像素和位置, 下面是创建纹理的标准代码,将image传入到textImage2D,然后将缓冲区绑定到这样我们就可以绘制纹理了


 // webgl创建纹理,并设置基本纹理参数,载入image图片
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

//定义纹理处理能力
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);


帧缓冲


如何给图片施加多种状态的叠加效果,也就是图片 -> 纹理一 -> 纹理一 + 纹理二 -> 画布,那么我们需要用到帧缓冲,其实就是通过不断的bindTexture来覆盖之前的状态。


// 绘制帧缓冲
function drawFrames () {
const originTexture = createAndSetupTexture(gl)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
let textures = []
let frameBuffers = []
const kernelsFilterList = ['gaussianBlur', 'emboss', 'boxBlur',
'gaussianBlur', 'boxBlur', 'gaussianBlur', 'boxBlur', 'gaussianBlur'] //叠加效果的数组

for (let i = 0; i < kernelsFilterList.length; i++) {
let texture = createAndSetupTexture(gl)
textures.push(texture)

gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);

var fBuffer = gl.createFramebuffer()
frameBuffers.push(fBuffer)
gl.bindFramebuffer(gl.FRAMEBUFFER, fBuffer);
// 绑定纹理到帧缓冲
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
}
gl.bindTexture(gl.TEXTURE_2D, originTexture);

for (var i = 0; i < kernelsFilterList.length; i++) {
setFramebuffer(frameBuffers[i], image.width, image.height);
drawWithKernel(kernelsFilterList[i]);
// 叠加
gl.bindTexture(gl.TEXTURE_2D, textures[i]);
}

// 绘制
setFramebuffer(null, canvas.width, canvas.height);
drawWithKernel("normal");

function setFramebuffer (fbo, width, height) {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 绑定帧缓存
gl.uniform2f(resolutionLocation, width, height); // 设置到裁剪坐标
gl.viewport(0, 0, width, height); // 将裁剪坐标自适应到屏幕坐标
}
}


总结


通过基本的语法、纹理使用、帧缓存等,我们对webgl的基本的2d图形处理有了一定的认知,正常在绘制三角形,四边形,圆形,我们都可以使用缓存区,最后drawArrays绘制,在一些图形的渲染需要保存之前的状态的时候,我们可以使用帧缓存处理。关于当前页面的优化,当前的修图页面应该将各种调色分到不同的glsl文件,同样我们也可以做裁剪,上传图片编辑并下载。



如果觉得文章对你有帮助,不要忘了一键三连 👍



附录



  1. 内卷年代,是该学学WebGL了 - 掘金 (juejin.cn)

  2. 为什么我的WebGL开发这么丝滑 🌊 - 掘金 (juejin.cn)


作者:谦宇
来源:juejin.cn/post/7215977393696522299
收起阅读 »

硬盘坏了,一气之下用 js 写了个恢复程序

web
硬盘坏了,一气之下写了个恢复程序 师傅拯救无望 硬盘已经寄过去超过一周了,一问竟然是还没开始弄??? 再过一周,上来就问我分几个区?我要恢复哪些数据?我要恢复的数据在哪个位置? 那好吧,既然给了钱师傅也都放弃了,我也没什么好寄托希望的了。况且经过这三个...
继续阅读 »

硬盘坏了,一气之下写了个恢复程序


师傅拯救无望


硬盘已经寄过去超过一周了,一问竟然是还没开始弄???


2023-03-24-14-15-16.png


再过一周,上来就问我分几个区?我要恢复哪些数据?我要恢复的数据在哪个位置?


2023-03-24-14-18-50.png


2023-03-24-14-19-30.png


2023-03-24-14-20-05.png


那好吧,既然给了钱师傅也都放弃了,我也没什么好寄托希望的了。况且经过这三个星期的缓解,心情已经平复了很多,就像时光,回不来了就是回不来了。


自救之路


在把硬盘寄过去的时间里,等待师傅的修复结果的时间里,我并没有闲着(在摸鱼)。


经过调研,数据恢复方法通常有:



  • 硬件损坏,对坏的盘进行修复

  • 误删或逻辑错误等,文件扫描修复

  • git 重置恢复


很明显,这些都不适用于我现在的场景。因为师傅能不能修好是未知的,我只是数据盘没了,系统盘还在。由于 vscode 的数据目录空间占比较小,就没有搬迁到数据盘里,这刚好可以为恢复代码提供了可能。


这是因为新版 vscode 有一个时间线功能,这个时间线数据是默认存储在用户目录下的。


我从 C:/Users/love/AppData/Roaming/Code/User/History 目录中确实找到了很多名为 entries.json 的文件,结构如下:


{
// 配置版本
"version": 1,
// 原来文件所在位置
"resource": "file:///d%3A/git2/cloudcmd/.madrun.mjs",
// 文件历史
"entries": [
{
// 历史文件存储的名称
"id": "YFRn.mjs",
"source": "工作区编辑",
// 修改的时间
"timestamp": 1656583915880
},
{
"id": "Vfen.mjs",
"timestamp": 1656585664751
},
]
}

通过上面的文件大概可以看到,每一个时间点的文件都保存在另一个随机命名的文件里。而网上的方法基本都是自己一个个手动到目录里去根据最新的 id 去找对应的文件内容,然后创建文件并把内容复制出来。


这个过程恢复一两个文件还好,但我这可是要恢复整个 git 工作区,大概有几十个项目上千个文件。


这时候当然是在网上找找有没有什么 vscode 数据恢复 相关的工具,很遗憾找了大半天都没有找到。


气死我了,一气之下就自己写个!


恢复程序开发步骤


毕竟只要数据在磁盘上,无非就是一个文件读取操作的问题,还要拿在这水文章,见谅见谅。


首先考虑需求:



  • 我要实现一个自动扫描 vscode 数据目录

  • 然后以原始的目录结构还原出来,不需要我自己去创建文件夹和文件

  • 如果还原的文件最新的那份不是我想要的,我还能根据时间线进行对比和选择

  • 扫描出来有N个项目时,我可以指定只还原某此项目

  • 我可以搜索文件、目录名或文件内容进行还原

  • 为了方便,我还要一个看起来不太丑的操作界面


大概就上面这些吧。


然后考虑实现:


我要实现一个自动扫描 vscode 数据目录


要的就是我自己连数据目录和恢复地址也不需要填写,就能自动恢复的那种。那么就让程序来自动查找数据目录。经过调研,各版本的 vscode 的数据目录一般保存在这些地方:


参考: stackoverflow.com/a/72610691


  - win -- C:\Users\Mark\AppData\Roaming\Code\User\History
- win -- C:\Users\Mark\AppData\Roaming\Code - Insiders\User\History
- /home/USER/.config/VSCodium/User/History/
- C:\Users\USER\AppData\Roaming\VSCodium\User\History

大概有上面这些路径,当然不排除使用者故意把默认位置修改掉这种边缘情况,或者使用者就只想扫描某个数据目录的情况,所以我也要支持手动输入目录:


  let { historyPath, toDir } = req.body
const homeDir = os.userInfo().homedir
const pathList = [
historyPath,
`${homeDir}/AppData/Roaming/Code/User/History/`,
`${homeDir}/AppData/Roaming/Code - Insiders/User/History/`,
`${homeDir}/AppData/Roaming/VSCodium/User/History`,
`${homeDir}/.config/VSCodium/User/History/`,
]
historyPath = (() => {
return pathList.find((path) => path && fs.existsSync(path))
})()
toDir = toDir || normalize(`${process.cwd()}/re-store/`)

然后以原始的目录结构还原出来……


这就需要解析扫描到的时间线文件 entries.json 了。我们先把解析结果放到一个 list 中,以下是一个完整的解析方法。


然后再把列表转换为树型,与硬盘上的状态对应起来,这样便于调试数据和可视化。


function scan({ historyPath, toDir } = {}) {
const gitRoot = `${historyPath}/**/entries.json`

fs.existsSync(toDir) === false && fs.mkdirSync(toDir, { recursive: true })
const globbyList = globby.sync([gitRoot], {})

let fileList = globbyList.map((file) => {
const data = require(file)
const dir = path.parse(file).dir
// entries.json 地址
data.from = file
data.fromDir = dir
// 原文件地址
data.resource = decodeURIComponent(data.resource).replace(
/.*?\/\/\/(.*$)/,
`$1`
)
// 原文件存储目录
data.resourceDir = path.parse(data.resource).dir
// 恢复后的完整地址
data.rresource = `${toDir}/${data.resource.replace(/:\//g, `/`)}`
// 恢复后的目录
data.rresourceDir = `${toDir}/${path
.parse(data.resource)
.dir.replace(/:\//g, `/`)}
`

const newItem = [...data.entries].pop()
// 创建文件所在目录
fs.mkdirSync(data.rresourceDir, { recursive: true })
const binary = fs.readFileSync(`${dir}/${newItem.id}`, {
encoding: `binary`,
})
fs.writeFileSync(data.rresource, binary, { encoding: `binary` })
return data
})

const tree = pathToTree(fileList, { key: `resource` })
return tree
}

为了方便,我还要一个看起来不太丑的操作界面


我们要把文件树的形式展示出来,还要方便切换。后面决定使用 macos 的文件管理器风格,大概如下。


image.png


如果还原的文件最新的那份不是我想要的,我还能根据时间线进行对比和选择


理论上这里应该要做一个像 vscode 对比文件那样,有代码高亮功能,并且把有差异的字符高亮出来。


实际上,这个需求得加钱。


2023-03-24-15-09-25.png


由于界面是在浏览器里的,需要自动打开,浏览器与系统交互需要一个接口,所以我们使用 opener 来自动打开浏览器。


使用 get-port 来自动生成接口服务的端口,避免使用时出现占用。


  const opener = require(`opener`)
const { portNumbers, default: getPort } = await import(`get-port`)
const port = await getPort({ port: portNumbers(3000, 3100) })
const server = express()
server.listen(port, `0.0.0.0`, () => {
const link = `http://127.0.0.1:${port}`
opener(link)
})

封装成工具,我为人人


理论上我根本不需要什么 UI 界面,也不需要配置,因为我的文件都恢复出来了我还花时间去搞毛线?


实际上,万一别人也有这个恢复文件的需要呢?那么他只要运行下面这条命令代码就能立刻恢复到当前目录啦!


npx vscode-file-recovery

这就是恢复后的文件在硬盘里的样子啦:


2023-03-24-15-22-23.png


所有代码位于:



建议收藏,以备不时之需。/手动狗头


作者:程序媛李李李李李蕾
来源:juejin.cn/post/7213994684262826040
收起阅读 »

面试官:判断图是否有环

web
面试官让我写一个判断图是否有环,我没写出来,心想又是“面试造火箭,入职拧螺丝”。我把面试官pass了。没想到开发中真的遇到了判断有向图是否有环。 图是一种常见的数据结构,分为有向图和无向图。图是由边和节点组成的。 在前端开发中,接触到图的场景不算多。常见的有流...
继续阅读 »

  • 面试官让我写一个判断图是否有环,我没写出来,心想又是“面试造火箭,入职拧螺丝”。我把面试官pass了。没想到开发中真的遇到了判断有向图是否有环。

  • 图是一种常见的数据结构,分为有向图和无向图。图是由边和节点组成的。

  • 在前端开发中,接触到图的场景不算多。常见的有流程、图形可视化等场景。

  • 我们在配置题目流程时遇到了需要判断图是否有环的需求。


背景



  • 简单介绍需求,通过可视化流程配置答题流程,题目与题目之间用线连接,箭头的方向代表下一个题目。回答完当前题目,根据不同的条件,跳到下一题;如果题目流程中有循环,会导致答题流程无法结束,所以需要校验题目的流程中不能有循环。

  • 下面的是有循环,不符合条件


image.png



  • 下面的是无循环,符合条件


image.png
image.png
image.png
image.png


技术方案



  • 根据需求,我们把题目的流程配置抽象成有向图,题目是节点,题目之间的连线是边。

  • 需求里的有无循环,最终可以转换成图是否有环的问题。从图的某个节点作为起点,根据边的方向出发跳到下一个节点,最终是否回到起点。如果回到起点,就是有循环、有环,否则是无循环、无环。

  • 去除题目和各种条件等无关的结构,数据结构如下。


//边
export interface Edge {
id: string;
source: {
cell: string; //这条边的起点的id
[x: string]: any;
};
target: {
cell: string; //这条边的终点的id
[x: string]: any;
};
data: {
type: 'EDGE',
[x: string]: any;
}
[x: string]: any;
};
//节点
export interface Node {
id: string;
data: {
type: 'NODE';
name: string;
[x: string]: any;
};
[x: string]: any;
};
export type Data = Node | Edge;


  • 测试数据如下


const data: Data[] = [
{
id: '1',
data: {
type: 'NODE',
name: '节点1'
}
},
{
id: '2',
data: {
type: 'NODE',
name: '节点2'
}
},
{
id: '3',
data: {
type: 'NODE',
name: '节点3'
}
},
{
id: '4',
source: {
cell: '1'
},
target: {
cell: '2'
},
data: {
type: 'EDGE'
}
},
{
id: '5',
source: {
cell: '1'
},
target: {
cell: '3'
},
data: {
type: 'EDGE'
}
}
];


  • 根据数据结构和测试数据data:Data[],分为以下几个步骤:

    1. 获得边的集合和节点的集合。

    2. 根据边的集合和节点的集合,获得每个节点的有向邻居节点的集合。即以每个节点的为起点,通过边连接的下一个节点的集合。例如测试数据节点1,通过边id4和边id5,可以连接节点2节点3,所以节点1的邻居节点是节点2节点3,而节点2节点3无有向邻居节点。

    3. 最后根据有向邻居节点的集合,判断是否有环。




具体实现



  • 获得边的集合和节点的集合


const edges: Map<string, Edge> = new Map(), nodes: Map<string, Node> = new Map();
const idMapTargetNodes: Map<string, Node[]> = new Map();
const initGraph = () => {
for (const item of data) {
const { id } = item;
if (item.data.type === 'EDGE') {
edges.set(id, item as Edge);
} else {
nodes.set(id, item as Node);
}
}
};


  • 获取有向邻居节点的集合,这里的集合,可以优化成id。我为了方便处理,存储了节点


const idMapTargetNodes: Map<string, Node[]> = new Map();
const initTargetNodes = () => {
for (const [id, edge] of edges) {
const { source, target } = edge;
const sourceId = source.cell, targetId = target.cell;
if (nodes.has(sourceId) && nodes.has(targetId)) { //防止有空的边,即边的起点和终点不在节点的集合里
const targetNodes = idMapTargetNodes.get(sourceId);
if (Array.isArray(targetNodes)) {
targetNodes.push(nodes.get(targetId) as Node);
} else {
idMapTargetNodes.set(sourceId, [nodes.get(targetId) as Node]);
}
}
}
};


  • 最后判断是否有环,有两种方式:递归和循环。都是深度优先遍历。execute是遍历所有节点,hasCycle是把图的某个节点做为起点,判断是否有环。如果以所有节点为起点,都没有环,说明这个图没有环。

    1. 递归。hasCycle判断当前节点是否有环;checked是做优化,防止某些节点多次检查,回溯阶段,把当前节点加入checkedvisited记录当前执行的hasCycle里是否访问过,如果访问过,就是有环。需要注意的是,每次执行hasCycle时,visited用的是一个变量,所以在回溯阶段需要把当前节点从visited里删除。


    const checked: Set<string> = new Set();
    const hasCycle = (node: Node, visited: Set<Node>) => {
    if (checked.has(node.id)) return false;
    if (visited.has(node)) return true;
    visited.add(node);
    const { id } = node;
    const targetNodes = idMapTargetNodes.get(id);
    if (Array.isArray(targetNodes)) {
    for (const item of targetNodes) {
    if (hasCycle(item, visited)) return true;
    }
    }
    checked.add(node.id);
    visited.delete(node);
    return false;
    };
    const execute = () => {
    const visited: Set<Node> = new Set();
    for (const [id, node] of nodes) {
    if (hasCycle(node, visited)) return true;
    checked.add(id);
    }
    return false;
    };


    1. 循环。checked和递归时,作用一样,这里不做说明。visited是用来判断当前的节点是否遍历过,如果遍历过,就是有环。用循环实现深度优先遍历时,需要用来存储当前链路上的节点,即当前节点已经后代节点。并且从里面获取最后一个节点,作为当前遍历的节点。如果当前节点有向邻居节点不为空,就把有向邻居节点的最后一个节点拿出来压栈;如果有向邻居节点为空,就把当前的节点出栈。在压栈时,如果当前节点在visited里,就说明有环,如果没有就要把这个节点加入到visited。在出栈时,把当前节点从visited里删除掉,因为如果不删掉,当一个节点的多个邻居节点最终指向同一个节点时,会判断为有环。


    const checked: Set<string> = new Set();
    const hasCycle = (node: Node) => {
    const { id } = node;
    if (checked.has(id)) return false;
    const stack = [id];
    const visited: Set<string> = new Set();
    visited.add(id);
    while (stack.length > 0) {
    const lastId = stack[stack.length - 1];
    const targetNodes = idMapTargetNodes.get(lastId) || [];
    if (targetNodes.length > 0) {
    const { id } = targetNodes.pop() as Node;
    if (visited.has(id)) return true;
    stack.push(id);
    visited.add(id);
    } else {
    stack.pop();
    visited.delete(lastId);
    }
    }
    return false;
    };
    const execute = () => {
    for (const [id, node] of nodes) {
    if (hasCycle(node)) return true;
    checked.add(id);
    }
    return false;
    };



总结



  • 要掌握常见的数据结构与算法,本例中用到了图、深度优先遍历。


源码



作者:PlayerWho
来源:juejin.cn/post/7213945427853443131
收起阅读 »

给自己编写一个批量填写日报的工具

web
背景 公司要求我们每天填写工时,每天的时间都花在了哪些地方,干了什么。平时没顾得上填,欠下了一屁股工时债。收到邮件催填通知后,发现要补两个月的工时,填了一会儿,感觉变化的只是日期和工作内容,其它的内容项都是固定内容。一天一天填着实费劲,于是决定写一个填写日报的...
继续阅读 »

背景


公司要求我们每天填写工时,每天的时间都花在了哪些地方,干了什么。平时没顾得上填,欠下了一屁股工时债。收到邮件催填通知后,发现要补两个月的工时,填了一会儿,感觉变化的只是日期和工作内容,其它的内容项都是固定内容。一天一天填着实费劲,于是决定写一个填写日报的小工具,只要在js文件中,补充一下每天的工作内容,然后执行node命令,批量完成工时的填写。


思路



  1. 先根据设置的起始结束时间,查询一下当月有多少个工作日,要补多少天的工时。要排除当月每周周末,法定节假日的日期,加上调休补班的日期。

  2. 根据计算出来的需要补充工时的天数,编辑好要补填的工作内容条数,然后批量发送网络请求,完成工时的填写。


工作日查询实现


发现了一个叫蛙蛙工具的网站,免费提供接口给第三方使用,可以用来查询工作日。每分钟限制查10次。下图是抓取的响应数据:重点说一下要用到的weekend_date_listholiday_date_list字段;



  • weekend_date_list 周末日期

  • holiday_date_list 法定节假日


只要排除这两个数组中的日期,剩下的就是查询日期时间段工作日。


image.png


思路有了,来看看实现。首先要写好发送查询请求的逻辑,要知道请求地址,请求参数,请求参数格式,响应数据内容。其次,拿到响应结果后,生成一个从开始日期到结束日期,格式为YYYY-MM-DD的数组,从这个数组中剔除周末和法定节假日,剩余的日期就是工作日,知道工作日的天数后,就知道要写几天的工作日报。代码如下:


import axios from "axios";
import dayjs from "dayjs";
import { startDate,endDate } from "./config.js";


// 查询从本月的工作日
export const queryWorkingDay = () => {
return new Promise((resolve, reject) => {
const url = "https://www.iamwawa.cn/home/workingday/ajax";
const params = {
start_date: startDate.format("YYYY-MM-DD"),
end_date: endDate.format("YYYY-MM-DD"),
};

axios
.post(url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded" } })
.then(({ data: res }) => {
const { status, data, info } = res;
const {
// 平常的周末
weekend_date_list = [],
// 法定节假日
holiday_date_list = [],
// 工作日天数
working_date_count,
} = data;

// console.log(data);

// 生成设置的当月起始结束日期数组
const dayOfMonth = genNumArr(startDate.date(), endDate.date()).map((day) =>
dayjs().date(day).format("YYYY-MM-DD")
);

// 需要排除的法定节假日和周末日期
const excludeDays = [
weekend_date_list.map((item) => item.date),
holiday_date_list.map((item) => item.date),
].flat();

// 工作日
const workDays = dayOfMonth.filter((day) => !excludeDays.includes(day));

// console.log(status,data,info);
console.log(`本月你需要补充${working_date_count}天日报`);
console.log(`需要填写的日期:`);
workDays.forEach((day) => {
console.log(day);
});

console.log(`需要排除的日期:`);
excludeDays.forEach((day) => {
console.log(day);
});

resolve(workDays);
});
});
};

// 生成连续数字数组
function genNumArr(start, end) {
return Array.from(new Array(end + 1).keys()).slice(start);
}


提交工时实现


先登录填报工时网站, 手动填写一条,在调试模式下查看一下请求地址和请求参数。
1679728451342.png
请求地址我就不贴出来了,这里只提供思路,请求数据为:


{
"workDate": "2023-03-14",
"tapdId": null,
"groupId": 12,
"projectId": 159,
"lineId": 2,
"taskId": 16,
"workContent": "xxxxxx",
"workHours": 8
}

如法炮制查询工作日的方法,发起工时提交请求,结果吃了闭门羹。提示没有权限。


image.png


后面经过排查,发现网络请求的请求头,需要带一个authorization的参数,服务器根据这个参数判断有没有提交权限。这个参数你必须登录原网站才能拿到,把这个参数复制出来,配置到代码中,再发请求,这次很顺利的提交了。


image.png


提交数据跑通之后,要实现批量提交数据就很Easy了,循环调用提交单条数据的接口就可以了。有个细节需要注意一下,提交请求太快,服务器会返回错误,所以每个请求之间加了一个500ms的延时。提交工时的代码如下:


import axios from "axios";
import { queryWorkingDay } from "./queryDay.js";
import { authorization, workContentList } from "./config.js";

// 提交每月的工时
export const submitMonthWorkHour = async () => {
const workDays = await queryWorkingDay();

for (let index = 0, len = workContentList.length; index < len; index++) {
await submitEachDayData(workDays[index], workContentList[index]);
}
};

/**
* 提交每天的工时数据
* @param {*} workDate 工作日期
* @param {*} workContent 工作内容
*/

const submitEachDayData = (workDate, workContent) => {
return new Promise((resolve, reject) => {
const url = "https://xxx/xxx",
authorization,
"Content-Type": "application/json",
};

const params = {
workDate,
workContent,
tapdId: null,
groupId: 12,
projectId: 159,
lineId: 2,
taskId: 16,
workHours: 8,
};

setTimeout(() => {
axios
.post(url, params, { headers })
.then(({ data }) => {
const { ret, retdata, retmsg } = data;
// if (ret === 0) {
console.log(`${workDate}--${retmsg}`);
resolve("ok");
// }
})
.catch((err) => reject(err));
}, 500);
});
};

主流程实现


在package.json中配置两条指令,一条用于查询设置的起始结束时间有多少个工作日,需要补充多少天的工作日报,接着在上面的submitMonthWorkHour方法中,手动编辑,给工作内容列表workContentList填充数据,一条数据对应一天的工作日报。填写完之后,执行提交命令。


{
"license":"MIT",
"scripts": {
"query": "node main.js query",
"submit": "node main.js submit"
},
"dependencies": {
"axios": "^1.3.4",
"dayjs": "^1.11.7"
},
"type": "module",
"devDependencies": {}
}


顺便说一下,node v9+版本,若要使用import/export语法, 需要在package.json中指定 "type": "module"


在主函数中, 根据不同的指令执行不同的操作。实际使用时, 肯定是要先调用yarn query查询补充多少天日报才行。


import { queryWorkingDay } from "./queryDay.js";
import { submitMonthWorkHour } from "./submitData.js";

main();
// 主流程
function main() {
const argv = process.argv;
// 先查询需要补充多少天日报
if (argv.includes("query")) {
queryWorkingDay();
} else if (argv.includes("submit")) {
submitMonthWorkHour();
} else {
console.log('指令错误');
process.exit(1);
}
}


把配置数据放到config.js中, 这里要说一下dayjs().date()dayjs().daysInMonth(), 它们的执行结果都是一个数字,代表的含义是这个月的日期,默认开始时间是当天日期,结束时间是月底日期。可手动修改。


import dayjs from "dayjs";
// 设置查询工作日的开始时间
export const startDate = dayjs().set("date", dayjs().date());
export const endDate = dayjs().set("date", dayjs().daysInMonth());
// 每次先登录一下填报工时的网站,把http请求头中的authorization复制出来
export const authorization = "";
// 手动填写需要补充的工时
export const workContentList = [""];

结语


至此,批量提交日报的小工具就开发完了。爱因斯坦说, 比知识更重要的是想象力。文中列举的知识点大家可能都懂,但是要把这些知识串接起来,开发一个有实用价值的工具,是需要一点灵动和想象力的。而灵动来源于优化意识,需要一个善于发现问题的心灵,洞悉生活中,工作中的痛点,寻找改进之法。 这个小工具已上传至码云,感兴趣的朋友可点击这里下载


作者:去伪存真
来源:juejin.cn/post/7214349925064802362
收起阅读 »

去哪儿低代码平台跨端渲染方案及落地

web
作者介绍 何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。 一、低代码平台跨端渲染现状 去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小...
继续阅读 »

作者介绍


何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。


一、低代码平台跨端渲染现状


去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小程序、touch和 APP 多个平台。去哪儿低代码平台是基于 Shark 框架开发。Shark 是一款有着跨平台(一套代码支持跨端渲染)、按需加载(仅加载页面配置所需代码文件)等特性的类 React 框架。有着缓存、消息中心等多种能力。Shark 和低代码平台的无缝结合,给现在低代码平台带来了跨端、“所见即所得”得等多种特性。而“所见即所得”,就是一种动态加载的功能:我们在低代码平台上配置一个页面所需组件和对应的各种属性,可以及时的在各个端上看到。


随着低代码平台的推广应用,接入了越来越多的业务的核心流程,对于加载性能上的要求越来越高。在当前阶段,低代码平台在 APP 端是利用 H5 的方式来渲染页面。但是这种方式首先需要加载 WebView ,然后才会去绘制页面,导致白屏时间比较久。


去年遇到了一个契机,门票业务在对主流程进行了大改版,当时人力相对比较紧张,而且业务侧希望页面的组件是可配置的,对于这个挑战,结合低代码平台进行了思考,代码平台天然是可视化配置的,也支持多端运行,美中不足的是在端内是以 H5 方式运行的,如果在端内支持 RN 运行,补齐性能的短板,整体来讲将会是一个很好的方案。


二、APP 端替代 HY 方案调研以及可行性分析


说到既满足灵活发版,又能跨平台,还有较高的性能来解决前面的白屏时间久和性能差的问题,要同时满足这三个特点的技术。当前状况去哪儿 APP 是以React Native 为主的;于是我们提出了一个想法:Shark 和 React Native 能否结合一下呢?结合两家之长处,即实现灵活可配,又能保持高性能和跨平台,将扩大我们低代码平台的边界,提供更多可能性,于是我们开始了 Shark 和 RN 的结合探索之旅;


首先,我们开始分析 Shark 组件和 React Native 组件之间的区别;一个 Shark 组件主要是由 JS 文件以及 Scss 文件两个文件组成。那么作为一个类React框架,它和 RN 的代码有多大的区别那?通过下图对比我们可以看到差异点(左图是 Shark 组件代码,右图是 RN 组件代码):


图片


通过上面的对比,我们可以看到 Shark 和普通 RN 代码的区别在



  • 布局名称、方式和 RN 区别较大

  • 语法树标签主要是 View,可以看到交互和文字展示都是 View ,但在 RN中是不同标签

  • 标签属性的不同,在 RN 和 Shark 中点击事件不同等

  • ......


对比完 JS 文件,那布局文件的差异又有多大那?依旧可以通过下图的对比看到差异点


图片


上面是布局Scss文件部分,可以看到区别主要集中在



  • 布局名的嵌套

  • 单位的不同

  • 属性名和RN不同


通过上面的分析观察,我们可以看到 Shark 的代码和 RN 的代码虽然具有一定的区别但是相似度还是很高的。那如果我们先手动将这些差异点修改,能否将这份代码在 APP 上运行起来?下面我们先完成第一步:修改差异点。


图片


我们将手动修改后的代码嵌入在 RN 业务组件中,通过实验得知,这段代码是可以通过编译并正常运行的。


通过这些分析和实验得知,通过修改是可以将 Shark 的代码在 RN 上运行的。在上面的实验中,我们是通过手动修改的方式来达到目的,但是在实际项目中这样做肯定是不切实际的,我们可以通过 Babel 来编写自己的转化器,来达到批量转换的目的。


三、APP端实践


方案简述


通过上面的分析可知,Shark 的核心代码是可以通过 Babel 转换为 RN 的代码并在 APP 上直接运行的。在整个过程中是“代码转化”和“运行时能力提供”两个部分。通过下面的图,我们可以看到整体流程


图片


编译


整个编译时期我们的任务就是将 Shark 的源码转化为 RN 可以直接使用的代码。我们利用 Babel 编写了自己的工具:shark-cli,通过这个工具我们实现代码的适时转换。


- JS文件


JS 文件主要处理包括语法树(标签的替换、布局抹平、标签属性替换等)和JS( document 等的处理)两个部分。针对其中几个主要的问题展开讨论


标签以及属性的转换:


Shark 中绝大部分标签都是 View ,但是在 RN 中不同,RN 中不同的标签会承担不同的功能。比如在 Shark 里 View 还可以接收点击事件,但是在 RN 中只能是 TouchableOpacity 等少数组件。针对这一情况我们根据一些属性,当发现是一些特殊组合的时候就会在代码中替换组件。通过下面的映射表,我们将不同标签和属性的组合映射到 RN 中对应的标签和属性。


shark组件RN组件
ViewView、TouchableOpacity(当有onClick时转换为此组件)
InputTextInput
TextText
ImageImageImage + TouchableOpacity (当有onClick时转换)
ScrollViewScrollView

import的处理:


和 Shark 不同,RN 需要将使用到的标签、组件显示的引入并指明它在哪个库当中,比如我们经常遇到的下面的代码。


图片


为此,我们在代码转换时,准备了一个映射表,里面针对 react-native 的组件,可以直接 import 。但是这样并不能很好的支持,因为三方库和标签并不一样并不能枚举,为了解决这个问题我们提供了另一个能力,支持在标签上新增了两个属性,指定标签名称和来源来达到这一目的


图片


嵌套布局的抹平:


整个嵌套算是 Shark 和 RN 上分歧最大的地方,布局上要将Shark多种写法统一成 RN 的写法,其次就是要将 Shark 嵌套的布局在 RN 上抹平。过程如下图所示。


图片


对于不同的 class 或者 style 写法,在 babel 中都是不同的节点要单独处理,对于不同的节点我们应用不同的规则。我们收集到统一的格式之后,就可以运用一个规则去处理抹平。在 scss 文件处理的过程中,嵌套文件拿到的最后的属性名都是多层拼接完成的,比如 styles. 层级1_层级2_层级3,但是在 js 文件中处理完的都是 styles. 属性名,这就引出了嵌套布局抹平的问题。我们维护了一个当前布局层级的栈,我们在每一层 View 进入的时候入栈,记录一次布局名称,每一层 View 结束的时候作为出栈。在前面处理 scss 文件时,我们拿到了所有布局的嵌套关系,根据这个栈和我们拥有的嵌套关系去遍历,去匹配是否有布局嵌套,如果有就替换如果没有则进行下一次匹配。通过这种方式我们来解决嵌套布局的问题。


- Scss文件


图片


scss 文件和 RN 使用的布局属性,其实差异不大。我们最重要的是处理类型名的嵌套,整个的转换我们分为两步,每一步去处理不同的问题。


第一步:将 scss 文件转换为 css 文件。在转换的同时,我们将单位 rem 删除、嵌套的类名抹平,这时得到了我们想要的中间文件 .css 文件。


第二部:将 css 文件转换为 RN 的布局文件即 style.js 。在这一过程中,我们要记录所有嵌套布局的路径给将来index.js去处理嵌套布局。同时为了解决属性上的问题,我们通过配置文件将不支持的属性删除,并替换不同属性值的问题。通过这个方式我们获取到 RN 可使用的一个 object 对象并保存为一个 style.js 的文件。


整体 scss 文件的转换思路如下图所示


图片


Babel详解


编写自己的Babel插件


AST


整个工作流程可以描述为 AST → visitor 修改 AST→ 获取目标代码。在这其中,理解清楚 AST 十分重要,我们之所以需要将代码转换为 AST 也是为了让计算机能够更好地进行理解。我们可以来看看下面这段代码被解析成 AST 后对应的结构图:


以这一行代码为例子,它的语法树如下


图片


所有的 AST 根节点都是 Program 节点,从上图中我们可以看到解析生成的 AST 的结构的各个 Node 节点都很细微,github.com/babel/babyl… (不过这个文档并没有说明具体输出的样式,有时同一个节点,输入不同参数输出的代码可以差距非常大,尤其是在格式化时这点就非常重要)这个文档对每个节点类型都做了详细的说明,你可以对照各个节点类型在这查找到所需要的信息。通过astexplorer.net/ 可以有效的观察代码对应的节点,以及节点的各种属性关系。熟悉了 AST 之后,就可以通过 Visitor 来遍历节点,更改我们想要的代码。


Visitor


image.png


在 visitor 中引入了 path 的概念,它中包含了节点的信息以及节点和所在的位置,以供对特定节点进行操作。不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path 对象包含的属性和方法主要如下:


image.png


整个 visitor 的过程,可以简述为通过修改 path 来改变 AST 语法树的过程。


image.png


如上所示,我们在修改的过程中针对 path 对替换或者修改,生成新的节点,就可以达到我们的目标。以我们这次的代码为例子,当发现 onClick 属性时,我们要将 View 标签替换为 TouchableOpacity 标签,onClick 替换为 onPress 。


image.png


通过语法树分析,onClick 在语法树中的层级是 JSXOpeningElement → attributes → JSXAttribute → JSXIdentifier → name。这时才找到了 name = "onClick"。


image.png


此时我们在 visitor 中找到 JSXIdentifier 并通过 path 找到 name 


image.png


我们找到了对应的节点后,问题就是要替换成什么。通过上一步分析语法树的方式我们可以知道,onClick 转换为 onPress ,语法树上只是对应的 name 有变化。此时我们需要生成一个新的 JSXIdentifier 类型的节点。对应到我们的插件里如下图所示:


image.png


这样,我们就达到了转换的目的。通过总结不同的节点的替换和修改,就能达到修改语法树,转换代码的目的。


运行时


简述


拿到了通过编译态转换好的 RN 代码后,就是运行时要做的工作了。运行时的主要工作可以分为和低代码平台通讯获取需要渲染哪些组件、获取这些组件的实例进行组装,最后进行渲染。整体工作流程如下图所示。


image.png


可以把整个容器可以理解为一个 RN 业务组件。这个容器包含了缓存、动态加载、页面的绘制、提供 Shark 能力等几个部分。


提供Shark能力


整个 Shark 的能力包括几个部分:ability、core 以及 Qunar 特性,三个部分。其中 ability 是 shark 和 core 是 Shark 的核心能力。他们包括了如下能力:



  • message

  • request

  • jump

  • logger

  • ……


在运行时,我们要提供这些能力,让转换后的代码能够在 RN 上无缝运行。


缓存


如果每次进去页面都需要实时获取页面配置信息,这样很影响用户体验。针对这个问题,我们设计了一套缓存策略,让用户可以无感知的更新配置。我们缓存了不同页面的配置信息,在用户进入页面时可以通过直接读取缓存,省下了等待接口的时间。在用户进入页面的同时,去获取最新的配置信息来更新缓存内容。缓存包括内置配置文件和 cache 的二级缓存系统,当进入页面时会实时更新 cache ,如果有 cache 则优先读 cache ,否则就读取内置配置文件。整体流程如下


image.png


动态加载


有了上面两点能力,我们就可以实现在文章开头提到的动态加载的能力。由于 RN 只能渲染已经加载完的代码,为了达到这个目标,在容器端获取页面的配置信息,通过这个配置文件,我们能够获取到页面的组件信息,包括需要哪些组件、每个组件的属性,由于 RN 框架限制做不到去动态的加载一个新的组件,只能加载已经打包的组件,否则还是要去更新版本。通过一个专门的组装器,注册组件、解析组件属性并赋值。通过这个组装器,我们来选择对应的页面的对应的组件,来动态加载组件或更新组件信息。我们又借助上面提到的缓存的能力,来减少了用户的感知时间。


image.png


在获取到了页面配置信息之后,我们将配置信息交给容器。容器可以通过组件名称来找到需要渲染的组件。容器可以通过这个配置文件实现组件的选择、props的传递,来达到低代码平台组件所见即所得的功能。


成果展示


编译态代码转换结果。左图是低代码平台组件的源码,右图是经过编译态转换后的结果。


image.png


最后实际效果:
下方图 1 可以看到我们在低代码平台上配置的信息,图 2 是对应 APP 内页面的截图。可以看到左侧在平台上配置各个组件,最后可以做到在平台上配置组件属性和组件修改,在 APP 内部可以即使生效以及组件的灵活上下线。


image.png


(图1)


image.png


(图2)


针对我们开始提到的性能问题,根据TTI监控指标,我们可以看到,P90 和 P50 的平均时间都在 2 秒之内的,这样看整体的方案是达到了我们最初的目标。


image.png


四、未来规划


经过在实际业务线两个页面的尝试,整个低代码平台的跨平台能力已经经受了实际项目的验证。但是在开发人员实际使用的过程中还是有可以优化的地方,来提升开发人员的转换效率。将来考虑能将一些属性或者能力做成配置开放给开发人员,这样能做到更加个性化。


现在只有一个业务线尝试过这个功能,但是整个低代码平台其实是很大的一个平台且使用业务线众多,后面也要考虑针对业务线的推广,来扩大使用人群。


作者:去哪儿技术沙龙
来源:juejin.cn/post/7213665606628327461
收起阅读 »

svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退

web
在之前的系列文章中,我们介绍了图形编辑器基础的 移动、缩放、旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。 一、右键菜单 1. 右键菜单底层...
继续阅读 »

在之前的系列文章中,我们介绍了图形编辑器基础的 移动缩放旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。



一、右键菜单


1. 右键菜单底层方案


关于右键菜单的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:



功能:



  • 每个菜单项都可以独立设置是否禁用、是否隐藏

  • 支持子菜单

  • 支持显示icon图标、提示语、快捷键等

  • 与业务完全解耦,通过简单配置即可定制出功能各异的菜单


menu



  • 使用通用右键菜单组件演示:


import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
{ text: '复制', key: 'copy' },
{ text: '粘贴', key: 'paste', shortcutKeyDesc: `${cmd}+V` },
{
text: '对齐',
key: 'align',
children: [
{ text: '水平垂直居中', key: 'horizontalVerticalAlign' },
{ text: '水平居中', key: 'horizontalAlign' },
],
},
];

export () => {
const containerDomRef = React.useRef();
// 菜单点击触发
const handleMenuTrigger = (menu: IContextMenuItem) => {
console.log(menu); // { text: '复制', key: 'copy' }
// 这里处理触发菜单后的逻辑....

};
return (
<div
ref={containerDomRef}
style={{ position: 'relative' }}>

<ContextMenu
getContainerDom={() =>
containerDomRef.current}
menuList={menuList}
onTrigger={handleMenuTrigger}
/>
</div>

);
};


2. 图形编辑器右键菜单定制


上面的文章介绍了一种通过数据配置生成右键菜单的通用解决方案,它和业务没有任何的耦合,是一个独立功能。


但是仅有上面的功能在面临复杂业务的时候使用体验就不是很好了,例如:



  • 某个特殊的精灵想右键菜单在自己身上触发的时候,显示一个独属于自己的菜单项。

    • 比如富文本精灵提供清除内容富文本格式的功能,把加粗、字体大小等等样式全部清除变为普通无样式文本




这里我们为了提升右键菜单的扩展性易用性,会基于上面的方案做一些抽象和定制,例如:



  1. 菜单配置数据提供注册机制:以便于在不同的模块里维护属于自己模块的菜单项功能;

  2. 每个菜单项都可以独立定义点击触发时的操作:不在一个同一个onTrigger触发器里分发处理每个菜单项的点击逻辑;

  3. 为菜单项触发时处理函数里添加图形编辑器相关的上下文,以方便使用;


import { IContextMenuItem } from "context-menu-common-react";
import ContextMenu from "context-menu-common-react";
import React from "react";
import { ISprite, IStageApis } from "../../demo3-drag/type";
import { GraphicEditorCore } from "../../demo3-drag/graphic-editor";

export * from "context-menu-common-react";

export interface ITriggerParmas {
stage: GraphicEditorCore;
activeSpriteList: ISprite[];
menuItem: IEditorContextMenuItem;
}

export type IEditorContextMenuItem = IContextMenuItem & {
onTrigger: (params: ITriggerParmas) => void;
};

interface IProps {
getStage: () => GraphicEditorCore;
}

interface IState {
menuItemList: IContextMenuItem[];
}

export class EditorContextMenu extends React.Component<IProps, IState> {
triggerList: any[] = [];

stage: GraphicEditorCore | null = null;

menuItemMap: Record<string, IEditorContextMenuItem> = {};

state: IState = {
menuItemList: []
};

componentDidMount() {
this.stage = this.props.getStage?.();
}

public registerItemList = (_menuItemList: IEditorContextMenuItem[]) => {
const { menuItemList } = this.state;
_menuItemList.forEach((e) => {
this.menuItemMap[e.key] = e;
});
this.setState({ menuItemList: [...menuItemList, ..._menuItemList] });
};

public registerItem = (menuItem: IEditorContextMenuItem) => {
const { menuItemList } = this.state;
this.menuItemMap[menuItem.key] = menuItem;
this.setState({ menuItemList: [...menuItemList, menuItem] });
return () => this.remove(menuItem);
};

public remove = (menuItem: IEditorContextMenuItem | string) => {
const { menuItemList } = this.state;
const list = [...menuItemList];
const key = typeof menuItem === "string" ? menuItem : menuItem.key;
const index = list.findIndex((e) => e.key === key);
delete this.menuItemMap[key];
if (index !== -1) {
list.splice(index);
this.setState({ menuItemList: list });
}
};

public has = (menuItem: IEditorContextMenuItem | string) => {
const key = typeof menuItem === "string" ? menuItem : menuItem.key;
return Boolean(this.menuItemMap[key]);
};

handleTrigger = (menuItem: IContextMenuItem) => {
const { stage } = this;
const { activeSpriteList } = stage?.state || ({} as any);
const item = this.menuItemMap[menuItem?.key];
if (typeof item?.onTrigger === "function") {
item?.onTrigger({
menuItem,
stage: this.stage as any,
activeSpriteList
});
}
};

render() {
const { menuItemList } = this.state;
return (
<ContextMenu
getContainerDom={() =>
document.body}
menuList={menuItemList}
onTrigger={this.handleTrigger}
/>

);
}
}



3. 一些通用的右键操作方法


3.1 复制


const handleCopy = ({ stage, activeSprite }) => {
const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
return navigator.clipboard.writeText(jsonData);
};
const menuItem: IContextMenuItem = {
text: '复制',
key: 'copy',
// 此菜单项是否禁用
disabled: ({ activeSprite }) => Boolean(activeSprite),
onTrigger: handleCopy,
};

stage.apis.contextMenu.registerItem(menuItem);

3.2 粘贴


const handlePaste = async ({ stage }) => {
const jsonData = await navigator.clipboard.readText();
const jsonObj = JSON.parse(jsonData);
if (jsonObj?.type === 'activeSprite') {
stage.apis.addSpriteToStage(jsonObj.content);
}
};
const menuItem: IContextMenuItem = {
text: '粘贴',
key: 'paste',
onTrigger: handlePaste,
};

stage.apis.contextMenu.registerItem(menuItem);

3.3 删除


const handleRemove = async ({ stage, activeSprite }) => {
stage.apis.removeSprite(activeSprite);
};
const menuItem: IContextMenuItem = {
text: '删除',
key: 'remove',
onTrigger: handleRemove,
};

stage.apis.contextMenu.registerItem(menuItem);

3.4 剪切


const handleCut = async ({ stage, activeSprite }) => {
const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
// 先复制, 再删除
const res = await navigator.clipboard.writeText(jsonData);
stage.apis.removeSprite(activeSprite);
return res;
};
const menuItem: IContextMenuItem = {
text: '剪切',
key: 'cut',
onTrigger: handleCut,
};

stage.apis.contextMenu.registerItem(menuItem);

3.5 撤销、重做


const menuItem: IContextMenuItem = {
text: '撤销',
key: 'redo',
onTrigger: ({ stage }) => stage.apis.redo(),
};

stage.apis.contextMenu.registerItem(menuItem);

const menuItem: IContextMenuItem = {
text: '重做',
key: 'undo',
onTrigger: ({ stage }) => stage.apis.undo(),
};
stage.apis.contextMenu.registerItem(menuItem);

4. 精灵注册属于自己的右键菜单快捷操作


// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {

componentDidMount() {
const { stage } = this.props;
const { contextMenu } = stage.apis;
if (!contextMenu.has('clearRichTextFormat')) {
const menuItem: IContextMenuItem = {
text: '清除富文本格式',
key: 'clearRichTextFormat',
// 显示此菜单项的条件
condition: ({ sprite }) => sprite.type === 'RichTextSprite',
onTrigger: this.handleClearTextFormat,
};
stage.apis.contextMenu.registerItem(menuItem);
}
}

componentWillUnmount() {
if (contextMenu.has('clearRichTextFormat')) {
stage.apis.contextMenu.remove('clearRichTextFormat');
}
}

handleClearTextFormat = () => {
const { stage, sprite } = this.props;
const { content } = sprite.props;

const text = clearTextFormat(content);
const newProps = { ...sprite.props, content: text };
stage.apis.updateSpriteProps(sprite.id, newProps);
}

render() {
const { sprite } = this.props;
const { props, attrs } = sprite;
const { content } = props;
return (
<foreignObject
<span {...props}>
{content}</span>
</foreignObject>

);
}
}


二、快捷键


1. 图形编辑器快捷键定制


/**
* 快捷键配置
*/

export const shortcutOpts: IShortcutOpt[] = [
{
name: ShortcutNameEnum.copy,
title: '复制',
keys: ['c'],
containerSelectors: ['.div-1'],
option: { metaPress: true },
// 触发当前快捷键时执行
onTrigger: ({ opt, event }) => {
// 这里处理触发后的逻辑
},
},
{
name: ShortcutNameEnum.undo,
title: '重做',
keys: ['z'],
option: { metaPress: true, shiftPress: true },
// 触发当前快捷键时执行
onTrigger: ({ opt, event }) => {
// 这里处理触发后的逻辑
},
},
];

export default () => {

useEffect(() => {
// 实例化
const keyboardOpt = new KeyBoardOperate({
preventDefault: true,
onTrigger: (opt: IShortcutOpt, e) => {
console.info('bingo', opt, e);
// 所有快捷键触发后都会执行
},
});
shortcutOpts.forEach(e => keyboardOpt.registerShortcutKey(e));
return () => {
keyboardOpt.removeAllEventListener();
};
}, []);

return null
};

2. 精灵注册属于自己的快捷键操作


// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {
componentDidMount() {
const { stage } = this.props;
const { shortcutKey } = stage.apis;
if (!shortcutKey.has('clearRichTextFormat')) {
const opt: IShortcutOpt = {
title: '清除富文本格式',
name: 'clearRichTextFormat',
keys: ['c', 'l'],
option: { metaPress: true },
onTrigger: this.handleClearTextFormat,
};
stage.apis.shortcutKey.registerItem(menuItem);
}
}
componentWillUnmount() {
if (stage.apis.shortcutKey.has('clearRichTextFormat')) {
stage.apis.shortcutKey.remove('clearRichTextFormat');
}
}
render() {
...
}
}


3. 快捷键底层方案


这里的实现思路和右键菜单的注册思路类似,为了快捷键的稳定性和兼容性我们借助hotkeys-js这个包来实现快捷键的监听。


export interface IShortcutOpt {
// 快捷键的名字,不能重复,否则会报错
name: string;
// 按键数组
keys: string[];
// 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
containerSelectors?: string[];
// 名称
title?: string;
// 配置
option?: IShortcutOption;
// 触发回调
onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

上面就是一个快捷键的配置,我们的设计如下:



  • 使用option表示是否需要meta、shift等键按下

  • 使用keys表示监听的键,例如复制['c']

  • onTrigger表示快捷键被触发了时执行的回调

  • 同样支持 registerShortcutKey方法来注册上面的单个快捷键


以下是快捷键的源码:


import hotkeys from 'hotkeys-js';
import { getHotkeysStr, selectParents } from './helper';
import { IShortcutOpt, ITriggerCallback } from './types';

export class KeyBoardOperate {
// 快捷键映射
shortcutKeyMap: Record<string, IShortcutOpt[]> = {};

onTrigger: ITriggerCallback;

preventDefault: boolean = true;

clickEle: any;

constructor({
shortcutOpts = [],
preventDefault = true,
onTrigger = () => '',
}: {
shortcutOpts: IShortcutOpt[];
preventDefault?: boolean;
onTrigger?: ITriggerCallback;
}
) {
this.preventDefault = preventDefault;
this.onTrigger = (opt: IShortcutOpt, e: KeyboardEvent) => {
opt.onTrigger?.({ opt, event: e });
onTrigger?.(opt, e);
};
shortcutOpts.forEach(opt => this.registerShortcutKey(opt));
document.addEventListener('click', (e: MouseEvent) => {
this.clickEle = e.target;
});
console.log('yf123', this);
}

/**
* 注册快捷键
*
* @param shortcutOpt - 快捷键操作
* @param shortcutOpt.name - 快捷键操作名字,同时作为映射的key,要保证唯一性
* @param shortcutOpt.keys - 按键数组
* @param shortcutOpt.option - 配置
*/

public registerShortcutKey(shortcutOpt: IShortcutOpt) {
const { name, keys } = shortcutOpt;
if (!Array.isArray(keys)) {
throw new Error(`注册快捷键时, keys 参数是必要的!`);
}
// 避免重复
if (this.shortcutKeyMap[name]) {
throw new Error(`快捷键操作「${name}」已存在,请更换`);
}
this.addEventListener(shortcutOpt);
}

public removeAllEventListener() {
hotkeys.unbind();
}

private addEventListener(shortcutOpt: IShortcutOpt) {
const keyStr = getHotkeysStr(shortcutOpt);
hotkeys(keyStr, (e: KeyboardEvent) => this.handleKeyTrigger(e, shortcutOpt));
}

private removeEventListener(shortcutOpt: IShortcutOpt) {
const keyStr = getHotkeysStr(shortcutOpt);
hotkeys.unbind(keyStr);
}

private handleKeyTrigger = (event: KeyboardEvent, shortcutOpt: IShortcutOpt) => {
if (this.preventDefault) {
event.preventDefault();
}
// 如果配置了生效区域,但是触发快捷键的节点不在容器里,就认为是无效操作
const { containerSelectors = [] } = shortcutOpt;
if (containerSelectors.length > 0) {
const parents = selectParents(this.clickEle, containerSelectors);
if (parents.length === 0) {
return;
}
}
// 成功命中快捷键
this.onTrigger(shortcutOpt, event);
};
}



  • 工具函数


import { IShortcutOpt } from './types';

// 利用原生Js获取操作系统版本
export function getOS() {
const isWin =
navigator.platform === 'Win32' || navigator.platform === 'Windows';
const isMac =
navigator.platform === 'Mac68K' ||
navigator.platform === 'MacPPC' ||
navigator.platform === 'Macintosh' ||
navigator.platform === 'MacIntel';
if (isMac) {
return 'Mac';
}
const isLinux = String(navigator.platform).includes('Linux');
if (isLinux) {
return 'Linux';
}
if (isWin) {
return 'Win';
}
return 'other';
}

export const isMac = getOS() === 'Mac';

export const getMetaStr = () => (isMac ? 'command' : 'ctrl');

export const getHotkeysStr = (opt: IShortcutOpt) => {
const { metaPress, shiftPress, altPress } = opt.option || {};
let key = '';
if (metaPress) {
key += `${getMetaStr()}+`;
}
if (shiftPress) {
key += 'shift+';
}
if (altPress) {
key += 'alt+';
}
key += `${opt.keys.join('+')}`;
return key;
};

export const findDomParents = (dom: any) => {
const arr: any = [];
const findParent = (e: any) => {
if (e?.parentNode) {
arr.push(e);
findParent(e.parentNode);
}
};
findParent(dom);
return arr;
};

export const selectParents = (dom: any, selectors: string[]) => {
const results: any[] = [];
const parents = findDomParents(dom);
selectors.forEach((selector: string) => {
for (const node of parents) {
const selectorName = selector.slice(1);
if (selector.startsWith('#')) {
if (
node.getAttribute('id') === selectorName &&
!results.find(e => e === node)
) {
results.push(node);
}
} else if (selector.startsWith('.')) {
if (
node.classList.contains(selectorName) &&
!results.find(e => e === node)
) {
results.push(node);
}
}
}
});
return results;
};


  • types


export interface IShortcutOption {
metaPress?: boolean;
shiftPress?: boolean;
altPress?: boolean;
}

export type ITriggerCallback = (opt: IShortcutOpt, e: KeyboardEvent) => void;

export interface IShortcutOpt {
// 快捷键的名字,不能重复,否则会报错
name: string;
// 按键数组
keys: string[];
// 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
containerSelectors?: string[];
// 名称
title?: string;
// 配置
option?: IShortcutOption;
// 触发回调
onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

三、撤销回退


history.gif


1. 撤销回退底层方案


关于历史记录的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:



这个方案比较简单,是存储全量数据的,如果需要使用仅存储增量数据,欢迎在评论区分享方案讨论~


2. 图形编辑器中使用撤销回退


我们需要在图形编辑器里操作精灵列表spriteList数据的核心api里加上历史记录相关的操作。



export class GraphicEditorCore extends React.Component<IProps, IState> {
private readonly registerSpriteMetaMap: Record<string, ISpriteMeta> = {};

// 历史记录 - 添加
public pushHistory = (spriteList: ISprite[]) => {
history: string[] = [];

const { history } = this;
history.push(
JSON.stringify({ ...this.getMetaData(), children: spriteList }),
);
};

// 历史记录 - 撤销
public undo = () => {
const { history } = this;
if (history.getLength() > 1) {
history.undo();
history.currentValue &&
this.setSpriteList(JSON.parse(history.currentValue).children, false);
}
};

// 历史记录 - 重做
public redo = () => {
const { history } = this;
history.redo();
history.currentValue &&
this.setSpriteList(JSON.parse(history.currentValue).children, false);
};

public addSpriteToStage = (sprite: ISprite | ISprite[]) => {
const { spriteList } = this.state;
const newSpriteList = [...spriteList];
if (Array.isArray(sprite)) {
newSpriteList.push(...sprite);
} else {
newSpriteList.push(sprite);
}
this.setState({ spriteList: newSpriteList });
// 在操作精灵列表数据的方法里都加上历史记录的操作即可
this.pushHistory(newSpriteList);
};

setSpriteList = (newSpriteList: ISprite[]) => {
this.setState({ spriteList: newSpriteList });
};


四、总结


本文介绍了编辑器常用的三种提效功能:右键菜单、快捷键、历史记录,可以使我们编辑操作的效率得到大大的提升,优化体验,并且每个功能都做了分层抽象,可以形成解决方案,在别的业务中复用。


加下来我们会继续介绍提升编辑效率的功能:多选组合,以方便批量操作精灵,提升效率。


作者:前端君
来源:juejin.cn/post/7213757571960799291
收起阅读 »

深入 React Context 源码与实现原理

web
前置知识 本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0 Context API React 渲染流程 React 渲染分为 render 阶段和 com...
继续阅读 »

前置知识


本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0


Context API


image.png


React 渲染流程


React 渲染分为 render 阶段和 commit 阶段,其中 render 阶段分为两步(深度优先遍历)



  1. beginWork(进入节点的过程向下遍历,协调子元素)

  2. completeUnitOfWork(离开节点的过程向上回溯)


区别 render 和 beginWork


为了避免与上面的阶段混淆,以下 render 都代指开发者层面的 render,即指类组件执行 render 方法或函数组件执行



  • 如果一个组件发生更新,当前组件到 fiber root 上的父级链上的所有 fiber,都会执行 beginWork,但执行 beginWork,不代表触发了组件的 render(fiber 会检查组件是否需要进行渲染,不需要则会跳过复用旧的 fiber 节点)所以 render 不等于 beginWork

  • 如果组件 render 执行了,则一定经历了 beginWork 流程,触发了 beginWork


综上 beginWork 的工作是进入节点时协调子元素,如果 fiber 类型是类组件或者函数组件,则需检测比较组件是否需要执行 render,不需要则会跳过复用旧的 fiber 节点


React.createContext 原理


const MyContext = React.createContext(defaultValue)


创建一个 Context 对象。只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效



源码位置:packages/react/src/ReactContext.js


createContext 函数的核心逻辑是返回一个 context 对象,其中包括三个重要属性:



  • ProviderConsumer 两个组件(React Element 对象)属性

  • _currentValue :保存 context 的值,用来保存传递给 Provider 的 value 属性)


下列是精简去除类型定义和引入的源码,后面源码举例都这么处理,为了方便直观的看:


const REACT_PROVIDER_TYPE = Symbol.for('react.provider')
const REACT_CONTEXT_TYPE = Symbol.for('react.context')

export function createContext(defaultValue) {
const context = {
$$typeof: REACT_CONTEXT_TYPE, // 本质就是 Consumer Element 类型
_currentValue: defaultValue, // 保存 context 的值
_currentValue2: defaultValue, // 为了支持多个并发渲染器,适配不同的平台
_threadCount: 0, // 跟踪当前有多少个并发渲染器
Provider: null,
Consumer: null,
}
// 添加 Provider 属性,本质就是 Provider Element 类型
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
}
// 添加 Consumer 属性
context.Consumer = context

return context
}


JSX 语法在进入 render 时会被编译成 React Element 对象



Context.Provider 原理


<MyContext.Provider value={/* 某个值 */}>

image.png


先来了解 Provider 的特性:



  • 每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化

  • Provider 接收一个  value  属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。

  • 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效

  • 多个相同的 Provider 也可以嵌套使用,里层的会覆盖外层的数据。

  • 当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染,可跳过 shouldComponentUpdate 强制更新


如果一个组件发生更新,那么当前组件到 fiber root 上的父级链上的所有 fiber,更新优先级都会升高,都会触发 beginWork,但不一定会 render


当初次 Fiber 树渲染,进入 beginWork 方法,其中对应的节点处理函数是 updateContextProvider


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes)
}
}

进入 updateContextProvider 方法:


function updateContextProvider(current, workInProgress, renderLanes) {
const providerType = workInProgress.type
const context = providerType._context

const newProps = workInProgress.pendingProps
const oldProps = workInProgress.memoizedProps
// 新的 value 值
const newValue = newProps.value
// 获取 Provider 上的 value
pushProvider(workInProgress, context, newValue)

// 更新阶段
if (oldProps !== null) {
const oldValue = oldProps.value
// 使用 Object.is 来比较新旧值是否发生变化
if (is(oldValue, newValue)) {
// context 值没有变更,则提前退出
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
)
}
} else {
// context 值发生改变,深度优先遍历查找 consumer 消费组件,标记更新
propagateContextChange(workInProgress, context, renderLanes)
}
}

// 继续向下调和子代 fiber
const newChildren = newProps.children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

// 使用栈存储 context._currentValue 值,设置 context._currentValue 为最新值
function pushProvider(providerFiber, context, nextValue) {
// 压栈
push(valueCursor, context._currentValue, providerFiber)
// 修改 context 的值
context._currentValue = nextValue
}


  • 首次执行时,保存 workInProgress.pendingProps.value 值作为最新值,然后调用 pushProvider 方法设置context._currentValue

  • pushProvider:存储 context 值的函数,利用栈先进后出的特性,先把 context._currentValue 压栈;与后面流程的 popProvider(出栈)函数相对应。

  • 更新阶段时通过浅比较(Object.is)来判断新旧 context 值是否发生改变,没发生改变则调用 bailoutOnAlreadyFinishedWork 进入 bailout,复用当前 Fiber 节点,改变则调用propagateContextChange方法


我们总结下 Context.Provider 的 Fiber 更新方法 —— updateContextProvider的核心逻辑



  1. 将 Provider 的 value 属性赋值给 context._currentValue(压栈)

  2. 通过 Object.is 浅比较 context 新旧值是否发生变化

  3. 发生变化时,调用 propagateContextChange 走更新的流程,深度优先遍历查找消费组件来标记更新



propagateContextChange 逻辑:深度优先遍历所有的子代 fiber ,然后找到里面具有 dependencies 的属性,对比 dependencies 中的 context 和当前 Provider 的 context 是否是同一个,如果是同一个,会提高 fiber 的更新优先级,让 fiber 在接下来的调和过程中,处于一个高优先级待更新的状态,而高优先级的 fiber 都会 beginWork



消费 Context 原理


由上文知识我们简略粗暴的说:Provider 一顿操作核心就是修改 context._currentValue 的值,那么消费 Context 值的原理也就是想方设法读取 context._currentValue 的值了。


image.png


Context.Consumer(函数组件)


<MyContext.Consumer>
{value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>


一个 React 组件可以订阅 context 的变更,此组件可以让你在函数式组件中可以订阅 context。这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。传递给函数的 value 值等价于组件树上方离这个 context 最近的 Provider 提供的 value 值



当 context 值更新时,Fiber 树渲染时,进入 beginWork 方法,beginWork 中对于 ContextConsumer 的节点处理函数是 updateContextConsumer


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextConsumer:
return updateContextConsumer(current, workInProgress, renderLanes)
}
}

updateContextConsumer的核心逻辑:



  1. 调用 prepareToReadContextreadContext 读取最新的 context 值。

  2. 通过 render props 函数,传入最新的 context value 值,得到最新的 children 。

  3. 调和 children


function updateContextConsumer(current, workInProgress, renderLanes) {
// 拿到 context
let context = workInProgress.type
context = context._context

const newProps = workInProgress.pendingProps
// 获取 Consumer 组件的 render props children
const render = newProps.children
// 读取 context 前的准备工作
prepareToReadContext(workInProgress, renderLanes)
// 读取最新 context._currentValue 值
const newValue = readContext(context)

let newChildren
// 最新的 children element
newChildren = render(newValue)

// 进入主流程,调和 children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

useContext(函数组件)


const value = useContext(MyContext)


接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。



看如下代码,useContext Hook 挂载阶段和更新阶段,本质都是调用 readContext 函数,readContext 函数会返回 context._currentValue。而且也是调用了 prepareToReadContextreadContext


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}

function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
prepareToReadContext(workInProgress, renderLanes)
// 处理各种hooks逻辑
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
)
// ...
}

renderWithHooks 函数是调用函数组件的主要函数


function renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
) {
// ...
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 挂载阶段
: HooksDispatcherOnUpdate // 更新阶段
}

// 确保 Hooks 只能在函数组件内部或自定义 Hooks 中使用,提供正确的调度程序
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current
return dispatcher
}

function useContext(Context) {
const dispatcher = resolveDispatcher()
return dispatcher.useContext(Context)
}

const HooksDispatcherOnMount = {
useContext: readContext,
// ...
}
const HooksDispatcherOnUpdate = {
useContext: readContext,
// ...
}

Class.contextType(类组件)


class MyClass extends React.Component {
componentDidMount() {
let value = this.context
/* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
}
componentDidUpdate() {
let value = this.context
/* ... */
}
componentWillUnmount() {
let value = this.context
/* ... */
}
render() {
let value = this.context
/* 基于 MyContext 组件的值进行渲染 */
}
}
MyClass.contextType = MyContext


挂载在 class 上的 contextType 属性可以赋值为由 React.createContext() 创建的 Context 对象。此属性可以让你使用 this.context 来获取最近 Context 上的值。你可以在任何生命周期中访问到它,包括 render 函数中。




  • 类组件会判断类组件上是否有静态属性 contextType

  • 如果有则调用 readContext 方法,并赋值给类实例的 context 属性,所以我们才可以使用 this.context 获取 context 值


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ClassComponent:
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}

function updateClassComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
// ...
prepareToReadContext(workInProgress, renderLanes)
mountClassInstance(workInProgress, Component, nextProps, renderLanes)
// ...
}

function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
// ...
const instance = workInProgress.stateNode
// 判断类组件上是否有静态属性 contextType
const contextType = ctor.contextType
// 有则调用 readContext
if (typeof contextType === 'object' && contextType !== null) {
// 赋值给类实例的 context 属性
instance.context = readContext(contextType)
}
}

综上,以上三种方式只是 React 根据不同使用场景封装的 API,它们在消费/订阅 context 的共同操作:



  1. 先调用 prepareToReadContext 进行准备工作

  2. 再调用 readContext 方法读取 context 值(readContext 方法返回 context._currentValue 最新值)


上文提到 propagateContextChange ,如果组件订阅了 context,不管是函数组件还是类组件,都会将 fiber.lanes 设置为 renderLanes。在 beginWork 阶段,发现 fiber.lanes 等于 renderLanes,则走 beginWork 的逻辑,强制组件更新


prepareToReadContext 和 readContext 逻辑


prepareToReadContext 的核心逻辑:



  • 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber,并重置lastContextDependency 等全局变量


function prepareToReadContext(workInProgress, renderLanes) {
// 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber, 为 readContext 做准备
currentlyRenderingFiber = workInProgress
// 用于构造 dependencies 列表
lastContextDependency = null
// 将全局变量 lastFullyObservedContext (保存的是 context 对象) 重置为 null
lastFullyObservedContext = null

const dependencies = workInProgress.dependencies
if (dependencies !== null) {
const firstContext = dependencies.firstContext
if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update. Mark that this fiber performed work.
markWorkInProgressReceivedUpdate()
}
// 重置 fiber context 依赖
dependencies.firstContext = null
}
}
}

readContext 的核心逻辑:



  • 收集组件依赖的所有不同的 context,如果组件订阅了 context,则将 context 添加到 fiber.dependencies 链表中

  • 返回context._currentValue, 并构造一个contextItem添加到workInProgress.dependencies 链表之后。


function readContext(context) {
return readContextForConsumer(currentlyRenderingFiber, context)
}

function readContextForConsumer(consumer, context) {
// ReactDOM 中 isPrimaryRenderer 为 true,则一直返回 context._currentValue
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2

// 相等说明是同一个 Context,不处理为了防止重复添加依赖
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context: context,
memoizedValue: value,
next: null,
}
// 构造一个 contextItem, 加入到 workInProgress.dependencies 链表之后
if (lastContextDependency === null) {
lastContextDependency = contextItem
// dependencies 属性用于判定是否依赖了 ContextProvider 中的值
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
}
} else {
// 将 context 添加到 fiber.dependencies 链表末尾
lastContextDependency = lastContextDependency.next = contextItem
}
}
// 返回 context._currentValue
return value
}

Context 原理八连问


上面源码实际上还是讲解不够完整的,在这推荐一篇文章:【React 源码系列】React Context 原理,如何合理设计共享状态,个人认为相对讲得很清晰了。


想知道自己对原理的理解,除了输出就是回答解决一些提问了,这里列举了一些原理相关的问题,写下简略的解答,看看自己是否了解。


Provider 如何传递 context?


通过将 Provider 的 value 属性值赋值给 context._currentValue


没有 Provider 包裹,为什么读不到最新的 context 值?


render() {
return (
<>
<TestContext.Provider value={10}>
{/* 可读到 context 值最新值 10 */}
<Test />
</TestContext.Provider>
{/* 只能读到 context 初始值(createContext 函数的参数 defaultValue) */}
<Test />,
</>

)
}

消费 context 时是读取 context._currentValue 值,理论上其它组件也是读取该最新值的。Provider 其中一个特性是只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。所以没有被 Provider 包裹的组件,是只能读到默认值的。


React 在深度优先遍历 fiber 树时,最外层 Provider 开始 beginWork,会先将 context._currentValue 的旧值保存起来,赋新的值给 context._currentValue(所以在里层的组件都能读到最新值),在离开 Provider 节点时会调用 completeUnitOfWork 完成工作,在此会将 context._currentValue 恢复成旧值,到遍历第二个 <Test /> 节点时就读的是 context 的默认值(不被 Provider 包裹的组件 render 时 beginWork 的时候就读到旧值了)。


相同 Provider 嵌套使用,里层的会覆盖外层的数据是怎么实现的?


render() {
return (
<>
<TestContext.Provider value={10}>
<Test1 />
<TestContext.Provider value={100}>
<Test2 />
</TestContext.Provider>
</TestContext.Provider>
</>

)
}

在这场景下, <Test1 /><Test2 /> 组件读取的值分别是 10 和 100。


为了实现嵌套的机制,React 利用的是的特性(后入先出),通过 pushProviderpopProvider


Fiber 深度优先遍历时:



  • 最外层 Provider 将 value 值 10 压入栈 pushProvider,此时栈顶是 10

  • 遍历里层 Provider 时将 value 值 100 压入栈 pushProvider,此时栈顶是 100,即context._currentValue 的值为 100


消费组件 <Test2 />读取时,在其所在 Provider 范围内先读取栈顶的值,所以读取的是 100;里层的 Provider 完成遍历工作离开时,弹出栈顶 popProvider的值 100,此时栈顶的值是 10, 即 context._currentValue 的值为 10,<Test1 /> 里面读到的值也就为 10 了。


由于 React 调和过程就是 Fiber 树深度优先遍历的过程, 向下遍历(beginWork)和向上回溯(completeWork)恰好符合栈的特性(入栈和出栈),Context 的嵌套读取就是利用了这个特性。


三种消费 context 的原理



  • useContext:本质上调用 readContext 方法

  • Context.Consumer:本质上是类型为 REACT_CONTEXT_TYPE 的 React Element 对象,context 本身就存在 Consumer 里面,本质也是调用 readContext

  • Class.contextType:通过静态属性 contextType 建立联系 ,在类组件实例化的时候被使用,本质上也是调用 readContext


三种方式只是 React 根据不同使用场景封装的 API,本质都是调用了 readContext 方法读取 context._currentValue


context 的存取发生在 React 渲染的哪些阶段


context 的存取就是发生在 beginWork 阶段,在 beginWork 阶段,如果当前组件订阅了 context,则从 context 中读取 _currentValue


消费 context 的组件,context 改变为什么会订阅更新?



  • 当 Provider 的 context value 值更新时,会调用 updateContextProvider 方法,里面的 propagateContextChange 方法会对 fiber 子树向下深度优先遍历所有的 fiber 节点,目的是为了找到消费组件标记更新。如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,就会被标记更新。

  • 而消费组件调用的 readContext 方法则会把 fiber.dependencies 和 context 对象建立关联,fiber.dependencies 用于判断是否依赖了 ContextProvider 中的值

  • context 值更新时消费 context 的 fiber 和父级链都会提高更新优先级,向上遍历时,会设置消费节点的父路径上所有节点的 fiber.childLanes 属性,(childLanes 属性用于判断子节点是否需要更新)需要更新则子节点就会进入更新逻辑(开始 beginWork)。


消费 context 的组件是如何跳过 PureComponent、shouldComponentUpdate 强制 render?



  • 类组件更新流程中,强制更新会跳过 PureComponentshouldComponentUpdate 等优化策略,在外部代码层面,我们可调用 this.forceUpdate(),就会给类组件打上强制更新的 tag。而在内部实现上, context 的 value 改变时,要想订阅 context 的类组件更新,相应的也得打上强制更新的 tag

  • 当 context 值发生变化时,会调用 propagateContextChange 对 Fiber 子树向下深度优先遍历所有的 fiber 节点,如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,如果 fiber 节点是类组件, 则会创建一个 update 对象,并将 update.tag 标记为 ForceUpdate;而处理 update 时,发现 tag 为 ForceUpdate 的话,会将全局变量 hasForceUpdate 设置为 true, 这决定了类组件会强制更新。



updateClassComponent 中会调用 updateClassInstance 判断类组件是否应该更新。在 updateClassInstance 中会判断全局变量 hasForceUpdate 或者组件的 shouldComponentUpdate 的返回值是否为 true, true 则表示要强制更新。



简述 Context 原理


Context 的实现原理:



  • 创建 Context:createContext 返回一个 context 对象,对象包括 ProviderConsumer 两个组件属性,并创建 _currentValue 属性用来保存 context 的值

  • Provider 负责传递 context 值,并使用栈的特性存储修改 context 值

  • 消费 Context:消费组件节点调用 readContext 读取 context._currentValue 获取最新值

  • Provider 更新 Context:ContextProvider 节点深度优先遍历子代 fiber,消费 context 的 fiber 和父级链都会提升更新优先级;对于类组件的 fiber ,会被 forceUpdate 处理。接下来所有消费的 fiber,都会执行 beginWork


结语


本文对 Context 源码的理解有限,暂未能完全读完,只是过了一遍大致实现,如有错误恳请纠正。


参考文章




作者:JackySummer
来源:juejin.cn/post/7213752661761523772
收起阅读 »

css-transform2D变换

web
CSS transform 属性允许你旋转,缩放,倾斜或平移给定元素。 常用的transform 属性有下面几个 属性说明translate(0, 0)位移rotate(0deg)旋转scale(1)缩放skew(0deg)斜切 transform的说明文档:...
继续阅读 »

CSS transform 属性允许你旋转,缩放,倾斜或平移给定元素。
常用的transform 属性有下面几个


属性说明
translate(0, 0)位移
rotate(0deg)旋转
scale(1)缩放
skew(0deg)斜切

transform的说明文档:developer.mozilla.org/zh-CN/docs/…


下面分别说一下这几个方法


translate() 位移


translate通过x、y轴的参数来实现偏移
语法:transform: translate(10px, 10px); x轴偏移10pxy轴偏移10px
也可以单独对某一个轴进行偏移设置,css提供了x、y轴的语法:
transform: translateX(10px);
transform: translateY(10px);


translate的参数可以使用百分比,如果参数是百分比的话,实际的偏移距离是以自身大小为参考的,例如:一个100px的正方形,translateX(50%),那么实际x轴的偏移量是自身的100px * 50% = 50px,有了这个特性之后,可以通过transform: translate(-50%, -50%); 的写法实现垂直定位居中。


.box{
width: 20px;
height: 20px;
background: #e94242;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}

在这里插入图片描述


transform: translate第一个参数偏移自身x轴的50%,第二个参数偏移自身y50%,另外left偏移50%,假如自身100px
那么:left + 自身 - x轴自身50% = 50% + 100px - 50px = 偏移量正好居中,y轴同理。



另外,translate是不受文档流影响的,direction: ltr;文档流为左,translateX依然往右偏移。





rotate() 旋转


rotate() 用于设置元素的旋转角度,rotate(45deg)就是顺时针旋转45°rotate()的旋转受锚点的影响(transform-origin),锚点的问题在下文。
rotate() 有四个单位,分别是:deg角度、grad百分度、rad弧度 、return圈度,最常用的就是deg角度,其它的日常项目基本用不到。


.box{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(45deg);
}

在这里插入图片描述




scale()缩放


scale()有两个参数,语法:transform: scale(参数一 , 参数二),分别对应横向和纵向的放大和缩小,默认值为1(不放大)。


transform: scale(2); /**等比放大2倍 */
transform: scaleX(2); /**水平放大2倍 */
transform: scaleY(2); /**垂直放大2倍 */
transform: scale(2,1); /**x轴放大2倍,y轴不变 */
transform: scale(2,0.5); /**x轴放大2倍,y轴缩小一半 */

.shiftBox{
width: 80px;
height: 80px;
transform: scale(2,0.5); /**x轴放大2倍,y轴缩小一半 */
}

在这里插入图片描述




skew() 斜切


斜切字面意思就是将物体倾斜的意思,语法:transform: skew(10deg, 5deg)表示水平斜切10度 垂直斜切5度,它接受两个参数,第一个参数表示x轴,第二个参数y轴。
也可以单独对某一个轴进行斜切,css提供了x、y轴的语法:
transform: skewX(10deg):水平斜切10
transform: skewY(10deg):垂直斜切10


/* skew() 斜切 */
.shiftBox{
width: 80px;
height: 80px;
background: #80c342;
transform: skew(10deg, 5deg); /**水平斜切10度 垂直斜切5度 */
}

在这里插入图片描述


斜切可以应用在图形的变换,只通过调整x、y轴的倾斜角度即可实现一些画面效果,某些场合下比裁切属性(clip-path)方便。
例如:实现当前任务的进度展示


在这里插入图片描述


这种效果只需要绘制一个矩形,将x轴倾斜45


在这里插入图片描述


再绘制一个矩形,x轴倾斜 -45°即可实现


在这里插入图片描述




transform的细节和特性


元素引用transform属性值不会影响元素的尺寸和位置


我们在日常布局的时候,使用margin或者定位通常会影响到其他的元素


在这里插入图片描述


比如上面这个案例,第二个按钮设置了margin-left,导致第三个按钮的位置也发生变化。
如果第二个按钮使用的是transform: translateX()偏移,那么第三个按钮的位置并不会受到影响,因为transform属性值不会影响原始位置


在这里插入图片描述


另外,内联元素是不受transform所有的变换特性的影响的,必须转为行内块才可以。


span{
/* 内联元素不受transform所有的变换特性 */
display: inline-block; /* 设置行内块后,受transform影响,解决 */
transform: translateX(50px);
}



参数的顺序不同,会影响结果


transform的参数,会按照先后顺序执行,同样的参数,位置不同则会影响执行结果。


.order{
width: 200px;
height: 200px;
border: 1px solid red;
:nth-child(1){
width: 20px;
height: 20px;
background: #4d90fe;
transform: translateX(50px) scale(2); /* 先位移再放大,顺序影响结果 */
}
:nth-child(2){
width: 20px;
height: 20px;
background: #80c342;
transform: scale(2) translateX(50px); /* 先放大再位移,顺序影响结果 */
}
}

在这里插入图片描述


这里b盒子先放大后,再执行translateX,按照放大后的比例进行的偏移,所以b的偏移量比a的远。


有两点需要注意:
1、transformclip-path同时使用时,先裁剪再变换
2、transformmargin,应该优选选择transform,性能更高,因为transform属性值不会影响原始位置。




transform会创建新的层叠上下文


多个元素叠在一起时,通常后执行的元素会覆盖先执行的元素,类似下面的:


在这里插入图片描述


一层叠一层,如果想突出展示元素可以设置z-index来改变层级,其实这里使用transform也可以实现,transform会创建新的层叠上下文,后执行的元素会覆盖先执行的,所以这里无需z-index也可以实现突出展示层级效果,这里使用了transform: scale(1); 原大小保持不变,相当于没对元素做任何操作,但是层叠顺序改变了,如下:


.layer{
width: 200px;
height: 50px;
border: 1px solid red;
padding-left: 20px;
margin: 50px;
>img{
width: 50px;
margin-left: -20px;
}
>img:hover{
transform: scale(1); /*原大小*/
box-shadow: 0px 0px 5px black;
}
}

在这里插入图片描述




固定定位实效


固定定位fixed:元素会被移出正常文档流,并不为元素预留空间,而是通过指定元素相对于屏幕视口(viewport)的位置来指定元素位置。元素的位置在屏幕滚动时不会改变。
但如果fixed的父级设置了transform,那么固定定位将会实效。


/* 固定定位实效 */
.positions{
width: 200px;
height: 50px;
border: 1px solid red;
margin-top: 10px;
.positionBox{
width: 50px;
height: 50px;
background: #80c342;
transform: translateX(10px);
.positionInner{
width: 20px;
height: 20px;
background: #e94242;
right: 0px;
position: fixed; /* 父级设置了transform导致fixed失效 */

}
}
}

在这里插入图片描述




改变overflow对元素的限制


父级元素设置overflow: hidden;是不能对设置了绝对定位的子级元素产生影响的,子级内容超出父级范围不能被隐藏。


.overFlow{
width: 100px;
height: 100px;
background: #4d90fe;
overflow: hidden;
>img{
width: 200px;
height: 50px;
position: absolute; /* 绝对定位不受overflow:hidden影响 */
border: 1px solid red;
}
}

在这里插入图片描述


但如果给父级设置了transform,则会更改overflow的限制,绝对定位的子元素也受到到影响


.overFlow2{
width: 100px;
height: 100px;
background: #80c342;
overflow: hidden;
transform: scale(1); /* transform更改overflow的限制,绝对定位的子元素也受到到影响 */
>img{
width: 200px;
height: 50px;
position: absolute;
bottom: 0;
border: 1px solid red;
}
}

在这里插入图片描述


在这里还有个注意点,img图片跑到底部了,因为父级元素设置了transform,只要transform属性值不为none的元素也可以作为绝对定位元素的包含块 ,相当于开启了相对定位。




transform-origin更改元素变换的中心坐标


transform-origin CSS 属性让你更改一个元素变形的原点。其实就是元素的锚点坐标,默认锚点在元素的中心。


.innerBox2{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(20deg); /*顺时针旋转20°*/
}

在这里插入图片描述


锚点在中心,顺时针旋转20°,如果更改锚点的位置为右上角,那么会出现下面的效果


.innerBox2{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(20deg);
transform-origin: right top; /**受锚点影响 */
}


锚点可以使用方向关键字,也可以使用参数。


在这里插入图片描述


关于锚点的介绍,请看文档:developer.mozilla.org/zh-CN/docs/…


下面通过锚点实现钟摆效果


<div class="originPointer"></div>

.originPointer{
width: 10px;
height: 100px;
margin: 50px;
&::before{
content: '';
width: 10px;
height: 10px;
position: absolute;
background: #80c342;
border-radius: 50%;
transform: translateY(-50%);
}
&::after{
content: '';
width: 10px;
height: 100px;
background: #4d90fe;
position: absolute;
clip-path: polygon(50% 0%, 50% 0%, 100% 100%, 0% 100%);
transform: rotate(0deg);
/* transform-origin: top left; */ /* 改变锚点为左上角 */
transform-origin: 0px 0px; /* 锚点左上角 x轴和y轴,默认起点在最左侧 */
animation: pointer 2s infinite linear; /* 添加linear使画面流程不卡顿 */
}
@keyframes pointer {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-20deg);
}
100% {
transform: rotate(0deg);
}
}
}

在这里插入图片描述




案例源码:gitee.com/wang_fan_w/…


如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发哦~


作者:fanction
来源:juejin.cn/post/7211451845032902711
收起阅读 »

前端加载超大图片(100M以上)实现秒开解决方案

web
前言前端加载超大图片时,一般可以采取以下措施实现加速:图片压缩:将图片进行压缩可以大幅减小图片的大小,从而缩短加载时间。压缩图片时需要注意保持图片质量,以免影响图片显示效果。图片分割:将超大图片分割成多个小图块进行加载,可以避免一次性加载整个图片,从而加快加载...
继续阅读 »

前言

前端加载超大图片时,一般可以采取以下措施实现加速:

  1. 图片压缩:将图片进行压缩可以大幅减小图片的大小,从而缩短加载时间。压缩图片时需要注意保持图片质量,以免影响图片显示效果。

  2. 图片分割:将超大图片分割成多个小图块进行加载,可以避免一次性加载整个图片,从而加快加载速度。这种方式需要在前端实现图片拼接,需要确保拼接后的图片无缝衔接。

  3. CDN 加速:使用 CDN(内容分发网络)可以将图片缓存在离用户更近的节点上,从而加速图片加载速度。如果需要加载的图片是静态资源,可以将其存储在 CDN 上,以便快速访问。

  4. 懒加载:懒加载是一种图片延迟加载的方式,即当用户浏览到需要加载的图片时才进行加载,可以有效避免一次性加载大量图片而导致页面加载速度缓慢。

  5. WebP 格式:使用 WebP 格式可以将图片大小减小到 JPEG 和 PNG 的一半以下,从而加快图片加载速度。

  6. HTTP/2:使用 HTTP/2 协议可以并行加载多个图片,从而加快页面加载速度。

  7. 预加载:预加载是在页面加载完毕后,提前加载下一步所需要的资源。在图片加载方面,可以在页面加载完毕后提前加载下一个需要显示的图片,以便用户快速浏览。

而对于几百M或上G的大图而言,不管对图片进行怎么优化或加速处理,要实现秒开也是不太可能的事情。而上面介绍的第二条“图像分割切片”是最佳解决方案。下面介绍下如何对大图进行分割,在前端进行拼接实现秒开。

图像切片原理介绍

图像切片是指将一张大图分割成若干个小图的过程,以便于存储和处理。图像切片常用于网络地图、瓦片地图、图像拼接等应用中。

切片原理主要包括以下几个步骤:

  1. 定义切片大小:首先需要定义每个小图的大小,一般情况下是正方形或矩形。

  2. 计算切片数量:根据定义的切片大小,计算原始图像需要被切成多少个小图。计算公式为:切片数量 = 原始图像宽度 / 切片宽度 × 原始图像高度 / 切片高度。

  3. 切割图像:按照计算出的切片数量,将原始图像分割成相应数量的小图。可以使用图像处理库或自己编写代码实现。

  4. 存储切片:将切割后的小图存储到磁盘上,可以使用常见的图片格式,如JPEG、PNG等。

  5. 加载切片:在需要显示切片的地方,根据需要加载相应的小图,组合成完整的图像。

使用图像切片可以降低处理大图像的复杂度,同时也能够提高图像的加载速度,使得用户可以更快地查看图像的细节。图像切片广泛应用于需要处理大图像的场景,能够提高图像处理和显示效率,同时也能够提高用户的体验。

实现

先上效果图


上传打开图形

先上传大图,至后台进行切片处理, 上传相关代码为:

async onChangeFile(file) {
           try {
               message.info('文件上传中,请稍候...')
               this.isSelectFile = false;
               this.uploadMapResult = await svc.uploadMap(file.raw);
               if (this.uploadMapResult.error) {
                   message.error('上传图形失败!' + this.uploadMapResult.error)
                   return
              }
               this.form.mapid = this.uploadMapResult.mapid;
               this.form.uploadname = this.uploadMapResult.uploadname;
               this.maptype = this.uploadMapResult.maptype || '';
               this.dialogVisible = true;
          } catch (error) {
               console.error(error);
               message.error('上传图形失败!', error)
          }
      }

如果需要上传后对图像进行处理,可以新建一个cmd.txt文件,把处理的命令写进文件中,然后和图像一起打包成zip上传。

如需要把1.jpg,2.jpg拼接成一个新的图片m1.png再打开,cmd.txt的写法如下:

join
1.jpg
2.jpg
m1.png
horizontal

再把1.jpg,2.jpg,cmd.txt三个文件打包成zip文件上传即可

打开图像相关代码

async onOpenMap() {
           try {
               let mapid = this.form.mapid;
               let param = {
                   ...this.uploadMapResult,
                   // 图名称
                   mapid: this.form.mapid,
                   // 上传完返回的fileid
                   fileid: this.uploadMapResult.fileid,
                   // 上传完返回的文件名
                   uploadname: this.form.uploadname,
                   // 地图打开方式
                   mapopenway: this.form.openway === "直接打开图形" ? vjmap.MapOpenWay.Memory : vjmap.MapOpenWay.GeomRender,
                   // 如果要密码访问的话,设置秘钥值
                   secretKey: this.form.isPasswordProtection ? svc.pwdToSecretKey(this.form.password) : undefined,
                   style: vjmap.openMapDarkStyle(),// div为深色背景颜色时,这里也传深色背景样式
                   // 图像类型设置地图左上角坐标和分辨率
                   imageLeft: this.form.imageLeft ? +this.form.imageLeft : undefined,
                   imageTop: this.form.imageTop ? +this.form.imageTop : undefined,
                   imageResolution: this.form.imageResolution ? +this.form.imageResolution : undefined,
              }
               let isVectorStyle = this.form.openway === "存储后渲染矢量";
               await openMap(param, isVectorStyle);
          } catch (error) {
               console.error(error);
               message.error('打开图形失败!', error)
          }
      }

应用案例

应用一 对图像进行拼接前端查看

原始图片为



最终效果为:


体验地址: vjmap.com/app/cloud/#…

应用二 对tiff影像进行切片并与CAD图叠加校准

对tiff影像上传时可设置地理坐标范围。

tiff/tfw, jpg/jpgw坐标文件的格式(6个参数) 0.030000 0.0000000000 0.0000000000 -0.030000 451510.875000 3358045.000000

以上每行对应的含义:

1 地图单元中的一个象素在X方向上的X分辨率尺度。 2 平移量。 3 旋转量。 4 地图单元中的一个象素在Y方向上的Y分辨率尺度的负值。 5 象素1,1(左上方)的X地坐标。 6 象素1,1(左上方)的Y地坐标。

在上传图时需要根据文件中的第一个,第五个和第六个值设置地图范围


或者上传完后,操作菜单中点击设置地图范围进行设置


影像地图切片完成后,可与CAD图进行叠加校准。效果如下


体验地址: vjmap.com/demo/#/demo…

作者:vjmap
来源:juejin.cn/post/7212270321622106170

收起阅读 »