Coder Social home page Coder Social logo

liujie2019 / blog Goto Github PK

View Code? Open in Web Editor NEW
10.0 10.0 4.0 87.58 MB

💪Star是最大鼓励--平时学习中总结和demo,请多多指教。最新博客地址:

Home Page: https://liujie2019.github.io/

JavaScript 59.01% HTML 15.35% CSS 22.11% Vue 2.26% Dockerfile 0.01% Shell 0.02% TypeScript 1.25%
canvas css3 html5 js

blog's Introduction

我是自由

  • 🏡
  • 🌱
  • 😺
  • 💬
  • 🤔
  • 👬

新年汇总 ✨

blog's People

Contributors

liujie2019 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

blog's Issues

Redux源码分析

具体的源码分析文件请戳:源码详见

Redux的核心**是:将应用的状态state存储在唯一的store中。通过store.dispatch一个action来描述触发了什么动作,用reducer处理相应的action并返回新的state。需要注意的是:创建store的时候需要传入reducer,真正可以改变应用状态state的是store.dispatchAPI。

1. 相关文件说明

  • applyMiddleware.js:middleware串联起来生成一个更强大的dispatch函数,这就是中间件的本质作用;
  • bindActionCreators.js:action creators转成拥有同名keys的对象,使用dispatch把每个action creator包围起来,使用时可以直接调用;
  • combineReducers.js: 将多个reducer组合起来,每一个reducer独立管理自己对应的state
  • compose.js:middleware从右向左依次调用,函数式编程中的常用方法,被applyMiddleware调用;
  • createStore.js: 最核心功能,创建一个store,包括实现了subscribe,unsubscribe,dispatch及state的储存;
  • index.js: 对外export
  • utils: 一些小的辅助函数供其他的函数调用
    ├── actionTypes.js: redux内置的action,用来初始化initialState
    ├── isPlainObject.js: 用来判断是否为单纯对象
    └── warning.js: 控制台输出一个警告提示

推荐源码的阅读顺序为:

index.js -> creatStore.js -> applyMiddleware.js (compose.js) -> combineReducers.js -> bindActionCreators.js

redux是一个状态管理框架,是在Flux的基础上产生的,基本**是:保证数据的单向流动,同时便于控制、使用、测试。

参考文档

  1. redux-source-analyze
  2. Redux 源码深度解析
  3. React 实践心得:react-redux 之 connect 方法详解
  4. Redux 源码分析
  5. redux深入进阶
  6. Redux实例学习 - Redux套用七步骤
  7. Redux从设计到源码
  8. redux 源码阅读笔记
  9. Redux 源码解析系列(一) -- Redux的实现**
  10. redux源码分析之四:compose函数

webpack 4.x学习总结

webpack 是一个强大的模块打包工具,之所以强大的一个原因在于它拥有灵活、丰富的插件机制。webpack 本质上是一个打包工具,它会根据代码的内容解析模块依赖,帮助我们把多个模块的代码打包。webpack 会把我们项目中使用到的多个代码模块(可以是不同文件类型),打包构建成项目运行仅需要的几个静态文件。

webpack核心概念

  1. Entry: 入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  2. Module: 模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。
  3. Chunk: 代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。
  4. Loader: 模块转换器,用于把模块原内容按照需求转换成新内容。
  5. Plugin: 扩展插件,在 Webpack 构建流程中的特定时机注入扩展逻辑来改变构建结果或做你想要的事情。
  6. Output: 输出结果,在 Webpack 经过一系列处理并得出最终想要的代码后输出结果

特别注意:webpack 4 不是必须要有配置文件。它将查找./src/index.js作为默认入口点。 而且,它会在./dist/main.js中输出模块包。

webpack执行流程

webpack启动后会在entry里配置的module开始递归解析entry所依赖的所有module,每找到一个module, 就会根据配置的loader去找相应的转换规则,对module进行转换后,再解析当前module所依赖的module,这些模块会以entry为分组,一个entry和所有相依赖的module也就是一个chunk,最后webpack会把所有chunk转换成文件输出,在整个流程中webpack会在恰当的时机执行plugin的逻辑。

安装和使用

# npm全局安装
npm install webpack webpack-cli -g 

# yarn安装
yarn global add webpack webpack-cli

# 全局执行webpack命令
webpack --help

#在项目目录中安装
npm install webpack webpack-cli -D

特别注意: webpack-cli 是使用 webpack 的命令行工具,在 webpack4.x 版本之后不再作为 webpack 的依赖了,我们使用时需要单独安装这个工具。

引入了 mode 配置项,开发者可在 none,development(开发 ) 以及 production(产品)三种模式间选择。该配置项缺省情况下默认使用 production 模式。

webpack4有两种模式:development和production,默认为production。

#生产环境
webpack --mode production

#开发环境
webpack --mode development

package.json文件中的scripts字段中进行如下配置:

"scripts": {
    "build": "webpack --mode production --config webpack.production.config.js",
    "dev": "webpack-dev-server --mode development --open"
  }

1. 入口(entry)

webpack 在构建时需要有入口文件。webpack 会读取这个文件,并从它开始解析依赖,然后进行打包。webpack4 默认从项目根目录下的 ./src/index.js 中加载入口模块。默认的入口文件就是 ./src/index.js

我们常见的项目中,如果是单页面应用,那么可能入口只有一个;如果是多个页面的项目,那么经常是一个页面会对应一个构建入口。

入口可以使用 entry 字段来进行配置,webpack 支持配置多个入口来进行构建:

module.exports = {
  entry: './src/index.js' 
}

// 上述配置等同于
module.exports = {
  entry: {
    main: './src/index.js'
  }
}

// 或者配置多个入口
module.exports = {
  entry: {
    foo: './src/page-foo.js',
    bar: './src/page-bar.js', 
    // ...
  }
}

// 使用数组来对多个文件进行打包
// 可以理解为多个文件作为一个入口,webpack 会解析两个文件的依赖后进行打包
module.exports = {
  entry: {
    main: [
      './src/foo.js',
      './src/bar.js'
    ]
  }
}

2. 输出(output)

webpack 的输出即指 webpack 最终构建出来的静态文件,可以看看上面 webpack 官方图片右侧的那些文件。当然,构建结果的文件名、路径等都是可以配置的,使用 output 字段:

module.exports = {
  // ...
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    publicPath: 'http://cdn.eaxmple.com/assets/'
  },
}

// 或者多个入口生成不同文件
module.exports = {
  entry: {
    foo: './src/foo.js',
    bar: './src/bar.js',
  },
  output: {
    filename: '[name].js', //[name]是entry里的key
    path: __dirname + '/dist',
  },
}

// 路径中使用 hash,每次构建时会有一个不同 hash 值,避免发布新版本时线上使用浏览器缓存
module.exports = {
  // ...
  output: {
    filename: '[name].js',
    path: __dirname + '/dist/[hash]',
  },
}

2.1 publicPath属性

**publicPath属性:**指定了在浏览器中用什么地址来引用静态文件,包括图片、js脚本以及css样式加载的地址,一般用于线上发布以及CDN部署的时候使用。具体例子如下:

<link href="http://cdn.eaxmple.com/assets/main.css" rel="stylesheet"></head>
<body>
    <div id="root"></div>
	<script type="text/javascript" src="http://cdn.eaxmple.com/assets/bundle.7e74c10f3f0fabe41a65.js">
	</script>
</body>

之所以会自动使用publicPath属性中设置的值,主要在于使用了html-webpack-plugin插件来自动生成项目首页文件,这样一来,link中的href属性和script中的src属性都会被自动替换。

3. 安装项目需要用到的工具包

3.1 安装bable相关工具包

# 全局安装babel
npm install -g babel

# 在项目目录下安装相关包
npm install babel-loader babel-core babel-preset-env babel-preset-react -D
3.1.1 在项目根目录新建.babelrc文件

进行如下配置:

{
  "presets": ["env", "react"]
}

3.2 安装react相关工具包

npm install --save-dev react react-dom

4. loader

webpack 中提供一种处理多种文件格式的机制,便是使用 loader。我们可以把 loader 理解为是一个转换器,负责把某种文件格式的内容转换成 webpack 可以支持打包的模块。

在没有添加额外插件的情况下,webpack 会默认把所有依赖打包成 js 文件,如果入口文件依赖一个 .hbs 的模板文件以及一个 .css 的样式文件,那么我们需要 handlebars-loader 来处理 .hbs 文件,需要 css-loaderstyle-loader来处理 .css 文件,最终把不同格式的文件都解析成 js 代码,以便打包后在浏览器中运行。

当我们需要使用不同的 loader 来解析处理不同类型的文件时,我们可以在 module.rules 字段下来配置相关的规则。

4.1 css相关loader

4.1.1 postcss-loader

postcss-loader用来自动给css属性加浏览器兼容性前缀。需要注意的是webpack4.x版本后,postcss-loader需要结合postcss-cssnext来使用,而不是autoprefixer。在此之前,需要先在根目录下创建一个postcss.config.js文件(类似于.babelrc文件)。

#postcss.config.js相关配置
module.exports = {
    plugins: [
        require('postcss-cssnext')
    ]
}
#处理scss文件
#特别注意除了安装sass-loader之外,还需要安装node-sass
npm install sass-loader node-sass postcss-loader postcss-cssnext -D
#相关配置
rules:[
    {
        test: /\.css$/,
        use: [
            'style-loader', 
            {
                loader:'css-loader',
                options:{
                    modules:true, //css模块化
                    minimize: true //在开发环境下压缩css
                }
            }
        ],
        include: path.resolve(__dirname, 'src'), //限制范围,提高打包速度
        exclude: /node_modules/ //排除打包目录
    }, {
        test: /\.less$/,
        use: ['style-loader', 'css-loader', {
            loader:'less-loader',
            options:{
                modifyVars:{
                    "color":"#ccc"  //设置变量
                }
            }
        }]
    },
    {
        test: /\.scss$/,
        use: ExtractTextPlugin.extract({
            fallback: 'style-loader',
            use: ['css-loader', {
                loader: 'postcss-loader',
                options: {
                    config: {
                      path: './postcss.config.js'//得在项目根目录创建此文件
                    }
                }
            }, 'sass-loader']
        }),
        include: path.resolve(__dirname, 'src'), //限制范围,提高打包速度
        exclude: /node_modules/ //排除打包目录
    }
]

4.2 处理图片

4.2.1 file-loader和url-loader的区别

其实url-loader封装了file-loaderurl-loader不依赖于file-loader。我们在使用url-loader的时候,只需要安装url-loader,因为url-loader内置了file-loader

url-loader在处理图片资源时分两种情况:

  1. 图片大小小于limit参数:url-loader将会把文件转为base64编码字符串DataURL
  2. 图片大小大于limit参数:url-loader会调用file-loader进行处理。
#file-loader:在输出目录生成对应的图片,解决css等文件中引入图片路径的问题
{
    module:{
        rules:[
            {
                test: /\.(png|jpg|gif)$/,
                use: ['file-loader']
            }
        ]
    }
}

#url-loader
{
    module:{
        rules:[
            {
                test:/\.(jpg|gif|jpeg|gif|png)$/,
	            use:[
                    {
                        loader: 'url-loader',
                        options: {
                            outputPath: 'images/', // 图片会被打包在 dist/images 目录下
                            limit: 1024 * 10, //小于10kb进行base64转码引用
                            name: '[hash:8].[name].[ext]'//打包后图片的名称,在原图片名前加上8位hash值
                        }
                    }
                ]
            }
        ]
    }
}

5. plugin

在 webpack 的构建流程中,plugin 用于处理更多其他的一些构建任务。可以这么理解,模块代码转换的工作由 loader 来处理,除此之外的其他任何工作都可以交由 plugin 来完成。

5.1 extract-text-webpack-plugin

该插件的主要是为了抽离css样式,防止将样式打包在js中引起页面样式加载错乱的现象。

#安装
npm install extract-text-webpack-plugin --save-dev 或 -D

#特别注意:webpack4.x,现在要安装一下版本
npm install extract-text-webpack-plugin@next -D
#引入插件
const ExtractTextPlugin = require("extract-text-webpack-plugin");

module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ExtractTextPlugin.extract({
            fallback: 'style-loader',
            use: ['css-loader', {
                loader: 'postcss-loader',
                options: {
                    config: {
                      path: './postcss.config.js'//需要在项目根目录创建此文件
                    }
                }
            }, 'sass-loader']
        }),
        include: path.resolve(__dirname, 'src'), //限制范围,提高打包速度
        exclude: /node_modules/ //排除打包目录
      }
    ]
  },
  plugins: [
    new ExtractTextWebpackPlugin({
      filename: 'css/[name].css' //放到dist/css/下
    })
  ]
}

该插件有三个参数:

  • **use:**指需要什么样的loader去编译文件,这里由于源文件是.css所以选择css-loader;
  • **fallback:**编译后用什么loader来提取css文件;
  • **publicfile:**用来覆盖项目路径,生成该css文件的文件路径

5.2 uglifyjs-webpack-plugin(压缩js)

#如果是生产模式下,会自动压缩,不需要使用该插件进行压缩
webpack --mode production

#开发环境下
#安装该插件
npm install uglifyjs-webpack-plugin -D
#引入插件
const UglifyjsWebpackPlugin = require('uglifyjs-webpack-plugin')
#调用插件
plugins: [
    new UglifyjsWebpackPlugin()
]

5.3 清空打包输出目录

#安装
npm install clean-webpack-plugin -D

#引入插件
const CleanWebpackPlugin = require('clean-webpack-plugin');

#调用插件
new CleanWebpackPlugin('./dist/bundle.*.js')

5.4 DefinePlugin

webpack.DefinePlugin相当于是给配置环境定义了一组全局变量,业务代码可以直接使用定义在里面的变量。

5.5 ProvidePlugin(自动加载模块,而不必到处 import 或 require )

new webpack.ProvidePlugin({
  identifier: 'module1',
  // ...
})
# 自动加载lodash和jquery,可以将两个变量($和_)都指向对应的 node 模块:

new webpack.ProvidePlugin({
  $: 'jquery',
  _: 'lodash'
})

5.6 (copy-webpack-plugin)复制静态资源

#安装
npm install copy-webpack-plugin

#引入插件
const CopyWebpackPlugin = require('copy-webpack-plugin');

#调用
new CopyWebpackPlugin([
  {
    from: path.resolve(__dirname, 'static'),
    to: path.resolve(__dirname, 'pages/static'),
    ignore: ['.*']
  }
])

6. webpack-dev-server配置

6.1 安装相应的依赖包

#react-hot-loader用来支持react热加载
npm install webpack-dev-server react-hot-loader -D

6.2 配置文件中进行相关配置

#在webpack配置文件中添加devServer相应的配置
devServer: {
        contentBase: './dist',//本地服务器所加载的页面所在的目录
        historyApiFallback: true,//不跳转
        inline: true, //实时刷新,
        compress: true,
        port: 8088,
        hot: true //热加载
}

还需要在.babelrc文件中进行插件项配置:

{
    "presets": ["env", "react"],
    "plugins": ["react-hot-loader/babel"] //新增加
}

7. 配置文件

7.1 开发环境配置文件webpack.config.js

#rules是一个规则数组,每一项是一个对象,配置loader
rules:[
    {
        test:'匹配文件正则',
        include:'在哪个目录匹配',
        exclude:'排除哪个目录',
        use:[
            //配置多个loader,从右往左依次执行
            {
                loader:"需要的loader",
                options:{
                    //loader的相关配置项
                }
            }
        ]
    }
]
const path = require('path');
const webpack = require('webpack'); // 用于访问内置插件
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require("extract-text-webpack-plugin");

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js'
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: 'babel-loader'
            }, {
                test: /\.html$/,
                use: {
                    loader: 'html-loader',
                    options: { 
                        minimize: true
                    }
                }
            }, {
                test: /\.scss$/,
                use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
            }
        ]
    },
    devServer: {
        contentBase: './dist',//本地服务器所加载的页面所在的目录
        historyApiFallback: true,//不跳转
        inline: true, //实时刷新,
        compress: true,
        port: 8088,
        hot: true //热加载
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html',
            filename: 'index.html'
        }),
        //每次打包都会先清除当前目录中dist目录下的文件
        new CleanWebpackPlugin('./dist/bundle.*.js'),
        new webpack.HotModuleReplacementPlugin(),//热加载插件
    ],
    //由于压缩后的代码不易于定位错误, 配置该项后发生错误时即可采用source-map的形式直接显示你出错代码的位置  
    devtool: 'eval-source-map', 
    resolve: {  
        //配置简写, 配置过后, 书写该文件路径的时候可以省略文件后缀。  
        extensions: ['.js', '.jsx', '.coffee', '.css', './scss']  
    } 
};

7.2 生产环境配置文件webpack.production.config.js

const path = require('path');
const webpack = require('webpack'); // 用于访问内置插件
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require("extract-text-webpack-plugin");

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.[hash].js'
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {
                            presets: ['env', 'react']
                        }
                    }
                ]
            }, {
                test: /\.scss$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: ['css-loader', {
                        loader: 'postcss-loader',
                        options: {
                            config: {
                              path: './postcss.config.js'  // 得在项目根目录创建此文件
                            }
                        }
                    }, 'sass-loader']
                }),
                include: path.resolve(__dirname, 'src'), //限制范围,提高打包速度
                exclude: /node_modules/ //排除打包目录
            }, {
                test: /\.html$/,
                use: {
                        loader: 'html-loader',
                        options: { 
                            minimize: true
                        }
                }
            }, {
                test:/\.(jpg|gif|jpeg|gif|png)$/,
	            use:[
                    {
                        loader: 'url-loader',
                        options: {
                            outputPath: 'images/', // 图片会被打包在 dist/images 目录下
                            limit: 10240, //小于10kb进行base64转码引用
                            name: '[hash:8].[name].[ext]'//打包后图片的名称,在原图片名前加上8位hash值
                        }
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.html',
            title: 'webpack实战练习',
            template: './src/index.html'
        }),
        //每次打包都会先清除当前目录中dist目录下的文件
        new CleanWebpackPlugin('./dist/bundle.*.js'),
        new ExtractTextPlugin({
            filename: '[name].css'
        })
    ],
    //由于压缩后的代码不易于定位错误, 配置该项后发生错误时即可采用source-map的形式直接显示你出错代码的位置  
    devtool: 'eval-source-map', 
    resolve: {  
        //配置简写, 配置过后, 书写该文件路径的时候可以省略文件后缀。  
        extensions: ['.js', '.jsx', '.coffee', '.css', '.scss']  
    } 
};

webpack 的配置其实是一个 Node.js 的脚本,这个脚本对外暴露一个配置对象,webpack 通过这个对象来读取相关的一些配置。因为是 Node.js 脚本,所以可玩性非常高,你可以使用任何的 Node.js 模块,如上述用到的 path 模块,当然第三方的模块也可以。

创建了 webpack.config.js 后再执行 webpack 命令,webpack 就会使用这个配置文件的配置了。

7. 脚手架中的 webpack 配置

现今,大多数前端框架都提供了简单的工具来协助快速生成项目基础文件,一般都会包含项目使用的 webpack 的配置,如:

create-react-app 的 webpack 配置在这个项目下:react-scripts

vue-cli 使用 webpack 模板生成的项目文件中,webpack 相关配置存放在 build 目录下。

通常 angular 的项目开发和生产的构建任务都是使用 angular-cli 来运行的,但 angular-cli 只是命令的使用接口,基础功能是由 angular/devkit来实现的,webpack 的构建相关只是其中一部分,详细的配置可以参考webpack-configs

# webpack.config.js
const path = require('path');  //引入node的path模块
const webpack = require('webpack'); //引入的webpack,使用lodash
const HtmlWebpackPlugin = require('html-webpack-plugin')  //将html打包
const ExtractTextPlugin = require('extract-text-webpack-plugin')     //打包的css拆分,将一部分抽离出来  
const CopyWebpackPlugin = require('copy-webpack-plugin')
// console.log(path.resolve(__dirname,'dist')); //物理地址拼接
module.exports = {
    entry: './src/index.js', //入口文件  在vue-cli main.js
    output: {       //webpack如何输出
        path: path.resolve(__dirname, 'dist'), //定位,输出文件的目标路径
        filename: '[name].js'
    },
    module: {       //模块的相关配置
        rules: [     //根据文件的后缀提供一个loader,解析规则
            {
                test: /\.js$/,  //es6 => es5 
                include: [
                    path.resolve(__dirname, 'src')
                ],
                // exclude:[], 不匹配选项(优先级高于test和include)
                use: 'babel-loader'
            },
            {
                test: /\.less$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: [
                    'css-loader',
                    'less-loader'
                    ]
                })
            },
            {       //图片loader
                test: /\.(png|jpg|gif)$/,
                use: [
                    {
                        loader: 'file-loader' //根据文件地址加载文件
                    }
                ]
            }
        ]                  
    },
    resolve: { //解析模块的可选项  
        // modules: [ ]//模块的查找目录 配置其他的css等文件
        extensions: [".js", ".json", ".jsx",".less", ".css"],  //用到文件的扩展名
        alias: { //模快别名列表
            utils: path.resolve(__dirname,'src/utils')
        }
    },
    plugins: [  //插进的引用, 压缩,分离美化
        new ExtractTextPlugin('[name].css'),  //[name] 默认  也可以自定义name  声明使用
        new HtmlWebpackPlugin({  //将模板的头部和尾部添加css和js模板,dist 目录发布到服务器上,项目包。可以直接上线
            file: 'index.html', //打造单页面运用 最后运行的不是这个
            template: 'src/index.html'  //vue-cli放在跟目录下
        }),
        new CopyWebpackPlugin([  //src下其他的文件直接复制到dist目录下
            { from:'src/assets/favicon.ico',to: 'favicon.ico' }
        ]),
        new webpack.ProvidePlugin({  //引用框架 jquery  lodash工具库是很多组件会复用的,省去了import
            '_': 'lodash'  //引用webpack
        })
    ],
    devServer: {  //服务于webpack-dev-server  内部封装了一个express 
        port: '8080',
        before(app) {
            app.get('/api/test.json', (req, res) => {
                res.json({
                    code: 200,
                    message: 'Hello World'
                })
            })
        }
    }
    
}

8. cross-env(跨平台设置环境变量)

npm install --save-dev cross-env
"scripts": {
    "build": "cross-env NODE_ENV=production webpack --config webpack.production.config.js --mode production",
    "dev": "cross-env NODE_ENV=development webpack-dev-server --mode development --open",
    "dll": "webpack --config webpack_dll.config.js --mode development"
  }

参考文档

  1. webpack 4 教程
  2. 精读《webpack4.0 升级指南》
  3. 手写一个webpack4.0配置
  4. webpack详解
  5. webpack中文文档
  6. Webpack 实用技巧高效实战
  7. 玩转webpack(一)上篇:webpack的基本架构和构建流程
  8. 玩转webpack(二):webpack的核心对象
  9. Webpack 持久化缓存实践
  10. cross-env
  11. webpack4 中文文档

webpacck之Tree shaking打包性能优化

1. 认识Tree

什么是Tree Shaking?字面意思是摇树,一句话:项目中没有使用的代码会在打包时候丢掉。JSTree Shaking依赖的是ES2015的模块系统(比如:import和export)。

Tree Shaking可以用来剔除javascript中用不上的死代码。依赖静态的ES6模块化语法,例如通过import和export导入、导出。Tree Shaking最先在Rollup中出现,Webpack2.0版本中将其引入。

为了更直观的理解它,来看一个具体的例子。假如有一个文件util.js里存放了很多工具函数和常量,在main.js中会导入和使用util.js,代码如下:

util.js源码:

export function funcA() {
}

export function funcB() {
}

main.js源码:

import { funcA } from './util.js';
funcA();

Tree Shaking后的util.js

export function funcA() {
}

由于只用到了util.js中的funcA,所以剩下的都被Tree Shaking当作死代码给剔除了。

需要注意的是:要让Tree Shaking正常工作的前提是交给WebpackJavaScript代码必须是采用ES6模块化语法的,因为ES6模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让Webpack可以简单的分析出哪些export的被import过了。如果采用ES5中的模块化,例如module.export = {...}、require(x+y)、if(x){require('./util')}Webpack无法分析出哪些代码可以剔除。

2. 接入Tree Shaking

上面讲了Tree Shaking是做什么的,接下来一步步教你如何配置WebpackTree Shaking生效。

首先,为了把采用ES6模块化的代码交给Webpack,需要配置Babel让其保留ES6模块化语句,修改.babelrc文件为如下:

{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ]
}

其中"modules": false的含义是关闭Babel的模块转换功能,保留原本的 ES6模块化语法。

配置好Babel后,重新运行Webpack,在启动Webpack时带上--display-used-exports参数,以方便追踪Tree Shaking的工作, 这时你会发现在控制台中输出了如下的日志:

> webpack --display-used-exports
bundle.js  3.5 kB       0  [emitted]  main
   [0] ./main.js 41 bytes {0} [built]
   [1] ./util.js 511 bytes {0} [built]
       [only some exports used: funcA]

其中[only some exports used: funcA]提示了util.js只导出了用到的funcA,说明Webpack确实正确的分析出了如何剔除死代码。

但当你打开Webpack输出的bundle.js文件看下时,你会发现用不上的代码还在里面,如下:

/* harmony export (immutable) */
__webpack_exports__["a"] = funcA;

/* unused harmony export funB */

function funcA() {
  console.log('funcA');
}

function funB() {
  console.log('funcB');
}

Webpack只是指出了哪些函数用上了哪些没用上,要剔除用不上的代码还得经过 UglifyJS去处理一遍。要接入UglifyJS也很简单,不仅可以通过UglifyJSPlugin插件去实现,也可以简单的通过在启动Webpack时带上--optimize-minimize参数,为了快速验证Tree Shaking,我们采用较简单的后者来实验下。

通过webpack --display-used-exports --optimize-minimize重启 Webpack后,打开新输出的bundle.js,内容如下:

function r() {
  console.log("funcA")
}

t.a = r

可以看出Tree Shaking确实做到了,用不上的代码都被剔除了。

当我们的项目使用了大量第三方库时,你会发现Tree Shaking似乎不生效了,原因是大部分Npm中的代码都是采用的CommonJS语法,这导致Tree Shaking无法正常工作而降级处理。但幸运的时有些库考虑到了这点,这些库在发布到Npm上时会同时提供两份代码,一份采用CommonJS模块化语法,一份采用 ES6模块化语法。并且在package.json文件中分别指出这两份代码的入口。

redux库为例,其发布到 Npm 上的目录结构为:

node_modules/redux
|-- es
|   |-- index.js # 采用 ES6 模块化语法
|-- lib
|   |-- index.js # 采用 ES5 模块化语法
|-- package.json

package.json文件中有两个字段:

{
  "main": "lib/index.js", // 指明采用 CommonJS 模块化的代码入口
  "jsnext:main": "es/index.js" // 指明采用 ES6 模块化的代码入口
}

mainFields用于配置采用哪个字段作为模块的入口描述。为了让Tree Shakingredux生效,需要配置Webpack的文件寻找规则为如下:

module.exports = {
  resolve: {
    // 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
};

以上配置的含义是优先使用jsnext:main作为入口,如果不存在jsnext:main就采用browser或者main作为入口。虽然并不是每个Npm 中的第三方模块都会提供ES6模块化语法的代码,但对于提供了的不能放过,能优化的就优化。

目前越来越多的Npm中的第三方模块考虑到了Tree Shaking,并对其提供了支持。采用jsnext:main作为ES6模块化代码的入口是社区的一个约定,假如将来你要发布一个库到Npm时,希望你能支持Tree Shaking,以让Tree Shaking发挥更大的优化效果,让更多的人为此受益。
image

3. webpack4中使用Tree Shaking

需要注意的是:在webpack4中使用Tree Shaking,不再需要[uglifyjs-webpack-plugin]
image
image
image

参考博文

  1. webpack 如何通过作用域分析消除无用代码
  2. Tree-Shaking性能优化实践 - 原理篇
  3. webpack4 系列教程(八): JS Tree Shaking

Koa学习总结

Koa 必须使用 7.6 以上的版本。如果你的版本低于这个要求,就要先升级 Node。

1. 基本用法

搭建开发环境

新建一个项目文件夹,命令如下:

mkdir koa2 //创建koa2文件夹
cd koa2  //进入koa2文件夹

进入之后,我们初始化生产package.json 文件。

npm init -y

生成package.json后,安装koa包,我们这里使用npm来进行安装。

npm install --save koa

如果你安装错误,一般都是网速问题,可以使用cnpm来进行安装。

1.1 启动http服务

const Koa = require('koa');
const app = new Koa();

app.listen(3000);

1.2 Context对象

Koa提供一个Context对象,表示一次对话的上下文(包括HTTP请求和HTTP响应)。通过加工这个对象,就可以控制返回给用户的内容。

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
    ctx.response.body = 'hello world';
});

app.listen(3000);

ctx.response代表 HTTP Response。同样地,ctx.request代表 HTTP Request。ctx则是Koa所提供的 Context对象(上下文), ctx.body则是 ctx.response.body的alias(别名),这是响应体设置的API。

1.3 HTTP Response 的类型

Koa 默认的返回类型是text/plain,如果想返回其他类型的内容,可以先用ctx.request.accepts判断一下,客户端希望接受什么数据(根据 HTTP Request 的Accept字段),然后使用ctx.response.type指定返回类型。

2. Koa路由

2.1 原生路由实现

可以根据 ctx.request.url或者 ctx.request.path获取用户请求的路径,来实现简单的路由。

ctx.request.url

要想实现原生路由,需要得到地址栏输入的路径,然后根据路径的不同进行跳转。用ctx.request.url就可以实现。我们通过一个简单的例子了解一下如何获得访问路径。

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
  const url = ctx.request.url;
  ctx.body = url
})
app.listen(3000);

这时候访问http://127.0.0.1:3000/test/123页面会输出/test/123。这样一来,我们就可以根据输出的不同,实现的页面结果。

const Koa = require('koa');
const fs = require('fs');
const app = new Koa();

