作者主頁:
https://juejin.cn/user/2594503172831208
工程化目的是為了提升團隊的開發(fā)效率、提高項目的質量
例如大家所熟悉的構建工具、性能分析與優(yōu)化、組件庫等知識,都屬于工程化的內容
這篇文章的內容,是我這幾年對工程化的實踐經驗與收獲總結
文中大部分的內容,主要是以 代碼示例 + 分析總結 + 實踐操作
來講解的,始終圍繞實用可操作性
來說明,爭取讓小伙伴們更容易理解和運用
看十篇講 webpack 的文章,可能不如手寫一個 mini 版的 webpack 來的透徹
工程化是一個優(yōu)秀工程師的必修課,也是一個重要的分水嶺
Webpack
是前端最常用的構建工具,重要程度無需多言
之前看過很多關于 Webpack 的文章,總是感覺云里霧里,現(xiàn)在換一種方式,我們一起來解密它,嘗試打開這個盲盒
別擔心
我們不需要去掌握具體的實現(xiàn)細節(jié),而是通過這個案例,了解 webpack 的整體打包流程,明白這個過程中做了哪些事情,最終輸出了什么結果即可
minipack.js
const fs = require('fs');
const path = require('path');
// babylon解析js語法,生產AST 語法樹
// ast將js代碼轉化為一種JSON數(shù)據(jù)結構
const babylon = require('babylon');
// babel-traverse是一個對ast進行遍歷的工具, 對ast進行替換
const traverse = require('babel-traverse').default;
// 將es6 es7 等高級的語法轉化為es5的語法
const { transformFromAst } = require('babel-core');
// 每一個js文件,對應一個id
let ID = 0;
// filename參數(shù)為文件路徑, 讀取內容并提取它的依賴關系
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
// 獲取該文件對應的ast 抽象語法樹
const ast = babylon.parse(content, {
sourceType: 'module'
});
// dependencies保存所依賴的模塊的相對路徑
const dependencies = [];
// 通過查找import節(jié)點,找到該文件的依賴關系
// 因為項目中我們都是通過 import 引入其他文件的,找到了import節(jié)點,就找到這個文件引用了哪些文件
traverse(ast, {
ImportDeclaration: ({ node }) => {
// 查找import節(jié)點
dependencies.push(node.source.value);
}
});
// 通過遞增計數(shù)器,為此模塊分配唯一標識符, 用于緩存已解析過的文件
const id = ID++;
// 該`presets`選項是一組規(guī)則,告訴`babel`如何傳輸我們的代碼.
// 用`babel-preset-env`將代碼轉換為瀏覽器可以運行的東西.
const { code } = transformFromAst(ast, null, {
presets: ['env']
});
// 返回此模塊的相關信息
return {
id, // 文件id(唯一)
filename, // 文件路徑
dependencies, // 文件的依賴關系
code // 文件的代碼
};
}
// 我們將提取它的每一個依賴文件的依賴關系,循環(huán)下去:找到對應這個項目的`依賴圖`
function createGraph(entry) {
// 得到入口文件的依賴關系
const mainAsset = createAsset(entry);
const queue = [mainAsset];
for (const asset of queue) {
asset.mapping = {};
// 獲取這個模塊所在的目錄
const dirname = path.dirname(asset.filename);
asset.dependencies.forEach((relativePath) => {
// 通過將相對路徑與父資源目錄的路徑連接,將相對路徑轉變?yōu)榻^對路徑
// 每個文件的絕對路徑是固定、唯一的
const absolutePath = path.join(dirname, relativePath);
// 遞歸解析其中所引入的其他資源
const child = createAsset(absolutePath);
asset.mapping[relativePath] = child.id;
// 將`child`推入隊列, 通過遞歸實現(xiàn)了這樣它的依賴關系解析
queue.push(child);
});
}
// queue這就是最終的依賴關系圖譜
return queue;
}
// 自定義實現(xiàn)了require 方法,找到導出變量的引用邏輯
function bundle(graph) {
let modules = '';
graph.forEach((mod) => {
modules += `${mod.id}: [
function (require, module, exports) { ${mod.code} },
${JSON.stringify(mod.mapping)},
],`;
});
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports : {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`;
return result;
}
// ?? 項目的入口文件
const graph = createGraph('./example/entry.js');
const result = bundle(graph);
// ?? 創(chuàng)建dist目錄,將打包的內容寫入main.js中
fs.mkdir('dist', (err) => {
if (!err)
fs.writeFile('dist/main.js', result, (err1) => {
if (!err1) console.log('打包成功');
});
});
注:mini版的Webpack
未涉及 loader 和 plugin 等復雜功能,只是一個非常簡化的例子
1)從入口文件開始解析
2)查找入口文件引入了哪些 js 文件,找到依賴關系
3)遞歸遍歷引入的其他 js,生成最終的依賴關系圖譜
4)同時將 ES6 語法轉化成 ES5
5)最終生成一個可以在瀏覽器加載執(zhí)行的 js 文件
在目錄下創(chuàng)建以下 4 個文件
1)創(chuàng)建入口文件entry.js
import message from './message.js';
// 將message的內容顯示到頁面中
let p = document.createElement('p');
p.innerHTML = message;
document.body.appendChild(p);
2)創(chuàng)建message.js
import { name } from './name.js';
export default `hello ${name}!`;
3)創(chuàng)建name.js
export const name = 'Webpack';
4)創(chuàng)建index.html
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>Webpack App</title>
<meta name='viewport' content='width=device-width, initial-scale=1'></head>
<body>
<!-- 引入打包后的main.js -->
<script src='./dist/main.js'></script></body>
</html>
5) 執(zhí)行打包
運行node minipack.js
,dist 目錄下生成 main.js
6) 瀏覽器打開 index.html
頁面上顯示hello Webpack!
mini-webpack 的 github 源碼地址[1]
分析dist/main.js
1)文件里是一個立即執(zhí)行函數(shù)
2)該函數(shù)接收的參數(shù)是一個對象,該對象有 3 個屬性0
代表entry.js
;1
代表message.js
;2
代表name.js
dist/main.js 代碼
// 文件里是一個立即執(zhí)行函數(shù)
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
// ?? 第四步 跳轉到這里 此時mapping[name] = 1,繼續(xù)執(zhí)行require(1)
// ?? 第六步 又跳轉到這里 此時mapping[name] = 2,繼續(xù)執(zhí)行require(2)
return require(mapping[name]);
}
// 創(chuàng)建module對象
const module = { exports: {} };
// ?? 第二步 執(zhí)行fn
fn(localRequire, module, module.exports);
return module.exports;
}
// ?? 第一步 執(zhí)行require(0)
require(0);
})({
// 立即執(zhí)行函數(shù)的參數(shù)是一個對象,該對象有3個屬性
// 0 代表entry.js;
// 1 代表message.js
// 2 代表name.js
0: [
function(require, module, exports) {
'use strict';
// ?? 第三步 跳轉到這里 繼續(xù)執(zhí)行require('./message.js')
var _message = require('./message.js');
// ?? 第九步 跳到這里 此時_message為 {default: 'hello Webpack!'}
var _message2 = _interopRequireDefault(_message);
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
var p = document.createElement('p');
// ?? 最后一步 將_message2.default: 'hello Webpack!'寫到p標簽中
p.innerHTML = _message2.default;
document.body.appendChild(p);
},
{ './message.js': 1 }
],
1: [
function(require, module, exports) {
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true
});
// ?? 第五步 跳轉到這里 繼續(xù)執(zhí)行require('./name.js')
var _name = require('./name.js');
// ?? 第八步 跳到這里 此時_name為{name: 'Webpack'}, 在exports對象上設置default屬性,值為'hello Webpack!'
exports.default = 'hello ' + _name.name + '!';
},
{ './name.js': 2 }
],
2: [
function(require, module, exports) {
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true
});
// ?? 第七步 跳到這里 在傳入的exports對象上添加name屬性,值為'Webpack'
var name = (exports.name = 'Webpack');
},
{}
]
});
1)整體大致分為 10 步,第一步從require(0)
開始執(zhí)行,調用內置的自定義 require 函數(shù),跳轉到第二步,執(zhí)行fn
函數(shù)
2)執(zhí)行第三步require('./message.js')
,繼續(xù)跳轉到第四步 require(mapping['./message.js'])
, 最終轉化為require(1)
3)繼續(xù)執(zhí)行require(1)
,獲取modules[1]
,也就是執(zhí)行message.js
的內容
4)第五步require('./name.js')
,最終轉化為require(2)
,執(zhí)行name.js
的內容
5)通過遞歸調用,將代碼中導出的屬性,放到exports
對象中,一層層導出到最外層
6)最終通過_message2.default
獲取導出的值,頁面顯示hello Webpack!
總結一下 webpack 完整的打包流程
1)webpack 從項目的entry
入口文件開始遞歸分析,調用所有配置的 loader
對模塊進行編譯
因為 webpack 默認只能識別 js 代碼,所以如 css 文件、.vue 結尾的文件,必須要通過對應的 loader 解析成 js 代碼后,webpack 才能識別
2)利用babel(babylon)
將 js 代碼轉化為ast抽象語法樹
,然后通過babel-traverse
對 ast 進行遍歷
3)遍歷的目的找到文件的import引用節(jié)點
因為現(xiàn)在我們引入文件都是通過 import 的方式引入,所以找到了 import 節(jié)點,就找到了文件的依賴關系
4)同時每個模塊生成一個唯一的 id,并將解析過的模塊緩存
起來,如果其他地方也引入該模塊,就無需重新解析,最后根據(jù)依賴關系生成依賴圖譜
5)遞歸遍歷所有依賴圖譜的模塊,組裝成一個個包含多個模塊的 Chunk(塊)
6)最后將生成的文件輸出到 output
的目錄中
什么是 webpack 熱更新?
開發(fā)過程中,代碼發(fā)生變動后,webpack 會重新編譯,編譯后瀏覽器替換修改的模塊,局部更新,無需刷新整個頁面
好處:節(jié)省開發(fā)時間、提升開發(fā)體驗
熱更新原理
主要是通過websocket
實現(xiàn),建立本地服務和瀏覽器的雙向通信。當代碼變化,重新編譯后,通知瀏覽器請求更新的模塊,替換原有的模塊
1) 通過webpack-dev-server
開啟server服務
,本地 server 啟動之后,再去啟動 websocket 服務,建立本地服務和瀏覽器的雙向通信
2) webpack 每次編譯后,會生成一個Hash值
,Hash 代表每一次編譯的標識。本次輸出的 Hash 值會編譯新生成的文件標識,被作為下次熱更新的標識
3)webpack監(jiān)聽文件變化
(主要是通過文件的生成時間判斷是否有變化),當文件變化后,重新編譯
4)編譯結束后,通知瀏覽器請求變化的資源,同時將新生成的 hash 值傳給瀏覽器,用于下次熱更新使用
5)瀏覽器拿到更新后的模塊后,用新模塊替換掉舊的模塊,從而實現(xiàn)了局部刷新
輕松理解 webpack 熱更新原理[2]
深入淺出 Webpack[3]
帶你深度解鎖 Webpack 系列(基礎篇)[4]
作用:擴展 webpack 功能
工作原理
webpack 通過內部的事件流機制保證了插件的有序性,底層是利用發(fā)布訂閱模式
,webpack 在運行過程中會廣播事件,插件只需要監(jiān)聽它所關心的事件,在特定的時機對資源做處理
// 自定義一個名為MyPlugin插件,該插件在打包完成后,在控制臺輸出'打包已完成'
class MyPlugin {
// 原型上需要定義apply 的方法
apply(compiler) {
// 通過compiler獲取webpack內部的鉤子
compiler.hooks.done.tap('My Plugin', (compilation, cb) => {
console.log('打包已完成');
// 分為同步和異步的鉤子,異步鉤子必須執(zhí)行對應的回調
cb();
});
}
}
module.exports = MyPlugin;
1)在vue.config.js
引入該插件
const MyPlugin = require('./MyPlugin.js')
2)在configureWebpack
的 plugins 列表中注冊該插件
module.exports = {
configureWebpack: {
plugins: [new MyPlugin()]
}
};
3)執(zhí)行項目的打包命令
當項目打包成功后,會在控制臺輸出:打包已完成
1)Plugin 的本質是一個 node 模塊
,這個模塊導出一個 JavaScript 類
2)它的原型上需要定義一個apply
的方法
3)通過compiler
獲取 webpack 內部的鉤子,獲取 webpack 打包過程中的各個階段
鉤子分為同步和異步的鉤子,異步鉤子必須執(zhí)行對應的回調
4)通過compilation
操作 webpack 內部實例特定數(shù)據(jù)
5)功能完成后,執(zhí)行 webpack 提供的 cb 回調
compiler 上暴露的一些常用的鉤子簡介
鉤子 | 類型 | 調用時機 |
---|---|---|
run | AsyncSeriesHook | 在編譯器開始讀取記錄前執(zhí)行 |
compile | SyncHook | 在一個新的 compilation 創(chuàng)建之前執(zhí)行 |
compilation | SyncHook | 在一次 compilation 創(chuàng)建后執(zhí)行插件 |
make | AsyncParallelHook | 完成一次編譯之前執(zhí)行 |
emit | AsyncSeriesHook | 在生成文件到 output 目錄之前執(zhí)行,回調參數(shù): compilation |
afterEmit | AsyncSeriesHook | 在生成文件到 output 目錄之后執(zhí)行 |
assetEmitted | AsyncSeriesHook | 生成文件的時候執(zhí)行,提供訪問產出文件信息的入口,回調參數(shù):file,info |
done | AsyncSeriesHook | 一次編譯完成后執(zhí)行,回調參數(shù):stats |
插件名稱 | 作用 |
---|---|
html-webpack-plugin | 生成 html 文件,引入公共的 js 和 css 資源 |
webpack-bundle-analyzer | 對打包后的文件進行分析,生成資源分析圖 |
terser-webpack-plugin | 代碼壓縮,移除 console.log 打印等 |
HappyPack Plugin | 開啟多線程打包,提升打包速度 |
Dllplugin | 動態(tài)鏈接庫,將項目中依賴的三方模塊抽離出來,單獨打包 |
DllReferencePlugin | 配合 Dllplugin,通過 manifest.json 映射到相關的依賴上去 |
clean-webpack-plugin | 清理上一次項目生成的文件 |
vue-skeleton-webpack-plugin | vue 項目實現(xiàn)骨架屏 |
揭秘 webpack-plugin[5]
Loader 作用
webpack 只能直接處理 js 格式的資源,任何非 js 文件都必須被對應的loader
處理轉換為 js 代碼
一個簡單的style-loader
// 作用:將css內容,通過style標簽插入到頁面中
// source為要處理的css源文件
function loader(source) {
let style = `
let style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.innerHTML = ${source};
document.head.appendChild(style)`;
return style;
}
module.exports = loader;
1)在vue.config.js
引入該 loader
const MyStyleLoader = require('./style-loader')
2)在configureWebpack
中添加配置
module.exports = {
configureWebpack: {
module: {
rules: [
{
// 對main.css文件使用MyStyleLoader處理
test: /main.css/,
loader: MyStyleLoader
}
]
}
}
};
3)項目重新編譯main.css
樣式已加載到頁面中
loader 的本質是一個 node
模塊,該模塊導出一個函數(shù),函數(shù)接收source(源文件)
,返回處理后的source
相同優(yōu)先級的 loader 鏈,執(zhí)行順序為:從右到左,從下到上
如use: ['loader1', 'loader2', 'loader3']
,執(zhí)行順序為 loader3 → loader2 → loader1
名稱 | 作用 |
---|---|
style-loader | 用于將 css 編譯完成的樣式,掛載到頁面 style 標簽上 |
css-loader | 用于識別 .css 文件, 須配合 style-loader 共同使用 |
sass-loader/less-loader | css 預處理器 |
postcss-loader | 用于補充 css 樣式各種瀏覽器內核前綴 |
url-loader | 處理圖片類型資源,可以轉 base64 |
vue-loader | 用于編譯 .vue 文件 |
worker-loader | 通過內聯(lián) loader 的方式使用 web worker 功能 |
style-resources-loader | 全局引用對應的 css,避免頁面再分別引入 |
揭秘 webpack-loader[6]
webpack5 模塊聯(lián)邦(Module Federation)
使 JavaScript 應用,得以從另一個 JavaScript 應用中動態(tài)的加載代碼,實現(xiàn)共享依賴,用于前端的微服務化
比如項目A
和項目B
,公用項目C
組件,以往這種情況,可以將 C 組件發(fā)布到 npm 上,然后 A 和 B 再具體引入。當 C 組件發(fā)生變化后,需要重新發(fā)布到 npm 上,A 和 B 也需要重新下載安裝
使用模塊聯(lián)邦后,可以在遠程模塊的 Webpack 配置中,將 C 組件模塊暴露出去,項目 A 和項目 B 就可以遠程進行依賴引用。當 C 組件發(fā)生變化后,A 和 B 無需重新引用
模塊聯(lián)邦利用 webpack5 內置的ModuleFederationPlugin
插件,實現(xiàn)了項目中間相互引用的按需熱插拔
重要參數(shù)說明
1)name
當前應用名稱,需要全局唯一
2)remotes
可以將其他項目的 name
映射到當前項目中
3)exposes
表示導出的模塊,只有在此申明的模塊才可以作為遠程依賴被使用
4)shared
是非常重要的參數(shù),制定了這個參數(shù),可以讓遠程加載的模塊對應依賴,改為使用本地項目的依賴,如 React 或 ReactDOM
配置示例
new ModuleFederationPlugin({
name: 'app_1',
library: { type: 'var', name: 'app_1' },
filename: 'remoteEntry.js',
remotes: {
app_02: 'app_02',
app_03: 'app_03',
},
exposes: {
antd: './src/antd',
button: './src/button',
},
shared: ['react', 'react-dom'],
}),
精讀《Webpack5 新特性 - 模塊聯(lián)邦》[7]
Webpack 5 模塊聯(lián)邦引發(fā)微前端的革命?[8]
Webpack 5 升級內容(二:模塊聯(lián)邦)[9]
Vite 被譽為下一代的構建工具
上手了幾個項目后,果然名不虛傳,熱更新速度真的是快的飛起!
1)Vite 利用瀏覽器支持原生的es module
模塊,開發(fā)時跳過打包的過程,提升編譯效率
2)當通過 import 加載資源時,瀏覽器會發(fā)出 HTTP 請求對應的文件,Vite攔截到該請求
,返回對應的模塊文件
es module 簡單示例
<script type='module'>import { a } from './a.js'</script>
1)當聲明一個 script 標簽類型為 module
時,瀏覽器將對其內部的 import 引用發(fā)起 HTTP 請求,獲取模塊內容
2)瀏覽器將發(fā)起一個對 HOST/a.js
的 HTTP 請求,獲取到內容之后再執(zhí)行
Vite 的限制
Vite 主要對應的場景是開發(fā)模式(生產模式是用 rollup 打包
)
Vite 熱更新的速度不會隨著模塊增多而變慢
1)Webpack 的熱更新原理:一旦某個依賴(比如上面的 a.js)改變,就將這個依賴所處的 整個module
更新,并將新的 module 發(fā)送給瀏覽器重新執(zhí)行
試想如果依賴越來越多,就算只修改一個文件,熱更新的速度會越來越慢
2)Vite 的熱更新原理:如果 a.js 發(fā)生了改變,只會重新編譯這個文件 a,而其余文件都無需重新編譯
所以理論上 Vite 熱更新的速度不會隨著文件增加而變慢
推薦珠峰的從零手寫 vite 視頻[10]
vite 的實現(xiàn)流程
1)通過koa
開啟一個服務,獲取請求的靜態(tài)文件內容
2)通過es-module-lexer
解析 ast
拿到 import 的內容
3)判斷 import 導入模塊是否為三方模塊
,是的話,返回node_module
下的模塊, 如 import vue
返回 import './@modules/vue'
4)如果是.vue文件
,vite 攔截對應的請求,讀取.vue 文件內容進行編譯,通過compileTemplate
編譯模板,將template轉化為render函數(shù)
5)通過 babel parse 對 js 進行編譯,最終返回編譯后的 js 文件
尤雨溪幾年前開發(fā)的“玩具 vite”[11]
Vite 原理淺析[12]
這里先聊一下AST抽象語法樹
,因為AST
是babel
的核心
什么是 AST ?
AST
是源代碼的抽象語法結構的樹狀表現(xiàn)形式
在 js 世界中,可以認為抽象語法樹(AST)是最底層
一個簡單的 AST 示例
let a = 1
,轉化成 AST 的結果
{
'type': 'Program',
'start': 0,
'end': 9,
'body': [
{
'type': 'VariableDeclaration',
'start': 0,
'end': 9,
'declarations': [
{
'type': 'VariableDeclarator',
'start': 4,
'end': 9,
'id': {
'type': 'Identifier',
'start': 4,
'end': 5,
'name': 'a'
},
'init': {
'type': 'Literal',
'start': 8,
'end': 9,
'value': 1,
'raw': '1'
}
}
],
'kind': 'let'
}
],
'sourceType': 'module'
}
AST 抽象語法樹的結構,可以通過AST 網(wǎng)站[13]在線輸入代碼查看
AST 抽象語法樹——最基礎的 javascript 重點知識,99%的人根本不了解[14]
Babel
是一個 JS 編譯器
,把我們的代碼轉成瀏覽器可以運行的代碼
作用
babel 主要用于將新版本的代碼轉換為向后兼容的 js 語法(Polyfill
方式),以便能夠運行在各版本的瀏覽器或其他環(huán)境中
基本原理
核心就是 AST (抽象語法樹)
首先將源碼轉成抽象語法樹,然后對語法樹進行處理生成新的語法樹,最后將新語法樹生成新的 JS 代碼
Babel 的流程
3 個階段: parsing (解析)、transforming (轉換)、generating (生成)
1)通過babylon
將 js 轉化成 ast (抽象語法樹)
2)通過babel-traverse
是一個對 ast 進行遍歷,使用 babel 插件轉化成新的 ast
3)通過babel-generator
將 ast 生成新的 js 代碼
1)單個軟件包在 .babelrc
中配置
.babelrc {
// 預設: Babel 官方做了一些預設的插件集,稱之為 Preset,我們只需要使用對應的 Preset 就可以了
'presets': [],
// babel和webpack類似,主要是通過plugin插件進行代碼轉化的,如果不配置插件,babel會將代碼原樣返回
'plugins': []
}
2)vue 中,在 babel.config.js 中配置
配置 babel-plugin-component 插件,按需引入 elementUI
module.exports = {
presets: ['@vue/app'],
// 配置babel-plugin-component插件
plugins: [
[
'component',
{
libraryName: 'element-ui',
styleLibraryName: 'theme-chalk'
}
]
]
};
3)配置browserslist
browserslist
用來控制要兼容瀏覽器版本,配置的范圍越具體,就可以更精確控制Polyfill
轉化后的體積大小
'browserslist': [
// 全球超過1%人使用的瀏覽器
'> 1%',
// 所有瀏覽器兼容到最后兩個版本根據(jù)CanIUse.com追蹤的版本
'last 2 versions',
// chrome 版本大于70
'chrome >= 70'
// 排除部分版本
'not ie <= 8'
]
Babel 插件的作用
Babel 插件擔負著編譯過程中的核心任務:轉換 AST
babel 插件的基本格式
1)一個函數(shù),參數(shù)是 babel,然后就是返回一個對象,key是visitor
,然后里面的對象是一個箭頭函數(shù)
2)函數(shù)有兩個參數(shù),path
表示路徑,state
表示狀態(tài)
3)CallExpression
就是我們要訪問的節(jié)點,path 參數(shù)表示當前節(jié)點的位置,包含的主要是當前節(jié)點(node)
內容以及父節(jié)點(parent)
內容
插件的簡單格式示例
module.exports = function (babel) {
let t = babel.type
return {
visitor: {
CallExression: (path, state) => {
do soming
}}}}
一個最簡單的插件: 將const a 轉化為const b
創(chuàng)建 babelPluginAtoB.js
module.exports = function(babel) {
let t = babel.types;
return {
visitor: {
VariableDeclarator(path, state) {
// VariableDeclarator 是要找的節(jié)點類型
if (path.node.id.name == 'a') {
// path.node.id.name = 'b' 是不行的,想改變某個值,就是用對應的ast來替換,所以我們要把id是a的ast換成b的ast
path.node.id = t.Identifier('b');
}
}
}
};
};
在.babelrc 中引入 babelPluginAtoB 插件
const babelPluginAtoB = require('./babelPluginAtoB.js');
{
'plugins': [
[babelPluginAtoB]
]
}
編寫測試代碼
let a = 1;
console.log(b);
// babel插件生效,沒有報錯,打印 1
Babel 入門教程[15]
Babel 中文文檔[16]
不容錯過的 Babel 知識[17]
快速寫一個 babel 插件[18]
gulp
是基于 node 流
實現(xiàn)的前端自動化開發(fā)的工具
適用場景
在前端開發(fā)工作中有很多“重復工作”
,比如批量將Scss文件編譯為CSS文件
這里主要聊一下,在開發(fā)的組件庫中如何使用 gulp
以elementUI
為例,下載elementUI 源碼[19]
打開packages/theme-chalk/gulpfile.js
該文件的作用是將 scss 文件編譯為 css 文件
'use strict';
// 引入gulp
// series創(chuàng)建任務列表,
// src創(chuàng)建一個流,讀取文件
// dest 創(chuàng)建一個對象寫入到文件系統(tǒng)的流
const { series, src, dest } = require('gulp');
// gulp-dart-sass編譯scss文件
const sass = require('gulp-dart-sass');
// gulp-autoprefixer 給css樣式添加前綴
const autoprefixer = require('gulp-autoprefixer');
// gulp-cssmin 壓縮css
const cssmin = require('gulp-cssmin');
// 處理src目錄下的所有scss文件,轉化為css文件
function compile() {
return (
src('./src/*.scss')
.pipe(sass.sync().on('error', sass.logError))
.pipe(
// 給css樣式添加前綴
autoprefixer({
overrideBrowserslist: ['ie > 9', 'last 2 versions'],
cascade: false
})
)
// 壓縮css
.pipe(cssmin())
// 將編譯好的css 輸出到lib目錄下
.pipe(dest('./lib'))
);
}
// 將src/fonts文件的字體文件 copy到 /lib/fonts目錄下
function copyfont() {
return src('./src/fonts/**')
.pipe(cssmin())
.pipe(dest('./lib/fonts'));
}
// series創(chuàng)建任務列表
exports.build = series(compile, copyfont);
總體流程
1)使用css var()
定義顏色變量
2)創(chuàng)建主題theme.css
文件,存儲所有的顏色變量
3)使用gulp
將theme.css
合并到base.css
中,解決按需引入的情況
4)使用gulp
將index.css
與base.css
合并,解決全局引入的情況
步驟一:創(chuàng)建基礎顏色變量theme.css
文件
步驟二:修改packages/theme-chalk/src/common/var.scss
文件
將該文件的中定義的 scss 變量,替換成 var()變量
步驟三:修改后的packages/theme-chalk/gulpfile.js
'use strict';
const {series, src, dest} = require('gulp');
const sass = require('gulp-dart-sass');
const autoprefixer = require('gulp-autoprefixer');
const cssmin = require('gulp-cssmin');
const concat = require('gulp-concat');
function compile() {
return src('./src/*.scss')
.pipe(sass.sync().on('error', sass.logError))
.pipe(autoprefixer({
overrideBrowserslist: ['ie > 9', 'last 2 versions'],
cascade: false
}))
.pipe(cssmin())
.pipe(dest('./lib'));
}
// 將 theme.css 和 lib/base.css合并成 最終的 base.css
function compile1() {
return src(['./src/theme.css', './lib/base.css'])
.pipe(concat('base.css'))
.pipe(dest('./lib'));
}
// 將 base.css、 index.css 合并成 最終的 index.css
function compile2() {
return src(['./lib/base.css', './lib/index.css'])
.pipe(concat('index.css'))
.pipe(dest('./lib'));
}
function copyfont() {
return src('./src/fonts/**')
.pipe(cssmin())
.pipe(dest('./lib/fonts'));
}
exports.build = series(compile, compile1, compile2, copyfont);
elementUI 多套主題下 按需引入和全局引入 的換膚方案[20]
腳手架是開發(fā)中經常會使用的工具,比如vue-cli
、create-react-app
等,這些腳手架可以通過簡單的命令,快速去搭建項目,讓我們更專注于項目的開發(fā)
隨著項目的增多、人員的擴展,大家開發(fā)的基礎組件和公共方法也越來越多,希望把這些積累添加到腳手架中,當成項目模板留存下來
這樣再創(chuàng)建項目時,就不用每次去其他項目中來回 copy
下面我們一起,手寫一個 mini 版的腳手架
通過這個案例來了解腳手架的工作流程,以及使用了哪些常用工具
新建文件夾my-build-cli
,執(zhí)行npm init -y
1)創(chuàng)建bin
目錄,該目錄下創(chuàng)建www.js
bin/www.js
內容
#! /usr/bin/env node
console.log('link 成功');
注:/usr/bin/env node
這行的意思是使用 node 來執(zhí)行此文件
2)package.json
中配置入口文件的路徑
{
'name': 'my-build-cli',
'version': '1.0.0',
'description': '',
'bin': './bin/www.js', // 手動添加入口文件為 ./bin/www.js
'scripts': {
'test': 'echo \'Error: no test specified\' && exit 1'
},
'keywords': [],
'author': '',
'license': 'ISC'
}
3)項目目錄結構
my-build-cli
├─ bin
│ └─ www.js
└─ package.json
在控制臺輸入npm link
測試是否連接成功
在控制臺輸入my-build-cli
在控制臺輸出link 成功
, 項目配置成功
一次性安裝所需的工具
npm install commander inquirer download-git-repo util ora fs-extra axios
工具名稱 | 作用 |
---|---|
commander | 自定義命令行工具 |
inquirer | 命令行交互工具 |
download-git-repo | 從 git 上下載項目模板工具 |
util | download-git-repo 不支持異步調用,需要使用 util 插件的util.promisify 進行轉換 |
ora | 命令行 loading 動效 |
fs-extra | 提供文件操作方法 |
axios | 發(fā)送接口,請求 git 上的模板列表 |
commander.js
是自定義命令行工具
這里用來創(chuàng)建create
命令,用戶可以通過輸入 my-cli creat appName
來創(chuàng)建項目
修改www.js
#! /usr/bin/env node
const program = require('commander');
program
// 創(chuàng)建create 命令,用戶可以通過 my-cli creat appName 來創(chuàng)建項目
.command('create <app-name>')
// 命名的描述
.description('create a new project')
// create命令的選項
.option('-f, --force', 'overwrite target if it exist')
.action((name, options) => {
// 執(zhí)行'./create.js',傳入項目名稱和 用戶選項
require('./create')(name, options);
});
program.parse();
inquirer.js
命令行交互工具,用來詢問用戶的操作,讓用戶輸入指定的信息,或給出對應的選項讓用戶選擇
此處 inquirer 的運用場景有 2 個
1)場景 1:當用戶要創(chuàng)建的項目目錄已存在時,提示用戶是否要覆蓋 or 取消
2)場景 2:讓用戶輸入項目的author
作者和項目description
描述
create.js
bin/create.js
const path = require('path');
const fs = require('fs-extra');
const inquirer = require('inquirer');
const Generator = require('./generator');
module.exports = async function (name, options) {
// process.cwd獲取當前的工作目錄
const cwd = process.cwd();
// path.join拼接 要創(chuàng)建項目的目錄
const targetAir = path.join(cwd, name);
// 如果該目錄已存在
if (fs.existsSync(targetAir)) {
// 強制刪除
if (options.force) {
await fs.remove(targetAir);
} else {
// 通過inquirer:詢問用戶是否確定要覆蓋 or 取消
let { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
message: 'Target already exists',
choices: [
{
name: 'overwrite',
value: 'overwrite'
},
{
name: 'cancel',
value: false
}
]
}
]);
if (!action) {
return;
} else {
// 刪除文件夾
await fs.remove(targetAir);
}
}
}
const args = require('./ask');
// 通過inquirer,讓用戶輸入的項目內容:作者和描述
const ask = await inquirer.prompt(args);
// 創(chuàng)建項目
const generator = new Generator(name, targetAir, ask);
generator.create();
};
ask.js
配置 ask
選項,讓用戶輸入作者和項目描述
bin/create.js
// 配置ask 選項
module.exports = [
{
type: 'input',
name: 'author',
message: 'author?'
},
{
type: 'input',
name: 'description',
message: 'description?'
}
];
generator.js
generator.js
的工作流程
1)通過接口獲取git
上的模板目錄
2)通過inquirer
讓用戶選擇需要下載的項目
3)使用download-git-repo
下載用戶選擇的項目模板
4)將用戶創(chuàng)建時,將項目名稱、作者名字、描述
寫入到項目模板的package.json
文件中
bin/generator.js
const path = require('path');
const fs = require('fs-extra');
// 引入ora工具:命令行l(wèi)oading 動效
const ora = require('ora');
const inquirer = require('inquirer');
// 引入download-git-repo工具
const downloadGitRepo = require('download-git-repo');
// download-git-repo 默認不支持異步調用,需要使用util插件的util.promisify 進行轉換
const util = require('util');
// 獲取git項目列表
const { getRepolist } = require('./http');
async function wrapLoading(fn, message, ...args) {
const spinner = ora(message);
// 下載開始
spinner.start();
try {
const result = await fn(...args);
// 下載成功
spinner.succeed();
return result;
} catch (e) {
// 下載失敗
spinner.fail('Request failed ……');
}
}
// 創(chuàng)建項目類
class Generator {
// name 項目名稱
// target 創(chuàng)建項目的路徑
// 用戶輸入的 作者和項目描述 信息
constructor(name, target, ask) {
this.name = name;
this.target = target;
this.ask = ask;
// download-git-repo 默認不支持異步調用,需要使用util插件的util.promisify 進行轉換
this.downloadGitRepo = util.promisify(downloadGitRepo);
}
async getRepo() {
// 獲取git倉庫的項目列表
const repolist = await wrapLoading(getRepolist, 'waiting fetch template');
if (!repolist) return;
const repos = repolist.map((item) => item.name);
// 通過inquirer 讓用戶選擇要下載的項目模板
const { repo } = await inquirer.prompt({
name: 'repo',
type: 'list',
choices: repos,
message: 'Please choose a template'
});
return repo;
}
// 下載用戶選擇的項目模板
async download(repo, tag) {
const requestUrl = `yuan-cli/${repo}`;
await wrapLoading(this.downloadGitRepo, 'waiting download template', requestUrl, path.resolve(process.cwd(), this.target));
}
// 文件入口,在create.js中 執(zhí)行generator.create();
async create() {
const repo = await this.getRepo();
console.log('用戶選擇了', repo);
// 下載用戶選擇的項目模板
await this.download(repo);
// 下載完成后,獲取項目里的package.json
// 將用戶創(chuàng)建項目的填寫的信息(項目名稱、作者名字、描述),寫入到package.json中
let targetPath = path.resolve(process.cwd(), this.target);
let jsonPath = path.join(targetPath, 'package.json');
if (fs.existsSync(jsonPath)) {
// 讀取已下載模板中package.json的內容
const data = fs.readFileSync(jsonPath).toString();
let json = JSON.parse(data);
json.name = this.name;
// 讓用戶輸入的內容 替換到 package.json中對應的字段
Object.keys(this.ask).forEach((item) => {
json[item] = this.ask[item];
});
//修改項目文件夾中 package.json 文件
fs.writeFileSync(jsonPath, JSON.stringify(json, null, '\t'), 'utf-8');
}
}
}
module.exports = Generator;
http.js
用來發(fā)送接口,獲取 git 上的模板列表
bin/http.js
// 引入axios
const axios = require('axios');
axios.interceptors.response.use((res) => {
return res.data;
});
// 獲取git上的項目列表
async function getRepolist() {
return axios.get('https://api.github.com/orgs/yuan-cli/repos');
}
module.exports = {
getRepolist
};
最終的目錄結構
完善 package.json
1)配置main
屬性,指定包的入口 'main': './bin/www.js'
2)增加files
屬性,files 用來描述當把 npm 包,作為依賴包安裝的文件列表。當 npm 包發(fā)布時,files 指定的文件會被推送到 npm 服務器中
3)增加description
、keywords
等描述字段
{
'name': 'my-2022-cli',
'version': '1.1.0',
'description': '一個mini版的腳手架',
'main': './bin/www.js',
'bin': './bin/www.js',
'scripts': {
'test': 'echo \'Error: no test specified\' && exit 1'
},
'files': [
'bin'
],
'keywords': [
'my-yuan-cli',
'自定義腳手架'
],
'author': '海闊天空',
'license': 'ISC',
'dependencies': {
'axios': '^0.24.0',
'commander': '^8.3.0',
'download-git-repo': '^3.0.2',
'fs-extra': '^10.0.0',
'inquirer': '^8.2.0',
'ora': '^5.4.1',
'util': '^0.12.4'
}
}
增加 README.md 說明文檔
## my-2022-cli
一個 mini 版的自定義腳手架
### 安裝
npm install my-2022-cli -g
### 使用說明
1)通過 my-2022-cli create appName 創(chuàng)建項目
2)author? 輸入項目作者
3)description? 輸入項目描述
4)選擇項目模塊 appDemo or pcDemo
5)安裝選擇的模板
### 演示示例

發(fā)布成功后,在 npm 網(wǎng)站搜索my-2022-cli
自定義腳手架 github 源碼地址[21]
my-2022-cli[22]
一百用戶與一百萬用戶的網(wǎng)站有著本質區(qū)別
隨著用戶的增長,任何細節(jié)的優(yōu)化都變得更為重要,網(wǎng)站的性能差異直接影響著用戶的體驗
試想,如果我們在網(wǎng)上購物,商城頁面好幾秒才打開,或圖片加載不出來,購物的欲望瞬間消減,抬手就去其他競品平臺了
我曾經負責過幾個大型項目的整體性能優(yōu)化,盡量從實戰(zhàn)的角度聊一聊自己所理解的性能問題
性能分析工具
好比去醫(yī)院看病一樣,得了什么病,通過檢測化驗后才知道。網(wǎng)站也是一樣,需要借助性能分析工具來檢測
Lighthouse
是 Chrome 自帶的性能分析工具,它能夠生成一個有關頁面性能的報告
通過報告我們可以知道需要采取哪些措施,來改進應用的性能和體驗
并且 Lighthouse 可以對頁面多方面的效果指標進行評測,并給出最佳實踐的建議,以幫助開發(fā)者改進網(wǎng)站的質量
通過 Lighthouse 拿到網(wǎng)站的整體分析報告,通過報告來診斷“病情”
這里以https://juejin.cn[23]網(wǎng)站為例, 打開 Chrome 瀏覽器控制臺,選擇Lighthouse
選項,點擊Generate report
Lighthouse 能夠生成一份該網(wǎng)站的報告,比如下圖:
這里重點關注Performance性能評分
性能評分的分值區(qū)間是 0 到 100,如果出現(xiàn) 0 分,通常是在運行 Lighthouse 時發(fā)生了錯誤,滿分 100 分代表了網(wǎng)站已經達到了 98 分位值的數(shù)據(jù),而 50 分則對應 75 分位值的數(shù)據(jù)
小伙伴看看自己開發(fā)的項目得分是多少,處于什么樣的水平
Lighthouse 會針對當前網(wǎng)站,給出一些Opportunities
優(yōu)化建議
Opportunities 指的是優(yōu)化機會,它提供了詳細的建議和文檔,來解釋低分的原因,幫助我們具體進行實現(xiàn)和改進
舉一個我曾開發(fā)過的一個項目,以下是
Opportunities 給出優(yōu)化建議列表
問題 | 建議 |
---|---|
Remove unused JavaScript | 去掉無用 js 代碼 |
Preload key requests | 首頁資源 preload 預加載 |
Remove unused CSS | 去掉無用 css 代碼 |
Serve images in next-gen formats | 使用新的圖片格式,比如 webp 相對 png jpg 格式體積更小 |
Efficiently encode images | 比如壓縮圖片大小 |
Preconnect to required origins | 使用 preconnect or dns-prefetch DNS 預解析 |
Diagnostics
指的是現(xiàn)在存在的問題,為進一步改善性能的驗證和調整給出了指導
Diagnostics 診斷問題列表
問題 | 影響 |
---|---|
A long cache lifetime can speed up repeat visits to your page | 這些資源需要提供長的緩存期,現(xiàn)發(fā)現(xiàn)圖片都是用的協(xié)商緩存,顯然不合理 |
Image elements do not have explicit width and height | 給圖片設置具體的寬高,減少 cls 的值 |
Avoid enormous network payloads | 資源太大增加網(wǎng)絡負載 |
Minimize main-thread work | 最小化主線程 這里會執(zhí)行解析 Html、樣式計算、布局、繪制、合成等動作 |
Reduce JavaScript execution time | 減少非必要 js 資源的加載,減少必要 js 資源的大小 |
Avoid large layout shifts | 避免大的布局變化,從中可以看到影響布局變化最大的元素 |
這些Opportunities建議和Diagnostics診斷問題是非常具體且有效的(親測),開發(fā)者可以根據(jù)這些建議,一條條去修改或優(yōu)化
Performance 列出了FCP、SP、LCP、TTI、TBI、CLS
六個指標的用時和得分情況
下文會聊一聊這些指標的用法與作用
性能測評工具 lighthouse 的使用[24]
web-vitals[25]是 Google 給出的定義是 一個良好網(wǎng)站的基本指標
過去要衡量一個網(wǎng)站的好壞,需要使用的指標太多了,現(xiàn)在我們可以將重點聚焦于 Web Vitals 指標的表現(xiàn)即可
官方指標標準
指標 | 作用 | 標準 |
---|---|---|
FCP(First Contentful Paint) | 首次內容繪制時間 | 標準 ≤1s |
LCP(Largest Contentful Paint) | 最大內容繪制時間 | 標準 ≤2 秒 |
FID(first input delay) | 首次輸入延遲,標準是用戶觸發(fā)后,到瀏覽器響應時間 | 標準 ≤100ms |
CLS(Cumulative Layout Shift) | 累積布局偏移 | 標準 ≤0.1 |
TTFB(Time to First Byte) | 頁面發(fā)出請求,到接收第一個字節(jié)所花費的毫秒數(shù)(首字節(jié)時間) | 標準<= 100 毫秒 |
我們將 Lighthouse 中 Performance 列出的指標表現(xiàn),與官方指標標準做對比,可以發(fā)現(xiàn)頁面哪些指標超出了范圍
通過 Lighthouse 我們知道了頁面整體的性能得分,但是頁面打開慢或者卡頓的瓶頸在哪里?
具體是加載資源慢
、dom渲染慢
、還是js執(zhí)行慢
呢?
chrome 瀏覽器提供的performance
是常用來查看網(wǎng)頁性能的工具,通過該工具,我們可以知道頁面在瀏覽器運行時的性能表現(xiàn)
打開 Chrome 瀏覽器控制臺,選擇Performance
選項,點擊左側reload圖標
Performance 面板可以記錄和分析頁面在運行時的所有活動,大致分為以下 4 個區(qū)域
1)FPS
FPS(Frames Per Second),表示每秒傳輸幀數(shù),是用來分析頁面是否卡頓
的一個主要性能指標
如下圖所示,綠色的長條越高,說明FPS越高,用戶體驗越好
如果發(fā)現(xiàn)了一個紅色的長條,那么就說明這些幀存在嚴重問題
,可能會造成頁面卡頓
2)NET
NET 記錄資源的等待、下載、執(zhí)行時間,每條彩色橫杠表示一種資源
橫杠越長,檢索資源所需的時間越長。每個橫杠的淺色部分表示等待時間(從請求資源到第一個字節(jié)下載完成的時間)
Network 的顏色說明:白色表示等待的顏色、淺黃色表示請求的時間、深黃色表示下載的時間
在這里,我們可以看到所有資源的加載過程,有兩個地方重點關注:
1)資源等待的時間是否過長(標準 ≤100ms)
2)資源文件體積是否過大,造成加載很慢(就要考慮如何拆分該資源)
3)火焰圖
火焰圖(Flame Chart)用來可視化 CPU 堆棧信息記錄
Main:表示主線程(重點,下文會詳細介紹)
4)統(tǒng)計匯總
Summary: 表示各指標時間占用統(tǒng)計報表
1)Loading: 加載時間
2)Scripting: js計算時間
3)Rendering: 渲染時間
4)Painting: 繪制時間
5)Other: 其他時間
6)Idle: 瀏覽器閑置時間
Main 表示主線程,主要負責
1)Javascript 的計算與執(zhí)行
2)CSS 樣式計算
3)Layout 布局計算
4)將頁面元素繪制成位圖(paint),也就是光柵化(Raster)
展開 Main,可以發(fā)現(xiàn)很多紅色三角(long task)
,這些執(zhí)行時間超過 50ms
就屬于長任務
,會造成頁面卡頓,嚴重時會造成頁面卡死
展開其中一個紅色三角,Devtools 在Summary
面板里展示了更多關于這個事件的信息
在 summary 面板里點擊app.js
鏈接,Devtools 可以跳轉到需要優(yōu)化的代碼處
下面我們需要結合自己的代碼邏輯,去判斷這塊代碼為什么執(zhí)行時間超長?
如何去解決或優(yōu)化這些long task
,從而解決去頁面的性能瓶頸
全新 Chrome Devtool Performance 使用指南[26]
手把手帶你入門前端工程化——超詳細教程[27]
Chrome Devtool — Performance[28]
項目發(fā)布生產后,用戶使用時的性能如何,頁面整體的打開速度是多少、白屏時間多少,F(xiàn)P、FCP、LCP、FID、CLS 等指標,要設置多大的閥值呢,才能滿足TP50、TP90、TP99
的要求呢?
TP 指標: 總次數(shù) * 指標數(shù) = 對應 TP 指標的值。
設置每個指標的閥值,比如 FP 指標,設置閥值為 1s,要求 Tp95,即 95%的 FP 指標,要在 1s 以下,剩余 5%的指標超過 1s
TP50 相對較低,TP90 則比較高,TP99,TP999 則對性能要求很高
這里就需要性能監(jiān)控,采集到用戶的頁面數(shù)據(jù)
常用的兩種方式:
方式一:通過 web-vitals 官方庫進行計算
import {onLCP, onFID, onCLS} from 'web-vitals';
onCLS(console.log);
onFID(console.log);
onLCP(console.log);
方式二:通過performance api
進行計算
下面聊一下 performance api 來計算各種指標
打開任意網(wǎng)頁,在控制臺中輸入 performance 回車,可以看到一系列的參數(shù),
重點看下performance.timing
,記錄了頁面的各個關鍵時間點
時間 | 作用 |
---|---|
navigationStart | (可以理解為該頁面的起始時間)同一個瀏覽器上下文的上一個文檔卸載結束時的時間戳,如果沒有上一個文檔,這個值會和 fetchStart 相同 |
unloadEventStart | unload 事件拋出時的時間戳,如果沒有上一個文檔,這個值會是 0 |
unloadEventEnd | unload 事件處理完成的時間戳,如果沒有上一個文檔,這個值會是 0 |
redirectStart | 第一個 HTTP 重定向開始時的時間戳,沒有重定向或者重定向中的不同源,這個值會是 0 |
redirectEnd | 最后一個 HTTP 重定向開始時的時間戳,沒有重定向或者重定向中的不同源,這個值會是 0 |
fetchStart | 瀏覽器準備好使用 HTTP 請求來獲取文檔的時間戳。發(fā)送在檢查緩存之前 |
domainLookupStart | 域名查詢開始的時間戳,如果使用了持續(xù)連接或者緩存,則與 fetchStart 一致 |
domainLookupEnd | 域名查詢結束的時間戳,如果使用了持續(xù)連接或者緩存,則與 fetchStart 一致 |
connectStart | HTTP 請求開始向服務器發(fā)送時的時間戳,如果使用了持續(xù)連接,則與 fetchStart 一致 |
connectEnd | 瀏覽器與服務器之間連接建立(所有握手和認證過程全部結束)的時間戳,如果使用了持續(xù)連接,則與 fetchStart 一致 |
secureConnectionStart | 瀏覽器與服務器開始安全連接握手時的時間戳,如果當前網(wǎng)頁不需要安全連接,這個值會是 0 |
requestStart | 瀏覽器向服務器發(fā)出 HTTP 請求的時間戳 |
responseStart | 瀏覽器從服務器收到(或從本地緩存讀?。┑谝粋€字節(jié)時的時間戳 |
responseEnd | 瀏覽器從服務器收到(或從本地緩存讀?。┳詈笠粋€字節(jié)時(如果在此之前 HTTP 連接已經關閉,則返回關閉時)的時間戳 |
domLoading | 當前網(wǎng)頁 DOM 結構開始解析時的時間戳 |
domInteractive | 當前網(wǎng)頁 DOM 結構解析完成,開始加載內嵌資源時的時間戳 |
domContentLoadedEventStart | 需要被執(zhí)行的腳本已經被解析的時間戳 |
domContentLoadedEventEnd | 需要立即執(zhí)行的腳本已經被執(zhí)行的時間戳 |
domComplete | 當前文檔解析完成的時間戳 |
loadEventStart | load 事件被發(fā)送時的時間戳,如果這個事件還未被發(fā)送,它的值將會是 0 |
loadEventEnd | load 事件結束時的時間戳,如果這個事件還未被發(fā)送,它的值將會是 0 |
白屏時間 FP(First Paint)指的是從用戶輸入 url 的時刻開始計算,一直到頁面有內容展示出來的時間節(jié)點,標準≤2s
這個過程包括 dns 查詢、建立 tcp 連接、發(fā)送 http 請求、返回 html 文檔、html 文檔解析
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint') {
observer.disconnect()}
// 其中startTime 就是白屏時間
let FP = entry.startTime)
}
}
const observer = new PerformanceObserver(entryHandler)
// buffered 屬性表示是否觀察緩存數(shù)據(jù),也就是說觀察代碼添加時機比事件觸發(fā)時機晚也沒關系。
observer.observe({ type: 'paint', buffered: true })
FCP(First Contentful Paint) 表示頁面任一部分渲染完成的時間,標準≤1s
// 計算方式:
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
observer.disconnect()
}
// 計算首次內容繪制時間
let FCP = entry.startTime
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })
LCP(Largest Contentful Paint)表示最大內容繪制時間,標準≤2 秒
// 計算方式:
const entryHandler = (list) => {
if (observer) {
observer.disconnect()
}
for (const entry of list.getEntries()) {
// 最大內容繪制時間
let LCP = entry.startTime
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })
CLS(Cumulative Layout Shift) 表示累積布局偏移,標準≤0.1
// cls為累積布局偏移值
let cls = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
}
}
}).observe({type: 'layout-shift', buffered: true});
平常所說的TTFB
,默認指導航請求的TTFB
導航請求:在瀏覽器切換頁面時創(chuàng)建,從導航開始到該請求返回 HTML
window.onload = function () {
// 首字節(jié)時間
let TTFB = responseStart - navigationStart;
};
FID(first input delay)首次輸入延遲,標準是用戶觸發(fā)后,瀏覽器的響應時間, 標準≤100ms
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 計算首次輸入延遲時間
const FID = entry.processingStart - entry.startTime;
}
}).observe({ type: 'first-input', buffered: true });
FID 推薦使用 web-vitals 庫,因為官方兼容了很多場景
window.onload = function () {
// 首頁加載時間
// domComplete 是document的readyState = complete(完成)的狀態(tài)
let firstScreenTime = performance.timing.domComplete - performance.timing.navigationStart;
};
首屏加載時間和首頁加載時間不一樣,首屏指的是用戶看到屏幕內頁面渲染完成的時間
比如首頁很長需要好幾屏展示,這種情況下屏幕以外的元素不考慮在內
計算首屏加載時間流程
1)利用MutationObserver
監(jiān)聽document
對象,每當 dom 變化時觸發(fā)該事件
2)判斷監(jiān)聽的 dom 是否在首屏內,如果在首屏內,將該 dom 放到指定的數(shù)組中,記錄下當前 dom 變化的時間點
3)在 MutationObserver 的 callback 函數(shù)中,通過防抖函數(shù),監(jiān)聽document.readyState
狀態(tài)的變化
4)當document.readyState === 'complete'
,停止定時器和 取消對 document 的監(jiān)聽
5)遍歷存放 dom 的數(shù)組,找出最后變化節(jié)點的時間,用該時間點減去performance.timing.navigationStart
得出首屏的加載時間
定義 performance.js
// firstScreenPaint為首屏加載時間的變量
let firstScreenPaint = 0;
// 頁面是否渲染完成
let isOnLoaded = false;
let timer;
let observer;
// 定時器循環(huán)監(jiān)聽dom的變化,當document.readyState === 'complete'時,停止監(jiān)聽
function checkDOMChange(callback) {
cancelAnimationFrame(timer);
timer = requestAnimationFrame(() => {
if (document.readyState === 'complete') {
isOnLoaded = true;
}
if (isOnLoaded) {
// 取消監(jiān)聽
observer && observer.disconnect();
// document.readyState === 'complete'時,計算首屏渲染時間
firstScreenPaint = getRenderTime();
entries = null;
// 執(zhí)行用戶傳入的callback函數(shù)
callback && callback(firstScreenPaint);
} else {
checkDOMChange();
}
});
}
function getRenderTime() {
let startTime = 0;
entries.forEach((entry) => {
if (entry.startTime > startTime) {
startTime = entry.startTime;
}
});
// performance.timing.navigationStart 頁面的起始時間
return startTime - performance.timing.navigationStart;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// dom 對象是否在屏幕內
function isInScreen(dom) {
const rectInfo = dom.getBoundingClientRect();
if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
return true;
}
return false;
}
let entries = [];
// 外部通過callback 拿到首屏加載時間
export default function observeFirstScreenPaint(callback) {
const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK'];
observer = new window.MutationObserver((mutationList) => {
checkDOMChange(callback);
const entry = { children: [] };
for (const mutation of mutationList) {
if (mutation.addedNodes.length && isInScreen(mutation.target)) {
for (const node of mutation.addedNodes) {
// 忽略掉以上標簽的變化
if (node.nodeType === 1 && !ignoreDOMList.includes(node.tagName) && isInScreen(node)) {
entry.children.push(node);
}
}
}
}
if (entry.children.length) {
entries.push(entry);
entry.startTime = new Date().getTime();
}
});
observer.observe(document, {
childList: true, // 監(jiān)聽添加或刪除子節(jié)點
subtree: true, // 監(jiān)聽整個子樹
characterData: true, // 監(jiān)聽元素的文本是否變化
attributes: true // 監(jiān)聽元素的屬性是否變化
});
}
外部引入使用
import observeFirstScreenPaint from './performance';
// 通過回調函數(shù),拿到首屏加載時間
observeFirstScreenPaint((data) => {
console.log(data, '首屏加載時間');
});
DOM 的渲染的時間和 window.onload 執(zhí)行的時間不是一回事
DOM 渲染的時間
DOM渲染的時間 = performance.timing.domComplete - performance.timing.domLoading
window.onload 要晚于 DOM 的渲染,window.onload 是頁面中所有的資源都加載后才執(zhí)行(包括圖片的加載)
window.onload 的時間
window.onload的時間 = performance.timing.loadEventEnd
緩存命中率:從緩存中得到數(shù)據(jù)的請求數(shù)與所有請求數(shù)的比率
理想狀態(tài)是緩存命中率越高越好,緩存命中率越高說明網(wǎng)站的緩存策略越有效,用戶打開頁面的速度也會相應提高
如何判斷該資源是否命中緩存?
1)通過performance.getEntries()
找到所有資源的信息
2)在這些資源對象中有一個transferSize
字段,它表示獲取資源的大小,包括響應頭字段和響應數(shù)據(jù)的大小
3)如果這個值為 0,說明是從緩存中直接讀取的(強制緩存)
4)如果這個值不為 0,但是encodedBodySize
字段為 0,說明它走的是協(xié)商緩存(encodedBodySize 表示請求響應數(shù)據(jù) body 的大小
)
function isCache(entry) {
// 直接從緩存讀取或 304
return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
}
將所有命中緩存的數(shù)據(jù) / 總數(shù)據(jù)
就能得出緩存命中率
上報方式
一般使用圖片打點
的方式,通過動態(tài)創(chuàng)建 img 標簽的方式,new 出像素為1x1 px
的gif Image
(gif 體積最小)對象就能發(fā)起請求,可以跨域、不需要等待服務器返回數(shù)據(jù)
上報時機
可以利用requestIdleCallback
,瀏覽器空閑的時候上報,好處是:不阻塞其他流程的進行
如果瀏覽器不支持該requestIdleCallback
,就使用setTimeout
上報
// 優(yōu)先使用requestIdleCallback
if (window.requestIdleCallback) {
window.requestIdleCallback(
() => {
// 獲取瀏覽器的剩余空閑時間
console.log(deadline.timeRemaining());
report(data); // 上報數(shù)據(jù)
},
// timeout設置為1000,如果在1000ms內沒有執(zhí)行該后調,在下次空閑時間時,callback會強制執(zhí)行
{ timeout: 1000 }
);
} else {
setTimeout(() => {
report(data); // 上報數(shù)據(jù)
});
}
性能分析很火,但內存分析相比就低調很多了
舉一個我之前遇到的情況,客戶電腦配置低,打開公司開發(fā)的頁面,經常出現(xiàn)頁面崩潰
調查原因,就是因為頁面內存占用太大,客戶打開幾個頁面后,內存直接拉滿,也是經過這件事,我開始重視內存分析與優(yōu)化
下面,聊一聊內存這塊有哪些知識
Memory 工具,通過內存快照
的方式,分析當前頁面的內存使用情況
Memory 工具使用流程
1)打開 chrome 瀏覽器控制臺,選擇Memory
工具
2)點擊左側start按鈕
,刷新頁面,開始錄制的JS堆動態(tài)分配時間線
,會生成頁面加載過程內存變化的柱狀統(tǒng)計圖(藍色表示未回收,灰色表示已回收)
Memory 工具中的關鍵項
關鍵項
Constructor:對象的類名;
Distance:對象到根的引用層級;
Objects Count:對象的數(shù)量;
Shallow Size: 對象本身占用的內存,不包括引用的對象所占內存;
Retained Size: 對象所占總內存,包含引用的其他對象所占內存;
Retainers:對象的引用層級關系
通過一段測試代碼來了解 Memory 工具各關鍵性的關系
// 測試代碼
class Jane {}
class Tom {
constructor () { this.jane = new Jane();}
}
Array(1000000).fill('').map(() => new Tom())
shallow size 和 retained size 的區(qū)別,以用紅框里的 Tom
和 Jane
更直觀的展示
Tom 的 shallow 占了 32M,retained 占用了 56M,這是因為 retained 包括了引用的指針對應的內存大小,即 tom.jane
所占用的內存
所以 Tom 的 retained 總和比 shallow 多出來的 24M,正好跟 Jane 占用的 24M 相同
retained size 可以理解為當回收掉該對象時可以釋放的內存大小,在內存調優(yōu)中具有重要參考意義
找到內存最高的節(jié)點,分析這些時刻執(zhí)行了哪些代碼,發(fā)生了什么操作,盡可能去優(yōu)化它們
1)從柱狀圖中找到最高的點,重點分析該時間內造成內存變大的原因
2)按照Retainers size
(總內存大?。┡判?,點擊展開內存最高的哪一項,點擊展開構造函數(shù),可以看到所有構造函數(shù)相關的對象實例
3)選中構造函數(shù),底部會顯示對應源碼文件,點擊源碼文件,可以跳轉到具體的代碼,這樣我們就知道是哪里的代碼造成內存過大
4)結合具體的代碼邏輯,來判斷這塊內存變大的原因,重點是如何去優(yōu)化它們,降低內存的使用大小
點擊keyghost.js
可以跳轉到具體的源碼
1)意外的全局變量, 掛載到 window 上全局變量
2)遺忘的定時器,定時器沒有清除
3)閉包不當?shù)氖褂?/p>
1)利用 Memory 工具,了解頁面整體的內存使用情況
2)通過 JS 堆動態(tài)分配時間線,找到內存最高的時刻
3)按照 Retainers size(總內存大?。┡判颍c擊展開內存最高的前幾項,分析由于哪個函數(shù)操作導致了內存過大,甚至是內存泄露
4)結合具體的代碼,去解決或優(yōu)化內存變大的情況
chrome 內存泄露(一)、內存泄漏分析工具[29]
chrome 內存泄露(二)、內存泄漏實例[30]
JavaScript 進階-常見內存泄露及如何避免[31]
優(yōu)化的本質:響應更快,展示更快
更詳細的說,是指在用戶輸入 url,到頁面完整展示出來的過程中,通過各種優(yōu)化策略和方法,讓頁面加載更快;在用戶使用過程中,讓用戶的操作響應更及時,有更好的用戶體驗
很多前端優(yōu)化準則都是圍繞著這個展開
結合我曾經負責優(yōu)化的項目實踐,在下面總結了一些經驗與方法,提供給大家參考
可以使用webpack-bundle-analyzer[32]插件(vue 項目可以使用--report)生成資源分析圖
我們要清楚的知道項目中使用了哪些三方依賴,以及依賴的作用。特別對于體積大的依賴,分析是否能優(yōu)化
比如:組件庫如elementUI
的按需引入、Swiper輪播圖
組件打包后的體積約 200k,看是否能替換成體積更小的插件、momentjs
去掉無用的語言包等
如果項目支持 CDN,可以配置externals
,將Vue、Vue-router、Vuex、echarts
等公共資源,通過 CDN 的方式引入,不打到項目里邊
如果項目不支持 CDN,可以使用DllPlugin
動態(tài)鏈接庫,將業(yè)務代碼和公共資源代碼相分離,公共資源單獨打包,給這些公共資源設置強緩存(公共資源基本不會變),這樣以后可以只打包業(yè)務代碼,提升打包速度
preload 預加載
<link rel='preload' href='/path/style.css' as='style'>
<link rel='preload' href='/path/home.js' as='script'>
preload 預加載是告訴瀏覽器頁面必定需要的資源,瀏覽器會優(yōu)先加載這些資源;使用 link 標簽創(chuàng)建(vue 項目打包后,會將首頁所用到的資源都加上 preload)
注意:preload 只是預加載資源,但不會執(zhí)行,還需要引入具體的文件后才會執(zhí)行 <script src='/path/home.js'>
DNS 預解析
DNS Prefetch
是一種 DNS 預解析技術,當你瀏覽網(wǎng)頁時,瀏覽器會在加載網(wǎng)頁時,對網(wǎng)頁中的域名進行解析緩存
這樣在你單擊當前網(wǎng)頁中的連接時就無需進行DNS
的解析,減少用戶等待時間,提高用戶體驗
使用dns-prefetch
,如<link rel='dns-prefetch' href='//img1.taobao.com'>
很多大型的網(wǎng)站,都會用N
個CDN
域名來做圖片、靜態(tài)文件等資源訪問。解析單個域名同樣的地點加上高并發(fā)難免有點堵塞,通過多個 CDN 域名來分擔高并發(fā)下的堵塞
方式一: defer 或 async
使用 script 標簽的defer或async
屬性,這兩種方式都是異步加載 js,不會阻塞 DOM 的渲染
async 是無順序的加載,而 defer 是有順序的加載
1)使用 defer 可以用來控制 js 文件的加載順序
比如 jq 和 Bootstrap,因為 Bootstrap 中的 js 插件依賴于 jqery,所以必須先引入 jQuery,再引入 Bootstrap js 文件
2)如果你的腳本并不關心頁面中的 DOM 元素(文檔是否解析完畢),并且也不會產生其他腳本需要的數(shù)據(jù),可以使用 async,如添加統(tǒng)計、埋點等資源
方式二:依賴動態(tài)引入
項目依賴的資源,推薦在各自的頁面中動態(tài)引入,不要全部都放到 index.html 中
比如echart.js
,只有 A 頁面使用,可以在 A 頁面的鉤子函數(shù)中動態(tài)加載,在onload事件
中進行 echart 初始化
資源動態(tài)加載的代碼示例
// url 要加載的資源
// isMustLoad 是否強制加載
cont asyncLoadJs = (url, isMustLoad = false) => {
return new Promise((resolve, reject) => {
if (!isMustLoad) {
let srcArr = document.getElementsByTagName('script');
let hasLoaded = false;
let aTemp = [];
for (let i = 0; i < srcArr.length; i++) {
// 判斷當前js是否加載上
if (srcArr[i].src) {
aTemp.push(srcArr[i].src);
}
}
hasLoaded = aTemp.indexOf(url) > -1;
if (hasLoaded) {
resolve();
return;
}
}
let script = document.createElement('script');
script.type = 'text/javascript';
script.src = url;
document.body.appendChild(script);
// 資源加載成功的回調
script.onload = () => {
resolve();
};
script.onerror = () => {
// reject();
};
});
}
方式三:import()
使用import() 動態(tài)加載路由和組件
,對資源進行拆分,只有使用的時候才進行動態(tài)加載
// 路由懶加載
const Home = () => import(/* webpackChunkName: 'home' */ '../views/home/index.vue')
const routes = [ { path: '/', name: 'home', component: Home} ]
// 組件懶加載
// 在visible屬性為true時,動態(tài)去加載demoComponent組件
<demoComponent v-if='visible == true' />
components: {
demoComponent: () => import(/* webpackChunkName: 'demoComponent' */ './demoComponent.vue')
},
html 資源設置協(xié)商緩存,其他 js、css、圖片等資源設置強緩存
當用戶再次打開頁面時,html 先和服務器校驗,如果該資源未變化,服務器返回 304,直接使用緩存的文件;若返回 200,則返回最新的 html 資源
1)開啟服務器 Gzip 壓縮,減少請求內容的體積,對文本類能壓縮 60%以上
2)使用 HTTP2,接口解析速度快、多路復用、首部壓縮等
3)減少 HTTP 請求,使用 url-loader,limit 限制圖片大小,小圖片轉 base64
1)前端長列表渲染優(yōu)化,分頁 + 虛擬列表,長列表渲染的性能效率與用戶體驗成正比
2)圖片的懶加載、圖片的動態(tài)裁剪
特點是手機端項目,圖片幾乎不需要原圖,使用七?;虬⒗镌频膭討B(tài)裁剪功能,可以將原本幾M
的大小裁剪成幾k
3)動畫的優(yōu)化,動畫可以使用絕對定位,讓其脫離文檔流,修改動畫不造成主界面的影響
使用 GPU 硬件加速包括:transform 不為none、opacity、filter、will-change
4)函數(shù)的節(jié)流和防抖,減少接口的請求次數(shù)
5)使用骨架屏優(yōu)化用戶等待體驗,可以根據(jù)不同路由配置不同的骨架
vue 項目推薦使用vue-skeleton-webpack-plugin
,骨架屏原理將<div id='app'></div>
中的內容替換掉
6)大數(shù)據(jù)的渲染,如果數(shù)據(jù)不會變化,vue 項目可以使用Object.freeze()
Object.freeze()方法可以凍結一個對象,Vue 正常情況下,會將 data 中定義的是對象變成響應式,但如果判斷對象的自身屬性不可修改,就直接返回改對象,省去了遞歸遍歷對象的時間與內存消耗
7)定時器和綁定的事件,在頁面銷毀時卸載
提升構建速度或優(yōu)化代碼體積,推薦以下兩篇文章
Webpack 優(yōu)化——將你的構建效率提速翻倍[33]
帶你深度解鎖 Webpack 系列(優(yōu)化篇)[34]
1)先用 Lighthouse 得到當前頁面的性能得分,了解頁面的整體情況,重點關注 Opportunities 優(yōu)化建議和 Diagnostics 診斷問題列表
2)通過 Performance 工具了解頁面加載的整個過程,分析到底是資源加載慢、dom 渲染慢、還是 js 執(zhí)行慢,找到具體的性能瓶頸在哪里,重點關注長任務(long task)
3)利用 Memory 工具,了解頁面整體的內存使用情況,通過 JS 堆動態(tài)分配時間線,找到內存最高的時刻。結合具體的代碼,去解決或優(yōu)化內存變大的問題
沒有監(jiān)控的項目,就是在“裸奔”
需要通過監(jiān)控才能真正了解項目的整體情況,各項指標要通過大量的數(shù)據(jù)采集、數(shù)據(jù)分析,變得更有意義
監(jiān)控的好處:事前預警和事后分析
事前預警:設置一個閾值,當監(jiān)控的數(shù)據(jù)達到閾值時,通過各種渠道通知管理員和開發(fā),提前避免可能會造成的宕機或崩潰
事后分析:通過監(jiān)控日志文件,分析故障原因和故障發(fā)生點。從而做出修改,防止這種情況再次發(fā)生
我們可以使用市面上現(xiàn)有的工具,也可以自建 sentry 監(jiān)控,關鍵在于通過這些數(shù)據(jù),能看到這些冰冷數(shù)據(jù)背后的故事
組件庫是開發(fā)項目時必備的工具,因為現(xiàn)在各個大廠提供的組件庫都太好用了,反而讓大家輕視了組件庫的重要性
現(xiàn)在開發(fā)一個新項目,如果要求不能用現(xiàn)成的組件庫,我估計要瞬間懵逼,無從下手了,不知不覺中,我已經患上嚴重的組件庫依賴癥
如果讓我只能說出一種,快速提升編程技能的方法,我一定會推薦去看看組件庫源碼
因為組件庫源碼中,都是最經典的案例,是集大成的杰作
相比于前端框架源碼的晦澀,組件庫源碼更容易上手,里邊很多經典場景的寫法,我們都可以借鑒,然后運用到實際的項目中
比如最常用的彈框組件,里邊的流程控制、層級控制、組件間事件派發(fā)與廣播、遞歸查找組件
等設計,相當驚艷,真沒想到一個小小的彈框組件,也是內有乾坤
推薦一下我正在寫的ElementUI 源碼-打造自己的組件庫[35]文章,經典的東西永遠是經典
工程化,是一個非常容易出彩
的方向,是展現(xiàn)一個俠者內功是否深厚的窗口
文中大部分知識點,只是入門級教程
,也是我以后需要持續(xù)努力的方向