老项目Webpack构建优化

最近在接手一个项目的时候,由于其开发已经一年有余,整个项目使用了create-react-app脚手架,Webpack版本停留在3.x,React版本15.x,并且在线上使用效果并不好,首页加载缓慢。对于这样一个典型的老项目,进行构建优化。

webpack 3.x之前的构建中,该项目使用CommonChunkPlugin对特定的package进行了抽取,例如react、图表库、组件库,其余使用到的package和业务代码被打包在了一起,Gzip过后差不多1MB。通过分析可以得知,分离出的第三方库和业务代码存在重复打包的情况,并且页面初始加载并不需要用到图标库,而组件库只使用到了部分组件却被完全打包了。

升级Webpack

由于使用的是create-react-app脚手架,其自带了react-scripts作为webpack的配置,本项目使用的是老版本脚手架生成,Webpack版本是3.x。分析后,在不更新脚手架的前提下,直接升级Webpack,可以使用splitChunk进行代码分隔,配合新版本的Reactlazy & Suspense,可不引入三方库的情况下实现按需加载。

升级Webpack比较容易,直接升级最新版的react-scripts即可(注意不是react-script)。

1
yarn add --exact react-scripts@2.1.1

安装完后yarn start运行项目,不出意外会有报错提示,因为某些react-scripts依赖的库版本过低,此时只要删除yarn.lockpackage-lock.jsonnode_modules,并且运行yarn install重新安装依赖即可。

安装完成后,不出意外项目可以正常运行了,此时Webpack已升级。

代码分隔

  1. 考虑到初次构建的时候仅仅分离部分第三方库导致第三方包和业务代码之间存在代码冗余,在新构建的版本中,需要对公用代码进行抽取,这样可以直接避免冗余。具体的splitChunk配置这里不再列出,抽取完成后例如lodashmoment这样的公共库被抽取到了公共包中,构建完成后生成了三个包:业务代码及未抽取的第三方包、特定的第三方包、公共代码包,构建体积明显减少。

  2. 由于业务的特殊性,首页并不包含任何图表,因此不需要加载图表库,并且图表库打包完成后大小为700KB(未Gzip),因此考虑使用基于路由的按需加载来去除首页不需要加载的资源。

  3. 进行代码分隔依赖于运行时加载,也就是import(),这是一个ES提案,目前还未标准化(babel已实现)。不同于import语法在预编译时链接代码,import()可以在运行时异步加载资源。而Webpack在解析到这种语法的时候,就会自动的进行代码分隔。

  4. 目前React懒加载的方案有很多,比如react-loadable,但由于新版的React已经支持了代码分隔,就没必要引入额外的库。React.lazy函数能够让你像处理常规组件一样处理懒加载组件。该函数接收一个函数作为参数,这个函数必须使用到import()动态加载组件,import()返回一个Promise,在resolve后返回这个React组件。Suspense是一个组件,其主要的作用是在懒加载块没有加载完成时使用占位组件进行UI 的优雅降级。

  5. 经过分析,基于路由的分隔可能最适合本项目。涉及到图表库的三个页面使用懒加载进行代码分隔,其余页面打包在一起,并且完全分离第三方库和业务代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import { lazy, Suspense } from 'react';
    import { render } from 'react-dom';
    import Loading from '@/components/loading'
    const ChartPage = lazy(() => import(/* webpackChunkName: "detail" */'@/views/chartPage'));
    const App = () => (
    <Router>
    <Suspense fallback={Loading}>
    <Switch>
    <Route path='/detail' component={ChartPage} />
    ...
    </Switch>
    </Suspense>
    </Router>
    );

直接运行yarn start,观察打包结果,发现已经按照路由进行了分隔,在总打包大小基本不变的情况下,初始加载的包只有230KB,减少了一大半。

  1. 观察打包结果,发现公共包的命名是0.hash.chunk.js,对于已经在注释中指定了webpackChunkName,这显然是不正常的,可能是react-scripts默认分隔策略的问题。为了修复这个命名问题并且进一步分隔runtime chunk,对splitChunk进行少量的配置。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const SplitCodeConfig = {
    optimization: {
    splitChunks: {
    chunks: 'all',
    },
    runtimeChunk: {
    name: 'runtime'
    }
    }
    }

只需要进行简单的配置,设置splitChunkschunksall, Webpack就会使用默认的规则进行打包:抽取公共包,抽取node_modules第三方包等等,具体见Webpack官网 -> 文档 -> 代码分离

至此,项目构建已经完成,构建的包可以直接用于生产换环境。

思考

  1. 在项目中使用到的组件库包含近百个通用组件和业务组件,但是项目中只使用到了十余个,并且通过观察打包结果,可以看到未被使用到的组件也被打包进来了,这显然不合理。针对这个问题,我们可以查看对应组件库的按需加载方案,并且在Webpack进行相应的配置即可。这里由于使用的是内部开发的组件库,就不在举例。

  2. 除了使用的组件库,我们需要找到所有能够按需加载却在项目中没有使用的第三方库。例如我要使用lodashdebounce函数进行防抖,我在项目中是这样使用的:

    1
    2
    import _ from 'lodash';
    window.onscroll = _.debounce(handler);

在构建的时候,整个lodash会被打包,如果只需要打包debounce函数,在import的时候直接import debounce from 'lodash/debounce'即可。

其它

  1. 为了直观的查看构建结果,推荐使用webpack-bundle-analyzer插件,它会在构建完成后打开一个页面,该页面可以看到每个bundle的打包情况。使用也很简单:

    1
    2
    3
    4
    5
    6
    7
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    {
    ...
    plugins: [
    new BundleAnalyzerPlugin(),
    ]
    }
  2. 如果想测试Gzip后的文件大小,在webpack-dev-server的配置项中添加compress: true即可。但如果查看生产环境下构建的Gzip包大小,可以使用compression-webpack-plugin插件,该插件会在构建完成后生成对应bundleGzip包。

    1
    2
    3
    4
    5
    6
    7
    const CompressionPlugin = require('compression-webpack-plugin');
    {
    ...
    plugins: [
    new CompressionPlugin({ threshold: 8192 }), // 只有大于8KB的资源才压缩
    ]
    }

在一般的情况下开启Gzip只需要设置生产服务器即可,并不需要在构建时生成Gzip包,但如果每次请求时再Gzip无疑会加大服务器的负担,因此事先准备好gzip包,服务器不必压缩而是直接返回,这不失为一个好的选择。

开启Gzip会极大的减小传输体积,但无论是压缩还是解压都需要大量的运算,对于某些较小的资源,使用Gzip可能反而会降低性能,因此建议只对较大的资源进行Gzip压缩,而较小的资源直接传输。

完。

分享到 评论

Webpack 4.x 简易使用指南

本篇来自于之前在团队内部分享webpack时的笔记,内容比较浅显,主要讲webpack基本配置,同时webpack运行原理,自定义loaderplugin也有涉及。

Webpack4.x使用指南

零配置使用Webpack

从Webpack4开始支持零配置,在不进行任何配置的形式下,直接运行webpackwebpacksrc/index.js作为入口,最终结果输出到/dist

在默认情况下,webpack只会对只会从入口开始遍历所有依赖文件,由于没有配置loaders,因此无法处理jsx/css/图片等。

在默认情况下运行webpack,打包完成后控制台会提示在配置打包的模式。在cli中通过设置--mode=production|development可以指定默认,在开发模式|产品模式下,webpack会默认启用不同插件对打包进行优化。

