目录

[TOC]

1. Webpack能够帮Web前端工程化做哪些事

研发工程化是现代开发的必备基础,大部分流程进入工程化都可以极大地减少人为错误和提升效率。从研发流程上说,开发、代码拉取、检查、构建和部署发布都可以做工程化,这样的工程化工作往往需要一个系统来支撑,如亚马逊的apollo,腾讯的蓝盾。从编写代码上说,代码检查、补全、固定内容替换、压缩、混淆等都是工程化的一部分。webpack就是做代码工程化的工具之一。

Webpack的精细配置往往比较复杂,webpack4中已经简化了很多配置,用约定代替了部分配置。但在一些应用的极致优化中,还是需要对工程化的很多项做单独配置。下面以一个简单的移动端项目webpack构建后预发布的HTML页面为例,逐一讲解webpack在工程化中都做了哪些工作(当然,webpack能做比下例更多的工作)。

<!DOCTYPE html>
<html lang=zh-CN>
  <head>
    <meta charset=utf-8>
    <meta content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover" name=viewport>
    <meta content=yes name=apple-mobile-web-app-capable>
    <meta content=black name=apple-mobile-web-app-status-bar-style>
    <link rel=icon href=https://cdn.cn/m/favicon.ico>
    <meta name=Copyright content=Tencent>
    <title>App Built By Webpack</title>
    <link href=https://cdn.cn/m/assets/css/about.a2389654.css rel=prefetch>
    <link href=https://cdn.cn/m/assets/css/home.83e6cc06.css rel=prefetch>
    <link href=https://cdn.cn/m/assets/js/about.f9f934c9.js rel=prefetch>
    <link href=https://cdn.cn/m/assets/js/home.05b64c92.js rel=prefetch>
    <link href=https://cdn.cn/m/assets/css/app.2ce89ca8.css rel=preload as=style>
    <link href=https://cdn.cn/m/assets/js/app.6f840142.js rel=preload as=script>
    <link href=https://cdn.cn/m/assets/js/chunk-vendors.c8ba45a9.js rel=preload as=script>
    <link href=https://cdn.cn/m/assets/css/app.2ce89ca8.css rel=stylesheet>
  </head>
  <body>
    <noscript>
      <strong>很抱歉,您当前的浏览器不支持Javascript,请先启用它来继续体验我们的产品。</strong>
    </noscript>
    <div id=app></div>
    <script src=https://cdn.cn/m/assets/js/chunk-vendors.c8ba45a9.js></script>
    <script src=https://cdn.cn/m/assets/js/app.6f840142.js></script>
  </body>
</html>

以下只是简单说明webpack的工作,具体内容将在后面分别讲解。

这个HTML其实是只有一行的文件,为了让读者清晰地看到它的内容,我格式化了HTML标签,但在发布版本中它是一个没有缩进只有一行的size很小的HTML文件。

首先这个HTML是怎么形成的呢,它是通过webpack的一个插件“html-webpack-plugin”构建出来的。

你可能还会注意到里面有很多资源请求,请求的地址都是CDN服务器域名的地址,开发的时候肯定不会写这样的地址(难以更新文件测试代码),但是发布的版本就可以替换为请求CDN服务器的资源地址,这也是webpack帮我们做的,区别了不同环境,进行各自的工程化配置。

还可以看到,有些资源还带了版本号(为了服务器做缓存更新),这些版本号不会随着每次构建而改变,只有当资源内容变化了才会更新版本号。CSS文件也可以独立于JS文件,用contenthash做版本号,这样单页面的js变化也不会影响到css的缓存。

有些资源有preload属性,有些资源有prefetch属性,这是webpack帮我们区分了哪些资源需要提前加载,哪些要提前获取以让用户获得更好的体验。

一些第三方库的代码都被打包到了chunk-vendors这个文件中,它们被单独提取了出来。

如果你打开每个资源文件,还会发现这些文件都是经过压缩混淆的。其中JS代码还都是ES5语法的,不是开发时写的ES6语法代码。

当然,一些开发者还喜欢用typescript开发代码,但是浏览器并不能识别TS代码。webpack还可以将typescript的编译工作放进工程化的流程中。它不仅可以通过eslint对代码格式进行检查,还可以通过typescript的loader对代码进行编译。

这只是一个基本的移动端单页面的demo构建,如果用命令行让计算机分别做以上任务要写一个很长很长的脚本。这就是webpack诞生的原因,一个指令完成所有Web前端工程化的任务npm run build

后面将会对这里提到的每一个webpack实现方式做详解,但在这之前,我们先来看一下,webpack的核心概念,模块化

2. Webpack的模块化处理

2.1 Webpack术语概念

在说明webpack处理模块流程之前,先说明几个webpack的概念。

Entry

每一个Webpack项目都有一个或多个entry,这取决于Web应用是单页面还是多页面。顾名思义,entry就是webpack处理的入口文件,后续的一系列处理都从这个entry开始。

JS Module

JS模块规范比较多,如CommonJS,AMD,UMD,现在因为ES2015 Module的出现而逐渐开始统一,基本现在模块都是ES2015 Module和CommonJS的。现代开发者写的代码基本都是模块化结构的,从入口文件开始,代码调用了多个模块最终构建了一个完整的Web应用。

Chunk

Chunk是webpack打包成的代码块,主要用于处理构建bundle。从入口文件开始,如果不做任何其他处理,那么webpack就会将代码打包进一个chunk。如果是单页面应用,动态加载路由资源,那么每一个路由资源会被单独打包成一个chunk。如果项目做了代码分片,那么webpack还会从各个chunk中提取相同的部分形成新的chunk。Chunks与我们的应用没有直接关系,因为应用都是用下面的bundle来进行资源加载的。

Bundle

Bundle是独立可运行的捆绑包,通常一个bundle对应一个chunk或子chunks的集合。Bundle是对一个应用来说的概念,浏览器通过加载bundle来把webpack处理好的资源全部加载到App中去。

