Web 打印开发总结

对于经常浏览网页的你来说,打印网页可能并不陌生。我们平时所使用的浏览器在设置菜单都提供了这个功能选项,也可以通过快捷键来(Mac 上是 command + p)触发打印。下面将简单介绍下,在做打印这个不是特别刚性的需求中遇到的一些问题及注意的要点。

怎么实现打印

除了浏览器提供的内置功能选项,在 JavaScript 中可以通过调用 window.print() 方法来实现,调用之后会出现打印预览的对话框。这样做的好处就是我们可以在用户打印之前从服务端获取一些数据然后动态地生成一些内容插入到文档中,这在某些特定的场景中是非常有用的。

@media print

当你打印网页时,你会发现打印预览时的效果往往跟你的预期不是很相符,里面或多或少包含了一些不必要的内容。同样为了给网站提供更好的用户体验,专门为打印的内容应用样式就显得格外重要。

CSS 提供了一种叫做媒体查询的功能,允许我们在不同的媒介应用不同的样式。这个功能在移动端网页较为常见,常用来兼容不同屏幕的样式。为此我们可以通过媒体查询来引入打印所需要的样式,以下是两种引入方式:

<!-- link 标签引入 -->
<link rel="stylesheet" href="print.css" media="print" />
/* index.css */
@media print {
  .noprint {
    display: none;
  }

  .printable {
    display: block;
  }
}

通过在 link 元素上添加 media="print" 属性就可以为打印应用样式,这种方式的好处就是并不会阻塞页面的首次渲染。注意你选择这种方式的话就不需要在 print.css 中在添加 @media pirnt{} 了。

另外一种方式就是把打印的样式放到页面首次加载的主 CSS 文件中,用 @media print {} 包起来就行了。如果你的打印样式比较少的话可以选择这种方式,也就不必要新增一个文件了。

以上的打印样式都只会在打印预览时产生效果,原理其实也挺简单的,就是在打印预览时隐藏不需要打印的元素就行了。

单位 pt

pt 是自然界的标准长度单位,类似于 cm,mm 等,通常用于打印媒体。实际上 1pt 的大小为 1/72 英寸。而我们平时用的 px(像素)通常用于屏幕媒体,虽然也是固定单位,但是根据屏幕的分辨率不同而可大可小。

当然选择哪个单位并没有绝对的。在写这边文章之前,我做打印的需求时都用的是 px,查阅资料后发现对于打印单位都推荐使用 pt,经过一番实践对比后,在不同的设备上(iPad 和 Mac)用 px 打印出来的字体的大小在 iPad 上显得更加纤细,而使用了 pt 的话,差距就不是很明显。还有如果你对 sketch 比较了解的话,你可以对你的 Artboard(画板)类型选为 Paper Sizes(如下图):A4、A5、A6、Letter,纸张类型(比如 A4)后面的大小为 595 x 842,但是是没有单位的。如果你稍微换算一下就知道,A4 的纸张大小为 210mm x 297mm,如果以 pt 为单位的话,换算过来就是 595pt x 842pt。

屏幕快照_2018-01-21_14.19.12

布局

布局其实跟平时写网页没区别,最省心的方式当然是用 flexbox 来搞定了。但是不建议用浮动,在某些情况下,在电脑端正常但是在 pad 端样式就会错乱,所以尽量避免。如果打印的内容里含有图片,最好是给图片设置一个最大宽度或者最大高度(max-width: 100% or max-height: 100%),这样做是为了图片尺寸太大超出纸张大小影响布局。

通常打印的页面往往都会有固定的 header 或 footer,而中间的 body 是根据内容伸缩的,很常见的布局,如下图:

屏幕快照_2017-09-27_00.37.58

为了显示良好,我们可以用 flexbox 来轻松搞定,但为了兼容性的话也可以用 table 来完成,最终的效果都是相同的,以下为这种布局的伪代码供参考:

// flexbox

.page-wrapper {
    display: flex;
    flex-direction: column;
    height: 100%;
}

.page-body {
    flex: 1;
}

// table

.page-wrapper {
    display: table;
    height: 100%;
}

