写Bug

写Bug 查看完整档案

广州编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

写Bug 收藏了文章 · 2019-01-19

很全很全的JavaScript的模块讲解

最近一直在搞基础的东西,弄了一个持续更新的github笔记,可以去看看,诚意之作(本来就是写给自己看的……)链接地址:Front-End-Basics

此篇文章的地址:JavaScript的模块

基础笔记的github地址:https://github.com/qiqihaobenben/Front-End-Basics ,可以watch,也可以star。


正文开始


JavaScript的模块

介绍

模块通常是指编程语言所提供的代码组织机制,利用此机制可将程序拆解为独立且通用的代码单元。所谓模块化主要是解决代码分割、作用域隔离、模块之间的依赖管理以及发布到生产环境时的自动化打包与处理等多个方面。

模块的优点

  1. 可维护性。 因为模块是独立的,一个设计良好的模块会让外面的代码对自己的依赖越少越好,这样自己就可以独立去更新和改进。
  2. 命名空间。 在 JavaScript 里面,如果一个变量在最顶级的函数之外声明,它就直接变成全局可用。因此,常常不小心出现命名冲突的情况。使用模块化开发来封装变量,可以避免污染全局环境。
  3. 重用代码。 我们有时候会喜欢从之前写过的项目中拷贝代码到新的项目,这没有问题,但是更好的方法是,通过模块引用的方式,来避免重复的代码库。

CommonJS

CommonJS 最开始是 Mozilla 的工程师于 2009 年开始的一个项目,它的目的是让浏览器之外的 JavaScript (比如服务器端或者桌面端)能够通过模块化的方式来开发和协作。

在 CommonJS 的规范中,每个 JavaScript 文件就是一个独立的模块上下文(module context),在这个上下文中默认创建的属性都是私有的。也就是说,在一个文件定义的变量(还包括函数和类),都是私有的,对其他文件是不可见的。

需要注意的是,CommonJS 规范的主要适用场景是服务器端编程,所以采用同步加载模块的策略。如果我们依赖3个模块,代码会一个一个依次加载它们。

该模块实现方案主要包含 require 与 module 这两个关键字,其允许某个模块对外暴露部分接口并且由其他模块导入使用。

//sayModule.js
function SayModule () {
    this.hello = function () {
        console.log('hello');
    };

    this.goodbye = function () {
        console.log('goodbye');
    };
}

module.exports = SayModule;

//main.js 引入sayModule.js
var Say = require('./sayModule.js');
var sayer = new Say();
sayer.hello(); //hello

作为一个服务器端的解决方案,CommonJS 需要一个兼容的脚本加载器作为前提条件。该脚本加载器必须支持名为 require 和 module.exports 的函数,它们将模块相互导入导出。

Node.js

Node 从 CommonJS 的一些创意中,创造出自己的模块化实现。由于Node 在服务端的流行,Node 的模块形式被(不正确地)称为 CommonJS。

Node.js模块可以分为两大类,一类是核心模块,另一类是文件模块。
核心模块 就是Node.js标准的API中提供的模块,如fs、http、net等,这些都是由Node.js官方提供的模块,编译成了二进制代码,可以直接通过require获取核心模块,例如require('fs'),核心模块拥有最高的加载优先级,如果有模块与核心模块命名冲突,Node.js总是会加载核心模块。
文件模块 是存储为单独的文件(或文件夹)的模块,可能是JavaScript代码、JSON或编译好的C/C++代码。在不显式指定文件模块扩展名的时候,Node.js会分别试图加上.js、.json、.node(编译好的C/C++代码)。

加载方式
  • 按路径加载模块

如果require参数以"/"开头,那么就以绝对路径的方式查找模块名称,如果参数以"./"、"../"开头,那么则是以相对路径的方式来查找模块。

  • 通过查找node_modules目录加载模块

如果require参数不以"/"、"./"、"../"开头,而该模块又不是核心模块,那么就要通过查找node_modules加载模块了。我们使用的npm获取的包通常就是以这种方式加载的。

加载缓存

Node.js模块不会被重复加载,这是因为Node.js通过文件名缓存所有加载过的文件模块,所以以后再访问到时就不会重新加载了。
注意: Node.js是根据实际文件名缓存的,而不是require()提供的参数缓存的,也就是说即使你分别通过require('express')和require('./node_modules/express')加载两次,也不会重复加载,因为尽管两次参数不同,解析到的文件却是同一个。

Node.js 中的模块在加载之后是以单例化运行,并且遵循值传递原则:如果是一个对象,就相当于这个对象的引用。

模块载入过程

加载文件模块的工作,主要由原生模块module来实现和完成,该原生模块在启动时已经被加载,进程直接调用到runMain静态方法。

例如运行: node app.js

Module.runMain = function () {
    // Load the main module--the command line argument.
    Module._load(process.argv[1], null, true);
};

//_load静态方法在分析文件名之后执行
var module = new Module(id, parent);

//并根据文件路径缓存当前模块对象,该模块实例对象则根据文件名加载。
module.load(filename);

具体说一下上文提到了文件模块的三类模块,这三类文件模块以后缀来区分,Node.js会根据后缀名来决定加载方法,具体的加载方法在下文require.extensions中会介绍。

  • .js 通过fs模块同步读取js文件并编译执行。
  • .node 通过C/C++进行编写的Addon。通过dlopen方法进行加载。
  • .json 读取文件,调用JSON.parse解析加载。

接下来详细描述js后缀的编译过程。Node.js在编译js文件的过程中实际完成的步骤有对js文件内容进行头尾包装。以app.js为例,包装之后的app.js将会变成以下形式:

//circle.js
var PI = Math.PI;
exports.area = function (r) {
    return PI * r * r;
};
exports.circumference = function (r) {
    return 2 * PI * r;
};

//app.js
var circle = require('./circle.js');
console.log( 'The area of a circle of radius 4 is ' + circle.area(4));

//app包装后
(function (exports, require, module, __filename, __dirname) {
    var circle = require('./circle.js');
    console.log('The area of a circle of radius 4 is ' + circle.area(4));
});

//这段代码会通过vm原生模块的runInThisContext方法执行(类似eval,只是具有明确上下文,不污染全局),返回为一个具体的function对象。最后传入module对象的exports,require方法,module,文件名,目录名作为实参并执行。

这就是为什么require并没有定义在app.js 文件中,但是这个方法却存在的原因。从Node.js的API文档中可以看到还有__filename__dirnamemoduleexports几个没有定义但是却存在的变量。其中__filename__dirname在查找文件路径的过程中分析得到后传入的。module变量是这个模块对象自身,exports是在module的构造函数中初始化的一个空对象({},而不是null)。
在这个主文件中,可以通过require方法去引入其余的模块。而其实这个require方法实际调用的就是module._load方法。
load方法在载入、编译、缓存了module后,返回module的exports对象。这就是circle.js文件中只有定义在exports对象上的方法才能被外部调用的原因。

以上所描述的模块载入机制均定义在lib/module.js中。

require 函数

require 引入的对象主要是函数。当 Node 调用 require() 函数,并且传递一个文件路径给它的时候,Node 会经历如下几个步骤:

  • Resolving:找到文件的绝对路径;
  • Loading:判断文件内容类型;
  • Wrapping:打包,给这个文件赋予一个私有作用范围。这是使 require 和 module 模块在本地引用的一种方法;
  • Evaluating:VM 对加载的代码进行处理的地方;
  • Caching:当再次需要用这个文件的时候,不需要重复一遍上面步骤。
require.extensions 来查看对三种文件的支持情况

clipboard.png
可以清晰地看到 Node 对每种扩展名所使用的函数及其操作:对 .js 文件使用 module._compile;对 .json 文件使用 JSON.parse;对 .node 文件使用 process.dlopen。

文件查找策略

  • 从文件模块缓存中加载

尽管原生模块与文件模块的优先级不同,但是优先级最高的是从文件模块的缓存中加载已经存在的模块。

  • 从原生模块加载

原生模块的优先级仅次于文件模块缓存的优先级。require方法在解析文件名之后,优先检查模块是否在原生模块列表中。以http模块为例,尽管在目录下存在一个httphttp.jshttp.nodehttp.json文件,require(“http”)都不会从这些文件中加载,而是从原生模块中加载。
原生模块也有一个缓存区,同样也是优先从缓存区加载。如果缓存区没有被加载过,则调用原生模块的加载方式进行加载和执行。

  • 从文件加载

当文件模块缓存中不存在,而且不是原生模块的时候,Node.js会解析require方法传入的参数,并从文件系统中加载实际的文件,加载过程中的包装和编译细节在前面说过是调用load方法。
··

当 Node 遇到 require(X) 时,按下面的顺序处理。

(1)如果 X 是内置模块(比如 require('http')) 
  a. 返回该模块。 
  b. 不再继续执行。

(2)如果 X 以 "./" 或者 "/" 或者 "../" 开头 
  a. 根据 X 所在的父模块,确定 X 的绝对路径。 
  b. 将 X 当成文件,依次查找下面文件,只要其中有一个存在,就返回该文件,不再继续执行。
        X
        X.js
        X.json
        X.node

  c. 将 X 当成目录,依次查找下面文件,只要其中有一个存在,就返回该文件,不再继续执行。
        X/package.json(main字段)
        X/index.js
        X/index.json
        X/index.node

(3)如果 X 不带路径 
  a. 根据 X 所在的父模块,确定 X 可能的安装目录。 
  b. 依次在每个目录中,将 X 当成文件名或目录名加载。

(4) 抛出 "not found"

clipboard.png


模块循环依赖
//创建两个文件,module1.js 和 module2.js,并且让它们相互引用
// module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;

// module2.js
const Module1 = require('./module1');
console.log('Module1 is partially loaded here', Module1);

//执行 node module2.js 打印:Module1 is partially loaded here {a:1,b:2,c:3}
//执行 node module1.js 打印:Module1 is partially loaded here {a:1}

在 module1 完全加载之前需要先加载 module2,而 module2 的加载又需要 module1。这种状态下,我们从 exports 对象中能得到的就是在发生循环依赖之前的这部分。上面代码中,只有 a 属性被引入,因为 b 和 c 都需要在引入 module2 之后才能加载进来。

Node 使这个问题简单化,在一个模块加载期间开始创建 exports 对象。如果它需要引入其他模块,并且有循环依赖,那么只能部分引入,也就是只能引入发生循环依赖之前所定义的这部分。

AMD

AMD 是 Asynchronous Module Definition 的简称,即“异步模块定义”,是从 CommonJS 讨论中诞生的。AMD 优先照顾浏览器的模块加载场景,使用了异步加载和回调的方式。

AMD 和 CommonJS 一样需要脚本加载器,尽管 AMD 只需要对 define 方法的支持。define 方法需要三个参数:模块名称,模块运行的依赖数组,所有依赖都可用之后执行的函数(该函数按照依赖声明的顺序,接收依赖作为参数)。只有函数参数是必须的。define 既是一种引用模块的方式,也是定义模块的方式。

// file lib/sayModule.js
define(function (){
    return {
        sayHello: function () {
            console.log('hello');
        }
    };
});

//file main.js
define(['./lib/sayModule'], function (say){
    say.sayHello(); //hello
})

main.js 作为整个应用的入口模块,我们使用 define 关键字声明了该模块以及外部依赖(没有生命模块名称);当我们执行该模块代码时,也就是执行 define 函数的第二个参数中定义的函数功能,其会在框架将所有的其他依赖模块加载完毕后被执行。这种延迟代码执行的技术也就保证了依赖的并发加载。

RequireJS

RequireJS 是一个前端的模块化管理的工具库,遵循AMD规范,通过一个函数来将所有所需要的或者说所依赖的模块实现装载进来,然后返回一个新的函数(模块),我们所有的关于新模块的业务代码都在这个函数内部操作,其内部也可无限制的使用已经加载进来的以来的模块。

<script data-main='scripts/main' data-original='scripts/require.js'></script>
//scripts下的main.js则是指定的主代码脚本文件,所有的依赖模块代码文件都将从该文件开始异步加载进入执行。

defined用于定义模块,RequireJS要求每个模块均放在独立的文件之中。按照是否有依赖其他模块的情况分为独立模块和非独立模块。
1、独立模块 不依赖其他模块。直接定义

define({
    methodOne: function (){},
    methodTwo: function (){}
});

//等价于

define(function (){
    return {
        methodOne: function (){},
        methodTwo: function (){}
    };
});

2、非独立模块,对其他模块有依赖

define([ 'moduleOne', 'moduleTwo' ], function(mOne, mTwo){
    ...
});

//或者

define( function( require ){
    var mOne = require( 'moduleOne' ),
        mTwo = require( 'moduleTwo' );
    ...
});

如上代码, define中有依赖模块数组的 和 没有依赖模块数组用require加载 这两种定义模块,调用模块的方法合称为AMD模式,定义模块清晰,不会污染全局变量,清楚的显示依赖关系。AMD模式可以用于浏览器环境并且允许非同步加载模块,也可以按需动态加载模块。

CMD

CMD(Common Module Definition),在CMD中,一个模块就是一个文件。

全局函数define,用来定义模块。
参数 factory 可以是一个函数,也可以为对象或者字符串。
当 factory 为对象、字符串时,表示模块的接口就是该对象、字符串。

定义JSON数据模块:

define({ "foo": "bar" });

factory 为函数的时候,表示模块的构造方法,执行构造方法便可以得到模块向外提供的接口。

define( function(require, exports, module) { 
    // 模块代码
});

SeaJS

sea.js 核心特征:

  1. 遵循CMD规范,与NodeJS般的书写模块代码。
  2. 依赖自动加载,配置清晰简洁。

seajs.use用来在页面中加载一个或者多个模块


 // 加载一个模块 
seajs.use('./a');

// 加载模块,加载完成时执行回调
seajs.use('./a',function(a){
    a.doSomething();
});

// 加载多个模块执行回调
seajs.use(['./a','./b'],function(a , b){
    a.doSomething();
    b.doSomething();
});
AMD和CMD最大的区别是对依赖模块的执行时机处理不同,注意不是加载的时机或者方式不同。
很多人说requireJS是异步加载模块,SeaJS是同步加载模块,这么理解实际上是不准确的,其实加载模块都是异步的,只不过AMD依赖前置,js可以方便知道依赖模块是谁,立即加载,而CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块,这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略。

为什么说是执行时机处理不同?
同样都是异步加载模块,AMD在加载模块完成后就会执行该模块,所有模块都加载执行完后会进入回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。
CMD加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到require语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。

UMD

统一模块定义(UMD:Universal Module Definition )就是将 AMD 和 CommonJS 合在一起的一种尝试,常见的做法是将CommonJS 语法包裹在兼容 AMD 的代码中。

(function(define) {
    define(function () {
        return {
            sayHello: function () {
                console.log('hello');
            }
        };
    });
}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));

该模式的核心思想在于所谓的 IIFE(Immediately Invoked Function Expression),该函数会根据环境来判断需要的参数类别

ES6模块(module)

严格模式 

ES6 的模块自动采用严格模式,不管有没有在模块头部加上"use strict";。
严格模式主要有以下限制。

  • 变量必须声明后再使用
  • 函数的参数不能有同名属性,否则报错
  • 不能使用with语句
  • 不能对只读属性赋值,否则报错
  • 不能使用前缀0表示八进制数,否则报错
  • 不能删除不可删除的属性,否则报错
  • 不能删除变量delete prop,会报错,只能删除属性delete global[prop]
  • eval不会在它的外层作用域引入变量
  • eval和arguments不能被重新赋值
  • arguments不会自动反映函数参数的变化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全局对象
  • 不能使用fn.caller和fn.arguments获取函数调用的堆栈
  • 增加了保留字(比如protected、static和interface)

模块Module

一个模块,就是一个对其他模块暴露自己的属性或者方法的文件。

导出Export

作为一个模块,它可以选择性地给其他模块暴露(提供)自己的属性和方法,供其他模块使用。

// profile.js
export var firstName = 'qiqi';
export var lastName = 'haobenben';
export var year = 1992;

//等价于

var firstName = 'qiqi';
var lastName = 'haobenben';
var year = 1992;
export {firstName, lastName, year}

1、 通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

//上面代码使用as关键字,重命名了函数v1和v2的对外接口。重命名后,v2可以用不同的名字输出两次。

2、 需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

// 报错
export 1;

// 报错
var m = 1;
export m;

//上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出1,第二种写法通过变量m,还是直接输出1。1只是一个值,不是接口。

/ 写法一
export var m = 1;

// 写法二
var m = 1;
export {m};

// 写法三
var n = 1;
export {n as m};

//上面三种写法都是正确的,规定了对外的接口m。其他脚本可以通过这个接口,取到值1。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。

3、最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,接下来说的import命令也是如此。

function foo() {
  export default 'bar' // SyntaxError
}
foo()

导入import

作为一个模块,可以根据需要,引入其他模块的提供的属性或者方法,供自己模块使用。

1、 import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。

import { lastName as surename } from './profile';

2、import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js路径可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。

3、注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。

foo();

import { foo } from 'my_module';

//上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。

4、由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

/ 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

// 报错
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}

5、最后,import语句会执行所加载的模块,因此可以有下面的写法。

import 'lodash';
//上面代码仅仅执行lodash模块,但是不输入任何值。

默认导出(export default)

每个模块支持我们导出一个没有名字的变量,使用关键语句export default来实现.

export default function(){
            console.log("I am default Fn");
        }
//使用export default关键字对外导出一个匿名函数,导入这个模块的时候,可以为这个匿名函数取任意的名字

//取任意名字均可
import sayDefault from "./module-B.js";
sayDefault();
//结果:I am default Fn

1、默认输出和正常输出的比较

// 第一组
export default function diff() { // 输出
  // ...
}

import diff from 'diff'; // 输入

// 第二组
export function diff() { // 输出
  // ...
};

import {diff} from 'diff'; // 输入

//上面代码的两组写法,第一组是使用export default时,对应的import语句不需要使用大括号;第二组是不使用export default时,对应的import语句需要使用大括号。
export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能对应一个方法。


2、因为export default本质是将该命令后面的值,赋给default变量以后再默认,所以直接将一个值写在export default之后。

/ 正确
export default 42;

// 报错
export 42;

//上面代码中,后一句报错是因为没有指定对外的接口,而前一句指定外对接口为default。

3、如果想在一条import语句中,同时输入默认方法和其他变量,可以写成下面这样。

import _, { each } from 'lodash';

//对应上面代码的export语句如下
export default function (){
    //...
}
export function each (obj, iterator, context){
    //...
}

export 与 import 的复合写法

如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。

export { foo, bar } from 'my_module';

// 等同于
import { foo, bar } from 'my_module';
export { foo, bar };

/ 接口改名
export { foo as myFoo } from 'my_module';

// 整体输出
export * from 'my_module';
注意事项
1、声明的变量,对外都是只读的。但是导出的是对象类型的值,就可修改。
2、导入不存在的变量,值为undefined。

ES6 中的循环引用

ES6 中,imports 是 exports 的只读视图,直白一点就是,imports 都指向 exports 原本的数据,比如:

//------ lib.js ------
export let counter = 3;
export function incCounter() {
    counter++;
}