总结

一个chunk包含了多个JS modules,一个bundle又包含了一个chunk或多个chunk的一部分集合。bundle可以直接从chunk转化来,也可以通过动态加载import或common chunk split形成。

动态加载的方式为:

import(/* webpackChunkName: "lodash" */ 'lodash')

这样,webpack就会把lodash单独分离成一个chunk,并把这个chunk做成一个bundle让浏览器加载。

Common chunk split方式是从不同入口chunks中提取相同的模块做成新的chunk或合并到入口chunk中。如果做成新的chunk就会形成新的bundle,单独让浏览器加载。

下图是两个入口webpack打包处理示意图:

可以看到Index Chunk的部分内容分出去形成了三个bundle。

那么webpack到底是怎么处理各个modules的,bundle又具体是怎么构建的呢?下面就详细介绍一下webpack的模块处理流程。

2.2 Webpack模块处理流程

Webpack本质上就是一个javascript的模块打包器。通过开发者设定的入口文件,webpack建立了一个模块依赖图。这里的模块指的并非是CommonJS,也不是ES6 Module,而是说webpack自己的模块。Webpack为什么要拥有一个自己定义的模块实现方式呢?

在一个Web应用代码中,可能开发者引用了ES6 Module模块的JS文件,开发者还可能用了一个第三方的库,这个库是AMD模块化的,如requirejs。除了这些,开发者还有可能要用JS引入一个图片。这么多不同类型的模块如何在webpack下统一管理呢,这就是webpack自己实现其模块结构的原因。

它能够将多种不同的模块格式化为自己的模块结构并建立模块依赖图,它支持如下的模块:

  • ES2015 Module (import,export)
  • CommonJS (require(),module.exports)
  • AMD (define,require)
  • css中url引入的模块和img中src的模块
  • ……

Webpack的模块结构与AMD、UMD、CommonJS和ES6 Module不是一个层面的关系,它将任意模块包装成它自己方便处理的数据结构。可以理解为,webpack的每一个模块里面包裹着任意上述js模块中的一种。至于代码执行层面的问题,那是babel的工作,babel可以将上述任意模块格式化为开发者指定的一种模块,大多数情况下都会为了兼容性转化为ES5的语法。所以不要将webpack的模块结构与js的模块结构弄混淆,上述的模块代码能运行是因为babel的处理,将不同模块代码转化为统一的语言,如ES5。而webpack的模块是为了方便让它统一管理打包的。我们看一个简单的例子。

/**
	File: index.js
**/
var minus = require('./minus');
var add = require('./add');
var diff = minus(5, 3);
var sum = add(1, 2);
console.log('Diff = ' + diff);
console.log('Sum = ' + sum);

/**
  File: add.js
**/
module.exports = function add(a, b) {
  return a + b;
}

/**
  File: minus.js
**/
module.exports = function diff(a, b) {
  return a - b;
}

上面的代码都是CommonJS模块化的,在不做任何分块处理情况下,用webpack处理后(分块处理会将运行时部分代码分离出来):

(function (modules) {
  // 模块缓存
  var installedModules = {};
  // webpack封装模块的方法
  function __webpack_require__(moduleId) {
    // 如果模块缓存中存在该模块,则直接返回
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 如果模块缓存中不存在,则新建一个并把它存在缓存中
    var module = installedModules[moduleId] = {
      // module Id
      i: moduleId,
      // 是否加载的标记
      l: false,
      // 模块exports
      exports: {}
    };
    // 执行模块
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 标记模块为已加载
    module.l = true;
    // 返回这个模块的exports
    return module.exports;
  }
  ...
  // modules的第一个元素就是入口模块。
  return __webpack_require__(0);
})
/************************************************************************/
([
    // index.js
    /* 0 */
    function (module, exports, __webpack_require__) {
        var minus = __webpack_require__(1);
        var add = __webpack_require__(2);
        var diff = minus(5, 3);
        var sum = add(1, 2);
				console.log('Diff = ' + diff);
				console.log('Sum = ' + sum);
    },
    // add.js
    /* 1 */
    function (module, exports, __webpack_require__) {
        var add = function (a, b) {
            return a + b;
        };
        module.exports = add;
    },
    // minus.js
    /* 2 */
    function (module, exports) {
        var minus = function (a, b) {
            return a - b;
        };
        module.exports = minus;
    }
]);

可以看到,最外层就是一个IIFE(立即调用函数表达式),参数modules就是这一个chunk涉及到的所有的模块,在下面调用处有定义。这样所有通过这个入口文件加载的模块都被加载进来了,如果有重复引入的地方,会直接从installedModules里返回exports。

如果是ES6的Module,要先经过babel的编译改成ES5的代码才能被webpack执行。但是整体的webpack模块打包方式是相同的。

最终,这些代码被打包整合到一个文件里-bundle,被浏览器加载。

这个bundle在浏览器中是怎么执行的呢?

  1. webpack打包后首先会形成自己的运行时代码,如__webpack_require__方法的定义等,为模块执行和加载做准备工作。浏览器要先加载这些运行时的代码。
  2. 通过IIFE,浏览器执行第一个webpack模块,也就是开发者定义的入口文件的代码。
  3. 浏览器执行入口文件时会遇到很多JS模块的加载,通过__webpack_require__方法会执行JS模块代码,JS模块又引用了别的模块,因此这是一个递归过程。期间不断注册新的installedModules并执行。
  4. 最后所有的模块执行完毕,代码执行流程一定会回到入口文件,执行到入口文件的最后一行,完成整个执行过程。

3. 非Javascript文件的处理-loader

上文描述的都是JS的处理情况,但是非JS的文件,webpack怎么处理呢?

比如在一个完整的项目中,开发者用了很多第三方的库和框架,它们的文件格式都不是js,内容也是千差万别,如Vue的单文件组件。那么webpack如何识别vue文件并进行后续打包处理呢?