function render(page) {
        return  new Promise((resolve, reject) => {
            let pageUrl = `./page/${page}`;
            fs.readFile(pageUrl, 'utf-8', (err,data) => {
                console.log(444);
                if(err) {
                    reject(err)
                } else {
                    resolve(data);
                }
            })
        });
}

const route = async (url) => {
    let page = 'index.html';
    switch(url) {
        case '/':
            page ='index.html';
            break;
        case '/about':
            page = 'about.html';
            break;
        case '/404':
            page = '404.html';
            break;
        default:
            break;
    }
    const html = await render(page);
    return html;
}

app.use(async(ctx) => {
    const url = ctx.request.url;
    const html = await route(url);

    ctx.body = html;
})
app.listen(3000);
console.log('starting at 3000');

原生路由的实现需要引入fs模块来读取文件。然后再根据路由的路径去读取,最后返回给页面,进行渲染。

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
    let _html = '404 NotFound';
    switch (ctx.url) {
        case '/':
            _html = '<h1>Index</h1>';
            break;
        case '/about':
            _html = '<h1>About</h1>';
            break;
        case '/hello':
            _html = '<h1>world</h1>';
            break;
        default:
            break;
    }
    ctx.body = _html;
});
app.listen(3000);

2.2 koa-route模块

const Koa = require('koa');
const route = require('koa-route');
const app = new Koa();

const home = async (ctx) => {
    ctx.response.body = 'Hello Koa';
}

const about = async (ctx) => {
    ctx.response.type = 'html';
    ctx.response.body = '<a href="/">跳转到首页</a>';
}

// 根路径/的处理函数是home
app.use(route.get('/', home));
// 路径/about的处理函数是about
app.use(route.get('/about', about));
app.listen(3000);
2.2.1 koa-router模块
// 安装
npm i -S koa-router
const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/', (ctx, next) => {
    ctx.body = 'Hello Koa';
})
.get('/todo', (ctx,next) => {
    ctx.body = "Todo page"
});

app
  .use(router.routes())
  .use(router.allowedMethods());
  app.listen(3000, () => {
      console.log('starting at port 3000');
  });
const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/', async (ctx) => {
    ctx.body = `
        <ul>
            <li><a href="/hello">hello world</a></li>
            <li><a href="/about">hello about</a></li>
        </ul>
    `;
}).get('/hello', async (ctx) => {
    ctx.body = 'hello world';
}).get('/about', async (ctx) => {
    ctx.body = 'hello about';
});

app.use(router.routes(), router.allowedMethods());
app.listen(3000);
2.2.2 设置路由前缀

有时候我们想把所有的路径前面都再加入一个级别,比如原来我们访问的路径是http://127.0.0.1:3000/todo,现在我们希望在所有的路径前面都加上一个project层级,把路径变成http://127.0.0.1:3000/project/todo。这时候就可以使用层级来完成这个功能。路由在创建的时候是可以指定一个前缀的,这个前缀会被至于路由的最顶层,也就是说,这个路由的所有请求都是相对于这个前缀的。

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
// 设置路由前缀
const router = new Router({
    prefix:'/project'
})

router.get('/', (ctx) => {
    ctx.body = 'Hello Koa';
})
.get('/todo', (ctx) => {
    ctx.body = 'Todo page';
});

app
  .use(router.routes())
  .use(router.allowedMethods());
app.listen(3000,() => {
    console.log('starting at port 3000');
});
// 访问http://127.0.0.1:3000/project/todo
2.2.3 设置路由层级

设置前缀一般都是全局的,并不能实现路由的层级,如果你想为单个页面设置层级,也是很简单的。只要在use时使用路径就可以了。

// 这里声明了两个路由,第一个是home,第二个是page,然后通过use赋予不同的前层级。

const Koa = require('koa');
const app = new Koa();
const Router = require('koa-router');

const home = new Router();
home.get('/liujie', async(ctx) => {
    ctx.body = 'Home liujie';
}).get('/todo',async(ctx) => {
    ctx.body = 'Home ToDo';
})

const page = new Router();
page.get('/liujie', async(ctx) => {
    ctx.body = 'Page liujie';
}).get('/todo', async(ctx) => {
    ctx.body = 'Page ToDo';
});

// 装载所有子路由
const router = new Router();
router.use('/home', home.routes(), home.allowedMethods());
router.use('/page', page.routes(), page.allowedMethods());

// 加载路由中间件
app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
    console.log('[demo] server is starting at port 3000');
});
2.2.4 Koa-router中间件参数

获取GET请求参数:ctx.query。

const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();

router.get('/', (ctx) => {
    ctx.body = ctx.query;
});
app
  .use(router.routes())
  .use(router.allowedMethods());
  app.listen(3000,() => {
      console.log('starting at port 3000');
  });
// 访问http://127.0.0.1:3000/?name=liujie&age=23
{
  "name": "liujie",
  "age": "23"
}

2.3 静态资源(图片、字体、样式表、脚本)

koa-static -> 静态资源中间件。

// 安装
npm i -S koa-static
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const static = require('koa-static');

app.use(static(path.join(__dirname)));
app.listen(3000);
运行该脚本文件,访问 http://127.0.0.1:3000,在浏览器里就可以看到该目录下的所有静态资源。
const Koa = require('koa');
const path = require('path');
const static = require('koa-static');

const app = new Koa();
const staticPath = './static';

app.use(static(
  path.join( __dirname,  staticPath)
));

app.use(async (ctx) => {
  ctx.body = 'hello world111';
});

app.listen(3000, () => {
  console.log('[demo] static-use-middleware is starting at port 3000');
});
// 访问http://localhost:3000/test.js,即可看到test.js脚本的结果。

2.4 重定向

在项目的某些场景下,服务器需要重定向(redirect)访问请求。比如,用户登陆以后,将他重定向到登陆前的页面。ctx.response.redirect()方法可以发出一个302跳转,将用户导向另一个路由。

const Koa = require('koa');
const app = new Koa();
const route = require('koa-route');

const redirect = async (ctx) => {
    console.log('重定向了');
    ctx.response.redirect('/');
};

const home = async (ctx) => {
    ctx.response.body = '<a href="/">我是首页</a>'
};

app.use(route.get('/', home));
app.use(route.get('/redirect', redirect));

app.use(home);
app.listen(3000);

3. 中间件

Koa 的最大特色,也是最重要的一个设计,就是中间件(middleware)Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。Koa中使用 app.use()用来加载中间件,基本上Koa 所有的功能都是通过中间件实现的。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是 next函数。只要调用 next函数,就可以把执行权转交给下一个中间件。

const Koa = require('koa');
const app = new Koa();

// x-response-time
app.use(async (ctx, next) => {
    const start = Date.now();
    await next();
    const ms = Date.now() - start;
    ctx.set('X-response-time', `${ms}ms`);
});

// logger
app.use(async (ctx, next) => {
    const start = Date.now();
    await next();
    const ms = Date.now() - start;
    console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response
app.use(async ctx => {
    ctx.body = 'hello world';
});

app.listen(3000);

上面的执行顺序就是:请求 ==> x-response-time中间件 ==> logger中间件 ==> 响应中间件 ==> logger中间件 ==> response-time中间件 ==> 响应。 通过这个顺序我们可以发现这是个栈结构以"先进后出"(first-in-last-out)的顺序执行。

3.1 Logger功能(打印日志)

const Koa = require('koa');
const app = new Koa();

const main = async (ctx) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  ctx.response.body = 'Hello World';
};

app.use(main);
app.listen(3000);
// 输出
1534731922607 GET /

3.2 中间件的概念

const Koa = require('koa');
const app = new Koa();

const logger = async (ctx, next) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  next();
}

const home = async (ctx) => {
  ctx.response.body = 'Hello World';
};

app.use(logger);
app.use(home);
app.listen(3000);

上面代码中的logger函数就叫做"中间件"(middleware),因为它处在 HTTP Request 和 HTTP Response 中间,用来实现某种中间功能。app.use()用来加载中间件。基本上,Koa 所有的功能都是通过中间件实现的,前面例子里面的main也是中间件。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next函数。只要调用next函数,就可以把执行权转交给下一个中间件。

3.3 中间件栈

多个中间件会形成一个栈结构(middle stack),以"先进后出"(first-in-last-out)的顺序执行。

  1. 最外层的中间件首先执行。
  2. 调用next函数,把执行权交给下一个中间件。
  3. ...
  4. 最内层的中间件最后执行。
  5. 执行结束后,把执行权交回上一层的中间件。
  6. ...
  7. 最外层的中间件收回执行权之后,执行next函数后面的代码。
const Koa = require('koa');
const app = new Koa();

const one = async (ctx, next) => {
    console.log('>> one');
    next();
    console.log('<< one');
}

const two = async (ctx, next) => {
    console.log('>> two');
    next();
    console.log('<< two');
}

const three = async (ctx, next) => {
    console.log('>> three');
    next();
    console.log('<< three');
}

app.use(one);
app.use(two);
app.use(three);

app.listen(3000);
// 运行结果
>> one
>> two
>> three
<< three
<< two
<< one

特别注意:如果中间件内部没有调用next函数,那么执行权就不会传递下去。

3.4 异步中间件

如果有异步操作(比如读取数据库),中间件就必须写成 async 函数。

const fs = require('fs.promised');
const Koa = require('koa');
const app = new Koa();

const main = async (ctx, next) => {
  ctx.response.type = 'html';
  ctx.response.body = await fs.readFile('./template.html', 'utf8');
};

app.use(main);
app.listen(3000);
# 需要安装fs.promised
npm i -S fs.promised

3.5 中间件的合成

koa-compose模块可以将多个中间件合成为一个。

const Koa = require('koa');
const compose = require('koa-compose');
const app = new Koa();

