wuwhs

wuwhs 查看完整档案

深圳编辑  |  填写毕业院校深圳市某科技公司  |  前端工程师 编辑 wuwhs.gitee.io 编辑
编辑

一步步往前爬的小前端

个人动态

wuwhs 发布了文章 · 10月13日

彻底学会element-ui按需引入和纯净主题定制

前言

手上有些项目用的element-ui,刚好有空琢磨一下怎么减小打包文件大小和打包速度方面,为了演示实验,用 vue-cli 生成初始项目,在这仅对 element-ui 主题和组件方面来优化。

vue init webpack vuecli

完整引入

完整地将 ui 和样式引入。

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

在页面简单使用 2 个组件,看看效果。

<el-tabs v-model="activeName" @tab-click="handleClick">
  <el-tab-pane label="用户管理" name="first">用户管理</el-tab-pane>
  <el-tab-pane label="配置管理" name="second">配置管理</el-tab-pane>
  <el-tab-pane label="角色管理" name="third">角色管理</el-tab-pane>
  <el-tab-pane label="定时任务补偿" name="fourth">定时任务补偿</el-tab-pane>
</el-tabs>

<el-steps :active="2" align-center>
  <el-step title="步骤1" description="这是一段很长很长很长的描述性文字"></el-step>
  <el-step title="步骤2" description="这是一段很长很长很长的描述性文字"></el-step>
  <el-step title="步骤3" description="这是一段很长很长很长的描述性文字"></el-step>
  <el-step title="步骤4" description="这是一段很长很长很长的描述性文字"></el-step
></el-steps>

组件效果

再看一下打包后的资源大小情况npm run build --report

Hash: 40db03677fe41f7369f6
Version: webpack 3.12.0
Time: 20874ms
                                                  Asset       Size  Chunks                    Chunk Names
    static/css/app.cb8131545d15085cee647fe45f1d5561.css     234 kB       1  [emitted]         app
                 static/fonts/element-icons.732389d.ttf      56 kB          [emitted]
               static/js/vendor.a753ce0919c8d42e4488.js     824 kB       0  [emitted]  [big]  vendor
                  static/js/app.8c4c97edfce9c9069ea3.js    3.56 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
                static/fonts/element-icons.535877f.woff    28.2 kB          [emitted]
static/css/app.cb8131545d15085cee647fe45f1d5561.css.map     332 kB          [emitted]
           static/js/vendor.a753ce0919c8d42e4488.js.map    3.26 MB       0  [emitted]         vendor
              static/js/app.8c4c97edfce9c9069ea3.js.map    16.6 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  506 bytes          [emitted]

发现打包后提取公共模块 static/js/vendor.js824kb

再看一下各个模块占用情况:

各个模块占用情况

发现 elment-ui.common.js 占用最大。所有模块资源总共有 642kb。怎么才能减小打包后的大小呢?很容易就会想到 ui 的引入和样式的引入中,实际我们只使用了三个组件,却整体都被打包了,在这里引入这三个组件即可。

按需引入组件样式

新建一个 element-variables.scss 文件(为什么是 SCSS 文件,后面自定义主题会用到)。

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

/*按需引入用到的组件的scss文件和基础scss文件*/
@import "~element-ui/packages/theme-chalk/src/base.scss";
@import "~element-ui/packages/theme-chalk/src/rate.scss";
@import "~element-ui/packages/theme-chalk/src/button.scss";
@import "~element-ui/packages/theme-chalk/src/row.scss";

按需引入组件

新建一个 element-config.js 文件,将项目用到的 element 组件引入。

import {
  Tabs,
  TabPane,
  Steps,
  Step
} from 'element-ui'

export default {
  install (V) {
    V.use(Tabs)
    V.use(TabPane)
    V.use(Steps)
    V.use(Step)
  }
}

第一次优化后打包分析

将以上 element-variables.scsselement-config.js 引入到 main.js 中。

import ElementUI from '@/assets/js/element-config'
import '@/assets/css/element-variables.scss'

Vue.use(ElementUI)

貌似上面一切都很顺理成章,打包后大小会减小。

Hash: 2ef987c23a5d612e00e1
Version: webpack 3.12.0
Time: 17430ms
                                                  Asset       Size  Chunks                    Chunk Names
    static/css/app.3c70d8d75c176393318b232a345e3f0f.css    38.8 kB       1  [emitted]         app
                 static/fonts/element-icons.732389d.ttf      56 kB          [emitted]
               static/js/vendor.caa5978bb1eb0a15b097.js     824 kB       0  [emitted]  [big]  vendor
                  static/js/app.5ebb19489355acc3167b.js    3.64 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
                static/fonts/element-icons.535877f.woff    28.2 kB          [emitted]
static/css/app.3c70d8d75c176393318b232a345e3f0f.css.map    53.9 kB          [emitted]
           static/js/vendor.caa5978bb1eb0a15b097.js.map    3.26 MB       0  [emitted]         vendor
              static/js/app.5ebb19489355acc3167b.js.map      17 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  506 bytes          [emitted]

结果可知,static/js/vendor.js 还是 824kb

再看各个模块占用情况:

第一次优化后各个模块占用情况

WHAT? 竟然模块都没什么变化,岂不是竹篮打水,事与愿违。

再次打包优化尝试

后来查到有人同样遇到这个问题,提出一个issues#6362,原来只引入需要的element-ui组件,webpack还是把整体的 UI 库和样式都打包了,需要一个 webpackbabel 插件 babel-plugin-component,这样才能真正按需引入打包。这块其实被写到官方文档更换 自定义主题 的配置了。

于是 npm i babel-pugin-componet -D 安装后,在增加 .babelrc 文件插件配置

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": [
    "transform-vue-jsx",
    "transform-runtime",
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

页面运行正常,再次打包。

Hash: f182f70cb4ceee63b5d5
Version: webpack 3.12.0
Time: 10912ms
                                                  Asset       Size  Chunks             Chunk Names
    static/css/app.95c94c90ab11fdd4dfb413718f444d0c.css    39.9 kB       1  [emitted]  app
                 static/fonts/element-icons.732389d.ttf      56 kB          [emitted]
               static/js/vendor.befb0a8962f74af4b7e2.js     157 kB       0  [emitted]  vendor
                  static/js/app.5343843cc20a78e80469.js    3.86 kB       1  [emitted]  app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]  manifest
                static/fonts/element-icons.535877f.woff    28.2 kB          [emitted]
static/css/app.95c94c90ab11fdd4dfb413718f444d0c.css.map    93.5 kB          [emitted]
           static/js/vendor.befb0a8962f74af4b7e2.js.map     776 kB       0  [emitted]  vendor
              static/js/app.5343843cc20a78e80469.js.map    17.1 kB       1  [emitted]  app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]  manifest
                                             index.html  506 bytes          [emitted]

static/js/vendor.js 确实变小了,157kB。再来看各个模块分析图。

再次优化后各个模块分析图

模块总共 157.93KB,少了 5 倍!

更换主题-覆盖样式

element-uitheme-chalk 使用 SCSS 编写,如果在自己的项目中也是用 SCSS,那么可以直接在项目中改变样式变量,因此可以在前面新建的 element-variables.scss 文件用新的主题颜色变量覆盖即可。

/**
* 覆盖主题色
*/
/*主题颜色变量*/
$--color-primary: #f0f;

/*icon字体路径变量*/
$--font-path: '~element-ui/lib/theme-chalk/fonts';

/* 引入全部默认样式 会引入没用到的组件样式 */
// @import '~element-ui/packages/theme-chalk/src/index';

/* 按需引入用到的组件的scss文件和基础scss文件 */
@import '~element-ui/packages/theme-chalk/src/base.scss';
@import '~element-ui/packages/theme-chalk/src/rate.scss';
@import '~element-ui/packages/theme-chalk/src/button.scss';
@import '~element-ui/packages/theme-chalk/src/row.scss';

现在我们的主题就变成了预期效果

主题改变了

可能你已经注意到了,这里推荐的是分别引入用到的组件样式,而不是引入全部默认样式,因为这样会导致引入没有使用到的组件样式。比如当前案例中我们没有使用到 ColorPicker 组件,在打包输出的 css 文件中确有该组件样式。

打包样式表出现没有使用的样式

更换主题-纯净样式

通过以上优化可以按需的将所用到组件打包,排除没用到的组件,减少包的大小。但是,还是存在一个小瑕疵:一个用到的组件样式会被两次打包,一次是默认的样式,一次是覆盖的样式。

还存在默认样式

出现这个问题是由于我们在两个地方对样式进行引入了,一个是在 .babelrc 文件中通过 babel-plugin-component 插件按需引入 element-ui 组件及其默认样式,一个是在 element-variables.scss 文件中覆盖默认样式生成的自定义样式。

所以怎样将二者结合,即babel-plugin-component 插件按需引入的组件样式改成用户自定义样式,达成纯净样式目标呢?这里就要用到 element-ui 的主题工具进行深层次的主题定制。

主题和主题工具安装

首先安装主题工具 element-theme,可以全局安装也可安装在项目目录。这里推荐安装在项目录,方便别人 clone 项目时能直接安装依赖并启动。

npm i element-theme -D

然后安装白垩主题,可以从 npm 安装或者从 GitHub 拉取最新代码。

# 从 npm
npm i element-theme-chalk -D

# 从 GitHub
npm i https://github.com/ElementUI/theme-chalk -D

主题构建

element-theme 支持的构建有 Node APICLI 方式。

通过 CLI 构建方式

如果全局安装可以在命令行里通过 et 调用工具,如果安装在当前目录下,需要通过 node_modules/.bin/et 访问到命令。执行 -i--init) 初始化变量文件。默认输出到 element-variables.scss,当然你可以传参数指定文件输出目录。如果你想启用 watch 模式,实时编译主题,增加 -w--watch) 参数;如果你在初始化时指定了自定义变量文件,则需要增加 -c--config) 参数,并带上你的变量文件名。默认情况下编译的主题目录是放在 ./theme 下,你可以通过 -o--out) 参数指定打包目录。

# 初始化变量文件
et --init [file path]

# 实时编译
et --watch [--config variable file path] [--out theme path]

# 编译
et [--config variable file path] [--out theme path] [--minimize]

通过 Node API 构建方式

引入 element-theme 通过 Node API 形式构建

var et = require('element-theme')

// 实时编译模式
et.watch({
  config: 'variables/path',
  out: 'output/path'
})

// 编译
et.run({
  config: 'variables/path', // 配置参数文件路径 默认`./element-variables.css`
  out: 'output/path', // 输出目录 默认`./theme`
  minimize: false, // 压缩文件
  browsers: ['ie > 9', 'last 2 versions'], // 浏览器支持
  components: ['button', 'input'] // 选定组件构建自定义主题
})

应用 Node API 构建自定义主题

在这里,为了让主题的构建更加直观和被项目共享,采用 Node API 方式构建,在项目根目录下新建 theme.js文件。

const et = require('element-theme')
// 第一步生成样式变量文件
// et.init('./src/theme.scss')
// 第二步根据实际需要修改该文件
// ...
// 第三步根据该变量文件编译出自定义的主题样式文件
et.run({
  config: './src/theme.scss',
  out: './src/theme'
})

package.json 中增加 scripts 指令

{
  "scripts": {
    "theme": "node theme.js"
  }
}

这样就可以通过 npm run theme 指令来编译主题了。编译过程:

  • 运行该指令初始化主题变量文件 theme.scss
  • 根据实际需要修改这个文件里主题样式。
  • 再运行该指令编译输出自定义的主题样式文件放在 theme 目录下。

这样就完成了所有自定义主题样式的构建。要想将这些自定义样式随着组件按需引入,需要将 .babelrc 文件中按需引入插件 babel-plugin-component 参数 styleLibraryName 从原本的 element-ui 默认样式目录变成现在自定义目录 ~src/theme

"plugins": [
    "transform-vue-jsx",
    "transform-runtime",
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "~src/theme"
      }
    ]
  ]

一切准备就绪,项目打包,打包后的 css 文件中只有唯一自定义样式,没有了默认样式,也不存在没被引入组件的样式,实现了我们预期的纯净的自定义样式!

不存在默认样式

Hash: c442bcf9d471bddfdccf
Version: webpack 3.12.0
Time: 10174ms
                                                  Asset       Size  Chunks             Chunk Names
    static/css/app.52d411d0c1b344066ec1f456355aa7b9.css    38.8 kB       1  [emitted]  app
                static/fonts/element-icons.535877f.woff    28.2 kB          [emitted]
               static/js/vendor.befb0a8962f74af4b7e2.js     157 kB       0  [emitted]  vendor
                  static/js/app.43c09c1f16b24d371e07.js    3.82 kB       1  [emitted]  app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]  manifest
                 static/fonts/element-icons.732389d.ttf      56 kB          [emitted]
static/css/app.52d411d0c1b344066ec1f456355aa7b9.css.map    81.3 kB          [emitted]
           static/js/vendor.befb0a8962f74af4b7e2.js.map     776 kB       0  [emitted]  vendor
              static/js/app.43c09c1f16b24d371e07.js.map    17.1 kB       1  [emitted]  app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]  manifest
                                             index.html  506 bytes          [emitted]

由于样式是纯净的,css 文件大小从原来完全引入的 234KB 变成 38.8KB,进一步减小了打包大小。

总结

通过以上实验分析我们可以得知,element-ui 要想实现按需引入和纯净的主题样式:

  • 首先通过 babel-plugin-component 插件进行按需引入。
  • 再用 element-theme 工具生成样变量文件。
  • 然后根据项目需求修改自定义样式,依据该文件构建生成所有样式。
  • 最后将按需引入样式 styleLibraryName 指向自定义样式目录。

如果对样式提取要求不高,可直接采取变量覆盖形式(同时存在默认样式)。
还有不清楚可以戳这里查看案例源码,赠人 star,手有余香。

完~ps:个人见解有限,欢迎指正。

查看原文

赞 25 收藏 18 评论 7

wuwhs 发布了文章 · 8月4日

了解JS压缩图片,这一篇就够了

image

前言

公司的移动端业务需要在用户上传图片是由前端压缩图片大小,再上传到服务器,这样可以减少移动端上行流量,减少用户上传等待时长,优化用户体验。

插播一下,本文案例已整理成插件,已上传 npm ,可通过 npm install js-image-compressor -D 安装使用,可以从 github 下载。

JavaScript 操作压缩图片原理不难,已有成熟 API,然而在实际输出压缩后结果却总有意外,有些图片竟会越压缩越大,加之终端(手机)类型众多,有些手机压缩图片甚至变黑。

压缩小龙女,哈哈哈😂

所以本文将试图解决如下问题:

  • 弄清 Image 对象、data URLCanvasFile(Blob)之间的转化关系;
  • 图片压缩关键技巧;
  • 超大图片压缩黑屏问题。

转化关系

在实际应用中有可能使用的情境:大多时候我们直接读取用户上传的 File 对象,读写到画布(canvas)上,利用 CanvasAPI 进行压缩,完成压缩之后再转成 File(Blob) 对象,上传到远程图片服务器;不妨有时候我们也需要将一个 base64 字符串压缩之后再变为 base64 字符串传入到远程数据库或者再转成 File(Blob) 对象。一般的,它们有如下转化关系:

js-image-compressor-flow-chat

具体实现

下面将按照转化关系图中的转化方法一一实现。

file2DataUrl(file, callback)

用户通过页面标签 <input type="file" /> 上传的本地图片直接转化 data URL 字符串形式。可以使用 FileReader 文件读取构造函数。FileReader 对象允许 Web 应用程序异步读取存储在计算机上的文件(或原始数据缓冲区)的内容,使用 FileBlob 对象指定要读取的文件或数据。该实例方法 readAsDataURL 读取文件内容并转化成 base64 字符串。在读取完后,在实例属性 result 上可获取文件内容。

function file2DataUrl(file, callback) {
  var reader = new FileReader();
  reader.onload = function () {
    callback(reader.result);
  };
  reader.readAsDataURL(file);
}

Data URL 由四个部分组成:前缀(data:)、指示数据类型的 MIME 类型、如果非文本则为可选的 base64 标记、数据本身:

data:<mediatype>,<data>

比如一张 png 格式图片,转化为 base64 字符串形式:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAgAElEQVR4XuxdB5g

file2Image(file, callback)

若想将用户通过本地上传的图片放入缓存并 img 标签显示出来,除了可以利用以上方法转化成的 base64 字符串作为图片 src,还可以直接用 URL 对象,引用保存在 FileBlob 中数据的 URL。使用对象 URL 的好处是可以不必把文件内容读取到 JavaScript 中 而直接使用文件内容。为此,只要在需要文件内容的地方提供对象 URL 即可。

function file2Image(file, callback) {
  var image = new Image();
  var URL = window.webkitURL || window.URL;
  if (URL) {
    var url = URL.createObjectURL(file);
    image.onload = function() {
      callback(image);
      URL.revokeObjectURL(url);
    };
    image.src = url;
  } else {
    inputFile2DataUrl(file, function(dataUrl) {
      image.onload = function() {
        callback(image);
      }
      image.src = dataUrl;
    });
  }
}

注意:要创建对象 URL,可以使用 window.URL.createObjectURL() 方法,并传入 FileBlob 对象。如果不再需要相应数据,最好释放它占用的内容。但只要有代码在引用对象 URL,内存就不会释放。要手工释放内存,可以把对象 URL 传给 URL.revokeObjectURL()

url2Image(url, callback)

通过图片链接(url)获取图片 Image 对象,由于图片加载是异步的,因此放到回调函数 callback 回传获取到的 Image 对象。

function url2Image(url, callback) {
  var image = new Image();
  image.src = url;
  image.onload = function() {
    callback(image);
  }
}

image2Canvas(image)

利用 drawImage() 方法将 Image 对象绘画在 Canvas 对象上。

drawImage 有三种语法形式:

void ctx.drawImage(image, dx, dy);
void ctx.drawImage(image, dx, dy, dWidth, dHeight);
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

参数:

  • image 绘制到上下文的元素;
  • sx 绘制选择框左上角以 Image 为基准 X 轴坐标;
  • sy 绘制选择框左上角以 Image 为基准 Y 轴坐标;
  • sWidth 绘制选择框宽度;
  • sHeight 绘制选择框宽度;
  • dxImage 的左上角在目标 canvasX 轴坐标;
  • dyImage 的左上角在目标 canvasY 轴坐标;
  • dWidthImage 在目标 canvas 上绘制的宽度;
  • dHeightImage 在目标 canvas 上绘制的高度;

canvas-draw-image

function image2Canvas(image) {
  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  canvas.width = image.naturalWidth;
  canvas.height = image.naturalHeight;
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  return canvas;
}

canvas2DataUrl(canvas, quality, type)

HTMLCanvasElement 对象有 toDataURL(type, encoderOptions) 方法,返回一个包含图片展示的 data URL 。同时可以指定输出格式和质量。

参数分别为:

  • type 图片格式,默认为 image/png
  • encoderOptions在指定图片格式为 image/jpegimage/webp 的情况下,可以从 01 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92,其他参数会被忽略。
function canvas2DataUrl(canvas, quality, type) {
  return canvas.toDataURL(type || 'image/jpeg', quality || 0.8);
}

dataUrl2Image(dataUrl, callback)

图片链接也可以是 base64 字符串,直接赋值给 Image 对象 src 即可。

function dataUrl2Image(dataUrl, callback) {
  var image = new Image();
  image.onload = function() {
    callback(image);
  };
  image.src = dataUrl;
}

dataUrl2Blob(dataUrl, type)

data URL 字符串转化为 Blob 对象。主要思路是:先将 data URL 数据(data) 部分提取出来,用 atob 对经过 base64 编码的字符串进行解码,再转化成 Unicode 编码,存储在Uint8Array(8位无符号整型数组,每个元素是一个字节) 类型数组,最终转化成 Blob 对象。

function dataUrl2Blob(dataUrl, type) {
  var data = dataUrl.split(',')[1];
  var mimePattern = /^data:(.*?)(;base64)?,/;
  var mime = dataUrl.match(mimePattern)[1];
  var binStr = atob(data);
  var arr = new Uint8Array(len);

  for (var i = 0; i < len; i++) {
    arr[i] = binStr.charCodeAt(i);
  }
  return new Blob([arr], {type: type || mime});
}

canvas2Blob(canvas, callback, quality, type)

HTMLCanvasElementtoBlob(callback, [type], [encoderOptions]) 方法创造 Blob 对象,用以展示 canvas 上的图片;这个图片文件可以被缓存或保存到本地,由用户代理端自行决定。第二个参数指定图片格式,如不特别指明,图片的类型默认为 image/png,分辨率为 96dpi。第三个参数用于针对image/jpeg 格式的图片进行输出图片的质量设置。

function canvas2Blob(canvas, callback, quality, type){
  canvas.toBlob(function(blob) {
    callback(blob);
  }, type || 'image/jpeg', quality || 0.8);
}

为兼容低版本浏览器,作为 toBlobpolyfill 方案,可以用上面 data URL 生成 Blob 方法 dataUrl2Blob 作为HTMLCanvasElement 原型方法。

if (!HTMLCanvasElement.prototype.toBlob) {
 Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
  value: function (callback, type, quality) {
    let dataUrl = this.toDataURL(type, quality);
    callback(dataUrl2Blob(dataUrl));
  }
 });
}

blob2DataUrl(blob, callback)

Blob 对象转化成 data URL 数据,由于 FileReader 的实例 readAsDataURL 方法不仅支持读取文件,还支持读取 Blob 对象数据,这里复用上面 file2DataUrl 方法即可:

function blob2DataUrl(blob, callback) {
  file2DataUrl(blob, callback);
}

blob2Image(blob, callback)

Blob 对象转化成 Image 对象,可通过 URL 对象引用文件,也支持引用 Blob 这样的类文件对象,同样,这里复用上面 file2Image 方法即可:

function blob2Image(blob, callback) {
  file2Image(blob, callback);
}

upload(url, file, callback)

上传图片(已压缩),可以使用 FormData 传入文件对象,通过 XHR 直接把文件上传到服务器。

function upload(url, file, callback) {
  var xhr = new XMLHttpRequest();
  var fd = new FormData();
  fd.append('file', file);
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
      // 上传成功
      callback && callback(xhr.responseText);
    } else {
      throw new Error(xhr);
    }
  }
  xhr.open('POST', url, true);
  xhr.send(fd);
}

也可以使用 FileReader 读取文件内容,转化成二进制上传

function upload(url, file) {
  var reader = new FileReader();
  var xhr = new XMLHttpRequest();

  xhr.open('POST', url, true);
  xhr.overrideMimeType('text/plain; charset=x-user-defined-binary');

  reader.onload = function() {
    xhr.send(reader.result);
  };
  reader.readAsBinaryString(file);
}

实现简易图片压缩

在熟悉以上各种图片转化方法的具体实现,将它们封装在一个公用对象 util 里,再结合压缩转化流程图,这里我们可以简单实现图片压缩了:
首先将上传图片转化成 Image 对象,再将写入到 Canvas 画布,最后由 Canvas 对象 API 对图片的大小和尺寸输出调整,实现压缩目的。

/**
 * 简易图片压缩方法
 * @param {Object} options 相关参数
 */
(function (win) {
  var REGEXP_IMAGE_TYPE = /^image\//;
  var util = {};
  var defaultOptions = {
    file: null,
    quality: 0.8
  };
  var isFunc = function (fn) { return typeof fn === 'function'; };
  var isImageType = function (value) { return REGEXP_IMAGE_TYPE.test(value); };

  /**
   * 简易图片压缩构造函数
   * @param {Object} options 相关参数
   */
  function SimpleImageCompressor(options) {
    options = Object.assign({}, defaultOptions, options);
    this.options = options;
    this.file = options.file;
    this.init();
  }

  var _proto = SimpleImageCompressor.prototype;
  win.SimpleImageCompressor = SimpleImageCompressor;

  /**
   * 初始化
   */
  _proto.init = function init() {
    var _this = this;
    var file = this.file;
    var options = this.options;

    if (!file || !isImageType(file.type)) {
      console.error('请上传图片文件!');
      return;
    }

    if (!isImageType(options.mimeType)) {
      options.mimeType = file.type;
    }

    util.file2Image(file, function (img) {
      var canvas = util.image2Canvas(img);
      file.width = img.naturalWidth;
      file.height = img.naturalHeight;
      _this.beforeCompress(file, canvas);

      util.canvas2Blob(canvas, function (blob) {
        blob.width = canvas.width;
        blob.height = canvas.height;
        options.success && options.success(blob);
      }, options.quality, options.mimeType)
    })
  }

  /**
   * 压缩之前,读取图片之后钩子函数
   */
  _proto.beforeCompress = function beforeCompress() {
    if (isFunc(this.options.beforeCompress)) {
      this.options.beforeCompress(this.file);
    }
  }

  // 省略 `util` 公用方法定义
  // ...

  // 将 `util` 公用方法添加到实例的静态属性上
  for (key in util) {
    if (util.hasOwnProperty(key)) {
      SimpleImageCompressor[key] = util[key];
    }
  }
})(window)

这个简易图片压缩方法调用和入参:

var fileEle = document.getElementById('file');

fileEle.addEventListener('change', function () {
  file = this.files[0];

  var options = {
    file: file,
    quality: 0.6,
    mimeType: 'image/jpeg',
    // 压缩前回调
    beforeCompress: function (result) {
      console.log('压缩之前图片尺寸大小: ', result.size);
      console.log('mime 类型: ', result.type);
      // 将上传图片在页面预览
      // SimpleImageCompressor.file2DataUrl(result, function (url) {
      //   document.getElementById('origin').src = url;
      // })
    },
    // 压缩成功回调
    success: function (result) {
      console.log('压缩之后图片尺寸大小: ', result.size);
      console.log('mime 类型: ', result.type);
      console.log('压缩率: ', (result.size / file.size * 100).toFixed(2) + '%');

      // 生成压缩后图片在页面展示
      // SimpleImageCompressor.file2DataUrl(result, function (url) {
      //   document.getElementById('output').src = url;
      // })

      // 上传到远程服务器
      // SimpleImageCompressor.upload('/upload.png', result);
    }
  };

  new SimpleImageCompressor(options);
}, false);

如果看到这里的客官不嫌弃这个 demo 太简单可以戳这里试试水。如果你有足够的耐心多传几种类型图片就会发现还存在如下问题:

  • 压缩输出图片寸尺固定为原始图片尺寸大小,而实际可能需要控制输出图片尺寸,同时达到尺寸也被压缩目的;
  • png 格式图片同格式压缩,压缩率不高,还有可能出现“不减反增”现象;
  • 有些情况,其他格式转化成 png 格式也会出现“不减反增”现象;
  • 大尺寸 png 格式图片在一些手机上,压缩后出现“黑屏”现象;

越压缩越膨胀😂

改进版图片压缩

俗话说“罗马不是一天建成的”,通过上述实验,我们发现了很多不足,下面将逐条问题分析,寻求解决方案。

压缩输出图片寸尺固定为原始图片尺寸大小,而实际可能需要控制输出图片尺寸,同时达到尺寸也被压缩目的;

为了避免压缩图片变形,一般采用等比缩放,首先要计算出原始图片宽高比 aspectRatio,用户设置的高乘以 aspectRatio,得出等比缩放后的宽,若比用户设置宽的小,则用户设置的高为为基准缩放,否则以宽为基准缩放。

var aspectRatio = naturalWidth / naturalHeight;
var width = Math.max(options.width, 0) || naturalWidth;
var height = Math.max(options.height, 0) || naturalHeight;
if (height * aspectRatio > width) {
  height = width / aspectRatio;
} else {
  width = height * aspectRatio;
}

输出图片的尺寸确定了,接下来就是按这个尺寸创建一个 Canvas 画布,将图片画上去。这里可以将上面提到的 image2Canvas 方法稍微做一下改造:

function image2Canvas(image, destWidth, destHeight) {
  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  canvas.width = destWidth || image.naturalWidth;
  canvas.height = destHeight || image.naturalHeight;
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
  return canvas;
}
png 格式图片同格式压缩,压缩率不高,还有可能出现“不减反增”现象

一般的,不建议将 png 格式图片压缩成自身格式,这样压缩率不理想,有时反而会造成自身质量变得更大。

