Webpack实战-04-04-构建不同运行环境下的项目

281 阅读10分钟

构建同构应用

同构应用:是指写一份代码但可以同时再浏览器和服务器中运行的应用

认识同构应用

同构应用的出现: 大多数单页应用的视图都是通过js在浏览器端渲染出来的,但是浏览器渲染会出现以下问题 1、搜索引擎无法收录我们的网页 2、复杂的单页应用,渲染过程计算量过大,可能会有性能方面的问题 所以有人提出,能否将原本只运行浏览器中的js渲染代码也在服务器中运行,在服务器端渲染出带内容的HTML后再返回,这样就能让搜索引擎爬虫直接抓去带数据的HTML,同时减少首屏渲染时间 如此,同构应用出现了

同构应用核心原理:虚拟DOM

虚拟DOM是不直接操作DOM,而是通过JavaScript Object描述原本的DOM结构,在需要更DOM时不直接操作DOM树,而是在更新JavaScript Object后再映射成DOM操作

虚拟DOM优点:

  • DOM树是高耗时操作,所以尽量减少DOM树操作能优化网页操作
  • 虚拟DOM再渲染时不仅可以通过操作DOM树表示结果,也可以有其他方式,如将虚拟DOM渲染成字符串(服务器渲染),或渲染成手机APP原生UI组件(RN)

瞅瞅React: React的具体渲染工作是由react-dom模块操作 react-dom在渲染虚拟DOM树时有两种方式

  • render()函数取操作浏览器DOM展示出结果
  • renderToString()计算表示虚拟DOM的HTML形式的字符串

构建同构应用最终目的是从一份项目源码中构建出两份js代码,一份用于浏览器运行,一份用于在Node.js环境运行并渲染出HTML。对于要在Node.js环境中运行的js代码需要注意以下问题

  • 不能包含浏览器环境提供的API,如使用document进行DOM操作
  • 不能包含CSS代码:因为服务器主要渲染HTML内容,渲染出CSS代码会增加额外的工作量,影响服务器端性能
  • 不能像用于浏览器环境的输出代码那样将node_modules里的第三方模块和Node.js原生模块打包进去,而是通过CommonJS规范引入这些模块
  • 需要通过CommonJS规范导出一个渲染函数,用于在HTTP服务器中执行这个渲染函数,渲染出HTML的内容后返回

解决方案

1、用于构建浏览器环境代码的webpack.config.js配置文件保持不变,参见使用新框架-react配置,新建一个专门用于构建服务器端渲染代码的配置文件webpack_server.config.js,内容如下

const path = require('path');
const nodeEnternals = require('webpack-node-externals');
module.exports = {
    // JavaScript执行入口文件
    entry: './main_server.js',
    // 为了不将Node.js内置的模块打包进输出文件
    target: 'node',
    // 为了不将node_modules目录下的第三方模块打包进输出文件
    externals: [nodeExternals()],
    output: {
        // 为了以CommonJS2规范导出渲染函数,以被采用Node.js编写的HTTP服务调用
        libraryTarget: 'commonjs2',
        // 最终可在Node.js中运行的代码输出到bundle_server.js文件
        filename: 'bundle_server.js',
        // 将输出文件都放在dist目录下
        path: path.resolve(__dirname, './dist'),
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: ['babel-loader'],
                exclude: path.resolve(__dirname, 'node_modules'),
            },
            {
                // CSS代码不能被打包到服务器的代码中,忽略CSS文件
                test: /\.css/,
                use: ['ignore-loader'],
            },
        ]
    },
    devtool: 'source-map' // 输出source-map,以方便直接调试ES6源码
}

2、为了最大限度的复用代码,需要调整目录结构,将页面放到一个单独的文件AppComponent.js中,该文件只能包含根组件的代码,不能包含渲染入口的代码,而且需要导出根组件以供渲染入口调用,内容如下

import React, { Component } from 'react',
import './main.css';
export class AppComponent extends Component {
    render() {
        renturn <h1>Hello, Webpack</h1>
    }
}

