Babel

[TOC]

抽象语法树 AST(Abstract Syntax Tree)

将我们所写的代码转化为机器能识别的一种树形结构。本身有一堆节点(Node)构成。每个节点都代表一种结构,不同结构用类型(Type)区分。

AST 结构

JS 为了统一 ECMAScript 标准,社区中衍生出了 ESTree Spec 标准。

节点类型

类型 说明
File 文件(顶层节点包含 Program)
Program 程序(包含 body)
Directive 指令(如 “use strict”)
Comment 注释
Statement 语句
Literal 字面量(基本数据类型、复杂数据类型等值类型)
Identifier 标识符(变量名、属性名、函数名、参数名等)
Declaration 声明(变量声明、函数声明、Import、Export 声明等)
Specifier 关键字(ImportSpecifier、ImportDefaultSpecifier、ImportNamespaceSpecifier、ExportSpecifier 等)
Expression 表达式

公共属性

属性 说明
type 节点类型
start 记录该节点代码字符串起始下标
end 记录该节点代码字符串结束下标
loc 内含 line、column 属性,分别记录开始结束的行列号
leadingComments 开始的注释
innerComments 中间的注释
trailingComments 结尾的注释
extra 额外信息

AST 示例

有两个工具:

如下的代码:

1
2
3
4
function test(args) {
const a = 1;
console.log(args);
}

转化为 AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
{
"type": "Program",
"start": 0,
"end": 62,
"body": [
{
"type": "FunctionDeclaration",
"start": 0,
"end": 62,
"id": {
"type": "Identifier",
"start": 9,
"end": 13,
"name": "test"
},
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 14,
"end": 18,
"name": "args"
}
],
"body": {
"type": "BlockStatement",
"start": 20,
"end": 62,
"body": [
{
"type": "VariableDeclaration",
"start": 24,
"end": 36,
"declarations": [
{
"type": "VariableDeclarator",
"start": 30,
"end": 35,
"id": {
"type": "Identifier",
"start": 30,
"end": 31,
"name": "a"
},
"init": {
"type": "Literal",
"start": 34,
"end": 35,
"value": 1,
"raw": "1"
}
}
],
"kind": "const"
},
{
"type": "ExpressionStatement",
"start": 39,
"end": 60,
"expression": {
"type": "CallExpression",
"start": 39,
"end": 59,
"callee": {
"type": "MemberExpression",
"start": 39,
"end": 50,
"object": {
"type": "Identifier",
"start": 39,
"end": 46,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 47,
"end": 50,
"name": "log"
},
"computed": false,
"optional": false
},
"arguments": [
{
"type": "Identifier",
"start": 51,
"end": 55,
"name": "args"
},
{
"type": "Identifier",
"start": 57,
"end": 58,
"name": "a"
}
],
"optional": false
}
}
]
}
}
],
"sourceType": "module"
}

变量声明就是 VariableDeclaration,包含 declarations 数组和 kind,之所以是数组是因为有 const a = 1, b = 2; 这种写法。

数组中的每个元素是 VariableDeclarator,包含 idinit

idIdentifier 是变量名。

init 即初始值,包括 typevalue,此处 typeLiteralvalue1

kind 即声明类型,此处是 const

AST 应用

  • Babel、ESLint 插件
    • Babel 主要做代码转换
    • ESLint 主要做错误检查和修复
  • 代码转换
    • ES6 转 ES5
    • TypeScript、JSX 转 JavaScript
    • css 预处理器编译
  • 代码压缩/混淆
    • terser-webpack-plugin
  • 模板编译
    • JSX、Vue 模板编译
  • Code2Code
    • 如 vue-to-react
  • IDE
    • 代码高亮、代码提示、代码格式化
  • 可视化编程(LowCode 方向)
    • 相比于 Schema 驱动,AST 驱动更加灵活,配合 CloudIDE、CodeSandbox 等浏览器端在线编译实现

Babel

Babel 解析代码后生成的 AST 是以 ESTree 作为基础,并略作修改。

Babel 核心工具包

工具 说明
@babel/core Babel 转码的核心包,包括了整个 babel 工作流(集成@babel/types)
@babel/parser 解析器,将代码解析为 AST
@babel/traverse 遍历/修改 AST 的工具
@babel/generator 生成器,将 AST 还原成代码
@babel/types 包含手动构建 AST 和检查 AST 节点类型的方法
@babel/template 可将字符串代码片段转化为 AST 节点

