注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

从解决一个页面请求太多的问题开始的

web
一、写在前面   上周测试同事给我提了个bug。他说在公司运营系统某个编辑页面中,一个post请求调用太多次了,想让我看看怎么回事。我刚听他讲这个事情时心里有点不屑一顾,觉得能有多少次啊,大惊小怪的。然而当我在测试环境中打开那个页面一看,直呼好家伙!这个页面...
继续阅读 »

一、写在前面




  上周测试同事给我提了个bug。他说在公司运营系统某个编辑页面中,一个post请求调用太多次了,想让我看看怎么回事。我刚听他讲这个事情时心里有点不屑一顾,觉得能有多少次啊,大惊小怪的。然而当我在测试环境中打开那个页面一看,直呼好家伙!这个页面调用了30次相同的请求,属实有点离谱的!


image-20230409202804026.png
  既然情况属实,那么肯定是需要优化一下的。我打开项目代码全局搜索这个请求,发现是在全局公用的一个 Upload 组件的created方法里面调用的。这个请求发送的目的是获取图片上传 oss 系统的签名。因为这个页面一共有30个 Upload 组件,所以整个页面渲染完成后会调用30次接口!!我接着查看接口请求返回的数据,发现签名的有效期是1小时。每次请求的发送又会重新刷新了这个签名和有效时间。但是为什么最先调用接口的 Upload 组件还能上传图片成功,这我还不知道。


  我灵机一动,如果把这个获取签名的方法单纯抽取出来。第一次调用方法后将返回数据缓存下来,后面请求时岂不美哉!但实际操作时发现事情没我想象的那么简单。。。


二、我的解决方案1.0




  一开始我的方案是使用 Vuex 缓存接口返回的签名数据,Upload 组件每次都先从 Vuex 中 state 中查找签名数据 cosConfig,如果没找到再去请求接口。大致的流程如下图:


image-20230410231612623.png


  在捋清楚后逻辑之后,我开始新写 Vuex 的 state 和对应的 mutation了。当我写完代码后一运行,发现这个也能还是依旧调用了30次请求。这让我我很是纳闷啊!!!无奈只好debugger语句开始一行行代码进行调试。
经过一小段时间的调试,问题被我发现了。那就是:签名数据的异步获取。这个签名数据是通过调用后端接口异步返回给前端的。当这个页面存在30个 Upload 组件时,每个组件都会在自己的 created 生命周期函数里先查找了 Vuex 中有没有缓存的签名数据。当页面第一次渲染时,vuex 中肯定是没有签名数据的。所以每个 Upload 组件都会找不到签名数据,然后每个组件都会继续调用接口获取签名数据。等获取到了签名之后,签名配置数据再缓存在 Vuex 中,也就没有意义了。所以方案一失败!!


三、我的解决方案2.0




  我需要承认的是平时困于重复性业务的开发中,很少去处理稍微复杂一点的问题,脑子容易混沌。我在发现方案1.0失败了之后,开始想其他的解决方案。通过 google 的无私帮助下,我找到了这篇文章([vue中多个相同组件重复请求的问题?]),完全就是和我一样的问题嘛。我进去看了第一个赞最多的回答,清晰透彻!主要的解决方案就是运用设计模式中的单例模式,把 Upload 组件中的获取签名的方案单独抽出来。这样子页面上不管有多少个 Upload 组件,调用的获取签名的方法都是同一个。这样子就可以在这个方法里面做文章了。


  那么要做什么文章呢?我们假设这个获取上传图片签名的方法名叫做 getCosConfig,无论多少个 Upload 组件,都是调用同一个 getCosConfig 方法。那么在这个方法外部添加一个缓存对象 cacheConfig,组件每次先从这个缓存对象查找存不存在配置数据。如果存在直接获取缓存对象,如果不存在就调用接口获取。


  但光是这样效果还是和方案1.0结果一样的,同样会调用30次接口。所以我们还需要加一个计数器变量 count。count 的初始值是0,Upload 组件每次发送请求时都会给 count 加1。这样子当我们发现是第一次请求时就去调用接口,不是第一次的话就等待,直到第一次请求结束获得数据。逻辑流程图如下:


image-20230415123202746.png


四、我的解决方案2.1




  到此,本以为这个问题完美解决,但是我突然发现这个接口有入参的!这个页面调用的30个接口中,其中两个剩余的28个参数是不同的。我赶忙去查询了接口文档,发现这个接口是用于获取图片上传的签名,并且不同的业务模块的存储位置是不同的。那么自然返回的上传签名也是不同的,这也意味着原来的 cosConfig 的数据结构是不对的。因为原来的一级对象结构会导致不同业务模块的签名数据混乱了,搞不好弄成了p0级的线上bug。想到这里我心里一凉,感慨还好我细心多瞅了一眼。


  既然问题已经定位到了,那么解决方案2.1自然而然也出来了,只要改造一下 co sConfig 和 count 的结构即可,增加一个key,变成二级的对象。最后我的代码成品如下:


image.png


image.png


五、总结




  最后总结一下,数据结构和设计原则的学习看似虚无缥缈,实际上能够帮助我们解决复杂度很高的问题。通过结合我们日常的开发工作,我们才能感受到这些知识的魅力,也会让我们更加有动力去提高我们的水平。


六、评论区其他方案推荐




 之前写文章都是自娱自乐,没啥人看。这篇文章不知道怎么看的人挺多,评论的朋友也不少。评论区也提出了不少其他方案和业界通用的解决方案,让我见识到了自己知识面的狭窄。我也总结一下供有需要的人使用:


1.【业务维度】在上传图片时再去获取服务端的token,不需要提前去获取。


2.【技术维度】一些请求库自带了去重的功能,例如vue-query。


3.【技术维度】缓存池的概念和处理,这个老哥写的很好【你不知道的promise】设计一个支持并发的前端接口缓存


4.【技术维度】使用异步单例模式,将请求的Promise缓存下来,再次调用函数的时候返回这个Promise。这篇文章讲的不错,给大家推荐一下高级异步模式 - Promise 单例


image.png


作者:徐徐徐叨叨
来源:juejin.cn/post/7222096611635003451
收起阅读 »

程序员入行感触二三事

引言 好久没有发感触了,之前一直在做讲师授课,接触了好多入门的程序员,有很多感触,但是在做讲师的时候有时候不方便说,在做开发又开始忙,所以就沉淀下来了,忽然今天收到了之前一个学习的小伙伴的消息,心里有些触动,本人也不是一个特别喜欢发朋友圈的人,但是总感觉想说点...
继续阅读 »

引言


好久没有发感触了,之前一直在做讲师授课,接触了好多入门的程序员,有很多感触,但是在做讲师的时候有时候不方便说,在做开发又开始忙,所以就沉淀下来了,忽然今天收到了之前一个学习的小伙伴的消息,心里有些触动,本人也不是一个特别喜欢发朋友圈的人,但是总感觉想说点啥(矫情了,哈哈),所以写写做一个回顾吧。


编程行业从开始到现在被贴上了很多标签: 幸苦,掉头发,工资高,不愁工作等等,这些有好有坏,但是总结起来大多数人对编程行业的认知是:


1、需要一定的学历,尤其对数学和英语要求很高。


2、工作比较累,加班是便饭。


3、收入很可观,10k轻轻松松。


4、岗位比较多,是一个搞高级技术(嘿嘿嘿,之前一个家长和我聊的)的行业。


当然还有很多,但是就是上面这些认知让好多毕业迷茫、家境一般、工作遇到问题的人,把编程行业作为了一个全新开始的选择。于是,就有了市场,有了市场很快就有了资本,有了资本很快就有了营造焦虑氛围的营销策略,然后就有各种各样在掩盖在光鲜下的问题,又得真的很无奈,那么今天就聊聊吧。


问题


1、社会是你做程序员的第一绊脚石


啥意思,啥叫做社会,这里的社会不是一个群居的结构,而是人情世故(嘿嘿嘿),好多小伙伴是转行过来的,老话说的好,人往高处走,水往低处流,大部分转行的小伙伴不是来自于大家认知当中更好的行业(比如:公务员,医生,律师..嘿嘿嘿,扯远了),甚至编程本行业的也很少(程序员自学的能力还是很不错的),所以大家在学习之前就已经在社会上摸爬滚打了很久,久历人情,好处是好沟通,不好的地方就是真的把人情世故看的比技术更重要了,这一点可能拉低这些小伙伴70%的学习效果,你要明白,程序员这个行业确实也有人情世故,但前提是大家可以在一个水平上,这个水平可以是技术,也可以是职级,但是如果开头就这么琢磨的话,没有一个扎实的编程基础,真的很难立足在这个行业。没有必要的谦让,习惯性的差不多损耗了太多的学习效果了,既然选择编程,首先请把技术学好,哪怕是基础(当然那个行业也会有浑水摸鱼的,但是对于转行的小伙伴来说,概率太低了)


2、学历重要,学力也很重要


编程行业是一个需要终生学习的行业,不论是前端,后端,测试,运维还是其他岗位,如果在做技术就一定需要学习,好多人会说学历不够所以干不了编程,但是在我个人的眼里,学历确实重要,但是并没有完全限制你进入编程行业,因为:


(1)任何行业都是有完整的岗位结构的,需要的高精尖人才是需要的,但是普通的岗位也少不了,编程行业也是如此,有些岗位的学历要求不是很高。


(2)在编程行业除了那些竞争激烈的大厂,自考学历是有一定的市场和认可程度的


但是,在学历背后的学力就不是这样一个概念了,这里想表述的是学习能力,包括:


(1)专注能力,好多小伙伴如果之前有一定的社会经历或者在大学过的比较懒散,在没有聊到学历之前,先决条件就是能静下心来学习,但是很多小伙伴专注力根本不达标,听课走神,练习坐不住...(其实个人感觉任何一个行业,能静下心来做,并且活下来的都不会很差)


(2)学习习惯,这里不贬低学历低的小伙伴,但是不能否认的是,参加高考后获得一个高学历的小伙伴能力不谈,但是99%都有一个很好的学习习惯。比如不会在学习的时候把手机放到旁边,科学的记笔记,有效的复习和预习等等,所以在担心学历之前,请先培养好自己的学习习惯(个人建议,如果真的没有一个好的学习习惯,那么学习的时候就不要在眼前出现多余的东西分散注意力,比如: 课桌上除了听课的电脑,不要有其他的,之前见过的容易分散注意力的:手机,水杯,指尖陀螺,魔方....)


3、不要在没有选择能力的时候做出选择


这里想聊的是一些学习恐慌的小伙伴的惯性,好多小伙伴在选择了一种学习方式(买书,看视频,加入培训班)之后,还会进行类比学习,比如:买了Python的一本基础书,然后再大数据或者小伙伴的推荐下又买了另外一本,或者参加了培训班,又去看其他的教学视频,这些对小白同学的学习伤害会很大,因为,本身对技术没有全面的理解,不同的书,不同的教程传递的教学方法是不一样的,混着来有点像老家喝酒掺着喝,白酒不醉,啤酒不醉,白加啤那么就不一定了(很大概率会醉),所以小白同学最总要的不是再学习的过程当中进行对比,而是可以最快最稳的完成基础感念的学习,在自己脑子当中有了基础概念再做选择。


当然了,还有很多,一次也聊不完,之后有时间再聊吧,今天就先写这么多

作者:老边
来源:juejin.cn/post/7174259081484763173
,欢迎大家讨论交流。

收起阅读 »

简述 js 的代码整洁之道

web
前言 为什么代码要整洁? 代码质量与整洁度成正比。有的团队在赶工期的时候,不注重代码的整洁,代码写的越来越糟糕,项目越来越混乱,生产力也跟着下降,那就必须找更多人来提高生产力,开发成本越来越高。 整洁的代码是怎样的? 清晰表达意图、消除重复、简单抽象、能通过测...
继续阅读 »

前言


为什么代码要整洁?


代码质量与整洁度成正比。有的团队在赶工期的时候,不注重代码的整洁,代码写的越来越糟糕,项目越来越混乱,生产力也跟着下降,那就必须找更多人来提高生产力,开发成本越来越高。


整洁的代码是怎样的?


清晰表达意图、消除重复、简单抽象、能通过测试。
换句话说:具有可读性、可重用性和可重构性。


命名




  1. 名副其实:不使用缩写、不使用让人误解的名称,不要让人推测。


    // bad: 啥?
    const yyyymmdstr = moment().format("YYYY/MM/DD");
    // bad: 缩写
    const cD = moment().format("YYYY/MM/DD");

    // good:
    const currentDate = moment().format("YYYY/MM/DD");

    const locations = ["Austin", "New York", "San Francisco"];

    // bad:推测l是locations的项
    locations.forEach(l => doSomeThing(l));

    // good
    locations.forEach(location => doSomeThing(location));



  2. 使用方便搜索的名称:避免硬编码,对数据用常量const记录。


    // bad: 86400000指的是?
    setTimeout(goToWork, 86400000);

    // good: 86400000是一天的毫秒数
    const MILLISECONDS_PER_DAY = 60 * 60 * 24 * 1000;
    setTimeout(goToWork, MILLISECONDS_PER_DAY);



  3. 类名应该是名词,方法名应该是动词。


    // bad
    function visble() {}

    // good
    function getVisble() {}



  4. 多个变量属于同一类型的属性,那就他们整合成一个对象。同时省略多余的上下文。


    // bad:可以整合
    const carMake = "Honda",
    const carModel = "Accord",
    const carColor = "Blue",

    // bad: 多余上下文
    const Car = {
    carMake: "Honda",
    carModel: "Accord",
    carColor: "Blue",
    };

    // good
    const Car = {
    make: "Honda",
    model: "Accord",
    color: "Blue",
    };



其他:




  • 不要写多余的废话,比如theMessagethe可以删除。




  • 统一术语。比如通知一词,不要一会在叫notice,一会叫announce




  • 用读得通顺的词语。比如getElementById就比 useIdToGetElement好读。




函数(方法)




  • 删除重复的代码,don't repeat yourself。很多地方可以注意dry,比如偷懒复制了某段代码、try...catch或条件语句写了重复的逻辑。


     // bad
    try {
    doSomeThing();
    clearStack();
    } catch (e) {
    handleError(e);
    clearStack();
    }
    // good
    try {
    doSomeThing();
    } catch (e) {
    handleError(e);
    } finally {
    clearStack();
    }



  • 形参不超过三个,对测试函数也方便。多了就使用对象参数。




    • 同时建议使用对象解构语法,有几个好处:



      1. 能清楚看到函数签名有哪些熟悉,

      2. 可以直接重新命名,

      3. 解构自带克隆,防止副作用,

      4. Linter检查到函数未使用的属性。




     // bad
    function createMenu(title, body, buttonText, cancellable) {}

    // good
    function createMenu({ title, body, buttonText, cancellable }) {}



  • 函数只做一件事,代码读起来更清晰,函数就能更好地组合、测试、重构。


     // bad: 处理了输入框的change事件,并创建文件的切片,并保存相关信息到localStorage
    function handleInputChange(e) {
    const file = e.target.files[0];
    // --- 切片 ---
    const chunkList = [];
    let cur = 0;
    while (cur < file.size) {
    chunkList.push({
    chunk: file.slice(cur, cur + size)
    });
    cur += size;
    }
    // --- 保存信息到localstorage ---
    localStorage.setItem("file", file.name);
    localStorage.setItem("chunkListLength", chunkList.length);
    }

    // good: 将三件事分开写,同时自顶而下读,很舒适
    function handleInputChange(e) {
    const file = e.target.files[0];
    const chunkList = createChunk(file);
    saveFileInfoInLocalStorage(file, chunkList);
    }
    function createChunk(file, size = SLICE_SIZE) {
    const chunkList = [];
    let cur = 0;
    while (cur < file.size) {
    chunkList.push({
    chunk: file.slice(cur, cur + size)
    });
    cur += size;
    }
    return chunkList
    }
    function saveFileInfoInLocalStorage(file, chunkList) {
    localStorage.setItem("file", file.name);
    localStorage.setItem("chunkListLength", chunkList.length);
    }



  • 自顶向下地书写函数,人们都是习惯自顶向下读代码,如,为了执行A,需要执行B,为了执行B,需要执行C。如果把A、B、C混在一个函数就很难读了。(看前一个的例子)。




  • 不使用布尔值来作为参数,遇到这种情况时,一定可以拆分函数。


     // bad
    function createFile(name, temp) {
    if (temp) {
    fs.create(`./temp/${name}`);
    } else {
    fs.create(name);
    }
    }

    // good
    function createFile(name) {
    fs.create(name);
    }

    function createTempFile(name) {
    createFile(`./temp/${name}`);
    }



  • 避免副作用。




    • 副作用的缺点:出现不可预期的异常,比如用户对购物车下单后,网络差而不断重试请求,这时如果添加新商品到购物车,就会导致新增的商品也会到下单的请求中。




    • 集中副作用:遇到不可避免的副作用时候,比如读写文件、上报日志,那就在一个地方集中处理副作用,不要在多个函数和类处理副作用。




    • 其它注意的地方:



      • 常见就是陷阱就是对象之间共享了状态,使用了可变的数据类型,比如对象和数组。对于可变的数据类型,使用immutable等库来高效克隆。

      • 避免用可变的全局变量。




    // bad:注意到cart是引用类型!
    const addItemToCart = (cart, item) => {
    cart.push({ item, date: Date.now() });
    };

    // good
    const addItemToCart = (cart, item) => {
    return [...cart, { item, date: Date.now() }];
    };



  • 封装复杂的判断条件,提高可读性。


     // bad
    if (!(obj => obj != null && typeof obj[Symbol.iterator] === 'function')) {
    throw new Error('params is not iterable')
    }

    // good
    const isIterable = obj => obj != null && typeof obj[Symbol.iterator] === 'function';
    if (!isIterable(promises)) {
    throw new Error('params is not iterable')
    }



  • 在方法中有多条件判断时候,为了提高函数的可扩展性,考虑下是不是可以使用能否使用多态性来解决。


     // 地图接口可能来自百度,也可能来自谷歌
    const googleMap = {
    show: function (size) {
    console.log('开始渲染谷歌地图', size));
    }
    };
    const baiduMap = {
    render: function (size) {
    console.log('开始渲染百度地图', size));
    }
    };

    // bad: 出现多个条件分支。如果要加一个腾讯地图,就又要改动renderMap函数。
    function renderMap(type) {
    const size = getSize();
    if (type === 'google') {
    googleMap.show(size);
    } else if (type === 'baidu') {
    baiduMap.render(size);
    }
    };
    renderMap('google')

    // good:实现多态处理。如果要加一个腾讯地图,不需要改动renderMap函数。
    // 细节:函数作为一等对象的语言中,作为参数传递也会返回不同的执行结果,也是“多态性”的体现。
    function renderMap (renderMapFromApi) {
    const size = getSize();
    renderMapFromApi(size);
    }
    renderMap((size) => googleMap.show(size));



其他




  • 如果用了TS,没必要做多余类型判断。




注释




  1. 一般代码要能清晰的表达意图,只有遇到复杂的逻辑时才注释。


     // good:由于函数名已经解释不清楚函数的用途了,所以注释里说明。
    // 在nums数组中找出 和为目标值 target 的两个整数,并返回它们的数组下标。
    const twoSum = function(nums, target) {
    let map = new Map()
    for (let i = 0; i < nums.length; i++) {
    const item = nums[i];
    const index = map.get(target - item)
    if (index !== undefined){
    return [index, i]
    }
    map.set(item, i)
    }
    return []
    };

    // bad:加了一堆废话
    const twoSum = function(nums, target) {
    // 声明map变量
    let map = new Map()
    // 遍历
    for (let i = 0; i < nums.length; i++) {
    const item = nums[i];
    const index = map.get(target - item)
    // 如果下标为空
    if (index !== undefined){
    return [index, i]
    }
    map.set(item, i)
    }
    return []
    };



  2. 警示作用,解释此处不能修改的原因。


    // hack: 由于XXX历史原因,只能调度一下。
    setTimeout(doSomething, 0)



  3. TODO注释,记录下应该做但还没做的工作。另一个好处,提前写好命名,可以帮助后来者统一命名风格。


    class Comment {
    // todo: 删除功能后期实现
    delete() {}
    }



  4. 没用的代码直接删除,不要注释,反正git提交历史记录可以找回。


    // bad: 如下,重写了一遍两数之和的实现方式

    // const twoSum = function(nums, target) {
    // for(let i = 0;i<nums.length;i++){
    // for(let j = i+1;j<nums.length;j++){
    // if (nums[i] + nums[j] === target) {
    // return [i,j]
    // }
    // }
    // }
    // };
    const twoSum = function(nums, target) {
    let map = new Map()
    for (let i = 0; i < nums.length; i++) {
    const item = nums[i];
    const index = map.get(target - item)
    if (index !== undefined){
    return [index, i]
    }
    map.set(item, i)
    }
    return []
    };



  5. 避免循规式注释,不要求每个函数都要求jsdoc,jsdoc一般是用在公共代码上。


    // bad or good?
    /**
    * @param {number[]} nums
    * @param {number} target
    * @return {number[]}
    */

    const twoSum = function(nums, target) {}



对象




  • 多使用getter和setter(getXXX和setXXX)。好处:



    • 在set时方便验证。

    • 可以添加埋点,和错误处理。

    • 可以延时加载对象的属性。


    // good
    function makeBankAccount() {
    let balance = 0;

    function getBalance() {
    return balance;
    }

    function setBalance(amount) {
    balance = amount;
    }

    return {
    getBalance,
    setBalance
    };
    }

    const account = makeBankAccount();
    account.setBalance(100);



  • 使用私有成员。对外隐藏不必要的内容。


    // bad
    const Employee = function(name) {
    this.name = name;
    };

    Employee.prototype.getName = function getName() {
    return this.name;
    };
    const employee = new Employee("John Doe");
    delete employee.name;
    console.log(employee.getName()); // undefined


    // good
    function makeEmployee(name) {
    return {
    getName() {
    return name;
    }
    };
    }




solid




  • 单一职责原则 (SRP) - 保证“每次改动只有一个修改理由”。因为如果一个类中有太多功能并且您修改了其中的一部分,则很难预期改动对其他功能的影响。


    // bad:设置操作和验证权限放在一起了
    class UserSettings {
    constructor(user) {
    this.user = user;
    }

    changeSettings(settings) {
    if (this.verifyCredentials()) {
    // ...
    }
    }

    verifyCredentials() {
    // ...
    }
    }
    // good: 拆出验证权限的类
    class UserAuth {
    constructor(user) {
    this.user = user;
    }

    verifyCredentials() {
    // ...
    }
    }

    class UserSettings {
    constructor(user) {
    this.user = user;
    this.auth = new UserAuth(user);
    }

    changeSettings(settings) {
    if (this.auth.verifyCredentials()) {
    // ...
    }
    }
    }



  • 开闭原则 (OCP) - 对扩展放开,但是对修改关闭。在不更改现有代码的情况下添加新功能。比如一个方法因为有switch的语句,每次出现新增条件时就要修改原来的方法。这时候不如换成多态的特性。


    // bad: 注意到fetch用条件语句了,不利于扩展
    class AjaxAdapter extends Adapter {
    constructor() {
    super();
    this.name = "ajaxAdapter";
    }
    }

    class NodeAdapter extends Adapter {
    constructor() {
    super();
    this.name = "nodeAdapter";
    }
    }

    class HttpRequester {
    constructor(adapter) {
    this.adapter = adapter;
    }

    fetch(url) {
    if (this.adapter.name === "ajaxAdapter") {
    return makeAjaxCall(url).then(response => {
    // transform response and return
    });
    } else if (this.adapter.name === "nodeAdapter") {
    return makeHttpCall(url).then(response => {
    // transform response and return
    });
    }
    }
    }

    function makeAjaxCall(url) {
    // request and return promise
    }

    function makeHttpCall(url) {
    // request and return promise
    }

    // good
    class AjaxAdapter extends Adapter {
    constructor() {
    super();
    this.name = "ajaxAdapter";
    }

    request(url) {
    // request and return promise
    }
    }

    class NodeAdapter extends Adapter {
    constructor() {
    super();
    this.name = "nodeAdapter";
    }

    request(url) {
    // request and return promise
    }
    }

    class HttpRequester {
    constructor(adapter) {
    this.adapter = adapter;
    }

    fetch(url) {
    return this.adapter.request(url).then(response => {
    // transform response and return
    });
    }
    }



  • 里氏替换原则 (LSP)




    • 两个定义



      • 如果S是T的子类,则T的对象可以替换为S的对象,而不会破坏程序。

      • 所有引用其父类对象方法的地方,都可以透明的替换为其子类对象。

      •     也就是,保证任何父类对象出现的地方,用其子类的对象来替换,不会出错。下面的例子是经典的正方形、长方形例子。




    // bad: 用正方形继承了长方形
    class Rectangle {
    constructor() {
    this.width = 0;
    this.height = 0;
    }

    setColor(color) {
    // ...
    }

    render(area) {
    // ...
    }

    setWidth(width) {
    this.width = width;
    }

    setHeight(height) {
    this.height = height;
    }

    getArea() {
    return this.width * this.height;
    }
    }

    class Square extends Rectangle {
    setWidth(width) {
    this.width = width;
    this.height = width;
    }

    setHeight(height) {
    this.width = height;
    this.height = height;
    }
    }

    function renderLargeRectangles(rectangles) {
    rectangles.forEach(rectangle => {
    rectangle.setWidth(4);
    rectangle.setHeight(5);
    const area = rectangle.getArea(); // BAD: 返回了25,其实应该是20
    rectangle.render(area);
    });
    }

    const rectangles = [new Rectangle(), new Rectangle(), new Square()];// 这里替换了
    renderLargeRectangles(rectangles);

    // good: 取消正方形和长方形继承关系,都继承Shape
    class Shape {
    setColor(color) {
    // ...
    }

    render(area) {
    // ...
    }
    }

    class Rectangle extends Shape {
    constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
    }

    getArea() {
    return this.width * this.height;
    }
    }

    class Square extends Shape {
    constructor(length) {
    super();
    this.length = length;
    }

    getArea() {
    return this.length * this.length;
    }
    }

    function renderLargeShapes(shapes) {
    shapes.forEach(shape => {
    const area = shape.getArea();
    shape.render(area);
    });
    }

    const shapes = [new Rectangle(4, 5), new Rectangle(4, 5), new Square(5)];
    renderLargeShapes(shapes);



  • 接口隔离原则 (ISP) - 定义是"客户不应被迫使用对其而言无用的方法或功能"。常见的就是让一些参数变成可选的。


     // bad
    class Dog {
    constructor(options) {
    this.options = options;
    }

    run() {
    this.options.run(); // 必须传入 run 方法,不然报错
    }
    }

    const dog = new Dog({}); // Uncaught TypeError: this.options.run is not a function

    dog.run()

    // good
    class Dog {
    constructor(options) {
    this.options = options;
    }

    run() {
    if (this.options.run) {
    this.options.run();
    return;
    }
    console.log('跑步');
    }
    }



  • 依赖倒置原则(DIP) - 程序要依赖于抽象接口(可以理解为入参),不要依赖于具体实现。这样可以减少耦合度。


     // bad
    class OldReporter {
    report(info) {
    // ...
    }
    }

    class Message {
    constructor(options) {
    // ...
    // BAD: 这里依赖了一个实例,那你以后要换一个,就麻烦了
    this.reporter = new OldReporter();
    }

    share() {
    this.reporter.report('start share');
    // ...
    }
    }

    // good
    class Message {
    constructor(options) {
    // reporter 作为选项,可以随意换了
    this.reporter = this.options.reporter;
    }

    share() {
    this.reporter.report('start share');
    // ...
    }
    }
    class NewReporter {
    report(info) {
    // ...
    }
    }
    new Message({ reporter: new NewReporter });



其他




  • 优先使用 ES2015/ES6 类而不是 ES5 普通函数。




  • 多使用方法链。




  • 多使用组合而不是继承。




错误处理




  • 不要忽略捕获的错误。而要充分对错误做出反应,比如console.error()到控制台,提交错误日志,提醒用户等操作。




  • 不要漏了catch promise中的reject。




格式


可以使用eslint工具,这里就不展开说了。


最后


接受第一次愚弄


让程序一开始就做到整洁,并不是一件很容易的事情。不要强迫症一样地反复更改代码,因为工期有限,没那么多时间。等到下次需求更迭,你发现到代码存在的问题时,再改也不迟。


入乡随俗


每个公司、项目的代码风格是不一样的,会有与本文建议不同的地方。如果你接手了一个成熟的项目,建议按照此项目的风格继续写代码(不重构的话)。因为形成统一的代码风格也是一种代码整洁。



参考:



  1. 《代码整洁之道》

  2. github.com/ryanmcdermo…
    (里面有很多例子。有汉化但没更新)


作者:xuwentao
来源:juejin.cn/post/7224382896626778172

收起阅读 »

ES6 Class类,就是构造函数语法糖?

web
一、Class 类可以看作是构造函数的语法糖 ES6引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。constructor()方法,这就是构造方法,而this关键字则代表实例对象。类的所有方法都定义在类的prototype...
继续阅读 »

一、Class 类可以看作是构造函数的语法糖



ES6引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。constructor()方法,这就是构造方法,而this关键字则代表实例对象。类的所有方法都定义在类的prototype属性上面,方法前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。使用的时候,类必须使用new调用跟构造函数的用法完全一致。



  • 类不存在变量提升



    class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}

toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
var p = new Point(1, 2);

通过代码证明:


    class Point {
// ...
}

typeof Point // "function"
Point === Point.prototype.constructor // true

类的数据类型就是函数,类本身就指向构造函数。



constructor: 方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor()方法,如果没有显式定义,一个空的constructor()方法会被默认添加。



class Point {
}

// 等同于
class Point {
constructor() {}
}

取值函数(getter)和存值函数(setter)


        class Person {
constructor(name, age) {
this.name = name
this.age = age
}

get nl() {
return this.age
}

set nl(value) {
this.age = value
}
}
let p = new Person('fzw', 25)
console.log(p.nl);
p.nl = 44
console.log(p.nl);

class表达式


        let person = new class {
constructor(name) {
this.name = name;
}

sayName() {
console.log(this.name);
}
}('张三');

person.sayName(); // "张三"


上面代码中,person是一个立即执行的类的实例。


二、静态方法、静态属性



类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。



         class Foo {
static classMethod() {
this.baz(); // 'hello'
return '我被调用了';
}
static baz() {
console.log('hello');
}
baz() {
console.log('world');
}
}

console.log(Foo.classMethod()); // 我被调用了

var foo = new Foo();
foo.classMethod() // TypeError: foo.classMethod is not a function

注意 如果静态方法包含this关键字,这个this指的是类,而不是实例。静态方法可以与非静态方法重名。


        class Foo {
static classMethod() {
return 'hello';
}
}

class Bar extends Foo {
}

Bar.classMethod() // 'hello'

父类的静态方法,可以被子类继承。父类Foo有一个静态方法,子类Bar可以调用这个方法。
静态方法也是可以从super对象上调用的。


        class Foo {
static classMethod() {
return 'hello';
}
}

class Bar extends Foo {
static classMethod() {
// super在静态方法之中指向父类
return super.classMethod() + ', too';
}
}

console.log(Bar.classMethod());

注意 super 在静态方法之中指向父类。


静态属性



static 关键词修饰,可继承使用



         class MyClass {
static myStaticProp = 42;
constructor() {
console.log(MyClass.myStaticProp); // 42
}
}
class Bar extends MyClass {
}
new MyClass()
console.log(Bar.myStaticProp);

三、私有方法和私有属性



#修饰属性或方法,私有属性和方法只能在类的内部使用。
私有属性也可以设置 getter 和 setter 方法
私有属性和私有方法前面,也可以加上static关键字,表示这是一个静态的私有属性或私有方法。



    class Counter {
#xValue = 0;

constructor() {
console.log(this.#x);
}

get #x() { return this.#xValue; }
set #x(value) {
this.#xValue = value;
}
}

四、class 继承




  • Class 可以通过extends关键字实现继承,让子类继承父类的属性和方法。

  • ES6 规定,子类必须在constructor()方法中调用super(),如果不调用super()方法,子类就得不到自己的this对象。调用super()方法会执行一次父类构造函数。

  • 在子类的构造函数中,只有调用super()之后,才可以使用this关键字,



        class Foo {
constructor() {
console.log(1);
}
}

class Bar extends Foo {
constructor(color) {
// this.color = color; // ReferenceError
super();
this.color = color; // 正确
}
}

const bar = new Bar('blue');
console.log(bar); // Bar {color: 'blue'}

super 关键字



super这个关键字,既可以当作函数使用,也可以当作对象使用。



  • super作为函数调用时,代表父类的构造函数。只能用在子类的构造函数之中,用在其他地方就会报错。

  • super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。



作为对象,普通方法中super指向父类的原型对象


    class A {
constructor() {
this.x = 1;
}
print() {
console.log(this.x);
}
}

class B extends A {
constructor() {
super();
this.x = 2;
}
m() {
super.print();
}
}

let b = new B();
b.m() // 2

注意:
在子类普通方法中通过super调用父类的方法时,方法内部的this指向当前的子类实例。


作为对象,静态方法之中,这时super将指向父类


        class Parent {
static myMethod(msg) {
console.log('static', msg);
}

myMethod(msg) {
console.log('instance', msg);
}
}

class Child extends Parent {
static myMethod(msg) {
// super 代表父类
super.myMethod(msg);
}

myMethod(msg) {
// super 代表父类原型对象
super.myMethod(msg);
}
}

Child.myMethod(1); // static 1

var child = new Child();
child.myMethod(2); // instance 2

extends 关键字


168fb9a3828f9cb4_tplv-t2oaga2asx-zoom-in-crop-mark_4536_0_0_0.awebp
    // 1、构造器原型链
Child.__proto__ === Parent; // true
Parent.__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true
// 2、实例原型链
child.__proto__ === Child.prototype; // true
Child.prototype.__proto__ === Parent.prototype; // true
Parent.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true


extends 继承,主要就是:



  1. 把子类构造函数(Child)的原型(__proto__)指向了父类构造函数(Parent),

  2. 把子类实例child的原型对象(Child.prototype) 的原型(__proto__)指向了父类parent的原型对象(Parent.prototype)。


这两点也就是图中用不同颜色标记的两条线。



子类构造函数Child继承了父类构造函数Parent的里的属性,使用super调用的。




作者:f_人生如戏
来源:juejin.cn/post/7225511164125855781
收起阅读 »

深拷贝的终极实现

web
引子 通过本文可以学习到深拷贝的三种写法的实现思路与性能差异 首先,我们要理解什么是深拷贝,以及为什么要实现深拷贝 深拷贝是什么 通俗来讲,深拷贝就是深层的拷贝一个变量值; 为什么要实现深拷贝 因为在拷贝引用值时,由于复制一个变量只是将其指向要复制变量的引...
继续阅读 »

引子



通过本文可以学习到深拷贝的三种写法的实现思路与性能差异



首先,我们要理解什么是深拷贝,以及为什么要实现深拷贝


深拷贝是什么


通俗来讲,深拷贝就是深层的拷贝一个变量值;


为什么要实现深拷贝


因为在拷贝引用值时,由于复制一个变量只是将其指向要复制变量的引用内存地址,他们并没有完全的断开,而使用就可以实现深拷贝将其完全拷贝为两个单独的存在,指向不同的内存地址;


如何实现深拷贝


一行实现


let deepClone = JSON.parse(JSON.stringify(obj))

这种是最简单的实现方法,虽然这个方法适用于常规,但缺点是无法拷贝 Date()或是RegExp()
 


简单实现


function deepClone(obj) {
// 判断是否是对象
if (typeof obj !== 'object') return obj
// 判断是否是数组 如果是数组就返回一个新数组 否则返回一个新对象
var newObj = obj instanceof Array ? [] : {};
// 遍历obj
for (var key in obj) {
// 将key值拷贝,再层层递进拷贝对象的值
newObj[key] = deepClone(obj[key]);
}
// 返回最终拷贝完的值
return newObj;
}

对于普通的值(如数值、字符串、布尔值)和常见的引用类型(如对象和数组),这个写法完全够用。


但是这个写法有个缺陷,就是无法正确拷贝 Date()  和  RegExp()  等实例对象,因为少了对这些引用类型的特殊处理


普通版


function deepClone(origin, target) {
let tar = target || {};
for (var key in origin) {
if (origin.hasOwnProperty(key)) {
if (typeof origin[key] === 'object' && origin[key] !== null) {
tar[key] = Array.isArray(origin[key]) ? [] : {};
deepClone(origin[key], tar[key]);
} else {
tar[key] = origin[key];
}
}
}
return tar;
}

这个深拷贝方法通过判断属性的值类型,实现了对 对象数组 以及 DateRegExp 等引用类型对象的递归拷贝,同时也考虑了拷贝基本类型值的情况,能够满足大多数场景的要求。


最终版


为什么还有最终版?

上面的案例,可以应对一般场景。


但是对于有两个对象相互拷贝的场景,会导致循环的无限递归,造成死循环!



Uncaught RangeError: Maximum call stack size exceeded



场景:


image.png


如何解决无限递归的问题?

首先我们要了解 WeakMap()
WeakMap的键名所指向的对象,不计入垃圾回收机制;


而通过 WeakMap 记录已经拷贝过的对象,能防止循环引用导致的无限递归;



WeakMap 的设计目的在于,有时我们想在某个对象上面存放一些数据,但是这会形成对于这个对象的引用



代码

利用 WeakMap() 在属性遍历完绑定,并在每次循环时获取当前键名,如果存在则返回数据,不存在则拷贝


function deepClone(origin, hashMap = new WeakMap()) {
// 判断是否是对象
if (origin == undefined || typeof origin !== 'object') return origin;
// 判断是否是Date类型
if (origin instanceof Date) return new Date(origin);
if (origin instanceof RegExp) return new RegExp(origin);

// 判断是否是数组
const hashKey = hashMap.get(origin);
// 如果是数组
if (hashKey) return hashKey;

// 从原型上复制一个值
// *:利用原型构造器获取新的对象 如: [], {}
const target = new origin.constructor();
// 将对象存入map
hashMap.set(origin, target);
// 循环遍历当前层数据
for (let k in origin) {
// 判断当前属性是否为引用类型
if (origin.hasOwnProperty(k)) {
target[k] = deepClone(origin[k], hashMap);
}
}
return target;
}

我们再来看一下使用最新版后的两个对象互相拷贝:


image.png


可以看到,通过使用 WeakMap 记录已经拷贝的对象,有效防止循环引用导致的栈溢出错误,是功能最完备的深拷贝实现。


总结


深拷贝可以完全拷贝一个对象,生成两个独立的且相互不影响的对象。


明白各种深拷贝实现的思路和性能差异,可以在不同场景选用最优的方案。


作者:Shrimpsss
来源:juejin.cn/post/7226181917997547576
收起阅读 »

前端想要做一个定时任务?来试试这个吧

前言 在工作以及学习中,我们有时候会遇到一些定时任务的需求,比如每天定时发送邮件,定时给群或者频道发消息,定时爬取数据等等。这些其实都是比较常见的需求,但是我们又不想去部署一个专门的定时任务服务,这个时候我们就可以使用 Google App Script 实现...
继续阅读 »

前言


在工作以及学习中,我们有时候会遇到一些定时任务的需求,比如每天定时发送邮件,定时给群或者频道发消息,定时爬取数据等等。这些其实都是比较常见的需求,但是我们又不想去部署一个专门的定时任务服务,这个时候我们就可以使用 Google App Script 实现这些需求。


Google App Script 是什么?


Google App Script 是谷歌提供的一种脚本语言,可以在谷歌的各种服务中使用,比如 Google docsGoogle sheetsGoogle forms 等等。它的语法和 js 基本差不多,但是又有一些不同,比如没有 windowdocument 等对象,但是有SpreadsheetAppDocumentApp 等对象,这些对象可以用来操作 Google docsGoogle sheets 等服务。


写代码前的准备


既然 Google App Script 可以直接操作 Google sheets,那我们就可以直接使用 Google Sheets 来充当我们的数据库。我们在读取表格中的数据以后,再根据数据的内容来执行不同的操作。


首先我们先新建一张表格,然后在表格中填入一些数据,比如下面这样:


image.png


channel 字段表示消息要发送到的频道,hourOfDayminutesOfDay 表示消息要发送的时间,message 表示消息的内容,isWorkDay 则使用了 Google Sheets 中的 NETWORKDAYS 函数来判断今天是否是工作日。而 webhook 就是我们要发送消息的 URL


slack 为例,如果想用机器人给频道发消息,我们可以在 slack 中创建一个 App,然后在 App 中创建 一个新的Incoming Webhooks,然后我们就可以获取到一个 Webhook URL,我们只需要发送 POST 请求 URLbody 中带上消息内容,就可以实现给频道发消息的功能了。


image.png


然后我们在 Google Sheets中点击 扩展程序 -> App 脚本,就会跳转到 Google App Script 的编辑器中,这个时候我们就可以开始写 js 代码了。


用法


首先,我们要先获取到 Google Sheets 中的数据。并将其转换成我们想要的格式。


const getDailyMessage = () => {
const workbook = SpreadsheetApp.getActiveSpreadsheet();
const sheet = workbook.getSheetByName(sheets.dailyMessage.sheetName);
return convertValues(sheet.getDataRange().getValues());
}

const convertValues = (values) => {
const [header, ...rows] = values;
return rows.map((row, rowIndex) => {
const result = {};
row.forEach((column, columnIndex) => {
result[header[columnIndex].trim()] = {
value: column,
position: {
row: rowIndex + 2, // skip the header row
column: columnIndex + 1,
},
};
});
return result;
});
};

有了表格中的数据以后,我们就要开始发请求了


const shouldRun = item => {
const hourOfDay = item[sheets.dailyMessage.columnNames.hourOfDay].value;
const minutesOfDay = item[sheets.dailyMessage.columnNames.minutesOfDay].value;
const currentHour = new Date().getHours();
const currentMinutes = new Date().getMinutes()
const isWorkDay = item[sheets.dailyMessage.columnNames.isWorkDay].value
if (isWorkDay && hourOfDay === currentHour && currentMinutes === minutesOfDay) {
return true
}
return false
}
// daily message
function runDailyMessage() {
const dailyMessage = getDailyMessage();
Logger.log(`${dailyMessage.length} daily message channel(s) are ready to notify...`)
const tasks = dailyMessage.filter(shouldRun).map(item => new Promise((resolve) => {
try {
const channelName =
item[sheets.dailyMessage.columnNames.channel].value;
const message = item[sheets.dailyMessage.columnNames.message].value;
const url = item[sheets.dailyMessage.columnNames.webhook].value
const options = {
method: "post",
payload: JSON.stringify({
text: message,
}),
}
const fetchResult = UrlFetchApp.fetch(url, options).getContentText();
if (fetchResult === "ok") {
afterNotify(cfg, nextMemberIndex);
}
Logger.log(`${channelName} daily message notify successfully`);
} catch (err) {
Logger.log(`${channelName} daily message throws error: `);
Logger.log(err);
} finally {
resolve()
}
}))
Promise.all(tasks)
.then(() => {
Logger.log("daily message have been executed");
})
.catch((err) => Logger.log(err));
}

写完以后,我们就可以在 Google Sheets 中点击 运行 -> runDailyMessage 来执行我们的代码了。


image.png


这时候,就可以在 slack 中看到我们的消息了。


image.png


触发器


上面是手动执行的代码,但是我们希望代码能够自动执行,这时候就需要用到 Google App Script 的触发器了。


Tips


在设置触发器以前,有两个值得注意的地方:



  1. 上面的函数中,我们使用的是 SpreadsheetApp.getActiveSpreadsheet() 获取到 Google Sheets 中的数据。但是在触发器中,使用这个 api 是无法获取到数据的,这会导致你的触发器失败。所以我们需要在触发器中使用 SpreadsheetApp.openById 来获取到 Google Sheets 中的数据。


//  use openById replace getActiveSpreadsheet when use app script trigger
const workbook = SpreadsheetApp.openById("在这里填入你 Google Sheets 的 id");


  1. 我们最好是部署我们当前的应用, Google App Script 编辑器右上角就有部署按钮


image.png


选择脚本库,添加新版本描述,然后点击部署。就部署完成了。


触发器设置


image.png


App Script 中点击小时钟图标,就可以看到触发器的设置了。


image.png


选择我们要运行的函数,选择刚才部署的版本,然后设置触发器的时间,这里我们设置为每分钟执行一次,就完成了一个简单的定时任务啦。


总结


Google App Script语法跟 JavaScript 很像,所以对于前端学习起来也很容易,提供了部署以及触发器的功能,可以帮助我们快速实现一些简单的功能。希望这篇文章能够帮助到你。


作者:xinglee
来源:juejin.cn/post/7226229808199581756
收起阅读 »

关于前端实现上传文件这个功能,我只能说so easy!

web
前言 在web前端开发中,文件上传属于很常见的功能,不论是图片、还是文档等等资源,或多或少会有上传的需求。一般都是从添加文件开始,然后读取文件信息,再通过一定的方式将文件上传到服务器上,以供后续展示或下载使用。 下面简单介绍几种上传的方法 简单文件上传 文件上...
继续阅读 »

前言


在web前端开发中,文件上传属于很常见的功能,不论是图片、还是文档等等资源,或多或少会有上传的需求。一般都是从添加文件开始,然后读取文件信息,再通过一定的方式将文件上传到服务器上,以供后续展示或下载使用。


下面简单介绍几种上传的方法


简单文件上传


文件上传的传统形式,是使用表单元素 file


<input type="file" id="file-uploader">

你可以添加 change 事件监听器读取 event.target.files 文件对象。


const fileUploader = document.getElementById('file-uploader')
fileUploader.addEventListener('change', (e) => {
const files = e.target.files
console.log('files', files)
})

多个文件上传


使用 multiple 属性


<input type="file" id="file-uploader" multiple />

文件元数据


在成功上传文件内容后,您可能需要显示该文件内容。对于图片,如果我们在上传后不立即将上传的图片显示给用户,则会感到困惑。


每当上传文件时,File 对象都会包含元数据信息,如文件名称、大小、上次更新时间、类型等。此信息可用于进一步验证和决策。


const fileUploader = document.getElementById('file-uploader')

// 侦听更改事件并读取元数据
fileUploader.addEventListener('change', (e) => {
// 获取文件列表数组
const files = e.target.files

// 循环浏览文件并获取元数据
for (const file of files) {
const name = file.name
const type = file.type ? file.type: 'NA'
const size = file.size
const lastModified = file.lastModified
console.log({ file, name, type, size, lastModified })
}
})

上传前预览图像


我们准备一个上传文件控件,并为预览所选文件准备 img 元素,结构如下:


<input type="file" id="fileInput" />

<img id="preview" />

getElementById() 方法可以获取这两个元素:


const fileEle = document.getElementById('fileInput')
const previewEle = document.getElementById('preview')

使用 URL.createObjectURL() 方法


URL.createObjectURL() 方法包含一个表示参数中给出的对象的 URL。这个新的 URL 对象表示指定的 File对象或 Blob 对象。


fileEle.addEventListener('change', function (e) {
// 获取所选文件
const file = e.target.files[0]

// 创建引用该文件的新 URL
const url = URL.createObjectURL(file)

// 设置预览元素的源
previewEle.src = url
})

使用 FileReader 的 readAsDataURL() 方法



  • 使用 FileReader 对象将文件转换为二进制字符串。然后添加 load 事件侦听器,以获得成功文件上传的二进制字符串。

  • FileReader.readAsDataURL() 方法用于读取指定的 BlobFile对象。


// 获取 FileReader 的实例
const reader = new FileReader()

fileUploader.addEventListener('change', (e) => {
const files = e.target.files
const file = files[0]

// 上传后获取文件对象,以 URL 二进制字符串的形式读取数据
reader.readAsDataURL(file)

// 加载后,对字符串进行处理
reader.addEventListener('load', (e) => {
// 设置预览元素的源
previewEle.src = reader.result
})
})

accept 属性


使用 accept 属性来限制要上传的文件类型。


<input type="file" id="file-uploader" accept=".jpg, .png" multiple>

上面示例中,浏览器将只允许具有 .jpg 和 .png 的文件类型。


验证文件大小


我们读取了文件的大小元数据,可以使用它进行文件大小验证。您可以允许用户上传高达 1MB 的图像文件。


// 文件上载更改事件的侦听器
fileUploader.addEventListener('change', (event) => {
// 读取文件大小
const file = event.target.files[0]
const size = file.size

let msg = ''

// 检查文件大小是否大于 1MB,提示对应消息。
if (size > 1024 * 1024) {
msg = `<span style="color: red;">允许的文件大小为 1MB。您尝试上载的文件属于${returnFileSize(size)}</span>`
} else {
msg = `<span style="color: green;"> ${returnFileSize(size)} 文件已成功上载。 </span>`
}

// 向用户显示消息
feedback.innerHTML = msg
})

显示文件上传进度


更好的可用性是让用户了解文件上传进度。XMLHttpRequest 第二版还定义了一个 progress 事件,可以用来制作进度条。


先在页面中放置一个 progress 标签


<label id="progress-label" for="progress"></label>
<progress id="progress" value="0" max="100" value="0">0</progress>

定义 progress 事件的回调函数


const reader = new FileReader()

reader.addEventListener('progress', (e) => {
if (e.loaded && e.total) {
// 计算完成百分比
const percent = (e.loaded / e.total) * 100
// 将值设置为进度组件
progress.value = percent
}
})

上传目录



有一个非标准属性 webkitdirectory,使我们能够上传整个目录。
虽然最初仅针对基于 WebKit 的浏览器实施,但 WebkitDirectory 在微软 Edge 以及 Firefox 50 及以后也可用。然而,即使它有相对广泛的支持,它仍然不是标准的,不应该使用,除非你别无选择。



<input type="file" id="file-uploader" webkitdirectory />

拖放上传


主要的 JS 如下:


const dropZone = document.getElementById('drop-zone')
const content = document.getElementById('content')

dropZone.addEventListener('dragover', event => {
event.stopPropagation()
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
})
dropZone.addEventListener('drop', event => {
// 获取文件
const files = event.dataTransfer.files
// ..
})

用对象处理文件


使用 URL.createObjectURL() 方法从文件创建一个唯一的 URL。使用 URL.revokeObjectURL() 方法释放它。



DOM 和 URL.createObjectURL()URL.revokeObjectURL() 方法允许您创建简单的 URL 字符串,可用于引用任何可以使用 DOM 文件对象引用的数据,包括用户计算机上的本地文件。



示例:


<div>
<h1>使用 Object URL</h1>
<input type="file" id="file-uploader" accept=".jpg, .jpeg, .png" >
<div id="image-grid"></div>
</div>

const fileUploader = document.getElementById('file-uploader')
const reader = new FileReader()
const imageGrid = document.getElementById('image-grid')

fileUploader.addEventListener('change', (event) => {
const files = event.target.files
const file = files[0]

const img = document.createElement('img')
imageGrid.appendChild(img)
img.src = URL.createObjectURL(file)
img.alt = file.name
})

总结



  1. 表单元素 file

  2. 文件元数据

  3. 上传前预览图像

  4. URL.createObjectURL() 方法

  5. 使用 FileReader 的 readAsDataURL() 方法

  6. accept
    作者:整天想死的鱼
    来源:juejin.cn/post/7224402365452238906
    属性

收起阅读 »

5分钟速通Kotlin委托

1、什么是委托? 委托,又叫委托模式是一种常用的设计模式,它可以让一个对象在不改变自己原有的行为的前提下,将某些特定的行为委托给另一个对象来实现。它通过将对象之间的关系分离,可以降低系统的耦合度,提高代码的复用性和可维护性。 其中有三个角色,约束、委托对象和被...
继续阅读 »

1、什么是委托?


委托,又叫委托模式是一种常用的设计模式,它可以让一个对象在不改变自己原有的行为的前提下,将某些特定的行为委托给另一个对象来实现。它通过将对象之间的关系分离,可以降低系统的耦合度,提高代码的复用性和可维护性。


其中有三个角色,约束、委托对象和被委托对象。



  • 约束: 一般为接口也可以是抽象类,定义了某个行为。

  • 被委托对象: 负责执行具体的行为。

  • 委托对象: 负责将约束中定义的行为交给被委托对象。


2、Java中的委托


先来说一说委托在Java中的应用用一个简单的例子来说明:


老板在创业初期时因为只有一个人而需要负责产品的客户端UI服务器
这个时候老板负责的这些工作就可以被抽象出来形成一个约束接口:


public interface Work {
void app();
void ui();
void service();
}


public class Boss implements Work {

@Override
public void app() {
System.out.println("Boss doing app");
}

@Override
public void ui() {
System.out.println("Boss doing ui");
}

@Override
public void service() {
System.out.println("Boss doing service");
}
}

现在老板每天都在做这几件事:


public class Main {  
public static void main(String[] args) {
Boss boss = new Boss();
boss.app();
boss.ui();
boss.service();
}
}

输出:


Boss doing app
Boss doing ui
Boss doing service

运气不错,产品赚了不少钱,老板花钱雇了一个员工,将这些工作委托给他处理,自己直接脱产,只需要知道结果就可以了,于是就有了:


public class Employee implements Work{  
@Override
public void app() {
System.out.println("Employee doing app");
}

@Override
public void ui() {
System.out.println("Employee doing ui");
}

@Override
public void service() {
System.out.println("Employee doing service");
}
}


public class Boss implements Work{  
private Employee employee;

public Boss(Employee employee) {
this.employee = employee;
}

@Override
public void app() {
employee.app();
}

@Override
public void ui() {
employee.ui();
}

@Override
public void service() {
employee.service();
}
}


public class Main {  
public static void main(String[] args) {
Boss boss = new Boss(new Employee());
boss.app();
boss.ui();
boss.service();
}
}


Employee doing app
Employee doing ui
Employee doing service

这就是一个委托模式,老板委托对象)将 工作约束)委托给 员工被委托者)处理,老板并不关心每项工作具体是如何实现的,员工在完成工作后也会和老板汇报,就算这几项工作内容发生变化也只是员工需要处理。


3、Kotlin中的委托


那么针对上述的委托所描述例子在Kotlin中是如何实现的呢?


答案是使用关键字by,Kotlin专门推出了by来实现委托:
上述例子中的工作员工都不变:


interface Work {  
fun app()
fun ui()
fun service()
}


class Employee : Work {  
override fun app() {
println("Employee doing app")
}

override fun ui() {
println("Employee doing ui")
}

override fun service() {
println("Employee doing service")
}
}

老板这个类中,我们要将工作使用关键字by委托给员工


class Boss(private val employee: Employee) : Work by employee

就这么一行,实现了Java代码中老板类的效果。


fun main(args: Array<String>) {  
val boss = Boss(Employee())
boss.app()
boss.ui()
boss.service()
}

结果肯定是一样的。
那么by是如何实现Java中委托的效果的呢?通过反编译Kotlin字节码后我们看到:


public final class Boss implements Work {  
private final Employee employee;

public Boss(@NotNull Employee employee) {
Intrinsics.checkNotNullParameter(employee, "employee");
super();
this.employee = employee;
}

public void app() {
this.employee.app();
}

public void service() {
this.employee.service();
}

public void ui() {
this.employee.ui();
}
}

其实就是Java中实现委托的代码,Kotlin将它包成一个关键字by,效率大幅提升。


4、属性委托


上述说明的委托都属于类委托,而在Kotlin当中by不仅可以实现类委托,还可以实现属性委托,属性委托为Kotlin的一大特性,将对属性的访问委托给另一个对象。使用属性委托可以让我们编写更简洁、更模块化的代码,并且能够提高代码的可重用性。


4.1 如何实现属性委托?


Kotlin官方文档中给出了定义:


使用方式:val/var <属性名>: <类型> by <表达式>


在 by 后面的表达式是该 委托, 属性对应的 get() 和set()会被委托给它的 getValue() 与 setValue() 方法。 如果该属性是只读的(val)其委托只需要提供一个 getValue() 函数如果该属性是var则还需要提供 setValue()函数。例如:


   class Example {  
var str: String by Delegate()
}


    class Delegate {  
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "$thisRef, thank you for delegating '${property.name}' to me!"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
println("$value has been assigned to '${property.name}' in $thisRef.")
}
}


fun main(args: Array<String>) {  
val p = Example()
p.str = "Hello"
println(p.str)
}

因为属性str是可变的所以在Delegate类中实现了getValue和setValue两个函数,其中一共出现了三个参数分别是



  • thisRef :读出 str 的对象

  • property :保存了对 str 自身的描述 (例如你可以取它的名字)

  • value :保存将要被赋予的值


运行结果如下:


Hello has been assigned to 'str' in Example@1ddc4ec2.
Example@1ddc4ec2, thank you for delegating 'str' to me!

我们再将Example类中的代码转为Kotlin字节码反编译得到以下代码:


 public final class Example {  
// $FF: synthetic field
static final KProperty[] $$delegatedProperties = new KProperty[]{Reflection.mutableProperty1(new MutablePropertyReference1Impl(Example.class, "str", "getStr()Ljava/lang/String;", 0))};
@NotNull
private final Delegate str$delegate = new Delegate();

@NotNull
public final String getStr() {
return this.str$delegate.getValue(this, $$delegatedProperties[0]);
}

public final void setStr(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.str$delegate.setValue(this, $$delegatedProperties[0], var1);
}
}

就是创建了一个Delegate对象,再通过调用setVaule和getValue一对方法来获取和设置值的。


4.2 标准委托


在Kotlin标准库为委托提供了几种方法


4.2.1 延迟属性 Lazy


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

首次访问属性时才进行初始化操作,lazy() 是接受一个 lambda 并返回一个 Lazy <T> 实例的函数,返回的实例可以作为实现延迟属性的委托, 该lambda表达式将在第一次访问该属性时被调用,初始化属性并返回属性值,之后的访问将直接返回初始化后的值。


简单的例子:


fun main(args: Array<String>) {  
val str : String by lazy {
println("Hello str")
"lazy"
}
println(str)
println(str)
}

输出:


Hello str//只在第一次访问时执行
//后续访问只返回值
lazy
lazy

当我们使用 by lazy 委托实现延迟初始化时,Kotlin 编译器会生成一个私有的内部类,用于实现委托属性的懒加载逻辑,其内部包含一个名为 value 的属性,用于存储真正的属性值。同时,还会生成一个名为 isInitialized 的私有 Boolean 属性,用于标识属性是否已经初始化。


当我们首次访问被 lazy 修饰的属性时,如果它还未被初始化,就会调用 lazy 所接收的 lambda 表达式进行初始化,并将结果保存在 value 属性中。之后,每次访问该属性时,都会返回 value 中存储的属性值。


4.2.2 可观察属性 Observable


Delegates.observable() 接受两个参数:初始值与修改时处理程序(handler)。 每当我们给属性赋值时会调用该处理程序(在赋值执行)。它有三个参数:被赋值的属性、旧值与新值:


class User {  
var name : String by Delegates.observable("no value") {
property, oldValue, newValue ->
println("property :${property.name}, old value $oldValue -> new value $newValue")
}
}


fun main() {
val user = User()
user.name = "Alex"
user.name = "Bob"
}


property :name, old value no value -> new value Alex
property :name, old value Alex -> new value Bob

如果你想截获赋值并“否决”它们,那么使用 vetoable() 取代 observable()。 在属性被赋新值生效之前会调用传递给 vetoable 的处理程序,简单来说就是利用你设定的条件来决定设定的值是否生效,还是以上述代码为例,在User中增加一个年龄属性:


var age : Int by Delegates.vetoable(0) {  
_, oldValue, newValue ->
println("old value : $oldValue, new value : $newValue")
newValue > oldValue
}

在这里我们设定了输入的年龄大于现在的年龄才生效,运行一下看看输出什么:
0
old value : 0, new value : 20
20
old value : 20, new value : 19
20
old value : 20, new value : 25
25


0
old value : 0, new value : 20
20
old value : 20, new value : 19
20
old value : 20, new value : 25
25

4.2.3 将属性储存在映射中


映射(map)里存储属性的值。 这经常出现在像解析 JSON 或者做其他“动态”事情的应用中。 在这种情况下,你可以使用映射实例自身作为委托来实现委托属性。


class User(map: MutableMap<String, Any?>) {  
val name: String by map
val age: Int by map
}


fun main(args: Array<String>) {  
val user = User(
mutableMapOf(
"name" to "Alex",
"age" to 18
)
)
println("name : ${user.name}, age : ${user.age}")
}

输出:


name : Alex, age : 18

5、总结


委托是一种常见的软件设计模式,旨在提高代码的复用性和可维护性,在 Java 中,委托通过定义接口和实现类来实现。实现类持有接口的实例,并将接口的方法委托给实例来实现。这种方式可以实现代码的复用和解耦,但是需要手动实现接口中的方法,比较繁琐,而在 Kotlin 中,委托通过by关键字实现委托其中还包括了属性委托一大特性,Kotlin 提供了很多内置的属性委托,比如延迟属性、映射属性等。此外,Kotlin 还支持自定义属性委托。自定义属性委托需要实现 getValuesetValue 方法,用于获取和设置属性的值,与 Java 的委托相比,Kotlin 的属性委托更加方便和简洁,减少样板代码。


6、感谢



  1. 校稿:ChatGpt

  2. 文笔优化:ChatGpt


参考:Kotlin官方文档:委托 Kotlin官方文档:属性委托


作者:Otaku_尻男
链接:https://juejin.cn/post/7223258679259873317
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

哎,今天在公司的最后一天了

“啊!” 我今天居然被通知裁员了!!! 虽然之前一直盛传我们这边要裁员,但是我想着,应该一时半会轮不到我这边,我感觉我们项目还是相对挣点钱的。但是呢,心里也是挺忐忑的。 今天,我正在那全心全意的敲代码,敲的正起劲呢。我突然看到我们项目经理被叫走了。啊?我一想啥...
继续阅读 »

“啊!” 我今天居然被通知裁员了!!!


虽然之前一直盛传我们这边要裁员,但是我想着,应该一时半会轮不到我这边,我感觉我们项目还是相对挣点钱的。但是呢,心里也是挺忐忑的。


今天,我正在那全心全意的敲代码,敲的正起劲呢。我突然看到我们项目经理被叫走了。啊?我一想啥情况?要开会的话怎么只叫我们项目经理,怎么不叫我呀。


难道是要裁员?!难道真的是要裁员?!然后我就看着我们项目经理和我们的上级领导他们一起坐在小屋里聊了半天,啊,我的小心脏呀,我心里就祈祷呀:“千万不要裁员啊!千万不要裁员呀!千万不要裁员呀!!!”


等我们项目经理出来之后,他走到了我这边,然后 “啪” 拍了一下我的肩膀,然后 “哎” 叹了口气。他说:“我们这个项目要被裁掉了。”


我说心里特别失落,但还故作镇定的说:“为啥?我们的项目不是还挣钱呢吗?”


项目经理说:“哎,挣钱也不行。我们现在不需要这么多人了。我们现在的项目,没有一个大的发展了啊!你先等一会吧,等一会他们还得找你谈。”。他走的时候,又拍了拍我肩膀。


哎,当时我就感觉我心里呀那种失落感呀,没法说的那种感觉。果然,没一会,我们经理就来了。他过来之后跟我说:“走,请你到小屋里喝点水。”


我苦笑着跟他说:“经理,我能不去吗?我现在不渴。”


然后我们经理说:“哎,不行呀,我都已经给你倒好了,走吧走吧,歇会去。”


然后我就默默的跟他去了。进去之后呢,我们俩都坐下了。经理跟我笑着说:“恭喜你呀,脱离苦海了。”


哎,我当时心情比较低落,我说:“是呀,脱离苦海了,但又上了刀山了呀。哈哈哈。。。”


然后他说:“哎,确实是,没办法,现在,哎,公司也不容易。现在有一些项目确实得收缩。”


我说:“哎,这也没啥,这都很正常。咱公司还算不错的,最起码还让过了个节。很多公司什么都不管,就这样让走了呀。哎!”


后面我们就谈了一些所谓的那种离职补偿啊,等等一些列的东西**。**


反正当时感觉着吧,就是,嗯,聊完之后呢就准备出去嘛。然后走路的时候呀,就感觉这个腿上啊就跟绑了铅块一样。


当时我感觉,哎,裁员这玩意怎么说呢,都没法回去和亲人说呀,弄的一下午这个心里慌慌的。怎么跟家人交代呢?人至中年居然混成这样,哎!!!


郑重声明,本文不是为了制造焦虑,发文的原因有两个:



  1. 我今年 33 了,一方面给大家展现下一个普通程序员 35 岁后能咋样?是送外卖还是跑滴滴?难道真的就找不到工作了吗?

  2. 感觉我并没有走好自己的人生路,把自己的经历写出来发到网上,让年轻人以我为鉴,能更好的走好自己的人生路。

作者:程序员黑黑
链接:https://juejin.cn/post/7110887208953282590
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

写在入职九周年这天,讲讲这些年的心路历程

往前翻翻才意识到已经很长很长时间没有写文章,大概具体有多长?感觉上有一光年那么长。 今天,刚好是入职九周年,竟然遇到周末,省了奶茶钱。是的,没错,我在一个窝里面趴了九年。 这些年,彷徨过,迷茫过,孤独过,也充满热血的奋斗过,激情的追求过,有犹豫,有脆弱,也有失...
继续阅读 »

往前翻翻才意识到已经很长很长时间没有写文章,大概具体有多长?感觉上有一光年那么长。


今天,刚好是入职九周年,竟然遇到周末,省了奶茶钱。是的,没错,我在一个窝里面趴了九年。


这些年,彷徨过,迷茫过,孤独过,也充满热血的奋斗过,激情的追求过,有犹豫,有脆弱,也有失落。在悠悠岁月中,能及时不断做出调整,让自己学会享受工作带来的乐趣,学会慢慢成长。在当下浮躁的时代,写些闲言碎语,给诸君放松下心情,缓解压力。


过去的那些年


入职那天,阳光明媚,清风柔和,大厦旁边的道路开满迎春花,连空气都是甜的,让人不由自主地深呼吸,可以闻到花香草香,还有阳光的味道。


那一天也是为数不多来上班较早的一天,哦吼有大草坪,哦哟还有篮球场,这楼还波浪线,牛批,B座这个大厅有点大,有点豪华,牛批牛批,头顶上这看着怎么像熊掌,12345,设计真不孬啊。慢慢的大厅上聚集了很多人,有点吵,咋也没人组织一下呢,大家都很随意的站着等待,陆陆续续有员工来上班。


“XXX”,听到有人喊我名字,吓我一跳,还以为偷看小姐姐被发现了。


“站到靠近电梯入口的最右一列,第一个位置上”,“XX,去站在他后面”,“大家按我叫名字的顺序排好队,咱们准备上楼了”。


那会儿,AI 还不会人脸识别过闸机。


呦呵,这公司真牛批,还有扶手电梯。跟着带路的同学来到三楼五福降中天会议室,一个挺老大的屋子,还有各种数不过来的高大上仪器电子设备,一周后,也是在这里,我和厂长面对面聊聊人生。坐稳扶好后,HR 同学开始入职培训,我摸摸新电脑,摸摸工卡牌,心里美滋滋,想到未来几年,将在这样美妙的环境中度过,喜不胜收,甚至我都闻到了楼下食堂啵啵鱼的香味。


培训刚结束。


“我叫到名字的同学,跟我来。XXX,XX……”,纳尼???中午还管饭??这福利也太好了吧,真不用吧,我自己能找到食堂,再说,你知道我喜欢吃什么吗?“跟着我走,咱们去北门做班车,去另一个办公楼,你们的工位不在这儿。”不在这?还坐班车?what?被外包了?她刚刚喊我了吗???差不多六七个同学,跟着楼长鱼贯而出,下楼,走小路,几分钟后,上了班车。司机大哥,一脚油门,就带着我远离啵啵鱼。


大约10分钟,也有可能是5分钟,或者15分钟,按照现在萝卜快跑无人车的速度,是7分钟。来到一栋圆了咕咚的,长的像长南瓜的楼,它有一个很科技感的名字,“首创空间”,就是这个空间,不仅给我带来技术的成长,还有十几斤的未来多年甩也甩不掉的肥肉。没有啵啵鱼的日子,相见便成为世上最奢侈的愿望。


大约是两年后的初春 ,准确的说,不到两年,记得是二、三月份,北京的PM2.5比较严重的时候,鼻子还不会过敏,也没学会发炎,眼睛也不知道怎么迎风流泪,总之,我们要搬家了。


“科技园”\color{#333333}“科技园”,听听,听听,多好的名字,长得像无穷大与莫比乌斯环,楼顶带跑道,位置也牛批,毗邻猪厂、鹅厂、渣浪,北邻联想,西靠壹号院,远眺百望山,低头写代码,啧啧,美滋滋的日子又来了。当时还没有共享单车,晚饭时蹭着班车和一群小伙伴过去看新工位,喏,不错不错,挺大,位置离厕所不远,不错不错,会议室安静舒适好多个,不错不错。重点来了,食堂大的离谱,还有很多美食,连吃几个月,基本不重样。吃过几次啵啵鱼,与大厦简直天壤之别,怀念。


机会说来就来,几个月后的一天,发生了一件大事。我回到了梦开始的地方,那让人朝思暮想的啵啵鱼,那让人魂牵梦绕的味道,那让人无法忘怀的美妙口感。


清醒一下.gif


命运说变就变,国庆休假回来,食堂换了运营商,我他么……¥#%@%#!@#@!&%


一直没变的:不忘初心,砥砺前行。


曾经觉得自己无所不能,可以改变世界,总幻想像蝴蝶一样扇扇翅膀,亚马逊的雨林就会刮起大风。食堂吃的多了,越来越认识到自己的影响力微乎其微,我们能做到,是把交代的工作做好,做到极致,应该就是对公司最大的回馈了,也对得起日渐增多的白发。


早些年,搞视频直播,课程学习,每天研究各种编解码技术,与视频流打交道,看过不少底层技术原理书籍,探索低延迟的 P2P 技术,枯燥,乏味,也跟不上时代变化,觉得自己会的那些早晚被淘汰,技术乏陈革新的速度超乎想象,而你所负责的,恰恰不是那些与时代贴合度较高的业务,边缘化。


怎么破?


从来没有人限制你,不允许你去学习。\color{red}从来没有人限制你,不允许你去学习。


因为恰巧在做课程的直播、录播,需要特别关注课程内容,主要担心出现线上问题,刚好利用这个契机,了解到很多跨专业,跨部门的业务,当时给自己的宗旨是,“只要有时间,就去听课”,“凡是所学,皆有收获”。前后积累近千小时的学习时长,现在想想,觉得都有些不可能,怎么做到的,是人吗?这是人干的事?


日常工作,专心不摸鱼,积极努力提高工作效率,解决研发任务,配合 peer 做好产品协同。晚饭后,专心研究 HTML大法,通勤路上手机看文档,学 api 用法,学习各种牛批的框架,技巧,逛各大论坛,写博客做积累,与各种人物斯比,每天晚上十点,跑步半小时,上床睡觉,生活很规律。


机缘巧合下,我终于从一个小坑,成功跳到一个大坑,并至今依然在坑中。那天,我想起了啵啵鱼。


16797732_0_final.png


一直在变的


团队在变,用两只手数了数,前前后后换了七次 leader,管理风格比五味杂陈还多一味,有的事无巨细,有的不闻不问,有的给你空间让你发挥,有的完全帮不上忙。怎么破?尊重,学习,并努力适应,不断调整心态,适应环境的变化。


业务在变,这么多年数过来,参与过的产品没有一百也有八十了,真正能够长期坚守下来的产品不多,机会可遇不可求,能得一二,实属幸运。把一款产品从零做到一,很容易;再做到十,很难但能够完成;再从十到百,几乎不可能。互联网公司怎么会有这样的产品存在,少之又少。


技能在变,经历过前端技术栈井喷的同学都深有体会,学不动的感受。


时代在变,社会在变,人心也在改变。


曾经多次想过换个环境,换一个坑趴着,毕竟很多机会还是很诱人的。印象最深的一次,是在某年夏天,对手头的工作实在是感到无聊。由于前一年小伙伴们的共同努力,产品架构设计相当完美,今年的工作接近于智力劳动转变为纯人力的重复的机械的体力劳动,对产品建设渐失激情,每天如同行尸走肉般的敲键盘,突然意识到,自己到了职业发展瓶颈期。如何抉择,走或留,临门一脚的事,至于这一脚踢向何方,还未知。


忧思几天后,去找 leader 沟通,好家伙,他让我呆在这里别动,帮他稳住团队,他要撤,一两个月的事。好家伙,你不殿后掩护我们,自己先撂了,还说可以试试带团队,我说大哥,也没几个人呀。他说你还能招兵买马,试试新的角色,体会下不同的视角,很好的机会。坑,绝对的大坑,我他么竟然义不容辞的答应了。


好在,不枉大家这么多年的认可,团队战斗力很强大。


你觉得什么是幸福



  • 有独处的时间

  • 有生活的追求

  • 工作能给你带来乐趣


颐和园.jpg


前些日子,给娃拿药请了半天假,工作日人不多,十点多就完事了,看看时间地铁回去差不多到公司刚好中午饭。医院出来看到很多小黄车,美团那种新式的自行车,看着很不错,还没体验过,特别想兜几圈。查地图,距离公司有22公里,按照骑行的速度推算,70分钟也差不多到了。打定主意后,书包里翻出俩水煮蛋(鬼知道我为什么早上去公司拿了俩鸡蛋)和一瓶水(鬼使神差的早上往书包放的),算是吃过早饭了。于是一个人,一条狗,开局一把刀,沿着滨河路,经过木樨地,二里河,中关村南大街,北大街,信息路,上地西路回来了。您还别说,就是一个地道。竟然还路过玉渊潭,还遇到了封路限行,静悄悄的圆明园东路,过国图,还有数不清的大学,附中,有那么一瞬间好想回母校去看看,总之,重点是顺路吃到心心念的煎饼果子。


路上给媳妇打电话,这小妞竟然说我疯了,疯了?你懂个屁,这叫幸福。


人生的乐趣


人生的乐趣何在?你的答案和我的答案可能不一样,作为打工人,我知道,肯定不是工作。但似乎又不能没有工作,不工作我们怎么活着?怎么在这个社会上,换取资源,立足于当下,着眼于未来。说回工作,最后悔的事,曾经有那么一小段,人际关系没有处理好,可能造成误会,当时来自于我对某些事情的不表态,默许的态度,十周年前修复它。最快乐的时光,是和大家一起沉浸在技术点的探讨,Bug的跟进定位,发现问题解决问题的成就感;参与产品的规划,出谋划策,影响他人;挑灯夜战,专注于产品的 DDL,为上线争分夺秒的努力前行,感受团队的力量。


这个春天,爬过许多京郊的小山头,站在山顶,凝视着壮丽的景色,总以为自己是秦始皇。不惑之前,去征服贡嘎雪山。


总之,故事太多讲也讲不完,作为一个九年的老东西,我是不会爆金币的。


到结尾了,给点建议吧


建议?给不了给不了,我自己还没活明白。


历史的滚滚车轮中,每个生命都很渺小,时代一直在变,抓住机遇,让自己成长,多读书,沉下心,慢慢来。


作者:水鳜鱼肥
链接:https://juejin.cn/post/7222509109948989501
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

该写好代码吗?我也迷茫了

我在抖音上看到当当网创始人李国庆发了一条视频,感觉他说的挺有意思。 他说,作为企业中层管理者,该不该有自己的山头,用于自我保护,这让人很迷茫。 其实我也迷茫过。我猜测,我们迷茫的可能是同一件事。 程序员内部,曾经流传着这样几句圣经: 代码写的好,写得快,会像...
继续阅读 »

我在抖音上看到当当网创始人李国庆发了一条视频,感觉他说的挺有意思。


他说,作为企业中层管理者,该不该有自己的山头,用于自我保护,这让人很迷茫。


其实我也迷茫过。我猜测,我们迷茫的可能是同一件事。


程序员内部,曾经流传着这样几句圣经:



代码写的好,写得快,会像个闲人。代码有注释,逻辑清晰,任何人都能轻松取代你。


代码写的烂,只有自己能看懂,一次次救火,你反而会成为团队不可缺少的人才。



那么,问题来了:到底该把事情干好呢,还是不要干好呢?


这是一个问题吗?当然是往多快好省了做呀!


我以前的想法就是这样。


我做底层员工时,代码写的清晰简洁,高效严谨。有时候我会因为计算循环次数而费心设计。如果循环层数太多,我会先把关键数据放到Map里,后续可以直接取用。我也会关注代码的可读性,尽量少套几层循环,命名兼顾字符长度和表意指向,如果代码太多就抽离成一个方法函数,并且要在代码里注释清楚。而这些操作,在形成习惯之后,是不会影响开发效率的。反而在某些情况下,还会提高效率。因为不管逻辑多复杂,不管过去多久,一看就能懂,很容易排查问题和他人接手。


我做中层管理时,除了培养团队内每个员工都能做到上述标准外,让我投入很大精力的事情就是“去我化”。也就是通过手段、流程、文化做到团队自治。我在团队时,大家能很高效地完成工作。当我短时间内离开时,大家依然能依靠惯性维持高效的状态。


我的想法很单纯:不管我是一线开发,还是中层管理,我修炼的都是自己。当你具备一定的职场能力时,你就是值钱的。作为员工你能把手头的活干得又快又好,作为管理你能把团队管理得积极健康。这就是亮点。不要在意别人的看法,你只需要修炼自己。当你具备了这个能力,这里不适合你,好多地方都会求此类贤人若渴。


其实,后面慢慢发现,这种想法可能还值得商榷。


因为创业和打工的区别还是挺大的。


创业是给自己干,想干好是肯定的,谁都不愿意面对一团糟。


我看明朝的历史,建文帝朱允炆刚登基时,就想削弱其他藩王的势力,加强自己的权力。当建文帝打算办燕王朱棣时,朱棣就起兵造反,自己做了皇帝。我感觉朱棣其实是自卫。后来,感情朱棣当上皇帝的第一件事,也是继续削弱藩王的势力。其实,大家都一样。


打工就不一样了,干得好不好,不是你说了算,是你的上级领导说了算,周围同事说了算,规章制度说了算。


因此,扁鹊三兄弟的现象就出现了。


扁鹊大哥,医术最高,能预防病人生病。扁鹊二哥,医术很高,能消灭病症在萌芽阶段。到扁鹊这里,只能到人快死了,开刀扎针,救人于生死之间。但是,世人都称扁鹊为神医。


如果你的领导是一个技术型的,同时他还能对你的工作质量做一些审查,那么他对你的评价,可能还具有些客观性。


但是,如果你碰到的是一个行政型的领导,他不是很懂医术,那他就只能像看医生治病一样,觉得救治好了快要死的人,才是高人。而对于预防重症这类防患于未然的事情,他会觉得你在愚弄他。


事实上,不少中小企业的领导,多是行政型领导。他们常常以忠心于老板而被提拔。


因此,为了自己获得一些利益。有些人常常是先把大病整出来,然后再治好,以此来体现自己的价值。


老维修工建设管道,水管里流的是燃气,燃气管的作用是排废水。出了问题,来一批一批的新师傅,都解决不了,越弄越乱。结果老维修工一出手,就把问题解决了。老板觉得,哎呀,看,还得是我的老师傅管用。


相反,如果你把事情打理的井井有条,没有一丝风浪,就像扁鹊大哥一样,我都不得病,还养你干啥,随便换谁都可以。这样的话,往往你的结局就多是被领导忽视。


我有好几个大领导都在大会上说过:你请假一周,你的部门连给你打一个电话的都没有,这说明你平时疏于管理,对于团队没有一丝作用!


从业这么多年,见过各种现实,很讽刺,就像是笑话。有一个同事,给程序加了一个30秒后延时执行。后来,领导让他优化速度,他分4次,将30秒调到5秒。最后领导大喜,速度提高6倍,他被授予“超级工匠”的荣誉称号。


一个是领导的评价。还有一个是同事的评价。


我有一次,在自己的项目组里搞了个考核。考核的核心就是,干好了可以奖,干差了便会罚。我觉得这样挺好,避免伤了好人心,杜绝隧了闲人的意。结果因为其他项目组没有搞,所以我成了众矢之的。他为什么要搞?人家项目组都没有,就他多事,咱们不在他这里干了!


我想,国内的管理可能不是一种客观的结果制。而是另一种客观的”平衡制“。


就像是古代的科举,按照才学,按照成绩来说,状元每届多是江南的。但是,皇帝需要平衡,山西好久没有出个状元了,点一个吧。河南今年学子闹事,罢考,为了稳一稳人心,给一个吧。江南都这么多了,少一个没什么关系的。


他更多是要让各方都满意。一个”平衡“贯穿了整个古今现代的价值观。


有人遵循自己的内心做事,也有人遵循别人的内心做事。不管遵循哪一方,坚持就好,不要去轻易比较,各自有各自的付出和收获。


当路上都在逆行时,你会发现,其实是你在逆行。


作者:TF男孩
链接:https://juejin.cn/post/7224764099187966010
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么面试聊得很好,转头却挂了?

了解校招、分享校招知识的学长来了! 四月中旬了,大家面试了几场? 大家有没有这样的感受:面试的时候和面试官聊得火热朝天,气味相投。 面完觉得自己稳了,已经在畅想入职事宜了,结果一封感谢信让人瞬间清醒。 不少同学应该有这样的经历。 学长也曾经有过:面试两小时,...
继续阅读 »

了解校招、分享校招知识的学长来了!


四月中旬了,大家面试了几场?


大家有没有这样的感受:面试的时候和面试官聊得火热朝天,气味相投。


面完觉得自己稳了,已经在畅想入职事宜了,结果一封感谢信让人瞬间清醒。


image.png


不少同学应该有这样的经历。


学长也曾经有过:面试两小时,自觉面试问题回答得不错,但是面试官只说:你回去等消息吧。


经历过面试的同学应该懂”回去等消息“这句话的杀伤力有多大。


在此也想先和那些面试多次但还是不通过的朋友说:千万别气馁!


找工作看能力,有时候也看运气,面试没有通过,这并不说明你不优秀。


所有,面试未通过,这其中的问题到底出在哪呢?


01 缺乏相关经验或技能


如果应聘者没有足够的经验或技能来完成职位要求,或者面试的时候没有展现自己的优势,那么失败很常见。


而面试官看重也许就是那些未展现的经验或技能,考察的是与岗位的匹配程度。


02 没有准备充分


每年学长遇到一些同学因为时间安排不当,没有任何了解就开投简历。


而被春招和毕业论文一起砸晕的同学更是昏头转向。


如果没有花足够的时间和精力来了解公司和职位,并准备回答常见的面试问题,那么可能表现不佳。


03 与招聘人员沟通不畅


在面试过程中,面试官真的非常看重沟通效果!


如果应聘者无法清晰地表达自己的想法,或者不能理解面试官的问题,那么可能会被认为不适合该职位。


04 缺乏信心或过度紧张


学长也很理解应届生的局促感,以及面对面试官的紧张。


image.png


但是如果面试场上感到非常紧张或缺乏自信,那么可能表现得不自然或不真诚。


好像,面试的时候需要表现得自信、大方,才能入面试官的眼。


05 不符合公司文化或价值观


企业文化,也成为考察面试者的一个利器。


如果应聘者的个人品格、行为或态度与公司文化或价值观不符,那么可能无法通过面试。


比如一个一心躺平的候选人,面对高压氛围,只会 Say goodbye。


image.png


06 其他候选人更加匹配


如果公司有其他候选人比应聘者更加匹配该职位,那么应聘者可能无法通过面试。


一个岗位,你会面对强劲的对手。


同样学历背景,但有工作经验比你丰富的;


工作经验都 OK,但有其他学历背景比你合适,或稳定性比你高的面试者。


经常有同学发帖吐槽面试经历:一场群面,只有 Ta 是普通本科生,其余人均 Top 学校研究生学历。


面试不容易,祝大家都能斩获心仪的 Offer!


作者:林行学长
链接:https://juejin.cn/post/7221131892426096701
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

程序员如何给变量起名字

程序员如何给变量起名字 在编写代码时,为变量命名是非常重要的。良好的命名习惯可以提高代码的可读性和可维护性,使得其他开发者能够更容易地理解你的代码。在这篇文章中,我们将讨论程序员如何为变量选择合适的名称。 规范 首先,需要了解所用编程语言和项目的命名规范。不同...
继续阅读 »

程序员如何给变量起名字


在编写代码时,为变量命名是非常重要的。良好的命名习惯可以提高代码的可读性和可维护性,使得其他开发者能够更容易地理解你的代码。在这篇文章中,我们将讨论程序员如何为变量选择合适的名称。


规范


首先,需要了解所用编程语言和项目的命名规范。不同的编程语言和团队可能有不同的命名约定。例如,Python 中通常使用下划线分隔单词(snake_case),而 Java 和 JavaScript 则倾向于驼峰式大小写(camelCase)。遵循一致的命名规则会使得整个代码库更具统一性,降低学习成本。


见名知意


一个好的变量名应该尽可能描述它代表的实际含义。换句话说,当其他开发者看到变量名时,他们应该能够猜测出它表示什么以及如何使用。


好例子



  • user_name 代表用户名;

  • password_hash 表示经过哈希处理的密码;

  • email_list 是一个邮件列表。


不好的例子



  • x, y, z 这样的简单字母命名无法反映变量的实际含义(除非在特定场景下,如表示坐标或数学公式中);

  • tempdata 等过于泛化,无法直接理解其用途;

  • string1array2 只提供了数据类型信息,但未说明其用途。


避免冗长


虽然应该让变量名具有描述性,但同时需要避免使用冗长的名称。太长的名称可能会导致代码难以阅读和维护。通常情况下,选择简洁明确的单词组合更为可取。


好例子



  • index

  • user_count


不好的例子



  • the_index_of_the_current_element_in_the_list

  • the_total_number_of_users_in_the_database


使用专业术语


如果你正在编写涉及某个领域知识的代码,可以使用该领域的专业术语作为变量名。这将使得对该领域能较好理解的开发者更容易理解你的代码意图。


好例子



  • 在计算几何领域,变量名 centroid 表示多边形的质心;

  • 在密码学领域,变量名 salt 代表加密时混入的额外值。


处理复数


当变量包含一系列对象时,最好使用复数名称。这样可以让读者知道它是一个集合类型(如列表、数组、集等),而不仅仅包含一个对象。


好例子



  • users

  • files


避免重名和相似命名


为了提高代码的可读性,应尽量避免在同一作用域内使用相似或容易混淆的变量名。


不好的例子



  • user_listusers_list

  • convert_to_stringtransform_to_string


结论


良好的命名习惯对于编写高质量的代码至关重要。请确保你所选择的变量名既简洁明了,又具有描述性,并且遵循项目规范。这将使得其他开发者能够更容易地理解和维护你的代码。


作者:牙叔教程
链接:https://juejin.cn/post/7225490584058724410
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android补间动画

帧动画是通过连续播放图片来模拟动画效果,而补间动画开发者只需指定动画开始,以及动画结束"关键帧",而动画变化的"中间帧"则由系统计算并补齐! 1.补间动画的分类和Interpolator Andoird所支持的补间动画效果有如下这五种,或者说四种吧,第五种是...
继续阅读 »

帧动画是通过连续播放图片来模拟动画效果,而补间动画开发者只需指定动画开始,以及动画结束"关键帧",而动画变化的"中间帧"则由系统计算并补齐!



1.补间动画的分类和Interpolator


Andoird所支持的补间动画效果有如下这五种,或者说四种吧,第五种是前面几种的组合而已。




  • AlphaAnimation: 透明度渐变效果,创建时许指定开始以及结束透明度,还有动画的持续时间,透明度的变化范围(0,1),0是完全透明,1是完全不透明;对应<alpha/>标签!

  • ScaleAnimation:缩放渐变效果,创建时需指定开始以及结束的缩放比,以及缩放参考点,还有动画的持续时间;对应<scale/>标签!

  • TranslateAnimation:位移渐变效果,创建时指定起始以及结束位置,并指定动画的持续时间即可;对应<translate/>标签!

  • RotateAnimation:旋转渐变效果,创建时指定动画起始以及结束的旋转角度,以及动画持续时间和旋转的轴心;对应<rotate/>标签

  • AnimationSet:组合渐变,就是前面多种渐变的组合,对应<set/>标签



在开始讲解各种动画的用法之前,我们先要来讲解一个东西:Interpolator


用来控制动画的变化速度,可以理解成动画渲染器,当然我们也可以自己实现Interpolator接口,自行来控制动画的变化速度,而Android中已经为我们提供了五个可供选择的实现类:



  • LinearInterpolator:动画以均匀的速度改变

  • AccelerateInterpolator:在动画开始的地方改变速度较慢,然后开始加速

  • AccelerateDecelerateInterpolator:在动画开始、结束的地方改变速度较慢,中间时加速

  • CycleInterpolator:动画循环播放特定次数,变化速度按正弦曲线改变:Math.sin(2 * mCycles * Math.PI * input)

  • DecelerateInterpolator:在动画开始的地方改变速度较快,然后开始减速

  • AnticipateInterpolator:反向,先向相反方向改变一段再加速播放

  • AnticipateOvershootInterpolator:开始的时候向后然后向前甩一定值后返回最后的值

  • BounceInterpolator: 跳跃,快到目的值时值会跳跃,如目的值100,后面的值可能依次为85,77,70,80,90,100

  • OvershottInterpolator:回弹,最后超出目的值然后缓慢改变到目的值


2.各种动画的详细讲解


这里的android:duration都是动画的持续时间,单位是毫秒


1)AlphaAnimation(透明度渐变)


anim_alpha.xml


<alpha xmlns:android="http://schemas.android.com/apk/res/android"  
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:fromAlpha="1.0"
android:toAlpha="0.1"
android:duration="2000"/>

属性解释:


fromAlpha :起始透明度toAlpha:结束透明度透明度的范围为:0-1,完全透明-完全不透明


2)ScaleAnimation(缩放渐变)


anim_scale.xml


<scale xmlns:android="http://schemas.android.com/apk/res/android"  
android:interpolator="@android:anim/accelerate_interpolator"
android:fromXScale="0.2"
android:toXScale="1.5"
android:fromYScale="0.2"
android:toYScale="1.5"
android:pivotX="50%"
android:pivotY="50%"
android:duration="2000"/>

属性解释:




  • fromXScale/fromYScale:沿着X轴/Y轴缩放的起始比例

  • toXScale/toYScale:沿着X轴/Y轴缩放的结束比例

  • pivotX/pivotY:缩放的中轴点X/Y坐标,即距离自身左边缘的位置,比如50%就是以图像的中心为中轴点



3)TranslateAnimation(位移渐变)


anim_translate.xml


<translate xmlns:android="http://schemas.android.com/apk/res/android"  
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:fromXDelta="0"
android:toXDelta="320"
android:fromYDelta="0"
android:toYDelta="0"
android:duration="2000"/>

属性解释:




  • fromXDelta/fromYDelta:动画起始位置的X/Y坐标

  • toXDelta/toYDelta:动画结束位置的X/Y坐标



4)RotateAnimation(旋转渐变)


anim_rotate.xml


<rotate xmlns:android="http://schemas.android.com/apk/res/android"  
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:fromDegrees="0"
android:toDegrees="360"
android:duration="1000"
android:repeatCount="1"
android:repeatMode="reverse"/>

属性解释:




  • fromDegrees/toDegrees:旋转的起始/结束角度

  • repeatCount:旋转的次数,默认值为0,代表一次,假如是其他值,比如3,则旋转4次另外,值为-1或者infinite时,表示动画永不停止

  • repeatMode:设置重复模式,默认restart,但只有当repeatCount大于0或者infinite或-1时才有效。还可以设置成reverse,表示偶数次显示动画时会做方向相反的运动!



5)AnimationSet(组合渐变)


非常简单,就是前面几个动画组合到一起而已


anim_set.xml


<set xmlns:android="http://schemas.android.com/apk/res/android"  
android:interpolator="@android:anim/decelerate_interpolator"
android:shareInterpolator="true" >

<scale
android:duration="2000"
android:fromXScale="0.2"
android:fromYScale="0.2"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1.5"
android:toYScale="1.5" />

<rotate
android:duration="1000"
android:fromDegrees="0"
android:repeatCount="1"
android:repeatMode="reverse"
android:toDegrees="360" />

<translate
android:duration="2000"
android:fromXDelta="0"
android:fromYDelta="0"
android:toXDelta="320"
android:toYDelta="0" />

<alpha
android:duration="2000"
android:fromAlpha="1.0"
android:toAlpha="0.1" />

</set>

3.写个例子来体验下


好的,下面我们就用上面写的动画来写一个例子,让我们体会体会何为补间动画:首先来个简单的布局:activity_main.xml


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<Button
android:id="@+id/btn_alpha"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="透明度渐变" />

<Button
android:id="@+id/btn_scale"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="缩放渐变" />

<Button
android:id="@+id/btn_tran"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="位移渐变" />

<Button
android:id="@+id/btn_rotate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="旋转渐变" />

<Button
android:id="@+id/btn_set"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="组合渐变" />

<ImageView
android:id="@+id/img_show"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="48dp"
android:src="@mipmap/img_face" />

</LinearLayout>

好哒,接着到我们的MainActivity.java,同样非常简单,只需调用AnimationUtils.loadAnimation()加载动画,然后我们的View控件调用startAnimation开启动画即可。


public class MainActivity extends AppCompatActivity implements View.OnClickListener{

private Button btn_alpha;
private Button btn_scale;
private Button btn_tran;
private Button btn_rotate;
private Button btn_set;
private ImageView img_show;
private Animation animation = null;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
bindViews();
}

private void bindViews() {
btn_alpha = (Button) findViewById(R.id.btn_alpha);
btn_scale = (Button) findViewById(R.id.btn_scale);
btn_tran = (Button) findViewById(R.id.btn_tran);
btn_rotate = (Button) findViewById(R.id.btn_rotate);
btn_set = (Button) findViewById(R.id.btn_set);
img_show = (ImageView) findViewById(R.id.img_show);

btn_alpha.setOnClickListener(this);
btn_scale.setOnClickListener(this);
btn_tran.setOnClickListener(this);
btn_rotate.setOnClickListener(this);
btn_set.setOnClickListener(this);

}

@Override
public void onClick(View v) {
switch (v.getId()){
case R.id.btn_alpha:
animation = AnimationUtils.loadAnimation(this,
R.anim.anim_alpha);
img_show.startAnimation(animation);
break;
case R.id.btn_scale:
animation = AnimationUtils.loadAnimation(this,
R.anim.anim_scale);
img_show.startAnimation(animation);
break;
case R.id.btn_tran:
animation = AnimationUtils.loadAnimation(this,
R.anim.anim_translate);
img_show.startAnimation(animation);
break;
case R.id.btn_rotate:
animation = AnimationUtils.loadAnimation(this,
R.anim.anim_rotate);
img_show.startAnimation(animation);
break;
case R.id.btn_set:
animation = AnimationUtils.loadAnimation(this,
R.anim.anim_set);
img_show.startAnimation(animation);
break;
}
}
}

运行效果图



有点意思是吧,还不动手试试,改点东西,或者自由组合动画,做出酷炫的效果吧。


作者:向阳逐梦
链接:https://juejin.cn/post/7225604123008401465
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Swift快速集成环信IM iOS SDK并实现单聊

本文介绍如何使用swift快速集成环信即时通讯 IM iOS SDK 实现单聊。前提条件• Xcode (推荐最新版本)。• 安装 iOS 10.0 或更高版本的 iOS 模拟器或 Apple 设备。• CocoaPods 1.10.1 或更高版本。• 有效的...
继续阅读 »

本文介绍如何使用swift快速集成环信即时通讯 IM iOS SDK 实现单聊。

前提条件
• Xcode (推荐最新版本)。
• 安装 iOS 10.0 或更高版本的 iOS 模拟器或 Apple 设备。
• CocoaPods 1.10.1 或更高版本。
• 有效的环信即时通讯 IM 开发者账号(注册环信账号:https://console.easemob.com/user/register)和 App Key,见 环信即时通讯云管理后台(https://console.easemob.com/user/login)。

• 如果你的网络环境部署了防火墙,请联系环信技术支持设置白名单。

集成方式
使用CocoaPods来添加环信SDK,具体步骤如下:

platform :ios, ‘10.0’
use_frameworks!

target ‘YourTarget’ do
pod ‘HyphenateChat’, ‘~> 4.0.2’
end

然后在终端中运行pod install,即可将环信SDK添加到项目中。

因为环信sdk是OC的代码,所以需要创建桥接文件(Bridging Header)来让Swift可以调用Objective-C的代码和库。下面是创建桥接文件的步骤:

1. 创建桥接文件
在Xcode项目中,选择File -> New -> File…,在弹出的对话框中选择iOS -> Source -> Header File,然后给该文件起一个名字,例如YourProjectName-Bridging-Header.h

2.配置桥接文件选项
在桥接文件的属性中,设置Objective-C Bridging Header选项。具体操作如下:
• 选中项目,在Xcode菜单中选择Build Settings
• 在搜索框中输入bridging header,找到Objective-C bridges Header选项
• 双击该选项,然后在弹出的对话框中输入桥接文件的路径,例如$(SRCROOT)/YourProjectName/YourProjectName-Bridging-Header.h

3.导入Objective-C头文件

// YourProjectName-Bridging-Header.h
#import


4.初始化环信SDK
在AppDelegate.swift文件中的application(_:didFinishLaunchingWithOptions:)方法中初始化环信SDK。以下是初始化代码示例:

let options = EMOptions(appkey: "yourappkey#demo")
let error = EMClient.shared().initializeSDK(with: options)
if error == nil {
//初始化成功
} else {
//初始化失败
}


5.登录环信服务器

注册服务端账号:http://docs-im-beta.easemob.com/document/server-side/account_system.html

EMClient.shared().login(withUsername: "yourUsername", password: "yourPassword") { (aUserName, aError) in
if aError != nil {
//登录失败处理
print("\(aUserName) login fail")
}else {
//登录成功处理
print("\(aUserName) login success")
}
}


6.发送消息

初始化聊天页面文档链接:http://docs-im-beta.easemob.com/document/ios/quickstart.html#_4-

let chatText = "Hello, World!"
let message = EMChatMessage(conversationID: "yourConversationID", from: "yourFrom", to: "yourTo", body: EMTextMessageBody(text: chatText), ext: ["yourKey": "yourValue"])
message.chatType = EMChatTypeChat // 设置为单聊消息
EMClient.shared().chatManager?.send(message, progress: nil) { (aMessage, aError) in
if let error = aError {
// 发送失败处理
} else {
// 发送成功处理
}
}

至此,即时通讯的基本功能已经集成完,如果您在集成中遇到问题可以随时联系环信技术支持或IMGeek社区提问。


SDK地址:https://www.easemob.com/download/im
IMGeek社区:https://www.imgeek.net/

收起阅读 »

launchAnyWhere: Activity组件权限绕过漏洞解析

前言 今年3月份,知名反病毒软件公司卡巴斯基实验室发布了一份关于中国电商平台拼多多的调查报告,称该平台的安装程序中含有恶意代码。这一消息引起了广泛的关注和讨论,也引发了人们对于拼多多平台安全性的担忧 作为技术开发人员,我看到了PDD对安卓OEM源码中的漏洞的...
继续阅读 »

前言


Screenshot_20230414163441298_com.ss.android.article.newsedit.jpg


今年3月份,知名反病毒软件公司卡巴斯基实验室发布了一份关于中国电商平台拼多多的调查报告,称该平台的安装程序中含有恶意代码。这一消息引起了广泛的关注和讨论,也引发了人们对于拼多多平台安全性的担忧


作为技术开发人员,我看到了PDD对安卓OEM源码中的漏洞的深入研究。



了解和学习Android漏洞原理有以下几个用处:





  • 提高应用安全性:通过了解漏洞原理,开发者可以更好地了解漏洞的产生机理,进而在应用开发过程中采取相应的安全措施,避免漏洞的产生,提高应用的安全性。




  • 提升应用质量:学习漏洞原理可以帮助开发者更好地理解 Android平台的工作原理,深入了解操作系统的内部机制,有助于开发高质量的应用程序。




  • 改善代码风格:学习漏洞原理可以帮助开发者更好地理解代码的运行方式和效果,从而提高代码的可读性和可维护性。




  • 了解安全防护技术:学习漏洞原理可以帮助开发者了解目前主流的安全防护技术,掌握安全防护的最佳实践,从而更好地保障应用程序的安全性。




总之,了解和学习Android漏洞原理可以帮助开发者更好地理解操作系统的内部机制,提高应用程序的安全性、质量和可维护性。


LaunchAnyWhere漏洞


这是一个AccountManagerService的漏洞,利用这个漏洞,我们可以任意调起任意未导出的Activity,突破进程间组件访问隔离的限制。这个漏洞影响2.3 ~ 4.3的安卓系统。



有些同学看到这里或许有些疑问,这个漏洞不是在Android4.3以后被解决了么?我想要说的是要了解startAnyWhere就需要了解它的历史,而LaunchAnyWhere漏洞可以说是它的一部分历史。



普通应用(记为AppA)去请求添加某类账户时,会调用AccountManager.addAccount,然后AccountManager会去查找提供账号的应用(记为AppB)的Authenticator类,调用Authenticator. addAccount方法;AppA再根据AppB返回的Intent去调起AppB的账户登录界面。


关于AccountManagerService


AccountManagerService同样也是系统服务之一,暴露给开发者的的接口是AccountManager。该服务用于管理用户各种网络账号。这使得一些应用可以获取用户网络账号的token,并且使用token调用一些网络服务。很多应用都提供了账号授权功能,比如微信、支付宝、邮件Google服务等等。


关于AccountManager的使用,可以参考Launchanywhere的Demo:github.com/stven0king/…


由于各家账户的登陆方法和token获取机制肯定存在差异,所以AccountManager的身份验证也被设计成可插件化的形式:由提供账号相关的应用去实现账号认证。提供账号的应用可以自己实现一套登陆UI,接收用户名和密码;请求自己的认证服务器返回一个token;将token缓存给AccountManager


可以从“设置-> 添加账户”中看到系统内可提供网络账户的应用:


添加账户页面.png


如果应用想要出现在这个页面里,应用需要声明一个账户认证服务AuthenticationService


<service android:name=".AuthenticationService"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>

并在服务中提供一个Binder:


public class AuthenticationService extends Service {
private AuthenticationService.AccountAuthenticator mAuthenticator;
private AuthenticationService.AccountAuthenticator getAuthenticator() {
if (mAuthenticator == null)
mAuthenticator = new AuthenticationService.AccountAuthenticator(this);
return mAuthenticator;
}
@Override
public void onCreate() {
mAuthenticator = new AuthenticationService.AccountAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
Log.d("tanzhenxing33", "onBind");
return getAuthenticator().getIBinder();
}
static class AccountAuthenticator extends AbstractAccountAuthenticator {
/****部分代码省略****/
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {
Log.d("tanzhenxing33", "addAccount: ");
return testBundle();
}
}
}

声明账号信息:authenticator.xml


<?xml version="1.0" encoding="utf-8"?>
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="com.tzx.launchanywhere"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name">
</account-authenticator>

漏洞原理


普通应用(记为AppA)去请求添加某类账户时,会调用AccountManager.addAccount,然后AccountManager会去查找提供账号的应用(记为AppB)的Authenticator类,调用Authenticator.addAccount方法;AppA再根据AppB返回的Intent去调起AppB的账户登录界面。


这个过程如图所示:


launchanywhere.png


我们可以将这个流程转化为一个比较简单的事实:



  • AppA请求添加一个特定类型的网络账号

  • 系统查询到AppB可以提供一个该类型的网络账号服务,系统向AppB发起请求

  • AppB返回了一个intent给系统,系统把intent转发给appA

  • AccountManagerResponse在AppA的进程空间内调用 startActivity(intent)调起一个Activity;

  • AccountManagerResponse是FrameWork中的代码, AppA对这一调用毫不知情。


这种设计的本意是,AccountManagerService帮助AppA查找到AppB账号登陆页面,并呼起这个登陆页面。而问题在于,AppB可以任意指定这个intent所指向的组件,AppA将在不知情的情况下由AccountManagerResponse调用起了一个Activity. 如果AppA是一个system权限应用,比如Settings,那么AppA能够调用起任意AppB指定的未导出Activity


如何利用


上文已经提到过,如果假设AppA是Settings,AppB是攻击程序。那么只要能让Settings触发addAcount的操作,就能够让AppB launchAnyWhere。而问题是,怎么才能让Settings触发添加账户呢?如果从“设置->添加账户”的页面去触发,则需要用户手工点击才能触发,这样攻击的成功率将大大降低,因为一般用户是很少从这里添加账户的,用户往往习惯直接从应用本身登陆。
不过现在就放弃还太早,其实Settings早已经给我们留下触发接口。只要我们调用com.android.settings.accounts.AddAccountSettings,并给Intent带上特定的参数,即可让``Settings触发launchAnyWhere


Intent intent1 = new Intent();
intent1.setComponent(new ComponentName("com.android.settings", "com.android.settings.accounts.AddAccountSettings"));
intent1.setAction(Intent.ACTION_RUN);
intent1.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
String authTypes[] = {"自己的账号类型"};
intent1.putExtra("account_types", authTypes);
AuthenticatorActivity.this.startActivity(intent1);

这个过程如图Step 0所示:


launchanywhere2.png


应用场景


主要的攻击对象还是应用中未导出的Activity,特别是包含了一些intenExtraActivity。下面只是举一些简单例子。这个漏洞的危害取决于你想攻击哪个Activity,还是有一定利用空间的。比如攻击很多app未导出的webview,结合FakeID或者JavascriptInterface这类的浏览器漏洞就能造成代码注入执行。


重置pin码



  • 绕过pin码认证界面,直接重置手机系统pin码。


intent.setComponent(new ComponentName("com.android.settings","com.android.settings.ChooseLockPassword"));
intent.setAction(Intent.ACTION_RUN);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra("confirm_credentials",false);
final Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;

重置锁屏


绕过原有的锁屏校验,直接重置手机的锁屏密码。


Intent intent = new Intent();
intent.setComponent(new ComponentName("com.android.settings", "com.android.settings.ChooseLockPattern"));
intent.setAction(Intent.ACTION_RUN);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
final Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;

漏洞修复


安卓4.4已经修复了这个漏洞,检查了Step3中返回的intent所指向的Activity和AppB是否是有相同签名的。避免了luanchAnyWhere的可能。
Android4.3源代码:androidxref.com/4.3_r2.1/xr…
Android4.4源代码:androidxref.com/4.4_r1/xref…
官网漏洞修复的Diff:android.googlesource.com/platform/fr…


diffcode.png


文章到这里就全部讲述完啦,若有其他需要交流的可以留言哦~!


作者:Stven_King
链接:https://juejin.cn/post/7225132351448186936
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android - 统一依赖管理(config.gradle)

前言 本文属于 《一款基于MVP架构的快速应用开发框架,kotlin版本》(注:此文章还在更新中,可先看看,敬请期待!) 的扩展文章,详细介绍在使用 LeoFastDevMvpKotlin 快速开发框架的时候,进行项目依赖管理的方法。 介绍 Android 依...
继续阅读 »

前言


本文属于 《一款基于MVP架构的快速应用开发框架,kotlin版本》(注:此文章还在更新中,可先看看,敬请期待!) 的扩展文章,详细介绍在使用 LeoFastDevMvpKotlin 快速开发框架的时候,进行项目依赖管理的方法。


介绍


Android 依赖统一管理距目前为止,博主一共知道有三种方法,分别是:




  1. 传统apply from的方式(也是本文想讲的一种方式):新建一个 「config.gradle」 文件,然后将项目中所有依赖写在里面,更新只需修改 「config.gradle」 文件内容,作用于所有module。

  2. buildSrc 方式:当运行 Gradle 时会检查项目中是否存在一个名为 buildSrc 的目录。然后 Gradle 会自动编译并测试这段代码,并将其放入构建脚本的类路径中, 对于多项目构建,只能有一个 buildSrc 目录,该目录必须位于根项目目录中, buildSrc 是 Gradle 项目根目录下的一个目录。

  3. Composing builds 方式:复合构建只是包含其他构建的构建. 在许多方面,复合构建类似于 Gradle 多项目构建,不同之处在于,它包括完整的 builds ,而不是包含单个 projects,总的来说,他有 buildSrc 方式的优点,同时更新不需要重新构建整个项目。



三种方式各有各的好,目前最完美的应该是第三种实现。但是这种方式不利于框架使用,因为它属于的是新建一个module,如果项目远程依赖了框架,默认也包含了这个 module。所以博主选择了第一种方式。以下文章也是围绕第一种方式进行讲解。


实现方式


实现这个统一依赖管理,拢共分三步,分别是:




  • 第一步:创建「config.gradle」 文件

  • 第二步:项目当中引入「config.gradle」

  • 第三步:在所有module的「build.gradle」当中添加依赖





  • 第一步:创建 「config.gradle」 文件


    首先将 Aandroid Studio 目录的Android格式修改为Project,然后再创建一个「config.gradle」的文件


    1681962514751.jpg


    然后我们编辑文章里面的内容,这里直接给出框架的代码出来(篇幅太长,省略部分代码):


    ext {
    /**
    * 基础配置 对应 build.gradle 当中 android 括号里面的值
    */
    android = [
    compileSdk : 32,
    minSdk : 21,
    targetSdk : 32,
    versionCode : 1,
    versionName : "1.0.0",
    testInstrumentationRunner: "androidx.test.runner.AndroidJUnitRunner",
    consumerProguardFiles : "consumer-rules.pro"

    ......
    ]

    /**
    * 版本号 包含每一个依赖的版本号,仅仅作用于下面的 dependencies
    */
    version = [
    coreKtx : "1.7.0",
    appcompat : "1.6.1",
    material : "1.8.0",
    constraintLayout : "2.1.3",
    navigationFragmentKtx: "2.3.5",
    navigationUiKtx : "2.3.5",
    junit : "4.13.2",
    testJunit : "1.1.5",
    espresso : "3.4.0",

    ......
    ]

    /**
    * 项目依赖 可根据项目增加删除,但是可不删除本文件里的,在 build.gradle 不写依赖即可
    * 因为MVP框架默认依赖的也在次文件中,建议只添加,不要删除
    */
    dependencies = [

    coreKtx : "androidx.core:core-ktx:$version.coreKtx",
    appcompat : "androidx.appcompat:appcompat:$version.appcompat",
    material : "com.google.android.material:material:$version.material",
    constraintLayout : "androidx.constraintlayout:constraintlayout:$version.constraintLayout",
    navigationFragmentKtx : "androidx.navigation:navigation-fragment-ktx:$version.navigationFragmentKtx",
    navigationUiKtx : "androidx.navigation:navigation-ui-ktx:$version.navigationUiKtx",
    junit : "junit:junit:$version.junit",
    testJunit : "androidx.test.ext:junit:$version.testJunit",
    espresso : "androidx.test.espresso:espresso-core:$version.espresso",

    ......
    ]
    }

    简单理解就是将所有的依赖,分成版本号以及依赖名两个数组的方式保存,所有都在这个文件统一管管理。用 ext 包裹三个数组:第一个是「build.gradle」Android 里面的,第二个是版本号,第三个是依赖的名字。依赖名字数组里面的依赖版本号通过 $ 关键字指代 version 数组里面的版本号




  • 第二步:项目当中引入 「config.gradle」


    将「config.gradle」文件引入项目当中,在项目的根目录的「build.gradle」文件(也就是刚刚新建的 「config.gradle」同目录下的),添加如下代码:


    apply from:"config.gradle"

    需要注意的的是,如果你是 AndroidStudio 4.0+ 那么你将看到这样的「build.gradle」文件


    // Top-level build file where you can add configuration options common to all sub-projects/modules.
    plugins {
    id 'com.android.application' version '7.2.2' apply false
    id 'com.android.library' version '7.2.2' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.10' apply false
    }

    apply from:"config.gradle"

    相反,如果你是 AndroidStudio 4.0- 那么你将会看到这样的「build.gradle」文件



    apply from: "config.gradle"

    buildscript {
    ext.kotlin_version="1.7.10"
    repositories {
    maven { url "https://jitpack.io" }
    mavenCentral()
    google()
    }
    dependencies {
    classpath "com.android.tools.build:gradle:4.2.1"
    classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3'
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

    // NOTE: Do not place your application dependencies here; they belong
    // in the individual module build.gradle files
    }
    }

    allprojects {
    repositories {
    maven { url "https://jitpack.io" }
    mavenCentral()
    google()
    }
    }

    task clean(type: Delete) {
    delete rootProject.buildDir
    }

    不过仅仅是两个文件里面的内容不一致,这个文件的位置是一样的,而且我们添加的引入代码也是一样的。可以说,这只是顺带提一嘴,实际上不影响我们实现统一依赖管理这个方式。




  • 第三步:在所有module的「build.gradle」当中添加依赖


    这一步是最重要的,我们完成了上面两步之后,只是做好了准备,现在我们需要将我们每一个module里面「build.gradle」文件里面的依赖指向「config.gradle」文件。也就是下图圈起来的 那两个「build.gradle」文件。


    Snipaste_2023-04-20_14-15-58.png


    因为我们第二步的时候已经在根目录引入了「config.gradle」,所以我们在「build.gradle」就可以指向「config.gradle」例如:



    implementation rootProject.ext.dependencies.coreKtx



    这一行,就指代了我们「config.gradle」文件里面的 dependencies 数组里面的 coreKtx 的内容。完整示例如下:


    plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    }
    android {
    namespace 'leo.dev.mvp.kt'
    // compileSdk 32
    compileSdk rootProject.ext.android.compileSdk

    defaultConfig {
    applicationId "leo.dev.mvp.kt"
    // minSdk 21
    // targetSdk 32
    // versionCode 1
    // versionName "1.0"
    //
    // testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

    minSdk rootProject.ext.android.minSdk
    targetSdk rootProject.ext.android.targetSdk
    versionCode rootProject.ext.android.versionCode
    versionName rootProject.ext.android.versionName

    testInstrumentationRunner rootProject.ext.android.testInstrumentationRunner

    }

    ......
    }

    dependencies {

    implementation fileTree(include: ['*.jar'], dir: 'libs')

    // implementation 'androidx.core:core-ktx:1.7.0'
    // implementation 'androidx.appcompat:appcompat:1.6.1'
    // implementation
    //
    // testImplementation 'junit:junit:4.13.2'
    // androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    // androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

    implementation rootProject.ext.dependencies.coreKtx
    implementation rootProject.ext.dependencies.appcompat
    implementation rootProject.ext.dependencies.material

    testImplementation rootProject.ext.dependencies.junit
    androidTestImplementation rootProject.ext.dependencies.testJunit
    androidTestImplementation rootProject.ext.dependencies.espresso

    }

    需要注意的是,我们在编写代码的时候,是没有代码自动补全的。所以得小心翼翼,必须要和「config.gradle」文件里面的名字向一致。




注意事项



  • 首先就是这种方式在coding的时候,是没有代码补全的(只有输入过的,才会有提示),我们需要确保我们的名字一致

  • 我们在增加依赖的时候,在「config.gradle」里面添加完之后,记得在对应的module里面的「build.gradle」里面添加对应的指向代码。


总结


以上就是本篇文章的全部内容,总结起来其实步骤不多,也就三步。但是需要注意的是细节。需要保持写入的依赖与「config.gradle」文件一致,并且未写过的词,是不会有代码自动补全的。


另外本篇文章是本文属于 《一款基于MVP架构的快速应用开发框架,kotlin版本》 的扩展文章,所以会一步一步说得比较详细一点。大家可以挑重点跳过阅读。


抬头图片


作者:肥仔仁
链接:https://juejin.cn/post/7224007334513770551
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android大图预览

前言 加载高清大图时,往往会有不能缩放和分段加载的需求出现。本文将就BitmapRegionDecoder和subsampling-scale-image-view的使用总结一下Bitmap的分区域解码。 定义 假设现在有一张这样的图片,尺寸为3040 × ...
继续阅读 »

前言


加载高清大图时,往往会有不能缩放分段加载的需求出现。本文将就BitmapRegionDecodersubsampling-scale-image-view的使用总结一下Bitmap的分区域解码


定义


image.png


假设现在有一张这样的图片,尺寸为3040 × 1280。如果按需要完全显示在ImageView上的话就必须进行压缩处理。当需求要求是不能缩放的时候,就需要进行分段查看了。由于像这种尺寸大小的图片在加载到内存后容易造成OOM,所以需要进行区域解码


图中红框的部分就是需要区域解码的部分,即每次只有进行红框区域大小的解码,在需要看其余部分时可以通过如拖动等手势来移动红框区域,达到查看全图的目的。


BitmapRegionDecoder


Android原生提供了BitmapRegionDecoder用于实现Bitmap的区域解码,简单使用的api如下:


// 根据图片文件的InputStream创建BitmapRegionDecoder
val decoder = BitmapRegionDecoder.newInstance(inputStream, false)

val option: BitmapFactory.Options = BitmapFactory.Options()
val rect: Rect = Rect(0, 0, 100, 100)

// rect制定的区域即为需要区域解码的区域
decoder.decodeRegion(rect, option)


  • 通过BitmapRegionDecoder.newInstance可以根据图片文件的InputStream对象创建一个BitmapRegionDecoder对象。

  • decodeRegion方法传入一个Rect和一个BitmapFactory.Options,前者用于规定解码区域,后者用于配置Bitmap,如inSampleSize、inPreferredConfig等。ps:解码区域必须在图片宽高范围内,否则会出现崩溃。


区域解码与全图解码


通过区域解码得到的Bitmap,宽高和占用内存只是指定区域的图像所需要的大小


譬如按1080 × 1037区域大小加载,可以查看Bitmap的allocationByteCount为4479840,即1080 * 1037 * 4


image.png


若直接按全图解码,allocationByteCount为15564800,即3040 * 1280 * 4
image.png


可以看到,区域解码的好处是图像不会完整的被加载到内存中,而是按需加载了。


自定义一个图片查看的View


由于BitmapRegionDecoder只是实现区域解码,如果改变这个区域还是需要开发者通过具体交互实现。这里用触摸事件简单实现了一个自定义View。由于只是简单依赖触摸事件,滑动的灵敏度还是偏高,实际开发可以实现一些自定义的拖拽工具来进行辅助。代码比较简单,可参考注释。


class RegionImageView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attr, defStyleAttr) {

private var decoder: BitmapRegionDecoder? = null
private val option: BitmapFactory.Options = BitmapFactory.Options()
private val rect: Rect = Rect()

private var lastX: Float = -1f
private var lastY: Float = -1f

fun setImage(fileName: String) {
val inputStream = context.assets.open(fileName)
try {
this.decoder = BitmapRegionDecoder.newInstance(inputStream, false)

// 触发onMeasure,用于更新Rect的初始值
requestLayout()
} catch (e: Exception) {
e.printStackTrace()
} finally {
inputStream.close()
}
}

override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
this.decoder ?: return false
this.lastX = event.x
this.lastY = event.y
return true
}
MotionEvent.ACTION_MOVE -> {
val decoder = this.decoder ?: return false
val dx = event.x - this.lastX
val dy = event.y - this.lastY

// 每次MOVE事件根据前后差值对Rect进行更新,需要注意不能超过图片的实际宽高
if (decoder.width > width) {
this.rect.offset(-dx.toInt(), 0)
if (this.rect.right > decoder.width) {
this.rect.right = decoder.width
this.rect.left = decoder.width - width
} else if (this.rect.left < 0) {
this.rect.right = width
this.rect.left = 0
}
invalidate()
}
if (decoder.height > height) {
this.rect.offset(0, -dy.toInt())
if (this.rect.bottom > decoder.height) {
this.rect.bottom = decoder.height
this.rect.top = decoder.height - height
} else if (this.rect.top < 0) {
this.rect.bottom = height
this.rect.top = 0
}
invalidate()
}
}
MotionEvent.ACTION_UP -> {
this.lastX = -1f
this.lastY = -1f
}
else -> {

}
}

return super.onTouchEvent(event)
}

// 测量后默认第一次加载的区域是从0开始到控件的宽高大小
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)

val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)

this.rect.left = 0
this.rect.top = 0
this.rect.right = w
this.rect.bottom = h
}

// 每次绘制时,通过BitmapRegionDecoder解码出当前区域的Bitmap
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.let {
val bitmap = this.decoder?.decodeRegion(rect, option) ?: return
it.drawBitmap(bitmap, 0f, 0f, null)
}
}
}

SubsamplingScaleImageView


davemorrissey/subsampling-scale-image-view可以用于加载超大尺寸的图片,避免大内存导致的OOM,内部依赖的也是BitmapRegionDecoder。好处是SubsamplingScaleImageView已经帮我们实现了相关的手势如拖动、缩放,内部还实现了二次采样和区块显示的逻辑。


如果需要加载assets目录下的图片,可以这样调用


subsamplingScaleImageView.setImage(ImageSource.asset("sample1.jpeg"))

public final class ImageSource {

static final String FILE_SCHEME = "file:///";
static final String ASSET_SCHEME = "file:///android_asset/";

private final Uri uri;
private final Bitmap bitmap;
private final Integer resource;
private boolean tile;
private int sWidth;
private int sHeight;
private Rect sRegion;
private boolean cached;

ImageSource是对图片资源信息的抽象



  • uri、bitmap、resource分别指代图像来源是文件、解析好的Bitmap对象还是resourceId。

  • tile:是否需要分片加载,一般以uri、resource形式加载的都会为true。

  • sWidth、sHeight、sRegion:加载图片的宽高和区域,一般可以指定加载图片的特定区域,而不是全图加载

  • cached:控制重置时,是否需要recycle掉Bitmap


public final void setImage(@NonNull ImageSource imageSource, ImageSource previewSource, ImageViewState state) {
...

if (imageSource.getBitmap() != null && imageSource.getSRegion() != null) {
...
} else if (imageSource.getBitmap() != null) {
...
} else {
sRegion = imageSource.getSRegion();
uri = imageSource.getUri();
if (uri == null && imageSource.getResource() != null) {
uri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + getContext().getPackageName() + "/" + imageSource.getResource());
}
if (imageSource.getTile() || sRegion != null) {
// Load the bitmap using tile decoding.
TilesInitTask task = new TilesInitTask(this, getContext(), regionDecoderFactory, uri);
execute(task);
} else {
// Load the bitmap as a single image.
BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false);
execute(task);
}
}
}

由于在我们的调用下,tile为true,setImage方法最后会走到一个TilesInitTask当中。是一个AsyncTask。ps:该库中的多线程异步操作都是通过AsyncTask封装的。


// TilesInitTask
@Override
protected int[] doInBackground(Void... params) {
try {
String sourceUri = source.toString();
Context context = contextRef.get();
DecoderFactory<? extends ImageRegionDecoder> decoderFactory = decoderFactoryRef.get();
SubsamplingScaleImageView view = viewRef.get();
if (context != null && decoderFactory != null && view != null) {
view.debug("TilesInitTask.doInBackground");
decoder = decoderFactory.make();
Point dimensions = decoder.init(context, source);
int sWidth = dimensions.x;
int sHeight = dimensions.y;
int exifOrientation = view.getExifOrientation(context, sourceUri);
if (view.sRegion != null) {
view.sRegion.left = Math.max(0, view.sRegion.left);
view.sRegion.top = Math.max(0, view.sRegion.top);
view.sRegion.right = Math.min(sWidth, view.sRegion.right);
view.sRegion.bottom = Math.min(sHeight, view.sRegion.bottom);
sWidth = view.sRegion.width();
sHeight = view.sRegion.height();
}
return new int[] { sWidth, sHeight, exifOrientation };
}
} catch (Exception e) {
Log.e(TAG, "Failed to initialise bitmap decoder", e);
this.exception = e;
}
return null;
}

@Override
protected void onPostExecute(int[] xyo) {
final SubsamplingScaleImageView view = viewRef.get();
if (view != null) {
if (decoder != null && xyo != null && xyo.length == 3) {
view.onTilesInited(decoder, xyo[0], xyo[1], xyo[2]);
} else if (exception != null && view.onImageEventListener != null) {
view.onImageEventListener.onImageLoadError(exception);
}
}
}

TilesInitTask主要的操作是创建一个SkiaImageRegionDecoder,它主要的作用是封装BitmapRegionDecoder。通过BitmapRegionDecoder获取图片的具体宽高和在Exif中获取图片的方向,便于显示调整。


后续会在onDraw时调用initialiseBaseLayer方法进行图片的加载操作,这里会根据比例计算出采样率来决定是否需要区域解码还是全图解码。值得一提的是,当采样率为1,图片宽高小于Canvas的getMaximumBitmapWidth()getMaximumBitmapHeight()时,也是会直接进行全图解码的。这里调用的TileLoadTask就是使用BitmapRegionDecoder进行解码的操作。


ps:Tile对象为区域的抽象类型,内部会包含指定区域的Bitmap,在onDraw时会根据区域通过Matrix绘制到Canvas上。


private synchronized void initialiseBaseLayer(@NonNull Point maxTileDimensions) {
debug("initialiseBaseLayer maxTileDimensions=%dx%d", maxTileDimensions.x, maxTileDimensions.y);

satTemp = new ScaleAndTranslate(0f, new PointF(0, 0));
fitToBounds(true, satTemp);

// Load double resolution - next level will be split into four tiles and at the center all four are required,
// so don't bother with tiling until the next level 16 tiles are needed.
fullImageSampleSize = calculateInSampleSize(satTemp.scale);
if (fullImageSampleSize > 1) {
fullImageSampleSize /= 2;
}

if (fullImageSampleSize == 1 && sRegion == null && sWidth() < maxTileDimensions.x && sHeight() < maxTileDimensions.y) {
// Whole image is required at native resolution, and is smaller than the canvas max bitmap size.
// Use BitmapDecoder for better image support.
decoder.recycle();
decoder = null;
BitmapLoadTask task = new BitmapLoadTask(this, getContext(), bitmapDecoderFactory, uri, false);
execute(task);
} else {
initialiseTileMap(maxTileDimensions);

List<Tile> baseGrid = tileMap.get(fullImageSampleSize);
for (Tile baseTile : baseGrid) {
TileLoadTask task = new TileLoadTask(this, decoder, baseTile);
execute(task);
}
refreshRequiredTiles(true);

}

}

加载网络图片


BitmapRegionDecoder只能加载本地图片,而如果需要加载网络图片,可以结合Glide使用,以SubsamplingScaleImageView为例


Glide.with(this)
.asFile()
.load("")
.into(object : CustomTarget<File?>() {
override fun onResourceReady(resource: File, transition: Transition<in File?>?) {
subsamplingScaleImageView.setImage(ImageSource.uri(Uri.fromFile(resource)))
}

override fun onLoadCleared(placeholder: Drawable?) {}
})

可以通过CustomTarget获取到图片的File文件,然后再调用SubsamplingScaleImageView#setImage


最后


本文主要总结Bitmap的分区域解码,利用原生的BitmapRegionDecoder可实现区域解码,通过SubsamplingScaleImageView可以对BitmapRegionDecoder进行进一步的交互扩展和优化。如果需要是TV端开发可以参考这篇文章,里面有结合具体的TV端操作适配:Android实现TV端大图浏览


作者:Cy13er
链接:https://juejin.cn/post/7224311569778229304
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

你可能需要了解下的Android开发技巧(一)

callbackFlow {}+debounce()降频 假如当前要做一个实时搜索的功能,监听输入框动态输入的内容向服务器发起搜索请求,这不仅会增大服务器的压力,而且也会产生很多的无用请求。 比如其实你想搜索一个“android”,但随着你在输入框中动态编辑,...
继续阅读 »

callbackFlow {}+debounce()降频


假如当前要做一个实时搜索的功能,监听输入框动态输入的内容向服务器发起搜索请求,这不仅会增大服务器的压力,而且也会产生很多的无用请求。


比如其实你想搜索一个“android”,但随着你在输入框中动态编辑,最多可能会向服务器发送7次请求,很明显前面6次请求都是属于无用请求(暂时不考虑模糊匹配的场景)。


这个时候我们就可以借助于callbackFlow{}将输入框的动态输入转换成流,再借助debounce()对流进行降频即可。关于对debounce()的讲解,可以参考之前的文章:debounce()限流


fun test4(editText: EditText) {
lifecycleScope.launchWhenResumed {
callbackFlow {
val watcher = editText.doAfterTextChanged {
trySend(it?.toString() ?: "")
}

invokeOnClose {
editText.removeTextChangedListener(watcher)
}
}.debounce(200).collect {
//对于输入框中的内容向服务器发起实时搜索请求

}
}
}

判断当前是否为主进程


常见的业务场景中,可能我们会把Service单独放一个进程处理,比如为了单独存放WebView再或者专门开一个服务进程与服务器进行通信,这样当UI进程死掉,也能缓存最新的数据到内容和本地 。


但有时,Service单独放一个进程处理,也会走Application的初始化逻辑,比如初始化第三方SDK、获取某些资源等等,但这些可能是只有UI进程才需要,所以Service进程初始化应该跳过这些逻辑。


所以我们需要判断当前的线程是否属于UI线程,可以利用UI进程的包名和进程名相同的特性实现,代码如下:


fun isMainProcess(): Boolean =
getSystemService<ActivityManager>()?.let {
it.runningAppProcesses.find { info ->
info.pid == Process.myPid()
}?.let { res ->
res.processName == packageName
}
} ?: true

当我写完上面的代码之后,发现Application竟然直接提供了一个获取当前进程名称的方法:


image.png


不过这个只有SDK28以上才能使用,可以判断一下,SDK28以下用上面的代码判断,SDK28及以上用下面的代码判断:


fun isMainProcess2(): Boolean = packageName == getProcessName()

作者:长安皈故里
链接:https://juejin.cn/post/7136913350893502494
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么开发者应该多关注海外市场

在上一篇《单枪匹马程序员,月营收从 0 到 1 万刀,近 90% 的毛利》里有掘友评论,这样的产品只有国外有,国内缺乏生态。 这篇文章主要是分享一下为什么我认为作为国内的开发者,应该多关注海外市场、做面向海外用户的产品。 早在 2000 年(点 com 泡沫...
继续阅读 »

在上一篇《单枪匹马程序员,月营收从 0 到 1 万刀,近 90% 的毛利》里有掘友评论,这样的产品只有国外有,国内缺乏生态。


这篇文章主要是分享一下为什么我认为作为国内的开发者,应该多关注海外市场、做面向海外用户的产品。




早在 2000 年(点 com 泡沫那阵),国外就有了 indie hacker / indie maker 这个名词来称呼做“独立”产品的人。这里的 indie 是 independent 的意思,意在独立(解脱)于各种束缚,比如:朝九晚五的工作时间、固定的办公室、领导、或者是投资人。


而国内在最近几年也涌现了一拨独立开发者,多数以工程师为主,当然做的产品也是面向国内的市场。有做地不错的,像 Baye 的熊猫吃短信、vulgur 的极简时钟、Kenshin 的简阅等;但综合我这两年来对海外一些独立产品的研究,海外市场或许是更好的选择。


当然凡是都有个前提,就是你没有一个豪华的创始团队或者是顶级投资人的背书,就是个人或者两三人的小团队。这个条件我觉得可以覆盖 90% 的中国的开发者;对于另外 10% 的拥有资源或者金主爸爸靠山的个人或者团队,不仅“可以”还“应该”去磕中国市场。但这不是今天要讨论的主题。


在 BAT TMD 等巨头和背靠资源的精英创业者们的夹缝里,我觉得只有做面向海外市场的小产品是更有胜率一点;做国内市场面临的四个问题:


第一、不存在足够的空间给个人/小团队做独立产品存活。


Slack 大家应该都知道,在美国已经上市了,市值 200 亿刀。Slack 一直是被 qiang 的,但是为什么国内没有出现 Slack 这样的产品作为一个信息中心来连接各个办公工具?


其实有,还不少,但都没活太久。一部分原因是腾讯阿里都非常重视这个“商业流量入口”,不想有可能被对方占有了。另外是国内互联网生态,从 BAT TMD 巨头到小软件公司,都太封闭;不仅不开放,还相互制约,都想把自己流量的守住,所以就同时出现了三个 Slack:



  • 微信出个企业微信(还封杀了 wetools)

  • 阿里出个钉钉

  • 字节出个飞书


在这种巨头虎视眈眈且相互对抗的格局里,作为三缺(缺钱、缺资源、缺核心门槛)的个人或者团队是无法存活的。或许在 2010 年至 2016 年间还有草根产品团队依靠“热钱”注入有爆发的可能性,时至今日,特别是这个蜜汁 2020 的局势,是不太可能的了。


即使,你找到了一个空白的利基市场(niche),你接下来面对三个问题:需求验证(试错)、推广、和商业化。


第二点、需求验证或者叫“试错”成本高。


由于国情不同,咱们需要经过一些不可避免的审核流程来保证互联网的干净。这个没话说,在哪做事就守哪的规矩。但这“需求验证”的门槛可就提高了不少。


比如要做个网站吧,备案最快也两周。做游戏?有没有版号?做 app ?有没有软著?小程序(从用户端来讲)是个不错的创新,但是你最烦看到的是不是“审核不通过,类目不符合”?稍微做点有用户互动的功能都需要公司主体。公司注册、银行开户、做帐、以及各种实名制等;这些虽然都不是不可达到的门槛,但是去完成这些要耗费大量的精力,对于本身就单打独斗的开发者来说 - 太累了。


再看看海外,简直不要太爽。做 app 还是需要经过苹果和谷歌的审核,但几乎不会对程序本身以外的东西设置门槛。网站注册个域名,30 秒改个 DNS 指到你的 IP,Netlify 或 Vercel 代码一推,就自动构建、部署、上线了。哪怕你不会写代码或者会写代码但是想先验证一下需求,看看潜在用户的响应如何,国外有不少非常值得一样的 no code 或 low code 平台。这个以后可以单独写一篇。


OK,国内你也通过重重难关项目终于上线了,你面临剩下的两个问题:推广和商业化。


第三点、推广渠道少 && 门槛高。


海外市场的推广渠道更多元,比如 ProductHunt, IndieHackers, BetaList 等。这些平台不仅国内没有,我想表达的更重要一点是,这些平台用户都比较真诚和热心会实在地给你提建议,给你写反馈。国内也有几个论坛/平台,但是用户氛围和友好度就和上述几个没法比了。


在付费推广方面,国内门槛(资质、资金)都挺高,感觉只有大品牌才能投得起广告,这对于缺资金的团队来讲,又是闭门羹。而国外,facebook 和 google 拿个 50、100 刀去做个推广计划都没问题。


可以以非常低的成本来获取种子用户或者验证需求。


行吧,推广也做得不错,得到了批种子用户并且涨势还不错;就最后一步了,商业化。


第四点、商业化选择少。


说商业化选择少可能说过了。国内是由于巨头间的竞争太激烈,出现各种补贴手段,导致互联网用户习惯于免费的互联网产品,甚至觉得应该倒贴给他来使用;伸手党、白 piao 党挺多;付费以及版权意识都还有改善空间。


想想你都给哪些浏览器插件付费过?“插件还需要付钱?!”


而海外用户的付费意愿足够强烈,在以后的案例分享中就能体会到。一个小小的浏览器插件,做得精美,触碰到了用户的购买欲望,解决了他一个痛点,他就愿意购买。


下一篇就分享国外浏览器插件的产品案例。


顺便再说一个「免费 vs 付费」的问题,这个不针对哪个国家,全世界都一样。免费(不愿意付费)的用户是最难伺候的,因为他们不 value 你的产品,觉得免费的就是创造者轻易做出来的、廉价的。如果依着这部分不愿意付费的客户来做需求,产品只会越做越难盈利。

作者:强生
来源:juejin.cn/post/7224400777216720952

收起阅读 »

十个高阶Javascript知识及用法

web
hi,今天给大家整理了十个Javascript的高级知识,希望对你有所帮助 1. 高阶函数 高阶函数是指接受一个或多个函数作为参数,并/或者返回一个函数的函数。这种技巧可以用于将函数组合起来,实现函数的复用。 // 高阶函数示例:将一个数组中的所有元素相加 ...
继续阅读 »

hi,今天给大家整理了十个Javascript的高级知识,希望对你有所帮助




1. 高阶函数


高阶函数是指接受一个或多个函数作为参数,并/或者返回一个函数的函数。这种技巧可以用于将函数组合起来,实现函数的复用。


// 高阶函数示例:将一个数组中的所有元素相加
function add(...args) {
return args.reduce((a, b) => a + b, 0);
}
function addArrayElements(arr, fn) {
return fn(...arr);
}
const arr = [1, 2, 3, 4, 5];
const sum = addArrayElements(arr, add);
console.log(sum); // 15

2. 纯函数


纯函数是指没有副作用(不改变外部状态)并且输出仅由输入决定的函数。纯函数可以更容易地进行单元测试和调试,并且可以更好地支持函数式编程的概念。


// 纯函数示例:将一个数组中的所有元素转换为字符串
function arrToString(arr) {
return arr.map(String);
}
const arr = [1, 2, 3, 4, 5];
const strArr = arrToString(arr);
console.log(strArr); // ["1", "2", "3", "4", "5"]

3. 闭包


闭包是指一个函数可以访问其定义范围之外的变量。这种技巧可以用于将变量“私有化”,从而避免全局变量的滥用。


// 闭包示例:使用闭包实现计数器
function makeCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

4. 柯里化


柯里化是指将一个接受多个参数的函数转换为一个接受一个参数的函数序列的技巧。这种技巧可以用于将函数变得更加通用化。


// 柯里化示例:将一个接受多个参数的函数转换为一个接受一个参数的函数序列
function add(a) {
return function(b) {
return a + b;
};
}
const add5 = add(5);
console.log(add5(10)); // 15
console.log(add5(20)); // 25

5. 函数组合


函数组合是指将多个函数组合成一个函数的技巧。这种技巧可以用于将多个函数的输出传递给下一个函数,实现函数的复用。


// 函数组合示例:将多个函数组合成一个函数
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function compose(...fns) {
return function(x, y) {
return fns.reduce((acc, fn) => fn(acc, y), x);
};
}
const addAndMultiply = compose(add, multiply);
console.log(addAndMultiply(2, 3)); // 15

6. 函数记忆化


函数记忆化是指使用缓存来保存函数的结果,从而避免重复计算。这种技巧可以用于提高函数的性能。


// 函数记忆化示例:使用缓存来保存函数的结果
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (cache[key]) {
return cache[key];
}
const result = fn(...args);
cache[key] = result;
return result;
};
}
function add(a, b) {
console.log("Calculating sum...");
return a + b;
}
const memoizedAdd = memoize(add);
console.log(memoizedAdd(2, 3)); // Calculating sum... 5
console.log(memoizedAdd(2, 3)); // 5 (from cache)

7. 类和继承


类和继承是指使用面向对象编程的概念来组织代码的技巧。这种技巧可以用于使代码更加模块化和可维护。


// 类和继承示例:使用类和继承实现动物类和猫类
class Animal {
constructor(name, age) {
this.name = name;
this.age = age;
}
speak() {
console.log("I am an animal.");
}
}
class Cat extends Animal {
constructor(name, age, color) {
super(name, age);
this.color = color;
}
speak() {
console.log("Meow!");
}
}
const cat = new Cat("Fluffy", 2, "black");

8. Generator


Generator 是一种特殊的函数,它可以暂停和恢复执行,并且可以用来生成迭代器。
示例代码:


function* generate() {
yield 1;
yield 2;
yield 3;
}
const iterator = generate();
console.log(iterator.next()); // 输出 { value: 1, done: false }
console.log(iterator.next()); // 输出 { value: 2, done: false }
console.log(iterator.next()); // 输出 { value: 3, done: false }
console.log(iterator.next()); // 输出 { value: undefined, done: true }

9. Proxy


Proxy 是一种对象代理机制,它可以拦截对象的访问、赋值、删除等操作,并且可以用来实现数据校验、数据缓存等功能。
示例代码:


const user = {
name: 'John',
age: 30,
};
const proxy = new Proxy(user, {
get(target, key) {
console.log(`Getting ${key} value.`);
return target[key];
},
set(target, key, value) {
console.log(`Setting ${key} value to ${value}.`);
target[key] = value;
},
});
console.log(proxy.name); // 输出 "Getting name value." 和 "John"
proxy.age = 40; // 输出 "Setting age value to 40."

10. Reflect


Reflect 是一种对象反射机制,它提供了一系列操作对象的方法,并且可以用来替代一些原来只能通过 Object 上的方法来实现的功能。
示例代码:


const user = {
name: 'John',
age: 30,
};
console.log(Reflect.has(user, 'name')); // 输出 true
console.log(Reflect.get(user, 'name')); // 输出 "John"
console.log(Reflect.set(user, 'age', 40)); // 输出 true
console.log(user.age); // 输出 40

image.png


作者:一条小尾鱼
来源:juejin.cn/post/7222838155605639226
收起阅读 »

打造高性能CSS的九个技巧

web
大佬:你的CSS的写的质量太低,看的我难受。 萌新:那要怎么样? 大佬:自己去优化一下。 萌新:额。。。CSS还能怎么样优化? 咳咳。。。咱们进入正题。 当前端项目想做到极致的性能与体验,优化CSS代码是非常重要的一环。优化CSS代码能够减少页面加载时间,提...
继续阅读 »

大佬:你的CSS的写的质量太低,看的我难受。


萌新:那要怎么样?


大佬:自己去优化一下。


萌新:额。。。CSS还能怎么样优化?


218585ea773deab374b233c6f64dda23.jpeg


咳咳。。。咱们进入正题。


当前端项目想做到极致的性能与体验,优化CSS代码是非常重要的一环。优化CSS代码能够减少页面加载时间,提升性能,改善用户体验。前端的同学有没有想过如何在项目中把CSS这一环优化?


下面我将给大家介绍优化CSS的9个技巧。


1. 使用简洁的选择器


选择器越短,浏览器匹配就越快。因此在编写CSS时,应该尽可能使用简洁的选择器。例如,优先使用类选择器和标签选择器,而不是id选择器和属性选择器。应该避免使用通配符选择器。


/* 优化前的代码 */
#sidebar ul li a:hover {
color: red;
}
/* 优化后的代码 */
.sidebar a:hover {
color: red;
}

2. 避免嵌套过深


嵌套过深会增加选择器的复杂度,影响浏览器性能,同时也使得代码难以维护。为了避免嵌套过深,可以采用命名约定,或者使用后代选择器代替嵌套。


/* 优化前的代码 */
#header .nav ul li a {
color: red;
}
/* 优化后的代码 */
.header-nav-link {
color: red;
}

3. 减少重复的样式


重复的样式会让CSS文件变得臃肿,增加文件大小,影响页面加载速度。如果某些样式被多处引用,可以将其定义为一个class或者使用CSS变量。


/* 优化前的代码 */
#sidebar h3 {
font-size: 1.5rem;
color: #333;
margin-bottom: 1rem;
}
#main h3 {
font-size: 1.5rem;
color: #333;
margin-bottom: 1rem;
}
/* 优化后的代码 */
.heading {
font-size: 1.5rem;
color: #333;
margin-bottom: 1rem;
}

4. 避免使用昂贵的属性


有些CSS属性会影响浏览器性能,例如position、float、display等。应该尽可能避免使用这些属性,或者使用更轻量级的替代方案。


/* 优化前的代码 */
#header {
position: absolute;
top: 0;
left: 0;
}
/* 优化后的代码 */
.header {
position: sticky;
top: 0;
z-index: 999;
}

5. 压缩CSS文件


压缩CSS文件是一种简单而有效的优化方式。压缩CSS文件可以删除注释和空格等无用代码,减少文件大小,加快页面加载速度。可以使用在线压缩工具或者构建工具自动压缩CSS文件。


例如:


/* 压缩前的代码 */
.header {
position: sticky;
top: 0;
z-index: 999;
}
/* 压缩后的代码 */
.header{position:sticky;top:0;z-index:999;}

6. 单独使用!important


!important能够优先级最高控制CSS属性值,但这种方法很容易过分使用,在大的CSS文件中成为代码的混乱来源。尝试尽可能避免使用它们,只在必要的情况下使用。


7. 避免使用通配符选择器


通配符选择器(*)会匹配所有元素,这样的选择器不仅速度慢,而且可能会导致CSS规则被某些你不想匹配的元素使用。因此,尽量避免使用通配符选择器。如果必须使用,也应该在选择器中增加额外的限制条件来提高精确度。


8.使用CSS继承


CSS继承能够将子元素的样式设置为与其父元素相同的属性值。这种方法不仅简单,而且可以减少代码量和增强代码的可读性。使用继承可以减少你的样式表中的重复代码。


9. 使用CSS预处理器


CSS预处理器(如Sass、Less、Stylus)能够让你使用变量、嵌套、函数、注释等高级功能,从而更加简洁、易于维护的方式编写CSS代码。预处理器将会自动将这些代码转换为标准CSS语法,这样能够降低代码量和复杂度,提高开发效率。


作者:白椰子
来源:juejin.cn/post/7223598443666964517
收起阅读 »

⏰⏰ 手把手实现一个进度条时钟,麻麻再也不用担心我把时间看茬了!

web
前言 挂钟大家都知道吧,它通过时针、分针和秒针来表示时间,想当初小学刚开始教怎么看时钟的完全看不懂。今天带大家一步步实现一个类进度条时钟的效果,更直观的知晓当前的时间。 本文将会带大家学到以下知识点: 垂直水平居中方式 gap 属性搭配 flex 布局 实现...
继续阅读 »

前言


挂钟大家都知道吧,它通过时针、分针和秒针来表示时间,想当初小学刚开始教怎么看时钟的完全看不懂。今天带大家一步步实现一个类进度条时钟的效果,更直观的知晓当前的时间。


本文将会带大家学到以下知识点:



  1. 垂直水平居中方式

  2. gap 属性搭配 flex 布局 实现等边距

  3. Date 日期函数的使用及注意点

  4. CSS 变量的简单应用

  5. svgcircle 标签的用法

  6. stroke-dashoffset 属性和 stroke-dasharray 属性能够的用法


样式重置


首先老规矩,我们将 CSS 样式重置 ,方便各个浏览器统一展示效果。


* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

背景调整


接下来通过添加 min-height: 100vh 属性,将 body 限制为 视口大小 且通过 overflow: hidden 来将 超出部分隐藏


body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #2f363e;
}

这里为了将我们的时钟在屏幕中间展示,我们需要使用 flex 布局body 设置为 水平垂直居中 。同样的,小伙伴们还可以使用 light-heighttransform 等手段实现。


时间绘制


接下来我们要准备 4 个 div ,用来作为展示时间的容器。


<body>
<div id="time">
<div class="circle">
<div id="hours">00</div>
</div>
<div class="circle">
<div id="minutes">00</div>
</div>
<div class="circle">
<div id="seconds">00</div>
</div>
<div class="ap">
<div id="ampm">00</div>
</div>
</div>

</body>

然后给其宽高。


#time {
display: flex;
color: #fff;
}
#time .circle {
position: relative;
width: 150px;
height: 150px;
}

细心的小伙伴一定注意到了这段 CSS 中有个比较特别的属性:gap。这有什么用呢?


我们来看看 MDN 对 gap 属性的描述:



CSS gap 属性是用来设置网格行与列之间的间隙(gutters),该属性是 row-gap 和 column-gap 的简写形式。



gap 属性它适用于 multi-column elements, flex containers, grid containers,也就是多列布局、弹性布局以及网格布局中(知识点++)。因此这里我们用 flex 布局 搭配 gap 是完全行得通的。


我们看看此时的效果如何:


微信截图_20221011180851.png


我们发现最后一个用来表示上午下午的字体有点大,我们将其调小,同时通过 translateY 属性将该元素偏移 -20px(负数表示向上偏移,正数表示向下偏移),和时间做区分。


#time .ap {
position: relative;
font-size: 1em;
transform: translateY(-20px);
}

微信截图_20221011182321.png


这样美观了许多,主次分明。


动态时间


接下来我们要让时间变成实时的,因此我们要用到 Javascript 的 Date 函数了。



  1. 通过 getHours() 获取当前小时数。

  2. 通过 getMinutes() 获取当前分钟数。

  3. 通过 getSeconds() 获取当前秒钟数。


这里有个注意点,通过以上三个方法获取的时间值,都是不带前缀 0 的,并且是 number 类型。什么意思呢?


比如现在是下午的 14:07:04 ,通过 getMinutes() 获取的分钟数是 7 而不是 07,同理,通过 getSeconds()getHours() 获取的时间也是如此。


因此为了美观,我们需要手动给 10 以内 的时间值 补一个 0。具体怎么做呢?这里我用到了字符类型的 padStart 方法,它传递两个参数,分别是数字最后要填充到这个指定的位数,以及用来填充的字符。


为了将 7 变成 07,我们需要将 number 类型的 7 变为字符串类型的 '7',然后执行 '7'.padStart(2, 0)。


除此之外,对于 AM 以及 PM 的区分,我们通过判断 getHours() 的返回值是否大等于 12。如果大于则为 PM,否则是 AM。


处理完数据之后通过修改 innerHTML 的值来改变页面上展现的时间,同时通过 setInterval 来不断执行该操作,实现实时更新时间的效果。


<script>
let hours = document.querySelector('#hours');
let minutes = document.querySelector('#minutes');
let seconds = document.querySelector('#seconds');
let ampm = document.querySelector('#ampm');

setInterval(() => {
let h = `${new Date().getHours() % 12}`.padStart(2, 0);
let m = `${new Date().getMinutes()}`.padStart(2, 0);
let s = `${new Date().getSeconds()}`.padStart(2, 0);
let am = h >= 12 ? 'PM' : 'AM';

hours.innerHTML = h + '\n<div class="tip">HOURS</div>';
minutes.innerHTML = m + '\n<div class="tip">MINUTES</div>';
seconds.innerHTML = s + '\n<div class="tip">SECONDS</div>';
ampm.innerHTML = am;
}, 1000);
</script>

注意点小结:



  1. getHours、getMinutes 以及 getSeconds 返回 number 类型的值(不带前缀 0)

  2. padStart 是字符类型的方法,注意先将类型转为 string 再进行调用。


我们来看看效果:


20221011_190750.gif


画圆


接下来我们要对每个时间的容器都画一个圆的效果,这里我使用了 svg 标签。原因下文会提。


有的小伙伴可能对 svg 标签比较陌生,确实平时开发的时候用的比较少,实际上它和我们普通的标签差不多,而且也能用过 CSS 设置它的一些属性。


这里画圆我们需要用到 circle 标签,其中,cxcy 属性共同确定了一个圆心的位置,r 属性表示待绘制圆的半径。


<div id="time">
<div class="circle" style="--clr: #ff2972">
<svg>
<circle cx="70" cy="70" r="70"></circle>
</svg>
<div id="hours">00</div>
</div>

<div class="circle" style="--clr: #fee800">
<svg>
<circle cx="70" cy="70" r="70"></circle>
</svg>
<div id="minutes">00</div>
</div>

<div class="circle" style="--clr: #04fc43">
<svg>
<circle cx="70" cy="70" r="70"></circle>
</svg>
<div id="seconds">00</div>
</div>

</div>

微信截图_20221011192308.png


可以看到默认情况下,circle 标签的背景色是黑色的,我们给它点样式。


#time .circle svg circle {
width: 100%;
height: 100%;
fill: transparent;
stroke: #191919;
stroke-width: 4;
}

fill 属性表示当前填充 circle 标签应当用什么颜色(实际上就是背景色的意思)。


stroke 属性表示绘制一个边线(实际上就是边框)。


stroke-width 属性一般搭配 stroke 属性一起用,表示边线的宽度。


微信截图_20221011192621.png


经过这么一手修改后已经有模有样了,但是别急,最麻烦的部分要来了。接下来我们要模拟时间进度条了。


模拟进度条


这里我们模拟进度条需要在每个 svg 标签下再添加一个 circle 标签。


<div id="time">
<div class="circle" style="--clr: #ff2972">
<svg>
<circle cx="70" cy="70" r="70"></circle>
<circle cx="70" cy="70" r="70" id="hh"></circle>
</svg>
<div id="hours">00</div>
</div>

<div class="circle" style="--clr: #fee800">
<svg>
<circle cx="70" cy="70" r="70"></circle>
<circle cx="70" cy="70" r="70" id="mm"></circle>
</svg>
<div id="minutes">00</div>
</div>

<div class="circle" style="--clr: #04fc43">
<svg>
<circle cx="70" cy="70" r="70"></circle>
<circle cx="70" cy="70" r="70" id="ss"></circle>
</svg>
<div id="seconds">00</div>
</div>

</div>

同时,通过 CSS 变量给这个新增的 circle 标签各不相同的颜色。


#time .circle svg circle:nth-child(2) {
stroke: var(--clr);
}

此时效果如下:


微信截图_20221011193127.png


接下来我们要用到一个新的属性:stroke-dasharray


我们来看看 MDN 对 stroke-dasharray 属性的介绍:



属性 stroke-dasharray 可控制用来描边的点划线的图案范式。



是不是有点懵,我们看看下面这张动图:


20221011_193806.gif


这是 stroke-dasharray 属性值从 0 开始不断增加的效果。那我们就知道了,实际上这个属性就是控制点划线的长度用的。


那它和我们的进度条有什么关系呢?


不要急,要实现我们的进度条,还需要另一个属性:stroke-dashoffset


我们来看看 MDN 对 stroke-dashoffset 属性的介绍:



属性 stroke-dashoffset 指定了 dash 模式到路径开始的距离。



话不多说,上图:


20221011_194553.gif


这是 stroke-dashoffset 属性值从 0 开始不断增加的效果,是不是很像进度条在跑?


我们就是通过 Javascript 动态修改 stroke-dashoffset 来达到进度条跟着时间一起跑的效果!


let hh = document.querySelector('#hh');
let mm = document.querySelector('#mm');
let ss = document.querySelector('#ss');

setInterval(() => {
...
hh.style.strokeDashoffset = 440 - (440 * h) / 12;
mm.style.strokeDashoffset = 440 - (440 * m) / 60;
ss.style.strokeDashoffset = 440 - (440 * s) / 60;
}, 1000);

20221011_195356.gif


码上掘金


SumXiMRX - 码上掘金 (juejin.cn)


Github 源码地址


juejin-demo/digital-clock-demo at main · catwatermelon/juejin-demo (github.com)


结束语


本文就到此结束了,希望大家共同努力,早日拿下 CSS 💪💪。


如果文中有不对的地方,或是大家有不同的见解,欢迎指出 🙏🙏。


如果大家觉得所有收获,欢迎一键三连💕💕。


作者:CatWatermelon
来源:juejin.cn/post/7153836297218424863
收起阅读 »

Android 将json数据显示在RecyclerView

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的 本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出 Share...
继续阅读 »

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的
本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出


SharedPreferences pref=getSharedPreferences("data",MODE_PRIVATE);
String phone=pref.getString("phone","");

得到了phone之后,我采用了okhttp请求返回json,注意:进行网络请求都需要开启线程以及一些必要操作
例如


<uses-permission android:name="android.permission.INTERNET" /> 

url为你申请的网络url


 new Thread(new Runnable() {
@Override
public void run() {
OkHttpClient client=new OkHttpClient().newBuilder()
.connectTimeout(60000, TimeUnit.MILLISECONDS)
.readTimeout(60000,TimeUnit.MILLISECONDS).build();
//url/phone
Request request=new Request.Builder().url("url/phone"+phone).build();
try {
Response sponse=client.newCall(request).execute();
String string = sponse.body().string();
Log.d("list",string);
jsonJXDate(string);
}catch (IOException | JSONException e){
e.printStackTrace();
}
}
}).start();

由上可知,string即为所需的json


展示大概长这样


{
"code": 200,
"message": "成功",
"data": [
{
"id": "string",
"createTime": "2023-04-18T05:50:08.905+00:00",
"updateTime": "2023-04-18T05:50:08.905+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T05:50:08.905+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "烧烤",
"amount": "4",
"price": "60",
"subtotal": "240"
}
],
"total": "string"
},
{
"id": "643e9efb09ecf071b0fd2df0",
"createTime": "2023-04-18T13:28:35.889+00:00",
"updateTime": "2023-04-18T13:28:35.889+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T13:28:35.889+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "兰州拉面",
"amount": "5",
"price": "40",
"subtotal": "200"
}
],
"total": "string"
}
],
"ok": true
}

我所需要的是payTime,product,subtotal


有{}用JSONObject,有[]用JSONArray,一步步来靠近你的需要


JSONObject j1 = new JSONObject(data);
try {
JSONArray array = j1.getJSONArray("data");
for (int i=0;i<array.length();i++){
j1=array.getJSONObject(i);
Map<String,Object>map=new HashMap<>();
String payTime = j1.getString("payTime");
JSONObject bills = j1.getJSONArray("bills").getJSONObject(0);
String product = bills.getString("product");
String subtotal = bills.getString("subtotal");
map.put("payTime",payTime);
map.put("product",product);
map.put("subtotal",subtotal);
list.add(map);
}
Message msg=new Message();
msg.what=1;
handler.sendMessage(msg);

}catch (JSONException e){
e.printStackTrace();
}

}
public Handler handler=new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case 1:
//添加分割线
rv.addItemDecoration(new androidx.recyclerview.widget.DividerItemDecoration(
MeActivity.this, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL));
MyAdapter recy = new MyAdapter(MeActivity.this, list);
//设置布局显示格式
rv.setLayoutManager(new LinearLayoutManager(MeActivity.this));
rv.setAdapter(recy);
break;
}
}
};

在adapter处通过常规layout显示后填入数据


 //定义时间展现格式
Map<String, Object> map = list.get(position);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
LocalDateTime dateTime = LocalDateTime.parse(map.get("payTime").toString(), formatter);
String strDate = dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

holder.produce.setText(map.get("product").toString());
holder.payTime.setText(strDate);
holder.price.setText(map.get("subtotal").toString());

就大功告成啦,由于后台那边还没把base64图片传上来,导致少了个图片,大致就是这样的


6a2d483f93267f3cd09f25576c1f29c.jpg


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

原来Promise 还可以这样用?

web
举个例子 需求 组件b初始化某个用到的库,只有在初始化完成后才能调用其API,不然会报错。a页面负责调用 上代码 // a.vue <template> <div> 这是a页面 <childB ref="chi...
继续阅读 »

举个例子


需求


组件b初始化某个用到的库,只有在初始化完成后才能调用其API,不然会报错。a页面负责调用


上代码


// a.vue
<template>
<div>
这是a页面
<childB ref="childB" />
</div>

</template>
<script>
import childB from './b'
export default {
mounted() {
setTimeout(() => {
this.$refs.childB.play()
}, 3000)
},
components: {
childB,
},
}
</script>

// b.vue
<template>
<div>这是b页面</div>
</template>

<script>
export default {
data() {
return {
flag: false,
}
},
created() {
this.init()
},
methods: {
init() {
setTimeout(() => {
this.flag = true
}, 2000)
},
play() {
if (!this.flag) return console.log('not init')
console.log('ok')
},
},
}
</script>



以上代码为模拟初始化,用setTimeout代替,实际开发中使用是一个回调函数,那么我页面a也是用setTimeout?写个5秒?10秒?有没有解决方案呢?


解决方案


那肯定是有的,我们可以这样写……


// a.vue
<template>
<div>
这是a页面
<childB ref="childB" />
</div>

</template>
<script>
import childB from './b'
export default {
mounted() {
this.init()
},
methods: {
init() {
setTimeout(() => {
this.$refs.childB.play()
}, 2000)
},
},
components: {
childB,
},
}
</script>

// b.vue
<template>
<div>这是b页面</div>
</template>

<script>
export default {
methods: {
play() {
console.log('ok')
},
},
}
</script>


相信这也是最常见也是大多数人使用的方案了,但是我觉得把b组件中的代码写到了a页面中,假如有多个b组件,那么a页面中要写多好的b组件代码。容易造成代码混淆、冗余,发生异常的错误,阻塞进程,这显然是不能接受的。


思考


我们能不能用promise来告诉我们是否已经完成初始呢?


答案当然是可以的!


下面我们改造一下代码


// a.vue
<template>
<div>
这是a页面
<childB ref="childB" />
</div>

</template>
<script>
import childB from './b'
export default {
mounted() {
const { init, play } = this.$refs.childB
init().then(play)
},
components: {
childB,
},
}
</script>

// b.vue
<template>
<div>这是b页面</div>
</template>

<script>
export default {
methods: {
init() {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, 2000)
})
},
play() {
console.log('ok')
},
},
}
</script>


嗯~ o( ̄▽ ̄)o 果然nice,干净整洁,一气呵成!


不足


init在a页面mounted时候才触发,感觉太晚了。能不能在b组件created时候自行触发呢?


哈哈,当然可以了!


我们再改造一下代码


// a.vue
<template>
<div>
这是a页面
<childB ref="childB" />
</div>

</template>
<script>
import childB from './b'

export default {
mounted() {
this.$refs.childB.play()
},
components: {
childB,
},
}
</script>

// b.vue
<template>
<div>这是b页面</div>
</template>

<script>
function getPromiseWait() {
let success, fail
const promise = new Promise((resolve, reject) => {
success = resolve
fail = reject
})
return { promise, resolve: success, reject: fail }
}
const { promise, resolve } = getPromiseWait()
export default {
created() {
this.init()
},
methods: {
init() {
setTimeout(() => {
resolve('hello')
}, 2000)
},
async play() {
const res = await promise
console.log('ok', res)
},
},
}
</script>



完美


我们在b组件中生成一个promise来控制是否init完成,a页面只需要直接调用b组件的play方法即可。如有需要还可以在resolve传递参数,通过then回调函授拿到数据,Pro

作者:𝓼𝓹𝓻𝓲𝓽𝓮𝓐𝓹𝓮
来源:juejin.cn/post/7225127360445841466
mise YYDS!

收起阅读 »

编程中最难的就是命名?这几招教你快速上手

作者:陈立(勤仁) 你可不能像给狗狗取名字那样给类、方法、变量命名。仅仅因为它很可爱或者听上去不错。 在写代码的时候,你要经常想着,那个最终维护你代码的人可能将是一个有暴力倾向的疯子,并且他还知道你住在哪里。 01 为什么命名很重要? 在项目中,从项目的创建到...
继续阅读 »

作者:陈立(勤仁)


你可不能像给狗狗取名字那样给类、方法、变量命名。仅仅因为它很可爱或者听上去不错。


在写代码的时候,你要经常想着,那个最终维护你代码的人可能将是一个有暴力倾向的疯子,并且他还知道你住在哪里。


01 为什么命名很重要?


在项目中,从项目的创建到方法的实现,每一步都以命名为起点,我们需要给变量、方法、参数、类命名,这些名字出现在代码的每个角落,随处可见,混乱或错误的命名不仅让我们对代码难以理解,更糟糕的是,会误导我们的思维,导致对代码的理解完全错误。如果整个项目始终贯穿着好的命名,就能给阅读者一个神清气爽的开始,也能给阅读者一个好的指引。


要知道,代码的阅读次数远远多于编写的次数。请确保你所取的名字更侧重于阅读方便而不是编写方便。


02 为什么很难正确命名?


有人称编程中最难的事情就是命名。我同样深以为然,中国有句古话叫做万事开头难。抛开环境搭建,真正到了编码阶段第一件事就是命名,而最常见的一种情况,就是毫无目的、仅凭个人的喜好的去决定了一个名字。但因为没有想清楚目标和具体实施步骤,所以进行过程中往往会面临无数次的小重构甚至是推倒重来。


1、缺乏意愿


害怕在选择名字上花时间,对做好命名的意愿不足,随心所欲,甚至无视团队对命名的基本规范,觉得编译器能编译通过,代码能正常运行就成。


其实对发现的命名问题进行重构和推倒重来并不可怕,最可怕的是当下程序员不具备发现问题后肯回过头来纠偏的意愿。这终将演变成为一场灾难。


2、缺乏思考


没想清楚被命名的事物是什么,事物应该承担什么职责,是否会对其他人造成误解。


新手程序员总会花很多时间学习一门编程语言、代码语法、技术和工具。他们觉得如果掌握了这些东西,就能成为一个好程序员。然而事实并不是这样,事实上,编程不仅仅关乎掌握技能和工具,更重要的是在特定范畴内解决问题的能力,还有和其他程序员合作的能力。因此,能在代码中准确的表达自己的想法就变得异常重要,代码中最直观的表达方式是命名,其次是注释。


3、缺乏技巧


选一个好的名字真很难,你可能得有较高的描述能力和共同的文化背景。并且知晓一些常见且应该避免的命名问题。


如果最终还是没法找到合适的名字,还请添加准确的注释辅助他人理解,等想到合适的名字后再进行替换,不过往往能够通过注释(母语)描述清楚的事物,命名应该问题不大,问题大的是连注释都无法准确表达,那说明可能当前类、函数、变量承担的职责太多太杂。


03 如何正确的命名?


这里不讨论具体语言的命名规则,原因是不同编程语言命名规则各不相同,甚至不同团队间相同语言的命名规则也有出入。这里主要从提高可读性出发,结合我所在的客户端团队日常开发情况,以Java作为演示语言,给一些关于命名的建议。


1、名副其实


无论是变量、方法、或者类,在看到他名称的时候应该以及答复了所有的大问题,它应该告诉你,它为什么会存在,他做什么事,应该怎么做。如果在看到名称时,还需要去查找注释来确认自己的理解,那就不算名副其实。而且在发现有更好的命名时,记得果断替换。


Case1:到底怎样算End?

代码示例:


public interface OnRequestListener {
/**
* 请求结束 只有成功点才认为是真正的结束
* @param ...
*/

void onRequestEnd(....);
/**
* 请求开始
* @param ...
*/

void onRequestStart(...);
}

大脑活动:


onRequestEnd是请求的什么阶段?请求成功和失败任一情况都算 “end”吗?喔,原来注释有写:“只有成功点才认为是真正的结束”。


修改建议:


// 省略注释
public interface OnRequestListener {
void onStart(....);
void onSuccess(....);
void onFailure(...);
}

2、避免误导


在每种语言中都有内置的标识符,他们都有特定的含义,如果和他们没有关联就不要在命名中加上他们。


2.1 避免使用令人误解的名字


Case1:命错名的集合

代码示例:


private List<SectionModel> dataSet;

大脑活动:


“dataSet” 在最初一定是为了元素去重选择了Set类型,肯定后来某一个历史时刻发现有bug被偷偷改成了List类型,但是变量名没变。


代码跟读:


跟踪提交记录,呃,在18年被刚定义时就是 List<***> dataSet;


修改建议:


private List<SectionModel> dataList;
或者
private List<SectionModel> sections;

Case2:不是View的View类

代码示例:


/** 作者+日期 */
public class RItemOverlayView {
}

/** 作者+日期 */
public class NRItemOverlayView {
}

大脑活动:


“N”是啥意思?类名只有一个N的字母差别,难道是新旧的差别,新的和旧的有什么区别呢?


类名以View结尾,嗯,应该是一个视图,可是,视图为啥不用继承视图基类的?


代码跟读:


喔,N确实代表“New”的意思,NRItemOverlayView被首页推荐使用,RItemOverlayView被购后推荐使用。


这个类主要核心工作是构建浮层视图(职责并不单一),而类本身并不是一个真正的视图;


修改建议:


// 放在首页推荐场景的包下
public class ItemOverlayViewCreator {
}

// 放在购后推荐场景的包下
public class ItemOverlayViewCreator {
}

Case3:整形变量为啥要用is开头

代码示例:


private int isFirstEnter = 0;

大脑活动:


为什么“is”开头的变量却声明成整形?到底是要计数还是判断真假呢?


代码跟读:


isFirstEnter < 1 做第一次进入的逻辑


修改建议:


private boolean isFirstEnter = true;

Case4:开关作用反掉啦

代码示例:


....
if (InfoFlowOrangeConfig.getBooleanValue(POST_DELAYED_HIDE_COVER_VIEW_ENABLE, true)) {
hideCoverImageView();
} else {
delayedHideCoverImageView();
}

大脑活动:


为什么开关名为“delay....”为“true”的时候,走的不是delay逻辑,那开关要怎么发?容我多看几遍,是不是最近没休息好所以看岔了。


代码跟读:


反复看了几遍,确实是开关命名和实际操作完全相反,开关名意为“延迟隐藏封面视图”,执行的却是“立即隐藏封面视图”。


修改建议:


....
if (InfoFlowOrangeConfig.getBooleanValue(IMMEDIATELY_HIDE_COVER_VIEW_ENABLE, true)) {

hideCoverImageView();
} else {
delayedHideCoverImageView();
}

3、做有意义的区分


如果单纯只是为了区分两个名称不能一样,就使用就使用诸如数字,字母来做区分的话,那似乎是毫无意义的区分。


3.1 避免在名字中使用数字


case1: 来自包名的暴击

问题示例:


以下是首页客户端的工程目录节选,数字化的包名:recommend、recommend2、recommend3、recommend4


image.png


大脑活动:


2、3、4难道是因为首页历史包袱太沉重,推荐迭代的版本实在太多导致Old、New单词不够用所以用数字来代替新旧4个历史阶段的版本吗?


代码跟读:



  • recommend:推荐的公共工具和模块;

  • recommend2:收藏夹场景的推荐实现;

  • recommend3:首页场景的推荐实现;

  • recommend4:购后场景的推荐实现;


修改建议:


这里暂时只讨论如何把数字替换成有意义的命名


image.png


3.2 避免使用具有相似含义的名字


case1:同一个类下的“刷新7剑客”

代码示例:


image.png


大脑活动:


为什么一个Adapter类对外有七个刷新数据的接口?


"refreshData()" 和 “speedRefreshData()” 是什么区别?“mainRefreshData()” + "refreshDeltaData()" =“mainRefreshDeltaData()” ?


是一个拆分组合的关系吗?我应该在何总场景下如何正确的使用refresh,我在哪,我在做什么?


代码跟读:


大部分refresh代码线上并不会被调用。阅读和调试下来,实际还在生效的方法只有一个:“gatewayRefreshData()”。


修改建议:实际上这已经不是一个单纯优化命名可以解决的问题,无论叫的多具体,面对7个刷新接口都会懵圈。期望在方法声明期间,作者多体量后来的阅读者和维护者,及时的调整代码。


后来者可以从实际出发去假存真,做减法干掉其它无用的6个刷新方法保留一个刷新接口。


case2:4个数据源定义,该用谁呢

代码示例:


声明1:


public interface IR4UDataSource { 
....
}

声明2:


public interface RecommendIDataSource {
....
}

声明3:


public interface IRecommendDataResource {
....
}

声明4:


public class RecmdDataSource {
....
}

大脑活动:


4个推荐数据源,其中有3个是接口声明,为什么接口定义了不能多态,不能复用接口的声明?这三代的抽象好像有一丢丢失败。


代码跟读:


homepage 包下的 IR4UDataSource,和非常古老的首页曾经爱过,线上实际不会使用;


Recommend2 包下的“RecommendIDataSource” 属于收藏夹,但也属于古老版本,收藏夹不在使用;


Recommend3 包下的“IRecommendDataResource” 确实是首页场景推荐使用,但也是曾经的旧爱;


原来当今的真命天子是Recommend3包下的“RecmdDataSource”,一个使用俏皮缩写未继承接口的实体类,看来是已经放弃伪装。


修改建议:


......


3.3 避免使用具有不同含义但却有相似名字的变量


case1 : 大家都是view,到底谁是谁

代码示例:


public void showOverlay(@NonNull View view ...) {
...
View rootView = getRootView(view);
DxOverlayViewWidget dView = createDxOverlayViewWidget();
dView.showOverLayer(view.getContext(), (ViewGroup)rootView, cardData, itemData);


...
}

代码跟读:


代码中存在3个以view结尾的局部变量,rootView、view 、 dView,其中 view 和 dView 之间只有一个字母的差异,方法如果长一点,view 和 dView 使用频率在高一点,掺杂着rootView会让人抓狂。另外dView也并不是一个view,实际是个DXViewWidget。


修改建议:


public void showOverlay(@NonNull View hostView ...) {
...
ViewGroup parentView = getParentView(hostView);
DxOverlayViewWidget dxOverlayViewWidget = createDxOverlayViewWidget();
dxOverlayViewWidget.showOverLayer(hostView.getContext(), parentView, ...);
...
}

4.使用读的出来的名称


使用读的出来的名称,而不是自造词,这会给你无论是记忆,还是讨论需要说明是哪个方法时,都能带来便利。可以使用达成共识的缩写,避免造成阅读障碍。


4.1 避免使用令人费解的缩写


Case1:接口定义中的俏皮缩写

代码示例:


/**
* Created by *** on 16/8/6.
*/
public interface IR4UDataSource {
....
}

大脑活动:


R4U是什么?R4和Recommend4这个目录有什么关系,难道是购后推荐的数据源定义吗?那U又代表什么?


代码跟读:


原来R4U是Recommend For You的俏皮写法


修改建议:


public interface IRecommendForYouDataSource {
....
}

Case2:成员变量命名的缩写

代码示例:


....
// 标题指示器(indicators)
private LinearLayout mTabLL;
private TabLayout mTabLayout;
....

大脑活动:


“mTabLL”是什么呢?有注释!难道mTabLL是指示器视图?“LL“”也不像是indicators的缩写,喔,LL是LinearLayout的首字母缩写。嗯,使用LinearLayout自定义做成指示器有点厉害!诶,不对,好像TabLayout更像是个选项卡式指示器的样子。


代码跟读:


原来“mTabLL” 下面声明的 “mTabLayout”才是指示器视图,“mTabLL”只是指示器视图的父视图。还好“mTabLayout”没有缩写成“mTabL”,导致和“mTabLL”傻傻分不清,作者已然是手下留情了。


修改建议:


....
private LinearLayout mTabLayoutParentView;
private TabLayout mTabLayout;
....

Case3:局部变量命名的缩写

代码示例:


....
for (PageParams.GroupBuckets ss:params.groupBucketIds.values()) {

if (ss != null) {
bucketIds.removeAll(ss.bucketIdsAll);
Collections.addAll(bucketIds, ss.currentBucketIds);
}
}
....

大脑活动:


"ss"是什么鬼,是不是写错了,GroupBuckets首字母缩写是“gb”,PageParams和GroupBuckets 的首字母缩写是“pg”


这难道是,PageParams 和 GroupBuckets 的尾字母缩写,在一个圈复杂度为18的方法中看到尾字母缩写“ss”?啊!好难受。


修改建议:


for (PageParams.GroupBuckets groupBuckets :params.groupBucketIds.values()) {
if (groupBuckets != null) {
....
}
}

5、使用可搜索的名称


若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。


5.1 给魔法值赐名


Case1:数字魔法值没法搜索也看不懂

代码示例:


public static void updateImmersiveStatusBar(Context context) {
....
if (TextUtils.equals(isFestivalOn, "1")) {
if (TextUtils.equals(navStyle, "0") || TextUtils.equals(navStyle, "1")) {
....
} else if (TextUtils.equals(navStyle, "2")) {
....
}
}
....
}

大脑活动:


对于TextUtils.equals(isFestivalOn, "1") ,我还能猜测一下这里的“1” 代表开关为开的意思。


那TextUtils.equals(navStyle, "0"/"1"/"2") 中的“0”,“1”,“2” 我该如何知道代表什么意思?


老板,请不要再问我为什么需求吞吐率不高,做需求慢了,可能是因为我的想象力不够。


修改建议:


实际上,协议约定时就不应该以 “0”,“1”,“2” 这类无意义的数字做区分声明。


public static final String FESTIVAL_ON = "1";
public static final String NAV_STYLE_FESTIVAL = "0";
public static final String NAV_STYLE_SKIN = "1";
public static final String NAV_STYLE_DARK = "2";

public static void updateImmersiveStatusBar(Context context) {
....
if (TextUtils.equals(isFestivalOn, FESTIVAL_ON)) {
if (TextUtils.equals(navStyle, NAV_STYLE_FESTIVAL)
|| TextUtils.equals(navStyle, NAV_STYLE_SKIN)) {
....
} else if (TextUtils.equals(navStyle, NAV_STYLE_DARK)) {
....
}
}
....
}

5.2 避免在名字中拼错单词


Case1:接口拼错单词,实现类也被迫保持队形

代码示例:


public interface xxx {
....
void destory();
}

image.png


修改建议:


public interface xxx {
....
void destroy();
}

6、类的命名


应该总是名词在最后面,名词决定了这个类代表什么,前面的部分都是用于修饰这个名词;比如,假如现在你有一个服务,然后又是一 个关于订单的服务,那就可以命名为OrderService,这样命名就是告诉我们这是一个服务,然后是一个订单服务;再比如 CancelOrderCommand,看到这个我们就知道这是一个Command,即命令,然后是什么命令呢?就是一个取消订单的命令,CancelOrder表示取消订单。


类的命名可以参考前面讲述过的规则。实际上往往了解一个类更多需要通过查看类的方法定义,而仅仅通过类名无法知晓类是如何工作的。关于类的更多内容,会在后续章节详细展开。


7、方法的命名


可以用一个较强的动词带目标的形式。一个方法往往是对某一目标进行操作,名字应该反映出这个操作过程是干什么的,而对某一目标进行操作则意味着我们应该使用动宾词组。比如:addOrder()。当方法有返回值的时候,方法应该用它所返回的值命名,比如currentPenColor()。


《代码大全》:变量名称的最佳长度是 9 到 15 个字母,方法往往比变量要复杂,因而其名字也要长些。有学者认为恰当的长度是 20 到 35 个字母。但是,一般来说 15 到 20 个字母可能更现实一些,不过有些名称可能有时要比它长。


7.1 避免对方法使用无意义或者模棱两可的动词


避免无意义或者模棱两可的动词 。有些动词很灵活,可以有任何意义,比如 HandleCalculation(),processInput()等方法并没有告诉你它是作什么的。这些名字最多告诉你,它们正在进行一些与计算或输入等有关的处理。


所用的动词意义模糊是由于方法本身要做的工作太模糊。方法存在着功能不清的缺陷,其名字模糊只不过是个标志而已。如果是这种情况,最好的解决办法是重新构造这个方法,弄清它们的功能,从而使它们有一个清楚的、精确描述其功能的名字。


Case1: 名不副实的process

代码示例:


/**
* 处理主图的数据
*
* @return 如果有浮层数据就返回true,没有就返回false
*/

private boolean processMainPic() {
....
boolean hasMainPicFloat = false;
....
return hasMainPicFloat;
}

// 调用处
boolean hasMainPicFloat = processMainPic();

大脑活动:


1、方法名的字面意思是处理主图(暂不纠结缩写Pic了),但是是如何处理主图的呢?


2、返回值是bool类型,是表示处理成功或失败吗?


3、查看注释解释,当前方法是在处理主图的数据,返回为是否存在浮层数据,为什么一个处理主图数据的方法检查的是浮层数据呢?


看完发现,这个方法原来是拿主图数据检查其中是否存在浮层数据,名不副实呀。


修改建议:


额外说明:既然工程默认“Float”是浮层,这里不做额外修改,但实际上不合理,毕竟Float在Java中表示浮点型数据类型,会引起误解。


/**
* 是否有浮层数据
*
* @return 如果有浮层数据就返回true,没有就返回false
*/

private boolean hasFloatData($MainPictureData) {
....
boolean hasFloatData = false;
....
return hasFloatData;
}

// 调用处
boolean hasFloatData = hasFloatData(mainPictureData);

Case2: 我该如何正确使用这个方法

代码示例:


// 10多处调用
... = GatewayUtils.processTime(System.currentTimeMillis());

public class GatewayUtils {
....
// 这个方法没有注释
public static long processTime(long time) {
return time + (SDKUtils.getTimeOffset() * 1000L);
}
....
}

大脑活动:


好多地方调用工具类的processTime,processTime到底是在处理些什么呢?


如果入参传入的不是 System.currentTimeMillis() 而是 SystemClock.uptimeMillis() 或者随意传入一个long值,方法的返回值会是什么呢?


修改建议:


public static long currentNetworkTime() {
return System.currentTimeMillis() + (SDKUtils.getTimeOffset() * 1000L);
}

7.2 避免返回和方法名定义不一致的类型


Case1: 私有方法就可以乱定义吗?

码示例:


// 唯一调用处
final IPageProvider pageProvider = checkActivityAvaliable();
if (pageProvider == null) {
....
return;
}

// 函数声明
private IPageProvider checkActivityAvaliable() {
IPageProvider pageProvider = pageProviderWeakReference.get();
if (pageProvider == null) {
PopFactory.destroyPopCenter(pageName);
return null;
}
return pageProvider;
}

大脑活动:


check方法如果有返回值的话不应该是bool类型吗?


“Avaliable”拼错了诶,正确的单词拼写是:“Available”。


“IPageProvider” 和 “ActivityAvaliable” 是什么关系,为什么校验可用的Activity返回的是“IPageProvider”。


代码跟读:


原来方法里面偷偷做了一个销毁“PopCenter”的动作。把获取“PageProvider”和销毁“PopCenter”两件事情放在了一起。确实没看懂方法名和方法所做任何一件事情有什么关系。


修改建议:


干掉checkActivityAvaliable()方法。(这里不展开讨论高质量的函数相关内容)


final IPageProvider pageProvider = pageProviderWeakReference.get();
if (pageProvider == null) {
PopFactory.destroyPopCenter(pageName);
....
return;
}

04 养成良好的命名习惯一些建议


1.对自己严格自律,自己写代码时要有一种希望把每个名称都命名好的强烈意识和严格的自律意识;


2.要努力分析和思考当前被你命名的事物或逻辑的本质;这点非常关键,思考不深入,就会导致最后对这个事物的命名错误,因为你还没想清楚被你命名的事物是个什么东西;


3.你的任何一个属性的名字都要和其实际所代表的含义一致;你的任何一个方法所做的事情都要和该方法的名字的含义一致;


4.要让你的程序的每个相似的地方的命名风格总是一致的。不要一会儿大写,一会儿小写;一会儿全称一会儿简写;一会儿帕斯卡(Pascal)命名法,一会儿骆驼(Camel)命名法或匈牙利命名法;


作者:阿里云云原生
来源:juejin.cn/post/7225524569506005053
收起阅读 »

WEB前端奇淫巧计-消除异步的传染性

web
简介 大家好今天给大家介绍一个关于异步的比较恶心的东西也许大家在开发中也曾遇到过只不过解决起来比较棘手废话不多说直接上代码 async function getUser() { return await fetch('https://my-json-ser...
继续阅读 »


简介


大家好
今天给大家介绍一个关于异步的比较恶心的东西
也许大家在开发中也曾遇到过
只不过解决起来比较棘手
废话不多说直接上代码


async function getUser() {
return await fetch('https://my-json-server.typicode.com/typicode/demo/profile').then((resp) => resp.json())
}

async function m1(){
//other works
return await getUser()
}

async function m2(){
//other works
return await m1()
}

async function m3(){
//other works
return await m2()
}

async function main() {
const res = await m3()
console.log('res', res)
}
main()

经过观察上述代码有没有发现
一旦一个函数使用 async await
其他函数调用这个函数进行异步操作时,也要加上async await
突然有没有觉得有那么一丝丝小恶心
我们今天的目的就是把以上的async await去掉也能达到同样的效果


function getUser() {
return fetch('https://my-json-server.typicode.com/typicode/demo/profile')
}

function m1() {
//other works
return getUser()
}

function m2() {
//other works
return m1()
}

function m3() {
//other works
return m2()
}

function main() {
const res = m3()
console.log('res', res)
}
main()

就像以上代码调用,也能实现同样的效果
是不是一下子有点懵懵的
这其实是一个大厂的内部晋升题,还是有点小难度的
这个问题在一些框架的底层也会常遇到
我来带你逐步探讨


解决问题


不难发现通过以上直接去掉async await是无法得到原来的结果的
因为它会返回一个promise 对象,无法使res得到真实的数据
这里我先说一下大概思路
首先fetch会返回一个promise,但是在请求时就想对结果进行操作,显然是不可能的
这时候我们需要在fetch没返回我们想要的数据前先终止函数运行,等拿到正确的数据后我们再运行函数
是不是听到这个过程也是一头雾水呀
先别着急
继续往下看
如果想要函数终止运行有个办法那就是抛出异常 throw error
然后等fetch返回数据data后,对数据进行缓存
缓存后开始函数的运行,
最后交付data
看一下流程图
image.png
整体流程大概就是这样
为了方便理解,我化简一下上述代码


function main() {
const res = fetch('https://my-json-server.typicode.com/typicode/demo/profile')
console.log('res', res)//res要得到一个data数据而不是一个promise对象
}
main()

我们都知道fetch实际返回一个promise对象
此时返回的是一个promise
image.png
在不改变main函数体的情况下使得res是我们想要的数据而不是promise
下面是我们想要的数据
image.png
那我们就得想办法更改main的调用方式


function main() {
const res = fetch('https://my-json-server.typicode.com/typicode/demo/profile')
console.log('res', res)//res要得到一个data数据而不是一个promise对象
}
function run(func){
//瓜瓜一顿操作,使得fetch返回真实的数据而不是promise
}
run(main)

根据上述讲的流程,我们来看一下run函数的具体过程
注释我已经写的很详细了
大家认真看哦


function run(func) {
let cache = []//缓存的列表,由于可能不止一个fetch,所以要用一个list
let i = 0;//缓存列表的下标
const _originalFetch = window.fetch//储存原先的fetch
window.fetch = (...args) => {//重写fetch函数,这个fetch要么抛出异常,要么返回真实的数据
if (cache[i]) {//判断一下缓存是否存在,如果存在就返回真实的数据或抛出异常
if (cache[i].status === 'fulfilled') {
return cache[i].data
} else if (cache[i].status === 'rejected') {
throw cache[i].err
}
}
const result = {
status: 'pending',
data: null,
err: null
}
cache[i++] = result//添加缓存
//发送请求
//真实的fetch调用
const prom = _originalFetch(...args).then(resp => resp.json()).then(resp => {
//等待返回结果,然后修改缓存
result.status = 'fulfilled'
result.data = resp
}, err => {
result.status = 'rejected'
result.data = err
})
//如果没有缓存,就添加缓存和抛出异常
throw prom
//这里为什么会抛出真实fetch返回的promise,主要是因为外面会用到这个promise然后等待拿到最终结果
}
try {
//在try里调用func也就是上述的main函数
//由于main里面有fetch,且第一次没有缓存,所以会抛出一个异常
func()

} catch (err) {
//从这里捕获到异常
//这里的err就是上述fetch返回的promise

if (err instanceof Promise) {//验证一下是不是promise
const reRun = () => {
i = 0//重置一下下标
func()
}
err.then(reRun, reRun)//待promise返回结果后重新执行func,也就是重新执行main
//这次执行已经有缓存了,并且返回中有了正确的结果,所以重写的fetch会返回真实的数据
}
}
}

通过这么一个函数调用main,就可以使得在不改变main函数体的情况下使得fetch返回真实的数据而不是promise对象
是不是感到很神奇
我们来看下完整代码


function getUser() {
return fetch('https://my-json-server.typicode.com/typicode/demo/profile')
}

function m1() {
//other works
return getUser()
}

function m2() {
//other works
return m1()
}

function m3() {
//other works
return m2()
}

function main() {
const res = m3()
console.log('res', res)
}

function run(func) {
let cache = []//缓存的列表
let i = 0;//缓存下标
const _originalFetch = window.fetch//储存原先的fetch
window.fetch = (...args) => {//重写fetch函数
if (cache[i]) {
if (cache[i].status === 'fulfilled') {
return cache[i].data
} else if (cache[i].status === 'rejected') {
throw cache[i].err
}
}
const result = {
status: 'pending',
data: null,
err: null
}
cache[i++] = result
//发送请求
const prom = _originalFetch(...args).then(resp => resp.json()).then(resp => {
result.status = 'fulfilled'
result.data = resp
}, err => {
result.status = 'rejected'
result.data = err
})
throw prom
}
try {
func()
} catch (err) {
//什么时候引发重新执行function
if (err instanceof Promise) {
const reRun = () => {
i = 0
func()
}
err.then(reRun, reRun)
}
}
}
run(main)

此时执行的结果,就是我们想要的结果
image.png
没错就是这样,nice



在框架中的应用


其实在react这个应用很常见
我们先来看一段代码


const userResource = getUserResource()
function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
</Suspense>

)
}
function ProfileDetails(){
const user = userResource.read();
return <h1>{user.name}</h1>
}
ReactDOM.render(<ProfilePage/>, document.getElementById("root"));

别急别急我来稍微翻译下
这段代码的意思是在ProfileDetails没加载到数据前先显示Loading profile...
待ProfileDetails加载到数据就渲染 {user.name}
他是怎么实现的呢
如果放在vue里面ProfileDetails必须为一个异步函数
而在这里的实现方案与我上述讲述的类似
我们来验证一下
在ProfileDetails打印1


function ProfileDetails(){
console.log(1)//在这里输出一个1
const user = userResource.read();
return <h1>{user.name}</h1>
}

输出结果是这样的
image.png
为什么会输出两个1呢
原因就和我们上述代码类似
在userResource.read()第一次执行它会抛出一个错误
第二次是已经拿到数据
所以它执行了两遍,最终拿到了数据
我们在函数里手动抛出一个promise


function ProfileDetails(){
throw new Promise((resolve)=>{})//我们在这里抛出一个promise,且函数体里没有执行resolve()
const user = userResource.read();
return <h1>{user.name}</h1>
}

你会发现页面一直展示Loading profile...
image.png
因为我们抛出的promise,一直没有resolve,也就是等待不了结果返回,所以它只会渲染Loading profile...保持不变
肿么样,神奇吧,你学费了嘛
有兴趣的可以一起学习交流,有什么问题也可以联系我
小编微信:buouyupro


作者:布偶鱼
来源:juejin.cn/post/7223937161707716669
收起阅读 »

前端枚举最佳规范——优雅可能也会过时

web
痛点很久很久以前,我在ts项目中使用枚举是这样的export enum GENDER {      MALE = 1,      FEMALE = 2,  }export const GEN...
继续阅读 »

痛点

很久很久以前,我在ts项目中使用枚举是这样的

export enum GENDER {  
   MALE = 1,  
   FEMALE = 2,  
}

export const GENDER_MAP: Record<GENDER, string> = {  
  [GENDER.MALE]: '男',  
  [GENDER.FEMALE]: '女',  
}

// 可能还会写一个给select组件使用的options
export const GENDER_OPTIONS = [  
  {  
       label: '男',  
       value: GENDER.MALE  
  },  
  {  
       label: '女',  
       value: GENDER.FEMALE,  
  }  
];

封装

淦!好麻烦。封装一下,暂时只封装了一个js版本的,想做成ts版本的请自行更改

import { invert, isArray } from 'lodash';

class Enum {
 constructor(enumsName) {
   this.enumsName = enumsName;
   this.enums = {};
}
 // 设置枚举项
 setItem(desc, value) {
   this.enums[desc] = value;
   return this;
}
 findEnumItem(value) {
   return Object.keys(this.enums).find(
    (desc) => this.getValueFromDesc(desc) === value,
  );
}
 // 根据值获取描述
 getValueFromDesc(desc) {
   return this.enums[desc];
}
 getDescriptionFromValue(value, separator = ',') {
   if (isArray(value)) {
     return value.map((item) => this.findEnumItem(item)).join(separator);
  } else {
     return this.findEnumItem(value);
  }
}
 // 获取枚举第一项的值
 getFirstValue() {
   const enums = this.getEnums();
   return enums[Object.keys(enums)[0]];
}
 // 自定义转换枚举数组格式
 transformEnums(formatTarget) {
   // formatTarget 数组第一项是描述属性名称,第二项是枚举值属性名称
   if (isArray(formatTarget) && formatTarget.length === 2) {
     const [keyName, valueName] = formatTarget;
     return Object.entries(this.enums).map((item) => {
       const [desc, value] = item;
       return {
        [keyName]: desc,
        [valueName]: value,
      };
    });
  } else {
     return [];
  }
}
 // 获取 options
 getOptions() {
   return Object.entries(this.enums).map((item) => {
     const [desc, value] = item;
     return {
       label: desc,
       value,
    };
  });
}
 // 获取描述和值反转对象,输出 {value: desc}
 getInvertEnums() {
   return invert(this.enums);
}
 is(enumsName) {
   return this.enumsName === enumsName;
}
}
export default Enum;

export const getEnums = (enumsName, enums) =>
 enums.find((enumItem) => enumItem.is(enumsName));

使用

ok,咋使用呢?首先当然要创建枚举啦

// commonEnum.js
import Enum from '@/utils/enum';

// 我习惯把同一个模块或功能中的枚举全都塞到一个数组中
export const COMMON_ENUMS = [
   new Enum('性别').setItem('男', 1).setItem('女', 2),
   ...
];

在使用到的地方,先获取你想要的枚举

import {getEnums} from '@/utils/enum';
import {COMMON_ENUMS} from '@/enum/commonEnum';

const genderEnums = getEnums('性别', COMMON_ENUMS);

接下来分一些场景来举例一些使用方法:

  1. 作某个字段的值映射

{
   title: '性别',  
   dataIndex: 'gender',  
   render: (gender) => genderEnums.getDescriptionFromValue(gender),
}
  1. select组件中需要传入选项

<Select  
   name="gender"  
   label="性别"
   options={genderEnums.getOptions}
/>

其余方法就留给大家自己探索吧,我觉得这个封装已经可以涵盖大部分的场景了,你觉得呢?

作者:AliPaPa
来源:juejin.cn/post/7221820151397335077

收起阅读 »

前端实现点击选词功能

web
今天有一个需求,点击选中某个英文单词,然后对这个单词做一些处理,例如高亮背景、查看一些详细信息等等,今天简单实现了一下,效果如下:(支持移动端,chrome和sarafi浏览器均能正常使用。语言🚀 vue3 + typescript) 选词 由于要动态添加给...
继续阅读 »

今天有一个需求,点击选中某个英文单词,然后对这个单词做一些处理,例如高亮背景、查看一些详细信息等等,今天简单实现了一下,效果如下:(支持移动端,chrome和sarafi浏览器均能正常使用。语言🚀 vue3 + typescript)


highlight.gif


选词


由于要动态添加给某些单词动态添加一些标签,我们这里可以考虑使用v-html


首先我们先编写一下简单的结构


<script setup lang="ts">
</script>

<template>
<div class="container" v-html="shortArticle"></div>
</template>


<style>
.container {
font-size: 18px;
}
</style>

然后,我们将需要处理的短文变换为span标签包裹,这里的思路是按照空格划分,然后添加span结构,最后拼接到一起返回。这里有一些边缘条件要考虑,比如can't(whichyes!等等,按照空格划分出来的数据有一点问题。


截屏2023-04-19 20.48.19.png


如果不做处理的话,一些标点符号也会高亮出来,就不太正确了。下面是处理逻辑,整体比较简单,就不解释了。


function addElementSpan(str: string): string {
return str
.split(' ')
.map((item) => {
const { start, word, end } = getWord(item)
return `${start}<span>${word}</span>${end} `
})
.join('')
}

function getWord(str: string) {
let word = ''
let start = ''
let end = ''
let j = str.length - 1
let i = 0

while (i < str.length) {
if (/^[a-zA-Z]$/.test(str[i])) {
break
}
start = start + str[i]
i += 1
}

while (j >= 0) {
if (/^[a-zA-Z]$/.test(str[j])) {
break
}
end = str[j] + end
j -= 1
}

word = str.slice(i, j + 1)

// 处理数字
if (!word && start === end) {
start = ''
}

return {
start,
word,
end
}
}

现在我们来实现效果


<script setup lang="ts">
import { computed } from 'vue'
import { addElementSpan } from './utils'

const str = `It works fine if you move the navbar outside the header. See below. For the reason, according to MDN: The element is positioned according to the normal flow of the document, and then offset
relative to its flow root and containing block based on the values of top, right, bottom, and
left. For the containing block: The containing block is the ancestor to which the element is
relatively positioned So, when I do not misunderstand, the navbar is positioned at offset 0
within the header as soon as it is scrolled outside the viewport (which, clearly, means, you
can't see it anymore).`


const shortArticle = computed(() => {
return addElementSpan(str)
})

function setColor(event: any) {
// console.log(event.target.innerText) 获取选中的文本
event.target?.classList.add('word_highlight')
}
</script>

<template>
<div class="container" @click="setColor($event)" v-html="shortArticle"></div>
</template>


<style>
.word_highlight {
background-color: red;
}
</style>


在父亲元素上添加点击事件,触发事件点击之后,调用setColor函数,高亮背景(添加class)


不过有一点小小的问题,点击div的空白区域或者非英文单词区域会直接整个背景变成红色,控制台打印event.target.innerText可以发现它的值为整个文本,所以我们可以根据判断打印的文本长度和需要设置的文本长度是否一致来解决这个问题。(ps:⬆️面的示例代码str字符串使用了反引号 模板字符串 ,直接使用下面会影响结果)


function setColor(event: any) {
// console.log(event.target.innerText)
if(str !== event.target.innerText){
event.target?.classList.add('word_highlight')
}
}

对于event.target不太了解的伙伴可以看这篇文章 ➡️ Event.target - Web API 接口参考 | MDN (mozilla.org)


(和event.target类似的还有一个属性event.currentTarget,不太了解的伙伴可以看下这篇文章 ➡️ Event.currentTarget - Web API 接口参考 | MDN (mozilla.org),它俩的区别是event.target指的是事件触发的元素,而event.currentTarget指的是事件绑定的元素)


功能拓展


这里只是演示了一下比较简单的背景高亮效果,有需求的伙伴可以自己拓展一下。


比如类似于掘金的拼写错误提示框


截屏2023-04-19 21.16.20.png


如果要实现滑动选词的话,可以参考这个博主的文章 ➡️ 鼠标选中文本划词高亮、再次选中划词取消高亮效果


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

因为写不出拖拽移动效果,我恶补了一下Dom中的各种距离

web
背景 最近在项目中要实现一个拖拽头像的移动效果,一直对JS Dom拖拽这一块不太熟悉,甚至在网上找一个示例,都看得云里雾里的,发现遇到最大的拦路虎就是JS Dom各种各样的距离,让人头晕眼花,看到一个距离属性,大脑中的印象极其模糊,如同有一团雾一样,不知其确切...
继续阅读 »

背景


最近在项目中要实现一个拖拽头像的移动效果,一直对JS Dom拖拽这一块不太熟悉,甚至在网上找一个示例,都看得云里雾里的,发现遇到最大的拦路虎就是JS Dom各种各样的距离,让人头晕眼花,看到一个距离属性,大脑中的印象极其模糊,如同有一团雾一样,不知其确切含义。果然是基础不牢,地动山摇。今天决心夯实一下基础,亲自动手验证一遍dom各种距离的含义。


JS Dom各种距离释义


下面我们进入正题, 笔者不善于画图, 主要是借助浏览器开发者工具,通过获取的数值给大家说明一下各种距离的区别。


第一个发现 window.devicePixelRatio 的存在


本打算用截图软件丈量尺寸,结果发现截图软件显示的屏幕宽度与浏览器开发者工具获取的宽度不一致,这是为什么呢?



  • 截图软件显示的屏幕宽度是1920


image.png



  • window.screen.width显示的屏幕宽度是1536


image.png


这是怎么回事?原来在PC端,也存在一个设备像素比的概念。它告诉浏览器一个css像素应该使用多少个物理像素来绘制。要说设备像素比,得先说一下像素和分辨率这两个概念。



  • 像素
    屏幕中最小的色块,每个色块称之为一个像素(Pixel)


image.png



image.png



  • 设备像素比


设备像素比的定义是:


window.devicePixelRatio =显示设备物理像素分辨率显示设备CSS像素分辨率\frac{显示设备物理像素分辨率}{显示设备CSS像素分辨率}


根据设备像素比的定义, 如果知道显示设备横向的css像素值,根据上面的公式,就能计算出显示设备横向的物理像素值。


显示设备宽度物理像素值= window.screen.width * window.devicePixelRatio;

设备像素比在我的笔记本电脑上显示的数值是1.25, 代表一个css逻辑像素对应着1.25个物理像素。


image.png


我前面的公式计算了一下,与截图软件显示的像素数值一致。这也反过来说明,截图软件显示的是物理像素值。


image.png



  • window.devicePixelRatio 是由什么决定的 ?


发现是由笔记本电脑屏幕的缩放设置决定的,如果设置成100%, 此时window.screen.width与笔记本电脑的显示器分辨率X轴方向的数值一致,都是1920(如右侧图所示), 此时屏幕上的字会变得比较小,比较伤视力。





  • 逻辑像素是为了解决什么问题?


逻辑像素是为了解决屏幕相同,分辨率不同的两台显示设备, 显示同一张图片大小明显不一致的问题。比如说两台笔记本都是15英寸的,一个分辨率是1920*1080,一个分辨率是960*540, 在1920*1080分辨率的设备上,每个格子比较小,在960*540分辨率的设备上,每个格子比较大。一张200*200的图片,在高分率的设备上看起来会比较小,在低分辨率的设备上,看起来会比较大。观感不好。为了使同样尺寸的图片,在两台屏幕尺寸一样大的设备上,显示尺寸看起来差不多一样大,发明了逻辑像素这个概念。规定所有电子设备呈现的图片等资源尺寸统一用逻辑像素表示。然后在高分辨率设备上,提高devicePixelRatio, 比如说设置1920*1080设备的devicePixelRatio(dpr)等于2, 一个逻辑像素占用两个格子,在低分辨率设备上,比如说在960*540设备上设置dpr=1, 一个css逻辑像素占一个格子, 这样两张图片在同样的设备上尺寸大小就差不多了。通常设备上的逻辑像素是等于物理像素的,在高分辨率设备上,物理像素是大于逻辑像素数量的。由此也可以看出,物理像素一出厂就是固定的,而设备的逻辑像素会随着设备像素比设置的值不同而改变。但图片的逻辑像素值是不变的。


document.body、document.documentElement和window.screen的宽高区别


差别是很容易辨别的,如下图所示:



  • document.body -- body标签的宽高

  • document.documentElement -- 网页可视区域的宽高(不包括滚动条)

  • window.screen -- 屏幕的宽高


image.png



  • 网页可视区域不包括滚动条


如下图所示,截图时在未把网页可视区域的滚动条高度计算在内的条件下, 截图工具显示的网页可视区域高度是168, 浏览器显示的网页可视区域的高度是167.5, 误差0.5,由于截图工具是手动截图,肯定有误差,结果表明,网页可视区域的高度 不包括滚动条高度。宽度同理。


image.png



  • 屏幕和网页可视区域的宽高区别如下:


屏幕宽高是个固定值,网页可视区域宽高会受到缩放窗口影响。


image.png



  • 屏幕高度和屏幕可用高度区别如下:


屏幕可用高度=屏幕高度-屏幕下方任务栏的高度,也就是:


window.screen.availHeight = window.screen.height - 系统任务栏高度

image.png


scrollWidth, scrollLeft, clientWidth关系


scrollWidth(滚动宽度,包含滚动条的宽度)=scrollLeft(左边卷去的距离)+clientWidth(可见部分宽度);
// 同理
scrollHeight(滚动高度,包含滚动条的高度)=scrollTop(上边卷去的距离)+clientHeight(可见部分高度);

需要注意的是,上面这三个属性,都取的是溢出元素的父级元素属性。而不是溢出元素本身。本例中溢出元素是body(document.body),其父级元素是html(document.documentElement)。另外,


溢出元素的宽度(document.body.scrollWidth)=父级元素的宽度(document.documentElement.scrollWidth) - 滚动条的宽度(在谷歌浏览器上滚动条的宽度是19px)

image.png


<!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>JS Dom各种距离</title>
<style>
html,
body {
margin: 0;
}

body {
width: 110%;
border: 10px solid blue;
}

.rect {
height: 50px;
background-color: green;
}
</style>
</head>

<body>
<div id="rect" class="rect"></div>
</body>

</html>

元素自身和父级元素的scrollWidth和scrollLeft关系?


从下图可以看出:



  • 元素自身没有X轴偏移量,元素自身的滚动宽度不包含滚动条

  • 父级元素有X轴便宜量, 父级元素滚动宽度包含滚动条
    image.png


<!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>JS Dom各种距离</title>
<style>
div {
border: 1px solid #000;
width: 200px;
height: 600px;
padding: 10px;
background-color: green;
margin: 10px;
}
</style>
</head>

<body>
<div class="rect"> 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
</div>
</body>
<script>
</script>
</html>

offsetWidth和clientWidth的关系?


offsetWidth和clientWidth的共同点是都包括 自身宽度+padding , 不同点是offsetWidth包含border


如下图所示:



  • rect元素的clientWidth=200px(自身宽度) + 20px(左右padding) = 220px

  • rect元素的offsetWidth=200px(自身宽度) + 20px(左右padding) + 2px(左右boder) = 222px


image.png


<!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>JS Dom各种距离</title>
<style>
div {
border: 1px solid #000;
width: 200px;
height: 100px;
padding: 10px;
background-color: green;
margin: 10px;
}
</style>
</head>

<body>
<div class="rect">111111111111111111111111111111111111111111111111</div>
</body>
<script>


</script>

</html>

event.clientX,event.clientY, event.offsetX 和 event.offsetY 关系


代码如下,给rect元素添加一个mousedown事件,打印出事件源的各种位置值。


<!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>JS Dom各种距离</title>
<style>
html,
body {
margin: 0;
}

body {
width: 200px;
padding: 10px;
border: 10px solid blue;
}

.rect {
height: 50px;
background-color: green;
}
</style>
</head>

<body>

<div id="rect" class="rect"></div>


</body>
<script>
const rectDom = document.querySelector('#rect');

rectDom.addEventListener('mousedown', ({ offsetX, offsetY, clientX, clientY, pageX, pageY, screenX, screenY }) => {
console.log({ offsetX, offsetY, clientX, clientY, pageX, pageY, screenX, screenY });
})
</script>

</html>

我们通过y轴方向的高度值,了解一下这几个属性的含义。 绿色块的高度是50px, 我们找个特殊的位置(绿色块的右小角)点击一下,如下图所示:



  • offsetY=49, 反推出这个值是相对于元素自身的顶部的距离

  • clientY=69, body标签的border-top是10,paiding是10, 反推出这个值是相对网页可视区域顶部的距离

  • screenY=140,目测肯定是基于浏览器窗口,


所以它们各自的含义,就很清楚了。


image.png


事件源属性表示的距离
event.offsetX、event.offsetY鼠标相对于事件源元素(srcElement)的X,Y坐标,
event.clientX、event.clientY鼠标相对于浏览器窗口可视区域的X,Y坐标(窗口坐标),可视区域不包括工具栏和滚动偏移量。
event.pageX、event.pageY鼠标相对于文档坐标的x,y坐标,文档坐标系坐标 = 视口坐标系坐标 + 滚动的偏移量
event.screenX、event.screenY鼠标相对于用户显示器屏幕左上角的X,Y坐标


  • pageX和clientX的关系


我们点击下图绿色块的右下角,把pageX和clientX值打印出来。如下图所示:



  • 可视区域的宽度是360,点击点的clientX=359(由于是手动点击,有误差也正常)

  • 水平方向的偏移量是56

  • pageX是415,360+56=416,考虑到点击误差,可以推算出 ele.pageX = ele.clientX + ele.scrollLeft


image.png


getBoundingClientRect获取的top,bottom,left,right的含义


从下图可以看出,上下左右这四个属性,都是相对于浏览器可视区域左上角而言的。



从下图可以看出,当有滚动条出现的时候,right的值是359.6,而不是360+156(x轴的偏移量), 说明通过getBoundingClientRect获取的属性值是不计算滚动偏移量的,是相对浏览器可视区域而言的。


image.png


想移动元素,mouse和drag事件怎么选?


mouse事件相对简单,只有mousedown(开始),mousemove(移动中),mouseup(结束)三种。与之对应的移动端事件是touch事件,也是三种touchstart(手指触摸屏幕), touchmove(手指在屏幕上移动), touchend(手指离开屏幕)。


相对而言, drag事件就要丰富一些。



  • 被拖拽元素事件


事件名触发时机触发次数
dragstart拖拽开始时触发一次1
drag拖拽开始后反复触发多次
dragend拖拽结束后触发一次1


  • 目标容器事件


事件名触发时机触发次数
dragenter被拖拽元素进入目标时触发一次1
dragover被拖拽元素在目标容器范围内时反复触发多次
drop被拖拽元素在目标容器内释放时(前提是设置了dropover事件)1

想要移动一个元素,该如何选择这两种事件类型呢? 选择依据是:


类型选择依据
mouse事件1. 要求丝滑的拖拽体验 2. 无固定的拖拽区域 3. 无需传数据
drag事件1. 拖拽区域有范围限制 2. 对拖拽流畅性要求不高 3. 拖拽时需要传数据

现在让我们写个拖拽效果


光说不练假把式, 扫清了学习障碍后,让我们自信满满地写一个兼容PC端和移动端的拖动效果。不积跬步无以至千里,幻想一口吃个胖子,是不现实的。这一点在股市上体现的淋漓尽致。都是有耐心的人赚急躁的人的钱。所以,要我们沉下心来,打牢基础,硬骨头啃一点就会少一点,步步为营,稳扎稳打,硬骨头也会被啃成渣。



<!DOCTYPE html>
<html lang="en">
<head>
    
<meta charset="UTF-8" />
    
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
    
<title>拖拽水潭</title>
<style>
.water {
position: absolute;
width: 100px;
height: 100px;
border-radius: 100px;
cursor: grab;
z-index: 10;
}
</style>
</head>

<body>
<img class="water" src="./water.png" alt="" />  
</body>

<script>
let evtName = getEventName();
// 确保图片加载完
window.onload = () => {
// 鼠标拖拽图片时,拖拽点距离图片X和Y轴方向的距离
let offsetX = 0, offsetY = 0;
const water = document.querySelector(".water");

const moveAt = ({ pageX, pageY }) => {
water.style.cssText = `left:${pageX - offsetX}px;top:${pageY - offsetY}px;`;
};

water.addEventListener(evtName.start, (event) => {
// 图片的偏移距离是针对图片边界, 不能把图片边界到鼠标点击图片位置的距离算在内
// 否则移动图片结束后,就会出现向下,向右非自然的偏移
offsetX = event.clientX - water.getBoundingClientRect().left;
offsetY = event.clientY - water.getBoundingClientRect().top;

// 设置初始偏移
moveAt(event);

// 监听鼠标相对于可视窗口移动的距离
document.addEventListener(evtName.move, moveAt);
});

// 拖动停止时,释放document上绑定的移动事件
// 不然移动鼠标,不拖拽时白白产生性能开销
water.addEventListener(evtName.end, () =>
document.removeEventListener(evtName.move, moveAt);
});
};

// 区分是移动端还是PC端移动事件
function getEventName() {
if ("ontouchstart" in window) {
return {
start: "touchstart",
move: "touchmove",
end: "touchend",
};
} else {
return {
start: "mousedown",
move: "mousemove",
end: "mouseup",
};
}
}
</script>
</html>

彩蛋


在chrome浏览器上发现一个奇怪的现象,设置的border值是整数,计算出来的值却带有小数


image.png


而当border值是4的整数倍的时候,计算值是正确的


image.png


看了这篇文章解释说,浏览器可能只能渲染具有整数物理像素的border值,不是整数物理像素的值时,计算出的是近似border值。这个解释似乎讲得通,在设备像素比是window.devicePixelRatio=1.25的情况下, 1px对应的是1.25物理像素, 1.25*4的倍数才是整数,所以设置的逻辑像素是4的整数倍数,显示的渲染计算值与设置值一致,唯一让人不理解的地方,为什么padding,margin,width/height却不遵循同样的规则。


作者:去伪存真
来源:juejin.cn/post/7225206098692407355
收起阅读 »

使用双token实现无感刷新,前后端详细代码

前言 近期写的一个项目使用双token实现无感刷新。最后做了一些总结,本文详细介绍了实现流程,前后端详细代码。前端使用了Vue3+Vite,主要是axios封装,服务端使用了koa2做了一个简单的服务器模拟。 一、token 登录鉴权 jwt:JSON Web...
继续阅读 »

微信图片_2022090618343531.jpg


前言


近期写的一个项目使用双token实现无感刷新。最后做了一些总结,本文详细介绍了实现流程,前后端详细代码。前端使用了Vue3+Vite,主要是axios封装,服务端使用了koa2做了一个简单的服务器模拟。


一、token 登录鉴权


jwt:JSON Web Token。是一种认证协议,一般用来校验请求的身份信息和身份权限。
由三部分组成:Header、Hayload、Signature


header:也就是头部信息,是描述这个 token 的基本信息,json 格式


{
"alg": "HS256", // 表示签名的算法,默认是 HMAC SHA256(写成 HS256)
"type": "JWT" // 表示Token的类型,JWT 令牌统一写为JWT
}

payload:载荷,也是一个 JSON 对象,用来存放实际需要传递的数据。不建议存放敏感信息,比如密码。


{
"iss": "a.com", // 签发人
"exp": "1d", // expiration time 过期时间
"sub": "test", // 主题
"aud": "", // 受众
"nbf": "", // Not Before 生效时间
"iat": "", // Issued At 签发时间
"jti": "", // JWT ID 编号
// 可以定义私有字段
"name": "",
"admin": ""
}

Signature 签名 是对前两部分的签名,防止数据被篡改。
需要指定一个密钥。这个密钥只有服务器才知道,不能泄露。使用 Header 里面指定的签名算法,按照公式产生签名。


算出签名后,把 Header、Payload、Signature 三个部分拼成的一个字符串,每个部分之间用 . 分隔。这样就生成了一个 token


二、何为双 token



  • accessToken:用户获取数据权限

  • refreshToken:用来获取新的accessToken


双 token 验证机制,其中 accessToken 过期时间较短,refreshToken 过期时间较长。当 accessToken 过期后,使用 refreshToken 去请求新的 token。


双 token 验证流程



  1. 用户登录向服务端发送账号密码,登录失败返回客户端重新登录。登录成功服务端生成 accessToken 和 refreshToken,返回生成的 token 给客户端。

  2. 在请求拦截器中,请求头中携带 accessToken 请求数据,服务端验证 accessToken 是否过期。token 有效继续请求数据,token 失效返回失效信息到客户端。

  3. 客户端收到服务端发送的请求信息,在二次封装的 axios 的响应拦截器中判断是否有 accessToken 失效的信息,没有返回响应的数据。有失效的信息,就携带 refreshToken 请求新的 accessToken。

  4. 服务端验证 refreshToken 是否有效。有效,重新生成 token, 返回新的 token 和提示信息到客户端,无效,返回无效信息给客户端。

  5. 客户端响应拦截器判断响应信息是否有 refreshToken 有效无效。无效,退出当前登录。有效,重新存储新的 token,继续请求上一次请求的数据。


注意事项



  1. 短token失效,服务端拒绝请求,返回token失效信息,前端请求到新的短token如何再次请求数据,达到无感刷新的效果。

  2. 服务端白名单,成功登录前是还没有请求到token的,那么如果服务端拦截请求,就无法登录。定制白名单,让登录无需进行token验证。


三、服务端代码


1. 搭建koa2服务器


全局安装koa脚手架


npm install koa-generator -g

创建服务端 直接koa2+项目名


koa2 server

cd server 进入到项目安装jwt


npm i jsonwebtoken

为了方便直接在服务端使用koa-cors 跨域


npm i koa-cors

在app.js中引入应用cors


const cors=require('koa-cors')
...
app.use(cors())

2. 双token


新建utils/token.js


const jwt=require('jsonwebtoken')

const secret='2023F_Ycb/wp_sd' // 密钥
/*
expiresIn:5 过期时间,时间单位是秒
也可以这么写 expiresIn:1d 代表一天
1h 代表一小时
*/

// 本次是为了测试,所以设置时间 短token5秒 长token15秒
const accessTokenTime=5
const refreshTokenTime=15

// 生成accessToken
const accessToken=(payload={})=>{ // payload 携带用户信息
return jwt.sign(payload,secret,{expireIn:accessTokenTime})
}
//生成refreshToken
const refreshToken=(payload={})=>{
return jwt.sign(payload,secret,{expireIn:refreshTokenTime})
}

module.exports={
secret,
setAccessToken,
setRefreshToken
}

3. 路由


直接使用脚手架创建的项目已经在app.js使用了路由中间件
在router/index.js 创建接口


const router = require('koa-router')()
const jwt = require('jsonwebtoken')
const { accesstoken, refreshtoken, secret }=require('../utils/token')

/*登录接口*/
router.get('/login',()=>{
let code,msg,data=null
code=2000
msg='登录成功,获取到token'
data={
accessToken:accessToken(),
refreshToken:refreshToken()
}
ctx.body={
code,
msg,
data
}
})

/*用于测试的获取数据接口*/
router.get('/getTestData',(ctx)=>{
let code,msg,data=null
code=2000
msg='获取数据成功'
ctx.body={
code,
msg,
data
}
})

/*验证长token是否有效,刷新短token
这里要注意,在刷新短token的时候回也返回新的长token,延续长token,
这样活跃用户在持续操作过程中不会被迫退出登录。长时间无操作的非活
跃用户长token过期重新登录
*/

router.get('/refresh',(ctx)=>{
let code,msg,data=null
//获取请求头中携带的长token
let r_tk=ctx.request.headers['pass']
//解析token 参数 token 密钥 回调函数返回信息
jwt.verify(r_tk,secret,(error)=>{
if(error){
code=4006,
msg='长token无效,请重新登录'
} else{
code=2000,
msg='长token有效,返回新的token'
data={
accessToken:accessToken(),
refreshToken:refreshToken()
}
}
})
})


4. 应用中间件


utils/auth.js


const { secret } = require('./token')
const jwt = require('jsonwebtoken')

/*白名单,登录、刷新短token不受限制,也就不用token验证*/
const whiteList=['/login','/refresh']
const isWhiteList=(url,whiteList)=>{
return whiteList.find(item => item === url) ? true : false
}

/*中间件
验证短token是否有效
*/

const cuth = async (ctx,next)=>{
let code, msg, data = null
let url = ctx.path
if(isWhiteList(url,whiteList)){
// 执行下一步
return await next()
} else {
// 获取请求头携带的短token
const a_tk=ctx.request.headers['authorization']
if(!a_tk){
code=4003
msg='accessToken无效,无权限'
ctx.body={
code,
msg,
data
}
} else{
// 解析token
await jwt.verify(a_tk,secret.(error)=>{
if(error)=>{
code=4003
msg='accessToken无效,无权限'
ctx.body={
code,
msg,
datta
}
} else {
// token有效
return await next()
}
})
}
}
}
module.exports=auth

在app.js中引入应用中间件


const auth=requier(./utils/auth)
···
app.use(auth)

其实如果只是做一个简单的双token验证,很多中间件是没必要的,比如解析静态资源。不过为了节省时间,方便就直接使用了koa2脚手架。


最终目录结构:


双token服务端目录结构.png


四、前端代码


1. Vue3+Vite框架


前端使用了Vue3+Vite的框架,看个人使用习惯。


npm init vite@latest client_side

安装axios


npm i axios

2. 定义使用到的常量


config/constants.js


export const ACCESS_TOKEN = 'a_tk' // 短token字段
export const REFRESH_TOKEN = 'r_tk' // 短token字段
export const AUTH = 'Authorization' // header头部 携带短token
export const PASS = 'pass' // header头部 携带长token

3. 存储、调用过期请求


关键点:把携带过期token的请求,利用Promise存在数组中,保持pending状态,也就是不调用resolve()。当获取到新的token,再重新请求。
utils/refresh.js


export {REFRESH_TOKEN,PASS} from '../config/constants.js'
import { getRefreshToken, removeRefreshToken, setAccessToken, setRefreshToken} from '../config/storage'

let subsequent=[]
let flag=false // 设置开关,保证一次只能请求一次短token,防止客户多此操作,多次请求

/*把过期请求添加在数组中*/
export const addRequest = (request) => {
subscribes.push(request)
}

/*调用过期请求*/
export const retryRequest = () => {
console.log('重新请求上次中断的数据');
subscribes.forEach(request => request())
subscribes = []
}

/*短token过期,携带token去重新请求token*/
export const refreshToken=()=>{
if(!flag){
flag = true;
let r_tk = getRefershToken() // 获取长token
if(r_tk){
server.get('/refresh',Object.assign({},{
headers:{[PASS]=r_tk}
})).then((res)=>{
//长token失效,退出登录
if(res.code===4006){
flag = false
removeRefershToken(REFRESH_TOKEN)
} else if(res.code===2000){
// 存储新的token
setAccessToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
flag = false
// 重新请求数据
retryRequest()
}
})
}
}
}

4. 封装axios


utlis/server.js


import axios from "axios";
import * as storage from "../config/storage"
import * as constants from '../config/constants'
import { addRequest, refreshToken } from "./refresh";

const server = axios.create({
baseURL: 'http://localhost:3004', // 你的服务器
timeout: 1000 * 10,
headers: {
"Content-type": "application/json"
}
})

/*请求拦截器*/
server.interceptors.request.use(config => {
// 获取短token,携带到请求头,服务端校验
let aToken = storage.getAccessToken(constants.ACCESS_TOKEN)
config.headers[constants.AUTH] = aToken
return config
})

/*响应拦截器*/
server.interceptors.response.use(
async response => {
// 获取到配置和后端响应的数据
let { config, data } = response
console.log('响应提示信息:', data.msg);
return new Promise((resolve, reject) => {
// 短token失效
if (data.code === 4003) {
// 移除失效的短token
storage.removeAccessToken(constants.ACCESS_TOKEN)
// 把过期请求存储起来,用于请求到新的短token,再次请求,达到无感刷新
addRequest(() => resolve(server(config)))
// 携带长token去请求新的token
refreshToken()
} else {
// 有效返回相应的数据
resolve(data)
}

})

},
error => {
return Promise.reject(error)
}
)

5. 复用封装


import * as constants from "./constants"

// 存储短token
export const setAccessToken = (token) => localStorage.setItem(constanst.ACCESS_TOKEN, token)
// 存储长token
export const setRefershToken = (token) => localStorage.setItem(constants.REFRESH_TOKEN, token)
// 获取短token
export const getAccessToken = () => localStorage.getItem(constants.ACCESS_TOKEN)
// 获取长token
export const getRefershToken = () => localStorage.getItem(constants.REFRESH_TOKEN)
// 删除短token
export const removeAccessToken = () => localStorage.removeItem(constants.ACCESS_TOKEN)
// 删除长token
export const removeRefershToken = () => localStorage.removeItem(constants.REFRESH_TOKEN)

6. 接口封装


apis/index.js


import server from "../utils/server";
/*登录*/
export const login = () => {
return server({
url: '/login',
method: 'get'
})
}
/*请求数据*/
export const getData = () => {
return server({
url: '/getList',
method: 'get'
})
}

项目运行


双token前端.png


最后的最后,运行项目,查看效果
后端设置的短token5秒,长token10秒。登录请求到token后,请求数据可以正常请求,五秒后再次请求,短token失效,这时长token有效,请求到新的token,refresh接口只调用了一次。长token也过期后,就需要重新登录啦。
效果.png


写在最后


这就是一整套的前后端使用双token机制实现无感刷新。token能做到的还有很多,比如权限管理、同一账号异地登录。本文只是浅显的应用了一下。


下次空余时间写写大文件切片上传,写文不易,大家多多点赞。感谢各位看官老爷。


作者:夜琛白
来源:juejin.cn/post/7224764099187736634
收起阅读 »

让我看看你们公司的代码规范都是啥样的?

web
我这里提供一份我自己在使用的项目代码规范,当然我这里比较简陋,有补充的可以打在评论区,我丰富到文章里去。 1.组件命名规范 components下的组件命名规范遵循大驼峰命名规范。 示例:conpnents/AlbumItemCard/AlbumItemCar...
继续阅读 »

我这里提供一份我自己在使用的项目代码规范,当然我这里比较简陋,有补充的可以打在评论区,我丰富到文章里去。


1.组件命名规范


components下的组件命名规范遵循大驼峰命名规范。


示例:conpnents/AlbumItemCard/AlbumItemCard.vue



小驼峰式命名法(lower camel case): 第一个单词以小写字母开始;第二个单词的首字母大写,例如:myName




大驼峰式命名法(upper camel case): 每一个单字的首字母都采用大写字母,例如:MyName



2.目录命名规范


pages下的文件命名规范:遵循小驼峰命名规范。


示例:pages/createAlbum/createAlbum.vue


3.CSS命名规范


class命名规范为中划线。


示例:


<template>
<view class="gui-padding">
...
</view>
</template>
<style lang="scss" scoped>
.gui-padding {
...
}
</style>

css使用scss进行书写。


4.代码注释规范


行内注释://


函数注释:


/**
* @description: 加深颜色值
* @param {string} color 颜色值字符串
* @returns {*} 返回处理后的颜色值
*/

export function getDarkColor(color: string, level: number) {
const reg = /^#?[0-9A-Fa-f]{6}$/
if (!reg.test(color)) return ElMessage.warning('输入错误的hex颜色值')
const rgb = hexToRgb(color)
for (let i = 0; i < 3; i++)
rgb[i] = Math.round(20.5 * level + rgb[i] * (1 - level))
return rgbToHex(rgb[0], rgb[1], rgb[2])
}

接口注释:


/**
* @description 获取后台用户分页列表(带搜索)
* @param page
* @param limit
* @param username
* @returns {<PageRes<AclUser.ResAclUserList>>}
* @docs https://xxxx
*/

export function getAclUserList(params: AclUser.ReqAclUserListParams) {
return http.get<PageRes<AclUser.ResAclUserList>>(
`/admin/acl/user/${params.pageNum}/${params.pageSize}`,
{ username: params.username },
)
}

5.接口书写规范


4.1 接口定义规范:


接口全部写在api目录下面,按照功能划分,分为不同的目录。


比如搜索接口,定义在api/search/index.ts下面。


4..2 接口书写规范:


统一使用类方法,内部方法定义每个接口,最后统一export,接口使用到的类型全部定义在同级目录的interfaces.ts文件中。比如搜索相关的接口:


import Service from '../../utils/request'
import { SearchItemInterface, SearchPageResponseInterface, SearchParamsInterface } from "./interfaces"

class CateGory extends Service {

/**
* @description 搜索功能
* @param {SearchParamsInterface} params 二级分类Id
*/

// 搜索
getSearchAlbumInfo(params: SearchParamsInterface) {
return this.post<SearchPageResponseInterface<SearchItemInterface[]>>({
url: '/api/search/albumInfo',
data: params
})
}
/**
* @description: 获取搜索建议
* @param {string} keyword 搜索关键字
* @return {*}
*/

getSearchSuggestions(keyword: string) {
return this.get<string[]>({
url: `/api/search/albumInfo/completeSuggest/${keyword}`,
loading:false
})
}

}

export const search = new CateGory()

4.3 接口类型定义:


// 搜索参数
export interface SearchParamsInterface {
keyword: string;
category1Id?: number | null;
category2Id?: number | null;
category3Id?: number | null;
attributeList?: string[] | null;
order?: string | null;
pageNo?: number;
pageSize?: number;
}
// 搜索结果item向接口
export interface SearchItemInterface {
id: number;
albumTitle: string;
albumIntro: string;
announcerName: string;
coverUrl: string;
includeTrackCount: number;
isFinished: string;
payType: string
createTime: string;
playStatNum: number;
collectStatNum: number;
buyStatNum: number;
albumCommentStatNum: number;
}

4.4 接口引用


所有export的类接口方法都在api/index.ts中统一引入:


export { courseService } from './category/category'
export { albumsService } from './albums/albums'
export { search } from './search/search'

在页面中使用:


<script>
import { courseService } from "../../api"
/**
* @description: 获取所有分类
* @returns {*}
*/

const getCategoryList = async () => {
try {
const res = await courseService.findAllCategory()
} catch (error) {
console.log(error)
}
}
</script>

6.分支命名规范


分支管理命名规范解释
master 主分支master稳定版本分支,上线完成回归后后,由项目技术负责人从 release 分支合并进来,并打 tag
test 测试分支test/版本号示例:test/1.0.0测试人员使用分支,测试时从 feature 分支合并进来,支持多人合并
feature 功能开发分支feature/功能名称示例:feature/blog新功能开发使用分支,基于master建立
bugfix修复分支bugfix/功能名称示例:fix/blog紧急线上bug修复使用分支,基于master建立
release 上线分支release/版本号示例:release/0.1.0用于上线的分支,基于 master 建立,必须对要并入的 分支代码进行 Code review 后,才可并入上线

7.代码提交规范


作者:白哥学前端
来源:juejin.cn/post/7224408845685522492
tbody>
前缀解释示例
feat新功能feat: 添加新功能
fix修复fix: 修改bug
docs文档变更docs: 更新文档
style代码样式变更style: 修改样式
refactor重构refactor: 重构代码
perf性能优化perf: 优化了性能
test增加测试test: 单元测试
revert回退revert: 回退代码
build打包build: 打包代码
chore构建过程或辅助工具的变动chore: 修改构建
收起阅读 »

感情、事业双丰收的2022年

大家好,我是雄雄。 前言 今天是2022年12月31日,明天就是2023年了,时光如白驹过隙,在这对马上要过去的一年做个总结~ 有网友调侃整个2022年的总结: 1月-11月:做核酸 12月:阳了 其实确实也是如此,印象中,今年的疫情比2020年的疫情...
继续阅读 »


大家好,我是雄雄。



在这里插入图片描述


前言


今天是2022年12月31日,明天就是2023年了,时光如白驹过隙,在这对马上要过去的一年做个总结~


有网友调侃整个2022年的总结:



  • 1月-11月:做核酸

  • 12月:阳了


其实确实也是如此,印象中,今年的疫情比2020年的疫情还要严重,一年下来,唯一刻在闹钟挥之不去的就是做核酸和封控,光是封控,就封了两周,最后接憧而至的阳人遍地,继续居家,前前后后算下来,一个月的居家时间肯定是绝多不少!今天借此机会,做个简单总结吧。




事业上


依稀还记得三月份的第三个周,离开学校,步入公司搞研发,当时接受的项目是一个别人开发完一版的一个半成品,因为需求的重大变更,所以需要重新开发;于是我和几个人人就开始着手写了,由于对业务没有特别熟悉,且需求也在反反复复的变,所以也是费了不少精力去整;


项目周期短,任务重,于是团队里面的成员就没日没夜的去干,那时候基本上没有下班,没有周末,只有不断修改的Bug和不断完善的功能,这个项目一直持续到了5月份才算是基本完成。


期间还做了个项目,这个项目别的没有给我留下什么映像,反而映像最深的是,近几年来唯一一次干的通宵,就是干在了这个项目上;从第一天的7点多去了公司,开始忙活,因为第二天要上线,所以我们两一直干一直处理问题,一直干到了第二天早上8点多,也没觉得有多少困意。忙完后,赶紧各自回家补觉,心中有事睡不安稳,没睡两小时,又回公司干活去了......


在这里插入图片描述


7月之际,由于其他原因,从原来干活地儿离开,开始步入医疗行业;刚开始接触医疗行业,所以没有直接上手动项目,而是先熟悉了一周的国家政策,红头文件,法律法规。之后开始接受第一个项目。


在这一个项目上,我和团队学到了许许多多的新技能点,以前我们以为微服务高深莫测,在这个项目,我们直接上了微服务;以前我们觉得小程序怎样怎样,现如今,这款小程序已经上线运营;以前我们觉得软硬件结合多么多么麻烦,现在感觉也就那样。技术在不断的变化,而我们,也在不断的学习应对,使得各自能力不断提升!


团队比较给力,只要需求明确,一个项目一两个月基本就完事儿。在技术不断提升的过程中,我们的业务能力也在提升,和三方对接、和其他企业老总沟通商务等,啥都干,只要对自己有帮助的,啥都学!


感情上


今年万幸的是,遇到了我的另一半~


我们从今年3月份开始,到现在已经经历了278天的日子,还是和当初那样的互相欣赏彼此~


当时正好有事儿需要回老家,我们两就一起回去了,我爸妈很喜欢,喜笑颜开。我们回去一起做了好多好吃的~


在这里插入图片描述


后来,我也去了她们家一趟,叔叔阿姨对我也很好,每周都让我去她们家吃饭;给我做了好多好多好吃的,姥娘还给我缝了一件特别特别暖和的棉袄,纯手工打造,穿着很舒服~


自从见过了叔叔阿姨后,感觉他们就把我当成他们的孩子一样,无微不至的关爱,使得我在济南这个无情无故的地方体会到了些许的温暖~


在这里插入图片描述


生活上


今年生活上的空余时间少之甚少,基本都在忙着搞项目和处理项目延伸出来别的事宜,导致之前有些习惯都没有坚持下来。


锻炼吧,偶尔~
练字吧,从不~
看书吧,经常~


可能也就是看书坚持下来了,但也不是坐下来看,而是路上骑车的时间得有半个小时到40分钟,我嫌这个时间空着浪费,于是就每天上下班听书。


翻了下微信读书的年度总结,得出来了如下数据:


看书时长:573小时。
读完图书:28本。
读过的书:41本。
写了笔记:906条。




除了读书,平时还写写博客啥的,而写博客也让我拿到了很多奖励和荣誉,光CSDN给的书包就有三个,什么马克杯、衣服、鼠标垫、奖牌以及飞盘啥的,更是多如牛毛!除了CSDN给的东西外,还有阿里云给的奖牌、奖品,键盘。还有华为云的鼠标垫,平板……还有微软的奖品,掘金的奖品.......
在这里插入图片描述


临近年底,还阳了....不过好在症状不重,喉咙不舒服了几天就痊愈了,基本上不耽误啥事儿......


在这里插入图片描述


作者:穆雄雄
来源:juejin.cn/post/7183965673494675513
收起阅读 »

《左手上篮》之弹幕含‘’坤‘’量分析?!

对不起 别骂了别骂了我有错,但是我不认。哈哈哈 本来就是想爬一下最近比较火的国产动漫《左手上篮》,我是一个篮球爱好者 ,也是一个篮球迷,有这种篮球的国漫怎么会放过呢,所以我也想搞点事情分析分析弹幕,其实我有想过一个比较好的题目《左手上篮》--我们的灌篮高手,其...
继续阅读 »

对不起


别骂了别骂了我有错,但是我不认。哈哈哈


本来就是想爬一下最近比较火的国产动漫《左手上篮》,我是一个篮球爱好者 ,也是一个篮球迷,有这种篮球的国漫怎么会放过呢,所以我也想搞点事情分析分析弹幕,其实我有想过一个比较好的题目《左手上篮》--我们的灌篮高手,其实没开始爬之前我一直是这么想的,但是当我真正去爬的时候发现一个这样的弹幕‘‘123,背带裤’’事情就开始变得不一样了,我想正常的一板一眼的做弹幕数据分析是不是太无聊了,所以我决定做弹幕的含坤量分析,这就是我标题的来源。


爬取弹幕


image.png


上面这个就是我的爬虫代码了,其实非常简单,就是一个请求头,一个request函数,然后在爬的视频网站找到你要的json包,给他请求解析下来,然后写到我们的CSV文件中。下面就是我爬到的数据大概有两万条弹幕


image.png


数据处理


接着就是对爬到的数据进行中文分词,把弹幕用jieba分好词,大概有80万条小数据,我做的第一个处理是把他做一个词云图,通过对停用词的不同限定,做了几个版本的词云图,为什么做了几个版本呢,其实是被迫的,本来我早就开始这个项目了,就是在这里被卡了很久,不然早就做完了,主要就是stylecloud这个库不太熟悉,所以一直画不出来,第一个词云图其实是我用fineBI做的,直到今天有空了,所以好好研究了下,终于不报错了谢天谢地。其实画的还是很粗糙,大家将就看吧。


屏幕截图 2023-03-16 140620.png


image.png


image.png


含坤量分析


接着有趣的来了,我们来看一下我们鸡哥在这些弹幕里的含量,首先我们在弹幕中把含坤的弹幕统计出来,words = ["坤", "背带裤", "小黑子", "ikun", "蔡徐坤", "只因", "鸡", "鸡你太美"],这些都是我们的含坤的弹幕类型,我们对弹幕进行筛选,有这些词的我们就把他放到一起去。


image.png


大概有多少呢?如图所示一共236条。接着我们对他进行数据的可视化,我分别做了柱状图和一个饼状图。


image.png


image.png


OK,最后一步含坤量的计算最后的结果是:


0.02906%


怎么算的呢?其实很简单就是用我们筛选出来的词比上我们全部的词。


image.png


结尾


以上内容,如有雷同纯属巧合,如有冒犯就是你对。


作者:HeteroCat
来源:juejin.cn/post/7221521544496873528
收起阅读 »

接受平庸,三年前端2022年总结

2022年即将结束,这一年尤为特殊,对每个人来说都是一次新的开始。 我以前很爱给自己定很多目标,似乎定得越多越能缓解我的焦虑,让我能有种不再碌碌无为,虚度光阴的错觉。尤其是在过了某个年龄段后,开始觉得时间流逝得近乎疯狂,感觉才刚习惯填写日期时填上2022,突然...
继续阅读 »

2022年即将结束,这一年尤为特殊,对每个人来说都是一次新的开始。


我以前很爱给自己定很多目标,似乎定得越多越能缓解我的焦虑,让我能有种不再碌碌无为,虚度光阴的错觉。尤其是在过了某个年龄段后,开始觉得时间流逝得近乎疯狂,感觉才刚习惯填写日期时填上2022,突然间就这么结束了。自己这一年好像在原地踏步,并没有做出太多改变。


我之前很长一段时间都不开心,一直在尝试着自救,以前觉得自救是拼命努力,踏出自己的舒适圈,一旦过得舒服点就会开始焦虑和自责。我不停地在学习新东西,这样似乎能缓解我的焦虑,确实学习能让我变得更好,但是这种心态会让生活质量变得很低。


现在慢慢开始觉得自救是接受自己的平庸,不要给自己定太高的目标。多去尝试一些一直想做但没有去做的事情,比如我一直想去学冲浪,去跳下伞。后悔的事情往往不是那些已经做过的,而是想做但没有去做的事。


1.工作


我做的最好的一次选择就是进入这个行业,这是我真正热爱的事情,其实实际算起来我从事前端开发才三年,但我每天上班不会再觉得煎熬,甚至有时还会有点期待。


这三年里心态也经历了几次变化,一开始觉得只有技术好才能到达一个更高的位置,后来一段时间找工作发现,包装也很重要,把简历改好点,多刷点题,进入好公司起点就和别人不一样了。


后来开始带了四五个组员,汇报变成我的工作之一,这时觉得,汇报好像更加重要。其他人幸苦完成的功能,变成了我PPT里的一个亮点,领导和其他人的赞赏,好像全变成了我的功劳,这时我总会惶恐地加一句也是大家的努力。


其实汇报也并不简单,它需要你更了解项目,掌握所有细节,当一个问题答不上时,其他人就会紧攥着不放,以此来否定你的工作。而且听汇报的往往都是不懂技术的人,需要把一个技术难点用通俗易懂的话讲清楚,这更加考验功力。


这段时期我没别的进步,表达能力上倒是突飞猛进,而且不会怯场。


现在我不会再排哪个最重要,每个时期都会有不同的侧重点,更应该要看的是自己的职业规划,想当架构师,那就更加注重技术。想转管理,就多提高下表达能力。


2.学习


第一次是刚开始工作的时候,那时候对一切都好奇,每天都在看各类教程,不停地在折腾东西,觉得要学习的东西实在太多了。但这段时间仅仅在学习怎么用,浅尝辄止,对很多框架的理解仅仅停留在表面,虽然曾经试图去读下源码,但很快就因为看不懂退缩了。


或许这也是当时我学习热情高涨的原因,我学了Vue、Spring Boot,自己摸索着把博客的前后端搭建起来,又去买了台服务器,配置好nginx,挑了个心仪的域名,去备案。一切都搭建好后,在浏览器输入自己的域名,看到博客打开的那一刻,那种感觉我到现在还记得。


在工作一段时间后我用了很多框架,写了足够多的代码,我渐渐碰到很多奇怪的问题,虽然在一番搜索后我解决了,但没有明白为什么,这时候我知道自己需要更加深入了。


我开始尝试去读源码,去学习优化,去动手实现一些封装好的功能。这时候,我终于从会用到大概知道为什么。


学习go是今年的事情,契机是我用Spring Boot搭建的博客服务挂了,再加上小程序的云开发开始收费,我迫切需要搭建起两套web服务,这时候就选中了正当红的编程语言Go。学语言其实还是挺快的,js、java、go,都有很多相似的地方。


有了以前的经验,用Gin搭建的web服务很快就起来了,我的博客和小程序得以满血复活。


截屏2023-01-05 21.17.31.png


开发这个小程序的初衷是想学习下云开发,当时自己正好很焦虑,迫切想改变,就决定开发一款能监督自己培养习惯的工具,但现在并不这么想了,现在更多是希望它能记录下这个过程,即使失败也没关系。


WechatIMG51.jpeg


3.健身


如果说这几年感触最深的事是什么,那一定是健康,人有时真的很脆弱,别奋斗了大半生最后化为乌有。


我开始重新去健身房,这次很顺利,坚持了很长时间,直到中间封城和后面放开才没再去,但在家的时候也会用哑铃继续练。


目前深蹲80kg,卧推50kg,其实可以更进一步,但我经常是一个人练,老害怕受伤,尤其是腰,如果受伤了还不如不练。所以暂时停在这两个重量,更多的是练习动作和感受发力,直到感觉对了再往上冲一冲。


从开始去健身房到现在,身体的变化也挺明显的,甚至因为背挺直了一些让我长高了一厘米,精神也更好了,不再像以前一样混混沌沌,也开始喜欢上运动。


23年我仍然会把健康放在第一位,除了去健身房外还会拓展更多运动项目。


4.英语


今年看了两本原版书,比预计的数量差好多。分别是 Wander 和 The twilight saga,非常推荐给词汇量不多的人,语法简单,故事精彩,很容易就能读下去。


WechatIMG57.jpeg


从以前英语就一直不是很好,已经成为我的一块心病。总结了以前学英语失败的经历,发现就是太追求速成,越追求速度反而越容易有挫败感,在认识到这应该是一个长期的过程后,就开始放慢脚步,从简单的读书和对话开始,避免背太多单词。


5.最后


接受了平庸并不是躺平,而是不再给自己设一些虚无缥缈的目标,2023年我还是会继续学习更多知识,解锁新的技能。


作者:Nothlu
来源:juejin.cn/post/7185347261683990565
收起阅读 »

工作 7 年的老程序员,现在怎么样了

犹记得高中班主任说:“大家努力考上大学,大学没人管,到时候随便玩”。我估计很多老师都这么说过。 我考上大学(2010年)之前也是这么过的。第一年哥哥给买了个一台华硕笔记本电脑。那个年代买华硕的应该不少,我周边就好几个。有了电脑之后,室友就拉着我一起 cs,四个...
继续阅读 »

犹记得高中班主任说:“大家努力考上大学,大学没人管,到时候随便玩”。我估计很多老师都这么说过。


我考上大学(2010年)之前也是这么过的。第一年哥哥给买了个一台华硕笔记本电脑。那个年代买华硕的应该不少,我周边就好几个。有了电脑之后,室友就拉着我一起 cs,四个人组队玩,那会觉得很嗨,上头。


后来看室友在玩魔兽世界,那会不知道是什么游戏,就感觉很好玩,再后来就入坑了。还记得刚开始玩,完全不会,玩个防骑,但是打副本排DPS,结果还被人教育,教育之后还不听(因为别的职业不会玩),就经常被 T 出组。之后,上课天天看游戏攻略和玩法,或者干脆看小说。前两年就这么过去了


1 跟风考研


大三开始,觉得这么混下去不行了。在豆瓣上找了一些书,平时不上课的时候找个自习室学习。那会家里打电话说有哪个亲戚家的孩子考研了,那是我第一次知道“考研”这个词。那会在上宏微观经济学的课,刚好在豆瓣上看到一本手《牛奶面包经济学》,就在自习室里看。刚好有个同院系的同学在里面准备考研,在找小伙伴一起战斗(毕竟,考研是一场长跑,没有同行者,会很艰难)。我一合计,就加入了他们的小团队。从此成为“中国合伙人”(刚好四个人)中的一员。


我那会也不知道毕业了之后能去哪些公司,能找哪些岗位,对于社会完全不了解,对于考研也是完全不了解。小团队中的三个人都是考金融学,我在网上查,知道了学硕和专硕的区别,也知道专硕学费贵。我家里没钱,大学时期的生活费都是自己去沃尔玛、麦当劳、发传单挣得,大学四年,我在沃尔玛工作超过了 2 年、麦当劳半年,食堂倒盘子半年,中途还去发过传单,暑假还去实习。没钱,他们考金融学专硕,那我就靠经济学学硕吧,学硕学费便宜。


从此开始了考研之路。


2 三次考研


大三的时候,报名不是那么严格,混进去报了名,那会还没开始看书,算是体验了一把考研流程;


还记得那次政治考了 48 分,基本都过了很多学校的单科线,那会就感觉政治最好考(最后发现,还是太年轻)。


大四毕业那年,把所有考研科目的参数书都过了 2 遍,最后上考场,最后成绩也就刚过国家线。


毕业了,也不知道干啥,就听小伙伴的准备再考一次,之前和小伙伴一起来了北京,租了个阳台,又开始准备考研。结果依然是刚过国家线。这一年也多亏了一起来北京的几个同学资助我,否则可能都抗不过考试就饿死街头了。


总结这几次考研经历,失败的最大原因是,我根本不知道考研是为了什么。只是不知道如果工作的话,找什么工作。刚好别人提供了这样一个逃避工作的路,我麻木的跟着走而已。这也是为什么后面两次准备的过程中,一有空就看小说的原因。


但是,现在来看,我会感谢那会没有考上,不然就错过了现在喜欢的技术工作。因为如果真的考上了经济学研究生,我毕业之后也不知道能干啥,而且金融行业的工作我也不喜欢,性格上也不合适,几个小伙伴都是考的金融,去的券商,还是比较了解的。


3 入坑 JAVA 培训


考完之后,大概估了分,知道自己大概率上不了就开始找工作了。那会在前程无忧上各种投简历。开始看到一个做外汇的小公司,因为我在本科在一个工作室做过外汇交易相关的工作,还用程序写了一段量化交易的小程序。


所以去培训了几天,跟我哥借了几千块钱,注册了一个账号,开始买卖外汇。同时在网上找其他工作。


后面看介绍去西二旗的一家公司面试,说我的技术不行,他们提供 Java 培训(以前的套路),没钱可以贷款。


我自己也清楚本科一行 Java 代码没写过,直接工作也找不到工作。就贷款培训了,那会还提供住宿,跟学校宿舍似的,上下铺。


4 三年新手&非全研究生


培训四个月之后,开始找工作。那会 Java 还没这么卷,而且自己还有个 211 学历,一般公司的面试还是不少的。但是因为培训的时候学习不够刻苦(也是没有基础)。最后进了一个小公司,面试要 8000,最后给了 7000。这也是我给自己的最底线工资,少于这个工资就离开北京了,这一年是 2015 年。


这家公司是给政府单位做内部系统的,包括中石油、气象局等。我被分配其中一个组做气象相关系统。第二年末的时候,组内的活对我来说已经没什么难度了,就偷偷在外面找工作,H3C 面试前 3 面都通过了,结果最后大领导面气场不符,没通过。最后被另外一家公司的面试官劝退了。然后公司团建的时候,大领导也极力挽留我,最后没走成。


这次经历的经验教训有 2 个,第 1 个是没有拿到 offer 之前,尽量不要被领导知道。第 2 个是,只要领导知道你要离职,就一定要离职。这次就是年终团建的时候,被领导留下来了。但是第二年以各种理由不给工资。


之前自己就一直在想出路,但是小公司,技术成长有限,看书也对工作没有太大作用,没有太大成长。之后了解到研究生改革,有高中同学考了人大非全。自己也就开始准备非全的考试。最后拿到录取通知书,就开始准备离职了。PS:考研准备


在这家公司马上满 3 年重新签合同的时候,偷偷面试了几家,拿到了 2 个还不错的 offer。第二天就跟直属领导提离职了。这次不管直属领导以及大领导如何劝说,还是果断离职了。


这个公司有两个收获。一个是,了解了一般 Java Web 项目的全流程,掌握了基本开发技能,了解了一些大数据开发技术,如Hadoop套件。另外一个是,通过准备考研的过程,也整理出了一套开发过程中应该先思而后行。只有先整理出


5 五年开发经历


第二家公司是一家央企控股上市公司,市场规模中等。主要给政府提供集成项目。到这家公司第二年就开始带小团队做项目,但是工资很低,可能跟公司性质有关。还好公司有宿舍,有食堂。能省下一些钱。


到这家公司的时候,非全刚好开始上课,还好我们 5 点半就下班,所以我天天卡点下班,大领导天天给开发经理说让我加班。但是第一学期要上课,领导对我不爽,也只能这样了。


后来公司来了一个奇葩的产品经理,但是大领导很挺他,大领导下面有 60 号人,研发、产品、测试都有。需求天天改,还不写在文档上。研发都开发完了,后面发现有问题,要改回去,产品还问,谁让这么改的。


是否按照文档开发,也是大领导说的算,最后你按照文档开发也不对,因为他们更新不及时;不按照文档开发也不对,写了你不用。


最后,研发和产品出差,只能同时去一波人,要是同时去用户现场,会打架。最后没干出成绩,产品和大领导一起被干走了。


后面我们整体调整了部门,部门领导是研发出身。干了几个月之后,领导也比较认可我的能力,让我带团队做一个中型项目,下面大概有 10 号人,包括前后端和算法。也被提升为开发经理。


最后因为工资、工作距离(老婆怀孕,离家太远)以及工作内容等原因,跳槽到了下一家互联网公司。


6 入行互联网


凭借着 5 年的工作经历,还算可以的技术广度(毕竟之前啥都干),985 学校的非全研究生学历,以及还过得去的技术能力。找到了一家知名度还可以的互联网公司做商城开发。


这个部门是公司新成立的部门,领导是有好几家一线互联网经验的老程序员,技术过硬,管理能力强,会做人。组内成员都年轻有干劲。本打算在公司大干一场,涨涨技术深度(之前都是传统企业,技术深度不够,但是广度可以)。


结果因为政策调整,整个部门被裁,只剩下直属领导以及领导的领导。这一年是 2020 年。这个时候,我在这个公司还不到 1 年。


7 再前行


拿着上家公司的大礼包,马上开始改简历,投简历,面试,毕竟还有房贷要还(找了个好老婆,她们家出了大头,付了首付),马上还有娃要养,一天也不敢歇息。


经过一个半月的面试,虽然挂的多,通过的少。最终还是拿了 3 个不错的offer,一个滴滴(滴滴面经)、一个XXX网络(最终入职,薪资跟滴滴基本差不多,技术在市场上认可度也还不错。)以及一个建信金科的offer。


因为大厂部门也和上家公司一样,是新组建的部门,心有余悸。然后也还年轻,不想去银行躺平,也怕银行也不靠谱,毕竟现在都是银行科技公司,干几年被裁,更没有出路。最终入职XXX网络。


8 寒冬


入职XXX网络之后,开始接入公司的各种技术组件,以及看到比较成熟的需求提出、评估、开发、测试、发布规范。也看到公司各个业务中心、支撑中心的访问量,感叹还是大公司好,流程规范,流量大且有挑战性。


正要开心的进入节奏,还没转正呢(3 个月转正),组内一个刚转正的同事被裁,瞬间慌得一批。


刚半年呢,听说组内又有 4 个裁员指标,已经开始准备简历了。幸运的是,这次逃过一劫。


现在已经 1 年多了,在这样一个裁员消息满天飞的年代,还有一份不错的工作,很幸运、也很忐忑,也在慢慢寻找自己未来的路,共勉~


9 总结


整体来看,我对自己的现状还算满意,从一个高中每个月 300 块钱生活费家里都拿不出来;高考志愿填报,填学校看心情想去哪,填专业看专业名字的的村里娃,走到现在在北京有个不错的工作,组建了幸福的家庭,买了个不大不小的房子的城里娃。不管怎么样,也算给自己立足打下了基础,留在了这个有更多机会的城市;也给后代一个更高的起点。


但是,我也知道,现在的状态并不稳固,互联网工作随时可能会丢,家庭成员的一场大病可能就会导致整个家庭回到解放前。


所以,主业上,我的规划就是,尽力提升自己的技术能力和管理能力,争取能在中型公司当上管理层,延迟自己的下岗年龄;副业上,提升自己的写作能力,尝试各种不同的主题,尝试给各个自媒体投稿,增加副业收入。


希望自己永

作者:六七十三
来源:juejin.cn/post/7173506418506072101
远少年,不要下岗~


收起阅读 »

html手写一个打印机效果-从最基础到学会

web
手写一个打印机效果 啥叫打印机效果,话不多说,直接上效果。我们可以自己写入一段文本然后通过html的方式,让它跟打印机一样,一个一个的打印到页面,并且还可以一个一个的删除。在这里我先浅说一下,我们的实现技巧,定时器setTimeout控制时间,然后for循环遍...
继续阅读 »

手写一个打印机效果


啥叫打印机效果,话不多说,直接上效果。我们可以自己写入一段文本然后通过html的方式,让它跟打印机一样,一个一个的打印到页面,并且还可以一个一个的删除。在这里我先浅说一下,我们的实现技巧,定时器setTimeout控制时间,然后for循环遍历写入到页面上。


封装的打印js
main(str,text)直接传入要写入的数组对象和要写入的元素。
copy.js 下载到本地引入然后调用它就可以了
image.png


代码


先拿到我们要写入的元素,然后设置好我们要写入的内容。


 var text = document.querySelector('.text');
var str = ['你好 ,我是一名刚入坑不久的大三在校生。', '现在学习都是为了将来的工作。', '希望能够得到大家的鼓励,谢谢!']

基础代码一


首先这里,我们先实现一个只有一段文字的实现效果。实现思路就是通过计时器,控制好时间,每次写入的文字通过str[0].substr(0, k)拿到,需要注意的是,因为是异步任务,回退的时候,我们的时间要设置好,加上写入完的时间1000 + 200 * str[0].length)


  写入
for (let j = 0; j < str[0].length; j++) {
// 使用 setTimeout 函数来实现每个字符的延时输出
setTimeout(() => {
text.innerHTML = str[0].substr(0, j) // 显示当前字符串的前 j 个字符
}, 200 * j) // 延迟时间为 200 毫秒乘以 j,即每个字符间隔 200 毫秒
}

// 回退
// 在所有字符输出完成后,等待 1000 毫秒后开始回退
setTimeout(() => {
for (let k = str[0].length, i = 0; k >= 0; k--, i++) {
// 使用 setTimeout 函数来实现每个字符的延时输出
setTimeout(() => {
text.innerHTML = str[0].substr(0, k) // 显示当前字符串的前 k 个字符
}, 200 * i) // 延迟时间为 200 毫秒乘以 i,即每个字符间隔 200 毫秒
}
}, 1000 + 200 * str[0].length) // 等待时间为 1000 毫秒加上所有字符输出的延时时间

基础代码二 错误代码


首先这个代码是错误的
为了能让大家更好的看到错误的效果,于是我把这个代码也上传了。大家可以看到,在这里,页面上的文字总是会莫名奇怪的出现删除,根本不是我们想要的。其实我们也只是对上面一个代码进行了一个for循环遍历,却出现了这样的效果。其实这导致的原因就是setTimeout是异步任务,时间没有控制好。即每个字符串的打印和删除都是异步任务,无法保证它们的执行顺序。因此,可能会出现多个字符串的打印和删除任务交错执行的情况,导致效果不符合预期。



 // 即每个字符串的打印和删除都是异步任务,无法保证它们的执行顺序。因此,可能会出现多个字符串的打印和删除任务交错执行的情况,导致效果不符合预期。
// 整个str 这是一个有问题的代码 因为计算时间太麻烦了 都是异步任务
// for (let s = 0; s < str.length; s++) {
// // 写入
// for (let j = 0; j < str[s].length; j++) {
// setTimeout(() => {
// text.innerHTML = str[s].substr(0, j)
// }, 200 * j)
// }
// // 回退
// setTimeout(() => {
// for (let k = str[s].length, i = 0; k >= 0; k--, i++) {
// setTimeout(() => {
// text.innerHTML = str[s].substr(0, k)
// }, 200 * i)

// }
// }, 1000 + 200 * str[s].length)
// }

基础代码三


为了解决上面的问题,我们使用了函数封装并且使用了回调函数实现我们想要的效果。我们将打印和删除都封装成一个含有回调函数的函数,为什么要含有回调函数呢?这是为了我们下面对一个字符串打印和删除的函数做封装。打我们打印完一个字符串时,我们才会执行删除。所有我们将删除函数放到打印的回调函数中去执行。然后我们将打印整个字符串数组进行封装,因为我们在删除的里面也有一个回调函数,那么我们可以在这个回调函数里去执行打印下一条字符串,这样就防止了控制时间不准确的问题。


 // 打印字符串
function printText(str, callback) {
var i = 0;
var timer = setInterval(function () {
text.innerHTML = str.substr(0, i); // 将字符串的前缀赋值给显示文本的元素
i++;
if (i > str.length) { // 如果已经打印完整个字符串
clearInterval(timer); // 停止定时器
callback && callback(); // 调用回调函数
}
}, 200); // 每 200 毫秒打印一个字符
}

// 删除字符串
function deleteText(str, callback) {
var i = str.length;
var timer = setInterval(function () {
text.innerHTML = str.substr(0, i); // 将字符串的前缀赋值给显示文本的元素
i--;
if (i < 0) { // 如果已经删除到空字符串
clearInterval(timer); // 停止定时器
callback && callback(); // 调用回调函数
}
}, 200); // 每 200 毫秒删除一个字符
}

// 打印和删除字符串
function printAndDeleteText(str, callback) {
printText(str, function () { // 先打印字符串
setTimeout(function () {
deleteText(str, callback); // 等待 1 秒后再删除字符串
}, 1000);
});
}

// 循环遍历字符串数组,依次打印和删除字符串
function printAndDeleteAllText(strArr) {
function printAndDeleteNext(i) {
if (i >= strArr.length) { // 如果已经处理完所有字符串
printAndDeleteNext(0); // 重新从头开始处理
} else {
printAndDeleteText(strArr[i], function () { // 先打印字符串
i++;
printAndDeleteNext(i); // 递归调用自身,处理下一个字符串
});
}
}
printAndDeleteNext(0); // 开始处理第一个字符串
}
// 开始打印和删除字符串数组中的所有字符串
printAndDeleteAllText(str)

最优代码


其实我们做了,这么多,最后就是为了解决异步任务。
所以我这里直接采用Promiseasync await解决上面的问题。我们通过Promise解决实现打印和删除的异步任务。我们通过async await封装整个运行函数,解决了定时器异步问题,不用再计算时间,又难有算不出来。


 // 最终版 封装 解决异步任务
function writeText(t, delay = 200) {
return new Promise((resolve, reject) => {
setTimeout(() => {
text.innerHTML = t; // 显示当前字符串 t
resolve(); // Promise 完成
}, delay) // 延迟 delay 毫秒后执行
})
}

async function main(str) {
while (true) { // 无限循环
for (let j = 0; j < str.length; j++) {
// 写入
for (let i = 0; i <= str[j].length; i++) {
await writeText(str[j].substr(0, i)) // 显示当前字符串的前 i 个字符
}
// 回退
// 回退前先等一秒
await new Promise((resolve, reject) => {
setTimeout(() => {
resolve(); // 等待 1000 毫秒后 Promise 完成
}, 1000) // 等待 1000 毫秒
})
for (let i = str[j].length; i >= 0; i--) {
await writeText(str[j].substr(0, i), 200) // 显示当前字符串的前 i 个字符,间隔 200 毫秒
}
}
}
}
main(str)

源码


<!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>打印机效果</title>
<style>
.container {
display: flex;
/* 使用 flex 布局 */
flex-direction: column;
/* 垂直布局 */
align-items: center;
/* 水平居中 */
justify-content: center;
/* 垂直居中 */
height: 100vh;
/* 高度占满整个视口 */
}

h1 {
font-size: 3rem;
/* 字体大小 */
margin-bottom: 2rem;
/* 底部间距 */
text-align: center;
/* 居中对齐 */
}

.text {
font-size: 2rem;
/* 字体大小 */
font-weight: bold;
/* 字体加粗 */
text-align: center;
/* 居中对齐 */
border-right: 2px solid black;
/* 添加光标效果 */
white-space: nowrap;
/* 不换行 */
overflow: hidden;
/* 隐藏超出部分 */
animation: blink 0.5s step-end infinite;
/* 添加光标闪烁效果 */
height: 3rem;
/* 设置一个固定的高度 */
}


@keyframes blink {

from,
to {
border-color: transparent;
/* 透明边框颜色 */
}

50% {
border-color: black;
/* 黑色边框颜色 */
}
}
</style>
</head>

<body>
<div class="container">
<h1>逐字打印和删除文字效果</h1>
<p class="text"></p>
</div>
</body>
<script>
var text = document.querySelector('.text');
var str = ['你好 ,我是一名刚入坑不久的大三在校生。', '现在学习都是为了将来的工作。', '希望能够得到大家的鼓励,谢谢!']


// 写入
// for (let j = 0; j < str[0].length; j++) {
// // 使用 setTimeout 函数来实现每个字符的延时输出
// setTimeout(() => {
// text.innerHTML = str[0].substr(0, j) // 显示当前字符串的前 j 个字符
// }, 200 * j) // 延迟时间为 200 毫秒乘以 j,即每个字符间隔 200 毫秒
// }

// // 回退
// // 在所有字符输出完成后,等待 1000 毫秒后开始回退
// setTimeout(() => {
// for (let k = str[0].length, i = 0; k >= 0; k--, i++) {
// // 使用 setTimeout 函数来实现每个字符的延时输出
// setTimeout(() => {
// text.innerHTML = str[0].substr(0, k) // 显示当前字符串的前 k 个字符
// }, 200 * i) // 延迟时间为 200 毫秒乘以 i,即每个字符间隔 200 毫秒
// }
// }, 1000 + 200 * str[0].length) // 等待时间为 1000 毫秒加上所有字符输出的延时时间


// 即每个字符串的打印和删除都是异步任务,无法保证它们的执行顺序。因此,可能会出现多个字符串的打印和删除任务交错执行的情况,导致效果不符合预期。
// 整个str 这是一个有问题的代码 因为计算时间太麻烦了 都是异步任务
// for (let s = 0; s < str.length; s++) {
// // 写入
// for (let j = 0; j < str[s].length; j++) {
// setTimeout(() => {
// text.innerHTML = str[s].substr(0, j)
// }, 200 * j)
// }
// // 回退
// setTimeout(() => {
// for (let k = str[s].length, i = 0; k >= 0; k--, i++) {
// setTimeout(() => {
// text.innerHTML = str[s].substr(0, k)
// }, 200 * i)

// }
// }, 1000 + 200 * str[s].length)
// }


// 最终版 封装 解决异步任务
function writeText(t, delay = 200) {
return new Promise((resolve, reject) => {
setTimeout(() => {
text.innerHTML = t; // 显示当前字符串 t
resolve(); // Promise 完成
}, delay) // 延迟 delay 毫秒后执行
})
}

async function main(str) {
while (true) { // 无限循环
for (let j = 0; j < str.length; j++) {
// 写入
for (let i = 0; i <= str[j].length; i++) {
await writeText(str[j].substr(0, i)) // 显示当前字符串的前 i 个字符
}
// 回退
// 回退前先等一秒
await new Promise((resolve, reject) => {
setTimeout(() => {
resolve(); // 等待 1000 毫秒后 Promise 完成
}, 1000) // 等待 1000 毫秒
})
for (let i = str[j].length; i >= 0; i--) {
await writeText(str[j].substr(0, i), 200) // 显示当前字符串的前 i 个字符,间隔 200 毫秒
}
}
}
}
main(str)
</script>

</html>

作者:Mr-Wang-Y-P
来源:juejin.cn/post/7225178555827191868
收起阅读 »

URL刺客现身,竟另有妙用!

web
工作中大家会接触到形形色色的 url,有些完美遵循格式,有些却像刺客一样,冷不丁的给你一刀。 先介绍下我的惨痛经历,给大家避避坑,最后告诉大家一个 url 刺客的妙用。 刺客介绍 1. iOS WKWebview 刺客 此类刺客手段单一,只会影响 iOS WK...
继续阅读 »

工作中大家会接触到形形色色的 url,有些完美遵循格式,有些却像刺客一样,冷不丁的给你一刀。


先介绍下我的惨痛经历,给大家避避坑,最后告诉大家一个 url 刺客的妙用。


刺客介绍


1. iOS WKWebview 刺客


此类刺客手段单一,只会影响 iOS WKWebview



  • 空格


运营人员由于在通讯工具中复制粘贴,导致前面多了一个空格,没有仔细检查,直接录入了后台管理系统。



  • 中文


运营人员为了方便自身统计,直接在url中加入中文,录入了后台管理系统。


现象均为打开一个空白页,常见的处理手段如下:



  • 将参数里的中文URIEncode

  • 去掉首尾空格


const safeUrl = (url: string) => {
const index = url.indexOf('?');

if (index === -1) return url.trim();

// 这行可以用任意解析参数方法替代,仅代表要拿到参数,不考虑兼容性的简单写法
const params = new URLSearchParams(url.substring(index));
const paramStr = Object.keys(params)
.map((key: string) => {
return `${key}=${encodeURIComponent(params[key])}`;
})
.join('&');

const formatUrl = url.substring(0, index + 1) + paramStr;

return formatUrl.trim();
};

可以看到虽然这里提出了一个 safeUrl 方法,但如果业务中大量使用 window.location.href , window.location.replace, 之类的方法进行跳转,替换起来会比较繁琐.


再比如在 Hybrid App 的场景中,虽然都是跳转,打开新的 webview ,还是在本页面跳转会是不同的实现,所以在业务内提取一个公共的跳转方法更有利于健壮性和拓展性。


值得注意的是,如果链接上的中文可能是用于统计的,在上报打点时,应该将其值(前端/服务端处理均可)进行 URIDecode,否则运营人员会在后台看到一串串莫名其妙的 %XX ,会非常崩溃(别问我怎么知道的,可能只是伤害过太多运营)


2. 格式刺客


格式刺客指的是,不管何种原因,不知何种场景,就是不小心配错了,打错了,漏打了等。


比如:https://www.baidu.com 就被打成了 htps://www.baidu.com、www.baidu.com 等。


// 检查URL格式是否正确
function isValidUrl(url: string): boolean {
const urlPattern = new RegExp(
"^(https?:\/\/)?" + // 协议
"(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})" + // 域名
"(:[0-9]{1,5})?" + // 端口号
"(\/.*)?$", // 路径
"i"
);
return urlPattern.test(url);
}

以上是一个很基础的判断,但是实际的应用场景中,有可能会需要填写相对路径,或者自定义的 scheme ,比如 wx:// ,所以检验的宽松度可以自行把握。


在校验到 url 配置可能存在问题时,可以上报到 sentry 或者其他异常监控平台,这样就可以比用户上报客服更早的发现潜在问题,避免长时间的运营事故。


3. 异形刺客


这种刺客在视觉上让人无法察觉,只有在跳转后才会让人疑惑不已。他也是最近被产品同学发现的,以下是当时的现场截图:



一段平平无奇的文本,跟着一段链接,视觉上无任何异常。


经过对跳转后的地址进行分析,发现了前面居然有一个这样的字符%E2%80%8B,好奇的在控制台中进行了尝试。




一个好家伙,这是什么,两个单引号吗?并不是,对比了很常用的 '%2B' ,单引号是自带的,那么我到底看到了什么,魔鬼嘛~


在进行了一番检索后知道了这种字符被称为零宽空格,他还有以下兄弟:



  • \u202b-\u202f

  • \ufeff

  • \u202a-\u202e


具体含义可以看看参考资料,这一类字符完全看不见,但是却对程序的运行产生了恶劣的影响。


可以使用这个语句去掉


str.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");

刺客的妙用


头一天还被刺客气的瑟瑟发抖。第二天居然发现刺客的妙用。


场景:




  • 产品要求在微信环境隐藏标题




我方前端工程师:



  • 大手一挥,发功完毕,准备收工


document.title = '';

测试:




  • 来看看,页面A标题隐藏不了




我方前端工程师:


啊?怎么回事,本地调试还是好的,发上去就不行了,为什么页面A不可以,另外一个页面B只是参数变了变就行。


架构师出手:


页面A包含了开放标签,导致设置空Title失效,空,猛然想起了刺客,快用起来!


function setTitle(title: string) {
if (title) {
document.title = title;
} else {
document.title = decodeURIComponent('%E2%80%8B');
}
}

果然有效,成功解决了一个疑难杂症,猜测是微信里有不允许设置标题为空的机制,会在某些标签存在的时候被触发。(以上场景在 Android 微信 Webview 中可复现)


小结


以上只是工作中碰到 url 异常的部分场景和处理方案,如果小伙伴们也有类似的经历,可以在评论区中分享,帮助大家避坑,感谢朋友们的阅读,笔芯~


参考资料:


零宽字符 - 掘金LvLin


什么零宽度字符,以及零宽度字符在JavaScript中的应用 - 掘金whosmeya


作者:windyrain
来源:juejin.cn/post/7225133152490094651
收起阅读 »

关于 Emoji 你不知道的事

web
2022 年,支付宝上线了生僻字键盘,解决了“身份认证”环节中普通输入法经常打不出生僻字的问题。生僻字键盘是蚂蚁集团生僻字解决方案的一部分,本系列将持续分享其背后的字符编码科普文章,包括不限于:《文字是如何显示在屏幕上的?》、《字符编码工作者都在做什么》,敬请...
继续阅读 »

2022 年,支付宝上线了生僻字键盘,解决了“身份认证”环节中普通输入法经常打不出生僻字的问题。生僻字键盘是蚂蚁集团生僻字解决方案的一部分,本系列将持续分享其背后的字符编码科普文章,包括不限于:《文字是如何显示在屏幕上的?》、《字符编码工作者都在做什么》,敬请期待。


本文作者是蚂蚁集团前端工程师醉杭(👉 点击查看醉杭的成长故事),本篇将介绍 Emoji 的编码逻辑,以及如何在代码中正确处理 Emoji 。蚂蚁集团前端工程师七柚封装了字符处理 js 库,已开源,欢迎使用~ github.com/alipay/char…



结论先行



  • 基本 emoji 和常用 Unicode 字符毫无区别


每个 emoji 用对应一个 Unicode 码位,如:🌔 U+1F314 (对应 JS 中 UTF-16 编码是:"\uD83C\uDF14"),汉字 𠇔 U+201D4,对应 JS 中的 UTF-16 编码是"\uD840\uDDD4"



  • emoji 有特殊的修饰、拼接等规则


在某些 emoji 字符后增加一个肤色修饰符改变 emoji 的肤色、可以将多个 emoji 通过连接符拼接成一个emoji,这些特殊规则使得在代码中判定 emoji 的长度、截取和对 emoji 做其他处理都比较困难。需要澄清的是:用一个 Unicode 字符修饰前一个字符不是 emoji 独有的,其他 Unicode 字符也存在,如:Ü,由大写字母U(U+0055),后面跟一个连音符号(U+0308)组成。



  • 术语


码点/码位:Unicode 编码空间中的一个编码,如,汉字𠇔的码位是 201D4,通常表示为:U+201D4


起源


1982 年,卡内基美隆大学是首次在电子公告里中使:-)表情符号。后续在日本手机短信中盛行,并称为颜文字(日语:かおもじ,英文:emoticon),颜文字仍然是普通的文本字符。
1999 年,栗田穰崇 (Shigetaka Kurita) 发明了 e-moji (え-もじ),并设计了 176 个 emoji 符号,共 6 种颜色,分辨率为 12x12。
image.png
纽约博物馆馆藏:最初的 176 个 emoji


2010 年,Unicode 正式收录了 emoji,为每个 emoji 分配了唯一的码点。
2011 年,Apple 在 iOS 中加入了标准的 emoji 输入键盘,2 年后安卓系统也引入了 emoji 键盘。


Unicode


Unicode 中原本就收录了很多有意义的字符,如:㎓、𐦖、☳,大家还可以查看 Unicode 1 号平面的埃及象形文字区 (U+13000–U+1342F)。收录 emoji 对 Unicode 来说没有挑战,技术上是完全兼容的。
image.png
Unicode 象形文字区节选


Emoji 的编码


基本 emoji



基本 emoji 是指在 Unicode 编码表中用 1 个唯一码位表示的 emoji



最简单的 emoji 就是 Unicode 表中的一个字符,和我们常用的 Unicode 字符没有区别。多数基本 emoji 都被分配到 Unicode 编码表 1 号平面的 U+1F300–1F6FFU+1F900–1FAFF 两个区域,完整的列表请看15.0/emoji-sequences.txt
image.png
Unicode 中 emoji 的码位


我们常见的 emoji 是彩色的,而常见的字体是黑色的。字符的颜色取决于字体文件,如果你愿意,你也可以把其常见的汉字字体设计成彩色的。iOS/MacOS 的Apple Color Emoji字体是一种 160x160 的点阵字体, Android 的Noto Emoji是一种 128x128 的点阵字体,而 Windows 使用的 Segoe UI Emoji 是一种矢量彩色字体。


为什么同一个 emoji 在不同设备、不同软件中显示不同?
不同设备、软件使用了不同的 emoji 字体所以显示效果不同。Unicode 只是约定了码点到 emoji 的映射关系,并没有约定 emoji 图形,每个 emoji 字体文件可以按照自己的想法设计 emoji。
image.png
同一个 emoji 在不同软件上的显示效果


为什么在钉钉中发送**[憨笑]**会显示成image.png
早期包含 Unicode emoji 的字体还没广泛普及,你给对方发一个 emoji 符号😄,如果没对方设备有对应的字体看到的会是**?**
为了解决缺失 emoji 字体导致大家显示不一致的问题(或者为了方便自定义自己的**伪emoji**——为了方便描述,把软件自定义的图片称作伪 emoji),很多软件自己开发了能向下兼容的解决方案,如钉钉。该自定义方案与 Unicode 编码没有关系,只是将特殊的字符串与一张图片映射起来,当对方发送[xx]字符串时,将它显示成对应的图片
早期支付宝的转账备注功能中也定义了自己的伪emoji伪emoji的好处是向下兼容,如果使用标准的Unicode emoji 可能会导致别的系统无法处理(如:做了汉字正则校验),导致转账失败;弊端是不通用,别的系统通常不支持另一个系统定义的伪emoji,直接将[xx]文本显示出来,如:收银台在支付界面就会直接显示转账备注的伪 emoji 文本[xx]
image.png


字素集


字素集(grapheme cluster)在 Unicode 中通常一个码点对应一个字符,但是 Unicode 引入了特定的机制允许多个 Unicode 码点组合成一个字形符号。这样由于多个码点组合成的一个字符称作字素集。
比如Ü是一个字素集,是由两个码点组成:大写字母 U(U+0055),后面跟一个连音符号(U+0308)。再比如:'曙󠄀'.length=3'🤦🏼‍♂️'.length=7,前者由基本的字符加上一个变体选择符️ VS-17 (见后文)组成,后者由多个基础 emoji 修饰符、连接符组成。
点开有惊喜Ų̷̡̡̨̫͍̟̯̣͎͓̘̱̖̱̣͈͍̫͖̮̫̹̟̣͉̦̬̬͈͈͔͙͕̩̬̐̏̌̉́̾͑̒͌͊͗́̾̈̈́̆̅̉͌̋̇͆̚̚̚͠ͅ[左边是一个.length 为 65 的字素集,它是不可分割的一个字符]


在 Unicode 的规范中要求所有软件(本编辑器、文本渲染、搜索等)将一个字素集当做不可分割的整体,即:当做一个单一的字符对待。
image.png
Unicode 处理的难点就在于字素集,下文均与该定义有关,开发者的噩梦都源自该概念。不能简单地通过 .length 读取字符串的长度;如果想截取字符串的前 10 个字符,也不能简单的使用.substring(0, 10),因为这可能会截断 emoji 字符;反转字符串也非常困难,U+263A U+FE0F 有意义,反转之后 U+FE0F U+263A 却没有意义,后文会介绍正确的处理方式。


变体选择符️


Variation Selector(又叫异体字选择器),是 Unicode 中定义的一种修饰符机制。一个基本字符后接上一个异体字选择器组成一个异体字。背景是:一个字符可能会有多个不同的变体,这些变体本质上是同一个字符,具有同样的含义,由于地区、文化差异导致他们演变成了不同的书写形式。Unicode 为变体字分配了同一个码点,如果想要显示特定的书写形式可以在字符后紧接着一个异体字选择器指定。
image.pngimage.png就是变体字。需要澄清的是,并非所有相似的字符都按照异性字的形式合并成了一个码点,就是分别分配了不同的码点,理论上这两个字符也可以合并变体字共用一个码点。
在 Unicode 中引入彩色的 emoji 前就已经定义了一些黑色的图形符号,引入彩色 emoji 后,新的 emoji 与黑色的符号具有相同的含义,于是共用了同一个 Unicode 码点,可在字符后接上一个 VS 指定要显示那个版本。
常用的 VS 有 16 个 VS-1 ~ VS-16,对应的 Unicode 是(U+FE00~U+FE0F),其中 VS-15(U+FE0E)用于指定显示黑色的 text 版本,VS-16(U+FE0F)用于指定显示彩色的 emoji 版本。


默认显示VS-15 修饰符VS-16 修饰符
U+2702✂︎U+2702 U+FE0E✂︎U+2702 U+FE0F ✂️
U+2620☠︎U+2620 U+FE0E☠︎U+2620 U+FE0F ☠️
U+26A0⚛︎U+26A0 U+FE0E⚛︎U+26A0 U+FE0F ⚛️
U+2618☘︎U+2618 U+FE0E☘︎U+2618 U+FE0F ☘️

可以动手验证一下



image.png



  • ✂ 不含修饰符'\u2702'

  • ✂︎ 含 VS-15'\u2702\uFE0E'

  • ✂️ 含 VS-16'\u2702\uFE0F'



为什么把黑色的剪刀 ✂︎ 粘贴到 Chrome 搜索栏中显示成彩色,把彩色剪刀 ✂️ 复制到 Chrome 的 Console 中显示成黑色?
image.png image.png
我们通过 VS 符号告诉软件要显示成指定的异体字符,但是软件可以不听我们的,软件可能会强制指定特定的字体,如果该字体中只包含一种异体字符的字形数据那就只会显示该字形。


肤色修饰符


大多数人形相关的 Emoji 默认是黄色的,在 2015 年为 emoji 引入肤色支持。没有为每种肤色的 emoji 组合分配新的码点,而是引入了五个新码点作为修饰符:1F3FB 🏻、1F3FC 🏼、1F3FD 🏽、1F3FE 🏾、1F3FF 🏿 。肤色修饰符追加到现有的 emoji 后面则形成新的变种,如:👋 U+1F44B+ 🏽U+1F3FD= 👋🏽



  • 👋 在 JavaScript 中 UTF-16 值是'\uD83D\uDC4B'

  • **🏽 **在 JavaScript 中 UTF-16 值是'\uD83C\uDFFD'


组合在一起'\uD83D\uDC4B\uD83C\uDFFD'就得到了 👋🏽
image.png


5 种肤色修饰符的取值是基于菲茨帕特里克度量,因此叫做 EMOJI MODIFIER FITZPATRICK。肤色度量共有 6 个取值,但在 emoji 中前两个颜色合并成了一个。
image.png
最终 280 个人形 emoji 就产生了 1680 种肤色变种,这是五种不同肤色的舞者:🕺🕺🏻🕺🏼🕺🏽🕺🏾🕺🏿


零宽度连接符(ZWJ)


Unicode 通过多个基础 emoji 组合的形式表示某些复杂 emoji。组合的方式是在两个 emoji 之间添加一个U+200D,即:零宽度连接符(ZERO-WIDTH JOINER,简写为 ZWJ),如:



  • 👩 + ZWJ+ 🌾 = 👩‍🌾


image.png
下面是一些例子,完整的组合列表参考:Unicode 15.0/emoji-zwj-sequences.txt




  • 👩 + ✈️ → 👩‍✈️

  • 👨 + 💻 → 👨‍💻

  • 👰 + ♂️ → 👰‍♂️

  • 🐻 + ❄️ → 🐻‍❄️

  • 🏴 + ☠️ → 🏴‍☠️

  • 🏳️ + 🌈 → 🏳️‍🌈

  • 👨 + 🦰 → 👨‍🦰 (有意思的是:发色是通过 ZWJ 组合基础 emoji 实现,而肤色则是用肤色修饰符实现)

  • 👨🏻 + 🤝 + 👨🏼 → 👨🏻‍🤝‍👨🏼

  • 👨 + ❤️ + 👨 → 👨‍❤️‍👨

  • 👨 + ❤️ + 💋 + 👨 → 👨‍❤️‍💋‍👨

  • 👨 + 👨 + 👧 → 👨‍👨‍👧

  • 👨 + 👨 + 👧 + 👧 → 👨‍👨‍👧‍👧



可惜,有些 emoji 不是通过 ZWJ 组全 emoji 实现的,可能是因为没有赶上 ZWJ 定义的时机




  • 🌂 + 🌧 ≠ ☔️

  • 💄 + 👄 ≠ 💋

  • 🐴 + 🌈 ≠ 🦄

  • 👁 + 👁 ≠ 👀

  • 👨 + 💀 ≠ 🧟

  • 👩 + 🔍 ≠ 🕵️‍♀️

  • 🦵 + 🦵 + 💪 + 💪 + 👂 + 👂 + 👃 + 👅 + 👀 + 🧠 ≠ 🧍



旗帜·双字母连字


Unicode 中包含国旗符号,每个国旗也没有分配独立的码点,而是由双字符连字(ligature)来表示。(但 Windows 平台因为某些原因不支持显示,如果你是用 Windows 平台的浏览器阅读本文,只能说抱歉了)



  • 🇺 + 🇳 = 🇺🇳

  • 🇷 + 🇺 = 🇷🇺

  • 🇮 + 🇸 = 🇮🇸

  • 🇿 + 🇦 = 🇿🇦

  • 🇯 + 🇵 = 🇯🇵


这里的🇦 ~ 🇿不是字母,而是地区标识符,对应的码点是U+1F1E6~U+1F1FF,可以随意复制并组合,如果是合法的组合会显示成一个国家的旗帜。你可以在 MacOS 的 FontBook 中打开 Apple Color Emoji 查看到这些码点以及各个地区的旗帜符号
image.png image.png
完整地区标识符如下,你可以动手组合试一试:
🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭 🇮 🇯 🇰 🇱 🇲 🇳 🇴 🇵 🇶 🇷 🇸 🇹 🇺 🇻 🇼 🇽 🇾 🇿


标签序列


在 Unicode 中称作 Emoji Tag Sequence。在 Unicode 中U+E0020~ U+E007F 95 个码点表示的是Unicode 中不可见的控制符号,其中从E0061~E007A的 26 个码点分别表示小写的拉丁字符(不是常规的拉丁字母,而是 emoji 相关的控制字符),对应关系如下:




  • U+E0061 - TAG LATIN SMALL LETTER A

  • U+E0062 - TAG LATIN SMALL LETTER B



...




  • U+E007A - TAG LATIN SMALL LETTER Z



前文的双字母连字机制支持将两个地区标识符连接在一起表示一个旗帜符号。标签序列与之类似,是 Unicode 中定义的一种更复杂的连接方式,格式是:基础emoji+ 一串拉丁标签字符(U+E0061~U+E007A) + 结束符(U+E007F)
如:🏴 + gbeng + U+E007F = 🏴󠁧󠁢󠁥󠁮󠁧󠁿
其中 🏴 是基础 emoji U+1F3F4,_gbeng _分别代表对应的拉丁控制字符: g(U+E0067)b(U+E0062)e(U+E0065) n(U+E006E)g(U+E0067)U+E007F表示结束符,全称是 TAG CANCEL


/**
* 根据地区缩写返回对应的emoji
* 如:flag('gbeng') -> 🏴󠁧󠁢󠁥󠁮󠁧󠁿
*/

function flag(letterStr) {
const BASE_FLAG = '🏴';
const TAG_CANCEL = String.fromCodePoint(0xE007F);

// 将普通字母字符序列转换为"标签拉丁字符"序列
const tagLatinStr = (letterStr.toLowerCase().split('').map(letter => {
const codePoint = letter.charCodeAt(0) - 'a'.charCodeAt(0) + 0xE0061;
return String.fromCodePoint(codePoint);
})).join('');


return BASE_FLAG + tagLatinStr + TAG_CANCEL;
}

目前用这种方式表示的 emoji 共有三个



  • 🏴 + gbeng + U+E007F = 🏴󠁧󠁢󠁥󠁮󠁧󠁿 英格兰旗帜,完整序列:1F3F4 E0067 E0062 E0065 E006E E0067 E007F

  • 🏴 + gbsct + U+E007F = 🏴󠁧󠁢󠁳󠁣󠁴󠁿 苏格兰旗帜,完整序列:1F3F4 E0067 E0062 E0073 E0063 E0074 E007F

  • 🏴 + gbwls + U+E007F = 🏴󠁧󠁢󠁷󠁬󠁳󠁿 威尔士旗帜,完整序列:1F3F4 E0067 E0062 E0077 E006C E0073 E007F


键位符


共有 12 个键位符 #️⃣ *️⃣ 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣,规则是这样的:井号、星号和数字,加 U+FE0F 变成 emoji,再加上U+20E3变成带方框的键位符。







      • FE0F + 20E3 = *️⃣






  • + FE0F + 20E3 = #️⃣



  • 0 + FE0F + 20E3 = 0️⃣

  • ...


U+FE0F是前文提到的变体选择符中的VS-16,表示显示为 emoji 形态。JavaScript 中'\u0030'表示数字'0', '\u0030\ufe0f'则表示它的 emoji 变体,两者在 zsh 的 console 中显示效果不同,.length的值也不同。
image.png image.png


小结


一共有七种 emoji 造字法



  1. 基础emoji,单个码点表示一个emoji 🧛 U+1F9DB

  2. 单个码点 + 变体选择符 ⚛️ = ⚛︎ U+26A0 + U+FE0F

  3. 皮肤修饰符 🤵🏽 = 🤵 U+1F935 + 🏽 U+1F3FD

  4. **ZWJ连接符 ** 👨‍💻 = 👨 + ZWJ + 💻

  5. 旗帜符号 🇨🇳 = 🇨 + 🇳

  6. **标签序列 ** 🏴󠁧󠁢󠁳󠁣󠁴󠁿 = 🏴 + gbsct + U+E007F

  7. **键位序列 ** *️⃣ = * + U+FE0F + U+20E3


前四种方法也可以组合使用,可构造非常复杂的 emoji



U+1F6B5 🚵 个人山地骑行



  • U+1F3FB 浅色皮肤

  • U+200D ZWJ

  • U+2640 ♀️女性标志

  • U+FE0F 变体标志
    = 🚵🏻‍♀️ 浅色皮肤的女性山地骑行



/**
* 显示一个字符种所有的Unicode码点
*/

function codePoints(str) {
const result = [];
for(let i = 0; i < str.length; i ++) {
result.push(str.codePointAt(i).toString(16).toUpperCase());
}
return result;
}
codePoints('🚵🏻‍♀️') => ['1F6B5', 'DEB5', '1F3FB', 'DFFB', '200D', '2640', 'FE0F']

如何在代码中正确处理 emoji?


emoji 引入的问题


'中国人123'.length = 6'工作中👨‍💻'.length = 8
emoji 给编程带来的主要问题是视觉上看到的字符长度(后文称作视觉 length)与代码中获取的长度(后文称作技术 length)不相同,使得字符串截取等操作返回非预期内的结果,如:
'工作中👨‍💻'.substr(0,5) => '工作中👨''工作中👨‍💻'.substr(5)' => '‍💻'


本质上在 emoji 出现之前 Unicode 编码就遇到了该问题,只不过 emoji 的普及让该问题更普遍。有的 emoji 长度为 1,有的长度可以达到 15。问题的根源是 Unicode 中可以用多个码点表示一个 emoji,如果所有 emoji 都用一个 Unicode 码点表示就不存在该问题。
image.png


解法:视觉 length VS. 技术 length


解法显而易见,只要能将字符串中所有的字符元素按照视觉上看到的情况准确拆分,即:准确拆解字符串中的所有字素集
下述伪代码是要实现的效果,很多开源工具库就在做同样的事情,搜:Grapheme Cluster 即可。找到一个JavaScript版的grapheme-splitter,但是数据已经过时(勿用)。


const vs = new VisualString('工作中👨‍💻');
// vs.length => 4; // 视觉长度
// vs.physicalLength => 8; // 字符串长度
// vs[0] => 工
// vs[3] => 👨‍💻 // 按照所见即所得的方式拆分字符

// 字素集方法
// vs.substr(3,1) => 👨‍💻 // 截取字符

// 字素集属性
// vs[3].physicalLength => 5 // 物理长度
// vs[3].isEmoji => true // 是否是emoji

我们将产出工具库中将要提供这些能力



  1. 判断一个字符串中是否包含 emoji

  2. 将一个字符串准确拆分成若干个字素集

    • 每个字素集包含这些属性:isEmojiphysicalLength



  3. 按照字素集对字符串做截取操作

    • 基础截取: new VisualString('👨123👨‍💻').substr(1, 4) => '123👨‍💻'

    • 限定物理长度截取:new VisualString('👨123👨‍💻').substr(1, 4, 6) => '123',最后一个参数6代表最大物理长度,其中'123👨‍💻'.length = 8,如果限定最大物理长度6则只能截取到'123'备注:在产品体验上我们遵循“所见即所得”,但是在后端系统中传输和存储时候要遵循物理长度的限制,因此需要提供限定物理长度的截取能力。




版本兼容问题


如果 A 向 B 发送了一个组合 emoji「工作👨‍💻123」,B 的系统或软件中版本低(兼容的 Unicode 版本低)不支持该组合 emoji,看到的可能会是「工作👨💻123」。
用看到的是👨‍💻还是👨💻取决于用户的操作系统、软件和字体,我们提供的 JS 库无法感知到用户最终看到的是什么。我们提供的 JS 库会按照最新 Unicode 规范实现,无论用户看到的是什么都会把它当成一个字符(准确地说是字素集),即:
const vs = new VisualString('工作👨💻123'); vs.length => 6; vs[2] => '👨💻'
有办法可以一定程度上解决上述问题,但是我们觉得可能不解决才是正确的做法。


一个彩蛋


最后希望你使用 emoji 愉快 😄
发现 emoji 的维护者彻底贯彻「众生平等」,除了推出了不同肤色的 emoji 外,竟还设计了一个 Pregnant Man :)
image.png 🤰🫃🫄🏼
以上是分别是 woman、man、person,emoji 的新趋势是设计中性的 emoji




参考



作者:支付宝体验科技
来源:juejin.cn/post/7225074892357173308
收起阅读 »

微信黑科技

我一直认为技术是用来服务于用户,提升用户体验,而不是像 拼多多,写了恶意代码,操控用户的手机 ,利用技术做一些不好的事,今天这篇文章主要分享微信如何利用黑科技,减少 512MB 内存,降低 OOM 和 Native Crash 提升用户体验。 在上一篇文章谁动...
继续阅读 »

我一直认为技术是用来服务于用户,提升用户体验,而不是像 拼多多,写了恶意代码,操控用户的手机 ,利用技术做一些不好的事,今天这篇文章主要分享微信如何利用黑科技,减少 512MB 内存,降低 OOM 和 Native Crash 提升用户体验。


在上一篇文章谁动了我的内存,揭秘 OOM 崩溃下降 90% 的秘密 中分享了内存相关的知识点,包含堆、虚拟内存、发生 OOM 的原因,以及 为什么虚拟内存不足主要发生在 32 位的设备上导致虚拟内存不足的原因都有那些,目前都有哪些黑科技帮助我们去降低 OOM,有兴趣的小伙伴可以前往查看,从这篇文章开始细化每个知识点。


随着业务的增长,32 位设备上虚拟内存不足问题会越来越突出,尤其是大型应用会更加明显。除了业务上的优化之后,还需要一些黑科技尽可能降低更多的内存,而今天这篇主要分析微信分享的「堆空间减半」的方案,最高可减少 512MB 内存,从而降低 OOM 和 Native Crash,在开始之前,我们需要介绍一下 相关的知识点。


根据 Android 源码中的解释,Java 堆的大小应该是根据 RAM Size 来设置的,这是一个经验值,厂商是可以更改的,如果手机 Root 之后,自己也可以改,Google 源码的设置如下如下图所示

android.googlesource.com/platform/fr…



RAM (MB)-dalvik-heap. mkheapgrowthlimit (MB)heapsize (MB) 需要设置 android: largeHeap 为 true
512-dalvik-heap. mk48128
1024-dalvik-heap. mk96256
2048-dalvik-heap. mk192512
4096-dalvik-heap. mk192512
6144-dalvik-heap. mk256512
无论 RAM 多大,到目前为止堆的最大上限都是 512MB

正如上面表格所示,在 AndroidManifest.xml 文件 Application 节点中设置 android:largeHeap="true" 和不设置 largeHeap 获取到的最大堆的上限是不一样。


"true">



为什么默认关闭 android:largeHeap


Java 堆用于分配 Java / Kotlin 创建的对象,由 GC 管理和回收,GC 回收时将 From Space 里的对象复制到 To Space,这两片区域分别为 dalvik-main spacedalvik-main space 1, 这两片区域的大小和 Java 堆大小一样,如下图所示。



图中我们只需要关注 size(虚拟内存) 即可,如果 Java 堆的上限是 512 MB,那么 dalvik-main space(512 MB)dalvik-main space 1(512 MB) 共占用 1G 的虚拟内存。


如果堆的上限越大,那么 main space 占用的虚拟内存就会越大,在 32 位设备上,用户空间可用虚拟内存只有 3G,但是如果堆上限是 512MB,那么 main space 总共占用 1G 虚拟内存,剩下只有 2G 可用,因此 Google 在默认情况下会关闭 android:largeHeap 选项,只有在有需要的时候,主动设置 android:largeHeap = true,尝试获取更大的堆内存。


main space 占用虚拟内存的计算方式是不一样的。


Android 5. x ~ Android 7. x



  • 如果设置 android:largeHeap = true 时,main space size = dalvik.vm.heapsize,如果 heapsize 是 512MB,那么两个 main space 共占用 1G 虚拟内存

  • 如果不设置 largeHeap,那么 main space size = dalvik.vm.heapgrowthlimit,如果 heapgrowthlimit 是 256 MB,那么两个 main space 共占用 512 MB 虚拟内存


>= Android 8. x


无论 AndroidManifest 是否设置 android:largeHeapmain space size = dalvik.vm.heapsize * 2,如果 dalvik.vm.heapsize 是 512MB 那么 main space 占用 1G 的虚拟内存内存。


main space 在不同的系统分配方式是不一样的。



  • Android 5.x ~ Android 7.x 中,系统分配两块 main space,它们占用虚拟内存的大小和堆的大小是一样的

  • >= Android 8.x 之后,只分配了一个 main space,但是它占用虚拟内存的大小是堆的 2 倍


不同的系统上,它们的实现方式是不一样的,所以我们要采用不同的方法来释放 main space 占用的内存。


在 Android 5. x ~ Android 7. x


5.0 之后使用的是 ART 虚拟机,在 ART 虚拟机引入了,两种 Compacting GC 分为 Semi-Space(SS)GC (半空间压缩) 和 Generational Semi-Space(GSS)GC (分代半空间压缩)。 GSS GCSS GC 的改进版本,作为 background GC 的默认实现方式。


这两种 GC 的共同点,存在两片大小和堆大小一样的内存空间分别作为 From SpaceTo Space,这两片区域分别为 dalvik-main space1dalvik-main space2



上面的这两块区域对应的源码 地址

cs.android.com/android/_/a…



执行 Compact / Moving GC 的时候才会使用到这两片区域,在 GC 执行期间,将 From Space 分配的还存活的对象会依次拷贝到 To Space 中,在复制对象的过程中 From Space 中的碎片就会被消除,下次 GC 时重复这套逻辑,但是 GSS GC 还多了一个 Promote Space


Promote Space 主要存储老年代的对象,老年代对象的存活性要比新生代的久,因此将它们拷贝到 Promote Space 中去,可以避免每次执行 GSS GC 时,都需要对它们进行无用的处理。


新生代和老年代采用的不同的算法:



  • 新生代:复制算法。在两块 space 来回移动,高效且执行频繁,每次 GC 不需要挂起线程

  • 老年代:标记-压缩算法。会在 Mark 阶段是在挂起除当前线程之外的所有其它运行时线程,然后在 Compact 阶段才移动对象,Compact 方式是 Sliding Compaction,也就是在 Mark 之后就可以按顺序一个个对象 “滑动” 到空间的某一侧,移动的时候都是在一个空间内移动,不需要多一份空间


如何释放掉其中一个 main space 占用的内存


释放方案,可以参考腾讯开源的方案 Matrix,总来的来说分为两步:

github.com/Tencent/mat…



  • 确定 From SpaceTo Space 的内存地址

  • 调用 munmap 函数释放掉其中一个 Space 所占用的内存


如何确定 From Space 和 To Space 的内存地址


我们需要读取 mpas 文件,然后搜索关键字 main spacemain space 1,就可以知道 main spacemain space 1 的内存地址。


当我们知道 space 的内存地址之后,我们还需要确认当前正在使用的是那个 space,才能安全的调用 munmap 函数,释放掉另外一个没有使用的 space


matrix 的方案,创建一个基本类型的数组,然后通过 GetPrimitiveArrayCritical 方法获取它的地址,代码如下:



调用 GetPrimitiveArrayCritical 方法会返回对象的内存地址,如果地址在那块区域,当前的区域就是我们正在使用的区域,然后我们就可以安全的释放掉另外一个 space 了。



释放掉其中一个 Space 会有问题吗?


如果我们直接释放掉其中一个 Space,在执行 Compact / Moving GC 的时候,需要将 From Space 分配的对象依次拷贝到 To Space 中,因为找不到 To Space,会引起 crash, 所以需要阻止 Moving GC


源码中也说明了调用 GetPrimitiveArrayCritical 方法可以阻止 Moving GC。



GetPrimitiveArrayCritical 方法会调用 IncrementDisableMovingGC 方法阻止 Moving GC,对应的源码如下。

https://android. googlesource. com/platform/art/+/master/runtime/gc/heap. cc #956


void Heap::IncrementDisableMovingGC(Thread* self) {
// Need to do this holding the lock to prevent races where the GC is about to run / running when
// we attempt to disable it.
ScopedThreadStateChange tsc(self, kWaitingForGcToComplete);
MutexLock mu(self, *gc_complete_lock_);
++disable_moving_gc_count_;
if (IsMovingGc(collector_type_running_)) {
WaitForGcToCompleteLocked(kGcCauseDisableMovingGc, self);
}
}

所以只需要调用 GetPrimitiveArrayCritical 方法,阻止 Moving GC,也就不需要用到另外一个空间了,因此可以安全的释放掉。


阻止 Compact / Moving GC 会有性能问题吗


按照微信给出的测试数据,在性能上没有明显的变化。



OS Version >= Android 8. x


8.0 引入了 Concurrent Copying GC(并发复制算法),堆空间也变成了 RegionSpace。RegionSpace 的算法并不是靠把已分配对象在两片空间之间来回倒腾来实现的,分析 smaps 文件,发现也只创建了一个 main space,但是它占用的虚拟内存是堆的 2 倍,所以 8.0 之前的方案释放另外一个 space 是无法使用的。


为什么没有创建 main space2


我们从源码看一下创建 main space2 的触发条件。


if (foreground_collector_type_ == kCollectorTypeCC) {
use_homogeneous_space_compaction_for_oom_ = false;
}

bool support_homogeneous_space_compaction =
background_collector_type_ == gc::kCollectorTypeHomogeneousSpaceCompact ||
use_homogeneous_space_compaction_for_oom_;

if (support_homogeneous_space_compaction ||
background_collector_type_ == kCollectorTypeSS ||
foreground_collector_type_ == kCollectorTypeSS) {

ScopedTrace trace2("Create main mem map 2");
main_mem_map_2 = MapAnonymousPreferredAddress(
kMemMapSpaceName[1], main_mem_map_1.End(), capacity_, &error_str);
}

正如如源码所示,后台回收器类型 kCollectorTypeHomogeneousSpaceCompactkCollectorTypeCC 才会创建 main space2



  • kCollectorTypeHomogeneousSpaceCompact(同构空间压缩(HSC),用于后台回收器类型)

  • kCollectorTypeCCCompacting GC) 分为两种类型

    • Semi-Space(SS)GC (半空间压缩)

    • Generational Semi-Space(GSS)GC (分代半空间压缩),GSS GCSS GC 的改进版本




而 Android 8.0 将 Concurrent Copying GC 作为默认方式,对应的回收器的类型是 kCollectorTypeCCBackground



Concurrent Copying GC 分为 Pause, Copying, Reclaim 三个阶段,以 Region 为单位进行 GC,大小为 256 KB。



  • pause: 这个阶段耗时非常少,这里很重要的一块儿工作是确定需要进行 GC 的 region, 被选中的 region 称为 source region

  • Copying:这个阶段是整个 GC 中耗时最长的阶段。通过将 source region 中对象根据 root set 计算并标记为 reachable,然后将标记为 reachable 的对象拷贝到 destination region

  • Reclaim:在经过 Copying 阶段后,整个进程中就不再存在指向 source regions 的引用了,GC 就可以将这些 source region 的内存释放供以后使用了。


Concurrent Copying GC 使用了 read barrier 技术,来确保其它线程不会读到指向 source region 的对象,所以不会将 app 线程挂起,也不会阻止内存分配。


如何减少 main space 占用的内存


Adnroid 8.0 之后使用的阿里巴巴 Patrons 的方案,在虚拟内存占用超过一定阈值时调用 RegionSpace 中的 ClampGrowthLimit 方法来缩减 RegionSpace 的大小。


但是 ClampGrowthLimit 只在 Android 9.0 以后才出现,8.0 是没有的,所以参考了 Android 9.0 的代码实现了一个 ClampGrowthLimit。



ClampGrowthLimit 方法中,通过调用 MemMap::SetSize 方法来调整 RegionSpace 的大小。

https://android. googlesource. com/platform/art/+/5f0b71ab2f60f76b5f73402bd1fdd25bbc179b6c/runtime/gc/space/region_space. cc #416



MemMap::SetSize 方法的实现。

https://android. googlesource. com/platform/art/+/android-9.0.0_r7/runtime/mem_map. cc #883



new_base_size_base_size_ 不相等的情况下会执行 munmap 函数 , munmap 释放的大小为 base_size_new_base_size_ 的差值。




全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。



作者:程序员DHL
来源:juejin.cn/post/7223930180867063864
收起阅读 »

一个神奇的小工具,让URL地址都变成了"ooooooooo"

web
发现一个很有创意的小工具网站,如封面图所示功能很简单,就是将一个URL地址转换为都是 ooooooooo 的样子,通过转换后的地址访问可以转换回到原始地址,简单流程如下图所示。转换的逻辑有点像短链平台一样,只不过这个是将你的URL地址变的很长长长长,但是看着都...
继续阅读 »

发现一个很有创意的小工具网站,如封面图所示功能很简单,就是将一个URL地址转换为都是 ooooooooo 的样子,通过转换后的地址访问可以转换回到原始地址,简单流程如下图所示。转换的逻辑有点像短链平台一样,只不过这个是将你的URL地址变的很长长长长,但是看着都是 ooooooooo,很好奇是如何实现的,所以查阅了源码,本文解读其核心实现逻辑,很有趣且巧妙的实现了这个功能。



前置知识点


在正式开始前,先了解一些需要学习的知识点。因为涉及到两个地址其实也就是字符串之间的转换,会用到一些编码和解码的能力。


将字符转为utf8数组,转换后的每个字符都有一个特定的唯一数值,比如 http 转换后的 utf8 格式数组即是 [104, 116, 116, 112]


    toUTF8Array(str) {
var utf8 = [];
for (var i = 0; i < str.length; i++) {
var charcode = str.charCodeAt(i);
if (charcode < 0x80) utf8.push(charcode);
else if (charcode < 0x800) {
utf8.push(0xc0 | (charcode >> 6),
0x80 | (charcode & 0x3f));
}
else if (charcode < 0xd800 || charcode >= 0xe000) {
utf8.push(0xe0 | (charcode >> 12),
0x80 | ((charcode >> 6) & 0x3f),
0x80 | (charcode & 0x3f));
}
else {
i++;
charcode = ((charcode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)
utf8.push(0xf0 | (charcode >> 18),
0x80 | ((charcode >> 12) & 0x3f),
0x80 | ((charcode >> 6) & 0x3f),
0x80 | (charcode & 0x3f));
}
}
console.log(utf8, 'utf8');
return utf8;
}

上面是编码,对应下面的则是解码,将utf8数组转换为字符串,比如 [99, 111, 109] 转换后的 utf8 格式数组即是 com


    Utf8ArrayToStr(array) {
var out, i, len, c;
var char2, char3;

out = "";
len = array.length;
i = 0;
while (i < len) {
c = array[i++];
switch (c >> 4) {
case 0: case 1: case 2: case 3: case 4: case 5: case 6: case 7:
// 0xxxxxxx
out += String.fromCharCode(c);
break;
case 12: case 13:
// 110x xxxx 10xx xxxx
char2 = array[i++];
out += String.fromCharCode(((c & 0x1F) << 6) | (char2 & 0x3F));
break;
case 14:
// 1110 xxxx 10xx xxxx 10xx xxxx
char2 = array[i++];
char3 = array[i++];
out += String.fromCharCode(((c & 0x0F) << 12) |
((char2 & 0x3F) << 6) |
((char3 & 0x3F) << 0));
break;
}
}

return out;
}

将 Number 对象以 4 进制的形式表示为字符串,toString 用的比较多,但是里面传入参数的场景比较少,这个参数 radix 是一个可选的参数,用于指定转换的进制数,范围为 2 ~ 36,如果未传入该参数,则默认使用 10 进制。


n.toString(4)

在字符串左侧填充指定字符,直到字符串达到指定长度。基本语法为 str.padStart(targetLength [, padString])



  • targetLength:必需,指定期望字符串的最小长度,如果当前字符串小于这个长度,则会在左侧使用 padString 进行填充,直到字符串达到指定长度。

  • padString:可选,指定用于填充字符串的字符,默认为 " "(空格)。


str.padStart(4, '0')

URL 编码/解码


下面正式开始URL编码的逻辑,核心的逻辑如下:



  • 转换为utf8数组

  • 转换为4进制并左侧补0到4位数

  • 分割转换为字符串数组

  • 映射到o的不同形式

  • 再次拼接为字符串,即转换完成后的URL


// 获取utf8数组
let unversioned = this.toUTF8Array(url)
// 转换为base 4字符串
// padstart非常重要!否则会丢失前导0
.map(n => n.toString(4).padStart(4, "0"))
// 转换为字符数组
.join("").split("")
// 映射到o的不同形式
.map(x => this.enc[parseInt(x)])
// 连接成单个字符串
.join("")

上面有两个关键点解释一下,首先映射到o的不同形式这个是什么意思呢?其实转换后的o并不是一种“o”,而是4种,只不过我们肉眼看到的效果很像,通过 encodeURI 转换后的字符可以看出来。


encodeURI('o-ο-о-ᴏ')
// o-%CE%BF-%D0%BE-%E1%B4%8F

这里其实也解释了为什么上面为什么是转换为4进制和左侧补0到四位数。因为上面代码定义的 this.enc 如下,因为总共只有四种“o”,4进制只会产生0,1,2,3,这样就可以将转换后的utf8字符一一对应上这几种特殊的“o”。


enc = ["o", "ο", "о", "ᴏ"] 

最后的效果举例转换 http 这个字符:



  • 转换为utf8数组:[ 104, 116, 116, 112 ]

  • 转换为4进制并左侧补0到4位数:['1220', '1310', '1310', '1300']

  • 分割转换为字符串数组:['1', '2', '2', '0', '1', '3', '1', '0', '1', '3', '1', '0', '1', '3', '0', '0']

  • 映射到o的不同形式:[ 'ο', 'о', 'о', 'o', 'ο', 'ᴏ', 'ο', 'o', 'ο', 'ᴏ', 'ο', 'o', 'ο', 'ᴏ', 'o', 'o' ]

  • 再次拼接为字符串,即转换完成后的URL:οооoοᴏοoοᴏοoοᴏoo


到此整个转换编码的过程就结束了,看完后是不是觉得设计的很不错,编码完后就是解码,解码就是将上面的过程倒序来一遍,恢复到最原始的URL地址。这里要注意一点的是每次解析4个字符且parseInt以4进制的方式进行解析。


// 获取url的base 4字符串表示
let b4str = ooo.split("").map(x => this.dec[x]).join("")

let utf8arr = []
// 每次解析4个字符
// 记住添加前导0的填充
for (let i = 0; i < b4str.length; i += 4)
utf8arr.push(parseInt(b4str.substring(i, i + 4), 4))
// 返回解码后的字符串
return this.Utf8ArrayToStr(utf8arr)

最后


到此就核心实现代码就分享结束了,看完是不是感觉并没有很复杂,基于此设计或许可以延伸出其他的字符效果,有兴趣的也可以试试看。将转码后的地址分享给你的朋友们一定会带来不一样的惊喜。


以下将官网源码运行在码上掘金,方便大家体验。



下面是我转换的一个AI小工具地址,点击看看效果吧~


ooooooooooooooooooooooo.ooo/ooooοооoοᴏο…


看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~


专注前端开发,分享前端相关技术干货,公众号:南城大前端(ID: nanchengfe)


作者:南城FE
来源:juejin.cn/post/7225573912670191677
收起阅读 »

十分钟,带你了解 Vue3 的新写法

web
最近因为项目需要,不得不学习一下 Vue3。于是花了 4 个小时,把 Vue3 过了一遍。现在我来带你快速了解 Vue3 的写法。 本文的目的,是为了让已经有 Vue2 开发经验的 人 ,快速掌握 Vue3 的写法。 因此, 本篇假定你已经掌握 Vue 的核心...
继续阅读 »

最近因为项目需要,不得不学习一下 Vue3。于是花了 4 个小时,把 Vue3 过了一遍。现在我来带你快速了解 Vue3 的写法。


本文的目的,是为了让已经有 Vue2 开发经验的 ,快速掌握 Vue3 的写法。


因此, 本篇假定你已经掌握 Vue 的核心内容 ,只为你介绍编写 Vue3 代码,需要了解的内容。


一、Vue3 里 script 的三种写法


首先,Vue3 新增了一个叫做组合式 api 的东西,英文名叫 Composition API。因此 Vue3 的 script 现在支持三种写法,


1、最基本的 Vue2 写法


<template>
<div>{{ count }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script>
export default {
data() {
return {
count: 1,
};
},
methods: {
onClick() {
this.count += 1;
},
},
}
</script>

2、setup() 属性


<template>
<div>{{ count }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script>
import { ref } from 'vue';
export default {

// 注意这部分
setup() {
let count = ref(1);
const onClick = () => {
count.value += 1;
};
return {
count,
onClick,
};
},

}
</script>

3、<script setup>


<template>
<div>{{ count }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(1);
const onClick = () => {
count.value += 1;
};
</script>

正如你看到的那样,无论是代码行数,还是代码的精简度,<script setup> 的方式是最简单的形式。


如果你对 Vue 很熟悉,那么,我推荐你使用 <script setup> 的方式。


这种写法,让 Vue3 成了我最喜欢的前端框架。


如果你还是前端新人,那么,我推荐你先学习第一种写法。


因为第一种写法的学习负担更小,先学第一种方式,掌握最基本的 Vue 用法,然后再根据我这篇文章,快速掌握 Vue3 里最需要关心的内容。


第一种写法,跟过去 Vue2 的写法是一样的,所以我们不过多介绍。


第二种写法,所有的对象和方法都需要 return 才能使用,太啰嗦。除了旧项目,可以用这种方式体验 Vue3 的新特性以外,我个人不建议了解这种方式。反正我自己暂时不打算精进这部分。


所以,接下来,我们主要介绍的,也就是 <script setup> ,这种写法里需要了解的内容。


注意: <script setup> 本质上是第二种写法的语法糖,掌握了这种写法,其实第二种写法也基本上就会了。(又多了一个不学第二种写法的理由)。


二、如何使用 <script setup> 编写组件


学习 Vue3 并不代表你需要新学习一个技术,Vue3 的底层开发思想,跟 Vue2 是没有差别的。


V3 和 V2 的区别就像是,你用不同的语言或者方言说同一句话。


所以我们需要关心的,就是 Vue2 里的内容,怎么用 Vue3 的方式写出来。


1、data——唯一需要注意的地方


整个 data 这一部分的内容,你只需要记住下面这一点。


以前在 data 中创建的属性,现在全都用 ref() 声明。


template 中直接用,在 script 中记得加 .value


在开头,我就已经写了一个简单的例子,我们直接拿过来做对比。


1)写法对比


 // Vue2 的写法

<template>
<div>{{ count }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script>
export default {
data() {
return {
count: 1,
};
},
methods: {
onClick() {
this.count += 1;
},
},
}
</script>

 // Vue3 的写法

<template>
<div>{{ count }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script setup>
import { ref } from 'vue';

// 用这种方式声明
const count = ref(1);

const onClick = () => {
// 使用的时候记得 .value
count.value += 1;
};
</script>

2)注意事项——组合式 api 的心智负担


a、ref 和 reactive

Vue3 里,还提供了一个叫做 reactiveapi


但是我的建议是,你不需要关心它。绝大多数场景下,ref 都够用了。


b、什么时候用 ref() 包裹,什么时候不用。

要不要用ref,就看你的这个变量的值改变了以后,页面要不要跟着变。


当然,你可以完全不需要关心这一点,跟过去写 data 一样就行。


只不过这样做,你在使用的时候,需要一直 .value


c、不要解构使用

在使用时,不要像下面这样去写,会丢失响应性。


也就是会出现更新了值,但是页面没有更新的情况


// Vue3 的写法
<template>
<div>{{ count }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(1);
const onClick = () => {
// 不要这样写!!
const { value } = count;
value += 1;
};
</script>

注意: 学习 Vue3 就需要考虑像这样的内容,徒增了学习成本。实际上这些心智负担,在学习的过程中,是可以完全不需要考虑的。


这也是为什么我推荐新人先学习 Vue2 的写法。


2、methods


声明事件方法,我们只需要在 script 标签里,创建一个方法对象即可。


剩下的在 Vue2 里是怎么写的,Vue3 是同样的写法。


// Vue2 的写法
<template>
<div @click="onClick">
这是一个div
</div>
</template>

<script>
export default {
methods: {
onClick() {
console.log('clicked')
},
},
}
</script>

// Vue3 的写法
<template>
<div @click="onClick">
这是一个div
</div>
</template>

<script setup>

// 注意这部分
const onClick = () => {
console.log('clicked')
}

</script>

3、props


声明 props 我们可以用 defineProps(),具体写法,我们看代码。


1)写法对比


// Vue2 的写法
<template>
<div>{{ foo }}</div>
</template>

<script>
export default {
props: {
foo: String,
},
created() {
console.log(this.foo);
},
}
</script>

// Vue3 的写法
<template>
<div>{{ foo }}</div>
</template>

<script setup>

// 注意这里
const props = defineProps({
foo: String
})

// 在 script 标签里使用
console.log(props.foo)
</script>

2)注意事项——组合式 api 的心智负担


使用 props 时,同样注意不要使用解构的方式。


<script setup>
const props = defineProps({
foo: String
})

// 不要这样写
const { foo } = props;
console.log(foo)
</script>

4、emits 事件


props 相同,声明 emits 我们可以用 defineEmits(),具体写法,我们看代码。


// Vue2 的写法
<template>
<div @click="onClick">
这是一个div
</div>
</template>

<script>
export default {

emits: ['click'], // 注意这里
methods: {
onClick() {
this.$emit('click'); // 注意这里
},
},

}
</script>

// Vue3 的写法
<template>
<div @click="onClick">
这是一个div
</div>
</template>

<script setup>

// 注意这里
const emit = defineEmits(['click']);

const onClick = () => {
emit('click') // 注意这里
}

</script>

5、computed


直接上写法对比。


// Vue2 的写法
<template>
<div>
<span>{{ value }}</span>
<span>{{ reversedValue }}</span>
</div>
</template>

<script>
export default {
data() {
return {
value: 'this is a value',
};
},
computed: {
reversedValue() {
return value
.split('').reverse().join('');
},
},
}
</script>

// Vue3 的写法
<template>
<div>
<span>{{ value }}</span>
<span>{{ reversedValue }}</span>
</div>
</template>

<script setup>
import {ref, computed} from 'vue'
const value = ref('this is a value')

// 注意这里
const reversedValue = computed(() => {
// 使用 ref 需要 .value
return value.value
.split('').reverse().join('');
})

</script>

6、watch


这一部分,我们需要注意一下了,Vue3 中,watch 有两种写法。一种是直接使用 watch,还有一种是使用 watchEffect


两种写法的区别是:




  • watch 需要你明确指定依赖的变量,才能做到监听效果。




  • watchEffect 会根据你使用的变量,自动的实现监听效果。




1)直接使用 watch


// Vue2 的写法
<template>
<div>{{ count }}</div>
<div>{{ anotherCount }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script>
export default {
data() {
return {
count: 1,
anotherCount: 0,
};
},
methods: {
onClick() {
this.count += 1;
},
},
watch: {
count(newValue) {
this.anotherCount = newValue - 1;
},
},
}
</script>

// Vue3 的写法
<template>
<div>{{ count }}</div>
<div>{{ anotherCount }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script setup>
import { ref, watch } from 'vue';

const count = ref(1);
const onClick = () => {
count.value += 1;
};

const anotherCount = ref(0);

// 注意这里
// 需要在这里,
// 明确指定依赖的是 count 这个变量
watch(count, (newValue) => {
anotherCount.value = newValue - 1;
})

</script>

2)使用 watchEffect


// Vue2 的写法
<template>
<div>{{ count }}</div>
<div>{{ anotherCount }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script>
export default {
data() {
return {
count: 1,
anotherCount: 0,
};
},
methods: {
onClick() {
this.count += 1;
},
},
watch: {
count(newValue) {
this.anotherCount = newValue - 1;
},
},
}
</script>

// Vue3 的写法
<template>
<div>{{ count }}</div>
<div>{{ anotherCount }}</div>
<button @click="onClick">
增加 1
</button>
</template>

<script setup>
import { ref, watchEffect } from 'vue';

const count = ref(1);
const onClick = () => {
count.value += 1;
};

const anotherCount = ref(0);

// 注意这里
watchEffect(() => {
// 会自动根据 count.value 的变化,
// 触发下面的操作
anotherCount.value = count.value - 1;
})

</script>

7、生命周期


Vue3 里,除了将两个 destroy 相关的钩子,改成了 unmount,剩下的需要注意的,就是在 <script setup> 中,不能使用 beforeCreatecreated 两个钩子。


如果你熟悉相关的生命周期,只需要记得在 setup 里,用 on 开头,加上大写首字母就行。


// 选项式 api 写法
<template>
<div></div>
</template>

<script>
export default {
beforeCreate() {},
created() {},

beforeMount() {},
mounted() {},

beforeUpdate() {},
updated() {},

// Vue2 里叫 beforeDestroy
beforeUnmount() {},
// Vue2 里叫 destroyed
unmounted() {},

// 其他钩子不常用,所以不列了。
}
</script>

// 组合式 api 写法
<template>
<div></div>
</template>


<script setup>
import {
onBeforeMount,
onMounted,

onBeforeUpdate,
onUpdated,

onBeforeUnmount,
onUnmounted,
} from 'vue'

onBeforeMount(() => {})
onMounted(() => {})

onBeforeUpdate(() => {})
onUpdated(() => {})

onBeforeUnmount(() => {})
onUnmounted(() => {})
</script>

三、结语


好了,对于快速上手 Vue3 来说,以上内容基本已经足够了。


这篇文章本身不能做到帮你理解所有 Vue3 的内容,但是能帮你快速掌握 Vue3 的写法。


如果想做到对 Vue3 的整个内容心里有数,还需要你自己多看看 V

作者:Wetoria
来源:juejin.cn/post/7225267685763907621
ue3 的官方文档。

收起阅读 »

辞职卖烤肠

前景 为35后做准备 找到一个合适的位置 周末出门多转转,找个人多的地方、并且容易饿的地方 营销策略 制定一个LOGO,视频中up的摊子叫公路商店 准备个小黑板 带个好看的围裙 头套啥的也备上 装备 必要 载具(小电车、自行车) 制作台 (可以展开...
继续阅读 »

前景


为35后做准备


找到一个合适的位置


周末出门多转转,找个人多的地方、并且容易饿的地方


营销策略



  • 制定一个LOGO,视频中up的摊子叫公路商店


image.png



  • 准备个小黑板

  • 带个好看的围裙

  • 头套啥的也备上


装备


必要



  • 载具(小电车、自行车)

  • 制作台 (可以展开的箱子、桌子)

  • 厨具(卡式炉、各式烤盘、酱料瓶子)

  • 收钱码(零钱)


可选



  • 投币机小玩具


卖什么


烤肠



  • 淀粉肠

  • 沙拉酱

  • 番茄酱

  • 黑胡椒酱

  • 孜然

  • 辣椒面

  • 油壶

  • 小刷子

  • 食品袋子
    image.png


润奶宝


甜筒、奶油、棉花糖、小熊饼干的组合


image.png


章鱼小丸子



  • 原材料 (面粉、鸡蛋、奶油)

  • 配菜(洋葱、芝士)

  • 定价 (12元/4个、15元/6个)


周末机车露营节集市,章鱼小丸子依旧是游客的宠爱,现场歌舞助兴_哔哩哔哩_bilibili


image.png


手工冰淇淋



  • 保温桶

  • 学习制作

  • 脆皮桶

  • 定价(3元/个)

  • 要用动物奶油


接完女儿放学,再一起骑着二八大杠去摆摊赚钱,多少人儿时的回忆_哔哩哔哩_bilibili


image.png


刨冰



  • 刨冰机


30℃的天气,我用樱桃小丸子刨冰机贩卖快乐_哔哩哔哩_bilibili


image.png


作者:codeniu
链接:https://juejin.cn/post/7220351836331311159
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

今天,我终于写了我大学时候想写的代码了

前言 最近心情不是很好,然后就开始回忆以前大学的时光,好想和大学的朋友再吃一顿饭啊。 然后想到一个月黑风高的晚上,我的宿友和我讨论多线程的问题: “假如有一个非常大的文件,要入库,你怎么将这个文件读取入库?” 当时我说:“很简单啊,就这样读文件然后入库啊。” ...
继续阅读 »

前言


最近心情不是很好,然后就开始回忆以前大学的时光,好想和大学的朋友再吃一顿饭啊。


然后想到一个月黑风高的晚上,我的宿友和我讨论多线程的问题:


“假如有一个非常大的文件,要入库,你怎么将这个文件读取入库?”


当时我说:“很简单啊,就这样读文件然后入库啊。”


他说:“我当时也是这样说的,然后面试官叫我回去等通知。”


想想还是太菜了,然后他和我说可以用多线程去操作。我当时由于技术原因不知道怎么操作,只知道大概思路。而今天也是有空去实现当时的遗憾了。


思路


思路也挺简单的,就是多条线程共同操作一个变量,这个变量记录了这个大文件读取的进度,也可以理解为文件行数。各个线程读取一定的数据然后记录mark,分段入库。


image.png


代码


餐前甜品


此处模拟从数据库读取大量数据原理和分批插入相同,每条线程循环500次,偏移量为500条,使用AtomicInteger来进行线程变量共享。


public class myThread implements Runnable {
private volatile int num = 0;
private AtomicInteger val = new AtomicInteger();
@Override
public void run() {
for (int i = 0; i < 500; i++) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("select id,name from user limit ");
stringBuffer.append(val.get());
val.addAndGet(500);
stringBuffer.append(",").append(val.get());
System.out.println(stringBuffer);
}
}
}

    public static void main(String[] args) {
myThread myRunnable1 = new myThread();
Thread thread1= new Thread(myRunnable1);
Thread thread2 = new Thread(myRunnable1);
thread1.start();
thread2.start();
}

每条线程是500x500,也就是250000,此处开了2条线程,最终结果应该是500000。控制台输出如下


image.png


ok,不错,证明方向是正确的,接下来是模拟读取大文件入库。

正餐


线程类


@Component
public class myNewThread extends Thread {
@Autowired
private IUserService userService;

private static AtomicInteger val = new AtomicInteger();

public myNewThread(){
}
public void run() {
List<User> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
User user = User.builder().wxName(val.get() + "").build();
SoftReference<User> stringBufferSoftReference = new SoftReference<>(user);
list.add(stringBufferSoftReference.get());
val.incrementAndGet();
}
userService.saveBatch(list);
}

}

@Test
void contextLoads() throws InterruptedException {
Thread thread3 = new myNewThread();
Thread thread4 = new myNewThread();
thread3.start();
thread4.start();
}

每条线程插入400条数据,wxName这个字段记录第几条数据
启动后发现控制台什么都没输出,数据库也没插入数据,很纳闷。然后经过一番思考后找出来问题所在。
在线程启动后加入以下两行代码


thread3.join();
thread4.join();

原因是,主线程在创建这两个线程后就结束了,子线程还没来得及操作数据库主线程就已经死亡了导致子线程被迫停止。


join表示主等待这线程执行结束,这样子就不会出现主线程创建完子线程就死亡导致子线程都没来得及执行线程体就死了的情况。


ok!启动!!
坏啦,空指针异常


image.png
经过调试发现,是service类在线程里面为空,导致能空指针异常,看来是spring捕获不到线程体。


既然捕获不到那我就传一个给线程体吧。


修改后的线程体


@Component
public class myNewThread extends Thread {
private IUserService userService;

private static AtomicInteger val = new AtomicInteger();
public myNewThread(IUserService userService){
this.userService = userService;
}
public myNewThread(){
}
public void run() {
List<User> list = new ArrayList<>();
for (int i = 0; i < 400; i++) {
User user = User.builder().wxName(val.get() + "").build();
SoftReference<User> stringBufferSoftReference = new SoftReference<>(user);
list.add(stringBufferSoftReference.get());
val.incrementAndGet();
}
userService.saveBatch(list);
}

}

修改后的单元测试


@Autowired
private IUserService userService;
@Test
void contextLoads() throws InterruptedException {
Thread thread3 = new myNewThread(userService);
//Thread thread4 = new myNewThread();
Thread thread4 = new myNewThread(userService);
thread3.start();
thread4.start();
thread3.join();
thread4.join();
}

简单来说就是使用构造函数给线程体传递一个非空的service类。


启动单元测试


image.png


证明是可以的刚刚好800条数据


总结


启动线程时要注意主线程和子线程的关系,然后操作数据库时,要注意传入的类是否空指针。


分段插入就到这里了,在高并发的情况下还没测试过,明天再说吧,已经是下午5点58了,下班!!!


作者:Avalon2353
链接:https://juejin.cn/post/7224051399255703613
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

五一在即:有哪些办法能阻止票贩子和我们一起抢票?

背景 五一就要来了,本来准备去淄博吃烧烤,结果发现根本抢不到机票。了解了一下原因,原来是黄牛的恶意爬虫把票都抢完了,现在买票,只能通过黄牛的渠道,多花几倍的钱来买了。 有鉴于此,想试试能不能针对这种爬虫防止相关问题,当然是站在航空公司的角度,就个人而言,躺平任...
继续阅读 »

背景


五一就要来了,本来准备去淄博吃烧烤,结果发现根本抢不到机票。了解了一下原因,原来是黄牛的恶意爬虫把票都抢完了,现在买票,只能通过黄牛的渠道,多花几倍的钱来买了。


有鉴于此,想试试能不能针对这种爬虫防止相关问题,当然是站在航空公司的角度,就个人而言,躺平任嘲就得了。


恶意爬虫的相关特征


对于恶意爬虫的特征,航空公司自己有总结:


1、访问的目标网页比较集中:“爬虫”代理人目标明确,主要是爬取班次、价格、数量等核心信息,因此只浏览访问几个固定页面,不访问其他页面。


**2、查询订票等行为很有规律:**由于“爬虫”是程序化操作,按照预先设定的流程进行访问等,因此呈现出毫无思维、但很有规律、有节奏且持续的行为。


3、同一设备上有规模化的访问和操作:“爬虫”的目的是最短时间内抓取最多信息,因此同一设备会有大量离散的行为,包括访问、浏览、查询等。


**4、访问来源IP地址异常:**正常情况下用户在查询、购买时,用户的IP地址比较稳定,而且访问来源IP比较;“爬虫”、“虚假占座”等操作时,IP来源地址呈现不同维度上的聚集,而且浏览、查询、购票等操作时不停变化IP地址。


5、设置UA模拟浏览器和频繁使用代理IP:很多“爬虫”程序伪装成浏览器进行访问,比如在程序头或者UA中默认含有类似python-requests/2.18.4等固定字符串;并且通过购买或者租用的云服务、改造路由器、租用IP代理、频繁变更代理IP等进行访问。


6、操作多集中非业务时间段:“爬虫”程序运行时间多集中在无人值守阶段。此时系统监控会放松,而且平台的带宽等资源占用少,爬虫密集的批量爬取不会对带宽、接口造成影响。以下是顶象反欺诈中心监测到,凌晨1-5点是恶意“爬虫”的运行高峰时段。


浅谈防范措施


1.设备指纹的应用


设备指纹单独用的话,在恶意爬虫面前,会稍微较弱一些。因为攻击者会使用其他技术手段来绕过设备指纹的检测,例如使用虚拟机、代理服务器等来隐藏真实设备的指纹。所以可以考虑结合IP地址限制一起使用:


import fingerprintjs2
from flask import Flask, request, abort

app = Flask(__name__)

# 创建一个新的Fingerprint2实例
fp = fingerprintjs2.Fingerprint2()

# 白名单IP地址
IP_WHITELIST = ['127.0.0.1', '192.168.0.1']

@app.route('/book-flight')
def book_flight():
# 获取设备指纹和IP地址
device_fingerprint = fp.get()
ip_address = request.remote_addr

# 检查IP地址是否在白名单中
if ip_address not in IP_WHITELIST:
abort(403)

# 其他业务逻辑代码...
return "Flight booked successfully!"

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


2. 人机验证


人机验证肯定是一个阻挡办法,但是在这种恶意强攻击的情况下,我们可以试试语音验证码(但是可能对用户体验不大友好):


from flask import Flask, request, jsonify
import random
from io import BytesIO
from captcha.audio import AudioCaptcha

app = Flask(__name__)

# 生成随机字符串作为验证码
def random_string(length):
pool = 'abcdefghijklmnopqrstuvwxyz0123456789'
return ''.join(random.choice(pool) for i in range(length))

# 生成语音验证码
def generate_audio_captcha():
captcha = random_string(4)
audio = AudioCaptcha().generate(captcha)
return captcha, audio

# 创建一个全局变量,用于保存已生成的语音验证码
captcha_cache = {}

@app.route('/audio-captcha')
def audio_captcha():
# 生成新的语音验证码
captcha, audio = generate_audio_captcha()

# 将验证码保存到缓存中,以便后续验证
captcha_cache[captcha] = True

# 返回语音验证码
response = BytesIO(audio.read())
response.headers['Content-Type'] = 'audio/wav'
return response

@app.route('/book-flight', methods=['POST'])
def book_flight():
# 获取表单中的验证码
captcha = request.form['captcha']

# 检查验证码是否正确
if captcha not in captcha_cache:
return 'Invalid captcha!'

# 其他业务逻辑代码...
return "Flight booked successfully!"

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


因为对用户体验不大友好,研发同学在设计的时候,最好考虑一下它的易用性和可访问性问题。需要权衡用户体验和恶意爬虫。


结语


“天下熙熙皆为利来,天下攘攘皆为利往。”因为这一块存在着巨大的利益,也就一直吸引着无数黑客从中牟利,而我们能做的,是“魔高一尺道高一丈”,希望能为安全世界尽一份力。


作者:昀和
链接:https://juejin.cn/post/7223965203254771771
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

从现在开始,对你的Flutter代码进行单元测试和微件测试

必要性 作为一个开发,对自己开发的功能进行单元测试是非常有必要的。单元测试是软件开发中一种必要的测试方法。它旨在测试一个单独的模块或组件的功能。单元测试通常是自动化的,并且可以在开发过程中进行频繁的测试。在本文中,我们将探讨单元测试的必要性以及为什么它是软件开...
继续阅读 »

必要性


作为一个开发,对自己开发的功能进行单元测试是非常有必要的。单元测试是软件开发中一种必要的测试方法。它旨在测试一个单独的模块或组件的功能。单元测试通常是自动化的,并且可以在开发过程中进行频繁的测试。在本文中,我们将探讨单元测试的必要性以及为什么它是软件开发中不可或缺的一部分。



  1. 验证代码的正确性


在编写代码时,开发人员往往会犯错误。这些错误可能是语法错误,逻辑错误或其他类型的错误。单元测试可以帮助开发人员及时发现这些错误。通过编写单元测试,开发人员可以验证代码是否按照预期执行,并且可以及早发现和解决错误。



  1. 提高代码质量


单元测试可以帮助开发人员编写更高质量的代码。编写单元测试需要开发人员仔细考虑每个功能点,并确保代码的每个方面都被测试到。通过这个过程,开发人员可以发现并解决潜在的问题,并确保代码的质量得到提高。



  1. 支持重构和改进


在软件开发的生命周期中,代码经常需要进行重构和改进。单元测试可以帮助开发人员在进行这些更改时保证代码的正确性。如果重构或改进代码后,单元测试仍然能够通过,那么开发人员就可以确信代码的行为没有发生变化。这种自信可以让开发人员更加轻松地进行代码更改,并减少由于更改而引入错误的风险。



  1. 提高代码的可维护性


单元测试可以提高代码的可维护性。通过编写单元测试,开发人员可以快速定位代码中的问题并进行修复。这可以使代码更容易维护,并减少开发人员需要花费的时间和精力。在团队合作的情况下,单元测试还可以帮助新成员更快地理解代码,并快速定位和解决问题。


总之,单元测试是软件开发中必不可少的一部分。它可以帮助开发人员验证代码的正确性,提高代码质量,支持重构和改进,并提高代码的可维护性。通过编写单元测试,开发人员可以确保代码的正确性和稳定性,并减少由于更改而引入错误的风险。


单元测试



  1. 安装测试框架


Flutter提供了自己的测试框架,称为flutter_test。在项目中使用flutter_test,需要在pubspec.yaml文件中添加依赖项:


dev_dependencies:
flutter_test:
sdk: flutter

然后,运行以下命令安装依赖项:


flutter packages get

当然在新创建的Flutter工程里,都会默认引用flutter_test并且创建好了test文件夹


image.png



  1. 编写测试用例


测试用例是用来测试应用程序的各个部分的代码。在Flutter中,测试用例通常包含在一个单独的文件中。在这个文件中,你需要导入flutter_test库,并编写测试代码。以下是一个示例:


void main() {
String time = testDate();
print('time=' + time);
}
///检查到期时间
String testDate() {
String expiryDate = '-长期';
List<String> list = (expiryDate).split('-');
if (list.length == 2) {
var start = list[0];
var end = list[1];
if (start.isEmpty || end.isEmpty) {
return '';
}
if (start.length == 8) {
start = DateTime.parse(start).format('yyyy.MM.dd');
}
if (end.length == 8) {
end = DateTime.parse(end).format('yyyy.MM.dd');
}
return '$start-$end';
}
return (expiryDate);
}

在单元测试中,我们可以给对应文件创建对应的测试文件。当我们把数据计算解耦出来,就可以达到不需要UI的情况测试返回结果的情况。配合Mock数据能极大的提高效率。



  1. 进行微件测试
    flutter_test还可以进行微件测试。
    比如我写了一个Widget,这个Widget需要一个json来构建,而json中的一个字段会影响我Widget的创建.我们需要检查这个Widget是否兼容这个json的所有情况.


void questionTitleWidgetTest() {
testWidgets('questionTitle', (widgetTester) async {
String path = '/Users/kfzs_002/Desktop/122.json';
File file = File(path);
String str = file.readAsStringSync();
Map<String, dynamic> json = jsonDecode(str);
Temp temp = Temp.fromJson(json);
for (int i = 0; i < (temp.data?.list?.length ?? 0); i++) {
var data = temp.data!.list![i];
if ((data.questionTitleArr ?? []).isEmpty) {
print('没有标题');
continue;
}
await widgetTester.pumpWidget(
MaterialApp(
home: Material(
child: QuestionTitleWidget(data: data.questionTitleArr ?? []),
),
),
);
print('index=$i');
await widgetTester.pump();
}
});
}

执行测试方法,我们就能在run窗口看到对应的数据结果。


有时我们会写一个动画组件,它有复杂的动画我想检查动画相关参数是否正确。


void examCircleProgressTest() {
testWidgets('examCircleProgressTest', (widgetTester) async {
await widgetTester.pumpWidget(ScreenUtilInit(
designSize: const Size(375, 812),
minTextAdapt: true,
useInheritedMediaQuery: true,
builder: (context, child) => GetMaterialApp(
home: Material(
child: Row(
children: [
ExamCircleProgress(
mTitle: 'title',
subjectType: 2,
progress: 30,
subTitle: 'subtitle',
score: '30',
)
],
),
),
),
));
// await widgetTester.pump();
for (var i = 0; i < 2000; i += 33) {
await widgetTester.pump(Duration(milliseconds: i));
}
});
}

这里我用到了widgetTester.pump.在Widget测试中,widgetTester.pump()方法是一个非常重要的方法,它的作用是将应用程序的状态推进到下一个时间片段。我以33毫秒为间隔,把这个Widget推进到了2秒后的状态。这样就能看到对应的数据情况了。


作者:无敌何尝不可
链接:https://juejin.cn/post/7225116802732425275
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Flutter 手指拖动实现弹簧动画交互

物理模拟可以让应用程序的交互感觉逼真和互动,例如,你可能希望为一个 Widget 设置动画,使其看起来像是附着在弹簧上或是重力下落。本文章实现了演示了如何使用弹簧模拟将小部件从拖动的点移回中心。实现步骤如下 设置动画控制器 使用手势移动小部件 为小部件制作动...
继续阅读 »

物理模拟可以让应用程序的交互感觉逼真和互动,例如,你可能希望为一个 Widget 设置动画,使其看起来像是附着在弹簧上或是重力下落。本文章实现了演示了如何使用弹簧模拟将小部件从拖动的点移回中心。
实现步骤如下



  1. 设置动画控制器

  2. 使用手势移动小部件

  3. 为小部件制作动画

  4. 计算速度以模拟弹簧运动




1 创建一个动画控制器


首页创建一个测试使用的Demo页面


void main() {
runApp(const MaterialApp(home: PhysicsCardDragDemo()));
}

class PhysicsCardDragDemo extends StatelessWidget {
const PhysicsCardDragDemo({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: const DraggableCard(
child: FlutterLogo(
size: 128,
),
),
);
}
}

DraggableCard 是自定义的一个 StatefulWidget,代码如下:


class _DraggableCardState extends State<DraggableCard> {
@override
void initState() {
super.initState();
}

@override
void dispose() {
super.dispose();
}

@override
Widget build(BuildContext context) {
return Align(
child: Card(
child: widget.child,
),
);
}
}

然后在 _DraggableCardState 中创建一个动画控制器,并在页面销毁的时候释放动画控制器,代码如下:


class _DraggableCardState extends State<DraggableCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Align(
child: Card(
child: widget.child,
),
);
}
}

SingleTickerProviderStateMixin是用来在StatefulWidget中管理单个AnimationController的Mixin;它提供了一个TickerProvider,用于将AnimationController与TickerProviderStateMixin一起使用。


TickerProviderStateMixin提供了一个Ticker,它可以在每个frame中调用AnimationController的方法,这使得AnimationController可以在每个frame中更新动画。


2 使用手势移动Widget


在 _DraggableCardState 中,结合使用 Alignment 与 GestureDetector,代码如下:


class _DraggableCardState extends State<DraggableCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
Alignment _dragAlignment = const Alignment(0, 0);
@override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
return GestureDetector(
onPanDown: (details) {},
onPanUpdate: (details) {
_dragAlignment += Alignment(
details.delta.dx / (size.width / 2),
details.delta.dy / (size.height / 2),
);
setState(() {
});
},
onPanEnd: (details) {},
child: Align(
alignment: _dragAlignment,
child: Card(
child: widget.child,
),
),
);
}

GestureDetector用来检测手势,例如轻触、滑动、拖动等,可以用来实现各种交互效果。


Alignment用于控制子widget在父widget中的位置。可以通过Alignment的构造函数来指定子widget相对于父widget的位置,如Alignment.topLeft表示子widget位于父widget的左上角。也可以通过FractionalOffset来指定子widget相对于父widget的位置,如FractionalOffset(0.5, 0.5)表示子widget位于父widget的中心。Alignment还可以与Stack一起使用,实现多个子widget的定位。
在这里插入图片描述


3 创建一个动画Widget


我们需要实现,当手指抬起时,被移动的 Widget 动画的方式弹回去。


在这里需要一个 Animation ,再定义一个 runAnimation 方法,同时为 第一步创建的动画控制器添加一个更新监听。


class _DraggableCardState extends State<DraggableCard>
with SingleTickerProviderStateMixin {

late AnimationController _controller;
late Animation<Alignment> _animation;
@override
void initState() {
super.initState();
_controller =
AnimationController(vsync: this, duration: const Duration(seconds: 1));

_controller.addListener(() {
setState(() {
_dragAlignment = _animation.value;
});
});
}

void _runAnimation() {
_animation = _controller.drive(
AlignmentTween(
begin: _dragAlignment,
end: Alignment.center,
),
);
_controller.reset();
_controller.forward();
}
}

然后在手指抬起的时候,执行动画,将被移动的 Widget (如这里的图片)以动画的方式移动回原位:


@override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
return GestureDetector(
onPanDown: (details) {
_controller.stop();
},
onPanUpdate: (details) {
_dragAlignment += Alignment(
details.delta.dx / (size.width / 2),
details.delta.dy / (size.height / 2),
);
setState(() {

});
},
onPanEnd: (details) {
_runAnimation();
},
child: Align(
alignment: _dragAlignment,
child: Card(
child: widget.child,
),
),
);
}

在这里插入图片描述


4 计算速度以模拟弹簧运动


最后一步是做一些数学运算,计算小部件完成拖动后的速度。这是为了使小部件在被拍回之前能够以这种速度逼真地继续。(_runAnimation方法已经通过设置动画的开始和结束对齐来设置方向。)


导入包如下:


import 'package:flutter/physics.dart';

onPanEnd回调提供了一个DragEndDetails对象。此对象提供指针停止接触屏幕时的速度。速度以像素每秒为单位,但Align小部件不使用像素。它使用介于[-1.0,-1.0]和[1.0,1.0]之间的坐标值,其中[0.0,0.0]表示中心。步骤2中计算的大小用于将像素转换为该范围内的坐标值。


然后修改 runAnimation 执行动画函数如下:


void _runAnimation(Offset pixelsPerSecond, Size size) {
_animation = _controller.drive(
AlignmentTween(
begin: _dragAlignment,
end: Alignment.center,
),
);

final unitsPerSecondX = pixelsPerSecond.dx / size.width;
final unitsPerSecondY = pixelsPerSecond.dy / size.height;
final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
final unitVelocity = unitsPerSecond.distance;

//它可以用于模拟弹簧的阻尼、质量和刚度等属性,从而实现更加真实的动画效果。
const spring = SpringDescription(
mass: 30,
stiffness: 1,
damping: 1,
);
//SpringSimulation用来模拟一个弹簧的运动,可以用于创建具有弹性的动画效果。
final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);

_controller.animateWith(simulation);
}

然后在手指抬起的时候调用


onPanEnd: (details) {
_runAnimation(details.velocity.pixelsPerSecond, size);
},

作者:早起的年轻人
链接:https://juejin.cn/post/7224433107776389157
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin跨平台第四弹:了解Kotlin/Wasm 前言

前言 前几天,Compose for iOS 发布了Alpha版本,不过早在两个多月前的试验阶段时,我们已经在Compose跨平台第三弹:体验Compose for iOS 为大家分享了Compose开发iOS的体验。 我们也在Compose跨平台第二弹:体验...
继续阅读 »

前言


前几天,Compose for iOS 发布了Alpha版本,不过早在两个多月前的试验阶段时,我们已经在Compose跨平台第三弹:体验Compose for iOS 为大家分享了Compose开发iOS的体验。


我们也在Compose跨平台第二弹:体验Compose for Web 中了解了如何使用Compose开发Web程序,当时也一起见证了Compose for Web 割裂严重的问题。这个问题官方也一直在推进解决,这要得益于Kotlin/Wasm。那么Kotlin/Wasm又是什么呢?


了解Kotlin/Wasm


是什么


Kotlin/Wasm是将Kotlin编译为WebAssembly (Wasm)的工具链。那WebAssembly又是什么呢?


WebAssembly是一种低级字节码格式,可以在Web浏览器中运行,并且具有比JavaScript更快的执行速度和更好的跨平台兼容性。


可以做什么


使用Kotlin/Wasm,我们可以使用Kotlin编写Web应用程序,然后将其编译为Wasm字节码,以在Web浏览器中运行。这样我们就可以在单个代码库中使用相同的语言和工具来开发应用程序,而不必学习JavaScript等其他语言。此外,由于Wasm字节码是一种跨平台格式,因此应用程序可以在各种操作系统和设备上运行,而不必重新编写代码。


简单的说


总之,Kotlin/Wasm是一种新兴的技术,可以让开发人员使用Kotlin编写Web应用程序,并在Web浏览器中运行。这可以使开发更加简单和高效,并提供更好的跨平台兼容性和更快的执行速度。


Kotlin/Wasm 是从 Kotlin 1.8.20版本开始支持的,当前处于实验阶段。


体验Kotlin/Wasm


启用WASM


我们使用最新版本的IntelliJ IDEA,先随便打开一个项目,双击Shift,再弹出的搜索中输入Registry



选中并回车,在弹出的窗口中找到Kotlin.wasm.wizard,勾选此选项。



然后IDEA会提示我们重启,重启后,就启用了wasm。之后,我们就可以通过IDEA创建Kotlin/Wasm项目。


创建Kotlin/Wasm项目


打开IDEA,创建Kotin Multiplatform项目,选择Browser Application with Kotlin/Wasm,如下图所示。



默认情况下,项目将使用带有 Kotlin DSL 的 Gradle 作为构建系统。创建好项目后,在wasmMain目录下为我们创建了Simple.kt文件,如下图所示。



此外,由于Kotlin/Wasm是从1.8.20版本新增的,所以我们要确保配置文件中的版本号是正确的。


plugins {
kotlin("multiplatform") version "1.8.20"
}

运行程序


点击运行程序,运行后在浏览器输入框中输入http://localhost:8080/ ,如下图所示。



这里我使用的是Chrome浏览器,需要在Chrome中输入chrome://flags/#enable-webassembly-garbage-collection,然后启用WebAssembly Garbage Collection,这一点需要注意下。



Hello World程序运行之后,我们可以修改自己想要的展示的文字,比如修改代码如下所示:


fun main() {
document.body?.appendText("Hello, first Kotlin/Wasm Project!")
}

运行程序,如下图所示。



Kotlin/Wasm 可以使用来自 Kotlin 的 JavaScript 代码和来自 JavaScript 的 Kotlin 代码。也就是Kotlin和JavaScript是可以互相操作的。这并不是我们的重点这里就不演示了。


体验Wasm版本的Compose for Web


Compose跨平台第二弹:体验Compose for Web中,我们使用的是“compose-html”,Kotlin/Wasm在Web浏览器中可以实现更高性能和低延迟的计算机程序。


当前依赖于Kotlin/Wasm的“compose-wasm-canvas”已经在实验阶段,而“compose-wasm-canvas”基本可以解决我们之前所体验到的割裂问题。我们来一起体验一下。


项目配置


由于“compose-wasm-canvas”还处于实验结算所以我们在确保版本号、配置可用,修改配置文件代码如下所示:


kotlin.version=1.8.20
org.jetbrains.compose.experimental.jscanvas.enabled=true
compose.version=1.4.0-dev-wasm06

代码编写


@OptIn(ExperimentalComposeUiApi::class)
fun main() {
CanvasBasedWindow {
LoginUi()
}
}

外层使用CanvasBasedWindow包裹,test就是我们自己写的Compose代码,这里我们两个输入框和一个登陆按钮,代码如下所示:


@Composable
fun LoginUI() {

var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }

Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("userName") },
placeholder = { Text("input userName") },
modifier = Modifier.fillMaxWidth()
)

OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("password") },
placeholder = { Text("input password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)

Button(onClick = {
//login
}) {
Text("Login")
}
}
}

这就是和Android中Compose完全一样的代码,运行程序,结果如下图所示。



总结


“compose-wasm-canvas”与“compose-html”完全不同,并且解决了我们之前所提到的在Compose for Web中严重的割裂问题,不过,不管是“compose-wasm-canvas”还是Kotlin/Wasm都还处于早期的实验性阶段,什么时候发布Aplha甚至是Beta版本,是个未知数,让我们一起期待吧~


作者:黄林晴
链接:https://juejin.cn/post/7224718832786325559
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

面试串讲009-布局层级太多怎么优化?

问题: 布局层级太多怎么优化? 回答: View整体布局是通过深度优先的方式来进行组织的,整体形似一颗树,所以优化布局层级主要通过三个方向来实施: 降低布局深度:使用merge标签或者布局层级优化等手段来减少View树的深度; 布局懒加载:使用ViewStu...
继续阅读 »

问题:


布局层级太多怎么优化?


回答:


View整体布局是通过深度优先的方式来进行组织的,整体形似一颗树,所以优化布局层级主要通过三个方向来实施:



  • 降低布局深度:使用merge标签或者布局层级优化等手段来减少View树的深度;

  • 布局懒加载:使用ViewStub,AsyncLayoutInflater等布局加载手段,来确保只有当需要该布局时,该布局才会被创建,优化布局加载速度;

  • 布局重用:通过include等标签重用界面布局,减少GPU重复工作


解析:


<merge/>


<merge/>标签通常用于将其包裹的内容直接添加到父布局以达到降低布局深度的目的,一个普通的layout布局文件及其结构如下图所示:


mianshi009-1


当将该布局文件的根标签修改为<merge/>标签后,得到的布局结构如下图所示:


mianshi009-2


可以看出<merge/>标签内子元素的父布局均变更为顶上的FrameLayout,进而使得布局深度减1.


结合以上例子,我们可以得出 <merge/>标签的主要工作原理是将本应在<merge/>标签节点的Layout与该节点的父布局进行重用,以达到优化布局深度的目的,对<merge/>标签内包含的其他布局结构而言并不能起到优化深度的作用


使用<merge/>标签有以下注意事项:



  • 布局文件中<merge/>标签只能作为根标签;

  • 使用LayoutInflater加载<merge/>标签为根的布局文件时,必须设置attachToRoot为true,以确保重用父布局;

  • <merge/>标签携带的参数没有实际意义

  • <merge/>标签并不是真实存在的View或者ViewGroup,其相当于一种标记,用来表示其所包裹的内容应被添加到其上级布局,真实存在的ViewGroup是引用<merge/>标签布局的上一级布局


<ViewStub/>


<ViewStub/>标签通常用于声明布局中可以被延时加载的部分,在首次布局文件加载时处于占位状态,当调用inflate或者setVisible时才会完成加载动作,一个普通的使用<ViewStub/>布局文件及其结构如下图所示:


mianshi009-3


当执行ViewStub.inflate之后,得到的布局结构如下图所示:


mianshi009-4


可以看出ViewStub区域被其对应的布局结构替换掉了。


结合上述例子,我们可以得出使用<ViewStub/>标签可以管理在页面首次初始化时不需要加载的布局,提升渲染速度,等到需要这部分UI时再进行加载


<include/>


<include/>标签可以将一些公共布局文件在多处重复引用,以便提升布局效率,例如各个页面都有的状态栏,当使用自定义布局实现后,则可以使用<include/>标签进行重复引用。


<include/>标签使用示例代码如下:


 <?xml version="1.0" encoding="utf-8"?>
 <merge xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent">
 
     <LinearLayout
         android:layout_width="wrap_content"
         android:layout_height="wrap_content">
 
         <include
             android:id="@+id/view_stub"
             layout="@layout/test"/>
 
         <com.poseidon.looperobserver.customview.CustomView
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_gravity="center"
             android:id="@+id/custom_view"
             android:text="move me!" />
 
     </LinearLayout>
 
 </merge>

使用<include/>标签得到的布局结构如下图所示:


mianshi009-5


可以看出从布局结构来讲并无明显差异,在初次加载就会直接构建在View树上。


作者:小海编码日记
链接:https://juejin.cn/post/7224078438722666557
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android中用ViewModel优雅地管理数据

前言 将应用的界面数据与界面(Activity/Fragment)分离可以让您更好地遵循我们前面讨论的单一责任原则。activity 和 fragment 负责将视图和数据绘制到屏幕上,而 ViewModel 则负责存储并处理界面所需的所有数据。 数据变量从 ...
继续阅读 »

前言


将应用的界面数据与界面(Activity/Fragment)分离可以让您更好地遵循我们前面讨论的单一责任原则。activity 和 fragment 负责将视图和数据绘制到屏幕上,而 ViewModel 则负责存储并处理界面所需的所有数据。


数据变量从 XXFragment 移至 XXViewModel



  1. 将数据变量 scorecurrentWordCountcurrentScrambledWord 移至 XXViewModel 类。


class XXViewModel : ViewModel() {

private var score = 0
private var currentWordCount = 0
private var currentScrambledWord = "test"
...


请注意这些属性仅对 ViewModel 可见,界面无法对其进行访问



想要解决此问题,就不能将这些属性的可见性修饰符设为 public,不应该让数据可被其他类修改。因为外部类可能会以不符合视图模型中指定的游戏规则的预料外方式对数据做出更改。外部类有可能会将 score 更改为其他错误的值。


ViewModel 之内,数据应可修改,数据应设为 privatevar。而在 ViewModel 之外,数据应可读取但无法修改,因此数据应作为 publicval 公开。为了实现此行为,Kotlin 提供了称为后备属性的功能。


后备属性


使用后备属性,可以从 getter 返回确切对象之外的某些其他内容。


Kotlin 框架会为每个属性生成 getter 和 setter。


对于 getter 和 setter 方法,可以替换其中一个方法或同时替换两个方法,并提供自定义行为。为了实现后备属性,需要替换 getter 方法以返回只读版本的数据。后备属性示例:


private var _count = 0

val count: Int
get() = _count

举例而言,在应用中,需要应用数据仅对 ViewModel 可见:


ViewModel 类之内:



  • _count 属性设为 private 且可变。因此,只能在 ViewModel 类中对其访问和修改。惯例是为 private 属性添加下划线前缀。


ViewModel 类之外:



  • Kotlin 中的默认可见性修饰符为 public,因此 count 是公共属性,可从界面控制器等其他类对其进行访问。由于只有 get() 方法会被替换,所以此属性不可变且为只读状态。当外部类访问此属性时,它会返回 _count 的值且其值无法修改。这可以防止外部类擅自对 ViewModel 内的应用数据进行不安全的更改,但允许外部调用方安全地访问该应用数据的值。


将后备属性添加到 currentScrambledWord



  • XXViewModel 中,更改 currentScrambledWord 声明以添加一个后备属性。现在,只能在 XXViewModel 中对 _currentScrambledWord 进行访问和修改。界面控制器 XXFragment 可以使用只读属性 currentScrambledWord 读取其值。


private var _currentScrambledWord = "test"
val currentScrambledWord: String
get() = _currentScrambledWord


  • XXFragment 中,更新 updateNextWordOnScreen() 方法以使用只读的 viewModel 属性 currentScrambledWord


private fun updateNextWordOnScreen() {
binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
}


  • XXFragment 中,删除 onSubmitWord()onSkipWord() 方法内的代码。稍后您将实现这些方法。现在,您应该能够不出错误地编译代码了。


注意:勿公开 ViewModel 中的可变数据字段,确保无法从其他类修改此数据。ViewModel 内的可变数据应始终设为 private


作者:子不语Any
链接:https://juejin.cn/post/7224496484254810167
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »