董懂 发布的文章

这段代码,你觉得会输出什么?

for (
  let i = 0, getI = () => i, incrementI = () => i++;
  getI() < 3;
  incrementI()
) {
  console.log(i);
}

别偷看答案,先想一下,是 0 1 2,还是 3 3 3,还是 0 0 0
.
.
.
.
.
. 密
. 封
. 线
.
.
.
.
.

答案是 0 0 0。为什么?这就要明白 for 循环的初始化块中的词法声明中,变量的作用域是什么。

for 循环的结构是这样的:

for (initialization; condition; afterthought)
  statement

其中的 initialization 叫做初始化块,通常用于声明计数器变量。condition 是每次循环之前要判定的条件的表达式。afterthought 是每次循环结束时执行的表达式。前面这些都是可选的。statement 是每次 condition 判断为真时要执行的语句。

对于初始化块的作用域,MDN 上这样解释:

初始化块的作用域范围可以理解为声明发生在循环体内部,但实际上只能在 condition 和 afterthought 部分中访问。更准确地说,let 声明是 for 循环特有的——如果 initialization 是 let 声明,那么每次循环体执行完毕后,都会发生以下事情:

使用 let 声明新的变量会创建一个新的词法作用域。
上次迭代的绑定值用于重新初始化新变量。
afterthought 在新的作用域中执行。

新的词法作用域会在 initialization 之后、condition 第一次被判定之前创建。initialization 内部的变量与每次迭代中的变量是不同的,包括第一次

根据这个解释,文章开头的代码的执行流程是这样的:

  1. 在初始化块中声明了一个变量 i,它的值是 1;声明了一个函数 getI,它读取并返回 i;声明了一个函数 incrementI,它首先读取并返回 i,之后将 i 增加 1。注意!这两个函数和变量 i 处于同一个作用域下,并且它们都引用了 i,在后续步骤中,这两个函数处于不同的作用域下,依然能够读取这里的 i,这就是闭包
  2. 在第一次循环时,创建了一个新的作用域,这个时候的变量 i,并不是步骤 1 中的变量 i!它们不在同一个作用域中。根据文档描述,它的值应该使用上次循环的值(文档中对此值的称呼为“上次迭代的绑定值”,你可以理解为 i 自增后的那个值),但现在是第一次循环,它的值应该取的是初始值,因此是 0(这在文档中没有描述,如果你知道哪里有确切的描述,欢迎在评论区指出)。在第一次的条件判断中,函数 getI 实际上读取的是步骤 1 中的 i,返回 0。在 statement 中,打印了当前的变量 i 的值,输出 0。最后,在 afterthought 部分,调用了函数 incrementI,而函数 incrementI 也是闭包,它读取的依然是步骤 1 中的变量 i 的值,因此,步骤 1 中的变量 i 此时是 1,而当前作用域中的变量 i 的值依然为 0,因为并没有什么地方改变它的值。
  3. 第二次循环,又创建了一个新的作用域,在此作用域中创建了新的变量 i,它的值是上次循环的值 0。其他流程和步骤 2 相同。此时步骤 1 中的 i 的值为 2,而本次循环中的 i 依然是 0。继续输出一个 0
  4. 重复步骤 3,步骤 1 中的 i 的值增加为 3,依然输出一个 0
  5. 不符合判断条件,循环退出。

从这段代码可以看出 for 循环的初始化块是如何创建作用域并进行词法声明的。通过闭包绑定初始的 i,可以做到不影响 statement 部分的 i 的值。

queueMicrotask 用于在微任务队列中添加一个微任务,从而保证代码在宏任务执行之前执行。

这篇文章中有一个批量发送消息的例子,代码如下:

const messageQueue = [];

let sendMessage = (message) => {
  messageQueue.push(message);

  if (messageQueue.length === 1) {
    queueMicrotask(() => {
      const json = JSON.stringify(messageQueue);
      messageQueue.length = 0;
      fetch("url-of-receiver", json);
    });
  }
};