Babel 插件

相当于是指令,告诉 Babel 如何转换代码。

主要分为两类:

  • 语法插件。作用于 @babel/parser,负责将代码转化为 AST。官方插件以 babel-plugin-syntax 开头。
  • 转换插件。作用于 @babel/core,负责转换 AST 的形态。绝大情况下我们都是在编写转换插件。

插件的本质是编写各种 visitor 去访问 AST 的节点,并进行 parse,将节点 transform 最终 generate 出代码。

如 ES6 => ES5 let 转 var:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function (babel) {
const { types: t } = babel;

return {
name: "let-to-var",
visitor: {
VariableDeclaration(path) {
if (path.node.kind === "let") {
path.node.kind = "var";
}
},
},
};
}

ESLint 插件

变量名长度限制:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports.rules = {
"var-lenght": (context) => {
VariableDeclaration(node) {
if (node.id.name.length <= 2) {
context.report({
node,
message: "变量名长度需要大于 2 个字符",
});
}
},
}
}

JSX 节点

1
<Button>{props.name}</Button>

转化为

1
2
3
4
5
types.jsxElement(
types.jsxOpeningElement(types.jsxIdentifier("Button"), []),
types.jsxClosingElement(types.jsxIdentifier("Button")),
[types.jsxExpressionContainer(types.identifier("props.name"))]
);
1
2
3
4
<>
<Button>{props.name}</Button>
<Button>{props.age}</Button>
</>

转化为

1
2
3
4
5
6
7
8
9
10
11
12
types.jsxFragment(types.jsxOpeningFragment(), types.jsxClosingFragment(), [
types.jsxElement(
types.jsxOpeningElement(types.jsxIdentifier("Button"), []),
types.jsxClosingElement(types.jsxIdentifier("Button")),
[types.jsxExpressionContainer(types.identifier("props.name"))]
),
types.jsxElement(
types.jsxOpeningElement(types.jsxIdentifier("Button"), []),
types.jsxClosingElement(types.jsxIdentifier("Button")),
[types.jsxExpressionContainer(types.identifier("props.age"))]
),
]);

引用

手把手带你走进 Babel 的编译世界——BoBoooooo

Vite

[TOC]

Vite(读音veet)

  • 一个开发服务器。基于原生 ESM 提供了丰富的内建功能,如速度惊人的模块热更新(HMR)。
  • 一套构建指令。用预配置的 Rollup 打包生产代码,可输出高度优化的静态资源。
  • 提供开箱即用的配置。插件 API 和 JavaScript API 带来高度可扩展性。

解决问题:

  • 缓慢的服务器启动。
  • 缓慢的更新。

优化服务器启动时间

  • Vite 将模块分为依赖源码
  • 依赖基本不变。Vite 使用 esbuild 预构建依赖,用 Go 编写的 esbuild 构建比传统的 JS 快 10 倍。
  • 源码时常变化,且并不是所有源码需要同时被加载。Vite 以原生 ESM 方式提供源码。让浏览器接管了部分打包的工作。即只在屏幕中实际使用时才会被处理。

优化更新时间

  • 一些打包器的开发服务器将构建内容存入缓存,这样重新加载页面时会消除应用的当前状态。所以支持了动态模块热替换(HMR)。但实践即使用 HMR 随着应用规模增长热更新速度也会显著下降。
  • Vite 中的 HMR 在原生 ESM 上实现。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活(大多数只是模块本身)。
  • Vite 会利用 HTTP 头加速页面重载,会根据 304 NOT MODIFIED 进行协商缓存,对依赖模块通过Cache-Control: max-age=31536000, imuutable进行强缓存。

生产环境还是需要打包

  • 尽管原生 ESM 得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即时使用 HTTP/2)。
  • 为了在生产环境中获得最佳的加载性能,最好还是对代码进行 Tree-shaking、懒加载和 Chunk 分割(以获得更好的缓存)。
  • 虽然 esbuild 快得惊人,目前还是 Rollup 更加成熟和灵活。

Vite 使用

浏览器支持

支持原生 ESM 语法的 script 标签、原生 ESM 动态导入和 import.meta 的浏览器。

传统浏览器可以通过 @vitejs/plugin-legacy 支持。

Rollup

[TOC]

Rollup

