nojsja

nojsja 查看完整档案

成都编辑成都信息工程大学  |  计算机科学与技术 编辑上海德拓  |  前端 编辑 nojsja.gitee.io/blogs/ 编辑
编辑

Stay hungry, Stay foolish.

个人动态

nojsja 收藏了文章 · 4月8日

webpack 填坑之路--提取独立文件(模块)

前言

最近重新看了一遍 webpack 提取公共文件的配置。原来觉得这东西是个玄学,都是 “凭感觉” 配置。这篇文章将以解决实际开发遇到的问题为核心,悉数利用 webpack 提取独立文件(模块)的应用。

独立文件在实际开发中一般有两种:

  1. 第三方模块 如 Vue React jQuery 等
  2. 项目开发编写的独立模块(模块),对于 MPA 多页面开发来说是封装出的一些方法库比如 utils.getQueryString() 或者是每个页面的共同操作;对于SPA 应用来说没有特别的需要分离出模块,但是针对首屏渲染速度的提升,可以将 某些独立模块分离出来实现按需加载。

分离出独立文件的目的:

  1. 独立文件一般很少更改或者不会更改,webpack 没必要每次打包进一个文件中,独立文件提取出可以长期缓存。
  2. 提升 webpack 打包速度

提取第三方模块

  1. 配置externals
    Webpack 可以配置 externals 来将依赖的库指向全局变量,从而不再打包这个库。
// webpack.config.js 中
module.exports = {
  entry: {
    app: __direname +'/app/index.js'
  }
  externals: {
    jquery: 'window.jQuery'
  }
  ...
}

// 模板 html 中
...
<script data-original="https://code.jquery.com/jquery-3.1.0.js"></script>
...

// 入口文件  index.js
import $ from 'jquery'

其实就是 script 标签引入的jquery 挂载在window下 其他类型 externals 的配置可以去官网查看,这种方法不算是打包提取第三方模块,只是一个变量引入,不是本文讨论的重点。

  1. 利用CommonsChunkPlugin
    CommonsChunkPlugin 插件是专门用来提取独立文件的,它主要是提取多个入口 chunk 的公共模块。他的配置介绍如下:
配置属性配置介绍
name 或者 nameschunk 的名称 如果是names数组 相当于对每个name进行插件实例化
filename这个common chunk 的文件输出名
minChunks通常情况为一个整数,至少有minChunks个chunk使用了该模块,该模块才会被移入[common chunk]里 minChunks 还可以是Infinity意思为没有任何模块被移入,只是创建当前这个 chunk,这通常用来生成 jquery 等第三方代码库。minChunks还可以是一个返回布尔值的函数,返回 true 该模块会被移入 common chunk,否则不会。默认值是 chunks 的长度。
chunks元素为chunk名称的数组,插件将从该数组中提取common chunk 可见 minChunks 应该小予chunks的长度,且大于1。如果没有 所有的入口chunks 会被选中
children默认为false 如果为true 相当于为上一项chunks配置为chunk的子chunk 用于代码分割code split
async默认为false 如果为true 生成的common chunk 为异步加载,这个异步的 common chunk 是 name 这个 chunk 的子 chunk,而且跟 chunks 一起并行加载
minSize如果有指定大小,那么 common chunk 的文件大小至少有 minSize 才会被创建。非必填项。

创建一个如下图的目录

clipboard.png

package.json 如下

{
  "name": "webpacktest",
  "version": "1.0.0",
  "description": "",
  "directories": {
    "doc": "doc"
  },
  "scripts": {
    "start": "webpack"
  },
  "author": "abzerolee",
  "license": "ISC",
  "devDependencies": {
    "html-webpack-plugin": "^2.30.1",
    "webpack": "^3.8.1"
  },
  "dependencies": {
    "underscore": "^1.8.3",
  }
}

a.js 引入了 underscore 需要进行了数组去重操作,现在需要将underscore分离为独立文件。

// webpack.config.js
entry: {
  a: __dirname +'/app/a.js',
  vendor: ['underscore']
},
output: {
  path: __dirname +'/dist',
  filename: '[name].[chunkhash:6].js',       
  chunkFilename: '[name].[id].[chunkhash:6].js'
},
plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
  }),
  new HtmlWebpackPlugin({
    template: __dirname +'/app/index.html'
  })
]
// a.js
let _ = require('underscore');

let arr = _.filter([1,2,3,2,3,3,5,5], (v, i, self) => self.indexOf(v) === i);
console.log('unique:' +arr);

这样underscore就分离进了 vendor 块,注意的是需要在入口定义 要输出的 [ 独立文件名 ]: [ 需要分离的模块数组 ], 然后在CommonsChunkPlugin中配置 name : [独立文件名]。

当然也可以不用在入口定义,如vue-cli 就是在 在CommonsChunk中配置了minChunks。我们的第三方模块都是通过npm 安装在node_modules 目录下,我们可以通过minChunks 判断模块路径是否含有node_module 来返回true 或 false,前文有介绍minChunks的含义。配置如下:

entry: {
    a: __dirname +'/app/a.js', // **注意** 入口没定义vendor 
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].[chunkhash:6].js',
    chunkFilename: '[name].[id].[chunkhash:6].js'
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: function(module) {
        let flag =  module.context && module.context.indexOf('node_modules') !== -1;
        console.log(module.context, flag);
        return flag;
      }
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html'
    })
  ]

上述两种方式,对于多页面还是单页面都是可应用的。但是现在的问题是每次入口文件 a.js 修改之后都会造成 vendor重新打包。那么如何解决这个问题呢。

manifest 处理第三方模块应用

我们将 a.js 做一个简单修改:

// 原来
-  console.log('unique:' +arr);
// 修改后
+   console.log(arr);

clipboard.png

重新打包发现vendor的hash变化了相当于重新打包了underscore,解决的方法是利用一个 manifest 来记录 vendor 的 id ,如果vendor没改变,则不需要重新打包。这就有两种解决方式 :

1. 利用manifest.js

利用CommonsChunkPlugin的chunks特性,提取出 webpack定义的异步加载代码,配置如下:

entry: {
  a: __dirname +'/app/a.js',
},
output: {
  path: __dirname +'/dist',
  filename: '[name].[chunkhash:6].js',
  chunkFilename: '[name].[id].[chunkhash:6].js'
},
plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: function(module) {
      let flag =  module.context && module.context.indexOf('node_modules') !== -1;
      console.log(module.context, flag);
      return flag;
    }
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest',
    chunks: ['vendor'],
  }),
  new HtmlWebpackPlugin({
    template: __dirname +'/app/index.html'
  })
]

还是修改了 a.js 之后发现 vendor的 hash 值没有变化,如下图:

clipboard.png

这里要注意的是chunks: [ 独立文件名 ]。但是,又有但是,要是这么就配置没问题了,就不能叫做玄学了,修改 a.js 的内部代码没问题,如果修改了 require 的模块引入,vendor的hash又有变化了,当然我们可以尽量避免修改文件的依赖引入,但是终归不是最完美的方式。那么终极解决方法是什么呢?DllReferencePlugin,DllPlugin。

2. 利用DllReferencePlugin,DllPlugin

既然动态打包的时候建立 manifest 不行,那么能不能直接把他打包成一个纯净的依赖库,本身无法运行,只是让我们的app 来引入。

那么我们需要完成两步,先webpack.DllPlugin打包dll(纯净的第三方独立文件),然后用DllReferencePlugin 在我们的应用中引用,这样的好处是如果下一个项目还是使用一样的依赖比如react react-dom react-router,可以直接引入这个dll。

配置文件如下:

  entry: {
    vendor: ['underscore']
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].js',
    library: '[name]',
  },
  plugins: [
    new webpack.DllPlugin({
      path: __dirname +'/dist/manifest.json',
      name: '[name]',
      context: __dirname,
    }),
  ],

clipboard.png

根据上述配置打包结果如上图,dist目录下现在有一个vender.js 和 manifest.json 注意这里输出的路径配置。DllPlugin配置介绍如下:

配置项介绍
pathpath 是 manifest.json 文件的输出路径,这个文件会用于后续的业务代码打包;
namename 是 dll 暴露的对象名,要跟 output.library 保持一致;
contextcontext 是解析包路径的上下文,这个要跟接下来配置的 webpack.config.js 一致。

之后在我们的应用中引入中,配置如下:

  entry: {
    a: __dirname +'/app/a.js',
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].[chunkhash:6].js',
    chunkFilename: '[name].[id].[chunkhash:6].js'
  },
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('./dist/manifest.json'),
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html'
    })
  ]

clipboard.png

根据上述配置打包得到a.3e6285.js index.html 如上图,浏览器中打开index.html会显示
Uncaught ReferenceError: vendor is not defined

这里需要在 index.html 中 a.3e6285.js 插入 script 标签

<script type="text/javascript" data-original="vendor.js" ></script>
<script type="text/javascript" data-original="a.3e6285.js"></script>

再打开index.html 可以控制台打印出了数组去重的结果。插入标签的这一步可以在打包好独立文件之前,就在模板html 中插入。

到了这里,提取第三方模块的方法,避免重复打包的方法都介绍完毕了。接下来是配置提取自己编写的公共模块方法。

提取项目公共模块

单页面应用的公共模块没有必要提取出单独的文件,因为不必考虑复用的情况。但是对于打包生成的文件过大,我们又想分离出几个模块有需要的时候才加载,其实这并不是提取公共模块,而是代码分割,通过:

require.ensure(dependencies: String[], callback: function(require), chunkName: String)

在callback中定义的 require的模块将会独立打包,并且插入在 html 的head标签,这里就不做更多介绍了。

多页面应用是有必要抽取公共模块的,比如a.js 引用了lib1, b.js 也引用了 lib1 那么lib1,那么我们肯定希望在提取出 lib1 同时还可以提取出第三方库,配置文件如下:

// a.js 
let _ = require('underscore');
let lib1 = require('./lib1');
console.log('this is entry_a import lib1');

let arr = _.filter([1,2,3,2,3,3,5,5], (v, i, self) => self.indexOf(v) === i);
console.log(arr);

// b.js
require('./lib1');
var b = 'b';

console.log('this is entry_b import lib1');

// webpack.config.js
  entry: {
    a: __dirname +'/app/a.js',
    b: __dirname +'/app/b.js',
    vendor: ['underscore'],
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].[chunkhash:6].js',
    chunkFilename: '[name].[id].[chunkhash:6].js'
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: ['chunk', 'vendor'],
      minChunks: 2,
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      chunks: ['vendor']
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html',
      filename: __dirname +'/dist/a.html',
      chunks: ['a', 'chunk', 'vendor', 'manifest'],
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html',
      filename: __dirname +'/dist/b.html',
      chunks: ['b', 'chunk', 'vendor', 'manifest'],
    }),
  ]
}

通过打包后发现生成了如下文件:

clipboard.png

可以明确看出生成了chunk.d09623.js 而且 其中就是我们的lib1.js 的库的代码。这里要注意的是Commons.ChunkPlugin的配置 当name 给定数组之后从入口文件中选取 共同引用超过 minChunks 次数的模块打包进name 数组的第一个模块,然后name 数组后面的块 'vendor' 依次打包(查找entry里的key,没有找到相关的key就生成一个空的块),最后一个块包含webpack生成的在浏览器上使用各个块的加载代码,所以插入到页面中最后一个块要最先加载,加载顺序由name数组自右向左

这里我们使用manifest 去提取了 webpackJsonp 的加载代码,为了防止重复打包库文件,这在前文已经提到过。所以vendor中的加载代码在mainfest.js 中,修改a.js 的console.log, 重新打包后的文件可以发现chunk.d0962e.js, vendor.98054b.js都没有重新打包