webpack基本配置

  1. 入口(entry)
    入口的配置形式有多种,例如:

    1
    2
    3
    4
    5
    6
    entry: './src/index.js', // #1
    entry: {
    home: './home.js',
    about: './about.js',
    }, // #2
    entry: ['./home.js', './about.js'] // #3

    第一种方式配置单一入口,所有文件将会被打包到同一个文件中,例如默认将会打包到main.js中;第二种方式是多页面(MPA)的配置方式,配置多个入口,最终将会生成多个bundle,配合html-webpack-plugin可以将对应的bundle注入到对应的html中;第三种配置的形式指定了多个入口,但是会将最终结果打包到一个文件中。

  2. 输出(output)
    输出指定了webpack将结果输出到哪里,如何进行输出,甚至是使用何种方式构建等,配置比较复杂。常用配置举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, 'dist'),
    }, // #1
    output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, 'dist'),
    library: 'xxLibrary',
    libraryTarget: 'amd',
    }, // #2

    第一种配置方式是最基本的输出配置,它配置了输入文件的存放的目录以及文件命名,filename支持模板语法,[name].[hash].js表示使用模块名称(入口配置)以及模块标识符来命名文件。
    第二种配置增加了’library’, ‘libraryTarget’两个属性,而library值的作用取决于libraryTarget的值。例2中配置libraryTarget: 'amd'表示使用amd的方式打包,而library的值将作为模块名。

    1
    2
    3
    define('xxLibrary', [], function() {
    return _entry_return_;
    })

    而当libraryTarget: 'commonjs2'时,表示该模块将使用commonjs的方式打包,用于node环境,而library将不起作用。此外libraryTarget的值还可以是var(默认)this等。

  3. 加载器(loader)
    在webpack中module决定了如何处理项目中不同类型的模块,而加载器(loader)则是具体的处理逻辑。

    noParse:指定不需要进行构建的文件。例如,指定项目中不需要对jquery进行构建,可以进行如下配置:

    1
    2
    3
    noParse: function(content) {
    return /jquery/.test(content);
    },

    规则(rules)的配置同样比较复杂,常用的配置如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    rules: [
    {
    test: /\.(js|jsx)$/,
    exclude: /(node_modules|bower_components)/,
    include: /src/,
    use: 'babel-loader'
    }, // #1
    {
    test: /\.(png|jpeg|gif)$/,
    oneOf: [
    {
    resourceQuery: /inline/,
    use: 'url-loader',
    },
    {
    resourceQuery: /external/,
    use: 'file-loader',
    }
    ]
    }, // #2
    {
    test: /\.css$/,
    use: ExtractTextPlugin.extract({
    fallback: 'style-loader',
    use: [
    {
    loader: 'css-loader',
    options: {
    modules: true
    },
    },
    ],
    }),
    },
    ], // #3

    上面列举了常用的配置loader的三种情况,第一种处理js|jsx文件时,使用excludeinclude指定构建目录和不需要构建的目录,对于明确不需要构建的模块进行指定可以加快构建速度。第二种情况则是根据具体情况选择对应的loader进行处理。例如当我们使用import emoj from './emoj.png?inline'时,则会使用url-loader对该png文件进行处理,对于external类型的图片,则使用file-loader处理。第三种情况则是使用多个loader并且可以传递options,例如处理css文件,这里使用了style-loadercss-loader两个加载器,并且配置了css-loader的optionsmodules: true。这里需要关注loader执行顺序,loader执行的顺序是先配置后执行,例如这里先使用css-loader解析css,再使用style-loader配置将生成好的css文件通过<style>标签注入到DOM中。options配置了modules: true,则开启css-modules,写法如下:

    1
    2
    3
    4
    import style from 'index.css';
    let IButton = ({children}) => (
    <span className={style.btn}>{children}</span>
    );
  4. 插件(plugin)
    插件(plugin)用于以各种方式自定义webpack构建过程。webpack附带了非常多的插件,可以直接使用webpack.pluginname使用,也存在非常多的第三方插件。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    plugins: [
    new CleanWebpackPlugin(['dist']), // #1
    new HtmlWebpackPlugin({
    title: 'index',
    chunks: ['index'],
    }), // #2
    new BundleAnalyzerPlugin({
    analyzerMode: 'server',
    analyzerHost: '127.0.0.1',
    analyzerPort: 8889,
    }), // #3
    ],

上例使用了三个插件,第一个插件CleanWebpackPlugin作用是在构建输出前清空文件夹;第二个插件用于生成入口html,例如该htmltitle,指定其需要注入的bundle(MPA)。第三个插件则是用于分析构建结果的插件,它会在本地启动一个开发服务器,并且通过canvas来绘制构建可视化结果,方便进行构建分析和优化。

  1. 开发服务器(devServer)
    webpack-dev-server方便我们进行快速开发应用,它会对代码进行监控,一旦发生更改,它会立即构建,并通过补丁的方式应用更改,使得应用能够快速应用更改。并且其提供了一个基于Express开发的简易服务器,所有资源文件都存在内存中,访问速度极快,并且通过配置可以支持热替换。介绍一下常见的webpack-dev-server配置。
    1
    2
    3
    4
    5
    6
    7
    devServer: {
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    inline: true,
    historyApiFallback: true,
    allowedHosts: ['host1.com', 'host2.com'],
    }

contentBase主要用于指定静态文件的存放路径,例如所需要图片等,如不指定则无法找到对应文件;

compress表示是否开启Gzip压缩;

inline表示是否启用内联模式,内联模式:实时重载的脚本会被插入到bundle中,构建消息将会出现在控制台。此外还有iframe模式。

historyApiFallback主要针对的是访问不存在的页面时的活动,historyApiFallback: true时访问不存在的页面会直接跳转到index.html,也可以传入一个对象更精确的进行控制跳转。

allowedHosts用于指定允许该devServer的主机。

hot是否开启热替换,如果为true的话,则会使用webpack.HotModuleReplacementPlugin插件。通过CLI的方式传递。

1
webpack-dev-server --hot

Webpack 运行流程

概括上来说:
初始化配置参数 -> 绑定事件钩子回调 -> 确定Entry开始遍历 -> 使用loader编译文件 -> 输出

webpack就像一条生产线,经过一系列处理流程后才能将源文件转换成输出结果。

每个流程的处理职责是单一的,流程之间存在依赖关系,只有当前处理完成后才能交由下一个流程处理。

插件像是插入到生产线上的一个功能,在特定的时机对资源进行处理。

webpack 通过 Tapable来组织这一切。

Webpack在运行过程中会广播事件,插件只监听它关心的事件。

—《深入浅出webpack》

Tapable的核心代码可以简化成。

1
2
3
4
5
6
7
8
9
10
11
12
13
class SyncHook {
constructor() {
this.hooks = [];
}
// 订阅事件
tap(name, func) {
this.hook.push(func);
}
// 发布
call() {
this.hooks.forEach(hook => hook(...arguments));
}
}

Webpack具体的流程如图所示。
流程

  1. 初始化构建参数(依据webpack.config.js),插件实例化,生成Compiler供插件使用,挂载自定义钩子。

  2. 依据入口递归遍历文件,并且使用对应的loader进行编译。

  3. 将编译好的文件解析成AST(抽象语法树),分析依赖逐个拉取济源。

  4. 编译完成,输出。

编写自定义loader

在webpack中,真正起编译作用的就是各种各样的loader。loader其实是一个function,其传入匹配到的文件内容(String),然后对这些内容做处理即可。

一个最简单的loader可以使下面这样:

1
2
3
module.exports = function(content) {
return "{};" + content;
}

config中进行配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
module: {
rules: [
{
test: /\.js$/,
loader: path.resolve(__dirname, './loaders/index.js'),
options: {
param1: 1,
param2: 2,
}
}
]
}

这样在编译JS的时候就会在每个JS文件前加上{};


获取自定义配置

可以使用loader-utils模块来拿到自定义配置。

