0%

React 中提供的 hooks:

  • useState:setState

  • useReducer:setState,同时 useState 也是该方法的封装

  • useRef: ref

  • useImperativeHandle: 给 ref 分配特定的属性

  • useContext: context,需配合 createContext 使用

  • useMemo: 可以对 setState 的优化

  • useCallback: useMemo 的变形,对函数进行优化

  • useEffect: 类似 componentDidMount/Update, componentWillUnmount,当效果为 componentDidMount/Update 时,总是在整个更新周期的最后(页面渲染完成后)才执行

  • useLayoutEffect: 用法与 useEffect 相同,区别在于该方法的回调会在数据更新完成后,页面渲染之前进行,该方法会阻碍页面的渲染

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

一、Hooks 初体验

example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useState  } from 'react';

function Example() {
// 声明一个名为“count”的新状态变量
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

export default Example;

useState 就是一个 Hook,可以在我们不使用 class 组件的情况下,拥有自身的 state,并且可以通过修改 state 来控制 UI 的展示。

1、useState 状态

语法:

1
const [state, setState] = useState(initialState)
  • 传入唯一的参数: initialState,可以是数字,字符串等,也可以是对象或者数组。
  • 返回的是包含两个元素的数组:第一个元素,state 变量,setState 修改 state 值的方法。

与在类中使用 setState 的异同点:

  • 相同点:在一次渲染周期中调用多次 setState,数据只改变一次。
  • 不同点:类中的 setState 是合并,而函数组件中的 setState 是替换。

使用对比

之前想要使用组件内部的状态,必须使用 class 组件,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { Component } from 'react';

export default class Example extends Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}

render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}

而现在,我们使用函数式组件也可以实现一样的功能了。也就意味着函数式组件内部也可以使用 state 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useState } from 'react';

function Example() {
// 声明一个名为“count”的新状态变量
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

export default Example;

优化

创建初始状态是比较昂贵的,所以我们可以在使用 useState API 时,传入一个函数,就可以避免重新创建忽略的初始状态。

普通的方式:

1
2
// 直接传入一个值,在每次 render 时都会执行 createRows 函数获取返回值
const [rows, setRows] = useState(createRows(props.count));

优化后的方式(推荐):

1
2
// createRows 只会被执行一次
const [rows, setRows] = useState(() => createRows(props.count));

2、useEffect 执行副作用操作

  • effect(副作用):指那些没有发生在数据向视图转换过程中的逻辑,如 ajax 请求、访问原生 dom 元素、本地持久化缓存、绑定/解绑事件、添加订阅、设置定时器、记录日志等
  • 副作用操作可以分两类:需要清除的和不需要清除的。
  • 需要清除的,比如开启的定时器,订阅外部数据源等,这些操作如果在组件消亡后不及时清除会导致内存泄漏。
  • 不需要清除的,比如发起网络请求,手动变更 DOM,记录日志等。
  • 原先在函数组件内(这里指在 React 渲染阶段)改变 dom 、发送 ajax 请求以及执行其他包含副作用的操作都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性
  • useEffect 就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount、componentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API
  • useEffect 接收一个函数,该函数会在组件渲染到屏幕之后才执行,该函数有要求:要么返回一个能清除副作用的函数,要么就不返回任何内容
  • 与 componentDidMount 或 componentDidUpdate 不同,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快。大多数情况下,effect 不需要同步地执行。在个别情况下(例如测量布局),有单独的 useLayoutEffect Hook 供你使用,其 API 与 useEffect 相同。

语法:

1
2
3
4
5
1、useEffect(() => { doSomething });

2、useEffect(() => { doSomething },[]);

3、useEffect(() => { doSomething },[count]);

第一个参数为 effect 函数,该函数将在 componentDidMount 时触发和 componentDidUpdate 时有条件触发(该添加为 useEffect 的第二个数组参数)

第二个参数是可选的,根据条件限制看是否触发

  • 如果不传,如语法 1,则每次页面数据有更新(如 componentDidUpdate),都会触发 effect。

  • 如果为空数组[],如语法 2,则每次初始化的时候只执行一次 effect(如 componentDidMmount)

  • 如果只需要在指定变量变化时触发 effect,将该变量放入数组。如语法 3,count 只要变化,就会执行 effect,如观察者监听

清除副作用

副作用函数还可以通过返回一个函数来指定如何清除副作用,为防止内存泄漏,清除函数会在组件卸载前执行。如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已被清除。

例 1、比如 window.addEventListener(‘resize’, handleResize);:监听 resize 等

1
2
3
4
5
6
7
8
9
10
useEffect(() => {
window.addEventListener('resize', handleResize);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return (() => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
})
}, [globalRef]);

例 2、清除定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Counter(){
let [number,setNumber] = useState(0);
let [text,setText] = useState('');
useEffect(()=>{
let $timer = setInterval(()=>{
setNumber(number=>number+1);
},1000);
// useEffect 如果返回一个函数的话,该函数会在组件卸载和更新时调用
// useEffect 在执行副作用函数之前,会先调用上一次返回的函数
// 如果要清除副作用,要么返回一个清除副作用的函数
/* return ()=>{
console.log('destroy effect');
clearInterval($timer);
} */
});
// },[]);//要么在这里传入一个空的依赖项数组,这样就不会去重复执行
return (
<>
<input value={text} onChange={(event)=>setText(event.target.value)}/>
<p>{number}</p>
<button>+</button>
</>
)
}

3、useContext 组件之间传值

语法

1
const value = useContext(MyContext);

之前在用类声明组件时,父子组件的传值是通过组件属性和 props 进行的,那现在使用方法(Function)来声明组件,已经没有了 constructor 构造函数也就没有了 props 的接收,但是也可以直接收,如下:

1
2
3
4
5
6
7
8
组件:
<SwitchList dataList={toolsList} isReverse={false}/>


接收:
const SwitchList = ({dataList = null, isReverse = false}: any): React.ReactElement => {
//TODO
}

React Hooks 也为我们准备了 useContext。它可以帮助我们跨越组件层级直接传递变量,实现共享。

一:利用 createContext 创建上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { useState , createContext } from 'react';

// 创建一个 CountContext
const CountContext = createContext()

function Example(){
const [ count , setCount ] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={()=>{setCount(count+1)}}>click me</button>
{/* 将 context 传递给 子组件,context 值由value props决定*/}
<CountContext.Provider value={count}>
<Counter/>
</CountContext.Provider>
</div>
)
}
export default Example;

二:使用 useContext 获取上下文

对于要接收 context 的后代组件,只需引入 useContext() Hooks 即可。

1
2
3
4
function Counter(){
const count = useContext(CountContext) //一句话就可以得到count
return (<h2>{count}</h2>)
}

强调一点:
useContext 的参数必须是 context 对象本身:

  • 正确: useContext(MyContext)
  • 错误: useContext(MyContext.Consumer)
  • 错误: useContext(MyContext.Provider)

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 <MyContext.Provider> 的 context value 值。

4、useReducer 处理更为复杂 state 结构

语法

1
const [state, dispatch] = useReducer(reducer, initialArg, init);

useReducer 接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。

我们可以使用 useReducer 来重新写我们开篇计数器的 demo:

Example:

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
import React, { useReducer } from 'react';

const initialState = {count: 0};

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();
}
}

export default () => {

// 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<br />
<button onClick={() => dispatch({type: 'increment'})}>+</button>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
</>
);
}

优化:延迟初始化

还可以懒惰地创建初始状态。为此,您可以将 init 函数作为第三个参数传递。初始状态将设置为 init(initialArg)。

它允许您提取用于计算 reducer 外部的初始状态的逻辑。这对于稍后重置状态以响应操作也很方便:

Example.js

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
import React, { useReducer } from 'react';

function init(initialCount) {
return {count: initialCount};
}

function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
case 'reset':
return init(action.payload);
default:
throw new Error();
}
}

export default ({initialCount = 0}) => {

const [state, dispatch] = useReducer(reducer, initialCount, init);
return (
<>
Count: {state.count}
<br />
<button
onClick={() => dispatch({type: 'reset', payload: initialCount})}>
Reset
</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
</>
);

}

与 useState 的区别

  • 当 state 状态值结构比较复杂时,使用 useReducer 更有优势。
  • 使用 useState 获取的 setState 方法更新数据时是异步的;而使用 useReducer 获取的 dispatch 方法更新数据是同步的。

针对第二点区别,我们可以演示一下: 在上面 useState 用法的例子中,我们新增一个 button:

useState 中的 Example.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { useState } from 'react';

function Example() {
// 声明一个名为“count”的新状态变量
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={() => {
setCount(count + 1);
setCount(count + 1);
}}>
测试能否连加两次
</button>
</div>
);
}

export default Example;

点击 测试能否连加两次 按钮,会发现,点击一次, count 还是只增加了 1,由此可见,useState 确实是 异步 更新数据;

在上面 useReducer 用法的例子中,我们新增一个 button: useReducer 中的 Example.js

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
import React, { useReducer } from 'react';

const initialState = {count: 0};

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();
}
}

export default () => {

// 使用 useReducer 函数创建状态 state 以及更新状态的 dispatch 函数
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<br />
<button onClick={() => dispatch({type: 'increment'})}>+</button>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => {
dispatch({type: 'increment'});
dispatch({type: 'increment'});
}}>
测试能否连加两次
</button>
</>
);
}

点击 测试能否连加两次 按钮,会发现,点击一次, count 增加了 2,由此可见,每次 dispatch 一个 action 就会更新一次数据,useReducer 确实是 同步 更新数据;

对于 useReducer 和 useState 的区别主要是以下两点:

  • 当 state 状态值结构比较复杂时,使用 useReducer 更有优势。
  • 使用 useState 获取的 setState 方法更新数据时是异步的;而使用 useReducer 获取的 dispatch 方法更新数据是同步的。

5、useMemo 性能优化

语法:

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

返回一个 memoized 值。 传递“创建”函数和依赖项数组。useMemo 只会在其中一个依赖项发生更改时重新计算 memoized 值。此优化有助于避免在每个渲染上进行昂贵的计算。

useMemo 在渲染过程中传递的函数会运行。不要做那些在渲染时通常不会做的事情。例如,副作用属于 useEffect,而不是 useMemo。

用法

useMemo 可以帮助我们优化子组件的渲染,比如这种场景: 在 A 组件中有两个子组件 B 和 C,当 A 组件中传给 B 的 props 发生变化时,A 组件状态会改变,重新渲染。此时 B 和 C 也都会重新渲染。其实这种情况是比较浪费资源的,现在我们就可以使用 useMemo 进行优化,B 组件用到的 props 变化时,只有 B 发生改变,而 C 却不会重新渲染。

例子:

ExampleA.js

1
2
3
4
5
6
7
8
import React from 'react';

export default ({ text }) => {

console.log('Example A:', 'render');
return <div>Example A 组件:{ text }</div>

}

ExampleB.js

1
2
3
4
5
6
7
8
import React from 'react';

export default ({ text }) => {

console.log('Example B:', 'render');
return <div>Example B 组件:{ text }</div>

}

App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useState } from 'react';
import ExampleA from './ExampleA';
import ExampleB from './ExampleB';

import './App.css';

export default () => {

const [a, setA] = useState('ExampleA');
const [b, setB] = useState('ExampleB');

return (
<div>
<ExampleA text={ a } />
<ExampleB text={ b } />
<br />
<button onClick={ () => setA('修改后的 ExampleA') }>修改传给 ExampleA 的属性</button>
&nbsp;&nbsp;&nbsp;&nbsp;
<button onClick={ () => setB('修改后的 ExampleB') }>修改传给 ExampleB 的属性</button>
</div>
)
}

此时我们点击上面任意一个按钮,都会看到控制台打印了两条输出, A 和 B 组件都会被重新渲染。

现在我们使用 useMemo 进行优化

App.js

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
import React, { useState, useMemo } from 'react';
import ExampleA from './ExampleA';
import ExampleB from './ExampleB';

import './App.css';

export default () => {

const [a, setA] = useState('ExampleA');
const [b, setB] = useState('ExampleB');

+ const exampleA = useMemo(() => <ExampleA />, [a]);
+ const exampleB = useMemo(() => <ExampleB />, [b]);

return (
<div>
+ {/* <ExampleA text={ a } />
+ <ExampleB text={ b } /> */}
+ { exampleA }
+ { exampleB }
<br />
<button onClick={ () => setA('修改后的 ExampleA') }>修改传给 ExampleA 的属性</button>
&nbsp;&nbsp;&nbsp;&nbsp;
<button onClick={ () => setB('修改后的 ExampleB') }>修改传给 ExampleB 的属性</button>
</div>
)
}

此时我们点击不同的按钮,控制台都只会打印一条输出,改变 a 或者 b,A 和 B 组件都只有一个会重新渲染。

6、useCallback 优化函数式组件性能

语法:

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

返回值 memoizedCallback 是一个 memoized 回调。传递内联回调和一系列依赖项。useCallback 将返回一个回忆的 memoized 版本,该版本仅在其中一个依赖项发生更改时才会更改。当将回调传递给依赖于引用相等性的优化子组件以防止不必要的渲染(例如 shouldComponentUpdate)时,这非常有用。

这个 Hook 的 API 不能够一两句解释的清楚,建议看一下这篇文章:useHooks 第一期:聊聊 hooks 中的 useCallback。里面介绍的比较详细。

7、useRef 获取 dom

语法:

1
const refContainer = useRef(initialValue);

类组件、React 元素用 React.createRef,如:remindRef: any = React.createRef();通过 this.remindRef.current获取

函数组件使用 useRef,如let globalToolRef: any = useRef(null);通过globalToolRef.current获取

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

  • 从本质上讲,useRef 就像一个“盒子”,可以在其.current 财产中保持一个可变的价值。
  • useRef() Hooks 不仅适用于 DOM 引用。 “ref” 对象是一个通用容器,其 current 属性是可变的,可以保存任何值(可以是元素、对象、基本类型、甚至函数),类似于类上的实例属性。

注意:useRef() 比 ref 属性更有用。与在类中使用 instance(实例) 字段的方式类似,它可以 方便地保留任何可变值。

注意,内容更改时 useRef 不会通知您。变异.current 属性不会导致重新渲染。如果要在 React 将引用附加或分离到 DOM 节点时运行某些代码,则可能需要使用回调引用。

使用

下面这个例子中展示了可以在 useRef() 生成的 ref 的 current 中存入元素、字符串

Example.js

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
import React, { useRef, useState, useEffect } from 'react';

export default () => {

// 使用 useRef 创建 inputEl
const inputEl = useRef(null);

const [text, updateText] = useState('');

// 使用 useRef 创建 textRef
const textRef = useRef();

useEffect(() => {
// 将 text 值存入 textRef.current 中
textRef.current = text;
console.log('textRef.current:', textRef.current);
});

const onButtonClick = () => {
// `current` points to the mounted text input element
inputEl.current.value = "Hello, useRef";
};

return (
<>
{/* 保存 input 的 ref 到 inputEl */}
<input ref={ inputEl } type="text" />
<button onClick={ onButtonClick }>在 input 上展示文字</button>
<br />
<br />
<input value={text} onChange={e => updateText(e.target.value)} />
</>
);

}

点击 在 input 上展示文字 按钮,就可以看到第一个 input 上出现 Hello, useRef;在第二个 input 中输入内容,可以看到控制台打印出对应的内容。

8、useLayoutEffect

语法:

1
useLayoutEffect(() => { doSomething });

与 useEffect Hooks 类似,都是执行副作用操作。但是它是在所有 DOM 更新完成后触发。可以用来执行一些与布局相关的副作用,比如获取 DOM 元素宽高,窗口滚动距离等等。

进行副作用操作时尽量优先选择 useEffect,以免阻止视觉更新。与 DOM 无关的副作用操作请使用 useEffect。

用法

用法与 useEffect 类似。

Example.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { useRef, useState, useLayoutEffect } from 'react';

export default () => {

const divRef = useRef(null);

const [height, setHeight] = useState(100);

useLayoutEffect(() => {
// DOM 更新完成后打印出 div 的高度
console.log('useLayoutEffect: ', divRef.current.clientHeight);
})

return <>
<div ref={ divRef } style={{ background: 'red', height: height }}>Hello</div>
<button onClick={ () => setHeight(height + 50) }>改变 div 高度</button>
</>

}

注意:

  • useLayoutEffect 相比 useEffect,通过同步执行状态更新可解决一些特定场景下的页面闪烁问题。
  • useEffect 可以满足百分之 99 的场景,而且 useLayoutEffect 会阻塞渲染,请谨慎使用。
  • useEffect 在全部渲染完毕后才会执行
  • useLayoutEffect 会在 浏览器 layout 之后,painting 之前执行
  • 其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect
  • 可以使用它来读取 DOM 布局并同步触发重渲染
  • 在浏览器执行绘制之前 useLayoutEffect 内部的更新计划将被同步刷新
  • 尽可能使用标准的 useEffect 以避免阻塞视图更新

forwardRef

因为函数组件没有实例,所以函数组件无法像类组件一样可以接收 ref 属性

1
2
3
4
5
6
7
8
9
function Parent() {
return (
<>
// <Child ref={xxx} /> 这样是不行的
<Child />
<button>+</button>
</>
)
}
  • forwardRef 可以在父组件中操作子组件的 ref 对象
  • forwardRef 可以将父组件中的 ref 对象转发到子组件中的 dom 元素上
  • 子组件接受 props 和 ref 作为参数
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
function Child(props,ref){
return (
<input type="text" ref={ref}/>
)
}
Child = React.forwardRef(Child);
function Parent(){
let [number,setNumber] = useState(0);
// 在使用类组件的时候,创建 ref 返回一个对象,该对象的 current 属性值为空
// 只有当它被赋给某个元素的 ref 属性时,才会有值
// 所以父组件(类组件)创建一个 ref 对象,然后传递给子组件(类组件),子组件内部有元素使用了
// 那么父组件就可以操作子组件中的某个元素
// 但是函数组件无法接收 ref 属性 <Child ref={xxx} /> 这样是不行的
// 所以就需要用到 forwardRef 进行转发
const inputRef = useRef();//{current:''}
function getFocus(){
inputRef.current.value = 'focus';
inputRef.current.focus();
}
return (
<>
<Child ref={inputRef}/>
<button onClick={()=>setNumber({number:number+1})}>+</button>
<button onClick={getFocus}>获得焦点</button>
</>
)
}