这就需要loader来处理了,webpack的各种loader分别处理各种文件,如对vue而言的vue-loader。

Loader就是用于对源代码进行转换,然后输出给webpack它能识别的JS代码。

3.1 loader使用规则

Loaders可以内联调用,也可以在webpack.config.js里调用,下面的代码就是内联调用:

import Styles from 'style-loader!css-loader?modules!./styles.css';

这句代码就是指定用style-loader和css-loader来处理styles.css,将其转化为JS模块加载进来。

这种内联方式非常不方便,通常开发者会在config定义规则来匹配文件格式,进行加载,如:

module: {
  rules: [
    {
      test: /\.css$/,
      use: [
        { loader: 'style-loader' },
        {
          loader: 'css-loader',
          options: {
            modules: true
          }
        }
      ]
    }
  ]
}

这种方式与上述内联代码作用是一样的,但是它却匹配了所有css文件,对所有css文件进行这两个loader的处理。

use属性是一个数组,是因为loader支持链式调用,数组最后一个loader最先处理匹配到的文件,并将处理后的内容传递给下一个loader。数组的第一个loader最后被调用,要输出javascript源码给webpack。也就是说,loader处理顺序是从下往上的。

对于链式调用的loader,严格地说,第一个输出给webpack的loader可以返回两个值,第一个是处理后的javascript源码字符串或buffer,第二个是可选的sourcemap,一个javascript对象。但loader的输入输出没有强制要求,如果一个loader专门是用来做最后一步处理的loader,那么这个loader就必须遵循上述规则。如果一个loader是用来做中间处理的,那么它的输入输出只需要能承接上下loader就可以了,这个往往需要做好loader之间的约定。而第一个处理的loader则必须是接受原始文件源码。

经过loader处理过的代码都是模块化的。这是webpack loader的强制性要求。

test是一个正则表达式,用于匹配这个loader使用范围。

每一个loader还可以有自己的options,用来让开发者自己选择具体的代码处理方式。

还有一些其他的方法,比如排除指定文件路径等,关于具体的module.rules,可以看官网文档。

3.2 loader还能做的更多

有了loader之后,开发者就可以加载各种非javascript资源了,但是,loader可以帮助开发者做更多的东西。

  • 可以转译javascript语法,将开发者用ES6编程的代码输出为ES5通用兼容版本的代码。
  • 如果开发者在编写javascript库,它还可以帮开发者将css加到库中,这样发布后,其他人引用这个库时只需要以模块化的方式import进来即可,无需单独再加载css。
  • 可以对应用内所有静态资源增加版本号,以优化缓存,如,index.e3au67deu.css。
  • 可以对页面引用CDN资源的链接自动加CDN域名的链接替换,包括css中的url请求。
  • 可以对sass文件直接编译,输出css。
  • ……

下面简单介绍几个常用的loader,具体使用方法可以查看下方链接。

babel-loader

babel-loader是很多项目都会用到的loader,它会把开发者写的各种版本的JS代码编译成指定版本的JS代码以适应各大浏览器的兼容性。

eslint-loader

eslint是代码检查工具,当多人一起开发时,eslint就尤为重要,再多的口头约定也比不上工具的强制规范。

css-loader

css-loader用来解析css文件中@import和url()。

sass-loader

sass-loader用来读取并解析scss文件为css文件。

Loader有太多了,根据项目需求不同可以加载不同的loader,这里不做赘述。当然,开发者还可以自己定义loader来满足工程化需求。

4. 代码的tree-shaking优化

4.1 Tree-shaking作用是什么

Tree-shaking是Web前端代码优化的一种方法,它跟DCE(Dead Code Elimination)有些相似但又不一样,根据RollUp的作者Rich Harris的说法,在Web前端代码构建中,DCE是代码打包好后进行优化代码。而Tree-shaking是在打包时,处理各modules时就删除掉了dead codes。

Tree-shaking的基础就是ES6的module,静态的特性使得代码分析成为可能,这也是为什么commonjs做不了tree-shaking。

看如下代码:

import test from 'utils/a.js';

console.log(test);

utils/a.js中的代码为:

export default {
  a: 'flagAAAAA'
};

export const testB = {
  b: 'flagBBBBB'
};

testB这个模块其实是没有用到的,如果没有任何工程化处理,那么打包的文件中会有testB这个无用的代码存在的。如果用webpack4做工程化处理,那么在构建production版本代码过程中,Tree-shaking会自动进行,进而删掉testB。

再来用class做一次webpack4构建代码实验:

import Test from 'utils/a.js';

utils/a.js中的代码为:

export default class {
  a = 'flagAAAAA';
  getA() {
    return a;
  }
  redirect() {
    window.location.href = '';
  }
}

Test被引用进来但是没有被调用。

a.js的代码依然没有打包进去。

另一段关于模块引用的代码:

import Test from 'utils/a.js';

const t = new Test();
t[Math.random() > 0.5 ? 'getA' : 'redirect']();

utils/a.js中的代码为:

export default class {
  a = 'flagAAAAA';
  getA() {
    return a;
  }
  redirect() {
    window.location.href = '';
  }
}

因为class中的redirect方法引入进来但是并没有确定一定使用,所以打包后的没有变化,redirect和getA方法依然存在。

4.2 无法Tree-shaking的情况

import { Add } from 'utils/a.js';
Add(1, 1);

utils/a.js代码为:

import { isArray } from 'lodash-es';

export function array(array) {
  console.log('isArray');
  return isArray(array);
}

export function Add(a, b) {
  console.log('Add');
  return a + b
}

因为array没有用到,所以按理说lodash-es不应该打包进来,但是tree-shaking还是打包了它。

可以使用插件webpack-deep-scope-analysis-plugin来解决这个问题。

4.3 代码副作用

