专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
OSC开源社区  ·  RAG市场的2024:随需而变,从狂热到理性 ·  12 小时前  
码农翻身  ·  漫画 | 为什么大家都愿意进入外企? ·  20 小时前  
程序员的那些事  ·  OpenAI ... ·  昨天  
程序员的那些事  ·  印度把 DeepSeek ... ·  2 天前  
51好读  ›  专栏  ›  SegmentFault思否

9102年了,你还不会手写React脚手架?(已优化至完美版)

SegmentFault思否  · 公众号  · 程序员  · 2019-05-14 12:08

正文

本文原载于SegmentFault专栏“前端进阶”

作者:Jerry谭金杰

整理编辑:SegmentFault


webpack马上要出5了,完全手写一个优化后的脚手架是不可或缺的技能。

本文书写时间 2019年5月9日 , webpack版本 4.30.0最新版本,所有代码均出自手写,亲自试验过可以运行达到优化效果。

杜绝5分钟的技术,先深入原理再写配置,大家一起学习参考。


实现需求:

  • 识别 JSX 文件

  • tree shaking 摇树优化 删除掉无用代码

  • PWA 功能,热刷新,安装后立即接管浏览器 离线后仍让可以访问网站 还可以在手机上添加网站到桌面使用

  • CSS 模块化,不怕命名冲突

  • 小图片的 base64 处理

  • 文件后缀省掉 jsx js json

  • 实现React懒加载,按需加载 , 代码分割 并且支持服务端渲染

  • 支持 less sass stylus 等预处理

  • code spliting 优化首屏加载时间 不让一个文件体积过大

  • 提取公共代码,打包成一个 chunk

  • 每个 chunk 有对应的 chunkhash ,每个文件有对应的 contenthash ,方便浏览器区别缓存

  • 图片压缩

  • CSS 压缩

  • 增加 CSS 前缀 兼容各种浏览器

  • 对于各种不同文件打包输出指定文件夹下

  • 缓存 babel 的编译结果,加快编译速度

  • 每个入口文件,对应一个 chunk ,打包出来后对应一个文件 也是 code spliting

  • 删除 HTML 文件的注释等无用内容

  • 每次编译删除旧的打包代码

  • CSS 文件单独抽取出来

  • 让babel不仅缓存编译结果,还在第一次编译后开启多线程编译,极大加快构建速度

  • 等等....

  • webpack 中文官网的标语是 :让一切都变得简单




概念:

本质上, webpack 是一个现代 JavaScript 应用程序的静态模块打包器( module bundler )。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图( dependency graph ),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle


webpack v4.0.0 开始,可以不用引入一个配置文件。然而, webpack 仍然还是高度可配置的。在开始前你需要先理解四个核心概念:

  • 入口( entry )

  • 输出( output )

  • loader

  • 插件( plugins )


本文旨在给出这些概念的高度概述,同时提供具体概念的详尽相关用例。