3、分别为不同环境的渲染入口写两份不同的文件,即用于浏览器渲染DOM的main_browser.js文件和用于服务器渲染HTML字符串的main_server.js文件

// main_browser.js文件
import React from 'react';
import { renderToString } from 'react-dom/server';
import { AppComponent } from './AppComponent';
// 根组件渲染到DOM树上
render(<AppComponent />, window.document.getElementById('app'));

 // main_server.js文件
 import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';
// 导出渲染函数,以供采用Node.js编写HTTP服务器代码调用
export function render() {
    // 将根组件渲染成HTML字符串
    return renderToString(<AppComponent />)
}

4、为了能将渲染的完整HTML文件通过HTTP服务返回给请求端,还需要Node.js编写一个HTTP服务器(本节不注重HTTP服务器实现,所以采用ExpressJS实现),http_server.js文件内容如下:

const express = require('express');
const { render } = require('./dist/bundle_server');
const app = express();
// 调用构建出bundle_server.js中暴露出渲染函数,再拼接出HTML模版,形成完整的HTML文件
app.get('/', function (req, res) {
    res.send(`
        <html>
        <head>
            <meta charser="UTF-8">
        </head>
        <body>
            <div id="app">${render()}</div>
            <!--导入Webpack输出的用于浏览器端渲染的js文件-->
            <script src="./dist/bundle_browser.js"></script>
        </body>
        </html>
    `);
});
// 其他请求路径返回对应的本地文件
app.use(express.static('.'));
app.listen(3000, function(){
    console.log('app listening on port 3000!')
})

5、再安装新引入的第三方依赖

# 安装Webpack构建依赖
npm i -D css-loader style-loader ignore-loader webpack-node-externals
# 安装HTTP服务器依赖
npm i -S express

6、以上准备完毕,可以执行构建了

  • 执行命令webpack --config webpack_server.config.js,构建出用于服务端渲染的./dist/bundle_server.js文件
  • 执行命令webpack,构建出用于浏览器环境运行的./dist/bundle_browser.js的文件,默认的配置文件为webpack.config.js

7、构建完成后,执行node ./http_server.js启动HTTP服务器,再用浏览器去访问http://localhost:3000,就能看到Hello, Webpack

构建Electron应用

Electron是啥玩应

它可以让我们使用开发Web技术去开发跨平台的桌面端应用 是Node.js和Chromium浏览器的结合体,用Chromium浏览器显示出的Web页面作为应用的GUI,通过Node.js和操作系统交互。 优点:

  • 降低开发门槛,只需掌握网页开发技术和Node.js,大量的Web开发技术和现成库可以复用与Electron
  • 由于Chromium浏览器和Node.js都是跨平台的,所以Electron能做到在不同的操作系统中运行一份代码

运行Electron应用时,会从启动一个主进程开始,主进程的启动通过node执行一个js入口文件实现的,main.js

const { app, BrowserWindow } = require('electron')
// 保持一个对window对象的全局引用,如果我们不这样做,则当js对象被垃圾回收时,window会被自动的关闭
let win
// 打开主窗口
function creatWindow() {
    // 创建浏览器窗口
    win = new BrowserWindow({ width: 800, height: 600})
    // 加载应用的index.html
    const indexPageURL = `file://${__diename}/dist/index.html`;
    win.loadURL(indexPageURL);
    // 当Window被关闭时,这个事件会被触发
    win.on('closed', () => {
        // 取消引用window对象
        win = null
    })
}
// Electron会在创建浏览器窗口时调用这个函数
app.on('ready', createWindow)
// 当全部窗口关闭时退出
app.on('window-all-closed', () => {
    // 在MacOS,除非用户用cmd+Q确定地退出,否则绝大部分应用会保持激活状态
    if(process.platform !== 'darwin') {
        app.quit()
    }
})