代码副作用简单点来说,就是代码不确定性地在某处改变了某些东西,它会导致在一些情况会使tree-shaking无法准确的打包内容,只能保留全部代码。

如下面代码:

import { a } from './index.js';
console.log(a);

index.js有如下代码:

export { a } from "./a.js";
export { b } from "./b.js";

a.js有如下代码:

export const a = 'a';

b.js有如下代码:

(function() {
  console.log('fun');
  window.name = 'name';
})();
export const b = 'b';

虽然第一段代码只引用了a,但是打包代码中同样存在了b。这是因为b的代码中有IIFE(立即执行函数),虽然IIFE代码内容可能跟整个逻辑都无关,但是它不可以被删掉。这个就是函数副作用,因为没办法确定执行的内容会不会对其他代码产生影响,所以只能保留。

当然在Webpack4中提供了打包时可以强制忽略某一模块的方法。

4.4 Tree-shaking在Webpack4中使用方法

Webpack4默认在mode为production时开启了tree-shaking,省去了很多配置。

其中有一个配置项:sideEffects。

配置如:

{
  name: 'your-project',
  sideEffects: [
    './src/some-side-effectful-file.js',
    '*.css'
  ]
}

开发者可以主动标记哪些文件为没有副作用的代码,这样webpack在处理时就可以直接按照引用关系来进行tree-shaking了。

具体使用方法见:sideEffects

5. Webpack工程化实践与优化

上面几个部分说明了一些原理和实现方法,用这些就可以对一个简单的项目进行webpack工程化处理了。

还是用第一章的Vue项目进行实践,讲解如何生成那样的HTML,下面会逐行注释说明,webpack4的配置如下:

// 一些必要的webpack插件
// HTML页面生成插件
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 对JS文件进行混淆压缩的插件
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
// 最小化css并从js中提取单独成文件的css处理插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// css压缩处理插件
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
// vue单文件组件loader插件
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const webpack = require('webpack');
const path = require('path');
// 一些项目自定义的环境变量
const localEnv = require('../env.js');

const PATHS = {
  src: path.join(__dirname, '../src'),
  dist: path.join(__dirname, '../dist')
};
// webpack4的配置
module.exports = env => {
  return {
    context: __dirname,
    // 生产模式
    mode: 'production',
    // 构建入口文件路径
    entry: {
      index: `${PATHS.src}/pages/index.js`
    },
    // 输出路径、引用路径和文件名的设置
    // chunkhash可以让它构建时根据内容来生成带有版本号的文件
    output: {
      path: path.join(PATHS.dist, 'static'),
      filename: '[name].[chunkhash].js',
      // 构建后html中引用路径,可换成CDN地址
      publicPath: localEnv.cdn
    },
    // optimization是构建优化部分
    optimization: {
      minimizer: [
        // 对JS进行uglify
        new UglifyJsPlugin({
          cache: true,
          parallel: true,
          extractComments: true,
          uglifyOptions: {
            ecma: 7,
            compress: {
              warnings: false,
              drop_console: true
            },
            output: {
              comments: /@license/i
            }
          }
        }),
        new OptimizeCSSAssetsPlugin({})
      ],
      // 值 "single" 会创建一个在所有生成 chunk 之间共享的webpack运行时文件。
	  runtimeChunk: 'single',
	  // 分块优化,这里跟默认值差不多,具体分块优化见下面说明。
      splitChunks: {
        cacheGroups: {
          vendors: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            enforce: true,
            chunks: 'all'
          }
        }
      }
    },
    resolve: {
	  // 对于以下扩展名的文件中可进行别名命名,
      extensions: ['.js', '.vue'],
      alias: {
        vue: path.resolve(__dirname, '../node_modules/vue/dist/vue.min.js'),
        css: path.resolve(__dirname, '../src/resources/css'),
        components: path.resolve(__dirname, '../src/components'),
        ...
      }
    },
	// 各种loader
    module: {
      rules: [
        {
          test: /\.vue$/,
          use: ['vue-loader']
        },
		// eslint编译
        {
          enforce: 'pre',
          test: /\.js$/,
          exclude: [
            path.resolve(__dirname, '../node_modules/'),
            path.resolve(__dirname, '../src/plugins/'),
            path.resolve(__dirname, '../src/utils/'),
            path.resolve(__dirname, '../src/sdk/')
          ],
          use: [
            {
              loader: 'eslint-loader'
            }
          ]
        },
		// babel编译
        {
          test: /\.js$/,
          exclude: [
            path.resolve(__dirname, '../node_modules/'),
            path.resolve(__dirname, '../src/plugins/'),
            path.resolve(__dirname, '../src/sdk/')
          ],
          use: [
            {
              loader: 'babel-loader'
            }
          ]
        },
        {
          test: /\.css$/,
          use: [
            MiniCssExtractPlugin.loader,
            {
              loader: 'css-loader'
            },
            {
              loader: 'postcss-loader'
            }
          ]
        },
        {
          test: /\.scss$/,
          use: [
            MiniCssExtractPlugin.loader,
			// css处理
            {
              loader: 'css-loader'
            },
			// css预处理,如自动加浏览器兼容autoprefixer
            {
              loader: 'postcss-loader'
            },
			// sass编译
            {
              loader: 'sass-loader'
            }
          ]
        },
		// 对于小于1KB的以下格式的文件直接进行base64的处理并进行hash版本化。
        {
          test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
          use: [
            {
              loader: 'url-loader',
              options: {
                limit: 1000,
                name: 'images/[name].[hash:7].[ext]'
              }
            }
          ]
        },
        {
          test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
          use: [
            {
              loader: 'url-loader',
              options: {
                limit: 1000,
                name: 'fonts/[name].[hash:7].[ext]'
              }
            }
          ]
        },
        {
          test: /\.(ogg|mp3|wav|mpe)(\?.*)?$/,
          loader: 'url-loader',
          options: {
            limit: 10000,
            name: 'media/[name].[hash:7].[ext]'
          }
        }
      ]
    },
    plugins: [
	  // 生成最终HTML
      new HtmlWebpackPlugin({
        template: `${PATHS.src}/pages/index.html`,
        inject: true,
        chunks: ['runtime', 'vendors', 'index'],
        filename: path.join(PATHS.dist, 'index.html')
      }),
	  // 压缩提取css
      new MiniCssExtractPlugin({
        filename: 'css/[name].[chunkhash].css'
      }),
	  // 创建编译时可以配置的全局常量
      new webpack.DefinePlugin({
        PRODUCTION: JSON.stringify(true),
        VERSION: JSON.stringify('1.0.0'),
        DEBUG: false,
        SERVER_TYPE: JSON.stringify(env.SERVERTYPE)
      }),
	  // 自动加载定义变量
      new webpack.ProvidePlugin({
        Vue: 'vue'
      }),
      new VueLoaderPlugin()
    ]
  };
};

