呆恋小喵

应用于 Hybrid App 的 Vue 多页面构建

本文介绍一款基于 Vue 的使 App 支持离线缓存 Web 资源的混合开发框架。本人小白一枚,请将它视作一份我的学习总结,欢迎大神们赐教。本文多阐述思路,实现细节请阅读源码。

源码

为何选择混合开发?

为何离线缓存 Web 资源?

相比于从远程服务器请求加载 Web 资源,App 优先加载本地预置资源,可提升页面响应速度,节省用户流量。

问题来了…本地预置的 Web 资源也随 App 安装包一起成为泼出去的水,修复 H5 线上 Bug 也需发版了?丢西瓜捡芝麻的事定不可做!请注意“优先加载本地预置资源”,但检测到更新时加载远程最新资源,如何检测更新我稍后阐明。

对我司前端团队的意义

弊端


进入正题~

混合开发框架运作机制

将 Web 资源文件打包至 dist/(含 routes.json 及 N 多 .html)并压缩为 dist.zip,图片资源单独打包至 assets/,一同上传至 CDN。

App 内预置 dist/ 下全部资源(发版时仅下载 dist.zip,安装 App 时解压),在拦截并解析 URL 后,通过 routes.json 查找并加载本地 .html 页面。

routes.json 如下:

{
    "items": [
        {
            "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Demo-13700fc663.html",
            "uri": "https://backend.igengmei.com/demo[/]?.*"
        },
        {
            "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Album-a757d93443.html",
            "uri": "https://backend.igengmei.com/album[/]?.*"
        },
        {
            "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/ArticleDetail-d5c43ffc46.html",
            "uri": "https://backend.igengmei.com/article/detail[/]?.*"
        }
    ],
    "deploy_time": "Fri Mar 16 2018 15:27:57 GMT+0800 (CST)"
}

欠你一个回答~

请注意“优先加载本地预置资源”,但检测到更新时加载远程最新资源,如何检测更新我稍后阐明。

检测 .html 文件更新的桥梁便是 routes.json。每启动 App 从 CDN 静默更新 routes.json 一次(CDN 缓存会导致 routes.json 无法及时更新,下载路由表请添加时间戳参数强制更新),任一资源更新均同步至 routes.json 并上传 CDN。

标记更新的方式则是为 .html 打 Hash(MD5)戳,于 App 而言不同 Hash 后缀的 .html 为不同文件。App 根据路由表 remote_file 查寻本地 .html,若该 .html 不存在则直接加载远程资源同时静默下载更新。

注:由于 js、css 脚本均被内联至对应 .html,App 仅需监听 .html 文件的变化。其实我们可以提取公用脚本并为之打 Hash 戳,将该资源的变化记录至一张表供 App 监听。常年不更新的公用脚本,缓存在 App 内不随 .html 一同加载也可提升页面响应速度。

综上,Web 资源虽被预置于 App,但其 Fixbug 级别的更新不必走发版这条路。

为何图片资源单独打包至 assets/,先欠着~


Web 框架设计

Web 框架设计围绕:

减少无用资源及冗余资源

机智的你发现使用 Vue 脚手架 build 后产生单 .html、单 .js、单 .css(所有页面资源打包在一坨啦),而我所举例的却是多 .html。如何实现 Vue 多页面拆分我会细讲,先讨论拆分多页面的意义吧:“快” + “节约”!

假定我站含页面 A、B、C,用户仅访问 A 但单页应用却将 A、B、C 所依赖的全部资源加载。B、C 于用户而言是无用的,我们偷偷吃用户流量下载无用资源很不厚道。

拆分资源可减小 .html 体积自然提升页面加载速度,且 App 优先访问本地 .html 免去远程请求更是快上加快。

无用资源需丢弃,公共资源也需提取。假定页面 A、B 均引用资源 C,资源 C 便可单独提取。可使用 CommonsChunkPlugin 达成对第三方库,公用组件的抽离。一提取项目所应用 node_module 脚本示例:

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: function (module) {
        return (
            module.resource &&
            /\.js$/.test(module.resource) &&
            module.resource.indexOf(
                path.join(__dirname, '../node_modules')
            ) === 0
        )
    }
})

项目中所应用到的 node_module 将统一打包至 vendor.js。公用脚本也需预置,也需检测更新,若认为监听众多资源较麻烦将脚本内联至 .html 也可,但我不提倡这样做(失去了去冗余的意义)。预置的公用脚本拷贝到哪里?拷贝至手机内存空间不够怎么破,拷贝至存储卡被用户误删怎么破,客户端同学为此很纠结…emmm

