普通视图

发现新文章,点击刷新页面。
昨天以前戴兜的小屋

优雅地实现滚动容器遮罩

作者 戴兜
2023年12月9日 14:00

在设计前端页面时,常常会遇到这种情况:可滚动容器的边界并非父容器的边界,导致子元素溢出造成裁切,让页面产生比较怪异的视觉效果(左图)

添加遮罩之后,效果自然了许多(右图)

纯色遮罩

以上图的这种情况举例,我们需要做的,是在可滚动容器的顶部和底部分别放置一个线性渐变的纯色遮罩,遮挡生硬的裁切线。创建两个元素 .top-mask.bottom-mask 来作为遮罩,遮罩的颜色与父容器背景一致,使用 absolute 定位。

.top-mask {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 24px;
    background: linear-gradient(to top, transparent 0%, white 100%);
}

.bottom-mask {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 24px;
    background: linear-gradient(to bottom, transparent 0%, white 100%);
}

直接为可滚动容器设置 position: relative 是行不通的,这会导致遮罩跟随容器滚动。所以需要在可滚动容器外部再嵌套一层 relative 定位的元素,使两个遮罩根据其位置定位,最终的结构大概是下面这样的:

<!-- 无遮罩 -->
<div class="out-container">
    <div class="scrollable-container">
        <!-- 很多很多的子元素 -->
    </div>
</div>

<!-- 有遮罩 -->
<div class="out-container">
    <div class="relative-container">
        <div class="top-mask"></div>
        <div class="bottom-mask"></div>
        <div class="scrollable-container">
            <!-- 很多很多的子元素 -->
        </div>
    </div>
</div>

Codepen 查看演示

后续为了优化视觉效果,可以根据条件显示/隐藏对应的 mask 元素(滚动条在顶部时不显示 top-mask,反之亦然)

改进:Alpha 遮罩

上面的这种方法有许多缺陷:

  1. 引入了许多额外的元素,致使整体布局变得复杂。
  2. 蒙版覆盖在可滚动容器之上,需要使用 pointer-events: none; 避免影响滚动操作。
  3. 仅适用于父容器为纯色的场景,在父容器有透明度、有背景图案或渐变时,遮罩会露馅。

是否有一种方法,在不引入额外元素、不使用绝对定位的条件下,解决这些缺陷呢?这时候就可以用到 mask CSS属性。mask 属性允许提供一张图片作为蒙版,改变元素的可视区域。我们只需要生成一个线性渐变,将其作为可滚动容器的蒙版即可。

使用linear-gradient创建一个多段的线性渐变,得到图中的蒙版效果。

linear-gradient(to bottom, transparent 0%, white 25px, white calc(100% - 25px), transparent 100%)

接着,将得到的渐变图案作为 mask 应用到滚动容器上,为了便于自定义,将这里的遮罩高度 25px 提取出来,以 CSS 变量的形式提供。下面是完整的样式:

.scrollable-container {
  --show-top-mask: 0;
  --show-bottom-mask: 0;
  --mask-size: 25px;
  --gradient: linear-gradient(to bottom, transparent 0%, white calc(var(--show-top-mask) * var(--mask-size)),white calc(100% - calc(var(--mask-size)*var(--show-bottom-mask))), transparent 100%);
  -webkit-mask: var(--gradient);
  mask: var(--gradient);
}

.top-mask {
  --show-top-mask: 1;
}

.bottom-mask {
  --show-bottom-mask: 1;
}

因为我们将容器两侧的遮罩合并到了一个线性渐变中,想要控制其中一侧的遮罩就不太容易了,为了实现遮罩的独立控制,额外定义了 --show-top-mask--show-bottom-mask 变量。最终的效果如下图所示:

CodePen 查看

写一个炫酷的个人名片页✨✨

作者 戴兜
2023年4月11日 00:13

这篇文章主要介绍名片页的路由过渡是如何去做的

介绍

在19年,我就写了一个较为炫酷的个人名片页。当时的我热衷于使用各种过渡效果,当然,也尝试了很多新鲜的 css 特性,例如为了实现多种主题色使用了 css 变量(好像还是我首次使用flex布局呢)

但当时的我显然还尚未深谙前端布局之道🤯,许多页面元素在当时的浏览器渲染是正常的,现在却有些崩坏了,很多细节处理不完善,遂准备将这个名片页进行重制

不过还是有挺多小巧思在里面的。比如为了防止滚动导致卡片被切开,给容器加了一个伪元素实现的阴影。实现很简单,效果却非常不错。

现在重制也基本上完成了(还剩几个页面没写完,不过无伤大雅),可以先看看效果 im.daidr.me

不难看出整体的页面风格和以前非常相似,不过确实很符合我对「炫酷」的想象

技术栈

Vue3 + WindiCSS + SCSS + Nuxt

这个名片页其实在去年12月就开始写了,刚开始没做SSR,最近尝试迁移到了Nuxt,路由动效之类兼容也很折磨,不过这不是这篇文章的重点,就不多说啦~

文章页是前几个星期才刚加上的,目前是把旧WordPress博客当CMS用,但是有些太重了——给文章页加上了Redis swr缓存,才能勉强保证流畅访问。最近在食用基于 Crossbell 链的 xLog,感觉非常不错,希望研究明白之后能把文章页接入 xLog😗。

分析

路由结构

首先是路由结构(关系到之后页面如何进行变换)。

目前,这个名片页有5套页面 /me(别名 /),/friends/projects/blog/:slug/404。他们的结构是这样的:

├── `/`
│   ├── `/me`
│   └── `/friends`
├── `/projects`
├── `/blog/:slug`
└── `/404`

因为 /me 和 /friends 页面的容器大小一致,翻牌的效果不太合适,所以互相切换时使用另外一套过渡效果。

(现在看来这种实现并不合适,维护和自定义都会比较困难。Nuxt有自带的路由过渡配置选项,不需要依赖子路由来实现。)

容器定位

能够将一个容器固定在页面正中心的方法其实蛮少的,我使用的是 fixed 绝对定位。先给元素设置 top: 50%; left: 50%;,但这时候的元素并不在页面正中心(而是其左上角在页面中心)

所以需要接着设置 transform: translate(-50%, -50%);,将元素向左/向上偏移。

翻牌过渡

来讲讲图片中的翻牌过渡是如何实现的。(仅考虑翻牌元素本身)

下面的代码各位 Vuer 一定不陌生,这能让 vue-router 在切换页面时应用过渡效果

<router-view v-slot="{ Component }">
  <transition name="fade">
    <component :is="Component" />
  </transition>
</router-view>

而实际上,Transition 组件可不仅仅能传递 name,还能够通过 JavaScript 具体控制过渡的每个环节。将过渡动效的相关逻辑封装到 RouterTransition 中:

