注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

webpack-dev-server 从入门到实战

古有云:“工欲善其事,必先利其器”。作为一个前端开发,搭建一个便捷的开发环境,将会为我们的开发工作带来极大的效率提升。而Webpack作为如今前端工程打包必不可少的工具,很多人却不知道从Webpack 4开始提供的DevServer功能。 让我们一起来学习下吧...
继续阅读 »

古有云:“工欲善其事,必先利其器”。作为一个前端开发,搭建一个便捷的开发环境,将会为我们的开发工作带来极大的效率提升。而Webpack作为如今前端工程打包必不可少的工具,很多人却不知道从Webpack 4开始提供的DevServer功能。


让我们一起来学习下吧!


1 什么是webpack-dev-server


DevServerWebpack 3开放的一个实验功能,使用webpack-dev-middleware中间件,提供热更新的开发服务器,旨在帮助开发者在开发阶段快速进行环境搭建。


最新Webpack 5还支持反向代理、防火墙、Socketgzip压缩等功能。


2 反向代理配置


Nginx类似,webpack-dev-server也是通过url正则匹配的方式进行url代理配置,常用配置参考如下代码:


{
"/rest/": {
"target": "http://127.0.0.1:8080",
"secure": false
}
}

还可以通过用JavaScript定义此配置,把多个条目代理到同一个目标。将代理配置文件设置为proxy.conf.js(代替proxy.conf.json),并指定如下例子中的配置文件。


module.exports = {
    //...
    devServer: {
        proxy: [
            {
                context: ['/auth', '/api'],
                target: 'http://localhost:3000',
            },
        ],
    },
};

2.1 基本配置项介绍



  • proxydevServer代理配置

  • /api: 表示需要代理的请求url

  • target:反向代理的地址

  • pathRewrite:请求地址重写,类似NginxRewite功能


其他写法参考:


"pathRewrite": {
  "^/old/api": "/new/api"
}

 // remove path
pathRewrite: {
'^/remove/api': ''
}

// add base path
pathRewrite: {
'^/': '/basepath/'
}

// custom rewriting
pathRewrite: function (path, req) {
return path.replace('/api', '/base/api');
}

// custom rewriting, returning Promise
pathRewrite: async function (path, req) {
const should_add_something = await httpRequestToDecideSomething(path);
if (should_add_something) path += 'something';
return path;
}

2.2 其他配置参考



  • logLevel:日志打印等级,支持['debug', 'info', 'warn', 'error', 'silent']silent不打印日志

  • logProvider: 自定义日志打印中间件

  • secure:是否关闭https安全认证

  • changeOrigin:修改代理请求host

  • protocolRewrite:协议重写,httphttps请求互转

  • cookieDomainRewrite:修改cookieDomain的值

  • headers:给所有请求添加headers配置

  • proxyTimeout:请求超时时间


2.3 高级代理机制



  • onError:  对请求状态码进行处理


function onError(err, req, res, target) {
    res.writeHead(500, {
        'Content-Type': 'text/plain',
    });
    res.end('Something went wrong. And we are reporting a custom error message.');
}


  • onProxyRes: 对代理接口的Response处理,这里常用来获取cookie、重定向等


function onProxyRes(proxyRes, req, res) {
    proxyRes.headers['x-added'] = 'foobar'; // 添加一个header
    delete proxyRes.headers['x-removed']; // 删除一个header
}


  • onProxyReq:对代理接口request处理,执行在请求前,常用来设置cookieheader等操作


function onProxyReq(proxyReq, req, res) {
    // add custom header to request
    proxyReq.setHeader('x-added', 'foobar');
    // or log the req
}

3 域名白名单配置


配置该配置后,只有匹配的host地址才可以访问该服务,常用于开发阶段模拟网络网络防火墙对访问IP进行限制。当该配置项被配置为all时,会跳过host检查,但不建议这样做,因为有DNS攻击的风险。



  1. webpack配置项配置


module.exports = {
  //...
  devServer: {
    allowedHosts: [
      'host.com',
      'subdomain.host.com',
      'subdomain2.host.com',
      'host2.com',
    ],
  },
};


  1. cli 启动命令配置


npx webpack serve --allowed-hosts .host.com --allowed-hosts host2.com

4 端口配置



  1. webpack配置项配置


module.exports = {
  //...
  devServer: {
    port: 8080,
  },
};


  1. cli 启动命令配置


   npx webpack serve --port 8080

5 Angular 实战 —— 通过webpack devServer代理REST接口到本地服务器


在Angular框架中,由于对webpack进行了封装,proxy配置文件默认使用的是proxy.config.json。(js格式配置文件需要到angular.json配置文件中修改),这里以proxy.config.json为例。



  1. 代理所有以/rest/开头的接口到127.0.0.1:8080,并且将/rest/请求地址转为/


{
  "/rest/": {
    "target": "http://127.0.0.1:8080",
    "secure": false,
    "pathRewrite": {
      "/rest/": "/"
    },
    "changeOrigin": true,
    "logLevel": "debug",
    "proxyTimeout": 3000
  }
}

访问启动地址测试{{ host地址}}/rest/testApi



  1. 给所有的/rest/接口加上cftk的header


这个需要使用js格式的proxy配置文件,修改angular.json中的proxyConfig为 proxy.config.js,在proxy.config.js中添加如下内容:


const PROXY_CONFIG = [
    {
        "target": "http://127.0.0.1:8080",
        "secure": false,
        "pathRewrite": {
            "/rest/": "/"
        },
        "changeOrigin": true,
        "logLevel": "debug",
        "proxyTimeout": 3000,
        "onProxyReq": (request, req, res) => {
            request.setHeader('cftk', 'my cftk');
        }
    },
];
module.exports = PROXY_CONFIG;

6 webpack-dev-server 与 nginx 的对比



作者:DevUI团队
链接:https://juejin.cn/post/7010571347705200671

收起阅读 »

JavaScript实现2048小游戏,我终于赢了一把

效果图 实现思路 编写页面和画布代码。 绘制背景。 绘制好全部卡片。 随机生成一个卡片(2或者4)。 键盘事件监听(上、下、左、右键监听)。 根据键盘的方向,处理数字的移动合并。 加入成功、失败判定。 处理其他收尾工作。 代码实现编写页面代码 <...
继续阅读 »

效果图


在这里插入图片描述


实现思路



  1. 编写页面和画布代码。

  2. 绘制背景。

  3. 绘制好全部卡片。

  4. 随机生成一个卡片(2或者4)。

  5. 键盘事件监听(上、下、左、右键监听)。

  6. 根据键盘的方向,处理数字的移动合并。

  7. 加入成功、失败判定。

  8. 处理其他收尾工作。


代码实现

编写页面代码



<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2048</title>
<style>
#box{
width:370px;
height:370px;
position:absolute;
margin:0 auto;
left:0;
right:0;
top:1px;
bottom:0;
}

.rebutton{
position: absolute;
top:370px;
left:38%;
}
</style>
</head>
<body>
<div id='box'></div>
<button onclick="restart()" class='rebutton'>重开</button>
</body>
<script src="js/util.js"></script>
<script src="js/2048.js"></script>
<script type="text/javascript">

</script>
</html>

复制代码

添加画布


在2048.js编写代码



  1. 创建函数


function G2048(){
this.renderArr=[];//渲染数组
this.cards=initCardArray();
//游戏标记
this.flag='start';
}
//初始化数组
function initCardArray(){
var cards = new Array();
for (var i = 0; i < 4; i++) {
cards[i] = new Array();
for (var j = 0; j < 4; j++) {
//cards[i][j]=null;
}
}
return cards;
}


  1. 初始化和绘制背景代码(在2048.js中编写)


//初始化
G2048.prototype.init=function(el,musicObj){
if(!el) return ;
this.el=el;
var canvas = document.createElement('canvas');//创建画布
canvas.style.cssText="background:white;";
var W = canvas.width = 370; //设置宽度
var H = canvas.height = 370;//设置高度

el.appendChild(canvas);//添加到指定的dom对象中
this.ctx = canvas.getContext('2d');

this.draw();
}
//绘制入口
G2048.prototype.draw=function(){
//创建背景
this.drawGB();

//渲染到页面上
this.render();

}

//创建背景
G2048.prototype.drawGB=function(){
var bg = new _.Rect({x:0,y:0,width:364,height:364,fill:true,fillStyle:'#428853'});
this.renderArr.push(bg);
}

//渲染图形
G2048.prototype.render=function(){
var context=this.ctx;
this.clearCanvas();
_.each(this.renderArr,function(item){
item && item.render(context);
});
}
//清洗画布
G2048.prototype.clearCanvas=function() {
this.ctx.clearRect(0,0,parseInt(this.w),parseInt(this.h));
}


  1. 在页面代码中加入以下 js 代码


var box = document.getElementById('box');
g2048.init(box);

在这里插入图片描述
运行效果:
在这里插入图片描述


绘制好全部卡片



  1. 创建Card


//定义Card
function Card(i,j){
this.i=i;//下标i
this.j=j;//下标j
this.x=0;// x坐标
this.y=0;// y坐标
this.h=80;//高
this.w=80;//宽
this.start=10;//偏移量(固定值)
this.num=0;//显示数字
this.merge=false;//当前是否被合并过,如果合并了,则不能继续合并,针对当前轮
//初始化创建
this.obj = this.init();
//创建显示数字对象
this.numText = this.initNumText();
}
//初始创建
Card.prototype.init=function(){
return new _.Rect({x:this.x,y:this.y,width:this.w,height:this.h,fill:true});
}
//根据i j计算x y坐标
Card.prototype.cal=function(){
this.x = this.start + this.j*this.w + (this.j+1)*5;
this.y = this.start + this.i*this.h + (this.i+1)*5;
//更新给obj
this.obj.x=this.x;
this.obj.y=this.y;
//设置填充颜色
this.obj.fillStyle=this.getColor();

//更新文字的位置
this.numText.x = this.x+40;
this.numText.y = this.y+55;
this.numText.text=this.num;
}
//初始化显示数字对象
Card.prototype.initNumText=function(){
var font = "34px 思源宋体";
var fillStyle = "#7D4E33";
return new _.Text({x:this.x,y:this.y+50,text:this.num,fill:true,textAlign:'center',font:font,fillStyle:fillStyle});
}
//获取color
Card.prototype.getColor=function(){
var color;
//根据num设定颜色
switch (this.num) {
case 2:
color = "#EEF4EA";
break;
case 4:
color = "#DEECC8";
break;
case 8:
color = "#AED582";
break;
case 16:
color = "#8EC94B";
break;
case 32:
color = "#6F9430";
break;
case 64:
color = "#4CAE7C";
break;
case 128:
color = "#3CB490";
break;
case 256:
color = "#2D8278";
break;
case 512:
color = "#09611A";
break;
case 1024:
color = "#F2B179";
break;
case 2048:
color = "#DFB900";
break;

default://默认颜色
color = "#5C9775";
break;
}

return color;
}

Card.prototype.render=function(context){
//计算坐标等
this.cal();
//执行绘制
this.obj.render(context);
//是否绘制文字的处理
if(this.num!=0){
this.numText.render(context);
}
}

}


  1. 创建卡片


	//创建卡片
G2048.prototype.drawCard=function(){
var that=this;
var card;
for (var i = 0; i < 4; i++) {
for (var j = 0; j < 4; j++) {
card = new Card(i,j);
that.cards[i][j]=card;
that.renderArr.push(card);
}
}
}


  1. 调用绘制代码


在这里插入图片描述
运行效果:
在这里插入图片描述
4. 修改一下卡片的默认数字
在这里插入图片描述


在这里插入图片描述


随机生成一个卡片,2或者4




  1. 先把Card中 num 默认改成0

  2. 因为2跟4出现的比例是1:4,所以采用随机出1-5的数字,当是1的时候就表示,当得到2、3、4、5的时候就表示要出现数字2.

  3. 随机获取i,j 就可以得到卡片的位置,割接i,j取到card实例,如果卡片没有数字,就表示可以,否则就递归继续取,取到为止。

  4. 把刚才取到的数字,设置到card实例对象中就好了。



代码如下:


//随机创建一个卡片
G2048.prototype.createRandomNumber=function(){
var num = 0;
var index = _.getRandom(1,6);//这样取出来的就是1-5 之间的随机数
//因为2和4出现的概率是1比4,所以如果index是1,则创建数字4,否则创建数字2(1被随机出来的概率就是1/5,而其他就是4/5 就是1:4的关系)
console.log('index==='+index)
if(index==1){
num = 4;
}else {
num = 2;
}
//判断如果格子已经满了,则不再获取,退出
if(this.cardFull()){
return ;
}
//获取随机卡片,不为空的
var card = this.getRandomCard();
//给card对象设置数字
if(card!=null){
card.num=num;
}
}
//获取随机卡片,不为空的
G2048.prototype.getRandomCard=function(){
var i = _.getRandom(0,4);
var j = _.getRandom(0,4);
var card = this.cards[i][j];
if(card.num==0){//如果是空白的卡片,则找到了,直接返回
return card;
}
//没找到空白的,就递归,继续寻找
return this.getRandomCard();
}
//判断格子满了
G2048.prototype.cardFull=function() {
var card;
for (var i = 0; i < 4; i++) {
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
if(card.num==0){//有一个为空,则没满
return false;
}
}
}
return true;
}

draw方法中调用,表示打开游戏默认一个数字
在这里插入图片描述
运行效果:
在这里插入图片描述


加入键盘事件


同样要在draw方法中调用哦


	//按键的控制
G2048.prototype.control=function(){
var that=this;
global.addEventListener('keydown',function(e){
console.log(that.flag)
if(that.flag!='start') return ;
var dir;
switch (e.keyCode){
case 87://w
case 38://上
dir=1;//上移动
break;
case 68://d
case 39://右
dir=2;//右移动
break;
case 83://s
case 40://下
dir=3;//下移动
break;
case 65://a
case 37://左
dir=4;//左移动
break;
}
//卡片移动的方法
that.moveCard(dir);
});
}


  1. 加入移动逻辑处理代码


//卡片移动的方法
G2048.prototype.moveCard=function(dir) {
//将卡片清理一遍,因为每轮移动会设定合并标记,需重置
this.clearCard();

if(dir==1){//向上移动
this.moveCardTop(true);
}else if(dir==2){//向右移动
this.moveCardRight(true);
}else if(dir==3){//向下移动
this.moveCardBottom(true);
}else if(dir==4){//向左移动
this.moveCardLeft(true);
}
//移动后要创建新的卡片
this.createRandomNumber();
//重绘
this.render();
//判断游戏是否结束
this.gameOverOrNot();
}

//将卡片清理一遍,因为每轮移动会设定合并标记,需重置
G2048.prototype.clearCard=function() {
var card;
for (var i = 0; i < 4; i++) {//i从1开始,因为i=0不需要移动
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
card.merge=false;
}
}
}


  1. 加入上下左右处理逻辑


//向上移动
G2048.prototype.moveCardTop=function(bool) {
var res = false;
var card;
for (var i = 1; i < 4; i++) {//i从1开始,因为i=0不需要移动
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveTop(this.cards,bool)){//向上移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}
//向右移动
G2048.prototype.moveCardRight=function(bool) {
var res = false;
var card;
for (var i = 0; i < 4; i++) {
for (var j = 3; j >=0 ; j--) {//j从COLS-1开始,从最右边开始移动递减
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveRight(this.cards,bool)){//向右移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}

//向下移动
G2048.prototype.moveCardBottom=function(bool) {
var res = false;
var card;
for (var i = 3; i >=0; i--) {//i从ROWS-1开始,往下递减移动
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveBottom(this.cards,bool)){//下移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}

//向左移动
G2048.prototype.moveCardLeft=function(bool) {
var res = false;
var card;
for (var i = 0; i < 4; i++) {
for (var j = 1; j < 4 ; j++) {//j从1开始,从最左边开始移动
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveLeft(this.cards,bool)){//向左移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}


  1. 在Card中加入向上移动的处理逻辑




  1. 从第2行开始移动,因为第一行不需要移动。

  2. 只要卡片的数字不是0,就表示要移动。

  3. 根据 i-1 可以获取到上一个卡片,如果上一个卡片是空,则把当前卡片交换上去,并且递归,因为可能要继续往上移动。

  4. 如果当前卡片与上一个卡片是相同数字的,则要合并。

  5. 以上两种都不是,则不做操作。



//卡片向上移动
Card.prototype.moveTop=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(i==0){//已经是最上面了
return false;
}
//上面一个卡片
var prev = cards[i-1][j];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveTop(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}
}

在这里插入图片描述



  1. 在Card中加入其他3个方向的代码


//向下移动
Card.prototype.moveBottom=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(i==3){//已经是最下面了
return false;
}
//上面一个卡片
var prev = cards[i+1][j];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveBottom(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}


}
//向右移动
Card.prototype.moveRight=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(j==3){//已经是最右边了
return false;
}
//上面一个卡片
var prev = cards[i][j+1];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveRight(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}
}
//向左移动
Card.prototype.moveLeft=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(j==0){//已经是最左边了
return false;
}
//上面一个卡片
var prev = cards[i][j-1];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveLeft(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}
}

运行效果:
在这里插入图片描述


做到这里就基本完成了,加入其他一下辅助的东西就行了,比如重新开始、游戏胜利,游戏结束等,也就不多说了。


收起阅读 »

js 实现以鼠标位置为中心滚轮缩放图片

前言 不知道各位前端小伙伴蓝湖使用的多不多,反正我是经常在用,ui将原型图设计好后上传至蓝湖,前端开发人人员就可以开始静态页面的的编写了。对于页面细节看的不是很清楚可以使用滚轮缩放后再拖拽查看,还是很方便的。于是就花了点时间研究了一下。今天分享给大家。 实现 ...
继续阅读 »

前言


不知道各位前端小伙伴蓝湖使用的多不多,反正我是经常在用,ui将原型图设计好后上传至蓝湖,前端开发人人员就可以开始静态页面的的编写了。对于页面细节看的不是很清楚可以使用滚轮缩放后再拖拽查看,还是很方便的。于是就花了点时间研究了一下。今天分享给大家。


实现


HTML


<div class="container">
<img id="image" alt="">
</div>
<div class="log"></div>

js


设置图片宽高且居中展示


// 获取dom
const container = document.querySelector('.container');
const image = document.getElementById('image');
const log = document.querySelector('.log');
// 全局变量
let result,
x,
y,
scale = 1,
isPointerdown = false, // 按下标识
point = { x: 0, y: 0 }, // 第一个点坐标
diff = { x: 0, y: 0 }, // 相对于上一次pointermove移动差值
lastPointermove = { x: 0, y: 0 }; // 用于计算diff
// 图片加载完成后再绑定事件
image.addEventListener('load', function () {
result = getImgSize(image.naturalWidth, image.naturalHeight, window.innerWidth, window.innerHeight);
image.style.width = result.width + 'px';
image.style.height = result.height + 'px';
x = (window.innerWidth - result.width) * 0.5;
y = (window.innerHeight - result.height) * 0.5;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(1)';
// 拖拽查看
drag();
// 滚轮缩放
wheelZoom();
});
image.src = '../images/liya.jpg';
/**
* 获取图片缩放尺寸
* @param {number} naturalWidth
* @param {number} naturalHeight
* @param {number} maxWidth
* @param {number} maxHeight
* @returns
*/
function getImgSize(naturalWidth, naturalHeight, maxWidth, maxHeight) {
const imgRatio = naturalWidth / naturalHeight;
const maxRatio = maxWidth / maxHeight;
let width, height;
// 如果图片实际宽高比例 >= 显示宽高比例
if (imgRatio >= maxRatio) {
if (naturalWidth > maxWidth) {
width = maxWidth;
height = maxWidth / naturalWidth * naturalHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
} else {
if (naturalHeight > maxHeight) {
width = maxHeight / naturalHeight * naturalWidth;
height = maxHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
}
return { width: width, height: height }
}

拖拽查看图片逻辑


// 拖拽查看
function drag() {
// 绑定 pointerdown
image.addEventListener('pointerdown', function (e) {
isPointerdown = true;
image.setPointerCapture(e.pointerId);
point = { x: e.clientX, y: e.clientY };
lastPointermove = { x: e.clientX, y: e.clientY };
});
// 绑定 pointermove
image.addEventListener('pointermove', function (e) {
if (isPointerdown) {
const current1 = { x: e.clientX, y: e.clientY };
diff.x = current1.x - lastPointermove.x;
diff.y = current1.y - lastPointermove.y;
lastPointermove = { x: current1.x, y: current1.y };
x += diff.x;
y += diff.y;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
log.innerHTML = `x = ${x.toFixed(0)}<br>y = ${y.toFixed(0)}<br>scale = ${scale.toFixed(5)}`;
}
e.preventDefault();
});
// 绑定 pointerup
image.addEventListener('pointerup', function (e) {
if (isPointerdown) {
isPointerdown = false;
}
});
// 绑定 pointercancel
image.addEventListener('pointercancel', function (e) {
if (isPointerdown) {
isPointerdown = false;
}
});
}

滚轮缩放逻辑


// 滚轮缩放
function wheelZoom() {
container.addEventListener('wheel', function (e) {
let ratio = 1.1;
// 缩小
if (e.deltaY > 0) {
ratio = 0.9;
}
// 目标元素是img说明鼠标在img上,以鼠标位置为缩放中心,否则默认以图片中心点为缩放中心
if (e.target.tagName === 'IMG') {
const origin = {
x: (ratio - 1) * result.width * 0.5,
y: (ratio - 1) * result.height * 0.5
};
// 计算偏移量
x -= (ratio - 1) * (e.clientX - x) - origin.x;
y -= (ratio - 1) * (e.clientY - y) - origin.y;
}
scale *= ratio;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
log.innerHTML = `x = ${x.toFixed(0)}<br>y = ${y.toFixed(0)}<br>scale = ${scale.toFixed(5)}`;
e.preventDefault();
});
}

Demo:jsdemo.codeman.top/html/wheelZ…



链接:https://juejin.cn/post/7009892447211749406

收起阅读 »

深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别

vue
因为 Diff 算法,计算的就是虚拟 DOM 的差异,所以先铺垫一点点虚拟 DOM,了解一下其结构,再来一层层揭开 Diff 算法的面纱,深入浅出,助你彻底弄懂 Diff 算法原理 认识虚拟 DOM 虚拟 DOM 简单说就是 用JS对象来模拟 DOM 结构 那...
继续阅读 »

因为 Diff 算法,计算的就是虚拟 DOM 的差异,所以先铺垫一点点虚拟 DOM,了解一下其结构,再来一层层揭开 Diff 算法的面纱,深入浅出,助你彻底弄懂 Diff 算法原理


认识虚拟 DOM


虚拟 DOM 简单说就是 用JS对象来模拟 DOM 结构


那它是怎么用 JS 对象模拟 DOM 结构的呢?看个例子


<template>
<div id="app" class="container">
<h1>沐华</h1>
</div>
</template>

上面的模板转在虚拟 DOM 就是下面这样的


{
'div',
props:{ id:'app', class:'container' },
children: [
{ tag: 'h1', children:'沐华' }
]
}

这样的 DOM 结构就称之为 虚拟 DOM (Virtual Node),简称 vnode


它的表达方式就是把每一个标签都转为一个对象,这个对象可以有三个属性:tagpropschildren



  • tag:必选。就是标签。也可以是组件,或者函数

  • props:非必选。就是这个标签上的属性和方法

  • children:非必选。就是这个标签的内容或者子节点,如果是文本节点就是字符串,如果有子节点就是数组。换句话说 如果判断 children 是字符串的话,就表示一定是文本节点,这个节点肯定没有子元素


为什么要使用虚拟 DOM 呢? 看个图


image.png


如图可以看出原生 DOM 有非常多的属性和事件,就算是创建一个空div也要付出不小的代价。而使用虚拟 DOM 来提升性能的点在于 DOM 发生变化的时候,通过 diff 算法和数据改变前的 DOM 对比,计算出需要更改的 DOM,然后只对变化的 DOM 进行操作,而不是更新整个视图


在 Vue 中是怎么把 DOM 转成上面这样的虚拟 DOM 的呢,有兴趣的可以关注我另一篇文章详细了解一下 Vue 中的模板编译过程和原理


在 Vue 里虚拟 DOM 的数据更新机制采用的是异步更新队列,就是把变更后的数据变装入一个数据更新的异步队列,就是 patch,用它来做新老 vnode 对比


认识 Diff 算法


Diff 算法,在 Vue 里面就是叫做 patch ,它的核心就是参考 Snabbdom,通过新旧虚拟 DOM 对比(即 patch 过程),找出最小变化的地方转为进行 DOM 操作



扩展

在 Vue1 里是没有 patch 的,每个依赖都有单独的 Watcher 负责更新,当项目规模变大的时候性能就跟不上了,所以在 Vue2 里为了提升性能,改为每个组件只有一个 Watcher,那我们需要更新的时候,怎么才能精确找到组件里发生变化的位置呢?所以 patch 它来了



那么它是在什么时候执行的呢?


在页面首次渲染的时候会调用一次 patch 并创建新的 vnode,不会进行更深层次的比较


然后是在组件中数据发生变化时,会触发 setter 然后通过 Notify 通知 Watcher,对应的 Watcher 会通知更新并执行更新函数,它会执行 render 函数获取新的虚拟 DOM,然后执行 patch 对比上次渲染结果的老的虚拟 DOM,并计算出最小的变化,然后再去根据这个最小的变化去更新真实的 DOM,也就是视图


那么它是怎么计算的? 先看个图


diff.jpg


比如有上图这样的 DOM 结构,是怎么计算出变化?简单说就是



  • 遍历老的虚拟 DOM

  • 遍历新的虚拟 DOM

  • 然后根据变化,比如上面的改变和新增,再重新排序


可是这样会有很大问题,假如有1000个节点,就需要计算 1000³ 次,也就是10亿次,这样是无法让人接受的,所以 Vue 或者 React 里使用 Diff 算法的时候都遵循深度优先,同层比较的策略做了一些优化,来计算出最小变化


Diff 算法的优化


1. 只比较同一层级,不跨级比较


如图,Diff 过程只会把同颜色框起来的同一层级的 DOM 进行比较,这样来简化比较次数,这是第一个方面


diff1.jpg


2. 比较标签名


如果同一层级的比较标签名不同,就直接移除老的虚拟 DOM 对应的节点,不继续按这个树状结构做深度比较,这是简化比较次数的第二个方面


diff2.jpg


3. 比较 key


如果标签名相同,key 也相同,就会认为是相同节点,也不继续按这个树状结构做深度比较,比如我们写 v-for 的时候会比较 key,不写 key 就会报错,这也就是因为 Diff 算法需要比较 key


面试中有一道特别常见的题,就是让你说一下 key 的作用,实际上考查的就是大家对虚拟 DOM 和 patch 细节的掌握程度,能够反应出我们面试者的理解层次,所以这里扩展一下 key


key 的作用


比如有一个列表,我们需要在中间插入一个元素,会发生什么变化呢?先看个图


diff3.jpg


如图的 li1li2 不会重新渲染,这个没有争议的。而 li3、li4、li5 都会重新渲染


因为在不使用 key 或者列表的 index 作为 key 的时候,每个元素对应的位置关系都是 index,上图中的结果直接导致我们插入的元素到后面的全部元素,对应的位置关系都发生了变更,所以全部都会执行更新操作,这可不是我们想要的,我们希望的是渲染添加的那一个元素,其他四个元素不做任何变更,也就不要重新渲染


而在使用唯一 key 的情况下,每个元素对应的位置关系就是 key,来看一下使用唯一 key 值的情况下


diff4.jpg


这样如图中的 li3li4 就不会重新渲染,因为元素内容没发生改变,对应的位置关系也没有发生改变。


这也是为什么 v-for 必须要写 key,而且不建议开发中使用数组的 index 作为 key 的原因


总结一下:



  • key 的作用主要是为了更高效的更新虚拟 DOM,因为它可以非常精确的找到相同节点,因此 patch 过程会非常高效

  • Vue 在 patch 过程中会判断两个节点是不是相同节点时,key 是一个必要条件。比如渲染列表时,如果不写 key,Vue 在比较的时候,就可能会导致频繁更新元素,使整个 patch 过程比较低效,影响性能

  • 应该避免使用数组下标作为 key,因为 key 值不是唯一的话可能会导致上面图中表示的 bug,使 Vue 无法区分它他,还有比如在使用相同标签元素过渡切换的时候,就会导致只替换其内部属性而不会触发过渡效果

  • 从源码里可以知道,Vue 判断两个节点是否相同时主要判断两者的元素类型和 key 等,如果不设置 key,就可能永远认为这两个是相同节点,只能去做更新操作,就造成大量不必要的 DOM 更新操作,明显是不可取的


有兴趣的可以去看一下源码:src\core\vdom\patch.js -35行 sameVnode(),下面也有详细介绍


Diff 算法核心原理——源码


上面说了Diff 算法,在 Vue 里面就是 patch,铺垫了这么多,下面进入源码里看一下这个神乎其神的 patch 干了啥?


patch


其实 patch 就是一个函数,我们先介绍一下源码里的核心流程,再来看一下 patch 的源码,源码里每一行也有注释


它可以接收四个参数,主要还是前两个



  • oldVnode:老的虚拟 DOM 节点

  • vnode:新的虚拟 DOM 节点

  • hydrating:是不是要和真实 DOM 混合,服务端渲染的话会用到,这里不过多说明

  • removeOnly:transition-group 会用到,这里不过多说明


主要流程是这样的:



  • vnode 不存在,oldVnode 存在,就删掉 oldVnode

  • vnode 存在,oldVnode 不存在,就创建 vnode

  • 两个都存在的话,通过 sameVnode 函数(后面有详解)对比是不是同一节点

    • 如果是同一节点的话,通过 patchVnode 进行后续对比节点文本变化或子节点变化

    • 如果不是同一节点,就把 vnode 挂载到 oldVnode 的父元素下

      • 如果组件的根节点被替换,就遍历更新父节点,然后删掉旧的节点

      • 如果是服务端渲染就用 hydrating 把 oldVnode 和真实 DOM 混合






下面看完整的 patch 函数源码,说明我都写在注释里了


源码地址:src\core\vdom\patch.js -700行


// 两个判断函数
function isUndef (v: any): boolean %checks {
return v === undefined || v === null
}
function isDef (v: any): boolean %checks {
return v !== undefined && v !== null
}
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新的 vnode 不存在,但是 oldVnode 存在
if (isUndef(vnode)) {
// 如果 oldVnode 存在,调用 oldVnode 的组件卸载钩子 destroy
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}

let isInitialPatch = false
const insertedVnodeQueue = []

// 如果 oldVnode 不存在的话,新的 vnode 是肯定存在的,比如首次渲染的时候
if (isUndef(oldVnode)) {
isInitialPatch = true
// 就创建新的 vnode
createElm(vnode, insertedVnodeQueue)
} else {
// 剩下的都是新的 vnode 和 oldVnode 都存在的话

// 是不是元素节点
const isRealElement = isDef(oldVnode.nodeType)
// 是元素节点 && 通过 sameVnode 对比是不是同一个节点 (函数后面有详解)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 如果是 就用 patchVnode 进行后续对比 (函数后面有详解)
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 如果不是同一元素节点的话
if (isRealElement) {
// const SSR_ATTR = 'data-server-rendered'
// 如果是元素节点 并且有 'data-server-rendered' 这个属性
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
// 就是服务端渲染的,删掉这个属性
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
// 这个判断里是服务端渲染的处理逻辑,就是混合
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
warn('这是一段很长的警告信息')
}
}
// function emptyNodeAt (elm) {
// return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
// }
// 如果不是服务端渲染的,或者混合失败,就创建一个空的注释节点替换 oldVnode
oldVnode = emptyNodeAt(oldVnode)
}

// 拿到 oldVnode 的父节点
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

// 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)

// 如果新的 vnode 的根节点存在,就是说根节点被修改了,就需要遍历更新父节点
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
// 递归更新父节点下的元素
while (ancestor) {
// 卸载老根节点下的全部组件
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
// 替换现有元素
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
const insert = ancestor.data.hook.insert
if (insert.merged) {
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
// 更新父节点
ancestor = ancestor.parent
}
}
// 如果旧节点还存在,就删掉旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
// 否则直接卸载 oldVnode
invokeDestroyHook(oldVnode)
}
}
}
// 返回更新后的节点
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}

sameVnode


这个是用来判断是不是同一节点的函数


这个函数不长,直接看源码吧


源码地址:src\core\vdom\patch.js -35行


function sameVnode (a, b) {
return (
a.key === b.key && // key 是不是一样
a.asyncFactory === b.asyncFactory && ( // 是不是异步组件
(
a.tag === b.tag && // 标签是不是一样
a.isComment === b.isComment && // 是不是注释节点
isDef(a.data) === isDef(b.data) && // 内容数据是不是一样
sameInputType(a, b) // 判断 input 的 type 是不是一样
) || (
isTrue(a.isAsyncPlaceholder) && // 判断区分异步组件的占位符否存在
isUndef(b.asyncFactory.error)
)
)
)
}

patchVnode


源码地址:src\core\vdom\patch.js -501行


这个是在新的 vnode 和 oldVnode 是同一节点的情况下,才会执行的函数,主要是对比节点文本变化或子节点变化


还是先介绍一下主要流程,再看源码吧,流程是这样的:



  • 如果 oldVnode 和 vnode 的引用地址是一样的,就表示节点没有变化,直接返回

  • 如果 oldVnode 的 isAsyncPlaceholder 存在,就跳过异步组件的检查,直接返回

  • 如果 oldVnode 和 vnode 都是静态节点,并且有一样的 key,并且 vnode 是克隆节点或者 v-once 指令控制的节点时,把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上,然后返回

  • 如果 vnode 不是文本节点也不是注释的情况下

    • 如果 vnode 和 oldVnode 都有子节点,而且子节点不一样的话,就调用 updateChildren 更新子节点

    • 如果只有 vnode 有子节点,就调用 addVnodes 创建子节点

    • 如果只有 oldVnode 有子节点,就调用 removeVnodes 删除该子节点

    • 如果 vnode 文本为 undefined,就删掉 vnode.elm 文本



  • 如果 vnode 是文本节点但是和 oldVnode 文本内容不一样,就更新文本


  function patchVnode (
oldVnode, // 老的虚拟 DOM 节点
vnode, // 新的虚拟 DOM 节点
insertedVnodeQueue, // 插入节点的队列
ownerArray, // 节点数组
index, // 当前节点的下标
removeOnly // 只有在
) {
// 新老节点引用地址是一样的,直接返回
// 比如 props 没有改变的时候,子组件就不做渲染,直接复用
if (oldVnode === vnode) return

// 新的 vnode 真实的 DOM 元素
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}

const elm = vnode.elm = oldVnode.elm
// 如果当前节点是注释或 v-if 的,或者是异步函数,就跳过检查异步组件
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// 当前节点是静态节点的时候,key 也一样,或者有 v-once 的时候,就直接赋值返回
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
// hook 相关的不用管
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// 获取子元素列表
const oldCh = oldVnode.children
const ch = vnode.children

if (isDef(data) && isPatchable(vnode)) {
// 遍历调用 update 更新 oldVnode 所有属性,比如 class,style,attrs,domProps,events...
// 这里的 update 钩子函数是 vnode 本身的钩子函数
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
// 这里的 update 钩子函数是我们传过来的函数
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
// 如果新节点不是文本节点,也就是说有子节点
if (isUndef(vnode.text)) {
// 如果新老节点都有子节点
if (isDef(oldCh) && isDef(ch)) {
// 如果新老节点的子节点不一样,就执行 updateChildren 函数,对比子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 如果新节点有子节点的话,就是说老节点没有子节点

// 如果老节点文本节点,就是说没有子节点,就清空
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
// 添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 如果新节点没有子节点,老节点有子节点,就删除
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 如果老节点是文本节点,就清空
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 新老节点都是文本节点,且文本不一样,就更新文本
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
// 执行 postpatch 钩子
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}

updateChildren


源码地址:src\core\vdom\patch.js -404行


这个是新的 vnode 和 oldVnode 都有子节点,且子节点不一样的时候进行对比子节点的函数


这里很关键,很关键!


比如现在有两个子节点列表对比,对比主要流程如下


循环遍历两个列表,循环停止条件是:其中一个列表的开始指针 startIdx 和 结束指针 endIdx 重合


循环内容是:{



  • 新的头和老的头对比

  • 新的尾和老的尾对比

  • 新的头和老的尾对比

  • 新的尾和老的头对比。 这四种对比如图


diff2.gif


以上四种只要有一种判断相等,就调用 patchVnode 对比节点文本变化或子节点变化,然后移动对比的下标,继续下一轮循环对比


如果以上四种情况都没有命中,就不断拿新的开始节点的 key 去老的 children 里找



  • 如果没找到,就创建一个新的节点

  • 如果找到了,再对比标签是不是同一个节点

    • 如果是同一个节点,就调用 patchVnode 进行后续对比,然后把这个节点插入到老的开始前面,并且移动新的开始下标,继续下一轮循环对比

    • 如果不是相同节点,就创建一个新的节点




}



  • 如果老的 vnode 先遍历完,就添加新的 vnode 没有遍历的节点

  • 如果新的 vnode 先遍历完,就删除老的 vnode 没有遍历的节点


为什么会有头对尾,尾对头的操作?


因为可以快速检测出 reverse 操作,加快 Diff 效率


function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // 老 vnode 遍历的下标
let newStartIdx = 0 // 新 vnode 遍历的下标
let oldEndIdx = oldCh.length - 1 // 老 vnode 列表长度
let oldStartVnode = oldCh[0] // 老 vnode 列表第一个子元素
let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最后一个子元素
let newEndIdx = newCh.length - 1 // 新 vnode 列表长度
let newStartVnode = newCh[0] // 新 vnode 列表第一个子元素
let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最后一个子元素
let oldKeyToIdx, idxInOld, vnodeToMove, refElm

const canMove = !removeOnly

// 循环,规则是开始指针向右移动,结束指针向左移动移动
// 当开始和结束的指针重合的时候就结束循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]

// 老开始和新开始对比
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 是同一节点 递归调用 继续对比这两个节点的内容和子节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 然后把指针后移一位,从前往后依次对比
// 比如第一次对比两个列表的[0],然后比[1]...,后面同理
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

// 老结束和新结束对比
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 然后把指针前移一位,从后往前比
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]

// 老开始和新结束对比
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 老的列表从前往后取值,新的列表从后往前取值,然后对比
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]

// 老结束和新开始对比
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// 老的列表从后往前取值,新的列表从前往后取值,然后对比
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]

// 以上四种情况都没有命中的情况
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

// 新的 children 里有,可是没有在老的 children 里找到对应的元素
if (isUndef(idxInOld)) {
/// 就创建新的元素
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 在老的 children 里找到了对应的元素
vnodeToMove = oldCh[idxInOld]
// 判断标签如果是一样的
if (sameVnode(vnodeToMove, newStartVnode)) {
// 就把两个相同的节点做一个更新
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 如果标签是不一样的,就创建新的元素
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// oldStartIdx > oldEndIdx 说明老的 vnode 先遍历完
if (oldStartIdx > oldEndIdx) {
// 就添加从 newStartIdx 到 newEndIdx 之间的节点
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)

// 否则就说明新的 vnode 先遍历完
} else if (newStartIdx > newEndIdx) {
// 就删除掉老的 vnode 里没有遍历的节点
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}

至此,整个 Diff 流程的核心逻辑源码到这就结束了,再来看一下 Vue 3 里做了哪些改变吧


Vue3 的优化


本文源码版本是 Vue2 的,在 Vue3 里整个重写了 Diff 算法这一块东西,所以源码的话可以说基本是完全不一样的,但是要做的事还是一样的


关于 Vue3 的 Diff 完整源码解析还在撰稿中,过几天就发布了,这里先介绍一下相比 Vue2 优化的部分,尤大公布的数据就是 update 性能提升了 1.3~2 倍ssr 性能提升了 2~3 倍,来看看都有哪些优化



  • 事件缓存:将事件缓存,可以理解为变成静态的了

  • 添加静态标记:Vue2 是全量 Diff,Vue3 是静态标记 + 非全量 Diff

  • 静态提升:创建静态节点时保存,后续直接复用

  • 使用最长递增子序列优化了对比流程:Vue2 里在 updateChildren() 函数里对比变更,在 Vue3 里这一块的逻辑主要在 patchKeyedChildren() 函数里,具体看下面


事件缓存


比如这样一个有点击事件的按钮


<button @click="handleClick">按钮</button>

来看下在 Vue3 被编译后的结果


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
}, "按钮"))
}

注意看,onClick 会先读取缓存,如果缓存没有的话,就把传入的事件存到缓存里,都可以理解为变成静态节点了,优秀吧,而在 Vue2 中就没有缓存,就是动态的


静态标记


看一下静态标记是啥?


源码地址:packages/shared/src/patchFlags.ts


export const enum PatchFlags {
TEXT = 1 , // 动态文本节点
CLASS = 1 << 1, // 2 动态class
STYLE = 1 << 2, // 4 动态style
PROPS = 1 << 3, // 8 除去class/style以外的动态属性
FULL_PROPS = 1 << 4, // 16 有动态key属性的节点,当key改变时,需进行完整的diff比较
HYDRATE_EVENTS = 1 << 5, // 32 有监听事件的节点
STABLE_FRAGMENT = 1 << 6, // 64 一个不会改变子节点顺序的fragment (一个组件内多个根元素就会用fragment包裹)
KEYED_FRAGMENT = 1 << 7, // 128 带有key属性的fragment或部分子节点有key
UNKEYEN_FRAGMENT = 1 << 8, // 256 子节点没有key的fragment
NEED_PATCH = 1 << 9, // 512 一个节点只会进行非props比较
DYNAMIC_SLOTS = 1 << 10, // 1024 动态slot
HOISTED = -1, // 静态节点
BAIL = -2 // 表示 Diff 过程中不需要优化
}

先了解一下静态标记有什么用?看个图


在什么地方用到的呢?比如下面这样的代码


<div id="app">
<div>沐华</div>
<p>{{ age }}</p>
</div>

在 Vue2 中编译的结果是,有兴趣的可以自行安装 vue-template-compiler 自行测试


with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[
_c('div',[_v("沐华")]),
_c('p',[_v(_s(age))])
]
)
}

在 Vue3 中编译的结果是这样的,有兴趣的可以点击这里自行测试


const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
]))
}

看到上面编译结果中的 -11 了吗,这就是静态标记,这是在 Vue2 中没有的,patch 过程中就会判断这个标记来 Diff 优化流程,跳过一些静态节点对比


静态提升


其实还是拿上面 Vue2 和 Vue3 静态标记的例子,在 Vue2 里每当触发更新的时候,不管元素是否参与更新,每次都会全部重新创建,就是下面这一堆


with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[
_c('div',[_v("沐华")]),
_c('p',[_v(_s(age))])
]
)
}

而在 Vue3 中会把这个不参与更新的元素保存起来,只创建一次,之后在每次渲染的时候不停地复用,比如上面例子中的这个,静态的创建一次保存起来


const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

然后每次更新 age 的时候,就只创建这个动态的内容,复用上面保存的静态内容


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
]))
}

patchKeyedChildren


在 Vue2 里 updateChildren 会进行



  • 头和头比

  • 尾和尾比

  • 头和尾比

  • 尾和头比

  • 都没有命中的对比


在 Vue3 里 patchKeyedChildren



  • 头和头比

  • 尾和尾比

  • 基于最长递增子序列进行移动/添加/删除


看个例子,比如



  • 老的 children:[ a, b, c, d, e, f, g ]

  • 新的 children:[ a, b, f, c, d, e, h, g ]



  1. 先进行头和头比,发现不同就结束循环,得到 [ a, b ]

  2. 再进行尾和尾比,发现不同就结束循环,得到 [ g ]

  3. 再保存没有比较过的节点 [ f, c, d, e, h ],并通过 newIndexToOldIndexMap 拿到在数组里对应的下标,生成数组 [ 5, 2, 3, 4, -1 ]-1 是老数组里没有的就说明是新增

  4. 然后再拿取出数组里的最长递增子序列,也就是 [ 2, 3, 4 ] 对应的节点 [ c, d, e ]

  5. 然后只需要把其他剩余的节点,基于 [ c, d, e ] 的位置进行移动/新增/删除就可以了

作者:沐华
链接:https://juejin.cn/post/7010594233253888013

收起阅读 »

Vue首屏加载优化之使用CND资源

背景 vue项目线上首屏加载速度非常慢,查看网络中加载的资源文件发现main.js文件大小为3.6MB,加载速度也是高达6.5s,已经严重影响了用户的体验效果。经过查看发现项目本地打包后main.js大小也是高达三十多兆,为了减少main.js文件打包后的大...
继续阅读 »

背景



vue项目线上首屏加载速度非常慢,查看网络中加载的资源文件发现main.js文件大小为3.6MB,加载速度也是高达6.5s,已经严重影响了用户的体验效果。经过查看发现项目本地打包后main.js大小也是高达三十多兆,为了减少main.js文件打包后的大小,查阅了众多经验文章后,发现使用CDN替代package引入后,体积可以大大减少。



建议


像echarts这种比较大的库,不要挂载比较大的库,一般使用到的地方不多按需加载就行。


使用CND资源


进入正题,这里修改了vue、vue-router、vuex、element-ui和mint-ui。



  • 首先修改模板文件index.html注意对应之前版本号。


<head> 
...
<!-- element-ui 组件引入样式 -->
<link rel="stylesheet" href="https://cdn.bootcss.com/element-ui/2.5.4/theme-chalk/index.css">
<!-- mint-ui 组件引入样式 -->
<link rel="stylesheet" href="https://cdn.bootcss.com/mint-ui/2.2.13/style.css">
</head>
<body>
<!-- 引入vue -->
<script src="https://cdn.bootcss.com/vue/2.5.2/vue.min.js"></script>
<!-- 引入vuex -->
<script src="https://cdn.bootcss.com/vuex/3.0.1/vuex.min.js"></script>
<!-- 引入vue-router -->
<script src="https://cdn.bootcss.com/vue-router/3.0.1/vue-router.min.js"></script>
<!-- 引入element-ui组件库 -->
<script src="https://cdn.bootcss.com/element-ui/2.5.4/index.js"></script>
<!-- 引入mint-ui组件库 -->
<script src="https://cdn.bootcss.com/mint-ui/2.2.13/index.js"></script>
<div id="app"></div>
</body>


  • 修改 build/webpack.base.conf.js。配置 externals 


/ * 说明:由于本项目是vue-cl2搭建,并有一个node中间层,所以我修改的是webpack.client.config.js文件*/
module.exports = {
...
externals: {
// CDN 的 Element 依赖全局变量 Vue, 所以 Vue 也需要使用 CDN 引入
'vue': 'Vue',
'vuex': 'Vuex',
'vue-router': 'VueRouter',
// 属性名称 element-ui, 表示遇到 import xxx from 'element-ui' 这类引入 'element-ui'的,
// 不去 node_modules 中找,而是去找 全局变量 ELEMENT
'element-ui': 'ELEMENT',
'mint-ui': 'MINT',
},
...
}


  • 修改 src/router/index.js


// 原来的样子
import Router from "vue-router";
Vue.use(Router);
const originalPush = Router.prototype.push
Router.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
const router = new Router({})

// 修改后的样子
import VueRouter from "vue-router";
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
const router = new VueRouter({})

// 总结
1、由于我们在externals中定义的vue-router的名字是‘VueRouter’,所以我们需要使用VueRouter来接收 import VueRouter from "vue-router";
2、注释掉 Vue.use(Router)


  • 修改 src/store/index.js


... 
// 注释掉
// Vue.use(Vuex)
...


  • 修改 src/main.js


/* 原来的样子 */
import Vue from "vue";
import App from "./App.vue";
import router from "./router";

// mint-ui
import MintUI from 'mint-ui'
import 'mint-ui/lib/style.css'
Vue.use(MintUI);
// element-ui
import ElementUi from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUi);

new Vue({
render: h => h(App),
router,
store
}).$mount("#app");


/* 修改之后的样子 */
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import {sync} from 'vuex-router-sync' // 这里使用了vuex-router-sync工具 作用:是将`vue-router`的状态同步到`vuex`中

// mint-ui
import MINT from 'mint-ui'
Vue.use(MINT);
// element-ui
import ELEMENT from 'element-ui'
Vue.use(ELEMENT);

sync(store, router)

new Vue({
render: h => h(App),
router,
store
}).$mount("#app");

// 总结:
1、element-ui 和 mint-ui 的变量名要使用 ELEMENT 和 MINT,在配置externals时有。

这样操作之后,重新打包一下可以发现,main.js文件大小已经减小到了12MB,当然这也和main.js我文件里引入其他东西的缘故,最后打开页面的时间也是得到了减少,这边文章作为一个记录和简单的介绍,希望能够给你带来帮助。


链接:https://juejin.cn/post/7009120766465687588

收起阅读 »

3~5年前端开发面经

前言 终于要从宁波去杭州了,经过从8月份结束面试到现在,中秋过完之后就要入职了。提完离职之后,差不多闲了1个月。 今天难得地放下游戏,回忆下面试题,希望能帮助到大家。杭州的大厂几乎面了个遍,阿里,蚂蚁,网易,字节,华为,有赞,只能按照记忆整理下面试题。 面试内...
继续阅读 »

前言


终于要从宁波去杭州了,经过从8月份结束面试到现在,中秋过完之后就要入职了。提完离职之后,差不多闲了1个月。


今天难得地放下游戏,回忆下面试题,希望能帮助到大家。杭州的大厂几乎面了个遍,阿里,蚂蚁,网易,字节,华为,有赞,只能按照记忆整理下面试题。


面试内容


算法,笔试


1.解析URL


出现得挺高频的,把一个url的query参数,解析成指定格式的对象。


2.对象的合并,key值的转化


出现得也比较多,给你一个对象,也是把它转化成指定的格式。比如把 a_b 这种下划线的key值转化为驼峰 aB,或者给你一个些数据,转化成对象。


比如把 a.b.c 变成 { a: { b: c } }


3.实现vue的双向绑定


4.实现eventListner


5.数组的操作


这个就挺多的,leecode多刷一刷,字节的题感觉都是从leecode找的,一眼看到就直接认出了。。。。。


这个题,难易程度其实相差很多的。有的题很简单,有的题很难。不过碰到的最难的也就是滑动窗口了。因为之前没碰到过类似的题,没有用双指针,磕磕绊绊做出来了,但是挺吃力的。


6.promise的使用


比如把fallback的函数改造成使用promise的。或者使用promise实现输出。这种题真挺烦的,要么不出,一出就挺搞脑子的,主要是绕。


字节对promise真的有偏爱,每个面试官绝对都会问。


笔试总结


虽然每个厂都会考算法,但是总体来说真的不难。最看重算法的应该是华为跟字节吧。


技术面试


技术的内容遇到的题目就很五花八门的,因为每个岗位需要的技能可能也不一样,但是高频出现的题目也是有很多的。


1 webpack的plugin和loader有啥区别,有写过什么loader和plugin吗


这个题真的是被问到无数次了,但是我依旧不知悔改,每次都是,了解过,没写过。不清楚区别,你敢问,我就敢说不知道。


2 打包优化,性能提升


这个也是,我永远都是回答那几个实际会用到的,多了就是不会,我特别反感背面试题,我高考古诗词填空都懒得背,滕王阁序永远只会那一句 落霞与孤鹜齐飞,秋水共长天一色 ,反正高考时候诗词填空错了好几个,让我为了面试去背这种东西 ?


如果是实际中用到了,肯定会记得,但是去硬记,不存在的。


3 promise


没错,promise,永远的噩梦。还有async await。


4 import 和 require


5 原型链, new


6 跨域(cors), http请求


7 XSS 和 CSRF


8 框架原理


业务面试


问一下具体做的业务,业务方向难点。


如果讲到业务中解决了什么困难,或者又使用了新的框架。一定要知其所以然了,再拿出来说。面试官很喜欢在这里,问你是如果决策,为什么要使用,以及原理是什么。


如果只是简单的用一用,就别说了,很有可能一问三不知,心态直接绷不住了。


总结


主要时间也过去一个月。只有一些高频出现的还记得比较清楚,希望对大家有所帮助。


但我还是觉得,背面试题,可能不是太好。除非理解得很深入,不然问起来,可能很容易被听出来是背题的。其实简单想想也是,回答起来切入面很大,又浅又泛经不起推敲的,一下就知道是背题的,大厂的面试官水平一般来说肯定是优于我们的。


就跟上学时候,低头看课外杂志以为老师在讲台上会看不到一样,自欺欺人罢了。


所以嘛,努力工作,努力积累才是硬道理,笔试题或者基础概念题临时抱抱佛脚问题不大,其他的还是积累大于一切吧。


希望大家,能找到心仪的工作。继续打炉石去了~


链接:https://juejin.cn/post/7009153862221168671

收起阅读 »

用 VSCode 调试网页的 JS 代码有多香

相比纯看代码来说,我更推荐结合 debugger 来看,它可以让我们看到代码实际的执行路线,每一个变量的变化。可以大段大段代码跳着看,也可以对某段逻辑一步步的执行来看。 Javascript 代码主要有两个运行环境,一个是 Node.js ,一个是浏览器。一般...
继续阅读 »

相比纯看代码来说,我更推荐结合 debugger 来看,它可以让我们看到代码实际的执行路线,每一个变量的变化。可以大段大段代码跳着看,也可以对某段逻辑一步步的执行来看。


Javascript 代码主要有两个运行环境,一个是 Node.js ,一个是浏览器。一般来说,调试 Node.js 上跑的 JS 代码我会用 VSCode 的 debugger,调试浏览器上的 JS 代码我会用 chrome devtools。直到有一天我发现 VSCode 也能调试浏览器上的的 JS 代码,试了一下,是真的香。


具体有多香呢?我们一起来看一下。


在项目的根目录下有个 .vscode/launch.json 的文件,保存了 VSCode 的调试配置。


我们点击 Add Configuration 按钮添加一个调试 chrome 的配置。



配置是这样的:



url 是网页的地址,我们可以把本地的 dev server 跑起来,然后把地址填在这里。


然后点击 debug 运行:



VSCode 就会起一个 Chrome 浏览器加载该网页,并且在我们的断点处断住。会在左侧面板现实调用栈、作用域的变量等。


最底层当然是 webpack 的入口,我们可以单步调试 webpack 的 runtime 部分。



也可以看下从 render 的流程,比如 ReactDOM.render 到渲染到某个子组件,中间都做了什么。



或者看下某个组件的 hooks 的值是怎么变化的(hooks 的值都存在组件的 fiberNode 的 memerizedState 属性上):


image.png


可以看到,调试 webpack runtime 代码,或者调试 React 源码、或者是业务代码,都很方便。


可能你会说,这个在 chrome devtools 里也可以啊,有啥特别的地方么?


确实,chrome devtools 也能做到一样的事情,但 VSCode 来调试网页代码有两个主要的好处:




  1. 在编辑器里面给代码打断点,还可以边调试边改代码。




  2. 调试 Node.js 的代码和调试网页的代码用同样的工具,经验可以复用,体验也一致。




对于第一点,chrome devtools 的 sources 其实也可以修改代码然后保存,但是毕竟不是专门的编辑器,用它来写代码比较别扭。我个人是比较习惯边 debug 边改代码的,这点 VSCode 胜出。


调试 Node.js 我们一般用 VSCode,而调试网页也可以用 VSCode,那么只要用熟了一个工具就行了,不用再去学 chrome devtools 怎么用,而且用 VSCode 调试体验也更好,毕竟是我们每天都用的编辑器,更顺手,这点也是 VSCode 胜出。


但你可能说那我想看 profile 信息呢? 也就是每个函数的耗时,这对于分析代码性能很重要。


这点 VSCode debugger 也支持了:



点击左侧的按钮,就可以录制一段时间内的耗时信息,可以手动停止、可以指定固定的时间、可以指定到某个断点,这样 3 种方式来选择某一段代码的执行过程记录 profile 信息。


它会在项目根目录保存一个 xxx.cpuprofile 的文件,里面记录了执行每一个函数的耗时,可以层层分析某段代码的耗时,来定位问题从而优化性能。



如果装了 vscode-js-profile-flame 的 VSCode extension 后,还可以换成火焰图的展示。



有的同学可能看不懂火焰图,我来讲一下:


我们知道某个函数的执行路径是有 call stack 的,可以看到从哪个函数一步步调用过来的,是一条线。



但其实这个函数调用的函数并不只一个,可能是多个:



调用栈只是保存了执行到某个函数的一条路线,而火焰图则保存了所有的执行路线。


所以你会在火焰图中看到这样的分叉:



其实就是这样的执行过程:



来算一道题:


函数 A 总耗时 50 ms,它调用的函数 B 耗时 10 ms,它调用的函数 C 耗时 20 ms,问:函数 A 的其余逻辑耗时多少 ms?



很明显可以算出是 50 - 10 - 20= 20 ms,可能你觉得函数 D 耗时太长了,那就去看下具体代码,然后看看是不是可以优化,之后再看下耗时。


就这么简单,profile 的性能分析就是这么做的,简单的加减法。


火焰图中的每个方块的宽度也反应了耗时,所以更直观一些。


JS 引擎是 event loop 的方式不断执行 JS 代码,因为火焰图是反应所有的代码的执行时间,所以会看到每一个 event loop 的代码执行,具体耗时多少。



每个长条的宽度代表了每个 loop 的耗时,那当然是越细越好,这样就不会阻塞渲染了。所以性能优化目标就是让火焰图变成一个个小细条,不能粗了。


绕回正题,VSCode 的 cpu profile 和火焰图相比 chrome devtools 的 performance 其实更简洁易用,可以满足大多数的需求。


我觉得,除非你想看 rendering、memory 这些信息,因为 VSCode 没有支持需要用 chrome devtools 以外,调试 JS 代码,看 profile 信息和火焰图,用 VSCode 足够了。


反正我觉得 VSCode 调试网页的 JS 代码挺香的,你觉得呢?


链接:https://juejin.cn/post/7010768454458277924

收起阅读 »

从最简单的角度走上读源码

1.前言 很早前就想多看一些源码,也看过不少源码的分析,自己简单的去浏览过,但等到想静下心来自己去分析一些的时候,却一直没有时间被搁置,其实可能也是因为自己对相关操作比较陌生,潜意识里又一点抵触。最近想开始行动又在想如何开始,从哪个源码,从什么部分去入手的时候...
继续阅读 »

1.前言


很早前就想多看一些源码,也看过不少源码的分析,自己简单的去浏览过,但等到想静下心来自己去分析一些的时候,却一直没有时间被搁置,其实可能也是因为自己对相关操作比较陌生,潜意识里又一点抵触。最近想开始行动又在想如何开始,从哪个源码,从什么部分去入手的时候,想起若川大佬经常有一些源码的研究,并参加了他发起的源码共读活动,受益良多。本次读的部分是vue3的工具函数,这是若川哥文章的地址juejin.cn/post/699497…


这里从一个源码初学者的角度对这一次共读进行一些记录和总结


2.项目准备


万事开头难,很多时候正是因为没有想好怎样去有一个好的开始,而一直搁置。


先是在若川的引导下,我先去看了vue-next的readme和相关协作文档,其实之前也会看,但没有想法去仔细想一下,并动手去实践起来,虽然是英文的,但是可以先稍微读慢一些


vue-next贡献文档里有写到过,当有一些多个编译方式都要用的方法函数的时候,要写到share模块里,当时我会感觉这是一个很困难的部分,但还是继续去做好了


当对文档有一定的了解之后,开始把vue-next下载到本地进行浏览


git clone https://github.com/vuejs/vue-next.git

cd vue-next

npm install --global yarn

yarn

yarn build

以上流程大家应该都比较熟悉


有个要说的就是,在大家yarn的时候 很有可能也会遇到The engine "node" is incompatible with this module的错误


这是vue-next的代码不久前有一个在engine里对node版本有限制,大家只要把node更新到相应的版本就可以了,用nvm可以很方便的进行。或者用yarn install --ignore-engines 对这个限制进行无视


还有个重要的就是在我们build之后,因为vue-next的代码基本都用ts进行了重构,build完会有一个vue-next/packages/shared/dist/shared.esm-bundler.js 文件,这是对本文件夹ts的js转义输出,这里的文件位置可以在tsconfig里找到。(忽然找到一个一边学源码一边复习ts的好方法!


3.源码调试


在源码调试的时候有一个困难的事情,就是代码经过各种步骤输出后,是没有办法直接调试的,所以我们往往会通过sourceMap去进行帮助,sourcemap是一个记录位置的文件,让我们能在经过巨大变化的代码里找到我们原来开发的样子


这是贡献指南里说提供的:Build with Source Maps Use the --sourcemap or -s flag to build with source maps. Note this will make the build much slower.


所以在 vue-next/package.json 追加 "dev:sourcemap": "node scripts/dev.js --sourcemap",yarn dev:sourcemap执行,即可生成sourcemap,或者直接 build。


然后会在控制台输出类似vue-next/packages/vue/src/index.ts → packages/vue/dist/vue.global.js的信息。


我们在文件里引入这个文件,就会有效果啦~


4.工具代码


上文有说道过,当初觉得这个模块是困难的,但其实真正去看的话,很多写法其实也都是平时会用到的,我们看这类源码,要抛开其他,对一些对我们有帮助的代码写法进行学习。我们从vue-next/packages/shared/src/index.ts开始。


前边的一些其实都是为了更加方便使用和严谨,但其实并不难。但有一些我们平时没有那么常用的方法,其实某些时候也都会有用,至少都应该有印象,比如对象的方法,Object.freeze({}),还有es6的字符串方法Startwith等


还有很有用的是,在学习工具源码的过程中,复习到了一些之前的知识,比如原型链的一些相关


hasOwn:判断一个属性是否属于某个对象


const hasOwnProperty = Object.prototype.hasOwnProperty
export const hasOwn = (
val: object,
key: string | symbol
): key is keyof typeof val => hasOwnProperty.call(val, key)

toRawType:对象转字符串


const objectToString = Object.prototype.toString;
const toTypeString = (value) => objectToString.call(value);
const toRawType = (value) => {
// extract "RawType" from strings like "[object RawType]"
return toTypeString(value).slice(8, -1);
};

这里是三个函数,我把三个放在一起去进行总结,typeof很多时候是不准的,这个时候用这个方法可以进行一些补充


比如可以分出array和普通object


// typeof 返回值目前有以下8种
'undefined'
'object'
'boolean'
'number'
'bigint'
'string'
'symobl'
‘function'

isPromise判断是不是promise


const isPromise = (val) => {
return isObject(val) && isFunction(val.then) && isFunction(val.catch);
};

// 判断是不是Promise对象
const p1 = new Promise(function(resolve, reject){
resolve('');
});
isPromise(p1); // true

之前没有想到这种思路,很简单实用


cacheStringFunction函数缓存


const cacheStringFunction = (fn) => {
const cache = Object.create(null);
return ((str) => {
const hit = cache[str];
return hit || (cache[str] = fn(str));
});
};

5.总结


能写出这篇文章要很感谢若川哥的帮助


这一期的读源码很有收获



  1. 有了开始研究困难源码的信心和方向

  2. 对项目的github文档更加重视并懂得去理解

  3. 学会了通过sourcemap帮助我们调试源码

  4. 学习了vue工具函数的写法,复习了相关知识,并在工作中有意识借鉴


链接:https://juejin.cn/post/7010004468292911118

收起阅读 »

巧用CSS counter属性

前言 你一定遇到过这样的排版,如实现步骤条、给文章编号等。如果写固定的编号,增删步骤或者章节时,后续的编号都需要手动更改。这样会很麻烦。 CSS 提供了计数器功能,可以动态设置编号。 CSS计数器 要实现CSS计数器的,先了解CSS计数器的属性和方法 co...
继续阅读 »

前言


你一定遇到过这样的排版,如实现步骤条、给文章编号等。如果写固定的编号,增删步骤或者章节时,后续的编号都需要手动更改。这样会很麻烦。


image.png
image.png


CSS 提供了计数器功能,可以动态设置编号。


CSS计数器


要实现CSS计数器的,先了解CSS计数器的属性和方法


counter-reset
counter-increment
counter()

counter-reset


counter-reset 用于定义和初始化一个或者多个css计数器。设置计数器的名称和初始值。
使用语法:


counter-reset:[<标识符><整数>?]+|none|inherit

每个计数器名称后面都可以跟一个可选的<整数>值,该值指定计数器的初始值。
计数器的初始值不是计数器显示时的第一个数字,如果希望计数器从1开始显示,则需要设置coutter-reset中的初始值设置为0。


someSelector{
counter-reset:counterA;/*计数器counterA初始,初始值为0*/
counter-reset:counterA 6;/*计数器counterA初始,初始值为6*/
counter-reset:counterA 4 counter B;/*计数器counterA初始,初始值为4,计数器counterB初始,初始值为0*/
counter-reset:counterA 4 counterB 2;/*计数器counterA初始,初始值为4,计数器counterB初始,初始值为2*/
}

counter-increment


counter-increment属性用于指定一个或多个CSS计数器的增量值。它将一个或多个标识符作为值,指定要递增的计数器的名称。


使用语法:


counter-increment:[<标识符><整数>?]+|none|inherit

每个计数器名称(标识符)后面都可以跟一个可选<整数>值,该值指定对于我们所编号的元素每次出现时,计数器需要递增多少。默认增量为1。允许零和负整数。如果指定了负整数,则计数器被递减。


counter-increment属性必须和counter-reset属性配合使用


article{/*定义和初始化计数器*/
  counter-reset:section;/*'section'是计数器的名称*/
}
article h2{/*每出现一次h2,计数器就增加1*/
  counter-increment:section;/*相当于计数器增量:第1节;*/
}

couter()


counter()函数必须和content属性一起使用,用来显示CSS计数器。它以CSS计数器名称作为参数,并作为值传递给content属性,而content属性就会使用:before伪元素将计数器显示为生成的内容。


h2:before{
  content:counter(section);
}

counter()函数有两种形式:counter(name)和counter(name,style)。
name参数就是要显示的计数器的名称;使用counter-reset属性就可以指定计数器的名称。


couters()


counters()函数也必须和content属性一起使用,用来显示CSS计数器。和counter()函数一样,counters()函数也作为值传递给content属性;然后,content属性在使用:before伪元素将计数器显示为生成的内容。


counters()函数也有两种形式:counters(name,string)和counters(name,string,style)。
name参数也是要显示的计数器的名称。可以使用counter-reset属性来指定计数器的名称。


而counters()函数与counter()函数(单数形式)区别在于:counters()函数可以用于设置嵌套计数器。


嵌套计数器是用于为嵌套元素(如嵌套列表)提供自动编号。如果您要将计数器应用于嵌套列表,则可以对第一级项目进行编号,例如,1,2,3等。第二级列表项目将编号为1.1,1.2,1.3等。第三级项目将是1.1.1,1.1.2,1.1.3,1.2.1,1.2.2,1.2.3等。


string参数用作不同嵌套级别的数字之间的分隔符。例如,在'1.1.2'中,点('.')用于分隔不同的级别编号。如果我们使用该counters()函数将点指定为分隔符,则它可能如下所示:


  content:counters(counterName,".")

  如果希望嵌套计数器由另一个字符分隔,例如,如果希望它们显示为“1-1-2”,则可以使用短划线而不是点作为字符串值:


  content:counters(counterName,"-")

总结


使用CSS Counters给元素创建自动递增计算器不仅仅是依赖于某一个CSS属性来完成,他需要几个属性一起使用才会有效果。使用的到属性包括:使用CSS Counters给元素创建自动递增计算器不仅仅是依赖于某一个CSS属性来完成,他需要几个属性一起使用才会有效果。使用的到属性包括:



  • counter-reset: 定义计数器的名称和初始值。

  • counter-increment:用来标识计数器与实际相关联的范围。

  • content:用来生成内容,其为:before:after::before::after的一个属性。在生成计数器内容,主要配合counter()一起使用。

  • counter():该函数用来设置插入计数器的值。

  • :before :after:配合content用来生成计数器内容。



链接:https://juejin.cn/post/7010031620983881735

收起阅读 »

rgb和hex相互转换

前言 这里使用了一些位运算进行计算,如果对位运算不了解的,可以了解一下,位运算 hex(16进制):#FFF,#ffffff等等16进制颜色 rgb:rgb(255,255,255),rgb(123,125,241)等等 笔者第一次遇到颜色转换时,懵了,没有思...
继续阅读 »

前言


这里使用了一些位运算进行计算,如果对位运算不了解的,可以了解一下,位运算


hex(16进制):#FFF,#ffffff等等16进制颜色


rgb:rgb(255,255,255),rgb(123,125,241)等等


笔者第一次遇到颜色转换时,懵了,没有思路,害,想着放着后面再来看看,结果放着放着,哦豁,再一次遇到了它,唉,被它逮住了。


这题,必须得剿,不剿不行呀,码着代码看着题,结果它来了。哈哈哈哈


因为笔者想自己输入hex或者rgb然后转换,就想着写个输入框,获取转换前后颜色。


设计



  • test1和test2的背景颜色由输入颜色和转化后颜色决定

  • 需要一个输入框inChange来让我输入

  • 提交按钮colorBtn提交我输入颜色


html片段:


<div>
<div>
输入颜色
</div>
<div>
转换颜色
</div>
</div>
<div>
<input type="text">
<button>提交</button>
</div>

css片段:


    * {
padding: 0;
margin: 0;
}

.box {
display: flex;
justify-content: center;
}

.test1,
.test2 {
width: 200px;
height: 100px;
text-align: center;
border: 1px solid red;
margin: 10px 20px;
}

.boxin {
width: 100%;
text-align: center;
}

这里的js我把实现转换的核心代码在下面注释给分割出来,方便复制


注意:



  • 16进制每个字符所占4位,超过32位溢出.

  • hex有6个16进制字符,24位。


js


//hex转换成rgb
function hexToRgb(hex) {
   //用于判断hex的格式对不对
let regExp = /^#([0-9A-F]{3}|[0-9A-F]{6})$/i;
//判断hex的格式是否正确
if (!regExp.test(hex)) {
return false;
}

   //-----hex到rgb转换核心代码
   //获取#后的16进制数
let str = hex.substr(1,);
//当str长度为3时,它是简写,需要把它装回6位
if (str.length == 3) {
let tempStr = "";
for (let i = 0; i < 3; i++) {
tempStr += str[i] + str[i];
}
str = tempStr;
}
//16进制
str = "0x" + str;
   //16进制每个字符占4个字节,16/4=4,对应的是(例子:0xaf54ff)af
let r = str >> 16;
   //对应af54在与运算符后,对应54
let g = str >> 8 & 0xff;
   //对应ff
let b = str & 0xff;
   let rgb = `rgb(${r}, ${g}, ${b})`;
   //-----hex到rgb转换完毕

document.querySelector(".test1").style.backgroundColor = hex;
   document.querySelector(".test1").innerHTML = hex;
   document.querySelector(".test2").style.backgroundColor = rgb;
   document.querySelector(".test2").innerHTML = rgb;
}

function rgbToHex(rgb) {
   //正则太长,直接复制粘贴会有空格,请自行删除(有空格报错噢,嘿嘿嘿)
let regExp = /^rgb(\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*,\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*,\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*)$/i;
   //判断rgb的格式是否正确
if (!regExp.test(rgb)) {
return false;
}

   //-----rgb到hex转换核心代码
   //获取rgb的数字
let arr = rgb.split(",");
let r = + arr[0].split("(")[1];
let g = + arr[1];
let b = + arr[2].split(")")[0];
   //hex有6位,占了4*6=24字节,每一步将rgb还原
let value = (1 << 24) + (r << 16) + (g << 8) + b;
   //只要24位,其实有25位,16进制转换后高位多出了一个1,提取1后面的16进制数。
let hex = "#" + value.toString(16).slice(1);
   //-----rgb到hex转换完毕

document.querySelector(".test1").style.backgroundColor = rgb;
   document.querySelector(".test1").innerHTML = rgb;
   document.querySelector(".test2").style.backgroundColor = hex;
   document.querySelector(".test2").innerHTML = hex;
}
function hexOrRgb() {
   //获取输入框的值
let val = document.querySelector(".inChange").value;
   //调用,不是执行下一个
hexToRgb(val) || rgbToHex(val);
}
function init() {
//监听按钮的点击
let btn = document.querySelector(".colorBtn");
btn.addEventListener("click", () => {
hexOrRgb();
  });
}
//入口
init();


链接:https://juejin.cn/post/7010212491363893279

收起阅读 »

React 的 Fiber 树是什么?

我发现,如果不搞清楚 React 的更新流程,就无法理解 useEffect 的原理,于是分享 React 更新流程的文章就来了。 其实我本想把整个更新流程放到一篇文章里去的,但是昨天查了一天资料后,发现这太不现实了,要是写在一篇里,中秋假期仅剩的一个下午也没...
继续阅读 »

我发现,如果不搞清楚 React 的更新流程,就无法理解 useEffect 的原理,于是分享 React 更新流程的文章就来了。


其实我本想把整个更新流程放到一篇文章里去的,但是昨天查了一天资料后,发现这太不现实了,要是写在一篇里,中秋假期仅剩的一个下午也没了,还写不完,并且我现在的能力并不能很好的组织它们。


所以,我还是放弃了,我决定把它拆开,分多篇博客更新,拆分的结果大概是这样子的:



  1. React 的 Fiber 树是什么;

  2. 更新流程中的 Render 阶段;

  3. 更新流程中的 Commit 阶段;

  4. 通过 useEffect 里调用 useSate, 把 2、3 结合起来。



What I cannot [re]create, I do not understand.



分享完上面的内容后,我们应该就可以有能力自己实现一个 Mini 版 React 了。为了真正的掌握,我会和大家一起实现一个支持 Hooks 的 Mini 版 React,可能以文章的形式放出,也可能就把源码贴在这里,不过,那肯定是 10 月份或者 11 月份的事情了。


刚开始听到 「Fiber」 这个词的时候,觉得高端极了,当时甚至没有去网上搜索一下这个到底是什么,就默认自己不可能理解了。逃的了一时,逃不了一世,为了不当框架熟练工,最终还是要克服它。


幸运的是,了解之后发现,这东西既没有想得那么难,也没有想得那么简单,只要花一点时间,大家都还是能理解的。


你要试着了解一下吗,如果选择是的话,那我们就开始吧。


Fiber 树与 DOM 树


DOM 树大家都很熟悉,下面是我们的一段 HTML 的片段:


<div>
<ul>
<li>1</li>
<li>2</li>
</ul>
<button>submit</button>
<div>

对应到 DOM 结构,就是下面这样子:


image.png


大家可能都知道 React 使用了虚拟 DOM 来提高性能。


虚拟 DOM 是一个描述 DOM 节点信息的 JS 对象,操作 DOM 是一个比较昂贵的操作,使用虚拟 DOM 这项技术,我们就能通过在 JS 对象中进行新老节点的对比,尽量减小查询、更新 DOM 操作的频次和范围。在 React 中,虚拟 DOM 对应的就是 Fiber 树。


说到 Fiber 树,名字中带一个「树」字,大家的第一印象会把它和树结构联系起来,认为它和 DOM 树的结构是一样的,但这里还真就有点不同了,它和我们见过的树都不一样。


还是用上面那段 HTMl 代码,我们假设它是一段用 JSX 语法书写的,它的结构实际是如下图这般:


image.png


父节点只和它的第一个孩子节点相连接,而第一个孩子和后面的兄弟节点相连接,它们之间构成了一个单项链表的结构。最后,每个孩子都有一个指向父节点的指针。


因为比较重要,我们再来复述一遍:在上面的结构中,我们会有一个 child 指针指向它的孩子节点,也会有一个 return 指针指向它的父节点,另外会有一个叫做 sibling 的指针指向它的兄弟节点,如果它没有孩子节点、兄弟节点或父节点,他们的指向就为空。


乍一看,这种结构还是很奇怪的,你可能会疑问,为什么不用树结构,这个我们放在本文的后面讨论。


Fiber 树的遍历


在我们学习树的时候,我们学的第一个算法往往是遍历算法,在继续下面的内容之前,我们也先来看一下怎么去遍历下面这样结构的 Fiber 树。


这一块还是很重要的,因为在后面 React 的更新流程中,它要遍历整个 Fiber 去收集更新,理解了这一块就有助于我们理解后面它的遍历过程。


我们先来描述一下它的遍历顺序:



  1. 把当前遍历的节点名记作 aa

  2. 遍历当前节点 aa,完成对这个节点要做的事

  3. 判断 a.childa.child 是否为空

  4. a.childa.child 不为空,则把 a.childa.child 记作 aa,回到 [步骤 1]

  5. a.childa.child 为空,则判断 a.sibinga.sibing 是否为空,不为空将 a.sibinga.sibing 记为 aa,回到 [步骤 1]

  6. a.childa.childa.siblinga.sibling 都为空,则证明当前节点和和他兄弟节点都遍历完了,那就返回它的父节点,找父节点中还没有遍历的兄弟节点,找到了,回到步骤 1

  7. 如此反复,直到遍历到顶点,结束。


只看逻辑可能不太直观,我们举一个例子。


<div id="a"> 
<ul id="b">
<li id="c">1</li>
<li id="d">2</li>
</ul>
<button id="e">submit</button>
<div>

对于上面这段代码,我们的遍历顺序会是:a -> b -> c -> d -> e,和正常树结构的前序遍历的结果是一样的。


如果看着还是有点懵,没关系,这很正常,接下来我会和大家演示代码。


为了方便起见,我们就固定写好的一个 Fiber 树结构,它对应我们上面那段 HTML。


// 为了简单起见,我把 TextNode 节点省略了
function createFiberTree() {
let rootFiber = {
type: 'div',
sibling: null,
return: null,
child: {
type: 'ul',
return: null,
sibling: {
type: 'button',
return: null,
sibling: null,
child: null
},
child: {
type: 'li',
return: null,
child: null,
sibling: {
type: 'li',
return: null,
child: null
}
}
}
}


rootFiber.return = null;
rootFiber.child.return = rootFiber
rootFiber.child.sibling.return = rootFiber;

let ul = rootFiber.child;
rootFiber.child.child.return = ul;
rootFiber.child.child.sibling.return = ul;

return rootFiber;
}

上面那段代码很有点长,不用管,大家就知道它根据上面的 HTML 结构构造了 Fiber 对象就好了。


接下来我们要去遍历这个树,下面就是我们的遍历方法,大家可以稍微停一会看一下这个算法,在React 的更新流程的 Render 阶段,遍历 Fiber 树的地方都是沿用这个思路。


function traverse(node) {
const root = node;
let current = node;

while(true) {
console.log('当前遍历的节点是:' + current.type)

if (current.child) {
current = current.child
continue
}

if (current.sibling) {
current = current.sibling
continue
}

while(!current.sibling) {
if (
current.return === null || current.return === root) {
return;
}
current = current.return;
}
current = current.sibling
}
}

我们在控制台运行上面遍历方法的结果如下:


image.png


Fiber 树结构的优势


好了,现在我们就已经和大家讨论清楚 Fiber 树大体是什么样了,并且我们了解了怎样去遍历一棵 Fiber 树,接下来讨论一下,为什么需要这么样的设计。


刚开始的时候,我也很疑惑,为什么不和 DOM 一样,使用普通的多叉树呢?


type Fiber {
type: string;
children: Array<Fiber>
}

这样子的话,我们不需要维护孩子节点之间的指针,找某个节点的孩子的话,直接读取 children 属性就好了。这样看起来是没问题的,我们知道,在遍历树的时候,我们最常用的是使用递归去写,如果我们采用上面的多叉树结构,遍历节点可能就是这样的:


function traverse(node) {
if (!node || !node.children) {
return;
}

for (let i = 0; i < node.children.length; i++) {
traverse(node.children[i]);
}
}

看起来确实是简洁了很多,但是如果我们的 DOM 层级很深就会引发严重的性能问题,在一个普通的项目里,几百层的 DOM 嵌套是经常发生的,这样以来,使用递归会占用大量的 JS 调用栈,不仅如此,我们的调用栈肯定不是只给这一块遍历 Fiber 节点的呀,我们还有其他的事情要去做,这对性能来说是很不能接受的。


但是,如果用我们上面提到的那种架构,我们就能做到不使用递归去遍历链表,就能始终保持遍历时,调用栈只使用了一个层,这就很大的提升了性能。


除此之外,上述遍历 Fiber 节点的过程是发生在整个更新流程的 Render 阶段,在这个阶段,React 是允许低优先级的任务被更高优先级的任务所打断的。所以说,遍历过程也可能随时被中断。为了能在下次更新时继续从上次中断的点开始,我们就需要记录下上一次的中断点。


如果使用普通的树结构,是很难记录下中断点的,假设我们有一段这样一段 HTML:


<div>
<ul>
<li>
<a>在这里中断了</a>
</li>
<!-- 可能还有很多项 -->
</ul>
<!-- 可能还有很多项 -->
</div>

按照上面的遍历算法,假设我们在遍历到 a 标签的时候中断了。


当遍历到 a 标签的时候,我们还有很多节点没有遍历的,包括 ul 的其他孩子节点、div 的其他孩子节点,也就是我标注 '可能还有很多项' 的那个地方,为了下一次能继续下去,我们就需要把这些都保存下来,当这些节点很多的时候,这在内存上是一个巨大的开销。


使用当前 Fiber 架构呢?只需要把当前节点记录在一个变量里就好了,等下次更新,它还是可以按照一样的逻辑,先遍历自己,再遍历 child 节点,再遍历 sibling 节点......


因此,我们最终选用了刚开始看起来有点怪的 Fiber 树结构。


Fiber 节点部分属性介绍


在 React官网的这一章节,讲述了 Diff 算法的大致流程,这里 Diff 的东西就是两棵新旧 Fiber 树。


说了这么多,我们还没看过一个 Fiber 节点到底长什么样。


不妨,我们先用 Babel 转译一段 JSX 看看。就编译下面这一小段吧:


<div>
<span key="1" className="box">hello world</span>
</div>


结果是下面这样的:


const a = React.createElement("div", null, 
React.createElement("span", {
key: "1",
className: "box"
}, "hello world"))

我们会根据这个结果去构建 Fiber 对象,就是这样:


image.png



注意:
上面的截图并不是全部的属性,本人只截取了一部分。



我们再根据上面的图,介绍几个 Fiber 节点常用的属性。


alternate:Diff 过程要新老节点对比,他们就是通过这个找到对方。所以,新节点的 Fiber.alternate 就指向它对应的老节点;同时,老节点的 alternate 也指向新节点。


child: 指向第一个孩子节点,我们这里就是指向了 span 那个节点。


elementType: 和 React.createElement 的第一个参数相同,DOM 元素是它的类型,组件的话就是对应的构造函数,比如函数式组件就是对应的函数,类组件就是对应的类。


sibling:指向下一个兄弟节点


return:指向父节点


stateNode:对应的 DOM 节点


memoizedProps 存储的计算好了的 props,可能是已经更新到页面上的了;也可能是刚根据 pendingProps 计算好,还没有来得及更新到页面上,准备和旧节点进行对比


memoizedState:和 memoziedProps 一样。像 usetState 能保存状态,就是因为上一次的值被存到了这个属性里面。


关于 Fiber 的属性,我们就先介绍这几个,后面等我们用到了再介绍更多。


好了,这就是我们今天的全部内容了,相信看完了上面的内容就对 Fiber 树是什么有大体印象了吧。之前我写的 useState 源码解读可能不是特别好,可能原因就是不太明白某些朋友不了解 Fiber 到底是什么,现在我通过这篇文章把它补上了,希望能弥补一下吧。


中秋回家的朋友,你们现在在归程了吗?


链接:https://juejin.cn/post/7010263907008937997

收起阅读 »

让你用最简单的方式使用Vue2 + Web Worker + js-xlsx 解析excel数据

vue
最简单的应该就是 C V 大法了吧!!! 说明 本文重点在于实现功能,没有过多去关注其他。 就想使用的话直接cv到自己的项目即可,想深入学习下边也有官方网址自行查看咯🍺 由SheetJS出品的js-xlsx是一款非常方便的只需要纯JS即可读取和导出excel...
继续阅读 »

最简单的应该就是 C V 大法了吧!!!


cv.jpg


说明


本文重点在于实现功能,没有过多去关注其他。 就想使用的话直接cv到自己的项目即可,想深入学习下边也有官方网址自行查看咯🍺


SheetJS出品的js-xlsx是一款非常方便的只需要纯JS即可读取和导出excel的工具库,功能强大,支持格式众多,支持xls、xlsx、ods(一种OpenOffice专有表格文件格式)等十几种格式。本文以xlsx格式为例。github:github.com/SheetJS/she…


为什么使用Web Worker呢?为了加快解析速度,提高用户体验度🤡。Web Worker具体介绍看阮老师的博客就好😀
Web Worker 使用教程 - 阮一峰的网络日志


本文配套demo仓库:gitee.com/ardeng/work…


效果演示


演示效果


上代码


HTML


普普通通、简简单单的element ui 上传组件


<el-upload
ref="input"
action="/"
:show-file-list="false"
:auto-upload="false"
:on-change="importExcel"
type="file"
>
<el-button type="primary">上传</el-button>
</el-upload>

JS部分


先来个无 Web Worker 版


Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。


Web Worker 有以下几个使用注意点。



  1. 同源限制 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

  2. DOM 限制 Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用documentwindowparent这些对象。但是,Worker 线程可以navigator对象和location对象。

  3. 通信联系 Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

  4. 脚本限制 Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

  5. 文件限制 Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。


由于以上的限制,所以不想搞Worker也可以。 直接解析文件对象 转换数据即可。


importExcel(file) {
// 验证文件是否为excel
if (!/\.(xlsx|xls|csv)$/.test(file.name)) {
alert("格式错误!请重新选择")
return
}
this.fileToExcel(file).then(tabJson => {
// 这里拿到excel的数据
console.log(tabJson)
})
},
// excel数据转为json数组
fileToExcel(file) {
// 不使用 Promise 也可以 只是把读文件做成异步更合理
return new Promise(function (resolve, reject) {
const reader = new FileReader()
reader.onload = function (e) {
// 拿到file数据
const result = e.target.result
// XLSX 解析的配置 type: 'binary' 必写
const excelData = XLSX.read(result, { type: 'binary' })
// 注意要加 { header: 1 }, 此配置项 可生成二维数组
const data = XLSX.utils.sheet_to_json(excelData.Sheets[excelData.SheetNames[0]],
{ header: 1 }) //! 读取去除工作簿中的数据
resolve(data)
}
// 调用方法 读取二进制字符串
reader.readAsBinaryString(file.raw)
})
}

Web Worker版


想用Worker 一些前置工作是必不可少的



  1. 下载 worker-loader


npm i -D worker-loader


  1. vue.config.js中配置loader


// 设置解析以worker.js 结尾的文件使用worker-loader 解析
chainWebpack: config => {
config.module.rule('worker')
.test(/\.worker\.js$/)
.use('worker-loader')
.loader('worker-loader')
.options({ inline: 'fallback' })
}

正式进入使用Web Worker


封装一下 Web Worker 命名规则如下:xxx.worker.js


下面代码中,self代表子线程自身,即子线程的全局对象。


// src\utils\excel.worker.js

import XLSX from 'xlsx'

/**
* 处理错误的函数 主线程可以监听 Worker 是否发生错误。
* 如果发生错误,Worker 会触发主线程的`error`事件。
*/
const ERROR = () => {
// 发送错误信息
self.postMessage({ message: 'error', data: [] })

// `self.close()`用于在 Worker 内部关闭自身。
self.close()
}

// 错误处理
self.addEventListener('error', (event) => {
ERROR()

// 输出错误信息
console.log('ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message)
})

/**
* @description: Worker 线程内部需要有一个监听函数,监听`message`事件。 工作线程接收到主线程的消息
* @param {object} event event.data 获取到主线程发送过来的数据
*/
self.addEventListener('message', async (event) => {
// 向主线程发送消息
// postMessage(event.data);

// 解析excel数据
parsingExcel(event.data)
}, false)

/**
* @description: 解析excel数据
* @param {object} data.excelFileData 文件数据
* @param {object} data.config 配置信息
*/
const parsingExcel = (data) => {
try {
// 注意 { header: 1 }, 此配置项 可生成二维数组
const { excelFileData, config = { header: 1 } } = data

// 创建实例化对象
const reader = new FileReader()

// 处理数据
reader.onload = function (e) {
// 拿到file数据
const result = e.target.result;
const excelData = XLSX.read(result, { type: 'binary' })
const data = XLSX.utils.sheet_to_json(excelData.Sheets[excelData.SheetNames[0]], config) //! 读取去除工作簿中的数据

// 发送消息
self.postMessage({ message: 'success', data })
};
// 调用方法 读取二进制字符串
reader.readAsBinaryString(excelFileData.raw);
} catch (err) {
ERROR()
console.log('解析excel数据时 catch到的错误===>', err)
}
}

使用


引入文件


import Worker from '@/utils/excel.worker.js'

业务相关的逻辑


importExcel(file) {
if (!/\.(xlsx|xls|csv)$/.test(file.name)) {
alert("格式错误!请重新选择")
return;
}

// 创建实列
const worker = new Worker()

// 主线程调用`worker.postMessage()`方法,向 Worker 发消息
worker.postMessage({
excelFileData: file,
config: { header: 1 }
})

// 主线程通过`worker.onmessage`指定监听函数,接收子线程发回来的消息
worker.onmessage = (event) => {
const { message, data } = event.data
if (message === 'success') {
// data是个二维数组 表头在上边
console.log(data)
// Worker 完成任务以后,主线程就可以把它关掉。
worker.terminate()
}
}
}

可能遇到的问题



  1. npm run dev 启动不了,并且 webpack 报错:检查下 webpack worker-loader 的版本。

  2. 控制台报错包含 not a function 或 not a constructor:检查下 webpck 配置。最好是查看英文文档 webpack,因为中文文档更新不及时。

  3. 控制台报错 window is not defined:改成 self 试试。参考 Webpack worker-loader - import doesn’t work


链接:https://juejin.cn/post/7010046891480055815

收起阅读 »

react-native拆包&热更体系搭建-代码拆包

一、前言 触过react-native的小伙伴都知道热更是rn最大的特点之一,掌握了热更就可以随时上线新的迭代、线上bug hotfix,这样一来发版也更有底气了从此告别跑路的担忧,code-push(以下简称cp)是大多数人接触的第一款热更库,功能很强大但是...
继续阅读 »

一、前言


触过react-native的小伙伴都知道热更是rn最大的特点之一,掌握了热更就可以随时上线新的迭代、线上bug hotfix,这样一来发版也更有底气了从此告别跑路的担忧,code-push(以下简称cp)是大多数人接触的第一款热更库,功能很强大但是也有一些弊端,在刚开始接触热更的时候我有遇到bundle包因为某种来自东方的神秘力量阻挡无法下载的问题,后来我又发现了code-push-server这个开源的配套服务,细心的小伙伴们会发现这个项目最后一次commit已经在两年前,而且配套的cli工具早就升级到与某软的xxx center强绑定了,在此我个人不推荐再使用这个库来实现热更了。


二.CLI工具 (rn-multi-bundle)


image.png


Github:rn-multi-bundle


这是我开发的一款辅助拆包的cli工具,使用方法很简单


安装cli工具


npm install rn-multi-bundle -D
yarn add rn-multi-bundle -D

修改模块注册格式如下:
必须修改,cli工具通过分析ComponentMap对象拆离每个业务包


const ComponentMap = {
[appName]: Home,
[ComponentName.Home]: Home,
[ComponentName.Test]: Test,
};

Object.keys(ComponentMap).forEach(name => {
AppRegistry.registerComponent(name, () => ComponentMap[name]);
});

打包方法(公共包和所有业务包)


yarn rn-multi-bundle

打业务增量包方法


yarn rn-multi-bundle -u

后面考虑会做内联优化RAM Bundles 和内联引用优化


三、拆包与热更的关系


image.png


在大型项目中一般会使用AppRegistry.registerComponent来注册多个模块,每个模块各司其职可以是衍生产品或者零时活动,研究过cp的人都知道它的原理是替换单个bundle包,即时你改动一行代码发版那也是替换整个bundle包,这样造成的问题:一是对资源的浪费很多代码其实还是能够复用的用户的流量也是要钱的、二是不利于优化rn的启动速度。我想要实现的是每个模块可以独立更新,所以拆包对热更来说很重要!!!


四、如何实现拆包?


image.png


react-native的官方打包工具叫metro,metro提供了两个重要的apiprocessModuleFiltercreateModuleIdFactory,两个api都能获取到模块(文件)的路径,也就是栗如/src/utils/constant.tsx这样的字符串,从方法名可以了解到processModuleFilter可以判断是否要过滤出某个文件返回true表示这个文件需要打入bundle中,false则相反。createModuleIdFactory是为每个文件生成一个id,这个id其实就是commonjs规范中每个模块生成的id也就是一个索引。


五、拆出公共包


image.png


公共包顾名思义里边全是公共的代码可复用程度高,可以被各个模块使用到,比如node_modules中的第三方依赖,公共组件components、以及一些工具方法utils,所以我们只需要把文件路径属于这几个文件夹的文件用processModuleFilterapi过滤出来,这样就产生了一个公共包。


六、拆出业务包


image.png


在拆公共包的过程中,生成一个source map记录每个文件的id。利用这个文件使用processModuleFilter把已经存在公共包中的模块过滤掉,然后再使用createModuleIdFactory返回对应的id,这样业务包就能调用公共包中的各种模块


七、打包结果


初始包


image.png


业务增量包


image.png


作者:soul96816
链接:https://juejin.cn/post/7010014852307484685

收起阅读 »

React下一代状态管理库——recoil

引言 对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要...
继续阅读 »

引言


对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要开发者自己去保证,所以不得不引入例如immer这类的库;另外,redux本身是框架无关的库,他需要和redux-react结合才能在react中使用。使用我们不得不借助redux toolkit或者rematch这种内置了很多最佳实践的库以及重新设计接口的库,但与此同时也增加了开发者的学习成本。
所以react的状态管理的轮子层出不穷,下面将会介绍面向未来设计的react状态管理库——recoil。


简介


recoil 的 slogan 十分简单:一个react状态管理库(A state management library for React)。它不是一个框架无关的状态库,它是专门为react而生的。


和react一样,recoil也是facebook的开源的库。官方宣称有三个主要的特性:




  1. Minimal andReactish:最小化和react风格的api。




  2. Data-Flow Graph:数据流图。支持派生数据和异步查询都是纯函数,内部都是高效的订阅。




  3. Cross-App Observation: 跨应用监听,能够实现整体状态监听。




基本设计思想


假如有这么一个场景,相应状态改变我们 仅仅需要 更新list中的第二个节点和canvas的第二个节点。

如果没有使用第三外部状态管理库,使用context API可能是这样的:



我们可能需要很多个单独的provider,对应仅仅需要更新的节点,这样实际上使用状态的子节点的和Provider实际上是 耦合 的,我们使用状态的时候需要关心是否有相应的provider。
又假如我们使用的redux,其实如果只是某一个状态更新,其实所有的订阅函数都会重新运行,即使我们最后通过selector浅对比两次状态一样的,阻止更新react树,但是一旦订阅的节点数量非常多,实际上是会有性能问题的。


recoil把状态分为了一个个原子,react组件树只会订阅他们需要的状态。在这个场景中,组件树左边和右边的item订阅了不同的原子,当原子改变,他们只会更新相应的订阅的节点。

同时recoil也支持“派生状态”,也就是说已有的原子组合成一个新的状态(selector),并且新的状态也可以成为其他状态的依赖。

不仅支持同步的selector,recoil也支持异步的selector,recoil对selector的唯一要求就是他们必须是一个纯函数。

Recoil的设计思想就是我们把状态拆分一个一个的原子atom,再由selector派生出更多状态,最后React的组件树订阅自己需要的状态,当有原子状态更新,只有改变的原子及其下游节点有订阅他们的组件才会更新。也就是说,recoil其实构建了一个 有向无环图 ,这个图和react组件树正交,他的状态和react组件树是完全 解耦 的。


简单用法


吹了这么多先来看看简单的用法吧。
区别于redux是与框架无关的状态管理库,既然Recoil是专门为React设计的状态管理库,那么他的API满满的“react风格”。 Recoil 只支持hooks API,在使用上来说可以说十分简洁了。
下面看看 Demo


import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue
} from "recoil";

export default function App() {
return (
<RecoilRoot>
<Demo />
</RecoilRoot>
);
}

const textState = atom({
key: "textState",
default: ""
});

const charCountState = selector({
key:'charCountState',
get: ({get}) => {
// 要求是纯函数
const text = get(textState)
return text.length
}
})

function Demo() {
const [text, setText] = useRecoilState(textState);
const count = useRecoilValue(charCountState)
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<br />
Echo: {text}
<br />
charCount: {count}
</>
);
}



  • 类似于React Redux,recoil也有一个Provider——RecoilRoot,用于全局共享一些方法和状态。




  • atom(原子)是recoil中最小的状态单元,atom表示一个值可以被读、写、订阅,它必须有一个区别于其他atom保持 唯一性和不变性 的key。通过atom可以定义一个数据。




  • Selector 有点像React-Redux中的selector,同样是用来“派生”状态的,不过和React-Redux中不同是:




    • React-redux的selector是一个纯函数,当全局唯一的状态改变,它总是会运行,从全局唯一的状态运算出新的状态。




    • 而在recoil中,selector的 options.的get也要求是一个纯函数,其中传入其中的get方法用来获取其他atom。 当且仅当依赖的 atom 发生改变且有组件订阅selector ,它其实才会重新运算,这意味着计算的值是会被缓存下来的,当依赖没有发生改变,其实直接会从缓存中读取并返回。而selector返回的也是一个atom,这意味着派生状态其实也是一个原子,其实也可以作为其他selector的依赖。






很明显,recoil是通过get函数中的get入参来收集依赖的,recoil支持动态收集依赖,也就是说get可以在条件中调用:


const toggleState = atom({key: 'Toggle', default: false});

const mySelector = selector({
key: 'MySelector',
get: ({get}) => {
const toggle = get(toggleState);
if (toggle) {
return get(selectorA);
} else {
return get(selectorB);
}
},
});

异步


recoil天然支持异步,用法也十分简单,也不需要配置什么异步插件,看看 Demo


const asyncDataState = selector({
key: "asyncData",
get: async ({get}) => {
// 要求是纯函数
return await getAsyncData();
}
});

function AsyncComp() {
const asyncData = useRecoilValue(asyncDataState);
return <>{asyncData}</>;
}
function Demo() {
return (
<React.Suspense fallback={<>loading...</>}>
<AsyncComp />
</React.Suspense>
);
}

由于recoil天然支持react suspense的特性,所以使用useRecoilValue获取数据的时候,如果异步状态pending,那么默认将会抛出该promise,使用时需要外层使用React.Suspense,那么react就会显示fallback里面的内容;如果报错,也会抛出里面的内容,被外层的ErrorBoundary捕获。
如果你不想使用该特性,可以使用useRecoilValueLoadable直接获取异步状态, demo


function AsyncComp() {
const asyncState = useRecoilValueLoadable(asyncDataState);
if (asyncState.state === "loading") {
return <>loading...</>;
}
if (asyncState.state === "hasError") {
return <>has error....</>;
}
if (asyncState.state === "hasValue") {
return <>{asyncState.contents}</>;
}
return null;
}

另外注意默认异步的结果是会被缓存下来,其实所有的selector上游没有改变的结果都会被缓存下来。也就是说如果异步的依赖没有发生改变,那么不会重新执行异步函数,直接返回缓存的值。这也是为什么一直强调selector配置项get是纯函数的原因。


依赖外部变量


我们常常会遇到状态不纯粹的问题,如果状态其实是依赖外部的变量,recoil有selectorFamily支持:


const getUserInfoState = selectorFamily({
key: "userInfo",
get: (userId) => ({ get }) => {
return queryUserState({userId: id, xxx: get(xxx) });
},
});

function MyComponent({ userID }) {

const number = useRecoilValue(getUserInfoState(userID));
//...
}

这里外部的参数和key,会同时生成一个全局唯一的key,用于标识状态,也就是说如果外部变量没有变化或者依赖没有发生变化,不会重新计算状态,而是直接返回缓存值。


源码解析


如果说看到这里,仅仅实现上面那些简单例子的话,大家可能会说“就这”?实现起来应该不太难,这里有一个简单的 实现的版本 ,虽然功能差不多,但是架构完全不一样,recoil的源码继承了react源码的优良传统,就是十分难读。。。


其源码核心功能分为几个部分:




  • Graph 图相关的逻辑




  • Nodeatom和selector在内部统一抽象为node




  • RecoilRoot 主要是就是外部用的一些recoilRoot,




  • RecoilValue 对外部暴露的类型。也就说atom、selector的返回值。




  • hooks 使用的hooks相关的。




  • Snapshot 状态快照,提供状态记录和回滚。




  • 一些其他读不懂的代码。。。




下面就谈谈自己这几天看源码粗浅的认识,欢迎大佬们指正。


Concurrent mode 支持


为了防止把大家绕晕,先讲讲我最关心的问题,recoil是如何支持conccurent的思路,可能不太正确(网上没有资料参考,欢迎讨论)。


Cocurrent mode


先讲一讲什么是react的Cocurrent mode,官网的介绍是,一系列新的特性帮助ract应用保持响应式和并优雅的使用用户设备能力和网络速度。
react迁移到fiber架构就是为了concurrent mode的实现,React 在新的架构下实际上有两个阶段:




  • 渲染(rendering)阶段




  • 提交(commit)阶段




在渲染阶段,react 可以根据任务优先级对组件树进行渲染,所以当前渲染任务可能会因为优先级不够或者当前帧没有剩余时间而被中断。后续调度会重新执行当前任务渲染。


ui和state不一致的问题


因为react现在会放弃控制流,在渲染开始到渲染结束,任何事情都可能发生,一些的钩子被取消就是因为这个原因。而对于第三方状态库来说,比如说有一个异步请求在这段时间把外部的状态改变了,react会继续上一次打断的地方重新渲染,就会读到新的状态值。 就会发生 状态和 UI 不一致 的情况。


recoil的解决办法


整体数据结构



atom


atom实际上是调用baseAtom,baseAtom内部有闭包变量defaultLoadable一个用于记录当前的默认值。声明了getAtom函数和setAtom函数等,最后传给registerNode,完成注册。


function baseAtom(options){
// 默认值
let defaultLoadable = isPromise(options.default) ? xxxx : options.default

function getAtom(store,state){
if(state.atomValues.has(key)){
// 如果当前state里有这个key的值,直接返回。
return state.atomValues.get(key)
}else if(state.novalidtedAtoms.has(key)){
//.. 一些逻辑
}else{
return defaultLoadable;
}
}

function setAtom(store, state, newValue){
if (state.atomValues.has(key)) {
const existing = nullthrows(state.atomValues.get(key));
if (existing.state === 'hasValue' && newValue === existing.contents) {
// 如果相等就返回空map
return new Map();
}
}
//...
// 返回的的是key --> 新的loadableValue的Map
return new Map().set(key, loadableWithValue(newValue));
}

function invalidateAtom(){
//...
}



const node = registerNode(
({
key,
nodeType: 'atom',
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
// 忽略其他配置。。。
}),
);
return node;
}

function registerNode(){
if (nodes.has(node.key)) {
//...
}
nodes.set(node.key, node);

const recoilValue =
node.set == null
? new RecoilValueClasses.RecoilValueReadOnly(node.key)
: new RecoilValueClasses.RecoilState(node.key);

recoilValues.set(node.key, recoilValue);
return recoilValue;
}

selector


由于selector也可以传入set配置项,这里就不分析了。


function selector(options){
const {key, get} = options
const deps = new Set();
function selectorGet(){
// 检测是否有循环依赖
return detectCircularDependencies(() =>
getSelectorValAndUpdatedDeps(store, state),
);
}

function getSelectorValAndUpdatedDeps(){
const cachedVal = getValFromCacheAndUpdatedDownstreamDeps(store, state);
if (cachedVal != null) {
setExecutionInfo(cachedVal, store);
// 如果有缓存值直接返回
return cachedVal;
}
// 解析getter
const [loadable, newDepValues] = evaluateSelectorGetter(
store,
state,
newExecutionId,
);
// 缓存结果
maybeSetCacheWithLoadable(
state,
depValuesToDepRoute(newDepValues),
loadable,
);
//...
return lodable
}

function evaluateSelectorGetter(){
function getRecoilValue(recoilValue){
const { key: depKey } = recoilValue
dpes.add(key);
// 存入graph
setDepsInStore(store, state, deps, executionId);
const depLoadable = getCachedNodeLoadable(store, state, depKey);
if (depLoadable.state === 'hasValue') {
return depLoadable.contents;
}
throw depLoadable.contents;
}
const result = get({get: getRecoilValue});
const lodable = getLodable(result);
//...

return [loadable, depValues];
}

return registerNode<T>({
key,
nodeType: 'selector',
peek: selectorPeek,
get: selectorGet,
init: selectorInit,
invalidate: invalidateSelector,
//...
});
}
}

hooks


useRecoilValue && useRecoilValueLoadable




  • useRecoilValue底层实际上就是依赖useRecoilValueLoadable,如果useRecoilValueLoadable的返回值是promise,那么就把他抛出来。




  • useRecoilValueLoadable 首先是在useEffect里订阅RecoilValue的变化,如果发现变化不太一样,调用forceupdate重新渲染。返回值则是通过调用node的get方法拿到值为lodable类型的,返回出来。




function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
const storeRef = useStoreRef();
const loadable = useRecoilValueLoadable(recoilValue);
// 如果是promise就是throw出去。
return handleLoadable(loadable, recoilValue, storeRef);
}

function useRecoilValueLoadable_LEGACY(recoilValue){
const storeRef = useStoreRef();
const [_, forceUpdate] = useState([]);

const componentName = useComponentName();

useEffect(() => {
const store = storeRef.current;
const storeState = store.getState();
// 实际上就是在storeState.nodeToComponentSubscriptions里面建立 node --> 订阅函数的映射
const subscription = subscribeToRecoilValue(
store,
recoilValue,
_state => {
// 在代码里通过gkx开启一些特性,方便单元测试和代码迭代。
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([]);
}
const newLoadable = getRecoilValueAsLoadable(
store,
recoilValue,
store.getState().currentTree,
);
// 小小的优化
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
//...
// release
return subscription.release;
})

// 实际上就是调用node.get方法。然后做一些其他处理
const loadable = getRecoilValueAsLoadable(storeRef.current, recoilValue);

const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

这里一个有意思的点是useComponentName的实现有一点点hack:由于我们通常会约定hooks的命名是use开头,所以可以通过调用栈去找第一个调用函数不是use开头的函数名,就是组件的名称。当然生产环境,由于代码混淆是不可用的。


function useComponentName(): string {
const nameRef = useRef();
if (__DEV__) {
if (nameRef.current === undefined) {
const frames = stackTraceParser(new Error().stack);
for (const {methodName} of frames) {
if (!methodName.match(/\buse[^\b]+$/)) {
return (nameRef.current = methodName);
}
}
nameRef.current = null;
}
return nameRef.current ?? '<unable to determine component name>';
}
return '<component name not available>';
}

useRecoilValueLoadable_MUTABLESOURCE基本上是一样的,除了订阅函数里我们从手动调用foceupdate变成了调用参数callback。


function useRecoilValueLoadable_MUTABLESOURCE(){
//...

const getLoadable = useCallback(() => {
const store = storeRef.current;
const storeState = store.getState();
//...
const treeState = storeState.currentTree;
return getRecoilValueAsLoadable(store, recoilValue, treeState);
}, [storeRef, recoilValue]);

const subscribe = useCallback(
(_storeState, callback) => {
const store = storeRef.current;
const subscription = subscribeToRecoilValue(
store,
recoilValue,
() => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return callback();
}
const newLoadable = getLoadable();
if (!prevLoadableRef.current.is(newLoadable)) {
callback();
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
return subscription.release;
},
[storeRef, recoilValue, componentName, getLoadable],
);
const source = useRecoilMutableSource();
const loadable = useMutableSource(source, getLoadableWithTesting, subscribe);
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

useSetRecoilState & setRecoilValue


useSetRecoilState最终其实就是调用queueOrPerformStateUpdate,把更新放入更新队列里面等待时机调用


function useSetRecoilState(recoilState){
const storeRef = useStoreRef();
return useCallback(
(newValueOrUpdater) => {
setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
},
[storeRef, recoilState],
);
}

function setRecoilValue<T>(
store,
recoilValue,
valueOrUpdater,
) {
queueOrPerformStateUpdate(store, {
type: 'set',
recoilValue,
valueOrUpdater,
});
}

queueOrPerformStateUpdate,之后的操作比较复杂这里做简化为三步,如下;


function queueOrPerformStateUpdate(){
//...
//atomValues中设置值
state.atomValues.set(key, loadable);
// dirtyAtoms 中添加key。
state.dirtyAtoms.add(key);
//通过storeRef拿到。
notifyBatcherOfChange.current()
}

Batcher


recoil内部自己实现了一个批量更新的机制。


function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();

const [_, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));

useEffect(() => {
endBatch(storeRef);
});

return null;
}


function endBatch(storeRef) {
const storeState = storeRef.current.getState();
const {nextTree} = storeState;
if (nextTree === null) {
return;
}
// 树交换
storeState.previousTree = storeState.currentTree;
storeState.currentTree = nextTree;
storeState.nextTree = null;

sendEndOfBatchNotifications(storeRef.current);
}

function sendEndOfBatchNotifications(store: Store) {
const storeState = store.getState();
const treeState = storeState.currentTree;
const dirtyAtoms = treeState.dirtyAtoms;
// 拿到所有下游的节点。
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
);
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentSubscriptions.get(key);

if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState);
}
}
}
}
//...
}

总结


虽然关于react的状态管理库很多,但是recoil的一些思想还是很先进,社区里面对这个新轮子也很多挂关注,目前githubstar14k。因为recoil目前还不是稳定版本,所以npm下载量并不高,也不建议大家在生产环境中使用。不过相信随着react18的发布,recoil也会更新为稳定版本,它的使用将会越来越多,到时候大家可以尝试一下。


链接:https://juejin.cn/post/7006253866610229256

收起阅读 »

css做‘展开收起’功能,借鉴大佬思路

开局一张图 上图所示,多行文本的展开收起是一个很常见的交互效果。 实现这一类布局和交互难点主要一下几点: 位于多行文本右下角的“展开收起”按钮 “展开”和“收起”两种状态的切换 当文本不超过指定行数时,不显示“展开收起”按钮 在此之前,单独看这个布局,即...
继续阅读 »

开局一张图


more.gif


上图所示,多行文本的展开收起是一个很常见的交互效果。


实现这一类布局和交互难点主要一下几点:



  • 位于多行文本右下角的“展开收起”按钮

  • “展开”和“收起”两种状态的切换

  • 当文本不超过指定行数时,不显示“展开收起”按钮


在此之前,单独看这个布局,即便是配合JavaScript也不那么容易做出好看的交互效果。经过各方学习,发现纯CSS也能完美实现。


第一步,"展开收起"按钮


多行文本截断

假设有如下的一段html结构


<div class='more-text'>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

多行文本超出展示省略号的方式,大家平常也用得蛮多吧,关键代码如下


.more-text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

image.png


按钮右下角环绕效果

<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

.more-btn{
float: left;
/*其他装饰样式*/
}

image.png


换为右浮动


.more-btn{
float: right;
/*其他装饰样式*/
}


image.png


再移到右下角


.more-btn{
float: right;
margin-top: 50px;
/*其他装饰样式*/
}

image.png


不难看出,按钮确实到了右下角,但按钮上方空白空间太大了。并不是我们希望的效果。


此时,借鉴伪元素配合多个浮动元素来完成。


.more-text::before {
content: '';
float: right;
width: 10px;
height: 50px;
background: red;
}
.more-btn{
float: right;
clear: both;
/*其他装饰样式*/
}


image.png


如上图,当按钮和伪元素before都浮动,并且按钮clear: both,此时,伪元素before成功将按钮顶到了右下角。让伪元素before的宽度去掉便出现如下效果。


.more-text::before {
content: '';
float: right;
width: 0;
height: 50px;
background: red;
}

image.png


如你所见,按钮环绕效果非~常完美符合预期。


但是before高度是固定的50px,不一定会满足场景所需。还需修改为calc动态计算。


.more-text::before {
content: '';
float: right;
width: 0;
height: calc(100% - 20px);
/*100%减去一个按钮的高度即可*/
background: red;
}

image.png


很可惜,calc并没有达到理想的效果。


为什么呢?打开控制台可以发现,calc计算所得高度为0。怎么会这样呢?原因其实是因为父级元素没有设置高度,calc里面的 100% 便失效了。但问题在于,这里所需要的高度是动态变化的,不可能给父级定下一个固定高度。


至此,我们需要对布局进行修改。利用flex布局。大概的方法就是在 flex 布局 的子项中,可以通过百分比来计算变化高度。


修改如下,给.more-text再包裹一层,再设置 display: flex


<div class='more-wrapper'>
<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

.more-wrapper{
display: flex;
}

这样修改之后,calc的计算高度便能够生效。如下图所示。


image.png


至此,按钮右下角环绕效果就基本完成了。配上一个按钮点击事件就大功告成了。


浏览器兼容性处理


上面的实现是最完美的处理方式。但是,在Firefox浏览器却出现了兼容性问题。


image.png


哦豁。如此就非常尴尬。祸不单行,Safari浏览器也出现了兼容问题。


经过多番查证,发现是display: -webkit-box;属性存在兼容问题。


问题就在于,如果没有display: -webkit-box;怎么实现多行截断呢?如果在知道行数的情况下设置一个最大高度,理论上也能实现多行截断。由此我们通过行高属性line-height去入手。如果需要设置成 3 行,那就将高度设置成为 line-height * 3。


.more-text {
/*
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
*/
overflow: hidden;
line-height: 1.5;
max-height: 4.5em;
}

image.png


此时呢还缺少省略号...。可以利用伪元素实现。


.more-btn::before{
content: '…';
color: #333;
font-size: 14px;
position: absolute;
left: -10px;
transform: translateX(-100%);
}

image.png


大功告成,接下来加上点击切换即可。


点击切换“展开“ 与 ”收起“。


咱们目标是纯CSS完成。那么CSS状态切换就必不可少了,完全可以用input type = "checkbox"这个特性来完成。


要用到input特性就得对html代码进行一些修改。


<div class="more-wrapper">
<input type="checkbox" id="exp" />
<div class="more-text">
<!-- <div>展开</div> -->
<label class="more-btn" for="exp">展开</label>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

#exp:checked + .more-text {
-webkit-line-clamp: 999;
max-height: none;
}

more.gif


接下来,就是变换按钮文字,以及展开之后省略号隐藏。此时都可以利用伪元素处理。


<label class="more-btn" for="exp"></label>
<!-- 去掉按钮文字 -->

.more-btn::after {
content: '更多';
}

在:checked状态中


#exp:checked + .more-text .more-btn::after {
content: '收起';
}

省略号隐藏处理。


#exp:checked + .more-text .more-btn::before {
visibility: hidden;
}

more.gif


至此,我们需要的效果便成了。


当然咱们还可以添加一些过渡动画让展开收起效果更加美观。在此就不演示了。


最后,文本行数判断


此前的步骤已经能够满足使用需求。但是还是存在问题。比如当文本内容较少时,此时不会发生截断,便不需要省略号...以及展开收起按钮。


image.png


此时当然可以选择js方式去做判断。但我们的目标是纯CSS。


那CSS没有逻辑判断,咱们只能另辟蹊径,视觉欺骗。或者叫做障眼法


比如在上图中的场景,没有发生截断,那就不需要省略号...展开按钮。这时,如果在文本的最后加上一个元素。并且为了不影响布局,给此元素设置绝对定位。


.more-text::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
background: red;
}

同时,我们把父级的overflow: hidden;先去掉。得到效果如下


image.png


如图可见,红色部分的元素非常完美的挡住了按钮部分。


那我们把红色改成父级一样的背景色,并且恢复父级的overflow: hidden;


more.gif


上图可见,发现展开之后呢,伪元素盖住了收起按钮。所以必须再做一些修改。


#exp:checked + .more-text::after {
visibility: hidden;
}

more.gif


如你所见,非~常的好用。



注:IE10以下就不考虑了哈~




链接:https://juejin.cn/post/7007632958622269471

收起阅读 »

浅谈前端的状态管理

前言 提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在...
继续阅读 »

前言


提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在下方留言提出。


什么是前端状态管理?


举个例子:图书馆里所有人都可以随意进书库借书还书,如果人数不多,这种方式可以提高效率减少流程,一旦人数多起来就容易混乱,书的走向不明确,甚至丢失。所以需要一个图书管理员来专门记录借书的记录,也就是你要委托图书管理员给你借书及还书。


实际上,大多数状态管理方案都是如上思想,通过管理员(比如 Vuex)去规范书库里书本的借还(项目中需要存储的数据)


Vuex


在国内业务使用中 Vuex 的比例应该是最高的,Vuex 也是基于 Flux 思想的产品,Vuex 中的 state 是可以被修改的。原因和 Vue 的运行机制有关系,Vue 基于 ES5 中的 getter/setter 来实现视图和数据的双向绑定,因此 Vuex 中 state 的变更可以通过 setter 通知到视图中对应的指令来实现视图更新。更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。我们以图书馆来作为例子:


const state = {
book: 0
}

const mutations = {
borrow_book(state) {
state.book ++
}
}

//调用时
store.commit('borrow_book')

那还有action呢? 在 mutation 中混合异步调用会导致你的程序很难调试。你怎么知道是哪个先执行完呢?
aciton 可以包含任意异步操作,用法跟上面基本类似,不再叙述。


其实我只是拿 Vuex 来浅入一下相关用法大家应该是都熟悉了,那 Vuex 解决了什么问题呢?



  • 管理多个组件共享状态。

  • 全局状态管理。

  • 状态变更跟踪。

  • 让状态管理形成一种规范,使代码结构更清晰。


实际上大部分程序员都比较懒(狗头保命),只是为了能多个组件共享状态,至于其他的都是事后了。最典型的就是加入购物车的数量,加入一个就通过 Vuex 记录保存最终的总数显示在下栏。


那问题来了,既然你的目的只是共享多个状态,那何不直接用 Bus 总线好了?


Bus 总线


Bus 总线实际上他是一个公共的 Vue 实例,专门处理 emit 和 on 事件。


实际上 Bus 总线十分轻便,他并不存在 Dom 结构,他仅仅只是具有实例方法而已。


Vue.prototype.$Bus = new Vue()

然后,你可以通过 emit 来发送事件, on 来接收事件。


// 发送事件
this.$Bus.$emit('borrow_book', 1)

// 任意组件中接收
this.$Bus.$on('borrow_book', (book) => {
console.log(`借了${book}本书`)
})

当然还有 off(移除)、once(监听一次)等操作感兴趣可以自行搜索引擎。


怎么样?上面对于满足共享一个状态是不是比 Vuex 要简单多了?实际上确实是简单多了,但这也代表他比较适合中小型项目。多于大型项目来说 Bus 只会让你追述更改源时一脸懵逼甚至你都不知道他在哪里改变了。


他的工作原理就是发布订阅者的思想,虽然非常优雅简单,但实际 Vue 并不提倡这种写法,并在3.0版本中移除了大部分相关Api(emit、on等),其实不然,发布订阅模式你也可以自己手写一个去实现:


class Bus {
constructor() {
// 收集订阅信息,调度中心
this.list = {};
}

// 订阅
$on(name, fn) {
this.list[name] = this.list[name] || [];
this.list[name].push(fn);
}

// 发布
$emit(name, data) {
if (this.list[name]) {
this.list[name].forEach((fn) => {
fn(data);
});
}
}

// 取消订阅
$off(name) {
if (this.list[name]) {
delete this.list[name];
}
}
}
export default Bus;

简单吧?你只需要跟用 Vue Bus 一样去实例化然后用就可以了。什么?你想共享两三个甚至更少的状态(一个),那封装一个 Bus 是不是有点没必要了? 行吧,那你用 web storage 吧。


web storage


其实说到这,storage只是数据存储方式,跟状态管理其实没有太大关系,只是共享数据。但是既然都提到了那就顺带说一下(狗头)


web storage 有这三种:cookie、local storage、session storage。


无论这三种的哪种都强烈建议不要将敏感信息放入其中,这里应该是加密或一些不那么重要的数据在里面。


先简单复习一下三者:































类别生命周期存储容量存储位置
cookie默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效)4KB保存在客户端,每次请求时都会带上
localStorage理论上永久有效的,除非主动清除。4.98MB(不同浏览器情况不同,safari 2.49M)保存在客户端,不与服务端交互。节省网络流量
sessionStorage仅在当前网页会话下有效,关闭页面或浏览器后会被清除。4.98MB(部分浏览器没有限制)同上

cookie 不必多说,大家发起请求时经常会携带cookie请求一些个人数据等,与我们要探讨的内容没有太大关系。


loaclStorage 可以存储理论上永久有效的数据,如果你要存储状态一般推荐是放在 sessionStorage,localStorage 也有以下局限:



  • 浏览器的大小不统一,并且在 IE8 以上的 IE 版本才支持 localStorage 这个属性。

  • 目前所有的浏览器中都会把localStorage的值类型限定为string类型,这个在对我们日常比较常见的JSON对象类型需要一些转换。

  • localStorage在浏览器的隐私模式下面是不可读取的。

  • localStorage本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡。

  • localStorage不能被爬虫抓取到。


localStorage 与 sessionStorage 的唯一一点区别就是 localStorage 属于永久性存储,而 sessionStorage 属于当会话结束的时候,sessionStorage 中的键值对会被清空。


localStorage 本身只支持字符串形式存储,所以你存整数类型,拿出来的会是字符串类型。


sessionStorage 与 localStorage 基本差不多,只是回话关闭时,数据就会清空。


总结


不论哪种方案选择合适自己项目的方案才是最佳实践。没有最好的方案,只有合适自己的方案。


以上只是略微浅谈,也可能不够全面,欢迎下方留言~


链接:https://juejin.cn/post/7007306391836688415

收起阅读 »

关注 ? ? ? 前端仔也需要懂的nginx内容

tips 如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件和使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景 前言 作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容...
继续阅读 »


tips


如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景


前言


作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容易上手的Nginx可以满足你的一切幻想。学习nginx可以让我们更加清晰前端项目上线的整个流程

作为一个前端,或多或少都会对Nginx有一些经验,那为什么还要学习那? 不系统:以前可能你只会配置某项功能(网上搜集),都是碎片化的知识,不没有形成系统化。这样就导致你服务出现问题时,根本不知道从哪里下手来解决这些问题。


一、Nginx是什么?


nginx官方介绍:



"Nginx是一款轻量级的HTTP服务器,采用事件驱动的异步非阻塞处理方式框架,这让其具有极好的IO性能,时常用于服务端的反向代理和负载均衡。"



nginx的优点



  • 支持海量高并发:采用IO多路复用epoll。官方测试Nginx能够支持5万并发链接,实际生产环境中可以支撑2-4万并发连接数。

  • 内存消耗少

  • 可商业化

  • 配置文件简单


除了这些优点还有很多,比如反向代理功能,灰度发布,负载均衡功能等


二、安装


这里的文章不着重介绍怎么安装nginx,但是也给大家留下了安装的教程地址,自取



如果是centos大家也可以直接用yum安装也是很方便的


yum -y install nginx


nginx.conf 文件是nginx总配置文件也是nginx读取配置的入口。


三、nginx文件介绍


nginx我们最常用到的文件,其实就是nginx的配置文件,其他的文件我们就带过了,当你能熟练编写nginx文件,其实就等于熟练使用nginx了


[wujianrong@localhost ~]# tree /usr/local/nginx
/usr/local/nginx
├── client_body_temp
├── conf # Nginx所有配置文件的目录
│ ├── fastcgi.conf # fastcgi相关参数的配置文件
│ ├── fastcgi.conf.default # fastcgi.conf的原始备份文件
│ ├── fastcgi_params # fastcgi的参数文件
│ ├── fastcgi_params.default
│ ├── koi-utf
│ ├── koi-win
│ ├── mime.types # 媒体类型
│ ├── mime.types.default
│ ├── nginx.conf # Nginx主配置文件
│ ├── nginx.conf.default
│ ├── scgi_params # scgi相关参数文件
│ ├── scgi_params.default
│ ├── uwsgi_params # uwsgi相关参数文件
│ ├── uwsgi_params.default
│ └── win-utf
├── fastcgi_temp # fastcgi临时数据目录
├── html # Nginx默认站点目录
│ ├── 50x.html # 错误页面优雅替代显示文件,例如当出现502错误时会调用此页面
│ └── index.html # 默认的首页文件
├── logs # Nginx日志目录
│ ├── access.log # 访问日志文件
│ ├── error.log # 错误日志文件
│ └── nginx.pid # pid文件,Nginx进程启动后,会把所有进程的ID号写到此文件
├── proxy_temp # 临时目录
├── sbin # Nginx命令目录
│ └── nginx # Nginx的启动命令
├── scgi_temp # 临时目录
└── uwsgi_temp # 临时目录


1. 配置文件(重点)


conf //nginx所有配置文件目录   
nginx.conf //这个是Nginx的核心配置文件,这个文件非常重要,也是我们即将要学习的重点
nginx.conf.default //nginx.conf的备份文件

2. 日志


logs: 记录入门的文件,当nginx服务器启动后
这里面会有 access.log error.log 和nginx.pid三个文件出现。

3. 资源目录


html //存放nginx自带的两个静态的html页面   
50x.html //访问失败后的失败页面
index.html //成功访问的默认首页

4. 备份文件


fastcgi.conf:fastcgi  //相关配置文件
fastcgi.conf.default //fastcgi.conf的备份文件
fastcgi_params //fastcgi的参数文件
fastcgi_params.default //fastcgi的参数备份文件
scgi_params //scgi的参数文件
scgi_params.default //scgi的参数备份文件
uwsgi_params //uwsgi的参数文件
uwsgi_params.default //uwsgi的参数备份文件
mime.types //记录的是HTTP协议中的Content-Type的值和文件后缀名的对应关系
mime.types.default //mime.types的备份文件

5.编码文件


koi-utf、koi-win、win-utf这三个文件都是与编码转换映射相关的配置文件,
用来将一种编码转换成另一种编码

6. 执行文件


sbin: 是存放执行程序文件nginx

7. 命令


nginx: 是用来控制Nginx的启动和停止等相关的命令。

四、nginx常用命令



  1. 常见2种启动命令


> nginx //直接nginx启动,前提是配好nginx环境变量
> systemctl start nginx.service //使用systemctl命令启动


  1. 常见的4种停止命令


> nginx  -s stop //立即停止服务
> nginx -s quit // 从容停止服务 需要进程完成当前工作后再停止
> killall nginx //直接杀死nginx进程
> systemctl stop nginx.service //systemctl停止


  1. 常见的2种重启命令


> nginx -s reload //重启nginx
> systemctl reload nginx.service //systemctl重启nginx


  1. 验证nginx配置文件是否正确


> nginx -t //输出nginx.conf syntax is ok即表示nginx的配置文件正确


五、nginx配置详细介绍


1. 配置文件的结构介绍


为了让大家有个简单的轮廓,这里先对配置文件做一个简单的描述:


worker_processes  1;                			# worker进程的数量
events { # 事件区块开始
worker_connections 1024; # 每个worker进程支持的最大连接数
} # 事件区块结束
http { # HTTP区块开始
include mime.types; # Nginx支持的媒体类型库文件
default_type application/octet-stream; # 默认的媒体类型
sendfile on; # 开启高效传输模式
keepalive_timeout 65; # 连接超时
server { # 第一个Server区块开始,表示一个独立的虚拟主机站点
listen 80; # 提供服务的端口,默认80
server_name localhost; # 提供服务的域名主机名
location / { # 第一个location区块开始
root html; # 站点的根目录,相当于Nginx的安装目录
index index.html index.htm; # 默认的首页文件,多个用空格分开
} # 第一个location区块结果
error_page 500502503504 /50x.html; # 出现对应的http状态码时,使用50x.html回应客户
location = /50x.html { # location区块开始,访问50x.html
root html; # 指定对应的站点目录为html
}
}
......



  1. ngxin.conf 相当于是入口文件,nginx启动后会先从nginx.conf里面读取基础配置

  2. conf 目录下面的各种xxx.conf文件呢,一般就是每一个应用的配置,比如a网站的nginx配置叫a.conf,b网站的叫b.conf,可以方便我们去便于管理

  3. 加载conf目录下的配置,在主配置文件nginx.conf中,一般会有这么一行代码


2. nginx.conf主配置文件详细介绍


image.png


3. xx.conf 子配置文件详细介绍


我们最常改动nginx的,就是子配置文件


image.png


4. 关于location匹配


    #优先级1,精确匹配,根路径
location =/ {
return 400;
}

#优先级2,以某个字符串开头,以av开头的,优先匹配这里,区分大小写
location ^~ /av {
root /data/av/;
}

#优先级3,区分大小写的正则匹配,匹配/media*****路径
location ~ /media {
alias /data/static/;
}

#优先级4 ,不区分大小写的正则匹配,所有的****.jpg|gif|png 都走这里
location ~* .*\.(jpg|gif|png|js|css)$ {
root /data/av/;
}

#优先7,通用匹配
location / {
return 403;
}

更多配置


六、nginx反向代理、负载均衡 简单介绍


1. 反向代理


在聊反向代理之前,我们先看看正向代理,正向代理也是大家最常接触的到的代理模式,我们会从两个方面来说关于正向代理的处理模式,分别从软件方面和生活方面来解释一下什么叫正向代理,也说说正反向代理的区别


正向代理


正向代理,"它代理的是客户端",是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。客户端必须要进行一些特别的设置才能使用正向代理
正向代理的用途:



  • 访问原来无法访问的资源,如Google

  • 可以做缓存,加速访问资源

  • 对客户端访问授权,上网进行认证

  • 代理可以记录用户访问记录(上网行为管理),对外隐藏用户信息


反向代理


反向代理,"它代理的是服务端",主要用于服务器集群分布式部署的情况下,反向代理隐藏了服务器的信息
反向代理的作用:



  • 保证内网的安全,通常将反向代理作为公网访问地址,Web服务器是内网

  • 负载均衡,通过反向代理服务器来优化网站的负载


image.png


2. 负载均衡


服务器接收不同客户端发送的、Nginx反向代理服务器接收到的请求数量,就是我们说的负载量。
这些请求数量按照一定的规则进行分发到不同的服务器处理的规则,就是一种均衡规则。
所以,将服务器接收到的请求按照规则分发的过程,称为负载均衡

负载均衡也分硬件负载均衡和软件负载均衡两种,我们来讲的是软件负载均衡,关于硬件负载均衡的有兴趣的靓仔可以去了解下
负载均衡的算法:



  • 轮询(默认、加权轮询、ip_hash)

  • 插件(fair、url_hash),url_hash和ip_hash大同小异,一个基于ip一个基于url,就不过多介绍了


默认轮询


每个请求按时间顺序逐一分配到不同的后端服务器,如果后端某个服务器宕机,能自动剔除故障系统。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001;
server localhost:10002;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

加权轮询


通过设置weight,值越大分配率越大
到的访问概率越高,主要用于后端每台服务器性能不均衡的情况下。其次是为在主从的情况下设置不同的权值,达到合理有效的地利用主机资源。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

权重越大,被访问的概率越大,比如上面就是33.33%和百分66.66%的访问概率
访问的效果:

localhost:10001、localhost:10002、localhost:10002、localhost:10001、localhost:10002、localhost:10002


ip_hash


每个请求都根据访问ip的hash结果分配,经过这样的处理,每个访客固定访问一个后端服务,如下配置(ip_hash可以和weight配合使用),并且可以有效解决动态网页存在的session共享问题


upstream constPolling {
ip_hash;
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}

fair


个人比较喜欢用的一种负载均衡算法,fair算法可以根据页面大小和加载时间长短智能地进行负载均衡,响应时间短的优先分配。



  1. 安装upstream_fair模块 附上fair安装教程

  2. 哪个服务器的响应速度快,就将请求分配到那个服务器上


upstream constPolling { 
server localhost:10001;
server localhost:10002;
fair;
}

七、nginx错误页面配置、开启Gzip压缩配置


1. nginx错误页面配置


当我们访问的地址不存在的时候,我们可以根据http状态码来做对应的处理,我们以404为例


image.png
当然除了404以为我们还可以根据其他的状态码显示的,比如500、502等,熊猫的公司项目中,因为多个项目的错误页面都是统一的,所以我们有单独维护的一套错误码页面放到了我们公司的中台项目中,然后根据客户端是PC/移动端,跳转到对应的错误页面


2.Gzip压缩


Gzip是网页的一种网页压缩技术,经过gzip压缩后,页面大小可以变为原来的30%甚至更小。更小的网页会让用户浏览的体验更好,速度更快。gzip网页压缩的实现需要浏览器和服务器的支持

gzip是需要服务器和浏览器同时支持的。当浏览器支持gzip压缩时,会在请求消息中包含Accept-Encoding:gzip,这样Nginx就会向浏览器发送听过gzip后的内容,同时在相应信息头中加入Content-Encoding:gzip,声明这是gzip后的内容,告知浏览器要先解压后才能解析输出。
如果项目是在ie或者一些兼容性比较低浏览器上运行的,需要去查阅确定是否浏览器支持gzip


server {

listen 12089;

index index.php index.html;

error_log /var/log/nginx/error.log;

access_log /var/log/nginx/access.log;

root /var/www/html/gzip;
# 开启gzip压缩

gzip on;

# http请求版本

gzip_http_version 1.0;

# 设置什么类型的文件需要压缩

gzip_types text/css text/javascript application/javascript image/png image/jpeg image/gif;

location / {

index index.html index.htm index.php;

autoindex off;

}

}

gzip_types对应需要什么格式,可以去查看content-Type


image.png


Content-Type: text/css

# 成功开启gzip
Content-Encoding: gzip

八、常用全局变量
































































































变量含义
$args这个变量等于请求行中的参数,同$query_string
$content length请求头中的Content-length字段。
$content_type请求头中的Content-Type字段。
$document_root当前请求在root指令中指定的值。
$host请求主机头字段,否则为服务器名称。
$http_user_agent客户端agent信息
$http_cookie客户端cookie信息
$limit_rate这个变量可以限制连接速率。
$request_method客户端请求的动作,通常为GET或POST。
$remote_addr客户端的IP地址。
$remote_port客户端的端口。
$remote_user已经经过Auth Basic Module验证的用户名。
$request_filename当前请求的文件路径,由root或alias指令与URI请求生成。
$schemeHTTP方法(如http,https)。
$server_protocol请求使用的协议,通常是HTTP/1.0或HTTP/1.1。
$server_addr服务器地址,在完成一次系统调用后可以确定这个值。
$server_name服务器名称。
$server_port请求到达服务器的端口号。
$request_uri包含请求参数的原始URI,不包含主机名,如”/foo/bar.php?arg=baz”。
$uri不带请求参数的当前URI,$uri不包含主机名,如”/foo/bar.html”。
$document_uri与$uri相同。



九、nginx使用综合场景(在github里面会持续更新和补充)


1. 同一个域名通过不同目录指定不同项目目录


在开发过程中,有一种场景,比如有项目有多个子系统需要通过同一个域名通过不同目录去访问
在A/B Test 灰度发布等场景也会用上

比如:

访问 a.com/a/*** 访问的是a系统

访问 a.com/b/*** 访问的是b系统


image.png


2. 自动适配PC/移动端页面


image.png


3. 限制只能通过谷歌浏览器访问


image.png


4. 前端单页面应用刷新404问题


image.png


更多:包括防盗链、动静分离、权限控制



链接:https://juejin.cn/post/7007346707767754765

收起阅读 »

从零开发一款轻量级滑动验证码插件

效果演示 滑动验证组件基本使用和技术实现 上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果...
继续阅读 »

效果演示


slider.gif


滑动验证组件基本使用和技术实现


上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果大家有一定的技术基础,也可以直接跳到技术实现部分。


基本使用


因为 react-slider-vertify 这款组件我已经发布到 npm 上了,所以大家可以按照如下方式安装和使用:



  1. 安装


# 或者 yarn add @alex_xu/react-slider-vertify
npm i @alex_xu/react-slider-vertify -S


  1. 使用


import React from 'react';
import { Vertify } from '@alex_xu/react-slider-vertify';

export default () => {
return <Vertify
width={320}
height={160}
onSuccess={() => alert('success')}
onFail={() => alert('fail')}
onRefresh={() => alert('refresh')}
/>
};

通过以上两步我们就可以轻松使用这款滑动验证码组件了,是不是很简单?
image.png


当然我也暴露了很多可配置的属性,让大家对组件有更好的控制。参考如下:


image.png


技术实现


在做这个项目之前我也研究了一些滑动验证码的知识以及已有的技术方案,收获很多。接下来我会以我的组件设计思路来和大家介绍如何用 react 来实现和封装滑动验证码组件,如果大家有更好的想法和建议, 也可以在评论区随时和我反馈。


1.组件设计的思路和技巧


每个人都有自己设计组件的方式和风格,但最终目的都是更 优雅 的设计组件。这里我大致列举一下 优雅 组件的设计指标:




  • 可读性(代码格式统一清晰,注释完整,代码结构层次分明,编程范式使用得当)




  • 可用性(代码功能完整,在不同场景都能很好兼容,业务逻辑覆盖率)




  • 复用性(代码可以很好的被其他业务模块复用)




  • 可维护性(代码易于维护和扩展,并有一定的向下/向上兼容性)




  • 高性能




以上是我自己设计组件的考量指标,大家可以参考一下。


另外设计组件之前我们还需要明确需求,就拿滑动验证码组件举例,我们需要先知道它的使用场景(用于登录注册、活动、论坛、短信等高风险业务场景的人机验证服务)和需求(交互逻辑,以什么样的方式验证,需要暴露哪些属性)。


image.png


以上就是我梳理的一个大致的组件开发需求,在开发具体组件之前,如果遇到复杂的业务逻辑,我们还可以将每一个实现步骤列举出来,然后一一实现,这样有助于整理我们的思路和更高效的开发。


2.滑动验证码基本实现原理


在介绍完组件设计思路和需求分析之后,我们来看看滑动验证码的实现原理。


image.png


我们都知道设计验证码的主要目的是为了防止机器非法暴力地入侵我们的应用,其中核心要解决的问题就是判断应用是谁在操作( or 机器),所以通常的解决方案就是随机识别


上图我们可以看到只有用户手动将滑块拖拽到对应的镂空区域,才算验证成功,镂空区域的位置是随机的(随机性测试这里暂时以前端的方式来实现,更安全的做法是通过后端来返回位置和图片)。


基于以上分析我们就可以得出一个基本的滑动验证码设计原理图:


image.png


接下来我们就一起封装这款可扩展的滑动验证码组件。


3.封装一款可扩展的滑动验证码组件


按照我开发组件一贯的风格,我会先基于需求来编写组件的基本框架:


import React, { useRef, useState, useEffect, ReactNode } from 'react';

interface IVertifyProp {
/**
* @description canvas宽度
* @default 320
*/
width:number,
/**
* @description canvas高度
* @default 160
*/
height:number,
/**
* @description 滑块边长
* @default 42
*/
l:number,
/**
* @description 滑块半径
* @default 9
*/
r:number,
/**
* @description 是否可见
* @default true
*/
visible:boolean,
/**
* @description 滑块文本
* @default 向右滑动填充拼图
*/
text:string | ReactNode,
/**
* @description 刷新按钮icon, 为icon的url地址
* @default -
*/
refreshIcon:string,
/**
* @description 用于获取随机图片的url地址
* @default https://picsum.photos/${id}/${width}/${height}, 具体参考https://picsum.photos/, 只需要实现类似接口即可
*/
imgUrl:string,
/**
* @description 验证成功回调
* @default ():void => {}
*/
onSuccess:VoidFunction,
/**
* @description 验证失败回调
* @default ():void => {}
*/
onFail:VoidFunction,
/**
* @description 刷新时回调
* @default ():void => {}
*/
onRefresh:VoidFunction
}

export default ({
width = 320,
height = 160,
l = 42,
r = 9,
imgUrl,
text,
refreshIcon = 'http://yourimgsite/icon.png',
visible = true,
onSuccess,
onFail,
onRefresh
}: IVertifyProp) => {
return <div className="vertifyWrap">
<div className="canvasArea">
<canvas width={width} height={height}></canvas>
<canvas className="block" width={width} height={height}></canvas>
</div>
<div className={sliderClass}>
<div className="sliderMask">
<div className="slider">
<div className="sliderIcon">&rarr;</div>
</div>
</div>
<div className="sliderText">{ textTip }</div>
</div>
<div className="refreshIcon" onClick={handleRefresh}></div>
<div className="loadingContainer">
<div className="loadingIcon"></div>
<span>加载中...</span>
</div>
</div>
}

以上就是我们组件的基本框架结构。从代码中可以发现组件属性一目了然,这都是提前做好需求整理带来的好处,它可以让我们在编写组件时思路更清晰。在编写好基本的 css 样式之后我们看到的界面是这样的:


image.png


接下来我们需要实现以下几个核心功能:



  • 镂空效果的 canvas 图片实现

  • 镂空图案 canvas 实现

  • 滑块移动和验证逻辑实现


上面的描述可能比较抽象,我画张图示意一下:


image.png


因为组件实现完全采用的 react hooks ,如果大家对 hooks 不熟悉也可以参考我之前的文章:



1.实现镂空效果的 canvas 图片


image.png


在开始 coding 之前我们需要对 canvas 有个基本的了解,建议不熟悉的朋友可以参考高效 canvas 学习文档: Canvas of MDN


由上图可知首先要解决的问题就是如何用 canvas 画不规则的图形,这里我简单的画个草图:


image.png


我们只需要使用 canvas 提供的 路径api 画出上图的路径,并将路径填充为任意半透明的颜色即可。建议大家不熟悉的可以先了解如下 api :



  • beginPath() 开始路径绘制

  • moveTo() 移动笔触到指定点

  • arc() 绘制弧形

  • lineTo() 画线

  • stroke() 描边

  • fill() 填充

  • clip() 裁切路径


实现方法如下:


const drawPath  = (ctx:any, x:number, y:number, operation: 'fill' | 'clip') => {
ctx.beginPath()
ctx.moveTo(x, y)
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
ctx.lineTo(x + l, y)
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
ctx.lineTo(x + l, y + l)
ctx.lineTo(x, y + l)
// anticlockwise为一个布尔值。为true时,是逆时针方向,否则顺时针方向
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
ctx.lineTo(x, y)
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'
ctx.stroke()
ctx.globalCompositeOperation = 'destination-over'
// 判断是填充还是裁切, 裁切主要用于生成图案滑块
operation === 'fill'? ctx.fill() : ctx.clip()
}

这块实现方案也是参考了 yield 大佬的原生 js 实现,这里需要补充的一点是 canvasglobalCompositeOperation 属性,它的主要目的是设置如何将一个源(新的)图像绘制到目标(已有)的图像上。




  • 源图像 = 我们打算放置到画布上的绘图




  • 目标图像 = 我们已经放置在画布上的绘图




w3c上有个形象的例子:


image.png


这里之所以设置该属性是为了让镂空的形状不受背景底图的影响并覆盖在背景底图的上方。如下:


image.png


接下来我们只需要将图片绘制到画布上即可:


const canvasCtx = canvasRef.current.getContext('2d')
// 绘制镂空形状
drawPath(canvasCtx, 50, 50, 'fill')

// 画入图片
canvasCtx.drawImage(img, 0, 0, width, height)

当然至于如何生成随机图片和随机位置,实现方式也很简单,前端实现的话采用 Math.random 即可。


2.实现镂空图案 canvas


上面实现了镂空形状,那么镂空图案也类似,我们只需要使用 clip() 方法将图片裁切到形状遮罩里,并将镂空图案置于画布左边即可。代码如下:


const blockCtx = blockRef.current.getContext('2d')
drawPath(blockCtx, 50, 50, 'clip')
blockCtx.drawImage(img, 0, 0, width, height)

// 提取图案滑块并放到最左边
const y1 = 50 - r * 2 - 1
const ImageData = blockCtx.getImageData(xRef.current - 3, y1, L, L)
// 调整滑块画布宽度
blockRef.current.width = L
blockCtx.putImageData(ImageData, 0, y1)

上面的代码我们用到了 getImageDataputImageData,这两个 api 主要用来获取 canvas 画布场景像素数据和对场景进行像素数据的写入。实现后 的效果如下:


image.png


3.实现滑块移动和验证逻辑


实现滑块移动的方案也比较简单,我们只需要利用鼠标的 event 事件即可:



  • onMouseDown

  • onMouseMove

  • onMouseUp


image.png


以上是一个简单的示意图,具体实现代码如下:


const handleDragMove = (e) => {
if (!isMouseDownRef.current) return false
e.preventDefault()
// 为了支持移动端, 可以使用e.touches[0]
const eventX = e.clientX || e.touches[0].clientX
const eventY = e.clientY || e.touches[0].clientY
const moveX = eventX - originXRef.current
const moveY = eventY - originYRef.current
if (moveX < 0 || moveX + 36 >= width) return false
setSliderLeft(moveX)
const blockLeft = (width - l - 2r) / (width - l) * moveX
blockRef.current.style.left = blockLeft + 'px'
}

当然我们还需要对拖拽停止后的事件做监听,来判断是否验证成功,并埋入成功和失败的回调。代码如下:


const handleDragEnd = (e) => {
if (!isMouseDownRef.current) return false
isMouseDownRef.current = false
const eventX = e.clientX || e.changedTouches[0].clientX
if (eventX === originXRef.current) return false
setSliderClass('sliderContainer')
const { flag, result } = verify()
if (flag) {
if (result) {
setSliderClass('sliderContainer sliderContainer_success')
// 成功后的自定义回调函数
typeof onSuccess === 'function' && onSuccess()
} else {
// 验证失败, 刷新重置
setSliderClass('sliderContainer sliderContainer_fail')
setTextTip('请再试一次')
reset()
}
} else {
setSliderClass('sliderContainer sliderContainer_fail')
// 失败后的自定义回调函数
typeof onFail === 'function' && onFail()
setTimeout(reset.bind(this), 1000)
}
}

实现后的效果如下:


chrome-capture (4).gif


当然还有一些细节需要优化处理,这里在 github 上有完整的代码,大家可以参考学习一下,如果大家想对该组件参与贡献,也可以随时提 issue


4.如何使用 dumi 搭建组件文档


为了让组件能被其他人更好的理解和使用,我们可以搭建组件文档。作为一名热爱开源的前端 coder,编写组件文档也是个很好的开发习惯。接下来我们也为 react-slider-vertify 编写一下组件文档,这里我使用 dumi 来搭建组件文档,当然大家也可以用其他方案(比如storybook)。我们先看一下搭建后的效果:


image.png


image.png


dumi 搭建组件文档非常简单,接下来和大家介绍一下安装使用方式。



  1. 安装


$ npx @umijs/create-dumi-lib        # 初始化一个文档模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib

$ npx @umijs/create-dumi-lib --site # 初始化一个站点模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib --site


  1. 本地运行


npm run dev
# or
yarn dev


  1. 编写文档


dumi 约定式的定义了文档编写的位置和方式,其官网上也有具体的饭介绍,这里简单给大家上一个 dumi 搭建的组件目录结构图:


image.png


我们可以在 docs 下编写组件库文档首页和引导页的说明,在单个组件的文件夹下使用 index.md 来编写组件自身的使用文档,当然整个过程非常简单,我这里举一个文档的例子:


image.png


通过这种方式 dumi 就可以帮我们自动渲染一个组件使用文档。如果大家想学习更多组件文档搭建的内容,也可以在 dumi 官网学习。


5.发布自己第一个npm组件包


最后一个问题就是组件发布。之前很多朋友问我如何将自己的组件发布到 npm 上让更多人使用,这块的知识网上有很多资料可以学习,那今天就以滑动验证码 @alex_xu/react-slider-vertify 的例子,来和大家做一个简单的介绍。



  1. 拥有一个 npm 账号并登录


如果大家之前没有 npm 账号,可以在 npm 官网 注册一个,然后用我们熟悉的 IDE 终端登录一次:


npm login

跟着提示输入完用户名密码之后我们就能通过命令行发布组件包了:


npm publish --access public

之所以指令后面会加 public 参数,是为了避免权限问题导致组件包无法发布成功。我们为了省事也可以把发布命令配置到 package.json 中,在组件打包完成后自动发布:


{
"scripts": {
"start": "dumi dev",
"release": "npm run build && npm publish --access public",
}
}

这样我们就能将组件轻松发布到 npm 上供他人使用啦! 我之前也开源了很多组件库,如果大家对组件打包细节和构建流程有疑问,也可以参考我之前开源项目的方案。 发布到 npm 后的效果:


image.png


最后


如果大家对可视化搭建或者低代码/零代码感兴趣,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端真正的技术。


链接:https://juejin.cn/post/7007615666609979400

收起阅读 »

精益求精!记一次业务代码的优化探索

关键词:需求实现、设计模式、策略模式、程序员成长 承启: 本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。 会介绍一个运用策略模式的实战。 需求和编码本身小于打怪升级成长路径。 文中代码为伪代码。 场景...
继续阅读 »

关键词:需求实现、设计模式、策略模式、程序员成长



承启:


本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。



  • 会介绍一个运用策略模式的实战。

  • 需求和编码本身小于打怪升级成长路径。

  • 文中代码为伪代码。


场景说明:


需求描述:手淘内“充值中心”要投放在饿了么、淘宝极速版、UC浏览器等集团二方APP。
拿到需求之后,来梳理下“充值中心”在他端投放涉及到的核心功能点



  • 通讯录读取


不同客户端、操作系统,JSbridge API实现略有不同。



  • 支付


不同端支付JSbridge调用方式不同。



  • 账号体系:


集团内不同端账号体系可能不同,需要打通。



  • 容器兼容


手淘内采用PHA容器,淘宝极简版本投放H5,饿了么以手淘小程序的方式投放。环境变量、通信方式等需要兼容。



  • 各端个性化诉求


极速版投放极简链路,只保留核心模块等。


解决方案


需求明确了:充值相关核心模块,需要兼容每个APP,本质是提供一个多端投放的解决方案
那么这个场景如何编码实现呢?


1、方案一


首先第一个想法💡,在每个功能点模块用if-else判断客户端环境,编写此端逻辑。
下面以获取通讯录列表功能为例,代码如下:


// 业务代码文件 index.js
/**
* 获取通讯录列表
* @param clientName 端名称
*/
const getContactsList = (clientName) => {
if (clientName === 'eleme') {
getContactsListEleme()
} else if (clientName === 'taobao') {
getContactsListTaobao()
} else if (clientName === 'tianmao') {
getContactsListTianmao()
} else if (clientName === 'zhifubao') {
getContactsListZhifubao()
} else {
// 其他端
}
}

写完之后,review一下代码,思考一下这样编码的利弊。


:逻辑清晰,可快速实现。

:代码不美观、可读性略差,每兼容一个端都要在业务逻辑处改动,改一端测多端。


这时,有的同学就说了:“把if-else改成switch-case的写法,把获取通讯录模块抽象成独立的sdk封装,用户在业务层统一调用”,天才!动手实现一下。


2、方案二


核心功能模块,抽象成独立的sdk,模块内部对不同的端进行兼容,业务逻辑里统一方式调用。


/**
* 获取通讯录列表 sdk caontact.js
* @param clientName 端名称
* @param successCallback 成功回调
* @param failCallback 失败回调
*/
export default function (clientName, successCallback, failCallback) {
switch (clientName) {
case 'eleme':
getContactsListEleme()
break
case 'taobao':
getContactsListTaobao()
break
case 'zhifubao':
getContactsListTianmao()
break
case 'tianmao':
getContactsListZhifubao()
break
default:
// 省略
break
}
}

// 业务调用 index.js
<Contacts onIconClick={handleContactsClick} />

import getContactsList from 'Contacts'
import { clientName } from 'env'
const handleContactsClick = () => {
getContactsList(
clientName,
({ arr }) => {
this.setState({
contactsList: arr
})
},
() => {
alert('获取通讯录失败')
}
)
}

惯例,review一下代码:


:模块分工明确,业务层统一调用,代码可读性较高。

:多端没有解藕,每次迭代,需要各个端回归。


上面的实现,看起来代码可读性提高了不少,是一个不错的设计,可是这样是最优的设计吗?


3、方案三


熟悉设计模式的同学,这时候可能要说了,用策略模式啊,对了,这个场景可以用策略模式。
这里简单解释一下策略模式:
策略模式,英文全称是 Strategy Design Pattern。
在 GoF 的《设计模式》一书中,它是这样定义的:



Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.



翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。


难免有些晦涩,什么意思呢?我个人的理解为:策略模式用来解耦策略的定义、创建、使用。它典型的应用场景就是:避免冗长的if-else或switch分支判断编码。


下面看代码实现:


/**
* 策略定义
*/
const strategies = {
eleme: () => {
getContactsListEleme()
},
taobao: () => {
getContactsListTaobao()
},
tianmao: () => {
// 省略
}
}
/**
* 策略创建
*/
const getContactsStrategy = (clientName) => {
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
}
/**
* 策略使用
*/
import { clientName } from 'env'
getContactsStrategy(clientName)()

策略模式的运用,把策略的定义、创建、使用解耦,符合设计原则中的迪米特法则(LOD),实现“高内聚、松耦合”。
当需要新增一个适配端时,我们只需要修改策略定义Map,其他代码都不需要修改,这样就将代码改动最小化、集中化了。


能做到这里,相信你已经超越了一部分同学了,但是我们还要思考、精益求精,如何更优呢?这个时候单从编码层面思考已经受阻塞了,可否从工程构建角度、性能优化角度、项目迭代流程角度、后期代码维护角度思考一下,相信你会有更好的想法。


下面抛砖,聊聊我自己的思考:


4、方案四


从工程构建和性能优化角度出发:如果每个端独立一个文件,构建的时候shake掉其他端chunk,这样bundle可以变更小,网络请求也变更快。



等等... Tree-Shaking是基于ES静态分析,我们的策略判断,基于运行时,好像没什么用啊。



方案三使用策略模式来编码,本质是策略定义、创建和使用解藕,那可否使用刚才的想法,把每端各个功能模块兼容方法聚合成独立module,从更高维度,将多端业务策略定义、创建和使用解藕?



思考一下这样做的收益是什么?



因为每个端的适配,聚合在一个module,将多端业务策略解藕,某个端策略变更,只需要修改此端module,代码改动较小,且后续测试链路,不需要重复回归其他端。符合“高内聚、松耦合”。


代码实现:


/**
* 饿了么端策略定义module
*/
export const elmcStrategies = {
contacts: () => {
getContactsListEleme()
},
pay: () => {
payEleme()
},
// 其他功能略
}
/**
* 手淘端策略定义module
*/
export const tbStrategies = {
contacts: () => {
getContactsListTaobao()
},
pay: () => {
payTaobao()
},
// 其他功能略
};
// ...... (其他端略)
/**
* 策略创建 index.js
*/
import tbStrategies from './tbStrategies'
import elmcStrategies from './elmcStrategies'
export const getClientStrategy = (clientName) => {
const strategies = {
elmc: elmcStrategies,
tb: tbStrategies
// ...
}
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
};
/**
* 策略使用 pay
*/
import { clientName } from 'env'
getClientStrategy(clientName).pay()

代码目录如下图所示:index.js是多端策略的入口,其他文件为各端策略实现。




从方案四的推导来看,有时候,判断不一定是对的,但是从多个维度去思考,会打开思路,这时,更优方案往往就找上门来了~



5、方案五


既要解决眼前痛点,也要长远谋划,基于以上四种方案,再深入思考一步,如果业务有投放在第三方(非集团APP)的需求,比如投放在商家APP,且商家APP获取通讯录、支付逻辑等复杂多变,这个时候如何设计编码呢?
例如:拉起别端的唤端策略,受多方因素影响,涉及到产品壁垒,策略攻防,怎样控制代码改动次数,及时提高唤端率呢?
在这里简单抛砖,可以借助近几年很火的serverless,搭建唤端策略的faas函数,动态获取最优唤端策略,是不是一个好的方案呢?


沉淀&思考


以上针对多端兼容的问题,我们学习并运用了设计模式——策略模式。那么我们再来看看策略模式的设计思想是什么:

一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。

实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。

还有一点需要注意,在代码设计时,应该了解他的业务价值和复杂度,避免过度设计,如果一个if-else可以解决的问题,何必大费周折,阔谈设计模式呢?


总结


理一下全文的核心路径,也是我此篇文章想要主要传达的打怪升级成长路径。


接到一个复杂的需求--> 理清需求 --> 拆解技术难点 --> 编码实现 --> 代码优化 --> 设计模式和设计原则学习 --> 举一反三 --> 记录沉淀。


当下,前端工程师在工作中,难免会陷入业务漩涡中,被业务推着走。面对这种风险,我们要思考如何在保障完成业务迭代的基础上,运用适合的技术架构,抽象出通用解决方案,沉淀落地。这样,既能帮助业务更快更稳定增长,又能在这个过程中收获个人成长。


作者:喜橙
链接:https://juejin.cn/post/7006136807263830029

收起阅读 »

使用CSS实现中秋民风民俗-拜月

前言 好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。 看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的...
继续阅读 »

前言


image.png
好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。


看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的,那我来吧。


拜月,在我国是一种十分古老的习俗,实际上是源自我国一些地方古人对“月神”的一种崇拜活动。中秋节是上古天象崇拜——敬月习俗的遗痕,祭月作为中秋节重要的祭礼之一,从古代延续至今,逐渐演化为民间的赏月、颂月活动,同时也成为现代人渴望团聚、寄托对生活美好愿望的主要形态。「以上来自百度百科」


不知道大家那边有没有这个习俗,我老家是有的,每次拜月都会准备很多好吃的,可把我高兴坏了,因为第二天就是我生日,也就是八月十六,所以好吃的贼多。


废话不多说,开始进入正题,源码在这里:中秋拜月


HTML


一个大的div里面套了三个div,分别代表月亮,月亮上的嫦娥玉兔,月亮下的拜月人群。


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>中秋拜月</title>
</head>

<body>
<div class="background">
<div class="moon"></div>
<div class="change"></div>
<div class="table"></div>
</div>
</body>

CSS


星星背景


背景图片是从某位大佬的那里获取的,自己作图能力不行,流汗。很常规,设置了一个长宽适应全屏幕,然后就是背景图。


body {
margin: 0;
}

.background {
width: 100%;
height: calc(100vh);
background: #000 url("https://test-jou.oss-cn-beijing.aliyuncs.com/3760b5e2cc46f556.png") repeat top center;
z-index: 1;
}

image.png


月亮


月亮的颜色用的是#ff0,搭配了一个相符的阴影,之所以没有用白色,是我感觉主色调用黄色更能显示拜月的“神圣性”「非迷信」


 .moon {
position: absolute;
left: 108px;
top: 81px;
width: 180px;
height: 180px;
background-color: #ff0;
border-radius: 50%;
box-shadow: 0 0 20px 20px rgba(247, 247, 9, 0.5);
}

image.png


嫦娥和月兔


把嫦娥和月兔放到月亮上,有种嫦娥就在注视人间的味道


.change {
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/unnamed.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
position: absolute;
left: 100px;
top: 81px;
width: 180px;
height: 180px;
z-index: 99;
}

image.png


拜月人群


我感觉这是最不搭配的图了,如有不适,敬请谅解


  .table{
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/table.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
bottom: 0;
width: 640px;
right: 0;
height: 450px;
position: absolute;
}

image.png


总结


好几年中秋没有回家了,也有可能是忙或者其他原因,家里近几年中秋也不拜月了。有的时候会很怀念小时候,长大了,小时候也回不去了,因为是后端的缘故,页面也不是很懂,留作纪念吧……


附言


以前,车马很远,书信很慢,一生只够爱一个人「有感」


作者:Jouzeyu
链接:https://juejin.cn/post/7007288677831278628

收起阅读 »

一顿操作,我把 Table 组件性能提升了十倍

背景 Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。 通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM ...
继续阅读 »

背景


Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。


通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM 渲染的数量。在我们公司的项目中,会选择分页的方式,因为虚拟滚动不能正确的读出行的数量,会有 Accessibility 的问题。


记得 19 年的时候,我在 Zoom 已经推行了基于 Vue.js 的前后端分离的优化方案,并且基于 ElementUI 组件库开发了 ZoomUI。其中我们在重构用户管理页面的时候使用了 ZoomUI 的 Table 组件替换了之前老的用 jQuery 开发的 Table 组件。


因为绝大部分场景 Table 组件都是分页的,所以并不会有性能问题。但是在某个特殊场景下:基于关键词的搜索,可能会出现 200 * 20 条结果且不分页的情况,且表格是有一列是带有 checkbox 的,也就是可以选中某些行进行操作。


当我们去点选其中一行时,发现过了好久才选中,有明显的卡顿感,而之前的 jQuery 版本却没有这类问题,这一比较令人大跌眼镜。难道好好的技术重构,却要牺牲用户体验吗?


Table 组件第一次优化尝试


既然有性能问题,那么我们的第一时间的思路应该是要找出产生性能问题的原因。


列展示优化


首先,ZoomUI 渲染的 DOM 数量是要多于 jQuery 渲染的 Table 的,因此第一个思考方向是让 Table 组件尽可能地减少 DOM 的渲染数量


20 列数据通常在屏幕下是展示不全的,老的 jQuery Table 实现很简单,底部有滚动条,而 ZoomUI 在这种列可滚动的场景下,支持了左右列的固定,这样在左右滑动过程中,可以固定某些列一直展示,用户体验更好,但这样的实现是有一定代价的。


想要实现这种固定列的布局,ElementUI 用了 6 个 table 标签来实现,那么为什么需要 6 个 table 标签呢?


首先,为了让 Table 组件支持丰富的表头功能,表头和表体都是各自用一个 table 标签来实现。因此对于一个表格来说,就会有 2 个 table 标签,那么再加上左侧 fixed 的表格,和右侧 fixed 的表格,总共有 6 个 table 标签。


在 ElementUI 实现中,左侧 fixed 表格和右侧 fixed 表格从 DOM 上都渲染了完整的列,然后从样式上控制它们的显隐:


element.png


element1.png


但这么实现是有性能浪费的,因为完全不需要渲染这么多列,实际上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。ZoomUI 就是这么实现的,效果如下:


zoom-ui.png
当然,仅仅减少 fixed 表格渲染的列,性能的提升还不够明显,有没有办法在列的渲染这个维度继续优化呢?


这就是从业务层面的优化了,对于一个 20 列的表格,往往关键的列并没有多少,那么我们可不可以初次渲染仅仅渲染关键的列,其它列通过配置方式的渲染呢?


根据上述需求,我给 Table 组件添加了如下功能:


zoom-ui1.png


Table 组件新增一个 initDisplayedColumn 属性,通过它可以配置初次渲染的列,同时当用户修改了初次渲染的列,会在前端存储下来,便于下一次的渲染。


通过这种方式,我们就可以少渲染一些列。显然,列渲染少了,表格整体渲染的 DOM 数就会变少,对性能也会有一定的提升。


更新渲染的优化


当然,仅仅通过优化列的渲染还是不够的,我们遇到的问题是当点选某一行引起的渲染卡顿,为什么会引起卡顿呢?


为了定位该问题,我用 Table 组件创建了一个 1000 * 7 的表格,开启了 Chrome 的 Performance 面板记录 checkbox 点选前后的性能。


在经过几次 checkbox 选择框的点选后,可以看到如下火焰图:


element2.png


其中黄色部分是 Scripting 脚本的执行时间,紫色部分是 Rendering 所占的时间。我们再截取一次更新的过程:


element3.png


然后观察 JS 脚本执行的 Call Tree,发现时间主要花在了 Table 组件的更新渲染上


element4.png


我们发现组件的 render to vnode 花费的时间约 600ms;vnode patch to DOM 花费的时间约 160ms。


为什么会需要这么长时间呢,因为点选了 checkbox,在组件内部修改了其维护的选中状态数据,而整个组件的 render 过程中又访问了这个状态数据,因此当这个数据修改后,会引发整个组件的重新渲染。


而又由于有 1000 * 7 条数据,因此整个表格需要循环 1000 * 7 次去创建最内部的 td,整个过程就会耗时较长。


那么循环的内部是不是有优化的空间呢?对于 ElementUI 的 Table 组件,这里有非常大的优化空间。


其实优化思路主要参考我之前写的 《揭秘 Vue.js 九个性能优化技巧》 其中的 Local variables 技巧。举个例子,在 ElementUI 的 Table 组件中,在渲染每个 td 的时候,有这么一段代码:


const data = {
store: this.store,
_self: this.context || this.table.$vnode.context,
column: columnData,
row,
$index
}

这样的代码相信很多小伙伴随手就写了,但却忽视了其内部潜在的性能问题。


由于 Vue.js 响应式系统的设计,在每次访问 this.store 的时候,都会触发响应式数据内部的 getter 函数,进而执行它的依赖收集,当这段代码被循环了 1000 * 7 次,就会执行 this.store 7000 次的依赖收集,这就造成了性能的浪费,而真正的依赖收集只需要执行一次就足够了。


解决这个问题其实也并不难,由于 Table 组件中的 TableBody 组件是用 render 函数写的,我们可以在组件 render 函数的入口处定义一些局部变量:


render(h) {
const { store /*...*/} = this
const context = this.context || this.table.$vnode.context
}

然后在渲染整个 render 的过程中,把局部变量当作内部函数的参数传入,这样在内部渲染 td 的渲染中再次访问这些变量就不会触发依赖收集了:


rowRender({store, context, /* ...其它变量 */}) {
const data = {
store: store,
_self: context,
column: columnData,
row,
$index,
disableTransition,
isSelectedRow
}
}

通过这种方式,我们把类似的代码都做了修改,就实现了 TableBody 组件渲染函数内部访问这些响应式变量,只触发一次依赖收集的效果,从而优化了 render 的性能。


来看一下优化后的火焰图:


zoom-ui2.png


从面积上看似乎 Scripting 的执行时间变少了,我们再来看它一次更新所需要的 JS 执行时间:


zoom-ui3.png


我们发现组件的 render to vnode 花费的时间约 240ms;vnode patch to DOM 花费的时间约 127ms。


可以看到,ZoomUI Table 组件的 render 的时间和 update 的时间都要明显少于 ElementUI 的 Table 组件。render 时间减少是由于响应式变量依赖收集的时间大大减少,update 的时间的减少是因为 fixed 表格渲染的 DOM 数量减少。


从用户的角度来看,DOM 的更新除了 Scripting 的时间,还有 Rendering 的时间,它们是共享一个线程的,当然由于 ZoomUI Table 组件渲染的 DOM 数量更少,执行 Rendering 的时间也更短。


手写 benchmark


仅仅从 Performance 面板的测试并不是一个特别精确的 benchmark,我们可以针对 Table 组件手写一个 benchmark。


我们可以先创建一个按钮,去模拟 Table 组件的选中操作:


<div>
<zm-button @click="toggleSelection(computedData[1])
">切换第二行选中状态
</zm-button>
</div>
<div>
更新所需时间: {{ renderTime }}
</div>

然后实现这个 toggleSelection 函数:


methods: {
toggleSelection(row) {
const s = window.performance.now()
if (row) {
this.$refs.table.toggleRowSelection(row)
}
setTimeout(() => {
this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
})
}
}

我们在点击事件的回调函数中,通过 window.performance.now() 记录起始时间,然后在 setTimeout 的回调函数中,再去通过时间差去计算整个更新渲染需要的时间。


由于 JS 的执行和 UI 渲染占用同一线程,因此在一个宏任务执行过程中,会执行这俩任务,而 setTimeout 0 会把对应的回调函数添加到下一个宏任务中,当该回调函数执行,说明上一个宏任务执行完毕,此时做时间差去计算性能是相对精确的。


基于手写的 benchmark 得到如下测试结果:


element5.png


ElementUI Table 组件一次更新的时间约为 900ms。


zoom-ui4.png


ZoomUI Table 组件一次更新的时间约为 280ms,相比于 ElementUI 的 Table 组件,性能提升了约三倍


v-memo 的启发


经过这一番优化,基本解决了文章开头提到的问题,在 200 * 20 的表格中去选中一列,已经并无明显的卡顿感了,但相比于 jQuery 实现的 Table,效果还是要差了一点。


虽然性能优化了三倍,但我还是有个心结:明明只更新了一行数据的选中状态,却还是重新渲染了整个表格,仍然需要在组件 render 的过程中执行多次的循环,在 patch 的过程中通过 diff 算法来对比更新。


最近我研究了 Vue.js 3.2 v-memo 的实现,看完源码后,我非常激动,因为发现这个优化技巧似乎可以应用到 ZoomUI 的 Table 组件中,尽管我们的组件库是基于 Vue 2 版本开发的。


我花了一个下午的时间,经过一番尝试,果然成功了,那么具体是怎么做的呢?先不着急,我们从 v-memo 的实现原理说起。


v-memo 的实现原理


v-memo 是 Vue.js 3.2 版本新增的指令,它可以用于普通标签,也可以用于列表,结合 v-for 使用,在官网文档中,有这么一段介绍:



v-memo 仅供性能敏感场景的针对性优化,会用到的场景应该很少。渲染 v-for 长列表 (长度大于 1000) 可能是它最有用的场景:



<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>


当组件的 selected 状态发生变化时,即使绝大多数 item 都没有发生任何变化,大量的 VNode 仍将被创建。此处使用的 v-memo 本质上代表着“仅在 item 从未选中变为选中时更新它,反之亦然”。这允许每个未受影响的 item 重用之前的 VNode,并完全跳过差异比较。注意,我们不需要把 item.id 包含在记忆依赖数组里面,因为 Vue 可以自动从 item:key 中把它推断出来。



其实说白了 v-memo 的核心就是复用 vnode,上述模板借助于在线模板编译工具,可以看到其对应的 render 函数:


import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

基于 v-for 的列表内部是通过 renderList 函数来渲染的,来看它的实现:


function renderList(source, renderItem, cache, index) {
let ret
const cached = (cache && cache[index])
if (isArray(source) || isString(source)) {
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
}
else if (typeof source === 'number') {
// source 是数字
}
else if (isObject(source)) {
// source 是对象
}
else {
ret = []
}
if (cache) {
cache[index] = ret
}
return ret
}

我们只分析 source,也就是列表 list 是数组的情况,对于每一个 item,会执行 renderItem 函数来渲染。


从生成的 render 函数中,可以看到 renderItem 的实现如下:


(item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}

renderItem 函数内部,维护了一个 _memo 变量,它就是用来判断是否从缓存里获取 vnode 的条件数组;而第四个参数 _cached 对应的就是 item 对应缓存的 vnode。接下来通过 isMemoSame 函数来判断 memo 是否相同,来看它的实现:


function isMemoSame(cached, memo) {
const prev = cached.memo
if (prev.length != memo.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== memo[i]) {
return false
}
}
// ...
return true
}

isMemoSame 函数内部会通过 cached.memo 拿到缓存的 memo,然后通过遍历对比每一个条件来判断和当前的 memo 是否相同。


而在 renderItem 函数的结尾,就会把 _memo 缓存到当前 itemvnode 中,便于下一次通过 isMemoSame 来判断这个 memo 是否相同,如果相同,说明该项没有变化,直接返回上一次缓存的 vnode


那么这个缓存的 vnode 具体存储到哪里呢,原来在初始化组件实例的时候,就设计了渲染缓存:


const instance = {
// ...
renderCache: []
}

然后在执行 render 函数的时候,把这个缓存当做第二个参数传入:


const { renderCache } = instance
result = normalizeVNode(
render.call(
proxyToUse,
proxyToUse,
renderCache,
props,
setupState,
data,
ctx
)
)

然后在执行 renderList 函数的时候,把 _cahce 作为第三个参数传入:


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
// renderItem 实现
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

所以实际上列表缓存的 vnode 都保留在 _cache 中,也就是 instance.renderCache 中。


那么为啥使用缓存的 vnode 就能优化 patch 过程呢,因为在 patch 函数执行的时候,如果遇到新旧 vnode 相同,就直接返回,什么也不用做了。


const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
if(n1 === n2) {
return
}
// ...
}

显然,由于使用缓存的 vnode,它们指向同一个对象引用,直接返回,节约了后续执行 patch 过程的时间。


在 Table 组件的应用


v-memo 的优化思路很简单,就是复用缓存的 vnode,这是一种空间换时间的优化思路。


那么,前面我们提到在表格组件中选择状态没有变化的行,是不是也可以从缓存中获取呢?


顺着这思路,我给 Table 组件设计了 useMemo 这个 prop,它其实是专门用于有选择列的场景。


然后在 TableBody 组件的 created 钩子函数中,创建了用于缓存的对象:


created() {
if (this.table.useMemo) {
if (!this.table.rowKey) {
throw new Error('for useMemo, row-key is required.')
}
this.vnodeCache = []
}
}

这里之所以把 vnodeCache 定义到 created 钩子函数中,是因为它并不需要变成响应式对象。


另外注意,我们会根据每一行的 key 作为缓存的 key,因此 Table 组件的 rowKey 属性是必须的。


然后在渲染每一行的过程中,添加了 useMemo 相关的逻辑:


function rowRender({ /* 各种变量参数 */}) {
let memo
const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
let cached
if (useMemo) {
cached = this.vnodeCache[key]
const currentSelection = store.states.selection
if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
return cached
}
memo = currentSelection.slice()
}
// 渲染 row,返回对应的 vnode
const ret = rowVnode
if (useMemo && columns.length) {
ret.memo = memo
this.vnodeCache[key] = ret
}
return ret
}

这里的 memo 变量用于记录已选中的行数据,并且它也会在函数最后存储到 vnodememo,便于下一次的比对。


在每次渲染 rowvnode 前,会根据 row 对应的 key 尝试从缓存中取;如果缓存中存在,再通过 isRowSelectionChanged 来判断行的选中状态是否改变;如果没有改变,则直接返回缓存的 vnode


如果没有命中缓存或者是行选择状态改变,则会去重新渲染拿到新的 rowVnode,然后更新到 vnodeCache 中。


当然,这种实现相比于 v-memo 没有那么通用,只去对比行选中的状态而不去对比其它数据的变化。你可能会问,如果这一行某列的数据修改了,但选中状态没变,再走缓存不就不对了吗?


确实存在这个问题,但是在我们的使用场景中,遇到数据修改,是会发送一个异步请求到后端,然获取新的数据再来更新表格数据。因此我只需要观测表格数据的变化清空 vnodeCache 即可:


watch: {
'store.states.data'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
}
}

此外,我们支持列的可选则渲染功能,以及在窗口发生变化时,隐藏列也可能发生变化,于是在这两种场景下,也需要清空 vnodeCache


watch:{
'store.states.columns'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
},
columnsHidden(newVal, oldVal) {
if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
this.vnodeCache = []
}
}
}

以上实现就是基于 v-memo 的思路实现表格组件的性能优化。我们从火焰图上看一下它的效果:


zoom-ui6.png


我们发现黄色的 Scripting 时间几乎没有了,再来看它一次更新所需要的 JS 执行时间:


zoom-ui7.png
我们发现组件的 render to vnode 花费的时间约 20ms;vnode patch to DOM 花费的时间约 1ms,整个更新渲染过程,JS 的执行时间大幅减少。


另外,我们通过 benchmark 测试,得到如下结果:


zoom-ui5.png
优化后,ZoomUI Table 组件一次更新的时间约为 80ms,相比于 ElementUI 的 Table 组件,性能提升了约十倍


这个优化效果还是相当惊人的,并且从性能上已经不输 jQuery Table 了,我两年的心结也随之解开了。


总结


Table 表格性能提升主要是三个方面:减少 DOM 数量、优化 render 过程以及复用 vnode。有些时候,我们还可以从业务角度思考,去做一些优化。


虽然 useMemo 的实现还比较粗糙,但它目前已满足我们的使用场景了,并且当数据量越大,渲染的行列数越多,这种优化效果就越明显。如果未来有更多的需求,更新迭代就好。


由于一些原因,我们公司仍然在使用 Vue 2,但这并不妨碍我去学习 Vue 3,了解它一些新特性的实现原理以及设计思想,能让我开拓不少思路。


从分析定位问题到最终解决问题,希望这篇文章能给你在组件的性能优化方面提供一些思路,并应用到日常工作中。


链接:https://juejin.cn/post/7007252464726458399

收起阅读 »

vue3+typescript 实现一个中秋RPG游戏

前言 又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性....
继续阅读 »

前言


又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性. 所以我最后选择做类似这种风格的页面.


ChMkJ1tpIkuINzThAAUqitDPvVkAAqf4wHB5i0ABSqi715.jpg


选择好了技术栈和制作主题和风格. 就直接开干了. 肝了一天, 以下是制作完成后的成果


GIF.gif


先说一下剧本,这个剧本是春光灿烂猪八戒后羿(二牛)嫦娥的人物角色加上东成西就大理段王爷飞升桥段. 还有最后一个鬼畜飞升的效果,我先说一下,这个是实在没找到可用的素材,只能凑合的用网上找来的这个动画. o(╥﹏╥)o 好了, 那么就开始说说, 我是怎么实现这个类游戏的页面动画效果的.


页面组织结构


页面使用vite创建出来, 文件的结构是这样的


image.png


由于页面只有一个场景,所以整个页面是放在APP.vue中写的. interface文件夹存放定义的一些接口对象. 组件里边划分出来了4个组件, 依次是



  1. dialogBox: 底部对话框组件

  2. lottie: 输入咒语后的一个彩蛋爆炸效果组件

  3. sprite 精灵图动画组件

  4. typed 输入咒语的打字效果组件


那么我们就按照页面出现的动画效果依次去讲一下吧.


精灵图动画


页面开头首先是二牛角色从左边走上桥头的动画. 这个动画我们先来分析一下, 首先是帧动画, 也就是走路的这个动作的效果, 其次是从左边走上桥头的这个位移动画. 那么我们先说一下帧动画


帧动画



“逐帧动画是一种常见的动画形式(Frame By Frame),其原理是在“连续的关键帧”中分解动画动作,也就是在时间轴的每帧上逐帧绘制不同的内容,使其连续播放而成动画



image.png


用我这个项目举例, 二牛走路的动画其实是一张图片在我们前端这张图也叫雪碧图,图上有4个动作,4个动作在不停的切换的时候,在我们人眼中就形成了走路的动效了. 好的,原理解释清楚了,那么我们现在看一下代码


  <div ref="spriteBox">
<div ref="sprite" class="sprite"></div>
</div>

页面的结构很简单, 就三行html代码, 外边包裹的html其实是用来做位移动画用的, 里边的sprite就是做帧动画的. 下面我们看一下javascript代码


// 样式位置
export interface positionInterface {
left?: string,
top?: string,
bottom?: string,
right?: string
}

export interface spriteInterface {
length: number, // 精灵图的长度
url: string, // 图片的路径
width: number, // 图片的宽度
height: number, // 图片的高度
scale?: number, // 缩放
endPosition: positionInterface // 动画结束站的位置
}

import { Ref } from "vue";
import { positionInterface, spriteInterface } from "../../interface";

/**
* 精灵图实现逐帧动画
* @param spriteObj 精灵对象
* @param target 精灵节点
* @param wrap 精灵父节点 [控制精灵移动]
* @param callback 图片加载好回调函数
* @param moveCallback 移动到对应位置的回调函数
*/
export function useFrameAnimation(
spriteObj: spriteInterface,
target: Ref,
wrap: Ref,
callback: Function,
moveCallback: Function
) {
const { width, length, url, endPosition } = spriteObj;
let index = 0;

var img = new Image();
img.src = url;
img.addEventListener("load", () => {
let time;
(function autoLoop() {
callback && callback();
// 如果到达了指定的位置的话,则停止
if (isEnd(wrap, endPosition)) {
if (time) {
clearTimeout(time);
time = null;
moveCallback && moveCallback();
return;
}
}
if (index >= length) {
index = 0;
}
target.value.style.backgroundPositionX = -(width * index) + "px";
index++;
// 使用setTimeout, requestFrameAnimation 是60HZ进行渲染,部分设备会卡,使用setTimeout可以手动控制渲染时间
time = setTimeout(autoLoop, 160);
})();
});

// 走到了对应的位置
function isEnd(wrap, endPosition: positionInterface) {
let keys = Object.keys(endPosition);
for (let key of keys) {
if (window.getComputedStyle(wrap.value)[key] === endPosition[key]) {
return true;
}
}
return false;
}
}

参数


useFrameAnimation 这个帧动画的函数, 函数参数先传递精灵图的描述对象,它主要描述精灵图上是有几个动作组成的,图片的地址是多少,图片在DOM节点上的对象,以及移动到指定位置后,传递给调用函数的父级的回调函数. 其实在代码中的注释也描述的很清楚了.


图片加载


我们在使用这张图片做帧动画的时候,首先得在这张图片是加载好之后再去处理的. 所以我们得先new Image, 然后给它赋值上src, 然后监听它的load事件,


循环切换动画


在load事件句柄内, 写了一个loop循环切换图片的backgroundPositionX属性达到页面动作图片的切换,由于是循环动画,如果动画走到了最后一张图片的时候,得切回第一张图片


添加回调函数钩子


在图片加载完成的时候,回调一个callback函数,告诉外边图片已经加载完成了,如果有一些需要图片加载完成的事情做的话,可以在这个回调函数里边去写. 代码里边还有一个isEnd函数, 去判断位移动画是否已经完成,如果位移动画完成了的话,则停止帧动画的循环,让它静止下来成为一张图片. 然后再执行moveCallback告诉调用函数的父级,位移动画已经执行完成了. 这个函数大致做的事情就是这些了.


位移动画


位移动画就比较简单了, 我们先看下代码:


<script lang="ts">
import {
computed,
defineComponent,
defineEmit,
PropType,
reactive,
ref,
toRefs,
watchEffect,
} from "vue";
import { spriteInterface } from "../../interface";
import { useFrameAnimation } from "./useFrameAnimation";

export default defineComponent({
props: {
action: {
type: Boolean,
default: false,
},
spriteObj: Object as PropType<spriteInterface>,
},
defineEmit: ["moveEnd"],
setup(props, { emit }) {
const spriteBox = ref(null);
const sprite = ref({ style: "" });
const spriteObj = reactive(props.spriteObj || {}) as spriteInterface;
const { width, height, url, length } = toRefs(spriteObj);
watchEffect(() => {
if (props.action) {
useFrameAnimation(
spriteObj,
sprite,
spriteBox,
() => {
triggerMove();
},
() => {
emit("moveEnd");
}
);
}
});
// 给宽度后边加上单位
const widthRef = computed(() => {
return width.value + "px";
});
// 给高度后边加上单位
const heightRef = computed(() => {
return height.value + "px";
});
// 给背景图片连接添加url
const urlImg = computed(() => {
return `url("${url.value}")`;
});
// 移动到目标位置
function triggerMove() {
if (spriteObj.scale || spriteObj.scale === 0) {
spriteBox.value.style.transform = `scale(${spriteObj.scale})`;
}
if (spriteObj.endPosition) {
Object.keys(spriteObj.endPosition).forEach((o) => {
if (spriteBox.value && sprite.value.style) {
spriteBox.value.style[o] = spriteObj.endPosition[o];
}
});
}
}
return {
widthRef,
heightRef,
urlImg,
length,
sprite,
spriteBox,
triggerMove,
};
},
});
</script>

代码中主要的是这个watchEffect, 根据使用精灵组件传递的props.action去开始决定是否开始帧动画,在调用我们上一段讲的useFrameAnimation函数后,第四个参数回调函数是图片加载完成,图片加载完成的时候,我们可以在这里做位移动画,也就是triggerMove,triggerMove函数里实际上就是把在spriteObj配置好的一些位置以及缩放信息放到对应的DOM节点上,要说动画的话,其实是css去做的. 在监听到位移动画结束后,传递给父级一个moveEnd自定义事件.


<style lang="scss" scoped>
.sprite {
width: v-bind(widthRef);
height: v-bind(heightRef);
background-image: v-bind(urlImg);
background-repeat: no-repeat;
background-position: 0;
background-size: cover;
}
</style>

这里的css只描述了关于精灵图的宽度高度和图片路径,上边这种写法v-bind是vue3后可以使用的一种方式,这样就可以把动态的变量直接写在CSS里边了, 用过的都说好~ 关于精灵图真正的动画效果是写在了APP.vue里边的css里


  .boy {
position: absolute;
bottom: 90px;
left: 10px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}
.girl {
position: absolute;
bottom: 155px;
right: 300px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}

上面描述了二牛嫦娥的初始位置,以及动效.


对话框组件


二牛走到嫦娥旁边后,APP.vue就通过前面说的moveEnd自定义事件知晓了动画结束,然后在动画结束后,弹出对话框. 对话的话, 其实就得先想好一个对话的剧本以及对话剧本的格式了.


对话剧本


const dialogueContent = [
{
avatar: "/images/rpg_male.png",
content: "二牛:嫦娥你终于肯和我约会了, 哈哈",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:二牛对不起,我是从月宫来的,我不能和人间的你在一起!",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:今天是中秋节,我只有今天这个机会可以重新回月宫",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:回月宫的条件是找到真心人,让他念起咒语,我才能飞升!",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:而你就是我的真心人,你可以帮我嘛?",
},
{
avatar: "/images/rpg_male.png",
content: "二牛:好的,我明白了! 我会帮你的.",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:好的。 谢谢你!",
},
];

以上就是我这个小游戏的剧本了, 因为是别人先说一段,我再说一段,或者别人说了一段,再接着说一段. 这种的话,就是直接按照对话顺序写下来就好了, 然后我们在代码里边就可以通过点击时间的交互来按照顺序一个一个展现出来. 对话的结构主要就人物头像人物内容, 这里我为了省事,把人物的名称也直接在内容里边展现出来, 其实如果需要的话,可以提出来.


结构


我们先看一下它的html结构


  <div v-if="isShow" class="rpg-dialog" @click="increase">
<img :src="dialogue.avatar" class="rpg-dialog__role" />
<div class="rpg-dialog__body">
{{ contentRef.value }}
</div>
</div>

结构其实也很简单,里边就是一个头像和内容,我们用isShow去控制对话框的显示隐藏,用increase去走到下一个对话内容里边.


逻辑实现


    function increase() {
dialogueIndex.value++;
if (dialogueIndex.value >= dialogueArr.length) {
isShow.value = false;
emit("close");
return;
}
// 把下个内容做成打字的效果
contentRef.value = useType(dialogue.value.content);
}

increase方法里边也很简单,点击后,申明的索引(默认是0开始)+1,如果索引等于剧本的长度了的时候, 就把对话框关掉,然后给APP.vue一个close自定义事件, 如果小于剧本的长度的话,则走到下一个剧本内容,并且以打字的效果呈现. 也就是useType方法.


/**
* 打字效果
* @param { Object } content 打字的内容
*/
export default function useTyped(content: string): Ref<string> {
let time: any = null
let i:number = 0
let typed = ref('_')
function autoType() {
if (typed.value.length < content.length) {
time = setTimeout(() =>{
typed.value = content.slice(0, i+1) + '_'
i++
autoType()
}, 200)
} else {
clearTimeout(time)
typed.value = content
}
}
autoType()
return typed
}

打字效果实现也很简单,默认给一个_,然后逐一拿到字符串的每一个字符,一个一个的加在新字符串后边. 如果拿到完整的字符串的时候,则停止循环.


打字框(咒语)组件


在结束了剧本后, APP.vue会拿到组件跑出来的close自定义事件,在这里面,我们可以把诅咒组件给显示出来,


结构


<div v-if="isShow" class="typed-modal">
<div class="typed-box">
<div class="typed-oldFont">{{ incantation }}</div>
<div
@input="inputChange"
ref="incantainerRef"
contenteditable
class="typed-font"
>
{{ font }}
</div>
</div>
</div>

诅咒组件,这里的html结构,我们可以看一下,里边用到了contenteditable这个属性,设置了这个属性后,div就可以变的和输入框类似,我们可以直接在div上面的文字上自由修改. 所以我们就需要在用户修改的时候,监听它的input事件. incantation 这个放的就是底部的提示咒语, font放的就是我们需要输入的咒语.


逻辑实现


export default defineComponent({
components: {
ClickIcon,
},
emits: ["completeOver"],
setup(props, { emit }) {
const isShow = ref(true);
const lottie = ref(null);
const incantainerRef = ref(null);
const defaultOption = reactive(defaultOptions);
const incantation = ref("Happy Mid-autumn Day");
let font = ref("_");

nextTick(() => {
incantainerRef.value.focus();
});

function inputChange(e) {
let text = e.target.innerText.replace("_", "");
if (!incantation.value.startsWith(text)) {
e.target.innerText = font.value;
} else {
if (incantation.value.length === text.length) {
emit("completeOver");
font.value = text;
isShow.value = false;
lottie.value.toggle();
} else {
font.value = text + "_";
}
}
}

return {
font,
inputChange,
incantation,
incantainerRef,
defaultOption,
lottie,
isShow,
};
},
});
</script>

在组件弹窗的时候,我们用incantainerRef.value.focus();让它自动获取焦点. 在inputChange事件里边, 我们去判断输入的咒语是否和提示的咒语相同,如果不同的话,则无法继续输入, 并停留在输入正确的咒语上, 如果都输入正确了的话, 则会自动关闭咒语弹窗,并弹出一个类似恭喜通过的烟花效果. 传入一个completeOver自定义事件给APP.vue.


页面主题APP.vue


页面的话,其实就像一个导演了. 接收到演员的各种回馈后, 然后安排下一个演员就位


  setup() {
let isShow = ref(false); // 对话框窗口开关
let typedShow = ref(false); // 咒语窗口开关
let girlAction = ref(false); // 女孩动作开关, 导演喊一句action后,演员开始演绎
const boy = reactive(boyData);
const girl = reactive(girlData);
const dialogueArr = reactive(dialogueContent);
// 男孩移动动画结束
function boyMoveEnd() {
isShow.value = true;
}
// 完成输入咒语
function completeOver() {
girlAction.value = true;
}
function girlMoveEnd() {}
// 对话窗口关闭
function dialogClose() {
// 对话框关闭后,弹出咒语的窗口,二牛输入咒语后,嫦娥开始飞仙动作
typedShow.value = true;
}
return {
dialogueArr,
boy,
girl,
isShow,
boyMoveEnd,
girlMoveEnd,
girlAction,
dialogClose,
typedShow,
completeOver,
};

大家看看就好,其实没啥特别好说的.


写在最后


关于那个烟花效果的话,我就不讲了,因为我上次的文章如何在vue中使用Lottie已经详细的讲清楚了每一个细节. 并且这一次的这个组件其实就是复用的我这篇文章讲的这个自己封装的组件. 基本的效果就这些,如果大家有兴趣的话,可以参照在我这个基础上再加入一些细节在里边. 比如添加云彩动效,添加水波动效等. 需要源码的可以点这里看看 充实的一天就是过得这么快呀~ 大家下次再见咯. 提前祝大家中秋节快乐!.



链接:https://juejin.cn/post/7007011750746783757

收起阅读 »

一文彻底搞懂js中的位置计算

引言 文章中涉及到的api列表:scroll相关Apiclient相关Apioffset相关ApiElement.getBoundingClientRectAPiWindow.getComputedStyleApi 我们会结合api定义,知名开源库中的应用场...
继续阅读 »

引言


文章中涉及到的api列表:

scroll相关Api

client相关Api

offset相关Api

Element.getBoundingClientRectAPi

Window.getComputedStyleApi



我们会结合api定义,知名开源库中的应用场景来逐层分析这些api。足以应对工作中关于元素位置计算的大部分场景。




注意在使用位置计算api时要格外的小心,不合理的使用他们可能会造成布局抖动Layout Thrashing影响页面渲染。



scroll


首先我们先来看看scroll相关的属性和方法。


Element.scroll()


Element.scroll()方法是用于在给定的元素中滚动到某个特定坐标的Element 接口。


element.scroll(x-coord, y-coord)
element.scroll(options)


  • x-coord 是指在元素左上方区域横轴方向上想要显示的像素。

  • y-coord 是指在元素左上方区域纵轴方向上想要显示的像素。


也就是element.scroll(x,y)会将元素滚动条位置滚动到对应x,y的位置。


同时也支持element.scroll(options)方式调用,支持传入额外的配置:


{
left: number,
top: number,
behavior: 'smooth' | 'auto' // 平滑滚动还是默认直接滚动
}

Element.scrollHeight/scrollWidth



  • Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。



scrollHeight 的值等于该元素在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度。 没有垂直滚动条的情况下,scrollHeight值与元素视图填充所有内容所需要的最小值clientHeight相同。包括元素的padding,但不包括元素的border和margin。scrollHeight也包括 ::before 和 ::after这样的伪元素。



换句话说Element.scrollHeight在元素不存在滚动条的情况下是恒等于clientHeight的。


但是如果出现了滚动条的话scrollHeight指的是包含元素不可以见内容的高度,出现滚动条的情况下是scrollHeight恒大于clientHeight



  • Element.scrollWidth 这也是一个元素内容宽度的只读属性,包含由于溢出导致视图中不可以见的内容。



原理上和scrollHeight是同理的,只不过这里是宽度而非高度。



简单来说一个元素如果不存在滚动条,那么他们的scrollclient都是相等的值。如果存在了滚动条,client只会计算出当前元素展示出来的高度/宽度,而scroll不仅仅会计算当前元素展示出的,还会包含当前元素的滚动条隐藏内容的高度/宽度。


clientWidth/height + [滚动条被隐藏内容宽度/高度] = scrollWidth/Height


Element.scrollLeft/scrollTop




  • Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数.




  • Element.scrollLeft 属性可以读取或设置元素滚动条到元素左边的距离.





需要额外注意的是: 注意如果这个元素的内容排列方向(direction) 是rtl (right-to-left) ,那么滚动条会位于最右侧(内容开始处),并且scrollLeft值为0。此时,当你从右到左拖动滚动条时,scrollLeft会从0变为负数。



scrollLeft/Top在日常工作中是比较频繁使用关于操作滚动条的相关api,他们是一个可以设置的值。根据不同的值对应可以控制滚动条的位置。


其实这两个属性和上方的Element.scroll()可以达到相同的效果。



在实际工作中如果对于滚动操作有很频繁的需求,个人建议去使用better-scroll,它是一个移动/web端的通用js滚动库,内部是基于元素transform去操作的滚动并不会触发相关重塑/回流。



判断当前元素是否存在滚动条



出现滚动条便意味着元素空间将大于其内容显示区域,根据这个现象便可以得到判断是否出现滚动条的规则。



export const hasScrolled = (element, direction) => {
if (!element || element.nodeType !== 1) return;
if (direction === "vertical") {
return element.scrollHeight > element.clientHeight;
} else if (direction === "horizontal") {
return element.scrollWidth > element.clientWidth;
}
};

判断用户是否滚动到底部



本质上就是当元素出现滚动条时,判断当前元素出现的高度 + 滚动条高度 = 元素本身的高度(包含隐藏部分)



element.scrollHeight - element.scrollTop === element.clientHeight

client


MouseEvent.clientX/Y


MounseEvent.clientX/Y同样也是只读属性,它提供事件发生时的应用客户端区域的水平坐标。



例如,不论页面是否有垂直/水平滚动,当你点击客户端区域的左上角时,鼠标事件的 clientX/Y 值都将为 0 。



其实MouseEvent.clientX/Y也就是相对于当前视口(浏览器可视区)进行位置计算。


转载一张非常直白的图:


clientX


Element.clientHeight/clientWidth


Element.clientWidth/clinetHeight 属性表示元素的内部宽度,以像素计。该属性包括内边距 padding,但不包括边框 border、外边距 margin 和垂直滚动条(如果有的话)。



内联元素以及没有 CSS 样式的元素的 clientWidth 属性值为 0。



在不出现滚动条时候Element.clientWidth/Height === Element.scrollWidth/Height


image.png


Element.clientTop/clientLeft


Element.clientLeft表示一个元素的左边框的宽度,以像素表示。如果元素的文本方向是从右向左(RTL, right-to-left),并且由于内容溢出导致左边出现了一个垂直滚动条,则该属性包括滚动条的宽度。clientLeft 不包括左外边距和左内边距。clientLeft 是只读的。


同样的Element.clientTop表示元素上边框的宽度,也是一个只读属性。



这两个属性日常使用会比较少,但是也应该了解以避免搞混这些看似名称都类似的属性。



offset


MouseEvent.offsetX/offsetY


MouseEvent 接口的只读属性 offsetX/Y 规定了事件对象与目标节点的内填充边(padding edge)在 X/Y 轴方向上的偏移量。


相信使用过offest的同学对这个属性深有体会,它是相对于父元素的左边/上方的偏移量。



注意是触发元素也就是 e.target,额外小心如果事件对象中存在从一个子元素当移动到子元素内部时,e.offsetX/Y 此时相对于子元素的左上角偏移量。



offsetWidth/offsetHeight


HTMLElement.offsetWidth/Height 是一个只读属性,返回一个元素的布局宽度/高度。


所谓的布局宽度也就是相对于我们上边说到的clientHeight/Width,offsetHeight/Width,他们都是不包含border以及滚动条的宽/高(如果存在的话)。


offsetWidth/offsetHeight返回元素的布局宽度/高度,包含元素的边框(border)、水平线/垂直线上的内边距(padding)、竖直/水平方向滚动条(scrollbar)(如果存在的话)、以及CSS设置的宽度(width)的值


offsetTop/left


HTMLElement.offsetLeft 是一个只读属性,返回当前元素左上角相对于 HTMLElement.offsetParent 节点的左边界偏移的像素值。



注意返回的是相对于 HTMLElement.offsetParent 节点左边边界的偏移量。



何为HTMLElement.offsetParent?



HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table,td,th,body 元素。当元素的 style.display 设置为 "none" 时,offsetParent 返回 null。offsetParent 很有用,因为 offsetTop 和 offsetLeft 都是相对于其内边距边界的。 -- MDN



讲讲人话,当前元素的祖先组件节点如果不存在任何 table,td,th 以及 position 属性为 relative,absolute 等为定位元素时,offsetLeft/offsetTop 返回的是距离 body 左/上角的偏移量。


当祖先元素中有定位元素(或者上述标签元素)时,它就可以被称为元素的offsetParent。元素的 offsetLeft/offsetTop 的值等于它的左边框左侧/顶边框顶部到它的 offsetParent 元素左边框的距离。


我们来看看这张图:


image.png


计算元素距离 body 的偏移量


当我们需要获得元素距离 body 的距离时,但是又无法确定父元素是否存在定位元素时(大多数时候在组件开发中,并不清楚父节点是否存在定位)。此时需要实现类似 jqery 的 offset()方法:获得当前元素对于 body 的偏移量。



  • 无法直接使用 offsetLeft/offsetTop 获取,因为并不确定父元素是否存在定位元素。

  • 使用递归解决,累加偏移量 offset,当前 offsetParent 不为 body 时。

  • 继续递归向上超着 offsetParent 累加 offset,直到遇到 body 元素停止。


const getOffsetSize = function(Node: any, offset?: any): any {
if (!offset) {
offset = {
x: 0,
y: 0
};
}
if (Node === document.body) return offset;
offset.x = offset.x + Node.offsetLeft;
offset.y = offset.y + Node.offsetTop;
return getOffsetSize(Node.offsetParent, offset);
};


注意:这里不可以使用 parentNode 上文已经讲过 offsetLeft/top 针对的是 HTMLElement.offsetParent 的偏移量而非 parentNode 的偏移量。



Element.getBoundingClientRect


用法讲解


Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。



element.getBoundingClientRect()返回的相对于视口左上角的位置。



element.getBoundingClientRect()返回的 heightwidth 是针对元素可见区域的宽和高(具体尺寸根据 box-sizing 决定),并不包含滚动条被隐藏的内容。



TIP: 如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于 width/height。



rectObject = object.getBoundingClientRect();

返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有 left, top, right, bottom, x, y, width, 和 height 这几个以像素为单位的只读属性用于描述整个边框。除了 widthheight 以外的属性是相对于视图窗口的左上角来计算的。


widthheight是计算元素的大小,其他属性都是相对于视口左上角来说的。


当计算边界矩形时,会考虑视口区域(或其他可滚动元素)内的滚动操作,也就是说,当滚动位置发生了改变,top 和 left 属性值就会随之立即发生变化(因此,它们的值是相对于视口的,而不是绝对的) 。如果你需要获得相对于整个网页左上角定位的属性值,那么只要给 top、left 属性值加上当前的滚动位置(通过 window.scrollX 和 window.scrollY),这样就可以获取与当前的滚动位置无关的值。


image.png


计算元素是否出现在视口内


利用的还是元素距离视口的位置小于视口的大小。



注意即便变成了负值,那么也表示元素曾经出现过在屏幕中只是现在不显示了而已。(就比如滑动过)



vue-lazy图片懒加载库源码就是这么判断的。


 isInView (): boolean {
const rect = this.el.getBoundingClientRect()
return rect.top < window.innerHeight && rect.left < window.innerWidth
}


如果rect.top < window.innerHeight表示当前元素已经已经出现在(过)页面中,left同理。



window.getComputedStyle


用法讲解


Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。 私有的CSS属性值可以通过对象提供的API或通过简单地使用CSS属性名称进行索引来访问。


let style = window.getComputedStyle(element, [pseudoElt]);



  • element


     用于获取计算样式的Element




  • pseudoElt 可选


    指定一个要匹配的伪元素的字符串。必须对普通元素省略(或null)。




返回的style是一个实时的 CSSStyleDeclaration 对象,当元素的样式更改时,它会自动更新本身。


作者:19组清风
链接:https://juejin.cn/post/7006878952736161829

收起阅读 »

面试贼坑的十道js面试题(我只会最后一题)

前言 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批 typeof null 为什么是object null就出了一个 bug。...
继续阅读 »

前言



  • 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批


typeof null 为什么是object




  • null就出了一个 bug。根据 type tags 信息,低位是 000,因此 null被判断成了一个对象。这就是为什么 typeofnull的返回值是 "object"。




  • 关于 null的类型在 MDN 文档中也有简单的描述:typeof - java | MDN




  • 在 ES6 中曾有关于修复此 bug 的提议,提议中称应该让 typeofnull==='null'wiki.ecma.org/doku.php?id… 但是该提议被无情的否决了,自此 typeofnull终于不再是一个 bug,而是一个 feature,并且永远不会被修复




0.1+0.2为什么不等于0.3,以及怎么等于0.3



  • 在开发过程中遇到类似这样的问题:


let n1 = 0.1, n2 = 0.2
console.log(n1 + n2) // 0.30000000000000004


  • 这里得到的不是想要的结果,要想等于0.3,就要把它进行转化:


(n1 + n2).toFixed(2) // 注意,toFixed为四舍五入

toFixed(num) 方法可把 Number 四舍五入为指定小数位数的数字。那为什么会出现这样的结果呢?


计算机是通过二进制的方式存储数据的,所以计算机计算0.1+0.2的时候,实际上是计算的两个数的二进制的和。0.1的二进制是0.0001100110011001100...(1100循环),0.2的二进制是:0.00110011001100...(1100循环),这两个数的二进制都是无限循环的数。那JavaScript是如何处理无限循环的二进制小数呢?


一般我们认为数字包括整数和小数,但是在 JavaScript 中只有一种数字类型:Number,它的实现遵循IEEE 754标准,使用64位固定长度来表示,也就是标准的double双精度浮点数。在二进制科学表示法中,双精度浮点数的小数部分最多只能保留52位,再加上前面的1,其实就是保留53位有效数字,剩余的需要舍去,遵从“0舍1入”的原则。


根据这个原则,0.1和0.2的二进制数相加,再转化为十进制数就是:0.30000000000000004


下面看一下双精度数是如何保存的:


2020080420355853.png



  • 第一部分(蓝色):用来存储符号位(sign),用来区分正负数,0表示正数,占用1位

  • 第二部分(绿色):用来存储指数(exponent),占用11位

  • 第三部分(红色):用来存储小数(fraction),占用52位


对于0.1,它的二进制为:


0.00011001100110011001100110011001100110011001100110011001 10011...

转为科学计数法(科学计数法的结果就是浮点数):


1.1001100110011001100110011001100110011001100110011001*2^-4

可以看出0.1的符号位为0,指数位为-4,小数位为:


1001100110011001100110011001100110011001100110011001

那么问题又来了,指数位是负数,该如何保存呢?


IEEE标准规定了一个偏移量,对于指数部分,每次都加这个偏移量进行保存,这样即使指数是负数,那么加上这个偏移量也就是正数了。由于JavaScript的数字是双精度数,这里就以双精度数为例,它的指数部分为11位,能表示的范围就是0~2047,IEEE固定双精度数的偏移量为1023



  • 当指数位不全是0也不全是1时(规格化的数值),IEEE规定,阶码计算公式为 e-Bias。 此时e最小值是1,则1-1023= -1022,e最大值是2046,则2046-1023=1023,可以看到,这种情况下取值范围是-1022~1013

  • 当指数位全部是0的时候(非规格化的数值),IEEE规定,阶码的计算公式为1-Bias,即1-1023= -1022。

  • 当指数位全部是1的时候(特殊值),IEEE规定这个浮点数可用来表示3个特殊值,分别是正无穷,负无穷,NaN。 具体的,小数位不为0的时候表示NaN;小数位为0时,当符号位s=0时表示正无穷,s=1时候表示负无穷。


对于上面的0.1的指数位为-4,-4+1023 = 1019 转化为二进制就是:1111111011.


所以,0.1表示为:


0 1111111011 1001100110011001100110011001100110011001100110011001

说了这么多,是时候该最开始的问题了,如何实现0.1+0.2=0.3呢?


对于这个问题,一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2-52,在ES6中,提供了Number.EPSILON属性,而它的值就是2-52,只要判断0.1+0.2-0.3是否小于Number.EPSILON,如果小于,就可以判断为0.1+0.2 ===0.3


function numberepsilon(arg1,arg2){                   
return Math.abs(arg1 - arg2) < Number.EPSILON;
}

console.log(numberepsilon(0.1 + 0.2, 0.3)); // true

为什么要用weakMap




  • WeakMap 为弱引用,利于垃圾回收机制。




  • 一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。




  • 总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。




RAF 和 RIC 是什么



  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。

  • requestIdleCallback:: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序。


escape、encodeURI、encodeURIComponent 的区别



  • encodeURI 是对整个 URI 进行转义,将 URI 中的非法字符转换为合法字符,所以对于一些在 URI 中有特殊意义的字符不会进行转义。

  • encodeURIComponent 是对 URI 的组成部分进行转义,所以一些特殊字符也会得到转义。

  • escape 和 encodeURI 的作用相同,不过它们对于 unicode 编码为 0xff 之外字符的时候会有区别,escape 是直接在字符的 unicode 编码前加上 %u,而 encodeURI 首先会将字符转换为 UTF-8 的格式,再在每个字节前加上 %。


await 到底在等啥


await 在等待什么呢? 一般来说,都认为 await 是在等待一个 async 函数完成。不过按语法说明,await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。


因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数,但要清楚,它等的实际是一个返回值。注意到 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果,所以,await 后面实际是可以接普通函数调用或者直接量的。所以下面这个示例完全可以正确运行:


function getSomething() {
return "something";
}
async function testAsync() {
return Promise.resolve("hello async");
}
async function test() {
const v1 = await getSomething();
const v2 = await testAsync();
console.log(v1, v2);
}
test();

await 表达式的运算结果取决于它等的是什么。



  • 如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。

  • 如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。


来看一个例子:


function testAsy(x){
return new Promise(resolve=>{setTimeout(() => {
resolve(x);
}, 3000)
}
)
}
async function testAwt(){
let result = await testAsy('hello world');
console.log(result); // 3秒钟之后出现hello world
console.log('cuger') // 3秒钟之后出现cug
}
testAwt();
console.log('cug') //立即输出cug

这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。await暂停当前async的执行,所以'cug''最先输出,hello world'和‘cuger’是3秒钟后同时出现的。


|| 和 && 操作符的返回值



  • || 和 && 首先会对第一个操作数执行条件判断,如果其不是布尔值就先强制转换为布尔类型,然后再执行条件判断。

  • 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。

  • && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。

  • || 和 && 返回它们其中一个操作数的值,而非条件判断的结果


2 == [[[2]]]



  • 根据ES5规范,如果比较的两个值中有一个是数字类型,就会尝试将另外一个值强制转换成数字,再进行比较。而数组强制转换成数字的过程会先调用它的 toString方法转成字符串,然后再转成数字。所以 [2]会被转成 "2",然后递归调用,最终 [[[2]]] 会被转成数字 2。


var x = [typeof x, typeof y][1];typeof typeof x;//"string"




  • 因为没有声明过变量y,所以typeof y返回"undefined"




  • 将typeof y的结果赋值给x,也就是说x现在是"undefined"




  • 然后typeof x当然是"string"




  • 最后typeof "string"的结果自然还是"string"




你能接受加班吗?而且我们加班不给钱!



  • f¥¥¥¥¥k y**********u

链接:https://juejin.cn/post/7005402640746020877

收起阅读 »

for 循环不是目的,map 映射更有意义!【FP探究】

楔子 在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递! 例🌰: function sayHi() { console.log("...
继续阅读 »

楔子


在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递


例🌰:


function sayHi() {
console.log("Hi");
}
function sayBye() {
console.log("Bye");
}

function greet(type, sayHi, sayBye) {
type === 1 ? sayHi() : sayBye()
}

greet(1, sayHi, sayBye); // Hi

又得讲这个老生常谈的定义:如果一个函数“接收函数作为参数”或“返回函数作为输出”,那么这个函数被称作“高阶函数”


本篇要谈的是:高阶函数中的 mapfilterreduce 是【如何实践】的,我愿称之为:高阶映射!!


先别觉得这东西陌生,其实咱们天天都见!!


例🌰:


[1,2,3].map(item => item*2)

实践



Talk is cheap. Show me the code.



以下有 4 组代码,每组的 2 个代码片段实现目标一致,但实现方式有异,感受感受,你更喜欢哪个?💖


第 1 组:


1️⃣


const arr1 = [1, 2, 3];
const arr2 = [];
for(let i = 0; i < arr1.length; i++) {
arr2.push(arr1[i] * 2);
}
console.log(arr2); // [ 2, 4, 6 ]

2️⃣


const arr1 = [1, 2, 3];
const arr2 = arr1.map(item => item * 2);
console.log(arr2); // [ 2, 4, 6 ]

第 2 组:


1️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = [];
for(let i = 0; i < birthYear.length; i++) {
let age = 2018 - birthYear[i];
ages.push(age);
}
console.log(ages); // [ 43, 21, 16, 23, 33 ]

2️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = birthYear.map(year => 2018 - year);
console.log(ages); // [ 43, 21, 16, 23, 33 ]

第 3 组:


1️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = [];
for(let i = 0; i < persons.length; i++) {
if(persons[i].age >= 18) {
fullAge.push(persons[i]);
}
}
console.log(fullAge);

2️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = persons.filter(person => person.age >= 18);
console.log(fullAge);

第 4 组:


1️⃣


const arr = [5, 7, 1, 8, 4];
let sum = 0;
for(let i = 0; i < arr.length; i++) {
sum = sum + arr[i];
}
console.log(sum); // 25

2️⃣


const arr = [5, 7, 1, 8, 4];
const sum = arr.reduce(function(accumulator, currentValue) {
return accumulator + currentValue;
});
console.log(sum); // 25

更喜欢哪个?有答案了吗?


image.png


每组的代码片段 2️⃣ 就是map/filter/reduce高阶函数的应用,没有别的说的,就是更加简洁易读


手写


实际上,map/filter/reduce 也是基于 for 循环封装来的,所以我们也能自己实现一套相同的 高阶映射 🚀;



  • map1


Array.prototype.map1 = function(fn) {
let newArr = [];
for (let i = 0; i < this.length; i++) {
newArr.push(fn(this[i]))
};
return newArr;
}

console.log([1,2,3].map1(item => item*2)) // [2,4,6]


  • filter1


Array.prototype.filter1 = function (fn) {
let newArr=[];
for(let i=0;i<this.length;i++){
fn(this[i]) && newArr.push(this[i]);
}
return newArr;
};

console.log([1,2,3].filter1(item => item>2)) // [3]


  • reduce1


Array.prototype.reduce1 = function (reducer,initVal) {
for(let i=0;i<this.length;i++){
initVal =reducer(initVal,this[i],i,this);
}
return initVal
};

console.log([1,2,3].reduce1((a,b)=>a+b,0)) // 6

如果你不想直接挂在原型链上🛸:



  • mapForEach


function mapForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
newArray.push(
fn(arr[i])
);
}
return newArray;
}

mapForEach([1,2,3],item=>item*2) // [2,4,6]


  • filterForEach


function filterForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
fn(arr[i]) && newArray.push(arr[i]);
}
return newArray;
}

filterForEach([1,2,3],item=>item>2) // [3]


  • reduceForEach


function reduceForEach(arr,reducer,initVal) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
initVal =reducer(initVal,arr[i],i,arr);
}
return initVal;
}

reduceForEach([1,2,3],(a,b)=>a+b,0) // 6

这里本瓜有个小疑惑,在 ES6 之前,有没有一个库做过这样的封装❓


小结


本篇虽基础,但很重要


对一些惯用写法的审视、改变,会产生一些奇妙的思路~ 稀松平常的 map 映射能做的比想象中的要多得多!


for 循环遍历只是操作性的手段,不是目的!而封装过后的 map 映射有了更易读的意义,映射关系(输入、输出)也是函数式编程之核心!


YY一下:既然 map 这类函数都是从 for 循环封装来的,如果你能封装一个基于 for 循环的另一种特别实用的高阶映射或者其它高阶函数,是不是意味着:有朝一日有可能被纳入 JS 版本标准 API 中?🐶🐶🐶


或许:先意识到我们每天都在使用的高阶函数,刻意的去使用、训练,然后能举一反三,才能做上面的想象吧~~~



链接:https://juejin.cn/post/7006077858338570270

收起阅读 »

用canvas实现一个大气球送给你

一、背景 近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。 二、实现 在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分: 实现球体部分; 实现气球口...
继续阅读 »

一、背景



近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。



balloon1.gif


二、实现



在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分:




  1. 实现球体部分;

  2. 实现气球口子部分;

  3. 实现气球的线部分;

  4. 进行颜色填充;

  5. 实现动画;


气球.PNG


2.1 球体部分实现



对于这样的气球的球体部分,大家都有什么好的实现思路的?相信大家肯定会有多种多样的实现方案,我也是在看到某位大佬的效果后,感受到了利用四个三次贝塞尔曲线实现这个效果的妙处。为了看懂后续代码,先了解一下三次贝塞尔曲线的原理。(注:引用了CSDN上某位大佬的文章,写的很好,下图引用于此)



三次贝塞尔曲线.gif



在上图中P0为起始点、P3为终止点,P1和P2为控制点,其最终的曲线公式如下所示:



B(t)=(1−t)^3 * P0+3t(1−t)^2 * P1+3t ^ 2(1−t) * P2+t ^ 3P3, t∈[0,1]



上述已经列出了三次贝塞尔曲线的效果图和公式,但是通过这个怎么跟我们的气球挂上钩呢?下面通过几张图就理解了:



image.png



如上图所示,就是实现整个气球球体的思路,具体解释如下所示:




  1. A图中起始点为p1,终止点为p2,控制点为c1、c2,让两个控制点重合,绘制出的效果并不是很像气球的一部分,此时就要通过改变控制点来改变其外观;

  2. 改变控制点c1、c2,c1中y值不变,减小x值;c2中x值不变,增大y值(注意canvas中坐标方向即可),改变后就得到了图B的效果,此时就跟气球外观很像了;

  3. 紧接着按照这个方法就可以实现整个的气球球体部分的外观。


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(250, 250);
drawCoordiante(ctx);
ctx.save();
ctx.beginPath();
ctx.moveTo(0, -80);
ctx.bezierCurveTo(45, -80, 80, -45, 80, 0);
ctx.bezierCurveTo(80, 85, 45, 120, 0, 120);
ctx.bezierCurveTo(-45, 120, -80, 85, -80, 0);
ctx.bezierCurveTo(-80, -45, -45, -80, 0, -80);
ctx.stroke();
ctx.restore();
}

function drawCoordiante(ctx) {
ctx.beginPath();
ctx.moveTo(-120, 0);
ctx.lineTo(120, 0);
ctx.moveTo(0, -120);
ctx.lineTo(0, 120);
ctx.closePath();
ctx.stroke();
}

2.2 口子部分实现



口子部分可以简化为一个三角形,效果如下所示:



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(-5, 130);
ctx.lineTo(5, 130);
ctx.closePath();
ctx.stroke();
ctx.restore();
}

2.3 线部分实现



线实现的比较简单,就用了一段直线实现



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(0, 300);
ctx.stroke();
ctx.restore();
}

2.4 进行填充



气球部分的填充用了圆形渐变效果,相比于纯色来说更加漂亮一些。



function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = getBalloonGradient(ctx, 0, 0, 80, 210);
……

}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}

image.png


2.5 动画效果及整体代码



上述流程已经将一个静态的气球部分绘制完毕了,要想实现动画效果只需要利用requestAnimationFrame函数不断循环调用即可实现。下面直接抛出整体代码,方便同学们观察效果进行调试,整体代码如下所示:



let posX = 225;
let posY = 300;
let points = getPoints();
draw();

function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (posY < -200) {
posY = 300;
posX += 300 * (Math.random() - 0.5);
points = getPoints();
}
else {
posY -= 2;
}
ctx.save();
ctx.translate(posX, posY);
drawBalloon(ctx, points);
ctx.restore();

window.requestAnimationFrame(draw);
}

function drawBalloon(ctx, points) {
ctx.scale(points.scale, points.scale);
ctx.save();
ctx.fillStyle = getBalloonGradient(ctx, 0, 0, points.R, points.hue);
// 绘制球体部分
ctx.moveTo(points.p1.x, points.p1.y);
ctx.bezierCurveTo(points.pC1to2A.x, points.pC1to2A.y, points.pC1to2B.x, points.pC1to2B.y, points.p2.x, points.p2.y);
ctx.bezierCurveTo(points.pC2to3A.x, points.pC2to3A.y, points.pC2to3B.x, points.pC2to3B.y, points.p3.x, points.p3.y);
ctx.bezierCurveTo(points.pC3to4A.x, points.pC3to4A.y, points.pC3to4B.x, points.pC3to4B.y, points.p4.x, points.p4.y);
ctx.bezierCurveTo(points.pC4to1A.x, points.pC4to1A.y, points.pC4to1B.x, points.pC4to1B.y, points.p1.x, points.p1.y);

// 绘制气球钮部分
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.knowA.x, points.knowA.y);
ctx.lineTo(points.knowB.x, points.knowB.y);
ctx.fill();
ctx.restore();

// 绘制线部分
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.lineEnd.x, points.lineEnd.y);
ctx.stroke();
ctx.restore();
}

function getPoints() {
const offset = 35;
return {
scale: 0.3 + Math.random() / 2,
hue: Math.random() * 255,
R: 80,
p1: {
x: 0,
y: -80
},
pC1to2A: {
x: 80 - offset,
y: -80
},
pC1to2B: {
x: 80,
y: -80 + offset
},
p2: {
x: 80,
y: 0
},
pC2to3A: {
x: 80,
y: 120 - offset
},
pC2to3B: {
x: 80 - offset,
y: 120
},
p3: {
x: 0,
y: 120
},
pC3to4A: {
x: -80 + offset,
y: 120
},
pC3to4B: {
x: -80,
y: 120 - offset
},
p4: {
x: -80,
y: 0
},
pC4to1A: {
x: -80,
y: -80 + offset
},
pC4to1B: {
x: -80 + offset,
y: -80
},
knowA: {
x: -5,
y: 130
},
knowB: {
x: 5,
y: 130
},
lineEnd: {
x: 0,
y: 250
}
};
}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}


链接:https://juejin.cn/post/7006967510134161438

收起阅读 »

通过一个例子学习css层叠上下文

层叠上下文 & 层叠等级 & 层叠规则 http://www.w3.org/TR/CSS22/vi… The order in which the rendering tree is painted onto the canvas is d...
继续阅读 »

层叠上下文 & 层叠等级 & 层叠规则



http://www.w3.org/TR/CSS22/vi…


The order in which the rendering tree is painted onto the canvas is described in terms of stacking contexts. Stacking contexts can contain further stacking contexts. A stacking context is atomic from the point of view of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.


Each box belongs to one stacking context. Each positioned box in a given stacking context has an integer stack level, which is its position on the z-axis relative other stack levels within the same stacking context. Boxes with greater stack levels are always formatted in front of boxes with lower stack levels. Boxes may have negative stack levels. Boxes with the same stack level in a stacking context are stacked back-to-front according to document tree order.


The root element forms the root stacking context.



翻译一下:
渲染树被绘制到画布上的顺序是根据层叠上下文来描述的。层叠上下文可以包含更多的层叠上下文。从父层叠上下文的角度来看,层叠上下文是原子的;其他层叠上下文中的盒子可能不会出现在它的任何盒子中。


每个框都属于一个层叠上下文。给定层叠上下文中的每个定位框都有一个整数层叠等级,这是它在 z 轴上相对于同一层叠上下文中其他层叠等级的位置。具有较高层叠等级的框始终放置在具有较低层叠等级的框之前。盒子可能有负的层叠等级。层叠上下文中具有相同层叠等级的框根据文档树顺序从后到前绘制。


根元素创建根层叠上下文。


理解:
所有的元素都属于一个层叠上下文,所以所有的元素都有自己的层叠等级。
每个元素都有自己所属的层叠上下文,在当前层叠上下文中具有自己的层叠等级。



那层叠等级的规则是啥呢?



http://www.w3.org/TR/CSS22/vi…


Within each stacking context, the following layers are painted in back-to-front order:


the background and borders of the element forming the stacking context.
the child stacking contexts with negative stack levels (most negative first).
the in-flow, non-inline-level, non-positioned descendants.
the non-positioned floats.
the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
the child stacking contexts with stack level 0 and the positioned descendants with stack level 0.
the child stacking contexts with positive stack levels (least positive first).



在每一个层叠上下文中,阿照下面的顺序从后往前绘制。



  1. 创建层叠上下文元素的背景和边框

  2. 创建层叠上下文元素的具有负层叠等级子元素

  3. 非inline元素并且没有定位的后代【block后代】

  4. 非定位的浮动元素

  5. 包括inline-table / inline-block的非定位inline元素

  6. 创建层叠上下文元素的层叠等级为0的子元素【0 / auto】

  7. 创建层叠上下文元素的层叠等级为大于0的子元素



关于这个等级张鑫旭有一张图说明
image.png
这里提到了一个新增的:不依赖于z-index的层叠上下文,这里指的应该是css3会有一些元素在不通过定位来创建新的层叠上下文



  1. z-index值不为auto的flex项(父元素display:flex|inline-flex).

  2. 元素的opacity值不是1.

  3. 元素的transform值不是none.

  4. 元素mix-blend-mode值不是normal.

  5. 元素的filter值不是none.

  6. 元素的isolation值是isolate.

  7. will-change指定的属性值为上面任意一个。

  8. 元素的-webkit-overflow-scrolling设为touch.





Demo



先看parent元素


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 100px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div class="parent">
parent
<div class="child1">child1
<div class="child1-2">child1-2</div>
<div class="child1-1">child1-1</div>
<!-- <div>child1-1</div>
<div>child1-2</div> -->
</div>
<div class="child2">
child2
</div>
</div>
</body>

image.png


先从根节点看起:根节点上根层级上下文,因为只有一个子节点parent。然后parent有自己的层叠上下文。parent有两个子节点,上文说到每个盒子属于一个层叠上下文,parent属于html的层叠上下文,parent会创建自己的层叠上下文,当然这个层叠上下文的作用主要针对parent的子元素。child1,child2。


image.pngimage.png


因为child1的z-index为1,child2的z-index为-1。所以这里的child1会绘制在child2的上面。


当我们在看child1的子元素和child2的子元素就不能放在一起看了,因为child1和child2都创建了自己的层叠上下文。只能独立看了。


这里child2的绘制会在parent的上面,尽管child2的z-index为负树。这里也对应了上面说的7层关系。因为parent属于创建层叠上下文的元素。



知识点:层叠上下文



  1. 普通元素的层叠等级优先由其所在的层叠上下文决定。

  2. 层叠等级的比较只有在当前层叠上下文元素中才有意义。不同层叠上下文中比较层叠等级是没有意义的。





知识点:层叠等级



  1. 在同一个层叠上下文中,它描述定义的是该层叠上下文中的层叠上下文元素在Z轴上的上下顺序。

  2. 在其他普通元素中,它描述定义的是这些普通元素在Z轴上的上下顺序。





接下来看block层级小于float


image.png


再看具体的页面渲染,我们修改一下代码,将child1-2和child1-2的顺序调换一下:


image.pngimage.png


这里不同的顺序会有不同的效果:第二张图看得出来是我们期望的,child1-2绘制到了chil1-1下面。因为float元素没有脱离文本流,所以child1-2的文本会被挤压到下面去。那么我们看一下第一张图为什么会这样。
从float的概念当中就可以看出来了。
浮动定位作用的是当前行,当前浮动元素在绘制的时候,child1父元素第一个元素是block元素,所以。float在绘制的时候,因为child1-1的宽度和child1的宽度相同,所以float所在的当前行就是目前的位置。第二张图是我们期望的结果是因为float在绘制的时候所在的当前行就是第一行。所以会按照我们期望的体现。



接下来看float小于inline / inline-block


我们接着上面第二张图继续看。这样是看不出来效果的,需要修改一下代码再看。


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 200px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
display: inline-block;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
margin: 10px -15px 10px 10px;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div>
parent
<div>child1
<divhljs-number">1">child1-1</div>
<divhljs-number">2">child1-2</div>
</div>
<div>
child2
</div>
</div>
</body>

image.png
修改代码是需要将float元素和inline-block元素放在同一行,如果不是在同一行是没意义的。我们可以看到child1的文本节点和child1-2的inline-block元素都绘制在了child1-1的元素上面了。


论证一下css3的内容


也就是下面这个红框的内容:


image.png
继续用上面的例子:
上面看到的float元素已经放置在了inline / inline-block内容的下面。现在我们加一下:上面说的css3的样式在看一下。下面的两个例子可以看到之前放置在inline / inline-block下面的child1-1已经绘制在上面了。



opacity


image.png



tranform


image.png





概念



z-index



  1. 首先,z-index属性值并不是在任何元素上都有效果。它仅在定位元素(定义了position属性,且属性值为非static值的元素)上有效果。

  2. 判断元素在Z轴上的堆叠顺序,不仅仅是直接比较两个元素的z-index值的大小,这个堆叠顺序实际由元素的层叠上下文层叠等级共同决定。





层叠上下文的特性



  • 层叠上下文的层叠水平要比普通元素高;

  • 层叠上下文可以嵌套,内部层叠上下文及其所有子元素均受制于外部的层叠上下文。

  • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需要考虑后代元素。

  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中



链接:https://juejin.cn/post/7006978541988347941

收起阅读 »

【中秋】纯CSS实现日地月的公转

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。 但因为我根本没咋学过前端,这两天恶补了一下重学了 flexbox 和 grid ,成...
继续阅读 »

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。


但因为我根本没咋学过前端,这两天恶补了一下重学了 flexboxgrid ,成果应该说还挺好看(如果我的审美没有问题的话)。


配色我挺喜欢的,希望你也喜欢。


源码我放到了 CodePen 上,链接 Sun Earth Moon (codepen.io)


HTML


重点是CSS,HTML放上三个 div 就🆗了。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Mancuoj</title>
<link
href="simulation.css"
rel="stylesheet"
/>
</head>

<body>
<h1>Mancuoj</h1>
<figure class="container">
<div class="sun"></div>
<div class="earth">
<div class="moon"></div>
</div>
</figure>
</body>
</html>

背景和文字


导入我最喜欢的 Lobster 字体,然后设为白色,字体细一点。


@import url("https://fonts.googleapis.com/css2?family=Lobster&display=swap");

h1 {
color: white;
font-size: 60px;
font-family: Lobster, monospace;
font-weight: 100;
}

背景随便找了一个偏黑紫色,然后把画的内容设置到中间。


body {
margin: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #2f3141;
}

.container {
font-size: 10px;
width: 40em;
height: 40em;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

日地月动画


众所周知:地球绕着太阳转,月球绕着地球转。


我们画的是公转,太阳就直接画出来再加个阴影高光,月亮地球转就可以了。


最重要的其实是配色(文章末尾有推荐网站),我实验好长时间的配色,最终用了三个渐变色来表示日地月。


日: linear-gradient(#fcd670, #f2784b);
地: linear-gradient(#19b5fe, #7befb2);
月: linear-gradient(#8d6e63, #ffe0b2);

CSS 应该难不到大家,随便看看吧。


轨道用到了 border,用银色线条当作公转的轨迹。


动画用到了自带的 animation ,每次旋转一周。


.sun {
position: absolute;
width: 10em;
height: 10em;
background: linear-gradient(#fcd670, #f2784b);
border-radius: 50%;
box-shadow: 0 0 8px 8px rgba(242, 120, 75, 0.2);
}

.earth {
--diameter: 30;
--duration: 36.5;
}

.moon {
--diameter: 8;
--duration: 2.7;
top: 0.3em;
right: 0.3em;
}

.earth,
.moon {
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
border-width: 0.1em;
border-style: solid solid none none;
border-color: silver transparent transparent transparent;
border-radius: 50%;
animation: orbit linear infinite;
animation-duration: calc(var(--duration) * 1s);
}

@keyframes orbit {
to {
transform: rotate(1turn);
}
}

.earth::before {
--diameter: 3;
--color: linear-gradient(#19b5fe, #7befb2);
--top: 2.8;
--right: 2.8;
}

.moon::before {
--diameter: 1.2;
--color: linear-gradient(#8d6e63, #ffe0b2);
--top: 0.8;
--right: 0.2;
}

.earth::before,
.moon::before {
content: "";
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
background: var(--color);
border-radius: 50%;
top: calc(var(--top) * 1em);
right: calc(var(--right) * 1em);
}

总结


参加个活动真不容易,不过前端还是挺好玩的。


链接:https://juejin.cn/post/7006507905050492935

收起阅读 »

6年的老项目迁移vite2,提速几十倍,真香

背景 gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它 先看看vue-cli3的启动编译吧... 该项目为内部运营管理系统...
继续阅读 »

vite-dev.png


背景



gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它



先看看vue-cli3的启动编译吧...


编译-new-48803ms.png



  • 该项目为内部运营管理系统,年龄6岁+

  • 基于vue2+elementui,2年入职时将vue-cli2升级到了vue-cli3,2年后的今天迫不及待的的奔向vite2

  • 仅迁移开发环境(我的痛点只是开发环境,对于生产环境各位自行考虑)


痛点分析


实质上是对webpack工作原理的分析,webpack在开发环境的工作流大致如下(个人见解不喜勿喷):



查找入口文件 => 分析依赖关系 => 转化模块函数 => 打包生成bundle => node服务启动



所以随着项目越来越大,速度也就越来越慢...


至于HMR也是同理,只不过HMR是将当前文件作为入口,进行rebuild,涉及的相关依赖都需要重载


为什么是Vite



  • vite是基于esm实现的,主流浏览器已支持,所以不需要对文件进行打包编译

  • 项目启动超快(迁移后简单的概算数据是从30s 提升到 1s。30倍?3000%?一点都不夸张...)

  • 还是基于esmHMR很快,不需要编译重载,速度可以用一闪而过来形容...


vite大致工作流:



启动服务 => 查找入口文件(module script) => 浏览器发送请求 => vite劫持请求处理返回文件到浏览器



开盘,踏上迁移之路




  1. 安装相关npm包


    npm i vite vite-plugin-vue vite-plugin-html -D


    • vite-plugin-vue,用于构建vue,加载jsx

    • vite-plugin-html,用于入口文件模板注入




  2. package.json文件中,新增一个vite启动命令:


    "vite": "cross-env VITE_NODE_ENV=dev vite"



  3. 根目录新建vite.config.js文件




  4. public下的index.html复制一份到根目录



    仅迁移开发环境,public下仍然需要index.html,支持开发环境下vite和webpack两种模式





  5. 修改根目录下index.html(vite启动的入口文件,必须是根目录)


    <% if (htmlWebpackPlugin.options.isVite) { %>
    <script type="module" src="/src/main.js"></script>
    <%}%>


    htmlWebpackPlugin在vite.config.js注入,isVite用于标识是否是vite启动



    import { injectHtml } from 'vite-plugin-html';
    export default defineConfig({
     plugins:[
       injectHtml({
         injectData: {
           htmlWebpackPlugin: {
             options: {
               isVite: true
            }
          },
           title: '运营管理平台'
        }
      })
    ]
    })



  6. 完整vite.config.js 配置


    import { defineConfig } from 'vite'
    import path from 'path'
    import fs from 'fs'
    import { createVuePlugin } from 'vite-plugin-vue2'
    import { injectHtml, minifyHtml } from 'vite-plugin-html'
    import dotenv from 'dotenv'

    try {
       // 根据环境变量加载环境变量文件
       const VITE_NODE_ENV = process.env.VITE_NODE_ENV
       const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
       const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
           debug: true
      })
       for (const key in file) {
           process.env[key] = file[key]
      }
    } catch (e) {
       console.error(e)
    }

    const resolve = (dir) => {
       return path.join(__dirname, './', dir)
    }
    export default defineConfig({
       root: './',
       publicDir: 'public',
       base: './',
       mode: 'development',
       optimizeDeps: {
           include: []
      },
       resolve: {
           alias: {
               'vendor': resolve('src/vendor'),
               '@': resolve('src'),
               '~component': resolve('src/components')
          },
           extensions: [
               '.mjs',
               '.js',
               '.ts',
               '.jsx',
               '.tsx',
               '.json',
               '.vue'
          ]
      },
       plugins: [
           createVuePlugin({
               jsx: true,
               jsxOptions: {
                   injectH: false
              }
          }),
           minifyHtml(),
           injectHtml({
               injectData: {
                   htmlWebpackPlugin: {
                       options: {
                           isVite: true
                      }
                  },
                   title: '运营管理平台'
              }
          })
      ],
       define: {
           'process.env': process.env
      },
       server: {
           host: '0.0.0.0',
           open: true,
           port: 3100,
           proxy: {}
      }
    })



    相关配置会在下文遇到的问题中做具体描述





迁移过程中遇到的问题




  1. Uncaught SyntaxError: The requested module 'xx.js' does not provide an export named 'xx'


    本人遇到的分以下两类情况:


    a. 一个模块只能有一个默认输出,导入默认输出时,import命令后不需要加大括号,否则会报错


    处理方式:将原先{}导入的keys,改成导入默认keyes6解构赋值


    -import { postRedeemDistUserUpdate } from '@/http-handle/api_types'

    +import api_types from '@/http-handle/api_types'
    +const { postRedeemDistUserUpdate } = api_types

    b. 浏览器仅支持 esm,不支持 cjs,需要将cjs改为esm (看了网文有通过cjs2esmodule处理的,但是本人应用有些场景是报错的,最后就去掉了)


    处理方式:不推荐使用cjs2esmodule,手动将module.exports更改为export


    -module.exports = {

    +export default {



  2. .vue文件扩展,最新版本的vite貌似已支持extensions添加.vue,不过还是推荐手动添加下后缀。(骚操作:正则匹配批量添加)




  3. Uncaught ReferenceError: require is not defined


    浏览器不支持cjs


    处理方式:require引用的文件都需要修改为import引用




  4. vite启动,页面空白


    处理方式:注意入口文件index.html,需要放置项目根目录




  5. vite环境下默认没有process.env,可通过define定义全局变量


    vue-cli模式下,环境变量都是读取根目录.env文件中的变量,那么vite模式下是否也可以读取.env文件中的变量最终注入到process.env中呢?


    这样不就可以两种模式共存了么?成本变小了么?


    处理方式:



    1. 安装环境变量加载工具:dotenv


    npm i dotenv -D




    1. 自定义全局变量process.env


      vite.config.js中配置




    define: {
    'process.env': {}
    }



    1. 加载环境变量,并添加到process.env


      vite.config.js中配置



      因为仅迁移开发环境,所以我这里默认是读取.local文件。


      VITE_NODE_ENV是在启动时通过cross-env注入的







import dotenv from 'dotenv'
try {
const VITE_NODE_ENV = process.env.VITE_NODE_ENV
const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
debug: true
})
console.log(file)
for (const key in file) {
process.env[key] = file[key]
}
} catch (e) {
console.error(e)
}




  1. jsx支持


    vite.config.js中配置


    plugins: [
    createVuePlugin({
      jsx: true,
      jsxOptions: {
        injectH: false
      }
    })



  2. webpack中require.context方法,在vite中使用import.meta.glob替换




现存问题


项目中导入/导出的功能,是纯前端实现的


require('script-loader!file-saver')
require('script-loader!@/vendor/Blob')

由于以上文件目前不支持import引入,webpack下是通过script-loader加载挂载到全局的,vite环境下未能解决。需要导入导出功能时只能切换到vue-cli模式启动服务...


如果各位大大有方案,麻烦指导指导~,实在是不想回到webpack开发了...


最后


总体迁移上并没有遇到什么疑难杂症,迁移成本还是不大的,实操1-2天,性价比很高哦,我这个项目按数据看就是几十倍的启动提效,几倍的HMR提效...各位可以在内部系统上做下尝试。



链接:https://juejin.cn/post/7005479358085201957

收起阅读 »

50行代码串行Promise,koa洋葱模型原来是这么实现?

1. 前言 写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。 所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂。 之前写过 ko...
继续阅读 »

1. 前言


写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。


所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂


之前写过 koa 源码文章学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理比较长,读者朋友大概率看不完,所以本文从koa-compose50行源码讲述。


本文涉及到的 koa-compose 仓库 文件,整个index.js文件代码行数虽然不到 50 行,而且测试用例test/test.js文件 300 余行,但非常值得我们学习。


歌德曾说:读一本好书,就是在和高尚的人谈话。 同理可得:读源码,也算是和作者的一种学习交流的方式。


阅读本文,你将学到:


1. 熟悉 koa-compose 中间件源码、可以应对面试官相关问题
2. 学会使用测试用例调试源码
3. 学会 jest 部分用法

2. 环境准备


2.1 克隆 koa-compose 项目


本文仓库地址 koa-compose-analysis,求个star~


# 可以直接克隆我的仓库,我的仓库保留的 compose 仓库的 git 记录
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose/compose
npm i

顺带说下:我是怎么保留 compose 仓库的 git 记录的。


# 在 github 上新建一个仓库 `koa-compose-analysis` 克隆下来
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose-analysis
git subtree add --prefix=compose https://github.com/koajs/compose.git main
# 这样就把 compose 文件夹克隆到自己的 git 仓库了。且保留的 git 记录

关于更多 git subtree,可以看这篇文章用 Git Subtree 在多个 Git 项目间双向同步子项目,附简明使用手册


接着我们来看怎么根据开源项目中提供的测试用例调试源码。


2.2 根据测试用例调试 compose 源码


VSCode(我的版本是 1.60 )打开项目,找到 compose/package.json,找到 scriptstest 命令。


// compose/package.json
{
"name": "koa-compose",
// debug (调试)
"scripts": {
"eslint": "standard --fix .",
"test": "jest"
},
}

scripts上方应该会有debug或者调试字样。点击debug(调试),选择 test


VSCode 调试


接着会执行测试用例test/test.js文件。终端输出如下图所示。


koa-compose 测试用例输出结果


接着我们调试 compose/test/test.js 文件。
我们可以在 45行 打上断点,重新点击 package.json => srcipts => test 进入调试模式。
如下图所示。


koa-compose 调试


接着按上方的按钮,继续调试。在compose/index.js文件中关键的地方打上断点,调试学习源码事半功倍。


更多 nodejs 调试相关 可以查看官方文档


顺便提一下几个调试相关按钮。





    1. 继续(F5)





    1. 单步跳过(F10)





    1. 单步调试(F11)





    1. 单步跳出(Shift + F11)





    1. 重启(Ctrl + Shift + F5)





    1. 断开链接(Shift + F5)




接下来,我们跟着测试用例学源码。


3. 跟着测试用例学源码


分享一个测试用例小技巧:我们可以在测试用例处加上only修饰。


// 例如
it.only('should work', async () => {})

这样我们就可以只执行当前的测试用例,不关心其他的,不会干扰调试。


3.1 正常流程


打开 compose/test/test.js 文件,看第一个测试用例。


// compose/test/test.js
'use strict'

/* eslint-env jest */

const compose = require('..')
const assert = require('assert')

function wait (ms) {
return new Promise((resolve) => setTimeout(resolve, ms || 1))
}
// 分组
describe('Koa Compose', function () {
it.only('should work', async () => {
const arr = []
const stack = []

stack.push(async (context, next) => {
arr.push(1)
await wait(1)
await next()
await wait(1)
arr.push(6)
})

stack.push(async (context, next) => {
arr.push(2)
await wait(1)
await next()
await wait(1)
arr.push(5)
})

stack.push(async (context, next) => {
arr.push(3)
await wait(1)
await next()
await wait(1)
arr.push(4)
})

await compose(stack)({})
// 最后输出数组是 [1,2,3,4,5,6]
expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
})
}

大概看完这段测试用例,context是什么,next又是什么。


koa的文档上有个非常代表性的中间件 gif 图。


中间件 gif 图


compose函数作用就是把添加进中间件数组的函数按照上面 gif 图的顺序执行。


3.1.1 compose 函数


简单来说,compose 函数主要做了两件事情。





    1. 接收一个参数,校验参数是数组,且校验数组中的每一项是函数。





    1. 返回一个函数,这个函数接收两个参数,分别是contextnext,这个函数最后返回Promise




/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/
function compose (middleware) {
// 校验传入的参数是数组,校验数组中每一项是函数
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

/**
* @param {Object} context
* @return {Promise}
* @api public
*/

return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch(i){
// 省略,下文讲述
}
}
}

接着我们来看 dispatch 函数。


3.1.2 dispatch 函数


function dispatch (i) {
// 一个函数中多次调用报错
// await next()
// await next()
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// 取出数组里的 fn1, fn2, fn3...
let fn = middleware[i]
// 最后 相等,next 为 undefined
if (i === middleware.length) fn = next
// 直接返回 Promise.resolve()
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}

值得一提的是:bind函数是返回一个新的函数。第一个参数是函数里的this指向(如果函数不需要使用this,一般会写成null)。
这句fn(context, dispatch.bind(null, i + 1)i + 1 是为了 let fn = middleware[i]middleware中的下一个函数。
也就是 next 是下一个中间件里的函数。也就能解释上文中的 gif图函数执行顺序。
测试用例中数组的最终顺序是[1,2,3,4,5,6]


3.1.3 简化 compose 便于理解


自己动手调试之后,你会发现 compose 执行后就是类似这样的结构(省略 try catch 判断)。


// 这样就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = stack;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};


也就是说koa-compose返回的是一个Promise,从中间件(传入的数组)中取出第一个函数,传入context和第一个next函数来执行。

第一个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第二个函数,传入context和第二个next函数来执行。

第二个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第三个函数,传入context和第三个next函数来执行。

第三个...

以此类推。最后一个中间件中有调用next函数,则返回Promise.resolve。如果没有,则不执行next函数。



这样就把所有中间件串联起来了。这也就是我们常说的洋葱模型。


洋葱模型图如下图所示:


不得不说非常惊艳,“玩还是大神会玩”


3.2 错误捕获


it('should catch downstream errors', async () => {
const arr = []
const stack = []

stack.push(async (ctx, next) => {
arr.push(1)
try {
arr.push(6)
await next()
arr.push(7)
} catch (err) {
arr.push(2)
}
arr.push(3)
})

stack.push(async (ctx, next) => {
arr.push(4)
throw new Error()
})

await compose(stack)({})
// 输出顺序 是 [ 1, 6, 4, 2, 3 ]
expect(arr).toEqual([1, 6, 4, 2, 3])
})

相信理解了第一个测试用例和 compose 函数,也是比较好理解这个测试用例了。这一部分其实就是对应的代码在这里。

try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}

3.3 next 函数不能调用多次


it('should throw if next() is called multiple times', () => {
return compose([
async (ctx, next) => {
await next()
await next()
}
])({}).then(() => {
throw new Error('boom')
}, (err) => {
assert(/multiple times/.test(err.message))
})
})

这一块对应的则是:


index = -1
dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
}

调用两次后 iindex 都为 1,所以会报错。


compose/test/test.js文件中总共 300余行,还有很多测试用例可以按照文中方法自行调试。


4. 总结


虽然koa-compose源码 50行 不到,但如果是第一次看源码调试源码,还是会有难度的。其中混杂着高阶函数、闭包、Promisebind等基础知识。


通过本文,我们熟悉了 koa-compose 中间件常说的洋葱模型,学会了部分 jest 用法,同时也学会了如何使用现成的测试用例去调试源码。


相信学会了通过测试用例调试源码后,会觉得源码也没有想象中的那么难


开源项目,一般都会有很全面的测试用例。除了可以给我们学习源码调试源码带来方便的同时,也可以给我们带来的启发:自己工作中的项目,也可以逐步引入测试工具,比如 jest


此外,读开源项目源码是我们学习业界大牛设计思想和源码实现等比较好的方式。



链接:https://juejin.cn/post/7005375860509245471

收起阅读 »

这是一个被面烂的面试题——简述 JavaScript 的事件捕获和事件冒泡

JavaScript 事件冒泡是为了捕捉和处理 DOM 内部传播的事件。但是你知道事件冒泡和事件捕获之间的区别吗? 在这篇文章中,我将用相关的示例来讨论关于这个主题你所需要了解的全部情况。 事件流的传播 在介绍事件捕获和事件冒泡之前,先来看下一个事件是如何在 ...
继续阅读 »

JavaScript 事件冒泡是为了捕捉和处理 DOM 内部传播的事件。但是你知道事件冒泡和事件捕获之间的区别吗?


在这篇文章中,我将用相关的示例来讨论关于这个主题你所需要了解的全部情况。


事件流的传播


在介绍事件捕获和事件冒泡之前,先来看下一个事件是如何在 DOM 内部传播的。


如果我们有几个嵌套的元素处理同一个事件,我们会对哪个事件处理程序会先触发的问题感到困惑。这时,理解事件传播顺序就变得很有必要。



通常,一个事件会从父元素开始向目标元素传播,然后它将被传播回父元素。



JavaScript 事件分为三个阶段:



  • 捕获阶段:事件从父元素开始向目标元素传播,从 Window 对象开始传播。

  • 目标阶段:该事件到达目标元素或开始该事件的元素。

  • 冒泡阶段:这时与捕获阶段相反,事件向父元素传播,直到 Window 对象。


下图将让你进一步了解事件传播的生命周期:


22.jpg


现在你大概了解了 DOM 内部的事件流程,让我们再来看下事件捕获和冒泡是如何出现的。


什么是事件捕获



事件捕获是事件传播的初始场景,从包装元素开始,一直到启动事件生命周期的目标元素。



如果你有一个与浏览器的 Window 对象绑定的事件,它将是第一个被执行的。所以,在下面的例子中,事件处理的顺序将是 WindowDocumentDIV 2DIV 1,最后是 button


33.gif


这里我们可以看到,事件捕获只发生在被点击的元素或目标上,该事件不会传播到子元素。


我们可以使用 addEventListener() 方法的 useCapture 参数来注册捕捉阶段的事件。


target.addEventListener(type, listener, useCapture)

你可以使用下面的代码来测试上述示例,并获得事件捕获的实践经验。


window.addEventListener("click", () => {
console.log('Window');
},true);

document.addEventListener("click", () => {
console.log('Document');
},true);

document.querySelector(".div2").addEventListener("click", () => {
console.log('DIV 2');
},true);

document.querySelector(".div1").addEventListener("click", () => {
console.log('DIV 1');
},true);

document.querySelector("button").addEventListener("click", () => {
console.log('CLICK ME!');
},true);

什么是事件冒泡


如果你知道事件捕获,事件冒泡就很容易理解,它与事件捕获是完全相反的。



事件冒泡将从一个子元素开始,在 DOM 树上传播,直到最上面的父元素事件被处理。



addEventListener() 中省略或将 useCapture 参数设置为 false,将注册冒泡阶段的事件。所以,事件监听器默认监听冒泡事件。


44.gif


在我们的示例中,我们对所有的事件使用了事件捕获或事件冒泡。但是如果我们想在两个阶段内都处理事件呢?


让我们举个例子,在冒泡阶段处理 DocumentDIV 2 的点击事件,其他事件则在捕获阶段处理。


55.gif


连接到 WindowDIV 1button 的点击事件将在捕获过程中分别触发,而 DIV 2Document 监听器则在冒泡阶段依次触发。


window.addEventListener("click", () => {
console.log('Window');
},true);

document.addEventListener("click", () => {
console.log('Document');
}); // 已注册为冒泡

document.querySelector(".div2").addEventListener("click", () => {
console.log('DIV 2');
}); // 已注册为冒泡

document.querySelector(".div1").addEventListener("click", () => {
console.log('DIV 1');
},true);

document.querySelector("button").addEventListener("click", () => {
console.log('CLICK ME!');
},true);

我想现在你已经对事件流、事件冒泡和事件捕获有了很好的理解。那么,让我们看下什么时候可以使用事件冒泡和事件捕获。


事件捕获和冒泡的应用


通常情况下,我们只需要在全局范围内执行一个函数,就可以使用事件传播。例如,我们可以注册文档范围内的监听器,如果 DOM 内有事件发生,它就会运行。



同样地,我们可以使用事件捕获和冒泡来改变用户界面。



假设我们有一个允许用户选择单元格的表格,我们需要向用户显示所选单元格。


66.gif



在这种情况下,为每个单元格分配事件处理程序将不是一个好的做法。它最终会导致代码的重复。



作为一个解决方案,我们可以使用一个单独的事件监听器,并利用事件冒泡和捕获来处理这些事件。


因此,我为 table 创建了一个单独的事件监听器,它将被用来改变单元格的样式。


document.querySelector("table").addEventListener("click", (event) =>
{
if (event.target.nodeName == 'TD')
event.target.style.background = "rgb(230, 226, 40)";
}
);

在事件监听器中,我使用 nodeName 来匹配被点击的单元格,如果匹配,单元格的颜色就会改变。


如何防止事件传播



有时,如果事件冒泡和捕捉开始不受我们控制地传播时,就会让人感到厌烦。



如果你有一个严重嵌套的元素结构,这也会导致性能问题,因为每个事件都会创建一个新的事件周期。


77.gif


在上述情况下,当我点击删除按钮时,包装元素的点击事件也被触发了。这是由于事件冒泡导致的。



我们可以使用 stopPropagation() 方法来避免这种行为,它将阻止事件沿着 DOM 树向上或向下进一步传播。



document.querySelector(".card").addEventListener("click", () => {
$("#detailsModal").modal();
});

document.querySelector("button").addEventListener("click",(event)=>{
event.stopPropagation(); // 停止冒泡
$("#deleteModal").modal();
});

88.gif


本文总结


JavaScript 事件捕获和冒泡可以用来有效地处理 Web 应用程序中的事件。了解事件流以及捕获和冒泡是如何工作的,将有助于你通过正确的事件处理来优化你的应用程序。


例如,如果你的应用程序中有任何意外的事件启动,了解事件捕获和冒泡可以节省你排查问题的时间。


因此,我希望你尝试上述示例并在评论区分享你的经验。


感谢阅读!


链接:https://juejin.cn/post/7005558885947965454

收起阅读 »

几个简单的小例子手把手带你入门webgl

各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点: 为什么需要有shader ? shader的作用是什么???? shader 中的每个参数到...
继续阅读 »

各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点:



  1. 为什么需要有shader ? shader的作用是什么????

  2. shader 中的每个参数到底是什么意思?? 怎么去用???


你如果会了,这篇文章你可以不用看👀,不用浪费时间,去看别的文章。 如果哪里写的有问题欢迎大家指正,我也在不断地学习当中。


WHY NEED SHADER


这里我结合自己的思考🤔,讲讲webgl的整个的一个渲染过程。


渲染管线


Webgl的渲染依赖底层GPU的渲染能力。所以WEBGL 渲染流程和 GPU 内部的渲染管线是相符的。


渲染管线的作用是将3D模型转换为2维图像。


在早期,渲染管线是不可编程的,叫做固定渲染管线,工作的细节流程已经固定,修改的话需要调整一些参数。


现代的 GPU 所包含的渲染管线为可编程渲染管线,可以通过编程 GLSL 着色器语言 来控制一些渲染阶段的细节。


简单来说: 就是使用shader,我们可以对画布中每个像素点做处理,然后就可以生成各种酷炫的效果了。


渲染过程


渲染过程大概经历了下面这么多过程, 因为本篇文章的重点其实是在着色器,所以我重点分析从顶点着色器—— 片元着色器的一个过程



  • 顶点着色器

  • 图片装配

  • 光栅化

  • 片元着色器

  • 逐片段操作(本文不会分享此内容)

  • 裁剪测试

  • 多重采样操作

  • 背面剔除

  • 模板测试

  • 深度测试

  • 融合

  • 缓存


顶点着色器


WebGL就是和GPU打交道,在GPU上运行的代码是一对着色器,一个是顶点着色器,另一个是片元着色器。每次调用着色程序都会先执行顶点着色器,再执行片元着色器。


一个顶点着色器的工作是生成裁剪空间坐标值,通常是以下的形式:


const vertexShaderSource = `
   attribute vec3 position;
   void main() {
       gl_Position = vec4(position,1);
   }

每个顶点调用一次(顶点)着色器,每次调用都需要设置一个特殊的全局变量 gl_Position。 该变量的值就是裁减空间坐标值。 这里有同学就问了, 什么是裁剪空间的坐标值???


其实我之前有讲过,我在讲一遍。


何为裁剪空间坐标?就是无论你的画布有多大,裁剪坐标的坐标范围永远是 -1 到 1 。


看下面这张图:


裁剪坐标系


如果运行一次顶点着色器, 那么gl_Position 就是 (-0.5,-0.5,0,1) 记住他永远是个 Vec4, 简单理解就是对应x、y、z、w。即使你没用其他的,也要设置默认值, 这就是所谓的 3维模型转换到我们屏幕中。


顶点着色器需要的数据,可以通过以下四种方式获得。



  1. attributes 属性(从缓冲读取数据)

  2. uniforms 全局变量 (一般用来对物体做整体变化、 旋转、缩放)

  3. textures 纹理(从像素或者纹理获得数据)

  4. varyings 变量 (将顶点着色器的变量 传给 片元着色器)


ATTRIBUTES 属性


属性可以用 float, vec2, vec3, vec4, mat2, mat3mat4 数据类型


所以它内建的数据类型例如vec2, vec3vec4分别代表两个值,三个值和四个值, 类似的还有mat2, mat3mat4 分别代表 2x2, 3x3 和 4x4 矩阵。 你可以做一些运算例如常量和矢量的乘法。看几个例子吧:


vec4 a = vec4(1, 2, 3, 4);
vec4 b = a * 2.0;
// b 现在是 vec4(2, 4, 6, 8);

向量乘法 和矩阵乘法 :


mat4 a = ???
mat4 b = ???
mat4 c = a * b;

vec4 v = ???
vec4 y = c * v;

它还支持矢量调制,意味者你可以交换或重复分量。


v.yyyy  ===  vec4(y, y, y,y )
v.bgra  ===  vec4(v.b,v.g,v.r,v.a)
vec4(v.rgb, 1) ===  vec4(v.r, v.g, v.b, 1)
vec4(1) === vec4(1, 1, 1, 1)

这样你在处理图片的时候可以轻松进行 颜色通道 对调, 发现你可以实现各种各样的滤镜了。


后面的属性在下面实战中会讲解:我们接着往下走:


图元装配和光栅化


什么是图元?



描述各种图形元素的函数叫做图元,描述几何元素的称为几何图元(点,线段或多边形)。点和线是最简单的几何图元 经过顶点着色器计算之后的坐标会被组装成组合图元



通俗解释图元就是一个点、一条线段、或者是一个多边形。


什么是图元装配呢?


简单理解就是说将我们设置的顶点、颜色、纹理等内容组装称为一个可渲染的多边形的过程。


组装的类型取决于: 你最后绘制选择的图形类型


gl.drawArrays(gl.TRIANGLES, 0, 3)

如果是三角形的话,顶点着色器就执行三次


光栅化


什么是光栅化:


通过图元装配生成的多边形,计算像素并填充,剔除不可见的部分,剪裁掉不在可视范围内的部分。最终生成可见的带有颜色数据的图形并绘制。


光栅化流程图解:


光珊化图解


剔除和剪裁




  • 剔除


    在日常生活中,对于不透明物体,背面对于观察者来说是不可见的。同样,在webgl中,我们也可以设定物体的背面不可见,那么在渲染过程中,就会将不可见的部分剔除,不参与绘制。节省渲染开销。




  • 剪裁


    日常生活中不论是在看电视还是观察物体,都会有一个可视范围,在可视范围之外的事物我们是看不到的。类似的,图形生成后,有的部分可能位于可视范围之外,这一部分会被剪裁掉,不参与绘制。以此来提高性能。这个就是视椎体, 在📷范围内能看到的东西,才进行绘制。




片元着色器


光珊化后,每一个像素点都包含了 颜色 、深度 、纹理数据, 这个我们叫做片元



小tips : 每个像素的颜色由片元着色器的gl_FragColor提供



接收光栅化阶段生成的片元,在光栅化阶段中,已经计算出每个片元的颜色信息,这一阶段会将片元做逐片元挑选的操作,处理过的片元会继续向后面的阶段传递。 片元着色器运行的次数由图形有多少个片元决定的


逐片元挑选


通过模板测试和深度测试来确定片元是否要显示,测试过程中会丢弃掉部分无用的片元内容,然后生成可绘制的二维图像绘制并显示。



  • 深度测试: 就是对 z 轴的值做测试,值比较小的片元内容会覆盖值比较大的。(类似于近处的物体会遮挡远处物体)。

  • 模板测试: 模拟观察者的观察行为,可以接为镜像观察。标记所有镜像中出现的片元,最后只绘制有标记的内容。


实战——绘制个三角形


在进行实战之前,我们先给你看一张图,让你能大概了解,用原生webgl生成一个三角形需要那些步骤:


draw


我们就跟着这个流程图一步一步去操作:


初始化CANVAS


新建一个webgl画布


<canvas id="webgl" width="500" height="500"></canvas>

创建webgl 上下文:


const gl = document.getElementById('webgl').getContext('webgl')

创建着色器程序


着色器的程序这些代码,其实是重复的,我们还是先看下图,看下我们到底需要哪些步骤:


shader


那我们就跟着这个流程图: 一步一步来好吧。


创建着色器


 const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)

gl.VERTEX_SHADER 和 gl.FRAGMENT_SHADER 这两个是全局变量 分别表示顶点着色器片元着色器


绑定数据源


顾名思义: 数据源,也就是我们的着色器 代码。


编写着色器代码有很多种方式:



  1. 用 script 标签 type notjs 这样去写

  2. 模板字符串 (比较喜欢推荐这种)


我们先写顶点着色器:


const vertexShaderSource = `
   attribute vec4 a_position;
   void main() {
       gl_Position = a_position;
   }
`

顶点着色器 必须要有 main 函数 ,他是强类型语言, 记得加分号哇 不是js 兄弟们。 我这段着色器代码非常简单 定义一个vec4 的顶点位置, 然后传给 gl_Position


这里有小伙伴会问 ? 这里a_position一定要这么搞??


这里其实是这样的哇, 就是我们一般进行变量命名的时候 都会的前缀 用来区分 他是属性 还是 全局变量 还是纹理 比如这样:


uniform mat4 u_mat;

表示个矩阵,如果不这样也可以哈。 但是要专业呗,防止bug 影响。


我们接着写片元着色器:


const fragmentShaderSource = `
   void main() {
       gl_FragColor = vec4(1.0,0.0,0.0,1.0);
   }
`

这个其实理解起来非常简单哈, 每个像素点的颜色 是红色 , gl_FragColor 其实对应的是 rgba 也就是颜色的表示。


有了数据源之后开始绑定:


// 创建着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
//绑定数据源
gl.shaderSource(vertexShader, vertexShaderSource)
gl.shaderSource(fragmentShader, fragmentShaderSource)


是不是很简答哈哈哈哈,我觉得你应该会了。


后面着色器的一些操作


其实后面编译着色器绑定着色器连接着色器程序使用着色器程序 都是一个api 搞定的事不多说了 直接看代码:


// 编译着色器
gl.compileShader(vertexShader)
gl.compileShader(fragmentShader)
// 创建着色器程序
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
// 链接 并使用着色器
gl.linkProgram(program)
gl.useProgram(program)

这样我们就创建好了一个着色器程序了。


这里又有人问,我怎么知道我创建的着色器是对的还是错的呢? 我就是很粗心的人呢??? 好的他来了 如何调试:


const success = gl.getProgramParameter(program, gl.LINK_STATUS)
if (success) {
 gl.useProgram(program)
 return program
}
console.error(gl.getProgramInfoLog(program), 'test---')
gl.deleteProgram(program)


getProgramParameter 这个方法用来判断 我们着色器 glsl 语言写的是不是对的, 然后你可以通过 getProgramInfoLog这个方法 类似于打 日志 去发现❌了。


数据存入缓冲区


有了着色器,现在我们差的就是数据了对吧。


上文在写顶点着色器的时候用到了Attributes属性,说明是这个变量要从缓冲中读取数据,下面我们就来把数据存入缓冲中。


首先创建一个顶点缓冲区对象(Vertex Buffer Object, VBO)


const buffer = gl.createBuffer()

gl.createBuffer()函数创建缓冲区并返回一个标识符,接下来需要为WebGL绑定这个buffer


gl.bindBuffer(gl.ARRAY_BUFFER, buffer)

gl.bindBuffer()函数把标识符buffer设置为当前缓冲区,后面的所有的数据都会都会被放入当前缓冲区,直到bindBuffer绑定另一个当前缓冲区


我们新建一个数组 然后并把数据存入到缓冲区中。


const data = new Float32Array([0.0, 0.0, -0.3, -0.3, 0.3, -0.3])
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)

因为JavaScript与WebGL通信必须是二进制的,不能是传统的文本格式,所以这里使用了ArrayBuffer对象将数据转化为二进制,因为顶点数据是浮点数,精度不需要太高,所以使用Float32Array就可以了,这是JavaScript与GPU之间大量实时交换数据的有效方法。


gl.STATIC_DRAW 指定数据存储区的使用方法: 缓存区的内容可能会经常使用,但是不会更改


gl.DYNAMIC_DRAW 表示 缓存区的内容经常使用,也会经常更改。


gl.STREAM_DRAW 表示缓冲区的内容可能不会经常使用


从缓冲中读取数据


GLSL着色程序的唯一输入是一个属性值a_position。 我们要做的第一件事就是从刚才创建的GLSL着色程序中找到这个属性值所在的位置。


const aposlocation = gl.getAttribLocation(program, 'a_position')

接下来我们需要告诉WebGL怎么从我们之前准备的缓冲中获取数据给着色器中的属性。 首先我们需要启用对应属性


gl.enableVertexAttribArray(aposlocation)

最后是从缓冲中读取数据绑定给被激活的aposlocation的位置


gl.vertexAttribPointer(aposlocation, 2, gl.FLOAT, false, 0, 0)

gl.vertexAttribPointer()函数有六个参数:



  1. 读取的数据要绑定到哪

  2. 表示每次从缓存取几个数据,也可以表示每个顶点有几个单位的数据,取值范围是1-4。这里每次取2个数据,之前vertices声明的6个数据,正好是3个顶点的二维坐标。

  3. 表示数据类型,可选参数有gl.BYTE有符号的8位整数,gl.SHORT有符号的16位整数,gl.UNSIGNED_BYTE无符号的8位整数,gl.UNSIGNED_SHORT无符号的16位整数,gl.FLOAT32位IEEE标准的浮点数。

  4. 表示是否应该将整数数值归一化到特定的范围,对于类型gl.FLOAT此参数无效。

  5. 表示每次取数据与上次隔了多少位,0表示每次取数据连续紧挨上次数据的位置,WebGL会自己计算之间的间隔。

  6. 表示首次取数据时的偏移量,必须是字节大小的倍数。0表示从头开始取。


渲染


现在着色器程序 和数据都已经ready 了, 现在就差渲染了。 渲染之前和2d canvas 一样做一个清除画布的动作:


// 清除canvas
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)

我们用0、0、0、0清空画布,分别对应 r, g, b, alpha (红,绿,蓝,阿尔法)值, 所以在这个例子中我们让画布变透明了。


开启绘制三角形:


gl.drawArrays(gl.TRIANGLES, 0, 3)


  1. 第一个参数表示绘制的类型

  2. 第二个参数表示从第几个顶点开始绘制

  3. 第三个参数表示绘制多少个点,缓冲中一共6个数据,每次取2个,共3个点


绘制类型共有下列几种 看图:


drawtype


这里我们看下画面是不是一个红色的三角形 :


三角形截图


我们创建的数据是这样的:


画布的宽度是 500 * 500 转换出来的实际数据其实是这样的


0,0  ====>  0,0 
-0.3, -0.3 ====> 175, 325
0.3, -0.3 ====>  325, 325

矩阵的使用


有了静态的图形我们开始着色器,对三角形做一个缩放。


改写顶点着色器: 其实在顶点着色器上加一个全局变量 这就用到了 着色器的第二个属性 uniform


 const vertexShaderSource = `
 attribute vec4 a_position;
 // 添加矩阵代码
 uniform mat4 u_mat;
 void main() {
     gl_Position = u_mat * a_position;
 }
`

然后和属性一样,我们需要找到 uniform 对应的位置:


const matlocation = gl.getUniformLocation(program, 'u_mat')

然后初始化一个缩放举证:


// 初始化一个旋转矩阵。
 const mat = new Float32Array([
   Tx,  0.0, 0.0, 0.0,
   0.0,  Ty, 0.0, 0.0,
   0.0, 0.0,  Tz, 0.0,
   0.0, 0.0, 0.0, 1.0,
]);

Tx, Ty, Tz 对应的其实就是 x y z 轴缩放的比例。


最后一步, 将矩阵应用到着色器上, 在画之前, 这样每个点 就可以✖️ 这个缩放矩阵了 ,所以整体图形 也就进行了缩放。


gl.uniformMatrix4fv(matlocation, false, mat)

三个参数分别代表什么意思:



  1. 全局变量的位置

  2. 是否为转置矩阵

  3. 矩阵数据


OK 我写了三角形缩放的动画:


  let Tx = 0.1 //x坐标的位置
 let Ty = 0.1 //y坐标的位置
 let Tz = 1.0 //z坐标的位置
 let Tw = 1.0 //差值
 let isOver = true
 let step = 0.08
 function run() {
   if (Tx >= 3) {
     isOver = false
  }
   if (Tx <= 0) {
     isOver = true
  }
   if (isOver) {
     Tx += step
     Ty += step
  } else {
     Tx -= step
     Ty -= step
  }
   const mat = new Float32Array([
     Tx,  0.0, 0.0, 0.0,
     0.0,  Ty, 0.0, 0.0,
     0.0, 0.0,  Tz, 0.0,
     0.0, 0.0, 0.0, 1.0,
  ]);
   gl.uniformMatrix4fv(matlocation, false, mat)
   gl.drawArrays(gl.TRIANGLES, 0, 3)

   // 使用此方法实现一个动画
   requestAnimationFrame(run)
}

效果图如下:


缩放动画


最后 给大家看一下webgl 内部是怎么搞的 一张gif 动画 :


vertex-shader-anim


原始的数据通过 顶点着色器 生成一系列 新的点。


变量的使用


说完矩阵了下面👇,我们开始说下着色器中的varying 这个变量 是如何和片元着色器进行联动的。


我们还是继续改造顶点着色器:


const vertexShaderSource = `
 attribute vec4 a_position;
 uniform mat4 u_mat;
 // 变量
 varying vec4 v_color;
 void main() {
     gl_Position = u_mat * a_position;
     v_color = gl_Position * 0.5 + 0.5;
 }
`

这里有一个小知识 , gl_Position 他的值范围是 -1 -1 但是片元着色 他是颜色 他的范围是 0 - 1 , 所以呢这时候呢,我们就要 做一个范围转换 所以为什么要 乘 0.5 在加上 0.5 了, 希望你们明白。


改造下片元着色器:


const fragmentShaderSource = `
   precision lowp float;
   varying vec4 v_color;
   void main() {
       gl_FragColor = v_color;
   }
`

只要没一个像素点 改为由顶点着色器传过来的就好了。


我们看下这时候的三角形 变成啥样子了。


彩色三角形


是不是变成彩色三角形了, 这里很多人就会问, 这到底是怎么形成呢, 本质是在三角形的三个顶点, 做线性插值的过程:


插值过程


总结


本篇文章大概是对webgl 做了一个基本的介绍, 和带你用几个简单的小例子 带你入门了glsl 语言, 你以为webgl 就这样嘛 那你就错了,其实有一个texture 我是没有讲的, 后面我去专门写一篇文章去将纹理贴图 , 漫反射贴图、 法线贴图。 希望你关注下我,不然找不到我了, 如果你觉得本篇文章对你有帮助的话,欢迎 点赞 、评论、收藏。 我们下期再见👋。




链接:https://juejin.cn/post/7004386540843434020

收起阅读 »

完美解决macOS Homebrew安装JDK的一些问题

自从Oracle接手JDK之后,更新变快了,之前的“旧版本”也不容易下载了。最近一段时间Oracle一直不安生, 搞出来一堆幺蛾子, 所以安装方式也一直在变, 之前的方法已经不能用了, 网上找各种办法都不好使,下面针对各个版本给出了不同建议, 安装结束后, 可...
继续阅读 »

自从Oracle接手JDK之后,更新变快了,之前的“旧版本”也不容易下载了。最近一段时间Oracle一直不安生, 搞出来一堆幺蛾子, 所以安装方式也一直在变, 之前的方法已经不能用了, 网上找各种办法都不好使,下面针对各个版本给出了不同建议, 安装结束后, 可输入java -version确认是否安装成功。


一、JDK8~JDK12、OpenJDK及AdoptOpenJDK


这些都是比较主流的JDK版本, 目前大多数企业还在使用,但想要通过 Homebrew 却并不容易,网上查询的90%Homebrew安装JDK8的方式都是不能用的, 必须要寻求开源世界的帮助, 对于 JDK8 ~ JDK12, 这时会推荐 AdoptOpenJDK.


AdoptOpenJDK 是免费的、完全无品牌的 OpenJDK 版本,基于 GPL 开源协议(+Classpath Extension),以免费软件的形式提供社区版的 OpenJDK 二进制包,公司也可安全且放心使用。与由 Oracle 的 OpenJDK 构建版本不同,这些版本会提供更长的支持,像 Java 11 一样,至少提供 4 年的免费长期支持(LTS)计划。


通过 AdoptOpenJDK 可以安装最多版本的 JDK.


brew cask install AdoptOpenJDK/openjdk/adoptopenjdk8
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk9
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk10
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk11
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk12
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk

二、JDK12、JDK13及OracleJDK


如果你想在电脑上装最新版的 JDK, 那么 Oracle 或许是你最想要的选择, 而 Oracle 家的最新版 JDK 也有两款, 一个是 Oracle 提供的 OpenJDK, 一个是商业版本 Oracle JDK, 但请注意 Oracle JDK 并不比 OpenJDK “更好”, 大家需要理性看待.


# 运行以下命令会安装 Oracle 提供的 Oracle JDK12
brew cask install oracle-jdk

# 在2019年5月
## 该命令会安装由 Oracle 提供的 OpenJDK12
brew cask install java
## 而该命令则安装由 Oracle 提供的 OpenJDK11
brew cask install java11

三、JDK7和Zulu


JDK7 甚至 AdoptOpenJDK 都不提供了, 这时候需要的是有商业背景的 Azul Zulu, zulu 是 OpenJDK 的免费版本, 在提供商业付费支持之外, Azul 也有为 zulu 提供免费的社区技术支持.


通过安装 zulu7 我们可以安装 OpenJDK7.


# Azul Zulu 也提供其他版本的 OpenJDK 像 zulu8 zulu11 和最新版的 zulu 均可使用
brew cask install homebrew/cask-versions/zulu7
brew cask install homebrew/cask-versions/zulu8
brew cask install homebrew/cask-versions/zulu11
brew cask install homebrew/cask-versions/zulu

四、JDK6


估计现在连好多企业都不用了吧,所以放到左后。 JDK6 主要由 Apple 自身提供。


brew cask install homebrew/cask-versions/java6



遇到问题耐心解决,如果还没解决,那肯定是时间没花够



  • 9月21日心心念想、梦寐以求的MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)入手的第一天晚上,我拆机用了多半个小时,就是边拆边拍照,拆个盒子都要洗个手😂那种。在这之前,我平时抽空就提前“上手macOS”

  • 依稀还记得初一自己捣鼓的那台组装机,主板:华硕,CPU:英特尔奔腾E5400,硬盘512G HDD,金士顿2G,现在确实已经很卡了,尽管装了最新版的Windows v1903专业版。不过作为我的第一台电脑,好多东西都是在它上面学的,感谢下老爸将它作为考了年级第一奖励台电脑送给我。

  • 9月21日那天晚上,emmm,是晚上,大家应该和我有同感,一到晚上一些站点就特别慢,比如GitHub之类的,但强迫症的我肯定得在第一时间部署好我的开发环境吧,毕竟大二那会儿我快成专业的运维了~~同学电脑有毛病就找我,开发环境(Java/Android/Python/Node~)有问题还是找我。所以就先装Git、JDK、Maven吧。

  • Git简单,现在macOS 虽然不自带Git,但是安装Homebrew之前安装的Command Line Tools里面包括了Git、gcc等工具,很方便,也比较快(前提是切换了Homebrew的源

  • JDK确实费了老大劲才装好,还是第二天早晨6点10分起来干的,因为早晨访问GitHub这种站点确实比下午快很多。最后诞生了此文。

  • Maven不说了,可以Homebrew安装,也可以在Maven官网下载包之后解压,然后使用,好多人可能会想着配Mavne的环境变量,其实我个人认为没必要。直接说下我是怎么使用的吧:IDEA现在是Java开发的主流IDE工具,里面的终端可以自己配置,结合zsh加上oh my zsh简直无可挑剔!我是使用了“两个Maven”,一个是公司用(公司有自己的Maven仓库),另一个是自己玩(配置的阿里云镜像),在同一个IDEA切换不同的settings.xml便可以实现多Maven切换,之间互不影响,主要是IDEA确实智能,智能在部分配置是每个项目单独的,多个项目可以完全配置不同的Maven,公司项目和自己项目随意切换,Maven也跟着换,很方便。至于不配置Maven环境mvn命令不能使用的问题我想说:IDEA里面依然可以执行你手敲的mvn -U clean package -Dmaven.test.skip=true,所以我不推荐配置Maven的环境变量。用多个Maven的原因我想大家也没明白,如果公司项目里修改了某个包的源码或者重写了某个方法,而你自己项目同样使用了该包的,那么这种情况下极易出错,使用Maven或多或少可能会遇到Maven存在一些Bug,这种迭代了N个版本依然存在的Bug,也许这就是包统一管理的缺陷,如果至今你还没碰过这种情况,说明你的项目比较稳定或者emmmm...你平时自己不捣鼓学习一些东西。


链接:https://juejin.cn/post/6896353939277496327

收起阅读 »

如何将react-native的style样式转换成css样式

背景: 我们总是倾向于一套代码走天下,正所谓一招鲜,吃遍天。刚接触RN项目的时候,常常为RN style样式的写法而头痛,等到熟悉了RN样式写法时,一个web端项目从天而降,于是,你又不得不操练起日渐陌生的css写法。更过分的是,有时你还得在RN样式和css样...
继续阅读 »

背景: 我们总是倾向于一套代码走天下,正所谓一招鲜,吃遍天。刚接触RN项目的时候,常常为RN style样式的写法而头痛,等到熟悉了RN样式写法时,一个web端项目从天而降,于是,你又不得不操练起日渐陌生的css写法。更过分的是,有时你还得在RN样式和css样式之间来回切换,时刻处于水深火热之中。抬首间,不禁叹息一声:人间不值得。


一、准备工作


本文中详细讲解sass样式的转换,其它诸如less、css、PostCss的转换请参考:(https://github.com/kristerkari/react-native-css-modules)
这里面有较为详细说明。


我们需要准备四个依赖:

react-native-sass-transformer 将 Sass 转换为与 React Native 兼容的样式对象并处理实时重新加载

babel-plugin-react-native-platform-specific-extensions 如果磁盘上存在特定于平台的文件,则将 ES6 导入语句转换为特定于平台的 require 语句

babel-plugin-react-native-classname-to-style 将 className 属性转换为 style 属性

node-sass


二、 创建一个React-Native APP


参考官方文档创建即可。


三、安装依赖


yarn add babel-plugin-react-native-classname-to-style babel-plugin-react-native-platform-specific-extensions react-native-sass-transformer node-sass --dev

四、设置babel配置


对于React Native v0.57 或者更新版本


.babelrc (or babel.config.js)


{
"presets": ["module:metro-react-native-babel-preset"],
"plugins": [
"react-native-classname-to-style",
[
"react-native-platform-specific-extensions",
{
"extensions": ["scss", "sass"]
}
]
]
}

对于React Native v0.57以下版本


{
"presets": ["react-native"],
"plugins": [
"react-native-classname-to-style",
[
"react-native-platform-specific-extensions",
{
"extensions": ["scss", "sass"]
}
]
]
}

五、设置Metro配置


在项目根目录下新增一个metro.config.js的文件


const { getDefaultConfig } = require("metro-config");

module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve("react-native-sass-transformer")
},
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
}
};
})()
;


对于React Native v0.57以下版本,在根目录下新增rn-cli.config.js文件


module.exports = {
getTransformModulePath() {
return require.resolve("react-native-sass-transformer");
},
getSourceExts() {
return ["js", "jsx", "scss", "sass"];
}
};

六、接下来你就可以愉快的使用sass来写样式


style.scss


.container {
flex: 1;
justify-content: center;
align-items: center;
background-color: #f5fcff;
}

.blue {
color: blue;
font-size: 30px;
}


你既可以使用className来写样式,也可以使用style


import React, { Component } from "react";
import { Text, View } from "react-native";
import styles from "./styles.scss";

const BlueText = () => {
return Blue Text;
};

export default class App extends Component<{}> {
render() {
return (



);
}
}

七、为sass配置TypeScript


在ts项目中,为sass配置类型提示很有必要。首先我们需要把在第三步第五步中把react-native-sass-transformer依赖替换成react-native-typed-sass-transformer


为了让className 属性正常工作,我们还需要安装下面的依赖包:


对于React Native v0.57 或者更新版本


yarn add typescript --dev

老版本:


yarn add react-native-typescript-transformer typescript --dev

在package.json中添加下面依赖,然后运行yarn命令


"@types/react-native": "^0.57.55",

如果版本versions >=0.52.4


"@types/react-native": "kristerkari/react-native-types-for-css-modules#v0.57.55",

你也可以删掉版本号,但是不建议这样做


"@types/react-native": "kristerkari/react-native-types-for-css-modules",

如果你使用的rn版本>=0.57,这样就OK了,如果不是,请参照文档:github.com/kristerkari…


八、原生提供的属性和方法如何添加到scss文件中,如何做不同机型的适配?


我们需要自定义一个transform用于sass文件的转换。


metro.config.js文件中,修改如下:


const { getDefaultConfig } = require("metro-config");

module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve("./transformer.js")
},
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
}
};
})()
;


metro.config.js


const upstreamTransformer = require("metro-react-native-babel-transformer");
const sassTransformer = require("react-native-typed-sass-transformer");
const DtsCreator = require("typed-css-modules");
const css2rn = require("css-to-react-native-transform").default;

const creator = new DtsCreator();

/** 引入原生的属性和方法 */
const preImport = `
import { PixelRatio, Dimensions, StatusBar, Platform } from 'react-native';
let DEVICE_WIDTH = Dimensions.get('window').width;
let DEVICE_HEIGHT = Dimensions.get('window').height;
let S=(designPx) => {
return PixelRatio.roundToNearestPixel((designPx / 750) * DEVICE_WIDTH);
}
`

function renderCSSToReactNative(css) {
return css2rn(css, { parseMediaQueries: true });
}

/** px转换成pt,做一个标记 */
function pxToPtForMark(code){
let newCode=code;
try {
newCode=code.replace(/([0-9]+)px/g,(...arg)=>{
const px=Number(arg[1]);
return `${px}pt`;
})
} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

/** px 或者 pt单位的适配 需要注意正负值 */
function unitAdaption(code){
let newCode=code;
try {
newCode=code.replace(/"([-+]{0,1})([0-9]+)pt"/g,(...arg)=>{
const px=arg[1]+arg[2];
return `S(${px})`;
})
} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

/** vh和vw的适配 */
function vhAndVwAdaption(code){
let newCode=code;
try {
newCode=code.replace(/"([0-9]+)vw"/g,(...arg)=>{
const vw=Number(arg[1]);
return `${vw/100} * DEVICE_WIDTH`;
}).replace(/"([0-9]+)vh"/g,(...arg)=>{
const vh=Number(arg[1]);
return `${vh/100} * DEVICE_HEIGHT`;
});

} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

function isPlatformSpecific(filename) {
var platformSpecific = [".native.", ".ios.", ".android."];
return platformSpecific.some(name => filename.includes(name));
}

module.exports ={
transform:async function({ src, filename, options }) {
if (filename.endsWith(".scss") || filename.endsWith(".sass")) {

let newSrc=pxToPtForMark(src);

let css =await sassTransformer.renderToCSS({ src:newSrc, filename, options });
let cssObject = renderCSSToReactNative(css);
let cssObjectStr=JSON.stringify(cssObject);

cssObjectStr=unitAdaption(cssObjectStr);

cssObjectStr=vhAndVwAdaption(cssObjectStr);

//特殊文件直接return
if (isPlatformSpecific(filename)) {
return upstreamTransformer.transform({
src: preImport+";module.exports = " + cssObjectStr,
filename,
options
});
}

//一般文件创建types文件之后再return
return creator.create(filename, css).then(content => {
return content.writeFile().then(() => {
return upstreamTransformer.transform({
src: preImport+";module.exports = " + cssObjectStr,
filename,
options
});
});
});
} else {
return upstreamTransformer.transform({ src, filename, options });
}
}
}

在scss文件中,px单位转换成style对象时,会自动去掉,如下:


.unpaidRemind {
position: absolute;
bottom: 56px;
right: 28px;
background-color: #999;
padding: 20px;
border-radius: 16px;
}
.unpaidRemindText {
color: rgba(255, 255, 255, 0.9);
font-size: 28px;
}

转换之后变成


{
unpaidRemind: {
position: 'absolute',
bottom: 56,
right: 28,
backgroundColor: '#999',
padding: 20,
borderRadius: 16,
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 28,
},
}

我们的目标是在在转后之后把所有px都换成我们的适配方法:


{
unpaidRemind: {
position: 'absolute',
bottom: S(55),
right: S(28),
backgroundColor: '#999',
padding: S(20),
borderRadius: S(16),
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: S(28),
},
}

最终拿到的代码类似于这样,它是可以直接执行的,同样的道理,我们可以注入更多的RN属性到我们的文件中,这取决于我们是否需要这些属性。


import { PixelRatio, Dimensions, StatusBar, Platform } from 'react-native'; 
let DEVICE_WIDTH = Dimensions.get('window').width;
let DEVICE_HEIGHT = Dimensions.get('window').height;
let S=(designPx) => { return PixelRatio.roundToNearestPixel((designPx / 750) * DEVICE_WIDTH); }
module.exports ={
unpaidRemind: {
position: 'absolute',
bottom: S(55),
right: S(28),
backgroundColor: '#999',
padding: S(20),
borderRadius: S(16),
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: S(28),
},
}

pxToPtForMark方法将px转换成pt,这一步主要是方便我们后续把pt转成S(28)这种形式,unitAdaption方法就是实现这一功能。为什么不是直接把px转成S(28)这种形式?renderCSSToReactNative会把px转没掉,我们无法区分flex:1这种属性和fontSize:28的区别,但是它不会吧pt转没,而是变成fontSize:"28pt".


为了使vhvw这两个单位能够生效,我们使用vhAndVwAdaption方法做了处理,width:100vw最后会变成width:100/100 * DEVICE_WIDTH,其中DEVICE_WIDTH就是我们前面注入的设备宽度这个变量。


九、referenceError:'xx' is not defined 报错


const Button=(props)=>{ 
const {style}=props;
return
}
const Page=()=>{
return
}

//以上写法会导致报referenceError:'xx' is not defined,而且是非必现,偶尔会报
//要这样写
const Button=(props)=>{
const {style}=props;
return
}
const Page=()=>{
return
}

链接:https://juejin.cn/post/6995883216695459870

收起阅读 »

聊聊 RN 中 Android 提供 View 的那些坑

最近笔者研究 Android 中使用自定义 View 提供原生组件给 React Native(下面统一写成 RN ) 端的时候,遇到一些实际问题,在这里从 RN 的一些...
继续阅读 »


最近笔者研究 Android 中使用自定义 View 提供原生组件给 React Native(下面统一写成 RN ) 端的时候,遇到一些实际问题,在这里从 RN 的一些工作机制入手,分享一下问题的原因和解决方案。

自定义 View 内容不生效

原因

在给 RN 提供自定义 View 的时候发现自定义 View 内部很多 UI 逻辑没有生效。
例如下图,根据逻辑隐藏/展示了一些控件,但是应显示控件的位置没有变化。被隐藏控件的位置还是空出来的。很明显整个自定义 View 的 requestLayout 没有执行。


问题的答案就在 RN 根布局 ReactRootView 的 measure 方法里面。


在这个View的测量过程中,会判断 measureSpec 是否有更新。


当 measureSpec 有变化,或者宽高有变化的时候,才会触发 updateRootLayoutSpecs 的逻辑。
继续看下 updateRootLayoutSpecs 里做了一些什么事情,跟着源码最后会执行到 UIImplementation 的 dispatchViewUpdates 方法:


最终执行:


这里会从根节点往下一直更新子 View ,执行 View的 measure 和 layout
所以 ReactRootView 在宽高和测量模式都没有变化的情况下,就相当于把子 View 发出的 requestLayout 请求都拦截了。

解决方案

知道了原因就非常好解决了,既然你不让我通知我的根控件需要重新布局,那我就自己给自己重新布局好了。参考了 RN 一些自带的自定义 View 的实现,我们可以在这个自定义 View 重新布局的时候,注册一个 FrameCallback 去执行自己的 measure 和 layout 方法。

RN 自定义View 必须在JS端设置宽高

实现了自定义 View 之后,在 JSX 里面指定标签之后,会发现这个原生组件并没有显示。通过 IDE 的 Layout Inspect 可以发现此时这个自定义 View 的 width 和 height 都是 0 。如果设置了 width 和height 的话就可以展示了。
这时候就很奇怪了, 为什么我的自定义 View 里面的内容明明是 WRAP_CONTENT 的,很多自定义 View 又是直接继承的 ConstraintLayout 、 RelativeLayout 这种 Android 的 ViewGroup ,但还是要指定宽高才能在 RN 中渲染出来呢?
要解决这个疑惑,就需要了解一下 RN 的渲染流程。

RN 是怎么确定 Native View的宽高的

我们顺着 RN 更新 View 结构的 UIImplementation#updateViewHierarchy 方法,发现有两处关键的逻辑:


calculateRootLayout 中调用了 cssRoot 的布局计算逻辑:


接下来就是 applyUpdatesRecursive,顾名思义就是递归的更新根节点的所有子节点,在我们的场景中即整个页面的布局。


需要更新的节点则调用了 dispatchUpdates 方法,执行 enqueueUpdateLayout, 调用 NativeViewHierarchyManager#updateLayout 逻辑。


updateLayout 的核心流程如下:

  • 调用 resolveView 方法获取到真实的控件对象。
  • 调用这个控件的 measure 方法。


  • 调用updateLayout,执行这个控件的 layout方法



发现了没有?这里的 widthheight 已经是固定的值分别传给了 meausre 和 layout, 也就是说,这些 View 的宽高根本不是 Android 的绘制流程决定的,那么这个 width 和 height 的值是从哪里来的呢?
回头看看就发现了答案:


宽高是 lefttoprightbottom坐标相减得到的,而这些坐标则是通过
getLayoutWidth 和 getLayoutHeight 得到的:


而这个 layoutWidth 和 layoutHeight,则都是 Yoga 帮我们计算好,存放在 YogoNode里面的。
关于 Yoga

Yoga 是 Facebook 实现的一个高性能、易用、 Flex 的跨端布局引擎。
React Native 内部则是使用 Yoga 来布局的。
具体内容可以看 Yoga 的官网:https://yogalayout.com/

这里也就解释了为什么自定义 View 需要在 jsx 中指定了 width 和 height 才会渲染出来。因为这些自定义 View 原本在 Android系统的 measure layout 流程都已经被 RN 给控制住了。
这里可以总结成一句话:
RN 中最终渲染出来的控件的宽高,都由 Yoga 引擎来计算决定,系统自身的布局流程无法直接决定这些控件的宽高
但是这时候还是有一个疑问,为什么RN自己的一些组件,例如  ,没有指定
宽高也可以正常自适应显示呢?

为什么 RN 自己的 Text 是有自己的宽高的

我们来看一下RN是怎么定义渲染出来的 TextView 的,找到对应的 TextView 的 ViewManager,
com.facebook.react.views.text.ReactTextViewManager
我们关注两个方法:

  1. createViewInstance


  1. createShadowNodeInstance



其中,ReactTextView 其实就是实现了一个普通的 Android TextViewReactTextShadowNode 则表示了这个 TextView 对应的 YogaNode 的实现。


在它的实现中,我们可以看到一个成员变量,从名字上看是负责这个 YogaNode 的 measure 工作。


YogaNodeJNIBase 会调用这个JNI的方法,给JNI的逻辑注册这样一个回调函数。


这个 YogaMeasureFunction 的具体实现:


这里截个图,可以看到这里调用了 Android 中 Text 绘制的 API 来确定的文本的宽高。函数返回的是


这里是使用了 YogaMeasureOutput.make 把 Layout 算出来的宽高转成一定格式的二进制回调给 Yoga 引擎,这也是为什么 RN 自己的 Text 标签是可以自适应宽高展示的。
这里我们也可以得到一个结论:如果 Android 端封装的自定义 View 可以是确定宽高或者内部的控件是非常固定可以通过 measure 和 layout 就能算出宽高的,我们可以通过注册 measureFunction 回调的方式告诉 Yoga 我们 View 的宽高。
但是在实际业务中,我们很多业务组件是封装在 ConstraintLayout 、RelativeLayout 等 ViewGroup 中,所以我们还需要其他的方法来解决组件宽高设置的问题。

解决方案

那么这个问题可以重写 View 的 onMeasure 和 layout 方法来解决吗?看起来是这个做法是可以解决 View 宽高为 0 渲染不出来的问题。但是如果 jsx 这样描述布局的时候:


这时候 AndroidView 和 Text 会同时显示,并且 AndroidView 被 Text 遮住。
稍微思考一下就能得到原因:对于 Yoga 引擎来说,AndroidView 所代表的的节点仍然是没有宽高的,YogaNode 里面的 widthheight 仍然是 0,那么当重写 onMeasure 和 onLayout 的逻辑生效后,View 显示的左上方顶点是 (0,0) 的坐标。
而 Yoga 引擎自己计算出 Text 的宽高后, Text 的左上方顶点坐标肯定也是 (0,0) ,所以这时候2个 View 会显示在同一个位置(重叠或者覆盖)。
所以这时候问题就变成了,我们想通过 Android 自己的布局流程来确定并刷新这个自定义控件,但是 Yoga 引擎并不知道。
所以想要解决这个问题,可行的有两条路:

  • 改变 UI 层级和自定义 View 的粒度
  • Native 测量出实际需要的宽高后同步给Yoga 引擎
增加自定义控件的粒度

举一个自定义控件的例子:


我们希望把这个图上第一行的控件拆分成粒度较低的自定义 View 交给 RN 来布局实现布局动态配置的能力。但是这类场景的左右两边控件都是自适应宽度。这时候在 JS 端其实没有办法提供一个合适的宽度。考虑到更多场景下同一个方向轴上的自适应宽度控件是有位置上的依赖性的,所以可以不拆分这两个部分,直接都定义在同一个自定义 View 内:


提供给 JS 端使用,没有宽高的话,就把整个 SingHeaderView 的宽度设置成


这时候内部的两个控件会自己去进行布局。最终展示出来的就是左右都是 Wrap_Content 的。

Native 测量出实际需要的宽高后同步给Yoga引擎

但是控制自定义 View 的粒度的方式总归是不够灵活,开发的时候也往往会让人犹豫是否拆分。接着之前的内容,既然这个问题的矛盾点在于 Yoga 不知道 Android 可以自己再次调用 measure 来确定宽高,那如果能把最新的宽高传给 Yoga,不就可以解决我们的问题吗?
具体怎么触发 YogaNode 的刷新呢?通过阅读源码可以找到解决方法。在 UIManage里面,有一个叫做 updateNodeSize 的 api:


这个 api 会更新 View 对应的 cssNode 的大小,然后分发刷新 View 的逻辑。这个逻辑是需要保证在后台消息队列里面执行的,所以需要把这个刷新的消息发送到 nativeModulesQueueThread 里面去执行。
我们在 ViewManager 里面保存这个 Manager 对应的 View 和 ReactNodeImpl 实例。例如 Android 端封装了一个 LinearLayout , 对应的 node 是 MyLinearLayoutNode


重写自定义 View 的 onMeasure, 让自己是 wrap_content 的布局:


在 requestLayout 中根据自己真实的宽高布局并触发以下逻辑:




不过上面这个方案虽然可以解决 View 的 wrap_content 显示的问题,但是存在一些缺点:
刷新 YogaNode 实际是在 requestLayout 的时候触发的,这就相当于 requestLayout 这种比较耗费性能的操作会双倍的执行。对于一些可能会频繁触发 requestLayout 的业务场景来说需要慎重考虑。如果遇到这种场景,还是需要根据自己的需求来灵活选择解决方式。

收起阅读 »

巧用CSS filter,让你的网站更加酷炫!

前言 我们在处理图片时,经常使用的一个功能就是滤镜,它能使一张图像呈现各种不同的视觉效果。 在 CSS 中,也有一个filter属性,让我们能用 CSS 代码为元素指定各种滤镜效果,比如模糊、灰度、明暗度、颜色偏移等。 CSS filter的基础使用非常简单...
继续阅读 »

前言


我们在处理图片时,经常使用的一个功能就是滤镜,它能使一张图像呈现各种不同的视觉效果。


image.png


在 CSS 中,也有一个filter属性,让我们能用 CSS 代码为元素指定各种滤镜效果,比如模糊、灰度、明暗度、颜色偏移等。


CSS filter的基础使用非常简单,CSS 标准里包含了一些已实现预定义效果的函数(下面blur、brightness、contrast等),我们可以通过指定这些函数的值来实现想要的效果:


/* 使用单个滤镜 (如果传入的参数是百分数,那么也可以传入对应的小数:40% --> 0.4)*/
filter: blur(5px);
filter: brightness(40%);
filter: contrast(200%);
filter: drop-shadow(16px 16px 20px blue);
filter: grayscale(50%);
filter: hue-rotate(90deg);
filter: invert(75%);
filter: opacity(25%);
filter: saturate(30%);
filter: sepia(60%);

/* 使用多个滤镜 */
filter: contrast(175%) brightness(3%);

/* 不使用任何滤镜 */
filter: none;

官方demo:MDN


filter-demo.gif


滤镜在日常开发中是很常见的,比如使用drop-shadow给不规则形状添加阴影;使用blur来实现背景模糊,以及毛玻璃效果等。


下面我们将进一步使用CSS filter实现一些动画效果,让网站交互更加酷炫,同时也加深对CSS filter的理解。一起开始吧!


( 下面要使用到的 动画 和 伪类 知识,在 CSS的N个编码技巧 中都有详细的介绍,这里就不重复了,有需要的朋友可以前往查看哦。 )


电影效果


滤镜中的brightness用于调整图像的明暗度。默认值是1;小于1时图像变暗,为0时显示为全黑图像;大于1时图像显示比原图更明亮。


我们可以通过调整 背景图的明暗度文字的透明度 ,来模拟电影谢幕的效果。


movie.gif


<div>
<div></div>
<div>
<p>如果生活中有什么使你感到快乐,那就去做吧</p>
<br>
<p>不要管别人说什么</p>
</div>
</div>

.pic{
height: 100%;
width: 100%;
position: absolute;
background: url('./images/movie.webp') no-repeat;
background-size: cover;
animation: fade-away 2.5s linear forwards; //forwards当动画完成后,保持最后一帧的状态
}
.text{
position: absolute;
line-height: 55px;
color: #fff;
font-size: 36px;
text-align: center;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
opacity: 0;
animation: show 2s cubic-bezier(.74,-0.1,.86,.83) forwards;
}

@keyframes fade-away { //背景图的明暗度动画
30%{
filter: brightness(1);
}
100%{
filter: brightness(0);
}
}
@keyframes show{ //文字的透明度动画
20%{
opacity: 0;
}
100%{
opacity: 1;
}
}

模糊效果


在下面的单词卡片中,当鼠标hover到某一张卡片上时,其他卡片背景模糊,使用户焦点集中到当前卡片。


card-blur.gif


html结构:


<ul>
<li>
<p>Flower</p>
<p>The flowers mingle to form a blaze of color.</p>
</li>
<li>
<p>Sunset</p>
<p>The sunset glow tinted the sky red.</p>
</li>
<li>
<p>Plain</p>
<p>The winds came from the north, across the plains, funnelling down the valley. </p>
</li>
</ul>

实现的方式,是将背景加在.card元素的伪类上,当元素不是焦点时,为该元素的伪类加上滤镜。


.card:before{
z-index: -1;
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 20px;
filter: blur(0px) opacity(1);
transition: filter 200ms linear, transform 200ms linear;
}
/*
这里不能将滤镜直接加在.card元素,而是将背景和滤镜都加在伪类上。
因为,父元素加了滤镜,它的子元素都会一起由该滤镜改变。
如果滤镜直接加在.card元素上,会导致上面的文字也变模糊。
*/

//通过css选择器选出非hover的.card元素,给其伪类添加模糊、透明度和明暗度的滤镜 

.cards:hover > .card:not(:hover):before{
filter: blur(5px) opacity(0.8) brightness(0.8);
}

//对于hover的元素,其伪类增强饱和度,尺寸放大

.card:hover:before{
filter: saturate(1.2);
transform: scale(1.05);
}

褪色效果


褪色效果可以打造出一种怀旧的风格。下面这组照片墙,我们通过sepia滤镜将图像基调转换为深褐色,再通过降低 饱和度saturate 和 色相旋转hue-rotate 微调,模拟老照片的效果。


old-photo-s.gif


.pic{
border: 3px solid #fff;
box-shadow: 0 10px 50px #5f2f1182;
filter: sepia(30%) saturate(40%) hue-rotate(5deg);
transition: transform 1s;
}
.pic:hover{
filter: none;
transform: scale(1.2) translateX(10px);
z-index: 1;
}

灰度效果


怎样让网站变成灰色?在html元素上加上filter: grayscale(100%)即可。


grayscale(amount)函数将改变输入图像灰度。amount 的值定义了灰度转换的比例。值为 100% 则完全转为灰度图像,值为 0% 图像无变化。若未设置值,默认值是 0


gray-scale.gif


融合效果


要使两个相交的元素产生下面这种融合的效果,需要用到的滤镜是blurcontrast


merge.gif


<div>
<div></div>
<div></div>
</div>

.container{
margin: 50px auto;
height: 140px;
width: 400px;
background: #fff; //给融合元素的父元素设置背景色
display: flex;
align-items: center;
justify-content: center;
filter: contrast(30); //给融合元素的父元素设置contrast
}
.circle{
border-radius: 50%;
position: absolute;
filter: blur(10px); //给融合元素设置blur
}
.circle-1{
height: 90px;
width: 90px;
background: #03a9f4;
transform: translate(-50px);
animation: 2s moving linear infinite alternate-reverse;
}
.circle-2{
height: 60px;
width: 60px;
background: #0000ff;
transform: translate(50px);
animation: 2s moving linear infinite alternate;
}
@keyframes moving { //两个元素的移动
0%{
transform: translate(50px)
}
100%{
transform: translate(-50px)
}
}

实现融合效果的技术要点:



  1. contrast滤镜应用在融合元素的父元素(.container)上,且父元素必须设置background

  2. blur滤镜应用在融合元素(.circle)上。


blur设置图像的模糊程度,contrast设置图像的对比度。当两者像上面那样组合时,就会产生神奇的融合效果,你可以像使用公式一样使用这种写法。


在这种融合效果的基础上,我们可以做一些有趣的交互设计。



  • 加载动画:


loading-l.gif
htmlcss如下所示,这个动画主要通过控制子元素.circle的尺寸和位移来实现,但是由于父元素和子元素都满足 “融合公式” ,所以当子元素相交时,就出现了融合的效果。


<div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>

.container {
margin: 10px auto;
height: 140px;
width: 300px;
background: #fff; //父元素设置背景色
display: flex;
align-items: center;
filter: contrast(30); //父元素设置contrast
}
.circle {
height: 50px;
width: 60px;
background: #1aa7ff;
border-radius: 50%;
position: absolute;
filter: blur(20px); //子元素设置blur
transform: scale(0.1);
transform-origin: left top;
}
.circle{
animation: move 4s cubic-bezier(.44,.79,.83,.96) infinite;
}
.circle:nth-child(2) {
animation-delay: .4s;
}
.circle:nth-child(3) {
animation-delay: .8s;
}
.circle:nth-child(4) {
animation-delay: 1.2s;
}
.circle:nth-child(5) {
animation-delay: 1.6s;
}
@keyframes move{ //子元素的位移和尺寸动画
0%{
transform: translateX(10px) scale(0.3);
}
45%{
transform: translateX(135px) scale(0.8);
}
85%{
transform: translateX(270px) scale(0.1);
}
}


  • 酷炫的文字出场方式:


gooey-text.gif
主要通过不断改变letter-spacingblur的值,使文字从融合到分开:


<div>
<span>fantastic</span>
</div>

.container{
margin-top: 50px;
text-align: center;
background-color: #000;
filter: contrast(30);
}
.text{
font-size: 100px;
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
letter-spacing: -40px;
color: #fff;
animation: move-letter 4s linear forwards; //forwards当动画完成后,保持最后一帧的状态
}
@keyframes move-letter{
0% {
opacity: 0;
letter-spacing: -40px;
filter: blur(10px);
}
25% {
opacity: 1;
}
50% {
filter: blur(5px);
}
100% {
letter-spacing: 20px;
filter: blur(2px);
}
}

水波效果


filter还可以通过 URL 链接到 SVG 滤镜元素,SVG滤镜元素MDN 。


下面的水波纹效果就是基于 SVG 的feTurbulence滤镜实现的,原理参考了 说说SVG的feTurbulence滤镜

SVG feTurbulence滤镜深入介绍,有兴趣的朋友可以深入阅读。



feTurbulence滤镜借助Perlin噪声算法模拟自然界真实事物那样的随机样式。它接收下面5个属性:



  • baseFrequency表示噪声的基本频率参数,频率越高,噪声越密集。

  • numOctaves就表示倍频的数量,倍频的数量越多,噪声看起来越自然。

  • seed属性表示feTurbulence滤镜效果中伪随机数生成的起始值,不同数量的seed不会改变噪声的频率和密度,改变的是噪声的形状和位置。

  • stitchTiles定义了Perlin噪声在边框处的行为表现。

  • type属性值有fractalNoiseturbulence,模拟随机样式使用turbulence



wave.gif


在这个例子,两个img标签使用同一张图片,将第二个img标签使用scaleY(-1)实现垂直方向的镜像翻转,模拟倒影。


并且,对倒影图片使用feTurbulence滤镜,通过动画不断改变feTurbulence滤镜的baseFrequency值实现水纹波动的效果。


<div>
<img src="images/moon.jpg">
<img src="images/moon.jpg">
</div>

<!--定义svg滤镜,这里使用的是feTurbulence滤镜-->
<svg width="0" height="0">
<filter id="displacement-wave-filter">

<!--baseFrequency设置0.01 0.09两个值,代表x轴和y轴的噪声频率-->
<feTurbulence baseFrequency="0.01 0.09">

<!--这是svg动画的定义方式,通过动画不断改变baseFrequency的值,从而形成波动效果-->
<animate attributeName="baseFrequency"
dur="20s" keyTimes="0;0.5;1" values="0.01 0.09;0.02 0.13;0.01 0.09"
repeatCount="indefinite" ></animate>

</feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="10" />
</filter>
</svg>

.container{
height: 520px;
width: 400px;
display: flex;
clip-path: inset(10px);
flex-direction: column;
}
img{
height: 50%;
width: 100%;
}
.reflect {
transform: translateY(-2px) scaleY(-1);
//对模拟倒影的元素应用svg filter
//url中对应的是上面svg filter的id
filter: url(#displacement-wave-filter);
}

抖动效果


在上面的水波动画中改变的是baseFrequency值,我们也通过改变seed的值,实现文字的抖动效果。
text-shaking.gif


<div>
<p>Such a joyful night!</p>
</div>
<svg width="0" height="0">
<filter id="displacement-text-filter">

<!--定义feTurbulence滤镜-->
<feTurbulence baseFrequency="0.02" seed="0">

<!--这是svg动画的定义方式,通过动画不断改变seed的值,形成抖动效果-->
<animate attributeName="seed"
dur="1s" keyTimes="0;0.5;1" values="1;2;3"
repeatCount="indefinite" ></animate>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="10" />
</filter>
</svg>

.shaky{
font-size: 60px;
filter: url(#displacement-text-filter); //url中对应的是上面svg filter的id
}

链接:https://juejin.cn/post/7002829486806794276

收起阅读 »

用 JavaScript 做数独

最近看到老婆天天在手机上玩数独,突然想起 N 年前刷 LeetCode 的时候,有个类似的算法题(37.解数独),是不是可以把这个算法进行可视化。 说干就干,经过一个小时的实践,最终效果如下: 怎么解数独 解数独之前,我们先了解一下数独的规则: 数字 1-...
继续阅读 »

最近看到老婆天天在手机上玩数独,突然想起 N 年前刷 LeetCode 的时候,有个类似的算法题(37.解数独),是不是可以把这个算法进行可视化。


说干就干,经过一个小时的实践,最终效果如下:



怎么解数独


解数独之前,我们先了解一下数独的规则:



  1. 数字 1-9 在每一行只能出现一次。

  2. 数字 1-9 在每一列只能出现一次。

  3. 数字 1-9 在每一个以粗实线分隔的九宫格( 3x3 )内只能出现一次。



接下来,我们要做的就是在每个格子里面填一个数字,然后判断这个数字是否违反规定。


填第一个格子


首先,在第一个格子填 1,发现在第一列里面已经存在一个 1,此时就需要擦掉前面填的数字 1,然后在格子里填上 2,发现数字在行、列、九宫格内均无重复。那么这个格子就填成功了。



填第二个格子


下面看第二个格子,和前面一样,先试试填 1,发现在行、列、九宫格内的数字均无重复,那这个格子也填成功了。



填第三个格子


下面看看第三个格子,由于前面两个格子,我们已经填过数字 12,所以,我们直接从数字 3 开始填。填 3 后,发现在第一行里面已经存在一个 3,然后在格子里填上 4,发现数字 4 在行和九宫格内均出现重复,依旧不成功,然后尝试填上数字 5,终于没有了重复数字,表示填充成功。



一直填,直到填到第九个格子


照这个思路,一直填到第九个格子,这个时候,会发现,最后一个数字 9 在九宫格内冲突了。而 9 已经是最后一个数字了,这里没办法填其他数字了,只能返回上一个格子,把第七个格子的数字从 8 换到 9,发现在九宫格内依然冲突。


此时需要替换上上个格子的数字(第六个格子)。直到没有冲突为止,所以在这个过程中,不仅要往后填数字,还要回过头看看前面的数字有没有问题,不停地尝试。



综上所述


解数独就是一个不断尝试的过程,每个格子把数字 1-9 都尝试一遍,如果出现冲突就擦掉这个数字,直到所有的格子都填完。



通过代码来实现


把上面的解法反映到代码上,就需要通过 递归 + 回溯 的思路来实现。


在写代码之前,先看看怎么把数独表示出来,这里参考 leetcode 上的题目:37. 解数独



前面的这个题目,可以使用一个二维数组来表示。最外层数组内一共有 9 个数组,表示数独的 9 行,内部的每个数组内 9 字符分别对应数组的列,未填充的空格通过字符('.' )来表示。


const sudoku = [
['.', '.', '.', '4', '.', '.', '.', '3', '.'],
['7', '.', '4', '8', '.', '.', '1', '.', '2'],
['.', '.', '.', '2', '3', '.', '4', '.', '9'],
['.', '4', '.', '5', '.', '9', '.', '8', '.'],
['5', '.', '.', '.', '.', '.', '9', '1', '3'],
['1', '.', '.', '.', '8', '.', '2', '.', '4'],
['.', '.', '.', '.', '.', '.', '3', '4', '5'],
['.', '5', '1', '9', '4', '.', '7', '2', '.'],
['4', '7', '3', '.', '5', '.', '.', '9', '1'],
]

知道如何表示数组后,我们再来写代码。


const sudoku = [……]
// 方法接受行、列两个参数,用于定位数独的格子
function solve(row, col) {
if (col >= 9) {
// 超过第九列,表示这一行已经结束了,需要另起一行
col = 0
row += 1
if (row >= 9) {
// 另起一行后,超过第九行,则整个数独已经做完
return true
}
}
if (sudoku[row][col] !== '.') {
// 如果该格子已经填过了,填后面的格子
return solve(row, col + 1)
}
// 尝试在该格子中填入数字 1-9
for (let num = 1; num <= 9; num++) {
if (!isValid(row, col, num)) {
// 如果是无效数字,跳过该数字
continue
}
// 填入数字
sudoku[row][col] = num.toString()
// 继续填后面的格子
if (solve(row, col + 1)) {
// 如果一直到最后都没问题,则这个格子的数字没问题
return true
}
// 如果出现了问题,solve 返回了 false
// 说明这个地方要重填
sudoku[row][col] = '.' // 擦除数字
}
// 数字 1-9 都填失败了,说明前面的数字有问题
// 返回 FALSE,进行回溯,前面数字要进行重填
return false
}

上面的代码只是实现了递归、回溯的部分,还有一个 isValid 方法没有实现。该方法主要就是按照数独的规则进行一次校验。


const sudoku = [……]
function isValid(row, col, num) {
// 判断行里是否重复
for (let i = 0; i < 9; i++) {
if (sudoku[row][i] === num) {
return false
}
}
// 判断列里是否重复
for (let i = 0; i < 9; i++) {
if (sudoku[i][col] === num) {
return false
}
}
// 判断九宫格里是否重复
const startRow = parseInt(row / 3) * 3
const startCol = parseInt(col / 3) * 3
for (let i = startRow; i < startRow + 3; i++) {
for (let j = startCol; j < startCol + 3; j++) {
if (sudoku[i][j] === num) {
return false
}
}
}
return true
}

通过上面的代码,我们就能解出一个数独了。


const sudoku = [
['.', '.', '.', '4', '.', '.', '.', '3', '.'],
['7', '.', '4', '8', '.', '.', '1', '.', '2'],
['.', '.', '.', '2', '3', '.', '4', '.', '9'],
['.', '4', '.', '5', '.', '9', '.', '8', '.'],
['5', '.', '.', '.', '.', '.', '9', '1', '3'],
['1', '.', '.', '.', '8', '.', '2', '.', '4'],
['.', '.', '.', '.', '.', '.', '3', '4', '5'],
['.', '5', '1', '9', '4', '.', '7', '2', '.'],
['4', '7', '3', '.', '5', '.', '.', '9', '1']
]
function isValid(row, col, num) {……}
function solve(row, col) {……}
solve(0, 0) // 从第一个格子开始解
console.log(sudoku) // 输出结果

输出结果


动态展示做题过程


有了上面的理论知识,我们就可以把这个做题的过程套到 react 中,动态的展示做题的过程,也就是文章最开始的 Gif 中的那个样子。


这里直接使用 create-react-app 脚手架快速启动一个项目。


npx create-react-app sudoku
cd sudoku

打开 App.jsx ,开始写代码。


import React from 'react';
import './App.css';

class App extends React.Component {
state = {
// 在 state 中配置一个数独二维数组
sudoku: [
['.', '.', '.', '4', '.', '.', '.', '3', '.'],
['7', '.', '4', '8', '.', '.', '1', '.', '2'],
['.', '.', '.', '2', '3', '.', '4', '.', '9'],
['.', '4', '.', '5', '.', '9', '.', '8', '.'],
['5', '.', '.', '.', '.', '.', '9', '1', '3'],
['1', '.', '.', '.', '8', '.', '2', '.', '4'],
['.', '.', '.', '.', '.', '.', '3', '4', '5'],
['.', '5', '1', '9', '4', '.', '7', '2', '.'],
['4', '7', '3', '.', '5', '.', '.', '9', '1']
]
}

// TODO:解数独
solveSudoku = async () => {
const { sudoku } = this.state
}

render() {
const { sudoku } = this.state
return (
<div className="container">
<div className="wrapper">
{/* 遍历二维数组,生成九宫格 */}
{sudoku.map((list, row) => (
{/* div.row 对应数独的行 */}
<div className="row" key={`row-${row}`}>
{list.map((item, col) => (
{/* span 对应数独的每个格子 */}
<span key={`box-${col}`}>{ item !== '.' && item }</span>
))}
</div>
))}
<button onClick={this.solveSudoku}>开始做题</button>
</div>
</div>
);
}
}

九宫格样式


给每个格子加上一个虚线的边框,先让它有一点九宫格的样子。


.row {
display: flex;
direction: row;
/* 行内元素居中 */
justify-content: center;
align-content: center;
}
.row span {
/* 每个格子宽高一致 */
width: 30px;
min-height: 30px;
line-height: 30px;
text-align: center;
/* 设置虚线边框 */
border: 1px dashed #999;
}

可以得到一个这样的图形:



接下来,需要给外边框和每个九宫格加上实线的边框,具体代码如下:


/* 第 1 行顶部加上实现边框 */
.row:nth-child(1) span {
border-top: 3px solid #333;
}
/* 第 3、6、9 行底部加上实现边框 */
.row:nth-child(3n) span {
border-bottom: 3px solid #333;
}
/* 第 1 列左边加上实现边框 */
.row span:first-child {
border-left: 3px solid #333;
}

/* 第 3、6、9 列右边加上实现边框 */
.row span:nth-child(3n) {
border-right: 3px solid #333;
}

这里会发现第三、六列的右边边框和第四、七列的左边边框会有点重叠,第三、六行的底部边框和第四、七行的顶部边框也会有这个问题,所以,我们还需要将第四、七列的左边边框和第三、六行的底部边框进行隐藏。



.row:nth-child(3n + 1) span {
border-top: none;
}
.row span:nth-child(3n + 1) {
border-left: none;
}

做题逻辑


样式写好后,就可以继续完善做题的逻辑了。


class App extends React.Component {
state = {
// 在 state 中配置一个数独二维数组
sudoku: [……]
}

solveSudoku = async () => {
const { sudoku } = this.state
// 判断填入的数字是否有效,参考上面的代码,这里不再重复
const isValid = (row, col, num) => {
……
}
// 递归+回溯的方式进行解题
const solve = async (row, col) => {
if (col >= 9) {
col = 0
row += 1
if (row >= 9) return true
}
if (sudoku[row][col] !== '.') {
return solve(row, col + 1)
}
for (let num = 1; num <= 9; num++) {
if (!isValid(row, col, num)) {
continue
}

sudoku[row][col] = num.toString()
this.setState({ sudoku }) // 填了格子之后,需要同步到 state

if (solve(row, col + 1)) {
return true
}

sudoku[row][col] = '.'
this.setState({ sudoku }) // 填了格子之后,需要同步到 state
}
return false
}
// 进行解题
solve(0, 0)
}

render() {
const { sudoku } = this.state
return (……)
}
}

对比之前的逻辑,这里只是在对数独的二维数组填空后,调用了 this.setStatesudoku 同步到了 state 中。


function solve(row, col) {   ……   sudoku[row][col] = num.toString()+  this.setState({ sudoku })	 ……   sudoku[row][col] = '.'+  this.setState({ sudoku }) // 填了格子之后,需要同步到 state}

在调用 solveSudoku 后,发现并没有出现动态的效果,而是直接一步到位的将结果同步到了视图中。



这是因为 setState 是一个伪异步调用,在一个事件任务中,所有的 setState 都会被合并成一次,需要看到动态的做题过程,我们需要将每一次 setState 操作放到该事件流之外,也就是放到 setTimeout 中。更多关于 setState 异步的问题,可以参考我之前的文章:React 中 setState 是一个宏任务还是微任务?


solveSudoku = async () => {
const { sudoku } = this.state
// 判断填入的数字是否有效,参考上面的代码,这里不再重复
const isValid = (row, col, num) => {
……
}
// 脱离事件流,调用 setState
const setSudoku = async (row, col, value) => {
sudoku[row][col] = value
return new Promise(resolve => {
setTimeout(() => {
this.setState({
sudoku
}, () => resolve())
})
})
}
// 递归+回溯的方式进行解题
const solve = async (row, col) => {
……
for (let num = 1; num <= 9; num++) {
if (!isValid(row, col, num)) {
continue
}

await setSudoku(row, col, num.toString())

if (await solve(row, col + 1)) {
return true
}

await setSudoku(row, col, '.')
}
return false
}
// 进行解题
solve(0, 0)
}

最后效果如下:



作者:Shenfq
链接:https://juejin.cn/post/7004616375591239711

收起阅读 »

JS中this的指向原理

前言 在JS中,每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。与声明的位置无关。 调用位置 理解调用位置:调用位置就是函数在执行时被调用的位置(而不是声明的位置)。 要找到函数的调用位置,最重要是找到函数的调用...
继续阅读 »

前言


在JS中,每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。与声明的位置无关。


调用位置



理解调用位置:调用位置就是函数在执行时被调用的位置(而不是声明的位置)。



要找到函数的调用位置,最重要是找到函数的调用栈(就是为了到达当前执行位置所调用的所有函数),而函数的调用位置就是当前所在栈顶的前一个位置。


举个栗子


function baz() { 
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域,浏览器下位window,node下为global
console.log( "baz" );
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log( "bar" );
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo
// 因此,当前调用位置在 bar 中
console.log( "foo" );
}
baz(); // <-- baz 的调用位置

this绑定规则


函数的this在js引擎执行时,会根据一些规则去绑定到上下文中。


默认绑定


默认绑定应用在最常用的函数调用类型:独立函数调用上。可以把这条规则看作是无法应用其他规则时的默认规则。


function foo() {
//默认规则下,this指向全局对象,即顶层作用域
console.log( this.a );
}

var a = 2;
foo()//2

怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看 foo()是如何调用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用
默认绑定,无法应用其他规则。



严格模式下,不能将全局对象用于默认绑定,因此 this 会绑定到undefined,在浏览器和node中是一样的。



这里有一个微妙但是非常重要的细节,虽然this的绑定规则完全取决于调用位置,但是只有 foo() 运行在非严格模式下时,默认绑定才能绑定到全局对象;在严格模式下调用
foo() 则不影响默认绑定:


function foo() { 
//在非严格模式下运行
console.log( this.a );
}
var a = 2;
(function(){
//在严格模式下调用
"use strict";
foo(); // 2
})();

以上代码混合使用了严格模式和非严格模式,因此foothis不受严格模式影响,但混合使用严格模式是不提倡的,幸运的是es6默认是严格模式


隐式绑定


当一个函数的引用被一个对象持有时(作为该对象的方法),那么该函数的this就绑定在了这个对象上。通常这在声明一个对象,并将一个已声明的函数作为该对象属性时触发。


function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2

obj对象声明时,foo作为obj的一个属性,因此其this被隐式绑定到了obj上,因为obj持有对foo的引用。



在对象属性引用链中,只有上一层或者说最后一层在调用位置中起作用。



举个栗子


function foo() { 
console.log( this.a );
}

// obj2.foo引用了foo函数
var obj2 = {
a: 42,
foo: foo
};

//obj1.obj2 引用了obj1对象
var obj1 = {
a: 2,
obj2: obj2
};

//但是foo中的this永远指向直接持有它的引用的那个对象,即obj2
obj1.obj2.foo(); // 42

一个函数的引用被一个对象持有,而这个对象的引用又被另一个对象持有,另一个对象的引用再被另一个对象持有...,这就像一条项链,但是不管层次有多深,这个函数的this永远指向直接持有它的引用的那个对象。


隐式丢失



一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。



function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!

var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

虽然barobj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此==此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定==。


再看一个栗子,发生在传入回调函数时


function foo() { 
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!,很明显,这是个默认绑定
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一
个例子一样。


如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一
样的,没有区别:


function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
setTimeout( obj.foo, 100 ); // "oops, global"

以上的栗子再次向我们证明了,函数this是在运行时绑定的,与声明位置无关。



除此之外,还有一种情



况 this 的行为会出乎我们意料:调用回调函数的函数可能会修改 this。在一些流行的
JavaScript 库中事件处理器常会把回调函数的 this 强制绑定到触发事件的 DOM 元素上。如onclick,addEventListener,会将this绑定在dom元素 上。


显式绑定


显示绑定就是利用js提供的一些内置函数,将this绑定到指定的上下文中。



具体点说,可以使用函数的 call(..) 和



apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们
并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自
己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。


function foo() { 
console.log( this.a );
}
var obj = {
a:2
};
//执行时,foo的this就是obj了
foo.call( obj ); // 2


如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)、new Boolean(..) 或者



new Number(..))。这通常被称为“装箱”。



显式绑定仍然无法解决我们之前提出的丢失绑定问题。



硬绑定


硬绑定是显式绑定的一个变种。


function foo() { 
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的 bar 不可能再修改它的 this
bar.call( window ); // 2

很好理解,就是在函数运行时再把这个函数绑定到我们制定的this上。



硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值



function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5


另一种使用方法是创建一个可以重复使用的辅助函数



function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

由于硬绑定是一种非常常用的模式,所以 ES5 提供了内置的方法 Function.prototype.bind,bind(..) 会返回一个硬编码的新函数,它会把你指定的参数设置为 this 的上下文并调用原始函数


function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

API中可选的调用“上下文”



第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一



个可选的参数,通常被称为"上下文"(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定。


function foo(el) { 
console.log( el, this.id );
}
var obj = {
id: "awesome"
};
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome

new 绑定


使用new来调用函数时(函数也是对象),或者说发生构造函数调用时,会自动执行下面的操作:



  1. 创建(或者说构造)一个全新的对象。

  2. 这个新对象会被执行原型链[[Prototype]] 连接。

  3. 这个新对象会绑定到函数调用的 this

  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。


function foo(a) { 
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2


使用 new 来调用foo(..)时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this上。ne是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。



规则的优先级


实际判断时,一个场景可能存在多个规则,因此判定时需要由高优先级往下判定。


可以按照下面的顺序来进行判断:




  1. 函数是否在new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。




  2. 函数是否通过callapply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是




指定的对象。



  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上


下文对象。



  1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定


到全局对象。


规则例外



在某些场景下this的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是默认绑定规则。



1. 将null或undefined作为this进行显式绑定


2. 赋值表达式的返回值


function foo() { 
console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是
p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。


3.软绑定


硬绑定很好地解决了隐式绑定可能会无意间将this绑定在顶级作用对象(严格模式下,为undefined)上的问题,但降低了其灵活性,我们要的结果是,保留其灵活性,既能绑定到指定的this上,但又不想让它默认绑定到全局对象上,解决方法就是软绑定。


通俗的说,就是有一个默认值,指定了绑定对象的话就绑定到指定的对象上,否则就绑定到默认对象。


//实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。
if (!Function.prototype.softBind) {
Function.prototype.softBind = function(obj) {
var fn = this;
// 捕获所有 curried 参数
var curried = [].slice.call( arguments, 1 );
var bound = function() {
return fn.apply(
(!this || this === (window || global)) ?
obj : this,
curried.concat.apply( curried, arguments )
);
};
bound.prototype = Object.create( fn.prototype );
return bound;
};
}

总结


如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。


找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。



  1. new 调用?绑定到新创建的对象。

  2. call 或者 apply(或者 bind)调用?绑定到指定的对象。

  3. 由上下文对象调用?绑定到那个上下文对象。

  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。


箭头函数不会以上四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论this绑定到什么)。这和我们创建一个变量来保存当前的this的效果是一样的。


链接:https://juejin.cn/post/7000756069244862477

收起阅读 »

一道看似简单的阿里前端算法题

题目描述 题目分析 我们以下面这个数组为例,我们首先要明白题目中的第2大的元素指的是4,第3大的元素指的是3,也就是说指的是去重后的数组中的排序。我们之所以要建立一个哈希表是因为我们需要知道第k大和第m大的元素总共出现了几次,因为最后需要进行求和。 [1...
继续阅读 »

题目描述


image.png


题目分析



我们以下面这个数组为例,我们首先要明白题目中的第2大的元素指的是4,第3大的元素指的是3,也就是说指的是去重后的数组中的排序。我们之所以要建立一个哈希表是因为我们需要知道第k大和第m大的元素总共出现了几次,因为最后需要进行求和。



[1, 2, 4, 4, 3, 5]

解题思路



本题博主采用的是哈希表 + 堆排序的方式来求解。



第一步:构建哈希表,键为目标元素,值为目标元素出现的次数


const map = new Map();
for (let v of arr) {
if (!map.get(v)) {
map.set(v,1);
} else {
map.set(v,map.get(v) + 1)
}
}

第二步:对数组去重


const singleNums = [...new Set(arr)]

第三步:构建大顶堆


// 堆的尺寸指的是去重后的数组
let heapSize = singleNums.length;
buildMaxHeap(singleNums, heapSize);
function buildMaxHeap(arr, heapSize) {
// 从最后一个叶子节点开始进行堆化
for (let i = Math.floor(heapSize / 2) - 1; i >= 0; i--) {
// 进行堆化
maxHeapify(arr, i, heapSize);
}
}
function maxHeapify(arr, i, heapSize) {
// 首先假定第i个是最大的
let max = i;
let leftChild = 2 * i + 1;
let rightChild = 2 * i + 2;
// 如果下标不越界,并且左孩子的比最大值大则更新最大值
if (leftChild < heapSize && arr[leftChild] > arr[max]) {
max = leftChild;
}
if (rightChild < heapSize && arr[rightChild] > arr[max]) {
max = rightChild;
}
if (max !== i) {
swap(arr, i, max);
// 上来的元素的位置往下要接着堆化
maxHeapify(arr, max, heapSize);
}
}
// 交换数组中两个元素
function swap(nums, a, b) {
let temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}

第四步:求第k大的元素和第m大元素


function target(arr, x) {
for (let i = 0; i < x - 1; i++) {
// 交换不需要进行堆化的元素
if (i === min - 1) result.push(arr[0]);
swap(arr, 0, arr.length - 1 - i);
arr
heapSize--;
maxHeapify(arr, 0, heapSize)
}
}
target(singleNums, max)
result.push(singleNums[0]);

第五步:根据哈希表出现的次数计算并返回结果


return result.reduce((pre,cur) => pre + cur * map.get(cur),0)

AC代码


/*
* @Author: FaithPassion
* @Date: 2021-07-09 10:06:00
* @LastEditTime: 2021-08-28 11:09:30
* @Description: 找出数组中第k大和第m大的数字相加之和
* let arr = [1,2,4,4,3,5], k = 2, m = 4
* findTopSum(arr, k, m); // 第2大的数是4,出现2次,第4大的是2,出现1次,所以结果为10
*/

/**
* @description: 采用堆排序求解
* @param {*} arr 接收一个未排序的数组
* @param {*} k 数组中第k大的元素
* @param {*} m 数组中第m大的元素
* @return {*} 返回数组中第k大和第m大的数字相加之和
*/
function findTopSum(arr, k, m) {


function buildMaxHeap(arr, heapSize) {
// 从最后一个叶子节点开始进行堆化
for (let i = Math.floor(heapSize / 2) - 1; i >= 0; i--) {
// 进行堆化
maxHeapify(arr, i, heapSize);
}
}
// 最大堆化函数
function maxHeapify(arr, i, heapSize) {
// 首先假定第i个是最大的
let max = i;
let leftChild = 2 * i + 1;
let rightChild = 2 * i + 2;
// 如果下标不越界,并且左孩子的比最大值大则更新最大值
if (leftChild < heapSize && arr[leftChild] > arr[max]) {
max = leftChild;
}
if (rightChild < heapSize && arr[rightChild] > arr[max]) {
max = rightChild;
}
if (max !== i) {
swap(arr, i, max);
// 上来的元素的位置往下要接着堆化
maxHeapify(arr, max, heapSize);
}
}

// 交换数组中两个元素
function swap(nums, a, b) {
let temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}
let result = []
// k和m中较大的
let max = Math.max(k, m);
// k和m中较小的
let min = Math.min(k, m);
const map = new Map();

for (let v of arr) {
if (!map.get(v)) {
map.set(v,1);
} else {
map.set(v,map.get(v) + 1)
}
}
// 求第x大的元素
function target(arr, x) {
for (let i = 0; i < x - 1; i++) {
// 交换不需要进行堆化的元素
if (i === min - 1) result.push(arr[0]);
swap(arr, 0, arr.length - 1 - i);
arr
heapSize--;
maxHeapify(arr, 0, heapSize)
}
}
const singleNums = [...new Set(arr)]
// 堆的大小
let heapSize = singleNums.length;
// 构建大顶堆
buildMaxHeap(singleNums, heapSize);

target(singleNums, max)
result.push(singleNums[0]);
return result.reduce((pre,cur) => pre + cur * map.get(cur),0)

}

findTopSum([1, 2, 4, 4, 3, 5], 2, 4)

题目反思



  • 学会通过堆排序的方式来求解Top K问题。

  • 学会对数组进行去重。

  • 学会使用reduce Api。


链接:https://juejin.cn/post/7001397295912583198

收起阅读 »

cookie和session、localStorage和sessionStorage、IndexedDB、JWT汇总

cookie和session HTTP协议是一种无状态协议,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;Session和Cookie的主要目的就是为了弥补HTTP的无状态特性。 cookie是什么? cookie是...
继续阅读 »

cookie和session


HTTP协议是一种无状态协议,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;SessionCookie的主要目的就是为了弥补HTTP的无状态特性。


cookie是什么?


cookie是服务器发送到Web浏览器的一小块数据,服务器发送到浏览器的Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器,用于判断请求是否来自同一个浏览器,例如用户保持登录状态。


cookie的属性




  • name:表示cookie的名称




  • valuecookie对应的值。




  • domain:该字段为可以访问此cookie的域名,即cookie在哪个域有效




  • path: cookie的有效路径。DomainPath标识共同定义了Cookie的作用域:即 Cookie应该发送给哪些URL




  • sizecookie的大小(不超过4kb)




  • expires/Max-Age:有效期。expirescookie被删除的时间戳;Max-Age有效期的时间戳(服务器返回的时间,和客户端可能存在误差),默认为-1,页面关闭立即失效。




  • HttpOnly: 设置为true时不允许通过脚本document.cookie去更改cookie值,也不可获取,能有效的防止xss攻击。但发送请求仍会携带cookie。




  • secure: 标记为SecureCookie只应通过被HTTPS协议加密过的请求发送给服务端,可以保护Cookie在浏览器和Web服务器间的传输过程中不被窃取和篡改。




  • SameSite: 该属性可以让Cookie在跨站请求时不会被发送,用来防止CSRF攻击和用户追踪




    • Strict:完全禁止第三方cookie,跨站点时,任何情况下都不会发送cookie。也就是说,只有当前网页的URL与请求目标一致,才会带上cookie




    • Lax: 大多数情况不发送第三方cookie,但导航到目标网址的get请求(链接,预加载请求,GET表单)除外。




    • None: 网站可以选择显式关闭SameSite属性,将其设为None。不过,前提是必须同时设置Secure属性(Cookie 只能通过 HTTPS 协议发送),否则无效。


      浏览器查看cookie






cookie安全


安全问题可以看我总结的这篇:前端安全—常见的攻击方式及防御方法


session是什么?


Session是保存在服务器记录客户状态的机制。客户端浏览器访问服务器的时候,服务器会为这次请求开辟一块内存空间,这个对象便是Session 对象,存储结构为 ConcurrentHashMap。Session 弥补了 HTTP 无状态特性,服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。


session的创建



  • 用户向服务器发送用户名和密码

  • 服务器通过验证后,在当前对话(session)里面保存相关数据(比如用户角色,登录时间等)

  • 服务器向用户返回一个session_id,写入要不干湖的cookie

  • 用户随后的每一次请求都会通过cookie,将session_id传回服务器

  • 服务器收到session_id,找到前期保存的数据,由此得知用户的身份。


cookie和session的区别



  • 储存方式:cookie是服务端产生,储存在客户端;session储存在服务端

  • 储存大小:单个cookie不超过4kb;session没有大小限制

  • 安全性:session更安全

  • 储存内容:cookie只能保存字符串,以文本的方式;session通过类似hashtable的数据结构来储存,能支持任何类型的对象

  • 使用方式

    • cookie机制:如果不在浏览器设置过期时间,cookie被保存在内存中,cookie生命周期随浏览器的关闭而结束。如果在浏览器中设置了cookie的过期时间,cookie被保存在硬盘中,关闭浏览器后,cookie数据仍然存在,知道过期时间才消失。

    • session机制:当服务器收到请求需要创建session对象时,首先会检查客户端请求中是否包含session_id,如果有,服务器将根据id返回对应的session对象。如果没有session_id,服务器会创建新的session对象,并把session_id在本次响应中返回给客户端。




cookie、localStorage和sessionStorage


HTML5提供了两种在客户端存储数据的新方法:localStorage和sessionStorage,挂载在window对象下。


webStorage是本地存储,数据不是由服务器请求传递的。从而它可以存储大量的数据,而不影响网站的性能。


Web Storage的目的是为了克服由cookie带来的一些限制,当数据需要被严格控制在客户端上时,无须持续地将数据发回服务器。比如客户端需要保存的一些用户行为或数据,或从接口获取的一些短期内不会更新的数据,我们就可以利用Web Storage来存储。


localStorage


生命周期是永久性的。localStorage存储的数据,以“键值对”的形式存在。即使关闭浏览器,也不会让数据消失,除非主动的去删除数据。如果想设置失效时间,需自行封装。localStorage 在所有同源窗口中都是共享的。


sessionStorage


sessionStorage保存的数据用于浏览器的一次会话,当会话结束(关闭浏览器或者页面),数据被清空;SessionStorage的属性和方法与LocalStorage完全一样。


sessionStorage特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享
localStorage 在所有同源窗口中都是共享的; cookie也是在所有同源窗口中都是共享的。除了保存期限的长短不同,


cookie、localStorage和sessionStorage的区别



  • 共同点:都是保存在浏览器端,且都遵循同源策略。

  • 不同点:在于生命周期与作用域等不同


image.png


IndexedDB


IndexedDB是一个运行在浏览器上的非关系型数据库,储存空间大,用于客户端存储大量结构化数据(包括文件和blobs) 。可以存字符串,也可以存二进制数据,数据以"键值对"的形式保存,不能有重复,否则会报错。除非被清理,否则一直存在。



  • 键值对储存

  • 异步

  • 支持事务

  • 同源策略

  • 支持二进制储存


JWT(JSON Web Token)


互联网服务离不开用户认证。一般流程看上面session的创建


什么是Token?




  • Token的定义


    Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。token其实说的更通俗点可以叫暗号,在一些数据传输之前,要先进行暗号的核对,不同的暗号被授权不同的数据操作。




  • 简单 token 的组成



    • uid(用户唯一的身份标识)

    • time(当前时间戳)

    • sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)




  • token 的身份验证流程



    • 客户端使用用户名跟密码请求登录

    • 服务端收到请求,去验证用户名与密码

    • 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage

    • 客户端每次向服务端请求资源的时候需要带着服务端签发的 token

    • 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
      image.png




  • 使用Token的目的


    Token的目的是为了减少频繁的查询数据库,减轻服务器的压力。基于Token用户认证是一种服务器无状态的认证方式,服务器不存放数据,所有数据都保存在客户端,每次请求都发回服务器,用解析token的时间来换取session的储存空间,从而减轻服务器的压力,减少频繁的查询数据库。token 完全由应用管理,所以它可以避开同源策略。




什么是 JWT?


JWT的原理


JWT的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。


JWT的数据结构




  • Header(头部)


    Header部分是一个JSON对象,描述JWT的元数据,使用Base64编码转成字符串。




  • Payload(负载)


    Payload是一个JSON对象,用来存放实际需要传递的数据,使用Base64编码转成字符串。



    • iss (issuer):签发人

    • exp (expiration time):过期时间

    • sub (subject):主题

    • aud (audience):受众

    • nbf (Not Before):生效时间

    • iat (Issued At):签发时间

    • jti (JWT ID):编号




  • Signature(签名)


    Signature是对前两部分的签名,防止数据篡改。首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是 HMAC SHA256)产生签名。用"点"(.)分隔拼接成字符串后返回给用户。




JWT的特点



  • 默认不加密,也可以加密

  • 可以用于认证,也可以用于交换信息。降低服务器查询数据库的次数,减小服务器压力

  • 服务器无状态,因此无法在使用过程中废除某个Token,或者更改Token的权限。即一旦JWT签发了,在到期之前始终有效,除非服务器部署额外的逻辑

  • JWT本身包含了认证信息,为保证安全性,有效期应设置得比较短

  • 为了减少盗用,JWT应使用HTTPS协议传输

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

Flutter 系列 - 环境搭建

Flutter 作为火热的跨端工具包,在 github 上超过 120k 的关注量,可见一斑。 基于目前本人正在学习 Flutter 的路上,会将整个学习的过程记录下来。 本博文主要讲解环境的搭建,先把项目搭建好,跑通 demo 才有玩下去的必要和成就感,你说...
继续阅读 »

Flutter 作为火热的跨端工具包,在 github 上超过 120k 的关注量,可见一斑。


基于目前本人正在学习 Flutter 的路上,会将整个学习的过程记录下来。


本博文主要讲解环境的搭建,先把项目搭建好,跑通 demo 才有玩下去的必要和成就感,你说是吧?


本人开发环境




  • macOS Big Sur 版本 11.2 芯片 Apple M1




  • 磁盘空间:> 2.8 GB (要求的最小的空间)




  • $SHELL




echo $SHELL
/bin/bash


⚠️ 之后出现并解决的问题都是基于本人的环境



安装 Flutter


通过官网下载安装包。


将安装包放到自己想存放的地方。这里,我放在 文稿 -> sdk 方便管理,然后解压下载包。


配置 flutterPATH 环境变量,格式如下:


export PATH=$PATH:${pwd}/flutter/bin

export PATH=${pwd}/flutter/bin:$PATH

这里我需要编辑 ~/.bash_profile 文件,添加下面这行内容:


export PATH=/Users/jimmy/Documents/sdk/flutter/bin:$PATH

安装 IDE


作为一个前端开发者,比较偏向 VS code,直接安装其稳定版即可。


因为需要调试安卓平台,还需要安装编辑器 Android StudioAndroid StudioFlutter 提供了一个完整的集成开发环境。


不管 VS code 还是 Android Studio 都需要安装 Flutter 插件。



Android Studio 我还是安装在 文稿 -> sdk



注意安装android studio的路径,也许会报sdk的错误。类似错误 ❌


# [Flutter-Unable to find bundled Java version(flutter doctor), after updated android studio Arctic Fox(2020.3.1) on M1 Apple Silicon](https://stackoverflow.com/questions/68569430/flutter-unable-to-find-bundled-java-versionflutter-doctor-after-updated-andro)

对应的解决方法:flutter-unable-to-find-bundled-java-versionflutter-doctor-after-updated-andro


验证


之后,运行 flutter doctor 或者 flutter doctor -v 来检查是否安装了必要的安装包。


下面是自己搭建环境的情况flutter doctor -v


[✓] Flutter (Channel stable, 2.2.3, on macOS 11.2 20D64 darwin-arm, locale

    zh-Hans-CN)

    • Flutter version 2.2.3 at /Users/jimmy/Documents/sdk/flutter

    • Framework revision f4abaa0735 (9 weeks ago), 2021-07-01 12:46:11 -0700

    • Engine revision 241c87ad80

    • Dart version 2.13.4

[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)

    • Android SDK at /Users/jimmy/Library/Android/sdk

    • Platform android-31, build-tools 31.0.0

    • Java binary at: /Users/jimmy/Documents/sdk/Android

      Studio.app/Contents/jre/jdk/Contents/Home/bin/java

    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS

    • Xcode at /Applications/Xcode.app/Contents/Developer

    • Xcode 12.5.1, Build version 12E507

    • CocoaPods version 1.10.2

[✓] Chrome - develop for the web

    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2020.3)

    • Android Studio at /Users/jimmy/Documents/sdk/Android Studio.app/Contents # 留意 Android Studio 路径

    • Flutter plugin can be installed from:

      🔨 https://plugins.jetbrains.com/plugin/9212-flutter

    • Dart plugin can be installed from:

      🔨 https://plugins.jetbrains.com/plugin/6351-dart

    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

[✓] VS Code (version 1.59.1)

    • VS Code at /Applications/Visual Studio Code.app/Contents

    • Flutter extension version 3.25.0

[✓] Connected device (1 available)

    • Chrome (web) • chrome • web-javascript • Google Chrome 92.0.4515.159

• No issues found!

出现 No issues found! 的提示,说明你捣鼓成功了~


运行 Demo


我们在 VS code 上新建一个项目:


查看 -> 命令面板 -> Flutter: New Application Project

初始化项目之后,运行 -> 启动调试,然后按照下图运行应用:


vscode_demo.png


如果选中 Chrome web 会直接调起你安装好的谷歌浏览器。


如果选中 Start iOS Simulator 会调起 xCode 的模拟器。


如果选中 Start Pixel 2 API 31 会调起 Android Studio 的模拟器。



当然你得在 Android Studio 上预设手机型号是哪个,不然初次在 VS code 上调不起来。



effect_result.png


【完】~ 下次可以更加愉快玩耍了


作者:Jimmy
链接:https://juejin.cn/post/7002401225270362143

收起阅读 »

面试官问:我们聊聊原型和继承?我:这里边水深,我把握不住。。。

前言 原型和继承一直是js中非常基础和重要的部分,我们来看看日常开发中经常会用到的原型和继承。 class Person extends React.Component { componentDidMount() {} render() {...
继续阅读 »

前言


原型和继承一直是js中非常基础和重要的部分,我们来看看日常开发中经常会用到的原型和继承。


  class Person extends React.Component {
componentDidMount() {}
render() {}
}

这行代码代码大家都很熟悉,Person通过extends关键字继承了React的特性,componentDidMount和render在class类中的是一个普通定义好的函数。特殊的是,它们也是在Component中提前定义好的钩子函数,用于在某个固定的时机触发。


看完了基本的使用,下面我们一起来深入探索下class和extends。


class只是一个语法糖


class是ES6中引入的概念,我们也称它为类。class的用途是作为对象模版,用来创建对象。但需要明确的是,class只是一个语法糖,它内部实现上还是和ES5创建对象是相同的。由于class的写法更加符合面向对象编程的习惯,所以被推广使用,逐步替代了ES5中的对象创建。


   console.log(typeof React.Component); // function

ES5是通过构造函数函数来创建对象,React.Component的类型同样是一个function,所以想要完全搞清楚对象和原型,还是要去学习下ES5中对象的创建。后面有一篇文章是关于ES5中对象的创建和继承,有需要的大家可以自己去看,这里就不展开说了。


class与构造函数的对比


class的本质还是构造函数,但是与构造函数又有些许使用上的不同。


相同点


定义方式


class与构造函数都有两种定义方式,声明和表达式,这两种写法完全等价。且名称都必须大写,以区别于它创建的实例.


  // 函数声明
function Person() {};
// 函数表达式
let Person = function () {};

// 类声明
class Person {}
// 类表达式
let Person = class {};

// 创建实例(函数和类)
let person = new Person();

通过name访问原表达式。


表达式赋值时,可通过name访问原表达式。


  let Student = function Person() {};
// 通过name属性获取类表达式的名称
console.log(Student.name); // Person

let Student = class Person {};
// 通过name属性获取类表达式的名称
console.log(Student.name); // Person

表达式外部,无法访问原表达式


  let Student = function Person() {};
// 外部无法访问类表达式
console.log(Person); // Person is not defined

let Student = class Person {};
// 外部无法访问类表达式
console.log(Person); // Person is not defined

不同点


类不可以变量提升


函数可以变量提升,而类不可以。


  // 声明
console.log(Person); // 报错
console.log(Student); // ƒ Student() {}

class Person {}
function Student() {}

console.log(Person); // class Person {}
console.log(Student); // ƒ Student() {}

// 表达式定义
console.log(Person); // undefined
console.log(Student); // undefined

var Person = class {};
var Student = function () {};

console.log(Person); // class {}
console.log(Student); // ƒ () {}

类受块级作用域限制


  {
class Person {}
function Student() {}
}
console.log(Person); // 报错,Person is not defined
console.log(Student); // ƒ Student() {}

类必须通过new来调用


类必须通过new来调用,否则会报错。构造函数不使用new调用也可以,就会把全局的this作为内部对象。


  function Person() {}
class Animal {}

let p = Person(); // Person内部this指向window
let a = Animal(); // TypeError: class constructor Animal cannot be invoked without 'new'

class的实例化


class实例化的时候,会调用class中的constructor函数。constructor是类的默认方法,如果没有定义,constructor方法会被默认添加。


  class Bar {}
等同于
class Bar {
constructor() {}
}

constructor方法会默认返回一个实例对象(即this),也可以完全返回另一个对象。但返回另一个对象,会导致返回的对象不是Bar的实例(因为它的原型指针没有被更改,具体的原因后面分析)。


  // 返回一个对象
class Bar {
constructor() {
return {
name: 1,
};
}
}

let bar = new Bar();
console.log(bar); // {name: 1}
console.log(bar instanceof Bar); // false

// 返回默认对象
class Bar {
constructor() {}
}

let bar = new Bar();
console.log(bar); // Bar {}
console.log(bar instanceof Bar); // true

前面说到了,如果手动返回了一个对象,会导致返回的对象不是class的实例。那么我们看看生成一个对象的过程是什么样的,为什么手动返回一个对象,这个对象就不是类的实例了。


实例化的过程:



  1. 在内存中创建一个对象

  2. 新对象的__proto__赋值为构造函数的prototype

  3. 构造函数内部的this指向新对象

  4. 执行构造函数内部代码(给新对象添加属性)

  5. 如果构造函数返回非空对象,则返回该对象。否则,则返回新创建的对象。


通过上面的第二步可以看到,原型的赋值作用在新对象上,只有新对象与原型有关系,人为的在constructor返回的对象,与原型毫无关联,自然不是class的实例。


数据共享


定义在constructor中的属性,是每个实例独有的,不会在原型上共享。


  class Person {
constructor() {
this.name = new String("Jack");
// 定义在constructor中的函数是不被原型共享的
this.sayName = () => console.log(this.name);
this.nicknames = ["Jake", "J-Dog"];
}
}
let p1 = new Person();
let p2 = new Person();
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); //false

实例化的时候相当于复制了一个新函数


  class Person {
constructor() {
this.name = new String("Jack");
this.sayName = new Function();
this.nicknames = new Array(["Jake", "J-Dog"]);
}
}

如果想在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。


  class Person {
constructor() {
// 定义在constructor中的方法是属于每个实例的
this.locate = () => console.log("instance");
}
// 定义在类块中的方法是所有实例共享的
test() {
console.log("test");
}
}

let person1 = new Person();
let person2 = new Person();
console.log(person1.locate === person2.locate); // false
console.log(person1.test === person2.test); // true
// 实例中有该属性
console.log(person1.hasOwnProperty("locate")); // true
// 实例中没有该属性
console.log(person1.hasOwnProperty("test")); // false

类的静态方法


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


作用


在日常开发中,我会通过类的静态方法去处理一些名称管理和接口,如下面所示:


  class Home {
static getData() {
return [];
}
}
console.log(Home.getData());

静态方法中的this至与类有关


需要注意的是,静态方法不要求存在类的实例,所以this引用类自身,而不是实例。


  class Bar {
static test() {
console.log(this);
}
}
// 类可以直接调用静态方法
Bar.test(); // class Bar {}

var bar = new Bar();
// 实例与静态方法无关
bar.test(); // 报错,bar.test is not a function

静态方法也可被继承


  class Bar {
static test() {
console.log(this);
}
}

class Foo extends Bar {}
Foo.test(); // class Foo extends Bar {}

静态方法也是可以从super对象上调用的


  class Bar {
static test() {
return "test1";
}
}

class Foo extends Bar {
static test2() {
return super.test() + " test2";
}
}
console.log(Foo.test2()); // test1 test2

类中this指向



  1. this存在于类的构造函数中,this指向实例

  2. this存在于类的原型对象上,this指向类的原型

  3. this存在于类的静态方法中,this指向当前类


类的继承


类的继承使用的是新语法,但它的本质依旧是原型链。


ES6中,使用extends关键字,就可以继承任何拥有constructor和原型的对象。所以它不仅可以继承一个类,还可以继承普通的构造函数。


  class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true
function Person() {}
// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true

super


派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,在类构造函数中使用 super 可以调用父类构造函数。


提炼几个要点:



  1. super关键字只能在派生类的构造函数和静态方法上使用,如下所示,Vehicle不是派生类


  class Vehicle {
constructor() {
// SyntaxError: 'super' keyword unexpected
super();
}
}


  1. 在类构造函数中,不能在调用 super()之前引用 this。


  class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this);
}
}
new Bus();
// Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor


  1. 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。


  class Vehicle {}

class Car extends Vehicle {}
console.log(new Car()); // Car {}

class Bus extends Vehicle {
constructor() {
super();
}
}
console.log(new Bus()); // Bus {}

class Van extends Vehicle {
constructor() {
return {};
}
}
console.log(new Van()); // {}

class Test extends Vehicle {
constructor() {}
}
console.log(new Test());
// Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

链接:https://juejin.cn/post/7001502812261580836

收起阅读 »

二进制都不了解?也配做什么程序员???

最近在学习一些计算机专业课,学习的过程中二进制的基础是必须要有的,不管是计算机网络,还是组成原理,还是操作系统,都是以二进制为基础的,所以本文总结一些二进制基础。今天,2021年8月30日,写下第一版,后面会陆续增加一些内容,增加一些应用便于更多人理解。 本文...
继续阅读 »

最近在学习一些计算机专业课,学习的过程中二进制的基础是必须要有的,不管是计算机网络,还是组成原理,还是操作系统,都是以二进制为基础的,所以本文总结一些二进制基础。今天,2021年8月30日,写下第一版,后面会陆续增加一些内容,增加一些应用便于更多人理解。


本文目标:



  • 理解的概念

  • 熟记常见的2的次幂,例如128是2的几次幂(2的几次幂就需要多少个二进制位)

  • 理解字节,对于1个字节能存储多少数据做到理性认知

  • 熟记16进制0-16,对应的2进制


带着问题阅读:



  1. 一个ip地址 192.168.1.1共有几位

  2. CSS中的颜色表示 #ffaaff,需要占用多大的存储空间存储

  3. 为什么计算机专业书籍中,表示内存地址大部分都是用16进制表示的,而不是10进制或者2进制

  4. javascirpt中的数字类型在计算机内存储为多少Byte

  5. 宽带的带宽是200M,为什么下载的时候怎么都达不到200M呢


如果所有的问题,你都会,就不用读了,直接退出。


进制


10进制,一位数可以是0-9,共10种可能,如果要表示第11种可能,就要进位。


类比一下,2进制,一位数只能是0或1,有2种可能。


16进制,一位数可以是0-15,有16种可能


10进制的进位规则如下:满10进一位


0  10  20
1 11
2 12
3 13
4 14
5 15
6 16
7 17
8 18
9 19

2进制的进位规则如下:满两位进一位,10进制的0是2进制0,10进制的1是2进制的1,如果要表示10进制的2,就要用两位2进制数,10


0  10  100  1000
1 11 101 1001
110 1010
111 1011
1100
1101
1110
1111


16进制的规则,满16进一位(a表示10进制的10,b:11,c:12...)


0  10(10进制的16)
1 11(10进制的17)
2 12
3
4
5
6
7
8
9
a
b
c
d
e
f

2进制与16进制


一位二进制数,称为1bit。


image.png


1位二进制数,也就是1bit,有2种可能,可以表示数0,1


2位二进制数,2bit,有4种可能(2x2),可以表示数0,1,2,3


3位二进制数,3bit,有8种可能(2x2x2),可以表示数0,1,2,3,4,5,6,7


...


n位二进制数,有 2^n -1 种可能。


有一些常用的2的次幂需要记住,必须记在脑子里,例如看到10进制的128,就想起来是2的7次方,就想起来有7位,0000000


image.png


2进制是计算机用的,人用起来写起来并不方便,所以就有了16进制。


一个16进制,可以表示16种可能性,也就是2的4次方,就是4位2进制数,就是4bit


举个栗子,


16进制是f,表示为2进制就是1111


16进制的ff,表示为2进制就是1111 1111


规律就是,一位16进制,可以用4位2进制来表示。2位16进制,用8位2进制数来表示。


那么16进制的ffffff表示为2进制是多少位呢


字节



字节(英语:Byte),通常用作计算机信息计量单位,不分数据类型。是通信和数据存储的概念。



一个字节能存储8位2进制数据(这个是规范,需要刻在DNA里面)


1Byte =8bit

2^8是256,1个字节能表示的数就是0-255,共256种可能性。


1位16进制数能表示为4位2进制,所以一个字节能表示2个16进制。


总结如下:


1Byte
8bit 1111 1111
2个16进制位 f f

KB,MB,GB,Kb,Mb,Gb


KB(Kilobyte) 千字节,国际单位法一般以1000来定义千,例如1千米=1000米,但是在信息领域,尤其是表示主存储容量时,千字节一般表示1024(2^10)个字节


1KB = 1024 B   2^10 Byte
1MB = 1024 KB 2^20 Byte
1GB = 1024 MB 2^30 Byte

Kb与KB是不同的,Kb是 Kilobit,


1Kb = 1024bit

我们的宽带的带宽是200M每秒,其实是200Mb/s,但是文件是以Byte为单位的,而不是bit,所以需要换算一下


200Mb / 8 = 25 MB

其实能够达到的最高下载速度是25MB/s


简单应用


一个ip地址 192.168.1.1,共32位,why?


因为ip地址是10进制表示的,ip地址用.分开,每一段的范围是0-255,就是2^8,共8位,4*8=32,一共32位。


CSS中的颜色表示 #ffaaff,需要占用多大的存储空间存储


1个Byte存储8位2进制,


1个16进制相当于4位2进制,


所以1个Byte存储2位16进制


#ffaaff存储需要 3Byte


本文就先到这里,后续要有一些内容需要补充,比如按位&``|``!左移右移以及更多的应用(在内存层面的应用,在计算机网络中的应用,在字符编码中的应用等)等我学会了,整理了,补充在这篇文章的后面。


有问题请在评论区提出。


链接:https://juejin.cn/post/7002088412903637022

收起阅读 »

一个"剑气"加载?️

🙇 前言 我们在网页加载的时候总会加上一些过渡效果来引导用户,减少白屏时间,而加载的效果可以用svg也可以使用一些我们封装好的组件,今天就来分享一种"剑气"加载效果。 相信大家看封面都知道效果了,那我们就直接开干吧。 🏋️‍♂️ToDoList 剑气...
继续阅读 »

🙇 前言



  • 我们在网页加载的时候总会加上一些过渡效果来引导用户,减少白屏时间,而加载的效果可以用svg也可以使用一些我们封装好的组件,今天就来分享一种"剑气"加载效果。

  • 相信大家看封面都知道效果了,那我们就直接开干吧。


src=http___image.17173.com_bbs_v1_2012_12_01_1354372326576.gif&refer=http___image.17173.gif


🏋️‍♂️ToDoList



  • 剑气形状

  • 剑气转动

  • 组合剑气


🚴 Just Do It



  • 其实做一个这样的效果仔细看就是有三个类似圆环状的元素进行循环转动,我们只需要拆解出一个圆环来做效果即可,最后再将三个圆环组合起来。


剑气形状



  • 仔细看一道剑气,它的形状是不是很像一把圆圆的镰刀分成一半,而这个镰刀我们可以通过边框和圆角来做。

  • 首先准备一个剑气雏形。


  <div class="sword">
<span>
</div>


  • 我们只需要对一个圆加上一个方向的边框就可以做成半圆的形状,这样类似剑气的半圆环形状就完成了🌪️。


.sword {
position: relative;
margin: 200px auto;
width: 64px;
height: 64px;
border-radius: 50%;
}
.sword span{
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
border-radius: 50%;
}
.sword :first-child{
left: 0%;
top: 0%;
border-bottom: 3px solid #EFEFFA;
}

image.png


剑气转动



  • 因为我们需要剑气一直不停的循环转动,所以我们可以借助cssanimation动画属性就可以自己给它添加一个动画了。

  • animation属性是一个简写属性,可以用于设置以下动画属性分别是:

    • animation-name:指定要绑定到选择器的关键帧的名称

    • animation-duration:动画指定需要多少秒或毫秒完成

    • animation-timing-function:设置动画将如何完成一个周期

    • animation-delay:设置动画在启动前的延迟间隔

    • animation-iteration-count:定义动画的播放次数

    • animation-direction:指定是否应该轮流反向播放动画

    • animation-fill-mode:规定当动画不播放时,要应用到元素的样式

    • animation-play-state:指定动画是否正在运行或已暂停



  • 更多的动画学习可以参考MDN


...
.sword :first-child{
...
animation: sword-one 1s linear infinite;
...
}
@keyframes sword-one {
0% {
transform: rotateZ(0deg);
}
100% {
transform: rotateZ(360deg);
}
}
...


  • 我们可以给定一个不断绕z0deg360deg转动的动画,设定为一秒完成一次一直无限循环,我们来看看效果:


剑气1.gif



  • 接下来让这个半圆弧分别绕x轴和y轴也转动一定角度即可完成一个剑气的转动。


...
@keyframes sword-one {
0% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg);
}
}
...


  • 我们来看看完成后的效果:


剑气2.gif


组合剑气



  • 最后我们只需要再制作两个剑气在组装起来就好了。


<div class="sword">
<span></span>
<span></span>
<span></span>
</div>


  • 给新添的两个span添加动画和样式。


...
.sword :nth-child(2){
right: 0%;
top: 0%;
animation: sword-two 1s linear infinite;
border-right: 3px solid #EFEFFA;
}

.sword :last-child{
right: 0%;
bottom: 0%;
animation: sword-three 1s linear infinite;
border-top: 3px solid #EFEFFA;
}

@keyframes sword-two {
0% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg);
}
100% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg);
}
}

@keyframes sword-three {
0% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg);
}
}
...


  • 这样我们的剑气加载效果就制作好了,以上就是全部代码了,喜欢的可以拿去用哟。

  • 我们来看看最终的效果吧~


剑气3.gif



链接:https://juejin.cn/post/7001779766852321287

收起阅读 »

学会这个,我的http加载速度更快了!

1. 前言 说到 HTTP 怎么提升网络加载速度,就不得不聊一聊 HTTP/2 了。 HTTP/2 的主要目标是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。 HTTP/2 ...
继续阅读 »

1. 前言


说到 HTTP 怎么提升网络加载速度,就不得不聊一聊 HTTP/2 了。


HTTP/2 的主要目标是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。


HTTP/2 没有改动 HTTP 的应用语义。 HTTP 方法、状态代码、URI 和标头字段等核心概念一如往常。 不过,HTTP/2 修改了数据格式化(分帧)以及在客户端与服务器间传输的方式。这两点统帅全局,通过新的分帧层向我们的应用隐藏了所有复杂性。 因此,所有现有的应用都可以不必修改而在新协议下运行。


2. 二进制分帧层


HTTP/2 所有性能增强的核心在于新的二进制分帧层,它定义了如何封装 HTTP 消息并在客户端与服务器之间传输。


image.png


这里所谓的“层”,指的是位于套接字接口与应用可见的高级 HTTP API 之间一个经过优化的新编码机制: HTTP 的语义(包括各种动词、方法、标头)都不受影响,不同的是传输期间对它们的编码方式变了。 HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式对它们编码。


3. 数据流、消息和帧


新的二进制分帧机制改变了客户端与服务器之间交换数据的方式。 为了说明这个过程,我们需要了解 HTTP/2 的三个概念:



  • 数据流: 已建立的连接内的双向字节流,可以承载一条或多条消息。

  • 消息: 与逻辑请求或响应消息对应的完整的一系列帧。

  • : HTTP/2 通信的最小单位,每个帧都包含帧头,至少也会标识出当前帧所属的数据流。


这些概念的关系总结如下:



  • 所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。

  • 每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。

  • 每条消息都是一条逻辑 HTTP 消息(例如请求或响应),包含一个或多个帧。

  • 帧是最小的通信单位,承载着特定类型的数据,例如 HTTP 标头、消息负载等等。 来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。


image.png


简言之,HTTP/2 将 HTTP 协议通信分解为二进制编码帧的交换,这些帧对应着特定数据流中的消息。所有这些都在一个 TCP 连接内复用。 这是 HTTP/2 协议所有其他功能和性能优化的基础。


4. 请求与响应复用


在 HTTP/1.x 中,如果客户端要想发起多个并行请求以提升性能,则必须使用多个 TCP 连接(请参阅使用多个 TCP 连接)。 这是 HTTP/1.x 交付模型的直接结果,该模型可以保证每个连接每次只交付一个响应(响应排队)。 更糟糕的是,这种模型也会导致队首阻塞,从而造成底层 TCP 连接的效率低下。


HTTP/2 中新的二进制分帧层突破了这些限制,实现了完整的请求和响应复用: 客户端和服务器可以将 HTTP 消息分解为互不依赖的帧,然后交错发送,最后再在另一端把它们重新组装起来。


image.png


快照捕捉了同一个连接内并行的多个数据流。 客户端正在向服务器传输一个 DATA 帧(数据流 5),与此同时,服务器正向客户端交错发送数据流 1 和数据流 3 的一系列帧。因此,一个连接上同时有三个并行数据流。


将 HTTP 消息分解为独立的帧,交错发送,然后在另一端重新组装是 HTTP 2 最重要的一项增强。事实上,这个机制会在整个网络技术栈中引发一系列连锁反应,从而带来巨大的性能提升,让我们可以:



  • 并行交错地发送多个请求,请求之间互不影响。

  • 并行交错地发送多个响应,响应之间互不干扰。

  • 使用一个连接并行发送多个请求和响应。

  • 不必再为绕过 HTTP/1.x 限制而做很多工作(请参阅针对 HTTP/1.x 进行优化,例如级联文件、image sprites 和域名分片。

  • 消除不必要的延迟和提高现有网络容量的利用率,从而减少页面加载时间。

  • 等等…


HTTP/2 中的新二进制分帧层解决了 HTTP/1.x 中存在的队首阻塞问题,也消除了并行处理和发送请求及响应时对多个连接的依赖。 结果,应用速度更快、开发更简单、部署成本更低。


5. 数据流优先级


将 HTTP 消息分解为很多独立的帧之后,我们就可以复用多个数据流中的帧,客户端和服务器交错发送和传输这些帧的顺序就成为关键的性能决定因素。 为了做到这一点,HTTP/2 标准允许每个数据流都有一个关联的权重和依赖关系:



  • 可以向每个数据流分配一个介于 1 至 256 之间的整数。

  • 每个数据流与其他数据流之间可以存在显式依赖关系。


数据流依赖关系和权重的组合让客户端可以构建和传递“优先级树”,表明它倾向于如何接收响应。 反过来,服务器可以使用此信息通过控制 CPU、内存和其他资源的分配设定数据流处理的优先级,在资源数据可用之后,带宽分配可以确保将高优先级响应以最优方式传输至客户端。


image.png


HTTP/2 内的数据流依赖关系通过将另一个数据流的唯一标识符作为父项引用进行声明;如果忽略标识符,相应数据流将依赖于“根数据流”。 声明数据流依赖关系指出,应尽可能先向父数据流分配资源,然后再向其依赖项分配资源。 换句话说,“请先处理和传输响应 D,然后再处理和传输响应 C”。


共享相同父项的数据流(即,同级数据流)应按其权重比例分配资源。 例如,如果数据流 A 的权重为 12,其同级数据流 B 的权重为 4,那么要确定每个数据流应接收的资源比例,请执行以下操作:



  1. 将所有权重求和: 4 + 12 = 16

  2. 将每个数据流权重除以总权重: A = 12/16, B = 4/16


因此,数据流 A 应获得四分之三的可用资源,数据流 B 应获得四分之一的可用资源;数据流 B 获得的资源是数据流 A 所获资源的三分之一。


我们来看一下上图中的其他几个操作示例。 从左到右依次为:



  1. 数据流 A 和数据流 B 都没有指定父依赖项,依赖于隐式“根数据流”;A 的权重为 12,B 的权重为 4。因此,根据比例权重: 数据流 B 获得的资源是 A 所获资源的三分之一。

  2. 数据流 D 依赖于根数据流;C 依赖于 D。 因此,D 应先于 C 获得完整资源分配。 权重不重要,因为 C 的依赖关系拥有更高的优先级。

  3. 数据流 D 应先于 C 获得完整资源分配;C 应先于 A 和 B 获得完整资源分配;数据流 B 获得的资源是 A 所获资源的三分之一。

  4. 数据流 D 应先于 E 和 C 获得完整资源分配;E 和 C 应先于 A 和 B 获得相同的资源分配;A 和 B 应基于其权重获得比例分配。


如上面的示例所示,数据流依赖关系和权重的组合明确表达了资源优先级,这是一种用于提升浏览性能的关键功能,网络中拥有多种资源类型,它们的依赖关系和权重各不相同。 不仅如此,HTTP/2 协议还允许客户端随时更新这些优先级,进一步优化了浏览器性能。 换句话说,我们可以根据用户互动和其他信号更改依赖关系和重新分配权重。


注: 数据流依赖关系和权重表示传输优先级,而不是要求,因此不能保证特定的处理或传输顺序。 即,客户端无法强制服务器通过数据流优先级以特定顺序处理数据流。 尽管这看起来违反直觉,但却是一种必要行为。 我们不希望在优先级较高的资源受到阻止时,还阻止服务器处理优先级较低的资源。


6. 每个来源一个连接


有了新的分帧机制后,HTTP/2 不再依赖多个 TCP 连接去并行复用数据流;每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别设定优先级。 因此,所有 HTTP/2 连接都是永久的,而且仅需要每个来源一个连接,随之带来诸多性能优势。



SPDY 和 HTTP/2 的杀手级功能是,可以在一个拥塞受到良好控制的通道上任意进行复用。 这一功能的重要性和良好运行状况让我吃惊。 我喜欢的一个非常不错的指标是连接拆分,这些拆分仅承载一个 HTTP 事务(并因此让该事务承担所有开销)。 对于 HTTP/1,我们 74% 的活动连接仅承载一个事务 - 永久连接并不如我们所有人希望的那般有用。 但是在 HTTP/2 中,这一比例锐减至 25%。 这是在减少开销方面获得的巨大成效。  (HTTP/2 登陆 Firefox,Patrick McManus)



大多数 HTTP 传输都是短暂且急促的,而 TCP 则针对长时间的批量数据传输进行了优化。 通过重用相同的连接,HTTP/2 既可以更有效地利用每个 TCP 连接,也可以显著降低整体协议开销。 不仅如此,使用更少的连接还可以减少占用的内存和处理空间,也可以缩短完整连接路径(即,客户端、可信中介和源服务器之间的路径) 这降低了整体运行成本并提高了网络利用率和容量。 因此,迁移到 HTTP/2 不仅可以减少网络延迟,还有助于提高通量和降低运行成本。


注: 连接数量减少对提升 HTTPS 部署的性能来说是一项特别重要的功能: 可以减少开销较大的 TLS 连接数、提升会话重用率,以及从整体上减少所需的客户端和服务器资源。


7. 流控制


流控制是一种阻止发送方向接收方发送大量数据的机制,以免超出后者的需求或处理能力: 发送方可能非常繁忙、处于较高的负载之下,也可能仅仅希望为特定数据流分配固定量的资源。 例如,客户端可能请求了一个具有较高优先级的大型视频流,但是用户已经暂停视频,客户端现在希望暂停或限制从服务器的传输,以免提取和缓冲不必要的数据。 再比如,一个代理服务器可能具有较快的下游连接和较慢的上游连接,并且也希望调节下游连接传输数据的速度以匹配上游连接的速度来控制其资源利用率;等等。


上述要求会让您想到 TCP 流控制吗?您应当想到这一点;因为问题基本相同(请参阅流控制)。 不过,由于 HTTP/2 数据流在一个 TCP 连接内复用,TCP 流控制既不够精细,也无法提供必要的应用级 API 来调节各个数据流的传输。 为了解决这一问题,HTTP/2 提供了一组简单的构建块,这些构建块允许客户端和服务器实现其自己的数据流和连接级流控制:



  • 流控制具有方向性。 每个接收方都可以根据自身需要选择为每个数据流和整个连接设置任意的窗口大小。

  • 流控制基于信用。 每个接收方都可以公布其初始连接和数据流流控制窗口(以字节为单位),每当发送方发出 DATA 帧时都会减小,在接收方发出 WINDOW_UPDATE 帧时增大。

  • 流控制无法停用。 建立 HTTP/2 连接后,客户端将与服务器交换 SETTINGS 帧,这会在两个方向上设置流控制窗口。 流控制窗口的默认值设为 65,535 字节,但是接收方可以设置一个较大的最大窗口大小(2^31-1 字节),并在接收到任意数据时通过发送 WINDOW_UPDATE 帧来维持这一大小。

  • 流控制为逐跃点控制,而非端到端控制。 即,可信中介可以使用它来控制资源使用,以及基于自身条件和启发式算法实现资源分配机制。


HTTP/2 未指定任何特定算法来实现流控制。 不过,它提供了简单的构建块并推迟了客户端和服务器实现,可以实现自定义策略来调节资源使用和分配,以及实现新传输能力,同时提升网页应用的实际性能和感知性能(请参阅速度、性能和人类感知)。


例如,应用层流控制允许浏览器仅提取一部分特定资源,通过将数据流流控制窗口减小为零来暂停提取,稍后再行恢复。 换句话说,它允许浏览器提取图像预览或首次扫描结果,进行显示并允许其他高优先级提取继续,然后在更关键的资源完成加载后恢复提取。


8. 服务器推送


HTTP/2 新增的另一个强大的新功能是,服务器可以对一个客户端请求发送多个响应。 换句话说,除了对最初请求的响应外,服务器还可以向客户端推送额外资源(图 12-5),而无需客户端明确地请求。


image.png


注: HTTP/2 打破了严格的请求-响应语义,支持一对多和服务器发起的推送工作流,在浏览器内外开启了全新的互动可能性。 这是一项使能功能,对我们思考协议、协议用途和使用方式具有重要的长期影响。


为什么在浏览器中需要一种此类机制呢?一个典型的网络应用包含多种资源,客户端需要检查服务器提供的文档才能逐个找到它们。 那为什么不让服务器提前推送这些资源,从而减少额外的延迟时间呢? 服务器已经知道客户端下一步要请求什么资源,这时候服务器推送即可派上用场。


事实上,如果您在网页中内联过 CSS、JavaScript,或者通过数据 URI 内联过其他资产(请参阅资源内联),那么您就已经亲身体验过服务器推送了。 对于将资源手动内联到文档中的过程,我们实际上是在将资源推送给客户端,而不是等待客户端请求。 使用 HTTP/2,我们不仅可以实现相同结果,还会获得其他性能优势。 推送资源可以进行以下处理:



  • 由客户端缓存

  • 在不同页面之间重用

  • 与其他资源一起复用

  • 由服务器设定优先级

  • 被客户端拒绝


PUSH_PROMISE 101


所有服务器推送数据流都由 PUSH_PROMISE 帧发起,表明了服务器向客户端推送所述资源的意图,并且需要先于请求推送资源的响应数据传输。 这种传输顺序非常重要: 客户端需要了解服务器打算推送哪些资源,以免为这些资源创建重复请求。 满足此要求的最简单策略是先于父响应(即,DATA 帧)发送所有 PUSH_PROMISE 帧,其中包含所承诺资源的 HTTP 标头。


在客户端接收到 PUSH_PROMISE 帧后,它可以根据自身情况选择拒绝数据流(通过 RST_STREAM 帧)。 (例如,如果资源已经位于缓存中,便可能会发生这种情况。) 这是一个相对于 HTTP/1.x 的重要提升。 相比之下,使用资源内联(一种受欢迎的 HTTP/1.x“优化”)等同于“强制推送”: 客户端无法选择拒绝、取消或单独处理内联的资源。


使用 HTTP/2,客户端仍然完全掌控服务器推送的使用方式。 客户端可以限制并行推送的数据流数量;调整初始的流控制窗口以控制在数据流首次打开时推送的数据量;或完全停用服务器推送。 这些优先级在 HTTP/2 连接开始时通过 SETTINGS 帧传输,可能随时更新。


推送的每个资源都是一个数据流,与内嵌资源不同,客户端可以对推送的资源逐一复用、设定优先级和处理。 浏览器强制执行的唯一安全限制是,推送的资源必须符合原点相同这一政策: 服务器对所提供内容必须具有权威性。


9. 标头压缩


每个 HTTP 传输都承载一组标头,这些标头说明了传输的资源及其属性。 在 HTTP/1.x 中,此元数据始终以纯文本形式,通常会给每个传输增加 500–800 字节的开销。如果使用 HTTP Cookie,增加的开销有时会达到上千字节。 (请参阅测量和控制协议开销。) 为了减少此开销和提升性能,HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:



  1. 这种格式支持通过静态霍夫曼代码对传输的标头字段进行编码,从而减小了各个传输的大小。

  2. 这种格式要求客户端和服务器同时维护和更新一个包含之前见过的标头字段的索引列表(换句话说,它可以建立一个共享的压缩上下文),此列表随后会用作参考,对之前传输的值进行有效编码。


利用霍夫曼编码,可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码,索引值可用于有效查询和重构完整的标头键值对。


image.png


作为一种进一步优化方式,HPACK 压缩上下文包含一个静态表和一个动态表: 静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段(例如,有效标头名称)的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。 因此,为之前未见过的值采用静态 Huffman 编码,并替换每一侧静态表或动态表中已存在值的索引,可以减小每个请求的大小。


注: 在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异: 所有标头字段名称均为小写,请求行现在拆分成各个 :method:scheme:authority 和 :path 伪标头字段。


HPACK 的安全性和性能


早期版本的 HTTP/2 和 SPDY 使用 zlib(带有一个自定义字典)压缩所有 HTTP 标头。 这种方式可以将所传输标头数据的大小减小 85% - 88%,显著减少了页面加载时间延迟:



在带宽较低的 DSL 链路中,上行链路速度仅有 375 Kbps,仅压缩请求标头就显著减少了特定网站(即,发出大量资源请求的网站)的页面加载时间。 我们发现,仅仅由于标头压缩,页面加载时间就减少了 45 - 1142 毫秒。  (SPDY 白皮书, chromium.org)



10. 相关阅读



链接:https://juejin.cn/post/7002025354542415902

收起阅读 »

我是如何用这3个小工具,助力小姐姐提升100%开发效率的

前言 简单的知识点搭配合适的业务场景,往往能起到意想不到的效果。这篇文章会用三个最基础人人都知道的前端知识来说明如何助力运营小姐姐、公司48+前端开发同学的日常工作,让他们的工作效率得到极大地提升。 看完您可以会收获:用vue从零开始写一个chrome插件&n...
继续阅读 »

前言


简单的知识点搭配合适的业务场景,往往能起到意想不到的效果。这篇文章会用三个最基础人人都知道的前端知识来说明如何助力运营小姐姐、公司48+前端开发同学的日常工作,让他们的工作效率得到极大地提升。


看完您可以会收获:用vue从零开始写一个chrome插件 如何用Object.defineProperty拦截fetch请求`  如何使用油猴脚本开发一个扩展程序  日常提效的一些思考


油猴脚本入门示例



因为接下来的两个小工具都是基于油猴脚本来实现的,所以我们提前先了解一下它



油猴脚本是什么?



油猴脚本(Tampermonkey)是一个流行的浏览器扩展,可以运行用户编写的扩展脚本,来实现各式各样的功能,比如去广告、修改样式、下载视频等。



如何写一个油猴脚本?


1. 安装油猴


以chrome浏览器扩展为例,点击这里先安装


安装完成之后可以看到右上角多了这个


image.png


2. 新增示例脚本 hello world



// ==UserScript==
// @name hello world // 脚本名称
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://juejin.cn/* // 表示怎样的url才执行下面的代码
// @icon https://www.google.com/s2/favicons?domain=juejin.cn
// @grant none
// ==/UserScript==

(function() {
'use strict';
alert('hello world')
// Your code here...
})();

没错当打开任意一个https://juejin.cn/*掘金的页面时,都会弹出hello world,而其他的网页如https://baidu.com则不会。


到此你就完成了一个最简单的油猴脚本,接下来我们看一下用同样简单的代码,来解决一个实际问题吧!O(∩_∩)O


3行代码让SSO自动登录


问题是什么?


1. 有一天运营小姐姐要在几个系统之间配置点东西


一顿操作,终于把事情搞定了,心情美美的。


但是她心想,为啥每个系统都要我登录一次,不开心 o( ̄ヘ ̄o#)


1.gif


2. 下午一觉醒来,领导让把上午的配置重新改一下(尽职的小姐姐马上开始操作)


但是让她没想到的是:上午的登录页面仿佛许久没有见到她一样,又和小姐姐来了一次亲密接触😭


此时,她的内心已经开始崩溃了


2.gif


3. 但是这不是结束,以后的每一天她都是这种状态😭😭😭


3.gif


痛点在哪里?



看完上面的动图,我猜你已经在替小姐姐一起骂娘了,这做的什么玩意,太垃圾了。SSO是统一登录,你们这搞的是什么东西。



是的,我的内心和你一样愤愤不平, 一样有一万个草泥马在奔腾,这是哪个sb设计的方案,简直不配做人,一天啥事也不干,尽是跳登录页,输入用户名密码点登录按钮了,久而久之,朋友间见面说的第一句话不是“你吃了吗?”,而是“你登录了吗?”。


不过吐槽完,我们还是要想想如何通过技术手段解决这两个痛点,达到只需要登录一次的目的


1. 在A系统登录之后,跑到其他系统需要重新登录。


2. 登录时效只有2小时,2小时后,需要重新登录


该如何解决?


根本原因还是公司的SSO统一登录方案设计的有问题,所以需要推动他们修改,但是这是一个相对长期的过程,短期内有没有什么办法能让我们愉快的登录呢?


痛点1: 1. 在A系统登录之后,跑到其他系统需要重新登录。已无力回天


痛点2: 2. 登录时效只有2小时,2小时后,需要重新登录已无力回天


我们不好直接侵入各个系统去改造登录逻辑,改造其登录时效,但是却可以对登录页面(示例)做点手脚


image.png


最关键的是:




  1. 用户名输入框




  2. 密码输入框




  3. 点击按钮




所以可以借助油猴脚本,在DOMContentLoaded的时候,插入一下代码,来实现自动登录,减少手动操作的过程,大概原理如下。


结构图.jpg


// ==UserScript==
// @name SSO自动登录
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://*.xxx.com/login* // 这里是SSO登录页面地址,表示只有符合这个规则的才注入这段代码
// @grant none
// ==/UserScript==

document.querySelector('#username').value = 'xxx' // 用户名
document.querySelector('#password').value = 'yyy' // 密码
document.querySelector('#login-submit').click() // 自动提交登录

是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


image.png


是的,就这 😄,第一次帮小姐姐解决了困扰她许久的问题,晚上就请我吃了麻辣烫,还夸我"技术"好(此处不是开车


试试效果


gif中前半部分没有开启自动登录的脚本需要手动登录,后半部开启了就可以自动登录了。


autoLogin.gif


拦截fetch请求,只留你想要的页面


问题是什么?


前端常见的调试方式



  1. chrome inspect

  2. vconsole

  3. weinre

  4. 等等


这些方式都有各自的优缺点,比如chrome inspect第一次需要翻墙才能使用,只适用于安卓; vconsole不方便直接调试样式; weinre只适用于调试样式等。


基于这些原因,公司很久之前搞了一个远程调试工具,可以很方便的增删DOM结构、调试样式、查看请求、查看application 修改后手机上立即生效。


autoLogin2.gif


远程调试平台使用流程


他的使用流程大概是这样的




  1. 打开远程调试页面列表


    此页面包含测试环境所有人打开的调试页面链接, 多的时候有上百个




image.png



  1. 点击你要调试的页面,就可以进入像chrome控制台一样调试了


image.png


看完流程你应该大概知道问题在哪里了, 远程调试页面列表不仅仅包含我自己的页面,还包括很多其他人的,导致很难快速找到自己想要调试的页面


该如何解决?


问题解析


有什么办法能让我快速找到自己想要调试的页面呢?其实观察解析这个页面会发现列表是



  1. 通过发送一个请求获取的

  2. 响应中包含设备关键字


image.png


拦截请求


所以聪明的你已经猜到了,我们可以通过Object.defineProperty拦截fetch请求,过滤设备让列表中只存在我们指定的设备(毕竟平时开发时调试的设备基本是固定的,而设备完全相同的概率是很低的,所以指定了设备其实就是唯一标识了自己)页面。


具体如何做呢?



// ==UserScript==
// @name 前端远程调试设备过滤
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://chii-fe.xxx.com/ // 指定脚本生效的页面
// @grant none
// @run-at document-start // 注意这里,脚本注入的时机是document-start
// ==/UserScript==

;(() => {
const replaceRe = /\s*/g
// 在这里设置设备白名单
const DEVICE_WHITE_LIST = [
'Xiaomi MI 8',
'iPhone9,2',
].map(
(it) => it.replace(replaceRe, '').toLowerCase())

const originFetch = window.fetch
const recordListUrl = 'record-list'
const filterData = (source) => {
// 数据过滤,返回DEVICE_WHITE_LIST指定的设备的数据
// 详细过程省略
return data
}
// 拦截fetch请求
Object.defineProperty(window, 'fetch', {
configurable:
true,
enumerable:
true,
get () {
return function (url, options) {
return originFetch(url, options).then((response) => {
// 只处理指定的url
if (url.includes(recordListUrl)) {
if (response.clone) {
const cloneRes = response.clone()

return new Promise((resolve, reject) => {
resolve(
{
text: (
) => {
return cloneRes.json().then(json => {
return filterData(JSON.stringify(json))
}
);
}
}
)
}
)
}
}

return response
}
)
}
}
}
)
}
)()


试试效果


通过下图可以看出,过滤前有37个页面,过滤后只剩3个,瞬间就找到你要调试页面,再也不用从几百个页面中寻找你自己的那个啦!


image.png


助力全公司45+前端开发 - chrome插件的始与终



通过插件一键设置ua,模拟用户登录状态,提高开发效率。



先看结果


插件使用方式


new.gif


插件使用结果



团队48+小伙伴也使用起来了



image.png


image.png


背景和问题



日常c端业务中有很多场景都需要用户登录后才能正常进行,而开发阶段基本都是通过chrome模拟手机设备来开发,所以往往会涉及到在chrome浏览器中模拟用户登录,其涉及以下三步(这个步骤比较繁琐)。



备注:保持用户的登录态一般是通过cookie,但也有通过header来做,比如我们公司是改写ua来做的



  1. 获取ua: 前往公司UA生成平台输入手机号生成ua

  2. 添加ua: 将ua复制到chrome devtool设置/修改device

  3. 使用ua: 选择新添加的ua,刷新页面,重新开发调试


ua.gif


来看一段对话



隔壁98年刚毕业妹子:



又过期了,谁又把我挤下去了嘛


好的,稍等一会哈,我换个账号测测


好麻烦哎!模拟一个用户信息,要这么多步骤,好烦呀!!!



我,好奇的大叔:



“细心”了解下,她正在做一个h5活动项目,场景复杂,涉及的状态很多,需要用不同的账号来做测试。


模拟一两个用户还好,但是此刻小姐姐测这么多场景,已经模拟了好多个(谁都会烦啊)


公司的登录体系是单点登录,一个好不容易模拟的账号,有可能别人也在用,结果又被顶掉了,得重新生成,我TM


看着她快气哭的小眼神,作为隔壁桌友好的邻居,此刻我心里只想着一件事...!帮她解决这个恼人的问题。


分析和解决问题



通过上面的介绍您应该可以感觉到我们开发阶段遇到需要频繁切换账号做测试时的烦恼,相对繁琐的ua生成过程导致了它一定是个费时费力的麻烦事。



有没有什么办法让我们的开发效率得到提升,别浪费在这种事情上呢?一起一步步做起来


需求有哪些



提供一种便捷地模拟ua的方式,助力开发效率提升。




  1. 基本诉求:本地开发阶段,希望有更便捷的方式来模拟用户登录

  2. 多账号: 一个项目需要多个账号,不同项目间的账号可以共享也可以不同

  3. 指定域: 只有指定的下才需要模拟ua,不能影响浏览器正常使用

  4. 过期处理: 账号过期后,可以主动生成,无需手动重新获取


如何解决




  1. 需求1:结合前面生成ua阶段,我们可以通过某种方式让用户能直接在当前页面生成ua,无需跳出,一键设置省略手动过程




  2. 需求2:提供多账号管理功能,能直接选中切换ua




  3. 需求3:限定指定域,该ua才生效




  4. 需求4:当使用到过期账号时,可一键重新生成即可




为什么是chrome插件




  1. 浏览器中发送ajax请求的ua无法直接修改,但是chrome插件可以修改请求的ua(很重要的一点




  2. chrome插件popup模式可直接在当前页面打开,无需跳出开发页面,减少跳出过程




用vue从零开始写一个chrome插件



篇幅原因,这里只做示例级别的简单介绍,如果您希望详细了解chrome插件的编写可以参考这里



从一个小例子开始



接下来我们会以下页面为例,说明用vue如何写出来。



ua3.gif


基本功能




  1. 底部tab切换区域viewAviewBviewC




  2. 中间内容区域:切换viewA、B、C分别展示对应的页面




content部分


借助chrome浏览器可以向网页插入脚本的特性,我们会演示如何插入脚本并且在网页加载的时候弹一个hello world


popup与background通信部分


popup完成用户的主要交互,在viewA页面点击获取自定义的ua信息


修改ajax请求ua部分


会演示如果通过chrome插件修改请求header


1. 了解一个chrome插件的构成



  1. manifest.json

  2. background script

  3. content script

  4. popup


1. manifest.json



几乎所有的东西都要在这里进行声明、权限资源页面等等




{
"manifest_version": 2, // 清单文件的版本,这个必须写
"name": "hello vue extend", // 插件的名称,等会我们写的插件名字就叫hello vue extend
"description": "hello vue extend", // 插件描述
"version": "0.0.1", // 插件的版本
// 图标,写一个也行
"icons": {
"48": "img/logo.png"
},
// 浏览器右上角图标设置,browser_action、page_action、app必须三选一
"browser_action": {
"default_icon": "img/logo.png",
"default_title": "hello vue extend",
"default_popup": "popup.html"
},
// 一些常驻的后台JS或后台页面
"background": {
"scripts": [
"js/hot-reload.js",
"js/background.js"
]
},
// 需要直接注入页面的JS
"content_scripts": [{
"matches": [""],
"js": ["js/content.js"],
"run_at": "document_start"
}],
// devtools页面入口,注意只能指向一个HTML文件
"devtools_page": "devcreate.html",
// Chrome40以前的插件配置页写法
"options_page": "options.html",
// 权限申请
"permissions": [
"storage",
"webRequest",
"tabs",
"webRequestBlocking",
""
]
}

2. background script



后台,可以认为是一个常驻的页面,权限很高,几乎可以调用所有的API,可以与popup、content script等通信



3. content script



chrome插件向页面注入脚本的一种形式(js和css都可以)



4. popup



popup是点击browser_action或者page_action图标时打开的一个小窗口网页,焦点离开网页就立即关闭。



比如我们要用vue做的页面。


image.png


2. 改写vue.config.js



manifest.json对文件引用的结构基本决定了打包后的文件路径



打包后的路径


// dist目录用来chrome扩展导入

├── dist
│ ├── favicon.ico
│ ├── img
│ │ └── logo.png
│ ├── js
│ │ ├── background.js
│ │ ├── chunk-vendors.js
│ │ ├── content.js
│ │ ├── hot-reload.js
│ │ └── popup.js
│ ├── manifest.json
│ └── popup.html


源码目录



├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── js
│ └── hot-reload.js
├── src
│ ├── assets
│ │ ├── 01.png
│ │ ├── disabled.png
│ │ └── logo.png
│ ├── background
│ │ └── background.js
│ ├── content
│ │ └── content.js
│ ├── manifest.json
│ ├── popup
│ │ ├── App.vue
│ │ ├── main.js
│ │ ├── router.js
│ │ └── views
│ │ ├── viewA.vue
│ │ ├── viewB.vue
│ │ └── viewC.vue
│ └── utils
│ ├── base.js
│ ├── fixCaton.js
│ └── storage.js
└── vue.config.js



修改vue.config.js



主需要稍微改造变成可以多页打包,注意输出的目录结构就可以了




const CopyWebpackPlugin = require('copy-webpack-plugin')
const path = require('path')
// 这里考虑可以添加多页
const pagesObj = {}
const chromeName = ['popup']
const plugins = [
{
from: path.resolve('src/manifest.json'),
to: `${path.resolve('dist')}/manifest.json`
},
{
from: path.resolve('src/assets/logo.png'),
to: `${path.resolve('dist')}/img/logo.png`
},
{
from: path.resolve('src/background/background.js'),
to: `${path.resolve('dist')}/js/background.js`
},
{
from: path.resolve('src/content/content.js'),
to: `${path.resolve('dist')}/js/content.js`
},
]

chromeName.forEach(name => {
pagesObj[name] = {
css: {
loaderOptions: {
less: {
modifyVars: {},
javascriptEnabled: true
}
}
},
entry: `src/${name}/main.js`,
filename: `${name}.html`
}
})

const vueConfig = {
lintOnSave:false, //关闭eslint检查
pages: pagesObj,
configureWebpack: {
entry: {},
output: {
filename: 'js/[name].js'
},
plugins: [new CopyWebpackPlugin(plugins)]
},
filenameHashing: false,
productionSourceMap: false
}

module.exports = vueConfig



3. 热刷新



我们希望修改插件源代码进行打包之后,chrome插件对应的页面能主动更新。为什么叫热刷新而不是热更新呢?因为它其实是全局刷新页面,并不会保存状态。



这里推荐一个github上的解决方案crx-hotreload


4. 完成小例子编写


new.gif


文件目录结构



├── popup
│ ├── App.vue
│ ├── main.js
│ ├── router.js
│ └── views
│ ├── viewA.vue
│ ├── viewB.vue
│ └── viewC.vue



main.js



import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
router,
render: h => h(App)
}).$mount('#app')



router.js


import Vue from 'vue'
import Router from 'vue-router'

import ViewA from './views/viewA.vue'
import ViewB from './views/viewB.vue'
import ViewC from './views/viewC.vue'

Vue.use(Router)

export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
redirect: '/view/a'
},
{
path: '/view/a',
name: 'viewA',
component: ViewA,
},
{
path: '/view/b',
name: 'viewB',
component: ViewB,
},
{
path: '/view/c',
name: 'viewC',
component: ViewC,
},
]
})

App.vue









viewA、viewB、viewC



三个页面基本长得是一样的,只有背景色和文案内容不一样,这里我就只贴viewA的代码了。



需要注意的是这里会演示popup与background,通过sendMessage方法获取background后台数据










background.js


const customUa = 'hello world ua'
// 请求发送前拦截
const onBeforeSendCallback = (details) => {
for (var i = 0; i < details.requestHeaders.length; ++i) {
if (details.requestHeaders[i].name === 'User-Agent') {
details.requestHeaders.splice(i, 1);
break;
}
}
// 修改请求UA为hello world ua
details.requestHeaders.push({
name: 'User-Agent',
value: customUa
});

return { requestHeaders: details.requestHeaders };
}

// 前面的sendMessage获取getCustomUserAgent,会被这里监听
const onRuntimeMessageListener = () => {
chrome.runtime.onMessage.addListener(function (msg, sender, callback) {
if (msg.type === 'getCustomUserAgent') {
callback({
customUa
});
}
});
}

const init = () => {
onRuntimeMessageListener()
onBeforeSendHeadersListener()
}

init()


content.js



演示如何往网页中插入代码




function setScript({ code = '', needRemove = true } = params) {
let textNode = document.createTextNode(code)
let script = document.createElement('script')

script.appendChild(textNode)
script.remove()

let parentNode = document.head || document.documentElement

parentNode.appendChild(script)
needRemove && parentNode.removeChild(script)
}

setScript({
code: `alert ('hello world')`,
})

ua3.gif


关于一键设置ua插件



大体上和小例子差不都,只是功能相对复杂一些,会涉及到





  1. 数据本地存储chrome.storage.sync.get|setchrome.tabs.query等API




  2. popup与background通信、content与background通信




  3. 拦截请求修改UA




  4. 其他的大体就是常规的vue代码编写啦!




这里就不贴详细的代码实现了。



链接:https://juejin.cn/post/7001998089938534437

收起阅读 »

跨浏览器窗口通讯 ,7种方式,你还知道几种呢?

前言 为什么会扯到这个话题,最初是源于听 y.qq.com/ QQ音乐, 播放器处于单独的一个页面 当你在另外的一个页面搜索到你满意的歌曲的时候,点击播放或添加到播放队列 你会发现,播放器页面做出了响应的响应 这里我又联想到了商城的购物车的场景,体验确实有...
继续阅读 »

前言


为什么会扯到这个话题,最初是源于听 y.qq.com/ QQ音乐,



  • 播放器处于单独的一个页面

  • 当你在另外的一个页面搜索到你满意的歌曲的时候,点击播放或添加到播放队列

  • 你会发现,播放器页面做出了响应的响应


这里我又联想到了商城的购物车的场景,体验确实有提升。

刚开始,我怀疑的是Web Socket作妖,结果通过分析网络请求和看源码,并没有。 最后发现是localStore的storage事件作妖,哈哈。




回归正题,其实在一般正常的知识储备的情况下,我们会想到哪些方案呢?


先抛开如下方式:



  1. 各自对服务器进行轮询或者长轮询

  2. 同源策略下,一方是另一方的 opener


演示和源码


多页面通讯的demo, 为了正常运行,请用最新的chrome浏览器打开。

demo的源码地址



两个浏览器窗口间通信


WebSocket


这个没有太多解释,WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。当然是有代价的,需要服务器来支持。

js语言,现在比较成熟稳定当然是 socket.iows. 也还有轻量级的ClusterWS


你可以在The WebSocket API (WebSockets)
看到更多的关于Web Socket的信息。


定时器 + 客户端存储


定时器:setTimeout/setInterval/requestAnimationFrame

客户端存储: cookie/localStorage/sessionStorage/indexDB/chrome的FileSystem


定时器没啥好说的,关于客户端存储。



  • cookie: 每次会带到服务端,并且能存的并不大,4kb?,记得不太清楚

  • localStorage/sessionStorage 应该是5MB, sessionStorage关闭浏览器就和你说拜拜。

  • indexDB 这玩意就强大了,不过读取都是异步的,还能存 Blob文件,真的是很high。

  • chrome的FileSystem ,Filesystem & FileWriter API,主要是chrome和opera支持。这玩意就是文件系统。


postMessage


Cross-document messaging 这玩意的支持率98.9%。 好像还能发送文件,哈哈,强大。

不过仔细一看 window.postMessage(),就注定了你首先得拿到window这个对象。 也注定他使用的限制, 两个窗体必须建立起联系。 常见建立联系的方式:



  • window.open

  • window.opener

  • iframe


提到上面的window.open, open后你能获得被打开窗体的句柄,当然也可以直接操作窗体了。




到这里,我觉得一般的前端人员能想到的比较正经的方案应该是上面三种啦。

当然,我们接下来说说可能不是那么常见的另外三种方式。


StorageEvent


Page 1


localStorage.setItem('message',JSON.stringify({
message: '消息',
from: 'Page 1',
date: Date.now()
}))

Page 2


window.addEventListener("storage", function(e) {
console.log(e.key, e.newValue, e.oldValue)
});

如上, Page 1设置消息, Page 2注册storage事件,就能监听到数据的变化啦。


上面的e就是StorageEvent,有下面特有的属性(都是只读):



  • key :代表属性名发生变化.当被clear()方法清除之后所有属性名变为null

  • newValue:新添加进的值.当被clear()方法执行过或者键名已被删除时值为null

  • oldValue:原始值.而被clear()方法执行过,或在设置新值之前并没有设置初始值时则返回null

  • storageArea:被操作的storage对象

  • url:key发生改变的对象所在文档的URL地址


Broadcast Channel


这玩意主要就是给多窗口用的,Service Woker也可以使用。 firefox,chrome, Opera均支持,有时候真的是很讨厌Safari,浏览器支持77%左右。


使用起来也很简单, 创建BroadcastChannel, 然后监听事件。 只需要注意一点,渠道名称一致就可以。

Page 1


    var channel = new BroadcastChannel("channel-BroadcastChannel");
channel.postMessage('Hello, BroadcastChannel!')

Page 2


    var channel = new BroadcastChannel("channel-BroadcastChannel");
channel.addEventListener("message", function(ev) {
console.log(ev.data)
});

SharedWorker


这是Web Worker之后出来的共享的Worker,不通页面可以共享这个Worker。

MDN这里给了一个比较完整的例子simple-shared-worker


这里来个插曲,Safari有几个版本支持这个特性,后来又不支持啦,还是你Safari,真是6。


虽然,SharedWorker本身的资源是共享的,但是要想达到多页面的互相通讯,那还是要做一些手脚的。
先看看MDN给出的例子的ShareWoker本身的代码:


onconnect = function(e) {
var port = e.ports[0];

port.onmessage = function(e) {
var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
port.postMessage(workerResult);
}

}

上面的代码其实很简单,port是关键,这个port就是和各个页面通讯的主宰者,既然SharedWorker资源是共享的,那好办,把port存起来就是啦。

看一下,如下改造的代码:

SharedWorker就成为一个纯粹的订阅发布者啦,哈哈。


var portList = [];

onconnect = function(e) {
var port = e.ports[0];
ensurePorts(port);
port.onmessage = function(e) {
var data = e.data;
disptach(port, data);
};
port.start();
};

function ensurePorts(port) {
if (portList.indexOf(port) < 0) {
portList.push(port);
}
}

function disptach(selfPort, data) {
portList
.filter(port => selfPort !== port)
.forEach(port => port.postMessage(data));
}


MessageChannel


Channel Messaging API的 MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个MessagePort 属性发送数据。


其需要先通过 postMessage先建立联系。


MessageChannel的基本使用:


var channel = new MessageChannel();
var para = document.querySelector('p');

var ifr = document.querySelector('iframe');
var otherWindow = ifr.contentWindow;

ifr.addEventListener("load", iframeLoaded, false);

function iframeLoaded() {
otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);
}

channel.port1.onmessage = handleMessage;
function handleMessage(e) {
para.innerHTML = e.data;
}

至于在线的例子,MDN官方有一个版本 MessageChannel 通讯



链接:https://juejin.cn/post/7002012595200720927

收起阅读 »

更新需要提示用户,需要控制应用是否更新

更新需要提示用户,需要控制应用是否更新1. 方案一在检测到更新后提示用户,让用户选择更新。设置autoDownload参数为false,让应用检测到更新不自动下载,改成手动下载更新包。通过在钩子update-available中,加入对话框提示用户,让用户选择...
继续阅读 »

更新需要提示用户,需要控制应用是否更新

1. 方案一

在检测到更新后提示用户,让用户选择更新。

设置autoDownload参数为false,让应用检测到更新不自动下载,改成手动下载更新包。

通过在钩子update-available中,加入对话框提示用户,让用户选择。

response为0用户选择确定,触发downloadUpdate方法下载应用更新包进行后续更新操作。否则,不下载更新包。

如果我们不配置autoDownload为false,那么问题来了:在弹出对话框的同时,用户还来不及选择,应用自动下载并且更新完成,做不到阻塞。

本文首发于公众号「全栈大佬的修炼之路」,欢迎关注。

重要代码如下:

autoUpdater.autoDownload = false

update-available钩子中弹出对话框

autoUpdater.on('update-available', (ev, info) => {
// // 不可逆过程
const options = {
type: 'info',
buttons: ['确定', '取消'],
title: '更新提示',
// ${info.version} Cannot read property 'version' of undefined
message: '发现有新版本,是否更新?',
cancelId: 1
}
dialog.showMessageBox(options).then(res => {
if (res.response === 0) {
autoUpdater.downloadUpdate()
logger.info('下载更新包成功')
sendStatusToWindow('下载更新包成功');
} else {
return;
}
})
})

2. 方案二

在更新下载完后提示用户,让用户选择更新。

先配置参数autoInstallOnAppQuit为false,阻止应用在检测到更新包后自动更新。

在钩子update-downloaded中加入对话框提示用户,让用户选择。

response为0用户选择确定,更新应用。否则,当前应用不更新。

如果我们不配置autoInstallOnAppQuit为false,那么问题是:虽然第一次应用不更新,但是第二次打开应用,应用马上关闭,还没让我们看到主界面,应用暗自更新,重点是更新完后不重启应用。

重要代码如下:

// 表示下载包不自动更新
autoUpdater.autoInstallOnAppQuit = false
在update-downloaded钩子中弹出对话框
autoUpdater.on('update-downloaded', (ev, releaseNotes, releaseName) => {
logger.info('下载完成,更新开始')
sendStatusToWindow('下载完成,更新开始');
// Wait 5 seconds, then quit and install
// In your application, you don't need to wait 5 seconds.
// You could call autoUpdater.quitAndInstall(); immediately
const options = {
type: 'info',
buttons: ['确定', '取消'],
title: '应用更新',
message: process.platform === 'win32' ? releaseNotes : releaseName,
detail: '发现有新版本,是否更新?'
}
dialog.showMessageBox(options).then(returnVal => {
if (returnVal.response === 0) {
logger.info('开始更新')
setTimeout(() => {
autoUpdater.quitAndInstall()
}, 5000);
} else {
logger.info('取消更新')
return
}
})
});

3. 源码分析

未打包目录位于: electron-builder/packages/electron-updater/src/AppUpdater.ts中。 打包后在electron-updater\out\AppUpdater.d.ts中

  1. 首先进入checkForUpdates()方法,开始检测更新
  2. 正在更新不需要进入
  3. 开始更新前判断autoDownload,为true自动下载,为false不下载等待应用通知。
export declare abstract class AppUpdater extends EventEmitter {
/**
* 当被发现有更新时,是否要自动下载更新
* 场景:可以适用于electron检查更新包提示,用户操作是否需要更新
*/
autoDownload: boolean;
/**
* 在app.quit()后,是否自动将下载下载的更新包更新
* 场景:可以适用于electron下载完更新包提示,用户操作是否需要更新。在第二次打开应用,应用不会自动更新。
*/
autoInstallOnAppQuit: boolean;
}


/**
* 检测是否需要更新
*/
checkForUpdates(): Promise < UpdateCheckResult > {
let checkForUpdatesPromise = this.checkForUpdatesPromise
// 正在检测更新跳过
if (checkForUpdatesPromise != null) {
this._logger.info("Checking for update (already in progress)")
return checkForUpdatesPromise
}

const nullizePromise = () => this.checkForUpdatesPromise = null
// 开始检测更新
this._logger.info("Checking for update")
checkForUpdatesPromise = this.doCheckForUpdates()
.then(it => {
nullizePromise()
return it
})
.catch(e => {
nullizePromise()
this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`)
throw e
})

this.checkForUpdatesPromise = checkForUpdatesPromise
return checkForUpdatesPromise
}
// 检测更新具体函数
private async doCheckForUpdates(): Promise < UpdateCheckResult > {
// 触发 checking-for-update 钩子
this.emit("checking-for-update")
// 取更新信息
const result = await this.getUpdateInfoAndProvider()
const updateInfo = result.info
// 判断更新信息是否有效
if (!await this.isUpdateAvailable(updateInfo)) {
this._logger.info(`Update for version ${this.currentVersion} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).`)
this.emit("update-not-available", updateInfo)
return {
versionInfo: updateInfo,
updateInfo,
}
}

this.updateInfoAndProvider = result
this.onUpdateAvailable(updateInfo)

const cancellationToken = new CancellationToken()
//noinspection ES6MissingAwait
// 如果设置autoDownload为true,则开始自动下载更新包,否则不下载
return {
versionInfo: updateInfo,
updateInfo,
cancellationToken,
downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null
}
}

如果需要配置updater中的其他参数达到某种功能,我们可以仔细查看其中的配置项。

export abstract class AppUpdater extends EventEmitter {
/**
* 当被发现有更新时,是否要自动下载更新
* 场景:可以适用于electron检查更新包提示,用户操作是否需要更新
*/
autoDownload: boolean;
/**
* 在app.quit()后,是否自动将下载下载的更新包更新
* 场景:可以适用于electron下载完更新包提示,用户操作是否需要更新。在第二次打开应用,应用不会自动更新。
*/
autoInstallOnAppQuit: boolean;
/**
* GitHub提供者。
是否允许升级到预发布版本。
如果应用程序版本包含预发布组件,默认为“true”。0.12.1-alpha.1,这里alpha是预发布组件),否则“false”。
allowDowngrade设置为true,则应用允许降级。
*/
allowPrerelease: boolean;
/**
* GitHub提供者。
获取所有发布说明(从当前版本到最新版本),而不仅仅是最新版本。
@default false
*/
fullChangelog: boolean;
/**
*是否允许版本降级(当用户从测试通道想要回到稳定通道时)。
*仅当渠道不同时考虑(根据语义版本控制的预发布版本组件)。
* @default false
*/
allowDowngrade: boolean;
/**
* 当前应用的版本
*/
readonly currentVersion: SemVer;
private _channel;
protected downloadedUpdateHelper: DownloadedUpdateHelper | null;
/**
* 获取更新通道。
不适用于GitHub。
从更新配置不返回“channel”,仅在之前设置的情况下。
*/
get channel(): string | null;
/**
* 设置更新通道。
不适用于GitHub。
覆盖更新配置中的“channel”。
“allowDowngrade”将自动设置为“true”。
如果这个行为不适合你,明确后简单设置“allowDowngrade”。
*/
set channel(value: string | null);
/**
* 请求头
*/
requestHeaders: OutgoingHttpHeaders | null;
protected _logger: Logger;
get netSession(): Session;
/**
* The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`.
* Set it to `null` if you would like to disable a logging feature.
* 日志,类型有:info、warn、error
*/
get logger(): Logger | null;
set logger(value: Logger | null);
/**
* For type safety you can use signals, e.g.
为了类型安全,可以使用signals。
例如:
`autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})`
*/
readonly signals: UpdaterSignal;
private _appUpdateConfigPath;
/**
* test only
* @private
*/
set updateConfigPath(value: string | null);
private clientPromise;
protected readonly stagingUserIdPromise: Lazy<string>;
private checkForUpdatesPromise;
protected readonly app: AppAdapter;
protected updateInfoAndProvider: UpdateInfoAndProvider | null;
protected constructor(
options: AllPublishOptions | null | undefined,
app?: AppAdapter
);
/**
* 获取当前更新的url
*/
getFeedURL(): string | null | undefined;
/**
* Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`.
* @param options If you want to override configuration in the `app-update.yml`.
*
* 配置更新提供者。通过提供url
* @param options 如果你想覆盖' app-update.yml '中的配置。
*/
setFeedURL(options: PublishConfiguration | AllPublishOptions | string): void;
/**
* 检查服务其是否有更新
*/
checkForUpdates(): Promise<UpdateCheckResult>;
isUpdaterActive(): boolean;
/**
*
* @param downloadNotification 询问服务器是否有更新,下载并通知更新是否可用
*/
checkForUpdatesAndNotify(
downloadNotification?: DownloadNotification
): Promise<UpdateCheckResult | null>;
private static formatDownloadNotification;
private isStagingMatch;
private computeFinalHeaders;
private isUpdateAvailable;
protected getUpdateInfoAndProvider(): Promise<UpdateInfoAndProvider>;
private createProviderRuntimeOptions;
private doCheckForUpdates;
protected onUpdateAvailable(updateInfo: UpdateInfo): void;
/**
*
* 作用:开始下载更新包
*
* 如果将`autoDownload`选项设置为false,就可以使用这个方法。
*
* @returns {Promise<string>} Path to downloaded file.
*/
downloadUpdate(cancellationToken?: CancellationToken): Promise<any>;
protected dispatchError(e: Error): void;
protected dispatchUpdateDownloaded(event: UpdateDownloadedEvent): void;
protected abstract doDownloadUpdate(
downloadUpdateOptions: DownloadUpdateOptions
): Promise<Array<string>>;
/**
* 作用:下载后重新启动应用程序并安装更新。
*只有在' update- downloads '被触发后才会调用。
*
* 注意:如果在update-downloaded钩子中,让用户选择是否更新应用,选择不更新,那就是没有执行autoUpdater.quitAndInstall()方法。
* 虽然应用没有更新,但是当第二次打开应用的时候,应用检测到本地有更新包,他就会直接更新,最后不会重启更新后的应用。
*
* 为了解决这个问题,需要设置`autoInstallOnAppQuit`为false。关闭应用自动更新。
*
* **Note:** ' autoUpdater.quitAndInstall() '将首先关闭所有的应用程序窗口,然后只在' app '上发出' before-quit '事件。
*这与正常的退出事件序列不同。
*
* @param isSilent 仅Windows以静默模式运行安装程序。默认为false。
* @param isForceRunAfter 即使无提示安装也可以在完成后运行应用程序。不适用于macOS。忽略是否isSilent设置为false。
*/
abstract quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
private loadUpdateConfig;
private computeRequestHeaders;
private getOrCreateStagingUserId;
private getOrCreateDownloadHelper;
protected executeDownload(
taskOptions: DownloadExecutorTask
): Promise<Array<string>>;
}

最后,希望大家一定要点赞三连。


链接:https://juejin.cn/post/7001682043104919565

收起阅读 »

JS数字之旅——Number

首先来一段神奇的数字比较的代码 23333333333333333 === 23333333333333332 // output: true 233333333333333330000000000 === 23333333333333333999999999...
继续阅读 »

首先来一段神奇的数字比较的代码


23333333333333333 === 23333333333333332
// output: true
233333333333333330000000000 === 233333333333333339999999999
// output: true

咦?明明不一样的两个数字,为啥是相等的呢?


Number


众所周知,每一种编程语言,都有自己的数字类型,像Java里面,有intfloatlongdouble等,不同的类型有不同的可表示的数字范围。


同理,JavaScript也有Number表示数字,但是没有像C、Java等语言那样有表示不同精度的类型,Number可以用来表示整数也可以表示浮点数。由于Number是在内部被表示为64位的浮点数,所以是有边界值,而这个边界值如下:


Number.MAX_VALUE
// output: 1.7976931348623157e+308
Number.MIN_VALUE
// output: 5e-324

最大正数和最小正数



Number.MAX_VALUE代表的是可表示的最大正数,Number.MIN_VALUE代表的是可表示的最小正数。它们的值分别大约是1.79E+3085e-324



这时很容易想到一个问题,那超过MAX_VALUE会发生什么呢?通过下面的代码和输出可以发现,当超过MAX_VALUE,无论什么数字,都一律认为与MAX_VALUE相等,直到超过一定值之后,就会等于Infinity


Number.MAX_VALUE
// output: 1.7976931348623157e+308
Number.MAX_VALUE+1
// output: 1.7976931348623157e+308
Number.MAX_VALUE+1e291
// output: 1.7976931348623157e+308
Number.MAX_VALUE === Number.MAX_VALUE+1e291
// output: true
Number.MAX_VALUE+1e292
// output: Infinity

很明显,最开始的那段代码里面的数字,是在这两个Number.MAX_VALUENumber.MIN_VALUE,没有出现越界的情况,但为什么会发生比较上错误呢?


安全整数


其实,Number还有另一个概念,叫做安全整数,其中MAX_SAFE_INTEGER的定义是最大的整数n,使得n和n+1都能准确表示。而MIN_SAFE_INTEGER的定义则是最小的整数n,使得n和n-1都能准确表示。如下面代码所示,9007199254740991(2^53 - 1) 和 -9007199254740991(-(2^53 - 1)) 就是符合定义的最大和最小整数。


Number.MAX_SAFE_INTEGER
// output: 9007199254740991
Number.MAX_SAFE_INTEGER+1
// output: 9007199254740992
Number.MAX_SAFE_INTEGER+2
// output: 9007199254740992

Number.MIN_SAFE_INTEGER
// output: -9007199254740991
Number.MIN_SAFE_INTEGER-1
// output: -9007199254740992
Number.MIN_SAFE_INTEGER-2
// output: -9007199254740992

这意味着,在这个范围内的整数,进行计算或比较都是精确的。而超过这个区域的整数,则是不安全、有较大误差的。


现在回头看最开始的代码,很明显这些数字都已经超过MAX_SAFE_INTEGER。另外,也可以使用Number.isSafeInteger方法去判断是否是安全整数


Number.isSafeInteger(23333333333333333)
// output: false

Infinity


关于Infinity的一个有趣的地方是,它是一个数字类型,但既不是NaN,也不是整数。


typeof Infinity
// output: "number"
Number.isNaN(Infinity)
// output: false
Number.isInteger(Infinity)
// output: false

进制


JavaScript里面,除了支持十进制以外,也支持二进制、八进制和十六进制的字面量。



  • 二进制:以0b开头

  • 八进制:以0开头

  • 十六进制:以0x开头


0b11001
// output: 25
0234
// output: 156
0x1A2B
// output: 6699

里面出现的字母是不区分大小写,可以放心使用。那当我们遇到上面的进制以字符串形式出现的时候,如何解决呢?答案是使用parseInt


parseInt('11001', 2)
// output: 25
parseInt('0234', 8)
// output: 156
parseInt('0x1A2B', 16)
// output: 6699

parseInt这个方法实际上支持2-36进制的转换,虽然平时绝大多数情况,它通常被用来转十进制格式的字符串,而不会特别声明第二个参数。需要注意的一个特别的点是,部分浏览器,如果字符串是0开头的话,不带第二个参数的话,会默认以八进制进行换算,从而导致一些意想不到的bug。所以,保险起见,还是应该声明第二个参数。


浮点数计算


前面基本上都是在讨论整数,Number在浮点数计算方面,就显得有些力不从心,经常出现摸不着头脑的情况,最著名的莫过于0.1 + 0.2不等于0.3的精度问题。


0.1 + 0.2 === 0.3
// output: false
0.1 + 0.2
// output: 0.30000000000000004
1.5 * 1.2
// output: 1.7999999999999998

但其实这并非JavaScript特有的现象,像Java等语言也是有这种问题存在。究其原因,是因为我们以十进制的角度去计算,但计算机本身是以二进制运行和计算的,这就会导致在浮点数类型的表示和计算上,会存在一定的偏差,当这种偏差累计足够大的时候,就会导致精度问题。


正所谓解决办法总比困难多,仔细想想还是能找到一些解决方案。


有一种思路是,既然尽管出现误差也只有一点点,那就通过toFixed()强行限制小数位数,让存在误差的数值进行转换从而消除误差,但有一定的局限性,当遇上乘法和除法的时候,需要确认限制小数位数具体要多少个才合适。


另一种思路是,浮点数计算有误差,但是整数计算是准确的,那就把浮点数放大转换为整数,然后进行计算后再转换为浮点数。跟上面的方案类似,同样需要解决放大多少倍,以及后面转换为浮点数时的额外计算。


链接:https://juejin.cn/post/7001183062792863774

收起阅读 »

前端动画lottie-web

lottie是一个跨平台的动画库,通过AE(After Effects)制作动画,再通过AE插件Bodymovin导出Json文件,最终各个终端解析这个Json文件,还原动画。本文中我只介绍前端用到的库lottie-web。 对比三种常规的制作动画方式 Pn...
继续阅读 »

lottie是一个跨平台的动画库,通过AE(After Effects)制作动画,再通过AE插件Bodymovin导出Json文件,最终各个终端解析这个Json文件,还原动画。本文中我只介绍前端用到的库lottie-web。


对比三种常规的制作动画方式



  1. Png序列帧

  2. 2.Gif图

  3. 前端Svg API


先对位图与矢量图有一个基本的概念。



矢量图就是使用直线和曲线来描述的图形,构成这些图形的元素是一些点、线、矩形、多边形、圆和弧线等,它们都是通过数学公式计算获得的,具有编辑后不失真的特点。

位图是由称作像素(图片元素)的单个点组成的,放大会失真。\



Png序列帧


用Png序列帧是也容易理解,用css keyframes操作每一帧需要展示的图片,缺点也很明显,每一帧都是一张图片,占比较大的体积。当然也可以将图片合并成精灵图(Sprites Map),可参考这个方案,使用 gka 一键生成帧动画。Png也是位图,放大会失真,不过可以通过增大图片尺寸,避免模糊。


Gif图


如果之前没有用过动画,用Gif图是最简单的,只需要引入一张图。但是Gif图是位图,不是矢量图,放大会虚。


前端Svg API


Svg API对于动画初学者不太友好,你要实现一个自定义的动画,需要了解Svg的所有的API,虽然它的属性与css的动画有一些相似。它是矢量图,不失真。


lottie


而lottie是一个不太占体积,还原度高,对于初学者友好的库。设计师制作好动画,并且利用Bodymovin插件导出Json文件。而前端直接引用lottie-web库即可,它默认的渲染方式是svg,原理就是用JS操作Svg API。但是前端完全不需要关心动画的过程,Json文件里有每一帧动画的信息,而库会帮我们执行每一帧。


前端安装lottie-web插件


npm install lottie-web

代码调用


import lottie from 'lottie-web';

this.animation = lottie.loadAnimation({
container: this.animationRef.current,
renderer: 'svg',
loop: false,
autoplay: false,
animationData: dataJson,
assetsPath: CDN_URL,
});

介绍一个每个属性的意思。



  • container 当前需要渲染的DOM

  • renderer,渲染方式,默认是Svg,还有Html和Canvas方案。

  • loop 是否循环播放

  • autoplay 是否自动播放

  • animationData AE导出的Json,注意,这里不是路径

  • assetsPath Json文件里资源的绝对路径,webpack项目需要配合这个参数。


动画的播放与暂停,如果动画需要用户触发与暂停,需要有一个切换操作(toggle)


this.animation.play();
this.animation.pause();

动画执行过程中的钩子,可以对动画有一定的控制权



  • complete

  • loopComplete

  • enterFrame

  • segmentStart

  • config_ready(初始配置完成)

  • data_ready(所有动画数据加载完成)

  • DOMLoaded(元素已添加到DOM节点)

  • destroy


// 动画播放完成触发
anm.addEventListener('complete', anmLoaded);

// 当前循环播放完成触发
anm.addEventListener('loopComplete', anmComplete);

// 播放一帧动画的时候触发
anm.addEventListener('enterFrame', enterFrame);

打包时图片资源路径


webpack工程需要注意Json文件如果有图片资源(Png或者Svg),需要将文件放在项目的根目录的static下。这样打包的时候,图片会被打包,并且后缀名不会被改变,当然需要配合assetsPath这个参数,设置图片的绝对路径。而CDN的路径可以通过process.env.CDN_URL从webpack传到前端代码中。


关于源码


关于lottie源码解析,这位老哥已经分析的挺到位了,Lottie原理与源码解析。尽管lottie也一直在迭代,但是顺着这篇解析应该也能理清源码。以及Svg动画的介绍,SVG 动画精髓



链接:https://juejin.cn/post/7001312313953222670

收起阅读 »