.page-header,
.page-footer {
    display: table-row;
    height: 1px;
}

.page-body {
    display: table;
    height: 100%;
}

@page

@page 这个规则很强,可以让我们在 CSS 中设置打印页面的一些属性如:纸张尺寸、边距等。比如以下代码:

@page {
  size: A4 landscape;
  margin: 2cm;
}

应用上面的 CSS,打印出来的纸张就是 A4 大小,并且纸张的上下左右边距都为 2cm。这个可以确保你输出内容符合预期,用户用着也省心,不用费劲探究各种配置项了。这个规则里面还是有蛮多东西可以设置的,详情请参见 CSS Paged Media Module Level 3

-webkit-print-color-adjust: exact

在打印的对话框中我们可以设置是否打印背景图形,通过这个属性我们可以强制打印的页面有背景图形,即便你在打印对话框中没有勾选背景图形,做这个的好处就是不要用户再费心探究了。当然这是一个非标准属性,以后可能会更改,目前在 Chrome、Safari 是有效的,Firefox 上不生效。

分页

对于分页问题,CSS 早在 2.x 版本中就支持了一些属性用来控制元素的分页行为。如 page-break-before,page-break-after,page-break-inside,在元素上应用这些属性就可以控制在该元素之前或之后,或者元素内部是否分页。这几个属性的取值这里就不一一列出了,请自行探究。

在实际的场景往往不是很简单,页面上超出的内容可能需要以某种样式整齐的放在下一页,这些属性并不能带来想要的效果,因此还要用
JavaScript 来操作 DOM 来处理分页问题。思路也比较简单,就是如果内容超出了纸张大小就去截取超出内容并放到下一页。

总结

以上是做打印需求的一些简单总结及实践,并不是很全面深入,希望能让各位前端同学们做同样的需求时,少走一些弯路。

参考

CSS print 样式

Print —— 被埋没的Media Type

CSS:EM, PX, PT, CM, IN…

CSS 打印

0

从 React 到 Preact 迁移指南

为什么选 Preact

Fast 3kB alternative to React with the same ES6 API.
React 的 3kb 轻量化方案,拥有同样的 ES6 API

  • 体积小。React v15.6.1 有 49.8kb,最新的 React v16.0 小了很多,有 34.8kb。而 Preact 官网声称只有 3kb,实测 v8.2.5 版本有
    4.1kb,但这已经比 React 小了至少 30kb,在移动端网页开发中占了不少优势。
  • 性能高。是最快的虚拟 DOM 框架之一,具体可以查看 Results for js web frameworks benchmark – round 6
  • 生态好。官方提供 preact-compat,可以无缝使用 React 生态系统中的各类组件。
  • 更方便。相比 React,Preact 添加了几个更为便捷的特性,包括可以直接使用标准的 HTML 属性(如 classfor),propsstatecontext 作为参数传进了 render() 等。

除了 Preact,React-like 中比较出名的还有 Inferno,它大小在9kb左右。它和前面两者相比,还加了一个新的特性就是无状态组件(stateless components)也支持生命周期事件,从而可以减少 class 的使用,做到更加轻量。性能比 React 和 Preact 都要好,其它方面和 Preact 差不多。但是有个问题是 Inferno 用了一些相对较新的特性,比如 PromiseMapSetWeakMap 等,所以浏览器兼容性比 Preact 要差点。

迁移指南

官方文档

官方文档提供了2种途径,都是在原项目上把 React 替换成 Preact,我们为了保持项目干净采用创建新项目的方式来做。

准备

1. 创建项目

Preact 官方提供了2种方式来创建项目:

  1. preact-cli
  2. Preact Boilerplate / Starter Kit

官方推荐用方式一,但我们为了方便定制采用了方式二来创建,去掉了 preact-compat 等暂时没用到库。

2. 复制代码

创建完项目,把原项目中 src 目录下的代码(index.js、组件等)拷贝到新项目 src 目录下就可以了。

这样准备工作就做好了,当然现在项目还是跑不起来,会各种报错,接下来我们需要修改代码。

修改代码

react

react 替换成 preact

import React from 'react';
// =>
import { h } from 'preact';