因为我们在“具体实现”中两个有关压缩关键 API

  • toBlob(callback, [type], [encoderOptions]) 参数 encoderOptions 用于针对image/jpeg 格式的图片进行输出图片的质量设置;
  • toDataURL(type, encoderOptions 参数encoderOptions 在指定图片格式为 image/jpegimage/webp 的情况下,可以从 01 的区间内选择图片的质量。

均未对 png 格式图片有压缩效果。

有个折衷的方案,我们可以设置一个阈值,如果 png 图片的质量小于这个值,就还是压缩输出 png 格式,这样最差的输出结果不至于质量太大,在此基础上,如果压缩后图片大小 “不减反增”,我们就兜底处理输出源图片给用户。当图片质量大于某个值时,我们压缩成 jpeg 格式。

// `png` 格式图片大小超过 `convertSize`, 转化成 `jpeg` 格式
if (file.size > options.convertSize && options.mimeType === 'image/png') {
  options.mimeType = 'image/jpeg';
}
// 省略一些代码
// ...
// 用户期待的输出宽高没有大于源图片的宽高情况下,输出文件大小大于源文件,返回源文件
if (result.size > file.size && !(options.width > naturalWidth || options.height > naturalHeight)) {
  result = file;
}
大尺寸 png 格式图片在一些手机上,压缩后出现“黑屏”现象;

由于各大浏览器对 Canvas 最大尺寸支持不同

浏览器最大宽高最大面积
Chrome32,767 pixels268,435,456 pixels(e.g.16,384 x 16,384)
Firefox32,767 pixels472,907,776 pixels(e.g.22,528 x 20,992)
IE8,192 pixelsN/A
IE Mobile4,096 pixelsN/A

如果图片尺寸过大,在创建同尺寸画布,再画上图片,就会出现异常情况,即生成的画布没有图片像素,而画布本身默认给的背景色为黑色,这样就导致图片“黑屏”情况。

这里可以通过控制输出图片最大宽高防止生成画布越界,并且用透明色覆盖默认黑色背景解决解决“黑屏”问题:

// ...
// 限制最小和最大宽高
var maxWidth = Math.max(options.maxWidth, 0) || Infinity;
var maxHeight = Math.max(options.maxHeight, 0) || Infinity;
var minWidth = Math.max(options.minWidth, 0) || 0;
var minHeight = Math.max(options.minHeight, 0) || 0;

if (maxWidth < Infinity && maxHeight < Infinity) {
  if (maxHeight * aspectRatio > maxWidth) {
    maxHeight = maxWidth / aspectRatio;
  } else {
    maxWidth = maxHeight * aspectRatio;
  }
} else if (maxWidth < Infinity) {
  maxHeight = maxWidth / aspectRatio;
} else if (maxHeight < Infinity) {
  maxWidth = maxHeight * aspectRatio;
}

if (minWidth > 0 && minHeight > 0) {
  if (minHeight * aspectRatio > minWidth) {
    minHeight = minWidth / aspectRatio;
  } else {
    minWidth = minHeight * aspectRatio;
  }
} else if (minWidth > 0) {
  minHeight = minWidth / aspectRatio;
} else if (minHeight > 0) {
  minWidth = minHeight * aspectRatio;
}

width = Math.floor(Math.min(Math.max(width, minWidth), maxWidth));
height = Math.floor(Math.min(Math.max(height, minHeight), maxHeight));

// ...
// 覆盖默认填充颜色 (#000)
var fillStyle = 'transparent';
context.fillStyle = fillStyle;

到这里,上述的意外问题被我们一一解决了,如需体验改进版的图片压缩 demo 的小伙伴可以戳这里

总结

我们梳理了通过页面标签 <input type="file" /> 上传本地图片到图片被压缩整个过程,也覆盖到了在实际使用中还存在的一些意外情况,提供了相应的解决方案。将改进版图片压缩整理成插件,已上传 npm ,可通过 npm install js-image-compressor -D 安装使用,可以从 github 下载。整理匆忙,如有问题欢迎大家指正,完~

查看原文

赞 55 收藏 37 评论 33

wuwhs 发布了文章 · 5月25日

vue3.0 修炼手册

前言

随着2020年4月份 Vue3.0 beta 发布,惊喜于其性能的提升,友好的 TS 支持(语法补全),改写ES export写法,利用Tree shaking 减少打包大小,Composition APICustom Renderer API 新功能拓展及其RECs 文档的完善。当然,还有一些后续工作(vuex, vue-router, cli, vue-test-utils, DevTools, Vetur, Nuxt)待完成,当前还不稳定,正式在项目中使用(目前可以在小型新项目中),还需在2020 Q2稳定版本之后。

vue3

Vue3.0 的到来已只是时间问题,未雨绸缪,何不先来尝鲜一波新特性~

设计动机

逻辑组合与复用

组件 API 设计所面对的核心问题之一就是如何组织逻辑,以及如何在多个组件之间抽取和复用逻辑。基于 Vue 2.x 目前的 API 我们有一些常见的逻辑复用模式,但都或多或少存在一些问题。这些模式包括:

  • Mixins
  • 高阶组件 (Higher-order Components, aka HOCs)
  • Renderless Components (基于 scoped slots / 作用域插槽封装逻辑的组件)

以上这些模式存在以下问题:

  • 模版中的数据来源不清晰。举例来说,当一个组件中使用了多个 mixin 的时候,光看模版会很难分清一个属性到底是来自哪一个 mixinHOC 也有类似的问题。
  • 命名空间冲突。由不同开发者开发的 mixin 无法保证不会正好用到一样的属性或是方法名。HOC 在注入的 props 中也存在类似问题。
  • 性能。HOCRenderless Components 都需要额外的组件实例嵌套来封装逻辑,导致无谓的性能开销。

Composition APIReact Hooks 的启发,提供了一个全新的逻辑复用方案,且不存在上述问题。使用基于函数的 API,我们可以将相关联的代码抽取到一个 "composition function"(组合函数)中 —— 该函数封装了相关联的逻辑,并将需要暴露给组件的状态以响应式的数据源的方式返回出来。这里是一个用组合函数来封装鼠标位置侦听逻辑的例子:

function useMouse() {
  const x = ref(0)
  const y = ref(0)
  const update = e => {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}

// 在组件中使用该函数
const Component = {
  setup() {
    const { x, y } = useMouse()
    // 与其它函数配合使用
    const { z } = useOtherLogic()
    return { x, y, z }
  },
  template: `<div>{{ x }} {{ y }} {{ z }}</div>`
}

从以上例子中可以看到:

  • 暴露给模版的属性来源清晰(从函数返回);
  • 返回值可以被任意重命名,所以不存在命名空间冲突;
  • 没有创建额外的组件实例所带来的性能损耗。

类型推导

3.0 的一个主要设计目标是增强对 TypeScript 的支持。
基于函数的 API 天然对类型推导很友好,因为 TS 对函数的参数、返回值和泛型的支持已经非常完备。

打包尺寸

基于函数的 API 每一个函数都可以作为 named ES export 被单独引入,这使得它们对 tree-shaking 非常友好。没有被使用的 API 的相关代码可以在最终打包时被移除。同时,基于函数 API 所写的代码也有更好的压缩效率,因为所有的函数名和 setup 函数体内部的变量名都可以被压缩,但对象和 class 的属性/方法名却不可以。

Composition API

除了渲染函数 API 和作用域插槽语法之外的所有内容都将保持不变,或者通过兼容性构建让其与 2.x 保持兼容

Vue 3.0并不像 Angular 那样超强跨度版本,导致不兼容,而是在兼容 2.x 基础上做改进。

在这里可以在 2.x 中通过引入 @vue/composition-api,使用 Vue 3.0 新特性。

初始化项目

1、安装 vue-cli3

npm install -g @vue/cli

2、创建项目

vue create vue3

3、项目中安装 composition-api

npm install @vue/composition-api --save

4、在使用任何 @vue/composition-api 提供的能力前,必须先通过 Vue.use() 进行安装

import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'

Vue.use(VueCompositionApi)

setup()

Vue3 引入一个新的组件选项,setup(),它会在一个组件实例被创建时,初始化了 props 之后调用。 会接收到初始的 props 作为参数:

export default {
  props: {
    name: String
  },
  setup(props) {
    console.log(props.name)
  }
}

传进来的 props 是响应式的,当后续 props 发生变动时它也会被框架内部同步更新。但对于用户代码来说,它是不可修改的(会导致警告)。

同时,setup() 执行时机相当于 2.x 生命周期 beforeCreate 之后,且在 created 之前:

export default {
  beforeCreate() {
    console.log('beforeCreate')
  },
  setup() {
    console.log('setup')
  },
  created() {
    console.log('created')
  }
}
// 打印结果
// beforeCreate
// setup
// created

setup()this 不再是 vue 实例对象了,而是 undefined,可以理解为此时机实例还没有创建。在 setup() 第二个参数是上下文参数,提供了一些 2.xthis 上有用属性。

export default {
  setup(props, context) {
    console.log('this: ', this)
    console.log('context: ', context)
  }
}
// 打印结果
// this: undefined
// context: {
//   attrs: Object
//   emit: f()
//   isServer: false
//   listeners: Object
//   parent: VueComponent
//   refs: Object
//   root: Vue
//   slots: {}
//   ssrContext: undefined
// }

类似 data()setup() 可以返回一个对象,这个对象上的属性将会暴露给模版的渲染上下文:

<template>
  <div>{{ name }}</div>
</template>

<script>
export default {
  setup() {
    return {
      name: 'zs'
    }
  }
}
</script>

reactive()

等价于 vue 2.x 中的 Vue.observable() 函数,vue 3.x 中提供了 reactive() 函数,用来创建响应式的数据对象。

当(引用)数据直接改变不会让模版响应更新渲染:

<template>
  <div>count: {{state.count}}</div>
</template>

<script>
export default {
  setup() {
    const state = { count: 0 }
    setTimeout(() => {
      state.count++
    })
    return { state }
  }
}
// 一秒后页面没有变化
</script>

reactive 创建的响应式数据对象,在对象属性发生变化时,模版是可以响应更新渲染的:

<template>
  <div>count: {{state.count}}</div>
</template>

<script>
import { reactive } from '@vue/composition-api'

export default {
  setup() {
    const state = reactive({ count: 0 })

    setTimeout(() => {
      state.count++
    }, 1000)

    return { state }
  }
}
// 一秒后页面数字从0变成1
</script>

ref()

Javascript 中,原始类型(如 StringNumber)只有值,没有引用。如果在一个函数中返回一个字符串变量,接收到这个字符串的代码只会获得一个值,是无法追踪原始变量后续的变化的。

<template>
  <div>count: {{state.count}}</div>
</template>

<script>
import { ref } from '@vue/composition-api'

export default {
  setup() {
    const count = 0

    setTimeout(() => {
      count++
    }, 1000)

    return { count }
  }
}
// 页面没有变化
</script>

因此,包装对象 ref() 的意义就在于提供一个让我们能够在函数之间以引用的方式传递任意类型值的容器。这有点像 React Hooks 中的 useRef —— 但不同的是 Vue 的包装对象同时还是响应式的数据源。有了这样的容器,我们就可以在封装了逻辑的组合函数中将状态以引用的方式传回给组件。组件负责展示(追踪依赖),组合函数负责管理状态(触发更新)。

ref() 返回的是一个 value reference (包装对象)。一个包装对象只有一个属性:.value ,该属性指向内部被包装的值。包装对象的值可以被直接修改。

<script>
import { ref } from '@vue/composition-api'

export default {
  setup() {
    const count = ref(0)
    console.log('count.value: ', count.value)
    count.value++ // 直接修改包装对象的值
    console.log('count.value: ', count.value)
  }
}
// 打印结果:
// count.value: 0
// count.value: 1
</script>

当包装对象被暴露给模版渲染上下文,或是被嵌套在另一个响应式对象中的时候,它会被自动展开 (unwrap) 为内部的值:

<template>
  <div>ref count: {{count}}</div>
</template>

<script>
import { ref } from '@vue/composition-api'

export default {
  setup() {
    const count = ref(0)
    console.log('count.value: ', count.value)
    return {
      count // 包装对象 value 属性自动展开
    }
  }
}
</script>

也可以用 ref() 包装对象作为 reactive() 创建的对象的属性值,同样属性值 ref() 包装对象也会模版上下文被展开:

<template>
  <div>reactive ref count: {{state.count}}</div>
</template>

<script>
import { reactive, ref } from '@vue/composition-api'

export default {
  setup() {
    const count = ref(0)
    const state = reactive({count})
    return {
      state // 包装对象 value 属性自动展开
    }
  }
}
</script>

Vue 2.x 中用实例上的 $refs 属性获取模版元素中 ref 属性标记 DOM 或组件信息,在这里用 ref() 包装对象也可以用来引用页面元素和组件;

<template>
  <div><p ref="text">Hello</p></div>
</template>

<script>
import { ref } from '@vue/composition-api'

export default {
  setup() {
    const text = ref(null)
    setTimeout(() => {
      console.log('text: ', text.value.innerHTML)
    }, 1000)
    return {
      text
    }
  }
}
// 打印结果:
// text: Hello
</script>

unref()

如果参数是一个 ref 则返回它的 value,否则返回参数本身。它是 val = isRef(val) ? val.value : val 的语法糖。

isref()

检查一个值是否为一个 ref 对象。

toRefs()

把一个响应式对象转换成普通对象,该普通对象的每个 property 都是一个 ref ,和响应式对象 property 一一对应。并且,当想要从一个组合逻辑函数中返回响应式对象时,用 toRefs 是很有效的,该 API 让消费组件可以 解构 / 扩展(使用 ... 操作符)返回的对象,并不会丢失响应性:

<template>
  <div>
    <p>count: {{count}}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
import { reactive, toRefs } from '@vue/composition-api'

export default {
  setup() {
    const state = reactive({
      count: 0
    })

    const increment = () => {
      state.count++
    }

    return {
      ...toRefs(state), // 解构出来不丢失响应性
      increment
    }
  }
}
</script>

computed()

computed() 用来创建计算属性,computed() 函数的返回值是一个 ref 的实例。这个值模式是只读的:

import { ref, computed } from '@vue/composition-api'

export default {
  setup() {
    const count = ref(0)
    const plusOne = computed(() => count.value + 1)
    plusOne.value = 10
    console.log('plusOne.value: ', plusOne.value)
    console.log('count.value: ', count.value)
  }
}
// 打印结果:
// [Vue warn]: Computed property was assigned to but it has no setter.
// plusOne.value: 1
// count.value: 0

或者传入一个拥有 getset 函数的对象,创建一个可手动修改的计算状态:

import { ref, computed } from '@vue/composition-api'

export default {
  setup() {
    const count = ref(0)
    const plusOne = computed({
      get: () => count.value + 1,
      set: val => {
        count.value = val - 1
      }
    })
    plusOne.value = 10
    console.log('plusOne.value: ', plusOne.value)
    console.log('count.value: ', count.value)
  }
}
// 打印结果:
// plusOne.value: 10
// count.value: 9

watchEffect()

watchEffect() 监测副作用函数。立即执行传入的一个函数,并响应式追踪其依赖,并在其依赖变更时重新运行该函数:

<template>
  <div>
    <p>count: {{count}}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
import { ref, watchEffect } from '@vue/composition-api'

export default {
  setup() {
    // 监视 ref 数据源
    const count = ref(0)
    // 监视依赖有变化,立刻执行
    watchEffect(() => {
      console.log('count.value: ', count.value)
    })
    const increment = () => {
      count.value++
    }
    return {
      count,
      increment
    }
  }
}
</script>

停止侦听。当 watchEffect 在组件的 setup() 函数或生命周期钩子被调用时, 侦听器会被链接到该组件的生命周期,并在组件卸载时自动停止。

在一些情况下(比如超时就无需继续监听变化),也可以显式调用返回值以停止侦听:

<template>
  <div>
    <p>count: {{state.count}}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
import { reactive, watchEffect } from '@vue/composition-api'

export default {
  setup() {
    // 监视 reactive 数据源
    const state = reactive({
      count: 0
    })
    const stop = watchEffect(() => {
      console.log('state.count: ', state.count)
    })
    setTimeout(() => {
      stop()
    }, 3000)
    const increment = () => {
      state.count++
    }
    return {
      state,
      increment
    }
  }
}
// 3秒后,点击+1按钮不再打印
</script>

清除副作用。有时候当观察的数据源变化后,我们可能需要对之前所执行的副作用进行清理。举例来说,一个异步操作在完成之前数据就产生了变化,我们可能要撤销还在等待的前一个操作。为了处理这种情况,watchEffect 的回调会接收到一个参数是用来注册清理操作的函数。调用这个函数可以注册一个清理函数。清理函数会在下属情况下被调用:

  • 副作用即将重新执行时
  • 侦听器被停止 (如果在 setup() 或 生命周期钩子函数中使用了 watchEffect, 则在卸载组件时)

我们之所以是通过传入一个函数去注册失效回调,而不是从回调返回它(如 React useEffect 中的方式),是因为返回值对于异步错误处理很重要。

const data = ref(null)
watchEffect(async (id) => {
  data.value = await fetchData(id)
})

async function 隐性地返回一个 Promise - 这样的情况下,我们是无法返回一个需要被立刻注册的清理函数的。除此之外,回调返回的 Promise 还会被 `Vue 用于内部的异步错误处理。

在实际应用中,在大于某个频率(请求 padding状态)操作时,可以先取消之前操作,节约资源:

<template>
  <div>
    <input type="text"
      v-model="keyword">
  </div>
</template>

<script>
import { ref, watchEffect } from '@vue/composition-api'

export default {
  setup() {
    const keyword = ref('')
    const asyncPrint = val => {
      return setTimeout(() => {
        console.log('user input: ', val)
      }, 1000)
    }

    watchEffect(
      onInvalidate => {
        const timer = asyncPrint(keyword.value)
        onInvalidate(() => clearTimeout(timer))
        console.log('keyword change: ', keyword.value)
      },
      {
        flush: 'post' // 默认'post',同步'sync','pre'组件更新之前
      }
    )

    return {
      keyword
    }
  }
}
// 实现对用户输入“防抖”效果
</script>

watch()

watch API 完全等效于 2.xthis.$watch (以及 watch 中相应的选项)。watch 需要侦听特定的数据源,并在回调函数中执行副作用。默认情况是懒执行的,也就是说仅在侦听的源变更时才执行回调。

watch() 接收的第一个参数被称作 “数据源”,它可以是:

  • 一个返回任意值的函数
  • 一个包装对象
  • 一个包含上述两种数据源的数组

第二个参数是回调函数。回调函数只有当数据源发生变动时才会被触发:

watch(
  // getter
  () => count.value + 1,
  // callback
  (value, oldValue) => {
    console.log('count + 1 is: ', value)
  }
)
// -> count + 1 is: 1

count.value++
// -> count + 1 is: 2

上面提到第一个参数的“数据源”可以是一个包含函数和包装对象的数组,也就是可以同时监听多个数据源。同时,watchwatchEffect 在停止侦听, 清除副作用 (相应地 onInvalidate 会作为回调的第三个参数传入),等方面行为一致。下面用上面“防抖”例子用 watch 改写:

<template>
  <div>
    <input type="text"
      v-model="keyword">
  </div>
</template>

<script>
import { ref, watch } from '@vue/composition-api'

export default {
  setup() {
    const keyword = ref('')
    const asyncPrint = val => {
      return setTimeout(() => {
        console.log('user input: ', val)
      })
    }

    watch(
      keyword,
      (newVal, oldVal, onCleanUp) => {
        const timer = asyncPrint(keyword)
        onCleanUp(() => clearTimeout(timer))
      },
      {
        lazy: true // 默认未false,即初始监听回调函数执行了
      }
    )
    return {
      keyword
    }
  }
}
</script>

2.x$watch 有所不同的是,watch() 的回调会在创建时就执行一次。这有点类似 2.x watcherimmediate: true 选项,但有一个重要的不同:默认情况下 watch() 的回调总是会在当前的 renderer flush 之后才被调用 —— 换句话说,watch()的回调在触发时,DOM 总是会在一个已经被更新过的状态下。 这个行为是可以通过选项来定制的。

2.x 的代码中,我们经常会遇到同一份逻辑需要在 mounted 和一个 watcher 的回调中执行(比如根据当前的 id 抓取数据),3.0watch() 默认行为可以直接表达这样的需求。

生命周期钩子函数

可以直接导入 onXXX 一族的函数来注册生命周期钩子。

import { onMounted, onUpdated, onUnmounted } from '@vue/composition-api'

const MyComponent = {
  setup() {
    onMounted(() => {
      console.log('mounted!')
    })
    onUpdated(() => {
      console.log('updated!')
    })
    onUnmounted(() => {
      console.log('unmounted!')
    })
  },
}

这些生命周期钩子注册函数只能在 setup() 期间同步使用, 因为它们依赖于内部的全局状态来定位当前组件实例(正在调用 setup() 的组件实例), 不在当前组件下调用这些函数会抛出一个错误。

组件实例上下文也是在生命周期钩子同步执行期间设置的,因此,在卸载组件时,在生命周期钩子内部同步创建的侦听器和计算状态也将自动删除。

2.x 的生命周期函数与新版 Composition API 之间的映射关系:

  • beforeCreate -> 使用 setup()
  • created -> 使用 setup()
  • beforeMount -> onBeforeMount
  • mounted -> onMounted
  • beforeUpdate -> onBeforeUpdate
  • updated -> onUpdated
  • beforeDestroy -> onBeforeUnmount
  • destroyed -> onUnmounted
  • errorCaptured -> onErrorCaptured

注意:beforeCreatecreatedVue3 中已经由 setup 替代。

依赖注入

provideinject 提供依赖注入,功能类似 2.xprovide/inject。两者都只能在当前活动组件实例的 setup() 中调用。

可以使用 ref 来保证 providedinjected 之间值的响应。

父依赖注入,作为提供者,传给子组件:

import { ref, provide } from '@vue/composition-api'
import ComParent from './ComParent.vue'

export default {
  components: {
    ComParent
  },
  setup() {
    let treasure = ref('传国玉玺')
    provide('treasure', treasure)
    setTimeout(() => {
      treasure.value = '尚方宝剑'
    }, 1000)
    return {
      treasure
    }
  }
}

子依赖注入,可作为使用者:

import { inject } from '@vue/composition-api'
import ComChild from './ComChild.vue'

export default {
  components: {
    ComChild
  },
  setup() {
    const treasure = inject('treasure')
    return {
      treasure
    }
  }
}

孙组件依赖注入,作为使用者使用,当祖级依赖传入的值改变时,也能响应:

import { inject } from '@vue/composition-api'

export default {
  setup() {
    const treasure = inject('treasure')
    console.log('treasure: ', treasure)
  }
}

缺点/潜在问题

新的 API 使得动态地检视/修改一个组件的选项变得更困难(原来是一个对象,现在是一段无法被检视的函数体)。

这可能是一件好事,因为通常在用户代码中动态地检视/修改组件是一类比较危险的操作,对于运行时也增加了许多潜在的边缘情况(特别是组件继承和使用 mixin 的情况下)。新 API 的灵活性应该在绝大部分情况下都可以用更显式的代码达成同样的结果。

缺乏经验的用户可能会写出 “面条代码”,因为新 API 不像旧 API 那样强制将组件代码基于选项切分开来。

noodle code

基于函数的新 API 和基于选项的旧 API 之间的最大区别,就是新 API 让抽取逻辑变得非常简单 —— 就跟在普通的代码中抽取函数一样。也就是说,我们不必只在需要复用逻辑的时候才抽取函数,也可以单纯为了更好地组织代码去抽取函数。

基于选项的代码只是看上去更整洁。一个复杂的组件往往需要同时处理多个不同的逻辑任务,每个逻辑任务所涉及的代码在选项 API 下是被分散在多个选项之中的。举例来说,从服务端抓取一份数据,可能需要用到 props, data(), mountedwatch。极端情况下,如果我们把一个应用中所有的逻辑任务都放在一个组件里,这个组件必然会变得庞大而难以维护,因为每个逻辑任务的代码都被选项切成了多个碎片分散在各处。

对比之下,基于函数的 API 让我们可以把每个逻辑任务的代码都整理到一个对应的函数中。当我们发现一个组件变得过大时,我们会将它切分成多个更小的组件;同样地,如果一个组件的 setup() 函数变得很复杂,我们可以将它切分成多个更小的函数。而如果是基于选项,则无法做到这样的切分,因为用 mixin 只会让事情变得更糟糕。

总结

Vue 3.0API 的调整其实并不大,熟悉 2.x 的童鞋就会有一种似曾相识的感觉,过渡成本极小。更多是源码层面的重构,让其更好用(从选项式到函数式,基于 typescript 重写,强制类型检查和提示补全),性能更强(重写了虚拟 Dom 的实现,采用原生 Proxy 监听)。

本文案例代码可以戳这里

本文参考了:

Vue Function-based API RFC
Vue Composition API
抄笔记:尤雨溪在Vue3.0 Beta直播里聊到了这些…
完~

查看原文

赞 17 收藏 12 评论 2

wuwhs 发布了文章 · 1月10日

CSS中层叠上下文进来了解一下?

前言

在有些 CSS 相互影响作用下,对元素设置的 z-index 并不会按实际大小叠加,一直不明白其中的原理,最近特意查了一下相关资料,做一个小总结。

层叠上下文与层叠顺序

层叠上下文(stacking content)是 HTML 中的三维概念,也就是元素z轴。层叠顺序(stacking order)表示层叠时有着特定的垂直显示顺序。

层叠准则

  • 谁大谁上

当具有明显的层叠水平标示的时候,如识别的 z-indx 值,在同一个层叠上下文领域,层叠水平值大的那一个覆盖小的那一个。

  • 后来居上

当元素的层叠水平一致、层叠顺序相同的时候,在DOM流中处于后面的元素会覆盖前面的元素。

层叠上下文的特性

层叠上下文有以下特性:

  • 层叠上下文的层叠水平要比普通元素高;
  • 层叠上下文可以阻断元素的混合模式;
  • 层叠上下文可以嵌套,内部层叠上下文及其所有子元素均受制于外部的层叠上下文;
  • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需考虑后代元素;
  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的叠层顺序中;

stacking-context-order

z-index 值不是 auto 的时候,会创建层叠上下文

对于包含 position: relative;position: absolute; 的定位元素,以及 FireFox/IE浏览器下包含 position声明定位的元素,当其 z-index 值不是 auto 的时候,会创建层叠上下文。

HTML 代码

<div  class="red-wrapper">
    <div  class="red">小红</div>
</div>
<div  class="gray-wrapper">
    <div  class="gray">小灰</div>
</div>

CSS代码

.red-wrapper {
    position: relative;
    z-index: auto;
}

.red {
    position: absolute;
    z-index: 2;
    width: 300px;
    height: 200px;
    text-align: center;
    background-color: brown;
}

.gray-wrapper {
    position: relative;
    z-index: auto;
}  

.gray {
    position: relative;
    z-index: 1;
    width: 200px;
    height: 300px;
    text-align: center;
    background-color: gray;
}

z-index-auto

当两个兄弟元素 z-index 都为 auto 时,它们为普通元素,子元素遵循”谁大谁上“的原则,所以小灰 z-index: 1; 输给了小红的 z-index: 2;,被压在了下面

然而当 z-index 变成数值时,就会创建一个层叠上下文,各个层叠元素相互独立,子元素受制于父元素的层叠顺序。将兄弟元素的 z-indexauto 变成了数值 0,他们的子元素的之间的层叠关系就不不受本身 z-index 的影响,而是由父级元素的 z-index 决定。

下面小红和小灰的父级的 z-index 都调整成 0

.red-wrapper {
    /* 其他样式 */
    z-index: 0;
}  

.gray-wrapper {
    /* 其他样式 */
    z-index: 0;
}

z-index-0

就会发现小灰在小红的上面了,因为小灰的父级和小红的父级都变成了层叠上下文元素,层叠级别一样,根据文档流中元素位置”后来居上“原则。

CSS3对层叠上下文的影响

display: flex|inline-flex 与层叠上下文

父级是 display: flex 或者 display: inline-flex;,子元素的 z-index 不是 auto,此时,这个子元素(注意这里是子元素)为层叠上下文元素。

HTML 代码

<div  class="wrapper">
    <div  class="gray">
        小灰
        <div  class="red">小红</div>
    </div>
</div>

CSS代码

.wrapper {
    display: flex;
}

.gray {
    z-index: 1;
    width: 200px;
    height: 300px;
    text-align: center;
    background-color: gray;
}  

.red {
    z-index: -1;
    width: 300px;
    height: 200px;
    text-align: center;
    background-color: brown;
    position: relative;
}

这样,由于小灰的父级的 display: flex;,自身的 z-index 不为 auto,因此变成了层叠上下文元素,原本小红垫底变成了小灰垫底了。

mix-blend-mode 与层叠上下文

具有 mix-blend-mode 属性的元素是层叠上下文元素

CSS 属性mix-blend-mode(混合模式),可以将叠加的元素的内容和背景混合在一起。

代码同上,只需在小灰上添加 mix-blend-mode 属性,为了能查看到混合效果,将外面容器增加一个背景图。

.wrapper {
    background-image: url("./jz.png");
}

.gray {
    /* 其他样式 */
    mix-blend-mode: darken;
}

mix-blend-mode

同理,小灰有 mix-blend-mode 属性,变成了层叠上下文元素,让小灰垫底。

opacity 与层叠上下文

如果元素的 opacity 不为1,这个元素为层叠上下文元素

HTML 代码

<div  class="gray">
    小灰
    <div  class="red">小红</div>
</div>

CSS代码

.gray {
    z-index: 1;
    width: 200px;
    height: 300px;
    text-align: center;
    background-color: gray;
    opacity: 0.5;
}

.red {
    z-index: -1;
    width: 300px;
    height: 200px;
    text-align: center;
    background-color: brown;
    position: relative;
}

opacity

由于小灰自身有 opacity 半透明属性,变成了层叠上下文元素,使得小红 z-index: -1;也无法穿透。

transform 与层叠上下文

应用了 transform 的元素为层叠上下文元素

代码同上,只不过把小灰应用 transform 变换。

.gray {
    /* 其他相关样式 */
    transform: rotate(30deg);
}

transform

同理,小灰应用 transform 变换,变成了层叠上下文元素,使得小红 z-index: -1;也无法穿透。

filter 与层叠上下文

具有 filter 属性的元素是层叠上下文元素

代码同上,只不过把小灰加上 filter 属性。

.gray {

    /* 其他相关样式 */
    filter: blur(5px);
}

filter

同理,小灰有 filter 属性,变成了层叠上下文元素,使得小红 z-index: -1; 还是在小灰上层。

will-change 与层叠上下文

具有 will-change 属性的元素是层叠上下文元素

代码同上,只不过把小灰加上 will-change 属性。

.gray {
    /* 其他相关样式 */
    filter: will-change;
}

结果,同理如上。

总结

综合来看元素层叠规则,首先要理解在什么情况下元素是层叠上下文元素

  • 含有定位属性 position: relative|absolute|fixed;,且 z-index 不为 autowebkit 内核浏览器,fixed 定位无此限制)的元素是层叠上下文元素;
  • 元素有一些 CSS3 属性,可以变成层叠上下文元素:
  • 父级是 display: flex|inline-flex; 子元素的 z-index 不是 auto,此时,这个子元素(注意这里是子元素)为层叠上下文元素
  • 具有 mix-blend-mode 属性的元素
  • opacity 属性不为1的元素
  • transform 变换的元素
  • 具有 filter 属性的元素
  • 具有 will-change 属性的元素

其次要理解叠层准则:”谁大谁上“,”后来居上“,最后就是要了解层叠上下文主要特性(详见文章层叠上下文的特性)。完~

查看原文

赞 4 收藏 2 评论 0

wuwhs 关注了专栏 · 2019-08-29

code秘密花园

基础知识、算法、原理、项目、面试。公众号code秘密花园

关注 5199

wuwhs 赞了文章 · 2019-08-29

前端必备10种设计模式

接手项目越来越复杂的时候,有时写完一段代码,总感觉代码还有优化的空间,却不知道从何处去下手。设计模式主要目的是提升代码可扩展性以及可阅读性。

本文主要以例子的方式展示设计模式应该如何使用!(例子主要来源于javascript设计模式一书,如果已经对这本书读得滚瓜烂熟的,可以划过,如果还未读,或者想了解一下可以收藏起来慢慢看~)
photo-1504691342899-4d92b50853e1 (1).jpg-119.8kB

设计原则

在使用设计模式前应该需要知道的几个原则(其中对应设计模式满足对应原则):

  • 单一职责原则(SRP): 一个对象(只做一件事)。

    • 代理模式,迭代器模式,单例模式,装饰者模式
  • 最少知识原则(LKP): 一个软件实体应当尽可能少地与其他实体发生相互作用。

    • 中介者模式
  • 开放-封闭原则(OCP):软件实体(类,模块,函数)应该都是可以扩展,但是不可修改

    • 发布-订阅模式,模板方法模式,策略模式,代理模式,职责链模式

代理模式

代理顾名思义,就是客服无法直接与本体进行的沟通通过第三方进行转述。

QQ五笔截图未命名.png-2.5kB

虚拟代理

作为创建开销大的对象的代表;虚拟代理经常直到我们真正需要一个对象的时候才创建它;当对象在创建前或创建中时,由虚拟代理来扮演对象的替身;对象创建后,代理就会将请求直接委托给对象;

图片预加载例子

const myImage = (function() {
    const imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    return {
        setSrc: function(src) {
            imgNode.src = src;
        }
    }
}())

// 代理容器
const proxyImage = (function() {
    let img = new Image();
    
    // 加载完之后将设置为添加的图片
    img.onload = function() {
        myImage.setSrc(this.src)
    }
    return {
        setSrc: function(src) {
            myImage.setSrc('loading.gif');
            img.src = src;
        }
    }
}())

proxyImage.setSrc('file.jpg')

如上:代理容器控制了客户对MyImage的访问,并且在过程中加了一些额外的操作。

缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

计算乘积例子

// 求乘积函数(专注于自身职责,计算成绩,缓存由代理实现)
const mult = function() {
    let a = 1;
    for (let i = 0, l =arguments.length; i< l; i++){
        a = a * arguments[i];
    }
    return a;
}

// proxyMult 
const proxyMult = (function() {
    let cache = {};
    return function() {
        let args = Array.prototype.join.call(arguments, ',');
        if (args in cache) {
            return cache[args];
        }
        return cache[arg] = mult.apply(this, arguments);
    }
}())

proxyMult(1, 2, 3) // 6
proxyMult(1, 2, 3) // 6

迭代器模式

迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表达式。迭代器模式可以将迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即不用关心对象内部构造也可以按顺序访问其中的每个元素。

迭代器分为内部迭代器和外部迭代器,内部迭代器,是在函数内部已经定义好了迭代规则,外部只需要调用即可。但如果要修改需求,那就要去迭代函数内部去修改逻辑了。外部迭代器指的是必须显示的请求迭代下一个元素。

如现在有一个需求,判断两个函数是否完全相等,分别使用内部迭代器和外部迭代器去实现。

// 使用内部迭代的方式实现 compare
const compare = function (arr1, arr2) {
    try {
        if (arr1.length !== arr2.length) {
            throw 'arr1和arr2不相等'
        }
        // forEach 相当一于一个迭代器
        arr1.forEach((item, index) => {
            if (item !== arr2[index]) {
                throw 'arr1和arr2不相等'
            }
        })
        console.log('arr1等于arr2')
    } catch (e) {
        console.log(e)
    }
}

使用外部迭代器模式改写compare

// 迭代器
const iterator = function (obj) {
    let current = 0;
    let next = function () {
        current += 1;
    };
    let isDone = function () {
        return current >= obj.length;
    }
    let getCurrItem = function () {
        return obj[current];
    }
    return {
        next: next,
        isDone: isDone,
        getCurrItem: getCurrItem,
        length: obj.length
    }
}
// 重写compare
const compare = function (iterator1, iterator2) {
    try {
        if (iterator1.length !== iterator2.length) {
            throw 'iterator1不等于iterator2'
        }
        while (!iterator1.isDone() && !iterator2.isDone()) {
            if (iterator1.getCurrItem() !== iterator2.getCurrItem()) {

                throw 'iterator1不等于iterator2'
            }
            iterator1.next();
            iterator2.next();
        }
        console.log('iterator1 === iterator2')
    } catch (e) {
        console.log(e)
    }
}
const iterator1 = iterator([1, 2, 3]);
const iterator2 = iterator([1, 2, 3]);
compare(iterator1, iterator2)

迭代器实际场景的应用

根据不同的浏览器获取相应上传组件对象
// 常规写法
const getUploadObj = function() {
    try {
        return new ActiveXObject('txftna')
    } catch (e) {
        if (supportFlash()) {
            let str = `<object type="application/x-shockwave-flast"></object>`
            return document.body.appendChild(str)
        } else {
            let str = `<input name="file" type="file"/>`;
            return document.body.appendChild(str);
        }
    }
}
// 迭代模式改写

// IE上传控件
const getActiveUploadObj = function () {
    try {
        return new ActiveXObject('TXFTNActiveX.FTNUPload')
    } catch (e) {
        return false;
    }
}

// flash上传控件
const getFlashUploadObj = function () {
    if (supportFlash()) {
        let str = `<object type="application/x-shockwave-flast"></object>`
        return document.body.appendChild(str)
    }
    return false
}
// 表单上传
const getFormUploadObj = function () {
    let str = `<input name="file" type="file"/>`;
    return document.body.appendChild(str);
}
// 使用迭器执行
const iteratorUploadObj = function () {
    for (let i = 0, fn; fn = arguments[i++];) {
        const uploadInstane = fn()
        if (uploadInstane !== false) {
            return uploadInstane
        }
    }
}

iteratorUploadObj(getActiveUploadObj, getFlashUploadObj, getFormUploadObj)

观察上面经过未使用迭代器模式,和使用迭代器模式后,发现不使用的话,如果后期想新增一个其他的模式,需要修改原来代码的逻辑,如果使用迭代器的模式的话只需要新增一个方法即可。虽然在写的时候代码多了几行,但总的来说,后期扩展以及可阅读性明显提高了~。

单例模式

定义:单例模式指的是保证一个类仅有一个实例,且提供一个访问它的全局访问点。全局缓存,window对象,都可以看作是一个单例。

目的: 解决一个全局使用的类频繁地创建与销毁

使用ES6 class 创建单例:

class Instance {
    static init () {
        if (!this.instance) {
            this.instance = new Instance()
        }
        return this.instance;
    }
}
const instance1 = Instance.init()
const instance2 = Instance.init()

console.log(instance1 === instance2) //true

使用闭包创建单例:

const instance = (function() {
    let instance = null;
    return function(name) {
        if (!instance) {
            instance = new Singleton(name)
        }
        return instance;
    }
}())

使用代理实现单例模式

以在页面上创建唯一的dom节点为例;

// 创建div类
class CreateDiv {
    constructor(html) {
        this.html = html
        this.init()
    }

    init() {
        let div = document.createElement('div');
        div.innerHTML = this.html;
        document.body.appendChild(div)
    }
}

// 代理类
class ProxySingletonCreateDiv {
    static initInstance(html) {
        if (!this.instance) {
            this.instance = new CreateDiv(html)
        }
        return this.instance;
    }
}

ProxySingletonCreateDiv.initInstance('test1');
ProxySingletonCreateDiv.initInstance('test2');

测试上面这段代码会发现页面上只会显示test1,因为实例只创建了一次,也就是new CreateDiv只执行了第一次,第二次并没有执行。

惰性单例

惰性单例是指在需要的时候才创建对象实例。(在一定场景下,用户只有在需要的时候才创建)

例:instance实例总是在我们调用getInstance的时候才会被创建

Singleton.getInstance = (function () {
    let instance = null;
    return function (name) {
        if (!instance) {
            instance = new Singleton(name)
        }
        return instance;
    }
}())

const instance = Singleton.getInstance('hello')

实现通用惰性单例

const getSingle = function (fn) {
    let result;
    return function() {
        return result || (result = fn.apply(this, arguments))
    }
}

装饰者模式

装饰者模式指的是:可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。

最基础的装饰者

以编写一个飞机大战的游戏为例,随着等级的增加最开始我们只能发送普通的子弹,二级可以发送导弹,三级可以发送原子弹。

// 飞机对象
let plane = {
  fire: function () {
      console.log('发射普通子弹')
  }
};
// 发送导弹的方法,实际内容略
let missileDecorator = function () {
    console.log('发射导弹');
};
// 发送原子弹的方法
let atomDecorator = function () {
    console.log('发射原子弹');
};
// 将发送子弹方法存起来,
let fire1 = plane.fire;
// 装饰发送普通子弹的方法
plane.fire = function () {
    fire1();
    missileDecorator()
};

let fire2 = plane.fire;
plane.fire = function () {
    fire2();
    atomDecorator()
};

plane.fire() // 发射普通子弹,发射导弹,发射原子弹

我们有时候在维护代码的时候,可能会遇到这样的需求。比如给window绑定onload事件,但又不太确定这个事件是否被其他人绑定过了,为了避免覆盖掉之前的window.onload的函数的行为,我们一般会向上面的例子一样,将之前的行为保存在一个变量内,然后再给绑定的window.onload函数添加这个变量执行从而满足需求。
以上方法的缺陷

  1. 需要多维护了中间变量,如上面的例子fire1fire2,如果链越来越长,那么维护的就越来越多。
  2. 还会遇到this劫持问题,如上fire函数被变量存起来的时候plane.fire执行时this指向global(node环境)

AOP装饰函数

为解决上面this的劫持问题,延伸实现Function.prototype.beforeFunction.prototype.after方法:

Function.prototype.before = function (beforeFn) {
    let that = this; // 保存原函数的引用
    return function () {
        beforeFn.apply(this, arguments);
        return that.apply(this, arguments); //执行原函数,且保证this不被劫持
    }
};

Function.prototype.after = function (afterFn) {
    let that = this;
    return function () {
        let ret = that.apply(this, arguments);
        afterFn.apply(this, arguments);
        return ret;
    }
};

改写上面飞机的例子:

let plane = {
    fire: function () {
        console.log('发射普通子弹!')
    }
};
plane.fire.after(function () {
    console.log('发射导弹!')
}).after(function () {
    console.log('发射原子弹!')
})()

很明显的看见解决了上面的两个缺陷。

AOP应用实例之数据上报

做前端开发,主要提升用户体验,所以有时候在项目结尾为了能更多的收集到用户的操作数据不得不加入一些埋点数据在业务中。如:点击上报多少人点击登录按钮来显示登录浮窗。

// 常规做法 bad
let log = function () {
    console.log('上报')// 实际内容略
}
let showLogin = function () {
    console.log('打开登录浮窗');
    log();
}
// 上面做法,showLogin既要做显示弹窗的操作,又要负责数据上报,违反了单一职责原则
// 使用装饰者方式改写 good
let showLogin1 = function () {
  console.log('显示弹窗')
}

let showLogin = showLogin.after(log);
document.getElement('button').onclick = showLogin;

装饰者模式与代码模式的区别:
代理模式强调的是代理与它的实体之间的关系(这种关系在一开始就可以被确定),装饰者模式用于一开始无法确定对象的全部功能场景。代理模式通常只有一层代理。而装饰者会形成一条长长的装饰链。

中介者模式

中介者指的是解除对象与对象之间的紧耦关系,增加中介者之后,所有对象通过中介者来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。

现实生活中很的中介者场景,如:快递物流公司,快递员将负责的区域拿到用户的快递之后将快递送到中转场,然后中转场再进行整理之后输出分好类的区域,再由快递员送到指定区域。在设计模式中,中转场就扮演者,中介者的角色。
QQ五笔截图未命名.jpg-12.1kB
如图将 A,B,C,D互相关联的东西使用一个中介者进行管理,减少A,B,C,D内的互相引用。

const createAgent = (function () {
    return {
        add() => {
            //添加一个东西 代码略
        },
        send () => {
            // 发送一个东西 代码略
        }
    }
}())

const createA = function () {
    createAgent.add()
    setTimeout(() => {
        createAgent.send();
        
        // 代码略
    }, 3000)
}

const createB = function () {
    //同上面方法类似
}

代码只是说明中介者的意图,内容不要在意。

当关联的东西越来越多的时候中介者模式会变得越来越大,虽然会带来这个缺点,但取舍一下还是会比相互之间引用会更好。

发布-订阅模式

发布-订阅模式:定义对象之间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知!

常见的javascript事件指令,也是一种发布订阅模式,

document.body.addEventListener('click', function () {
    // 一堆操作
}, false)

// 模拟用户点击
document.body.click()

这里监控用户点击document.body的动作,但我们并不知道用户什么时候会点击,所以我们订阅document.body上的click事件,当body被点击后,body节点会向订阅者发布这个消息。

发布与订阅模式的实现步骤:

  • 首先要指定谁充当发布者。
  • 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者
  • 最后发布消息的时候,发布者会便利缓存列表依次触发里面存放的订阅者回调函数

以售楼处去购买房子为例:A去售楼部查看了一下房源,并告知了销售者小姐姐自己的电话以及自己需要的房源类型(这里A充当订阅者),售楼处将用户的电话以及需要房源类型记录在小本子上(这个小本子相当于缓存列表),售楼处充当发布者!下面以一段代码实现这段描述:

// 定义售楼处
let saleOffices = {
    
    // 存放订阅者的回调函数,即用户的电话以及需求
    clientList: {},
    
    // 订阅消息
    listen: function (key, fn) {
        // 如果没有订阅过此类消息,则创建一个缓存列表
        if (!this.clientList[key]) { 
            this.clientList[key] = [];
        }
        // 将订阅的消息添加进消息缓存列表
        this.clientList[key].push(fn);
    },

    // 发布消息
    trigger: function () {
        let key = Array.prototype.shift.call(arguments );
        let fns = this.clientList[key];

        // 如果没订阅此消息则返回
        if (!fns || fns.length === 0) return false;


        for (let i = 0, fn; fn = fns[i++];) {
            // arguments是发布消息的附送参数
            fn.apply(this, arguments);
        }
    },
    
    // 取消订阅
    remove: function (key, fn) {
        let fns = this.clientList[key];

        if (!fns) return false;

        // 如果没有传入回调函数,则取消key所有的订阅
        if (!fn) {
            fns && (fns.length = 0)
        } else {
            for (let l = fns.length - 1; l >= 0; l--) {
                let _fn = fns[l];
                if (_fn === fn) {
                    // 删除订阅的回调函数
                    fns.splice(l, 1)
                }
            }
        }
    }
}; 

let fn1 = function (price) {
    console.log('价格=', price)
}
saleOffices.listen('listen100', fn1)

saleOffices.remove('listen100', fn1)
saleOffices.trigger('listen100', 20000)

看了发布与订阅模式和中介者模式,发现两者之间有着很多相似之处。

发布-订阅中介者之间的区别:

中介者目的是为了减少对象之间的耦合,而且类里的内容可能存在着不同对象之间需要的一些东西存储,后期可能是某个对象自己去取。发布-订阅主要也是解决对象之间的耦合,不同的是发布订阅是取决用户关注什么东西后发布者在有了这个东西之后主动推送给订阅者~

模板方法模式

模板模式指的是一种只需要使用继承就可以实现的非常简单的模式 ,比较依赖于抽象类的一种设计模式,主要由抽象父类和具体实现子类组成!

抽象类可以表示一种契约,继承了这个抽象类的所有子类都将拥有跟抽象类一致的接口方法,抽象类的主要作用就是为了它子类定义这些公共接口

咖啡与茶的例子

泡茶与冲咖啡:
首先可以将茶和咖啡抽象成饮料。

两个在泡和冲都有相似的步骤:

  1. 把水煮沸
  2. 用沸水冲泡饮料
  3. 把饮料倒进杯子
  4. 加调料

在类的继承情况下,有时会存在子类未实现父类里已经调用过的一些方法,常见解决可以在父类里添加对象的方法并提示一个错误如:

Beverage.prototype.brew = function () {
    throw new Error('子类必须重写brew方法');
}

实现冲咖啡与泡茶的例子:

// 抽象类
class Beverage {
    boilWater () {
        console.log('把水煮沸');
    }
    brew () {
        throw new Error('子类必须实现此方法!')
    }
    pourInCup () {
        throw new Error('子类必须实现此方法!')
    }
    addCondiments () {
        throw new Error('子类必须实现此方法!')
    }
    // 构子方法是否需要添加调料
    customerWantsCondiments() {
        return true
    }
    init () {
        this.boilWater();
        this.brew();
        this.pourInCup();
        if (this.customerWantsCondiments()) {
            this.addCondiments();
        }
    }
}
// 咖啡类
class Coffee extends Beverage{
    constructor(props) {
        super(props)
    }

    brew() {
        console.log('用沸水煮咖啡')
    }
    pourInCup() {
        console.log('把咖啡倒进杯子')
    }
    addCondiments() {
        console.log('加糖和牛奶')
    }
    customerWantsCondiments () {
        return false;
    }
}
// 泡茶类
class Tea extends Beverage {
    constructor(props) {
        super(props);
    }
    brew () {
        console.log('用沸水浸泡茶叶')
    }
    pourInCup () {
        console.log('将茶水倒进杯子')
    }
    addCondiments () {
        console.log('加对应配料')
    }
}
let coffee = new Coffee()
coffee.init() // 把水煮沸 用沸水煮咖啡 把咖啡倒进杯子

let tea = new Tea()
tea.init(); // 把水煮沸 用沸水浸泡茶叶 将茶水倒进杯子 加对应配料

好莱坞原则

许多新人演员在好莱坞把简历递给演艺公司之后就只有回家等待尽管。有时候演员等得不耐烦了,给演艺公司打电话询问情况,演艺公司往往这样回答:“不要来差我,我会给你打电话。”,这就是好莱坞原则。

好莱坞原则,允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候,什么方式使用这些底层组件,与好莱坞原则一样。

在javascript中,我们很多时候不需要用类这样繁琐的方式实现模板方法,使用高阶函数更好。我们用高阶函数改写上面的例子。

    const Beverage = function(param) {
        let boilWater = function() {
            console.log('把水煮沸')
        }
        let brew = param.brew || function() {
            throw new Error('必须传递brew方法')
        }
        let pourInCup = param.pourInCup || function() {
            throw new Error('必须传递pourInCup方法')
        }
        let addCondiments = param.addCondiments || function() {
            throw new Error('必须传递addCondiments')
        }
        let F = function () {};
        F.prototype.init = function() {
            boilWater();
            brew();
            pourInCup();
            addCondiments()
        }
        return F;
    }
    const Coffee = Beverage({
        brew:function() {
            console.log('用沸水冲泡咖啡')
        },
        pourInCup: function() {
            console.log('把咖啡倒进杯子')
        },
        addCondiments: function() {
            console.log('加糖和牛奶')
        }
    })

策略模式

策略模式指的是:定义一系列算法,把它们一个个封装起来,并且使它们可以互相替换。

策略模式优点:

  • 策略模式例用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句
  • 策略模式提供了对开放-封闭原则的完美支持,将算法封装在独立的strategy中,使得它们易于切换,易于理解,易于扩展。
  • 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。
  • 在策略模式中利用组合和委托来让context拥有执行算法的能力,这也是继承的一种更轻便的替代方案。

表单校验例子

校验逻辑:

  • 用户名不能为空,用户名长度不能小于10位
  • 密码长度不能少于6位
  • 手机号必须符合格式。

为了下面的javascript代码更少的编写html代码,我这里将html代码提取出来

// html 代码
<html>
    <body>
        <form action="http://xxx./register" id="registerForm" method="post">
        请输入用户名:<input type="text" name="userName">
        请输入密码:<input type="password" name="password">
        请输入手机号:<input type="text" name="phoneNumber">
        <button>提交</button>
        </form>
    </body>
</html>

不使用策略模式我们正常的实现

 const registerForm = document.getElementById('registerForm');
 registerForm.onsubmit = function() {
     const userName = registerForm.userName.value
     if(userName === '' && userName.length >= 10) {
         console.log('用户名不能为空')
        return false;
     }
     if (registerForm.password.value.length < 6) {
         console.log('密码不能为空')
         return false;
     }

     if (!/(^1[3|5|8][0|9]{9}$)/.test(registerForm.phoneNumber.value)) {
         console.log('手机号输入不正确')
         return false;
     }
 }

这样的代码会导致校验的函数越来越庞大,在系统变化的时候缺乏弹性。

使用策略模式重构上面的表单校验:

策略模式的组成部分:

  • 策略类:封装具体算法,并负责具体计算过程。
  • 环境类:环境类Context,Context接受客户的请求,随后把请求委托给某一个策略类。
// 封装算法
const strategies = {
    isNonEmpty: function (value, errorMsg) {
        if (value === '') {
            return errorMsg;
        }
    },
    minLength: function (value, length, errorMsg) {
        if (value.length < length) {
            return errorMsg;
        }
    },
    isMobile: function (value, errorMsg) {
        if (!/(^1[3|5|8][0|9]{9}$)/.test(value)) {
            return errorMsg;
        }
    }
}

// 实现Context环境类
const Validator = function () {
    this.cache = [];
}
Validator.prototype = {
    add (dom, rules) {
        let self = this;
        for (let i = 0, rule; rule = rules[i++];) {
            let strategyAry = rule.strategy.split(':');
            let errorMsg = rule.errorMsg;

            self.cache.push(function () {
                let strategy = strategyAry.shift()
                strategyAry.unshift(dom.value)
                strategyAry.push(errorMsg);
                return strategies[strategy].apply(dom, strategyAry)
            })
        }
    },
    start () {
        for(let i = 0, validatorFunc; validatorFunc = this.cache[i++];) {
            let errorMsg = validatorFunc();
            if (errorMsg) {
                return errorMsg;
            }
        }
    }
}

// 客户端使用
let registerForm = document.getElementById('registerForm');
let validataFunc = function () {
    let validator = new Validator();
    validator.add(registerForm.userName, [{
        strategy: 'isNonEmpty',
        errorMsg: '用户名不能为空'
    }, {
        strategy: 'minLength:10',
        errorMsg: '用户名长度不能小于10位'
    }])

    validator.add(registerForm.password, [{
        strategy: 'minLength:6',
        errorMsg: '密码长度不能小于6位'
    }])
    
    validator.add(registerForm.phoneNumber, [{
        strategy: 'isMobile',
        errorMsg: '手机号码格式不正确'
    }])
    let errorMsg = validator.start();
    return errorMsg;
}

registerForm.onsubmit = function () {
    let errorMsg = validataFunc();
    if (errorMsg) {
        console.log('errorMsg');
        return false;
    }
}

虽然看起来代码多了很多,但对于以后的维护和扩展方法,复用方法,这种方式明显会好很多。

职责链模式

职责链模式指的是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一个链条,并沿着链条传递这些请求,直到有一个对象处理它为止。

职责链生活例子:

中学时期末考试,如果你平时不老实考试时就会被安排到第一排的位置,遇到不会答的题就会把题写在纸条上传给后排的同学,如果后排同样不会继续往后排传值到会为止。

实际开发中职责链模式使用场景:

需求是这样的:

商场在预定手机时,分别缴纳500和200的定金,到的购买阶段后交付500定金的可以收到100优惠券,200的可以收到50优惠券,没有支付定金没有优惠券且只能保证购买时库存有货时能购买成功!

平常我们拿到这样的需求后可能会写下面这样的代码:

const fn = function(stock) {
    if (stock > 0) {
        console.log('普通购买,无优惠券')
    } else {
        console.log('手机库存不足')
    }
}
/**
 * @param {number} orderType 订单类型1,2,3
 * @param {boolean} pay 是否支付定金 true | false
 * @param {number} stock 库存数量 
 */
let order = function(orderType, pay, stock) {
    if (orderType === 1) {
        if (pay === true) {
            console.log('500元定金预购,得到100优惠券。')
        } else {
            fn(stock);
        }
    } else if (olderType === 2) {
        if (pay === true) {
            console.log('200元,50优惠券')
        } else {
            fn(stock)
        }
    } else if (orderType === 3) {
        fn(stock)
    }
}

看上面的代码,逻辑上也没什么问题,但相信我们在写的时候一般也不会这样去写,因为这样在后期维护的时候order函数会变得越来越庞大,而且要新增一些其他的逻辑也是比较困难。

使用职责链模式重写上面的例子:

  1. 拆分条件语句,将每个条件提取成一个函数。
  2. 约定一个字符串'nextSuccess'是否需要向后传递
  3. 包装职责链chain
const order500 = function(orderType, pay, stock) {
    if(orderType === 1 && pay === true) {
        console.log('500定金,100优惠券')
    } else {
        return 'nextSuccess'
    }
}

const order200 = function(orderType, pay, stock) {
    if (orderType === 2 && pay === true) {
        console.log('200定金,返50优惠券')
    } else {
        return 'nextSuccess'
    }
}

const orderNormal = function(orderType, pay, stock) {
    if (stock > 0) {
        console.log('普通购买')
    } else {
        console.log('手机库存不足')
    }
}

const Chain = function(fn) {
    this.fn = fn;
    this.successor = null;
}

Chain.prototype = {
    setNextSuccessor: function(successor) {
        return this.successor = successor;
    },
    passRequest: function() {
        let ret = this.fn.apply(this, arguments);
        
        if (ret === 'nextSuccessor') {
            return this.successor && this.successor.passRequest.apply(this.successor, arguments)
        }

        return ret;
    }
}
// 包装职责链节点
const chainOrder500 = new Chain(order500);
const chainOrder200 = new Chain(order200);
const chainOrderNormal = new Chain(orderNormal);
// 指定职责链顺序
chainOrder500.setNextSuccessor(chainOrder200);
chainOrder200.setNextSuccessor(chainOrderNormal)
// 应用
chainOrder500.passRequest(1, true, 500); // 500定金,100优惠券
chainOrder500.passRequest(1, false, 0) // 库存不足

职责链的缺点:

职责链使程序中多了一些节点对象,可能在某一次的请求传递过程中,大部分节点没有起到实质性的作用,它们的作用仅仅让请求传递下去,从性能方面考虑我们应该避免过长的职责链带来的性能损耗。

在之前我们使用了AOP装饰函数,实现装饰者的模式。
同样 这里我们可以使用AOP实现职责链

改写之前的Function.prototype.after函数

Function.prototype.after = function(fn) {
    let self = this;
    return function() {
        let ret = self.apply(this, arguments);
        
        if (ret === 'nextSuccessor') {
            return fn.apply(this, arguments)
        }
        return ret;
    }
}
// 指定顺序
let order = order500.after(order200).after(orderNormal);
order(1, true,  50) // 500定金,100优惠券

去掉了chain类,整个逻辑也变得更加清晰了,同样这种方式也不适合太长的链条。

状态模式

状态模式指的是:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

状态模式的优点:

  • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
  • 避免Context无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了Context中原来过多的条件分支。
  • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。
  • Context中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

使用javascript版本的状态机,实现开灯与关灯例子:

let delegate = function(client, delegation) {
    return {
        buttonWasPressed: function() {
            // 将客户端操作委托给delegation对象
            return delegation.buttonWasPressed.apply(client, arguments)
        }
    }
}
let FSM = {
    off: {
        buttonWasPressed: function() {
            console.log('关灯')
            this.button.innerHTML = '下次按我是开灯';
            this.currState = this.onState;
        }
    }, 
    on: {
        buttonWasPressed: function() {
            console.log('开灯');
            this.button.innerHTML = '下次按我是关灯'
            this.currState = this.offState;
        }
    }
}

let Light = function() {
    this.offState = delegate(this, FSM.off);
    this.onState = delegate(this, FSM.on);
    this.currState = this.offState; // 设置初始状态为关闭状态
    this.button = null;
}

Light.prototype = {
    init () {
        let button = document.getElementById('button');
        let self = this;
        button.innerHTML = '已关灯';
        this.button = document.body.appendChild(button);
        this.button.onclick = function () {
            self.currState.buttonWasPressed()
        }
    }
}
let light = new Light()
light.init()

结语

写这篇文章主要意图在于以前也看过javascript设计模式这本书,但可能在写代码的时候一般只会想到文章开头的三个原则,但具体如何通过比较优雅的代码去满足三个原则是比较困难的,最近又重新看了一遍这本书,为了加深自己的印象,将一些比较常用的模式通过自己去描述的方式呈现出来,也能分享给广大的搬砖同学~, 如果觉得读完文章后有所收获,请点赞和收藏给予一丢丢鼓励,haha

查看原文

赞 4 收藏 2 评论 2

wuwhs 关注了专栏 · 2019-08-29

前端面试每日3+1

前端面试每日 3+1,以面试题来驱动学习,提倡每日学习与思考,每天进步一点!每天早上5点纯手工发布面试题(死磕自己,愉悦大家)

关注 215

wuwhs 收藏了文章 · 2019-07-30

一个合格的前端都应该阅读这些文章

前言

的确,有些标题党了。起因是微信群里,有哥们问我,你是怎么学习前端的呢?能不能共享一下学习方法。一句话也挺触动我的,我真的不算是什么大佬,对于学习前端知识,我也不能说是掌握了什么捷径。当然,我个人的学习方法这篇文章已经在写了,预计这周末会在我个人公众号发布。而在此之前,我想展(gong)示(xiang)一下,我平时浏览各个技术网站,所记录下来的文章。如果你能做到每日消化一篇,或许,你只要一年,就能拿下各个大厂 offer!

不由感慨,好文太多!吾等岂能浪费,还整日怨天尤人。

个人好文收藏

收藏截止时间:2019-07-24 11:50:49

typescript

CSS

前端工程(架构、软实力)

React 技术栈

webpack/babel

Test

JavaScript

Node

Flutter

Http

浏览器

面试

数据结构与算法

其他

结束语

以上包括我已读还未移至已读的记录中(主要是由于感觉还需再度)。所有文章,我都会好好学习,没办法,毕竟比较菜。还有太多需要学习。

欢迎关注我个人微信公众号:全栈前端精选

我会每日推荐各种精选好文,以及每日一道面试题讲解。(今日才开启这个计划)

查看原文

wuwhs 发布了文章 · 2019-07-25

如何优雅监听容器高度变化

前言

老鸟:怎样去监听 DOM 元素的高度变化呢?
菜鸟:哈哈哈哈哈,这都不知道哦,用 onresize 事件鸭!
老鸟扶了扶眼睛,空气安静几秒钟,菜鸟才晃过神来。对鸭,普通 DOM 元素没有 onresize 事件,只有在 window 对象下有此事件,该死,又双叒叕糗大了。

哈哈哈哈,以上纯属虚构,不过在最近项目中还真遇到过对容器监听高(宽)变化:在使用 iscrollbetter-scroll 滚动插件,如果容器内部元素有高度变化要去及时更新外部包裹容器,即调用 refresh() 方法。不然就会造成滚动误差(滚动不到底部或滚动脱离底部)。

可能我们一般处理思路:

  • 在每次 DOM 节点有更新(删除或插入)后就去调用 refresh(),更新外部容器。
  • 对异步资源(如图片)加载,使用onload 监听每次加载完成,再去调用 refresh(),更新外部容器。

这样我们会发现,如果容器内部元素比较复杂,调用会越来越繁琐,甚至还要考虑到用户使用的每一个操作都可能导致内部元素宽高变化,进而要去调整外部容器,调用 refresh()

实际上,不管是对元素的哪种操作,都会造成它的属性、子孙节点、文本节点发生了变化,如果能能监听得到这种变化,这时只需比较容器宽高变化,即可实现对容器宽高的监听,而无需关系它外部行为。DOM3 Events 规范为我们提供了 MutationObserver 接口监视对 DOM 树所做更改的能力。

MutationObserver

Mutation Observer API 用来监视 DOM 变动。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。

MutationObserver

PS Mutation Observer API 已经有很不错的浏览器兼容性,如果对IE10及以下没有要求的话。

MutationObserver 特点

DOM 发生变动都会触发 Mutation Observer 事件。但是,它跟事件还是有不用点:事件是同步触发,DOM 变化立即触发相应事件;Mutation Observer 是异步触发,DOM 变化不会马上触发,而是等当前所有 DOM 操作都结束后才触发。总的来说,特点如下:

  • 它等待所有脚本任务完成后,才会运行(即异步触发方式)。
  • 它把 DOM 变动记录封装成一个数组进行处理,而不是一条条个别处理 DOM 变动。
  • 它既可以观察 DOM 的所有类型变动,也可以指定只观察某一类变动。

MutationObserver 构造函数

MutationObserver 构造函数的实例传的是一个回调函数,该函数接受两个参数,第一个是变动的数组,第二个是观察器的实例。

var observer = new MutationObserver(function (mutations, observer){
  mutations.forEach(function (mutaion) {
    console.log(mutation);
  })
})

MutationObserver 实例的 observe() 方法

observe 方法用来执行监听,接受两个参数:

  1. 第一个参数,被观察的 DOM 节点;
  2. 第二个参数,一个配置对象,指定所要观察特征。
var $tar = document.getElementById('tar');
var option = {
  childList: true, // 子节点的变动(新增、删除或者更改)
  attributes: true, // 属性的变动
  characterData: true, // 节点内容或节点文本的变动

  subtree: true, // 是否将观察器应用于该节点的所有后代节点
  attributeFilter: ['class', 'style'], // 观察特定属性
  attributeOldValue: true, // 观察 attributes 变动时,是否需要记录变动前的属性值
  characterDataOldValue: true // 观察 characterData 变动,是否需要记录变动前的值
}
mutationObserver.observe($tar, option);

option 中,必须有 childListattributescharacterData中一种或多种,否则会报错。其中各个属性意思如下:

  • childList 布尔值,表示是否应用到子节点的变动(新增、删除或者更改);
  • attributes 布尔值,表示是否应用到属性的变动;
  • characterData 布尔值,表示是否应用到节点内容或节点文本的变动;
  • subtree 布尔值,表示是否应用到是否将观察器应用于该节点的所有后代节点;
  • attributeFilter 数组,表示观察特定属性;
  • attributeOldValue 布尔值,表示观察 attributes 变动时,是否需要记录变动前的属性值;
  • characterDataOldValue 布尔值,表示观察 characterData 变动,是否需要记录变动前的值;

childList 和 subtree 属性

childList 属性表示是否应用到子节点的变动(新增、删除或者更改),监听不到子节点后代节点变动。

var mutationObserver = new MutationObserver(function (mutations) {
  console.log(mutations);
})

mutationObserver.observe($tar, {
  childList: true, // 子节点的变动(新增、删除或者更改)
})

var $div1 = document.createElement('div');
$div1.innerText = 'div1';

// 新增子节点
$tar.appendChild($div1); // 能监听到

// 删除子节点
$tar.childNodes[0].remove(); // 能监听到

var $div2 = document.createElement('div');
$div2.innerText = 'div2';

var $div3 = document.createElement('div');
$div3.innerText = 'div3';

// 新增子节点
$tar.appendChild($div2); // 能监听到

// 替换子节点
$tar.replaceChild($div3, $div2); // 能监听到

// 新增孙节点
$tar.childNodes[0].appendChild(document.createTextNode('新增孙文本节点')); // 监听不到

attributes 和 attributeFilter 属性

attributes 属性表示是否应用到 DOM 节点属性的值变动的监听。而 attributeFilter 属性是用来过滤要监听的属性 key

// ...
mutationObserver.observe($tar, {
  attributes: true, // 属性的变动
  attributeFilter: ['class', 'style'], // 观察特定属性
})
// ...
// 改变 style 属性
$tar.style.height = '100px'; // 能监听到
// 改变 className
$tar.className = 'tar'; // 能监听到
// 改变 dataset
$tar.dataset = 'abc'; // 监听不到

characterData 和 subtree 属性

characterData 属性表示是否应用到节点内容或节点文本的变动。subtree 是否将观察器应用于该节点的所有后代节点。为了更好观察节点文本变化,将两者结合应用到富文本监听上是不错的选择。

简单的富文本,比如

<div id="tar" contentEditable>A simple editor</div>
var $tar = document.getElementById('tar');
var MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver;
var mutationObserver = new MutationObserver(function (mutations) {
  console.log(mutations);
})
mutationObserver.observe($tar, {
  characterData: true, // 节点内容或节点文本的变动
  subtree: true, // 是否将观察器应用于该节点的所有后代节点
})

characterData节点内容或节点文本的变动

takeRecords()、disconnect() 方法

MutationObserver 实例上还有两个方法,takeRecords() 用来清空记录队列并返回变动记录的数组。disconnect() 用来停止观察。调用该方法后,DOM 再发生变动,也不会触发观察器。

var $text5 = document.createTextNode('新增文本节点5');
var $text6 = document.createTextNode('新增文本节点6');

// 新增文本节点
$tar.appendChild($text5);
var record = mutationObserver.takeRecords();

console.log('record: ', record); // 返回 记录新增文本节点操作,并清空监听队列

// 替换文本节点
$tar.replaceChild($text6, $text5);

mutationObserver.disconnect(); // 此处以后的不再监听

// 删除文本节点
$tar.removeChild($text6); // 监听不到

前面还有两个属性 attributeOldValuecharacterDataOldValue 没有说,其实是影响 takeRecords() 方法返回 MutationRecord 实例。如果设置了这两个属性,就会对应返回对象中 oldValue 为记录之前旧的 attributedata值。

比如将原来的 className 的值 aaa 替换成 taroldValue 记录为 aaa

record: [{
  addedNodes: NodeList []
  attributeName: "class"
  attributeNamespace: null
  nextSibling: null
  oldValue: "aaa"
  previousSibling: null
  removedNodes: NodeList []
  target: div#tar.tar
  type: "attributes"
}]

MutationObserver 的应用

一个容器本身以及内部元素的属性变化,节点变化和文本变化是影响该容器高宽的重要因素(当然还有其他因素),以上了解了 MutationObserver API 的一些细节,可以实现监听容器宽高的变化。

var $tar = document.getElementById('tar');
var MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver;

var recordHeight = 0;
var mutationObserver = new MutationObserver(function (mutations) {
  console.log(mutations);

  let height = window.getComputedStyle($tar).getPropertyValue('height');
  if (height === recordHeight) {
    return;
  }
  recordHeight = height;
  console.log('高度变化了');
  // 之后更新外部容器等操作
})

mutationObserver.observe($tar, {
  childList: true, // 子节点的变动(新增、删除或者更改)
  attributes: true, // 属性的变动
  characterData: true, // 节点内容或节点文本的变动
  subtree: true // 是否将观察器应用于该节点的所有后代节点
})

漏网之鱼:动画(animation、transform)改变容器高(宽)

除了容器内部元素节点、属性变化,还有 css3 动画会影响容器高宽,由于动画并不会造成元素属性的变化,所以 MutationObserver API 是监听不到的。

#tar 容器加入以下 css 动画

@keyframes changeHeight {
  to {
    height: 300px;
  }
}

#tar {
  background-color: aqua;
  border: 1px solid #ccc;
  animation: changeHeight 2s ease-in 1s;
}

MutationObserver监听不到动画改变高宽

可以看出,没有打印输出,是监听不到动画改变高宽的。所以,在这还需对这条“漏网之鱼”进行处理。处理很简单,只需在动画(transitionendanimationend)停止事件触发时监听高宽变化即可。在这里用 Vue 自定义指令处理如下:

/**
 * 监听元素高度变化,更新滚动容器
 */
Vue.directive('observe-element-height', {
  insert (el, binding) {
    const MutationObserver = window.MutationObserver || window.webkitMutationObserver || window.MozMutationObserver
    let recordHeight = 0
    const onHeightChange = _.throttle(function () { // _.throttle 节流函数
      let height = window.getComputedStyle(el).getPropertyValue('height');
      if (height === recordHeight) {
        return
      }
      recordHeight = height
      console.log('高度变化了')
      // 之后更新外部容器等操作
    }, 500)

    el.__onHeightChange__ = onHeightChange

    el.addEventListener('animationend', onHeightChange)

    el.addEventListener('transitionend', onHeightChange)

    el.__observer__ = new MutationObserver((mutations) => {
      onHeightChange()
    });

    el.__observer__.observe(el, {
      childList: true,
      subtree: true,
      characterData: true,
      attributes: true
    })
  },
  unbind (el) {
    if (el.__observer__) {
      el.__observer__.disconnect()
      el.__observer__ = null
    }
    el.removeEventListener('animationend', el.__onHeightChange__)
    el.removeEventListener('transitionend', el.__onHeightChange__)
    el.__onHeightChange__ = null
  }
})

animationend事件监听动画改变高宽

ResizeObserver

既然对容器区域宽高监听有硬性需求,那么是否有相关规范呢?答案是有的,ResizeObserver 接口可以监听到 Element 的内容区域或 SVGElement 的边界框改变。内容区域则需要减去内边距 padding。目前还是实验性的一个接口,各大浏览器对ResizeObserver兼容性不够,实际应用需谨慎。

ResizeObserver

ResizeObserver Polyfill

实验性的 API 不足,总有 Polyfill 来弥补。

  1. ResizeObserver Polyfill 利用事件冒泡,在顶层 document 上监听动画 transitionend
  2. 监听 windowresize 事件;
  3. 其次用 MutationObserver 监听 document 元素;
  4. 兼容IE11以下 通过 DOMSubtreeModified 监听 document 元素。

利用MapShim (类似ES6中 Map) 数据结构,key 为被监听元素,valueResizeObserver 实例,映射监听关系,顶层 documentwindow 监听到触发事件,通过绑定元素即可监听元素尺寸变化。部分源码如下:

/**
 * Initializes DOM listeners.
 *
 * @private
 * @returns {void}
 */
ResizeObserverController.prototype.connect_ = function () {
    // Do nothing if running in a non-browser environment or if listeners
    // have been already added.
    if (!isBrowser || this.connected_) {
        return;
    }
    // Subscription to the "Transitionend" event is used as a workaround for
    // delayed transitions. This way it's possible to capture at least the
    // final state of an element.
    document.addEventListener('transitionend', this.onTransitionEnd_);
    window.addEventListener('resize', this.refresh);
    if (mutationObserverSupported) {
        this.mutationsObserver_ = new MutationObserver(this.refresh);
        this.mutationsObserver_.observe(document, {
            attributes: true,
            childList: true,
            characterData: true,
            subtree: true
        });
    }
    else {
        document.addEventListener('DOMSubtreeModified', this.refresh);
        this.mutationEventsAdded_ = true;
    }
    this.connected_ = true;
};

PS:不过,这里貌似作者没有对 animation 做处理,也就是 animation 改变元素尺寸还是监听不到。不知道是不是我没有全面的考虑,这点已向作者提了issue

用 iframe 模拟 window 的 resize

windowresize 没有兼容性问题,按照这个思路,可以用隐藏的 iframe 模拟 window 撑满要监听得容器元素,当容器尺寸变化时,自然会 iframe 尺寸也会改变,通过contentWindow.onresize() 就能监听得到。

function observeResize(element, handler) {
  let frame = document.createElement('iframe');
  const CSS = 'position:absolute;left:0;top:-100%;width:100%;height:100%;margin:1px 0 0;border:none;opacity:0;visibility:hidden;pointer-events:none;';
  frame.style.cssText = CSS;
  frame.onload = () => {
    frame.contentWindow.onresize = () => {
      handler(element);
    };
  };
  element.appendChild(frame);
  return frame;
}

let element = document.getElementById('main');
// listen for resize
observeResize(element, () => {
  console.log('new size: ', {
    width: element.clientWidth,
    height: element.clientHeight
  });
});

采用这种方案常用插件有 iframe-resizerresize-sensor等。不过这种方案不是特别优雅,需要插入 iframe 元素,还需将父元素定位,可能在页面上会有其他意想不到的问题,仅作为供参考方案吧。

总结

最后,要优雅地监听元素的宽高变化,不要去根据交互行为而是从元素本身去监听,了解 MutationObserver 接口是重点,其次要考虑到元素动画可能造成宽高变化,兼容IE11以下,通过 DOMSubtreeModified 监听。用 iframe 模拟 window 的 resize 属于一种供参考方案。做的功课有点少,欢迎指正,完~

查看原文

赞 57 收藏 43 评论 1

wuwhs 赞了文章 · 2019-06-10

了解HTML5中的MutationObserver

MutationObserver翻译过来就是变动观察器,字面上就可以理解这是用来观察Node(节点)变化的。MutationObserver是在DOM4规范中定义的,它的前身是MutationEvent事件,该事件最初在DOM2事件规范中介绍,到来了DOM3事件规范中正式定义,但是由于该事件存在兼容性以及性能上的问题被弃用。

MutationEvent

虽然MutationEvent已经被弃用,但是我们还是需要了解它,可能你会为了浏览器兼容性的问题而遇到它(万恶的浏览器兼容性)。

MutationEvent总共有7种事件:DOMNodeInsertedDOMNodeRemovedDOMSubtreeModifiedDOMAttrModified
DOMCharacterDataModifiedDOMNodeInsertedIntoDocumentDOMNodeRemovedFromDocument

MutationEvent的兼容性:

  1. MutationEvent在IE浏览器中最低支持到IE9
  2. 在webkit内核的浏览器中,不支持DOMAttrModified事件
  3. IE,Edge以及Firefox浏览器下不支持DOMNodeInsertedIntoDocumentDOMNodeRemovedFromDocument事件

MutationEvent中的所有事件都被设计成无法取消,如果可以取消MutationEvent事件则会导致现有的DOM接口无法对文档进行改变,比如appendChild,remove等添加和删除节点的DOM操作。
MutationEvent中最令人诟病的就是性能以及安全性的问题,比如下面这个例子:

document.addEventListener('DOMNodeInserted', function() {
    var newEl = document.createElement('div');
    document.body.appendChild(newEl);
});

document下的所有DOM添加操作都会触发DOMNodeInserted方法,这时就会出现循环调用DOMNodeInserted方法,导致浏览器崩溃。还有就是MutationEvent是事件机制,因此会有一般事件都存在的捕获和冒泡阶段,此时如果在捕获和冒泡阶段又对DOM进行了操作会拖慢浏览器的运行。

另一点就是MutationEvent事件机制是同步的,也就是说每次DOM修改就会触发,修改几次就触发几次,严重降低浏览器的运行,严重时甚至导致线程崩溃

<div id='block'></div>
var i=0;
block.addEventListener('DOMNodeInserted', function(e) {
     i++                                  
});
block.appendChild(docuemnt.createTextNode('1'));
console.log(i)                  //1
block.appendChild(docuemnt.createTextNode('2'));
console.log(i)                  //2
block.appendChild(docuemnt.createTextNode('3'));
console.log(i)                  //3

再看个例子:

<div id='block'>
  <span id='span'>Text</span>
</div>
block.addEventListener('DOMNodeInserted', function(e) {
     console.log('1');                                  //1
});
span.appendChild(docuemnt.createTextNode('other Text'));

span元素中添加节点会触发block中的DOMNodeInserted事件,可是你只想观察block的变化,不想观察block中子节点的变化,这时你不得不在DOMNodeInserted事件中进行过滤,把对span的操作忽略掉,这无疑增加了操作的复杂性。

MutationObserver

MutationObserver的出现就是为了解决MutationEvent带来的问题。
先看一下MutationObserver的浏览器兼容性:

MutationObserver浏览器兼容性

我们可以看到MutationObserver在IE中最低要就是IE11,如果你的网站不需要支持IE或者只支持到IE11,那么你可以放心的使用MutationObserver,否则你可能需要用到上面提到的MutationEvent事件,当然如果你的网站还要支持IE8及以下版本,那么你只能和Mutation说拜拜了。

MutationObserver是一个构造器,接受一个callback参数,用来处理节点变化的回调函数,返回两个参数,mutations:节点变化记录列表(sequence<MutationRecord>),observer:构造MutationObserver对象。

var observe = new MutationObserver(function(mutations,observer){
})

MutationObserver对象有三个方法,分别如下:

  1. observe:设置观察目标,接受两个参数,target:观察目标,options:通过对象成员来设置观察选项
  2. disconnect:阻止观察者观察任何改变
  3. takeRecords:清空记录队列并返回里面的内容

关于observe方法中options参数有已下几个选项:

  1. childList:设置true,表示观察目标子节点的变化,比如添加或者删除目标子节点,不包括修改子节点以及子节点后代的变化
  2. attributes:设置true,表示观察目标属性的改变
  3. characterData:设置true,表示观察目标数据的改变
  4. subtree:设置为true,目标以及目标的后代改变都会观察
  5. attributeOldValue:如果属性为true或者省略,则相当于设置为true,表示需要记录改变前的目标属性值,设置了attributeOldValue可以省略attributes设置
  6. characterDataOldValue:如果characterData为true或省略,则相当于设置为true,表示需要记录改变之前的目标数据,设置了characterDataOldValue可以省略characterData设置
  7. attributeFilter:如果不是所有的属性改变都需要被观察,并且attributes设置为true或者被忽略,那么设置一个需要观察的属性本地名称(不需要命名空间)的列表

下表描述了MutationObserver选项与MutationEvent名称之间的对应关系:

MutationEventMutationObserver options
DOMNodeInserted{ childList: true, subtree: true }
DOMNodeRemoved{ childList: true, subtree: true }
DOMSubtreeModified{ childList: true, subtree: true }
DOMAttrModified{ attributes: true, subtree: true }
DOMCharacterDataModified{ characterData: true, subtree: true }

从上表我们也可以看出相比与MutationEvent而言MutationObserver极大地增加了灵活性,可以设置各种各样的选项来满足程序员对目标的观察。

我们简单看几个例子:

<div id='target' class='block' name='target'>
    target的第一个子节点
    <p>
       <span>target的后代</span>
    </p>
</div>

1.callback的回调次数

var target=document.getElementById('target');
var i=0
var observe=new MutationObserver(function (mutations,observe) {
    i++   
});
observe.observe(target,{ childList: true});
target.appendChild(docuemnt.createTextNode('1'));
target.appendChild(docuemnt.createTextNode('2'));
target.appendChild(docuemnt.createTextNode('3'));
console.log(i)                //1

MutationObserver的callback回调函数是异步的,只有在全部DOM操作完成之后才会调用callback。

2.当只设置{ childList: true}时,表示观察目标子节点的变化

var observe=new MutationObserver(function (mutations,observe) {
    debugger;
    console.log(mutations);
    //observe.discount();     
});

observe.observe(target,{ childList: true});
target.appendChild(document.createTextNode('新增Text节点'));   //增加节点,观察到变化
target.childNodes[0].remove();                                //删除节点,可以观察到
target.childNodes[0].textContent='改变子节点的后代';             //不会观察到

如果想要观察到子节点以及后代的变化需设置{childList: true, subtree: true}

attributes选项用来观察目标属性的变化,用法类似与childList,目标属性的删除添加以及修改都会被观察到。

3.我们需要注意的是characterData这个选项,它是用来观察CharacterData类型的节点的,只有在改变节点数据时才会观察到,如果你删除或者增加节点都不会进行观察,还有如果对不是CharacterData类型的节点的改变不会观察到,比如:

observe.observe(target,{ characterData: true, subtree: true});
target.childNodes[0].textContent='改变Text节点';              //观察到
target.childNodes[1].textContent='改变p元素内容';              //不会观察到
target.appendChild(document.createTextNode('新增Text节点'));  //不会观察到
target.childNodes[0].remove();                               //删除TEXT节点也不会观察到

我们只需要记住只有对CharacterData类型的节点的数据改变才会被characterData为true的选项所观察到。

4.最后关注一个特别有用的选项attributeFilter,这个选项主要是用来筛选要观察的属性,比如你只想观察目标style属性的变化,这时可以如下设置:

observe.observe(target,{ attributeFilter: ['style'], subtree: true});
target.style='color:red';                      //可以观察到
target.removeAttribute('name');                //删除name属性,无法观察到 

disconnect方法是用来阻止观察的,当你不再想观察目标节点的变化时可以调用observe.disconnect()方法来取消观察。

takeRecords方法是用来取出记录队列中的记录。它的一个作用是,比如你对一个节点的操作你不想马上就做出反应,过段时间在显示改变了节点的内容。

var observe=new MutationObserver(function(){});
observe.observe(target,{ childList: true});
target.appendChild(document.createTextNode('新增Text节点'));
var record = observe.takeRecords();              //此时record保存了改变记录列表  
//当调用takeRecords方法时,记录队列被清空因此不会触发MutationObserver中的callback回调方法。
target.appendChild(document.createElement('span'));
observe.disconnect();                            //停止对target的观察。
//MutationObserver中的回调函数只有一个记录,只记录了新增span元素

//之后可以对record进行操作
//...

MutationRecord
变动记录中的属性如下:

  1. type:如果是属性变化,返回"attributes",如果是一个CharacterData节点(Text节点、Comment节点)变化,返回"characterData",节点树变化返回"childList"
  2. target:返回影响改变的节点
  3. addedNodes:返回添加的节点列表
  4. removedNodes:返回删除的节点列表
  5. previousSibling:返回分别添加或删除的节点的上一个兄弟节点,否则返回null
  6. nextSibling:返回分别添加或删除的节点的下一个兄弟节点,否则返回null
  7. attributeName:返回已更改属性的本地名称,否则返回null
  8. attributeNamespace:返回已更改属性的名称空间,否则返回null
  9. oldValue:返回值取决于type。对于"attributes",它是更改之前的属性的值。对于"characterData",它是改变之前节点的数据。对于"childList",它是null

其中 typetarget这两个属性不管是哪种观察方式都会有返回值,其他属性返回值与观察方式有关,比如只有当attributeOldValue或者characterDataOldValue为true时oldValue才有返回值,只有改变属性时,attributeName才有返回值等。

查看原文

赞 47 收藏 35 评论 7

wuwhs 收藏了文章 · 2019-05-17

移动端H5实现图片上传

需求

公司现在在移动端使用webuploader实现图片上传,但最近需求太奇葩了,插件无法满足我们的PM
经过商讨决定下掉这个插件,使用H5原生的API实现图片上传。

7.3日发布:单张图片上传

9.29日更新:多张图片并发上传

11.06日更新:常见问题

效果图:
uploader

基础知识

上传图片这块有几个知识点要先了解的。首先是有几种常见的移动端图片上传方式:

FormData

通过FormData对象可以组装一组用 XMLHttpRequest发送请求的键/值对。它可以更灵活方便的发送表单数据,因为可以独立于表单使用。如果你把表单的编码类型设置为multipart/form-data ,则通过FormData传输的数据格式和表单通过submit() 方法传输的数据格式相同。

这是一种常见的移动端上传方式,FormData也是H5新增的 兼容性如下:
clipboard.png

base64

Base64是一种基于64个可打印字符来表示二进制数据的表示方法。 由于2的6次方等于64,所以每6个位元为一个单元,对应某个可打印字符。 三个字节有24个位元,对应于4个Base64单元,即3个字节可表示4个可打印字符。

base64可以说是很出名了,就是用一段字符串来描述一个二进制数据,所以很多时候也可以使用base64方式上传。兼容性如下:

clipboard.png

还有一些对象需要了解:

Blob对象

一个 Blob对象表示一个不可变的, 原始数据的类似文件对象。Blob表示的数据不一定是一个JavaScript原生格式。 File 接口基于Blob,继承 blob功能并将其扩展为支持用户系统上的文件。

简单说Blob就是一个二进制对象,是原生支持的,兼容性如下:

clipboard.png

FileReader对象

FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。

FileReader也就是将本地文件转换成base64格式的dataUrl。

clipboard.png

图片上传思路

准备工作都做完了,那怎样用这些材料完成一件事情呢。
这里要强调的是,考虑到移动端流量很贵,所以有必要对大图片进行下压缩再上传。
图片压缩很简单,将图片用canvas画出来,再使用canvas.toDataUrl方法将图片转成base64格式。
所以图片上传思路大致是:

  1. 监听一个input(type=‘file’)onchange事件,这样获取到文件file
  2. file转成dataUrl;
  3. 然后根据dataUrl利用canvas绘制图片压缩,然后再转成新的dataUrl
  4. 再把dataUrl转成Blob
  5. BlobappendFormData中;
  6. xhr实现上传。

手机兼容性问题

理想很丰满,现实很骨感。
实际上由于手机平台兼容性问题,上面这套流程并不能全都支持。
所以需要根据兼容性判断。

经过试验发现:

  1. 部分安卓微信浏览器无法触发onchange事件(第一步就特么遇到问题)
    这其实安卓微信的一个遗留问题。 查看讨论 解决办法也很简单:input标签 <input type=“file" name="image" accept="image/gif, image/jpeg, image/png”>要写成<input type="file" name="image" accept=“image/*”>就没问题了。
  2. 部分安卓微信不支持Blob对象
  3. 部分Blob对象appendFormData中出现问题
  4. iOS 8不支持new File Constructor,但是支持input里的file对象。
  5. iOS 上经过压缩后的图片可以上传成功 但是size是0 无法打开。
  6. 部分手机出现图片上传转换问题,请移步
  7. 安卓手机不支持多选,原因在于multiple属性根本就不支持。
  8. 多张图片转base64时候卡顿,因为调用了cpu进行了计算。
  9. 上传图片可以使用base64上传或者formData上传

上传思路修改方案

经过考虑,我们决定做兼容性处理:

clipboard.png

这里边两条路,最后都是File对象appendFormData中实现上传。

代码实现

首先有个html

<input type="file" name="image" accept=“image/*” onchange='handleInputChange'>

然后js如下:

// 全局对象,不同function使用传递数据
const imgFile = {};

function handleInputChange (event) {
    // 获取当前选中的文件
    const file = event.target.files[0];
    const imgMasSize = 1024 * 1024 * 10; // 10MB

    // 检查文件类型
    if(['jpeg', 'png', 'gif', 'jpg'].indexOf(file.type.split("/")[1]) < 0){
        // 自定义报错方式
        // Toast.error("文件类型仅支持 jpeg/png/gif!", 2000, undefined, false);
        return;
    }

    // 文件大小限制
    if(file.size > imgMasSize ) {
        // 文件大小自定义限制
        // Toast.error("文件大小不能超过10MB!", 2000, undefined, false);
        return;
    }

    // 判断是否是ios
    if(!!window.navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)){
        // iOS
        transformFileToFormData(file);
        return;
    }

    // 图片压缩之旅
    transformFileToDataUrl(file);
}
// 将File append进 FormData
function transformFileToFormData (file) {
    const formData = new FormData();
    // 自定义formData中的内容
    // type
    formData.append('type', file.type);
    // size
    formData.append('size', file.size || "image/jpeg");
    // name
    formData.append('name', file.name);
    // lastModifiedDate
    formData.append('lastModifiedDate', file.lastModifiedDate);
    // append 文件
    formData.append('file', file);
    // 上传图片
    uploadImg(formData);
}
// 将file转成dataUrl
function transformFileToDataUrl (file) {
    const imgCompassMaxSize = 200 * 1024; // 超过 200k 就压缩

    // 存储文件相关信息
    imgFile.type = file.type || 'image/jpeg'; // 部分安卓出现获取不到type的情况
    imgFile.size = file.size;
    imgFile.name = file.name;
    imgFile.lastModifiedDate = file.lastModifiedDate;

    // 封装好的函数
    const reader = new FileReader();

    // file转dataUrl是个异步函数,要将代码写在回调里
    reader.onload = function(e) {
        const result = e.target.result;

        if(result.length < imgCompassMaxSize) {
            compress(result, processData, false );    // 图片不压缩
        } else {
            compress(result, processData);            // 图片压缩
        }
    };

    reader.readAsDataURL(file);
}
// 使用canvas绘制图片并压缩
function compress (dataURL, callback, shouldCompress = true) {
    const img = new window.Image();

    img.src = dataURL;

    img.onload = function () {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');

        canvas.width = img.width;
        canvas.height = img.height;

        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

        let compressedDataUrl;

        if(shouldCompress){
            compressedDataUrl = canvas.toDataURL(imgFile.type, 0.2);
        } else {
            compressedDataUrl = canvas.toDataURL(imgFile.type, 1);
        }

        callback(compressedDataUrl);
    }
}

function processData (dataURL) {
    // 这里使用二进制方式处理dataUrl
    const binaryString = window.atob(dataUrl.split(',')[1]);
    const arrayBuffer = new ArrayBuffer(binaryString.length);
    const intArray = new Uint8Array(arrayBuffer);
    const imgFile = this.imgFile;

    for (let i = 0, j = binaryString.length; i < j; i++) {
        intArray[i] = binaryString.charCodeAt(i);
    }

    const data = [intArray];

    let blob;

    try {
        blob = new Blob(data, { type: imgFile.type });
    } catch (error) {
        window.BlobBuilder = window.BlobBuilder ||
            window.WebKitBlobBuilder ||
            window.MozBlobBuilder ||
            window.MSBlobBuilder;
        if (error.name === 'TypeError' && window.BlobBuilder){
            const builder = new BlobBuilder();
            builder.append(arrayBuffer);
            blob = builder.getBlob(imgFile.type);
        } else {
            // Toast.error("版本过低,不支持上传图片", 2000, undefined, false);
            throw new Error('版本过低,不支持上传图片');
        }
    }

    // blob 转file
    const fileOfBlob = new File([blob], imgFile.name);
    const formData = new FormData();

    // type
    formData.append('type', imgFile.type);
    // size
    formData.append('size', fileOfBlob.size);
    // name
    formData.append('name', imgFile.name);
    // lastModifiedDate
    formData.append('lastModifiedDate', imgFile.lastModifiedDate);
    // append 文件
    formData.append('file', fileOfBlob);

    uploadImg(formData);
}

// 上传图片
uploadImg (formData) {
    const xhr = new XMLHttpRequest();

    // 进度监听
    xhr.upload.addEventListener('progress', (e)=>{console.log(e.loaded / e.total)}, false);
    // 加载监听
    // xhr.addEventListener('load', ()=>{console.log("加载中");}, false);
    // 错误监听
    xhr.addEventListener('error', ()=>{Toast.error("上传失败!", 2000, undefined, false);}, false);
    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
            const result = JSON.parse(xhr.responseText);
            if (xhr.status === 200) {
                // 上传成功
                

            } else {
                // 上传失败
            }
        }
    };
    xhr.open('POST', '/uploadUrl' , true);
    xhr.send(formData);
}

多图并发上传

这个上限没多久,需求又改了,一张图也满足不了我们的PM了,要求改成多张图。

多张图片上传方式有三种:

  1. 图片队列一张一张上传
  2. 图片队列并发全部上传
  3. 图片队列并发上传X个,其中一个返回了结果直接触发下一个上传,保证最多有X个请求。

这个一张一张上传好解决,但是问题是上传事件太长了,体验不佳;多张图片全部上传事件变短了,但是并发量太大了,很可能出现问题;最后这个并发上传X个,体验最佳,只是需要仔细想想如何实现。

并发上传实现

最后我们确定X = 3或者4。比如说上传9张图片,第一次上传个3个,其中一个请求回来了,立即去上传第四个,下一个回来上传第5个,以此类推。
这里我使用es6的generator函数来实现的,定义一个函数,返回需要上传的数组:

*uploadGenerator (uploadQueue) {
        /**
         * 多张图片并发上传控制规则
         * 上传1-max数量的图片
         * 设置一个最大上传数量
         * 保证最大只有这个数量的上传请求
         *
         */

        // 最多只有三个请求在上传
        const maxUploadSize = 3;

        if(uploadQueue.length > maxUploadSize){

            const result = [];

            for(let i = 0; i < uploadQueue.length; i++){
                // 第一次return maxUploadSize数量的图片
                if(i < maxUploadSize){
                    result.push(uploadQueue[i]);

                    if(i === maxUploadSize - 1){
                        yield result;
                    }
                } else {
                    yield [uploadQueue[i]];
                }
            }

        } else {
            yield uploadQueue.map((item)=>(item));
        }
    }

调用的时候:

// 通过该函数获取每次要上传的数组
        this.uploadGen = this.uploadGenerator(uploadQueue);
        // 第一次要上传的数量
        const firstUpload = this.uploadGen.next();


        // 真正开始上传流程
        firstUpload.value.map((item)=>{
            /**
             * 图片上传分成5步
             * 图片转dataUrl
             * 压缩
             * 处理数据格式
             * 准备数据上传
             * 上传
             *
             * 前两步是回调的形式 后面是同步的形式
             */
            this.transformFileToDataUrl(item, this.compress, this.processData);
        });

这样将每次上传几张图片的逻辑分离出来。

单个图片上传函数改进

然后遇到了下一个问题,图片上传分成5步,

  1. 图片转dataUrl
  2. 压缩
  3. 处理数据格式
  4. 准备数据上传
  5. 上传

这里面前两个是回调的形式,最后一个是异步形式。无法写成正常函数一个调用一个;而且各个function之间需要共享一些数据,之前把这个数据挂载到this.imgFile上了,但是这次是并发,一个对象没法满足需求了,改成数组也有很多问题。

所以这次方案是:第一步创建一个要上传的对象,每次都通过参数交给下一个方法,直到最后一个方法上传。并且通过回调的方式,将各个步骤串联起来。Upload完整的代码如下:

/**
 * Created by Aus on 2017/7/4.
 */
import React from 'react'
import classNames from 'classnames'
import Touchable from 'rc-touchable'
import Figure from './Figure'
import Toast from '../../../Feedback/Toast/components/Toast'
import '../style/index.scss'

// 统计img总数 防止重复
let imgNumber = 0;

// 生成唯一的id
const getUuid = () => {
    return "img-" + new Date().getTime() + "-" + imgNumber++;
};

class Uploader extends React.Component{
    constructor (props) {
        super(props);
        this.state = {
            imgArray: [] // 图片已上传 显示的数组
        };
        this.handleInputChange = this.handleInputChange.bind(this);
        this.compress = this.compress.bind(this);
        this.processData = this.processData.bind(this);
    }
    componentDidMount () {
        // 判断是否有初始化的数据传入
        const {data} = this.props;

        if(data && data.length > 0){
            this.setState({imgArray: data});
        }
    }
    handleDelete(id) {
        this.setState((previousState)=>{
            previousState.imgArray = previousState.imgArray.filter((item)=>(item.id !== id));
            return previousState;
        });
    }
    handleProgress (id, e) {
        // 监听上传进度 操作DOM 显示进度
        const number = Number.parseInt((e.loaded / e.total) * 100) + "%";
        const text = document.querySelector('#text-'+id);
        const progress = document.querySelector('#progress-'+id);

        text.innerHTML = number;
        progress.style.width = number;
    }
    handleUploadEnd (data, status) {
        // 准备一条标准数据
        const _this = this;
        const obj = {id: data.uuid, imgKey: '', imgUrl: '', name: data.file.name, dataUrl: data.dataUrl, status: status};

        // 更改状态
        this.setState((previousState)=>{
            previousState.imgArray = previousState.imgArray.map((item)=>{
                if(item.id === data.uuid){
                    item = obj;
                }

                return item;
            });
            return previousState;
        });

        // 上传下一个
        const nextUpload = this.uploadGen.next();
        if(!nextUpload.done){
            nextUpload.value.map((item)=>{
                _this.transformFileToDataUrl(item, _this.compress, _this.processData);
            });
        }
    }
    handleInputChange (event) {
        const {typeArray, max, maxSize} = this.props;
        const {imgArray} = this.state;
        const uploadedImgArray = []; // 真正在页面显示的图片数组
        const uploadQueue = []; // 图片上传队列 这个队列是在图片选中到上传之间使用的 上传完成则清除

        // event.target.files是个类数组对象 需要转成数组方便处理
        const selectedFiles = Array.prototype.slice.call(event.target.files).map((item)=>(item));

        // 检查文件个数 页面显示的图片个数不能超过限制
        if(imgArray.length + selectedFiles.length > max){
            Toast.error('文件数量超出最大值', 2000, undefined, false);
            return;
        }

        let imgPass = {typeError: false, sizeError: false};

        // 循环遍历检查图片 类型、尺寸检查
        selectedFiles.map((item)=>{
            // 图片类型检查
            if(typeArray.indexOf(item.type.split('/')[1]) === -1){
                imgPass.typeError = true;
            }
            // 图片尺寸检查
            if(item.size > maxSize * 1024){
                imgPass.sizeError = true;
            }

            // 为图片加上位移id
            const uuid = getUuid();
            // 上传队列加入该数据
            uploadQueue.push({uuid: uuid, file: item});
            // 页面显示加入数据
            uploadedImgArray.push({ // 显示在页面的数据的标准格式
                id: uuid, // 图片唯一id
                dataUrl: '', // 图片的base64编码
                imgKey: '', // 图片的key 后端上传保存使用
                imgUrl: '', // 图片真实路径 后端返回的
                name: item.name, // 图片的名字
                status: 1 // status表示这张图片的状态 1:上传中,2上传成功,3:上传失败
            });
        });

        // 有错误跳出
        if(imgPass.typeError){
            Toast.error('不支持文件类型', 2000, undefined, false);
            return;
        }

        if(imgPass.sizeError){
            Toast.error('文件大小超过限制', 2000, undefined, false);
            return;
        }

        // 没错误准备上传
        // 页面先显示一共上传图片个数
        this.setState({imgArray: imgArray.concat(uploadedImgArray)});

        // 通过该函数获取每次要上传的数组
        this.uploadGen = this.uploadGenerator(uploadQueue);
        // 第一次要上传的数量
        const firstUpload = this.uploadGen.next();


        // 真正开始上传流程
        firstUpload.value.map((item)=>{
            /**
             * 图片上传分成5步
             * 图片转dataUrl
             * 压缩
             * 处理数据格式
             * 准备数据上传
             * 上传
             *
             * 前两步是回调的形式 后面是同步的形式
             */
            this.transformFileToDataUrl(item, this.compress, this.processData);
        });
    }
    *uploadGenerator (uploadQueue) {
        /**
         * 多张图片并发上传控制规则
         * 上传1-max数量的图片
         * 设置一个最大上传数量
         * 保证最大只有这个数量的上传请求
         *
         */

        // 最多只有三个请求在上传
        const maxUploadSize = 3;

        if(uploadQueue.length > maxUploadSize){

            const result = [];

            for(let i = 0; i < uploadQueue.length; i++){
                // 第一次return maxUploadSize数量的图片
                if(i < maxUploadSize){
                    result.push(uploadQueue[i]);

                    if(i === maxUploadSize - 1){
                        yield result;
                    }
                } else {
                    yield [uploadQueue[i]];
                }
            }

        } else {
            yield uploadQueue.map((item)=>(item));
        }
    }
    transformFileToDataUrl (data, callback, compressCallback) {
        /**
         * 图片上传流程的第一步
         * @param data file文件 该数据会一直向下传递
         * @param callback 下一步回调
         * @param compressCallback 回调的回调
         */
        const {compress} = this.props;
        const imgCompassMaxSize = 200 * 1024; // 超过 200k 就压缩

        // 封装好的函数
        const reader = new FileReader();

        // ⚠️ 这是个回调过程 不是同步的
        reader.onload = function(e) {
            const result = e.target.result;
            data.dataUrl = result;

            if(compress && result.length > imgCompassMaxSize){
                data.compress = true;

                callback(data, compressCallback); // 图片压缩
            } else {
                data.compress = false;

                callback(data, compressCallback); // 图片不压缩
            }
        };

        reader.readAsDataURL(data.file);
    }
    compress (data, callback) {
        /**
         * 压缩图片
         * @param data file文件 数据会一直向下传递
         * @param callback 下一步回调
         */
        const {compressionRatio} = this.props;
        const imgFile = data.file;
        const img = new window.Image();

        img.src = data.dataUrl;

        img.onload = function () {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            canvas.width = img.width;
            canvas.height = img.height;

            ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

            let compressedDataUrl;

            if(data.compress){
                compressedDataUrl = canvas.toDataURL(imgFile.type, (compressionRatio / 100));
            } else {
                compressedDataUrl = canvas.toDataURL(imgFile.type, 1);
            }

            data.compressedDataUrl = compressedDataUrl;

            callback(data);
        }
    }
    processData (data) {
        // 为了兼容性 处理数据
        const dataURL = data.compressedDataUrl;
        const imgFile = data.file;
        const binaryString = window.atob(dataURL.split(',')[1]);
        const arrayBuffer = new ArrayBuffer(binaryString.length);
        const intArray = new Uint8Array(arrayBuffer);

        for (let i = 0, j = binaryString.length; i < j; i++) {
            intArray[i] = binaryString.charCodeAt(i);
        }

        const fileData = [intArray];

        let blob;

        try {
            blob = new Blob(fileData, { type: imgFile.type });
        } catch (error) {
            window.BlobBuilder = window.BlobBuilder ||
                window.WebKitBlobBuilder ||
                window.MozBlobBuilder ||
                window.MSBlobBuilder;
            if (error.name === 'TypeError' && window.BlobBuilder){
                const builder = new BlobBuilder();
                builder.append(arrayBuffer);
                blob = builder.getBlob(imgFile.type);
            } else {
                throw new Error('版本过低,不支持上传图片');
            }
        }

        data.blob = blob;
        this.processFormData(data);
    }
    processFormData (data) {
        // 准备上传数据
        const formData = new FormData();
        const imgFile = data.file;
        const blob = data.blob;

        // type
        formData.append('type', blob.type);
        // size
        formData.append('size', blob.size);
        // append 文件
        formData.append('file', blob, imgFile.name);

        this.uploadImg(data, formData);
    }
    uploadImg (data, formData) {
        // 开始发送请求上传
        const _this = this;
        const xhr = new XMLHttpRequest();
        const {uploadUrl} = this.props;

        // 进度监听
        xhr.upload.addEventListener('progress', _this.handleProgress.bind(_this, data.uuid), false);

        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4) {
                if (xhr.status === 200 || xhr.status === 201) {
                    // 上传成功
                    _this.handleUploadEnd(data, 2);
                } else {
                    // 上传失败
                    _this.handleUploadEnd(data, 3);
                }
            }
        };

        xhr.open('POST', uploadUrl , true);
        xhr.send(formData);
    }
    getImagesListDOM () {
        // 处理显示图片的DOM
        const {max} = this.props;
        const _this = this;
        const result = [];
        const uploadingArray = [];
        const imgArray = this.state.imgArray;

        imgArray.map((item)=>{
            result.push(
                <Figure key={item.id} {...item} onDelete={_this.handleDelete.bind(_this)} />
            );

            // 正在上传的图片
            if(item.status === 1){
                uploadingArray.push(item);
            }
        });

        // 图片数量达到最大值
        if(result.length >= max ) return result;

        let onPress = ()=>{_this.refs.input.click();};

        //  或者有正在上传的图片的时候 不可再上传图片
        if(uploadingArray.length > 0) {
            onPress = undefined;
        }

        // 简单的显示文案逻辑判断
        let text = '上传图片';

        if(uploadingArray.length > 0){
            text = (imgArray.length - uploadingArray.length) + '/' + imgArray.length;
        }

        result.push(
            <Touchable
                key="add"
                activeClassName={'zby-upload-img-active'}
                onPress={onPress}
            >
                <div className="zby-upload-img">
                    <span key="icon" className="fa fa-camera" />
                    <p className="text">{text}</p>
                </div>
            </Touchable>
        );

        return result;
    }
    render () {
        const imagesList = this.getImagesListDOM();
            
        return (
            <div className="zby-uploader-box">
                {imagesList}
                <input ref="input" type="file" className="file-input" name="image" accept="image/*" multiple="multiple" onChange={this.handleInputChange} />
            </div>
        )
    }
}

Uploader.propTypes = {
    uploadUrl: React.PropTypes.string.isRequired, // 图上传路径
    compress: React.PropTypes.bool, // 是否进行图片压缩
    compressionRatio: React.PropTypes.number, // 图片压缩比例 单位:%
    data: React.PropTypes.array, // 初始化数据 其中的每个元素必须是标准化数据格式
    max: React.PropTypes.number, // 最大上传图片数
    maxSize: React.PropTypes.number, // 图片最大体积 单位:KB
    typeArray: React.PropTypes.array, // 支持图片类型数组
};

Uploader.defaultProps = {
    compress: true,
    compressionRatio: 20,
    data: [],
    max: 9,
    maxSize: 5 * 1024, // 5MB
    typeArray: ['jpeg', 'jpg', 'png', 'gif'],
};

export default Uploader

配合Figure组件使用达到文章开头的效果。
源码在github

总结

使用1-2天时间研究如何实现原生上传图片,这样明白原理之后,上传再也不用借助插件了,
再也不怕PM提出什么奇葩需求了。
同时,也认识了一些陌生的函数。。

参考资料

  1. 移动端图片上传的实践
  2. 移动端H5图片上传的那些坑
  3. 文件上传那些事儿
  4. 如何给一个Blob上传的FormData一个文件名?
  5. 移动端H5图片压缩
查看原文

wuwhs 赞了文章 · 2019-05-17

移动端H5实现图片上传

需求

公司现在在移动端使用webuploader实现图片上传,但最近需求太奇葩了,插件无法满足我们的PM
经过商讨决定下掉这个插件,使用H5原生的API实现图片上传。

7.3日发布:单张图片上传

9.29日更新:多张图片并发上传

11.06日更新:常见问题

效果图:
uploader

基础知识

上传图片这块有几个知识点要先了解的。首先是有几种常见的移动端图片上传方式:

FormData

通过FormData对象可以组装一组用 XMLHttpRequest发送请求的键/值对。它可以更灵活方便的发送表单数据,因为可以独立于表单使用。如果你把表单的编码类型设置为multipart/form-data ,则通过FormData传输的数据格式和表单通过submit() 方法传输的数据格式相同。

这是一种常见的移动端上传方式,FormData也是H5新增的 兼容性如下:
clipboard.png

base64

Base64是一种基于64个可打印字符来表示二进制数据的表示方法。 由于2的6次方等于64,所以每6个位元为一个单元,对应某个可打印字符。 三个字节有24个位元,对应于4个Base64单元,即3个字节可表示4个可打印字符。

base64可以说是很出名了,就是用一段字符串来描述一个二进制数据,所以很多时候也可以使用base64方式上传。兼容性如下:

clipboard.png

还有一些对象需要了解:

Blob对象

一个 Blob对象表示一个不可变的, 原始数据的类似文件对象。Blob表示的数据不一定是一个JavaScript原生格式。 File 接口基于Blob,继承 blob功能并将其扩展为支持用户系统上的文件。

简单说Blob就是一个二进制对象,是原生支持的,兼容性如下:

clipboard.png

FileReader对象

FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。

FileReader也就是将本地文件转换成base64格式的dataUrl。

clipboard.png

图片上传思路

准备工作都做完了,那怎样用这些材料完成一件事情呢。
这里要强调的是,考虑到移动端流量很贵,所以有必要对大图片进行下压缩再上传。
图片压缩很简单,将图片用canvas画出来,再使用canvas.toDataUrl方法将图片转成base64格式。
所以图片上传思路大致是:

  1. 监听一个input(type=‘file’)onchange事件,这样获取到文件file
  2. file转成dataUrl;
  3. 然后根据dataUrl利用canvas绘制图片压缩,然后再转成新的dataUrl
  4. 再把dataUrl转成Blob
  5. BlobappendFormData中;
  6. xhr实现上传。

手机兼容性问题

理想很丰满,现实很骨感。
实际上由于手机平台兼容性问题,上面这套流程并不能全都支持。
所以需要根据兼容性判断。

经过试验发现:

  1. 部分安卓微信浏览器无法触发onchange事件(第一步就特么遇到问题)
    这其实安卓微信的一个遗留问题。 查看讨论 解决办法也很简单:input标签 <input type=“file" name="image" accept="image/gif, image/jpeg, image/png”>要写成<input type="file" name="image" accept=“image/*”>就没问题了。
  2. 部分安卓微信不支持Blob对象
  3. 部分Blob对象appendFormData中出现问题
  4. iOS 8不支持new File Constructor,但是支持input里的file对象。
  5. iOS 上经过压缩后的图片可以上传成功 但是size是0 无法打开。
  6. 部分手机出现图片上传转换问题,请移步
  7. 安卓手机不支持多选,原因在于multiple属性根本就不支持。
  8. 多张图片转base64时候卡顿,因为调用了cpu进行了计算。
  9. 上传图片可以使用base64上传或者formData上传

上传思路修改方案

经过考虑,我们决定做兼容性处理:

clipboard.png

这里边两条路,最后都是File对象appendFormData中实现上传。

代码实现

首先有个html

<input type="file" name="image" accept=“image/*” onchange='handleInputChange'>

然后js如下:

// 全局对象,不同function使用传递数据
const imgFile = {};

function handleInputChange (event) {
    // 获取当前选中的文件
    const file = event.target.files[0];
    const imgMasSize = 1024 * 1024 * 10; // 10MB

    // 检查文件类型
    if(['jpeg', 'png', 'gif', 'jpg'].indexOf(file.type.split("/")[1]) < 0){
        // 自定义报错方式
        // Toast.error("文件类型仅支持 jpeg/png/gif!", 2000, undefined, false);
        return;
    }

    // 文件大小限制
    if(file.size > imgMasSize ) {
        // 文件大小自定义限制
        // Toast.error("文件大小不能超过10MB!", 2000, undefined, false);
        return;
    }

    // 判断是否是ios
    if(!!window.navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)){
        // iOS
        transformFileToFormData(file);
        return;
    }

    // 图片压缩之旅
    transformFileToDataUrl(file);
}
// 将File append进 FormData
function transformFileToFormData (file) {
    const formData = new FormData();
    // 自定义formData中的内容
    // type
    formData.append('type', file.type);
    // size
    formData.append('size', file.size || "image/jpeg");
    // name
    formData.append('name', file.name);
    // lastModifiedDate
    formData.append('lastModifiedDate', file.lastModifiedDate);
    // append 文件
    formData.append('file', file);
    // 上传图片
    uploadImg(formData);
}
// 将file转成dataUrl
function transformFileToDataUrl (file) {
    const imgCompassMaxSize = 200 * 1024; // 超过 200k 就压缩

    // 存储文件相关信息
    imgFile.type = file.type || 'image/jpeg'; // 部分安卓出现获取不到type的情况
    imgFile.size = file.size;
    imgFile.name = file.name;
    imgFile.lastModifiedDate = file.lastModifiedDate;

    // 封装好的函数
    const reader = new FileReader();

    // file转dataUrl是个异步函数,要将代码写在回调里
    reader.onload = function(e) {
        const result = e.target.result;

        if(result.length < imgCompassMaxSize) {
            compress(result, processData, false );    // 图片不压缩
        } else {
            compress(result, processData);            // 图片压缩
        }
    };

    reader.readAsDataURL(file);
}
// 使用canvas绘制图片并压缩
function compress (dataURL, callback, shouldCompress = true) {
    const img = new window.Image();

    img.src = dataURL;

    img.onload = function () {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');

        canvas.width = img.width;
        canvas.height = img.height;

        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

        let compressedDataUrl;

        if(shouldCompress){
            compressedDataUrl = canvas.toDataURL(imgFile.type, 0.2);
        } else {
            compressedDataUrl = canvas.toDataURL(imgFile.type, 1);
        }

        callback(compressedDataUrl);
    }
}

function processData (dataURL) {
    // 这里使用二进制方式处理dataUrl
    const binaryString = window.atob(dataUrl.split(',')[1]);
    const arrayBuffer = new ArrayBuffer(binaryString.length);
    const intArray = new Uint8Array(arrayBuffer);
    const imgFile = this.imgFile;

    for (let i = 0, j = binaryString.length; i < j; i++) {
        intArray[i] = binaryString.charCodeAt(i);
    }

    const data = [intArray];

    let blob;

    try {
        blob = new Blob(data, { type: imgFile.type });
    } catch (error) {
        window.BlobBuilder = window.BlobBuilder ||
            window.WebKitBlobBuilder ||
            window.MozBlobBuilder ||
            window.MSBlobBuilder;
        if (error.name === 'TypeError' && window.BlobBuilder){
            const builder = new BlobBuilder();
            builder.append(arrayBuffer);
            blob = builder.getBlob(imgFile.type);
        } else {
            // Toast.error("版本过低,不支持上传图片", 2000, undefined, false);
            throw new Error('版本过低,不支持上传图片');
        }
    }

    // blob 转file
    const fileOfBlob = new File([blob], imgFile.name);
    const formData = new FormData();

    // type
    formData.append('type', imgFile.type);
    // size
    formData.append('size', fileOfBlob.size);
    // name
    formData.append('name', imgFile.name);
    // lastModifiedDate
    formData.append('lastModifiedDate', imgFile.lastModifiedDate);
    // append 文件
    formData.append('file', fileOfBlob);

    uploadImg(formData);
}

// 上传图片
uploadImg (formData) {
    const xhr = new XMLHttpRequest();

    // 进度监听
    xhr.upload.addEventListener('progress', (e)=>{console.log(e.loaded / e.total)}, false);
    // 加载监听
    // xhr.addEventListener('load', ()=>{console.log("加载中");}, false);
    // 错误监听
    xhr.addEventListener('error', ()=>{Toast.error("上传失败!", 2000, undefined, false);}, false);
    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
            const result = JSON.parse(xhr.responseText);
            if (xhr.status === 200) {
                // 上传成功
                

            } else {
                // 上传失败
            }
        }
    };
    xhr.open('POST', '/uploadUrl' , true);
    xhr.send(formData);
}

多图并发上传

这个上限没多久,需求又改了,一张图也满足不了我们的PM了,要求改成多张图。

多张图片上传方式有三种:

  1. 图片队列一张一张上传
  2. 图片队列并发全部上传
  3. 图片队列并发上传X个,其中一个返回了结果直接触发下一个上传,保证最多有X个请求。

这个一张一张上传好解决,但是问题是上传事件太长了,体验不佳;多张图片全部上传事件变短了,但是并发量太大了,很可能出现问题;最后这个并发上传X个,体验最佳,只是需要仔细想想如何实现。

并发上传实现

最后我们确定X = 3或者4。比如说上传9张图片,第一次上传个3个,其中一个请求回来了,立即去上传第四个,下一个回来上传第5个,以此类推。
这里我使用es6的generator函数来实现的,定义一个函数,返回需要上传的数组:

*uploadGenerator (uploadQueue) {
        /**
         * 多张图片并发上传控制规则
         * 上传1-max数量的图片
         * 设置一个最大上传数量
         * 保证最大只有这个数量的上传请求
         *
         */

        // 最多只有三个请求在上传
        const maxUploadSize = 3;

        if(uploadQueue.length > maxUploadSize){

            const result = [];

            for(let i = 0; i < uploadQueue.length; i++){
                // 第一次return maxUploadSize数量的图片
                if(i < maxUploadSize){
                    result.push(uploadQueue[i]);

                    if(i === maxUploadSize - 1){
                        yield result;
                    }
                } else {
                    yield [uploadQueue[i]];
                }
            }

        } else {
            yield uploadQueue.map((item)=>(item));
        }
    }

调用的时候:

// 通过该函数获取每次要上传的数组
        this.uploadGen = this.uploadGenerator(uploadQueue);
        // 第一次要上传的数量
        const firstUpload = this.uploadGen.next();


        // 真正开始上传流程
        firstUpload.value.map((item)=>{
            /**
             * 图片上传分成5步
             * 图片转dataUrl
             * 压缩
             * 处理数据格式
             * 准备数据上传
             * 上传
             *
             * 前两步是回调的形式 后面是同步的形式
             */
            this.transformFileToDataUrl(item, this.compress, this.processData);
        });

这样将每次上传几张图片的逻辑分离出来。

单个图片上传函数改进

然后遇到了下一个问题,图片上传分成5步,

  1. 图片转dataUrl
  2. 压缩
  3. 处理数据格式
  4. 准备数据上传
  5. 上传

这里面前两个是回调的形式,最后一个是异步形式。无法写成正常函数一个调用一个;而且各个function之间需要共享一些数据,之前把这个数据挂载到this.imgFile上了,但是这次是并发,一个对象没法满足需求了,改成数组也有很多问题。

所以这次方案是:第一步创建一个要上传的对象,每次都通过参数交给下一个方法,直到最后一个方法上传。并且通过回调的方式,将各个步骤串联起来。Upload完整的代码如下:

/**
 * Created by Aus on 2017/7/4.
 */
import React from 'react'
import classNames from 'classnames'
import Touchable from 'rc-touchable'
import Figure from './Figure'
import Toast from '../../../Feedback/Toast/components/Toast'
import '../style/index.scss'

// 统计img总数 防止重复
let imgNumber = 0;

// 生成唯一的id
const getUuid = () => {
    return "img-" + new Date().getTime() + "-" + imgNumber++;
};

class Uploader extends React.Component{
    constructor (props) {
        super(props);
        this.state = {
            imgArray: [] // 图片已上传 显示的数组
        };
        this.handleInputChange = this.handleInputChange.bind(this);
        this.compress = this.compress.bind(this);
        this.processData = this.processData.bind(this);
    }
    componentDidMount () {
        // 判断是否有初始化的数据传入
        const {data} = this.props;

        if(data && data.length > 0){
            this.setState({imgArray: data});
        }
    }
    handleDelete(id) {
        this.setState((previousState)=>{
            previousState.imgArray = previousState.imgArray.filter((item)=>(item.id !== id));
            return previousState;
        });
    }
    handleProgress (id, e) {
        // 监听上传进度 操作DOM 显示进度
        const number = Number.parseInt((e.loaded / e.total) * 100) + "%";
        const text = document.querySelector('#text-'+id);
        const progress = document.querySelector('#progress-'+id);

        text.innerHTML = number;
        progress.style.width = number;
    }
    handleUploadEnd (data, status) {
        // 准备一条标准数据
        const _this = this;
        const obj = {id: data.uuid, imgKey: '', imgUrl: '', name: data.file.name, dataUrl: data.dataUrl, status: status};

        // 更改状态
        this.setState((previousState)=>{
            previousState.imgArray = previousState.imgArray.map((item)=>{
                if(item.id === data.uuid){
                    item = obj;
                }

                return item;
            });
            return previousState;
        });

        // 上传下一个
        const nextUpload = this.uploadGen.next();
        if(!nextUpload.done){
            nextUpload.value.map((item)=>{
                _this.transformFileToDataUrl(item, _this.compress, _this.processData);
            });
        }
    }
    handleInputChange (event) {
        const {typeArray, max, maxSize} = this.props;
        const {imgArray} = this.state;
        const uploadedImgArray = []; // 真正在页面显示的图片数组
        const uploadQueue = []; // 图片上传队列 这个队列是在图片选中到上传之间使用的 上传完成则清除

        // event.target.files是个类数组对象 需要转成数组方便处理
        const selectedFiles = Array.prototype.slice.call(event.target.files).map((item)=>(item));

        // 检查文件个数 页面显示的图片个数不能超过限制
        if(imgArray.length + selectedFiles.length > max){
            Toast.error('文件数量超出最大值', 2000, undefined, false);
            return;
        }

        let imgPass = {typeError: false, sizeError: false};

        // 循环遍历检查图片 类型、尺寸检查
        selectedFiles.map((item)=>{
            // 图片类型检查
            if(typeArray.indexOf(item.type.split('/')[1]) === -1){
                imgPass.typeError = true;
            }
            // 图片尺寸检查
            if(item.size > maxSize * 1024){
                imgPass.sizeError = true;
            }

            // 为图片加上位移id
            const uuid = getUuid();
            // 上传队列加入该数据
            uploadQueue.push({uuid: uuid, file: item});
            // 页面显示加入数据
            uploadedImgArray.push({ // 显示在页面的数据的标准格式
                id: uuid, // 图片唯一id
                dataUrl: '', // 图片的base64编码
                imgKey: '', // 图片的key 后端上传保存使用
                imgUrl: '', // 图片真实路径 后端返回的
                name: item.name, // 图片的名字
                status: 1 // status表示这张图片的状态 1:上传中,2上传成功,3:上传失败
            });
        });

        // 有错误跳出
        if(imgPass.typeError){
            Toast.error('不支持文件类型', 2000, undefined, false);
            return;
        }

        if(imgPass.sizeError){
            Toast.error('文件大小超过限制', 2000, undefined, false);
            return;
        }

        // 没错误准备上传
        // 页面先显示一共上传图片个数
        this.setState({imgArray: imgArray.concat(uploadedImgArray)});

        // 通过该函数获取每次要上传的数组
        this.uploadGen = this.uploadGenerator(uploadQueue);
        // 第一次要上传的数量
        const firstUpload = this.uploadGen.next();


        // 真正开始上传流程
        firstUpload.value.map((item)=>{
            /**
             * 图片上传分成5步
             * 图片转dataUrl
             * 压缩
             * 处理数据格式
             * 准备数据上传
             * 上传
             *
             * 前两步是回调的形式 后面是同步的形式
             */
            this.transformFileToDataUrl(item, this.compress, this.processData);
        });
    }
    *uploadGenerator (uploadQueue) {
        /**
         * 多张图片并发上传控制规则
         * 上传1-max数量的图片
         * 设置一个最大上传数量
         * 保证最大只有这个数量的上传请求
         *
         */

        // 最多只有三个请求在上传
        const maxUploadSize = 3;

        if(uploadQueue.length > maxUploadSize){

            const result = [];

            for(let i = 0; i < uploadQueue.length; i++){
                // 第一次return maxUploadSize数量的图片
                if(i < maxUploadSize){
                    result.push(uploadQueue[i]);

                    if(i === maxUploadSize - 1){
                        yield result;
                    }
                } else {
                    yield [uploadQueue[i]];
                }
            }

        } else {
            yield uploadQueue.map((item)=>(item));
        }
    }
    transformFileToDataUrl (data, callback, compressCallback) {
        /**
         * 图片上传流程的第一步
         * @param data file文件 该数据会一直向下传递
         * @param callback 下一步回调
         * @param compressCallback 回调的回调
         */
        const {compress} = this.props;
        const imgCompassMaxSize = 200 * 1024; // 超过 200k 就压缩

        // 封装好的函数
        const reader = new FileReader();

        // ⚠️ 这是个回调过程 不是同步的
        reader.onload = function(e) {
            const result = e.target.result;
            data.dataUrl = result;

            if(compress && result.length > imgCompassMaxSize){
                data.compress = true;

                callback(data, compressCallback); // 图片压缩
            } else {
                data.compress = false;

                callback(data, compressCallback); // 图片不压缩
            }
        };

        reader.readAsDataURL(data.file);
    }
    compress (data, callback) {
        /**
         * 压缩图片
         * @param data file文件 数据会一直向下传递
         * @param callback 下一步回调
         */
        const {compressionRatio} = this.props;
        const imgFile = data.file;
        const img = new window.Image();

        img.src = data.dataUrl;

        img.onload = function () {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            canvas.width = img.width;
            canvas.height = img.height;

            ctx.drawImage(img, 0, 0, canvas.width, canvas.height);

            let compressedDataUrl;

            if(data.compress){
                compressedDataUrl = canvas.toDataURL(imgFile.type, (compressionRatio / 100));
            } else {
                compressedDataUrl = canvas.toDataURL(imgFile.type, 1);
            }

            data.compressedDataUrl = compressedDataUrl;

            callback(data);
        }
    }
    processData (data) {
        // 为了兼容性 处理数据
        const dataURL = data.compressedDataUrl;
        const imgFile = data.file;
        const binaryString = window.atob(dataURL.split(',')[1]);
        const arrayBuffer = new ArrayBuffer(binaryString.length);
        const intArray = new Uint8Array(arrayBuffer);

        for (let i = 0, j = binaryString.length; i < j; i++) {
            intArray[i] = binaryString.charCodeAt(i);
        }

        const fileData = [intArray];

        let blob;

        try {
            blob = new Blob(fileData, { type: imgFile.type });
        } catch (error) {
            window.BlobBuilder = window.BlobBuilder ||
                window.WebKitBlobBuilder ||
                window.MozBlobBuilder ||
                window.MSBlobBuilder;
            if (error.name === 'TypeError' && window.BlobBuilder){
                const builder = new BlobBuilder();
                builder.append(arrayBuffer);
                blob = builder.getBlob(imgFile.type);
            } else {
                throw new Error('版本过低,不支持上传图片');
            }
        }

        data.blob = blob;
        this.processFormData(data);
    }
    processFormData (data) {
        // 准备上传数据
        const formData = new FormData();
        const imgFile = data.file;
        const blob = data.blob;

        // type
        formData.append('type', blob.type);
        // size
        formData.append('size', blob.size);
        // append 文件
        formData.append('file', blob, imgFile.name);

        this.uploadImg(data, formData);
    }
    uploadImg (data, formData) {
        // 开始发送请求上传
        const _this = this;
        const xhr = new XMLHttpRequest();
        const {uploadUrl} = this.props;

        // 进度监听
        xhr.upload.addEventListener('progress', _this.handleProgress.bind(_this, data.uuid), false);

        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4) {
                if (xhr.status === 200 || xhr.status === 201) {
                    // 上传成功
                    _this.handleUploadEnd(data, 2);
                } else {
                    // 上传失败
                    _this.handleUploadEnd(data, 3);
                }
            }
        };

        xhr.open('POST', uploadUrl , true);
        xhr.send(formData);
    }
    getImagesListDOM () {
        // 处理显示图片的DOM
        const {max} = this.props;
        const _this = this;
        const result = [];
        const uploadingArray = [];
        const imgArray = this.state.imgArray;

        imgArray.map((item)=>{
            result.push(
                <Figure key={item.id} {...item} onDelete={_this.handleDelete.bind(_this)} />
            );

            // 正在上传的图片
            if(item.status === 1){
                uploadingArray.push(item);
            }
        });

        // 图片数量达到最大值
        if(result.length >= max ) return result;

        let onPress = ()=>{_this.refs.input.click();};

        //  或者有正在上传的图片的时候 不可再上传图片
        if(uploadingArray.length > 0) {
            onPress = undefined;
        }

        // 简单的显示文案逻辑判断
        let text = '上传图片';

        if(uploadingArray.length > 0){
            text = (imgArray.length - uploadingArray.length) + '/' + imgArray.length;
        }

        result.push(
            <Touchable
                key="add"
                activeClassName={'zby-upload-img-active'}
                onPress={onPress}
            >
                <div className="zby-upload-img">
                    <span key="icon" className="fa fa-camera" />
                    <p className="text">{text}</p>
                </div>
            </Touchable>
        );

        return result;
    }
    render () {
        const imagesList = this.getImagesListDOM();
            
        return (
            <div className="zby-uploader-box">
                {imagesList}
                <input ref="input" type="file" className="file-input" name="image" accept="image/*" multiple="multiple" onChange={this.handleInputChange} />
            </div>
        )
    }
}

Uploader.propTypes = {
    uploadUrl: React.PropTypes.string.isRequired, // 图上传路径
    compress: React.PropTypes.bool, // 是否进行图片压缩
    compressionRatio: React.PropTypes.number, // 图片压缩比例 单位:%
    data: React.PropTypes.array, // 初始化数据 其中的每个元素必须是标准化数据格式
    max: React.PropTypes.number, // 最大上传图片数
    maxSize: React.PropTypes.number, // 图片最大体积 单位:KB
    typeArray: React.PropTypes.array, // 支持图片类型数组
};

Uploader.defaultProps = {
    compress: true,
    compressionRatio: 20,
    data: [],
    max: 9,
    maxSize: 5 * 1024, // 5MB
    typeArray: ['jpeg', 'jpg', 'png', 'gif'],
};

export default Uploader

配合Figure组件使用达到文章开头的效果。
源码在github

总结

使用1-2天时间研究如何实现原生上传图片,这样明白原理之后,上传再也不用借助插件了,
再也不怕PM提出什么奇葩需求了。
同时,也认识了一些陌生的函数。。

参考资料

  1. 移动端图片上传的实践
  2. 移动端H5图片上传的那些坑
  3. 文件上传那些事儿
  4. 如何给一个Blob上传的FormData一个文件名?
  5. 移动端H5图片压缩
查看原文

赞 185 收藏 217 评论 40

wuwhs 评论了文章 · 2019-05-16

可能这些是你想要的H5软键盘兼容方案

image

前言

最近一段时间在做 H5 聊天项目,踩过其中一大坑:输入框获取焦点,软键盘弹起,要求输入框吸附(或顶)在输入法框上。需求很明确,看似很简单,其实不然。从实验过一些机型上看,发现主要存在以下问题:

  • AndroidIOS 上,获知软键盘弹起和收起状态存在差异,且页面 webview 表现不同。
  • IOS12 上,微信版本 v6.7.4 及以上,输入框获取焦点,键盘弹起,页面(webview)整体往上滚动,当键盘收起后,不回到原位,导致键盘原来所在位置是空白的。
  • IOS 上,使用第三方输入法,高度计算存在偏差,导致在有些输入法弹起,将输入框挡住一部分。
  • 在有些浏览器上使用一些操作技巧,还是存在输入框被输入法遮挡。

下面就上述发现的问题,逐个探索一下解决方案。

获知软键盘弹起和收起状态

获知软键盘的弹起还是收起状态很重要,后面的兼容处理都要以此为前提。然而,H5 并没有直接监听软键盘的原生事件,只能通过软键盘弹起或收起,引发页面其他方面的表现间接监听,曲线救国。并且,在 IOSAndroid 上的表现不尽相同。

IOS 软键盘弹起表现

IOS 上,输入框(inputtextarea 或 富文本)获取焦点,键盘弹起,页面(webview)并没有被压缩,或者说高度(height)没有改变,只是页面(webview)整体往上滚了,且最大滚动高度(scrollTop)为软键盘高度。

Android 软键盘弹起表现

同样,在 Android 上,输入框获取焦点,键盘弹起,但是页面(webview)高度会发生改变,一般来说,高度为可视区高度(原高度减去软键盘高度),除了因为页面内容被撑开可以产生滚动,webview 本身不能滚动。

IOS 软键盘收起表现

触发软键盘上的“收起”按钮键盘或者输入框以外的页面区域时,输入框失去焦点,软键盘收起。

Android 软键盘收起表现

触发输入框以外的区域时,输入框失去焦点,软键盘收起。但是,触发键盘上的收起按钮键盘时,输入框并不会失去焦点,同样软键盘收起。

软键盘弹起,IOS 和 Android 的 webview 不同表现

监听软键盘弹起和收起

综合上面键盘弹起和收起在 IOSAndroid 上的不同表现,我们可以分开进行如下处理来监听软键盘的弹起和收起:

  • IOS 上,监听输入框的 focus 事件来获知软键盘弹起,监听输入框的 blur 事件获知软键盘收起。
  • Android 上,监听 webview 高度会变化,高度变小获知软键盘弹起,否则软键盘收起。
// 判断设备类型
var judgeDeviceType = function () {
  var ua = window.navigator.userAgent.toLocaleLowerCase();
  var isIOS = /iphone|ipad|ipod/.test(ua);
  var isAndroid = /android/.test(ua);

  return {
    isIOS: isIOS,
    isAndroid: isAndroid
  }
}()

// 监听输入框的软键盘弹起和收起事件
function listenKeybord($input) {
  if (judgeDeviceType.isIOS) {
    // IOS 键盘弹起:IOS 和 Android 输入框获取焦点键盘弹起
    $input.addEventListener('focus', function () {
      console.log('IOS 键盘弹起啦!');
      // IOS 键盘弹起后操作
    }, false)

    // IOS 键盘收起:IOS 点击输入框以外区域或点击收起按钮,输入框都会失去焦点,键盘会收起,
    $input.addEventListener('blur', () => {
      console.log('IOS 键盘收起啦!');
      // IOS 键盘收起后操作
    })
  }

  // Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
  if (judgeDeviceType.isAndroid) {
    var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

    window.addEventListener('resize', function () {
      var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
      if (originHeight < resizeHeight) {
        console.log('Android 键盘收起啦!');
        // Android 键盘收起后操作
      } else {
        console.log('Android 键盘弹起啦!');
        // Android 键盘弹起后操作
      }

      originHeight = resizeHeight;
    }, false)
  }
}

var $inputs = document.querySelectorAll('.input');

for (var i = 0; i < $inputs.length; i++) {
  listenKeybord($inputs[i]);
}

弹起软键盘始终让输入框滚动到可视区

有时我们会做一个输入表单,有很多输入项,输入框获取焦点,弹起软键盘。当输入框位于页面下部位置时,在 IOS 上,会将 webview 整体往上滚一段距离,使得该获取焦点的输入框自动处于可视区,而在 Android 则不会这样,它只会改变页面高度,而不会去滚动到当前焦点元素到可视区。
由于上面已经实现监听 IOSAndroid 键盘弹起和收起,在这里,只需在 Android 键盘弹起后,将焦点元素滚动(scrollIntoView())到可视区。查看效果,可以戳这里

// 获取到焦点元素滚动到可视区
function activeElementScrollIntoView(activeElement, delay) {
  var editable = activeElement.getAttribute('contenteditable')

  // 输入框、textarea或富文本获取焦点后没有将该元素滚动到可视区
  if (activeElement.tagName == 'INPUT' || activeElement.tagName == 'TEXTAREA' || editable === '' || editable) {
    setTimeout(function () {
      activeElement.scrollIntoView();
    }, delay)
  }
}

// ...
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);
// ...

唤起纯数字软键盘

上面的表单输入框有要求输入电话号码,类似这样就要弹出一个数字软键盘了,既然说到了软键盘兼容,在这里就安插一下。比较好的解决方案如下:

<p>请输入手机号</p>
<input type="tel" novalidate="novalidate" pattern="[0-9]*" class="input">
  • type="tel", 是 HTML5 的一个属性,表示输入框类型为电话号码,在 AndroidIOS 上表现差不多,都会有数字键盘,但是也会有字母,略显多余。
  • pattern="[0-9]"pattern 用于验证表单输入的内容,通常 HTML5type 属性,比如 emailtelnumberdata 类、url 等,已经自带了简单的数据格式验证功能了,加上 pattern 后,前端部分的验证更加简单高效了。IOS 中,只有 [0-9]\* 才可以调起九宫格数字键盘,\d 无效,Android 4.4 以下(包括X5内核),两者都调起数字键盘。
  • novalidate="novalidate"novalidate 属性规定当提交表单时不对其进行验证,由于 pattern 校验兼容性不好,可以不让其校验,只让其唤起纯数字键盘,校验工作由 js 去做。

软键盘弹起,IOS 和 Android 的 webview 不同表现

兼容 IOS12 + V6.7.4+

如果你在用 IOS12V6.7.4+版本的微信浏览器打开上面表单输入的 demo ,就会惊奇的发现键盘收起后,原本被滚动顶起的页面并没有回到底部位置,导致原来键盘弹起的位置“空”了。

兼容 codeIOS12/code + codeV6.7.4+/code

其实这是 AppleIOSbug,会出现在所有的 Xcode10 打包的 IOS12 的设备上。微信官方已给出解决方案,只需在软键盘收起后,将页面(webview)滚回到窗口最底部位置(clientHeight位置)。修复后的上面表单输入 demo 可以戳这里

console.log('IOS 键盘收起啦!');
// IOS 键盘收起后操作
// 微信浏览器版本6.7.4+IOS12会出现键盘收起后,视图被顶上去了没有下来
var wechatInfo = window.navigator.userAgent.match(/MicroMessenger\/([\d\.]+)/i);
if (!wechatInfo) return;

var wechatVersion = wechatInfo[1];
var version = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/);

if (+wechatVersion.replace(/\./g, '') >= 674 && +version[1] >= 12) {
  setTimeout(function () {
    window.scrollTo(0, Math.max(document.body.clientHeight, document.documentElement.clientHeight));
  })
}

兼容第三方输入法

上面说了那么多,其实已经把 H5 聊天输入框的坑填了一大半了,接下来就先看下聊天输入框的基本HTML结构

<div class="chat__content">
  <div>
    <p>一些聊天内容1</p>
  </div>
  <!-- 省略几千行聊天内容 -->
</div>
<div class="input__content">
  <div class="input" contenteditable="true"></div>
  <button>发送</button>
</div>

样式

/* 省略一些样式 */
.chat__content {
  height: calc(100% - 40px);
  margin-bottom: 40px;
  overflow-y: auto;
  overflow-x: hidden;
}

.input__content {
  display: flex;
  height: 40px;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  align-items: center;
}
/* 省略一些样式 */

很简单,就是划分内容区和输入区,输入区是绝对定位,按照上面表单输入 demo 的做法,确实大部分 Android 浏览器是没问题的,但是测试在 IOS 上,UC 浏览器配合原生输入法和第三方输入法(比如搜狗输入法),输入框都会被完全挡住;QQ 浏览器或微信浏览器,配合第三方输入法,输入框会被遮住一半;百度浏览器配合第三方输入法输入框也会被完全遮住。查看效果可以用相应浏览器中访问这里

keyboard-chat-input.png

UC 浏览器上,软键盘弹起后,浏览器上面的标题栏高度就有个高度变小延时动态效果,这样导致 webview 往下滚了一点,底部输入框滚到了非可视区。
而对于第三方输入法,猜测本身是由于输入法面板弹起后高度计算有误,导致 webview 初始滚动定位有误。其实这两点都是 webview 滚动不到位造成的。可以让软键盘弹起后,让焦点元素再次滚到可视区,强迫 webview 滚到位。

console.log('Android 键盘弹起啦!');
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);

兼容 Android 小米浏览器的 Hack 方案

Android 的小米浏览器上,应用上面的方案,发现聊天输入框还是被遮挡得严严实实,scrollIntoView() 仍然纹丝不动。所以猜测,其实是滚到底了,软键盘弹起,页面实现高度大于可视区高度,这样只能在软键盘弹起后,强行增加页面高度,使输入框可以显示出来。综合上面兼容第三方输入法,查看效果可以戳这里

// Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
if (judgeDeviceType.isAndroid) {
  var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

  window.addEventListener('resize', function () {
    var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
    if (originHeight < resizeHeight) {
      console.log('Android 键盘收起啦!');
      // Android 键盘收起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '0px';
      }
    } else {
      console.log('Android 键盘弹起啦!');
      // Android 键盘弹起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '40px';
      }
      activeElementScrollIntoView($input, 1000);
    }

    originHeight = resizeHeight;
  }, false)
}

总结

H5 端前路漫漫,坑很多,需要不断尝试。了解软键盘弹起页面在 IOSAndroid 上的表现差异是前提,其次是将焦点元素滚动到可视区,同时要考虑到第三方输入法和某些浏览器上的差别。总结肯定不全面,欢迎大家指正哈,完~

查看原文

wuwhs 评论了文章 · 2019-05-16

可能这些是你想要的H5软键盘兼容方案

image

前言

最近一段时间在做 H5 聊天项目,踩过其中一大坑:输入框获取焦点,软键盘弹起,要求输入框吸附(或顶)在输入法框上。需求很明确,看似很简单,其实不然。从实验过一些机型上看,发现主要存在以下问题:

  • AndroidIOS 上,获知软键盘弹起和收起状态存在差异,且页面 webview 表现不同。
  • IOS12 上,微信版本 v6.7.4 及以上,输入框获取焦点,键盘弹起,页面(webview)整体往上滚动,当键盘收起后,不回到原位,导致键盘原来所在位置是空白的。
  • IOS 上,使用第三方输入法,高度计算存在偏差,导致在有些输入法弹起,将输入框挡住一部分。
  • 在有些浏览器上使用一些操作技巧,还是存在输入框被输入法遮挡。

下面就上述发现的问题,逐个探索一下解决方案。

获知软键盘弹起和收起状态

获知软键盘的弹起还是收起状态很重要,后面的兼容处理都要以此为前提。然而,H5 并没有直接监听软键盘的原生事件,只能通过软键盘弹起或收起,引发页面其他方面的表现间接监听,曲线救国。并且,在 IOSAndroid 上的表现不尽相同。

IOS 软键盘弹起表现

IOS 上,输入框(inputtextarea 或 富文本)获取焦点,键盘弹起,页面(webview)并没有被压缩,或者说高度(height)没有改变,只是页面(webview)整体往上滚了,且最大滚动高度(scrollTop)为软键盘高度。

Android 软键盘弹起表现

同样,在 Android 上,输入框获取焦点,键盘弹起,但是页面(webview)高度会发生改变,一般来说,高度为可视区高度(原高度减去软键盘高度),除了因为页面内容被撑开可以产生滚动,webview 本身不能滚动。

IOS 软键盘收起表现

触发软键盘上的“收起”按钮键盘或者输入框以外的页面区域时,输入框失去焦点,软键盘收起。

Android 软键盘收起表现

触发输入框以外的区域时,输入框失去焦点,软键盘收起。但是,触发键盘上的收起按钮键盘时,输入框并不会失去焦点,同样软键盘收起。

软键盘弹起,IOS 和 Android 的 webview 不同表现

监听软键盘弹起和收起

综合上面键盘弹起和收起在 IOSAndroid 上的不同表现,我们可以分开进行如下处理来监听软键盘的弹起和收起:

  • IOS 上,监听输入框的 focus 事件来获知软键盘弹起,监听输入框的 blur 事件获知软键盘收起。
  • Android 上,监听 webview 高度会变化,高度变小获知软键盘弹起,否则软键盘收起。
// 判断设备类型
var judgeDeviceType = function () {
  var ua = window.navigator.userAgent.toLocaleLowerCase();
  var isIOS = /iphone|ipad|ipod/.test(ua);
  var isAndroid = /android/.test(ua);

  return {
    isIOS: isIOS,
    isAndroid: isAndroid
  }
}()

// 监听输入框的软键盘弹起和收起事件
function listenKeybord($input) {
  if (judgeDeviceType.isIOS) {
    // IOS 键盘弹起:IOS 和 Android 输入框获取焦点键盘弹起
    $input.addEventListener('focus', function () {
      console.log('IOS 键盘弹起啦!');
      // IOS 键盘弹起后操作
    }, false)

    // IOS 键盘收起:IOS 点击输入框以外区域或点击收起按钮,输入框都会失去焦点,键盘会收起,
    $input.addEventListener('blur', () => {
      console.log('IOS 键盘收起啦!');
      // IOS 键盘收起后操作
    })
  }

  // Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
  if (judgeDeviceType.isAndroid) {
    var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

    window.addEventListener('resize', function () {
      var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
      if (originHeight < resizeHeight) {
        console.log('Android 键盘收起啦!');
        // Android 键盘收起后操作
      } else {
        console.log('Android 键盘弹起啦!');
        // Android 键盘弹起后操作
      }

      originHeight = resizeHeight;
    }, false)
  }
}

var $inputs = document.querySelectorAll('.input');

for (var i = 0; i < $inputs.length; i++) {
  listenKeybord($inputs[i]);
}

弹起软键盘始终让输入框滚动到可视区

有时我们会做一个输入表单,有很多输入项,输入框获取焦点,弹起软键盘。当输入框位于页面下部位置时,在 IOS 上,会将 webview 整体往上滚一段距离,使得该获取焦点的输入框自动处于可视区,而在 Android 则不会这样,它只会改变页面高度,而不会去滚动到当前焦点元素到可视区。
由于上面已经实现监听 IOSAndroid 键盘弹起和收起,在这里,只需在 Android 键盘弹起后,将焦点元素滚动(scrollIntoView())到可视区。查看效果,可以戳这里

// 获取到焦点元素滚动到可视区
function activeElementScrollIntoView(activeElement, delay) {
  var editable = activeElement.getAttribute('contenteditable')

  // 输入框、textarea或富文本获取焦点后没有将该元素滚动到可视区
  if (activeElement.tagName == 'INPUT' || activeElement.tagName == 'TEXTAREA' || editable === '' || editable) {
    setTimeout(function () {
      activeElement.scrollIntoView();
    }, delay)
  }
}

// ...
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);
// ...

唤起纯数字软键盘

上面的表单输入框有要求输入电话号码,类似这样就要弹出一个数字软键盘了,既然说到了软键盘兼容,在这里就安插一下。比较好的解决方案如下:

<p>请输入手机号</p>
<input type="tel" novalidate="novalidate" pattern="[0-9]*" class="input">
  • type="tel", 是 HTML5 的一个属性,表示输入框类型为电话号码,在 AndroidIOS 上表现差不多,都会有数字键盘,但是也会有字母,略显多余。
  • pattern="[0-9]"pattern 用于验证表单输入的内容,通常 HTML5type 属性,比如 emailtelnumberdata 类、url 等,已经自带了简单的数据格式验证功能了,加上 pattern 后,前端部分的验证更加简单高效了。IOS 中,只有 [0-9]\* 才可以调起九宫格数字键盘,\d 无效,Android 4.4 以下(包括X5内核),两者都调起数字键盘。
  • novalidate="novalidate"novalidate 属性规定当提交表单时不对其进行验证,由于 pattern 校验兼容性不好,可以不让其校验,只让其唤起纯数字键盘,校验工作由 js 去做。

软键盘弹起,IOS 和 Android 的 webview 不同表现

兼容 IOS12 + V6.7.4+

如果你在用 IOS12V6.7.4+版本的微信浏览器打开上面表单输入的 demo ,就会惊奇的发现键盘收起后,原本被滚动顶起的页面并没有回到底部位置,导致原来键盘弹起的位置“空”了。

兼容 codeIOS12/code + codeV6.7.4+/code

其实这是 AppleIOSbug,会出现在所有的 Xcode10 打包的 IOS12 的设备上。微信官方已给出解决方案,只需在软键盘收起后,将页面(webview)滚回到窗口最底部位置(clientHeight位置)。修复后的上面表单输入 demo 可以戳这里

console.log('IOS 键盘收起啦!');
// IOS 键盘收起后操作
// 微信浏览器版本6.7.4+IOS12会出现键盘收起后,视图被顶上去了没有下来
var wechatInfo = window.navigator.userAgent.match(/MicroMessenger\/([\d\.]+)/i);
if (!wechatInfo) return;

var wechatVersion = wechatInfo[1];
var version = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/);

if (+wechatVersion.replace(/\./g, '') >= 674 && +version[1] >= 12) {
  setTimeout(function () {
    window.scrollTo(0, Math.max(document.body.clientHeight, document.documentElement.clientHeight));
  })
}

兼容第三方输入法

上面说了那么多,其实已经把 H5 聊天输入框的坑填了一大半了,接下来就先看下聊天输入框的基本HTML结构

<div class="chat__content">
  <div>
    <p>一些聊天内容1</p>
  </div>
  <!-- 省略几千行聊天内容 -->
</div>
<div class="input__content">
  <div class="input" contenteditable="true"></div>
  <button>发送</button>
</div>

样式

/* 省略一些样式 */
.chat__content {
  height: calc(100% - 40px);
  margin-bottom: 40px;
  overflow-y: auto;
  overflow-x: hidden;
}

.input__content {
  display: flex;
  height: 40px;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  align-items: center;
}
/* 省略一些样式 */

很简单,就是划分内容区和输入区,输入区是绝对定位,按照上面表单输入 demo 的做法,确实大部分 Android 浏览器是没问题的,但是测试在 IOS 上,UC 浏览器配合原生输入法和第三方输入法(比如搜狗输入法),输入框都会被完全挡住;QQ 浏览器或微信浏览器,配合第三方输入法,输入框会被遮住一半;百度浏览器配合第三方输入法输入框也会被完全遮住。查看效果可以用相应浏览器中访问这里

keyboard-chat-input.png

UC 浏览器上,软键盘弹起后,浏览器上面的标题栏高度就有个高度变小延时动态效果,这样导致 webview 往下滚了一点,底部输入框滚到了非可视区。
而对于第三方输入法,猜测本身是由于输入法面板弹起后高度计算有误,导致 webview 初始滚动定位有误。其实这两点都是 webview 滚动不到位造成的。可以让软键盘弹起后,让焦点元素再次滚到可视区,强迫 webview 滚到位。

console.log('Android 键盘弹起啦!');
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);

兼容 Android 小米浏览器的 Hack 方案

Android 的小米浏览器上,应用上面的方案,发现聊天输入框还是被遮挡得严严实实,scrollIntoView() 仍然纹丝不动。所以猜测,其实是滚到底了,软键盘弹起,页面实现高度大于可视区高度,这样只能在软键盘弹起后,强行增加页面高度,使输入框可以显示出来。综合上面兼容第三方输入法,查看效果可以戳这里

// Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
if (judgeDeviceType.isAndroid) {
  var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

  window.addEventListener('resize', function () {
    var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
    if (originHeight < resizeHeight) {
      console.log('Android 键盘收起啦!');
      // Android 键盘收起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '0px';
      }
    } else {
      console.log('Android 键盘弹起啦!');
      // Android 键盘弹起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '40px';
      }
      activeElementScrollIntoView($input, 1000);
    }

    originHeight = resizeHeight;
  }, false)
}

总结

H5 端前路漫漫,坑很多,需要不断尝试。了解软键盘弹起页面在 IOSAndroid 上的表现差异是前提,其次是将焦点元素滚动到可视区,同时要考虑到第三方输入法和某些浏览器上的差别。总结肯定不全面,欢迎大家指正哈,完~

查看原文

wuwhs 评论了文章 · 2019-05-06

element-ui更换主题和按需引入组件

前言

手上有些项目用的element-ui,刚好有空琢磨一下怎么减小打包文件大小和打包速度方面,为了演示实验,用vue-cli生成初始项目,在这仅对element-ui主题和组件方面来优化。

vue init webpack vuecli

完整引入

完整地将ui和样式引入。

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

在页面简单使用三个组件,看看效果。

<div class="block">
  <span class="demonstration">区分颜色</span>
  <el-rate v-model="value1" :colors="['#99A9BF', '#F7BA2A', '#FF9900']">
  </el-rate>
</div>
<el-button type="primary" icon="el-icon-edit"></el-button>
<el-row>
  <el-button icon="el-icon-search" circle></el-button>
  <el-button type="primary" icon="el-icon-edit" circle></el-button>
  <el-button type="success" icon="el-icon-check" circle></el-button>
  <el-button type="info" icon="el-icon-message" circle></el-button>
  <el-button type="warning" icon="el-icon-star-off" circle></el-button>
  <el-button type="danger" icon="el-icon-delete" circle></el-button>
</el-row>

再看一下打包后的资源大小情况npm run build --report

Hash: fa47514a97341329a7c0
Version: webpack 3.11.0
Time: 20363ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.5efcf828140d5dbedda9.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.a4a31db472f653b911e7.js      12 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css     185 kB       1  [emitted]         app
static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css.map     267 kB          [emitted]
           static/js/vendor.5efcf828140d5dbedda9.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.a4a31db472f653b911e7.js.map    22.8 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

发现打包后提取公共模块static/js/vendor.js有714kb

再看一下各个模块占用情况:

发现elment-ui.common.js占用最大。所有模块资源总共有708kb。怎么才能减小打包后的大小呢?很容易就会想到ui的引入和样式的引入中,实际我们只使用了三个组件,却整体都被打包了,在这里引入这三个组件即可。

按需引入组件样式

新建一个element-variables.scss文件(为什么是SCSS文件,后面自定义主题会用到)。

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

/*按需引入用到的组件的scss文件和基础scss文件*/
@import "~element-ui/packages/theme-chalk/src/base.scss";
@import "~element-ui/packages/theme-chalk/src/rate.scss";
@import "~element-ui/packages/theme-chalk/src/button.scss";
@import "~element-ui/packages/theme-chalk/src/row.scss";

按需引入组件

新建一个elementConfig.js文件,将项目用到的element组件引入。

import {
  Rate,
  Row,
  Button
} from 'element-ui'

export default {
  install (V) {
    V.use(Rate)
    V.use(Button)
    V.use(Row)
  }
}

第一次优化后打包分析

将以上element-variables.scsselementConfig.js引入到main.js中。

import ElementUI from '@/assets/js/elementConfig'
import '@/assets/css/element-variables.scss'

Vue.use(ElementUI)

貌似上面一切都很顺理成章,打包后大小会减小。

Hash: 3ba9b74482f121efd3aa
Version: webpack 3.11.0
Time: 18854ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.11c71f168a2d61b547a0.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.dbb5b49dad2d42b3598c.js    11.2 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.bf52525d6279e7fb87b4db770d119a8d.css    25.7 kB       1  [emitted]         app
static/css/app.bf52525d6279e7fb87b4db770d119a8d.css.map      63 kB          [emitted]
           static/js/vendor.11c71f168a2d61b547a0.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.dbb5b49dad2d42b3598c.js.map      21 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

结果可知,static/js/vendor.js还是714kb!

再看各个模块占用情况:

WHAT?竟然模块都没什么变化,岂不是竹篮打水,事与愿违。

再次打包优化尝试

后来查到有人同样遇到这个问题,提出一个issues#6362,原来只引入需要的element-ui组件,webpack还是把整体的ui库和样式都打包了,需要一个webpackbabel插件babel-plugin-component,这样才能真正按需引入打包。这块其实被写到官方文档更换 自定义主题 的配置了。

于是npm i babel-plugin-component -D安装后,在增加.babelrc文件插件配置

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": ["transform-vue-jsx", "transform-runtime", ["component", {
    "libraryName": "element-ui",
    "styleLibraryName": "theme-chalk"
  }]]
}

页面运行正常,再次打包。

Hash: 9cc71dead6d7646c9ed4
Version: webpack 3.11.0
Time: 9963ms
                                                  Asset       Size  Chunks             Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
                  static/js/app.77c0883f4f0fc0bf5cbc.js    11.4 kB       0  [emitted]  app
               static/js/vendor.942130fd13274b901889.js     126 kB       1  [emitted]  vendor
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]  manifest
    static/css/app.b140020e5dbee406ae70780b43ba7ddc.css    27.8 kB       0  [emitted]  app
static/css/app.b140020e5dbee406ae70780b43ba7ddc.css.map    91.4 kB          [emitted]
              static/js/app.77c0883f4f0fc0bf5cbc.js.map    21.1 kB       0  [emitted]  app
           static/js/vendor.942130fd13274b901889.js.map     613 kB       1  [emitted]  vendor
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]  manifest
                                             index.html  508 bytes          [emitted]

static/js/vendor.js确实变小了,126kB。再来看各个模块分析图。

模块总共135.03KB,少了5倍!

更换主题

element-uitheme-chalk使用SCSS编写,如果在自己的项目中也是用SCSS,那么可以直接在项目中改变样式变量,因此可以在前面新建的element-variables.scss文件用新的主题颜色变量覆盖即可。

/*主题颜色变量*/
$--color-primary: #f0f;

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

可能你已经注意到了,这里没有分别引入用到的组件样式了,是因为babel-plugin-component帮我们按需引入了对应的样式。

现在我们的主题就变成了

如果你没有用到SCSS,可以用element-theme主题编译插件,生成自定义主题文件引入。

完~ps:个人见解有限,欢迎指正。

查看原文

wuwhs 评论了文章 · 2019-05-06

element-ui更换主题和按需引入组件

前言

手上有些项目用的element-ui,刚好有空琢磨一下怎么减小打包文件大小和打包速度方面,为了演示实验,用vue-cli生成初始项目,在这仅对element-ui主题和组件方面来优化。

vue init webpack vuecli

完整引入

完整地将ui和样式引入。

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

在页面简单使用三个组件,看看效果。

<div class="block">
  <span class="demonstration">区分颜色</span>
  <el-rate v-model="value1" :colors="['#99A9BF', '#F7BA2A', '#FF9900']">
  </el-rate>
</div>
<el-button type="primary" icon="el-icon-edit"></el-button>
<el-row>
  <el-button icon="el-icon-search" circle></el-button>
  <el-button type="primary" icon="el-icon-edit" circle></el-button>
  <el-button type="success" icon="el-icon-check" circle></el-button>
  <el-button type="info" icon="el-icon-message" circle></el-button>
  <el-button type="warning" icon="el-icon-star-off" circle></el-button>
  <el-button type="danger" icon="el-icon-delete" circle></el-button>
</el-row>

再看一下打包后的资源大小情况npm run build --report

Hash: fa47514a97341329a7c0
Version: webpack 3.11.0
Time: 20363ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.5efcf828140d5dbedda9.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.a4a31db472f653b911e7.js      12 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css     185 kB       1  [emitted]         app
static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css.map     267 kB          [emitted]
           static/js/vendor.5efcf828140d5dbedda9.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.a4a31db472f653b911e7.js.map    22.8 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

发现打包后提取公共模块static/js/vendor.js有714kb

再看一下各个模块占用情况:

发现elment-ui.common.js占用最大。所有模块资源总共有708kb。怎么才能减小打包后的大小呢?很容易就会想到ui的引入和样式的引入中,实际我们只使用了三个组件,却整体都被打包了,在这里引入这三个组件即可。

按需引入组件样式

新建一个element-variables.scss文件(为什么是SCSS文件,后面自定义主题会用到)。

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

/*按需引入用到的组件的scss文件和基础scss文件*/
@import "~element-ui/packages/theme-chalk/src/base.scss";
@import "~element-ui/packages/theme-chalk/src/rate.scss";
@import "~element-ui/packages/theme-chalk/src/button.scss";
@import "~element-ui/packages/theme-chalk/src/row.scss";

按需引入组件

新建一个elementConfig.js文件,将项目用到的element组件引入。

import {
  Rate,
  Row,
  Button
} from 'element-ui'

export default {
  install (V) {
    V.use(Rate)
    V.use(Button)
    V.use(Row)
  }
}

第一次优化后打包分析

将以上element-variables.scsselementConfig.js引入到main.js中。

import ElementUI from '@/assets/js/elementConfig'
import '@/assets/css/element-variables.scss'

Vue.use(ElementUI)

貌似上面一切都很顺理成章,打包后大小会减小。

Hash: 3ba9b74482f121efd3aa
Version: webpack 3.11.0
Time: 18854ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.11c71f168a2d61b547a0.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.dbb5b49dad2d42b3598c.js    11.2 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.bf52525d6279e7fb87b4db770d119a8d.css    25.7 kB       1  [emitted]         app
static/css/app.bf52525d6279e7fb87b4db770d119a8d.css.map      63 kB          [emitted]
           static/js/vendor.11c71f168a2d61b547a0.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.dbb5b49dad2d42b3598c.js.map      21 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

结果可知,static/js/vendor.js还是714kb!

再看各个模块占用情况:

WHAT?竟然模块都没什么变化,岂不是竹篮打水,事与愿违。

再次打包优化尝试

后来查到有人同样遇到这个问题,提出一个issues#6362,原来只引入需要的element-ui组件,webpack还是把整体的ui库和样式都打包了,需要一个webpackbabel插件babel-plugin-component,这样才能真正按需引入打包。这块其实被写到官方文档更换 自定义主题 的配置了。

于是npm i babel-plugin-component -D安装后,在增加.babelrc文件插件配置

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": ["transform-vue-jsx", "transform-runtime", ["component", {
    "libraryName": "element-ui",
    "styleLibraryName": "theme-chalk"
  }]]
}

页面运行正常,再次打包。

Hash: 9cc71dead6d7646c9ed4
Version: webpack 3.11.0
Time: 9963ms
                                                  Asset       Size  Chunks             Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
                  static/js/app.77c0883f4f0fc0bf5cbc.js    11.4 kB       0  [emitted]  app
               static/js/vendor.942130fd13274b901889.js     126 kB       1  [emitted]  vendor
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]  manifest
    static/css/app.b140020e5dbee406ae70780b43ba7ddc.css    27.8 kB       0  [emitted]  app
static/css/app.b140020e5dbee406ae70780b43ba7ddc.css.map    91.4 kB          [emitted]
              static/js/app.77c0883f4f0fc0bf5cbc.js.map    21.1 kB       0  [emitted]  app
           static/js/vendor.942130fd13274b901889.js.map     613 kB       1  [emitted]  vendor
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]  manifest
                                             index.html  508 bytes          [emitted]

static/js/vendor.js确实变小了,126kB。再来看各个模块分析图。

模块总共135.03KB,少了5倍!

更换主题

element-uitheme-chalk使用SCSS编写,如果在自己的项目中也是用SCSS,那么可以直接在项目中改变样式变量,因此可以在前面新建的element-variables.scss文件用新的主题颜色变量覆盖即可。

/*主题颜色变量*/
$--color-primary: #f0f;

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

可能你已经注意到了,这里没有分别引入用到的组件样式了,是因为babel-plugin-component帮我们按需引入了对应的样式。

现在我们的主题就变成了

如果你没有用到SCSS,可以用element-theme主题编译插件,生成自定义主题文件引入。

完~ps:个人见解有限,欢迎指正。

查看原文

wuwhs 评论了文章 · 2019-05-06

element-ui更换主题和按需引入组件

前言

手上有些项目用的element-ui,刚好有空琢磨一下怎么减小打包文件大小和打包速度方面,为了演示实验,用vue-cli生成初始项目,在这仅对element-ui主题和组件方面来优化。

vue init webpack vuecli

完整引入

完整地将ui和样式引入。

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

在页面简单使用三个组件,看看效果。

<div class="block">
  <span class="demonstration">区分颜色</span>
  <el-rate v-model="value1" :colors="['#99A9BF', '#F7BA2A', '#FF9900']">
  </el-rate>
</div>
<el-button type="primary" icon="el-icon-edit"></el-button>
<el-row>
  <el-button icon="el-icon-search" circle></el-button>
  <el-button type="primary" icon="el-icon-edit" circle></el-button>
  <el-button type="success" icon="el-icon-check" circle></el-button>
  <el-button type="info" icon="el-icon-message" circle></el-button>
  <el-button type="warning" icon="el-icon-star-off" circle></el-button>
  <el-button type="danger" icon="el-icon-delete" circle></el-button>
</el-row>

再看一下打包后的资源大小情况npm run build --report

Hash: fa47514a97341329a7c0
Version: webpack 3.11.0
Time: 20363ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.5efcf828140d5dbedda9.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.a4a31db472f653b911e7.js      12 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css     185 kB       1  [emitted]         app
static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css.map     267 kB          [emitted]
           static/js/vendor.5efcf828140d5dbedda9.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.a4a31db472f653b911e7.js.map    22.8 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

发现打包后提取公共模块static/js/vendor.js有714kb

再看一下各个模块占用情况:

发现elment-ui.common.js占用最大。所有模块资源总共有708kb。怎么才能减小打包后的大小呢?很容易就会想到ui的引入和样式的引入中,实际我们只使用了三个组件,却整体都被打包了,在这里引入这三个组件即可。

按需引入组件样式

新建一个element-variables.scss文件(为什么是SCSS文件,后面自定义主题会用到)。

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

/*按需引入用到的组件的scss文件和基础scss文件*/
@import "~element-ui/packages/theme-chalk/src/base.scss";
@import "~element-ui/packages/theme-chalk/src/rate.scss";
@import "~element-ui/packages/theme-chalk/src/button.scss";
@import "~element-ui/packages/theme-chalk/src/row.scss";

按需引入组件

新建一个elementConfig.js文件,将项目用到的element组件引入。

import {
  Rate,
  Row,
  Button
} from 'element-ui'

export default {
  install (V) {
    V.use(Rate)
    V.use(Button)
    V.use(Row)
  }
}

第一次优化后打包分析

将以上element-variables.scsselementConfig.js引入到main.js中。

import ElementUI from '@/assets/js/elementConfig'
import '@/assets/css/element-variables.scss'

Vue.use(ElementUI)

貌似上面一切都很顺理成章,打包后大小会减小。

Hash: 3ba9b74482f121efd3aa
Version: webpack 3.11.0
Time: 18854ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.11c71f168a2d61b547a0.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.dbb5b49dad2d42b3598c.js    11.2 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.bf52525d6279e7fb87b4db770d119a8d.css    25.7 kB       1  [emitted]         app
static/css/app.bf52525d6279e7fb87b4db770d119a8d.css.map      63 kB          [emitted]
           static/js/vendor.11c71f168a2d61b547a0.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.dbb5b49dad2d42b3598c.js.map      21 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

结果可知,static/js/vendor.js还是714kb!

再看各个模块占用情况:

WHAT?竟然模块都没什么变化,岂不是竹篮打水,事与愿违。

再次打包优化尝试

后来查到有人同样遇到这个问题,提出一个issues#6362,原来只引入需要的element-ui组件,webpack还是把整体的ui库和样式都打包了,需要一个webpackbabel插件babel-plugin-component,这样才能真正按需引入打包。这块其实被写到官方文档更换 自定义主题 的配置了。

于是npm i babel-plugin-component -D安装后,在增加.babelrc文件插件配置

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": ["transform-vue-jsx", "transform-runtime", ["component", {
    "libraryName": "element-ui",
    "styleLibraryName": "theme-chalk"
  }]]
}

页面运行正常,再次打包。

Hash: 9cc71dead6d7646c9ed4
Version: webpack 3.11.0
Time: 9963ms
                                                  Asset       Size  Chunks             Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
                  static/js/app.77c0883f4f0fc0bf5cbc.js    11.4 kB       0  [emitted]  app
               static/js/vendor.942130fd13274b901889.js     126 kB       1  [emitted]  vendor
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]  manifest
    static/css/app.b140020e5dbee406ae70780b43ba7ddc.css    27.8 kB       0  [emitted]  app
static/css/app.b140020e5dbee406ae70780b43ba7ddc.css.map    91.4 kB          [emitted]
              static/js/app.77c0883f4f0fc0bf5cbc.js.map    21.1 kB       0  [emitted]  app
           static/js/vendor.942130fd13274b901889.js.map     613 kB       1  [emitted]  vendor
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]  manifest
                                             index.html  508 bytes          [emitted]

static/js/vendor.js确实变小了,126kB。再来看各个模块分析图。

模块总共135.03KB,少了5倍!

更换主题

element-uitheme-chalk使用SCSS编写,如果在自己的项目中也是用SCSS,那么可以直接在项目中改变样式变量,因此可以在前面新建的element-variables.scss文件用新的主题颜色变量覆盖即可。

/*主题颜色变量*/
$--color-primary: #f0f;

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

可能你已经注意到了,这里没有分别引入用到的组件样式了,是因为babel-plugin-component帮我们按需引入了对应的样式。

现在我们的主题就变成了

如果你没有用到SCSS,可以用element-theme主题编译插件,生成自定义主题文件引入。

完~ps:个人见解有限,欢迎指正。

查看原文

wuwhs 评论了文章 · 2019-05-06

element-ui更换主题和按需引入组件

前言

手上有些项目用的element-ui,刚好有空琢磨一下怎么减小打包文件大小和打包速度方面,为了演示实验,用vue-cli生成初始项目,在这仅对element-ui主题和组件方面来优化。

vue init webpack vuecli

完整引入

完整地将ui和样式引入。

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

在页面简单使用三个组件,看看效果。

<div class="block">
  <span class="demonstration">区分颜色</span>
  <el-rate v-model="value1" :colors="['#99A9BF', '#F7BA2A', '#FF9900']">
  </el-rate>
</div>
<el-button type="primary" icon="el-icon-edit"></el-button>
<el-row>
  <el-button icon="el-icon-search" circle></el-button>
  <el-button type="primary" icon="el-icon-edit" circle></el-button>
  <el-button type="success" icon="el-icon-check" circle></el-button>
  <el-button type="info" icon="el-icon-message" circle></el-button>
  <el-button type="warning" icon="el-icon-star-off" circle></el-button>
  <el-button type="danger" icon="el-icon-delete" circle></el-button>
</el-row>

再看一下打包后的资源大小情况npm run build --report

Hash: fa47514a97341329a7c0
Version: webpack 3.11.0
Time: 20363ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.5efcf828140d5dbedda9.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.a4a31db472f653b911e7.js      12 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css     185 kB       1  [emitted]         app
static/css/app.f24bb0ae3686720fe2e00c5a2024b8f1.css.map     267 kB          [emitted]
           static/js/vendor.5efcf828140d5dbedda9.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.a4a31db472f653b911e7.js.map    22.8 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

发现打包后提取公共模块static/js/vendor.js有714kb

再看一下各个模块占用情况:

发现elment-ui.common.js占用最大。所有模块资源总共有708kb。怎么才能减小打包后的大小呢?很容易就会想到ui的引入和样式的引入中,实际我们只使用了三个组件,却整体都被打包了,在这里引入这三个组件即可。

按需引入组件样式

新建一个element-variables.scss文件(为什么是SCSS文件,后面自定义主题会用到)。

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

/*按需引入用到的组件的scss文件和基础scss文件*/
@import "~element-ui/packages/theme-chalk/src/base.scss";
@import "~element-ui/packages/theme-chalk/src/rate.scss";
@import "~element-ui/packages/theme-chalk/src/button.scss";
@import "~element-ui/packages/theme-chalk/src/row.scss";

按需引入组件

新建一个elementConfig.js文件,将项目用到的element组件引入。

import {
  Rate,
  Row,
  Button
} from 'element-ui'

export default {
  install (V) {
    V.use(Rate)
    V.use(Button)
    V.use(Row)
  }
}

第一次优化后打包分析

将以上element-variables.scsselementConfig.js引入到main.js中。

import ElementUI from '@/assets/js/elementConfig'
import '@/assets/css/element-variables.scss'

Vue.use(ElementUI)

貌似上面一切都很顺理成章,打包后大小会减小。

Hash: 3ba9b74482f121efd3aa
Version: webpack 3.11.0
Time: 18854ms
                                                  Asset       Size  Chunks                    Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
               static/js/vendor.11c71f168a2d61b547a0.js     714 kB       0  [emitted]  [big]  vendor
                  static/js/app.dbb5b49dad2d42b3598c.js    11.2 kB       1  [emitted]         app
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]         manifest
    static/css/app.bf52525d6279e7fb87b4db770d119a8d.css    25.7 kB       1  [emitted]         app
static/css/app.bf52525d6279e7fb87b4db770d119a8d.css.map      63 kB          [emitted]
           static/js/vendor.11c71f168a2d61b547a0.js.map    2.73 MB       0  [emitted]         vendor
              static/js/app.dbb5b49dad2d42b3598c.js.map      21 kB       1  [emitted]         app
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]         manifest
                                             index.html  508 bytes          [emitted]

结果可知,static/js/vendor.js还是714kb!

再看各个模块占用情况:

WHAT?竟然模块都没什么变化,岂不是竹篮打水,事与愿违。

再次打包优化尝试

后来查到有人同样遇到这个问题,提出一个issues#6362,原来只引入需要的element-ui组件,webpack还是把整体的ui库和样式都打包了,需要一个webpackbabel插件babel-plugin-component,这样才能真正按需引入打包。这块其实被写到官方文档更换 自定义主题 的配置了。

于是npm i babel-plugin-component -D安装后,在增加.babelrc文件插件配置

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": ["transform-vue-jsx", "transform-runtime", ["component", {
    "libraryName": "element-ui",
    "styleLibraryName": "theme-chalk"
  }]]
}

页面运行正常,再次打包。

Hash: 9cc71dead6d7646c9ed4
Version: webpack 3.11.0
Time: 9963ms
                                                  Asset       Size  Chunks             Chunk Names
                 static/fonts/element-icons.6f0a763.ttf      11 kB          [emitted]
                  static/js/app.77c0883f4f0fc0bf5cbc.js    11.4 kB       0  [emitted]  app
               static/js/vendor.942130fd13274b901889.js     126 kB       1  [emitted]  vendor
             static/js/manifest.2ae2e69a05c33dfc65f8.js  857 bytes       2  [emitted]  manifest
    static/css/app.b140020e5dbee406ae70780b43ba7ddc.css    27.8 kB       0  [emitted]  app
static/css/app.b140020e5dbee406ae70780b43ba7ddc.css.map    91.4 kB          [emitted]
              static/js/app.77c0883f4f0fc0bf5cbc.js.map    21.1 kB       0  [emitted]  app
           static/js/vendor.942130fd13274b901889.js.map     613 kB       1  [emitted]  vendor
         static/js/manifest.2ae2e69a05c33dfc65f8.js.map    4.97 kB       2  [emitted]  manifest
                                             index.html  508 bytes          [emitted]

static/js/vendor.js确实变小了,126kB。再来看各个模块分析图。

模块总共135.03KB,少了5倍!

更换主题

element-uitheme-chalk使用SCSS编写,如果在自己的项目中也是用SCSS,那么可以直接在项目中改变样式变量,因此可以在前面新建的element-variables.scss文件用新的主题颜色变量覆盖即可。

/*主题颜色变量*/
$--color-primary: #f0f;

/*icon字体路径变量*/
$--font-path: "~element-ui/lib/theme-chalk/fonts";

可能你已经注意到了,这里没有分别引入用到的组件样式了,是因为babel-plugin-component帮我们按需引入了对应的样式。

现在我们的主题就变成了

如果你没有用到SCSS,可以用element-theme主题编译插件,生成自定义主题文件引入。

完~ps:个人见解有限,欢迎指正。

查看原文

wuwhs 评论了文章 · 2019-04-30

可能这些是你想要的H5软键盘兼容方案

image

前言

最近一段时间在做 H5 聊天项目,踩过其中一大坑:输入框获取焦点,软键盘弹起,要求输入框吸附(或顶)在输入法框上。需求很明确,看似很简单,其实不然。从实验过一些机型上看,发现主要存在以下问题:

  • AndroidIOS 上,获知软键盘弹起和收起状态存在差异,且页面 webview 表现不同。
  • IOS12 上,微信版本 v6.7.4 及以上,输入框获取焦点,键盘弹起,页面(webview)整体往上滚动,当键盘收起后,不回到原位,导致键盘原来所在位置是空白的。
  • IOS 上,使用第三方输入法,高度计算存在偏差,导致在有些输入法弹起,将输入框挡住一部分。
  • 在有些浏览器上使用一些操作技巧,还是存在输入框被输入法遮挡。

下面就上述发现的问题,逐个探索一下解决方案。

获知软键盘弹起和收起状态

获知软键盘的弹起还是收起状态很重要,后面的兼容处理都要以此为前提。然而,H5 并没有直接监听软键盘的原生事件,只能通过软键盘弹起或收起,引发页面其他方面的表现间接监听,曲线救国。并且,在 IOSAndroid 上的表现不尽相同。

IOS 软键盘弹起表现

IOS 上,输入框(inputtextarea 或 富文本)获取焦点,键盘弹起,页面(webview)并没有被压缩,或者说高度(height)没有改变,只是页面(webview)整体往上滚了,且最大滚动高度(scrollTop)为软键盘高度。

Android 软键盘弹起表现

同样,在 Android 上,输入框获取焦点,键盘弹起,但是页面(webview)高度会发生改变,一般来说,高度为可视区高度(原高度减去软键盘高度),除了因为页面内容被撑开可以产生滚动,webview 本身不能滚动。

IOS 软键盘收起表现

触发软键盘上的“收起”按钮键盘或者输入框以外的页面区域时,输入框失去焦点,软键盘收起。

Android 软键盘收起表现

触发输入框以外的区域时,输入框失去焦点,软键盘收起。但是,触发键盘上的收起按钮键盘时,输入框并不会失去焦点,同样软键盘收起。

软键盘弹起,IOS 和 Android 的 webview 不同表现

监听软键盘弹起和收起

综合上面键盘弹起和收起在 IOSAndroid 上的不同表现,我们可以分开进行如下处理来监听软键盘的弹起和收起:

  • IOS 上,监听输入框的 focus 事件来获知软键盘弹起,监听输入框的 blur 事件获知软键盘收起。
  • Android 上,监听 webview 高度会变化,高度变小获知软键盘弹起,否则软键盘收起。
// 判断设备类型
var judgeDeviceType = function () {
  var ua = window.navigator.userAgent.toLocaleLowerCase();
  var isIOS = /iphone|ipad|ipod/.test(ua);
  var isAndroid = /android/.test(ua);

  return {
    isIOS: isIOS,
    isAndroid: isAndroid
  }
}()

// 监听输入框的软键盘弹起和收起事件
function listenKeybord($input) {
  if (judgeDeviceType.isIOS) {
    // IOS 键盘弹起:IOS 和 Android 输入框获取焦点键盘弹起
    $input.addEventListener('focus', function () {
      console.log('IOS 键盘弹起啦!');
      // IOS 键盘弹起后操作
    }, false)

    // IOS 键盘收起:IOS 点击输入框以外区域或点击收起按钮,输入框都会失去焦点,键盘会收起,
    $input.addEventListener('blur', () => {
      console.log('IOS 键盘收起啦!');
      // IOS 键盘收起后操作
    })
  }

  // Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
  if (judgeDeviceType.isAndroid) {
    var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

    window.addEventListener('resize', function () {
      var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
      if (originHeight < resizeHeight) {
        console.log('Android 键盘收起啦!');
        // Android 键盘收起后操作
      } else {
        console.log('Android 键盘弹起啦!');
        // Android 键盘弹起后操作
      }

      originHeight = resizeHeight;
    }, false)
  }
}

var $inputs = document.querySelectorAll('.input');

for (var i = 0; i < $inputs.length; i++) {
  listenKeybord($inputs[i]);
}

弹起软键盘始终让输入框滚动到可视区

有时我们会做一个输入表单,有很多输入项,输入框获取焦点,弹起软键盘。当输入框位于页面下部位置时,在 IOS 上,会将 webview 整体往上滚一段距离,使得该获取焦点的输入框自动处于可视区,而在 Android 则不会这样,它只会改变页面高度,而不会去滚动到当前焦点元素到可视区。
由于上面已经实现监听 IOSAndroid 键盘弹起和收起,在这里,只需在 Android 键盘弹起后,将焦点元素滚动(scrollIntoView())到可视区。查看效果,可以戳这里

// 获取到焦点元素滚动到可视区
function activeElementScrollIntoView(activeElement, delay) {
  var editable = activeElement.getAttribute('contenteditable')

  // 输入框、textarea或富文本获取焦点后没有将该元素滚动到可视区
  if (activeElement.tagName == 'INPUT' || activeElement.tagName == 'TEXTAREA' || editable === '' || editable) {
    setTimeout(function () {
      activeElement.scrollIntoView();
    }, delay)
  }
}

// ...
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);
// ...

唤起纯数字软键盘

上面的表单输入框有要求输入电话号码,类似这样就要弹出一个数字软键盘了,既然说到了软键盘兼容,在这里就安插一下。比较好的解决方案如下:

<p>请输入手机号</p>
<input type="tel" novalidate="novalidate" pattern="[0-9]*" class="input">
  • type="tel", 是 HTML5 的一个属性,表示输入框类型为电话号码,在 AndroidIOS 上表现差不多,都会有数字键盘,但是也会有字母,略显多余。
  • pattern="[0-9]"pattern 用于验证表单输入的内容,通常 HTML5type 属性,比如 emailtelnumberdata 类、url 等,已经自带了简单的数据格式验证功能了,加上 pattern 后,前端部分的验证更加简单高效了。IOS 中,只有 [0-9]\* 才可以调起九宫格数字键盘,\d 无效,Android 4.4 以下(包括X5内核),两者都调起数字键盘。
  • novalidate="novalidate"novalidate 属性规定当提交表单时不对其进行验证,由于 pattern 校验兼容性不好,可以不让其校验,只让其唤起纯数字键盘,校验工作由 js 去做。

软键盘弹起,IOS 和 Android 的 webview 不同表现

兼容 IOS12 + V6.7.4+

如果你在用 IOS12V6.7.4+版本的微信浏览器打开上面表单输入的 demo ,就会惊奇的发现键盘收起后,原本被滚动顶起的页面并没有回到底部位置,导致原来键盘弹起的位置“空”了。

兼容 codeIOS12/code + codeV6.7.4+/code

其实这是 AppleIOSbug,会出现在所有的 Xcode10 打包的 IOS12 的设备上。微信官方已给出解决方案,只需在软键盘收起后,将页面(webview)滚回到窗口最底部位置(clientHeight位置)。修复后的上面表单输入 demo 可以戳这里

console.log('IOS 键盘收起啦!');
// IOS 键盘收起后操作
// 微信浏览器版本6.7.4+IOS12会出现键盘收起后,视图被顶上去了没有下来
var wechatInfo = window.navigator.userAgent.match(/MicroMessenger\/([\d\.]+)/i);
if (!wechatInfo) return;

var wechatVersion = wechatInfo[1];
var version = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/);

if (+wechatVersion.replace(/\./g, '') >= 674 && +version[1] >= 12) {
  setTimeout(function () {
    window.scrollTo(0, Math.max(document.body.clientHeight, document.documentElement.clientHeight));
  })
}

兼容第三方输入法

上面说了那么多,其实已经把 H5 聊天输入框的坑填了一大半了,接下来就先看下聊天输入框的基本HTML结构

<div class="chat__content">
  <div>
    <p>一些聊天内容1</p>
  </div>
  <!-- 省略几千行聊天内容 -->
</div>
<div class="input__content">
  <div class="input" contenteditable="true"></div>
  <button>发送</button>
</div>

样式

/* 省略一些样式 */
.chat__content {
  height: calc(100% - 40px);
  margin-bottom: 40px;
  overflow-y: auto;
  overflow-x: hidden;
}

.input__content {
  display: flex;
  height: 40px;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  align-items: center;
}
/* 省略一些样式 */

很简单,就是划分内容区和输入区,输入区是绝对定位,按照上面表单输入 demo 的做法,确实大部分 Android 浏览器是没问题的,但是测试在 IOS 上,UC 浏览器配合原生输入法和第三方输入法(比如搜狗输入法),输入框都会被完全挡住;QQ 浏览器或微信浏览器,配合第三方输入法,输入框会被遮住一半;百度浏览器配合第三方输入法输入框也会被完全遮住。查看效果可以用相应浏览器中访问这里

keyboard-chat-input.png

UC 浏览器上,软键盘弹起后,浏览器上面的标题栏高度就有个高度变小延时动态效果,这样导致 webview 往下滚了一点,底部输入框滚到了非可视区。
而对于第三方输入法,猜测本身是由于输入法面板弹起后高度计算有误,导致 webview 初始滚动定位有误。其实这两点都是 webview 滚动不到位造成的。可以让软键盘弹起后,让焦点元素再次滚到可视区,强迫 webview 滚到位。

console.log('Android 键盘弹起啦!');
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);

兼容 Android 小米浏览器的 Hack 方案

Android 的小米浏览器上,应用上面的方案,发现聊天输入框还是被遮挡得严严实实,scrollIntoView() 仍然纹丝不动。所以猜测,其实是滚到底了,软键盘弹起,页面实现高度大于可视区高度,这样只能在软键盘弹起后,强行增加页面高度,使输入框可以显示出来。综合上面兼容第三方输入法,查看效果可以戳这里

// Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
if (judgeDeviceType.isAndroid) {
  var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

  window.addEventListener('resize', function () {
    var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
    if (originHeight < resizeHeight) {
      console.log('Android 键盘收起啦!');
      // Android 键盘收起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '0px';
      }
    } else {
      console.log('Android 键盘弹起啦!');
      // Android 键盘弹起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '40px';
      }
      activeElementScrollIntoView($input, 1000);
    }

    originHeight = resizeHeight;
  }, false)
}

总结

H5 端前路漫漫,坑很多,需要不断尝试。了解软键盘弹起页面在 IOSAndroid 上的表现差异是前提,其次是将焦点元素滚动到可视区,同时要考虑到第三方输入法和某些浏览器上的差别。总结肯定不全面,欢迎大家指正哈,完~

查看原文

wuwhs 评论了文章 · 2019-04-30

可能这些是你想要的H5软键盘兼容方案

image

前言

最近一段时间在做 H5 聊天项目,踩过其中一大坑:输入框获取焦点,软键盘弹起,要求输入框吸附(或顶)在输入法框上。需求很明确,看似很简单,其实不然。从实验过一些机型上看,发现主要存在以下问题:

  • AndroidIOS 上,获知软键盘弹起和收起状态存在差异,且页面 webview 表现不同。
  • IOS12 上,微信版本 v6.7.4 及以上,输入框获取焦点,键盘弹起,页面(webview)整体往上滚动,当键盘收起后,不回到原位,导致键盘原来所在位置是空白的。
  • IOS 上,使用第三方输入法,高度计算存在偏差,导致在有些输入法弹起,将输入框挡住一部分。
  • 在有些浏览器上使用一些操作技巧,还是存在输入框被输入法遮挡。

下面就上述发现的问题,逐个探索一下解决方案。

获知软键盘弹起和收起状态

获知软键盘的弹起还是收起状态很重要,后面的兼容处理都要以此为前提。然而,H5 并没有直接监听软键盘的原生事件,只能通过软键盘弹起或收起,引发页面其他方面的表现间接监听,曲线救国。并且,在 IOSAndroid 上的表现不尽相同。

IOS 软键盘弹起表现

IOS 上,输入框(inputtextarea 或 富文本)获取焦点,键盘弹起,页面(webview)并没有被压缩,或者说高度(height)没有改变,只是页面(webview)整体往上滚了,且最大滚动高度(scrollTop)为软键盘高度。

Android 软键盘弹起表现

同样,在 Android 上,输入框获取焦点,键盘弹起,但是页面(webview)高度会发生改变,一般来说,高度为可视区高度(原高度减去软键盘高度),除了因为页面内容被撑开可以产生滚动,webview 本身不能滚动。

IOS 软键盘收起表现

触发软键盘上的“收起”按钮键盘或者输入框以外的页面区域时,输入框失去焦点,软键盘收起。

Android 软键盘收起表现

触发输入框以外的区域时,输入框失去焦点,软键盘收起。但是,触发键盘上的收起按钮键盘时,输入框并不会失去焦点,同样软键盘收起。

软键盘弹起,IOS 和 Android 的 webview 不同表现

监听软键盘弹起和收起

综合上面键盘弹起和收起在 IOSAndroid 上的不同表现,我们可以分开进行如下处理来监听软键盘的弹起和收起:

  • IOS 上,监听输入框的 focus 事件来获知软键盘弹起,监听输入框的 blur 事件获知软键盘收起。
  • Android 上,监听 webview 高度会变化,高度变小获知软键盘弹起,否则软键盘收起。
// 判断设备类型
var judgeDeviceType = function () {
  var ua = window.navigator.userAgent.toLocaleLowerCase();
  var isIOS = /iphone|ipad|ipod/.test(ua);
  var isAndroid = /android/.test(ua);

  return {
    isIOS: isIOS,
    isAndroid: isAndroid
  }
}()

// 监听输入框的软键盘弹起和收起事件
function listenKeybord($input) {
  if (judgeDeviceType.isIOS) {
    // IOS 键盘弹起:IOS 和 Android 输入框获取焦点键盘弹起
    $input.addEventListener('focus', function () {
      console.log('IOS 键盘弹起啦!');
      // IOS 键盘弹起后操作
    }, false)

    // IOS 键盘收起:IOS 点击输入框以外区域或点击收起按钮,输入框都会失去焦点,键盘会收起,
    $input.addEventListener('blur', () => {
      console.log('IOS 键盘收起啦!');
      // IOS 键盘收起后操作
    })
  }

  // Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
  if (judgeDeviceType.isAndroid) {
    var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

    window.addEventListener('resize', function () {
      var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
      if (originHeight < resizeHeight) {
        console.log('Android 键盘收起啦!');
        // Android 键盘收起后操作
      } else {
        console.log('Android 键盘弹起啦!');
        // Android 键盘弹起后操作
      }

      originHeight = resizeHeight;
    }, false)
  }
}

var $inputs = document.querySelectorAll('.input');

for (var i = 0; i < $inputs.length; i++) {
  listenKeybord($inputs[i]);
}

弹起软键盘始终让输入框滚动到可视区

有时我们会做一个输入表单,有很多输入项,输入框获取焦点,弹起软键盘。当输入框位于页面下部位置时,在 IOS 上,会将 webview 整体往上滚一段距离,使得该获取焦点的输入框自动处于可视区,而在 Android 则不会这样,它只会改变页面高度,而不会去滚动到当前焦点元素到可视区。
由于上面已经实现监听 IOSAndroid 键盘弹起和收起,在这里,只需在 Android 键盘弹起后,将焦点元素滚动(scrollIntoView())到可视区。查看效果,可以戳这里

// 获取到焦点元素滚动到可视区
function activeElementScrollIntoView(activeElement, delay) {
  var editable = activeElement.getAttribute('contenteditable')

  // 输入框、textarea或富文本获取焦点后没有将该元素滚动到可视区
  if (activeElement.tagName == 'INPUT' || activeElement.tagName == 'TEXTAREA' || editable === '' || editable) {
    setTimeout(function () {
      activeElement.scrollIntoView();
    }, delay)
  }
}

// ...
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);
// ...

唤起纯数字软键盘

上面的表单输入框有要求输入电话号码,类似这样就要弹出一个数字软键盘了,既然说到了软键盘兼容,在这里就安插一下。比较好的解决方案如下:

<p>请输入手机号</p>
<input type="tel" novalidate="novalidate" pattern="[0-9]*" class="input">
  • type="tel", 是 HTML5 的一个属性,表示输入框类型为电话号码,在 AndroidIOS 上表现差不多,都会有数字键盘,但是也会有字母,略显多余。
  • pattern="[0-9]"pattern 用于验证表单输入的内容,通常 HTML5type 属性,比如 emailtelnumberdata 类、url 等,已经自带了简单的数据格式验证功能了,加上 pattern 后,前端部分的验证更加简单高效了。IOS 中,只有 [0-9]\* 才可以调起九宫格数字键盘,\d 无效,Android 4.4 以下(包括X5内核),两者都调起数字键盘。
  • novalidate="novalidate"novalidate 属性规定当提交表单时不对其进行验证,由于 pattern 校验兼容性不好,可以不让其校验,只让其唤起纯数字键盘,校验工作由 js 去做。

软键盘弹起,IOS 和 Android 的 webview 不同表现

兼容 IOS12 + V6.7.4+

如果你在用 IOS12V6.7.4+版本的微信浏览器打开上面表单输入的 demo ,就会惊奇的发现键盘收起后,原本被滚动顶起的页面并没有回到底部位置,导致原来键盘弹起的位置“空”了。

兼容 codeIOS12/code + codeV6.7.4+/code

其实这是 AppleIOSbug,会出现在所有的 Xcode10 打包的 IOS12 的设备上。微信官方已给出解决方案,只需在软键盘收起后,将页面(webview)滚回到窗口最底部位置(clientHeight位置)。修复后的上面表单输入 demo 可以戳这里

console.log('IOS 键盘收起啦!');
// IOS 键盘收起后操作
// 微信浏览器版本6.7.4+IOS12会出现键盘收起后,视图被顶上去了没有下来
var wechatInfo = window.navigator.userAgent.match(/MicroMessenger\/([\d\.]+)/i);
if (!wechatInfo) return;

var wechatVersion = wechatInfo[1];
var version = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/);

if (+wechatVersion.replace(/\./g, '') >= 674 && +version[1] >= 12) {
  setTimeout(function () {
    window.scrollTo(0, Math.max(document.body.clientHeight, document.documentElement.clientHeight));
  })
}

兼容第三方输入法

上面说了那么多,其实已经把 H5 聊天输入框的坑填了一大半了,接下来就先看下聊天输入框的基本HTML结构

<div class="chat__content">
  <div>
    <p>一些聊天内容1</p>
  </div>
  <!-- 省略几千行聊天内容 -->
</div>
<div class="input__content">
  <div class="input" contenteditable="true"></div>
  <button>发送</button>
</div>

样式

/* 省略一些样式 */
.chat__content {
  height: calc(100% - 40px);
  margin-bottom: 40px;
  overflow-y: auto;
  overflow-x: hidden;
}

.input__content {
  display: flex;
  height: 40px;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  align-items: center;
}
/* 省略一些样式 */

很简单,就是划分内容区和输入区,输入区是绝对定位,按照上面表单输入 demo 的做法,确实大部分 Android 浏览器是没问题的,但是测试在 IOS 上,UC 浏览器配合原生输入法和第三方输入法(比如搜狗输入法),输入框都会被完全挡住;QQ 浏览器或微信浏览器,配合第三方输入法,输入框会被遮住一半;百度浏览器配合第三方输入法输入框也会被完全遮住。查看效果可以用相应浏览器中访问这里

keyboard-chat-input.png

UC 浏览器上,软键盘弹起后,浏览器上面的标题栏高度就有个高度变小延时动态效果,这样导致 webview 往下滚了一点,底部输入框滚到了非可视区。
而对于第三方输入法,猜测本身是由于输入法面板弹起后高度计算有误,导致 webview 初始滚动定位有误。其实这两点都是 webview 滚动不到位造成的。可以让软键盘弹起后,让焦点元素再次滚到可视区,强迫 webview 滚到位。

console.log('Android 键盘弹起啦!');
// Android 键盘弹起后操作
activeElementScrollIntoView($input, 1000);

兼容 Android 小米浏览器的 Hack 方案

Android 的小米浏览器上,应用上面的方案,发现聊天输入框还是被遮挡得严严实实,scrollIntoView() 仍然纹丝不动。所以猜测,其实是滚到底了,软键盘弹起,页面实现高度大于可视区高度,这样只能在软键盘弹起后,强行增加页面高度,使输入框可以显示出来。综合上面兼容第三方输入法,查看效果可以戳这里

// Andriod 键盘收起:Andriod 键盘弹起或收起页面高度会发生变化,以此为依据获知键盘收起
if (judgeDeviceType.isAndroid) {
  var originHeight = document.documentElement.clientHeight || document.body.clientHeight;

  window.addEventListener('resize', function () {
    var resizeHeight = document.documentElement.clientHeight || document.body.clientHeight;
    if (originHeight < resizeHeight) {
      console.log('Android 键盘收起啦!');
      // Android 键盘收起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '0px';
      }
    } else {
      console.log('Android 键盘弹起啦!');
      // Android 键盘弹起后操作
      // 修复小米浏览器下,输入框依旧被输入法遮挡问题
      if (judgeDeviceType.isMiuiBrowser) {
        document.body.style.marginBottom = '40px';
      }
      activeElementScrollIntoView($input, 1000);
    }

    originHeight = resizeHeight;
  }, false)
}

总结

H5 端前路漫漫,坑很多,需要不断尝试。了解软键盘弹起页面在 IOSAndroid 上的表现差异是前提,其次是将焦点元素滚动到可视区,同时要考虑到第三方输入法和某些浏览器上的差别。总结肯定不全面,欢迎大家指正哈,完~

查看原文