clipboard.png

所以总结来讲就是多入口配置CommonsChunk

    new webpack.optimize.CommonsChunkPlugin({
      name: ['生成的项目公共模块文件名', '第三方模块文件名'],
      minChunks: 2,
    }),
查看原文

nojsja 赞了文章 · 4月8日

webpack 填坑之路--提取独立文件(模块)

前言

最近重新看了一遍 webpack 提取公共文件的配置。原来觉得这东西是个玄学,都是 “凭感觉” 配置。这篇文章将以解决实际开发遇到的问题为核心,悉数利用 webpack 提取独立文件(模块)的应用。

独立文件在实际开发中一般有两种:

  1. 第三方模块 如 Vue React jQuery 等
  2. 项目开发编写的独立模块(模块),对于 MPA 多页面开发来说是封装出的一些方法库比如 utils.getQueryString() 或者是每个页面的共同操作;对于SPA 应用来说没有特别的需要分离出模块,但是针对首屏渲染速度的提升,可以将 某些独立模块分离出来实现按需加载。

分离出独立文件的目的:

  1. 独立文件一般很少更改或者不会更改,webpack 没必要每次打包进一个文件中,独立文件提取出可以长期缓存。
  2. 提升 webpack 打包速度

提取第三方模块

  1. 配置externals
    Webpack 可以配置 externals 来将依赖的库指向全局变量,从而不再打包这个库。
// webpack.config.js 中
module.exports = {
  entry: {
    app: __direname +'/app/index.js'
  }
  externals: {
    jquery: 'window.jQuery'
  }
  ...
}

// 模板 html 中
...
<script data-original="https://code.jquery.com/jquery-3.1.0.js"></script>
...

// 入口文件  index.js
import $ from 'jquery'

其实就是 script 标签引入的jquery 挂载在window下 其他类型 externals 的配置可以去官网查看,这种方法不算是打包提取第三方模块,只是一个变量引入,不是本文讨论的重点。

  1. 利用CommonsChunkPlugin
    CommonsChunkPlugin 插件是专门用来提取独立文件的,它主要是提取多个入口 chunk 的公共模块。他的配置介绍如下:
配置属性配置介绍
name 或者 nameschunk 的名称 如果是names数组 相当于对每个name进行插件实例化
filename这个common chunk 的文件输出名
minChunks通常情况为一个整数,至少有minChunks个chunk使用了该模块,该模块才会被移入[common chunk]里 minChunks 还可以是Infinity意思为没有任何模块被移入,只是创建当前这个 chunk,这通常用来生成 jquery 等第三方代码库。minChunks还可以是一个返回布尔值的函数,返回 true 该模块会被移入 common chunk,否则不会。默认值是 chunks 的长度。
chunks元素为chunk名称的数组,插件将从该数组中提取common chunk 可见 minChunks 应该小予chunks的长度,且大于1。如果没有 所有的入口chunks 会被选中
children默认为false 如果为true 相当于为上一项chunks配置为chunk的子chunk 用于代码分割code split
async默认为false 如果为true 生成的common chunk 为异步加载,这个异步的 common chunk 是 name 这个 chunk 的子 chunk,而且跟 chunks 一起并行加载
minSize如果有指定大小,那么 common chunk 的文件大小至少有 minSize 才会被创建。非必填项。

创建一个如下图的目录

clipboard.png

package.json 如下

{
  "name": "webpacktest",
  "version": "1.0.0",
  "description": "",
  "directories": {
    "doc": "doc"
  },
  "scripts": {
    "start": "webpack"
  },
  "author": "abzerolee",
  "license": "ISC",
  "devDependencies": {
    "html-webpack-plugin": "^2.30.1",
    "webpack": "^3.8.1"
  },
  "dependencies": {
    "underscore": "^1.8.3",
  }
}

a.js 引入了 underscore 需要进行了数组去重操作,现在需要将underscore分离为独立文件。

// webpack.config.js
entry: {
  a: __dirname +'/app/a.js',
  vendor: ['underscore']
},
output: {
  path: __dirname +'/dist',
  filename: '[name].[chunkhash:6].js',       
  chunkFilename: '[name].[id].[chunkhash:6].js'
},
plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
  }),
  new HtmlWebpackPlugin({
    template: __dirname +'/app/index.html'
  })
]
// a.js
let _ = require('underscore');

let arr = _.filter([1,2,3,2,3,3,5,5], (v, i, self) => self.indexOf(v) === i);
console.log('unique:' +arr);

这样underscore就分离进了 vendor 块,注意的是需要在入口定义 要输出的 [ 独立文件名 ]: [ 需要分离的模块数组 ], 然后在CommonsChunkPlugin中配置 name : [独立文件名]。

当然也可以不用在入口定义,如vue-cli 就是在 在CommonsChunk中配置了minChunks。我们的第三方模块都是通过npm 安装在node_modules 目录下,我们可以通过minChunks 判断模块路径是否含有node_module 来返回true 或 false,前文有介绍minChunks的含义。配置如下:

entry: {
    a: __dirname +'/app/a.js', // **注意** 入口没定义vendor 
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].[chunkhash:6].js',
    chunkFilename: '[name].[id].[chunkhash:6].js'
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: function(module) {
        let flag =  module.context && module.context.indexOf('node_modules') !== -1;
        console.log(module.context, flag);
        return flag;
      }
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html'
    })
  ]

上述两种方式,对于多页面还是单页面都是可应用的。但是现在的问题是每次入口文件 a.js 修改之后都会造成 vendor重新打包。那么如何解决这个问题呢。

manifest 处理第三方模块应用

我们将 a.js 做一个简单修改:

// 原来
-  console.log('unique:' +arr);
// 修改后
+   console.log(arr);

clipboard.png

重新打包发现vendor的hash变化了相当于重新打包了underscore,解决的方法是利用一个 manifest 来记录 vendor 的 id ,如果vendor没改变,则不需要重新打包。这就有两种解决方式 :

1. 利用manifest.js

利用CommonsChunkPlugin的chunks特性,提取出 webpack定义的异步加载代码,配置如下:

entry: {
  a: __dirname +'/app/a.js',
},
output: {
  path: __dirname +'/dist',
  filename: '[name].[chunkhash:6].js',
  chunkFilename: '[name].[id].[chunkhash:6].js'
},
plugins: [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: function(module) {
      let flag =  module.context && module.context.indexOf('node_modules') !== -1;
      console.log(module.context, flag);
      return flag;
    }
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest',
    chunks: ['vendor'],
  }),
  new HtmlWebpackPlugin({
    template: __dirname +'/app/index.html'
  })
]

还是修改了 a.js 之后发现 vendor的 hash 值没有变化,如下图:

clipboard.png

这里要注意的是chunks: [ 独立文件名 ]。但是,又有但是,要是这么就配置没问题了,就不能叫做玄学了,修改 a.js 的内部代码没问题,如果修改了 require 的模块引入,vendor的hash又有变化了,当然我们可以尽量避免修改文件的依赖引入,但是终归不是最完美的方式。那么终极解决方法是什么呢?DllReferencePlugin,DllPlugin。

2. 利用DllReferencePlugin,DllPlugin

既然动态打包的时候建立 manifest 不行,那么能不能直接把他打包成一个纯净的依赖库,本身无法运行,只是让我们的app 来引入。

那么我们需要完成两步,先webpack.DllPlugin打包dll(纯净的第三方独立文件),然后用DllReferencePlugin 在我们的应用中引用,这样的好处是如果下一个项目还是使用一样的依赖比如react react-dom react-router,可以直接引入这个dll。

配置文件如下:

  entry: {
    vendor: ['underscore']
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].js',
    library: '[name]',
  },
  plugins: [
    new webpack.DllPlugin({
      path: __dirname +'/dist/manifest.json',
      name: '[name]',
      context: __dirname,
    }),
  ],

clipboard.png

根据上述配置打包结果如上图,dist目录下现在有一个vender.js 和 manifest.json 注意这里输出的路径配置。DllPlugin配置介绍如下:

配置项介绍
pathpath 是 manifest.json 文件的输出路径,这个文件会用于后续的业务代码打包;
namename 是 dll 暴露的对象名,要跟 output.library 保持一致;
contextcontext 是解析包路径的上下文,这个要跟接下来配置的 webpack.config.js 一致。

之后在我们的应用中引入中,配置如下:

  entry: {
    a: __dirname +'/app/a.js',
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].[chunkhash:6].js',
    chunkFilename: '[name].[id].[chunkhash:6].js'
  },
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      manifest: require('./dist/manifest.json'),
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html'
    })
  ]

clipboard.png

根据上述配置打包得到a.3e6285.js index.html 如上图,浏览器中打开index.html会显示
Uncaught ReferenceError: vendor is not defined

这里需要在 index.html 中 a.3e6285.js 插入 script 标签

<script type="text/javascript" data-original="vendor.js" ></script>
<script type="text/javascript" data-original="a.3e6285.js"></script>

再打开index.html 可以控制台打印出了数组去重的结果。插入标签的这一步可以在打包好独立文件之前,就在模板html 中插入。

到了这里,提取第三方模块的方法,避免重复打包的方法都介绍完毕了。接下来是配置提取自己编写的公共模块方法。

提取项目公共模块

单页面应用的公共模块没有必要提取出单独的文件,因为不必考虑复用的情况。但是对于打包生成的文件过大,我们又想分离出几个模块有需要的时候才加载,其实这并不是提取公共模块,而是代码分割,通过:

require.ensure(dependencies: String[], callback: function(require), chunkName: String)

在callback中定义的 require的模块将会独立打包,并且插入在 html 的head标签,这里就不做更多介绍了。

多页面应用是有必要抽取公共模块的,比如a.js 引用了lib1, b.js 也引用了 lib1 那么lib1,那么我们肯定希望在提取出 lib1 同时还可以提取出第三方库,配置文件如下:

// a.js 
let _ = require('underscore');
let lib1 = require('./lib1');
console.log('this is entry_a import lib1');

let arr = _.filter([1,2,3,2,3,3,5,5], (v, i, self) => self.indexOf(v) === i);
console.log(arr);

// b.js
require('./lib1');
var b = 'b';

console.log('this is entry_b import lib1');

// webpack.config.js
  entry: {
    a: __dirname +'/app/a.js',
    b: __dirname +'/app/b.js',
    vendor: ['underscore'],
  },
  output: {
    path: __dirname +'/dist',
    filename: '[name].[chunkhash:6].js',
    chunkFilename: '[name].[id].[chunkhash:6].js'
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: ['chunk', 'vendor'],
      minChunks: 2,
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      chunks: ['vendor']
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html',
      filename: __dirname +'/dist/a.html',
      chunks: ['a', 'chunk', 'vendor', 'manifest'],
    }),
    new HtmlWebpackPlugin({
      template: __dirname +'/app/index.html',
      filename: __dirname +'/dist/b.html',
      chunks: ['b', 'chunk', 'vendor', 'manifest'],
    }),
  ]
}

通过打包后发现生成了如下文件:

clipboard.png

可以明确看出生成了chunk.d09623.js 而且 其中就是我们的lib1.js 的库的代码。这里要注意的是Commons.ChunkPlugin的配置 当name 给定数组之后从入口文件中选取 共同引用超过 minChunks 次数的模块打包进name 数组的第一个模块,然后name 数组后面的块 'vendor' 依次打包(查找entry里的key,没有找到相关的key就生成一个空的块),最后一个块包含webpack生成的在浏览器上使用各个块的加载代码,所以插入到页面中最后一个块要最先加载,加载顺序由name数组自右向左