Preact 的 h() 相当于 React 中的 createElement(),可以阅读 WTF is JSX 了解更多。

import { render } from 'react-dom';
// =>
import { render } from 'preact';
import { Component } from 'react';
// =>
import { Component } from 'preact';

redux

直接替换成 preact-redux 就可以,其它都一样:

import { Provider } from 'react-redux';
// =>
import { Provider } from 'preact-redux';

router

我们原项目采用的是 React Router v3,Preact 官方提供了 preact-router,它并不是 React Router 的 preact 兼容版本,而是另一种轻量级的路由方案。如果你还是想用 React Router,可以它的 v4 版本,因为 v4 版本可以直接和 Preact 一起使用,而不需要 preact-compat 来做兼容。

所以如果你有比较复杂的需求的话,比如路由嵌套、视图合成等等,可以考虑用 React Router v4;如果只是想要比较基本的路由需求的话,那就用 preact-router 好了。我们项目路由需求不复杂,为了追求轻量,就采用了 preact-router,毕竟 React Router 比 preact-router 大了很多。

因为不兼容,所以改动也是比较大的:

import { Router, Route, IndexRoute } from 'react-router';
// =>
import Router from 'preact-router';
import { hashHistory } from 'react-router';
// =>
import createHashHistory from 'history/createHashHistory';
const hashHistory = createHashHistory();
const routes = (
  <Router history={hashHistory}>
    <IndexRoute component={Home} />
    <Route path="about" component={About} />
    <Route path="inbox" component={Inbox} />
  </Router>
);
// =>
const routes = (
  <Router history={hashHistory}>
    <Home path="/" />
    <About path="/about" />
    <Inbox path="/inbox" />
  </Router>
);

<Route/>onEnter/onLeave 钩子也没了,不过没关系,因为用组件的生命周期才是更合理的。

这样做完路由配置之后,组件中用到路由的部分也不一样了。原先取当前页面的 URL 或者 query 是这样的:

class Home extends Component {
  componentWillMount() {
    const { router } = this.context;
    const { params } = this.props;

    const url = router.location.pathname;
    const { id } = params;
  }
}

现在就只需要:

class Home extends Component {
  componentWillMount() {
    const { url, id } = this.props;
  }
}

是不是更简单了。preact-router 引入了更少的 API,语法也更加简洁。

有了路由,当然也少不了链接。原先我们会写成这样:

import { Link } from 'react-router';

<Link to="/about">关于</Link>

现在只需要写成普通的 <a/> 标签就可以了,因为 preact-router 会自动匹配 <a/> 标签到路由:

<a href="/about">关于</a>

当然如果你要修改激活状态样式的话还是可以用 <Link/>,但注意这里用的是 href="/" 而不是 to="/"

import { Link } from 'preact-router/match';

<Link activeClassName="active" href="/">Home</Link>

总结

上面只是梳理了我们在做迁移时需要改动的地方,因为之前并没有用第三方的 React 组件,所以也没有用 preact-compat,除了 router 改得比之前更简单了,其它基本不需要怎么改动,整体来说迁移成本不高。

迁移后效果最明显的就是编译出来的 js 小了很多,基本是之前的1/3,所以移动端网页开发推荐用 Preact 替换 React。

2+

Web 与 App 数据交互原理和实现

背景

点击图片查看大图已经成了用户浏览页面时的一种习惯,原生 App 往往都实现了这些图片处理功能(点击查看、缩放、保存、滑动浏览等)。对于 Web 页面,为了更好的体验,一般开发者都会自己实现或是使用三方的图片处理框架。但如果一个 Web 页面能在一些特定的原生 App 中打开,那完全可以让 App 去代理处理这些图片。毕竟原生 App 的体验会更好,而且同一个 App 内点击原生图片和 Web 里面的图片,体验应该是一致的。

所以需求很简单,就是Web 页能直接调用原生的图片显示功能嘛!

交互原理

这个需求背后要解决的问题,实际上是要通过 Web 与原生的交互,让 Web 把图片资源交给原生应用去处理。

