JS
理解深拷贝和浅拷贝?
- 浅拷贝:是指在拷贝对象时,如果是基本数据类型,就是拷贝的这个基本数据类型的值,但如果拷贝的是引用类型数据拷贝的是这个引用数据类型的内存地址,当其中一个对象发生改变时,会导致另一个对象受到影响
- 浅拷贝的实现方式: object.assgin()、对象扩展符
- 深拷贝:是指从内存中完整地拷贝一个对象出来,并在堆内存中为其分配一个新的内存区域来存放,并且修改该对象的属性不会影响到原来的对象。
- 深拷贝的实现方式:1.通过JSON.parse()和JSON.stringify()序列化实现 2.手动递归实现
JavaScript 继承的几种实现方式?
(1)第一种是以原型链的方式来实现继承,但是这种实现方式存在的缺点是,在包含有引用类型的数据时,会被所有的实例对象所共享,容易造成修改的混乱。还有就是在创建子类型的时候不能向超类型传递参数。
(2)第二种方式是使用借用构造函数的方式,这种方式是通过在子类型的函数中调用超类型的构造函数来实现的,这一种方法解决了不能向超类型传递参数的缺点,但是它存在的一个问题就是无法实现函数方法的复用,并且超类型原型定义的方法子类型也没有办法访问到。
(3)第三种方式是组合继承,组合继承是将原型链和借用构造函数组合起来使用的一种方式。通过借用构造函数的方式来实现类型的属性的继承,通过将子类型的原型设置为超类型的实例来实现方法的继承。这种方式解决了上面的两种模式单独使用时的问题,但是由于我们是以超类型的实例来作为子类型的原型,所以调用了两次超类的构造函数,造成了子类型的原型中多了很多不必要的属性。
(4)第四种方式是原型式继承,原型式继承的主要思路就是基于已有的对象来创建新的对象,实现的原理是,向函数中传入一个对象,然后返回一个以这个对象为原型的对象。这种继承的思路主要不是为了实现创造一种新的类型,只是对某个对象实现一种简单继承,ES5 中定义的 Object.create() 方法就是原型式继承的实现。缺点与原型链方式相同。
(5)第五种方式是寄生式继承,寄生式继承的思路是创建一个用于封装继承过程的函数,通过传入一个对象,然后复制一个对象的副本,然后对象进行扩展,最后返回这个对象。这个扩展的过程就可以理解是一种继承。这种继承的优点就是对一个简单对象实现继承,如果这个对象不是我们的自定义类型时。缺点是没有办法实现函数的复用
Javascript中如何实现函数缓存?函数缓存有哪些应用场景?
-
函数缓存:函数缓存指的是将函数的返回值缓存下来,当相同的参数再次调用该函数时,直接返回缓存的结果,而不是再次执行函数。这可以提高程序的执行效率。
-
如何实现:在JavaScript中,可以通过创建一个闭包存储结果来实现函数缓存,也可以使用ES6中的Map对象来存储函数的结果。
-
应用场景:
- 计算量大的函数,比如阶乘、斐波那契数列等,由于计算结果具有重复性,因此可以采用函数缓存来节省计算时间。
- 数据请求函数,当相同的请求参数被多次调用时,可以使用函数缓存来避免重复请求及提高响应速度。
Javascript 数字精度丢失的问题,如何解决?
前提须知: JavaScript 使用 IEEE 754 标准的双精度浮点数,即 64 位二进制表示,其中 1 位表示符号位,11 位表示指数位,52 位表示小数点后的数字。因此,JavaScript 中的数字精度限制在 16 位小数。
- 原因:在计算机角度,计算机算的是二进制,而不是十进制。有些数二进制后变成了无限不循环的数,而计算机可支持浮点数的小数部分可支持到52位,所有两者相加,在转换成十进制,得到的数就不准确了。
- 解决方案: 1.三方库
Math.js
、BigDecimal.js
2.将浮点数转为整数运算,再对结果做除法
VUE
vue2和vue3核心diff算法的区别
Vue 2 使用的是基于递归的双指针的 diff 算法,而 Vue 3 使用的是基于数组的动态规划的 diff 算法。 Vue 3 的算法效率更高,因为它使用了一些优化技巧,例如按需更新、静态标记等。
vuex和pinia
1.vuex有5个属性:
- state: 存放数据源的地方 this.$store.state
- getter:计算数据
- mutation:同步,唯一能修改state数据源的地方 this.$store.commit
- actions:异步 store.dispatch
- modules:模块化
2.pinia有三个属性:
- state:存放数据源的地方
- actions:同步和异步
- getters:计算数据 pinia体积更小(性能更好),pinia可以直接修改state里的数据,而vuex只能通过mutation中去修改。
Vue template 到 render 的过程(vue的编译过程)
- 解析模板为AST:利用parse方法将template转换为AST树,这种树形式的JavaScript对象描述了整个模板的结构。在解析过程中,会使用正则表达式顺序解析模板,构造AST树,其中包含普通元素、表达式和纯文本等节点类型。
- 静态节点优化:对AST进行深度遍历,标记静态节点,这些节点的DOM永远不会改变,因此在后续更新渲染时可以直接跳过静态节点,实现优化
- 生成代码:通过generate方法将AST编译成render函数的字符串,并将静态部分放到staticRenderFns中。最后,通过new Function(render)生成render函数。
如果从0到1自己架构一个vue项目,你会怎么做?
- 从0创建一个项目我大致会做以下事情:项目构建、引入必要插件、代码规范、提交规范、常用库和组件
- 项目构建我会用vite或者create-vue创建项目
- 接下来引必要的插件:路由插件vue-router、状态管理vuex/pinia、ui库则是后台管理的一般是elemnet-plus如果是h5项目则是vant,http工具我一般选择axios
- 还会引入一些常用库如:vueuseprettie,nprogress,图标会使用vite-svg-loader
- 代码规范:prettier,eslint
- 提交规范:githooks+husky+lint-staged+commitlint
- 最后就是目录结构,如api:用来放http的一些接口配置,utils:用来放项目中的工具方法类,views:用来放项目的页面文件等等
nextTick的使用和原理
Vue有个异步更新策略,意思是如果数据变化,Vue不会立刻更新DOM,而是开启一个队列,把组件更新函数保存在队列中,在同一事件循环中发生的所有数据变更会异步的批量更新。这一策略导致我们对数据的修改不会立刻体现在DOM上,此时如果想要获取更新后的DOM状态,就需要使用nextTick。
- 开发时,有两个场景我们会用到nextTick:
- created中想要获取DOM时;
- 响应式数据变化后获取DOM更新后的状态,比如希望获取列表更新后的高度。
vue响应式
所谓数据响应式就是能够使数据变化可以被检测并对这种变化做出响应的机制 采用了"发布-订阅"的设计模式,通过Object.defineProperty()劫持各个属性的getter、setter,在数据变动时调用Dep.notify发布消息给订阅者Watcher,使之更新相应的视图,如果是数组的话,对数组原型链上的方法进行一些修改才能实现监听
vue的生命周期以及其各阶段做的事
Vue 生命周期指的是 Vue 实例在创建、更新、销毁等过程中经历的各个阶段。Vue 生命周期可以分为三个阶段:创建阶段、挂载阶段和销毁阶段。
beforeCreate:通常用于插件开发中执行一些初始化任务
created:组件初始化完毕,可以访问各种数据,获取接口数据等
mounted:dom已创建,可用于获取访问数据和dom元素;访问子组件等。
beforeUpdate :此时view
层还未更新,可用于获取更新前各种状态
updated :完成view
层的更新,更新后,所有状态已是最新
beforeunmount:实例被销毁前调用,可用于一些定时器或订阅的取消
unmounted:销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器
Vue要做权限管理该怎么做?控制到按钮级别的权限怎么做?
1.权限管理一般分为页面权限和按钮权限
2.具体实现的时候分后端和前端两种方案:
-
前端方案会把所有路由信息在前端配置 ,通过路由守卫要求用户登录,用户登录后根据角色过滤出路由表 。比如我会配置一个
asyncRoutes
数组,需要认证的页面在其路由的meta
中添加一个roles
字段,等获取用户角色之后取两者的交集,若结果不为空则说明可以访问。此过滤过程结束,剩下的路由就是该用户能访问的页面,最后通过router.addRoutes(accessRoutes)
方式动态添加路由即可。 -
后端方案会把所有页面路由信息存在数据库 中,用户登录的时候根据其角色查询得到其能访问的所有页面路由信息 返回给前端,前端再通过
addRoutes
动态添加路由信息 -
按钮权限的控制通常会实现一个指令 ,例如
v-permission
,将按钮要求角色通过值传给v-permission指令 ,在指令的moutned
钩子中可以判断当前用户角色和按钮是否存在交集,有则保留按钮,无则移除按钮。
如何实现图片懒加载?
1.使用img标签的 loading 属性
loading 属性指定浏览器是应立即加载图像还是延迟加载图像。 设置 loading="lazy" 只有鼠标滚动到该图片所在位置才会显示
2.自定义指令 vue实现懒加载指令v-lazy
主要是通过IntersectionObserver
实现的,IntersectionObserver
是一个在浏览器中提供的用于异步观察目标元素与其祖先元素或视口交叉情况的API。它可以有效地用于实现懒加载、无限滚动等场景。
js
const lazyLoad = {
// mounted 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted(el, binding) {
// 如果有需要可以先设置src为 loading 图
// el.setAttribute('src', 'loading 图的路径');
const options = {
rootMargin: '0px',
threshold: 0.1,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// binding 是一个对象,binding.value 是传递给指令的值
el.setAttribute('src', binding.value);
observer.unobserve(el);
}
});
}, options);
observer.observe(el);
},
};
export default lazyLoad;
vue3.0哪些优化
1.源码优化
引入了ts
2.性能优化
体积优化
引入tree-shaking
,可以将无用模块"剪辑",仅打包需要的,使打包的整体体积变小了
编译优化
- Patch Flag:在Vue 3.0中,编译的生成vnode会根据节点patch的标记,只对需要重新渲染的数据进行响应式更新,不需要更新的数据不会重新渲染,从而大大提高了渲染性能。
- 静态属性提升:做了静态提升后,未参与更新的元素,被
放置在render 函数外
,每次渲染的时候只要取出
即可。同时该元素会被打上静态标记值为-1
,特殊标志是负整数
表示永远不会用于Diff
。 - 事件监听缓存:默认情况下绑定事件行为会被视为动态绑定(
没开启事件监听器缓存
),所以每次
都会去追踪它的变化。开启事件侦听器缓存
后,没有了静态标记。也就是说下次diff算法
的时候直接使用
。
数据劫持优化
从object.defineProperty改成了proxy,proxy可以监听整个对象。
3.语法优化
可以使用composition API,优化逻辑组织、优化逻辑复用
Proxy 与 Object.defineProperty 对比
-
Proxy 是一个对象的代理,Object.defineProperty只能代理某个属性
-
Proxy可以在读取时递归代理,Object.defineProperty只能在创建时递归所有
-
对象上新增属性,Proxy可以监听到,Object.defineProperty不能
-
数组修改,Proxy可以监听到, object.defineProperty不能
-
Proxy兼容性差
构建工具
vite的热更新原理
- 1.webSocket,同时监听本地的文件变化。
- 2.当用户修改了本地的文件时,webSocket的服务端会拿到变化的文件的ID或者其他标识,并推送给客户端
- 3.客户端获取到变化的文件信息后,便去请求最新的文件并刷新页面
webpack的构建流程
在了解 Webpack 原理前,需要掌握以下几个核心概念,以方便后面的理解:
- entry :入口,Webpack 执行构建的第一步将从
entry
开始,可抽象成输入 - module :模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的
entry
开始递归找出所有依赖的模块 - chunk :代码块,一个
chunk
由多个模块组合而成,用于 代码合并与分割 - loader:模块转换器,用于把模块原内容按照需求转换成新内容
- plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情
webpack的构建流程:
- 初始化参数:从配置文件和
Shell
语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数 - 编译构建流程:从Entry发出,针对每个Module串行调用对应的Loader去翻译文件内容,再找到该Moudle依赖的Module,递归地进行编译处理
- 输出流程:对编译后的Module组合成Chunk,把Chunk转换成文件,输出到文件系统
网络和浏览器
介绍一下你对浏览器内核的理解
- 主要分成两部分:渲染引擎(layout engineer或 Rendering Engine)和JS引擎。
- 渲染引擎:负责取得网页的内容(HTML、XML、图像)、整理讯息(例如加入CSS等),以及计算网页的显示方式,然后渲染到用户的屏幕上。
- JS 引擎:解析和执行 javascript 来实现逻辑和控制 DOM 进行交互。
- 最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于只指渲染引擎。
输入url后经历了哪些过程
- 解析 URL,判断是否命中缓存(DNS prefetch)
- 访问 DNS 服务器,将域名解析获取 IP 地址
- 三次握手建立 TCP 连接
- 发送 HTTP 请求
- 服务器处理请求并返回 HTTP 报文
- 浏览器解析渲染页面
- 断开连接:TCP 四次挥手
浏览器重绘和回流的区别?
- 回流: 部分渲染树或整个渲染树需要重新分析且节点尺寸需要重新计算,表现为重新生成布局,重新排列元素
- 重绘: 由于节点的几何属性发生改变或样式改变,例如元素背景元素,表现为某些元素的外观被改变
重绘不一定导致回流,但回流一定会导致重绘
避免白屏,提高CSS的加载速度
- 使用CDN(CDN会根据你的网络状况,挑选最近的一个具有缓存内容的节点为你提供资源,因此可以减少加载时间)
- 对CSS进行压缩,按需加载css
- 合理使用缓存
- 减少http请求数,合并css文件
http缓存,强缓存和协商缓存的区别
所谓http缓存其实就是指在本地使用的计算机中开辟一个内存区,同时也开辟一个硬盘作为数据传输的缓冲区,然后用这个缓冲区来暂时保存用户以前访问过的信息。
HTTP缓存通常分为强缓存和协商缓存。强缓存是指浏览器直接从本地缓存中获取响应,而不发送请求到服务器。协商缓存是指浏览器在发送请求到服务器之前,先检查本地缓存是否过期,如果过期则发送请求到服务器更新缓存。
强制缓存的实现主要依赖于HTTP响应头中的Cache-Control
和Expires
字段。Cache-Control
字段用于指定缓存的行为和策略,而Expires
字段则指定了资源的过期时间。当浏览器接收到这些字段后,它会根据指令将资源存储在本地缓存中,并在后续请求时检查缓存。
协商缓存的实现主要依赖于HTTP响应头中的Last-Modified
和ETag
字段。Last-Modified
字段用于记录资源的最后修改时间,而ETag
字段则是一个由服务器生成的唯一标识符,用于表示资源的特定版本。当浏览器再次请求资源时,它会将这两个字段发送给服务器,服务器会根据这些信息来判断资源是否被修改。
当浏览器访问一个已经访问过的资源是,它的步骤是:
1.先看是否命中强缓存,命中的话直接使用缓存
2.没命中强缓存,则会发送请求到服务器看是否命中 协商缓存
3.如果命中了协商缓存,服务器会返回304告诉浏览器可以使用本地缓存
4.没命中协商缓存,则服务器会返回新的资源给浏览器
前端性能优化方案
可以从DOM层面 ,CSS样式层面 和JS逻辑层面 分别入手,大概给出以下几种:
(1) 减少DOM的访问次数,可以将DOM缓存到变量中;
(2) 减少重绘 和回流 ,任何会导致重绘 和回流 的操作都应减少执行,可将多次操作合并为一次 ;
(3) 尽量采用事件委托 的方式进行事件绑定,避免大量绑定导致内存占用过多;
(4) css层级尽量扁平化 ,避免过多的层级嵌套,尽量使用特定的选择器 来区分;
(5) 动画尽量使用CSS3动画属性 来实现,开启GPU硬件加速;
(6) 图片在加载前提前指定宽高 或者脱离文档流 ,可避免加载后的重新计算导致的页面回流;
(7) css文件在<head>
标签中引入,js文件在<body>
标签中引入,优化关键渲染路径 ;
(8) 加速或者减少HTTP请求,使用CDN加载静态资源 ,合理使用浏览器强缓存 和协商缓存 ,小图片可以使用Base64 来代替,合理使用浏览器的预取指令prefetch 和预加载指令preload ;
(9) 压缩混淆代码 ,删除无用代码 ,代码拆分 来减少文件体积;
(10) 小图片使用雪碧图 ,图片选择合适的质量 、尺寸 和格式,避免流量浪费。
设计模式
工厂模式
js
class Man {
constructor(name) {
this.name = name
}
alertName() {
alert(this.name)
}
}
class Factory {
static create(name) {
return new Man(name)
}
}
Factory.create('yck').alertName()
可以想象一个场景。假设有一份很复杂的代码需要用户去调用,但是用户并不关心这些复杂的代码,只需要你提供给我一个接口去调用,用户只负责传递需要的参数,至于这些参数怎么使用,内部有什么逻辑是不关心的,只需要你最后返回我一个实例。这个构造过程就是工厂。
单例模式
单例模式很常用,比如全局缓存、全局状态管理等等这些只需要一个对象,就可以使用单例模式 单例模式的核心就是保证全局只有一个对象可以访问。因为 JS 是门无类的语言,所以别的语言实现单例的方式并不能套入 JS 中,我们只需要用一个变量确保实例只创建一次就行,以下是如何实现单例模式的例子
js
class Singleton {
constructor() {}
}
Singleton.getInstance = (function() {
let instance
return function() {
if (!instance) {
instance = new Singleton()
}
return instance
}
})()
let s1 = Singleton.getInstance()
let s2 = Singleton.getInstance()
console.log(s1 === s2) // true
发布-订阅模式
发布-订阅模式也叫做观察者模式。通过一对一或者一对多的依赖关系,当对象发生改变时,订阅方都会收到通知。在实际代码中其实发布-订阅模式也很常见 在 Vue 中,如何实现响应式也是使用了该模式。对于需要实现响应式的对象来说,在 get
的时候会进行依赖收集,当改变了对象的属性时,就会触发派发更新。
代理模式
代理是为了控制对象的访问,不让外部直接访问到对象。再现实生活中,也有很多代理的场景。比如国外商品代购。 实际代码:让父节点作为代理去拿到真实点击的子节点。
适配器模式
适配器用来解决两个接口不兼容的情况,不需要改变已有的接口,通过包装一层的方式实现两个接口的正常协作。 在 Vue 中,我们其实经常使用到适配器模式。比如父组件传递给子组件一个时间戳属性,组件内部需要将时间戳转为正常的日期显示,一般会使用 computed
来做转换这件事情,这个过程就使用到了适配器模式。
js
class Plug {
getName() {
return '港版插头'
}
}
class Target {
constructor() {
this.plug = new Plug()
}
getName() {
return this.plug.getName() + ' 适配器转二脚插头'
}
}
let target = new Target()
target.getName() // 港版插头 适配器转二脚插头
装饰模式
装饰模式不需要改变已有的接口,作用是给对象添加功能。就像我们经常需要给手机戴个保护套防摔一样,不改变手机自身,给手机添加了保护套提供防摔功能。
以下是如何实现装饰模式的例子,使用了 ES7 中的装饰器语法
js
function readonly(target, key, descriptor) {
descriptor.writable = false
return descriptor
}
class Test {
@readonly
name = 'yck'
}
let t = new Test()
t.yck = '111' // 不可修改
Hybrid
一套好的 Hybrid 架构方案能让 App 既能拥有 极致的体验和性能 ,同时也能拥有 Web技术 灵活的开发模式、跨平台能力以及热更新机制。
1.混合方案简析
Hybrid App,俗称混合应用,即混合了Native技术与web技术进行开发的移动应用。现在比较流行的混合方案有三种,主要是在UI渲染机制上的不同:
Webview UI:
- 通过 JSBridge 完成 H5 与 Native 的双向通讯,并 基于 Webview 进行页面的渲染;
- 优势: 简单易用,架构门槛/成本较低,适用性与灵活性极强;
- 劣势: Webview 性能局限,在复杂页面中,表现远不如原生页面;
Native UI:
- 通过 JSBridge 赋予 H5 原生能力,并进一步将 JS 生成的虚拟节点树(Virtual DOM)传递至 Native 层,并使用 原生系统渲染。
- 优势: 用户体验基本接近原生,且能发挥 Web技术 开发灵活与易更新的特性;
- 劣势: 上手/改造门槛较高,最好需要掌握一定程度的客户端技术。相比于常规 Web开发,需要更高的开发调试、问题排查成本;
小程序
- 通过更加定制化的 JSBridge,赋予了 Web 更大的权限,并使用双 WebView 双线程的模式隔离了 JS逻辑 与 UI渲染,形成了特殊的开发模式,加强了 H5 与 Native 混合程度,属于第一种方案的优化版本;
- 优势: 用户体验好于常规 Webview 方案,且通常依托的平台也能提供更为友好的开发调试体验以及功能;
- 劣势: 需要依托于特定的平台的规范限定
其他
输出0-5
方案一:(闭包和自执行函数的理解)
js
for(var i=0;i<6;i++){
(function(j){
setTimeout(()=>{
console.log(j)
},1000)
})(i)
}
方案二:(对setTimeout第二个参数的了解)
js
for(var i=0;i<6;i++){
setTimeout((j)=>{
console.log(j)
},1000,i)
}
按时间每1秒输出0-5(对异步处理的理解)
方案一:
js
const tasks = []
for(var i=0;i<6;i++) {
((j)=>{
tasks.push(new Promise((resolve)=>{
setTimeout(() => {
console.log(j)
}, 1000*j)
resolve()
}))
})(i)
}
Promise.all(tasks)
方案二(最优解):
js
const sleep = (time)=> new Promise(resolve=>{
setTimeout(resolve,time)
)
(async () => { // 声明即执行的 async 函数表达式
for (var i = 0; i < 6; i++) {
if (i > 0) {
await sleep(1000);
}
console.log(i);
}
})();
简单说说怎么实现的接入企业微信的sso登录
-
注册应用与授权:
- 在企业微信后台注册你的应用,并获取到
Corp ID
、App ID
、App Secret
等信息。 - 授权:在企业微信后台为你的应用配置授权信息,包括登录授权和通讯录授权等。
- 在企业微信后台注册你的应用,并获取到
-
前端跳转至企业微信授权页面:
- 前端通过跳转到特定的企业微信授权页面,引导用户进行登录。
- 页面中通常会包含一个登录按钮,用户点击后,企业微信会验证用户的身份并跳转回你的应用。
-
处理跳转回应用的请求:
- 根据企业微信返回的
code
,你的应用前端需要发送请求到你的应用服务器。 - 服务器接收到
code
后,需要使用App Secret
请求企业微信服务器获取access_token
。
- 根据企业微信返回的
-
使用
access_token
获取用户信息:- 应用服务器使用
access_token
请求企业微信服务器,获取用户的基本信息。
- 应用服务器使用
-
用户信息验证:
- 服务器端验证用户信息,确保用户身份的有效性。
-
登录成功后的处理:
- 服务器端生成登录令牌(如JWT),并返回给前端。
- 前端保存登录状态,并可以进行页面跳转,展示登录后的界面。
单点登录通常需要一个集中的认证中心,当用户在应用a登录后,认证中心会记录用户的登录状态。 当用户尝试访问应用B时,认证中心直接位应用B提供用户的登录状态验证,从而实现单点登录。 而使用企业微信的sso登录,认证中心就是企业微信的服务器,前端只需要重定向到企业微信的认证地址上,带上在回调地址和appid,跳转到企业微信认证页。用户授权后,企业微信会跳转回你的回调地址,并附带code参数,从url上获取到code并把一些必要的信息传給服务端就行了。
对自己未来的发展规划是什么
- 对自身已经掌握的技术持续精进,并通过技术手段去回馈团队和业务(如前端架构、异常监控、性能优化、指标体系等)
- 在某一个技术方向上做到突出,能够沉淀出相应方法论,并建设出系统性的平台,在部门及公司内部普及应用
- 保持对新技术的热情,持续扩宽技术广度,对团队的技术栈持续迭代,保持团队整体技术的竞争力
具体:(下篇)中高级前端大厂面试秘籍,寒冬中为您保驾护航,直通大厂 - 掘金 (juejin.cn)
参考文献
构建流程 - Webpack Guidebook (tsejx.github.io)