这里我们使用manifest 去提取了 webpackJsonp 的加载代码,为了防止重复打包库文件,这在前文已经提到过。所以vendor中的加载代码在mainfest.js 中,修改a.js 的console.log, 重新打包后的文件可以发现chunk.d0962e.js, vendor.98054b.js都没有重新打包

clipboard.png

所以总结来讲就是多入口配置CommonsChunk

    new webpack.optimize.CommonsChunkPlugin({
      name: ['生成的项目公共模块文件名', '第三方模块文件名'],
      minChunks: 2,
    }),
查看原文

赞 8 收藏 7 评论 0

nojsja 收藏了问题 · 3月24日

正则表达式中 ?= 和 ?: 的区别

例如,我们通过一个函数把一个 number 类型的数字转换成一个字符串,并且每三位给他加上一个 ',';
1999 -> 1,999

先看看 (?=pattern) 的使用,下面这个是正确的:

function groupByCommas(n) {
  return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
console.log(groupByCommas(1234567));    //1,234,567

如果我们删掉(\d{3})后面的 '+'的话,全局标志依然还在,但是这个时候,就只能匹配字符串中的部分了。

function groupByCommas(n) {
  return n.toString().replace(/\B(?=(\d{3})(?!\d))/g, ",");
}
console.log(groupByCommas(1234567));    //1234,567

我的看法是这样的:
正则表达式等价于

/\B(?=(\d{3}){1}(?!\d))/g

所以当匹配到匹配项的时候,index 的位置已经到了4与5之间,而前面的正则是通过

/\B(?=(\d{3}){1}(?!\d))/g
/\B(?=(\d{3}){2}(?!\d))/g

匹配。

最后如果我们把 ?= 换成 ?: 的话:

function groupByCommas(n) {
  return n.toString().replace(/\B(?:(\d{3})+(?!\d))/g, ",");
}
console.log(groupByCommas(1234567));    //1,

nojsja 赞了文章 · 3月17日

大型 Web 应用插件化架构探索

简介: 随着 Web 技术的逐渐成熟,越来越多的应用架构趋向于复杂,例如阿里云等巨型控制台项目,每个产品下都有各自的团队来负责维护和迭代。不论是维护还是发布以及管控成本都随着业务体量的增长而逐渐不可控。在这个背景下微前端应用而生,微前端在阿里内部已经有许多成熟的实践,这里不再赘述。本文以微前端为引子(蹭热度),探讨一些另类的 Web 应用所面临的类似问题。

前言

随着 Web 技术的逐渐成熟,越来越多的应用架构趋向于复杂,例如阿里云等巨型控制台项目,每个产品下都有各自的团队来负责维护和迭代。不论是维护还是发布以及管控成本都随着业务体量的增长而逐渐不可控。在这个背景下微前端应用而生,微前端在阿里内部已经有许多成熟的实践,这里不再赘述。本文以微前端为引子,探讨一些另类的 Web 应用所面临的类似问题。

现代文本编辑器沉浮

2018年微软 GitHub 后,Atom便经常被拿来调侃,所谓一山不容二虎。在 VS Code 已经成为一众前端工程师编辑器首选的当下,Atom 的地位显得很尴尬,论性能被同为 Electron 的 VS Code 秒杀,论插件,VS Code 去年插件总数就已经突破 1w 大关,而早发布一年多的 Atom 至今还停留在 8k +。再加上微软官方主导的 LSP/DAP 等重量级协议的普及,时至今日 Atom 作为曾经 Web/Electron 技术标杆应用的地位早已被 VS Code 斩落马下。

网上关于 Atom 的日渐衰落的讨论,始终离不开性能。Atom 的确太慢了,究其原因很大程度上是被其插件架构所拖累的。尤其是 Atom 在 UI 层面开放过多的权限给插件开发者定制,插件质量良萎不齐以及 UI 完全开放给插件后带来的安全隐患都成为 Atom 的阿喀琉斯之踵。甚至其主界面的 FileTree、Tab 栏、Setting Views 等重要组件都是通过插件实现的。相比之下 VS Code 则封闭很多,VS Code 插件完全运行在 Node.js 端,对于 UI 的定制性只有极个别被封装为纯方法调用的 API。

但另一方面,VS Code 这种相对封闭的插件 UI 方案,一些需要更强定制性的功能便无法满足,更多插件开发者开始魔改 VS Code 底层甚至源码来实现定制。例如社区很火的 VS Code Background,这款插件通过强行修改 VS Code 安装文件中的 CSS 来实现编辑器区域的背景图。而另一款 VSC Netease Music 则更激进,因为 VS Code 捆绑包中的 Electron 剔除了 FFmpeg 导致在 Webview 视图下无法播放音视频,使用此插件需要自行替换 FFmpeg 的动态链接库。而这些插件不免会对 VS Code 安装包造成一定程度的破坏,导致用户需要卸载重装。

不止编辑器 - 飞个马

Figma 是一个在线协作式 UI 设计工具, 相比 Sketch 它具有跨平台、实时协作等优点,近年来逐渐受到 UI 设计师们的青睐。而近期 Figma 也正式上线了其插件系统。

作为一个 Web 应用,Figma 的插件系统自然也是基于 JavaScript 构建的,这一定程度上降低了开发门槛。自去年6月份 Figma 官方宣布开放插件系统测试以来,已经有越来越多的 Designner/Developer 开发了300+ 插件,其中包括图形资源、文件归档、甚至是导入 3D 模型等。

Figma 的插件系统是如何工作的?

这是一个基于 TypeScript + React 技术栈,使用 Webpack 构建的 Figma 插件目录结构

.
├── README.md
├── figma.d.ts
├── manifest.json
├── package-lock.json
├── package.json
├── src
│   ├── code.ts
│   ├── logo.svg
│   ├── ui.css
│   ├── ui.html
│   └── ui.tsx
├── tsconfig.json
└── webpack.config.js

在其 manifest.json 文件中包含了一些简单的信息。

{
  "name": "React Sample",
  "id": "738168449509241862",
  "api": "1.0.0",
  "main": "dist/code.js",
  "ui": "dist/ui.html"
}

可以看出 Figma 将插件入口分为了 main 与 ui 两部分, main 中包含了插件实际运行时的逻辑,而 ui 则是一个插件的 HTML 片段。即 UI 与逻辑分离。安装一个Color Search 插件后观察页面结构可以发现 main 中的 js 文件被包裹在一个 iframe 里加载到页面上,关于 main 入口的沙箱机制后文中有详细的阐述。而 ui 中的 HTML 最终也被包裹在一个 iframe 里渲染出来,这将有效的避免插件 UI 层 CSS 代码导致全局样式污染。

Figma Developers 文档中 有一章节 How Plugins Run 对其插件系统运行机制进行了简单的介绍,简单来说 Figma 为插件中逻辑层的 main 入口创建了一个最小的 JavaScript 执行环境,它运行在浏览器主线程上,在这个执行环境中插件代码无法访问到一些浏览器全局的 API,从而也就无法在代码层面对 Figma 本身运行造成影响。而 UI 层有且仅有一份 HTML 代码片段,在插件被激活后被渲染到一个弹窗中。

Figma 官方博客中对其插件的沙箱机制做了详细的阐述。起初他们尝试的方案是 iframe,一个浏览器自带的沙箱环境。将插件代码由 iframe 包裹起来,由于 iframe 天然的限制,这将确保插件代码无法操作 Figma 主界面上下文,同时也可以只开放一份白名单 API 供插件调用。乍一看似乎解决了问题,但由于 iframe 中的插件脚本只能通过 postMessage 与主线程通信,这导致插件中的任何 API 调用都必须被包装为一个异步 async/await 的方法,这无疑对 Figma 的目标用户非专业前端开发者的设计师不够友好。其次对于较大的文档,postMessage 通信序列化的性能成本过高,甚至会导致内存泄漏。

Figma 团队选择回到浏览器主线程,但直接将第三方代码运行在主线程,由此引发的安全问题是不可避免的。最终他们发现了一个尚在 stage2 阶段的草案 Realm API。Realm 旨在创建一个领域对象,用于隔离第三方 JavaScript 作用域的 API。

let g = window; // outer global
let r = new Realm(); // root realm

let f = r.evaluate("(function() { return 17 })");

f() === 17 // true

Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.globalThis.Function.prototype // true

值得注意的是,Realm 同样可以使用 JavaScript 目前已有的特性来实现,即 with 与 Proxy。这也是目前社区比较流行的沙箱方案。

const whitelist = {
  windiw: undefined,
  document: undefined,
  console: window.console,
};

const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    }
    return undefined
  }
});

with (scopeProxy) {
  eval("console.log(document.write)") // Cannot read property 'write' of undefined!
  eval("console.log('hello')")        // hello
}

前文中 Figma 插件被 iframe 所包裹的插件 main 入口即包含了一个被 Realm 接管的作用域,你可以认为是类似这段示例代码中的一份 白名单 API,毕竟维护一份白名单比屏蔽黑名单实现起来更简洁。但事实上由于 JavaScript 的原型式继承,插件仍然可以通过 console.log 方法的原型链访问到外部对象,理想的解决方案是将这些白名单 API 在 Realm 上下文中包装一次,从而彻底隔离原型链。

const safeLogFactory = realm.evaluate(`
  (function safeLogFactory(unsafeLog) { 
    return function safeLog(...args) {
      unsafeLog(...args);
    }
  })
`);

const safeLog = safeLogFactory(console.log);

