前端技术演进(六):前端项目与技术实践

这个来自之前做的培训,删减了一些业务相关的,参考了很多资料( 参考资料列表),谢谢前辈们,么么哒 ?

任何五花八门的技术,最终还是要在实践中落地。现代的软件开发,大部分讲求的不是高难度高精尖,而是效率和质量。

这里主要来说说现代前端技术在项目中的实践。

开发规范

开发规范是开发工程师之间交流的另一种语言,它在一定程度上决定了代码是否具有一致性和易维护性,统一的开发规范常常可以降低代码的出错概率和团队开发的协作成本。

就拿命名规范来说,如果没有规范,你会经常看到这样的代码:

var a1,a2,temp1,temp2,woshimt;

前端技术演进(六):前端项目与技术实践_第1张图片

开发规范制定的重要性不言而喻,使用怎样的规范又成为了另一个问题,因为编程规范并不唯一。通俗地讲,规范的差别很多时候只是代码写法的区别,不同的规范都有各自的特点,大部分没有优劣之分。一般在选择时没必要纠结于使用哪一种规范, 只要团队成员都认可并达成一致就行。

实际上,我们平时所说的开发规范更多时候指的是狭义上的编码规范,广义上的开发规范包括实际项目开发中可能涉及的所有规范,如项目技术选型规范、组件规范、接口规范、模块化规范等。由于每个团队使用的项目技术实现不一样,规范也可能千差万别,但无论是哪一种规范, 在一个团队中尽可能保持统一。

这里是一个规范的例子:https://guide.aotu.io/docs/index.html

如果使用框架,各个框架会有自己的最佳实践,一般来说参考官方的最佳实践,结合自己团队的习惯即可。

比如Vue:https://cn.vuejs.org/v2/style-guide/

自动化构建

在现代软件开发中,自动化构建已经成为一个不可缺少的部分。

对于编译型语言来说,一般都会通过命令行或者IDE先进行编译,然后在不同平台上安装运行。而前端代码不需要软件编译,Javascript算是解释型语言,浏览器变解析边执行,所以前端的自动化构建和传统语言略有不同。

前端自动化构建目的

前端构建工具的作用主要是对项目源文件或资源进行文件级处理,将文件或资源处理成需要的最佳输出结构和形式。

在处理过程中,我们可以对文件进行模块化引入、依赖分析、资源合并、压缩优化、文件嵌入、路径替换、生成资源包等多种操作,这样就能完成很多原本需要手动完成的事情,极大地提高开发效率。

前端自动化构建工具

在没有自动化构建工具之前,前端在上线前的处理一般是这样的:

  1. HTML代码语法检查
  2. HTML去掉注释
  3. CSS代码去掉注释,添加版权信息
  4. CSS代码语法检查
  5. CSS文件添加兼容性属性
  6. CSS文件压缩合并
  7. JS文件语法检查
  8. JS文件去掉注释,添加版权信息
  9. JS文件压缩
  10. 图片压缩、合并
  11. 各个文件名称添加唯一hash
  12. 修改HTML文件引用路径
  13. 区分线上和开发环境

整个过程每个步骤会用到相应的工具,比如:CSSLint、JSLint、Uglyfy、HTMLMin、CssMinify、imagemin等,繁琐且浪费时间。

而且还有一些附加的构建要求,比如代码一旦修改就要自动校验,自动测试,刷新浏览器等,这种在几年前基本上无法实现。

渐渐地,出现了一些自动化构建的工具。

Grunt

Grunt 是比较早期的工具,它通过安装插件和配置任务,来执行自动化构建。比如:

module.exports = function(grunt) {

  grunt.initConfig({
    jshint: {
      files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
      options: {
        globals: {
          jQuery: true
        }
      }
    },
    watch: {
      files: ['<%= jshint.files %>'],
      tasks: ['jshint']
    }
  });

  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-watch');

  grunt.registerTask('default', ['jshint']);

};

这里就是监控js文件的变化,一旦改版,就执行jshint,也就是语法校验。

Grunt有很强的生态,但是它运用配置的思想来写打包脚本,一切皆配置,所以会出现比较多的配置项,诸如option,src,dest等等。而且不同的插件可能会有自己扩展字段,导致认知成本的提高,运用的时候要搞懂各种插件的配置规则。