包括 ChatGPT 在内的 AI 都不能正确解释这段代码的作用,MDN 上对这段代码的解释也比较难理解。

从表面上看,这段代码并没有什么难以理解的地方,但是,只有理解了 JavaScript 的任务队列、微任务、宏任务才能正确理解这段代码。

JavaScript 的任务队列分为两个:宏任务队列和微任务队列。JavaScript 执行完同步任务后,就会开始执行任务队列里的任务。主线程先检查微任务队列中是否有微任务,如果有,就执行它们,直到清空微任务队列,之后开始执行宏任务。与执行微任务不同的是,每执行完一个宏任务,就检查并执行、清空微任务。另外还有一个需要清楚的是,在执行微任务过程中,如果有新的微任务添加到微任务队列,JavaScript 会按顺序执行它们,直到清空微任务队列。

上面代码实现了消息的批量发送。当 messageQueue 的长度为 1 ,也就是第一次调用 sendMessage 时,queueMicrotask 会创建一个微任务(这里用 f 指代这个微任务),添加到微任务队列的末尾。在执行到 f 之前的这段时间内,调用 sendMessage 只会向 messageQueue 中添加新消息,不会再创建微任务。当执行到 f 时,将会把 messageQueue 序列化成 JSON,这个 json 包含了这段时间中添加到 messageQueue 中的所有消息,之后发送给服务器,并清空 messageQueue,开始下一次循环。


End

上一篇文章分享了一个 Region 区域折叠的方法,以及为不同折叠区域提供不同背景颜色的插件,这篇文章继续分享几个好用的 vscode 插件。

1、Better Comments

这个插件可以让注释以更鲜明的颜色显示出来,可以对不同作用的注释设置不同的颜色,如下图所示:

1.jpg

2、Highlight Line

如果你觉得 vscode 的当前行高亮不太显眼,可以使用这个插件来为当前行提供更加显眼的高亮:

2.jpg

上图中绿色的下划线就是这个插件的效果。也可以自定义线条的粗细、样式和颜色。注意上图已经把 vscode 自带的高亮效果关闭了。

3、Highlight on Copy

有些 vscode 主题,在选中一些文字后,高亮效果不太明显,导致不知道到底复制成功没有。这个插件可以在按下 Ctrl + C 后短暂高亮复制的文字,让你清楚地知道自己复制的内容。高亮的持续时间、高亮颜色、高亮时的文字颜色是可以设置的。

1.gif

4、JS Quick Console

这个插件可以快速插入 console.log(),只需要选中要打印的变量,然后按 Alt + Shift + L,就会在下一行插入 console.log('foo', foo)。如果不选中任何东西,直接按快捷键,将会插入空的 console.log()


End

0、介绍

regl 是一个简化 WebGL 编程的库,可以更轻松地编写 WebGL 程序。GL Transitions (github: gl-transitions) 是收集写好的 WebGL GLSL 程序的网站,用来实现转场效果。

有的视频剪辑软件,比如剪映,已经在浏览器中实现了视频转场特效的预览,其原理就是利用 WebGL 运行 GLSL 来实现的。GLSL 是一种运行在 GPU 中的语言,虽然不是很难,但想要在短时间内掌握也是不太现实的。

利用开头提到的两个工具,我们也可以实现一样的效果。目前 GL Transitions 网站上有六十多个转场效果,基本上能够满足常见需求。网站上还提供了在线编辑器,可以实现你自己想要的效果。

下面我们就一步一步实现视频转场。

1、转场流程

视频转场与图片转场不同。GL Transitions 网站上的视频转场例子,实际上也是把视频当做图片来转场了。在实际运用中,我们需要考虑到,视频需要完整播放完毕才能切换到下一个视频,不能像图片转场一样,每张图片都展示相同的时间。视频的转场效果需要运用在上一个视频播放即将结束、下一个视频即将开始播放的时候。