const outerIntrinsics = safeLog instanceOf Function;
const innerIntrinsics = realm.evaluate(`log instanceOf Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError(); 

realm.evaluate(`log("Hello outside world!")`, { log: safeLog });

显然为每一个白名单中的 API 做这样操作的工作是非常繁杂且容易出错的。那么如何构建一个安全且易于添加 API 的沙箱环境呢?

Duktape 是一个由 C++ 实现的用于嵌入式设备的 JavaScript 解释器,它不支持任何浏览器 API,自然地它可以被编译到 WebAssembly,Figma 团队将 Duktape 嵌入到 Realm 上下文中,插件最终通过 Duktape 解释执行。这样可以安全的实现插件所需 API,且不用担心插件会通过原型链访问到沙箱外部。

这是一种被称为 Membrane Pattern 的防御性的编程模式,用于在程序中与子组件(广义上)实现一层中介。简单来说就是代理(Proxy),为一个对象创建一个可控的访问边界,使得它可以保留一部分特性给第三方嵌入脚本,而屏蔽一部分不希望被访问到的特性。关于 Membrane 的详细论述可以查看 Isolating application sub-components with membranes 与 Membranes in JavaScript 这两篇文章。

这是最终 Figma 的插件方案,它运行在主线程,不需要担心 postMessage 通信带来的传输损耗。多了一次 Duktape 解释执行的消耗,但得益于 WebAssembly 出色的性能,这部分消耗并不是很大。

另外 Figma 还保留了最初的 iframe ,允许插件可以自行创建 iframe ,并在其中插入任意 JavaScript ,同时它可以与沙箱中的 JavaScript 脚本通过 postMessage 相互通信。

鱼和熊掌如何兼得?

我们把这类插件的需求总结为在 Web 应用中运行第三方代码及其自定义控件,它有与开头提到的微前端架构非常相似的一些问题。

  1. 一定程度上的 JavaScript 代码沙箱隔离机制,应用主体对第三方代码(或子应用)有一定的管控能力
  2. 样式强隔离,第三方代码样式不对应用主体产生 CSS 污染

JavaScript 沙箱

JavaScript 沙箱隔离在社区是个经久不衰的话题,最简单的 iframe 标签 Sandbox 属性就已经能做到 JavaScript 运行时的隔离,社区较为流行的是利用一些语言特性(with、realm、Proxy 等 API )屏蔽(或代理) Window、Document 等全局对象,建立白名单机制,对可能潜在危险操作的 API 重写(如阿里云 Console OS - Browser VM)。另外还有 Figma 这种尝试嵌入平台无关的 JavaScript 解释器,所有第三方代码都通过嵌入的解释器来执行。以及利用 Web Worker 做 DOM Diff 计算,并将计算结果发送回 UI 线程来进行渲染,这个方案早在 2013 年就已经有人进行了实践,这篇论文中作者将 JSDOM 这一 Node.js 平台广泛流行的测试库运行在 Web Worker。而近些年来也有 preact-worker-demo 、react-worker-dom 等项目基于 Web Worker 的 DOM Renderer 尝试将 DOM API 代理到 Worker 线程。而 Google AMP Project 在JSCONF 2018 US 对外公布的 worker-dom 则将 DOM API 在 Web Worker 端实现了 DOM API,虽然实践下来还存在一些问题(例如同步方法无法模拟),但 WorkerDOM 在性能和隔离性上都取得了一定成果。

以上这些解决方案被广泛的应用在各种插件化架构的 Web 应用中,但大多都是 Case By Case,每种解决方案都有各自的成本与取舍。

CSS 作用域

CSS 样式隔离方案中,如上文中 Figma 使用 iframe 渲染插件界面,牺牲一部分性能换来了相对完美的样式隔离。而在现代前端工程化体系下,可以通过 CSS Module 在转译时对 class 添加 hash 或 namespace 等方式实现,这类方案较为依赖插件代码编译过程。而更新潮的是利用 Web Component 的 Shadow DOM,将插件元素用 Web Component 包裹起来,Shadow Root 外部样式无法作用于内部,同样 Shadow Root 内部的样式也无法影响到外部。

最后

本文列举了目前编辑器、设计工具这类大型 Web 应用插件化架构下所面临的的一些问题,以及社区实践的解决方案。不论是让人又爱又恨的 iframe ,还是 Realm、Web Worker 、 Shadow DOM 等,目前来说每种方案都有各自的优势与不足。但随着 Web 应用的复杂度增长,插件化这一需求也逐渐被各大标准化组织所重视起来。下一篇将着重介绍 KAITIAN IDE 中插件架构的探索与实践,包括 JavaScript 沙箱、CSS 隔离、Web Worker 等。

作者:开发者小助手_LS
原文链接
本文为阿里云原创内容,未经允许不得转载

查看原文

赞 5 收藏 2 评论 0

nojsja 收藏了文章 · 3月15日

HTTP1.0 HTTP1.1 HTTP2.0 主要特性对比

本篇是对HTTP不同版本主要特性的一个概述和总结。

HTTP1.0

早先1.0HTTP版本,是一种无状态、无连接的应用层协议。

HTTP1.0规定浏览器和服务器保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。

这种无状态性可以借助cookie/session机制来做身份认证和状态记录。而下面两个问题就比较麻烦了。

首先,无连接的特性导致最大的性能缺陷就是无法复用连接。每次发送请求的时候,都需要进行一次TCP的连接,而TCP的连接释放过程又是比较费事的。这种无连接的特性会使得网络的利用率非常低。

其次就是队头阻塞(head of line blocking)。由于HTTP1.0规定下一个请求必须在前一个请求响应到达之前才能发送。假设前一个请求响应一直不到达,那么下一个请求就不发送,同样的后面的请求也给阻塞了。

为了解决这些问题,HTTP1.1出现了。

HTTP1.1

对于HTTP1.1,不仅继承了HTTP1.0简单的特点,还克服了诸多HTTP1.0性能上的问题。

首先是长连接HTTP1.1增加了一个Connection字段,通过设置Keep-Alive可以保持HTTP连接不断开,避免了每次客户端与服务器请求都要重复建立释放建立TCP连接,提高了网络的利用率。如果客户端想关闭HTTP连接,可以在请求头中携带Connection: false来告知服务器关闭请求。

其次,是HTTP1.1支持请求管道化pipelining)。基于HTTP1.1的长连接,使得请求管线化成为可能。管线化使得请求能够“并行”传输。举个例子来说,假如响应的主体是一个html页面,页面中包含了很多img,这个时候keep-alive就起了很大的作用,能够进行“并行”发送多个请求。(注意这里的“并行”并不是真正意义上的并行传输,具体解释如下。)

需要注意的是,服务器必须按照客户端请求的先后顺序依次回送相应的结果,以保证客户端能够区分出每次请求的响应内容。

也就是说,HTTP管道化可以让我们把先进先出队列从客户端(请求队列)迁移到服务端(响应队列)。

如图所示,客户端同时发了两个请求分别来获取htmlcss,假如说服务器的css资源先准备就绪,服务器也会先发送html再发送css

换句话来说,只有等到html响应的资源完全传输完毕后,css响应的资源才能开始传输。也就是说,不允许同时存在两个并行的响应

可见,HTTP1.1还是无法解决队头阻塞(head of line blocking)的问题。同时“管道化”技术存在各种各样的问题,所以很多浏览器要么根本不支持它,要么就直接默认关闭,并且开启的条件很苛刻...而且实际上好像并没有什么用处。

那我们在谷歌控制台看到的并行请求又是怎么一回事呢?

如图所示,绿色部分代表请求发起到服务器响应的一个等待时间,而蓝色部分表示资源的下载时间。按照理论来说,HTTP响应理应当是前一个响应的资源下载完了,下一个响应的资源才能开始下载。而这里却出现了响应资源下载并行的情况。这又是为什么呢?

其实,虽然HTTP1.1支持管道化,但是服务器也必须进行逐个响应的送回,这个是很大的一个缺陷。实际上,现阶段的浏览器厂商采取了另外一种做法,它允许我们打开多个TCP的会话。也就是说,上图我们看到的并行,其实是不同的TCP连接上的HTTP请求和响应。这也就是我们所熟悉的浏览器对同域下并行加载6~8个资源的限制。而这,才是真正的并行

此外,HTTP1.1还加入了缓存处理(强缓存和协商缓存[传送门])新的字段如cache-control,支持断点传输,以及增加了Host字段(使得一个服务器能够用来创建多个Web站点)。

HTTP2.0

HTTP2.0的新特性大致如下:

二进制分帧

HTTP2.0通过在应用层和传输层之间增加一个二进制分帧层,突破了HTTP1.1的性能限制、改进传输性能。

可见,虽然HTTP2.0的协议和HTTP1.x协议之间的规范完全不同了,但是实际上HTTP2.0并没有改变HTTP1.x的语义。
简单来说,HTTP2.0只是把原来HTTP1.xheaderbody部分用frame重新封装了一层而已。

多路复用(连接共享)

下面是几个概念:

  • 流(stream):已建立连接上的双向字节流。
  • 消息:与逻辑消息对应的完整的一系列数据帧。
  • 帧(frame):HTTP2.0通信的最小单位,每个帧包含帧头部,至少也会标识出当前帧所属的流(stream id)。

从图中可见,所有的HTTP2.0通信都在一个TCP连接上完成,这个连接可以承载任意数量的双向数据流。

每个数据流以消息的形式发送,而消息由一或多个帧组成。这些帧可以乱序发送,然后再根据每个帧头部的流标识符(stream id)重新组装。

举个例子,每个请求是一个数据流,数据流以消息的方式发送,而消息又分为多个帧,帧头部记录着stream id用来标识所属的数据流,不同属的帧可以在连接中随机混杂在一起。接收方可以根据stream id将帧再归属到各自不同的请求当中去。

另外,多路复用(连接共享)可能会导致关键请求被阻塞。HTTP2.0里每个数据流都可以设置优先级和依赖,优先级高的数据流会被服务器优先处理和返回给客户端,数据流还可以依赖其他的子数据流。

可见,HTTP2.0实现了真正的并行传输,它能够在一个TCP上进行任意数量HTTP请求。而这个强大的功能则是基于“二进制分帧”的特性。

头部压缩

HTTP1.x中,头部元数据都是以纯文本的形式发送的,通常会给每个请求增加500~800字节的负荷。

比如说cookie,默认情况下,浏览器会在每次请求的时候,把cookie附在header上面发送给服务器。(由于cookie比较大且每次都重复发送,一般不存储信息,只是用来做状态记录和身份认证)

HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。高效的压缩算法可以很大的压缩header,减少发送包的数量从而降低延迟。

服务器推送

服务器除了对最初请求的响应外,服务器还可以额外的向客户端推送资源,而无需客户端明确的请求。

HTTP1.1的合并请求是否适用于HTTP2.0

首先,答案是“没有必要”。之所以没有必要,是因为这跟HTTP2.0的头部压缩有很大的关系。

在头部压缩技术中,客户端和服务器均会维护两份相同的静态字典和动态字典。

在静态字典中,包含了常见的头部名称以及头部名称与值的组合。静态字典在首次请求时就可以使用。那么现在头部的字段就可以被简写成静态字典中相应字段对应的index

而动态字典跟连接的上下文相关,每个HTTP/2连接维护的动态字典是不尽相同的。动态字典可以在连接中不听的进行更新。

也就是说,原本完整的HTTP报文头部的键值对或字段,由于字典的存在,现在可以转换成索引index,在相应的端再进行查找还原,也就起到了压缩的作用。

所以,同一个连接上产生的请求和响应越多,动态字典累积得越全,头部压缩的效果也就越好,所以针对HTTP/2网站,最佳实践是不要合并资源。

另外,HTTP2.0多路复用使得请求可以并行传输,而HTTP1.1合并请求的一个原因也是为了防止过多的HTTP请求带来的阻塞问题。而现在HTTP2.0已经能够并行传输了,所以合并请求也就没有必要了。

总结

HTTP1.0

  • 无状态、无连接

HTTP1.1

  • 持久连接
  • 请求管道化
  • 增加缓存处理(新的字段如cache-control
  • 增加Host字段、支持断点传输等

HTTP2.0

  • 二进制分帧
  • 多路复用(或连接共享)
  • 头部压缩
  • 服务器推送

参考:
https://www.zhihu.com/questio...
https://segmentfault.com/q/10...
http://imweb.io/topic/554c587...
http://web.jobbole.com/85635/

查看原文

nojsja 赞了文章 · 3月15日

HTTP1.0 HTTP1.1 HTTP2.0 主要特性对比

本篇是对HTTP不同版本主要特性的一个概述和总结。

HTTP1.0

早先1.0HTTP版本,是一种无状态、无连接的应用层协议。

HTTP1.0规定浏览器和服务器保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。

这种无状态性可以借助cookie/session机制来做身份认证和状态记录。而下面两个问题就比较麻烦了。

首先,无连接的特性导致最大的性能缺陷就是无法复用连接。每次发送请求的时候,都需要进行一次TCP的连接,而TCP的连接释放过程又是比较费事的。这种无连接的特性会使得网络的利用率非常低。

其次就是队头阻塞(head of line blocking)。由于HTTP1.0规定下一个请求必须在前一个请求响应到达之前才能发送。假设前一个请求响应一直不到达,那么下一个请求就不发送,同样的后面的请求也给阻塞了。

为了解决这些问题,HTTP1.1出现了。

HTTP1.1

对于HTTP1.1,不仅继承了HTTP1.0简单的特点,还克服了诸多HTTP1.0性能上的问题。

首先是长连接HTTP1.1增加了一个Connection字段,通过设置Keep-Alive可以保持HTTP连接不断开,避免了每次客户端与服务器请求都要重复建立释放建立TCP连接,提高了网络的利用率。如果客户端想关闭HTTP连接,可以在请求头中携带Connection: false来告知服务器关闭请求。

其次,是HTTP1.1支持请求管道化pipelining)。基于HTTP1.1的长连接,使得请求管线化成为可能。管线化使得请求能够“并行”传输。举个例子来说,假如响应的主体是一个html页面,页面中包含了很多img,这个时候keep-alive就起了很大的作用,能够进行“并行”发送多个请求。(注意这里的“并行”并不是真正意义上的并行传输,具体解释如下。)

需要注意的是,服务器必须按照客户端请求的先后顺序依次回送相应的结果,以保证客户端能够区分出每次请求的响应内容。

也就是说,HTTP管道化可以让我们把先进先出队列从客户端(请求队列)迁移到服务端(响应队列)。

如图所示,客户端同时发了两个请求分别来获取htmlcss,假如说服务器的css资源先准备就绪,服务器也会先发送html再发送css

换句话来说,只有等到html响应的资源完全传输完毕后,css响应的资源才能开始传输。也就是说,不允许同时存在两个并行的响应

可见,HTTP1.1还是无法解决队头阻塞(head of line blocking)的问题。同时“管道化”技术存在各种各样的问题,所以很多浏览器要么根本不支持它,要么就直接默认关闭,并且开启的条件很苛刻...而且实际上好像并没有什么用处。

那我们在谷歌控制台看到的并行请求又是怎么一回事呢?

如图所示,绿色部分代表请求发起到服务器响应的一个等待时间,而蓝色部分表示资源的下载时间。按照理论来说,HTTP响应理应当是前一个响应的资源下载完了,下一个响应的资源才能开始下载。而这里却出现了响应资源下载并行的情况。这又是为什么呢?

其实,虽然HTTP1.1支持管道化,但是服务器也必须进行逐个响应的送回,这个是很大的一个缺陷。实际上,现阶段的浏览器厂商采取了另外一种做法,它允许我们打开多个TCP的会话。也就是说,上图我们看到的并行,其实是不同的TCP连接上的HTTP请求和响应。这也就是我们所熟悉的浏览器对同域下并行加载6~8个资源的限制。而这,才是真正的并行

此外,HTTP1.1还加入了缓存处理(强缓存和协商缓存[传送门])新的字段如cache-control,支持断点传输,以及增加了Host字段(使得一个服务器能够用来创建多个Web站点)。

HTTP2.0

HTTP2.0的新特性大致如下:

二进制分帧

HTTP2.0通过在应用层和传输层之间增加一个二进制分帧层,突破了HTTP1.1的性能限制、改进传输性能。

可见,虽然HTTP2.0的协议和HTTP1.x协议之间的规范完全不同了,但是实际上HTTP2.0并没有改变HTTP1.x的语义。
简单来说,HTTP2.0只是把原来HTTP1.xheaderbody部分用frame重新封装了一层而已。

多路复用(连接共享)

下面是几个概念:

  • 流(stream):已建立连接上的双向字节流。
  • 消息:与逻辑消息对应的完整的一系列数据帧。
  • 帧(frame):HTTP2.0通信的最小单位,每个帧包含帧头部,至少也会标识出当前帧所属的流(stream id)。

从图中可见,所有的HTTP2.0通信都在一个TCP连接上完成,这个连接可以承载任意数量的双向数据流。

每个数据流以消息的形式发送,而消息由一或多个帧组成。这些帧可以乱序发送,然后再根据每个帧头部的流标识符(stream id)重新组装。

举个例子,每个请求是一个数据流,数据流以消息的方式发送,而消息又分为多个帧,帧头部记录着stream id用来标识所属的数据流,不同属的帧可以在连接中随机混杂在一起。接收方可以根据stream id将帧再归属到各自不同的请求当中去。

另外,多路复用(连接共享)可能会导致关键请求被阻塞。HTTP2.0里每个数据流都可以设置优先级和依赖,优先级高的数据流会被服务器优先处理和返回给客户端,数据流还可以依赖其他的子数据流。

可见,HTTP2.0实现了真正的并行传输,它能够在一个TCP上进行任意数量HTTP请求。而这个强大的功能则是基于“二进制分帧”的特性。

头部压缩

HTTP1.x中,头部元数据都是以纯文本的形式发送的,通常会给每个请求增加500~800字节的负荷。

比如说cookie,默认情况下,浏览器会在每次请求的时候,把cookie附在header上面发送给服务器。(由于cookie比较大且每次都重复发送,一般不存储信息,只是用来做状态记录和身份认证)

HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。高效的压缩算法可以很大的压缩header,减少发送包的数量从而降低延迟。

服务器推送

服务器除了对最初请求的响应外,服务器还可以额外的向客户端推送资源,而无需客户端明确的请求。

HTTP1.1的合并请求是否适用于HTTP2.0

首先,答案是“没有必要”。之所以没有必要,是因为这跟HTTP2.0的头部压缩有很大的关系。

在头部压缩技术中,客户端和服务器均会维护两份相同的静态字典和动态字典。

在静态字典中,包含了常见的头部名称以及头部名称与值的组合。静态字典在首次请求时就可以使用。那么现在头部的字段就可以被简写成静态字典中相应字段对应的index

而动态字典跟连接的上下文相关,每个HTTP/2连接维护的动态字典是不尽相同的。动态字典可以在连接中不听的进行更新。

也就是说,原本完整的HTTP报文头部的键值对或字段,由于字典的存在,现在可以转换成索引index,在相应的端再进行查找还原,也就起到了压缩的作用。

所以,同一个连接上产生的请求和响应越多,动态字典累积得越全,头部压缩的效果也就越好,所以针对HTTP/2网站,最佳实践是不要合并资源。

另外,HTTP2.0多路复用使得请求可以并行传输,而HTTP1.1合并请求的一个原因也是为了防止过多的HTTP请求带来的阻塞问题。而现在HTTP2.0已经能够并行传输了,所以合并请求也就没有必要了。

总结

HTTP1.0

  • 无状态、无连接

HTTP1.1

  • 持久连接
  • 请求管道化
  • 增加缓存处理(新的字段如cache-control
  • 增加Host字段、支持断点传输等

HTTP2.0

  • 二进制分帧
  • 多路复用(或连接共享)
  • 头部压缩
  • 服务器推送

参考:
https://www.zhihu.com/questio...
https://segmentfault.com/q/10...
http://imweb.io/topic/554c587...
http://web.jobbole.com/85635/

查看原文

赞 34 收藏 30 评论 0

nojsja 赞了文章 · 3月15日

前端常见跨域解决方案(全)

什么是跨域?

跨域是指一个域下的文档或脚本试图去请求另一个域下的资源,这里跨域是广义的。

广义的跨域:

1.) 资源跳转: A链接、重定向、表单提交
2.) 资源嵌入: <link>、<script>、<img>、<frame>等dom标签,还有样式中background:url()、@font-face()等文件外链
3.) 脚本请求: js发起的ajax请求、dom和js对象的跨域操作等

其实我们通常所说的跨域是狭义的,是由浏览器同源策略限制的一类请求场景。

什么是同源策略?
同源策略/SOP(Same origin policy)是一种约定,由Netscape公司1995年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。

同源策略限制以下几种行为:

1.) Cookie、LocalStorage 和 IndexDB 无法读取
2.) DOM 和 Js对象无法获得
3.) AJAX 请求不能发送

常见跨域场景

URL                                      说明                    是否允许通信
http://www.domain.com/a.js
http://www.domain.com/b.js         同一域名,不同文件或路径           允许
http://www.domain.com/lab/c.js

http://www.domain.com:8000/a.js
http://www.domain.com/b.js         同一域名,不同端口                不允许
 
http://www.domain.com/a.js
https://www.domain.com/b.js        同一域名,不同协议                不允许
 
http://www.domain.com/a.js
http://192.168.4.12/b.js           域名和域名对应相同ip              不允许
 
http://www.domain.com/a.js
http://x.domain.com/b.js           主域相同,子域不同                不允许
http://domain.com/c.js
 
http://www.domain1.com/a.js
http://www.domain2.com/b.js        不同域名                         不允许

跨域解决方案

1、 通过jsonp跨域
2、 document.domain + iframe跨域
3、 location.hash + iframe
4、 window.name + iframe跨域
5、 postMessage跨域
6、 跨域资源共享(CORS)
7、 nginx代理跨域
8、 nodejs中间件代理跨域
9、 WebSocket协议跨域

一、 通过jsonp跨域

通常为了减轻web服务器的负载,我们把js、css,img等静态资源分离到另一台独立域名的服务器上,在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许,基于此原理,我们可以通过动态创建script,再请求一个带参网址实现跨域通信。

1.)原生实现:

 <script>
    var script = document.createElement('script');
    script.type = 'text/javascript';

    // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
    script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback';
    document.head.appendChild(script);

    // 回调执行函数
    function handleCallback(res) {
        alert(JSON.stringify(res));
    }
 </script>

服务端返回如下(返回时即执行全局函数):

handleCallback({"status": true, "user": "admin"})

2.)jquery ajax:

$.ajax({
    url: 'http://www.domain2.com:8080/login',
    type: 'get',
    dataType: 'jsonp',  // 请求方式为jsonp
    jsonpCallback: "handleCallback",    // 自定义回调函数名
    data: {}
});

3.)vue.js:

this.$http.jsonp('http://www.domain2.com:8080/login', {
    params: {},
    jsonp: 'handleCallback'
}).then((res) => {
    console.log(res); 
})

后端node.js代码示例:

var querystring = require('querystring');
var http = require('http');
var server = http.createServer();

server.on('request', function(req, res) {
    var params = qs.parse(req.url.split('?')[1]);
    var fn = params.callback;

    // jsonp返回设置
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    res.write(fn + '(' + JSON.stringify(params) + ')');

    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

jsonp缺点:只能实现get一种请求。

二、 document.domain + iframe跨域

此方案仅限主域相同,子域不同的跨域应用场景。

实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。

1.)父窗口:(http://www.domain.com/a.html)

<iframe id="iframe" data-original="http://child.domain.com/b.html"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'admin';
</script>

2.)子窗口:(http://child.domain.com/b.html)

<script>
    document.domain = 'domain.com';
    // 获取父窗口中变量
    alert('get js data from parent ---> ' + window.parent.user);
</script>

三、 location.hash + iframe跨域

实现原理: a欲与b跨域相互通信,通过中间页c来实现。 三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。

具体实现:A域:a.html -> B域:b.html -> A域:c.html,a与b不同域只能通过hash值单向通信,b与c也不同域也只能单向通信,但c与a同域,所以c可通过parent.parent访问a页面所有对象。

1.)a.html:(http://www.domain1.com/a.html)

<iframe id="iframe" data-original="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');

    // 向b.html传hash值
    setTimeout(function() {
        iframe.src = iframe.src + '#user=admin';
    }, 1000);
    
    // 开放给同域c.html的回调方法
    function onCallback(res) {
        alert('data from c.html ---> ' + res);
    }
</script>

2.)b.html:(http://www.domain2.com/b.html)

<iframe id="iframe" data-original="http://www.domain1.com/c.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');

    // 监听a.html传来的hash值,再传给c.html
    window.onhashchange = function () {
        iframe.src = iframe.src + location.hash;
    };
</script>

3.)c.html:(http://www.domain1.com/c.html)

<script>
    // 监听b.html传来的hash值
    window.onhashchange = function () {
        // 再通过操作同域a.html的js回调,将结果传回
        window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', ''));
    };
</script>

四、 window.name + iframe跨域

window.name属性的独特之处:name值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

1.)a.html:(http://www.domain1.com/a.html)

var proxy = function(url, callback) {
    var state = 0;
    var iframe = document.createElement('iframe');

    // 加载跨域页面
    iframe.src = url;

    // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
    iframe.onload = function() {
        if (state === 1) {
            // 第2次onload(同域proxy页)成功后,读取同域window.name中数据
            callback(iframe.contentWindow.name);
            destoryFrame();

        } else if (state === 0) {
            // 第1次onload(跨域页)成功后,切换到同域代理页面
            iframe.contentWindow.location = 'http://www.domain1.com/proxy.html';
            state = 1;
        }
    };

    document.body.appendChild(iframe);

    // 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问)
    function destoryFrame() {
        iframe.contentWindow.document.write('');
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
    }
};

// 请求跨域b页面数据
proxy('http://www.domain2.com/b.html', function(data){
    alert(data);
});

2.)proxy.html:(http://www.domain1.com/proxy....
中间代理页,与a.html同域,内容为空即可。

3.)b.html:(http://www.domain2.com/b.html)

<script>
    window.name = 'This is domain2 data!';
</script>

总结:通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

五、 postMessage跨域

postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,它可用于解决以下方面的问题:
a.) 页面和其打开的新窗口的数据传递
b.) 多窗口之间消息传递
c.) 页面与嵌套的iframe消息传递
d.) 上面三个场景的跨域数据传递

用法:postMessage(data,origin)方法接受两个参数
data: html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。
origin: 协议+主机+端口号,也可以设置为"*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"。

1.)a.html:(http://www.domain1.com/a.html)

<iframe id="iframe" data-original="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>       
    var iframe = document.getElementById('iframe');
    iframe.onload = function() {
        var data = {
            name: 'aym'
        };
        // 向domain2传送跨域数据
        iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
    };

    // 接受domain2返回数据
    window.addEventListener('message', function(e) {
        alert('data from domain2 ---> ' + e.data);
    }, false);
</script>

2.)b.html:(http://www.domain2.com/b.html)

<script>
    // 接收domain1的数据
    window.addEventListener('message', function(e) {
        alert('data from domain1 ---> ' + e.data);

        var data = JSON.parse(e.data);
        if (data) {
            data.number = 16;

            // 处理后再发回domain1
            window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
        }
    }, false);
</script>

六、 跨域资源共享(CORS)

普通跨域请求:只服务端设置Access-Control-Allow-Origin即可,前端无须设置,若要带cookie请求:前后端都需要设置。

需注意的是:由于同源策略的限制,所读取的cookie为跨域请求接口所在域的cookie,而非当前页。如果想实现当前页cookie的写入,可参考下文:七、nginx反向代理中设置proxy_cookie_domain 和 八、NodeJs中间件代理中cookieDomainRewrite参数的设置。

目前,所有浏览器都支持该功能(IE8+:IE8/9需要使用XDomainRequest对象来支持CORS)),CORS也已经成为主流的跨域解决方案。

1、 前端设置:

1.)原生ajax

// 前端设置是否带cookie
xhr.withCredentials = true;

示例代码:

var xhr = new XMLHttpRequest(); // IE8/9需用window.XDomainRequest兼容

// 前端设置是否带cookie
xhr.withCredentials = true;

xhr.open('post', 'http://www.domain2.com:8080/login', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('user=admin');

xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
        alert(xhr.responseText);
    }
};

2.)jQuery ajax

$.ajax({
    ...
   xhrFields: {
       withCredentials: true    // 前端设置是否带cookie
   },
   crossDomain: true,   // 会让请求头中包含跨域的额外信息,但不会含cookie
    ...
});

3.)vue框架

a.) axios设置:

axios.defaults.withCredentials = true

b.) vue-resource设置:

Vue.http.options.credentials = true
2、 服务端设置:

若后端设置成功,前端浏览器控制台则不会出现跨域报错信息,反之,说明没设成功。

1.)Java后台:

/*
 * 导入包:import javax.servlet.http.HttpServletResponse;
 * 接口参数中定义:HttpServletResponse response
 */

// 允许跨域访问的域名:若有端口需写全(协议+域名+端口),若没有端口末尾不用加'/'
response.setHeader("Access-Control-Allow-Origin", "http://www.domain1.com"); 

// 允许前端带认证cookie:启用此项后,上面的域名不能为'*',必须指定具体的域名,否则浏览器会提示
response.setHeader("Access-Control-Allow-Credentials", "true"); 

// 提示OPTIONS预检时,后端需要设置的两个常用自定义头
response.setHeader("Access-Control-Allow-Headers", "Content-Type,X-Requested-With");

2.)Nodejs后台示例:

var http = require('http');
var server = http.createServer();
var qs = require('querystring');

server.on('request', function(req, res) {
    var postData = '';

    // 数据块接收中
    req.addListener('data', function(chunk) {
        postData += chunk;
    });

    // 数据接收完毕
    req.addListener('end', function() {
        postData = qs.parse(postData);

        // 跨域后台设置
        res.writeHead(200, {
            'Access-Control-Allow-Credentials': 'true',     // 后端允许发送Cookie
            'Access-Control-Allow-Origin': 'http://www.domain1.com',    // 允许访问的域(协议+域名+端口)
            /* 
             * 此处设置的cookie还是domain2的而非domain1,因为后端也不能跨域写cookie(nginx反向代理可以实现),
             * 但只要domain2中写入一次cookie认证,后面的跨域接口都能从domain2中获取cookie,从而实现所有的接口都能跨域访问
             */
            'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly'  // HttpOnly的作用是让js无法读取cookie
        });

        res.write(JSON.stringify(postData));
        res.end();
    });
});

server.listen('8080');
console.log('Server is running at port 8080...');

七、 nginx代理跨域

1、 nginx配置解决iconfont跨域

浏览器跨域访问js、css、img等常规静态资源被同源策略许可,但iconfont字体文件(eot|otf|ttf|woff|svg)例外,此时可在nginx的静态资源服务器中加入以下配置。

location / {
  add_header Access-Control-Allow-Origin *;
}
2、 nginx反向代理接口跨域

跨域原理: 同源策略是浏览器的安全策略,不是HTTP协议的一部分。服务器端调用HTTP接口只是使用HTTP协议,不会执行JS脚本,不需要同源策略,也就不存在跨越问题。

实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。

nginx具体配置:

#proxy服务器
server {
    listen       81;
    server_name  www.domain1.com;

    location / {
        proxy_pass   http://www.domain2.com:8080;  #反向代理
        proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
        index  index.html index.htm;

        # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.domain1.com;  #当前端只跨域不带cookie时,可为*
        add_header Access-Control-Allow-Credentials true;
    }
}

1.) 前端代码示例:

var xhr = new XMLHttpRequest();

// 前端开关:浏览器是否读写cookie
xhr.withCredentials = true;

// 访问nginx中的代理服务器
xhr.open('get', 'http://www.domain1.com:81/?user=admin', true);
xhr.send();

2.) Nodejs后台示例:

var http = require('http');
var server = http.createServer();
var qs = require('querystring');

server.on('request', function(req, res) {
    var params = qs.parse(req.url.substring(2));

    // 向前台写cookie
    res.writeHead(200, {
        'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly'   // HttpOnly:脚本无法读取
    });

    res.write(JSON.stringify(params));
    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

八、 Nodejs中间件代理跨域

node中间件实现跨域代理,原理大致与nginx相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。

1、 非vue框架的跨域(2次跨域)

利用node + express + http-proxy-middleware搭建一个proxy服务器。

1.)前端代码示例:

var xhr = new XMLHttpRequest();

// 前端开关:浏览器是否读写cookie
xhr.withCredentials = true;

// 访问http-proxy-middleware代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();

2.)中间件服务器:

var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();

app.use('/', proxy({
    // 代理跨域目标接口
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,

    // 修改响应头信息,实现跨域并允许带cookie
    onProxyRes: function(proxyRes, req, res) {
        res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
        res.header('Access-Control-Allow-Credentials', 'true');
    },

    // 修改响应信息中的cookie域名
    cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
}));

app.listen(3000);
console.log('Proxy server is listen at port 3000...');

3.)Nodejs后台同(六:nginx)

2、 vue框架的跨域(1次跨域)

利用node + webpack + webpack-dev-server代理接口跨域。在开发环境下,由于vue渲染服务和接口代理服务都是webpack-dev-server同一个,所以页面与代理接口之间不再跨域,无须设置headers跨域信息了。

webpack.config.js部分配置:

module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.domain2.com:8080',  // 代理跨域目标接口
            changeOrigin: true,
            secure: false,  // 当代理某些https服务报错时用
            cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
        }],
        noInfo: true
    }
}

九、 WebSocket协议跨域

WebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是server push技术的一种很好的实现。
原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。

1.)前端代码:

<div>user input:<input type="text"></div>
<script data-original="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
<script>
var socket = io('http://www.domain2.com:8080');

// 连接成功处理
socket.on('connect', function() {
    // 监听服务端消息
    socket.on('message', function(msg) {
        console.log('data from server: ---> ' + msg); 
    });

    // 监听服务端关闭
    socket.on('disconnect', function() { 
        console.log('Server socket has closed.'); 
    });
});

document.getElementsByTagName('input')[0].onblur = function() {
    socket.send(this.value);
};
</script>

2.)Nodejs socket后台:

var http = require('http');
var socket = require('socket.io');

// 启http服务
var server = http.createServer(function(req, res) {
    res.writeHead(200, {
        'Content-type': 'text/html'
    });
    res.end();
});

server.listen('8080');
console.log('Server is running at port 8080...');

// 监听socket连接
socket.listen(server).on('connection', function(client) {
    // 接收信息
    client.on('message', function(msg) {
        client.send('hello:' + msg);
        console.log('data from client: ---> ' + msg);
    });

    // 断开处理
    client.on('disconnect', function() {
        console.log('Client socket has closed.'); 
    });
});
查看原文

赞 1464 收藏 2512 评论 59

nojsja 赞了文章 · 3月6日

Node.js child_process模块解读

在介绍child_process模块之前,先来看一个下面的代码。

const http = require('http');
const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
  if (req.url === '/compute') {
    const sum = longComputation();
    return res.end(`Sum is ${sum}`);
  } else {
    res.end('Ok')
  }
});

server.listen(3000);

可以试一下使用上面的代码启动Node.js服务,然后打开两个浏览器选项卡分别访问/compute和/,可以发现node服务接收到/compute请求时会进行大量的数值计算,导致无法响应其他的请求(/)。

在Java语言中可以通过多线程的方式来解决上述的问题,但是Node.js在代码执行的时候是单线程的,那么Node.js应该如何解决上面的问题呢?其实Node.js可以创建一个子进程执行密集的cpu计算任务(例如上面例子中的longComputation)来解决问题,而child_process模块正是用来创建子进程的。

创建子进程的方式

child_process提供了几种创建子进程的方式

  • 异步方式:spawn、exec、execFile、fork
  • 同步方式:spawnSync、execSync、execFileSync

首先介绍一下spawn方法

child_process.spawn(command[, args][, options])

command: 要执行的指令
args:    传递参数
options: 配置项
const { spawn } = require('child_process');
const child = spawn('pwd');

pwd是shell的命令,用于获取当前的目录,上面的代码执行完控制台并没有任何的信息输出,这是为什么呢?

控制台之所以不能看到输出信息的原因是由于子进程有自己的stdio流(stdin、stdout、stderr),控制台的输出是与当前进程的stdio绑定的,因此如果希望看到输出信息,可以通过在子进程的stdout 与当前进程的stdout之间建立管道实现

child.stdout.pipe(process.stdout);

也可以监听事件的方式(子进程的stdio流都是实现了EventEmitter API的,所以可以添加事件监听)

child.stdout.on('data', function(data) {
  process.stdout.write(data);
});

在Node.js代码里使用的console.log其实底层依赖的就是process.stdout

除了建立管道之外,还可以通过子进程和当前进程共用stdio的方式来实现

const { spawn } = require('child_process');
const child = spawn('pwd', {
  stdio: 'inherit'
});

stdio选项用于配置父进程和子进程之间建立的管道,由于stdio管道有三个(stdin, stdout, stderr)因此stdio的三个可能的值其实是数组的一种简写

  • pipe 相当于['pipe', 'pipe', 'pipe'](默认值)
  • ignore 相当于['ignore', 'ignore', 'ignore']
  • inherit 相当于[process.stdin, process.stdout, process.stderr]

由于inherit方式使得子进程直接使用父进程的stdio,因此可以看到输出

ignore用于忽略子进程的输出(将/dev/null指定为子进程的文件描述符了),因此当ignore时child.stdout是null。

spawn默认情况下并不会创建子shell来执行命令,因此下面的代码会报错

const { spawn } = require('child_process');
const child = spawn('ls -l');
child.stdout.pipe(process.stdout);

// 报错
events.js:167
      throw er; // Unhandled 'error' event
      ^

Error: spawn ls -l ENOENT
    at Process.ChildProcess._handle.onexit (internal/child_process.js:229:19)
    at onErrorNT (internal/child_process.js:406:16)
    at process._tickCallback (internal/process/next_tick.js:63:19)
    at Function.Module.runMain (internal/modules/cjs/loader.js:746:11)
    at startup (internal/bootstrap/node.js:238:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)
Emitted 'error' event at:
    at Process.ChildProcess._handle.onexit (internal/child_process.js:235:12)
    at onErrorNT (internal/child_process.js:406:16)
    [... lines matching original stack trace ...]
    at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)

如果需要传递参数的话,应该采用数组的方式传入

const { spawn } = require('child_process');
const child = spawn('ls', ['-l']);
child.stdout.pipe(process.stdout);

如果要执行ls -l | wc -l命令的话可以采用创建两个spawn命令的方式

const { spawn } = require('child_process');
const child = spawn('ls', ['-l']);
const child2 = spawn('wc', ['-l']);
child.stdout.pipe(child2.stdin);
child2.stdout.pipe(process.stdout);

也可以使用exec

const { exec } = require('child_process');
exec('ls -l | wc -l', function(err, stdout, stderr) {
  console.log(stdout);
});

由于exec会创建子shell,所以可以直接执行shell管道命令。spawn采用流的方式来输出命令的执行结果,而exec也是将命令的执行结果缓存起来统一放在回调函数的参数里面,因此exec只适用于命令执行结果数据小的情况。

其实spawn也可以通过配置shell option的方式来创建子shell进而支持管道命令,如下所示

const { spawn, execFile } = require('child_process');
const child = spawn('ls -l | wc -l', {
  shell: true
});
child.stdout.pipe(process.stdout);

配置项除了stdio、shell之外还有cwd、env、detached等常用的选项

cwd用于修改命令的执行目录

const { spawn, execFile, fork } = require('child_process');
const child = spawn('ls -l | wc -l', {
  shell: true,
  cwd: '/usr'
});
child.stdout.pipe(process.stdout);

env用于指定子进程的环境变量(如果不指定的话,默认获取当前进程的环境变量)

const { spawn, execFile, fork } = require('child_process');
const child = spawn('echo $NODE_ENV', {
  shell: true,
  cwd: '/usr'
});
child.stdout.pipe(process.stdout);
NODE_ENV=randal node b.js

// 输出结果
randal

如果指定env的话就会覆盖掉默认的环境变量,如下

const { spawn, execFile, fork } = require('child_process');
spawn('echo $NODE_TEST $NODE_ENV', {
  shell: true,
  stdio: 'inherit',
  cwd: '/usr',
  env: {
    NODE_TEST: 'randal-env'
  }
});

NODE_ENV=randal node b.js

// 输出结果
randal

detached用于将子进程与父进程断开连接

例如假设存在一个长时间运行的子进程

// timer.js
while(true) {

}

但是主进程并不需要长时间运行的话就可以用detached来断开二者之间的连接

const { spawn, execFile, fork } = require('child_process');
const child = spawn('node', ['timer.js'], {
  detached: true,
  stdio: 'ignore'
});
child.unref();

当调用子进程的unref方法时,同时配置子进程的stdio为ignore时,父进程就可以独立退出了

execFile与exec不同,execFile通常用于执行文件,而且并不会创建子shell环境

fork方法是spawn方法的一个特例,fork用于执行js文件创建Node.js子进程。而且fork方式创建的子进程与父进程之间建立了IPC通信管道,因此子进程和父进程之间可以通过send的方式发送消息。

注意:fork方式创建的子进程与父进程是完全独立的,它拥有单独的内存,单独的V8实例,因此并不推荐创建很多的Node.js子进程

fork方式的父子进程之间的通信参照下面的例子

parent.js

const { fork } = require('child_process');

const forked = fork('child.js');

forked.on('message', (msg) => {
  console.log('Message from child', msg);
});

forked.send({ hello: 'world' });

child.js

process.on('message', (msg) => {
  console.log('Message from parent:', msg);
});

let counter = 0;

setInterval(() => {
  process.send({ counter: counter++ });
}, 1000);
node parent.js

// 输出结果
Message from parent: { hello: 'world' }
Message from child { counter: 0 }
Message from child { counter: 1 }
Message from child { counter: 2 }
Message from child { counter: 3 }
Message from child { counter: 4 }
Message from child { counter: 5 }
Message from child { counter: 6 }

回到本文初的那个问题,我们就可以将密集计算的逻辑放到单独的js文件中,然后再通过fork的方式来计算,等计算完成时再通知主进程计算结果,这样避免主进程繁忙的情况了。

compute.js

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};

process.on('message', (msg) => {
  const sum = longComputation();
  process.send(sum);
});

index.js

const http = require('http');
const { fork } = require('child_process');

const server = http.createServer();

server.on('request', (req, res) => {
  if (req.url === '/compute') {
    const compute = fork('compute.js');
    compute.send('start');
    compute.on('message', sum => {
      res.end(`Sum is ${sum}`);
    });
  } else {
    res.end('Ok')
  }
});

server.listen(3000);

监听进程事件

通过前述几种方式创建的子进程都实现了EventEmitter,因此可以针对进程进行事件监听

常用的事件包括几种:close、exit、error、message

close事件当子进程的stdio流关闭的时候才会触发,并不是子进程exit的时候close事件就一定会触发,因为多个子进程可以共用相同的stdio。

close与exit事件的回调函数有两个参数code和signal,code代码子进程最终的退出码,如果子进程是由于接收到signal信号终止的话,signal会记录子进程接受的signal值。

先看一个正常退出的例子

const { spawn, exec, execFile, fork } = require('child_process');
const child = exec('ls -l', {
  timeout: 300
});
child.on('exit', function(code, signal) {
  console.log(code);
  console.log(signal);
});

// 输出结果
0
null

再看一个因为接收到signal而终止的例子,应用之前的timer文件,使用exec执行的时候并指定timeout

const { spawn, exec, execFile, fork } = require('child_process');
const child = exec('node timer.js', {
  timeout: 300
});
child.on('exit', function(code, signal) {
  console.log(code);
  console.log(signal);
});
// 输出结果
null
SIGTERM

注意:由于timeout超时的时候error事件并不会触发,并且当error事件触发时exit事件并不一定会被触发

error事件的触发条件有以下几种:

  • 无法创建进程
  • 无法结束进程
  • 给进程发送消息失败

注意当代码执行出错的时候,error事件并不会触发,exit事件会触发,code为非0的异常退出码

const { spawn, exec, execFile, fork } = require('child_process');
const child = exec('ls -l /usrs');
child.on('error', function(code, signal) {
  console.log(code);
  console.log(signal);
});
child.on('exit', function(code, signal) {
  console.log('exit');
  console.log(code);
  console.log(signal);
});

// 输出结果
exit
1
null

message事件适用于父子进程之间建立IPC通信管道的时候的信息传递,传递的过程中会经历序列化与反序列化的步骤,因此最终接收到的并不一定与发送的数据相一致。

sub.js

process.send({ foo: 'bar', baz: NaN });
const cp = require('child_process');
const n = cp.fork(`${__dirname}/sub.js`);

n.on('message', (m) => {
  console.log('got message:', m);   // got message: { foo: 'bar', baz: null }
});

关于message有一种特殊情况要注意,下面的message并不会被子进程接收到

const { fork } = require('child_process');

const forked = fork('child.js');

forked.send({
  cmd: "NODE_foo",
  hello: 'world'
});

当发送的消息里面包含cmd属性,并且属性的值是以NODE_开头的话,这样的消息是提供给Node.js本身保留使用的,因此并不会发出message事件,而是会发出internalMessage事件,开发者应该避免这种类型的消息,并且应当避免监听internalMessage事件。

message除了发送字符串、object之外还支持发送server对象和socket对象,正因为支持socket对象才可以做到多个Node.js进程监听相同的端口号。

未完待续......

参考资料

https://medium.freecodecamp.o...

https://nodejs.org/dist/lates...

查看原文

赞 3 收藏 1 评论 0

nojsja 赞了文章 · 3月4日

可靠React组件设计的7个准则之封装

翻译:刘小夕

原文链接:https://dmitripavlutin.com/7-...

原文的篇幅非常长,不过内容太过于吸引我,还是忍不住要翻译出来。此篇文章对编写可重用和可维护的React组件非常有帮助。但因为篇幅实在太长,我对文章进行了分割,本篇文章重点阐述 封装。因本人水平有限,文中部分翻译可能不够准确,如果您有更好的想法,欢迎在评论区指出。

更多文章可戳:https://github.com/YvetteLau/...

———————————————我是一条分割线————————————————

封装

一个封装组件提供 props 控制其行为而不是暴露其内部结构。

耦合是决定组件之间依赖程度的系统特性。根据组件的依赖程度,可区分两种耦合类型:

  • 当应用程序组件对其他组件知之甚少或一无所知时,就会发生松耦合。
  • 当应用程序组件知道彼此的许多详细信息时,就会发生紧耦合。

松耦合是我们设计应用结构和组件之间关系的目标。

松耦合应用(封装组件)

clipboard.png

松耦合会带来以下好处:

  • 可以在不影响应用其它部分的情况下对某一块进行修改。、
  • 任何组件都可以替换为另一种实现
  • 在整个应用程序中实现组件复用,从而避免重复代码
  • 独立组件更容易测试,增加了测试覆盖率

相反,紧耦合的系统会失去上面描述的好处。主要缺点是很难修改高度依赖于其他组件的组件。即使是一处修改,也可能导致一系列的依赖组件需要修改。

紧耦合应用(组件无封装)

clipboard.png

封装信息隐藏 是如何设计组件的基本原则,也是松耦合的关键。

信息隐藏

封装良好的组件隐藏其内部结构,并提供一组属性来控制其行为。

隐藏内部结构是必要的。其他组件没必要知道或也不依赖组件的内部结构或实现细节。

React 组件可能是函数组件或类组件、定义实例方法、设置 ref、拥有 state 或使用生命周期方法。这些实现细节被封装在组件内部,其他组件不应该知道这些细节。

隐藏内部结构的组件彼此之间的依赖性较小,而降低依赖度会带来松耦合的好处。

通信

细节隐藏是隔离组件的关键。此时,你需要一种组件通信的方法:propsporps 是组件的输入。

建议 prop 的类型为基本数据(例如,stringnumberboolean):

<Message text="Hello world!" modal={false} />;

必要时,使用复杂的数据结构,如对象或数组:

<MoviesList items={['Batman Begins', 'Blade Runner']} />

prop 可以是一个事件处理函数和异步函数:

<input type="text" onChange={handleChange} />

prop 甚至可以是一个组件构造函数。组件可以处理其他组件的实例化:

function If({ component: Component, condition }) {
    return condition ? <Component /> : null;
}
<If condition={false} component={LazyComponent} />  

为了避免破坏封装,请注意通过 props 传递的内容。给子组件设置 props 的父组件不应该暴露其内部结构的任何细节。例如,使用 props 传输整个组件实例或 refs 都是一个不好的做法。

访问全局变量同样也会对封装产生负面影响。

案例研究:封装修复

组件的实例和状态对象是封装在组件内部的实现细节。因此,将状态管理的父组件实例传递给子组件会破坏封装。

我们来研究一下这种情况。

一个简单的应用程序显示一个数字和两个按钮。第一个按钮增加数值,第二个按钮减少数值:

clipboard.png

这个应用由两个组件组成:<App><Controls>.

number<App>state 对象,<App> 负责 将这个数字渲染到页面。

// 问题: 封装被破坏
class App extends Component {
    constructor(props) {
        super(props);
        this.state = { number: 0 };
    }

    render() {
        return (
            <div className="app">
                <span className="number">{this.state.number}</span>
                <Controls parent={this} />
            </div>
        );
    }
}

<Controls> 负责渲染按钮,并为其设置事件处理函数,当用户点击按钮时,父组件的状态将会被更新:number 加1或者减1((updateNumber()方法`)