1
2
3
4
5
6
7
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
console.log(options.param1); // 1
console.log(options.param2); // 2
return "{};" + content;
}


数据导出

loader可以通过return来返回处理后的结果;当然,更好的方式是使用this.callback的形式。因为它更加灵活,除了content以外,还可以传递其它参数。

this.callback(error, content, sourceMap, ast)可以传入四个参数:

  • error loader向外抛出一个错误
  • content 经过loader编译后的内容
  • sourceMap
  • ast 本次编译生成的AST,之后执行的loader可以直接使用,而不需要再次生成
1
2
3
4
5
6
7
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
console.log(options.param1); // 1
console.log(options.param2); // 2
this.callback(null, "{};" + content);
}

异步loader
对于异步loader,可以使用this.async来获取
callback函数。

1
2
3
4
5
6
7
8
9
10
11
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
let callback = this.async();
this.cacheable(false); // 是否缓存结果
console.log(options.param1); // 1
console.log(options.param2); // 2
setTimeout(function() {
callback(null, "{};" + content);
}, 1000);
}

pitch钩子
可以在loader文件中exports一个名为pitch的函数,它会先于所有的loaders执行。可以在这个过程中传参,而当前rule的所有loaders都可以拿到这个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
let callback = this.async();
this.cacheable(false); // 是否缓存结果
console.log(options.param1); // 1
console.log(options.param2); // 2
console.log(this.data.hello); // hello
setTimeout(function() {
callback(null, "{};" + content);
}, 1000);
}
module.exports.pitch = function(remaining, preceding, data) {
data.hello = 'hello';
}

Plugin 初探

Plugin起始是一个简单的class,有一个必须要实现的apply方法。

1
2
3
4
5
6
7
8
9
class LPlugin {
constructor(options) {
this.options = options;
console.log('options', options);
}
apply(compiler) {
console.log('run this plugin');
}
}

使用

1
2
3
4
5
6
7
8
9
10
const LPlugin = require('./plugins/LPlugin');
module.exports = {
...,
plugins: [
new LPlugin({
param1: 1,
param2: 2,
})
]
}

plugin在初始化参数就进行实例化(事件流开始时),因此类似于CleanWebpackPlugin在构建之前进行文件操作删除掉某些目录也是很好实现的。


Tapable & Hook Tapable是Webpack构建过程中的核心所在,其暴露了tap tapAsync tapPromoise等方法,可以使用这些方法,来注入一些逻辑,这些逻辑将会在构建过程的不同时机触发。

例如:

1
2
3
compiler.hooks.compile.tap('LPlugin', params => {
console.log('触发了钩子函数');
})

该钩子函数将会在compile阶段触发。

*** 自定义钩子函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { SyncHook } = require('tapable');
class LPlugin {
constructor(options) {
this.options = options;
console.log('options', options);
}
apply(compiler) {
compiler.hooks.LPlugin = new SyncHook(['data']);
compiler.hooks.enviroment.tap('MyPlugin', function() {
compiler.hooks.LPlugin.call('hello');
})
console.log('run this plugin');
}
}

1
2
3
4
5
6
7
class LLPlugin {
apply(compiler) {
compiler.hooks.LPlugin.tap('LLPlugin', function(data) {
console.log('data', data); // data hello
});
}
}
分享到 评论

使用Webpack DllPlugin

DllPlugin 简易使用指南

  1. 创建webpack.dll.config.js 用于对特定的模块打包成dll
  2. webpack --config webpack.dll.config.js 生成dll以及其描述文件
  3. webpack.common.config.js中使用DllReferencePlugin引入打包好的dll文件。
  4. 打包。此时遇到相应的模块时直接引入而不会重新打包。

创建webpack.dll.config.js

这里以分别打包momentlodash为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// config.js
module.exports = {
entry: {
lodash: ['lodash'],
moment: ['moment'],
}
}
// webpack.dll.config.js
const webpack = require('webpack');
const path = require('path');
const config = require('./config');
module.exports = {
entry: config.entry,
output: {
path: path.resolve(__dirname, '/static/dll'),
filename: '[name].dll.js',
library: '[name]_library'
},
plugins: [
new webpack.DllPlugin({
path: path.join(__dirname, '/static/dll', '[name]-manifest.json'),
name: '[name]_library',
context: __dirname,
})
]
}

解释:

  1. 推荐把入口配置信息写在单独文件中,易于维护。
  2. DllPlugin类接受一个配置对象,该对象有三个属性:context(绝对路径), manifest文件中请求上下文;name,暴露的dll函数名;path:manifest文件存放的位置(绝对路径)。

使用DllReferencePlugin

1
2
3
4
5
6
7
8
9
10
11
12
// webpack.common.config.js
const config = require('./config');

module.exports = {
... // 省略其它配置
plugins: [
...Object.keys(dllConfig.entry).map((name) => new new webpack.DllReferencePlugin({
context: __dirname,
manifest: require(`./static/dll/${name}-manifest.json`),
}))
]
}

DllReferencePlugin 接受一个对象用于初始化

  1. context: manifest的上下文(绝对路径),需和DllPlugin中的context一致
  2. manifest: manifest文件,使用require引入或指定绝对路径

可选参数:

  1. content:模块id的映射,默认为 manifest.content
  2. name: dll文件的名称,默认为 manifest.name
  3. scope: dll 内容前缀
  4. sourceType: dll如何暴露的?amd commonjs2 …

scope: ‘abc’, 则该dll中的xyz文件可以通过require(‘abc/xyz’)来引用
例如在一个dll中打包了lodashaxios两个库,并且指定了{scope: 'lib'},则在需要使用axios的时候使用require('lib/axios')即可。

测试1:把echartswinduireact及其周边分别抽取成dll文件。

抽取的dll文件大小分别为:753KB 1603KB,385KB。

构建方式 平均构建时间 包大小
普通 97s 4.1MB
使用Dll 71s 2.6MB
dll文件 24s 2.7MB

经过对比发现,由于无法使用按需加载,所以整个windui打包的大小差不多为1.6MB,而普通的打包方式windui的大小仅为365KB。

在使用windui链接库后生成的打包,发现vendors模块中仍然含有windui,大小为330KB,仅windui中的node_modules文件夹下的rc-triggerrc-dropdown被重用,因此这里可能重复打包了。

windui中依赖的rc-*部分模块仍然被打包。

方式二 仅对react相关模块和echarts进行打包

抽取的两个dll文件的大小为393KB 769KB

构建方式 平均构建时间 包大小
普通 97s 4.1MB
使用DLL 77s 3.08MB
dll 11s 1.16MB

windui的大小基本不变,而windui依赖的rc-*系列组件基本没被打包。

vendors模块从0.61MB1.67MB

使用html-webpack-include-assets-plugin 把dll注入到index.html

1
2
3
4
5
const HtmlWebpackIncludeAssetsPlugin = require('html-webpack-include-assets-plugin');
const dllIntoHtml = new HtmlWebpackIncludeAssetsPlugin({
assets: ['./static/dll/echarts.dll.js', './static/dll/react.dll.js'], // 需要注入的dll文件路径
append: false, // 是否尾注入?push : unshift
});

因为先构建dll,再进行项目构建,在项目构建过程中会删除整个build目录,所以在构建完后再把dll文件夹拷贝进build/static/

总结

  1. windui进行dll打包后会因为无法按需加载而导致总大小偏高(且可能会产生冗余);而不进行的话,包的总大小基本不变。

  2. 初始时需要加载的chunk从3个变为6个/5个

升级到webpack4.x

升级到webpak4.x后无论是dll还是最终生成块,都有小幅度的下降。与此同时,构建总时间(dll构建时间+项目build时间)略微减小。