这张图大概描述了其流程:

1.png

假设转场效果持续时间为 duration,在上一个视频还差 duration 结束播放的时候,开始转场效果。

转场效果需要两个视频参与,以淡入淡出为例,上一个视频开始淡出的时候,下一个视频开始淡入。

转场的时间具体怎么安排,可以根据你的需要。比如,如果在第一个视频完全淡出之后,第二个视频才开始淡出,那么转场可能看起来是这样:第一个视频慢慢模糊,同时画面越来越黑,直到画面完全变黑;之后从黑到亮慢慢出现第二个视频的画面。这是因为视频变得越来越“透明”时,没有内容补充到黑色背景上去。如果这正是你想要的效果,那么就可以这样做——也就是根据你的需要。

2、准备视频资源

转场肯定是在多个视频之间进行的。使用 Promise.all 可以同时下载多个视频并确保全部下载完毕之后再使用。
首先准备一个容器,用来放 WebGL 生成的 canvas

<div id="canvas-box"></div>

之后需要一个创建 video 的函数:

const createVedio = src => {
    if (!src) return Promise.resolve(null);

    return new Promise((resolve, reject) => {
        try {
            let video = document.createElement('video');
            video.src = src;
            video.preload = true;
            video.autoplay = false;
            video.muted = true;
            video.controls = false;
            video.loop = false;
            video.crossOrigin = 'anonymous';
            video.oncanplaythrough = () => {
                resolve(video);
            };
        } catch (err) {
            reject(err);
        }
    });
};

在上面的函数中,如果视频资源是跨域的,即使服务器允许跨域,也需要为 video 设置 crossOrigin = 'anonymous',以允许 canvas 使用跨域资源。

需要注意的是,即使在上面的代码中设置了视频自动播放,在将视频作为纹理时,也可能只会显示黑屏,因为视频可能在等待其他资源下载时就已经播放完了。必须在用到视频时,调用视频的 play 方法。

假如我们有两个视频地址:

const url1 = 'https://xxx.xxx.xxx/xxx1.mp4';
const url2 = 'https://xxx.xxx.xxx/xxx2.mp4';

接下来就加载它们:

const videos = [];
Promise.all([createVedio(url1), createVedio(url2)]).then(r => {
  videos = r;
})

3、使用 regl 和 regl-transition 进行转场

首先需要导入相关库:

  import createREGL from 'regl';
  import GLTransitions from 'gl-transitions';
  import createREGLTransition from 'regl-transition';

如果没有安装以上三个库,请到 npm 官网搜索安装。接下来创建一个转场对象:

  const regl = createREGL({
    container: document.querySelector('#canvas-box')
  });
  //在 GLTransitions 库中搜索名称为 fade 的转场效果
  //这里固定使用这个效果,如果你想切换不同效果,请自行实现
  const fade = GLTransitions.find(t => t.name === 'fade');
  const transition = createREGLTransition(regl, fade);

transition 接受 3 个参数:progressfromto,分别表示当前的进度、前一个纹理,后一个纹理。其中 progress 的取值为 01,如果不需要应用转场效果,可以提供 0。

声明转场持续时长:

const transitionDuration = 1;

由于转场的开始和结束依赖视频的播放时间,所以,我们给 videos 进行如下处理:

  const slides = videos.map((video, index) => {
    return { video };
  });
  slides.forEach((s, i) => {
    const duration = i === 0 ? s.video.duration : s.video.duration - transitionDuration;
      if (i === 0) {
        s.timeline = duration;
      } else {
        s.timeline = duration + slides[i - 1].timeline;
      }
  });

上面的代码中,使用 timeline 保存了了每个视频结束的时间点。
从下图中可以看出,由于播放时间重合,因此除了第一个视频之外,后面的每个视频都会提前 transitionDuration 开始播放,而且提前量是累加的,比如第二个视频,比原来提前 1 秒播放,第三个视频比原来提前 2 秒播放,以此类推。