const logger = (ctx, next) => {
  console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`);
  next();
}

const main = ctx => {
  ctx.response.body = 'Hello World';
};

// 合成中间件
const middlewares = compose([logger, main]);

app.use(middlewares);
app.listen(3000);

4. 错误处理

4.1 500错误

如果代码运行过程中发生错误,我们需要把错误信息返回给用户。HTTP 协定约定这时要返回500状态码。Koa 提供了ctx.throw()方法,用来抛出错误,ctx.throw(500)就是抛出500错误。

const Koa = require('koa');
const app = new Koa();

const main = ctx => {
  ctx.throw(500);
};

app.use(main);
app.listen(3000);

4.2 404错误

如果将ctx.response.status设置成404,就相当于ctx.throw(404),返回404错误。

const Koa = require('koa');
const app = new Koa();

const main = ctx => {
  ctx.response.status = 404;
  ctx.response.body = 'Page Not Found';
};

app.use(main);
app.listen(3000);

4.3 封装处理错误的中间件

为了方便处理错误,最好使用try...catch将其捕获。但是,为每个中间件都写try...catch太麻烦,我们可以让最外层的中间件,负责所有中间件的错误处理。

const Koa = require('koa');
const app = new Koa();

const handler = async (ctx, next) => {
  try {
    await next();
  }
  catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.body = {
      message: err.message
    };
  }
};

const main = ctx => {
  ctx.throw(404);
};

app.use(handler);
app.use(main);
app.listen(3000);
访问 http://127.0.0.1:3000 ,你会看到一个500页,里面有报错提示 {"message": "Not Found"}。

4.4 error 事件的监听

运行过程中一旦出错,Koa 会触发一个error事件。监听这个事件,也可以处理错误。

const Koa = require('koa');
const app = new Koa();

const main = ctx => {
  ctx.throw(500);
};

app.on('error', (err, ctx) => {
  console.error('server error', err);
});

app.use(main);
app.listen(3000);
访问 http://127.0.0.1:3000 ,你会在命令行窗口看到"server error xxx"。

4.5 释放 error 事件

需要注意的是,如果错误被try...catch捕获,就不会触发error事件。这时,必须调用ctx.app.emit(),手动释放error事件,才能让监听函数生效。

const Koa = require('koa');
const app = new Koa();

const handler = async (ctx, next) => {
  try {
    await next(); // 没有错误则将执行权交给下一个中间件
  }
  catch (err) {
    ctx.response.status = err.statusCode || err.status || 500;
    ctx.response.type = 'html';
    ctx.response.body = '<p>Something wrong, please contact administrator.</p>';
    ctx.app.emit('error', err, ctx);
  }
};

const main = ctx => {
  ctx.throw(500);
};

app.on('error', function(err) {
  console.log('logging error ', err.message);
  console.log(err);
});

app.use(handler);
app.use(main);
app.listen(3000);

上面代码中,main函数抛出错误,被handler函数捕获。catch代码块里面使用ctx.app.emit()手动释放error事件,才能让监听函数监听到。

5. Web App 的功能

5.1 Cookies

ctx.cookies用来读写 Cookie。

const Koa = require('koa');
const app = new Koa();

const main = (ctx) => {
  const n = Number(ctx.cookies.get('view') || 0) + 1;
  ctx.cookies.set('view', n);
  ctx.response.body = n + ' views';
}

app.use(main);
app.listen(3000);
访问 http://127.0.0.1:3000 ,你会看到1 views。刷新一次页面,就变成了2 views。再刷新,每次都会计数增加1。

5.2 表单

Web 应用离不开处理表单。本质上,表单就是 POST 方法发送到服务器的键值对。koa-body模块可以用来从 POST 请求的数据体里面提取键值对。

const Koa = require('koa');
const koaBody = require('koa-body');
const app = new Koa();

const main = async (ctx) => {
  const body = ctx.request.body;
  if (!body.name) ctx.throw(400, '.name required');
  ctx.body = { name: body.name };
};

app.use(koaBody());
app.use(main);
app.listen(3000);

上面代码使用 POST 方法向服务器发送一个键值对,会被正确解析。如果发送的数据不正确,就会收到错误提示。

curl -X POST --data "name=liujie" 127.0.0.1:3000
{"name":"liujie"}

curl -X POST --data "name" 127.0.0.1:3000
.name required

5.3 文件上传

koa-body模块还可以用来处理文件上传。

const os = require('os');
const path = require('path');
const Koa = require('koa');
const fs = require('fs');
const koaBody = require('koa-body');

const app = new Koa();

const main = async (ctx) => {
  const tmpdir = os.tmpdir();
  const filePaths = [];
  const files = ctx.request.body.files || {};

  for (let key in files) {
    const file = files[key];
    const filePath = path.join(tmpdir, file.name);
    const reader = fs.createReadStream(file.path);
    const writer = fs.createWriteStream(filePath);
    reader.pipe(writer);
    filePaths.push(filePath);
  }

  ctx.body = filePaths;
};

app.use(koaBody({ multipart: true }));
app.use(main);
app.listen(3000);
打开另一个命令行窗口,运行下面的命令,上传一个文件。注意,/path/to/file要更换为真实的文件路径。

$ curl --form upload=@/path/to/file http://127.0.0.1:3000
["/tmp/file"]

6. 模板引擎(ejs)

# 安装koa模板相关中间件
$ npm i -S koa-views

# 安装ejs模板引擎
$ npm i -S ejs
const Koa = require('koa');
const views = require('koa-views');
const path = require('path');
const app = new Koa();

// 加载模板引擎,模板放在views目录中
app.use(views(path.join(__dirname, './views'), {
    extension: 'ejs'
}));

app.use(async (ctx) => {
    const title = 'Koa2';
    await ctx.render('index', {
        title
    });
});

app.listen(3000);
// ./view/index.ejs 模板
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
    <p>EJS Welcome to <%= title %></p>
</body>
</html>

7. 请求数据的获取

7.1 GET请求参数的获取

在koa2中,获取GET请求数据需要使用request对象中的query方法或querystring方法。两者的区别在于:query返回是格式化好的参数对象,querystring返回的是请求字符串。

  • 请求对象ctx.query(或ctx.request.query),返回如 { a:1, b:2 };
  • 请求字符串ctx.querystring(或ctx.request.querystring),返回如 a=1&b=2
const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
    const url = ctx.url;
    const query = ctx.query;
    const querystring = ctx.querystring;
    ctx.body = {
        url,
        query,
        querystring
    };
});
app.listen(3000, () => {
    console.log(server is starting at port 3000);
});
访问:http://localhost:3000/demo?name=liujie&age=18
结果如下:
{
  "url": "/demo?name=liujie&age=18",
  "query": {
    "name": "liujie",
    "age": "18"
  },
  "querystring": "name=liujie&age=18"
}

总结:获得GET请求的方式有两种,一种是从ctx.request对象中获得,另一种是直接从从上下文对象ctx中获得。获得的格式也有两种:query和querystring。

7.2 POST请求数据获取

7.2.1 POST请求数据获取-1

获取Post请求的步骤:

  1. 解析上下文ctx中的原生node.js对象req。
  2. 将POST表单数据解析成querystring-字符串。(例如:user=liujie&age=20)
  3. 将字符串转换成JSON格式。

ctx.request和ctx.req的区别:

  1. ctx.request:是Koa2中context经过封装的请求对象,它用起来更直观和简单。
  2. ctx.req:是context提供的node.js原生HTTP请求对象。这个虽然不那么直观,但是可以得到更多的内容,适合我们深度编程。
ctx.method(获取请求类型)

Koa2中提供了ctx.method属性,可以获取到请求的类型,然后根据请求类型编写不同的相应方法,这在工作中非常常用。

const Koa = require('koa');
const app = new Koa();
app.use( async (ctx) => {
    //当请求时GET请求时,显示表单让用户填写
    if(ctx.url === '/' && ctx.method === 'GET') {
        const html =`
            <h1>Koa2 request post demo</h1>
            <form method="POST"  action="/">
                <p>userName</p>
                <input name="userName" /> <br/>
                <p>age</p>
                <input name="age" /> <br/>
                <p>webSite</p>
                <input name='webSite' /><br/>
                <button type="submit">submit</button>
            </form>
        `;
        ctx.body = html;
    //当请求时POST请求时
    }
    else if(ctx.url === '/' && ctx.method === 'POST') {
        ctx.body = '接收到请求';
    }
    else {
        //其它请求显示404页面
        ctx.body = '<h1>404 not found!</h1>';
    }
})

app.listen(3000, () => {
    console.log('[demo] server is starting at port 3000');
});
const Koa = require('koa');
const app = new Koa();
app.use( async (ctx) => {
    //当请求时GET请求时,显示表单让用户填写
    if(ctx.url === '/' && ctx.method === 'GET') {
        const html =`
            <h1>Koa2 request post demo</h1>
            <form method="POST"  action="/">
                <p>userName</p>
                <input name="userName" /> <br/>
                <p>age</p>
                <input name="age" /> <br/>
                <p>webSite</p>
                <input name='webSite' /><br/>
                <button type="submit">submit</button>
            </form>
        `;
        ctx.body = html;
    //当请求时POST请求时
    }
    else if(ctx.url === '/' && ctx.method === 'POST') {
        const pastData = await parsePostData(ctx);
        ctx.body = pastData;
    }
    else {
        //其它请求显示404页面
        ctx.body = '<h1>404 not found!</h1>';
    }
});

// 解析Node原生POST参数
function parsePostData(ctx) {
    return new Promise((resolve, reject) => {
        try {
            let postdata = '';
            ctx.req.on('data', (data) => {
                postdata += data
            });
            ctx.req.addListener('end', () => {
                const parseData = parseQueryStr(postdata);
                resolve(parseData);
            });
        } catch (error) {
            reject(error);
        };
    });
}
// POST字符串解析JSON对象
function parseQueryStr(queryStr) {
    let queryData = {};
    let queryStrList = queryStr.split('&');
    console.log(queryStrList);
    for ( let [index,queryStr] of queryStrList.entries() ) {
        let itemList = queryStr.split('=');
        console.log(itemList);
        queryData[itemList[0]] = decodeURIComponent(itemList[1]);
    }
    return queryData;
}

app.listen(3000, () => {
    console.log('[demo] server is starting at port 3000');
});
// 运行结果:
{
  "userName": "liujie",
  "age": "20",
  "webSite": "www.baidu.com"
}
7.2.2 POST请求数据获取-koa-bodyparser

对于POST请求的处理,koa2没有封装获取参数的方法,需要通过自己解析上下文context中的原生node.js请求对象req,将POST表单数据解析成querystring(例如:a=1&b=2&c=3),再将querystring 解析成JSON格式(例如:{"a":"1","b":"2","c":"3"}),我们来直接使用koa-bodyparser模块从 POST 请求的数据体里面提取键值对。

// 安装(-S是因为需要在生产环境使用)
npm i -S koa-bodyparser
const Koa = require('koa');
const app = new Koa();
const bodyParser = require('koa-bodyparser');

// 使用koa-bodyparser中间件
app.use(bodyParser());

app.use(async (ctx) => {
    if(ctx.url === '/' && ctx.method === 'GET') {
        // 当GET请求时返回表单页面
        ctx.body = `
            <h1>koa-bodyparser</h1>
            <form method="post" action="/">
                姓名:<input name="name" />

                年龄:<input name="age" />

                邮箱:<input name="email" />

                <button type="submit">提交</button>
            </form>
        `;
    }
    else if(ctx.url === '/' && ctx.method === 'POST') {
        // 当POST请求的时候,中间件koa-bodyparser解析POST表单里面的数据,并展示出来
        ctx.body = ctx.request.body;
    }
    else {
        ctx.body = '<h1>404 Not Found</h1>';
    }
});

app.listen(3000);
// 提交结果展示:
{
  "name": "liujie",
  "age": "18",
  "email": "[email protected]"
}

8. Koa中使用cookie

开发中制作登录和保存用户信息在本地,最常用的就是cookie操作。比如我们在作一个登录功能时,希望用户在接下来的一周内都不需要重新登录就可以访问资源,这时候就需要我们操作cookie来完成我们的需求。koa的上下文(ctx)直接提供了读取和写入的方法。

  • ctx.cookies.get(name,[optins]):读取上下文请求中的cookie。
  • ctx.cookies.set(name,value,[options]):在上下文中写入cookie。

8.1 写入Cookie操作

const Koa  = require('koa');
const app = new Koa();

app.use(async (ctx) => {
    if(ctx.url === '/index'){
        ctx.cookies.set(
            'name','liujie'
        );
        ctx.body = 'cookie is ok';
    }
    else {
        ctx.body = 'hello world';
    }
});

app.listen(3000, () => {
    console.log('[demo] server is starting at port 3000');
})

写好后预览,打开控制台,可以在Application中找到Cookies选项。就可以找到我们写入的name和value了。

Cookie选项
  • domain:写入cookie所在的域名;
  • path:写入cookie所在的路径;
  • maxAge:Cookie最大有效时长;
  • expires:cookie失效时间;
  • httpOnly:是否只用http请求中获得;
  • overwirte:是否允许重写。
const Koa  = require('koa');
const app = new Koa();

app.use(async(ctx) => {
    console.log(ctx.url);
    if(ctx.url === '/index') {
        ctx.cookies.set(
            'age','22', {
                domain: '127.0.0.1', // 写cookie所在的域名
                path: '/index',       // 写cookie所在的路径
                maxAge: 1000*60*60*24,   // cookie有效时长
                expires: new Date('2019-12-31'), // cookie失效时间
                httpOnly: false,  // 是否只用于http请求中获取
                overwrite: false  // 是否允许重写
            }
        );
        ctx.body = 'cookie is ok222';
    }
    else {
        ctx.body = 'hello world'
    }
});

app.listen(3000, () => {
    console.log('[demo] server is starting at port 3000');
});

特别注意:127.0.0.1和localhost是两个不同的domain。

读取Cookie

const Koa  = require('koa');
const app = new Koa();

app.use(async(ctx) => {
    if (ctx.url === '/index') {
        ctx.cookies.set(
            'name','liujie', {
                domain:'127.0.0.1', // 写cookie所在的域名
                path:'/index',       // 写cookie所在的路径
                maxAge:1000*60*60*24,   // cookie有效时长
                expires:new Date('2018-12-31'), // cookie失效时间
                httpOnly:false,  // 是否只用于http请求中获取
                overwrite:false  // 是否允许重写
            }
        );
        ctx.body = 'cookie is ok';
    }
    else {
        if (ctx.cookies.get('name')) {
            ctx.body = ctx.cookies.get('name');
        }
        else {
            ctx.body = 'Cookie is not found';
        }
    }
});

app.listen(3000, () => {
    console.log('[demo] server is starting at port 3000');
})

参考文档

  1. Koa 框架教程
  2. Koa文档
  3. Koa快速入门教程(一)
  4. Koa wiki
  5. Koa2进阶学习笔记
  6. 使用mvc

webpack4实战(2-1)-Babel7转译ES2015+

[TOC]
目前项目中,js主要是用ES6+编写的。但是,并不是所有浏览器都支持ES6+,这就需要对其进行转换,这个转换步骤称为 transpiling(转译)。
Webpack需要借助于loader(加载器)进行相应的转换。babel-loader就是用于将ES6+转译成ES5

一个webpack loader作用就是:把输入进去的文件转化成指定的文件格式输出。其中babel-loader负责将传入的es6文件转化成浏览器可以运行的文件。

1. 初始化Babel

  • babel-loader: 负责es6+语法转化;
  • babel-preset-env: 将ES6+转换成ES5(注意:babel-preset-es2015已经被废弃了);
  • babel-polyfill: es6内置方法和函数转化垫片;
  • babel-plugin-transform-runtime: 避免polyfill污染全局变量。

需要注意的是:babel-loader和babel-polyfill。前者负责语法转化,比如:箭头函数;后者负责内置方法和函数,比如:new Set()

下面来安装和配置Babel:

npm i @babel/core babel-loader @babel/preset-env --save-dev

在项目根目录新建一个.babelrc文件,内容为:

{
  "presets": ["@babel/preset-env"]
}

创建一个新的文件webpack.config.js,内容为

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
 
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader"
        }
      }
    ]
  }
};

上述webpack的配置很简单,所有以.js结尾的文件都会用babel-loaderES6编译转化成ES5的文件。同时指定了入口文件的路径为src/index.js,输出为dist/bundle.js

2. babel-polyfill引入

node中引入:

require("babel-polyfill");

如果在应用入口使用ES6import语法,需要在入口顶部通过 importpolyfill引入,以确保它能够最先加载:

import "babel-polyfill";

webpack.config.js中,将babel-polyfill加到entry 数组中:

module.exports = {
  entry: ['babel-polyfill', './src/index.js']
};

3. 在npm scripts中使用babel-loader(不推荐)

--module-bind参数允许我们在命令行中指定加载器。该参数从webpack3开始就有了。

如果希望在没有配置文件的情况下使用babel-loader,需要在 package.json中配置npm scripts,如下所示:

"scripts": {
    "dev": "webpack --mode development --module-bind js=babel-loader",
    "build": "webpack --mode production --module-bind js=babel-loader"
}

运行npm run build构建项目。

Linux下tar命令总结

1. 压缩命令

常用压缩格式:
.zip,.gz,.bz2,.tar.gz,.tar.bz2.

1.1 .zip格式压缩

#压缩文件
zip 压缩文件名 源文件
#压缩目录
zip -r 压缩文件名 源目录

#解压缩.zip文件
unzip 压缩文件

1.2 .gz格式压缩

#压缩为.gz格式的压缩文件,源文件会消失
gzip 源文件
#压缩为.gz格式,源文件保留
#例如:gzip -c img > img.gz
gzip -c 源文件 > 压缩文件
#压缩目录下所有的子文件,但是不能压缩目录
gzip -r 目录

#解压缩文件
gzip -d 压缩文件
或者
gunzip 压缩文件
#解压缩目录(目录中的子文件会被解压缩,目录不会发生变化)
gunzip -r 目录

1.3 .bz2格式压缩

#压缩为.bz2格式,不保留源文件
bzip2 源文件
#压缩之后保留源文件
bzip2 -k 源文件
#注意:bzip2命令不能压缩目录

#解压缩,-k保留压缩文件
bzip2 -d 压缩文件
或者
bunzip2 压缩文件

1.4 打包命令tar

#打包
tar -cvf 打包文件名 源文件
选项:
-c: 打包
-v: 显示打包过程
-f: 指定打包后的文件名
例如:
tar -cvf img1.tar img1

#打包到指定目录
tar czvf test.tar *.txt -C /home/work

#解打包
tar -xvf 打包文件名
选项:
-x: 解打包
例如:
tar -xvf img1.tar

1.5 .tar.gz压缩格式

.tar.gz格式是先打包为.tar格式,再压缩为.gz格式。

tar -zcvf 压缩包名.tar.gz 源文件
选项:
-z: 压缩为.tar.gz格式

#解压缩
tar -zxvf 压缩包名.tar.gz
选项:
-x: 解压缩.tar.gz格式

1.6 .tar.bz2压缩格式

tar -jcvf 压缩包名.tar.bz2 源文件
选项:
-z: 压缩为.tar.bz2格式

#解压缩
tar -jxvf 压缩包名.tar.bz2
选项:
-x: 解压缩.tar.bz2格式
tar -jxvf 压缩包名.tar.bz2 -C 解压目录
选项:
-C: 用来指定想要解压到的目录
#把压缩包放到指定位置
tar -zcvf 绝对路径+压缩包名.tar.gz 源文件
例如:
tar -zcvf /test/img.tar.gz img

1. 打包和压缩

  • 打包:将一大堆文件或目录变成一个总的文件【tar命令】
  • 压缩:将一个大的文件通过一些压缩算法变成一个小文件【gzip,bzip2等】

Linux中很多压缩程序只能针对一个文件进行压缩,这样当你想要压缩一大堆文件时,你得将这一大堆文件先打成一个包(tar命令),然后再用压缩程序进行压缩(gzip bzip2命令)。

Linux下最常用的打包程序就是tar了,使用tar程序打出来的包我们常称为tar包tar包文件的命令通常都是以.tar结尾的。生成tar包后,就可以用其它的程序来进行压缩。

1.1 命令格式

tar [必要参数][选择参数] [文件] 

1.2 命令功能

用来压缩和解压文件,tar本身不具有压缩功能,通过调用压缩功能实现的。

1.3 命令参数

必要参数有如下:

  • -A 新增压缩文件到已存在的压缩
  • -B 设置区块大小
  • -c 建立新的压缩文件
  • -d 记录文件的差别
  • -r 添加文件到已经压缩的文件
  • -u 添加改变了和现有的文件到已经存在的压缩文件
  • -x 从压缩的文件中提取文件
  • -t 显示压缩文件的内容
  • -z 支持gzip解压文件
  • -j 支持bzip2解压文件
  • -Z 支持compress解压文件
  • -v 显示操作过程
  • -l 文件系统边界设置
  • -k 保留原有文件不覆盖
  • -m 保留文件不被覆盖
  • -W 确认压缩文件的正确性

可选参数如下:

  • -b 设置区块数目
  • -C 切换到指定目录
  • -f 指定压缩文件
  • --help 显示帮助信息
  • --version 显示版本信息

2. 常用命令

2.1 .tar

#解包
tar xvf FileName.tar
#打包
tar cvf FileName.tar DirName
#说明:tar是打包,不是压缩

2.1 .gz

#解压1
gunzip FileName.gz
#解压2
gzip -d FileName.gz
#压缩
gzip FileName

2.3 .tar.gz 和 .tgz

#解压
tar zxvf FileName.tar.gz
#压缩
tar zcvf FileName.tar.gz DirName

2.4 .bz2

#解压1
bzip2 -d FileName.bz2
#解压2
bunzip2 FileName.bz2
#压缩
bzip2 -z FileName

2.5 .tar.bz2

#解压
tar jxvf FileName.tar.bz2
#压缩
tar jcvf FileName.tar.bz2 DirName

2.6 .bz

#解压1
bzip2 -d FileName.bz
#解压2
bunzip2 FileName.bz

2.7 .tar.bz

#解压
tar jxvf FileName.tar.bz

2.8 .Z

#解压
uncompress FileName.Z
#压缩
compress FileName

2.9 .tar.Z

#解压
tar Zxvf FileName.tar.Z
#压缩
tar Zcvf FileName.tar.Z DirName

2.10 .zip

#解压
unzip FileName.zip
#压缩
zip FileName.zip DirName

2.11 .rar

#解压
rar x FileName.rar
#压缩
rar a FileName.rar DirName 

参考文档

  1. linux tar命令简介
  2. 每天一个linux命令(28):tar命令
  3. Linux常用命令之压缩打包篇

ajax跨域完全讲解

1. 产生跨域问题的原因

  1. 浏览器限制(出去安全原因)
  2. 跨域
  3. XHR请求

2. 解决思路

image

  1. 浏览器限制(基于同源策略的安全检查),取消安全检查;
  2. jsonp:实现jsonp、不好用(让发出的请求变为不是XHR的类型);
  3. xhr:两种方法 一种:被调方(修改服务器,支持跨域)。第二种:调用方,通过实现代理的方式(隐藏跨域)。
2.1 浏览器禁止检查:命令行参数启动
  1. 终端输入:C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --disable-web-security
  2. 如果方法1不行,通过everyting软件找到chrome.exe所在的路径,在chrome.exe所在的路径按下shift键,点击右键,点击“在此处打开命令行窗口”,然后输入chrome --disable-web-security
chrome --disable-web-security --user-data-dir=g:\temp3
2.2 jsonp(JSON with Padding)

jsonp返回的是js代码,不是json对象。

  • content-type:发送信息至服务器时内容编码类型,即客户端发送请求数据的类型;
  • ajax的属性添加cache:true,表示结果可以被缓存,请求的链接中就没有_=某个值;

jsonp的弊端:

  1. 需要服务器改动代码;
  2. 只支持GET请求;
  3. 发送的不是xhr请求。

image

jsonp方式发出的请求为script类型。

image

客户端->http服务器->应用服务器,然后从,应用服务器->http服务器->客户端。

image

Apache/nginx为http静态服务器,用来处于静态请求或者负载均衡。

2.3 被调用方解决-支持跨域

image

跨域请求和非跨域请求的区别:跨域请求的请求头中多了Origin字段,即当前域名。
image

表示允许所有的域名和方法。

2.4 简单请求和非简单请求
  1. 简单请求:先执行后检测;
  2. 非简单请求:先预检,后执行。
  3. OPTIONS:预检命令
  4. OPTIONS缓存:Access-Control-Max-Age指定缓存预检请求的时间。

非简单请求每次都要发送两条请求,效率很低,可以通过将预检请求缓存来减少请求数量,设置方法是服务端响应头设置Access-Control-Max-Age,值是预检请求缓存时间,如下所示:

// 缓存预检请求1个小时
"Access-Control-Max-Age": "3600"

image
image

2.5 带Cookie的跨域
$.ajax({
    type: "get",
    xhrFields: {
        widthCredentials: true // 发送ajax请求的时候会带上cookie
    }
})
  1. cookie是加在被调用方;
  2. 读cookie只能读到本域的。

不允许设置:Access-Control-Allow-Origin: *;,必须指定为特定的域名。

// enable cookie
res.addHeader("Access-Control-Allow-Credentials", "true")

当产生跨域的时候,请求头中会多一个字段,叫做origin,这个字段存储着当前域的信息。所以在发送带cookie的请求,后台又不知道调用方的域的信息时,可以先取到请求头中origin字段的值,然后再赋值给响应头的access-control-allow-origin字段。
image

2.6 带自定义头的跨域

image

2.7 被调用方解决跨域-nginx解决方案

虚拟主机:多个域名指向同一个服务器,服务器根据不同的域名把请求转到不同的应用服务器。看上去有多个主机,实际上只有一个主机。

nginx配置:

server {
    listen 80; // 监听的端口
    server_name b.com; // 监听的域名
    // 所有的请求都转发到这个地址
    location /{
        proxy_pass http://localhost:8080/;
    }
}

image

nginx配置跨域:

server {
    listen 80; // 监听的端口
    server_name b.com; // 监听的域名
    // 所有的请求都转发到这个地址
    location /{
        proxy_pass http://localhost:8080/;
    }
}
2.8 被调用方解决跨域-apache解决方案

image

2.9 调用方解决跨域-隐藏跨域

反向代理:访问同一个域名的不同url,最后去到两个不同的服务器。

反向代理-nginx配置:

image

反向代理-apache配置:

image
image

当前请求地址:

image

参考文档

  1. Nginx 解决API跨域问题

深入理解js对象

1. 理解对象

创建自定义对象的最简单方式就是创建一个Object的实例,然后再为这个实例添加相应的属性和方法,例子如下:

var person = new Object();
person.name = "lisi";
person.age = 22;
person.job = "worker";

person.sayName = function() {
	console.log(this.name);
}

对象字面量形式创建对象(首选方式):

var person = {
	name: "lisi",
	age: 22,
	job: "worker",
	sayName: function() {
		console.log(this.name);
	}
};

2. 属性类型

2.1 数据属性

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特性。

  • [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。直接在对象上定义的属性,这个特性默认值为true;
  • [[Enumerable]]:表示能否通过for-in循环返回属性;直接在对象上定义的属性,这个特性默认值为true;
  • [[Writable]]:表示能否修改属性的值;直接在对象上定义的属性,这个特性默认值为true;
  • [[Value]]:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为undefined

特别说明:直接在对象上定义的属性,它们的[[Configurable]]、[[Enumerable]]和[[Writable]]特性都被设置为true,而[[Value]]特性被设置为特定的值。

var person = {
    name: 'lisi'
};

这里创建了一个名为name的属性,为它指定的值为lisi[[Value]]特性被设置为lisi,而对这个值的任何修改都将反映在这个位置。

要修改属性默认的特性,必须使用ECMAScript5Object.defineProperty()方法。该方法接收三个参数:属性所属对象、属性名称和一个描述符对象。其中,描述符(descriptor)对象的属性必须是:configurable、enumerable、writable和value。设置其中的一或多个值,可以修改对性的特性值。例如:

var person = {};
Object.defineProperty(person, 'name', {
    // 表明name属性是只读的
    writable: false,
    value: 'lisi'
});
console.log(person.name); // lisi
person.name = 'wangwu';
console.log(person.name); // lisi

在上述例子中:name属性是只读的,即该属性的值不可修改,如果尝试为它指定新值,在非严格模式下,赋值操作会被忽略;在严格模式下,赋值操作将会导致错误:TypeError: Cannot assign to read only property 'name' of object '#<Object>'

var person = {};
Object.defineProperty(person, 'name', {
    // 表明name属性不可配置
    configurable: false,
    value: 'lisi'
});
console.log(person.name); // lisi
delete person.name;
console.log(person.name); // lisi

严格模式下将报错:TypeError: Cannot delete property 'name' of #<Object>

var person = {};
Object.defineProperty(person, 'name', {
    configurable: false,
    value: 'lisi'
});
console.log(Object.getOwnPropertyDescriptor(person, 'name'));
// 运行结果如下:
{ value: 'lisi',
  writable: false,
  enumerable: false,
  configurable: false }

特别说明:在调用Object.defineProperty()方法时,如果不指定,configurable、enumerable和writable特性的默认值都是false。在上述代码中enumerable和writable都没有被指定,默认值均为false。

configurable注意点

特别注意:把configurable设置为false,表示不能从对象中删除属性,即一旦把属性定义为不可配置的,就不能再把它变回可配置的了。再调用Object.defineProperty()方法修改除writable之外的特性,都会导致错误。

var person = {};
Object.defineProperty(person, 'name', {
    configurable: false,
    writable: true,
    value: 'lisi'
});
Object.defineProperty(person, 'name', {
    writable: false,
    value: 'lisi'
});
console.log(Object.getOwnPropertyDescriptor(person, 'name'));
// 运行结果:
{ value: 'lisi',
  writable: false,
  enumerable: false,
  configurable: false }
var person = {};
Object.defineProperty(person, 'name', {
    configurable: false,
    value: 'lisi'
});
Object.defineProperty(person, 'name', {
    writable: true,
    value: 'lisi'
});
console.log(Object.getOwnPropertyDescriptor(person, 'name'));
// 运行结果:
TypeError: Cannot redefine property: name

从上述例子中我们可以知道:可以多次调用Object.defineProperty()方法修改同一个属性,但是前提是configurable设置为true。当configurable设置为false时就会有限制。

configurable值 描述
true 可以多次使用,任意修改其他属性
false 仅可以再次将writable由true修改为false,其他均会报错
2.2 访问器属性

访问器属性不包含数据值;它们包含一对儿getter和setter函数(不是必需的)。在读取访问器属性是,会调用getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下4个特性:

  • [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。直接在对象上定义的属性,这个特性的默认值为true
  • [[Enumerable]]:表示能否通过for-in循环返回属性。直接在对象上定义的属性,这个特性的默认值为true
  • [[Get]]:在读取属性时调用的函数。默认值为undefined
  • [[Set]]:在写入属性时调用的函数。默认值为undefined

特别说明:访问器属性不能直接定义,必须使用Object.defineProperty()来定义。

var book = {
    _year: 2004,
    edition: 1
};
// year是访问器属性
Object.defineProperty(book, 'year', {
    get: function() {
        return this._year;
    },
    set: function(newValue) {
        if (newValue > 2004) {
            this._year = newValue;
            this.edition += newValue - 2004;
        }
    }
});
console.log(Object.getOwnPropertyDescriptor(book, '_year'));
console.log(Object.getOwnPropertyDescriptor(book, 'year'));

// 这里修改访问器属性,会调用访问器属性的set函数
book.year = 2005;
console.log(book.edition); // 2
// 这里读取访问器属性year,会调用访问器属性的get函数,返回this._year
console.log(book.year);
console.log(book._year); // 2005
// 运行结果如下:
{ value: 2004,
  writable: true,
  enumerable: true,
  configurable: true }
{ get: [Function: get],
  set: [Function: set],
  enumerable: false,
  configurable: false }
2

上述例子中定义了一个book对象,该对象有两个默认属性_year和edition_year前面的下划线是一种常用的记号,表示只能通过对象方法访问的属性。访问器属性year包含一个getter函数和一个setter函数,getter函数返回_year的值,setter函数通过计算来确定正确的版本。

var book = {
    _year: 2004,
    edition: 1
};
// year是访问器属性
Object.defineProperty(book, 'year', {
    get: function() {
        return this._year;
    }
});

book.year = 2005;
console.log(book.edition);

运行结果如下:

book.year = 2005;
          ^
TypeError: Cannot set property year of #<Object> which has only a getter

如上例所示:没有指定setter函数的属性不能写,再严格模式下尝试写入属性会报错。

2.2 定义多个属性

ECMAScript5定义了一个Object.defineProperties()方法,可以通过描述符一次定义多个属性。接收两个对象参数:第一个对象是要添加和修改其属性的对象,第二个对象的属性与第一个对象中要添加或修改的属性一一对应。例如下例所示:

var book = {};

Object.defineProperties(book, {
    _year: {
        value: 2004
    },
    edition: {
        value: 1
        },
    year: {// 访问器属性
        get: function() {
            return this._year;
        },
        set: function(newValue){
            if (newValue > 2004) {
                this._year = newValue;
                this.edition += newValue -2004;
            }
        }
    }
});
2.3 读取属性的特性

ECMAScript5Object.getOwnPropertyDescriptor()方法,可以取得给定属性的描述符。方法接受两个参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,如果是访问器属性,这个对象的属性有configurable、enumerable、get和set;如果是数据对象,这个对象的属性有configurable、enumerable、writable和value。例如:

var book = {};

Object.defineProperties(book, {
    _year: {
        value: 2004
    },
    edition: {
        value: 1
    },
    year: {
        get: function() {
            return this._year;
        },
        set: function(newValue) {
            if (newValue > 2004) {
                this._year = newValue;
                this.edition += newValue - 2004;
            }
        }
    }
});

var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
alert(descriptor.value);            //2004
alert(descriptor.configurable);     //false
alert(typeof descriptor.get);       //"undefined"

var descriptor = Object.getOwnPropertyDescriptor(book, "year");
alert(descriptor.value);        //undefined
alert(descriptor.enumerable);   //false
alert(typeof descriptor.get);   //"function"

对于数据属性_yearvalue等于最初的值,configurablefalse,而get等于undefined;对于访问器属性yearvalue等于undefinedenumerablefalse,而get是一个指向getter函数的指针。

注意:在JavaScript中,可以针对任何对象——包括DOM和BOM对象,使用Object.getOwnPropertyDescriptor()方法。

3. 创建对象

虽然Object构造函数或对象字面量都可以用来创建单个对象,但这些方法有个明显的缺点:使用同一个接口创建很多对象,会产生大量重复代码。下面介绍一些创建对象的模式:

3.1 工厂模式
<script type="text/javascript">
function createPerson(name, age, job) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function() {
        console.log(this.name);
    };
    return o;
}
var person1 = createPerson("liujie", 13, "student");
var person2 = createPerson("lisi", 14, "doctor");
person1.sayName();
person2.sayName();
</script>

可以无数次的调用这个函数,每次它都会返回一个包含3个属性和一个方法的对象。 工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题即怎样知道一个对象的类型。

3.2 构造函数模式
<script type="text/javascript">
    function Person(name, age, job) {
        this.name = name;
        this.age = age;
        this.job = job;
        this.sayName = function() {
            console.log(this.name);
        };
    }
    var person1 = new Person("liujie", 13, "student");
    var person2 = new Person("lisi", 15, "doctor");
    person2.sayName();
    person1.sayName();
    // 实例的constructor属性指向构造函数
    console.log(person1.constructor == Person);//true
    console.log(person2.constructor == Person);//true
    //创建的所有对象既是Object的实例,也是Person的实例
    //之所以是Object的实例,是因为所有对象均继承自Object
    console.log(person1 instanceof Object);//true
    console.log(person1 instanceof Person);//true
    console.log(person2 instanceof Object);//true
    console.log(person2 instanceof Person);//true
</script>

Person()中的代码除了与createPerson()中相同的部分外,还存在以下不同之处:

  1. 没有显式地创建对象;
  2. 直接将属性和方法赋给了this对象;
  3. 没有return语句。

特别注意:构造函数始终都应该以一个大写字母开头,非构造函数应该以小写字母开头。构造函数本身也是函数,只不过用来创建对象而已。

要创建Person的新实例,必须使用new操作符。以这种方式调用构造函数实际上经历以下4个步骤:

  1. 创建一个新对象;
  2. 将构造函数的作用域赋值给新对象(因此this就指向了这个新对象);
  3. 执行这个构造函数中的代码,为这个新对象添加属性;
  4. 返回新对象。

在上述例子的中,person1和person2分别保存着Person的一个不同实例。这两个对象都有一个constructor(构造函数)属性,该属性指向Person
创建自定义的构造函数意味着将来可以将它的实例标志为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。

<script type="text/javascript">
    function Person(name, age, job) {
        this.name = name;
        this.age = age;
        this.job = job;
        this.sayName = function() {
            console.log(this.name);
        };
    }
    // 当作构造函数使用
    var person = new Person("liujie", 14, "student");
    person.sayName();// liujie
    // 当作普通函数调用(属性和方法都添加到window对象)
    Person("lisi", 16, "doctor");
    window.sayName();// lisi
    // 在另一个对象的作用域中调用
    var o = new Object();
    Person.call(o, "wangwu", 15, "master");//这里是在对象o的作用域中调用Person构造函数,调用后o就拥有了所有属性和sayName()方法
    o.sayName();//wangwu
</script>

由上述例子可知:构造函数与其他函数的唯一区别在于:调用方式不同。但是,构造函数毕竟也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过new操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new操作符来调用,那它跟普通函数也不会有什么两样。例如,前面例子的Person()函数可以通过上述例子中的任何一种方式来调用。

3.2.1 构造函数问题
console.log(person1.sayName == person2.sayName);// false

构造函数的主要问题在于:每个方法都要在每个实例上重新创建一遍。每个Person实例都有一个不同的Function实例。在前面例子中person1person2都有一个sayName()方法,但是这两个方法不是同一个Function的实例。原因在于:在ECMAScript中的函数是对象,因此每定义一个函数,也就是实例化了一个对象。

创建两个完成同样任务的Function实例的确没有必要;况且有this对象在,根本不用在执行代码前就把函数绑定到特定对象上面。因此,可以把函数定义转移到构造函数外部来解决这个问题。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName;
}

function sayName() {
    alert(this.name);
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

在上述例子中:我们把sayName()函数的定义转移到了构造函数外部。在构造函数内部将sayName属性设置成等于全局的sayName()函数。这样依赖,sayName属性中包含的是一个指向函数的指针,因为person1和person2对象就共享了在全局作用域中定义的同一个sayName()函数。

这样做确实解决了两个函数做同一件事的问题,但是带来了新问题:在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。更让人无法接受的是:如果对象需要定义很多方法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。好在,这些问题可通过原型模式解决。

3.3 原型模式

我们创建的每个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。按照字面意思来理解,prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是: 可以让所有对象实例共享它所包含的属性和方法。即不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下:

function Person() {
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
    console.log(this.name);
};

var person1 = new Person();
person1.sayName();  //"Nicholas"

var person2 = new Person();
person2.sayName();  //"Nicholas"

console.log(person1.sayName == person2.sayName); //true

上述例子中:直接将sayName()方法和所有属性添加到了Personprototype属性中,构造函数变成了空函数。这样一来,通过构造函数创建的新对象都会具有相同的属性和方法,而且新对象的这些属性和方法是由所有实例共享的。

3.3.1 理解原型对象

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个constructor(构造函数)属性,这个属性包含一个指向prototype属性所在函数的指针。

拿前面的例子来说,Person.prototype.constructor指向Person。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。

创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。

wx20180926-135336 2x

>如上图,展示了`Person`构造函数、`Person`的原型属性以及`Person`现有的两个实例之间的关系。其中,`Person.prototype`指向了原型对象,而`Person.prototype.constructor`又指回了`Person`。原型对象中除了包含`constructor`属性之外,还包括后来添加的其他属性。`Person`的每个实例——`person1和person2`都包含了一个内部属性,该属性仅仅指向了`Person.prototype`;换句话说,它们与构造函数没有直接关系。 ###### 3.3.2 isPrototypeOf():确定对象原型方法 ```js console.log(Person.prototype.isPrototypeOf(person1)); //true console.log(Person.prototype.isPrototypeOf(person2)); //true ``` >上述代码说明`person1和person2`内部都有一个指向`Person.prototype`的指针。 ###### 3.3.3 Object.getPrototypeOf():ECMAScript 5新增方法 ```js console.log(Object.getPrototypeOf(person1) == Person.prototype); //true console.log(Object.getPrototypeOf(person1).name); //"Nicholas" ``` >通过上述代码可知:`Object.getPrototypeOf()`方法可以方便地获取到一个对象的原型。 ###### 3.3.4 对象属性读取的搜索顺序 >每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。**搜索首先从实例本身开始**。如果实例中找到了具有给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有指定名字的属性。所以,当为对象实例添加一个属性时,这个属性就会屏蔽原型对象中保存的同名属性。这同时也是多个对象实例共享原型所保存的属性和方法的基本原理。
<script type="text/javascript">
    function Person() {
    }
    Person.prototype.name = "liujie";
    Person.prototype.age = 13;
    Person.prototype.job = "student";
    Person.prototype.sayName = function() {
        console.log(this.name);
    };
    //这里person1和person2访问的是同一组属性和同一个sayName()方法
    var person1 = new Person();
    person1.sayName();//liujie
    var person2 = new Person();
    person2.sayName();//liujie
    console.log(person1.sayName == person2.sayName);//true

    console.log(Person.prototype.isPrototypeOf(person1));//true
    console.log(Person.prototype.isPrototypeOf(person2));//true

    //Object.getPrototypeOf()返回的对象就是原型对象
    console.log(Object.getPrototypeOf(person1) == Person.prototype);//true
    console.log(Object.getPrototypeOf(person2) == Person.prototype);//true
</script>
<script type="text/javascript">
    function Person() {
    }
    Person.prototype.name = "liujie";
    Person.prototype.age = 13;
    Person.prototype.job = "student";
    Person.prototype.sayName = function() {
        console.log(this.name);
    };
    //这里person1和person2访问的是同一组属性和同一个sayName()方法
    var person1 = new Person();
    person1.name = "liujiejie";
    //如果对象实例中的属性与实例原型中的一个属性同名,实例中的属性会屏蔽原型中的那个属性
    //person1.name=null;//即使将这个属性设置为null,也只会在实例中设置这个属性,而不会恢复其指向原型的链接
    delete person1.name;//如果使用delete操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性
    person1.sayName();//liujiejie
    var person2 = new Person();
    // 说明了可以通过对象实例访问保存在原型中的值,但是不能通过对象实例重写原型中的值
    person2.sayName();//liujie
</script>
3.3.5 hasOwnProperty()

hasOwnProperty()方法可以检测一个属性存在于实例中还是存在于原型中。只有给定属性存在于对象实例中时,该方法才返回true

<script type="text/javascript">
    function Person() {
    }
    Person.prototype.name = "liujie";
    Person.prototype.age = 13;
    Person.prototype.job = "student";
    Person.prototype.sayName = function(){
        console.log(this.name);

    };
    //这里person1和person2访问的是同一组属性和同一个sayName()方法
    //使用hasOwnProperty()方法,当给定属性存在于实例中时,返回true
    var person1 = new Person();
    var person2 = new Person();
    console.log(person1.hasOwnProperty("name"));//false
    person1.name = "liujiejie";
    console.log(person1.name);//liujiejie   来自实例
    console.log(person1.hasOwnProperty("name"));//true
    console.log(person2.name);//liujie  来自原型
    console.log(person2.hasOwnProperty("name"));//false
    delete person1.name;
    console.log(person1.name);//liujie 来自原型
    console.log(person1.hasOwnProperty("name")); // false
</script>
3.4 原型与in操作符

特别注意:只要能够访问到给定属性,in操作符就返回true。不管属性存在于原型上还是实例上

<script type="text/javascript">
    function Person() {
    }
    Person.prototype.name = "liujie";
    Person.prototype.age = 13;
    Person.prototype.job = "student";
    Person.prototype.sayName = function() {
        console.log(this.name);

    };
    //这里person1和person2访问的是同一组属性和同一个sayName()方法
    //使用hasOwnProperty()方法,当给定属性存在于实例中时,返回true
    var person1 = new Person();
    var person2 = new Person();
    console.log(person1.hasOwnProperty("name"));//false
    console.log("name" in person1);// true

    person1.name = "liujiejie";
    console.log(person1.name); // liujiejie 来自实例
    console.log(person1.hasOwnProperty("name"));// true
    console.log("name" in person1);// true

    console.log(person2.name); //liujie  来自原型
    console.log(person2.hasOwnProperty("name"));//false
    console.log("name" in person2);//true

    delete person1.name;
    console.log(person1.name);//liujie   来自原型
    console.log(person1.hasOwnProperty("name"));// false
    console.log("name" in person1);// true
</script>

同时使用hasOwnProperty()方法和in操作符,就可以确定该属性到底是存在于对象中,还是存在于原型中。如下代码所示:

// 返回true表示存在于原型,false表示存在于实例中
function hasPrototypeProperty(object, name) {
    return !object.hasOwnProperty(name) && (name in object);
}
3.4.1 for-in循环

在使用for-in循环时,返回的是所有能够通过对象访问的、可枚举(enumerated) 的属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性的实例属性也会存在for-in循环中返回,因为根据规定,所有开发人员定义的属性都是可枚举的,但是在IE8及更早版本中例外。

<script type="text/javascript">
	var obj = {
		toString: function(){
			return "myvalue";
		}
	}
	for(var prop in obj) {
		if(prop == "toString"){
			alert("toString found"); // 在IE中不会显示
		}
	}
</script>
3.4.2 Object.keys()

要取得对象上所有可枚举的实例属性,可以使用ECMAScript5的Object.keys()方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。

<script type="text/javascript">
	function Person(){}
	Person.prototype.name = "lisi";
	Person.prototype.age = 23;
	Person.prototype.sayName = function(){
		alert(this.name + "--" + this.age);
	}
	var keys = Object.keys(Person.prototype);
	console.log(keys);//["name", "age", "sayName"]
	var person1 = new Person();
	person1.name = "wangwu";
	person1.age = 23;
	console.log(Object.keys(person1));//["name", "age"]
	</script>
3.4.3 Object.getOwnPropertyNames()

如果想要得到所有的实例属性,无论它是否可枚举,可以使用Object.getOwnPropertyNames()方法。

<script type="text/javascript">
	function Person(){}
	Person.prototype.name = "lisi";
	Person.prototype.age = 23;
	Person.prototype.sayName = function(){
		alert(this.name + "--" + this.age);
	}
	var keys2 = Object.getOwnPropertyNames(Person.prototype);
	console.log(keys2); // ["constructor", "name", "age", "sayName"]
</script>

注意:这里的结果中包含了不可枚举的constructor属性Object.getOwnPropertyNames()Object.keys()方法都可以用来代替for-in循环。

3.5 更简单的原型语法

在前面的例子中每添加一个属性和方法都要敲一遍Person.prototype。为了减少不必要的输入,从视觉上更好地封装原型的功能,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下代码所示:

function Person() {
}
// 这里将一个新的对象字面量赋值给Person.prototype
// 新的对象字面量的constructor指向Object
Person.prototype = {
    name : "Nicholoas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        console.log(this.name);
    }
};
var friend = new Person();

console.log(friend instanceof Object);  //true
console.log(friend instanceof Person);  //true
console.log(friend.constructor == Person);  //false
console.log(friend.constructor == Object);  //true

在上述代码中:将Person.prototype设置为等于一个对象字面量形式创建的新对象,最终结果相同。但是有一个例外:constructor属性不再指向Person了。因为,每创建一个函数,就会同时创建它的prototype对象,这个对象也会自动获得constructor属性。而我们在这里本质上完全重写了默认的prototype对象,因此constructor属性也就变成了新对象的constructor属性(指向Object构造函数),不再指向Person函数。所以需要显式设置constructor属性到适当的值。

function Person() {
}

Person.prototype = {
    // 显示指定constructor属性
    constructor : Person,
    name : "Nicholoas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

这样重设constructor属性会导致它的[[Enumerable]]特性被设置为true。默认情况下,原生的constructor属性是不可枚举的。因此,考虑使用Object.defineProperty()方法。

function Person() {
}

Person.prototype = {
    name : "Nicholoas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};
Object.defineProperty(Person.prototype, 'constructor', {
    enumerable: false,
    value: Person
});

3.6 原型的动态性

<script type="text/javascript">
    function Person() {
    }
    Person.prototype = {
        constructor: Person,
        name : "Nicholas",
        age : 29,
        job : "Software Engineer",
        sayName : function () {
            alert(this.name);
        }
    };
    var friend = new Person(); // 即使先创建了实例,后修改了原型也没有问题
    Person.prototype.sayHi = function() {//向原型中添加方法
        alert("hi");
    };
    friend.sayHi();   //"hi" – works! 先在实例中寻找sayHi方法,找不到的话再搜索原型
</script>
function Person() {
}
var friend = new Person();// 调用构造函数创建实例,并向实例中添加一个指向最初原型的[[Prototype]]指针,而把原型重写修改后就等于切断了构造函数与最初原型之间的联系。
Person.prototype = {
    //这里重写原型对象切断了现有的原型与任何之前已经存在的对象实例之间的联系,之前的实例引用的仍然是最初的原型
    constructor: Person,
    name : "Nicholas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        console.log(this.name);
    }
};
friend.sayName(); // TypeError: friend.sayName is not a function
var person = new Person();
person.sayName(); // Nicholas

wx20180919-113308 2x

#### 3.7 原生对象的原型

原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object、Array、String,等等)都在其构造函数的原型上定义了方法。

3.8 原型对象的问题

首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。最重要的,其共享的本性对于函数非常合适,对于包含基本值的属性也说得过去,通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。但对于包含引用类型值得属性来说,例如数组,就会有问题了