主进程启动后会一直驻留在后台运行,我们看到的和操作的窗口并不是主进程,而是由主进程新启动的窗口子进程。 应用从启动到退出有一系列生命周期事件,可通过electron.app.on()函数取监听声明周期事件,在特定的时刻做出反应。

接入Webpack

做一个简单的Electron应用(启动后显示一个主窗口,在主窗口里点击按钮,重新展示一个窗口,使用React框架) 分析:因为Electron应用每个窗口对应一个网页,所以需要开发两个网页,分别为主窗口index.html和新打开的窗口login.html,也就是说由两个单页应用组成,所以配置这不就来了么,参照构建多个单页应用,需要改动点如下: 1、修改在项目根目录下新建主进程的入口文件main.js,内容和上方一致 2、修改主窗口网页的代码如下:

import React, { Component } from 'react';
import { render } from 'react-dom';
import { remote } from 'electron';
import path from 'path';
import './index.css';
class App extends Component {
    // 在按钮被点击时
    handleBtnClick() {
        // 新窗口对应的URL地址
        const modalPath = path.join('file://',
        remote.app.getAppPath(), 'dist/login.html');
        let win = new remote.BrowserWindow({
            width: 400,
            height: 320,
        })
        win.on('close', function() {
            // 再窗口被关闭时清空资源
            win = null
        })
        // 加载网页
        win.loadURL(modalPath);
        // 显示窗口
        win.show();
    }
    render() {
        <div>
            <h1>Page Index</h1>
            <button onClick={ this.handleBtnClick }>Open Page Login</button>
        </div>
    }
}
render(<App />, window.document.getElementById('app'));

单击事件通过electron库提供的API重新打开一个窗口

3、构建需要做到以下两点:

  • 构建出两个可在浏览器运行的网页,分别对应两个窗口的界面
  • 构建出的代码不能包含Node.js原生模块或者Electron模块,因为他们内置支持 完成以上要求,只需要在Webpack配置文件加上一行代码即可target: 'electron-renderer',

注意要安装依赖

# 安装Electron执行环境到项目中
npm i -D electron
# 安装成功后再项目目录下执行命令electron ./就可以启动桌面应用

构建Npm模块

认识Npm

目前最大的js模块仓库 特点:

  • 每个模块根目录下都有一个描述该模块的package.json文件
  • 模块中的文件以js文件为主,但不限于js文件
  • 模块中的代码大多采用模块化规范

问题引出

webpack不仅用于构建可运行应用,也用于可上传到npm的模块 例如:

1、源码采用es6编写,但发布到Npm仓库时需要转化成es5代码,同时支持commonjs模块化规范(提供Source Map方便调试)
2、依赖的其他资源文件也需要包含在发布模块里
3、尽量减少冗余代码
4、发布出去的代码不能含有其他依赖的模块代码

Npm仓库模块的最终目录

node_modules/hello-webpack
|--- lib
|   |--- index.css
|   |--- index.css.map
|   |--- index.js(符合CommonJS模块化规范的es5代码)
|   |--- index.js.map
|--- src(es6源码)
|   |--- index.css
|   |--- index.js
|--- package.json

随便搞一个React组件

import React, { Component } from 'react';
import './index.css';
// 导出该组件以供其他模块使用
export default class HelloWebpack extends Component {
    render() {
        return <h1 className="hello-component">Hello, Webpack</h1>
    }
}

使用该模块时只需要这样做

// 通过ES6语法导入
import HelloWebpack from 'hello-component';
import 'hello-webpack/lib/index.css';
// 或者通过ES5语法导入
var HelloWebpack = require('hello-webpack');
require('hello-webpack/lob/index.css');
// 使用react-dom渲染
render(<HelloWebpack />)

使用Webpack构建Npm模块

刚刚的举例

1、源码采用es6编写,但发布到Npm仓库时需要转化成es5代码,同时支持commonjs模块化规范(提供Source Map方便调试)
2、依赖的其他资源文件也需要包含在发布模块里
3、尽量减少冗余代码
4、发布出去的代码不能含有其他依赖的模块代码