// 问题: 使用父组件的内部结构
class Controls extends Component {
    render() {
        return (
            <div className="controls">
                <button onClick={() => this.updateNumber(+1)}>
                    Increase
          </button>
                <button onClick={() => this.updateNumber(-1)}>
                    Decrease
          </button>
            </div>
        );
    }

    updateNumber(toAdd) {
        this.props.parent.setState(prevState => ({
            number: prevState.number + toAdd
        }));
    }
}

当前的实现有什么问题?

  • 第一个问题是: <App> 的封装被破坏,因为它的内部结构在应用中传递。<App> 错误地允许 <Controls> 直接去修改其 state
  • 第二个问题是: 子组件 Controls 知道了太多父组件 <App> 的内部细节,它可以访问父组件的实例,知道父组件是一个有状态组件,知道父组件的 state 对象的细节(知道 number 是父组件 state 的属性),并且知道怎么去更新父组件的 state.

一个麻烦的结果是: <Controls> 将很难测试和重用。对 <App> 结构的细微修改会导致需要对 <Controls> 进行修改(对于更大的应用程序,也会导致类似耦合的组件需要修改)。

解决方案是设计一个方便的通信接口,考虑到松耦合和封装。让我们改进两个组件的结构和属性,以便恢复封装。

只有组件本身应该知道它的状态结构。<App> 的状态管理应该从 <Controls>updateNumber()方法)移到正确的位置:即 <App> 组件中。