function Person() {
}
Person.prototype = {
    constructor: Person,
    name : "Nicholas",
    age : 29,
    job : "Software Engineer",
    friends: ['lisi', 'wangwu'],
    sayName : function () {
        console.log(this.name);
    }
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push('xiaohong');
console.log(person1.friends); // [ 'lisi', 'wangwu', 'xiaohong' ]
console.log(person2.friends); // [ 'lisi', 'wangwu', 'xiaohong' ]
console.log(person1.friends === person2.friends); // true

person1和person2实例共享了同一个friends,但是实例一般都是要有属于自己的全部属性。这就是原型模式的问题所在。

4. 组合使用构造函数模式和原型模式

创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参数;可谓是集两种模式之长。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["Shelby", "Court"];
}

Person.prototype = {
    constructor : Person,
    sayName : function() {
        alert(this.name);
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

person1.friends.push("Van");
alert(person1.friends);     //"Shelby,Count,Van"
alert(person2.friends);     //"Shelby,Count"
alert(perosn1.friends == person2.friends);      //false
alert(person1.sayName == person2.sayName);      //true

该例中,实例属性都是在构造函数中定义,共享属性constructor和方法sayName()则是在原型中定义的。这种构造函数和原型混成的模式,是目前在ECMAScript中使用最广泛、认同度最高的一种创建自定义类型的方法。可以说,这是用来定义引用类型的一种默认模式。

5. 动态原型模式

动态原型模式把所有信息都封装在了构造函数中,而通过构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。

function Person(name, age, job) {

    //属性
    this.name = name;
    this.age = age;
    this.job = job;
    //方法
    if (typeof this.sayName != "function" {

        Person.prototype.sayName = function() {
            alert(this.name);
        };
    }
}

var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();

6. 寄生构造函数模式

通常,在前几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的基本**是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象;但从表面上看,这个函数又很像是典型的构造函数。

这个模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。用于不能直接修改Array构造函数,可以使用这个模式。

function SpecialArray() {

    //创建数组
    var values = new Array();

    //添加值
    values.push.apply(values, arguments);

    //添加方法
    values.toPipedString = function() {
        return this.join("|");
    };

    //返回数组
    return values;
}

var colors = new SpecialArray("red", "blue", "green");
alert(colors.toPipedString());  //"red|blue|green"

注意:返回的对象与构造函数或者与构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。因此,不能依赖instanceof操作符来确定对象类型。

7. 稳妥构造函数模式

道格拉斯 · 克罗克福德(Douglas Crockford)发明了JavaScrip中的稳妥对象(durable objects)这个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用this和new),或者在防止数据被其他应用程序(如Mashup)改动时使用。稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建的对象的实例方法不引用this;二是不适用new操作符调用构造函数。

function Person(name, age, job) {

    //创建要返回的对象
    var o = new Object();

    //可以在这里定义私有变量和函数

    //添加方法
    o.sayName function() {
        alert(name);
    };

    //返回对象
    return o;
}

除了使用sayName()方法外,没有别的方式可以访问name的值。

var friend = Person("Nicholas", 29, "Software Engineer");
friend.sayName();   //"Nicholas"

变量friend中保存的是一个稳妥对象,而除了调用sayName()方法外,没有别的方式可以访问其数据成员。即使有其他代码会给这个对象添加方法或数据成员,但也不可能有别的方法访问到构造函数中的原始数据。稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全环境——例如,ADsafe和Caja提供的环境——下使用。

参考文档

  1. JavaScript Object.defineProperty()方法详解
  2. Object.defineProperty()

webpack实战(5)-自动生成HTML文件

[TOC]
该插件直接为项目生成一个或多个HTML文件(文件个数由插件实例的个数决定),并将webpack打包后输出的所有脚本文件自动添加到生成的HTML文件中。通过配置,可以将根目录下用户自定义的HTML文件作为插件生成HTML文件的模板。另外,还可以通过向插件传递参数,控制HTML文件的输出。

1. 插件用法

第一步:在项目根目录下安装插件:

npm install html-webpack-plugin --save-dev

第二步:在webpack配置文件中引入该插件:

const htmlWebpackPlugin = require('html-webpack-plugin');

第三步:在webpack配置文件暴露的对象中添加一个plugins属性,该属性值是一个数组,将新建的html-webpack-plugin对象实例添加到数组中。若不传入任何参数,那么插件将生成默认的html文件。

module.exports = {
    entry: {
        main:'./src/index.js'
    }, 
    output: {
        path: './dist',
        filename: '[name].bundle.js'
    },
    plugins:[
        new htmlWebpackPlugin()
    ]
}

第四步:配置参数,为新建的对象实例传入一个对象字面量参数,初始化对象实例的属性。

module.exports = {
    ... ,
    plugins:[
        new htmlWebpackPlugin({
            filename: 'index.html',
            template: './src/template.html', 
            inject: false,
            title: 'Common template',
            chunks: ['main']
        })
   ]
}

2. htmlWebpackPlugin对象

htmlWebpackPlugin对象有两个属性,一个是files,一个是options。这两个属性的值都是对象。通过EJS语法,可以在HTML模板文件中遍历这两个属性:

<% for(var key in htmlWebpackPlugin.files) { %>
    <%= key %> : <%= JSON.stringify(htmlWebpackPlugin.files[key]) %> //将对象或数组转换为JSON字符串。
<% } %>

<% for(var key in htmlWebpackPlugin.options) { %>
    <%= key %> : <%= JSON.stringify(htmlWebpackPlugin.options[key]) %>  
<% } %>

遍历结果如下:

image

2.1 参数说明
  • title: 指定生成页面的title;
  • filename: 生成的html文件的文件名。默认index.html,可以直接配置带有子目录;
  • template: 指定生成的html文件所依赖的模板文件,模板类型可以是html、jade、ejs等。但是要注意的是,如果想使用自定义的模板文件的时候,需要安装对应的loader

举个例子:

npm install jade-loader --save-dev
// webpack.config.js
loaders: {
    ...
    {
        test: /\.jade$/,
        loader: 'jade'
    }
}
plugins: [
    new HtmlWebpackPlugin({
        ...
        jade: 'path/to/yourfile.jade'
    })
]
  • inject: 添加所有的静态资源(assets)到模板文件,有以下四个值:
    • true:默认值,所有打包后的脚本文件均位于html文件的body底部;
    • body:作用跟true一样,所有打包后的脚本文件均位于html文件的body底部;
    • head:所有打包后的脚本文件均位于html文件的head中;
    • false:所有打包后的脚本文件都不会被自动添加到HTML模板文件中。
  • favicon: 给生成的html文件生成一个favicon,值是一个路径:
plugins: [
    new HtmlWebpackPlugin({
        favicon: 'path/to/my_favicon.ico'
    }) 

然后在生成的html中就有了一个link标签:

<link rel="shortcut icon" href="example.ico">
  • minify: 使用该属性会对生成的html文件进行压缩,默认是falsehtml-webpack-plugin内部集成了html-minifier。因此,还可以对minify进行配置,注意,虽然minify支持BooleanObject,但是不能直接这样写:minify: true,这样会报错ERROR in TypeError: Cannot use 'in' operator to search for 'html5' in true,使用时候必须给定一个{}对象。具体配置如下:
plugins: [
    new HtmlWebpackPlugin({
        ...
        minify: {
            removeAttributeQuotes: true // 移除属性的引号
        }
    })
]
  • hash: true | false 如果值为true,就添加一个唯一的webpack compilation hash给所有已included的 scripts 和 CSS 文件。这对缓存清除(cache busting)十分有用。
  • cache: 默认值是true,表示内容变化的时候生成一个新的文件。
  • showErrors: 默认值为true,当webpack报错的时候,详细的错误信息将被包裹在一个pre中输入到HTML页面中。

chunks: 允许我们只对页面添加部分chunks,主要用于多入口文件的情况。当有多个入口文件时,打包后就会生成多个文件,那么chunks选项用于设置想要使用的js文件,具体配置如下:

entry: {
    index: path.resolve(__dirname, './src/index.js'),
    vender: path.resolve(__dirname, './src/vender.js'),
    main: path.resolve(__dirname, './src/main.js')
}

plugins: [
    new htmlWebpackPlugin({
        chunks: ['index', 'vender']
    })
]

那么编译打包后将只包含如下两个文件:

<script type=text/javascript src="index.js"></script>
<script type=text/javascript src="vender.js"></script>

如果没有设置chunks选项,那么默认是全部包含。

  • excludeChunks: 排除掉不需要的js脚本。
// 等价于上面的配置
excludeChunks: ['main.js']
  • chunksSortMode: 在chunks被加入到html文件前,允许控制chunks应当如何被排序。允许的值:'none','auto','dependency',{function},默认值: 'auto'
    • 'dependency':按照不同文件的依赖关系来排序;
    • 'auto':默认值,插件的内置的排序方式;
    • 'none':无序?
    • {function}:提供一个函数?
  • xhtml: 默认值是false,如果为true,则以兼容xhtml的模式引用文件。
2.2 特殊情况:使用ejs语法向HTML模板文件手动添加打包后的脚本文件:
  1. 由于inject参数不能被同时设置为'head'和'body',因此,当有的打包后的脚本文件需要被添加到head标签,而另外的需要被添加到body标签中时,就需要手动向HTML模板注入脚本。
<head>
    <script src="<%= htmlWebpackPlugin.files.chunks.main.entry %>"></script>
</head>

<body>
<% for(let k in htmlWebpackPlugin.files.chunks){ %>
    <% if(k!=='main'){ %>
    <script src="<%= htmlWebpackPlugin.files.chunks[k].entry %>"></script>
    <% } %>
<% } %>
</body>
  1. 为了网页的加载性能,减少HTTP请求数,当有的打包后的脚本文件需要被内嵌到head标签中,而其余的需要以引用外部资源的方式添加到HTML模板中时,也需要手动向HTML模板注入脚本。
<head>
    ...
    <script type="text/javascript" src="<%= compilation.assets[htmlWebpackPlugin.files.chunks.main.entry.substr(htmlWebpackPlugin.files.publicPath.length)].source() %>"></script>
</head>

<body>
<% for(var k in htmlWebpackPlugin.files.chunks){ %>
    <% if(k!=='main'){ %>
    <script src="<%= htmlWebpackPlugin.files.chunks[k].entry %>"></script>
    <% } %>
<% } %>
</body>

3. 生成多个HTML文件

当开发一个多页面应用程序,那么我们就需要为不同的页面生成不同的HTML文件。通过向plugins数组添加多个插件实例就可以实现:

module.exports = {
  entry: 'index.js',
  output: {
    path: './dist',
    filename: '[name].bundle.js'
  },
  plugins: [
    new HtmlWebpackPlugin(), // 生成index.html 
    new HtmlWebpackPlugin({  // 生成test.html
      filename: 'test.html',
      template: './src/test.html'
    })
  ]
}

4. 相关问题

// 不生效
<%= htmlWebpackPlugin.options.title %>

应该是webpack.config.js的配置文件里面加了html-loader,加了之后会正常解析html文件作为模版,就会直接把<%= htmlWebpackPlugin.options.title %>解析成字符串。如果有html-loader,去掉就可以了。

参考文档

  1. html-webpack-plugin
  2. 插件 html-webpack-plugin 的详解

webpack实战(0-2)-基本配置

[TOC]

1. Entry(入口)

entry是配置模块的入口,可抽象成输入,Webpack执行构建的第一步将从入口开始,搜寻及递归解析出所有入口依赖的模块。

1.1 context

context参数用于配置基础目录,是绝对路径,用于从配置中解析入口起点和加载器。
Webpack在寻找相对路径的文件时,会以context为根目录,context默认为执行启动Webpack时所在的当前工作目录。如果想改变context的默认配置,可以在配置文件里设置:

module.exports = {
  context: path.resolve(__dirname, 'src')
}

注意:context必须是一个绝对路径的字符串。 除此之外,还可以通过在启动 Webpack时带上参数webpack --context来设置context

const path = require('path');

module.exports = {
    mode: 'none',
    // 从当前目录下(运行webpack命令的目录)的src文件夹下寻找入口文件
    context: path.resolve(__dirname, 'src'),
    // 这里因为设置了context
    // 所以是从当前目录下的src目录下查找index.js文件作为入口文件
    entry: './index.js',
    output: {
        publicPath: __dirname + '/dist/', // js引用路径或者CDN地址
        path: path.resolve(__dirname, 'dist'), // 打包文件的输出目录
        filename: 'bundle.js'
    }
};

需要注意:如果没有设置context配置项,webpack也是默认从当前目录下查找的,因此,如果使用context的话,建议加上第二个参数,即从当前目录下哪个目录下查找,或者直接不要这个配置项。

1.2 Entry类型

Entry类型可以是以下三种中的一种或者相互组合:

image

string类型,具体配置如下:

module.exports = {
  entry: './js/index.js',
  output: {
    // 将所有依赖的模块合并输出到一个叫bundle.js文件内
    filename: 'bundle.js',
    // 将输出的文件都放在dist目录下
    path: path.resolve(__dirname, './dist')
  }
};

array类型,具体配置如下:

module.exports = {
  entry: ['./js/index.js', './js/index2.js'],
  output: {
    // 将所有依赖的模块合并输出到一个叫bundle.js文件内
    filename: 'bundle.js',
    // 将输出的文件都放在dist目录下
    path: path.resolve(__dirname, './dist')
  }
};

如果入口类型为数组的话,将会创建多个主入口,并且把数组中的js打包在一起到一个文件里面去。

object类型,具体配置如下:

module.exports = {
  entry: {
    'index': './js/index.js',
    'index2': './js/index2.js'
  },
  output: {
    filename: '[name].js', // [name]的值是entry的键值, 会输出多个入口文件
    // 将输出的文件都放在dist目录下
    path: path.resolve(__dirname, './dist')
  }
};
1.3 Chunk的名称

Webpack会为每个生成的Chunk取一个名称,Chunk的名称和Entry的配置有关:

  1. 如果entry是一个string或array,就只会生成一个Chunk,这时Chunk的名称是main
  2. 如果entry是一个object,就可能会出现多个Chunk,这时Chunk的名称是object键值对中键的名称。
1.4 动态配置entry

假如项目里有多个页面需要为每个页面的入口配置一个 Entry ,但这些页面的数量可能会不断增长,则这时 Entry 的配置会受到到其他因素的影响导致不能写成静态的值。其解决方法是把 Entry 设置成一个函数去动态返回上面所说的配置,代码如下:

// 同步函数
entry: () => {
  return {
    a:'./pages/a',
    b:'./pages/b',
  }
};
// 异步函数
entry: () => {
  return new Promise((resolve)=>{
    resolve({
       a:'./pages/a',
       b:'./pages/b',
    });
  });
};

2. Output(输出)

output配置如何输出最终想要的代码。output是一个object,里面包含一系列配置项:

2.1 filename

output.filename配置输出文件的名称,为string类型。如果只有一个输出文件,则可以将它写成静态不变的:

filename: 'bundle.js'

但是在有多个Chunk要输出时,就需要借助模版和变量了。前面说到,Webpack会为每个Chunk取一个名称,所以我们可以根据Chunk的名称来区分输出的文件名:

filename: '[name].js'

单入口配置如下:

{
  entry: './js/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, './dist')
  }
}

如果有多个chunk要输出时,就需要借助[name]变量了,webpack会为每个chunk取一个名称,因此我们根据chunk的名称来区分输出的文件名。如下:

filename: '[name].js'

多入口配置如下:

{
  entry: {
    index: './js/index.js',
    index2: './js/index2.js'
  }
  output: {
    filename: '[name].js', // [name]的值是entry的键值, 会输出多个入口文件
    path: path.resolve(__dirname, './dist')
  }
}

上面的配置,将会在当前目录下的dist目录中输出index.jsindex2.js

代码里的[name]代表用内置的name变量去替换[name],这时我们可以将它看作一个字符串模块函数,每个要输出的Chunk都会通过这个函数去拼接出输出的文件名称。除了name之外,还有如下内置变量列表:

变量名 含义
id Chunk 的唯一标识,从0开始
name Chunk 的名称
hash Chunk 的唯一标识的 Hash 值
chunkhash Chunk 内容的 Hash 值
其中hash和chunkhash的长度是可指定的,[hash:8]代表取8Hash值,默认是20位。hash配置如下:
entry: {
  'index': './js/index.js',
  'index2': './js/index2.js'
},
output: {
  filename: '[name]_[hash:8].js', // [name]的值是entry的键值, 会输出多个入口文件
  // 将输出的文件都放在dist目录下
  path: path.resolve(__dirname, './dist')
}

上述配置,将会在当前目录下的dist目录中输出index_xxxx.jsindex2_xxxx.js,其中xxxxhash值的随机八位。

注意:ExtractTextWebpackPlugin插件是使用contenthash来代表哈希值而不是chunkhash,原因在于ExtractTextWebpackPlugin提取出来的内容是代码内容本身,而不是由一组模块组成的Chunk

2.2 ChunkFilename

output.chunkFilename用于配置无入口的Chunk在输出时的文件名称。 chunkFilename和上面的filename非常类似,chunkFilename只用于指定在运行过程中生成的Chunk在输出时的文件名称。常见的会在运行时生成 Chunk场景有:使用CommonChunkPlugin(webpack4已经废弃)、使用 import('path/to/module')异步按需动态加载模块等时,一般这样的文件没有被列在entry中。chunkFilename支持和 filename一致的内置变量。

在项目src目录下再新建common文件夹,里面包含一个common.js文件;代码如下:

function a() {
  console.log('a.js');
}
module.exports = a;

然后在index.js入口文件如下编写代码;使用异步方式引用a.js,代码如下:

require.ensure(['./plugins/a.js'], function(require) {
  var aModule = require('./common/a.js');
}, 'a');

然后在webpackoutput配置添加chunkFilename,配置如下:

module.exports = {
  context: path.resolve(__dirname, ''),
  entry: {
    'index': './js/index.js',
    'index2': './js/index2.js'
  },
  output: {
    filename: '[name]_[hash:8].js', // [name]的值是entry的键值, 会输出多个入口文件
    chunkFilename: '[name].min.js', 
    // 将输出的文件都放在dist目录下
    path: path.resolve(__dirname, './dist')
  }
};

其中上面的require.ensure()API的第三个参数是给这个模块命名的,因此在webpack配置完成后,继续打包下,会在dist文件夹下打出a.min.js文件了,这就是chunkFilename的用途场景了。

2.3 path

output.path配置输出文件存放在本地的目录,必须是string类型的绝对路径,一般通过Node.jspath模块获取绝对路径,代码如下:

path: path.resolve(__dirname, './dist')
2.4 PublicPath

在复杂的项目里可能会有一些构建出的资源需要异步加载,加载这些异步资源需要对应的URL地址。
output.publicPath配置发布到线上资源的URL前缀,为string类型。 默认值是空字符串'',即使用相对路径

举个例子,将构建出的资源文件上传到CDN服务上,以利于加快页面的打开速度。配置代码如下:

filename:'[name]_[chunkhash:8].js'
publicPath: 'https://cdn.example.com/assets/'

这时发布到线上的HTML在引入JavaScript文件时就需要以下配置项:

<script src='https://cdn.example.com/assets/a_12345678.js'></script>

使用该配置项时要小心,稍有不慎将导致资源加载404错误。
output.path和output.publicPath都支持字符串模版,内置变量只有一个:hash代表一次编译操作Hash值。

2.5 crossOriginLoading

Webpack输出的部分代码块可能需要异步加载,而异步加载是通过JSONP方式实现的。JSONP的原理是:动态地向HTML中插入一个<script src="url"></script>标签去加载异步资源。
output.crossOriginLoading则是用于配置这个异步插入的标签的 crossorigin值。

script标签的crossorigin属性可以取以下值:

  • crossOriginLoading: false - 禁用跨域加载(默认),在加载此脚本资源时不会带上用户的Cookies;
  • crossOriginLoading: "anonymous" - 不带凭据(credential)启用跨域加载;
  • crossOriginLoading: "use-credentials" - 带凭据(credential)启用跨域加载with credentials,在加载此脚本资源时会带上用户的Cookies。

通常用设置crossorigin来获取异步加载的脚本执行时的详细错误信息。

2.6 LibraryTarget 和 Library

当用Webpack去构建一个可以被其他模块导入使用的库时,需要用到LibraryTarget 和 Library

  • output.libraryTarget:配置以何种方式导出库;
  • output.library:配置导出库的名称。

它们通常搭配在一起使用。output.libraryTarget是字符串的枚举类型,支持以下配置。

2.6.1 Var (默认)

编写的库将通过var被赋值给通过library指定名称的变量。
假如配置了output.library='LibraryName',则输出和使用的代码如下:

// Webpack 输出的代码
var LibraryName = lib_code;

// 使用库的方法
LibraryName.doSomething();

假如output.library为空,则将直接输出:lib_code,其中lib_code代指导出库的代码内容,是有返回值的一个自执行函数。

2.6.2 Commonjs2

编写的库将通过CommonJS2规范导出,输出和使用的代码如下:

// Webpack 输出的代码
module.exports = lib_code;

// 使用库的方法
require('library-name-in-npm').doSomething();

library-name-in-npm是指模块被发布到Npm代码仓库时的名称。
CommonJS2和CommonJS规范很相似,差别在于CommonJS只能用exports导出,而CommonJS2CommonJS的基础上增加了module.exports的导出方式。

output.libraryTargetcommonjs2时,配置output.library将没有意义。

2.6.3 This

编写的库将通过this被赋值给通过library指定的名称,输出和使用的代码如下:

// Webpack 输出的代码
this['LibraryName'] = lib_code;

// 使用库的方法
this.LibraryName.doSomething();
2.6.4 Window

编写的库将通过window赋值给通过library指定的名称,即把库挂载到window 上,输出和使用的代码如下:

// Webpack 输出的代码
window['LibraryName'] = lib_code;

// 使用库的方法
window.LibraryName.doSomething();
2.6.5 Global

编写的库将通过global被赋值给通过library指定的名称,即把库挂载到 global上,输出和使用的代码如下:

// Webpack 输出的代码
global['LibraryName'] = lib_code;

// 使用库的方法
global.LibraryName.doSomething();

2.7 LibraryExport

output.libraryExport配置要导出的模块中哪些子模块需要被导出。它只有在 output.libraryTarget被设置成commonjs 或者 commonjs2时使用才有意义。

假如要导出的模块源代码是:

export const a=1;
export default b=2;

现在想让构建输出的代码只导出其中的a,可以把output.libraryExport设置成 a,那么构建输出的代码和使用方法将变成如下:

// Webpack 输出的代码
module.exports = lib_code['a'];

// 使用库的方法
require('library-name-in-npm')===1;

3. Module

3.1 配置 Loader

rules配置模块的读取和解析规则,通常用来配置Loader。其类型是一个数组,数组里的每一项都描述了如何去处理部分文件。配置一项rules时大致通过以下方式:

  • 条件匹配:通过test、include、exclude三个配置项来选中Loader要应用规则的文件。
  • 应用规则:对选中后的文件通过use配置项来应用Loader,可以只应用一个Loader或者按照从后往前的顺序应用一组Loader,同时还可以分别给 Loader传入参数。
  • 重置顺序一组Loader的执行顺序默认是从右到左执行,通过 enforce选项可以让其中一个Loader的执行顺序放到最前或者最后。

举个例子:

module: {
  rules: [
    {
      // 命中 JavaScript 文件
      test: /\.js$/,
      // 用babel-loader转换JavaScript文件
      // ?cacheDirectory 表示传给 babel-loader 的参数,用于缓存 babel 编译结果加快重新编译速度
      use: ['babel-loader?cacheDirectory'],
      // 只命中src目录里的js文件,加快 Webpack 搜索速度
      include: path.resolve(__dirname, 'src')
    },
    {
      // 命中 SCSS 文件
      test: /\.scss$/,
      // 使用一组 Loader 去处理 SCSS 文件。
      // 处理顺序为从后到前,即先交给 sass-loader 处理,再把结果交给 css-loader,最后再给 style-loader。
      use: ['style-loader', 'css-loader', 'sass-loader'],
      // 排除 node_modules 目录下的文件
      exclude: path.resolve(__dirname, 'node_modules'),
    },
    {
      // 对非文本文件采用file-loader加载
      test: /\.(gif|png|jpe?g|eot|woff|ttf|svg|pdf)$/,
      use: ['file-loader']
    },
  ]
}

Loader需要传入很多参数时,我们还可以通过一个Object来描述,例如在上面的babel-loader配置中有如下代码:

use: [
  {
    loader:'babel-loader',
    options:{
      cacheDirectory:true,
    },
    // enforce: 'post'的含义是把该`Loader`的执行顺序放到最后
    // enforce的值还可以是pre,代表把Loader的执行顺序放到最前面
    enforce:'post'
  },
  // 省略其它 Loader
]

上面的例子中test、include、exclude这三个命中文件的配置项只传入了一个字符串或正则,其实它们还都支持数组类型,使用如下:

{
  test: [
    /\.jsx?$/,
    /\.tsx?$/
  ],
  include:[
    path.resolve(__dirname, 'src'),
    path.resolve(__dirname, 'tests'),
  ],
  exclude:[
    path.resolve(__dirname, 'node_modules'),
    path.resolve(__dirname, 'bower_modules'),
  ]
}

数组里的每项之间是的关系,即文件路径符合数组中的任何一个条件就会被命中。

3.2 NoParse

noParse配置项可以让Webpack忽略对部分没采用模块化的文件的递归解析和处理,这样做的好处是能提高构建性能。原因是一些库例如jQuery、ChartJS 庞大又没有采用模块化标准,让Webpack去解析这些文件耗时又没有意义。

noParse是可选配置项,类型需要是RegExp、[RegExp]、function中的一种。

例如想要忽略掉jQuery、ChartJS,可以使用如下代码:

// 使用正则表达式
noParse: /jquery|chartjs/

// 使用函数,从Webpack 3.0.0开始支持
noParse: (content)=> {
  // content 代表一个模块的文件路径
  // 返回 true or false
  return /jquery|chartjs/.test(content);
}

注意:被忽略掉的文件里不应该包含import、require、define等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。

3.3 Parser

因为Webpack是以模块化的JavaScript文件为入口,所以内置了对模块化 JavaScript的解析功能,支持AMD、CommonJS、SystemJS、ES6

parser属性可以更细粒度的配置哪些模块语法要解析、哪些不解析。和noParse配置项的区别在于:parser可以精确到语法层面,而noParse只能控制哪些文件不被解析。parser使用如下:

module: {
  rules: [
    {
      test: /\.js$/,
      use: ['babel-loader'],
      parser: {
      amd: false, // 禁用 AMD
      commonjs: false, // 禁用 CommonJS
      system: false, // 禁用 SystemJS
      harmony: false, // 禁用 ES6 import/export
      requireInclude: false, // 禁用 require.include
      requireEnsure: false, // 禁用 require.ensure
      requireContext: false, // 禁用 require.context
      browserify: false, // 禁用 browserify
      requireJs: false, // 禁用 requirejs
      }
    },
  ]
}

4. Resolve

Webpack在启动后会从配置的入口模块出发找出所有依赖的模块,Resolve配置Webpack如何寻找模块对应的文件。Webpack内置JavaScript模块化语法解析功能,默认会采用模块化标准里约定的规则去寻找,但我们也可以根据自己的需要修改默认的规则。

4.1 alias

resolve.alias配置项通过别名来将原导入路径映射成一个新的导入路径。例如使用以下配置:

// Webpack alias 配置
resolve:{
  alias:{
    components: './src/components/'
  }
}

当通过import Button from 'components/button'导入时,实际上被 alias等价替换成了import Button from './src/components/button'

以上alias配置的含义是:把导入语句里的components关键字替换成 ./src/components/

这样做可能会命中太多的导入语句,alias还支持$符号来缩小范围到只命中以关键字结尾的导入语句:

resolve:{
  alias:{
    'react$': '/path/to/react.min.js'
  }
}

react$只会命中以react结尾的导入语句,即只会把import 'react'关键字替换成import '/path/to/react.min.js'

4.2 mainFields

有一些第三方模块会针对不同的环境提供几份代码。例如分别提供采用了ES5和ES6的两份代码,这两份代码的位置写在了package.json文件里,代码如下:

{
    "jsnext:main": "es/index.js", // 采用ES6语法的代码入口文件
    "main": "src/index.js" // 采用ES5语法的代码入口文件
}

Webpack会根据mainFields的配置去决定优先采用哪份代码,mainFields默认如下:

module.exports = {
  //...
  resolve: {
    mainFields: ['browser', 'main']
  }
};

Webpack会按照数组里的顺序在package.json文件里寻找,只会使用找到的第一个文件。假如我们想优先采用ES6的代码,则可以如下配置:

mainFields: ['jsnext:main', 'browser', 'main']
4.3 extensions

在导入语句没带文件后缀时,Webpack会自动带上后缀去尝试访问文件是否存在。resolve.extensions用于配置在尝试过程中用到的后缀列表,默认是:

module.exports = {
  //...
  resolve: {
    extensions: ['.js', '.json']
  }
};

也就是说,当遇到require('./data')这样的导入语句时,Webpack会先寻找./data.ts文件,如果该文件不存在,就去寻找./data.js文件,如果该文件不存在,就去寻找./data.json文件,如果还是找不到,就报错。

假如我们想让Webpack优先使用目录下的TypeScript文件,则可以这样配置:

extensions: ['.ts', '.js', '.json']
4.4 modules

resolve.modules配置Webpack去哪些目录下寻找第三方模块,默认只会去node_modules目录下寻找。有时项目里会有一些模块被其他模块大量依赖和导入,由于其他模块的位置不定,针对不同的文件都要计算被导入的模块文件的相对路径,这个路径有时会很长,就像import '../../../components/button',这就可以利用resolve.modules配置项进行优化。假如那些大量被导入的模块都在./src/components目录下,则进行如下配置即可:

resolve: {
    modules: ['./src/components', 'node_modules']
}

之后可以简单的通过import 'button'导入。

4.5 descriptionFiles

resolve.descriptionFiles配置描述第三方模块的文件名称,默认为:package.json,具体配置如下:

module.exports = {
  //...
  resolve: {
    descriptionFiles: ['package.json']
  }
};
4.6 enforceExtension

resolve.enforceExtension默认值为false,如果被配置为true,则所有导入语句都必须带文件后缀,例如设置之前import './foo'能正常工作,开启后就必须写成import './foo.js'

module.exports = {
  //...
  resolve: {
    enforceExtension: false
  }
};
4.7 enforceModuleExtension

resolve.enforceModuleExtensionresolve.enforceExtension的作用类似,但是enforceModuleExtension只对node_modules下的模块生效。enforceModuleExtension通常搭配enforceExtension使用,在enforceExtension: true时,因为安装的第三方模块中大多数导入语句都没有带文件的后缀,所以这时候通过设置enforceModuleExtension: false来兼容第三方模块。

5. Plugin

Plugin用于扩展Webpack的功能,各种各样的Plugin几乎让Webpack可以做任何构建相关的事情。
Plugin的配置很简单,plugins配置项接受一个数组,数组里每一项都是一个要使用的Plugin的实例,Plugin需要的参数通过构造函数传入。

const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

module.exports = {
  plugins: [
    // 所有页面都会用到的公共代码提取到 common 代码块中
    new CommonsChunkPlugin({
      name: 'common',
      chunks: ['a', 'b']
    }),
  ]
};

Plugin使用的难点在于:掌握Plugin本身提供的配置项,而不是如何在Webpack中接入Plugin

6. DevServer

要配置DevServer,除了可以在配置文件里通过devServer传入参数,还可以通过命令行参数传入。需要注意的是: 只有在通过DevServer启动Webpack时,配置文件里的devServer才会生效,因为这些参数所对应的功能都是DevServer提供的,Webpack本身并不认识devServer配置项。

6.1 devServer.hot

devServer.hot配置模块热替换功能。DevServer的默认行为是:在发现源代码被更新后通过自动刷新整个页面来做到实时预览,开启模块热替换功能后,将在不刷新整个页面的情况下通过用新模块替换老模块来做到实时预览。

6.2 devServer.inline

DevServer的实时预览功能依赖一个注入页面里的代理客户端,去接收来自DevServer的命令并负责刷新网页的工作。devServer.inline用于配置是否将这个代理客户端自动注入将运行在页面中的Chunk里,默认自动注入。DevServer会根据我们是否开启inline来调整它的自动刷新策略。

  1. 开启inline,则DevServer会在构建变化后的代码时通过代理客户端控制网页刷新;
  2. 关闭inline,则DevServer将无法直接控制要开发的网页。这时它会通过iframe的方式去运行要开发的网页。在构建完变化后的代码时,会通过刷新iframe来实现实时预览,但这时我们需要通过http://localhost:8080/webpack-dev-server/实时预览自己的网页。

如果想使用DevServer去自动刷新网页实现实时预览,最方便的方法是直接开启 inline

6.3 devServer.historyApiFallback

devServer.historyApiFallback用于方便地开发使用了HTML5 History API的单页应用。这类单页应用要求服务器在针对任何命中的路由时,都返回一个对应的HTML文件。例如:在访问http://localhost/userhttp://localhost/home时都返回index.html文件,浏览器端的javascript代码会从url里解析出当前页面的状态,显示对应的界面。

配置historyApiFallback最简单的做法是:

historyApiFallback: true

上述配置会导致任何请求都会返回index.html文件,这只能用于只有一个HTML文件的应用。如果应用由多个单页应用组成,则需要DevServer根据不同的请求返回不同的HTML文件,配置如下:

historyApiFallback: {
    // 使用正则匹配命中路由
    rewrites: [
        // /user开头的都返回user.html
        {from: /^\/user/, to: '/user.html'},
        // /game开头的都返回user.html
        {from: /^\/game/, to: '/game.html'},
        // 其他的都返回index.html
        {from: /./, to: '/index.html'},
    ]
}
6.4 devServer.contentBase

devServer.contentBase配置DevServerHTTP服务器的文件根目录。在默认情况下为当前的执行目录,通常是项目根目录,所以在一般情况下不需要设置它,除非有额外的文件需要被DevServer服务。例如:想将项目根目录下的public目录设置成DevServer服务器的文件根目录,则可以这样配置:

devServer: {
    contentBase: path.join(__dirname, 'public')
}

需要注意的是:DevServer服务器通过HTTP服务暴露文件的方式分为两类:

  1. 暴露本地文件;
  2. 暴露Webpack构建出的结果,由于构建出的结果交给了DevServer,所以我们在使用DevServer时,会在本地找不到构建出的文件。

contentBase只能用来配置暴露本地文件的规则,可以通过contentBase: false来关闭暴露本地文件。

6.5 devServer.headers

devServer.headers配置项可以在HTTP响应中注入一些HTTP响应头,使用如下:

devServer: {
    headers: {
        'X-foo': 'bar'
    }
}
6.6 devServer.host

devServer.host配置项用于配置DevServer服务监听的地址。host的默认值是127.0.0.1,即只有本地可以访问DevServerHTTP服务。如果想让局域网中的其他设备访问自己的本地服务,可以这样配置:

module.exports = {
  //...
  devServer: {
    host: '0.0.0.0'
  }
};

命令行使用方式:

webpack-dev-server --host 0.0.0.0
6.7 devServer.port

devServer.port配置项用于配置DevServer服务监听的端口,默认使用8080端口。

6.8 devServer.allowedHosts

devServer.allowedHosts配置一个白名单列表,只有HTTP请求的HOST在列表里才正常返回,配置如下:

module.exports = {
  //...
  devServer: {
    allowedHosts: [
      // 匹配单个域名
      'host.com',
      'subdomain.host.com',
      'subdomain2.host.com',
      'host2.com',
      // host.com和所有的子域名*.host.com都将匹配
      '.host.com'
    ]
  }
};
6.9 devServer.disableHostCheck

devServer.disableHostCheck配置项用于配置是否关闭用于DNS重新绑定的HTTP请求的HOST检查。DevServer默认只接收来自本地的请求,关闭后可以接收来自任意HOST的请求。通常用于搭配--host 0.0.0.0使用,因为想让其他设备访问自己的本地服务,但访问时是直接通过IP地址访问而不是通过HOST访问,所以需要关闭HOST检查。

6.10 devServer.https

DevServer默认使用HTTP服务,也可以使用HTTPS服务。配置如下:

module.exports = {
  //...
  devServer: {
    https: true
  }
};

DevServer会自动为我们生成一份HTTPS证书,如果想使用自己的证书,可以这样配置:

module.exports = {
  //...
  devServer: {
    https: {
      key: fs.readFileSync('/path/to/server.key'),
      cert: fs.readFileSync('/path/to/server.crt'),
      ca: fs.readFileSync('/path/to/ca.pem'),
    }
  }
};
6.11 devServer.clientLogLevel

devServer.clientLogLevel配置客户端的日志等级,这会影响我们在浏览器开发者工具控制台里看到的日志内容。clientLogLevel是枚举类型,可取如下值之一:none、error、warning、info。默认为info级别,即输出所有类型的日志,设置为none时可以不输出任何日志。

6.12 devServer.compress

devServer.compress配置是否启用Gzip压缩,默认为false

6.13 devServer.open

devServer.open用于在DevServer启动且第一次构建完成时,自动用我们的系统的默认浏览器去打开要开发的网页。还可以使用devServer.openPage配置项来打开指定URL的网页。

6.14 devServer.overlay

devServer.overlay配置在浏览器中显示编译的错误或者警告。

7. 其他配置

7.1 Target

Target配置项可以让Webpack构建出针对不同运行环境的代码。
6dce3a03d834d0b06de15680b1221279.png

在设置target: 'node'时,在源代码中导入Node.js原生模块的语句require('fs')将会保留,fs模块的内容不会被打包到Chunk中。

7.2 Devtool

devtool配置Webpack如何生成Source Map,默认值是false,即不生成Source Map,若想为构建出的代码生成Source Map以方便调试,可以这样配置:

module.export = {
    devtool: 'source-map'
}
7.3 Watch and WatchOptions

watch配置项用来配置Webpack的监听模式,支持监听文件更新,在文件发生变化时重新编译。在使用Webpack时,监听模式默认是关闭的,打开需进行如下配置:

module.exports = {
  //...
  watch: true
};

在使用DevServer时,监听模式默认开启。除此之外,还提供了watchOptions配置项来更灵活地控制监听模式,具体配置如下:

module.exports = {
  // 只有在开启监听模式时,watchOptions才有意义
  // 默认为false,也就是不开启
  watch: true,
  // 监听模式运行时的参数
  // 在开启监听模式时才有意义
  watchOptions: {
    // 不监听的文件或文件夹,支持正则匹配
    // 默认为空
    ignored: /node_modules/,
    // 监听到变化发生后等300ms再去执行动作,截流
    // 防止文件更新太快而导致重新编译频率太快。默认为300ms
    aggregateTimeout: 300,
    // 判断文件是否发生变化是通过不停地询问系统指定文件有没有变化实现的
    // 默认每秒询问1000次
    poll: 1000
  }
};
7.4 Externals

Externals用来告诉Webpack要构建的代码中使用了哪些不用被打包的模块,也就是说这些模板是外部环境提供的,Webpack在打包时可以忽略它们。

有些Javascript运行环境可能内置了一些全局变量或者模块,例如:在HTMLHEAD标签通过以下代码引入jQuery

<script
  src="https://code.jquery.com/jquery-3.1.0.js"
  integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
  crossorigin="anonymous">
</script>

这时,全局变量jQuery就会被注入网页的Javascript运行环境里。

如果想在使用模块化的源代码里导入和使用jQuery,可能需要这样:

import $ from 'jquery';

$('.my-element').animate(/* ... */);

构建后我们会发现输出的Chunk里包含的jQuery库的内容,这导致jQuery库出现了两次,浪费加载流量,最好是Chunk里不会包含jQuery库的内容。

Externals配置项就是用于解决这个问题的。通过Externals可以告诉WebpackJavascript运行环境中已经内置了哪些全局变量,不用将这些全局变量打包到代码中而是直接使用它们。具体配置如下:

module.exports = {
  //...
  externals: {
    jquery: 'jQuery'
  }
};
7.5 resolveLoader

resolveLoader配置项用来告诉Webpack如何去寻找Loader,因为在使用Loader时是通过其包名称去引用的,Webpack需要根据配置的Loader包名去找到Loader的实际代码,以调用Loader去处理源文件。默认配置如下:

module.exports = {
  //...
  resolveLoader: {
    // 去哪个目录下寻找Loader
    modules: [ 'node_modules' ],
    // 入口文件的后缀
    extensions: [ '.js', '.json' ],
    // 指明入口文件位置的字段
    mainFields: [ 'loader', 'main' ]
  }
};

webpack实战(0-1)-入门配置

[TOC]

1. 模块化

模块化是指将一个复杂的系统分解为多个模块以方便编码。

1.1 CommonJS

CommonJS是一种被广泛使用的javascript模块化规范,其核心**是:通过require方法来同步加载依赖的其他模块,通过module.exports导出需要暴露的接口。CommonJS规范的流行得益于Node.js采用了这种方式,后来这种方式被引入到了网页开发中。

// 导入
const moduleA = require('./moduleA');
// 导出
module.exports = moduleA.someFunc;

CommonJS的优点:

  • 代码可复用于Node.js环境下并运行,例如做同构应用;
  • 通过Npm发布的很多第三方模块都采用了CommonJS规范。

CommonJS的缺点:这样的代码无法直接运行在浏览器环境下,必须通过工具转换成标准的ES5。

1.2 AMD

AMD也是一种javascript模块化规范,与CommonJS最大的不同在于:它采用了异步的方式去加载依赖的模块。AMD规范主要用于解决针对浏览器环境的模块化问题,最具代表性的实现是requirejs

// 定义一个模块
define('module', ['dep'], function(dep) {
    return exports;
});
// 导入和使用
require(['module'], function(module) {
});

AMD的优点:

  1. 可在不转换代码的情况下直接在浏览器中运行;
  2. 可异步加载依赖;
  3. 可并行加载多个依赖;
  4. 代码可运行在浏览器环境和Node.js环境下。

AMD的缺点:在javascript运行环境没有原生支持AMD,需要先导入实现了AMD的库后才能正常使用。

1.3 ES6模块化

ES6模块化是国际标准化组织ECMA提出的javascript模块化规范,它在语言层面上实现了模块化。浏览器厂商和Node.js都宣布要原生支持该规范,它将逐渐取代CommonJS和AMD规范,成为浏览器和服务器通用的模块化解决方案。

// 导入
improt React, {Component} from 'react';
// 导出
export function hello() {};
export default {
 // ...
}

ES6模块化虽然是终极模块化方案,但是它的缺点在于:目前无法直接运行在大部分javascript运行环境下,必须通过工具转换成标准的ES5后才能正常运行。

Webpack 2版本开始,Webpack已经内置了对ES6、CommonJS、AMD模块化语句的支持。

2. 安装webpack

2.1 安装webpack到全局
npm i -g webpack webpack-cli
2.2 安装webpack到项目目录
// 安装最新的稳定版本
npm i -D webpack webpack-cli
// 安装指定版本
npm i -D webpack@<version> webpack-cli@<version>
// 安装最新的体验版本
npm i -D webpack@beta webpack-cli@beta

特别注意:一般不推荐全局安装webpack,原因是可防止不同的项目因依赖不同版本的webpack而导致冲突。

3. webpack使用

在编写webpack代码之前,先搭建如下的项目目录:

webpack-demo             # 项目名
|   |--- dist            # 打包后生成的文件目录             
|   |--- node_modules    # 所有的依赖包
|   |--- src             # 项目源码目录
|   | |-- js             # js文件目录
|   | |-- styles         # css文件目录
|   |--- webpack.config.js # webpack配置文件
|   |--- index.html       # html文件                     
|   |--- .gitignore  
|   |--- README.md
|   |--- package.json

3. 使用Loader

// 安装loader
npm i -D style-loader css-loader
module: {
    rules: [
        {
            test: /\.css$/,
            use: ['style-loader', 'css-loader?minimize']
        }
    ]
}
  • use属性的值是一个由Loader名称组成的数组,Loader的执行顺序是由后到前的;
  • 每个Loader都可以通过URL querystring的方式传入参数,例如上述代码中'css-loader?minimize'中的minimize就是告诉css-loader要开启css压缩。

loader传入属性的方式除了可以通过URL querystring实现,还可以通过Object实现,配置如下:

module: {
    rules: [
        {
            test: /\.css$/,
            use: ['style-loader',
                {
                    loader: 'css-loader',
                    // 设置loader相关参数
                    options: {
                        minimize: true
                    }
                }
            ]
        }
    ]
}

style-loader的工作原理是:将css的内容用javascript里的字符串存储起来,在网页执行javascript时通过DOM操作,动态地向HTML head标签里插入HTML style标签。

4. 使用Plugin

Plugin是用来扩展webpack功能的,通过在构建流程里注入钩子实现,为webpack带来了很大的灵活性。
如果想要使用一个插件,我们只需要require()它,然后把它添加到plugins数组中。我们可以在一个配置文件中因为不同的目的多次使用用一个插件,因此我们可以使用new操作符来创建它的实列。

mini-css-extract-plugin是将css提取到单独文件中的插件,具体配置如下:

npm install --save-dev mini-css-extract-plugin
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      // 指定提取出来的css文件的名称
      filename: "[name]_[contenthash:8].css"
    })
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader"
        ]
      }
    ]
  }
}