2.jpg

之后就是使用 regl.frame 方法播放视频,并在特定的时间进行转场。

regl.frame 方法的回调函数提供了一个对象,其中 time 表示从开始运行 regl.frame 到当前帧一共过去了多长时间。

const len = slides.length;
const endTimeline = slides[len - 1].timeline;

//初始化纹理
//纹理必须重用,不能在下面的 frame 中重复声明,否则会造成内存泄漏,WegGL 崩溃
let fromTexture = regl.texture();
let toTexture = regl.texture();

let progress = 0;

tick = regl.frame(({ time }) => {
  // 播放到最后时停止
  if (time >= endTimeline) {
    return tick.cancel();
  }

  // 当前播放视频的索引
  const index = slides.findIndex(ele => ele.timeline >= time);

  const curr = slides[index];
  //确保当前视频正在播放
  curr.video.paussed && curr.video.play();

  //第一个视频,还没播放到开始转场的时间
  //此时不需要 transition 的 to 参数,下同
  if (index === 0 && time < curr.timeline - transitionDuration) {
    progress = 0;
    //重新初始化纹理,用来捕捉当前视频画面,下同
    fromTexture(curr.video);
  } 
  //当 index 到最后一个视频时,最后一个视频已经参与了转场,后面都不会再转场了
  else if (index === len - 1) {
    progress = 0;
    fromTexture(curr.video);
  } else {
    //这里是正在转场的阶段
    if (time >= curr.timeline - transitionDuration && time <= curr.timeline) {
      //progress = (当前时间 - 转场开始时间) / 转场持续时间
      progress = (time - (curr.timeline - transitionDuration)) / transitionDuration;

      播放下一个视频
      const next = index === len - 1 ? curr : slides[index + 1];
      next.video.paused && next.video.play();

      fromTexture(curr.video);
      toTexture(next.video);
    }
    //以第二个视频为例,这里代表视频处于上一个转场完毕、下一个转场未开始的播放阶段
    else {
      progress = 0;
      fromTexture(curr.video);
    }
  }

  transition({ progress, from: fromTexture, to: toTexture });
});

4、总结

本文的难点在于确定转场的开始和结束时间。另外,还需要注意视频的播放状态,视频只有处于播放状态,才能够获取到它的内容作为纹理。另外还需要注意纹理的复用,一定不要在 regl.frame 回调中重复声明纹理,否则会造成严重的性能问题,甚至电脑黑屏。


End

今天 vscode 的 Vue 官方插件 Vue-Official 更新了,其中隐藏了一个小彩蛋:当你把鼠标放到任意组件的属性上,如果这个属性是一个内联事件处理器,它的类型提示小弹窗的事件参数名会显示成 __0_0,如图:

1.png

2.png

这个字符 0是数字 0 哦,这是我复制的。
在枯燥的工作之中看到这个呆萌的字符表情,也能够开心一下~
工作愉快~


End

1、问题重现

有下面的 HTML 结构(没错,我说的就是 Element Plus 的 Dialog 组件):

/* css */
.div, .inner-div {
  position: fixed;
  inset: 0;
}
.video {
  height: 500px;
}
<!-- html -->
<div class="div">
  <div class="inner-div">
    <video class="video" autoplay controls src="http://xxx.com/xxxx3.mp4"></video>
  </div>
</div>

video 的两个父元素 .div.inner-div 都是 fixed 定位。一切看起来都没什么问题,直到你想点击 video 控件最右侧的三个点按钮,你会发现毫无反应。

2、video 的弹出菜单哪去了?

在 Chrome 开发者工具的设置中,勾选“显示用户代理 Shadow DOM”,再返回“元素”面板,就能看到 video 元素的内部样式。这时你会发现,点击三个点其实是有反应的,对应的元素在下图的红圈中:

1.jpg

为了节省点流量,图片经过压缩,有点模糊不清,请看官谅解。下同。