<!-- App.vue -->
<RouterView v-slot="{ Component }">
    <RouterTransition>
        <component :is="Component" />
    </RouterTransition>
</RouterView>

<!-- RouterView.vue -->
<Transition 
     ref="SlotRef" :css="false"
     @before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter"
     @before-leave="onBeforeLeave" @leave="onLeave"
>
    <slot />
</Transition>

分析

使用 JavaScript 去控制过渡,我们就需要知道过渡前后元素的尺寸以及位置,拿到元素倒是好办,但是这里有一个问题:需要应用过渡的元素并不一定是页面根元素

比方说 /projects 页面,只有顶部的菜单栏应用了过渡。所以需要有一个手段去标识这些元素。我使用的方法是为需要过渡的元素加上类名 transition-page-wrapper

写一个工具函数,传入页面根元素,返回需要过渡的元素

const getTransitionContainer = (el) => {
    const containerClass = 'transition-page-wrapper';
    // 如果el不是元素,直接返回
    if (!el || !el.classList) {
        return el;
    }
    // 如果el是目标元素,直接返回
    if (el.classList.contains(containerClass)) {
        return el;
    }
    // 否则,遍历el的所有层级的子元素,找到目标元素
    for (let i = 0; i < el.children.length; i++) {
        const child = el.children[i];
        if (child.classList && child.classList.contains(containerClass)) {
            return child;
        } else {
            const _child = getTransitionContainer(child);
            if (_child != child) {
                return _child;
            }
        }
    }
    return el;
}

过渡开始前

之后,路由切换前的页面元素会被称为 fromEl,路由切换后的页面元素会被称为 toEl

首先,我们来搞定 before-leave 事件。在这个函数中,我们需要将 fromEl 的位置、尺寸信息记录下来,为了保证过渡顺滑,我还准备额外记录 border-radius 属性。

const fromWrapperStyle = {
    x: 0,
    y: 0,
    w: 0, // 宽度
    h: 0, // 高度
    br: 0, // border-radius 属性
    t: "", // transform 属性
};

其中,xywh 的值可以使用 getBoundingClientRect 方法取到。而 b(border-radius) t(transform) 是 css 样式属性,元素的 style 属性只能拿到其内联样式,为了拿到浏览器计算之后元素的所有准确 css 样式,需要使用 getComputedStyle 方法。封装成 writeCfgObj 工具函数方便之后使用:

const writeCfgObj = (el, cfgObj) => {
    const elRect = el.getBoundingClientRect();
    cfgObj.x = elRect.x;
    cfgObj.y = elRect.y;
    cfgObj.w = elRect.width;
    cfgObj.h = elRect.height;
    const _style = getComputedStyle(el);
    cfgObj.br = parseFloat(_style.borderRadius.replace("px", ""));
    cfgObj.t = _style.transform;
}