//------ main.js ------
import { counter, incCounter } from './lib';

// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4

// The imported value can’t be changed
counter++; // TypeError

因此在 ES6 中处理循环引用特别简单,看下面这段代码:

//------ a.js ------
import {bar} from 'b'; // (1)
export function foo() {
  bar(); // (2)
}

//------ b.js ------
import {foo} from 'a'; // (3)
export function bar() {
  if (Math.random()) {
    foo(); // (4)
  }
}

假设先加载模块 a,在模块 a 加载完成之后,bar 间接性地指向的是模块 b 中的 bar。无论是加载完成的 imports 还是未完成的 imports,imports 和 exports 之间都有一个间接的联系,所以总是可以正常工作。

实例

//---module-B.js文件---
//导出变量:name
export var name = "cfangxu";

moduleA模块代码:
//导入 模块B的属性 name    
import { name } from "./module-B.js";   
console.log(name)
//打印结果:cfangxu

批量导出

//属性name
var name = "cfangxu";
//属性age
var age  = 26;
//方法 say
var say = function(){
            console.log("say hello");
         }
//批量导出
export {name,age,say}

批量导入

//导入 模块B的属性
import { name,age,say } from "./module-B.js";
console.log(name)
//打印结果:cfangxu
console.log(age)
//打印结果:26
say()
//打印结果:say hello

重命名导入变量

import {name as myName} from './module-B.js';
console.log(myName) //cfangxu

整体导入

/使用*实现整体导入
import * as obj from "./module-B.js";

console.log(obj.name)
//结果:"cfangxu"
console.log(obj.age)
//结果:26
obj.say();
//结果:say hello

推荐资料

查看原文

写Bug 评论了文章 · 2019-01-09

不要再问我移动适配的问题了

“不要再问我XX的问题”系列:
一、不要再问我this的指向问题了
二、不要再问我跨域的问题了

移动端适配的问题,一般来说我们都不会去深究,因为这种东西都是配置一次就再也不用管的了,接到设计图就按照祖传套路撸就完事了。按部就班的必定只能成为活动页写手,研究透彻以后,才能成为一名专业的活动页写手嘛。

纠缠不清的关系

文章开始,我们需要来捋清楚像素、视口以及缩放之间种种藕断丝连的关系,来抽丝剥茧一波。

像素

像素我们写得多了,不就是px嘛,为什么要拿出来说呢?因为像素还不仅仅就是px。

  • 设备像素

设备像素也可以叫物理像素,由设备的屏幕决定,其实就是屏幕中控制显示的最小单位。

  • 设备独立像素

设备独立像素是一种可以被程序所控制的虚拟像素,在Web开发中对应CSS像素。

  • DPR

设备像素与设备独立像素之间的关系就是,DPR(设备像素比),设备像素比 = 设备像素 / 设备独立像素。这条公式成立的前提是,缩放比为1,原因下面讲到缩放的时候就会知道。根据这种关系,如果设备像素大于设备独立像素(DPR大于1的设备,我们常说的高清屏或者Retina屏),就会出现一个设备独立像素对应多个设备像素的情况:
图片描述

视口

遥想从前智能手机刚出的时候,很少网站去特意适配移动端,然而用户是可以直接从手机去访问PC端网站的,所以怎样显示好一个网站,无论这个网站是一个PC网站还是移动端网站,就是亟需解决的问题。所以移动端三个视口布局视口、视觉视口、理想视口横空出世,成为各种移动适配方案的基础。

  • 布局视口

布局视口是在html元素之上的容器,我们的页面就“装”在布局视口中。
想想我们常写的width:100%,这个100%是基于什么计算出来的呢?去翻资料会看到:如果某些属性被赋予一个百分值的话,它的计算值是由这个元素的包含块计算而来的。那html元素的包含块是什么呢?没错,就是我们的布局视口,它是所有CSS百分比推算的根源,如果说CSS是一支画笔,那么布局视口就是那张画布吧。这张画布有一个默认尺寸(如果没有手动去设置meta viewport),一般在768px ~ 1024px间,可以通过document.documentElement.clientWidth获取。这样一来,网页的布局就不再受限于设备的尺寸,即使是小屏幕的移动端设备中也能容得下PC网站。
布局视口

  • 视觉视口

视觉视口是指用户通过设备屏幕看到的区域,可以通过缩放来改变视觉视口的大小,并通过window.innerWidth获取。
这里有必要讲一下缩放,缩放改变的是CSS像素的大小,放大时CSS像素增大,则一个CSS像素可以跨越更多的设备像素,视觉视口会变小。什么?放大反而视觉视口变小?没错,这是因为视觉视口也是通过CSS像素度量,而放大就是使CSS像素放大,假设屏幕上本来需要200个CSS像素才能占满屏幕,由于放大,现在只需要100个CSS像素就能占满,所以视觉视口的宽就变成100px。
虽然缩放改变了CSS像素的大小,但移动端的缩放是不会改变布局视口的,所以缩放并不会影响布局,不过在PC端是会影响布局的。最直观的感受是,我们平时在移动端双指缩放网页,整个网页的布局是没有变化的,可以通过拖动来看到不同区域的东西,但是在PC端进行缩放,比如阅读时想文字大一些而对网页进行放大操作,这时字是放大了,但整个页面的布局会有所改变。那么既然与布局视口无关那还跟谁有关系呢?答案就是下面准备要讲的理想视口,它们之间的计算方式是:缩放系数 = 理想视口宽度 / 视觉视口宽度
视觉视口

  • 理想视口

理想视口是指网站在移动设备中的理想大小,这个大小就是设备的屏幕大小。
为什么需要理想视口呢?首先,先来看看现在的情况是怎么的不理想。我们在浏览一个没经过移动适配的网站时,由于布局视口在768px ~ 1024px之间,整个网站就“画”在一个这么大的“画布”上,但由于手机屏幕比“画布”小,所以需要经过缩小才能塞进手机屏幕,结果我们浏览网站的时候虽然看得见全貌,但里面的东西都变得很小,需要放大一下才能看得清,就是这么不理想。如果不需要放大就可以看得清那就很理想了嘛。回想一下上面不理想的解决方案,就是将一个大画布经过缩小装进小屏幕里,假设现在画布跟屏幕一样大,就在这个画布上作画,岂不是很合适?
所以总结起来,理想视口说白了就是理想的布局视口,通过<meta name="viewport" content="width=device-width, initial-scale=1">来设置。

将它们连在一起

关联

认识Meta viewport

<meta> 元素可提供有关页面的元信息,不会显示在页面上,可以用来告诉浏览器怎样解析页面。<meta>可以设置的东西很多,但这里只讲vieport,它是所有移动适配方案的基础。
首先meta viewport的设置格式是<meta name="viewport" content="name=value,name=value",其中name的值可设为:

  1. width:将布局视口设置为固定的值,比如375px或者device-width(设备宽度)
  2. initial-scale:设置页面的初始缩放
  3. minimum-scale:设置最小的缩小程度
  4. maximum-scale:设置最大的放大程度
  5. user-scalable:设置为no时禁用缩放

虽然只有五个值,但仍有一些值得注意的点:

设置initial-scale的影响

根据公式缩放系数 = 理想视口宽度 / 视觉视口宽度 ,如果设置了initial-scale比如为0.5,那么以iPhone6为例,iPhone6的设备宽度是375px,即理想视口宽度也为375px,所以视觉视口宽度 = 375px(理想视口宽度)/ 0.5(缩放系数)。很明显设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值

width和initial-scale共存

上面说到设置了initial-scale相当于初始化了视觉视口和布局视口,但width用于指定布局视口的大小,那么一起设置的话听谁的呢?
还是以iPhone6为例,它的尺寸是667(h) * 375(w),如果设置<meta name="viewport" content="width=400, initial-scale=1">,执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 400; 视觉视口: 400”。
这时候旋转一下设备,这时尺寸变成了667(w) * 375(h),再执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 667; 视觉视口: 667”。
结论是:width与initial-scale都会初始化布局视口,但浏览器会取其最大值。

设置理想视口

这时候再看回<meta name="viewport" content="width=device-width, initial-scale=1">,明明width=device-width和initial-scale=1都是去初始化布局视口成理想的布局视口,只写其中一个不就完了嘛,为什么要两个都一起写呢?因为有的浏览器只设置其中一个,不能保证理想视口的尺寸能随着屏幕的旋转而正确改变,所以两个一起写只是为了解决兼容性问题。

舒服地还原移动端设计图

上面说了很多理论知识,其实就是为了能有一套方案舒服地还原移动端设计图,做出一个专为移动端访问的页面。

经典的问题

  • 图片

这里的图片问题是指高清/Retina屏下图片会显示得比较模糊,这是因为我们平时使用的图片大多数是png、jpg这样格式的图片,它们称作是位图图像(bitmap),是由一个个像素点构成,缩放会失真。上面讲像素的时候说过,这种高清/Retina屏DPR大于一,则一像素横跨了多个设备像素,而位图图像需要一个像素点对应一个设备像素才清晰。所以假设一张100 x 100的图片放在普通屏上看是清晰的,放到高清/Retina屏上就会显得比较模糊,那是因为本来100 x 100的图片在普通屏上图片像素与设备像素一一对应,而到了高清/Retina屏上一个图片像素却要对应多个设备像素,这样一来看起来图片就比较模糊。
图片问题
如图所示,如果一个图片像素要对应多个设备像素的话,那这些设备像素只能显示成跟这个图片像素差不多的颜色,导致看起来会模糊。
既然知道了问题产生的原因,那解决方法也很简单,位图图像需要一个像素点对应一个设备像素才清晰嘛,那就本来是100 x 100的图片在DPR为1的屏幕上显示清晰,在DPR为2的屏幕上显示模糊,那就在DPR为2的屏幕上放200 x 200的图好了,这样就一一对应了。

  • 1px边框

1px边框
“你看看设计图这根线是很细的,为什么你实现出来那么粗,看起来很劣质的感觉。”
没道理呀,设计图量的是1px,css写的也是1px,怎么会粗了呢?一般设计师出图的时候,都会按照一个尺寸作为标准来出图,比如按照iPhone6的尺寸出图,就是一张750px宽的设计图,这个750px其实就是iPhone6的设备像素,在测量设计图时量到的1px其实是1设备像素,而当我们设置<meta name="viewport" content="width=device-width, initial-scale=1">时,布局视口等于理想视口等于375px,并且由于iPhone6的DPR为2,写css时的1px对应的是2设备像素,所以看起来会粗一点。
那么只要写0.5px就是对应1设备像素了嘛。是的,道理是这么说,但是很多浏览器并不支持0.5px的写法,导致显示不出来,但不要紧,网上很多方法解决这个问题的方法就不细说了,这里只是讲清楚1px边框问题产生的原因。

还原设计图

因为PC端屏幕一般都会比设计图尺寸要大,所以只需要居中固定一个内容区用于显示设计图的内容,其余多出的地方留白即可。而移动端屏幕有大有小,设计图一般会以一款机型为标准来出图,比如说iPhone6的尺寸,如果不经处理直接量设计图就开干会出现什么问题呢?
对比
(从左到右为iPhone4、iPhone6、iPhone plus)
可以看到以iPhone6为标准出的设计图测量出来350px x 350px的元素在iPhone6上写width: 350px;height: 350px;是刚刚好的,左右的间隙各有10px,但小一点的屏幕iPhone4横向滚动条都出来了,而plus左右间隙明显比10px大很多,这样一来不同尺寸的屏幕出来的效果跟设计图的效果就会有不同程度的出入,这并不是我们想要的,我们想要的是不同尺寸的屏幕显示的效果与设计图比例是一致的。
既然想要的是不同屏幕尺寸显示的比例与设计图一致,那么显然适配方案就是等比缩放
(以下代码都是为了讲述原理,没有过多的细节考虑与测试,不能用于生产环境)

  • viewport方案

说到缩放,首先想到的当然是initial-scale。回想一下initial-scale的作用:设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值。那么我们是不是可以以设计图为基准等比缩放布局视口从而适配呢?

<script>
    const scale = window.screen.width / 750
    document.write(`<meta name="viewport" content="initial-scale=${scale}">`)
</script>

这种方式进行适配优点是简单粗暴,缺点是太简单粗暴了,因为viewport的设置是影响全局的,这样一来虽然可以直接将设计图量得的尺寸写到css上,但如果有一些需要地方不需要等比缩放而需要设置固定尺寸,比如要求在不同尺寸屏幕上显示固定大小的文字,或者你引进了一个库,里面的有样式你也不知道人家是按照怎样的适配方案进行适配的,那么到了你的项目里由于全局的viewport缩放,可能会影响到这个库的显示效果。

  • rem方案

不同于px是固定尺寸单位,rem是相对单位,相对于html标签字体大小的单位。比如html标签的font-size为100px,那么1rem就等于100px。借助rem这个相对单位我们同样可以达到等比缩放的效果。

  • 这个方案不需要对viewport进行缩放,所以首先按照惯例我们让布局视口等于理想视口:<meta name="viewport" content="width=device-width, initial-scale=1">
  • 还是以iPhone6的设备像素为标准的设计图,宽是750px,假设以设计图为标准的html标签的font-size为100px,所以1rem = 100px,那么这个设计图总宽就有7.5rem
  • 以总宽是7.5rem的设计图为标准,则不同屏幕尺寸的总宽应该也是7.5rem,由于上面设置了布局视口等于理想视口,所以以iPhone6为例,iPhone6的布局视口等于理想视口,则它的布局视口为375px(也就是总宽7.5rem),现在只需要解决在布局视口为375px的情况下,html的font-size需要设置多少。很简单,html font-size * 7.5 = 375,那么font-size为50px。
  • 拓展到其他屏幕document.documentElement.style.fontSize = `${document.documentElement.clientWidth / 7.5}px`
  • 现在我们只需要测量设计图,比如设计图有一个300px的元素,那我们写css的时候就写成3rem(由于以1rem = 100px为基准,所以这里300px / 100即可)

使用这个方案,我们只对需要等比缩放的元素使用rem,而要求固定尺寸的地方使用px即可,这样一来相对于viewport方案来说就比较灵活,可以按需使用而不是一刀切。不过这种方案写css的时候可能会没那么直观,成本可能会高一点点,但是借助构建工具或者less/sass可以解决,毕竟现在应该很少项目不使用这些工具的了吧。

  • 加强版rem方案

这里所说的加强版rem方案其实就是手淘的Flexible方案(也类似移动端高清、多屏适配方案),究竟加强了什么呢?那就是,通过设置viewport进而全局解决1px边框问题。
既然要通过设置viewport来解决1px边框问题,那设置这个viewport的方式肯定内有乾坤:

if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
        if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {                
            dpr = 3;
        } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
            dpr = 2;
        } else {
            dpr = 1;
        }
    } else {
        // 其他设备下,仍旧使用1倍的方案
        dpr = 1;
    }
    scale = 1 / dpr;
}

得出的scale用于设置viewport的缩放document.write(`<meta name="viewport" content="initial-scale=${scale}">`),这样一来,对于Retina屏将viewport缩放为1 / dpr最终产生的效果是,1px css像素严格等于1px 设备像素,由此解决了1px边框问题。那为什么只对iPhone进行缩放呢?请看大漠老师的文章再谈Retina下1px的解决方案
其他与rem相关的配置与上面的rem方案类似,这里就不再展开说了。
这个加强版rem方案最大的优势是解决了1px边框问题,但由此也进行了viewport的缩放,仍然会面临着上面说的viewport方案涉及到的一些影响,为此该方案会通过给html设置data-dpr

document.documentElement.setAttribute('data-dpr', dpr)

从而写css的时候可以针对不同的dpr固定设置尺寸:

.test {
    width: 1rem; 
    height: 2rem;
    font-size: 12px; 
}
[data-dpr="2"] .test {
    font-size: 13px;
}
[data-dpr="3"] .test {
    font-size: 14px;
}
  • vw方案

vw也是一个相对单位,它相对的是布局视口,1vw就是1%的布局视口宽度。其实rem方案就是在模拟vw,来看看使用vw怎么做。

  1. 还是熟悉的iPhone6标准设计图,宽750px。那么1vw = 1%视口宽度的话,按设计图来说就是100vw = 750px,则1vw = 7.5px。
  2. 设计图量得一个元素是100px,css需要写成 Xvw * 7.5 = 100,所以X就等于13.3vw。
  3. 计算的话还是交给构建工具即可,详细请看再聊移动端页面的适配

rem方案有的优势vw也有,而且也不会像rem那么绕,但就是兼容性不够rem好,长远来看vw最后会接棒rem作为移动适配的主力,因为它生来就干这个事情呢。

终于结束了

没有银弹。
全局viewport缩放方案很粗暴?但对于要求不高也不需要兼顾固定尺寸的页面,上来就全局缩放,拿起设计稿就可以写代码了。要求高又想灵活,还会怕构建的那一点点麻烦吗?rem方案走起。兼容性不需要考虑,那vw方案直白又优雅不试试看吗?方案没有优劣之分只有合适与否。
最后,如果有说得不对的地方,还望指正。

查看原文

写Bug 评论了文章 · 2019-01-09

不要再问我移动适配的问题了

“不要再问我XX的问题”系列:
一、不要再问我this的指向问题了
二、不要再问我跨域的问题了

移动端适配的问题,一般来说我们都不会去深究,因为这种东西都是配置一次就再也不用管的了,接到设计图就按照祖传套路撸就完事了。按部就班的必定只能成为活动页写手,研究透彻以后,才能成为一名专业的活动页写手嘛。

纠缠不清的关系

文章开始,我们需要来捋清楚像素、视口以及缩放之间种种藕断丝连的关系,来抽丝剥茧一波。

像素

像素我们写得多了,不就是px嘛,为什么要拿出来说呢?因为像素还不仅仅就是px。

  • 设备像素

设备像素也可以叫物理像素,由设备的屏幕决定,其实就是屏幕中控制显示的最小单位。

  • 设备独立像素

设备独立像素是一种可以被程序所控制的虚拟像素,在Web开发中对应CSS像素。

  • DPR

设备像素与设备独立像素之间的关系就是,DPR(设备像素比),设备像素比 = 设备像素 / 设备独立像素。这条公式成立的前提是,缩放比为1,原因下面讲到缩放的时候就会知道。根据这种关系,如果设备像素大于设备独立像素(DPR大于1的设备,我们常说的高清屏或者Retina屏),就会出现一个设备独立像素对应多个设备像素的情况:
图片描述

视口

遥想从前智能手机刚出的时候,很少网站去特意适配移动端,然而用户是可以直接从手机去访问PC端网站的,所以怎样显示好一个网站,无论这个网站是一个PC网站还是移动端网站,就是亟需解决的问题。所以移动端三个视口布局视口、视觉视口、理想视口横空出世,成为各种移动适配方案的基础。

  • 布局视口