Grunt的速度也比较慢,他是一个任务一个任务依次执行,会有很多IO操作。现在基本上用的人比较少了。

Gulp

Gulp 用代码方式来写打包脚本,并且代码采用流式的写法,只抽象出了gulp.src, gulp.pipe, gulp.dest, gulp.watch 接口,运用相当简单,使用 Gulp 的代码量能比 Grunt 少一半左右。

var gulp = require('gulp');
var pug = require('gulp-pug');
var less = require('gulp-less');
var minifyCSS = require('gulp-csso');
var concat = require('gulp-concat');
var sourcemaps = require('gulp-sourcemaps');

gulp.task('html', function(){
  return gulp.src('client/templates/*.pug')
    .pipe(pug())
    .pipe(gulp.dest('build/html'))
});

gulp.task('css', function(){
  return gulp.src('client/templates/*.less')
    .pipe(less())
    .pipe(minifyCSS())
    .pipe(gulp.dest('build/css'))
});

gulp.task('js', function(){
  return gulp.src('client/javascript/*.js')
    .pipe(sourcemaps.init())
    .pipe(concat('app.min.js'))
    .pipe(sourcemaps.write())
    .pipe(gulp.dest('build/js'))
});

gulp.task('default', [ 'html', 'css', 'js' ]);

Gulp 基于并行执行任务的思想,通过一个pipe方法,以数据流的方式处理打包任务,中间文件只生成于内存,不会产生多余的IO操作,所以 Gulp 比 Grunt 要快很多。

Webpack

前端技术演进(六):前端项目与技术实践_第2张图片

Grunt 和 Gulp 可以算是第一代的自动化构建工具。现在前端主要使用的是 Webpack。

其实对比 Gulp 来说,Webpack 并不是一个完全的替代平,Gulp 是任务运行工具,它只是一个自动执行可重复活动的应用程序,它的用途更加的广泛,因为自动任务的范围更广。

相对Gulp来说, Webpack是一个静态模块打包器(static module bundler),主要目的是帮助程序模块及其依赖构建静态资源。但是因为前端自动化构建的主要任务其实就是静态资源的构建,所以Webpack基本都可以完成。因此 Gulp 现在的使用比较少了。

前端技术演进(六):前端项目与技术实践_第3张图片

其实 Webpack 之所以流行,是因为之前的工具对模块化的支持不足,以前的工具大部分是以文件为单位的,而现代JS开发,都是基于模块的,模块依赖的识别是需要语法语义分析的,像 Gulp 之类的工具,只是一个自动执行的工具,没法很好的识别所有的模块依赖,所以继续使用会限制书写的方式和项目结构,配置起来也更加繁琐。

Webpack 把所有的代码或图片都当做资源,它会从一个或多个入口文件开始找起,找到所有的资源依赖,然后做语法分析,去除掉不用的或重复的,最终按照配置要求生成处理过的文件。

前端技术演进(六):前端项目与技术实践_第4张图片

一个典型的Webpack配置文件:

var webpack = require('webpack');
var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin')
var CleanWebpackPlugin = require('clean-webpack-plugin')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')

const VENOR = [
  "lodash",
  "react",
  "redux",
]

module.exports = {
  entry: {
    bundle: './src/index.js',
    vendor: VENOR
  },
  // 如果想修改 webpack-dev-server 配置,在这个对象里面修改
  devServer: {
    port: 8081
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].[chunkhash].js'
  },
  module: {
    rules: [{
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        use: [{
            loader: 'url-loader',
            options: {
                limit: 10000,
                name: 'images/[name].[hash:7].[ext]'
            }
        }]
    },
    {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract({
            fallback: 'style-loader',
            use: [{
            // 这边其实还可以使用 postcss 先处理下 CSS 代码
                loader: 'css-loader'
            }]
        })
    },
    ]
  },
  plugins: [
    // 抽取共同代码
    new webpack.optimize.CommonsChunkPlugin({
      name: ['vendor', 'manifest'],
      minChunks: Infinity
    }),
    // 删除不需要的hash文件
    new CleanWebpackPlugin(['dist/*.js'], {
      verbose: true,
      dry: false
    }),
    new HtmlWebpackPlugin({
      template: 'index.html'
    }),
    // 生成全局变量
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("process.env.NODE_ENV")
    }),
    // 分离 CSS 代码
    new ExtractTextPlugin("css/[name].[contenthash].css"),
    // 压缩提取出的 CSS,并解决ExtractTextPlugin分离出的 JS 重复问题
    new OptimizeCSSPlugin({
      cssProcessorOptions: {
        safe: true
      }
    }),
    // 压缩 JS 代码
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false
      }
    })
  ]
};