关于使用已存在的模块直接作为dll文件引入的可能性

  1. 目前无相关方面的实践。
  2. DllPlugin抽取特定的模块构建dll文件后会生成一个manifest文件。该存储了各个模块的和公共模块的对应关系。
    该文件会对已经打包成dll的模块中的文件进行描述,会给每个文件指定id,并且该json文件中的name属性对应dll的library
  3. 在进行项目构建的时候,如需要打包某个模块,会在manifest文件中查找,如果该模块已经存在于dll中,依据manifest中的信息进行链接即可,不必重新打包。

构建的dll块和使用splitChunk或者直接使用babel打包出来的块不一致。其依赖构建dll时指定的library等,而manifest文件也是和该块一一对应的。所以从理论上,使用其它方式提供dll文件由于构建方式和无法提供manifest文件,在构建过程中并不能被重用。

PS: 本片文章来自于我在团队内部分享的笔记,任何关于webpack-dll-plugin的理解和使用方式请在该插件的Webpack介绍页查看

分享到 评论

前端路由拦截和http响应拦截

问题由来

最近在制作毕业设计的时候,遇到一个问题,那就是用户的访问控制。简单点来说,就是未登录用户只能访问某些特定的页面、API。最初我的想法是用户登录后返回一个凭证,用户以后的每次http请求都带上该凭证,进行验证,只有验证成功才能继续请求。然后在每个页面进行判断,如果用户是未登录或者凭证失效,则进行相应的提示和路由跳转。刚开始的时候,这个方法是完全可行的,但是在开发过程中,随着业务逻辑变得复杂、页面增多,重复代码太多,这样的方式也许并不合适。

如何解决

首先是后端,我使用了jsonwebtoken,用户登录成功都会生成一个具有一定时效的 token,这个token会发回到客户端,并且接下来每次发起http请求,都在http头的authorization字段带上这个token。我这里使用了axios这个http请求库,只需要在拿到token后:

1
axios.defaults.headers.common.authorization = `Bearer ${token}`;

就可以了。

由于在开发过程中涉及到跨域,这里我使用CORS来解决:通过设置一系列Access-Control-Allow-*响应头进行访问控制,上面提到了在请求头的authorization字段中设置token,因此发出的请求都不是简单请求,所以注意在每次发起http请求时,就会自动发起一个OPTIONS请求。

我服务器端用的是Express框架,我们需要写一个中间件来处理每一个请求。处理逻辑为:针对每个OPTIONS请求,直接放行;对于某些请求,如果在白名单中(例如登录、注册等不需要验证的路由),放行;对于其他请求,我们拿到其携带的token,并且进行验证,如果验证通过,放行,否则结束请求,返回未授权。具体的代码如下,这里我使用jsonwebtoken这个package,用于生成token和进行token验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
app.use((req, res, next) => {
console.log('methods' ,req.method);
if( req.method === 'OPTIONS' ) {
console.log('option请求直接通过');
next();
}else {
// 除去某些特定的API,其余的都做token的验证
let { path } = req;
if(path === '/api/users/auth'
|| path === '/api/users/auth_vc'
|| path === '/api/users/check_id_validation'
|| path === '/api/users/regist'
|| path === '/api/users/send_reset_email'
|| path === '/api/users/reset_password'
)
{
console.log('本次请求不需要验证权限');
next();
}else {
const token = req.headers.authorization ? req.headers.authorization.split(' ')[1] : '';
req.token = token;
jwt.verify(token, KEY, (err, decoded) => {
if(err) {
res.status(401).json({ status: 3, error: '用户认证失败', data: '' })
}else {
console.log('验证权限通过');
req.decoded = decoded;
next();
}
})
}
}
})

然后是前端,我想如果能像后端拦截每个请求一样,写一个逻辑拦截所有的相应,并进行处理,信号,axios自带拦截器,我们只需要写我们的逻辑就可以了。我的想法是,拦截每一个相应,如果其状态码是401,那么久提示token失效,并且进行路由跳转。
vue-cli构建的应用为例,在main.js中,下面是实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
axios.interceptors.response.use(data => data, (error) => {
if(error.response) {
switch(error.response.status) {
case 401: {
localStorage.removeItem('token');
router.replace({
path: '/auth',
query: {redirect: router.currentRoute.fullPath}
})
}
}
}
return Promise.reject(error);
})

值得说明的是,如果我们在某个访问的过程中,token失效,我们需要跳转到登录页面,但是想登录过后再跳转回来,所以这里在进行路由跳转的时候,我设置了一个参数, redirect,表示传入当前的路径,当我们登录成功后,在跳转回来即可。

最后是路由拦截,这里我使用了vue-router,其实vue-router的路由对象提供一个钩子函数beforeEach,其会在每一次路由跳转之前,执行这个函数,我们就在这里进行路由拦截。原理很简单,使用一个标志位标明每个路由是否需要用户权限,如果需要的话,我们检查保存在本地的凭证,一般存在localStorage中,如果不含凭证就直接跳转到登录页面。

好了,找到根路由文件,添加:

router.beforeEach((to, from , next) => {
  if(to.matched.some(res => res.meta.requireAuth)) {
    if(localStorage.getItem('token')) {
      next();
    }else {
      next({
        path: '/auth',
        query: { redirect: to.fullPath }
      })
    }
  }else {
    next();
  }
})

这里要注意的是,res.meta.requireAuth是你自己在声明路由的时候自定义的。

总结

差不多,这算是一个比较好的解决方案了。但是有这样一个情况:如果用户凭证有效期是1小时,那么如果我浏览网页超过一个小时了,凭证还是保存在本地的,当我们进行路由跳转的时候,并没有验证凭证是否失效,所以还是会进行路由跳转。这里不用担心,因为进入进入了一个路由后,一旦发起http请求,token失效,http相应拦截就会生效,进而跳转到登录页面。

加油!

分享到 评论

SQL参数化查询

SQL注入想必是每个人都听过,其原理和XSS攻击很相似,都是把用户的输入当做程序去执行。防御办法也很类似,就是对用户的输入进行转义,但是同样转义十分麻烦,因为SQL注入攻击的方式和变种实在太多,转义需要考虑到的情况也复杂多变;而另外一种方式就是使用参数化查询–Prepared Statements。

SQL注入

在先介绍参数化查询的时候我们先复习一下SQL注入,上面提到其原理是把用户的输入当做了SQL语句程序的一部分去执行,因为我们经常使用字符串拼接来构建SQL语句。

在这里开始演示一下(使用MySQL):

在我的数据库中我数据库中我建立了一个名为urls的表,其结构和数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> desc urls;
+-------------+------------------+------+-----+-------------------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+------------------+------+-----+-------------------+----------------+
| id | int(11) unsigned | NO | PRI | NULL | auto_increment |
| url | varchar(255) | NO | | | |
| insert_time | timestamp | NO | | CURRENT_TIMESTAMP | |
| tid | bigint(20) | YES | | NULL | |
+-------------+------------------+------+-----+-------------------+----------------+
mysql> select * from urls;
+----+-----------------------+---------------------+------+
| id | url | insert_time | tid |
+----+-----------------------+---------------------+------+
| 1 | http://www.limoer.cc | 0000-00-00 00:00:00 | NULL |
| 2 | http://baidu.com/news | 0000-00-00 00:00:00 | NULL |
| 3 | http://do.io | 0000-00-00 00:00:00 | NULL |
| 5 | http://github.iod | 0000-00-00 00:00:00 | NULL |
+----+-----------------------+---------------------+------+