布局视口是在html元素之上的容器,我们的页面就“装”在布局视口中。
想想我们常写的width:100%,这个100%是基于什么计算出来的呢?去翻资料会看到:如果某些属性被赋予一个百分值的话,它的计算值是由这个元素的包含块计算而来的。那html元素的包含块是什么呢?没错,就是我们的布局视口,它是所有CSS百分比推算的根源,如果说CSS是一支画笔,那么布局视口就是那张画布吧。这张画布有一个默认尺寸(如果没有手动去设置meta viewport),一般在768px ~ 1024px间,可以通过document.documentElement.clientWidth获取。这样一来,网页的布局就不再受限于设备的尺寸,即使是小屏幕的移动端设备中也能容得下PC网站。
布局视口

  • 视觉视口

视觉视口是指用户通过设备屏幕看到的区域,可以通过缩放来改变视觉视口的大小,并通过window.innerWidth获取。
这里有必要讲一下缩放,缩放改变的是CSS像素的大小,放大时CSS像素增大,则一个CSS像素可以跨越更多的设备像素,视觉视口会变小。什么?放大反而视觉视口变小?没错,这是因为视觉视口也是通过CSS像素度量,而放大就是使CSS像素放大,假设屏幕上本来需要200个CSS像素才能占满屏幕,由于放大,现在只需要100个CSS像素就能占满,所以视觉视口的宽就变成100px。
虽然缩放改变了CSS像素的大小,但移动端的缩放是不会改变布局视口的,所以缩放并不会影响布局,不过在PC端是会影响布局的。最直观的感受是,我们平时在移动端双指缩放网页,整个网页的布局是没有变化的,可以通过拖动来看到不同区域的东西,但是在PC端进行缩放,比如阅读时想文字大一些而对网页进行放大操作,这时字是放大了,但整个页面的布局会有所改变。那么既然与布局视口无关那还跟谁有关系呢?答案就是下面准备要讲的理想视口,它们之间的计算方式是:缩放系数 = 理想视口宽度 / 视觉视口宽度
视觉视口

  • 理想视口

理想视口是指网站在移动设备中的理想大小,这个大小就是设备的屏幕大小。
为什么需要理想视口呢?首先,先来看看现在的情况是怎么的不理想。我们在浏览一个没经过移动适配的网站时,由于布局视口在768px ~ 1024px之间,整个网站就“画”在一个这么大的“画布”上,但由于手机屏幕比“画布”小,所以需要经过缩小才能塞进手机屏幕,结果我们浏览网站的时候虽然看得见全貌,但里面的东西都变得很小,需要放大一下才能看得清,就是这么不理想。如果不需要放大就可以看得清那就很理想了嘛。回想一下上面不理想的解决方案,就是将一个大画布经过缩小装进小屏幕里,假设现在画布跟屏幕一样大,就在这个画布上作画,岂不是很合适?
所以总结起来,理想视口说白了就是理想的布局视口,通过<meta name="viewport" content="width=device-width, initial-scale=1">来设置。

将它们连在一起

关联

认识Meta viewport

<meta> 元素可提供有关页面的元信息,不会显示在页面上,可以用来告诉浏览器怎样解析页面。<meta>可以设置的东西很多,但这里只讲vieport,它是所有移动适配方案的基础。
首先meta viewport的设置格式是<meta name="viewport" content="name=value,name=value",其中name的值可设为:

  1. width:将布局视口设置为固定的值,比如375px或者device-width(设备宽度)
  2. initial-scale:设置页面的初始缩放
  3. minimum-scale:设置最小的缩小程度
  4. maximum-scale:设置最大的放大程度
  5. user-scalable:设置为no时禁用缩放

虽然只有五个值,但仍有一些值得注意的点:

设置initial-scale的影响

根据公式缩放系数 = 理想视口宽度 / 视觉视口宽度 ,如果设置了initial-scale比如为0.5,那么以iPhone6为例,iPhone6的设备宽度是375px,即理想视口宽度也为375px,所以视觉视口宽度 = 375px(理想视口宽度)/ 0.5(缩放系数)。很明显设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值

width和initial-scale共存

上面说到设置了initial-scale相当于初始化了视觉视口和布局视口,但width用于指定布局视口的大小,那么一起设置的话听谁的呢?
还是以iPhone6为例,它的尺寸是667(h) * 375(w),如果设置<meta name="viewport" content="width=400, initial-scale=1">,执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 400; 视觉视口: 400”。
这时候旋转一下设备,这时尺寸变成了667(w) * 375(h),再执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 667; 视觉视口: 667”。
结论是:width与initial-scale都会初始化布局视口,但浏览器会取其最大值。

设置理想视口

这时候再看回<meta name="viewport" content="width=device-width, initial-scale=1">,明明width=device-width和initial-scale=1都是去初始化布局视口成理想的布局视口,只写其中一个不就完了嘛,为什么要两个都一起写呢?因为有的浏览器只设置其中一个,不能保证理想视口的尺寸能随着屏幕的旋转而正确改变,所以两个一起写只是为了解决兼容性问题。

舒服地还原移动端设计图

上面说了很多理论知识,其实就是为了能有一套方案舒服地还原移动端设计图,做出一个专为移动端访问的页面。

经典的问题

  • 图片

这里的图片问题是指高清/Retina屏下图片会显示得比较模糊,这是因为我们平时使用的图片大多数是png、jpg这样格式的图片,它们称作是位图图像(bitmap),是由一个个像素点构成,缩放会失真。上面讲像素的时候说过,这种高清/Retina屏DPR大于一,则一像素横跨了多个设备像素,而位图图像需要一个像素点对应一个设备像素才清晰。所以假设一张100 x 100的图片放在普通屏上看是清晰的,放到高清/Retina屏上就会显得比较模糊,那是因为本来100 x 100的图片在普通屏上图片像素与设备像素一一对应,而到了高清/Retina屏上一个图片像素却要对应多个设备像素,这样一来看起来图片就比较模糊。
图片问题
如图所示,如果一个图片像素要对应多个设备像素的话,那这些设备像素只能显示成跟这个图片像素差不多的颜色,导致看起来会模糊。
既然知道了问题产生的原因,那解决方法也很简单,位图图像需要一个像素点对应一个设备像素才清晰嘛,那就本来是100 x 100的图片在DPR为1的屏幕上显示清晰,在DPR为2的屏幕上显示模糊,那就在DPR为2的屏幕上放200 x 200的图好了,这样就一一对应了。

  • 1px边框

1px边框
“你看看设计图这根线是很细的,为什么你实现出来那么粗,看起来很劣质的感觉。”
没道理呀,设计图量的是1px,css写的也是1px,怎么会粗了呢?一般设计师出图的时候,都会按照一个尺寸作为标准来出图,比如按照iPhone6的尺寸出图,就是一张750px宽的设计图,这个750px其实就是iPhone6的设备像素,在测量设计图时量到的1px其实是1设备像素,而当我们设置<meta name="viewport" content="width=device-width, initial-scale=1">时,布局视口等于理想视口等于375px,并且由于iPhone6的DPR为2,写css时的1px对应的是2设备像素,所以看起来会粗一点。
那么只要写0.5px就是对应1设备像素了嘛。是的,道理是这么说,但是很多浏览器并不支持0.5px的写法,导致显示不出来,但不要紧,网上很多方法解决这个问题的方法就不细说了,这里只是讲清楚1px边框问题产生的原因。

还原设计图

因为PC端屏幕一般都会比设计图尺寸要大,所以只需要居中固定一个内容区用于显示设计图的内容,其余多出的地方留白即可。而移动端屏幕有大有小,设计图一般会以一款机型为标准来出图,比如说iPhone6的尺寸,如果不经处理直接量设计图就开干会出现什么问题呢?
对比
(从左到右为iPhone4、iPhone6、iPhone plus)
可以看到以iPhone6为标准出的设计图测量出来350px x 350px的元素在iPhone6上写width: 350px;height: 350px;是刚刚好的,左右的间隙各有10px,但小一点的屏幕iPhone4横向滚动条都出来了,而plus左右间隙明显比10px大很多,这样一来不同尺寸的屏幕出来的效果跟设计图的效果就会有不同程度的出入,这并不是我们想要的,我们想要的是不同尺寸的屏幕显示的效果与设计图比例是一致的。
既然想要的是不同屏幕尺寸显示的比例与设计图一致,那么显然适配方案就是等比缩放
(以下代码都是为了讲述原理,没有过多的细节考虑与测试,不能用于生产环境)

  • viewport方案

说到缩放,首先想到的当然是initial-scale。回想一下initial-scale的作用:设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值。那么我们是不是可以以设计图为基准等比缩放布局视口从而适配呢?

<script>
    const scale = window.screen.width / 750
    document.write(`<meta name="viewport" content="initial-scale=${scale}">`)
</script>

这种方式进行适配优点是简单粗暴,缺点是太简单粗暴了,因为viewport的设置是影响全局的,这样一来虽然可以直接将设计图量得的尺寸写到css上,但如果有一些需要地方不需要等比缩放而需要设置固定尺寸,比如要求在不同尺寸屏幕上显示固定大小的文字,或者你引进了一个库,里面的有样式你也不知道人家是按照怎样的适配方案进行适配的,那么到了你的项目里由于全局的viewport缩放,可能会影响到这个库的显示效果。

  • rem方案

不同于px是固定尺寸单位,rem是相对单位,相对于html标签字体大小的单位。比如html标签的font-size为100px,那么1rem就等于100px。借助rem这个相对单位我们同样可以达到等比缩放的效果。

  • 这个方案不需要对viewport进行缩放,所以首先按照惯例我们让布局视口等于理想视口:<meta name="viewport" content="width=device-width, initial-scale=1">
  • 还是以iPhone6的设备像素为标准的设计图,宽是750px,假设以设计图为标准的html标签的font-size为100px,所以1rem = 100px,那么这个设计图总宽就有7.5rem
  • 以总宽是7.5rem的设计图为标准,则不同屏幕尺寸的总宽应该也是7.5rem,由于上面设置了布局视口等于理想视口,所以以iPhone6为例,iPhone6的布局视口等于理想视口,则它的布局视口为375px(也就是总宽7.5rem),现在只需要解决在布局视口为375px的情况下,html的font-size需要设置多少。很简单,html font-size * 7.5 = 375,那么font-size为50px。
  • 拓展到其他屏幕document.documentElement.style.fontSize = `${document.documentElement.clientWidth / 7.5}px`
  • 现在我们只需要测量设计图,比如设计图有一个300px的元素,那我们写css的时候就写成3rem(由于以1rem = 100px为基准,所以这里300px / 100即可)

使用这个方案,我们只对需要等比缩放的元素使用rem,而要求固定尺寸的地方使用px即可,这样一来相对于viewport方案来说就比较灵活,可以按需使用而不是一刀切。不过这种方案写css的时候可能会没那么直观,成本可能会高一点点,但是借助构建工具或者less/sass可以解决,毕竟现在应该很少项目不使用这些工具的了吧。

  • 加强版rem方案

这里所说的加强版rem方案其实就是手淘的Flexible方案(也类似移动端高清、多屏适配方案),究竟加强了什么呢?那就是,通过设置viewport进而全局解决1px边框问题。
既然要通过设置viewport来解决1px边框问题,那设置这个viewport的方式肯定内有乾坤:

if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
        if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {                
            dpr = 3;
        } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
            dpr = 2;
        } else {
            dpr = 1;
        }
    } else {
        // 其他设备下,仍旧使用1倍的方案
        dpr = 1;
    }
    scale = 1 / dpr;
}

得出的scale用于设置viewport的缩放document.write(`<meta name="viewport" content="initial-scale=${scale}">`),这样一来,对于Retina屏将viewport缩放为1 / dpr最终产生的效果是,1px css像素严格等于1px 设备像素,由此解决了1px边框问题。那为什么只对iPhone进行缩放呢?请看大漠老师的文章再谈Retina下1px的解决方案
其他与rem相关的配置与上面的rem方案类似,这里就不再展开说了。
这个加强版rem方案最大的优势是解决了1px边框问题,但由此也进行了viewport的缩放,仍然会面临着上面说的viewport方案涉及到的一些影响,为此该方案会通过给html设置data-dpr

document.documentElement.setAttribute('data-dpr', dpr)

从而写css的时候可以针对不同的dpr固定设置尺寸:

.test {
    width: 1rem; 
    height: 2rem;
    font-size: 12px; 
}
[data-dpr="2"] .test {
    font-size: 13px;
}
[data-dpr="3"] .test {
    font-size: 14px;
}
  • vw方案

vw也是一个相对单位,它相对的是布局视口,1vw就是1%的布局视口宽度。其实rem方案就是在模拟vw,来看看使用vw怎么做。

  1. 还是熟悉的iPhone6标准设计图,宽750px。那么1vw = 1%视口宽度的话,按设计图来说就是100vw = 750px,则1vw = 7.5px。
  2. 设计图量得一个元素是100px,css需要写成 Xvw * 7.5 = 100,所以X就等于13.3vw。
  3. 计算的话还是交给构建工具即可,详细请看再聊移动端页面的适配

rem方案有的优势vw也有,而且也不会像rem那么绕,但就是兼容性不够rem好,长远来看vw最后会接棒rem作为移动适配的主力,因为它生来就干这个事情呢。

终于结束了

没有银弹。
全局viewport缩放方案很粗暴?但对于要求不高也不需要兼顾固定尺寸的页面,上来就全局缩放,拿起设计稿就可以写代码了。要求高又想灵活,还会怕构建的那一点点麻烦吗?rem方案走起。兼容性不需要考虑,那vw方案直白又优雅不试试看吗?方案没有优劣之分只有合适与否。
最后,如果有说得不对的地方,还望指正。

查看原文

写Bug 评论了文章 · 2019-01-08

不要再问我移动适配的问题了

“不要再问我XX的问题”系列:
一、不要再问我this的指向问题了
二、不要再问我跨域的问题了

移动端适配的问题,一般来说我们都不会去深究,因为这种东西都是配置一次就再也不用管的了,接到设计图就按照祖传套路撸就完事了。按部就班的必定只能成为活动页写手,研究透彻以后,才能成为一名专业的活动页写手嘛。

纠缠不清的关系

文章开始,我们需要来捋清楚像素、视口以及缩放之间种种藕断丝连的关系,来抽丝剥茧一波。

像素

像素我们写得多了,不就是px嘛,为什么要拿出来说呢?因为像素还不仅仅就是px。

  • 设备像素

设备像素也可以叫物理像素,由设备的屏幕决定,其实就是屏幕中控制显示的最小单位。

  • 设备独立像素

设备独立像素是一种可以被程序所控制的虚拟像素,在Web开发中对应CSS像素。

  • DPR

设备像素与设备独立像素之间的关系就是,DPR(设备像素比),设备像素比 = 设备像素 / 设备独立像素。这条公式成立的前提是,缩放比为1,原因下面讲到缩放的时候就会知道。根据这种关系,如果设备像素大于设备独立像素(DPR大于1的设备,我们常说的高清屏或者Retina屏),就会出现一个设备独立像素对应多个设备像素的情况:
图片描述

视口

遥想从前智能手机刚出的时候,很少网站去特意适配移动端,然而用户是可以直接从手机去访问PC端网站的,所以怎样显示好一个网站,无论这个网站是一个PC网站还是移动端网站,就是亟需解决的问题。所以移动端三个视口布局视口、视觉视口、理想视口横空出世,成为各种移动适配方案的基础。

  • 布局视口

布局视口是在html元素之上的容器,我们的页面就“装”在布局视口中。
想想我们常写的width:100%,这个100%是基于什么计算出来的呢?去翻资料会看到:如果某些属性被赋予一个百分值的话,它的计算值是由这个元素的包含块计算而来的。那html元素的包含块是什么呢?没错,就是我们的布局视口,它是所有CSS百分比推算的根源,如果说CSS是一支画笔,那么布局视口就是那张画布吧。这张画布有一个默认尺寸(如果没有手动去设置meta viewport),一般在768px ~ 1024px间,可以通过document.documentElement.clientWidth获取。这样一来,网页的布局就不再受限于设备的尺寸,即使是小屏幕的移动端设备中也能容得下PC网站。
布局视口

  • 视觉视口

视觉视口是指用户通过设备屏幕看到的区域,可以通过缩放来改变视觉视口的大小,并通过window.innerWidth获取。
这里有必要讲一下缩放,缩放改变的是CSS像素的大小,放大时CSS像素增大,则一个CSS像素可以跨越更多的设备像素,视觉视口会变小。什么?放大反而视觉视口变小?没错,这是因为视觉视口也是通过CSS像素度量,而放大就是使CSS像素放大,假设屏幕上本来需要200个CSS像素才能占满屏幕,由于放大,现在只需要100个CSS像素就能占满,所以视觉视口的宽就变成100px。
虽然缩放改变了CSS像素的大小,但移动端的缩放是不会改变布局视口的,所以缩放并不会影响布局,不过在PC端是会影响布局的。最直观的感受是,我们平时在移动端双指缩放网页,整个网页的布局是没有变化的,可以通过拖动来看到不同区域的东西,但是在PC端进行缩放,比如阅读时想文字大一些而对网页进行放大操作,这时字是放大了,但整个页面的布局会有所改变。那么既然与布局视口无关那还跟谁有关系呢?答案就是下面准备要讲的理想视口,它们之间的计算方式是:缩放系数 = 理想视口宽度 / 视觉视口宽度
视觉视口

  • 理想视口

理想视口是指网站在移动设备中的理想大小,这个大小就是设备的屏幕大小。
为什么需要理想视口呢?首先,先来看看现在的情况是怎么的不理想。我们在浏览一个没经过移动适配的网站时,由于布局视口在768px ~ 1024px之间,整个网站就“画”在一个这么大的“画布”上,但由于手机屏幕比“画布”小,所以需要经过缩小才能塞进手机屏幕,结果我们浏览网站的时候虽然看得见全貌,但里面的东西都变得很小,需要放大一下才能看得清,就是这么不理想。如果不需要放大就可以看得清那就很理想了嘛。回想一下上面不理想的解决方案,就是将一个大画布经过缩小装进小屏幕里,假设现在画布跟屏幕一样大,就在这个画布上作画,岂不是很合适?
所以总结起来,理想视口说白了就是理想的布局视口,通过<meta name="viewport" content="width=device-width, initial-scale=1">来设置。

将它们连在一起

关联

认识Meta viewport

<meta> 元素可提供有关页面的元信息,不会显示在页面上,可以用来告诉浏览器怎样解析页面。<meta>可以设置的东西很多,但这里只讲vieport,它是所有移动适配方案的基础。
首先meta viewport的设置格式是<meta name="viewport" content="name=value,name=value",其中name的值可设为:

  1. width:将布局视口设置为固定的值,比如375px或者device-width(设备宽度)
  2. initial-scale:设置页面的初始缩放
  3. minimum-scale:设置最小的缩小程度
  4. maximum-scale:设置最大的放大程度
  5. user-scalable:设置为no时禁用缩放

虽然只有五个值,但仍有一些值得注意的点:

设置initial-scale的影响

根据公式缩放系数 = 理想视口宽度 / 视觉视口宽度 ,如果设置了initial-scale比如为0.5,那么以iPhone6为例,iPhone6的设备宽度是375px,即理想视口宽度也为375px,所以视觉视口宽度 = 375px(理想视口宽度)/ 0.5(缩放系数)。很明显设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值