transition 组件的 before-leave 事件有一个参数,该参数会传递将在过渡中消失的元素(即 fromEl

const onBeforeLeave = (fromEl) => {
    // 根据根元素,获取实际需要过渡的元素
    let _fromWrapper = getTransitionContainer(fromEl);
    
    // 之前写的工具方法,用于存入元素位置/尺寸/部分样式
    writeCfgObj(_fromWrapper, fromWrapperStyle);
}

有了 fromEl 的位置/尺寸,接下来就是 toEl 的位置尺寸了,可以通过 before-enter 事件拿到

需要注意和 before-leave 不同的是:此时的 toEl 实际上还没有被插入到 dom 树中 (都插入进去了还过渡什么),此时元素的位置和尺寸都没法直接获取,我们需要一些额外的步骤。

const onBeforeEnter = (toEl) => {
    // 复制一份 toEl
    let toWrapper = toEl.cloneNode(true);
    // 禁用过渡,防止元素自带 transition 的情况下,之后设置 opacity 出现不必要的穿帮
    toWrapper.style.transitionDuration = '0s'
    // 设置 opacity 为 0
    toWrapper.style.opacity = 0;
    // 插入到 body
    document.body.appendChild(toWrapper);

    // 取得克隆后容器内的过渡元素
    let _toWrapper = getTransitionContainer(toWrapper);
    writeCfgObj(_toWrapper, toWrapperStyle);
    
    // 移除
    toWrapper.remove();
}

实际上就是将 toEl 克隆一份插入到 dom 中,获取完位置立刻删掉。因为 opacity 被我们设置成了 0,此时元素不可见,用户其实不太会感知到。

TransitionGroup 的实现其实差不多

过渡进行中!

拿到了 toElfromEl 的这些属性,过渡就可以开始啦!过渡主要会使用到 tranform 元素

不过先别急😜,在开始过渡之前,我们需要算出 toElfromEl 的位置和尺寸差值,这样我们才方便使用 translatescale 对元素应用变换。

这里需要注意的是:我们对元素应用变换使用了 transform 属性,而元素本身可能就有位移。过渡的过程中,我们会对其进行覆盖,所以计算时千万别忘了把元素本身的位移考虑进去。

const calcDelta = (prevCfg, nextCfg, nextMatrix3dStr) => {
    const matrix3d = nextMatrix3dStr.replace(/matrix3d\(|\)/g, "").split(",").map((v) => parseFloat(v));
    // 转换为 translate
    const nextTranslateX = matrix3d[12];
    const nextTranslateY = matrix3d[13];

    // 计算 scale
    const scaleX = prevCfg.w / nextCfg.w;
    const scaleY = prevCfg.h / nextCfg.h;

    // 计算 delta
    let deltaX = prevCfg.x - prevCfg.x + nextTranslateX;
    let deltaY = prevCfg.y - prevCfg.y + nextTranslateY;

    // 因为进行了 scale,所以需要根据 scale 修正 delta
    deltaX -= (1 - scaleX) * nextCfg.w / 2;
    deltaY -= (1 - scaleY) * nextCfg.h / 2;

    return {
        deltaX,
        deltaY,
        scaleX,
        scaleY,
    };
}

看到上面的代码可能会有些懵,matrix3d 是什么?什么时候冒出来的?

还记得之前取元素 transform 属性时使用的 getComputedStyle 么?浏览器会返回计算后的样式。我们拿到的,并不是形似 translate(-50%, -50%) 的字符串,而是一个 matrix3d 函数所代表的变换矩阵。为了拿到元素的位移,我们只需要第13个参数 a4 和第14个参数 b4 就够了

scaleX/Y – 通过 toElfromEl 的尺寸算出应该缩放的比例

deltaX/Y – 通过 toElfromEl 的位置和位移算出应该移动的距离,由于需要进行缩放,还需要使用缩放比例对这个差值进行修正

接下来,就可以正式来处理 toEl 的离开了,需要使用到 transition 组件的 leave 事件

const onLeave = (el, done) => {
    // 获取应该过渡的元素
    el = getTransitionContainer(el);

    // 强制赋予一个过渡效果
    el.style.transitionProperty = 'all';
    el.style.transitionDuration = '1300ms';
    
    // 让浏览器缓一缓 ε=ε=ε=┏(゜ロ゜;)┛
    requestAnimationFrame(() => {
        const d = calcDelta(toWrapperStyle, fromWrapperStyle, fromWrapperStyle.t);
        
        // 因为使用了 windicss,所以这里采用了覆盖 css 变量的方式
        // 也可直接使用 transform
        
        // 翻转
        // 可以看到这里进行了一个x轴和z轴的反转,这样正好把容器反过来
        el.style.setProperty("--tw-rotate-x", "180deg");
        el.style.setProperty("--tw-rotate-z", "-180deg");
        
        // 将容器(fromEl)移到新位置(toEl的位置)
        el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
        el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
        el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
        el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
        
        // 改变容器的圆角
        const scale = (d.scaleX + d.scaleY) / 2;
        el.style.borderRadius = toWrapperStyle.br / scale + "px";
        
        // 渐隐
        el.style.opacity = "0";
    })
    
    // 监听过渡结束事件,在 tranform 过渡完成之后,告知 transition 组件过渡已结束
    let _event = null;
    el.addEventListener('transitionend', _event = (ev) => {
        if (ev.target === el && ev.propertyName === 'transform') {
            el.removeEventListener('transitionend', _event);
            done();
        }
    })
}

调用 leave 事件传递进来的 done() 回调函数之后, fromEl 就会被 transition 组件删除,不需要我们自己删除。

现在,fromEl 已经完成过渡并且被清除了,最后一件事,就是要将 toEl 显示出来,正好和 fromEl 相反。fromEl 旋转 180°,toEl 就旋转 -180°。

const onEnter = (el, done) => {
    el.style.transitionDuration = '0s'
    const d = calcDelta(fromWrapperStyle, toWrapperStyle, toWrapperStyle.t);
    el.style.setProperty("--tw-rotate-x", "-180deg");
    el.style.setProperty("--tw-rotate-z", "-180deg");
    el.style.setProperty("--tw-translate-x", `${d.deltaX}px`);
    el.style.setProperty("--tw-translate-y", `${d.deltaY}px`);
    el.style.setProperty("--tw-scale-x", `${d.scaleX}`);
    el.style.setProperty("--tw-scale-y", `${d.scaleY}`);
    el.style.opacity = "0";
    const scale = (d.scaleX + d.scaleY) / 2;
    el.style.borderRadius = fromWrapperStyle.br / scale + "px";

    document.body.offsetHeight;

    requestAnimationFrame(() => {
        el.style.transitionProperty = 'all';
        el.style.transitionDuration = '1300ms';

        // 重置全部属性
        el.style.borderRadius = "";
        el.style.opacity = "";
        el.style.setProperty("--tw-rotate-x", "");
        el.style.setProperty("--tw-rotate-z", "");
        el.style.setProperty("--tw-translate-x", "");
        el.style.setProperty("--tw-translate-y", "");
        el.style.setProperty("--tw-scale-x", "");
        el.style.setProperty("--tw-scale-y", "");
    })
    
    let _event = null;
    el.addEventListener('transitionend', _event = (ev) => {
        if (ev.target === el && ev.propertyName === 'transform') {
            el.removeEventListener('transitionend', _event);
            done();
        }
    })
}

这个 onEnter 函数看起来和之前的 onLeave 差不多,但仔细一看又差很多🤯。这是因为两者的原理是不一样的。

onLeave 事件用于处理 fromElfromEl 在过渡完成后就要被删掉的,谁管它会不会残留什么乱七八糟的内联样式呢。所以,我们选择先给 fromEl 一个 transition 属性,然后给他赋予位移,使其慢慢过渡到新元素的位置。

onEnter 事件用于处理 toEl,这里的 toEl 在过渡完成后是要留在页面上的,我们不能因为过渡,就往上面写一堆内联样式,写了至少也要在过渡完成后删掉。

所以,这里的逻辑是:先禁用 transition,然后通过内联的 transformtoEl 放置到 fromEl 的位置上。这时候,开启 transition,然后删除之前设置的 transform 属性,toEl 就会过渡回来啦!而且过渡完成后,transform 属性不会残留在元素上,棒!

过渡完成后

我们给 toEl 设置了 transition 属性,所以需要 after-enter 事件来「擦擦屁股」

const onAfterEnter = (el) => {
    el = getTransitionContainer(el);
    el.style.transitionProperty = '';
    el.style.transitionDuration = '';
}

现在,整个翻牌过渡就完成啦

你可能会发现在某些情况下,会出现另外一种过渡(加载超过100ms时,先转变到loading)

这个动画通过路由守卫实现,原理也差不多,只是将 toEl/fromEl 替换成 Loading 元素。不过在 Workbox 和 Nuxt Prefetch 双重加持下,这个动画已经没有什么意义了。

【戴兜的游戏安利】Hi-Fi RUSH,不可多得的动作爽游

作者 戴兜
2023年4月4日 01:10

“你这是个技术博客么”

“不全是”

“那怎么只有技术文章”

“……”

我很少对玩过的游戏发表看法,但是今天要推荐的这款游戏 Hi-Fi RUSH,它实在是太好玩了。Hi-Fi RUSH 是 Tango 开发的一款动作游戏。在今年1月底就发售了,但当时正好在玩鬼泣5,之后又碰上了原子之心,正好 Tango 之前的幽灵线又比较粪,主客观共同作用下让我略过了这个游戏,最终到4月份我才在同学的安利下购入了Hi-Fi RUSH,自此便一发不可收拾。

简单介绍

Hi-Fi RUSH 是一款节奏类的第三人称动作游戏,你可以将其看作是音游和鬼泣的结合。大体上是一条线性的主线剧情,不算隐藏关的话流程不长,大概 10 小时左右就能通关。通关之后你就可以选择关卡自由探索了(有很多隐藏关需要二周目才能解锁)

目前在做全收集,感觉算上隐藏关和全收集,游戏时长应该会达到30小时左右。

主线剧情其实比较王道,就是解决几个小BOSS、解决一个大BOSS最终拯救世界的套路,但是游戏过程中的铺垫还是很到位的,叙事的过程中也穿插了一些比较⌈噼咔⌋的人物关系,让人眼前一亮。为了避免剧透,剧情相关就讲到这里。

画面

首先是我个人特别吃风格化渲染的游戏(比如原神),其次是 Hi-Fi RUSH 的画面真的无可挑剔,三渲二美漫风格非常抓眼,色调也很鲜明有活力,玩起来特别轻松愉悦。

与之相关的便是演出效果,基本上大的剧情转折都有穿插 CG 动画,即便是没有 CG 的剧情,3D 场景的氛围渲染、分镜、音效也很出色(我甚至觉得优于CG的表现效果),人物动作、表情非常生动,基本没有什么特别尬特别出戏的剧情演出。

音乐

这个游戏的音乐非常抓耳,你的攻击、连招、跳跃和闪避都有贝斯伴奏,而且游戏内包括主角在内的你能想到的所有能动的物品、UI,都会跟随音乐跳动。战斗的过程中会不由自主地点头抖腿。

游戏性

动作

平A、重击、闪避、连招等常见的元素当然都有,玩家的目标就是跟随背景音乐的节奏按下攻击按钮,时机越准确,得分越高。即便玩家攻击的节奏不对,游戏内的主角也会调整前摇,保证每次攻击都落在节拍上,引导玩家寻找正确的按键时机。游戏的连招很多,攻击节奏正确时会触发QTE进行连招,连招需要在商店内使用齿轮(一种收集物)解锁。(空中XYXY我至今没在实战中用出来过)

随着剧情推进,主角拥有召唤队友的能力。对应的,队友也具有技能和QTE,在地图探索中也会发挥一定的作用。

小游戏

除了常见的动作要素,游戏内也有一些比较有趣的小游戏,例如躲激光、各种QTE(比如杀鸡修机的QTE、太鼓达人),在游戏后面几章碰到的大型怪物也会有强制的闪避机制,需要闪避破盾才能击杀。

惩罚

Hi-Fi RUSH 虽然注重节奏,但是没有明显的惩罚机制,跟着节奏,也只是伤害更高、结算得分更高,并不会断 Combo,商店中也有各种Buff增加爽感。唯一能算得上惩罚的可能只有这个游戏的闪避盾反,只要不完美,就得一直重来🥲

总结

快去玩!!!😤😤😤

!important导致TransitionGroup失效

作者 戴兜
2022年11月23日 19:10

大家如果曾经接触过 Vue, 那么大抵会对其自带的组件 TransitionGroup 有所了解。这篇文章便记录了 TransitionGroup 中「移动动画」的一些使用细节。

或许你对 TransitionGroup 的「移动动画」还不太了解,那么我在这里浅浅地介绍一下。正常使用时,你需要为 .[name]-move 类提供一个过渡样式,例如 transition: all 0.5s ease;,这样,当 TransitionGroup 内的元素位置变更后, Vue 会尝试让变动了位置的元素从老位置平滑过渡到新位置。当然,Vue 也支持新增元素和删除元素的过渡效果,只需要为 [name]-enter-from[name]-leave-to 类名提供样式,这不是本文的重点,故不再详细介绍。

曾经的我,也像大部分人那样按部就班把样式写完,没出过问题。

直到群里有人告诉我:「试试给元素增加一个常驻的、带有 !importanttransition 样式,会让过渡失效」

我当场愣住 😀 ,这在当时的我看来是一件很难理解的事情:本身过渡时 Vue 就会通过 [name]-move 为元素加上 transition 属性,为什么提前给元素加上一个优先级最高的 transition 属性,过渡反而没法生效了呢?

从源码入手

我们可以在 TransitionGroup.ts 阅读与 TransitionGroup 相关的代码内容

初始化阶段

不难发现,Vue 在渲染函数内,将子元素数组 children 赋值给 setup 函数作用域下的变量 prevChildren (L113)。同时,通过一个 for 循环遍历 prevChildren (L135),将每个子元素的位置信息储存于 positionMap 中(旧位置)。

该阶段中与我们讨论内容相关的,便是这两处内容。prevChildren 的赋值,使得 Vue 能够在之后的 updated 生命周期中,得以取得子元素引用,方便进行相关操作。而 positionMap,让 Vue 有能力在之后的操作中,得到元素的原始位置。

此处的 positionMap 是一个 WeakMap,Vue 使用元素对象作为 key 值,能够保证在元素被销毁后,positionMap 中对应元素的位置信息被适时自动回收。

Updated 生命周期

当 TransitionGroup 内的子元素发生变动后,会调用 onUpdated 注册的回调函数,同时也是在这里,Vue 完成了过渡所需要的大部分操作。

首先,Vue 通过一个 forEach,再次遍历获取了各个元素的位置信息,储存到 newPositionMap 中(L72)。此时,一个元素的新旧位置分别储存在 newPositionMappositionMap 中,我们需要做的,就是让元素从旧位置平滑过渡到新位置。

接下来就是关键所在:既然是 updated 生命周期,此时元素们应该已经在新位置呆着了,又谈何过渡?所以,我们要做的,并不是单纯让元素从旧位置过渡到新位置。而是将已经位于新位置的元素,重新放回旧位置,再让其平滑返回到新位置,完成整个过渡过程。那么 Vue 是怎么完成的呢?

L73的代码是这样的:

const movedChildren = prevChildren.filter(applyTranslation)

Vue 使用 applyTranslation 方法过滤出了需要移动的子元素数组 movedChildren。通过观察函数和变量的命名,我们几乎可以肯定地说,在过滤的同时,Vue 还对元素进行了一些操作,而实际上也确实如此。

function applyTranslation(c: VNode): VNode | undefined {
    const oldPos = positionMap.get(c)!
    const newPos = newPositionMap.get(c)!
    const dx = oldPos.left - newPos.left
    const dy = oldPos.top - newPos.top
    if (dx || dy) {
        const s = (c.el as HTMLElement).style
        s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
        s.transitionDuration = '0s'
        return c
    }
}

对于需要移动的元素,Vue 计算了新旧位置差值,使用 css 属性 Transform,将元素放回旧位置。特别关注一下 s.transitionDuration = '0s'之后要考

但是实际上,此时的元素仍然没有回到旧位置。浏览器会将样式变动加入渲染队列中,而不是立刻渲染。这里涉及浏览器重排(reflow)的相关知识,可以搜索相关文章来进行阅读。

为了保证元素立刻被放置到旧位置,在L73得到 movedPosition 后,Vue 执行了 forceReflow 方法(L76),强制触发重排。而 forceReflow 方法内容也很简单(Transition.ts#L461)

function forceReflow() {
  return document.body.offsetHeight
}

这里也是个前端小知识 😛 ,读取文档的 offsetHeightoffsetWidth,也能触发文档重排。

可以这么理解:渲染队列中存在改动而不进行重排直接获取文档宽度或高度,会导致拿到的元素宽高是过时的,所以浏览器在读取前对文档进行了重排。

之后的工作就很简单了,只需要给元素加上 [name]-move 类名(L81),然后去除之前添加的 transitionDurationTransform 属性,元素自然就能平滑返回到新位置啦~监听 transitionend 事件(L83-L93),做一些收尾工作(去除过渡相关类名等)

读到这里,我们已经能够解决文章开头的那个问题了。实现过渡效果,需要确保元素正位于旧位置。在 Vue 中,为了确保文档重排后元素通过 Transform 放到了旧位置,Vue 将元素的过渡时间设置为 0s 并进行了一次强制重排。但是人为添加的高优先级 transition 属性导致重排时元素没法第一时间回到旧位置,也就没有过渡效果了。

我也写了一个小 demo,简化了 TransitionGroup 中的无关代码,感兴趣可以看看 https://codepen.io/DaiDR/pen/VwdMRxa

CSS Layout API初探:瀑布流布局实现

作者 戴兜
2022年1月14日 23:32

自己在写的小项目中有瀑布流的需求,不久之前刚刚完成瀑布流的布局部分,这部分代码也已经上传到了Github gist。写的时候我就在思考:如果能有更优雅的方式快速实现瀑布流布局该多好。于是,我便想到了之前无聊时翻看MDN时,CSS Houdini里边所描述的CSS Layout API。正好最近刚写完瀑布流,实践起来比较方便。

warning
CSS Layout API目前还是First Public Working Draft,本文所述内容在将来随时可能过时。

warning
目前没有**任何**浏览器支持该特性,为了正常展示本文所述的所有demo,你需要使用edge/chrome浏览器并在flags中将Experimental Web Platform features启用。

〇. 结果

因为这篇文章前戏很长,所以将结果放在了最前面呈现,完整的示例可以前往 https://masonry.daidr.me 查看。

如果将来浏览器支持了该特性,那么使用瀑布流布局将会是一件易如反掌的事情,你需要做的,仅仅是

  • 引入 masonry.js
  • 准备一个父级容器,和一些瀑布流元素(例如卡片)
  • 为这个父级元素加上一个布局样式。
<script src="masonry.js" />

<div class="container">
    <div class="card">瀑布流元素</div>
    <div class="card">瀑布流元素</div>
    <div class="card">瀑布流元素</div>
    <!-- ... -->
</div>

<style>
.container {
    display: layout(masonry);
}
</style>

Ⅰ. 一些新的知识

我兴致冲冲地去MDN翻阅与CSS Layout API相关的文档,结果发现…居然什么都没有 🙄 …既然没有的话,直接去w3c上看看吧,于是,我打开了https://www.w3.org/TR/css-layout-api-1,结果经过我的一番尝试,连里边的示例都没法正常使用,才发现这个文档也过时了 😮

不过好在Editor’s Draft里面的内容一直在更新,这才让我有了继续写下去的动力。那么,让我们开始吧!

Typed OM

不知道大家在使用js操作样式时,是否会感到百般别扭:

let newWidth = 10;
element1.style.width = `${newWidth}px`

因为返回的是字符串,进行运算的时候总是很狼狈,傻傻搞不清楚font-size/fontSize/margin-top/marginTop,更别提各种数值和单位的拼接,我已经不止一次犯过下面这样的错误了:

element2.style.opacity += 0.1;

Typed OM便可以来解决我们直接操作CSSOM时发生的诸多不愉快。你可以通过元素的attributeStyleMap属性获取到一个StylePropertyMap对象,之后,便可以以map的方式读取元素的样式了。

element3.attributeStyleMap.get('opacity'); // CSSUnitValue {value: 0.5, unit: 'number'}
element3.attributeStyleMap.get('width'); // CSSUnitValue {value: 10, unit: 'px'}

返回的是一个CSSUnitValue对象(也可能是CSSMathValue或其子类的对象),我们可以很轻松地获取到属性值的数值部分,简化我们的操作。浏览器甚至能够自动转换em、rem等相对单位,得到绝对单位数值。我们还可以通过CSSUnitValue内置的to方法,进行快速的单位转换。不仅如此,浏览器还提供了大量的工厂方法来规范化表达css的属性值,比如我们的第一个例子,使用Typed OM进行操作就会是下面的样子。

let newWidth = 10;
element1.attributeStyleMap.set('width', CSS.px(newWidth));

舒服多了。在使用CSS Layout API的过程中,我们会经常看到Typed OM的身影。在MDN可以找到Typed OM相关的文档

CSS Properties and Values API

这个接口能够让我们注册一些自定义的css属性,并定义格式和默认值。

CSS.registerProperty({
    name: "--masonry-gap",   // 自定义属性的名称
    syntax: "<number>",      // 自定义属性的格式
    initialValue: 4,         // 默认值
    inherits: false          // 是否从父元素继承
});

不仅可以在JavaScript中使用该接口,浏览器也提供了自定义属性值的 At Rule

@property --masonry-gap {
    syntax: '<number>';
    initial-value: 4;
    inherits: false;
}

自定义属性注册完成后,之后再通过Typed OM操作样式,浏览器便会按照你所提供的格式,返回对应的CSSUnitValue(或CSSMathValue)对象。倘若不这么做,浏览器将会返回一个携带原始css属性值的CSSUnparsedValue对象。

syntax字符串的内容其实很简单,syntax由一堆syntax component组成,默认情况下,syntax字段的内容是*。除此之外,还可以使用 | 来表示或, + 来表示接受使用空格分割的属性值, # 表示接受使用逗号分割的属性值。这里的syntax仅仅是Value Definition Syntax的一个子集。更详细的资料,可以去草案的第五节详细了解。

CSS Layout API

终于到了咱们的重头戏!布局的相关逻辑需要使用浏览器提供的Worklet接口,这个接口允许脚本独立于js运行环境,进行诸如绘图、布局、音频处理等需要高性能的操作。所以,我们需要一个脚本,用于将布局逻辑相关的代码载入到LayoutWorklet中。(别忘了检查一下浏览器兼容性)

// masonry.js

if ('layoutWorklet' in CSS) {
    CSS.layoutWorklet.addModule('layout-masonry.js');
}

接下来就是需要被载入到LayoutWorklet中的代码

// layout-masonry.js

registerLayout('masonry', class {
    // 在这里声明之后你需要读取的css属性
    static inputProperties = ['--masonry-gap', '--masonry-column'];

    // 这个方法用于在弹性布局中确定元素尺寸,可以空着,但不能没有
    async intrinsicSizes(children, edges, styleMap) { }

    // 布局逻辑
    async layout(children, edges, constraints, styleMap, breakToken) { }
});

这样我们就创建了一个名为masonry的布局方式,上面两段代码可以看作是一套模板,直接拿来用就行。

接下来就是噩梦了 😯 ,layout的这几个参数是什么,该如何操作?好在草案写得足够详细,也提供了一些示例以供参考。(这篇文章不会讨论breakToken的用法)

children

是一个许多LayoutChild对象组成的数组,代表着容器内的所有子元素。LayoutChild主要包含下面这些属性或方法

LayoutChild.intrinsicSizes()

返回一个promise,用以得到IntrinsicSizes对象,可以获取元素的最大/最小尺寸

LayoutChild.layoutNextFragment(constraints, breakToken)

返回一个promise,用以得到LayoutFragment对象,LayoutFragment对象主要包含下面这些属性:

  • LayoutFragment.inlineSize:子元素内联方向上的尺寸,即宽度(只读)
  • LayoutFragment.blockSize:子元素块级方向上的尺寸,即高度(只读)
  • LayoutFragment.inlineOffset:子元素内联方向上的偏移
  • LayoutFragment.blockOffset:子元素块级方向上的偏移,布局主要就靠这两个偏移了

LayoutChild.styleMap

返回一个StylePropertyMapReadOnly对象,用来操作子元素的样式

edges

是一个LayoutEdges对象(属性均只读),用来获取容器内外边距、滚动条导致的content box与border box产生的距离

  • LayoutEdges.inlineStart:内联起始方向的距离
  • LayoutEdges.inlineEnd:内联结束方向的距离
  • LayoutEdges.blockStart:块级起始方向的距离
  • LayoutEdges.blockEnd:块级结束方向的距离
  • LayoutEdges.inline:内联方向的距离和
  • LayoutEdges.block:块级方向的距离和

可能不是很直观,这里放一张草案里提供的rtl方向下的图(和ltr正好相反):

constraints

是一个LayoutConstraints对象(属性均只读),用来获取元素(这里是指容器)的尺寸信息

  • LayoutConstraints.availableInlineSize:内联方向上的可用尺寸
  • LayoutConstraints.availableBlockSize:块级方向上的可用尺寸
  • LayoutConstraints.fixedInlineSize:内联方向上的确定尺寸
  • LayoutConstraints.fixedBlockSize:块级方向上的确定尺寸
  • LayoutConstraints.percentageInlineSize:内联方向上的尺寸(百分比表示)
  • LayoutConstraints.percentageBlockSize:块级方向上的尺寸(百分比表示)

不过似乎目前浏览器提供的 LayoutConstraints 对象只能获取到 fixedInlineSizefixedBlockSize 这两个属性…

styleMap

是一个 StylePropertyMapReadOnly 对象,用来操作容器的样式

Ⅱ. 开始实现瀑布流

使用CSS Layout API实现瀑布流的基本逻辑其实和其他实现方式基本是一致的。

我们先来定义两个自定义属性,方便之后进行属性值的格式化。

顺便把layout-masonry.js载入到layoutWorklet中

// masonry.js

if ('layoutWorklet' in CSS) {
    CSS.registerProperty({
        name: '--masonry-column',
        syntax: '<number>',
        inherits: false,
        initialValue: 4
    });

    CSS.registerProperty({
        name: '--masonry-gap',
        syntax: '<length-percentage>',
        inherits: false,
        initialValue: '20px'
    });

    CSS.layoutWorklet.addModule('layout-masonry.js');
}

接下来的所有代码若没有额外说明则均在layout-masonry.js的layout逻辑内部。

首先,我们来获取容器的内容盒子宽度:

// 获取容器的可用宽度(水平尺寸 - 左右内边距之和)
const availableInlineSize = constraints.fixedInlineSize - edges.inline;

接下来,我们来获取瀑布流列数(因为值是整数且默认值为4,我们无需做任何处理,读进来就好)

//获取定义的瀑布流列数
const column = styleMap.get('--masonry-column').value;

接着,我们需要得到每列的间距,此时情况就复杂了。不过好在所有相对单位和绝对单位在传入时都会自动转换成px,所以实际上我们只需要处理百分比和calc函数,css里边的calc函数是支持嵌套的,所以我们这里使用递归来完成计算,同时将百分比转换为像素值。

// layout-masonry.js 外部
function calc(obj, inlineSize) {
    if (obj instanceof CSSUnitValue && obj.unit == 'px') {
        return obj.value;
    } else if (obj instanceof CSSMathNegate) {
        return -obj.value;
    } else if (obj instanceof CSSUnitValue && obj.unit == 'percent') {
        return obj.value * inlineSize / 100;
    } else if (obj instanceof CSSMathSum) {
        return Array.from(obj.values).reduce((total, item) => total + calc(item, inlineSize), 0);
    } else if (obj instanceof CSSMathProduct) {
        return Array.from(obj.values).reduce((total, item) => total * calc(item, inlineSize), 0);
    } else if (obj instanceof CSSMathMax) {
        let temp = Array.from(obj.values).map((item) => calc(item, inlineSize));
        return Math.max(...temp);
    } else if (obj instanceof CSSMathMin) {
        let temp = Array.from(obj.values).map((item) => calc(item, inlineSize));
        return Math.min(...temp);
    } else {
        throw new TypeError('Unsupported expression or unit.')
    }
}
// 获取定义的瀑布流间距
let gap = styleMap.get('--masonry-gap');
// 将计算属性和百分比处理成像素值
gap = calc(gap, availableInlineSize);

我们需要根据列数和间隔计算出子元素的宽度

// 计算子元素的宽度
const childAvailableInlineSize = (availableInlineSize - ((column + 1) * gap)) / column;

下面的代码可以算是模板,我们需要获取子元素的fragment,只有这样我们才可以修改子元素的偏移

// 设定子元素宽度,获取fragments
let childFragments = await Promise.all(children.map((child) => {
    return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));

紧接着,就是瀑布流的逻辑了,基本上所有瀑布流的逻辑是类似的。在我的Github gist中vue的版本也是这么实现的。我们需要记录每一列的当前高度,在布局新元素时,选取其中最短的一列进行插入操作(倘若按照顺序插入会导致每列的高度差距过大)

// 设定子元素宽度,获取fragments
let childFragments = await Promise.all(children.map((child) => {
    return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));

let autoBlockSize = 0; //初始化容器高度
const columnHeightList = Array(column).fill(edges.blockStart); //初始化每列的高度,用容器的上边距填充
for (let childFragment of childFragments) {
    // 得到当前高度最小的列
    const shortestColumn = columnHeightList.reduce((curShortestColumn, curValue, curIndex) => {
        if (curValue < curShortestColumn.value) {
            return { value: curValue, index: curIndex };
        }

        return curShortestColumn;
    }, { value: Number.MAX_SAFE_INTEGER, index: -1 });

    // 计算子元素的位置
    childFragment.inlineOffset = gap + shortestColumn.index * (childAvailableInlineSize + gap) + edges.inlineStart;
    childFragment.blockOffset = gap + shortestColumn.value;

    // 更新当前列的高度(原高度 + 子元素高度)
    columnHeightList[shortestColumn.index] = childFragment.blockOffset + childFragment.blockSize;

    // 更新容器高度(若最短列的高度没有超过容器原高度,则容器高度保持不变)
    autoBlockSize = Math.max(autoBlockSize, columnHeightList[shortestColumn.index] + gap);
}

与普通瀑布流唯一的不同可能是在最后一步,我们需要更新容器的高度,所以每布局一个子元素,都尝试记录目前最高那列的高度。

最后,我们需要固定返回一个包含容器高度和子元素fragment的对象

注:按照草案中的描述,此处应该返回一个FragmentResult对象,但是目前没有任何一个浏览器实现了这个类…

// 固定返回一个包含autoBlockSize和childFragments的对象
return { autoBlockSize, childFragments };

完整的代码可以在文章开头的仓库中找到。

手撸一个前端天气卡片

作者 戴兜
2021年10月1日 23:02

自己亲手做一个天气卡片组件的想法其实很早就有了,但是做起来难度还是很大的(布局、数据源、天气展示、自适应),最终不了了之。最近学校社团面试题目是做一个天气卡片,正好可以借此机会趁着国庆小长假静下心来好好研究一番。于是就有了今天的这篇文章。

(实际上在国庆小长假之前就基本上把问题搞定了)目前版本的DouWeather托管在临时仓库,因为没有考虑代码结构,后期维护困难。可以watch一下这个仓库关注后续进展,我会抽时间重构下代码并在这个仓库更新。

Ⅰ.设计构思阶段

1. 灵感来源

我给DouWeather(后称DW)的定位是网页小组件,也是出于这个考虑,我参考了如iOS系统的小部件、新版MIUI系统小组件、鸿蒙系统小部件、win11小组件,发现都无一例外具有同一特征:扁平化,圆角,选用无衬线字体,元素风格简洁,并且四者都在或背景或图标中大量使用渐变,使小部件表现得较为灵动。其中win11小部件添加了浅阴影,可能是为了让小部件从亚克力背景中凸显出来。

于是乎,我也照猫画虎,设计了DW的晴天图标,并且用XD设计出了第一种卡片样式(现DW的medium卡片样式)。

注: DouWeather的设计也参考了google adsense(MD2),mew等网站的设计风格。

将medium类样式的卡片缩短,便衍生出了一个正方形的small样式小组件。

之前心血来潮想写天气卡片的时候也做过图,当时参考的MD风格,正好趁着国庆回家从电脑里边翻了出来,下面便不知道多久前天气卡片的初稿…就…蛮抽象的…

2. 图标设计

DW中许多要素都借鉴了小米天气,图标也是如此。DW尽量保证图标整体风格简洁,使用大块的渐变背景突出天气特点。在正式开干前,我就仿照小米天气做出了晴、阴、多云这三个图标,方便之后的设计和开发。格式依然采用了svg,控制组件整体的体积,保证加载速度。

开发过程中受到室友启发,尝试为天气图标增加了一些动画,不过有些喧宾夺主,最后不了了之。

3. normal样式和detail样式?

开发前我其实仅仅计划做出两种样式(即small和medium)。做normal样式的主要原因,是开发过程中我发现:当medium样式被置于一个宽度过大的元素上方时,会显得内容空洞,不够美观。于是便在medium样式的基础上,加长了宽度,增加了空气质量、防晒建议等数据展示。而detail样式,纯粹是因为我对小米天气的趋势预报爱得深沉,想要在DW中复刻一个出来。

Ⅱ. 开发阶段

1. 不借助构建工具如何优雅地使用Web Components?

在写天气卡片前,我只使用过一次Web Components,那是在原神玩家信息查询中,当时是因为有很多重复的要素(角色信息),所以想尝试用这个新鲜玩意封装一下。得到的教训就是:如果不用构建工具,又想要较为优雅地开发,template标签是必不可少的,否则维护代码简直要了我的老命。

2. 如何优雅地显示图标?

天气图标会在卡片中大量复用(尤其是detail样式),如果没有一个比较简洁的调用方式,维护起来会很困难。并且在开发时图标仅设计了3个,需要顾及开发后期如何便捷地对图标增删改,尽量降低图标检索和主体代码间的耦合度。在前端中,一般有下面几种图标引入方式:

① 使用@font-face引入图标字体文件

大型图标字体一般都采用这种方式,如Font Awesome和Material Icons。优点是操作直观,能够使用font-size或者color直接修改图标展现形式,而且得益于浏览器对colr的支持,能够使用彩色图标字体。不过缺点也很明显:维护较为困难,尤其是涉及到渐变填充,目前还没有什么字体制作软件能够较为优雅地完成这个任务。并且某些手机自定义字体的hook逻辑可能导致这种方法引入的图标字体无法生效。

不过,在DW中也有一部分图标采用了这种方式,那便是风向的图标,图标单色且数量固定不需要频繁修改(8个方位),非常适合使用这种方式。

② 使用svg的symbol

这也是很常用的一种图标引用方式,兼容性极好。维护相对方便,能够支持一些动画。AI能够直接导出图标为symbol标签,而且有许多构建工具也能够为此提供支持,基本没有缺点。

然而DW的天气图标并没有采用上述的两种方式。我对图标部分使用Web Components做了封装,已经是类似symbol的作用,因此再使用symbol便显得有些多此一举。

封装后的天气图标调用就方便多了,可以直接使用 <dw-icon type="sunny"></dw-icon> 这样的代码来调用特定的图标,下面是一个示例。

See the Pen WebComponents封装天气图标 by 戴兜 (@DaiDR) on CodePen.

之后打算使用gulp,这种方式也能够为开发提供便利。

3. 自适应怎么做?

在天气组件的开发过程中,我才发现还原设计稿其实是这其中最简单的一件事。我需要保证卡片中的所有元素都能有条不紊地展现出来,我原本想要固定每一种样式的卡片宽度,这样能够确保卡片的布局总是完美的,但是会使天气卡片的泛用性大打折扣,其他使用DW的人并不会专门为了一个小组件而修改自己的布局方案,同时固定宽度意味着在移动设备上,天气卡片的体验会很糟糕。但是自适应,又该怎么做呢?

最常用的自适应方法是写媒体查询,但是我不能使用媒体查询,其他开发者在哪儿插入卡片、怎么插入卡片、卡片的父级元素是什么状态我都无从得知,我不能仅通过屏幕尺寸判断出天气卡片目前的状态。

我也不能通过判断卡片宽度就隐藏或显示某些元素,因为之后的版本DW会将数据展示的部分模块化,允许其他开发者自定义展示哪些数据,擅自修改展示的元素可能导致其他开发者的配置没法如预期那样展示出来。

既然谈到了判断父容器尺寸,不如来谈谈实现方式。一般来说,我常用的方法是在父容器中嵌入一个iframe,通过iframe的尺寸变化监听容器尺寸变化,或许未来也可以试试css容器查询(Container Queries),能够提供很大便利,不过目前这个特性还处在pr阶段…兼容性列表

我一度想要固定卡片宽度,事实上直到我将DW的布局和逻辑基本全部写完后,我依然没有找到很好的解决方案。

给我灵感的,是windows的资源管理器:

天气卡片的主体元素固定在左侧不动,右侧的数据展示根据卡片宽度显示滚动条,实现也非常简单,因为我使用的弹性布局,只要在原来的数据展示区域外边包装一层带有 flex-grow: 1; 样式的容器就好了。

4. detail样式的数据展示

这一部分也很复杂,因为社团面试任务中有提到图表展示,当时是想复刻一个小米天气的15天趋势预报试试水,如果成了的话之后还可以拓展到小时预报之类的图表展示。图表部分是使用svg实现的,为了让暗黑模式的样式操作能够便利,所以使用了svg而不是canvas。绘图直接用的浏览器原生js实现,只需要绘制一个折线图,chartjs显然有些大材小用,比较臃肿。原先设计稿中采用的展示方式很难优雅地在中间位置插入图表,所以后来将早上数据、图表、晚上数据全部分了开来,因为列宽是一致的,所以也不用担心错位的问题。

原设计
修改后

接着就是绘制图表了,首先统一计算出折点的X坐标,接着按照温度确定出每个折点的Y坐标,折点用的是svg的circle元素,折线部分直接用path搞定了。

path的d参数语法逻辑其实和canvas绘制的逻辑是相类似的,首先使用M(MoveTo)指令将起点移动到第一个点的位置,接着只需要使用L(LineTo)指令绘制之剩下折线便完成了。

4. 暗黑模式

只需要使用 @media(prefers-color-scheme: dark) 这个媒体查询便能够定义暗黑模式下的卡片样式。

值得一提的是,我使用了css变量,目前大部分浏览器已经兼容了,能够大幅减少重复代码。

有时候使用者可能不想让媒体查询自作主张修改卡片样式,于是乎我提供了属性 theme 来控制卡片颜色。可以使用theme="light" 或是 theme="dark" 将卡片锁定在明亮模式或暗黑模式。这点小功能我想着完全用css来实现,之前Web Components用得不多,想着用宿主选择器轻松就能搞定,便想当然地写出了下面的这段css…

:host {
    // 默认样式
}

:host[theme="dark"] {
    // 暗黑模式样式
}

然鹅…翻车了,样式并不会生效,翻遍了MDN后,我找到了这个选择器:host()(面向MDN编程),所以正确的写法应该是这样(所以有哪个翻过blink源码的小伙伴能告诉我为什么要这样设计么…):

:host {
    // 默认样式
}

:host([theme="dark"]) {
    // 暗黑模式样式
}

Ⅲ. 总结

这次写DW,让我学到了许多,之前写前端很少会自己去做图表生成,经常是引用个chartjs或是echarts了事。对Web Components也有了较之前更为全面的了解,同时也熟悉了一下flex布局的使用,至少2天多的小米天气没白看,我同学看我一天到晚拿着手机刷小米天气以为我疯了

Hi!2021

作者 戴兜
2021年1月27日 18:37

2020年转瞬即逝,上一次写年末总结,仿佛还在昨天…恐怕这一年对于我们来说都是印象深刻的一年,新冠疫情的爆发,让我们改变了太多。对于我来说,2020年同样意义非凡——2020年9月,我成年了。2021年1月,参加了人生中第一次“小高考”,在调和考试的焦虑的过程中,也让我收获了许多。这一年,戴兜又认识了许多新伙伴,感谢大家对戴兜的小屋的支持,同时也感谢在这一年帮助我解决了诸多困难的各位大佬们。由于学业压力,在过去的2020全年仅更新了3篇文章,深感抱歉。

Ⅰ. 过去一年的 戴兜的小屋

(数据来自Google Analytics

  1. 2020年戴兜的小屋有效访问量:2.2万 次,相比去年增长 37.07%
  2. 被浏览次数最多的文章:PoiLive2d(被浏览 689,875 次,占比 99%
  3. 2020年十二月共有 3,264 次有效访问,占比 14.84%
  4. 戴兜的小屋友人帐新增了 16 位小伙伴,现在戴兜有 67 位小伙伴啦🎉
  5. 戴兜的小屋评论共有 704 条评论,增加了 204 条,增长 40.8%
  6. PoiLive2d各版本插件被下载6000+次(数据来自蓝奏云)
  7. Google Ads为戴兜创造了 $39.11 的广告收入

Ⅱ. DouAPI 今年怎样了?

  1. DouAPI自去年重构以来,性能获得了较大提升,2020年初新增又拍云节点,目前可用节点为CloudFlare节点又拍云节点,源站仍使用Vultr的新加坡服务器。
  2. DouAPI的动态签名档接口与呆呆酱合作,现提供呆呆酱签名档的生成(背景ID:15)。
  3. DouAPI在2020年里被调用 459945090 (4亿) 次。
  4. 一言接口被调用 449717327 (4亿) 次,占比 97.78%
  5. 毒鸡汤接口被调用 9407471 (940.7万) 次,占比 2.04%
  6. Bili直播弹幕接口(未公开)被调用 453044 (45.3万)次,占比 0.10%
  7. 签名档接口被调用 137212 (13.7万)次,占比 0.03%
  8. 如果时间充裕,将会尝试使用Go重写DouAPI
  9. 所有统计数据会实时显示在 DouAPI 首页

Ⅲ. 2020年我做了什么?

  1. DouBoard——微软白板的临摹版本
    • 被微软白板应用的界面吸引,遂简单临摹之。
  2. GHAuth(GayHome Authorition)——轻量的Minecraft Yggdrasil身份验证和皮肤托管程序
    • 基佬之家服务器先前采用正版验证的方式确认玩家身份,由于部分小伙伴未拥有正版账号,便爆肝一周写了GHAuth,因部分群友的强烈要求,便分离了配置文件,进行了简单的封装,目前开源在Github
    • GHAuth是我第一次完整开发用户注册登录逻辑,开发过程中第一次接触了Koa、复杂的MongoDB操作和Redis操作,收获了很多,也感谢遇到困惑时各位大佬的帮助。

Ⅳ. 一些杂七杂八的东西

  1. PuzzleMaker暂时弃坑待填。
  2. 方舟弃坑,目前沉迷原神。(官服UID: 104003683)
  3. 多次收到了大家的投食❤,详细列表会在独立页面进行展示。
  4. 看起来月更的梦想有点难以实现

Ⅴ. 未来的展望

  1. GHAuth开发(邮箱验证,FIDO支持,快捷部署配置等)
  2. DouAPI的Go语言重制

感谢各位小伙伴的理解与陪伴,戴兜会继续努力哒,各位新年快乐q(≧▽≦q)

元旦的时候忘记发了,春节又没到,好难受啊>﹏<

❌
❌