Rollup 是一个 JS 模块打包工具,用 ES6 的格式来打包。

ES 模块带来的优势

  • 是官方标准,是明确的未来发展方向
  • CommonJS 是一种特殊的兼容格式,是临时解决方案
  • ES 模块允许静态分析,可以辅助优化(如 Tree-shaking 和作用域提升),并提供高级功能(如循环引用和实时绑定)

Tree-shaking

  • ES 模块带来的优势。
  • 即“保留有用代码”。在 Rollup 中使用,用于消除未使用的代码。
  • 该名称源自模块的抽象语法树,和“标记-清除”垃圾收集算法类似。

Rollup 使用

命令行

对于浏览器,Rollup 可以打包成 ES6 模块。

1
2
# iife 会将代码封装起来,以便给 script 标签使用
rollup main.js --file bundle.js --format iife

对于 Node.js,Rollup 可以打包成 CommonJS 格式。

1
rollup main.js --file bundle.js --format cjs

对于浏览器和 Node.js,Rollup 可以打包成 UMD 格式。

1
2
# umd 格式需要一个包名
rollup main.js --file bundle.js --format umd --name "myBundle"

配置文件

在根目录中的 rollup.config.mjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// rollup.config.mjs
import json from "@rollup/plugin-json";
import terser from "@rollup/plugin-terser";

export default {
input: "src/main.js",
output: [
{
file: "bundle.js",
format: "cjs",
},
{
file: "bundle.min.js",
format: "iife",
name: "version",
plugins: [terser()],
},
],
plugins: [json()],
watch: {},
};

package.jsonscript 中添加"build": "rollup -c"。在 module 字段指定 ES6 模块入口,main 字段指定 CommonJS 或 UMD 模块入口。

插件

@rollup/plugin-node-resolve

找到外部模块。

@rollup/plugin-commonjs

将 CommonJS 转化为 ES6(ES2015)。

对等依赖(peer dependency)

lodash 作为外部导入的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import resolve from "@rollup/plugin-node-resolve";

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "cjs",
},
plugins: [
resolve({
moduleDirectories: ["node_modules"],
}),
],
external: ["lodash"],
};

Rollup 4

  • Node 18。
  • 面向浏览器的构建需要依赖一个 wasm 文件,需要将 @rollup/browser 添加到 optimizeDeps.exclude 中。

Rollup 和 Babel

使用 @rollup/plugin-babel@rollup/plugin-node-resolve 插件。添加到 rollup.config.mjs

1
2
3
4
5
6
7
8
9
10
11
import resolve from "@rollup/plugin-node-resolve";
import babel from "@rollup/plugin-babel";

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "cjs",
},
plugins: [resolve(), babel({ babelHelpers: "bundled" })],
};

同时创建一个 src/.babelrc.json 文件:

1
2
3
{
"presets": ["@babel/env"]
}

还需要安装 @babel/core@babel/preset-env

ES 模块语法

导入:

1
2
3
4
5
6
7
8
9
10
11
// 具名导入
import { something } from "some-module";
import { something as somethingElse } from "another-module";
// 名称空间导入
import * as all from "all-module";
// 默认导入
import defaultExport from "default-export";
// 无命名导入,适用于 polyfill 或处理 prototypes
import "side-effect";
// 动态导入,适用于拆分和动态使用模块
import("some-module").then(({ default: something, somethingElse }) => {});

导出:

1
2
3
4
5
6
7
8
9
// 具名导出
const something = "something";
export { something };
export { something as somethingElse };

export const something = "something";
export function something() {}
// 默认导出
export default something;

React相关知识点

[TOC]

React 18 新特性

新的 ReactHooks

useTransition 和 startTransition,即过渡更新模式(Transition Mode)

可以将某些状态更新标记为不紧急的。

相比于 setTimeout

  • startTransition 是立刻执行的,setTimeout 是延迟执行的。
  • startTransition 可被中断的,setTimeout 是阻塞的。
1
2
3
4
5
6
7
8
const [isPending, startTransition] = useTransition();
const [count, setCount] = useState(0);

function handleClick() {
startTransition(() => {
setCount((c) => c + 1);
});
}

useDeferredValue,即延迟渲染(Deferred Rendering)

实现类似于防抖节流的延迟渲染,是可被中断的,没有固定的时间延迟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Father() {
const [value, setValue] = useState(0);
const deferredValue = useDeferredValue(value);

return (
<div>
<button onClick={() => setValue((v) => v + 1)}>Father: {value}</button>
<Child value={deferredValue} />
</div>
);
}

