美文网首页前端攻城狮让前端飞我爱编程
应用于 Hybrid App 的 Vue 多页面构建

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

作者: 呆恋小喵 | 来源:发表于2018-03-21 14:25 被阅读42次

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

    为何选择混合开发?

    • 高效率界面开发:HTML + CSS + JavaScript 被证实具备极高的界面开发效率。

    • 跨平台:较统一的浏览器内核标准,使 H5 页面在 IOS、Android 共享同套代码。使用 Native 开发一功能需 IOS、Android 研发各一枚,而使用 H5 一枚前端工程师足矣。但混合 App 并非 Native 越少越佳,性能要求较高的仍需劳 Native 大驾...分工需明确,不可厚此薄彼。

    • 热更新:不依赖于发布渠道自主更新应用。Native 修复线上 Bug 需发布新版本,用户未升级 App 该 Bug 将一直呈现。而修复 H5 只需将 Fixbug 的代码推至服务器,任一版本 App 便可同步更新对应功能无需升级。

    为何离线缓存 Web 资源?

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

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

    对我司前端团队的意义

    • 技术栈由 Jinja + jQuery + Require + Gulp 迁移至 Vue + Webpack + Gulp + Sass,拥抱 Vue!
    • 实现前后端分离:原 Jinja 为 Python 模板引擎,前端代码的运作依赖于服务端,服务端异常等待环境维修严重影响前端工作进度。分离后,服务器挂了我们愉快的开启 Mock Server 继续搬砖便是。

    • App 优先加载本地预置 Web 资源,可提升 H5 页面加载速度。

    弊端

    • 技术重构本身具备风险性。

    • 增加团队学习成本。

    • 前端框架通过 JS 渲染 HTML 对 SEO 不友好。但你可选择使用 Vue 2.2 的服务端渲染(SSR)。增添 Node 层除实现 SSR,能做的事还很多...


    进入正题~

    混合开发框架运作机制

    将 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/post/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 框架设计围绕:

    • 减少无用资源及冗余资源

    • 减小依赖模块对 Hash 的影响

    • 开发环境模式尽量简易

    减少无用资源及冗余资源

    机智的你发现使用 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,如何实现?

    • 生成 vendor.js 时过滤 Swiper 并将其单独打包,node_modules 仍含 Swiper。

    • 将 Swiper 从 node_modules 移动至其它路径,引用时使用迁移后的路径。

    引入 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 多页面拆分如何做。相关文章很多在此推荐一篇,点我~

    核心思想:

    • 单页:多 View 对应 单 index.html + 单 entry.js

    • 多页:多 View 对应 多 index.html + 多 entry.js

    假定含 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 参数。

    Cookie 同步问题

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

    • 区分 file:/// 来源,判定来源安全则载入 Cookie,但 H5 依然无法将 Cookie 带到请求中。

    • 伪造类似 http 请求形成假域。

    • Native 维护 Cookie 并提供获取接口,H5 拼接 Cookie 自行写入 Request Header。

    • Native 代发请求回传返回值,但无法实现大数据量 POST 请求(例 POST File)。

    通常在页面 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

    相关文章

      网友评论

        本文标题:应用于 Hybrid App 的 Vue 多页面构建

        本文链接:https://www.haomeiwen.com/subject/bfdgqftx.html