[name]代表文件的名称,[contenthash:8]代表根据文件内容算出的8位Hash值。

5. 使用DevServer(端口号默认是8080)

DevServer会启动一个HTTP服务器用于服务网页请求,同时会帮助启动Webpack,并接收Webpack发出的文件变更信号,通过WebSocket协议自动刷新网页做到实时预览。

npm i -D webpack-dev-server

需要注意的是:DevServer会将Webpack构建出的文件保存在内存中,在要访问输出的文件时,必须通过HTTP服务访问。

5.1 实时预览

Webpack在启动时可以开启监听模式,默认是关闭的,之后Webpack会监听本地文件系统的变化,在发生变化时重新构建出新的结果。Webpack默认关闭监听模式,我们可以在启动Webpack时通过如下命令来开启监听模式:

webpack --watch

通过DevServer启动的Webpack会开启监听模式,当发生变化时重新执行构建,然后通知DevServerDevServer会让Webpack在构建出的javascript代码里注入一个代理客户端用于控制网页,网页和DevServer之间通过WebSocket协议通信,以方便DevServer主动向客户端发送命令。DevServer在收到来自Webpack的文件变化通知时,通过注入的客户端控制网页刷新

如果尝试修改index.html文件并保存,则我们会发现这并不会触发以上机制,导致这个问题的原因是:Webpack在启动时会以配置里的entry为入口去递归解析出entry所依赖的文件,只有entry本身和依赖的文件才会被Webpack添加到监听列表里。而index.html文件是脱离了javascript模块化系统的,所以Webpack不知道它的存在。但是要完成上面的实时预览,html页面的bundle.js要直接引入即可,如下html页面代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

为什么http://localhost:8080/bundle.js这样也能访问的到呢?原因是:DevServer会将webpack构建出的文件保存在内存中,DevServer不会理会webpack.config.js里配置的output.path属性的。

5.2 模块热替换

除了上面介绍的改动本地入口文件或依赖文件后,会自动打包,然后会自动刷新浏览器即可看到更新效果外,我们还可以使用模块热替换技术,

模块热替换技术能做到在不重新加载整个网页的情况下,通过将已更新的模块替换旧模块,它默认是关闭的,要开启模块热替换,我们只需在启动DevServer时带上--inline参数即可。如下命令:

webpack-dev-server --inline  
webpack-dev-server --inline --hot

webpack-dev-server有如下两种启动模式:

  1. iframe:该模式下修改代码后会自动打包,但是浏览器不会自动刷新;
  2. inline:内联模式,该模式下修改代码,webpack将自动打包并刷新浏览器。
5.3 支持Source Map

在浏览器中运行javascript代码都是编译器输出的代码,但是如果在代码中碰到一个bug的时候,我们不好调式,因此我们需要Source Map来映射到源代码上,Webpack支持生成Source Map,只需在启动时带上--devtool source-map参数即可;如下命令:

webpack-dev-server --inline --hot --devtool source-map

注意:每次运行如上命令,感觉非常长,因此我们可以在项目的根目录的package.json文件的scripts中添加如下配置:

"scripts": {
  "dev": "webpack-dev-server --devtool source-map --hot --inline"
}

加上如上配置后,我们只需要在命令行中运行npm run dev即可;

其他配置常见的选项:

  • --quiet:控制台中不输出打包的信息;
  • --compress:开启gzip的压缩;
  • --progress:显示打包的进度。

因此在项目中scripts经常会做如下配置:

"scripts": {
  "dev": "webpack-dev-server --progress --colors --devtool source-map --hot --inline",
  "build": "webpack --progress --colors"
}

这样的话,打包的时候会显示打包进度。

也可以在webpack.config.js中添加如下配置:

// 开启Source Map
devtool: 'cheap-module-source-map'

image

webpack之分割代码按需加载

1. 为什么需要按需加载

随着互联网的发展,一个网页需要承载的功能越来越多。采用单页应用作为前端架构的网站会面临着网页需要加载的代码量很大的问题,因为许多功能都被集中做到了一个HTML里。这会导致网页加载缓慢、交互卡顿,使用户体验非常糟糕

导致这个问题的根本原因在于:一次性加载所有功能对应的代码,但其实用户在每个阶段只可能使用其中一部分功能。所以解决以上问题的方法就是用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载

2. 如何使用按需加载

在为单页应用做按需加载优化时,一般采用以下原则:

  • 将整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类。
  • 将每一类合并为一个Chunk,按需加载对应的Chunk
  • 对于用户首次打开网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的Chunk中,以降低用户能感知的网页加载时间。
  • 对于不依赖大量代码的功能点,例如依赖Chart.js去画图表、依赖flv.js去播放视频的功能点,可再对其进行按需加载。

被分割出去的代码的加载需要一定的时机去触发,即当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。

由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,所以可以预估用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。

3. 用Webpack实现按需加载

Webpack内置了强大的分割代码的功能去实现按需加载,实现起来非常简单。举个例子,现在需要做这样一个进行了按需加载优化的网页:

  • 网页首次加载时只加载main.js文件,网页会展示一个按钮,在main.js文件中只包含监听按钮事件和加载按需加载的代码
  • 在按钮被点击时才去加载被分割出去的show.js文件,在加载成功后再执行 show.js里的函数。

其中main.js文件内容如下:

window.document.getElementById('btn').addEventListener('click', function () {
  // 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
  import(/* webpackChunkName: "show" */ './show').then((show) => {
    show('Webpack');
  })
});

show.js文件内容如下:

module.exports = function (content) {
  window.alert('Hello ' + content);
};

代码中最关键的一句是:

import(/* webpackChunkName: "show" */ './show')

Webpack内置了对import(*)语句的支持,当Webpack遇到了类似的语句时会这样处理:

  • ./show.js为入口新生成一个Chunk
  • 当代码执行到import所在的语句时才会去加载由Chunk对应生成的文件。
  • import返回一个Promise,当文件加载成功时可以在Promisethen 方法中获取到show.js导出的内容。

在使用import()分割代码后,浏览器要支持Promise API才能让代码正常运行,因为import()返回一个Promise,它依赖Promise。对于不原生支持 Promise的浏览器,可以注入Promise polyfill

/* webpackChunkName: "show" */的含义是:为动态生成的Chunk赋予一个名称,以方便我们追踪和调试代码。 如果不指定动态生成的Chunk的名称,则其默认的名称将会是[id].js/* webpackChunkName: "show" */是在Webpack3中引入的新特性,在Webpack3之前是无法为动态生成的Chunk赋予名称的。

为了正确输出在/ webpackChunkName: “show” /中配置的ChunkName,还需要配置下Webpack,具体配置如下:

module.exports = {
  // JS 执行入口文件
  entry: {
    main: './main.js',
  },
  output: {
    // 为从entry中配置生成的Chunk配置输出文件的名称
    filename: '[name].js',
    // 为动态加载的Chunk配置输出文件的名称
    chunkFilename: '[name].js',
  }
};

其中,最关键的一行是chunkFilename: '[name].js',,它专门指定动态生成的 Chunk在输出时的文件名称。如果没有这一行,则分割出的代码的文件名称将会是 [id].js

4. 按需加载与ReactRouter

在实战中,不可能会有上面那么简单的场景,接下来举一个实战中的例子:对采用了 ReactRouter的应用进行按需加载优化。这个例子由一个单页应用构成,这个单页应用由两个子页面构成,通过ReactRouter在两个子页面之间切换和管理路由。

这个单页应用的入口文件main.js如下:

import React, { PureComponent, createElement } from 'react';
import {render} from 'react-dom';
import {HashRouter, Route, Link} from 'react-router-dom';
import PageHome from './pages/home';

/**
 * 异步加载组件
 * @param load 组件加载函数,load 函数会返回一个 Promise,在文件加载完成时 resolve
 * @returns {AsyncComponent} 返回一个高阶组件用于封装需要异步加载的组件
 */
function getAsyncComponent(load) {
  return class AsyncComponent extends PureComponent {

    componentDidMount() {
      // 在高阶组件 DidMount 时才去执行网络加载步骤
      load().then(({default: component}) => {
        // 代码加载成功,获取到了代码导出的值,调用 setState 通知高阶组件重新渲染子组件
        this.setState({
          component,
        })
      });
    }

    render() {
      const {component} = this.state || {};
      // component 是 React.Component 类型,需要通过 React.createElement 生产一个组件实例
      return component ? createElement(component) : null;
    }
  }
}

// 根组件
function App() {
  return (
    <HashRouter>
      <div>
        <nav>
          <Link to='/'>Home</Link> | <Link to='/about'>About</Link> | <Link to='/login'>Login</Link>
        </nav>
        <hr/>
        <Route exact path='/' component={PageHome}/>
        <Route path='/about' component={getAsyncComponent(
          // 异步加载函数,异步地加载 PageAbout 组件
          () => import(/* webpackChunkName: 'page-about' */'./pages/about')
        )}
        />
        <Route path='/login' component={getAsyncComponent(
          // 异步加载函数,异步地加载 PageAbout 组件
          () => import(/* webpackChunkName: 'page-login' */'./pages/login')
        )}
        />
      </div>
    </HashRouter>
  )
}

// 渲染根组件
render(<App/>, window.document.getElementById('app'));

以上代码中最关键的部分是getAsyncComponent函数,它的作用是配合 ReactRouter去按需加载组件,具体含义请看代码中的注释。

由于以上源码需要通过Babel去转换后才能在浏览器中正常运行,需要在Webpack 中配置好对应的babel-loader,源码先交给babel-loader处理后再交给 Webpack去处理其中的import(*)语句。

但这样做后你很快会发现一个问题:Babel报出错误说不认识import(*)语法。 导致这个问题的原因是:import(*)语法还没有被加入到在使用ES6语言中提到的 ECMAScript标准中去,为此我们需要安装一个Babel插件babel-plugin-syntax-dynamic-import,并且将其加入到.babelrc中去:

{
  "presets": [
    "env",
    "react"
  ],
  "plugins": [
    "syntax-dynamic-import"
  ]
}

执行Webpack构建后,你会发现输出了三个文件:

  • main.js:执行入口所在的代码块,同时还包括PageHome所需的代码,因为用户首次打开网页时就需要看到PageHome的内容,所以不对其进行按需加载,以降低用户能感知到的加载时间;
  • page-about.js:当用户访问/about时才会加载的代码块;
  • page-login.js:当用户访问/login时才会加载的代码块。

同时我们还会发现,page-about.js和page-login.js这两个文件在首页是不会加载的,而是会在当你切换到了对应的子页面后文件才会开始加载。

babel学习总结

[TOC]

babel学习总结

Babel是一个广泛使用的转码器,可以将ES6代码转为ES5代码,从而在现有环境执行。

1. 基础安装

如果在CLI(command-line interface命令行界面)使用babel的话,请安装babel-cli

# 全局安装
$ npm install -g babel-cli

如果想结合node.js来使用的话,需要安装babel-core

$ npm install -g babel-core

2. 插件和预设(Plugins and Presets)

babel6里并没有默认的转换规则,所以你安装了如上两项,用babel运行你的文件会发现并没有什么变化,因此我们需要安装所需插件,并在.babelrc文件做一些设置:

2.1 例如使用箭头函数
$ npm install -D babel-plugin-transform-es2015-arrow-functions

同时在.babelrc文件添加:

{
  "plugins": ["transform-es2015-arrow-functions"]
}

当然还有很多细节我们不可能一点点全部去安装,我们如果想要转换某些特性的话,可以去安装某个版本的预置,babel可以去向下兼容:

// 把ES2015(即ES6)编译成ES5(目前已经废弃)
$ npm install -D babel-preset-es2015
// 在.babelrc文件中添加:
{
  "presets": ["es2015"]
}

如果想包含所有javascript版本的话:

npm install -D babel-preset-env
// 在.babelrc文件中添加
{
  "presets": ["env"]
}

配置项目所支持浏览器所需的polyfilltransform。只编译所需的代码会使你的代码包更小。

{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["last 2 versions", "safari >= 7"]
      }
    }]
  ]
}

上面的例子只包含了支持每个浏览器最后两个版本和safari大于等于7版本所需的polyfill和代码转换。我们使用browserslist来解析这些信息,所以你可以使用 browserslist 支持的有效的查询格式。

同样,如果你目标开发Node.js而不是浏览器应用的话,你可以配置babel-preset-env仅包含特定版本所需的polyfilltransform:

{
  "presets": [
    ["env", {
      "targets": {
        "node": "6.10"
      }
    }]
  ]
}

方便起见,你可以使用"node": "current"来包含用于运行BabelNode.js最新版所必需的polyfillstransforms

{
  "presets": [
    ["env", {
      "targets": {
        "node": "current"
      }
    }]
  ]
}

具体配置详见

2.2 Plugin/Preset 排序

插件中每个访问者都有排序问题。这意味着如果两次转译都访问相同的程序节点,则转译将按照plugin 或 preset 的规则进行排序然后执行。

  1. Plugin会运行在Preset之前;
  2. Plugin会从第一个开始顺序执行;
  3. Preset的顺序则刚好相反(从最后一个逆序执行)。
{
  "plugins": [
    "transform-decorators-legacy",
    "transform-class-properties"
  ]
}
# 将先执行 transform-decorators-legacy,再执行 transform-class-properties
# 一定要记得 preset 的顺序是反向的。举个例子:

{
  "presets": [
    "es2015",
    "react",
    "stage-0"
  ]
}
# 按以下顺序运行: stage-0,react,最后es2015

这主要是为了保证向后兼容,因为大多数用户会在stage-0之前列出es2015

2.3 babel-plugin-transform-runtime插件

// 源代码如下
let obj = {name: 'lisi'};
let obj2 = {age: 23};
let obj3 = Object.assign({}, obj, obj2);

console.log(obj3);
// 对Object.assign进行编译
// 没有配置babel-plugin-transform-runtime
'use strict';

var obj = { name: 'lisi' };
var obj2 = { age: 23 };
var obj3 = Object.assign({}, obj, obj2);

console.log(obj3);
// 配置babel-plugin-transform-runtime
'use strict';

var _assign = require('babel-runtime/core-js/object/assign');

var _assign2 = _interopRequireDefault(_assign);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var obj = { name: 'lisi' };
var obj2 = { age: 23 };
var obj3 = (0, _assign2.default)({}, obj, obj2);

console.log(obj3);

2.4 babel-polyfill(使得低版本浏览器兼容es6新语法)

# 安装
npm install -D babel-polyfill

使用方法:

  1. require("babel-polyfill");
  2. import "babel-polyfill";
  3. module.exports = { entry: ["babel-polyfill", "./app/js"] };

特别说明:第三种方法适用于使用webpack构建的情况,加入到webpack配置文件(webpack.config.js)的entry项中。

2.5 babel-polyfill和babel-runtime区别