10、useImperativeHandle

  • useImperativeHandle 可以让你在使用 ref 时,自定义暴露给父组件的实例值,不能让父组件想干嘛就干嘛
  • 在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef 一起使用
  • 父组件可以使用操作子组件中的多个 ref
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
import React,{useState,useEffect,createRef,useRef,forwardRef,useImperativeHandle} from 'react';

function Child(props,parentRef){
// 子组件内部自己创建 ref
let focusRef = useRef();
let inputRef = useRef();
useImperativeHandle(parentRef,()=>{
// 这个函数会返回一个对象
// 该对象会作为父组件 current 属性的值
// 通过这种方式,父组件可以使用操作子组件中的多个 ref
return {
focusRef,
inputRef,
name:'计数器',
focus(){
focusRef.current.focus();
},
changeText(text){
inputRef.current.value = text;
}
}
});
return (
<>
<input ref={focusRef}/>
<input ref={inputRef}/>
</>
)

}
const ForwardChild = forwardRef(Child);
function Parent(){
const parentRef = useRef();//{current:''}
function getFocus(){
parentRef.current.focus();
// 因为子组件中没有定义这个属性,实现了保护,所以这里的代码无效
parentRef.current.addNumber(666);
parentRef.current.changeText('<script>alert(1)</script>');
console.log(parentRef.current.name);
}
return (
<>
<ForwardChild ref={parentRef}/>
<button onClick={getFocus}>获得焦点</button>
</>
)
}

官网介绍 forwardRef 与 useImperativeHandle 结合使用

image

11、useMemo 和 useCallback 的使用

useMemo

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

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

也就是说 useMemo 可以让函数在某个依赖项改变的时候才运行,这可以避免很多不必要的开销。举个例子:

不使用 useMemo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Example() {
const [count, setCount] = useState(1);
const [val, setValue] = useState('');

function getNum() {
return Array.from({length: count * 100}, (v, i) => i).reduce((a, b) => a+b)
}

return <div>
<h4>总和:{getNum()}</h4>
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<input value={val} onChange={event => setValue(event.target.value)}/>
</div>
</div>;
}

上面这个组件,维护了两个 state,可以看到 getNum 的计算仅仅跟 count 有关,但是现在无论是 count 还是 val 变化,都会导致 getNum 重新计算,所以这里我们希望 val 修改的时候,不需要再次计算,这种情况下我们可以使用 useMemo。

使用 useMemo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Example() {
const [count, setCount] = useState(1);
const [val, setValue] = useState('');

const getNum = useMemo(() => {
return Array.from({length: count * 100}, (v, i) => i).reduce((a, b) => a+b)
}, [count])

return <div>
<h4>总和:{getNum()}</h4>
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<input value={val} onChange={event => setValue(event.target.value)}/>
</div>
</div>;
}

使用 useMemo 后,并将 count 作为依赖值传递进去,此时仅当 count 变化时才会重新执行 getNum。

useCallback

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

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

看起来似乎和 useMemo 差不多,我们来看看这两者有什么异同:

useMemo 和 useCallback 接收的参数都是一样,都是在其依赖项发生变化后才执行,都是返回缓存的值,区别在于 useMemo 返回的是函数运行的结果,useCallback 返回的是函数。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

使用场景

正如上面所说的,当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。也就是说父组件传递一个函数给子组件的时候,由于父组件的更新会导致该函数重新生成从而传递给子组件的函数引用发生了变化,这就会导致子组件也会更新,而很多时候子组件的更新是没必要的,所以我们可以通过 useCallback 来缓存该函数,然后传递给子组件。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent() {
const [count, setCount] = useState(1);
const [val, setValue] = useState('');

const getNum = useCallback(() => {
return Array.from({length: count * 100}, (v, i) => i).reduce((a, b) => a+b)
}, [count])

return <div>
<Child getNum={getNum} />
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<input value={val} onChange={event => setValue(event.target.value)}/>
</div>
</div>;
}

const Child = React.memo(function ({ getNum }: any) {
return <h4>总和:{getNum()}</h4>
})

使用 useCallback 之后,仅当 count 发生变化时 Child 组件才会重新渲染,而 val 变化时,Child 组件是不会重新渲染的。

在 React router 中通常使用的组件有三种:

  • 路由组件(作为根组件): BrowserRouter(history 模式) 和 HashRouter(hash 模式)
  • 路径匹配组件: Route 和 Switch
  • 导航组件: Link 和 NavLink

关于路由组件,如果我们的应用有服务器响应 web 的请求,建议使用组件; 如果使用静态文件服务器,建议使用组件

1. 安装

1
npm install react-router-dom

2. 实例

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
import React, { Component, Fragment } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter, Route } from 'react-router-dom';
import store from './store';
import Header from './common/header';
import Home from './pages/home';
import Detail from './pages/detail';
import Login from './pages/login';


class App extends Component {
render() {
return (
<Provider store={store}>
<Fragment>
<BrowserRouter>
<div>
<Header />
<Route path='/' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
<Route path='/detail/:id' exact component={Detail}></Route>
</div>
</BrowserRouter>
</Fragment>
</Provider>
)
}
}

export default App;

3. 路由组件 BrowserRouter 和 HashRouter

BrowserRouter(history 模式) 和 HashRouter(hash 模式)作为路由配置的最外层容器,是两种不同的模式,可根据需要选择。

history 模式:

1
2
3
4
5
6
7
8
9
10
11
12
class App extends Component {
render() {
return (
<BrowserRouter>
<Header />
<Route path='/' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
<Route path='/detail/:id' exact component={Detail}></Route>
</BrowserRouter>
)
}
}

hash 模式:

1
2
3
4
5
6
7
8
9
10
11
12
class App extends Component {
render() {
return (
<HashRouter>
<Header />
<Route path='/' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
<Route path='/detail/:id' exact component={Detail}></Route>
</HashRouter>
)
}
}

4. 路径匹配组件: Route 和 Switch

一、Route: 用来控制路径对应显示的组件

有以下几个参数:

  • 4.1 path:指定路由跳转路径
  • 4.2 exact: 精确匹配路由
  • 4.3 component:路由对应的组件
1
2
3
4
5
import About from './pages/about';

··· ···

<Route path='/about' exact component={About}></Route>
  • 4.4 render: 通过写 render 函数返回具体的 dom:
1
<Route path='/about' exact render={() => (<div>about</div>)}></Route>

render 也可以直接返回 About 组件,像下面:

1
<Route path='/about' exact render={() => <About /> }></Route>

但是,这样写的好处是,不仅可以通过 render 方法传递 props 属性,并且可以传递自定义属性:

1
2
3
<Route path='/about' exact render={(props) => {
return <About {...props} name={'cedric'} />
}}></Route>

然后,就可在 About 组件中获取 props 和 name 属性:

1
2
3
4
5
6
7
8
9
10
componentDidMount() {
console.log(this.props)
}


// this.props:
// history: {length: 9, action: "POP", location: {…}, createHref: ƒ, push: ƒ, …}
// location: {pathname: "/home", search: "", hash: "", state: undefined, key: "ad7bco"}
// match: {path: "/home", url: "/home", isExact: true, params: {…}}
// name: "cedric"

render 方法也可用来进行权限认证:

1
2
3
4
<Route path='/user' exact render={(props) => {
// isLogin 从 redux 中拿到, 判断用户是否登录
return isLogin ? <User {...props} name={'cedric'} /> : <div>请先登录</div>
}}></Route>
  • 4.5 location: 将 与当前历史记录位置以外的位置相匹配,则此功能在路由过渡动效中非常有用
  • 4.6 sensitive:是否区分路由大小写
  • 4.7 strict: 是否配置路由后面的 ‘/‘

二、Switch

渲染与该地址匹配的第一个子节点或者

类似于选项卡,只是匹配到第一个路由后,就不再继续匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Switch>
<Route path='/home' component={Home}></Route>
<Route path='/login' component={Login}></Route>
<Route path='/detail' component={detail}></Route>
<Redirect to="/home" from='/' />
</Switch>

// 类似于:
// switch(Route.path) {
// case '/home':
// return Home
// case '/login':
// return Login
// ··· ···
// }

所以,如果像下面这样:

1
2
3
4
5
6
7
<Switch>
<Route path='/home' component={Home}></Route>
<Route path='/login' component={Login}></Route>
<Route path='/detail' component={detail}></Route>
<Route path='/detail/:id' component={detailId}></Route>
<Redirect to="/home" from='/' />
</Switch>

当路由为/detail/1 时,只会访问匹配组件 detail, 所以需要在 detail 路由上加上 exact:

1
2
3
4
5
6
7
<Switch>
<Route path='/home' component={Home}></Route>
<Route path='/login' component={Login}></Route>
<Route path='/detail' exact component={detail}></Route>
<Route path='/detail/:id' component={detailId}></Route>
<Redirect to="/home" from='/' />
</Switch>

注意:如果路由 Route 外部包裹 Switch 时,路由匹配到对应的组件后,就不会继续渲染其他组件了。但是如果外部不包裹 Switch 时,所有路由组件会先渲染一遍,然后选择到匹配的路由进行显示。

Link 和 NavLink 都可以用来指定路由跳转,NavLink 的可选参数更多。

Link

两种配置方式:

通过字符串执行跳转路由

1
2
3
<Link to='/login'>
<span>登录</span>
</Link>

通过对象指定跳转路由

  • pathname: 表示要链接到的路径的字符串。
  • search: 表示查询参数的字符串形式。
  • hash: 放入网址的 hash,例如 #a-hash。
  • state: 状态持续到 location。通常用于隐式传参(埋点),可以用来统计页面来源
1
2
3
4
5
6
7
8
<Link to={{
pathname: '/login',
search: '?name=cedric',
hash: '#someHash',
state: { fromWechat: true }
}}>
<span>登录</span>
</Link>

点击链接 进入 Login 页面后,就可以在 this.props.location.state 中看到 fromWechat: true

NavLink

可以看做 一个特殊版本的 Link,当它与当前 URL 匹配时,为其渲染元素添加样式属性。

1
2
3
<Link to='/login' activeClassName="selected">
<span>登录</span>
</Link>
1
2
3
4
5
6
7
8
9
<NavLink
to="/login"
activeStyle={{
fontWeight: 'bold',
color: 'red'
}}
>
<span>登录</span>
</NavLink>
  • exact: 如果为 true,则仅在位置完全匹配时才应用 active 的类/样式。
  • strict: 当为 true,要考虑位置是否匹配当前的 URL 时,pathname 尾部的斜线要考虑在内。
  • location 接收一个 location 对象,当 url 满足这个对象的条件才会跳转
  • isActive: 接收一个回调函数,只有当 active 状态变化时才能触发,如果返回 false 则跳转失败
1
2
3
4
5
6
7
8
9
10
11
12
const oddEvent = (match, location) => {
if (!match) {
return false
}
const eventID = parseInt(match.params.eventID)
return !isNaN(eventID) && eventID % 2 === 1
}

<NavLink
to="/login"
isActive={oddEvent}
>login</NavLink>

6. Redirect

将导航到一个新的地址。即重定向。

1
2
3
4
5
<Switch>
<Route path='/home' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
<Redirect to="/home" from='/' exact />
</Switch>

上面,当访问路由‘/’时,会直接重定向到‘/home’。
常在用户是否登录:

1
2
3
4
5
6
7
8
9
10
11
12
class Center extends PureComponent {
render() {
const { loginStatus } = this.props;
if (loginStatus) {
return (
<div>个人中心</div>
)
} else {
return <Redirect to='/login' />
}
}
}

也可使用对象形式:

1
2
3
4
5
6
7
<Redirect
to={{
pathname: "/login",
search: "?utm=your+face",
state: { referrer: currentLocation }
}}
/>

7. withRouter

withRouter 可以将一个非路由组件包裹为路由组件,使这个非路由组件也能访问到当前路由的 match, location, history 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { withRouter } from 'react-router-dom';

class Detail extends Component {
render() {
··· ···
}
}

const mapStateToProps = (state) => {
return {
··· ···
}
}

const mapDispatchToProps = (dispatch) => {
return {
··· ···
}
}

export default connect(mapStateToProps, mapDispatchToProps)(withRouter(Detail));

8. 编程式导航 - history 对象

例如,点击 img 进入登录页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Home extends PureComponent {

goHome = () => {
console.log(this.props);

this.props.history.push({
pathname: '/login',
state: {
identityId: 1
}
})
}

render() {
return (
<img className='banner-img' alt='' src="img.png" onClick={this.goHome} />
)
}
}

history 对象通常会具有以下属性和方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
length - (number 类型) history 堆栈的条目数
action - (string 类型) 当前的操作(PUSH, REPLACE, POP)
location - (object 类型) 当前的位置。location 会具有以下属性:
pathname - (string 类型) URL 路径
search - (string 类型) URL 中的查询字符串
hash - (string 类型) URL 的哈希片段
state - (object 类型) 提供给例如使用 push(path, state) 操作将 location 放入堆栈时的特定 location 状态。只在浏览器和内存历史中可用。
push(path, [state]) - (function 类型) 在 history 堆栈添加一个新条目
replace(path, [state]) - (function 类型) 替换在 history 堆栈中的当前条目
go(n) - (function 类型) 将 history 堆栈中的指针调整 n
goBack() - (function 类型) 等同于 go(-1)
goForward() - (function 类型) 等同于 go(1)
block(prompt) - (function 类型) 阻止跳转。

注意,只有通过 Route 组件渲染的组件,才能在 this.props 上找到 history 对象
所以,如果想在路由组件的子组件中使用 history ,需要使用 withRouter 包裹:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { PureComponent } from 'react';
import { withRouter } from 'react-router-dom';

class 子组件 extends PureComponent {

goHome = () => {
this.props.history.push('/home')
}


render() {
console.log(this.props)
return (
<div onClick={this.goHome}>子组件</div>
)
}
}

export default withRouter(子组件);

9. 路由过渡动画

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
import { TransitionGroup, CSSTransition } from "react-transition-group";

class App extends Component {

render() {
return (
<Provider store={store}>
<Fragment>
<BrowserRouter>
<div>
<Header />

{/* 最外部的<Route></Route>不进行任何路由匹配,仅仅是用来传递 location */}

<Route render={({location}) => {
console.log(location);
return (
<TransitionGroup>
<CSSTransition
key={location.key}
classNames='fade'
timeout={300}
>
<Switch>
<Redirect exact from='/' to='/home' />
<Route path='/home' exact component={Home}></Route>
<Route path='/login' exact component={Login}></Route>
<Route path='/write' exact component={Write}></Route>
<Route path='/detail/:id' exact component={Detail}></Route>
<Route render={() => <div>Not Found</div>} />
</Switch>
</CSSTransition>
</TransitionGroup>
)
}}>
</Route>
</div>
</BrowserRouter>
</Fragment>
</Provider>
)
}
}
1
2
3
4
5
6
7
8
9
.fade-enter {
opacity: 0;
z-index: 1;
}

.fade-enter.fade-enter-active {
opacity: 1;
transition: opacity 300ms ease-in;
}

10. 打包部署的路由配置

项目执行 npm run build 后,将打包后的 build 文件,放置到 Nginx 配置的静态地址中。

如果 react-router 路由 使用了 history 模式(即),那么在 Nginx 配置中必须加上:

1
2
3
4
5
6
location / {
··· ···
try_files $uri /index.html;
··· ···
}
}

如果 react-router 路由 使用了 hash 模式,那么在 Nginx 中不需要上面的配置。

1 微信小程序基本知识与概念

微信小程序开发,入门算是非常简单,只要看官方文档即可小程序简易教程。如何申请小程序账号,如何开发自己第一个小程序,如何发布,这一系列 hello world 操作官方文档都有手把手教学。小程序开发的每个步骤,提供的能力文档里都有,个人觉得,做小程序开发,有事没事都看下文档,因为小程序更新比较快速,同时一些细小的能力我们可能会漏掉,所以多看文档。

1.1 简单说下目录结构和 app.json

文件目录结构很灵活

先来看看小程序项目的文件目录结构

image

除了 app.json 必须位于根目录下,其他文件随意,并且都可以删。并且页面文件可以放到任何位置,只要在 app.json 中的 pages 中配置了就可以。可以说是很灵活。你还可以多个页面放在同个文件夹下。

image

接下来简单介绍下各个文件:

全局配置文件 app.json
对于一个小程序项目而言,最重要的文件是 app.json,它也是开发工具识别一个文件夹是否为小程序项目的标识。当使用开发者工具创建一个项目时,如果选择的是空文件夹,它会创建一个新的项目。如果是一个有文件的文件夹,它会看该文件夹中是否有 app.json 文件,如果有,则它会认为是一个小程序项目,则会打开该项目,如果文件夹中没有 app.json 文件,则提示无法创建项目。

image

app.json 必须放置于项目的根目录下,它是小程序项目的全局配置文件。在小程序代码包准备完成进行启动后(下文会详细介绍小程序从用户点击打开小程序到小程序销毁的整个过程),会先读取 app.json 文件,进行小程序的初始化,比如初始化整个小程序外框样式,获取首页页面地址等。

其实小程序就是微信提供的一个容器,各个页面就在这个容器里加载运行销毁

下面介绍下小程序的全局配置选项:

注意:

  • 所有配置项 key 必须使用双引号括起来,value 值为字符串类型的也必须使用双引号,不支持单引号
  • 因为小程序功能迭代非常迅速,基础库版本更新也很快,所以下面的介绍是截止目前的最新版本库 2.4.0
  • pages
1
2
3
4
"pages": [
"pages/index/index",
"pages/log/log"
]

在 app.json 中,pages 选项是必须配置的。该配置项注册了小程序所有页面的地址,其中每一项都是页面的 路径+文件名 。配置的字符串其实就是每个页面 wxml 路径,去掉.wxml 后缀。因为框架会自动去寻找路径下.json、.js、.wxml、.wxss 四个文件进行整合。也就意味着.json、.js、.wxss 这三个文件的文件名必须要和.wxml 的一致,否则不生效。所以一个页面至少必须得有.wxml 文件。

总结:

