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:个人见解有限,欢迎指正。

查看原文

赞 31 收藏 22 评论 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 下载。整理匆忙,如有问题欢迎大家指正,完~

查看原文

赞 57 收藏 38 评论 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直播里聊到了这些…
完~

查看原文

赞 20 收藏 12 评论 3

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秘密花园

关注 5202

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点纯手工发布面试题(死磕自己,愉悦大家)

关注 220

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 属于一种供参考方案。做的功课有点少,欢迎指正,完~

查看原文

赞 59 收藏 45 评论 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

认证与成就

  • 获得 1505 次点赞
  • 获得 14 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 12 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2018-02-09
个人主页被 3.2k 人浏览