值得一提的是spiltChunks的优化,webpack4会自动提取出来一些chunks进行分块,根据下面这块代码:

cacheGroups: {
	vendors: {
		test: /[\\/]node_modules[\\/]/,
		name: 'vendors',
		enforce: true,
		chunks: 'all'
	}
}

Webpack会把node_modules下的代码中引用到的代码提取出来,以“vendor”命名他们,然后形成bundle让html加载。除了这个代码块优化外,其实最重要的优化是自定义的优化,根据自身业务不同,往往优化也会很不一样。

那么有没有一种完美通用的优化手段来规避webpack复杂的配置呢?个人觉得答案是没有的,其实webpack默认的配置已经可以满足一些中小项目的需求,但是要精细化优化,给用户较好体验的话,那么自定义优化是肯定要做的,统一的优化方法用webpack默认的就差不多了。通常对于工程师来说,使用一些已有的项目框架开始一个新项目是不错的选择,比如vue-cli,nuxt等,里面帮用户配置了绝大部分webpack选项,用户只需要按照约定做精细化配置和优化即可。

个人觉得比较合适的优化策略是,对于经常访问到的,同时页面发布周期较短的代码,应该把这部分代码分割成多个小文件,虽然会增加浏览器的并发请求数(HTTP2.0的多路复用可以较好的解决这个问题),但是可以极大的优化缓存命中。因为我们给资源加版本号,做代码分割优化就是为了让用户体验更好,访问更快。通过高命中缓存,用户可以拥有打开本地页面的体验。因为网站需要经常发布更新,所有被改动的带有版本号的文件会随着发布而失效,如果这个失效的文件带有太多公共代码,那么用户访问很多页面时,只要涉及到这个文件,就会重新发送请求。所以,对于这种高频访问的文件,应该尽量精细化。

其次,对于一些不是经常访问都会用到的低命中文件,应该尽可能采用动态加载的方式,减少浏览器请求负担。这种大文件如果不是有特殊需求,是不应该加到每次都需要访问的文件块中的,如路由分割的页面代码。因为用户一次浏览访问大概率不会访问全部页面,所以这种资源做preload更好。

说到preload,上面配置里并没有js资源的preload或prefetch,这些其实是在动态加载时设置的:

import(/* webpackPrefetch: true */ 'home');

其实优化是一个策略性的问题,从30分到80分的优化可能难度没那么大,但是80分到90分的优化可能要几倍于前者的精力与时间才能做到,有时可能因为业务原因还无法到达90分甚至更高的优化水平。当你优化了某一部分时,必然会损失某一部分,综合取舍,达到一个平衡才是个人认为较好的优化策略。

举例来说,对于一个详情页,如何优化才能给用户更好的体验(打开的更快,图片或视频更早出现在用户可视界面中等等)。方法有很多,比如在列表页时,当浏览器空闲时就提前加载详情页数据,但是要考虑用户命中问题,这需要取舍。也可以在客户端做列表页的缓存,每次进到列表页,如果参数等信息不变,那么直接返回缓存的页面给用户。还要考虑产品形态,是否主页或列表页有主推内容,这些主推内容是不是可以做资源预加载。

所以优化问题是一个长期的问题,只要产品有迭代,优化必然会一直伴随下去。当然,大部分的产品可能根本不需要那么高精度的优化,过度优化可能会让代码难以维护。找到一个最适合开发者所做产品的优化策略才是最好的优化。


实现效果

初始页面 一进入页面就直接显示动画场景,没有资源加载过渡页。随着用户长按按钮,进入正式场景(以下每个不同用户交互动画简称场景)。

场景一:溏心蛋会随着用户手势右移而按照固定轨迹移动,随后变成月亮。

场景二:小女孩的“烦恼”会随着用户左右滑动而逐渐消失,同时小女孩还会有个打滚动画。

场景三:一棵树会随着用户缩小的手势而变小,到一定程度后会变成西兰花。

场景四:风筝会随着用户放大手势而变大,到一定程度后变成摇尾巴的狗狗。

场景五: 猫头鹰叼着的信件会随着用户向下滑动的手势而下落,随后信件展开显示具体内容。

场景六: 小女孩追的蝴蝶是可以点击的,点击后小女孩和蝴蝶都会进行移动。

单屏动画适配

单屏H5动画的一个难点就在于移动端机型的适配,如果时间条件允许而且还有设计师来做图的话,用两套坐标和两套图来适配3dpr和2dpr的屏幕是最好的。

但是对大部分情况来说,往往都是一套图来适配不同的屏幕。

这种情况下,”基准”就很重要了。

“基准”是对于整个单屏布局的起点和方向,这往往是由业务需求本身决定的。如果”基准”在顶部,那么所有的主内容都要以上边为起点,向下排列,当遇到长屏幕时(如iPhone X),iPhone多出来的部分就只显示背景部分,不会有可交互部分。