width和initial-scale共存

上面说到设置了initial-scale相当于初始化了视觉视口和布局视口,但width用于指定布局视口的大小,那么一起设置的话听谁的呢?
还是以iPhone6为例,它的尺寸是667(h) * 375(w),如果设置<meta name="viewport" content="width=400, initial-scale=1">,执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 400; 视觉视口: 400”。
这时候旋转一下设备,这时尺寸变成了667(w) * 375(h),再执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 667; 视觉视口: 667”。
结论是:width与initial-scale都会初始化布局视口,但浏览器会取其最大值。

设置理想视口

这时候再看回<meta name="viewport" content="width=device-width, initial-scale=1">,明明width=device-width和initial-scale=1都是去初始化布局视口成理想的布局视口,只写其中一个不就完了嘛,为什么要两个都一起写呢?因为有的浏览器只设置其中一个,不能保证理想视口的尺寸能随着屏幕的旋转而正确改变,所以两个一起写只是为了解决兼容性问题。

舒服地还原移动端设计图

上面说了很多理论知识,其实就是为了能有一套方案舒服地还原移动端设计图,做出一个专为移动端访问的页面。

经典的问题

  • 图片

这里的图片问题是指高清/Retina屏下图片会显示得比较模糊,这是因为我们平时使用的图片大多数是png、jpg这样格式的图片,它们称作是位图图像(bitmap),是由一个个像素点构成,缩放会失真。上面讲像素的时候说过,这种高清/Retina屏DPR大于一,则一像素横跨了多个设备像素,而位图图像需要一个像素点对应一个设备像素才清晰。所以假设一张100 x 100的图片放在普通屏上看是清晰的,放到高清/Retina屏上就会显得比较模糊,那是因为本来100 x 100的图片在普通屏上图片像素与设备像素一一对应,而到了高清/Retina屏上一个图片像素却要对应多个设备像素,这样一来看起来图片就比较模糊。
图片问题
如图所示,如果一个图片像素要对应多个设备像素的话,那这些设备像素只能显示成跟这个图片像素差不多的颜色,导致看起来会模糊。
既然知道了问题产生的原因,那解决方法也很简单,位图图像需要一个像素点对应一个设备像素才清晰嘛,那就本来是100 x 100的图片在DPR为1的屏幕上显示清晰,在DPR为2的屏幕上显示模糊,那就在DPR为2的屏幕上放200 x 200的图好了,这样就一一对应了。

  • 1px边框

1px边框
“你看看设计图这根线是很细的,为什么你实现出来那么粗,看起来很劣质的感觉。”
没道理呀,设计图量的是1px,css写的也是1px,怎么会粗了呢?一般设计师出图的时候,都会按照一个尺寸作为标准来出图,比如按照iPhone6的尺寸出图,就是一张750px宽的设计图,这个750px其实就是iPhone6的设备像素,在测量设计图时量到的1px其实是1设备像素,而当我们设置<meta name="viewport" content="width=device-width, initial-scale=1">时,布局视口等于理想视口等于375px,并且由于iPhone6的DPR为2,写css时的1px对应的是2设备像素,所以看起来会粗一点。
那么只要写0.5px就是对应1设备像素了嘛。是的,道理是这么说,但是很多浏览器并不支持0.5px的写法,导致显示不出来,但不要紧,网上很多方法解决这个问题的方法就不细说了,这里只是讲清楚1px边框问题产生的原因。

还原设计图

因为PC端屏幕一般都会比设计图尺寸要大,所以只需要居中固定一个内容区用于显示设计图的内容,其余多出的地方留白即可。而移动端屏幕有大有小,设计图一般会以一款机型为标准来出图,比如说iPhone6的尺寸,如果不经处理直接量设计图就开干会出现什么问题呢?
对比
(从左到右为iPhone4、iPhone6、iPhone plus)
可以看到以iPhone6为标准出的设计图测量出来350px x 350px的元素在iPhone6上写width: 350px;height: 350px;是刚刚好的,左右的间隙各有10px,但小一点的屏幕iPhone4横向滚动条都出来了,而plus左右间隙明显比10px大很多,这样一来不同尺寸的屏幕出来的效果跟设计图的效果就会有不同程度的出入,这并不是我们想要的,我们想要的是不同尺寸的屏幕显示的效果与设计图比例是一致的。
既然想要的是不同屏幕尺寸显示的比例与设计图一致,那么显然适配方案就是等比缩放
(以下代码都是为了讲述原理,没有过多的细节考虑与测试,不能用于生产环境)

  • viewport方案

说到缩放,首先想到的当然是initial-scale。回想一下initial-scale的作用:设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值。那么我们是不是可以以设计图为基准等比缩放布局视口从而适配呢?

<script>
    const scale = window.screen.width / 750
    document.write(`<meta name="viewport" content="initial-scale=${scale}">`)
</script>

这种方式进行适配优点是简单粗暴,缺点是太简单粗暴了,因为viewport的设置是影响全局的,这样一来虽然可以直接将设计图量得的尺寸写到css上,但如果有一些需要地方不需要等比缩放而需要设置固定尺寸,比如要求在不同尺寸屏幕上显示固定大小的文字,或者你引进了一个库,里面的有样式你也不知道人家是按照怎样的适配方案进行适配的,那么到了你的项目里由于全局的viewport缩放,可能会影响到这个库的显示效果。

  • rem方案

不同于px是固定尺寸单位,rem是相对单位,相对于html标签字体大小的单位。比如html标签的font-size为100px,那么1rem就等于100px。借助rem这个相对单位我们同样可以达到等比缩放的效果。

  • 这个方案不需要对viewport进行缩放,所以首先按照惯例我们让布局视口等于理想视口:<meta name="viewport" content="width=device-width, initial-scale=1">
  • 还是以iPhone6的设备像素为标准的设计图,宽是750px,假设以设计图为标准的html标签的font-size为100px,所以1rem = 100px,那么这个设计图总宽就有7.5rem
  • 以总宽是7.5rem的设计图为标准,则不同屏幕尺寸的总宽应该也是7.5rem,由于上面设置了布局视口等于理想视口,所以以iPhone6为例,iPhone6的布局视口等于理想视口,则它的布局视口为375px(也就是总宽7.5rem),现在只需要解决在布局视口为375px的情况下,html的font-size需要设置多少。很简单,html font-size * 7.5 = 375,那么font-size为50px。
  • 拓展到其他屏幕document.documentElement.style.fontSize = `${document.documentElement.clientWidth / 7.5}px`
  • 现在我们只需要测量设计图,比如设计图有一个300px的元素,那我们写css的时候就写成3rem(由于以1rem = 100px为基准,所以这里300px / 100即可)

使用这个方案,我们只对需要等比缩放的元素使用rem,而要求固定尺寸的地方使用px即可,这样一来相对于viewport方案来说就比较灵活,可以按需使用而不是一刀切。不过这种方案写css的时候可能会没那么直观,成本可能会高一点点,但是借助构建工具或者less/sass可以解决,毕竟现在应该很少项目不使用这些工具的了吧。

  • 加强版rem方案

这里所说的加强版rem方案其实就是手淘的Flexible方案(也类似移动端高清、多屏适配方案),究竟加强了什么呢?那就是,通过设置viewport进而全局解决1px边框问题。
既然要通过设置viewport来解决1px边框问题,那设置这个viewport的方式肯定内有乾坤:

if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
        if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {                
            dpr = 3;
        } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
            dpr = 2;
        } else {
            dpr = 1;
        }
    } else {
        // 其他设备下,仍旧使用1倍的方案
        dpr = 1;
    }
    scale = 1 / dpr;
}

得出的scale用于设置viewport的缩放document.write(`<meta name="viewport" content="initial-scale=${scale}">`),这样一来,对于Retina屏将viewport缩放为1 / dpr最终产生的效果是,1px css像素严格等于1px 设备像素,由此解决了1px边框问题。那为什么只对iPhone进行缩放呢?请看大漠老师的文章再谈Retina下1px的解决方案
其他与rem相关的配置与上面的rem方案类似,这里就不再展开说了。
这个加强版rem方案最大的优势是解决了1px边框问题,但由此也进行了viewport的缩放,仍然会面临着上面说的viewport方案涉及到的一些影响,为此该方案会通过给html设置data-dpr

document.documentElement.setAttribute('data-dpr', dpr)

从而写css的时候可以针对不同的dpr固定设置尺寸:

.test {
    width: 1rem; 
    height: 2rem;
    font-size: 12px; 
}
[data-dpr="2"] .test {
    font-size: 13px;
}
[data-dpr="3"] .test {
    font-size: 14px;
}
  • vw方案

vw也是一个相对单位,它相对的是布局视口,1vw就是1%的布局视口宽度。其实rem方案就是在模拟vw,来看看使用vw怎么做。

  1. 还是熟悉的iPhone6标准设计图,宽750px。那么1vw = 1%视口宽度的话,按设计图来说就是100vw = 750px,则1vw = 7.5px。
  2. 设计图量得一个元素是100px,css需要写成 Xvw * 7.5 = 100,所以X就等于13.3vw。
  3. 计算的话还是交给构建工具即可,详细请看再聊移动端页面的适配

rem方案有的优势vw也有,而且也不会像rem那么绕,但就是兼容性不够rem好,长远来看vw最后会接棒rem作为移动适配的主力,因为它生来就干这个事情呢。

终于结束了

没有银弹。
全局viewport缩放方案很粗暴?但对于要求不高也不需要兼顾固定尺寸的页面,上来就全局缩放,拿起设计稿就可以写代码了。要求高又想灵活,还会怕构建的那一点点麻烦吗?rem方案走起。兼容性不需要考虑,那vw方案直白又优雅不试试看吗?方案没有优劣之分只有合适与否。
最后,如果有说得不对的地方,还望指正。

查看原文

写Bug 评论了文章 · 2019-01-07

不要再问我移动适配的问题了

“不要再问我XX的问题”系列:
一、不要再问我this的指向问题了
二、不要再问我跨域的问题了

移动端适配的问题,一般来说我们都不会去深究,因为这种东西都是配置一次就再也不用管的了,接到设计图就按照祖传套路撸就完事了。按部就班的必定只能成为活动页写手,研究透彻以后,才能成为一名专业的活动页写手嘛。

纠缠不清的关系

文章开始,我们需要来捋清楚像素、视口以及缩放之间种种藕断丝连的关系,来抽丝剥茧一波。

像素

像素我们写得多了,不就是px嘛,为什么要拿出来说呢?因为像素还不仅仅就是px。

  • 设备像素

设备像素也可以叫物理像素,由设备的屏幕决定,其实就是屏幕中控制显示的最小单位。

  • 设备独立像素

设备独立像素是一种可以被程序所控制的虚拟像素,在Web开发中对应CSS像素。

  • DPR

设备像素与设备独立像素之间的关系就是,DPR(设备像素比),设备像素比 = 设备像素 / 设备独立像素。这条公式成立的前提是,缩放比为1,原因下面讲到缩放的时候就会知道。根据这种关系,如果设备像素大于设备独立像素(DPR大于1的设备,我们常说的高清屏或者Retina屏),就会出现一个设备独立像素对应多个设备像素的情况:
图片描述

视口

遥想从前智能手机刚出的时候,很少网站去特意适配移动端,然而用户是可以直接从手机去访问PC端网站的,所以怎样显示好一个网站,无论这个网站是一个PC网站还是移动端网站,就是亟需解决的问题。所以移动端三个视口布局视口、视觉视口、理想视口横空出世,成为各种移动适配方案的基础。

  • 布局视口

布局视口是在html元素之上的容器,我们的页面就“装”在布局视口中。
想想我们常写的width:100%,这个100%是基于什么计算出来的呢?去翻资料会看到:如果某些属性被赋予一个百分值的话,它的计算值是由这个元素的包含块计算而来的。那html元素的包含块是什么呢?没错,就是我们的布局视口,它是所有CSS百分比推算的根源,如果说CSS是一支画笔,那么布局视口就是那张画布吧。这张画布有一个默认尺寸(如果没有手动去设置meta viewport),一般在768px ~ 1024px间,可以通过document.documentElement.clientWidth获取。这样一来,网页的布局就不再受限于设备的尺寸,即使是小屏幕的移动端设备中也能容得下PC网站。
布局视口

  • 视觉视口

视觉视口是指用户通过设备屏幕看到的区域,可以通过缩放来改变视觉视口的大小,并通过window.innerWidth获取。
这里有必要讲一下缩放,缩放改变的是CSS像素的大小,放大时CSS像素增大,则一个CSS像素可以跨越更多的设备像素,视觉视口会变小。什么?放大反而视觉视口变小?没错,这是因为视觉视口也是通过CSS像素度量,而放大就是使CSS像素放大,假设屏幕上本来需要200个CSS像素才能占满屏幕,由于放大,现在只需要100个CSS像素就能占满,所以视觉视口的宽就变成100px。
虽然缩放改变了CSS像素的大小,但移动端的缩放是不会改变布局视口的,所以缩放并不会影响布局,不过在PC端是会影响布局的。最直观的感受是,我们平时在移动端双指缩放网页,整个网页的布局是没有变化的,可以通过拖动来看到不同区域的东西,但是在PC端进行缩放,比如阅读时想文字大一些而对网页进行放大操作,这时字是放大了,但整个页面的布局会有所改变。那么既然与布局视口无关那还跟谁有关系呢?答案就是下面准备要讲的理想视口,它们之间的计算方式是:缩放系数 = 理想视口宽度 / 视觉视口宽度
视觉视口

  • 理想视口

理想视口是指网站在移动设备中的理想大小,这个大小就是设备的屏幕大小。
为什么需要理想视口呢?首先,先来看看现在的情况是怎么的不理想。我们在浏览一个没经过移动适配的网站时,由于布局视口在768px ~ 1024px之间,整个网站就“画”在一个这么大的“画布”上,但由于手机屏幕比“画布”小,所以需要经过缩小才能塞进手机屏幕,结果我们浏览网站的时候虽然看得见全貌,但里面的东西都变得很小,需要放大一下才能看得清,就是这么不理想。如果不需要放大就可以看得清那就很理想了嘛。回想一下上面不理想的解决方案,就是将一个大画布经过缩小装进小屏幕里,假设现在画布跟屏幕一样大,就在这个画布上作画,岂不是很合适?
所以总结起来,理想视口说白了就是理想的布局视口,通过<meta name="viewport" content="width=device-width, initial-scale=1">来设置。

将它们连在一起

关联

认识Meta viewport

<meta> 元素可提供有关页面的元信息,不会显示在页面上,可以用来告诉浏览器怎样解析页面。<meta>可以设置的东西很多,但这里只讲vieport,它是所有移动适配方案的基础。
首先meta viewport的设置格式是<meta name="viewport" content="name=value,name=value",其中name的值可设为:

  1. width:将布局视口设置为固定的值,比如375px或者device-width(设备宽度)
  2. initial-scale:设置页面的初始缩放
  3. minimum-scale:设置最小的缩小程度
  4. maximum-scale:设置最大的放大程度
  5. user-scalable:设置为no时禁用缩放

虽然只有五个值,但仍有一些值得注意的点:

设置initial-scale的影响

根据公式缩放系数 = 理想视口宽度 / 视觉视口宽度 ,如果设置了initial-scale比如为0.5,那么以iPhone6为例,iPhone6的设备宽度是375px,即理想视口宽度也为375px,所以视觉视口宽度 = 375px(理想视口宽度)/ 0.5(缩放系数)。很明显设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值

width和initial-scale共存

上面说到设置了initial-scale相当于初始化了视觉视口和布局视口,但width用于指定布局视口的大小,那么一起设置的话听谁的呢?
还是以iPhone6为例,它的尺寸是667(h) * 375(w),如果设置<meta name="viewport" content="width=400, initial-scale=1">,执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 400; 视觉视口: 400”。
这时候旋转一下设备,这时尺寸变成了667(w) * 375(h),再执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 667; 视觉视口: 667”。
结论是:width与initial-scale都会初始化布局视口,但浏览器会取其最大值。

设置理想视口

这时候再看回<meta name="viewport" content="width=device-width, initial-scale=1">,明明width=device-width和initial-scale=1都是去初始化布局视口成理想的布局视口,只写其中一个不就完了嘛,为什么要两个都一起写呢?因为有的浏览器只设置其中一个,不能保证理想视口的尺寸能随着屏幕的旋转而正确改变,所以两个一起写只是为了解决兼容性问题。

舒服地还原移动端设计图

上面说了很多理论知识,其实就是为了能有一套方案舒服地还原移动端设计图,做出一个专为移动端访问的页面。

经典的问题

  • 图片

这里的图片问题是指高清/Retina屏下图片会显示得比较模糊,这是因为我们平时使用的图片大多数是png、jpg这样格式的图片,它们称作是位图图像(bitmap),是由一个个像素点构成,缩放会失真。上面讲像素的时候说过,这种高清/Retina屏DPR大于一,则一像素横跨了多个设备像素,而位图图像需要一个像素点对应一个设备像素才清晰。所以假设一张100 x 100的图片放在普通屏上看是清晰的,放到高清/Retina屏上就会显得比较模糊,那是因为本来100 x 100的图片在普通屏上图片像素与设备像素一一对应,而到了高清/Retina屏上一个图片像素却要对应多个设备像素,这样一来看起来图片就比较模糊。
图片问题
如图所示,如果一个图片像素要对应多个设备像素的话,那这些设备像素只能显示成跟这个图片像素差不多的颜色,导致看起来会模糊。
既然知道了问题产生的原因,那解决方法也很简单,位图图像需要一个像素点对应一个设备像素才清晰嘛,那就本来是100 x 100的图片在DPR为1的屏幕上显示清晰,在DPR为2的屏幕上显示模糊,那就在DPR为2的屏幕上放200 x 200的图好了,这样就一一对应了。

  • 1px边框

1px边框
“你看看设计图这根线是很细的,为什么你实现出来那么粗,看起来很劣质的感觉。”
没道理呀,设计图量的是1px,css写的也是1px,怎么会粗了呢?一般设计师出图的时候,都会按照一个尺寸作为标准来出图,比如按照iPhone6的尺寸出图,就是一张750px宽的设计图,这个750px其实就是iPhone6的设备像素,在测量设计图时量到的1px其实是1设备像素,而当我们设置<meta name="viewport" content="width=device-width, initial-scale=1">时,布局视口等于理想视口等于375px,并且由于iPhone6的DPR为2,写css时的1px对应的是2设备像素,所以看起来会粗一点。
那么只要写0.5px就是对应1设备像素了嘛。是的,道理是这么说,但是很多浏览器并不支持0.5px的写法,导致显示不出来,但不要紧,网上很多方法解决这个问题的方法就不细说了,这里只是讲清楚1px边框问题产生的原因。

还原设计图