查看其样式,可以发现它的 position 计算结果非常奇怪:

2.jpg

经过尝试,positiontoprightbottom 的值不受外面的样式影响,它是浏览器内部计算出来的。也就是说,不管你在 video 的父容器上怎么折腾,出现异常的 toprightbottom 的值永远是 539.33、1507.330、-144,至少在我的浏览器里是这样的(Chrome 125.0.6422.142)。

3、能否使用伪类选择器(-webkit-xxxx)矫正错误的定位?

答案是不能。经过尝试,在 Shadow DOM 中,如果一个元素有 pseudo="-webkit-xxxx" 这样的属性,那么它就可以使用属性值作为伪类选择器,从而改变它的样式:

video::-webkit-xxxx { }

不幸的是,咱们的目标元素的 pseudo 属性的值为 -internal-media-controls-overflow-menu-list,单词 internal 就说明了它是内部的,外部无法影响它。

4、Fixed 定位能从相对于窗口(视口,viewport)改为相对于其他元素?

答案是可以。没想到吧.jpg!MDN 文档对此有详细的描述:

元素会被移出正常文档流,并不为元素预留空间,而是通过指定元素相对于屏幕视口(viewport)的位置来指定元素位置。元素的位置在屏幕滚动时不会改变。打印时,元素会出现在的每页的固定位置。fixed 属性会创建新的层叠上下文。当元素祖先的 transformperspectivefilterbackdrop-filter 属性非 none,容器由视口改为该祖先。

真的没想到.jpg!video 元素的三个点弹出菜单就是 fixed 定位。

不过,值得指出的是,这个弹出菜单设置了相对于三个点图标的定位——锚点定位:

/* css 弹出菜单 */
position-anchor: --internal-media-control-button-anchor;
/* css 三个点图标 */
anchor-name: --internal-media-control-button-anchor;

这指定了弹出菜单相对三个点的图标来确定位置。不过这不影响本文的结论。

5、如何利用上面的特点?

Chrome 是如何计算出上面第 2 点那些 toprightbottom 值的不得而知,因为这是浏览器内部实现的。既然这个弹出菜单的位置能够被外部影响,那么我们可以尝试利用上面第 4 点提到的 fixed 定位的特性,看看能不能让这个弹出菜单相对一个咱们自己的元素来定位?

根据上面提到的 MDN 文档,让任意一个 video 的父元素的 transformperspectivefilterbackdrop-filter 属性值不为 none,就会让 video 中的弹出菜单变成相对其进行定位。尝试给 .inner-div 设置 transform

.inner-div {
    transform: translate(0, 0);
}

刷新页面,点击三个点图标,菜单仍旧不能显示出来。难道这个方法不起作用吗?改为给 .div 设置 transform

.div {
    transform: translate(0, 0);
}

刷新页面,点击 video 的三个点图标,这次弹出菜单出来了!

经过多次尝试,发现了下面的规律:

  1. 如果 video 有多个 fixed 定位的父元素,比如层次结构为 div1 - div2 - div3 - video,那么给最外层div1 设置 transformperspectivefilterbackdrop-filter 才会起作用
  2. 除了 div1, 给 body(包含) 到 div1 之间的任意 video 的父元素设置以上 4 个 css 属性都会起作用。

例如下面的代码:

body { perspective: 0; } /* 起作用 */
a { filter: blur(0); } /* 起作用 */
div { position: fixed; }
.div1 { transform: translate(0, 0); } /* 起作用 */
.div1 { filter: blur(0); } /* 起作用 */
.div1 { perspective: 0; } /* 起作用 */
.div1 { backdrop-filter: blur(0); } /* 起作用 */
.div2 { transform: translate(0, 0); } /* 不起作用 */
.div3 { transform: translate(0, 0); } /* 不起作用 */
<body>
  <a>
    <div class="div1">
      <div class="div2"
        <div class="div3"
          <video src="xxxx.mp4" controls autoplay></video>
        </div>
      </div>
    </div>
  </a>
