董懂 发布的文章

0、介绍

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

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

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

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

1、准备视频资源

转场肯定是在多个视频之间进行的。使用 Promise.all 可以同时下载多个视频。
首先准备一个 canvas 元素:

<canvas id="canvas"></canvas>

之后需要一个创建 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 = true;
            video.muted = true;
            video.controls = false;
            video.loop = false;
            video.crossOrigin = 'anonymous';
            video.oncanplaythrough = () => {
                resolve(video);
            };
        } catch (err) {
            reject(err);
        }
    });
};

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

另外,如果视频没有处于播放状态,regl 获取到的视频纹理是全黑的画面,但如果同时加载很多个视频,并让它们都播放的话,可能会影响浏览器甚至是你的电脑的性能。所以,如果视频的数量比较多,最好是在用到视频作为纹理之前再播放它,用不到的视频如果没有自动停止播放,需要手动停止播放。

在本文中,我们假设只有两个视频,所以上面的代码设置了 video.autoplay = true。如果视频没有播放,还需要手动调用 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;
})

2、使用 regl.frame 渲染

未完待续

今天 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

6、需要注意的地方

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

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

相信大家肯定都遇到过题目中所说的那种需求,在 app.onLaunch 中获取一些信息,在 Page 中使用。然而这些生命周期钩子都是异步执行的,并没有特定的执行顺序。这就需要开发者自己控制生命周期钩子的执行顺序。

小程序设计得已经比较糟糕,性能低的同时还有许多不可知的奇怪的 bug (关于性能,他们推出了 SkyLine,暂时没有使用过)。而 uni-app 在小程序的基础上,还要兼容更多不同厂家的小程序,还要能够编译成各端代码,莫名其妙的 bug 就更多了。

关于题目中的需求,我尝试了 2 种方法,一是回调函数法,就是在 app.onLaunch 执行完毕后执行一个回调函数,用来初始化页面;二是 Promise.resolve 法,就是在 Page.onLoad 中使用 await 执行一个函数,将其 Promise 的 resovle 回调函数存储起来,这样一来,await 之后的代码就处于等待状态;在 app.onLaunch 的最后调用那个 resolve,结束 Page.onLoadawait 的等待状态。

遗憾的是,虽然网上有很多成功案例,但经过尝试,截止到本文发表的时刻,在最新的 HBuilder X 环境下,这两种方法并不能保证 app.onLaunchpage.onLoad/onShow 先后执行。

除了这两种方法,网上还有诸多神奇的解决方案,但我并没有尝试。最后我使用了最“笨”的方法,但感觉是最稳妥的办法,那就是 setInterval 大法!

setInterval 平常总是被嫌弃,就连实现倒计时,也要用 setTimeout 代替它。没想到最后它解决了我的问题,而且非常稳健!

思路非常简单:
app.globalData 中声明一个变量,比如叫 launched,初始值是 false,用来表示 app.onLaunch 是否执行完毕。然后,在 app.onLaunch 的最后,把它设置为 true
与此同时,页面中的 onLoad 或者 onShow 中设置一个 interval,一旦检查到 getApp().globalData.launched === true,则取消定时器,并执行初始化。

由于 uni-app 使用的是 Vue,所以可以在 main.js 中添加全局的 mixin 来执行 onLoad/onShow,若符合条件,则在 onLoad/onShow 中执行页面中约定好的方法,例如,若 mixin 中的 onLoad 检测到小程序已经执行完 onLaunch,则进一步检查当前页面中是否有约定好的方法,如 _onLoad,若有则执行。这样,就不用在每个页面中的 onLoad/onShow 中写重复的代码了。

由于比较简单,就不上代码了。

以前觉得写小程序简单,因为只需要在小程序那“简单的”框架中写代码,所能做的东西有限。现在终于明白,想要实现一些在 H5 中普通的功能,小程序需要更多的尝试。


End

如果你平时老是需要编写各种列表,例如商品列表、图片列表,不妨自己写一个通用的列表组件,只需要传入几个参数,就能帮我们自动分列、自动调整间距。下面就是一个实现起来非常简单,但真的很好用的自定义列表组件。

定义

直接上完整的代码:

<script setup>
const props = defineProps({
    data: {
        type: Array,
        default: []
    },
    columnCount: {
        type: Number,
        default: 2
    },
    gap: {
        type: Number,
        default: 10
    }
});

const emit = defineEmits(['select']);
</script>

<template>
    <div class="list" :style="{ gap: `${gap}px` }">
        <template v-for="i in data">
            <div class="item" :style="{ 'flex-basis': `calc((100% - ${columnCount - 1} * ${gap}px) / ${columnCount})` }" @click="emit('select', i)">
                <slot :row="i"></slot>
            </div>
        </template>
    </div>
</template>

<style lang="scss" scoped>
.list {
    display: flex;
    flex-wrap: wrap;

    >.item {
        flex-shrink: 0;
        flex-grow: 0;
        cursor: pointer;
    }
}
</style>

在 script 部分,这个组件定义了 3 个属性,data是需要传入的数组形式的数据,就像 el-table 的 data 属性。columnCount是列数,可以是任意整数列,默认值为 2,这也是最常用的列数。gap是每一项与其他项的边距。如果你需要实现其他特定,可以非常容易地自己增加其他参数。

模板部分,列表使用了 flex 布局,根据参数自动计算每一列的宽度。还提供了一个默认插槽,用来自定义每一项里面的内容。

使用

使用起来非常简单,假设我们将上面的代码保存到一个叫 CommonMultiColumnList.vue 的文件中,首先引入这个组件:

import CommonMultiColumnList from 'CommonMultiColumnList.vue';

然后在模板中使用:

<CommonMultiColumnList :data="someData" :column-count="1" :gap="15">
    <template #default="{row}">
        <!-- 这里是自定义的每一项的结构 -->
    </template>
</CommonMultiColumnList>

现在只需要编写每一项的结构和样式,列表就会展示出来了。
如果你希望更加方便,也可以在组件中写好每一项的样式,而不是提供插槽,并多建立几个每一项样式不同的组件。


End