因为PC端屏幕一般都会比设计图尺寸要大,所以只需要居中固定一个内容区用于显示设计图的内容,其余多出的地方留白即可。而移动端屏幕有大有小,设计图一般会以一款机型为标准来出图,比如说iPhone6的尺寸,如果不经处理直接量设计图就开干会出现什么问题呢?
对比
(从左到右为iPhone4、iPhone6、iPhone plus)
可以看到以iPhone6为标准出的设计图测量出来350px x 350px的元素在iPhone6上写width: 350px;height: 350px;是刚刚好的,左右的间隙各有10px,但小一点的屏幕iPhone4横向滚动条都出来了,而plus左右间隙明显比10px大很多,这样一来不同尺寸的屏幕出来的效果跟设计图的效果就会有不同程度的出入,这并不是我们想要的,我们想要的是不同尺寸的屏幕显示的效果与设计图比例是一致的。
既然想要的是不同屏幕尺寸显示的比例与设计图一致,那么显然适配方案就是等比缩放
(以下代码都是为了讲述原理,没有过多的细节考虑与测试,不能用于生产环境)

  • viewport方案

说到缩放,首先想到的当然是initial-scale。回想一下initial-scale的作用:设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值。那么我们是不是可以以设计图为基准等比缩放布局视口从而适配呢?

<script>
    const scale = window.screen.width / 750
    document.write(`<meta name="viewport" content="initial-scale=${scale}">`)
</script>

这种方式进行适配优点是简单粗暴,缺点是太简单粗暴了,因为viewport的设置是影响全局的,这样一来虽然可以直接将设计图量得的尺寸写到css上,但如果有一些需要地方不需要等比缩放而需要设置固定尺寸,比如要求在不同尺寸屏幕上显示固定大小的文字,或者你引进了一个库,里面的有样式你也不知道人家是按照怎样的适配方案进行适配的,那么到了你的项目里由于全局的viewport缩放,可能会影响到这个库的显示效果。

  • rem方案

不同于px是固定尺寸单位,rem是相对单位,相对于html标签字体大小的单位。比如html标签的font-size为100px,那么1rem就等于100px。借助rem这个相对单位我们同样可以达到等比缩放的效果。

  • 这个方案不需要对viewport进行缩放,所以首先按照惯例我们让布局视口等于理想视口:<meta name="viewport" content="width=device-width, initial-scale=1">
  • 还是以iPhone6的设备像素为标准的设计图,宽是750px,假设以设计图为标准的html标签的font-size为100px,所以1rem = 100px,那么这个设计图总宽就有7.5rem
  • 以总宽是7.5rem的设计图为标准,则不同屏幕尺寸的总宽应该也是7.5rem,由于上面设置了布局视口等于理想视口,所以以iPhone6为例,iPhone6的布局视口等于理想视口,则它的布局视口为375px(也就是总宽7.5rem),现在只需要解决在布局视口为375px的情况下,html的font-size需要设置多少。很简单,html font-size * 7.5 = 375,那么font-size为50px。
  • 拓展到其他屏幕document.documentElement.style.fontSize = `${document.documentElement.clientWidth / 7.5}px`
  • 现在我们只需要测量设计图,比如设计图有一个300px的元素,那我们写css的时候就写成3rem(由于以1rem = 100px为基准,所以这里300px / 100即可)

使用这个方案,我们只对需要等比缩放的元素使用rem,而要求固定尺寸的地方使用px即可,这样一来相对于viewport方案来说就比较灵活,可以按需使用而不是一刀切。不过这种方案写css的时候可能会没那么直观,成本可能会高一点点,但是借助构建工具或者less/sass可以解决,毕竟现在应该很少项目不使用这些工具的了吧。

  • 加强版rem方案

这里所说的加强版rem方案其实就是手淘的Flexible方案(也类似移动端高清、多屏适配方案),究竟加强了什么呢?那就是,通过设置viewport进而全局解决1px边框问题。
既然要通过设置viewport来解决1px边框问题,那设置这个viewport的方式肯定内有乾坤:

if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
        if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {                
            dpr = 3;
        } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
            dpr = 2;
        } else {
            dpr = 1;
        }
    } else {
        // 其他设备下,仍旧使用1倍的方案
        dpr = 1;
    }
    scale = 1 / dpr;
}

得出的scale用于设置viewport的缩放document.write(`<meta name="viewport" content="initial-scale=${scale}">`),这样一来,对于Retina屏将viewport缩放为1 / dpr最终产生的效果是,1px css像素严格等于1px 设备像素,由此解决了1px边框问题。那为什么只对iPhone进行缩放呢?请看大漠老师的文章再谈Retina下1px的解决方案
其他与rem相关的配置与上面的rem方案类似,这里就不再展开说了。
这个加强版rem方案最大的优势是解决了1px边框问题,但由此也进行了viewport的缩放,仍然会面临着上面说的viewport方案涉及到的一些影响,为此该方案会通过给html设置data-dpr

document.documentElement.setAttribute('data-dpr', dpr)

从而写css的时候可以针对不同的dpr固定设置尺寸:

.test {
    width: 1rem; 
    height: 2rem;
    font-size: 12px; 
}
[data-dpr="2"] .test {
    font-size: 13px;
}
[data-dpr="3"] .test {
    font-size: 14px;
}
  • vw方案

vw也是一个相对单位,它相对的是布局视口,1vw就是1%的布局视口宽度。其实rem方案就是在模拟vw,来看看使用vw怎么做。

  1. 还是熟悉的iPhone6标准设计图,宽750px。那么1vw = 1%视口宽度的话,按设计图来说就是100vw = 750px,则1vw = 7.5px。
  2. 设计图量得一个元素是100px,css需要写成 Xvw * 7.5 = 100,所以X就等于13.3vw。
  3. 计算的话还是交给构建工具即可,详细请看再聊移动端页面的适配

rem方案有的优势vw也有,而且也不会像rem那么绕,但就是兼容性不够rem好,长远来看vw最后会接棒rem作为移动适配的主力,因为它生来就干这个事情呢。

终于结束了

没有银弹。
全局viewport缩放方案很粗暴?但对于要求不高也不需要兼顾固定尺寸的页面,上来就全局缩放,拿起设计稿就可以写代码了。要求高又想灵活,还会怕构建的那一点点麻烦吗?rem方案走起。兼容性不需要考虑,那vw方案直白又优雅不试试看吗?方案没有优劣之分只有合适与否。
最后,如果有说得不对的地方,还望指正。

查看原文

写Bug 发布了文章 · 2019-01-06

不要再问我移动适配的问题了

“不要再问我XX的问题”系列:
一、不要再问我this的指向问题了
二、不要再问我跨域的问题了

移动端适配的问题,一般来说我们都不会去深究,因为这种东西都是配置一次就再也不用管的了,接到设计图就按照祖传套路撸就完事了。按部就班的必定只能成为活动页写手,研究透彻以后,才能成为一名专业的活动页写手嘛。

纠缠不清的关系

文章开始,我们需要来捋清楚像素、视口以及缩放之间种种藕断丝连的关系,来抽丝剥茧一波。

像素

像素我们写得多了,不就是px嘛,为什么要拿出来说呢?因为像素还不仅仅就是px。

  • 设备像素

设备像素也可以叫物理像素,由设备的屏幕决定,其实就是屏幕中控制显示的最小单位。

  • 设备独立像素

设备独立像素是一种可以被程序所控制的虚拟像素,在Web开发中对应CSS像素。

  • DPR

设备像素与设备独立像素之间的关系就是,DPR(设备像素比),设备像素比 = 设备像素 / 设备独立像素。这条公式成立的前提是,缩放比为1,原因下面讲到缩放的时候就会知道。根据这种关系,如果设备像素大于设备独立像素(DPR大于1的设备,我们常说的高清屏或者Retina屏),就会出现一个设备独立像素对应多个设备像素的情况:
图片描述

视口

遥想从前智能手机刚出的时候,很少网站去特意适配移动端,然而用户是可以直接从手机去访问PC端网站的,所以怎样显示好一个网站,无论这个网站是一个PC网站还是移动端网站,就是亟需解决的问题。所以移动端三个视口布局视口、视觉视口、理想视口横空出世,成为各种移动适配方案的基础。

  • 布局视口

布局视口是在html元素之上的容器,我们的页面就“装”在布局视口中。
想想我们常写的width:100%,这个100%是基于什么计算出来的呢?去翻资料会看到:如果某些属性被赋予一个百分值的话,它的计算值是由这个元素的包含块计算而来的。那html元素的包含块是什么呢?没错,就是我们的布局视口,它是所有CSS百分比推算的根源,如果说CSS是一支画笔,那么布局视口就是那张画布吧。这张画布有一个默认尺寸(如果没有手动去设置meta viewport),一般在768px ~ 1024px间,可以通过document.documentElement.clientWidth获取。这样一来,网页的布局就不再受限于设备的尺寸,即使是小屏幕的移动端设备中也能容得下PC网站。
布局视口

  • 视觉视口

视觉视口是指用户通过设备屏幕看到的区域,可以通过缩放来改变视觉视口的大小,并通过window.innerWidth获取。
这里有必要讲一下缩放,缩放改变的是CSS像素的大小,放大时CSS像素增大,则一个CSS像素可以跨越更多的设备像素,视觉视口会变小。什么?放大反而视觉视口变小?没错,这是因为视觉视口也是通过CSS像素度量,而放大就是使CSS像素放大,假设屏幕上本来需要200个CSS像素才能占满屏幕,由于放大,现在只需要100个CSS像素就能占满,所以视觉视口的宽就变成100px。
虽然缩放改变了CSS像素的大小,但移动端的缩放是不会改变布局视口的,所以缩放并不会影响布局,不过在PC端是会影响布局的。最直观的感受是,我们平时在移动端双指缩放网页,整个网页的布局是没有变化的,可以通过拖动来看到不同区域的东西,但是在PC端进行缩放,比如阅读时想文字大一些而对网页进行放大操作,这时字是放大了,但整个页面的布局会有所改变。那么既然与布局视口无关那还跟谁有关系呢?答案就是下面准备要讲的理想视口,它们之间的计算方式是:缩放系数 = 理想视口宽度 / 视觉视口宽度
视觉视口

  • 理想视口

理想视口是指网站在移动设备中的理想大小,这个大小就是设备的屏幕大小。
为什么需要理想视口呢?首先,先来看看现在的情况是怎么的不理想。我们在浏览一个没经过移动适配的网站时,由于布局视口在768px ~ 1024px之间,整个网站就“画”在一个这么大的“画布”上,但由于手机屏幕比“画布”小,所以需要经过缩小才能塞进手机屏幕,结果我们浏览网站的时候虽然看得见全貌,但里面的东西都变得很小,需要放大一下才能看得清,就是这么不理想。如果不需要放大就可以看得清那就很理想了嘛。回想一下上面不理想的解决方案,就是将一个大画布经过缩小装进小屏幕里,假设现在画布跟屏幕一样大,就在这个画布上作画,岂不是很合适?
所以总结起来,理想视口说白了就是理想的布局视口,通过<meta name="viewport" content="width=device-width, initial-scale=1">来设置。

将它们连在一起

关联

认识Meta viewport

<meta> 元素可提供有关页面的元信息,不会显示在页面上,可以用来告诉浏览器怎样解析页面。<meta>可以设置的东西很多,但这里只讲vieport,它是所有移动适配方案的基础。
首先meta viewport的设置格式是<meta name="viewport" content="name=value,name=value",其中name的值可设为:

  1. width:将布局视口设置为固定的值,比如375px或者device-width(设备宽度)
  2. initial-scale:设置页面的初始缩放
  3. minimum-scale:设置最小的缩小程度
  4. maximum-scale:设置最大的放大程度
  5. user-scalable:设置为no时禁用缩放

虽然只有五个值,但仍有一些值得注意的点:

设置initial-scale的影响

根据公式缩放系数 = 理想视口宽度 / 视觉视口宽度 ,如果设置了initial-scale比如为0.5,那么以iPhone6为例,iPhone6的设备宽度是375px,即理想视口宽度也为375px,所以视觉视口宽度 = 375px(理想视口宽度)/ 0.5(缩放系数)。很明显设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值

width和initial-scale共存

上面说到设置了initial-scale相当于初始化了视觉视口和布局视口,但width用于指定布局视口的大小,那么一起设置的话听谁的呢?
还是以iPhone6为例,它的尺寸是667(h) * 375(w),如果设置<meta name="viewport" content="width=400, initial-scale=1">,执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 400; 视觉视口: 400”。
这时候旋转一下设备,这时尺寸变成了667(w) * 375(h),再执行一下console.log(`布局视口: ${document.documentElement.clientWidth}; 视觉视口: ${window.innerWidth}`)会得到“布局视口: 667; 视觉视口: 667”。
结论是:width与initial-scale都会初始化布局视口,但浏览器会取其最大值。

设置理想视口

这时候再看回<meta name="viewport" content="width=device-width, initial-scale=1">,明明width=device-width和initial-scale=1都是去初始化布局视口成理想的布局视口,只写其中一个不就完了嘛,为什么要两个都一起写呢?因为有的浏览器只设置其中一个,不能保证理想视口的尺寸能随着屏幕的旋转而正确改变,所以两个一起写只是为了解决兼容性问题。

舒服地还原移动端设计图

上面说了很多理论知识,其实就是为了能有一套方案舒服地还原移动端设计图,做出一个专为移动端访问的页面。

经典的问题

  • 图片

这里的图片问题是指高清/Retina屏下图片会显示得比较模糊,这是因为我们平时使用的图片大多数是png、jpg这样格式的图片,它们称作是位图图像(bitmap),是由一个个像素点构成,缩放会失真。上面讲像素的时候说过,这种高清/Retina屏DPR大于一,则一像素横跨了多个设备像素,而位图图像需要一个像素点对应一个设备像素才清晰。所以假设一张100 x 100的图片放在普通屏上看是清晰的,放到高清/Retina屏上就会显得比较模糊,那是因为本来100 x 100的图片在普通屏上图片像素与设备像素一一对应,而到了高清/Retina屏上一个图片像素却要对应多个设备像素,这样一来看起来图片就比较模糊。
图片问题
如图所示,如果一个图片像素要对应多个设备像素的话,那这些设备像素只能显示成跟这个图片像素差不多的颜色,导致看起来会模糊。
既然知道了问题产生的原因,那解决方法也很简单,位图图像需要一个像素点对应一个设备像素才清晰嘛,那就本来是100 x 100的图片在DPR为1的屏幕上显示清晰,在DPR为2的屏幕上显示模糊,那就在DPR为2的屏幕上放200 x 200的图好了,这样就一一对应了。

  • 1px边框

1px边框
“你看看设计图这根线是很细的,为什么你实现出来那么粗,看起来很劣质的感觉。”
没道理呀,设计图量的是1px,css写的也是1px,怎么会粗了呢?一般设计师出图的时候,都会按照一个尺寸作为标准来出图,比如按照iPhone6的尺寸出图,就是一张750px宽的设计图,这个750px其实就是iPhone6的设备像素,在测量设计图时量到的1px其实是1设备像素,而当我们设置<meta name="viewport" content="width=device-width, initial-scale=1">时,布局视口等于理想视口等于375px,并且由于iPhone6的DPR为2,写css时的1px对应的是2设备像素,所以看起来会粗一点。
那么只要写0.5px就是对应1设备像素了嘛。是的,道理是这么说,但是很多浏览器并不支持0.5px的写法,导致显示不出来,但不要紧,网上很多方法解决这个问题的方法就不细说了,这里只是讲清楚1px边框问题产生的原因。

还原设计图

因为PC端屏幕一般都会比设计图尺寸要大,所以只需要居中固定一个内容区用于显示设计图的内容,其余多出的地方留白即可。而移动端屏幕有大有小,设计图一般会以一款机型为标准来出图,比如说iPhone6的尺寸,如果不经处理直接量设计图就开干会出现什么问题呢?
对比
(从左到右为iPhone4、iPhone6、iPhone plus)
可以看到以iPhone6为标准出的设计图测量出来350px x 350px的元素在iPhone6上写width: 350px;height: 350px;是刚刚好的,左右的间隙各有10px,但小一点的屏幕iPhone4横向滚动条都出来了,而plus左右间隙明显比10px大很多,这样一来不同尺寸的屏幕出来的效果跟设计图的效果就会有不同程度的出入,这并不是我们想要的,我们想要的是不同尺寸的屏幕显示的效果与设计图比例是一致的。
既然想要的是不同屏幕尺寸显示的比例与设计图一致,那么显然适配方案就是等比缩放
(以下代码都是为了讲述原理,没有过多的细节考虑与测试,不能用于生产环境)

  • viewport方案

说到缩放,首先想到的当然是initial-scale。回想一下initial-scale的作用:设置了initial-scale就相当于初始化了视觉视口,而且会将布局视口初始化为这个视觉视口的值。那么我们是不是可以以设计图为基准等比缩放布局视口从而适配呢?

<script>
    const scale = window.screen.width / 750
    document.write(`<meta name="viewport" content="initial-scale=${scale}">`)
</script>

这种方式进行适配优点是简单粗暴,缺点是太简单粗暴了,因为viewport的设置是影响全局的,这样一来虽然可以直接将设计图量得的尺寸写到css上,但如果有一些需要地方不需要等比缩放而需要设置固定尺寸,比如要求在不同尺寸屏幕上显示固定大小的文字,或者你引进了一个库,里面的有样式你也不知道人家是按照怎样的适配方案进行适配的,那么到了你的项目里由于全局的viewport缩放,可能会影响到这个库的显示效果。

  • rem方案

不同于px是固定尺寸单位,rem是相对单位,相对于html标签字体大小的单位。比如html标签的font-size为100px,那么1rem就等于100px。借助rem这个相对单位我们同样可以达到等比缩放的效果。

  • 这个方案不需要对viewport进行缩放,所以首先按照惯例我们让布局视口等于理想视口:<meta name="viewport" content="width=device-width, initial-scale=1">
  • 还是以iPhone6的设备像素为标准的设计图,宽是750px,假设以设计图为标准的html标签的font-size为100px,所以1rem = 100px,那么这个设计图总宽就有7.5rem
  • 以总宽是7.5rem的设计图为标准,则不同屏幕尺寸的总宽应该也是7.5rem,由于上面设置了布局视口等于理想视口,所以以iPhone6为例,iPhone6的布局视口等于理想视口,则它的布局视口为375px(也就是总宽7.5rem),现在只需要解决在布局视口为375px的情况下,html的font-size需要设置多少。很简单,html font-size * 7.5 = 375,那么font-size为50px。
  • 拓展到其他屏幕document.documentElement.style.fontSize = `${document.documentElement.clientWidth / 7.5}px`
  • 现在我们只需要测量设计图,比如设计图有一个300px的元素,那我们写css的时候就写成3rem(由于以1rem = 100px为基准,所以这里300px / 100即可)

使用这个方案,我们只对需要等比缩放的元素使用rem,而要求固定尺寸的地方使用px即可,这样一来相对于viewport方案来说就比较灵活,可以按需使用而不是一刀切。不过这种方案写css的时候可能会没那么直观,成本可能会高一点点,但是借助构建工具或者less/sass可以解决,毕竟现在应该很少项目不使用这些工具的了吧。

  • 加强版rem方案

这里所说的加强版rem方案其实就是手淘的Flexible方案(也类似移动端高清、多屏适配方案),究竟加强了什么呢?那就是,通过设置viewport进而全局解决1px边框问题。
既然要通过设置viewport来解决1px边框问题,那设置这个viewport的方式肯定内有乾坤:

