让 Node.js 与浏览器共用同一套代码

跨平台环境下的 JS 模块

浏览器接收并解析一大批脚本文件,在速度上还是不如把这些脚本代码优化到少数几个文件里面

所以可以把源代码规整到少数几个 bundle(包)中,减少文件数量,这过程中还可以进行 code minification(代码最小化)等优化

1. 模块打包工具

无标题-2022-06-05-1232.png

  • 服务器端:Node.js 平台直接运行 serverApp.js 脚本,该脚本引入三个 module 模块
  • 浏览器端:使用 browserApp.js 脚本,该脚本也引入三个 module 模块,但是通过模块打包工具处理该脚本,将该文件和它所依赖的三个文件,打包成一个 main.js 文件,这样浏览器只需要下载 main.js 和 index.html 两个文件即可,而不是五个

    2. 模块打包工具的运行原理

    模块打包工具,是一种接受应用程序的源代码并产生一个或多个 bundle 文件的工具,还可以使用 babel 来处理源代码并转换其语法风格适应不同浏览器

模块打包工具的运作流程:

  • 依赖关系解析(dependency resolution)
  • 打包(packing)

    依赖关系解析

    遍历整套代码库,从主模块(入口点,entry point)开始,把该模块直接或间接依赖的所有模块找到,构成一张有向无环图(direct acyclic graph),也称为依赖关系图(dependency graph)

  1. // app.js
  2. import {calculator} from './calculator.js'
  3. import {display} from './display.js'
  4. display(calculator('2 + 2 / 4'))
  1. // display.js
  2. export function display() {
  3. //...
  4. }
  1. // calculator.js
  2. import {parser} from './parser.js'
  3. import {resolver} from './resolver.js'
  4. export function calculator (expr) {
  5. return resolver(parser(expr))
  6. }
  1. // parser.js
  2. export function parser(expr) {
  3. //...
  4. }
  1. // resolver.js
  2. export function resolver(tokens) {
  3. //...
  4. }

只要模块打包工具从一个模块跳到另一个模块,即意味着它发现一条新的依赖关系,此时会在依赖关系图中添加一个节点,以表示跳转到的那个模块,并在这两个模块之间建立一条有向的路径
如果模块打包程序在解析过程中又遇到之前解析过的模块,会跳过该模块,以防止依赖关系图中出现环状路径
无标题-2022-06-05-1320.png
在解析依赖关系时,模块打包工具会构建出 modules map(模块映射表)这种数据结构,利用这个 hash map ,把某种独特的标识符(如文件路径名)做为键(key),来区分每个模块的身份,并把这个标识符映射到相应的值(该模块的源代码),大概如下:

  1. {
  2. 'app.js': (module, require) => {/*...*/},
  3. 'calculator.js': (module, require) => {/*...*/},
  4. 'display.js': (module, require) => {/*...*/},
  5. 'parser.js': (module, require) => {/*...*/},
  6. 'resolver.js': (module, require) => {/*...*/},
  7. }

modules map 中的每个模块,都是一个工厂函数(factory function),这个工厂函数会把它要加载的那个模块所具备的源代码完整的收录进来

  1. 'calculator.js': (module, require) => {
  2. const {parser} = require('parser.js')
  3. const {resolver} = require('resolver.js')
  4. module.exports.calculator = function (expr) {
  5. return resolver(parser(expr))
  6. }
  7. }

打包

modules map 是依赖关系解析环节的成果。在 打包(packing)中,模块打包工具要把这份 modules map 转换成一个可执行的 bundle(executable bundle),这是一份 JS 文件,其中包含原应用程序的所有业务代码

  1. ((modulesMap) => {
  2. const require = (name) => {
  3. // 使用 exports 来装载想要导出的实体
  4. const module = { exports: {} }
  5. // 通过 require 递归地引入其他模块
  6. modulesMap[name](module, require)
  7. return module.exports
  8. }
  9. require('app.js')
  10. })(
  11. {
  12. 'app.js': (module, require) => {/*...*/},
  13. 'calculator.js': (module, require) => {/*...*/},
  14. 'display.js': (module, require) => {/*...*/},
  15. 'parser.js': (module, require) => {/*...*/},
  16. 'resolver.js': (module, require) => {/*...*/},
  17. }
  18. )

bundle 文件的名称中有一串数字,这串数字是对文件内容做 hash 所得的。在源代码发生变化后,模块打包工具产生的 bundle 文件,会改用另一串数字命名,这种优化技术叫 cache busting webpack 会默认开启这项技术,对于部署在 CDN 上的资源尤其有用。对于 CDN 而言,如果用新版本的文件覆盖同名的旧文件,开销较大,因为必须把新版文件分发到位于各地的多台服务器,且各个层面的缓存(包括用户的浏览器)中,存放的是否依旧是旧版文件 如果每次修改源代码后产生的 bundle 文件名称和原本不同,会迫使程序去必须去访问新版文件,而不是缓存中的文件