对于以上栗子的分析

分析(一一对应)

第一点:

  • 使用babel-loader将ES6代码转化成ES5代码
  • 开启devtool: 'source-map'输出Source Map以发布调试
  • 设置output.libraryTarget='commonjs2',使输出的代码符合CommonJS2模块化规范

相关Webpack配置代码如下:

module.exports = {
    output: {
        // 给出的代码符合CommonJS模块规范化
        libraryTargrt: 'commonjs2',
    },
    // 输出Source Map
    devtool: 'source-map',
}

第二点

  • 通过css-loader和extract-text-webpack-plugin实现
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
    module: {
        rules: [
            {
                // 增加对CSS文件的支持
                test:/\.css/,
                // 提取出Chunk中的CSS代码到单独的文件中
                use: ExtractTextPlugin.extract({
                    use: ['css-loader']
                }),
            },
        ]
    },
    plugins: [
        new ExtractTextPlugin({
            // 输出CSS文件名称
            filename: 'index.css',
        }),
    ],
};
  • 在此引入新的依赖
# 安装Webpack构建所安装的依赖
npm i -D style-loader css-loader extract-text-webpack-plugin

第三点

  • 注意Babel在将ES6代码转换成ES5代码时会注入一些辅助函数(参考
  • 所以修改.babelrc文件,为其加入tranform-runtime插件
"plugins": [
    [
        "transform-runtime",
        {
            // transform-runtime默认会自动为我们使用es6 api注入polyfill,假如在源码中使用了Promise,则输出语句自动注入require('babel-runtime/core-js/Promise')语句
            // polyfill的注入应该交给模块使用者,因为使用者可能在其他地方注入了其他Promise polyfill库
            // 所以关闭该功能
            "polyfill": false
        }
    ]
]
  • 在此不要忘记安装依赖
# 安装Webpack构建所需的新依赖
npm i -D babel-plugin-transform-runtime
# 安装输出代码运行时所需的新依赖
npm i -S babel-runtime

第四点

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

module.exports = {
    // 通过正则命中所有以react或者babel-runtime开头的模块
    // 这些模块通过注册在运行环境中的全局变量访问,不用被重复打包进输出的代码里
    externals: /^(react|babel-runtime)/,
};

最终完整版

const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
    // 模块入口文件
    entry: './src/index.js',
    output: {
        // 输出文件名称
        filename: 'index.js',
        path: path.resolve(_dirname, './dist'),
        // 给出的代码符合CommonJS模块规范化
        libraryTargrt: 'commonjs2',
    },
    // 通过正则命中所有以react或者babel-runtime开头的模块
    // 这些模块通过注册在运行环境中的全局变量访问,不用被重复打包进输出的代码里
    externals: /^(react|babel-runtime)/,
    module: {
        rules: [
            {
                test: /\.js$/,
                use: ['babel-loader'],
                // 排除node_modules底下所有文件
                exclude: path.resolve(__dirname, 'node_modules'),
            }
            {
                // 增加对CSS文件的支持
                test:/\.css/,
                // 提取出Chunk中的CSS代码到单独的文件中
                use: ExtractTextPlugin.extract({
                    use: ['css-loader']
                }),
            },
        ]
    },
    plugins: [
        new ExtractTextPlugin({
            // 输出CSS文件名称
            filename: 'index.css',
        }),
    ],
    // 输出Source Map
    devtool: 'source-map',
};

发布到Npm

将构建出的代码发布到npm时,要确保我们的模块描述文件package.json已经配置正确

由于构建出的代码入口文件是./lib/index.js,所以需要修改package.json的main字段

{
    "main": "lib/index.js",
    // 指出采用es6编写的模块入口文件所在位置
    "jsnext:main": "src/index.js"
}

修改完成在项目目录下执行npm publish,就能将构建出的代码发布到Npm仓库(确保已经npm login)

Webpack适用于构建完整不可分割的Npm模块