两种方式的原理:

  • babel-polyfill: 不会将代码编译成低版本的ECMAScript,其原理是:当运行环境中并没有实现的一些方法,babel-polyfill中会给做兼容。
  • babel-runtime:es6编译成es5去运行,前端可以使用es6的语法来写,最终浏览器上运行的是es5

两种方式的优缺点:

  • babel-polyfill: 通过向全局对象和内置对象的prototype上添加方法来实现,比如运行环境中不支持Array-prototype.find,引入polyfill,前端就可以放心的在代码里用es6的语法来写;但是这样会造成全局空间污染。比如像Array-prototype.find就不存在了,还会引起版本之前的冲突。不过即便是引入babel-polyfill,也不能全用,代码量比较大。
  • babel-runtime: 不会污染全局对象和内置的对象原型。比如当前运行环境不支持promise,可以通过引入babel-runtime/core-js/promise来获取promise,或者通过babel-plugin-transform-runtime自动重写你的promise。但是它不会模拟内置对象原型上的方法,比如Array-prototype.find,就没法支持了,如果运行环境不支持es6,代码里又使用了find方法,就会出错,因为es5并没有这个方法。

babel-polyfill 与 babel-runtime 的最大区别在于:babel-polyfill改造目标浏览器,让你的浏览器拥有本来不支持的特性;babel-runtime改造你的代码,让你的代码能在所有目标浏览器上运行,但不改造浏览器。

3. 配置文件.babelrc

Babel的配置文件是 .babelrc ,存放在项目的根目录下。使用Babel的第一步,就是配置这个文件。babel 会自动读取 .babelrc 里的配置并应用到编译中。

该文件用来设置转码规则和插件,基本格式如下:

{
  "presets": [],
  "plugins": []
}

presets 字段设定转码规则,官方提供以下的规则集,你可以根据需要安装:

ES2015转码规则
$ npm install --save-dev babel-preset-es2015

react转码规则
$ npm install --save-dev babel-preset-react

# ES7不同阶段语法提案的转码规则(共有4个阶段),选装一个,其中0功能最全
$ npm install --save-dev babel-preset-stage-0
$ npm install --save-dev babel-preset-stage-1
$ npm install --save-dev babel-preset-stage-2
$ npm install --save-dev babel-preset-stage-3

然后,将这些规则加入 .babelrc

 {
    "presets": [
      "es2015",
      "react",
      "stage-2"
    ],
    "plugins": []
  }

注意: 所有Babel工具和模块的使用,都必须先写好 .babelrc

需要注意的是:babel-preset-env 等同于 babel-preset-latest,同时又等同于babel-preset-es2015, babel-preset-es2016babel-preset-es2017三者的集合。

{
  "presets": [
    ["@babel/preset-env", {
      "targets": {
        "browsers": ["last 1 Chrome versions"] //表示只想支持最新版本的Chrome
      }
    }]
  ]
}

最新版本的Chrome已经支持箭头函数、class、const,所以 babel 在编译过程中,不会编译它们。这也是为什么我们把 @babel/preset-env 称为 JavaScript 的 Autoprefixer

4. babel-cli(命令行转码)

Babel提供 babel-cli 工具,用于命令行转码。
安装命令如下:

// 局部安装
npm install -D babel-cli
// 全局安装
npm isntall babel-cli -g

基本用法:

# 将转码结果输出到标准输出(输出到命令行窗口)
$ babel example.js

# 局部安装使用
# 转码结果写入一个新的文件,--out-file 或 -o 参数指定输出文件
node_module/.bin/babel example.js --out-file compiled.js 或者
npx babel example.js --out-file compiled.js

# 全局安装使用
babel example.js --out-file compiled.js
# 或者
babel example.js -o compiled.js

# 整个目录转码,使用--out-dir 或 -d 参数指定输出目录
babel src --out-dir lib
或者
$ babel src -d lib

# 使用-s参数来生成source map文件
$ babel src -d lib -s

在上面给出的转码命令是在全局环境下利用Babel进行转码。也就是说,如果项目要运行,全局环境必须有Babel,这就导致项目对环境产生了依赖。另一方面,这样做也无法支持不同项目使用不同版本的Babel。

尽管你可以把Babel CLI全局安装在你的机器上,但是在项目中安装会更好。

有两个主要的原因:

  1. 在同一台机器上的不同项目或许会依赖不同版本的Babel并允许你有选择的更新。
  2. 这意味着项目对工作环境没有隐式依赖,这可以让项目有很好的可移植性并且易于安装。

对这一问题,相应的解决办法是将babel-cli安装在项目中。然后,在 package.json文件中的scripts字段中加入:

"scripts": {
    "build": "babel src -d lib"
  }

要对项目文件进行转码的时候,就执行下面的命令:

$ npm run build

对于项目中,我们还可以使用npx,npx会自动寻找项目依赖包中的babel-nodebabel

// 进入`REPL`环境
npx babel-node
// 编译并运行test.js
npx babel-node test.js

特别注意: 由于全局运行Babel是一个坏习惯,如果你要卸载全局安装的版本可以执行:

npm uninstall -g babel-cli

5. babel-node(运行代码,是babel-cli下的一个command)

babel-cli工具自带一个babel-node命令,提供一个支持ES6的REPL环境。它支持Node的REPL环境的所有功能,而且可以直接运行ES6代码。babel-node实现了node执行脚本和命令行写代码的能力。

它不用单独安装,而是随 babel-cli 一起安装。然后,执行 babel-node 就进入PEPL环境。

$ babel-node
> (x => x * 3)(2)
6

babel-node 命令可以直接运行ES6脚本。将上面的代码放入脚本文件demo.js,然后直接运行:

$ babel-node demo.js
6

babel-node 也可以安装在项目中:

$ npm install --save-dev babel-node

然后,改写package.json文件中的 scripts 字段:

  "scripts": {
    "script-name": "babel-node script.js"
  }

上面代码中,使用babel-node替代 node ,这样script.js本身就不用做任何转码处理。

6. babel-register

经过babel的编译后,我们的源代码与运行在生产下的代码是不一样的。

babel-register则提供了动态编译。换句话说:我们的源代码能够真正运行在生产环境下,不需要babel编译这一环节。

我们先在项目下安装babel-register

npm install --save-dev @babel/register

然后在入口文件中require:

require('@babel/register')
require('./app');

在入口文件头部引入@babel/register后,我们的app文件中即可使用任意 es2015+的特性。

当然,坏处是动态编译,导致程序在速度、性能上有所损耗。

babel-register模块改写require命令,为它加上一个钩子。此后,每当使用require加载.js.jsx后缀名的文件,就会先用Babel进行转码。

npm install --save-dev babel-register

使用时,必须首先加载babel-register

require("babel-register");
require("./index.js");

然后,就不需要手动对index.js转码了。
需要注意的是,babel-register只会对require命令加载的文件转码,而不会对当前文件转码。另外,由于它是实时转码,所以只适合在 开发环境 中使用。

7. babel-core

babel-core可以看做babel的编译器。babel的核心api都在这里面。
如果某些代码需要调用babel的API进行转码,就要使用 babel-core 模块。
安装命令如下:

npm install babel-core -S

然后,在项目中就可以调用 babel-core

var babel = require('babel-core');

8. 使用webpack工具

8.1 安装

除了安装babel自己的包,还需要多装一个babel-loader配合webpack使用。

npm install -D babel-loader babel-core
8.2 使用

webpack.config.js中加入loader相应的配置:

module: {
  rules: [
    { 
    	test: /\.js$/, 
    	exclude: /node_modules/, 
    	use: ['babel-loader'] 
    }
  ]
}

9. babel 的配置

目前babel官方推荐是写到.babelrc文件下,你还可以在package.json里面添加babel字段。不用配置文件的话,可以把配置当做参数传给babel-cli

9.1 使用.babelrc配置文件
{
"presets": [
 	[
 		"env",
 		{
        "targets": { // 配支持的环境
          "browsers": [ // 浏览器
            "last 2 versions",
            "safari >= 7"
          ],
          "node": "current"
        },
        "modules": true,  //设置ES6 模块转译的模块格式 默认是 commonjs
        "debug": true, // debug,编译的时候 console
        "useBuiltIns": false, // 是否开启自动支持 polyfill
        "include": [], // 总是启用哪些 plugins
        "exclude": []  // 强制不启用哪些 plugins,用来防止某些插件被启用
      }
 	]
],
"plugins": [
 ["transform-runtime", {
   "helpers": true,
   "polyfill": true,
   "regenerator": true,
   "moduleName": "babel-runtime"
 }]
]
}
9.2 在package.json文件中配置
"babel": {
"presets": [
  "env"
],
}
9.3 在命令行参数中配置
babel example.js --plugins=transform-runtime --presets=env

10. webpackbabel配置react开发环境

10.1 安装react
# 安装以下两个包
npm install --save react react-dom
10.2 安装babel以及相应的包

要让babel转换react代码,首先要安装好babel,再装babel转化react的包。

npm install --save-dev babel-core babel-preset-react babel-preset-env
# babel-preset-env这个插件很全,能把所有es6语法都转化

创建.babelrc文件,并进行如下配置:

{
  "presets": ["env", "react"]
}

但是,有时候我们不需要那么全的功能,比如说只需要能转化async函数即可。babel-plugin-transform-async-to-generator这个插件就能办到。

npm install --save-dev babel-plugin-transform-async-to-generator

安装完之后,直接在命令行里转化:

babel index.js --out-file main.js --plugins=transform-async-to-generator

更多插件详见

10.3 在webpack中配置babel-loader

需要在webpack中使用一个loader来转化react的代码。

# 安装相应的loader
npm install --save-dev babel-loader
# 相应的配置文件webpack.config.js
module.exports = {
  entry: './src/app.js',
  ...
  module: {
    rules: [
       { 
	      	test: /\.(js | jsx)$/, 
	      	loader: 'babel-loader', 
	      	exclude: /node_modules/ 
      	}
    ]
  }
};

11. 问题处理

11.1 对ES7 async的支持

使用 eES7的async 会报:ReferenceError: regeneratorRuntime is not defined".

$ npm i -D babel-plugin-transform-runtime

.babelrc 文件中添加:

"plugins": [[
    "transform-runtime",
    {
      "helpers": false,
      "polyfill": false,
      "regenerator": true,
      "moduleName": "babel-runtime"
    }
 ]]

参考文章

  1. Babel 入门教程
  2. babel 教程
  3. Babel 用户手册
  4. Babel 插件手册
  5. plugins
  6. babel之配置文件.babelrc入门详解
  7. Babel 全家桶
  8. Runtime transform · Babel
  9. Polyfill · Babel
  10. babel-polyfill vs babel-runtime
  11. babel到底该如何配置?

JS函数去抖(debounce)和节流(throttle)

[TOC]
DOM操作比起非DOM交互需要更多的内存和CPU时间,连续尝试进行过多的DOM相关操作可能会导致浏览器挂起,有时候甚至会崩溃。

如果在程序中使用了onresize事件处理程序,当调整浏览器大小的时候,该事件会连续触发。如果在该事件处理程序内部进行了相关DOM操作,其高频率的更改可能会导致浏览器崩溃。为了绕开这个问题,我们可以考虑使用定时器对该函数进行节流。

函数节流背后的基本**是:某些代码不可以在没有间断的情况下连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除之前的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是在只有在执行函数的请求停止了一段时间之后才执行。

以下场景往往由于事件频繁被触发,因而频繁执行DOM操作、资源加载等重行为,导致UI停顿甚至浏览器崩溃。

  • window对象的resize、scroll事件;
  • 拖拽时的mousemove事件;
  • 射击游戏中的mousedown、keydown事件;
  • 文字输入、自动完成的keyup事件。

实际上对于window的resize事件,实际需求大多为停止改变大小n毫秒后执行后续处理;而其他事件大多的需求是以一定的频率执行后续处理。针对这两种需求就出现了debounce和throttle两种解决办法。

throttle(又称节流)和debounce(又称去抖)其实都是函数调用频率的控制器。

debounce强制函数在某段时间内只执行一次,throttle强制函数以固定的速率执行。在处理一些高频率触发的DOM事件的时候,它们都能极大提高用户体验。

在处理诸如resize、scroll、mousemovekeydown/keyup/keypress等事件的时候,通常我们不希望这些事件太过频繁地触发,尤其是监听程序中涉及到大量的计算或者有非常耗费资源的操作。

有多频繁呢?以mousemove为例,根据DOM Level 3的规定,「如果鼠标连续移动,那么浏览器就应该触发多个连续的mousemove 事件」,这意味着浏览器会在其内部计时器允许的情况下,根据用户移动鼠标的速度来触发mousemove事件。(当然了,如果移动鼠标的速度足够快,比如一下扫过去,浏览器是不会触发这个事件的)。resize、scroll 和 key*等事件与此类似。

1. Debounce(函数防抖)

DOM事件里的debounce概念其实是从机械开关和继电器的去弹跳(debounce)衍生而来的,基本思路就是:把多个信号合并为一个信号。

JavaScript中,debounce函数所做的事情就是:强制一个函数在某个连续时间段内只执行一次,哪怕它本来会被调用多次。我们希望在用户停止某个操作一段时间之后才执行相应的监听函数,而不是在用户操作的过程当中,浏览器触发多少次事件,就执行多少次监听函数。

比如:在某个3s的时间段内连续地移动了鼠标,浏览器可能会触发几十(甚至几百)个 mousemove事件,不使用debounce的话,监听函数就要执行这么多次;如果对监听函数使用100ms去弹跳,那么浏览器只会执行一次这个监听函数,而且是在第3.1s的时候执行的。

现在,我们就来实现一个 debounce 函数。

1.1 具体实现

debounce函数接收两个参数,第一个是要去弹跳的回调函数 fn,第二个是延迟的时间delay。实际上,大部分的完整debounce 实现还有第三个参数immediate,表明回调函数是在一个时间区间的最开始执行(immediate为true)还是最后执行(immediate为false),比如underscore_.debounce。本文不考虑这个参数,只考虑最后执行的情况,感兴趣的可以自行研究。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        #box, #test {
            width: 100px;
            height: 100px;
            background-color: aqua;
        }
    </style>
</head>
<body>
    <div id="box"></div>
    <div id="test"></div>
    <script>
        function debounce(fn, delay) {
            var timer = null;
            return function () {
                // 保存函数调用时的上下文和参数
                var that = this;
                var args = arguments;
                // 每次这个返回的函数被调用,就清除定时器,以保证不执行fn
                clearTimeout(timer);
                timer = setTimeout(function () {
                    fn.apply(that, args);
                }, delay);
            }
        }
        const box = document.querySelector('#box');
        const box2 = document.querySelector('#test');
        let time = 0;
        let time2 = 0;
        // 没有采用防抖
        document.addEventListener('mousemove', function() {
            box2.innerHTML = ++time;
        }, false);
        // 采用防抖处理
        document.addEventListener('mousemove', debounce(function() {
            box.innerHTML = ++time2;
        }, 250), false);
        document.addEventListener('mouseleave', function() {
            time = 0;
            time2 = 0;
            box.innerHTML = 0;
            box2.innerHTML = 0;
        }, false);
    </script>
</body>
</html>

实现思路:debounce返回了一个闭包,这个闭包依然会被连续频繁地调用,但是在闭包内部,却限制了原始函数fn的执行,强制fn只在连续操作停止后只执行一次。

再来考虑另外一个场景:根据用户的输入实时向服务器发ajax请求获取数据。我们知道,浏览器触发key*事件也是非常快的,即便是正常人的正常打字速度,key*事件被触发的频率也是很高的。以这种频率发送请求,一是我们并没有拿到用户的完整输入发送给服务器,二是这种频繁的无用请求实在没有必要。

更合理的处理方式是:在用户停止输入一小段时间以后,再发送请求。那么 debounce就派上用场了:

$('input').on('keyup', debounce(function(e) {
	// 发送 ajax 请求
}, 300))
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        #box {
            width: 100px;
            height: 100px;
            background-color: blueviolet;
        }
    </style>
</head>
<body>
    <div id="box"></div>
    <script>
        var box = document.querySelector('#box');
        var height = 100;
        function debounce(fn, delay) {
            var timer = null;
            return function () {
                var that = this;
                clearTimeout(timer);
                timer = setTimeout(function() {
                    fn.call(that);
                }, delay);
            }
        }
        /*
        没有做防抖处理
        window.addEventListener('resize', function() {
            height += 1;
            box.style.height = height + 'px';
        }, false);
        */
        window.addEventListener('resize', debounce(function() {
            height += 1;
            box.style.height = height + 'px';
        }, 250), false);
    </script>
</body>
</html>

2. Throttle(函数节流)

throttle的概念理解起来更容易,就是固定函数执行的速率,即所谓的节流。正常情况下,假设mousemove的监听函数每20ms执行一次,如果设置200ms的节流,那么它就会每200ms执行一次。比如在 1s 的时间段内,正常的监听函数可能会执行 50(1000/20) 次,节流 200ms 后则会执行 5(1000/200)次。

2.1 具体实现

debounce类似,throttle也接收两个参数,一个实际要执行的函数fn,一个执行间隔阈值threshhold