</body>

下图展示了 filter: blur(1px) 的作用。可以看到视频内容有些模糊,这是 blur 滤镜起的作用,而弹出菜单显示出来了:

3.jpg

视频内容为直播带货_0-O

6、需要注意的地方

最好使用除了 transform 之外的那 3 个 css 属性,因为 transform 是常用的 css 属性,可能会被其他样式覆盖,比如,如果其他地方为上面代码的 .div1 设置了 transform: translate(-50%, -50%),受此影响,video 的弹出菜单不会显示在正确的位置。

UPDATE: 2024-10-23
CSS 已经支持某些 transform 属性简写了,例如 transition: -50%, -50%;scale: .5;
具体可查看 MDN 上的文档

7、另一种解决办法

这种办法原理特别简单:隐藏原生控件,自己实现一套控件。就是有点麻烦。

8、结束

如果哪位大佬阅读了 Chrome 相关源代码,知道其中的原理,请不吝赐教。期待 Chrome 修复此 Bug。


END

问题及原因分析

RFID 阅读器在读取到数据后,会寻找具有焦点的 TextField,并将读取到的数据输出到 TextField 中,之后发送一个回车按键事件。

这里有个问题,不能使用 TextFieldonChanged 方法来获取输入框中的文字,因为此事件会触发多次。也不能使用 onEditingComplete 或者 onSubmitted,因为在 TextField 接收完数据之前,就会触发回车键,导致 TextField 失去焦点,并中断接收数据。至于为什么回车事件会先于数据接收完成之前执行,我猜测,大概是因为不断重建 TextField Widget 的过程消耗了太多时间,同时 Flutter 内部存在一个类似 JavaScript 的事件循环机制,导致回车事件排在了接下来的 onChanged 事件之前。说实话,我是一个初学者,对 Flutter 了解不够深入。

解决思路

想要解决这个问题,貌似不能从 TextField 本身的属性配置上来下手了。既然 TextField 接收数据有延迟,那就需要定时检测 TextField 关联的 TextEditingController.text 在一定时间内是否还会变化,根据变化情况来确定数据是还在接收,还是已经接收完毕。

焦点的问题

在实现上面的定时检测代码之前,还有一个问题需要解决:焦点问题。上文提到了,TextField 在没接收完数据之前就会触发回车事件并失去焦点。一旦 TextField 失去焦点,便不再接收剩余的数据。经过不断尝试,将 TextFieldkeyboardType 设置为 TextInputType.multiline 能够解决这个问题。

以下是官方文档对 TextInputType.multiline 的解释:

Optimize for multiline textual information.

Requests the default platform keyboard, but accepts newlines when the enter key is pressed. This is the input type used for all multiline text fields.

优化多行文本信息。

请求默认平台键盘,但按下enter键时接受换行。这是用于所有多行文本字段的输入类型

但设置了这一属性,TextField 里面的文字在遇到回车时不就换行了吗?没有关系,只要我们不设置它的 maxLines 属性为 1 以外的值,这就不会发生。maxLines 的默认值就是 1,所以,总而言之,我们只要设置 keyboardType 属性为 TextInputType.multiline 就行了,无需考虑其他问题。

定时检测的实现

下面需要一个定时器来检测 TextField 的内容。核心思想是,定时检查 TextFieldTextEditingController.text 的值是否与上一次保存的旧值相同,如果不同,说明接收仍在继续,这时要将新值保存起来,用于下一次对比。如果新值和旧值相同,就认为数据接收已经完毕。

代码如下:

final inputController = TextEditingController();

Timer? timer;
int count = 0; //设置了一个定时器执行次数
String oldVal = '';