打包后生成:

前端技术演进(六):前端项目与技术实践_第5张图片

Rollup

前端技术演进(六):前端项目与技术实践_第6张图片

最近,React,Vue、Ember、Preact、D3、Three.js、Moment 等众多知名项目都使用了 Rollup 这个构建工具。

Rollup 可以使用 ES2015的语法来写配置文件,而 Webpack 不行:

// rollup.config.js
import babel from 'rollup-plugin-babel';

export default {
    input: './src/index.js',
    output: {
        file: './dist/bundle.rollup.js',
        format: 'cjs'
    },
    plugins: [
        babel({
            presets: [
                [
                    'es2015', {
                        modules: false
                    }
                ]
            ]
        })
    ]
}
// webpack.config.js
const path = require('path');
const webpack = require('webpack');

module.exports = {
    entry: {
        'index.webpack': path.resolve('./src/index.js')
    },
    output: {
        libraryTarget: "umd",
        filename: "bundle.webpack.js",
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader',
                query: {
                    presets: ['es2015']
                }
            }
        ]
    }
};

举个简单的例子,两个文件:

//some-file.js
export default 10;


// index.js
import multiplier from './some-file.js';

export function someMaths() {
 console.log(multiplier);
 console.log(5 * multiplier);
 console.log(10 * multiplier);
}

通过 Rollup 和 Webpack 打包之后,分别长成下面这样:

// bundle.rollup.js — ~245 bytes

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

var multiplier = 10;

function someMaths() {
  console.log(multiplier);
  console.log(5 * multiplier);
  console.log(10 * multiplier);
}

exports.someMaths = someMaths;
// bundle.webpack.js — ~4108 bytes