function Child({ value }) {
let temp = 0;
for (let i = 0; i < 1000000000; i++) {
temp++;
}
return <div>Child: {value}</div>;
}

useId

useId 用于生成唯一的 id。

1
const id = useId();

useSyncExternalStore

用于同步外部状态,常用于集成外部 React 库。

1
2
const value = useSyncExternalStore(externalStore.subscribe, externalStore.read);
return <div>{value}</div>;

useInsertionEffect

类似于 useEffect,在 useLayoutEffect 获取布局前执行。一般用于动态插入 style 标签或 SVG 的 defs。

缺点是不能获取到 refs,也不能触发 React 更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在你的 CSS-in-JS 库中
let isInserted = new Set();
function useCSS(rule) {
useInsertionEffect(() => {
// 同前所述,我们不建议在运行时注入 <style> 标签。
// 如果你必须这样做,那么应当在 useInsertionEffect 中进行。
if (!isInserted.has(rule)) {
isInserted.add(rule);
document.head.appendChild(getStyleForRule(rule));
}
});
return rule;
}

function Button() {
const className = useCSS("...");
return <div className={className} />;
}

注意:useInsertionEffect 在 SSR 阶段是不会执行的。

并发模式(Concurrent Mode)

有两个重要特性:

  • 渲染可中断。让用户输入立即被响应。
  • 可重用状态。如切换标签页时,能立即恢复到之前的状态。后期考虑增加标签。

适用场景:计算量大的游戏应用、地图应用和机器学习应用。

和 Fiber 的关系

Fiber 简介:

  • React 16 之前用的是 Stack Reconciler,任务无法中断或拆分,递归方式更新。
  • React 16 引入 Fiber 架构,任务可中断和恢复,遍历更新。将更新任务拆分成小任务(即 Fibers),引入了调度器(Sehedule),从而将渲染任务拆分至主线程空闲时完成。

关系:

  • Fiber 架构的细粒度任务调度模式,为并发模式提供了基础。
  • 在 React 18 之前,Fiber 可以让任务暂停并根据优先级执行,并支持异步渲染,但属于同步不可中断更新。在 React 18 之后,可以交替执行不同任务,变为异步可中断更新。
  • 并发模式是实现并发更新的基本前提,时间切片是实现并发更新的具体手段。

应用

  • 通过 startTransitionuseTransition 实现过渡更新模式。
  • 通过 useDeferredValue 实现延迟渲染。
  • Suspense
  • RSC(React Server Components)

Suspense

依赖于错误边界(ErrorBoundary)组件,可以捕获子组件抛出的任何错误。

此前 Suspense 主要用来配合 React.lazy 实现代码拆分和懒加载。

React 18 中的 Suspense 新增了对 SSR 的支持。属于特殊的错误边界组件。在子组件抛出 Promise 时会渲染 fallback UI,直到 Promise resolved 之后重新渲染。

现在,即时 Suspense 的值为 null 或 undefined,也不会跳过。

RSC(React Server Components)

对比 SSR 和 SSG:

  • 不会在服务器上注水,不会向客服端发送任何 JS。
  • 减少了 JS 捆绑包的体积

RSC 的优点:

  • 减轻客户端工作负载,改善 LCP 和 FID。
  • 高效的 SEO。
  • 增强的安全性。
  • 有权限直接访问服务端程序和 API,数据获取更快,客户端只能通过请求访问部分程序。

缺点:

  • 不能用 React Hooks。
  • 不能访问浏览器 API。

CSR -> SSR

React Suspense and Streaming

可以暂停 React 数中的呈现,在后台获取内容并将其分块流式传输到客户端时显示一个正在加载的组件作为占位符,一旦准备就绪就会无缝切换。

React DOM Server

都支持流式 Suspense。

renderToPipeableStream

用于 Node 的流式传输。

renderToReadableStream

用于 Deno 或 Cloudflare Workers。

React DOM Client

两者都接收一个新选项:onRecoverableError

createRoot

createRoot 用于创建根节点。替代 reactDOM.render

1
const root = createRoot(document.getElementById("root"));

hydrateRoot

hydrateRoot 用于服务器渲染。替代 reactDOM.hydrate

自动批处理(Automatic Batching)