vendor.js 含所有页面依赖到的 node_module。假定页面 A 使用了 Swiper 而其它页面未引用它,vendor.js 中的 Swiper 相关代码便应仅打包至页面 A,如何实现?

引入 Sass 也可一定程度的去除无用代码:

使用 @mixin、% 定义的通用样式未被继承不会被解析产生相应的 css。

想了解更多的同学请研读 Sass: Syntactically Awesome Style Sheets

减小依赖模块对 Hash 的影响

由于 App 需监听众 .html 变化并实时更新资源,应格外注意 Hash 值的稳定性,为此应坚守代码模块化原则。假定全局引入 app.js、app.css,则不允许添加非全局性质的代码至上述两个文件。

假如模块 A 被注入 app.js,它的修改将影响所有 .html 的 Hash 值,未调用模块 A 的页面实际上未做修改却被动更新 Hash。App 根据 Hash 的变化判断资源更新则认为所有 .html 更新了,进而重新下载所有 Web 资源。

总之 A 未调用 B,B 的修改不要影响 A 的 Hash,模块如何拆分请自行依照此原则把握。

接下来讨论 manifest 的注入时机。manifest 包含模块处理逻辑,在 Webpack 编译及映射应用代码时,模块信息被记录至 manifest,runtime 则根据 manifest 加载模块。

new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest',
    minChunks: Infinity
})

任一模块更新均会引发它的细微变化(但可通过 minChunks 控制 manifest 影响范围),且所有页面加载依赖 manifest。可怕的现象发生了:manifest 更新所有 .html 的 Hash 更新 -> 所有 .html 被重新下载。我们可先为 .html 打 Hash 再将 manifest 内联,因为未更新模块调用旧 manifest 不会受影响。

开发环境模式尽量简易

一个项目参与者众多,开发环境模式复杂将提高学习成本与风险。在简化开发模式上我做了哪些:

开发环境单入口、生产环境多入口

先讲下 Vue 多页面拆分如何做。相关文章很多在此推荐一篇,点我~

核心思想:

假定含 100 个 View 则需对应创建 100 个 index.html、100 个 entry.js!但它们几乎一模一样,重复创建十分浪费,开发成本也被增加。

index.html 可被多个 View 复用,entry.js 不可。共享 entry 需在其中 import 全部 View,则 build 生成的每一页面含每一 View 的全部资源,即 100 个内容一模一样的 .html。

我们可形式上单入口,实际上多入口,如何做?定义一含占位符的 entry 模板,build 时将占位符替换为对应 View 的引入,如此 import 资源将按需拆分。

<%=Page%> 占位符的 entry.js:

import Vue from 'vue'
import Page from '<%=Page%>'
/* eslint-disable no-new */
new Vue({
    el: '#app',
    template: '<Page />',
    components: {
        Page
    }
})

生成多 entry 的 gulp task:

gulp.task('entries', () => {
    var flag = true
    for (let key in routes) {
        // 检查 entry 是否已存在
        gulp.src(`./entry/entries/${routes[key].view}.js`)
            .on('data', () => {
                // 已存在 entry 不重复构造
                flag = false
            })
            .on('end', () => {
                if (flag) {
                    console.log('new entry: ', `/entries/${routes[key].view}.js`)
                    // 构造新 entry
                    gulp.src('./entry/entry.js')
                        .pipe(replace({
                            patterns: [
                                {
                                    match: /<%=Page%>/g,
                                    replacement: `../../src/views/${routes[key].path}${routes[key].view}`
                                }
                            ]
                        }))
                        .pipe(rename(`entries/${routes[key].view}.js`))
                        .pipe(gulp.dest('./entry/'))
                }
                flag = true
            })
    }
})

仅生产环境执行 gulp entries 构造多入口,开发环境单入口即可,免去研发同学构造 entry 的成本。

function entries () {
    var entries = {}
    for (let key in routes) {
        entries[routes[key].view] = process.env.NODE_ENV === 'production'
            ? `./entry/entries/${routes[key].view}.js`
            : './entry/dev.js'
    }
    return entries
}
开发环境引用本地图片、生产环境引用 CDN 图片

由于 App 仅监听 .html 变化,图片资源需从远程引用。研发自行上传图片至 CDN 似乎并不复杂,但我司 CDN 上传权限泛滥是不被允许的。

