友链页面中链接卡片盒(link-boxes)的实现

文章目录

起因

本来我的友链是全部放在博客的 About 文章中作为其中一部分的,不过呢,由于列表陆续变得越来越长,便打算把它单独做成一个页面,考虑了一段时间之后把实现的想法落在了 Bokjan 大佬的友链页面 上,因为发现这种卡片形式的友链列表蛮好看的,不过比较可惜的是这个功能(经 Bokjan 本人提醒 来源在 这里)是 Typecho 专有的,所以只能自己仿造实现了

这篇文章记录的是实现这种链接卡片盒的过程,中间有很大一部分的代码最后都用更好的方式实现了(其实是把一些不优雅的 JavaScript 操作都改成 CSS 了),最终的代码挺短的 = = 不过我觉得中间的这些折腾也蛮有意思的 2333

Demo

卡片

最初的版本当然是先把卡片写出来啦,基本的需求很简单,整张卡片可以被点击,然后包含图片和昵称,大概就是这样:

<a href="https://www.google.com.hk/" class="link-box">  
    <img src="https://image.flaticon.com/teams/slug/google.jpg" alt="Google" class="avatar">
    <div class="nickname">Google</div>
</a>  

CSS 方面,主要有两点,一是要把整个 <a> 标签作为一个区块也就是 display:inline-block以保证后续操作的方便(比如限制子元素图片大小等),二是加上阴影让它看起来有卡片的效果,这个效果要怎么样取决于个人爱好了,我用的值是 box-shadow: 0 1.5px 3px rgba(0,0,0,0.1);

接下来加个对鼠标滑过(hover) 的响应,往上移动一小段距离并加重阴影:

.link-box:hover {
    box-shadow: 0 5px 9px rgba(0,0,0,0.15);
    transform: translateY(-0.3rem);
}

为了让转变不那么突兀,可以对 link-box 这个 class 本身加个过渡: transition: all .3s;

布局

一张微小的卡片已经完成了,接下来可以多加一些卡片考虑一下布局的问题

最方便也相对高效的布局方式当然是 CSS 的 flex 布局啦,只要把所有的 link-box 放在 link-boxes 父元素下,然后对父元素进行 flex 布局设置:

.link-boxes {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: baseline;
 }

这样的话,对于多张卡片,就会自动按照水平居中(center)、垂直对齐元素底线(baseline)的标准自动布局,并且在一行排满的时候自动换行(wrap)

再稍微设置一下 avatar 的宽高大小,就基本完成初始版本的链接卡片了

细节

垂直对齐方式

前面的垂直对齐方式选择了 baseline 而不是和水平对齐相同的 center ,是因为我特地找了两张长方形的图片用作测试,分别看一下两种对齐方式的效果:

align-items-center

align-items-baseline

可以发现在这种情况下,底部对齐的方式看起来会相对整齐些

其实这类情况还是比较容易出现的,主要是因为在一开始就默认了头像图片是方形或者接近方形的,那么为了彻底解决这些问题就需要考虑一些特殊的情况了,详细的内容在接下来的 加戏 小节内介绍

移动端适配

移动端的适配当然还是要做的啦,首先是 HTML 文件里的 viewport 声明:

<meta name="viewport" content="width=device-width">  

不过这个其实如果是博客上用的话,基本都是已经写好的,毕竟博客文章页面本身也是需要移动端的适配的 = = 怎么感觉在讲废话

我个人一般用 520px 作为移动端与 PC 端的分界线,两边的差别主要应该是在图片大小的设置上,写个媒体查询(@media (max-width: 520px))然后根据目前屏幕宽度计算就行了,比如设置头像宽度为calc((100% - 1.4rem * 2) / 2) !important1.4rem 是图片两侧留白的大小,注意手机端这样的重写是需要覆盖原值的,所以要加上 !important 属性

其它代码内容上其实和 PC 端相差不大,这里就不重复写了

加戏