如果让发布到npm的代码同源代码目录结构一致,webpack不合适,因为源码是一个个峰哥的模块化文件,webpack会将这些模块组合在一起

构建离线应用

认识离线应用

通过离线缓存技术让资源在第一次被加载后缓存在本地,下次访问直接返回本地文件 优点:

  • 没有联网的情况下也能打开网页
  • 部分缓存资源直接在本地加载,所以对用户来说可以加快网页的加载速度,对网站运营者来说减轻服务器压力以及传输流量费用

离线应用的核心:离线缓存技术,有两种

  • AppCache,也叫Application Cache,目前已经从Web标准库删除,尽量不要使用(废弃)
  • Service Workers,Web Worker的一部分,通过拦截网络请求实现离线缓存,也是构建PWA应用的关键技术之一

认识Service Workers

是一个在浏览器后台运行的脚本,生命周期完全独立于网页,无法直接访问DOM,但可以通过postMessage接口发送消息来和UI进行通信

Service Workers兼容性

chrome,firefox,opera已经全面支持,但是只有高版本的android支持移动端浏览器(Service Workers无法通过注入polyfill实现兼容,所以使用前先弄明白自己网页的运行场景)

  • 判断浏览器是否支持Service Workers最简单方法:
// 如果navigator对象上存在serviceWorker对象,表示支持
if(navigator.serviceWorker) {
    // 通过navigator.serviceWorker对象使用
}

注册Service Workers

要为网页接入Service Workers,需要再网页加载后注册一个Service Workers逻辑脚本,代码如下

if(navigator.serviceWorker) {
    window.addEventListener('DOMContentLoaded', function() {
        // 调用serviceWorker.register注册,参数/sw.js为脚本文件所在的URL路径
        navigator.serviceWorker.register('/sw.js');
    });
}

一旦这个脚本文件被加载,Service Workers的安装就开始了(这个脚本文件被安装到浏览器中后,就算用户关闭了当前网页,它仍然会存在),所以,第一次打开网页时,Service Workers逻辑不会生效,因为脚本还没有被加载注册,以后代开网页时脚本里面的逻辑才会生效

chrome://inspect/#service-workers查看当前浏览器所有已经注册的Service Workers

使用Service Workers实现离线缓存

Service Workers注册成功后回在其生命周期中派发一些事件,通过网络监听对应的事件再特定节点上做一些事 1、在Service Workers脚本文件中引入了新的关键字self,代表当前Service Workers实例 2、在Service Workers安装成功后回派发install事件,需要在这个事件中执行缓存资源逻辑代码如下

// 当前缓存版本的唯一标识符,用当前时间代替
var cacheKey = new Date().toISOString();
// 需要被缓存的文件的URL列表
var cacheFileList = [
    'index.html',
    '/app.js',
    '/app.css'
];
// 监听install事件
self.addEventListener('install', function(event){
    // 等待所有资源缓存完成时才可进行下一步
    event.waitUntil(
        caches.open(cacheKey).then(function (cache) {
            // 要缓存的文件URL列表
            return cache.addAll(cacheFileList)
        })
    );
});

3、监听网络请求事件,拦截请求,复用缓存

self.addEventListener('fetch', function(event) {
    event.respondWith(
        // 去缓存中查询对应的请求
        caches.match(event.request).then(function(response) {
            // 如果命中本地缓存,就直接返回本地资源
            if(response) {
                return response;
            }
            // 否则就用fetch下载资源
            return fetch(event.request);
        })
    );
});

4、更新缓存 浏览器针对Service Workers有如下机制

  • 每次打开接入Service Workers网页,浏览器都会重新下载Service Workers脚本文件(不能太大)如果发现和当前已经注册过的文件存在字节差异,将其视为“新服务工作线程”
  • 新的Service Workers线程将会启动,且触发install事件
  • 当网页上当前打开的页面关闭时,旧的Service Workers县城将会被终止,新的Service Workers线程取得控制权
  • 新的Service Workers线程取得控制权后,将会触发其activate事件