让我们一起来复习一下最基础的Webpack知识,如果你是高手,那么请直接忽略这些往下看吧!


  • 入口

  • 入口起点`(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。

  • 每个依赖项随即被处理,最后输出到称之为 bundles 的文件中,我们将在下一章节详细讨论这个过程。

  • 可以通过在 webpack 配置中配置 entry 属性,来指定一个入口起点(或多个入口起点)。默认值为 ./src

  • 接下来我们看一个 entry 配置的最简单例子:

webpack.config.js
module.exports = { entry: './path/to/my/entry/file.js'};
  • 入口可以是一个对象,也可以是一个纯数组

entry: {    app: ['./src/index.js', './src/index.html'],    vendor: ['react'] },entry: ['./src/index.js', './src/index.html'],
  • 有人可能会说,入口怎么放 HTML 文件,因为开发模式下热更新如果不设置入口为 HTML ,那么更改了 HTML 文件内容,是不会刷新页面的,需要手动刷新,所以这里给了入口 HTML 文件,一个细节。


  • 出口(output)

  • output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值为 ./dist。基本上,整个应用程序结构,都会被编译到你指定的输出路径的文件夹中。你可以通过在配置中指定一个 output 字段,来配置这些处理过程:

 webpack.config.js        const path = require('path');        module.exports = {      entry: './path/to/my/entry/file.js',      output: {        path: path.resolve(__dirname, 'dist'),        filename: 'my-first-webpack.bundle.js'      }    };

在上面的示例中,我们通过 output.filename output.path 属性,来告诉 webpack bundle 的名称,以及我们想要 bundle 生成( emit )到哪里。可能你想要了解在代码最上面导入的 path 模块是什么,它是一个 Node.js 核心 模块,用于操作文件路径。


  • loader

  • loader 让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。

  • 本质上,webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。

  • 注意,loader 能够 import 导入任何类型的模块(例如 .css 文件),这是 webpack 特有的功能,其他打包程序或任务执行器的可能并不支持。我们认为这种语言扩展是有很必要的,因为这可以使开发人员创建出更准确的依赖关系图。

  • 在更高层面,在 webpack 的配置中 loader 有两个目标:

  • test 属性,用于标识出应该被对应的 loader 进行转换的某个或某些文件。

  • use 属性,表示进行转换时,应该使用哪个 loader。

    webpack.config.js        const path = require('path');        const config = {      output: {        filename: 'my-first-webpack.bundle.js'      },      module: {        rules: [          { test: /\.txt$/, use: 'raw-loader' }        ]      }    };        module.exports = config;
  • 以上配置中,对一个单独的 module 对象定义了 rules 属性,里面包含两个必须属性:test 和 use。这告诉 webpack 编译器( compiler ) 如下信息:

  • “嘿, webpack 编译器,当你碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在你对它打包之前,先使用 raw-loader 转换一下。”

  • 重要的是要记得,在 webpack 配置中定义 loader 时,要定义在 module.rules 中,而不是 rules。然而,在定义错误时 webpack 会给出严重的警告。为了使你受益于此,如果没有按照正确方式去做, webpack 会“给出严重的警告”

  • loader 还有更多我们尚未提到的具体配置属性。

  • 这里引用这位作者的优质文章内容,手写一个 loader plugin


高潮来了 , webpack 的编译原理 ,为什么要先学学习原理? 因为你起码得知道你写的是干什么的!


  • webpack 打包原理

  • 识别入口文件

  • 通过逐层识别模块依赖。( Commonjs、amd 或者es6的 import,webpack 都会对其进行分析。来获取代码的依赖)

  • webpack 做的就是分析代码。转换代码,编译代码,输出代码,最终形成打包后的代码。

  • 这些都是 webpack 的一些基础知识,对于理解 webpack 的工作机制很有帮助。


  • 什么是 loader

  • loader 是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中

  • 处理一个文件可以使用多个 loader loader 的执行顺序是和本身的顺序是相反的,即最后一个 loader 最先执行,第一个 loader 最后执行。

  • 第一个执行的 loader 接收源文件内容作为参数,其他 loader 接收前一个执行的 loader 的返回值作为参数。最后执行的 loader 会返回此模块的 JavaScript 源码

  • 在使用多个 loader 处理文件时,如果要修改 outputPath 输出目录,那么请在最上面的 loader中options设置


  • 什么是 plugin?

  • Webpack 运行的生命周期中会广播出许多事件, Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

  • plugin和loader 的区别是什么?

  • 对于 loader ,它就是一个转换器,将A文件进行编译形成B文件,这里操作的是文件,比如将A.scss或A.less转变为B.css,单纯的文件转换过程

  • plugin 是一个扩展器,它丰富了 wepack 本身,针对是 loader 结束后, webpack 打包的整个过程,它并不直接操作文件,而是基于事件机制工作,会监听 webpack 打包过程中的某些节点,执行广泛的任务。


  • webpack 的运行

  • webpack 启动后,在读取配置的过程中会先执行 new MyPlugin(options) 初始化一个 MyPlugin 获得其实例。在初始化compiler 对象后,再调用 myPlugin.apply(compiler) 给插件实例传入 compiler 对象。插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin (事件名称, 回调函数) 监听到 Webpack 广播出来的事件。并且可以通过 compiler 对象去操作 webpack

  • 看到这里可能会问 compiler 是啥, compilation 又是啥?

  • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;

  • Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。 Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。

  • Compiler Compilation 的区别在于:

  • Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。


  • 事件流

  • webpack 通过 Tapable 来组织这条复杂的生产线。

  • webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

  • webpack 的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。


下面正式开始开发环境的配置:

  • 入口设置 :

  • 设置APP,几个入口文件,即会最终分割成几个 chunk

  • 在入口中配置 vendor ,可以 code spliting ,将这些公共的复用代码最终抽取成一个 chunk ,单独打包出来

  • 要想在开发模式中 HMTL 文件也热更新,需要加入· index.html 为入口文件。

    entry: {            app: ['./src/index.js', './src/index.html'],            vendor: ['react']  //这里还可以加入redux react-redux better-scroll等公共代码         },


  • output 出口

  • webpack 基于 Node.js 环境运行,可以使用 Node.js API path 模块的 resolve 方法

  • 对输出的 JS 文件,加入 contenthash 标示,让浏览器缓存文件,区别版本。

     output: {            filename: '[name].[contenthash:8].js',            path: resolve(__dirname, '../dist')        },
  • mode: 'development' 模式选择,这里直接设置成开发模式,先从开发模式开始。

  • resolve 解析配置,为了为了给所有文件后缀省掉 js jsx json ,加入配置

resolve: {    extensions: [".js", ".json", ".jsx"]}
  • 加入插件 热更新 plugin html-webpack-plugin


const HtmlWebpackPlugin = require('html-webpack-plugin') const webpack = require('webpack') new HtmlWebpackPlugin({ template: './src/index.html' }), new webpack.HotModuleReplacementPlugin(),
  • 加入 babel-loader 还有 解析 JSX ES6 语法的 babel preset

  • @babel/preset-react 解析 jsx语法

  • @babel/preset-env 解析 es6 语法

  • @babel/plugin-syntax-dynamic-import 解析 react-loadable import 按需加载,附带 code spliting 功能

    {        test: /\.(js|jsx)$/,        use:        {            loader: 'babel-loader',            options: {                presets: ["@babel/preset-react", ["@babel/preset-env", { "modules": false }]],                plugins: ["@babel/plugin-syntax-dynamic-import"]                     },                cacheDirectory: true//开启babel编译缓存        }   },  
  • 加入 thread-loader ,在 babel 首次编译后开启多线程


const os = require('os') { loader: 'thread-loader', options: { workers: os.cpus().length }    }
  • React 的按需加载,附带代码分割功能 ,每个按需加载的组件打包后都会被单独分割成一个文件


import React from 'react' import loadable from 'react-loadable' import Loading from '../loading' const LoadableComponent = loadable({ loader: () => import('../Test/index.jsx'), loading: Loading, }); class Assets extends React.Component { render() { return ( <div> <div>这即将按需加载div> <LoadableComponent /> div> ) } } export default Assets
  • 加入 html-loader 识别 html 文件

    {    test: /\.(html)$/,    loader: 'html-loader'    }
  • 加入 eslint-loader

        {        enforce:'pre',        test:/\.js$/,        exclude:/node_modules/,        include:resolve(__dirname,'/src/js'),        loader:'eslint-loader'        }


必须了解的 webpack 热更新原理 :

  • webpack 的热更新又称热替换( Hot Module Replacement ),缩写为 HMR 。 这个机制可以做到不用刷新浏览器而将新变更的模块替换掉旧的模块。

  • 首先要知道server端和client端都做了处理工作

  • 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改, webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。

  • 第二步是 webpack-dev-server webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互, webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack ,将代码打包到内存中。

  • 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了 devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。

  • 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。

  • webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。

  • HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json ,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。

  • 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中, HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。

  • 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。


正式开始生产环节:

  • 加入 WorkboxPlugin PWA 的插件

  • pwa 这个技术其实要想真正用好,还是需要下点功夫,它有它的生命周期,以及它在浏览器中热更新带来的副作用等,需要认真研究。可以参考百度的 lavas 框架发展历史~

const WorkboxPlugin = require('workbox-webpack-plugin')

new WorkboxPlugin.GenerateSW({ clientsClaim: true, //让浏览器立即servece worker被接管 skipWaiting: true, // 更新sw文件后,立即插队到最前面 importWorkboxFrom: 'local', include: [/\.js$/






请到「今天看啥」查看全文