如果业务需求是垂直居中的话,那么全部的元素都要以中间为”基准”向两边排列,长屏幕时内容等比向两边扩展。

同理,如果以底部为”基准”的话,那么在长屏幕时就要在上边流出非交互区域只做显示用,用单屏背景来补充长屏幕的空缺。

总结一下就是,用一个较长的背景图做背景,然后选择基底来让背景以不同方式展示。那么背景图要有多长呢,这就取决于你要适配的最长设备的屏幕了。

背景图尺寸

以iPhone X为例,iPhone X的逻辑分辨率为375 * 812。而我们与设计师沟通的设计稿通常都是750 * 1334像素的,换算一下也就是375 * 667的。所以当我们按照设计稿做出来一个单屏页面时,在iPhone X上显示就会有145像素(812 - 667)的空缺,如下图所示的extra space。

为了填补这一空缺,就需要让设计师填充一些元素在背景图上,但是怎么填充就是上面提到的基准决定的了。如果选择垂直居中,那么就需要在上下同等填充内容。这样,即使在小屏幕的设备上,屏幕中依然能够显示中间重要的信息部分。

那么怎么决定用什么样的基准呢?

基准场景

比如下图的遮罩提示,从设计的角度来说,为了符合人类默认行为,肯定会把这种提示放在离屏幕下面一定的距离,让用户用拇指直接就能触到。不管用户的移动设备多长,设计师都希望用户伸出手指就可以碰到,所以这个场景下,这个提示就是以下边为基准的。所以在适配的时候就要全部以下面为基准定位坐标。

再比如下图的蓝天和草地的场景,设计师希望天地交界线就在屏幕的中间,那么就需要全部元素都垂直居中,这样不管设备屏幕大小,动画的元素都是基于这条”交界线”的。

其实最常见的还是以顶边为基准的场景,比如关闭、后退这些按钮,通常都是要放到屏幕顶部的,如下图所示。

canvas适配

有了以上的基础,就可以开始做正式的适配了。

整体的适配思路是这样:

  1. 最外层的Pixi应用大小就为屏幕大小。
    // 初始化pixi应用
    this.app = new PIXI.Application({
     width: WINDOW_WIDTH,
     height: WINDOW_HEIGHT,
     resolution: window.devicePixelRatio,
     antialias: true,
     backgroundColor: 0xffffff
    });
    

    WINDOW_WIDTH个WINDOW_HEIGHT为屏幕的宽高,这里定义为常量,用window.innerWidth和window.innerHeight获取。

  2. 应用内部搭建自适应动画舞台stage。

舞台大小就是设计稿大小,然后设置其scale,让它的大小能适应屏幕的宽高。

this.rootStage = new PIXI.Container();
this.rootStage.width = DESIGN_WIDTH;
this.rootStage.height = DESIGN_HEIGHT;
this.rootStage.scale.set(WINDOW_WIDTH / DESIGN_WIDTH);
this.app.stage.addChild(this.rootStage);

这样做的一个好处就是,对于舞台上的每一个元素的坐标和尺寸都是可以计算好的固定值(基于设计稿)。然后通过舞台的scale来适配。

但此时出现了上边说的长屏幕适配问题,解决它的方法就是选择一个基准,然后用一个长图做背景。这样,即使在iphoneX上看仍然可以很好得显示。但是要注意,用户的交互区域也一定是跟着基准走的,不要把交互区域做到舞台外。

比如垂直居中的话,那么上边和下边多出来的地方就尽量不要放置可交互的舞台元素了,不过仍然可以放置显示元素。

逐帧动画

逐帧动画是利用tweenjs和HTML5的requestAnimationFrame方法实现的。

animate();

function animate() {
    requestAnimationFrame(animate);
    // [...]
    TWEEN.update();
    // [...]
}

tweenjs是一个顺滑改变对象某些属性的库,利用这点可以实现很多动画效果,如淡入淡出,平移,抖动,旋转和大小变换等动画效果。

利用这些动画效果就可以拼装出来整个页面的动效,比如开头起始页。

一进入起始场景,文字开始淡入淡出,背景的云一直在循环漂浮。然后用户长按按钮积攒进度条,同时背景迅速滚动,当进度条满时,画面淡出,淡出过程中背景继续在滚动,然后正式进入第一个互动场景。

云朵循环漂浮效果其实是用了两套一样的云朵图实现的,两套图向上缓慢移动,当第一套图移出屏幕区域,同时位移大小是第一个图的高度时,让第一套图移动到第二套图的起始位置,这样就形成了一个循环。

下面就是封装好的元素平移方法。

_move(sprite, position, duration, easing, callback) {
	let oldPosition = {
		x: sprite.position.x,
		y: sprite.position.y
	};
	let tween = new TWEEN.Tween(oldPosition)
		.to(position, duration)
		.easing(easing || TWEEN.Easing.Cubic.InOut)
		.onUpdate(function() {
			sprite.position.set(oldPosition.x, oldPosition.y);
		})
		.onComplete(() => {
			tween.stop();
			if (callback) {
				callback();
			}
		});
	return tween;
}

这里对按钮长按的监听是pixi元素的pointerdown的监听,每次pointerdown都会触发进度条加载,当用户松手时,会触发pointerup,这时取消加载进度。

在进度条加载过程中,背景也是有个动画效果的,快速向上滚动,这个其实也是用tweenjs对背景的container做一个向上平移。最后在进度条满了时对整个container做一个淡出,同时第一个场景淡入,以上就是整个初始页的过程。

因为其他场景的动画基本都是用tweenjs封装的动画方法拼凑的,所以这里就不一一赘述了。

重点就是,用tweenjs改变pixi元素的alpha、position、scale的值来实现动画效果

性能优化

性能优化是动画实现中最为重要的一环,下面主要从“资源加载”,“资源销毁”两个方面来说明本项目中做的性能优化。

动态加载资源