if (!dpr && !scale) {
    var isAndroid = win.navigator.appVersion.match(/android/gi);
    var isIPhone = win.navigator.appVersion.match(/iphone/gi);
    var devicePixelRatio = win.devicePixelRatio;
    if (isIPhone) {
        if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {                
            dpr = 3;
        } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
            dpr = 2;
        } else {
            dpr = 1;
        }
    } else {
        // 其他设备下,仍旧使用1倍的方案
        dpr = 1;
    }
    scale = 1 / dpr;
}

得出的scale用于设置viewport的缩放document.write(`<meta name="viewport" content="initial-scale=${scale}">`),这样一来,对于Retina屏将viewport缩放为1 / dpr最终产生的效果是,1px css像素严格等于1px 设备像素,由此解决了1px边框问题。那为什么只对iPhone进行缩放呢?请看大漠老师的文章再谈Retina下1px的解决方案
其他与rem相关的配置与上面的rem方案类似,这里就不再展开说了。
这个加强版rem方案最大的优势是解决了1px边框问题,但由此也进行了viewport的缩放,仍然会面临着上面说的viewport方案涉及到的一些影响,为此该方案会通过给html设置data-dpr

document.documentElement.setAttribute('data-dpr', dpr)

从而写css的时候可以针对不同的dpr固定设置尺寸:

.test {
    width: 1rem; 
    height: 2rem;
    font-size: 12px; 
}
[data-dpr="2"] .test {
    font-size: 13px;
}
[data-dpr="3"] .test {
    font-size: 14px;
}
  • vw方案

vw也是一个相对单位,它相对的是布局视口,1vw就是1%的布局视口宽度。其实rem方案就是在模拟vw,来看看使用vw怎么做。

  1. 还是熟悉的iPhone6标准设计图,宽750px。那么1vw = 1%视口宽度的话,按设计图来说就是100vw = 750px,则1vw = 7.5px。
  2. 设计图量得一个元素是100px,css需要写成 Xvw * 7.5 = 100,所以X就等于13.3vw。
  3. 计算的话还是交给构建工具即可,详细请看再聊移动端页面的适配

rem方案有的优势vw也有,而且也不会像rem那么绕,但就是兼容性不够rem好,长远来看vw最后会接棒rem作为移动适配的主力,因为它生来就干这个事情呢。

终于结束了

没有银弹。
全局viewport缩放方案很粗暴?但对于要求不高也不需要兼顾固定尺寸的页面,上来就全局缩放,拿起设计稿就可以写代码了。要求高又想灵活,还会怕构建的那一点点麻烦吗?rem方案走起。兼容性不需要考虑,那vw方案直白又优雅不试试看吗?方案没有优劣之分只有合适与否。
最后,如果有说得不对的地方,还望指正。

查看原文

赞 268 收藏 212 评论 15

写Bug 赞了文章 · 2018-11-02

关于 vue 弹窗组件的一些感想

最近是用 vue 开发了一套组件库 vue-carbon , 在开发过程对对于组件化的开发有一些感想,于是开始记录下这些。

弹窗组件一直是 web 开发中必备的,使用频率相当高,最常见的莫过于 alert,confirm,prompt .. 这些(曾经我们都会用alert来调试程序), 不同的组件库对于弹窗的处理也是不一样的。在开发时需要考虑一下三点:

  1. 进入和弹出的动画效果。

  2. z-index 的控制

  3. overlay 遮盖层

关于动画

vue 对于动画的处理相对简单,给组件加入css transition 动画即可

<template>
<div class="modal" transition="modal-scale">
    <!--省略其它内容-->
</div>
</template>
<script>
// ...
</script>
<style>
.modal-scale-transition{
  transition: transform,opacity .3s ease;
}

.modal-scale-enter,
.modal-scale-leave {
    opacity: 0;
}

.modal-scale-enter {
  transform: scale(1.1);
}
.modal-scale-leave {
  transform: scale(0.8);
}
</style>

外部可以由使用者自行控制,使用 v-if 或是 v-show 控制显示

z-index 的控制

关于z-index的控制,需要完成以下几点

  1. 保证弹出框的 z-index 足够高能使 其再最外层

  2. 后弹出的弹出框的 z-index 要比之前弹出的要高

要满足以上两点, 我们需要以下代码实现

const zIndex = 20141223  // 先预设较高值

const getZIndex = function () {
    return zIndex++ // 每次获取之后 zindex 自动增加
}

然后绑定把 z-index 在组件上

<template>
<div class="modal" :style="{'z-index': zIndex}" transition="modal-scale">
    <!--省略其它内容-->
</div>
</template>
<script>
export default {
    data () {
        return {
            zIndex: getZIndex()
        }
    }
}
</script>

overlay 遮盖层的控制

遮盖层是弹窗组件中最难处理的部分, 一个完美的遮盖层的控制需要完成以下几点:

  1. 遮盖层和弹出层之间的动画需要并行

  2. 遮盖层的 z-index 要较小与弹出层

  3. 遮盖层的弹出时需要组件页面滚动

  4. 点击遮盖层需要给予弹出层反馈

  5. 保证整个页面最多只能有一个遮盖层(多个叠在一起会使遮盖层颜色加深)

为了处理这些问题,也保证所有的弹出框组件不用每一个都解决,所以决定利用 vue 的 mixins 机制,将这些弹出层的公共逻辑封装层一个 mixin ,每个弹出框组件直接引用就好。

vue-popup-mixin

明确了上述所有的问题,开始开发 mixin, 首先需要一个 overlay (遮盖层组件) ;

<template>
  <div class="overlay" @click="handlerClick" @touchmove="prevent" :style="style" transition="overlay-fade"></div>
</template>
<script>
export default {
  props: {
    onClick: {
      type: Function
    },
    opacity: {
      type: Number,
      default: 0.4
    },
    color: {
      type: String,
      default: '#000'
    }
  },
  computed: {
    style () {
      return {
        'opacity': this.opacity,
        'background-color': this.color
      }
    }
  },
  methods: {
    prevent (event) {
      event.preventDefault()
      event.stopPropagation()
    },
    handlerClick () {
      if (this.onClick) {
        this.onClick()
      }
    }
  }
}
</script>
<style lang="less">
.overlay {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  background-color: #000;
  opacity: .4;
  z-index: 1000;
}


.overlay-fade-transition {
  transition: all .3s linear;
  &.overlay-fade-enter,
  &.overlay-fade-leave {
    opacity: 0 !important;
  }
}
</style>

然后 需要一个 js 来管理 overlay 的显示和隐藏。

import Vue from 'vue'
import overlayOpt from '../overlay'  // 引入 overlay 组件
const Overlay = Vue.extend(overlayOpt)

const getDOM = function (dom) {
  if (dom.nodeType === 3) {
    dom = dom.nextElementSibling || dom.nextSibling
    getDOM(dom)
  }
  return dom
}

// z-index 控制
const zIndex = 20141223  

const getZIndex = function () {
    return zIndex++ 
}
// 管理
const PopupManager = {
  instances: [],  // 用来储存所有的弹出层实例
  overlay: false,
  // 弹窗框打开时 调用此方法
  open (instance) {
    if (!instance || this.instances.indexOf(instance) !== -1) return
    
    // 当没有遮盖层时,显示遮盖层
    if (this.instances.length === 0) {
      this.showOverlay(instance.overlayColor, instance.overlayOpacity)
    }
    this.instances.push(instance) // 储存打开的弹出框组件
    this.changeOverlayStyle() // 控制不同弹出层 透明度和颜色
    
    // 给弹出层加上z-index
    const dom = getDOM(instance.$el)
    dom.style.zIndex = getZIndex()
  },
  // 弹出框关闭方法
  close (instance) {
    let index = this.instances.indexOf(instance)
    if (index === -1) return
    
    Vue.nextTick(() => {
      this.instances.splice(index, 1)
      
      // 当页面上没有弹出层了就关闭遮盖层
      if (this.instances.length === 0) {
        this.closeOverlay()
      }
      this.changeOverlayStyle()
    })
  },
  showOverlay (color, opacity) {
    let overlay = this.overlay = new Overlay({
      el: document.createElement('div')
    })
    const dom = getDOM(overlay.$el)
    dom.style.zIndex = getZIndex()
    overlay.color = color
    overlay.opacity = opacity
    overlay.onClick = this.handlerOverlayClick.bind(this)
    overlay.$appendTo(document.body)

    // 禁止页面滚动
    this.bodyOverflow = document.body.style.overflow
    document.body.style.overflow = 'hidden'
  },
  closeOverlay () {
    if (!this.overlay) return
    document.body.style.overflow = this.bodyOverflow
    let overlay = this.overlay
    this.overlay = null
    overlay.$remove(() => {
      overlay.$destroy()
    })
  },
  changeOverlayStyle () {
    if (!this.overlay || this.instances.length === 0) return
    const instance = this.instances[this.instances.length - 1]
    this.overlay.color = instance.overlayColor
    this.overlay.opacity = instance.overlayOpacity
  },
  // 遮盖层点击处理,会自动调用 弹出层的 overlayClick 方法
  handlerOverlayClick () {
    if (this.instances.length === 0) return
    const instance = this.instances[this.instances.length - 1]
    if (instance.overlayClick) {
      instance.overlayClick()
    }
  }
}

window.addEventListener('keydown', function (event) {
  if (event.keyCode === 27) { // ESC
    if (PopupManager.instances.length > 0) {
      const topInstance = PopupManager.instances[PopupManager.instances.length - 1]
      if (!topInstance) return
      if (topInstance.escPress) {
        topInstance.escPress()
      }
    }
  }
})

export default PopupManager

最后再封装成一个 mixin

import PopupManager from './popup-manager'

export default {
  props: {
    show: {
      type: Boolean,
      default: false
    },
    // 是否显示遮盖层
    overlay: {
      type: Boolean,
      default: true
    },
    overlayOpacity: {
      type: Number,
      default: 0.4
    },
    overlayColor: {
      type: String,
      default: '#000'
    }
  },
  // 组件被挂载时会判断show的值开控制打开
  attached () {
    if (this.show && this.overlay) {
      PopupManager.open(this)
    }
  },
  // 组件被移除时关闭
  detached () {
    PopupManager.close(this)
  },
  watch: {
    show (val) {
      // 修改 show 值是调用对于的打开关闭方法
      if (val && this.overlay) {
        PopupManager.open(this)
      } else {
        PopupManager.close(this)
      }
    }
  },
  beforeDestroy () {
    PopupManager.close(this)
  }
}

使用

以上所有的代码就完成了所有弹出层的共有逻辑, 使用时只需要当做一个mixin来加载即可

<template>
  <div class="dialog"
    v-show="show"
    transition="dialog-fade">
    <div class="dialog-content">
      <slot></slot>
    </div>
  </div>
</template>

<style>
  .dialog {
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    position: fixed;
    width: 90%;
  }

  .dialog-content {
    background: #fff;
    border-radius: 8px;
    padding: 20px;
    text-align: center;
  }

  .dialog-fade-transition {
    transition: opacity .3s linear;
  }

  .dialog-fade-enter,
  .dialog-fade-leave {
    opacity: 0;
  }
</style>

<script>
import Popup from '../src'

export default {
  mixins: [Popup],
  methods: {
    // 响应 overlay事件
    overlayClick () {
      this.show = false
    },
    // 响应 esc 按键事件
    escPress () {
      this.show = false
    }
  }
}
</script>

项目地址 vue-popup-mixin

查看原文

赞 14 收藏 93 评论 14

写Bug 赞了文章 · 2018-09-03

一眼看穿👀JS继承

继承

我们知道JS是OO编程,自然少不了OO编程所拥有的特性,学习完原型之后,我们趁热打铁,来聊聊OO编程三大特性之一——继承。

继承这个词应该比较好理解,我们耳熟能详的,继承财产,继承家业等,他们的前提是有个继承人,然后你是继承者,这样才有继承而言。没错,JS中的继承正如你所理解的一样,也是成对出现的。
继承就是将对象的属性复制一份给需要继承的对象

OO语言支持两种继承方式:接口继承和实现继承,其中接口继承只继承方法签名,而实现继承则继承实际的方法。由于ECMAScript中的函数没有签名,因此无法实现接口继承,只支持实现继承,而继承的主要方式,是通过原型链实现的,要理解原型链,首先要知道什么是原型,不懂的小伙伴,可以看这篇博文一眼看穿👀JS原型
其实继承说白了就是
①它上面必须有个父级
②且它获取了这个父级的所有实例和方法

这里普及一个小概念,上文提到的没有签名,第一次看这个字眼也不是很懂,搜索了一下,觉得这个说法还是比较认可的。

没有签名
我们知道JS是弱类型语言,它的参数可以由0个或多个值的数组来表示,我们可以为JS函数命名参数,这个做法只是为了方便,但不是必须,也就是说,我们命不命名参数和传不传参数没有必然联系,既可以命名参数,但不传(此时默认为undefined),也可以不命名参数,但传参数,这种写法在JS中是合法的,反之,强类型语言,对这个要求就非常严格,定义了几个参数就一定要传几个参数下去。命名参数这块必须要求事先创建函数签名,而将来的调用也必须与该签名一致。(也就是说定义几个参数就要传几个下去)**,而js没有这些条条框框,解析器不会验证命名参数,所以说js没有签名。

举个🌰

function JSNoSignature () {  
  console.log("first params" + arguments[0] + "," + "second params" + arguments[1]);
}
JSNoSignature ("hello", "world");

这个🌰很明显了。命名参数为空,但我们依旧可以传参,调用该方法。所谓的参数类型,参数个数,参数位置,出入参数,js统统不关心,它所有的值都被放到arguments中了,需要返回值的话直接return,不用声明,这就叫做js没有签名。

原型链

什么是原型链呢?字面上也很好理解,就是将所有的原型串在一起就叫做原型链。当然这个解释只是为了方便理解罢了,原型链是作为实现继承的主要方法,其基本思想是利用原型一个引用类型继承另一个引用类型的属性和方法。我们知道每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型的内部指针。此时,如果我们让原型对象等于另外一个类型的实例,那么此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针,这种周而复始的连接关系,就构成了实例与原型的链条,这就是原型链。

一句话说白了,就是实例→原型→实例→原型→实例... 连接下去就是原型链。

我觉得继承就是原型链的一种表现形式

我们知道了原型链后,要知道他如何去使用,ECMA提供一套原型链的基本模式基本模式👇

原型链的基本模式

// 创建一个父类
function FatherType(){
    this.fatherName = '命名最头痛';
}

FatherType.prototype.getFatherValue = function() {
    return this.fatherName;
}

function ChildType(){
    this.childName = 'George';
}

// 继承了FatherType,即将一个实例赋值给函数原型,我们就说这个原型继承了另一个函数实例
// 将子类的原型指向这个父类的实例
ChildType.prototype = new FatherType();

ChildType.prototype.getChildValue = function() {
    return this.childName;
}

let instance = new ChildType();
console.log(instance.getFatherValue()); // 命名最头痛
 

调用instance.getFatherValue()时会经历三个搜索步骤
①搜索实例
②搜索ChildType.prototype
③搜索FatherType.prototype,此时在这步找到该方法,在找不到属性或方法的情况下,搜索过程总是要一环一环地向前行到原型链末端才会停下来。

此时的原型链是instance → ChildType.prototype → FatherType.prototype
执行instance.getFatherValue()后,getFatherValue里面的this是ChildType,此时ChildType会根据原型链去找fatherName属性,最终在FatherType中找到。
此时instance.constructor是指向FatherType的

默认的原型

所有的引用类型默认都继承了Object,而这个继承也是通过原型链实现的,因此,所有函数的默认原型都是Object的实例,因此默认原型都会包含一个内部指针,指向Object。prototype,这也就是所有自定义类型都会继承toString(),valueOf()等默认方法的根本原因。
Array类型也是继承了Object类型的。
因此,我们可以总结一下,在原型链的最顶端就是Object类型,所有的函数默认都继承了Object中的属性。

原型和实例关系的确认

isPrototypeOf方法

一眼看穿👀JS原型中我们有提到过isPrototypeOf方法可以用于判断这个实例的指针是否指向这个原型,这一章我们学习了原型链,这里做个补充,按照原型链的先后顺序,isPrototypeOf方法可以用于判断这个实例是否属于这个原型的

依旧用上面那个🌰
// 注意,这里用的是原型,Object.prototype,FatherType.prototype,ChildType.prototype
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(FatherType.prototype.isPrototypeOf(instance)); // true
console.log(ChildType.prototype.isPrototypeOf(instance)); // true

下面再介绍另一种方法,通过instanceof操作符,也可以确定原型和实例之间的关系

instanceof操作符

instanceof操作符是用来测试原型链中的构造函数是否有这个实例

console.log(instance instanceof Object); // true
console.log(instance instanceof FatherType); // true
console.log(instance instanceof ChildType); // true

与isPrototypeOf不同的是,前者用的是原型,后者用的是构造函数

定义方法时需要注意的问题

①给原型添加方法的代码一定要放在继承之后,这是因为,在继承的时候被继承者会覆盖掉继承者原型上的所有方法

function FatherType(){
    this.fatherName = '命名最头痛';
}

FatherType.prototype.getFatherValue = function() {
    return this.fatherName;
}

function ChildType(){
    this.childName = 'George';
}

// 继承了FatherType
ChildType.prototype = new FatherType();

// 创建实例
let instance = new ChildType();

// 为ChildType原型上添加新方法,要放在继承FatherType之后,这是因为new FatherType()会将ChildType原型上添加的新方法全部覆盖掉

ChildType.prototype.getChildValue = function() {
    return this.childName;
}

// 此时getFatherValue被重写了
ChildType.prototype.getFatherValue = function() {
    return true
}

console.log(instance.getFatherValue()); // true 

②通过原型链实现继承时,不能使用对象字面量创建原型方法,因为这样会重写原型链。这部分的🌰和解释在一眼看穿👀JS原型中《原型对象的问题》这一小节已经表述过了。一样的道理,只不过把原型换成了原型链罢了。

原型链的bug

原型链虽然强大,可以用它来实现继承,但是也是存在bug的,它最大的bug来自包含引用类型值的原型。也就是说原型链上面定义的原型属性会被所有的实例共享。
它还有另外一个bug,即在创建子类型的实例时,不能向父类型(超类型)的构造函数中传递参数。或者说没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。
基于以上这两个原因,实践过程中很少会单独使用原型链

借用构造函数

设计思想就是在子类型构造函数的内部调用父类(超类)构造函数
由于函数只不过是在特定环境中执行代码的对象,因此通过apply()和call()方法也可以在(将来)新创建的对象上执行构造函数。

function FatherType() {
  this.name = 'George';
} 

function ChildType() {
  //通过call方法改变this的指向,此时FatherType中的this指的是ChildType,相当于在构造函数中定义自己的属性。
  FatherType.call(this);
}

let instance1 = new ChildType(); 
instance1.name = '命名最头痛';
console.log(instance1.name); // '命名最头痛'

let instance2 = new ChildType();
console.log(instance2.name); // George