跨平台开发的基础知识

在运行程序的时候做代码分支

如果某个全局变量只在 Node.js 平台或浏览器平台存在,通过判断该变量是否存在,切换相应的分支来适应不同的平台
在运行期做代码分支时可能遇到的问题:

  • 两套代码在同一个模块中,会造成代码文件较大,存在泄密危险
  • 判断逻辑会降低代码的阅读性

    在构建程序时做代码分支

    可以利用 webpack 的一些插件在构建程序的时候做代码分支:

  • DefinePlugin:能够把源文件里面的特定部位,替换成定制的代码或变量值

  • terser-Webpack-plugin:能够压缩成品代码并移除那些执行不到的语句 ```typescript import nunjucks from ‘nunjucks’

export function sayHello (name) { if (typeof BROWSER !== ‘undefined’) { // client side code const template = ‘

Hello {{ name }}

‘ return nunjucks.renderString(template, { name }) }

// Node.js code return Hello \u001b[1m${name}\u001b[0m }

  1. ```typescript
  2. const path = require('path')
  3. const webpack = require('webpack')
  4. const TerserPlugin = require('terser-webpack-plugin')
  5. /*
  6. * SplitChunksPlugin is enabled by default and replaced
  7. * deprecated CommonsChunkPlugin. It automatically identifies modules which
  8. * should be splitted of chunk by heuristics using module duplication count and
  9. * module category (i. e. node_modules). And splits the chunks…
  10. *
  11. * It is safe to remove "splitChunks" from the generated configuration
  12. * and was added as an educational example.
  13. *
  14. * https://webpack.js.org/plugins/split-chunks-plugin/
  15. *
  16. */
  17. const HtmlWebpackPlugin = require('html-webpack-plugin')
  18. /*
  19. * We've enabled HtmlWebpackPlugin for you! This generates a html
  20. * page for you when you compile webpack, which will make you start
  21. * developing and prototyping faster.
  22. *
  23. * https://github.com/jantimon/html-webpack-plugin
  24. *
  25. */
  26. module.exports = {
  27. mode: 'production',
  28. entry: './src/index.js',
  29. output: {
  30. filename: '[name].[chunkhash].js',
  31. path: path.resolve(__dirname, 'dist')
  32. },
  33. plugins: [
  34. new webpack.ProgressPlugin(),
  35. new HtmlWebpackPlugin(),
  36. new webpack.DefinePlugin({
  37. __BROWSER__: true
  38. })
  39. ],
  40. module: {
  41. rules: [
  42. {
  43. test: /.(js|jsx)$/,
  44. include: [path.resolve(__dirname, 'src')],
  45. loader: 'babel-loader',
  46. options: {
  47. plugins: ['syntax-dynamic-import'],
  48. presets: [
  49. [
  50. '@babel/preset-env',
  51. {
  52. modules: false
  53. }
  54. ]
  55. ]
  56. }
  57. }
  58. ]
  59. },
  60. optimization: {
  61. splitChunks: {
  62. cacheGroups: {
  63. vendors: {
  64. priority: -10,
  65. test: /[\\/]node_modules[\\/]/
  66. }
  67. },
  68. chunks: 'async',
  69. minChunks: 1,
  70. minSize: 30000,
  71. name: true
  72. },
  73. minimize: true,
  74. minimizer: [new TerserPlugin({
  75. terserOptions: {
  76. mangle: false,
  77. output: {
  78. beautify: true
  79. },
  80. compress: {
  81. dead_code: true,
  82. if_return: true
  83. }
  84. }
  85. })]
  86. },
  87. devServer: {
  88. open: true
  89. }
  90. }

模块交换(模块替换)

因为在构建程序的时候,已经知道客户端的 bundle 中需要什么代码,所以可以让模块打包工具在构建的过程中把某个模块换成另一个模块。优点:

  • 生成的 bundle 文件较小
  • 不会把原始的程序代码弄乱,不会存在多余的判断逻辑

    1. plugins: [
    2. new webpack.ProgressPlugin(),
    3. new HtmlWebpackPlugin(),
    4. new webpack.NormalModuleReplacementPlugin(
    5. /src\/say-hello\.js$/,
    6. path.resolve(__dirname, 'src', 'say-hello-browser.js')
    7. )
    8. ]

    webpack.NormalModuleReplacementPlugin:构建程序时,如果源代码想要引入的某个模块,在路径上与第一个参数中的正则表达式相匹配,那么该插件就会让源代码改为引入第二个参数所指向的那个模块

    在服务器端与浏览器端采用同一套方案获取数据

  • two-pass rendering(两轮渲染)

  • async page(异步页面)

无论采用哪种方法,服务器都会在它所生成的 HTML 页面中提供一个内联的 script 块,只要服务器把数据完全加载好,它就会将这些数据注入 global scope(全局作用域,即 window 对象)中,这样客户端就不需要把服务器端加载好的数据重新加载一遍了。