该表有4字段并且有4条记录,现在我们如果想要查询id=1的那条记录,应该这样写:select * from urls where id=1。执行该条语句,正确返回结果,现在我们修改一下这条语句,改成:select * from urls where id=1 and 1=1,执行这条语句,同样没问题,返回结果正常;我们接下来再把and 改成 or再执行,结果出乎我们的意料,我们把所有的记录都查询了出来,id=1的限定条件失效了。至于如何导致其失效,是因为or后面的条件1=1是恒等的,所以前面的限定条件已经不重要了,and也是如此,我们想要获取正确的结果,那么and后面的限定条件必须要正确才可以。

说到这里,其实我们就已经进行了一次SQL注入的攻击,并且窃取了数据库的所有记录(更严重的删库、窃取管理员密码也很easy)!

其实不光是上面演示到的使用and or来进行SQL注入,还有很多神奇的SQL语法让SQL注入有了可乘之机,例如我们常用的union等等。

解决办法

如果我们把上面情景放在实际开发过程中,我们可能现在有一个输入框,用户可以输入任意一个数据来查看某条记录,
服务端的SQL语句也许是这样的:select * from urls where id=${userInput}。如果某个淘气的用户不遵守约定输入了非数字,例如10 or 1=1,SQL语句拼接过后就成了这样:select * from urls where id=10 or 1=1,表中的信息一次被完全暴露!

针对上面的情况,我最想想到的不是转义输入也不是使用参数化查询,而是针对本问题,我们直接对其进行输入验证即可,既然其必须限定用户输入数字,那么在进行SQL拼接之前,对用户输入进行验证即可!

例如,在Node.js环境下,我们可以使用parseInt(userInput)就可以完成对用户输入进行强制性的验证。

第二种也就是最常用的解决办法就是转义,和防御XSS攻击一样,我们需要构建用于转义的函数,对用户的输入进行转义,还是上面的那个例子:

1
select * from urls where `id`= ${id};

如果用户输入1 or 1=1,那么毫无疑问将会导致一次非常严重的SQL注入攻击,现在假设我们已经写好了我们的转义函数escape,我们只需要在进行字符串拼接之前,做一次转义即可。 例如对于用户的输入1 or 1=1经过转义后变成了'1 or 1=1',经过SQL拼接过后则变成了:

1
select * from urls where `id`='1 or 1=1';

不出意外,我们得到了正确的结果。

关于转义函数escape如何实现,这里就不不再多说,很多数据库的驱动工具都带有相应的工具函数,我们在实际开发过程中一定要注意对用户的输入进行转义,来避免SQL注入攻击;当然,如果你使用参数化查询的话,就完全没有必要了。

参数化查询

最开始提到参数化查询的时候,我提到了Prepared Statements也就是预处理语句,其实我们可以把参数化查询理解为预处理,我们把完整的一次SQL查询分成两部分,第一步是预先查询,第二步使用参数得到结果。具体该怎么理解呢,还是接着上面的那个例子,现在我们使用参数化查询执行select * from urls where id=1。其分为两步,第一步执行select * from urls where id=?,注意这里的?,其实代表了未来将要传入的参数;第二步,传入用户的输入作为具体的id值,并且输出结果。这里要注意,因为执行完第一步的时候期待第二步传入的是一个用户的id(这里必须是数字),这时候用户传入的非法输入就不会生效,这也就从根本上杜绝了了SQL注入攻击。

好了,参数化查询(预处理)可以完全避免SQL注入,其还有其他的优点例如更加可读(相比于字符串拼接),多次查询性能会有提升(因为会对预处理语句进行缓存再利用)等。

说了这么多,那么如何使用参数化查询呢?很简单,使用一个支持该特性的数据库连接工具就可以了,比如我们下面要演示的Node环境下MySQL的参数化查询。

Demo

我们在Node环境下进行演示,首先通过npm install mysql2命令安装数据库连接工具,这里是mysql2,能够支持参数化查询。

如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const mysql2 = require('mysql2');
const conn = mysql2.createConnection({
host: 'localhost',
user: 'admin',
password: '123',
database: 'news'
});
// 不使用任何防护手段(将导致SQL注入攻击)
const userInput = '1 or 1=1';
conn.query(
`select * from urls where id=${userInput}`,
(err, result) => {
console.log(result);
}
);
// 使用转义(这里默认进行了转义)
conn.query(
'select * from urls where `id`=?',
['1 or 1=1'],
(err, result) => {
console.log(result);
}
);
// 使用参数化查询
conn.execute(
'select * from urls where `id`=?',
['1 or 1=1'],
(err, result, fields) => {
console.log(result);
}
);

尾巴

关于SQL注入和参数化查询就介绍到这里,如果你觉得参数化查询两步走我说得并不明确,你可以使用抓包工具来加深理解;还有最后的Demo,其实query和execute的区别就是一个支持了参数化查询而另外一个不支持;如果你运行Demo,仔细看,区别就藏在里面(Tips:B & T);最后,请总是使用参数化查询!

分享到 评论

使用react-transition-group实现路由切换动画

我们在使用React开发SPA的时候,使用react-router可以完成路由切换,但是这样路由切换是非常生硬的。有什么解决办法呢?我们可以使用react-transition-group来实现自定义的路由切换效果。

需要注意的是react-transition-group目前有两个版本,v1和v2版本的差距十分巨大,本教程使用的是最新的V2版本,你可以使用npm install --save react-transition-group来安装,如果想安装v1版本,则只需使用npm install --save react-transition-group@1.x命令即可。

react-transition-group主要提供三个组件TransitionTransitionGroupCSSTransition。从名字当中我们知道TransitionGroup作为一个容器组件,而其它两个组件才是实现动画的关键。这里我只介绍CSSTransition如何使用以及其注意的点。如需了解更多react-transition-group,请查看官方文档

CSSTransition

这个组件主要是使用css来控制组件的转场。它使用了在缓动中appearenterexit的三个状态,并且提供钩子类让我们自定义效果。

我们常用到的类有:

.className-enter
.className-enter.className-enter-active
.className-exit
.className-exit.className-exit-active

这里className是你自定义动画的名称,和V1版本大体相同的钩子类,只不过把leave改成了更加语义化的exit,这里需要注意。

CSSTransition有多个十分重要的属性:

  1. classNames属性接收一个字符串类名,注意这里是classNames而不是className
  2. timeout用于规定动画执行的时间,如果enterexit的持续时间相同的话可以使用timeout={number}即可,如果持续时间不一样,则timeout接收一个字典,两个键分别是enter和exit。
  3. 其他参数例如onEnteronExit你可以自定义逻辑在动画进行到某个阶段后触发。
  4. 动画进行的阶段:enter->entering->entered->exit->exiting->exited

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

let App = () => (
<BrowserRouter>
<Route render={({location}) => {
return <div>
<Route exact path="/" render={() => (
<Redirect to="/home"/>
)}/>
<TransitionGroup>
<CSSTransition
key={location.pathname.split('/')[1]}
classNames="transitionWrapper" // 这里一定要注意的是:classNames 而不是className
timeout={400}
mountOnEnter={true}
unmountOnExit={true}
>
<div className="wrapper">
<Switch location={location}>
<Route exact path="/home" component={Home}/>
<Route path="/inspiration" render={() =><NavLink style={{marginRight: '20px', marginLeft: '20px'}} to="/home">HOME</NavLink>
}/>
<Route path="/mood" render={() => <h1>this is page3 mood!</h1>}/>
</Switch>
</div>
</CSSTransition>
</TransitionGroup>
</div>
}}/>
</BrowserRouter>
);
分享到 评论

使用antd和css-modules冲突的解决办法