<App> 被修改为 <Controls> 设置属性 onIncreaseonDecrease。这些是更新 <App> 状态的回调函数:

// 解决: 恢复封装
class App extends Component {
    constructor(props) {
        super(props);
        this.state = { number: 0 };
    }

    render() {
        return (
            <div className="app">
                <span className="number">{this.state.number}</span>
                <Controls
                    onIncrease={() => this.updateNumber(+1)}
                    onDecrease={() => this.updateNumber(-1)}
                />
            </div>
        );
    }

    updateNumber(toAdd) {
        this.setState(prevState => ({
            number: prevState.number + toAdd
        }));
    }
}

现在,<Controls> 接收用于增加和减少数值的回调,注意解耦和封装恢复时:<Controls> 不再需要访问父组件实例。也不会直接去修改父组件的状态。

而且,<Controls> 被修改为了一个函数式组件:

// 解决方案: 使用回调函数去更新父组件的状态
function Controls({ onIncrease, onDecrease }) {
    return (
        <div className="controls">
            <button onClick={onIncrease}>Increase</button>
            <button onClick={onDecrease}>Decrease</button>
        </div>
    );
}

<App> 组件的封装已经恢复,状态由其本身管理,也应该如此。

此外,<Controls> 不在依赖 <App> 的实现细节,onIncreaseonDecrease 在按钮被点击的时候调用,<Controls> 不知道(也不应该知道)这些回调的内部实现。