基本需求完成了就应该好好休息一下自己给自己加点需求了(逃

图片宽高比过大

简单粗暴的解决方案:限制最大宽度,然后检查高度是否达到要求,对过低的图片增加垂直方向上的 margin 使它的总高度和其他图片相同

图片的大小我是写在 CSS 全局变量里的,格式如下:

html {  
    --img-size: 200px;
}

这样在写样式的时候可以直接通过 var(--img-size) 代替 200px,作用类似其他编程语言中的常量,也就是避免在常量值变动时需要进行重复的更改

JavaScript 可以通过 getComputedStyle 获取这个值,当然为了后续的计算需要通过 parseInt 去掉 px 把它转为整数,然后为了保证操作在卡片渲染完成之后进行,最好把整个代码作为 window.onload 事件的响应,代码如下:

window.onload = function() {  
    var imgSize = parseInt(
        getComputedStyle(document.body)
        .getPropertyValue('--img-size')
    );
    var imgs = document.getElementsByClassName("avatar");

    for(var i = 0; i < imgs.length; i++)
    {
        if (imgs[i].height < imgSize)
            imgs[i].style.margin =
            String((imgSize - imgs[i].height) / 2) +
            "px 0";
    }
}

可以说是非常粗暴的代码了 23333
同样为了防止变化过于突兀,对 avatar 设置一个过渡时间,这样效果看起来就是宽高比过大的图片在渲染后向上移动至卡片中央:

.link-box .avatar {
    /* other styles */
    transition: all 1s cubic-bezier(0, 0, 0.2, 1);
}
图片高宽比过大

宽高比大一点的图片作为头像还是比较常见的,高宽比过大的……其实应该是不会有人用这种头像的吧23333,不过既然考虑到那就一起解决了

最开始的实现也是一样的,只是把对高度的判断改成对宽度的判断,然后前面的 transition 作用范围是 all 所以也不用增加什么额外的代码了,效果同样是图片在渲染后向右移动至卡片中央

实现到这里就开始出现一些问题,具体在下面一部分里介绍

那么问题来了

精度问题

在测试的时候发现有些图片增加 margin 后似乎整体的高度或者宽度并非严格等于设定的尺寸,有略微的 1px 左右的误差,不难发现原因:并不是每一张图片的尺寸比例都是整数,而代码的是全部按照整数处理的,这就导致计算 margin 的时候有较大的误差

比如对于一个元素 ele,它的 height 属性值是 49,但是实际上它占的高度并不正好是 49px,我们可以通过 getBoundingClientRect 函数获得精度更高的 height 值:

bounding-client-rect

当然,计算过程中或者设置结果值时还是难免有些位被舍弃,但这样子计算的结果已经相对而言精确很多了,至少基本上肉眼看不太出来差别(逃

移动端情况

在前面的 移动端适配 小结,有提到对于手机端的头像尺寸设置的方式,那一部分的代码大概是这样:

@media (max-width: 520px) {
    html {
        --img-size: calc((100% - 1.4rem * 2) / 2) !important;
    }
}

但是 JS 这时候获取的值不是计算后的,而是一段字符串(calc((100% - 1.4rem * 2) / 2)),所以如果要通过 JS 计算这个值的话,只能通过 JS 自己的 window.innerWidth 进行计算了

这样的话,更大的问题又出现了,rem 这个单位要在 JS 里换成 px 值进行计算,那么就得获取 HTML 的根元素字体大小,毕竟不同设备上的 1rem 值并不一定都是默认的 16px

虽然这个问题也可以通过 JS 提供的函数实现:

parseFloat(getComputedStyle(document.body, null).fontSize);  

但是为了避免一些麻烦以及方便统一,我还是把所有的 rem 之类的单位换成了 px 值,顺便把 max-height 的值也通过 JS 进行了设置,因为只通过 CSS 设置的话实在有些麻烦

这个时候的 imgSize 值就不能依赖 CSS 获得而需要另外写代码进行计算,可以发现代码已经变得越来越乱并且有很大冗余度了:

function reCenterImgs() {  
    var imgSize = window.innerWidth < 520 ?
        (window.innerWidth * 0.5 - 22.4) : 150;
    var imgs = document.getElementsByClassName("avatar");

    for(var i = 0; i < imgs.length; i++) {
        imgs[i].style.maxHeight = imgSize + 'px';
        imgs[i].style.margin = '0 0';

        var vert = '0', horz = '0';
        var rect = imgs[i].getBoundingClientRect();

        if (parseFloat(rect.width.toFixed(1)) < imgSize)
            horz = ((imgSize - imgs[i].width) / 2).toFixed(1);
        if (parseFloat(rect.height.toFixed(1)) < imgSize)
            vert = ((imgSize - imgs[i].height) / 2).toFixed(1);

        imgs[i].style.margin = vert + 'px ' + horz + 'px';
    }
}

把它写成一个函数是为了方便后面优化成自响应操作后直接调用,这个函数就只需要考虑当前页面情况下的 margin 增量而不用考虑页面改变的问题了,也就是自响应代码部分检测到页面的改变后调用它,然后这个函数首先重置所有的 margin 值为 0,再计算并重新设置它们在当前情况下的值

自响应问题

本来写这样一个东西肯定是希望它是 Responsive 的,这样通过 JS 直接设置 margin 的话在页面改变的时候就会变得非常奇怪……所以就去找了找 JavaScript 自响应相关的资料,也算是开了个新坑吧[捂脸]

Solution 1. matchMedia()

这是个……假自响应23333,因为看到 media 这个词直觉和 CSS 那边的媒体查询联系了起来,于是没加思考就拿来用,后来发现并不能达到效果,原因会在后面提及

看一下 文档 用起来还是很方便的,格式也和媒体查询差不多:

window.onload = function() {  
    reCenterImgs();

    var mql = window.matchMedia("(max-width: 520px)");
    var matchState = mql.matches;

    mql.addListener(function(e) {
        if (e.matches != matchState) {
            reCenterImgs();
            matchState = e.matches;
        }
    });
}

这个 mql.addListener() 会对页面的宽度改变大于或小于 520px 进行响应,其中的 matchState 记录的是页面的原始状态是否满足条件,因为 e.matches 只在页面从大于 520px 改变为小于时才为 true,所以需要一个记录之前值的变量来保证当页面从小于 520px 改变为大于时也能执行代码

……其实我刚刚又测试了一遍发现 matchState 这个变量完全可以不需要的,好像直接这样写就可以了:

var mql = window.matchMedia("(max-width: 520px)");  
mql.addListener(function(e) {  
    reCenterImgs();
});

应该是因为这个 listener 响应的是条件的改变,也就是本身已经是对两种情况都会响应了,不知道当时是怎么萎事强行写了这么长

Solution 2. "resize" Event

CSS 里面写的是对于移动端根据设备宽度动态计算值,所以这种只对一次改变响应的 matchMedia() 并不能完全符合自响应的要求,举个例子,页面从 600px 改变为 500px 时它会执行一次代码,而当页面从 500px 变为 450px 时,由于都是 520px 以下的值,代码并不会被执行

要完全响应每一次页面状态的改变,可以通过监听 window 对象的 resize 事件 来实现:

window.onload = function() {  
    reCenterImgs();

    addEventListener("resize", function() {
        var matches = (window.innerWidth < 520);
        formerState =
            (typeof formerState == "undefined") ?
            matches : formerState

        //Width changes from (520-)px to (520+)px
        //Or width is less than 520px
        if (matches != formerState
            || matches) {
            reCenterImgs();
            formerState = matches;
        }
    });
}

基本代码结构也和原来差不多,只是记录原状态的变量没有进行初始化而是在第一次监听到 resize 事件时赋值,后面的判断多了个 || matches,也就是在页面从 520px 以下变为以上或者一直在 520px 以下时,都执行重新设置 margin 的代码

到这里,Alpha 版本的 link-boxes 就已经完成了,你可以在 https://demos.kingsleyxie.cn/link-boxes-alpha/ 上查看效果,有两张明显宽高比过大的和一张明显高宽比过大的图片(Facebook 那张)……然后这个本来不打算考虑的高宽比过大的情况又有了一些麻烦,最大的问题就是加载的时候由于图片一开始没有高度限制导致破坏了整个页面的结构,直到代码执行才显示得正常了一点,其他的比如计算精度之类零零碎碎的问题其实还是有,所以就考虑用更优雅的方式实现了

优雅的实现

需求全部完成之后,当然是再好好休息一下尝试用更加优雅的代码实现同样甚至更好的功能啦

头像

写了那么多 JS 的代码基本都是实现居中的功能,很自然会想到通过 flex 布局来代替,给 img 标签加个父元素,做好尺寸限制并设置好布局方式即可,现在的 HTML 代码是这样的:

<div class="link-boxes">  
    <a href="LINK-HERE" class="link-box">
        <div class="avatar">
            <img src="AVATAR-LINK-HERE" alt="AVATAR-ALT-HERE">
        </div>
        <div class="nickname">NICKNAME-HERE</div>
    </a>

    <!-- or more `<a></a>` tags -->
</div>  

那么稍微修改一下对应的 CSS 就可以了,主要的改变如下:

.link-box .avatar {
    display: flex;
    justify-content: center;
    align-items: center;
    height: var(--img-size);
}
.link-box .avatar img {
    max-width: 100%;
    max-height: 100%;
}

之前写的那些 JavaScript 代码现在已经可以完全不用了,代码一下子简洁了好多23333,而且效果上也明显好很多,毕竟 CSS 实现的居中和通过 JS 手动写的强行居中还是有很大差别的

昵称

这也算是个细节问题吧,要保证卡片显示基本的统一,就不能让文字的换行干扰到排版布局,考虑到这个问题出现频率不算高,而且昵称方块的高度也不方便做得太大,所以还是直接把显示不下的元素以省略号的形式显示了:

.link-box .nickname {
    color: grey;
    margin: 15px 0;
    font-size: 1.3em;
    text-align: center;

    /* Text Overflow Handler */
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

注意其中的 white-space: nowrap; 也是很重要的,强制让空格间隔的词都显示在同一行,没有这一个样式的话空格后的内容也会被挤到下面一行

Release v0.0

那么到这里整个 link-boxes 的实现就已经完成了,这个最初的版本(v0.0)代码在 这里,Demo 地址是 https://demos.kingsleyxie.cn/link-boxes,对比一下可以很明显感觉到相对之前通过部分 JS 实现的版本而言有很大的改进

简化格式

标准简化格式约定

直接写 HTML 多少还是有些太麻烦了,要考虑用类似 Markdown 的格式进行简化,首先约定好格式:

<div class="link-boxes">  
    <!-- Standard Format For Each -->
    [nickname](link+avatar|alt)

    <!-- Or Without `alt` -->
    [nickname](link+avatar)
</div>  

然后写个正则表达式把四个部分分别提取出来,再替换成 HTML 里对应的内容就可以了,因为符号不多还是比较容易完成的,代码在后一小节一起给出

不带 alt 值的情况

并不是每个人都会愿意给图片设置一个 alt 值,为了方便还是做一个处理,匹配没有 | 符号的内容然后给加上一个 alt 值(这里默认设置成 avatart),接下来一起进行替换处理:

const filterReg = /\[(.*)\]\((.*)+([^\|]*)\)/g;  
const replaceReg = /\[(.*)\]\((.*)+(.*)\|(.*)\)/g;  
const divSelector = ".link-boxes";

document.querySelector(divSelector).innerHTML =  
document.querySelector(divSelector).innerHTML  
.replace(filterReg, '[$1]($2+$3|avatar)');

document.querySelector(divSelector).innerHTML =  
document.querySelector(divSelector).innerHTML  
.replace(replaceReg,
'<a href="$2" class="link-box">' +  
    '<div class="avatar">' +
        '<img src="$3" alt="$4">' +
    '</div>' +
    '<div class="nickname">$1</div>' +
'</a>'  
);
Release v0.1

写到这里,就是目前的最新版本 v0.1 了,代码在 这里,相比 v0.0 增加了一个格式的约定和 parser,不过实际上也不能真的称作 parser 吧,感觉是比较偏向暴力匹配替换的,不敢保证性能,另外要注意的一点是中间的几个分隔符 +| 之类的尽量不要作为一部分出现在设置的值里面,要不然在某些情况下会导致匹配混乱的 hhh

部署上线

部署上线第一定律(雾):

考虑的条件再多,在上线的时候也照样会发现各种千奇百怪的被忽略的情景

友链页面在 这里

分隔符

本来链接和头像链接之间的分隔符我是用 ~~ 的,Typecho 那个插件的语法是 [nickname](link)+avatar,当时考虑到其实加号好像也有时候会出现在 URL 里,就换了一个,而且由于我是用正则表达式匹配的,为了方便就把右括号放在末尾

然后放到页面上的时候……emmm……明明都是 <div> 区块内的东西了竟然还是把 ~~ 解析成了 <del> 也就是删除线,不知道是 parser 的问题还是特地提供了这个 feature

总之这样子的话这个分隔符就很不合适了,最后还是换成了 + 号,额外需要注意的一点是这个符号在正则表达式需要通过转义符转义,漏加转义符的话就会……嘿嘿嘿

头像大小

考虑了那么多图片大小的问题,死活没想到会被尺寸过小这种情景坑……

因为大部分实际使用的头像还是比较规则或者只是宽度略大于高度,所以把 width 设置成 100% 就基本解决问题了,只是对于高宽比过大的图片而言看起来就有些水平方向被拉扯的感觉了[捂脸]

至于怎么样平衡尺寸过小和高宽比过大两种情况的显示效果,就先挖个坑吧,以后有空再考虑

头像的 alt 值

自己写的时候才发现好像设置头像的 alt 值意义不大而且好麻烦

其实有个把 alt 自动设置成图片文件名的想法,实现的话其实也很简单,就是在 replace 的时候稍微修改一下就行了,原来是 replace(filterReg, '[$1]($2+$3|avatar)');,把后面的参数改为函数 filter,然后在函数里面把增加的内容改成图片文件名即可:

function filter(m, p1, p2, p3) {  
    //Get the short path of image
    var t = p3.split('/')
    t = t[t.length - 1];

    //Rejoin the string with an `alt` value
    return '[' + p1 + '](' + p2 + '+' + p3 + '|' + t + ')';
}

后期可能考虑直接去掉 alt 值的选项

结语

这个链接卡片盒的实现也算是折腾了蛮久的,今年(2018年)的元旦完成那个 JS 实现的版本后开始就打算写一篇博客介绍,咸鱼了差不多一个月后才加需求做出简化格式版的 v0.1

主要耗时的部分还是一些图片尺寸限制和居中显示的问题,我记得大一下的时候写 书籍预约系统 的时候也思考了蛮久关于图片布局的问题,不过那时候还不太熟悉 flex 布局,而且大部分的书籍图片也都比较中规中矩,所以 xjb 写也不会有什么问题(逃

代码的 Repo 一开始是在 这里,后来为了方便后期可能的更新和维护把它单独提取出来命名为 link-boxes 了,同时也有简略的 README,至于 Demo 的地址前面已经给过就不重复了

最后,三点小总结:

  • flex 布局大法好

  • 涉及样式的操作能用 CSS 的就用 CSS,尽可能避免通过 JavaScript 直接操作和更改样式

  • ……实践是检验真理的唯一标准!(逃