使用ASP.NET MVC构建HTML5离线web应用程序
fmms 13年前
<p> web 应用程序的主要制约之一就是连接性。在 HTML5 到来之前我们就曾想挖掘浏览器的能力,以使 web 应用程序能像桌面应用程序一样功能强大和易于使用,但浏览器始终让我们感到失望。虽然之前已出现了一些浏览器缓存技术,但这些缓存技术的设计初衷并不是为了使 web 应用程序能够完全地离线运行,令人遗憾的是,事实上使用这些技术的 web 应用程序很容易出问题,而且难于使用。HTML5试图通过离线应用程序缓存( offline application cache)技术来填补浏览器的能力空缺,该技术能更加可靠地使 web 应用程序离线工作。</p> <p> <strong>为什么 web 应用程序需要离线运行呢?</strong></p> <p> 老实讲,一般来说,桌面电脑的 web 应用程序即使能够完全离线运行也不能带来多大的好处,因为桌面电脑一般都是一直连线的。我特别期待看到的是,移动设备 web 应用程序能够从离线应用程序缓存技术得到多大的好处。</p> <p> 在许多地方,移动电话普及率都在持续增长。如果能够自然地填补网络断线的鸿沟,移动设备浏览器中的 web 应用程序对用户来说就更加友好了。</p> <p> 在一些特定场景中,使整个应用程序能够离线运行,意味着我们只需创建一个跨平台的浏览器解决方案,而不必创建多个内建应用程序。</p> <p> 试想一下,一位销售员需要随时随地向她的顾客展示商品目录单。她可以使用任何她想要的电子设备,她首次浏览商品目录单时需要连线,之后便能够随时随地离线浏览。</p> <p> 应用程序缓存技术并不只是在离线状态下才有用武之地。我们可以将应用程序缓存作为一个超级缓存,用于本地存储资源,这样可以加速应用程序启动。服务器上更新了的资源可以在后台线程重新加载,加载完成之后便替换掉本地旧的资源并更新到正在运行的应用程序上。这种方式非常适用于桌面电脑的重量级 web 应用程序。</p> <p> <strong>清单文件</strong></p> <p> 要使用应用程序缓存,你不需要编写大量代码。你可以在一个简单的文本文件中定义需要离线使用的资源,这个文件被称作清单(manifest)文件。</p> <p> <strong>清单文件格式</strong></p> <p> 一个简单的清单文件具有如下格式:</p> <div class="cnblogs_code"> <pre>CACHE MANIFEST # Version <span style="color:#800080;">1.0</span> CACHE: /home/index /content/style.css /scripts/main.js NETWORK: /service/status FALLBACK: /logo.png /logo_offline.png</pre> </div> <p> 其中,你必须将 CACHE MANIFEST 头放在清单文件的第一行。</p> <p> 以数字符号#开头的行是注释行。这通常用于显式地修改清单文件以通知浏览器更新缓存。比如,在你更新了一张图片但没有修改图片的名称时,这种方式非常有用,因为浏览器并没有其他方式可以检测到服务器上的图片已被更新。</p> <p> 接下来,清单文件包含了以下三节:CACHE,NETWORK 以及 FALLBACK。在 CACHE 节你可以指定需要缓存的资源。需要一直从服务器下载的资源(即使在断线的情况下)则在 NETWORK 中指定。如果有大量的资源需要一直从服务器下载,你可以在 NETWORK 节中使用通配字符(即一个星号*)表示。在 FALLBACK 节中,你可以指定在离线状态下可以使用的备用资源。</p> <p> 清单文件的格式并不特别严格。以上介绍的几个部分可以是任意次序的,它们甚至可以在一个清单文件中多次使用。</p> <p> 在清单文件中你可以使用相对路径或者绝对路径来定位资源。如果你使用相对路径,则必须以清单文件的位置作为参考来定位资源。</p> <p> <strong>引用清单文件</strong></p> <p> 要将清单文件绑定到应用程序,需要将 manifest 属性添加到 html 标签上。每个引用清单文件的页面自身默认会被缓存。然而,还是建议在清单文件中显示列出你想要缓存的资源。如果某个页面没有在清单文件中被指定,同时也不曾被在线浏览过,则在离线状态下无法访问到这个页面,因为浏览器无法知道页面是否存在于本地缓存中。</p> <div class="cnblogs_code"> <pre><html manifest=<span style="color:#800000;">"</span><span style="color:#800000;">cache.manifest</span><span style="color:#800000;">"</span>)/></pre> </div> <p> <strong>检查缓存状态</strong></p> <p> 使用应用程序缓存 API,我们可以检查应用程序缓存的状态。使用 window.applicationCache 这个属性可以查询当前缓存的状态。该状态属性的值是一个介于 0 至 5 之间的数字,每个数字对应一个特定的缓存状态。</p> <table style="width:621px;height:262px;" border="1" cellspacing="0" cellpadding="0"> <tbody> <tr> <td valign="top" width="62"> <p> <strong>数字 </strong></p> </td> <td valign="top" width="110"> <p align="center"><strong>状态 </strong></p> </td> <td valign="top" width="543"> <p align="center"><strong>描述 </strong></p> </td> </tr> <tr> <td valign="top" width="62"> <p align="center"></p> </td> <td valign="top" width="110"> <p align="center">uncached</p> </td> <td valign="top" width="543"> <p> 页面不在应用程序缓存中。应用程序缓存第一次加载时页面也处于这种状态。</p> </td> </tr> <tr> <td valign="top" width="62"> <p align="center">1</p> </td> <td valign="top" width="110"> <p align="center">idle</p> </td> <td valign="top" width="543"> <p> 当应用程序缓存是最新的时,浏览器将状态设置为 idle。</p> </td> </tr> <tr> <td valign="top" width="62"> <p align="center">2</p> </td> <td valign="top" width="110"> <p align="center">checking</p> </td> <td valign="top" width="543"> <p> 当应用程序检查是否有更新了的清单文件时,浏览器将状态设置为 checking。</p> </td> </tr> <tr> <td valign="top" width="62"> <p align="center">3</p> </td> <td valign="top" width="110"> <p align="center">downloading</p> </td> <td valign="top" width="543"> <p> 当应用程序正在下载新缓存时,浏览器将状态设置为 downloading。</p> </td> </tr> <tr> <td valign="top" width="62"> <p align="center">4</p> </td> <td valign="top" width="110"> <p align="center">updateready</p> </td> <td valign="top" width="543"> <p> 当新缓存已下载完毕,可以替换旧有资源时,浏览器将状态设置为<span style="text-decoration:underline;">updateready</span>。</p> </td> </tr> <tr> <td valign="top" width="62"> <p align="center">5</p> </td> <td valign="top" width="110"> <p align="center">obsolete</p> </td> <td valign="top" width="543"> <p> 当找不到清单文件时,浏览器将状态设置为 obsolete。</p> </td> </tr> </tbody> </table> <p> 你可以使用 setInterval 函数来快速显示状态变化。</p> <div class="cnblogs_code"> <pre>setInterval (function () { console.log (window.applicationCache.status) }, <span style="color:#800080;">500</span>);</pre> </div> <p> <strong>事件处理</strong></p> <p> 除了检查缓存状态,我们还可以处理特定事件。</p> <table style="width:626px;height:361px;" border="1" cellspacing="0" cellpadding="0"> <tbody> <tr> <td valign="top" width="111"> <p align="center"><strong>事件 </strong></p> </td> <td valign="top" width="603"> <p align="center"><strong>描述 </strong></p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">checking</p> </td> <td valign="top" width="603"> <p> 当浏览器在检查是否有清单文件被更新过时这个事件被触发。这通常是第一个被触发的事件。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">downloading</p> </td> <td valign="top" width="603"> <p> 当浏览器开始下载新资源时该事件被触发。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">cached</p> </td> <td valign="top" width="603"> <p> 当所有资源下载完毕并提交到缓存时,该事件被触发。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">error</p> </td> <td valign="top" width="603"> <p> 当应用程序缓存机制出问题时该事件被触发。这可能是因为找不到清单文件,或者找不到清单文件中指定的某个资源。也可能是超过了浏览器离线缓存限额。通常来说,每当发生致命错误时该事件被触发。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">noupdate</p> </td> <td valign="top" width="603"> <p> 第一次下载清单文件时该事件被触发。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">progress</p> </td> <td valign="top" width="603"> <p> 每当应用程序缓存下载完一项资源时该事件被触发。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">updateready</p> </td> <td valign="top" width="603"> <p> 当新资源下载完毕并可以更新旧缓存时该事件被触发。</p> </td> </tr> <tr> <td valign="top" width="111"> <p align="center">obsolete</p> </td> <td valign="top" width="603"> <p> 当找不到清单文件时该事件被触发。</p> </td> </tr> </tbody> </table> <p> <strong>缓存替换</strong></p> <p> 当新缓存下载完成之后,它并不会立即替换掉旧的缓存,而是直到我们通知应用程序使用新缓存时它才进行替换。我们可以通过处理 updateready 事件,使用 swapCache 将旧缓存替换为新缓存。更新的资源要在刷新页面后才能见到。</p> <div class="cnblogs_code"> <pre>window.applicationCache.onupdateready = function (){ window.applicationCache.swapCache (); });</pre> </div> <p> <strong>怎样让用户知道你的应用程序可以离线运行呢?</strong></p> <p> 据我所知,没有哪种浏览器会通知用户当前应用程序是能离线运行的。不过,我们可以自己通知用户:通过监听应用程序缓存的特定事件,当应用程序已经可以离线工作时通知用户。我们甚至可以将应用程序缓存生命周期的每个阶段都通知用户。</p> <p> 应用程序缓存相关事件的处理是直截了当的。其中一个特别有用的事件是 progress 事件。每当一个资源下载完毕时这个事件被触发,其包含三个非常有用的属性,我们可以用这三个属性来显示下载进度:lengthComputable、 loaded 以及 total。首先,我们需检查 lengthComputable 属性来判断 loaded 和 total 属性是否可用,接着我们使用 loaded 和 total 属性计算出资源下载的百分比进度。</p> <div class="cnblogs_code"> <pre>window.applicationCache.onchecking = function (e) { updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">Checking for a new version of the application.</span><span style="color:#800000;">'</span>); }; window.applicationCache.ondownloading = function (e) { updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">Downloading a new offline version of the application</span><span style="color:#800000;">'</span>); }; window.applicationCache.oncached = function (e) { updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">The application is available offline.</span><span style="color:#800000;">'</span>); }; window.applicationCache.onerror = function (e) { updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">Something went wrong while updating the offline version of the application. It will not be available offline.</span><span style="color:#800000;">'</span>); }; window.applicationCache.onupdateready = function (e) { window.applicationCache.swapCache (); updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">The application was updated. Refresh for the changes to take place.</span><span style="color:#800000;">'</span>); }; window.applicationCache.onnoupdate = function (e) { updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">The application is also available offline.</span><span style="color:#800000;">'</span>); }; window.applicationCache.onobsolete = function (e) { updateCacheStatus (<span style="color:#800000;">'</span><span style="color:#800000;">The application cannot be updated, no manifest file was found.</span><span style="color:#800000;">'</span>); }; window.applicationCache.onprogress = function (e) { var message = <span style="color:#800000;">'</span><span style="color:#800000;">Downloading offline resources.. </span><span style="color:#800000;">'</span>; <span style="color:#0000ff;">if</span> (e.lengthComputable) { updateCacheStatus (message + Math.round (e.loaded / e.total * <span style="color:#800080;">100</span>) + <span style="color:#800000;">'</span><span style="color:#800000;">%</span><span style="color:#800000;">'</span>); } <span style="color:#0000ff;">else</span> { updateCacheStatus (message); }; };</pre> </div> <p> <strong>怎样检测浏览器是处于在线状态还是离线状态呢?</strong></p> <p> 你需要知道浏览器是在线的还是离线的有以下几个原因:也许是因为你想通知用户其正在离线工作,也许是因为你想在网络断开时禁用应用程序的某些功能,还或许是因为你想通过本地存储(local storage)技术以支持离线用户输入,然后在上线时将用户输入的文本同步到服务器。要实现这些需求,你可以通过自造基础架构,也可以通过使用开源项目或第三方项目。</p> <p> <strong>检测在线状态</strong></p> <p> 从原理上讲检测在线状态应该是非常简单的,比如在标准状况下,你使用 navigator 单件的 onLine 属性就可以检测出当前浏览器是否在线。</p> <div class="cnblogs_code"> <pre>console.log (navigator.onLine)</pre> </div> <p> 但事实上并非如此简单,因为各种浏览器对在线和离线的定义不尽相同。比如,旧版本的火狐浏览器只当用户显示地进行在线和离线状态切换时才更新 onLine 属性的值,而忽略了实际的网络状况。抛开实现上的不一致,检测网络连接状况本身就不是一件微不足道的事情。比如,假设你的电脑是连接上了的,但是你的路由器出问题了,这时应该显示什么状态呢?</p> <p> 一种流行的 hack 方法是检查每个 AJAX 请求的状态码,然后当状态码为不成功时则回退到离线机制。</p> <p> <strong>事件处理</strong></p> <p> 如果你想在浏览器改变连线状态时做一些事情,你可以通过处理 offline 和 online 事件来实现。但是请注意,和检查 onLine 属性一样,使用这两个事件也有类似问题。</p> <div class="cnblogs_code"> <pre>window.addEventListener (<span style="color:#800000;">'</span><span style="color:#800000;">offline</span><span style="color:#800000;">'</span>, function (e) { console.log (<span style="color:#800000;">'</span><span style="color:#800000;">offline</span><span style="color:#800000;">'</span>); }, <span style="color:#0000ff;">false</span>); window.addEventListener (<span style="color:#800000;">'</span><span style="color:#800000;">online</span><span style="color:#800000;">'</span>, function (e) { alert (<span style="color:#800000;">'</span><span style="color:#800000;">online</span><span style="color:#800000;">'</span>); }, <span style="color:#0000ff;">false</span>);</pre> </div> <p> <strong>浏览器支持</strong></p> <p> 除了 Internet Explorer,所有主流现代浏览器都支持离线 web 应用程序。Internet Explorer 10 也实现了相关规范,只是目前它还未发布。在 caniuse.com 上可以查看到每种浏览器及其版本对这一规范的支持情况。</p> <p> 对于大部分实现,各主流浏览器基本上是相一致的。但在实现存储限额以及对限额的管理(这两项没有定义在规范中)上,各浏览器差异比较大。在测试你的 web 应用程序时应该考虑这个问题,移动设备中的浏览器在缓存大小上可是斤斤计较的。</p> <p> <strong>使用 ASP.NET MVC 生成和提供清单文件</strong></p> <p> <strong>生成清单文件</strong></p> <p> 利用 ASP.NET MVC 创建和提供清单文件有几种方式。最简单地方式就是让 ASP.NET MVC 提供静态文本文件。然而,如果我们想要使用内建的 ASP.NET MVC 特性来解析路由,或者想编写代码来动态操控清单文件,我们最好使用自定义的 action result。</p> <p> 我把这个自定义的 action result 命名为 ManifestResult,它继承自 MVC 框架中的 FileResult 类。提供清单文件服务时应该使用'text/cache-manifest' MIME 类型,我把这个字符串传递给了父类的构造函数。</p> <div class="cnblogs_code"> <pre><span style="color:#0000ff;">public</span> <span style="color:#0000ff;">class</span> ManifestResult : FileResult { <span style="color:#0000ff;">public</span> ManifestResult (<span style="color:#0000ff;">string</span> version) : <span style="color:#0000ff;">base</span>(<span style="color:#800000;">"</span><span style="color:#800000;">text/cache-manifest</span><span style="color:#800000;">"</span>) { } }</pre> </div> <p> ManifestResult 类具有四个属性,其中三个属性对应清单文件的三个节,另外一个属性对应版本号。表示 CACHE 节和 NETWORK 节的两个属性仅仅是字符串枚举,而表示 FALLBACK 节的属性是字典类型的,用于将资源映射到 FALLBACK 指定的资源。</p> <div class="cnblogs_code"> <pre><span style="color:#0000ff;">public</span> <span style="color:#0000ff;">class</span> ManifestResult : FileResult { <span style="color:#0000ff;">public</span> ManifestResult (<span style="color:#0000ff;">string</span> version) : <span style="color:#0000ff;">base</span>(<span style="color:#800000;">"</span><span style="color:#800000;">text/cache-manifest</span><span style="color:#800000;">"</span>) { Version = version; CacheResources = <span style="color:#0000ff;">new</span> List<<span style="color:#0000ff;">string</span>>(); NetworkResources = <span style="color:#0000ff;">new</span> List<<span style="color:#0000ff;">string</span>>(); FallbackResources = <span style="color:#0000ff;">new</span> Dictionary<<span style="color:#0000ff;">string</span>, <span style="color:#0000ff;">string</span>>(); } <span style="color:#0000ff;">public</span> <span style="color:#0000ff;">string</span> Version { <span style="color:#0000ff;">get</span>; <span style="color:#0000ff;">set</span>; } <span style="color:#0000ff;">public</span> IEnumerable<<span style="color:#0000ff;">string</span>> CacheResources { <span style="color:#0000ff;">get</span>; <span style="color:#0000ff;">set</span>; } <span style="color:#0000ff;">public</span> IEnumerable<<span style="color:#0000ff;">string</span>> NetworkResources { <span style="color:#0000ff;">get</span>; <span style="color:#0000ff;">set</span>; } <span style="color:#0000ff;">public</span> Dictionary<<span style="color:#0000ff;">string</span>, <span style="color:#0000ff;">string</span>> FallbackResources { <span style="color:#0000ff;">get</span>; <span style="color:#0000ff;">set</span>; } }</pre> </div> <p> 要将格式化的清单文件输出到响应流,需要重写 WriteFile 方法。</p> <div class="cnblogs_code"> <pre><span style="color:#0000ff;">protected</span> <span style="color:#0000ff;">override</span> <span style="color:#0000ff;">void</span> WriteFile (HttpResponseBase response) { WriteManifestHeader (response); WriteCacheResources (response); WriteNetwork (response); WriteFallback (response); }<span style="color:#0000ff;">private</span> <span style="color:#0000ff;">void</span> WriteManifestHeader (HttpResponseBase response) { response.Output.WriteLine (<span style="color:#800000;">"</span><span style="color:#800000;">CACHE MANIFEST</span><span style="color:#800000;">"</span>); response.Output.WriteLine (<span style="color:#800000;">"</span><span style="color:#800000;">#V</span><span style="color:#800000;">"</span> + Version ?? <span style="color:#0000ff;">string</span>.Empty); }<span style="color:#0000ff;">private</span> <span style="color:#0000ff;">void</span> WriteCacheResources (HttpResponseBase response) { response.Output.WriteLine (<span style="color:#800000;">"</span><span style="color:#800000;">CACHE:</span><span style="color:#800000;">"</span>); <span style="color:#0000ff;">foreach</span> (var cacheResource <span style="color:#0000ff;">in</span> CacheResources) response.Output.WriteLine (cacheResource); }<span style="color:#0000ff;">private</span> <span style="color:#0000ff;">void</span> WriteNetwork (HttpResponseBase response) { response.Output.WriteLine (); response.Output.WriteLine (<span style="color:#800000;">"</span><span style="color:#800000;">NETWORK:</span><span style="color:#800000;">"</span>); <span style="color:#0000ff;">foreach</span> (var networkResource <span style="color:#0000ff;">in</span> NetworkResources) response.Output.WriteLine (networkResource); }<span style="color:#0000ff;">private</span> <span style="color:#0000ff;">void</span> WriteFallback (HttpResponseBase response) { response.Output.WriteLine (); response.Output.WriteLine (<span style="color:#800000;">"</span><span style="color:#800000;">FALLBACK:</span><span style="color:#800000;">"</span>); <span style="color:#0000ff;">foreach</span> (var fallbackResource <span style="color:#0000ff;">in</span> FallbackResources) response.Output.WriteLine (fallbackResource.Key + <span style="color:#800000;">"</span> <span style="color:#800000;">"</span> + fallbackResource.Value); }</pre> </div> <p> <strong>提供清单文件服务</strong></p> <p> 为了提供清单文件服务,我们要将相应的 action 添加到相应的控制器类中,以生成和返回清单文件的动作结果(action result)。在该 action 中,我们利用 MVC 的 UrlHelper 对象来正确地解析路由。</p> <div class="cnblogs_code"> <pre><span style="color:#0000ff;">public</span> ActionResult Manifest () { var manifestResult = <span style="color:#0000ff;">new</span> ManifestResult (<span style="color:#800000;">"</span><span style="color:#800000;">1.0</span><span style="color:#800000;">"</span>) { CacheResources = <span style="color:#0000ff;">new</span> List<<span style="color:#0000ff;">string</span>>() { Url.Action (<span style="color:#800000;">"</span><span style="color:#800000;">Index</span><span style="color:#800000;">"</span>, <span style="color:#800000;">"</span><span style="color:#800000;">Home</span><span style="color:#800000;">"</span>), <span style="color:#800000;">"</span><span style="color:#800000;">/content/style.css</span><span style="color:#800000;">"</span>, <span style="color:#800000;">"</span><span style="color:#800000;">/scripts/main.js</span><span style="color:#800000;">"</span> }, NetworkResources = <span style="color:#0000ff;">new</span> <span style="color:#0000ff;">string</span>[] { Url.Action (<span style="color:#800000;">"</span><span style="color:#800000;">Status</span><span style="color:#800000;">"</span>, <span style="color:#800000;">"</span><span style="color:#800000;">Service</span><span style="color:#800000;">"</span>)}, FallbackResources = { { <span style="color:#800000;">"</span><span style="color:#800000;">/logo.png</span><span style="color:#800000;">"</span>, <span style="color:#800000;">"</span><span style="color:#800000;">/logo_offline.png</span><span style="color:#800000;">"</span> } } }; <span style="color:#0000ff;">return</span> manifestResult; }</pre> </div> <p> <strong>为清单文件设置路由</strong></p> <p> 我们应该为清单文件设置特定的路由。大多数浏览器对清单文件的位置没有严格的规定,而最可靠的跨浏览器方式是将清单文件放在根目录,并将其命名为 cache.manifest。在应用程序启动时,下面的代码将这个新的“cache.manifest”路由添加到路由表中。</p> <div class="cnblogs_code"> <pre>routes.MapRoute (<span style="color:#800000;">"</span><span style="color:#800000;">cache.manifest</span><span style="color:#800000;">"</span>, <span style="color:#800000;">"</span><span style="color:#800000;">cache.manifest</span><span style="color:#800000;">"</span>, <span style="color:#0000ff;">new</span> { controller = <span style="color:#800000;">"</span><span style="color:#800000;">Resources</span><span style="color:#800000;">"</span>, action = <span style="color:#800000;">"</span><span style="color:#800000;">Manifest</span><span style="color:#800000;">"</span> });</pre> </div> <p> <strong>结论</strong></p> <p> 离线 web 应用程序是正处于不断发展中的 HTML 规范的重要内容之一。根据实际用例,你可能仅仅是利用这个特性来缓存资源或者让 web 应用程序完全离线运行。这个特性的中心就是清单文件。清单文件的格式和要求一点也不复杂,使用 ASP.NET MVC 或其他服务端技术可以直截了当地生成和提供清单文件服务。编写好清单文件之后,使用应用程序缓存 API 就可以很容易地进行缓存更新。你也可以使用这组 API 来查询缓存状态和处理应用程序缓存的特定事件。想知道浏览器处于在线状态还是离线状态,你可以通过检查 navigator 对象的 onLine 属性,或者处理特定的在线和离线事件来判断。</p> <p> <strong>关于作者</strong></p> <p> <strong>Jef Claes</strong> 热衷于创建新玩意。在日常工作中,他是 <a href="/misc/goto?guid=4958339836726130135" target="_blank">Euricom</a> 的一名软件工程师,负责构建基于微软技术的企业应用软件。在业余时间,他喜欢将随机的想法或创新的方案付诸实践。除了这些,Jef 一般会在<a href="/misc/goto?guid=4958339837520407385" target="_blank">他的博客</a>上写他感兴趣的事情。Jef 不羞于演讲,他在一些本地会议上谈论和 web 相关的技术和问题。你可以通过 <a href="/misc/goto?guid=4958339838317182993" target="_blank">推ter</a> 和他联系。</p> <p> <strong>查看英文原文:</strong><a href="/misc/goto?guid=4958339839105940420">HTML5 offline web applications using ASP.NET MVC</a></p>