通过上述方法很好解决了原型属性共享问题,此外,既然是一个函数,它也能传相应的参数,因此也能实现在子类型构造函数中向超类型构造函数传递参数

function FatherType(name){
  this.name = name
}
function ChildType(){
  FatherType.call(this, "George");
  this.age = 18
}
let instance = new ChildType();
console.log(instance.name);  // George
console.log(instance.age);   // 18

借用构造函数的问题
借用构造函数,方法都在构造函数中定义,那么函数的复用就无从谈起,而且在父类(超类型)的原型定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。

组合继承

组合继承也叫伪经典继承,其设计思想是将原型链和借用构造函数的技术组合到一块,发挥二者之长的一种继承模式,其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承,这样既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。

function FatherType(name){
  this.name = name
  this.colors = ['red', 'blue', 'green']
}

FatherType.prototype.sayName = function() {
  console.log(this.name)
}

// 借用构造函数实现对实例的继承
function ChildType(name, age){
  // 使用call方法继承FatherType中的属性
  FatherType.call(this, name);
  this.age = age
}

// 利用原型链实现对原型属性和方法的继承
ChildType.prototype = new FatherType(); //将FatherType的实例赋值给ChildType原型
ChildType.prototype.constructor = ChildType; // 让ChildType的原型指向ChildType函数
ChildType.prototype.sayAge = function(){
  console.log(this.age)
}
let instance1 = new ChildType('命名最头痛', 18);
instance1.colors.push('black');
console.log(instance1.colors);         // 'red, blue, green, black'
instance1.sayName();
instance1.sayAge();

var instance2 = new ChildType('命名最头痛', 18);
console.log(instance2.colors);         // 'red, blue, green'
instance2.sayName();                   // '命名最头痛'
instance2.sayAge();                    // 18

组合继承方式避免了原型链和借用构造函数的缺陷,是JS中常用的继承方式。

原型链继承

原型链继承没有使用严格意义上的构造函数,其思想是基于已有的对象创建新对象

// 此object函数返回一个实例, 实际上object()对传入其中的对象执行了一次浅复制.
function object(o) {
  function F() {}  // 创建一个临时构造函数
  F.prototype = o; // 将传入的对象作为构造函数的原型
  return new F();  // 返回这个临时构造函数的新实例
}

let demo = {
  name: 'George',
  like: ['apple', 'dog']
}

let demo1 = object(demo);
demo1.name = '命名';     // 基本类型
demo1.like.push('cat'); // 引用类型共用一个内存地址

let demo2 = object(demo);
demo2.name = '头痛';    // 基本类型
demo2.like.push('chicken') // 引用类型共用一个内存地址
console.log(demo.name) // George
console.log(demo.like) // ["apple", "dog", "cat", "chicken"]

原型链继承的前提是必须要有一个对象可以作为另一个对象的基础。通过object()函数生成新对象后,再根据需求对新对象进行修改即可。 由于新对象(demo1, demo2)是将传入对象(demo)作为原型的,因此当涉及到引用类型时,他们会共用一个内存地址,引用类型会被所有实例所共享,实际上相当于创建了demo对象的两个副本。

Object.create()方法

ECMA5中新增Object.create()方法规范化了原型式继承。该方法接收两个参数
①基础对象,这个参数的实际作用是定义了模板对象中有的属性,就像上面🌰中的demo,只有一个参数情况下,Object.create()与上🌰中的object相同
②这个是可选参数,一个为基础对象定义额外属性的对象, 该对象的书写格式与Object.defineProperties()方法的第二个参数格式相同,每个属性都是通过自己的描述符定义的,以这种方式指定的任何属性都会覆盖原型对象上的同名属性。
// 只有一个参数
var demoObj = {
  name: 'George',
  like: ['apple', 'dog', 'cat']
}
let demo1Obj = Object.create(demoObj);
demo1Obj.name = '命名';
demo1Obj.like.push('banana');

let demo2Obj = Object.create(demoObj);
demo2Obj.name = '头痛';
demo2Obj.like.push('walk');

console.log(demoObj.like) //["apple", "dog", "cat", "banana", "walk"]

// 两个参数
var demoObj = {
  name: 'George',
  like: ['apple', 'dog', 'cat']
}

let demo1Obj = Object.create(demoObj, {
  name: {
    value:'命名'
  },
  like:{
    value: ['monkey']
  },
  new_val: {
    value: 'new_val'
  }
});
console.log(demoObj.name) // George
console.log(demo1Obj.name) // 命名
console.log(demo1Obj.like) // ["monkey"]
console.log(demo1Obj.new_val) // new_val
console.log(Object.getOwnPropertyDescriptor(demo1Obj,'new_val')) // {value: "new_val", writable: false, enumerable: false, configurable: false}

如果只想让一个对象与另一个对象保持类型的情况下,原型式继承是完全可以胜任的,不过要注意的是,引用类型值的属性始终都会共享相应的值。

寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路,其设计思想与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数内部以某种方式来增强对象,最后返回一个对象。

// 这个函数所返回的对象,既有original的所有属性和方法,也有自己的sayHello方法
function createAnother(original) {
  let clone = Object.create(original);
  clone.sayHello = function(){            
    console.log('HELLO WORLD')
  }
  return clone;
}

let person = {
  name: 'George',
  foods: ['apple', 'banana']
}

let anotherPerson = createAnother(person);
anotherPerson.sayHello();  // HELLO WORLD

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这一点与构造函数模式类似。

寄生组合式继承

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混合形式来继承方法。其背后思想:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。说白了就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

function inheritPrototype(childType, fatherType){
  let fatherObj = Object.create(fatherType.prototype);  // 创建对象
  fatherObj.constructor = childType;   // 弥补重写原型而失去的默认constructor属性
  childType.prototype = fatherObj;     // 指定对象
}

上🌰是寄生组合式继承最简单的形式,这个函数接受两个参数:子类型构造函数和超类型构造函数,在函数内部,①创建了父类型原型的一个副本,②为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认的constructor属性。③将新创建的对象(即副本)赋值给子类型的原型。

function FatherType(name){
  this.name = name;
  this.foods = ['apple', 'banana'];
}
FatherType.prototype.sayName = function(){
  console.log(this.name)
}
function ChildType(name, age){
  FatherType.call(this, name);
  this.age = age;
}
inheritPrototype(ChildType, FatherType);
ChildType.prototype.sayAge = function(){
  console.log(this.age)
}

小结

  • JS继承的主要方式是通过原型链实现的
  • 实例-原型-实例-原型...无限链接下去就是原型链
  • 所有引用类型的默认原型都是Object
  • instanceof操作符和isPrototypeOf方法都可以用于判断实例与原型的关系,其区别是,前者用的是原型,后者用的是构造函数
  • 给原型添加方法的代码一定要放在继承之后,这是因为,在继承的时候被继承者会覆盖掉继承者原型上的所有方法
  • Object.create()方法用于创建一个新对象,其属性会放置在该对象的原型上
  • 继承有6种方式,分别是原型链,借用构造函数,组合继承,原型式继承,寄生式继承和寄生组合式继承
查看原文

赞 26 收藏 22 评论 2

写Bug 发布了文章 · 2018-09-03

使用有限状态机管理状态

背景

近年来由于一些前端框架的兴起而后逐渐成熟,组件化的概念已经深入人心,为了管理好大型应用中错综复杂的组件,又有了单向数据流的思想指引着我们,Vuex、Redux、MobX等状态管理工具也许大家都信手拈来。
我们手握着这些工具,不断思考着哪些数据应该放在全局,哪些数据应该局部消化,这样那样的数据该怎样流转。仔细想想会发现,我们一直在做的是如何将数据存在合理的地方,进而去规范怎样使用这些数据,我们称之为状态管理,但我觉得好像只是做了状态存储与使用,却没有做好管理二字。嗯,总感觉差了些什么。

你能将状态描述清楚吗?

来看一段简单的代码:

state = {
    data: [
        {
            id: 1,
            userName: xxx
        },
        {
            id: 2,
            userName: yyy
        }
    ]
}

如果根据UI = f(state)来说,上面的state.data就是一个状态,它能直接反映视图的样子:

render () {
    const {data} = this.state
    return (
        <div>
            {
                data && data.length ? data.map(item => <div :key={item.id}>{item.userName}</div>) : '暂无数据'
            }
        </div>
    )
}

我们还会在合适的时机进行某种操作去更新状态,比如请求获取数据的接口就会去更新上面的data:

updateData () {
  getData().then(({data}) => {
    this.setState({data})
  })
}

但随着时间的推移,这样的状态会越来越多,更新状态的方法暗藏在日益膨胀的代码中,维护者可能自己都要如履薄冰地一翻抽丝剥茧才勉强捋清楚状态什么时机更新,为什么要更新,更别说如果是去接盘一份祖传代码了。
究其原因,我觉得是没有将状态描述清楚,更别说管理好状态。所以一个描述得清楚的状态是长什么样子呢?比如:开始的时候,data是空数组,初始化完成需要去更新data,增删改完成后都需要更新data。
想想日常,有没有地方能一眼就看清楚这些状态信息?需求文档?UI稿?靠谱吗?
图片描述

有限状态机了解一下

自己动手丰衣足食,我们的目标是在代码里就能清晰地看到这些状态信息。如果我们能够写一份配置文件来将它们描述清楚,然后写代码的时候就根据这份配置文件来写,有修改的时候也必须先修改这份配置文件,那我们最后看配置文件就能对状态信息一目了然了。
为了达到这样的目标,我们得请有限状态机来帮忙。概念性的东西请移步到JavaScript与有限状态机,总的来说,有限状态机是一个模型,它能描述清楚有哪些状态,状态之间是怎样转化的,它有以下特点:
1.状态的数量是固定的
2.状态会因为触发了某种行为而转变成另一种状态(比如典型的promise,初始状态为pending,resolve后状态转变成fulfilled,reject则变成rejected)
3.任意时间点状态唯一(初始化完成了才能进行增删改嘛)
ok,了解这些之后,我们来看看怎样一步步达到目的。
我们以一个需求为例:
图片描述
就是一个没有一毛钱特效的Todoist,非常简单的增删改查。

初版

按照之前的想法,我们首先需要一份配置文件来描述状态:

const machine = {
  // 初始状态
  initial: "start",
  start: {
    INIT: "loadList"
  },
  loadList: {
    LOAD_LIST_SUCCESS: "showList",
    LOAD_LIST_ERROR: "showListError"
  },
  showListError: {
    RETRY: "loadList"
  },
  showList: {
    ADD: "add",
    EDIT: "edit",
    DELETE: "delete"
  },
  edit: {
    SAVE_EDIT: "saveEdit"
  },
  saveEdit: {
    SAVE_EDIT_SUCCESS: "loadList"
  },
  delete: {
    DELETE_SUCCESS: "loadList"
  },
  add: {
    ADD_SUCCESS: "loadList"
  }
};

配置是写完了,现在对着上面的需求gif图说一下这份配置是什么意思。

  1. 加载列表数据(initial: "start"表示初始状态是start,start: {INIT: "loadList"}表示状态start触发INIT事件之后状态会转变成loadList)
  2. 加载列表数据失败了(loadList触发LOAD_LIST_ERROR事件状态转变为showListError)
  3. 加载失败后重新加载(showListError触发RETRY事件之后状态重新变回loadList
  4. 重新加载列表成功(loadList触发LOAD_LIST_SUCCESS事件状态转变为showList)
  5. 列表加载成功就可以对列表进行增删改操作(showList可以触发ADD、DELETE、EDIT事件对应增删改操作带来的状态变化)

剩下的配置就不继续写了,可以看到通过这份配置,我们可以清晰知道,这份代码究竟做了些什么,而且写这份配置有利于整理好自己的思路,让自己首先将需求过一遍,将所有边边角角通过写配置预演一遍,而不是拿到需求就开撸,遇到了问题才发现之前写的代码不适用。同理如果需求有变,首先从这份配置入手,看看这波修改会对哪些状态分支造成影响,就不会出现那种不知道改一个地方会不会影响到别的地方宛如拆炸弹一样的心情。
接着,为了方便根据这份配置来进行操作,需要实现一点辅助函数:

class App extends Component {
    constructor(props) {
        state = {
            curState: machine.initial
        }
    }
    handleNextState (nextState, action) {
        switch (nextState) {
            case "loadList":
            // 处理loadList的逻辑
            break;
        }
    }
    transition (action) {
        const { curState } = this.state;
        const nextState = machine[curState][action.type];
        if (nextState) {
            this.setState({ curState: nextState }, () =>
                this.handleNextState(nextState, action)
            );
        }
    }
}

基本就是这样的结构,通过this.transition({ type: "INIT" })触发一个事件(INIT)将当前状态(start)转变成另外一个状态(loadList),而handleNextState则处理状态转变后的逻辑(当状态变成loadList需要去请求接口获取列表数据)。通过这样的方式,我们真正将状态管理了起来,因为我们有清晰的配置文件去描述状态,我们有分层清晰的地方去处理当前状态需要处理的逻辑,这就相当于有明确的战略图,大家都根据这份战略图各司其职做好自己的本分,这不是将状态管理得井井有条吗?
而且这样做之后,比较容易规避一些意外的错误,因为任意时间点状态唯一这个特点,带来了状态只能从一个状态转变到另一个状态,比如点击一个按钮提交,这时的状态是提交中,我们经常需要去处理用户重复点击而导致重复提交的事情:

let isSubmit = false
const submit = () => {
    if (isSubmit === true) return
    isSubmit = true
    toSubmit().then(() => isSubmit = false)
}
submit()

使用有限状态机进行管理后就不需要写这种额外的isSubmit状态,因为提交中的状态只能转变为提交完成。
上面的代码完整版请点这里

更进一步

虽然初版对于状态的管理更加清晰了一些,但仍然不够直观,如果能将配置转化成图就好了,有图有真相嘛。心想事成:
图片描述
不仅有图可看,还可以逼真地将所有状态都预演一遍。这个好东西就是xstate给予我们的,它是一个实现有限状态机模型的js库,感兴趣可以去详看,这里我们只需要按照它的写法去写状态机的配置,就可以生成出这样的图
看过xstate会发现,里面的东西真不少,其实如果只是想在简单的项目上用这种模式试试水,却要把整个库引进来似乎不太划算。那,不如自己来撸一个简化版?
心动不如行动,先分析一下初版有什么不足之处。

  1. 没有将有限状态机的模式分离出来,如果不是用react而是用vue就用不了了。
  2. 没有将模式分离出来导致复用性很差,总不能每个地方要用的时候都要写一次transition等方法吧。
  3. 配置项没写成xstate的样子无法使用xstate提供的工具生成图。

现在首要的任务就是把有限状态机的模式抽离出来,顺便使用xstate的写法来写配置。

const is = (type, val) =>
  Object.prototype.toString.call(val) === "[object " + type + "]";

export class Fsm {
  constructor(stateConfig) {
    // 状态描述配置
    this.stateConfig = stateConfig;
    // 当前状态
    this.state = stateConfig.initial;
    // 上一个状态
    this.lastState = "";
    // 状态离开回调集合
    this.onExitMap = {};
    // 状态进入回调集合
    this.onEntryMap = {};
    // 状态改变回调
    this.handleStateChange = null;
  }

  /**
   * 改变状态
   * @param type 行为类型 描述当前状态通过该类型的行为转变到另一个状态
   * @param arg 转变过程中的额外传参
   * @returns {Promise<void>}
   */
  transition({ type, ...arg }) {
    const states = this.stateConfig.states;
    const curState = this.state;
    if (!states) {
      throw "states undefined";
    }
    if (!is("Object", states)) {
      throw "states should be object";
    }
    if (
      !states[curState] ||
      !states[curState]["on"] ||
      !states[curState]["on"][type]
    ) {
      console.warn(`transition fail, current state is ${this.state}`);
      return;
    }
    const nextState = states[curState]["on"][type];
    const curStateObj = states[curState];
    const nextStateObj = states[nextState];
    // 状态转变的经历
    return (
      Promise.resolve()
        // 状态离开
        .then(() =>
          this.handleLifeCycle({
            type: "onExit",
            stateObj: curStateObj,
            arg: { exitState: curState }
          })
        )
        // 状态改变
        .then(() => this.updateState({ state: nextState, lastState: curState }))
        // 进入新状态
        .then(() =>
          this.handleLifeCycle({
            type: "onEntry",
            stateObj: nextStateObj,
            arg: { state: nextState, lastState: curState, ...arg }
          })
        )
    );
  }

  /**
   * 状态改变回调 只注册一次
   * @param cb
   */
  onStateChange(cb) {
    cb &&
      is("Function", cb) &&
      !this.handleStateChange &&
      (this.handleStateChange = cb);
  }

  /**
   * 注册状态离开回调
   * @param type
   * @param cb
   */
  onExit(type, cb) {
    !this.onExitMap[type] && (this.onExitMap[type] = cb);
  }

  /**
   * 注册状态进入回调
   * @param type
   * @param cb
   */
  onEntry(type, cb) {
    !this.onEntryMap[type] && (this.onEntryMap[type] = cb);
  }

  /**
   * 更新状态
   * @param state
   * @param lastState
   */
  updateState({ state, lastState }) {
    this.state = state;
    this.lastState = lastState;
    this.handleStateChange && this.handleStateChange({ state, lastState });
  }

  /**
   * 处理状态转变的生命周期
   * @param stateObj
   * @param type onExit/onEntry
   * @param arg
   * @returns {*}
   */
  handleLifeCycle({ stateObj, type, arg }) {
    const cbName = stateObj[type];
    if (cbName) {
      const cb = this[`${type}Map`][cbName];
      if (cb && is("Function", cb)) {
        return cb(arg);
      }
    }
  }

  /**
   * 获取当前状态
   * @returns {*}
   */
  getState() {
    return this.state;
  }

  /**
   * 获取上一个状态
   * @returns {string|*}
   */
  getLastState() {
    return this.lastState;
  }
}

然后这样使用就好:

const stateConfig = {
  initial: "start",
  states: {
    start: {
      on: {
        INIT: "loadList"
      },
      onExit: "onExitStart"
    },
     loadList: {
      on: {
        LOAD_LIST_SUCCESS: "showList",
        LOAD_LIST_ERROR: "showListError"
      },
      onEntry: "onEntryLoadList"
    }
  }
}
/*
结果:
1.console.log('onExitStart')
2.console.log('onEntryLoadList')
3.console.log('transition success')
transition以及生命周期函数onExit、onEntry都支持promise控制异步流程
*/
const fsm = new Fsm(stateConfig);
transition({ type: "INIT"}).then(() => {
  console.log('transition success')
})
fsm.onExit('onExitStart', (data) => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('onExitStart')
      resolve()
    }, 1000)
  })
})
fsm.onEntry('onEntryLoadList', (data) => {
  console.log('onEntryLoadList')
})

总算把有限状态机抽成一个工具来使用了,已经完成了最关键的一步。

集成到react去使用

如果想在react中使用,想到比较方便的使用形式是高阶组件,需要用到有限状态机的组件传进高阶组件,就立马拥有了使用有限状态机的能力。