页面的.json、.js、.wxss 文件必须与.wxml 文件同名,否则不生效
每个页面都必须在 pages 下注册,没有注册的页面,如果不访问,编译能通过,一旦试图访问该页面则会报错
可以通过在 pages 下添加一个选项快速新建一个页面,开发工具会自动生成对应的文件

  • window
1
2
3
4
"window":{
"enablePullDownRefresh": ture,
"navigationStyle": "custom"
}

该配置项用于配置小程序的全局外观样式,具体请查阅文档。这里重点提一下两个比较实用的

1
2
3
4
//去掉默认的导航栏,轻松实现全面屏
"navigationStyle": "custom" ,
//开启自带的下拉刷新,减少自己写样式
"enablePullDownRefresh": true,
  • tabBar
    该选项可以让我们轻松实现导航栏 tab 效果,不过有个不足就是跳转可操作性非常低。就是每个 tab 只能跳当前小程序页面,不能跳到其他小程序。如果需要跳到其他小程序,还需自己封装个组件。

  • networkTimeout
    这是网络请求超时时间,可以设置不同类型请求的超时时间,比如 wx.request、wx.uploadFile 等。其实很多时候我们都会忽略这个选项,小程序默认是 60s 超时,但我们应该手动设置更低的值,因为我们的接口一般都会在 10s 内完成请求(如果超过 10s,那你是时候优化了),所以如果网络或者服务器出问题了,那么会让用户等 60s,最后还是失败,这对用户很不友好,还不如提前告诉用户,现在出问题了,请稍后再试。

前段时间由于公司服务器网关出现了点小问题,导致有些请求连接不上,出现大量连接超时。通过之前添加的错误信息收集插件(这个是性能优化,下文有讲到)看到了很多接口返回 time-out 60s。让用户等了 60s 还是失败,这不友好。所以这个超时时间一般设置 15s-30s 比较好。

  • debug
    是否开启 debug 功能,开启后查看更多的调试信息,方便定位问题,开发阶段可以考虑开启

  • functionalPages
    这个是结合插件使用的,因为微信小程序插件有很大限制,插件里提供的 api 很有限,wx.login 和 wx.requestPayment 在插件中不能使用,如果需要获取用户信息和进行支付,就必须通过插件提供的功能去实现。当你的小程序下的插件启用了插件功能时,必须设置该选项为 true

小程序插件必须挂载在一个微信小程序中,一个小程序也只能开通一个插件。当你小程序开通的插件启用了插件功能时,必须设置该选项为 true

  • plugins
1
2
3
4
5
6
"plugins": {
"myPlugin": {
"version": "1.0.0",
"provider": "wxidxxxxxxxxxxxxxxxx"
}
}

当小程序使用了插件就必须在这里声明引入。小程序自身开通的小程序不能在本身应用

  • navigateToMiniProgramAppIdList
1
2
3
"navigateToMiniProgramAppIdList": [
"wxe5f52902cf4de896"
]

之前小程序之间只要是关联了通过公众号就可以相互跳转,如今微信做出了限制,要这里配置好需要跳转的小程序,上限为 10 个,还必须写死,不支持配置。所以当小程序有跳转到其他小程序,一定要配好这个,否则无法跳转。

  • usingComponents
1
2
3
"usingComponents": {
"hello-component": "plugin://myPlugin/hello-component"
}

使用自定义组件或者插件提供的组件前,必须先在这里声明

1.2 小程序启动与生命周期

下面来说说小程序从用户点击打开到销毁的整个过程。用图说话更清晰,特地画了个流程图:

image

小程序启动会有两种情况,一种是「冷启动」,一种是「热启动」。 假如用户已经打开过某小程序,然后在一定时间内再次打开该小程序,此时无需重新启动,只需将后台态的小程序切换到前台,这个过程就是热启动;冷启动指的是用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动。