timer = Timer.periodic(const Duration(milliseconds: 500), (_timer) async {
  if (
       count >= 10 || 
       (
          inputController.text != '' && 
          oldVal != '' && 
          inputController.text == oldVal
       )
  ) {
    _timer.cancel();
    //do something you want...
  } else {
    count++;
    oldVal = inputController.text;
  }
});

END

上一篇文章的自定义指令非常简陋,使用场景仅限于静态的、不再改变的图片,如果用在列表中,会因为没有处理 update 事件,图片不会更新。

在原来的代码中,依赖原图片的 onload 事件获取原图像的尺寸,并最终用新生成的图片替换了原图片,导致自定义指令的 updated 钩子传入的参数 el 在 DOM 里已经是不存在了,也就没办法找到并更新这个图片了。

由于上述的缺点,本篇文章将换一种思路,不再替换原图像,而是替换原图像的 src,也不再从原图像的 onload 事件中获取其尺寸,而是获取原图像的 src,新创建一个 img 元素,在这个新 img 的 onload 事件中获取尺寸。

在 Vue 的自定义指令中,如果只需要关注 mounted 和 updated 钩子,那么可以使用简写形式,即直接返回一个函数。本篇文章就使用了简写的形式,新的自定义指令代码如下:

export default (el, binding, vnode) => {
    const _img = document.createElement('img');
    _img.crossOrigin = 'anonymous';
    _img.onload = () => {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        const thumbWidth = binding.value ?? 50;
        const thumbHeight = _img.naturalHeight * thumbWidth / _img.naturalWidth;
        canvas.width = thumbWidth;
        canvas.height = thumbHeight;

        try {
            ctx.drawImage(_img, 0, 0, thumbWidth, thumbHeight);
        } catch (error) {
            console.error(error);
            return;
        }

        el.src = canvas.toDataURL();
    };
    _img.src = vnode.props.src;
}

代码比原来简单得多。在使用上也比原来简单,不需要添加 crossorigin 属性了:

<img v-thumb :src="xxxx" >

End

这个自定义指令本不应该存在。

正常工作中,要显示一个图片,为了性能考虑,肯定是显示它的低分辨率版本。可最近在做的一个项目中,服务器直接提供了原图用来显示,即使只用来当做一个小图标。这导致一个网页消耗的流量达到了近 100MB,并严重拖累页面性能,连鼠标的 hover 样式都延迟很久才会响应。

可惜奇葩的后端目前没有解决这个问题的打算,产品同学也觉得这样还是很 OK,即使解决这个问题很容易——就是在生成图片的时候多生成几个不同分辨率的版本。

下载网页资源消耗的是公司服务器和用户的流量,自然不需要操心,也不是前端同仁能够管得了的。显然用户用起这个产品来并没有感到不适,公司也不在乎服务器多消耗的那点流量。但页面性能变差,作为一个前端老师傅,这能忍?本文的自定义指令正是用来解决这个问题的,正所谓改变不了别人,还改变不了自己么?

既然图片太大拖累了页面性能,那么在图片下载完毕之后,生成一个低分辨率的版本替换它,问题应该就解决了。代码很简单,全部贴上来。因为才疏学浅,肯定有考虑不周的地方(实际上没有考虑),请不吝指教。

//新建一个 thumb.js
export default {
    mounted(el, binding) {
        el.onload = () => {
            //同是天涯沦落人
            if (el.src.startsWith('data:image')) return;

            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            //默认宽度给50是不是有点小气了?
            const thumbWidth = binding.value ?? 50;
            //naturalHeight 和 naturalWidth 只有在 onload 之后才能获取到
            const thumbHeight = el.naturalHeight * thumbWidth / el.naturalWidth;
            canvas.width = thumbWidth;
            canvas.height = thumbHeight;
            ctx.drawImage(el, 0, 0, thumbWidth, thumbHeight);

            //将原来图片上的属性全复制过去,除了 src
            const attrs = el.attributes;
            const img = document.createElement('img');
            img.src = canvas.toDataURL();
            //如果你不喜欢 for 循环遍历 NamedNodeMap,还能用 Array.prototype.forEach.call
            for (let i = 0; i < attrs.length; i++) {
                const attr = attrs[i]; //这是一个 Attr 对象
                if (attr.name === 'src') continue;
                img.setAttribute(attr.name, attr.value);
            }

            if (el.parentNode) {
                el.parentNode.replaceChild(img, el);
            }
        };
    }
};