<Controls> 组件的可重用性和可测试性显著增加。

<Controls> 的复用变得很容易,因为它除了需要回调,没有其它依赖。测试也变得简单,只需验证单击按钮时,回调是否执行。

最后谢谢各位小伙伴愿意花费宝贵的时间阅读本文,如果本文给了您一点帮助或者是启发,请不要吝啬你的赞和Star,您的肯定是我前进的最大动力。https://github.com/YvetteLau/...

推荐关注本人公众号

clipboard.png

查看原文

赞 22 收藏 15 评论 4

nojsja 收藏了文章 · 2月25日

浅谈移动端中的视口(viewport)

在 PC 端,视口指的是浏览器的可视区域,其宽度和浏览器窗口的宽度保持一致。在 CSS 标准文档中,视口也被称为初始包含块,它是所有 CSS 百分比宽度推算的根源,给 CSS 布局限制了一个最大宽度。

而移动端则较为复杂,它涉及到三个视口:布局视口(Layout Viewport)、视觉视口(Visual Viewport)和理想视口(Ideal Viewport)。

本文主要讨论移动端中的视口。

1. 基本概念

1.1 两种像素

像素是计算机屏幕中显示特定颜色的最小区域。屏幕中的像素越多,同一范围内能看到的内容就越多。或者说,当设备尺寸相同时,像素越密集,画面就越精细。