import React from "react";
import { Fsm } from "../fsm";
export default function(stateConfig) {
  const fsm = new Fsm(stateConfig);
  return function(Component) {
    return class extends React.Component {
      constructor() {
        super();
        this.state = {
          machineState: {
            // 当前状态
            value: stateConfig.initial,
            // 上一个状态
            lastValue: ""
          }
        };
      }
      updateMachineState(data) {
        this.setState({
          machineState: { ...this.state.machineState, ...data }
        });
      }
      componentDidMount() {
        this.handleStateChange();
        this.handleEvent();
      }

      /**
       * 处理状态更新
       */
      handleStateChange() {
        fsm.onStateChange(({ state, lastState }) => {
          this.updateMachineState({ value: state, lastValue: lastState });
        });
      }

      /**
       * 处理状态改变事件
       */
      handleEvent() {
        const states = stateConfig.states;
        // 获取状态配置中所有的onEntry与onExit
        const eventObj = Object.keys(states).reduce(
          (obj, key) => {
            const value = states[key];
            const onEntry = value.onEntry;
            const onExit = value.onExit;
            onEntry && obj.onEntry.push(onEntry);
            onExit && obj.onExit.push(onExit);
            return obj;
          },
          {
            onEntry: [],
            onExit: []
          }
        );
        // 获取组件实例中onEntry与onExit的回调方法
        Object.keys(eventObj).forEach(key => {
          eventObj[key].forEach(item => {
            this.ref[item] && fsm[key](item, this.ref[item].bind(this.ref));
          });
        });
      }
      render() {
        return (
          <Component
            ref={c => (this.ref = c)}
            {...this.state}
            transition={fsm.transition.bind(fsm)}
          />
        );
      }
    };
  };
}

使用的时候就可以:

const stateConfig = {
  initial: "start",
  states: {
    start: {
      on: {
        INIT: "loadList"
      },
      onExit: "onExitStart"
    }
  }
}
class App extends Component {
    componentDidMount () {
        this.props.transition({ type: "INIT" });
    }
    onExitStart () {
        console.log('onExitStart ')
    }
}
export default withFsm(machine)(App);

现在我们可以愉快地使用这个高阶组件将Todoist重构一遍
当然,大佬们会说了,我的项目比较复杂,有没有比较完善的解决方案呢?那肯定是有的,可以看看react-automata,将xstate集成到react中使用。由于我们上面的小高阶组件用法比较像react-automata,所以基本不需要什么改动,就可以迁移到react-automata,使用react-automata再重构一遍Todoist

最后

对于符合有限状态机的使用场景,使用它确实能将状态管理起来,因为我们的状态再也不是那种如isSubmit = false/true那样杂乱无章的状态,而是某个时间节点里的一个总括状态。不管怎样,有限状态机的方案还是促使了我们去重新思考怎样能更大程度地提高项目的可维护性,提供了一个新方向尽可能减少祖传代码,改起bug或者需求的时候分析起来更加容易,终极目的只有一个,那就是,希望能早点下班。

查看原文

赞 22 收藏 18 评论 2

写Bug 评论了文章 · 2018-08-31

不要再问我跨域的问题了

写下这篇文章后我想,要不以后就把这种基础的常见知识都归到这个“不要再问我XX的问题”,形成一系列内容,希望大家看完之后再有人问你这些问题,你心里会窃喜:“嘿嘿,是时候展现真正的技术了!”
一、不要再问我this的指向问题了

跨域这两个字就像一块狗皮膏药一样黏在每一个前端开发者身上,无论你在工作上或者面试中无可避免会遇到这个问题。为了应付面试,我每次都随便背几个方案,也不知道为什么要这样干,反正面完就可以扔了,我想工作上也不会用到那么多乱七八糟的方案。到了真正工作,开发环境有webpack-dev-server搞定,上线了服务端的大佬们也会配好,配了什么我不管,反正不会跨域就是了。日子也就这么混过去了,终于有一天,我觉得不能再继续这样混下去了,我一定要彻底搞懂这个东西!于是就有了这篇文章。

要掌握跨域,首先要知道为什么会有跨域这个问题出现

确实,我们这种搬砖工人就是为了混口饭吃嘛,好好的调个接口告诉我跨域了,这种阻碍我们轻松搬砖的事情真恶心!为什么会跨域?是谁在搞事情?为了找到这个问题的始作俑者,请点击浏览器的同源策略
这么官方的东西真难懂,没关系,至少你知道了,因为浏览器的同源策略导致了跨域,就是浏览器在搞事情。
所以,浏览器为什么要搞事情?就是不想给好日子我们过?对于这样的质问,浏览器甩锅道:“同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。”
这么官方的话术真难懂,没关系,至少你知道了,似乎这是个安全机制。
所以,究竟为什么需要这样的安全机制?这样的安全机制解决了什么问题?别急,让我们继续研究下去。

没有同源策略限制的两大危险场景

据我了解,浏览器是从两个方面去做这个同源策略的,一是针对接口的请求,二是针对Dom的查询。试想一下没有这样的限制上述两种动作有什么危险。

没有同源策略限制的接口请求

有一个小小的东西叫cookie大家应该知道,一般用来处理登录等场景,目的是让服务端知道谁发出的这次请求。如果你请求了接口进行登录,服务端验证通过后会在响应头加入Set-Cookie字段,然后下次再发请求的时候,浏览器会自动将cookie附加在HTTP请求的头字段Cookie中,服务端就能知道这个用户已经登录过了。知道这个之后,我们来看场景:
1.你准备去清空你的购物车,于是打开了买买买网站www.maimaimai.com,然后登录成功,一看,购物车东西这么少,不行,还得买多点。
2.你在看有什么东西买的过程中,你的好基友发给你一个链接www.nidongde.com,一脸yin笑地跟你说:“你懂的”,你毫不犹豫打开了。
3.你饶有兴致地浏览着www.nidongde.com,谁知这个网站暗地里做了些不可描述的事情!由于没有同源策略的限制,它向www.maimaimai.com发起了请求!聪明的你一定想到上面的话“服务端验证通过后会在响应头加入Set-Cookie字段,然后下次再发请求的时候,浏览器会自动将cookie附加在HTTP请求的头字段Cookie中”,这样一来,这个不法网站就相当于登录了你的账号,可以为所欲为了!如果这不是一个买买买账号,而是你的银行账号,那……
这就是传说中的CSRF攻击浅谈CSRF攻击方式
看了这波CSRF攻击我在想,即使有了同源策略限制,但cookie是明文的,还不是一样能拿下来。于是我看了一些cookie相关的文章聊一聊 cookieCookie/Session的机制与安全,知道了服务端可以设置httpOnly,使得前端无法操作cookie,如果没有这样的设置,像XSS攻击就可以去获取到cookieWeb安全测试之XSS;设置secure,则保证在https的加密通信中传输以防截获。

没有同源策略限制的Dom查询

1.有一天你刚睡醒,收到一封邮件,说是你的银行账号有风险,赶紧点进www.yinghang.com改密码。你吓尿了,赶紧点进去,还是熟悉的银行登录界面,你果断输入你的账号密码,登录进去看看钱有没有少了。
2.睡眼朦胧的你没看清楚,平时访问的银行网站是www.yinhang.com,而现在访问的是www.yinghang.com,这个钓鱼网站做了什么呢?

// HTML
<iframe name="yinhang" data-original="www.yinhang.com"></iframe>
// JS
// 由于没有同源策略的限制,钓鱼网站可以直接拿到别的网站的Dom
const iframe = window.frames['yinhang']
const node = iframe.document.getElementById('你输入账号密码的Input')
console.log(`拿到了这个${node},我还拿不到你刚刚输入的账号密码吗`)

由此我们知道,同源策略确实能规避一些危险,不是说有了同源策略就安全,只是说同源策略是一种浏览器最基本的安全机制,毕竟能提高一点攻击的成本。其实没有刺不穿的盾,只是攻击的成本和攻击成功后获得的利益成不成正比。

跨域正确的打开方式

经过对同源策略的了解,我们应该要消除对浏览器的误解,同源策略是浏览器做的一件好事,是用来防御来自邪门歪道的攻击,但总不能为了不让坏人进门而把全部人都拒之门外吧。没错,我们这种正人君子只要打开方式正确,就应该可以跨域。
下面将一个个演示正确打开方式,但在此之前,有些准备工作要做。为了本地演示跨域,我们需要:
1.随便跑起一份前端代码(以下前端是随便跑起来的vue),地址是http://localhost:9099。
2.随便跑起一份后端代码(以下后端是随便跑起来的node koa2),地址是http://localhost:9971。

同源策略限制下接口请求的正确打开方式

1.JSONP
在HTML标签里,一些标签比如script、img这样的获取资源的标签是没有跨域限制的,利用这一点,我们可以这样干:

后端写个小接口

// 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
  static async jsonp (ctx) {
    // 前端传过来的参数
    const query = ctx.request.query
    // 设置一个cookies
    ctx.cookies.set('tokenId', '1')
    // query.cb是前后端约定的方法名字,其实就是后端返回一个直接执行的方法给前端,由于前端是用script标签发起的请求,所以返回了这个方法后相当于立马执行,并且把要返回的数据放在方法的参数里。
    ctx.body = `${query.cb}(${JSON.stringify(successBody({msg: query.msg}, 'success'))})`
  }
}
module.exports = CrossDomain

简单版前端

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script type='text/javascript'>
      // 后端返回直接执行的方法,相当于执行这个方法,由于后端把返回的数据放在方法的参数里,所以这里能拿到res。
      window.jsonpCb = function (res) {
        console.log(res)
      }
    </script>
    <script data-original='http://localhost:9871/api/jsonp?msg=helloJsonp&cb=jsonpCb' type='text/javascript'></script>
  </body>
</html>

简单封装一下前端这个套路

/**
 * JSONP请求工具
 * @param url 请求的地址
 * @param data 请求的参数
 * @returns {Promise<any>}
 */
const request = ({url, data}) => {
  return new Promise((resolve, reject) => {
    // 处理传参成xx=yy&aa=bb的形式
    const handleData = (data) => {
      const keys = Object.keys(data)
      const keysLen = keys.length
      return keys.reduce((pre, cur, index) => {
        const value = data[cur]
        const flag = index !== keysLen - 1 ? '&' : ''
        return `${pre}${cur}=${value}${flag}`
      }, '')
    }
    // 动态创建script标签
    const script = document.createElement('script')
    // 接口返回的数据获取
    window.jsonpCb = (res) => {
      document.body.removeChild(script)
      delete window.jsonpCb
      resolve(res)
    }
    script.src = `${url}?${handleData(data)}&cb=jsonpCb`
    document.body.appendChild(script)
  })
}
// 使用方式
request({
  url: 'http://localhost:9871/api/jsonp',
  data: {
    // 传参
    msg: 'helloJsonp'
  }
}).then(res => {
  console.log(res)
})

2.空iframe加form
细心的朋友可能发现,JSONP只能发GET请求,因为本质上script加载资源就是GET,那么如果要发POST请求怎么办呢?

后端写个小接口

// 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
  static async iframePost (ctx) {
    let postData = ctx.request.body
    console.log(postData)
    ctx.body = successBody({postData: postData}, 'success')
  }
}
module.exports = CrossDomain

前端

const requestPost = ({url, data}) => {
  // 首先创建一个用来发送数据的iframe.
  const iframe = document.createElement('iframe')
  iframe.name = 'iframePost'
  iframe.style.display = 'none'
  document.body.appendChild(iframe)
  const form = document.createElement('form')
  const node = document.createElement('input')
  // 注册iframe的load事件处理程序,如果你需要在响应返回时执行一些操作的话.
  iframe.addEventListener('load', function () {
    console.log('post success')
  })

  form.action = url
  // 在指定的iframe中执行form
  form.target = iframe.name
  form.method = 'post'
  for (let name in data) {
    node.name = name
    node.value = data[name].toString()
    form.appendChild(node.cloneNode())
  }
  // 表单元素需要添加到主文档中.
  form.style.display = 'none'
  document.body.appendChild(form)
  form.submit()

  // 表单提交后,就可以删除这个表单,不影响下次的数据发送.
  document.body.removeChild(form)
}
// 使用方式
requestPost({
  url: 'http://localhost:9871/api/iframePost',
  data: {
    msg: 'helloIframePost'
  }
})

3.CORS

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)跨域资源共享 CORS 详解。看名字就知道这是处理跨域问题的标准做法。CORS有两种请求,简单请求和非简单请求。

这里引用上面链接阮一峰老师的文章说明一下简单请求和非简单请求。
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求。
(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

1.简单请求
后端

// 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
  static async cors (ctx) {
    const query = ctx.request.query
    // *时cookie不会在http请求中带上
    ctx.set('Access-Control-Allow-Origin', '*')
    ctx.cookies.set('tokenId', '2')
    ctx.body = successBody({msg: query.msg}, 'success')
  }
}
module.exports = CrossDomain

前端什么也不用干,就是正常发请求就可以,如果需要带cookie的话,前后端都要设置一下,下面那个非简单请求例子会看到。

fetch(`http://localhost:9871/api/cors?msg=helloCors`).then(res => {
  console.log(res)
})

2.非简单请求
非简单请求会发出一次预检测请求,返回码是204,预检测通过才会真正发出请求,这才返回200。这里通过前端发请求的时候增加一个额外的headers来触发非简单请求。
clipboard.png

后端

// 处理成功失败返回格式的工具
const {successBody} = require('../utli')
class CrossDomain {
  static async cors (ctx) {
    const query = ctx.request.query
    // 如果需要http请求中带上cookie,需要前后端都设置credentials,且后端设置指定的origin
    ctx.set('Access-Control-Allow-Origin', 'http://localhost:9099')
    ctx.set('Access-Control-Allow-Credentials', true)
    // 非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)
    // 这种情况下除了设置origin,还需要设置Access-Control-Request-Method以及Access-Control-Request-Headers
    ctx.set('Access-Control-Request-Method', 'PUT,POST,GET,DELETE,OPTIONS')
    ctx.set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, t')
    ctx.cookies.set('tokenId', '2')

    ctx.body = successBody({msg: query.msg}, 'success')
  }
}
module.exports = CrossDomain

一个接口就要写这么多代码,如果想所有接口都统一处理,有什么更优雅的方式呢?见下面的koa2-cors。

const path = require('path')
const Koa = require('koa')
const koaStatic = require('koa-static')
const bodyParser = require('koa-bodyparser')
const router = require('./router')
const cors = require('koa2-cors')
const app = new Koa()
const port = 9871
app.use(bodyParser())
// 处理静态资源 这里是前端build好之后的目录
app.use(koaStatic(
  path.resolve(__dirname, '../dist')
))
// 处理cors
app.use(cors({
  origin: function (ctx) {
    return 'http://localhost:9099'
  },
  credentials: true,
  allowMethods: ['GET', 'POST', 'DELETE'],
  allowHeaders: ['t', 'Content-Type']
}))
// 路由
app.use(router.routes()).use(router.allowedMethods())
// 监听端口
app.listen(9871)
console.log(`[demo] start-quick is starting at port ${port}`)

前端

fetch(`http://localhost:9871/api/cors?msg=helloCors`, {
  // 需要带上cookie
  credentials: 'include',
  // 这里添加额外的headers来触发非简单请求
  headers: {
    't': 'extra headers'
  }
}).then(res => {
  console.log(res)
})

4.代理
想一下,如果我们请求的时候还是用前端的域名,然后有个东西帮我们把这个请求转发到真正的后端域名上,不就避免跨域了吗?这时候,Nginx出场了。
Nginx配置

server{
    # 监听9099端口
    listen 9099;
    # 域名是localhost
    server_name localhost;
    #凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871 
    location ^~ /api {
        proxy_pass http://localhost:9871;
    }    
}

前端就不用干什么事情了,除了写接口,也没后端什么事情了

// 请求的时候直接用回前端这边的域名http://localhost:9099,这就不会跨域,然后Nginx监听到凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871 
fetch('http://localhost:9099/api/iframePost', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    msg: 'helloIframePost'
  })
})

Nginx转发的方式似乎很方便!但这种使用也是看场景的,如果后端接口是一个公共的API,比如一些公共服务获取天气什么的,前端调用的时候总不能让运维去配置一下Nginx,如果兼容性没问题(IE 10或者以上),CROS才是更通用的做法吧。

同源策略限制下Dom查询的正确打开方式

1.postMessage
window.postMessage() 是HTML5的一个接口,专注实现不同窗口不同页面的跨域通讯。
为了演示方便,我们将hosts改一下:127.0.0.1 crossDomain.com,现在访问域名crossDomain.com就等于访问127.0.0.1。

这里是http://localhost:9099/#/crossDomain,发消息方

<template>
  <div>
    <button @click="postMessage">给http://crossDomain.com:9099发消息</button>
    <iframe name="crossDomainIframe" data-original="http://crossdomain.com:9099"></iframe>
  </div>
</template>

<script>
export default {
  mounted () {
    window.addEventListener('message', (e) => {
      // 这里一定要对来源做校验
      if (e.origin === 'http://crossdomain.com:9099') {
        // 来自http://crossdomain.com:9099的结果回复
        console.log(e.data)
      }
    })
  },
  methods: {
    // 向http://crossdomain.com:9099发消息
    postMessage () {
      const iframe = window.frames['crossDomainIframe']
      iframe.postMessage('我是[http://localhost:9099], 麻烦你查一下你那边有没有id为app的Dom', 'http://crossdomain.com:9099')
    }
  }
}
</script>

这里是http://crossdomain.com:9099,接收消息方

<template>
  <div>
    我是http://crossdomain.com:9099
  </div>
</template>

<script>
export default {
  mounted () {
    window.addEventListener('message', (e) => {
      // 这里一定要对来源做校验
      if (e.origin === 'http://localhost:9099') {
        // http://localhost:9099发来的信息
        console.log(e.data)
        // e.source可以是回信的对象,其实就是http://localhost:9099窗口对象(window)的引用
        // e.origin可以作为targetOrigin
        e.source.postMessage(`我是[http://crossdomain.com:9099],我知道了兄弟,这就是你想知道的结果:${document.getElementById('app') ? '有id为app的Dom' : '没有id为app的Dom'}`, e.origin);
      }
    })
  }
}
</script>

结果可以看到:

clipboard.png

2.document.domain
这种方式只适合主域名相同,但子域名不同的iframe跨域。
比如主域名是http://crossdomain.com:9099,子域名是http://child.crossdomain.com:9099,这种情况下给两个页面指定一下document.domain即document.domain = crossdomain.com就可以访问各自的window对象了。

3.canvas操作图片的跨域问题
这个应该是一个比较冷门的跨域问题,张大神已经写过了我就不再班门弄斧了解决canvas图片getImageData,toDataURL跨域问题

最后

希望看完这篇文章之后,再有人问跨域的问题,你可以嘴角微微上扬,冷笑一声:“不要再问我跨域的问题了。”
扬长而去。

查看原文

认证与成就

  • 获得 1841 次点赞
  • 获得 25 枚徽章 获得 1 枚金徽章, 获得 8 枚银徽章, 获得 16 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-03-23
个人主页被 7.6k 人浏览