V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
alex1504
V2EX  ›  程序员

移动端 H5 多页开发拍门砖经验

  •  
  •   alex1504 · 2018-10-18 16:18:03 +08:00 · 1023 次点击
    这是一个创建于 2264 天前的主题,其中的信息可能已经有所发展或是发生改变。

    poster

    两年前刚接触移动端开发,刚开始比较疑惑,每次遇到问题都是到社区里提问或者吸取前辈的经验分享,感谢热衷于分享的开发者为前端社区带来欣欣向上的生命力。本文结合先前写的文章和开发经验分享给大家,希望也能帮助刚步入移动端开发的新人解惑。以下会以其中一个以公积金页面开发项目作为例子,介绍移动端的一些常见问题和使用 Vuejs 作为 lib 进行多页开发的经验。

    移动端自适应布局

    在项目中移动端最常用的自适应布局方案就是 flexbox 结合 rem。规范的分栏式使用 flexbox,其他大部分不规则视图使用 rem,对于 rem 最常用的方案就是淘宝开源的可伸缩布局方案

    根据设备设备像素比设置 scale 的值( scale = 1 / deviceRatio ),这样可以保持视口 device-width 始终等于设备物理像素,接着根据屏幕大小动态计算根字体大小,具体是将屏幕划分为 100 等分,每份为 a,1rem 就等于 10a。

    标注

    通常我们会拿到 750 宽的设计稿,这是基于 iPhone6 的物理分辨率。有的设计师也许会偷懒,设计图上面没有任何的标注,如果我们边开发边量尺寸,无疑效率是比较低的。要么让设计师标注上,要么自食其力。

    如果设计师实在没有时间,推荐使用markman进行标注,免费版阉割了一些功能(比如无法保存本地)不过基本满足了我们的需求了。

    后来我发现比 markman 更好的标注工具PxCook,该工具可以显示PSD 设计图中的图层的样式代码,对于前端来说简直方便极了。

    标注完成后开始写我们的样式,使用了淘宝的lib-flexible库之后,我们的根字体基准值就为 750/100*10 = 75px。此时我们从图中若某个标注为 100px,那么 css 中就应该设置为 100/75 = 1.333333rem。所以为了提高开发效率,可以使用 px 转化为 rem 的插件。下面是 sublimeText 和 Vscode 的转换插件:

    px 转 rem 插件

    使用 rem 的几点总结

    • 在所有的单位中,font-size 推荐使用 px,然后结合媒体查询进行重要节点的控制,这样可以满足突出或者弱化某些字体的需求,而非整体调整。
    • 众向的单位可以全部使用 px,横向的使用 rem,因为移动设备宽度有限,而高度可以无限向下滑动。但这也有特例,比如对于一些活动注册页面,需要在一屏幕内完全显示,没有下拉,这时候所有众向或者横向都应该使用 rem 作为单位。如图:

    shili

    左图的表单高度单位由于下边空距较大,使用 px 在不同屏幕显示更加;而右边的活动注册页由于不能出现滚动条,所有的众向高度、margin、padding 都应该使用 rem。

    • border、box-shadow、border-radius等一些效果应该使用 px 作为单位。

    手机状态栏和浏览器导航栏的影响

    之前发布的文章中,有个 SF 的前端小伙伴提出的问题: 文中作者有重点强调布局全部铺满,和下方与很多空隙的处理方案是不同的,在工作中我遇到这种情况,设计师的设计稿宽度为 750×1334,但实际的展示高度并没有那么多,因为上方有导航栏还包括手机自己的状态栏展示,所以整体高度就达不到 750,但是设计师设计稿是严格按照 750 进行设计的,这种情况下使用 rem,严格按照设计师尺寸进行还原就会出现屏幕出现滚动条情况,请问针对这种情况您是怎么处理的?是从设计稿上规范,还是从开发上有相应的措施

    依旧以我的分享界面为例: 展示高度不同通常发生在微信及浏览器端,因为前者没有地址栏和工具栏,这样显示高度通常会和设计师设计的视图吻合。那如果按照纯 padding,margin 即使全部使用 rem,在浏览器端依旧会超出一屏高度,对于分享页面这种不是我们想要看到的。这时候就要做出取舍,我对主体区域采用绝对定位,这样上面间隙虽然小,不过仍能保持在一个屏幕高度显示。若采用 margin padding 在设置,必然已出现滚动条。当然这样的前提是依赖设计图的,通常设计师会为了空间感有保留一定的间隙,也不会将主要对象高度设的过高,否则太撑满也不好看,开发上如果设计图宽高没有在一定界限之内,超出也无法避免,不过我们这种分享界面通常是通过微信分享好友,通过浏览器打开的视图效果出现滚动条其实也不怎么影响不是么? 下面附上微信端和浏览器端的效果图:

    微信端:微信端

    浏览器端: 浏览器端

    Vuejs 作为 lib 开发移动端页面

    为何不使用 SPA 模式

    一般移动端使用 vue 是为了数据交互频繁而且快速开发的页面,为什么不使用单页 SPA 开发模式,原因大概几点。

    • 为了快速开发,快速上线
    • 项目其他成员不熟悉 SPA,不熟悉 webpack
    • 参与项目时项目已使用多页开发,短时间无法重构

    抛开使用单页的架构,开发多页应用时,一个页面交互逻辑与一个 Vue 实例对应。

    基于接口返回数据的属性注入

    "基于接口返回数据的属性注入"是个人创建的话术,抛开此概念,先说一下表单数据的绑定方式。

    表单的数据绑定

    一个重要的点是有几份表单就分开几个表单对象进行数据绑定

    以上图公积金查询为例,由于不同城市会有不同的查询要素,可能登陆方式只有一种,也可能有几种。比如上图有三种登陆方式,在使用 vue 布局时,有两种方案。

    • 1、 只建立一个表单用于数据绑定,点击按钮触发判断
    • 2、有几种登陆方式建立几个表单,用一个字段标识当前显示的表单

    由于使用第三方的接口,一开始也没有先进行接口返回数据结构的查看,采用了第一种错误的方式,错误一是每种登陆方式下面的登陆要素的数量也不同,错误二是数据绑定在同一个表单 data 下,当用户在用户名登陆方式输入用户名密码后,切换到客户号登陆方式,就会出现数据错乱的情况。

    解决完布局问题后,我们需要根据设计图定义一些状态,比如当前登陆方式的切换、同意授权状态的切换、按钮是否可以点击的状态、是否处于请求中的状态。当然还有一些 app 穿过来的数据,这里就忽略了。

     data: {
         tags: {
             arr: [''],
             activeIndex: 0
         },
         isAgreeProxy: true,
         isLoading: false
     }
    

    接着审查一下接口返回的数据,推荐使用 chrome 插件 postman,比如呼和浩特的登陆要素如下:

    {
        "code": 2005,
        "data": [
            {
                "name": "login_type",
                "label": "身份证号",
                "fields": [
                    {
                        "name": "user_name",
                        "label": "身份证号",
                        "type": "text"
                    },
                    {
                        "name": "user_pass",
                        "label": "密码",
                        "type": "password"
                    }
                ],
                "value": "1"
            },
            {
                "name": " login_type",
                "label": "公积金账号",
                "fields": [
                    {
                        "name": "user_name",
                        "label": "公积金账号",
                        "type": "text"
                    },
                    {
                        "name": "user_pass",
                        "label": "密码",
                        "type": "password"
                    }
                ],
                "value": "0"
            }
        ],
        "message": "登录要素请求成功"
    }
    

    可以看到呼和浩特有两种授权登陆方式,我们在 data 中定义了一个 loginWays,初始为空数组,接着 methods 中定义一个请求接口的函数,里面就是基于返回数据的基础上为上面 fields 对象注入一个 input 字段用于绑定,这就是所谓的基于接口返回数据的属性注入。

    methods: {
        queryloginWays: function(channel_type, channel_code) {
            var params = new URLSearchParams();
            params.append('channel_type', channel_type);
            params.append('channel_code', channel_code);
            axios.post(this.loginParamsProxy, params)
                .then(function(res) {
                    console.log(res);
                    var code = res.code || res.data.code;
                    var msg = res.message || res.data.message;
                    var loginWays = res.data.data ? res.data.data : res.data;
                    // 查询失败
                    if (code != 2005) {
                        alert(msg);
                        return;
                    }
                    // 添加 input 字段用于 v-model 绑定
                    loginWays.forEach(function(loginWay) {
                        loginWay.fields.forEach(function(field) {
                            field.input = '';
                        })
                    })
                    this.loginWays = loginWays;
                    this.tags.arr = loginWays.map(function(loginWay) {
                        return loginWay.label;
                    })
                }.bind(this))
        }
    }
    

    即使返回的数据有我们不需要的数据也没有关系,这样保证我们不会遗失进行下一步登陆所需要的数据。

    这样多个表单绑定数据问题解决了,那么怎么进行页面间数据传递?如果是 app 传过来,那么通常使用 URL 拼接的方式,使用 window.location.search 获得 queryString 后再进行截取;如果通过页面套入 javaWeb 中,那么直接使用"${字段名}"就能获取,注意要 js 中获取 java 字段需要加双引号。

    computed: {
            // 真实姓名
            realName: function() {
                return this.getQueryVariable('name') || ''
            },
            // 身份证
            identity: function() {
                return parseInt(this.getQueryVariable('identity')) || ''
            },
            /*If javaWeb
            realName: function() {
                return this.getQueryVariable('name') || ''
            },
            identity: function() {
                return parseInt(this.getQueryVariable('identity')) || ''
            }*/
        },
        methods: {
            getQueryVariable: function(variable) {
                var query = window.location.search.substring(1);
                var vars = query.split('&');
                for (var i = 0; i < vars.length; i++) {
                    var pair = vars[i].split('=');
                    if (decodeURIComponent(pair[0]) == variable) {
                        return decodeURIComponent(pair[1]);
                    }
                }
                console.log('Query variable %s not found', variable);
            }
        }
    

    关于前端跨域调试

    在进行接口请求时,我们的页面通常是在 sublime 的本地服务器或者 vscode 本地服务器预览,所以请求接口会遇到跨域的问题,如果使用 Gulp 进行打包,可以使用插件 http-proxy-middleware,或者使用 nginx。

    使用 Gulp

    在项目构建的时候通常我们源代码会放在 src 文件夹下,然后使用 gulp 进行代码的压缩、合并、图片的优化(根据需要)等等,我们会使用 gulp。

    解决跨域的问题可以用gulp-connect结合http-proxy-middleware,此时我们在 gulp-connect 中的本地服务器进行预览调试。

    gulpfile.js 如下: 开发过程使用gulp server:dev命令,监听文件改动并使用 livereload 刷新,并且代理 src 目录;使用gulp命令进行打包;使用gulp server:dist代理 dist 生产目录。

    var gulp = require('gulp');
    var concat = require('gulp-concat');
    var uglify = require('gulp-uglify');
    var autoprefixer = require('gulp-autoprefixer');
    var useref = require('gulp-useref');
    var connect = require('gulp-connect');
    var proxyMiddleware = require('http-proxy-middleware');
    
    // 开发跨域代理  将 localhost:8088/api 映射到 https://api.xxxxx.com/
    gulp.task('server:dev', ['listen'], function() {
        var middleware = proxyMiddleware(['/api'], {
            target: 'https://api.xxxxx.com/',
            changeOrigin: true,
            pathRewrite: {
                '^/api': '/'
            }
        });
        connect.server({
            root: env == 'dev' ? './src' : './dist',
            port: 8088,
            livereload: true,
            middleware: function(connect, opt) {
                return [middleware]
            }
    
        });
    });
    
    // 打包后跨域代理
    gulp.task('server:dist', ['listen'], function() {
        var middleware = proxyMiddleware(['/api'], {
            target: 'https://api.xxxxx.com/',
            changeOrigin: true,
            pathRewrite: {
                '^/api': '/'
            }
        });
        connect.server({
            root: './dist',
            port: 8088,
            livereload: true,
            middleware: function(connect, opt) {
                return [middleware]
            }
    
        });
    });
    
    gulp.task('html', function() {
        gulp.src('src/*.html')
            .pipe(useref())
            .pipe(gulp.dest('dist'));
    });
    gulp.task('css', function() {
        gulp.src('src/css/main.css')
            .pipe(concat('main.css'))
            .pipe(autoprefixer({
                browsers: ['last 2 versions'],
                cascade: false
            }))
            .pipe(gulp.dest('dist/css/'));
    
        gulp.src('src/css/share.css')
            .pipe(concat('share.css'))
            .pipe(autoprefixer({
                browsers: ['last 2 versions'],
                cascade: false
            }))
            .pipe(gulp.dest('dist/css/'));
    
        gulp.src('src/vendors/css/*.css')
            .pipe(concat('vendors.min.css'))
            .pipe(autoprefixer({
                browsers: ['last 2 versions'],
                cascade: false
            }))
            .pipe(gulp.dest('dist/vendors/css'));
        return gulp
    });
    gulp.task('js', function() {
        return gulp.src('src/vendors/js/*.js')
            .pipe(concat('vendors.min.js'))
            .pipe(uglify())
            .pipe(gulp.dest('dist/vendors/js'));
    });
    gulp.task('img', function() {
        gulp.src('src/imgs/*')
            .pipe(gulp.dest('dist/imgs'));
    });
    gulp.task('listen', function() {
        gulp.watch('./src/css/*.css', function() {
            gulp.src(['./src/css/*.css'])
                .pipe(connect.reload());
        });
        gulp.watch('./src/js/*.js', function() {
            gulp.src(['./src/js/*.js'])
                .pipe(connect.reload());
        });
        gulp.watch('./src/*.html', function() {
            gulp.src(['./src/*.html'])
                .pipe(connect.reload());
        });
    });
    gulp.task('default', ['html', 'css', 'js', 'img']);
    

    使用 nginx

    在 nginx 配置使用 proxy_pass,需要注意一点: 如果在 proxy_pass 后面的 url 加 /,表示绝对根路径;如果没有 /,表示相对路径,把匹配的路径部分也给代理走。

    server {
    	listen 80;
    	server_name  localhost;
    	root   [Your project root];
    	index  index.html index.htm default.html default.htm;
    	
        location ^~/api { 
          proxy_pass https://api.xxxxx.com/;
        } 
    }
    

    公众号网页的调试

    如果你开发的 H5 基于微信 jsSDK,你一定接触过微信授权域名,微信会将授权数据传给一个回调页面,而回调页面必须在你配置的域名下(含子域名)。比如我们获取用户的 openid 操作。而微信配置域名回去该域名根目录下检测一个xxx_verify_xxx.txt文件,确保该域名是属于你的。

    所以要想在微信开发调试工具中获取 openid,我们需要使用一种叫做内网穿透的工具。下面是自己比较常用的两个工具:

    ngrok

    ngrok 执行命令

    ngrok -config ngrok.cfg start web
    

    在 ngrok.exe 目录需要一个配置文件ngrok.cfg 以下是配置示例:

    server_addr: "tunnel.cn:4443"
    trust_host_root_certs: false
    tunnels:
      web:
       subdomain: "xxx" 
       proto:
        http: 8086
        https: 8086
    

    启动后 xxx.tunnel.cn:4443 会指向你本地的 8086 端口,将xxx_verify_xxx.txt文件放到 8086 端口根目录即可配置授权域名成功。

    花生壳

    花生壳免费版对于个人开通仅需 6 元,然后每月会提供给你 1G 的流量,免费版不支持 80 端口,最多支持两个域名,需要下载桌面客户端。

    添加域名映射很简单,免费版无法配置自定义域名,由花生壳自动生成。 花生壳 配置成功后启动客户端可查看当前的状态

    感谢阅读,欢迎任何形式的技术提问和交流!

    4 条回复    2018-10-25 12:42:20 +08:00
    whypool
        1
    whypool  
       2018-10-18 16:42:18 +08:00
    用 vw 布局比 rem 更加灵活
    nezumi85757
        2
    nezumi85757  
       2018-10-18 16:59:21 +08:00
    现在绝大多数时候适配跟布局已经不需要自己从头写了,成熟的库基本上都提供解决方案了,大多时候时候去写也只是为了迎合设计跟交互
    alex1504
        3
    alex1504  
    OP
       2018-10-25 12:41:41 +08:00
    @nezumi85757 的确是这样的
    alex1504
        4
    alex1504  
    OP
       2018-10-25 12:42:20 +08:00
    @whypool 嗯 现在都是混合使用多种布局,rem 只是一种方案
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2611 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 10:57 · PVG 18:57 · LAX 02:57 · JFK 05:57
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.