上面的流程图包含了所有内容,但毕竟文字有限,接下来详细说下几个点。

  1. 小程序会先检测本地是否有代码包,然后先使用本地代码包进行小程序启动,再异步去检测远端版本。这就是小程序的离线能力,相对于 H5,这是优点,能加快小程序启动速度。
  2. 当本地有小程序代码包时,会异步去请求远端是否有最新版本。有则下载到本地,但该次的启动还是会用之前的代码。所以当我们发布了最新的版本,需要用户两次冷启动,才能使用到最新版本。如果想要用户一次冷启动就可以使用到最新版本,可以使用小程序提供的版本更新API 更新。代码如下,只要在 app.js 的 onShow 函数加上以下代码,每次小程序有更新,都会提示用户更新小程序。不过这个每次提示更新,一定程度上影响用户体验。如果结合后端配置,每次进来读取配置,就可以实现根据需要是否进行该版本的更新,比如一定需要用户更新才能使用的,那就使用强制更新。对于一些小版本,就不需要使用这个强制更新。
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
if (wx.canIUse('getUpdateManager')) {
//检测是否有版本更新
var updateManager = wx.getUpdateManager()
updateManager.onCheckForUpdate(function (res) {
// 请求完新版本信息的回调,有更新
if (res.hasUpdate) {
wx.showLoading({
title: '检测到新版本',
})
}
})
updateManager.onUpdateReady(function () {
wx.hideLoading();
wx.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
success: function (res) {
if (res.confirm) {
//清楚本地缓存
try {
wx.clearStorageSync()
} catch (e) {
// Do something when catch error
}
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(function () {
// 新的版本下载失败
console.log('新版本下载失败');
})
}

1.3 开发工具

对于小程序开发工具,还没有一款让开发者满意的工具,至少我不满意,哈哈哈!微信提供的微信开发者工具。除了编译器不行外,其他都还行。但由于开发工具、ios、android 三个平台运行小程序的内核不同。所以有时会出现开发工具上没问题,真机有问题的情况,特别是样式,可以通过在开发工具中设置上传代码时样式自动补全来解决大多数问题。另外微信开发者工具提供了真机调试功能,该功能对真机调试非常方便

还有就是可以自定义编译条件

image

可以模拟任意场景值、设置页面参数、模拟更新等。基本满足了所有的调试。不过还有一些效果,开发工具和真机可能会不同,所以还是需要在真机上确认。

1.4 测试-审核-上线的那些事

服务器域名 request 合法域名每个月只能修改 5 次。所以不应该每次请求一个新域名就添加一次。在开发阶段,在微信开发者工具上勾上不校验合法域名,真机上需要开启调试模式,就可以先不配置合法域名的情况下请求任何域名甚至 ip 地址。待开发完成了,再一次性配置所有合法域名,在微信开发者工具上取消不校验合法域名,真机上关闭调试模式,然后开始测试。

使用体验版+线上环境的接口,这就是和线上环境一模一样的,所以在发布前,使用体验版+线上环境过一遍。如果没问题,发布以后也就没问题了。

小程序二维码只要发布了线上版本调用生成小程序二维码接口才能成功返回二维码。而且二维码识别是线上版本,所以还未发布的小程序是无法生成二维码的。

线上版本有个版本回退功能,这里有个坑,就是版本回退以后,退回的版本需要重新审核才能发布

image

还有设置体验版时可以设置指定路径和参数,这样很方便测试

image

2 重点介绍几个组件

接下来说说使用频率比较多,功能强大,但又有比较多坑的几个组件

2.1 web-view

web-view 的出现,让小程序和 H5 网页之前的跳转成为了可能。通过把 H5 页面放置到 web-view 中,可以让 H5 页面在小程序内运行。同时在 H5 页面中也可以跳转回小程序页面。可以说是带来了很大的便利,但同时由于 web-view 的诸多限制,用起来也不是很舒服。

  1. 需要打开的 H5 页面必须在后台业务页面中配置,这其中还有个服务校验。另外 H5 页面必须是 https 协议,否则无法打开
  2. web-view 中无法在页面中调起分享,如果需要分享,比如跳回小程序原生页面
  3. 小程序与 web-view 里 H5 通信问题。小程序向 web-view 传递,不敏感信息可以通过页面 url 传递。如果是敏感信息比如用户 token 等,可以让服务端重定向,比如请求服务端一个地址,让他把敏感信息写在 cookie 中,再重定向到我们的 H5 页面。之后 H5 页面就可以通过在 cookie 中拿这些敏感数据了,或者 http-only,发送请求时直接带上。
  4. 每次 web-view 中 src 值有变化就会重新加载一次页面。所以用 src 拼接参数时,需要先赋值给一个变量拼接好,再一次性 setData 给 web-view 的 src,防止页面重复刷新
  5. 从微信客户端 6.7.2 版本开始,navigationStyle: custom 对组件无效。也就意味着使用 web-view 时,自带的导航栏无法去掉。
  6. 因为导航栏无法去掉,这里就出现了一个巨大的坑。实现全屏效果问题。如果想要实现 H5 页面全屏,就是不滑动,全屏显示完所有内容。这时如果你使用 width:100%;height:100%,你会发现,你页面底部可能会缺失一段。上图:

image

因为 web-view 是默认铺满全屏的,也就是 web-view 宽高和屏幕宽高一样。然后 H5 页面这是高度 100%,这是相对 web-view 的高度,也是屏幕高度。但是关键问题:web-view 里 H5 页面是从导航栏下开始渲染的。这就导致了 H5 页面溢出了屏幕,无法达到全屏效果。

解决方法

这个问题我在前段时间的实际项目碰到过,我们要做个 H5 游戏,要求是全屏,刚开始我也是设置高度 100%。后来发现底部一块不见了。我的解决方法比较粗暴,如果有更好的解决方法,欢迎评论交流。
我的解决方法是:通过拼接宽高参数在 H5 页面 url 上,这个宽高是在 web-view 外层计算好的。H5 页面直接读取 url 上的宽高,动态设置页面的宽高。页面高度的计算,根据上图,很显然就是屏幕高度减去导航栏高度。宽度都是一样的,直接是屏幕宽度。

但问题又来了,貌似没有途径获取导航栏高度。而且对于不同机型的手机,导航栏高度不同。经过了对多个机型导航栏跟屏幕高度的比较。发现了一个规律,导航栏高度与屏幕高度、屏幕宽高比有一定的关系。所以根据多个机型就计算出了这个比例。这解决了 95%以上手机的适配问题,只有少数机型适配不是很好。基本实现了全屏效果。具体代码如下:

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
onLoad (options) {
//同步获取屏幕信息,现在用到的是屏幕宽高
var res = wx.getSystemInfoSync();
if (res) {
var widHeight = res.screenHeight;
//对于大多数手机,屏幕高度/屏幕宽度 = 1.78。此时导航栏占屏幕高度比为0.875
var raito = 0.875;
if (res.screenHeight / res.screenWidth > 1.95) {
//对于全屏手机,这个占比会更高些
raito = 0.885;
} else if (res.screenHeight / res.screenWidth > 1.885) {
raito = 0.88;
}
//做兼容处理,只有微信版本库高于6.7.2,有导航栏才去兼容,否则可以直接使用高度100%。res.statusBarHeight是手机顶部状态栏高度
//如果微信版本号大于6.7.2,有导航栏
if (util.compareVersion(res.version, "6.7.2") > 0) {
widHeight = Math.round(widHeight * raito) + (res.statusBarHeight || 0);
}
this.setDate({
//将H5页面宽高拼接在url上,赋值给web-view的src即可加载出H5页面
webview_src: util.joinParams(h5_src, {
"height": widHeight,
"width": res.screenWidth
})
})
}
}

2.2 scroll-view

当我们要实现一个区域内滑动效果时,在 H5 页面中我们设置overflow-y: scroll即可。但在小程序中,没有该属性。需要用到 scroll-view 标签。具体操作实现我们可以查看文件scroll-view

锚点定位在前端开发中会经常用到,在 H5 页面中,我们会在 url 后面加上#来实现锚点定位效果。但是在小程序中这样是不起作用的,因为小程序内渲染页面的容器不是一个浏览器,无法实时监听 Hash 值的变化。但是使用 scroll-view,我们可以实现锚点定位效果。主要是使用 scroll-into-view 属性,具体实现我们直接上代码

scroll-into-view | String | 值应为某子元素 id(id 不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素

wxml 文件

1
2
3
4
5
6
7
8
<!--toView的值动态变化,当toView为luckydraw时,会定位到id为luckydraw的view
需要注意的是,这里需要设置高度为屏幕高度-->
<scroll-view scroll-y scroll-into-view="{{toView}}"
scroll-with-animation = "true" style="height: 100%; white-space:nowrap">
<view id="top"></view>
<view id="luckydraw"></view>
<view id="secskill"></view>
<scroll-view>

2.3 canvas

画布标签,它是原生组件,所以它必须位于屏幕最上边,而且是不能隐藏的。所以如果想要使用 canvas 动态生成分享照片。那你要设置她的宽高和屏幕一样。要不导出为照片时就会失真。因为这个原因,所以生成分享照片还是由服务端实现吧,照片失真太严重了。

3 formid 收集

给用户发送消息对一个小程序是非常重要的,它可以召唤回用户,导量效果非常明显。我们可以通过模板消息向小程序用户发送消息,但前提是我们得获取到 openid 和 formid。用户登录我们即可获取到用户 openid。而只要用户有点击行为,我们即可获取 formid。所以说 formid 是很重要的。我们可以提前收集好 formid,在需要的时候给用户推送消息。我们可以给每个 button 都包上 form 标签,只要有用户点击行为都可以收集到 formid.

1
2
3
<form bindsubmit="formSubmit" report-submit='true'>
<button formType="submit">点击</button>
</form>

我们实现一个 formid 收集系统,为了尽量减少冗余代码和减少对业务的影响,我们的设计是这样的

  1. 在整个页面的最外层包裹 form 标签,不是每个 button 都包裹一个,这样只要是页面中formType=submit的 button 有点击都能获取到 formid。
  2. formid 保存在全局变量数组中,当小程序切换到后台是一次性发送。
  3. 对于需要实时发送消息的,不添加到全局数组中,直接保存在页面变量中。

wxml 文件

1
2
3
4
5
6
7
8
9
10
<!--在整个页面的最外层包裹form标签,这样就不必对每个button都包裹一个form标签,代码简洁-->
<form bindsubmit="formSubmit" report-submit='true'>
<view>页面内容</view>
<view>页面内容</view>
<button formType="submit">点击</button>
<view>页面内容</view>
<view>
<button formType="submit">点击</button>
</view>
</form>

page.js 文件

1
2
3
4
5
6
7
8
9
//每次用户有点击,都将formid添加到全局数组中
formSubmit(e) {
//需要实时发送的,不添加
if(e.target.dataset.sendMsg){
formid = e.detail.formId;
return;
}
app.appData.formIdArr.push(e.detail.formId);
}

app.js

1
2
3
4
onHide: function () {
//小程序切到后台时上传formid
this.submitFormId();
},

4 性能优化相关

从用户打开小程序到小程序销毁,我们可以想想有哪些地方是可以优化的。首先是打开速度。小程序打开速度直接影响了用户留存。在小程序后台,运维中心-监控告警下有个加载性能监控数据,我们可以看到小程序启动总耗时、下载耗时、首次渲染耗时等加载相关的数据。而这里的打开速度其实就是小程序的启动总耗时。它包括了代码包下载、首次渲染,微信内环境初始化等步凑。在这一步,我们能做的就是如何加快代码包下载速度和减少首次渲染时间

在小程序呈现给用户之后,接下来就是如何提高用户体验,增强小程序健壮性的问题了。每个程序都有 bug。只是我们没发现而已,尽管在测试阶段,我们进行了详尽的测试。但是在实际生产环境,不同的用户环境,不同的操作路径,随时会触发一些隐藏的 bug。这时如果用户没有向我们报告,我们是无法获知的。所以有必要给我们的小程序增加错误信息收集,js 脚本错误,意味着整个程序挂掉了,无法响应用户操作。所以对于运行时的脚本错误,我们应该上报。对出现的 bug 及时修复,增强程序健壮性,提高用户体验。

每个程序都有大量的前后端数据交互,这是通过 http 请求进行的。因此,还有一个错误信息收集就是接口错误信息收集。对那些请求状态码非 2XX、3XX 的,或者请求接口成功了,但是数据不是我们预期的,都可以进行信息采集。

通过对小程序运行时脚本和 http 请求进行监控,我们就可以实时了解我们线上小程序的运行状况,有什么问题可以及时发现,及时修复,极高地提高了用户体验性。

4.1 让小程序更快

让小程序快,主要因素有两个,代码包下载和首屏渲染。
我们来看一个数据:

image

前面状态小程序代码大小是 650Kb 左右,这是下载耗时(虽然跟用户网络有关,但这个是全部用户平均时间)是 1.3s 左右。但是经过优化,将代码包降低至 200kb 左右时。下载耗时只有 0.6s 左右。所以说,代码包减少 500kb,下载耗时能减少 0.5s。这个数据还是非常明显的。所以说,在不影响业务逻辑的情况下,我们小程序代码包应该尽可能地小。那么如何降低代码包大小呢?以下有几点可以参考

  1. 因为我们上传代码到微信服务器时,它会将我们的代码进行压缩的,所以用户下载的代码包并不是我们开发时的那个大小。对此,开发时也没必要删空行、删注释这些。在开发工具项目详情中可以看到上次上传大小,这个大小就是用户最终使用的大小。如果觉得微信压缩还不够好,可以通过第三方工具对我们代码进行一次压缩再上传,然后对比效果,有没有更小。这个没有使用过。如果有什么好工具,欢迎推荐。

  2. 将静态资源文件上传到我们自己服务器或者 cdn 上。一个小程序,最耗空间的往往是图片文件。所以我们可以抽离出来,图片文件可以异步获取,在小程序启动以后再去获取。这样,代码包就会小很多。

  3. 使用分包加载。小程序提供了分包加载功能。如果你的小程序很庞大,可以考虑使用分包加载功能,先加载必要功能代码。这样就可以极大降低代码包大小
    接下来是首屏渲染,从上图的小程序生命周期可以看出,从加载首页代码到首页完成渲染,这段时间就是白屏时间,也就是首次渲染时间。而小程序在这段时间内,主要工作是:加载首页代码、创建 View 和 AppService 层、初始数据传输、页面渲染。在这四个步骤中,加载首页代码,前面已经说过;创建 View 和 AppService 层,是微信完成的,跟用户手机有关,这不是我们可控的。我们能做的就是减少初始数据传输时间和页面渲染时间。

  4. 我们知道 page.js 中的 data 对象在首次渲染时会通过数据管道传到视图层进行页面渲染。所以我们应该控制这个 data 对象的大小。对于与视图渲染无关的数据,不要放在 data 里面,可以设置个全局变量来保存。

1
2
3
4
5
6
7
8
9
10
Page({
//与页面渲染有关的数据放这里
data: {
goods_list:[]
},
//与页面渲染无关的数据放这里
_data: {
timer: null
}
})
  1. 页面渲染速度还跟 html 的 dom 结构有关。这一点的优化空间算是非常少了,就是写高质量 html 代码,减少 dom 嵌套,让页面渲染速度快一丢丢。

4.2 让小程序更强

接下来就是给小程序增加错误信息收集,包括 js 脚本错误信息收集和 http 请求错误信息收集。前段时间,在实际工作开发中,为了更好的复用和管理,我把这个错误信息收集功能做成了插件。然而做成插件并没有想象中的那么美好,下面再具体说。

脚本错误收集

对于脚本错误收集,这个相对比较简单,因为在 app.js 中提供了监听错误的 onError 函数

image

只不过错误信息是包括堆栈等比较详细的错误信息,然后当上传时我们并不需要这么多信息,第一浪费宽带,第二看着累又无用。我们需要的信息是:错误类型、错误信息描述、错误位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
thirdScriptError
aa is not defined;at pages/index/index page test function
ReferenceError: aa is not defined
at e.test (http://127.0.0.1:62641/appservice/pages/index/index.js:17:3)
at e.<anonymous> (http://127.0.0.1:62641/appservice/__dev__/WAService.js:16:31500)
at e.a (http://127.0.0.1:62641/appservice/__dev__/WAService.js:16:26386)
at J (http://127.0.0.1:62641/appservice/__dev__/WAService.js:16:20800)
at Function.<anonymous> (http://127.0.0.1:62641/appservice/__dev__/WAService.js:16:22389)
at http://127.0.0.1:62641/appservice/__dev__/WAService.js:16:27889
at http://127.0.0.1:62641/appservice/__dev__/WAService.js:6:16777
at e.(anonymous function) (http://127.0.0.1:62641/appservice/__dev__/WAService.js:4:3403)
at e (http://127.0.0.1:62641/appservice/appservice?t=1543326089806:1080:20291)
at r.registerCallback.t (http://127.0.0.1:62641/appservice/appservice?t=1543326089806:1080:20476)

这是错误信息字符串,接下来我们对它进行截取只需要拿我们想要的信息即可。我们发现这个字符串是有规则的。第一行是错误类型,第二行是错误详情和发生的位置,并且是”;”分号分开。所以我们还是很容易就可以拿到我们想要的信息。

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
//格式化错误信息
function formateErroMsg(errorMsg){
//包一层try catch 不要让信息收集影响了业务
try{
var detailMsg = '';
var detailPosition= '';
var arr = errorMsg.split('\n')
if (arr.length > 1) {
//错误详情和错误位置在第二行并用分好隔开
var detailArr = arr[1].split(';')
detailMsg = detailArr.length > 0 ? detailArr[0] : '';
if (detailArr.length > 1) {
detailArr.shift()
detailPosition = detailArr.join(';')
}
}

var obj = {
//错误类型就是第一行
error_type: arr.length > 0 ? arr[0] : '',
error_msg: detailMsg,
error_position: detailPosition
};
return obj
}catch(e){}
}

获取到我们想要的信息,就可以发送到我们服务后台,进行数据整理和显示,这个需要服务端配合,就不深入讲了,我们拿到了数据,其他都不是事。

http 请求错误信息收集

对于 http 请求错误信息收集方式,我们尽量不要暴力埋点,每个请求发送前发送后加上我们的埋点。这样工作量太大,也不易维护。因此,我们可以从底层出发,拦截 wx.request 请求。使用 Object.defineProperty 对 wx 对象的 request 进行重新定义。具体实现如下

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
function rewriteRequest(){
try {
const originRequest = wx.request;
Object.defineProperty(wx, 'request', {
configurable:true,
enumerable: true,
writable: true,
value: function(){
let options = arguments[0] || {};
//对于发送错误信息的接口不收集,防止死循环
var regexp = new RegExp("https://xxxx/error","g");
if (regexp.test(options.url)) {
//这里要执行原来的方法
return originRequest.call(this, options)
}
//这里拦截请求成功或失败接口,拿到请求后的数据
["success", "fail"].forEach((methodName) => {
let defineMethod = options[methodName];
options[methodName] = function(){
try{ //在重新定义函数中执行原先的函数,不影响正常逻辑
defineMethod && defineMethod.apply(this, arguments);
//开始信息收集
let statusCode, result, msg;
//请求失败
if (methodName == 'fail') {
statusCode = 0;
result = 'fail';
msg = ( arguments[0] && arguments[0].errMsg ) || ""
}
//请求成功,
//收集规则为:
// 1、 statusCode非2xx,3xx
// 2、 statusCode是2xx,3xx,但接口返回result不为ok
if (methodName == 'success') {
let data = arguments[0] || {};
statusCode = data.statusCode || "";
if (data.statusCode && Number(data.statusCode) >= 200 && Number(data.statusCode) < 400 ) {
let resData = data.data ? (typeof data.data == 'object' ? data.data : JSON.parse(data.data)) : {};
//请求成功,不收集
if (resData.result == 'ok') {
return;
}
result = resData.result || "";
msg = resData.msg || "";
}else{
result = "";
msg = data.data || "";
}
}
//过滤掉header中的敏感信息
if (options.header) {
options.header.userid && (delete options.header.userid)
}
//过滤掉data中的敏感信息
if (options.data) {
options.data.userid && (delete options.data.userid)
}

var collectInfo = {
"url": options.url || '', //请求地址
"method": options.method || "GET", //请求方法
"request_header": JSON.stringify(options.header || {}), //请求头部信息
"request_data": JSON.stringify(options.data || {}), //请求参数
"resp_code": statusCode + '', //请求状态码
"resp_result": result, //请求返回结果
"resp_msg": msg, //请求返回描述信息
}
//提交参数与上一次不同,或者参数相同,隔了1s
if (JSON.stringify(collectInfo) != lastParams.paramStr || (new Date().getTime() - lastParams.timestamp > 1000)) {
//上传错误信息
Post.post_error(_miniapp, 'http', collectInfo)
lastParams.paramStr = JSON.stringify(collectInfo);
lastParams.timestamp = new Date().getTime()
}

}catch(e){
//console.log(e);
}
};
})
return originRequest.call(this, options)
}
})
} catch (e) {
// Do something when catch error
}
}

在不使用插件的小程序中,我们可以在使用 wx.request 方法执行上面的代码,对 wx.request 进行拦截,然后其他无需加任何代码就可以收集 http 请求了。
上面说了,当我们封装成到插件时,这个就不管用了,因为当使用插件时,小程序不允许我们修改全局变量。所以执行上面代码时会报错。这时,我们退而求其次,只能是在插件中自己封装个方法,这个方法其实就是 wx.request 发送请求,但是在插件中我们就可以拦截 wx.request 了。具体实现如下:

1
2
3
4
5
function my_request(){
//只要执行一次拦截代码即可
!_isInit && rewriteRequest();
return wx.request(options)
}

接下来我们看下后台数据

image

image

持续监控,会帮我们找出很多隐藏的 bug

4 总结

洋洋洒洒写了这么多,或许有些地方说的不太清楚,慢慢锻炼吧。然后后面几点只是挑了重要的讲,我相信有过小程序开发经验的朋友应该没问题。然后有时间再补充和优化了。先到此,有缘看到的朋友,欢迎留言交流。

昨天(2018.3.13),微信小程序发布了重大功能更新,支持插件的使用和开发,个人预计,不超过 2 个月,优质服务的插件将会如雨后春笋般涌现。

这篇文章,我将会带大家,从 0 开始,学习如何开发和使用插件。文章分为 3 个章节:

  • 1、什么是微信小程序插件
  • 2、如何开发微信小程序插件
  • 3、如何使用第三方微信小程序插件

备注:为了节省文字内容,我会将“微信小程序插件”简称为“插件”。

什么是微信小程序插件?

插件是一组由 js 和自定义组件封装的代码库,插件无法单独使用、也无法预览,必须被其他小程序应用嵌入,才能使用。它和 NPM 的依赖、Maven 的依赖库是一个道理。

不过,插件和 NPM、Maven 依赖管理不同的是:

  • 插件拥有独立的 API 接口和域名列表,不被小程序本身的域名列表限制。(NPM 依赖进来的库不能进行第三方数据请求)
  • 插件必须由腾讯审核通过才能使用(NPM 无需腾讯审核)
  • 使用第三方插件必须向第三方申请 (通过 NPM 使用第三方库无需向第三方申请)

所以,我觉得:在未来,插件应该会被第三方打包成为服务,而不仅仅只是一个代码库。

如何开发微信小程序插件?

下载最新的微信小程序开发者工具,(必须是 1.02.1803130 版本以上),打开开发者工具,进入小程序项目,我们会看到“代码片段”标签,如下图:

image

点击,右下角的 “创建” 按钮,就可以创建插件了,如下图:

image

插件的 AppId 和之前的微信小程序的 AppId 是同个道理,需要在微信开发者后台新建一个微信小程序插件:

image

image

微信小程序插件的名称也必须是独一无二的,申请完毕后就可以获得 插件的 AppId 了。

填写名称和插件 AppID 后,进入小程序项目,如下图显示:

image

项目的代码目录结构如下:

1
2
3
4
5
6
7
8
9
10
├── miniprogram
│ ├── app.js
│ ├── app.json
│ └── pages
├── plugin
│ ├── api
│ ├── components
│ ├── index.js
│ └── plugin.json
└── project.config.json

在文件 project.config.json 中,我们看到代码如下:

1
2
3
4
5
6
7
8
9
10
11
{
"miniprogramRoot": "./miniprogram",
"pluginRoot": "./plugin",
"compileType": "plugin",
"setting": {
"newFeature": true
},
"appid": ".....",
"projectname": "videoPlayer",
"condition": {}
}
  • miniprogramRoot:配置小程序的根目录,可以使用小程序来测试编写的插件
  • pluginRoot:插件相关代码所在的根目录
  • compileType:项目的编译类型,必须配置为 plugin,在上传代码的时候才会以插件的方式上传到腾讯服务器。

plugin/plugin.json 文件中,代码如下:

1
2
3
4
5
6
{
"publicComponents": {
"hgPlayer": "components/player/player"
},
"main": "index.js"
}
  • publicComponents:配置的是插件可以给使用的小程序提供哪些组件,一个插件可以定义很多个组件,组件和组件之间相互引用,但是小程序只能使用在 publicComponents 里配置的组件。

  • main:定义入口文件,在入口文件 index.js 中定义小程序可以使用插件的那些接口。

plugin/index.js 文件中,代码如下:

1
2
3
4
5
6
var data = require('./api/data.js')

module.exports = {
getData: data.getData,
setData: data.setData
}

plugin/index.js 定义了对外抛出接口为 getDatasetData,小程序在使用这个插件的时候,只能使用到插件提供的这两个接口,插件的其他接口(或方法)小程序无法使用。

做好以上配置后,就可以开始在 plugin/components 编写组件代码了,例如我写了我的播放器组件,代码如下:

player.js:

1
2
3
4
5
Component({
data: {
myData:[]
}
})

player.wxml:

1
2
3
4
<view class="section tc">
<video id="myVideo" src="..." enable-danmu danmu-btn controls>
</video>
</view>

值得注意的是:

  1. 编写组件是调用 Component() 定义组件代码,和 App() 、Page()一样的道理。

  2. 在组件能够调用的微信 API 受限,比如说不能调用 wx.login() 获取用户信息,具体限制在:https://mp.weixin.qq.com/debug/wxadoc/dev/framework/plugin/api-limit.html

代码编写完毕后,注意在 plugin/plugin.json 文件配置:

1
2
3
4
5
6
{
"publicComponents": {
"hgPlayer": "components/player/player"
},
"main": "index.js"
}

表示使用该插件的小程序,可以使用 hgPlayer 这个组件,组件 hgPlayer 对应的代码是 "components/player/player"

配置好后,我就可以上传插件代码到腾讯服务器,进入微信小程序开发者后台提交审核,腾讯审核通过后,第三方小程序就可以使用我们编写的这个插件了。

如何使用第三方插件

使用第三方插件之前,需要进入微信小程序开发者后台,在第三方服务里添加插件:

image

image

填写第三方插件的 AppId,点击添加按钮,对方账号的 小程序插件 > 申请管理 会出现你的申请,如下图:

image

需要第三方同意你的申请后,你就可以开始使用第三方插件了。

使用第三方插件的时候,需要在 我们自己的小程序的 app.json 做如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"pages": [
"pages/index/index"
],
"plugins": {
"myPlugin": {
"version": "dev",
"provider": "填写申请通过的插件AppId"
},
"plugin1": {
"version": "dev",
"provider": "填写申请通过的插件AppId"
},
"plugin2": {
"version": "dev",
"provider": "填写申请通过的插件AppId"
}
...
}
}

plugins: 配置的要使用的第三方插件列表。

插件列表配置好后,由于每个插件可能会有多个组件,所以需要我们在每个页面定义要使用到的组件,例如,在 index.js 中要使用 hgPlayer 这个组件,需要在 index.json 配置如下:

1
2
3
4
5
{
"usingComponents": {
"player": "plugin://myPlugin/hgPlayer"
}
}

"player": "plugin://myPlugin/hgPlayer" 的含义是:要本页面使用插件 myPlugin 的组件 hgPlayer,同时在本页面的别名为 :player 。

配置好 index.json 后,就可以在 index.wxml 直接使用了,例如:

1
2
3
<view class="xxxx">
<player />
</view>

后续

到目前为止,我们已经讲完了:

  • 1、什么是微信小程序插件
  • 2、如何开发微信小程序插件
  • 3、如何使用第三方微信小程序插件

一、app.json

(1)设置小程序通用的的状态栏、导航条、标题、窗口背景色

支付宝小程序

1
2
3
4
"window": {
"defaultTitle": "病案到家", //页面标题
"titleBarColor": "#1688FB" //导航栏背景色
},

微信小程序

1
2
3
4
5
6
"window": {
"backgroundTextStyle": "light",//窗口的背景色
"navigationBarBackgroundColor": "#1688FB",//导航栏背景颜色
"navigationBarTitleText": "病案到家",//导航栏标题文字内容
"navigationBarTextStyle": "white"//导航栏标题颜色,仅支持 black/white
},

(2)设置 tabBar

支付宝小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"tabBar": {
"textColor": "#333333",//默认颜色
"selectedColor": "#1688FB",//选中颜色
"backgroundColor": "#ffffff",//背景色
"items": [
{
"icon": "/images/indexGrey.png",
"activeIcon": "/images/indexWhite.png",
"pagePath": "pages/homeIndex/homeIndex",
"name": "首页"
},
{
"icon": "/images/personGrey.png",
"activeIcon": "/images/personWhite.png",
"pagePath": "pages/orderList/orderList",
"name": "我的"
}
]
}

微信小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"tabBar": {
"color": "#333333",
"selectedColor": "#1688FB",
"backgroundColor": "#ffffff",
"borderStyle": "#e5e5e5",
"list": [
{
"iconPath": "/images/indexGrey.png",
"selectedIconPath": "/images/indexWhite.png",
"pagePath": "pages/homeIndex/homeIndex",
"text": "首页"
},
{
"iconPath": "/images/personGrey.png",
"selectedIconPath": "/images/personWhite.png",
"pagePath": "pages/orderList/orderList",
"text": "我的"
}
]
}

二、pages

(1)文件命名不同

支付宝小程序

image

微信小程序

image

我分别在微信小程序和支付宝小程序建立了页面,区别在于

  1. 支付宝小程序里面的视图层页面文件后缀是“axml”,样式文件后缀是“acss”;
  2. 微信小程序里面的视图层页面文件后缀是“wxml”,样式文件后缀是“wxss”。

(2)视图层页面 axml 以及 wxml

1.冒泡事件和非冒泡事件

支付宝小程序

onTap, catchTap

on 事件绑定不会阻止冒泡事件向上冒泡,catch 事件绑定可以阻止冒泡事件向上冒泡。

1
<button class="weui-btn" onTap="login" type="primary">登录</button>

微信小程序

bindtap、catchtouchstart

bind 事件绑定不会阻止冒泡事件向上冒泡,catch 事件绑定可以阻止冒泡事件向上冒泡。

1
<button class="weui-btn" bindtap='login' type="primary">登录</button>

2.列表渲染

1
2
3
4
5
6
7
8
9
Page({
data: {
list: [{
Title: '支付宝',
}, {
Title: '微信',
}]
}
})

支付宝小程序

1
2
3
<block a:for="{{list}}">
<view key="item-{{index}}" index="{{index}}">{{item.Title}}</view>
</block>

微信小程序

1
2
3
<block wx:for="{{list}}">
<view wx:key="this" wx:for-item="item">{{item.Title}}</view>
</block>

3.条件渲染

支付宝小程序

1
2
3
<view a:if="{{length > 5}}"> 1 </view>
<view a:elif="{{length > 2}}"> 2 </view>
<view a:else> 3 </view>

微信小程序

1
2
3
<view wx:if="{{length > 5}}"> 1 </view>
<view wx:elif="{{length > 2}}"> 2 </view>
<view wx:else> 3 </view>

三、开发过程中常用到的两个小程序中组件的不同用法

(1)交互

1.消息提示框

支付宝小程序

1
2
3
4
5
6
7
8
9
10
11
my.showToast({
type: 'success',//默认 none,支持 success / fail / exception / none’。
content: '操作成功',//文字内容
duration: 3000,//显示时长,单位为 ms,默认 2000
success: () => {
my.alert({
title: 'toast 消失了',
});
},
});
my.hideToast()//隐藏弱提示。

微信小程序

1
2
3
4
5
6
7
8
wx.showToast({
title: '成功',//提示的内容
icon: 'success',//success 显示成功图标;loading 显示加载图标;none不显示图标
duration: 2000
})

//icon为“success”“loading”时 title 文本最多显示 7 个汉字长度
wx.hideToast() //隐藏

2.消息提示框

支付宝小程序

1
2
3
4
5
my.showLoading({
content: '加载中...',
delay: 1000,
});
my.hideLoading();

微信小程序

1
2
3
4
wx.showLoading({
title: '加载中',
})
wx.hideLoading()

3.http 请求

支付宝小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
my.httpRequest({
url: 'http://httpbin.org/post',
method: 'POST',
data: {
from: '支付宝',
production: 'AlipayJSAPI',
},
headers:"",//默认 {'Content-Type': 'application/x-www-form-urlencoded'}
dataType: 'json',
success: function(res) {
my.alert({content: 'success'});
},
fail: function(res) {
my.alert({content: 'fail'});
},
complete: function(res) {
my.hideLoading();
my.alert({content: 'complete'});
}
});

微信小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
wx.request({
url: 'test.php', //仅为示例,并非真实的接口地址
data: {
x: '',
y: ''
},
header: {
'content-type': 'application/json' // 默认值
},
success (res) {
console.log(res.data)
}
})

其实微信小程序和支付宝小程序提供的 api 方法大致相同,只是微信小程序是以“wx.”起头,支付宝小程序是以“my.”起头,其余可能只是 api 方法里面字段“text、content、name、title”等命名不同。

(2)选择器

1.时间选择器

支付宝小程序

支付宝小程序提供了一个 api,my.datePicker(object)

1
2
3
4
5
6
7
8
9
10
11
my.datePicker({
format: 'yyyy-MM-dd',//返回的日期格式,
currentDate: '2012-12-12',//初始选择的日期时间,默认当前时间
startDate: '2012-12-10',//最小日期时间
endDate: '2012-12-15',//最大日期时间
success: (res) => {
my.alert({
content: res.date,
});
},
});

微信小程序

微信小程序是通过 picker 组件来实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<view class="section">
<view class="section__title">日期选择器</view>
<picker mode="date" value="{{date}}" start="2015-09-01" end="2017-09-01" bindchange="bindDateChange">
<view class="picker">
当前选择: {{date}}
</view>
</picker>
</view>

Page({
data: {
date: '2016-09-01',
},

bindDateChange: function(e) {
console.log('picker发送选择改变,携带值为', e.detail.value)
this.setData({
date: e.detail.value
})
},
})

2.省市区选择器

支付宝小程序

支付宝小程序提供了一个 api,my.multiLevelSelect(Object)

级联选择功能主要使用在于多级关联数据选择,比如说省市区的信息选择。

1.1、引入一个省市区的 json 格式文件 http://blog.shzhaoqi.com/uploads/js/city_json.zip

1.2、在 js 中引入这个文件

1.3、使用 my.multiLevelSelect(Object)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var citysJSON = require('../../utils/city.js');
Page({
data: {
provinces: '陕西省',
city: '西安市',
area: '碑林区'
},
chooseAddress: function () {
my.multiLevelSelect({
title: '选择省市区',//级联选择标题
list: citysJSON.citys,
success: (res) => {
this.setData({
provinces: res.result[0].name,
city: res.result[1].name,
area: res.result[2].name,
})
}
});
},
})

微信小程序

微信小程序依然是通过 picker 组件来实现的

1
2
3
4
5
6
7
8
9
10
<view class="section">
<view class="section__title">省市区选择器</view>
<picker mode="region" bindchange="bindRegionChange" value="{{region}}" custom-item="{{customItem}}">
<view class="picker">
当前选择:{{region[0]}},{{region[1]}},{{region[2]}}
</view>
</picker>
</view>

//custom-item 可为每一列的顶部添加一个自定义的项,可为空
1
2
3
4
5
6
7
8
9
10
11
12
13
Page({
data: {
region: ['广东省', '广州市', '海珠区'],
customItem: '全部'
},

bindRegionChange: function (e) {
console.log('picker发送选择改变,携带值为', e.detail.value)
this.setData({
region: e.detail.value
})
}
})

(3)小程序唤起支付

支付宝小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
my.tradePay({
tradeNO: '201711152100110410533667792', // 调用统一收单交易创建接口(alipay.trade.create),获得返回字段支付宝交易号trade_no
success: (res) => {
my.alert({
content: JSON.stringify(res),
});
},
fail: (res) => {
my.alert({
content: JSON.stringify(res),
});
}
});

微信小程序

1
2
3
4
5
6
7
8
9
wx.requestPayment({
timeStamp: '',//时间戳,从 1970 年 1 月 1 日 00:00:00 至今的秒数,即当前的时间
nonceStr: '',//随机字符串,长度为32个字符以下
package: '',//统一下单接口返回的 prepay_id 参数值,提交格式如:prepay_id=***
signType: 'MD5',//签名算法
paySign: '',//签名
success (res) { },
fail (res) { }
})

(4)电话

支付宝小程序

1
2
3
my.makePhoneCall({
number: '400-8097-114'
})

微信小程序

1
2
3
wx.makePhoneCall({
phoneNumber: '400-8097-114'
})

(5)获取登录凭证(code)

支付宝小程序

1
2
3
4
5
6
7
my.getAuthCode({
success (res) {
if (res.authCode) {
console.log(res.authCode)
}
}
})

微信小程序

1
2
3
4
5
6
7
wx.login({
success (res) {
if (res.code) {
console.log(res.code)
}
}
})

web 端与小程序错误监控差异

  • 在 Web 端监测的是页面完整的 url,而小程序端监测的是路由地址;
  • 小程序页面属于 app 内部的页面,使用时已全部加载完毕,因此监控页面性能时不统计页面加载时长等信息,更多的是对页面内请求、资源请求和用户行为的监控;
  • 由于微信官方和小程序代码的要求,集成方式对比 Web 端会相对严格一些。

小程序需要监控的数据

  • JavaScript 异常监控:不论是 Web 端还是小程序端,对 JavaScript 异常的监控都是必要的;
  • 页面内请求监控:对于小程序来说,需要统计发送网络请求的 swan.request() 异常时的请求状态、请求时长、请求地址等;
  • 资源加载监控:当需要下载资源到本地的 swan.downloadFile() 出现异常时,统计加载时间、异常类型、资源地址等;
  • 页面性能监控:访问监控、页面来源及流向监控等,方便更好的对小程序进行运营;
  • 用户数据统计:用户的分布、操作系统及版本、app 版本、IP 地址等,给错误的分析提供更多条件。

简单收集

1
2
3
4
5
6
7
8
9
10
11
App({
// 监听错误
onError: function (err) {
// 上报错误
swan.request({
url: "https://url", // 自行定义报告服务器
method: "POST",
errMsg: err
})
}
})

用户操作路径收集

一些较隐蔽的错误如果只有错误栈信息,排查起来会比较难,如果有用户操作的路径,在排查时就方便多了。

  • 暴力打点方法收集

    优点:简单直接

    缺点:污染业务代码,造成较多垃圾代码
  • 函数劫持
    需要在 App 函数中的 onLaunch、onShow、onHide 生命周期插入监控代码,通过重写 App 生命周期函数来实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
App = function(app) {
["onLaunch", "onShow", "onHide"].forEach(methodName => {
app[methodName] = function(options) {
// 构造访问日志对象
var breadcrumb = {
type: "function",
time: utils.now(),
belong: "App", // 来源
method: methodName,
path: options && options.path, // 页面路径
query: options && options.query, // 页面参数
scene: options && options.scene // 场景编号
};
self.pushToBreadcrumb(breadcrumb); // 把执行对象加入到面包屑中
})
}

但是这样写,会把用户自定义的内容给覆盖掉,所以还需要把用户定义的函数和监控代码合并。

1
2
3
4
5
6
var originApp = App // 保存原对象
App = function(app) {
// .... 此处省略监控代码
// .... 此处省略监控代码
originApp(app) // 执行用户定义的方法
}

小程序性能监控插件

  • Fundebug 提供网站、微信小程序和小游戏的 bug 监控服务,例如:API 的一些函数调用情况、监控函数调用的参数、收集 HTTP 请求错误的 body、监控某些特定的自定义函数等。
  • FrontJS 的小程序错误监控相比于微信小程序后台的数据监控,增加了对于错误的统计和产生错误的相关用户分析,FrontJS 可以收集精细到 console.log 级别的任何 JavaScript 异常信息并提供 stack trace 信息;对于任何一条错误信息或访问,它都会统计到该用户 IP、屏幕分辨率、DPR、操作系统类型和微信版本,方便更有针对性的去调试出现的错误。
  • 百度数据统计分析展示平台 提供对 web 页面的性能、访问点击、js 异常、浏览器新特性、跨站资源、XSS 漏洞、自定义事件、Native 性能检测服务,对 百度小程序 的支持还需进一步调研。

白屏监控

用户在访问网页的时候,在浏览器开始显示之前都会有一个的白屏过程,在移动端,受限于设备性能和网络速度,白屏会更加明显。

白屏时间

页面完全空白的时间,web 可以在页面的 head 底部添加的 JS 代码用来做白屏时间的标记。

微信 web 资源离线存储

通过使用微信离线存储,Web 开发者可借助微信提供的资源存储能力,直接从微信本地加载 Web 资源而不需要再从服务端拉取,从而减少网页加载时间,为微信用户提供更优质的网页浏览体验。每个公众号下所有 Web App 累计最多可缓存 5M 的资源。这个设计有点类似 HTML5 的 Application Cache。

概述

作为一名前端开发,如果你还停留在应用开发层面,那你就 OUT 了,快来跟我一起探讨下小程序框架本身底层实现的一些技术细节吧,让我们从小程序的运行机制来深度了解小程序。
小程序是基于 WEB 规范,采用 HTML,CSS 和 JS 等搭建的一套框架,微信官方给它们取了一个很牛逼的名字:WXML,WXSS,但本质上还是在整个 WEB 体系之下构建的。
WXML,个人猜测在取这个名字的是微信的 Xml,说到底就是 xml 的一个子集。WXML 采用微信自定义的少量标签 WXSS,大家可以理解为就是自定义的 CSS。实现逻辑部分的 JS 还是通用的 ES 规范,并且 runtime 还是 Webview(IOS WKWEBVIEW, ANDROID X5)。

小程序

小程序目录结构

image

一个完整的小程序主要由以下几部分组成:

  • 一个入口文件:app.js
  • 一个全局样式:app.wxss
  • 一个全局配置:app.json
  • 页面:pages 下,每个页面再按文件夹划分,每个页面 4 个文件
  • 视图:wxml,wxss
  • 逻辑:js,json(页面配置,不是必须)

注:pages 里面还可以再根据模块划分子目录,孙子目录,只需要在 app.json 里注册时填写路径就行。

小程序打包

开发完成后,我们就可以通过这里可视化的按钮,点击直接打包上传发布,审核通过后用户就可以搜索到了。

image

那么打包怎么实现的呢?
这就涉及到这个编辑器的实现原理和方式了,它本身也是基于 WEB 技术体系实现的,nwjs+react,nwjs 是什么:简单是说就是 node+webkit,node 提供给我们本地 api 能力,而 webkit 提供给我们 web 能力,两者结合就能让我们使用 JS+HTML 实现本地应用程序。
既然有 nodejs,那上面的打包选项里的功能就好实现了。
ES6 转 ES5:引入 babel-core 的 node 包
CSS 补全:引入 postcss 和 autoprefixer 的 node 包(postcss 和 autoprefixer 的原理看这里)
代码压缩:引入 uglifyjs 的 node 包

注:在 android 上使用的 x5 内核,对 ES6 的支持不好,要兼容的话,要么使用 ES5 的语法或者引入 babel-polyfill 兼容库。

打包后的目录结构

小程序打包后的结构如下:

image

所有的小程序基本都最后都被打成上面的结构

  1. WAService.js 框架 JS 库,提供逻辑层基础的 API 能力
  2. WAWebview.js 框架 JS 库,提供视图层基础的 API 能力
  3. WAConsole.js 框架 JS 库,控制台
  4. app-config.js 小程序完整的配置,包含我们通过 app.json 里的所有配置,综合了默认配置型
  5. app-service.js 我们自己的 JS 代码,全部打包到这个文件
  6. page-frame.html 小程序视图的模板文件,所有的页面都使用此加载渲染,且所有的 WXML 都拆解为 JS 实现打包到这里
  7. pages 所有的页面,这个不是我们之前的 wxml 文件了,主要是处理 WXSS 转换,使用 js 插入到 header 区域。

小程序架构

微信小程序的框架包含两部分 View 视图层、App Service 逻辑层,View 层用来渲染页面结构,AppService 层用来逻辑处理、数据请求、接口调用,它们在两个进程(两个 Webview)里运行。
视图层和逻辑层通过系统层的 JSBridage 进行通信,逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理。

小程序架构图:

image

小程序启动时会从 CDN 下载小程序的完整包,一般是数字命名的,如:_-2082693788_4.wxapkg

小程序技术实现

小程序的 UI 视图和逻辑处理是用多个 webview 实现的,逻辑处理的 JS 代码全部加载到一个 Webview 里面,称之为 AppService,整个小程序只有一个,并且整个生命周期常驻内存,而所有的视图(wxml 和 wxss)都是单独的 Webview 来承载,称之为 AppView。所以一个小程序打开至少就会有 2 个 webview 进程,正是因为每个视图都是一个独立的 webview 进程,考虑到性能消耗,小程序不允许打开超过 5 个层级的页面,当然同是也是为了体验更好。

AppService

可以理解 AppService 即一个简单的页面,主要功能是负责逻辑处理部分的执行,底层提供一个 WAService.js 的文件来提供各种 api 接口,主要是以下几个部分:
消息通信封装为 WeixinJSBridge(开发环境为 window.postMessage, IOS 下为 WKWebview 的 window.webkit.messageHandlers.invokeHandler.postMessage,android 下用 WeixinJSCore.invokeHandler)

  1. 日志组件 Reporter 封装
  2. wx 对象下面的 api 方法
  3. 全局的 App,Page,getApp,getCurrentPages 等全局方法
  4. 还有就是对 AMD 模块规范的实现

然后整个页面就是加载一堆 JS 文件,包括小程序配置 config,上面的 WAService.js(调试模式下有 asdebug.js),剩下就是我们自己写的全部的 js 文件,一次性都加载。

在开发环境下

  1. 页面模板:app.nw/app/dist/weapp/tpl/appserviceTpl.js
  2. 配置信息,是直接写入一个 js 变量,__wxConfig。
  3. 其他配置

image

线上环境

而在上线后是应用部分会打包为 2 个文件,名称 app-config.json 和 app-service.js,然后微信会打开 webview 去加载。线上部分应该是微信自身提供了相应的模板文件,在压缩包里没有找到。

  1. WAService.js(底层支持)
  2. app-config.json(应用配置)
  3. app-service.js(应用逻辑)

然后运行在 JavaScriptCore 引擎里面。

AppView

这里可以理解为 h5 的页面,提供 UI 渲染,底层提供一个 WAWebview.js 来提供底层的功能,具体如下:

  1. 消息通信封装为 WeixinJSBridge(开发环境为 window.postMessage, IOS 下为 WKWebview 的 window.webkit.messageHandlers.invokeHandler.postMessage,android 下用 WeixinJSCore.invokeHandler)
  2. 日志组件 Reporter 封装
  3. wx 对象下的 api,这里的 api 跟 WAService 里的还不太一样,有几个跟那边功能差不多,但是大部分都是处理 UI 显示相关的方法
  4. 小程序组件实现和注册
  5. VirtualDOM,Diff 和 Render UI 实现
  6. 页面事件触发

在此基础上,AppView 有一个 html 模板文件,通过这个模板文件加载具体的页面,这个模板主要就一个方法,$gwx,主要是返回指定 page 的 VirtualDOM,而在打包的时候,会事先把所有页面的 WXML 转换为 ViirtualDOM 放到模板文件里,而微信自己写了 2 个工具 wcc(把 WXML 转换为 VirtualDOM)和 wcsc(把 WXSS 转换为一个 JS 字符串的形式通过 style 标签 append 到 header 里)。

Service 和 View 通信

使用消息 publish 和 subscribe 机制实现两个 Webview 之间的通信,实现方式就是统一封装一个 WeixinJSBridge 对象,而不同的环境封装的接口不一样,具体实现的技术如下:

windows 环境

通过 window.postMessage 实现(使用 chrome 扩展的接口注入一个 contentScript.js,它封装了 postMessage 方法,实现 webview 之间的通信,并且它也通过 chrome.runtime.connect 方式,也提供了直接操作 chrome native 原生方法的接口)
发送消息:window.postMessage(data, ‘*’);,// data 里指定 webviewID
接收消息:window.addEventListener(‘message’, messageHandler); // 消息处理并分发,同样支持调用 nwjs 的原生能力。
在 contentScript 里面看到一句话,证实了 appservice 也是通过一个 webview 实现的,实现原理上跟 view 一样,只是处理的业务逻辑不一样。

1
'webframe' === b ? postMessageToWebPage(a) : 'appservice' === b && postMessageToWebPage(a)

IOS

通过 WKWebview 的 window.webkit.messageHandlers.NAME.postMessage 实现,微信 navite 代码里实现了两个 handler 消息处理器:
invokeHandler: 调用原生能力
publishHandler: 消息分发

image

Android

通过 WeixinJSCore.invokeHandler 实现,这个 WeixinJSCore 是微信提供给 JS 调用的接口(native 实现)
invokeHandler: 调用原生能力
publishHandler: 消息分发

微信组件

在 WAWebview.js 里有个对象叫 exparser,它完整的实现小程序里的组件,看具体的实现方式,思路上跟 w3c 的 web components 规范神似,但是具体实现上是不一样的,我们使用的所有组件,都会被提前注册好,在 Webview 里渲染的时候进行替换组装。
exparser 有个核心方法:
registerBehavior: 注册组件的一些基础行为,供组件继承
registerElement:注册组件,跟我们交互接口主要是属性和事件

image

组件触发事件(带上 webviewID),调用 WeixinJSBridge 的接口,publish 到 native,然后 native 再分发到 AppService 层指定 webviewID 的 Page 注册事件处理方法。

总结

小程序底层还是基于 Webview 来实现的,并没有发明创造新技术,整个框架体系,比较清晰和简单,基于 Web 规范,保证现有技能价值的最大化,只需了解框架规范即可使用已有 Web 技术进行开发。易于理解和开发。

MSSM:对逻辑和 UI 进行了完全隔离,这个跟当前流行的 react,angular,vue 有本质的区别,小程序逻辑和 UI 完全运行在 2 个独立的 Webview 里面,而后面这几个框架还是运行在一个 webview 里面的,如果你想,还是可以直接操作 dom 对象,进行 ui 渲染的。

组件机制:引入组件化机制,但是不完全基于组件开发,跟 vue 一样大部分 UI 还是模板化渲染,引入组件机制能更好的规范开发模式,也更方便升级和维护。

多种节制:不能同时打开超过 5 个窗口,打包文件不能大于 1M,dom 对象不能大于 16000 个等,这些都是为了保证更好的体验。

使用手册

mpvue 继承自 Vue.js,其技术规范和语法特点与 Vue.js 保持一致。

本文档适用于有一定 Vue.js 使用经验的开发者。我们默认你已经掌握 Vue.js 技术体系,如果你是新手,你可能需要先熟悉 Vue.js 官方文档。

五分钟教程

通过 Vue.js 命令行工具 vue-cli,你只需在终端窗口输入几条简单命令,即可快速创建和启动一个带热重载、保存时静态检查、内置代码构建功能的小程序项目:

1
2
3
4
5
6
7
8
9
10
11
# 全局安装 vue-cli
$ npm install --global vue-cli

# 创建一个基于 mpvue-quickstart 模板的新项目
$ vue init mpvue/mpvue-quickstart my-project

# 安装依赖
$ cd my-project
$ npm install
# 启动构建
$ npm run dev

接下来,你只需要启动微信开发者工具,引入项目即可预览到你的第一个 mpvue 小程序。

框架原理

  • mpvue 保留了 vue.runtime 核心方法,无缝继承了 Vue.js 的基础能力
  • mpvue-template-compiler 提供了将 vue 的模板语法转换到小程序的 wxml 语法的能力
  • 修改了 vue 的建构配置,使之构建出符合小程序项目结构的代码格式: json/wxml/wxss/js 文件

Vue 实例

支持 官方文档:Vue 实例,同时我们做了一些修改,来适应小程序的独特加载逻辑。

实例生命周期

同 vue,不同的是我们会在小程序 onReady 后,再去触发 vue mounted 生命周期,详细的 vue 生命周期文档请看生命周期钩子

  • beforeCreate
  • created
  • beforeMount
  • mounted
  • beforeUpdate
  • updated
  • activated
  • deactivated
  • beforeDestroy
  • destroyed

除了 Vue 本身的生命周期外,mpvue 还兼容了小程序生命周期,这部分生命周期钩子的来源于微信小程序的 Page, 除特殊情况外,不建议使用小程序的生命周期钩子。

app 部分

  • onLaunch,初始化
  • onShow,当小程序启动,或从后台进入前台显示
  • onHide,当小程序从前台进入后台

page 部分

  • onLoad,监听页面加载
  • onShow,监听页面显示
  • onReady,监听页面初次渲染完成
  • onHide,监听页面隐藏
  • onUnload,监听页面卸载
  • onPullDownRefresh,监听用户下拉动作
  • onReachBottom,页面上拉触底事件的处理函数
  • onShareAppMessage,用户点击右上角分享
  • onPageScroll,页面滚动
  • onTabItemTap, 当前是 tab 页时,点击 tab 时触发 (mpvue 0.0.16 支持)
    用法示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
new Vue({
data: {
a: 1
},
created () {
// `this` 指向 vm 实例
console.log('a is: ' + this.a)
},
onShow () {
// `this` 指向 vm 实例
console.log('a is: ' + this.a, '小程序触发的 onshow')
}
})
// => "a is: 1"

注意点

  1. 不要在选项属性或回调上使用箭头函数,比如 created: () => console.log(this.a) 或 vm.$watch(‘a’, newValue => this.myMethod())。因为箭头函数是和父级上下文绑定在一起的,this 不会是如你做预期的 Vue 实例,且 this.a 或 this.myMethod 也会是未定义的。

  2. 微信小程序的页面的 query 参数是通过 onLoad 获取的,mpvue 对此进行了优化,直接通过 this.$root.$mp.query 获取相应的参数数据,其调用需要在 onLoad 生命周期触发之后使用,比如 onShow 等,具体生命周期调用顺序,见下图。

生命周期图示

你不需要立马弄明白所有的东西,不过随着你的不断学习和使用,它的参考价值会越来越高。

生命周期的调用关系和顺序图。

image

模板语法

几乎全支持 官方文档:模板语法,下面讲下不支持的情况。

不支持 纯-HTML

小程序里所有的 BOM/DOM 都不能用,也就是说 v-html 指令不能用。

不支持部分复杂的 JavaScript 渲染表达式

我们会把 template 中的 {{}} 双花括号的部分,直接编码到 wxml 文件中,由于微信小程序的能力限制(数据绑定),所以无法支持复杂的 JavaScript 表达式。

目前可以使用的有 + - * % ?: ! == === > < [] .,剩下的还待完善。

1
2
3
4
5
6
7
8
9
<!-- 这种就不支持,建议写 computed -->
<p>{{ message.split('').reverse().join('') }}</p>

<!-- 但写在 @event 里面的表达式是都支持的,因为这部分的计算放在了 vdom 里面 -->
<ul>
<li v-for="item in list">
<div @click="clickHandle(item, index, $event)">{{ item.value }}</p>
</li>
</ul>

不支持过滤器

渲染部分会转成 wxml ,wxml 不支持过滤器,所以这部分功能不支持。

计算属性

支持 官方文档:计算属性

不支持函数

不支持在 template 内使用 methods 中的函数。

#Class 与 Style 绑定
为节约性能,我们将 Class 与 Style 的表达式通过 compiler 硬编码到 wxml 中,支持语法和转换效果如下:

class 支持的语法:

1
2
3
4
5
<p :class="{ active: isActive }">111</p>
<p class="static" v-bind:class="{ active: isActive, 'text-danger': hasError }">222</p>
<p class="static" :class="[activeClass, errorClass]">333</p>
<p class="static" v-bind:class="[isActive ? activeClass : '', errorClass]">444</p>
<p class="static" v-bind:class="[{ active: isActive }, errorClass]">555</p>

将分别被转换成:

1
2
3
4
5
<view class="_p {{[isActive ? 'active' : '']}}">111</view>
<view class="_p static {{[isActive ? 'active' : '', hasError ? 'text-danger' : '']}}">222</view>
<view class="_p static {{[activeClass, errorClass]}}">333</view>
<view class="_p static {{[isActive ? activeClass : '', errorClass]}}">444</view>
<view class="_p static {{[[isActive ? 'active' : ''], errorClass]}}">555</view>

style 支持的语法:

1
2
<p v-bind:style="{ color: activeColor, fontSize: fontSize + 'px' }">666</p>
<p v-bind:style="[{ color: activeColor, fontSize: fontSize + 'px' }]">777</p>

将分别被转换成:

1
2
<view class="_p" style=" {{'color:' + activeColor + ';' + 'font-size:' + fontSize + 'px' + ';'}}">666</view>
<view class="_p" style=" {{'color:' + activeColor + ';' + 'font-size:' + fontSize + 'px' + ';'}}">777</view>

不支持 官方文档:Class 与 Style 绑定 中的 classObject 和 styleObject 语法。

最佳实践见上文支持的语法,从性能考虑,建议不要过度依赖此

此外还可以用 computed 方法生成 class 或者 style 字符串,插入到页面中,举例说明:

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
<template>
<!-- 支持 -->
<div class="container" :class="computedClassStr"></div>
<div class="container" :class="{active: isActive}"></div>

<!-- 不支持 -->
<div class="container" :class="computedClassObject"></div>
</template>
<script>
export default {
data () {
return {
isActive: true
}
},
computed: {
computedClassStr () {
return this.isActive ? 'active' : ''
},
computedClassObject () {
return { active: this.isActive }
}
}
}
</script>

用在组件上

暂不支持在组件上使用 Class 与 Style 绑定

条件渲染

全支持 官方文档:条件渲染

列表渲染

全支持 官方文档:列表渲染

只是需要注意一点,嵌套列表渲染,必须指定不同的索引!

示例:

1
2
3
4
5
6
7
8
<!-- 在这种嵌套循环的时候, index 和 itemIndex 这种索引是必须指定,且别名不能相同,正确的写法如下 -->
<template>
<ul v-for="(card, index) in list">
<li v-for="(item, itemIndex) in card">
{{item.value}}
</li>
</ul>
</template>

事件处理器

几乎全支持啦 官方文档:事件处理器

我们引入了 Vue.js 的虚拟 DOM ,在前文模版中绑定的事件会被挂在到 vnode 上,同时我们的 compiler 在 wxml 上绑定了小程序的事件,并做了相应的映射,所以你在真实点击的时候通过 runtime 中 handleProxy 通过事件类型分发到 vnode 的事件上,同 Vue 在 WEB 的机制一样,所以可以做到无损支持。同时还顺便支持了自定义事件和 $emit 机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 事件映射表,左侧为 WEB 事件,右侧为 小程序 对应事件
{
click: 'tap',
touchstart: 'touchstart',
touchmove: 'touchmove',
touchcancel: 'touchcancel',
touchend: 'touchend',
tap: 'tap',
longtap: 'longtap',
input: 'input',
change: 'change',
submit: 'submit',
blur: 'blur',
focus: 'focus',
reset: 'reset',
confirm: 'confirm',
columnchange: 'columnchange',
linechange: 'linechange',
error: 'error',
scrolltoupper: 'scrolltoupper',
scrolltolower: 'scrolltolower',
scroll: 'scroll'
}

在 input 和 textarea 中 change 事件会被转为 blur 事件。

踩坑注意

  • 列表中没有的原生事件也可以使用例如 bindregionchange 事件直接在 dom 上将 bind 改为@ @regionchange,同时这个事件也非常特殊,它的 event type 有 begin 和 end 两个,导致我们无法在 handleProxy 中区分到底是什么事件,所以你在监听此类事件的时候同时监听事件名和事件类型既 <map @regionchange=”functionName” @end=”functionName” @begin=”functionName”>
  • 小程序能力所致,bind 和 catch 事件同时绑定时候,只会触发 bind ,catch 不会被触发,要避免踩坑。
  • 事件修饰符
    • .stop 的使用会阻止冒泡,但是同时绑定了一个非冒泡事件,会导致该元素上的 catchEventName 失效!
    • .prevent 可以直接干掉,因为小程序里没有什么默认事件,比如 submit 并不会跳转页面
    • .capture 支持 1.0.9
    • .self 没有可以判断的标识
    • .once 也不能做,因为小程序没有 removeEventListener, 虽然可以直接在 handleProxy 中处理,但非常的不优雅,违背了原意,暂不考虑
  • 其他 键值修饰符 等在小程序中压根没键盘,所以。。。

表单控件绑定

几乎全支持 官方文档:表单控件绑定,不支持的还没测出来,之所以说几乎,是因为 WEB 表单这么复杂,谁特么知道会出什么奇怪的特性。

建议开发过程中直接使用 [微信小程序:表单组件]https://mp.weixin.qq.com/debug/wxadoc/dev/component/button.html) 。用法示例:

select 组件用 picker 组件进行代替

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
<template>
<div>
<picker @change="bindPickerChange" :value="index" :range="array">
<view class="picker">
当前选择:{{array[index]}}
</view>
</picker>
</div>
</template>

<script>
export default {
data () {
return {
index: 0,
array: ['A', 'B', 'C']
}
},
methods: {
bindPickerChange (e) {
console.log(e)
}
}
}

</script>

表单元素 radio 用 radio-group 组件进行代替

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
<template>
<div>
<radio-group class="radio-group" @change="radioChange">
<label class="radio" v-for="(item, index) in items" :key="item.name">
<radio :value="item.name" :checked="item.checked"/> {{item.value}}
</label>
</radio-group>
</div>
</template>

<script>
export default {
data () {
return {
items: [
{name: 'USA', value: '美国'},
{name: 'CHN', value: '中国', checked: 'true'},
{name: 'BRA', value: '巴西'},
{name: 'JPN', value: '日本'},
{name: 'ENG', value: '英国'},
{name: 'TUR', value: '法国'}
]
}
},
methods: {
radioChange (e) {
console.log('radio发生change事件,携带value值为:', e.target.value)
}
}
}

</script>

组件

Vue 组件

组件是整个 Vue.js 中最复杂的部分,当然要支持 官方文档:组件

有且只能使用单文件组件(.vue 组件)的形式进行支持。其他的诸如:动态组件,自定义 render,和<script type="text/x-template"> 字符串模版等都不支持。原因很简单,因为我们要预编译出 wxml。

如果未来小程序支持了动态增删改查 wxml 节点信息,那我们就能做到全支持。

详细的不支持列表:

  • 暂不支持在组件引用时,在组件上定义 click 等原生事件、v-show(可用 v-if 代替)和 class style 等样式属性(例: 样式是不会生效的),因为编译到 wxml,小程序不会生成节点,建议写在内部顶级元素上。
  • Slot(scoped 暂时还没做支持)
  • 动态组件
  • 异步组件
  • inline-template
  • X-Templates
  • keep-alive
  • transition
  • class
  • style

小程序组件

mpvue 可以支持小程序的原生组件,比如: picker,map 等,需要注意的是原生组件上的事件绑定,需要以 vue 的事件绑定语法来绑定,如 bindchange=”eventName” 事件,需要写成 @change=”eventName”

示例代码:

1
2
3
4
5
<picker mode="date" :value="date" start="2015-09-01" end="2017-09-01" @change="bindDateChange">
<view class="picker">
当前选择: {{date}}
</view>
</picker>

TypeScript 支持

目前 mpvue-loader 是可以支持 TypeScript 选项的,配置方法在此。具体 Demo 可以见 mpvue-ts-demo

最佳实践

1. 精简 data 数据

冗余数据不要挂在 data 里,所有在 data/props/computed 中的数据,每次变更都会从微信小程序的 JSCore 进程,通过 setData 序列化成字符串后发送到 JSRender 进程。所以,如果你的数据量巨大的时候,会导致页面非常卡顿。

2. 优化长列表性能

一般情况下这种页面会有大量的数据,除了遵从上面的建议外还有额外的建议。

  • 避免在 v-for 中嵌套子组件,这样可以优化大部分 setData 时的冗余数据。
  • 通过实践发现 wx:if 和 hidden 的优化肉眼不可见,所以或许可以试试直接通过样式 display 来展示和隐藏。
  • 如果列表过长,强烈建议产品思考更好的展示形态。比如只展示热门,多余的折叠等形式。

    注:我们对其进行了专门优化,最佳实践时和原生小程序代码的性能相差无几。

3. 合理使用双向绑定 mpvue 建议使用 v-model.lazy 绑定方式以优化性能,此外 v-model 在老基础库下输入框输入时可能存在光标重设的问题。

4. 谨慎选择直接使用小程序的 API 如果你有小程序和 H5 复用代码的需要,业务代码需要保持对 WEB Vue.js 的兼容性。此时我们不建议在代码中直接调用小程序 API,更好的选择是通过桥接适配层屏蔽两端差异。

常见问题

1. 如何获取小程序在 page onLoad 时候传递的 options

在所有 页面 的组件内可以通过 this.$root.$mp.query 进行获取。

2. 如何获取小程序在 app onLaunch/onShow 时候传递的 options

在所有的组件内可以通过 this.$root.$mp.appOptions 进行获取。

3. 如何捕获 app 的 onError

由于 onError 并不是完整意义的生命周期,所以只提供一个捕获错误的方法,在 app 的根组件上添加名为 onError 的回调函数即可。如下:

1
2
3
4
5
6
7
8
9
10
11
export default {
// 只有 app 才会有 onLaunch 的生命周期
onLaunch () {
// ...
},

// 捕获 app error
onError (err) {
console.log(err)
}
}

mpvue 框架使用场景汇总

mpvue 作为一个前端开发框架,提供了一整套解决方案。但开发者面临的实际情况可能更加复杂,我们整理了 mpvue 可能的使用场景,在这些场景下,我们为你提供了一些建议。

首先,mpvue 小程序框架包含如下内容:

  • 运行时 JS SDK
  • 初始化模板项目(包含推荐的目录结构,webpack 构建,代码检查配置等)
  • 项目构建所需的 npm 依赖(已经包含在项目模板中,无需手动引入)

开发者可能会面对的四种典型场景

  • 单独以 mpvue 框架构建小程序
  • mpvue 框架为主,同时使用其它框架(原生开发方式或 wepy 等)
  • 已经使用其它框架,引入 mpvue 做部分模块的开发
  • 只使用 mpvue 的 JS SDK,自定义构建策略

针对上述不同场景,mpvue 框架需要提供的方案和建议如下

  • 单独以 mpvue 框架构建小程序
    • 推荐的方式,无需额外支持。通过框架提供的项目初始化工具初始化项目即可,已经包含完整的构建策略,代码组织方式等.
  • mpvue 框架为主,同时使用其它框架(原生开发方式或 wepy 等)
    • 第三方框架和 mpvue 做分块构建。可能的方案是:不同框架各自的构建策略做好边界分离,最终通过单一入口聚合到一起。或者更简单的,拆成多个子项目,最终输出到同一目录,目标代码符合小程序规范即可。
  • 已经使用其它框架,引入 mpvue 做部分模块的开发
    • mpvue 提供轻量的模块构建工具支持部分构建。对已有小程序项目接入 mpvue 来说,渐进的方式会是乐于接受的,可以先让一部分功能通过 mpvue 编写。此类场景,不再适合通过模板项目初始化项目结构,开发者可以参考模板项目中的代码编写方式,通过我们单独准备的构建工具,定制好构建任务即可。
  • 只使用 mpvue 的 JS SDK,自定义构建策略
    • 需要开发者自定义 webpack 构建策略。 框架本身不建议开发者这么使用。但对于高阶的开发者,这是可能的方案。此时,开发者可以参考我们模板项目中的构建策略即可,我们目前只提供 webpack 构建方案。对于其它构建,我们暂不支持。

前端 JS 项目开发规范

规范的目的是为了编写高质量的代码,让你的团队成员每天的心情都是愉悦的,大家在一起是快乐的。

引自《阿里规约》的开头片段:

—-现代软件架构的复杂性需要协同开发完成,如何高效地协同呢?无规矩不成方圆,无规范难以协同,比如,制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全,试想如果没有限速,没有红绿灯,谁还敢上路行驶。对软件来说,适当的规范和标准绝不是消灭代码内容的创造性、优雅性,而是限制过度个性化,以一种普遍认可的统一方式一起做事,提升协作效率,降低沟通成本。代码的字里行间流淌的是软件系统的血液,质量的提升是尽可能少踩坑,杜绝踩重复的坑,切实提升系统稳定性,码出质量。

一、编程规约

(一)命名规范

1.1.1 项目命名

全部采用小写方式, 以中划线分隔。

正例:mall-management-system

反例:mall_management-system / mallManagementSystem

1.1.2 目录命名

全部采用小写方式, 以中划线分隔,有复数结构时,要采用复数命名法, 缩写不用复数

正例: scripts / styles / components / images / utils / layouts / demo-styles / demo-scripts / img / doc

反例: script / style / demo_scripts / demoStyles / imgs / docs

【特殊】VUE 的项目中的 components 中的组件目录,使用 kebab-case 命名

正例: head-search / page-loading / authorized / notice-icon

反例: HeadSearch / PageLoading

【特殊】VUE 的项目中除 components 组件目录外的所有目录也使用 kebab-case 命名
正例: page-one / shopping-car / user-management

反例: ShoppingCar / UserManagement

1.1.3 JS、CSS、SCSS、HTML、PNG 文件命名

全部采用小写方式, 以中划线分隔

正例: render-dom.js / signup.css / index.html / company-logo.png

反例: renderDom.js / UserManagement.html

1.1.4 命名严谨性

代码中的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式。 说明:正确的英文拼写和语法可以让阅读者易于理解,避免歧义。注意,即使纯拼音命名方式也要避免采用

正例:henan / luoyang / rmb 等国际通用的名称,可视同英文。

反例:DaZhePromotion [打折] / getPingfenByName() [评分] / int 某变量 = 3

杜绝完全不规范的缩写,避免望文不知义:

反例:AbstractClass“缩写”命名成 AbsClass;condition“缩写”命名成 condi,此类随意缩写严重降低了代码的可阅读性。

(二)HTML 规范 (Vue Template 同样适用)

1.2.1 HTML 类型

推荐使用 HTML5 的文档类型声明: .
(建议使用 text/html 格式的 HTML。避免使用 XHTML。XHTML 以及它的属性,比如 application/xhtml+xml 在浏览器中的应用支持与优化空间都十分有限)。

  • 规定字符编码
  • IE 兼容模式
  • 规定字符编码
  • doctype 大写
    正例:
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta charset="UTF-8" />
<title>Page title</title>
</head>
<body>
<img src="images/company-logo.png" alt="Company" />
</body>
</html>

1.2.2 缩进

缩进使用 2 个空格(一个 tab)

嵌套的节点应该缩进。

1.2.3 分块注释

在每一个块状元素,列表元素和表格元素后,加上一对 HTML 注释。注释格式

1.2.4 语义化标签

HTML5 中新增很多语义化标签,所以优先使用语义化标签,避免一个页面都是 div 或者 p 标签

正例

1
2
<header></header>
<footer></footer>

反例

1
2
3
<div>
<p></p>
</div>

1.2.5 引号

使用双引号(“”) 而不是单引号(‘’) 。

正例: “”

反例: ‘’

(三) CSS 规范

1.3.1 命名

  • 类名使用小写字母,以中划线分隔
  • id 采用驼峰式命名
  • scss 中的变量、函数、混合、placeholder 采用驼峰式命名
    ID 和 class 的名称总是使用可以反映元素目的和用途的名称,或其他通用的名称,代替表象和晦涩难懂的名称

不推荐:

1
2
3
4
5
6
7
.fw-800 {
font-weight: 800;
}

.red {
color: red;
}

推荐:

1
2
3
4
5
6
7
.heavy {
font-weight: 800;
}

.important {
color: red;
}

1.3.2 选择器

1)css 选择器中避免使用标签名
从结构、表现、行为分离的原则来看,应该尽量避免 css 中出现 HTML 标签,并且在 css 选择器中出现标签名会存在潜在的问题。

2)很多前端开发人员写选择器链的时候不使用 直接子选择器(注:直接子选择器和后代选择器的区别)。有时,这可能会导致疼痛的设计问题并且有时候可能会很耗性能。然而,在任何情况下,这是一个非常不好的做法。如果你不写很通用的,需要匹配到 DOM 末端的选择器, 你应该总是考虑直接子选择器。

不推荐:

.content .title { font-size: 2rem; }

推荐:

.content > .title { font-size: 2rem; }

1.3.3 尽量使用缩写属性

不推荐:

1
2
3
4
5
6
7
8
border-top-style: none;
font-family: palatino, georgia, serif;
font-size: 100%;
line-height: 1.6;
padding-bottom: 2em;
padding-left: 1em;
padding-right: 1em;
padding-top: 0;

推荐:

1
2
3
border-top: 0;
font: 100%/1.6 palatino, georgia, serif;
padding: 0 1em 2em;

1.3.4 每个选择器及属性独占一行

不推荐:

1
2
3
button{
width:100px;height:50px;color:#fff;background:#00a0e9;
}

推荐:

1
2
3
4
5
6
button{
width:100px;
height:50px;
color:#fff;
background:#00a0e9;
}

1.3.5 省略 0 后面的单位

不推荐:

1
2
3
4
div{
padding-bottom: 0px;
margin: 0em;
}

推荐:

1
2
3
4
div{
padding-bottom: 0;
margin: 0;
}

1.3.6 避免使用 ID 选择器及全局标签选择器防止污染全局样式

不推荐:

1
2
3
4
#header{
padding-bottom: 0px;
margin: 0em;
}

推荐:

1
2
3
4
.header{
padding-bottom: 0px;
margin: 0em;
}

(四) LESS 规范

1.4.1 代码组织

1)将公共 less 文件放置在style/less/common文件夹
例:// color.less,common.less

2)按以下顺序组织

  • 1、@import;
  • 2、变量声明;
  • 3、样式声明;
1
2
3
4
5
6
7
8
@import "mixins/size.less";

@default-text-color: #333;

.page {
width: 960px;
margin: 0 auto;
}

1.4.2 避免嵌套层级过多

将嵌套深度限制在 3 级。对于超过 4 级的嵌套,给予重新评估。这可以避免出现过于详实的 CSS 选择器。
避免大量的嵌套规则。当可读性受到影响时,将之打断。推荐避免出现多于 20 行的嵌套规则出现

不推荐:

1
2
3
4
5
6
7
.main{
.title{
.name{
color:#fff
}
}
}

推荐:

1
2
3
4
5
.main-title{
.name{
color:#fff
}
}

(五) Javascript 规范

1.5.1 命名

  1. 采用小写驼峰命名 lowerCamelCase,代码中的命名均不能以下划线,也不能以下划线或美元符号结束
    反例: _name / name_ / name$

  2. 方法名、参数名、成员变量、局部变量都统一使用 lowerCamelCase 风格,必须遵从驼峰形式。
    正例: localValue / getHttpMessage() / inputUserId

其中 method 方法命名必须是 动词 或者 动词+名词 形式

正例:saveShopCarData /openShopCarInfoDialog

反例:save / open / show / go

特此说明,增删查改,详情统一使用如下 5 个单词,不得使用其他(目的是为了统一各个端)

add / update / delete / detail / get

附: 函数方法常用的动词:

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
get 获取/set 设置,
add 增加/remove 删除
create 创建/destory 移除
start 启动/stop 停止
open 打开/close 关闭,
read 读取/write 写入
load 载入/save 保存,
create 创建/destroy 销毁
begin 开始/end 结束,
backup 备份/restore 恢复
import 导入/export 导出,
split 分割/merge 合并
inject 注入/extract 提取,
attach 附着/detach 脱离
bind 绑定/separate 分离,
view 查看/browse 浏览
edit 编辑/modify 修改,
select 选取/mark 标记
copy 复制/paste 粘贴,
undo 撤销/redo 重做
insert 插入/delete 移除,
add 加入/append 添加
clean 清理/clear 清除,
index 索引/sort 排序
find 查找/search 搜索,
increase 增加/decrease 减少
play 播放/pause 暂停,
launch 启动/run 运行
compile 编译/execute 执行,
debug 调试/trace 跟踪
observe 观察/listen 监听,
build 构建/publish 发布
input 输入/output 输出,
encode 编码/decode 解码
encrypt 加密/decrypt 解密,
compress 压缩/decompress 解压缩
pack 打包/unpack 解包,
parse 解析/emit 生成
connect 连接/disconnect 断开,
send 发送/receive 接收
download 下载/upload 上传,
refresh 刷新/synchronize 同步
update 更新/revert 复原,
lock 锁定/unlock 解锁
check out 签出/check in 签入,
submit 提交/commit 交付
push 推/pull 拉,
expand 展开/collapse 折叠
begin 起始/end 结束,
start 开始/finish 完成
enter 进入/exit 退出,
abort 放弃/quit 离开
obsolete 废弃/depreciate 废旧,
collect 收集/aggregate 聚集
  1. 常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要嫌名字长。
    正例: MAX_STOCK_COUNT

反例: MAX_COUNT

1.5.2 代码格式

  1. 使用 2 个空格进行缩进
    正例:
1
2
3
4
5
if (x < y) {
x += 10;
} else {
x += 1;
}
  1. 不同逻辑、不同语义、不同业务的代码之间插入一个空行分隔开来以提升可读性。
    说明:任何情形,没有必要插入多个空行进行隔开。

1.5.3 字符串

统一使用单引号(‘’),不使用双引号(“”)。这在创建 HTML 字符串非常有好处:

正例:

1
2
let str = 'foo';
let testDiv = '<div id="test"></div>';

反例:

1
2
let str = "foo";
let testDiv = "<div id='test'></div>";

1.5.4 对象声明

1)使用字面值创建对象
正例: let user = {};

反例: let user = new Object();

  1. 使用字面量来代替对象构造器
    正例:
1
2
3
4
5
var user = {
age: 0,
name: 1,
city: 3
};

反例:

1
2
3
4
var user = new Object();
user.age = 0;
user.name = 0;
user.city = 0;

1.5.5 使用 ES6,7

必须优先使用 ES6,7 中新增的语法糖和函数。这将简化你的程序,并让你的代码更加灵活和可复用。

必须强制使用 ES6, ES7 的新语法,比如箭头函数、await/async , 解构, let , for…of 等等

1.5.6 括号

下列关键字后必须有大括号(即使代码块的内容只有一行):if, else, for, while, do, switch, try, catch, finally, with。

正例:

1
2
3
if (condition) {
doSomething();
}

反例:

1
if (condition) doSomething();

1.5.7 undefined 判断

永远不要直接使用 undefined 进行变量判断;使用 typeof 和字符串’undefined’对变量进行判断。

正例:

1
2
3
if (typeof person === 'undefined') {
...
}

反例:

1
2
3
if (person === undefined) {
...
}

1.5.8 条件判断和循环最多三层

条件判断能使用三目运算符和逻辑运算符解决的,就不要使用条件判断,但是谨记不要写太长的三目运算符。如果超过 3 层请抽成函数,并写清楚注释。

1.5.9 this 的转换命名

对上下文 this 的引用只能使用’self’来命名

1.5.10 慎用 console.log

因 console.log 大量使用会有性能问题,所以在非 webpack 项目中谨慎使用 log 功能

二、Vue 项目规范

(一) Vue 编码基础

vue 项目规范以 Vue 官方规范 (https://cn.vuejs.org/v2/style-guide/) 中的 A 规范为基础,在其上面进行项目开发,故所有代码均遵守该规范。

请仔仔细细阅读 Vue 官方规范,切记,此为第一步。

2.1.1. 组件规范

  1. 组件名为多个单词。
    组件名应该始终是多个单词组成(大于等于 2),且命名规范为 KebabCase 格式。
    这样做可以避免跟现有的以及未来的 HTML 元素相冲突,因为所有的 HTML 元素名称都是单个单词的。

正例:

1
2
3
4
export default {
name: 'TodoItem'
// ...
};

反例:

1
2
3
4
5
6
7
8
9
10
export default {
name: 'Todo',
// ...
}


export default {
name: 'todo-item',
// ...
}
  1. 组件文件名为 pascal-case 格式
    正例:
1
2
components/
|- my-component.vue

反例:

1
2
3
components/
|- myComponent.vue
|- MyComponent.vue
  1. 基础组件文件名为 base 开头,使用完整单词而不是缩写。
    正例:
1
2
3
4
components/
|- base-button.vue
|- base-table.vue
|- base-icon.vue

反例:

1
2
3
4
components/
|- MyButton.vue
|- VueTable.vue
|- Icon.vue
  1. 和父组件紧密耦合的子组件应该以父组件名作为前缀命名
    正例:
1
2
3
4
5
components/
|- todo-list.vue
|- todo-list-item.vue
|- todo-list-item-button.vue
|- user-profile-options.vue (完整单词)

反例:

1
2
3
4
5
components/
|- TodoList.vue
|- TodoItem.vue
|- TodoButton.vue
|- UProfOpts.vue (使用了缩写)
  1. 在 Template 模版中使用组件,应使用 PascalCase 模式,并且使用自闭合组件。
    正例:
1
2
3
4
5
6
<!-- 在单文件组件、字符串模板和 JSX 中 -->
<MyComponent />
<Row><table :column="data"/></Row>
反例:

<my-component /> <row><table :column="data"/></row>
  1. 组件的 data 必须是一个函数
    当在组件中使用 data 属性的时候 (除了 new Vue 外的任何地方),它的值必须是返回一个对象的函数。 因为如果直接是一个对象的话,子组件之间的属性值会互相影响。

正例:

1
2
3
4
5
6
7
export default {
data () {
return {
name: 'jack'
}
}
}

反例:

1
2
3
4
5
export default {
data: {
name: 'jack'
}
}
  1. Prop 定义应该尽量详细
  • 必须使用 camelCase 驼峰命名
  • 必须指定类型
  • 必须加上注释,表明其含义
  • 必须加上 required 或者 default,两者二选其一
  • 如果有业务需要,必须加上 validator 验证
    正例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
props: {
// 组件状态,用于控制组件的颜色
status: {
type: String,
required: true,
validator: function (value) {
return [
'succ',
'info',
'error'
].indexOf(value) !== -1
}
},
// 用户级别,用于显示皇冠个数
userLevel:{
type: String,
required: true
}
}
  1. 为组件样式设置作用域
    正例:
1
2
3
4
5
6
7
8
9
10
<template>
<button class="btn btn-close">X</button>
</template>

<!-- 使用 `scoped` 特性 -->
<style scoped>
.btn-close {
background-color: red;
}
</style>

反例:

1
2
3
4
5
6
7
8
9
<template>
<button class="btn btn-close">X</button>
</template>
<!-- 没有使用 `scoped` 特性 -->
<style>
.btn-close {
background-color: red;
}
</style>
  1. 如果特性元素较多,应该主动换行。
    正例:
1
2
3
4
5
6
7
<MyComponent foo="a" bar="b" baz="c"
foo="a" bar="b" baz="c"
foo="a" bar="b" baz="c"
/>
反例:

<MyComponent foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c"/>

2.1.2. 模板中使用简单的表达式

组件模板应该只包含简单的表达式,复杂的表达式则应该重构为计算属性或方法。复杂表达式会让你的模板变得不那么声明式。我们应该尽量描述应该出现的是什么,而非如何计算那个值。而且计算属性和方法使得代码可以重用。

正例:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<p>{{ normalizedFullName }}</p>
</template>

// 复杂表达式已经移入一个计算属性
computed: {
normalizedFullName: function () {
return this.fullName.split(' ').map(function (word) {
return word[0].toUpperCase() + word.slice(1)
}).join(' ')
}
}

反例:

1
2
3
4
5
6
7
8
9
<template>
<p>
{{
fullName.split(' ').map(function (word) {
return word[0].toUpperCase() + word.slice(1)
}).join(' ')
}}
</p>
</template>

2.1.3 指令都使用缩写形式

指令推荐都使用缩写形式,(用 : 表示 v-bind: 、用 @ 表示 v-on: 和用 # 表示 v-slot:)

正例:

1
2
3
4
<input
@input="onInput"
@focus="onFocus"
>

反例:

1
2
3
4
<input
v-on:input="onInput"
@focus="onFocus"
>

2.1.4 标签顺序保持一致

单文件组件应该总是让标签顺序保持为 `

正例:

1
2
3
<template>...</template>
<script>...</script>
<style>...</style>

反例:

1
2
3
<template>...</template>
<style>...</style>
<script>...</script>

2.1.5 必须为 v-for 设置键值 key

2.1.6 v-show 与 v-if 选择

如果运行时,需要非常频繁地切换,使用 v-show ;如果在运行时,条件很少改变,使用 v-if。

2.1.7 script 标签内部结构顺序

components > props > data > computed > watch > filter > 钩子函数(钩子函数按其执行顺序) > methods

2.1.8 Vue Router 规范

  1. 页面跳转数据传递使用路由参数
    页面跳转,例如 A 页面跳转到 B 页面,需要将 A 页面的数据传递到 B 页面,推荐使用 路由参数进行传参,而不是将需要传递的数据保存 vuex,然后在 B 页面取出 vuex 的数据,因为如果在 B 页面刷新会导致 vuex 数据丢失,导致 B 页面无法正常显示数据。

正例:

1
2
let id = '123';
this.$router.push({ name: 'userCenter', query: { id: id } });
  1. 使用路由懒加载(延迟加载)机制
1
2
3
4
5
6
7
8
{
path: '/uploadAttachment',
name: 'uploadAttachment',
meta: {
title: '上传附件'
},
component: () => import('@/view/components/uploadAttachment/index.vue')
},
  1. router 中的命名规范
    path、childrenPoints 命名规范采用 kebab-case 命名规范(尽量 vue 文件的目录结构保持一致,因为目录、文件名都是 kebab-case,这样很方便找到对应的文件)

name 命名规范采用 KebabCase 命名规范且和 component 组件名保持一致!(因为要保持 keep-alive 特性,keep-alive 按照 component 的 name 进行缓存,所以两者必须高度保持一致)

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
// 动态加载
export const reload = [
{
path: '/reload',
name: 'reload',
component: Main,
meta: {
title: '动态加载',
icon: 'icon iconfont'
},

children: [
{
path: '/reload/smart-reload-list',
name: 'SmartReloadList',
meta: {
title: 'SmartReload',
childrenPoints: [
{
title: '查询',
name: 'smart-reload-search'
},
{
title: '执行reload',
name: 'smart-reload-update'
},
{
title: '查看执行结果',
name: 'smart-reload-result'
}
]
},
component: () =>
import('@/views/reload/smart-reload/smart-reload-list.vue')
}
]
}
];
  1. router 中的 path 命名规范
    path 除了采用 kebab-case 命名规范以外,必须以 / 开头,即使是 children 里的 path 也要以 / 开头。如下示例

目的:

经常有这样的场景:某个页面有问题,要立刻找到这个 vue 文件,如果不用以/开头,path 为 parent 和 children 组成的,可能经常需要在 router 文件里搜索多次才能找到,而如果以/开头,则能立刻搜索到对应的组件

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
{
path: '/file',
name: 'File',
component: Main,
meta: {
title: '文件服务',
icon: 'ios-cloud-upload'
},
children: [
{
path: '/file/file-list',
name: 'FileList',
component: () => import('@/views/file/file-list.vue')
},
{
path: '/file/file-add',
name: 'FileAdd',
component: () => import('@/views/file/file-add.vue')
},
{
path: '/file/file-update',
name: 'FileUpdate',
component: () => import('@/views/file/file-update.vue')
}
]
}

(二) Vue 项目目录规范

2.2.1 基础

vue 项目中的所有命名一定要与后端命名统一。

比如权限:后端 privilege, 前端无论 router , store, api 等都必须使用 privielege 单词!

2.2.2 使用 Vue-cli 脚手架

使用 vue-cli3 来初始化项目,项目名按照上面的命名规范。

2.2.3 目录说明

目录名按照上面的命名规范,其中 components 组件用大写驼峰,其余除 components 组件目录外的所有目录均使用 kebab-case 命名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
src                               源码目录
|-- api 所有api接口
|-- assets 静态资源,images, icons, styles等
|-- components 公用组件
|-- config 配置信息
|-- constants 常量信息,项目所有Enum, 全局常量等
|-- directives 自定义指令
|-- filters 过滤器,全局工具
|-- datas 模拟数据,临时存放
|-- lib 外部引用的插件存放及修改文件
|-- mock 模拟接口,临时存放
|-- plugins 插件,全局使用
|-- router 路由,统一管理
|-- store vuex, 统一管理
|-- themes 自定义样式主题
|-- views 视图目录
| |-- role role模块名
| |-- |-- role-list.vue role列表页面
| |-- |-- role-add.vue role新建页面
| |-- |-- role-update.vue role更新页面
| |-- |-- index.less role模块样式
| |-- |-- components role模块通用组件文件夹
| |-- employee employee模块
  1. api 目录
  • 文件、变量命名要与后端保持一致。
  • 此目录对应后端 API 接口,按照后端一个 controller 一个 api js 文件。若项目较大时,可以按照业务划分子目录,并与后端保持一致。
  • api 中的方法名字要与后端 api url 尽量保持语义高度一致性。
  • 对于 api 中的每个方法要添加注释,注释与后端 swagger 文档保持一致。
    正例:

后端 url: EmployeeController.java

1
2
3
/employee/add
/employee/delete/{id}
/employee/update

前端: employee.js

1
2
3
4
5
6
7
8
9
10
11
12
// 添加员工
addEmployee: (data) => {
return postAxios('/employee/add', data)
},
// 更新员工信息
updateEmployee: (data) => {
return postAxios('/employee/update', data)
},
// 删除员工
deleteEmployee: (employeeId) => {
return postAxios('/employee/delete/' + employeeId)
},
  1. assets 目录
    assets 为静态资源,里面存放 images, styles, icons 等静态资源,静态资源命名格式为 kebab-case
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|assets
|-- icons
|-- images
| |-- background-color.png
| |-- upload-header.png
|-- styles
3) components 目录
此目录应按照组件进行目录划分,目录命名为 KebabCase,组件命名规则也为 KebabCase

|components
|-- error-log
| |-- index.vue
| |-- index.less
|-- markdown-editor
| |-- index.vue
| |-- index.js
|-- kebab-case
  1. constants 目录
    此目录存放项目所有常量,如果常量在 vue 中使用,请使用 vue-enum 插件(https://www.npmjs.com/package/vue-enum)

目录结构:

1
2
3
4
|constants
|-- index.js
|-- role.js
|-- employee.js

例子: employee.js

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
export const EMPLOYEE_STATUS = {
NORMAL: {
value: 1,
desc: '正常'
},
DISABLED: {
value: 1,
desc: '禁用'
},
DELETED: {
value: 2,
desc: '已删除'
}
};

export const EMPLOYEE_ACCOUNT_TYPE = {
QQ: {
value: 1,
desc: 'QQ登录'
},
WECHAT: {
value: 2,
desc: '微信登录'
},
DINGDING: {
value: 3,
desc: '钉钉登录'
},
USERNAME: {
value: 4,
desc: '用户名密码登录'
}
};

export default {
EMPLOYEE_STATUS,
EMPLOYEE_ACCOUNT_TYPE
};
  1. router 与 store 目录
    这两个目录一定要将业务进行拆分,不能放到一个 js 文件里。

router 尽量按照 views 中的结构保持一致

store 按照业务进行拆分不同的 js 文件

  1. views 目录
  • 命名要与后端、router、api 等保持一致
  • components 中组件要使用 PascalCase 规则
1
2
3
4
5
6
7
8
9
10
11
12
|-- views                            视图目录
| |-- role role模块名
| | |-- role-list.vue role列表页面
| | |-- role-add.vue role新建页面
| | |-- role-update.vue role更新页面
| | |-- index.less role模块样式
| | |-- components role模块通用组件文件夹
| | | |-- role-header.vue role头部组件
| | | |-- role-modal.vue role弹出框组件
| |-- employee employee模块
| |-- behavior-log 行为日志log模块
| |-- code-generator 代码生成器模块

2.2.4 注释说明

整理必须加注释的地方

  • 公共组件使用说明
  • api 目录的接口 js 文件必须加注释
  • store 中的 state, mutation, action 等必须加注释
  • vue 文件中的 template 必须加注释,若文件较大添加 start end 注释
  • vue 文件的 methods,每个 method 必须添加注释
  • vue 文件的 data, 非常见单词要加注释

2.2.5 其他

  1. 尽量不要手动操作 DOM
    因使用 vue 框架,所以在项目开发中尽量使用 vue 的数据驱动更新 DOM,尽量(不到万不得已)不要手动操作 DOM,包括:增删改 dom 元素、以及更改样式、添加事件等。

  2. 删除无用代码
    因使用了 git/svn 等代码版本工具,对于无用代码必须及时删除,例如:一些调试的 console 语句、无用的弃用功能代码。

1、写一个 js 函数,实现对一个数字每 3 位加一个逗号,如输入 100000, 输出 100,000(不考虑负数,小数)—百度前端面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let arr = []
function main(num) {
if (num === null) return
let n = parseInt(num).toString()
s(n)
}

function s(num) {
if (num.length > 3) {
arr[arr.length] = num.slice(-3)
s(num.slice(0, -3))
} else {
arr[arr.length] = num
}
}
main(123456789)

console.log(arr.reverse().join(","))

解题思路
本题是 js 实现 number.toLocaleString()方法,面试题做了简化不考虑负数小数,此题主要是考数据类型及字符串操作,答案不唯一。

按现实思路解题,现实中添加千位分隔符是从后到前,每 3 位添加逗号,所以这里输入数据转换成字符串后,利用 slice 方法的输入负数参数从后取的特点,从后取三位数字保存在数组中,并把取剩后的数据递归重复取值,直到数据不足 3 位,把剩下一起存入数组中。

这时数组中按顺序保存从后到前的分割数据。实例中数组是[‘789’,’456’,’123’]。通过 reverse 方法倒序输出,并通过 join 方法添加逗号。

2、给定一个字符串,找出其中无重复字符的最长子字符串长度—字节跳动前端面试题

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
// var lengthOfLongestSubstring = function(s) {
// let n = s.length;
// let set = new Set();
// let ans = 0, i = 0, j = 0;
// while (i < n && j < n) {
// if (!set.has(s[j])) {
// set.add(s[j++]);
// ans = Math.max(ans, j - i);
// } else {
// set.delete(s[i++]);
// }
// }
// return ans;
// };

//时间复杂度:O(2n) = O(n)O(2n)=O(n),在最糟糕的情况下,每个字符将被 ii 和 jj 访问两次。
//空间复杂度:O(min(m, n))O(min(m,n)),与之前的方法相同。滑动窗口法需要 O(k)O(k) 的空间,其中 kk 表示 Set 的大小。而Set的大小取决于字符串 nn 的大小以及字符集/字母 mm 的大小。



function StrLen(str) {
let result = 1 //最终要返回的结果的初始值
let norepeatStr = '' //用于存放无重复字符串
let len = str.length
for (let i = 0; i < len; i++) {
//charAt()获取的是字符串索引对应的具体字符
let specStr = str.charAt(i)
//indexOf()查找的是某个字符第一次出现并返回这个索引值,若没有这个字符,返回-1
let index = norepeatStr.indexOf(specStr)
if (index === -1) {
//将遍历得到的字符(未重复)拼接在norepeatStr后面
norepeatStr = norepeatStr + specStr
result = result < norepeatStr.length ? norepeatStr.length : result
} else {
//若遇到重复的字符,那么将已出现在norepeatStr里的字符删除,并将新的(重复的添加到末尾)
norepeatStr = norepeatStr.substr(index + 1) + specStr
}
}
return result
}

console.log(StrLen(abbbcbd))

解题思路
这题的要点就是无重复字符的理解。首先字符串内字符位置是固定的,我们要采用顺序循环的方式解题,然后就是理解无重复字符的含义,把当前字符串分割,每个小分割内不能出现重复的字符。也就是说分割的字符串是不会互相叠加重复的,每当该段分割的下一个字符与该段分割内字符相同,当即重新开始分割字符。

所以解题时需要一个存储当前分割片段的对象,用来比较下一个字符。并取这个分割片段的长度,与每个分割片段的最大长度比较即可。本题主要考的是题面的理解,以及字符串方法的运用,需要熟练地运用才能快速解题。

3、实现超出整数存储范围的两个大正整数相加—腾讯前端面试题

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
function func() {
let a = '333333333333333333333333333'
let b = '9999999999999999999'
let n1 = a.length
let n2 = b.length
for (let i = 0; i < Math.max(n1, n2) - Math.min(n1, n2); i ++) {
if (n1 > n2) b = '0' + b
if (n2 > n1) a = '0' + a
}
a = a.split('').reverse()
b = b.split('').reverse()
//split()基于指定的分隔符将一个字符串分割成多个子字符串并将结果放在一个数组中
//reverse()反转数组项的顺序(加法计算顺序)
//现在a,b数组中存储着相同个数的大数字的逆顺序拆解

let n = Math.max(n1, n2)
let result = Array.apply(this, Array(n)).map((item, i) => {
return 0
})
//生成一个长度为n的每个元素都为0的数组(用来保存最终结果)

for (let k = 0; k < n; k ++) {
let temp = parseInt(a[k]) + parseInt(b[k])
if (temp > 9) {
result[k] += temp - 10
result[k+1] = 1
} else {
result[k] += temp
}
}

//把ab数组中的数字相加减,注意进位

console.log(result.reverse().join('').toString())
//将数组项基于指定的分隔符以字符串输出

}

func()

解题思路
首先了解超出存储范围的大数字概念,每种数据类型可存储数据量都是存在范围的

数字类型的范围:

Number.MAX_VALUE = 1.7976931348623157e+308

Number.MIN_VALUE = 5e-324

整数类型的范围:-2-53-253

当超出这个范围,为了避免数据丢失,就要采用其他手段进行运算。在参考答案中,运用数组的方式解决这个问题。首先两个大整数要存储在数组中,要先保证位数对齐,我们比较字符串长度把低位数的大整数字符串前面添加相应的 0 占位, 并逆排序。创建一个新的数组保存运算结果,将两个大整数按从后到前的顺序进行相加减,这里注意进位。把得到的数组反转到正常顺序即可。

4、任意数组的全排列组合—阿里巴巴前端面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var arr = ['foo','bar','hello','world'];
var count = 1;
function getStr(a){
for (var i = 0; i < arr.length; i++) {
// indexOf 是es6数组的方法,如果不存在返回-1,存在返回下标
if(a.indexOf(arr[i])<0){

//数组 a 中不存在 arr[i],将arr[i]添加到数组末尾
a.push(arr[i]);

if(a.length==arr.length){
console.log(count++ + ': ' +a.join(""));
}else{
//结束一次for循环 进行了4次递归 getStr(['foo']) getStr(['bar']) getStr(['hello']) getStr(['world'])
getStr(a);

}
//一定从数组 a 中删除arr[i],进行下次循环,如果不删除就只能获得一种结果了
a.pop();
}
}
}
getStr([])

解题思路
本题做法不唯一,这里采用了把多项数组逐步两两相乘的方式,第一次先取二维数组前两项组合,把组合的结果在与第三项组合以此类推。这种递归做法简单易懂,把复杂的多项问题简化成两项问题的逐渐递增。

5、公司最近新研发了一种产品,共生产了 n 件。有 m 个客户想购买此产品,已知每个顾客出价。为了确保公平,公司决定要以一个固定的价格出售产品。每一个出价不低于要价的客户将会得到产品(每人只买一个),余下的将会被拒绝购买。请你找出能让公司利润最大化的售价。—京东前端面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let n = 3
let m = 4
let arr = [2, 8, 10, 7]
let key = 0, max = 0
arr = arr.sort( (a, b) => {
return a - b //升序
})
if (n < m) {
arr = arr.slice(m - n) //截取出价高的人
}
for (let i = 0; i < arr.length; i ++) {
if (max < arr[i] * (arr.length - i)) {
max = arr[i] * (arr.length - i)
key = arr[i]
}
}

console.log(key)

解题思路
本题是京东的业务演变题,首先要理清思路。本题中,固定出价,以及出价低于产品的顾客会被拒绝购买是解题核心。

条件中已知产品总个数,顾客出价。这里有个小陷阱,会出现 N<M 供不应求的情况,要特殊考虑。依据题目,我们首先需要对顾客出价排序,这里按升序排列。当供不应求出现时,我们截取出价高的顾客。然后把每个顾客的出价当做最终售价循环,得出最大化利润下的售价。

6、计算出字符串中出现次数最多的字符是什么,出现了多少次?—华为前端面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let s = 'asdaaaaaad'
let count = 0, char = '' //count出现次数 char字符
let arr = [] // 储存去重后字符
function foo(str) { // 去重
return r = str.split("").filter(function (element, index, self) {
return self.indexOf(element) === index // 输出第一次出现的字符
})
}

arr = foo(s)
for (let i = 0; i < arr.length; i ++) {
let n = (s.split(arr[i])).length - 1 //出现次数
if (count < n) {
count = n
char = arr[i]
}
}
console.log("count:" + count + ",char:" + char)

解题思路
计算出全部字符出现次数,并留下最大的。首先利用 filter()与 indexOf()的方法连用字符串去重,再将得到的作为索引,利用 split()分割字符串,得到字符出现次数,比较得出结果。

7、”123456789876543212345678987654321…”的第 n 位是什么?—小米面试题

1
2
3
4
5
let k = "1234567898765432"  //最小循环节
function getNum(n) {
console.log(k.charAt(n % k.length - 1))
}
getNum(20)

解题思路
这道题的答案不唯一,这里可以利用数学中最小循环节的概念解题,找到最小循环节后,利用余数查找第 n 位数字。

8、请编写一个 JavaScript 函数 parseQueryString,它的用途是把 URL 参数解析为一个对象—淘宝面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function parseQueryString(url) {
var pos = url.indexOf("?")
var obj = {}
if (pos != -1) {
var urlString=url.slice(pos+1)
var urlArr = urlString.split("&")
var keyValue = []
for (var i = 0; i < urlArr.length; i++) {
keyValue = urlArr[i].split("=")
obj[keyValue[0]]=keyValue[1]
}
}
return obj
}

解题思路
淘宝这道题是很常用的场景题,这里需要处理好分段次序,首先把?分离,然后按&分割最后按=分割,主要考察字符串的函数运用以及对象的创建。

9、如果给定的字符串是回文,返回 true,反之,返回 false。回文:如果一个字符串忽略标点符号、大小写和空格,正着读和反着读一模一样,那么这个字符串就是 palindrome(回文)。—网易前端面试题

1
2
3
4
5
6
7
8
function palindrome(str) {
let str1 = str.replace(/[^0-9a-zA-Z]/g,"").toLowerCase() // 去掉标点符号,转化成小写,比较参数一
let str2 = str1.split("").reverse().join("") // 翻转字符串,比较参数二
if (str1 === str2) {
return true
} else return false
}
console.log(palindrome("aBc,./1d42--==EFG0 00 h0 ';00gfE' ./.24d 1cBA")) // 输出结果:true

解题思路
去掉字符串多余的标点符号和空格,然后把字符串转化成小写来验证此字符串是否为回文。

10、确保字符串的每个单词首字母都大写,其余部分小写。——搜狐前端面试题

1
2
3
4
5
6
7
8
9
function titleCase(str) {
let aStr = str.toLowerCase().split(" ") // 转小写,分割成字符串数组
for (let i = 0; i < aStr.length; i ++) {
aStr[i] = aStr[i][0].toUpperCase() + aStr[i].slice(1) // 重新组合字符串元素
}
let oString = aStr.join(" ") //转成字符串
return oString
}
console.log(titleCase("I'm a title Case")) // 输出结果为 I'm A Title Case

解题思路
字符串转化成小写;
分割成字符串数组;
新组合字符串元素=首字母转大写+其余小写。

代码 github 地址