新的Service Workers线程中的activate事件就是清理旧缓存的最佳时间点,代码如下

// 当前缓存的白名单,在新脚本的install事件里将使用白名单里的key
var chcheWhitelist = [cacheKey];
self.addEventListener('activate', function(event) {
    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(
                cacheNames.map(function(cacheName) {
                    // 将不在白名单里的缓存全部清掉
                    if(cacheWhitelist.indexOf(cacheName) === -1) {
                        // 删除缓存
                        return caches.delete(cacheName)
                    }
                })
            );
        })
    );
});

完整版Service Workers脚本代码如下

// 当前缓存版本的唯一标识符,用当前时间代替
var cacheKey = new Date().toISOString();
// 当前缓存的白名单,在新脚本的install事件里将使用白名单里的key
var chcheWhitelist = [cacheKey];
// 需要被缓存的文件的URL列表
var cacheFileList = [
    'index.html',
    '/app.js',
    '/app.css'
];
// 监听install事件
self.addEventListener('install', function(event){
    // 等待所有资源缓存完成时才可进行下一步
    event.waitUntil(
        caches.open(cacheKey).then(function (cache) {
            // 要缓存的文件URL列表
            return cache.addAll(cacheFileList)
        })
    );
});
// 拦截网络请求
self.addEventListener('fetch', function(event) {
    event.respondWith(
        // 去缓存中查询对应的请求
        caches.match(event.request).then(function(response) {
            // 如果命中本地缓存,就直接返回本地资源
            if(response) {
                return response;
            }
            // 否则就用fetch下载资源
            return fetch(event.request);
        })
    );
});
// 新的Service Workers线程取得控制权后,触发activate事件
self.addEventListener('activate', function(event) {
    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(
                cacheNames.map(function(cacheName) {
                    // 将不在白名单里的缓存全部清掉
                    if(cacheWhitelist.indexOf(cacheName) === -1) {
                        // 删除缓存
                        return caches.delete(cacheName)
                    }
                })
            );
        })
    );
});

接入Webpack

要解决的问题:如何生成sw.js文件 webpack原声功能不能完成,需要serviceworker-webpack-plugin插件,配置如下

const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { WebPlugin } = require('web-webpack-plugin');
const ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin');

module.exports = {
    entry: {
        app: './main.js'
    },
    output: {
        filename: '[name].js',
        publicPath: '',
    },
    module: {
        rules: [
            {
                // 增加对CSS文件的支持
                test:/\.css/,
                // 提取出Chunk中的CSS代码到单独的文件中
                use: ExtractTextPlugin.extract({
                    use: ['css-loader']
                }),
            },
        ]
    },
    plugins: [
        // 一个Webplugin对应一个Html文件
        new WebPlugin({
            template: './template.html', // html模版文件所在的文件路径
            filename: 'index.html' // 输出的html文件名称
        }),
        new ExtractTextPlugin({
            filename: `[name].css`, // 为输出的css文件名称加上Hash值
        }),
        new ServiceWorkerWebpackPlugin({
            // 自定义的sw.js文件所在路径
            // ServiceWorkerWebpackPlugin会将文件列表注入生成的sw.js中
            entry: path.join(__dirname, 'sw.js'),
        }),
    ],
    devServer: {
        // Service Workers依赖HTTPS,使用DevServer提供的HTTPS功能
        https: true,
    }
}

以上配置注意点:

  • Service Workers必须再https环境下才能拦截网络请求实现离线缓存
  • serviceworker-webpack-plugin为了保证灵活性,允许使用者自定义sw.js,构建输出的sw.js文件中会在头部注入一个变量,serviceWorkerOption.assets到全局,里面存放着所有需要被缓存的文件的URL列表

将上面的sw.js文件中被写成了静态值的cacheFileList修改如下

// 需要被缓存的文件的URL列表
var cacheFileList = global.serviceWorkerOption.assets;

不要忘记引入依赖

npm i -D serviceworker-webpack-plugin webpack-dev-server