注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

实现一个简易的热🥵🥵更新

简单模拟一个热更新 什么是热更新 热更新是指在应用程序运行时,对程序的部分内容进行更新,而无需重启整个应用程序。通常,热更新是在不停止整个应用程序的情况下,将新的代码、资源或配置应用于正在运行的应用程序,以实现功能修复、性能优化或新增功能等目的。 热更新的...
继续阅读 »

简单模拟一个热更新


什么是热更新



热更新是指在应用程序运行时,对程序的部分内容进行更新,而无需重启整个应用程序。通常,热更新是在不停止整个应用程序的情况下,将新的代码、资源或配置应用于正在运行的应用程序,以实现功能修复、性能优化或新增功能等目的。



热更新的优点


实时反馈,提高开发效率:热更新允许开发人员在进行代码更改后立即看到结果,无需重新编译和重启应用程序。这大大减少了开发、测试和调试代码所需的时间,提高了开发效率。


保持应用程序状态:通过热更新,应用程序的状态可以在代码更改时得以保留。这意味着开发人员不需要在每次更改后重新导航到特定页面或重现特定的应用程序状态,从而节省了时间和精力。


webpack 中的热更新



在Webpack中,热更新(Hot Module Replacement,HMR)是一种通过在应用程序运行时替换代码的技术,而无需重新加载整个页面或应用程序。它提供了一种高效的开发方式,可以在开发过程中快速看到代码更改的效果,而不中断应用程序的状态。



原理如下:


客户端连接到开发服务器:当启动Webpack开发服务器时,它会在浏览器中创建一个WebSocket连接。


打包和构建模块:在开发模式下,Webpack会监视源代码文件的变化,并在代码更改时重新打包和构建模块。这些模块构成了应用程序的各个部分。


将更新的模块发送到浏览器:一旦Webpack重新构建了模块,它会将更新的模块代码通过WebSocket发送到浏览器端。


浏览器接收并处理更新的模块:浏览器接收到来自Webpack的更新模块代码后,会根据模块的标识进行处理。它使用模块热替换运行时(Hot Module Replacement Runtime)来执行以下操作:(个人理解就像是 AJAX 局部刷新的过程,不对请指出)


(1)找到被替换的模块并卸载它。


(2)下载新的模块代码,并对其进行注入和执行。


(3)重新渲染或更新应用程序的相关部分。


保持应用程序状态:热更新通过在模块替换时保持应用程序状态来确保用户不会失去当前的状态。这意味着在代码更改后,开发人员可以继续与应用程序进行交互,而无需手动重新导航到特定页面或重现特定状态。


代码模拟



在同一个目录下创建 server.js 和 watcher.js



server.js

const http = require("http");
const server = http.createServer((req, res) => {
res.statusCode = 200;
// 设置字符编码为 UTF-8,若有中文也不乱码
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("offer get!!!");
});

server.listen(7777, () => {
console.log("服务已启动在 7777 端口");
process.send("started");
});

// 监听来自 watcher.js 的消息
process.on("message", (message) => {
if (message === "refresh") {
// 重新加载资源或执行其他刷新操作
console.log("重新加载资源");
}
});


(1)使用 http.createServer 方法创建一个 HTTP 服务器实例,该服务器将监听来自客户端的请求。


(2)启动服务,当服务器成功启动并开始监听指定端口时,回调函数将被调用。


(3)通过调用 process.send 方法,我们向父进程发送一条消息,消息内容为字符串 "started",表示服务器已经启动。


(4)使用 process.on 方法监听来自父进程的消息。当收到名为 "refresh" 的消息时,回调函数将被触发。


(5)在回调函数中,我们可以执行资源重新加载或其他刷新操作。一般是做页面的刷新操作。


server.js 创建了一个简单的 HTTP 服务器。当有请求到达时,服务器会返回 "offer get!!!" 的文本响应。它还与父进程进行通信,当收到名为 "refresh" 的消息时,会执行资源重新加载操作。


watcher.js

const fs = require("fs");
const { fork } = require("child_process");

let childProcess = null;

const watchFile = (filePath, callback) => {
fs.watch(filePath, (event) => {
if (event === "change") {
console.log("文件已经被修改,重新加载");

// 如果之前的子进程存在,终止该子进程
childProcess && childProcess.kill();

// 创建新的子进程
childProcess = fork(filePath);
childProcess.on("message", callback);
}
});
};

const startServer = (filePath) => {
// 创建一个子进程,启动服务器
childProcess = fork(filePath);
childProcess.on("message", () => {
console.log("服务已启动!");
// 监听文件变化
watchFile(filePath, () => {
console.log("文件已被修改");
});
});
};

// 注意文件的相对位置
startServer("./server.js");


watcher.js 是一个用于监视文件变化并自动重新启动服务器的脚本。


(1)首先,引入 fs 模块和 child_processfork 方法,fork 方法是 Node.js 中 child_process 模块提供的一个函数,用于创建一个新的子进程,通过调用 fork 方法,可以在主进程中创建一个全新的子进程,该子进程可以独立地执行某个脚本文件。


(2)watchFile 函数用于监视指定文件的变化。当文件发生变化时,会触发回调函数。


(3)startServer 函数负责启动服务器。它会创建一个子进程,并以指定的文件作为脚本运行。子进程将监听来自 server.js 的消息。


(4)在 startServer 函数中,首先创建了一个子进程来启动服务器并发送一个消息表示服务器已启动。


(5)然后,通过调用 watchFile 函数,开始监听指定的文件变化。当文件发生变化时,会触发回调函数。


(6)回调函数中,会终止之前的子进程(如果存在),然后创建一个新的子进程,重新运行 server.js


watcher.js 负责监视特定文件的变化,并在文件发生变化时自动重新启动服务器。它使用 child_process 模块创建子进程来运行 server.js,并在需要重新启动服务器时终止旧的子进程并创建新的子进程。这样就可以实现服务器的热更新,当代码发生变化时,无需手动重启服务器,watcher.js 将自动处理这个过程。


效果图


打开本地的 7777 端口,看到了 'offet get!!!' 的初始字样




当修改了响应文字时,刷新页面,无需重启服务,也能看到更新的字样!


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

JavaScript中return await究竟有无用武之地?

web
我先回答:有的,参考文章末尾。 有没有区别?  先上一个Demo,看看async函数中return时加和不加await有没有区别: function bar() { return Promise.resolve('this from bar().'); ...
继续阅读 »

我先回答:有的,参考文章末尾。



有没有区别?


 先上一个Demo,看看async函数中return时加和不加await有没有区别:


function bar() {
return Promise.resolve('this from bar().');
}

async function foo1() {
return await bar(); // CASE#1 with await
}

async function foo2() {
return bar(); // CASE#2 without await
}

// main
(() => {
foo1().then((res) => {
console.log('foo1:', res); // res is string: 'this from bar().'
})
foo2().then((res) => {
console.log('foo2:', res); // res is string: 'this from bar().'
})
})();

 可能在一些社区或团队的编程规范中,有明确要求:不允许使用非必要的 return await。给出的原因是这样做对于foo函数而言,会增加等待bar函数返回的Promise出结果的时间(但其实它可以不用等,因为马上就要return了嘛,这个时间应留给foo函数的调用者去等)。


 如果你觉得上面的文字不大通顺,直接看代码,问:以上例子中,foo1()函数和foo2()函数的写法对程序的执行过程有何影响?


 先说结论:async 函数中 return await promise;return promise; 从宏观结果来看是一样的,但微观上有区别。


有什么区别?


 基于上面的Demo改造一下,做个试验:


const TAG = Symbol();
const RESULT = Promise.resolve('return from bar().');
RESULT[TAG] = 'TAG#RESULT';

function bar() {
return RESULT;
}

async function foo1() {
return await bar();
}

async function foo2() {
const foo2Ret = bar();
console.log('foo2Ret(i):', foo2Ret[TAG], foo2Ret === RESULT); // 'TAG#RESULT', true (1)
return foo2Ret; // without await
}

// main
(() => {
const foo1Ret = foo1();
console.log('foo1Ret:', foo1Ret[TAG], foo1Ret === RESULT); // undefined, false (2)
console.log('--------------------------------------------');
const foo2Ret = foo2();
console.log('foo2Ret(o):', foo2Ret[TAG], foo2Ret === RESULT); // undefined, false (3)
})();

 从注释标注的执行结果可以看到:



  • (1)处没有疑问,foo2Ret 本来就是 RESULT

  • (2)处应该也没有疑问,foo1Ret 是基于 RESULT 这个Promise的结果重新包装的一个新的Promise(只是这个Promise的结果和Result是一致的);

  • (3)处应该和常识相悖,竟然和(2)不一样?是的,对于 async 函数不管return啥都会包成Promise,而且不是简单的通过 Pomise.resolve() 包装。


 那么结论就很清晰了,async 函数中 return await promise;return promise; 至少有两个区别:



  1. 对象上的区别:

    • return await promise; 会先把promise的结果解出来,再构造成新的Promise

    • return await promise; 直接在promise的基础上构造Promise,也就是套了两个Promise(两层Promise的状态和结果是一致的)



  2. 时间上的区别:假设 bar() 函数耗时 10s

    • foo1() 中的写法会导致这10s消耗在 foo1() 函数的执行上

    • foo2() 的写法则会让10s的消耗在 foo2() 函数的调用者侧,也就是注释为main的匿名立即函数




 从对象上的区别看,不论怎样async函数都会构造新的Promise对象,有无await都节约不了内存;从时间上来看,总体的等待时长理论上是一样的,怎么写对结果都没啥影响嘛。


 举个不大恰当的例子:你的上司交给你一个重要任务让你完成后发邮件给他,你分析了下后发现任务需要同事A做一部分,遂找他。同事A完成他的部分需要2天。这个时候你有两个做法选择:一、做完自己的部分后等着A出结果,有结果后再发邮件回复上司;二、将自己的部分完成后汇报给上司,并跟和上司说已经告知A:让A等完成他的部分后直接回邮件给上司。


 如果,我是说假如果哈,如果,这个重要任务本来要求必须在12h内完成,但实际耗时了两天严重超标......请问上述例子中哪种做法更容易获取N+1大礼包?


到底怎么写?


 回到代码层,通过上述分析可以知道,一个主要是耗时归属问题,一个是async函数“总是”会返回的那个Promise对象不是由Promise.resolve()简单包装的(因为Promise.resolve(promise) === promise),可以得到两个编码指南:



强调下,async函数不是通过Pomise.resolve()简单包装的,其实进一步思考下也不难理解,因为它要考虑执行有异常的场景,甚至还可能根据不同的Promise状态做一些其他的操作(比如日志输出、埋点统计?我瞎猜的)



// 避免非必要的 return await 影响模块耗时统计的准确性
async function foo() {
return bar();
}

// 除非你要处理执行过程中的异常
async function foo() {
try {
return await bar();
} catch (_) {
return null;
}
}
// 或:
async function foo() {
return bar().catch(() => null);
}

// async 函数中避免对返回值再使用多余的 Pomise 包装
async function bar() {
return 'this is from bar().'; // YES
}
async function bar() {
return Promise.resolve('this is from bar().'); // !!! NO !!!
}

回到标题:JavaScript中return await有无用武之地?


答:有的,当需要消化掉依赖 Promise

作者:Chavin
来源:juejin.cn/post/7268593569781350455
执行中的异常时。

收起阅读 »

吐槽大会,来瞧瞧资深老前端写的垃圾代码

web
阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉 忍无可忍,不吐不快。 本期不写技术文章,单纯来吐槽下公司项目里的奇葩代码,还都是一些资深老前端写的,希望各位对号入座。 知道了什么是烂代码,才能写出好代码。 别说什么代码和人有一个能跑就行的话,玩笑归玩笑...
继续阅读 »

阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉



忍无可忍,不吐不快。


本期不写技术文章,单纯来吐槽下公司项目里的奇葩代码,还都是一些资深老前端写的,希望各位对号入座。


知道了什么是烂代码,才能写出好代码。


别说什么代码和人有一个能跑就行的话,玩笑归玩笑。


人都有菜的时候,写出垃圾代码无可厚非,但是工作几年了还是写垃圾代码,有点说不过去。


我认为的垃圾代码,不是你写不出深度优先、广度优先的算法,也不是你写不出发布订阅、策略模式的 if else


我认为垃圾代码都有一个共性:看了反胃。就是你看了天然的不舒服,看了就头疼的代码,比如排版极丑,没有空格、没有换行、没有缩进。


优秀的代码就是简洁清晰,不能用策略模式优化 if else 没关系,多几行代码影响不大,你只要清晰,就是好代码。


有了差代码、好代码的基本标准,那我们也废话不多说,来盘盘这些资深前端的代码,到底差在哪里。


---------------------------------------------更新------------------------------------------------


集中回答一下评论区的问题:


1、项目是原来某大厂的项目,是当时的外包团队写的,整体的代码是还行的。eslintcommitlint 之类的也是有的,只不过这些都是可以禁用的,作用全靠个人自觉。


2、后来项目被我司收购,原来的人也转为我司员工,去支撑其他项目了。这个项目代码也就换成我们这批新来的维护了。很多代码是三几年前留下的确实没错,谁年轻的时候没写过烂代码,这是可以理解的。只不过同为三年前的代码,为啥有人写的简单清晰,拆分清楚,有的就这么脏乱差呢?


3、文中提到的很多代码其实是最近一年写的,写这些功能的前端都是六七年经验的,当时因为项目团队刚组建,没有前端,抽调这些前端过来支援,写的东西真的非常非常不规范,这是让人难以理解的,他们在原来的项目组(核心产品,前端基建和规范都很厉害)也是这么写代码的吗?


4、关于团队规范,现在的团队是刚成立没多久的,都是些年轻人,领导也不是专业前端,基本属于无前端 leader 的状态,我倒是很想推规范,但是我不在其位,不好去推行这些东西,提了建议,领导是希望你简单写写业务就行(说白了我不是 leader 说啥都没用)。而且我们这些年轻前端大家都默认打开 eslint,自觉性都很高,所以暂时都安于现状,代码 review 做过几次,都没太大的毛病,也就没继续做了。


5、评论区还有人说想看看我写的代码,我前面文章有,我有几个开源项目,感兴趣可以自己去看。项目上的代码因为涉及公司机密,没法展示。


6、本文就是篇吐槽,也不针对任何人,要是有人看了不舒服,没错,说的就是你。要是没事,大家看了乐呵乐呵就行。轻喷~


文件命名千奇百怪


同一个功能,都是他写的,三个文件夹三种奇葩命名法,最重要的是第三个,有人能知道它这个文件夹是什么功能吗?这完全不知道写的什么玩意儿,每次我来看代码都得重新理一遍逻辑。


image.png


组件职责不清


还是刚才那个组件,这个 components 文件夹应该是放各个组件的,但是他又在底下放一个 RecordDialog.jsx 作为 components 的根组件,那 components 作为文件名有什么意义呢?


image.png


条件渲染逻辑置于底层


这里其实他写了几个渲染函数,根据客户和需求的不同条件性地渲染不同内容,但是判断条件应该放在函数外,和函数调用放在一起,根据不同条件调用不同渲染函数,而不是函数内就写条件,这里就导致逻辑内置,过于分散,不利于维护。违反了我们常说的高内聚、低耦合的编程理念。


image.png


滥用、乱用 TS


项目是三四年前的项目了,主要是类组件,资深前端们是属于内部借调来支援维护的,发现项目连个 TS 都没有,不像话,赶紧把 TS 配上。配上了,写了个 tsx 后缀,然后全是无类型或者 anyscript。甚至于完全忽视 TSESlint 的代码检查,代码里留下一堆红色报错。忍无可忍,我自己加了类型。


image.png


留下大量无用注释代码和报错代码


感受下这个注释代码量,我每次做需求都删,但是几十个工程,真的删不过来。


image.png


image.png


丑陋的、隐患的、无效的、混乱的 css


丑陋的:没有空格,没有换行,没有缩进


隐患的:大量覆盖组件库原有样式的 css 代码,不写类名实现 namespace


无效的:大量注释的 css 代码,各种写了类名不写样式的垃圾类名


混乱的:从全局、局部、其他组件各种引入 css 文件,导致样式代码过于耦合


image.png


一个文件 6 个槽点


槽点1:代码的空行格式混乱,影响代码阅读


槽点2:空函数,写了函数名不写函数体,但是还调用了!


槽点3:函数参数过多不优化


槽点4:链式调用过长,属性可能存在 undefined 或者 null 的情况,代码容易崩,导致线上白屏


槽点5:双循环嵌套,影响性能,可以替换为 reduce 处理


槽点6:参数、变量、函数的命名随意而不语义化,完全不知道有何作用,且不使用小驼峰命名


image.png


变态的链式取值和赋值


都懒得说了,各位观众自己看吧。


image.png


代码拆分不合理或者不拆分导致代码行数超标


能写出这么多行数的代码的绝对是人才。


尽管说后来维护的人加了一些代码,但是其初始代码最起码也有近 2000 行。


image.png


这是某个功能的数据流文件,使用 Dva 维护本身代码量就比 Redux 少很多了,还是能一个文件写出这么多。导致到现在根本不敢拆出去,没人敢动。


image.png


杂七杂八的无用 js、md、txt 文件


在我治理之前,充斥着非常多的无用的、散乱的 md 文档,只有一个函数却没有调用的 js 文件,还有一堆测试用的 html 文件。


实在受不了干脆建个文件夹放一块,看起来也要舒服多了。


image.png


less、scss 混用


这是最奇葩的。


image.png


特殊变量重命名


这是真大佬,整个项目基建都是他搭的。写的东西也非常牛,bug 很少。但是大佬有一点个人癖好,例如喜欢给 window 重命名为 G。这虽然算不上大缺点,但真心不建议大家这么做,window 是特殊意义变量,还请别重命名。


const G = window;
const doc = G.document;

混乱的 import


规范的 import 顺序,应该是框架、组件等第三方库优先,其次是内部的组件库、包等,然后是一些工具函数,辅助函数等文件,其次再是样式文件。乱七八糟的引入顺序看着都烦,还有这个奇葩的引入方式,直接去 lib 文件夹下引入组件,也是够奇葩了。


总而言之,css 文件一般是最后引入,不能阻塞 js 的优先引入。


image.png


写在最后


就先吐槽这么多吧,这些都是平时开发过程中容易犯的错误。希望大家引以为戒,不然小心被刀。


要想保持一个好的编码习惯,写代码的时候就得时刻告诉自己,你的代码后面是会有人来看的,不想被骂就写干净点。


我觉得什么算法、设计模式都是次要的,代码清晰,数据流向清晰,变量名起好,基本 80% 不会太差。


不过说这么多,成事在人。


不过我写了一篇讲解如何写出简洁清晰代码的文章,我看不仅是一年前端需要学习,六七年的老前端也需要看看。

作者:北岛贰
来源:juejin.cn/post/7265505732158472249

收起阅读 »

实现一个简易的热🥵🥵更新

web
简单模拟一个热更新 什么是热更新 热更新是指在应用程序运行时,对程序的部分内容进行更新,而无需重启整个应用程序。通常,热更新是在不停止整个应用程序的情况下,将新的代码、资源或配置应用于正在运行的应用程序,以实现功能修复、性能优化或新增功能等目的。 热更新的...
继续阅读 »

简单模拟一个热更新


什么是热更新



热更新是指在应用程序运行时,对程序的部分内容进行更新,而无需重启整个应用程序。通常,热更新是在不停止整个应用程序的情况下,将新的代码、资源或配置应用于正在运行的应用程序,以实现功能修复、性能优化或新增功能等目的。



热更新的优点


实时反馈,提高开发效率:热更新允许开发人员在进行代码更改后立即看到结果,无需重新编译和重启应用程序。这大大减少了开发、测试和调试代码所需的时间,提高了开发效率。


保持应用程序状态:通过热更新,应用程序的状态可以在代码更改时得以保留。这意味着开发人员不需要在每次更改后重新导航到特定页面或重现特定的应用程序状态,从而节省了时间和精力。


webpack 中的热更新



在Webpack中,热更新(Hot Module Replacement,HMR)是一种通过在应用程序运行时替换代码的技术,而无需重新加载整个页面或应用程序。它提供了一种高效的开发方式,可以在开发过程中快速看到代码更改的效果,而不中断应用程序的状态。



原理如下:


客户端连接到开发服务器:当启动Webpack开发服务器时,它会在浏览器中创建一个WebSocket连接。


打包和构建模块:在开发模式下,Webpack会监视源代码文件的变化,并在代码更改时重新打包和构建模块。这些模块构成了应用程序的各个部分。


将更新的模块发送到浏览器:一旦Webpack重新构建了模块,它会将更新的模块代码通过WebSocket发送到浏览器端。


浏览器接收并处理更新的模块:浏览器接收到来自Webpack的更新模块代码后,会根据模块的标识进行处理。它使用模块热替换运行时(Hot Module Replacement Runtime)来执行以下操作:(个人理解就像是 AJAX 局部刷新的过程,不对请指出)


(1)找到被替换的模块并卸载它。


(2)下载新的模块代码,并对其进行注入和执行。


(3)重新渲染或更新应用程序的相关部分。


保持应用程序状态:热更新通过在模块替换时保持应用程序状态来确保用户不会失去当前的状态。这意味着在代码更改后,开发人员可以继续与应用程序进行交互,而无需手动重新导航到特定页面或重现特定状态。


代码模拟



在同一个目录下创建 server.js 和 watcher.js



server.js


const http = require("http");
const server = http.createServer((req, res) => {
res.statusCode = 200;
// 设置字符编码为 UTF-8,若有中文也不乱码
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("offer get!!!");
});

server.listen(7777, () => {
console.log("服务已启动在 7777 端口");
process.send("started");
});

// 监听来自 watcher.js 的消息
process.on("message", (message) => {
if (message === "refresh") {
// 重新加载资源或执行其他刷新操作
console.log("重新加载资源");
}
});


(1)使用 http.createServer 方法创建一个 HTTP 服务器实例,该服务器将监听来自客户端的请求。


(2)启动服务,当服务器成功启动并开始监听指定端口时,回调函数将被调用。


(3)通过调用 process.send 方法,我们向父进程发送一条消息,消息内容为字符串 "started",表示服务器已经启动。


(4)使用 process.on 方法监听来自父进程的消息。当收到名为 "refresh" 的消息时,回调函数将被触发。


(5)在回调函数中,我们可以执行资源重新加载或其他刷新操作。一般是做页面的刷新操作。


server.js 创建了一个简单的 HTTP 服务器。当有请求到达时,服务器会返回 "offer get!!!" 的文本响应。它还与父进程进行通信,当收到名为 "refresh" 的消息时,会执行资源重新加载操作。


watcher.js


const fs = require("fs");
const { fork } = require("child_process");

let childProcess = null;

const watchFile = (filePath, callback) => {
fs.watch(filePath, (event) => {
if (event === "change") {
console.log("文件已经被修改,重新加载");

// 如果之前的子进程存在,终止该子进程
childProcess && childProcess.kill();

// 创建新的子进程
childProcess = fork(filePath);
childProcess.on("message", callback);
}
});
};

const startServer = (filePath) => {
// 创建一个子进程,启动服务器
childProcess = fork(filePath);
childProcess.on("message", () => {
console.log("服务已启动!");
// 监听文件变化
watchFile(filePath, () => {
console.log("文件已被修改");
});
});
};

// 注意文件的相对位置
startServer("./server.js");


watcher.js 是一个用于监视文件变化并自动重新启动服务器的脚本。


(1)首先,引入 fs 模块和 child_processfork 方法,fork 方法是 Node.js 中 child_process 模块提供的一个函数,用于创建一个新的子进程,通过调用 fork 方法,可以在主进程中创建一个全新的子进程,该子进程可以独立地执行某个脚本文件。


(2)watchFile 函数用于监视指定文件的变化。当文件发生变化时,会触发回调函数。


(3)startServer 函数负责启动服务器。它会创建一个子进程,并以指定的文件作为脚本运行。子进程将监听来自 server.js 的消息。


(4)在 startServer 函数中,首先创建了一个子进程来启动服务器并发送一个消息表示服务器已启动。


(5)然后,通过调用 watchFile 函数,开始监听指定的文件变化。当文件发生变化时,会触发回调函数。


(6)回调函数中,会终止之前的子进程(如果存在),然后创建一个新的子进程,重新运行 server.js


watcher.js 负责监视特定文件的变化,并在文件发生变化时自动重新启动服务器。它使用 child_process 模块创建子进程来运行 server.js,并在需要重新启动服务器时终止旧的子进程并创建新的子进程。这样就可以实现服务器的热更新,当代码发生变化时,无需手动重启服务器,watcher.js 将自动处理这个过程。


效果图


打开本地的 7777 端口,看到了 'offet get!!!' 的初始字样


image.png


当修改了响应文字时,刷新页面,无需重启服务,也能看到更新的字样!


image.png

收起阅读 »

孤独的游戏少年

本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。 楔子 又是一个闲暇的周末,正读一年级的我坐在床上,把象棋的旗子全部倒了出来,根据...
继续阅读 »

本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。



楔子


又是一个闲暇的周末,正读一年级的我坐在床上,把象棋的旗子全部倒了出来,根据颜色分为红色和绿色阵营,旗子翻盖为建筑,正面为单位,被子和枕头作为地图障碍,双手不断的移动着双方阵营的象棋,脑海中演练着星级争霸的游戏画面,将两枚不同阵营的旗子进行碰撞后,通过我对两枚旗子主观的判断,一方阵营的旗子直接被销毁,进入回收,一方的单位对建筑物全部破坏取得游戏胜利。因为我的父亲酷爱玩这款游戏,年少的我也被其玩法、画面所深深吸引,不过最主要的还是父亲获胜或者玩累后,能幸运的奖励我玩上几把。星际争霸成了我第一个启蒙游戏,那时候怎么样都获胜不了,直到发现了show me the money。因为Blizzard这个英文单词一直在游戏的启动界面一闪一闪,那时候还以为这款游戏的名字叫Blizzard,最后才发现,其实Blizzard是魔鬼的意思。


纸笔乐趣


小学一二年级的时候家里管得严,电视也不给我看,电脑直接上锁,作为家里的独生子女,没有同龄人的陪伴,闲暇时间要不就看《格林童话》、《安徒生童话》、《伊索寓言》,要不就打开这副象棋在那里自娱自乐。



起源



在某一天的音乐课上,老师喉咙不舒服,在教室播放猫和老鼠给我们观看,正当我看的津津有味,前面的同学小张突然转过头来,问我“要不要跟我玩个游戏”。小孩子一般都比较贪玩,我直接拒绝了他,“我想看猫和老鼠”。小张满脸失落转了回去,因为我是个特别怕伤害别人情绪的人,所以不忍心又用铅笔的橡皮端戳了戳他,问他“什么游戏,好玩不”。他顿时也来了精神,滔滔不绝的介绍起了这款游戏。


“游戏的规则非常的简单~~在一张纸上中间画一条分割线,双方有各自的基地,每人基地都有5点血量,双方通过猜拳,获胜的一方可以在自己阵营画一个火柴人,火柴人可以攻击对面的火柴人和基地,基地被破坏则胜利。管你听没听懂,试一把就完了!”



我呆呆的看着他,“这么无聊,赢了又怎样?”。他沉默了一会,好像我说的的确有点道理,突然想到了一个好办法,“谁获得胜利扇对方一巴掌”。我顿时来了兴趣。随着游戏逐渐深入,我们的猜拳速度和行动力也越来越快,最后发现扇巴掌还是太狠,改为扇对方的手背,这节课以双方手背通红结束了。


游戏改良


这个《火柴人对战》小游戏在班里火了一会儿,但很快就又不火了,我玩着玩着也发现没啥意思,总是觉得缺少点什么,但毕竟我也只是个没有吃APTX4869的小学生,想不出什么好点子。时间一晃,受到九年义务教育的政策,我也成功成为了一名初中生。在一节音乐课上,老师想让我们放松放松,给我们班看猫和老鼠,隔壁同桌小王撕了一张笔记本的纸,问我有没有玩过《火柴人对战》游戏,只能说,熟悉的配方,熟悉的味道。


当天晚上回到家,闲来无事,我在想这个《火柴人游戏》是不是可以更有优化,这种形式的游戏是不是可以让玩家更有乐趣。有同学可能会问,你那个年代没东西玩的吗?既然你诚心诚意发问,那我就大发慈悲的告诉你。玩的东西的确很多,但是能光明正大摆在课桌上玩的基本没有,一般有一个比较新鲜的好玩的东西,都会有一群人围了过来,这时候老师会默默站在窗户旁的阴暗角落,见一个收一个。


坐在家里的椅子上,我整理了一下思绪,突然产生了灵感,将《魔兽争霸》《游戏王》这两个游戏产生化学反应,游戏拥有着资源,单位,建筑,单位还有攻击力,生命值,效果,攻击次数。每个玩家每回合通过摇骰子的方式获得随机能源点,能源能够解锁建筑,建筑关联着高级建筑和单位,通过单位进行攻击,直至对方玩家生命值为0,那么如何在白纸上面显示呢?我想到比较好的解决方案,单位的画像虽然名字叫骷髅,但是在纸上面用代号A表示,建筑骷髅之地用代号1表示。我花了几天时间,弄了两个阵营,不死族和冰结界。立刻就拿去跟同桌试玩了一下,虽然游戏很丰富,但是有一个严重的弊端就是玩起来还挺耗费时间的,而且要人工计算单位之间的扣血量,玩家的剩余生命,在纸片上去完成这些操作,拿个橡皮擦来擦去,突然觉得有点蠢,有点尴尬,突然明白,一张白纸的承受能力是有限的。之后,我再也没有把游戏拿出来玩过,但我没有将他遗忘,而是深深埋藏在我的心里。


筑梦


直到大学期间《炉石传说》横空出世,直到《游戏王》上架网易,直到我的项目组完成1.0后迎来空窗期一个月,我再也蚌埠住了,之前一直都对微信小游戏很有兴趣,每天闲着也是闲着,所以我有了做一个微信小游戏的想法。而且,就做一款在十几年前,就已经被我设计好的游戏。


但是我不从来不是一个好学的人,领悟能力也很低,之前一直在看cocos和白鹭引擎学习文档,也很难学习下去,当然也因为工作期间没有这么多精力去学习,所以我什么框架也不会,不会框架,那就用原生。我初步的想法是,抛弃所有花里胡哨的动效,把基础的东西做出来,再作延伸。第一次做游戏,我也十分迷茫,最好的做法肯定是打飞机————研究这个微信项目如何用js原生,做出一个小游戏。



虽然微信小游戏刚出来的时候看过代码,但是也只是一扫而过,而这次带着目标进行细细品味,果然感觉不一样。微信小游戏打飞机这个项目是js原生使用纯gL的模式编写的,主要就是在canvas这个画布上面作展示和用户行为。

  // 触摸事件处理逻辑
touchEventHandler(e) {
e.preventDefault()

const x = e.touches[0].clientX
const y = e.touches[0].clientY

const area = this.gameinfo.btnArea

if (x >= area.startX
&& x <= area.endX
&& y >= area.startY
&& y <= area.endY) this.restart()
}

点击事件我的理解就是用户点击到屏幕的坐标为(x, y),如果想要一个按钮上面做处理逻辑,那么点击的范围就要落在这个按钮的范围内。当我知道如何在canvas上面做点击行为时,我感觉我已经成功了一半,接下来就是编写基础js代码。


首先这个游戏确定的元素分别为,场景,用户,单位,建筑,资源(后面改用能源替代),我先将每个元素封装好一个类,一边慢慢的回忆着之前游戏是如何设计的,一边编程,身心完全沉浸进去,已经很久很久没有试过如此专注的去编写代码。用了大概三天的时间,我把基本类该有的逻辑写完了,大概长这个样子



上面为敌方的单位区域,下方为我方的单位区域,单位用ABCDEFG表示,右侧1/1/1 则是 攻击力/生命值/攻击次数,通过点击最下方的icon弹出创建建筑,然后创建单位,每次的用户操作,都是一个点击。


一开始我设想的游戏名为想象博弈,因为每个单位每个建筑都只有名称,单位长什么样子的就需要玩家自己去脑补了,我只给你一个英文字母,你可以想象成奥特曼,也可以想象成哥斯拉,只要不是妈妈生的就行。



湿了


游戏虽然基本逻辑都写好了,放到整个微信小游戏界别人一定会认为是依托答辩,但我还是觉得这是我的掌上明珠,虽然游戏没有自己的界面,但是它有自己的玩法。好像上天也认可我的努力,但是觉得这个游戏还能更上一层楼,在某个摸鱼的moment,我打开了微信准备和各位朋友畅谈人生理想,发现有位同学发了一幅图,上面有四个格子,是赛博朋克风格的一位篮球运动员。他说这个AI软件生成的图片很逼真,只要把你想要的图片告诉给这个AI软件,就能发你一幅你所描绘的图片。我打开了图片看了看,说实话,质感相当不错,在一系列追问下,我得知这个绘图AI软件名称叫做midjourney



midjourney



我迫不及待的登录上去,询问朋友如何使用后,我用我蹩脚的英格力士迫不及待的试了试,让midjourney帮我画一个能源的icon,不试不要紧,一试便湿了,眼睛留下了感动地泪水,就像一个阴暗的房间打开了一扇窗,一束光猛地照射了进来。




对比我之前在iconfont下载的免费图标,midjourney提供这些图片简直就是我的救世主,我很快便将一开始的免费次数用完,然后氪了一个30美刀的会员,虽然有点肉痛,但是为了儿时的梦想,这点痛算什么


虽然我查找了一下攻略,别人说可以使用gpt和midjourney配合起来,我也试了一下,效果一般,可能姿势没有对,继续用我的有道翻译将重点词汇翻译后丢给midjourney。midjourney不仅可以四选一,还可以对图片不断优化,还有比例选择,各种参数,但是我也不需要用到那么多额外的功能,总之一个字,就是棒。


但当时的我突然意识到,这个AI如此厉害,那么会不会对现在行业某些打工人造成一定影响呢,结果最近已经出了篇报道,某公司因为AI绘图工具辞退了众多插画师,事实不一定真实,但是也不是空穴来风,结合众多外界名人齐心协力抵制gpt5.0的研发,在担心数据安全之余,是否也在担心着AI对人类未来生活的各种冲击。焦虑时时刻刻都有,但解决焦虑的办法,我有一个妙招,仍然还是奖励自己


门槛


当我把整个小游戏焕然一新后,便兴冲冲的跑去微信开放平台上传我的伟大的杰作。但微信突然泼了我一盆冷水,上传微信小游戏前的流程有点出乎意外,要写游戏背景、介绍角色、NPC、介绍模块等等,还要上传不同的图片。我的小游戏一共就三个界面,有六个大板块要填写,每个板块还要两张不同的图片,我当时人就麻了。我只能创建一个单位截一次图,确保每张图片不一样。做完这道工序,还要写一份自审自查报告。


就算做完了这些前戏,我感觉我的小游戏还是难登大雅之堂,突然,我又想到了这个东西其实是不是也能运行在web端呢,随后我便立刻付诸行动,创建一个带有canvas的html,之前微信小游戏是通过weapp-adapter这个文件把canvas暴露到全局,所以在web端使用canvas的时候,只需要使用document.getElementById('canvas')暴露到全局即可。然后通过http-server对应用进行启动,这个小游戏便以web端的形式运行到浏览器上了,终于也能理解之前为啥微信小游戏火起来的时候,很多企业都用h5游戏稍微改下代码进行搬运,原来两者之间是有异曲同工之妙之处的。


关于游戏





上面两张便是两个种族对应的生产链,龙族是我第一个创建的,因为我自幼对龙产生好感和兴趣,何况我是龙的传人/doge。魔法学院则是稍微致敬一下《游戏王》中黑魔导卡组吧。


其实开发难度最难的莫过于是AI,也就是人机,如何让人机在有限的资源做出合理的选择,是一大难题,也是我后续要慢慢优化的,一开始我是让人机按照创建一个建筑,然后创建一个单位这种形式去做运营展开,但后来我想到一个好的点子,我应该可以根据每个种族的特点,走一条该特点的独有运营,于是人机龙族便有了龙蛋破坏龙两种流派,强度提升了一个档次。


其实是否能上架到微信小游戏已经不重要了,重要的是这个过程带给我的乐趣,一步一步看着这个游戏被创建出来的成就感,就算这个行业受到什么冲击,我需要被迫转行,我也不曾后悔,毕竟是web前端让我跨越了十几年的时光,找到了儿时埋下的种子,浇水,给予阳光,让它在我的心中成长为一棵充实的参天大树


h5地址:hslastudio.com/game/


github地址: github.com/FEA-Dven/wa…


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

禁止别人调试自己的前端页面代码

web
🎈 为啥要禁止? 由于前端页面会调用很多接口,有些接口会被别人爬虫分析,破解后获取数据 为了 杜绝 这种情况,最简单的方法就是禁止人家调试自己的前端代码 🎈 无限 debugger 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点...
继续阅读 »

🎈 为啥要禁止?



  • 由于前端页面会调用很多接口,有些接口会被别人爬虫分析,破解后获取数据

  • 为了 杜绝 这种情况,最简单的方法就是禁止人家调试自己的前端代码


禁止调试


🎈 无限 debugger



  • 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点,因为 debugger 在控制台被打开的时候就会执行

  • 由于程序被 debugger 阻止,所以无法进行断点调试,所以网页的请求也是看不到的

  • 基础代码如下:


/**
* 基础禁止调试代码
*/

(() => {
function ban() {
setInterval(() => {
debugger;
}, 50);
}
try {
ban();
} catch (err) { }
})();

基础禁止调试


🎈 无限 debugger 的对策



  • 如果仅仅是加上面那么简单的代码,对于一些技术人员而言作用不大

  • 可以通过控制台中的 Deactivate breakpoints 按钮或者使用快捷键 Ctrl + F8 关闭无限 debugger

  • 这种方式虽然能去掉碍眼的 debugger,但是无法通过左侧的行号添加 breakpoint


取消禁止对策


🎈 禁止断点的对策



  • 如果将 setInterval 中的代码写在一行,就能禁止用户断点,即使添加 logpointfalse 也无用

  • 当然即使有些人想到用左下角的格式化代码,将其变成多行也是没用的


(() => {
function ban() {
setInterval(() => { debugger; }, 50);
}
try {
ban();
} catch (err) { }
})();

禁止断点


🎈 忽略执行的代码



  • 通过添加 add script ignore list 需要忽略执行代码行或文件

  • 也可以达到禁止无限 debugger


忽略执行的代码


🎈 忽略执行代码的对策



  • 那如何针对上面操作的恶意用户呢

  • 可以通过将 debugger 改写成 Function("debugger")(); 的形式来应对

  • Function 构造器生成的 debugger 会在每一次执行时开启一个临时 js 文件

  • 当然使用的时候,为了更加的安全,最好使用加密后的脚本


// 加密前
(() => {
function ban() {
setInterval(() => {
Function('debugger')();
}, 50);
}
try {
ban();
} catch (err) { }
})();

// 加密后
eval(function(c,g,a,b,d,e){d=String;if(!"".replace(/^/,String)){for(;a--;)e[a]=b[a]||a;b=[function(f){return e[f]}];d=function(){return"\w+"};a=1}for(;a--;)b[a]&&(c=c.replace(new RegExp("\b"+d(a)+"\b","g"),b[a]));return c}('(()=>{1 0(){2(()=>{3("4")()},5)}6{0()}7(8){}})();',9,9,"block function setInterval Function debugger 50 try catch err".split(" "),0,{}));

解决对策


🎈 终极增强防调试代码



  • 为了让自己写出来的代码更加的晦涩难懂,需要对上面的代码再优化一下

  • Function('debugger').call() 改成 (function(){return false;})['constructor']('debugger')['call']();

  • 并且添加条件,当窗口外部宽高和内部宽高的差值大于一定的值 ,我把 body 里的内容换成指定内容

  • 当然使用的时候,为了更加的安全,最好加密后再使用


(() => {
function block() {
if (window.outerHeight - window.innerHeight > 200 || window.outerWidth - window.innerWidth > 200) {
document.body.innerHTML = "检测到非法调试,请关闭后刷新重试!";
}
setInterval(() => {
(function () {
return false;
}
['constructor']('debugger')
['call']());
}, 50);
}
try {
block();
} catch (err) { }
})();

终极增强防调试

收起阅读 »

差点让我崩溃的“全选”功能

web
今天在实现电商结算功能的时候遇到了个全选功能,做之前不以为然,做之后抓狂。为了让自己全身心投入到对这个问题的攻克,我特意将其拿出来先创建一个demo单独实现下。不搞不知道,一搞吓一跳,下面是我demo在浏览器运行的截图: 开始,我是这样写代码的: f...
继续阅读 »

今天在实现电商结算功能的时候遇到了个全选功能,做之前不以为然,做之后抓狂。为了让自己全身心投入到对这个问题的攻克,我特意将其拿出来先创建一个demo单独实现下。不搞不知道,一搞吓一跳,下面是我demo在浏览器运行的截图:


1679229898519.png
开始,我是这样写代码的:


    for (let i = 0; i < aCheckbox.length; i++) {
aCheckbox[i].checked = this.checked;
}
});

for (let i = 0; i < aCheckbox.length; i++) {
aCheckbox[i].addEventListener("click", function () {
for (let index = 0; index < aCheckbox.length; index++) {
if (aCheckbox[index].checked) {
oAllchecked.checked = aCheckbox[index].checked;
} else {
oAllchecked.checked = !aCheckbox[index].checked;
}
}
});
}

点击全选这个功能不难,主要问题出现在如何保证另外两个复选框在其中一个没有选中的情况下,全选的这个复选框没有选中。苦思良久,最后通过查找资料看到了如今的代码:


    aCheckbox[i].addEventListener("click", function () {
let flag = true;
for (let index = 0; index < aCheckbox.length; index++) {
console.log(aCheckbox[index].checked);
if (!aCheckbox[index].checked) {
flag = false;
break;
}
}
oAllchecked.checked = flag;
});
}

功能完美就解决,第一个代码问题的原因是‘aCheckbox[index].checked’这个判断不能解决两个复选框什么时候一个选中一个没选中的问题。这个问题不解决也就不能让全选复选框及时更新正确的选中状态了。


而下面这个代码通过设置一个中间值flag,及时记录每个复选框按钮的选中状态,能准确的赋值给全选功能的复

作者:一个对前端不离不弃的中年菜鸟
来源:juejin.cn/post/7212942861518864421
选框按钮。于是这个需求就解决了~

收起阅读 »

前端使用a链接下载内容增加loading效果

web
问题描述:最近工作中出现一个需求,纯前端下载 Excel 数据,并且有的下载内容很多,这时需要给下载增加一个 loading 效果。 代码如下: // utils.js const XLSX = require('xlsx') // 将一个sheet转成最终...
继续阅读 »

  1. 问题描述:最近工作中出现一个需求,纯前端下载 Excel 数据,并且有的下载内容很多,这时需要给下载增加一个 loading 效果。

  2. 代码如下:


// utils.js
const XLSX = require('xlsx')
// 将一个sheet转成最终的excel文件的blob对象,然后利用URL.createObjectURL下载
export const sheet2blob = (sheet, sheetName) => {
sheetName = sheetName || 'sheet1'
var workbook = {
SheetNames: [sheetName],
Sheets: {}
}
workbook.Sheets[sheetName] = sheet
// 生成excel的配置项
var wopts = {
bookType: 'xlsx', // 要生成的文件类型
bookSST: false, // 是否生成Shared String Table,官方解释是,如果开启生成速度会下降,但在低版本IOS设备上有更好的兼容性
type: 'binary'
}
var wbout = XLSX.write(workbook, wopts)
var blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' })
// 字符串转ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length)
var view = new Uint8Array(buf)
for (var i = 0; i !== s.length; ++i) view[i] = s.charCodeAt(i) & 0xff
return buf
}
return blob
}

/**
* 通用的打开下载对话框方法,没有测试过具体兼容性
* @param url 下载地址,也可以是一个blob对象,必选
* @param saveName 保存文件名,可选
*/

export const openDownloadDialog = (url, saveName) => {
if (typeof url === 'object' && url instanceof Blob) {
url = URL.createObjectURL(url) // 创建blob地址
}
var aLink = document.createElement('a')
aLink.href = url
aLink.download = saveName + '.xlsx' || '1.xlsx' // HTML5新增的属性,指定保存文件名,可以不要后缀,注意,file:///模式下不会生效
var event
if (window.MouseEvent) event = new MouseEvent('click')
else {
event = document.createEvent('MouseEvents')
event.initMouseEvent(
'click',
true,
false,
window,
0,
0,
0,
0,
0,
false,
false,
false,
false,
0,
null
)
}
aLink.dispatchEvent(event)
}

<el-button
@click="clickExportBtn"
>
<i class="el-icon-download"></i>下载数据
</el-button>
<div class="mongolia" v-if="loadingSummaryData">
<el-icon class="el-icon-loading loading-icon">
<Loading />
</el-icon>
<p>loading...</p>
</div>


clickExportBtn: _.throttle(async function() {
const downloadDatas = []
const summaryDataForDownloads = this.optimizeHPPCDownload(this.summaryDataForDownloads)
summaryDataForDownloads.map(summaryItem =>
downloadDatas.push(this.parseSummaryDataToBlobData(summaryItem))
)
// donwloadDatas 数组是一个三维数组,而 json2sheet 需要的数据是一个二维数组
this.loadingSummaryData = true
const downloadBlob = aoa2sheet(downloadDatas.flat(1))
openDownloadDialog(downloadBlob, `${this.testItem}报告数据`)
this.loadingSummaryData = false
}, 2000),

// css
.mongolia {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
color: #409eff;
z-index: 9999;
}
.loading-icon {
color: #409eff;
font-size: 32px;
}


  1. 解决方案探究:




  • 在尝试了使用 $nextTick、将 openDownloadDialog 改写成 Promise 异步函数,或者使用 async/await、在 openDownloadDialog 中添加 loadingSummaryData 逻辑,发现依旧无法解决问题,因此怀疑是 document 添加新元素与 vue 的 v-if 渲染产生冲突,即 document 添加新元素会阻塞 v-if 的执性。查阅资料发现,问题可能有以下几种:



    • openDownloadDialog 在执行过程中执行了较为耗时的同步操作,阻塞了主线程,导致了页面渲染的停滞。

    • openDownloadDialog 的 click 事件出发逻辑存在问题,阻塞了事件循环(Event Loop)。

    • 浏览器在执行 openDownloadDialog 时,将其脚本任务的优先级设置得较高,导致占用主线程时间片,推迟了其他渲染任务。

    • Vue 的批量更新策略导致了 v-if 内容的显示被延迟。




  • 查阅资料后找到了如下几种方案:





      1. 使用 setTimeout 使 openDownloadDialog 异步执行


      clickExport() {
      this.loadingSummaryData = true;

      setTimeout(() => {
      openDownloadDialog(downloadBlob, `${this.testItem}报告数据`);

      this.loadingSummaryData = false;
      });
      }




      1. 对 openDownloadDialog 内部进行优化



      • 避免大循环或递归逻辑

      • 将计算工作分批进行

      • 使用 Web Worker 隔离耗时任务


        • 在编写 downloadWorker.js 中的代码时,要明确这部分代码是运行在一个独立的 Worker 线程内部,而不是主线程中。





            1. 不要直接依赖或者访问主线程的全局对象,比如 window、document 等。这些在 Worker 内都无法直接使用。





            1. 不要依赖 DOM 操作,比如获取某个 DOM 元素。Worker 线程无法访问页面的 DOM。





            1. 代码执行的入口是 onmessage 回调函数,在其中编写业务逻辑。





            1. 和主线程的通信只能通过 postMessage 和 onmessage 发送消息事件。





            1. 代码应该是自包含的,不依赖外部变量或状态。





            1. 可以导入其他脚本依赖,比如用 import 引入工具函数等。





            1. 避免修改或依赖全局作用域,比如定义全局变量等。






        • 总之,编写 downloadWorker.js 时要注意它执行在一个独立环境中,只能通过消息事件与主线程通信。


          // 创建 Worker
          const worker = new Worker('downloadWorker.js');

          // 点击下载时向 Worker 发送消息
          function clickDownload() {

          showLoading();

          worker.postMessage({
          url: fileURL,
          filename: 'report.xlsx'
          });

          worker.onmessage = function(e) {
          // 收到下载完成的消息
          hideLoading();
          }

          }

          // 显示 loading
          function showLoading() {
          loading.style.display = 'block';
          }

          // 隐藏 loading
          function hideLoading() {
          loading.style.display = 'none';
          }

          // downloadWorker.js

          onmessage = function(e) {
          const url = e.data.url;
          const filename = e.data.filename;

          // 创建并点击链接触发下载
          const a = document.createElement('a');
          a.href = url;
          a.download = filename;
          a.click();

          postMessage('下载完成');
          }

          <div id="loading" style="display:none;">
          Downloading...
          </div>

          <button onclick="clickDownload()">
          Download
          </button>

          <script src="downloadWorker.js"></script>








      1. 使用 requestIdleCallback 进行调度


      clickExport() {
      this.loadingSummaryData = true;

      requestIdleCallback(() => {
      openDownloadDialog(downloadBlob, `${this.testItem}报告数据`);

      this.loadingSummaryData = false;
      });
      }



    • 当使用上述方案时发现里面的内容变得异常的慢,于是去问GPT后,得到以下答案:


      使用 requestIdleCallback 来执行 openDownloadDialog 确实可能会需要更多时间。


      因为 requestIdleCallback 会在主线程空闲时才执行回调函数,所以不像 setTimeout 可以直接按指定时间异步执行。


      requestIdleCallback 的优先级较低,只在主线程空闲时才会执行,所以 download 的速度会比 setTimeout 慢一些。


      但使用 requestIdleCallback 的优点是它不会占用主线程时间,可以避免阻塞页面渲染。


      所以需要权衡执行速度和避免阻塞之间的关系:




      • 如果下载速度是优先考虑的因素,可以继续使用 setTimeout 异步执行。




      • 如果避免阻塞渲染更重要,则 requestIdleCallback 是更好的选择。




      偶尔下载任务被延迟不是大问题,但频繁的下载任务阻塞会严重影响页面体验。


      此外,可以结合两者试试:


      clickExport() {

      this.loadingSummaryData = true;

      setTimeout(() => {

      requestIdleCallback(() => {
      openDownloadDialog(downloadBlob);
      });

      this.loadingSummaryData = false;

      }, 200);

      }

      setTimeout 先异步调度,然后在回调中使用 requestIdleCallback 避免阻塞。这样就可以兼顾下载速度和避免渲染阻塞了。





      1. 分析线程模型,找到具体的阻塞点



      • 使用 Performance 工具分析线程

      • debugger 及 console.log 打印关键函数的执行时间

      • 检查是否有非主线程的任务阻塞了主线程





      1. 调整 vue 的批量更新策略


      new Vue({
      config: {
      // 修改批量更新的队列长度
      batchUpdateDuration: 100
      }
      })

      作者:李卫泽
      来源:juejin.cn/post/7268050036474609683
      i>


收起阅读 »

const声明的变量还能修改?原理都在这了

web
前言 const关键字用于声明只读的常量,一旦被赋值,其值就不能再被改变。但是,这并不意味着该变量持有的值是不可变的。 本文将结合案例逐步探讨JavaScript中const声明的工作原理,解释为何这些看似常量的变量却可以被修改,并探讨其实际应用。 耐心看完,...
继续阅读 »

前言


const关键字用于声明只读的常量,一旦被赋值,其值就不能再被改变。但是,这并不意味着该变量持有的值是不可变的。


本文将结合案例逐步探讨JavaScript中const声明的工作原理,解释为何这些看似常量的变量却可以被修改,并探讨其实际应用。


耐心看完,你一定有所收获。


giphy.gif


正文


const关键字用于声明一个变量,该变量的值在其生命周期中不会被重新赋值。


现象


1. 基本数据类型


对于基本数据类型(如数字、字符串、布尔值),const确保变量的值不会改变。


const num = 42;
// num = 43; // 这会抛出错误

2. 对象


对于对象,仍然可以修改对象的属性,但不能重新赋值整个对象。


const girlfriend = {
name: "小宝贝"
};

girlfriend.name = "亲爱的"; // 这是允许的,因为你只是修改了对象的一个属性

// girlfriend = { name: "亲爱的" }; // 这会抛出错误,因为你试图改变obj的引用

假如你有个女朋友,也许并没有,但我们可以假设,她的名字或者你平时叫她的昵称是"小宝贝"。


有一天,你心血来潮,想换个方式叫她,于是叫她"亲爱的"。这完全没问题,因为你只是给她换了个昵称,她本人并没有变。


但是,如果有一天你看到另一个女生,你却说:“哎,这不是亲爱的吗?”这就出大问题了!因为你把一个完全不同的人当成了你的女朋友。


这就像你试图改变girlfriend的引用,把它指向了一个新的对象。


JavaScript不允许这样做,因为你之前已经明确地告诉它,girlfriend就是那个你叫"小宝贝"的女朋友,你不能突然把另一个人说成她。


154eb98c10eaf8356b5da0e44b9e9fe6.gif


简单来说,你可以随时给你的女朋友起个新昵称,但你不能随便把别的女生当成你的女朋友。


3. 数组


对于数组,你可以修改、添加或删除元素,但不能重新赋值整个数组。


const arr = [1, 2, 3];

arr[0] = 4; // 这是允许的,因为你只是修改了数组的一个元素
arr.push(5); // 这也是允许的,因为你只是向数组添加了一个元素

// arr = [6, 7, 8]; // 这会抛出一个错误,因为你试图改变arr的引用

假设arr是你的超市购物袋,里面有三个苹果,分别标记为1、2和3。


你检查了第一个苹果,觉得它不够新鲜,所以你把它替换成了一个新的苹果,标记为4。这就像你修改数组的一个元素。这完全可以,因为你只是替换了袋子里的一个苹果。


后来,你决定再放一个苹果进去,标记为5。这也没问题,因为你只是向袋子里添加了一个苹果。


苹果再变,袋子仍然是原来的袋子。


但是,当你试图拿个新的装着6、7、8的购物袋来代替你原来的袋子时,就不对了。你不能拿了一袋子苹果,又扔在那不管,反而又去拿了一袋新的苹果。


你礼貌吗?


f2e0de05371993107839d315b5639a30.jpg


你可以随时替换袋里的苹果或者放更多的苹果进去,但你不能拿了一袋不要了又拿一袋。


原理


在JavaScript中,const并不是让变量的值变得不可变,而是让变量指向的内存地址不可变。换句话说,使用const声明的变量不能被重新赋值,但是其所指向的内存中的数据是可以被修改的。


使用const后,实际上是确保该变量的引用地址不变,而不是其内容。


结合上面两个案例,女朋友和购物袋就好比是内存地址,女朋友的外号可以改,但女朋友是换不了的,同理袋里装的东西可以换,但袋子仍然是那个袋子。


当使用const声明一个变量并赋值为一个对象或数组,这个变量实际上存储的是这个对象或数组在内存中的地址,形如0x00ABCDEF(这只是一个示例地址,实际地址会有所不同),而不是它的内容。这就是为什么我们说变量“引用”了这个对象或数组。


实际应用


这种看似矛盾的特性实际上在开发中经常用到。


例如,在开发过程中,可能希望保持一个对象的引用不变,同时允许修改对象的属性。这可以通过使用const来实现。


考虑以下示例:


假设你正在开发一个应用,该应用允许用户自定义一些配置设置。当用户首次登录时,你可能会为他们提供一组默认的配置。但随着时间的推移,用户可能会更改某些配置。


// 默认配置
const userSettings = {
theme: "light", // 主题颜色
notifications: true, // 是否开启通知
language: "en" // 默认语言
};

// 在某个时间点,用户决定更改主题颜色和语言
function updateUserSettings(newTheme, newLanguage) {
userSettings.theme = newTheme;
userSettings.language = newLanguage;
}

// 用户调用函数,将主题更改为"dark",语言更改为"zh"
updateUserSettings("dark", "zh");

console.log(userSettings); // 输出:{ theme: "dark", notifications: true, language: "zh" }

在这个例子中,我们首先定义了一个userSettings对象,它包含了用户的默认配置。尽管我们使用const来声明这个对象,但我们仍然可以随后更改其属性来反映用户的新配置。


这种模式在实际开发中很有用,因为它允许我们确保userSettings始终指向同一个对象(即我们不会意外地将其指向另一个对象),同时还能够灵活地更新该对象的内容以反映用户的选择。


为什么不用let


以上所以案例中,使用let都是可行,但它的语义和用途相对不同,主要从这几个方面进行考虑:



  1. 不变性:使用const声明的变量意味着你不打算重新为该变量赋值。这为其他开发人员提供了一个明确的信号,即该变量的引用不会改变。在上述例子中,我们不打算将userSettings重新赋值为另一个对象,我们只是修改其属性。因此,使用const可以更好地传达这一意图。

  2. 错误预防:使用const可以防止意外地重新赋值给变量。如果你试图为const变量重新赋值,JavaScript会抛出错误。这可以帮助捕获潜在的错误,特别是在大型项目或团队合作中。

  3. 代码清晰度:对于那些只读取和修改对象属性而不重新赋值的场景,使用const可以提高代码的清晰度,可以提醒看到这段代码的人:“这个变量的引用是不变的,但其内容可能会变。”


一般我们默认使用const,除非确定需要重新赋值,这时再考虑使用let。这种方法旨在鼓励不变性,并使代码更加可预测和易于维护。


避免修改


如果我们想要避免修改const声明的变量,当然也是可以的。


例如,我们可以使用浅拷贝来创建一个具有相同内容的新对象或数组,从而避免直接修改原始对象或数组。这可以通过以下方式实现:


const originalArray = [1, 2, 3];
const newArray = [...originalArray]; // 创建一个原始数组的浅拷贝
newArray.push(4); // 不会影响原始数组
console.log(originalArray); // 输出: [1, 2, 3]
console.log(newArray); // 输出: [1, 2, 3, 4]

总结


const声明的变量之所以看似可以被修改,是因为const限制的是变量指向的内存地址的改变,而不是内存中数据的改变。这种特性在实际开发中有其应用场景,允许我们保持引用不变,同时修改数据内容。


然而,如果我们确实需要避免修改数据内容,可以采取适当的措施,如浅拷贝。


9a9f1473841eca9a3e5d7e1408145a4b.gif

收起阅读 »

好烦啊,为什么点个链接还让我确认一下?

web
万丈苍穹水更深,无限乾坤尽眼中 背景 最近经常看到各大SNS平台有这样一项功能,点击跳转链接,如果不是本站的链接,那么就会跳转到一个中转页,告诉你跳转了一个非本站链接,提醒你注意账号财产安全,如图: 很明显,这个是为了一定程度的避免用户被钓鱼,预防XSS或...
继续阅读 »

万丈苍穹水更深,无限乾坤尽眼中



背景


最近经常看到各大SNS平台有这样一项功能,点击跳转链接,如果不是本站的链接,那么就会跳转到一个中转页,告诉你跳转了一个非本站链接,提醒你注意账号财产安全,如图:


A6C73047-4041-4584-9F97-BA04C896D73E.png


很明显,这个是为了一定程度的避免用户被钓鱼,预防XSS或CRSF攻击,所以请不要像标题一样抱怨,多点一下也花不了2S时间。


原理


那么这个是如何实现的呢,原理其实很简单。


a标签的onclick事件可以被拦截,当返回false时不会默认跳转。


那么具体如何实现呢,拿掘金来举例:


        function SetSafeA(whiteDomList: string[], safeLink = 'https://link.juejin.cn/?target=') {
          const aArr = document.getElementsByTagName('a')
          Array.from(aArr).forEach(item=>{
            item.onclick = ()  => {
              let target = item.getAttribute('href')!
              if(/^\//.test(target)) {
                // 相对本站链接
                return true
              }
             const isSafe = undefined !==  whiteDomList.find(item=>{
                 return target.indexOf(item) !== -1
              })
              if(!isSafe) {
                window.open(`${safeLink}${target}`, '_blank')
              } else {
return true
}
              return false
            }
          })
        }

可以随便找一个网页在控制台执行一下,都能跳到掘金的中转页,中转页的代码就不写了^_^


实践


刚好最近遇到一个使用场景,公司APP产品里面都有各自用户协议,其中SDK协议我们都是直接跳转链接的,结果在部分渠道如小天才,步步高等对用户信息非常敏感的平台上,要求所有的链接必须要跳转到平台默认的安全浏览器上,不能在APP内打开。那么协议有很多如何快速处理呢。由于项目用到了vue,这里就想到使用指令,通过批量添加指令来达到快速替换,比如'<a' =>'<a v-link="x"',代码如下:


Vue.directive('outlink', {
  bind: (el, binding) => {
    el.outlink = () => {
      if (GetEnv() === 'app') {
        const from = isNaN(+binding.value) ? 1 : +binding.value
        const url = el.getAttribute('href')
        if (url && url !== '' && url != 'javascript:;') {
          window.location.href = `${GetSchemeByFrom(from)}://outside_webview?url=${url}`
        }
        return false
      }
    }
    el.onclick = el.outlink
  },
  unbind: (el) => {
    el.onclick = null
    delete el.outlink
  }
})

这里我们传入了from值来区分APP平台,然后调用APP提供的相应scheme跳转到客户端的默认浏览器,如下:


DE2DFA5F-ED19-4e74-97B6-2D19246D5D84.png


结语


链接拦截可以做好事,也可以做一些hack,希望使用的人保持一颗爱好和平的心;当然遇到让你确认安全的

作者:CodePlayer
来源:juejin.cn/post/7161712791089315877
链接时,也请你保持一颗感谢的心。

收起阅读 »

前端重新部署如何通知用户刷新网页?

1.目标场景 有时候上完线,用户还停留在老的页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。 2.思考解决方案 如何去解决这个问题 思考中... 如果后端可以配合我们的话我们可以使用webSoc...
继续阅读 »



1.目标场景


有时候上完线,用户还停留在老的页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。


2.思考解决方案


如何去解决这个问题
思考中...


如果后端可以配合我们的话我们可以使用webSocket 跟后端进行实时通讯,前端部署完之后,后端给个通知,前端检测到Message进行提示,还可以在优化一下使用EvnentSource 这个跟socket很像只不过他只能后端往前端推送消息,前端无法给后端发送,我们也不需要给后端发送。


以上方案需要后端配合,奈何公司后端都在忙,需要纯前端实现。


重新进行思考...


根据和小伙伴的讨论得出了一个方案,在项目根目录给个json 文件,写入一个固定的key值然后打包的时候变一下,然后代码中轮询去判断看有没有变化,有就提示。




果然是康老师经典不知道。




但是写完之后发现太麻烦了,需要手动配置json文件,还需要打包的时候修改,有没有更简单的方案,
进行第二轮讨论。


第二轮讨论的方案是根据打完包之后生成的script src 的hash值去判断,每次打包都会生成唯一的hash值,只要轮询去判断不一样了,那一定是重新部署了.




3.代码实现

interface Options {
timer?: number
}

export class Updater {
oldScript: string[] //存储第一次值也就是script 的hash 信息
newScript: string[] //获取新的值 也就是新的script 的hash信息
dispatch: Record<string, Function[]> //小型发布订阅通知用户更新了
constructor(options: Options) {
this.oldScript = [];
this.newScript = []
this.dispatch = {}
this.init() //初始化
this.timing(options?.timer)//轮询
}


async init() {
const html: string = await this.getHtml()
this.oldScript = this.parserScript(html)
}

async getHtml() {
const html = await fetch('/').then(res => res.text());//读取index html
return html
}

parserScript(html: string) {
const reg = new RegExp(/<script(?:\s+[^>]*)?>(.*?)<\/script\s*>/ig) //script正则
return html.match(reg) as string[] //匹配script标签
}

//发布订阅通知
on(key: 'no-update' | 'update', fn: Function) {
(this.dispatch[key] || (this.dispatch[key] = [])).push(fn)
return this;
}

compare(oldArr: string[], newArr: string[]) {
const base = oldArr.length
const arr = Array.from(new Set(oldArr.concat(newArr)))
//如果新旧length 一样无更新
if (arr.length === base) {
this.dispatch['no-update'].forEach(fn => {
fn()
})

} else {
//否则通知更新
this.dispatch['update'].forEach(fn => {
fn()
})
}
}

timing(time = 10000) {
//轮询
setInterval(async () => {
const newHtml = await this.getHtml()
this.newScript = this.parserScript(newHtml)
this.compare(this.oldScript, this.newScript)
}, time)
}

}

代码用法

//实例化该类
const up = new Updater({
timer:2000
})
//未更新通知
up.on('no-update',()=>{
console.log('未更新')
})
//更新通知
up.on('update',()=>{
console.log('更新了')
})

4.测试


执行 npm run build 打个包


安装http-server


使用http-server 开个服务




重新打个包npm run build




这样子就可以检测出来有没有重新发布就可以通知用户更新了。


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

作为一个前端为什么要学习 Rust ?

这里抛出一个问题 作为一个前端为什么要去学习 Rust ? 这是个好问题,有人可能觉得前端学习 Rust 意义不大,学习成本高,投入产出比低啥的,JavaScript、TypeScript 这些前端语言还没搞明白呢,为什么还要去学一门这么难的新语言? 那么今天...
继续阅读 »

这里抛出一个问题


作为一个前端为什么要去学习 Rust ?


这是个好问题,有人可能觉得前端学习 Rust 意义不大,学习成本高,投入产出比低啥的,JavaScript、TypeScript 这些前端语言还没搞明白呢,为什么还要去学一门这么难的新语言?


那么今天我就谈谈我自己对于这个问题的看法~,主要是分为 5 点:

  • 性能
  • 跨平台特性
  • 安全性
  • 职业视野
  • 职业竞争力

性能


Rust 可以给 Node.js 提供一个性能逃生通道,当我们使用 Node.js 遇到性能瓶颈或 CPU 密集计算场景的时候,便可以使用 Rust 编写 Native Addon 解决这个问题了,Native Addon 就是一个二进制文件,也就是 xxx.node 文件,比如 swc(对应 babel)、Rspack(对应Webpack)、Rome(对应 eslint、prettier、babel、webpack 等,目标是代替我们所熟悉的所有前端工具链...),上面提到的工具链就都是使用 Rust 编写的,性能比 Node.js 对应功能的包都有了极大的提高,同时 Rust 也是支持多线程的,你编写的多线程代码在 Node.js 中一样可以跑,这就可以解决了 Node.js 不擅长 CPU 密集型的问题。在前端架构领域目前 Rust 已经差不多是标配了,阿里、字节内部的前端基建目前都开始基于 Rust 去重构了。


跨平台


可以编写高性能且支持跨平台的 WebAssembly 扩展,可以在浏览器、IOT 嵌入式设备、服务端环境等地方使用,并且也拥有很不错的性能;和上面提到的 Native Addon 不一样, Native Addon 在不同的平台上都需要单独的进行编译,不支持跨平台;但是 WebAssembly 不一样,虽然它的性能没 Native Addon 好,但是跨平台成本很低,我编写的一份代码在 Node.js 中执行没问题,在 Deno 中跑也没问题,在 Java 或者 Go 中跑也都没问题,甚至在单片机也可以运行,只要引入对应的 Wasm 运行时即可。现在 Docker 也已经有 WebAssembly 版本了;同时 Rust 也是目前编写 WebAssembly 最热门的语言,因为它没有垃圾回收,性能高,并且有一个超好用的包管理器 cargo。


安全


Rust 编译器真的是事无巨细,它保证你编写的代码不会出低级错误,比如一些类型上的错误和内存分配上的错误,基本上只要 Rust 代码能够编译通过,就可以安心上线,在服务端、操作系统等领域来说这也是个很好的特性,Linux 系统和安卓系统内核都已经开始使用 Rust ,这还信不过嘛?


视野


Rust 可以提升自己在服务端领域的视野,Rust 不同于 Node.js 这个使用动态 JS 语言的运行时,它是一门正儿八经的静态编译型编程语言,并且没有垃圾回收,可以让我们掌握和理解计算机的一些底层工作机制,比如内存是如何分配和释放的,Rust 中使用所有权、生命周期等概念来保证内存安全,这对我们对于编程的理解也可以进一步提升,很多人说学习了 Rust 之后对自己编写其它语言的代码也有了更深的理解,毕竟计算机底层的概念都是相通的,开阔自己的编程思维。


职业竞争力


这个问题简单,你比别人多一门技能,比如 WebAssembly 和 Native Addon 都可以作为 Node.js 性能优化的一种手段,面试的时候说你会使用 Rust 解决 Node.js 性能问题,这不是比别人多一些竞争力吗?面试官那肯定也会觉得你顶呱呱~ 另外虽然目前 Rust 的工作机会比较少,但是也不代表没有,阿里和字节目前都有关于前端基建的岗位,会 Rust 是加分项,另外 Rust 在 TIOBE 编程语言榜排名中已经冲进了前 20,今年 6 月份是第 20 名,7 月份是第 17 名,流行度开始慢慢上来了,我相信以后工作机会也会越来越多的。


总结


不过,总的来说,这还是得看自己个人的学习能力,学有余力的时候可以学习一下 Rust,我自己不是 Rust 吹啊,我学习 Rust 的过程中真的觉得很有趣,因为里面的很多概念在前端领域中都是接触不到的,学了之后真的像是打开了新世界的大门,包括可以去看 Deno 的源码了,可以了解到一个 Js 运行时是怎么进行工作的,这些都是与我们前端息息相关的东西,即使哪天不做前端了,可以去转服务端或嵌入式方向,起码编程语言这一关不需要费多大力气了,Rust 是目前唯一一门从计算机底层到应用层都有落地应用的语言。不多说了,学就完事了,技多不压身嘛


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

卸下if-else 侠的皮衣!- 状态模式

web
🤭当我是if-else侠的时候 😶怕出错 给我一个功能,我总是要写很多if-else,虽然能跑,但是维护起来确实很难受,每次都要在一个方法里面增加逻辑,生怕搞错,要是涉及到支付功能,分分钟炸锅 😑难调试 我总是不知道之前写的逻辑在哪里,一个方法几百行逻辑,而且...
继续阅读 »

🤭当我是if-else侠的时候


😶怕出错


给我一个功能,我总是要写很多if-else,虽然能跑,但是维护起来确实很难受,每次都要在一个方法里面增加逻辑,生怕搞错,要是涉及到支付功能,分分钟炸锅


😑难调试


我总是不知道之前写的逻辑在哪里,一个方法几百行逻辑,而且是不同功能点冗余在一起!这可能让我牺牲大量时间在这查找调试中


🤨交接容易挨打


当你交接给新同事的时候,这个要做好新同事的白眼和嘲讽,这代码简直是坨翔!这代码简直是个易碎的玻璃,一碰就碎!这代码简直是个世界十大奇迹!


🤔脱下if-else侠的皮衣


先学习下开发的设计原则


单一职责原则(SRP)



就一个类而言,应该仅有一个引起他变化的原因



开放封闭原则(ASD)



类、模块、函数等等应该是可以扩展的,但是不可以修改的



里氏替换原则(LSP)



所有引用基类的地方必须透明地使用其子类的对象



依赖倒置原则(DIP)



高层模块不应该依赖底层模块



迪米特原则(LOD)



一个软件实体应当尽可能的少与其他实体发生相互作用



接口隔离原则(ISP)



一个类对另一个类的依赖应该建立在最小的接口上



在学习下设计模式


大致可以分三大类:创建型结构型行为型

创建型:工厂模式 ,单例模式,原型模式

结构型:装饰器模式,适配器模式,代理模式

行为型:策略模式,状态模式,观察者模式


之前文章学习了**策略模式适配器模式,有兴趣可以过去看看,下面我们来学习适配器模式**


场景:做四种咖啡的咖啡机


- 美式咖啡(american):只吐黑咖啡
- 普通拿铁(latte):黑咖啡加点奶
- 香草拿铁(vanillaLatte):黑咖啡加点奶再加香草糖浆
- 摩卡咖啡(mocha):黑咖啡加点奶再加点巧克力


用if-else来写,如下


class CoffeeMaker {
constructor() {
/**
这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
**/

// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
}

// 关注咖啡机状态切换函数
changeState(state) {
// 记录当前状态
this.state = state;
if(state === 'american') {
// 这里用 console 代指咖啡制作流程的业务逻辑
console.log('我只吐黑咖啡');
} else if(state === 'latte') {
console.log(`给黑咖啡加点奶`);
} else if(state === 'vanillaLatte') {
console.log('黑咖啡加点奶再加香草糖浆');
} else if(state === 'mocha') {
console.log('黑咖啡加点奶再加点巧克力');
}
}
}


分下下问题,假如我们多了几种格式,我们又要在这个方法体里面扩展,违反了开放封闭原则(ASD),所以我们现在来采用适配器模式来写下:


class CoffeeMaker {
constructor() {
// 初始化状态,没有切换任何咖啡模式
this.state = 'init';
// 初始化牛奶的存储量
this.leftMilk = '500';
}
stateToProcessor = {
that: this,
american() {
this.that.leftMilk = this.that.leftMilk - 100
console.log('咖啡机现在的牛奶存储量是:', this.that.leftMilk)
console.log('吐黑咖啡');
},
latte() {
this.american()
console.log('加点奶');
},
vanillaLatte() {
this.latte();
console.log('再加香草糖浆');
},
mocha() {
this.latte();
console.log('再加巧克力');
}
}

changeState(state) {
this.state = state;
if (!this.stateToProcessor[state]) {
return;
}
this.stateToProcessor[state]();
}
}

const mk = new CoffeeMaker();
mk.changeState('latte');
mk.changeState('mocha');



这个状态模式实际上跟策略模式很像,但是状态模式会关注里面的状态变化,就像上诉代码能检测咖啡牛奶量,去除了if-else,能很好的扩展维护



结尾


遵守设计规则,脱掉if-else的皮衣,善用设计模式,加油,骚年们!给我点点赞,关注下!

作者:向乾看
来源:juejin.cn/post/7267207014382829579

收起阅读 »

老板搞这些事情降低我写码生产力,我把他开除了

web
Hi 大家好我是 ssh,在工作的时候,我经常会感觉到很头疼,觉得自己的时间被各种各样的事情浪费了,可能是没用的会议,写得很垃圾的文档,各种为了达到某些目的而做的 Code Review 和工程化建设等等,正巧看到这篇 Top 7 Things That Ki...
继续阅读 »

Hi 大家好我是 ssh,在工作的时候,我经常会感觉到很头疼,觉得自己的时间被各种各样的事情浪费了,可能是没用的会议,写得很垃圾的文档,各种为了达到某些目的而做的 Code Review 和工程化建设等等,正巧看到这篇 Top 7 Things That Kill Developer Productivity,不敢私藏干货,赶紧来分享给大家,希望能共同避免



简介


几周前,我突然发现:在工作 4 小时内,我的工作时间和有效的编码时间差了整整 2 小时。为了重回正轨,我决定主动减少阻碍,来缩小这个差距,争取能写更多代码,把无关的事情抛开。这个时间差越大,我的效率就越低。


和其他行业的人相比,程序员在这方面遇到的困境更甚。这些障碍往往会导致码农信心下降、写代码和优化的时间变少,职业倦怠率更高。影响创造力和热情。


根据本周 CodeTime 的全球平均编码时间,约 45%的总编码时间都是消极编码。时间和资金都在被浪费。


低效的开发流程是罪魁祸首。


1. 会议


会议


低效的会议是导致开发人员生产力下降的最不必要的因素之一。编程需要心流。平均而言,进入心流状态大约需要 30 分钟。但是由于乱七八糟会议,专注力就被打断了,码农必须重复这个过程。


有时 10 分钟的会议硬拖到一个小时,这也很浪费时间。减少了用于实际编程和解决问题的时间。有些会议还需要码农无用的出席。如果这次会议和码农的专业知识无关,根本没必要让他们参会。


2. 技术债(Fix it Later)


技术债


技术债,简单来说就是“以后再修”的心态。先采用捷径实现,妄图后面有空再修改成更优的方式。


最开始,先让功能可用就行,而优化留到以后。短期来看这可能行得通,因为它可以加快项目进度,你可能在 deadline 前完成。但是反复这么做就会留下大量待完成的工作。会使维护、扩展和优化软件变得更加困难。


技术债会以多种方式阻碍码农的生产力。列举一些:




  • Code Review 的瓶颈:当技术债增加时,会增加 Code Review 所花费的时间。




  • 更多 Bug:由于关注点都在速度而不是优化上,会导致引入隐藏的 Bug。




  • 代码质量降低:只为了让它可以跑,会导致糟糕的代码质量、随意的 Code Review,甚至没有代码注释,随意乱写复杂的代码等。




上述所有点都需要额外的时间来处理。因此,这会拖长项目的时间线。


3. Code Review


Code Review


Code Review 需要时间,如果 Review 时间过长,会延迟新代码的集成并放缓整个开发过程。有时候码农提出 PR 但 Code Reviewer 没有时间进行审查。会码农处理下一任务的时间。在进行下一个任务的同时,再回头 Code Review 时会有上下文切换。会影响码农的专注力和生产力。


对于 Code Review,码农可能不得不参加多个会议,减少了码农的生产力。代码反馈往往不明确或过于复杂,需要进一步讨论来加深理解,解决问题通常需要更长时间。Code Review 对一个组织来说必不可少且至关重要,但是需要注意方式和效率。


4. 微观管理 (Micromanagement)(缺乏自治)


微观管理


微观管理是一种管理方式,主管密切观察和管理下属的工作。在码农的语境下,当管理者想要控制码农的所有编码细节时就发生了。这可能导致码农对他们的代码、流程、决策和创造力的降低。


举例来说:




  • 缺乏动力:微观管理可能表明组织对码农能力的信任不足。这样,码农很容易感到失去动力。




  • 缺乏创造力:开发软件是一项需要专注以探索创造性解决方案的创作性任务。但是微观管理会导致码农对代码的控制较少,阻碍码农的创造力。




  • 决策缓慢:码农必须就简单的决定向管理层寻求确认,在这个过程中大量时间被浪费。




在所有这些情况下,码农的生产力都会下降。


5. 职业倦怠


职业倦怠


职业倦怠是码农面临的主要问题之一。面对复杂具有挑战性的项目和紧迫的 deadline,以及不断提高代码质量的压力都可能导致职业倦怠。这最终会导致码农的生产力下降,会显著减弱码农的注意力和写代码的能力。


这也会导致码农的创造力和解决问题的能力下降。这最终会导致开发周期变慢。


6. 垃圾文档


垃圾文档


文档对码农至关重要,因为它传达有关代码、项目和流程的关键信息。垃圾文档可能会导致开发周期被延迟,因为码农需要花更多时间试图理解代码库、项目和流程。这会导致码农生产力降低。


在码农入职期间,提供垃圾文档会导致码农在设置项目、管理环境、理解代码等任务上花费更多时间。在缺乏清晰文档的情况下,维护和修改现有代码变得困难。由于担心破坏功能,码农可能会犹豫重构或进行更改。因此,码农的生产力将浪费在非核心任务上。


7. 痴心妄想的 Deadline


痴心妄想的Deadline


Deadline 是使码农发疯的原因之一。你必须在较短的时间窗口内完成大量工作时,你会很容易感到沮丧。这可能导致职业倦怠、代码质量差、疏忽 Code Review 等。这将导致技术债的积累。因此,码农的生产力会下降。


Deadline 对计划开发周期是必要的,但是通过设置不切实际的 Deadline 来向码农施加压力,会让他们承受压力。在压力下,整体生产力和代码质量都会下降。


总结


上文提到的会议、技术债积累、拖沓的 Code Review、微观管理、导致职业倦怠的压力、垃圾代码文档以及为项目设置不切实际的 Deadline 等因素会阻碍码农的生产力。我试图阐明软件开发人员在追求效率和创造性解决方案的过程中面临的挑战。


其中的重点是,这些挑战是可以通过在码农的健康和高生产力之间建立平衡来克服的。你可以使用一些码农工具来帮助管理你的生产力、深度专注和工作效率。


下面是一些可以帮助提高生产力的工具:




  • FocusGuard:这是一个 Chrome 扩展,可以通过屏蔽网站帮助你保持专注。




  • Code Time:这是一个 VSCode 扩展,用于跟踪你的编码时间和活动编码时间。




  • JavaScript Booster:这个 VSCode 扩展可以为代码重构提供建议。你也给其他编程语言找找这种扩展。




  • Hatica:虽然上述工具局限于一个任务:专注于编码,但 Hatica 可以处理更多工作。它通过改进工作流程、识别瓶颈和跟踪进度来帮助工程团队提高码农生产力。通过这种方式,它可以给码农节约大量的时间。在这里了解更多关于这个工程管理平台的信息。





作者:ssh_晨曦时梦见兮

来源:juejin.cn/post/7267578376050114614


收起阅读 »

类似chat-gpt的打字机效果

类似chat-gpt的打字机效果 展示效果: 实现思路:只要控制显示内容的长度就行了,每次加一点显示内容,然后一直播放闪烁动画,加载完了就停掉动画。 结论:单个字逐渐加载 + 闪烁动画 = 打字机效果 闪烁动画实现 通过css实现.cursor { ...
继续阅读 »

类似chat-gpt的打字机效果


展示效果:




实现思路:只要控制显示内容的长度就行了,每次加一点显示内容,然后一直播放闪烁动画,加载完了就停掉动画。


结论:单个字逐渐加载 + 闪烁动画 = 打字机效果


闪烁动画实现


通过css实现

.cursor {
position: absolute;
display: inline-block;
width: 2px;
height: 16px;
background-color: #000;
animation: blink 1s infinite;
transform: translate(2px, 3px);
}

@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0;
}
}

展现效果:




完整代码

<!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>
.cursor {
position: absolute;
display: inline-block;
width: 2px;
height: 16px;
background-color: #000;
animation: blink 1s infinite;
transform: translate(2px, 3px);
}

@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.box1 {
line-height: 22px;
width: 300px;
font-size: 16px;
padding: 10px;
border: 1px solid pink;
margin-bottom: 10px;
min-height: 100px;
}
</style>
</head>
<body>
<div class="box1">
<span class="cursor"></span>
</div>
<button class="btn">添加文字</button>
<script>
const randomTextArr = ["萨嘎", '三', "agas", '大厦', '阿萨法施工', 'saf', '啊', '收到', '三个哈哈哈', '阿事实上事实上事实上', '事实上事实上少时诵诗书', '叫哦大家搜狗号度搜化工三打哈干撒的很尬山东干红手打很尬搜哈', '时间几节课MVvvvvvvvvvv啪啪啪啪啪啪PPT科技我IQ和瓦暖气,你', '撒啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊就你跟贵公司懂法守法收入与武器我先把发发花洒就你跟贵公司懂法守法收入与武器我先把发发花洒'];
const box1 = document.querySelector('.box1');
const btn = document.querySelector('.btn');
let showText = '';
let addTextArr = [];
let timer = null;

btn.onclick = () => {
getRandomText();
updateText();
}

function getRandomText() {
const randomTextArrLength = randomTextArr.length;
let randomNum = Math.random();
let addText = randomTextArr[Math.floor(Math.random() * randomTextArrLength)];
addTextArr.push(addText);
console.log(addText)
}

function updateText() {
let index = 0;
if (!timer) {
timer = setInterval(() => {
if (addTextArr.length > 0) {
if (index < addTextArr[0].length) {
box1.innerHTML = showText + addTextArr[0][index] + `<span></span>`;
showText += addTextArr[0][index];
index ++;
} else {
index = 0;
box1.innerHTML = showText;
addTextArr.shift();
}
} else {
clearInterval(timer);
timer = null;
}
}, 50)
}
}
</script>
</body>
</html>

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

让你兴奋不已的13个CSS技巧🤯

快来免费体验ChatGpt plus版本的,我们出的钱 体验地址:chat.waixingyun.cn 可以加入网站底部技术群,一起找bug,另外新版作图神器已上线 cube.waixingyun.cn/home 1.使用边框绘制一个三角形 在某些情况下,例如...
继续阅读 »

快来免费体验ChatGpt plus版本的,我们出的钱 体验地址:chat.waixingyun.cn 可以加入网站底部技术群,一起找bug,另外新版作图神器已上线 cube.waixingyun.cn/home


1.使用边框绘制一个三角形


在某些情况下,例如在工具提示中添加箭头指针时,如果你只需要简单的三角形,那么加载图片可能会过度。


仅使用CSS,您就可以通过边框创建一个三角形。


这是一个相当老的技巧。理想情况下,你会在一个宽度和高度都为零的元素上设置边框。所有的边框颜色都是透明的,除了那个将形成箭头的边框。例如,要创建一个向上指的箭头,底部边框是有颜色的,而左边和右边是透明的。无需包括顶部边框。边框的宽度决定了箭头的大小

.upwards-arrow {
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;

border-bottom: 20px solid crimson;
}

这将创建一个像下面所示的向上指的箭头:




事例地址:codepen.io/chriscoyier…


2.交换元素的背景


z-index 属性规定了元素如何堆叠在其他定位元素上。有时,你可能会设置一个 z-index 属性让子元素的层级较低,结果却发现它隐藏在其父元素的背景之后。为了防止这种情况,你可以在父元素上创建一个新的堆叠上下文,防止子元素隐藏在其后面。创建堆叠上下文的一种方法是使用 isolation: isolate CSS样式声明。


我们可以利用这种堆叠上下文技术来创建悬停效果,该效果可以交换按钮的背景。例如:

button.join-now {
cursor: pointer;
border: none;
outline: none;
padding: 10px 15px;

position: relative;
background-color: #5dbea3;
isolation: isolate; /* If ommitted, child pseudo element will be stacked behind */
}

button.join-now::before {
content: "";
position: absolute;
background-color: #33b249;
top: 0;
left: 100%;
right: 0;
bottom: 0;
transition: left 500ms ease-out;

z-index: -1;
}

button.join-now:hover::before {
left: 0;
}

上述代码在鼠标悬停时交换了 button 的背景。背景的变化不会干扰前景的文本,如下面的gif所示:




3.将元素居中


可能,你已经知道如何使用 display: flex;display: grid; 来居中元素。然而,另一种不太受欢迎的在x轴上居中元素的方法是使用 text-align CSS属性。这个属性在居中文本时就能直接使用。要想在DOM中也居中其他元素,子元素需要有一个 inline 的显示。它可以是 inline-block 或任何其他内联...

div.parent {
text-align: center;
}

div.child {
display: inline-block;
}

4.药丸💊形状按钮


可以通过将按钮的边框半径设置为非常高的值来制作药丸形状的按钮。当然,边框半径应该高于按钮的高度。

button.btn {
border-radius: 80px; /* value higher than height of the button */
padding: 20px 30px;
background-color: #fdd835;
border: none;
color: black;
font-size: 20px;
}



按钮的高度可能会随着设计的改变而增加。因此,你会发现将 border-radius 设置为非常高的值是很方便的,这样无论按钮是否增大,你的css都能继续工作。


5.轻松为你的网站添加美观的加载指示器


对于开发者来说,将注意力转移到为你的网站创建一个美观的加载指示器上往往是一项乏味的任务。这种关注力更好地用于构建项目的其他重要部分,这些部分值得我们去关注。


当你在阅读时,很可能你也觉得这是个令人烦恼的难题。这就是为什么我花时间为你消除这个障碍,并精心准备了一个装有加载指示器的库,让你可以在你的梦想项目中“即插即用”。这是一个完整的集合,你只需要挑选出那个能点燃你心中火花💖的。只需看看这个库的简单用法,源代码在Github上可用。别忘了给个星星⭐


地址:http://www.npmjs.com/package/rea…




6.简易暗色或亮色模式


您只需要几行CSS代码,就可以在我们的网站上启用深色/浅色模式。您只需让浏览器知道,您的网站可以在系统的深色/浅色模式下正确显示。

html {
color-scheme: light dark;
}

注意: color-scheme 属性可以设置在除 html 之外的任何DOM元素上。


然后通过我们的网站设置控制背景颜色和文字颜色的变量,通过检查浏览器支持使其更加防弹:

html {
--bg-color: #ffffff;
--txt-color: #000000;
}

@supports (background-color: Canvas) and (color: CanvasText) {
:root {
--bg-color: Canvas;
--txt-color: CanvasText;
}
}

注意:如果你不在元素上设置 background-color ,它将继承浏览器定义的与深色/浅色主题匹配的系统颜色。这些系统颜色在不同的浏览器之间可能会有所不同。


明确设置 background-color 可以与 prefers-color-scheme 结合使用,以提供与浏览器默认设置不同的颜色阴影。


以下是暗/亮模式的实际应用。用户的偏好在暗模式和亮模式之间进行模拟。




7.使用省略号( ... )截断溢出的文本


这个技巧已经存在一段时间,用于美观地修剪长文本。但你可能仍然错过了它。你只需要以下的CSS:

p.intro {
width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

只需实施以下规则:



  • 明确的宽度,因此剪裁的边界将永远被达到。

  • 浏览器会将超出元素宽度的长文本进行换行。所以你需要阻止这种情况: white-space: nowrap; 。

  • 溢出的内容应被剪裁: overflow: hidden; 。

  • 当文本即将被剪切时,用省略号( ... )填充字符串: text-overflow: ellipsis; 。


结果看起来像这样:




8.将长文本截断为若干行


这与上述技巧略有不同。这次,文本被剪裁,将内容限制为一定的行数。

p.intro {
width: 300px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3; /* Truncate when no. of lines exceed 3 */
overflow: hidden;
}

输出看起来像这样:




9. 停止过度劳累自己写作 toprightbottomleft


在处理定位元素时,你通常会编写如下代码:

.some-element {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}

这可以通过使用 inset 属性来简化:

.some-element {
position: absolute;
inset: 0;
}

或者,如果你对 toprightbottomleft 有不同的值,你可以按照如下的顺序分别设置它们: inset: -10px 0px -10px 0px这种简写方式与margin 的工作方式相同。


10.提供优化过的图片


请尝试在浏览器的开发者工具中将网络速度调整到较慢,然后访问一个由高清图片组成的网站,比如 unsplash。这就是你的网站访客在网络速度较慢的地理区域尝试欣赏你的高清内容时所经历的痛苦。


但你可以通过 image-set CSS 技巧提供一种解救方法。


可以为浏览器提供选项,让它决定最适合用户设备的图片。例如:

.banner {
background-image: url("elephant.png"),
background-image: -webkit-image-set(
url("elephant.webp") type("image/webp") 1x,
url("elephantHD.webp") type("image/webp") 2x,
url("elephant.png") type("image/png") 1x,
url("elephantHD.png") type("image/png") 2x
);
}

上述代码将设置元素的背景图像。


如果支持 -webkit-image-set ,那么背景图像将会是一种优化的图像,也就是说,这将是一种支持的MIME类型的图像,且更适合用户设备的分辨率能力。


例如:由于更高质量的图像直接与更大的尺寸成正比,所以在网络状况差的情况下使用高分辨率设备的用户,会促使浏览器决定提供支持的低分辨率图像。让用户等待高清图像加载是不合逻辑的。


11. 计数器


你不必纠结于浏览器如何渲染编号列表。你可以利用 counters() 实现你自己的设计。以下是操作方法:

ul {
margin: 0;
font-family: sans-serif;

/* Define & Initialize Counter */
counter-reset: list 0;
}

ul li {
list-style: none;
}

ul li:before {
padding: 5px;
margin: 0 8px 5px 0px;
display: inline-block;
background: skyblue;
border-radius: 50%;
font-weight: 100;
font-size: 0.75rem;

/* Increment counter by 1 */
counter-increment: list 1;
/* Show incremented count padded with `.` */
content: counter(list) ".";
}



12.表单验证视觉提示


仅使用CSS,您就可以向用户显示有关表单输入有效性的视觉提示。我们可以在表单元素上使用 :valid:invalid CSS伪类,当其内容验证成功或失败时,应用适当的样式。


请考虑以下HTML页面结构:

<!-- Regex in pattern attribute means input can accept `firstName Lastname` (whitespace sepearated names) -->
<!-- And invalidates any other symbols like `*` -->
<input
type="text"
pattern="([a-zA-Z0-9]\s?)+"
placeholder="Enter full name"
required
/>
<span></span>

<span> 将用于显示验证结果。以下的CSS根据其验证结果来设置输入框的样式:

input + span {
position: relative;
}

input + span::before {
position: absolute;
right: -20px;
bottom: 0;
}

input:not(:placeholder-shown):invalid {
border: 2px solid red;
}

input:not(:placeholder-shown):invalid + span::before {
content: "✖";
color: red;
}

input:not(:placeholder-shown):valid + span::before {
content: "✓";
color: green;
}

地址:codepen.io/hane-smitte…


13. 一键选择文本


这个技巧主要是为了提升网站用户的复制和粘贴体验。使用 user-select: all ,可以通过一键实现简单的文本选择。所有位于该元素下方的文本节点都会被选中。


另一方面,可以使用 user-select: none; 来禁用文本选择。禁用文本选择的另一种方法是将文本放在 ::before::after CSS伪元素的 content: ''; 属性中。


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

谈谈国内前端的三大怪啖

web
因为工作的原因,我和一些外国前端开发有些交流。他们对于国内环境不了解,有时候会问出一些有趣的问题,大概是这些问题的启发,让我反复在思考一些更为深入的问题。 今天聊三个事情: 小程序 微前端 模块加载 小程序 每个行业都有一把银座,当坐上那把银座时,做什么...
继续阅读 »

因为工作的原因,我和一些外国前端开发有些交流。他们对于国内环境不了解,有时候会问出一些有趣的问题,大概是这些问题的启发,让我反复在思考一些更为深入的问题。


今天聊三个事情:



  • 小程序

  • 微前端

  • 模块加载


小程序



每个行业都有一把银座,当坐上那把银座时,做什么便都是对的。



“我们为什么需要小程序?”


第一次被问到这个问题,是因为一个法国的同事。他被派去做一个移动端业务,刚好那个业务是采用小程序在做。于是一个法国小哥就在被痛苦的中文文档和黑盒逻辑中来回折磨着 🤦。


于是,当我们在有一次交流中,他问出了我这个问题:我们为什么需要小程序?


说实话,我试图解释了 19 年国内的现状,以及微信小程序推出时候所带来的便利和体验等等。总之,在我看来并不够深刻的见解。


即便到现在为止,每次当我使用小程序的时候,依旧会复现这个问题。在 ChatGPT 11 月份出来的时候,我也问了它这个很有国内特色的问题:





看起来它回答的还算不错,至少我想如果它来糊弄那些老外,应该会比我做的更好些。


但如果扪心自问,单从技术上来讲。以上这些事情,一定是有其他方案能解决的。


所以从某种程度上来看,这更像是一场截胡的商业案例:


应用市场

全世界的互联网人都知道应用市场是非常有价值的事情,可以说操作系统最值钱的部分就在于他们构建了自己的应用市场。


只要应用在这里注册发行,雁过拔毛,这家公司就是互联网世界里的统治阶级,规则的制定者。


反之则需要受制于人,APP 做的再大,也只是应用市场里的一个应用,做的好与坏还得让应用商店的评判。


另外,不得不承认的是,一个庞大如苹果谷歌这样的公司,他们的应用商店对于普通国内开发者来说,确实是有门槛的。


在国内海量的 APP 需求来临之前,能否提供一个更低成本的解决方案,来消化这些公司的投资?


毕竟不是所有的小企业都需要 APP,其实他们大部分需求 Web 就可以解决,但是 Web 没牌面啊,做 Web 还得砸搜索的钱才有流量。(某度搜索又做成那样...)


那做操作系统?太不容易,那么多人都溺死在水里了,这水太深。


那有没有一种办法可以既能构建生态,又有 APP 的心智,还能给入驻企业提供流量?


于是,在 19 年夏天,深圳滨海大厦下的软件展业基地里,每天都在轮番播放着,做 XX小程序,拥抱下一个风口...


全新体验心智

小程序用起来挺方便的。


你有没有想过,这些美妙感觉的具体都来自哪些?以及这些真的是 Web 技术本身无法提供的吗?



  1. 靠谱感,每个小程序都有约束和规范,于是你可以将他们整整齐齐的陈放在你的列表里,仿佛你已经拥有了这一个个精心雕琢的作品,相对于一条条记不住的网页地址和鱼龙混杂的网页内容来说,这让你觉得小程序更加的有分量和靠谱。

  2. 安全感,沉浸式的头部,没有一闪而过的加载条,这一切无打扰的设计,都让你觉得这是一个在你本地的 APP,而不是随时可能丢失网页。你不会因为网速白屏而感到焦虑,尽管网络差的时候,你的 KFC 依旧下不了单 😂

  3. 沉浸感,我不知道是否打开网页时顶部黑黑的状态栏是故意留下的,还是不小心的... 这些限制都让用户非常强烈的意识到这是一个网页而不是 APP,而小程序中虽然上面也存在一个空间的空白,但是却可以被更加沉浸的主题色和氛围图替代。网页这个需求做不了?我不信。


H5小程序


  1. 顺滑感,得益于 Native 的容器实现,小程序在所有的视图切换时,都可以表现出于原生应用一样的顺滑感。其实这个问题才是在很多 Hybrid 应用中,主要想借助客户端来处理和解决的问题。类似容器预开、容器切换等技术是可以解决相关问题的,只是还没有一个标准。


我这里没有提性能,说实话我不认为性能在小程序中是有优势的(Native 调用除外,如地图等,不是一个讨论范畴)。作为普通用户,我们感受到的只是离线加载下带来的顺滑而已。


而上述提到的许多优势,这对于一个高品质的 Web 应用来说是可以做得到的,但注意这里是高品质的 Web 应用。而这种“高品质”在小程序这里,只是入驻门槛而已。


心智,这个词,听起来很黑话,但却很恰当。当小程序通过长期这样的筛选,所沉淀出来一批这样品质的应用时。就会让每个用户即便在还没打开一个新的小程序之前,也有不错体验的心理预期。这就是心智,一种感觉跟 Web 不一样,跟 APP 有点像的心智。


打造心智,这件事情好像就是国内互联网企业最擅长做的事情,总是能从一些细微的差别中开辟一条独立的领域,然后不断强化灌输本来就不大的差异,等流量起来再去捞钱变现。




我总是感觉现在的互利网充斥着如何赚钱的想法,好像永远赚不够。“赚钱”这个事情,在这些公司眼里就是圈人圈地抢资源,看看谁先占得先机,别人有的我也得有,这好像是最重要的事情。


很少有企业在思考如何创造些没有的市场,创造些真正对技术发展有贡献,对社会发展有推动作用的事情。所以在国内互联网圈也充斥着一种奇怪的价值观,有技术的不一定比赚过钱的受待见。


管你是 PHP 还是 GO,管你是在做游戏还是直播带货,只要赚到钱就是高人。并且端的是理所应当、理直气壮,有些老板甚至把拍摄满屋子的程序员为自己打工作为一种乐趣。什么情怀、什么优雅、什么愿景,人生就俩字:搞钱。


不是故意高雅,赚钱这件事情本身不寒碜,只是在已经赚到盆满钵满、一家独大的时候还在只是想着赚更多的钱,好像赚钱的目的就是为了赚钱一样,这就有点不合适。企业到一定程度是要有社会责任的,龙头企业每一个决定和举措,都有会影响接下来的几年里这个行业的价值观走向。



当然也不是完全没有好格局的企业,我非常珍惜每一个值得尊重的中国企业,来自一个蔚来车主。



小程序在商业上固然是成功的,但吃的红利可以说还是来自 网页 到 应用 的心智变革。将本来流向 APP 的红利,截在了小程序生态里。


但对于技术生态的发展却是带来了无数条新的岔路,小程序的玩法就决定了它必须生长在某个巨型应用里面,不论是用户数据获取、还是 API 的调用,其实都是取决于应用容器的标准规范。


不同公司和应用之间则必然会产生差异,并且这种差异是墒增式的差异,只会随着时间的推移越变越大。如果每个企业都只关注到自己业务的增长,无外部约束的话,企业必然会根据自己的业务发展和政策需要,选择成本较低的调整 API,甚至会故意制造一些壁垒来增加这种差异。


小程序,应该是 浏览器 与 操作系统 的融合,这本应该是推动这两项技术操刀解决的事情。


微前端


qiankun、wujie、single-spa 是近两年火遍前端的技术方案,同样一个问题:我们为什么需要微前端?


我不确定是否每个在使用这项技术的前端都想清楚了这个问题,但至少在我面试过的候选人中,我很少遇到对自己项目中已经在使用的微前端,有很深的思考和理解的人。


先说下我的看法:



  1. 微前端,重在解决项目管理而不在用户体验。

  2. 微前端,解决不了该优化和需要规范的问题。

  3. 微前端,在挽救没想清楚 MPA 的 SPA 项目。


没有万能银弹



银色子弹(英文:Silver Bullet),或者称“银弹”“银质子弹”,指由纯银质或镀银的子弹。在欧洲民间传说及19世纪以来哥特小说风潮的影响下,银色子弹往往被描绘成具有驱魔功效的武器,是针对狼人、吸血鬼等超自然怪物的特效武器。后来也被比喻为具有极端有效性的解决方法,作为杀手锏、最强杀招、王牌等的代称。



所有技术的发展都是建立在前一项技术的基础之上,但技术依赖的选择过程中一定需要保留第一性原理的意识。


当 React、Vue 兴起,当 打包技术(Webpack) 兴起,当 网页应用(SPA) 兴起,这些杰出的技术突破都在不同场景和领域中给行业提供了新的思路、新的方案。


不知从何时开始,前端除了 div 竟说不出其他的标签(还有说 View 的),项目中再也不会考虑给一个通用的 class 解决通用样式问题。


不知从何时开始,有没有权限需要等到 API 请求过后才知道,没有权限的话再把页面跳转过去申请。


不知从何时开始,大家的页面都放在了一个项目里,两个这样的巨石应用融合竟然变成了一件困难的事。


上面这些不合理的现状,都是在不同的场景下,不思考适不适合,单一信奉 “一招吃遍天” 下演化出的问题。


B 端应用,是否应该使用 SPA? 这其实是一个需要思考的问题。


微前端从某种程度上来讲,是认准 SPA 了必须是互联网下一代应用标准的产物,好像有了 SPA 以后,MPA 就变得一文不值。甭管页面是移动端的还是PC的;甭管页面是面对 C 端的还是 B 端的;甭管一个系统是有 20 个页面还是 200 个页面,一律行这套逻辑。


SPA 不是万能银弹,React 不是万能银弹,Tailwind 不是万能银弹。在新技术出现的时候,保持热情也要保持克制。



ps. 我也十分痛恨 React 带来的这种垄断式的生态,一个 React 组件将 HTML 和 Style 都吃进去,让你即使在做一个移动端的纯展示页面时,也需要背上这些称重的负担。



质疑 “墨守成规”,打开视野,深度把玩,理性消费。


分而治之


分治法,一个很基本的工程思维。


在我看来在一个正常商业迭代项目中的主要维护者,最好不要超过 3 个人,注意是主要维护者(Maintainer) 。


你应该让每个项目都有清晰的责任人,而不是某行代码,某个模块。责任人的理解是有归属感,有边界感的那种,不是口头意义上的责任人。(一些公司喜欢搞这种虚头巴脑的事情,什么连坐…)


我想大部分想引入微前端的需求都是类似 如何更好的划分项目边界,同时保留更好的团队协同。


比如 导航菜单 应该是独立收口独立管理的,并且当其更新时,应该同时应用于所有页面中。类似的还有 环境变量、时区、主题、监控及埋点。微前端将这些归纳在主应用中。


而具体的页面内容,则由对应的业务进行开发子应用,最后想办法将路由关系注册进主应用即可。


当然这样纯前端的应用切换,还会出现不同应用之间的全局变量差异、样式污染等问题,需要提供完善的沙箱容器、隔离环境、应用之间通信等一系列问题,这里不展开。


当微前端做到这一部分的时候,我不禁在想,这好像是在用 JavaScript 实现一个浏览器的运行容器。这种本应该浏览器本身该做的事情,难道 JS 可以做的更好?


只是做到更好的项目拆分,组织协同的话,引入后端服务,由后端管控路由表和页面规则,将页面直接做成 MPA,这个方案或许并不比引入微前端成本高多少。


体验差异


从 SPA 再回 MPA,说了半天不又回去了么。


所以不防想想:在 B端 业务中使用 SPA 的优势在哪里?


流畅的用户体验:

这个话题其实涵盖范围很广,对于 SPA 能带来的 “流畅体验”,对于大多数情况下是指:导航菜单不变,内容变化 发生变化,页面切换中间不出现白屏


但要做到这个点,其实对于 MPA 其实并没有那么困难,你只需要保证你的 FCP 在 500ms 以内就行。



以上的页面切换全部都是 MPA 的多页面切换,我们只是简单做了导航菜单的 拆分 和 SWR,并没有什么特殊的 preload、prefetch 处理,就得到了这样的效果。


因为浏览器本身在页面切换时会在 unload 之前先 hold 当前页面的视图不变,发起一下一个 document 的请求,当页面的视图渲染做到足够快和界面结构稳定就可以得到这样的效果。



这项浏览器的优化手段我找了很久,想找一篇关于它的博文介绍,但确实没找到相关内容,所以 500ms 也是我的一个大致测算,如果你知道相关的内容,可以在评论区补充,不胜感激。



所以从这个角度来看,浏览器本身就在尽最大的努力做这些优化,并且他们的优化会更底层、更有效的。


离线访问 (PWA)

SPA 确实会有更好的 PWA 组织能力,一个完整的 SPA 应用甚至可以只针对编译层做改动就可以支持 PWA 能力。


但如果看微前端下的 SPA 应用,需要支持 PWA 那就同样需要分析各子应用之间的元数据,定制 Service Worker。这种组织关系和定制 SW,对于元数据对于数据是来自前端还是后端,并不在意。


也就是说微前端模式下的 PWA,同样的投入成本,把页面都管理在后端服务中的 MPA 应用也是可以做到相同效果的。


项目协同、代码复用

有人说 SPA 项目下,项目中的组件、代码片段是可以相互之间复用的,在 MPA 下就相对麻烦。


这其实涉及到项目划分的领域,还是要看具体的需求也业务复杂度来定。如果说整个系统就是二三十个页面,这做成 SPA 使用前端接管路由高效简单,无可厚非。


但如果你本身在面对的是一个服务于复杂业务的 B 端系统,比如类似 阿里云、教务系统、ERP 系统或者一些大型内部系统,这种往往需要多团队合作开发。这种时候就需要跟完善的项目划分、组织协同和系统整合的方案。


这个时候 SPA 所体现出的优势在这样的诉求下就会相对较弱,在同等投入的情况下 MPA 的方案反而会有更少的执行成本。



也不是所有项目一开始就会想的那么清楚,或许一开始的时候就是个简单的 SPA 项目,但是随着项目的不断迭代,才变成了一个个复杂的巨石应用,现在如果再拆出来也会有许多迁移成本。引入微前端,则可以...



这大概是许多微前端项目启动的背景介绍,我想说的是:对于屎山,我从来不信奉“四两拨千斤”


如果没有想好当下的核心问题,就引入新的“银弹”解决问题,只会是屎山雕花。


项目协同,抽象和复用这些本身不是微前端该解决的问题,这是综合因素影响下的历史背景问题。也是需要一个个资深工程师来把控和解决的核心问题,就是需要面对不同的场景给出不同的治理方案。


这个道理跟防沙治沙一样,哪有那么多一蹴而就、立竿见影的好事。


模块加载


模块加载这件事情,从玉伯大佬的成名作 sea.js 开始就是一个非常值得探讨的问题。在当时 jQuery 的时代里,这是一个绝对超前的项目,我也在实际业务中体会过在无编译的环境下 sea.js 的便捷。



实际上,不论是微前端、低代码、会场搭建等热门话题离不开这项技术基础。


import * from * 我们每天都在用,但最终的产物往往是一个自运行的 JS Bundle,这来自于 Webpack、Vite 等编译技术的发展。让我们可以更好的组织项目结构,以构建更复杂的前端应用。


模块的概念用久了,就会自然而然的在遇到浏览器环境中,遇到动态模块加载的需求时,想到这种类似模块加载的能力。


比如在遇到会场千奇百怪的个性化营销需求时,能否将模块的 Props 开放出来,给到非技术人员,以更加灵活的方式让他们去做自由组合。


比如在低代码平台中,让开发者自定义扩展组件,动态的将开发好的组件注册进低代码平台中,以支持更加个性的需求。


在万物皆组件的思想影响下,把一整个完整页面都看做一个组件也不是不可以。于是在一些团队中,甚至提倡所有页面都可以搭建、搭建不了的就做一个大的页面组件。这样及可以减少维护运行时的成本,又可以统一管控和优化,岂不美哉。


当这样大一统的“天才方案”逐渐发展成为标准后,也一定会出现一些特殊场景无法使用,但没关系,这些天才设计者肯定会提供一种更加天才的扩展方案出来。比如插件,比如扩展,比如 IF ELSE。再后来,就会有性能优化了,而优化的 追赶对象 就是用原来那种简单直出的方案。


有没有发现,这好像是在轮回式的做着自己出的数学题,一道接一道,仿佛将 1 + 1的结论重新演化了一遍。



题外话,我曾经自己实现过一套通过 JSON Schema 描述 React 结构的 “库” ,用于一个低代码核心层的渲染。在我的实现过程中,我越发觉得我在做一件把 JSX 翻译成 JS 的事情,但 JSX 或者 HTML 本身不就是一种 DSL 么。为什么一定要把它翻译成 JSON 来传输呢?或者说这样的封装其本身有意义么?这不就是在做 PHP、ASP 直接返回带有数据的 HTML Ajax 一样的事情么。




传统的浏览器运行环境下要实现一个模块加载器,无非是在全局挂载一个注册器,通过 Script 插入一段新的 JS,该 JS 通过特殊的头尾协议,将运行时的代码声明成一个函数,注册进事先挂载好的注册器。


但实际的实现场景往往要比这复杂的多,也有一些问题是这种非原生方式无法攻克的问题。比如全局注册器的命名冲突;同模块不同版本的加载冲突;并发加载下的时序问题;多次模块加载的缓存问题 等等等等等...


到最后发现,这些事情好像又是在用 JS 做浏览器该做的事情。然而浏览器果然就做了,<script type="module"></script>,Vite 就主要采用这种模式实现了它 1 年之内让各大知名项目切换到 Vite 的壮举。


“但我们用不了,有兼容性问题。”


哇哦,当我看着大家随意写出的 display: grid 样式定义,不禁再次感叹人们对未知的恐惧。



import.meta 的兼容性是另外一个版本,是需要 iOS12 以上,详情参考:caniuse.com/?search=imp…


试想一下,现在的低代码、会场搭建等等各类场景的模块加载部分,如果都直接采用 ESM 的形式处理,这对于整个前端生态和开发体验来说会有多么大的提升。


模块加载,时至今日,本来就已经不再需要 loader。 正如 seajs 中写的:前端模块化开发那点历史



历史不是过去,历史正在上演。随着 W3C 等规范、以及浏览器的飞速发展,前端的模块化开发会逐步成为基础设施。一切终究都会成为历史,未来会更好。



结语


文章的结尾,我想感叹另外一件事,国人为什么一定要有自己的操作系统?为什么一定需要参与到一些规范的制定中?


因为我们的智慧需要有开花的土壤。如果没有自己掌握核心技术,就是只能在问题出现的时候用另类的方式来解决。最后在一番折腾后,发现更底层的技术只要稍稍一改就可以实现的更好。这就像三体中提到的 “智子” 一样,不断在影响着我们前进的动力和方向。


不论是小程序、微前端还是模块加载。试想一下,如果我们有自己的互联网底蕴,能决定或者影响操作系统和浏览器的底层能力。这些 “怪啖” 要么不会出现,要么就是人类的科技创新。



希望未来技术人不用再追逐 Write Once, Run Everywhere 的事情...



作者:YeeWang
来源:juejin.cn/post/7267091810366488632
收起阅读 »

最新前端技术趋势

前端的车轮滚滚向前,轮子造的越来越圆,速度造的越来越快,每个人都在适应这个轮子的节奏,稍微不注意就会被甩出车轮之外。狼狈不堪之外还会发自心底的大喊一声:别卷了!! 话虽这么说,但现实就是这样,无论是客观还是主观因素都不得不让你继续的往前走。既然是往前走, 那么...
继续阅读 »

前端的车轮滚滚向前,轮子造的越来越圆,速度造的越来越快,每个人都在适应这个轮子的节奏,稍微不注意就会被甩出车轮之外。狼狈不堪之外还会发自心底的大喊一声:别卷了!!


话虽这么说,但现实就是这样,无论是客观还是主观因素都不得不让你继续的往前走。既然是往前走,
那么能知道一些前面有啥东西岂不是更好,也许能少走弯路。



自己对前端23年大概的技术做了一些展望,想到什么写什么。毕竟谁都不知道会不会突然间又出了个frontEndGPT打翻了所有人的饭碗。



1 AI


最先说的肯定是AI,22年末,23年初的chatgpt让AI话题火的一塌糊涂,同时也被认为是一次重大的技术革新,技术革新带来的就是重塑,一切都要被重塑,你的职业,你的工作。
视觉层面的stable diffusionmidjourney已经对设计师产生了重大影响,而涉及到视觉ui层面的话,前端肯定是绕不开的部分。虽然目前没有直接的对前端产生影响,但下面的就一个就不只是对前端了,而是对整个程序员都产生了影响。

  • copilot


Your AI pair programmer. Trained on billions of lines of code, GitHub Copilot turns natural language prompts into coding suggestions across dozens of languages.


看看醒目的文字就知道,程序员或多或少都会被影响了。


还有什么CodeWhisperer, Cursor等也都是AI辅助编程。


更有FrontyLocofy等将图片AI分析为HTML文档,无代码快速建站,figma快速解析成代码等,虽然是提效不少,但谁能说这些不是对前端的一种重塑呢。


2 主流框架


随着React,Vue等框架进一步的普及,现在前端想要脱离它们的场景越来越少了。那么它们的下一步规划,也会对我们产生一些不小的影响。


react


react 18以后,react似乎是对create-react-app这种项目启动方式也不怎么主推了,毕竟速度摆在那里,没有任何优势。而使用直接竞争对手的产品似乎又不太合适,而直接说又不用又显得跟开源精神不吻合(当然竞争对手的一些基本特性也确实和现有的构建思想不太吻合)。


他们似乎采取一种围魏救赵的方式,着重宣传next的方式。next不仅仅是一个ssr的框架,同时它也支持csr,ssg等不同的方式(next13开始,对于客户端组件和服务端组件可以有了比较好的区分)。同时next与react有着千丝万缕的联系,而next正在进行一个新的构建工具的替换。
next采用了turbopack,也是Webpack作者TobiasKoppers的作品,官方说它更新速度比Vite也要快10倍、比Webpack快700倍


而react也应该大概率会引入turbopack(当然它如果继续搞前端脚手架的话)。当然也可能会直接使用next环境。


至于快多少,以及评价基准等我们可以看下turbopack真比Vite快10倍吗?


next


next最新版本也加入了很多的特性,比如server component理念,约定式路由的更改,流式渲染,客户端组件与服务端组件分离更简单,更好的构建速度等等功能。可以让开发体验,用户体验更好,性能也会有响应的提升。


vite


这个不用说了,优秀的构建速度以及越来越丰富的社区,让其在22年有了很大提升。随着浏览器的逐步升级,23年vite肯定也会是重大的一年。


webpack


虽然5有了好多的功能提升,不过速度似乎一直是一个绕不过去的坎。就连作者也已经开始搞turbopack了,虽然加入swc能让编译有很大提升,但是目前从我身边的人的了解看,越来越多的人开始转向vite等其它方式了


turbopack


是webpack作者去的新公司开发的一款基于rust的打包工具。官方明确说明就是为了替换webpack。 同时强调webpack是这十年最火的工具,那turbopack就定位成未来几年的工具。由于作者和webpack, Vercel, next, React这些之间千丝万缕的联系,很难不说未来React也许会和这个打包工具绑定上。下面是官方提供的速度参考




而除了turbopack外,同一团队还在做Turborepo


这是一款项目管理的工具,最主要的面向场景是Monorepo这种复杂的多项目管理


Monorepo有很多优势,但是在多个项目中会有很多复杂的构建过程和相对闭塞的构建步骤,每一次上线都是耗时严重。所以Turborepo是为了解决这个问题出现的,让一些构建重复的构建步骤提炼出来,基于整个Monorepo项目的维度来管理多个子项目。


同时对于单个CI的构建步骤,解决每台机器,每个人都要单独构建的问题,还提炼了类似store的方案,脱离了构建环境,只跟项目绑定,当然这个方案是否使用要看你自己,毕竟原理是把构建产物放到第三方储存,而第三方又不是一个类似npm的开源机构。


3 服务端


node从7,8年前的爆火,到现在的不温不火,前端语言介入服务端这个命题似乎现在是越来越清晰了。那就是定位,可以做网关,可以做转发,可以做一些数据代理合并,定位清晰node依然有自己的使用场景。
node也马上到了20版本,迎来了一些特性

  • esm的更好支持,

  • 测试功能更加丰富

  • V8 引擎更新至 11.3,与Chromium113版本大部分相同

  • 支持以虚拟机的方式,动态运行js代码

  • WebAssembly的支持(实验性的)


于此同时,node曾经的作者Ryan Dahl几年前搞的Deno似乎没有太多的消息,似乎在向商业化方向前进。打造出Deno Deploy及其即时边缘渲染SSR框架Deno Fresh


4 其它


WebAssembly, 元框架,ts,微前端?


引用:


http://www.infoq.cn/article/9qu…
turbo.build/pack/docs/w…
http://www.robinwieruch.de/web-develop…


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

2023 了,不会还要做官网吧!

web
随着 AIGC 的兴起,移动互联网仿佛一瞬间也不香了,吃到红利的头部应用还在稳坐泰山,后起者在各种 xx 已死的声浪中,不知所措,信心尽失。流量的走势左右了太多人的思绪,太多的决策,所以当你在 2023 年,PC 互联网已经凉到不能再凉的时候,接到一个官网的需...
继续阅读 »

随着 AIGC 的兴起,移动互联网仿佛一瞬间也不香了,吃到红利的头部应用还在稳坐泰山,后起者在各种 xx 已死的声浪中,不知所措,信心尽失。流量的走势左右了太多人的思绪,太多的决策,所以当你在 2023 年,PC 互联网已经凉到不能再凉的时候,接到一个官网的需求,你会怎么去做呢,耐心看完,希望对你有所帮助。


官网开发作为前端的传统艺能已经诞生很久了,与现在主流的 react/vuecsr 框架开发不同,官网对 seo 有一定要求,所以咱们的网页不再是开局一个空 html,渲染全靠 js 了。传统的官网开发,一般采用 WordPress 建站,很好的技术栈 php + html/css/js 前后台一把梭,方便快捷,架构成熟。不过当遇到公司的前后端技术栈是 java + react 时,这个方案显然对开发人员并不友好,作为一名思维跳脱,勇于承担的前端,灵光乍现,我们是不是可以自己独立的完成整个官网呢。


在掘金上搜了搜,居然还有现成的课程,看看时间还挺新的,不如就从它入手吧。




作为网站尊贵的会员,我用兑换券换到了这门课程,开始了如饥似渴的学习。文章内容还是比较浅显易懂的,跟着操作下一个网站的雏形就出现了。课程中的技术栈主要采用 next.js + strapi 完成对官网的开发,相信各位看官并不陌生,业界知名的 react ssr 框架和 headless cms 系统,基础的介绍就不多说了,来分享点实操中得到经验教训吧。


next.js 相关


收获到的知识


1. 如何调试 next.js 服务端渲染的逻辑


之前,我只会使用 console.log 在启动的命令行里去判断代码的问题,现在我知道了使用 cross-env NODE_OPTIONS='--inspect' next dev 命令,他会开启一个 nodejsinspect 调试窗口,因为 next.js 的系统对象都很复杂,层级深,如果 console.log 的话很觉得很烦人,通过如上的方式就能便捷。






2. 生成有参数的静态页面的方法


其实像官网流量挺小的,没有必要走静态导出的 ssg ,使用 ssr 也完全没有问题,但是鉴于对 k8s 扩容,服务器费用之类的知识不太了解,官网的迭代频率也很低,所以本次采用了 ssg 的方案,要求所有子页面都要生成, 这就不得不提到内置的 api名为 getStaticPaths了。他会在打包阶段,先从一个列表接口拿到所有的id,再执行 getStaticProps 中的逻辑,拿到所有详情数据。比如我们的故事详情页,共有两条数据,id为1,2,执行完后,就会在对应目录下生成两个 html






得到的经验教训


1. 移动端适配


一个简易的判断移动端的代码很容易写,先不纠结完备性,大致如下:

/mobile|android|iphone|ipad|phone/i.test(window.navigator.userAgent.toLowerCase())

然而在 next.jsssg 方案中你却很难拿到,因为 render 里不能有对 window 的直接使用。我想了很多取巧的方法,但是都不行,如果是 ssr 还可以用请求头的 user-agent 来判断,作为属性透传到组件中,如课程一样。但 ssg 你只能使用响应式,同时渲染2套布局,才是最简单的做法。仔细想想,因为 html 字符串的构建是在 build 的时候就已经决定了,所以不能动态的拼接成不同的 dom 也就很好理解了。



PS: 感谢 @wonderL17 指出,也可以使用nginx配置的方式,将对应路径转发到为移动端生成的页面。
如果页面复用逻辑多,且能很好支持响应式,可以使用响应式。如果完全是另一套风格,则可以使用配置ng方式去处理,更为优秀,代码会更好维护且生成的页面更小。



2. api目录的处理


next.js 中的 api 目录可以承担接口的功能,课程中在 api 层进行了一次中转,我对此呈保留意见,想法如下:

  • 如果 strapi 不能直接支持一定的并发量,那么使用 api 来代理请求一样不能达到。

  • 中间层越多,可能出现的协作编码问题就越多,比如 strapi 返回的数据不符合预期,有些人会去修改 strapi,有些人会去改 next.js 的 api,这样会造成项目维护的不统一。


其实简单的对 strapi 加个跨域的配置,就能很好的直接在每个组件内使用,这对于一般的官网是完全可以满足的,而且代码清晰整洁。


strapi 相关


strapi 暂时没有给我带来新的知识,因为没有对他的原理进行研究,最核心的功能,编辑类型,即可生成对应的增删改查界面是最值得学习的部分,可是也是他最成熟的部分,开发嘛,能用就行,所以只有使用上的踩坑记录。但不得不说,strapi 的模块设计的挺不错的(除开dashboard ui定制外),有兴趣的可以了解下代码原理和设计思路。


得到的经验教训


1. 数据库(!!!!!)


请一定不要用 sqlite,不然你会哭的。因为项目肯定是需要多人协作的,强大如你 merge 代码对你来说轻而易举,但是你会 merge db 文件吗?相信我,你不会。


所以不要等项目已成,你才想起来去换个数据库,那么你即将面临,如果将 sqlite 数据导出成 postgresql 或者 mysql 数据的问题,听上去并不难吧,我花了 1~2 天时间才把这个无意义的工作做完。虽然能查到很多方案,但开源的不好用,付费的不想试,还是老老实实导出 sql,再导入是最快的。(不详述了,都是辛酸,你懂的,装了一堆东西,要么环境问题,要么功能问题,迟迟无法完成迁移的痛)


所以一开始就选好数据库,很关键,这样既方便了协作开发,又方便了后续使用。因为 4.x? 具体是几不清楚,relations 排序不生效(postgresql | mysql),sqlite 没有这个问题,所以替换数据库后,升级下 strapi,我升级的是4.10.6,顺便可以把依赖里的 sharp 删掉,有时候要装很久。


2. OSS(!!!!)


oss 一样至关重要,如果你使用了 strapi 的媒体库功能,请一定先配置好自定义的 oss,使用这个库就好了 strapi-provider-upload-oss,配置起来很方便,如果你头铁,一开始就是不配,你将面临,将上传好的图片删除,再重新上传一遍,没错他没有批量替换的功能,只能手动操作,我也没有花很久,1个小时,重新换了70~80张图片(关键素材上次用完还删了)。


配置如下:

// config/plugins.js

module.exports = ({ env }) => ({
upload: {
config: {
provider: "strapi-provider-upload-oss", // full package name is required
providerOptions: {
accessKeyId: "xxxx", // 用你的 oss 配置把 xxx 换掉,但千万别上传到开源库里(如github, gitee)哦
accessKeySecret: "xxx",
region: "xxx",
bucket: "xxx",
uploadPath: "/strapi/static",
baseUrl: "xxx",
timeout: 3000,
secure: true,
},
},
},
});

3. 数据的格式化(!!!)


课程中自己定义 removeTime, removeAttrsAndId 等方法,对数据进行处理,可能写的比较早,还没有成熟的转换库,这里介绍下 strapi-plugin-transformer,可以快速的去掉没必要的层级结构和一些属性,相当好用。配置如下:

// config/plugins.js

module.exports = ({ env }) => ({
transformer: {
enabled: true,
config: {
responseTransforms: {
removeAttributesKey: true,
removeDataKey: true,
},
},
},
});

这个操作可以避免对数据进行过多的处理,也就意味着 src/api/xx 里的代码,你基本不需要手动修改了,大大提升了后台配置的开发效率。C端对数据的处理也更简单了


4. 一些小的注意点

  • config 目录下添加插件,需要创建 plugins.js 文件,少写了 s 会导致插件不生效。(PS:嗯,就是粗心的我建了 plugin.js 还怪人家插件不好使~)

  • .cache 还挺有用的,一些修改不生效,可以试试 build 后再重启试试。


一些拓展能力


strapi 的初始状态很难满足直接交付给运营配置,最大的坑点在于类型定义是英文的,还没有层级结构,这里参考了这篇文章提到的方案:juejin.cn/post/721922…,来对 dashboard 进行定制。功能主要分为3个步骤,patch-package 使用参考原文章即可。

  • 类型汉化


只需要修改 admin/app.js 即可


  • 类型层级&排序:.cache/admin/src/content-manger/pages/App/LeftMenu 文件

// 目录排序,1,1.1,2,2.1
function compareDirectories(formatter, dir1, dir2) {
// 提取目录中的数字和点号
const regex = /(\d+|\.)+/g;
const arr1 = dir1.match(regex);
const arr2 = dir2.match(regex);

if (!arr1 || !arr2) return formatter.compare(dir1, dir2);

// 比较每个部分的数字
for (let i = 0; i < Math.max(arr1.length, arr2.length); i++) {
const num1 = parseFloat(arr1[i]) || 0; // 如果无法解析为数字,默认为0
const num2 = parseFloat(arr2[i]) || 0;

if (num1 < num2) {
return -1;
} else if (num1 > num2) {
return 1;
}
}

// 如果所有部分都相同,则按照长度进行比较
if (arr1.length < arr2.length) {
return -1;
} else if (arr1.length > arr2.length) {
return 1;
}

return 0; // 目录完全相同
}



// Sort correctly using the language
// 这条注释下,用目录排序的方法替代原有 sort

// SubNavLink 内,使用 dangerouslySetInnerHTML 体现目录层级
<span dangerouslySetInnerHTML={{__html: link.title.replace(/([0-9]+.)/g, (a, b, index) => {
if (index <= 1) return '';

return '   ';
})}}></span>


这样你就能得到一个这样的配置目录




  • 项目部署

因为我这边项目是 ssg,修改完内容是需要触发流水线重新部署的,如果像想避免这个工作,可以增加一些按钮,触发 webhook。这样只要排版是够用的,就不需要开发介入了。


如下示例,修改的是 .cache/admin/src/pages/HomePage/index.js




以上改动想要生效,记得用 patch-package,这也是我说他的定制模块不友好的原因~


总结


next.jsstapi 的学习,踩坑,使用经验就是这么多了,起初发起这个项目,是因为我们官网的框架有些老了,用的 fis3,有时候法务、市场同学来找替换素材,都是没什么工作难度的事儿,浪费时间,也整的挺烦的,所以借着机会升级了一下,以后就事半功倍了。


至于在当今这个时代,官网的 seo 是否还有意义,是否还能够为公司带来不俗的自然增量,这个我最感兴趣的事儿,迟迟没能发起。


因为在公司,这是涉及很多部门(品牌,公关,法务,市场,产品,设计)的事儿,普通开发并不能调动资源,我也很期待,如果有幸能发起一个这样的项目,并不断通过技术上的优化为业务带来新的增量,那里可能会用到更多的贴合业务的 ssr 技术,到时候有机会再和大家分享下~


但我也想对技术感兴趣的朋友说,底层的技术重构也是很有魅力的一件事儿,尽管没有业务方的支持,如果你持之以恒的来做,复刻原产品,也能让这个产品在技术层面上焕然一新。


谨以此文,与君共勉!


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

解决扫码枪因输入法中文导致的问题

web
问题 最近公司项目上遇到了扫码枪因搜狗/微软/百度/QQ等输入法在中文状态下,使用扫码枪扫码会丢失字符的问题 思考 这种情况是由于扫码枪的硬件设备,在输入的时候,是模拟用户键盘的按键来实现的字符输入的,所以会触发输入法的中文模式,并且也会触发输入法的自动联想。...
继续阅读 »

问题


最近公司项目上遇到了扫码枪因搜狗/微软/百度/QQ等输入法在中文状态下,使用扫码枪扫码会丢失字符的问题


思考


这种情况是由于扫码枪的硬件设备,在输入的时候,是模拟用户键盘的按键来实现的字符输入的,所以会触发输入法的中文模式,并且也会触发输入法的自动联想。那我们可以针对这个来想解决方案。


方案一


首先想到的第一种方案是,监听keydown的键盘事件,创建一个字符串数组,将每一个输入的字符进行比对,然后拼接字符串,并回填到输入框中,下面是代码:


function onKeydownEvent(e) {
this.code = this.code || ''
const shiftKey = e.shiftKey
const keyCode = e.code
const key = e.key
const arr = ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-']
this.nextTime = new Date().getTime()
const timeSpace = this.nextTime - this.lastTime
if (key === 'Process') { // 中文手动输入
if (this.lastTime !== 0 && timeSpace <= 30) {
for (const a of arr) {
if (keyCode === 'Key' + a) {
if (shiftKey) {
this.code += a
} else {
this.code += a.toLowerCase()
}
this.lastTime = this.nextTime
} else if (keyCode === 'Digit' + a) {
this.code += String(a)
this.lastTime = this.nextTime
}
}
if (keyCode === 'Enter' && timeSpace <= 30) {
if (String(this.code)) {
// TODO
dosomething....
}
this.code = ''
this.nextTime = 0
this.lastTime = 0
}
}
} else {
if (arr.includes(key.toUpperCase())) {
if (this.lastTime === 0 && timeSpace === this.nextTime) {
this.code = key
} else if (this.lastTime !== 0 && timeSpace <= 30) {
// 30ms以内来区分是扫码枪输入,正常手动输入时少于30ms的
this.code += key
}
this.lastTime = this.nextTime
} else if (arr.includes(key)) {
if (this.lastTime === 0 && timeSpace === this.nextTime) {
this.code = key
} else if (this.lastTime !== 0 && timeSpace <= 30) {
this.code += String(key)
}
this.lastTime = this.nextTime
} else if (keyCode === 'Enter' && timeSpace <= 30) {
if (String(this.code)) {
// TODO
dosomething()
}
this.code = ''
this.nextTime = 0
this.lastTime = 0
} else {
this.lastTime = this.nextTime
}
}
}


这种方案能解决部分问题,但是在不同的扫码枪设备,以及不同输入法的情况下,还是会出现丢失问题


方案二


使用input[type=password]来兼容不同输入的中文模式,让其只能输入英文,从而解决丢失问题


这种方案网上也有不少的参考

# 解决中文状态下扫描枪扫描错误

# input type=password 取消密码提示框


使用password密码框确实能解决不同输入法的问题,并且Focus到输入框,输入法会被强制切换为英文模式


添加autocomplete="off"autocomplete="new-password"属性


官方文档:
# 如何关闭表单自动填充


但是在Chromium内核的浏览器,不支持autocomplete="off",并且还是会出现这种自动补全提示:


image.png


上面的属性并没有解决浏览器会出现密码补全框,并且在输入字符后,浏览器还会在右上角弹窗提示是否保存:


image.png


先解决密码补全框,这里我想到了一个属性readonly,input原生属性。input[type=password]readonly
时,是不会有密码补全的提示,并且也不会弹窗提示密码保存。


那好,我们就可以在输入前以及输入完成后,将input[type=password]立即设置成readonly


但是需要考虑下面几种情况:



  • 获取焦点/失去焦点时

  • 当前输入框已focus时,再次鼠标点击输入框

  • 扫码枪输出完成最后,输入Enter键时,如果清空输入框,这时候也会显示自动补全

  • 清空输入框时

  • 切换离开页面时


这几种情况都需要处理,将输入框变成readonly


我用vue+element-ui实现了一份,贴上代码:


<template>
<div class="scanner-input">
<input class="input-password" :name="$attrs.name || 'one-time-code'" type="password" autocomplete="off" aria-autocomplete="inline" :value="$attrs.value" readonly @input="onPasswordInput">
<!-- <el-input ref="scannerInput" v-bind="$attrs" v-on="$listeners" @input="onInput"> -->
<el-input ref="scannerInput" :class="{ 'input-text': true, 'input-text-focus': isFocus }" v-bind="$attrs" v-on="$listeners">
<template v-for="(_, name) in $slots" v-slot:[name]>
<slot :name="name"></slot>
</template>
<!-- <slot slot="suffix" name="suffix"></slot> -->
</el-input>
</div>
</template>

<script>
export default {
name: 'WispathScannerInput',
data() {
return {
isFocus: false
}
},
beforeDestroy() {
this.$el.firstElementChild.setAttribute('readonly', true)
this.$el.firstElementChild.removeEventListener('focus', this.onPasswordFocus)
this.$el.firstElementChild.removeEventListener('blur', this.onPasswordBlur)
this.$el.firstElementChild.removeEventListener('blur', this.onPasswordClick)
this.$el.firstElementChild.removeEventListener('mousedown', this.onPasswordMouseDown)
this.$el.firstElementChild.removeEventListener('keydown', this.oPasswordKeyDown)
},
mounted() {
this.$el.firstElementChild.addEventListener('focus', this.onPasswordFocus)
this.$el.firstElementChild.addEventListener('blur', this.onPasswordBlur)
this.$el.firstElementChild.addEventListener('click', this.onPasswordClick)
this.$el.firstElementChild.addEventListener('mousedown', this.onPasswordMouseDown)
this.$el.firstElementChild.addEventListener('keydown', this.oPasswordKeyDown)

const entries = Object.entries(this.$refs.scannerInput)
// 解决ref问题
for (const [key, value] of entries) {
if (typeof value === 'function') {
this[key] = value
}
}
this['focus'] = this.$el.firstElementChild.focus.bind(this.$el.firstElementChild)
},
methods: {
onPasswordInput(ev) {
this.$emit('input', ev.target.value)
if (ev.target.value === '') {
this.$el.firstElementChild.setAttribute('readonly', true)
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
})
}
},
onPasswordFocus(ev) {
this.isFocus = true
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
})
},
onPasswordBlur() {
this.isFocus = false
this.$el.firstElementChild.setAttribute('readonly', true)
},
// 鼠标点击输入框一瞬间,禁用输入框
onPasswordMouseDown() {
this.$el.firstElementChild.setAttribute('readonly', true)
},
oPasswordKeyDown(ev) {
// 判断enter键
if (ev.key === 'Enter') {
this.$el.firstElementChild.setAttribute('readonly', true)
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
})
}
},
// 点击之后,延迟200ms后放开readonly,让输入框可以输入
onPasswordClick() {
if (this.isFocus) {
this.$el.firstElementChild.setAttribute('readonly', true)
setTimeout(() => {
this.$el.firstElementChild.removeAttribute('readonly')
}, 200)
}
},
onInput(_value) {
this.$emit('input', _value)
},
getList(value) {
this.$emit('input', value)
}
// onChange(_value) {
// this.$emit('change', _value)
// }
}
}
</script>

<style lang="scss" scoped>
.scanner-input {
position: relative;
height: 36px;
width: 100%;
display: inline-block;
.input-password {
width: 100%;
height: 100%;
border: none;
outline: none;
padding: 0 16px;
font-size: 14px;
letter-spacing: 3px;
background: transparent;
color: transparent;
// caret-color: #484848;
}
.input-text {
font-size: 14px;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
background-color: transparent;
::v-deep .el-input__inner {
// background-color: transparent;
padding: 0 16px;
width: 100%;
height: 100%;
}
}

.input-text-focus {
::v-deep .el-input__inner {
outline: none;
border-color: #1c7af4;
}
}
}
</style>


至此,可以保证input[type=password]不会再有密码补全提示,并且也不会再切换页面时,会弹出密码保存弹窗。
但是有一个缺点,就是无法完美显示光标。如果用户手动输入和删除,使用起来会有一定的影响。


我想到过可以使用模拟光标,暂时不知道可行性。有哪位同学知道怎么解决的话,可以私信我,非常感谢


作者:香脆又可口
来源:juejin.cn/post/7265666505102524475
收起阅读 »

你还在傻傻的打开页面输用户名和密码?来我教你实现自动化

web
背景 Hello~,大家好! 本文和各位分享一个有趣的事情! 我司主要的客户是银行,随着银行对信息安全越来越重视,我司积极配合银行防范信息安全,因此我司产品都从之前的外网开发引入了深信服的云桌面开发。由于笔者用的 Mac 电脑,云桌面 win7,天差地别的体验...
继续阅读 »

背景


Hello~,大家好! 本文和各位分享一个有趣的事情!


我司主要的客户是银行,随着银行对信息安全越来越重视,我司积极配合银行防范信息安全,因此我司产品都从之前的外网开发引入了深信服的云桌面开发。由于笔者用的 Mac 电脑,云桌面 win7,天差地别的体验我就先忍了😭,但是从 Mac 电脑桌面到进入云桌面,需要登录一些莫名其妙的 VPN、网址,这个过程是令人反感的、重复的、也是非常恶心的,每天都要这样、要等待很久......🤮



  1. 打开Mac上的xxxTrust并登录

  2. 打开Chrome

  3. 打开xxx网址

  4. 输入用户名、密码(不能自动填充那种)

  5. 点击登录

  6. 进入云桌面资源页面,点击一个资源,会自动调起 Mac 上安装的一个什么 VDIxxx 的软件

  7. 成功进入云桌面


我丢,各位来说说,这个过程是不是很恶心。作为一个技术人,我们不能忍受这种机械式的操作,我们要去做出改变。不能让自己一直做这些重复的恶心操作,于是我就想着 能不能像我打开 Mac 桌面的一个应用那样,中间的步骤自动完成,直到进入云桌面? 这就是笔者本文的主题。


实现自动化


其实笔者也没有一步到位——能不能像我打开 Mac 桌面的一个应用那样,中间的步骤自动完成,直到进入云桌面?。实现过程中,有一些新想法,接下来就分享一下我从开始有这个想法到实现的过程:


ConsoleSnippets


一开始: 我已经登录了 xxxTrust,笔者在浏览器已经打开了xxx网址,只是不想输密码,我就想起浏览器开发者工具 ConsoleSnippets,可以在里面写一些脚本,然后可以快捷执行:


image.png

打开控制台 -> 快捷键 command + p -> 输入!,选择执行哪个 Snippets -> 回车。看下效果:
1.gif


感觉还行是吧,那接着来,我们现在进入了资源管理的界面,接下来需要手动点击打开一个云桌面的资源,同样地,接着建一个 Snippets


image.png

看下效果:
1.gif


OK!成功进入,但是现在还需要我们手动去执行脚本,而且要执行两个。于是就有了新的想法。


篡改猴


能不能在对应的页面自动执行上面写好的脚本?


此时笔者想到了一个 Chrome 插件 —— 篡改猴,也叫油猴脚本,简单理解它的作用就是可以在你指定的网页中执行你写入的脚本。那就装一个呗!(Chrome商店需要🪜,可直接使用Edge浏览器)


image.png

打开管理面板,新建两个脚本:
image.png


编辑:
截屏2023-08-11 11.46.22.png


截屏2023-08-11 12.43.48.png

然后改一下设置,在 document-end 执行我们的脚本,此时可能还获取不到dom,因此使用 setTimeout
截屏2023-08-11 12.45.57.png
再来看下效果:


1.gif
鼠标一下没动哦!你以为这就完了?并没有,接着看。


自动化打开应用


能不能自动打开xxxTrustChrome,并自动打开登录的网址?


咱们一开始就说了,在访问网址之前,还需要链接VPN、打开浏览器,那就来吧!


截屏2023-08-11 13.21.17.png

在Mac上有两个东西可以完成自动操作:


截屏2023-08-11 13.22.20.png
通过自动操作配置出来的,也支持转变成快捷指令。我理解这俩应该是差不多的东西,来看下我们如何实现:


image.png
操作步骤:

  1. 打开 xxxTrust

  2. 通过 AppleScript,设置延时6秒,因为打开上面的 app 过后,有一个自动登录的过程,我们设置的长一点

  3. 打开 Chrome

  4. 打开登录网址


配置自动化操作和快捷指令都是可视化的,很容易上手,在此不过多介绍,感兴趣的掘友可自行探索哦!至于AppleScript,我只能告诉你是 GPT 教我这么写的。


最后一步就是把这个快捷指令发布到桌面:
截屏2023-08-11 13.33.00.png


看下最终的效果:
1.gif


这不,又多了几分钟摸鱼时间!😂


总结


笔者通过真实的一个场景,借助 Snippets篡改猴快捷指令自动操作 等工具实现自动化完成进入云桌面的一系列流程。笔者只实现了 Mac 的,windows 系统肯定也有类似的工具等待各位去探索(比如 python 脚本应该就能实现打开应用等操作)。


除此之外呢,还想表达一个观点就是——我们应该把那些机械式的活交给机械去做,比如在平时的开发中,总是CRUD?能不能高效CRUD?对吧!把这些对自己能力提升没有意义的工作,想办法用程序去实现了,岂不是美滋滋!


好了,本次分享就到此结束了,感谢阅读哦!


如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【】都是我创作的最大动力 ^_^


作者:Lvzl
来源:juejin.cn/post/7265744160694911028
收起阅读 »

axios使用异步方式无感刷新token,简单,太简单了

🍉 废话在前 写vue的伙伴们无感刷新token相信大家都不陌生了吧,刚好,最近自己的一个项目中就需要用到这个需求,因为之前没有弄过这个,研究了一个上午,终于还是把它拿下了,小小的一个token刷新😏。 下面接着分析一下踩到的坑以及解决思路 🍗 接着踩坑 我...
继续阅读 »

🍉 废话在前


写vue的伙伴们无感刷新token相信大家都不陌生了吧,刚好,最近自己的一个项目中就需要用到这个需求,因为之前没有弄过这个,研究了一个上午,终于还是把它拿下了,小小的一个token刷新😏。




下面接着分析一下踩到的坑以及解决思路


🍗 接着踩坑


我按照之前传统的方式在返回拦截器里面进行token刷新,正常的数据可以返回,但是这个时候会有比较麻烦的地方,就是请求的数据可以在拦截器里面得到,但是不能渲染到界面上(看到这里的时候我是懵的)。


看一下代码

service.interceptors.response.use(
response => {
const res = response.data
//刷新token的时候,可以从这里拦截到新数据,但是没有显示在页面上
console.log('拦截数据:',res)
if (loadingInstance) {
loadingInstance.close();
NProgress.done();
}
switch (res.code) {
case 200:
return res
case 401:
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '令牌过期',
message: '当前身份信息超过三天已失效,请您重新登录',
duration: 0
});
break;
default:
Message({
message: res.message || '请求错误',
type: 'error',
duration: 5 * 1000
})
break;
}
},
error => {
switch (error.response.status) {
case 401:
MessageBox.confirm('身份认证已过期,是否刷新本页继续浏览?', '提示', {
confirmButtonText: '继续浏览',
cancelButtonText: '退出登录',
type: 'warning'
}).then(() => {
axios.post('/api/token/refresh/', {
refresh: localStorage.getItem("retoken")
}).then(response => {
let res = response.data || {}
if (res.code == 200) {
let token = res.data.result;
localStorage.setItem("token", token.access);
localStorage.setItem("retoken", token.refresh);
error.response.config.baseURL = '';
error.response.config.headers['Authorization'] = 'Bearer ' + localStorage.getItem("token")
window.location.reload();
} else {
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '令牌过期',
message: '当前身份信息超过三天已失效,请您重新登录',
duration: 0
});
}
}).catch(err => {
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '认证失败',
message: '信息认证失败,请重新登录',
duration: 0
});
})
}).catch(() => {
router.push({
path: "/login",

})
Message({
message: '已退出',
type: 'success',
})
localStorage.clear();
});
break;
case 404:
Notification.error({
title: '404错误',
message: '服务器请求错误,请联系管理员或稍后重试。错误状态码:404',
});
break
default:
Notification.error({
title: '请求错误',
message: '服务器请求错误,请联系管理员或稍后重试',
});
break;
}
if (loadingInstance) {
loadingInstance.close();
NProgress.done();
}
return Promise.reject(error);

}
)

懵归懵,好在已经发现这个问题了,剩下的怎么解决呢?


当时想的是和后端配合,让后端直接发一个token过期的时间戳给我,我直接把这个时间戳放到localStrage里面,通过这个localStrage,直接在前端进行判断token的过期时间进行请求拦截,如果当前请求的时间大于了这个localStrage里面的时间,就说明token过期了,我这边就需要重新请求token了,而不要后端去进行token的验证。


信心满满的弄了一下,发现行不通,token过期的时候,后端直接一个错误401,前端就又回到解放前了。而且用时间戳的方式很容易出现bug,且在前端进行token时长的验证,很容易出现问题。因此,我还是觉得再研究一下上面那一段代码。


当然踩到坑不只是这个,还有百度和chatGPT,真的,看了一下没有找到一个可行的,总结一下主要有以下几种

  1. 状态管理vuex

  2. 路由router

  3. 时间戳(和我刚刚那种方式差不多)

  4. 依赖注入inject

  5. 刷新界面(我最开始那种方式,但是刷新的时候会出现页面白屏,且用户如果在页面上有一些自己输入的数据也会被清空,用户体验感不好)


以上方式我先不管行不行,但是麻烦是肯定的,做前端讲究的就一个字:“懒”
不是,应该是 “高效快捷”
所以这些方式就pass掉了


只能想想为什么拦截器里面可以得到数据,为什么页面位置得不到数据了


🥩 解决思路


下面是我的解决思路,有不对的地方还请看到的大神指出来一下😁


当用户发起请求的时候,因为刷新token的http状态码是401,这个时候axios的响应拦截器就直接进行错误捕获了,到了这里,因为数据已经返回了,但是因为是错误数据,页面得到的这个数据不可用且当前请求已经结束了,当然这里对状态码401是进行处理了的(应该获取token了)。


采用普通的获取方式来获取token,因为异步的原因,我们获取token的同时页面也在做刷新,token获取的同时,界面也刷新完毕了(但是是没有数据的,不做错误捕获会报错),因此我们在获取token完毕,且用新的token去获取数据时,拦截器里面会有数据,但是界面已经休息了,就不会把拦截器里面的新数据刷新到页面了。


因此这个地方需要对获取token的过程进行一下请求阻塞,把获取token的请求变成同步的。到这里就差不多了。直接把响应拦截器里面的error函数变成同步不就行了吗,async + await可以出来了。


以上是自己当时的想法,简单说来就是 页面刷新需要慢我获取token一步 ,通过这个方式也确实做到了无感刷新🤣


希望以上的能帮助到你,有什么好的思路也欢迎评论区指出。


🍓完整代码


这个是用了2.13.2版本的element-ui以及nprogress的一个axios代码模块,包含了一个下载文件的模块。


如果需要的话可以根据自己的需求来进行修改

import axios from 'axios'
import {
Message,
Loading,
Notification,
} from 'element-ui'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import router from '@/router/index'
const baseURL = '/api'
const service = axios.create({
baseURL,
timeout: 6000
})
NProgress.configure({
showSpinner: false
}) // NProgress Configuration
let loadingInstance = undefined;
service.interceptors.request.use(
config => {
NProgress.start()
loadingInstance = Loading.service({
lock: true,
text: '正在加载,请稍候...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.3)'
})
config.headers['Authorization'] = 'Bearer ' + localStorage.getItem("token")
return config;
},
error => {
return Promise.reject(error)
}
)

service.interceptors.response.use(
response => {
const res = response.data

if (loadingInstance) {
loadingInstance.close();
NProgress.done();
}
switch (res.code) {
case 200:
return res
case 401:
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '认证失效',
message: '当前身份信息超过三天已失效,请您重新登录',
duration: 0
});
break;
default:
Message({
message: res.message || '请求错误',
type: 'error',
duration: 5 * 1000
})
break;
}
},
async error => {
switch (error.response.status) {
case 401:
const err401Data = error.response.data || {}
if (err401Data.code !== "token_not_valid") {
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '认证失败',
message: '身份信息认证失败,请您重新登录',
duration: 0
});
return
}
try {
const res = await service.post('/token/refresh/', {
refresh: localStorage.getItem("retoken")
})
if (res.code == 200) {
let token = res.data.result;
localStorage.setItem("token", token.access);
localStorage.setItem("retoken", token.refresh);
error.response.config.baseURL = ''
error.response.config.headers['Authorization'] = 'Bearer ' + localStorage.getItem("token")
return service(error.response.config)
} else {
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '认证失败',
message: '当前身份认证信息已失效,请您重新登录',
duration: 0
});
}
} catch (error) {
router.push({
path: "/login",
})
localStorage.clear();
Notification.error({
title: '认证失败',
message: '身份认证失败,请您重新登录,失败原因:' + error.message,
duration: 0
});
}

break
case 404:
Notification.error({
title: '404错误',
message: '服务器请求错误,请联系管理员或稍后重试。错误状态码:404',
});
break
default:
Notification.error({
title: '请求错误',
message: '服务器请求错误,请联系管理员或稍后重试',
});
break;
}
if (loadingInstance) {
loadingInstance.close();
NProgress.done();
}
return Promise.reject(error);

}
)

// 文件下载通用方式
export const requestFile = axios.create({
baseURL,
timeout: 0, //关闭超时时间
});

requestFile.interceptors.request.use((config) => {
config.headers['Authorization'] = 'Bearer ' + localStorage.getItem("token") //携带的请求头
config.responseType = 'blob';
loadingInstance = Loading.service({
lock: true,
text: '正在下载,请稍候...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.3)'
})
return config;
});

requestFile.interceptors.response.use(
(response) => {
let res = response.data;
if (loadingInstance) {
loadingInstance.close()
}
// const contentType = response.headers['content-type'];//获取返回的数据类型
let blob = new Blob([res], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" //文件下载以及上传类型为 .xlsx
});
let url = window.URL.createObjectURL(blob);
// 创建一个链接元素
let link = document.createElement('a');
link.href = url;
link.download = '产品列表.xlsx'; // 自定义文件名
link.click();


},
(err) => {
Message({
message: '操作失败,请联系管理员',
type: 'error',
})
if (loadingInstance) {

loadingInstance.close()
}
}
);


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

🎉前端开发书籍推荐🎉

文章首发公众号:萌萌哒草头将军,欢迎关注 🎉SolidJS响应式原理和简易实现🎉 做为多年学习JavaScript的开发者,一路走来,几多坎坷,回头再看来时的路,特别感谢下面几本书带我入行前端。 💎《JavaScript高级程序设计》 推荐指数:⭐⭐⭐⭐⭐...
继续阅读 »

文章首发公众号:萌萌哒草头将军,欢迎关注



🎉SolidJS响应式原理和简易实现🎉


做为多年学习JavaScript的开发者,一路走来,几多坎坷,回头再看来时的路,特别感谢下面几本书带我入行前端。


💎《JavaScript高级程序设计》




推荐指数:⭐⭐⭐⭐⭐


推荐理由:内容扎实,且不深奥。


这本书在我大学的时候就已经买了,当时经济实力有限,买的是影印版,不过也被我研读了多遍,并且做了详细的记录。这本书见证了我的前端之路,一直被我珍藏至今。




现在已经出了第四版了。前端入门进阶宝典,所以也被广大开发者称为《红宝书》。


💎《Javascript权威指南》




推荐指数:⭐⭐⭐⭐⭐


推荐理由:内容全面,讲解详细,配合红宝书效果更佳。


该书被称为《犀牛书》,书如其名,真的是权威指南,即使你买了红宝书,我也推荐你买一本,因为这两本对相同的知识讲解,侧重点不同。


比如对于闭包,红宝书很详细,从作用域,到作用域链,再到活动对象,讲解由浅到深,十分详细,而《犀牛书》中仅仅是给出闭包的概念,然后举例说明差异。


但是《犀牛书》对于类型转换toStringvalueof讲解则十分详细,《红宝书》则浅浅的带过。


《犀牛书》更像是一本字典,有不懂的问题,可以及时查漏补缺。


💎《图解HTTP》




推荐指数:⭐⭐⭐⭐


推荐理由:图文并茂,简洁明了


这本书作为第三本推荐,绝不是空穴来风,大量的图来解释枯燥的概念,形象生动,老少皆宜。


前端开发一大部分的时间都是在和后端的接口打交道,而Http无疑是沟通的桥梁。


读完这本书,你将会了解到网络分层模型Http协议和TCP/IP的关系、后端的数据怎么从服务端到达浏览器的、常用Http状态码的含义、请求头的各种含义、你输入url浏览器发生了什么等热门面试题的答案。


我买的是三件套《图解Http》《图解TCP/IP》《图解网络硬件》,《图解网络硬件》一点也不推荐,不懂的硬件直接百度吧,还是彩色图片。如果你是做运维相关的前端开发,《图解TCP/IP》同样值得一看。




💎《数据结构与算法JavaScript描述》




推荐指数:⭐⭐⭐⭐


推荐理由:进阶利器,闭眼入就对了。


大多数同学选择前端,主要还是因为数据结构和算法方面比较薄弱,但是这本书却使用了简洁的方法实现了各种数据结构和算法。


对于难懂的数据结构,有详细的结构图解释,是我读过最容易理解的版本了,不过这本书目前还是ES5语法版本实现,我在前面的文章中使用ES6语法实现过,并做了部分笔记。


👉【数据结构】我的学习笔记




💎《JavaScript设计模式》




推荐指数:⭐⭐⭐


推荐理由:常见的设计模式都有,讲解的比较简单易懂,但是实现比较简陋。


这本书是你入门中级后继续提升的有利法宝,不管什么框架,底层都逃不出两三个设计模式的,所以十分推荐你进阶的时候去读它。


目前有两本名为《JavaScript设计模式》的书,我买的是徐涛翻译版本影印版(和红宝书一起买的),但是最近查阅发现流行的是张容铭著作的版本,这里请自行斟酌买哪个版本。


我买的这个版本将设计模式分为创建型、行为型和结构型三种,前面部分分别讲解了十三种设计模式,后半部分讲解了老牌框架JQuery设计的各种设计模式,虽然从现在的情况看JQuery已经凉了,但是它的设计智慧,真的令人敬佩。


这本书的缺点也是语法版本较旧,不过我也写了最新语法的部分笔记。


👉超级简单的设计模式,看不懂你来打我


今天的内容就这些了,如果你有更好的书籍,可以告诉我!


现在,关注我的公众号会有送书福利,具体请在公众号回复:活动,即可查看详情


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

推荐 6 个 火火火火 的开源项目

本期推荐开源项目目录:1、ChatGPT 网页应用(AI)2、AI 换脸(AI)3、API 调用 Midjourney 进行 AI 画图(AI)4、如何使用 Open AI 的 API?(AI)5、中华古诗词数据库6、动画编程 01. ChatGPT 网页应用...
继续阅读 »

本期推荐开源项目目录:

1、ChatGPT 网页应用(AI)
2、AI 换脸(AI)
3、API 调用 Midjourney 进行 AI 画图(AI)
4、如何使用 Open AI 的 API?(AI)
5、中华古诗词数据库
6、动画编程


01. ChatGPT 网页应用


基于 ChatGPT-Next-Web 二次开发的 ChatGPT 网页付费系统,包含用户管理模块和后台看板。


ChatGPT-Admin-Web 付费系统包含七个模块,包括:内容接口、用户系统、支付、敏感词过滤、自由聊天、分销、收益


编辑


添加图片注释,不超过 140 字(可选)


开源地址:github.com/AprilNEA/Ch…



编辑


添加图片注释,不超过 140 字(可选)


02. AI 换脸


适用于视频聊天的 AI 换脸模型,你可以使用这个 AI 模型替换摄像头中的面部或视频中的面部。这是一些例子:


编辑


添加图片注释,不超过 140 字(可选)


开源地址:github.com/iperov/Deep…


03. API 调用 Midjourney 进行 AI 画图


通过代理 MidJourney 的 Discord 频道,实现 api 形式调用AI绘图。


前提是你要注册 Midjourney 账号、并在 Discord 创建在自己的频道和机器人,然后就可以根据这个项目的指引一步步去使用 Api 调用 Midjourney 了。


开源地址:github.com/novicezk/mi…


编辑


添加图片注释,不超过 140 字(可选)


04. 如何使用 Open AI 的 API?


Open AI-Cook Book 是一本 Open AI 的 API 使用指南,提供了一些通过 Open AI 的 API 搭建任务的示例代码。


开源地址:github.com/openai/open…


编辑


添加图片注释,不超过 140 字(可选)


05. 中华古诗词数据库


为了让古诗词这个人类瑰宝传承下去,中华古诗词数据库诞生了。


这个项目整理了中华大量的古诗词,支持 Json 格式。数据库包含唐宋两朝近一万四千古诗人的作品, 接近 5.5 万首唐诗、26 万宋诗. 两宋时期1564位词人,21050首词。


开源地址:github.com/chinese-poe…


编辑


添加图片注释,不超过 140 字(可选)



  1. 动画编程


Motion Canvas 是一个 TypeScript 库,可以通过编程的方式生成动画,并提供所述动画的实时预览的编辑器。


开源地址:github.com/motion-canv…




编辑


添加图片注释,不超过 140 字(可选)


历史盘点


逛逛 GitHub 每天推荐一个好玩有趣的开源项目。历史推荐的开源项目已经收录到 GitHub 项目,欢迎 Star:

地址:https://github.com/Wechat-ggGitHub/Awesome-GitHub-Repo


作者:逛逛GitHub
链接:https://juejin.cn/post/7240690534075318309
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

this指向哪?

web
谁调用就指向谁 this 指向哪?先记住一句话:谁调用就指向谁,记住这句话就成功一大半了。 var userName = '张三' function fn() { var userName = '李四' console.log(this.use...
继续阅读 »

谁调用就指向谁


this 指向哪?先记住一句话:谁调用就指向谁,记住这句话就成功一大半了。


var userName = '张三'
function fn() {
var userName = '李四'
console.log(this.userName) // 张三
}
fn()

函数fn相当于挂在window,调用fn()等同于window.fn(),所以fn可以看做是被window调用,此时this便指向window,所以输出结果是函数外的userName变量。


再看下面的示例:


var userName = '张三'
var obj = {
userName: '李四',
fn: function() {
console.log(this.userName) // 李四
}
}

obj.fn()

函数fn被定义在对象obj里,通过obj.fn()调用,回到我们开头的那句话:谁调用就指向谁fnobj调用,所以this指向obj,输出结果是李四。


如果使用windowd.obj.fn()调用呢?结果还是一样的,因为最终是obj调用,所以this指向的还是obj


接着往下看下面这段代码:


var userName = '张三'
var obj = {
userName: '李四',
fn: function() {
console.log(this.name) // 张三
}
}

var func = obj.fn
func()

输出结果为张三,因为代码中只是将obj.fn赋值给func,此时函数fn还没有被调用,真正调用是在func()func挂在window下,所以最终相当于是window调用了函数fn,所以this指向的是window


还是应了那句话:谁调用就指向谁


其他情况


1、作为一个函数被直接调用


当函数被直接调用,没有挂在任何对象上时,this指向window


var userName = '张三'
var obj = {
userName: '李四',
fn: function() {
function a() {
console.log(this.userName) // 张三
}
a()
}
}
obj.fn()

这里在fn中定义了一个函数a并调用,注意这里函数a只是在函数fn内,它并没有挂在哪个对象上,也没有通过其他方式被调用,而是作为一个函数被直接调用,所以this指向window


2、箭头函数


箭头函数的特点是没有自己的this也不能通过new调用。箭头函数通过作用域向上查找,找到离包裹它最近的那一个普通函数的this作为自己的this,如果没有则指向全局对象window


var userName = '张三'
var obj = {
userName: '李四',
fn: function() {
var func = () => {
console.log(this.userName) // 李四
}
func()
},
a: {
b: () => {
console.log(this.userName) // 张三
}
}
}
obj.fn()
obj.a.b()

func是一个箭头函数,箭头函数通过向上查找找到fnfnthis指向obj,所以箭头函数的this指向的也是obj,输出结果:李四。


obj.a.b()则指向window,输出结果:张三。


3、new 构造函数


当函数通过使用new调用时,new过程中会创建一个新的对象,而this便是指向这个新创建的对象。


function fn(name) {
this.userName = name
this.age = 18
console.log(this) // {userName: '张三', age: 18}
}
let data = new fn('张三')
// new的同时内部会默认把this做回返回值返回
console.log('data: ', data) // data: {userName: '张三', age: 18}

如何改变this指向


1、call、apply、bind


可以通过callapplybind的第一个参数传值作为this值,改变this指向。


obj.fn('hello') // hello,张三
obj.fn.call({ userName: '李四' }, 'hello') // hello,李四
obj.fn.apply({ userName: '王五' }, ['hello']) // hello,王五
obj.fn.bind({ userName: '老六' }, 'hello')() // hello,老六

2、call、apply、bind区别


从上面的使用可以看到,这三个API的参数形式除了第一个参数外是有一些区别的



  • callbind是多参数的形式:call(thisArg, arg1, arg2...)

  • apply是数组的形式:call(thisArg, [arg1, arg2...])


另外一个区别是:bind的返回值是一个函数,需要自己再手动调用一次,而callapply<

作者:狂砍2分4篮板
来源:juejin.cn/post/7265534390506635319
/code>则不用。

收起阅读 »

面试官:“只会这一种懒加载实现思路?回去等通知吧”

web
思路一:监听滚动事件 监听滚动事件指的是:通过监听页面的滚动事件,判断需要懒加载的元素是否进入可视区域。当元素进入可视区域时,动态加载对应的资源。这种方式需要手动编写监听滚动事件的逻辑,可能会导致性能问题,如滚动时的抖动和卡顿。 关键 API getBo...
继续阅读 »

思路一:监听滚动事件



监听滚动事件指的是:通过监听页面的滚动事件,判断需要懒加载的元素是否进入可视区域。当元素进入可视区域时,动态加载对应的资源。这种方式需要手动编写监听滚动事件的逻辑,可能会导致性能问题,如滚动时的抖动和卡顿。



关键 API



  • getBoundingClientRect() 方法返回的对象包含以下属性:



  • top:元素上边缘相对于视窗的距离。

  • right:元素右边缘相对于视窗的距离。

  • bottom:元素下边缘相对于视窗的距离。

  • left:元素左边缘相对于视窗的距离。

  • width:元素的宽度(可选)。

  • height:元素的高度(可选)。


可以看下下面的图。
image.png


来看看代码示例


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lazy Loading Exampletitle>
<style>
img {
width: 100%;
height: 300px;
display: block;
}
style>
head>
<body>
<img data-src="https://via.placeholder.com/300x300?text=Image+1" src="" alt="Image 1">
<img data-src="https://via.placeholder.com/300x300?text=Image+2" src="" alt="Image 2">
<img data-src="https://via.placeholder.com/300x300?text=Image+3" src="" alt="Image 3">
<script>
function isInViewport(element) {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;

return (
rect.
top >= 0 &&
rect.
left >= 0 &&
rect.
bottom <= windowHeight &&
rect.
right <= windowWidth
);
}

function lazyLoad() {
const images = document.querySelectorAll('img[data-src]');
for (const img of images) {
if (isInViewport(img)) {
img.
src = img.getAttribute('data-src');
img.
removeAttribute('data-src');
}
}
}

window.addEventListener('scroll', lazyLoad);
window.addEventListener('resize', lazyLoad);
window.addEventListener('DOMContentLoaded', lazyLoad);

script>
body>
html>

这里的思路就相对简单了,核心思路就是判断这个元素在不在我的视口里面。


同样的思路下面还有另一种实现方式,是用 offsetTopscrollTopinnerHeight做的,这个很常见,我们就不说了。


思路二:Intersection Observer API



这是一种现代浏览器提供的原生 API,用于监控元素与其祖先元素或顶级文档视窗(viewport)的交叉状态。当元素进入可视区域时,会自动触发回调函数,从而实现懒加载。相比于监听滚动事件,Intersection Observer API 更高效且易于使用。



关键 API



  • Intersection Observer API:创建一个回调函数,该函数将在元素进入或离开可视区域时被调用。回调函数接收两个参数:entries(一个包含所有被观察元素的交叉信息的数组)和 observer(观察者实例)。


其中 entries 值得一提,entries 是一个包含多个 IntersectionObserverEntry 对象的数组,每个对象代表一个观察的元素(target element)与根元素(root element)相交的信息。IntersectionObserverEntry 对象包含以下属性:



  1. intersectionRatio: 目标元素和根元素相交区域占目标元素总面积的比例,取值范围为 0 到 1。

  2. intersectionRect: 目标元素和根元素相交区域的边界信息,是一个 DOMRectReadOnly 对象。

  3. isIntersecting: 布尔值,表示目标元素是否正在与根元素相交。

  4. rootBounds: 根元素的边界信息,是一个 DOMRectReadOnly 对象。

  5. target: 被观察的目标元素,即当前 IntersectionObserverEntry 对象所对应的 DOM 元素。

  6. time: 观察到的相交时间,是一个高精度时间戳,单位为毫秒。


这个最近我们组里的姐姐封了一个懒加载组件,通过单例模式 + Intersection Observer API,然后在外部控制 v-if 就好了,非常 nice。


由于代码保密性,我这里只能提供一个常规实现方法qwq


html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lazy Loading Example - Intersection Observertitle>
<style>
img {
width: 100%;
height: 300px;
display: block;
}
style>
head>
<body>
<img data-src="https://via.placeholder.com/300x300?text=Image+1" src="" alt="Image 1">
<img data-src="https://via.placeholder.com/300x300?text=Image+2" src="" alt="Image 2">
<img data-src="https://via.placeholder.com/300x300?text=Image+3" src="" alt="Image 3">
<script>
function loadImage(img) {
img.
src = img.getAttribute('data-src');
img.
removeAttribute('data-src');
}

function handleIntersection(entries, observer) {
entries.
forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target);
observer.
unobserve(entry.target);
}
});
}

function lazyLoad()
script>
body>
html>


思路三:虚拟列表



对于长列表数据,可以使用虚拟列表技术来实现懒加载。虚拟列表只会渲染当前可视区域内的列表项,当用户滚动列表时,动态更新可视区域内的内容。这种方式可以大幅减少 DOM 节点的数量,提高页面性能。



这个我之前专门有文章聊过:b站面试官:如果后端给你 1w 条数据,你如何做展示?


就不赘述啦~


那么这就是本篇文章的全部内容啦~


作者:阳树阳树
来源:juejin.cn/post/7265329025899823159
收起阅读 »

动态样式去哪儿了?

web
本文作者是蚂蚁集团前端工程师豆酱,众所周知,antd v5 使用了 CSS-in-JS 技术从而支持混合、动态样式的需求。相对的它需要在运行时生成样式,这会造成一定的性能损耗。因此我们研发了组件库级别的 @ant-design/cssinjs 库,通过一定的约...
继续阅读 »

本文作者是蚂蚁集团前端工程师豆酱,众所周知,antd v5 使用了 CSS-in-JS 技术从而支持混合、动态样式的需求。相对的它需要在运行时生成样式,这会造成一定的性能损耗。因此我们研发了组件库级别的 @ant-design/cssinjs 库,通过一定的约束提升缓存效率,从而达到性能优化的目的。不过我们并不止步于此。我们可以通过一些逻辑,直接跳过运行时生成样式的阶段。


动态样式去哪儿了?


如果你研究过 Ant Design 的官网,你会发现 Ant Design 的组件并没有动态插入 来控制样式,而是通过 CSS 文件来控制样式:

image.png


image.png


document.head 里有几个 css 文件引用:



  • umi.[hash].css

  • style-acss.[hash].css


前者为 dumi 生成的样式内容,例如 Demo 块、搜索框样式等等。而后者则是 SSR 生成的样式文件。在定制主题文档中,我们提过可以通过整体导出的方式将页面中用到的组件进行预先烘焙,从而生成 css 文件以供缓存命中从而提升下一次打开速度。这也是我们在官网中使用的方式。所以 Demo 中的组件,其实就是复用了这部分样式。 等等!CSS-in-JS 不是需要在运行时生成样式的 hash 然后通过 进行对齐的么?为什么 css 文件也可以对齐?不用着急,我们慢慢看。

CSS-in-JS 注水


应用级的 CSS-in-JS 方案会对生成的样式计算出 hash 值,并且将其存入 Cache 中。当下次渲染时,会先从 Cache 中查找是否存在对应的样式,如果存在则直接使用,否则再生成一次。这样就可以避免重复生成样式,从而提升性能。


image.png


每个动态插入到页面中的样式同样以为 hash 作为唯一标识符。如果页面中已经存在该 hash 的 ,则说明 SSR 中做过 inline style 注入。那么 就不用再次创建。 你可以发现,虽然 的节点创建可以省略,但是因为 hash 依赖于计算出的样式内容。所以即便页面中已经有可以复用的样式内容,它仍然免不了需要计算一次。实属不划算。

组件级 CSS-in-JS


组件级别的 CSS-in-JS 一文中,我们提过。Ant Design 的 Cache 机制并不需要计算出完整的样式。对于组件库而言,只要通过 Token 和 ComponentName 就可以确定生成样式一致性,所以我们可以提前计算出 hash 值: 也因此,我们发现可以复用这套机制,实现在在客户端侧感知组件样式是否已经注入过。


SSR HashMap


在 @ant-design/cssinjs 中,Cache 本身包含了每个元素对应的 style 和 hash 信息。过去的 extractStyle 方法只取 Cache 中 style 的内容进行封装:


// e.g. Real world path is much more complex

{

  "bAMbOo|Button": ["LItTlE"":where(.bAMbOo).ant-btn { color: red }"],

  "bAMbOo|Spin": ["liGHt"":where(.bAMbOo).ant-spin { color: blue }"]

}

提取:


:where(.bAMbOo).ant-btn {

  color: red;

}

:where(.bAMbOo).ant-spin {

  color: blue;

}

为了复用样式,我们更进一步。将 path 和 hash 值也进行了抽取:


{

  "bAMbOo|Button": "LItTlE",

  "bAMbOo|Spin""liGHt"

}

并且也打成 css 样式:


// Just example. Not real world code

.cssinjs-cache-path {

  content'bAMbOo|Button:LItTlE;bAMbOo|Spin:liGHt';

}

这样 SSR 侧就将我们所需的信息全部留存了下来,接下去只需要在客户端进行提取即可。


CSR HashMap


在客户端则简单的多,我们通过 getComputedStyle 提取 HashMap 信息留存即可:


// Just example. Not real world code

const measure = document.createElement('div');

measure.className = 'cssinjs-cache-path';

document.body.appendChild(measure);

 

// Now let's parse the `content`

const { content } = getComputedStyle(measure);

在组件渲染阶段,useStyleRegister 在计算 CSS Object 之前,会先在 HashMap 中查找 path 是否存在。如果存在,则说明该数据已经通过服务端生成。我们只需要将样式从现有的 里提取出来即可:

// e.g. Real world path is much more complex

{

  "bAMbOo|Button": ["LItTlE""READ_FROM_INLINE_STYLE"],

  "bAMbOo|Spin": ["liGHt""READ_FROM_INLINE_STYLE"]

}

而对于 CSS 文件提供的样式(比如官网的使用方式),它不像 会被移除,我们直接标记为来自于 CSS 文件即可。和 inline style 一样,它们会在 useInsertionEffect 阶段被跳过。

// e.g. Real world path is much more complex

{

  "bAMbOo|Button": ["LItTlE""__FROM_CSS_FILE__"],

  "bAMbOo|Spin": ["liGHt""__FROM_CSS_FILE__"]

}

总结


CSS-in-JS 因为运行时的性能损耗而被人诟病。而在 Ant Design 中,如果你的应用使用了 SSR,那么在客户端侧就可以直接跳过运行时生成样式的阶段从而提升性能。当然,我们会继续跟进 CSS-in-JS 的发展,

作者:支付宝体验科技
来源:juejin.cn/post/7265249262329839672
为你带来更好的体验。

收起阅读 »

Axios的封装思路与技巧

web
Axios的封装思路与技巧 提示:文中使用的为ts代码,对ts不熟悉的同学可以删除所有类型降级为js代码,不影响使用 前言 项目中或多或少会有一些需要接口发送请求的需求,与其复制粘贴别人在业务中对请求方法的使用,不如自己花点时间研究项目中请求方法的实现,这样在...
继续阅读 »

Axios的封装思路与技巧


提示:文中使用的为ts代码,对ts不熟悉的同学可以删除所有类型降级为js代码,不影响使用


前言


项目中或多或少会有一些需要接口发送请求的需求,与其复制粘贴别人在业务中对请求方法的使用,不如自己花点时间研究项目中请求方法的实现,这样在处理请求出现的问题时能够更好的定位问题原因。本文循序渐进的介绍了如何对axios进行封装实现自己项目中的请求方法,希望各位同学在阅读后能有一定的体会,如有问题还请大家在评论区指正。


1.创建axios实例


创建一个axios实例,在这里为实例进行一些配置(如超时时间)。但是要注意,不要在此处配置一些动态的属性,如headers中的token,具体的原因我们会在后面提起


import axios from 'axios'
const instance = axios.create({
 timeout: 1000 * 120, // 超时时间120s
})

为实例配置拦截器(请求拦截器,响应拦截器)


可能有些同学对axios不太熟悉,不了解请求拦截器和响应拦截器的作用,这里会简单介绍一下


请求拦截器:在请求发送前进行拦截,或者对请求错误进行拦截


instance.interceptors.request.use(
 config => {
   // config为AxiosRequestConfig的一个实例,它是包含请求配置参数的对象
   // 在这里可以在请求发送前做一些处理,如向config实例中添加属性,取消请求,设置loading等  
   return config
},
 // 这里是请求报错时的拦截方法,这里直接返回一个状态为reject的promise
 // 实际测试时,即使前端请求报错并且未到达后端,也没有触发这里的钩子函数
 error => Promise.reject(error),
)

响应拦截器:在响应被.then.catch处理前拦截


instance.interceptors.response.use(
 response => {
   // 响应成功的场景
   // 在这里可以关闭loading或者对响应的返参对象response进行处理
   return response
},
 error => {
   // 响应失败的场景
   // http状态码不为2xx时就会进入,根据项目要求处理接口401,404,500等情况
   // 返回的promise也可以根据项目要求进行修改
   return Promise.reject(error)
},
)

这样我们就创建了一个可用的axios实例,对于实例的一些其他配置可以参考axios官网


2.创建Abstract类进一步封装


在创建了一个axios实例之后,我们就可以使用它去发送请求了,但是出于减少重复代码的目的,我们不在业务代码中直接使用axios实例去发送各种请求,而是选择去做进一步的封装让整体的代码更加简洁


通常来说,我在项目中更喜欢用面向对象的方式去对axios做进一步的封装,使用这种方式的优点会在后面进行说明 创建一个类,起名可以按自己的喜好来,这里我写的是Abstract,因为它的主要作用是做为一个底层的类让其他类去继承,在这里我们提供一些属性的配置,以及一些基础的请求方法


import axios from './axios'
import type { AxiosRequest, CustomResponse } from './types/index'
class Abstract {
 // 配置接口的baseUrl,这里用的是vite环境变量,可以根据需求自行修改
 protected baseURL: string = import.meta.env.VITE_BASEURL
 // 配置接口的请求头,这里仅简单配置一下
 protected headers: object = {
   'Content-Type': 'application/json;charset=UTF-8',
}
 // 提供类的构造器,可以在这里修改一些基础参数如baseUrl
 constructor(baseURL?: string) {
   this.baseURL = baseURL ?? this.baseURL
}
 // 重点!发起请求的方法
 // 这里的T是ts中泛型的用法,主要用于控制接口返回的类型,不熟悉ts的同学可以略过
 private apiAxios<T = any>({
   baseURL = this.baseURL,
   headers = this.headers,
   method,
   url,
   data,
   params,
   responseType
}: AxiosRequest): Promise<CustomResponse<T>> {
 // 在这里加上请求头的好处在于,每次请求时都会动态读取存储的token值
 // 正如前面所说的,不要在创建axios实例时在header上配置token是因为,浏览器除非刷新,否则只会创建一次axios实例,它的header上的token的值不会发生变化,如果涉及到用户退出等清除token的操作,下次登录时获得的新token不会被使用
 Object.assign(headers, {
   // 根据情况使用localStorage或sessionStorage
   token: localStorage.getItem('token')        
})
 return new Promise((resolve, reject) => {
   axios({
     baseURL,
     headers,
     method,
     data,
     url,
     params,
     responseType,
  })
    .then(res => {
       // 在这里处理http2xx的接口,根据业务的需要进行一些处理,返回一个成功的promise
       // 这里仅为演示,直接返回了原始的res
       resolve(res)
    })
    .catch(err => {
       // 在这里处理http不成功的状态,并根据业务的需要进行一个处理,返回一个失败的promise
       reject(err)
    })
  })
}
 // 通常我还会在基础类上封装一些现成的请求方法,如Get Post等,可以根据自己的需要封装其他的请求方法
 protected getReq<T = any>({ baseURL, headers, url, data, params, responseType, messageType }: AxiosRequest) {
   return this.apiAxios<T>({
     baseURL,
     headers,
     method: 'GET',
     url,
     data,
     params,
     responseType,
     messageType,
  })
}
   
 protected postReq<T = any>({ baseURL, headers, url, data, params, responseType, messageType }: AxiosRequest) {
   return this.apiAxios<T>({
     baseURL,
     headers,
     method: 'POST',
     url,
     data,
     params,
     responseType,
     messageType,
  })
}

3.继承Abstract类实现业务相关的请求类


这样,我们就成功封装了一个Axios的基础类,接下来可以创建一个新的业务类去继承它并使用。这里我们创建一个User类,代表用户相关的请求


import Abstract from '@/api/abstract'

class User extends Abstract {
 constructor(baseUrl?: string) {
   super(baseUrl)
}

 // post请求
 login(data: unknown) {
   return this.postReq({
     data,
     url: 'back/v1/user/login',
  })
}
 
 // get请求
 getUser(param: { id: string })
   return this.getReq({
     param,
     url: 'back/v1/user/getUser'
  })
}
 
 // 需要修改请求头的Content-Type,如表单上传
 saveUser(data: any) {
   const formData = new FormData()
   Object.keys(data).forEach(key => {
     formData.append('file', data[key])
  })
   return this.postReq({
     data,
     headers: {
       'Content-Type': 'multipart/form-data',
    },
     url: 'back/v1/user/saveUser',
  })
}

export default User

文件创建好了之后我们就可以引用到具体项目中使用了


// 可以在这里传入baseUrl,这也是基于类封装的好处,我们可以实例化多个user并使用不同的baseUrl
const userInstance = new User()
const res = await userInstance.login({
 username: 'xxx',
 password: 'xxx'
})
作者:kamesan
来源:juejin.cn/post/7264749103125184527

收起阅读 »

我家等离子电视也能用的移动端适配方案

web
前几天我的领导“徐江”让我把一个移动端项目做一下适配,最好让他在家用等离子电视也能看看效果,做不出来就给我“埋了”,在这种情况下才诞生了这篇文章~ 什么是移动端适配 移动端适配是指在不同尺寸的移动端设备上,页面能相对达到合理的显示或者保持统一的等比缩放效果 ...
继续阅读 »

前几天我的领导“徐江”让我把一个移动端项目做一下适配,最好让他在家用等离子电视也能看看效果,做不出来就给我“埋了”,在这种情况下才诞生了这篇文章~



什么是移动端适配


移动端适配是指在不同尺寸的移动端设备上,页面能相对达到合理的显示或者保持统一的等比缩放效果


移动端适配的两个概念



  1. 自适应:根据不同的设备屏幕大小来自动调整尺寸、大小

  2. 响应式:会随着屏幕的实时变动而自动调整,是一种自适应


1.png

而在我们日常开发中自适应布局在pc端和移动端应用都极为普遍,一般是针对不同端分别做自适应布局,如果要想同时兼容移动端和pc端,尤其是等离子电视这样的大屏幕,那么最好还是使用响应式布局~


移动端适配-视口(viewport)


在一个浏览器中,我们可以看到的区域就是视口(viewport),我们在css中使用的fixed定位就是相对于视口进行定位的。

在pc端的页面中,我们不需要对视口进行区分,因为我们的布局视口和视觉视口都是同一个。而在移动端是不太一样的,因为我们的移动端的网页往往很小,有可能我们希望一个大的网页在移动端上也可以完整的显示,所以在默认情况下,布局视口是大于视觉视口的。


  <style>
.box {
width: 100px;
height: 100px;
background-color: orange;
}
</style>
<body>
<div class="box"></div>
</body>


pc端展示效果
3.png


移动端展示效果
4.png
从上图可以看出在移动端上同样是100px的盒子,但是却没有占到屏幕的1/3左右的宽度,这是因为在大部分浏览器上,移动端的布局视口宽度为980px,我们在把pc端的页面切换成移动端页面时,右上角也短暂的显示了一下我们的布局视口是980px x 1743px。

所以在移动端下,我们可以将视口划分为3种情况:



  1. 布局视口(layout viewport)

  2. 视觉视口(visual layout)

  3. 理想视口(ideal layout)


这些概念的提出也是来自于ppk,是一位世界级前端技术专家。
贴上大佬的文章链接quirksmode.org/mobile/view…


1.jpeg

所以我们相对于980px布局的这个视口,就称之为布局视口(layout viewport),而在手机端浏览器上,为了页面可以完整的显示出来,会对整个页面进行缩小,那么显示在可见区域的这个视口就是视觉视口(visual layout)。


3.png


4.png

但是我们希望设置的是100px就显示的是100px,而这就需要我们设置理想视口(ideal layout)。


// initial-scale:定义设备宽度与viewport大小之间的缩放比例
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

移动端适配方案



  1. 百分比设置

  2. rem单位+动态html的font-size

  3. flex的弹性布局

  4. vw单位


在我们移动端适配方案中百分比设置是极少使用的,因为相对的参照物可能是不同的,所以百分比往往很难统一,所以我们常常使用的都是后面3种方案。


rem单位+动态html的font-size


rem单位是相对于html元素的font-size来设置的,所以我们只需要考虑2个问题,第一是针对不同的屏幕,可以动态的设置html不同的font-size,第二是将原来的尺寸单位都转换为rem即可。



talk is cheap, show me the code



/*
* 方案1:媒体查询
* 缺点:需要针对不同的屏幕编写大量的媒体查询,且如果动态的修改尺寸,不会实时的进行更新
*/

<style>
@media screen and (min-width: 320px) {
html {
font-size: 20px;
}
}
@media screen and (min-width: 375px) {
html {
font-size: 24px;
}
}
@media screen and (min-width: 414px) {
html {
font-size: 28px;
}
}
.box {
width: 5rem;
height: 5rem;
background-color: orange;
}
</style>

<body>
<div class="box"></div>
</body>

/*
* 方案2:编写js动态设置font-size
*/

<script>
const htmlEl = document.documentElement;

function setRem() {
const htmlWidth = htmlEl.clientWidth;
const htmlFontSize = htmlWidth / 10;
htmlEl.style.fontSize = htmlFontSize + "px";
}
// 第一次不触发,需要主动调用
setRem();

window.addEventListener("resize", setRem);
</script>


<style>
.box {
width: 5rem;
height: 5rem;
background-color: orange;
}
</style>


<body>
<div class="box"></div>
</body>


但是写起来感觉还是好麻烦,如果可以的话我希望白嫖-0v0-


5.png

所以我选择 postcss-pxtorem,vscode中也可以下载到相关插件哦,一鱼多吃,😁


vw单位


/*
* 方案1:手动换算
*/

<style>
/** 设置给375的设计稿 */
/** 1vw = 3.75px */
.box {
width: 26.667vw;
height: 26.667vw;
background-color: orange;
}
</style>

/*
* 方案2:less/scss函数
*/

@vwUnit: 3.75;

.pxToVw(@px) {
result: (@px / @vwUnit) * 1vw;
}
.box {
width: .pxToVw(100) [result];
height: .pxToVw(100) [result];
background-color: orange;
}

当然白嫖党永不言输,我选择 postcss-px-to-viewport,贴一下我的配置文件~


module.exports = {
plugins: {
"postcss-px-to-viewport": {
unitToConvert: "px", //需要转换的单位,默认为"px"
viewportWidth: 750, // 视窗的宽度,对应设计稿的宽度
viewportUnit: "vw", // 指定需要转换成的视窗单位,建议使用 vw,兼容性现在已经比较好了
fontViewportUnit: "vw", // 字体使用的视口单位

// viewportWidth: 1599.96, // 视窗的宽度,对应设计稿的宽度
// viewportUnit: 'vw', // 指定需要转换成的视窗单位,建议使用 vw
// fontViewportUnit: 'vw', // 字体使用的视口单位

unitPrecision: 8, // 指定`px`转换为视窗单位值的小数后 x位数
// viewportHeight: 1334, //视窗的高度,正常不需要配置
propList: ["*"], // 能转化为 rem的属性列表
selectorBlackList: [], //指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名
minPixelValue: 1, // 小于或等于`1px`不转换为视窗单位,你也可以设置为你想要的值
mediaQuery: false, // 允许在媒体查询中转换
replace: true, //是否直接更换属性值,而不添加备用属性
// exclude: [], //忽略某些文件夹下的文件或特定文件,例如 'node_modules' 下的文件
landscape: false, //是否添加根据 landscapeWidth 生成的媒体查询条件 @media (orientation: landscape)
landscapeUnit: "rem", //横屏时使用的单位
landscapeWidth: 1134 //横屏时使用的视口宽度
}
}
};

rem单位和vw单位的区别


rem事实上是作为一个过渡方案,它利用的也是vw的思想,并且随着前端的发展,vw的兼容性已经越来越好了,可以说具备了rem的所有优势。

但是我们假想一个这样的场景,我们希望网页在达到800px的时候页面布局不需要继续扩大了,这个时候如果我们采用的是rem布局,我们可以使用媒体查询设置max-width,而vw则始终是以视口为单位,自然不容易处理这样的场景。

当然,vw相比于rem,存在以下优势:



  1. 不需要计算font-size大小

  2. 不存在font-size继承的问题

  3. 不存在因为某些原因篡改了font-size导致页面尺寸混乱的问题

  4. vw更加的语义化

  5. 具备rem的所有优点


所以在开发中也更推荐大家使用vw单位进行适配。


6.jpeg


作者:魔术师Grace
来源:juejin.cn/post/7197623702410248251
收起阅读 »

60分前端之canvas

web
前言 最近在等前端对接,没事干就折腾一下前端。写了这么多年服务端,一直都是写api接口,还没好好学过前端呢。前端框架那么多,什么Vue、React,还分移动端、PC端,还有什么响应式、自适应,各种花里胡哨的东西,也没那么多精力去折腾,直接上手小程序吧。 小程序...
继续阅读 »

前言


最近在等前端对接,没事干就折腾一下前端。写了这么多年服务端,一直都是写api接口,还没好好学过前端呢。前端框架那么多,什么Vue、React,还分移动端、PC端,还有什么响应式、自适应,各种花里胡哨的东西,也没那么多精力去折腾,直接上手小程序吧。


小程序开发虽然简单,但基本的CSS样式还是得学一下吧:

1. MDN官方CSS教程
2. 谷歌出的CSS教程
3. 谷歌HTML教程


在国内,微信小程序开发,应该是每个前端程序员必会的技术了吧。


话不多说,直接开干。


写了这么多年的hello world了,学一门“新”的技术还是很简单的。而且咱要求也不高,毕竟不是专门写前端的,所以对于前端的技术,只要做到60分就够了。


所以,下面很多东西,我们都是能忽略就忽略,主打一个囫囵吞枣、不求甚解,以解决问题为主。


微信小程序类目的坑


微信小程序文档


学习任何技术,官方文档都是最好的入门方式之一。


查看微信小程序官方文档,照着文档一步一步往下走,有个两年开发经验的人,基本都能搞定,没什么难度。


但是问题来了,在配置小程序时,需要设置小程序类目。这里提前说一下,因为是练手的项目,所以我写的这个小程序是个大杂烩,既有数独小游戏,又有背单词。由于微信小程序类目可以选择多个,所以我先选择了小游戏......


问题来了!


微信小程序类目,选择了游戏之后,就不能选择其他的了!


而且还改不了!!


改不了就算了,更坑的是,小游戏的代码结构和小程序还不一样,没办法通用。选择小游戏,就必须用小游戏的代码结构,没办法和小程序混用。


练手的几个小游戏,我都是用小程序实现的,没办法在小游戏模式下运行。


网上搜了一下,发现很多人都遇到过这样的问题,也都向官方反映过了,但官方都没有给回复。顺带一提,好像社区里很多问题,官方基本都不回复的。


没办法,只能重新注册一个号了。或者注销之前的号,然后重新注册也行。嫌麻烦,干脆重新注册一个号吧。


数独81宫格实现过程

数独游戏估计很多人都只听过,但没实际玩过,这里就不详细介绍数独游戏了,我们只需要知道一点,数独游戏需要有一个81宫格。


可以看到,数独的81宫格最外层边框加粗,里面每三列的右边框、每三行的下边框也加粗,相当于是9个9宫格合并在一起。

作为前端的初学者,想要实现一个样式,第一反应肯定是用CSS来实现。

CSS选择器

先来把基本的框给实现了。

.sudoku {
box-sizing: border-box;
width: 700rpx;
min-height: 730rpx;
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr);
gap: 0;
border: 2px solid #000000;
}
.cell {
background-color: #FFFFFF;
border: 1px solid #000000;
border-right-width: 0;
border-bottom-width: 0;
}

实现效果是这样的:


不知道大家有没有注意到这段样式min-height: 730rpx;,设置最小高度。


一开始设置height: 700rpx;,宽高都是700rpx,在iPhone12/13 Pro Max机型下没问题,可是在iPhone12/13 Pro机型下,最下面一行会少一截,高度不够了!


前端的同学都知道CSS的一个概念,叫盒模型。这里高度不够是因为每个小格子的边框也有个宽度,实际总高度应该是每个格子的高度加上格子边框的宽度之和。宽度也是一样的。


这里有一个问题,就算宽高要加上每个小格子边框的宽度,最终的宽高也应该一样的才对啊?!为什么高度反而比宽度还大了呢?!


不理解!算了,毕竟是写服务端的,前端只要做到60分就够了。解决问题就行。高度不够的话,给它加高不就行了。/doge


加多少呢?试呗。😁


好了,在iPhone12/13 Pro机型下问题是解决了,但其他机型下呢?


这个好办,既然知道高度可能不够,继续加高的话,怕影响其他机型下的效果,那干脆给一个最低高度,超过这个高度就让它自己加高,简单粗暴。


它已经是个成熟的高度了,已经学会了自己长高。


还有一种更直接的办法,就是不设置高度,让它自己撑开。但是这样有一个不好的点,高度在撑开的过程中,会很明显看到页面抖动。设置一个最低高度的话,抖动就不明显了。


只要我看不到它抖动,它就不存在。


基本的形状已经有了,下面开始对内部指定的边框进行加粗。先来对每三列的右边框加粗,看过上面的CSS教程的话,稍微学点CSS知识的人都知道,CSS有个伪类选择器,我们用选择器nth-child来实现:

.cell:nth-child(3n+1) {
border-left-width: 2px;
}

:nth-child(n) 选择器匹配父元素中的第 n 个子元素,元素类型没有限制。


n 可以是一个数字,一个关键字,或者一个公式。



这里,我们用的是公式的方式。



使用公式(an+ b)a代表一个循环的大小,n是一个计数器(从0开始),以及b是偏移量。ab都必须是整数,an 必须写在 b 的前面,不能写成 b+an 的形式。


第一列左边框看起来太粗了,虽然样式上看起来边框的宽度是2px,但视觉上却像是4px也不知道是什么原因?


不管它什么原因了,毕竟我们不是专业的前端,就像上面说的那样,我们只需要做到60分就够了。


既然视觉上看起来像是4px,那就让它在视觉上看起来像2px

.cell:nth-child(9n+1) {
border-left-width: 1px;
}


现在看起来顺眼了很多。


宽度明明设置的是1px,为什么视觉上看起来像2px,不知道什么原因?老规矩,不管!😁


每三列的左边框加粗,这个已经实现了,下面开始实现每三行的下边框加粗


可是问题来了,使用:nth-child选择器貌似不能选中连续的子元素,网上搜了一堆,都没有解决方案,后来问了一下ChatGPT,给的回复是这样的:

/* 设置第3行和第6行的下边框宽度为2px */
.cell:nth-child(3n+1):nth-child(n+7),
.cell:nth-child(3n+2):nth-child(n+8),
.cell:nth-child(3n+3):nth-child(n+9) {
border-bottom-width: 2px;
}

还亲切地给了注释。可惜根本用不了。


除了第一行前6个的下边框没加粗外,其他元素的下边框全加粗了!


题外话:ChatGPT对CSS的问题,一本正经地胡说八道,给的答案基本都用不了。


折腾了好久,都没折腾出怎么用CSS选择器实现每三行下边框加粗这个效果,算了,换种方式吧。


对象模型


数独游戏不但要有这个宫格,相应的宫格里还得要有数字的。最终的效果应该像这样:


既然没办法自动计算出每三列和每三行的元素,那我们就用笨办法,手动给它们全标记出来。

定义对象模型:

interface SudokuItem {
numerical: number, // 数值
tower: boolean, // 左边框是否需要加粗
floor: boolean // 下边框是否需要加粗
}

初始化对象模型:

Page({
data: {
sudoku: [
{numerical: 0, tower: false, floor: false},
{numerical: 0, tower: false, floor: false},
{numerical: 0, tower: true, floor: false},
...
]
}
})

实际初始化的时候,通过JS代码初始化sudoku,并计算哪个元素的tower为true,哪个floor为true。

页面渲染的时候:


class="cell-item {{item.tower ? 'tower' : ''}} {{item.floor ? 'floor' : ''}}"
bindtap="focusTheCell"
data-index="{{idx}}"
>
{{item.numerical == 0 ? undefined : item.numerical}}

.sudoku .floor {
border-top-width: 2px;
}

.sudoku .tower {
border-right-width: 2px;
}

好了,基本的样式是实现了,虽然过程很简单粗暴,但咱毕竟是写服务端的,对前端的要求也不高,能做到60分就够了。


继续折腾,上面是用CSS样式实现的81宫格,还有其他办法吗?


必须有啊,前端的Canvas技术也很火啊,这个必须得折腾一下啊。


而且貌似还有很多前端不会Canvas的哦,等我们学会了,就去找前端嘚瑟一下。


Canvas


MDN官方教程


先看Canvas官方教程,嗯......,东西挺多的,好像有点复杂,几十个API,看得人眼花。没关系,毕竟我们是带着问题来学习的。这里,我们只需要几个API就行了。


什么问题?使用Canvas画出一个数独的81宫格。


我在学习一个新技术的时候,都是先上手再深入,不懂的地方暂时先跳过去,前面说过的,主打一个囫囵吞枣、不求甚解。先把问题解决了再说。


我们先暂时抛开小程序,只专注于Canvas。


直接开干。


首页,创建一个Canvas画布。

const canvas = document.getElementById("sudoku");
const ctx = canvas.getContext("2d");

好了,一个画布就创建好了。是不是很简单。而且是固定套路,可以不理解,记住就行了。下次用的时候,直接复制粘贴,再简单改改就行了。


id:和HTML标签的id属性一样。


widthheight:画布的宽高,可以通过CSS属性调整。这里的宽高类似于图片的原始宽高,CSS调整后的宽高就和修改页面图片的宽高一样,实际是对图片进行缩放。


canvas.getContext("2d"):接受一个参数,即上下文的类型。可以传入2dwebglwebgl2bitmaprenderer,用于创建不同类型的上下文。


这里我们传入2d,创建一个二维渲染上下文。


画布创建好了,下面该画画了。


想想看,如果让你在现实中用笔画一个81宫格,你应该怎么做?


首先,得有张白纸,然后提笔,之后开始画。


在Canvas中也一样,白纸我们已经有了,上面创建的画布就是白纸。


下面,该考虑怎么画了。初中数学老师告诉我们,两点确定一条直线,从A点到B点,画一条直线。


Canvas通过路径和填充的方式来绘制图画的,填充我们暂时不管,路径可以简单理解为线段,直线、曲线这些。


每条线段就是一条路径。



生成路径的第一步叫做 beginPath()。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形。本质上,路径是由很多子路径构成,这些子路径都是在一个列表中,所有的子路径(线、弧形、等等)构成图形。而每次这个方法调用之后,列表清空重置,然后我们就可以重新绘制新的图形。



不理解?记住就行了,都是固定套路。先记住怎么用,等bug写多了,自然就理解了。别管代码是不是写的垃圾,先敲出来再说,别只光在脑子里想。


ctx.beginPath():清空子路径列表开始一个新路径。可以简单理解成“提笔”。


ctx.moveTo(x, y):将一个新的子路径的起始点移动到 (x,y) 坐标。可以理解成上面的“从A点......”


ctx.lineTo(x, y):使用直线连接子路径的终点到(x,y)坐标。可以理解成上面的“到B点,画一条直线”


上面的(x, y)为坐标,页面坐标这个东西,相信很多人应该都已经知道了。


这里的坐标为画布中的坐标,坐标原点(0, 0)为画布的左上角。

const canvas = document.getElementById("sudoku");
// 创建二维上下文
const ctx = canvas.getContext("2d");
// 从A点
ctx.moveTo(100, 100);
// 到B点
ctx.lineTo(200, 100);

刷新一下页面,什么都没有!


别急,这就和我们现实中画画一样,先要在脑中构思,构思完了,才开始落笔绘画。


同样的道理,上面的那些相当于是在构思,构思完了后,我们得要落笔绘制。


ctx.stroke():根据当前的画线样式,绘制当前或已经存在的路径。


所以,完整的代码应该是这样的:

const canvas = document.getElementById("sudoku");
// 创建二维上下文
const ctx = canvas.getContext("2d");
ctx.beginPath();
// 从A点
ctx.moveTo(100, 100);
// 到B点
ctx.lineTo(200, 100);
// 画线
ctx.stroke();


具体可以看一下MDN的示例,使用canvas来绘制图形


好了,一条直线就绘制出来了。


可以了,能画出一条直线,我们可以开始画81宫格了。81宫格就是10条横线、10条竖线组成的。只要计算好每个点的坐标,然后画线就行了。


计算点的坐标,这是数学的范畴,就不过多赘述了,而且点的坐标计算也不难。

const canvas = document.getElementById("sudoku");
const ctx = canvas.getContext("2d");
// 单元格的宽高
const cellSize = 50;

for (let i = 0; i < 10; i++) {
// 画横线,留5px的间距
ctx.beginPath();
ctx.moveTo(5, i * cellSize + 5);
ctx.lineTo(455, i * cellSize + 5);
ctx.stroke();

// 画竖线
ctx.beginPath();
ctx.moveTo(i * cellSize + 5, 5);
ctx.lineTo(i * cellSize + 5, 455);
ctx.stroke();
}


81宫格是画出来了,可还没法用,因为数独中的81宫格,要求最外围边框加粗,里面每三列左边框加粗、每三行下边框加粗。


ctx.lineWidth:设置线段的宽度


还记得上面我们通过CSS选择器给边框加粗时遇到的痛苦吗,现在就简单多了,只需要简单的数学计算就可以了。

// 设置画笔宽度
if (i % 3 == 0) {
ctx.lineWidth = 3;
} else {
ctx.lineWidth = 1;
}

设置完后我们,看一下效果


下面是完整的代码:






<span class="hljs-string">Canvas</span> <span class="hljs-string">练习</span>





之后只要根据小程序的规则,将代码移植到小程序里就行了。至于怎么往数独里填充数字,这个之后再说吧,我们一点一点的来。这里我们只介绍这么画直线。


学一门新技术,一定要多练。数独的81宫格画完了,还可以画点其他的啊。比如画一个象棋的棋盘,或者画一个围棋的棋盘。


多练手,遇到的问题多了,自然就会了。


最后


虽然是一个练手的项目,但是既然已经做出来了,干脆就上线算了。

作者:W_懒虫
链接:https://juejin.cn/post/7260820017758093349
来源:稀土掘金
收起阅读 »

背包算法——双条件背包

web
通过8.6日的小红书笔试第二题,我彻底搞懂了01背包与完全背包。 这篇文章不是手把手的基础教学,简单从我自己的对背包问题掌握的基础上分享一下新的心得,随缘帮助陌生人。 题目: 小红很喜欢前往小红书分享她的日常生活。已知她生活中有n个事件,分享第i个事件需要她花...
继续阅读 »

通过8.6日的小红书笔试第二题,我彻底搞懂了01背包与完全背包。


这篇文章不是手把手的基础教学,简单从我自己的对背包问题掌握的基础上分享一下新的心得,随缘帮助陌生人。


题目:


小红很喜欢前往小红书分享她的日常生活。已知她生活中有n个事件,分享第i个事件需要她花费ti的时间和hi的精力来编辑文章,并能获得ai的快乐值。
小红想知道,在总花费时间不超过T且总花费精力不超过H的前提下,小红最多可以获得多少快乐值?

第一行输入一个正整数n,代表事件的数量。


第二行输入两个正整数T和H,代表时间限制和精力限制。


接下来的n行,每行输入三个正整数ti,hi,ai,代表分享第i个事件需要花费ti的时间、hi的精力,收获ai的快乐值。


输入示例:


3
5 4
1 2 2
2 1 3
4 1 5

输出示例:


7

考场上的做法——暴力递归(超时,通过36%)


下面的一段没啥好说的,输入数据的处理:


const n = parseInt(read_line());
const tArr = new Array(n).fill();
const hArr = new Array(n).fill();
const aArr = new Array(n).fill();
let line2 = read_line();
const T = parseInt(line2.split(" ")[0]);
const H = parseInt(line2.split(" ")[1]);
// 构造t\h\a数组
for(let i = 0; i < n; i ++) {
 let line = read_line().split(" ");
 line = line.map((str) => {
   return parseInt((str));
})
 tArr[i] = line[0];
 hArr[i] = line[1];
 aArr[i] = line[2];
}

最终处理的目标是如下的数据结构:


// 输入:
// 3
// 5 4
// 1 2 2
// 2 1 3
// 4 1 5
let n = 3;
const T = 5;
const H = 4;
const tArr = [1, 2, 4];
const hArr = [2, 1, 1];
const aArr = [2, 3, 5];

递归算法部分,没啥好说的,下面是笔试时的源代码:


let maxA = 0; // 最大快乐值
// 考察第i件事情,并且考察前的t, h, a值分别为t, h, a
function backTrace(i, t, h, a) {
 if(a > maxA) {
   maxA = a;
}
 if(i === n) return;
 backTrace(i + 1, t, h, a); // 不做第i件事
 if(t + tArr[i] <= T && h + hArr[i] <= H) { // 做第i件事
   backTrace(i + 1, t + tArr[i], h + hArr[i], a + aArr[i]);
}
}
backTrace(0, 0, 0, 0);
console.log(maxA);

dp动态规划


数据读取同上,dp算法部分:


let dp = new Array(T + 1).fill(); // 定义dp[i][j]表示i的精力与j的体力能获得的最大快乐值
dp = dp.map(() => {
 return new Array(H + 1).fill(0);
})

for(let k = 0; k < n; k ++) { // k表示下面计算dp[i][j]时事件的集合是前k件事情
 for(let i = 1; i <= T; i ++) { // i, j 遍历各种时间和精力组合,计算dp[i][j],注意必须是倒序遍历i\j!!!
   for(let j = 1; j <= H; j ++) {
     if(i >= tArr[k] && j >= hArr[k]) {
       dp[i][j] = Math.max(dp[i - tArr[k]][j - hArr[k]] + aArr[k], dp[i][j]);
    }
  }
}
}

// 打印dp数组
for(let i = 0; i <= T; i ++) {
 let str = '';
 for(let j = 0; j <= H; j ++) {
   str += dp[i][j];
   str += " ";
}
 console.log(str);
}

console.log(dp[T][H]);

感悟:



  1. 这里的k,也就是事件,等价于背包问题中的物品;对于背包问题来说,只对物品的体积(重量)这一个指标进行限制,这里的精力与体力就相当于背包问题中的体积,相当于两个限制。

  2. 之所以不管01背包还是完全背包,都可以把dp数组初始化为一维而不是二维,就是因为省略了第一维的物品数量;这里我们dp数组初始化为二维,但是最外层进行k次迭代,也就相当于省略了事件数量。

  3. 基于上面的理解,为什么i和j都要倒序遍历就显而易见了,因为本题显然对标01背包问题,也就是说每件事情最多分享一次,所以说如果不省略第一维度k的递推公式应该是:dp[k][i][j] = max(dp[k - 1][i][j], dp[k - 1][i - tArr[k]][j - hArr[k]]) + aArr[k]。说白了就是**dp[k] = f(dp[k - 1])** ,所以因为我们省略了第一维k,完全等价于背包问题省略第一维,所以需要倒序计算,可以类比01背包问题的一维dp数组,倒序的目的就是保证计算dp[j]时所使用的dp数据的正确性。

  4. plus,如果修改题目,没件事情可以重复分享,那么就正序计算dp[i][j],按照上面的测例算出来结果是8,完全没问题。


如果以前没接触过背包问题,这篇文章确实p用没有,但是如果正对背包问题处于似懂非懂的状态,或许看下

作者:荣达
来源:juejin.cn/post/7264792741951062068
对你有所帮助,加油。

收起阅读 »

因为美工小妹妹天还没亮就下班,我做了一版可以把svg文件转成web组态组件的svg编辑器

web
自我介绍 本猫生活在东北的一个四线小城市,目前在一家小单位任职前端工程师的职位。早八晚五,生活充实,好不快活! 噩耗 Ctrl+c,Ctrl+v。Ctrl+c,Ctrl+v。新的一天开始了,本猫正在努力的工作着。看着旁边的美工小妹妹,我的口水止不住的往下流,别...
继续阅读 »

自我介绍


本猫生活在东北的一个四线小城市,目前在一家小单位任职前端工程师的职位。早八晚五,生活充实,好不快活!


噩耗


Ctrl+c,Ctrl+vCtrl+c,Ctrl+v。新的一天开始了,本猫正在努力的工作着。看着旁边的美工小妹妹,我的口水止不住的往下流,别误会,是因为公司没有几个人,所以我们每天中午都在一起吃饭,而且我本来身为一个吃货,想到午饭就会流口水,很合理吧。


正当我沉迷在幻想之际,手机的一声震动把我拽回到现实。


“来我办公室一趟!”

竟然是老板给我发的消息!!!

难不成是我每天辛苦的工作被他发现了?要给我涨工资?

我一溜烟的从工位冲了出去,直奔老板的办公室。

这一路上我想了很多:想我这么多年的辛劳付出终于达到了回报、想我迎娶白富美的现场何其壮观、想我出任ceo之后回村又是多么的风光......

终于在三秒之后,我来到了老板的办公室,见到了我最敬爱的老板。


“猫啊,听说你最近表现不错”

还没等我说话,老板又接着说到:

“咱们公司最近新接了一个项目要交给你完成,你有没有信心啊?”

我拍了拍胸脯:

“必须的!老板您说让我做什么我就做什么”

然后老板对我展开了需求攻势:

“巴拉巴拉,如此如此,这般这般,巴巴拉拉加班拉拉巴巴”

“什么?加班!”

其实老板说了那么多我是左耳听,右耳冒,但是加班这两个重重的砸在了我心上。我突然感觉一股热流冲到了头顶,双腿也止不住的颤抖,随即我双手用力的支撑在了老板的办公桌上,才勉强地站稳脚跟。

老板似乎也看出了我的不适,继续说道:

“小猫啊!你年龄这么小,正是应该努力奋斗的时候,我像你这么大的时候每天只睡十分钟,才有了今天的成就。巴拉巴拉。。。。”

听着老板的经历,我不由得留下了感动的泪水。原来老板是如此的器重我,耐心的劝导我只为了让我成长,想到我刚才还想向老板提涨工资的事,我的脸唰的一下就红了。我赶紧向老板鞠了一躬,猫着腰往办公室外面跑。

“谢谢老板关心,我一定完美完成任务!”

“公司就你一个前端,一定得好好做啊!”

随着老板的声音越来越小,我知道我已经离开了老板的办公室,我挺起胸膛,这一刻我仿佛重生了一般,浑身充满了干劲。迈着自信的步伐,我回到了我的工位。


打开电脑,老板已经把项目需求文档发给了我,打开1个Gdoc文件,首页赫然印着几个大字:项目工期一个月


我眼前一黑,晕了过去


曙光


“醒醒!醒醒!你怎么睡着了呢?”

我闻到一股淡淡的清香味,原来是我旁边的美工小妹妹在叫我。

“老板发你的新需求的项目文档你看了吧?老板让我们一起做这个项目”

“看到了看到了”

我惊喜着大叫一声,丝毫不顾忌周围同事的目光。我把头发往后捋了捋,望向我旁边的美工小妹妹:

“放心吧,包在我身上!”

“好的吧,那我先回去了,需要我做什么跟我说吖!”

我比了一个ok的手势,目送美工小妹妹转身回到了她的工位上。


需求分析


于是乎,我赶紧打开了文档,细心的分析了这个项目的需求。

原来这个项目是希望完成一个web端的拓扑图,要求使用svg技术实现,布局大概是长这样:


布局.png


功能呢,也不复杂,可以从左侧的节点区把节点拖到画布,在画布上选中绘制的节点可以缩放旋转,在右侧属性区可以快捷的更改节点的一些属性


美工小妹妹就负责配合我去设计svg的图形


我冷笑一声,这还不简单?真是小看了我这个代码吸血鬼。我打开搜索引擎,用出了我的绝招



“无敌暴龙cv大法”



不出半天,上面的布局就被我给做好了。


根据项目需求还需要去实现拖动的功能,凭借我多年的工作经验,我很快便找到了如下可以进行参考的文章:


一个低代码(可视化拖拽)教学项目


详细的拖拽需求以及缩放旋转的操作这篇文章里都有讲,我这里就不重复赘述了,经过了几天没日没夜的开发,主要说下我遇到的问题吧!


上面文章包含的项目实际上是采用定位来实现的,本身也是支持集成svg文件的,我们先看一下svg文件如何集成:


svg节点定义:


<template>
   <div class="svg-star-container">
       <svg xmlns="http://www.w3.org/2000/svg" version="1.1">
           <circle cx="100" cy="50" r="40" stroke="black" stroke-width="2" :fill="fill" />
       </svg>
   </div>
</template>

<script>
export default {
   props: {
       fill: {
           type: String,
           require: true,
           default: '#ff0000',
      },
  },
}
</script>

右侧属性面板定义:


<template>
   <div>
       <el-form>
           <el-form-item label="填充色">
               <el-color-picker v-model="curComponent.fill" />
           </el-form-item>
       </el-form>
   </div>
</template>

上面这是我新集成的一个svg图形,里面只有一个圆形,我这里希望可以在右侧改变圆形的填充(fill)颜色,集成之后的效果就是这样的:


集成效果.png


其实本质上是可以满足我们的需求的,因为我们也就是想改改svgfill或者是stroke这些基本属性的,但是我们的项目大概有300多个组件,这样一来我需要写600多vue文件。上面的示例我只是演示了如何更改svgfill属性,没做缩放适配效果是这样的:


未适配.png


所以我还需要手动调整每个文件去适配缩放,这个工作量堪比吨级,我的脑中顿时思绪万千:


想起来这几日美工小妹妹准时下班向我发我来的甜蜜问候


美工下班.png


想起来昨天向公司申请换键盘的尴尬经历


申请键盘.png


望着手机屏幕反射出连续高强度加班憔悴的我,看着我新换回来CV按键清晰可见的键盘,看着美工小妹妹键盘上那纤细的手指,我不由得感叹道:这么好看的手,不多做几个图可惜了。于是乎我的脑海里萌生了一个大胆的想法:



我要写个逻辑自动把svg文件转成组件,每个svg文件写个配置文件就能在右侧属性区动态的设置svgfillstroke等属性!!!



理想很丰满,现实很骨感


开始我是打算用viteraw以字符串的方式加载svg文件,再用v-html指令将字符串渲染成html


<script setup lang="ts">
import testSvgString from '../assets/vue.svg?raw'
</script>

<template>
 <div>
   <div v-html="testSvgString"></div>
   <div v-html="testSvgString"></div>
 </div>
</template>



效果是这样的:


raw效果.png


这个方案确实行得通,不过如果真正绘制图像的话,会导致dom节点变的很多很多。


dom节点.png


而且现在还没有考虑怎么适应svg节点的实际大小,怎么动态的改变节点fill等基本属性。。。


越想头越大,迷迷糊糊中我仿佛身处在一片森林中,映入我眼帘的是一个正在从我面前河里飘出来的白发老人


“少年哟,你掉的是这个<symbol>标签呢?还是这个<use>标签呢?”


我猛然惊醒


皇天不负有心人


对啊,之前我们用过的一个项目里面的icon图标就是一个个文件,当时是用的这个插件vite-plugin-svg-icons


它可以把svg文件加载成symbol标签,然后在需要的地方用use标签引用就可以了


说干就干,于是我赶紧熟练的打开了搜索引擎,开始了我的求知之路


终于


这个基于 vue3.2+ts 实现的 svg 可视化 web 组态编辑器项目诞生了!


项目介绍


svg文件即组件,引入并编写好配置文件后之后无需进行额外配置,编辑器会自适应解析加载组件。
同时支持自定义svg组件和传统的vue组件


开源地址


github


gitee


在线预览


绘画


选中左侧的组件库,按住鼠标左键即可把组件拖动到画布中


绘画.gif


操作


选中绘制好的节点后会出现锚点,可以直接进行移动、缩放、旋转等功能,右侧属性面板可以设置配置好的节点的属性,鼠标右键可以进行一些快捷操作


操作.gif


连线


鼠标移动到组件上时会出现连线锚点,左键点击锚点创建线段,继续左键点击画布会连续创建线段,右键停止创建线段,鼠标放在线段上会出现线段端点提示,拖动即可重新设置连线,选中线段后还可以在右侧的动画面板设置线段的动画效果


连线.gif


支持集成到已有项目


脚手架项目


# 创建项目(已有项目跳过此步骤)
npm init vite@latest

# 进入项目目录
cd projectname

# 安装插件
pnpm i webtopo-svg-edit

# 安装pinia
pnpm i pinia

# 修改main.ts 注册pinia
import { createPinia } from 'pinia';
const app = createApp(App);
app.use(createPinia());
app.mount('#app')

#在需要的页面引入插件
import { WebtopoSvgEdit,WebtopoSvgPreview } from 'webtopo-svg-edit';
import 'webtopo-svg-edit/dist/style.css'

umd方式集成


<!DOCTYPE html>
<html>
 <head>
   <title>webtopo-svg-edit Example</title>
   <link href="https://unpkg.com/webtopo-svg-edit@0.0.8/dist/style.css" rel="stylesheet" />
   <script src="https://unpkg.com/vue@3.2.6/dist/vue.global.prod.js"></script>
   <script src="https://unpkg.com/vue-demi@0.13.11/lib/index.iife.js"></script>
   <script src="https://unpkg.com/pinia@2.0.33/dist/pinia.iife.prod.js"></script>
   <script src="https://unpkg.com/webtopo-svg-edit@0.0.8/dist/webtopo-svg-edit.umd.js"></script>
 </head>
 <body>
   <div id="app"></div>
   <script>
     const pinia = Pinia.createPinia()
     const app = Vue.createApp(WebtopoYLM.WebtopoSvgEdit)
     app.use(pinia)
     app.mount('#app')
   
</script>
 </body>
</html>


es module方式集成


<!DOCTYPE html>
<html>
 <head>
   <title>webtopo-svg-edit Example</title>
   <link href="https://unpkg.com/webtopo-svg-edit@0.0.8/dist/style.css" rel="stylesheet" />
 </head>
 <body>
   <div id="app"></div>
 </body>
</html>
<script type="importmap">
{
  "imports": {
    "vue": "https://unpkg.com/vue@3.2.47/dist/vue.esm-browser.prod.js",
    "@vue/devtools-api": "https://cdn.jsdelivr.net/npm/@vue/devtools-api/lib/esm/index.min.js",
    "vue-demi": "https://unpkg.com/vue-demi@0.13.11/lib/index.mjs",
    "pinia": "https://unpkg.com/pinia@2.0.29/dist/pinia.esm-browser.js",
    "WebtopoYLM": "https://unpkg.com/webtopo-svg-edit@0.0.8/dist/webtopo-svg-edit.es.js"
  }
}
</script>
<script type="module">
 import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import { WebtopoSvgEdit } from 'WebtopoYLM'
 const app = createApp(WebtopoSvgEdit)
 app.use(createPinia())
 app.mount('#app')
</script>


后记


“报告老板,以前一个月的工作,现在7天就能做完!”


“小伙子你很有前途,等公司赚钱了一定不会忘了你的!”


“老板这是哪里话,牛马的命也是命,当牛做马是我的荣幸!”


“行了,没什么事就去忙吧,7天之后等你们的好消息!”


凌晨三点,我看着美工小妹妹忙碌的身影,不由得嘴角上扬


嘿嘿!天

作者:咬轮猫
来源:juejin.cn/post/7260126013111812153
还没亮,谁也别想走!

收起阅读 »

基于css3写出的流水加载效果

web
准备部分 这里写入基本的html样式,这里还设置了水球的css样式,用于css样式中的计算--i:1是一种自定义的CSS变量,可能用于控制样式中的计数 <body> <div class="box"> <d...
继续阅读 »

准备部分



这里写入基本的html样式,这里还设置了水球的css样式,用于css样式中的计算--i:1是一种自定义的CSS变量,可能用于控制样式中的计数



<body>
<div class="box">
<div class="loader">
<!-- 这里设置了水球的css样式变量,用于css样式中的计算 -->
<span style="--i:1"></span>
<span style="--i:2"></span>
<span style="--i:3"></span>
<span style="--i:4"></span>
<span style="--i:5"></span>
<span style="--i:6"></span>
<span style="--i:7"></span>
<span style="--i:8"></span>
</div>
</div>
</body>

设置基本的css背景及其样式


image.png


* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

.box {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #0c1b21;
}
/* 设置流水区域 */
.loader {
position: relative;
width: 250px;
height: 250px;
background: #666;
animation: animate 12s linear infinite;
}

下面进行详细的css设计



这里通过伪元素设计了第一个小球的效果,通过定位定位到左上角,,设置了大小为40,并且设置了颜色和圆角色设置,同时添加了阴影效果,形成了如下的圆球水滴效果,通过渐变函数linear-gradient是一个渐变函数,用于创建线性渐变背景,设置了颜色和倾斜的角度



image.png


    .loader span {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
/* 定位 伪元素,设置流水的珠子 */
.loader span::before {
content: '';
position: absolute;
top: 0;
width: 40px;
height: 40px;
/* 珠子颜色设置 */
background: linear-gradient(45deg, #c7eeff, #03a9f4);
border-radius: 50%;
/* 设置阴影 */
box-shadow: 0 0 30px #00bcd4;
}

接下来根据html中定义的css变量,来设置不同方向的数据


image.png


.loader span{
/* 设置不同方向的小球 */
transform: rotate(calc(45deg*var(--i)));
}

将小球向内收缩一部分


image.png


.loader span::before {
left: calc(50% - 20px);
}

动画设置


在这里设置了旋转动画,并且在整个区域添加入了动画


image.png


/* 这里设置了旋转动画 */
@keyframes animate {
0% {
transform: rotate(0deg);
}

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


接下来补一下html


这里设置了4个span元素,用来形成四个小球,在容器内沿着小球的方向进行转动,形成一个如下的的效果,并且通过在html中给span元素加入css变量来控制每个小球的延迟效果


ezgif.com-video-to-gif.gif


去掉背景颜色后


ezgif.com-video-to-gif.gif


<body>
<div class="box">
<div class="loader">
<!-- 这里设置了水球的css样式变量,用于css样式中的计算 --i:1是一种自定义的CSS变量,可能用于控制样式中的计数 -->
<span style="--i:1"></span>
<span style="--i:2"></span>
<span style="--i:3"></span>
<span style="--i:4"></span>
<span style="--i:5"></span>
<span style="--i:6"></span>
<span style="--i:7"></span>
<span style="--i:8"></span>
<!-- 这里设置了光晕效果,使得出现5个转动的小球 -->
<span class="rotate" style="--j:0"></span>
<span class="rotate" style="--j:1"></span>
<span class="rotate" style="--j:2"></span>
<span class="rotate" style="--j:3"></span>
<span class="rotate" style="--j:4"></span>
</div>
<div class="word">加载中</div>
</div>

</body>


.rotate {
animation: animate 4s ease-in-out infinite;
/* 设置延迟 */
animation-delay: calc(-0.2s*var(--j));
}
.loader {
filter: url(#fluid);
/* 去掉临时背景颜色为透明 */
background: transparent;
}

接下来补全所需要的html



这里不全了所需要的html,加入了svg部分,使用过svg,对图形进行高斯模糊处理,然后对图形的颜色进行变化,通过颜色矩阵来实现颜色变化,这里使用的颜色矩阵将每个像素的红、绿、蓝三个通道的颜色值分别乘以1,不变化;将透明度乘以20,增加透明度;最后将透明度减去10,进一步增加透明度。这段代码可能被使用在创建视觉效果中,比如给图像添加模糊效果并调整其透明度,从而实现一种"流体"或"柔和"的视觉效果
并且定义了一个filter的滤镜效果
“in"属性的值为"SourceGraphic”,表示将滤镜应用在源图形上,“stdDeviation"属性的值为"10”,表示高斯模糊的参数,即模糊程度



<body>
<div class="box">
<!-- 这里使用了svg,svg是可缩放矢量图形的标签,通过创建和操作svg,使得图形通过缩放而不失去真的在各种尺寸和分辨率下呈现 -->
<!-- 这段代码的作用是先对图形进行高斯模糊处理,然后对图形的颜色进行变换。具体的颜色变换可以通过颜色矩阵来实现,
这里使用的颜色矩阵将每个像素的红、绿、蓝三个通道的颜色值分别乘以1,不变化;将透明度乘以20,增加透明度;
最后将透明度减去10,进一步增加透明度。这段代码可能被使用在创建视觉效果中,比如给图像添加模糊效果并调整其透明度,
从而实现一种"流体"或"柔和"的视觉效果 -->

<svg>
<!-- 这是定义一个滤镜效果的元素,其中"id"属性的值为"fluid",用于给滤镜效果命名。 -->
<filter id="fluid">
<!-- 这是一个高斯模糊滤镜效果,用于对图形进行模糊处理。“in"属性的值为"SourceGraphic”,表示将滤镜应用在源图形上,
“stdDeviation"属性的值为"10”,表示高斯模糊的参数,即模糊程度。 -->

<feGaussianBlur in="SourceGraphic" stdDeviation="10"></feGaussianBlur>
<!-- 这是一个颜色矩阵滤镜效果,用于对图形的颜色进行变换。
"values"属性的值为一个包含20个数字的字符串,表示颜色矩阵的变换矩阵。 -->

<feColorMatrix values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 20 -10 "
>

</feColorMatrix>
</filter>
</svg>
<div class="loader">
<!-- 这里设置了水球的css样式变量,用于css样式中的计算 --i:1是一种自定义的CSS变量,可能用于控制样式中的计数 -->
<span style="--i:1"></span>
<span style="--i:2"></span>
<span style="--i:3"></span>
<span style="--i:4"></span>
<span style="--i:5"></span>
<span style="--i:6"></span>
<span style="--i:7"></span>
<span style="--i:8"></span>
<!-- 这里设置了光晕效果,使得出现5个转动的小球 -->
<span class="rotate" style="--j:0"></span>
<span class="rotate" style="--j:1"></span>
<span class="rotate" style="--j:2"></span>
<span class="rotate" style="--j:3"></span>
<span class="rotate" style="--j:4"></span>
</div>
<div class="word">加载中</div>
</div>
</body>

再继续设置svg的样式,这里将被svg偏移的容器位置归于中心位置,并且设置了文字效果及其设置了文字的缩放动画


    svg {
width: 0;
height: 0;
}

.word {
position: absolute;
color: #fff;
font-size: 1.2em;
animation: words 3s linear infinite;
}
/* 这里设置了文字的缩放动画 */
@keyframes words {
0% {
transform: scale(1.2);
}

25% {
transform: scale(1);
}

50% {
transform: scale(0.8);
}

75% {
transform: scale(1);
}

100% {
transform: scale(1.2);
}
}

效果展示


ezgif.com-video-to-gif (1).gif


总结



实现这个效果关键在最后的那个svg的使用,通过添加svg给了一个高斯模糊的效果,从而才会使得明显的小球变成了这种连在一起的流动液体样式的小球,要学习一下svg效果,在他也是属于html5里面的一部分内容,仅供自己学习记录和新的展示



作者:如意呀
来源:juejin.cn/post/7263064267560976442
收起阅读 »

记录实现音频可视化

web
实现音频可视化 这里主要使用了web audio api和canvas来实现的(当然也可以使用svg,因为canvas会有失帧的情况,svg的写法如果有大佬会的话,可以滴滴我一下) 背景 最近听音乐的时候,看到各种动效,突然好奇这些音频数据是如何获取并展示出来...
继续阅读 »

实现音频可视化


这里主要使用了web audio api和canvas来实现的(当然也可以使用svg,因为canvas会有失帧的情况,svg的写法如果有大佬会的话,可以滴滴我一下)


背景


最近听音乐的时候,看到各种动效,突然好奇这些音频数据是如何获取并展示出来的,于是花了几天功夫去研究相关的内容,这里只是给大家一些代码实例,具体要看懂、看明白,还是建议大家大家结合相关api来阅读这篇文章。

参考资料地址:Web Audio API - Web API 接口参考 | MDN (mozilla.org)


实现思路


首先画肯定是用canvas去画,关于音频的相关数据(如频率、波形)如何去获取,需要去获取相关audio的DOM 或通过请求处理去拿到相关的音频数据,然后通过Web Audio API 提供相关的方法来实现。(当然还要考虑要音频请求跨域的问题,留在最后。)


一个简单而典型的 web audio 流程如下(取自MDN):


1.创建音频上下文<br>
2.在音频上下文里创建源 — 例如 <audio>, 振荡器,流<br>
3.创建效果节点,例如混响、双二阶滤波器、平移、压缩<br>
4.为音频选择一个目的地,例如你的系统扬声器<br>
5.连接源到效果器,对目的地进行效果输出<br>

image.png


上代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #000;
}
div {
width: 100%;
border-top: 1px solid #fff;
padding-top: 50px;
display: flex;
justify-content: center;
}
</style>
</head>
<body>
<canvas></canvas>
<div>
<audio src="./1.mp3" controls></audio>
</div>
<script>
// 获取音频组件
const audioEle = document.querySelector("audio");
// 获取canvas元素
const cvs = document.querySelector("canvas");
// 创建canvas上下文
const ctx = cvs.getContext("2d");

// 初始化canvas的尺寸
function initCvs() {
cvs.width = window.innerWidth;
cvs.height = window.innerHeight / 2;
}
initCvs();

// 音频的步骤 音频源 ==》分析器 ===》输出设备

//是否初始化
let isInit = false;
let dataArray, analyser;
audioEle.onplay = function () {
if (isInit) return;
// 初始化
const audCtx = new AudioContext(); //创建一个音频上下文
const source = audCtx.createMediaElementSource(audioEle); //创建音频源节点
analyser = audCtx.createAnalyser(); //拿到分析器节点
analyser.fftSize = 512; // 时域图变换的窗口大小(越大越细腻)默认2048
// 创建一个数组,接受分析器分析回来的数据
dataArray = new Uint8Array(analyser.frequencyBinCount); // 数组每一项都是一个整数 接收数组的长度,因为快速傅里叶变换是对称的,所以除以2
source.connect(analyser); // 将音频源节点链接到输出设备,否则会没声音哦。
analyser.connect(audCtx.destination); // 把分析器节点了解到输出设备
isInit = true;
};

// 绘制,把分析出来的波形绘制到canvas上
function draw() {
requestAnimationFrame(draw);
// 清空画布
const { width, height } = cvs;
ctx.clearRect(0, 0, width, height);
// 先判断音频组件有没有初始化
if (!isInit) return;
// 让分析器节点分析出数据到数组中
analyser.getByteFrequencyData(dataArray);
const len = dataArray.length / 2.5;
const barWidth = width / len / 2; // 每一个的宽度
ctx.fillStyle = "#78C5F7";
for (let i = 0; i < len; i++) {
const data = dataArray[i]; // <256
const barHeight = (data / 255) * height; // 每一个的高度
const x1 = i * barWidth + width / 2;
const x2 = width / 2 - (i + 1) * barWidth;
const y = height - barHeight;
ctx.fillRect(x1, y, barWidth - 2, barHeight);
ctx.fillRect(x2, y, barWidth - 2, barHeight);
}
}
draw();
</script>
</body>
</html>

我这里只用了简单的柱状图,还有什么其他的奇思妙想,至于怎么把数据画出来,就凭大家的想法了。


关于请求音频跨域问题解决方案


1.因为我这里是简单的html,音频文件也在同一个文件夹但是如果直接用本地路径打开本地文件是会报跨域的问题,所以我这里是使用Open with Live Server就可以了


image.png


2.给获取的audio DOM添加一条属性即可
audio.crossOrigin ='anonymous'

或者直接在 aduio标签中 加入 crossorigin="anonymous"


作者:井川不擦
来源:juejin.cn/post/7263840667826257957
收起阅读 »

我写了一个自动化脚本涨粉,从0阅读到接近100粉丝

web
点击在线阅读,体验更好链接现代JavaScript高级小册链接深入浅出Dart链接现代TypeScript高级小册链接linwu的算法笔记📒链接 引言 在CSDN写了大概140篇文章,一直都是0阅读量,仿佛石沉大海,在掘金能能频频上热搜的文章,在CSDN一点反...
继续阅读 »

点击在线阅读,体验更好链接
现代JavaScript高级小册链接
深入浅出Dart链接
现代TypeScript高级小册链接
linwu的算法笔记📒链接

引言


在CSDN写了大概140篇文章,一直都是0阅读量,仿佛石沉大海,在掘金能能频频上热搜的文章,在CSDN一点反馈都没有,所以跟文章质量关系不大,主要是曝光量,后面调研一下,发现情况如下


image.png


好家伙,基本都是人机评论,后面问了相关博主,原来都是互相刷评论来涨阅读量,enen...,原来CSDN是这样的,真无语,竟然是刷评论,那么就不要怪我用脚本了。


puppeteer入门



先来学习一波puppeteer知识点,其实也不难



puppeteer 简介


Puppeteer 是 Chrome 开发团队在 2017 年发布的一个 Node.js 包, 用来模拟 Chrome 浏览器的运行。



Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。 Chromium 和 Chrome区别



在学puppeteer之前我们先来了解下 headless chrome


什么是 Headless Chrome



  • 在无界面的环境中运行 Chrome

  • 通过命令行或者程序语言操作 Chrome

  • 无需人的干预,运行更稳定

  • 在启动 Chrome 时添加参数 --headless,便可以 headless 模式启动 Chrome


alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"  # Mac OS X 命令别名

chrome --headless --disable-gpu --dump-dom https://www.baidu.com # 获取页面 DOM

chrome --headless --disable-gpu --screenshot https://www.baidu.com # 截图


查看更多chrome启动参数 英文
中文


puppeteer 能做什么


官方称:“Most things that you can do manually in the browser can be done using Puppeteer”,那么具体可以做些什么呢?



  • 网页截图或者生成 PDF

  • 爬取 SPA 或 SSR 网站

  • UI 自动化测试,模拟表单提交,键盘输入,点击等行为

  • 捕获网站的时间线,帮助诊断性能问题

  • ......


puppeteer 结构


image



  • Puppeteer 使用 DevTools 协议 与浏览器进行通信。

  • Browser 实例可以拥有浏览器上下文。

  • BrowserContext 实例定义了一个浏览会话并可拥有多个页面。

  • Page 至少有一个框架:主框架。 可能还有其他框架由 iframe 或 框架标签 创建。

  • frame 至少有一个执行上下文 - 默认的执行上下文 - 框架的 JavaScript 被执行。 一个框架可能有额外的与 扩展 关联的执行上下文。


puppeteer 运行环境


查看 Puppeteer 的官方 API 你会发现满屏的 async, await 之类,这些都是 ES7 的规范,所以你需要: Nodejs 的版本不能低于 v7.6.0


npm install puppeteer 

# or "yarn add puppeteer"


Note: 当你安装 Puppeteer 时,它会自动下载Chromium,由于Chromium比较大,经常会安装失败~ 可是使用以下解决方案



  • 把npm源设置成国内的源 cnpm taobao 等

  • 安装时添加--ignore-scripts命令跳过Chromium的下载 npm install puppeteer --ignore-scripts

  • 安装 puppeteer-core 这个包不会去下载Chromium


puppeteer 基本用法


先打开官方的入门demo


const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({path: 'example.png'});

await browser.close();
})();

上面这段代码就实现了网页截图,先大概解读一下上面几行代码:



  1. 先通过 puppeteer.launch() 创建一个浏览器实例 Browser 对象

  2. 然后通过 Browser 对象创建页面 Page 对象

  3. 然后 page.goto() 跳转到指定的页面

  4. 调用 page.screenshot() 对页面进行截图

  5. 关闭浏览器


是不是觉得好简单?


puppeteer.launch(options)


options 参数详解


参数名称参数类型参数说明
ignoreHTTPSErrorsboolean在请求的过程中是否忽略 Https 报错信息,默认为 false
headlessboolean是否以”无头”的模式运行 chrome, 也就是不显示 UI, 默认为 true
executablePathstring可执行文件的路劲,Puppeteer 默认是使用它自带的 chrome webdriver, 如果你想指定一个自己的 webdriver 路径,可以通过这个参数设置
slowMonumber使 Puppeteer 操作减速,单位是毫秒。如果你想看看 Puppeteer 的整个工作过程,这个参数将非常有用。
argsArray(String)传递给 chrome 实例的其他参数,比如你可以使用”–ash-host-window-bounds=1024x768” 来设置浏览器窗口大小。
handleSIGINTboolean是否允许通过进程信号控制 chrome 进程,也就是说是否可以使用 CTRL+C 关闭并退出浏览器.
timeoutnumber等待 Chrome 实例启动的最长时间。默认为30000(30秒)。如果传入 0 的话则不限制时间
dumpioboolean是否将浏览器进程stdout和stderr导入到process.stdout和process.stderr中。默认为false。
userDataDirstring设置用户数据目录,默认linux 是在 ~/.config 目录,window 默认在 C:\Users{USER}\AppData\Local\Google\Chrome\User Data, 其中 {USER} 代表当前登录的用户名
envObject指定对Chromium可见的环境变量。默认为process.env。
devtoolsboolean是否为每个选项卡自动打开DevTools面板, 这个选项只有当 headless 设置为 false 的时候有效

puppeteer如何使用



下面介绍 10 个关于使用 Puppeteer 的用例,并在介绍用例的时候会穿插的讲解一些 API,告诉大家如何使用 Puppeteer:



01 获取元素及操作


如何获取元素?



  • page.$('#uniqueId'):获取某个选择器对应的第一个元素

  • page.$$('div'):获取某个选择器对应的所有元素

  • page.$x('//img'):获取某个 xPath 对应的所有元素

  • page.waitForXPath('//img'):等待某个 xPath 对应的元素出现

  • page.waitForSelector('#uniqueId'):等待某个选择器对应的元素出现



Page.$(selector) 获取单个元素,底层是调用的是 document.querySelector() , 所以选择器的 selector 格式遵循 css 选择器规范




Page.$$(selector) 获取一组元素,底层调用的是 document.querySelectorAll(). 返回 Promise(Array(ElemetHandle)) 元素数组.




const puppeteer = require('puppeteer');

async function run (){
const browser = await puppeteer.launch({headless:false,defaultViewport:{width:1366,height:768}});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
const input_area = await page.$("#kw");
await input_area.type("Hello Wrold");

const search_btn = await page.$('#su');
await search_btn.click();

}

run();

02 获取元素属性


Puppeteer 获取元素属性跟我们平时写前段的js的逻辑有点不一样,按照通常的逻辑,应该是现获取元素,然后在获取元素的属性。但是上面我们知道 获取元素的 API 最终返回的都是 ElemetHandle 对象,而你去查看 ElemetHandle 的 API 你会发现,它并没有获取元素属性的 API.


事实上 Puppeteer 专门提供了一套获取属性的 API, Page.$eval() 和 Page.$$eval()


Page.$$eval(selector, pageFunction[, …args]), 获取单个元素的属性,这里的选择器 selector 跟上面 Page.$(selector) 是一样的。


const value = await page.$eval('input[name=search]', input => input.value);
const href = await page.$eval('#a", ele => ele.href);
const content = await page.$eval('
.content', ele => ele.outerHTML);

const puppeteer = require('puppeteer');

async function run (){
const browser = await puppeteer.launch({headless:false,defaultViewport:{width:1366,height:768}});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
const input_area = await page.$("#kw");
await input_area.type("Hello Wrold");

const search_btn = await page.$('#su');
await search_btn.click();

await page.waitFor('div#content_left > div.result-op.c-container.xpath-log',{visible:true});

let resultText = await page.$eval('div#content_left > div.result-op.c-container.xpath-log',ele=> ele.innerText)
console.log("result Text= ",resultText);



}

run();

03 处理多个元素




const puppeteer = require('puppeteer');

async function run() {
const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 1280,
height: 800,
},
slowMo: 200,
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
const input_area = await page.$('#kw');
await input_area.type('Hello Wrold');
await page.keyboard.press('Enter');
const listSelector = 'div#content_left > div.result-op.c-container.xpath-log';
// await page.waitForSelector(listSelector);
await page.waitFor(3 * 1000);

const list = await page.$$eval(listSelector, (eles) =>
eles.map((ele) => ele.innerText)
);
console.log('List ==', list);
}

run();


04 切换frame


一个 Frame 包含了一个执行上下文(Execution Context),我们不能跨 Frame 执行函数,一个页面中可以有多个 Frame,主要是通过 iframe 标签嵌入的生成的。其中在页面上的大部分函数其实是 page.mainFrame().xx 的一个简写,Frame 是树状结构,我们可以通过page.frames()获取到页面所有的 Frame,如果想在其它 Frame 中执行函数必须获取到对应的 Frame 才能进行相应的处理



const puppeteer = require('puppeteer')

async function anjuke(){
const browser = await puppeteer.launch({headless:false});
const page = await browser.newPage();
await page.goto('https://login.anjuke.com/login/form');

// 切换iframe

await page.frames().map(frame => {console.log(frame.url())})
const targetFrameUrl = 'https://login.anjuke.com/login/iframeform'
const frame = await page.frames().find(frame => frame.url().includes(targetFrameUrl));

const phone= await frame.waitForSelector('#phoneIpt')
await phone.type("13122022388")
}

anjuke();

05 拖拽验证码操作


const puppeteer = require('puppeteer')

async function aliyun(){
const browser = await puppeteer.launch({headless:false,ignoreDefaultArgs:['--enable-automation']});
const page = await browser.newPage();
await page.goto('https://account.aliyun.com/register/register.htm',{waitUntil:"networkidle2"});

const frame = await page.frames().find(frame=>{
console.log(frame.url())
return frame.url().includes('https://passport.aliyun.com/member/reg/fast/fast_reg.htm')

})

const span = await frame.waitForSelector('#nc_1_n1z');
const spaninfo = await span.boundingBox();
console.log('spaninfo',spaninfo)

await page.mouse.move(spaninfo.x,spaninfo.y);
await page.mouse.down();

const div = await frame.waitForSelector('div#nc_1__scale_text > span.nc-lang-cnt');
const divinfo = await div.boundingBox();

console.log('divinfo',divinfo)
for(var i=0;i<divinfo.width;i++){
await page.mouse.move(spaninfo.x+i,spaninfo.y);
}
await page.mouse.up();
}

aliyun();


06 模拟不同设备


const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone 6'];

puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.emulate(iPhone);
await page.goto('https://www.baidu.com');
// 其他操作...
await browser.close();
});

07 请求拦截



const puppeteer = require('puppeteer');
async function run () {
const browser = await puppeteer.launch({
headless:false,
defaultViewport:{
width:1280,
height:800
}
})
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
const blockTypes = new Set(['image', 'media', 'font']);
const type = interceptedRequest.resourceType();
const shouldBlock = blockTypes.has(type);
if (shouldBlock) {
interceptedRequest.abort();
} else {
interceptedRequest.continue();
}

});
await page.goto('https://t.zhongan.com/group');
}

run();

08 性能分析



const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.tracing.start({path: 'trace.json'});
await page.goto('https://t.zhongan.com/group');
await page.tracing.stop();
browser.close();
})();

09 生成pdf



const URL = 'http://es6.ruanyifeng.com';
const puppeteer = require('puppeteer');
const fs = require('fs');

fs.mkdirSync('es6-pdf');

(async () => {
let browser = await puppeteer.launch();
let page = await browser.newPage();
await page.goto(URL);
await page.waitFor(5000); // 等待五秒,确保页面加载完毕

// 获取左侧导航的所有链接地址及名字
let aTags = await page.evaluate(() => {
let eleArr = [...document.querySelectorAll('#sidebar ol li a')];
return eleArr.map((a) =>{
return {
href: a.href.trim(),
name: a.text
}
});
});

// 先将本页保存成pdf,并关闭本页
console.log('正在保存:0.' + aTags[0].name);
await page.pdf({path: `./es6-pdf/0.${aTags[0].name}.pdf`});

// 遍历节点数组,逐个打开并保存 (此处不再打印第一页)
for (let i = 1, len = aTags.length; i < len; i++) {
let a = aTags[i];
console.log('正在保存:' + i + '.' + a.name);
page = await browser.newPage();
await page.goto(a.href);
await page.waitFor(5000);
await page.pdf({path: `./es6-pdf/${i + '.' + a.name}.pdf`});
}
browser.close();
})


10 自动化发布微博


const puppeteer = require('puppeteer');
const {username,password} = require('./config')

async function run(){
const browser = await puppeteer.launch({
headless:false,
defaultViewport:{width:1200,height:700},
ignoreDefaultArgs:['--enable-automation'],
slowMo:200,
args:['--window-size=1200,700']})
const page = await browser.newPage();

await page.goto('http://wufazhuce.com/',{waitUntil:'networkidle2'});
const OneText = await page.$eval('div.fp-one-cita > a',ele=>ele.innerText);
console.log('OneText:',OneText);


await page.goto('https://weibo.com/',{waitUntil:'networkidle2'});
await page.waitFor(2*1000);
await page.reload();

const loginUserInput = await page.waitForSelector('input#loginname');
await loginUserInput.click();
await loginUserInput.type(username);

const loginUserPasswdInput = await page.waitForSelector('input[type="password"]');
await loginUserPasswdInput.click();
await loginUserPasswdInput.type(password);

const loginBtn = await page.waitForSelector('a[action-type="btn_submit"]')
await loginBtn.click();

const textarea = await page.waitForSelector('textarea[class="W_input"]')
await textarea.click();
await textarea.type(OneText);

const sendBtn = await page.waitForSelector('a[node-type="submit"]');
await sendBtn.click();
}

run();

CSDN的脚本


image.png



这里注意CSDN有反扒机制,规则自己琢磨就行,我贴了伪代码,核心代码就不开放,毕竟自己玩玩就行了



const puppeteer = require('puppeteer');

async function autoCommentCSDN(username, password, targetBlogger, commentContent) {
const browser = await puppeteer.launch({ headless: false }); // 打开有头浏览器
const page = await browser.newPage();

// 登录CSDN
await page.goto('https://passport.csdn.net/login');
await page.waitForTimeout(1000); // 等待页面加载

// 切换到最后一个Tab (账号登录)
// 点击“密码登录”
const passwordLoginButton = await page.waitForXPath('//span[contains(text(), "密码登录")]');
await passwordLoginButton.click();


// 输入用户名和密码并登录
const inputFields = await page.$$('.base-input-text');

await inputFields[0].type( username);
await inputFields[1].type( password);
await page.click('.base-button');

await page.waitForNavigation();

// // 跳转到博主的首页
await page.goto(`https://blog.csdn.net/${targetBlogger}?type=blog`);

// // 点击第一篇文章的标题,进入文章页面
await page.waitForSelector('.list-box-cont', { visible: true });
await page.click('.list-box-cont');
// // 获取文章ID
console.log('page.url()',page.url())

// await page.waitForTimeout(1000); // 等待页面加载


await page.goto('https://blog.csdn.net/weixin_52898349/article/details/132115618')


await page.waitForTimeout(1000); // 等待页面加载

console.log('开始点击评论按钮...')

console.log('page.url()',page.url())

// 获取当前页面的DOM内容

const bodyHTML = await page.evaluate(() => {
return document.body.innerHTML;
});

console.log(bodyHTML);

// await page.waitForSelector('.comment-side-tit');

// const commentInput = await page.$('.comment-side-tit');

// await commentInput.click();

// 等待评论按钮出现

// 点击评论按钮

// await page.waitForSelector('.comment-content');
// const commentInput = await page.$('.comment-content textarea');
// await commentInput.type(commentContent);
// const submitButton = await page.$('.btn-comment-input');
// await submitButton.click();

// console.log('评论成功!');
// await browser.close();

}

// 请替换以下参数为您的CSDN账号信息、目标博主和评论内容
const username = 'weixin_52898349';
const password = 'xxx!';
const targetBlogger = 'weixin_52898349'; // 目标博主的CSDN用户名
const commentContent = '各位大佬们帮忙三连一下,非常感谢!!!!!!!!!!!'; // 评论内容

autoCommentCSDN(username, password, targetBlogger, commentContent);




作者:linwu
来源:juejin.cn/post/7263871284010106938
收起阅读 »

清朝项目太臭怎么办?TS重构它!

web
多图预警,流量党慎入 图片:均图片在上,描述在下 全文:6474字 阅读需要约28分钟 项目背景 最近公司要求给一个老项目加功能,具体就是把原来免费的服务改成付费的,然后再加一点其他的功能,我之前看到过那个项目的线上,这么一说,顿感不妙。截个图简单感受一下:...
继续阅读 »

多图预警,流量党慎入


图片:均图片在上,描述在下


全文:6474字 阅读需要约28分钟



项目背景


最近公司要求给一个老项目加功能,具体就是把原来免费的服务改成付费的,然后再加一点其他的功能,我之前看到过那个项目的线上,这么一说,顿感不妙。截个图简单感受一下:


image.png


农业数字云页面


image-20230804132617537


告警平台页


image.png
天气监测页面(navBar直接没了,我天😅)


image-20230804132711975


气象大数据页,好家伙直接空壳页面,也是没有navBar了,好像进入了一个全新的大屏项目。


报错什么的咱就不说了,光log信息就看的眼花缭乱,点几下给你log十几行出来,你说log就log吧,把名字取好也行,不过各位,看图😅。


好好好,虽然线上看起来没那么漂亮,可能人家代码写的工整呢?


那咱接下来看看代码,我草!等会jym,咱先不看代码,光这目录结构就把俺老猪吓一跳:


image-20230804133247244


image-20230804134709932


image-20230804133320761


image-20230804133541655


牛蛙牛蛙,今天算开眼了,根目录下命名个entries,好家伙下面还有components、views、App.vue(这是把另一个项目整个搬过来了是吧😅),根目录下同时拥有:utils、tools、service,好家伙,直接跟俺摆迷魂阵!更离谱的是,view下面还有view,view下的文件夹还有components,组件和页面完全分不清啊!敢问掘友们见过这种史诗级屎目录吗?


被目录结构震惊之后,先平复一下心情,看一下代码,代码写的还是挺工整的


做好准备,图来了:


先拿个vue组件看看:


image-20230804134138764


(那个分页函数是我后来写的)嗯~中规中矩,我还可以接受,毕竟命名什么的还算规范,看个十几分钟就没问题了,继续看个组件


image-20230804134418622


啊~行,配合模板部分还是能看懂的,继续来:


image-20230804134856519


啊?我可以接受两个函数长得差不多,但是功能应该也差不多吧?你这addControl2里面的功能和addControl完全不一样啊!!一个是绑定控制地图时的监听回调,一个是添加地图控件,我承认这两种确实都能称之为 Control...但是


vue组件写的还都可以接受


image-20230804135816345


接口封装也行,命名和注释都有


image-20230804140404214


这哥们自己用正则写了个时间格式化函数,我只能说很强,但是没必要。


好吧,看到这,虽然每一项都还算过得去,但是如果让我维护这样的项目,实在是太累了(我截图不是个例各位,每个文件中都有类似的)。改了一周后,我申请了重构,没想到当即被领导拍板,重构吧,交给你了。


敲定技术栈


因为旧的项目变量和全局变量、路由传参、mixin混用,导致页面内的变量来源难以追踪,很多时候知道是这个值引起的问题,但是就是找不到这个值哪来(可能被路由传了好多级才过来)。由此,我准备用 ts + pinia 做类型和状态管理。


旧项目(webpack)每次冷启动或打包都要花费 20s 左右的时间,热更新在 1 - 2s之间还行。由于这个网站一直在使用阿里云云效在线部署,所以我也没特意看打包后的大小,但是估计要在几十上下,其实这些问题都不大,主要优化一些静态文件和无用的代码就好了。但是我还是因此选择了使用 vite


因为旧项目使用了vue2、mixin和vuex和vue-router导致 变量污染问题严重 。为了解决这个问题,除了上面提到的 ts + pinia ,还选用了vue3,因为vue3天然不支持mixin,使用组合式API很容易可以更清晰的实现mixin可以做到的所有功能。


在新项目中,使用vue-router时,只作为备用方案,因为在重构时进行了新一轮的需求整理,直接将告警平台移除,后面两个页面的核心功能合并到农业数字云,在此场景下,新版的农业数字云只需要一个路由就可以承载所有的功能了。并且,使用了pinia-plugin-persistedstate插件对pinia做了全局的持久化,这里要说明一点,很多人觉得在本地存储太多数据不太好,尤其是像我这样一股脑的全部持久化,其实,在localStorage允许的情况下,把一些常用数据甚至是状态信息存储在本地,是效率最高也是性能最好的方式。


最终技术栈为:vue3 + pinia + vue-router + ts


开干


概述


因为是旧的项目,所以接口基本都全,整理出来的新需求,后端大哥跟我一起做,速度很快,我们第一周就已经完成了整个项目的99%。


由于重构系统并没有出新的设计图,导致我页面端两眼一抹黑,只能靠模仿旧的大致样式来做(因为和旧版逻辑架构完全不一样了,所以大部分还是重做的),最终做出了一个更清爽的前端页面:


image-20230804143240923


image-20230804143358520


登录页也不是一个单独的页面了,几乎所有的功能都改成了轻量级的弹窗。


这个项目完整的展开后是这种样子:


image-20230804143517623


对比旧的


image-20230804143615322


重构和优化


页面变化


可以看到明显变化的地方有:



  1. 新增了行政区域级地块

  2. 不再对播种、未播种和样板田分类,而是使用tag徽标

  3. 不再对每个地块单独展示服务项,而是选中地块时使用同一个区域展示

  4. 移除了信息重复的图表,移除了冗余的图例、地图控件、农事记录

  5. 灾害信息亦作为服务项、不再单独列出

  6. 新增了时间轴替代了原本的轮播图


代码变化


不明显的变化在于:


移除所有生产环境的log

image-20230804144029382


控制台干净到一尘不染,生产环境的log全部被移除了,且使用ts编写,运行阶段基本不会出错,使用了autofit.js,一行轻松适配设计稿下任何分辨率(就是强无敌)


移除所有第三方大型UI组件库

image-20230804144258724


未使用任何大型UI组件库(我管你能不能树摇),这也使得该项目体积被压缩到了极限,打包后仅有 1M+


清晰代码结构

image-20230804144439678


代码结构清晰,现在内容比较少,但不难看出结构还是比较规范的,基本沿用了vite创建的默认结构,且静态文件基本没有,这也是保证打包体积的重要因素。


使用pinia持久化存储

image-20230804144806244


因为全篇使用了 pinia 持久化存储,开发时不用关心数据什么时候get什么时候set了,随时使用store,即可得到、设置数据,极大的提高了开发幸福感。


接口分类封装

image-20230804145117239


接口分类封装,调用时可以清楚的知道在调用哪个厂商的接口,并且有ts加持,绝不会少一个、错一个参数。


简化拦截器

image-20230804145317150


极简拦截器,拦截器本质上就是一个错误截获器,只需要保证后续流程不崩溃就可以了,所以这里只做了最简单的拦截器,非常好用。


简化路由、使用组件

image-20230804145538621


不出所料的,这个项目没有用到路由切换,所有的内部功能都以轻量化弹窗的形式展示,唯一需要跳转的是支付页面,但那是另一个项目,我们的解决方案是直接带着用户token跳转,简单粗暴。


基于mqtt理念的发布订阅范式

image-20230804145855326


我选择完全相信vue3的watch,以这种方式编码乍一看会很难阅读,但是实际上是仿照了mqtt的消息订阅机制,把watch当成一个subscribe,把store.xxx当成一个topic,你会发现这种写法再好理解不过了,并且这种做法很爽,你如果需要订阅一个数据的变化而做出一些操作的话,就写watch就好了。


清除冗余代码

image-20230804150605361


没有冗余代码,没有!函数、变量命名清晰规范,主打的就是一个清晰,你看我这段代码需要注释吗?


编码规范

image-20230804150758323


规范、规范、还是他妈的规范,这就是规范的节流器,都跟着写!


使用ts定义类型

image-20230804150944425


明确的类型,定义类型可能多花五分钟,但是会在编码时节省一小时。


开发轻量级必要组件

image-20230804151157258


自定义selecter、toast等组件,不需要的一点不写,主打的就是一个轻量。


打包


整个开发流程下来,打包体积达到了惊人的1.94M(少林功夫好耶,太棒辽啊🥳),线上运行表现基本令人满意(少林功夫,够劲!顶呱呱呀🥳)


image.png


完事


虽然在第一周改完99%后,又加了大大小小的新需求和新改动,不过在规范的开发面前都迎刃而解,编码成了一种享受,对于这种轻量级项目,就要用轻量级的方法去实现,搞得笨重的像一头装甲熊(没说沃利贝尔),没有任何意义,关于这次重构,除了技术上的学习,还有理念上的进步,往往一个前端项目,只需要保证页面不卡顿、不报错、不崩溃就行了,不要剪了芝麻丢了西瓜、头重脚轻、舍本逐末、南辕北辙。我是德莱厄斯,共勉。


作者:德莱厄斯
来源:juejin.cn/post/7263315523537928250
收起阅读 »

分享我使用两年的极简网页记事本

web
若无单独说明,按照文章代码块中命令的顺序,一条一条执行,即可实现目标。 适用系统:Debian 系发行版,包括 Ubuntu 和 Armbian,其他发行版按流程稍改命令一般也可。 走通预计时间:10 分钟(Docker) 可以访问这个实...
继续阅读 »

若无单独说明,按照文章代码块中命令的顺序,一条一条执行,即可实现目标。 适用系统:Debian 系发行版,包括 Ubuntu 和 Armbian,其他发行版按流程稍改命令一般也可。




走通预计时间:10 分钟(Docker)





可以访问这个实例: https://forward.vfly.app/index.php ,试试怎么样,公开使用的网页记事本。


minimalist-web-notepad



image.png

image.png



这个网页记事本是我 2 年前玩机子初期的一大驱动力。当时主要从手机上浏览信息,刚转变到在电脑上处理信息,需要一种简便的渠道在两者之间传递文本、网址。




网盘太重,微信需要验证,tg 很好,但在找到这个记事本后,都是乐色,这就是最好的全平台传递文本的工具。



极简网页 记事本,是一个使用浏览器访问的轻量好用的记事本,专注于文本记录。


Github:pereorga/minimalist-web-notepad: Minimalist Web Notepad (github.com)




使用方法





  1. 访问网页: https://forward.vfly.app/index.php



  2. 它会随机分配 5 个字符组成的地址,如 https://forward.vfly.app/5b79m ,如果想指定地址,只需要访问时手动修改,如 https://forward.vfly.app/this_is_a_path 。下面以 5b79m 为例。



  3. 在上面编辑文本



  4. 等待一会(几秒,取决于延迟),服务端就会存储网页内容到名为 5b79m 的文件里。



  5. 关闭网页,如果关闭太快,会来不及保存,丢失编辑。



  6. 在其他平台再访问同样的网址,就能剪切内容了 ٩۹(๑•̀ω•́ ๑)۶



只要不关闭过快和在两个网页同时编辑,它都能很好地工作。因为极简,项目作者不会考虑增加多余功能。




webnote-in-phone_compressed.webp

webnote-in-phone_compressed.webp


在远控其他电脑时,用这个先传递命令,在目标电脑上使用,非常方便,而且适应性强。多个手机之间也一样。或者用于临时传送敏感数据,避免受到平台审查。


使用 Docker 安装网页 记事本


GitHub: pereorga/minimalist-web-notepad at docker (github.com)


全复制并执行,一键创建工作目录并开放端口


myserve="webnote"
sudo ufw allow 8088/tcp comment $myserve && sudo ufw reload
cd ~/myserve/
wget https://github.com/pereorga/minimalist-web-notepad/archive/refs/heads/docker.zip
unzip docker.zip && mv minimalist-web-notepad-docker webnote
cd webnote

根据注释自定义,然后执行,一键创建 docker-compose.yml 文件


cat > docker-compose.yml << EOF
---

version: "2.4"
services:
  minimalist-web-notepad:
    build: .
    container_name: webnote
    restart: always
    ports:
     - "8088:80"
    volumes:
     - ./_tmp:/var/www/html/_tmp
EOF

前面的 5b79m 就存储在 _tmp 中。


构建并启动容器(完成后就可以访问网页了,通过 http://ip_addr_or_domain:8088 访问。将 ip_addr_or_domain 替换为服务器的 IP 或域名)


docker compose up -d

Docker 版很久没更新了,有技术的可以参考博文中原生安装流程创建镜像。


迁移


数据都在 /var/www/webnote/_tmp 中,也就是 ~/myserve/webnote/_tmp,在新机子上重新部署一遍,复制这个目录到新机子上即可。




受限于篇幅,如果对原生安装(Apache + PHP)网页 记事本 感兴趣,请到我的博客浏览,链接在下面。



原文链接: https://blog.vfly2.com/2023/08/a-minimalist-web-notepad-used-for-two-years/ 版权声明:本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 承飞之咎 (blog.vfly2.com)


作者:AhFei
来源:mdnice.com/writing/611872e312654a22aa2472d71a6b3844
收起阅读 »

当我遇见了强制横屏签字的需求...

web
序言 人的一生就是进行尝试,尝试的越多,生活就越美好。——爱默生 在前一阶段的工作中,突然接到了这个需求:手写签批的页面在移动端竖屏时强制页面横屏展示进行签字,一开始我觉着只要将页面使用 CSS3 的 transform 进行 rotate 一下就可以了...
继续阅读 »

序言



人的一生就是进行尝试,尝试的越多,生活就越美好。——爱默生



在前一阶段的工作中,突然接到了这个需求:手写签批的页面在移动端竖屏时强制页面横屏展示进行签字,一开始我觉着只要将页面使用 CSS3 的 transform 进行 rotate 一下就可以了,但是当我尝试后发现并不是像我想象的那样简单。


vue2实现手写签批


在介绍横屏签字之前,我想先说明一下我实现签批使用的插件以及插件所调用的方法,这样在之后说到横屏签字的时候,大佬们不会感觉唐突。


vue-signature-pad


项目使用 vue-signature-pad 插件进行签名功能实现,强调一下如果使用vue2进行开发,安装的 vue-signature-pad 的版本我自测 2.0.5 是可以的


安装


npm i vue-signature-pad@2.0.5

引入


// main.js
import Vue from 'vue'
import App from './App.vue'

import VueSignaturePad from 'vue-signature-pad'

Vue.use(VueSignaturePad)

Vue.config.productionTip = false

new Vue({
render: (h) => h(App),
}).$mount('#app')

使用 vue-signature-pad 完成签批功能


这里我为了方便直接写了一个demo放到App.vue中,没有封装成组件


// app.vue
<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="400px"
ref="signaturePad"
:options="options"
/>

</div>

<button @click="save">保存</button>
<button @click="resume">重置</button>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
console.log(isEmpty)
console.log(data)
},

//清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}
</style>



代码比较通俗易懂,就是调用组件封装好的方法,保存后能够解构出data为base64编码的图片
Kapture 2023-07-28 at 10.27.49.gif
之后需要将base64编码格式转换成File文件格式的图片最后进行接口请求,那么转换方法如下展示👇🏻


<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>

<div v-for="(item, index) in imgList" :key="index">
<img :src="item.src" alt="" width="100" />
</div>

<button @click="save" class="btn">保存</button>
<button @click="resume" class="btn">重置</button>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
imgList: [],
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
this.imgList.push({
src: data,
})
let res = this.dataURLtoFile(data, 'demo')
console.log(res)
},

// 清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},

// 将base64转换为文件
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)

while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}

return new File([u8arr], filename, { type: mime })
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}

.btn {
width: 35%;
color: #fff;
background: #5daaf3;
border: none;
height: 40px;
border-radius: 20px;
margin-top: 20px;
margin-left: 40px;
}
</style>


调用后,打印出转换成文件的图片如图


image.png
之后根据需求调用接口将文件图片作为入参即可。


阶段总结


经过上面的操作,我们就实现了前端的签批的完整流程,还是比较容易理解的。




新的需求


在实现这个功能不久之后,客户那边提出了新的需求:手机竖屏时将签字功能进行横屏展示。


错误思路


刚开始接到这个需求的时候,通过我所掌握的技术首先就是想到用CSS3的transform:rotate方法进行页面90deg的旋转,将签字组件也进行旋转之后进行签名;由于我对canvas不是很了解,所以我把包裹在签字组件外的div标签进行了旋转后签字发现落笔点位置错乱。


    <div style="background: #fff; transform: rotate(-90deg)">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>


改变思路


既然不能旋转外层的div,那我想到一种欺骗方式:不旋转div,样式修改成与横屏样式相似,然后将生成的图片进行一个旋转,这样就ok了!那么我们的目标就明确了,找到能够旋转bas64编码的方法然后返回一个旋转后的base64图片在转换成file文件传递给后端问题就解决了。

经过一个苦苦寻找,终于找到了方法并实现了这个功能,话不多说,先撸为敬(样式大佬们自己改下,我这里展示下转换后的图片)。


<template>
<div id="app">
<div style="background: #fff">
<vue-signature-pad
id="signature"
width="95%"
height="300px"
ref="signaturePad"
:options="options"
/>

</div>

<div v-for="(item, index) in imgList" :key="index">
<img :src="item.src" alt="" width="100" />
</div>

<div class="buttons">
<button @click="save" class="btn">保存</button>
<button @click="resume" class="btn">重置</button>
</div>
</div>

</template>

<script>
export default {
name: 'App',
data() {
return {
options: {
penColor: '#000',
},
imgList: [],
fileList: [],
}
},
methods: {
save() {
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()

this.rotateBase64Img(data, 90, (res) => {
console.log(res) // 旋转后的base64图片src
this.fileList.push({
file: this.dataURLtoFile(res, 'sign'),
name: 'sign',
})
this.imgList.push({
src: res,
})
})
},

// 清除重置
resume() {
this.$refs.signaturePad.clearSignature()
},

// 将base64转换为文件
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)

while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}

return new File([u8arr], filename, { type: mime })
},

// 通过canvas旋转图片
rotateBase64Img(src, edg, callback) {
var canvas = document.createElement('canvas')
var ctx = canvas.getContext('2d')

var imgW //图片宽度
var imgH //图片高度
var size //canvas初始大小

if (edg % 90 != 0) {
console.error('旋转角度必须是90的倍数!')
throw '旋转角度必须是90的倍数!'
}
edg < 0 && (edg = (edg % 360) + 360)
const quadrant = (edg / 90) % 4 //旋转象限
const cutCoor = { sx: 0, sy: 0, ex: 0, ey: 0 } //裁剪坐标

var image = new Image()

image.crossOrigin = 'anonymous'
image.src = src

image.onload = function () {
imgW = image.width
imgH = image.height
size = imgW > imgH ? imgW : imgH

canvas.width = size * 2
canvas.height = size * 2
switch (quadrant) {
case 0:
cutCoor.sx = size
cutCoor.sy = size
cutCoor.ex = size + imgW
cutCoor.ey = size + imgH
break
case 1:
cutCoor.sx = size - imgH
cutCoor.sy = size
cutCoor.ex = size
cutCoor.ey = size + imgW
break
case 2:
cutCoor.sx = size - imgW
cutCoor.sy = size - imgH
cutCoor.ex = size
cutCoor.ey = size
break
case 3:
cutCoor.sx = size
cutCoor.sy = size - imgW
cutCoor.ex = size + imgH
cutCoor.ey = size + imgW
break
}

ctx.translate(size, size)
ctx.rotate((edg * Math.PI) / 180)
ctx.drawImage(image, 0, 0)

var imgData = ctx.getImageData(
cutCoor.sx,
cutCoor.sy,
cutCoor.ex,
cutCoor.ey
)

if (quadrant % 2 == 0) {
canvas.width = imgW
canvas.height = imgH
} else {
canvas.width = imgH
canvas.height = imgW
}
ctx.putImageData(imgData, 0, 0)
callback(canvas.toDataURL())
}
},
},
}
</script>


<style lang="scss">
html,
body {
padding: 0;
margin: 0;
}
#app {
width: 100vw;
height: 100vh;
background: #ececec;
}

.btn {
width: 35%;
color: #fff;
background: #5daaf3;
border: none;
height: 40px;
border-radius: 20px;
margin-top: 20px;
margin-left: 40px;
}
</style>


那么经过翻转后当我们横屏移动设备时,保存出的图片会进行90度旋转,传递给后端的图片就是正常的了✅(代码可直接食用)


处理细节


后来我发现签字的笔锋太细了,打印出来的效果很差,于是通过查阅,只要设置 options 中的 minWidth和maxWidth 大一些即可
到此所有需求就已经都解决了。


总结


其实平时开发中没有对canvas用到很多,导致对这块的知识很薄弱,我在查阅的时候找到过用原生实现此功能,不过因为时间不够充裕,为了完成需求耍了一个小聪明,后续应该对canvas更多的了解一下,在深入了解上面的旋转方法具体是如何实现的,希望这篇文章能够对遇到这种需求并且时间紧迫的你有所帮助!如果有更好的方式,也希望大佬们分享,

作者:爱泡澡的小萝卜
来源:juejin.cn/post/7260697932173590565
交流经验变得更强!!

收起阅读 »

前端简洁表单模型

web
大家好,我是前端菜鸡木子 今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:Formily、Ant Design、FormRen...
继续阅读 »

gabriel-ramos-azbe3hSHNHU-unsplash.jpg


大家好,我是前端菜鸡木子


今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:FormilyAnt DesignFormRender 等。这些框架的底层都维护着一套基础的「表单模型」,虽然框架不同,但是「表单模型」的设计却是基本一致,只是上层应用层的设计会随着业务的需求进行调整。今天的主题也会围绕着「表单模型」进行展开


前言


本文是偏基础层面的介绍,不会涉及到太多框架的源码解析。另外,我会以最近如日中天的 Formily 为例进行讲解,大家如果对 Formily 不太了解,可以先去了解和使用。


表单模型的基础概念


我们知道一个表单包含了 N 多个字段,每个字段都需要用户输入或者联动带出,当用户输入完成之后我们可以通过 Form.Values 的形式直接获取到表单内部 N 多个字段的值,那么这是如何实现的呢?


我们通过一张图来简单阐述下:


yuque_diagram.png


其中:



  • Form:是通过 JS 维护的一个表单模型实例,FormilycreateForm 返回的就是这个实例,它负责维护表单的所有数据和每个字段 Field 的实例

  • Field: 是通过 JS 维护的每一个字段的实例,它负责维护当前字段的所有数据和状态

  • Component: 是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件


从图中不难看出,每个 Field 都对应着一个展示层的 Component,当用户在 Component 层输入时,会触发 props.onChange 事件,然后在事件内部将用户输入的值传入到 Field 里。同时当 Field 值变化时 (比如初始化时的默认值,或者通过 field.setValue 修改字段的值 ),又会将 Field.value 通过 props.value 的形式传入到 Component 内部,以此来达到 ComponetField 的数据联动。


我们可以看下在 Formily 内部是如何实现的(已对源码进行一些优化和注释):


const renderComponent = () => {
// 获取 Field 的 value
const value = !isVoidField(field) ? field.value : undefined;

// 设置 onChange 事件
const onChange = !isVoidField(field)
? (...args: any[]) => {
field.onInput(...args)
field.componentProps?.onChange?.(...args)
}
: field.componentProps?.onChange

// 生成 Field 对应的 Component
return React.createElement(
getComponent(field.componentType),
{
value,
onChange,
},
content
)
}

这里面的 onChange 事件里触发了 field.onInput 的事件,在 field.onInput 内会做两件事情:



  • onChange 携带的 value 赋值给 field.value

  • onChange 携带的 value 赋值给 form.values


这里需要额外说明的是,一个 Form 会通过「路径」系统聚合多个 Field,每个 Field.value 也是通过路径系统被聚合到 Form.values 下。


我们通过一个简单的 demo 来介绍下路径的概念:


const formValues = {
key1: {
key2: 'value',
}
};

我们通过 key1.key2 可以找到一个具体的值,这个 key1.key2 就是一个路径。在 Formily 内维护了一个高级的路径模块,感兴趣的可以去看下 form-path


表单模型的响应式


聊完表单模型的基础概念后,我们知道



  • Component 组件通过 props.onChange 将用户的数据回传到 FieldForm 实例内

  • Field 实例内的 value 会通过 props.value 形式传递到 Component 组件内


那么问题来了,Field 实例内部的 value 改变后,Component 组件是如何做到细粒度的重新渲染呢?


不卖关子,直接公布答案:



  • formily: 通过 formily/reactive 进行响应式跟踪,能知道具体是哪个组件依赖了 Field.value, 并做到精准刷新

  • Antd:通过 rc-field-form/useForm 这个 hook 来实现,本质上是通过 const [, forceUpdate] = React.useState({}); 来实现的


虽然这两种方法都能实现响应式,但是 Ant 的方式比较暴力,当其中一个 Field.value 发生改变时,整个表单组件都需要 render 。而 Formily 能通过 formily/reacitve 追踪到具体改变的 Field 对应的 Componet 组件,只让这个组件进行 render



formily/reactive 实现比较复杂,这边不会深入探讨具体实现方式,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架 (本质上是通过 Proxy 来拦截 getset,从而实现依赖追踪)



接下来,我们就看下如何借助 formily/reactive 来实现响应式


第一步:我们需要在 Field 初始化时将 value 变成响应式:


import { define, observable } from '@formily/reactive'

class Field {
constructor(props) {
// 初始化 value 值
this.value = props.value;

// 将 this.value 变成响应式
define(this, {
value: observable.computed
})
}
}

第二步:对 Field 对应的 Componet 进行下 "包装":


import { observer } from '@formily/reactive-react'

const ReactiveComponentInernal = () => {
// renderComponent 源码在 「基础概念」章节里
return renderComponent();
}

export const FieldComponent = observer(ReactiveComponentInernal);


observer 内部也和 rc-field-form/useForm 类似,通过 const [, forceUpdate] = React.useState({}); 来实现依赖改变时,子组件级别的动态 render



到此为止,表单模型的响应式也基本完成了


表单模型的规范


有了以上的表单模型,我们就可以构建一个简单的表单框架。但是真实的业务场景却不可能这么简单,迎面而来的第一个问题就是「联动」,举个例子:


QQ20230729-134607-HD.gif


需求:当城市名称改变后,城市编码字段需要联动带出对应的值。我们可以快速想到两种方案:



  • 方案1:在 城市名称 字段的 onChange 事件里通过 form.values.cityCode = hz 的形式去动态修改 城市编码 字段。

  • 方案2:在 城市编码 字段里显示的配置对 城市名称 字段的依赖,同时需要配置依赖改变时的处理逻辑,例如:


const formSchema = {
cityName: {
'x-component': 'Select',
},
cityCode: {
'x-component': 'Input',
'x-reactions': {
dependencies: ['cityName'],
fulfill: {
state: {
value: '{{ $deps[0]?.value }}',
},
},
},
},
};

无论方案 1 还是方案 2 都能实现需求,但是两个方案各有缺点


方案 1 有两个问题:



  • 问题一:打破了【表单模型的基础概念】,cityName 对应的组件的 onChange 事件里「直接」对 cityNamecityCode 字段进行了修改。

  • 问题二:我们不能「直观」的看到 cityCodecityName 字段产生了依赖,只有在看具体代码时才能知道


方案 2 也会有两个问题:



  • 问题一:schema 本身的可读性不强,且使用 formily schema 时,配置内容比较多

  • 问题二:使用 schema 配置 x-component-props 时不能使用 ts 特性


当表单逐渐复杂起来的时候,方案 1 的弊端会逐步显现出来,字段间会产生诸多的 「幽灵」依赖和控制,导致后续迭代的时候根本无从下手。所以在我自己的团队内部,我们规定出了几条「表单模型」的使用规范:




  • 规范 1: 每个 Field 对应的 Component 只对自己的字段负责,不允许通过 Form api 直接修改其他字段

  • 规范 2: 在 formSchema 里需要维护表单的所有字段配置和依赖,字段间不允许出现「幽灵」依赖

  • 规范 3: 尽量不要使用 form.setValuesform.queryField('xxx').setValue 等动态修改字段值的 Form api(特殊场景除外)

  • 规范 4: 表单涉及到的所有字段都尽量存储到表单模型中,不要使用外部变量来保存



这些规范其实是个普适性的范式,无论你在使用 Formily 也好,还是 Ant Design 也好,都需要去遵守。规范 2 里我用了 Formilyschema 来说明,但如果你使用的是 Ant Design,可以把 formSchema 理解为 <Form.Item reaction={{ xxx }}></Form.Item>



其实 formily 的 schema 最终会通过 RecursionField 组件递归渲染成具体的 FormItem 形式



表单模型的应用层


有了上述的「表单模型」概念和规范之后,我们就可以来构建表单模型的应用层了


yuque_diagram(1).png



  • Form Scheam: 整个表单的配置中心,负责表单各个字段的配置和联动 、校验等,它只负责定义不负责实现。它可以是个 Json Schema,也可以是 Ant Design<FormItem>

  • Form Component: 表单内每个字段的 UI 层组件,可以再分为:基础组件业务组件,每个组件都只负责和自己对应的 Field 字段交互

  • 业务逻辑:将复杂业务抽象出来的业务逻辑层,纯 JS 层。当然这一层是虚拟的概念,它可以存在于 Form Componet 里,也可以放在入口的 Index Component 内。如果业务复杂, 也可以放到 hooks 里或者单独的 JS 模块内部


有了应用层架构后,在写具体表单页面时,我们需要在脑海中清晰的勾勒出每层(Schema Component Logic)的设计。当页面足够简单时,也许会没有 Logic 层,Component 层也可以直接使用自带的基础表单组件,但是在设计层面我们不能混淆


表单模型的实践 - Formily


从去年开始,我们团队便引入 formily 作为中后台表单解决方案。在不断的实践过程中,我们逐步形成了一套自己的开发范式。主要有以下几个方面


Formily 的取舍


我们借助了 formily 的以下几个能力:



  • formily/reactive: 通过 reacitve 响应式框架来构建业务侧的数据模型

  • formily/schema: 通过 json-schema 配置来描述整个表单字段的属性,当然其背后还携带着 formily 关于 schema 的解析、渲染能力

  • formily/antd: 一些高级组件


同时,我们也在尽量避免使用 formily 的一些灵活 API:



  • Form 相关 API:比如 useFormform.setValues 等,我们不希望在任何组件内部都能「方便」的窜改整个表单的所有字段值,如果当前字段对 XX 字段有依赖或者影响,你应该在 schema 里显示的声明出来,而不是偷偷摸摸的修改。

  • Query 相关 API: 比如 form.query('field'),原因同上


当然,这不代表我们绝不会使用这些 API ,比如在表单初始化时需要回填信息的场景,我们就会用到 form.setValues 。我想说明的是不能滥用!!!


静态化的 schema


我们认为 schema 和普通的 JSX 相差不大,只不过前者是通过 JSON 标准语言来表述而已,举个例子:


// chema 形式
const formSchema = {
name: {
type: 'string',
'x-decorate': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: '请输入名称'
}
}
}

// jsx 形式
const Form = () => {
return (
<Form>
<FormItem name"name">
<Input placeholder="请输入名称" />
</FormItem>
</Form>

)
}

schema 最终也会被 formily/react 解析成 jsx 格式。那为什么我们推荐使用 schema 呢?



  • 原因一:schema 可以做到足够的静态化,避免我们做一些灵活的动态操作 (在 jsx 里我们几乎能做通过 form 实例动态的做任何事情)

  • 原因二: schema 更容易被解析和生成,为之后的智能化生成做铺垫(不一定是低代码)


表单模型的挑战


在真实业务开发过程中,我们对表单模型的使用会出现一些问题,以两个常见的问题为例:



  • 问题 1:我们是通过表单的 UI 结构来设计 schema 还是通过表单数据结构来设计?

  • 问题 2:有时候为了简单,我们会设计出一个巨大的 Component,这个 Componet 对应的 Field 嵌套了很多层字段


下面这个案例就可能触发上述的两个问题:


demo2.gif


其中,每个分类都对应着一组商品,所以最终表单的数据格式应该是这样的:


{
categoryList: [
{
categoryName: '分类一'
productList: [{ productName: '商品一', others: 'xxx' }],
},
{
categoryName: '分类二'
productList: [{ productName: '商品二', others: 'xxx' }],
}
],
}

我们提供两种思路来设计这个表单


方案一


我们发现简单的通过 ArrayTable 是实现不出这种交互的,所以我们直接设计出一个大而全的 Component,那么我们的实现方式应该是这样的:


// 设计一个大而全组件,过滤组件内部实现
const BigComponent = (props) => {
return (
<Row>
<CategoryArrayTable />
<ProductArrayTable />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': BigComponent,
}
}

在这种方案里,BigComponent 组件需要 onChange 整个表单的值(多层嵌套的对象数组),这会出现一个问题:formSchema 里看不到表单的所有字段配置,如果字段间需要有联动,那么只能在 BigComponent 组件内部去实现(违反了规范2)。


方案二


我们认为 schema 是面对表单数据结构设计的,Component 是面对 UI 设计的,两者的设计思路是分开的(但是在大多数场景下两者的设计结果是一致的)
那么我们的实现方式应该是这样的:


// 基于 formily/antd/ArrayTable + formily/react RecursionField 来实现
const CategoryArrayTable = (props) => {
return (
<Row>
<ArrayTableWithoutProductList />
<ArrayTableWithProductList />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': CategoryArrayTable,
items: {
categoryName: {
type: 'string',
'x-component': 'Select',
},
productList: {
type: 'array,
'
x-component': 'ArrayTable',
items: {
productName: {
type: '
string',
'
x-component': 'Select',
},
others: {},
}
}
},
}
}

在这种方案的 schema 里能够直接反映出表单的所有字段配置,一目了然,而且真实的代码实现会比方案一简洁很多


但是呢,这个方案有个难点,需要开发者对 formily 的渲染机制,主要是 RecursionFieldArrayTable 的源码有一定程度的了解。


当然,还有很多其他的方案可以实现这个需求,这边只是拿出两个方案来对比下设计思路上的差异,虽然最终的方案取舍是根据团队内部协商 + 规范而定的,但是在我自己的团队里,我们一直保持着一种设计准则:



schema 是面对表单结构的,Component 是面对 UI 的



后续


在实践过程中,我们发现了一些待优化点:


1、我们发现对于复杂的表单页面,schema 的配置会非常冗长,如果 schema 足够静态化的话,我们是否可以简化对 schema 的编写,同时能提高 schema 的可读性呢?低代码平台是个方案,但是太重,是否可以考虑弄个 vsocde 插件类接管 schema ?


2、如果表单配置、表单子组件、业务逻辑都由 schemaComponentLogic Fucntion 来负责了,我们是否可以取消表单页面的入口组件 index.tsx 呢?


当然随着对表单的不断深入研究,还有很多其他问题可以优化和解决

作者:木与子
来源:juejin.cn/post/7261262567304921146
,这边就不一一列举了

收起阅读 »

三言两语说透柯里化和反柯里化

web
JavaScript中的柯里化(Currying)和反柯里化(Uncurrying)是两种很有用的技术,可以帮助我们写出更加优雅、泛用的函数。本文将首先介绍柯里化的概念、实现原理和应用场景,然后介绍反柯里化的概 念、实现原理和应用场景,通过大量的代码示例帮助读...
继续阅读 »

JavaScript中的柯里化(Currying)和反柯里化(Uncurrying)是两种很有用的技术,可以帮助我们写出更加优雅、泛用的函数。本文将首先介绍柯里化的概念、实现原理和应用场景,然后介绍反柯里化的概 念、实现原理和应用场景,通过大量的代码示例帮助读者深入理解这两种技术的用途。


JavaScript中的柯里化


概念


柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由数学家Haskell Curry命名。


简单来说,柯里化可以将使用多个参数的函数转换成一系列使用一个参数的函数。例如:


function add(a, b) {
  return a + b; 
}

// 柯里化后
function curriedAdd(a) {
  return function(b) {
    return a + b;
  }
}

实现原理


实现柯里化的关键是通过闭包保存函数参数。以下是柯里化函数的一般模式:


function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  }
}

curry函数接受一个fn函数为参数,返回一个curried函数。curried函数检查接收的参数个数args.length是否满足fn函数需要的参数个数fn.length。如果满足,则直接调用fn函数;如果不满足,则继续返回curried函数等待接收剩余参数。


这样通过闭包保存每次收到的参数,直到参数的总数达到fn需要的参数个数,然后将保存的参数全部 apply fn执行。


利用这个模式可以轻松将普通函数柯里化:


// 普通函数
function add(a, b) {
  return a + b;


// 柯里化后
let curriedAdd = curry(add); 
curriedAdd(1)(2); // 3

应用场景



  1. 参数复用


柯里化可以让我们轻松复用参数。例如:


function discounts(price, discount) {
  return price * discount;
}

// 柯里化后
const tenPercentDiscount = discounts(0.1); 
tenPercentDiscount(500); // 50
tenPercentDiscount(200); // 20


  1. 提前返回函数副本


有时我们需要提前返回函数的副本给其他模块使用,这时可以用柯里化。


// 模块A
function ajax(type, url, data) {
  // 发送ajax请求
}

// 柯里化后
export const getJSON = curry(ajax)('GET');

// 模块B
import { getJSON } from './moduleA'

getJSON('/users', {name'John'});


  1. 延迟执行


柯里化函数在调用时并不会立即执行,而是返回一个函数等待完整的参数后再执行。这让我们可以更加灵活地控制函数的执行时机。


let log = curry(console.log);

log('Hello'); // 不会立即执行

setTimeout(() => {
  log('Hello'); // 2秒后执行
}, 2000);

JavaScript中的反柯里化


概念


反柯里化(Uncurrying)与柯里化相反,它将一个接受单一参数的函数转换成接受多个参数的函数。


// 柯里化函数  
function curriedAdd(a) {
  return function(b) {
    return a + b;
  }
}

// 反柯里化后
function uncurriedAdd(a, b) {
  return a + b; 
}

实现原理


反柯里化的关键是通过递归不停调用函数并传入参数,Until参数的数量达到函数需要的参数个数。


function uncurry(fn) {
  return function(...args) {
    let context = this;
    return args.reduce((acc, cur) => {
      return acc.call(context, cur); 
    }, fn);
  }
}

uncurry 接收一个函数 fn,返回一个函数。这个函数利用reduce不停调用 fn 并传入参数,Untilargs所有参数都传给 fn


利用这个模式可以轻松实现反柯里化:


const curriedAdd = a => b => a + b;

const uncurriedAdd = uncurry(curriedAdd);
uncurriedAdd(1, 2); // 3

应用场景



  1. 统一接口规范


有时我们会从其他模块接收到一个柯里化的函数,但我们的接口需要一个普通的多参数函数。这时可以通过反柯里化来实现统一。


// 模块A导出
export const curriedGetUser = id => callback => {
  // 调用callback(user)
};

// 模块B中
import { curriedGetUser } from './moduleA';

// 反柯里化以符合接口
const getUser = uncurry(curriedGetUser); 

getUser(123user => {
  // use user
});


  1. 提高参数灵活性


反柯里化可以让我们以任意顺序 passes 入参数,增加了函数的灵活性。


const uncurriedLog = uncurry(console.log);

uncurriedLog('a''b'); 
uncurriedLog('b''a'); // 参数顺序灵活


  1. 支持默认参数


柯里化函数不容易实现默认参数,而反柯里化后可以方便地设置默认参数。


function uncurriedRequest(url, method='GET', payload) {
  // 请求逻辑
}

大厂面试题解析


实现add(1)(2)(3)输出6的函数


这是一道典型的柯里化面试题。解析:


function curry(fn) {
  return function curried(a) {
    return function(b) {
      return fn(a, b);
    }
  }
}

function add(a, b) {
  return a + b;
}

const curriedAdd = curry(add);

curriedAdd(1)(2)(3); // 6

利用柯里化技术,我们可以将普通的 add 函数转化为 curriedAdd,它每次只接收一个参数,并返回函数等待下一个参数,从而实现了 add(1)(2)(3) 的效果。


实现单参数compose函数


compose函数可以将多个函数合并成一个函数,这也是一道常见的柯里化面试题。解析:


function compose(fn1) {
  return function(fn2) { 
    return function(x) {
      return fn1(fn2(x));
    };
  };
}

function double(x) {
  return x * 2;
}

function square(x) {
  return x * x;
}

const func = compose(double)(square);

func(5); // 50

利用柯里化,我们创建了一个单参数的 compose 函数,它每次返回一个函数等待下一个函数参数。这样最终实现了 compose(double)(square) 的效果。


反柯里化Function.bind


Function.bind 函数实现了部分参数绑定,这本质上是一个反柯里化的过程。解析:


Function.prototype.uncurriedBind = function(context) {
  const fn = this;
  return function(...args) {
    return fn.call(context, ...args);
  } 
}

function greet(greeting, name) {
  console.log(greeting, name);
}

const greetHello = greet.uncurriedBind('Hello');
greetHello('John'); // Hello John

uncurriedBind 通过递归调用并传参实现了反柯里化,使 bind 参数从两步变成一步传入,这也是 Function.bind 的工作原理。


总结


柯里化和反柯里化都是非常有用的编程技巧,让我们可以写出更加灵活通用的函数。理解这两种技术的实现原理可以帮助我们更好地运用它们。在编码中,我们可以根据需要决定是将普通函数柯里化,还是将柯里化函数反柯里化。合理运用这两种技术可以大大

作者:一码平川哟
来源:juejin.cn/post/7262349502920605753
提高我们的编程效率。

收起阅读 »

如何为你的 js 项目添加 ts 支持?

web
前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。 安装 TypeScript 依赖 首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件: pnpm ins...
继续阅读 »

前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。





安装 TypeScript 依赖


首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件:


pnpm install -D typescript

创建配置文件


接下来创建 TypeScript 配置文件:


npx tsc --init

这一步会在项目的根目录下创建一个 tsconfig.json 文件。我们在原来配置的基础上开放一些配置:


{
  "compilerOptions": {
     "target": "es2016",
     "module": "commonjs",
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
     "strict": true,
     "noImplicitAny": false,
     "skipLibCheck": true,
+    "allowJs": true,
+    "checkJs": true,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "rootDir": "./",
+    "outDir": "./types",
   }
+  "include": [
+    "security/**/*"
+  ]
}

字段说明


对上述字段,我们挑几个重要的说明一下。





  • allowJscheckJs 增加 JS 文件支持



  • declarationemitDeclarationOnly 我们只需要 tsc 帮我们生成类型声明文件即可



  • rootDiroutDir 指定了类型声明文件生成到 types/ 目录



  • include 我们只为 security/ 目录下的代码生成类型声明文件


想详细了解每个配置字段的含义,可以参考 TypeScript 官方说明:https://aka.ms/tsconfig


生成类型文件


项目根目录下创建 index.d.ts 文件


export let security: typeof import("./types/security");

接下里修改 package.json, 增加当前 npm 包的类型声明支持和构建脚本 typecheck


{
    "scripts": {
        // ...
        "typecheck""tsc",
    },
    types: "index.d.ts"   
}

接下来执行脚本:


npm run typecheck

最后就能看到在 types/ 目录下为 security/ 生成的类型声明文件了。


作者:zhangbao
来源:mdnice.com/writing/55c153377fd3436581576b7017c25f3a
收起阅读 »

如何在页面关闭时发送 API 请求

web
前言 在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等。 在 window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。 要实现这个需求,普遍的做法是在 w...
继续阅读 »

前言


在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等


window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。


要实现这个需求,普遍的做法是在 window.onbeforeunload 监听事件回调中发起 api 请求。


const onBeforeunload = async () => {
// 发起请求
}
window.addEventListener('beforeunload', onBeforeunload);


注意:在移动设备下,一些浏览器并不支持 beforeunload 事件,最可靠的方式是在 visibilitychange 事件中处理。



document.addEventListener('visibilitychange', function logData() {
if (document.visibilityState === 'hidden') {
...
}
});

发起请求的方式有如下几种:



  1. ajax(XMLHttpRequest)

  2. sendBeacon(Navigator.sendBeacon)

  3. fetch(Fetch keepalive)


下面,我们分析对比以上几种方式的优劣及适用性。


一、ajax


早期前后端进行数据交互多数都采用 XMLHttpRequest 方式创建一个 HTTP 请求,默认采用 异步 方式发起请求:


const ajax = (config) => {
const options = Object.assign({
url: '',
method: 'GET',
headers: {},
success: function () { },
error: function () { },
data: null,
timeout: 0,
async: true, // 是否异步发送请求,默认 true 是异步,同步需设置 false。
}, config);
const method = options.method.toUpperCase();

// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
xhr.timeout = options.timeout; // 设置请求超时时间

// 2、建立连接
xhr.open(method, options.url, options.async); // 第三参数决定是以 异步/同步 方式发起 HTTP 请求

// 设置请求头
Object.keys(options.headers).forEach(key => {
xhr.setRequestHeader(key, options.headers[key]);
});

// 3. 发送数据
xhr.send(['POST', 'PUT'].indexOf(method) > -1 ? JSON.stringify(options.data) : null);

// 4. 接收数据
xhr.onreadystatechange = function () { // 处理响应
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
options.success(xhr.responseText);
} else {
options.error(xhr.status, xhr.statusText);
}
}
};

// 超时处理
xhr.ontimeout = function () { options.error(0, 'timeout') };
// 错误处理
xhr.onerror = function () { options.error(0, 'error') };
// xhr.abort(); // 取消请求
}

对于 ajax 发起异步请求,若在发送过程中 刷新或关闭 浏览器,请求会被自动终止,如下图:


image.png



如果想在控制台查看刷新前页面接口调用情况,可勾选 Preserve log 选项,Network 会保留上个页面的请求记录。



可见,异步方式的 ajax 请求被浏览器自动 cancel 取消,无法将数据正常推送到后台。


一种处理方式是改为 同步 ajax 请求方式,在调用 open 建立连接时,第三参数 async 传递 false 表示以 同步方式 发送请求:


xhr.open(method, options.url, false);

但目前,谷歌浏览器已经不允许在页面关闭期间发起 同步 XHR 请求,建议使用 sendBeacon 或者 fetch keep-alive。我们接着往下看。


二、sendBeacon


navigator.sendBeacon()  方法可用于通过 HTTP POST 将少量数据 异步 传输到 Web 服务器。



官方链接:developer.mozilla.org/zh-CN/docs/…



它的语法如下:


navigator.sendBeacon(url);
navigator.sendBeacon(url, data);


  • url: 指定将要被发送到的网络地址;

  • data: 可选,是将要发送的 ArrayBufferArrayBufferViewBlobDOMStringFormData 或 URLSearchParams 类型的数据。

  • return: 返回值。当用户代理成功把数据加入传输队列时,sendBeacon()  方法将会返回 true,否则返回 false


navigator.sendBeacon 使用示例如下:


// 通过 Blob 方式传递 JSON 数据
const blob = new Blob(
[JSON.stringify({ ... })],
{ type: 'application/json; charset=UTF-8' }
);
// 发送请求
navigator.sendBeacon(url, blob);

sendBeacon 发送请求有以下几个特点:



  1. 通过 HTTP POST 请求方式 异步 发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能;

  2. 支持跨域,但不支持自定义 headers 请求头,这意味着:如果用户信息 Access-Token 是作为请求头信息传递,需要后台接口支持 url querystring 参数传递解析。

  3. 考虑其兼容性。


三、fetch keep-alive


当使用 fetch() 请求时,如果把 RequestInit.keeplive 设置为 true,即便页面被终止请求也会保持连接。


fetch(url, {
method: 'POST',
body: JSON.stringify({ ... }),
headers: {
'Content-Type': 'application/json', // 指定 type
},
keepalive: true,
});

推荐使用 Fetch API 实现「离开网页时,将数据保存到我们的服务器上」。


但它也有一些限制需要注意:



  1. 传输数据大小限制:无法发送兆字节的数数据,我们可以并行执行多个 keepalive 请求,但它们的 body 长度之和不得超过 64KB

  2. 无法处理服务器响应:在网页文档卸载后,尽管设置 keepalive 的 fetch 请求可以成功,但后续的响应处理无法工作。所以在大多数情况下,例如发送统计信息,这不是问题,因为服务器只接收数据,并通常向此类请求发送空的响应。


思考


在框架的生命周期,如 React useEffect 可以实现页面关闭时发送 HTTP 请求记录数据吗?


答案是:不可以


尽管,我们所理解的 useEffect 中的销毁函数会在页面销毁时触发,但有一个前提条件是:程序保活正常运行,即 ReactDOM.render 创建的 FiberRoot 正常运转


试想,浏览器页面进行刷新或关闭,React 所启动的应用会直接中断停止,程序中页面定义的 useEffect 将不会被执行。


参考


Navigator sendBeacon页面关闭也能发送请求方法.

fetch keep

laive.

收起阅读 »

微前端是怎样炼成的,从思想到实现

web
1 道 “微前端”的概念最早由 Thoughtworks 在2016年提出。 微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。 ——...
继续阅读 »

1 道


“微前端”的概念最早由 Thoughtworks 在2016年提出。



微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。
—— 黄峰达《前端架构——从入门到微前端》



1.1 独立


独立开发、独立部署、独立运行,是微前端应用组织的关键词。独立带来了很多有价值的特性:



  • 不同微应用可以使用不同的技术栈,从而兼容老应用,微应用也可以独立选型、渐进升级;

  • 微应用有单独的 git 仓库,方便管理;

  • 微应用隔离,单独上线,回归测试无需测试整个系统;

  • 拆分应用,加速加载;


为了实现可靠且灵活的独立,微前端必须面对几个核心问题:



  • 微应用间如何调度、解析、加载?

  • 如何避免运行时互相污染?

  • 微应用间如何进行通信?



1.2 大道至简——微前端的理论基础


微前端能成的理论基础是,底层API的唯一性。


首先无论各家前端框架多么天花乱坠,最后都离不开一个操作 ——「通过 js 在一个DOM容器中插入或更新节点树」。所以你在各家的demo也都看得到这样的 api 描述:


ReactDOM.render(<App />, document.getElementById('root'));	// react
createApp(...).mount('#app'); // vue

所以只要提供容器,就能让任何前端框架正常渲染。再上一层,任何 JS API,都离不开在全局对象 window 上的调用,包括 DOM 操作、事件绑定、页面路由、前端存储等等。所以只要封住 window,就可以隔离微应用的运行时。


1.3 主流微前端方案套娃


微前端是一个概念,历史上各种实现方案层出不穷,到今天阿里 qiankun 的方案成为国内主流。


qiankun 底层基于 single-spa,而业务系统也倾向于再在外面包一层,三层方案各自专注解决不同的问题:




  • single-spa 的官方定位是「一个顶层路由,当路由处于活动状态时,它将下载并执行该路由的相关代码」。放到微前端概念中,它专注解决微应用基于路由的调度。

  • qiankun 是一个「微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统」。在 single-spa 的基础上:所谓「简单」,是降低了接入门槛,增强了资源接入方式,支持 HTML Entry;所谓「无痛」,是尽量降低了微前端带来的副作用,即提供了样式和JS的隔离,并通过资源缓存加速微应用切换性能。

  • 到业务系统这一层,着重解决业务生产环境中的问题。最常见的像提供一个MIS管理后台,灵活配置,动态下发微应用信息,实现动态应用插拔。


2 single-spa


single-spa 做的事很聚焦,核心流程是:1、注册路由对应资源 —> 2、监听路由 —> 3、加载对应资源 —> 4、执行资源提供的状态回调。


2.1 api 概览


为了实现这套流程,single-spa 首先提供了「1、注册路由对应资源」的接口:


singleSpa.registerApplication({ name, appLoader, activeWhen });

然后启动「2、监听路由 —> 3、加载对应资源」机制:


singleSpa.start();

对资源则有「提供状态回调」的改造要求:


// 资源代码
export function bootstrap(props) {}
export function mount(props) {}
export function unmount(props) {}
export function unload(props) {} // 可选

2.2 整体实现原理


很显然,这里面有一套应用的状态机制,以及对应的状态流转流程,在 single-spa 内部是这样的:




  • app 池收集注册进来的微应用信息,包括应用资源、对应路由。app 池中的所有微应用,都会维护一个自身的状态机。

  • 刷新器是整个 single-spa 的发动机,负责流转整个状态流程。一旦刷新器被触发(首次启动或路由更新),就开始调度:

    • 拿着最新路由去池子里分拣 app

    • 根据分拣结果,执行 app 资源暴露的生命周期方法




2.3 app 池的实现


app 池的实现都在 src/applications/apps.js 模块中,首先是一个全局池:


const apps = [];

然后直接实现并导出 registerApplication 方法作为向 app 池添加成员的入口:


export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps) {
const registration = sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps);
apps.push(
assign(
{ status: NOT_LOADED },
registration
)
);
if (isInBrowser) {
reroute();
}
}

registerApplication 做了几件事:



  1. 构造 app 对象,整理入参,这个和 single-spa 入参兼容有关系。最终 app 对象将包含app 信息、状态、资源、激活条件等信息。

  2. 加入 app 池。这里可以看到初始状态是 NOT_LOADED

  3. 触发了一次 reroute。


2.4 reroute 触发


前面 registerApplication 调了一次 reroute 方法,这就是执行一次刷新。reroute 会在下列场景执行:



  • registerApplication:微应用注册

  • start:框架启动

  • 路由事件(popstate、hashchange)触发


window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
function urlReroute() {
reroute([], arguments);
}

2.5 reroute 分拣执行


reroute 先判断 app 是否应该激活,逻辑很简单,就是把当前路由带到 app.activeWhen 里计算返回(app.activeWhen(window.location)),这里我们只看当前应该处于什么状态。而且按我们通常用法,只有少数 app 会激活。



接下来看微前端应用的激活过程,是先 load 下载应用资源,再 mount 挂载启动应用。



这样结合「app 是否应该激活」X「app 当前状态」,可以得到「应该对 app 做什么操作」。



  1. 「激活」X「not loaded」:应该去加载微应用资源

  2. 「激活」X「not mounted」:应该去挂载启动微应用

  3. 「激活」X「mounted」:什么都不用动

  4. 「不激活」X「not loaded」:什么都不用动

  5. 「不激活」X「not mounted」:应该去卸掉微应用资源

  6. 「不激活」X「mounted」:应该卸载微应用


这里只有1、2、5、6需要操作,也对应了上图中的四个箭头。于是 app 被进一步分拣为四个组:



代码如下:


switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}

拿到四个组后,需要转为具体操作,于是 reroute 中有这种 map:const unloadPromises = appsToUnload.map(toUnloadPromise);,把 app 转换为操作的 Promise。


需要注意的是,load 后app处于中间状态,并未完成激活,还差一步,反之亦然。所以只到中间态的两个组 appsToUnmount、appsToLoad,还需要继续往前走一步。


const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));

至于这些Promise是干嘛的也很容易猜到,无非是执行资源暴露的生命周期回调 + 修改应用状态。toXXXPromise 方法都定义在 src/lifecycles 下,可以找到对应生命周期。


至此 reroute 从分拣到执行生命周期的过程完成,完整图如下:



2.6 小结



  • single-spa 主要实现了微前端微应用调度部分,包含一个 app 池及路由变化时刷新回调 app 生命周期函数的机制

  • app 池维护了 app 的信息、资源和状态,暴露添加方法给 registerApplication api

  • 刷新的过程:确定app是否active —> 结合状态判断要做的操作 —> 调用生命周期回调,改状态


3 qiankun


在 single-app 微应用调度的基础上,qiankun 要带来的是「更简单、无痛的」生产应用。这些特性包括:



  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

  • 🛡 样式隔离,确保微应用之间样式互相不干扰。

  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。


我们看它的实现思路。


3.1 qiankun 加载应用的过程


qiankun 的特性和它的应用加载方式密不可分,我们先从一个叫 loadApp 的方法入手,看看子应用加载的全过程。


1、入口解析


qiankun 从入参中拿到子应用的 name 和 entry,过一个import-html-entry库,这个库也是 qiankun 自己的,有俩主要用途:从 html 解析静态资源(HTML Entry 的基础),并使其在特定上下文下运行(JS 隔离的基础)。


解析后可以得到子应用对应的可执行 JS(execScripts 方法)、静态资源(assetPublicPath)、html 模版(template)。


2、创建应用容器


随后 qiankun 需要构造一个给子应用的容器(createElement),这个容器是一个子应用独有的 div,标记了从子应用信息挖出来的 id、name、version、config 等信息。容器的形态取决于几个因素:



  • 子应用是否有 html 模版,有的话需要装进去才能让子应用找到渲染 DOM

  • 子应用是否需要样式隔离,有的话可能要加一层 shadow DOM


然后要确保在子应用挂载前,这个容器被渲染并挂到页面上。


3、沙箱构造


接着 qiankun 会构造一个沙箱(createSandboxContainer),然后依赖 execScripts 方法把 window 代理到沙箱上,并在恰当的时候开关拦截。


4、构造传给下游 single-app 的生命周期


这里先从子应用脚本中解析出生命周期(bootstrap, mount, unmount, update),然后补充一些逻辑:



  • mount 时,补充容器获取和绑定、容器挂载、沙箱开启

  • unmount 时,补充沙箱关闭、容器卸载


3.2 HTML Entry 接入


html 解析能力来自import-html-entry库。


它加载完 html 资源,就按 string 继续解析(processTpl),主要方法是通过正则匹配出里面的字符串,比如异步 script:


// 异步 script
if (matchedScriptSrc) {
var asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
scripts.push(asyncScript ? {
async: true,
src: matchedScriptSrc
} : matchedScriptSrc);
return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
}

然后把这些资源打包返回。


3.3 样式隔离


样式隔离是避免子应用之间、子应用-父应用之间出现 class 名的相互污染。


处理样式隔离一般只有两个方法:一是为所有 class name 增加唯一的 scope 标记;二是利用 shadow dom 的天然隔离。


自己加 scope


参考 qiankun 文档:常见问题 - qiankun,可以通过干预编译、利用 antd 等框架的能力来做。


scope 的qiankun实现


如果懒得自己 scope,可以通过 qiankun 配置直接生成 scope:


sandbox: { experimentalStyleIsolation: true }

这个参数会给所有的 class name 外层增加一个子应用独有的标识:


div[data-qiankun-react16] .app-main {
font-size: 14px;
}

通过遍历所有 style 节点,增加前缀:


const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
// css.process
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);

shadow dom 的乾坤实现


qiankun 通过配置也可以实现 shadow dom 隔离:


sandbox: { strictStyleIsolation: true }

其实是在容器和内容间增加了一层 shadow:


// createElement
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;

当然后面获取容器的时候也会兼容这点:


// getAppWrapperGetter
if (strictStyleIsolation && supportShadowDOM) {
return element!.shadowRoot!;
}
return element!;

3.4 JS 沙箱


子应用加载过程中,qiankun 构造 JS 沙箱:


// loadApp
if (sandbox) {
sandboxContainer = createSandboxContainer( appName, /* 其他参数 */ );
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}

沙箱创建后,会去包裹子应用脚本的执行上下文。沙箱实例被传到 import-html-entry包里,最终用在对 script 标签的包装执行上:


const code = `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
eval(code);

这样子应用的所有「模块和全局变量」声明,就都挂到了代理上。脚本导出的生命周期,也都执行在代理上。


JS 沙箱的目的是,任何一个微应用,在活跃期间能正常使用和修改 window,但卸载后能把「初始」window 还回去供其他微应用正常使用,且当微应用再次活跃时,能找回之前修改过的 window。这就必然需要一个和 app 一一对应的代理对象来管理和记录「app 对 window 的修改」,并提供重置和恢复 window 的能力。



qiankun 为我们准备了三套沙箱方案:



  • ProxySandbox:代理沙箱,在支持 Proxy 时使用

  • LegacySandbox:继承沙箱,在支持 Proxy 且用户 useLooseSandbox 时使用

  • SnapshotSandbox:快照沙箱,在不支持 Proxy 时使用


ProxySandbox


当我们有 Proxy 时,这件事很好办。我们可以让 window 处于「只读模式」,所有对 window 的修改,都将属性挂到代理对象上,使用时先找代理对象,再找真 window。



class ProxySandbox {
proxyWindow
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
const fakeWindow = Object.create(null)
this.proxyWindow = new Proxy(fakeWindow, {
set: (target, prop, value, receiver) => {
if(this.isRunning) target[prop] = value
},
get: (target, prop, receiver) => {
return prop in target ?target[prop]:window[prop];
}
})
}
}

ProxySandbox 的好处是实现简单,在设计上非常严谨,完全不会影响原生 window,所以卸载时也不需要做任何处理。


LegacySandbox


Proxy 的另一种用法是,放 app 去修改 window,只做被修改属性的「键-初始值」、「键-修改值」记录,在卸载后把初始值挨个重置,在再次挂载后把修改值挨个恢复。



LegacySandbox 保证了真实 window 的属性和当前 app 用到的 window 属性完全一致。如果你需要全局监控当前应用的真实环境,这点就很重要。


SnapshotSandbox


如果环境不支持 Proxy,就没法在「活跃时」做监听,只能尝试在挂载卸载的时候想办法。



  • 对 window 来说,我们只要在挂载时备份一份「快照」存起来,卸载时再把快照覆盖回去。

  • 反过来对 app 环境来说,我们需要在卸载时 diff 出一份「被修改过」的快照,挂载时把快照覆盖回去。



拦截其他副作用


三种沙箱都实现了对 window 属性增删改查的拦截和记录,但子应用还可能对 window 做其他有副作用的操作,比如:定时器、事件监听、DOM节点API操作。


这就是沙箱实例暴露 mount、unmount 方法的原因。当子应用 mount 时,实现对其他副作用的拦截和记录,unmount 时再清除掉。这些副作用包括:


patchInterval									劫持定时器
patchWindowListener 劫持window事件监听
patchHistoryListener 劫持history事件监听(umi专用)
patchDocumentCreateElement 劫持DOM节点创建
patchHTMLDynamicAppendPrototypeFunctions 劫持DOM节点添加方法

副作用的拦截方法都采用同样的接口实现:


function patchXXX(global) {
// 给 mount 调用,在 global 上拦截方法
return function free() {
// 给 unmount 调用,清除副作用
}
}

实现思路都是维护一个「池」,把 mount 后注册的定时器、事件、DOM等记录下来,在 unmount 时清除。比如定时器:


function patchInterval(global) {
// 给 mount 调用,在 global 上拦截方法
let intervals: number[] = [];
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number) => {
const intervalId = rawWindowInterval(handler, timeout);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
// 给 unmount 调用,清除副作用
intervals.forEach((id) => global.clearInterval(id));
}
}

3.4 预加载


qiankun 可以通过配置或手动调用发起 prefetch:


start({ prefetch: true });
// or
prefetchApps([...]);

发起预加载的时机无非两种:立即预加载(prefetchImmediately)、首个应用挂载后预加载其他应用(prefetchAfterFirstMounted)。但这只是时机差别,预加载的逻辑是一致的。


qiankun 说了,在浏览器空闲时预加载,那肯定要用 requestIdleCallback:


requestIdleCallback(async () => {
// 第一次空闲时解析入口资源
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 后面空闲时下载资源
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});

3.5 小结



  • 乾坤的特性离不开在子应用加载上下的功夫(loadApp),这个过程包含入口解析、容器创建、沙箱构造、生命周期补充,最后调用 single-app

  • HTML Entry 特性由 import-html-entry 库实现,通过对 html 字符串进行正则匹配,得到资源信息。

  • 样式隔离主要有 class name scope 和 shadow DOM 两种方式,qiankun 都做了支持。前者靠遍历 stylesheet 更改 class name,后者靠容器构建时增加 shadow DOM 层。

  • JS 沙箱是用一个代理对象拦截对 window 的操作。qiankun 提供了Snapshot、Proxy、Legacy三种沙箱,区别在于对属性增删改的拦截方式,效果是一样的。一些直接调用全局 api 的副作用(定时器、DOM操作、事件等)则需要额外拦截和恢复,通常靠维护一个「属于当前子应用的副作用池」。

  • 预加载用的是 requestIdleCallback。


4 业务系统


4.1 配置的数据模型


为了实现动态部署,业务平台要回答一个问题:每次启动时,这个微前端要注册哪些微应用?也就是「平台 - 系统 - 子应用 - 资源」之间关系的维护和下发。


好在这个关系并不复杂:



这是一套最简单的微前端管理模型,在此之上,可根据自己需求选择性加上用户、角色、权限、模版、菜单、导航等。


4.2 用户请求流程


当用户来访问业务平台上的系统时,基本会经过以下流程:




  1. 用户通过域名,经DNS解析,访问到平台的前端服务器。平台作为基建,会承载多个业务系统,每个业务系统又有各自的域名,这里会要求每个接入的业务域名都在DNS配置解析到平台统一的前端服务器IP。

  2. 匹配接入配置,锁定一个系统。一方面系统配置会作为“准入”的nginx配置挂在前端服务器上。另一方面,根据请求携带的域名等信息,可以匹配到具体请求来自哪个系统。

  3. 根据系统获取系统配置。这些配置包含整个系统ID关联的子应用、资源、权限、导航等等配置,通过接口可以一次性返回给客户端,也可以先返回系统ID,客户端再按需请求。客户端的微前端框架现在知道要注册哪些应用了。

  4. 客户端加载静态资源。在配置中会关联应用框架和子应用用到的所有静态资源的CDN地址,按微前端的逻辑,这些资源会在微应用 load 的时候异步加载。


Z 总结



  • 微前端将单体应用拆分为若干个微应用,它们独立开发、独立部署、独立运行。其理论基础是不同框架下相同的底层API。目前主流是在 single-spa、qiankun 的技术方案基础上,做业务系统的封装。

  • single-spa 根据路由变化调度微应用的资源加载和运行,并定义了一套微应用生命周期。实现上依赖内部的一套“应用池”+“刷新器”+路由监听。

  • qiankun 在 single-spa 基础上增加了 js 和 css 隔离、html entry、预加载等开箱即用的工程友好特性。其基础是自己实现了import-html-entry库来控制资源加载和运行时,实现一层容器以隔离样式,并借助 Proxy 等api 实现沙箱来劫持 window 操作。

  • 在业务系统实际应用中,还要在 qiankun 基础上构建数据模型和服务,实现「平台-系统-微应用-资源」各级配置的下发来启动和动态注册微
    作者:几木_Henry
    来源:juejin.cn/post/7262158134322806844
    前端。

收起阅读 »

一文揭秘饿了么跨端技术的演进、实践与落地

web
导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决...
继续阅读 »

导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决的问题和取得的一些成果,希望能给大家带来一些跨端方面的新思路。



跨端技术背景与演进历程


跨端,究竟跨的是哪些端?


自 90 年的万维网出现,而后的三十多年,我们依次经历了 PC 时代、移动时代,以及现在的万物互联(的 IoT )时代,繁荣的背后,是越来越多的设备、越来越多的系统以及各种各样的解决方案。


总的来说,按照跨端的场景来划分,主要包含以下 4 类:




  • 跨设备平台,如 PC(电脑)/ Mobile(手机)/ OTT(机顶盒)/ IoT(物联网设备)。不同的设备平台往往意味着不同的硬件能力、传感器、屏幕尺寸与交互方式

  • 跨操作系统,如 Android/iOS/HarmonyOS。不同的操作系统为应用开发通常提供了不同的编程语言、应用框架和 API

  • 跨移动应用,如 微信/支付宝/手淘/抖音/快手等。由于移动平台 CS 架构 及 App 间天然的壁垒,不同 App 间相互隔离,并各自在其封闭体系内遵循一套自有标准进行各类资源的索引、定位及渲染。而同一业务投放至不同 App 端时,就需要分别适配这些不同的规则。

  • 跨渲染容器,如 Webview/React Native/Flutter。前面三类场景催生了针对不同设备平台、不同操作系统、不同 App 间解决方案,因而移动领域的各种 Native 化渲染、自绘渲染与魔改 Webview 的方案也由此被设计出来,在尝试解决跨端问题的同时,也一定程度上提高了跨端的迁移门槛和方案选择难度。


而在当下,移动领域依然是绝对的主角,我们来看一下移动端的跨端技术都经历了哪些阶段。


移动跨端技术演进


随着移动互联网的蓬勃发展,端形态变的多样,除了传统的 Native、H5 之外,以动态化与小程序为代表的新兴模式百花齐放大行其道,世面上的框架/容器/工具也层出不穷,整个业态朝着碎片化方向发展。


对开发者来说,碎片化的直接影响,是带来了包括但不限于,刚才提到的设备平台、操作系统、渲染容器、语法标准等方面的各种不确定性,增加了大量的学习、开发与维护成本。


于是,应运而生的各类跨端技术,核心在于从不确定性中找寻确定性,以保障研发体验与产物一致性为前提,为各端适配到最优解,用最少成本达到最好效果,真正做到 "一次编写,到处运行"。


移动跨端大致经历了如下几个阶段:





  • H5 Wap 阶段:Web 天然跨平台,响应式布局是当时的一个主要手段,但由于早期网络环境原因,页面加载速度无法满足业务预期,加之设备传感器标准缺失、内存占用大、GPU 利用率低等问题,在移动设备量爆发伊始,难堪大任的论调一下子被推上风口浪尖,并在 12 年达到顶峰。




  • Hybrid 阶段:典型代表是 Cordova/ionic。功能上看,Hybrid 解决了历史两大痛点:



    • 1)性能,依靠容器能力,各类离线化、预装包、Prefetch 方案大幅减少加载耗时,配合编码优化在 3/4G 时代使 H5 的体验上了一个台阶;

    • 2)功能,通过 JSBridge 方式规避了与 Native 原生割裂带来的底层能力缺失。




  • 框架+原生阶段:典型代表是 ReactNative/Weex。基于 JSC 或类似的引擎,在语法层与 React/Vue 结合,渲染层使用原生组件绘制,尝试在研发效率与性能体验间寻找更佳的平衡点,各类领域解决方案(受限 DSL + 魔改 web 标准 + Native 渲染能力)开始涌现,拉开了大前端融合渲染方案的序幕。




  • 自绘渲染阶段:典型代表是 Flutter/Qt。这里的 “自绘” 更强调不使用系统原生控件或 Webview 的渲染管线,而是依赖 Skia、Cairo 等跨平台图形库,自底向上自建渲染引擎、研发框架及基础配套的方式,其跨 Android/iOS 的特性迅速点燃了客户端研发领域。




  • 小程序阶段:典型代表是 微信/支付宝小程序。小程序是被创造出来的,其本质是各 APP 厂商出于商业考量构造了相对封闭的生态,在标准与能力上无论与 Web 还是厂商之间均存在差异,能力上是自定义 DSL & API + Hybrid + 同层渲染 + 商业管控的综合体。市面跨端方案策略均是锚定一种研发规约进行各形态编译时与运行时的差异抹平与适配。




回顾了以上跨端技术背景与演进历程,在这股浪潮里面,饿了么的跨端投放情况是什么样的?投了那些端?遇到了哪些问题?又是如何解决的?


饿了么跨端投放诉求、现状与策略



众所周知,饿了么是围绕 O2O 为用户提供线上到线下服务的公司,通过对时、空、人、货 的有机结合,来链接商家与消费者,相比于传统电商,时空人货本身具有区域属性,这意味着我们做的不是一个大卖场生意,更多的是需要围绕区域特性提供精细化的服务,这里面有一系列时空、体验、规模、成本的约束需要考虑与应对


而在这一系列约束背后,其实有一个各方共通的经营诉求:



  • 对于商家来说:为了有更好的经营需要有更多曝光,与客户有更多的触达,以便带来成交

  • 对于平台来说:为了能够让更多消费者享受我们的服务,除了深耕自己的超级APP(饿了么APP)外,还需要在人流量大的地方加大曝光、声量与服务能力来扩大我们的规模


这都导向一个目的:哪里流量多,我们就需要在哪里提供与消费者的连接能力


那么问题来了,流量在哪里?现在的互联网,更多都是在做用户的时间与精力生意,背后拆解下来,其实有几个关键因素可以衡量:用户密度、用户活跃度、市场占有率与用户时间分配,细化来看,其中任意几个条件满足,都可以作为我们流量阵地的候选集。


饿了么经过多年耕耘,对外部关键渠道做了大量布局,业务阵地众多,从效果上看,渠道业务无论是用户流量规模还是订单规模均对大盘贡献度较高,且随着业务的持续精进与外部合作环境的持续改善,增量渠道也在不断的涌现中。



在这么多的业务阵地中,投放在各个端的应用的形态基于:



  • 渠道的运行环境

  • 渠道的流量特性

  • 渠道的业务定位

  • 渠道的管控规则


等的差异和限制,目前形成了 以小程序为主,H5为辅 的承接方式,而这些差异带来了大量的不确定性,主要体现在:



  • 渠道环境的高度不确定性:对接了这么多渠道,每个端的运行环境存在巨大差异,拿小程序能力举例,即使是个别 APP 的小程序方案借鉴了微信的思路,由于其内部商业能力、产品设计思路、能力成熟度与完整度、研发配套(语法、框架、工具等)的不一致也会使研发体感有明显的不同,这对技术同学来说,带来的是渠道环境的高度不确定性;

  • 业务诉求的高度不确定性:同时,我们所投放的 APP 都可划分到某些细分领域,用户特性与用户在该平台上的诉求不一,渠道定位也不一致,随着每个业务域的功能演进越来越多,多个渠道功能是否对齐、要不要对齐、有没有对齐、什么时候对齐成了一个非常现实和麻烦的事情,同时业务域之间可能还存在功能上的关联,这进一步提高了其复杂度,在没有一个好的机制与能力保障下,业务、产品、研发对每个渠道的同步策略、能力范围的感知会有较大偏差,甚至于一个需求的迭代,每个端什么时候能同步都变成了一个无法预期的事情,这对于业、产、研来说,带来的是业务诉求上的高度不确定性。


而我们要做的,就是在这两种不确定性中,找到技术能带来的确定性的事情。如何系统性的解决这些问题,则成为我们在保障渠道业务灵活性的基础上持续提升研发效率和体验的关键。


在差异应对上,业务研发最理想的方式是对底层的变化与不一致无感,安心应对业务诉求,基于这个点出发,我们的主要策略是:围绕 “研发体验一致性提升与复杂应用协作机制改进”来保障业务高效迭代。这需要一套强有力的、贴合业务特性的基础设施来支撑。首先想到的便是如何通过“推动框架统一”和“实现一码多端”,来为业务研发降本增效,然而理想很丰满,现实很骨感:



框架的升级通常情况下,大概率会带来业务重构,综合评估之后,作为外部渠道流量大头的小程序业务,则成为了我们优先要保障的业务,也基于此,为了尽可能降低对业务的影响和接入成本,我们明确了以小程序为第一视角来实现多端。


基于小程序跨端的行业现状和思考


在明确了方向之后,那么问题来了:业界有没有适合我们的开源的框架或解决方案呢?


业界有哪些面向于小程序的研发框架?



市面上,从小程序视角出发,具备类似能力的优秀多端框架有很多,有代表性的如 Taro、uni-app、Rax 等,大多以 React 或者 Vue 作为 DSL


那么这些框架能否解决我们所面临的问题?答案是:并不能。


为什么饿了么选择以小程序 DSL 为基础实现跨端?



综合 饿了么 的渠道业务背景需要考虑以下几点:



  • 改造成本:以支付宝、微信、淘宝为代表的饿了么小程序运营多年,大部分存量业务是以支付宝或微信小程序 DSL 来编写,需关注已有业务逻辑(或组件库)的改造成本,而采纳业界框架基本上会直接引发业务的大量重构,这个改造成本是难以接受的。

  • 性能体验:渠道业务是饿了么非常重要的流量阵地,重视程度与APP无差,在体验和性能上有极致的要求,所以我们期望在推动跨端的同时,尽可能减少运行时引入带来的性能损耗。

  • 业务协同:由于每个渠道都基本相当于一个小型的饿了么APP,复杂度高,涉及到多业务域的协同,包括主线步调一致性考量、多业务线/应用类型集成、全链路功能无缝衔接等,在此之外还需给各业务线最大限度的自控与闭环能力,背后需要的是大型小程序业务的一体化研发支撑。


在做了较多的横向对比与权衡之后,上面的这些框架对于我们而言采纳成本过高,所以我们选择了另外一条相对艰辛但更为契合饿了么多端演进方向的路:以小程序原生 DSL 为基础建设跨端解决方案,最大限度保障各端产物代码贴合小程序原生语法,以此尽可能降低因同构带来的体验损耗和业务多端接入成本。


基于小程序 DSL 的跨端解决方案


确定以小程序 DSL 作为方向建设跨端解决方案之后,首先要解决的就是如果将已有的小程序快速适配到多端。这就需要对各个端的差异做细致的分析并给出解决方案。



如何解决小程序多端编译?


为了能够兼顾性能和研发体验,我们选择了 编译时(重)+运行时(轻) 的解决方案。


静态编译解决了那些问题?



静态编译转换主要用于处理 JSWXS/SJSWXML/AXMLWXSS/ACSSJSON 等源码中约束强且不能动态修改的部分,如:



  • 模块引用:JS/WXS/SJS/WXML/AXML/WXSS/ACSS/JSON 等源码中的模块引用替换和后缀名修改;

  • 模版属性映射或语法兼容: AXML/WXML 中如 a:if → wx:if、 onTap → bind:tap{{`${name}Props`}} →  {{name + 'Props'}} 等;

  • 配置映射:如页面 { "titleBarColor": "#000000" } → { "navigationBarBackgroundColor: "#000000", "navigationBarTextStyle": "white" }


等,原理是通过将源码文件转换为 AST(抽象语法树),并通过操作 AST 的方式来实现将源码转换为目标平台的代码。


但静态编译只能解决一部分的差异,还有一些差异需要通过运行时来抹平。


运行时补偿解决了那些问题?



运行时补偿主要用于处理静态编译无法处理或者处理成本较高的一些运行时动态内容,如:



  • JSAPI:实际业务使用上,不管是 JSAPI 的名字还是 JSAPI 的入参都会存在动态赋值的情况,导致了在 JSAPI 的真实调用上,很难通过 AST 去解析出实际传参;

  • 自定义组件 - Props 属性:比如,支付宝属性使用 props 声明,而微信属性使用 properties 声明,配置方式不同且使用时分别使用 this.props.x 及 this.properties.x 的方式获取,同时可能存在动态取值的情况;

  • 自定义组件 - 生命周期:支付宝小程序中的 didUpdate 生命周期,在触发了 propsdata 更新后都会进入 didUpdate 这个生命周期,且能够在 didUpdate 中访问到prevProps / prevData,而在微信小程序中静态转义出这个生命周期就意味着你需要去动态分析出didUpdate里面要用到的所有属性,然后去动态生成出这些属性的监听函数。这显然可靠程度是极其低的;


等等,类似的场景有很多,这里不再一一列举。


通过静态编译 + 运行时补偿的方式,我们便可以让现有的微信或支付宝小程序快速的迁移到其他小程序平台。


如何解决小程序转 Web?


伴随外卖小程序上线多年之后,各个大的渠道(支付宝、手淘、微信等)已切流为小程序承载,但是还有很多细分渠道或非小程序环境渠道,比如:各个银行金融渠道,客户端的极小包等,还需要依赖 H5 的形态快速投放,但当前饿了么的业务越来越复杂,相关渠道的投入资源有限,历史包袱重、迭代成本大等原因,产品功能和服务能力远远落后于小程序和饿了么App。而业务急需一个可以将小程序的功能快速复制到 h5 端的技术方案,以较低的研发和维护成本,满足业务多渠道能力对齐上线的诉求。


基于这个背景,我们自然而然的可以想到,即然小程序可以转其他小程序,那么是否也可以直接将小程序直接转换为 Web,从而最大程度上提升人效和功能对齐效率。


具体是怎么实现的?主要手段还是通过编译时 + 运行时的有机结合:


Web 转端编译原理



编译部分和小程序转小程序的主要区别和难点是:需要将 JSWXS/SJSWXML/AXML 等文件统一转换并合并为 JS 文件并将 WXML/AXML 文件转换为 JSX 语法,将样式文件统一转换为 CSS 文件,并将小程序的页面和组件都转换为 React 组件。


运行时原理



转 Web 的运行时相较于转换为其他小程序会重很多,为了兼顾性能和体验,运行时的核心在于提供与小程序对等的高效运行环境,这里面包含四个主要模块:



  • 框架:提供了小程序在 Web 中的基础运行时功能,比如:Page 、Component 、App 等全局函数,并且提供完整的生命周期实现,事件的注册、分发等

  • 组件:提供小程序公共组件的支持,比如 viewbuttonscroll-view 等小程序原生提供的组件

  • API:提供了类似小程序中 wx 或者 my 的 一系列 api 的实现

  • 路由:提供了页面路由支持和 getCurrentPages 等方法支持


基于这四个模块,配合编译时的自动注入和代码转换,以及路由映射等,我们就可以把一个小程序转换为一个 Web 的 SPA(单页) 或者 MPA(多页) 应用,也成功的解决了业务的研发效率问题,目前 饿了么的新 M 站就是基于这套方案在运行。


如何解决多端多形态问题?



解决了各端的编译转换问题,是不是就万事大吉,业务接下来只要按部就班的基于这套能力实现一码多端就可以了?


然而并不是,随着饿了么的业务场景和范围快速拓展,诞生了一些新的诉求,比如:



  • 支付宝的独立小程序作为分包接入微信小程序

  • 淘宝 / 支付宝的小程序插件作为分包接入某个现有的微信小程序

  • 支付宝的独立小程序作为插件接入淘宝小程序插件

  • 支付宝小程序插件作为分包接入微信或抖音小程序


等等,大家如果仔细观察这些诉求,就会发现一个共同的点:就是小程序的形态不一样。


虽然我们已经具备了多端的能力,但是形态的差异没有解决掉,而之前相关业务的做法是,尽可能将通用功能沉淀到组件库,并按照多端的方式分端输出产物,然而由于相同业务在不同小程序端形态差异性的问题,业务上难以自行规避,而重构的成本又比较高,所以有一部分业务选择了直接按照不同的端不同的形态(如微信、支付宝、淘宝、抖音)各自维护一套代码,但这样做不仅功能同步迭代周期被拉长,而且 BUG 较多,维护困难,研发过程也是异常痛苦。


小程序形态差异有哪些?


形态差异是指 小程序、小程序分包、小程序插件 三种不同形态的运行方式差异以及转换为其他形态之后产生的差异,具体如下:




  • getApp 差异



    • 小程序: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法

    • 小程序插件: 无法调用 getApp()

    • 小程序分包: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法;但当通过小程序转换为分包后,分包自身原本调用的 getApp 将失效,并被替换为宿主小程序的 getApp




  • App 应用生命周期 差异



    • 小程序: 应用会执行 onLaunch、onShow、onHide 等生命周期

    • 小程序插件: 无应用生命周期

    • 小程序分包: 无应用生命周期




  • 全局样式(如:app.wxss 或 app.acss)差异



    • 小程序: 可通过全局样式来声明全局样式

    • 小程序插件: 无全局样式

    • 小程序分包: 无全局样式




  • NPM 使用限制



    • 小程序: 各个小程序平台支持和限制情况不一

    • 小程序插件: 各个小程序平台支持和限制情况不一

    • 小程序分包: 各个小程序平台支持和限制情况不一




  • 接口调用限制





  • 路由差异



    • 小程序: 转换到其他形态后自身路由会发生变化

    • 小程序插件: 转换到其他形态后自身路由会发生变化,跳转插件页面需要包含 plugin:// 或 dynamic-plugin:// 等前缀,小程序或分包则不需要

    • 小程序分包: 转换到其他形态后自身路由会发生变化




  • getCurrentPages 差异



    • 小程序: 无限制

    • 小程序插件: 无法通过 getCurrentPages 获取到小程序的页面堆栈

    • 小程序分包: 无限制




  • 页面或组件样式差异



    • 小程序: 无限制

    • 小程序插件: 基本选择器只支持 ID 与 class 选择器,不支持标签、属性、通配符选择器

    • 小程序分包: 无限制




等等,相关形态差异可结合各个小程序平台查看,这里仅罗列常见的部分。


如何解决这些差异?


这里举几个例子:



通过在编译过程中,自动向产物中注入对 App 和 getApp 的运行时模拟实现,这样就可以解决分包和插件下方法缺失或者是冲突引起的报错问题。



方法也是类似,可以在编译的过程中检测全局样式是否存在,如果存在,则将对应的全局样式引用自动注入到每一个页面和组件中来解决全局样式失效的问题。



而针对各个小程序平台的 NPM 使用规则不同的问题,可以通过依赖解析、动态分组、组件提取打包、引用替换等方式,将 NPM 抽取到特定的地方,并将对应的组件和页面中的引用进行替换,来解决 NPM 的支持问题,这样业务就可以基本无脑使用各类 NPM 而不用关心平台差异。


以此类推,将业务难以自行适配的差异,逐一解决之后,剩余的一些功能差异,则由业务基于条件编译的方式来自行适配,这样便可以大大的降低业务形态转换成本,同时也形成了我们面向多端场景下的形态转换方案。


那么到这里,多端转换的问题才算是基本解决了。


如何治理 “复杂小程序”?


如果说上面讲的内容都是聚焦在如何通过编译的方式来解决多端同构以及形态问题的话,那么接下来要解决的就是针对“复杂小程序”的应用架构与研发协作的问题了。



首先介绍下我们所定义的 “复杂小程序”,即具备跨业务领域的、长周期的、多团队协同的、呈现主链路+多分支业务模式的应用,其之所以“复杂”,主要体现在应用形态多样、诉求多样、关联业务面广等特性上


对于饿了么来说,每个渠道阵地均相当于一个小型饿了么APP,除了在研发上提供便利外,还需一套可靠的应用架构来保证其有序演进。


同时,由于渠道之间定位不同,各域的业务、产品及研发对各渠道重视程度与投入比重均有差异,间接导致渠道间相同业务能力的参差不齐,且不同渠道功能缺失的情况持续出现。


我们以饿了么微信小程序为例:



面临的问题有哪些?



  • 工程复杂导致研发效率低:大量的团队在一个单体小程序应用上研发,带来的直接问题就是小程序巨大化带来的研发体验差和编译效率低,且业务相互依赖,单一模块构建失败会引发整个项目的失败,比如饿了么微信小程序单次编译的时间超过了半个小时,且体积逼近 20m 上限

  • 研发流程不规范导致稳定性差:同时由于不同的业务团队迭代周期不一致,而每次发版都需要所有业务的代码一起发,哪怕是某个业务分包或者插件没有更新,但是对应的底层依赖库发生了变更,也极有可能引入线上 BUG,导致测试回归的成本居高不下,发版质量难以保障


解决方案:线下线上结合的集成研发模式


针对上面两个“复杂小程序”所面临的核心问题,我们针对性的通过 「线下集成研发」和「线上研发协作」来解决。


线下集成研发


重点考虑的是提供什么样的集成研发能力,允许以业务单元维度将多个独立的构建(宿主、小程序、插件、分包等)组成一个可用的小程序,消除业务之间强依赖关系,从而达成业务可独立开发、调试和部署的目的,方面统一业务协作流程、降低多端同构成本,关键策略:



  • 提供统一的集成研发方式和流程

  • 提供标准、可复用的集成产物规范

  • 为复杂小程序提供解耦工具和集成方法

  • 标准化小程序宿主、小程序插件、小程序分包、小程序模块之间的通信及能力注入方式



将小程序宿主和各个业务模块(分包、小程序、插件)通过形态转换、拉包、编译、构建、合并等一系列处理后,合并为一个完整小程序,且根据不同的场景可以支持:



  • 主子分包研发模式:基于不同业务对小程序中的分包进行拆分,以达到各个业务相互解耦,独立迭代的目的;

  • SDK 研发模式:将通用的页面或组件封装置某个 NPM 包中作为提供特定功能的 SDK 交由业务使用;

  • 小程序插件研发模式:集成研发也可以用支持标准的小程序插件研发。


这样我们就可以解决线下研发的问题。


线上研发协作


前面介绍的“线下集成研发”为业务单元提供了无阻塞的开发与调试能力,但对于饿了么业务整体演进来说,重视的是每个版本功能的可用与可控,这里面除了将集成的范围扩展到所有业务域的之外,还需要标准化的流程约束:



具体方式上,在机制层面提供了业务类型定义的能力,开发者可将工程做对应标记(主包、分包、插件、独立小程序),在流程层面定义了开发、集成与发布三个阶段,这和 APP 的研发流程有些类似:



  • 开发:各业务应用自行研发并结合平台部署测试,开发测试通过,等待窗口期开启进入集成测试;

  • 集成:管理员设置集成窗口期,在窗口期,允许业务多次集成研发,确认最终要进集成的稳定版本,期间主包管理员可多次部署体验版用于集成测试。窗口期结束后,不允许随意变更;

  • 发布:集成测试通过,各业务进行代码 CR 并进入发布阶段,等候主包提审通过发布上线,最终由管理员完成本次迭代发布,发布完成后,符合标准的主分包产物会被保存下来,后续的迭代中,如果某个分包未发生变更,则会直接复用产物,极大的降低了业务的发布风险,并提升了整体的构建效率。


再进一步,多端业务的最佳实践


通过线下集成+线上协作的双重能力加持,结合已有的多端编译能力,在成功的支撑了饿了么多端渠道业务的稳定高效研发的同时,我们也在思考,面向于未来的多端研发模式应该是个什么样子?


下图是我们期望同时也是饿了么目前多端应用架构正在演进中的样子:



从图上可以看出,我们将应用架构划分为三层(从下往上看):




  • 基础服务与研发规范:最底部的是基础服务与研发规范,由 多端研发框架、多端研发平台和多端研发规范,来提供统一的研发支撑,保障业务研发的基础能力、体验和效率,并负责将相关的业务统一打包、封装、集成,并部署和投放到不同的渠道;




  • 宿主应用框架:第二层是宿主应用框架(Framework),也可以认为是多端统一解决方案,承接了面向于业务研发并适配了多端差异的基础 API(如 登录、定位、请求、路由、实验、风控、埋点、容器等)、基础组件和最佳实践,通过分渠道的配置化运行、标准化的接入手段和中心化的能力管理,来保障整体框架的轻量化、标准化与持续迭代和升级;




  • 渠道应用主体:最上层是各个业务的应用实体,有一个壳工程 + N个业务工程组成,壳工程承接各个渠道定制化的一些能力,而并将下层应用框架的能力暴露给上层的各个业务,各个业务只需要关心两件事即可:



    • 多端形态:以什么样的形态接入到对应的渠道(即壳工程中)?

    • 业务功能:不同的渠道需要展示那些功能?




基于这种分层协作模式,可以最大程度上消除业务对多端差异的感知,可以将重心放在如何更好的为用户提供服务上。


以上内容为饿了么基于小程序 DSL 的跨端实践和解决方案,下面我们来看一下具体取得的成果。


跨端成果


饿了么各渠道业务效果展示



业务一码多端研发提效数据



  • 研发提效:采用一码多端和集成研发模式的业务平均提效 70%,同构的端越多提效越多

  • 多端占比:饿了么内部 85%+ 的多端业务在基于这套方案实现多渠道业务研发和投放

  • 业务覆盖:涵盖了饿了么全域的各个业务板块


能力沉淀 — 饿了么自研 MorJS 多端研发框架


MorJS 开源



我们将饿了么在跨端多渠道上的多年沉淀和解决方案,融合为 MorJS 多端研发框架,并通过 Github 开源的方式向社区开放。


GitHub 仓库地址:github.com/eleme/morjs


下图为 MorJS 的完整架构图:



MorJS 框架目前支持 :



  • 2 种 DSL:微信小程序 DSL 或 支付宝小程序 DSL

  • 4 种编译形态:小程序、小程序插件、小程序分包、小程序多端组件

  • 9 个目标平台:微信、支付宝、百度、字节、快手、钉钉、手淘、QQ、Web


并支撑了饿了么 C 端大多数业务在各个渠道上的研发和投放。


MorJS 为饿了么解决了大量业务在多端研发上的差异问题,让小程序开发的重心回到产品业务本身,减少使用者对多端差异兼容的投入。通过 MorJS 的开源,我们期望能把其中的实现细节、架构设计和技术思考呈现给大家,为更多有类似多端同构需求的企业和开发者服务。同时,我们也希望能够借此吸引到更多志趣相投的小伙伴参与共建,一起加速小程序一码多端能力的发展。欢迎广大小程序开发者们与我们交流。


MorJS 特性介绍



为了能够帮助社区的用户可以快速上手,我们在易用性、标准化和灵活性方面做了大量的准备:



  • ⭐️ 易用性

    • 💎 DSL 支持:可使用微信小程序 DSL 或 支付宝小程序 DSL 编写小程序,无额外使用成本;

    • 🌴 多端支持:支持将一套小程序转换为各类小程序平台及 Web 应用, 节省双倍人力;

    • 🚀 快速接入:仅需引入两个包,增加一个配置文件,即可简单快速接入到现有小程序项目;



  • 🌟 标准化

    • 📦 开箱即用:内置了脚手架、构建、分析、多端编译等完整研发能力,仅需一个依赖即可上手开发;

    • 🌈 表现一致:通过编译时+运行时抹平多端差异性,让不同平台的小程序获得一致的用户体验;

    • 🖇 形态转换:支持同一个项目的不同的形态,允许小程序、分包、插件不同形态之间的相互转换;



  • ✨ 灵活性

    • 🎉 方便扩展:MorJS 将完备的生命周期和内部功能插件化,使用插件(集)以满足功能和垂直域的分层需求;

    • 📚 类型支持:除小程序标准文件类型外,还支持 ts、less/scss、jsonc/json5 等多种文件类型;

    • 🧰 按需适配:可根据需求选择性接入适配能力,小项目仅需编译功能,中等项目可结合编译和页面注入能力,大型项目推荐使用复杂小程序集成能力;




同时也提供了丰富的文档:mor.eleme.io/ 共大家查阅。


部分使用案例及社区服务


以下为部分基于 MorJS 的案例:



用户原声



MorJS 上线的这几个月里面,我们收到了一些社区用户的正向反馈,也收到了一些诉求和问题,其中用户最担心的问题是:MorJS 是不是 KPI 项目,是否会长期维护?


这里借用一下我在 Github 项目的讨论区(Discussions)的回复:



如果大家对 MorJS 感兴趣,期望有更多了解或者在使用 MorJS 中有遇到任何问题,欢迎加入 MorJS 社区服务钉钉群(群号:29445021084)反馈、交流和学习,也可以🔗 点击链接加入钉钉群


展望未来



未来,在现有的 MorJS 的能力基础上,我们会进一步完善已有的多端能力,提升多端转换可用度,完善对各类社区组件库的兼容,并持续扩展编译目标平台的支持(如 鸿蒙、快应用等),在持续为饿了么自身业务和社区用户提供高质量服务的同时,期望有朝一日 MorJS 可以成为业界小程序多端

作者:lyfeyaj
来源:juejin.cn/post/7262558218169319484
研发的基础设施之一。

收起阅读 »

三言两语说透koa的洋葱模型

web
Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。 洋葱圈模型设计思想 Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而...
继续阅读 »

Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。


洋葱圈模型设计思想


Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而来的。Compose函数可以将需要顺序执行的多个函数复合起来,后一个函数将前一个函数的执行结果作为参数。这种函数嵌套是一种函数式编程模式。


Koa借鉴了这个思想,其中的中间件(middleware)就相当于compose中的函数。请求到来时会经过一个中间件栈,每个中间件会顺序执行,并把执行结果传给下一个中间件。这就像洋葱一样,一层层剥开。


这样的洋葱圈模型设计有以下几点好处:



  • 更好地封装和复用代码逻辑,每个中间件只需要关注自己的功能;

  • 更清晰的程序逻辑,通过中间件的嵌套可以表明代码的执行顺序;

  • 更好的错误处理,每个中间件可以选择捕获错误或将错误传递给外层;

  • 更高的扩展性,可以很容易地在中间件栈中添加或删除中间件。


洋葱圈模型实现机制


Koa的洋葱圈模型主要是通过Generator函数和Koa Context对象来实现的。


Generator函数


Generator是ES6中新增的一种异步编程解决方案。简单来说,Generator函数可以像正常函数那样被调用,但其执行体可以暂停在某个位置,待到外部重新唤起它的时候再继续往后执行。这使其非常适合表示异步操作。


// koa中使用generator函数表示中间件执行链
function *logger(next){
  console.log('outer');
  yield next;
  console.log('inner');
}

function *main(){
  yield logger();
}

var gen = main();
gen.next(); // outer
gen.next(); // inner

Koa使用Generator函数来表示洋葱圈模型中的中间件执行链。外层不断调用next重新执行Generator函数体,Generator函数再按顺序yield内层中间件异步操作。这样就可以很优雅地表示中间件的异步串行执行过程。


Koa Context对象


Koa Context封装了请求上下文,作为所有中间件共享的对象,它保证了中间件之间可以通过Context对象传递信息。具体而言,Context对象在所有中间件间共享以下功能:



  • ctx.request:请求对象

  • ctx.response:响应对象

  • ctx.state:推荐的命名空间,用于中间件间共享数据

  • ctx.throw:手动触发错误

  • ctx.app:应用实例引用


// Context对象示例
ctx = {
  request: {...}, 
  response: {...},
  state: {},
  throwfunction(){...},
  app: {...}
}

// 中间件通过ctx对象传递信息
async function middleware1(ctx){
  ctx.response.body = 'hello';
}

async function middleware2(ctx){
  let body = ctx.response.body
  //...
}

每次请求上下文创建后,这个Context实例会在所有中间件间传递,中间件可以通过它写入响应,传递数据等。


中间件执行流程


当请求到达Koa应用时,会创建一个Context实例,然后按顺序执行中间件栈:



  1. 最内层中间件首先执行,可以操作Context进行一些初始化工作;

  2. 用yield将执行权转交给下一个中间件;

  3. 下一个中间件执行,并再次yield交还执行权;

  4. 当最后一个中间件执行完毕后,倒序执行中间件的剩余逻辑;

  5. 每个中间件都可以读取之前中间件写入Context的状态;

  6. 最外层获得Context并响应请求。


// 示意中间件执行流程
app.use(async function(ctx, next){
  // 最内层执行
  ctx.message = 'hello';

  await next();
  
  // 最内层剩余逻辑  
});

app.use(async function(ctx, next){
  // 第二层执行
  
  await next();

  // 第二层剩余逻辑
  console.log(ctx.message); 
});

// 最外层获得ctx并响应

这就是洋葱圈模型核心流程,通过Generator函数和Context对象实现了优雅的异步中间件机制。


完整解析


Koa中间件是一个Generator函数,可以通过yield关键字来调用下一个中间件。例如:


const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('中间件1开始');
  
  await next();
  
  console.log('中间件1结束');
});

app.use(async (ctx, next) => {
  console.log('中间件2');

  await next();

  console.log('中间件2结束');  
});

app.use(async ctx => {
  console.log('中间件3')
});

app.listen(3000);

在代码中,可以看到Koa注册中间件是通过app.use实现的。所有中间件的回调函数中,await next()前面的逻辑是按照中间件注册的顺序从上往下执行的,而await next()后面的逻辑是按照中间件注册的顺序从下往上执行的。


执行流程如下:



  1. 收到请求,进入第一个中间件

  2. 第一个中间件打印日志,调用next进入第二个中间件

  3. 第二个中间件打印日志,调用next进入第三个中间件

  4. 第三个中间件打印日志,并结束请求

  5. control返回第二个中间件,打印结束日志

  6. control返回第一个中间件,打印结束日志

  7. 请求结束


这样每个中间件都可以控制请求前和请求后,形成洋葱圈模型。


中间件的实现原理


Koa通过compose函数来组合中间件,实现洋葱圈模型。compose接收一个中间件数组作为参数,执行数组中的中间件,返回一个可以执行所有中间件的函数。


compose函数的实现源码如下:


function compose (middleware{

  return function (context, next{
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i{
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

这里利用了函数递归的机制。dispatch函数接收当前中间件的索引i,如果i大于中间件数组长度,则执行next函数。如果i小于中间件数组长度,则取出对应索引的中间件函数执行。


中间件的执行过程


中间件的执行过程


执行中间件函数的时候,递归调用dispatch,同时将索引+1,表示执行下一个中间件。


这样通过递归不断调用dispatch函数,就可以依次执行每个中间件,实现洋葱圈模型。


所以Koa的洋葱圈模型实现得非常简洁优雅,这也是Koa作为新一代Node框架,相比Express更优秀的设计。


洋葱圈模型的优势


提高中间件的复用性


洋葱模型让每个中间件都可以控制请求前和请求后,这样中间件可以根据需要完成各种额外的功能,不会相互干扰,提高了中间件的复用性。


使代码结构更清晰


洋葱模型层层嵌套,执行流程一目了然,代码阅读性好,结构清晰。不会像其他模型那样回调多层嵌套,代码难以维护。


异步编程更简单


洋葱模型通过async/await,使异步代码可以以同步的方式编写,没有回调函数,代码逻辑更清晰。


错误处理更友好


每个中间件都可以捕获自己的错误,并且不会影响其他中间件的执行,这样对错误处理更加友好。


方便Debug


通过洋葱模型可以清楚看到每个中间件的进入和离开,方便Debug。


便于扩展


可以随意在洋葱圈的任意层增加或删除中间件,结构灵活,便于扩展。


总结


总体来说,洋葱模型使中间件更容易编写、维护和扩展,这也是Koa等新框架选择它的主要原因。它的嵌套结构和异步编程支持,使Koa的中间件机制更优雅和高效。


作者:一码平川哟
来源:juejin.cn/post/7262158134323560508
收起阅读 »

2023.28 forEach 、for ... in 、for ... of有什么区别?

web
大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。 forEach 、for ... in 、for ... of有什么区别 forEach 数组提供的方法,只能遍历数组 遍历数组:for...in key返...
继续阅读 »

大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。


forEach 、for ... in 、for ... of有什么区别


forEach 数组提供的方法,只能遍历数组


遍历数组:for...in key返回数组下标;for...of key返回值;


1690806838416.png
遍历对象:for...in key返回对象的键;for...of 遍历对象报错,提示没有实现person对象不可迭代;


1690806968808.png


iterable什么是可迭代对象?


简单来说就是可以使用for...of遍历的对象,也就是实现了[Symbol.iterator]


迭代和循环有什么区别?


遍历强调把整个数据依次全部取出来,是访问数据结构的所有元素;


迭代虽然也是一次取出数据,但是并不保证取多少,需要调用next方法才能获取数据,不保证把所有的数据取完,是遍历的一种形式。


有哪些对象是可迭代对象呢?


原生的可迭代对象 set map nodelist arguments 数组 string


迭代器是针对某个对象的,有些对象是自己继承了Symbol.Iterator,也可以实现自己的迭代器,必须要实现一个next方法,返回内容



{value:any,done:boolean}

实现对象的迭代器


如果要实现迭代器,需要实现[Symbol.Iterator]是一个函数,这个函数返回一个迭代器


// let arr = ['a', 'b', 'c']
let person = {
name: 'a',
age: 18,
myIterator: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

let myIterator = person.myIterator()
console.log(person.myIterator())//{ next: [Function: next] }
console.log(myIterator.next())//{ value: 'a', done: false }
console.log(myIterator.next())//{ value: 18, done: false }
console.log(myIterator.next())//{ value: [Function: myIterator], done: false }
console.log(myIterator.next())//{ value: undefined, done: true }
{ value: undefined, done: true }

按道理实现了迭代器该对象就会变为可迭代对象了,可以使用for..of遍历


但是执行后发现还是会提示Person不是可迭代的,是因为for..of只能遍历实现了[Symbol.iterator]接口的的对象,因此我们写的方法名要使用[Symbol.iterator]


1690873941456.png


修改后:


let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

//for..in
for (let key in person) {
console.log(key, person[key])
}

//for...of
for (let key of person) {
console.log(key)
}

//打印结果
name a
age 18
a
18

什么时候会用迭代器?


应用场景:可以参考阮一峰老师列举的例子


js语法:for ... of 展开运算符 yield 解构赋值


创建对象时:new map new set new weakmap new weakset


一些方法的调用:promise.all promise.race array.from


for in 和for of 迭代器、生成器(generator)


迭代器中断:


迭代器中定义return方法在迭代器提前关闭时执行,必须返回一个对象


break return throw 在迭代器的return 方法中可以捕获到



let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
},
return: () => {
console.log('结束迭代')
return { done: true }
}
}
}
}

//for...of
for (let key of person) {
console.log(key)
if (key === 'a') break
}

//打印结果
a
结束迭代



作者:wo不是黄蓉
来源:juejin.cn/post/7262212980346404922
>结束:下节讲生成器

收起阅读 »

认识Base64,看这篇足够了

web
Base64的简介 Base64是常见的用于传输8Bit字节码的编码方式之一,基于64个可打印字符来标识二进制数据点方法。 使用Base64的编码不可读,需要解码。 Base64实现方式 Base64编码要求把3个8位字节(3*8=24)转化为4个6位...
继续阅读 »

Base64的简介


Base64是常见的用于传输8Bit字节码的编码方式之一,基于64个可打印字符来标识二进制数据点方法。


使用Base64的编码不可读,需要解码。


Base64实现方式


Base64编码要求把3个8位字节(3*8=24)转化为4个6位的字节(4*6=24),之后在6位的前面补两个0,形成8位一个字节的形式。如果剩下的字符不足3个字节,则用0填充,输出字符使用=,因此编码后输出的文本末尾可能会出现1个或2个=


Base64就是包括小写字母a-z,大写字母A-Z,数字0-9,符号+/组成的64个字符的字符集,另外包括填充字符=。任何符号都可以转换成这个字符集中的字符,这个转化过程就叫做Base64编码。


为了保证输出的编码位可读字符,Base64指定了一个编码表,以便进行统一转换,编码表的大小为2^6=64,即Base64名称的由来。




























































































































































































索引 对应字符 索引 对应字符 索引 对应字符 索引 对应字符
0 A 17 R 34 i 51 z
1 B 18 S 35 j 52 0
2 C 19 T 36 k 53 1
3 D 20 U 37 l 54 2
4 E 21 V 38 m 55 3
5 F 22 W 39 n 56 4
6 G 23 X 40 o 57 5
7 H 24 Y 41 p 58 6
8 I 25 Z 42 q 59 7
9 J 26 a 43 r 60 8
10 K 27 b 44 s 61 9
11 L 28 c 45 t 62 +
12 M 29 d 46 u 63 /
13 N 30 e 47 v
14 O 31 f 48 w
15 P 32 g 49 x
16 Q 33 h 50 y


十进制转二进制


十进制数转换为二进制数时,由于整数和小数的转换方法不同,所以先将十进制数的整数部分和小数部分分别转换后,再加以合并。


十进制整数转换为二进制整数采用 "除 2 取余,逆序排列" 法。


具体做法是:用 2 整除十进制整数,可以得到一个商和余数;再用 2 去除商,又会得到一个商和余数,如此进行,直到商为小于 1 时为止,然后把先得到的余数作为二进制数的低位有效位,后得到的余数作为二进制数的高位有效位,依次排列起来。


示例:求十进制34对应的二进制数





Base64编码示例


示例1:对字符串 Son 进行 Base64编码

























































ASCII字符 S(大写) o n
十进制 83 111 110
二进制 01010011 01101111 01101110
每6个bit为一组 010100 110110 111101 101110
高位补0 00010100 00110110 00111101 00101110
对应的Base64索引 20 54 61 46
对应的Base64字符 U 2 9 u


Son通过Base64编码转换成了U29u,3个ASCII字符刚好转换成对应的Base64字符。


示例2:对字符串 S 进行 Base64编码

























































ASCII字符 S(大写)
十进制 83
二进制 01010011
每6个bit为一组 010100 110000 000000 000000
高位补0 00010100 00110000 00000000 00000000
对应的Base64索引 20 48
对应的Base64字符 U w = =


字符串S对应1个字节,一共8位,按6位为一组分为4组,每组不足6位的补0,然后在每组的高位补0,找到Base64编码进行转换。


Base64的优缺点


优点:





  • 将二进制数据(如图片)转为可打印字符,在HTTP协议下传输



  • 对数据进行简单加密,肉眼安全



  • 如果是在html或css中处理图片,可以减少http请求


缺点:





  • 内容编码后体积变大,至少1/3



  • 编码和解码需要额外工作量


Base64编码和解码





  • btoaatob方法:js中有两个函数被分别用来处理编码和解码Base64字符串



    • btoa():该函数基于二进制数据字符串创建一个Base64编码的字符串



    • atob():该函数用于解码使用Base64编码的字符串




btoa()示例:


let btoaStr = window.btoa(123456)
console.log(btoaStr) // MTIzNDU2

atob()示例:


let atobStr = window.atob(btoaStr)
console.log(atobStr) // 123456

这两个函数容易混淆,可以这样记下,比如btoa是以b字母开头,编码也是以b字母开头。即btoa编码,升序的atob解码。



Data URI Scheme


Data URI scheme的目的是将一些小的数据,直接嵌入到网页中,从而不用再从外部文件载入,减少请求资源的连接数,缺点是不会被浏览器缓存起来。


Data URI scheme支持的Base64编码的类型比如:





  • data:image/png;base64,base64编码的png图片数据



  • data:image/gif;base64,base64编码的gif图片数据



  • data:text/javascript;base64,base64编码的javascript代码


Base64的应用


使用Base64编码资源文件


比如:在html文档中渲染一张Base64的图片


<img :src="data:image/png;base64,IVBORsssssfjsfsfs==" alt="">img>


注意:如果图片较大,在Base64编码后字符串会非常大,影响页面加载进度,这种情况不适合Base64编码。



在HTTP中传递较长的标识信息


比如使用Base64将一个较长的标识符(128bit的UUID)编码为一个字符串,用作表单和httpGET请求中的参数。


标准的Base64编码后不适合直接放在请求的URL中传输,因为URL编码器会把Base64中的/+变成乱码,即%xxx的形式。我们可以将base64编码改进一下用于URL中,比如Base64中的/+转换成_-等字符,避免URL编码器的转换。


canvas生成图片


canvas提供了toDataURL()toBlob()方法。


HTMLCanvasElement.toDataURL() 方法返回一个包含图片展示的data URI。可以使用 type 参数其类型,默认为PNG格式。图片的分辨率为 96dpi。


canvas.toDataURL(type, encoderOptions);

使用示例:设置jpeg图片的质量,图片将以Base64存储


let quality = canvas.toDataURL("image/jpeg"1.0);
// data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...9oADAMBAAIRAxEAPwD/AD/6AP/Z"

HTMLCanvasElement.toBlob() 方法创造Blob对象,用以展示 canvas 上的图片;这个图片文件可以被缓存或保存到本地(由用户代理自行决定)。


HTMLCanvasElement.toBlob(callback, type?, quality?)

使用示例:


canvas.toBlob(function(blob){...}, "image/jpeg"0.95); // JPEG at 95% quality

读取文件


FileReader.readAsDataURL()方法会读取指定的BlobFile对象。读取操作完成的时候,readyState会变成已完成DONE,并触发 loadEnd事件,同时result属性将包含一个data:URL 格式的字符串(Base64 编码)以表示所读取文件的内容。


读取文件示例:


代码演示可以点击这里查看哦


<input type="file" onchange="previewFile()" />
<br />
<br />
<img src="" alt="导入图片进行预览" />
<script>
  function previewFile({
    var preview = document.querySelector("img");
    var file = document.querySelector("input[type=file]").files[0];
    var reader = new FileReader();

    reader.addEventListener("load",
      function ({
        preview.src = reader.result;
        console.log(reader.result); // Base64编码的图片格式
      },
      false,
    );

    if (file) {
      reader.readAsDataURL(file);
    }
  }
script>

简单"加密"某些数据


Base64常用作一个简单的“加密”来保护某些数据,比如个人密码我们如果不使用其他加密算法的话,可以使用Base64来简单处理。


Base64是加密算法吗


Base64不是加密算法,只是一种编码方式,可以用Base64来简单的“加密”来保护某些数据。


结语


❤️ 🧡 💛大家喜欢我写的文章的话,欢迎大家点点关注、点赞、收藏和转载!!


作者:前端开心果
来源:mdnice.com/writing/3c8306915f484882a5b720bfdedc6e02
收起阅读 »

为什么你不应该使用div作为可点击元素

web
按钮是为任何网络应用程序提供交互性的最常见方式。但我们经常倾向于使用其他HTML元素,如divspan等作为clickable元素。但通过这样做,我们错过了许多内置浏览器的功能。 我们缺少什么? 无障碍问题(空格键或回车键无法触发按钮点击) 元素将无法通过按...
继续阅读 »

按钮是为任何网络应用程序提供交互性的最常见方式。但我们经常倾向于使用其他HTML元素,如divspan等作为clickable元素。

但通过这样做,我们错过了许多内置浏览器的功能。


我们缺少什么?



  1. 无障碍问题(空格键或回车键无法触发按钮点击)

  2. 元素将无法通过按Tab键来聚焦


1062174618-64c4718ba0919.webp


权宜之计


我们需要在每次创建可点击的 div 按钮时,以编程方式添加所有这些功能


image.png


更好的解决方案


始终优先使用 button 作为可点击元素,以获取浏览器的所有内置功能,如果你没有使用它,始终将上述列出的可访问性功能添加到你的div中。


虽然,直接使用按钮并不直观。我们必须添加并修改一些默认的CSS和浏览器自带的行为。


使用按钮的注意事项


1. 它自带默认样式


我们可以通过将每个属性值设置为 unset 来取消设置现有的CSS。


我们可以添加 all:unset 一次性移除所有默认样式。


在HTML中,我们有三种类型的按钮。 submit, reset and button.
默认的按钮类型是 submit.


无论何时使用按钮,如果它不在表单内,请始终添加 type='button' ,因为 submit 和 reset 与表格有关。


2.请不要在按钮标签内部放置 divs


我们仍然需要添加 cursor:pointer 以便将光标更改为手形。


image.png


作者:王大冶
来源:juejin.cn/post/7261985825089110076
收起阅读 »

从9G到0.3G,腾讯会议对他们的git库做了什么?

web
导读 过去三年在线会议需求井喷,腾讯会议用户量骤增到3亿。快速迭代的背后,腾讯会议团队发现:业务保留了长达5年的历史数据,大量未进行 lfs 转换,新 clone 仓库本地空间占17.7G+。本地磁盘面临严重告急,强烈影响团队 clone 效率。当务之急是将仓...
继续阅读 »

导读


过去三年在线会议需求井喷,腾讯会议用户量骤增到3亿。快速迭代的背后,腾讯会议团队发现:业务保留了长达5年的历史数据,大量未进行 lfs 转换,新 clone 仓库本地空间占17.7G+。本地磁盘面临严重告急,强烈影响团队 clone 效率。当务之急是将仓库进行瘦身。本栏目特邀腾讯会议的智子研发团队成员李双君,回顾腾讯会议客户端的瘦身历程和经验,欢迎阅读。


目录


1 瘦身成效


2 瘦身前事项


3 瘦身整体方案


4 瘦身具体命令执行


5 新代码库验证


6 解决其它设备本地老分支 push 问题


7 其他平台适配


8 最后的验证


9 瘦身完毕后的知会


10 兜底回滚方案


11 踩坑记录及应对


12 写在最后


*作者所在的腾讯会议智子研发团队是腾讯会议的终端团队,负责腾讯会议 Win、Mac、Linux、Android、iOS、小程序、Web 等全栈开发,致力于打造一流的端产品体验。


01、瘦身成效


腾讯会议瘦身完毕后整体收益:




  • Git 仓库大小,9G 到350M。




  • 新 clone 仓库占用空间,从17.7G 到12.2G。




  • 平常拉代码速度(北京地区测试):macbook m1 pro:提升45%;devcloud win:提升56%。




  • 包构建流水线全量拉代码耗时,从16分钟减少到5分钟以内。





02、瘦身前事项


2.1 环境准备


使用有线网,看看能否通过其他办法给机器的上传和下载速度提速?不建议在家中开代理来瘦身,因为家里网速一般都没有公司快;如果在家操作,提前配置好远程桌面,远程公司电脑来瘦身。


使用性能较好的机器,硬盘空间至少要有 xxxG 剩余 (可以提前演练,看看究竟要多大磁盘空间?会议最起码得要求有600G 空余)。会议本次瘦身使用的设备是 MAC Book M1 Pro(16寸)笔记本电脑。


2.2 周知


工作开发群或者邮件等通知瘦身时间和注意事项:


瘦身时间: 选一个大家基本上都不会提交代码的时间,比如十一国庆或者春节;会议选的是春节期间。


注意事项: (开发重点关注)


瘦身期间会锁库,必须提前推送代码到远端,否则需要手动同步; 锁库期间无法进行 MR,且已创建 MR 会失效; 因删除历史记录,会导致本地仓库与远端冲突,请恢复后重新 clone 代码; 需要查询或处理更老的代码,需要去备份仓库查看。

2.3 代码库锁定


禁止代码库写操作,一般公司的代码管理平台可以提供这个功能,Git 项目的 owner 有权限。


2.4 第三方 Git 平台禁用


如果 Git 项目被第三方 Git 平台使用了,要保证瘦身前仓库的同步任务禁用。


比如,会议使用了 Ugit(UGit 是腾讯内部的一款自研 Git 客户端,主要是为腾讯内部研发环境特点而定制),就要如下禁用项目同步:



03、瘦身整体方案


原仓库继续保留作为备份仓库,另外新建仓库,新仓库沿用原仓库的项目名称、版本库路径和 id,并同步原项目数据。


之所以这么做,是为了保证其他平台无缝对接新的 Git 仓库,不用再更换 Git 地址,另外有些通过 api 调用的系统和工具也不受到影响。


瘦身内容:




  • 历史记录删除,只保留最近半年的历史记录。




  • 将历史大文件以及未加入 lfs 的大文件进行 lfs 处理。




04、瘦身具体命令执行


4.1 clone 项目,拉取所有文件版本到本地


git clone example.com/test.git


为了后面的比对验证,可以拷贝一份 test 文件夹放到和 test 同级目录下面的新建的 copyForCompare 文件夹中。


ulimit -n 9999999 # 解决可能出现的报错too many open files的问题
ulimit -n # 查看改成9999999了没

遍历拉取所有分支的 lfs 最新文件,并追踪远端分支到本地


以下这段 shell 脚本可以直接拷贝到终端运行,也可以创建一个.sh 文件放到根目录执行


cur_index=1
j=1
git branch -r | grep -v '->' |
while read remote
do
echo ”deal $cur_index th branch“
cur_index=$[cur_index+1]
git branch --track "${remote#origin/}" "$remote"
echo "begin to lfs fetch branch $remote"
git lfs fetch origin $remote
if [ $? -eq 0 ]; then
echo "fetch branch $remote success"
else
echo "fetch branch $remote failed"
lfs_fetch_fail_array[$j]=$remote
j=$[j+1]
fi
done
if [ ${#lfs_fetch_fail_array[*]} -gt 0 ]; then
echo "git lfs fetch error branches are: ${lfs_fetch_fail_array[*]}"
else
echo "fetch all branches success. done."
fi

获取所有分支的文件和 lfs 文件版本


git fetch --all
git lfs fetch --all

4.2 使用 git filter-branch 截断历史记录


这次瘦身只保留最近半年的历史记录,2022.6.1之前的提交记录都删除,所以截断的 commit 节点按如下所述来找:


提前用 sourceTree(或者别的 Git 界面工具)找出来需要截断的那个 commit,以主干 master 为例,找到 master 分支上提交的并且只有一个父的提交节点(如果提交节点有多个父,那么所有父节点都要处理),该节点必须是所有分支的父节点,否则需要考虑其他分支特殊处理的情况,该情况后面的【特殊分支处理】会有说明。



可以看到选中的截断 commit id 是 ff75cc5cdbf0423a24b4f5438e52683210813ba0



  • 根据上面的 commit id,带入下面的命令,找出其父


git cat-file -p ff75cc5cdbf0423a24b4f5438e52683210813ba0



可以看到只有一个父,其父是7ffe6782272879056ca9618f1d85a5f9716f8e90 ,所以该提交 id 就是要置为空的。如果有多个父都需要处理。



  • 执行命令


注意:对于普通提交节点,下面命令的 parent 值是"-p parentId";对于合并提交节点,下面命令的 parent 值是"-p parentId1 -p parentId2 -p parentId3 ..."


git filter-branch --force --parent-filter '
read parent
if [ "$parent" = "-p 7ffe6782272879056ca9618f1d85a5f9716f8e90" ]
then
echo
else
echo "$parent"
fi' --tag-name-filter cat -- --all


  • 重点验证:上述命令执行完毕后,一定要用如下命令检查是否修改成功


注意:因为执行完了命令已经修改了历史记录,此时 Git log 命令执行会慢点,大概5分钟可以出结果,另外可以用这个在线时间戳转换工具来转换时间戳。


工具链接:http://www.beijing-time.org/shijianchuo…


如果执行成功会把之前的文件版本取最新的 add 到这个截断的提交节点里面,如下图:


git log --all-match --author="xxxx" --grep="auto update .code.yml by robot" --name-status --before="1654043400" --after="1654012800" --all


4.3 使用 git-filter-repo 清理截断日期前的所有历史记录,并将截断节点的提交信息修改


注意此步骤要谨慎处理,因为这步会真正地删除提交记录。


提前安装好 git-filter-repo,执行下面的 python 代码。


import os
try:
import git_filter_repo as fr
except ImportError:
raise SystemExit("Error: Couldn't find git_filter_repo.py. Did you forget to make a symlink to git-filter-repo named git_filter_repo.py or did you forget to put the latter in your PYTHONPATH?")

k_work_dir = "/Volumes/SolidCompany/S_Shoushen/test"
# 2022.6.1 00:00:00
k_clean_history_deadline = b"1654012800"
# 2022.6.1 07:05:07
k_clean_deadline_commit_date = b"1654038307"
k_clean_deadline_commit_author_name = b"xxxxx"
k_new_root_commit_message = "仓库瘦身历史记录裁剪,截断提交记录后新根结点新增历史文件;如果想查看更多历史记录,请去备份仓库:https://example.com/test_backup.git"

def commitCallBackFun(commit, metadata):
[time_stamp, timezone] = commit.committer_date.split()
if time_stamp == k_clean_deadline_commit_date and commit.author_name == k_clean_deadline_commit_author_name:
commit.message = k_new_root_commit_message.encode("utf-8")
if time_stamp >= k_clean_history_deadline:
return
commit.file_changes = []

def main():
os.chdir(k_work_dir)
print("git work dir is", os.getcwd())
args = fr.FilteringOptions.parse_args(['--force', '--debug'])
filter = fr.RepoFilter(args, commit_callback = commitCallBackFun)
filter.run()

if __name__ == '__main__':
main()

验证下截断提交结点的提交信息更改成功了没?


git log --all-match --author="xxx" --grep="仓库瘦身历史记录裁剪" --name-status --before="1654043400" --after="1654012800"

如下就对了:



以上执行完后做个简单验证:


用 BeyondCompare 工具跟刚开始备份的 copyForCompare 目录下的 test 仓库对比,看看有没有增删改文件,期望应该没有任何变化才对。



  • 特殊分支处理


说明:以上历史记录裁剪并删除历史提交记录执行完后,对于基于截断提交节点前的提交节点创建出来的分支或者其子分支会出现文件被删除或者整个分支被删除的情况。



所以要提前弄清楚有没有在截断节点之前早就创建出来一直在用的分支, 如果有就得特殊处理上面的2和3步骤了:


第2步中截断历史记录的时候,要类似分析 master 分支那样分析其它需要保留的特殊分支,找出各自的截断节点的父提交 id;然后执行的 shell 脚本里面条件判断改成判断所有的父提交 id;类似这样:


git filter-branch --force --parent-filter '
read parent
if [ "$parent" = "-p 85f5ee6314f4f46cc47eb02c6af93bd3020a1053 -p cd207e9b3372f68a6d1ffe06fcf189d952e3bf9f" ] || [ "$parent" = "-p 7ffe6782272879056ca9618f1d85a5f9716f8e90" ]
then
echo
else
echo "$parent"
fi' --tag-name-filter cat -- --all

第3步中删除截断节点前提交记录的 python 脚本里面,按照分支名字和自己分支的截断日期来做比对逻辑进行删除提交记录的操作。类似如下:


#!/usr/bin/env python3
import os
try:
import git_filter_repo as fr
except ImportError:
raise SystemExit("Error: Couldn't find git_filter_repo.py. Did you forget to make a symlink to git-filter-repo named git_filter_repo.py or did you forget to put the latter in your PYTHONPATH?")

k_work_dir = "/Users/jevon/Disk/work/appShoushen/shoushen/test"

# 2022.6.1 07:05:07
k_master_cut_date = b"1654038307"

# 2022.3.25 19:32:00
k_private_new_saas_sdk_master_cut_date = b"1648207920"

k_new_root_commit_message = "仓库瘦身历史记录裁剪,截断提交记录后新根结点新增历史文件;如果想查看更多历史记录,请去备份仓库:https://example.com/test_backup.git"

def commitCallBackFun(commit, metadata):
[time_stamp, timezone] = commit.committer_date.split()
# 每个特殊分支的截断提交点的提交信息修改
if (time_stamp == k_master_cut_date and commit.author_name == b"xxx_author1") or \
(time_stamp == k_private_new_saas_sdk_master_cut_date and commit.author_name == b"xxx_author2"):
commit.message = k_new_root_commit_message.encode("utf-8")

# 每个特殊分支的截断提交点前的提交记录,需要根据各自截止日期来做比对删除日期前的历史记录
strBranch = commit.branch.decode("utf-8")
if strBranch.endswith("refs/heads/master"):
if time_stamp < k_master_cut_date:
commit.file_changes = []
elif strBranch.endswith("refs/heads/private/feature/3.12.1/new-saas-sdk-master"):
if time_stamp < k_private_new_saas_sdk_master_cut_date:
commit.file_changes = []
def main():
os.chdir(k_work_dir)
print("git work dir is", os.getcwd())
args = fr.FilteringOptions.parse_args(['--force', '--debug'])
filter = fr.RepoFilter(args, commit_callback = commitCallBackFun)
filter.run()

if __name__ == '__main__':
main()

以上[特殊分支处理]没有实验过,但是个解决思路,具体实践结果待补充,也欢迎实验过的同学交流。


4.4 进行 lfs 转换


rm -Rf .git/refs/original
rm -Rf .git/logs
git branch | wc -l # 看一下本地分支总数
# 拷贝原来的仓库到新目录下面
git clone file:///Users/jevon/Disk/work/appShoushen/shoushen/test /Users/jevon/Disk/work/appShoushen/shoushen/test_new
cd test_new
git branch -r | grep -v '->' |
while read remote
do
git branch --track "${remote#origin/}" "$remote"
done
git fetch --all git branch | wc -l # 看一下本地分支总数,和拷贝之前是否一样
# 分析仓库中占用空间较大的文件类型(演练的时候可以提前分析出来,节省瘦身时间)
git lfs migrate info --top=100 --everything

命令结果如下,是按照文件所有的历史版本累加统计的,只有未加入 lfs 的才会统计。



git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sed -n 's/^blob //p' | sort --numeric-sort --key=2 | cut -c 1-12,41- | $(command -v gnumfmt || echo numfmt) --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest | grep MiB

该命令执行结果如下,是把所有大于 1Mb 的文件版本都列出来了,不进行累加,从小到大排序,已经加入 lfs 的不会统计。



# lfs转换
# --include=之后填入根据实际分析的大文件列表
git lfs migrate import --everything --include="*.jar,tool/ATG/index.js,xxx"
# 上面lfs转换执行完后,看一下根目录的.gitattribute文件里面是不是加入了新的lfs文件了

4.5 新建新仓库,推送所有历史记录修改


新创建目标仓库 test_backup.git ,然后运行下面代码:


git remote remove origin
git remote add origin https://example.com/test_backup.git
git remote set-url origin https://example.com/test_backup.git
git remote -v # 确保设置成功新仓库地址

此时可以用下面的命令看看还有没有大文件了(可选)。


git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sed -n 's/^blob //p' | sort --numeric-sort --key=2 | cut -c 1-12,41- | $(command -v gnumfmt || echo numfmt) --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest | grep MiB

用以下命令看看还有没有未转换的大的 lfs 文件了(可选)。


git lfs migrate info --top=100 --everything
# 推送历史记录修改到目标新仓库:
git push origin --no-verify --all
git push origin --no-verify --tags

4.6 回到原来的 test 目录,推送 lfs 文件


cd ../test
git config lfs. https://example.com/test_backup.git /info/lfs.access basic
git lfs env # 看一下改成了basic了吗
# 设置成远端目标仓库test_backup.git
git remote remove origin
git remote add origin https://example.com/test_backup.git
git remote set-url origin https://example.com/test_backup.git
git remote -v # 确保设置成功新仓库地址


将 upload_objects.sh 拷贝到 test 目录,然后执行 sh ./upload_objects.sh


upload_objects.sh 内容如下:


#!/bin/bash

set -e

count=$(find .git/lfs/objects -type f | wc -l)
echo "-- total objects count is $count"

index=0
concurrency=25

find .git/lfs/objects -type f | awk -F '/' '{print $NF}' | while read -r obj; do
echo "-- $(date) -- uploading ($index/$count) $obj"
git lfs push origin --object-id "$obj" &
index=$((index+1))
if [[ $index%$concurrency -eq 0 ]]; then
echo -e "\r\n-- $(date) -- waiting --------------------------\r\n"
wait
fi
done

注意脚本里面的并发值不能设置太高,不然会报错./upload_objects.sh: line 12: echo: write error: Interrupted system call,测试发现设置25是比较合适的。



确保像上图这样,最后一个也上传成功。


05、新代码库验证


git clone https://example.com/test_backup.git

使用 git lfs pull 先拉取主干分支所有的历史文件进行测试,保留瘦身的本地仓库;  后续如果发现有其他分支的 lfs 文件没有上传成功,再单独上传即可。


上传命令:


git lfs push --object-id origin "$objId"

对比新旧代码库主干最新代码是否一致,可使用 beyond compare 工具进行验证。四端编译不同代表性的分支运行验证。


06、解决其它设备本地老分支 push 问题


在公司的代码管理平台上设置瘦身后的 test_backup 仓库单文件大小上限为1.5M。


一般公司自己的代码管理平台都会提供设置单个 git 文件上传大小上限的功能,得管理员才有权限设置;腾讯的代码管理平台是像下图这样设置的:



解释:之后的步骤将会把新老仓库互换,新旧仓库互换后,其它机器本地的老仓库分支还是有 push 上去的风险,这样就会把瘦身前的历史记录又推送到瘦身后的 Git 仓库,造成瘦身白费。


07、其他平台适配


7.1 代码管理平台


找代码管理平台协助完成下面的操作:(需要提前预约沟通好)


会议用的代码管理平台是工蜂:


项目名称、版本库路径互换:test_backup 重命名为 test,test 重命名为 test_backup。 将两个项目项目 id 进行调换:新项目沿用旧项目的项目 id,以此保证通过 api 调用的系统和工具不受到影响。 项目数据同步:同步项目成员和权限相关的数据、保护分支规则组到新仓库。

自己工蜂适配(可以提前进行)。对照老工蜂的所有配置,在新工蜂上手动同步修改。


7.2 第三方 Git 工具


如果使用了第三方 Git 工具平台做过瘦身仓库与其他项目仓库的同步,需要处理下(会议使用了 UGit 第三方工具):


通知 UGit 相关负责人把旧的工作区移除一下,重新 clone test 仓库。 把 Ugit 里面 test 仓库的同步任务恢复(如有需要)。

7.3 出包流水线构建平台


因为执行完瘦身后,Git 的 commit id 都变了,历史记录也变了,而 coding 的构建机如果不清理缓存删掉老仓库的话,会导致构建机本地仓库历史与远端冲突,执行 Git 命令会异常,所以 coding 必须要清理掉老仓库,重新 clone。


08、最后的验证


代码管理平台以及出包构建平台都处理完成后,进行最后的验证。


本地验证:


本地是否能正常 clone 代码。 本地对比新旧仓库主干最新代码是否一致。 本地随机抽取分支对比新旧仓库文件个数以及最新代码是否一致。 本地编译验证,程序启动主流程验证。

出包构建平台验证:


主干分支、发布分支、个人需求分支、个人分支等的构建验证。

代码管理平台验证:


代码库基础、高级配置是否正确 保护分支规则配置是否正确,是否有效 项目成员是否和原仓库一致 MR 是否可正常发起、合并,能否正常调起检测流水线

代码库写权限恢复:


保证瘦身后的 Git 仓库恢复写权限;备份仓库禁用写权限。

09、瘦身完毕后的知会


知会参考模板:


xxx 仓库瘦身完成了!接下来需要开发重点关注:本地旧仓库已经失效,必须删掉重新 clone 代码【最最重要】未提前push到远端的本地新增代码需要手动同步旧的未完成的MR已经失效,需要关闭后重新发起需要查询或处理更老的代码,需要去备份仓库查看(xxxx/xxxx.git)开发过程中有任何疑问,欢迎请随时联系 xxx

10、兜底回滚方案


因为使用了备份仓库,所以不会修改原始仓库,但只有代码管理平台(工蜂)在第七步的时候修改了原始仓库,对于这个工蜂的协助修改,需要提前确认好工蜂那边做好了回滚的方案。


11、踩坑记录及应对


11.1 上传 lfs 的时候报错 User is null or anonymous user



LFS: Git:User is null or anonymous user.


解决:git config lfs.example.com/test_backup… basic


输入 git lfs env 看一下输出结果改成了 basic 了吗?



11.2 git push 的时候报错



把远程链接改成 https 的:


git remote set-url origin https://example.com/test_backup.git
git remote -v

如果~/.gitconfig 中有如下的内容要先注释掉。


url.git@example.com:.insteadof=http://example.com/ url.git@example.com:.insteadof=https://example.com/

最后再 push 即可。


如果上述还不行,那么在命令行中执行:


git config --global https.postbuffer 1572864000git config --global https.lowSpeedLimit 0git config --global https.lowSpeedTime 999999

如仍然无法解决,可能是用户的客户端默认有设默认值限制 git 传输包的大小,可执行指令:


git config --global core.packedGitLimit 2048m
git config --global core.packedGitWindowSize 2048m

11.3 window 如何在 git batch 里面运行 git-filter-repo?


安装 python:打开 cmd 窗口,运行 python -m pip install git-filter-repo,安装 git-filter-repo;


用 everything 查找 git-filter-repo.exe;


cmd 窗口,运行 git --exec-path,得到路径类似:C:\Program Files\Git\mingw64\libexec\git-core;


把上面找到的 git-filter-repo.exe 拷贝到 git-core 文件夹里面;


此时在 git batch 窗口中,输入命令 git filter-repo(注意输入的git后面没有-),会提示 No arguments specified.证明 ok 了。


11.4 如果想让 git-filter-repo 作为一个 python 库来使用,实现更复杂的功能该怎么办?


比如,不想这么用了 git-filter-repo --force --commit-callback "xxxx python code...",因为这么用只能写回调的 python 代码,太弱了。


解决:python3 -m pip install --user git-filter-repo,不行就 python3 -m pip install git-filter-repo,安装这个 git-filter-repo包,然后就可以在 python 代码中作为库使用:import git_filter_repo as fr。


11.5 瘦身后发现 coding 的 win 构建机器在 clone 代码时出问题,怎么办?


卡在 git lfs pull:



卡在 git checkout --force xxxxx 提交 id:



卡在 checking out files:



调查发现,是 lfs 进程卡住,不知道什么样的场景触发的,官方有个类似 issue,以上问题均是因为 git 或者 git lfs 版本过低导致的,升级到高版本即可解决。


据当时出错 case 总结得出结论,以下 git 和 git lfs 的版本号可以保证稳定运行不出问题,如果版本号低于以下所示,最好升级。



11.6 执行 git lfs fetch 的时候报错 too many open files 的问题


解决办法:ulimit -n 9999999


12、写在最后


仓库瘦身是个细致耗时的工作,需要谨慎认真地完成。最后腾讯会议客户端仓库的大小也从 9G 瘦身到 350M ,实现的效果还是不错的。


本次我们分享了仓库瘦身的全历程,把执行命令也公示给各位读者。希望可以帮助到为类似困境而头疼的开发者们。这篇文章对您有帮助的话,欢迎转发分享。


-End-


原创作者|李双君


技术责编|陈从贵、郭浩伟



作者:腾讯云开发者
来源:juejin.cn/post/7261814990843265061
收起阅读 »

浏览器渲染15M文本导致崩溃怎么办

web
最近,我刚刚完成了一个阅读器的txt文件阅读功能,但在处理大文件时,遇到了文本内容过多导致浏览器崩溃的问题。 一般情况下,没有任何样式渲染时不会出现什么问题,15MB的文件大约会有3秒的空白时间。 <div id="content"></di...
继续阅读 »

最近,我刚刚完成了一个阅读器的txt文件阅读功能,但在处理大文件时,遇到了文本内容过多导致浏览器崩溃的问题。


一般情况下,没有任何样式渲染时不会出现什么问题,15MB的文件大约会有3秒的空白时间。


<div id="content"></div>

fetch('./dp.txt').then(resp => resp.text()).then(text => {
document.getElementById('content').innerText = text
})

尽管目前还没有严重的问题,但随着文件继续增大,肯定会超过浏览器内存限制而导致崩溃。


在开发阅读器的过程中,我添加了下面的样式,结果导致浏览器直接崩溃:


* {
margin: 0;
padding: 0;
}

html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}

body {
column-fill: auto;
column-width: 375px;
overflow-x: auto;
}

预期结果应该是像下面这样分段显示:



然而,实际出现了下面的问题:


unnamed.png


因此,文件内容太多会导致浏览器崩溃。即使进行普通的渲染,我们也要考虑这个问题。


如何解决


解决这个问题的方法有点经验的前端开发工程师应该都知道可以使用虚拟滚动,重点是怎么对文本分段分段,最容易的可能就是按照一定数量字符划分,但是这个导致文本衔接不整齐出现文本跳动。如图,橙色和蓝色表示两端文本的衔接,虚拟滚动必然会提前移除橙色那块内容,那么就会导致蓝色文本位置发生改变。



要解决这个问题,我们需要想办法用某个元素替代原来的位置。当前页橙色部分删除并计算位置,问题会变得复杂并且误差比较大,因此这一部分直接保留,把这部分前面的内容移除,然后用相同长度的元素占据,接下来重点就是怎么获取到橙色部分与前面内容的分界点。


获取分界点可以使用document.createRange()document.createRange()是 JavaScript 中用于创建Range对象的方法。Range对象表示一个包含节点与文本节点之间一定范围的文档片段。这个范围可以横跨单个节点、部分节点或者多个节点。


// 创建 Range 对象
const range = document.createRange();

range.setStart(textNode, 0); // 第一个参数可以是文本节点,第二个参数表示偏移量
range.setEnd(textNode, 1);
const rect = range.getBoundingClientRect(); // 获取第一个字符的位置信息

利用Range对象的特性,我们可以从橙色部分的最后一个字符开始计算,直到找到分界点的位置。


阅读器如果仅仅只是从左往右阅读,按照上面的方法已经可以实现,但是阅读器可能会出现页面直接跳转,跳转之后的文本起点你并不知道,并且页面总页码你也无法知道。因此从一开始就要知道每一页的分界点,也就是需要做预渲染。以下是一个简单的示例:


let text = '...'
const step = 300
let end = Math.min(step, value.length) // 获取结束点

while (text.length > 0) {
node.innerText = value.substring(0, end) // 取部分插入节点
const range = document.createRange()
range.selectNodeContents(node)
const rect = range.getBoundingClientRect() // 获取当前内容的位置信息

if (rect.height > boxHeight) {
// 判断当前内容高度是否会超出显示区域的高度
// 如果超出,从 end 最后一个字符开始计算,直到不超出范围
while (bottom > boxHeight) {
// node.childNodes[0] 表示文本节点
range.setStart(node.childNodes[0], end - 1)
range.setEnd(node.childNodes[0], end)
bottom = range.getBoundingClientRect().bottom
end--
}
} else {
// 如果没有超出,end 继续增加
// ...
}
}

上面只是简单的实现原理,可以达到精确区分每一页的字符,但是计算量有点太大,15MB文本大约500多万字,循环次数估计也在几十万到上百万。在本人的电脑上测试大约需要20秒,每个人设备的性能不同,所需时间也会有所不同。很明显,这种实现方式并不太理想。


后来我对这个方案进行了优化,实际上我们不需要计算每一页的分界点,可以计算出多页的分界点,例如10页、20页、50页等。优化后的代码是将step增大,比如设为10000,然后将不能组成一页的尾部内容去掉。优化后,15MB的文本大约需要4秒左右。需要注意的是,step并不是越大越好,太大会导致渲染页面占用时间过长。


这就是我目前用来解决页面渲染大量文本的方法。如果你有更

作者:60岁咯
来源:juejin.cn/post/7261231729523965989
好的方案,欢迎留言。

收起阅读 »