图片上传交专人负责,方法原始沟通成本高,等待他人上传也影响自身开发效率。

开发阶段将图片上传测试 CDN,生产阶段再统一拷贝至线上环境?转化成本不小,遗漏上传还会引发线上事故。

开发阶段书写相对路径引用本地资源,免去研发自行上传图片的烦恼且模式与传统 Web 开发保持一致。生产环境直接转化图片链接为 CDN 路径。并将所有 image 单独打包至 assets/ 一同上传 CDN,此时 .html 对 CDN 图片的引用生效了。

{
    test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
    loader: 'url-loader',
    options: {
        limit: 1,
        name: 'assets/imgs/[name]-[hash:10].[ext]'
    }
}

为防止 CDN 缓存导致图片无法及时更新,build 后图片名称添加 Hash 后缀。在此我设置 Base64 转化 limit 为 1,防止 HTML 穿插过多 Base64 格式图片阻塞加载。

生产环境图片链接转化 CDN 路径代码如下:

const settings = require('../settings')
module.exports = {
    dev: {
        // code...
    },
    build: {
        assetsRoot: path.resolve(__dirname, '../../dist'),
        assetsSubDirectory: 'static',
        assetsPublicPath: `${settings.cdn}/`,
        // code...
    }
}

工具一览

html-webpack-inline-source-plugingulp-inline-source:JS、CSS 资源内联工具。

commons-chunk-plugin:公共模块拆分工具。

gulp-revhashed-module-ids-plugin:MD5 签名生成工具。

gulp-zip:压缩工具。

其它常用 Gulp 工具:gulp-renamegulp-replace-taskdel


踩坑札记

路由解析问题

假定路由配置为:

{
    "/demo": {
        "view": "Demo",
        "path": "demo/",
        "query": [
            "topic_id",
            "service_id"
        ]
    },
    "/album": {
        "view": "Album",
        "path": "demo/"
    }
}

生成 routes.json 为:

{
    "items": [
        {
            "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Demo-2392a800be.html",
            "uri": "https://backend.igengmei.com/demo[/]?.*"
        },
        {
            "remote_file": "http://p2znmi5xx.bkt.clouddn.com/dist/demo/Album-1564b12a1c.html",
            "uri": "https://backend.igengmei.com/album[/]?.*"
        }
    ],
    "deploy_time": "Mon Mar 19 2018 19:41:22 GMT+0800 (CST)"
}

开发环境通过 localhost:8080/demo?topic_id=&service_id= 访问 Demo 页面,形如 vue-router 为我们构建的路由。而生产环境访问路径为 file:////dist/demo/Demo-2392a800be.html?uri=https%3A%2F%2Fbackend.igengmei.com%2Fdemo%3Ftopic_id%3D%26service_id%3D,获取参数需解析 uri。

因两大环境参数解析方式不同,需自行封装 $router,例如 this.$router.query 的定义:

const App = {
    $router: {
        query: (key) => {
            var search = window.location.search
            var value = ''
            var tmp = []
            if (search) {
                // 生产环境解析 uri
                tmp = (process.env.NODE_ENV === 'production')
                    ? decodeURIComponent(search.split('uri=')[1]).split('?')[1].split('&')
                    : search.slice(1).split('&')
            }
            for (let i in tmp) {
                if (key === tmp[i].split('=')[0]) {
                    value = tmp[i].split('=')[1]
                    break
                }
            }
            return value
        }
    }
}

可将 $router 绑定至 Vue.prototype:

App.install = (Vue, options) => {
    Vue.prototype.$router = App.$router
}
export default App

在 entry.js 执行:

Vue.use(App)

此时任一 .vue 可直接调用 this.$router,无需 import。调用频率较高的 method 均可 bind 至 Vue.prototype,例如对请求的封装 this.$request。

缺陷:自制 router 仅支持 query 参数不支持 param 参数。

App 加载本地预置资源在 file:/// 域,无法直接将 Cookie 载入 Webview,对 file:/// 开放 Cookie 将导致安全问题。几种解决思路:

通常在页面 render 时服务器会将 CSRFToken 写入 Cookie,Request 时再将 CSRFToken 传回服务器防止跨域攻击。但加载本地 HTML 缺少上述步骤,需额外注意 CSRFToken 的获取问题。

未完待续~


作者:呆恋小喵

我的后花园:https://sunmengyuan.github.io/garden/

我的 github:https://github.com/sunmengyuan

原文链接:https://sunmengyuan.github.io/garden/2018/03/05/ballade.html