分类
js

利用 Web Speech API 实现语音阅读试题(TTS)

孩子上小学一年级,寒假作业里有一项:每天做20个20以内的加减法。这个作业老师不直接布置,而是让家长负责,方式任意。那么,显而易见,这个工作就由我负责。然后我就顺手抄起 Vue 做了一个 Web App:http://mui.evereditor.com,源代码在:https://github.com/meathill/mui-teacher

然后老婆说,别的教学应用都会把题目念出来,这样比较有上课的感觉,问我能不能也把题目念出来。我之前大略有些印象,可以直接调用浏览器的原生 API 实现 TTS(text to speech),于是就想试试看吧。

使用“Web speech API”作为关键词,很容易找到 MDN 上的这个页面,继而了解到 SpeechSynthesisUtterance 这个类,接下来就简单了,直接参考 Using the Web Speech API 里的 Demo,就完成了下面代码:

doRead(index) {
  const content = this.$refs.line[index].textContent;
  const msg = new SpeechSynthesisUtterance(content.replace('-', '减'));
  speechSynthesis.speak(msg);
},

这里有三个注意事项:

  1. 系统会把 - 读成“至”,但我这里是加减的“减”,所以我要手动把它替换一下
  2. 从某个版本开始,发音必须由用户主动触发,即放在交互性事件里,不能在页面打开时自动读
  3. 发音效果跟浏览器有关,目前 Chrome 和 Safari 效果比较好

这样的 TTS,对于整段文字来说,效果一般,但是读一般的算式足够了,小朋友也挺喜欢。这个 API 还可以用作语音输入,不过考虑到模型的效果,没有尝试,想真正可用的话,还是用那些高级的在线版本吧。


图文无关,想出去玩……

分类
php

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

分类
篮球

再见科比

小时候,受大我4岁的表哥影响,开始看 NBA。当时应该是乔丹第一次退役复出,我印象最深的是他的后仰式跳投和各种总决赛。

乔丹是公认的王者。乔丹二次退役之后,大家开始为他寻找接班人,当时呼声最高的是希尔、哈达威和科比。前两位看起来都比科比合适,更有风度,更儒雅随和。

结果大家都知道了,希尔、哈达威都只有灵光一现,只有科比所在的湖人成就三连冠霸业——但是这里面有多少应该归功奥尼尔,到现在也争不出个定论。

我当时也不喜欢科比,主要是觉得他目无尊长,让乔丹在最后一次全明星抱憾而归。接下来,乔丹彻底退役,我对 NBA 的兴趣也下降了,进入云球迷阶段。不过当时 NBA 也真不好看,原因有三:

  1. 没有统治级球队,没有传奇
  2. 马刺和活塞携手带来泥潭摔跤模式……
  3. 姚明加入 NBA,火箭队成为中国球迷的大主队,常规赛几乎只播火箭队;但是火箭队季后赛都是一轮游,对于云球迷来说,往后面看都不知道谁在打谁……

这种情况持续到 2008 年奥运会,科比来到中国,显示出巨大的影响力,让我感到震惊。同年,加索尔加盟湖人,帮助科比三进总决赛,并且夺下两冠。

时隔多年,我惊讶的发现,自己从一代科黑,生生的被改造成科粉。

以前觉得科比喜欢浪投,喜欢打英雄球;现在觉得,那是科比自信和负责任的表现。

以前觉得科比妨碍了乔丹最后一次表演;现在觉得,职业赛场上,全力以赴也是一种尊重。(感谢德国 8:0 沙特,7:1 巴西)

以前崇拜超级英雄,梦想成为奥尼尔、詹姆斯这种身体素质惊人的超人;现在工作了,发现自己更可能是芸芸众生中的一员,开始喜欢科比这样兢兢业业全力以赴的斗争精神。

另外,互联网的发展让信息传播得更快更多更好,我们也可以看到更多“非”比赛的信息。比如科比手指骨折简单处理一下就上场,跟腱断裂后单脚罚球,等等。让人不得不佩服这位硬汉。

然而我怎么也没想到,他的生命会在今天早上戛然而止,以这样一个无厘头的方式告别世界——乔丹、奥尼尔、甚至詹姆斯,你都可以想象他们无厘头的样子,唯独科比,我脑海里只有他咬碎钢牙战战战战到底的样子……还带着他最会打篮球的女儿——那个前几个月才以 115-27 狂胜对手而被大家熟知的女儿,我都不敢想象在人生的最后时刻,他们爷儿俩是怎么过的……

世事无常,且行且珍惜。今天起开始复工,以纪念科比。

分类
服务器端

配置 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;
        }
}
分类
许愿

远程工作第三年:2019年总结

去年,我 35 岁,一不小心就达到这个尴尬的年龄。这一年,是我远程工作的第三年,也是我在 OpenResty Inc. 工作的第二年。去年,儿子开始上小学,正式受教育。

分类
前端工具链

Webpack 4 笔记

之前的《Webpack 笔记》内容有些陈旧,不打算再改,改名为《Webpack 3 笔记》。今后关于 Webpack 4 的笔记写在这里。如果将来 Webpack API 再次发生巨大变化,类似 3->4 这样,就再开一篇。

分类
教程

给 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

Chrome Devtool Protocol 开发笔记

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

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

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

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

分类
技术

正则笔记

常看常新的正则教程

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

匹配中文及中文标点

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

ES2018~ES2019 中的正则

分类
技术

导出 CSV 时有数字的处理

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

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

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