注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

【Webpack Plugin】写了个插件跟喜欢的女生表白,结果......😭😭😭

web
👋 事情是这样的 作为一名母胎 solo 二十几年的我,平平无奇的一直活在别人的狗粮之下。渐渐的,我好像活成了一个随时见证别人爱情,也随时能为失恋的人排忧解难的角色。 直到前两天,公司新来了一个前端妹子。 相视的第一眼,我神迷了,我知道,终究是躲不过去了.....
继续阅读 »

👋 事情是这样的


作为一名母胎 solo 二十几年的我,平平无奇的一直活在别人的狗粮之下。渐渐的,我好像活成了一个随时见证别人爱情,也随时能为失恋的人排忧解难的角色。


image.png


直到前两天,公司新来了一个前端妹子。


相视的第一眼,我神迷了,我知道,终究是躲不过去了......


image.png


相逢却似曾相识,未曾相识已相思!


当晚,彻夜未眠...


6839f22e-2f0c-4117-b02b-5db21822e8f9.jpg


第二天早上,从同事的口中得知了女生的名字,我们暂且叫她小舒吧。


为了不暴露我的狼子野心(欲擒故纵拿捏的死死的),我决定出于同事的关心询问一下项目了解的怎么样了,有没有需要我帮忙的。


没想到小舒像抓到了救命稻草一样:“小哥,你来的正好,过来帮我看看项目怎么跑不起来??”


8f8a7944-af3a-41a3-9d46-0828aade3146.jpg


我回到座位上,很快的发现是由于项目中部分包的版本不兼容导致的,更新下版本就可以了。


正准备起身去找小舒时,一个奇怪的念头闪过......


我决定给我们的第一次交流一个惊喜:借着这次解决问题的机会,好好拉近一下我们之间的关系!!!


10145af4-9407-40a8-ba24-8b64bfebeaa8.jpg


想法一来便挡也挡不住。我决定在项目中运行一个插件:当启动项目时,直接在控制台中向小舒表达我的心意!!!😏😏😏


没办法,单身这么多年肯定是有原因的!一定是我不够主动!这次我可要好好把握这个机会!!!


f689092c-a552-4fda-8189-d6c7f59fecd3.jpg


🏂 说干就干


有了想法就开干,哥从来不是一个拖拖拉拉的人。


小舒的项目用的是 Webpack + React 技术栈,既然想要在项目启动的时候做事情,那肯定是得写个 Webpack 插件了。


先去官网了解一下 Webpack Plugin 的概念:



Webpack Plugin:向第三方开发者提供了 Webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以在 Webpack 构建流程中引入自定义的行为。创建插件比创建 loader 更加高级,因为你需要理解 Webpack 底层的特性来处理相应的钩子



867997b1-26f7-40fb-b4b2-c911306bdda4.jpg


通俗点说就是可以在构建流程中插入我们的自定义的行为,至于在哪个阶段插入或者做什么事情都可以通过 Webpack Plugin 来完成。


另外官网还提到,想要弄清楚 Webpack 插件 得先弄清楚这三个东西:tapablecompilercompilation对象,先快点花几分钟去了解一下,争取在中午吃饭前搞定!


27df2766-960f-4c9c-823c-46d62092bdd9.jpg


💁 tapable的使用姿势


tapable是一个类似于 Node.js 中的 EventEmitter 的库,但它更专注于自定义事件的触发和处理。通过 tapable 我们可以注册自定义事件,然后在适当的时机去触发执行。


e9bb13dc-a5b9-4e89-a258-8001c80d7558.jpg


举个例子🌰:类比到 VueReact 框架中的生命周期函数,它们就是到了固定的时间节点就执行对应的生命周期,tapable 做的事情就和这个差不多,可以先注册一系列的生命周期函数,然后在合适的时间点执行。


概念了解的差不多了,接下来去实操一下。初始化项目,安装依赖:


npm init //初始化项目
yarn add tapable -D //安装依赖

安装完项目依赖后,根据以下目录结构来添加对应的目录和文件:


├── dist # 打包输出目录
├── node_modules
├── package-lock.json
├── package.json
└── src # 源码目录
└── index.js # 入口文件

根据官方介绍,tapable 使用起来还是挺简单的,只需三步:



  1. 实例化钩子函数( tapable会暴露出各种各样的 hook,这里以同步钩子Synchook为例)

  2. 注册事件

  3. 触发事件


src/index.js


const { SyncHook } = require("tapable"); //这是一个同步钩子

//第一步:实例化钩子函数,可以在这里定义形参
const syncHook = new SyncHook(["author"]);

//第二步:注册事件1
syncHook.tap("监听器1", (name) => {
console.log("监听器1:", name);
});

//第二步:注册事件2
syncHook.tap("监听器2", (name) => {
console.log("监听器2", name);
});

//第三步:触发事件
syncHook.call("不要秃头啊");

运行 node ./src/index.js,拿到执行结果:


监听器1 不要秃头啊
监听器2 不要秃头啊

63c7e8b4-11bd-4cc5-a96d-be8bcc486365.jpg


从上面的例子中可以看出 tapable 采用的是发布订阅模式通过 tap 函数注册监听函数,然后通过 call 函数按顺序执行之前注册的函数


大致原理:


class SyncHook {
constructor() {
this.taps = [];
}

//注册监听函数,这里的name其实没啥用
tap(name, fn) {
this.taps.push({ name, fn });
}

//执行函数
call(...args) {
this.taps.forEach((tap) => tap.fn(...args));
}
}

另外,tapable 中不仅有 Synchook,还有其他类型的 hook:


image.png


image.png


这里详细说一下这几个类型的概念:



  • Basic(基本的):执行每一个事件函数,不关心函数的返回值

  • Waterfall(瀑布式的):如果前一个事件函数的结果 result !== undefined,则 result 会作为后一个事件函数的第一个参数(也就是上一个函数的执行结果会成为下一个函数的参数)

  • Bail(保险的):执行每一个事件函数,遇到第一个结果 result !== undefined 则返回,不再继续执行(也就是只要其中一个有返回了,后面的就不执行了)

  • Loop(循环的):不停的循环执行事件函数,直到所有函数结果 result === undefined


大家也不用死记硬背,遇到相关的需求时查文档就好了。


在上面的例子中我们用的SyncHook,它就是一个同步的钩子。又因为并不关心返回值,所以也算是一个基本类型的 hook。


0564085f-3d25-4be0-aeed-6e24c6205762.jpg


👫 tabpable 和 Webpack 的关系


要说它们俩的关系,可真有点像男女朋友之间的难舍难分......


5be98b8a-f500-4f28-8240-a69e567e71b1.jpg


Webpack 本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,比如



  • 在打包前需要处理用户传过来的参数,判断是采用单入口还是多入口打包,就是通过 EntryOptionPlugin 插件来做的

  • 在打包过程中,需要知道采用哪种读文件的方式就是通过 NodeEnvironmentPlugin 插件来做的

  • 在打包完成后,需要先清空 dist 文件夹,就是通过 CleanWebpackPlugin 插件来完成的

  • ......


而实现这一切的核心就是 tapable。Webpack 内部通过 tapable 会提前定义好一系列不同阶段的 hook ,然后在固定的时间点去执行(触发 call 函数)。而插件要做的就是通过 tap 函数注册自定义事件,从而让其控制在 Webapack 事件流上运行:


image.png


继续拿 Vue 和 React 举例,就好像框架内部定义了一系列的生命周期,而我们要做的就是在需要的时候定义好这些生命周期函数就好。


9fca05f9-1d48-436a-bafd-f45d808a3b49.jpg


🏊‍♀️ Compiler 和 Compilation 


在插件开发中还有两个很重要的资源:compilercompilation对象。理解它们是扩展 Webpack 引擎的第一步。



  • compiler 对象代表了完整的 webpack 生命周期。这个对象在启动 Webpack 时被一次性建立,并配置好所有可操作的设置,包括 optionsloaderplugin。当在 Webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 Webpack 的主环境。

  • compilation 对象代表了一次资源版本构建。当运行 Webpack 开发环境中间件( webpack-dev-server)时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。


5dd8339b-4ebd-4007-9e14-f1ca58c17d53.jpg


还是拿 React 框架举例子...... React:


590d1a42-2273-41c5-8ac2-d9e9ed90cf76.jpg


compiler比喻成 React 组件,在 React 组件中有一系列的生命周期函数(componentDidMount()render()componentDidUpdate()等等),这些钩子函数都可以在组件中被定义。


compilation比喻成 componentDidUpdate()componentDidUpdate()只是组件中的某一个钩子,它专门负责重复渲染的工作(compilation只是compiler中某一阶段的 hook ,主要负责对模块资源的处理,只不过它的工作更加细化,在它内部还有一些子生命周期函数)。


如果还是不理解,这里画个图帮助大家理解:


image.png


图上的 entryOptionafterPluginsbeforeRuncompilation 等均是构建过程中的生命周期,而 compilation 只是该过程中的其中一部分,它主要负责对模块资源的处理。在 compilation 内部也有自己的一系列生命周期,例如图中的 buildModulefinishModules 等。


cf8d3c96-74d9-434b-a27d-060dc5244311.jpg


至于为什么要这么处理,原因当然是为了解耦!!!


比如当我们启动 Webpack 的 watch模式,当文件模块发生变化时会重新进行编译,这个时候并不需要每次都重新创建 compiler 实例,只需要重新创建一个 compilation 来记录编译信息即可


另外,图中并没有将全部的 hook 展示出来,更多的hook可以自行查阅官网:compiler上挂载的 hookcompilation上挂载的 hook


ef377b09-4933-4828-9ce1-6bd2b2536e6f.jpg


🏃 如何编写插件


说了这么多,到底要怎么写一个 Webpack 插件?小舒还等着我呢!!!


bb9b6a98-7058-48fc-9ec5-bc84d7bcf8f2.jpg


刚才知道了在 Webpack 中的 compilercompilation 对象上挂载着一系列的生命周期 hook ,那接下来应该怎么在这些生命周期中注册自定义事件呢?


webpack 插件:


cb597c1b-a21a-4b8d-9fa8-b129380a3b9e.jpg


Webpack Plugin 其实就是一个普通的函数,在该函数中需要我们定制一个 apply 方法。当 Webpack 内部进行插件挂载时会执行 apply 函数。我们可以在 apply 方法中订阅各种生命周期钩子,当到达对应的时间点时就会执行。


bb740142-acbd-49da-898c-e0e765ec6552.jpg


这里可能有同学要问了,为什么非要定制一个apply方法?为什么不是其他的方法?


在这里我贴下官方源码:github.com/webpack/web…
大家一看便一目了然:


if (options.plugins && Array.isArray(options.plugins)) {
//这里的options.plugins就是webpack.config.js中的plugins
for (const plugin of options.plugins) {
plugin.apply(compiler); //执行插件的apply方法
}
}

这里官方写死了执行插件中的 apply 方法....,并没有什么很高深的原因.....


68456079-6083-46b1-9248-97f943d7d06d.jpg


那我们就按照规范写一个简易版的插件赶紧来练练手:在构建完成后打印日志。


首先我们需要知道构建完成后对应的的生命周期是哪个,通过 查阅文档得知是 complier 中的done 这个 hook :


image.png


接下来创建一个新项目验证我们的想法,时间不早了!小舒现在肯定很着急!!!


安装依赖:


npm init //初始化项目
yarn add webpack webpack-cli -D

安装完项目依赖后,根据以下目录结构来添加对应的目录和文件:


├── dist # 打包输出目录
├── plugins # 自定义插件文件夹
│ └── demo-plugin.js
├── node_modules
├── package-lock.json
├── package.json
├── src # 源码目录
│ └── index.js # 入口文件
└── webpack.config.js # webpack配置文件

demo-plugin.js


class DemoPlugin {
apply(compiler) {
//在done(构建完成后执行)这个hook上注册自定义事件
compiler.hooks.done.tap("DemoPlugin", () => {
console.log("DemoPlugin:编译结束了");
});
}
}

module.exports = DemoPlugin;

package.json


{
"name": "webpack-plugin",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"main": "index.js",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"tapable": "^2.2.1",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0"
}
}

src/index.js


console.log("author:""不要秃头啊");

webpack.config.js


const DemoPlugin = require("./plugins/demo-plugin");

module.exports = {
mode: "development",
entry: "./src/index.js",
devtool: false,
plugins: [new DemoPlugin()],
};

运行 yarn build,运行结果:


yarn build
$ webpack
DemoPlugin:编译结束了
asset main.js 643 bytes [emitted] (name: main)
./src/index.js 476 bytes [built] [code generated]
webpack 5.74.0 compiled successfully in 71 ms
Done in 0.64s.

db4cafab-ece6-4bbb-8103-79e4589f0ebe.png


💘 开始我的表白之路....


好了,终于搞清楚怎么写插件了!!!


39d696c9-90ee-4544-9999-c056db959cfc.jpg


直接把刚才学的的demo插件改造一下:


class DonePlugin {
apply(compiler) {
//在done(构建完成后执行)这个hook上注册自定义事件
compiler.hooks.done.tap("DonePlugin", () => {
console.log(
"小姐姐,我知道此刻你很意外。但不知道怎么回事,我看见你的第一眼就沦陷了...可以给我一个多了解了解你的机会吗? ————来自一个热心帮你解决问题的人"
);
});
}
}
module.exports = DonePlugin;

正准备提交代码,思来想去,直接叫小姐姐好像不太好吧?是不是显得我很轻浮?


再说了,小舒怎么知道我在跟她说呢?


想了一会,不如直接用她的 git 账号名吧(当时要是脑子不抽风就好了......😭),于是改成动态获取git 用户名,为了显眼甚至还加了点颜色:


const chalk = require("chalk");//给日志加颜色插件
const execSync = require("child_process").execSync;

const error = chalk.bold.red; //红色日志
const warning = chalk.keyword("orange"); //橘色日志

class DonePlugin {
apply(compiler) {
compiler.hooks.done.tap("DonePlugin", () => {
//获取git账号信息的username
let name = execSync("git config user.name").toString().trim();

console.log(
error(`${name},`),
warning(
"我知道此刻你很意外。但不知道怎么回事,我看见你的第一眼就沦陷了...可以给我一个多了解了解你的机会吗? ————来自一个热心帮你解决问题的人"
)
);
});
}
}

module.exports = DonePlugin;

大致效果就是这样...


image.png


98936199-cfea-4dce-afd1-a25b2a8b1f58.jpg


😳 等待回应


把这一切都准备妥当后,剩下的就交给天意了。


结果是左等右等,到了下午四点迟迟没有等到小舒的回应......


072c64a4-ff38-4a44-83f0-40c754980149.jpg


难道是没看到吗?不应该啊,日志还加了颜色,很明显了!!!


莫非是女孩子太含蓄了,害羞了?


不行,我得主动出击!!


image.png


乘兴而去,败兴而归!!!还在同事圈里闹了个笑话!!!


但是为了下半生,豁出去了!!!


经过我的一番解释,小舒总算相信了我说的话,而我也赶紧去优化了一下代码......


自此以后,每天一句不重样的小情话,小舒甚至还和我互动了起来:


image.png


就这样,我们慢慢的发展成了无话不谈的男女朋友关系,直到前两天甚至还过了1000天纪念日,还给小舒送了点小礼物,虽然被骂直男...


image.png


接下来也该考虑结婚了!!!


“滴~~~,滴~~~,滴~~~,不要命了!等个红绿灯都能睡着?“


“喂,醒醒,醒醒。我的尿黄,让我去渍醒他!”


只听旁边有人说到......


原来只是黄粱一梦。


38d25691-8bdb-4d7b-9b5c-adb0b090c206.jpg


💔 最后的结局


最后,给大家一个忠告:追女孩子一定不要这样, 一定要舍得送花,一定要懂浪漫!!!没有哪个女孩子会因为你写个插件就跟你在一起的!!!


我决定勇敢的试一试:


image.png


卒。


作者:不要秃头啊
来源:juejin.cn/post/7160467329334607908
收起阅读 »

为什么大厂前端监控都在用GIF做埋点?

web
什么是前端监控? 它指的是通过一定的手段来获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,为产品优化指明方向,为用户提供更加精确、完善的服务。 前端监控 一般来讲一个成熟的产品,运营与产品团队需要关注用户在产品内的行为记录,通过用户的行为记录来...
继续阅读 »

什么是前端监控?


它指的是通过一定的手段来获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,为产品优化指明方向,为用户提供更加精确、完善的服务。


前端监控


一般来讲一个成熟的产品,运营与产品团队需要关注用户在产品内的行为记录,通过用户的行为记录来优化产品,研发与测试团队则需要关注产品的性能以及异常,确保产品的性能体验以及安全迭代。


所以前端监控一般也分为三大类:


数据监控(监控用户行为)



  • PV/UV: PV(page view):即页面浏览量或点击量;UV:指访问某个站点或点击某条新闻的不同 IP 地址的人数

  • 用户在每一个页面的停留时间

  • 用户通过什么入口来访问该网页

  • 用户在相应的页面中触发的行为,等...


统计这些数据是有意义的,比如我们知道了用户来源的渠道,可以促进产品的推广,知道用户在每一个页面停留的时间,可以针对停留较长的页面,增加广告推送等等。


性能监控(监控页面性能)



  • 不同用户,不同机型和不同系统下的首屏加载时间

  • 白屏时间

  • http 等请求的响应时间

  • 静态资源整体下载时间

  • 页面渲染时间

  • 页面交互动画完成时间,等...


这些性能监控的结果,可以展示前端性能的好坏,根据性能监测的结果可以进一步的去优化前端性能,尽可能的提高用户体验。


异常监控(监控产品、系统异常)


及时的上报异常情况,可以避免线上故障的发上。虽然大部分异常可以通过 try catch 的方式捕获,但是比如内存泄漏以及其他偶现的异常难以捕获。常见的需要监控的异常包括:



  • Javascript 的异常监控

  • 样式丢失的异常监控


埋点上报


OK,上面我们说到了前端监控的三个分类,了解了一个产品需要监控哪些内容以及为什么需要监控这些内容,那么我们应该怎么实现前端监控呢?


实现前端监控,第一步肯定是将我们要监控的事项(数据)给收集起来,再提交给后台进行入库,最后再给数据分析组进行数据分析,最后处理好的数据再同步给运营或者是产品。数据收集的丰富性和准确性会直接影响到我们做前端监控的质量,因为我们会以此为基础,为产品的未来发展指引方向。


现在常见的埋点上报方法有三种:手动埋点、可视化埋点、无埋点


手动埋点


手动埋点,也叫代码埋点,即纯手动写代码,调用埋点 SDK 的函数,在需要埋点的业务逻辑功能位置调用接口,上报埋点数据,像**[友盟][百度统计]**等第三方数据统计服务商大都采用这种方案。手动埋点让使用者可以方便地设置自定义属性、自定义事件;所以当你需要深入下钻,并精细化自定义分析时,比较适合使用手动埋点。


手动埋点的缺陷就是,项目工程量大,需要埋点的位置太多,而且需要产品开发运营之间相互反复沟通,容易出现手动差错,如果错误,重新埋点的成本也很高。


可视化埋点


通过可视化交互的手段,代替上述的代码埋点。将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后输出的代码耦合了业务代码和埋点代码。


可视化埋点的缺陷就是可以埋点的控件有限,不能手动定制。


无埋点


无埋点则是前端自动采集全部事件,上报埋点数据,由后端来过滤和计算出有用的数据。优点是前端只要一次加载埋点脚本,缺点是流量和采集的数据过于庞大,服务器性能压力山大。


为什么都用GIF来做埋点?


发现过程


首先说一下我是怎么发现的,前一段时间,产品提了个需求,说我们现在的书籍曝光上报规范并不是他们想要的数据,并且以后所有页面的书籍上报都统一成最新规范。


曝光规范:



  • 书籍出现在可视区并停留1秒,算作有效曝光

  • 书籍不能重复曝光,假如它一直在可视区滚动时只能上报一次

  • 当它移出可视区后再回到可视区,再按第一点进行曝光


OK,既然要所有页面统一,那就只能封装成通用库来使用了,这里实现逻辑就不贴了,想看的私聊我发你,主要的难点就是停留时长计算,以及曝光标记。


const exposeReportClass = new exposeReport({
scrollDom: "", // 滚动容器,建议指定一个滚动容器,不传默认为window
watchDom: ".bookitem", // 监听的dom,建议使用class类,标签也支持
time: 1000 // 停留有效时长ms
});
// 提供两个上报方法
exposeReportClass.didReport(()=>{
// 手动上报
//callback
})
exposeReportClass.scrollReport(()=>{
// 滚动动上报
//callback
})
//

具体业务逻辑之需要放在对应的callback里面,而上报逻辑开发者无需考虑,因为我底层已经统一处理好了。


然后我再测试的时候就发现,上报发的请求居然是通过图片发起的,并不是我们认为的接口上报。


report.png


然后我去查了下资料,发现很多大厂的上报都是这么干的!


使用GIF上报的原因


向服务器端上报数据,可以通过请求接口,请求普通文件,或者请求图片资源的方式进行。只要能上报数据,无论是请求GIF文件还是请求js文件或者是调用页面接口,服务器端其实并不关心具体的上报方式。那为什么所有系统都统一使用了请求GIF图片的方式上报数据呢?



  • 防止跨域


一般而言,打点域名都不是当前域名,所以所有的接口请求都会构成跨域。而跨域请求很容易出现由于配置不当被浏览器拦截并报错,这是不能接受的。但图片的src属性并不会跨域,并且同样可以发起请求。(排除接口上报)



  • 防止阻塞页面加载,影响用户体验


通常,创建资源节点后只有将对象注入到浏览器DOM树后,浏览器才会实际发送资源请求。反复操作DOM不仅会引发性能问题,而且载入js/css资源还会阻塞页面渲染,影响用户体验。


但是图片请求例外。构造图片打点不仅不用插入DOM,只要在js中new出Image对象就能发起请求,而且还没有阻塞问题,在没有js的浏览器环境中也能通过img标签正常打点,这是其他类型的资源请求所做不到的。(排除文件方式)



  • 相比PNG/JPG,GIF的体积最小


最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节。


同样的响应,GIF可以比BMP节约41%的流量,比PNG节约35%的流量。


并且大多采用的是1*1像素的透明GIF来上报


1x1像素是最小的合法图片。而且,因为是通过图片打点,所以图片最好是透明的,这样一来不会影响页面本身展示效果,二者表示图片透明只要使用一个二进制位标记图片是透明色即可,不用存储色彩空间数据,可以节约体积。


作者:前端南玖
来源:juejin.cn/post/7065123244881215518
收起阅读 »

我是埋点SDK,看我如何让甲方爸爸的页面卡顿10s+

web
背景音: Sir,收到線報啦,今日喺生產環境用戶訪問網頁嘅時候,竟然感受到咁卡卡地!完全冇得爽啊!已經導致唔少用戶投訴。根據推斷,昨日更新咗埋點SDK... 昨日,一位前端程序员在优化公司的埋点SDK使用方式后,出了一些小插曲。不知道是什么原因,更新之后就...
继续阅读 »

背景音:



Sir,收到線報啦,今日喺生產環境用戶訪問網頁嘅時候,竟然感受到咁卡卡地!完全冇得爽啊!已經導致唔少用戶投訴。根據推斷,昨日更新咗埋點SDK...



昨日,一位前端程序员在优化公司的埋点SDK使用方式后,出了一些小插曲。不知道是什么原因,更新之后就开始有用户反馈说网页卡卡地,走得比蜗牛还慢。


六点二十分,第一个用户提交了投诉工单,但这只是个开始。


今天早上九点十分,公司的运维团队已经接到了一大堆反馈工单,许多用户都遭受到了同样的问题。这是一个巨大的问题,一旦得不到解决,可能导致数万的用户受到影响。运维人员立即开始了排查工作,想要找出问题所在。


经过一个小时的紧急排查,他们终于想到了昨日的这名前端程序员,一经沟通发现是SDK版本更新引起的问题。在新的版本中,有一些不稳定的代码导致了性能问题。


然而,这不仅仅是个技术问题,因为接下来,他们要开始着手写事故报告,准备给上层领导交代。


接下来,进入正题:


一、问题排查定位


根据更新的版本体量,可以缩小和快速定位问题源于新引入埋点SDK



  1. 打开 开发者工具-性能分析,开始记录

  2. 刷新页面,重现问题

  3. 停止记录,排查分析性能问题


性能分析


如上图,按照耗时排序,可以快速定位找到对应的代码问题。


首先把编译压缩后的代码整理一下,接下来,深入代码一探究竟。


代码耗时.png


⏸️暂停一下,不妨猜猜看这里是为了干嘛?


🍵喝口茶,让我们沿着事件路径,反向继续摸清它的意图吧。


image.png


这里列举了231个字体名称,调用上文的 detect() 来分析。


⏸️暂停一下,那么这个操作为什么会耗时且阻塞页面渲染呢?


...


休息一下,让我们去看看这段代码的来龙去脉。


上面我们大概猜到代码是用于获取用户浏览器字体,那就简单检索一下 js get browser font


搜索结果.png


代码示例.png


证据确凿,错在对岸。


二、解决问题


相信大家也看出来了,我不是埋点SDK,我也不是甲方爸爸,我只能是一位前端开发。


联系反馈至SDK方,需要走工单,流程,而这一切要多少时间?


我唔知啊!领导也不接受啊!


👐没办法,只能自己缝补缝补了。


那么如何解决呢?



  1. 尝试修复 getFonts detect 字体检测逻辑,避免多次重绘。

  2. 缩短待检测字体目录。


人生苦短,我选方案3,直接修改返回值,跳过检测


getFonts () { return 'custom_font' }

那么让我们继续搬砖吧。



  1. 寻根


image.png


首先找到 SDK 加载对应 JS 的加载方式,看看能不能动点手脚。
这里可以看到,是采用很常见的 通过 appendScript loadJs 的方案,那么就可以复写拦截一下 appendChild 函数。



  1. 正源


通过拦截 appendChild,将SDK加载的JS改为加载修复后的JS文件。


核心代码如下:


var tempCAppend = document.head.appendChild
document.head.appendChild = function (t) {
if (t.tagName === 'SCRIPT' && t.src.includes('xxx.js')) {
t.src = 'custom_fix_xxx.js'
}
return tempCAppend.bind(this)(t)
}

三、后续


这件事情发生在21年底,今天为什么拿出来分享一下呢?


近期排查 qiankun 部分子应用路由加载异常的时候,定位到与 document.head.appendChild 被复写有关,于是去看SDK方是否修复,结果纹丝未动....


结合近期境遇,不得不感慨,业务能不能活下去,真的和代码、技术什么的毫无关系。


其他


❄️下雪了,简单看了几眼文心一言的发布会,更凉了。


作者:夏无凉风冬有雪
来源:juejin.cn/post/7211020974023868475
收起阅读 »

上千行代码的输入框的逻辑是什么?

web
需求 我们要做一个前端需求:需要一个输入框,支持 KQL 语法,支持智能匹配,前提条件纯前端实现。 该功能详见 kibana es7版本。有条件的可以去使用一下,感受一番。 需求分析 使用了一下该功能,感觉还是挺复杂的。不好实现啊,我,我,我。。。 不过因为...
继续阅读 »

需求


我们要做一个前端需求:需要一个输入框,支持 KQL 语法,支持智能匹配,前提条件纯前端实现。


该功能详见 kibana es7版本。有条件的可以去使用一下,感受一番。


image.png


需求分析


使用了一下该功能,感觉还是挺复杂的。不好实现啊,我,我,我。。。


不过因为 kibana 是开源的,我就去 github 上看了一下源码。



  • 首先人家是 React 版本,我的项目是 Vue 版本,我不能行使拿来主义。

  • 一个 input 框的核心代码写了一千多行,不包括一些工具函数,公共组件之类。


方案




  1. 我先研究源码,再把研究好的源码转成 vue 版本输出?


    该方案短时间内看不到效果,需要好好梳理其源码。是一个 0 或者 1 的问题,如果研究好了并实现转化出来,那就是 1,如果期间遇到问题阻塞了,那就是短时间看不到产出效果。不敢冒险。




  2. 创建一个 React 项目,把相关的这部分代码拆分出来,以微前端的方式内嵌到我的项目中?


    不知道在拆分代码和组装代码的过程中会遇到什么问题?未知,不敢冒险去耽误时间,也是一个 0 或者 1 的问题。




  3. 自己研究 KQL 语法,自己摸索规则,自己实现其逻辑?


    由于项目排期紧张,不敢太过冒险,就选择了自研。起码 ld 能看到进度。😁




image.png


image.png


image.png


我最后选择的是方案3:自研。但是如果有时间,我更倾向的想去尝试方案1 和方案2。


针对自研方案,我们就开干吧!撸起袖子加油干!😄


准备


首先,我们需要一些准备工作,我需要了解 KQL 语法是什么?然后使用它,研究其规则,梳理其逻辑。


Kibana 查询语言 (Kibana Query Language、简称 KQL) 是一种使用自由文本搜索或基于字段的搜索过滤 Elasticsearch 数据的简单语法。 KQL 仅用于过滤数据,并没有对数据进行排序或聚合的作用。


KQL 能够在您键入时建议字段名称、值和运算符。 建议的性能由 Kibana 设置控制。


KQL 能够查询嵌套字段和脚本字段。 KQL 不支持正则表达式或使用模糊术语进行搜索。


更为详细的可以看官方文档 Kibana Query Language



  • key method value 标准单个语句

  • key method value OR/AND key method value OR/AND key method value .... 标准多个语句

  • key OR/AND key OR/AND key OR/AND key method value .... 不标准多个语句

  • ......



Tips:key(字段名称)、method(运算符)、value(值)



实现


textarea



  • 由于用户可以输入多行文本信息,所以需要 textarea。type="textarea"

  • 为了用户能清楚看到输入内容,以及input 的美观,初始行数 :rows="2"

  • 因为我们能支持关键字和KQL两种情况,所以 placeholder="KQL/关键字"

  • 获取焦点需要打开下拉展示框 @focus="dropStatus = true"

  • 失去焦点且没有操作下拉选项则关闭下拉框 @blur="changeDrop"

  • 由于下拉框的位置需要跟着 textarea 高度变化,所以 v-resizeHeight="inputResizeHeight"


<el-input
v-resizeHeight="inputResizeHeight"
id="searchInputID"
ref="searchInputRef"
v-model="input"
:rows="2"
type="textarea"
placeholder="KQL/关键字"
class="searchInput"
@blur="changeDrop"
@focus="dropStatus = true"
>

</el-input>

changeDrop 需要判断用户是否正在操作下拉框内容,如果是,就不要关闭。这块你会怎么实现呢?可以先思考自己的实现方式,再看下边是我个人的实现方式。


其实理论就是给下拉框操作的时候增加标记,在失去焦点要关闭的时候,判断是否有这个标记,如果有,就不要关闭,否则就关闭。但这个标记又不能影响真正的失焦状态下关闭动作。


我想到的就是定时器,定时器能增加一个变量,同时还能自动销毁。具体的实现方式:


// 不关闭下拉框标记
noCloseInput() {
this.$refs.searchInputRef.focus()
if (this.timer) clearInterval(this.timer)
let time = 500
this.timer = setInterval(() => {
time -= 100
if (time === 0) {
clearInterval(this.timer)
this.timer = null
}
}, 100)
}

// 失焦操作
changeDrop() {
setTimeout(() => {
if (!this.timer) this.dropStatus = false
}, 200)
}

这么做需要有以下几点注意:



  • 失焦操作因为需要切换到下拉框有一定延迟需要定时器,而定时器的时间必须小于标记里边的定时器时间

  • 定时器 this.timer = setInterval() 中 this.timer 是定时器的 id

  • clearInterval(this.timer) 只会清除定时器,不会清空 this.timer


v-resizeHeight="inputResizeHeight" 这个是我写的一个自定义指令来检测元素的高度变化的,不知道你有什么好的方法吗?有的话请请共享一下,😍


const resizeHeight = {
// 绑定时调用
bind(el, binding) {
let height = ''
function isResize() {
// 可根据需求,调整内部代码,利用 binding.value 返回即可
const style = document.defaultView.getComputedStyle(el)
if (height !== style.height) {
// 此处关键代码,通过此处代码将数据进行返回,从而做到自适应
binding.value({ height: style.height })
}
height = style.height
}
// 设置调用函数的延时,间隔过短会消耗过多资源
el.__vueSetInterval__ = setInterval(isResize, 100)
},
unbind(el) {
clearInterval(el.__vueSetInterval__)
}
}

export default resizeHeight

下拉面板


image.png


下拉框是左右布局,右侧是检索语法说明的静态文案,可忽略。左侧是语句提示内容。


语句提示内容经过研究其实有四种:


key(字段名称)、method(运算符)、value(值)、connectionSymbol(连接符)


由于可能会有多个语句,其实我们是只对当前语句进行提示的,所以我们只分析当前语句的情况。


// 当前语句详情
{
cur_fields: '', // 当前 key
cur_methods: '', // 当前 method
cur_values: '', // 当前 value
cur_input: '' // 当前用户输入内容,可模糊匹配检索
}

有四部分,肯定就是需要在符合条件的情况下分别展示对应的 options 面板内容。


那判断条件就是如下图,其中后续需要注意的就是这几个判断条件的值赋值场景要准确。


image.png


语法分析器


想处理输入内容,做一个语法分析器,首先需要去监听用户的输入,那么就用 vue 提供的 watch。


watch: {
input: debounce(function(newValue, oldValue) {
if (newValue !== oldValue) this.dealInputShow(newValue)
}, 500)
}

基本大概思路如下:


KQL语法分析器.png


其中获取输入框的光标位置的方法如下:


const selectionStart = this.$refs.searchInputRef.$el.children[0].selectionStart

修改完了之后,光标会自动跑的最后,这样有点违反用户操作逻辑,所以需要设置一下光标位置:


if (this.endValue) {
this.$nextTick(() => {
const dom = this.$refs.searchInputRef.$el.children[0]
dom.setSelectionRange(this.input.length, this.input.length)
this.input += this.endValue
})
}

还有面板里边有四项内容,那每一项内容选择都可以通过鼠标点击选择,点击选择后,就需要按照规则处理一下,进行最终的字符串 this.input 拼接,得到最终结果。


// 当前 key 点击选择
curFieldClick(str) {},

// 当前 method 点击选择
curMethodClick(str) {},

// 当前 value 点击选择
curValueClick(str) {},

// 当前 链接符 点击选择
curConnectClick(str) {},

这部分需要注意的就是点击面板 input 会失去焦点,就加上前边说到的 noCloseInput() 不关闭下拉面板标记。


键盘快捷操作


必备的目前就 3 个事件 enter、up、down,其他算是锦上添花,由于排期紧张,暂时只做了必备的 3 个 事件:


<el-input
v-resizeHeight="inputResizeHeight"
id="searchInputID"
ref="searchInputRef"
v-model="input"
:rows="2"
type="textarea"
placeholder="KQL/关键字"
class="searchInput"
@blur="changeDrop"
@focus="dropStatus = true"
@keydown.enter.native.capture.prevent="getSearchEnter($event)"
@keydown.up.native.capture.prevent="getSearchUp($event)"
@keydown.down.native.capture.prevent="getSearchDown($event)"
>

</el-input>

那么,我们的这几个键盘事件都需要怎么处理呢??接下来就直接上代码简单分析一下:


// 键盘 enter 事件,有两种情况
// 一种就是 选择内容,第二种就是 相当于回车事件直接触发接口
getSearchEnter(event) {
event.preventDefault()

// 当前下拉面板的展示的 options
const suggestions = this.get_suggestions()

// 满足可以选的条件
if (this.dropStatus && this.dropIndex !== null && suggestions[this.dropIndex]) {
// 光标之后是否有内容,有就需要截取处理
// ......

// 当前项是否是手动输入的,需要做截取处理
// .......

// 拼接 enter 键选择的选项
this.input += suggestions[this.dropIndex] + ' '

// 光标之后是否有内容,就需要设置光标在当前操作位置,并拼接之前截取掉的光标后的内容
// .......

// 设置当前语法区域的各个当前项 cur_fields、cur_methods、cur_values
// ......

// 恢复键盘 up、down 选择初始值
this.dropIndex = 0
} else {
// 不满足选的条件,就关闭选择面板,并触发检索查询接口
this.dropStatus = false
this.$emit('getSearchData', 2)
}
},

// 键盘 up 事件
getSearchUp(event) {
event.preventDefault()

// 满足上移,就做 dropIndex 减法
if (this.dropStatus && this.dropIndex !== null) {
this.decrementIndex(this.dropIndex)
}
},

// 键盘 down 事件
getSearchDown(event) {
event.preventDefault()

// 满足下移,就做 dropIndex 加法
if (this.dropStatus && this.dropIndex !== null) {
this.incrementIndex(this.dropIndex)
}
},

// 加法,注意边界问题
incrementIndex(currentIndex) {
let nextIndex = currentIndex + 1
const suggestions = this.get_suggestions()
// 到最后边,重置到第一个,形成循环
if (currentIndex === null || nextIndex >= suggestions.length) {
nextIndex = 0
}
this.dropIndex = nextIndex

// 被选择的选项如果不在可视范围之内,需要滚动到可视区
this.$nextTick(() => this.scrollToOption())
},

// 减法,注意边界问题
decrementIndex(currentIndex) {
const previousIndex = currentIndex - 1
const suggestions = this.get_suggestions()
// 到最前边,重置到最后,形成循环
if (previousIndex < 0) {
this.dropIndex = suggestions.length - 1
} else {
this.dropIndex = previousIndex
}

// 被选择的选项如果不在可视范围之内,需要滚动到可视区
this.$nextTick(() => this.scrollToOption())
},

键盘事件的核心逻辑上述基本说清楚了,那么其中需要注意的一个点,那就是被选择的选项如果不在可视范围之内,需要滚动到可视区,这样可提高用户体验。那这块到底怎么做呢?其实实现起来还挺有意思的。


import scrollIntoView from './scroll-into-view'

// 滚动 optiosns 区域,保持在可视区域
scrollToOption() {
if (this.dropStatus === true) {
const target = document.getElementsByClassName('drop-active')[0]
const menu = document.getElementsByClassName('search-drop__left')[0]
scrollIntoView(menu, target)
}
},

scroll-into-view.js 内容如下:


export default function scrollIntoView(container, selected) {
// 如果当前激活 active 元素不存在
if (!selected) {
container.scrollTop = 0
return
}

const offsetParents = []
let pointer = selected.offsetParent
while (pointer && container !== pointer && container.contains(pointer)) {
offsetParents.push(pointer)
pointer = pointer.offsetParent
}

const top = selected.offsetTop + offsetParents.reduce((prev, curr) => (prev + curr.offsetTop), 0)
const bottom = top + selected.offsetHeight
const viewRectTop = container.scrollTop
const viewRectBottom = viewRectTop + container.clientHeight

if (top < viewRectTop) {
container.scrollTop = top
} else if (bottom > viewRectBottom) {
container.scrollTop = bottom - container.clientHeight
}
}

针对上述内容几个技术点做出简单解释:


offsetParent:就是距离该子元素最近的进行过定位的父元素,如果其父元素中不存在定位则 offsetParent为:body元素。


offsetParent 根据定义分别存在以下几种情况:



  1. 元素自身有 fixed 定位,父元素不存在定位,则 offsetParent 的结果为 null(firefox 中为:body,其他浏览器返回为 null)

  2. 元素自身无 fixed 定位,且父元素也不存在定位,offsetParent 为 <body> 元素

  3. 元素自身无 fixed 定位,且父元素存在定位,offsetParent 为离自身最近且经过定位的父元素

  4. <body>元素的 offsetParent 是 null


offsetTop:元素到 offsetParent 顶部的距离


image.png


offsetHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding)和边框(border),不包含外边距(margin),是一个整数,单位是像素 px。


通常,元素的 offsetHeight 是一种元素 CSS 高度的衡量标准,包括元素的边框、内边距和元素的水平滚动条(如果存在且渲染的话),不包含 :before或 :after 等伪类元素的高度。


image.png


scrollTop:可以获取或设置一个元素的内容垂直滚动的像素数。


一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为0。


clientHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding),不包含边框(border),外边距(margin)和滚动条,是一个整数,单位是像素 px。


clientHeight 可以通过 CSS height + CSS padding - 水平滚动条高度 (如果存在)来计算。


image.png


最全各个属性相关图如下:


image.png


效果


效果怎么说呢,也算顺利上线生产环境了,在此截图几张,给大家看看效果。


image.png


image.png


image.png


image.png


小结


做这个需求,最难的点是要求自己去研究 KQL 的语法规则,以及使用方式,然后总结规则,写出自己的词法分析器。


其中有什么技术难点吗?似乎并没有,都是各种判断条件,最简单的 if-else。


所以想告诉大家的是,不要一心只钻研技术,在做业务的时候也需要好好梳理业务,做一个懂业务的技术人。业务和技术互相成就!


最后,如果感到本文还可以,请给予支持!来个点赞、评论、收藏三连,万分感谢!😄🙏


作者:Bigger
来源:juejin.cn/post/7210593177820676154
收起阅读 »

做一个文件拖动到文件夹的效果

web
在我的电脑中,回想一下我们想要把一个文件拖动到另一个文件夹是什么样子的呢 1:鼠标抓起文件 2:拖动文件到文件夹上方 3:文件夹高亮,表示到达指定位置 4:松开鼠标将文件夹放入文件 下面就来一步步实现它吧👇 一:让我们的元素可拖动 方式一: dragg...
继续阅读 »

在我的电脑中,回想一下我们想要把一个文件拖动到另一个文件夹是什么样子的呢



1:鼠标抓起文件

2:拖动文件到文件夹上方

3:文件夹高亮,表示到达指定位置

4:松开鼠标将文件夹放入文件



Kapture 2023-03-10 at 08.30.34.gif


下面就来一步步实现它吧👇


一:让我们的元素可拖动


方式一: draggable="true"


`<div draggable="true" class="dragdiv">拖动我</div>`

方式二:-webkit-user-drag: element;


  .dragdiv {

width: 100px;

height: 100px;

background-color: bisque;

-webkit-user-drag: element;

}


效果


Kapture 2023-03-10 at 08.55.25.gif


二:让文件夹有高亮效果


给文件夹添加伪类?


🙅如果你直接给文件夹设置伪类:hover,会发现当拖动元素时,文件夹的:hover是不会触发的


Kapture 2023-03-10 at 09.08.54.gif


🧘这是因为在拖拽元素时,拖拽操作和悬停操作是不同的事件类型,浏览器在处理拖拽操作时,会优先处理拖拽事件,而不会触发悬停事件。拖拽操作是通过鼠标点击和拖拽来触发的,而悬停事件是在鼠标指针停留在一个元素上时触发的。


所以我们就来对拖拽操作的事件类型做功课吧🫱



  • dragstart:拖拽开始

  • dragend:拖拽结束

  • dragover:拖拽的元素在可放置区域内移动时触发,即鼠标指针在可放置区域内移动时持续触发

  • dragenter:拖拽的元素首次进入可放置区域时触发

  • dragleave:拖拽的元素离开可放置区域时触发

  • drop:当在可放置区域时,松开鼠标放置元素时触发


什么是可放置元素?
当你给元素设置事件:dragover、dragenter、dragleave、drop的时候
它就变成了可放置元素,特点是移到上面有绿色的➕号

拖动高亮实现


1:我们给files文件夹添加两个响应事件:dragoverdragleave


ps: 这里用dragover事件而不用dragenter事件是为了后续能够成功触发drop事件

2:当拖动元素进入可放置区域时,动态的给files添加类,离开时则移除类


// 显示高亮类
.fileshover {
background-color: rgba(0, 255, 255, 0.979);
}
// 添加dragover事件处理程序,在可放置区域触发

files.addEventListener('dragover', (event) => {

event.target.classList.add('fileshover');

});

// 添加dragleave事件处理程序,离开可放置区域触发

files.addEventListener('dragleave', (event) => {

event.target.classList.remove('fileshover');

});

🥳 恭喜你成功实现了移动到元素高亮的效果了


Kapture 2023-03-14 at 11.54.14.gif


三:文件信息传递


文件拖过去,是为了切换文件夹,在这里你可能会进行一些异步的操作,比如请求后端更换文件在数据库中的路径等。我们的需求多种多样,但是归根到底都是获取到文件的数据,并传递到文件夹中


DataTransfer对象


DragEvent.dataTransfer: 在拖放交互期间传输的数据


我们主要使用它的两个方法:



  • DataTransfer.setData(format, data):就是设置键值对,把我们要传的数据添加到drag object

  • DataTransfer.getData(format):根据键获取保存的数据


知道了这两个方法,相信你一定就有实现思路了 👊


拖拽开始 --> setData添加数据 --> 进入可放置区域 --> 放置时getData获取数据 --> 完成


1:给文件设置dragstart事件


// 开始拖拽事件

draggable.addEventListener('dragstart', (event) => {

const data = event.target.innerText;

event.dataTransfer.setData('name', data); //添加数据

})

2:在dragover事件中用event.preventDefault()阻止默认行为,允许拖拽元素放置到该元素上,否则无法触发drop事件


// 添加dragover事件处理程序

files.addEventListener('dragover', (event) => {

event.target.classList.add('fileshover');

event.preventDefault(); //新增

});

3:给文件夹设置放置事件drop


// 添加drop事件处理程序

files.addEventListener('drop', (event) => {

const data = event.dataTransfer.getData('name'); // 获取文件的数据

const text = document.createTextNode(data);

files.appendChild(text);

event.target.classList.remove('fileshover'); // 记得放置后也要移除类

});

实现效果:


Kapture 2023-03-14 at 14.46.45.gif


四:完整代码:


<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="UTF-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>Document</title>

<style>

.dragdiv {

width: 100px;

height: 100px;

background-color: bisque;

-webkit-user-drag: element;

}

.files {

width: 200px;

height: 200px;

background-color: rgba(0, 255, 255, 0.376);

margin-top: 100px;

}

.fileshover {

background-color: rgba(0, 255, 255, 0.979);

}

</style>

</head>

<body>

<div draggable="true" class="dragdiv">我是文件1</div>

<div class="files">

<p>文件夹</p>

拖动文件名称:

</div>

<script>

const draggable = document.querySelector('.dragdiv');

const files = document.querySelector('.files');

// 开始拖拽事件

draggable.addEventListener('dragstart', (event) => {

const data = event.target.innerText;

event.dataTransfer.setData('name', data);

})

// 添加dragover事件处理程序

files.addEventListener('dragover', (event) => {

event.target.classList.add('fileshover')

event.preventDefault()

});

// 添加dragleave事件处理程序

files.addEventListener('dragleave', (event) => {

event.target.classList.remove('fileshover')

});

// 添加drop事件处理程序

files.addEventListener('drop', (event) => {

const data = event.dataTransfer.getData('name')

const text = document.createTextNode(data)

files.appendChild(text);

event.target.classList.remove('fileshover')


});

</script>

</body>

</html>

总结:以上只是简单的熟悉拖拽事件的整个过程,你可以在此拓展更多自己想要功能,欢迎分享👏


作者:隐兮
来源:juejin.cn/post/7210256070299549755
收起阅读 »

如何写一个炫酷的大屏仿真页

web
前言 之前我写过一遍文章《从阅读页仿真页看贝塞尔曲线》,简要的和大家介绍了仿真页的具体实现思路,正好写完文章的时候,看到 OPPO 发布会里面提到了仿真页,像这样: 看着确实有点炫酷,我平时也接触了很多跟阅读器相关的代码,就零零碎碎花了一些时间撸了一个双页仿...
继续阅读 »

前言


之前我写过一遍文章《从阅读页仿真页看贝塞尔曲线》,简要的和大家介绍了仿真页的具体实现思路,正好写完文章的时候,看到 OPPO 发布会里面提到了仿真页,像这样:


OPPO折叠屏


看着确实有点炫酷,我平时也接触了很多跟阅读器相关的代码,就零零碎碎花了一些时间撸了一个双页仿真。


看效果:


11.gif


由于使用录屏,所以看着有点卡顿,实际效果非常流畅!


一、基础知识具备


仿生页里面用到很多自定义 View 的知识,比如:



  1. 贝塞尔曲线

  2. 熟悉 Canvas、Paint 和 Path 等常用的Api

  3. Matrix


具备这些知识以后,我们就可以看懂绝大部分的代码了。这一篇同样并不想和大家过多的介绍代码,具体的可以看一下代码。


二、双仿真和单仿真有什么不同


我写双仿真的时候,感觉和单仿真有两点不同:



  • 绘制的页数

  • 背部的贴图处理


首先,单仿真只要准备两页的数据:


QQ20230312-0.jpg


背部的内容也是第一页的内容,需要对第一页内容进行翻转再平移。


而双仿真需要准备六页的内容,拿左边来说:


QQ20230312-1.jpg


我们需要准备上层图片(柯基)、背部图片(阿拉斯加)和底部图片(吉娃娃,看不清),因为我们不知道用户会翻页哪侧,所以两侧一共需要准备六页的数据。


由于翻转机制的不一样,双仿真对于背部的内容只需要平移就行,但是需要新的一页内容,这里相对来说比单仿真简单。


三、我做了哪些优化


主要对翻页的思路进行了优化,


正常的思路是这样的,手指落下的点即页脚:


QQ20230312-2.jpg


这样写起来更加简单,但是对于用户来说,可操作的区域比较小,相对来说有点难用。


另外一种思路就是,手指落下的点即到底部同等距离的边:


QQ20230312-4.jpg


即手指落位的位置到当前页页脚距离 = 翻动的位置到当前页脚的距离


使用这种方式的好处就是用户可以操作的区域更大,翻书的感觉跟翻实体书的感觉更类似,也更加跟手。


总结


这篇文章就讲到这了,这个 Demo 其实是一个半成品,还有一些手势没处理,阴影的展示还有一些问题。


写仿真比较难的地方在于将一些场景转化成代码,有些地方确实很难去想。


talk is cheap, show me code:


仓库地址:github.com/mCyp/Double…


如果觉得本文不错,点赞是对本文最好的肯定,如果你还有任何问题,欢迎评论区讨论!


作者:九心
来源:juejin.cn/post/7209625823581978680
收起阅读 »

抛弃trycatch,用go的思想去处理js异常

web
errors 错误处理在编程中是不可避免的一部分,在程序开发过程中,不可必要的会出现各种的错误,是人为也可能是失误,任何不可预料的可能都会发生 为了更好的保证程序的健壮性和稳定性 我们必须要对错误处理有更好的认识 最近迷上了golang的错误处理哲学,希望由浅...
继续阅读 »

errors


错误处理在编程中是不可避免的一部分,在程序开发过程中,不可必要的会出现各种的错误,是人为也可能是失误,任何不可预料的可能都会发生


为了更好的保证程序的健壮性和稳定性


我们必须要对错误处理有更好的认识


最近迷上了golang的错误处理哲学,希望由浅入深的总结一下自己的思考和看得见的思考


👀 用 error 表示可能出现的错误,用throw强制抛出错误


通常情况下 错误处理的方式无非不过两种



  • 泛处理

  • 精处理


其实都很好理解


// 伪代码

try {
const file = await readFile('../file')

const content = filterContent(file)

} catch (e) {
alert('xxxx',e.msg)
}

泛处理指的是对所有可能的错误都使用相同的处理方式,比如在代码中使用相同统一的错误提示信息


这种方式适用于一些简单的,不太可能发生的错误,不如文件不存在,网络连接超时等。


对于更加复杂的错误,应该使用精处理,即根据具体情况对不同类型的错误进行特定的处理



const [readErr,readFile] = await readFile('../file')

if (readErr) {
// 处理读取错误
return
}

const [filterErr,filterContent] = filterContent(readFile)

if (filterErr) {
// 处理过滤错误
return
}

精处理可以让我们把控计划的每一步,问题也很显然暴露了出来,过于麻烦


在实际开发当中,我们需要根据实际情况选择适当的方式进行错误处理。


由于本人是精处理分子,基于此开发了一个js版本的errors


如何定义一个错误


import { Errors } from '@memo28/utils'

Errors.News('err')

如何给一个错误分类


import { Errors } from '@memo28/utils'

Errors.News('err', { classify: 1 })

如何判断两个错误是相同类型


import { Errors } from '@memo28/utils'

Errors.As(Errors.News('err', { classify: 1 }),
Errors.News('err2', { classify: 1 })) // true

Errors.As(Errors.News('err', { classify: 1 }),
Errors.News('err2', { classify: 2 })) // false

一个错误包含了哪些信息?


Errors.New('as').info() // { msg: 'as' , classify: undefined }

Errors.New('as').trace() // 打印调用栈

Errors.New('as').unWrap() // 'as'

最佳实践


import { AnomalousChain, panicProcessing } from '@memo28/utils'

class A extends AnomalousChain {
// 定义错误处理函数,
// 当每次执行的被 panicProcessing 装饰器包装过的函数都会检查 是否存在 this.errors 是否为 ErrorsNewResult 类型
// 如果为 ErrorsNewResult 类型则 调用 panicProcessing的onError回调 和 skip函数
skip(errors: ErrorsNewResult | null): this {
console.log(errors?.info().msg)
return this
}

@panicProcessing()
addOne(): this {
console.log('run one')
super.setErrors(Errors.New('run one errors'))
return this
}

@panicProcessing({
// 在 skip 前执行
onError(error) {
console.log(error.unWrap())
},
// onRecover 从错误中恢复, 当返回的是true时 继续执行addTwo内逻辑,反之
// onRecover(erros) {
// return true
// },
})
addTwo(): this {
console.log('run two')
return this
}
}
new A().addOne().addTwo()



// output
run one
run one errors // in onError
run one errors // in skip fn

复制代码
作者:我开心比苦恼多
来源:juejin.cn/post/7207707775774031930
>
收起阅读 »

为什么是14px?

web
字号与体验 肉眼到物体之间的距离,物体的高度以及这个三角形的角度,构成了一个三角函数的关系。 h=2d*tan(a/2) 而公式中的 h 的值和我们要解决的核心问题『主字号』有着很大的关系。 关于这个 a 的角度,有机构和团队做过研究,当大于 0.3 度时的...
继续阅读 »

字号与体验


1609983492683.png


肉眼到物体之间的距离,物体的高度以及这个三角形的角度,构成了一个三角函数的关系。


h=2d*tan(a/2)


而公式中的 h 的值和我们要解决的核心问题『主字号』有着很大的关系。


关于这个 a 的角度,有机构和团队做过研究,当大于 0.3 度时的阅读效率是最好的。


同时我们在操作电脑时,一般来说眼睛距离电脑屏幕的平均值大概会在 50 厘米左右。


然而,公式中的距离和高度的单位都是厘米,字体的单位是 pixel。


因此我们还需要将二者之间做一轮转换,完成转换所需的两个数值分别是 2.54(cm 到 inch)和 PPI(inch 到 pixel)。           


*PPI(Pixels Per Inch):像素密度,所表示的是每英寸所拥有的像素数量。


公式表达为_ppi_=√(x2+y2)/z(x:长度像素数;y:宽度像素数;z:屏幕大小)  


我们假定 PPI 为 120。通过计算便可以得出在显示器的 PPI 为 120 的情况下,理论上大于 12px 的字体能够满足用户的最佳阅读效率。基于这样的思路,确定主流 PPI 的范围,就很容易锁定主字体的大小了。


根据网络上的数据来源,我们发现只有大约 37.6% 的显示器 PPI 是小于 120 的,而 PPI 在 120-140 的显示器的占比大约为 40%。换句话说 12px 的字体只能满足 37.6% 用户的阅读体验,但如果我们将字体放大到 14px,就可以保证大约 77% 的显示器用户处于比较好的阅读体验。


作者:IDuxFE
来源:juejin.cn/post/7209967260899147834
收起阅读 »

从零开始构建用户模块:前端开发实践

web
场景 在大多数前端应用中都会有自己的用户模块,对于前端应用中的用户模块来说,需要从多个方面功能考虑,以掘金为例,可能需要下面这些功能: 多种登录方式,账号密码,手机验证码,第三方登录等 展示类信息,用户头像、用户名、个人介绍等 用户权限控制,可能需要附带角色...
继续阅读 »

场景


在大多数前端应用中都会有自己的用户模块,对于前端应用中的用户模块来说,需要从多个方面功能考虑,以掘金为例,可能需要下面这些功能:



  1. 多种登录方式,账号密码,手机验证码,第三方登录等

  2. 展示类信息,用户头像、用户名、个人介绍等

  3. 用户权限控制,可能需要附带角色信息等

  4. 发起请求可能还需要带上token等


接下来我们来一步步实现一个简单的用户模块


需求分析


用户模型


针对这些需求我们可以列出一个用户模型,包括下面这些参数


展示信息:



  • username 用户名

  • avatar 头像

  • introduction 个人介绍


角色信息:



  • role


鉴权:




  • token




这个user模型对于前端应用来说应该是全局唯一的,我们这里可以用singleton,标注为全局单例


import { singleton } from '@clean-js/presenter';

@singleton()
export class User {
username = '';
avatar = '';
introduction = '';

role = 'member';
token = '';

init(data: Partial<Omit<User, 'init'>>) {
Object.assign(this, data);
}
}

用户服务


接着可以针对我们的用户场景来构建用户服务类。


如下面这个UserService:



  • 注入了全局单例的User

  • loginWithMobile 提供了手机号验证码登录方法,这里我们用一个mock代码来模拟请求登录

  • updateUserInfo 用来获取用户信息,如头像,用户名之类的。从后端拉取信息之后我们会更新单例User


import { injectable } from '@clean-js/presenter';
import { User } from '../entity/user';


@injectable()
export class UserService {
constructor(private user: User) {}


/**
* 手机号验证码登录
*/

loginWithMobile(mobile: string, code: string) {
// mock 请求接口登录
return new Promise((resolve) => {
setTimeout(() => {
this.user.init({
token: 'abcdefg',
});


resolve(true);
}, 1000);
});
}


updateUserInfo() {
// mock 请求接口登录
return new Promise<User>((resolve) => {
setTimeout(() => {
this.user.init({
avatar:
'https://p3-passport.byteimg.com/img/user-avatar/2245576e2112372252f4fbd62c7c9014~180x180.awebp',
introduction: '欢乐堡什么都有,唯独没有欢乐',
username: '鱼露',
role: 'member',
});


resolve(this.user);
}, 1000);
});
}
}

界面状态


我们以登录界面和个人中心页面为例


登录界面


在登录界面需要这些页面状态和方法


View State:



  • loading: boolean; 页面loading

  • mobile: string; 输入手机号

  • code: string; 输入验证码


methods:



  • showLoading

  • hideLoading

  • login


import { history } from 'umi';
import { UserService } from '@/module/user/service/user';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { injectable, Presenter } from '@clean-js/presenter';
import { usePresenter } from '@clean-js/react-presenter';
import { Button, Form, Input, message, Space } from 'antd';

interface IViewState {
loading: boolean;
mobile: string;
code: string;
}
@injectable()
class PagePresenter extends Presenter<IViewState> {
constructor(private userService: UserService) {
super();
this.state = {
loading: false,
mobile: '',
code: '',
};
}

_loadingCount = 0;

showLoading() {
if (this._loadingCount === 0) {
this.setState((s) => {
s.loading = true;
});
}
this._loadingCount += 1;
}

hideLoading() {
this._loadingCount -= 1;
if (this._loadingCount === 0) {
this.setState((s) => {
s.loading = false;
});
}
}

login = () => {
const { mobile, code } = this.state;
this.showLoading();
return this.userService
.loginWithMobile(mobile, code)
.then((res) => {
if (res) {
message.success('登录成功');
}
})
.finally(() => {
this.hideLoading();
});
};
}

export default function LoginPage() {
const { p } = usePresenter(PagePresenter);

return (
<div>
<Form
name="normal_login"
initialValues={{ email: 'admin@admin.com', password: 'admin' }}
onFinish={() => {
console.log(p, '==p');
p.login().then(() => {
setTimeout(() => {
history.push('/profile');
}, 1000);
});
}}
>
<Form.Item
name="email"
rules={[{ required: true, message: 'Please input your email!' }]}
>
<Input
prefix={<UserOutlined className="site-form-item-icon" />}
placeholder="email"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please input your Password!' }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="Password"
/>
</Form.Item>

<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
className="login-form-button"
>
Log in
</Button>
</Space>
</Form.Item>
</Form>
</div>
);
}

如上代码所示,一个登录页面就完成了,接下来我们实现一下个人中心页面


个人中心


import { UserService } from '@/module/user/service/user';
import { injectable, Presenter } from '@clean-js/presenter';
import { usePresenter } from '@clean-js/react-presenter';
import { Image, Spin } from 'antd';
import { useEffect } from 'react';

interface IViewState {
loading: boolean;
username: string;
avatar: string;
introduction: string;
}

@injectable()
class PagePresenter extends Presenter<IViewState> {
constructor(private userS: UserService) {
super();
this.state = {
loading: false,
username: '',
avatar: '',
introduction: '',
};
}

_loadingCount = 0;

showLoading() {
if (this._loadingCount === 0) {
this.setState((s) => {
s.loading = true;
});
}
this._loadingCount += 1;
}

hideLoading() {
this._loadingCount -= 1;
if (this._loadingCount === 0) {
this.setState((s) => {
s.loading = false;
});
}
}

/**
* 拉取用户信息
*/

getUserInfo() {
this.showLoading();
this.userS
.updateUserInfo()
.then((u) => {
this.setState((s) => {
s.avatar = u.avatar;
s.username = u.username;
s.introduction = u.introduction;
});
})
.finally(() => {
this.hideLoading();
});
}
}
const ProfilePage = () => {
const { p } = usePresenter(PagePresenter);

useEffect(() => {
p.getUserInfo();
}, []);

return (
<Spin spinning={p.state.loading}>
<p>
avatar: <Image src={p.state.avatar} width={100} alt="avatar"></Image>
</p>
<p>username: {p.state.username}</p>
<p>introduction: {p.state.introduction}</p>
</Spin>

);
};

export default ProfilePage;

在这个ProfilePage中,我们初始化时会执行p.getUserInfo();


期间会切换loading的状态,并映射到页面的Spin组件中,执行完成后,更新页面的用户信息,用于展示


总结


至此,一个简单的用户模块就实现啦,整个用户模块以及页面的依赖关系可以查看下面这个UML图,



状态库仓库

仓库


各位大佬,记得一键三连,给个star,谢谢



作者:鱼露
来源:juejin.cn/post/7208818303673679933
收起阅读 »

玩转Canvas——给坤坤变个颜色

web
Canvas可以绘制出强大的效果,让我们给坤坤换个色。 先看看效果图: 要怎么实现这样一个可以点击换色的效果呢? 话不多说,入正题。 第一步,创建基本元素,无须多言: <body> <canvas></canvas&...
继续阅读 »

Canvas可以绘制出强大的效果,让我们给坤坤换个色。


先看看效果图:





要怎么实现这样一个可以点击换色的效果呢?
话不多说,入正题。


第一步,创建基本元素,无须多言:


<body>
<canvas></canvas>
</body>

我们先加载坤坤的图片,然后给canvas添加基础事件:


const cvs = document.querySelector('canvas');
const ctx = cvs.getContext('2d', {
willReadFrequently: true
});
//加载图片并绘制
const img = new Image();
img.src = './img/cxk.png';
img.onload = () => {
cvs.width = img.width;
cvs.height = img.height;
ctx.drawImage(img, 0, 0);
};

再给canvas注册一个点击事件:


//监听canvas点击
cvs.addEventListener('click', clickCb);

function clickCb(e) {
const x = e.offsetX;
const y = e.offsetY;
}

这样就拿到了点击的坐标,接下来的问题是,我们要如何拿到点击坐标的颜色值呢?


其实,canvas早就给我们提供了一个强大的api:getImageData


我们可以通过它获取整个canvas上每个像素点的颜色信息,一个像素点对应四个值,分别为rgba


ctx.getImageData返回数据结构如下:





所以我们便可以利用它拿到点击坐标的颜色值:


function clickCb(e) {
//省略之前代码
//...

const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height);
//获取点击坐标的rgba信息
const clickRgba = getColor(x, y, imgData.data);
}

//通过坐标获取rgba数组
function getColor(x, y, data) {
const i = getIndex(x, y);
return [data[i], data[i + 1], data[i + 2], data[i + 3]];
}

//通过坐标x,y获取对应imgData数组的索引
function getIndex(x, y) {
return (y * cvs.width + x) * 4;
}

接下来便是在点击处绘制我们的颜色值:


//为了方便,这里将变色值写死为原谅绿
const colorRgba = [0, 255, 0, 255];

function clickCb(e) {
//省略之前代码
//...

//坐标变色
function changeColor(x, y, imgData) {
imgData.data.set(colorRgba, getIndex(x, y));
ctx.putImageData(imgData, 0, 0);
}
changeColor(x, y, imgData);
}

此时如果点击坤坤的头发,会发现头发上仅仅带一点绿。要如何才能绿得彻底呢?


我们新增一个判断rgba值变化幅度的方法getDeff,当两者颜色相差过大,则视为不同区域。


//简单地根据绝对值之和判断是否为同颜色区域
function getDiff(rgba1, rgba2) {
return (
Math.abs(rgba2[0] - rgba1[0]) +
Math.abs(rgba2[1] - rgba1[1]) +
Math.abs(rgba2[2] - rgba1[2]) +
Math.abs(rgba2[3] - rgba1[3])
);
}

再新增一个判断坐标是否需要变色的方法:


function clickCb(e) {
//省略之前代码
//...

//判断该坐标是否无需变色
function stopChange(x, y, imgData) {
if (x < 0 || y < 0 || x > cvs.width || y > cvs.height) {
//超出canvas边界
return true
}
const rgba = getColor(x, y, imgData.data);
if (getDiff(rgba, clickRgba) >= 100) {
//色值差距过大
return true;
}
if (getDiff(rgba, colorRgba) === 0) {
//同颜色,不用改
return true;
}
}
}

我们更改changeColor方法,接下来便可以绿得彻底了:


function clickCb(e) {
//省略之前代码
//...

//坐标变色
function changeColor(x, y, imgData) {
if (stopChange(x, y, imgData)) {
return
}
imgData.data.set(colorRgba, getIndex(x, y));
ctx.putImageData(imgData, 0, 0);
//递归变色
changeColor(x - 1, y, imgData);
changeColor(x + 1, y, imgData);
changeColor(x, y + 1, imgData);
changeColor(x, y - 1, imgData);
}

//省略之前代码
//...
}

效果已然实现。但是上面通过递归调用函数去变色,如果变色区域过大,可能会导致栈溢出报错。


为了解决这个问题,我们得改用循环实现了。


这一步的实现需要一定的想象力。读者可以自己试试,看看能不能改用循环方式实现出来。


鉴于循环实现的代码略多,这里不再解释,直接上最终代码:


<!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" />
<style>
.color-box {
margin-bottom: 20px;
}
.canvas-box {
text-align: center;
}
</style>
</head>
<body>
<div class="color-box">设置色值: <input type="color" /></div>
<div class="canvas-box">
<canvas></canvas>
</div>
<script>
let color = '#00ff00';
let colorRgba = getRGBAColor();
//hex转rgba数组
function getRGBAColor() {
const rgb = [color.slice(1, 3), color.slice(3, 5), color.slice(5)].map((item) =>
parseInt(item, 16)
);
return [...rgb, 255];
}

const input = document.querySelector('input[type=color]');
input.value = color;
input.addEventListener('change', function (e) {
color = e.target.value;
colorRgba = getRGBAColor();
});

const cvs = document.querySelector('canvas');
const ctx = cvs.getContext('2d', {
willReadFrequently: true,
});
cvs.addEventListener('click', clickCb);

const img = new Image();
img.src = './img/cxk.png';
img.onload = () => {
cvs.width = 240;
cvs.height = (cvs.width * img.height) / img.width;
//图片缩放
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, cvs.width, cvs.height);
};

function clickCb(e) {
let x = e.offsetX;
let y = e.offsetY;
const pointMark = {};

const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height);
const clickRgba = getColor(x, y, imgData.data);
//坐标变色
function changeColor(x, y, imgData) {
imgData.data.set(colorRgba, getIndex(x, y));
ctx.putImageData(imgData, 0, 0);
markChange(x, y);
}

//判断该坐标是否无需变色
function stopChange(x, y, imgData) {
const rgba = getColor(x, y, imgData.data);
if (getDiff(rgba, clickRgba) >= 150) {
//色值差距过大
return true;
}
if (getDiff(rgba, colorRgba) === 0) {
//同颜色,不用改
return true;
}
if (hasChange(x, y)) {
//已变色
return true;
}
}
function hasChange(x, y) {
const pointKey = `${x}-${y}`;
return pointMark[pointKey];
}
function markChange(x, y) {
const pointKey = `${x}-${y}`;
pointMark[pointKey] = true;
}
//添加上下左右方向的点到等待变色的点数组
function addSurroundingPoint(x, y) {
if (y > 0) {
addWaitPoint(`${x}-${y - 1}`);
}
if (y < cvs.height - 1) {
addWaitPoint(`${x}-${y + 1}`);
}
if (x > 0) {
addWaitPoint(`${x - 1}-${y}`);
}
if (x < cvs.width - 1) {
addWaitPoint(`${x + 1}-${y}`);
}
}
function addWaitPoint(key) {
waitPoint[key] = true;
}
function deleteWaitPoint(key) {
delete waitPoint[key];
}
//本轮等待变色的点
const waitPoint = {
[`${x}-${y}`]: true,
};
while (Object.keys(waitPoint).length) {
const pointList = Object.keys(waitPoint);
for (let i = 0; i < pointList.length; i++) {
const key = pointList[i];
const list = key.split('-');
const x1 = +list[0];
const y1 = +list[1];

if (stopChange(x1, y1, imgData)) {
deleteWaitPoint(key);
continue;
}
changeColor(x1, y1, imgData);
deleteWaitPoint(key);
addSurroundingPoint(x1, y1);
}
}
}

//通过坐标x,y获取对应imgData数组的索引
function getIndex(x, y) {
return (y * cvs.width + x) * 4;
}

//通过坐标获取rgba数组
function getColor(x, y, data) {
const i = getIndex(x, y);
return [data[i], data[i + 1], data[i + 2], data[i + 3]];
}

////简单地根据绝对值之和判断是否为同颜色区域
function getDiff(rgba1, rgba2) {
return (
Math.abs(rgba2[0] - rgba1[0]) +
Math.abs(rgba2[1] - rgba1[1]) +
Math.abs(rgba2[2] - rgba1[2]) +
Math.abs(rgba2[3] - rgba1[3])
);
}
</script>
</body>
</html>

若有疑问,欢迎评论区讨论。


完整demo地址:canvas-change-color


作者:TRIS
来源:juejin.cn/post/7209226686372937789
收起阅读 »

自从学会这几个写代码的技巧后,摸鱼时间又长了!!!

web
嘿!👋 今天,我们将介绍 5 个 JavaScript 自定义的实用函数,它们可以在您的大多数项目中派上用场。 目录 01.console.log() 02.querySelector() 03.addEventListener() 04.random() ...
继续阅读 »

嘿!👋


今天,我们将介绍 5 个 JavaScript 自定义的实用函数,它们可以在您的大多数项目中派上用场。


目录



  • 01.console.log()

  • 02.querySelector()

  • 03.addEventListener()

  • 04.random()

  • 05.times()




01.console.log()


在项目的调试阶段,我们都很喜欢用console.log()来打印我们的值、来进行调试。那么,我们可不可以缩短它,以减少我们的拼写方式,并且节省一些时间呢?


//把`log`从`console.log`中解构出来
const { log } = console;

//log等同于console.log

log("Hello world!");
// 输出: Hello world!

// 等同于 //

console.log("Hello world!");
// 输出: Hello world!

说明:


我们使用解构赋值logconsole.log中解构出来




02.querySelector()


在使用 JavaScript 时,当我们对DOM进行操作时经常会使用querySelector()来获取DOM元素,原生的获取DOM的操作过程写起来又长阅读性又差,那我们能不能让他使用起来更加简单,代码看起来更加简洁呢?


//把获取DOM元素的操作封装成一个函数
const select = (selector, scope = document) => {
return scope.querySelector(selector);
};

//使用
const title = select("h1");
const className = select(".class");
const message = select("#message", formElem);

// 等同于 //

const title = document.querySelector("h1");
const className = document.querySelector(".class");
const message = formElem.querySelector("#message");

说明:


我们在select()函数需要接收 2 个参数:



  • 第一个:您要选择的DOM元素

  • 第二:您访问该元素的范围(默认 = document);




03.addEventListener()


对click、mousemove等事件的处理大多是通过addEventListener()方法实现的。原生实现的方法写起来又长阅读性又差,那我们能不能让他使用起来更加简单,代码看起来更加简洁呢?


const listen = (target, event, callback, ...options) => {
return target.addEventListener(event, callback, ...options);
};

//监听buttonElem元素点击事件,点击按钮打印Clicked!
listen(buttonElem, "click", () => console.log("Clicked!"));

//监听document的鼠标移上事件,当鼠标移动到document上时打印Mouse over!
listen(document, "mouseover", () => console.log("Mouse over!"));

//监听formElem上的submit事件,当提交时打印Form submitted!
listen(formElem, "submit", () => {
console.log("Form submitted!");
}, { once: true }
);

说明:


我们在listen()函数需要接收 4 个参数:



  • 第一个:你要定位的元素(例如“窗口”、“文档”或特定的 DOM 元素)

  • 第二:事件类型(例如“点击”、“提交”、“DOMContentLoaded”等)

  • 第三:回调函数

  • 第 4 个:剩余的可选选项(例如“捕获”、“一次”等)。此外,如有必要,我们使用传播语法来允许其他选项。否则,它可以像在addEventListener方法中一样被省略。




04. random()


你可能知道Math.random()是可以随机生成从 0 到 1 的函数。例如Math.random() * 10,它可以生成从 0 到 10 的随机数。但是,问题是尽管我们知道生成数字的范围,我们还是无法控制随机数的具体范围的。那我们要怎么样才能控制我们生成随机数的具体范围呢?


const random = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};

random(5, 10);
// 5/6/7/8/9/10

这个例子返回了一个在指定值之间的随机数。这个值大于于 min(有可能等于),并且小于(有可能等于)max




05. times()


有时,我们经常会有运行某个特定功能或者函数的需求。应对这种需求,我们可以使用setInterval()每间隔一段时间运行一次:


setInterval(() => {
randomFunction();
}, 5000); // runs every 5 seconds

但是问题是setInterval()是无限循环的,当我们有限定运行次数要求时,我们无法指定它要运行它的次数。所以,让我们来解决它!


const times = (func, n) => {
Array.from(Array(n)).forEach(() => {
func();
});
};

times(() => {
randomFunction();
}, 3); // runs 3 times

解释:



  • Array(n)- 创建一个长度为n的数组.


Array(5); // => [,,]


  • Array.from()- 从创建一个浅拷贝的Array(n)数组。它可以帮助对数组进行操作,并用“undefined”填充数组里面的值。您也可以使用Array.prototype.fill()方法获得相同的结果。


Array.from(Array(3)); // => [undefined,undefined,undefined]


注意: 在封装这个函数时,我发现到有些程序员更喜欢先传参数n,再传函数times(n, func)。但是,我觉得有点奇怪,所以我决定交换它们的位置,从而使语法更类似于函数setInterval()



setInterval(func, delay);

times(func, n);

此外,您还可以可以使用setTimes()来代替times()。来代替setInterval()``setTimeout()的功能




写在最后


伙伴们,如果你觉得我写的文章对你有帮助就给zayyo点一个赞👍或者关注➕都是对我最大的支持。当然你也可以加我微信:IsZhangjianhao,邀你进我的前端学习交流群,一起学习前端,成为更优秀的工程

作者:zayyo
来源:juejin.cn/post/7209861267715817509
师~


收起阅读 »

vue为什么v-for的优先级比v-if的高?

web
前言 有时候有些面试中经常会问到v-for与v-if谁的优先级高,这里就通过分析源码去解答一下这个问题。 下面的内容是在 当我们谈及v-model,我们在讨论什么?的基础上分析的,所以阅读下面内容之前可先看这篇文章。 继续从编译出发 以下面的例子出发分析: n...
继续阅读 »

前言


有时候有些面试中经常会问到v-forv-if谁的优先级高,这里就通过分析源码去解答一下这个问题。


下面的内容是在 当我们谈及v-model,我们在讨论什么?的基础上分析的,所以阅读下面内容之前可先看这篇文章。


继续从编译出发


以下面的例子出发分析:


new Vue({
el:'#app',
template:`
<ul>
<li v-for="(item,index) in items" v-if="index!==0">
{{item}}
</li>
</ul>
`

})

从上篇文章可以知道,编译有三个步骤



  • parse : 解析模板字符串生成 AST语法树

  • optimize : 优化语法树,主要时标记静态节点,提高更新页面的性能

  • codegen : 生成js代码,主要是render函数和staticRenderFns函数


我们再次顺着这三个步骤对上述例子进行分析。


parse


parse过程中,会对模板使用大量的正则表达式去进行解析。开头的例子会被解析成以下AST节点:


// 其实ast有很多属性,我这里只展示涉及到分析的属性
ast = {
'type': 1,
'tag': 'ul',
'attrsList': [],
attrsMap: {},
'children': [{
'type': 1,
'tag': 'li',
'attrsList': [],
'attrsMap': {
'v-for': '(item,index) in data',
'v-if': 'index!==0'
},
// v-if解析出来的属性
'if': 'index!==0',
'ifConditions': [{
'exp': 'index!==0',
'block': // 指向el自身
}],
// v-for解析出来的属性
'for': 'items',
'alias': 'item',
'iterator1': 'index',

'parent': // 指向其父节点
'children': [
'type': 2,
'expression': '_s(item)'
'text': '{{item}}',
'tokens': [
{'@binding':'item'},
]
]
}]
}

对于v-for指令,除了记录在attrsMapattrsList,还会新增for(对应要遍历的对象或数组),aliasiterator1,iterator2对应v-for指令绑定内容中的第一,第二,第三个参数,开头的例子没有第三个参数,因此没有iterator2属性。


对于v-if指令,把v-if指令中绑定的内容取出放在if中,与此同时初始化ifConditions属性为数组,然后往里面存放对象:{exp,block},其中exp存放v-if指令中绑定的内容,block指向el


optimize 过程在此不做分析,因为本例子没有静态节点。


codegen


上一篇文章从const code = generate(ast, options)开始分析过其生成代码的过程,generate内部会调用genElement用来解析el,也就是AST语法树。我们来看一下genElement的源码:


export function genElement (el: ASTElement, state: CodegenState): string {
if (el.parent) {
el.pre = el.pre || el.parent.pre
}

if (el.staticRoot && !el.staticProcessed) {
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) {
return genOnce(el, state)
// 其实从此处可以初步知道为什么v-for优先级比v-if高,
// 因为解析ast树生成渲染函数代码时,会先解析ast树中涉及到v-for的属性
// 然后再解析ast树中涉及到v-if的属性
// 而且genFor在会把el.forProcessed置为true,防止重复解析v-for相关属性
} else if (el.for && !el.forProcessed) {
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)

} else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
return genSlot(el, state)
} else {
// component or element
let code
if (el.component) {
code = genComponent(el.component, el, state)
} else {
let data
if (!el.plain || (el.pre && state.maybeComponent(el))) {
data = genData(el, state)
}

const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c('${el.tag}'${ data ? `,${data}` : '' // data }${ children ? `,${children}` : '' // children })`
}
// module transforms
for (let i = 0; i < state.transforms.length; i++) {
code = state.transforms[i](el, code)
}
return code
}
}

接下来依次看看genForgenIf的函数源码:


export function genFor (el, state , altGen, altHelper) {
const exp = el.for
const alias = el.alias
const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''

el.forProcessed = true // avoid recursion
return `${altHelper || '_l'}((${exp}),` +
`function(${alias}${iterator1}${iterator2}){` +
`return ${(altGen || genElement)(el, state)}` + //递归调用genElement
'})'
}

在我们的例子里,当他处理liast树时,会先调用genElement,处理到for属性时,此时forProcessed为虚值,此时调用genFor处理li树中的v-for相关的属性。然后再调用genElement处理li树,此时因为forProcessedgenFor中已被标记为true。因此genFor不会被执行,继而执行genIf处理与v-if相关的属性。


export function genIf (el,state,altGen,altEmpty) {
el.ifProcessed = true // avoid recursion
// 调用genIfConditions主要处理el.ifConditions属性
return genIfConditions(el.ifConditions.slice(), state, altGen, altEmpty)
}

function genIfConditions (conditions, state, altGen, altEmpty) {
if (!conditions.length) {
return altEmpty || '_e()' // _e用于生成空VNode
}

const condition = conditions.shift()
if (condition.exp) { //condition.exp即v-if绑定值,例子中则为'index!==0'
// 生成一段带三目运算符的js代码字符串
return `(${condition.exp})?${ genTernaryExp(condition.block) }:${ genIfConditions(conditions, state, altGen, altEmpty) }`
} else {
return `${genTernaryExp(condition.block)}`
}

// v-if with v-once should generate code like (a)?_m(0):_m(1)
function genTernaryExp (el) {
return altGen
? altGen(el, state)
: el.once
? genOnce(el, state)
: genElement(el, state)
}
}

参考 前端进阶面试题详细解答


最后,经过codegen生成的js代码如下:


function render() {
with(this) {
return _c('ul', _l((items), function (item, index) {
return (index !== 0) ? _c('li') : _e()
}), 0)
}
}

其中:



  1. _c: 调用 createElement 去创建 VNode

  2. _l: renderList函数,主要用来渲染列表

  3. _e: createEmptyVNode函数,主要用来创建空VNode


总结


为什么v-for的优先级比v-if的高?总结来说是编译有三个过程,parse->optimize->codegen。在codegen过程中,会先解析AST树中的与v-for相关的属性,再解析与v-if相关的属性。除此之外,也可以知道Vuev-forv-if

作者:bb_xiaxia1998
来源:juejin.cn/post/7209950095402582072
de>是怎么处理的。

收起阅读 »

如何取消 script 标签发出的请求

web
问题 之前在业务上有这样一个场景,通过 script 标签动态引入了一个外部资源,具体方式是这样的 const script = document.createElement('script'); script.src = 'xxx'; script.asyn...
继续阅读 »

问题


之前在业务上有这样一个场景,通过 script 标签动态引入了一个外部资源,具体方式是这样的


const script = document.createElement('script');
script.src = 'xxx';
script.async = true;
document.body.appendChild(script);

最近发现在某些情况下需要取消这个请求,因此对取消script标签发出的请求的方法进行研究。


取消请求的几种方式


取消 XMLHttpRequest 请求


// 发送请求
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.send();
// 1s后取消请求的两种方法
// a. 设置超时时间属性,在 IE 中,超时属性可能只能在调用 open()方法之后且在调用 send()方法之前设置。
xhr.timeout = 1000;
// b. 利用abort方法
setTimeout(() => {
xhr.abort();
}, 1000);

取消 fetch 请求


fetch请求的取消主要依赖于AbortController对象,当fetch 请求初始化时,我们将 AbortSignal 作为一个选项传递进入请求的选项对象中(下面的 {signal})。


const controller = new AbortController();
fetch(url, { signal: controller.signal });
// 1s后取消请求
setTimeout(() => {
controller.abort();
}, 1000);

取消 axios 请求


取消 axios 请求同样依赖于 AbortController 对象。


const controller = new AbortController();
axios.get(url, { signal: controller.signal });
// 1s后取消请求
setTimeout(() => {
controller.abort();
}, 1000);

取消使用script标签


通过对网上的资料进行整理,并没有发现直接取消 script 标签发起的请
求的方法。并且当请求发出后对 script 进行的操作(如删除 dom 节点)也不会造成影响。那么能不能将 script 发起的请求改为使用以上三种方法之一来实现呢?


改为 fetch 方法


我首先尝试了 fetch 方法。通过使用 fetch 方法对网址进行请求,我发现请求得到的类型是一个 ReadableStream 对象。
image.png
MDN上提供了一种方法可以获取到 ReadableStream 对象中的内容:


fetch('https://www.example.org')
.then((response) => response.body)
.then((rb) => {
const reader = rb.getReader();
return new ReadableStream({
start(controller) {
// The following function handles each data chunk
function push() {
// "done" is a Boolean and value a "Uint8Array"
reader.read().then(({ done, value }) => {
// If there is no more data to read
if (done) {
console.log('done', done);
controller.close();
return;
}
// Get the data and send it to the browser via the controller
controller.enqueue(value);
// Check chunks by logging to the console
console.log(done, value);
push();
});
}
push();
},
});
})
.then((stream) =>
// Respond with our stream
new Response(stream, { headers: { 'Content-Type': 'text/html' } }).text()
)
.then((result) => {
// Do things with result
console.log(result);
});

使用这种方法我就通过 fetch 方法获取到了原来 script 标签请求的内容,也就可以使用 AbortController 来控制请求的取消。


改为 XMLHttpRequest 方法


尝试使用 fetch 方法解决问题之后,我又对 XMLHttpRequest 进行了尝试,发现这种方法更加简便,获取的请求内包含一个 responseText 字段就是我需要的内容,并且在请求未成功或尚未发送的情况下这个值为 null ,也就更方便进行请求是否成功的判断。


结论


对于 script 标签发出的请求我们无法取消,但是我们可以通过其他的方法来达到 script 标签的效果,因为 XMLHttpRequest 已经足够简便,我就没有对 axios 进行尝试,相信也肯定可以达到同样的目标,有兴趣的同学可以尝试一下。


作者:Maaarch
来源:juejin.cn/post/7208092574162419770
收起阅读 »

如何避免使用过多的 if else?

web
一、引言 相信大家听说过回调地狱——回调函数层层嵌套,极大降低代码可读性。其实,if-else层层嵌套,如下图所示,也会形成类似回调地狱的情况。 当业务比较复杂,判断条件比较多,项目进度比较赶时,特别容易使用过多if-else。其弊端挺多的,如代码可读性差、...
继续阅读 »

一、引言


相信大家听说过回调地狱——回调函数层层嵌套,极大降低代码可读性。其实,if-else层层嵌套,如下图所示,也会形成类似回调地狱的情况。


image.png


当业务比较复杂,判断条件比较多,项目进度比较赶时,特别容易使用过多if-else。其弊端挺多的,如代码可读性差、代码混乱、复杂度高、影响开发效率、维护成本高等。


因此,我们在日常编码时,有必要采取一些措施避免这些问题。本文的初衷不是建议大家完全不用if-else,而是希望我们能够在学会更多解决方案后更优雅地编码。


R-C.gif




二、8种if-else的优化/替代方案


1. 使用排非策略:!、!!


逻辑非(logic NOT),是逻辑运算中的一种,就是指本来值的反值。


当你想这么写时……


1、判断是否为空
if(value === null || value === NaN || value === 0 || value === ''|| value === undefined )
{
……
}

2、判断是否数组是否含有符合某条件的元素
const name = arr.find(item => item.status === 'error')?.name;
if(name !== undefined && name !== ''){
……
}

不妨尝试这么写:


1、判断是否为空
if(!value){……}

2、判断是否数组是否含有符合某条件的元素
if(!!arr.find(item => item.status === 'error')?.name){……}



2. 使用条件(三元)运算符: c ? t : f


三元运算符:
condition ? exprIfTrue : exprIfFalse;
如果条件为真值,则执行冒号(:)前的表达式;若条件为假值,则执行最后的表达式。


当你想这么写时……


let beverage = '';
if(age > 20){
beverage = 'beer';
} else {
beverage = 'juice';
}

不妨尝试这么写:


const beverage = age > 20 ? 'beer' : 'juice';

tips: 建议只用一层三元运算符,多层嵌套可读性差。




3. 使用短路运算符:&&||



  • && 为取假运算,从左到右依次判断,如果遇到一个假值,就返回假值,以后不再执行,否则返回最后一个真值;

  • || 为取真运算,从左到右依次判断,如果遇到一个真值,就返回真值,以后不再执行,否则返回最后一个假值。


当你想这么写时……


    if (isOnline){
makeReservation(user);
}

不妨尝试这么写:


 isOnline && makeReservation(user);



4. 使用 switch 语句


当你想这么写时……


    let result;
if (type === 'add'){
result = a + b;
} else if(type === 'subtract'){
result = a - b;
} else if(type === 'multiply'){
result = a * b;
} else if(type === 'divide'){
result = a / b;
} else {
console.log('Calculation is not recognized');
}

不妨尝试这么写:


let result;
switch (type) {
case 'add':
result = a + b;
break;
case 'subtract':
result = a - b;
break;
case 'multiply':
result = a * b;
break;
case 'divide':
result = a / b;
break;
default:
console.log('Calculation is not recognized');
}

个人认为,对于这类比较简单的判断,用switch语句虽然不会减少代码量,但是会更清晰喔。




5. 定义相关函数拆分逻辑,简化代码


当你想这么写时……


function itemDropped(item, location) {
if (!item) {
return false;
} else if (outOfBounds(location) {
var error = outOfBounds;
server.notify(item, error);
items.resetAll();
return false;
} else {
animateCanvas();
server.notify(item, location);
return true;
}
}

不妨尝试这么写:


// 定义dropOut和dropIn, 拆分逻辑并提高代码可读性
function itemDropped(item, location) {
const dropOut = function () {
server.notify(item, outOfBounds);
items.resetAll();
return false;
};

const dropIn = function () {
animateCanvas();
server.notify(item, location);
return true;
};

return !!item && (outOfBounds(location) ? dropOut() : dropIn());
}

细心的朋友会发现,在这个例子中,同时使用了前文提及的优化方案。这说明我们在编码时可以根据实际情况混合使用多种解决方案。




6. 将函数定义为对象,通过穷举查找对应的处理方法




  • 定义普通对象


    对于方案3的例子,不妨尝试这么写:




function calculate(action, num1, num2) {
 const actions = {
   add: (a, b) => a + b,
   subtract: (a, b) => a - b,
   multiply: (a, b) => a * b,
   divide: (a, b) => a / b,
};

 return actions[action]?.(num1, num2) ?? "Calculation is not recognized";
}



  • 定义 Map 对象


    普通对象的键需要是字符串,而 Map 对象的键可以是一个对象、数组或者更多类型,更加灵活。




let statusMap = new Map([
[
{ role: "打工人", status: "1" },
() => { /*一些操作*/},
],
[
{ role: "打工人", status: "2" },
() => { /*一些操作*/},
],
[
{ role: "老板娘", status: "1" },
() => { /*一些操作*/},
],
]);

let getStatus = function (role, status) {
statusMap.forEach((value, key) => {
if (JSON.stringify(key) === JSON.stringify({ role, status })) {
value();
}
});
};

getStatus("打工人", "1"); // 一些操作

tips: JSON.stringify()可用于深比较/深拷贝。




7. 使用责任链模式


责任链模式:将整个处理的逻辑改写成一条责任传递链,请求在这条链上传递,直到有一个对象处理这个请求。


例如 JS 中的事件冒泡


简单来说,事件冒泡就是在一个对象上绑定事件,如果定义了事件的处理程序,就会调用处理程序。相反没有定义的话,这个事件会向对象的父级传播,直到事件被执行,最后到达最外层,document对象上。


image.png


这意味着,在这种模式下,总会有程序处理该事件。

再举个🌰,当你想这么写时……


function demo (a, b, c) {
if (f(a, b, c)) {
if (g(a, b, c)) {
// ...
}
// ...
else if (h(a, b, c)) {
// ...
}
// ...
} else if (j(a, b, c)) {
// ...
} else if (k(a, b, c)) {
// ...
}
}

不妨参考这种写法:


const rules = [
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
},
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
},
{
match: function (a, b, c) { /* ... */ },
action: function (a, b, c) { /* ... */ }
}
// ...
]

// 每个职责一旦匹配,原函数就会直接返回。
function demo (a, b, c) {
for (let i = 0; i < rules.length; i++) {
if (rules[i].match(a, b, c)) {
return rules[i].action(a, b, c)
}
}
}


引申话题——如何降低if else代码的复杂度?


相关文章阅读: 如何无痛降低 if else 面条代码复杂度 建议多读几次!!!





8. 策略模式+工厂方法


因为此法比较复杂,此文暂时不做详细介绍。


详细可参考文章优化方案 8 if-else 代码优化的八种方案


三、小结


本文粗略介绍了8种优化/替代if-else的方法,希望能给你日常编码带来一些启示😄。


正如开头所说,我们的目的不是消灭代码中的if-else,而是让我们在学会更多解决方案的基础上,根据实际情况选择更优的编码方式。因此,当你发现自己的代码里面存在特别多的if-else或当你想用if-else时,不妨停下来思考一下——如何能写得更优雅、更方便日后维护呢


image.png


四、参考与感谢



  1. 优化 JS 中过多的使用 IF 语句

  2. 短路运算符(逻辑与&& 和 逻辑或||)

  3. 如何对多个 if-else 判断进行优化

  4. if-else 代码优化的八种方案

  5. 如何替换项目中的if-else和switch

  6. 如何无痛降低 if else 面条代码复杂度


作者:蓝瑟
来源:juejin.cn/post/7206529406613094460
收起阅读 »

听说你还不会使用Koa?

web
简介 Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没...
继续阅读 »

简介


Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。


简单来说,Koa也是一个web框架,但是比Express更轻量,并且有更好的异步机制。


本文适合有Koa基础,急需需要搭建项目的同学食用,如果对Koa完全不了解的建议先去看看Koa官方文档。


在讲Koa的使用之前,我们先来介绍一下非常出名的洋葱模型,这对后面代码的理解有很好的帮助


洋葱模型


前面我们在介绍Express的时候就说过了洋葱模型,如下图所示,Koa中的中间件执行机制也类似一个洋葱模型,只不过和Express还是有些许区别。


image.png


我们来看看Koa中的中间件是怎么样执行的。与Express不同的是在Koa中,next是支持异步的。


// 全局中间件
app.use(async (ctx, next) => {
console.log(
"start In comes a " + ctx.request.method + " to " + ctx.request.url
);
next();
console.log(
"end In comes a " + ctx.request.method + " to " + ctx.request.url
);
});

// 单独中间件
router.get(
"/select",
async (ctx, next) => {
console.log("我是单独中间件 start");
const result = await Promise.resolve(123);
console.log(result);
next();
console.log("我是单独中间件 end");
},
async (ctx) => {
console.log("send result start");
ctx.body = "get method!";
console.log("send result end");
}
);

看看上面的输出结果,可以看到它的执行顺序和Express是一样的。


image.png


前面说了,在Koanext是支持异步的。也就是可以await,我们添加await来测试下


// 全局中间件
app.use(async (ctx, next) => {
console.log(
"start In comes a " + ctx.request.method + " to " + ctx.request.url
);
await next();
console.log(
"end In comes a " + ctx.request.method + " to " + ctx.request.url
);
});

// 单独中间件
router.get(
"/select",
async (ctx, next) => {
console.log("我是单独中间件 start");
const result = await Promise.resolve(123);
console.log(result);
await next();
console.log("我是单独中间件 end");
},
async (ctx) => {
console.log("send result start");
ctx.body = "get method!";
console.log("send result end");
}
);

看看运行结果


image.png


可以看到,在Koa中,await会阻塞所有后续代码的执行,完全保证了按洋葱模型执行代码。以next为分水岭,先从前往后执行next前半部分代码,然后从后往前执行next下半部分代码。


Express中,next方法是不支持异步await的,这个是KoaExpress洋葱模型里面最大的一个区别。


创建应用


首先我们需要安装koa


npm i koa

然后引入使用


const Koa = require("koa");
const app = new Koa();

app.listen(3000, () => {
console.log("serve running on 3000");
});

这个和Express还是很相似的。


路由


Koa的路由和Express还是有差别的。Koaapp是不支持直接路由的,需要借助第三方插件koa-router


我们先来安装


npm i @koa/router

然后就可以引入使用了


// routes/user.js
const Router = require("@koa/router");
const router = new Router({ prefix: "/user" }); // 路由前缀

router.get("/select", (ctx) => {
ctx.body = "get";
});

router.post("/add", (ctx) => {
ctx.body = "post";
});

router.delete("/delete", (ctx) => {
ctx.body = "delete";
});

router.put("/update", (ctx) => {
ctx.body = "put";
});

// 所有请求都支持
router.all("/userall", (ctx) => {
ctx.body = "所有请求都可以?" + ctx.method;
});

// 重定向
router.get("/testredirect", (ctx) => {
ctx.redirect("/user/select");
});

module.exports = router;

然后在入口文件,引入路由并注册就可以使用了


const Koa = require("koa");
const app = new Koa();
const userRouter = require("./routes/user");

app.use(userRouter.routes()).use(userRouter.allowedMethods());

这样我们就可以通过localhost:3000/user/xxx来调用接口了。


自动注册路由


同样的,如果模块很多的话,我们还可以优化,通过fs模块读取文件,自动完成路由的注册。


// routes/index.js
const fs = require("fs");

// 批量注册路由
module.exports = (app) => {
fs.readdirSync(__dirname).forEach((file) => {
if (file === "index.js") {
return;
}
const route = require(`./${file}`);
app.use(route.routes()).use(route.allowedMethods());
});
};

在入口文件,我们可以通过该方法批量注册路由了


const registerRoute = require("./routes/index");
registerRoute(app);

这样我们就可以通过localhost:3000/模块路由前缀/xxx来调用接口了。


路由说完了,我们再来看看怎么获取参数


参数获取


参数的获取分为query、param、body三种形式


query参数


对于query参数,通过req.query获取


router.get("/", (ctx) => {
const query = ctx.query;
// const query = ctx.request.query; // 上面是简写形式
ctx.body = query;
});

参数能正常获取


image.png


我们再来看看路径参数


路径参数


对于路径参数,通过:变量定义,然后通过request.params获取。


router.get("/user2/:name/:age", (ctx) => {
// 路径参数获取
const params = ctx.params;
// const params = ctx.request.params; // 上面是简写形式
ctx.body = params
});

参数能正常获取


image.png


body参数


对于body参数,也就是请求体里面的参数,就需要借助koa-body插件。但是在新版的Express中已经自身支持了。


首先安装koa-body插件


npm i koa-body

然后在入口文件使用


const { koaBody } = require("koa-body");

app.use(koaBody());

然后通过ctx.request.body就可以获取到参数啦。


router.post("/", (ctx) => {
const body = ctx.request.body;
ctx.body = body;
});

设置完后,我们来测试下,参数正常获取。


image.png


文件上传


说完参数的获取,我们再来看看怎么处理文件上传。


koa中,对于文件上传也是借助koa-body插件,只需要在里面配置上传文件的参数即可。相较Express要简单很多。


app.use(
koaBody({
// 处理文件上传
multipart: true,
formidable: {
// 使用oss上传就注释 上传到本地就打开。路径必须事先存在
uploadDir: path.join(__dirname, "./uploads"),
keepExtensions: true,
},
})
);

配置好后,我们来测试一下


Express不同的是,不管是单文件还是多文件,都是通过ctx.request.files获取文件。


单文件上传


router.post("/file", (ctx) => {
const files = ctx.request.files;
ctx.body = files;
});

我们可以看到,它返回的是一个对象,并且在没填写表单字段的时候,它的key是空的。


image.png


我们再来看看有表单字段的


router.post("/file2", (ctx) => {
const files = ctx.request.files;
ctx.body = files;
});

可以看到,它返回的对象key就是我们的表单字段名。


image.png


我们再来看看多文件上传的情况


多文件上传


我们先来看看多文件不带表单字段的情况


router.post("/files", (ctx) => {
const files = ctx.request.files;
ctx.body = files;
});

可以看到,它返回的还是一个对象,只不过属性值是数组。


image.png


我们来看看带表单字段的情况,对于带表单字段的多文件上传,它返回的对象里面的key值就不是空值,并且如果是多个文件,它是以数组形式返回。


image.png


image.png


静态目录


文件上传我们介绍完毕了,如果我们想访问我们上传的图片该怎么办呢?能直接访问吗


对于文件,我们需要开启静态目录才能通过链接访问到我们目录里面的内容。与Express不同,koa需要借助koa-static插件才能开启静态目录。


下面的配置就是将我们系统的uploads目录设置为静态目录,这样我们通过域名就能直接访问该目录下的内容了。


const koaStatic = require("koa-static");

app.use(koaStatic(path.join(__dirname, "uploads")));

可以看到,图片能正确访问。


image.png


细心的同学可能发现了它是直接在域名后面访问,并没有像Express一样有个static前缀。那怎么实现这种自定义前缀的效果呢?


自定义静态资源目录前缀


Koa中,需要借助koa-mount插件


我们先来安装一下


npm i koa-mount

然后和koa-static搭配使用


app.use(mount("/static", koaStatic(path.join(__dirname, "uploads"))));

然后我们就可以带上/static前缀访问静态资源了。


image.png


错误处理


koa也可以通过中间件来捕获错误,但是需要注意,这个中间件需要写在前面


app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// 响应用户
ctx.status = error.status || 500;
ctx.body = error.message || "服务端错误";
// ctx.app.emit("error", error); // 触发应用层级error事件
}
});

我们来测试一下


// 模拟错误
router.get("/error", function (ctx, next) {
// 同步错误可以直接捕获
throw new Error("同步错误");
});

可以看到,错误被中间件捕获并正常返回了。


image.png


我们再来看看异步错误


router.get("/error2", async function (ctx, next) {
// 新建异步错误
await Promise.reject(new Error("异步错误"));
});

也能被正常捕获。


image.png


可以看到,相较于Express的错误处理,Koa变得更简单了,不管是同步错误还是异步错误都能正常捕获。


日志


对于线上项目用来说,日志是非常重要的一环。log4js是使用得比较多的一个日志组件,经常跟Express一起配合使用。本文简单讲解下在Express怎么使用log4js


我们首先来安装该插件,笔者这里安装的版本是6.8.0


npm install log4js

然后我们创建一个utils文件夹下创建log.js,用来创建一个logger


// utils/log.js

const log4js = require("log4js");
const logger = log4js.getLogger();

logger.level = "debug"; // 需要打印的日志等级

module.exports = logger;

在需要的地方引入logger就可以了,我们来测试下


app.get("/logtest", (req, res) => {
logger.debug("Some debug messages");
logger.info("Some info messages");
logger.warn("Some warn messages");
logger.error("Some error messages");
res.send("test log");
});

可以看到,日志都打印出来了


image.png


日志等级


我们再来改变下输出日志的等级


logger.level = "warn"; // 需要打印的日志等级

再来测试下,发现只输出了warnerror等级的日志,debuginfo等级的过滤掉了。


image.png


日志输出到文件


日志如果想输出到文件,我们还可以配置log4js


const log4js = require("log4js");

log4js.configure({
appenders: { test: { type: "file", filename: "applog.log" } },
categories: { default: { appenders: ["test"], level: "warn" } },
});

const logger = log4js.getLogger();

module.exports = logger;

我们再来测试下,发现它自动创建了applog.log文件,并将日志写入到了里面。


image.png


连接数据库


数据库目前主要有关系型数据库、非关系型数据库、缓存数据库,这三种数据库我们各举一个例子。


连接mongodb


为了方便操作mongodb,我们使用mongoose插件


首先我们来安装


npm  i mongoose

安装完后我们先创建db文件夹,然后创建mongodb.js,在这里来连接我们的mongodb数据库


// db/mongodb.js

const mongoose = require("mongoose");

module.exports = () => {
// 数据库连接
return new Promise((resolve, reject) => {
mongoose
.connect("mongodb://localhost/ExpressApi", {
// useNewUrlParser: true,
// useUnifiedTopology: true,
// useFindAndModify: false,
})
.then(() => {
console.log("mongodb数据库连接成功");
resolve();
})
.catch((e) => {
console.log(e);
console.log("mongodb数据库连接失败");
reject();
});
});
};

然后在我们的入口文件引用使用


// index.js

// 连接mongodb
const runmongodb = require("./db/mongodb.js");
runmongodb();

保存,我们运行一下,可以看到mongodb连接成功。


image.png


我们查看mongodb面板,可以看到KoaApi数据库也创建成功了


image.png


数据库连接成功了,下面我们正式来创建接口。


我们以mvc模式,创建model、controller、route三个文件夹分别来管理模型、控制器、路由。


项目总体目录如下


model // 模型
controller // 控制器
route // 路由
db // 数据库连接
index.js // 入口文件

创建接口总共分为四步



  1. 创建模型

  2. 创建控制器

  3. 创建路由

  4. 使用路由


我们先来创建一个user model


// model/user.js
const mongoose = require("mongoose");
// 建立用户表
const UserSchema = new mongoose.Schema(
{
username: {
type: String,
unique: true,
},
password: {
type: String,
select: false,
},
},
{ timestamps: true }
);

// 建立用户数据库模型
module.exports = mongoose.model("User", UserSchema);

然后创建user控制器,定义一个保存和一个查询方法。


// controller/userController.js
const User = require("../model/user");

class UserController {
async create(ctx) {
const { username, password } = ctx.request.body;
const repeatedUser = await User.findOne({ username, password });
if (repeatedUser) {
ctx.status = 409;
ctx.body = {
message: "用户已存在",
};
} else {
const user = await new User({ username, password }).save();
ctx.body = user;
}
}

async query(ctx) {
const users = await User.find();
ctx.body = users;
}
}

module.exports = new UserController();

然后我们在路由里面定义好查询和创建接口


// route/user.js

const Router = require("@koa/router");
const router = new Router({ prefix: "/user" });
const { create, query } = require("../controller/userController");

router.post("/create", create);
router.get("/query", query);

module.exports = router;

最后我们在入口文件使用该路由,前面我们说啦,路由少可以一个一个引入使用,对于路由多的话还是推荐使用自动注入的方式。


为了方便理解,这里我们还是使用引入的方式


// index.js

const userRouter = require("./routes/user");
app.use(userRouter.routes()).use(userRouter.allowedMethods())

好啦,通过这四步,我们的接口就定义好啦,我们来测试一下


先来看看新增,接口正常返回


image.png


我们来看看数据库,发现user表添加了一条新记录。


image.png


我们再来看看查询接口,数据也能正常返回。


image.png


至此,我们的mongodb接口就创建并测试成功啦。


连接mysql


为了简化我们的操作,这里我们借助了ORM框架sequelize


我们先来安装这两个库


npm i mysql2 sequelize

然后在db目录下创建mysql.js用来连接mysql


const Sequelize = require("sequelize");

const sequelize = new Sequelize("KoaApi", "root", "123456", {
host: "localhost",
dialect: "mysql",
});

// 测试数据库链接
sequelize
.authenticate()
.then(() => {
console.log("数据库连接成功");
})
.catch((err) => {
// 数据库连接失败时打印输出
console.error(err);
throw err;
});

module.exports = sequelize;

这里要注意,需要先把数据库koaapi提前创建好。它不会自动创建。


跟前面一样,创建接口总共分为四步



  1. 创建模型

  2. 创建控制器

  3. 创建路由

  4. 使用路由


首先我们创建model,这里我们创建user2.js


// model/user2.js

const Sequelize = require("sequelize");
const sequelize = require("../db/mysql");

const User2 = sequelize.define("user", {
username: {
type: Sequelize.STRING,
},
password: {
type: Sequelize.STRING,
},
});

//同步数据库:没有表就新建,有就不变
User2.sync();

module.exports = User2;

然后创建控制器,定义一个保存和一个查询方法。


// controller/user2Controller.js

const User2 = require("../model/user2.js");

class user2Controller {
async create(ctx) {
const { username, password } = ctx.request.body;

try {
const user = await User2.create({ username, password });
ctx.body = user;
} catch (error) {
ctx.status = 500;
ctx.body = { code: 0, message: "保存失败" };
}
}

async query(ctx) {
const users = await User2.findAll();
ctx.body = users;
}
}

module.exports = new user2Controller();

然后定义两个路由


const router = new Router({ prefix: "/user2" });
const { query, create } = require("../controller/user2Controller");

// 获取用户
router.get("/query", query);
// 添加用户
router.post("/create", create);

module.exports = router;

最后在入口文件使用该路由


// index.js

const user2Router = require("./routes/user2");
app.use(user2Router.routes()).use(user2Router.allowedMethods())

好啦,通过这四步,我们的接口就定义好啦,我们来测试一下


先来看看新增,接口正常返回


image.png


我们来看看数据库,发现users表添加了一条新记录。


image.png


我们再来看看查询接口,数据也能正常返回。


image.png


至此,我们的mysql接口就创建并测试成功啦。


我们再来看看缓存数据库redis


连接redis


这里我们也需要借助node-redis插件


我们先来安装


npm i redis

然后在db目录下创建redis.js用来连接redis


// db/redis.js

const { createClient } = require("redis");

const client = createClient();

// 开启连接
client.connect();

// 连接成功事件
client.on("connect", () => console.log("Redis Client Connect Success"));
// 错误事件
client.on("error", (err) => console.log("Redis Client Error", err));

module.exports = client;

然后我们创建一个简单的路由来测试一下


// route/dbtest

const Router = require("@koa/router");
const router = new Router({ prefix: "/dbtest" });
const client = require("../db/redis");

router.get("/redis", async (ctx) => {
await client.set("name", "randy");
const name = await client.get("name");
ctx.body = { name };
});

module.exports = router;

然后把该路由在入口文件注册使用


// index.js

const dbtestRouter = require("./routes/dbtest");
app.use(dbtestRouter.routes()).use(dbtestRouter.allowedMethods())

最后我们来测试下接口,可以看到接口正常返回


image.png


我们再来查看一下我们的redis数据库,发现数据保存成功。


image.png


当然,这里只是一个简单的入门,redis的操作还有很多,大家可以看官方文档,这里笔者就不再详细说啦。


token验证


对于token的认证,我们这里使用目前比较流行的方案 jsonwebtoken


生成token


我们首先安装jsonwebtoken


npm i jsonwebtoken

安装完后,我们来实现一个登录接口,在接口里生成token并返回给前端。


注意这里因为是演示,所以将密钥写死,真实项目最好从环境变量里面动态获取。


// route/user.js
const jwt = require("jsonwebtoken");

// ...
async login(ctx) {
const { username, password } = ctx.request.body;
const user = await User.findOne({ username, password });
if (user) {
const token = jwt.sign(
{ id: user.id, username: user.username },
"miyao",
{ expiresIn: 60 }
);

ctx.body = {
token,
};
} else {
ctx.status = 401;
ctx.body = {
message: "账号或密码错误",
};
}
}

// ...

这里生成token的接口我们就定义好了,我们来测试一下。


首先输入错误的账号,看到它提示账号密码错误了


image.png


然后我们输入正确的账号密码试一下,可以看到,token被正常返回出来了。


image.png


到这里我们通过jsonwebtoken生成token就没问题了。接下来就是怎么验证token了。


token解密


在说token验证前,我们先来说个token解密,一般来说token是不需要解密的。但是如果非要看看里面是什么东西也是有办法解密的,那就得用到jwt-decode插件了。


该插件不验证密钥,任何格式良好的JWT都可以被解码。


我们来测试一下,


首先安装该插件


npm i jwt-decode

然后在登录接口里面使用jwt-decode解析token


const decoded = require("jwt-decode");

async login(req, res) {
// ...
console.log("decoded token", decoded(token));
// ...
}

可以看到,就算没有秘钥也能将我们的token正确解析出来。


image.png


这个插件一般在我们前端用的比较多,比如想解析token,看看里面的数据是什么。它并不能验证token是否过期。如果想验证token的话还得使用下面的方法。


token验证


Koa中,验证token是否有效我们一般会选择koa-jwt插件。


下面笔者来演示下怎么使用


首先还是安装


npm install koa-jwt

然后在入口文件以全局中间件的形式使用。


这个中间件我们要尽量放到前面,因为我们要验证所有接口token是否有效。


然后记得和错误中间件结合使用。


如果有些接口不想验证,可以使用unless排除,比如登录接口、静态资源。


// index.js
const koaJwt = require("koa-jwt");

app.use(
koaJwt({ secret: "miyao" }).unless({ path: [/^\/user\/login/, "/static"] })
);

// 错误中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// 响应用户
ctx.status = error.status || 500;
ctx.body = error.message || "服务端错误";
// ctx.app.emit("error", error); // 触发应用层级error事件
}
});

下面我们测试下,


我们先来看看不要token的接口,来访问一个静态资源。可以看到,没有token能正常获取资源。


image.png


我们再来访问一个需要token的接口,可以看到它提示错误了,说是没有token


image.png


我们用登录接口生成一个token,然后给该接口加上来测试下,可以看到接口正常获取到数据了。


image.png


因为我们的token设置了一分钟有效,所以我们过一分钟再来请求该接口。可以看到,它提示token错误了。


image.png


好啦,关于token验证我们就讲到这里。


启动


node中,一般我们会使用node xx.js来运行某js文件。这种方式不仅不能后台运行而且如果报错了可能直接停止导致整个服务崩溃。


PM2Node 进程管理工具,可以利用它来简化很多 Node 应用管理的繁琐任务,如性能监控、自动重启、负载均衡等,而且使用非常简单。


首先我们需要全局安装


npm i pm2 -g

下面简单说说它的一些基本命令



  1. 启动应用:pm2 start xxx.js

  2. 查看所有进程:pm2 list

  3. 停止某个进程:pm2 stop name/id

  4. 停止所有进程:pm2 stop all

  5. 重启某个进程:pm2 restart name/id

  6. 删除某个进程:pm2 delete name/id


比如我们这里,启动当前应用,可以看到它以后台的模式将应用启动起来了。


image.png


当然关于pm2的使用远不止如此,大家可以查看PM2 文档自行学习。


总结


总体来说,koa更轻量,很多功能都不内置了而是需要单独安装。并且对异步有更好的支持,就是await会阻塞后面代码的执行(包括中间件)。


系列文章


Node.js入门之什么是Node.js


Node.js入门之path模块


Node.js入门之fs模块


Node.js入门之url模块和querystring模块


Node.js入门之http模块和dns模块


Node.js入门之process模块、child_process模块、cluster模块


听说你还不会使用Express


听说你还不会使用Koa?


后记


感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!


作者:苏苏同学
来源:juejin.cn/post/7208005547004919867
收起阅读 »

💤💤💤你的javascript被睡了

web
段子 老板说:给客户做的项目,你的运行程序太快了,我建议你慢一点,客户付款速度就会快一点 我:我不会。。。 老板:没工资 我:马上 需求背景 请求后端a接口后拿到返回结果,需要等待2秒钟后才能请求b接口,b接口拿到返回结果后,需要等待3秒钟后才能请求c接口 项...
继续阅读 »

段子


老板说:给客户做的项目,你的运行程序太快了,我建议你慢一点,客户付款速度就会快一点

我:我不会。。。

老板:没工资

我:马上


需求背景


请求后端a接口后拿到返回结果,需要等待2秒钟后才能请求b接口,b接口拿到返回结果后,需要等待3秒钟后才能请求c接口


项目原代码


main () {
this.$http('a').then((resA) => {
setTimeout(() => {
this.$http('b').then((resB) => {
setTimeout(() => {
this.$http('c')
}, 3000)
})
}, 2000)
})
}

这种写法,虽然是实现了效果,但是看着实在是脑瓜子疼


需求只是要求拿到接口后,有一定的休眠后再继续请求下一个接口,实际上只需要实现一个休眠的函数即可


理想写法


async main() {
const resA = await this.$http('a')
await sleep(2000)
const resB = await this.$http('b')
await sleep(3000)
this.$http('c')
}

要是能够这样调用是不是舒服很多,省去了嵌套地狱,可读性得到了提高


从代码来看,实际的关键实现就是sleep函数,我们要怎么用js封装一个好用的sleep函数?


sleep函数实现


function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

async function demo() {
console.log('Taking a break...');
await sleep(2000)
console.log('Two seconds later, showing sleep in a loop...')

// Sleep in loop
for (let i = 0; i < 5; i++) {
await sleep(2000)
console.log(i)
}
}

demo()

在上面的代码中,sleep()函数返回一个Promise对象。当Promise对象解决时,函数会继续执行。在demo()函数中,我们使用await关键字来等待sleep()函数的解决。这使得console.log()函数在等待指定的时间后才被调用,从而实现了s

作者:超神熊猫
来源:juejin.cn/post/7205812357875548215
leep函数的效果。

收起阅读 »

你还别不信,我帮同事优化代码,速度快了1000倍以上!!

web
背景 我们公司有个小程序的产品,里面有个功能是根据用户输入的商品信息,如 干葱3斤,沙姜1斤,番茄2斤...,然后传给后端接口就能解析到符合规格的数据,这样用户就不用一个个录入,只需要一次性输入大量 sku 信息文本,即可快速下单。 故事发生在这周三早上,我同...
继续阅读 »

背景


我们公司有个小程序的产品,里面有个功能是根据用户输入的商品信息,如 干葱3斤,沙姜1斤,番茄2斤...,然后传给后端接口就能解析到符合规格的数据,这样用户就不用一个个录入,只需要一次性输入大量 sku 信息文本,即可快速下单。


故事发生在这周三早上,我同事急匆匆地找到我,说识别商品很慢。


一开始,我以为是后端的接口慢(因为之前这个识别一直在做优化),那这个实际上前端大多无能为力,因为确实识别了大量的商品(具体是 124 个),且输入文本千奇百怪,比如豆腐一块,那我要理解为是一块豆腐,还是豆腐一块钱?但他跟我说,虽然接口耗时 2.8s,但是还得等待接近 5s 的时间才渲染商品列表,且经常出现创建完 124 个商品的订单,开发工具就报内存不足。


image.png



这个是网上找工具模拟的,因为企业微信截图水印去除太麻烦了。。。不过对话是真实的对话~



我一开始还以为,难道是渲染长列表没做性能优化?然而经过一顿排查,排除了是渲染的锅,罪魁祸首是请求完接口后,对商品信息的处理导致了卡顿,过程大致如下:


  /** 发起请求 */
async request() {
/** 这里接口耗时2.8s */
const data = await ParseDetails()
onst { order_detail_items, sku_map, price_map } = data;

/** 耗时出现在这里 长达5s+,随着识别商品数量呈线性增加 */
this.list = order_detail_items.map(
(item, i) => new DataController({ ...item, sku_map, price_map })
);
}

2023-03-01 21.34.05.gif



每次生成一个 DataController 实例大概耗时 30+ ~ 50ms



定位到耗时的大致位置,那就好办了,接下来,实际上就只需要看看为何创建 DataController 那么耗时就好了。


这里我也写了个类似的demo,点击可见具体的代码细节~




本来想通过码上掘金写 demo 的,但发现好像不太支持,所以还是在 codesandbox 上写,然后在码上掘金通过 iframe 插入,如果预览不出来,可能是 codesandbox 抽风了



image.png



尾缀为 1 的为优化后的代码



了解下 demo


代码结构


整个项目采用的技术栈是 react + mobx(一个响应式的数据管理库)



  • batch_input: 点击识别后会调用 batchInput 的 request 进行解析,解析完成后会处理商品列表信息

  • data_controller: 商品信息二次处理后的结构,request 后得到的 order_detail_items 会遍历生成一个个 DataController 实例,用于渲染商品列表

  • mock_data: 随便造了一点数据,124 项,屏蔽了真实项目的具体字段,结构为 { order_detail_items, sku_map, price_map }


其中 batch_input1、data_controller1 是优化后的代码


为何要有两个 map


每次请求接口解析后会返回一个数据结构:



  • order_detail_items: 返回列表的每一项,结果为 OrderDetailItem[]

  • sku_map: sku 即商品的结构,可通过 order_detail_item 上的 sku_id 映射 到对应的 sku,结构为 Record<string, Sku>,即 Sku_Map

  • price_map: 对应的报价信息,可通过 order_detail_item 上的 sku_id 映射 到对应的 price,结构为 Record<string, Price>,即 Price_Map


以上相关信息放到对应的 map 上是为了让一个 order_detail_item 不挂太多的数据,只通过对应的 id 去 map 对应的其他信息,比如我想拿到对应的 sku 信息,那么只需要:


const sku = sku_map[order_detail_item.sku_id]

而不是通过:


const sku = order_detail_item.sku

拿到,以达到更好的扩展性。


一起看看问题出在哪


现在我们定位到了问题大致是出现在创建 DataController 实例的时候,那么我们看具体的构造函数:


image.png


image.png


我们看到每次遍历都把 order_detail_item 和两个 map 都传给 DataController 类,然后 DataController 将得到的 detail 全部赋值到 this 上,之后通过makeAutoObservable实现响应式。


看到这里的读者,我想大部分都知道问题出现在哪了,就是原封不动地把所有传过来的参数都加到 this 上去,那么每次创建一个实例,都会挂载两个大对象的 map,导致 new 每个实例耗时 30 ~ 50ms,如果是 100+个,那就是 3 ~ 5s 了,这是多么的恐怖。


还有一个点,实际上 DataController 声明的入参类型是OrderDetailItem,是不包括 Sku_Map 和 Price_Map,但是上面的代码却都不顾 ts 报错传过去,这也是导致代码可能出现问题的原因


image.png


多说一嘴


然而实际上定位问题没有那么快,因为首先实际的 DataController 很大,且 constructor 里面的代码也有点多,还有我后来也没有负责这个项目,对代码不是特别的熟悉。


而上面的 demo 实际上是经过极简处理过的,实际的代码如下:


image.png



将近 250 行



image.png



单单一个 constructor 就 50+行了



一起看看如何优化吧


我们现在找到原因了,没必要每个示例都挂载那么多数据,特别是两个大对象 map,那我们如何优化呢?


大家可以想一想怎么做?


我的方案是,DataController 上面声明个静态属性 maps,用来映射每次请求后得到的 sku_map 和 price_map,见data_controller1


image.png


然后每次请求之前生成一个 parseId,用来映射每次请求返回的数据,demo 里面是用Date.now()简单模拟,将生成的两个 map 存放到静态属性 maps 上,然后 parseId 作为第二个参数传给每个实例,见 batch_input1


image.png


那么 每个实例的get sku, get mapPrice(真实项目中实际上很多,这里简化了不少) 中就可以替换为该静态 map 了,通过 parseId 映射到对应的 sku 和 price


我们看看优化后的效果:


2023-03-01 21.36.58.gif


现在生成 list 大概花费了 4 ~ 6ms 左右,比起之前动辄需要 5 ~ 6s,足足快了 1000 多倍!!!


c5826fd4a758463390413a173ee0899d.gif


先别急


等等,我们上次说了是因为把太多数据都放到实例上,特别是两个大 map,才导致生成实例太过于耗时,那真的是这样吗?
大家可以看看 demo 的第三个 tab,相比第一个 tab 只是注释了这行代码:


image.png


让我们看看结果咋样


2023-03-01 21.37.22.gif


可以看到生成 list 只是耗费了 1+ms,比优化后的代码还少了 3+ms 左右,那么,真正的根源是就肯定是makeAutoObservable这个函数了


makeAutoObservable 做了什么


我们上面说到,mobx 是个响应式的数据管理库,其将数据都转换为 Observable,无论数据多么深层,这个我们可以 log 下实例看看


image.png


会发现 map 上每个属性都变成一个个的 proxy(因为这里我们用了 mobx6),那如果我两个 map 都很大且很深层的话,遍历处理每个属性加起来后就很耗费时间,导致每次生成一个实例都耗费了将近 50ms!!


所以,我们上面所说的在this 上挂载了太多的数据只是直接原因,但根本原因在于 makeAutoObservable,当然,正是这两者的结合,才导致了代码如此的耗时。


总结


我们一开始以为是渲染太多数据导致页面卡顿,然而实际上是生成每个 DataController 实例导致的耗时。


我们开始分析原因,发现是因为每个实例挂了太多的数据,所以优化方案是将两个大对象 map 放到类的静态属性 maps 上,通过 parseId 来映射到对应的数据,从而将速度优化了 1000+倍。


然后我们继续深入,发现实例挂载太多数据只是表面的原因,根本原因还是在于 mobx 的 makeAutoObservable 对数据的每个属性都转换为 proxy 结构,让其变成响应式,而对象数据又很大,导致太过于耗时。


还有一点要注意的就是,原先的代码忽略了 ts 的类型限制,因为 sku_map、price_map 实际上不在入参的限制范围内(实际代码还不只多传了这两个 map),所以确保 ts 类型的正确性,也非常有利于规避潜在的 bug。


同时,如何写好每个 mobx store 也是我们应该深入思考的,多利用好 private、static,get 等等属性和方法,哪些应该放到实例上去,哪些应该放到静态属性上,哪些是 public、哪些是 static 的,都应该考虑好。


最后


当我优化代码后,就马上跟同事吹嘘:


image.png


看看,这是人说的话吗!!


但是,我突然想到:诶,这不是每次产品、测试、UI 说这里太慢、这里少 1px、这里交互有问题的时候,我不也是说:有啥问题?又不是不能跑吗?


image.png


但嘴上是这样说着,然而实际上私下却偷偷看为何会这样(不可能,绝对不可能,我的代码天下无敌),正所谓,嘴上说着不要,心里却很诚实。


QQ20230225-205345-HD.gif


好了,今天的故事就分享到这里,各位看官大大觉得可以的话,还请给个赞,谢谢~


作者:暴走老七
来源:juejin.cn/post/7204100122887536700
收起阅读 »

前端按钮生成器!要的就是效率!

web
大家好,我是前端实验室的老鱼!一名资深的互联网玩家,专注分享大前端领域技术、面试宝典、学习资料等 | 副业赚钱~ 「前端实验室」 专注分享 Github、Gitee等开源社区优质的前端项目、前端技术、前端学习资源以及前端资讯等各种有趣实用的前端内容。 按钮是我...
继续阅读 »

大家好,我是前端实验室的老鱼!一名资深的互联网玩家,专注分享大前端领域技术、面试宝典、学习资料等 | 副业赚钱~


「前端实验室」 专注分享 Github、Gitee等开源社区优质的前端项目、前端技术、前端学习资源以及前端资讯等各种有趣实用的前端内容。


按钮是我们页面开发中必不可少的一部分。在平常开发中,我们常常一遍又一遍的重复写着各种各样的按钮样式。


这些简单,但机械重复的工作是否影响到你的工作效率了呢?


今天为大家推荐一个按钮生成的网站。100+款按钮样式和响应方式供你挑选!图片准备好了吗?一起来看下吧!


3D款


平面3D效果的按钮。图片点击按钮,自动将按钮样式复制到剪切板,直接粘贴即可使用。


.css-button-3d--sky {
  min-width130px;
  height40px;
  color#fff;
  padding5px 10px;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.3s ease;
  position: relative;
  display: inline-block;
  outline: none;
  border-radius5px;
  border: none;
  background#3a86ff;
  box-shadow0 5px #4433ff;
}
.css-button-3d--sky:hover {
  box-shadow0 3px #4433ff;
  top1px;
}
.css-button-3d--sky:active {
  box-shadow0 0 #4433ff;
  top5px;
}

渐变款


渐变的按钮平常使用不多,但就怕产品经理提这样的需求。图片


阴影边框


按钮带点阴影边框,在大师兄的项目中算是基本需求了。因为生硬的边框总会缺乏点柔和的美感。图片拷贝个代码来看看。


.css-button-shadow-border--sky {
  min-width130px;
  height40px;
  color#fff;
  padding5px 10px;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.3s ease;
  position: relative;
  display: inline-block;
  outline: none;
  border-radius5px;
  border: none;
  box-shadow:inset 2px 2px 2px 0px rgba(255,255,255,.5), 7px 7px 20px 0px rgba(0,0,0,.1), 4px 4px 5px 0px rgba(0,0,0,.1);
  background#4433ff;
}
.css-button-shadow-border--sky:hover {
  background-color#3a86ff;
}
.css-button-shadow-border--sky:active {
  top2px;
}

这篇幅,让我自己一行代码一行代码的敲,确实有点费时间。还是直接拷贝来得快。


各种hover状态


浮光掠影的效果图片镂空效果图片滑动效果图片增加其他显示图片


其他


按钮的样式和交互功能,对大家来说都是很简单的操作。但重复的编写这些代码会浪费些许时间。

本文分享了各种常用的各种按钮形式,对于有自定义按钮需求的小伙伴可以作参考。



网站地址

markodenic.com/tools/butto…



写在最后


我是前端实验室的老鱼!一名资深的互联网玩家,专注分享大前端领域技术、面试宝典、学习资料、副业等~


喜欢的朋友,点赞收藏支持一下,也欢迎交流~



作者:程序员老鱼
来源:juejin.cn/post/7202907707472609337
收起阅读 »

内卷年代,是该学学WebGL了

web
前言 大部分公司的都会有可视化的需求,但是用echarts,antv等图表库,虽然能快速产出成果,但是还是要知道他们底层其实用canvas或svg来做渲染,canvas浏览器原生支持,h5天然支持的接口,而svg相比矢量化,但是对大体量的点的处理没有canva...
继续阅读 »

前言


大部分公司的都会有可视化的需求,但是用echarts,antv等图表库,虽然能快速产出成果,但是还是要知道他们底层其实用canvas或svg来做渲染,canvas浏览器原生支持,h5天然支持的接口,而svg相比矢量化,但是对大体量的点的处理没有canvas好,但是可以操作dom等优势。canvas和svg我们一般只能做2d操作,当canvas.getContext('webgl')我们就能获取webgl的3d上下文,通过glsl语言操作gpu然后渲染了。理解webgl,可以明白h5的很多三维的api底层其实都是webgl实现,包括对canvas和svg也会有新的认知。


canvas和webgl的区别


canvas和webgl都可以做二维三维图形的绘制。底层都会有对应的接口获取。cancvas一般用于二维ctx.getContext("2d"),三维一般可以通过canvas.getContext('webgl')


窥探WebGL


理解建模


如果你有建模软件基础的话,相信3dmax、maya、su等软件你一定不会陌生,本质其实就是点、线、面来组成千变万化的事物。打个比方球体就是无数个点连成线然后每三根线形成面,当然有常见的四边形,其实也是两个三边形组成,为什么不用四边形,因为三边形更稳定、重心可计算、数据更容易测算。


所以核心也就是点、线、三角面


了解WebGL


WebGL可以简单理解为是openGL的拓展,让web端通过js可以有强大的图形处理能力。当然为了与显卡做交互你必须得会glsl语言。


GLSL


glsl着色器语言最重要的就是顶点着色器和片元着色器。简单理解为一个定位置一个添颜色。


简单绘制一个点


webgl会有大量的重复性前置工作,也就是创建着色器 -> 传入着色器源码 -> 编译着色器 -> 创建着色器程序 -> 绑定、连接、启用着色器 -> 可以绘制了!


一般而言我们是不会重复写这个东西,封装好了直接调用就行。


function initShader (gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) {
const vertexShader = gl.createShader(gl.VERTEX_SHADER);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

gl.shaderSource(vertexShader, VERTEX_SHADER_SOURCE);
gl.shaderSource(fragmentShader, FRAGMENT_SHADER_SOURCE);

//编译着色器
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);

//创建程序对象
const program = gl.createProgram();

gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);

gl.linkProgram(program);
gl.useProgram(program);

return program;
}

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="./initShader.js"></script>
</head>

<body>
<canvas id="canvas" width="300" height="400">
不支持canvas
</canvas>
</body>

<script>
const ctx = document.getElementById('canvas')
const gl = ctx.getContext('webgl')

//着色器: 通过程序用固定的渲染管线,来处理图像的渲染,着色器分为两种,顶点着色器:顶点理解为坐标,片元着色器:像素

//顶点着色器源码
const VERTEX_SHADER_SOURCE = `
void main() {
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
gl_PointSize = 10.0;
}
`
//片元着色器源码
const FRAGMENT_SHADER_SOURCE = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`
//创建着色器
const program = initShader(gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE)

//执行绘制
gl.drawArrays(gl.POINTS, 0, 1)
//gl.drawArrays(gl.LINES, 0, 1)
//gl.drawArrays(gl.TRIANGLES, 0, 1)

</script>

</html>

绘制效果如下:


image.png


相信看了上面有段代码会有疑惑


image.png


gl_position代表坐标,vec4就一个存放个4个float的浮点数的容量,定义坐标, 分别对应x、y、z、w,也就是三维坐标,但是w就等于比例缩放xyz而已,一般在开发中,我们的浏览器的坐标要跟这个做个转换对应上,gl_POintSize是点的大小,注意是浮点数


image.png


gl_flagColor渲染的像素是红色,是因为这类似于比例尺的关系需要做个转换, (R值/255,G值/255,B值/255,A值/1) -》(1.0, 0.0, 0.0, 1.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>Document</title>
<script src="./initShader.js"></script>
</head>

<body>
<canvas id="canvas" width="300" height="400">
不支持canvas
</canvas>
</body>

<script>
const canvas = document.getElementById('canvas')
const gl = canvas.getContext('webgl')

const VERTEX_SHADER_SOURCE = `
precision mediump float;
attribute vec2 a_Position;
attribute vec2 a_Screen_Size;
void main(){
vec2 position = (a_Position / a_Screen_Size) * 2.0 - 1.0;
position = position * vec2(1.0, -1.0);
gl_Position = vec4(position, 0, 1);
gl_PointSize = 10.0;
}
`

const FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform vec4 u_Color;
void main() {
vec4 color = u_Color / vec4(255, 255, 255, 1);
gl_FragColor = color;
}
`

//前置工作,着色器可以渲染了!
const program = initShader(gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE)

//获取glsl的变量对应的属性做修改
var a_Position = gl.getAttribLocation(program, 'a_Position');
var a_Screen_Size = gl.getAttribLocation(program, 'a_Screen_Size');
var u_Color = gl.getUniformLocation(program, 'u_Color');
gl.vertexAttrib2f(a_Screen_Size, canvas.width, canvas.height); //给glsl的属性赋值两个浮点数

//给个默认背景颜色
gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

//存储点击位置的数组。
var points = [];
canvas.addEventListener('click', e => {
var x = e.pageX;
var y = e.pageY;
var color = { r: Math.floor(Math.random() * 256), g: Math.floor(Math.random() * 256), b: Math.floor(Math.random() * 256), a: 1 };
points.push({ x: x, y: y, color: color })

gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

for (let i = 0; i < points.length; i++) {
var color = points[i].color;
gl.uniform4f(u_Color, color.r, color.g, color.b, color.a);
gl.vertexAttrib2f(a_Position, points[i].x, points[i].y);
gl.drawArrays(gl.POINTS, 0, 1);
}
})
</script>

</html>

vec2 position = (a_Position / a_Screen_Size) * 2.0 - 1.0; 注意这里的坐标转换,从canvas转为ndc坐标,其实就是看范围就行,[0, 1] -> [0, 2] -> [-1, 1]。上面总体的流程总结下就是,定义着色器,定义glsl着色器源码 -> 通过api获取canvas的信息转换坐标系 -> 监听点击事件传递变量到glsl中 -》通过pointer缓存 -> drawArrays绘制。但是这种方法,很明显有大量的重复渲染,每次遍历都要把之前渲染的重复执行。


大致效果


动画.gif


总结


通过简单的webgl入门,已经有了初步的认知,大致的流程为:着色器初始化 -> 着色器程序对象 -> 控制变量 -> 绘制,为了更好的性能,后面会使用缓冲区来解决重复渲染的问题,包括我们的顶点不会一个一个设置,一般是会涉及到矩阵的转换,如平移、缩放、旋转、复合矩阵。


作者:谦宇
来源:juejin.cn/post/7207417288705458231
收起阅读 »

一张架构图让我认识到前端的渺小

web
前言 大家好这里是阳九,一个文科转码的野路子码农,热衷于研究和手写前端工具. 今天我们不聊前端,咱们来聊聊后端,聊聊架构 目的是正视自己的地位和价值,在寒冬中保持清醒 借用若川大佬的一句话: 所知甚少,唯善学 先别问我到底是前端程序员还是后端程序员,我自己也...
继续阅读 »


前言


大家好这里是阳九,一个文科转码的野路子码农,热衷于研究和手写前端工具.


今天我们不聊前端,咱们来聊聊后端,聊聊架构


目的是正视自己的地位和价值,在寒冬中保持清醒


借用若川大佬的一句话: 所知甚少,唯善学




先别问我到底是前端程序员还是后端程序员,我自己也不知道。当然自己也很想在某个领域精进,但是业务嘛,咱们就是一块砖,哪里需要哪里搬,硬着头皮上呗


最近是在维护公司的业务引擎, 对其进行优化并添加功能,技术栈的话大体来讲, 前端是React+Node BFF,后端是GO (Gin框架+原生)


随着看后端代码的时间越来越长,作为一个切图仔,我越来越觉得恐怖。那是一种,看到了过于庞大的未知世界,并深深觉得自己的认知太少的恐怖。


因为这个项目是定制项目,通俗点来讲就是"改装车",不从头造车但是要改装,这里改改哪里改改,一旦改动的点多了,就需要你把整个项目的逻辑全部理顺。


于是乎我在这两个月里,里里外外看了几万行代码。也是硬着头皮把整个架构梳理了一遍。


先在这里放上一张整理出来的架构图


(当然这里根据原系统魔改了的很多地方,并进行了简化,并修改了名称,防止泄密,模块的大小差不多是以核心逻辑代码量来算的,前端的核心代码量有多少咱们前端er应该都懂)


XXX系统总架构图.jpg


本文目的


想通过一次后端架构分析, 让我们前端人意识到自己的不足与眼界的狭窄,我们前端er需要对一个完整的大型项目有一个整体的认知,意识到自己的不足,才能在这条路上更好的走下去。
不要满足于html拼拼页面,写写样式,做做一些简单的工作。


技术栈介绍


这里先简单介绍一下技术栈, 否则无法理解



  • 前端 React webpack antd redux ... 前端er都懂,以下省略

  • Koa框架 一个node后端框架

  • Gin框架 一个GO后端框架

  • Docker 容器引擎

  • K8S Docker集群管理

  • RabbitMQ 实现AMQP消息队列协议的代理软件,也就是消息队列,用于通信

  • GFS 分布式文件共享系统,用于大量数据访问

  • MongoDB 快读读取用数据库

  • Elastic Search 分布式数据库,进行大批量存储查询

  • SQL 传统关系型数据库

  • MobileSuit 后端框架工厂框架,用于创建独立Gin服务

  • 扩容服务 GO原生实现

  • 引擎 GO原生实现

  • 守护进程 GO原生实现


关于前端


看到左上角我特意标出来的那一小块红色的UI了吗?我们称之为 前端


数据库


mongo DB : 用于小体积数据的快速读取,用作数据中间传输(原生json,使用方便)


Elastic Search : 分布式数据库, 用于大体积,大批量数据存储和快速检索(动辄十几亿条数据,一条json数据几千个字段)


SQL: 用于存储不变的数据,比如国家信息,公司信息等等,重在稳定。


容器化部署


简单介绍一下什么是容器,当然我这里默认大家都懂。 容器提供了程序所需要运行的环境, 并将其独立隔离出来,


Docker:  是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中


k8s: 是 Google 开源的一个容器集群管理系统


架构中除了守护进程和引擎扩容服务,所有引擎,前后端服务,数据库服务都使用Docker进行容器化,并使用K8S进行统一管理。


image.png


引擎



  • 引擎扩容服务:可以在判断需要并能够提供多个引擎的时候,开启更多的引擎进行处理。

  • 树状引擎结构: 一个任务可能需要多个引擎协同参与, 而且下级引擎依赖上级引擎的结果

  • 上级引擎的结果需要经过调度服务,通过MQ进行消息传递,调度后再传递给下级引擎

  • 最终结果写入数据库


image.png


调度层




  • 任务调度器:提供任务优先级调度,任务状态管理,任务拆分,任务下放等功能




  • 结果处理器; 提供各引擎对应的结果处理解析功能,有大量的数据库查询,结果计算,字符串解析逻辑,体积非常庞大.




  • 当然优先级调度器和引擎结果处理服务是单独运行在Docker中,需要使用MQ和GFS,数据库进行数据交换




image.png


数据聚合层


也就是node写的BFF数据聚合层,将gin框架(gopher层)获取来的数据进行聚合,格式化成前端所需的数据结构,并提供前端接口。这里就不赘述了


gopher服务层


提供主体服务, 数据库IO等等,体量最大。提供了各种处理模块,接口等。


架构也是一个简单的类node三层架构,


(Router - controller - Service)
带上validator层和数据库操作层(与node中的Model层区别不大)


image.png


守护进程


原生GO写的守护进程,一个部署时直接运行在机器某端口的进程, 主要功能有3个


创建 - 监视 - 代理



  • 它将整个系统包裹起来, 用于监视各个容器的运行情况,

  • 提供了一个用于自动注册Gin框架路由的上级自研框架MobileSuit,系统的每个服务都会通过MS框架进行启动,生成一个Gin路由实例。 并挂载到总路由树上。


image.png



  • 守护进程包裹了所有的服务, 系统各个服务发出的请求都会首先被代理到守护进程上,守护进程将其统一拦截下来, 方便之后的统一请求代理处理。


image.png


对前端人和自己的话


不知道小伙伴们看到我整理出来的架构有什么看法?有没有认识到前端的渺小.
我们一定要正视自己的地位,在寒冬中保持清醒


再聊聊,为什么很多小伙伴会觉得 前端已死?


我想说的是,对比起后端,前端人在几年内吃了太多的互联网红利。这个行业可能需要自我净化,提升整体素质。


我见过包装3年实际一年,连vscode调试都不会的前端人拿很高的月薪。


也见过对算法,原理,底层,优化,都很熟悉的3-4年后端的人拿的不比我这个小外包高多少。


我们前端人一定要明白,普通前端的可替代性实在太强太强。


要么我们深入业务,要么我们深入原理。


对于真正学习计算机科学的人来说,什么webpack代码构建,babel编译,什么react链表结构,vue模板编译,这些看上去比较底层的东西,在他们眼里可能只是基本功,可能只是常识。


如果不深入原理,那么最终真的只能“前端已死”。



  • 想想在刚入行的时候,读了一下某开源工具的源码,我的反应是


“哇这架构好神奇,居然将三层类层层嵌套” “哇一个参数居然能通过观察者模式传三层”



  • 想想在刚入行的时候,看了一下react渲染的原理


"哇他们真聪明,居然能想到将大任务分成多个5ms小任务,运行在浏览器每一帧上,阻止卡顿"


跟一个后端/硬件的朋友讨论,他跟我说"这不是常识吗?调操作系统底层,5ms任务给你掐了"


现在看来,这不过是基础罢了。但很多前端er,连这些都搞不明白,就像原来的我一样。


毕竟,即便是深入了前端的原理,可能也只是到达了软件开发的基本水平吧。


还是借用那句话吧。
所知甚少,唯善学。


前端不会死的,它只是停止了狂奔。


作者:不月阳九
来源:juejin.cn/post/7207617774634451000
收起阅读 »

如何优雅地校验后端接口数据,不做前端背锅侠

web
背景 最近新接手了一批项目,还没来得及接新需求,一大堆bug就接踵而至,仔细一看,应该返回数组的字段返回了 null,或者没有返回,甚至返回了字符串 "null"??? 这我能忍?我立刻截图发到群里,用红框加大加粗重点标出。后端同学也积极响应,答应改正。 第...
继续阅读 »

背景


最近新接手了一批项目,还没来得及接新需求,一大堆bug就接踵而至,仔细一看,应该返回数组的字段返回了 null,或者没有返回,甚至返回了字符串 "null"???


这我能忍?我立刻截图发到群里,用红框加大加粗重点标出。后端同学也积极响应,答应改正。


image.png


第二天,同样的事情又在其他的项目上演,我只是一个小前端,为什么什么错都找我啊!!


日子不能再这样下去,于是我决定写一个工具来解决遇到 bug 永远在找前端的困境。


TypeScript 运行时校验


如何对接口数据进行校验呢,因为我们的项目是 React+TypeScript 写的,所以第一时间就想到了使用 TypeScript 进行数据校验。但是众所周知,TypeScript 用于编译时校验,有没有办法作用到运行时呢?


我还真找到了一些运行时类型校验的库:typescript-needs-types,大部分需要使用指定格式编写代码,相当于对项目进行重构,拿其中 star 最多的 zod 举例,代码如下。


import { z } from "zod";

const User = z.object({
username: z.string(),
});

User.parse({ username: "Ludwig" });

// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }

我宁可查 bug 也不可能重构手里一大堆项目啊。此种方案 ❎。


此时看到了 typescript-json-schema 可以把 TypeScript 定义转为 JSON Schema ,然后再使用 JSON Schema 对数据进行校验就可以啦。这种方案比较灵活,且对代码入侵性较小。


搭建一个项目测试一下!


使用 npx create-react-app my-app --template typescript 快速创建一个 React+TS 项目。


首先安装依赖 npm install typescript-json-schema


创建类型文件 src/types/user.ts


export interface IUserInfo {
staffId: number
name: string
email: string
}

然后创建 src/types/index.ts 文件并引入刚才的类型。


import { IUserInfo } from './user';

interface ILabel {
id: number;
name: string;
color: string;
remark?: string;
}

type ILabelArray = ILabel[];

type IUserInfoAlias = IUserInfo;

接下来在 package.json 添加脚本


"scripts": {
// ...
"json": "typescript-json-schema src/types/index.ts '*' -o src/types/index.json --id=api --required --strictNullChecks"
}

然后运行 npm run json 可以看到新建了一个 src/types/index.json 文件(此步在已有项目中可能会报错报错,可以尝试在 json 命令中添加 --ignoreErrors 参数),打开文件可以看到已经成功转成了 JSON Schema 格式。


{
"$id": "api",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ILabel": {
"properties": {
"color": {
"type": "string"
},
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"remark": {
"type": "string"
}
},
"required": [
"color",
"id",
"name"
],
"type": "object"
},
"ILabelArray": {
"items": {
"$ref": "api#/definitions/ILabel"
},
"type": "array"
},
"IUserInfoAlias": {
"properties": {
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"staffId": {
"type": "number"
}
},
"required": [
"email",
"name",
"staffId"
],
"type": "object"
}
}
}

使用 JSON Schema 校验数据


至于如何使用JSON Schema 校验数据,我找到了现成的库 ajv,至于为什么选择 ajv,主要是因为它说它很快,详见:github.com/ebdrup/json…


image.png


接下来尝试一下。我找到了中文版文档,有兴趣的可以去看下 http://www.febeacon.com/ajv-docs-zh…


先安装依赖 npm install ajv,然后创建文件 src/validate.ts


import Ajv from 'ajv';
import schema from './types/index.json';

const ajv = new Ajv({ schemas: [schema] });

export function validateDataByType(type: string, data: unknown) {
console.log(`开始校验,类型:${type}, 数据:`, data);

var validate = ajv.getSchema(`api#/definitions/${type}`);
if (validate) {
const valid = validate(data);
if (!valid) {
console.log('校验失败', validate.errors);
}
else {
console.log('校验成功');
}
}
}

接下来在 src/index.tsx 添加下面代码来测试一下。


validateDataByType('IUserInfoAlias', {
email: 'idonteatcookie@gmail.com',
name: 'idonteatcookie',
staffId: 12306
})

validateDataByType('IUserInfoAlias', {
email: 'idonteatcookie@gmail.com',
staffId: 12306
})

validateDataByType('IUserInfoAlias', {
email: 'idonteatcookie@gmail.com',
name: 'idonteatcookie',
staffId: '12306'
})

可以在控制台看到成功打印如下信息:


image.png


拦截请求


因为项目中发送请求都是调用统一封装的函数,所以我首先想到的是在函数中增加一层校验逻辑。但是这样的话就与项目代码耦合严重,换一个项目又要再写一份。我真的有好多项目QAQ。


那干脆拦截所有请求统一处理好了。


很容易的找到了拦截所有 XMLHttpRequest 请求的库 ajax-hook,可以非常简单地对请求做处理。


首先安装依赖 npm install ajax-hook,然后创建 src/interceptTool.ts


import { proxy } from 'ajax-hook';
export function intercept() {
// 获取 XMLHttpRequest 发送的请求
proxy({
onResponse: (response: any, handler: any) => {
console.log('xhr', response.response)
handler.next(response);
},
});
}

这样就拦截了所有的 XMLHttpRequest 发送的请求,但是我突然想到我们的项目,好像使用 fetch 发送的请求来着???


好叭,那就再拦截一遍 fetch 发送的请求。


export function intercept() {
// ...
const { fetch: originalFetch } = window;
// 获取 fetch 发送的请求
window.fetch = async (...args) => {
const response = await originalFetch(...args);
response.clone().json().then((data: { result: any }) => {
console.log('window.fetch', args, data);
return data;
});
return response;
};
}

为了证明拦截成功,使用 json-server 搭建一个本地 mock 服务器。首先安装 npm install json-server,然后在根目录创建文件 db.json


{
"user": { "staffId": 1, "name": "cookie1", "email": "cookie@cookie.com" },
"labels": [
{
"id": 1,
"name": "ck",
"color": "red",
"remark": "blabla"
},
{
"id": 2,
"color": "green"
}
]
}

再在 package.json 添加脚本


"scripts": {
"serve": "json-server --watch db.json -p 8000"
},

现在执行 npm run serve 就可以启动服务器了。在 src/index.tsx 增加调用接口的代码,并引入 src/interceptTool.ts


import { intercept } from './interceptTool';
// ... other code
intercept();

fetch('http://localhost:8000/user');

const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:8000/labels');
xhr.send();

image.png


可以看到两种请求都拦截成功了。


校验接口返回数据


胜利在望,只差最后一步,校验返回数据。我们校验数据需要提供两个关键信息,数据本身和对应的类型名,为了将两者对应起来,需要再创建一个映射文件,把 url 和类型名对应起来。


创建文件 src/urlMapType.ts 然后添加内容


export const urlMapType = {
'http://localhost:8000/user': 'IUserInfoAlias',
'http://localhost:8000/labels': 'ILabelArray',
}

我们在 src/validate.ts 新增函数 validateDataByUrl


import { urlMapType } from './urlMapType';
// ...
export function validateDataByUrl(url: string, data: unknown) {
const type = urlMapType[url as keyof typeof urlMapType];
if (!type) {
// 没有定义对应格式不进行校验
return;
}
console.log(`==== 开始校验 === url ${url}`);
validateDataByType(type, data);
}

然后在 src/interceptTool.ts 文件中引用


import { proxy } from 'ajax-hook';
import { validateDataByUrl } from './validate';

export function intercept() {
// 获取 XMLHttpRequest 发送的请求
proxy({
onResponse: (response, handler: any) => {
validateDataByUrl(response.config.url, JSON.parse(response.response));
handler.next(response);
},
});

const { fetch: originalFetch } = window;
// 获取 fetch 发送的请求
window.fetch = async (...args) => {
const response = await originalFetch(...args);
response.json().then((data: any) => {
validateDataByUrl(args[0] as string, data);
return data;
});
return response;
};
}

现在可以在控制台看到接口数据校验的接口辣~ ✿✿ヽ(°▽°)ノ✿


image.png


总结下流程图


image.png


后续规划


目前所做的事情,准确的说不是拦截,只是获取返回数据,然后对比打印校验结果,因为初步目标不涉及数据的处理。


后续会考虑对不合法的数据进行处理,比如应该返回数组但是返回了 null 的情况,如果能自动赋值 [],就可以防止前端页面崩溃的情况了。


参考资料




作者:我不吃饼干
来源:juejin.cn/post/7166061734803963917
收起阅读 »

数据大屏最简单适配方案

web
根据本文内容,开发了以下三个 npm 包,希望大家能用得到 @fit-screen/shared: 提供计算自适应比例相关内容的工具包 @fit-screen/vue:Vue 自适应组件 @fit-screen/react:React 自适应组件 如果本文...
继续阅读 »

根据本文内容,开发了以下三个 npm 包,希望大家能用得到



  1. @fit-screen/shared: 提供计算自适应比例相关内容的工具包

  2. @fit-screen/vue:Vue 自适应组件

  3. @fit-screen/react:React 自适应组件


如果本文对你有帮助,希望大佬能给个 star~



前言


最近公司有个大屏的项目,之前没咋接触过。


就在掘金上看了许多大佬各种方案,最常见的方案无外乎一下 3 种👇,优缺点呢也比较明显


方案实现方式优点缺点
vw, vh按照设计稿的尺寸,将px按比例计算转为vwvh1.可以动态计算图表的宽高,字体等,灵活性较高
2.当屏幕比例跟 ui 稿不一致时,不会出现两边留白情况
1.需要编写公共转换函数,为每个图表都单独做字体、间距、位移的适配,比较麻烦
scale通过 scale 属性,根据屏幕大小,对图表进行整体的等比缩放1.代码量少,适配简单
2.一次处理后不需要在各个图表中再去单独适配
1.因为是根据 ui 稿等比缩放,当大屏跟 ui 稿的比例不一样时,会出现周边留白情况
2.当缩放比例过大时候,字体和图片会有一点点失真.
3.当缩放比例过大时候,事件热区会偏移。
rem + vw vh1.获得 rem 的基准值
2.动态的计算html根元素的font-size
3.图表中通过 vw vh 动态计算字体、间距、位移等
1.布局的自适应代码量少,适配简单1.因为是根据 ui 稿等比缩放,当大屏跟 ui 稿的比例不一样时,会出现周边留白情况
2.图表需要单个做字体、间距、位移的适配

这 3 种方案中,最简单的也最容易抽离为下次使用的当属 scale 方案了。


它优点是:



  1. 代码量少,编写公共组件,套用即可,可以做到一次编写,任何地方可用,无需重复编写。

  2. 使用 flex grid 百分比 还有 position 定位或者完全按照设计稿的 px 单位进行布局,都可以,不需要考虑单位使用失误导致适配不完全。实现数据大屏在任何分辨率的电脑上均可安然运作。


至于说缺点:




  1. 比例不一样的时候,会存在留白,开发大屏基本上都是为对应分辨率专门开发,我觉得这个缺点可以基本忽略,因为我们可以将背景色设置为大屏的基础色,这样留白部分不是太大基本没影响啦,哈哈




  2. 关于失真失真 是在你设置的 分辨率比例屏幕分辨率比例 不同的情况下,依然采用 铺满全屏 出现 拉伸 的时候,才会出现,正常是不会出现的。



    电视看电影比例不对,不也会出现上下黑边吗,你设置拉伸,他也会失真,是一个道理





🚀 开发


让我们先来看下效果吧!👇



既然选择了 scale 方案,那么我们来看看它的原理,以及如何实现吧!


原理


scale 方案是通过 css 的 transform 的 scale 属性来进行一个 等比例缩放 来实现屏幕适配的,既然如此我们要知道一下几个前提:



  1. 设设计稿的 宽高比1,则在任意显示屏中,只要展示内容的容器的 宽高比 也是 1,则二者为 1:1 只要 等比缩放/放大 就可以做到完美展示并且没有任何白边。

  2. 如果设计稿的 宽高比1, 而展示容器 宽高比 不是 1 的时候,则存在两种情况。

    1. 宽高比大于 1,此时宽度过长,计算时基准值采用高度,计算出维持 1 宽高比的宽度。

    2. 宽高比小于 1,此时高度过长,计算时基准值采用宽度,计算出维持 1 宽高比的高度。




代码实现


有了以上前提,我们可以得出以下代码


const el = document.querySelector('#xxx')
// * 需保持的比例
const baseProportion = parseFloat((width / height).toFixed(5))
// * 当前屏幕宽高比
const currentRate = parseFloat((window.innerWidth / window.innerHeight).toFixed(5))

const scale = {
widthRatio: 1,
heightRatio: 1,
}

// 宽高比大,宽度过长
if(currentRate > baseProportion) {
// 求出维持比例需要的宽度,进行计算得出宽度对应比例
scale.widthRatio = parseFloat(((window.innerHeight * baseProportion) / baseWidth).toFixed(5))
// 得出高度对应比例
scale.heightRatio = parseFloat((window.innerHeight / baseHeight).toFixed(5))
}
// 宽高比小,高度过长
else {
// 求出维持比例需要的高度,进行计算得出高度对应比例
scale.heightRatio = parseFloat(((window.innerWidth / baseProportion) / baseHeight).toFixed(5))
// 得出宽度比例
scale.widthRatio = parseFloat((window.innerWidth / baseWidth).toFixed(5))
}

// 设置等比缩放或者放大
el.style.transform = `scale(${scale.widthRatio}, ${scale.heightRatio})`

OK,搞定了。


哇!这也太简单了吧。


好,为了下次一次编写到处使用,我们对它进行封装,然后集成到我们常用的框架中,作为通用组件


function useFitScreen(options) {
const {
// * 画布尺寸(px)
width = 1920,
height = 1080,
el
} = options

// * 默认缩放值
let scale = {
widthRatio: 1,
heightRatio: 1,
}

// * 需保持的比例
const baseProportion = parseFloat((width / height).toFixed(5))
const calcRate = () => {
if (el) {
// 当前比例
const currentRate = parseFloat((window.innerWidth / window.innerHeight).toFixed(5))
// 比例越大,则越宽,基准值采用高度,计算出宽度
// 反之,则越高,基准值采用宽度,计算出高度
scale = currentRate > baseProportion
? calcRateByHeight(width, height, baseProportion)
: calcRateByWidth(width, height, baseProportion)
}

el.style.transform = `scale(${scale.widthRatio}, ${scale.heightRatio})`
}

// * 改变窗口大小重新绘制
const resize = () => {
window.addEventListener('resize', calcRate)
}

// * 改变窗口大小重新绘制
const unResize = () => {
window.removeEventListener('resize', calcRate)
}

return {
calcRate,
resize,
unResize,
}
}

其实一个基本的共用方法已经写好了,但是我们实际情况中,有可能会出现奇怪比例的大屏。


例如:



  1. 超长屏,我们需要 x 轴滚动条。

  2. 超高屏,我们需要 y 轴滚动条。

  3. 还有一种情况,比如需要占满屏幕,不需要留白,适当拉伸失真也无所谓的情况呢。


所以,我们需要进行扩展这个方法,像 节流 节约性能,对上面是那种情况做适配等,文章篇幅有限,源码已经开源并且工具包已经上传了 npm 需要的可以去看源码或者下载使用



  • 工具包源码:使用文档在这里,希望大佬们给一个小小的 star~

  • 工具包NPM: 你可以通过 npm install @fit-screen/shared 下载使用


Vue logo 集成到 Vue


通过以上的的原理和工具包实现,接下来我们接入 Vue 将会变得非常简单了,只需要我们用 Vue 的 ref 将对应的 dom 元素提供给工具包,就可以实现啦~


不过在这个过程中我遇到的问题是,既然是一次编写,任意使用,我们需要集成 Vue2 和 Vue3,如何做呢?


说道这一点想必各位大佬也知道我要用什么了吧,那就是偶像 Anthony Fuvueuse 中使用的插件 vue-demi


好的,开发完毕之后,一样将它上传到 npm ,这样以后就可以直接下载使用了



大家也可以这样使用


npm install @fit-screen/vue @vue/composition-api
# or
yarn add @fit-screen/vue @vue/composition-api
# or
pnpm install @fit-screen/vue @vue/composition-api

当做全局组件使用


// In main.[jt]s
import { createApp } from 'vue'
import FitScreen from '@fit-screen/vue'
import App from './App.vue'

const app = createApp(App)
app.use(FitScreen)
app.mount('#app')

Use in any component


<template>
<FitScreen :width="1920" :height="1080" mode="fit">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo">
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</FitScreen>
</template>

在 SFC 中单独使用


<script setup>
import FitScreen from '@fit-screen/vue'
</script>

<template>
<FitScreen :width="1920" :height="1080" mode="fit">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo">
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</FitScreen>
</template>

react logo 集成到 React


集成到 React 也是完全没毛病,而且好像更简单,不存在 vue2 和 vue3 这样版本兼容问题



大佬们可以这样使用:


npm install @fit-screen/react
# or
yarn add @fit-screen/react
# or
pnpm install @fit-screen/react

import { useState } from 'react'
import FitScreen from '@fit-screen/react'

function App() {
const [count, setCount] = useState(0)

return (
<FitScreen width={1920} height={1080} mode="fit">
<div className="App">
<div>
<a href="https://vitejs.dev" target="_blank" rel="noreferrer">
<img src="/vite.svg" className="logo" alt="Vite logo" />
</a>
<a href="https://reactjs.org" target="_blank" rel="noreferrer">
React logo
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount(count => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</div>
</FitScreen>

)
}

export default App

结尾



  1. 通过工具包可以在无框架和任意前端框架中开发自己的组件,比如说 Svelte,我也做了一个 Svelte 的版本示例,可以去 示例仓库 中查看。

  2. 目前就开发了 Vue 和 React 版本的自适应方案,大家可以根据需要进行使用。


感谢大家的阅读,希望大家能用得上,并且给上 star~


作者:jpliu
来源:juejin.cn/post/7202598910337138748
收起阅读 »

写一个可以当镜子照的 Button

web
最近写了一个好玩的 Button,它除了是一个 Button 外,还可以当镜子照。 那这个好玩的 Button 是怎么实现的呢? 很容易想到是用到了摄像头。 没错,这里要使用浏览器的获取媒体设备的 api 来拿到摄像头的视频流,设置到 video 上,然后对...
继续阅读 »

最近写了一个好玩的 Button,它除了是一个 Button 外,还可以当镜子照。



那这个好玩的 Button 是怎么实现的呢?


很容易想到是用到了摄像头。


没错,这里要使用浏览器的获取媒体设备的 api 来拿到摄像头的视频流,设置到 video 上,然后对 video 做下镜像反转,加点模糊就好了。


button 的部分倒是很容易,主要是阴影稍微麻烦点。


把 video 作为 button 的子元素,加个 overflow:hidden 就完成了上面的效果。


思路很容易,那我们就来实现下吧。


获取摄像头用的是 navigator.mediaDevices.getUserMedia 的 api。


在 MDN 中可以看到 mediaDevices 的介绍:



可以用来获取摄像头、麦克风、屏幕等。


它有这些 api:



getDisplayMedia 可以用来录制屏幕,截图。


getUserMedia 可以获取摄像头、麦克风的输入。



我们这里用到的是 getUserMedia 的 api。


它要指定音频和视频的参数,开启、关闭、分辨率、前后摄像头啥的:



这里我们把 video 开启,把 audio 关闭。


也就是这样:


navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
})
.then((stream) => {
//...
}).catch(e => {
console.log(e)
})

然后把获取到的 stream 用一个 video 来展示:


navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
})
.then((stream) => {
const video = document.getElementById('video');
video.srcObject = stream;
video.onloadedmetadata = () => {
video.play();
};
})
.catch((e) => console.log(e));

就是这样的:



通过 css 的 filter 来加点感觉:


比如加点 blur:


video {
filter: blur(10px);
}


加点饱和度:


video {
filter: saturate(5)
}



或者加点亮度:


video: {
filter: brightness(3);
}


filter 可以组合,调整调整达到这样的效果就可以了:


video {
filter: blur(2px) saturate(0.6) brightness(1.1);
}


然后调整下大小:


video {
width: 300px;
height: 100px;
filter: blur(2px) saturate(0.6) brightness(1.1);
}


你会发现视频的画面没有达到设置的宽高。


这时候通过 object-fit 的样式来设置:


video {
width: 300px;
height: 100px;
object-fit: cover;
filter: blur(2px) saturate(0.6) brightness(1.1);
}

cover 是充满容器,也就是这样:



但画面显示的位置不大对,看不到脸。我想显示往下一点的画面怎么办呢?


可以通过 object-position 来设置:


video {
width: 300px;
height: 100px;
object-fit: cover;
filter: blur(2px) saturate(0.6) brightness(1.1);
object-position: 0 -100px;
}

y 向下移动 100 px ,也就是这样的:



现在画面显示的位置就对了。


其实现在还有一个特别隐蔽的问题,不知道大家发现没,就是方向是错的。照镜子的时候应该左右翻转才对。


所以加一个 scaleX(-1),这样就可以绕 x 周反转了。


video {
width: 300px;
height: 100px;
object-fit: cover;
filter: blur(2px) saturate(0.6) brightness(1.1);
object-position: 0 -100px;
transform: scaleX(-1);
}


这样就是镜面反射的感觉了。


然后再就是 button 部分,这个我们倒是经常写:


function Button({ children }) {
const [buttonPressed, setButtonPressed] = useState(false);

return (
<div
className={`button-wrap ${buttonPressed ? "pressed" : null}`}
>

<div
className={`button ${buttonPressed ? "pressed" : null}`}
onPointerDown={() =>
setButtonPressed(true)}
onPointerUp={() => setButtonPressed(false)}
>
<video/>
</div>
<div className="text">{children}</div>
</div>

);
}

这里我用 jsx 写的,点击的时候修改 pressed 状态,设置不同的 class。


样式部分是这样的:


:root {
--transition: 0.1s;
--border-radius: 56px;
}

.button-wrap {
width: 300px;
height: 100px;
position: relative;
transition: transform var(--transition), box-shadow var(--transition);
}

.button-wrap.pressed {
transform: translateZ(0) scale(0.95);
}

.button {
width: 100%;
height: 100%;
border: 1px solid #fff;
overflow: hidden;
border-radius: var(--border-radius);
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.25), 0px 8px 16px rgba(0, 0, 0, 0.15),
0px 16px 32px rgba(0, 0, 0, 0.125);
transform: translateZ(0);
cursor: pointer;
}

.button.pressed {
box-shadow: 0px -1px 1px rgba(0, 0, 0, 0.5), 0px 1px 1px rgba(0, 0, 0, 0.5);
}

.text {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
color: rgba(0, 0, 0, 0.7);
font-size: 48px;
font-weight: 500;
text-shadow:0px -1px 0px rgba(255, 255, 255, 0.5),0px 1px 0px rgba(255, 255, 255, 0.5);
}

这种 button 大家写的很多了,也就不用过多解释。


要注意的是 text 和 video 都是绝对定位来做的居中。


再就是阴影的设置。


阴影的 4 个值是 x、y、扩散半径、颜色。


我设置了个多重阴影:




然后再改成不同透明度的黑就可以了:



再就是按下时的阴影,设置了上下位置的 1px 黑色阴影:


.button.pressed {
box-shadow: 0px -1px 1px rgba(0, 0, 0, 0.5), 0px 1px 1px rgba(0, 0, 0, 0.5);
}

同时,按下时还有个 scale 的设置:



再就是文字的阴影,也是上下都设置了 1px 阴影,达到环绕的效果:


text-shadow:0px -1px 0px rgba(255, 255, 255, 0.5),0px 1px 0px rgba(255, 255, 255, 0.5);


最后,把这个 video 嵌进去就行了。


完整代码如下:


import React, { useState, useEffect, useRef } from "react";
import "./button.css";

function Button({ children }) {
const reflectionRef = useRef(null);
const [buttonPressed, setButtonPressed] = useState(false);

useEffect(() => {
if (!reflectionRef.current) return;
navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
})
.then((stream) => {
const video = reflectionRef.current;
video.srcObject = stream;
video.onloadedmetadata = () => {
video.play();
};
})
.catch((e) => console.log(e));
}, [reflectionRef]);

return (
<div
className={`button-wrap ${buttonPressed ? "pressed" : null}`}
>

<div
className={`button ${buttonPressed ? "pressed" : null}`}
onPointerDown={() =>
setButtonPressed(true)}
onPointerUp={() => setButtonPressed(false)}
>
<video
className="button-reflection"
ref={reflectionRef}
/>

</div>
<div className="text">{children}</div>
</div>

);
}

export default Button;

body {
padding: 200px;
}
:root {
--transition: 0.1s;
--border-radius: 56px;
}

.button-wrap {
width: 300px;
height: 100px;
position: relative;
transition: transform var(--transition), box-shadow var(--transition);
}

.button-wrap.pressed {
transform: translateZ(0) scale(0.95);
}

.button {
width: 100%;
height: 100%;
border: 1px solid #fff;
overflow: hidden;
border-radius: var(--border-radius);
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.25), 0px 8px 16px rgba(0, 0, 0, 0.15),
0px 16px 32px rgba(0, 0, 0, 0.125);
transform: translateZ(0);
cursor: pointer;
}

.button.pressed {
box-shadow: 0px -1px 1px rgba(0, 0, 0, 0.5), 0px 1px 1px rgba(0, 0, 0, 0.5);
}

.text {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
color: rgba(0, 0, 0, 0.7);
font-size: 48px;
font-weight: 500;
text-shadow:0px -1px 0px rgba(255, 255, 255, 0.5),0px 1px 0px rgba(255, 255, 255, 0.5);
}

.text::selection {
background-color: transparent;
}

.button .button-reflection {
width: 100%;
height: 100%;
transform: scaleX(-1);
object-fit: cover;
opacity: 0.7;
filter: blur(2px) saturate(0.6) brightness(1.1);
object-position: 0 -100px;
}

总结


浏览器提供了 media devices 的 api,可以获取摄像头、屏幕、麦克风等的输入。


除了常规的用途外,还可以用来做一些好玩的事情,比如今天这个的可以照镜子的 button。


它看起来就像我上厕所时看到的这个东西一样😂:



作者:zxg_神说要有光
来源:juejin.cn/post/7206249542752567333
收起阅读 »

测试妹子提了个bug,为什么你多了个options请求?

web
测试妹子给我提了个bug,说为什么一次操作,network里面两个请求。 我脸色一变”不可能,我写的代码明明是一次操作,怎么可能两个请求“。走过去一看,原来是多了个options请求。 ”这个你不用管,这个是浏览器默认发送的一个预检请求“。可是妹子很执着”这可...
继续阅读 »

测试妹子给我提了个bug,说为什么一次操作,network里面两个请求。


我脸色一变”不可能,我写的代码明明是一次操作,怎么可能两个请求“。走过去一看,原来是多了个options请求。


”这个你不用管,这个是浏览器默认发送的一个预检请求“。可是妹子很执着”这可肯定不行啊,明明是一次请求,干嘛要两次呢?“。


”哟呵,挺固执啊,那我就给你讲个明白,到时候你可别说听不懂“。


HTTP的请求分为两种简单请求非简单请求


简单请求


简单请求要满足两个条件:



  1. 请求方法为:HEADGETPOST

  2. header中只能包含以下请求头字段:

    • Accept

    • Accept-Language

    • Content-Language

    • Content-Type: 所指的媒体类型值仅仅限于下列三者之一

      • text/plain

      • multipart/form-data

      • application/x-www-form-urlencoded






浏览器的不同处理方式


对于简单请求来说,如果请求跨域,那么浏览器会放行让请求发出。浏览器会发出cors请求,并携带origin。此时不管服务端返回的是什么,浏览器都会把返回拦截,并检查返回的responseheader中有没有Access-Control-Allow-Origin是否为true,说明资源是共享的,可以拿到。如果没有这个头信息,说明服务端没有开启资源共享,浏览器会认为这次请求失败终止这次请求,并且报错。


非简单请求


只要不满足简单请求的条件,都认为是非简单请求。


发出非简单cors请求,浏览器会做一个http的查询请求(预检请求)也就是optionsoptions请求会按照简单请求来处理。那么为什么会做一次options请求呢?


检查服务器是否支持跨域请求,并且确认实际请求的安全性。预检请求的目的是为了保护客户端的安全,防止不受信任的网站利用用户的浏览器向其他网站发送恶意请求。
预检请求头中除了携带了origin字段还包含了两个特殊字段:



  • Access-Control-Request-Method: 告知服务器实际请求使用的HTTP方法

  • Access-Control-Request-Headers:告知服务器实际请求所携带的自定义首部字段。
    比如:


OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

以上报文中就可以看到,使用了OPTIONS请求,浏览器根据上面的使用的请求参数来决定是否需要发送,这样服务器就可以回应是否可以接受用实际的请求参数来发送请求。Access-Control-Request-Method告知服务器,实际请求将使用 POST 方法。Access-Control-Request-Headers告知服务器,实际请求将携带两个自定义请求标头字段:X-PINGOTHER 与 Content-Type。服务器据此决定,该实际请求是否被允许。


什么时候会触发预检请求呢?



  1. 发送跨域请求时,请求头中包含了一些非简单请求的头信息,例如自定义头(custom header)等;

  2. 发送跨域请求时,使用了 PUT、DELETE、CONNECT、OPTIONS、TRACE、PATCH等请求方法。


我得意的说“讲完了,老妹你听懂了吗?”


妹子说“似懂非懂”


那行吧,带你看下实际场景。(借鉴文章CORS 简单请求+预检请求(彻底理解跨域)的两张图)


image.png


image.png


妹子说“这样就明了很多”,满是崇拜的关闭了Bug。


兄弟们,妹子都懂了,你懂了吗?😄


参考:


CORS 简单请求+预检请求(彻底理解跨域)


OPTIONS | MDN


跨源资源共享(CORS)| MDN


说明一下哈,以上事件是真实事件,只不过当时讲的时候没有那么的详细,😂


作者:翰玥
来源:juejin.cn/post/7206264862657445947
收起阅读 »

前端要对用户的电池负责!

web
前言 我有一个坏习惯:就是下班之后不关电脑,但是电脑一般来说第二天电量不会有什么损失。但是后来突然有一天它开不起来了,上午要罢工?那也不好吧,也不能在光天化日之下划水啊;聪明的我还是找到了原因:电池耗尽了;于是赶紧来查一查原因,到底是什么程序把电池耗尽了呢? ...
继续阅读 »

前言


我有一个坏习惯:就是下班之后不关电脑,但是电脑一般来说第二天电量不会有什么损失。但是后来突然有一天它开不起来了,上午要罢工?那也不好吧,也不能在光天化日之下划水啊;聪明的我还是找到了原因:电池耗尽了;于是赶紧来查一查原因,到底是什么程序把电池耗尽了呢?


这里我直接揭晓答案:是一个Web应用,看来这个锅必须要前端来背了;我们来梳理一下js中有哪些耗电的操作。


js中有哪些耗电的操作


js中有哪些耗电的操作?我们不如问问浏览器有哪些耗电的操作,浏览器在渲染一个页面的时候经历了GPU进程、渲染进程、网络进程,由此可见持续的GPU绘制、持续的网络请求和页面刷新都会导致持续耗电,那么对应到js中有哪些操作呢?


Ajax、Fetch等网络请求


单只是一个AjaxFetch请求不会消耗多少电量,但是如果有一个定时器一直轮询地向服务器发送请求,那么CPU一直持续运转,并且网络进程持续工作,它甚至有可能会阻止电脑进行休眠,就这样它会一直轮询到电脑电池不足而关机;
所以我们应该尽量少地去做轮询查询;


持续的动画


持续的动画会不断地触发GPU重新渲染,如果没有进行优化的话,甚至会导致主线程不断地进行重排重绘等操作,这样会加速电池的消耗,但是js动画与css动画又不相同,这里我们埋下伏笔,等到后文再讲;


定时器


持续的定时器也可能会唤醒CPU,从而导致电池消耗加速;


上面都是一些加速电池消耗的操作,其实大部分场景都是由于定时器导致的电池消耗,那么下面来看看怎么优化定时器的场景;


针对定时器的优化


先试一试定时器,当我们关闭屏幕时,看定时器回调是否还会执行呢?


let num = 0;
let timer = null;
function poll() {
clearTimeout(timer);
timer = setTimeout(()=>{
console.log("测试后台时是否打印",num++);
poll();
},1000*10)
}

结果如下图,即使暂时关闭屏幕,它仍然会不断地执行:


未命名.png


如果把定时器换成requestAnimationFrame呢?


let num = 0;
let lastCallTime = 0;
function poll() {
requestAnimationFrame(() =>{
const now = Date.now();
if(now - lastCallTime > 1000*10){
console.log("测试raf后台时是否打印",num++);
lastCallTime = now;
}
poll();
});
}

屏幕关闭之前打印到了1,屏幕唤醒之后才开始打印2,真神奇!


未命名.png


当屏幕关闭时回调执行停止了,而且当唤醒屏幕时又继续执行。屏幕关闭时我们不断地去轮询请求,刷新页面,执行动画,有什么意义呢?因此才出现了requestAnimationFrame这个API,浏览器对它进行了优化,用它来代替定时器能减少很多不必要的能耗;


requestAnimationFrame的好处还不止这一点:它还能节省CPU,只要当页面处于未激活状态,它就会停止执行,包括屏幕关闭、标签页切换;对于防抖节流函数,由于频繁触发的回调即使执行次数再多,它的结果在一帧的时间内也只会更新一次,因此不如在一帧的时间内只触发一次;它还能优化DOM更新,将多次的重排放在一次完成,提高DOM更新的性能;


但是如果浏览器不支持,我们必须用定时器,那该怎么办呢?


这个时候可以监听页面是否被隐藏,如果隐藏了那么清除定时器,如果重新展示出来再创建定时器:


let num = 0;
let timer = null;
function poll() {
clearTimeout(timer);
timer = setTimeout(()=>{
console.log("测试后台时是否打印",num++);
poll();
},1000*10)
}

document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==="visible"){
console.log("visible");
poll();
} else {
clearTimeout(timer);
}
})

针对动画优化


首先动画有js动画、css动画,它们有什么区别呢?


打开《我的世界》这款游戏官网,可以看到有一个keyframes定义的动画:


未命名.png


切换到其他标签页再切回来,这个扫描二维码的线会突然跳转;


因此可以得出一个结论:css动画在屏幕隐藏时仍然会执行,但是这一点我们不好控制。


js动画又分为三种种:canvas动画、SVG动画、使用js直接操作css的动画,我们今天不讨论SVG动画,先来看一看canvas动画(MuMu官网):


未命名.png


canvas动画在页面切换之后再切回来能够完美衔接,看起来动画在页面隐藏时也并没有执行;


那么js直接操作css的动画呢?动画还是按照原来的方式一直执行,例如大话西游这个官网的”获奖公示“;针对这种情况我们可以将动画放在requestAnimationFrame中执行,这样就能在用户离开屏幕时停止动画执行


上面我们对大部分情况已经进行优化,那么其他情况我们没办法考虑周到,所以可以考虑判断当前用户电池电量来兼容;


Battery Status API兜底


浏览器给我们提供了获取电池电量的API,我们可以用上去,先看看怎么用这个API:


调用navigator.getBattery方法,该方法返回一个promise,在这个promise中返回了一个电池对象,我们可以监听电池剩余量、电池是否在充电;


navigator.getBattery().then((battery) => {
function updateAllBatteryInfo() {
updateChargeInfo();
updateLevelInfo();
updateChargingInfo();
updateDischargingInfo();
}
updateAllBatteryInfo();

battery.addEventListener("chargingchange", () => {
updateChargeInfo();
});
function updateChargeInfo() {
console.log(`Battery charging? ${battery.charging ? "Yes" : "No"}`);
}

battery.addEventListener("levelchange", () => {
updateLevelInfo();
});
function updateLevelInfo() {
console.log(`Battery level: ${battery.level * 100}%`);
}

battery.addEventListener("chargingtimechange", () => {
updateChargingInfo();
});
function updateChargingInfo() {
console.log(`Battery charging time: ${battery.chargingTime} seconds`);
}

battery.addEventListener("dischargingtimechange", () => {
updateDischargingInfo();
});
function updateDischargingInfo() {
console.log(`Battery discharging time: ${battery.dischargingTime} seconds`);
}
});


当电池处于充电状态,那么我们就什么也不做;当电池不在充电状态并且电池电量已经到达一个危险的值得时候我们需要暂时取消我们的轮询,等到电池开始充电我们再恢复操作


后记


电池如果经常放电到0,这会影响电池的使用寿命,我就是亲身经历者;由于Web应用的轮询,又没有充电,于是一晚上就耗完了所有的电,等到再次使用时电池使用时间下降了好多,真是一次痛心的体验;于是后来我每次下班前将Chrome关闭,并关闭所有聊天软件,但是这样做很不方便,而且很有可能遗忘;


如果每一个前端都能关注到自己的Web程序对于用户电池的影响,然后尝试从定时器和动画这两个方面去优化自己的代码,那么我想应该就不会发生这种事情了。注意:桌面端程序也有可能是披着羊皮的狼,就是披着原生程序的Web应用;


参考:
MDN


作者:蚂小蚁
来源:juejin.cn/post/7206331674296746043
收起阅读 »

从零打造现代化绘图框架 Plait

web
我司大概从今年(2022年)年初决定思维导图,经过半年多的研究与实践,我们基于自研的绘图框架初步完成了一个脑图组件并成功集成到我们 PingCode Wiki 中,这篇文章主要探讨下这个绘图框架(Plait)的一些设计点和思维导图落地的一些关键技术。 概论 对...
继续阅读 »

我司大概从今年(2022年)年初决定思维导图,经过半年多的研究与实践,我们基于自研的绘图框架初步完成了一个脑图组件并成功集成到我们 PingCode Wiki 中,这篇文章主要探讨下这个绘图框架(Plait)的一些设计点和思维导图落地的一些关键技术。


概论


对于思维导图、流程图前期我们做了很多调研工作,流程图方向我们研究了 excalidraw 和 react-flow,它们都是基于 react 框架实现的库,在社区中有很高的知名度,思维导图方向我们研究了 mind-elixir、 mindmap-layouts (自动布局算法),在开源领域中思维导图发展不是很好,没有成熟、知名的作品。


mind-exlixir 介绍:



mind-elixir 功能示意图


优点:



  1. 麻雀虽小但五脏俱全

  2. 纯 JS 库、轻量


缺点:



  1. 不依赖前端框架、开发方式和主流的方式不同

  2. 架构设计没有太多可取之处,节点布局方式不易扩展


虽然我们前期的目标是研发 「思维导图」 ,但是最终我们的产品目标应该是做一个 「一体化的交互式绘图画板」 ,包含思维导图、流程图、自由画笔等。


最终调研发现目前开源社区恰恰缺少这样一个一体化的绘图框架,用于实现一体化的交互式绘图编辑器,集思维导图、流程图、自由画笔于一体,所以我们结合做富文本编辑器的经验,重新架构了一套绘图框架(Plait)、拥有插件机制,并在它的基础上实现思维导图插件、落地到 PingCode Wiki 产品中,所以今天分享的主角就是 Plait 框架。


下面正式进入今天的主题,分为四部分:



  1. 绘图框架设计

  2. 思维导图整体方案

  3. 思维导图自动布局算法

  4. 框架历程/未来


一、绘图框架设计


这部分首先会先简单介绍下绘图方案的选型(Canvas vs SVG)考量,然后介绍下 Plait 绘图框架中核心部分的设计:插件机制、数据管理,最后介绍下框架优势。


绘图方案:Canvas vs SVG


Canvas 还是 SVG 其实社区中也没有一个明确的答案,我们参考了一些知名产品的方案选型,SVG 和 Canvas 都有并且实现的效果都不差,比如语雀的白板使用的是 SVG,ProcessOn 使用的是 Canvas,Excalidraw 使用的是 Canvas,drawio 使用的是 SVG 等等。因为我们没有 Canvas 的使用经验,加上我们的思维导图节点希望支持富文本内容,所以暂时选定对 DOM 更友好的 SVG,觉得先按照这个方案试试水。


经过这么长时间的验证,发现基于 SVG 的方案并没有什么明显的不足,性能问题我们也经过验证,支持 1000+ 的思维导图节点渲染完全没有问题、操作依然很流畅。



对于 SVG 绘制我们没有直接使用 SVG 的底层API ,而是使用了一个第三方的绘图库 roughjs。



下面我们看看 Plait 框架「插件机制」的部分,这部分的灵感来源于富文本编辑器框架 Slate。


插件机制


Web 前端的画图领域有很多可以深度研发的业务方向,如何基于同一个框架实现不同业务方向功能开发,就需要用到插件机制了。


插件机制是 Plait 框架一个重要特性,框架底层并不提供具体的业务功能实现,业务功能都需要基于插件机制实现,如下图所示:



插件机制通俗讲就是框架层构建的一座基础桥梁,为实现具体的业务功能提供必要的支持,Plait 插件机制有三个核心要素:



  1. 抽象数据范式(插件数据)

  2. 可复写行为(识别交互)

  3. 可复写渲染(控制渲染)


具体到流程图、思维导图这类绘图场景,它的核心是基于用户交互行为(鼠标、键盘操作)实现符合交互预期的元素绘制、渲染,如果做成可扩展的那就插件开发者可以自定义交互行为、自定义节点元素渲染,基于自定义交互生成插件数据,基于插件数据控制插件元素渲染,构成插件闭环,如下图所示(插件机制闭环示意图):




这部分的核心就是设计可重写方法,目前 Plait 中主要有两类:

第一类用于实现自定义交互:mousedown、mouseup、mousemove、keydow、keyup。

第二类用于实现自定义渲染:drawElement、redrawElement、destroyElement 然后就是框架层与插件衔接部分的设计了,这一部分在 plait/core 中目前被设计的是比较松散的,drawElement 可以返回一个 SVGGElement 类型的 DOM 元素也可以返回一个框架组件,既可以直接衔接框架也可以基于 DOM 的方式对接。

目前 Plait 框架整个是基于 Angular 框架实现的,后续可能会考虑脱离框架的设计模式,这不是本文的重点。



举个例子: 画圆插件三步走



步骤一:定义数据结构


export interface CircleElement {
type: 'cirle';
radius: number;
dot: [x: number, y: number];
}

步骤二:处理画圆交互


board.mousedown = (event: MouseEvent) => {
if (board.cursor === 'circle') {
start = toPoint(event.x, event.y, board.host);
return;
}
mousedown(event);
};
board.mousemove = (event: MouseEvent) => {
if (start) {
end = toPoint(event.x, event.y, board.host);
if (board.cursor === 'circle') {
// fake draw circle
}
return;
}
mousemove(event);
};
board.globalMouseup = (event: MouseEvent) => {
globalMouseup(event);
if (start) {
end = toPoint(event.x, event.y, board.host);
const radius = Math.hypot(end[0] - start[0], end[1] - start[1]);
const circleElement = { type: 'circle', dot: start, radius };
Transforms.insertNode(board, circleElement, [0]);
}
};

步骤三:实现画圆方法


board.drawElement = (context) => {
if (context.elementInstance.element.type === 'circle') {
const roughSVG = HOST_TO_ROUGH_SVG.get(board.host) as RoughSVG;
const circle = context.elementInstance.element as unknown as CircleElement;
roughSVG.circle(circle.dot[0], circle.dot[1], circle.radius);
}
return drawElement(context);
}

这是一个最简单的插件示例,通过框架提供的桥梁实现一个画圆插件:拖放画一个圆 -> 生成对应圆数据 -> 根据数据渲圆。


插件机制大概就是这些内容,下面看看数据管理部分。


数据管理


数据管理是 Plait 框架中非常重要的部分,它是框架的灵魂,前面的插件机制是外在表现,主要包含以下特性:



  1. 提供基础数据模型

  2. 提供基于原子的数据变化方法(Transfroms)

  3. 基于不可变数据模型(基于 Immer)

  4. 提供 Change 机制,与框架配合完成数据驱动渲染

  5. 与插件机制融合,数据修改的过程可以被拦截处理


这些都是非常优秀的特性,既可以完成数据的约束,又可以灵活实现很多复杂高级的需求,感觉这块的设计和实现其实可以算是一种特定场景的状态管理。


框架状态流转图:



上面说到的插件机制的闭环要依赖数据模型状态作为的标准,最终的插件闭环如下图所示:



这里可以列举两个具体的业务场景,都是我们开发中经常落入的陷阱,体现数据管理在的约束作用及灵活性(这部分可能不好理解,谨慎阅读,其实也是框架作用的具体说明):


场景 1: 自动选中根节点


下面这张图是一个需求示意:新建脑图时自动选中根节点并弹出工具栏



新建思维导图自动弹出工具栏示需求意图


这是一个合理的需求,但它不是常规的交互路径(常规路径是用户点击节点,触发节点选中,进而触发工具栏弹出),我们的新同学在最开始的时候就选择了一种不标准的数据修改方式(手动修改数据)去完成这个需求。


常规点选:




框架数据会存储一个选区状态(点击位置或者框选位置),点击操作会触发选区数据变化,选区变化会触发 Change 事件,基于这个机制处理节点选中和工具栏弹出。



自动选中(手动修改数据):



不推荐的操作路径示意图



这里的思路是首先模拟位置(根据自动选择的节点计算),手动修改数据,然后自己手动调用工具栏的弹出、强制刷新界面让节点选中,这就是典型的没有按照框架约束实现需求的例子,前面说到的数据流没有正确运转,需要做很多特殊处理。 不通过 Transfrom 的方式手动修改数据是不被框架允许的,不会触发框架 Change 行为,理论上应该直接抛出异常(很可惜当时没有做到这步)。



自动选中(标准路径):



标准路径就是基于模拟位置通过 Transfrom 的方式修改数据(相当于模拟点击了要自动选中的节点),后面的流程就可以依赖框架机制去控制执行,无须再做很多手动的处理。


场景 2: 思维导图节点删除后节点的选中状态自动切换到临近节点


这是一个很基础的需求,目前我们的实现是拦截节点删除行为(按 Delete/Backspace 键)处理,这样做有两个弊端:



  1. 假如将来要做右键菜单删除需要把这部分的处理代码再写一遍,即使封装成工具方法,也要额外增加一个调用入口。

  2. 一个不太好解决的问题,新建一个节点后按 Ctrl + Z 撤回,无法把选中状态转移到临近节点上,虽然这里撤回执行的也是节点删除操作。


推荐路径:


拦截节点删除操作,前面提到框架统一了数据修改的方法,所以插件开发者可以对数据修改过程进行拦截,这个拦截过程可以在数据修改前,也可以在在数据修改后(Change),在这个地方做出就不会有任何漏掉的场景。


框架作用



  1. 插件机制实现分层

  2. 数据管理实现约束

  3. 配合框架规范数据流


框架最大的意义就是分层解构,降低业务的复杂度,每个领域或者每个模块只处理自己的事情。



比如前端框架,组件化开发,就是能够把一定的逻辑归拢到一个逻辑域中(组件),而不是所有东西杂糅在一起,这是架构演进的趋势。



架构图:



二、思维导图整体方案


这里简单介绍下思维导图插件的整体技术方案,但是不会介绍特别细,因为它是基于 Plait 框架实现,大的方案肯定是与 Plait 框架一致。



思维导图整体技术方案导图


1、整体方案


我们整体是 SVG + Richtext 的方案。


绘图使用 SVG ,目前我们脑图节点、节点连线、展开收起图标等等都是基于 SVG 绘制的。


节点内容使用 foreignObject 包括嵌入到 SVG 中,这种方案使节点内容支持富文本


2、功能方案


脑图核心交互处理及渲染都是可重写方法完成,与 Plait 集成。


脑图插件仅仅负责脑图部分的渲染,至于整个画布的渲染以及画布的移动放大缩小等等是框架底层功能。


因为 Plait 是支持扩展任意元素的,支持渲染多个元素,它只由数据决定,所以它支持同时渲染多个脑图。


底层脑图插件并不包含工具栏实现,它只处理核心交互、渲染、布局。


3、组件化


脑图组件渲染、节点渲染的整体机制就是我们前端经常提到的:组件化、数据驱动,虽然组件内部节点渲染还是创建 DOM、销毁 DOM,但是大的功能还是通过组件来进行划分的。


基于脑图业务里面有两个非常重要的组件:MindmapComponent 、MindmapNodeComponent,MindmapComponent 处理脑图整体的逻辑,比如执行节点布局算法,MindmapNodeComponent 处理某一个节点的逻辑,比如节点绘制、连线绘制、节点主题绘制等。


之所以把这个部分提出来说一下,是因为我觉得这块的思想其实是主流前端框架思想的延续,包括和 Plait 框架整体的机制是统一的。


4、绘图编辑器


这里可以理解为业务层的封装,业务层级决定集成那些扩展插件,以及进一步扩展插件上层功(比如脑图节点工具栏实现),Mindmap 插件层不依赖于我们的组件库和业务组件,所以工具栏这类需要组件库组件的场景统一放到业务层实现,这样 Mindmap 插件层可以减少依赖、保持聚焦。


思维导图具体落地到 PingCode Wiki 业务中,其实有一个更虽复杂、但清晰的分层结构:



三、自动布局算法


节点自动布局是思维导图的一个核心技术,它是思维导图美观以及内容表现力的决定性因素,它关注节点如何分布,这部分说复杂不复杂,说简单也不简单,包含以下几个部分:



  1. 布局分类

  2. 节点抽象

  3. 算法过程

  4. 方向变换

  5. 布局嵌套


布局分类


介绍说明下常规思维导图的布局分类:



示意图


标准布局:



逻辑布局:



缩进布局:



时间线:



鱼骨图:



美学标准


前面说过思维导图对可视化树的展现有很高的要求,需要它是美观(这个就很直观,每个人的审美可能不一样,但是它也应当有一些基础标准)的,所以需要基础的美学标准:



  1. 节点不重叠

  2. 子节点按照指定的顺序排列

  3. 父节点在子节点中心(逻辑布局)

  4. 主轴方向上不同层级节点不重叠


节点抽象


为了简化可视化树的绘制,[Reingold-Tilford] 提出可以把节点之间的间距和绘制连线抽象出来。通过在节点的宽度和高度上添加间隙来添加节点之间的间距。如下图中的实线框显示了原始宽度和高度,虚线框显示了添加间隙后的宽度和高度。



[Reingold-Tilford] 可视化树节点抽象示意图


我们的思维导图自动布局遵循这个抽象:



  1. 节点布局本身不关注节点连线,只关注节点的宽高和间距

  2. 节点从间距中抽象出来(节点宽高和节点间隙作抽象为一个虚拟节点 LayoutNode)

  3. 布局算法基于 LayoutNode 进行布局


节点在布局时它的宽和高已经融合了实际宽高和上下左右的间隙了,这样可以降低自动布局的复杂度,上图其实是我们布局后的结果,节点从间距中抽象出来之后,节点的垂直顶部位置是其父节点的底部坐标,而父节点的底部坐标又是其顶部坐标加上其高度,真实节点与虚拟节点的逻辑关系如下图所示:



LayoutNode 示意图


算法执行过程


算法流程图:



自动布局算法执行流程图


用一个包含三个节点的例子介绍它自动布局过程,理想的结果应当如下图所示:



步骤一、前置操作: 构造 LayoutNode


这个就是前面提到的节点抽象,基于节点宽高和节点之间的间隙构建布局使用的抽象节点,此时三个处于初始状态,x、y 坐标均为零且相互重叠,如下图所示:



初始状态示意图



左边是真实状态,右侧虚线框部分没有特别的意义,只是一个不重叠的示意



步骤二、布局准备: 垂直分离



布局准备:垂直分离示意图


基于节点的父子级关系进行分层,保证垂直方向是父子级节点不重叠(节点 0与节点 1、2不重叠)。


步骤三:分离兄弟节点



分离兄弟节点过程示意图


就是分离「节点 1」和「节点 2」,保证他们水平不重叠。


步骤四:定位父节点



父级节点定位示意图


基于「节点 1」和「节点 2」重新地位父节点「节点 0」的水平位置,保证父节点水平方向上居中与「节点 1」 和「节点 2」。


布局结果:



以上就是一个的完整布局过程(逻辑下布局),逻辑并不复杂,即使多一些层级和节点也只需要递归执行「步骤三」和「步骤四」。


可以看出「逻辑下布局」只用了「算法流程图」中的前四步就完成了,最后一步「方向变换」就是在「逻辑下布局」的基础上通过数学变换的方式实现「逻辑上」「逻辑右」等布局,下面对方向变换进行专门的解释。


方向变换


1、逻辑下 -> 逻辑上



可以看出这是垂直方向上的变换关系,它们应该是基于一个水平线对称,具体的变换关系如下图所示:



逻辑上变换图


可以看最右侧最下方的节点的「y 点」应该就对应的最右侧最上方的节点「y点」,它们的位置关系应该就是:y= y - (y-yBase) * 2 - node.height。



注意上下变换应该只涉及位移,不涉及上下翻转,也就是节点内部的方向不变,y 对应 y`这两个对应的都是节点的下边上的点位。



2、逻辑下 -> 逻辑右



逻辑右示意图


从上图可以看出,这个逻辑变换也不复杂:就是一个垂直到水平的变换过程,反应到布局算法层中 x、y 方向以及节点宽高的变换,比如:



  1. 垂直分层:需要将垂直分层变换为水平分层

  2. 增加 buildTee 过程:基于分层的节点需要将节点宽度变换高度、x 坐标变为 y 坐标



处理水平布局:增加 buildTree 过程示意图


最后在「方向变换」中将宽高和 x、y 再变换回来:



得到布局结果:



3、逻辑右 -> 逻辑左


逻辑右到逻辑左的位置对应关系应该和最上面说逻辑下到逻辑上的类似,这里不再赘述。


方向变换大概就这三种,下面介绍下下布局嵌套的思路。


布局嵌套


先看一个布局嵌套的示意:



上图第二个子节点使用了另外一种布局(缩进布局),这就属于布局嵌套,布局嵌套仍然需要保证前面说到的「美学标准」比如节点不重叠、父节点居中对齐等。


简单思考: 布局嵌套中的那个有独立布局的子树,它对于整体布局的影响在于它的延伸方向的不受控制,但是如果把有独立布局的子树看做一个整体,提前计算出子树的布局,然后把子树作为整体代入布局算法就可以屏蔽子树延伸方式对整体布局的影响。


整体的处理思路如下图所示:



布局嵌套处理思路示意图


这里可以有一个抽象:把有独立布局的子节点抽象成一个黑盒子(我把它叫做 BlackNode),那么子树布局的影响就会被带入到主布局中,而子树的布局可以保持独立性。



关键点:需要先计算出有独立布局的子树的布局,然后才可以计算父节点布局



四、框架历程/未来


从技术调研到架构设想再到架构落地到产品中,历时大概1年左右的时间,核心工作集中在 2022 年的 1-9 月份,大概的时间线如下:



Plait 框架未来的一些设想



结束语


本文主要介绍从零开始做画图应用、自研画图框架、落地思维导图场景的一些技术方案,作为一个 Web 前端开发者有机会做这样的东西个人感觉很幸运,对于 Plait 框架未来还有很多事情要做,希望它可以发展成为一个成熟的开源社区作品,也期待对画图框架有兴趣的同学可以加入到 Plait 的开源建设中。


作者:pubuzhixing
来源:juejin.cn/post/7205604505647988793
收起阅读 »

简述html2canvas遇见的坑点及解决方案

web
前言 大家好,最近公司在做公众号的海报图生成功能,功能不难,但是其中也遇到了一些坑还有一些细节的问题,这里给大家复盘一下,相互借鉴及学习 制作海报选用工具 这里我看了几款生成图片的工具: html2canvas dom-to-image 这里我选用的是ht...
继续阅读 »

前言


大家好,最近公司在做公众号的海报图生成功能,功能不难,但是其中也遇到了一些坑还有一些细节的问题,这里给大家复盘一下,相互借鉴及学习


制作海报选用工具


这里我看了几款生成图片的工具:



这里我选用的是html2canvas,因为大部分人使用这个比较多,而且我也只听过这个🤣,另一个大家可以去自行摸索,毕竟我看github上也有9k的star


image.png


开始使用插件生成


引入插件


// npm 下载插件
npm install html2canvas
// 项目引入插件
import html2canvas from 'html2canvas';

html2canvas的option配置


属性名默认值描述
allowTaintfalse是否允许跨域图像。会污染画布,导致无法使用canvas.toDataURL 方法
backgroundColor#ffffff画布背景色(如果未在DOM中指定),设置null为透明
canvasnull现有canvas元素用作绘图的基础
foreignObjectRenderingfalse如果浏览器支持,是否使用ForeignObject渲染
imageTimeout15000加载图像的超时时间(以毫秒为单位),设置0为禁用超时
ignoreElements(element) => false谓词功能,可从渲染中删除匹配的元素
loggingtrue启用日志以进行调试
onclonenull克隆文档以进行渲染时调用的回调函数可用于修改将要渲染的内容,而不会影响原始源文档
proxynull代理将用于加载跨域图像的网址。如果保留为空,则不会加载跨域图像
removeContainertrue是否清除html2canvas临时创建的克隆DOM元素
scalewindow.devicePixelRatio用于渲染的比例。默认为浏览器设备像素比率
useCORSfalse是否尝试使用CORS从服务器加载图像
widthElement widthcanvas的宽度
heightElement heightcanvas的高度
xElement x-offset裁剪画布X坐标
yElement y-offset裁剪画布X坐标
scrollXElement scrollX渲染元素时要使用的x滚动位置(例如,如果Element使用position: fixed)
scrollXElement scrollY呈现元素时要使用的y-scroll位置(例如,如果Element使用position: fixed)
windowWidthWindow.innerWidth渲染时使用的窗口宽度Element,这可能会影响媒体查询之类的内容
windowHeightWindow.innerHeight渲染时要使用的窗口高度Element,这可能会影响媒体查询之类的内容

调用html2canvas时传入两个参数,第一个参数是dom节点,第二个参数是options配置项(配置项可根据上方表格进行对应配置)


html2canvas(document.body).then(function(canvas) {
document.body.appendChild(canvas);
});

调用方法生成海报


1.获取节点:let img = document.querySelector("#myImg");


2.配置需要参数:


let options = {
useCORS: true,// 开启跨域
backgroundColor: "#caddff",// 背景色
ignoreElements: (ele) => {},// dom节点
scale: 4,// 渲染出来的比例
};

3.调用方法


html2canvas(img, options).then((canvas) => {
let url = canvas.toDataURL("image/png"); // canvas转png(base64)
this.url = url;
this.isShow = false;
});

到这里html2canvas的相关使用及配置就介绍完了,接下来就是遇见的问题


使用时遇见的坑点及解决方案


图片跨域问题


第一次用我就遇见了这个问题,第一个就是百度的方法,配置useCORS: true,// 开启跨域,然后图片标签上加crossorigin="anonymous",但是结果没用,图片依旧跨域,这时候咱们前端就要硬气一点,直接让后端处理,让后端把图片地址改成base64的形式传给你,或者服务器配置跨域


生成海报时图片模糊问题


生成海报如果模糊,建议把配置项的scale配置高一点,生成的canvas图片把盒子固定大小,显示的图片就更清晰


dom之间有一道横杠


本人是在公众号上做生成海报功能,dom元素顶部是两张图片,图片顶部有一道白线,而且两张图片之间还有一道杠(不好形容),后面发现是因为生成这个海报我在公众号上用的是image标签,改成img标签就没用影响了,具体原因应该是uniapp内部处理image标签时的一些样式问题吧,这是我的猜测


注意:app上不支持html2canvas生成海报(我也是调试的时候发现的)


全部代码


这里代码仅供大家参考


<template>
<view class="poster-content">
<view class="poster-img" id="myImg" v-if="isShow">
<img class="flow" src="../../static/QC-code.png" />
<view class="card-item">
<view class="title-card">爽卡优势</view>
<view class="tip-content">
<view class="left">
<p>零月租,随充随用,不用不扣费</p>
<p>全程4G、不限APP、不限速</p>
<p>支持多场景使用</p>
<p>官方正品、品质保证</p>
</view>
<view class="right">
<view class="right-item">
<view class="qr-code">
<img
id="codeImg"
:src="imgUrl"
style="width: 100%; height: 100%"
class="flow"
crossorigin="anonymous"
/>

</view>
<view style="color: #0032d0">扫码免费领取</view>
</view>
</view>
</view>
</view>
</view>
<view v-else class="canvas-img">
<img style="width: 100vw" :src="url" alt="" />
</view>
<view v-if="isShow" style="padding-bottom: 10px">
<view @click="getImage" class="createPoster">点击生成海报</view>
</view>
<view style="padding-bottom: 50px">
<view
@click="close"
class="createPoster"
style="background-color: #fff; color: #4f80e6"
>
关闭</view
>
</view>
</view>
</template>


<script>
import html2canvas from "html2canvas";
export default {
props: {
imgUrl: {
type: String,
default: "",
},
hasQrCode: {
type: Boolean,
default: false,
},
},
data() {
return {
url: "",
isShow: true,
};
},
onShow() {},
methods: {
close() {
this.$emit("closePop");
},
getImage() {
// this.saveImg()
this.saveImg();
},
saveImg() {
let img = document.querySelector("#myImg");
let options = {
useCORS: true,
backgroundColor: "#caddff",
ignoreElements: (ele) => {},
scale: 4,
};
html2canvas(img, options).then((canvas) => {
let url = canvas.toDataURL("image/png"); // canvas转png(base64)
this.url = url;
this.isShow = false;
});
},
},
};
</script>


<style lang="scss" scoped>
.poster-content {
width: 100vw;
background-color: #caddff;
height: calc(100vh - 50px);
overflow: scroll;
margin-top: -50px;
}
.poster-img {
width: 80vw;
margin: 0 auto;
background-color: #caddff;
// display: flex;
// flex-direction: column;
padding: 40upx 20upx;
text-align: center;
.title {
width: 203px;
height: 96px;
}
.flow {
width: 100%;
height: 300px;
}
.card-item {
background-color: #fff;
font-size: 14px;
margin-top: -12upx;
padding: 20upx 0 40upx;
text-align-last: left;
border-radius: 20upx;
.title-card {
color: #0032d0;
font-size: 36upx;
font-weight: 700;
padding-left: 10upx;
}
.tip-content {
display: flex;
justify-content: space-between;
font-size: 26upx;
.left {
flex: 1;
margin-top: 10upx;
& > p {
line-height: 1.5em;
margin-top: 20upx;
padding-left: 40upx;
position: relative;
&::after {
content: "";
position: absolute;
top: calc(50% - 10upx);
left: 8upx;
width: 20upx;
height: 20upx;
border-radius: 20upx;
background-color: #0256ff;
}
}
}
.right {
width: 90px;
font-size: 20upx;
display: flex;
align-items: center;
padding-right: 16upx;
.right-item {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
// height: 143px;
.qr-code {
width: 160upx;
height: 160upx;
background-color: #fff;
border-radius: 10upx;
display: flex;
justify-content: center;
align-items: center;
}
}
}
}
}
.canvas-img {
width: 100vw;
height: calc(100vh - 50px);
overflow: scroll;
}
}
.createPoster {
line-height: 2.8em;
width: 90%;
background-color: #5479f7;
margin: auto;
border-radius: 40upx;
color: #fff;
text-align: center;
}
</style>


结尾


这些就是本人在做海报功能所遇见的一些问题及解决方案,希望掘友们相互学习共同进步,如果有什么描述错误的地方希望给我指正,欢迎大家跟我一起交流


作者:一骑绝尘蛙
来源:juejin.cn/post/7168667322956120101
收起阅读 »

不惑之年谈中年危机

web
今年正式进入不惑之年。按 2011 年统计数据,国内 40 岁以上的程序员约是 1.5%,乐观来看,我进入了前 5% 的群体。 美国 2016 年此比例已经有 12.6%,大家还是应该乐观点。 大家都知道的国内第一代程序员,求伯君/雷军已经退休或做 CEO 了...
继续阅读 »

今年正式进入不惑之年。按 2011 年统计数据,国内 40 岁以上的程序员约是 1.5%,乐观来看,我进入了前 5% 的群体。


美国 2016 年此比例已经有 12.6%,大家还是应该乐观点。


大家都知道的国内第一代程序员,求伯君/雷军已经退休或做 CEO 了,而目前还在活跃的骨灰级程序员有陈皓(左耳朵耗子)。估计之后应该也会有越来越多的老程序员,或者说是目前活跃的程序员变成老程序员,出现在大家的视野。


骨灰级程序员:陈皓(左耳朵耗子)


程序员这个职业发展起来也就 20 年,还是一个很年轻的职业,年龄焦虑这个事情着实没有必要。


换位思考一下,如果我是公司的技术招聘者,15年内工作经验的都是大有前途的,只要你技术过得去,价格合理,我都愿意招你。


相反,15年以上的程序员就没那么受欢迎了。


因为初入职场就是互联网的蓬勃发展期,人才短缺是一直存在的,知识积累不够不要紧,个人品行不妥也不要紧,只要你敢于承担一些压力,那你的职业道路都会比较顺畅。如果再幸运一些,就能进入大厂。


升职加薪,迎娶白富美,走上人生巅峰


但是,这也造成了一些不良的现象,在这行业,投机份子其实挺多的。什么热门就跟风炒一炒,能不能做成不要紧,最主要是自己的 KPI 好看。另外,互联网企业的优待,让他们多少有些娇气。从大厂出来,薪资待遇要翻倍,要股票要期权,要好资源要好项目。


所以,一般的公司未必能容纳这些人。他们也未必愿意去这些公司。于是,35岁危机就来了。


35岁危机


年轻人其实是很难感受到中年危机的,中年危机与年轻失业的区别就像新冠与感冒的区别。你以为你经历了中年危机,实际上只是年轻失业。


今年经济不景气,裁员潮估计让一部分人离开了这个行业。年纪大的想要回炉再造,是很难的。但是,如果你还年轻,平时多积累,我相信能很轻松地找到一份工作的。


如何面对危机?


年轻人都会说苟住,换个正能量的说法是活在当下。


读好书、做好事,是能切切实实忘掉焦虑的。


今年读的《反脆弱》、《心流》和《毫无意义的工作》,将这三本书放在一起,还是很有意思的,能看到不同的观点:


jpg.jpg




  • 《反脆弱》让我对工作也有了新认识。看似很稳定的工作,会有可能让你过度依赖,如果遭遇失业就手足无措了。而类似的士司机,饿一餐饱一顿的,反而平时就很有充足的经验应对收入不稳定的情况。


    今年的形势让人更趋于进入大公司、国企、公务员单位,然后这些稳定的工作就真的这么值得大家去追随吗?越是追求稳定,最后是否会适得其反?




  • 《毫无意义的工作》今年敲醒了不少人,他提醒我们,日常琐碎的工作中消磨了我们的生命。然而这本书更多是情绪的宣泄,并没有什么好的解决方法。




  • 而《心流》则希望我们投入去做事情,只有投入了才会获得心流,才会有幸福感。同时,它让我意识到,无法逃避的事实是,工作占了我们生命的 1/4 时间。如果无法从中获得乐趣,那我们的人生注定是悲剧的。




这几本书都让我重新思考我与工作的关系,即使今年外部恶劣的情况,我们也应该重拾自身的信心,重新找回我们的热情、专注。


在面对人员缩减,项目被砍的情况下,我们也许可以把目光放在现有的项目上。


就前端而言,SSR 做不了,Docker 做不了,那就看看 nginx 缓存优化能不能做;低代码做不了,那就看看页面模板能不能做;开源做不了,就看看公共组件能不能做;什么都做不了,那首屏优化,静态资源优化,图片压缩也是能做的,而且还能做得很深。


R-C (1).jpg


只要你想,总有做不完的事情。并且这些事情,其实是我们要还的技术债务。


而此时也是做好技术积累的时机。


面向对象、设计模式、函数式编程、类型编程、异步编程这些基础都可以恶补一下;网络安全、网络通信、内存、CPU等等向外延伸的各类计算机知识也是我们必须掌握的。


工作上认真对待自己的每一行代码,生活中认真对待自己的每一分钱。


深挖知识,深入研究,懂得越多,焦虑自然就越少。


有足够的知识与经验,你的中年危机也许永远不会来


最后,对于年龄的焦虑,再推荐大家看看方励老师在“一席”的演讲《即使是像我这把年龄的人,好奇心也从来没变过,因为我们还活在人间的》


作者:陈佬昔没带相机
来源:juejin.cn/post/7187069862965936188
收起阅读 »

迄今为止我写过最复杂的算法

web
《亲戚计算器》大概是我迄今为止写过最复杂的算法了,它可能看起来它好像逻辑简单,仅1个方法调用而已,却耗费了我大量的时间!从一开始灵光乍现,想到实现它的初步思路,到如今开源已7年多了。这期间,我一直在不断更新才让它日趋完善,它的工作不仅是对数据的整理,还有我对程...
继续阅读 »

《亲戚计算器》大概是我迄今为止写过最复杂的算法了,它可能看起来它好像逻辑简单,仅1个方法调用而已,却耗费了我大量的时间!从一开始灵光乍现,想到实现它的初步思路,到如今开源已7年多了。这期间,我一直在不断更新才让它日趋完善,它的工作不仅是对数据的整理,还有我对程序逻辑的梳理和设计思路的推敲。


如果你也对传统文化稍微有点兴趣,不妨耐心的看下去……也许你会发现:原理我们日常习以为常的一个称呼,需要考虑那么多细节。


称谓系统的庞大


中国的亲戚称呼系统复杂在于,它对每种亲戚关系都有特定的称呼,同时对于同种关系不同地方、对于不同性别的人都可能有不同的称呼。




  1. 对外国人而言,父母的兄弟姐妹不外乎:uncle、aunt;而对于我们来说,父母的兄弟姐妹有:伯父、叔叔、姑姑、舅舅、姨妈;




  2. 不同地方对同个亲戚的称呼都是不一样的,以爸爸为例,别称包含有:爸爸、父亲、老爸、阿爸、老窦、爹地、老汉、老爷子等等;




  3. 不同关系链可能具有相同的称呼;比如“舅公”一词,可以是父母亲的舅舅,也可以是老公的舅舅,而这两种关系辈分却不同。究其原因我猜测是,传统上由姻亲产生的亲戚关系,为表达谦卑会自降一辈,随子女称呼配偶的长辈。




  4. 一个称呼中可能是多种关系的合称。比如:“父母”、“子女”、“公婆”,他们不是指代一个人物关系,而是几个关系的合称。




在设计这套算法的时候,我希望它能尽量包含各种称呼、各种关系链,因为我之所以做这个项目就是像让它真正集合多种需求,否则如果它不够全面那终究是个代码演示而已。


关系网络的表达


亲戚的关系网络是以血缘和婚姻为纽带联系在一起的,每个节点都是一个人,每个人都有诸如:父、母、兄、弟、姐、妹、子、女、夫、妻这样的基础关系。关系网络中的节点数量随着层级的加深而指数增长!如果是5层关系,大概就有9x9x9x9x9 = 59049种关系了(当然,这其中有小部分是重复的)。如果想要把几万个关系,数十万个称呼全部尽收其中显然是不可能的,没人有那个精力去维护。


xixik_627466c7fa1e646e.jpg


如何将亲戚关系网络中每个节点之间的关系用数据结构表现出来是一个难点。它需要保证数据量尽量全、占用体积小、易检索、可扩展等特点,这样才能保证算法检索关系时的完整性和高效性。


网络的寻址问题


既然是计算,那一定不是简单通过父、母、子、女等这些基础关系找对应称呼了。否则这就是简单的字典查询而已,谈不上算法。如果问的是:“舅妈的儿子的奶奶的外孙”又该如何呢?首先,需要在网络中找到单一称呼,如“舅妈”,而下一步找她的“儿子”,而非你自己的“儿子”。这就要求有类似于指针的功能,关系链每往前走一步,指针就指引着关系的节点,最终需找到答案。


而就像前面说到的一样,某些称谓可能对应多条关系,同时有些关系并不是唯一的。比方说你爸爸的儿子就是你吗?有没有可能是弟弟或者哥哥?而这些是不是同时取决于你的性别呢?
因为如果你是女的,那么你爸爸的儿子必然不是你呀!


这就对算法提出了一个要求,它必须准确的包含多种可能性。



年龄和性别的推测


随着关系链的复杂,最终得到的答案也有多种。那有没有一种可能,在对关系链的描述中是否存在一些词,可以通过逻辑判断知道对方的性别或年纪大小,进而排除一些不可呢?


例如“爱人的婆婆的儿子”,单从“爱人”二字我们并不能推测自己的性别,而后的“婆婆”确是只有女性才有的亲戚,“爱人的婆婆”就足以推断自己是男的,那么“爱人的婆婆的儿子”必然包含自己。相反,“爱人的婆婆的女儿”一定不是自己,只能是自己的姊妹。




再比如:自己哥哥的表哥也是你的表哥,你弟弟的表哥还是你表哥吗?因为你无法判断你弟弟和他的表哥谁大,自然无法判断对方是你的表哥还是表弟。既然都有可能存在,就需要保留可能性进一步计算。这就涉及到了在关系链的计算中不仅仅需要考虑隐藏的性别线索,还有年龄线索。




身份角度的切换


单从亲戚和自己的关系链条中开始算亲戚的称呼,仅仅是单向的推算,只需要一个个关系往下算就好。如果想知道对方称呼为我什么,这就需要站在对方的角度,重新逆向的调理出我和他之间的关系了。比如我的“外孙”应该叫我什么?



另一方面,如果把我置身于第三者,想知道我的两个亲戚他们之间如何称呼,就必须要同时站在两个亲戚的角度,看待他们彼此之间的关系了。比如:我的“舅妈”该叫我的“外婆”什么呢?



年龄排序的问题


前面说到的都是对不同关系链中的可能性推敲,那如果相同的关系如何判断年龄呢?如果你有3个舅舅呢?虽然不管哪个舅舅,他们对于你的关系都一样,他们的老婆你都得叫声“舅妈”。但他们毕竟有年龄区别,自然就有长幼的排序了。有了排序,就又引发了对他们之间关系的思考。


还是举例说明下:“舅舅”和“舅妈”是什么关系?相信大部分第一反应就是夫妻关系呗!其实不尽然,毕竟有些人不会只有一个舅舅吧?那“大舅妈”和“二舅”就不是夫妻关系了,他们是叔嫂关系呀。“二舅”得管“大舅妈”叫“嫂子”,“大舅妈”得管“二舅”叫“小叔子”。




再进一步说,“二舅的儿子”得叫“大舅妈”为“伯母”,“大舅的儿子”得叫“二舅”为“二叔”。这些由父辈的排序问题影响自己称谓的不同,而是我这套算法需要考虑的内容。




怎么样?是不是没有想象中的那么简单?
如果你想了解更多实现和思路的细节,可以关注本项目开源代码哦:github.com/mumuy/relat…


你也可以在此了解算法的基础原理:算法实现原理介绍


作者:passer-by6061
来源:juejin.cn/post/7203734711779196986
收起阅读 »

你可能忽略的10种JavaScript快乐写法

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

你可能忽略的10种JavaScript快乐写法


前言



  • 代码的简洁、美感、可读性等等也许不影响程序的执行,但是却对人(开发者)的影响非常之大,甚至可以说是影响开发者幸福感的重要因素之一;

  • 了解一些有美感的代码,不仅可以在一定程度上提高程序员们的开发效率,有些还能提高代码的性能,可谓是一举多得;


笔者至今难以忘记最开始踏入程序员领域时接触的一段List内嵌for的Python代码:


array = [[16, 3, 7], [2, 24, 9], [4, 1, 12]]
row_min = [min(row) for row in array ]
print(row_min)

这可能就是动态语言非常优秀的一点,而JavaScript同样作为动态语言,其中包含的优秀代码片段也非常之多,比如我们通过JavaScript也可以非常轻松地实现上述的功能:


const array = [[16, 3, 7], [2, 24, 9], [4, 1, 12]]
const row_min = array.map(item => Math.min(...item))
console.log(row_min)

能写出优秀的代码一直是笔者所追求的,以下为笔者在开发阅读过程积累的一些代码片段以及收集了互联网上一些优秀代码片段,希望对你有所帮助


概述


这里,考虑到有些技巧是大家见过的或者说是已经烂熟于心的,但总归有可能有些技巧没有留意过,为了让大家更加清楚的找到自己想要查阅的内容以查漏补缺,所以这里笔者贴心地为大家提供了一张本文内容的索引表,供大家翻阅以快速定位,如下:


应用场景标题描述补充1补充2
数组去重通过内置数据解构特性进行去重[] => set => []通过遍历并判断是否存在进行去重[many items].forEach(item => (item <不存在于> uniqueArr) && uniqueArr.push(item))
数组的最后一个元素获取数组中位置最后的一个元素使用at(-1)
数组对象的相关转换对象到数组:Object.entries()数组到对象:Obecjt.fromEntries()
短路操作通过短路操作避免后续表达式的执行a或b:a真b不执行a且b:a假b不执行
基于默认值的对象赋值通过对象解构合并进行带有默认值的对象赋值操作{...defaultData, ...data}
多重条件判断优化单个值与多个值进行对比判断时,使用includes进行优化[404,400,403].includes
交换两个值通过对象解构操作进行简洁的双值交换[a, b] = [b, a]
位运算通过位运算提高性能和简洁程度
replace()的回调通过传入回调进行更加细粒度的操作
sort()的回调通过传入回调进行更加细粒度的操作根据字母顺序排序根据真假值进行排序

数组去重


这不仅是我们平常编写代码时经常会遇到的一个功能实现之一,也是许多面试官在考查JavaScript基础时喜欢考查的题目,比较常见的基本有如下两类方法:


1)通过内置数据结构自身特性进行去重


主要就是利用JavaScript内置的一些数据结构带有不包含重复值的特性,然后通过两次数据结构转换的消耗[] => set => []从而达到去重的效果,如下演示:


const arr = ['justin1go', 'justin2go', 'justin2go', 'justin3go', 'justin3go', 'justin3go'];
const uniqueArr = Array.from(new Set(arr));
// const uniqueArr = [...new Set(arr)];

2)通过遍历并判断是否存在进行去重


白话描述就是:通过遍历每一项元素加入新数组,新数组存在相同的元素则放弃加入,伪代码:[many items].forEach(item => (item <不存在于> uniqueArr) && uniqueArr.push(item))


至于上述的<不存在于>操作,可以是各种各样的方法,比如再开一个for循环判断新数组是否有相等的,或者说利用一些数组方法判断,如indexOfincludesfilterreduce等等


const arr = ['justin1go', 'justin2go', 'justin2go', 'justin3go', 'justin3go', 'justin3go'];
const uniqueArr = [];
arr.forEach(item => {
// 或者!uniqueArr.includes(item)
if(uniqueArr.indexOf(item) === -1){
uniqueArr.push(item)
}
})

结合filter(),判断正在遍历的项的index,是否是原始数组的第一个索引:


const arr = ['justin1go', 'justin2go', 'justin2go', 'justin3go', 'justin3go', 'justin3go'];
const uniqueArr = arr.filter((item, index) => {
return arr.indexOf(item, 0) === index;
})

结合reduce(),prev初始设为[],然后依次判断cur是否存在于prev数组,如果存在则加入,不存在则不动:


const arr = ['justin1go', 'justin2go', 'justin2go', 'justin3go', 'justin3go', 'justin3go'];
const uniqueArr = arr.reduce((prev,cur) => prev.includes(cur) ? prev : [...prev,cur],[]);

数组的最后一个元素


对于获取数组的最后一个元素,可能平常见得多的就是arr[arr.length - 1],我们其实可以使用at()方法进行获取


const arr = ['justin1go', 'justin2go', 'justin3go'];
console.log(arr.at(-1)) // 倒数第一个值
console.log(arr.at(-2)) // 倒数第二个值
console.log(arr.at(0)) // 正数第一个
console.log(arr.at(1)) // 正数第二个


注:node14应该是不支持的,目前笔者并不建议使用该方法,但获取数组最后一个元素是很常用的,就应该像上述语法一样简单...



数组对象的相互转换



    const entryified = [
["key1", "justin1go"],
["key2", "justin2go"],
["key3", "justin3go"]
];

const originalObject = Object.fromEntries(entryified);
console.log(originalObject);

短路操作


被合理运用的短路操作不仅非常的优雅,还能减少不必要的计算操作


1)基本介绍


主要就是||或操作、&&且操作当第一个条件(左边那个)已经能完全决定整个表达式的值的时候,编译器就会跳过该表达式后续的计算



  • 或操作a || b:该操作只要有一个条件为真值时,整个表达式就为真;即a为真时,b不执行;

  • 且操作a && b:该操作只要有一个条件为假值时,整个表达式就为假;即a为假时,b不执行;


2)实例


网络传输一直是前端的性能瓶颈,所以我们在做一些判断的时候,可以通过短路操作减少请求次数:


const nextStep = isSkip || await getSecendCondition();
if(nextStep) {
openModal();
}

还有一个经典的代码片段:


function fn(callback) {
// some logic
callback && callback()
}

基于默认值的对象赋值



  • 很多时候,我们在封装一些函数或者类时,会有一些配置参数。

  • 但这些配置参数通常来说会给出一个默认值,而这些配置参数用户是可以自定义的

  • 除此之外,还有许许多多的场景会用到的这个功能:基于默认值的对象赋值。


function fn(setupData) {
const defaultSetup = {
email: "justin3go@qq.com",
userId: "justin3go",
skill: "code",
work: "student"
}
return { ...defaultSetup, ...setupData }
}

const testSetData = { skill: "sing" }
console.log(fn(testSetData))

如上{ ...defaultSetup, ...setupData }就是后续的值会覆盖前面key值相同的值。


多重条件判断优化


if(condtion === "justin1go" || condition === "justin2go" || condition === "justin3go"){
// some logic
}

如上,当我们对同一个值需要对比不同值的时候,我们完全可以使用如下的编码方式简化写法并降低耦合性:


const someConditions = ["justin1go", "justin2go", "justin3go"];
if(someConditions.includes(condition)) {
// some logic
}

交换两个值


一般来说,我们可以增加一个临时变量来达到交换值的操作,在Python中是可以直接交换值的:


a = 1
b = 2
a, b = b, a

而在JS中,也可以通过解构操作交换值;


let a = 1;
let b = 2;
[a, b] = [b, a]

简单理解一下:



  • 这里相当于使用了一个数组对象同时存储了a和b,该数组对象作为了临时变量

  • 之后再将该数组对象通过解构操作赋值给a和b变量即可


同时,还有种比较常见的操作就是交换数组中两个位置的值:


const arr = ["justin1go", "justin2go", "justin3go"];
[arr[0], arr[2]] = [arr[2], arr[0]]

位运算


关于位运算网上的讨论参差不齐,有人说位运算性能好,简洁;也有人说位运算太过晦涩难懂,不够易读,这里笔者不发表意见,仅仅想说的是尽量在使用位运算代码的时候写好注释!


下面为一些常见的位运算操作,参考链接


1 ) 使用&运算符判断一个数的奇偶


// 偶数 & 1 = 0
// 奇数 & 1 = 1
console.log(2 & 1) // 0
console.log(3 & 1) // 1

2 ) 使用~, >>, <<, >>>, |来取整


console.log(~~ 6.83)    // 6
console.log(6.83 >> 0) // 6
console.log(6.83 << 0) // 6
console.log(6.83 | 0) // 6
// >>>不可对负数取整
console.log(6.83 >>> 0) // 6

3 ) 使用^来完成值交换


var a = 5
var b = 8
a ^= b
b ^= a
a ^= b
console.log(a) // 8
console.log(b) // 5

4 ) 使用&, >>, |来完成rgb值和16进制颜色值之间的转换


/**
* 16进制颜色值转RGB
*
@param {String} hex 16进制颜色字符串
*
@return {String} RGB颜色字符串
*/

function hexToRGB(hex) {
var hexx = hex.replace('#', '0x')
var r = hexx >> 16
var g = hexx >> 8 & 0xff
var b = hexx & 0xff
return `rgb(${r}, ${g}, ${b})`
}

/**
* RGB颜色转16进制颜色
*
@param {String} rgb RGB进制颜色字符串
*
@return {String} 16进制颜色字符串
*/

function RGBToHex(rgb) {
var rgbArr = rgb.split(/[^\d]+/)
var color = rgbArr[1]<<16 | rgbArr[2]<<8 | rgbArr[3]
return '#'+ color.toString(16)
}
// -------------------------------------------------
hexToRGB('#ffffff') // 'rgb(255,255,255)'
RGBToHex('rgb(255,255,255)') // '#ffffff'

replace()的回调函数


之前写过一篇文章介绍了它,这里就不重复介绍了,F=>传送


sort()的回调函数


sort()通过回调函数返回的正负情况来定义排序规则,由此,对于一些不同类型的数组,我们可以自定义一些排序规则以达到我们的目的:



  • 数字升序:arr.sort((a,b)=>a-b)

  • 按字母顺序对字符串数组进行排序:arr.sort((a, b) => a.localeCompare(b))

  • 根据真假值进行排序:


const users = [
{ "name": "john", "subscribed": false },
{ "name": "jane", "subscribed": true },
{ "name": "jean", "subscribed": false },
{ "name": "george", "subscribed": true },
{ "name": "jelly", "subscribed": true },
{ "name": "john", "subscribed": false }
];

const subscribedUsersFirst = users.sort((a, b) => Number(b.subscribed) - Number(a.subscribed))

最后



  • 个人能力有限,并且代码片段这类东西每个人的看法很难保持一致,不同开发者有不同的代码风格,这里仅仅整理了一些笔者自认为还不错的代码片段;

  • 可能互联网上还存在着许许多多的优秀代码片段,笔者也不可能全部知道;

  • 所以,如果你有一些该文章中没有包含的优秀代码片段,就不要藏着掖着了,分享出来吧~


同时,如本文有所错误,望不吝赐教,友善指出🤝


Happy Coding!🎉🎉🎉


QQ图片20230223164124.gif


QQ图片20230223164124.gif


QQ图片20230223164124.gif


参考


作者:Justin3go
来源:juejin.cn/post/7203243879255277623
收起阅读 »

如何开发一个人人爱的组件?

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

组件,是前端最常打交道的东西,对于 React、Vue 等应用来说,万物皆组件毫不为过。


有些工作经验的同学都知道,组件其实也分等级的,有的组件可以被上万开发者复用,有些组件就只能在项目中运行,甚至挪动到自己的另外一个项目都不行。


如何考察一个前端的水平,首先可以看看他有没有对团队提供过可复用的组件,一个前端如果一直只能用自己写的东西,或者从没有对外提供过可复用的技术,那么他对于一个团队的贡献一定是有限的。


所以开始写一个能开放的组件应该考虑些什么呢?🤔


本篇文章类似一个菜谱,比较零碎的记录一些组件设计的内容,我分别按照 1~5 ⭐️ 区分其重要性。


意识


首先在意识层面,我们需要站在使用组件的开发者角度来观察这个组件,所以下面几点需要在组件开发过程中种在意识里面:



  1. 我应该注重 TypeScript API 定义,好用的组件 API 都应该看上去 理所应当绝不多余

  2. 我应该注重 README 和 Mock ,一个没有文档的组件 = 没有,最好不要使用 link 模式去开发组件

  3. 我不应引入任何副作用依赖,比如全局状态(Vuex、Redux),除非他们能自我收敛

  4. 我在开发一个开放组件,以后很有可能会有人来看我的代码,我得写好点


接口设计


好的 Interface 是开发者最快能搞清楚组件入参的途径,也是让你后续开发能够有更好代码提示的前提。


type Size = any; // 😖 ❌

type Size = string; // 🤷🏻‍♀️

type Size = "small" | "medium" | "large"; // ✅

`


DOM 属性(⭐️⭐️⭐️⭐️⭐️)

组件最终需要变成页面 DOM,所以如果你的组件不是那种一次性的,请默认顺手定义基础的 DOM 属性类型。className 可以使用 classnames 或者 clsx 处理,别再用手动方式处理 className 啦!


export interface IProps {
className?: string;
style?: React.CSSProperties;
}

对于内部业务来说,还会有 data-spm 这类 dom 属性,主要用于埋点上报内容,所以可以直接对你的 Props 类型做一个基础封装:


export type CommonDomProps = {
className?: string;
style?: React.CSSProperties;
} & Record<`data-${string}`, string>

// component.tsx
export interface IComponentProps extends CommonDomProps {
...
}

// or

export type IComponentProps = CommonDomProps & {
...
}

类型注释(⭐️⭐️⭐️)


  1. export 组件 props 类型定义

  2. 为组件暴露的类型添加 规范的注释


export type IListProps{
/**
* Custom suffix element.
* Used to append element after list
*/

suffix?: React.ReactNode;
/**
* List column definition.
* This makes List acts like a Table, header depends on this property
* @default []
*/

columns?: IColumn[];
/**
* List dataSource.
* Used with renderRow
* @default []
*/

dataSource?: Array<Record<string, any>>;
}

上面的类型注释就是一个规范的类型注释,清晰的类型注释可以让消费者,直接点击进入你的类型定义中查看到关于这个参数的清晰解释。


同时这类符合 jsdoc 规范的类型注释,也是一个标准的社区规范。利用 vitdoc 这类组件 DEMO 生成工具也可以帮你快速生成美观的 API 说明文档。



Tips:如果你非常厌倦写这些注释,不如试试著名的 AI 代码插件:Copilot ,它可以帮你快速生成你想要表达的文字。



以下是 ❌ 错误示范:


  toolbar?: React.ReactNode; // List toolbar.

// 👇🏻 Columns
// defaultValue is "[]"
columns?: IColumns[];

组件插槽(⭐️⭐️⭐️)

对于一个组件开发新手来说,往往会犯 string 类型替代 ReactNode 的错误。


比如要对一个 Input 组件定义一个 label 的 props ,许多新手开发者会使用 string 作为 label 类型,但这是错误的。


export type IInputProps = {
label?: string; // ❌
};

export type IInputProps = {
label?: React.ReactNode; // ✅
};

遇到这种类型时,需要意识到我们其实是在提供一个 React 插槽类型,如果在组件消费中仅仅是让他展示出来,而不做其他处理的话,就应当使用 ReactNode 类型作为类型定义。



受控 与 非受控(⭐️⭐️⭐️⭐️⭐️)

如果要封装的组件类型是 数据输入 的用途,也就是存在双向绑定的组件。请务必提供以下类型定义:


export type IFormPropsstring> = {
value?: T;
defaultValue?: T;
onChange?: (value: T, ...args) => void;
};

并且,这类接口定义不一定是针对 value, 其实对于所有有 受控需求 的组件都需要,比如:


export type IVisibleProps = {
/**
* The visible state of the component.
* If you want to control the visible state of the component, you can use this property.
* @default false
*/

visible?: boolean;
/**
* The default visible state of the component.
* If you want to set the default visible state of the component, you can use this property.
* The component will be controlled by the visible property if it is set.
* @default false
*/

defaultVisible?: boolean;
/**
* Callback when the visible state of the component changes.
*/

onVisibleChange?: (visible: boolean, ...args) => void;
};

具体原因请查看: 《受控组件和非受控组件》
消费方式推荐使用:ahooks useControllableValue


表单类常用属性(⭐️⭐️⭐️⭐️)

如果你正在封装一个表单类型的组件,未来可能会配合 antd/ fusionForm 组件来消费,以下这些类型定义你可能会需要到:


export type IFormProps = {
/**
* Field name
*/

name?: string;

/**
* Field label
*/

label?: ReactNode;

/**
* The status of the field
*/

state?: "loading" | "success" | "error" | "warning";

/**
* Whether the field is disabled
* @default false
*/

disabled?: boolean;

/**
* Size of the field
*/

size?: "small" | "medium" | "large";

/**
* The min value of the field
*/

min?: number;

/**
* The max value of the field
*/

max?: number;
};

选择类型(⭐️⭐️⭐️⭐️)

如果你正在开发一个需要选择的组件,可能以下类型你会用到:


export interface ISelectionextends object = Record<string, any>> {
/**
* The mode of selection
* @default 'multiple'
*/

mode?: "single" | "multiple";
/**
* The selected keys
*/

selectedRowKeys?: string[];
/**
* The default selected keys
*/

defaultSelectedRowKeys?: string[];
/**
* Max count of selected keys
*/

maxSelection?: number;
/**
* Whether take a snapshot of the selected records
* If true, the selected records will be stored in the state
*/

keepSelected?: boolean;
/**
* You can get the selected records by this function
*/

getProps?: (
record: T,
index: number
) =>
{ disabled?: boolean; [key: string]: any };
/**
* The callback when the selected keys changed
*/

onChange?: (
selectedRowKeys: string[],
records?: Array,
...args: any[]
) =>
void;
/**
* The callback when the selected records changed
* The difference between `onChange` is that this function will return the single record
*/

onSelect?: (
selected: boolean,
record: T,
records: Array,
...args: any[]
) =>
void;
/**
* The callback when the selected all records
*/

onSelectAll?: (
selected: boolean,
keys: string[],
records: Array,
...args: any[]
) =>
void;
}

另外,单选与多选存在时,组件的 value 可能会需要根据下传的 mode 自动变化数据类型。


比如,在 Select 组件中就会有以下区别:


mode="single" -> value: string | number
mode="multiple" -> value: string[] | number[]

所以对于需要 多选、单选 的组件来说,value 的类型定义会有更多区别。


对于这类场景可以使用 Merlion UI - useCheckControllableValue 进行抹平。


组件设计


服务请求(⭐️⭐️⭐️⭐️⭐️)

这是一个在业务组件设计中经常会遇到的组件设计,对于很多场景来说,或许我们只是需要替换一下请求的 url ,于是便有了类似下面这样的 API 设计:


export type IAsyncProps {
requestUrl?: string;
extParams?: any;
}

后面接入方增多后,出现了后端的 API 结果不符合组件解析逻辑,或者出现了需要请求多个 API 组合后才能得到组件所需的数据,于是一个简单的请求就出现了以下这些参数:


export type IAsyncProps {
requestUrl?: string;
extParams?: any;
beforeUpload?: (res: any) => any
format?: (res: any) => any
}

这还只是其中一个请求,如果你的业务组件需要 2 个、3 个呢?组件的 API 就会变得越来越多,越来越复杂,这个组件慢慢的也就变得没有易用性 ,也慢慢没有了生气。


对于异步接口的 API 设计最佳实践应该是:提供一个 Promise 方法,并且详细定义其入参、出参类型。


export type ProductList = {
total: number;
list: Array<{
id: string;
name: string;
image: string;
...
}>
}
export type AsyncGetProductList = (
pageInfo: { current: number; pageSize: number },
searchParams: { name: string; id: string; },
) =>
Promise<ProductList>;


export type IComponentProps = {
/**
* The service to get product list
*/

loadProduct?: AsyncGetProductList;
}

通过这样的参数定义后,对外只暴露了 1 个参数,该参数类型为一个 async 的方法。开发者需要下传一个符合上述入参和出参类型定义的函数。


在使用时组件内部并不关心请求是如何发生的,使用什么方式在请求,组件只关系返回的结果是符合类型定义的即可。


这对于使用组件的开发者来说是完全白盒的,可以清晰的看到需要下传什么,以及友好的错误提示等等。


Hooks(⭐️⭐️⭐️⭐️⭐️)

很多时候,或许你不需要组件!


对于很多业务组件来说,很多情况我们只是在原有的组件基础上封装一层浅浅的业务服务特性,比如:



  • Image Uploader:Upload + Upload Service

  • Address Selector: Select + Address Service

  • Brand Selector: Select + Brand Service

  • ...


而对于这种浅浅的胶水组件,实际上组件封装是十分脆弱的。


因为业务会对 UI 有各种调整,对于这种重写成本极低的组件,很容易导致组件的垃圾参数激增。


实际上,对于这类对服务逻辑的状态封装,更好的办法是将其封装为 React Hooks ,比如上传:


export function Page() {
const lzdUploadProps = useLzdUpload({ bu: "seller" });

return <Upload {...lzdUploadProps} />;
}

这样的封装既能保证逻辑的高度可复用性,又能保证 UI 的灵活性。


Consumer(⭐️⭐️⭐️)

对于插槽中需要使用到组件上下文的情况,我们可以考虑使用 Consumer 的设计进行组件入参设计。


比如 Expand 这个组件,就是为了让部分内容在收起时不展示。



对于这种类型的组件,明显容器内的内容需要拿到 isExpand 这个关键属性,从而决定索要渲染的内容,所以我们在组件设计时,可以考虑将其设计成可接受一个回调函数的插槽:


export type IExpandProps = {
children?: (ctx: { isExpand: boolean }) => React.ReactNode;
};

而在消费侧,则可以通过以下方式轻松消费:


export function Page() {
return (
<Expand>
{({ isExpand }) => {
return isExpand ? <Table /> : <AnotherTable />;
}}
Expand>

);
}

文档设计


package.json(⭐️⭐️⭐️⭐️⭐️)

请确保你的 repository是正确的仓库地址,因为这里的配置是很多平台溯源的唯一途径,比如: github.com



请确保 package.json 中存在常见的入口定义,比如 main \ module \ types \ exports,以下是一个标准的 package.json 的示范:


{
"name": "xxx-ui",
"version": "1.0.0",
"description": "Out-of-box UI solution for enterprise applications from B-side.",
"author": "yee.wang@xxx.com",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/cjs/index.d.ts",
"repository": {
"type": "git",
"url": "git@github.com:yee94/xxx.git"
}
}

README.md(⭐️⭐️⭐️⭐️)

如果你在做一个库,并希望有人来使用它,请至少为你的库提供一段描述,在我们的脚手架模板中已经为你生成了一份模板,并且会在编译过程中自动加入在线 DEMO 地址,但如果可以请至少为它添加一段描述。


这里的办法有很多,如果你实在不知道该如何写,可以找一些知名的开源库来参考,比如 antd \ react \ vue 等。


还有一个办法,或许你可以寻求 ChatGPT 的帮助,屡试不爽😄。


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

Vue动态表单组件的一点点小想法

web
Vue动态表单组件封装 本文章基于Vue2版本,使用的UI库为 elementUI。源于日常开发。 使用到的Vue技巧: 定义v-model <component is="componentName"></component> 动...
继续阅读 »

Vue动态表单组件封装



本文章基于Vue2版本,使用的UI库为 elementUI。源于日常开发。



使用到的Vue技巧:



  1. 定义v-model

  2. <component is="componentName"></component> 动态组件

  3. v-onv-bind$attrs$listeners、透传attribute

  4. slot插槽


1、关于组件的猜想


 <my-component config="config"></my-component>
复制代码

对于一个完美的组件,如上代码所示:丢入一堆config配置,组件输出我想要的页面。


1676516217126.png


那么对于一个表单组件,会需要什么呢?

基于elementUI官网中Form组件的第一个实例进行分析。
1676516277759.jpg

得出结论



  1. 表单左侧的文字每一行左侧的文字:得出属性label。

  2. 表单组件的渲染如图中的 el-input、el-select、el-radio等组件的名称:属性component

  3. 表单中 el-input、el-select、el-radio-group等组件双向绑定的值: 属性key。



(el-checkbox-group 或 el-radio-group) 类的组件,尽量使用组合的模式便于双向绑定



基于最简单的需求,总结出:


// 数据模型
const config = [
{
label: "活动名称",
component: "el-input",
key: "name",
},
{
label: "活动区域",
component: "el-select",
key: "area",
},
]
// 组件使用
<my-form config="config"></my-component>
复制代码

<template>
<el-form class="dynamic-form" ref="form" :model="formModel" label-width="80px">
<el-form-item v-for="(item, idx) in config" :key="idx" :label="item.label" :prop="item.key">
<el-input v-model="formModel[item.key]" v-if="item.component === 'el-input'"></el-input>
<el-select v-model="formModel[item.key]" v-if="item.component === 'el-select'"></el-select>
</el-form-item>
</el-form>
</template>

<script>
export default {
name: 'DynamicForm',
props: {
config: {
type: Array,
default: () => []
}
},
data() {
return {
formModel: {
name: '',
area: ''
}
}
}
}
</script>
复制代码

收获页面渲染结果如下:
1676537480111.jpg


基于以上的输出结果得出以下痛点:



  1. props参数只读,v-model需要内部变量去处理(这里指formModel要先定义好变量)。

  2. 使用v-for + v-if的判断去处理,如果思考缺少了部分组件,需要在组件内追加,繁琐。

  3. input、select等组件不添加参数。

  4. 组件与外部没有通信。

  5. 表单没办法添加校验

  6. 数据没办法回填

    ............


2、二次分析功能


基于上一节的痛点,对于组件的需求进行二次分析。



  1. 表单组件的结果要在外部方便获取。

    需要在外部修改数值时回填到组件内部 (添加自定义v-model)

  2. input、select等组件不能添加参数,

    el-form-item、el-form也需要参数配置的添加。

    (v-on, v-bind的批量绑定 以及透传Attributes)。

  3. 组件内部需要写大量的判断当前组件是什么类型,考虑不足是会造成后续组件的追加。(Vue动态组件解决)
    1676537323360.jpg


由此展开第二轮配置信息数据:















































属性字段
labellabel值
key需要绑定的内容
slot具名插槽
component组件名称
options列表数据: 如 el-select、el-cascader 需要使用到的子节点数据
formItemAttr表单item事件
formItemEven表单item属性
componentAttr表单控件属性
componentEvent表单控件事件

3、产出


组件使用部分


<template>
<div>
<MYform style="margin:60px" label-width="100px" v-model="formData" :config="config">
<template #slider>
<el-slider v-model="formData.slot"></el-slider>
</template>
</MYform>
</div>
</template>

<script>
import MYform from "./components/myForm.vue"
export default {
name: "app",
components: {
MYform
},
data() {
return {
formData: {}
};
},
mounted() {
},
computed: {
config() {
return [
{
label: "活动名称", // label值
key: "name", // 需要绑定的内容
component: "el-input", // 组件名称
formItemAttr: {
rules: [{ required: true, message: '请输入邮箱地址', trigger: 'blur' }],
}, // 表单item属性
formItemEven: {}, // 表单item事件
componentAttr: {
clearable: true,
prefixIcon: 'el-icon-search',
}, // 表单控件属性
componentEvent: {},
},
{
label: "活动内容", // label值
key: "type", // 需要绑定的内容
component: "el-select", // 组件名称
options: [{ label: "活动1", value: 1 }, { label: "活动2", value: 2 }],
formItemAttr: {}, // 表单item属性
formItemEven: {}, // 表单item事件
componentAttr: {
clearable: true,
}, // 表单控件属性
componentEvent: {},// 表单控件事件
}, {
label: "使用slot", // label值
key: "slot", // 需要绑定的内容
slot: "slider",
formItemAttr: {}, // 表单item属性
formItemEven: {}, // 表单item事件
componentAttr: {
clearable: true,
}, // 表单控件属性
componentEvent: {},// 表单控件事件
}
]
}
},
};
</script>
复制代码

最终输出的结果如下:
动画1.gif
组件代码:


<template>
<!-- v-bind="$attrs" 用于 透传属性的接收 v-on="$listeners" 方法的接收 -->
<el-form
class="dynamic-form"
ref="form"
v-bind="$attrs"
v-on="$listeners"
:model="formModel">
<el-form-item
v-for="(item, idx) in config"
:key="idx" :label="item.label"
:prop="item.key"
v-bind="item.formItemAttr">
<!-- 具名插槽 -->
<slot v-if="item.slot" :name="item.slot"></slot>
<!-- 1、动态组件(用于取代遍历判断。 is直接赋值为组件的名称即可) -->
<component v-else :is="item.component"
v-model="formModel[item.key]"
v-bind="item.componentAttr"
v-on="item.componentEvent"
@change="onUpdate"
>
<!-- 单独处理 select 的options(当然也可以基于 el-select进行二次封装,去除options遍历这一块 ) -->
<template v-if="item.component === 'el-select'">
<el-option v-for="option in item.options" :key="option.value" :label="option.label" :value="option.value">
</el-option>
</template>
</component>
<!-- 默认插槽 -->
<slot></slot>
</el-form-item>
</el-form>
</template>

<script>
export default {
name: 'MyForm',
props: {
config: {
type: Array,
default: () => []
},
modelValue: {}
},
model: {
prop: 'modelValue', // v-model绑定的值,因为v-model也是props传值,所以props要存在该变量
event: 'change' // 需要在v-model绑定的值进行修改时的触发事件。
},
computed: {

},
data() {
return {
formModel: {},
}
},
watch: {
// v-model的值发生改变时,同步修改form内部的值
modelValue(val) {
// 更新formModel
this.updateFormModel(val);
},
},
created() {
// 初始化
this.initFormModel();
},
methods: {
// 初始化表单数值
initFormModel() {
let formModelInit = {};
this.config.forEach((item) => {
// el-checkbox-group 必须为数组,否则会报错
if (item.componentName === "el-checkbox-group") {
formModelInit[item.key] = [];
} else {
formModelInit[item.key] = null;
}
});
this.formModel = Object.assign(formModelInit, this.modelValue);
this.onUpdate();
},
// 更新内部值
updateFormModel(modelValue) {
// 合并初始值和传入值
const sourceValue = modelValue ? modelValue : this.formModel;
this.formModel = Object.assign(this.formModel, sourceValue);
},
onUpdate() {
// 触发v-model的修改
this.$emit("change", this.formModel);
},
},
};
</script>
复制代码

4、结束


当然,动态组件并不是万能的,但是可以减少CV,以上代码只是一个概念篇的思想输出。但是在一定程度上也能够使用。

对于组件的完善,还是需要个人喜好来处理。

比如说:



  1. 添加methods的方法,像element一样 this.$refs[formName].resetFields(); 去重置数据或清空校验。(当然有了v-model, 其实可以直接修改v-model的值也可以完成重置数据)。

  2. 对el-select进一步封装,就可以避免去写 el-options 的遍历判断。

  3. el-checkbox-group、el-radio-group 这类型的组件尽量不使用单个的,用group便于双向绑定。

  4. el-checkbox-group、el-radio-group也可以进一步的进行封装,通过添加options配置的方式,去除内部额外添加 v-for的遍历。

  5. 还可以添加el-row、el-col的layout布局。

  6. 还有添加 form-item 的显示隐藏

  7. 甚至还可以把数据进行抽离成JSON的格式。

    ........

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

setTimeout与setInterval的区别

web
setTimeout与setInterval有什么区别,这是我6年前面试腾讯的一道面试题,上面是chatgpt的回答。简单来说,setTimeout是一次性定时器,setInterval是周期性定时器,如果你的回答也停留在api的字面解释,那chatgpt很可...
继续阅读 »

13771677231027_.pic.jpg


setTimeout与setInterval有什么区别,这是我6年前面试腾讯的一道面试题,上面是chatgpt的回答。简单来说,setTimeout是一次性定时器,setInterval是周期性定时器,如果你的回答也停留在api的字面解释,那chatgpt很可能会取代你的工作。递归地调用setTimeout,也能像setInterval一样实现周期性定时器,如下:


// start函数中调用了setTimeout,会在100ms后递归调用start,实现周期性定时器
let index = 1
const start = () => setTimeout(() => {
// 终止条件,最多调用5次
if(index++ >= 5) {
return
}
// 递归调用
start()
}, 100)
start()
复制代码

为了更直观在性能看板观察运行情况,增加了两个逻辑,调用delay函数拉长定时任务执行时长,并调用performance.mark和performance.measure标记间隔时长


let index = 1
const delay = () => {
const now = Date.now()
while(Date.now() - now < 200);
}
const start = () => {
setTimeout(() => {
// 为了方便在性能看板观察间隔时长
performance.measure(`setTimeout间隔${index}`, `setTimeout间隔${index}`)

// 耗时操作200ms
delay()

if(index++ >= 5) {
return
}


performance.mark(`setTimeout间隔${index}`)
// 递归调用
start()
}, 100)
}
performance.mark(`setTimeout间隔${index}`)
start()
复制代码

image.png
通过面板发现,定时任务的间隔时长是相等的,但是一个周期的总耗时是300ms,也就是执行耗时 + 间隔耗时,这没什么特别的,我们再使用setInterval实现相同的逻辑。


let index = 1
const delay = () => {
const now = Date.now()
while(Date.now() - now < 200);
}
const start = () => {
const ticker = setInterval(() => {
// 为了方便在性能看板观察间隔时长
performance.measure(`setTimeout间隔${index}`, `setTimeout间隔${index}`)

// 耗时操作200ms
delay()

if(index++ >= 5) {
clearInterval(ticker)
return
}

performance.mark(`setTimeout间隔${index}`)
}, 100)
}
performance.mark(`setTimeout间隔${index}`)
start()
复制代码

image.png
发现除了第一个间隔是100ms,后面其他间隔的耗时都可以忽略不计,定时器出现一个连续执行的现象,每一个周期的总耗时是200ms,也就是Math.max(执行耗时, 间隔耗时),当执行耗时大于间隔耗时,间隔失效连续执行。


js在单线程环境中执行,定时任务在指定时间加入事件队列,等待主线程空闲时,事件队列中的任务再加入执行栈执行。setInterval回调函数加入事件队列的时间点是固定的,当队列中存在重复的定时任务会进行丢弃。比如上面的例子,理论上每100ms会往事件队列中加入定时任务,由于每个周期主线程执行耗时是200ms,期间可以加入两个定时任务,由于第二个定时任务加入时,第一个定时任务还在事件队列中,重复的定时任务会被丢弃,200ms后主线程空闲,事件队列中只有一个定时任务,会立刻加入执行栈由主线程执行,由于定时任务的执行耗时大于间隔耗时,每次主线程执行完定时任务,事件队列中总会有一个新的任务在等待,所以出现了连续执行。而setTimeout的定时任务依赖上一次定时任务执行结束再调用定时器,所以定时任务之间的间隔是固定的,但是整个定时任务的周期会大于设置的间隔时长。


小结


setInterval加入事件队列的时间是固定的,setTimeout加入事件队列的时间是执行耗时 + 间隔耗时
setInterval任务间的间隔是 Math.max(执行耗时, 间隔耗时),setTimeout任务间的间隔是固定的。


这两个特性在实际开发中有什么影响吗?


轮询场景:当我们需要轮询查询某一个接口时,比如支付成功后查询订单的支付状态,为了提升性能,最好根据返回结果判断是否触发下一次查询,如果订单状态更新了,停止发送查询请求,避免不必要的开销。这个场景使用setTimeout更适合,因为它可以根据请求返回结果判断是否触发新的定时任务,而setInterval会在固定的间隔去触发请求,某一次查询请求的响应时长大于定时器间隔时长,将会发送多余的请求。


动画场景:比如像倒计时,使用setInterval会比setTimeout更稳定,因为定时任务的间隔更接近设置的间隔。当然实现动画用requestAnimationFrame性能更佳。


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

vue2实现带有阻尼下拉加载的功能

web
在vue中,需要绑定触发的事件<div  id="testchatBox"  class="chatWrap"  :style="{paddingTop: chatScroollTop + 'px'}"  @tou...
继续阅读 »

在vue中,需要绑定触发的事件

<div
 id="testchatBox"
 class="chatWrap"
 :style="{paddingTop: chatScroollTop + 'px'}"
 @touchstart="touchStart"
 @touchmove="touchMove"
 @touchend="touchEnd">
</div>

代码片段使用到了三个回调函数:

  • touchstart: 手指触摸到屏幕的那一刻的时候

  • touchmove: 手指在屏幕上移动的时候

  • touchend: 手指离开屏幕的时候

paddingTop可以看出,我们是通过控制这个容器距离的顶部的padding来实现下拉的效果。所以说我们的重调就是通过上面的三个回调函数来确定chatScroollTop的值。

通过chatScroollTop 这个命名就可以知道,我们这个下拉刷新是用在聊天框容器当中.

我们需要使用这些变量:

data() {
 return {
   chatScroollTop: 0, // 容器距离顶部的距离
   isMove: false, // 是否处于touchmove状态
   startY: 0, // 当前手指在屏幕中的y轴值
   pageScrollTop: 0, // 滚动条当前的纵坐标
   
}
}

三个回调函数对应三个阶段,而我们核心代码也分为三个部分:

第一部分:初始化当前容器的到顶部的距离,以及初始化当前是否处于滑动的状态,并获取当前滚动条的纵坐标。

touchStart(e) {
 // e代表该事件对象,e.targetTouches[0].pageY可以拿到手指按下的 y轴点
 this.startY = e.targetTouches[0].pageY
 // 开启下拉刷新状态
 this.isMove = false
 this.pageScrollTop = document.documentElement && document.documentElement.scrollTop
}

第二部分:根据当前手指当前距离触摸屏幕时刻的纵坐标差来确定容器和顶部的距离。但是由于不能一直的滑动,所以给了一个0 -> 80的氛围。为了让滑动更加的有趣,添加了一个step步进值来调整滑动的距离比例,所谓的距离比例就是手指距离一开始的距离越远,那么容量跟着滑动的距离就越短。实现一个类似阻尼的效果。

touchMove(e) {
 // 这个 touchMove,只要页面在动都会发生的,所以 touching就起作用了
 // 获取移动的距离
 let diff = e.targetTouches[0].pageY - this.startY
 let step = 60
 if (diff > 0 && diff < 80 && this.pageScrollTop === 0) {
    step++ // 越来越大
    this.chatScroollTop += (diff / (step * 0.1)) // 越向下给人的阻力感越大
    this.isMove = true
}
}

第三部分:手指松开之后,给一个距离顶部的距离是为了添加加载滚动条。

  touchEnd() {
   if(this.isMove) {
     this.chatScroollTop = 40
     this.downCallback() // api拉取数据
  }
}
 async downCallback() {
   try {
      // 拿数据
  } catch() {}
   finall{
     this.chatScrollTop = 0
  }
}

作者:砂糖橘加盐
来源:juejin.cn/post/7200388232106704952

收起阅读 »

柯里化到底是什么?

web
这本来是一篇柯里化的介绍文章,但是在我准备例子的时候,越写越不知道自己写什么。因为柯里化这个东西我现在无法真正的理解。所以这篇文章的标题其实是一个疑问句。一、柯里化是什么?有这么一道面试题:*实现一个add函数 满足add(1,2,3)与add(1)(2)(3...
继续阅读 »

这本来是一篇柯里化的介绍文章,但是在我准备例子的时候,越写越不知道自己写什么。因为柯里化这个东西我现在无法真正的理解。所以这篇文章的标题其实是一个疑问句。

一、柯里化是什么?

有这么一道面试题:*实现一个add函数 满足add(1,2,3)与add(1)(2)(3)结果相同。*

实现如下:

const addCurry = (a) => (b) => (c) => a + b + c;
console.log(addCurry(1)(2)(3)) // 6

// 等同于
const addCurry = function (a) {
 return function (b) {
   return function (c) {
     return a + b + c;
  }
}
}

就是利用闭包 的特性,函数运行之后不马上销毁对象来实现的。

再来一个进阶的,如果要同时满足add(1)(2, 3)add(1, 2)(3)。实现如下:

const curry = (fn, ...args) => 
   // 函数的参数个数可以直接通过函数数的.length属性来访问
   args.length >= fn.length // 这个判断很关键!!!
   // 传入的参数大于等于原始函数fn的参数个数,则直接执行该函数
  ? fn(...args)
   /**
    * 传入的参数小于原始函数fn的参数个数时
    * 则继续对当前函数进行柯里化,返回一个接受所有参数(当前参数和剩余参数) 的函数
  */
  : (..._args) => curry(fn, ...args, ..._args)

function add1(x, y, z) {
   return x + y + z
}
const add = curry(add1)
console.log(add(1, 2, 3)) // 6
console.log(add(1)(2)(3)) // 6
console.log(add(1, 2)(3)) // 6
console.log(add(1)(2, 3)) // 6

上面将fn(a, b, c)fn(a)(b)(c)的过程就是柯里化。把前后两者当成一个黑盒子,它们就是完全等价的。

简单总结一下:

柯里化用在工具函数中,提高了函数使用的灵活性和可读性。

二、为什么我老记不住柯里化

因为我只当它是面试的知识点,而不是JS函数式的知识点。

我是这么记忆它的,通过面试题来进行记忆。看到对应的题目就会想到curry()函数。什么是八股文,就是固定的模版,我只用把题干中的参数放入函数当中。和我读书的时候做题很像,看到不同的题目,脑中切换对应的公式,然后从题干中找到变量,将其放入公式当中。这不正是应试。

所以每次面试完之后,就把这个东西给忘得一干二净。下一次面试的时候再来背一次,如此循环,周而复始。

面向面试去学习,不去真正的理解它,平时工作中真遇到了对应场景自然想不到。前端是一门手艺活,不去使用又怎么能够会呢?

JS是一个多范式的语法,柯里化就是我们要学习函数式的重要概念。

也就意味着我们想要真正的学会柯里化,必须要理解函数式解决了问题,它在我们写业务代码的时候如何运用上。

想要真正的理解柯里化的,我们需要知道「多参数函数」和「单参数函数」的概念。想要理解柯里化的作用,我们需要知道「函数组合」是什么?它相比其他方式能够带来什么优点。

我们在学习一个知识点的时候,它不是孤立的一个点,它不是为了面试题而存在的。知识点之间是有联系的,我们要做的就是将这些知识点串联起来,形成自己的知识体系。

三、如何更近一步的理解柯里化

仅就柯里化而言,我们需要学习函数式的思考逻辑,如何学习呢?

在《JavaScript忍者秘籍》说,函数是一等公民。这个是JS具有写函数式的必要条件。

这也意味着JS这种非纯粹的函数式语言仅仅是模拟罢了。和设计模式一样,脱胎于Java,多数设计模式对于JS的使用场景而言根本没有意义,甚至扭曲了本来的意义。

所以说,我们只有学习一门函数式的语言才能够真正的理解函数式,才能够更加的理解为何要柯里化。

正如设计模式之于Java,它本来就是基于Java开发而总结的。不通过Java来学习设计模式,而直接使用JS来学习,理解起来的难度是大于学习一个语言的难度的。

为了理解一些概念就要去学习一门语言么?

如果觉得学习语言已经是一个门槛的话,那么或许真如别人说的那样,前端就是切图仔了。

共勉!

作者:砂糖橘加盐
来源:juejin.cn/post/7204031026338414648

收起阅读 »

前端这样的时钟代码,简直就是炫技!

web
在网上收了一番,找到一个专门收集时钟代码的网站! 这里和大家分享一下!几十款各种各样好玩又酷炫的时钟代码!值得收藏!概要网站上的所有代码都来自 codepen 站点。作者把它们收集起来,统一呈现给大家。作者把它们分为了三大类:BEAUTIFUL STYLE,...
继续阅读 »

在网上收了一番,找到一个专门收集时钟代码的网站!

这里和大家分享一下!几十款各种各样好玩又酷炫的时钟代码!值得收藏!

概要

网站上的所有代码都来自 codepen 站点。作者把它们收集起来,统一呈现给大家。

作者把它们分为了三大类:BEAUTIFUL STYLE,CREATIVE DESIGN, ELECTRONIC CLOCK.
大师兄看了半天,觉得这分类带有强烈的个人偏好!毕竟大师兄觉得在BEAUTIFUL STYLE下的很多例子都很具有CREATIVE范儿!一起来看下吧!

BEAUTIFUL STYLE CLOCK

这个分类下的时钟,表现形式比较简朴,但不妨碍它的美感!


这个时钟的呈现方式其实也满富有创造力的


这个就像家里的挂钟一样

CREATIVE DESIGN

凡是归纳到这个分类下的设计,都是很具有创作力的!


米老鼠的手臂指着时针、分针,脚和尾巴有规律的动着


通过肢体的动作来表示时间,真是别具一格

ELECTRONIC CLOCK

这个分类就是电子时钟类别了!


如果你的页面需要电子时钟,直接来这个类别找吧!


重点说明

上面只是在每个类别中选了两个给大家展示。官网上还有其他几十种样式供大家学习!

官网地址
drawcall.github.io/clock-shop/

另外,每个例子都有可参考的代码!


(伸手党们的福利!) 如果你现在的项目用不上!那赶紧找一款好看的时钟挂到你的博客主页上, 瞬间会让它变得高大上的。

作者:程序员老鱼
来源:juejin.cn/post/7202619396991352893

收起阅读 »

2023面试真题之浏览器篇

web
人生当中,总有一个环节,要收拾你一下,让你尝一尝生活的铁拳大家好,我是柒八九。今天,我们继续2023前端面试真题系列。我们来谈谈关于浏览器的相关知识点。如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。你能所学到的知识点浏览器的进程和线程&...
继续阅读 »

人生当中,总有一个环节,要收拾你一下,让你尝一尝生活的铁拳

大家好,我是柒八九

今天,我们继续2023前端面试真题系列。我们来谈谈关于浏览器的相关知识点。

如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。

你能所学到的知识点

  1. 浏览器的进程和线程 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  2. 浏览器渲染过程 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  3. Link rel= "prelaod" 推荐阅读指数⭐️⭐️⭐️⭐️
  4. cookie设置的几种方式 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  5. cookie和session的区别和联系 推荐阅读指数⭐️⭐️⭐️⭐️
  6. 客户端缓存 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  7. LightHouse v8/v9性能指标 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

好了,天不早了,干点正事哇。


浏览器的进程和线程

进程:某个应用程序的执行程序。
线程:常驻在进程内部并负责该进程部分功能的执行程序。

当你启动一个应用程序,对应的进程就被创建。进程可能会创建一些线程用于帮助它完成部分工作,新建线程是一个可选操作。在启动某个进程的同时,操作系统(OS)也会分配内存以用于进程进行私有数据的存储。该内存空间是和其他进程是互不干扰的。

有人的地方就会有江湖,如果想让多人齐心协力的办好一件事,就需要一个人去统筹这些工作,然后通过大喇叭将每个人的诉求告诉对方。而对于计算机而言,统筹的工作归OS负责,OS通过Inter Process Communication (IPC)的机制去传递消息。

网页中的主要进程


浏览器渲染过程(13步)

  1. 页面渲染起始标识

    • 当垂直同步信号(VSync)被排版线程接收到,新的屏幕渲染开始
  2. 输入事件回调

    • 输入事件的数据信息从排版线程主线程的事件回调中传递。
    • 所有输入事件的回调(touchmove/scroll/click)应该先被调用,并且每帧都应该触发,但是这不是必须的
  3. rAFrequestAnimationFrame

    • 这是一个用于屏幕视觉更新的理想的位置。
    • 因为,在此处能够获取到垂直同步事件最新的输入数据。
  4. {解析HTML|Parse HTML}

    • 通过指定的解析器,将不能被浏览器识别的HTML文本,转换为浏览器能识别的数据结构:DOM对象。
  5. 重新计算样式

    • 新生成被修改的元素进行样式信息计算
    • 生成CSSOM
    • 元素样式和DOM元素结合起来,就会生成Render Tree
  6. {布局|Layout}

    • 计算每个可视元素的位置信息(距离视口的距离和元素本身大小)。
    • 并生成对应的Layout Tree
  7. {更新图层树|Update Layer Tree}

    • 在 Render 树的基础上,我们会将拥有相同z 坐标空间的 Layout Objects归属到同一个{渲染层|Paint Layer}中。
    • Paint Layer 最初是用来实现{层叠上下文|Stacking Context}
      • 它主要来保证⻚面元素以正确的顺序合成。
  8. {绘制|Paint}:

    • 该过程包含两个过程,
    • 第一个过程是绘制操作(painting)
      • 该过程用于生成任何被新生成或者改动元素的绘制信息(包含图形信息和文本信息);
    • 第二个过程是栅格化(Rasterization),
      • 用于执行上一个过程生成的绘制信息。
  9. {页面合成|Composite}:

    • 将图层信息(layer)和图块信息提交(commit)到合成线程(排版线程)中。并且在合成线程中会对一些额外的属性进行解释处理。
    • 例如:某些元素被赋值will-change或者一些使用了硬件加速的绘制方式(canvas)。
  10. {栅格化|Rasterize} :

    • 在绘制阶段(Paint)生成的绘制记录(Paint Record)被合成线程维护的{图块工作线程|Compositor Tile Worker}所消费。
    • 栅格化是根据图层来完成的,而每个图层由多个图块组成。
  11. 页面信息提交:

    • 当页面中所有的图层都被栅格化,并且所有的图块都被提交到{合成线程|Compositor},此时{合成线程|Compositor}将这些信息连同输入数据(input data)一起打包,并发送到GPU线程
  12. 页面显示:

    • 当前页面的所有信息在GPU中被处理,GPU会将页面信息传入到双缓存中的后缓存区,以备下次垂直同步信号到达后,前后缓存区相互置换。然后,此时屏幕中就会显示想要显示的页面信息。
  13. requestIdleCallback:如果在当前屏幕刷新过程中,主线程在处理完上述过程后还有剩余时间(<16.6ms),此时主线程会主动触发requestIdleCallback


Link rel= "prelaod"

元素的rel属性的preload值允许你在HTML的中声明获取请求,指定页面将很快需要的资源,你希望在页面生命周期的早期开始加载这些资源,在浏览器的主线程启动之前。这确保了它们更早可用,不太可能阻塞页面的呈现,从而提高了性能。即使名称包含术语load,它也不会加载和执行脚本,而只是安排以更高的优先级下载和缓存脚本

rel属性设置为preload,它将转换为我们想要的任何资源的预加载器

还需要指定其他的属性:

  • href属性设置资源的路径
  • as属性设置资源类型

"utf-8" />
"preload" href="style.css" as="style" />
"preload" href="main.js" as="script" />


复制代码

预加载还有其他优点。使用as指定要预加载的内容类型允许浏览器:

  • 更准确地优先考虑资源加载。
  • 存储在缓存中以备将来的请求,并在适当时重用该资源。
  • 对资源应用正确的内容安全策略(CSP)。
    • 内容安全策略(CSP)是一个额外的安全层,它有助于检测和减轻某些类型的攻击,包括
    • 跨站脚本(XSS)
    • 数据注入攻击。
  • 为它设置正确的Accept请求标头。

预加载资源的类型(as的值类型)


cookie设置的几种方式

通常我们有两种方式给浏览器设置或获取Cookie

  1. 第一种 通过 HTTP 方式对 Cookie 进行赋值,又分为 Request 和 Response
    • HTTP Response Headers 中的 Set-Cookie Header
    • HTTP Request Headers 中的 Cookie Header
  2. 第二种 通过JavaScriptdocument.cookie进行赋值或取值。

两种方式的区别

HTTP Cookie

Set-Cookie Header,除了必须包含Cookie正文,还可以选择性包含6个属性

  1. path
  2. domain
  3. max-age
  4. expires
  5. secure
  6. httponly

它们之间用英文分号和空格("; ")连接;

JS Cookie

在浏览器端,通过 document.cookie 也可以设置CookieJS Cookie 的内容除了必须包含正文之外,还可选5个属性

  1. path
  2. domain
  3. max-age
  4. expires
  5. secure

JS 中设置 Cookie 和 HTTP 方式相比较,少了对 HttpOnly 的控制,是因为 JS 不能读写HttpOnly Cookie


http请求什么情况下会携带cookie

Cookie 请求头字段是客户端发送请求到服务器端时发送的信息

如果满足下面几个条件:(domain/http/path

  1. 浏览器端某个 Cookie 的 domain.a.com) 字段等于请求的域名或者是请求的父域名,请求的域名需要是 a.com/b.a.com 才可以
  2. 都是 http 或者 https,或者不同的情况下 Secure 属性为 false(即 secure 是 true 的情况下,只有 https 请求才能携带这个 cookie
  3. 要发送请求的路径,跟浏览器端 Cookie 的 path 属性必须一致,或者是浏览器端 Cookie 的 path 的子目录
    • 比如浏览器端 Cookie 的 path 为 /test,那么请求的路径必须为/test 或者/test/xxxx 等子目录才可以

上面 3 个条件必须同时满足,否则该请求就不能自动带上浏览器端已存在的 Cookie


客户端怎么设置跨域携带 cookie

  1. 前端请求时在request对象中
    • 配置"withCredentials": true;
  2. 服务端responseheader
    • 配置"Access-Control-Allow-Origin", "http://xxx:${port}";
    • 配置"Access-Control-Allow-Credentials", "true" `

cookie和session的区别和联系

SessionCookie安全,Session是存储在服务器端的,Cookie是存储在客户端的

  1. cookie数据存放在客户端,session数据放在服务器上。
  2. cookie不是很安全,别人可以分析存放在本地的cookie并进行cookie欺骗
    • 考虑到安全应当使用session
  3. session会在一定时间内保存在服务器上,当访问增多,会比较占用服务器的性能
    • 考虑性能应当使用cookie
  4. 不同浏览器对cookie的数据大小限制不同,个数限制也不相同。
  5. 可以考虑将登陆信息等重要信息存放为session,不重要的信息可以放在cookie中。

客户端缓存

本地存储小容量

  1. Cookie 主要用于用户信息的存储,Cookie的内容可以自动在请求的时候被传递给服务器。
    • 服务器响应 HTTP 请求时,通过发送 Set-Cookie HTTP 头部包含会话信息。
    • 浏览器会存储这些会话信息,并在之后的每个请求中都会通过 HTTP 头部 cookie 再将它们发回服务器
    • 有一种叫作 HTTP-only 的 cookieHTTP-only 可以在浏览器设置,也可以在服务器设置,但只能在服务器上读取
  2. Web Storage
    • 提供在 cookie 之外存储会话数据的途径
    • 提供跨会话持久化存储大量数据的机制
    • Web Storage 的第 2 版定义了两个对象
    • 1.LocalStorage 的数据将一直保存在浏览器内,直到用户清除浏览器缓存数据为止。
    • 2.SessionStorage 的其他属性同LocalStorage,只不过它的生命周期同标签页的生命周期,当标签页被关闭时,SessionStorage也会被清除。 。


本地存储大容量

  1. IndexDB:是浏览器中存储结构化数据的一个方案
    • IndexedDB 是类似于 MySQL 或 Web SQL Database 的数据库
  2. WebSQL: 用于存储较大量数据的缓存机制。
    • 已废弃并且被IndexDB所替代
  3. Application Cache:允许浏览器通过manifest配置文件在本地有选择的存储JS/CSS/图片等静态资源的文件级缓存机制
    • 已废弃并且被ServerWorkers所替代
  4. ServerWorkers:离线缓存

{服务工作线程|Service Worker}

{服务工作线程|Service Worker}是一种类似浏览器中代理服务器的线程,可以拦截外出请求缓存响应。这可以让网页在没有网络连接的情况下正常使用,因为部分或全部页面可以从服务工作线程缓存中提供服务。

服务工作线程在两个主要任务上最有用:

  • 充当网络请求的缓存层
  • 启用推送通知

在某种意义上

  • 服务工作线程就是用于把网页变成像原生应用程序一样的工具
  • 服务工作线程对大多数主流浏览器而言就是网络缓存

创建服务工作线程

ServiceWorkerContainer 没有通过全局构造函数创建,而是暴露了 register()方法,该方法以与 Worker()或 SharedWorker()构造函数相同的方式传递脚本 URL

serviceWorker.js
// 处理相关逻辑

main.js
navigator.serviceWorker.register('./serviceWorker.js');
复制代码

register()方法返回一个Promise

  • 该 Promise 成功时返回 ServiceWorkerRegistration 对象
  • 在注册失败时拒绝
serviceWorker.js
// 处理相关逻辑

main.js
// 注册成功,成功回调(解决)
navigator.serviceWorker.register('./serviceWorker.js')
.then(console.log, console.error);
// ServiceWorkerRegistration { ... }


// 使用不存在的文件注册,失败回调(拒绝)
navigator.serviceWorker.register('./doesNotExist.js')
.then(console.log, console.error);
// TypeError: Failed to register a ServiceWorker:
// A bad HTTP response code (404) was received
// when fetching the script.
复制代码

即使浏览器未全局支持服务工作线程,服务工作线程本身对页面也应该是不可见的。这是因为它的行为类似代理,就算有需要它处理的操作,也仅仅是发送常规的网络请求

考虑到上述情况,注册服务工作线程的一种非常常见的模式是基于特性检测,并在页面的 load 事件中操作。

if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('./serviceWorker.js');
});
}
复制代码

如果没有 load 事件做检测,服务工作线程的注册就会与页面资源的加载重叠,进而拖慢初始页面渲染的过程


使用 ServiceWorkerContainer 对象

ServiceWorkerContainer 接口是浏览器对服务工作线程生态的顶部封装

ServiceWorkerContainer 始终可以在客户端上下文中访问:

console.log(navigator.serviceWorker);
// ServiceWorkerContainer { ... }
复制代码

ServiceWorkerContainer 支持以下事件处理程序

  • oncontrollerchange
    在 ServiceWorkerContainer 触发 controllerchange 事件时会调用指定的事件处理程序。
    • 在获得新激活的 ServiceWorkerRegistration 时触发。
    • 可以使用 navigator.serviceWorker.addEventListener('controllerchange',handler)处理。
  • onerror
    在关联的服务工作线程触发 ErrorEvent 错误事件时会调用指定的事件处理程序。
    • 关联的服务工作线程内部抛出错误时触发
    • 也可以使用 navigator.serviceWorker.addEventListener('error', handler)处理
  • onmessage
    在服务工作线程触发 MessageEvent 事件时会调用指定的事件处理程序
    • 在服务脚本向父上下文发送消息时触发
    • 也可以使用 navigator.serviceWorker.addEventListener('message', handler)处理

ServiceWorkerContainer 支持下列属性

  • ready:返回 Promise
    • 成功时候返回激活的 ServiceWorkerRegistration 对象。
    • 该Promise不会拒绝
  • controller
    返回与当前页面关联的激活的 ServiceWorker 对象,如果没有激活的服务工作线程则返回 null

ServiceWorkerContainer 支持下列方法

  • register()
    使用接收的 url 和 options 对象创建或更新 ServiceWorkerRegistration
  • getRegistration():返回 Promise
    • 成功时候返回与提供的作用域匹配的 ServiceWorkerRegistration对象
    • 如果没有匹配的服务工作线程则返回 undefined
  • getRegistrations():返回 Promise
    • 成功时候返回与 ServiceWorkerContainer 关联的 ServiceWorkerRegistration 对象的数组
    • 如果没有关联的服务工作者线程则返回空数组。
  • startMessage():开始传送通过 Client.postMessage()派发的消息


使用 ServiceWorkerRegistration 对象

ServiceWorkerRegistration 对象表示注册成功的服务工作线程。该对象可以在 register() 返回的解决Promise的处理程序中访问到。通过它的一些属性可以确定关联服务工作线程的生命周期状态

调用 navigator.serviceWorker.register()之后返回的Promise会将注册成功的 ServiceWorkerRegistration 对象(注册对象)发送给处理函数。

同一页面使用同一 URL 多次调用该方法会返回相同的注册对象:即该操作是幂等

navigator.serviceWorker.register('./sw1.js')
.then((registrationA) => {
console.log(registrationA);

navigator.serviceWorker.register('./sw2.js')
.then((registrationB) => {
console.log(registrationA === registrationB);
// 这里结果为true
});
});
复制代码

ServiceWorkerRegistration 支持以下事件处理程序

  • onupdatefound
    在服务工作线程触发 updatefound 事件时会调用指定的事件处理程序。
    • 在服务工作线程开始安装新版本时触发,表现为 ServiceWorkerRegistration.installing 收到一个新的服务工作者线程
    • 也可以使用 serviceWorkerRegistration.addEventListener('updatefound',handler)处理

LightHouse v8/v9性能指标 (6个)

  1. FCP(First Contentful Paint)
    • FCP衡量的是,在用户导航到页面后,浏览器呈现第一块DOM内容所需的时间。
    • 页面上的图片非白色元素svg都被认为是DOM内容;
    • iframe内的任何内容都不包括在内
    • 优化手段:缩短字体加载时间
  2. SI(Speed Index)
    • SI指数衡量内容在页面加载期间视觉显示的速度。Lighthouse首先在浏览器中捕获页面加载的视频,并计算帧之间的视觉进展
    • 优化手段:1. 减少主线程工作 2. 减少JavaScript的执行时间
  3. LCP(Largest Contentful Paint)
    • LCP测量视口中最大的内容元素何时呈现到屏幕上。这接近于用户可以看到页面的主要内容
  4. TTI(Time to Interactive)
    • TTI测量一个页面变成完全交互式需要多长时间
    • 当页面显示
    • 有用的内容(由First Contentful Paint衡量),
    • 为大多数可见的页面元素注册了事件处理程序
    • 并且页面在50毫秒内响应用户交互时,
    • 页面被认为是完全交互式的。
  5. TBT(Total Blocking Time)
    • TBT 测量页面被阻止响应用户输入(例如鼠标点击、屏幕点击或按下键盘)的总时间。总和是FCPTTI之间所有长时间任务的阻塞部分之和
    • 任何执行时间超过 50 毫秒的任务都是长任务。50 毫秒后的时间量是阻塞部分。
    • 例如,如果检测到一个 70 毫秒长的任务,则阻塞部分将为 20 毫秒
  6. CLS(Cumulative Layout Shift)
    • 累积布局偏移 (CLS) 是测量视觉稳定性的一个以用户为中心的重要指标
    • CLS 较差的最常见原因为:
    • 1.无尺寸的图像
    • 2.无尺寸的嵌入和 iframe
    • 3.动态注入的内容
    • 优化手段1. 除非是对用户交互做出响应,否则切勿在现有内容的上方插入内容 2. 倾向于选择transform动画

优化LCP

导致 LCP 不佳的最常见原因是:

  1. 缓慢的服务器响应速度
  2. 阻塞渲染的 JavaScript 和 CSS
  3. 缓慢的资源加载速度
  4. 客户端渲染

缓慢的服务器响应速度

使用{首字节时间|Time to First Byte}(TTFB) 来测量您的服务器响应时间

  1. 将用户路由到附近的 CDN
  2. 缓存资产
    • 如果 HTML 是静态的,且不需要针对每个请求进行更改,那么缓存可以防止网页进行不必要的重建。通过在磁盘上存储已生成 HTML 的副本,服务器端缓存可以减少 TTFB 并最大限度地减少资源使用。
    • 配置反向代理(Varnish、nginx)来提供缓存内容
    • 使用提供边缘服务器的 CDN
  3. 优先使用缓存提供 HTML 页面
    • 安装好的 Service Worker 会在浏览器后台运行,并可以拦截来自服务器的请求。此级别的程序化缓存控制使得缓存部分或全部 HTML 页面内容得以实现,并且只会在内容发生更改时更新缓存。
  4. 尽早建立第三方连接
    • 第三方域的服务器请求也会影响 LCP,尤其是当浏览器需要这些请求来在页面上显示关键内容的情况下。
    • 使用rel="preconnect"来告知浏览器您的页面打算尽快建立连接。
    • 还可以使用dns-prefetch来更快地完成 DNS 查找。
    • 尽管两种提示的原理不同,但对于不支持preconnect的浏览器,可以考虑将dns-prefetch做为后备。

阻塞渲染的 JavaScript 和 CSS

  1. 减少 CSS 阻塞时间
    1. 削减 CSSCSS 文件可以包含空格缩进注释等字符。这些字符对于浏览器来说都不是必要的,而对这些文件进行削减能够确保将这些字符删除。使用模块打包器或构建工具,那么可以在其中包含一个相应的插件来在每次构建时削减 CSS 文件:对于 webpack5css-minimizer-webpack-plugin i
    2. 延迟加载非关键 CSS:使用 Chrome 开发者工具中的代码覆盖率选项卡查找您网页上任何未使用的 CSS
      对于任何初始渲染时不需要的 CSS,使用 loadCSS 来异步加载文件,这里运用了rel="preload"onload
    3. 内联关键 CSS:把用于首屏内容的任何关键路径 CSS 直接包括在中来将这些 CSS 进行内联。
  2. 减少 JavaScript 阻塞时间
    1. 缩小和压缩 JavaScript 文件:
      缩小是删除空格和不需要的代码,从而创建较小但完全有效的代码文件的过程。Terser 是一种流行的 JavaScript 压缩工具;
      压缩是使用压缩算法修改数据的过程Gzip 是用于服务器和客户端交互的最广泛使用的压缩格式。Brotli 是一种较新的压缩算法,可以提供比 Gzip 更好的压缩结果。
      静态压缩涉及提前压缩和保存资产。这会使构建过程花费更长的时间,尤其是在使用高压缩级别的情况下,但可确保浏览器获取压缩资源时不会出现延迟。如果您的 web 服务器支持 Brotli,那么请使用 BrotliWebpackPlugin 等插件通过 webpack 压缩资产,将其纳入构建步骤。否则,请使用 CompressionPlugin 通过 gzip 压缩您的资产。
    2. 延迟加载未使用的 JavaScript
      通过代码拆分减少 JavaScript 负载,- SplitChunksPlugin
    3. 最大限度减少未使用的 polyfill

最大限度减少未使用的 polyfill

Babel 是最广泛使用的编译代码的工具,它将包含较新语法的代码编译成不同浏览器和环境都能理解的代码。

要使用 Babel 只传递用户需要的信息

  1. 确定浏览器范围
  2. @babel/preset-env设置适当的浏览器目标
  3. 使用
收起阅读 »

我开源了一个好玩且好用的前端脚手架😏

web
经过半年的幻想,一个多月的准备,十天的开发,我终于开源了自己的脚手架。背景在我最开始学习 React 的时候,使用的脚手架就是 create-react-app,我想大部分刚开始学的时候都是使用这个脚手架吧。使用这个脚手架挺适合新手的,零配置,执行该脚手架命令...
继续阅读 »

经过半年的幻想,一个多月的准备,十天的开发,我终于开源了自己的脚手架。

背景

在我最开始学习 React 的时候,使用的脚手架就是 create-react-app,我想大部分刚开始学的时候都是使用这个脚手架吧。

使用这个脚手架挺适合新手的,零配置,执行该脚手架命令安装特定的模板,安装相关依赖包,通过执行 npm start 即可把项目运行起来。

但是这个脚手架在开发的过程中我要引入相对应的模块,例如要引入一个组件 import NiuBi from '../../../components/niubi.jsx',这个路径看起来就很丑,而且编写的时候极度困难,因此我们可以通过 Webpack 配置路径别名,可那时候我哪会配置 Webpack 啊,善于思考的我决定打开百度,发现可以使用 carco 配置 Webpack,但是发现 carco 版本和 react-script 版本并不兼容,因为这个问题把我折磨了一天,因此这个时刻我想自己去开源一个脚手架的想法从此诞生,虽然那时候的我技术非常菜,不过我现在的技术也菜,但是我胆子大啊!!!😏😏😏

所以在这里我总结一下 create-react-app 脚手架的一些缺点,但是这仅仅是个人观点:

  • 难定制: 如果你需要自定义配置 Webpack,你需要额外使用第三方工具 carco 或者 eject 保留全部 Webpack 配置文件;

  • 模板单一: 模板少而且简单,这意味着我们每次开发都要从零开始;

那么接下来就来看看我的这个脚手架是怎么使用的。

基本使用

全局安装

npm install @obstinate/react-cli -g

该脚手架提供的的全局指令为 crazy,查看该脚手架帮助,你可以直接使用:

crazy

输入该命令后,输出的是整个脚手架的命令帮助,如下图所示:


创建项目

要想创建项目,你可以执行以下命令来根据你想要的项目:

crazy create <projectName> [options]

例如创建一个名为 moment,如果当前终端所在的目录下存在同名文件时直接覆盖,你可以执行以下命令:

crazy create moment -f

如果你不想安装该脚手架,你也可以使用 npx 执行,使用 npx @obstinate/react-cli 代替 crazy 命令,例如,你要创建一个项目,你可以执行以下命令:

npx @obstinate/react-cli create moment -f

如果没有输入 -f,则会在后面的交互信息询问是否覆盖当前已存在的文件夹。

之后便有以下交互信息,你可以根据这些交互选择你想要的模板:


最终生成的文件如下图所示:


当项目安装完成之后你就可以根据控制台的指示去启动你的项目了。

创建文件

通过该脚手架你可以快速创建不同类型的文件,你可以指定创建文件的指定路径,否则则使用默认路径。

要想成创建创建文件,请执行以下指令:

crazy mkdir <type> [router]

其中 type 为必选命令,为你要创建的文件类型,现在可供选择的有 axios、component、page、redux、axios,router 为可选属性,为创建文件的路径。

具体操作请看下列动图:


输入不同的类型会有不同的默认路径,并且无需你输入文件的后缀名,会根据你的项目生成相对应的文件后缀名,其中最特别的是创建 redux 文件会自动全局导入 reduxer,无需你自己手动导入,方便了日常的开发效率。

灵活配置

create-react-app 不同的是,该脚手架提供了自定义 Webpackbabel 配置,并通过 webpack-merge 对其进行合并,美中不足的是暂时并还没有提供 env 环境变量,要区分环境你可以在你通过脚手架下来的项目的 webpack.config.js 文件中这样操作:

// 开发环境
const isDevelopment = process.argv.slice(2)[0] === "serve";

module.exports = {
 // ...
};

最后一个小提示,如果全局安装失败,检查是否权限不够,可以通过管理员身份打开 cmd 即可解决。

这些就是目前仅有的功能,其他的功能正在逐渐开发中......

未来(画饼)

  • 逐步优化用户体验效果,编写更完美的使用文档;

  • 添加对 vue 的支持;

  • 提供更多代码规范化配置选择,例如 husky;

  • 提供单元测试;

  • 添加 env 环境变量配置;

  • 增加更多的完美配置,减少用户对项目的额外配置;

  • 添加更多的模板,例如后台管理系统;

  • 将来会考虑开发一些配套的生态,例如组件库;

  • 等等......

如何贡献

项目从开发到现在都是我自己一人在开发,但仅凭一己之力会慢慢变得疲惫,其实现在这个版本早在几天前就已经写好了,就单纯不想写文档一直拖到现在。

所以希望能在这里找到一些志同道合的朋友一起把这个脚手架完善,我希望在不久的将来能创造出一个比 create-react-app 更好玩且好用的脚手架。

本人的联系方式请查看评论区图片。

最后

本人是一个掘金的活跃用户,一天里可能就两三次上 GitHub,如果你联系不到我,如果你不想添加我微信好友你可以通过掘金里私信我,掘金私信有通知,如果我不忙,我可能很快就能回复到你。

如果该脚手架有什么问题或者有什么想法可以通过 Githubissue 给我留言。

如果觉得该项目对你有帮助,也欢迎你给个 star,让更多的朋友能看到。

如果本篇文章的点赞或者评论较高,后期会考虑出一期文章来讲解如何基于 pnpm + monorepo + webpack 开发的脚手架,如果本篇文章对你有帮助,希望你能随时点个赞,让更多的人看到!!!😉😉😉

最后贴上一些地址:

作者:Moment
来源:juejin.cn/post/7202891949380173880

收起阅读 »

10 个值得掌握的 reduce 技巧

web
作为一个前端开发者,一定有接触过 reduce 函数,它是一个强大而实用的数组方法,熟练掌握 reduce 的使用可以在开发中提高开发效率和代码质量。本文介绍的 reduce 的 10 个技巧值得拥有,可以让你少写很多代码!reduce 方法在数组的每个元素上...
继续阅读 »

作为一个前端开发者,一定有接触过 reduce 函数,它是一个强大而实用的数组方法,熟练掌握 reduce 的使用可以在开发中提高开发效率和代码质量。本文介绍的 reduce 的 10 个技巧值得拥有,可以让你少写很多代码!

reduce 方法在数组的每个元素上执行提供的回调函数迭代器。它传入前一个元素计算的返回值,结果是单个值,它是在数组的所有元素上运行迭代器的结果。

迭代器函数逐个遍历数组的元素,在每一步中,迭代器函数将当前数组值添加到上一步的结果中,直到没有更多元素要添加。

语法

参数包含回调函数和可选的初始值,如下:

array.reduce(callback(accumulator, currentValue[, index[, array]])[, initialValue])

  • callback

    (必须):执行数组中每个值(如果没有提供

    initialValue

    则第一个值除外)的

    reducer

    函数,包含四个参数

    • accumulator(必须):累计器累计回调的返回值; 它是上一次调用回调时返回的累积值,初始值可以通过initialValue定义,默认为数组的第一个元素值,累加器将保留上一个操作的值,就像静态变量一样

    • currentValue(必须):数组中正在处理的元素

    • index(可选):数组中正在处理的当前元素的索引。 如果提供了 initialValue,则起始索引号为 0,否则从索引 1 起始。

      注意:如果没有提供 initialValuereduce 会从索引 1 的地方开始执行 callback 方法,跳过第一个索引。如果提供 initialValue,从索引 0 开始。

    • array(可选):调用 reduce() 的数组

  • initialValue(可选):作为第一次调用 callback 函数时的第一个参数的值。 如果没有提供初始值,则将使用数组中的第一个元素。 在没有初始值的空数组上调用 reduce 将报错

1. 计算数组的最大值和最小值

有很多种方式可以获取数组的最大值或最小值?

使用 Math.max 和 Math.min

使用 Math 的 API 是最简单的方式。

const arrayNumbers = [-1, 10, 6, 5, -3];
const max = Math.max(...arrayNumbers); // 10
const min = Math.min(...arrayNumbers); // -3
console.log(`max=${max}`); // max=10
console.log(`min=${min}`); // min=-3

使用 reduce

一行代码,就可以实现与 Math 的 API 相同的效果。

const arrayNumbers = [-1, 10, 6, 5, -3];
const getMax = (array) => array.reduce((max, num) => (max > num ? max : num));
const getMin = (array) => array.reduce((max, num) => (max < num ? max : num));

const max = getMax(arrayNumbers); // 10
const min = getMin(arrayNumbers); // -3
console.log(`max=${max}`); // max=10
console.log(`min=${min}`); // min=-3

或者写成一个函数:

const arrayNumbers = [-1, 10, 6, 5, -3];

const getMaxOrMin = (array, type = "min") =>
  type === "max"
      ? array.reduce((max, num) => (max > num ? max : num))
      : array.reduce((max, num) => (max < num ? max : num));

const max = getMaxOrMin(arrayNumbers, "max"); // 10
const min = getMaxOrMin(arrayNumbers, "min"); // -3
console.log(`max=${max}`); // max=10
console.log(`min=${min}`); // min=-3

2. 数组求和和累加器

使用 reduce ,可以轻松实现多个数相加或累加的功能。

// 数组求和
const sum = (...nums) => {
   return nums.reduce((sum, num) => sum + num);
};

// 累加器
const accumulator = (...nums) => {
   return nums.reduce((acc, num) => acc * num);
};
const arrayNumbers = [1, 3, 5];

console.log(accumulator(1, 2, 3)); // 6
console.log(accumulator(...arrayNumbers)); // 15

console.log(sum(1, 2, 3, 4, 5)); // 15
console.log(sum(...arrayNumbers)); // 9

3. 格式化搜索参数

获取 URL 种的搜索参数是经常要处理的功能。

// url https://www.devpoint.cn/index.shtml?name=devpoint&id=100
// 格式化 search parameters
{
  name: "devpoint",
  id: "100",
}

常规方式

这是大多数人使用它的方式。

const parseQuery = (search = window.location.search) => {
const query = {};
search
.slice(1)
.split("&")
.forEach((it) => {
const [key, value] = it.split("=");
query[key] = decodeURIComponent(value);
});
return query;
};
console.log(parseQuery("?name=devpoint&id=100")); // { name: 'devpoint', id: '100' }

使用 reduce

const parseQuery = (search = window.location.search) =>
search
.replace(/(^\?)|(&$)/g, "")
.split("&")
.reduce((query, it) => {
const [key, value] = it.split("=");
query[key] = decodeURIComponent(value);
return query;
}, {});

console.log(parseQuery("?name=devpoint&id=100")); // { name: 'devpoint', id: '100' }

4. 反序列化搜索参数

当要跳转到某个链接并为其添加一些搜索参数时,手动拼接的方式不是很方便。如果要串联的参数很多,那将是一场灾难。

const searchObj = {
name: "devpoint",
id: 100,
// ...
};
const strLink = `https://www.devpoint.cn/index.shtml?name=${searchObj.name}&age=${searchObj.id}`;
console.log(strLink); // https://www.devpoint.cn/index.shtml?name=devpoint&age=100

reduce 可以轻松解决这个问题。

const searchObj = {
name: "devpoint",
id: 100,
// ...
};
const stringifySearch = (search = {}) =>
Object.entries(search)
.reduce(
(t, v) => `${t}${v[0]}=${encodeURIComponent(v[1])}&`,
Object.keys(search).length ? "?" : ""
)
.replace(/&$/, "");

const strLink = `https://www.devpoint.cn/index.shtml${stringifySearch(
searchObj
)}`;
console.log(strLink); // https://www.devpoint.cn/index.shtml?name=devpoint&age=100

5. 展平多层嵌套数组

如何展平多层嵌套数组吗?

const array = [1, [2, [3, [4, [5]]]]];
const flatArray = array.flat(Infinity);

console.log(flatArray); // [ 1, 2, 3, 4, 5 ]

如果运行环境支持方法 flat ,则可以直接用,如果不支持,使用 reduce 也可以实现和flat一样的功能。

const array = [1, [2, [3, [4, [5]]]]];

const flat = (arrayNumbers) =>
arrayNumbers.reduce(
(acc, it) => acc.concat(Array.isArray(it) ? flat(it) : it),
[]
);
const flatArray = flat(array);

console.log(flatArray); // [ 1, 2, 3, 4, 5 ]

6. 计算数组成员的数量

如何计算数组中每个成员的个数?即计算重复元素的个数。

const count = (array) =>
array.reduce(
(acc, it) => (acc.set(it, (acc.get(it) || 0) + 1), acc),
new Map()
);
const array = [1, 2, 1, 2, -1, 0, "0", 10, "10"];
console.log(count(array));

这里使用了数据类型 Map ,关于 JavaScript 的这个数据类型,有兴趣可以阅读下文:

JavaScript 数据结构之 Map

上面代码的输出结果如下:

Map(7) {
1 => 2,
2 => 2,
-1 => 1,
0 => 1,
'0' => 1,
10 => 1,
'10' => 1
}

7.获取一个对象的多个属性

这是一个项目开发中比较常遇见的场景。通过 API 获取后端数据,前端很多时候只需要取其中部分的数据。

// 一个有很多属性的对象
const obj = {
a: 1,
b: 2,
c: 3,
d: 4,
e: 5,
// ...
};
// 只是想得到它上面的一些属性来创建一个新的对象
const newObj = {
a: obj.a,
b: obj.b,
c: obj.c,
d: obj.d,
// ...
};

这个时候可以使用 reduce 来解决。

/**
*
* @param {*} obj 原始对象
* @param {*} keys 需要获取的属性值列表,数组形式
* @returns
*/
const getObjectKeys = (obj = {}, keys = []) =>
Object.keys(obj).reduce(
(acc, key) => (keys.includes(key) && (acc[key] = obj[key]), acc),
{}
);

const obj = {
a: 1,
b: 2,
c: 3,
d: 4,
e: 5,
// ...
};
const newObj = getObjectKeys(obj, ["a", "b", "c", "d"]);
console.log(newObj); // { a: 1, b: 2, c: 3, d: 4 }

8.反转字符串

反转字符串是面试中最常问到的 JavaScript 问题之一。

const reverseString = (string) => {
return string.split("").reduceRight((acc, s) => acc + s);
};
const string = "devpoint";
console.log(reverseString(string)); // tniopved

9.数组去重

reduce 也很容易实现数组去重。

const array = [1, 2, 1, 2, -1, 10, 11];
const uniqueArray1 = [...new Set(array)];
const uniqueArray2 = array.reduce(
(acc, it) => (acc.includes(it) ? acc : [...acc, it]),
[]
);

console.log(uniqueArray1); // [ 1, 2, -1, 10, 11 ]
console.log(uniqueArray2); // [ 1, 2, -1, 10, 11 ]

10. 模拟方法 flat

虽然现在的JavaScript有原生方法已经实现了对深度嵌套数组进行扁平化的功能,但是如何才能完整的实现扁平化的功能呢?下面就是使用 reduce 来实现其功能:

// 默认展开一层
Array.prototype.flat2 = function (n = 1) {
   const len = this.length;
   let count = 0;
   let current = this;
   if (!len || n === 0) {
       return current;
  }
   // 确认当前是否有数组项
   const hasArray = () => current.some((it) => Array.isArray(it));
   // 每次循环后展开一层
   while (count++ < n && hasArray()) {
       current = current.reduce((result, it) => result.concat(it), []);
  }
   return current;
};
const array = [1, [2, [3, [4, [5]]]]];
// 展开一层
console.log(array.flat()); // [ 1, 2, [ 3, [ 4, [ 5 ] ] ] ]
console.log(array.flat2()); // [ 1, 2, [ 3, [ 4, [ 5 ] ] ] ]
// 展开所有
console.log(array.flat(Infinity)); // [ 1, 2, 3, 4, 5 ]
console.log(array.flat2(Infinity)); // [ 1, 2, 3, 4, 5 ]

作者:天行无忌
来源:https://juejin.cn/post/7202935318457860151

收起阅读 »

前端程序员是怎么做物联网开发的(下)

web
接:前端程序员是怎么做物联网开发的(上)mqttController.js// const mqtt = require('mqtt') $(document).ready(() => { // Welcome to request my open ...
继续阅读 »

接:前端程序员是怎么做物联网开发的(上)

mqttController.js

// const mqtt = require('mqtt')
$(document).ready(() => {
// Welcome to request my open interface. When the device is not online, the latest 2000 pieces of data will be returned
$.post("https://larryblog.top/api", {
topic: "getWemosDhtData",
skip: 0
},
(data, textStatus, jqXHR) => {
setData(data.res)
// console.log("line:77 data==> ", data)
},
);
// for (let i = 0; i <= 10; i++) {
// toast.showToast(1, "test")
// }
const options = {
clean: true, // true: 清除会话, false: 保留会话
connectTimeout: 4000, // 超时时间
// Authentication information
clientId: 'userClient_' + generateRandomString(),
username: 'userClient',
password: 'aa995231030',
// You are welcome to use my open mqtt broker(My server is weak but come on you). When connecting, remember to give yourself a personalized clientId to prevent being squeezed out
// Topic rule:
// baseName/deviceId/events
}
// 连接字符串, 通过协议指定使用的连接方式
// ws 未加密 WebSocket 连接
// wss 加密 WebSocket 连接
// mqtt 未加密 TCP 连接
// mqtts 加密 TCP 连接
// wxs 微信小程序连接
// alis 支付宝小程序连接
let timer;
let isShowTip = 1
const connectUrl = 'wss://larryblog.top/mqtt'
const client = mqtt.connect(connectUrl, options)
client.on('connect', (error) => {
console.log('已连接:', error)
toast.showToast("Broker Connected")
timer = setTimeout(onTimeout, 3500);
// 订阅主题
client.subscribe('wemos/dht11', function (err) {
if (!err) {
// 发布消息
client.publish('testtopic', 'getDHTData')
}
})
client.subscribe('home/status/')
client.publish('testtopic', 'Hello mqtt')

})
client.on('reconnect', (error) => {
console.log('正在重连:', error)
toast.showToast(3, "reconnecting...")
})

client.on('error', (error) => {
console.log('连接失败:', error)
toast.showToast(2, "connection failed")
})
client.on('message', (topic, message) => {
// console.log('收到消息:', topic, message.toString())
switch (topic) {
case "wemos/dht11":
const str = message.toString()
const arr = str.split(", "); // 分割字符串
const obj = Object.fromEntries(arr.map(s => s.split(": "))); // 转化为对象

document.getElementById("Temperature").innerHTML = obj.Temperature + " ℃"
optionTemperature.xAxis.data.push(moment().format("MM-DD/HH:mm:ss"))
optionTemperature.xAxis.data.length >= 100 && optionTemperature.xAxis.data.shift()
optionTemperature.series[0].data.length >= 100 && optionTemperature.series[0].data.shift()
optionTemperature.series[0].data.push(parseFloat(obj.Temperature))
ChartTemperature.setOption(optionTemperature, true);

document.getElementById("Humidity").innerHTML = obj.Humidity + " %RH"
optionHumidity.xAxis.data.push(moment().format("MM-DD/HH:mm:ss"))
optionHumidity.xAxis.data.length >= 100 && optionHumidity.xAxis.data.shift()
optionHumidity.series[0].data.length >= 100 && optionHumidity.series[0].data.shift()
optionHumidity.series[0].data.push(parseFloat(obj.Humidity))
ChartHumidity.setOption(optionHumidity, true);
break
case "home/status/":
$("#statusText").text("device online")
deviceOnline()
$(".statusLight").removeClass("off")
$(".statusLight").addClass("on")
clearTimeout(timer);
timer = setTimeout(onTimeout, 3500);
break

}

})

function deviceOnline() {
if (isShowTip) {
toast.showToast(1, "device online")
}
isShowTip = 0
}

function setData(data) {
// console.log("line:136 data==> ", data)
for (let i = data.length - 1; i >= 0; i--) {
let item = data[i]
// console.log("line:138 item==> ", item)
optionTemperature.series[0].data.push(item.temperature)
optionHumidity.series[0].data.push(item.humidity)
optionHumidity.xAxis.data.push(moment(item.updateDatetime).format("MM-DD/HH:mm:ss"))
optionTemperature.xAxis.data.push(moment(item.updateDatetime).format("MM-DD/HH:mm:ss"))
}
ChartTemperature.setOption(optionTemperature);
ChartHumidity.setOption(optionHumidity);
}

function onTimeout() {
$("#statusText").text("device offline")
toast.showToast(3, "device offline")
isShowTip = 1
document.getElementById("Temperature").innerHTML = "No data"
document.getElementById("Humidity").innerHTML = "No data"
$(".statusLight").removeClass("on")
$(".statusLight").addClass("off")
}

function generateRandomString() {
let result = '';
let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let charactersLength = characters.length;
for (let i = 0; i < 6; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
});

showTip.js 是我发布在npm上的一个包,如果有需要可以自行npm下载

style.less

* {
padding: 0;
margin: 0;
color: #fff;
}

.app {
background: #1b2028;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;

#deviceStatus {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;

.statusLight {
display: block;
height: 10px;
width: 10px;
border-radius: 100px;
background: #b8b8b8;

&.on {
background: #00a890;
}

&.off {
background: #b8b8b8;
}
}
}

.container {
width: 100%;
height: 0;
flex: 1;
display: flex;

@media screen and (max-width: 768px) {
flex-direction: column;
}

>div {
flex: 1;
height: 100%;
text-align: center;

#echartsViewTemperature,
#echartsViewHumidity {
width: 80%;
height: 50%;
margin: 10px auto;
// background: #eee;
}
}
}
}

echarts.js 这个文件是我自己写的,别学我这种命名方式,这是反例

let optionTemperature = null
let ChartTemperature = null
$(document).ready(() => {
setTimeout(() => {
// waiting
ChartTemperature = echarts.init(document.getElementById('echartsViewTemperature'));
ChartHumidity = echarts.init(document.getElementById('echartsViewHumidity'));
// 指定图表的配置项和数据
optionTemperature = {
textStyle: {
color: '#fff'
},
tooltip: {
trigger: 'axis',
// transitionDuration: 0,
backgroundColor: '#fff',
textStyle: {
color: "#333",
align: "left"
},
},
xAxis: {
min: 0,
data: [],
boundaryGap: false,
splitLine: {
show: false
},
axisLine: {
lineStyle: {
color: '#fff'
}
}
},
yAxis: {
splitLine: {
show: false
},
axisTick: {
show: false // 隐藏 y 轴的刻度线
},
axisLine: {
show: false,
lineStyle: {
color: '#fff'
}
}
},
grid: {
// 为了让标尺和提示框在图表外面,需要将图表向外扩展一点
left: '10%',
right: '5%',
bottom: '5%',
top: '5%',
containLabel: true,
},
series: [{
// clipOverflow: false,
name: '温度',
type: 'line',
smooth: true,
symbol: 'none',
data: [],
itemStyle: {
color: '#00a890'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: '#00a89066' // 0% 处的颜色
}, {
offset: 1,
color: '#00a89000' // 100% 处的颜色
}],
global: false // 缺省为 false
}
},
hoverAnimation: true,
label: {
show: false,
},
markLine: {
symbol: ['none', 'none'],
data: [
{
type: 'average',
name: '平均值',
},
],
},
}]
};
optionHumidity = {
textStyle: {
color: '#fff'
},
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
textStyle: {
color: "#333",
align: "left"
},
},
xAxis: {
min: 0,
data: [],
boundaryGap: false,
splitLine: {
show: false
},
axisTick: {
//x轴刻度相关设置
alignWithLabel: true,
},
axisLine: {
lineStyle: {
color: '#fff'
}
}
},
yAxis: {
splitLine: {
show: false
},
axisTick: {
show: false // 隐藏 y 轴的刻度线
},
axisLine: {
show: false,
lineStyle: {
color: '#fff'
}
}
},
grid: {
// 为了让标尺和提示框在图表外面,需要将图表向外扩展一点
left: '5%',
right: '5%',
bottom: '5%',
top: '5%',
containLabel: true,
},
// toolbox: {
// feature: {
// dataZoom: {},
// brush: {
// type: ['lineX', 'clear'],
// },
// },
// },
series: [{
clipOverflow: false,
name: '湿度',
type: 'line',
smooth: true,
symbol: 'none',
data: [],
itemStyle: {
color: '#ffa74b'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: '#ffa74b66' // 0% 处的颜色
}, {
offset: 1,
color: '#ffa74b00' // 100% 处的颜色
}],
global: false // 缺省为 false
}
},
hoverAnimation: true,
label: {
show: false,
},
markLine: {
symbol: ['none', 'none'],
data: [
{
type: 'average',
name: '平均值',
},
],
},
}]
};

// 使用刚指定的配置项和数据显示图表。
ChartTemperature.setOption(optionTemperature);
ChartHumidity.setOption(optionHumidity);
}, 100)
});

当你看到这里,你应该可以在你的前端页面上展示你的板子发来的每一条消息了,但是还远远做不到首图上那种密密麻麻的数据,我并不是把页面开了一天,而是使用了后端和数据库存储了一部分数据。

后端

后端我们分为了两个部分,一个是nodejs的后端程序,一个是nginx代理,这里先讲代理,因为上一步前端的连接需要走这个代理

nginx

如果你没有使用https连接,那么可以不看本节,直接使用未加密的mqtt协议,如果你有自己的域名,且申请了ssl证书,那么可以参考我的nginx配置,配置如下

http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;

##
# SSL Settings
##
server {
listen 80;
server_name jshub.cn;
#将请求转成https
rewrite ^(.*)$ https://$host$1 permanent;
}
server {
listen 443 ssl;
server_name jshub.cn;
location / {
root /larryzhu/web/release/toolbox;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /mqtt {
proxy_pass http://localhost:8083;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# SSL 协议版本
ssl_protocols TLSv1.2;
# 证书
ssl_certificate /larryzhu/web/keys/9263126_jshub.cn.pem;
# 私钥
ssl_certificate_key /larryzhu/web/keys/9263126_jshub.cn.key;
# ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# ssl_ciphers AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256;

# 与False Start没关系,默认此项开启,此处减少抓包的干扰而关闭
# ssl_session_tickets off;

# return 200 "https ok \n";
}

注意这只是部分配置,切不可全部覆盖你的配置。

如果你不会使用nginx,说明你无需配置 ssl ,直接使用 mqtt协议即可。

后端程序部分

这里以egg.js框架为例

首先需要下载egg.js的插件 egg-emqtt ,直接使用npm下载即可,详细配置和启用方法 参见 MQTT系列实践二 在EGG中使用mqtt

上面教程的方法并不全面,可以下载我的示例,仿照着写一下,因为内容相对复杂,地址:gitee.com/zhu_yongbo/…

其中还包含了 mysql 数据库的连接方法,内有我服务器的地址、mysql开放端口,用户名以及密码,我服务器还剩不到十天到期,有缘人看到我的文章可以对我的服务器为所欲为,没有什么重要数据。

mysql

mysql方面,只需要一个库,一个表即可完成全部工作


如图所示,不复杂,仿照我的建库即可

有一点,比较重要,因为mysql本身不适用于存储量级太大的数据,我们的数据重复的又比较多,可以考虑一下压缩算法,或者添加一个事件(每次插入时检查数据量是否超过一定值)。像我的板子大概正常累计运行了几天的时间(每两秒一条数据),到目前可以看到已经累计了七十万条数据了,如果不是因为我设置了插入事件,这个数据量已经可以明显影响查询速度了。

可以仿照我的事件,语句如下:

DELIMITER $$
CREATE TRIGGER delete_oldest_data
AFTER INSERT ON wemosd1_dht11
FOR EACH ROW
BEGIN
   -- 如果数据量超过43200(每两秒插入一条,这是一天的量)条,调用存储过程删除最早的一条数据
  IF (SELECT COUNT(*) FROM wemosd1_dht11) > 43200 THEN
      CALL delete_oldest();
  END IF;
END$$
DELIMITER ;

-- 创建存储过程
CREATE PROCEDURE delete_oldest()
BEGIN
   -- 删除最早的一条数据
   delete from wemosd1_dht11 order by id asc limit 1
   
END$$
DELIMITER ;

BTW:这是chatGPT教我的,我只进行了一点小小的修改。

这样做会删除id比较小的数据,然后就会导致,id会增长的越来越大,好处是可以看到一共累计了多少条数据。但是如果你不想让id累计,那么可以选择重建id,具体做法,建议你咨询一下chatGPT

结语

至此,我们已经完成了前端、后端、设备端三端连通。

我们整体梳理一下数据是怎么一步一步来到我们眼前的:

首先wemos d1开发板会在DHT11温湿度传感器上读取温湿度值,然后开发板把数据通过mqtt广播给某topic,我们的前后端都订阅了此topic,后端收到后,把处理过的数据存入mysql,前端直接使用echarts进行展示,当前端启动时,还可以向后端程序查询历史数据,比如前8000条数据,之后的变化由在线的开发板提供,我们就得到了一个实时的,并且能看到历史数据的温湿度在线大屏。


作者:加伊juejin
来源:juejin.cn/post/7203180003471081531

收起阅读 »

前端程序员是怎么做物联网开发的(上)

web
概述和基础讲解前端:jq、less、echarts、mqtt.js后端:eggjs、egg-emqtt数据库:mysql服务器:emqx(mqtt broker)硬件:板子:wemos D1 R2(设计基于 Arduino Uno R3 , 搭载esp8266...
继续阅读 »

前端程序员是怎么做物联网开发的

image-20230104162825029

上图是我历时一周做的在线的温湿度可视化项目,可以查看截至目前往前一天的温度、湿度变化趋势,并且实时更新当前温湿度

本文可能含有知识诅咒

概述和基础讲解

该项目用到的技术有:

  • 前端:jq、less、echarts、mqtt.js

  • 后端:eggjs、egg-emqtt

  • 数据库:mysql

  • 服务器:emqx(mqtt broker)

  • 硬件:

    • 板子:wemos D1 R2(设计基于 Arduino Uno R3 , 搭载esp8266 wifi模块)

  • 调试工具:mqttx、Arduino IDE v2.0.3 使用Arduino C开发

必备知识:

  • nodejs(eggjs框架)能面向业务即可

  • mysql 能写基本插入查询语句即可

  • C语言的基本语法了解即可

  • 知道mqtt协议的运作方式即可

  • arduino 开发板或任何其他电路板的初步了解即可

简单介绍一下上面几个的知识点:

  1. 从来没有后端学习经验的同学,推荐一个全栈项目供你参考:vue-xmw-admin-pro ,该项目用到了 前端VUE、后端eggjs、mysql、redis,对全栈学习很有帮助。

  2. mysql 只需要知道最简单的插入和查询语句即可,在本项目中,其实使用mongodb是更合适的,但是我为了方便,直接用了现成的mysql

  3. 即使你不知道C语言的基本语法,也可以在一小时内快速了解一下,知道简单的定义变量、函数、返回值即可

  4. MQTT(消息队列遥测传输)是一种网络协议(长连接,意思就是除了客户端可以主动向服务器通信外,服务器也可以主动向客户端发起),也是基于TCP/IP的,适用于算力低下的硬件设备使用,基于发布\订阅范式的消息协议,具体示例如下:

    image-20230104170333941

    当某客户端想发布消息时,图大概长这样:

    image-20230104171235368

    由上图可知,当客户端通过验证上线后,还需要订阅主题,当某客户端向某主题发布消息时,只有订阅了该主题的客户端会收到broker的转发。

    举一个简单的例子:你和我,还有他,我们把自己的名字、学号报告给门卫大爷(broker),门卫大爷就同意我们在警卫室玩一会,警卫室有无数块黑板(topic),我们每个人都可以向门卫请求:如果某黑板上被人写了字,请转告给我。门卫会记住每个人的要求,比如当你向一块黑板写了字(你向某topic发送了消息),所有要求门卫告诉的人都会被门卫告知你写了什么(如果你也要求被告知,那么也包括你自己)。

  5. 开发板可以被写入程序,程序可以使用简单的代码控制某个针脚的高低电平,或者读取某针脚的数据。

开始

  1. 购买 wemos d1开发板、DHT11温湿度传感器,共计19.3元。

  2. 使用arduino ide(以下简称ide) 对wemos d1编程需要下载esp8266依赖 参见:Arduino IDE安装esp8266 SDK

  3. 在ide的菜单栏选择:文件>首选项>其他开发板管理器地址填入:arduino.esp8266.com/stable/pack…,可以顺便改个中文

  4. 安装ch340驱动参见: win10 安装 CH340驱动 实测win11同样可用

  5. 使用 micro-usb 线,连接电脑和开发板,在ide菜单中选择:工具>开发板>esp8266>LOLIN(WEMOS) D1 R2 & mini

  6. 选择端口,按win+x,打开设备管理器,查看你的ch340在哪个端口,在ide中选择对应的端口

  7. 当ide右下角显示LOLIN(WEMOS) D1 R2 & mini 在comXX上时,连接就成功了

  8. 打开ide菜单栏 :文件>示例>esp8266>blink,此时ide会打开新窗口,在新窗口点击左上角的上传按钮,等待上传完成,当板子上的灯一闪一闪,就表明:环境、设置、板子都没问题,可以开始编程了,如果报错,那么一定是哪一步出问题了,我相信你能够根据错误提示找出到底是什么问题,如果实在找不出问题,那么可能买到了坏的板子(故障率还是蛮高的)

wemos d1 针脚中有一个 3.3v电源输出,三个或更多的GND接地口,当安装DHT11传感器元件时,需要将正极插入3.3v口,负极插入GND口,中间的数据线插入随便的数字输入口,比如D5口(D5口的PIN值是14,后面会用到)。

使用DHT11传感器,需要安装库:DHT sensor library by Adafruit , 在ide的左侧栏中的库管理中直接搜索安装即可

下面是一个获取DHT11数据的简单示例,如果正常的话,在串口监视器中,会每秒输出温湿度数据

#include "DHT.h" //这是依赖或者叫库,或者叫驱动也行
#include "string.h"
#define DHTPIN 14     // DHT11数据引脚连接到D5引脚 D5引脚的PIN值是14
#define DHTTYPE DHT11 // 定义DHT11传感器
DHT dht(DHTPIN, DHTTYPE); //初始化传感器

void setup() {
Serial.begin(115200);
//wemos d1 的波特率是 115200
pinMode(BUILTIN_LED, OUTPUT); //设置一个输出的LED
dht.begin(); //启动传感器
}

char* getDHT11Data() {
float h = dht.readHumidity(); //获取湿度值
float t = dht.readTemperature(); //获取温度值
static char data[100];
if (isnan(h) || isnan(t)) {
  Serial.println("Failed to read from DHT sensor!");
  sprintf(data, "Temperature: %.1f, Humidity: %.1f", 0.0, 0.0); //如果任何一个值没有值,直接返回两个0.0,这样我们就知道传感器可能出问题了
  return data;
}
sprintf(data, "Temperature: %.1f, Humidity: %.1f", t, h); //正常就取到值,我这里拼成了一句话
return data;
}

void loop() {
char* data = getDHT11Data(); //此处去取传感器值
Serial.println("got: " + String(data)); // 打印主题内容
delay(1000); //每次循环延迟一秒
}

继续

到了这一步,如果你用的是普通的arduino uno r3板子,就可以结束了。

取到数据之后,你就可以根据数据做一些其他的事情了,比如打开接在d6引脚上的继电器,而这个继电器控制着一个加湿器。

如果你跟我一样,使用了带wifi网络的板子,就可以继续跟我做。

我们继续分步操作:

设备端:

  1. 引入esp8266库(上面已经提到安装过程)

    1. #include "ESP8266WiFi.h"
      复制代码
  2. 安装mqtt客户端库 ,直接在库商店搜索 PubSubClient ,下载 PubSubClient by Nick O'Leary 那一项,下载完成后:

    1. #include "PubSubClient.h"
      复制代码
  3. 至此,库文件已全部安装引入完毕

  4. 设置 wifi ssid(即名字) 和 密码,如:

    1. char* ssid = "2104";
      char* passwd = "13912428897";
      复制代码
  5. 尝试连接 wifi

    1. WiFiClient espClient;
      int isConnect = 0;
      void connectWIFI() {
       isConnect = 0;
       WiFi.mode(WIFI_STA);  //不知道什么意思,照着写就完了
       WiFi.begin(ssidpasswd); //尝试连接
       int timeCount = 0;  //尝试次数
       while (WiFi.status() !WL_CONNECTED) { //如果没有连上,继续循环
         for (int i = 200i <= 255i++) {
           analogWrite(BUILTIN_LEDi);
           delay(2);
        }
         for (int i = 255i >= 200i--) {
           analogWrite(BUILTIN_LEDi);
           delay(2);
        }
         // 上两个循环共计200ms左右,在控制LED闪烁而已,你也可以不写
         Serial.println("wifi connecting......" + String(timeCount));
         timeCount++;
         isConnect = 1//每次都需要把连接状态码设置一下,只有连不上时设置为0
         // digitalWrite(BUILTIN_LED, LOW);
         if (timeCount >= 200) {
           // 当40000毫秒时还没连上,就不连了
           isConnect = 0//设置状态码为 0
           break;
        }
      }
       if (isConnect == 1) {
         Serial.println("Connect to wifi successfully!" + String("SSID is ") + WiFi.SSID());
         Serial.println(String("mac address is ") + WiFi.macAddress());
         // digitalWrite(BUILTIN_LED, LOW);
         analogWrite(BUILTIN_LED250); //设置LED常亮,250的亮度对我来说已经很合适了
         settMqttConfig();  //尝试连接mqtt服务器,在下一步有详细代码
      else {
         analogWrite(BUILTIN_LED255); //设置LED常灭,不要问我为什么255是常灭,因为我的灯是高电平熄灭的
         //连接wifi失败,等待一分钟重连
         delay(60000);
      }
      }
      复制代码
  6. 尝试连接 mqtt

    1. const char* mqtt_server = "larryblog.top"; //这里是我的服务器,当你看到这篇文章的时候,很可能已经没了,因为我的服务器还剩11天到期
      const char* TOPIC = "testtopic";           // 设置信息主题
      const char* client_id = "mqttx_3b2687d2";   //client_id不可重复,可以随便取,相当于你的网名
      PubSubClient client(espClient);
      void settMqttConfig() {
      client.setServer(mqtt_server, 1883); //设定MQTT服务器与使用的端口,1883是默认的MQTT端口
      client.setCallback(onMessage); //设置收信函数,当订阅的主题有消息进来时,会进这个函数
      Serial.println("try connect mqtt broker");
      client.connect(client_id, "wemos", "aa995231030"); //后两个参数是用户名密码
      client.subscribe(TOPIC); //订阅主题
      Serial.println("mqtt connected"); //一切正常的话,就连上了
      }
      //收信函数
      void onMessage(char* topic, byte* payload, unsigned int length) {
      Serial.print("Message arrived [");
      Serial.print(topic); // 打印主题信息
      Serial.print("]:");
      char* payloadStr = (char*)malloc(length + 1);
      memcpy(payloadStr, payload, length);
      payloadStr[length] = '\0';
      Serial.println(payloadStr); // 打印主题内容
      if (strcmp(payloadStr, (char*)"getDHTData") == 0) {
        char* data = getDHT11Data();
        Serial.println("got: " + String(data)); // 打印主题内容
        client.publish("wemos/dht11", data);
      }
      free(payloadStr); // 释放内存
      }
      复制代码
  7. 发送消息

    1. client.publish("home/status/", "{device:client_id,'status':'on'}");
      //注意,这里向另外一个主题发送的消息,消息内容就是设备在线,当有其他的客户端(比如web端)订阅了此主题,便能收到此消息
      复制代码

至此,板子上的代码基本上就写完了,完整代码如下:

#include "ESP8266WiFi.h"
#include "PubSubClient.h"
#include "DHT.h"
#include "string.h"
#define DHTPIN 14      // DHT11数据引脚连接到D5引脚
#define DHTTYPE DHT11  // DHT11传感器
DHT dht(DHTPINDHTTYPE);

charssid = "2104";
charpasswd = "13912428897";
const charmqtt_server = "larryblog.top";
const charTOPIC = "testtopic";            // 订阅信息主题
const charclient_id = "mqttx_3b2687d2";
int isConnect = 0;
WiFiClient espClient;
PubSubClient client(espClient);
long lastMsg = 0;
void setup() {
 Serial.begin(115200);
 // Set WiFi to station mode
 connectWIFI();
 pinMode(BUILTIN_LEDOUTPUT);
 dht.begin();
}
chargetDHT11Data() {
 float h = dht.readHumidity();
 float t = dht.readTemperature();
 static char data[100];
 if (isnan(h) || isnan(t)) {
   Serial.println("Failed to read from DHT sensor!");
   sprintf(data"Temperature: %.1f, Humidity: %.1f"0.00.0);
   return data;
}
 sprintf(data"Temperature: %.1f, Humidity: %.1f"th);
 return data;
}
void connectWIFI() {
 isConnect = 0;
 WiFi.mode(WIFI_STA);
 WiFi.begin(ssidpasswd);
 int timeCount = 0;
 while (WiFi.status() !WL_CONNECTED) {
   for (int i = 200i <= 255i++) {
     analogWrite(BUILTIN_LEDi);
     delay(2);
  }
   for (int i = 255i >= 200i--) {
     analogWrite(BUILTIN_LEDi);
     delay(2);
  }
   // 上两个循环共计200ms左右
   Serial.println("wifi connecting......" + String(timeCount));
   timeCount++;
   isConnect = 1;
   // digitalWrite(BUILTIN_LED, LOW);
   if (timeCount >= 200) {
     // 当40000毫秒时还没连上,就不连了
     isConnect = 0;
     break;
  }
}
 if (isConnect == 1) {
   Serial.println("Connect to wifi successfully!" + String("SSID is ") + WiFi.SSID());
   Serial.println(String("mac address is ") + WiFi.macAddress());
   // digitalWrite(BUILTIN_LED, LOW);
   analogWrite(BUILTIN_LED250);
   settMqttConfig();
else {
   analogWrite(BUILTIN_LED255);
   //连接wifi失败,等待一分钟重连
   delay(60000);
}
}
void settMqttConfig() {
 client.setServer(mqtt_server1883);  //设定MQTT服务器与使用的端口,1883是默认的MQTT端口
 client.setCallback(onMessage);
 Serial.println("try connect mqtt broker");
 client.connect(client_id"wemos""aa995231030");
 client.subscribe(TOPIC);
 Serial.println("mqtt connected");
}
void onMessage(chartopicbytepayloadunsigned int length) {
 Serial.print("Message arrived [");
 Serial.print(topic);  // 打印主题信息
 Serial.print("]:");
 charpayloadStr = (char*)malloc(length + 1);
 memcpy(payloadStrpayloadlength);
 payloadStr[length] = '\0';
 Serial.println(payloadStr);  // 打印主题内容
 if (strcmp(payloadStr, (char*)"getDHTData") == 0) {
   chardata = getDHT11Data();
   Serial.println("got: " + String(data));  // 打印主题内容
   client.publish("wemos/dht11"data);
}
 free(payloadStr);  // 释放内存
}
void publishDhtData() {
 chardata = getDHT11Data();
 Serial.println("got: " + String(data));  // 打印主题内容
 client.publish("wemos/dht11"data);
 delay(2000);
}
void reconnect() {
 Serial.print("Attempting MQTT connection...");
 // Attempt to connect
 if (client.connect(client_id"wemos""aa995231030")) {
   Serial.println("reconnected successfully");
   // 连接成功时订阅主题
   client.subscribe(TOPIC);
else {
   Serial.print("failed, rc=");
   Serial.print(client.state());
   Serial.println(" try again in 5 seconds");
   // Wait 5 seconds before retrying
   delay(5000);
}
}
void loop() {
 if (!client.connected() && isConnect == 1) {
   reconnect();
}
 if (WiFi.status() !WL_CONNECTED) {
   connectWIFI();
}
 client.loop();
 publishDhtData();
 long now = millis();
 if (now - lastMsg > 2000) {
   lastMsg = now;
   client.publish("home/status/""{device:client_id,'status':'on'}");
}
 // Wait a bit before scanning again
 delay(1000);
}

服务器

刚才的一同操作很可能让人一头雾水,相信大家对上面mqtt的操作还是一知半解的,不过没有关系,通过对服务端的设置,你会对mqtt的机制了解的更加透彻

我们需要在服务端部署 mqtt broker,也就是mqtt的消息中心服务器

在网络上搜索 emqx , 点击 EMQX: 大规模分布式物联网 MQTT 消息服务器 ,这是一个带有可视化界面的软件,而且画面特别精美,操作特别丝滑,功能相当强大,使用起来基本上没有心智负担。点击立即下载,并选择适合你的服务器系统的版本:

image-20230223102450653

这里拿 ubuntu和windows说明举例,相信其他系统也都大差不差

在ubuntu上,推荐使用apt下载,按上图步骤操作即可,如中途遇到其他问题,请自行解决

  1. sudo ufw status 查看开放端口,一般情况下,你只会看到几个你手动开放过的端口,或者只有80、443端口

  2. udo ufw allow 18083 此端口是 emqx dashboard 使用的端口,开启此端口后,可以在外网访问 emqx看板控制台

image-20230223103352676 当你看到如图所示的画面,说明已经开启成功了

windows下直接下载安装包,上传到服务器,双击安装即可

  1. 打开 “高级安全Windows Defender 防火墙”,点击入站规则>新建规则

  2. 点击端口 > 下一步

  3. 点击TCP、特定本地端口 、输入18083,点击下一步

  4. 一直下一步到最后一步,输入名称,推荐输入 emqx 即可

image-20230223103810837

当你看到如图所示画面,说明你已经配置成功了。

完成服务端程序安装和防火墙端口配置后,我们需要配置服务器后台的安全策略,这里拿阿里云举例:

如果你是 ESC 云主机,点击实例>点击你的服务器名>安全组>配置规则>手动添加

添加这么一条即可:

image-20230223104139442

如果你是轻量服务器,点击安全>防火墙>添加规则 即可,跟esc设置大差不差。

完成后,可以在本地浏览器尝试访问你的emqx控制台

image-20230223104408482

直接输入域名:18083即可,初始用户名为admin,初始密码为public,登录完成后,你便会看到如下画面

image-20230223104559151

接下来需要配置 客户端登录名和密码,比如刚刚在设备中写的用户名密码,就是在这个系统中设置的

点击 访问控制>认证 > 创建,然后无脑下一步即可,完成后你会看到如下画面

image-20230223104906488

点击用户管理,添加用户即可,用户名和密码都是自定义的,这些用户名密码可以分配给设备端、客户端、服务端、测试端使用,可以参考我的配置

image-20230223105013597

userClient是准备给前端页面用的 ,server是给后端用的,995231030是我个人自留的超级用户,wemos是设备用的,即上面设备连接时输入的用户名密码。

至此,emqx 控制台配置完成。

下载 mqttx,作为测试端尝试连接一下

image-20230223105505838

点击连接,你会发现,根本连接不上......

因为,1883(mqtt默认端口)也是没有开启的,当然,和开启18083的方法一样。

同时,还建议你开启:

  • 1803 websocket 默认端口

  • 1804 websockets 默认端口

  • 3306 mysql默认端口

后面这四个端口都会用到。

当你开启完成后,再次尝试使用mqttx连接broker,会发现可以连接了

image-20230223105957929

这个页面的功能也是很易懂的,我们在左侧添加订阅,右侧的聊天框里会出现该topic的消息

image-20230223110105586

你是否还记得,在上面的设备代码中,我们在loop中每一秒向 home/status/ 发送一条设备在线的提示,我们现在在这里就收到了。

当你看到这些消息的时候,就说明,你的设备、服务器、emqx控制台已经跑通了。

前后端以及数据库

前端

前端不必多说,我们使用echarts承载展示数据,由于体量较小,我们不使用任何框架,直接使用jq和echarts实现,这里主要讲前端怎么连接mqtt

首先引入mqtt库

然后设置连接参数

  const options = {
clean: true, // true: 清除会话, false: 保留会话
connectTimeout: 4000, // 超时时间
clientId: 'userClient_' + generateRandomString(),
//前端客户端很可能比较多,所以这里我们生成一个随机的6位字母加数字作为clientId,以保证不会重复
username: 'userClient',
password: 'aa995231030',
}
function generateRandomString() {
let result = '';
let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let charactersLength = characters.length;
for (let i = 0; i < 6; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

连接

  // const connectUrl = 'mqtt://larryblog.top/mqtt' 当然你可以使用mqtt协议,但是有可能会遇到 ssl 跨域的问题,如果你不使用 https 可以忽略这一项,直接使用mqtt即可
const connectUrl = 'wss://larryblog.top/mqtt' //注意,这里使用了nginx进行转发,后面会讲
const client = mqtt.connect(connectUrl, options)

因为前端代码不多,我这里直接贴了

html:

index.html











<span class="http"><span class="properties"><span class="hljs-attr">wemos</span> <span class="hljs-string">d1 test</span></span></span>






Loading device status





Current temperature:
loading...



Current humidity:
loading...












续:前端程序员是怎么做物联网开发的(下)

作者:加伊juejin
来源:juejin.cn/post/7203180003471081531

收起阅读 »

2023面试真题之CSS篇

web
恐惧就是这样,你直视它、向前一步的时候它就消失了,选择相信自己能克服一切困难,勇敢向前,直面恐惧,就会发现之前的害怕,变成了心里的能量。大家好,我是柒八九。今天,我们继续2023前端面试真题系列。我们来谈谈关于CSS的相关知识点。如果,想了解该系列的文章,可以...
继续阅读 »

恐惧就是这样,你直视它、向前一步的时候它就消失了,选择相信自己能克服一切困难,勇敢向前,直面恐惧,就会发现之前的害怕,变成了心里的能量。

大家好,我是柒八九

今天,我们继续2023前端面试真题系列。我们来谈谈关于CSS的相关知识点。

如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。

文章list

  1. 2023前端面试真题之JS篇

你能所学到的知识点

  1. 盒模型 推荐阅读指数⭐️⭐️⭐️⭐️

  2. CSS的display属性有哪些值 推荐阅读指数⭐️⭐️⭐️⭐️

  3. position 里面的属性有哪些 推荐阅读指数⭐️⭐️⭐️⭐️

  4. flex里面的属性 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  5. flex布局的应用场景 推荐阅读指数⭐️⭐️⭐️⭐️

  6. CSS的长度单位有哪些 推荐阅读指数⭐️⭐️⭐️⭐️

  7. 水平垂直居中 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  8. {块级格式化上下文|Block Formatting Context} 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  9. 层叠规则 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  10. 重绘和重排 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  11. CSS引入方式(4种) 推荐阅读指数⭐️⭐️⭐️⭐️

  12. 硬件加速 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  13. 元素超出宽度...处理 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  14. 元素隐藏 推荐阅读指数⭐️⭐️⭐️⭐️⭐️

  15. Chrome支持小于12px 的文字 推荐阅读指数⭐️⭐️⭐️⭐️

好了,天不早了,干点正事哇。 img


盒模型

一个盒子由四个部分组成:contentpaddingbordermargin

  1. content

    ,即

    实际内容

    ,显示文本和图像

    • content 属性大都是用在::before/::after这两个伪元素

  2. padding

    ,即内边距,内容周围的区域

    • 内边距是透明

    • 取值不能为负

    • 受盒子的background属性影响

    • padding 百分比值无论是水平还是垂直方向均是相对于宽度计算

  3. boreder,即边框,围绕元素内容的内边距的一条或多条线,由粗细、样式、颜色三部分组成

  4. margin,即外边距,在元素外创建额外的空白,空白通常指不能放其他元素的区域

img

标准盒模型

img

  • 盒子总宽度 = width + padding + border + margin;

  • 盒子总高度 = height + padding + border + margin

也就是,width/height 只是内容宽高,不包含 paddingborder

IE 怪异盒子模型

img

  • 盒子总宽度 = width + margin;

  • 盒子总高度 = height + margin;

也就是,width/height 包含了 paddingborder

更改盒模型

CSS 中的 box-sizing 属性定义了渲染引擎应该如何计算一个元素的总宽度和总高度

box-sizing: content-box|border-box
复制代码
  1. content-box (默认值),元素的 width/height 不包含paddingborder,与标准盒子模型表现一致

  2. border-box 元素的 width/height 包含 paddingborder,与怪异盒子模型表现一致


CSS的display属性有哪些值

CSS display 属性设置元素是否被视为或者内联元素以及用于子元素的布局,例如流式布局网格布局弹性布局

形式上,display 属性设置元素的内部外部的显示类型。

  1. 外部类型设置元素参与流式布局;

  2. 内部类型设置子元素的布局(子元素的格式化上下文

常见属性值(8个)

  1. inline :默认

  2. block

  3. inline-block

  4. flex

  5. grid

  6. table

  7. list-item

  8. 双值的:只有Firefox70支持了这一语法


position 里面的属性有哪些

定义和用法:position 属性规定元素的定位类型。
说明:这个属性定义建立元素布局所用的 定位机制

  • 任何元素都可以定位

  • 绝对或固定元素会生成一个块级框,而不论该元素本身是什么类型。

  • 相对定位元素会相对于它在正常流中的默认位置偏移。

position 有以下可选值:(6个)

img

CSS 有三种基本的定位机制:普通流浮动绝对定位


flex里面的属性

容器的属性 (6个)

  1. flex-direction
    • 决定主轴的方向(即项目的排列方向)

    • row默认值):主轴为水平方向,起点在左端。

    • row-reverse:主轴为水平方向,起点在右端。

    • column:主轴为垂直方向,起点在上沿。

    • column-reverse:主轴为垂直方向,起点在下沿。

  2. flex-wrap
    • flex-wrap属性定义,如果一条轴线排不下,如何换行

    • nowrap:(默认):不换行。

    • wrap:换行,第一行在上方。

    • wrap-reverse:换行,第一行在下方

  3. flex-flow
    • flex-flow属性是flex-direction属性和flex-wrap属性的简写形式,默认值为row nowrap

  4. justify-content
    • justify-content属性定义了项目在主轴上的对齐方式。

    • flex-start默认值):左对齐

    • flex-end:右对齐

    • center: 居中

    • space-between两端对齐,项目之间的间隔都相等。

    • space-around:每个项目两侧的间隔相等。所以,项目之间的间隔比项目与边框的间隔大一倍

  5. align-items
    • align-items属性定义项目在交叉轴上如何对齐。

    • stretch默认值):如果项目未设置高度或设为auto,将占满整个容器的高度

    • flex-start:交叉轴的起点对齐。

    • flex-end:交叉轴的终点对齐。

    • center:交叉轴的中点对齐。

    • baseline: 项目的第一行文字的基线对齐。

  6. align-content
    • align-content属性定义了多根轴线的对齐方式

    • 如果项目只有一根轴线,该属性不起作用。

项目的属性(6个)

  1. order
    • order属性定义项目的排列顺序。

    • 数值越小,排列越靠前,默认为0

  2. flex-grow
    • flex-grow属性定义项目的放大比例

    • 默认为0,即如果存在剩余空间,也不放大

    • 如果所有项目的flex-grow属性都为1,则它们将等分剩余空间(如果有的话)

  3. flex-shrink
    • flex-shrink属性定义了项目的缩小比例

    • 默认为1,即如果空间不足,该项目将缩小。

    • 如果所有项目的flex-shrink属性都为1,当空间不足时,都将等比例缩小

  4. flex-basis
    • flex-basis属性定义了在分配多余空间之前,项目占据的{主轴空间|main size}。

    • 浏览器根据这个属性,计算主轴是否有多余空间。

    • 它的默认值为auto,即项目的本来大小。

  5. flex
    • flex属性是flex-grow, flex-shrinkflex-basis的简写,默认值为0 1 auto后两个属性可选

    • flex: 1 = flex: 1 1 0%

    • flex: auto = flex: 1 1 auto

  6. align-self

flex:1 vs flex:auto

flex:1flex:auto 的区别,可以归结于flex-basis:0flex-basis:auto的区别

当设置为0时(绝对弹性元素),此时相当于告诉flex-growflex-shrink在伸缩的时候不需要考虑我的尺寸

当设置为auto时(相对弹性元素),此时则需要在伸缩时将元素尺寸纳入考虑


flex布局的应用场景

  1. 网格布局

    • Grid- display:flex

    • Grid-Cell - flex: 1; flex:1使得各个子元素可以等比伸缩,flex: 1 = flex: 1 1 0%

  2. 百分比布局

    • img

    • col2 - flex: 0 0 50%;

    • col3 - flex: 0 0 33.3%;

    • img

  3. 圣杯布局

    • 页面从上到下,分成三个部分:头部(header),躯干(body),尾部(footer)。其中躯干又水平分成三栏,从左到右为:导航、主栏、副栏

    • img

    • container - display: flex; - flex-direction: column;- min-height: 100vh;

    • header/footer - flex: 0 0 100px;

    • body - display: flex; - flex:1

    • content - flex: 1;

    • ads/av - flex: 0 0 100px;

    • nav - order: -1;

    • img

  4. 侧边固定宽度

    • 侧边固定宽度,右边自适应

    • img

    • aside1 - flex: 0 0 20%;

    • body1 - flex:1

  5. 流式布局

    • 每行的项目数固定,会自动分行

    • img

    • container2 - display: flex; - flex-flow: row wrap;

    • img


CSS的长度单位有哪些

  1. 相对长度

    • 相对长度单位指的是这个单位没有一个固定的值,它的值受到其它元素属性(例如浏览器窗口的大小、父级元素的大小)的影响,在响应式布局方面相对长度单位非常适用

    • img

  2. 绝对长度

    • 绝对长度单位表示一个真实的物理尺寸,它的大小是固定的,不会因为其它元素尺寸的变化而变化

    • img


水平垂直居中

  1. 宽&高固定

    1. absolute + 负 margin

    2. absolute + margin auto

    3. absolute + calc

  2. 宽&高不固定

    1. absolute + transform: translate(-50%, -50%);

    2. flex布局

    3. grid 布局

宽&高固定

absolute + 负 margin

.parent {
+ position: relative;
}

.child {
width: 300px;
height: 100px;
padding: 20px;

+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -70px 0 0 -170px;
}

复制代码

img

  • 初始位置为方块1的位置

  • 当设置left、top为50%的时候,内部子元素为方块2的位置

  • 设置margin为负数时,使内部子元素到方块3的位置,即中间位置

absolute + margin auto

img

absolute + calc

img


宽&高不固定

absolute + transform: translate(-50%, -50%);

.parent {
position: relative;
}
.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
复制代码

flex布局

.parent {
display: flex;
justify-content: center;
align-items: center;
}
复制代码

grid布局

.parent {
display:grid;
}
.parent .child{
margin:auto;
}
复制代码

{块级格式化上下文|Block Formatting Context}

{块级格式化上下文|Block Formatting Context}(BFC),它是页面中的一块渲染区域,并且有一套属于自己的渲染规则:(6个)

  1. 内部的盒子会在垂直方向一个接一个的放置

  2. 对于同一个BFC的俩个相邻的盒子的margin会发生重叠,与方向无关。

  3. 每个元素的左外边距与包含块的左边界相接触(页面布局方向从左到右),即使浮动元素也是如此

  4. BFC的区域不会与float的元素区域重叠

  5. 计算BFC的高度时,浮动子元素也参与计算

  6. BFC就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素,反之亦然

触发条件 (5个)RFODP

  1. 根元素,即HTML元素

  2. 浮动元素float值为left、right

  3. overflow值不为 visible,为 autoscrollhidden

  4. display的值为inline-block、table、inline-table、flex、inline-flex、grid、inline-grid

  5. position 的值为absolutefixed

应用场景

  1. 防止

    margin

    重叠

    • 将位于同一个BFC的元素,分割到不同的BFC中

  2. 高度塌陷

    • 计算BFC的高度时,浮动子元素也参与计算

    • 子元素浮动

    • 父元素 overflow: hidden;构建BFC

  3. 多栏自适应

    • BFC的区域不会与float的元素区域重叠

    • aside -float:left

    • main -margin-left:aside-width -overflow: hidden构建BFC


层叠规则

所谓层叠规则,指的是当网页中的元素发生层叠时的表现规则。

z-index:z-index属性只有和定位元素(position不为static的元素)在一起的时候才有作用。

CSS3中,z-index已经并非只对定位元素有效,flex盒子的子元素也可以设置z-index属性。

层叠上下文的特性

  • 层叠上下文的层叠水平要比普通元素高

  • 层叠上下文可以阻断元素的混合模式

  • 层叠上下文可以嵌套,内部层叠上下文及其所有元素均受制于外部的层叠上下文

  • 每个层叠上下文和兄弟元素独立

    • 当进行层叠变化或渲染的时候,只需要考虑后代元素

  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中

层叠上下文的创建(3类)

由一些CSS属性创建

  1. 天生派

    • 页面根元素天生具有层叠上下文

    • 根层叠上下文

  2. 正统派

    • z-index值为数值的定位元素的传统层叠上下文

  3. 扩招派

    • 其他CSS3属性

根层叠上下文

指的是页面根元素,页面中所有的元素一定处于至少一个层叠结界中

定位元素与传统层叠上下文

对于position值为relative/absolute的定位元素,当z-index值不是auto的时候,会创建层叠上下文。

CSS3属性(8个)FOTMFIWS

  1. 元素为flex布局元素(父元素display:flex|inline-flex),同时z-index不是auto - flex布局

  2. 元素的opactity值不是1 - {透明度|opactity}

  3. 元素的transform值不是none - {转换|transform}

  4. 元素mix-blend-mode值不是normal - {混合模式|mix-blend-mode}

  5. 元素的filter值不是none - {滤镜|filter}

  6. 元素的isolation值是isolate - {隔离|isolation}

  7. 元素的will-change属性值为上面②~⑥的任意一个(如will-change:opacity

  8. 元素的-webkit-overflow-scrolling设为touch


重绘和重排

页面渲染的流程, 简单来说,初次渲染时会经过以下6步

  1. 构建DOM树;

  2. 样式计算;

  3. 布局定位

  4. 图层分层;

  5. 图层绘制

  6. 合成显示

CSS属性改变时,重渲染会分为回流重绘直接合成三种情况,分别对应从布局定位/图层绘制/合成显示开始,再走一遍上面的流程。

元素的CSS具体发生什么改变,则决定属于上面哪种情况:

  • 回流(又叫重排):元素位置、大小发生变化导致其他节点联动,需要重新计算布局;

  • 重绘:修改了一些不影响布局的属性,比如颜色;

  • 直接合成:合成层transform、opacity修改,只需要将多个图层再次合并,而后生成位图,最终展示到屏幕上;

触发时机

回流触发时机

回流这一阶段主要是计算节点的位置和几何信息,那么当页面布局和几何信息发生变化的时候,就需要回流。

  • 添加或删除可见的DOM元素

  • 元素的位置发生变化

  • 元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)

  • 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代

  • 页面一开始渲染的时候(这避免不了)

  • 浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

  • 获取一些特定属性的值

    • offsetTop、offsetLeft、 offsetWidth、offsetHeight

    • scrollTop、scrollLeft、scrollWidth、scrollHeight

    • clientTop、clientLeft、clientWidth、clientHeight

    • 这些属性有一个共性,就是需要通过即时计算得到。因此浏览器为了获取这些值,也会进行回流。

重绘触发时机

触发回流一定会触发重绘

除此之外还有一些其他引起重绘行为:

  • 颜色的修改

  • 文本方向的修改

  • 阴影的修改

浏览器优化机制

由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列存储重排操作并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。

当你获取布局信息的操作的时候,会强制队列刷新,例如offsetTop等方法都会返回最新的数据。

因此浏览器不得不清空队列,触发回流重绘来返回正确的值

减少回流

  1. 对于那些复杂的动画,对其设置 position: fixed/absolute,尽可能地使元素脱离文档流,从而减少对其他元素的影响

  2. 使用css3硬件加速,可以让transformopacityfilters这些动画不会引起回流重绘

  3. 在使用 JavaScript 动态插入多个节点时, 可以使用DocumentFragment.创建后一次插入.

  4. 通过设置元素属性display: none,将其从页面上去掉,然后再进行后续操作,这些后续操作也不会触发回流与重绘,这个过程称为离线操作


CSS引入方式(4种)

  1. 内联方式

    • <div style="background: red"></div>

  2. 嵌入方式

    • HTML 头部中的 <style> 标签下书写 CSS 代码

  3. 链接方式

    • 使用 HTML 头部的 <head> 标签引入外部的 CSS 文件。

    • <link rel="stylesheet" type="text/css" href="style.css">

  4. 导入方式

    • 使用 CSS 规则引入外部 CSS 文件

比较链接方式和导入方式

链接方式(用 link )和导入方式(用 @import)都是引入外部的 CSS 文件的方式

  • link 属于 HTML,通过 <link> 标签中的 href 属性来引入外部文件,而 @import 属于 CSS,所以导入语句应写在 CSS 中,要注意的是导入语句应写在样式表的开头,否则无法正确导入外部文件;

  • @importCSS2.1 才出现的概念,所以如果浏览器版本较低,无法正确导入外部样式文件;

HTML 文件被加载时,link 引用的文件会同时被加载,而 @import 引用的文件则会等页面全部下载完毕再被加载;


硬件加速

浏览器中的层分为两种:渲染层合成层

渲染层

渲染层的概念跟层叠上下文密切相关。简单来说,拥有z-index属性的定位元素会生成一个层叠上下文,一个生成层叠上下文的元素就生成了一个渲染层。

层叠上下文的创建(3类)

由一些CSS属性创建

  1. 天生派

    • 页面根元素天生具有层叠上下文

    • 根层叠上下文

  2. 正统派

    • z-index值为数值的定位元素的传统层叠上下文

  3. 扩招派 (CSS3属性)

    1. 元素为flex布局元素(父元素display:flex|inline-flex),同时z-index不是auto - flex布局

    2. 元素的opactity值不是1 - {透明度|opactity}

    3. 元素的transform值不是none - {转换|transform}

    4. 元素mix-blend-mode值不是normal - {混合模式|mix-blend-mode}

    5. 元素的filter值不是none - {滤镜|filter}

    6. 元素的isolation值是isolate - {隔离|isolation}

    7. 元素的will-change属性值为上面②~⑥的任意一个(如will-change:opacity

    8. 元素的-webkit-overflow-scrolling设为touch

合成层

只有一些特殊的渲染层才会被提升为合成层,通常来说有这些情况:

  1. transform:3D变换:translate3dtranslateZ

  2. will-change:opacity | transform | filter

  3. opacity | transform | fliter 应用了过渡和动画(transition/animation

  4. video、canvas、iframe

硬件加速

浏览器为什么要分层呢?答案是硬件加速。就是给HTML元素加上某些CSS属性,比如3D变换,将其提升成一个合成层,独立渲染

之所以叫硬件加速,就是因为合成层会交给GPU(显卡)去处理,在硬件层面上开外挂,比在主线程(CPU)上效率更高。

利用硬件加速,可以把需要重排/重绘的元素单独拎出来,减少绘制的面积。

避免重排/重绘,直接进行合成,合成层的transformopacity的修改都是直接进入合成阶段的;

  • 可以使用transform:translate代替left/top修改元素的位置;

  • 使用transform:scale代替宽度、高度的修改;


元素超出宽度...处理

单行 (AKA: TWO)

  1. text-overflow:ellipsis:当文本溢出时,显示省略符号来代表被修剪的文本

  2. white-space:nowrap:设置文本不换行

  3. overflow:hidden:当子元素内容超过容器宽度高度限制的时候,裁剪的边界是border box的内边缘

p{
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width:400px;
}
复制代码

多行

  1. 基于高度截断(伪元素 + 定位)

  2. 基于行数截断()

基于高度截断

关键点 height + line-height + ::after + 子绝父相

核心的css代码结构如下:

.text {
position: relative;
line-height: 20px;
height: 40px;
overflow: hidden;
}
.text::after {
content: "...";
position: absolute;
bottom: 0;
right: 0;
padding: 0 20px 0 10px;
}
复制代码

基于行数截断

关键点:box + line-clamp + box-orient + overflow

  1. display: -webkit-box:将对象作为弹性伸缩盒子模型显示

  2. -webkit-line-clamp: n:和①结合使用,用来限制在一个块元素显示的文本的行数(n)

  3. -webkit-box-orient: vertical:和①结合使用 ,设置或检索伸缩盒对象的子元素的排列方式

  4. overflow: hidden

p {
width: 300px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
复制代码

元素隐藏

可按照隐藏元素是否占据空间分为两大类(6 + 3)

  1. 元素不可见,不占空间

    (

    3absolute

    +

    1relative

    +

    1script

    +

    1display

    )

    1. <script>

    2. display:none

    3. absolute + visibility:hidden

    4. absolute + clip:rect(0,0,0,0)

    5. absolute + opacity:0

    6. relative+left负值

  2. 元素不可见,占据空间

    (3个)

    1. visibility:hidden

    2. relative + z-index负值

    3. opacity:0

元素不可见,不占空间

<script>

<script type="text/html">
<img src="1.jpg">
</script>
复制代码

display:none

其他特点:辅助设备无法访问,资源加载,DOM可访问

对一个元素而言,如果display计算值是none,则该元素以及所有后代元素都隐藏

.hidden {
display:none;
}
复制代码

absolute + visibility

.hidden{
position:absolute;
visibility:hidden;
}
复制代码

absolute + clip

.hidden{
position:absolute;
clip:rect(0,0,0,0);
}
复制代码

absolute + opacity

.hidden{
position:absolute;
opacity:0;
}
复制代码

relative+负值

.hidden{
position:relative;
left:-999em;
}
复制代码

元素不可见,占据空间

visibility:hidden

visibility 的继承性

  • 父元素设置visibility:hidden,子元素也看不见

  • 但是,如果子元素设置了visibility:visible,则子元素又会显示出来

.hidden{
visibility:hidden;
}
复制代码

relative + z-index

.hidden{
position:relative;
z-index:-1;
}
复制代码

opacity:0

.hidden{
opacity:0;
filter:Alpha(opacity=0)
}
复制代码

总结

最常用的还是display:nonevisibility:hidden,其他的方式只能认为是奇招,它们的真正用途并不是用于隐藏元素,所以并不推荐使用它们。

关于display: nonevisibility: hiddenopacity: 0的区别,如下表所示:

img


Chrome支持小于12px 的文字

Chrome 中文版浏览器会默认设定页面的最小字号是12px,英文版没有限制

原由 Chrome 团队认为汉字小于12px就会增加识别难度

  • 中文版浏览器 与网页语言无关,取决于用户在Chrome的设置里(chrome://settings/languages)把哪种语言设置为默认显示语言

  • 系统级最小字号 浏览器默认设定页面的最小字号,用户可以前往 chrome://settings/fonts 根据需求更改

解决方案(3种)

  1. zoom

  2. transform:scale()

  3. -webkit-text-size-adjust:none

zoom

zoom 可以改变页面上元素的尺寸,属于真实尺寸。

其支持的值类型有:

  • zoom:50%,表示缩小到原来的一半

  • zoom:0.5,表示缩小到原来的一半

.span10{
font-size: 12px;
display: inline-block;
zoom: 0.8;
}
复制代码

transform:scale()

transform:scale()这个属性进行放缩

使用scale属性只对可以定义宽高的元素生效,所以,需要将指定元素转为行内块元素

.span10{
       font-size: 12px;
       display: inline-block;
       transform:scale(0.8);
  }
复制代码

text-size-adjust

该属性用来设定文字大小是否根据设备(浏览器)来自动调整显示大小

属性值:

  • auto默认,字体大小会根据设备/浏览器来自动调整;

  • percentage:字体显示的大小

  • none:字体大小不会自动调整

存在兼容性问题,chrome受版本限制,safari可以


后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

img

作者:前端小魔女
来源:https://juejin.cn/post/7203153899246780453

收起阅读 »

数据可视化大屏设计器开发-多选拖拽

web
开头本文是数据可视化开始的开发细节第五章。关于画布中的元素的各种鼠标拖拽操作。简单声明 本人只是一个菜鸡,以下方法仅个人思路,如有错误,轻喷🙏🏻 。开头说明 下面所说的元素表示的是组或者组件的简称。开始大屏设计当中,不乏需要调整图表组件的位置和尺寸。 相...
继续阅读 »

开头

本文是数据可视化开始的开发细节第五章。关于画布中的元素的各种鼠标拖拽操作。

简单声明
本人只是一个菜鸡,以下方法仅个人思路,如有错误,轻喷🙏🏻 。

开头说明
下面所说的元素表示的是组或者组件的简称。

开始

大屏设计当中,不乏需要调整图表组件的位置尺寸
相较于网页低代码,图表大屏低代码可能需要更复杂的操作,比如嵌套成组多选单元素拖拽缩放多元素拖拽缩放
并且需要针对鼠标的动作做相应的区分,当中包含了相当的细节,这里就一一做相应的讲解。

涉及的依赖

  • react-rnd
    react-rnd是一个包含了拖拽和缩放两个功能的react组件,并且有非常丰富的配置项。
    内部是依赖了拖拽(react-draggable)和缩放(re-resizable)两个模块。
    奈何它并没有内置多元素的响应操作,本文就是针对它来实现对应的操作。

  • react-selecto
    react-selecto是一个简单的简单易用的多选元素组件。

  • eventemitter3
    eventemitter3是一个自定义事件模块,能够在任何地方触发和响应自定义的事件,非常的方便。

相关操作

多选

画布当中可以通过鼠标点击拖拽形成选区,选区内的元素即是被选中的状态。

这里即可以使用react-selecto来实现此功能。


从图上操作可以看到,在选区内的元素即被选中(会出现黑色边框)。

  import ReactSelecto from 'react-selecto';

 const Selecto = () => {

   return (
     <ReactSelecto
       // 会被选中元素的父容器 只有这个容器里的元素才会被选中  
       dragContainer={'#container'}
       // 被选择的元素的query
       selectableTargets={['.react-select-to']}
       // 表示元素有被选中的百分比为多少时才能被选中
       hitRate={10}
       // 当已经存在选中项时,按住指定按键可进行继续选择  
       toggleContinueSelect={'shift'}
       // 可以通过点击选择元素
       selectByClick
       // 是否从内部开始选择(?)
       selectFromInside
       // 拖拽的速率(不知道是不是这个意思)
       ratio={0}
       // 选择结束
       onSelectEnd={handleSelectEnd}
     ></ReactSelecto>
  );
};
复制代码

这里有几个需要注意的地方。

  • 操作互斥
    画布当中的多选和拖拽都是通过鼠标左键来完成的,所以当一个元素是被选中的时候,鼠标想从元素上开始拖拽选择组件是不被允许的,此时应该是拖拽元素,而不是多选元素。

而元素如果没有被选中时,上面的操作则变成了多选。


  • 内部选中
    画布当中有的概念,它是一个组与组件无限嵌套的结构,并且可以单独选中组内的元素。
    当选中的是组内的元素时,即说明最外层的组是被选中的状态,同样需要考虑上面所说的互斥问题。

单元素拖拽缩放

单元素操作相对简单,只需要简单使用react-rnd提供的功能即可完成。


多元素拖拽缩放

这里就是本文的重点了,结合前面介绍的几个依赖,实现一个简单的多选拖拽缩放的功能。

具体思路

多个元素拖拽,说到底其实鼠标拖拽的还是一个元素,就是鼠标拖动的那一个元素。
其余被选中的元素,仅仅需要根据被拖动的元素的尺寸位置变动来做相应的加减处理即可。

相关问题
  • 信息计算
    联动元素的位置尺寸信息该如何计算。

  • 组件间通信
    因为每一个图表组件并非是单纯的同级关系,如果是通过层层props传递,免不了会有多余的刷新,造成性能问题。
    而通过全局的dva状态同样在更新的时候会让组件刷新。

  • 数据刷新
    图表数据是来自于dva全局的数据,现在频繁自刷新相当于是一直更新全局的数据,同样会造成性能问题。

  • 其他
    一些细节问题

解决方法

  • 信息计算
    关于位置的计算相对简单,只需要单纯的将操作的元素的位置和尺寸差值传递给联动组件即可。

  • 组件间通信
    根据上面问题的解析,可以使用eventemitter3来完成任意位置、层级的数据通信,并且它和react渲染无任何关系。

import { useCallback, useEffect } from 'react'
import EventEmitter from 'eventemitter3'

const eventemitter = new EventEmitter()

const SonA = () => {

 console.log('刷新')

 useEffect(() => {
   const listener = (value) => {
     console.log(value)
  }
   eventemitter.addListener('change', listener)
   return () => {
     eventemitter.removeListener('change', listener)
  }
}, [])

 return (
   <span>son A</span>
)

}

const SonB = () => {

 const handleClick = useCallback(() => {
   eventemitter.emit('change', 'son B')
}, [])

 return (
   <span>
     <button onClick={handleClick}>son B</button>
   </span>
)

}

const Parent = () => {

 return (
   <div>
     <SonA />
     <br />
     <SonB />
   </div>
)

}

运行上面的例子可以发现,点击SonB组件的按钮,可以让SonA接收到来自其的数据,并且并没有触发SonA的刷新。
需要接收数据的组件只需要监听(addListener)指定的事件即可,比如上面的change事件。
而需要发送数据的组件则直接发布(emit)事件即可。
这样就避免了一些不必要的刷新。

  • 数据刷新
    频繁刷新全局数据,会导致所有依赖其数据的组件都会刷新,所以考虑为需要刷新数据的组件在内部单独维护一份状态。
    开始操作时,记录下状态,标识开始使用内部状态表示图表的信息,结束操作时处理下内部数据状态,将数据更新到全局中去。

  import { useMemo, useEffect, useState, useRef } from 'react'
 import EventEmitter from 'eventemitter3'

 const eventemitter = new EventEmitter()

 const Component = (props: {
   position: {left: number, top: number}
}) => {

   const [ position, setPosition ] = useState({
     left: 0,
     top: 0
  })

   const isDrag = useRef(false)

   const dragStart = () => {
     isDrag.current = true
     setPosition(props.position)
  }

   const drag = (position) => {
     setPosition(position)
  }

   const dragEnd = () => {
     isDrag.current = false
     // TODO
     // 更新数据到全局
  }

   useEffect(() => {
     eventemitter.addListener('dragStart', dragStart)
     eventemitter.addListener('drag', drag)
     eventemitter.addListener('dragEnd', dragEnd)
     return () => {
       eventemitter.removeListener('dragStart', dragStart)
       eventemitter.removeListener('drag', drag)
       eventemitter.removeListener('dragEnd', dragEnd)
    }
  }, [])

   return (
     <span
       style={{
         left: (isDrag.current ? position : props.position).left,
         top: (isDrag.current ? position : props.position).top
      }}
     >图表组件</span>
  )

}

上面的数据更新还可以更加优化,对于短时间多次更新操作,可以控制一下更新频率,将多次更新合并为一次。

  • 其他

    • 控制刷新
      这里的控制刷新指的是上述的内部刷新,不需要每次都响应react-rnd发出的相关事件,可以做对应的节流(throttle)操作,减少事件触发频率。

    • 通信冲突问题
      因为所有的组件都需要监听拖拽的事件,包括当前被拖拽的组件,所以在传递信息时,需要把自身的id类似值传递,防止冲突。

    • 组件的缩放属性
      这里是关于前文说到的成组的逻辑相关,因为组存在scaleXscaleY两个属性,所以在调整大小的时候,也要兼顾此属性(本文先暂时不考虑这个问题)。

    • 单元素选中情况
      自定义事件的监听是无差别的,当只选中了一个元素进行拖拽缩放操作时,无须触发相应的事件。

最后的DEMO


成品

其实在之前就已经发现其实react-selecto的作者也有研发其他的可视化操作模块,包括本文所说的多选拖拽的操作,但是奈何无法满足本项目的需求,故自己实现了功能。
如果有兴趣可以去看一下这个成品moveable

总结

通过上面的思路,即可完成一个简单的多元素拖拽缩放的功能,其核心其实就是eventemitter3的自定义事件功能,它的用途在平常的业务中非常广泛。
比如我们完全可以在以上例子的基础上,加上元素拖拽时吸附的功能。

结束

结束🔚。

顺便在下面附上相关的链接。

试用地址
试用账号
静态版试用地址
操作文档
代码地址

作者:写代码请写注释
来源:juejin.cn/post/7202445722972815417

收起阅读 »

手把手教你实现一个自定义 eslint 规则

web
ESlint 概述ESLint 是一个代码检查工具,通过静态的分析,寻找有问题的模式或者代码。默认使用 Espree 解析器将代码解析为 AST 抽象语法树,然后再对代码进行检查。抽象语法树(Abstract Syntax Tree,AST),或简称语法树(S...
继续阅读 »

ESlint 概述

ESLint 是一个代码检查工具,通过静态的分析,寻找有问题的模式或者代码。默认使用 Espree 解析器将代码解析为 AST 抽象语法树,然后再对代码进行检查。

抽象语法树Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

随着前端工程化体系的不断发展,Eslint 已经前端工程化不可缺失的开发工具。它解决了前端工程化中团队代码风格不统一的问题,避免了一些由于代码规范而产生的 Bug, 同时它提高了了团队的整体效率。

运行机制

Eslint的内部运行机制不算特别复杂,主要分为以下几个部分:

  • preprocess,把非 js 文本处理成 js

  • 确定 parser(默认是 espree

  • 调用 parser,把源码 parseSourceCodeAST

  • 调用 rules,对 SourceCode 进行检查,返回 linting problems

  • 扫描出注释中的 directives,对 problems 进行过滤

  • postprocess,对 problems 做一次处理

  • 基于字符串替换实现自动 fix

具体描述,这里就不补充了。详细的运行机制推荐大家去学习一下Eslint的底层实现原理和源码。

常用规则

为了让使用者对规则有个更好的理解, Eslint 官方将常用的规则进行了分类并且定义了一个推荐的规则组 "extends": "eslint:recommended"。具体规则详情请见官网

示例规则如下:

  • array-element-newline<string|object>
    "always"(默认) - 需要数组元素之间的换行符
    "never" - 不允许数组元素之间换行
    "consistent" - 数组元素之间保持一致的换行符

配置详解

Eslint 配置我们主要通过.eslintrc配置来描述

extends

extends 的内容为

一个 ESLint配置文件,一旦扩展了(即从外部引入了其他配置包),就能继承另一个配置文件的所有属性(包括rules, plugins, and language option在内),然后通过 merge合并/覆盖所有原本的配置。最终得到的配置是前后继承和覆盖前后配置的并集。

extends属性的值可以是:

  • 定义一个配置的字符串(配置文件的路径、可共享配置的名称,如eslint:recommendedeslint:all)

  • 定义规则组的字符串。plugin:插件名/规则名称 (插件名取eslint-plugin-之后的名称)

 "extends": [
   "eslint:recommended",
   "plugin:react/recommended"
],

parserOptions

指定你想要支持的 JavaScript 语言选项。默认支持 ECMAScript 5 语法。你可以覆盖该设置,以启用对 ECMAScript 其它版本和 JSX 的支持。

"parserOptions": {
 "ecmaVersion": 6,
 "sourceType": "module",
 "ecmaFeatures": {
    "jsx": true
}
}

rules

ESLint 拥有大量的规则。你可以通过配置插件添加更多规则。使用注释或配置文件修改你项目中要使用的规则。要改变一个规则,你必须将规则 ID 设置为下列值之一:

  • "off"0 - 关闭规则

  • "warn"1 - 开启规则,使用警告级别的错误:warn (不会导致程序退出)

  • "error"2 - 开启规则,使用错误级别的错误:error (当被触发的时候,程序会退出)

"plugins": [
 "plugin-demo",
],
"rules": {
 "quotes": ["error", "double"], // 修改eslint recommended中quotes规则
 "plugin-demo/rule1": "error", // 配置eslint-plugin-plugin-demo 下rule1规则
}

对于 Eslint recommended 规则组中你不想使用的规则,也可以在这里进行关闭。

plugin

ESLint 支持使用第三方插件。要使用插件,必须先用 npm进行安装

"plugins": [
  "plugin-demo", // // 配置 eslint-plugin-plugin-demo 插件
],

这里做一下补充,extendsplugin 的区别在于,extendsplugin 的子集。就好比如 Eslint 中除了 recommended 规则组还有其他规则

自定义Eslint插件

团队开发中,我们经常会使用一些 eslint 规则插件来约束代码开发,但偶尔也会有一些个性定制化的团队规范,而这些规范就需要通过一些自定义的 ESlint 插件来实现。

我们先看一段简短的代码:

import { omit } from 'lodash';

上述代码是我们在使用lodash的一个习惯性写法,但是这段代码会导致全量引入lodash,造成工程包体积偏大。

正确的引用方式如下:

import omit from 'lodash/omit';

// 或
import { omit } from 'lodash-es';

我们希望可以通过插件去约束开发者的使用习惯。但是 Eslint 自带的规则对于这个定制化的场景就无法满足了。此时, 就需要去使用 Eslint 提供的开放能力去定制化一个 Eslint 规则。接下来我将从创建到使用去实现一个lodash引用规范的Eslint自定义插件

创建

工程搭建

Eslint 官方提供了脚手架来简化新规则的开发, 如不使用脚手架搭建,只需保证和脚手架一样的结构就可以啦。

创建工程前,先全局安装两个依赖包:

$ npm i -g yo
$ npm i -g generator-eslint

再执行如下命令生成 Eslint 插件工程。

$ yo eslint:plugin

这是一个交互式命令,需要你填写一些基本信息,如下

$ yo eslint:rule
? What is your name? // guming-eslint-plugin-custom-lodash
? What is the plugin ID? // 插件名 (eslint-plugin-xxx)
? Type a short description of this plugin: // 描述你的插件是干啥的
? Does this plugin contain custom ESLint rules? Yes // 是否为自定义Eslint 校验规则
? Does this plugin contain one or more processors? No // 是否需要处理器


接下来我们为插件创建一条规则,执行如下命令:

$ npx yo eslint:rule

这也是一个交互式命令,如下:

? What is your name? // guming-eslint-plugin-custom-lodash
? Where will this rule be published? ESLint Plugin
? What is the rule ID? // 规则名称 lodash-auto-import
? Type a short description of this rule: // 规则的描述
? Type a short example of the code that will fail: // 这里写你这条规则校验不通过的案例代码


填写完上述信息后, 我们可以得到如下的一个项目目录结构:

guming-eslint
├─ .eslintrc.js
├─ .git
├─ README.md
├─ docs
│ └─ rules
│ └─ lodash-auto-import.md
├─ lib // 规则
│ ├─ index.js
│ └─ rules
│ └─ lodash-auto-import.js
├─ node_modules
├─ package-lock.json
├─ package.json
└─ tests // 单测
└─ lib
└─ rules
└─ lodash-auto-import.js

eslint 规则配置

Eslint 官方制定了一套开发自定义规则的规范。我们只需要根据规范配置相应的内容就可以轻松的实现我们的自定义Eslint规则。具体配置详情可见官网

相关配置的说明如下:

module.exports = {
meta: {
// 规则的类型 problem|suggestion|layout
// problem: 这条规则识别的代码可能会导致错误或让人迷惑。应该优先解决这个问题
// suggestion: 这条规则识别的代码不会导致错误,但是建议用更好的方式
// layout: 表示这条规则主要关心像空格、分号等这种问题
type: "suggestion",
// 对于自定义规则,docs字段是非必须的
docs: {
description: "描述你的规则是干啥的",
// 规则的分类,假如你把这条规则提交到eslint核心规则里,那eslint官网规则的首页会按照这个字段进行分类展示
category: "Possible Errors",
// 假如你把规则提交到eslint核心规则里
// 且像这样extends: ['eslint:recommended']继承规则的时候,这个属性是true,就会启用这条规则
recommended: true,
// 你的规则使用文档的url
url: "https://eslint.org/docs/rules/no-extra-semi",
},
// 定义提示信息文本 error-name为提示文本的名称 定义后我们可以在规则内部使用这个名称
messages: {
"error-name": "这是一个错误的命名"
},
// 标识这条规则是否可以修复,假如没有这属性,即使你在下面那个create方法里实现了fix功能,eslint也不会帮你修复
fixable: "code",
// 这里定义了这条规则需要的参数
// 比如我们是这样使用带参数的rule的时候,rules: { myRule: ['error', param1, param2....]}
// error后面的就是参数,而参数就是在这里定义的
schema: [],
},
create: function (context) {
// 这是最重要的方法,我们对代码的校验就是在这里做的
return {
// callback functions
};
},
};

本次Eslint 校验规则是推荐使用更好的lodash引用方式,所以常见规则类型 typesuggestion

AST 结构

Eslint 的本质是通过代码生成的 AST 树做代码的静态分析,我们可以使用 astexplorer 快速方便地查看解析成 AST 的结构。

我们将如下代码输入

import { omit } from 'lodash'

得到的 AST 结构如下:

{
"type": "Program",
"start": 0,
"end": 29,
"body": [
{
"type": "ImportDeclaration",
"start": 0,
"end": 29,
"specifiers": [
{
"type": "ImportSpecifier",
"start": 9,
"end": 13,
"imported": {
"type": "Identifier",
"start": 9,
"end": 13,
"name": "omit"
},
"local": {
"type": "Identifier",
"start": 9,
"end": 13,
"name": "omit"
}
}
],
"source": {
"type": "Literal",
"start": 21,
"end": 29,
"value": "lodash",
"raw": "'lodash'"
}
}
],
"sourceType": "module"
}

分析 AST 的结构,我们可以知道:

  • type 为 包的引入方式

  • source 为 资源名(依赖包名)

  • specifiers 为导出的模块

节点访问方法

Eslint 规则中的 create 函数create (function) 返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree 定义的 AST) 时,用来访问节点的方法。其中, 访问节点的方法如下:

  • VariableDeclaration,则返回声明中声明的所有变量。

  • 如果节点是一个 VariableDeclarator,则返回 declarator 中声明的所有变量。

  • 如果节点是 FunctionDeclarationFunctionExpression,除了函数参数的变量外,还返回函数名的变量。

  • 如果节点是一个 ArrowFunctionExpression,则返回参数的变量。

  • 如果节点是 ClassDeclarationClassExpression,则返回类名的变量。

  • 如果节点是一个 CatchClause 子句,则返回异常的变量。

  • 如果节点是 ImportDeclaration,则返回其所有说明符的变量。

  • 如果节点是 ImportSpecifierImportDefaultSpecifierImportNamespaceSpecifier,则返回声明的变量。

本次我们是校验资源导入规范,所以我们使用ImportDeclaration获取我们导入资源的节点结构

代码修复

report()函数返回一个特定结构的对象,它用来发布警告或错误, 我们可以通过配置对象去配置错误AST 节点,错误提示的内容(可使用 meta 配置的 meaasge 名称)以及修复方式

实例配置代码如下

context.report({
node: node,
message: "Missing semicolon",
fix: function(fixer) {
return fixer.insertTextAfter(node, ";");
}
});

编写代码

了解完上述内容,我们就可以开始愉快的编写代码了。

自定义规则代码如下:

 // lib/rules/lodash-auto-import.js

/**
* @fileoverview 这是一个lodash按需引入的eslint规则
* @author guming-eslint
*/
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */

const SOURCElIST = ["lodash", "lodash-es"];
module.exports = {
// eslint-disable-next-line eslint-plugin/prefer-message-ids
meta: {
type: "suggestion", // `problem`, `suggestion`, or `layout`
docs: {
description: "这是一个lodash按需引入的eslint规则",
recommended: true,
url: null, // URL to the documentation page for this rule
},
messages: {
autoImportLodash: "请使用lodash按需引用",
invalidImport: "lodash 导出依赖不为空",
},
fixable: "code",
schema: [],
},

create: function (context) {

// 获取lodash中导入的函数名称,并返回
function getImportSpecifierArray(specifiers) {
const incluedType = ["ImportSpecifier", "ImportDefaultSpecifier"];
return specifiers
.filter((item) => incluedType.includes(item.type))
.map((item) => {
return item.imported ? item.imported.name : item.local.name;
});
}

// 生成修复文本
function generateFixedImportText(importedList, dependencyName) {
let fixedText = "";
importedList.forEach((importName, index) => {
fixedText += `import ${importName} from "${dependencyName}/${importName}";`;
if (index != importedList.length - 1) fixedText += "\n";
});
return fixedText;
}

return {
ImportDeclaration(node) {
const source = node.source.value;
const hasUseLodash = SOURCElIST.inclues(source);

// 使用lodash
if (hasUseLodash) {
const importedList = getImportSpecifierArray(node.specifiers || []);

if (importedList.length <= 0) {
return context.report({
node,
messageId: "invalidImport",
});
}

const dependencyName = getImportDependencyName(node);
return context.report({
node,
messageId: "autoImportLodash",
fix(fixer) {
return fixer.replaceTextRange(
node.range,
generateFixedImportText(importedList, dependencyName)
);
},
});
}
},
};
},
};

配置规则组

// lib/rules/index.js

const requireIndex = require("requireindex");
// 在这里导入了我们上面写的自定义规则
const rules = requireIndex(__dirname + "/rules");
module.exports = {
// rules是必须的
rules,
// 增加configs配置
configs: {
// 配置了这个之后,就可以在其他项目中像下面这样使用了
// extends: ['plugin:guming-eslint/recommended']
recommended: {
plugins: ['guming-eslint'],
rules: {
'guming-eslint/lodash-auto-import': ['error'],
}
}
}
}

补充测试用例

// tests/lib/rules/lodash-auto-import.js
/**
* @fileoverview 这是一个lodash按需引入的eslint规则
* @author guming-eslint
*/
"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require("../../../lib/rules/lodash-auto-import"),
RuleTester = require("eslint").RuleTester;

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester();
ruleTester.run("lodash-auto-import", rule, {
valid: ['import omit from "lodash/omit";', 'import { omit } from "lodash-es";'],

invalid: [
// eslint-disable-next-line eslint-plugin/consistent-output
{
code: 'import {} from "lodash";',
errors: [{ message: "invalidImport" }],
output: 'import xxx from lodash/xxx'
},
{
code: 'import {} from "lodash-es";',
errors: [{ message: "invalidImport" }],
output: 'import { xxx } from lodash-es'
},
{
code: 'import { omit } from "lodash";',
errors: [{ message: "directlyImportLodash" }],
output: 'import omit from "lodash/omit";',
},
{
code: 'import { omit as _omit } from "lodash";',
errors: [{ message: "directlyImportLodash" }],
output: 'import omit from "lodash/omit";',
},
{
code: 'import { omit, debounce } from "lodash";',
errors: [{ message: "directlyImportLodash" }],
output:
'import omit from "lodash/omit"; \n import debounce from "lodash/debounce";',
},
],
});

可输入如下指令,执行测试

$ yarn run test

注意事项

开发这个插件的一些注意事项如下

  • 多个模块导出

  • lodash 具名导出和默认导出

  • 模块别名(as)

使用

插件安装

  • npm 包发布安装调试

$ yarn add eslint-plugin-guming-eslint
  • npm link 本地调试(推荐使用) 插件项目目录执行如下指令

$ npm link

项目目录执行如下指令

$ npm link eslint-plugin-guming-eslint

项目配置

添加你的 plugin 包名(eslint-plugin- 前缀可忽略) 到 .eslintrc 配置文件的 extends 字段。

.eslintrc 配置文件示例:

module.exports = {
// 你的插件
extends: ["plugin:guming-eslint/recommended"],
parserOptions: {
ecmaVersion: 7,
sourceType: "module",
},
};

效果

作者:古茗前端团队
来源:juejin.cn/post/7202413628807938108

收起阅读 »

用一周时间开发了一个微信小程序,我遇到了哪些问题?

功能截图特别说明:由于本项目是用于教学案例,并没有上线的二维码供大家体验。开发版本微信开发者工具版本:1.06调试基础库:2.30代码仓库gitee:gitee.com/guigu-fe/gu…github:github.com/xiumubai/gu…建议全...
继续阅读 »

功能截图

home.pic.jpginfo.pic.jpg
address-add.pic.jpgaddress-list.pic.jpg
cart-list.pic.jpgcategory.pic.jpg
goods-detail.pic.jpgorder-list.pic.jpg
goods-list.pic.jpgorder-detail.pic.jpg
特别说明:由于本项目是用于教学案例,并没有上线的二维码供大家体验。

开发版本

  • 微信开发者工具版本:1.06

  • 调试基础库:2.30

代码仓库

建议全文参考源代码观看效果更佳,代码可直接在微信开发者工具当中打开预览,appid需要替换成自己的。

获取用户信息变化

用户头像昵称获取规则已调整,现在微信小程序已经获取不到用户昵称和头像了,只能已通过用户回填(提供给用户一个修改昵称和头像的表单页面)的方式来实现。不过还是可以获取到code跟后端换取token的方式来进行登录。

具体参考 *用户信息接口调整说明*小程序用户头像昵称获取规则调整公告

vant weapp组件库的使用

1.需要使用npm构建的能力,用 npm 构建前,请先阅读微信官方的 npm 支持。初始化package.json

npm init

2.安装@vant/weapp

# 通过 npm 安装
npm i @vant/weapp -S --production

# 通过 yarn 安装
yarn add @vant/weapp --production

# 安装 0.x 版本
npm i vant-weapp -S --production

2.修改 app.json 将 app.json 中的 "style": "v2" 去除,小程序的新版基础组件强行加上了许多样式,难以覆盖,不关闭将造成部分组件样式混乱。 3.修改 project.config.json 开发者工具创建的项目,miniprogramRoot 默认为 miniprogrampackage.json 在其外部,npm 构建无法正常工作。 需要手动在 project.config.json 内添加如下配置,使开发者工具可以正确索引到 npm 依赖的位置。

{
...
 "setting": {
  ...
   "packNpmManually": true,
   "packNpmRelationList": [
    {
       "packageJsonPath": "./package.json",
       "miniprogramNpmDistDir": "./miniprogram/"
    }
  ]
}
}

注意: 由于目前新版开发者工具创建的小程序目录文件结构问题,npm构建的文件目录为miniprogram_npm,并且开发工具会默认在当前目录下创建miniprogram_npm的文件名,所以新版本的miniprogramNpmDistDir配置为'./'即可。 4.构建 npm 包 打开微信开发者工具,点击 工具 -> 构建 npm,并勾选 使用 npm 模块 选项,构建完成后,即可引入组件。 image.png

使用组件

引入组件

// 通过 npm 安装
// app.json
"usingComponents": {
 "van-button": "@vant/weapp/button/index"
}

使用组件

<van-button type="primary">按钮</van-button>

如果预览没有效果,从新构建一次npm,然后重新打开此项目

自定义tabbar

这里我的购物车使用了徽标,所以需要自定义一个tabbar,这里自定义以后,会引发后面的一系列连锁反应(比如内容区域高度塌陷,导致tabbar遮挡内容区域),后面会讲如何计算。效果如下图: image.png

1. 配置信息

  • 在 app.json 中的 tabBar 项指定 custom 字段,同时其余 tabBar 相关配置也补充完整。

  • 所有 tab 页的 json 里需声明 usingComponents 项,也可以在 app.json 全局开启。

示例:

{
 "tabBar": {
   "custom": true,
   "color": "#000000",
   "selectedColor": "#000000",
   "backgroundColor": "#000000",
   "list": [{
     "pagePath": "page/component/index",
     "text": "组件"
  }, {
     "pagePath": "page/API/index",
     "text": "接口"
  }]
},
 "usingComponents": {}
}

2. 添加 tabBar 代码文件

需要跟pages目录同级,创建一个custom-tab-bar目录。 image.png .wxml代码如下:

<!--miniprogram/custom-tab-bar/index.wxml-->
<cover-view class="tab-bar">
 <cover-view class="tab-bar-border"></cover-view>
 <cover-view wx:for="{{list}}"
   wx:key="index"
   class="tab-bar-item"
   data-path="{{item.pagePath}}"
   data-index="{{index}}"
   bindtap="switchTab">
   <cover-view class="tab-img-wrap">
     <cover-image src="{{selected === index ? item.selectedIconPath : item.iconPath}}"></cover-image>
     <cover-view wx-if="{{item.info && cartCount > 0}}"class="tab-badge">{{cartCount}}</cover-view>
   </cover-view>
   <cover-view style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</cover-view>
 </cover-view>
</cover-view>

注意这里的徽标控制我是通过info字段来控制的,然后数量cartCount单独第一个了一个字段,这个字段是通过store来管理的,后面会讲为什么通过stroe来控制的。

3. 编写 tabBar 代码

用自定义组件的方式编写即可,该自定义组件完全接管 tabBar 的渲染。另外,自定义组件新增 getTabBar 接口,可获取当前页面下的自定义 tabBar 组件实例。

import { storeBindingsBehavior } from 'mobx-miniprogram-bindings';
import { store } from '../store/index';

Component({
 behaviors: [storeBindingsBehavior],
 storeBindings: {
   store,
   fields: {
     count: 'count',
  },
   actions: [],
},
 observers: {
   count: function (val) {
     // 更新购物车的数量
     this.setData({ cartCount: val });
  },
},
 data: {
   selected: 0,
   color: '#252933',
   selectedColor: '#FF734C',
   cartCount: 0,
   list: [
    {
       pagePath: '/pages/index/index',
       text: '首页',
       iconPath: '/static/tabbar/home-icon1.png',
       selectedIconPath: '/static/tabbar/home-icon1-1.png',
    },
    {
       pagePath: '/pages/category/category',
       text: '分类',
       iconPath: '/static/tabbar/home-icon2.png',
       selectedIconPath: '/static/tabbar/home-icon2-2.png',
    },
    {
       pagePath: '/pages/cart/cart',
       text: '购物车',
       iconPath: '/static/tabbar/home-icon3.png',
       selectedIconPath: '/static/tabbar/home-icon3-3.png',
       info: true,
    },
    {
       pagePath: '/pages/info/info',
       text: '我的',
       iconPath: '/static/tabbar/home-icon4.png',
       selectedIconPath: '/static/tabbar/home-icon4-4.png',
    },
  ],
},

 lifetimes: {},
 methods: {
 // 改变tab的时候,记录index值
   switchTab(e) {
     const { path, index } = e.currentTarget.dataset;
     wx.switchTab({ url: path });
     this.setData({
       selected: index,
    });
  },
},
});

这里的store大家不用理会,只需要记住是设置徽标的值就可以了。

4.设置样式

.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: white;
display: flex;
padding-bottom: env(safe-area-inset-bottom);
}

这里的样式单独贴出来说明一下:

padding-bottom: env(safe-area-inset-bottom);

可以让出底部安全区域,不然的话tabbar会直接沉到底部 image.png 别忘了在index.json中设置component=true

{
"component": true
}

5.tabbar页面设置index

上面的代码添加完毕以后,我们的tabbar就出来了,但是有个问题,就是在点击tab的时候,样式不会改变,必须再点击一次,这是因为当你切换页面或者刷新页面的时候,index的值会重置,为了解决这个问题,我们需要在每个tabbar的页面添加下面的代码:

/**
* 生命周期函数--监听页面显示
*/
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
selected: 0,
});
}
},

当页面每次show的时候,设置一下selected的值,也就是选中的index就可以了。其他的tabbar页面同样也是如此设置即可。

添加store状态管理

接下来我们来讲讲微信小程序如何用store来管理我们的数据。 上面我们说了我们需要实现一个tabbar的徽标,起初我想的是直接用个缓存来解决就完事了,后来发现我太天真了,忘记了这个字段是一个响应式的,它是需要渲染到页面上的,它变了,页面中的数据也得跟着一起变。后来我想通过globalData来实现,也不行。后来我又又想到了把这个数据响应式监听一下不就行了?于是通过proxy,跟vue3的处理方式一样,监听一下这个字段的改变就可以了。在购物车这个页面触发的时候是挺好,可当我切换到其他tabbar页面的时候它就不见了。我忽略了一个问题,它是全局响应的啊。于是最后才想到了使用store的方式来实现。 我找到了一个针对微信小程序的解决方法,就是使用mobx-miniprogram-bindingsmobx-miniprogram这两个库来解决。真是帮了我的大忙了。 下面我们直接来使用。 先安装两个插件:

npm install --save mobx-miniprogram mobx-miniprogram-bindings

方式跟安装vant weapp一样,npm安装完成以后,在微信开发者工具当中构建npm即可。 下面我们来通过如何实现一个tabbar徽标的场景来学习如何在微信小程序中使用store来管理全局数据。

tabbar徽标实现

1.定义store

import { observable, action, runInAction } from 'mobx-miniprogram';
import { getCartList } from './cart';
// 获取购物车数量

export const store = observable({
/** 数据字段 */
count: 0,

/** 异步方法 */
getCartListCount: async function () {
const num = await getCartList();
runInAction(() => {
this.count = num;
});
},

/** 更新购物车的数量 */
updateCount: action(function (num) {
this.count = num;
}),
});

看起来是不是非常简单。这里我们定义了一个count,然后定义了两个方法,这两个方法有点区别:

  • updateCount用来更新count

  • getCartListCount用来异步更新count,因为这里我们在进入小程序的时候就需要获取count的初始值,这个值的计算又的依赖接口,所以需要使用异步的方式。

好了,现在我们字段有了,设置初始值的方法有了,更新字段的方法也有了。下面我们来看一下如何使用。

2.使用store

回到我们的tabbr组件,在custom-tab-bari/ndex.js中,我们贴一下主要的代码:

import { storeBindingsBehavior } from 'mobx-miniprogram-bindings';
import { store } from '../store/index';

Component({
behaviors: [storeBindingsBehavior],
storeBindings: {
store,
fields: {
count: 'count',
},
actions: [],
},
observers: {
count: function (val) {
// 更新购物车的数量
this.setData({ cartCount: val });
},
},
data: {
cartCount: 0,
},
});

解释一下,这里我们只是获取了count的值,然后通过observers的方式监听了一下count,然后赋值给了cartCount,这里你直接使用count渲染到页面上也是没有问题的。我这里只是为了演示一下observers的使用方式才这么写的。这样设置以后,tabbar上面的徽标数字已经可以正常展示了。 现在当我们的购物车数字改变以后,就要更新count的值了。

3.使用action

找到我们的cart页面,下面是具体的逻辑:

import {
findCartList,
deleteCart,
checkCart,
addToCart,
checkAllCart,
} from '../../utils/api';

import { createStoreBindings } from 'mobx-miniprogram-bindings';
import { store } from '../../store/index';
import { getCartTotalCount } from '../../store/cart';
const app = getApp();
Page({
data: {
list: [],
totalCount: 0,
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
this.storeBindings = createStoreBindings(this, {
store,
fields: ['count'],
actions: ['updateCount'],
});
},

/**
* 声明周期函数--监听页面卸载
*/
onUnload() {
this.storeBindings.destroyStoreBindings();
},

/**
* 生命周期函数--监听页面显示
*/
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
selected: 2,
});
}
this.getCartList();
},

/**
* 获取购物车列表
*/
async getCartList() {
const res = await findCartList();
this.setData({
list: res.data,
});
this.computedTotalCount(res.data);
},

/**
* 修改购物车数量
*/
async onChangeCount(event) {
const newCount = event.detail;
const goodsId = event.target.dataset.goodsid;
const originCount = event.target.dataset.count;
// 这里如果直接拿+以后的数量,接口的处理方式是直接在上次的基础累加的,
// 所以传给接口的购物车数量的计算方式如下:
// 购物车添加的数量=本次的数量-上次的数量
const count = newCount - originCount;
const res = await addToCart({
goodsId,
count,
});
if (res.code === 200) {
this.getCartList();
}
},

/**
* 计算购物车总数量
*/
computedTotalCount(list) {
// 获取购物车选中数量
const total = getCartTotalCount(list);
// 设置购物车徽标数量
this.updateCount(total);
},


});

上面的代码有所删减。在page和component中使用action方法有所区别,需要在onUnload的时候销毁一下我们的storeBindings。当修改购物车数量的时候,我这里会重新请求一次接口,然后计算一下totalCount的数量,通过updateCount来修改count的值。到了这里,我们的徽标就可以正常的使用了。不管是切换到哪一个tabbar页面,徽标都会保持状态。

4.使用异步action

现在还剩最后一个问题,就是如何设置count的初始值,这个值还得从接口获取过来。下面是实现思路。 首先我们在store中定义了一个一步方法:

import { observable, action, runInAction } from 'mobx-miniprogram';
import { getCartList } from './cart';
// 获取购物车数量

export const store = observable({
/** 数据字段 */
count: 0,

/** 异步方法 */
getCartListCount: async function () {
const num = await getCartList();
runInAction(() => {
this.count = num;
});
},

/** 更新购物车的数量 */
updateCount: action(function (num) {
this.count = num;
}),
});

可以看到,异步action的实现跟同步的区别很大,使用了runInAction这个方法,在它的回调函数中去修改count的值。很坑的是,这个方法在[mobx-miniprogram-bindings](https://www.npmjs.com/package/mobx-miniprogram-bindings)中的官方文档中没有做任何说明,我百度了好久才找到。 现在,我们有了这个方法,在哪里触发好合适呢?答案是app.js中的onShow生命周期函数中。也就是每次我们进入小程序,就会设置一下count的初始值了。下面是代码:

// app.js
import { createStoreBindings } from 'mobx-miniprogram-bindings';
import { store } from './store/index';
App({
onShow() {
this.storeBindings = createStoreBindings(this, {
store,
fields: [],
actions: ['getCartListCount'],
});
// 在页面初始化的时候,更新购物车徽标的数量
this.getCartListCount();
},
});

到此为止,整个完整的徽标响应式改变和store的使用完美的融合了。 参考文章:blog.csdn.net/ice_stone_k…

如何获取tabbar的高度

当我们自定义tabbar以后,由于tabbar是使用的fixed定位,我们的内容区域如果不做任何限制,底部的内容就会被tabbar遮挡,所以我们需要给内容区域整体设置一个padding-bottom,那这个值是多少呢?有的人可能会说,直接把tabbar的高度固定,然后padding-bottom设置成这个高度的值不就可以了吗?你别忘了,现在五花八门的手机下面还有一个叫做安全区域的东西,如下图:

image.png

如果你没有把这个高度加上,那内容区域还是会被tabbar遮挡。下面我们就来看看这个高度具体如何计算呢? 我们以通过wx.getSystemInfoSync()获取机型的各种信息。 image.png

其中screenHeight是屏幕高度,safeAreabottom属性会自动计算安全区域也就是去除tabBar下面的空白区域后有用区域的纵坐标。如此我们就可以就算出来tabber的高度:

const res = wx.getSystemInfoSync()
const { screenHeight, safeArea: { bottom } } = res

if (screenHeight && bottom){
let safeBottom = screenHeight - bottom
const tabbarHeight = 48 + safeBottom
}

这里48是tabbar的高度,我们固定是48px。拿到tabbarHeight以后,把它设置成一个globalData,我们就可以给其他页面设置padding-bottom了。 我这里还使用了其他的一些属性,具体参考代码如下:

// app.js

App({
onLaunch() {
// 获取高度
this.getHeight();
},
onShow() {
},
globalData: {
// tabber+安全区域高度
tabbarHeight: 0,
// 安全区域的高度
safeAreaHeight: 0,
// 内容区域高度
contentHeight: 0,
},
getHeight() {
const res = wx.getSystemInfoSync();
// 胶囊按钮位置信息
const menuButtonInfo = wx.getMenuButtonBoundingClientRect();
const {
screenHeight,
statusBarHeight,
safeArea: { bottom },
} = res;
// console.log('resHeight', res);

if (screenHeight && bottom) {
// 安全区域高度
const safeBottom = screenHeight - bottom;
// 导航栏高度 = 状态栏到胶囊的间距(胶囊距上距离-状态栏高度) * 2 + 胶囊高度 + 状态栏高度
const navBarHeight =
(menuButtonInfo.top - statusBarHeight) * 2 +
menuButtonInfo.height +
statusBarHeight;
// tabbar高度+安全区域高度
this.globalData.tabbarHeight = 48 + safeBottom;
this.globalData.safeAreaHeight = safeBottom;
// 内容区域高度,用来设置内容区域最小高度
this.globalData.contentHeight = screenHeight - navBarHeight;
}
},
});

假如我们需要给首页设置一个首页设置一个padding-bottom

// components/layout/index.js
const app = getApp();
Component({
/**
* 组件的属性列表
*/
properties: {
bottom: {
type: Number,
value: 48,
},
},

/**
* 组件的方法列表
*/
methods: {},
});
<view style="padding-bottom: {{bottom}}px">
<slot></slot>
</view>

这里我简单粗暴的直接在外层套了一个组件,统一设置了padding-bottom。 除了自定义tabbar,还可以自定义navbar,这里我没这个功能,所以不展开讲了,这里放一个参考文章: 获取状态栏的高度。这个文章把如何自定义navbar,如何获取navbar的高度,讲的很通透,感兴趣的仔细拜读。

分页版上拉加载更多

为什么我称作是分页版本的上拉加载更多呢,因为就是上拉然后多加载一页,没有做那种虚拟加载,感兴趣的可以参考这篇文章(我觉得写的非常到位了)。下面我以商品列表为例,代码在pages/goods/list下,讲讲简单版本的实现:

<!--pages/goods/list/index.wxml-->

<view style="min-height: {{contentHeight}}px; padding-bottom: {{safeAreaHeight}}px">

<view wx:if="{{list.length > 0}}">
<goods-card wx:for="{{list}}" wx:key="index" item="{{item}}"></goods-card>
<!-- 上拉加载更多 -->
<load-more
list-is-empty="{{!list.length}}"
status="{{loadStatus}}"
/>
</view>

<van-empty wx:else description="该分类下暂无商品,去看看其他的商品吧~">
<van-button
round
type="danger"

bindtap="gotoBack">
查看其他商品
</van-button>
</van-empty>

</view>
// pages/goods/list/index.js
import { findGoodsList } from '../../../utils/api';
const app = getApp();
Page({
/**
* 页面的初始数据
*/
data: {
page: 1,
limit: 10,
list: [],
options: {},
loadStatus: 0,
contentHeight: app.globalData.contentHeight,
safeAreaHeight: app.globalData.safeAreaHeight,
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
this.setData({ options });
this.loadGoodsList(true);
},

/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
// 还有数据,继续请求接口
if (this.data.loadStatus === 0) {
this.loadGoodsList();
}
},

/**
* 商品列表
*/
async loadGoodsList(fresh = false) {
// wx.stopPullDownRefresh();
this.setData({ loadStatus: 1 });
let page = fresh ? 1 : this.data.page + 1;
// 组装查询参数
const params = {
page,
limit: this.data.limit,
...this.data.options,
};
try {
// loadstatus说明: 0-加载完毕,隐藏加载状态 1-正在加载 2-全部加载 3-加载失败
const res = await findGoodsList(params);
const data = res.data.records;
if (data.length > 0) {
this.setData({
list: fresh ? data : this.data.list.concat(data),
loadStatus: data.length === this.data.limit ? 0 : 2,
page,
});
} else {
// 数据全部加载完毕
this.setData({
loadStatus: 2,
});
}
} catch {
// 错误请求
this.setData({
loadStatus: 3,
});
}
},
});

代码已经很详细了,我再展开说明一下。

  • onLoad的时候第一次请求商品列表数据loadGoodsList,这里我加了一个fresh字段,用来区分是不是第一次加载,从而且控制page是不是等于1

  • 触发onReachBottom的时候,先判断loadStatus === 0,表示接口数据还没加载完,继续请求loadGoodsList

  • loadGoodsList里面,先设置loadStatus = 1,表示状态为加载中。如果fresh为false,则表示要请求下一页的数据了,page+1。

  • 接口请求成功,给了list添加数据的时候要注意了,这里需要再上次list的基础上拼接数据,所以得用concat。同时修改loadStatus状态,如果当前请求回来的数据条数小与limit(每页数据大小),则表示没有更对的数据了,loadStatus = 2,反之为0。

  • 最后为了防止特殊情况出现,还有个loadStatus = 3,表示加载失败的情况。

这里我封装了一个load-more组件,里面就是对loadStatus各种不同状态的处理。具体详情看看源码。 思考:如果加上个下拉刷新,跟上拉加载放在一起,如何实现呢?

如何分包

为什么要做小程序分包?先来看看小程序对文件包的大小限制 image.png 在不使用分包的时候,代码总包的大小限制为2M,如果使用了分包,总包大小可以达到20M,也就是我们能分10个包。 那么如何分包?非常的简单。代码如下:

{
"pages": [
"pages/index/index",
"pages/category/category",
"pages/cart/cart",
"pages/info/info",
"pages/login/index"
],
"subpackages": [
{
"root": "pages/goods",
"pages": [
"list/index",
"detail/index"
]
},
{
"root": "pages/address",
"pages": [
"list/index",
"add/index"
]
},
{
"root": "pages/order",
"pages": [
"pay/index",
"list/index",
"result/index",
"detail/index"
]
}
],
}

目录结构如下: image.png 解释一下: 我们subpackages下面的就是分包的内容,里面的每一个root就是一个包,pages里面的内容只能是这样的字符串路径,添加别的内容会报错。分包的逻辑:业务相关的页面放在一个包下,也就是一个目录下即可。 ⚠️注意:tabbar的页面不能放在分包里面。 下面是分包以后的代码依赖分析截图: image.png

后续更新计划

  • 小程序如何自定义navbar

  • 小程序如何添加typescript

  • 在小程序中如何做表单校验的小技巧

  • 微信支付流程

  • 如何在小程序中mock数据

  • 如何优化小程序

本文章可以随意转载。转给更多需要帮助的人。看了源码觉得有帮助的可以点个star。我会持续更新更多系列教程。

作者:白哥学前端
来源:https://juejin.cn/post/7202495679397511227

收起阅读 »

手把手教你实现MVVM架构

web
引言现在的前端真可谓是百花齐放,百家争鸣,各种框架层出不穷,但是主要目前用的最多的还是要数Vue、React、以及Angular,这三种,当然不乏近期新出的一些其他框架,但是她们都有一个显著的特点,那就是使用了MVVM的架构。首先我们要搞清楚什么是MVVM?M...
继续阅读 »

引言

现在的前端真可谓是百花齐放,百家争鸣,各种框架层出不穷,但是主要目前用的最多的还是要数VueReact、以及Angular,这三种,当然不乏近期新出的一些其他框架,但是她们都有一个显著的特点,那就是使用了MVVM的架构。

首先我们要搞清楚什么是MVVM

MVVM就是Model-View-ViewModel的缩写,MVVM最早由微软提出来,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。把ModelView关联起来的就是ViewModelViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model

改变JavaScript对象的状态,会导致DOM结构作出对应的变化!这让我们的关注点从如何操作DOM变成了如何更新JavaScript对象的状态,而操作JavaScript对象比DOM简单多了。

这就是MVVM的设计思想:关注Model的变化,让MVVM框架去自动更新DOM的状态,从而把开发者从操作DOM的繁琐步骤中解脱出来!

接下来我会带着你们如何去实现一个简易的MVVM架构。

一、构建MVVM构造函数

创建一个MVVM构造函数,用于接收参数,如:datamethodscomputed等:

function MVVM(options) { 
   this.$options = options;
   let data = this._data = this.$options.data;
   observe(data);
   for (let key in data) {
       Object.defineProperty(this, key, {
           enumerable: true,
           get() {
               return this._data[key];
          },
           set(newVal) {
               this._data[key] = newVal;
          }
      });
  };
   initComputed.call(this);
   new Compile(options.el, this);
   options.mounted.call(this);
}

二、构建Observe构造函数

创建一个Observe构造函数,用于监听数据变化:

function Observe(data) { 
   let dep = new Dep();
   for (let key in data) {
       let val = data[key];
       observe(val);
       Object.defineProperty(data, key, {
           enumerable: true,
           get() {
               Dep.target && dep.addSub(Dep.target);
               return val;
          },
           set(newVal) {
               if (val === newVal) {
                   return;
              }
               val = newVal;
               observe(newVal);
               dep.notify();
          }
      });
  };
};

三、构建Compile构造函数

创建一个Compile构造函数,用于解析模板指令:

function Compile(el, vm) { 
   vm.$el = document.querySelector(el);
   let fragment = document.createDocumentFragment();
   while (child = vm.$el.firstChild) {
       fragment.appendChild(child);
  }
   replace(fragment);
   function replace(frag) {
       Array.from(frag.childNodes).forEach(node => {
           let txt = node.textContent;
           let reg = /\{\{(.*?)\}\}/g;
           if (node.nodeType === 3 && reg.test(txt)) {
               let arr = RegExp.$1.split('.');
               let val = vm;
               arr.forEach(key => { val = val[key]; });
               node.textContent = txt.replace(reg, val).trim();
               new Watcher(vm, RegExp.$1, newVal => {
                   node.textContent = txt.replace(reg, newVal).trim();
              });
          }
       if (node.nodeType === 1) {
           let nodeAttr = node.attributes;
           Array.from(nodeAttr).forEach(attr => {
               let name = attr.name;
               let exp = attr.value;
               if (name.includes('')) {
                   node.value = vm[exp];
              }
               new Watcher(vm, exp, newVal => {
                   node.value = newVal;
              });
               node.addEventListener('input', e => {
                   let newVal = e.target.value;
                   vm[exp] = newVal;
              });
            });
        };
        if (node.childNodes && node.childNodes.length) {
            replace(node);
        }
      });
    }
    vm.$el.appendChild(fragment);
}

四、构建Watcher构造函数

创建一个Watcher构造函数,用于更新视图:

function Watcher(vm, exp, fn) { 
   this.fn = fn;
   this.vm = vm;
   this.exp = exp;
   Dep.target = this;
   let arr = exp.split('.');
   let val = vm;
   arr.forEach(key => {
       val = val[key];
  });
   Dep.target = null;
}

Watcher.prototype.update = function() {
       let arr = this.exp.split('.');
       let val = this.vm;
       arr.forEach(key => {
           val = val[key];
      });
       this.fn(val);
}

五、构建Dep构造函数

创建一个Dep构造函数,用于管理Watcher:

function Dep() { 
   this.subs = [];
}

Dep.prototype.addSub = function(sub) {
   this.subs.push(sub);
}

Dep.prototype.notify = function() {
   this.subs.forEach(sub => {
       sub.update();
  });
}

六、构建initComputed构造函数

创建一个initComputed构造函数,用于初始化计算属性:

function initComputed() { 
   let vm = this;
   let computed = this.$options.computed;
   Object.keys(computed).forEach(key => {
       Object.defineProperty(vm, key, {
           get: typeof computed[key] === 'function' ? computed[key] : computed[key].get,
           set() {}
      });
  });
}

总结:

至此我们就完成了一个简易的MVVM框架,虽然简易,但是基本的核心思想差不多都已经表达出来了,最后还是希望大家不要丢在收藏文件夹里吃灰,还是要多多动手练习一下,所谓眼过千遍,不如手过一遍。

作者:前端第一深情阿斌
来源:juejin.cn/post/7202431872968851517

收起阅读 »

Nginx基本介绍+跨域解决方案

web
Nginx简介 Nginx 是一款由俄罗斯的程序设计师 Igor Sysoev 所开发的高性能的 http 服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,它的主要功能有: 反向代理 负载均衡 HTTP 服务器 目前大部分运行的 Ngin...
继续阅读 »

Nginx简介


Nginx 是一款由俄罗斯的程序设计师 Igor Sysoev 所开发的高性能的 http 服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,它的主要功能有:



  • 反向代理

  • 负载均衡

  • HTTP 服务器


目前大部分运行的 Nginx 服务器都在使用其负载均衡的功能作为服务集群的系统架构。


功能说明


在上文中介绍了三种 Nginx 的主要功能,下面来讲讲具体每个功能的作用。


一、反向代理(Reverse Proxy)


介绍反向代理前,我们先理解下正向代理的概念。打个比方,你准备去看周杰伦的巡演,但是发现官方渠道的票已经卖完了,所以你只好托你神通广大的朋友A去内部购票,你如愿以偿地得到了这张门票。在这个过程中,朋友A就起到了一个正向代理的作用,即代理了客户端(你)去向服务端(售票方)发请求,但服务端(售票方)并不知道源头是谁发起的请求,只知道是代理服务(朋友A)向自己请求的。由这个例子,我们再去理解下反向代理,比如我们经常接到10086或者10000的电话,但是每次打过来的人都不一样,这是因为10086是中国移动的总机号,分机打给用户的时候,都是通过总机代理显示的号码,这个时候客户端(你)无法知道是谁发起的请求,只知道是代理服务(总机)向自己请求的。
而官方的解释说明就是,反向代理方式是指以代理服务器来接受 Internet 上 的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给 Internet 上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。
下面贴一段简单实现反向代理的 Nginx 配置代码:


server {  
listen 80;
server_name localhost;
client_max_body_size 1024M;

location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host:$server_port;
}
}
复制代码

其中的 http://localhost:8080 就是反代理的目标服务端,80是 Nginx 暴露给客户端访问的端口。


二、负载均衡(Load Balance**)**


负载均衡,顾名思义,就是将服务负载均衡地分摊到多个服务器单元上执行,来提高网站、应用等服务的性能和可靠性。
下面我们来对比一下两个系统拓扑,首先是未设计负载均衡的拓扑:
未命名绘图.png


下面是设计了负载均衡的拓扑:
未命名绘图2.png
从图二可以看到,用户访问负载均衡器,再由负载均衡器将请求转发给后端服务器,在这种情况下,服务C故障后,用户访问负载会分配到服务A和服务B中,避免了系统崩溃,如果这种故障出现在图一中,该系统一定会会直接崩溃。


负载均衡算法


负载均衡算法决定了后端的哪些健康服务器会被选中。几个常用的算法:



  • **Round Robin(轮询):**为第一个请求选择列表中的第一个服务器,然后按顺序向下移动列表直到结尾,然后循环。

  • **Least Connections(最小连接):**优先选择连接数最少的服务器,在普遍会话较长的情况下推荐使用。

  • **Source:**根据请求源的 IP 的散列(hash)来选择要转发的服务器。这种方式可以一定程度上保证特定用户能连接到相同的服务器。


如果你的应用需要处理状态而要求用户能连接到和之前相同的服务器。可以通过 Source 算法基于客户端的 IP 信息创建关联,或者使用粘性会话(sticky sessions)。


负载均衡同时需要配合反向代理功能才能发挥其作用。


三、HTTP服务器


除了以上两大功能外,Nginx也可以作为静态资源服务器使用,例如没有使用 SSR(Server Side Render)的纯前端资源,就可以依托Nginx来实现资源托管。
下面看一段实现静态资源服务器的配置:


server {
listen 80;
server_name localhost;
client_max_body_size 1024M;

location / {
root e:\wwwroot;
index index.html;
}
}
复制代码

root 配置就是具体资源存放的根目录,index 配置的则是访问根目录时默认的文件。


动静分离


动静分离也是Nginx作为Http服务器使用的一个重要概念,要搞清楚动静分离,首先要弄明白什么是动态资源,什么是静态资源:



  • **动态资源:**需要从服务器中实时获取的资源内容,如 JSP, SSR 渲染页面等,不同时间访问,资源内容会发生变化。

  • **静态资源:**如 JS、CSS、Img 等,不同时间访问,资源内容不会发生变化。


由于Nginx可以作为静态资源服务器,但无法承载动态资源,因此出现需要动静分离的场景时,我们需要拆分静态、动态资源的访问策略:


upstream test{  
server localhost:8080;
server localhost:8081;
}

server {
listen 80;
server_name localhost;

location / {
root e:\wwwroot;
index index.html;
}

# 所有静态请求都由nginx处理,存放目录为html
location ~ \.(gif|jpg|jpeg|png|bmp|swf|css|js)$ {
root e:\wwwroot;
}

# 所有动态请求都转发给tomcat处理
location ~ \.(jsp|do)$ {
proxy_pass http://test;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root e:\wwwroot;
}
}
复制代码

从这段配置可以大概理解到,当客户端访问不同类型的资源时,Nginx 会自动按照类型分配给自己的静态资源服务或者是远程的动态资源服务上,这样就能满足一个完整的资源服务器的功能了。


配置介绍


一、基本介绍


说完 Nginx 的功能,我们来简单进一步介绍下 Nginx 的配置文件。作为前端人员来讲,使用 Nginx 基本上就是修改配置 -> 启动/热重启 Nginx,就能搞定大部分日常和 Nginx 相关的工作了。
这里我们看下一份 Nginx 的默认配置,即安装 Nginx 后,默认的 nginx.conf 文件的内容:



#user nobody;
worker_processes 1;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid logs/nginx.pid;


events {
worker_connections 1024;
}


http {
include mime.types;
default_type application/octet-stream;

#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';

#access_log logs/access.log main;

sendfile on;
#tcp_nopush on;

#keepalive_timeout 0;
keepalive_timeout 65;

#gzip on;

server {
listen 80;
server_name localhost;

#charset koi8-r;

#access_log logs/host.access.log main;

location / {
root html;
index index.html index.htm;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}


# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;

# location / {
# root html;
# index index.html index.htm;
# }
#}


# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;

# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;

# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;

# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;

# location / {
# root html;
# index index.html index.htm;
# }
#}

}

复制代码

对应的结构大致是:


...              #全局块

events { #events块
...
}

http #http块
{
... #http全局块
server #server块
{
... #server全局块
location [PATTERN] #location块
{
...
}
location [PATTERN]
{
...
}
}
server
{
...
}
... #http全局块
}
复制代码

以上几个代码块对应功能是:



  • 全局块:配置影响 Nginx 全局的指令。一般有运行 Nginx 服务器的用户组,Nginx 进程 pid 存放路径,日志存放路径,配置文件引入,允许生成 worker process 数等。

  • events块:配置影响 Nginx 服务器或与用户的网络连接。有每个进程的最大连接数,选取哪种事件驱动模型处理连接请求,是否允许同时接受多个网路连接,开启多个网络连接序列化等。

  • http块:可以嵌套多个 server,配置代理,缓存,日志定义等绝大多数功能和第三方模块的配置。如文件引入,mime-type 定义,日志自定义,是否使用 sendfile 传输文件,连接超时时间,单连接请求数等。

  • server块:配置虚拟主机的相关参数,一个 http 中可以有多个 server。

  • location块:配置请求的路由,以及各种页面的处理情况。


各代码块详细的配置方式可以参考 Nginx 文档


二、Nginx 解决跨域问题


下面展示一段常用于处理前端跨域问题的 location代码块,方面各位读者了解及使用 Nginx 去解决跨域问题。


location /cross-server/ {
set $corsHost $http_origin;
set $allowMethods "GET,POST,OPTIONS";
set $allowHeaders "broker_key,X-Original-URI,X-Request-Method,Authorization,access_token,login_account,auth_password,user_type,tenant_id,auth_code,Origin, No-Cache, X-Requested-With, If-Modified-Since, Pragma, Last-Modified, Cache-Control, Expires, Content-Type, X-E4M-With, usertoken";

if ($request_method = 'OPTIONS'){
add_header 'Access-Control-Allow-Origin' $corsHost always;
add_header 'Access-Control-Allow-Credentials' true always;
add_header 'Access-Control-Allow-Methods' $allowMethods always;
add_header 'Access-Control-Allow-Headers' $allowHeaders;
add_header 'Access-Control-Max-Age' 90000000;
return 200;
}

proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
add_header Access-Control-Allow-Origin $corsHost always;
add_header Access-Control-Allow-Methods $allowMethods always;
add_header Access-Control-Allow-Headers $allowHeaders;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers *;
add_header Access-Control-Max-Age 90000000;

proxy_pass http://10.117.20.54:8000/;
proxy_set_header Host $host:443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_redirect http:// $scheme://;

}
复制代码

可以看到,前段使用 set 设置了 location 中的局部变量,然后分别在下方的各处指令配置中使用了这些变量,以下是各指令的作用:



  • add_header:用于给请求添加返回头字段,当且仅当状态码为以下列出的那些时有效:200, 201 (1.3.10), 204, 206, 301, 302, 303, 304, 307 (1.1.16, 1.0.13), or 308 (1.13.0)

  • **proxy_hide_heade:**可以隐藏响应头中的信息。

  • **proxy_redirect:**指定修改被代理服务器返回的响应头中的location头域跟refresh头域数值。

  • **proxy_set_header:**重定义发往后端服务器的请求头。

  • **proxy_pass:**被代理的转发服务路径。


以上这段配置可以直接复制到 nginx.conf 中,然后修改 /cross-server/ (Nginx 暴露给客户端访问的路径)和 http://10.117.20.54:8000/(被转发的服务路径)即可实避免服务跨域问题。


跨域技巧补充


开发环境下,如果不想使用 Nginx 来处理跨域调试问题,也可以采用修改 Chrome 配置的方式来实现跨域调试,本质上跨域是一种浏览器的安全策略,所以从浏览器出发去解决这个问题反而更加方便。


Windows 系统:


1、复制chrome浏览器快捷方式,对快捷方式图标点右键打开“属性” 如图:
image.png
2、在“目标”后添加 --disable-web-security --user-data-dir,例如图中修改完成后为:"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --disable-web-security --user-data-dir


3、点击确定后重新打开浏览器,出现:
image.png


此时,屏蔽跨域设置修改完毕,点开此快捷方式访问的页面均会忽略跨域规则,避免了开发环境下,服务端配置跨域的麻烦。


Mac 系统:


以下内容转载自:Mac上解决Chrome浏览器跨域问题


首先创建一个文件夹,这个文件夹是用来保存关闭安全策略后的用户信息的,名字可以随意取,位置也可以随意放。



创建一个文件夹


然后打开控制台,输入下面这段代码
open -n /Applications/Google\ Chrome.app/ --args --disable-web-security --user-data-dir=/Users/LeoLee/Documents/MyChromeDevUserData



关闭安全策略代码


大家需要根据自己存放刚刚创建的文件夹的地址来更改上面的代码,也就是下面图中的红框区域,而网上大多数的教程中也正是缺少了这部分的代码导致很多用户在关闭安全策略时失败



用户需要根据自己的文件夹地址修改代码


输入代码,敲下回车,接下来Chrome应该会弹出一个窗口



Chrome弹窗


点击启动Google Chrome,会发现与之前的Chrome相比,此时的Chrome多了上方的一段提示,告诉你现在使用的模式并不安全



浏览器上方会多出一行提示


其原理和 Windows 版本差不多,都是通过修改配置来绕过安全策略。


作者:WhaleFE
来源:juejin.cn/post/7202252704978026551
收起阅读 »

从输入 URL 到页面显示,这中间发生了什么?

web
前言从输入 URL 到页面显示的发生过程,这是一个在面试中经常会被问到的问题,此问题能比较全面地考察应聘者知识的掌握程度。其中涉及到了网络、操作系统、Web 等一系列的知识。以 Chrome 浏览器为例,目前的 Chrome 浏览器有以下几个进程:浏览器进程。...
继续阅读 »

前言

从输入 URL 到页面显示的发生过程,这是一个在面试中经常会被问到的问题,此问题能比较全面地考察应聘者知识的掌握程度。其中涉及到了网络、操作系统、Web 等一系列的知识。

以 Chrome 浏览器为例,目前的 Chrome 浏览器有以下几个进程:

浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

渲染进程。主要职责是把从网络下载的 HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面。

GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。

网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,后面才独立出来,成为一个单独的进程。

插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

1. 用户输入

如果输入的是内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。

如果输入的是 URL,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL。

2. URL 请求过程

浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。那具体流程是怎样的呢?

网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。

接下来就是利用 IP 地址和服务器建立 TCP 连接 (三次握手)。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。

服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。

Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。

如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是 HTML,那么浏览器则会继续进行导航流程。

3. 准备渲染进程

如果协议根域名相同,则属于同一站点。

但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。

渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。

4. 提交文档

所谓提交文档,就是指浏览器进程网络进程接收到的 HTML 数据提交给渲染进程,具体流程是这样的:

首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息。

渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”。

等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程。

浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。

5. 渲染阶段

一旦文档被提交,渲染进程便开始页面解析和子资源加载。

渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。

渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。

创建布局树,并计算元素的布局信息。

对布局树进行分层,并生成分层树

为每个图层生成绘制列表,并将其提交到合成线程。

合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图

合成线程发送绘制图块命令 DrawQuad 给浏览器进程。

浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。

最后

以上就是笔者对这一常考面试题的一些总结,对于其中的一些具体过程并没有详细地列举出来。如有不足欢迎大家在评论区指出......

作者:codinglin
来源:juejin.cn/post/7202602022355779644

收起阅读 »

vue中Axios添加拦截器刷新token的实现方法

web
vue中Axios添加拦截器刷新token的实现方法Axios是一款网络前端请求框架,本文主要介绍了vue中Axios添加拦截器刷新token的实现方法,1. Axios基本用法:        const respon...
继续阅读 »

vue中Axios添加拦截器刷新token的实现方法

Axios是一款网络前端请求框架,本文主要介绍了vue中Axios添加拦截器刷新token的实现方法,

1. Axios基本用法:

        const response = await Axios.create({
           baseURL: "https://test.api.com",
           headers: {
               'Content-Type': 'application/json',
          },
        }).post<RequestResponse>('/signin', {
           user_id: "test_user",
           password: "xxx",
      });

其中,RequestResponse是返回的数据要解析为的数据类型,如下:

export interface RequestResponse {
   data: any;
   message: string;
   resultCode: number;
}

这样,得到的response就是网络请求的结果,可以进行判断处理。

2. Axios基本封装用法:

对Axios进行简单的封装,使得多个网络请求可以使用统一的header等配置。

新建一个工具类,进行封装:

import Axios, { AxiosRequestConfig, AxiosError, AxiosInstance, AxiosResponse } from 'axios';

export const BASE_URL = "https://test.api.com";

export const axiosApi = (): AxiosInstance => {
 const instance = Axios.create({
   baseURL: BASE_URL,
   headers: {
      'Content-Type': 'application/json',
      Authorization: `${getAccessToken()}`,
  },
});
   
 return instance;
}

const getAccessToken = () => {
   // 这里获取本地保存的token
   return xxxxx
}

然后使用的地方是这样:

const response = await axiosApi().post<RequestResponse>('/signin', {
    user_id: "test_user",
    password: "xxx",
});

3. 添加拦截器的用法

现在我们想再增加个功能,就是调接口时,header里传了token,但是有时候token过期了接口就会返回失败,我们想在封装的地方添加统一处理,如果token过期就刷新token,然后再调接口。

其中token的数据格式及解析方法已知如下:

import * as crypto from 'crypto';
import * as jwt from "jsonwebtoken";

export interface TokenData {
 userid: string;
 exp: number;
 iat: number;
}

export const decodeJWT = function (token: string): TokenData {
 if (!token) {
   return null;
}
 const decoded = jwt.decode(token, { complete: true });
 return decoded?.payload;
};

如何统一刷新token呢?可以添加拦截器进行处理。把对Axios的封装再改下,添加拦截器:

export const axiosApi = (): AxiosInstance => {
 const instance = Axios.create({
   baseURL: BASE_URL,
   headers: {
      'Content-Type': 'application/json',
      Authorization: `${getAccessToken()}`,
  },
});
 
 // 添加拦截器
 instance.interceptors.request.use(
   config => {
     return refreshToken(config);
  },
   err => {
     return Promise.reject(err)
  }
)
 return instance;
}

// 刷新token的方法
const refreshToken = async (config: AxiosRequestConfig) => {
 const oldToken = getAccessToken();
 if (!oldToken) { //如果本地没有token,也就是没登录,那就不用刷新token
   return config;
}

 const tokenData = decodeJWT(oldToken);//解析token,得到token里包含的过期时间信息
 const currentTimeSeconds = new Date().getTime()/1000;

 if (tokenData && tokenData.exp > currentTimeSeconds) {
   return config; // token数据里的时间比当前时间大,也就是没到过期时间,那也不用刷新
}

 // 下面是刷新token的逻辑,这里是调API获取新的token
 const response = await signInRefreshToken(tokenData?.userid);
 if (response && response.status == 200) {
   const { token, refresh_token } = response.data?.data;
   // 保存刷新后的token
   storeAccessToken(token);
   // 给API的header设置新的token
   config.headers.Authorization = token;
}
 return config;
}

经过这样添加了拦截器,如果token没过期,就直接进行网络请求;如果token过期了,那就会调接口刷新token,然后给header设置新的token再进行网络请求。

4. 注意事项:

要注意的一点是,实际应用时,要注意:

1.刷新token时如果调接口,所使用的网络请求工具不能也使用这个封装的工具,否则就会陷入无限循环,可以使用简单未封装的方式请求。

2.本例使用的方法,是进行请求前刷新token。也可以使用先调网络请求,如果接口返回错误码表示token过期,则刷新token,再重新请求的方式。

作者:程序员小徐同学
来源:juejin.cn/post/7159727466439770119

收起阅读 »

使用 husky 实现基础代码审查

web
在日常提交 PR 的过程中,我们提交的文件不应该有例如 console、debugger、test.only 等调试语句,这会影响到线上代码。那每次提交之前都检查似乎又像是一个繁琐的工作,如果有个工作能代替我们检查我们提交的代码,让不能提交到线上的代码在 co...
继续阅读 »

在日常提交 PR 的过程中,我们提交的文件不应该有例如 console、debugger、test.only 等调试语句,这会影响到线上代码。那每次提交之前都检查似乎又像是一个繁琐的工作,如果有个工作能代替我们检查我们提交的代码,让不能提交到线上的代码在 commit 阶段停止下来,对 code reviewer 的工作会减少不少。 这里就来跟大家探讨一下我的一个实现方式。

前言

在提交代码的时候不知道大家有没有注意命令行中会打印一些日志:


像这里的

husky > pre-commit
🔍 Finding changed files since ...
🎯 Found 3 changed files.
✅ Everything is awesome!
husky > commit-msg

这个出处大家应该都知道,来自 pretty-quick ,然后通过 packge.json 中的:

  "husky": {
   "hooks": {
     "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
     "pre-commit": "pretty-quick --staged"
  }
}

这段代码在我们 commit 的时候对代码进行相关处理。

这里是 husky 调用了相关的 Git hooks ,在合适的时机处理我们提交的代码文件,从而使我们的代码达到提交的要求(如上面的是格式化相关代码)。

看到这里肯定大家就会想到,那这个是不是可以做的还有更多?

没错,下面就直接上配置,来实现我们不想让某些代码提交到线上这样的需求。

第一版

知道这个原理,那就很简单了,我们在 pre-commit 这个事件触发的时候对我们要提交代码检查一下,看其中有没有那几个关键字就可以了。

那就直接动手:

我们在项目根目录找到 .git/hooks :


可以看到,这里提供了各种触发时机,我们找到我们想要的 pre-commit (如果后缀有 .sample,需要去除掉才能让此文件生效)。打开此文件,前端的话应该看到的是(不需要阅读):

#!/bin/sh
# husky

# Hook created by Husky
#   Version: 2.7.0
#   At: 2023/2/2 13:14:26
#   See: https://github.com/typicode/husky#readme

# From
#   Directory: /Users/frank/Documents/work/worktile/wt-cronus/projects/pc-flow-sky/node_modules/husky
#   Homepage: undefined

scriptPath="node_modules/husky/run.js"
hookName=`basename "$0"`
gitParams="$*"

debug() {
 if [ "${HUSKY_DEBUG}" = "true" ] || [ "${HUSKY_DEBUG}" = "1" ]; then
   echo "husky:debug $1"
 fi
}

debug "$hookName hook started"

if [ "${HUSKY_SKIP_HOOKS}" = "true" ] || [ "${HUSKY_SKIP_HOOKS}" = "1" ]; then
debug "HUSKY_SKIP_HOOKS is set to ${HUSKY_SKIP_HOOKS}, skipping hook"
 exit 0
fi


if ! command -v node >/dev/null 2>&1; then
 echo "Info: can't find node in PATH, trying to find a node binary on your system"
fi

if [ -f "$scriptPath" ]; then
 # if [ -t 1 ]; then
 #   exec < /dev/tty
 # fi
 if [ -f ~/.huskyrc ]; then
  debug "source ~/.huskyrc"
  . ~/.huskyrc
 fi
node_modules/run-node/run-node "$scriptPath" $hookName "$gitParams"
else
 echo "Can't find Husky, skipping $hookName hook"
 echo "You can reinstall it using 'npm install husky --save-dev' or delete this hook"
fi

看了下,感觉没啥用,就是一个检测 husky 有没有安装的脚本。我们这里直接使用下面的替换掉:

#!/bin/sh

errorForOnly() {
 result=""
 for FILE in `git diff --name-only --cached`; do
     # 忽略检查的文件
     if [[ $FILE == *".html"* ]] ; then
        continue
     fi
     # 匹配不能上传的关键字
     grep 'serial.only\|console.log(\|alert(' $FILE 2>&1 >/dev/null
     if [ $? -eq 0 ]; then
         # 将错误输出
         echo '❌' $FILE '此文件中包含 [only]、[console]、[alert] 中的关键字, 删除后再次提交'
         # exit 1
         result=0
       else
         result=1
       fi
 done

 if [[ ${result} == 0 ]];then
       exit 1
 fi
 echo "✅ All files is OK!"

}

errorForOnly

然后我们在一些文件中添加我们不想要的关键字,然后 git commit :


可以看到错误日志以及文件已经在命令行中打印出来了,同时文件也没有进入本地仓库(Repository),让然在我们的暂存区(Index)。


使用这个方式,在一定程度上我们避免了提交一些不想要的代码到线上这种情况了发生。同时也是在本地提交代码之前就做了这个事情,也避免了使用服务端 hooks 造成提交历史混乱的问题。

这时候你肯定会产生这样的疑问:


【欸,欸,欸,不对啊】

问题

这种方式有个显而易见的问题,那就是不同团队协同方面。由于 hooks 本身不跟随克隆的项目副本分发,所以必须通过其他途径把这些 hooks 分发到团队其他成员的 .git/hooks 目录并设为可执行文件。

另外一个问题是我们使用了 husky,在每次 npm i 之后都会重置 hooks 文件。也就是 .git/hooks/pre-commit 文件恢复到了最初(只有 husky 检测的代码)的样子,没有了我们写的逻辑。

这种情况是不能允许的。那就寻找解决途径。查了下相关文档,发现可以使用新版的 husky 来解决这个问题。(其他相关的工具应该也可以,这里使用 husky 来进行展示)。

最新实现

husky 在 v4 版本之后进行了大的重构,一些配置方式不一样了,至于为什么重构,大家可以去

安装

安装 npm install husky --save-dev

安装最新版本为 8.0.3 ,安装完成后启用 Git hooks: npx husky install


在团队协作的情景下,得让本团队的其他人也能自动的启用相关 hooks ,所以添加下面这个命令,在每次 npm install 之后执行:

npm pkg set scripts.prepare="husky install"

我们就在 package.json 得到了这样的命令:


yarn2+ 不支持 prepare 生命周期脚本命令, 安装方式在此处

使用

先按照官方文档测试一下,执行 npx husky add .husky/pre-commit "npm test" 在相关目录我们就看到:


相应的文件以及内容已经准备就绪。这里就不运行了。

那如何将之前的流程使用新的版本配置好呢?

这里直接提供相关文件内容:

# .husky/commit-msg
# 用于 commit 信息的验证
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no -- commitlint --edit $1

使用下面的语句生成;

npx husky add .husky/commit-msg 'npx --no -- commitlint --edit `echo "\$1"`'

另外还有 pre-commit:

# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# pretty-quick 相关
npx pretty-quick --staged

# 验证是否有调试关键字的脚本:
node bin/debugger-keywords.js

脚本内容:

// bin/debugger-keywords.js
const { execSync } = require('child_process');
const fs = require('fs');
const gitLog = execSync('git diff --name-only --cached').toString();
const fileList = gitLog.split('\n').slice(1);
const keyWordsRegex = /(console\.(log|error|info|warn))|(serial\.only)|(debugger)/g;
const result = [];

for (let i = 0; i < fileList.length; i++) {
const filePath = fileList[i].trim();

if (filePath.length === 0 || filePath.includes('.husky') || filePath.includes('bin/')) {
continue;
}
const fileContent = fs.readFileSync(`${filePath}`, 'utf8');
const containerKeyWords = Array.from(new Set(Array.from(fileContent.matchAll(keyWordsRegex), m => m[0])));

if (containerKeyWords.length > 0) {
const log = `❌ ${filePath} 中包含 \x1B[31m${containerKeyWords.join('、')}\x1B[0m 关键字`;
result.push(log);
console.log(log);
}
}

if (result.length >= 1) {
console.log(`💡 修改以上问题后再次提交 💡`);
process.exit(1);
} else {
console.log('✅ All files is OK! \n');
process.exit(0);
}

为几个文件添加 console 、测试 only 等,提交 commit 后效果展示:


会提示文件中不合规的关键字是哪些。

更多

有了以上的使用示例,我们可以在随意添加脚本,比如,为 19:00 之后或周末提交代码的你来上一杯奶茶🧋和一个甜甜圈🍩:

// bin/check-time.js
const now = new Date()
const week = now.getDay()
const hour = now.getHours()
const validWeek = week >= 1 && week <= 5
const validHour = hour >= 9 && hour < 19
if (validHour && validWeek) return

console.log(`🌃 来点 🧋   🍩`);

这次为了方便也在 pre-commit hook 中执行:

# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# pretty-quick 相关
npx pretty-quick --staged

# 验证是否有调试关键字的脚本:
node bin/debugger-keywords.js

# 来杯奶茶
node bin/check-time.js

来看下结果:


结论

目前单纯使用 pre-commit hook 针对常见的 console、debugger 还有测试 only 这种。但是我们也可以看到只要我们写不同的脚本,可以实现不同的需求。

之后如果有更多的需求也能继续添加脚本,也可以产生我们 PingCode 自己的 lint 插件。

ps. 如果使用可视化工具提交可能会报错,大家可以自行查阅解决。

作者:阿朋
来源:juejin.cn/post/7202392792726011959

收起阅读 »