在 React 18 之前,异步更新比如同一个 Pormise 里的多个 setState 不会自动合并,需要手动调用 unstable_batchedUpdates 合并更新。

从 React 18 开始 createRoot,React 会自动合并异步更新。

可以用 React.flushSync() 退出批处理。

新的严格模式行为

其他

  • React 组件可以返回 undefined。

React Hooks

React Hooks 列表

useState

useState 返回一个数组,第一个元素是状态值,第二个元素是更新状态的函数,在这个回调函数里可以获取到更新后的 state。

在 React 18 开始,setState 在异步函数里不会立刻更新状态,React 会将多个 setState 合并成一个更新。

1
const [count, setCount] = useState(0);

useEffect

useEffect 用于执行副作用操作,比如数据请求、DOM 操作等。

第二个参数是依赖数组,只有依赖数组中的值发生变化时,才会执行副作用操作。

1
2
3
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);

useContext

useContext 用于在函数组件中获取上下文。

1
const value = useContext(MyContext);

useReducer

useReducer 用于复杂的状态逻辑。

第一个参数是 reducer 函数,第二个参数是初始状态,第三个参数是初始化函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}

const [state, dispatch] = useReducer(reducer, { count: 0 });

dispatch({ type: "increment" });

useCallback

useCallback 用于缓存函数。

1
2
3
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);

useMemo

useMemo 用于缓存计算结果。

1
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useRef

useRef 用于获取 DOM 元素或缓存变量。

1
const inputEl = useRef(null);

useImperativeHandle

useImperativeHandle 用于自定义暴露给父组件的实例值。

1
2
3
4
5
6
7
8
9
forwardRef((props, ref) => {
const inputEl = useRef(null);

useImperativeHandle(ref, () => ({
focus: () => {
inputEl.current.focus();
},
}));
});

可以将子组件暴露给父组件,让父组件可以直接调用子组件的方法。

useLayoutEffect

useLayoutEffectuseEffect 类似,但是会在浏览器 layout 之后执行。

1
2
3
useLayoutEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);

适用于要测量计算 DOM 尺寸或者位置的情况,避免出现闪烁或布局抖动。

useDebugValue

useDebugValue 用于在 React 开发者工具中显示自定义 hook 的标签。

1
2
3
4
5
6
7
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

useDebugValue(isOnline ? "Online" : "Offline");

return isOnline;
}

常见的问题

闭包陷阱

在如 useEffectuseCallbackuseMemo 等 hooks 中,如果使用外部变量,React 只会记住该变量的初始值,除非依赖项更新否则而不会更新。

引用类型的依赖项

在如 useEffectuseCallbackuseMemo 等 hooks 中,如果依赖项是一个对象,那么每次更新时都会生成一个新的对象,导致依赖项发生变化,从而导致副作用操作执行多次。

1
2
3
4
5
const [state, setState] = useState({ count: 0 });

useEffect(() => {
console.log("effect");
}, [state]);

父子组件之间交互的方式

建议的方式:

  • 父组件通过 props 传递数据给子组件,通过 useCallback 传递函数给子组件。
  • 通过 useContext 在父子组件之间传递数据。
  • 通过状态库管理全局状态,如 Redux、Mobx。
  • 通过 EventEmitter 让父子组件之间互相订阅。

不推荐的方式:

  • useImperativeHandle 暴露子组件给父组件。不推荐使用,因为会破坏封装性。
  • 父组件通过 useRef 获取子组件实例或者 Dom 元素。

React 生命周期

旧版生命周期

  • componentWillMount:组件挂载前调用。
  • componentDidMount:组件挂载后调用。
  • componentWillReceiveProps:组件接收到新的 props 时调用。
  • shouldComponentUpdate:组件接收到新的 props 或者 state 时调用,用于判断是否需要重新渲染。
  • componentWillUpdate:组件更新前调用。
  • componentDidUpdate:组件更新后调用。
  • componentWillUnmount:组件卸载前调用。
  • componentDidCatch:组件发生错误时调用。
  • componentWillReceiveProps:组件接收到新的 props 时调用。