iOS7 之后苹果推出 JSCore,通过获取 Web 上下文环境,实现了 App 可以直接调用 JS 方法,或者将 block 赋值给 JS 方法来实现 Web 调用 App 并传值。所以在不考虑安卓的情况下,可以通过 JSCore 实现 Web 与 App 交互。

JSCore

App 端

// 获取JS上下文
 JSContext *jsContext;
-(void)WebViewDidFinishLoad:(UIWebView *)webView {
  jsContext = [_webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
}

// App 调 Web insertText 事件
JSValue *funcValue = jsContext[@"insertText"];
[funcValue callWithArguments:@[@"hello web!"]];

// Web 调 App,App注册事件名为callNative
jsContext[@"callNative"] = ^(NSString*str){
  NSLog(str);
};

Web 端

// 接收 App 调用
function insertText(text) {
  ...
}

// 调用 App
btnNode.onclick = () => {
  callNative('hello app!');
}

这种方式使用简单,但大多数情况需要兼容 iOS 与安卓,所以需要找到更合适的方式。

传统方式

苹果推出 JSCore 以前,App 调用 Web 只能通过 WebView 执行 JSString 来实现。而 Web 没法直接调用 App,只能触发特定链接,让 App 在 WebView 代理方法中捕获到这特定链接,从而执行相应操作,间接实现 Web 调 App。这种传统方式也适用于安卓端的的实现。

App 调用 Web

[_webView stringByEvaluatingJavaScriptFromString:jsString];

这里的 JSString 是一个 JS 方法的调用,这个方法能给 Web 传值的前提是在 Web 端定义一个全局方法如:

function handlerMessageFromApp(data) {
  ...
}

那 App 端需要去拼接出这个 JSString 然后再调用 Webview 的 stringByEvaluatingJavaScriptFromString 方法:

NSString* jsString = [NSString stringWithFormat:@"handlerMessageFromApp('%@');", messageJSON];

[_webView stringByEvaluatingJavaScriptFromString:jsString];

App 调用 Web 就是利用这种 Webview 去执行一个 JS 方法的方式去实现。JS 方法可以直接返回值给 App,但通常情况下 Web 端收到 App 的消息会去进行一些异步操作,这样在 handlerMessageFromApp 中直接返回就不太合适了。此时需要有另外一个流程去保证 Web 端把消息传递给 App,也就是下面会说的 Web 调 App。

Web 调用 App

Web 调 App 则需要双方事先沟通定好协议。比如如果要点击 Web 页面链接跳转到 App 主页,那可以将协议的名称定为 xr://home,HTML 内容为 <a href="xr://home">查看主页>></a>,点击链接就会发生 url 的改变,同时原生 WebView 的代理方法也会被触发。通过在代理方法中监听 url 的变化实现约定的协议。

// 在 webview 代理方法中处理
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
    NSURL *url = [request URL];
    if  (url == "xr://home") {...}
}

如果基于这种方式让 Web 给 App 传值可以有两种方式:
1. 直接在协议后面拼接参数如 xr://message?name=carson,这种方式适合于简单的值传递,但对于复杂结构的数据传递这种方式不合适。
2. Web 端 与 App 端定好消息协议如:xr://message,Web 端要发送消息给 App 端时触发该协议,同时将要传递的值放在页面上,App 端监听到该协议变化时从 Web 页面上取值。这种方式适合传递复杂数据。

现实开发中的传值往往都是复杂结构的,所以我们选择第2种方式去完成 Web 端调用 App 端。

function getMessageFromWeb() {
  return messageObject;
}

基于前面的经验在 Web 端实现方法 getMessageFromWeb,这个方法负责返回要传递给 App 的值。

id messages = [_webView stringByEvaluatingJavaScriptFromString:@"getMessageFromWeb()"];

当特定的消息协议被触发时 App 通过 Webview 执行带有返回值的 JS 方法拿到数据,这就间接实现 Web 调用 App 并传值的原理。

