分类: 技术

各种开发心得,包括语言、软件工程、开发工具等

  • 给 Markdown 里的图片增加样式

    给 Markdown 里的图片增加样式

    在 markdown 里添加图片很容易,用这个语法即可:

    ![alt](/path/to/image "title")

    但是如果图片需要一些特殊样式,就不太好搞。比如前两天,老板觉得博文中的二维码太大,不好看,让我改小点。我一开始只知道可以用 HTML 来做,因为 Markdown 内建支持所有 HTML,但总觉得不够优雅,尤其是,不知道怎么要求将来写博客的人都用 HTML 来写。

    于是 Google 之,找到这篇文章:How to Style Images With Markdown,写得非常好,列举了很多添加样式的方法,尤其是使用 #hash + CSS 选择器的方法,很有想象力,推荐给大家。

    首先,在 Markdown 里,给图片的 URL 添加 hash。这个动作并不会造成任何实质性的影响。

    ![alt](/path/to/image#thumbnail)

    然后,在 CSS 里,定义“src 里包含字符串 #thumbnail”的规则即可:

    img[src*="#thumbnail"] {
      max-width: 10rem;
    }

    [attr*=val] 选择器的意思是 attr 属性里包含 val 字符串。这里用 [src$="#thumbnail"] 效果是一样的。如果你想了解所有 CSS 属性选择器,还是推荐看 MDN 文档

  • 解决 Raspberry Pi 4 安装 php-mbstring/php-curl 的问题

    解决 Raspberry Pi 4 安装 php-mbstring/php-curl 的问题

    最近要在 flarum 上做二次开发,尝试直接用 php -S localhost:8080 未果,于是打算在树莓派上搭个开发环境,省得它整日落灰。

    因为在本地创建过仓库,所以这次直接从 GitHub clone 项目下来,然后打算执行 composer install 安装依赖。结果提示差了 php-mbstring(解决汉字等多字节字符)和 php-curl(用于远程请求)两个模块。然后我就打算用 apt install php-curl 安装模块,没想到失败了,仔细看错误信息,因为这个模块依赖 libcurl3,但是系统里是 libcurl4,所以不行。

    那就安装 libcurl3 呗,结果系统认为明显 libcurl4 更新,不给装 3,哪怕删了重装都不行。

    后来查了半天,找到答案。原来我添加的源是 stretch 的,也就是面向 Debian 9 的;而 Raspberry Pi 的系统是基于 Debian 10,也就是 buster 的,所以依赖处理上,两方面就冲突了。

    这个时候,需要用 sh -c 'echo "deb https://packages.sury.org/php/ buster main" > /etc/apt/sources.list.d/php.list' 把属于“buster”的源添加进系统,接着删掉之前 stretch 的源,然后 apt update 之后,就可以正常安装了。


    参考链接:https://github.com/oerdnj/deb.sury.org/issues/1193

  • 笔记:七牛云续费免费证书

    笔记:七牛云续费免费证书

    这是一篇笔记,没啥技术含量。

    七牛云有一定的免费额度,对于我这种技术博客来说,可用量充足,所以我很早就开始使用他们家的服务。我启用了二级域名 qiniu.meathill.com,用来存放所有静态资源。

    主站 HTTPS 之后,如果加载非 HTTPS 资源,也会报错。所以需要在七牛也开启 HTTPS。这就需要 SSL 证书。七牛提供免费的 TrustAsia 证书,但是一次只能买一年份,不能自动续期,每次换证书都要折腾好久,所以简单写个笔记记一下。

    (更多…)
  • PHP built-in web server 支持自动查找入口

    PHP built-in web server 支持自动查找入口

    使用 php -s localhost:8080 可以快速启动一个开发服务器,非常方便,是我现在需要简单服务器支持时的首选。

    不过我最初了解到这个功能的时候,它(可能)还不支持请求重写,也就是说,我们访问 /foo/bar,它就会去当前目录里查找 /foo/bar,找不到就 404。如果想要实现 index.php 重定向,必须手动编写路由文件,比较麻烦。我宁可用 nginx 实现,因为部署上线的时候早晚要用。

    最近偶然发现,PHP built-in web server 已经支持请求重写了,如果命中,就会直接返回目标文件;如果没有命中,就会沿着目录往上找,直到找到 index.php 或者 index.html,或者到启动服务器的根目录,然后把请求地址放在 $_SERVER['PATH_INFO'] 里,留待 php 处理。

    这样一来,无论是 WordPress,还是 Laravel,还是其它基于路由的单一入口项目,都可以直接使用 PHP built-in web server 开发了,简单方便快捷。甚至连纯前端项目,如果你不熟悉服务器端的配置,也可以简单的安装一个 PHP 来实现。

    比如,在本地开发 WordPress,可以这样:

    # 安装 php 和 mysql
    brew install php
    brew install mysql
    
    # 配置 mysql root 用户密码,替换下面的 `NEWPASS` 
    $(brew --prefix mysql)/bin/mysqladmin -u root password NEWPASS
    
    # 下载并解压 wordpress.zip,进入目录,启动服务器
    php -S localhost:8080
    
    # 完成!

    参考文档:

    PHP manual: Built-in web server

  • 配置 nginx 支持目录别名

    配置 nginx 支持目录别名

    我有一个小项目,我们假定它叫 up,是用 PHP 写的私人图床,部署在服务器上,域名是 up.meathill.com。因为是 PHP,不需要编译,直接 `git clone` 仓库然后配置 nginx 指过去就好。

    后来想做一个在线预览的功能,是个单页应用,也放在这个仓库里,目录是 `up/fe`,项目用 Vue + Webpack 来做,源码放在 `up/fe/src`,生成的文件放在 `up/fe/dist`。

    这样一来,我就需要在原本的 nginx 里配置一个虚拟目录 `/admin`,指向生成的文件。预览的文件路径就是 `/admin/${file}`,所以我还要把未命中的文件重定向到 `index.html

    经过反复摸索,最终的配置如下:

    {
            location / {
                    # 未直接命中的请求都交给根目录里的 index.php 处理
                    try_files $uri $uri/ /index.php$args;
            }
    
            # 默认的 nginx 1.14 + PHP 7.1 + php-fpm 配置
            location ~ .php$ {
                    include snippets/fastcgi-php.conf;
                    fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
            }
    
            # /admin 的访问指向编译后的前端资源
            location /admin {
                    alias /mnt/www/uploader/fe/dist;
            }
    
            # 直接访问的地址解析,正则其实不必要
            location ~ "/admin/(?<page>d{8}-d{6})$" {
                    alias /mnt/www/uploader/fe/dist/index.html;
                    # 这里的定义很重要,默认是 application/octet-stream 会启动下载
                    default_type text/html;
            }
            
            # 把不希望看到的请求屏蔽掉
            location /fe {
                    return 404;
            }
    }
  • 给 Bootstrap Modal 增加缩放功能

    给 Bootstrap Modal 增加缩放功能

    需求

    Bootstrap 应该还是目前最流行的前端基础框架之一。因为架构方面的优势,它的侵入性很低,可以以各种方式集成到其它项目当中。在我厂的各种产品里,都有它的用武之地。

    前两天,老板抱怨,说 Modal(弹窗)在他的屏幕上太小,浪费他的 5K 显示器。

    我看了一下,按照 Bootstrap 的设计,超过 1200px 就算是 XL,此时 .modal-lg 的宽度固定在 1140px。其实 Bootstrap 这么设计也有它的道理,因为人眼聚焦后宽度有限,如果弹窗太宽的话,内容一眼看不全,也不好。不过在我厂的产品里,弹窗要呈现火焰图,所以宽一些也有好处。

    技术方案

    那么,综合来看,最合适的做法,就给 Modal 添加一个拖拽的功能:用户觉得够大了,就这么着;用户想看大一点,就自己拉大一些,然后我记录用户的选择,以便复用。

    看过我《用 `resize` 和 MutationObserver 实现缩放 DOM 并记录尺寸》的同学,应该知道 resize 这个 CSS 属性,使用它可以很方便的给元素添加缩放功能。参考 caniuse 上面的普及度,大部分新版本的浏览器都已经支持,可以放心使用。

    使用它的时候要注意两点:

    首先,我们在缩放元素的同时,也会对它的子元素、父元素同时造成影响。因为在静态文档流当中,块级元素的宽度默认是父元素 content-box 的 100%,而高度由子元素决定。所以,对一个块级元素的缩放,不可能宽过它的父元素(如果限制了宽度的话),也不可能矮于它的子元素。

    其次,拖拽手柄的显示优先级很低,会被子元素盖住,哪怕子元素没有填充任何内容。换言之,一定要有 padding 的元素才适合添加 resize 缩放。

    实施方案

    总而言之,把这个属性加在哪个元素上面,很有讲究。具体到本次需求,Bootstrap Modal,最合适添加 resize 属性的的是 modal-content,因为它有 1rem 的内边距。

    但是限制宽度的是父元素,也就是 modal-dialog,它是响应式的,会根据显示器的宽度设置一个最大宽度。如果不修改它的 max-widthmodal-content 的最大宽度就无法超过它,达不到预期效果。但是也不能改成 width,这样的话,弹窗会失去弹性,分辨率低的时候表现不好。

    所以还是要在 max-width 上做文章。如果直接去掉它,modal-dialog 的宽度就会是 100%,失去弹窗效果,所以也不能这样做。最终,我的方案是:

    1. 窗口完全展开后,获取宽高,赋给 modal-content
    2. 去掉 modal-dialogmax-width
    3. 用 MutationObserver 监测 modal-content 的宽高,保存 localStorage,以便接下来使用

    完整代码展示

    我厂的产品基于 Vue 开发,所以部分逻辑用 Vue 组件实现。

    效果演示

    为方便在 Codepen 里呈现,有部分修改。

    代码及解释

    <template lang="pug">
    .modal.simple-modal(
      :style="{display: visibility ? 'block' : 'none'}",
      @click="doCloseFromBackdrop",
    )
      .modal-dialog.modal-dialog-scrollable(
        ref="dialog",
        :class="dialogClass",
      )
        .modal-content(ref="content", :style="contentStyle")
          .modal-header.p-2
            slot(name="header")
              h4 {{title}}
            span.close(v-if="canClose", @click="doClose") ×
          .modal-body
            slot(name="body")
    </template>
    
    <script>
    import debounce from 'lodash/debounce';
    
    const RESIZED_SIZE = 'resized_width_key';
    let sharedSize = null;
    
    export default {
      props: {
        canClose: {
          type: Boolean,
          default: true,
        },
        size: {
          type: String,
          default: null,
          validator: function(value) {
            return ['sm', 'lg', 'xl'].indexOf(value) !== -1;
          },
        },
        resizable: {
          type: Boolean,
          default: false,
        },
        backdrop: {
          type: Boolean,
          default: true,
        },
        title: {
          type: String,
          default: 'Modal title',
        },
      },
    
      computed: {
        dialogClass() {
          const classes = [];
          if (this.size) {
            classes.push(`modal-${this.size}`);
          }
          if (this.resizable) {
            classes.push('modal-dialog-resizable');
          }
          if (this.resizedSize) {
            classes.push('ready');
          }
          return classes.join(' ');
        },
        contentStyle() {
          if (!this.resizable || !this.resizedSize) {
            return null;
          }
          const {width, height} = this.resizedSize;
          return {
            width: `${width}px`,
            height: `${height}px`,
          };
        },
      },
    
      data() {
        return {
          visibility: false,
          resizedSize: null,
        };
      },
    
      methods: {
        async doOpen() {
          this.visibility = true;
          this.$emit('open');
          if (this.resizable) {
            // 通过 debounce 节流可以降低函数运行次数
            const onResize = debounce(this.onEditorResize, 100);
            // 这里用 MutationObserver 监测元素尺寸
            const observer = this.observer = new MutationObserver(onResize);
            observer.observe(this.$refs.content, {
              attributes: true,
            });
    
            if (sharedSize) {
              this.resizedSize = sharedSize;
            } 
            // 第一次运行的时候,记录 Modal 尺寸,避免太大
            if (!this.resizedSize) {
              await this.$nextTick();
              // 按照张鑫旭的说法,这里用 `clientWidth` 有性能问题,不过暂时还没有更好的解决方案
              // https://weibo.com/1263362863/ImwIOmamC
              const width = this.$refs.dialog.clientWidth;
              this.resizedSize = {width};
              // 这里产生纪录之后,上面的 computed 属性就会把 `max-width` 去掉了
            }
          }
        },
        doClose() {
          this.visibility = false;
          this.$emit('close');
        },
        doCloseFromBackdrop({target}) {
          if (!this.backdrop || target !== this.$el) {
            return;
          }
          this.doClose();
        },
    
        onEditorResize([{target}]) {
          const width = target.clientWidth;
          const height = target.clientHeight;
          if (width < 320 || height < 160) {
            return;
          }
          sharedSize = {width, height};
          localStorage.setItem(RESIZED_SIZE, JSON.stringify(sharedSize));
        },
      },
    
      beforeMount() {
        const size = localStorage.getItem(RESIZED_SIZE);
        if (size) {
          this.resizedSize = JSON.parse(size);
        }
      },
    
      beforeDestroy() {
        if (this.observer) {
          this.observer.disconnect();
          this.observer = null;
        }
      },
    };
    </script>
    
    <style lang="stylus">
    .simple-modal
      background-color: rgba(0, 0, 0, 0.5)
      .modal-content
        padding 1em
    
        .close
          cursor pointer
    
    .modal-dialog-resizable
      &.ready
        max-width unset !important
    
      .modal-content
        resize both
        margin 0 auto
    </style>

    注意

    因为浏览器的异步加载机制,有可能在 modal 打开并完成布局后,高度和宽度被内容撑开导致记录不准,或者内容被异常遮盖。请读者自己想办法处理,就当练习题吧。

    总结

    本次组件开发非常符合我理想的组件模式:

    1. 充分利用浏览器原生机制
    2. 配合尽量少的 JS
    3. 需要什么功能就加什么功能,不需要大而全

    在 MVVM 框架的配合下,这样的方案很容易实现。另一方面,每个项目都有独特的使用场景,通过长期在特定场景下工作,我们可以逐步整理出适用于这个场景的组件库,不断改进该项目的开发效率。我认为这才是组件化的正道。

  • Chrome Devtool Protocol 开发笔记

    Chrome Devtool Protocol 开发笔记

    周末动笔写教程,越写越长暂时收不了笔;周日发现上一台 iMac 回收被坑了,显示器从烧屏变闪屏了,心情大坏,简直写不动了,哎……

    Chrome Devtool Protocol(下面简称 CDP)是一个非常强大工具,简单来说,它可以揭开束缚 Chrome 的各种封印,从浏览器角度深入页面(及其它领域,包括 worker),完成一些平日里难以完成的操作。

    我目前研究它主要是想优化我厂官网的首屏 CSS,顺便为将来 QA 插件的深入开发做准备。

    不过 CDP 的文档、资料各种不全,Google 也没什么结果,所以我会把日常踩到的坑记到这里,以备回顾。

    (更多…)
  • 正则笔记

    正则笔记

    常看常新的正则教程

    正则表达式30分钟入门教程

    匹配中文及中文标点

    如今建议用 /\p{sc=Han}/gu,参见:JavaScript 中使用正则 `u` 标记匹配多语言。

    const reg = /[\u4E00-\u9FCC\u3002\uff1b\uff0c\uff1a\u201c\u201d\uff08\uff09\u3001\uff1f\u300a\u300b]+/g

    ES2018~ES2019 中的正则

    转大写/小写

    转大写 \U\u,转小写 \L\l。比如 camel case 转 kebab case,可以查找 [A-Z],替换为 \L$0

    IDE 里可以,JS 不行。

    判断行开头或xx

    比如 css 规则中的值,我们可能会用 /[\s:](值表达式)/ 这样的表达式,但它没法匹配行的开头,^ 不能放到 [] 里。此时只需要 (^|[\s:]) 即可。

  • 导出 CSV 时有数字的处理

    导出 CSV 时有数字的处理

    导出数据为 CSV 的时候,如果是纯数字,可能会被 Excel 启动科学记数法,丢掉最后几位。

    以前比较流行在数字前加 `,强制把数字转换成文本,这样做能解决数字被取整的问题,坏处是字段变文本,影响观感,且无法应用公式。

    正确的做法是用公式,变成 ="${number}",这样首先看不到多余的 \,其次公式也能正常生效。

  • npm 支持开发者添加募款链接

    npm 支持开发者添加募款链接

    昨天随手升级个人首页的依赖,更新完之后,npm 提示:

    6 packages are looking for funding
      run `npm fund` for details

    如果你执行 npm fund,可以看到类似这样的信息:

    your-repo@0.1.0
    ├─┬ core-js@3.4.2
    │ ├── type: opencollective
    │ └── url: https://opencollective.com/core-js
    ├─┬ eslint@6.7.0
    │ └── url: https://opencollective.com/eslint
    └─┬ swiper@5.2.1
      ├── type: patreon
      └── url: https://www.patreon.com/vladimirkharlampidi

    opencollective 和 patreon 都是众筹网站,只不过众筹的内容不是实物商品,而是支持你喜欢的创作者,无论他是摄影师、画家,还是开源软件作者。据说 Vue 的作者尤雨溪每年可以从 Patreon 上获得将近 $20w 的捐赠,这笔钱可以帮助他以独立身份继续开发 Vue,还可以雇佣一名全职开发者帮助他把这份工作做得更好。

    我去 NPM 官网找了一下,npm fund 的文档还没更新上去,只找到这篇 RFC: Add funding support to package.json,大意是包开发者可以在包的描述文件(package.json)里放上一段募款声明,这样包的用户在安装的时候,就会看到我开篇写的那些文字。提醒包的开发者:你们可以通过捐赠的方式,表达对依赖开发者的感谢。

    我觉得这种形式挺好的。当然,有些人可能看不惯这种有“伸手嫌疑”的行为,但是,与其在无酬工作中纠结、放弃自己的项目、甚至被别有用心的人利用埋后门,任何不影响安全性、不增加使用负担的变现方式都是合理且值得鼓励的。

    希望开源软件开发者越过越好,开源基建开发者越过越好,世界越来越好。