同样的,throttle的更完整实现可以参看underscore_.throttle

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        .wrapper {
            width: 200px;
            height: 200px;
            float: left;
            border: 1px solid #ddd;
            overflow: auto;
            position: relative;
        }
        .wrapper .content {
            height: 100%;
            width: 100%;
            overflow: auto;
        }
        .content .inner {
            height: 6000px;
        }
        .wrapper .desc {
            position: absolute;
        }
        .wrapper .count {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        .normal {
            margin-right: 20px;
        }
    </style>
</head>
<body>
    <h3>Try scrolling in the 2 boxes...</h3>
    <div>
        <div class="wrapper normal">
            <div class="desc">Normal scroll</div>
            <div class="content">
                <div class="inner"></div>
            </div>
            <span id="normal" class="count">0</span>
        </div>
        <div class="wrapper throttled">
            <div class="desc">Throttled scroll</div>
            <div class="content">
                <div class="inner"></div>
            </div>
            <span id="throttled" class="count">0</span>
        </div>
    </div>
    <script>
        function throttle(fn, threshhold) {
            // 记录上次执行的时间
            var last;
            // 定时器
            var timer = null;
            // 默认间隔为250ms
            threshhold || (threshhold = 250);
            // 返回函数,每隔threshhold毫秒就执行一次fn函数
            return function () {
                // 保存函数调用时的上下文和参数,传递给fn
                var that = this;
                var args = arguments;
                var now = +new Date();
                // 如果距离上次执行fn函数的时间小于threshhold,就不执行fn
                // 否则执行fn,并重新计时
                if (last && now < last + threshhold) {
                    clearTimeout(timer);
                    // 保证在当前时间区间结束后,再执行一次fn
                    timer = setTimeout(function() {
                        last = now;
                        fn.apply(that, args);
                    }, threshhold);
                }
                else {
                    last = now;
                    fn.apply(that, args);
                }
            }
        }
        var normalCount = 0;
        var throttledCount = 0;
        var normalSpan = document.querySelector('#normal');
        var throttledSpan = document.querySelector('#throttled');
        var normalContent = document.querySelector('.normal .content');
        var throttledContent = document.querySelector('.throttled .content');
        normalContent.addEventListener('scroll', function() {
            normalSpan.innerText = ++normalCount;
        }, false);
        throttledContent.addEventListener('scroll', throttle(function() {
            throttledSpan.innerText = ++throttledCount;
        }, 250), false);
        document.addEventListener('mouseleave', function() {
            normalCount = 0;
            throttledCount = 0;
            normalSpan.innerText = 0;
            throttledSpan.innerText = 0;
        }, false);
    </script>
</body>
</html>

原理也不复杂,相比 debounce,无非是多了一个时间间隔的判断,其他的逻辑基本一致。throttle 的使用方式如下:

$(document).on('mouvemove', throttle(function(e) {
	// 代码
}, 250))

3. debounce和throttle各自的使用场景

  • throttle常用的场景是限制 resize 和 scroll 的触发频率。
  • debounce常用的场景是限制 mousemove 和keydown/keyup/keypress。
3.1 debounce使用场景

第一次触发后,进行倒计wait毫秒,如果倒计时过程中有其他触发,则重置倒计时;否则执行fn。用它来丢弃一些重复的密集操作、活动,直到流量减慢。例如:

  • 对用户输入的验证,不在输入过程中就处理,停止输入后进行验证;
  • 提交ajax时,不希望1s中内大量的请求被重复发送。
3.2 throttle使用场景

第一次触发后先执行fn(当然可以通过{leading: false}来取消),然后wait ms后再次执行,在单位wait毫秒内的所有重复触发都被抛弃。即如果有连续不断的触发,每wait ms执行fn一次。与debounce相同的用例,但是你想保证在一定间隔必须执行的回调函数。例如:

  • 对用户输入的验证,不想停止输入再进行验证,而是每n秒进行验证;
  • 对于鼠标滚动、window.resize进行节流控制。

4. 正真的业务场景:

一个相当常见的例子:用户在你无限滚动的页面上向下滚动鼠标加载页面,你需要判断现在距离页面底部多少。如果用户快接近底部时,我们应该发送请求来加载更多内容到页面。在此debounce没有用,因为它只会在用户停止滚动时触发,但我们需要用户快到达底部时去请求。通过throttle我们可以不间断的监测距离底部多远。

$(document).ready(function(){
  // 这里设置时间间隔为300ms
  $(document).on('scroll', throttle(function(){
    check_if_needs_more_content();
  }, 300));

  // 是否需要加载更多资源
  function check_if_needs_more_content() {     
    var pixelsFromWindowBottomToBottom = 0 + $(document).height() - $(window).scrollTop() - $(window).height();
    // 滚动条距离页面底部小于200,加载更多内容
    if (pixelsFromWindowBottomToBottom < 200){
      // 加载更多内容
      $('body').append($('.item').clone()); 
    }
  }
});

5. debounce和throttle的差异(可视化解释)

image

6. 项目实例

在学习Vue的时候,官网也用到了一个里子,就是用于对用户输入的事件进行了去抖,因为用户输入后需要进行ajax请求,如果不进行去抖会频繁的发送ajax请求,所以通过debounce对ajax请求的频率进行了限制。

methods: {
  // `_.debounce` 是一个通过 Lodash 限制操作频率的函数。
  // 在这个例子中,我们希望限制访问 yesno.wtf/api 的频率
  // AJAX 请求直到用户输入完毕才会发出。想要了解更多关于
  getAnswer: _.debounce(function() {
    if (!reg.test(this.question)) {
      this.answer = 'Questions usually end with a question mark. ;-)';
      return;
    }
    this.answer = 'Thinking ... ';
    let self = this;
    axios.get('https://yesno.wtf/api')
    // then中的函数如果不是箭头函数,则需要对this赋值self
    .then((response) = > {
      this.answer = _.capitalize(response.data.answer)
    }).
    catch ((error) = > {
      this.answer = 'Error! Could not reach the API. ' + error
    })
  }, 500) // 这是我们为判定用户停止输入等待的毫秒数
},

参考文档

  1. Debounce 和 Throttle 的原理及实现
  2. debounce 和 throttle 的可视化差异
  3. 函数去抖(debounce)和函数节流(throttle)
  4. debounce与throttle区别
  5. JS高级技巧学习小结
  6. 7分钟理解JS的节流、防抖及使用场景

editorconfig和eslint配置

在使用不同的编辑器,或者在window 或 mac 等不同的系统上编写的代码,会有一定的风格差异,如在window 上默认换行 使用 crlf , 而在 mac 上的换行风格是 lf ; 有的编辑器默认缩进使用 tab , 而有的编辑器使用 space ; 为了统一这些差异, 我们需要使用 editorconfig 来统一规范。

在团队开发中,统一的代码格式是必要的。但是不同开发人员的代码风格不同,代码编辑工具的默认格式也不相同,这样就造成代码的differ。而editorConfig可以帮助开发人员在不同的编辑器和IDE中定义和维护一致的编码风格。本文将详细介绍统一代码风格工具editorConfig

editorConfig不是什么软件,而是一个名称为.editorconfig的自定义文件。该文件用来定义项目的编码规范,编辑器的行为会与.editorconfig 文件中定义的一致,并且其优先级比编辑器自身的设置要高,这在多人合作开发项目时十分有用而且必要

有些编辑器默认支持editorConfig,如webstorm;而有些编辑器则需要安装editorConfig插件,如ATOM、Sublime、VS Code等

当打开一个文件时,EditorConfig插件会在打开文件的目录和其每一级父目录查找.editorconfig文件,直到有一个配置文件root=true

EditorConfig的配置文件是从上往下读取的并且最近的EditorConfig配置文件会被最先读取. 匹配EditorConfig配置文件中的配置项会按照读取顺序被应用, 所以最近的配置文件中的配置项拥有优先权。

如果.editorconfig文件没有进行某些配置,则使用编辑器默认的设置。

1. editorConfig配置

当多人团队进行一个项目开发时,每个人可能喜欢的编辑器不同,有人喜欢Webstrom、有人喜欢sublime、还有人喜欢Hbuilder。

这个时候,问题便迎面而来,如何使使用不同编辑器的开发者能够轻松惬意的遵守最基本的代码规范呢?

最后终于找到了editorConfig这个东东,发现在这里配置的代码规范规则优先级高于编辑器默认的代码格式化规则。比如我使用的是Webstrom编辑器,我每一次写完代码之后,都习惯性的按下Ctrl+Alt+L快捷键去整理代码格式。如果我没有配置editorconfig,执行的就是编辑器默认的代码格式化规则;如果我已经配置了editorConfig,则按照我设置的规则来,从而忽略浏览器的设置。

什么是EditorConfig?官方介绍:帮助开发人员定义和维护一致性开发风格(coding style)。它可以让代码在各种编辑器和IDE中保持风格一致,当然也可以让不同的队员写一致风格的代码

1.1 常用配置
  1. charset:代码编码方式;
  2. indent_style:缩进方式;
  3. indent_size:缩进大小;
  4. insert_final_newline:是否让文件以空行结束;
  5. trim_trailing_whitespace:自动删除文件末尾空白行;
  6. max_line_length:最大行宽。
1.2 使用方法
  1. 下载与安装编辑器对应的EditorConfig 插件;
  2. 在项目根目录下创建一个名为.editorconfig的文件。
  3. .editorconfig文件中配置相应的规范。

其工作原理是:当你在编码时,EditorConfig 插件会去查找当前编辑文件的所在文件夹或其上级文件夹中是否有.editorconfig 文件。如果有,则编辑器的行为会与.editorconfig文件中定义的一致,并且其优先级高于编辑器自身的设置。

1.3 文件语法

editorConfig配置文件需要是UTF-8字符集编码的, 以回车换行或换行作为一行的分隔符。

斜线(/)被用作为一个路径分隔符,井号(#)或分号(;)被用作于注释. 注释需要与注释符号写在同一行。

1.4 通配符
  1. *:匹配除/之外的任意字符串;
  2. **:匹配任意字符串;
  3. ?:匹配任意单个字符;
  4. [name]:匹配name中的任意一个单一字符;
  5. [!name]:匹配不存在name中的任意一个单一字符;
  6. {s1,s2,s3}:匹配给定的字符串中的任意一个(用逗号分隔);
  7. {num1..num2}:匹配num1到num2之间的任意一个整数, 这里的num1和num2可以为正整数也可以为负整数。
1.5 属性
  • indent_style:设置缩进风格(tab是硬缩进,space为软缩进);
  • indent_size:用一个整数定义的列数来设置缩进的宽度,如果indent_style为tab,则此属性默认为tab_width;可选值:
    • 整数。一般设置 2 或 4;
    • tab
  • tab_width:用一个整数来设置tab缩进的列数。默认是indent_size;
  • end_of_line:设置换行符格式,值为lf、cr和crlf;一般使用lf。
  • charset:设置文件编码,值为latin1、utf-8、utf-8-bom、utf-16be和utf-16le,一般使用utf-8;
  • trim_trailing_whitespace:是否删除行尾的空格。
  • insert_final_newline:是否在文件的最后插入一个空行;
  • root:表示是最顶层的配置文件,发现设为true时,才会停止查找.editorconfig文件。
1.6 使用EditorConfig

下面的实例中使用4个空格来缩进,并不是说按一下空格会自动打出4个空格来,也不是说要连按4下空格;而是按tab键时,编辑器会自动输出4个空格的宽度,而不是之前默认的制表符(\t)

使用EditorConfig很简单,在项目根目录放置一个.editorconfig文件,官方的一个示例:

# top-most EditorConfig file
root = true

# 使用Unix-style 换行符,并且每个文件以换行结束
[*]
end_of_line = lf
insert_final_newline = true

# 可以使用通配符匹配多个文件
# 设置默认编码为utf-8
[*.{js,py}]
charset = utf-8

# py文件 4 个空格缩进
[*.py]
indent_style = space
indent_size = 4

#  使用Tab缩进
[Makefile]
indent_style = tab

# lib目录下的js使用2个空格缩进
[lib/**.js]
indent_style = space
indent_size = 2

# 配置 package.json  .travis.yml文件 设置其为2个空格缩进
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2

上面这个例子,是对js和py代码风格作了定义,看注释应该很容易理解。EditorConfig文件使用INI格式。支持的属性如下:

  1. indent_style:tab为hard-tabs,space为soft-tabs;
  2. indent_size:设置整数表示规定每级缩进的列数和soft-tabs的宽度(译注:空格数)。如果设定为tab,则会使用tab_width的值(如果已指定);
  3. tab_width:设置整数用于指定替代tab的列数。默认值就是indent_size的值,一般无需指定;
  4. end_of_line:定义换行符,支持lf、cr和crlf;
  5. charset:编码格式,支持latin1、utf-8、utf-8-bom、utf-16be和utf-16le,不建议使用uft-8-bom;
  6. trim_trailing_whitespace:设为true表示会除去换行行首的任意空白字符,false反之;
  7. insert_final_newline:设为true表明使文件以一个空白行结尾,false反之;
  8. root:表明是最顶层的配置文件,发现设为true时,才会停止查找.editorconfig文件。

按照上面配置即可,在IDE中需要开启相应EditorConfig插件,比如`VScode中启用后,即可完成配置。下面是我的配置:

root = true

# 匹配所有文件类型
[*]
# 设置字符编码
charset = utf-8
# 设置缩进格式
indent_style = space
# 使用4个空格缩进
indent_size = 4
# 设置换行格式
end_of_line = lf
# 在文件最后添加一个空行
insert_final_newline = true
# 删除行末尾多余的空格
trim_trailing_whitespace = true

[*.json]
indent_size = 4

# 对后缀名为md的文件生效
[*.md]
trim_trailing_whitespace = false

2. eslint

ESLint是代码约束工具,也可以理解成代码质量工具,JS开发人员可以定义规则,ESLint根据这些规则来检查代码是否符合规范。之前有个工具叫JSLint,我没怎么用过。据说ESLint比JSLint更好用,它适配最新的ES6, ES7等语法。

不管写前端还是写后台代码都有所谓的代码规范,从约束层面上来说,以前只是大家约定了这样的规范,可能还配合使用IDE的自动化格式等工具,但实际上写好还是写坏,是没有检查的。通过ESLint可以让整个团队写出高度一致性,极具美观的代码。

2.1 安装相应的npm包
npm install eslint babel-eslint eslint-config-airbnb eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react -D
1.3 在项目中使用eslint
{
    "parser": "babel-eslint",
    "extends": "airbnb",
    "installedESLint": true,
    "plugins": [
        "react",
        "jsx-a11y",
        "import"
    ],
    "rules": {
        // js缩进规则是4个空格
        "indent": ["error", 4],
        // 一行长度最多是150个字符
        "max-len": ["error", 150],
        // jsx的缩进规则是4个空格
        "react/jsx-indent": ["error", 4],
        // jsx文件可以是.js或.jsx后缀
        "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx"] }],
        "react/jsx-indent-props": ["error", 4]
    }

特别注意:这里的4个空格,其实我实际是用tab来完成缩进,而不是按4下空格,所以上面的EditorConfig就可以帮上大忙了。如果不用EditorConfig,那么ESLint会警告你使用了Tab,破坏了规则。

参考博文

  1. 统一代码风格工具——editorConfig
  2. EditorConfig
  3. EditorConfig 介绍
  4. eslint + editorconfig
  5. 我是如何在公司项目中使用ESLint来提升代码质量的
  6. js代码规范之Eslint安装与配置

git stash命令总结

1. 储藏(Stashing)

在经常有这样的事情发生,当你正在进行项目中某一部分的工作,里面的东西处于一个比较杂乱的状态,而你想转到其他分支上进行一些工作。问题是,你不想提交进行了一半的工作,否则以后你无法回到这个工作点。解决这个问题的办法就是git stash命令。

“‘储藏”“可以获取你工作目录的中间状态——也就是你修改过的被追踪的文件和暂存的变更——并将它保存到一个未完结变更的堆栈中,随时可以重新应用。

储藏你的工作
为了演示这一功能,你可以进入你的项目,在一些文件上进行工作,有可能还暂存其中一个变更。如果你运行 git status,你可以看到你的中间状态:
image
image
image
image
image

暂存未跟踪或忽略的文件

默认情况下,git stash会缓存下列文件:

  1. 添加到暂存区的修改(staged changes);
  2. Git跟踪的但并未添加到暂存区的修改(unstaged changes);

但不会缓存一下文件:

  1. 在工作目录中新的文件(untracked files)
  2. 被忽略的文件(ignored files)

git stash命令提供了参数用于缓存上面两种类型的文件。使用-u或者--include-untracked可以stash untracked文件;使用-a或者--all命令可以stash当前目录下的所有修改。

webpack4实战(01)-从零配置打包js开始

[TOC]

1. webpack4零配置

  1. 创建项目目录并进入目录:
mkdir webpack4-demo && cd $_
  1. 初始化package.json文件
npm init -y
  1. 安装webpack(需要同时安装webpack-cli)
npm i webpack webpack-cli -D
  1. 打开package.json文件,并添加相应的build(构建)脚本:
"scripts": {
  "dev": "webpack --mode develop",
  "build": "webpack --mode production"
}

需要注意的是:在webpack4以前的版本中,必须在webpack.config.js的配置文件中通过entry属性定义entry point(入口点)和output属性定义输出目录和文件。但是,从webpack4开始,我们不需要必须定义entry point(入口点)和输出目录了,webpack将入口点默认为./src/index.js./dist/main.js为输出模块包。
所谓entry point(入口点)是webpack寻找开始构建 Javascript包的文件。

webpack同时支持es6,CommonJS,AMD三种模块化规范。

// ES6
import sum from './vender/sum';
console.log('sum(1, 2) = ', sum(1, 2));

// CommonJS
var minus = require('./vender/minus');
console.log('minus(1, 2) = ', minus(1, 2));

// AMD
require(['./vender/multi'], function(multi) {
    console.log('multi(1, 2) = ', multi(1, 2));
});

运行npm run build打包:

image

2. production(生产)和development(开发)模式

运行如下命令:

npm run dev

查看./dist/main.js,是一个未被压缩过的文件。

运行如下命令:

npm run build

查看./dist/main.js,是一个未被压缩过的文件。

这说明了,production mode(生产模式)可以开箱即用地进行各种优化。包括压缩,作用域提升,tree-shaking等;development mode(开发模式)针对速度进行了优化,仅仅提供了一种不压缩的bundle

webpack4中,我们可以在没有一行配置的情况下完成任务!只需定义–mode参数。

3. 覆盖默认entry(入口)/output(输出)

webpack4支持了零配置,但如何覆盖默认entry point(入口点) 和默认output(输出)呢?

package.json中进行如下配置:

"scripts": {
  "dev": "webpack --mode development ./demo/src/js/index.js --output ./demo/dist/main.js",
  "build": "webpack --mode production ./demo/src/js/index.js --output ./demo/dist/main.js"
}

webpack实战(03)-单页面解决方案--代码分割和懒加载

本节课讲解webpack4打包单页应用过程中的代码分割和代码懒加载。不同于多页面应用的提取公共代码,单页面的代码分割和懒加载不是通过webpack配置来实现的,而是通过webpack的写法和内置函数实现的。

目前webpack针对此项功能提供2种函数:

  • import(): 引入并且自动执行相关js代码;
  • require.ensure(): 引入但需要手动执行相关js代码。

代码目录如下:

image

其中,index.js是入口文件,subPageA.jssubPageB.js共同引用module.js。下面,我们按照代码引用的逻辑,从底向上展示代码:

module.js:

export default 'module';

subPageA.js:

import './module';
console.log('This is subPageA');
export default 'subPageA';

subPageB.js:

import './module';
console.log('This is subPageB');
export default 'subPageB';

注意:subPageA.jssubPageB.js两个文件中都执行了console.log()语句。之后将会看到import()和require()不同的表现形式:是否会自动执行js 的代码?

编写webpack配置文件:

const path = require('path');

module.exports = {
    mode: 'none',
    entry: './src/index2.js',
    output: {
        publicPath: __dirname + '/dist/',
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].bundle.js',
        chunkFilename: '[name].chunk.js'
    }
};

采用import()编写入口文件index.js,个人是非常推荐import()写法,因为和es6语法看起来很像。除此之外,import()可以通过注释的方法来指定打包后的chunk的名称。

除此之外,相信对vue-router熟悉的朋友应该知道,其官方文档的路由懒加载的配置也是通过import()来书写的。

采用import()编写index.js

index.js文件内容如下:

import(/* webpackChunkName: 'subPageA'*/ './subPageA').then(function(subPageA) {
    console.log(subPageA);
});

import(/* webpackChunkName: 'subPageB'*/ './subPageB').then(function(subPageB) {
    console.log(subPageB);
});

document.querySelector('#btn').onclick = () => {
    import(/* webpackChunkName: 'lodash'*/ 'lodash').then(function(_) {
        console.log(_.join(['1', '2']));
    });
}
export default 'page';

在命令行中运行webpack,打包结果如下:

image

我们创建index.html文件,通过<script>标签引入我们打包结果,需要注意的是:因为是单页应用,所以只要引用入口文件即可(即是上图中的main.bundle.js)。

index.html文件如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <button id="btn">按需加载</button>
    <script src="./dist/main.bundle.js"></script>
</body>
</html>

打开浏览器控制台,刷新界面,结果如下图所示:

image

图中圈出的部分,就是说明import()会自动运行subPageA.js和subPageB.js的代码。

NetWork选项中,我们可以看到,懒加载也成功了:

image

点击按需加载的按钮也会动态加载对应的chunk

image

采用require()编写index2.js

require.ensure()不会自动执行js代码,请注意注释。

require.include('./module.js'); // 将subPageA和subPageB共用的module.js打包在此page中
require.ensure(
    ['./subPageA.js', './subPageB.js'], // js文件或者模块名称
    function() {
      var subPageA = require('./subPageA'); // 引入后需要手动执行,控制台才会打印
      var subPageB = require('./subPageB');
    },
    'subPage' // chunkName
  );

  require.ensure(
    ['lodash'],
    function() {
      var _ = require('lodash');
      _.join(['1', '2']);
    },
    'lodash'
  );

  export default 'page';

打包结果如下:

image

requestAnimationFrame学习总结

首先总结一下在前端开发中实现动画的方式:

  • CSS3的animation+keyframes;
  • CSS3的transition过渡效果;
  • 通过在canvas上作图来实现动画;
  • 借助jQuery动画相关的API方便地实现;
  • 使用window.setTimout()或者window.setInterval()通过不断改变元素的状态位置等来实现动画,前提是画面的更新频率要达到每秒60次(因为大多数显示器的刷新频率是60Hz)才能让肉眼看到流畅的动画效果;
  • 使用window.requestAnimationFrame()方法。

1. 初识requestAnimationFrame

requestAnimationFrame 解决了浏览器不知道javascript动画什么时候开始、不知道最佳循环间隔时间的问题。它是跟着浏览器的绘制走的,如果浏览器绘制间隔是16.7ms,它就按这个间隔绘制;如果浏览器绘制间隔是10ms, 它就按10ms绘制。这样就不会存在过度绘制的问题,动画不会丢帧。

内部是这么运作的: 浏览器页面每次要重绘,就会通知requestAnimationFrame,这是资源非常高效的一种利用方式。怎么讲呢?有以下两点:

  • 就算很多个requestAnimationFrame()要执行,浏览器只要通知一次就可以了。而setTimeout是多个独立绘制。
  • 一旦页面不处于当前页面(比如:页面最小化了),页面是不会进行重绘的,自然requestAnimationFrame也不会触发(因为没有通知)。页面绘制全部停止,资源高效利用。

编写动画循环的关键: 是要知道延迟时间多长合适。一方面,循环间隔必须足够短,这样才能保证不同的动画效果显得更平滑流畅;另一方面,循环间隔还要足够长,这样才能保证浏览器有能力渲染产生的变化。大多数显示器的刷新频率是60Hz,相当于每秒重绘60次。大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率,因为即使超过了这个频率,用户体验也不会有提升。

因此,最平滑动画的最佳循环间隔是1000ms/60,约等于17ms。以这个循环间隔重绘的动画是平滑的,因为这个速度最接近浏览器的最高限速。为了适应17ms的循环间隔,多重动画可能需要加以节制,以便不会完成得太快。

虽然与使用多组setTimeout相比,使用setInterval的动画循环效率更高。但是无论setTimeout还是setInterval都不十分精确。为它们传入的第二个参数,实际上只是指定了把动画代码添加到浏览器UI线程队列以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务执行完成后再执行。如果UI线程繁忙,比如忙于处理用户操作,那么即使把代码加入队列也不会立即执行

因此,知道什么时候绘制下一帧是保证动画平滑的关键。然而,面对不十分精确的setTimeout和setInterval,开发人员至今都没有办法确保浏览器按时绘制下一帧。以下是几个浏览器的计时器精度:

  • IE8及其以下版本浏览器: 15.6ms;
  • IE9及其以上版本浏览器:4ms;
  • Firefox和Safari:10ms;
  • Chrome:4ms。

更为复杂的是:浏览器开始限制后台标签页或不活动标签页的计数器。因此,即使你优化了循环间隔,可能仍然只能接近你想要的效果。

CSS3动画的优势在于浏览器知道动画什么时候开始,因此会计算出正确的循环间隔,在适当的时候刷新UI。而对于JavaScript动画,浏览器就无从知晓什么时候开始。

在JS中,我们可以通过requestAnimationFrame( )方法告诉浏览器某些代码将要执行动画。这样浏览器可以在运行某些代码后进行适当的优化。

setTimeoutsetInterval方法不同,requestAnimationFrame不需要调用者指定帧速率,浏览器会自行决定最佳的帧效率

requestAnimationFrame是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是:按帧对网页进行重绘

设置这个API的目的是:为了让各种网页动画效果(DOM动画、Canvas动画、SVG动画、WebGL动画)能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。代码中使用这个API,就是告诉浏览器希望执行一个动画,让浏览器在下一个动画帧安排一次网页重绘

requestAnimationFrame的优势在于: 充分利用显示器的刷新机制,比较节省系统资源。显示器有固定的刷新频率(60Hz或75Hz),也就是说,每秒最多只能重绘60次或75次,requestAnimationFrame的基本**就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。

requestAnimationFrame方法接收一个参数(一个函数),即在重绘屏幕前调用这个函数。这个函数负责改变下一次重绘时的DOM样式。为了创建动画循环,可以像使用setTimeout一样,把多个对 requestAnimationFrame的调用连起来。如:

function updateFrame() {
	//其他代码
	if(一定条件){
		//在满足一定条件的情况下,继续调用
		window.requestAnimationFrame(updateFrame);
	}
}
window.requestAnimationFrame(updateFrame);

特别注意: requestAnimationFrame是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame的动画效果会大打折扣。

2. 基本用法

requestID = window.requestAnimationFrame(callback); 

requestAnimationFrame使用一个回调函数作为参数。这个回调函数会在浏览器重绘之前调用

3. cancelAnimationFrame方法

cancelAnimationFrame方法用于取消重绘。

window.cancelAnimationFrame(requestID);

它的参数是requestAnimationFrame返回的一个代表任务ID的整数值。

4. requestAnimationFrame()兼容性

 window.requestAnimFrame = (function(){
      return  window.requestAnimationFrame       || 
              window.webkitRequestAnimationFrame || 
              window.mozRequestAnimationFrame    || 
              window.oRequestAnimationFrame      || 
              window.msRequestAnimationFrame     || 
              function(callback){
                window.setTimeout(callback, 1000 / 60);
              };
    })();

首先判断各个浏览器是否支持这个API。如果不支持,则自行模拟部署该方法。上面的代码按照1秒钟60次(大约每16.7毫秒一次),来模拟requestAnimationFrame
Demo:

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<title></title>
	<style type="text/css">
		#anim{
			width: 200px;
			height: 200px;
			background-color: red;
			position: absolute;
		}
	</style>
</head>
<body>
<div id="anim">点击运行动画</div>
<script type="text/javascript">
//这里是兼容requestAnimationFrame
window.requestAnimFrame = (function(){
      return  window.requestAnimationFrame       ||
              window.webkitRequestAnimationFrame ||
              window.mozRequestAnimationFrame    ||
              window.oRequestAnimationFrame      ||
              window.msRequestAnimationFrame     ||
              function(callback){
                window.setTimeout(callback, 1000 / 60);
              };
    })();
var elem = document.getElementById("anim");
var startTime = undefined; //全局的
function render(time){ //time是局部的
  if (time === undefined)
    time = Date.now(); //获取到当前时间的毫秒数
  if (startTime === undefined)
     //第一次调用的时候startTime是undefined,之后就不是了
     //而time每次调用都等于当前时间的毫秒数
    startTime = time;
    //当是500的整数倍的时候,left值从0开始
  elem.style.left = ((time - startTime)/10%500) + "px";
}
elem.onclick = function(){
    (function animloop(){
      render();
      //这里利用requestAnimFrame方法来控制渲染的帧数
      requestAnimFrame(animloop);
    })();
};
</script>
</body>
</html>

Demo2:

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<title>模拟进度条</title>
	<style type="text/css">
	#box{
		height: 16px;
		background-color: #f00;
		color:#fff;
	}
	</style>
</head>
<body>
<div id="box">0%</div>
<input type="button" name="" value="加载" id="btn">
<script type="text/javascript">
window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
var oBox = document.getElementById("box"),
	oBtn = document.getElementById("btn"),
      progress = 0;
function requestAnim(timestamp){
	progress += 1;
	oBox.style.width = progress + 'px';
	oBox.innerHTML = progress + '%';
	if(progress<100){
		requestAnimationFrame(requestAnim);
	}
}
requestAnimationFrame(requestAnim);
oBtn.addEventListener('click',function(){
	oBox.style.width = "1px";
	progress = 0;
	requestAnimationFrame(requestAnim);
},false);
</script>
</body>
</html>

参考文档

  1. JavaScript 标准参考教程(alpha)
  2. 谈谈requestAnimationFrame的动画循环
  3. requestAnimationFrame,Web中写动画的另一种选择
  4. JS高级程序设计第3版

发布自己的npm包

[TOC]
npm是javascript的包管理工具,是前端模块化下的一个标志性产物。简单地地说,就是通过npm下载模块,复用已有的代码,提高工作效率。

1. 创建一个模块

Node.js模块就是发布到npm的代码包。

创建一个新模块的第一步就是创建一个package.json文件。 我们可以使用npm init -y来快速创建一个package.json文件。这个过程中命令行会逐步提示你输入这个模块的信息,其中模块名字和版本号是必填项。

{
  "name": "npm_lj_test",
  "version": "1.0.2",
  "description": "npm package test",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "lj",
  "license": "ISC"
}

2. 新建入口文件

入口文件默认是index.js。这里给出一个最简单的例子,在默认的index.js文件里写一个要导出的函数,这个函数也就是别人的代码里可以import或者require的。

exports.sayHello = function () {
    console.log("This is my first npm module");
};

至此,我们的第一个node模块就已经创建完成了,下一步就是发布到npm服务器了。

3. 将新建模块发布到npm服务器

3.1 注册npm账号

目前有两种方式:

  1. npm官网注册
  2. npm adduser (按照提示创建)
3.2 登录并输入相关信息

首次需要登录,npm login 存储证书到本地,之后就不需要每次都登录了。登录过程中需要输入用户名,密码,还有邮箱,这些都是刚刚注册时候填写的。

3.3 NPM 包文件设置

NPM 打包发布的时候,会默认把当前目录下所有文件打包。但是 Git 仓库中,有些东西是不需要 发布到 NPM 的,因此我们需要使用一个文件 .npmignore 来忽略这些文件,常用配置如下:

/.git/
/.vscode/
/docs/
/node_modules/
.gitignore
.npmignore
tslint.json
tsconfig.json
*.log

这些文件都不是发布需要的内容,因此可以忽略。

3.4 模块发布
// 发布包
npm publish 

发布过程会把整个目录发布,不想发布的内容模块,可以通过 .gitignore.npmignore 文件忽略不想发布的内容。

发布成功后可以到npm个人中心的packages页面查看

3.5 更新npm包

对包中的相关文件进行修改完毕后,在发布修改的模块之前,必须先创建新版本的version,执行一下命令:

npm version [patch/minor/major]
#运行命令后,package.json中的version将被修改

#然后在之下发布命令
npm publish

4. npm包维护

语义化的版本:

  1. patch(补丁): bug的修复和小的修改;
  2. minor(次要更新): 增添了新的特性,但不破坏之前的特性;
  3. major(主要更新): 项目大的调整,修改了之前的特性;
  4. prerelease: 预览版。
4.1. 版本号维护

维护一个包,肯定是要进行包的版本升级的。如何进行呢?手动修改 package.json 的 version 字段是一个办法,但是显得有点 low。可以使用下面的命令:

# 主版本号.子版本号.修订版本号 => 0.1.0
# 版本号变成 0.1.0,即显式设置版本号
npm version v0.1.0
   
# 版本号从 0.1.0 变成 0.1.1,即修订版本号加一 
npm version patch

# 版本号从 0.1.1 变成 0.2.0,即子版本号加一       
npm version minor 

# 版本号从 0.2.0 变成 1.0.0,即主版本号加一      
npm version major       

除了上述命令之外,还有四个命令用于创建预发布版本(非稳定版本),具体命令如下:

#显式设置版本为1.2.3
npm version v1.2.3

# 版本号从 1.2.3 变成 1.2.4-0,就是 1.2.4 版本的第一个预发布版本
npm version prepatch

# 版本号从 1.2.4-0 变成 1.3.0-0,就是 1.3.0 版本的第一个预发布版本
npm version preminor

# 版本号从 1.2.3 变成 2.0.0-0,就是 2.0.0 版本的第一个预发布版本
npm version premajor

# 版本号从 2.0.0-0 变成 2.0.0-1,就是使预发布版本号加一
npm version prerelease

特别注意: version 命令默认会给你的 git 仓库自动 commit 一把,并打一个 tag。如果不想它动你的 git 仓库,你应该使用 --no-git-tag-version 参数,例如:

npm --no-git-tag-version version patch

如果想一劳永逸,那么可以使用如下 NPM 设置彻底禁止它:

npm config set git-tag-version false  # 不要自动打 tag
npm config set commit-hooks false     # 不要自动 commit
4.2. 使用标签

以 TypeScript 为例,通过 npm info typescript 可以看到 dist-tags 字段有着五个 值,分别是 latest, beta, rc, next, insiders,这些都是 dist-tag,可以 称之为标签——你可以把它理解为 git 里面的分支。

有什么用呢?其实,我们平时用 npm install xxx 的时候,是使用了一个潜在的选项 tag = latest,可以通过 npm config list -l | grep tag 看到。

因此实际上是执行了 npm install xxx@latest。也就是安装了 latest 这个标签对应的最新版本。

不同的标签可以有不同的版本,这就方便我们发表非稳定版本到 npm 上,与稳定版本分开。默认是发布到 latest 标签下的。

例如 npm publish --tag dev 就可以发布一个版本到 dev 标签下。

4.3. 使用前缀

如果你使用过 AngularJS 或者 TypeScript,那么肯定知道有一些包的名字是这样的:

@types/node
@types/jquery
@angular/core

@types/ 和 @angular/ 叫做包前缀(scope)。

作者起初以为使用包前缀也是收费的,后来仔细阅读了文档才发现公开的包可以免费使用包前缀。

那我们怎么使用呢?很简单,首先在 package.json 里面把 name 字段加上一个前缀。

前缀必须是你 NPM 账户的用户名,比如你注册了一个用户名为 abc 的账户,则你只能使用 @abc/ 为你的包前缀。
举个例子,将你的包名设置为 @abc/test

如果你要初始化一个带包前缀的包,则可以使用下面的命令:

npm init --scope=abc #可以加上个 `-y` 快速创建。

或者你想每次都使用 @abc/ 包前缀,加个设置即可:

#这样每次初始化新的 package.json,都将自动应用 @abc/ 包前缀。
npm config set scope abc

现在,可以发布你的包到 npmjs.org 了。哦不,别忘了一点:

官方文档表示:所有带前缀的包,在发布的时候,默认都是发布为私有包。

这意味着你不能就这么发布,因为你(可能)不是付费用户,不能发布私有的包。那怎么办呢?别担心,npm publish 命令还有一个参数 --access ,通过这个参数可以指定发布的是公共包还是私有包。因此,只要用下面的命令就可以发布一个公共的带包前缀的包了:

npm publish --access=public

5. demo

#发布第一个稳定版本
npm publish  //1.0.0

#进行相应修改,发布第二个稳定版本
npm version patch
npm publish  //1.0.1

#继续修改文件发布一个prerelease版本
npm version prerelease
npm publish --tag beta //1.0.2-0

#继续修改文件发布一个prerelease版本
npm version prerelease
npm publish --tag beta //1.0.2-1

使用npm info命令查看相关信息:

#主要关注以下信息
'dist-tags': { latest: '1.0.1', beta: '1.0.2-1' },
versions: [ '1.0.0', '1.0.1', '1.0.2-0', '1.0.2-1' ]

dist-tags字段中存储了当前模块的最新的稳定版本和最新的beta版本,versions数组中存储的是当前模块的所有版本列表。

5.1 npm dist-tag命令
#获取到所有的最新的版本,包括prerelease与稳定版本
npm dist-tag ls

#结果如下:
beta: 1.0.2-1
latest: 1.0.1
#<pkg>代表当前npm包名
#<version>是版本号
#<tag>可以为latest, beta, rc, next, insiders
npm dist-tag add <pkg>@<version> [<tag>]
npm dist-tag rm <pkg> <tag>
npm dist-tag ls [<pkg>]
5.2 设置prerelease版本为稳定版本

当模块的prerelease版本已经稳定了,可以将其设置为稳定版本,具体命令如下:

npm dist-tag add [email protected] latest

通过npm info查看结果如下:

#latest: '1.0.2-1' => 已经改变
name: 'npm_test_lj',
'dist-tags': { latest: '1.0.2-1', beta: '1.0.2-1' },
versions: [ '1.0.0', '1.0.1', '1.0.2-0', '1.0.2-1' ]

参考文档

  1. npm开始
  2. 手把手教你创建你的第一个 NPM 包
  3. npm
  4. 使用 NPM 发布与维护 TypeScript 模块
  5. 深入 Node 模块的安装和发布

React 组件间通讯

在使用 React 的过程中,不可避免的需要在组件间进行消息传递,组件间通信大体有下面几种情况:

1. 父组件向子组件通信

通信是单向的,数据必须是由一方传到另一方。在 React 中,父组件可以向子组件通过传 props 的方式,向子组件进行通讯。

#Parent.jsx
import React, { Component } from 'react';
import Child from './Child';

export default class Parent extends Component {
    constructor(props) {
        super(props);
        this.state = {
            msg: '我是父组件传来的消息'
        };
    }

    render() {
        return (
            <div>
                我是父组件
                <Child 
                    title={this.state.msg}
                />
            </div>
        )
    }
}
#Child.jsx
import React from 'react';

export default class Child extends React.Component {
    constructor(props) {
        super(props);
    }
    render() {
        return (
            <div>
                {this.props.title}
            </div>
        )
    }
}

2. 子组件向父组件通信

利用回调函数,可以实现子组件向父组件通信。父组件将一个函数作为 props 传递给子组件,子组件调用该回调函数,便可以向父组件通信。

#Parent.jsx
import React, { Component } from 'react';
import Child from './Child';

export default class Parent extends Component {
    constructor(props) {
        super(props);
        this.state = {
            msg: '我是父组件传来的消息'
        };
    }
    //回调函数
    cbFn(msg) {
        this.setState({
            msg: msg
        })
    }

    render() {
        return (
            <div>
                我是父组件
                <Child 
                    title={this.state.msg}
                    cbFn={(msg) => this.cbFn(msg)}
                />
            </div>
        )
    }
}
#Child.jsx
import React from 'react';

export default class Child extends React.Component {
    constructor(props) {
        super(props);
    }

    handleClick() {
               //调用父组件传过来的回调函数
        this.props.cbFn('我是子组件传来的信息');
    }
    render() {
        return (
            <div>
                {this.props.title}
                <button
                    onClick={() => this.handleClick()}
                >
                    点击我进行通信吧
                </button>
            </div>
        )
    }
}

3. 子组件之间相互通信

对于没有直接关联关系的两个节点,就如 Child_1Child_2 之间的关系,他们唯一的关联点,就是拥有相同的父组件。参考之前介绍的两种关系的通讯方式,如果我们向由 Child_1Child_2 进行通讯,我们可以先通过 Child_1 向 Parent 组件进行通讯,再由 Parent 向 Child_2 组件进行通讯,具体示例如下:

4. 跨级组件通信

所谓跨级组件通信,就是父组件向子组件的子组件通信,向更深层的子组件通信。跨级组件通信可以采用下面两种方式:

  1. 中间组件层层传递 props;
  2. 使用 context 对象
#Parent.jsx
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Child from './Child';

export default class Parent extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            
        };
    }

    getChildContext() {
        return {
            color: "red"
        };
    }

    cbFn(msg) {
        console.log(msg);
    }

    render() {
        return (
            <div>
                我是父组件
                <Child 
                    title="我是子组件"
                    cbFn={(msg) => this.cbFn(msg)}
                />
            </div>
        )
    }
}
Parent.childContextTypes = {
    color: PropTypes.string
};
#Child.jsx
import React from 'react';
import PropTypes from 'prop-types';
import SubChild from './SubChild';

export default class Child extends React.Component {
    constructor(props) {
        super(props);
        this.state = {

        };
    }

    handleClick() {
        this.props.cbFn('我是子组件传来的信息');
    }
    render() {
        return (
            <div>
                {this.props.title}
                <button
                    onClick={() => this.handleClick()}
                >
                    点击我进行通信吧
                </button>
                <SubChild />
            </div>
        )
    }
}
#SubChild.jsx
import React from 'react';
import PropTypes from 'prop-types';

export default class SubChild extends React.Component {
    render() {
      return (
        <button 
            style={{background: this.context.color}}
        >
          删除
        </button>
      );
    }
  }
  
  SubChild.contextTypes = {
    color: PropTypes.string
  };

参考文档

  1. React 组件间通讯
  2. react中组件通信的几种方式
  3. React 中组件间通信的几种方式
  4. react 组件间的通信方法
  5. ReactJS组件之间如何进行通信
  6. 上下文(Context)

redux-thunk源码分析

redux-thunkredux解决异步的中间件。

当我们只使用redux的时候,我们需要dispatch的是一个action对象。但是当我们使用redux-thunk之后,我们dispatch的是一个functionredux-thunk会自动调用这个function,并且传递dispatch方法作为其第一个参数。这样一来,我们就能在这个function内根据我们的请求状态:开始请求,请求中,请求成功/失败,dispatch我们期望的任何action了,这也是为什么它能支持异步dispatch (action)

本质上是redux-thunkstore.dispatch方法进行了增强改造,使其具备接受一个函数作为参数的能力,从而达到middleware的效果,即在reduxdispatch(action) => reducer => update store这个流程中,在action被发起之后,到达reducer之前,加入相关操作,比如发生请求、打印log等。