在暑假做项目实训的时候前端就使用到React构建并且使用了Ant Design作为组件库,当时就使用了extract-text-webpack-plugin把css单独抽离出来成为一个单独的css文件并引入,当时就遇到一个问题,当我使用css-loader来处理css时,并不能处理自定义的css,但是我把CSS直接写进组件中是可行的,由于当时项目比较小并且时间比较赶,就直接使用了这种方式,在开发过程中也有苦说不清,但总算是完成了。

最近想写一点东西,又用到antd了,当然是相同的问题,只不过时过境迁,我有足够多的时间来处理这个遗留下来的问题。可是即使有那么多的时间,可是还是踩坑无数,最终还是完成了。相信遇到这个问题的并不止我一个人,这里就先记录下来,希望能对你有所帮助。

解决办法

经过查询和思考,解决这样的问题最好是单独处理antdCSS和自定义的CSS。好了问题解决办法已经很明显了,我们需要些两个不同的规则来出来css,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
use: 'css-loader'
})
},
{
test: /\.css$/,
exclude: /node_modules/,
use: 'css-loader'
},
...
plugins: [
new ExtractTextPlugin('style.css')
]

上面的代码我建立了两规则分别处理自定义css和antd 预定义css,我们可以正常的使用import './style.css'的形式引入css,但是我们查看页面,并没有加载我们自定义的css。

好吧,既然这样再试试css-modules的方式算了,我们把第二个规则改成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
...
{
test: /\.css$/,
exclude: /node_modules/,
use: [
loader:'css-loader',
options: {
modules: true,
localIndentName: '[local]--[hash:base64:5]'
}
]
}

现在我们可以通过import style from './style.css'的形式引入自定义css,并且通过style.className的形式给元素设置类。这次倒好,直接build不成功了,我一气之下索性不搞了;为了继续捣鼓下去,我直接又把CSS写在组件中了,直到我要使用react-transition-group来做路由切换动画,不得不倒回来解决。这次比以往更加冷静,我仔细阅读了extract-text-webpack-plugin的readme过后,恍然大悟,原来我们可以在一个项目中使用多个ExtractTextPlugin实例来生成多个css文件!好了,这次还是通过两个规则处理css,并且构建两个css文件,一个是自定义的css,一个是antd css,问题迎刃而解,又可以开心的捣鼓了!

好了,show you the code!:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const extractANTDCSS = new ExtractTextPlugin('[name]-antd.css');
const extractNormalCSS = new ExtractTextPlugin('[name]-normal.css');

module.exports = {
...
module:{
rules: [
{
test: /\.css$/,
include: /node_modules/,
loader: extractANTDCSS.extract({
fallback: 'style-loader',
use: [{
loader: 'css-loader',
options: {modules: false}
}]
})
},
{
test: /\.css$/,
exclude: /node_modules/,
use: extractNormalCSS.extract({
fallback: 'style-loader',
use: [{
loader: 'css-loader',
options: {
modules: true,
localIndentName: '[local]--[hash:base64:5]'
}
}]
})
}
]
},
plugins: [
extractANTDCSS,
extractNormalCSS,
...
]
}

以上的两个loader会生成两个css文件,分别是vendor-antd.cssmain-normal.css,我们只需要在正确的位置引入这两个css文件就好了!

尾巴

我在前面提到我把CSS直接写在元素/组件的style标签中,其实这种方式实不可取的,它会让你在编码和代码review中苦不堪言,因为一旦项目变得很大,当你想修改某个样式的时候,花在定位CSS的时间是非常多的;并且,可读性和可复用性也会大打折扣;而且我们经常在写样式的过程中使用的各种选择器、伪类、伪元素都无法发挥其灵活的作用。所以,无论你是以何种方式写前端,请尽量不以这种方式写CSS。

当然,我们也要从性能上去考虑。因为css是在页面解析正式前就加载好了的(写在header)里面,在我们再解析页面的时候,加载速度就会变得更快;再有,如果我们使用把CSS写在组件中后,无可避免的会产生更多的重绘和回流,这会严重影响渲染性能。比如我们使用JS修改我们在style属性中标明的样式,那么必然会触发一次repaint。

好了,到此打住!如果你想学习reflow和repaint,点击这里,也许会帮助你!

分享到 评论

Three.js

最近貌似Node又有了新的fork ayo.js(怎么读!哎呦?),加之前端一不留神就出框架的节奏,在2016年就开始用Next(wtf!你能看出来其是一个前端框架?)来命名,以后恐怕就得future.js、plus.js的节奏…贵圈真乱啊!

当然当然,这和我们今天的主角three.js并没有太大的关系,比起这些看了名字不知所云的xxx.js,Three.js这个就和明显了,其是一个3d JavaScript库,更准确的说是用JavaScript编写的WebGL三方库,那么什么是WebGL呢?这个我不解释,有兴趣的小伙伴可以去探索。

作为我最想学却一直学不会的技术之一,WebGL的确对于大部分的前端猿们来说有些复杂和繁琐了,早些时候我花了大量的时间去啃API,学习如何使用,可到目前脑子还是一团乱麻。既然这样的话,我们得另辟蹊径,不能因为有困难就放弃学习不是!所以我了解到了Three.js,其化繁为简,做同样的事,其只需要少于1/5的代码量就可以完成,并且API也十分通俗易懂,学习难度降低了不少,可以让我们关注使用WebGL创造而不是痛苦的学习和编码。

如果你还不理解WebGL是什么,这是官方文档上的原话:

WebGL (Web Graphics Library) is a JavaScript API for rendering interactive 3D and 2D graphics within any compatible web browser without the use of plug-ins. WebGL does so by introducing an API that closely conforms to OpenGL ES 2.0 that can be used in HTML5 canvas elements.

如果你对Three.js比较有兴趣的话可以直接进去官网,其中首页展示了很多featured projects,个人比较喜欢这个Paper Planes

你也可以去gayhub把Three.js代码download下来,里面有很多很多(大约几百个例子)可供学习,当然如果你想学习Three.js,来百度云下载,这是目前少有的全方面介绍Three.js的书籍。

好了,差不多介绍完该跑了。但是我好想发现了我竟然连副标题都没取,好吧,还是再多讲一会儿,为了彰显Three.js的简单易用的特性,我讲决定再写一个全面但是简单的例子,并且配上必要的讲解。

例子?不存在的!

这是一个很小的例子,它将会展示Three.js使用流程,并且是经过测试没有错误(也许有!),请放心食用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8">
<title>使用Three.js</title>
<script src='./three.js'></script>
</head>
<body>
<div id="three-container"></div>
<script type='text/javascript'>
// three.js中有几个非常重要的知识点,为了构成一个3D程序,我们至少需要以下几部分。
// 1. Scene 场景,用于承载一些必要元素
let scene = new THREE.Scene();
// 2. Camera 相机(此相机非你想的那个相机哦!)
// Three.js中提供了两种相机,透视相机和正交相机,这里使用的是透视相机(类似于人眼看到的)
let camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerWidth, 1, 1000);
camera.position.set(-20, 40, 20);
// 3. renderer 渲染器, 也可以在canvas中渲染,但是复杂场景可能有性能问题
let renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0x708090);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMapEnabled = true;
// 4. 物体
let cubegeo = new THREE.CubeGeometry(10, 10, 10);
// 5. 材质 用于物体表面,不同材质包含不同特性,可设置颜色等。
let material = new THREE.MeshLambertMaterial({
color: 0xffffff
});
// 组合物体与材质成为一个网格
let cube = new THREE.Mesh(cubegeo, material);
// 设置物体能够产生光源阴影
cube.castShadow = true;
scene.add(cube);
// 6. 光源 Three.js中存在多种光源
let light = new THREE.SpotLight(0xfffff);
light.position.set(-30, 40, -20);
light.castShadow = true;
scene.add(light);
// 设置相机看向场景远点(空间坐标系原点)
camera.lookAt(scene.position);
// 添加到HTML中
document.getElementById('three-container').appendChild(renderer.domElement);
// 为了更加直观,这里设置一下空间坐标系
let axes = new THREE.AxisHelper(30);
scene.add(axes);
// 动起来吧!添加动画
function animation() {
// 比如移动转动方块, 这里设置在x、y轴转动平面
cube.rotation.x += 0.1;
cube.rotation.y += 0.1;
requestAnimationFrame(animation);
renderer.render(scene, camera);
}
requestAnimationFrame(animation);
</script>
</body>
</html>