在大多数的动画项目中,往往都是需要加一个loading页面,在这个页面中进行后续资源的加载,这些资源包括音频、图片、字体等静态资源文件,有时可能还会读取服务器的数据。这是业内比较通用的一种做法,但是同时也带来了一些不好的用户体验,比如用户等待的时间较长,用户无处安放的小手可能会直接关闭页面。或者加载的资源没有全部都利用到,用户操作了一段时间就关闭了页面,导致移动端宝贵的资源加载浪费。

所以,为了给用户带来更好的体验,我把所有资源按场景进行了分割,只在用户需要的场景提前加载资源。

例如在页面初始化的时候,其实页面是只加载了蓝天白云这几个图片资源的,文字效果淡出,等待用户长按按钮的时候,第一个场景的资源才开始加载。

当正式进入第一个场景时,页面会加载下一个场景的资源,这样以此类推,直到最后一个场景。

在资源处理上其实还可以做一些工程上的优化,比如小的图片转化为base64的,大的图片剪切成两个或多个图片拼接显示,这个主要是因为过大的图片可能会在加载时一点点显示,体验不好,而且还有可能canvas绘制不出来,用pixijs时,在绘制大的长图时会黑屏。

销毁资源

用pixi绘制动画时,如果当前canvas上有以后用不到的元素时,最好是直接从canvas上移除,不要只是隐藏。

场景切换时都是有个淡入淡出效果过渡的,资源销毁就可以放在这一步。在新进入一个场景时,新场景的元素开始绘制,同时加载下一个场景的资源并销毁上一个场景的资源。pixi提供了destroy方法,对最外层的场景容器进行销毁,同时也会销毁其子元素。

TWEEN.removeAll();
if (buttonContainer.sceneContainer) {
	buttonContainer.sceneContainer.destroy(true);
	buttonContainer.sceneContainer = null;
}
that._enterNextScene();

tween的removeAll方法是移除所有tweenjs动画,也就是所有需要tween的update的动画都会被移除。这与销毁pixi的元素资源还不太一样,销毁资源是让canvas不再考虑绘制这些内容,而tween的清除是不再监听对象的属性变化。

总结

部分Android小屏幕机型问题

其实现在的单屏适配方法在小屏幕上还是可能会出现内容被截取的情况,小屏幕是指换算到750屏幕宽度后,屏幕长度小于1334的。虽然能保证大部分移动设备可以正常显示,但是还是特殊的设备会显示不全。

所以可能还是要对一些设备做特别的媒体查询,来进行特别适配。

一套图对单屏页面的适配目前还没有简单的特别完美的解决方法,要么是做媒体查询,要么是根据基准做背景调整。

以上就是作者的一些动画实现思路的总结,欢迎留言讨论。


1.前言

最近做一个移动端的项目优化,发现vue的nextTick用到了mutation observer方法,后来继续研究下去,发现mutation observer的回调都是放进microtask队列的,而UI的render是在每一次事件循环后就执行的。

本文将着重对事件循环-event loop的概念做讲解,如果你对event loop已经有一定了解,不妨直接看第四部分。

2.Event Loop是什么

Javascript引擎是单线程的,这就意味着js脚本只能是单线程的,因此要有一种机制让js引擎知道哪些任务要先执行,哪些可以放到后面执行。这个机制就是事件循环-Event Loop。

Event Loop有三个部分,task队列,microtask队列和js执行栈。

2.1 Task

Task队列是用来存放浏览器要执行的任务的,根据HTML标准,task主要包括:

  • Event 分派事件
  • Parsing HTML解析器,解析字节并处理解析结果。
  • Callbacks 一些异步事件回调
  • Using a resource
  • Reacting to DOM manipulation 一些异步DOM操作的反馈,如在HTML文档中插入节点。

因此,如setTimeout方法的回调也是一个task。

2.2 Microtask

Microtask队列通常保存那些在当前js脚本执行完后需要立即执行的任务,当js执行栈为空时,如果当前执行的task是callback,即便task任务执行到一半也会按序执行microtask队列的任务,将其队列内所有的microtasks执行完。同时,microtask也会在每一个task 执行完后执行。

Microtask队列包括Promise回调,Mutation Observer回调。

2.3 JS Stack

JS执行栈初始化的时候为空,其余时间将会一直有任务运行。当执行栈运行完任务为空后,就会从microtask取任务继续执行,直到microtask队列为空,然后继续从task队列取出一个任务执行。

Read More...

前几天看了HTML5的由来,系统性地看了HTML5的各个特性,虽然有些已经很常用了,但还是记录下来留着以后翻看。这次就先记录local storage的使用方法。

1.什么是Local Storage

Local Storage就是一种浏览器客户端数据存储的方法,它能够永久地存储一些web应用数据,即使用户已经离开了该Web应用。

虽然听起来有些像cookie的作用,但其实它的设计初衷跟cookie是不同的,它是为了解决“存储数据”这个问题的,而cookie是为了进行通信校验的,具体不同体现为:

  • 客户端每次发送请求都会携带cookie,cookie中会有一些作身份校验的信息如token等。即便是请求同源资源如图片等静态资源,客户端也会发送cookie。
  • cookie容量很小,仅为4KB。而local storage容量为5MB。
  • 浏览器对cookie的操作支持比较有限,只能通过document.cookie方法来获取并进行字符串操作。而local storage则有较多的浏览器方法支持。

2.如何使用Local Storage

现代浏览器提供了四个基本方法来操作local storage:

名称 参数 说明
getItem key{String} 获取存储数据
setItem key{String},value{Object} 设置存储数据
removeItem key{String} 删除存储数据
clear   清空所有数据
Read More...

1.测试代码

在开发者编写了一个npm包后,最关键的其实就是测试部分,如果只是内部使用的话出了错误改动代码的成本还比较小,如果发布到npm上,几万人在使用的一个包如果出了问题,可能影响的产品就太多了。