新版生命周期

  • constructor:构造函数。
  • static getDerivedStateFromProps:静态方法,用于派生状态。
  • render:渲染函数。
  • componentDidMount:组件挂载后调用。
  • shouldComponentUpdate:组件接收到新的 props 或者 state 时调用,用于判断是否需要重新渲染。
  • getSnapshotBeforeUpdate:在更新 DOM 之前获取快照。
  • componentDidUpdate:组件更新后调用。
  • componentWillUnmount:组件卸载前调用。
  • componentDidCatch:组件发生错误时调用。
  • componentDidCatch:组件接收到新的 props 时调用。

React 性能优化

优化策略

  • 使用 React.memo 缓存组件。
  • 使用 useMemo 缓存计算结果。
  • 使用 useCallback 缓存函数。
  • 使用 useRef 缓存 DOM 元素或变量。
  • 使用 useLayoutEffect 替代 useEffect
  • 使用 React.lazyReact.Suspense 实现组件懒加载。
  • 使用 React.PureComponent 代替 Component
  • 使用 shouldComponentUpdate 避免不必要的渲染。
  • 使用 React.createContext 代替 props 传递数据。

Vercel

[TOC]

Vercel

Vercel 是一个云平台,用于构建、部署和扩展无服务应用程序和静态网站。

Vercel 同时也在赞助 Next.js。Next.js 是一个 React 框架,用于构建静态和动态网站。

Vercel 前身是 ZEIT,是一个云服务提供商,提供部署、托管和监控 Node.js 应用程序的服务。

特点

  • 前端代码的零配置无缝部署
  • 实时洞察、分析和性能指标
  • “无限”可扩展性
  • 边缘网络(CDN、缓存、边缘功能)
  • 静态资产托管(图像、媒体)
  • 部署预览和 A/B 测试
  • 99.99% 正常运行时间
  • 基于 Git 的工作流
  • 个人免费套餐

前端与客户端相关

客户端相关

WebView

WebView 是一种嵌入到客户端的浏览器控件,可以加载网页,展示 HTML 内容。

Android

  • 基于 WebKit 的 WebView:在 Android 4.4 之前,Android 使用的是基于 WebKit 的 WebView。
  • 基于 Cromiun 的 WebView:从 Android 4.4 开始,为开发者提供了基于 Cromiun 的 WebView,性能、安全、功能更好,支持 HTML5、CSS3 和 JS 新特性。

iOS

  • UIWebView:UIWebView 是 iOS 2.0 时期引入的,是一个基于 WebKit 的控件。
    • 支持 NSURL。
  • WKWebView:iOS 8 起,苹果推出了新的 WKWebView,取代了 UIWebView,在 iOS 13 后宣布不再支持 UIWebView。
    • 多进程模型。
    • 大幅度降低占用内存,支持 60fps 的刷新率,内置手势测探。
    • 支持多进程。
    • 支持如 indexedDB 和 WebAssembly 等 HTML 5 新特性。
    • 代理方法命名更加清晰,提供更多功能。

JSBridge

简介与对比

JSBridge 是客户端与 H5 交互的桥梁,通过 JSBridge 可以实现客户端与 H5 的双向通信。

H5:

  • 版本迭代快,上线灵活
  • 兼容跨平台系统,如 PC、Android、iOS

Native:

  • 使用原生内核,更加稳定
  • 网速影响较小
  • 加载速度快,更加流畅
  • 原生系统 API 丰富,能实现的功能较多,体验较好

双向通信原理

JS 调用 Native

  • 拦截 URL Schema
  • 重写 prompt 方法
  • 注入 API
拦截 URL Schema
  • Android使用 shouldOverrideUrlLoading() 方法拦截 URL。
  • iOS 使用 UIWebView 的 delegate 方法拦截 URL。
  • 有长度的隐患。
重写 prompt 方法
  • 拦截 window.prompt() 方法。
注入 API
  • Android 使用 addJavascriptInterface() 方法。
  • iOS 的 WKWebView 提供 window.webkit.messageHandles 方法。
  • iOS 的 UIWebView 提供 JavaScriptScope 方法。

Native 调用 JS

拼接 JS 代码
  • Android 4.4 之后使用 evaluateJavascript() 方法调用 JS 方法。
  • Android 4.4 之前使用 loadUrl() 方法调用 JS 方法。
  • iOS 的 WKWebView 使用 evaluateJavaScript() 方法调用 JS 方法。

单向调用回调如何处理

类似 JSONP 的思路,在 url 里面拼接 callback 参数。

离线包