注意你不能直接替换原图片的 src 属性,因为这会循环触发 onload 事件。

使用这个自定义指令:

import vThumb from 'thumb.js'; //在 setup 中,以 v开头的驼峰形式命名的变量会自动注册为指令
<!-- 注意跨域的问题,如果图片不允许跨域,那么半天白忙活了 -->
<img v-thumb src="beauty.jpg" crossorigin="anonymous">
<img v-thumb="100" src="beauty.jpg" crossorigin="anonymous">

使用的时候还要注意它的局限性,例如并没有处理绑定到原图片上的事件。


End

Vue 3 使用了组合式 API,刚上手的同学可能会觉得写出来的代码非常凌乱。以前在使用选项式 API 时,要寻找一个方法,最起码知道要到 Methods 中去寻找。然而现在代码结构都在同一级别,即使精心组织代码,有时候也会难以找到目标代码。

Vue 3 提供了组合式函数,它不仅仅可以用来封装通用功能,也用来组织代码结构。你可以将相关联的一组代码封装到一个组合式函数中。由于组合式函数与 React 中的 Hooks 非常相似,所以这里也简称它为 hooks 吧。

然而写组合式函数也是要消耗一些心智的,有时候还可能会消耗额外的精力处理一些状况,如果代码直接写在 setup 中,这些情况是不会出现的。

幸运的是,Visual Studio Code 提供了丰富的便捷功能和许多强大的插件,组合使用这些能力,可以让我们在懒得写 hooks 时也不会迷失在代码海中。

首先推荐区域折叠 - region。这是 Visual Studio Code 提供的自定义折叠区域的方法,用过 Visual Studio 的同学一定不陌生。下面介绍使用方法。

首先定义区域开始位置。在想要折叠代码上一行弄个空行出来,输入 #region。通常,你不必全部输入这个单词,只要输入前几个字符,代码提示就会出现了,这时你只需要按回车,#region 就会补全了。你可以在 #region 后面输入此段代码的描述,在折叠此区域后,描述会继续显示。

接着定义区域结束位置。在想要折叠的代码最后一行弄一个空行,输入 #endregion。同样地,也不必输入完整,代码提示会帮你搞定。

此时,在 #region 行左边就会出现折叠符号,点击就可以折叠这个区域,就像折叠一个 HTML 元素或者一个 JavaScript 方法。

你可以把有关联的一组代码放到一个区域并折叠起来,需要的时候再展开。通常,Visual Studio Code 会记住你的折叠状态,这意味着即使你关闭了这个文件并再次打开,那些已经折叠的区域也不会自动展开。

当你新建了好多个折叠区域,会发现又出现了折叠区域难以分辨的问题,毕竟每个折叠区域折叠后,都有着一样的灰色背景,即使有描述,这些描述文字也几乎融合在灰色背景里,类似动物的保护色——它们的颜色只比背景颜色深一点。因此你还是需要努力寻找想要的代码。

这时候插件 Region Highlighter 可以解决这个问题。它可以给不同的折叠区域不同的背景颜色,你也可以配置自己喜欢的背景颜色。
它的使用方法是,按 Ctrl + Shift + P 打开命令面板,执行 Region Highlighter: Mark Region 命令,在弹出的输入框中输入此区域的描述,回车。效果如下:

1.png

图上是我比较喜欢的颜色,淡雅清爽,一如本博客的作者——他们都这么说 :D

最后还要提醒你,别忘了折叠代码的快捷键 Ctrl + Shift + [Ctrl + Shift + ]


END