但这种方式 Web 端每次调用时都需要触发特定协议,同时将全局变量 messageObject 赋值。我们往往希望有一个抽象层来做这些事情,每当调用的时候作为调用方最好是能一个方法传递消息名称与消息内容就行了,接收方也只需要按消息名称接收消息内容。Web 端与 App 端都应该具备这样一个抽象层,这样具体的两端交互就简化成了一端只管调用另一端只管接收。有了这样一个抽象层再来看 Web 端调 App 端就好比:

  1. W 委托 B 发消息给 A 说有包糖要给他,此时 W 已经把糖交给了 B。
  2. B 发出通知给 A。
  3. A 收到通知后跑过来从 B 这里拿走了糖。

这样一个单向的 W 把消息发送给 A 就完成了,原理还是基于前面 Web 调用 App 的原理。因为有 B 的存在此时 W 要做的事情少了很多。

完整的调用

上面的过程只是最简单的情况,是一个单向的,正常情况下 W 给 A 送了包糖或许还想知道 A 什么时候吃完,吃完了 W 可以再去买。

  1. W 委托 B 发消息给 A 说有包糖要给他,此时 W 已经把糖交给了 B。
  2. B 发出通知给 A。
  3. A 收到通知后跑过来从 B 这里拿走了糖。
  4. A 吃完糖后再通知到 B 说他吃完了。
  5. B 最后通知到 W,W 再去买糖。

步骤4与步骤5代表 App 回调 Web。W 最后再买糖相当于回调函数的实现,这是在 W 送糖的时候就已经决定的事情。 因为整个一去一回的过程并非同步,所以这个地方就需要处理好这样一个映射:消息 —> 回调处理函数。也就是说每条发送的消息对应上各自的回调处理函数,这就需要 B 去维护这样一个映射关系。B 给要发送的消息加上 callbackId,同时以 { callbackId: callbackHandler } 的方式将回调处理函数存储起来。A 收到了有 callbackId 的消息再响应时又回继续带上这个 callbackId,最后 B 按照 callbackId 找到回调处理函数去执行。这样才是一个完整的带有回调处理的 Web 调用 App 并传值。

所以关键任务是去实现 B 这样一个抽象层,B 的任务很重,它是一个 Web 端与 App 端交互的桥梁,它要具备发送消息提示、存储消息内容、存储消息回调、 接收消息、执行消息回调等功能。

实现这样一个抽象层并不算太难,但重复造轮子的事就不做了,前面描述的过程也正是参照第三方库 WebViewJavascriptBridge 的实现,B 也正是这个框架的核心 bridge,其整个实现基于观察者模式,在最基本的原理上加上了一些封装,抽象出一层 bridge 负责两端的交互,最终暴露给开发者的只有简单的 API(初始化、注册、调用)。

正如 bridge 这个名字一样,它起着桥梁作用,实现了两端的数据交互。这两个 bridge 基本实现了相同的功能,唯一的区别在于 Web 端这边的 bridge 没法直接发送消息内容,只能告诉 App 端有消息。

应用

回到最开始的需求,还是在 WebViewJavascriptBridge 的基础上去实现让 App 去处理 Web 里的图片。做过图片浏览的应该都知道,实现图片浏览需要提供所有图片的数组 array 以及当前点击图片的 index。所以给 img 标签绑定点击事件,再获取所有图片的数组,最后利用 JSBridge 传给 App 即可。至于第三方库的接入可以去查看官方文档。

App 端注册 handler,用于处理接收图片:

[self.bridge registerHandler:@"previewImage" handler:^(id data, WVJBResponseCallback responseCallback) {
    // 处理图片 data
}]

相应的 Web 端要去 callHandler:

// setup 去触发 App 将 bridge 注入 window
setupWebViewJavascriptBridge(function(bridge) { 
  bridge.callHandler('previewImage', { urls: [ /* ... */ ], index: 0 });
}

就这么简单的一端管接收另一端管调用就完成了 Web 将图片传给原生。
熟悉微信网页开发的应该知道微信也提供了相关功能,微信中的网页可以利用微信的 JSSDK 使用原生提供的一整套图片处理功能:相册、相机、裁剪、上传、下载、图片浏览...


App 与 Web 交互的场景还有很多,比如 Web 页自定义分享、控制 App 页面跳转,随着小程序的出现 Web 端与 App 的交互也是更加有了深度。总之原理不难,掌握了原理才能做出更好更多的事情。

1+