1. 使用

npm install -S redux-thunk

redux-thunk的源码非常简洁,除去空格一共只有11行,这11行中如果不算上},则只有8行。

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    // 如果action是一个function,就返回action(dispatch, getState, extraArgument),否则返回next(action)。
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    // next为之前传入的store.dispatch,即改写前的dispatch
    return next(action); 
  };
}

const thunk = createThunkMiddleware();
// 给thunk设置一个变量withExtraArgument,并且将createThunkMiddleware整个函数赋给它
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;
// thunk的内容如下
({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  }

// thunk.withExtraArgument的结果如下
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  };
}

thunk.withExtraArgument允许给返回的函数传入额外的参数,它比较难理解的部分和thunk一样,如下:

({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  }

我们先看看,在reudx中如何使用中间件:直接将thunk中间件引入,作为 applyMiddleware的参数,然后传入createStore方法中,就完成了 store.dispatch()的功能增强,这样就可以进行一些异步的操作了。其中 applyMiddlewareRedux的一个原生方法,将所有中间件组成一个数组,依次执行,中间件多了可以当做参数依次传进去。

let store = createStore(
    reducer,
    applyMiddleware(thunk)
);

那么createThunkMiddleware函数中dispatch,getState,next,action这些参数是从哪里来的呢?这就需要看看applyMiddleware的源码实现了,如下:

export default function applyMiddleware(...middlewares) {
  return (createStore) => (...args) => {
    const store = createStore(...args)
    let dispatch = store.dispatch
    let chain = []
    // 要传给middleware的参数
    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

thunk作为参数传入之后,即applyMiddleware(thunk),返回了一个函数,这个函数其实就是一个enhancer,然后传入reduxcreateStore函数中:

let store = createStore(
    reducer,
    applyMiddleware(thunk) // 返回一个`enhancer`
);
export default function createStore(reducer, preloadedState, enhancer) {
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }

  if (typeof enhancer !== 'undefined') {
    if (typeof enhancer !== 'function') {
      throw new Error('Expected the enhancer to be a function.')
    }

    return enhancer(createStore)(reducer, preloadedState)
  }
}

在上述redux源码中,createStore函数中的enhancer被执行,传入参数 createStore,紧接着执行了其返回的函数,传入reducer和preloadedState。接下来,我们进入applyMiddleware和thunk的关键部分,上面applyMiddleware接受的最初的(…middlewares)参数其实就是thunkthunk会被执行,并且传入参数getState和dispatch

// 传入到thunk的参数
const middlewareAPI = {
  getState: store.getState,
  dispatch: (action) => dispatch(action)
}
// 依次执行所有的中间件(thunk)
chain = middlewares.map(middleware => middleware(middlewareAPI))
// 改写dispatch
dispatch = compose(...chain)(store.dispatch)

上述代码中的chain是什么呢?这就需要结合redux-thunk源码来分析了:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

redux-thunk中间件export default的就是createThunkMiddleware()函数处理之后的thunk,再看createThunkMiddleware这个函数,返回的是一个返回函数的函数。将上述代码编译成ES5的代码:

function createThunkMiddleware(extraArgument) {
    return function({ dispatch, getState }) {
      // 这里返回的函数就是chain
      return function(next) {
        // 这里返回的函数就是改写的dispatch
        return function(action) {
          if (typeof action === 'function') {
              return action(dispatch, getState, extraArgument);
          }

          return next(action);
        };
      }
    }
}
//  compose源码
// 当funcs长度为1时,返回funcs中的第一项对应的函数
    if (funcs.length === 1) {
        return funcs[0]
    }

从上述代码中我们可以看出,chain就是以next作为形参的匿名函数,compose函数作用是:将多个函数连接起来,将一个函数的返回值作为另一个函数的传参进行计算,得出最终的返回值。这里chain是包含一个函数的数组,根据compose的源码,我们可以知道compose(...chain)直接返回数组中的唯一函数。所以很简单,这里就是直接执行chain,并将store.dispatch作为实参传递给next

改造后的dispatch最终变为:

function(action) {
  if (typeof action === 'function') {
    return action(dispatch, getState, extraArgument);
  }
  // next为之前传入的store.dispatch,即改写前的dispatch
  return next(action);
};

如果传入的action是函数,则执行函数;否则直接dispatch(action)

从上述分析中可以得出如下结论:middleware执行时传入的参数对象middlewareAPI中确实包含getState和dispatch两项,next则来自dispatch = compose(...chain)(store.dispatch)这一句中的store.dispatch,而actiondispatch某个action时传入。

一般来说,一个有效携带数据的action是如下这样的:

{
  type: ADD_TODO,
  text: 'Build my first Redux app'
}

加入redux-thunk后,action可以是函数了,依据redux-thunk的源码,我们可以看出如果传入的action是函数,则返回这个函数的调用。如果传入的函数是一个异步函数,我们完全可以在函数调用结束后,获取必要的数据再次触发dispatch,由此实现异步效果。

使用场景如下:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';
// 注册thunk到applyMiddleware
const createStoreWithMiddleware = applyMiddleware(
  thunk
)(createStore);

const store = createStoreWithMiddleware(rootReducer);

// action方法
function increment() {
  return {
    type: INCREMENT_COUNTER
  };
}
// 执行一个异步的dispatch
function incrementAsync() {
  return dispatch => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  };
}

参考文档

  1. 讓你的Action能作更多 — Redux-Thunk
  2. 掌控 redux 异步
  3. React系列——redux-thunk源码分析
  4. redux-thunk
  5. redux异步操作学习笔记
  6. Redux, Redux thunk 和 React Redux 源码阅读
  7. redux-thunk 源码全方位剖析

JS定时器学习总结

JavaScript提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()setInterval()这两个函数来完成。它们向任务队列添加定时任务。

js是运行于单线程的环境中的,定时器仅仅只是计划代码在未来的某个时间执行(但是并不保证在该时间点一定执行)执行时机是不能保证的,因为在页面的生命周期中,不同时间可能有其他代码在控制js进程。在页面下载完后的代码运行、事件处理程序、Ajax回调函数都必须使用同样的线程来执行。实际上,浏览器负责进行排序,指派某段代码在某个时间点运行的优先级。

setinterval2

如上图所示:我们可以把javascript想象成在时间线上运行的。当页面载入时,首先执行是任何包含在<script>元素中的代码,通常是页面生命周期后面要用到的一些简单的函数和变量的声明,有时候也包含一些初始数据的处理。在这之后,javascript进程将等待更多代码执行,当进程空闲时,下一个代码会被触发并立刻执行。例如:当点击某个按钮时,onclick事件处理程序会立刻执行,只要javascript进程处于空闲状态。

除了javascript主执行进程外,还有一个需要在进程下一次空闲时执行的代码队列。随着页面在其生命周期中的推移,代码会按照执行顺序添加到队列中。例如:当某个按钮被按下,它的事件处理程序代码就会被添加到队列中,并在下一个可能的时间里执行。当接收到某个Ajax响应时,回调函数的代码会被添加到队列。在javascript中没有任何代码是立刻执行的,但是一旦进程空闲则尽快执行。

定时器对队列的工作方式是: 当特定时间过去后将代码插入。注意,给队列添加代码并不意味着对它立刻执行,而只能表示它会尽快执行。例如:设定一个150ms后执行的定时器不代表到了150ms代码就立刻执行,它表示代码会在150ms后被加入到队列中。如果在这个时间点,队列中没有其他东西,那么这段代码就会被执行,表面上看上去就好像代码就在精确的时间点上执行了。其他情况,代码可能明显等待更长时间才执行。

setinterval3

在上图中:给按钮设置了一个事件处理程序,该事件处理程序设置了一个250ms后调用的定时器。点击该按钮后,首先将onclick事件处理程序加入队列。该事件处理程序执行后才设置定时器,再有250ms后,指定的代码才被添加到队列中等待执行

对于定时器而言:我们要记住指定的时间间隔表示何时将定时器的代码添加到队列,而不是何时实际执行代码。如果上图中的onclick事件处理程序执行了300ms,那么定时器的代码至少要在定时器设置之后的300ms后才会被执行。队列中所有的代码都要等到js进程空闲之后才能执行,而不管它们是如何添加到队列中的。

上图中,尽管在255ms处添加了定时器代码,但是这个时候不能执行,因为onclick事件处理程序还在运行。定时器代码最早的执行时机在300ms处,即onclick事件处理程序结束之后。

1. setTimeout

setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。

const timer = setTimeout(func|code, delay);

上面代码中,setTimeout函数接受两个参数,第一个参数func|code是将要推迟执行的函数名或者一段代码,第二个参数delay是推迟执行的毫秒数。

1.1 demo1(第一个参数是code)
console.log(111);
setTimeout('console.log(222)', 2000);
console.log(333);
// 运行结果:
111
333
222

上面代码会先输出111和333,然后等待2秒再输出222。特别注意:console.log(2)必须以字符串的形式,作为setTimeout的参数。

1.2 demo2(第一个参数是函数)

如果推迟执行的是函数,就直接将函数名作为setTimeout的参数。

const fn = () => {
    console.log(222);
}

console.log(111);
setTimeout(fn, 2000);
console.log(333);
// 运行结果:
111
333
222

特别注意:setTimeout的第二个参数如果省略,则默认为0。

setTimeout(f);
// 等同于
setTimeout(f, 0);
1.3 setTimeout参数

除了前面提到的两个参数,setTimeout还允许更多的参数。它们将依次传入推迟执行的函数(回调函数)。

console.log(111);
setTimeout((a,b) => {
    console.log(a + b);
}, 1000, 1, 2);
console.log(333);
// 运行结果:
111
333
3

上面代码中,setTimeout共有4个参数。最后那两个参数(1和2),将在1秒之后回调函数执行时,作为回调函数的参数。

1.4 setTimeout的回调函数是对象的方法

特别注意:如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象。

var x = 1;

var obj = {
  x: 2,
  y: function () {
    // 这里this指向window
    console.log(this.x);
  }
};

setTimeout(obj.y, 1000); // 1

上面代码输出的是1,而不是2。因为当obj.y在1秒后运行时,this所指向的已经不是obj了,而是全局环境window

1.4.1 解决方法一(将obj.y放入一个函数)
var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(function () {
  console.log(this); // window
  obj.y(); // 2
}, 1000);

上面代码中,obj.y放在一个匿名函数之中,这使得obj.yobj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。

1.4.2 解决方法二(使用bind方法,将obj.y这个方法绑定在obj上面)
var x = 1;

var obj = {
  x: 2,
  y: function () {
    console.log(this.x);
  }
};

setTimeout(obj.y.bind(obj), 1000); // 2

自己在总结的时候,自己用ES6声明全局变量x和obj。这样导致输出undefined。原因在于:let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从ES6开始,全局变量将逐步与顶层对象的属性脱钩,这一点要特别注意。

const x = 1;

const obj = {
  x: 2,
  y: () => {
    console.log(this); // window
    console.log(this.x);
  }
};

setTimeout(obj.y, 1000); // undefined
1.4.3 ES6声明变量的六种方法

ES5只有两种声明变量的方法:var命令和function命令ES6 除了添加let和const命令,后面章节还会提到,另外两种声明变量的方法:import命令和class命令。所以,ES6一共有6种声明变量的方法。

1.4.4 顶层对象的属性

顶层对象,在浏览器环境指的是window对象,在Node中指的是global对象。需要注意的是在ES5中,顶层对象的属性与全局变量是等价的。

window.a = 1;
a // 1

a = 2;
window.a // 2

上面代码中,顶层对象的属性赋值与全局变量的赋值,是一回事。

需要注意的是:在ES6中改变了这一点,一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从ES6开始,全局变量将逐步与顶层对象的属性脱钩。

var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1

let b = 1;
window.b // undefined

上面代码中,全局变量a由var命令声明,所以它是顶层对象的属性;全局变量b由let命令声明,所以它不是顶层对象的属性,返回undefined。

2. setInterval(重复的定时器)

使用setInterval创建的定时器确保了定时器代码规则地插入队列中。但是该方法的问题在于: 定时器代码可能在代码再次被添加到队列之前还没有执行完成,结果导致定时器代码连续运行好几次,而之间没有任何停顿。然而,javascript引擎够聪明,能避免这个问题。当使用setInterval时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。

setInterval函数的用法与setTimeout完全一致,区别仅仅在于:setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。

var timer = setInterval(function() {
  console.log(2);
}, 1000)

上面代码中,每隔1秒就输出一个2,会无限运行下去,直到关闭当前窗口。与setTimeout一样,除了前两个参数,setInterval方法还可以接受更多的参数,它们会传入回调函数。

// 通过setInterval方法实现网页动画的例子。
var div = document.querySelector('#box');
var opacity = 1;
var fader = setInterval(() => {
  opacity -= 0.1;
  if (opacity >= 0) {
    div.style.opacity = opacity;
  } 
  else {
    clearInterval(fader);
  }
}, 100);

上面代码每隔100毫秒,设置一次div元素的透明度,直至其完全透明为止。

setInterval的一个常见用途是:实现轮询

// 轮询URL的Hash值是否发生变化
var hash = window.location.hash;
var hashWatcher = setInterval(function() {
  if (window.location.hash != hash) {
    updatePage();
  }
}, 1000);

需要注意的是:setInterval指定的是函数开始执行之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。比如:setInterval指定某个函数每100ms执行一次,函数每次执行需要5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。

setinterval

如上图所示:重复定时器有两个问题:1.某些间隔会被跳过;2.多个定时器的代码执行之间的间隔可能会比预期的小。假设,某个onclick事件处理程序使用setInterval设置了一个200ms间隔的重复定时器。如果事件处理程序花费了300ms多一点的时间完成,同时定时器代码也花费了差不多的时间,就会跳过一个间隔同时运行着一个定时器代码。

在上图的例子中:第一个定时器在205ms时被添加到队列中,但是直到过了300ms处才能够执行。当执行这个定时器代码时,在405ms处又给队列添加了另外一个副本。在下一个间隔,即605ms处。第一个定时器代码扔在运行,同时在队列中已经存在一个定时器代码的实例。结果导致在这个时间点上的定时器代码不会被添加到队列中。同时,当5ms处添加的定时器代码结束后,405ms处添加的定时器代码就立刻执行。

为了避免setInterval的这两个缺点,确保两次执行之间有固定的间隔,可以使用链式setTimeout,即每次执行结束后,使用setTimeout指定下一次执行的具体时间。

// 主要用于重复定时器
var timer = setTimeout(function () {
  // 处理中
  timer = setTimeout(arguments.callee, 2000);
}, 2000);

上述代码中,链式调用了setTimeout。每次函数执行的时候都会创建一个新的定时器。第二个setTimeout调用使用了arguments.callee来获取对当前执行的函数的引用,并为其设置另外一个定时器。这样做的好处是:在前一个定时器代码执行完成之前,不会向队列中插入新的定时器代码,确保不会有任何缺失的间隔。而且,可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免连续的运行。上面代码可以确保,下一次执行总是在本次执行结束之后的2秒开始

3. clearTimeout和clearInterval

setTimeout和setInterval函数,都返回一个整数值,表示计数器编号。将该整数传入clearTimeout和clearInterval函数,就可以取消对应的定时器。

var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);

clearTimeout(id1);
clearInterval(id2);

上面代码中,回调函数f不会再执行了,因为两个定时器都被取消了。

setTimeout和setInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。

function f() {}
setTimeout(f, 1000) // 10
setTimeout(f, 1000) // 11
setTimeout(f, 1000) // 12

上面代码中,连续调用三次setTimeout,返回值都比上一次大了1。

利用这一点,可以写一个函数,取消当前所有的setTimeout定时器。

(function() {
  var gid = setInterval(clearAllTimeouts, 0);

  function clearAllTimeouts() {
    var id = setTimeout(function() {}, 0);
    while (id > 0) {
      if (id !== gid) {
        clearTimeout(id);
      }
      id--;
    }
  }
})();

上面代码中,先调用setTimeout,得到一个计算器编号,然后把编号比它小的计数器全部取消。

4. 实例应用:debounce

有时,我们不希望回调函数被频繁调用。比如:用户填入网页输入框的内容,希望通过Ajax方法传回服务器,jQuery的写法如下:

$('textarea').on('keydown', ajaxAction);

这样写有一个很大的缺点是:如果用户连续击键,就会连续触发keydown事件,造成大量的Ajax通信。这是不必要的,而且很可能产生性能问题。正确的做法应该是,设置一个门槛值,表示两次Ajax通信的最小间隔时间。如果在间隔时间内,发生新的keydown事件,则不触发Aja 通信,并且重新开始计时。如果过了指定时间,没有发生新的keydown事件,再将数据发送出去。这种做法叫做debounce(防抖动)。假定两次Ajax通信的间隔不得小于2500毫秒,上面的代码可以改写成下面这样。

$('textarea').on('keydown', debounce(ajaxAction, 2500));

function debounce(fn, delay){
  var timer = null; // 声明计时器
  return function() {
    var context = this;
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(context, args);
    }, delay);
  };
}

上面代码中,只要在2500毫秒之内,用户再次击键,就会取消上一次的定时器,然后再新建一个定时器。这样就保证了回调函数之间的调用间隔,至少是2500毫秒。

5. 运行机制

setTimeoutsetInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。

这意味着:setTimeout和setInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的。所以,没有办法保证setTimeout和setInterval指定的任务一定会按照预定时间执行。

setTimeout(someTask, 100);
veryLongTask();

上面代码的setTimeout,指定100ms以后运行一个任务。但是,如果后面的veryLongTask函数(同步任务)运行时间非常长,过了100ms还无法结束,那么被推迟运行的someTask就只有等着,等到veryLongTask运行结束,才轮到它执行。

setInterval(function () {
  console.log(2);
}, 1000);

sleep(3000);

function sleep(ms) {
  var start = Date.now();
  while ((Date.now() - start) < ms) {
  }
}

上面代码中,setInterval要求每隔1秒,就输出一个2。但是,紧接着的sleep语句需要3秒才能完成,那么setInterval就必须推迟到3秒之后才开始生效。特别注意: 生效后setInterval不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。

6. setTimeout(f, 0)

6.1 含义

setTimeout的作用是:将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),那么会立刻执行吗?

答案是不会。因为setTimeout指定的回调函数f,必须要等到当前脚本的同步任务全部处理完以后才会执行。也就是说,setTimeout(f, 0)会在下一轮事件循环一开始就执行。

setTimeout(() => {
  console.log(1);
}, 0);
console.log(2);
// 2
// 1

上面代码先输出2,再输出1。因为2是同步任务,在本轮事件循环执行,而1是下一轮事件循环执行。总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f。

6.2 应用

setTimeout(f, 0)有几个非常重要的用途。它的一大应用是:可以调整事件的发生顺序。 比如:网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果想让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)
click

<body>
    <input id="btn" type="button" value="click">
    <script>
        var btn = document.querySelector('#btn');
        btn.addEventListener('click', function() {
            btn.value += ' input';
        }, false);
        document.body.addEventListener('click', function() {
            btn.value += ' body';
        }, false);
    </script>
</body>

上面代码按照常规的事件冒泡机制触发。

click2

<body>
    <input id="btn" type="button" value="click">
    <script>
        var btn = document.querySelector('#btn');
        btn.addEventListener('click', function() {
            setTimeout(function() {
                btn.value += ' input';
            })
        }, false);
        document.body.addEventListener('click', function() {
            btn.value += ' body';
        }, false);
    </script>
</body>

上面代码在点击按钮后,setTimeout将子元素的回调函数推迟到下一轮事件循环执行,这样就起到了先触发父元素的回调函数的目的了。

另一个应用是:用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。如下图所示:

keypress

<body>
    <input type="text" id="txt">
    <script>
        var txt = document.querySelector('#txt');
        function handleKeyPress(e) {
            e.target.value = e.target.value.toUpperCase();
        }
        txt.addEventListener('keypress', handleKeyPress, false);
    </script>
</body>

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转为大写,因为浏览器此时还没接收到新的文本,所以this.value取不到最新输入的那个字符。只有用setTimeout改写,上面的代码才能发挥作用。如下图所示:

keypress2

<body>
    <input type="text" id="txt">
    <script>
        var txt = document.querySelector('#txt');
        function handleKeyPress(e) {
            setTimeout(function() {
                e.target.value = e.target.value.toUpperCase();
            }, 0);
        }
        txt.addEventListener('keypress', handleKeyPress, false);
    </script>
</body>

上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。

由于setTimeout(f, 0)实际上意味着:将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到setTimeout(f, 0)里面执行。

<body>
    <div id="box" style="width: 100px;height: 100px;"></div>
    <script>
        var box = document.querySelector('#box');
        // 写法一
        for (var i = 0xA00000; i < 0xFFFFFF; i++) {
            div.style.backgroundColor = '#' + i.toString(16);
        }
        // 写法二
        var timer = null;
        var i = 0x100000;
        function fn() {
            timer = setTimeout(fn, 0);
            box.style.backgroundColor = '#' + i.toString(16);
            if(i++ === 0xFFFFFF) clearTimeout(timer);
        }
        timer = setTimeout(fn, 0);
    </script>
</body>

上面代码有两种写法都是改变一个网页元素的背景色。写法一会造成浏览器堵塞,因为JavaScript执行速度远高于DOM,会造成大量DOM操作堆积,而写法二就不会,这就是setTimeout(f, 0)的好处。

另一个使用这种技巧的例子是:代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成setTimeout(highlightNext, 50)的样子,性能压力就会减轻。

参考文档

  1. 顶层对象的属性
  2. 定时器

Node命令行程序开发学习总结

[TOC]

// 执行npm link
/usr/local/bin/hello -> /usr/local/lib/node_modules/node-cli/bin/hello.js
/usr/local/lib/node_modules/node-cli -> /Users/liujie26/study/node/expressDemo/node-cli

1. npm link

开发NPM模块的时候,有时我们会希望,边开发边试用,比如本地调试的时候,require('myModule')会自动加载本机开发中的模块。Node规定,使用一个模块时,需要将其安装到全局的或项目的node_modules目录之中。对于开发中的模块,解决方法就是在全局的node_modules目录之中,生成一个符号链接,指向模块的本地目录。

npm link就能起到这个作用,会自动建立这个符号链接。

请设想这样一个场景,你开发了一个模块myModule,目录为src/myModule,你自己的项目myProject要用到这个模块,项目目录为src/myProject。首先,在模块目录(src/myModule)下运行npm link命令。

src/myModule$ npm link

上面的命令会在NPM的全局模块目录内,生成一个符号链接文件,该文件的名字就是package.json文件中指定的模块名。

/path/to/global/node_modules/myModule -> src/myModule

这个时候,已经可以全局调用myModule模块了。但是,如果我们要让这个模块安装在项目内,还要进行下面的步骤。

切换到项目目录,再次运行npm link命令,并指定模块名。

src/myProject$ npm link myModule

上面命令等同于生成了本地模块的符号链接。

src/myProject/node_modules/myModule -> /path/to/global/node_modules/myModule

然后,就可以在你的项目中,加载该模块了。

var myModule = require('myModule');

这样一来,myModule的任何变化,都可以直接反映在myProject项目之中。但是,这样也出现了风险,任何在myProject目录中对myModule的修改,都会反映到模块的源码中。

如果你的项目不再需要该模块,可以在项目目录内使用npm unlink命令,删除符号链接。

src/myProject$ npm unlink myModule

2. 参数解析

#! /usr/bin/env node

console.log(process.argv);
const argv = process.argv.slice(2);
console.log(argv);
const name = argv[0];
console.log(`hello, ${name}`);
# 执行 hello liujie
# 结果如下:
[ '/usr/local/bin/node', '/usr/local/bin/hello', 'liujie' ]
[ 'liujie' ]
hello, liujie

命令行参数可通过系统变量process.argv获取。 process.argv返回一个数组 第一个是node 第二个是脚本文件 第三个是输入的参数,process.argv[2]开始得到才是真正的参数部分。

3. Commander.js

对于参数处理,我们一般使用commander,commander是一个轻巧的nodejs模块,提供了用户命令行输入和参数解析强大功能如:自记录代码、自动生成帮助、合并短参数(“ABC”==“-A-B-C”)、默认选项、强制选项、命令解析、提示符。

#!/usr/bin/env node 

var program = require('commander');
 program
 	.version('0.0.1')
 	.option('-p, --peppers', 'Add peppers')
 	.option('-P, --pineapple', 'Add pineapple')
 	.option('-b, --bbq-sauce', 'Add bbq sauce')
 	.option('-c, --cheese [type]', 'Add the specified type of cheese [marble]', 'marble')
 	.parse(process.argv);
 	
 	console.log('you ordered a pizza with:');
 	if (program.peppers) console.log(' - peppers');
 	if (program.pineapple) console.log(' - pineapple');
 	if (program.bbqSauce) console.log(' - bbq');
 	console.log(' - %s cheese', program.cheese);
3.1 Commander API
  • Option(): 初始化自定义参数对象,设置关键字和描述;
  • Command(): 初始化命令行参数对象,直接获得命令行输入;
  • Command#command(): 定义一个命令名字;
  • Command#action(): 注册一个callback函数;
  • Command#option(): 定义参数,需要设置关键字和描述,关键字包括简写和全写两部分,以',','|,'空格'做分隔;
  • Command#parse(): 解析命令行参数argv;
  • Command#description(): 设置description值;
  • Command#usage(): 设置usage值。

4. 开发命令行翻译工具

// 安装相关依赖
npm install commander superagent cli-table2 --save
// 新建bin/translator.js文件,并加入package.json文件中

"bin": {
	"translator": "bin/translator.js"
}

然后执行:npm link

#! /usr/bin/env node
// 引入需要的模块
const program = require('commander');
const Table = require('cli-table2'); // 表格输出
const superagent = require('superagent'); // http请求
// 初始化commander
program
    .allowUnknownOption()
    .version('0.0.1')
    .usage('translator <cmd> [input]');

// 有道api
const API = 'http://fanyi.youdao.com/openapi.do?keyfrom=toaijf&key=868480929&type=data&doctype=json&version=1.1';

// 添加自定义命令
program
    .command('query')
    .description('翻译输入')
    .action((word) => {
        // 发起请求
        superagent.get(API)
        .query({ q: word})
        .end((err, res) => {
            if(err){
                console.log('excuse me, try again');
                return false;
            }
            const data = JSON.parse(res.text);
            const result = {};

            // 返回的数据处理
            if(data.basic) {
                result[word] = data['basic']['explains'];
            }
            else if(data.translation) {
                result[word] = data['translation'];
            }
            else {
                console.error('error');
            }

            // 输出表格
            const table = new Table();
            table.push(result);
            console.log(table.toString());
        })
    })

// 没有参数时显示帮助信息
if (!process.argv[2]) {
    program.help();
    console.log();
}

program.parse(process.argv);
// 执行:translator query food
┌──────┬───────────────┐
│ food │ n. 食物;养料 │

5. Node命令行工具开发【看段子小工具】

5.1 cheerio

cheerio可理解为服务器端的jQuery,基本用法与jQuery一样。有了它,我们在写小爬虫时就可抛开那可爱又可恨的正则表达式了。从此拥抱幸福生活。具体用法如下:

const cheerio = require('cheerio')
const $ = cheerio.load('<h2 class="title">Hello world</h2>');

$('h2.title').text('Hello there!');
$('h2').addClass('welcome');

$.html();
//=> <h2 class="title welcome">Hello there!</h2>
5.2 superagent

superagent专注于处理服务端/客户端的http请求,用法如下:

request
  .get(url)
  .end((err, res) => {
});
5.3 开始开发joke-cli
5.3.1 初始化项目
mkdir joke-cli
cd joke-cli
npm init
npm install cheerio superagent colors --save //colors 输出美化

新建bin/index.js文件,并加入package.json文件中:

"bin": {
    "joke-cli": "./bin/index.js"
 }

执行npm link

#!/usr/bin/env node

const superAgent = require('superagent');
const cheerio = require('cheerio');
const readline = require('readline');
const colors = require('colors');

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    prompt: '您正在使用joke-cli,按回车键查看笑话>>>'
});
const baseUrl = 'https://www.qiushibaike.com/text/page/';
let page = 1;

// 使用数组来存放笑话
const jokeArr = [];
// 获取笑话并存入数组中
function getJokes() {
    // 数组中的笑话不足三条时就请求下一页的数据
    if (jokeArr.length < 3) {
        superAgent
        .get(baseUrl + page)
        .end((err, res) => {
            if(err) console.error(err);
            const $ = cheerio.load(res.text);
            const jokeList = $('.article .content span');
            jokeList.each((index, element) => {
                jokeArr.push($(element).text()); // 存入数组
            });
            page++;
        });
    }
}
rl.prompt();
getJokes();

// line事件 每当 input 流接收到接收行结束符(\n、\r 或 \r\n)时触发 'line' 事件。 通常发生在用户按下 <Enter> 键或 <Return> 键。
// 按下回车键显示一条笑话
rl.on('line', (line) => {
    if(jokeArr.length > 0) {
        console.log('======================');
        console.log(jokeArr.shift().bgCyan.black); //用colors模块改变输出颜色
        getJokes();
     }
     else {
         console.log('正在加载中~~~'.green);
     }
     rl.prompt();
    }).on('close', () => {
        console.log('Bye!');
        process.exit(0);
    });

参考文档

  1. 跟着老司机玩转Node命令行
  2. Node.js 命令行程序开发教程
  3. commander.js
  4. Commander写自己的Nodejs命令
  5. Node.js+commander开发命令行工具
  6. 一起来学习如何用 Node 来制作 CLI
  7. node命令行小工具开发
  8. npm link 命令解析
  9. npm模块管理器
  10. commander.js
  11. 有道API
  12. SuperAgent-发起http请求
  13. cli-table2-命令行表格输出
  14. Node命令行工具开发【看段子小工具】
  15. cheerio-可理解为服务端的jQuery
  16. [译] SuperAgent中文使用文档

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.