尾巴

即使是这样一个简单的例子,我如今也没有办法在不参考官方文档的情况下一口气写下来,原因无非在于,虽然其简化了开发,但是概念还是偏多并且需要记住每个API也是在有难度。

但是,如果我们能够十分清楚的理解制作3D应用的流程,至少是使用Three.js的流程,按照流程十分有条理的写下去,代码总归是十分清晰的。

time waiting for no one,这是我最近在看《穿越时空的少女》看到的。对啊,时间不等人,珍惜好为说不多的’自由’而’枯燥’的时间吧!

分享到 评论

从Decorator到Mobx

最近在开发一款视频编辑器,其中就用到了Mobx作为状态管理工具。Mobx中很重要的概念例如可观察(observable)的状态,可计算(computed)的值都用到了decorator(当然在使用Mobx时可以不用)。Decorator作为ES7引入的新特性,用于给类/属性添加新行为。对于不少初学者而言,可能对其并不是很了解,所以在这里从装饰器开始,聊聊我对Decorator和Mobx的理解。如果你正在学习Mobx,希望能对你快速上手Mobx能有所帮助。

先说装饰器(Decorator)

装饰器是ES7中引入的,其目的在于修改类/方法的行为。例如我们可以在不修改“类”的情况下为其增加新的功能。

例如:我们定义了一个学生“类”,其中有nameage两个属性,以及showInfo一个方法。

1
2
3
4
5
6
7
8
9
class Student {
constructor(name, age) {
this.name = name;
this.age = age;
}
showInfo = () => {
console.log(`name:${this.name}, age: ${this.age}`)
}
}

如果此时我们想为这个类添加一个属性school用于标明学校,,在不修改“类”的情况下,我们可以使用装饰器这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function addSchool(target) {
target.prototype.school = 'SDU';
}
@addId
class Student {
// ...
}

/**
@decorator
class A{}
等价于
A = decorator(A);
*/

let limoer = new Student('limoer', 21);
console.log(limoer.school); // > SDU

addSchool()给Student“类”的原型对象上添加了一个属性,现在所有实例都可以取到school这个属性。

更深入一步,上面看到用于装饰的函数只接收一个目标“类”作为参数,如果我们有多个参数的话,可以写成高阶函数的形式(即返回一个函数)。同样是上面的例子,现在学校由参数指定,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
function addSchool(school_name) {
return function(target) {
target.prototype.school = school_name;
}
}

@addSchool('CQMU')
class Student {
// ...
}

let lin = new Student('lin', 20);
console.log(lin.school); // > CQMU

装饰器不但可以装饰“类”,也可以对方法(…属性)进行修饰,使用的方式类似于对“类”的修饰,不过用于修饰的函数接收三个参数,target将要被修饰的对象, name被修饰的属性名, descriptor被修饰的属性的描述对象(ES5中详细介绍过)。 写一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

function showCount(target, name, descriptor) {
let prev = descriptor.value;
descriptor.value = function() {
console.log('count:' + StudentList.list.length);
prev.call(null, arguments);
}
return descriptor;
}

class StudentList {

static list = ['limoer', 'lin'];

@showCount
showNames () {
console.log(StudentList.list.join(' '));
}
}

let list = new StudentList();
list.showNames(); // count:2 \n limoer lin

上面的代码给StudentList类的showNames方法添加了打印数量的功能,并且是在不改变原有“类”结构的情况下。

说明,在现有的浏览器环境和Node都不能运行上面的代码(暂不支持装饰器),如果想运行的话,可以借用babel 并且使用相关插件(babel-plugin-transform-decorators-legacy)的前提下进行compile,之后就可以进行了。推荐开发过程中webpack和babel结合使用,效果更佳!

好了,关于Decorator简单介绍到此到一段落,更多的相关知识请自行发掘和学习。接下来,是时候了解并使用Mobx了!

Mobx?想说爱你不容易!

在文章最开头谈到我在最近的学习开发中使用了Mobx作为状态管理工具,最主要的原因是其相比Redux,学习和快速上手成本的确消了很多,并且它足够简单。但是在后来的开发过程中,虽然其可以没有redux中action,也不存在reducer,更是告别了单一而庞大的store,我们可以定义多个state用于保存状态,让每个状态或者是每个类属性添加注解,让其编程可观察的状态,而为了能够自动的更新值,我们可以通过使用computed这个装饰器或者autorun函数来完成。可是,在使用过程中,定义多少个状态,每个状态的结构又是如何,等等等等,都困扰着我,远没有使用redux来得清晰和直观。这也许是因为我对mobx目前刚好达到基本使用的程度,并没有深入的了解。基于此,接下来,我只谈谈Mobx入门,至于该如何优雅的使用,请自行摸索。

几个概念

  1. 可观察的状态

这也许是Mobx最基础也是最重要的概念了。我们可以使用Mobx提供的observable装饰器,让基本的数据结构(数组、对象、原始值等)变成可观察的。使用的方式如下:

1
2
3
4
5
6
7
8
9
10
let TimeState = observable({
currentTime: Date.now()
})
TimeState.set("currentTime", new Date().toString());

class AppState {
@observable list = ['limoer', 'lin'];
}
let state = new AppState();
console.log(state.list.length); // > 2

好了,最简单的例子就是这样,我们使用ES5和ES6 decorator的方式分别创建了两个state,第一个state我们适应装饰器让一个对象(Map)变得可观察,而第二个我们则是对一个“类”属性(为一个数组)进行了修饰,让其变成可观察的。

这里值得注意的是,如果一个数据结构变得可观察,那么其类型也会发生改变,例如我们让一个数据变得可观察,此时其已经变成了一个 Observable Array, 这是一种Mobx定义的数据结构,拥有其独特的API,此时使用Array.isArray(state.list)讲返回false,因为Observable Array 并不是一种数组类型。

好了,当看到这里,你是否有这样一个疑问:让一个数据结构变得可观察,其作用到底在哪里呢?其实很简单,我们都知道Mobx是React的小伙伴,其目的是在于替换React本身的state,我们都知道对于React而言,如果一旦state发生改变,就将导致页面更新并且重新渲染,基于此,让数据结构变得可观察,其目的是在于当被观察的数据发生改变,React也能做出相应的更新和重绘操作等,并且,这样的重绘是经过Mobx优化的,只进行必要的重绘来增加性能!

  1. 可计算值

可计算值是通过现有状态和其它可计算值派生出来的值。这很好理解,我们在使用React的时候,往往要通过state衍生出很多的值,例如如果state的一部分是一个数组,那么我们通过衍生得到的数组长度就是一个计算值,并且在Mobx中,一旦可观察的state或者其他computed value 发生改变,可计算值就会重新计算。其实,在实际的React项目中,我们在很多地方都使用到了计算值。

还是上面AppState的例子,现在我们给其增加一个计算值,

1
2
3
4
5
6
7
8
9
10
class AppState {
@observable list = ['limoer', 'lin'];
@computed get count() {
return this.list.length;
}
}
let state = new AppState();
console.log(state.count); // > 2
state.list.push('lindo');
console.log(state.count); // > 3

