如何使用HTML5构建类似Instagram的照片分享应用






4.75/5 (5投票s)
如何使用HTML5构建类似Instagram的照片分享应用
当我开始开发这个应用程序时,我只是想看看 Web 平台是否真的发展到了一个可以用纯 HTML、JavaScript 和 CSS 构建像 [Instagram](http://aka.ms/Instragram) 这样非常流行的应用程序的程度。事实证明,我们确实可以做到。本文将带您了解使这成为可能的技术,并展示今天构建可互操作的 Web 应用程序是多么完全可行,无论用户运行的是哪种品牌的浏览器,都能提供出色的用户体验。
如果您是那少数几个没听说过 *Instagram* 的人中的一员,那么您可能会很高兴听到它是一个非常受欢迎的照片分享和社交网络服务,它允许您拍照,对它们应用有趣的数字滤镜,并与全世界分享。该服务非常受欢迎,以至于在 2012 年 4 月被 [Facebook](http://finance.fortune.cnn.com/2012/04/09/breaking-facebook-buying-instagram-for-1-billion/) 以巨额现金和股票收购。
[InstaFuzz](http://aka.ms/InstaFuzz) 是我开发的应用程序的名称,虽然我不指望能被 Facebook 或任何其他公司以十亿美元的价格收购,但它确实证明了使用诸如 Canvas、File API、Drag/Drop、Web Workers、ES5 和 CSS3 等符合标准的 Web 技术完全可以构建此类应用程序,并且在 [Internet Explorer 10](http://www.microsoft.com/click/services/Redirect2.ashx?CR_CC=200210639)、Google Chrome 和 Firefox 等现代浏览器上也能运行良好。您也可以轻松地使用这些代码来 [构建 Windows 应用商店应用](http://www.microsoft.com/click/services/Redirect2.ashx?CR_CC=200185224)。
关于应用程序
如果您想看看这个应用程序,它托管在这里:
http://blogorama.nerdworks.in/arbit/InstaFuzz/
加载后,您将看到一个如下所示的屏幕:
其想法是,您可以通过单击左下角的红色“添加”按钮来加载照片到应用程序中,或者将图像文件拖放到右侧的黑蓝色区域。一旦您这样做,您就会得到类似这样的东西:
您会注意到屏幕左侧列出了数字滤镜列表,显示了如果您应用该滤镜,图像会是什么样子的预览。应用滤镜很简单,只需单击左侧的滤镜预览之一。下面是应用“加权灰度”滤镜然后应用“运动模糊”后的样子。您可以看出滤镜是 *累加* 的——您继续单击滤镜,它们会叠加在之前应用过的滤镜之上。
接下来,我们来看看 UI 布局是如何构建的。
UI 布局
HTML 标记实际上非常少,我实际上可以在这里完整地重现 BODY 标签的内容(不包括 SCRIPT 包含):
<header>
<div id="title">InstaFuzz</div>
</header>
<section id="container">
<canvas id="picture" width="650" height="565"></canvas>
<div id="controls">
<div id="filters-list"></div>
<button id="loadImage">Add</button>
<input type="file" id="fileUpload"
style="display: none;"
accept="image/gif, image/jpeg, image/png" />
</div>
</section>
<!-- Handlebar template for a filter UI button -->
<script id="filter-template" type="text/x-handlebars-template">
<div class="filter-container" data-filter-id="{{filterId}}">
<div class="filter-name">{{filterName}}</div>
<canvas class="filter-preview" width="128" height="128"></canvas>
</div>
</script>
这里没什么特别的。几乎所有内容都应该是标准的。但我会特别提一下,我在这里使用了 [Handlebars](https://handlebars.node.org.cn/) JavaScript 模板系统来渲染屏幕左侧滤镜列表的标记。模板标记在 HTML 文件中声明(上面代码片段中的 SCRIPT 标签),然后从 JavaScript 中使用。然后将模板标记绑定到一个 JavaScript 对象,该对象为 handlebars 表达式(如 {{filterId}}
和 {{filterName}}
)提供值。这是应用程序中相关的 JS 部分,并借助 [jQuery](https://jqueryjs.cn/) 进行了一些 DOM 操作:
var templHtml = $("#filter-template").html(),
template = Handlebars.compile(templHtml),
filtersList = $("#filters-list");
var context = {
filterName: filter.name,
filterId: index
};
filtersList.append(template(context));
从 HTML 标记可以看出,所有滤镜预览框都包含一个 CANVAS 标签,右侧用于渲染最终输出的大盒子也是如此。稍后在文章中,我们将更详细地介绍 Canvas 技术如何用于实现这些效果。
该应用程序还使用 [CSS3 @font-face](http://aka.ms/fontface) 字体来渲染标题和“添加”按钮中的文本。字体取自优秀的 [Font Squirrel](http://aka.ms/FontSquirrel) 网站,声明如下:
@font-face {
font-family: 'TizaRegular';
src: url('fonts/tiza/tiza-webfont.eot');
src: url('fonts/tiza/tiza-webfont.eot?#iefix')
format('embedded-opentype'),
url('fonts/tiza/tiza-webfont.woff') format('woff'),
url('fonts/tiza/tiza-webfont.ttf') format('truetype'),
url('fonts/tiza/tiza-webfont.svg#TizaRegular') format('svg');
font-weight: normal;
font-style: normal;
}
此指令会导致用户代理将字体嵌入页面,并使其在分配给 font-family
规则的名称下可用,在本例中为“TizaRegular”。在此之后,我们可以像平常一样将此字体分配给任何 CSS font-family
规则。在 *InstaFuzz* 中,我使用以下规则将字体分配给标题元素:
font-family: TizaRegular, Cambria, Cochin, Georgia, Times,
"Times New Roman", serif;
您可能还注意到容器元素在页面上投射了一个微妙的阴影。
这是使用 [CSS3 box-shadow](http://aka.ms/boxshadowproperty) 规则实现的,下面是它在 *InstaFuzz* 中的用法:
-moz-box-shadow: 1px 0px 4px #000000, -1px -1px 4px #000000;
-webkit-box-shadow: 1px 0px 4px #000000, -1px -1px 4px #000000;
box-shadow: 1px 0px 4px #000000, -1px -1px 4px #000000;
这会导致浏览器围绕相关元素渲染阴影。值中每个逗号分隔的部分指定了阴影的以下属性:
- 水平偏移
- 垂直偏移
- 扩散距离 – 正值会使阴影变软
- 阴影颜色
如上所示,可以指定多个由逗号分隔的阴影值。请注意,我还使用了 Firefox 和 Chrome/Safari 的供应商前缀语法(使用 *moz* 和 *webkit* 前缀)指定了阴影。这使得阴影在那些浏览器版本中继续工作,这些版本使用供应商前缀版本的规则来支持此功能。请注意,规则的 W3C 版本(box-shadow
)最后指定。这样做是故意的,以确保万一浏览器同时支持这两种形式,只有 W3C 的行为实际上会应用于页面。
Web 开发者常常要么忘记为所有支持规则的浏览器包含 CSS3 规则的供应商前缀版本,要么忘记包含 W3C 版本。通常,开发者只是使用 *webkit* 版本,而忽略其他浏览器和 W3C 标准版本。这会导致两个问题:[1] 对于使用非 webkit 浏览器的用户来说,用户体验不佳;[2] 最终导致 webkit 成为 Web 的事实标准。理想情况下,我们希望 W3C 来驱动 Web 的未来,而不是一个特定的浏览器实现。因此,在尝试 CSS 功能的实验性实现时,需要记住以下几点:
- 尽管使用 CSS 规则的供应商前缀版本,但请记住为所有支持的浏览器指定规则,而不仅仅是您正在测试页面的那个浏览器(如果您正在使用 [Visual Studio](http://www.microsoft.com/click/services/Redirect2.ashx?CR_CC=200117040) 编辑 CSS,那么您可能会对 Visual Studio 中一个非常出色的扩展程序 *Web Essentials* 感兴趣,该扩展程序使管理供应商前缀的工作变得尽可能简单)。
- 记住也要指定规则的 W3C 版本。
- 记住要按顺序排列规则的出现,以便 W3C 版本最后出现。这是为了让同时支持供应商前缀版本和 W3C 版本的客户端可以使用 W3C 指定的规则语义。
拖放
InstaFuzz 支持的功能之一是将图像文件直接拖放到大黑蓝色框中。通过处理 CANVAS 元素上的“drop”事件来启用此支持。当一个文件被拖放到 HTML 元素上时,浏览器会在该元素上触发“drop”事件,并传递一个 [dataTransfer](http://aka.ms/dataTransfer) 对象,该对象包含一个 [files 属性](http://aka.ms/filesproperty),其中包含对被拖放文件列表的引用。下面是应用程序中处理此问题的方式(“picture”是页面上 CANVAS 元素的 ID):
var pic = $("#picture");
pic.bind("drop", function (e) {
suppressEvent(e);
var files = e.originalEvent.dataTransfer.files;
// more code here to open the file
});
pic.bind("dragover", suppressEvent).bind("dragenter", suppressEvent);
function suppressEvent(e) {
e.stopPropagation();
e.preventDefault();
}
files
属性是 File
对象的集合,随后可以使用 File API 来访问文件内容(下一节将介绍)。我们还处理 dragover
和 dragenter
事件,并基本阻止这些事件传播到浏览器,从而阻止浏览器处理文件拖放。例如,IE 可能会卸载当前页面并尝试直接打开文件。
文件 API
文件被拖放后,应用程序会尝试在 canvas 中打开并渲染图像。它通过使用 [File API](http://aka.ms/FileAPI) 来实现。File API 是一项 W3C 规范,它允许 Web 应用程序以安全的方式以编程方式访问本地文件系统中的文件。在 *InstaFuzz* 中,我们使用 [FileReader 对象](http://aka.ms/FileReaderObject) 通过 [readAsDataURL 方法](http://aka.ms/readAsDataURLmethod) 将文件内容读取为 [数据 URL](http://aka.ms/dataProtocol) 字符串,如下所示:
var reader = new FileReader();
reader.onloadend = function (e2) {
drawImageToCanvas(e2.target.result);
};
reader.readAsDataURL(files[0]);
在这里,files
是从处理 CANVAS 元素上的“drop”事件的函数检索到的 File
对象集合。由于我们只对单个文件感兴趣,我们只需选择集合中的第一个文件,而忽略其余文件(如果有的话)。实际文件内容是异步加载的,加载完成后,会触发 [onloadend](http://aka.ms/onloadend) 事件,我们在此事件中以数据 URL 的形式获取文件内容,然后将其绘制到 canvas 上。
渲染滤镜
这里的核心功能当然是应用滤镜。为了能够将滤镜应用到图像上,我们需要一种方法来访问图像的单个像素。在访问像素之前,我们需要已经将图像渲染到我们的 canvas 上。所以,让我们先看看将用户选择的图像渲染到 canvas 元素的代码。
将图像渲染到 canvas
Canvas 元素通过 [drawImage](http://aka.ms/drawImage) 方法支持 Image 对象的渲染。为了将图像文件加载到 *Image* 实例中,*InstaFuzz* 使用了以下实用例程:
App.Namespace.define("InstaFuzz.Utils", {
loadImage: function (url, complete) {
var img = new Image();
img.src = url;
img.onload = function () {
complete(img);
};
}
});
这允许应用程序使用如下代码从 URL 加载图像对象:
function drawImageToCanvas(url) {
InstaFuzz.Utils.loadImage(url, function (img) {
// save reference to source image
sourceImage = img;
mainRenderer.clearCanvas();
mainRenderer.renderImage(img);
// load image filter previews
loadPreviews(img);
});
}
在这里,mainRenderer
是从 *filter-renderer.js* 中定义的 FilterRenderer
构造函数创建的实例。该应用程序使用 FilterRenderer
对象来管理 Canvas 元素——无论是预览窗格中的,还是右侧主 Canvas 元素中的。FilterRenderer
上的 renderImage
方法定义如下:
FilterRenderer.prototype.renderImage = function (img) {
var imageWidth = img.width;
var imageHeight = img.height;
var canvasWidth = this.size.width;
var canvasHeight = this.size.height;
var width, height;
if ((imageWidth / imageHeight) >= (canvasWidth / canvasHeight)) {
width = canvasWidth;
height = (imageHeight * canvasWidth / imageWidth);
} else {
width = (imageWidth * canvasHeight / imageHeight);
height = canvasHeight;
}
var x = (canvasWidth - width) / 2;
var y = (canvasHeight - height) / 2;
this.context.drawImage(img, x, y, width, height);
};
这看起来可能代码量很多,但最终它只是确定了在可用屏幕区域中渲染图像的最佳方法,同时考虑了图像的纵横比。实际将图像渲染到 canvas 的关键代码发生在方法的最后一行。context
成员指的是通过调用 canvas 对象的 [getContext](http://aka.ms/getContext) 方法获取的 2D 上下文。
从 canvas 中获取像素
现在图像已经渲染,我们需要访问单个像素才能应用所有可用的不同滤镜。通过调用 canvas 上下文对象的 [getImageData](http://aka.ms/getImageData) 可以轻松获得这一点。下面是 *InstaFuzz* 从 *instafuzz.js* 中调用它的方式:
var imageData = renderer.context.getImageData(
0, 0,
renderer.size.width,
renderer.size.height);
getImageData
返回的对象通过其 data
属性提供对单个像素的访问,该属性是一个类数组对象,包含字节值集合,每个值代表单个像素单个通道的渲染颜色。每个像素使用 4 个字节表示,指定红色、绿色、蓝色和 Alpha 通道的数值。它还有一个 [length 属性](http://aka.ms/lengthProperty),用于返回缓冲区的长度。如果您有一个 2D 坐标,您可以使用以下代码轻松将其转换为该数组中的索引。每个通道的颜色强度值范围从 0 到 255。下面是 *filters.js* 中的实用函数,它接受一个图像数据对象以及调用者感兴趣的像素的 2D 坐标作为输入,并返回一个包含颜色值的对象:
function getPixel(imageData, x, y) {
var data = imageData.data, index = 0;
// normalize x and y and compute index
x = (x < 0) ? (imageData.width + x) : x;
y = (y < 0) ? (imageData.height + y) : y;
index = (x + y * imageData.width) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2]
};
}
应用滤镜
现在我们已经可以访问单个像素了,应用滤镜相当直接。例如,这是应用加权灰度滤镜到图像上的函数。它只是从红色、绿色和蓝色通道中提取强度,在对每个通道应用乘法因子后将它们相加,然后为所有 3 个通道分配结果。
// "Weighted Grayscale" filter
Filters.addFilter({
name: "Weighted Grayscale",
apply: function (imageData) {
var w = imageData.width, h = imageData.height;
var data = imageData.data;
var index;
for (var y = 0; y < h; ++y) {
for (var x = 0; x < w; ++x) {
index = (x + y * imageData.width) * 4;
var luminance = parseInt((data[index + 0] * 0.3) +
(data[index + 1] + 0.59) +
(data[index + 2] * 0.11));
data[index + 0] = data[index + 1] =
data[index + 2] = luminance;
}
Filters.notifyProgress(imageData, x, y, this);
}
Filters.notifyProgress(imageData, w, h, this);
}
});
一旦滤镜应用完毕,我们就可以通过调用 [putImageData](http://aka.ms/putImageData) 方法(传入修改后的图像数据对象)来反映到 canvas 上。虽然加权灰度滤镜相当简单,但大多数其他滤镜都使用了称为 `convolution` 的图像处理技术。所有滤镜的代码都可以在 *filters.js* 中找到,并且卷积滤镜是从此处可用的 C 代码移植过来的:[http://lodev.org/cgtutor/filtering.html](http://lodev.org/cgtutor/filtering.html)。
Web Workers
您可能会想象,执行所有这些数字计算来应用滤镜可能需要很长时间才能完成。例如,*运动模糊* 滤镜使用 9x9 滤镜矩阵来计算每个像素的新值,实际上是所有滤镜中 CPU 占用率最高的。如果我们要在浏览器的 UI 线程上执行所有这些计算,那么每次应用滤镜时应用程序都会基本冻结。为了提供响应式的用户体验,该应用程序使用现代浏览器对 W3C [Web Workers](http://aka.ms/WebWorkers) 的支持,将核心图像处理任务委托给后台脚本。
Web Workers 允许 Web 应用程序在后台任务中运行脚本,该任务与 UI 线程并行执行。Worker 和 UI 线程之间的通信通过使用 [postMessage](http://aka.ms/postMessage) API 传递消息来实现。在两端(即 UI 线程和 Worker)上,这都会表现为一个可以处理的事件通知。您只能在 Worker 和 UI 线程之间传递“数据”,也就是说,您不能传递任何与用户界面相关的内容——例如,您不能将 DOM 元素从 UI 线程传递给 Worker。
在 *InstaFuzz* 中,Worker 实现在文件 *filter-worker.js* 中。Worker 中所做的所有事情就是处理 [onmessage](http://aka.ms/onmessage) 事件,应用滤镜,然后通过 postMessage
将结果传回。事实证明,即使我们不能传递 DOM 元素(这意味着我们不能直接将 Canvas 元素交给 Worker 来应用滤镜),我们实际上可以传递前面讨论过的 getImageData
方法返回的图像数据对象。下面是 *filter-worker.js* 中的滤镜处理代码:
importScripts("ns.js", "filters.js");
var tag = null;
onmessage = function (e) {
var opt = e.data;
var imageData = opt.imageData;
var filter;
tag = opt.tag;
filter = InstaFuzz.Filters.getFilter(opt.filterKey);
var start = Date.now();
filter.apply(imageData);
var end = Date.now();
postMessage({
type: "image",
imageData: imageData,
filterId: filter.id,
tag: tag,
timeTaken: end - start
});
}
第一行通过调用 [importScripts](http://aka.ms/importScripts) 拉取 Worker 所依赖的一些脚本文件。这类似于在 HTML 文档中使用 SCRIPT 标签包含 JavaScript 文件。然后,我们设置一个 onmessage
事件的处理器,响应此事件,我们只需应用所请求的滤镜,然后通过调用 postMessage
将结果传递回 UI 线程。很简单!
初始化 Worker 的代码在 *instafuzz.js* 中,如下所示:
var worker = new Worker("js/filter-worker.js");
没什么特别的,对吧?当 Worker 向 UI 线程发送消息时,我们通过在 worker 对象上指定 onmessage
事件的处理器来处理它。下面是在 *InstaFuzz* 中完成此操作的方法:
worker.onmessage = function (e) {
var isPreview = e.data.tag;
switch (e.data.type) {
case "image":
if (isPreview) {
previewRenderers[e.data.filterId].
context.putImageData(
e.data.imageData, 0, 0);
} else {
mainRenderer.context.putImageData(
e.data.imageData, 0, 0);
}
break;
// more code here
}
};
代码应该相当自明。它只是获取 Worker 发送的图像数据对象,并将其应用于相关的 canvas 上下文对象,从而在屏幕上渲染修改后的图像。安排滤镜进行 Worker 转换同样简单。下面是 *InstaFuzz* 中执行此功能的例程:
function scheduleFilter(filterId,
renderer,
img, isPreview,
resetRender) {
if (resetRender) {
renderer.clearCanvas();
renderer.renderImage(img);
}
var imageData = renderer.context.getImageData(
0, 0,
renderer.size.width,
renderer.size.height);
worker.postMessage({
imageData: imageData,
width: imageData.width,
height: imageData.height,
filterKey: filterId,
tag: isPreview
});
}
总结
InstaFuzz 的源代码可在 [此处](http://sdrv.ms/11110mf) 下载。我们已经看到,今天使用 Canvas、Drag/Drop、File API 和 Web Workers 等 HTML5 技术可以实现相当复杂的 UI 体验。几乎所有现代浏览器对所有这些技术都有很好的支持。有一点我们没有在这里解决,那就是使应用程序与旧版浏览器兼容的问题。说实话,这是一项非同寻常但必不可少的任务,我希望将来能写一篇关于它的文章。
本文是 Internet Explorer 团队 HTML5 技术系列的一部分。在 [http://modern.IE](http://modern.IE/) 上,通过三个月的免费 BrowserStack 跨浏览器测试来 [试用](http://www.microsoft.com/click/services/Redirect2.ashx?CR_CC=200210639) 本文中的概念。
Rajasekharan Vengalil 是一位自称为“书呆子”的人,在一家名为 [Microsoft](http://www.microsoft.com/) 的公司工作。他是其 *开发与平台布道* 团队的成员,这意味着他有机会了解所有来自 Microsoft 的有趣的新技术,然后与人们分享(例如,用 HTML5 构建应用程序。他认为能得到报酬做他本来就会免费做的事情,真是非常幸运!