离线包是客户端将 web 资源本地缓存,请求 web 页面时拦截 webview 的请求,并优先使用本地缓存的静态资源进行响应,以此来优化页面的加载性能。

实现方案

  • 前端
    • 前端和客户端约定一套统一的页面 URL 的路由规则发布。
    • 在发布的时候配置是否需要离线包和是否差量更新(需要设置 diff 包的最大版本差,比如 10),上传离线包、计算差量包。
    • 离线包工程根目录新增 appJson.json 文件,用于存放离线包的信息,包括离线包需要过滤的文件、客户端预加载的接口列表等。
    • 一般离线包体积限定为 2mb 以内,图片等静态资源一般使用 CDN 链接。
    • 只有常驻 webview 页面,或经常会打开的重要活动页面可以用离线包。否则会让客户端重启时下载负担过重,造成资源浪费和空间占用。
  • 客户端
    • 客户端需要在首次打开/重启/每间隔一定小时(拉取离线包列表接口),检查离线包的版本号,如果有新版本则预加载离线包,下载时机最好是仅 WIFI。
    • 可以判断是否是全量包来差量更新。
    • 拦截 webview 的页面请求,优先使用本地缓存的静态资源进行响应。
    • 根据接口预加载列表在初始化 webview 的时候,预先获取首屏渲染依赖的接口数据,并存储在内存中,即用即销毁,H5 渲染时用内存中的数据。

性能优化相关

页面优化

相关指标

用户体验指标

  • FP(First Paint):首次绘制时间,即浏览器首次将像素绘制到屏幕的时间。即白屏结束时间。
  • FCP(First Contentful Paint):首次内容绘制时间,即浏览器首次绘制 DOM 元素的时间。
  • TTI(Time To Interactive):首次可交互时间,即页面变得可交互的时间。从 FCP 开始计算。
  • FID(First Input Delay):首次输入延迟,即用户首次与页面交互到页面响应的时间。记录 FCP 到 TTI 的时间。
  • FMP(First Meaningful Paint):首次有意义的绘制时间,即首次有意义的内容绘制到屏幕的时间。Lighthouse 6.0 已弃用,优先使用 LCP。
  • LCP(Largest Contentful Paint):最大内容绘制时间,即页面中最大的元素绘制到屏幕的时间。即首屏结束时间。
  • CLS(Cumulative Layout Shift):累计布局位移,即页面上所有元素的布局位移的总和,得分越小越稳定,应努力<0.1。

页面加载指标

  • DomLoading:开始解析 HTML 文档的时间。即 document.readyState 变为 loading 的时间,相应的 document.readystatechange 事件触发的时间。
  • DomInteractive:DOM 可交互时间,即 DOM 解析完成的时间,但是诸如图像、样式表和框架之类的子资源还在加载。即 document.readyState 变为 interactive 的时间,相应的 document.readystatechange 事件触发的时间。此时 defer 的 js 还未执行,没有 defer 时等同于 DOMContentLoaded。
  • DOMContentLoaded:DOM 加载完成时间,即 DOM 树构建完成的时间。即 document.DOMContentLoaded 事件触发的时间。此时 defer 的 js 已执行完毕。但是图片等资源还未加载完成。
  • DomComplete:DOM 完成时间,即 DOM 加载完成且所有资源(如图片、样式表等)也加载完成的时间。即 document.readyState 变为 complete 的时间,相应的 document.readystatechange 事件触发的时间。
  • Load:页面完全加载时间,即页面上所有资源加载完成的时间。即 window.onload 事件触发的时间,同样也是 document.readyState 变为 complete 的时间。

指标数值

  • 容器开销200ms
  • DNS查询25ms
  • TCP链接25ms(其中SSL建连15ms)
  • 请求响应约50ms
  • 内容传输约10ms
  • FP 一般不统计
  • DOM解析约200ms
  • FCP 在1秒内优秀 1.6秒内良好
  • FID = TTI - FCP 在0.1秒内优秀 0.3秒内良好
  • TTI 暂无指标 即DomInteractive
  • defer脚本约300ms 即DomReady(DOMContentLoaded) 介于FCP和LCP 之间
  • 资源加载约600ms
  • LCP 云音乐自定义 在2秒内优秀 3.5秒内良好 即DomComplete(Load)
  • CLS 在0.1以内优秀 0.25以内良好

相关 API

Performance

PerformanceNavigationTiming。替代 Performance.timing。兼容性还不太行。