count是一个计算值,一旦list发生变化,其就会自动重新计算,可以保证,count的值每次都是最新的,并且都是等于list数组的长度。

  1. autorun

其作用和函数名一样好理解,其会自动执行;autorun其本身是一个响应式函数,其使用到的依赖关系state/computed value等一旦发生改变,其就会自动执行一次,效果和计算值类似,但是计算值和autorun的应用场景是不一样的,computed value通常会产生一个新值而autorun达到某种目的而不产生新值,例如生成日志,处理网络请求等。
还是上面的例子,我们继续扩展:

1
2
3
4
class AppState {
// ...省略前面的代码
let logcount = autorun(() => {console.log('count: ' + this.count)});
}

这里我们在autorun中使用了computed value, 一旦发生count改变,就会自动打印出新的count值;当然,初始化state实例对象的时候,就会先执行一次。

  1. action

动作是用来修改状态的。并且只应该对修改状态的函数使用action,要使用动作很简单,使用@action修饰一个函数或者使用action(fn),把要修饰的函数作为参数即可。继续上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13

class AppState {
// 省略上面的代码
@action.bound
addOne(name) {
this.list.push(name);
}
// 或者
@action
addOne = (name) => {
this.list.push(name);
}
}

上面我们定义了一个函数,用于向列表中添加一个姓名。请注意,ES6 class的写法无法自动绑定到对象,所以使用`@action.bound` 或者是使用ES6中引入的箭头函数(推荐)。

与React使用

  1. observer
    observer是由mobx-react包(需独立安装)提供的用于让组件变成响应式组件的decorator。官方文档中写到:它用 mobx.autorun 包装了组件的 render 函数以确保任何组件渲染中使用的数据变化时都可以强制刷新组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React, { Component } from 'react';
import { render } from 'react-dom';
// 其余依赖省略
@observer
class NameList extends Component {
addUser = (e) => {
e.preventDefault();
if(this.uname.value){
this.props.appstate.addOne(this.uname.value);
}else{
console.log('must input user name!');
}
}
render() {
return <div>
<ul>
{
this.props.appstate.list.map((index, name) => {
return <li key={index + 10}>{name}</li>
})
}
</ul>
<div>
<p>当前用户人数:{this.props.appstate.count}</p>
<label for="uname">姓名</label>
<input type="text" name="uname" ref={(ref) => this.uname = ref}/>
<button onClick={this.addUser}>+</button>
</div>
</div>
}
}

render(<NameList appstate={appstate} />, document.getElementById('app'));

上面是一个响应式组件的例子,结合了上面定义的状态,我们可以查看所有的姓名、数量,并且可以通过点击按钮来改变state。其实observer对非响应式组件仍然有效,同样是上面的例子:

1
2
3
4
5
6
7
const List = observer(({appstate}) => {
return <ul>
appstate.list.map((index, name) => {
return <li key={index + 19}>{name}</li>
})
</ul>
})

好了,对于observer的介绍就告一段落,更多的Mobx和React连接的方式,以及Mobx提供的生命钩子函数等相关知识你可以查看官方文档来了解。

尾巴

自从放了暑假回了家,效率下降特别多,在学校的时候以为回家可以安心学习,到了家才知道一切都变了,该做的事情还没做,还有更多的知识要学习。所以,早早回学校也许是一个不错的选择!所以再过几天,就要启程回学校了,在最后一年里,期待所有的努力都没有白费,期待一个新(好)的开始!

分享到 评论

响应式布局的那些事

响应式设计在如今的web开发过程中已经是必不可少,它可以针对不同的设备环境对页面进行调整,并且可以在PC端和移动端达到很好效果的情况下,不用开发多套页面,可以提升开发速度,可维护性打打增强。

响应式布局

响应式布局的一种实现方式的原理是使用CSS3新引入的Media Query来调整元素在不同分辨率下的显示效果,并且通过JavaScript进行交互。总结起来,响应式布局有以下几个需要注意的点:

  1. 设置Viewport

我们知道,在移动设备中,页面被放置在虚拟的窗口中,这个窗口也称作视口(Viewport),对于未进行移动端适配或者是未进行响应式设计的页面,往往页面的宽高都会大于移动设备的宽高,所以为了能够在移动设备上进行页面交互,缩放是不可避免的,但是频繁的放大缩小带来的浏览体验肯定不会好。所以,在响应式设计的第一步,就是要禁止移动设备的缩放,这很容易实现,我们只需要在html页面中的head元素下添加一个meta标签用于规定禁止缩放就可以了:

1
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maxinum-scale=1.0" />
  1. 使用Media Query

媒介查询才是响应式布局的关键所在,我们使用Media Query 来实现在不同尺寸下使用不同的样式。Media Query的规则有很多,例如@media screen and (max-width: 980px){...}就表示了在980px下的屏幕下使用在此定义的各种样式,同样还有min-widthorientation(设备方向)等属性,我们需要按需进行设置。

  1. 使用JavaScript

如果能做到上面的两点,在一般情况下,响应式布局是可以实现的。但是如果在布局的过程中需要改变交互,那么JavaScript久必须派上用场了。例如一个菜单栏,在十分小的屏幕下需要折叠,那么就需要用到JavaScript。

Code

上面是我能够想到的响应式布局的一些要点,在实际学习过程中,我并没有在一些项目中使用相应式设计的方式(貌似很悲哀…)。在目前移动为先的时代,为移动端做更好的优化是不可避免的,无论是使用重新写一套移动端页面,还是使用响应式布局,或者使用其他的例如Flex Box来进行布局。作为一个工作在浏览器端的🐒,这都是我们必须具备的素质。

好了,写一个简单的小例子吧。如果你从未接触过响应式布局,那么希望接下来的code会帮助你更快地了解并应用它。

我们来写一个菜单栏,其HTML结构‍如下:

1
2
3
4
5
6
7
8
9
<div id="nav">
<ul id="nav-list">
<li><a href="#" id="home">Home</a></li>
<li><a href="#" id="topic">Topic</a></li>
<li><a href="#" id="today">Today</a></li>
<li><a href="#" id="about">About</a></li>
<li><a href="#" id="concat">Concat</a></li>
</ul>
</div>

CSS如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
* {
margin: 0;
padding: 0;
}
#nav {
position: relative;
}
#nav-list ul li {
list-style: none;
box-sizing: border-box;
width: 20%;
}

#nav-list ul li a {
display: block;
text-align: center;
text-decoration: none;
color: #FFF;
line-height: 4em;
font-size: 1.4em;
}

#nav-list ul li:nth-child(1) a {
background-color: #bcbcbc;
}
#nav-list ul li:nth-child(2) a {
...
/*添加背景色*/
}
#nav-list ul li:nth-child(1) a:hover {
background-color: rgba(188, 188, 188, .8);
/*添加鼠标移上去的样式*/
}
#nav-list ul li:nth-child(1) a::before {
content: ''
/*使用伪类来添加图标字体等*/
}

现在,我们来为屏幕宽度小于768px写一个样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@media screen and (max-width: 768px) {
ul li a::before {
font-size: 20px;
line-height: 60px;
}

ul li a {
font-size: 0;
height: 60px;
}
/*
上面的的样式指明了再768px宽度及以下,我们设置a标签的font-size为0,不显示字体。
设置伪元素所在的图标字体的行高等于a标签的宽度,使其垂直居中。
通过上面的简单设置,我们在小于768px跨度的屏幕下,对于该菜单就只能看到图标了。
*/
}

接着,我们可以为更窄的屏幕设置折叠菜单,我们通过css来绘制折菜单,使用JS来显示和隐藏。具体的实现这里就不贴出来了。

ok,到此为止,我们已经写好了一个响应式菜单栏了(虽然…)。

分享到 评论