lisongfeng 发布的文章

标题说的是官网,但实际上本文主要介绍的是展示大图的页面,也就是概述页。为简便起见,本文以下用“官网”代称“360 AI音箱官网概述页”。

这次官网的设计稿是提前4天才开始出的,最后一个页面是上线前一天下午2点给的。总之,时间非常紧,没有充裕的时间去调研多种实现方案。开发中采用了比较保守、兼容性好的技术。

基本数据

说到大图,就得知道主流显示器的分辨率。京东在销显示器的分辨率如下:

  • 3840×2160
  • 3840×1080
  • 3440×1440
  • 2560×1440
  • 2560×1080
  • 1920×1200
  • 1920×1080

维基百科2019年1月调查结果如下(https://p4.ssl.qhimg.com/t01fff3464ffc757200.png):

标准比例宽度(px)高度(px)用户(%)
nHD16:96403604.26
SVGA4:38006000.42
XGA4:310247683.29
WXGA16:912807203.15
WXGA16:1012808003.88
SXGA5:4128010243.37
HD~16:913607681.65
HD~16:9136676824.27
WXGA+16:1014409006.55
非标16:915368645.5
HD+16:916009004.81
WSXGA+16:10168010502.52
FHD16:91920108019.20
WUXGA16:10192012001.20
QWXGA16:9204811520.46
非标21:925601080n/a
QHD16:9256014402.09
21:934401440n/a
4K UHD16:938402160n/a

也就是说,2560像素及以下宽度屏幕的用户占86%以上。为此,官网所有大图最大宽度为2560像素。

图片数25
大图数15
页面体积6.8MB

接下来,本文主要从以下几方面概述官网开发涉及的技术点:

  • 呈现大图
  • 弹性文字
  • HTML结构
  • CSS布局
  • 自定义字体
  • 固定页脚

呈现大图:imgbackground-image

为简单起见,我们将整个页面的开发分成几个部分。首先从大图的呈现说起。

img标签可以直接把包裹它的元素撑起来,并且能在浏览器窗口缩放时保持宽高比同步缩放。

background-image因为是背景图片,所以需要明确设置包裹元素的宽度和高度,才能在浏览器窗口缩放时不至于少显示图片。而且,即使使用了background-size: cover,也需要额外通过媒体查询在某个断点重新设置包裹元素的宽度和高度,以保持元素与图片具有相同的宽高比。

出于快速开发的目的,我们选择了使用img标签:

<figure class="full-img">
  <img src="https://p1.ssl.qhimg.com/t01f061f44107fd8ed1.jpg">
</figure>

如下图所示:

弹性文字:remem

大图页需要适应屏幕缩放同步缩放文字大小。因此,需要在使用remem单位间做出选择。

rem是根元素htmlfont-size值,而em则是body元素的font-size值。使用这两个单位都可以实现对应文字大小的全局性缩放。区别在于,rem是全局性唯一参照大小,直接修改htmlfont-size就可以全局缩放文字大小;而em单位基于从父元素继承的大小缩放,只能通过缩放父元素的font-size来修改页面局部的继承大小。

为此我们决定使用简单直接的rem。 首先,根据设计稿确定某个屏幕宽度下对应的htmlfont-size值。比如,大约1200像素时,对应的rem为100像素(之所以设置为100像素,是为了换算方便):

html {
  font-size: 100px;
  body {
    font-size: .16rem;
    min-width: 1226px;
    background-color: #ffffff;
    font-family: sans-serif;
  }
}

然后再设定一个缩放系数,比如12,通过JavaScript来根据浏览器窗口大小动态修改rem大小。我们定义了一个模块ajust_rem.js,导出adjustRemOnResize类:

import throttle from 'lodash.throttle'

export default class adjustRemOnResize {
  constructor ({divisor, remBaseSize}) {
    this.divisor = divisor
    this.remBaseSize = remBaseSize
    this.html = $('html')
    this.resize()
    $(window).on('resize', throttle(this.resize.bind(this), 300))
  }
  resize () {
    const currentWidth = $(window).width()
    const remDyncSize = currentWidth / this.divisor
    const fontSize = remDyncSize < this.remBaseSize ? this.remBaseSize : remDyncSize
    this.html.css({fontSize})
  }
}

在相应页面引入这个类,传入参数,创建实例:

import adjustRemOnResize from './ajust_rem.js'

const adjustRemOnResizeOptions = {
  divisor: 12,
  remBaseSize: 100
}
new adjustRemOnResize(adjustRemOnResizeOptions)

remBaseSize其实就是最小rem值。从前面的代码可知,当浏览器窗口宽度为1200像素时,除以12就是100像素。相应地,当浏览器窗口宽度为2400像素时,除以12,rem就是200像素。

而所有尺寸(文字大小、内外边距、定位偏移等)需要根据窗口大小动态改变的值,都使用类似如下的方式来声明:

small {
  font-size: .12rem;
}
.double-engine {
  h2 {
    margin-bottom: 0.2rem;
  }
  figure {
    margin-top: 0.6rem;
  }
}

比如,small标签中的文本大小在rem为100像素时为12px:

而在rem为200像素时则会变成24px:

HTML结构

分别确定了处理大图和文字的方式,接下来更进一步,处理二者结合在一起的情况。换句话说,就是要把文字定位到大图上面,此时的HTML结构如下:

<section>
  <article>
    <h3>囊括三大音乐平台资源,千万音乐随心畅听</h3>
    <p>目前已经接入腾讯音乐集团旗下酷狗音乐,QQ音乐、酷我音乐接入中,囊括市场排名前三音乐平台,千万音乐随心畅听。</p>
    <small>* 部分音乐服务需要登录对应账号后才能享用免费服务,免费服务有效期1年,有效期结束后服务方式以官方公告为准</small>
  </article>
  <figure>
    <img src="https://p4.ssl.qhimg.com/t010b8bdd20c5668ae4.png">
  </figure>
</section>

其中,每个section代表音箱的一个卖点,卖点要通过大图和文字来展现:

  • article:文字区

    • h3:标题
    • p:内容
    • small:说明
  • figure:大图区

    • img:大图

CSS布局

基于上一节的HTML结构,CSS技术使用了最为保守、兼容性最好的定位和行内块居中。

首先,来看一下定位技术的运用:

section {
  position: relative;
  article {
    position: absolute;
    p {
      text-align: justify;
    }
  }
  figure {
    width: 100%;
    margin-top: 0.2rem;
    img {
      width: 100%;
    }
  }   
}

如前所述,包含卖点的section默认自然是一个块级元素,它的直接子元素只有两个:articlefigure。这里的CSS把section设置为相对定位,为其子元素创建一个定位上下文。而通过把article设置为绝对定位,就可以实现将其移出正常布局流同时又相对于其定位上下文section定位的目的。

figure呢?自然还包含在section内部,并且其包含的img通过引用大图,并将宽度设置为100%,最终会将整个figure乃至section撑至满屏。

这样,对于包含卖点的article通过设置百分比单位的left属性和width值,就可以将文字相对于大图任意定位,比如“儿童模式”的HTML:

<li class="child-mode">
  <section>
   <article class="intro-left intro7">
    <h3>专属儿童模式<br>精选内容助力成长</h3>
    <p>内容运营团队针对不同年龄段儿童,精选定制化的音频内容与百科知识库,提供科学的儿童内容。
    <br>云端建立儿童内容黑名单,屏蔽不适合小朋友收听的音乐与音频内容,避免受到不良内容的伤害。</p>
    <figure><img src="https://p5.ssl.qhimg.com/t017963a80a701e29ba.png"></figure>
   </article>
  <figure class="full-img">
    <img src="https://p5.ssl.qhimg.com/t0172cdc426c2f437c3.jpg">
  </figure>
 </section>
</li>

CSS如下:

.intro-left {
  left: 19%;
}
.child-mode {
  .intro7 {
    top: 1.2rem;
    width: 26%;
    h2 {
      margin: 0.1rem 0;
    }
    figure {
      width: 100%;
      margin-top: 0.2rem;
      img {
        width: 100%;
      }
    }
  }
}

除了使用绝对定位将文字定位到任意位置,还有一个通用需求是把文字定位在页面/大图水平中间。

这时候,我们使用了传统的display: inline-block;技术,即把包含文字的块级元素变成行内块,而在其父级通过text-align将其居中:

<article class="middle-heading-position-top intro6">
  <div>
    <h2>双重智能引擎<br>大人孩子都喜欢</h2>
    <p>双重智能唤醒,内置两套独立唤醒词与应答语音,可以温柔贴心,也可以稚气天真。<br>儿童专属童声语音,节奏更轻缓,语调活泼,更符合小朋友的沟通习惯。</p>
  </div>
</article>

CSS:

.middle-heading-position-top {
    text-align: center;
    width: 100%;
    div {
      display: inline-block;
    }
}

注意,无论是使用定位,还是居中行内块,都要恰当配合百分比单位的使用。

自定义字体

官网需要使用多种不同字重的思源黑体。这正是奇字库派上用场的地方,只要在模板包含文件里加上如下script代码:

奇字库是360内部自建的针对版权和开源字体一个字体托管和动态截取服务,使用基于Web Font Loader(由Goolge和Adobe联合开发)定制的Web字体加载脚本,可以一键式向网页中引入自定义Web字体。
<script>
(function(d) {
  var config = {
    fontFamily: ['siyuan-regular', 'siyuan-normal'],
    fontHash: ['/*siyuan-regular-hash*/', '/*siyuan-normal-hash*/'],
    urlType: 'data',
    scriptTimeout: 3000,
    async: true,
  },
  // ……省略加载脚本……
})(document);
</script>

就可以在CSS里像使用本地字体一样使用自定义字体了:

.wf-siyuanregular-n4-active .feature-list h2 {
  font-family: siyuan-regular, sans-serif;
}
.wf-siyuannormal-n4-active .feature-list p,
.wf-siyuannormal-n4-active .feature-list small {
  font-family: siyuan-normal, sans-serif;
}

固定页脚

“让该死的页脚始终呆在页面底部”是所有页面的共同需求。推荐一下纯CSS解决方案:

* {
 box-sizing: border-box;
}
*:before,
*:after {
 box-sizing: border-box;
}
html,
body {
 height: 100%;
 position: relative;
}
.main-container {
 min-height: 100vh; /* 与视口同高 */
 overflow: hidden;
 display: block;
 position: relative;
 padding-bottom: 100px; /* 控制页脚高度 */
}
footer {
 position: absolute;
 bottom: 0;
 width: 100%;
}

这个方案的优点:

  • 纯CSS,无JavaScript
  • 适用于所有支持视口单位(vh)的浏览器

唯一的缺点:

  • 需要通过容器元素的padding-bottom控制页脚的实际高度

感谢作者 @zero

小结

由于时间紧,官网没有采用Flexbox等比较先进的技术,而是选择了保守但兼容性好的定位和行内块居中技术,当然别忘了在父级和子级配合使用百分比宽度。另外,由于有了奇字库(360内部字体托管和截取服务),让使用自定义Web字体也变得非常简单。本文最后还顺便推荐了一个解决页脚始终显示在底部的纯CSS方案。

2018年11月26日发表的“360 AI音箱H5开发实践”一文中,曾简单提到“与Native交互”。本文将就此主题深入探讨H5与App交互的几种常见模式。

首先声明,本文涉及的H5与App交互协议和模式没有什么特别独到之处,相反,它们恰恰是在业界既有经验基础上结合项目实际归纳提炼出来的。因此,文中涉及的技术和代码可以看作是行业经验落地的产物,不涉秘,也不是权威做法,仅供业界同仁参考。

本文内容如下:

  • 概述
  • 基础接口
  • 单向调用
  • 双向调用
  • 实现模式
  • 哪方主导

1. 概述

H5,在中国被专门用来指代开发内嵌于手机应用中的网页的技术,外国好像并没有这个说法。从技术上讲,H5是HTML5即Hyper Text Markup Language(超文本标记语言)第5版的简称。而HTML只是开发网页要用到的多种技术之一。除了HTML,还要用CSS设计界面,用JavaScript实现交互,甚至要用Node.js实现服务端逻辑。为什么H5会被用来笼统地指代这些技术呢?我猜一是因为它简单,二是移动端网页开发技术又恰好需要这么一个概念。

移动端网页运行在手机应用内嵌的浏览器引擎中,这个没有UI的内核容器统称WebView,即iPhone的UIWebView(iOS 2.0–12.0)、WKWebView(iOS 8.0+,macOS 10.10+)和Android的WebView。总之,WebView就是在手机应用中运行和展示网页的界面和接口(神奇的是,英文Interface,既可以翻译成“界面”也可以翻译成“接口”)。

H5与原生应用的交互都是通过原生应用中的WebView实现的。通过这个环境,H5可以调用原生应用注入其中的原生对象的方法,原生应用也可以调用H5暴露在这个环境中的JavaScript对象的方法,从而实现指令与数据的传输。

比如,在Android应用中,WebView类有一个公有方法addJavascriptInterface,签名为:

public void addJavascriptInterface (Object object, String name)

调用这个方法可以向WebView中以指定的名称name注入指定的Java对象object。这样,WebView中的JavaScript就可以通过name调用object的方法。比如:

 class JsObject {
    @JavascriptInterface
    public String toString() { return "injectedObject"; }
 }
 webview.getSettings().setJavaScriptEnabled(true);
 webView.addJavascriptInterface(new JsObject(), "injectedObject");
 webView.loadData("", "text/html", null);
 webView.loadUrl("javascript:alert(injectedObject.toString())");

在iOS或macOS中,需要通过创建WKWebView类的实例在应用中嵌入网页,交互过程类似。

2. 基础接口

所谓基础接口,就是首先要规定原生应用和JS分别在WebView里注入/暴露一个什么对象:

  • NativeBridge:原生应用注入到WebView中的对象
  • JSBridge:JS暴露在WebView中的对象

并约定在这两个对象上分别可以调用什么方法:

  • NativeBridge.callNative(action, params, whoCare)
  • JSBridge.callJS(action, params, whoAmI)

顾名思义,NativeBridge.callNative是由JS调用向Native传递指令或数据的方法,而JSBridge.callJS则是由Native调用向JS传递指令或数据的方法。方法签名中的参数含义如下:

  •  action:字符串,希望Native/JS执行的操作
  •  params:JSON对象,要传给Native/JS的数据
  •  whoCare:数值,表示JS希望哪个端响应
  • whoAmI:数值,表示哪个端调用的JS

基础接口只有两个对象和两个方法,JS与App间的互操作则通过actionparams来扩展和定义。

3. 实现模式

对于JS而言,虽然这里只定义了一个对象一个方法,但实践中,可以把action对应方法的实现附加到JSBridge上,只要把callJS实现为一个分发方法即可,比如:

window.JSBridge = {}
window.JSBridge.callJS = function({action, params, whoAmI}) {
  return window.JSBridge[action](params, whoAmI)
}

这样,所有对callJS的调用,都会转化成对JSBridge上相应action方法的调用,优点是只需一行代码。

另一种实现方式是通过switch...case语句实现调用分发,比如:

function callJS (action, params, whoAmI) {
  params = JSON.parse(JSON.stringify(params))
  switch (action) {
    case 'showSkill':
      const category = params.category
      JSBridge.showSkill(category)
      break
    case 'showSkillDetail':
      const id = params.id
      JSBridge.showSkillDetail(id)
      break
    case 'otherAction':
      // some code
      break
    default:
  }
}
// JS暴露给应用的通用接口
const SpkJSBridge = {}
// 全部接口
JSBridge.callJS = callJS

这样实现的优点是所有方法一目了然,当然同样也是把所有相关接口都附加到同一个JSBridge对象上。

以上两种实现模式各有利弊。

4. 单向调用

由JS发起的单向调用App的操作,主要涉及加载URL和切换到原生界面,可对应如下action

  • loadUrl:加载另一个URL
  • loadContent:跳转到原生界面

loadUrl调用的参考协议如下:

NativeBridge.callNative({
    action: 'loadUrl',
    params: { url },
    whoCare: 0
})

这里NativeBridge是App的原生对象,其callNative方法被调用时,会收到一个对象(字典/映射)参数。根据这个参数的action属性的值,App可知需要执行的操作是加载URL。于是再取得params属性中的url,发送请求即可。

loadContent调用的参考协议如下:

NativeBridge.callNative({
  action: "loadContent",
  params: {
    type: "album",
    content: {
      album_id: "1"
    }
  },
  whoCare: 0
})

同上,这里通过params向App传递了必要参数,App负责切换到相应的原生界面。

由App发起的单向调用JS的操作,主要涉及用户点击后退按钮(<),可对应如下action

  • can_back:询问JS是否返回前是否需要用户确认

can_back调用的参考协议如下:

JSBridge.callJS({
  action: "can_back",
  params: {},
  whoAmI: 1/2
})

此调用返回的值示例如下:

{
  can: true,
  target: "prev"
}

顾名思义,can_back用于App询问JS:在返回上一级界面前,是否弹窗提示用户?

返回值中的can如果是true,则直接返回,不提示;如果是false,则弹出一个确认框,请用户确认。另一个值target是与App约定的返回目标,比如prev表示返回上一级,top表示返回顶级,等等。

5. 双向调用

双向调用是JS先调用App,然后App在完成操作后再调用JS,双向通常都需要传递数据。双向调用主要涉及JS调用App原生组件和用户点击右上角按钮,可对应如下action

  • loadComponent:调用原生组件
  • displayNextButton:显示“下一步”“保存”或“完成”按钮

loadComponent的参考协议如下:

NativeBridge.callNative({
  action: 'loadComponent',
  params: {
    type: 'location',
    value,
    callbackName: 'set_location'
  },
  whoCare: 0
})

在这个例子中,涉及JS调用App显示其实现的城市选择组件:type: 'location',用户选择完城市之后,App再调用set_location,将用户选择的城市名称传给JS:

JSBridge.callJS({
  action: 'set_location',
  params: {
    value: '北京市朝阳区',
  },
  whoAmI: 1/2
})

JS根据拿到的值更新界面,完成一次双向调用。另一个例子是JS调用原生的日期选择组件,与此类似。

为什么叫displayNextButton?因为根据具体业务场景,可能存在如下三种情况:

  1. 当前WebView不需要显示右上角按钮,比如详情页;
  2. 当前WebView需要显示“下一步”或“保存”按钮,但需要禁用变灰;
  3. 当前WebView需要显示“下一步”或“保存”按钮,允许用户点击。

displayNextButton协议的参考实现如下:

NativeBridge.callNative({
  action: "displayNextButton",
  params: {
    name: "下一步",
    enable: false,
    callbackName: "save_form"
  },
  whoCare: 0
})

以上代码示例表明,JS调用App,告诉App显示“下一步”按钮,但是要禁用变灰,因为enable: false。如果传递的是enable: true,那么用户就可以点击“下一步”按钮了。点击之后,App再调用JS的save_form。最后,如果不想显示按钮,可以传递name: ''

下面重点说一下用户点击“下一步”按钮,App调用save_form的场景。此时也分两种情况:

  1. JS直接保存数据
  2. JS通过App保存数据

如果是JS通过App保存数据——可能因为App端实现了数据写入必需的加密机制——那么,JS可以在App调用save_form时将约定好的数据返回给App,由App去保存数据。

如果是JS直接保存数据,比如通过Ajax,那么在保存完数据之后,则还需要调用前面所说的App暴露的loadUrlloadComponent方法,以告知App切换界面。当然这种情况下会出现第三次调用,但仍然属于双向调用。

6. 哪方主导

本文介绍了JS与App交互的几种模式,而且只讨论了JS端的实现。在开发实践中,团队各端总会面临哪一端主导的问题。本文展示的参考实现就是H5端主导的一种实现形式。H5主导的特点是把主要业务逻辑都封装到WebView中,App主要协同配合,而优点是业务逻辑的变更不会蔓延到App。毕竟相对于H5,App的安装部署模式会造成多版本共存问题,需要尽可能控制新版本。假如由App端主导,将逻辑封装在App端,势必造成版本不受控,给整个项目或产品埋下隐患。

当然,事无绝对。具体情况还要具体分析。而且,哪方主导有时候也取决多方面因素。实践中还是要因人、因时、因势制宜。

如果我问:你知道“剪贴板”(clipboard)吗?

恐怕没人不知道。我们每天都不知道自己要在电脑或手机上“复制”、“粘贴”多少回。每次“复制”、“粘贴”的背后,都会用到“剪贴板”。

根据“维基百科”:

The clipboard is a data buffer used for short-term data storage and/or data transfer between documents or applications used by cut, copy and paste operations and provided by the operating system.

翻译一下:

剪贴板是一种数据缓存,用于文档或应用间短期数据的存储/转移,在用户执行剪切、复制和粘贴操作时会用到,由操作系统提供。

这里最重要的一点在于,“剪贴板”是“由操作系统提供”的,因此它是系统级的一个软件特性。

对于前端开发者来说,如果我问:你知道怎么操作“剪贴板”吗?

很多人的第一反应可能是:使用clipboard.js吧……

clipboard.js的原理

clipboard.js(https://clipboardjs.com/)是在Github上有24000多个星,其流行程度可见一斑。关于这个库的用法,大家可以自己去看,我们这里主要分析其实现原理,以便了解目前操作剪贴板的主流技术。

简单来说,clipboard.js利用了两个已有的Web API(前者属于HTML5,后者属于HTML Editing API):

  • HTMLInputElement.select()
  • document.execCommand()

相应地,原理也只有两步。

第一步,创建临时的texterea元素、通过CSS隐藏起来,然后把要复制的文本赋值给这个文本区,再选择这个文本区中的所有内容:

// https://github.com/zenorocha/clipboard.js/blob/master/src/clipboard-action.js
/**
 * Creates a fake textarea element, sets its value from `text` property,
 * and makes a selection on it.
 */
// selectFake()
// 创建临时的texterea元素
this.fakeElem = document.createElement('textarea');
// 隐藏这个元素
this.fakeElem.style.position = 'absolute';
this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px';
// 把要复制的文本赋给文本区并选择全部内容
this.fakeElem.value = this.text;
this.selectedText = select(this.fakeElem);
// 触发复制
this.copyText();

第二步,通过脚本触发copy操作,如果成功则文本会写入剪贴板,然后根据执行结果派发自定义事件:

// https://github.com/zenorocha/clipboard.js/blob/master/src/clipboard-action.js
/**
 * Executes the copy operation based on the current selection.
 */
// copyText()
succeeded = document.execCommand(this.action);
this.handleResult(succeeded);

/**
  * Fires an event based on the copy operation result.
  * @param {Boolean} succeeded
  */
handleResult(succeeded) {
    this.emitter.emit(succeeded ? 'success' : 'error', {
        action: this.action,
        text: this.selectedText,
        trigger: this.trigger,
        clearSelection: this.clearSelection.bind(this)
    });
}

如前所述,剪贴板是由操作系统提供的,是系统级的。浏览器厂商为安全和用户体验考虑,只信任用户通过应用、文档或脚本触发的复制操作。而且,复制到剪贴板的内容来源还必须是已有的DOM元素。

以上复制操作的结果是将文本内容写入剪贴板。如果要读取剪贴板中的内容,必须给粘贴(paste)事件操作注册处理程序,通过e.clipboardData.getData()方法获取剪贴板中的内容。这样可以在用户把内容粘贴到目标输入框之前,对剪贴板上的内容进行一些预处理。但这不是本文的重点。

虽然clipboard.js使用的这种技术几乎所有浏览器都支持,但也存在诸多缺陷。

已有技术的缺陷

以前的技术存在什么失陷呢?除了前面需要通过编程方式通过execCommand模拟用户触发剪贴板的复制操作之外,还有:

  • execCommand的初衷是编辑DOM,而且浏览器间实现存在不少差异
  • 复制粘贴操作是同步的,会阻塞主线程,导致页面无法响应
  • 如果进而再弹窗申请授权,则可能会惹恼用户
  • 相应地,妨碍对某些类型的数据(如图片)进行无害化处理或转码操作
  • 而为避免外部利用,有时候转码又是必需的

为了克服这些问题,W3C开始着手制定相关标准:Clipboard API and events(https://www.w3.org/TR/clipboard-apis/)。

Clipboard API and events定义了Async Clipboard API,相应地增加了clipboardchange事件。

Async Clipboard API

换句话说,为避免阻塞主线程,这个新标准引入了基于Promise的异步剪贴板API。由于剪贴板是系统级软件特性,所以相应的API挂载到了navigator上面:

navigator.clipboard

这个clipboard对象有4个方法:

Promise<DataTransfer> read();
Promise<DOMString> readText();
Promise<void> write(DataTransfer data);
Promise<void> writeText(DOMString data);

两个读剪贴板,两个写剪贴板。

支持读写以下数据类型:

  • text/plain
  • text/uri-list
  • text/csv
  • text/css
  • text/html
  • application/xhtml+xml
  • image/png
  • image/jpg, image/jpeg
  • image/gif
  • image/svg+xml
  • application/xml, text/xml
  • application/javascript
  • application/json
  • application/octet-stream

read()

navigator.clipboard.read().then(function(data) {
  for (var i = 0; i < data.items.length; i++) {
    if (data.items[i].type == "text/plain") {
      console.log("Your string: ", data.items[i].getAs("text/plain"));
    } else {
      console.error("No text/plain data on clipboard.");
    }
  }
});

readText()

navigator.clipboard.readText().then(function(data) {
  console.log("Your string: ", data);
});

write(data)

var data = new DataTransfer();
data.items.add("text/plain", "Howdy, partner!");
navigator.clipboard.write(data).then(function() {
  console.log("Copied to clipboard successfully!");
}, function() {
  console.error("Unable to write to clipboard. :-(");
});

writeText(data)

navigator.clipboard.writeText("Howdy, partner!").then(function() {
  console.log("Copied to clipboard successfully!");
}, function() {
  console.error("Unable to write to clipboard. :-(");
});

注意事项

  1. navigator.clipboard只能在“安全上下文”中使用。什么是“安全上下文”?简单说,就是locahost和HTTPS环境下。(可以通过window.isSecureContext属性取得。)
  2. 桌面浏览器中目前只有Chrome、Firefox和Opera支持,Safari和IE/Edge还不支持;而且,Chrome也只支持readText()和writeText()。

参考链接:

“360 AI音箱”即将发布,移动应用也在紧张有序地开发中。本文将介绍“360 AI音箱”移动应用H5部分的实践,主要包括:

  • 项目环境搭建
  • 与Native交互
  • 自定义中文字体
  • 表单输入
  • Docker部署

360 AI音箱应用H5部分简介

应用主要分4大版块:

  1. 内容:音箱可以播放的音乐、故事、有声书等等
  2. 技能:运营预配置的音箱指令
  3. 场景:用户自定义的音箱指令
  4. 我的:用户的智能设备、账号等

其中,“技能”和“场景”版块由H5制作。如下图所示,技能部分主要包括运营后端预配置的指令列表和详情两个页面。

注意:本文图片为“360 AI音箱”版权所有。另外,因为是设计稿截图,应用实际发布后的外观可能会有所不同。

技能列表和详情主要涉及使用自定义字体:Adobe思源宋体(https://source.typekit.com/source-han-serif/cn/)。

场景,其实是用户自定义的技能。场景相对复杂,不仅涉及类似技能的展示,还涉及增、删、改,甚至还有与原生配合的闹钟场景。

项目环境搭建

前端H5的技术方案有单页SPA、传统B/S架构。考虑到项目涉及使用自定义字体和保存自定义场景的中间结果,所以采用了传统B/S架构,以最大限度避免加载Web字体的FOIT/FOUT(https://www.zachleat.com/web/fout-vs-foit/),同时利用服务端缓存。

开发框架:

项目代码结构如下:

其中,

  • deploy:是部署脚本和Docker构建脚本所在目录
  • frontend:是前端资源目录,主要是Webpack编译入口文件、模板文件及JS和CSS资源
  • runtime:是ThinkJS运行时存放配置等信息目录
  • src:是服务端源代码目录
  • view:是服务端模板目录,模板文件由Webpack编译保存过来
  • www:是服务端静态资源目录,比如Webpack打包后的JS、CSS、图片、字体等

实际项目中的静态资源都使用Webpack插件直接上传CDN,图片也直接引用CDN图片,因此服务端并不保存任何静态文件。

与Native交互

H5与Native共同定义了两个接口,用于双方互相调用。

1. JS调Native

// JS调用传参,通过参数把数据传给Native
SpkNativeBridge.callNative({action, params, whoCare})

接口说明:

  • SpkNativeBridge是iOS和Android实现并注入到WebView中的接口对象
  • callNativeSpkNativeBridge的方法

参数说明:

  • action:字符串,希望Native执行的操作
  • params:JSON对象,要传给Native的数据
  • whoCare:数值,表示JS希望哪个端响应

    • 0:iOS和Android都响应(默认值)
    • 1:iOS响应
    • 2:Android响应

返回值:具体商定

2. Native调JS

// Native调用传参,通过参数把数据传给JS
SpkJSBridage.callJS({action, params, whoAmI})

接口说明:

  • SpkJSBridage是JS在WebView中实现并暴露的接口对象
  • callJSSpkJSBridage的方法

参数说明:

  • action:字符串,希望JS执行的操作
  • params:JSON对象,要传给JS的数据
  • whoAmI:数值,表示哪个端调用的

    • 1:iOS调用的
    • 2:Android调用的

返回值:具体商定

3. 调用示例

下面通过两个例子来说明H5与Native如何使用上述接口交互。下面这张图是用户创建场景期间退出时原生要弹出确认框的情景:

如图所示,导航条是Native,下面是WebView。导航条上的返回和保存按钮需要H5根据场景内容控制。比如,如上图所示,用户有未保存的内容时点击了返回按钮,H5要告诉Native是否可以返回,还是需要提示。交互过程如下:

Native调用

window.SpkJSBridge.callJS({
  action: "can_back",
  params: {},
  whoAmI: 1/2
})

H5返回值

{
  can: false,
  target: "prev"
}

返回值说明:

  • can: 布尔值,true 表示可以返回,false 表示需要弹确认框
  • target: 字符串,"prev" 返回上一级, "top" 返回顶级,"closeweb" 关闭之前的webview

换句话说,用户点击Native的“返回”按钮,Native调用JS的can_back()方法,JS判断是否有未保存内容,如果有则返回上述值,通知Native弹确认框。

除了“返回”,还有“保存”按钮。H5负责控制“保存”按钮是否启用,以及启用之后用户点击调用的方法。具体来说,每次WebView加载后,JS判断是否可以保存,比如上图中场景只有“对音箱说”的部分,没有“音箱的回应”,不能保存,因此H5会调用Native的displayRightButton()方法,告诉Native按钮文字、是否启用,以及启用后用户点击的回调函数:

window.SpkNativeBridge.callNative({
  action: "displayRightButton",
  params: {
    name: "下一步",
    enable: false,
    callbackName: "scene_topic_save"
  },
  whoCare: 0
})

4. 注意事项

H5调Native时传参,如果调Andoid则只能传基本数据类型(字符串或数值),不能传JSON对象;iOS则没问题。为此,H5需要判断WebView环境是Android还是iOS,如果是前者,则将JSON对象转换为字符串:

// 包装方法,对Android将JSON转换为字符串
window.callNative = (param) => {
  if (speakerWebviewHost === 2) param = JSON.stringify(param)
  window.SpkNativeBridge.callNative(param)
}

另外,JS方法向Native返回值必须同步返回,虽然Native调用JS是异步调用,但JS如果返回Promise,Native是无法处理的。因此,需要在使用XMLHttpRequest对象时将async参数设置为false

const scene = $.ajax({
    url,
    async: false
}).responseJSON

最后,还有一个“坑”:Android如果未开启WebView的localStorage特性,使用localStorage的H5页面就会“冻结”!

自定义中文字体

如前所述,“技能”列表和详情都需要用到Adobe开源的“思源宋体”,而且原生闹钟等也会用到该特殊字体:

图中的“玩法介绍”“功能介绍”的标题以及前者的内容都需要使用自定义中文字体。然而,设计师给的开源字体文件有23 MB这么大,包含65000多字符。考虑到技能和闹钟用到这个字体的字符有限,我们决定使用字体截取技术。

经过调研,并且考虑到技能列表需要动态截取,最终我们自建了一个字体服务:奇字库。“奇字库”提供中文字体在线动态截取服务,让字体文件从十几MB瞬间变成十几KB、几KB;基于Adobe和Google共同开发的Web Font Loader(https://github.com/typekit/webfontloader)定制了加载脚本,实现了字体加载与应用完全自动化。

目前,“奇字库”囊括了公司所有付费的版权字体,可供公司内部各业务线的各类Web或客户端项目使用:

为获得最佳用户体验,“奇字库”提供了丰富的接口,可满足灵活定制的需求。服务端或浏览器可以通过API调用,动态截取字体。共有两大类共8个API:第一类是获取字体“URL”的,包括上传到CDN的URL和base64格式的Data URL;第二类是获取CSS @font-face规则文本的,包括获取CDN URL和Data URL内容的CSS。

比如,获取字体的CDN URL,API返回结果示例如下:

{
  "ttf": "//s3.ssl.qhres.com/static/b73305c8dde4d68e.ttf",
  "woff": "//s1.ssl.qhres.com/static/e702cca6e68ab80a.woff",
  "woff2": "//s1.ssl.qhres.com/static/e27f2a98e5baf04d.woff2",
  "eot": "//s2.ssl.qhres.com/static/590b2e87fb74c9d6.eot"
}

再比如,获取字体Data URL的CSS @font-face规则,API返回的结果示例如下:

@font-face {
  font-family: myWebFont;
  src: url('data:font/opentype;base64,Fg8AA...8AAw==');
  src: url('data:font/opentype;base64,Fg8AA...8AAw==?#iefix') format('embedded-opentype'),
      url('data:font/opentype;base64,d09GM...AAA==') format('woff2'),
      url('data:font/opentype;base64,d09GR...Ssw==') format('woff'),
      url('data:font/opentype;base64,AAEAA...//wAD') format('truetype');
}

最简单的方式就是在网页里直接复制粘贴代码:

不过因为我们的项目有服务端,所以可以将截取到的字体文件与网页及CSS一起下发到浏览器,从而完全避免FOUT,实现与使用本地字体一样的用户体验。

“奇字库”目前只是360内部的项目,仅对公司内部提供服务,外部无法使用。如果读者对字体截取有兴趣,可以参考笔者之前的文章“前端字体截取:实战篇”(https://mp.weixin.qq.com/s/pq9hXz_iGwADNjNAWWoK-g)。

表单输入

表单输入的重点,一是组件化输入框,便于添加和删除;二是对用户输入的计数,涉及composition*事件;三是使用debounce,避免过早对用户输入进行计数。

首先,为满足用户输入多条“音箱回应”及自定义占位符文本的需求,输入框使用了contenteditable值为truediv元素:

<div class="fieldset">
    <div class="placeholder">输入想让音箱说的话<small>/最多{{lengthLimit}}字</small></div>
    <div class="input" contenteditable="true"></div>
    <img class="clearReplyInput" src="//p0.ssl.qhimg.com/d/lisongfeng/icon_close_s.png">
    <span class="countDown">0</span>
</div>

而且,基于这个元素构建了前端组件:

import inputComponent from './_replyInputComponent'

每次创建这个组件的新实例,就会自动在DOM上添加新的输入框:

// +继续添加
new inputComponent({formsSelector, formTemplate, lengthLimit})

其次,是对用度输入的字符数进行计数。此时,要用到三个事件:

  • keyup:用户触摸软键盘按键后触发
  • compositionstart:用户调用输入法开始输入一段文字时触发,类似keydown
  • compositionend:用户选取了最终要输入的文本结束一次输入时触发,比如用户使用拼音或五笔输入法,之前的输入比如“jiang ge gu shi”只是“中间输入”,不会触发这个事件,只有当用户最终选取了“讲个故事”之后才会触发

但是,compositionend不能识别英文的“组词输入”,所以最终还是绑定了keyup事件:

// 开始输入隐藏占位符提示
this.input.bind('compositionstart', e => {
  this.placeholder.hide()
})
// 组词结束后,处理内容并绑定input事件
this.input.bind('keyup compositionend', inputHandler)

最后,就是使用debounce对用户输入事件做延迟处理:

import debounce from 'lodash.debounce'
// 输入检查
const inputHandler = debounce(e=>{
  // ...
}, 300)

很多人不清楚debouncethrottle的区别。

  • debounce是在某事件至少停止触发多长时间后执行,比如上面的inputHandler会在我们注册的事件(keyup compositionend)至少间隔300毫秒才会触发
  • throttle是针对连续密集触发的一系列事件,比如scrollresize,将它们“节流”为均匀地每过多长时间才触发一次。

这里使用debounce包装实际的处理程序,是为了避免过早地在用户输入期间对输入进行计数。

Docker部署

容器部署的优点是多机房灾备,某机房因切割或服务下架而停服,都不会影响线上服务。容器部署用到的是360 HULK云平台的容器相关服务Stark:

Docke部署流程如下:

  1. 本地构建Docker镜像
  2. 上传到Stark
  3. 修改容器镜像
  4. 重启服务

关于使用Docker,主要看看官方的Get Started和Dockerfile相关文档即可

小结

本文又是一篇“急就章”,大略介绍了360 AI音箱H5开发过程中的一些基本实践,希望可以为同行提供一些参考和借鉴,也欢迎大家批评指正。另外,开发过程中还有一些涉及算法的有意思的技术点,同样值得分享,等项目上线之后有时间了再分享吧。

为什么要截取字体?

众所周知,相对于英文字体,中文字体天生是“庞然大物”。英文字体两三百KB已经很大了,而中文字体几MB十几MB都算小的。一方面,中文字体包含的字形数量极多,动辄数以千计甚至万计,而英文字体则只需包含几十个基本字符和符号,哪怕支持多种语言及字符变体,容量达到三千多个字形已经算非常庞大的了。另一方面,中文字形的曲折变化复杂度高,在基于轮廓的矢量字体设计中,用于控制中文字形曲线的控制点普遍比英文更多,因而需要的数据量更大,也会导致字体文件膨胀。

前端开发实践中,为了实现一些特殊视觉效果,经常需要使用某些特殊字体,而用户电脑上几乎不太可能安装这些字体,这时候通常需要使用Web字体技术,让浏览器动态下载我们的自定义字体。可是中文字体非常庞大,很多时候“全量”加载某个字体文件是不现实的。特别是对于一些动态页面且每个页面只有少量字符用到该字体的情况下。当然,也不是每个页面都会用到一个字体文件中的所有字符,全量加载本身也极其浪费。

研究表明,3500常用中文汉字(中国义务教育9年级需要掌握的汉字数量)即可覆盖日常使用汉字的99.8%:

  • 500 字(78.53202%)
  • 1000字(91.91527%)
  • 1500字(96.47563%)
  • 2000字(98.38765%)
  • 2500字(99.24388%)
  • 3000字(99.63322%)
  • 3500字(99.82015%)

可见,最常用的前500个汉字的覆盖率已经达到78%。因此,“全量”加载某个字体,特别是中文字体,在当前网络环境下不仅浪费流量和时间,而且也是完全没有必要的。这时候,我们可以根据网页用到的字符来截取字体的片段,这个技术英文叫subset,也就是“取子集”。

本文首先简单回顾Web自定义字体的技术规范,然后通过实例介绍两种前端常用的截取字体的技术。首先是CSS中的unicode-range属性,我们称之为“软截取技术”,因为它只是在本地既有字体或者浏览器已经下载的字体基础上做一个指向子集的“软链接”,并不能真正减小浏览器下载文件的大小。其次是Node命令行工具glyphhanger,我们称之为“硬截取技术”,即在服务端从“全量”字体中分离出一个体积相对极小的字体子集,做成Web字体通过Web服务器或CDN下发给浏览器。

无论是“软截取”,还是“硬截取”,都会用到Web字体和@font-face规则。因此,我们需要先来了解一下这个基础的Web标准语法。

Web字体与@font-face

为了超越“Web安全字体”的局限,在网页上使用一些用户电脑上不太可能会安装的字体,微软曾率先提出了@font-face规则。这个规则后来进入W3C的CSS Fonts Module Level 3模块,于是就有了前端常用的Web自定义字体技术:

@font-face {
  font-family: 'MyWebFont';
  src: url('webfont.eot'); /* 兼容IE9 */
  src: url('webfont.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
       url('webfont.woff2') format('woff2'), /* 最新浏览器 */
       url('webfont.woff') format('woff'), /* 较新浏览器 */
       url('webfont.ttf')  format('truetype'), /* Safari、Android、iOS */
       url('webfont.svg#svgFontName') format('svg'); /* 早期iOS */
}
示例代码出处:https://css-tricks.com/snippets/css/using-font-face/

当然,上面的代码是几乎可以兼容所有浏览器的方案。大约在两年前,也就是2016年,由于浏览器版本的快速更迭,写成下面这样已经是比较现实的了:

@font-face {
  font-family: 'MyWebFont';
  src:  url('myfont.woff2') format('woff2'),
        url('myfont.woff') format('woff');
}

如果要兼容更多浏览器,那再加上一种几乎所有浏览器都支持的ttf格式则似乎更稳妥:

@font-face {
  font-family: 'MyWebFont';
  src: url('myfont.woff2') format('woff2'),
       url('myfont.woff') format('woff'),
       url('myfont.ttf') format('truetype');
}

不过,我们的最终目标还是写成这样,即只使用woff2这种自带压缩的格式:

@font-face {
  font-family: 'MyWebFont';
  src: url('myfont.woff2') format('woff2');
}

从技术角度讲,除了直接使用@font-face,还可以使用@import规则或link元素导入或加载包含@font-face声明的外部文件:

// 导入
@import url(//fonts.googleapis.com/css?family=Open+Sans);
// 或者引用
<link href='//fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css'>
// 实际使用
body {
  font-family: 'Open Sans', sans-serif;
}
打开Google Fonts看一看:https://fonts.googleapis.com/css?family=Open+Sans

以上都是技术规范,至于什么时候可以过渡到只使用专门针对Web字体优化的压缩格式woff2,应该只是一个时间问题。

回顾完了基础的技术规范和语法,明确了未来的方向,接下来我们进入实战。先看一下CSS Fonts Module Level 3定义的与@font-face规则配合使用的unicode-range属性,然后再给大家介绍一家有名的国外Web开发公司Filament Group, Inc.推出的字体截取工具glyphhanger

unicode-range

unicode-range属性虽然可以算作“字体截取”技术,但它是“软截取”,不是“硬截取”。它类似于一种快捷方式,而不能真正减少浏览器需要下载的字体文件大小。

顾名思义,unicode-range用于指定自定义字体中包含的字符的Unicode码点范围,语法如下:

// CSS
@font-face {
  font-family: 'Ampersand';
  src: local('Times New Roman');
  unicode-range: U+26;
}
div {
  font-size: 4em;
  font-family: Ampersand, Helvetica, sans-serif;    
}
// HTML
<div>Me & You = Us</div>
示例代码出处:https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range

以上@font-face规则自定义了一个名为“Ampersand”(英文&符号)的字体,这个字体“截取”自本地字体Times New Roman,而这个字体只包含一个字符:U+2626是英文&符号的十六进制Unicode码点,对应的十进制值是38)。

HTML中div元素根据font-family的指令,依次会应用自定义字体Ampersand(Times New Roman,衬线字体)、Helvetica(无衬线字体)和sans-serif(无衬线)字体族。实际应用效果如下:

Unicode编码扩容到了17个编码平面,每个平面的容量为65,536,总容量为1,114,112个码点,其中实际分配使用的只有128,237个,约占12%。因此在可以预见的未来,Unicode有足够的空间包含地球上所有文明的字符。

看一个中文字体的例子。假设我们要用特殊字体突出显示“初唐四杰”之一王勃的千古名篇《滕王阁序》中最有名的那句:“落霞与孤鹜齐飞,秋水共长天一色。”

可以先把这句名句(包括标点)转换成Unicode码点:

字符串转码点可以使用以下JavaScript函数:

function text2point(t) {
   return t.split('').map(c => 'u+'+c.charCodeAt().toString(16)).join(',')
}

然后以“隶变”(Libian SC)作为源字体,自定义一个名叫custom的字体,把它应用到.emphasis元素:

// CSS
@font-face {
  font-family: custom;
  src: local(Libian SC);
  unicode-range: u+843d,u+971e,u+4e0e,u+5b64,u+9e5c,u+9f50,u+98de,u+ff0c,
                  u+79cb,u+6c34,u+5171,u+957f,u+5929,u+4e00,u+8272,u+3002;
  font-weight: 500;
}
.emphasis {
  font-family: custom;
}
// HTML
<!--其他句子-->
<span class="emphasis">落霞与孤鹜齐飞,秋水共长天一色。</span>
<!--其他句子-->
注意,上面代码中的码点列表为排版阅读方便而人为换了行,实际使用中不要人为换行,以免造成语法错误。下面的代码示例也一样。

结果如下:

此时,我们发现标点(逗号和句号)的样式与其他文字不统一,而其他文字使用的是“苹方”(PingFang SC)字体(在Mac上)。是不是可以简单地从前面的码点列表中删除逗号和句号的码点 u+ff0cu+3002?这个方案在Safari 12、Firefox 62中可行,删除码点之后的逗号和句号会继承使用“苹方”字体,但是在Chrome 69中并不奏效。

此外,Chrome似乎还有一个bug。假设不删除上述码点,而直接在标点左侧输入一个自定义字体中并不包含的字符,Chrome会强制把这个字符显示成自定义字体。看来浏览器的实现还是有不一致的地方。时间关系,Windows平台下的IE和Edge没有测试,读者可以自行测试一下。

无论如何,我们可以再定义一个只包含逗号和句号两个字符的自定义字体来解决这个问题:

@font-face {
  font-family: punc;
  src: local(PingFang SC);
  unicode-range: u+ff0c,u+3002;
}
.emphasis {
  font-family:punc, custom;
}

这样,即使不删除custom声明中的码点,Chrome、Safari和Firefox也都可以将逗号和句号显示为“苹方”字体了:

注意,不要试图基于英文字体自定义punc字体,因为英文字体中不包含对中文标点符号对应码点的映射。

虽然这个例子明显是自造的,“对中文内容中的某部分中文字符做特殊字体处理,或者是英文字体中部分字符做特殊字体处理”正是unicode-range这种“软截取技术”最适合的应用场景。更多unicode-range的内容,推荐大家看一看张鑫旭老师的文章“CSS unicode-range特定字符使用font-face自定义字体”:(https://www.zhangxinxu.com/wordpress/2016/11/css-unicode-range-character-font-face/)

使用unicode-range的注意事项:

  • unicode-range可以接收

    • 单个码点:U+26(或u+26)
    • 码点范围:U+0-7FU+0025-00FF
    • 通配符范围:U+4??,相当于U+400-U+4FF
    • 逗号分隔的多个值:U+0025-00FF, U+4??
  • unicode-range默认值为:U+0-10FFFF,即全部Unicode字符编码
  • unicode-range的值是码点的字面值或字面值列表,不是字符串

    • 正确:unicode-range: u+ff0c,u+3002;
    • 错误:unicode-range: "u+ff0c,u+3002";
  • unicode-range的值不能有语法错误,比如上面说的不是字符串,以及不能出现多余的逗号:u+ff0c,u+3002,;(末尾多了一个逗号)等,出现语法错误的后果是自定义字体会变成源字体的别名,而非基于源字体截取的子集。(当然,通过@font-face定义已有字体全集的别名,也是一种实用的CSS技术,可以参考前面张老师的文章。)
  • 转换为码点时确保使用正确的字符,比如前面例子中的“鹜”(u+9e5c)不要错误地使用“骛”(u+9a9b)。

关于unicode-range这种“软截取技术”的使用就介绍这些。接下来我们介绍“硬截取工具”:glyphhanger。

glyphhanger

glyphhanger是Zach Leatherman(https://www.zachleat.com/web/)Filament Group(https://www.filamentgroup.com)写的一个.ttf转WOFF/WOFF2等Web字体格式的命令行工具,可以:

  • 抓取远程或本地文件并分析其中包含的文字
  • 将分析结果去重排序并转换为Unicode码点
  • 根据指定的源字体生成对应格式的子集(需要安装另一个工具,稍后介绍)
  • 同时也生成包含@font-face规则的CSS文件

这个工具非常实用方便,下面我们就来演示在制作Web字体过程中glyphhanger的几个典型用法。

首先,全局安装:

npm install -g glyphhanger

用法一:把王勃的千古名句改成“思源宋体”

进入包含例子页面的目录fontSubsetInAction,运行如下命令:

➜ fontSubsetInAction glyphhanger http://127.0.0.1:8080/index.html --family='custom' --subset=SourceHanSerifCN-Light.ttf --formats=woff2

U+3002,U+4E00,U+4E0E,U+5171,U+5929,U+5B64,U+6C34,U+79CB,U+8272,U+843D,U+957F,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C
Writing CSS file: SourceHanSerifCN-Light.css
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.woff2 (was 12.44 MB, now 3.57 KB)

这里给glyphhanger传入了4个参数。

  • 要分析的远程文件(这里是一个本地Web服务):http://127.0.0.1:8080/index.html
  • --family='custom'指定只分析以上页面中应用了font-family: custom;规则的元素
  • --subset=SourceHanSerifCN-Light.ttf指定使用的源字体,这里为“思源宋体”(Source Han Serif)
  • --formats=woff2指定想要生成的字体子集的目标格式,这里是WOFF2

glyphhanger首先输出了“落霞与孤鹜齐飞,秋水共长天一色。”对应的Unicode码点(包含逗号和句号)。紧接着在当前目录创建了一个名为“SourceHanSerifCN-Light.css”的文件。之后的输出显示,截取的字体叫“SourceHanSerifCN-Light-subset.woff2”,且源字体文件有12.44 MB,子集文件3.57 KB。16个汉字字符就用了3.57 KB,平均每个字符占228字节,吓人吧?!

不过,比起12.44 MB,3.57 KB已经算极小了。下面,看看glyphhanger帮我们生成的CSS文件:

/* This file was automatically generated by GlyphHanger 3.0.3 */
@font-face {
  font-family: custom;
  src: url(SourceHanSerifCN-Light-subset.woff2) format("woff2");
  unicode-range: U+3002,U+4E00,U+4E0E,U+5171,U+5929,U+5B64,U+6C34,U+79CB,
                  U+8272,U+843D,U+957F,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C;
}

直接使用即可,相比之前手工生成码点,这样省事多了。结果如下:

用法二:分析网页用到汉字子集

可能有读者没有注意到,上面例子中glyphhanger输出的码点是按照每个字符在Unicode编码中的顺序从小到大排序过的。而且,这些码点是在自动去重之后排的序。

“落霞与孤鹜齐飞,秋水共长天一色。”没有重复的字,我们再看下面这个例子:

➜  fontSubsetInAction glyphhanger https://lisongfeng.cn/post/dive-into-async-function.html --string

 "#$&'()*+,-./0123456789:;<=>?ACDEFGHIJKLMNOPQRSTUVWXY[]`abcdefghijklmnopqrstuvwxy{|} ©«»—“”…、。一丁三上下不与且两个中串为主么义之乎乘也书了事二于互些交人什仅今介从代以们件任会伟传似但位低住体何作你使例供依便信修倍候值假做停催像儿充先兑入全兮关其典内册再写决况准出函分切列则刚创初利别到制刻前力办功加务动助努包化区升半协单占即却原去参又及反发取受变口句另只叫可各合同名后向吗吧含启呀员呢周味命和品哈哉响哎哥哦啊啥啦喽嘛器回因困围图在地坑块型基塞境增处备复外多大夫失头奇套好如始姐媒媲子字存它完定实家容对导封将小少尔尝就尾层屈展属山崩嵌工己已布带帮常干年并序库应底度建开异式引张强当彻往征待很得循微心必快念态怎思急性总恢息悉悖悲情想意感成我或户所手才打执扩扰找承把抛抢护抽拒括拼拿持按挠捕换据探接控推描提摸操擎支收改效救数文料断新方无既早时明易是显普景智暂更替最月有未末本机杂束条来构析果某查标样核根格案梦检概模次止正此步段每比毕毫永求没法注洞活流浏消涩深添溃满漏演漫点烦烫然照熟版特状独狱环现理甚甜生用由界疼疾白的目直相省看真眼着知码础确示神种秒积称程稍穷立竟端笔符第笼等答简算管箭类糖系索级纯线组细终绍经结给绝绞统继续维综编网置美翻考者而聪肉背能脚自至致节芋苦荒获虽蛮行补表被装要见规览解触计认让议记讲论设访证评译试话该详语误说请诺读调谋象负责败质费资赋越足踩身转载较辑达迅过迈运返还这进远迭述退送适逆逐递通速造逻遍道那部都释里重量鉴针链错键长问闲间队阻际限除随隐集需非靠面页顺须题风饰饱首香驾验高麻默!(),:;?~

第一个参数是一个“真正的”远程网页:https://lisongfeng.cn/post/dive-into-async-function.html,是我之前写的文章“小哥哥小姐姐,来尝尝Async函数这块语法糖”的网址。那篇文章全文接近5000字,但可以看到,经过分析去重之后,实际用到的只有604个汉字。另外,这里也使用另一个参数--string让glyphhanger把Unicode码点转换为字符串输出,是按照码点从小到大排序的。

字符串去重其实很简单,下面这个简单的JavaScript函数就可以搞定:

function textEliminateDuplicationAndSorting(text) {
    return text.split('').filter((value, index, self) => { 
      return self.indexOf(value) === index;
    }).sort().join('')
}

用法三:指定文本或码点生成字体子集

当然,如果你有现成的文本或码点,也可以只让glyphhanger帮你生成相应源字体的子集和CSS文件。比如我想把“渔舟唱晚,响穷彭蠡之滨,雁阵惊寒,声断衡阳之浦。”也显示为“思源宋体”:

➜  fontSubsetInAction glyphhanger --whitelist="落霞与孤鹜齐飞,秋水共长天一色。渔舟唱晚,响穷彭蠡之滨,雁阵惊寒,声断衡阳之浦。" --subset=SourceHanSerifCN-Light.ttf --css

U+3002,U+4E00,U+4E0E,U+4E4B,U+5171,U+54CD,U+5531,U+58F0,U+5929,U+5B64,U+5BD2,U+5F6D,U+60CA,U+65AD,U+665A,U+6C34,U+6D66,U+6E14,U+6EE8,U+79CB,U+7A77,U+821F,U+8272,U+843D,U+8821,U+8861,U+957F,U+9633,U+9635,U+96C1,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.ttf (was 12.44 MB, now 13.02 KB)
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.zopfli.woff (was 12.44 MB, now 9.13 KB)
Subsetting SourceHanSerifCN-Light.ttf to SourceHanSerifCN-Light-subset.woff2 (was 12.44 MB, now 7.45 KB)
Writing CSS file: SourceHanSerifCN-Light.css

@font-face {
  src: url(SourceHanSerifCN-Light-subset.woff2) format("woff2"), 
          url(SourceHanSerifCN-Light-subset.zopfli.woff) format("woff"), 
          url(SourceHanSerifCN-Light-subset.ttf) format("truetype");
  unicode-range: U+3002,U+4E00,U+4E0E,U+4E4B,U+5171,U+54CD,U+5531,U+58F0,U+5929,
              U+5B64,U+5BD2,U+5F6D,U+60CA,U+65AD,U+665A,U+6C34,U+6D66,U+6E14,
            U+6EE8,U+79CB,U+7A77,U+821F,U+8272,U+843D,U+8821,U+8861,U+957F,
            U+9633,U+9635,U+96C1,U+971E,U+98DE,U+9E5C,U+9F50,U+FF0C;
}

这一次使用--whitelist参数传入了要截取的汉字,省略了--formmats,增加了--css参数。

从结果可以看到,glyphhanger还是对文字进行了去重、转码点和排序。而且,在没有指定--formats的情况下,生成了.ttfwoffwoff2三种格式的字体子集,这是为了提高对浏览器的兼容性。最后,除了例行生成CSS文件,--css选项还让glyphhanger把CSS文件的内容输出到了控制台,便于复制。

但是要注意,CSS文件和输出都没有包含font-family属性,也就是没有自定义字体的名字(custom),使用时必须自己手工加上。好,结果如下:

安装pyftsubset

glyphhanger本身只做了网页抓取和分析,实际的字体截取使用的是一个著名的Python包fonttoolshttps://github.com/fonttools/fonttools。安装方法如下:

pip install fonttools

# Additional installation for --flavor=woff2
git clone https://github.com/google/brotli
cd brotli
python setup.py install

# Additional installation for --flavor=woff --with-zopfli
git clone https://github.com/anthrotype/py-zopfli
cd py-zopfli
git submodule update --init --recursive
python setup.py install

文章最后,为了便于大家参考,我们给出glyphhanger的帮助信息,大家可以自己去探索更多好玩的用法:

➜  fontSubsetInAction glyphhanger -h
glyphhanger error: requires at least one URL or whitelist.

usage: glyphhanger ./test.html
       glyphhanger http://example.com
       glyphhanger https://google.com https://www.filamentgroup.com
       glyphhanger http://example.com --subset=*.ttf
       glyphhanger --whitelist=abcdef --subset=*.ttf

arguments:
  --version
  --whitelist=abcdef
       A list of whitelist characters (optionally also --US_ASCII).
  --string
       Output the actual characters instead of Unicode code point values.
  --family='Lato,monospace'
       Show only results matching one or more font-family names (comma separated, case insensitive).
  --json
       Show detailed JSON results (including per font-family glyphs for results).
  --css
       Output a @font-face block for the current data.
  --subset=*.ttf
       Automatically subsets one or more font files using fonttools `pyftsubset`.
  --formats=ttf,woff,woff2,woff-zopfli
       woff2 requires brotli, woff-zopfli requires zopfli, installation instructions: https://github.com/filamentgroup/glyphhanger#installing-pyftsubset

  --spider
       Gather local URLs from the main page and navigate those URLs.
  --spider-limit=10
       Maximum number of URLs gathered from the spider (default: 10, use 0 to ignore).
  --timeout
       Maximum navigation time for a single URL.

[全文完]