在这里推荐使用自动化的测试工具travis,当然工具还有很多种,选择最适用的就好。

Travis是与github绑定的,如果你的代码管理在github上,那么每次github仓库的代码更新,travis都会自动运行一遍测试脚本,如果本次代码更新出了问题,那么travis的build就会失败,并告知失败原因。

测试脚本都是针对功能的,每一个功能至少要有一个测试脚本。

使用travis也很简单,在代码项目里维护一个文件“.travis.yml”。

文件的格式内容有很多,根据开发者需求不同,配置的内容也不同,具体可以参看.travis.yml

以一个Javascript的npm包为例,

language: node_js
node_js:
 - "lts/*"
script:
 - "if [[ -n $LINT_TEST ]]; then npm run lint; fi"
 - npm run test
Read More...

Artisan是PHP框架Laravel中提供的命令行接口(CLI),它提供了一些功能来辅助开发者开发Laravel应用。它可以通过命令行的方式生成App Key,还可以生成代码中的Controller。

下面具体列出所有可用的Artisan命令。

命令 说明
php artisan key:generate 生成App Key
php artisan make:controller 生成controller控制器
php artisan make:model 生成model模型
php artisan make:policy 生成授权策略
php artisan make:seeder 生成seed文件
php artisan migrate 迁移
php artisan migrate:rollback 迁移回滚
php artisan migrate:refresh 重置
php artisan db:seed 填充数据库
php artisan tinker 进入tinker环境
php artisan route:list 查看路由

还可以使用help来查看Artisan指令的使用说明:

php artisan help make


最近学习了React,一直在想React的优势在哪里。都说虚拟DOM提升了性能,可真的是这样吗?

先来简单说下React的几个概念:

1.Component

React通过component来构建整个页面,每个大的component可以由很多小的component组合构成。每个component有着自己的生命周期。

2.State

State是每个component内部的动态数据,也是由开发者维护管理的页面数据。凡是页面需要动态显示的地方都会有state来负责数据存储。

再来想一下,什么时候state变化了,component才会带着新的state重新渲染页面呢。

就是setState的时候。

Virtual DOM其实就是在这时发挥作用的,它是用javascript写的一个拥有DOM层级关系的一个数据结构。可以想成一个简化的DOM。当state变化时,component会重新触发render,那么Virtual DOM也会变化。 Virtual DOM会根据Diff算法来计算出哪里有变化,然后把新的Virtual DOM转换为真实的DOM,触发浏览器的渲染。

那这时就有一个问题,如果我只是更改一个<label>标签的值,那我直接DOM操作是不是更快一点呢?

答案是肯定的,因为只修改一个值,React还要经过render,Diff算法,DOM操作。这显然要比直接DOM修改一个节点的值要慢。

Read More...

1.Javascript 闭包

闭包通常指,有权访问另个函数作用域中的变量的函数. 形式为在一个外围函数内,通过返回一个函数这种形式来操作外围函数内的变量,即使当外围函数执行过后也不会释放其内部被闭包的变量。

示例:

function Counter() {
    var count = 0;
    function inc() {
        count++;
    }
    return {
        getCount : function() {
            return count;
        },
        increase : function() {
            inc();
        }
    }
}
var counter1 = new Counter();
var counter2 = new Counter();
console.log(counter1.getCount()); // 0
counter1.increase();
console.log(counter1.getCount()); // 1
console.log(counter2.getCount()); // 0

用闭包实现私有变量。

Read More...

1.HashTable与HashMap区别

  • 1.HashTable线程安全,HashMap线程不安全,HashMap要线程安全可以使用HashMap map = Collection.synchronized(new HashMap<Integer>()).
  • 2.HashTable不能有null的key,HashMap可以有.

2.TreeSet与TreeMap的底层实现

它们都是用红黑树实现的, 它们的Key都是有序的. 红黑树是一种二叉搜索树, 但是是平衡的. 它有如下性质:

  • 根节点是黑色的.
  • 红色节点的左右节点都是黑色的.
  • 叶节点都是null, 且叶节点都是黑色的.
  • 节点非黑即红.
  • 任意节点到其子叶节点路径中黑色节点的数量都是相同的.

因此,如果黑色高度为N,则根节点到叶节点的距离最小为N,最大为2 * (N - 1). 且最大路径情况为黑红交替,最小路径情况为纯黑..

3.Java重载Overload和重写Override区别

重载是同一个类中,同一函数名但参数不同,调用时根据参数来区分调用具体哪个方法,也就是静态多态性

重写是子类继承基类,函数名相同,参数也相同,但是把这个方法重写,也就是动态多态性.

4.Java动态绑定

在运行时,java虚拟机会根据实际创建的对象类型决定使用那个方法(重写).

Read More...

以下是博主自己总结的一些前端知识问题:

1. HTTP与HTTPS的区别

  • HTTP是超文本传输协议,信息是明文传输,HTTPS则是具有安全性的SSL加密传输协议.
  • HTTP和HTTPS使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443.
  • HTTPS在HTTP的基础上加入了SSL协议,SSL依靠证书来验证服务器的身份.

2. HTTP的POST与GET具体区别

显而易见,POST是发送数据,GET是请求数据,但是其实两者都可以发送数据与接收数据.

  • POST请求不会被缓存,GET会被缓存.
  • POST请求如果带数据是不会显示到URL上的,而GET会显示在URL上,GET的安全性较差,不可用于身份认证登陆.
  • POST对数据长度无限制,而GET有限制.

3. Javascript与传统面向对象语言有何不同

Javascript是一种原型语言,类的概念在javascript中是过时的.虽然它可以模拟类这种设计方式,包括实现继承,多态等,但是它本身的设计方式是组合方式.

4. Javascript函数方法call和apply区别

它们都是调用一个对象的一个方法,但参数不同,call是传指定参数,而apply是传参数数组.

Read More...