module.exports =
    /******/ (function(modules) { // webpackBootstrap
    /******/   // The module cache
    /******/   var installedModules = {};
    /******/
    /******/   // The require function
    /******/   function __webpack_require__(moduleId) {
    /******/
    /******/      // Check if module is in cache
    /******/      if(installedModules[moduleId]) {
    /******/         return installedModules[moduleId].exports;
    /******/      }
    /******/      // Create a new module (and put it into the cache)
    /******/      var module = installedModules[moduleId] = {
    /******/         i: moduleId,
    /******/         l: false,
    /******/         exports: {}
.........

可以看到 Webpack 打包后的代码基本上不具备可读性,尺寸也有些大。

所以对于主要是给其他人使用的纯JS库或框架来说,Rollup 比 Webpack 更适合。

性能优化

前端性能优化是一个很宽泛的概念,不过最终目的都是提升用户体验,改善页面性能。

性能优化是个很有意思的事情,很多人常常竭尽全力进行前端页面优化,但却忽略了这样做的效果和意义。

通常前端性能可以认为是用户获取所需要页面数据或执行某个页面动作的一个实时性指标,一般以用户希望获取数据的操作到用户实际获得数据的时间间隔来衡量。例如用户希望获取数据的操作是打开某个页面,那么这个操作的前端性能就可以用该用户操作开始到屏幕展示页面内容给用户的这段时间间隔来评判。

用户的等待延时可以分成两部分:可控等待延时和不可控等待延时。可控等待延时可以理解为能通过技术手段和优化来改进缩短的部分,例如减小图片大小让请求加载更快、减少HTTP请求数等。不可控等待延时则是不能或很难通过前后端技术手段来改进优化的,例如鼠标点击延时、CPU计算时间延时、ISP ( Internet Service Provider,互联网服务提供商)网络传输延时等。前端中的所有优化都是针对可控等待延时这部分来进行的。

前端性能测试

Performance Timing API

Performance Timing API是一个支持Internet Explorer9以上版本及WebKit内核浏览器中用于记录页面加载和解析过程中关键时间点的机制,它可以详细记录每个页面资源从开始加载到解析完成这一过程中具体操作发生的时间点,这样根据开始和结束时间戳就可以计算出这个过程所花的时间了。

之前我们介绍 Chrome 网络面板的时候说过一个请求的生命周期:

前端技术演进(六):前端项目与技术实践_第7张图片

可以通过 Performance Timing API 捕获到各个阶段的时间,通过计算各个属性的差值来评测性能,比如:

var timinhObj = performance.timing;

前端技术演进(六):前端项目与技术实践_第8张图片

DNS查询耗时 :domainLookupEnd - domainLookupStart
TCP链接耗时 :connectEnd - connectStart
request请求耗时 :responseEnd - responseStart
解析dom树耗时 : domComplete - domInteractive
白屏时间 :responseStart - navigationStart
domready时间 :domContentLoadedEventEnd - navigationStart
onload时间 :loadEventEnd - navigationStart

Profile 工具

之前有说过,使用 Chrome 开发者工具的 Audit 面板或者 Performance 面板,可以评估性能。

埋点计时

在关键逻辑之间手动埋点计时,比如:

let timeList = []

timeList.push({ tag: 'xxxBegin', time: +new Date })
...
timeList.push({ tag: 'xxxEnd', time: +new Date })

这种方式常常在移动端页面中使用,因为移动端浏览器HTML解析和JavaScript执行相对较慢,通常为了进行性能优化,需要找到页面中执行JavaScript 耗时的操作,如果将关键JavaScript的执行过程进行埋点计时并上报,就可以轻松找出JavaScript 执行慢的地方,并有针对性地进行优化。

资源时序图

可以通过 Chrome 的网络面板,或者 Fiddler 之类的工具查看时序图,来分析页面阻塞:

前端技术演进(六):前端项目与技术实践_第9张图片

前端优化策略

前端优化的策略非常多,主要的策略大概可以归为几大类:

网络加载类

减少HTTP资源请求次数

在前端页面中,通常建议尽可能合并静态资源图片、JavaScript或CSS代码,减少页面请求数和资源请求消耗,这样可以缩短页面首次访问的用户等待时间。

减小HTTP请求大小

应尽量减小每个HTTP请求的大小。如减少没必要的图片、JavaScript、 CSS及HTML代码,对文件进行压缩优化,或者使用gzip压缩传输内容等都可以用来减小文件大小,缩短网络传输等待时延。

将CSS或JavaScript放到外部文件中,避免使用 script 标签直接引入

在HTML文件中引用外部资源可以有效利用浏览器的静态资源缓存。

避免使用空的href和src

当 link 标签的 href 属性为空,或script、 img、iframe标签的src属性为空时,浏览器在渲染的过程中仍会将href属性或src属性中的空内容进行加载,直至加载失败,这样就阻塞了页面中其他资源的下载进程,而且最终加载到的内容是无效的,因此要尽量避免。

为HTML指定Cache-Control或Expires

为HTML内容设置Cache-Control或Expires可以将HTML内容缓存起来,避免频繁向服务器端发送请求。前面讲到,在页面Cache-Control或Expires头部有效时,浏览器将直接从缓存中读取内容,不向服务器端发送请求。比如:

合理设置Etag和Last-Modified

合理设置Etag和Last-Modified使用浏览器缓存,对于未修改的文件,静态资源服务器会向浏览器端返回304,让浏览器从缓存中读取文件,减少Web资源下载的带宽消耗并降低服务器负载。

减少页面重定向

页面每次重定向都会延长页面内容返回的等待延时,一次重定向大约需要600毫秒的时间开销,为了保证用户尽快看到页面内容,要尽量避免页面重定向。

使用静态资源分域存放来增加下载并行数

浏览器在同一时刻向同一个域名请求文件的并行下载数是有限的,因此可以利用多个域名的主机来存放不同的静态资源,增大页面加载时资源的并行下载数,缩短页面资源加载的时间。通常根据多个域名来分别存储JavaScript、CSS和图片文件。比如京东:

前端技术演进(六):前端项目与技术实践_第10张图片

使用静态资源CDN来存储文件

如果条件允许,可以利用CDN网络加快同一个地理区域内重复静态资源文件的响应下载速度,缩短资源请求时间。

使用CDN Combo下载传输内容

CDN Combo是在CDN服务器端将多个文件请求打包成一个文件的形式来返回的技术,这样可以实现HTTP连接传输的一次性复用,减少浏览器的HTTP请求数,加快资源下载速度。比如:

//g.alicdn.com/??kissy/k/6.2.4/seed-min.js,tbc/global/0.0.8/index-min.js,tms/tb-init/6.1.0/index-min.js,sea/sitenav-global/0.5.2/global-min.js

使用可缓存的AJAX

对于返回内容相同的请求,没必要每次都直接从服务端拉取,合理使用AJAX缓存能加快AJAX响应速度并减轻服务器压力。比如:

const cachedFetch = (url, options) => {
  let cacheKey = url

  let cached = sessionStorage.getItem(cacheKey)
  if (cached !== null) {
    let response = new Response(new Blob([cached]))
    return Promise.resolve(response)
  }

  return fetch(url, options).then(response => {
    if (response.status === 200) {
      let ct = response.headers.get('Content-Type')
      if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) {
        response.clone().text().then(content => {
          sessionStorage.setItem(cacheKey, content)
        })
      }
    }
    return response
  })
}

使用GET来完成AJAX请求

使用XMLHttpRequest时,浏览器中的POST方法发送请求首先发送文件头,然后发送HTTP正文数据。而使用GET时只发送头部,所以在拉取服务端数据时使用GET请求效率更高。

减少Cookie的大小并进行Cookie隔离

HTTP请求通常默认带上浏览器端的Cookie一起发送给服务器,所以在非必要的情况下,要尽量减少Cookie来减小HTTP请求的大小。对于静态资源,尽量使用不同的域名来存放,因为Cookie默认是不能跨域的,这样就做到了不同域名下静态资源请求的Cookie隔离。

缩小favicon.ico并缓存

这样有利于favicon.ico的重复加载,因为一般一个Web应用的favicon.ico是很少改变的。

推荐使用异步JavaScript资源

异步的JavaScript 资源不会阻塞文档解析,所以允许在浏览器中优先渲染页面,延后加载脚本执行。比如:


使用async时,加载和渲染后续文档元素的过程和main.js的加载与执行是并行的。使用defer 时,加载后续文档元素的过程和main.js的加载也是并行的,但是main.js的执行要在页面所有元素解析完成之后才开始执行。

使用异步Javascript,加载的先后顺序被打乱,要注意依赖问题。

消除阻塞渲染的CSS及JavaScript

对于页面中加载时间过长的CSS或JavaScript文件,需要进行合理拆分或延后加载,保证关键路径的资源能快速加载完成。

避免使用CSS import引用加载CSS

CSS中的@import可以从另一个样式文件中引入样式,但应该避免这种用法,因为这样会增加CSS资源加载的关键路径长度,带有@import的CSS样式需要在CSS文件串行解析到@import时才会加载另外的CSS文件,大大延后CSS渲染完成的时间。

首屏数据请求提前,避免JavaScript 文件加载后才请求数据

针对移动端,为了进一步提升页面加载速度,可以考虑将页面的数据请求尽可能提前,避免在JavaScript加载完成后才去请求数据。通常数据请求是页面内容渲染中关键路径最长的部分,而且不能并行,所以如果能将数据请求提前,可以极大程度.上缩短页面内容的渲染完成时间。

首屏加载和按需加载,非首屏内容滚屏加载,保证首屏内容最小化

由于移动端网络速度相对较慢,网络资源有限,因此为了尽快完成页面内容的加载,需要保证首屏加载资源最小化,非首屏内容使用滚动的方式异步加载。一般推荐移动端页面首屏数据展示延时最长不超过3秒。目前中国联通3G的网络速度为338KB/s (2.71Mb/s), 不能保证客户都是流畅的4G网络,所以推荐首屏所有资源大小不超过1014KB,即大约不超过1MB。

模块化资源并行下载

在移动端资源加载中,尽量保证JavaScript资源并行加载,主要指的是模块化JavaScript资源的异步加载,使用并行的加载方式能够缩短多个文件资源的加载时间。

inline 首屏必备的CSS和JavaScript

通常为了在HTML加载完成时能使浏览器中有基本的样式,需要将页面渲染时必备的CSS和JavaScript通过 style 内联到页面中,避免页面HTML载入完成到页面内容展示这段过程中页面出现空白。比如百度:







百度一下,你就知道