那么,当我们在 CSS 中为一个元素设置属性 width: 250px; 时,会发生什么?这个元素的宽度究竟是多少像素呢?

事实上,这里已经涉及了两种不同的像素:物理像素和 CSS 像素。

物理像素(设备像素,device pixels)

指的是设备屏幕的物理像素,任何设备的物理像素数量都是固定的。

CSS 像素(CSS pixels)

是 CSS 和 JS 中使用的一个抽象概念。它和物理像素之间的比例取决于屏幕的特性(是否为高密度)以及用户进行的缩放,由浏览器自行换算。

在 Apple 的视网膜屏(Retina)中,每 4 个像素为一组,渲染出普通屏幕中一个像素显示区域内的图像,从而实现更为精细的显示效果。此时, 250px 的元素跨越了 500 个物理像素的宽度。

如果用户进行了放大,那么一个 CSS 像素还将跨越更多的物理像素。

1.2 三种视口

移动端浏览器通常宽度是 240px~640px,而大多数为 PC 端设计的网站宽度至少为 800px,如果仍以浏览器窗口作为视口的话,网站内容在手机上看起来会非常窄。

因此,引入了布局视口、视觉视口和理想视口三个概念,使得移动端中的视口与浏览器宽度不再相关联。

布局视口(layout viewport)

一般移动设备的浏览器都默认设置了一个 viewport 元标签,定义一个虚拟的布局视口(layout viewport),用于解决早期的页面在手机上显示的问题。iOS, Android 基本都将这个视口分辨率设置为 980px,所以 PC 上的网页基本能在手机上呈现,只不过元素看上去很小,一般默认可以通过手动缩放网页。

布局视口的宽度/高度可以通过 document.documentElement.clientWidth / Height 获取。

可以看到,默认的布局视口宽度为 980px。如果要显式设置布局视口,可以使用 HTML 中的 meta 标签:

<meta name="viewport" content="width=400">

布局视口使视口与移动端浏览器屏幕宽度完全独立开。CSS 布局将会根据它来进行计算,并被它约束。

视觉视口(visual viewport)

视觉视口是用户当前看到的区域,用户可以通过缩放操作视觉视口,同时不会影响布局视口。

视觉视口和缩放比例的关系为:

当前缩放值 = 理想视口宽度  / 视觉视口宽度

所以,当用户放大时,视觉视口将会变小,CSS 像素将跨越更多的物理像素。

理想视口(ideal viewport)

布局视口的默认宽度并不是一个理想的宽度,于是 Apple 和其他浏览器厂商引入了理想视口的概念,它对设备而言是最理想的布局视口尺寸。显示在理想视口中的网站具有最理想的宽度,用户无需进行缩放。

理想视口的值其实就是屏幕分辨率的值,它对应的像素叫做设备逻辑像素(device independent pixel, dip)。dip 和设备的物理像素无关,一个 dip 在任意像素密度的设备屏幕上都占据相同的空间。如果用户没有进行缩放,那么一个 CSS 像素就等于一个 dip。

用下面的方法可以使布局视口与理想视口的宽度一致:

<meta name="viewport" content="width=device-width">

实际上,这就是响应式布局的基础。

2. 视口的设置

我们可以使用视口元标签(viewport meta 标签)来进行布局视口的设置。

<meta name="viewport"
    content="width=device-width,initial-scale=1.0,maximum-scale=1">

下面是每个属性的详细说明:

属性名取值描述
width正整数或device-width定义视口的宽度,单位为像素
height正整数或device-height定义视口的高度,单位为像素,一般不用
initial-scale[0.0-10.0]定义初始缩放值
minimum-scale[0.0-10.0]定义放大最大比例,它必须小于或等于maximum-scale设置
maximum-scale[0.0-10.0]定义缩小最小比例,它必须大于或等于minimum-scale设置
user-scalableyes / no定义是否允许用户手动缩放页面,默认值 yes

有几点值得注意:

  • viewport 标签只对移动端浏览器有效,对 PC 端浏览器是无效的
  • 当缩放比例为 100% 时,dip 宽度 = CSS 像素宽度 = 理想视口的宽度 = 布局视口的宽度
  • 单独设置 initial-scale 或 width 都会有兼容性问题,所以设置布局视口为理想视口的最佳方法是同时设置这两个属性
  • 即使设置了 user-scalable = no,在 Android Chrome 浏览器中也可以强制启用手动缩放

3. 一倍图、二倍图、三倍图

MacBook Pro 视网膜屏(Retina)显示器硬件像素是 2880px 1800px。当设置屏幕分辨率为 1920px 1200px 的时候,理想视口的宽度值是 1920px, 那么 dip 的宽度值就是 1920px。其与理想视口宽度的比值为1.5(2880/1920),这个比值叫做设备像素比:

逻辑像素宽度 * 设备像素比 = 物理像素宽度

设备像素比可以通过 window.devicePixelRatio 来获取,或者使用 CSS 中的 device-pixel-ratio

下面是常见的设备像素比:

  • 普通密度桌面显示屏:devicePixelRatio = 1
  • 高密度桌面显示屏(Mac Retina):devicePixelRatio = 2
  • 主流手机显示屏:devicePixelRatio = 2 or 3

对于一张 100px * 100px 的图片,通过 CSS 设置其宽高:

{
    width:100px;
    height:100px;
}

在普通显示屏的电脑中打开是正常的,但假设在手机或 Retina 屏中打开,按照逻辑分辨率来渲染,他们的 devicePixelRatio = 2,那么就相当于拿 4 个物理像素来描绘 1 个电子像素。这等于拿一个2倍的放大镜去看图片,图片就会变得模糊。

这时,就需要使用 @2x 甚至 @3x 图来避免图片的失真。


最后,本文仅涉及了移动端开发中视口的基本概念,具体细节可以参考 PPK 的大作《移动Web手册》

PS:电子版可关注公众号《代码写完了》,发送"ppk"获取

查看原文

认证与成就

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

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-08-25
个人主页被 1.6k 人浏览