Location: lasagna Kaia Super Galaxy Group
Work: SDE @Bytedance,EX-Kwai、Horizon Robotics
JavaScript(TS)/ Rust / Python / Golang
I just want to live while i'm alive
License: MIT License
去年刚入职的时候,学习了 AWS 的关于 BPMN2 的一些文档,输出一下自己对于文档的理解:
引用《流程性组织》的说法:流程是一套完整的端到端,为客户创造价值的活动连接集合。
那流程如何满足客户的要求呢:
其实总的来说,流程的这些需求,对于广泛的 B端产品 都是适用的。上述的这些要素,对于几乎所有的 B 端产品想要满足客户的需要,让用户用的舒服,都值得参考
那么流程本身,又有哪些必要构成呢:
按照JavaScript最新标准,一共定义了7种数据类型:
区别:
只有两个值,true和false,表示逻辑上的真和假
String用于文本数据,最大长度为2^53-1。需要注意的是String的意义并非字符串,而是字符串的UTF16编码,因此字符串的方法都是针对UTF16编码的。
此外,JavaScript的字符串是永远无法变更的,一旦字符串构造出来,就不能以任何形式的方式改变字符串的内容,所以字符串具有值类型的特征。(这个和Java类似)
Number对应的就是数字,大致对应数学中的有理数。JavaScript的Number类型有18437736874454810627(即 2^64-2^53+3)个值。
Number中有三个需要注意的值:
一段经典的代码:
console.log( 0.1 + 0.2 == 0.3);
其原因就是因为浮点数运算的精度问题所导致的。正确的运算方法应该是:
console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON);
Symbol 是 ES6 中引入的新类型,它是一切非字符串的对象 key 的集合,在 ES6 规范中,整个对象系统被用 Symbol 重塑。Symbol 可以具有字符串类型的描述,但是即使描述相同,Symbol 也不相等
JavaScript中,对象的定义是“属性的集合”。其中属性有分为:数据属性和访问器属性,二者都是key-value的结构,key可以是字符串和Symbol。
JavaScript 中的几个基本类型,都在对象类型中有一个“亲戚”。它们是:
需要注意的是, 3 与 new Number(3)是完全不一样的,一个是Number类型一个是对象类型。而Number、String 和 Boolean这三个构造器也是两用的,当和new搭配时,会产生对象,当直接调用时,会进行强制类型转换。
入职后公司用的技术栈还是 react,但状态管理由我原本熟悉的 redux 进化成了在 redux 基础上封装而成的 rematch。用起来也着实方便不少,减少了很多样板代码的编写,同时也不用引入中间件来管理异步请求了,但在用起来的过程中难免就会引起了我对 rematch 的一些小疑问:
带着上面的这些疑问,我翻开了 rematch 的源码开始读了起来
打开 rematch 的文件目录,来到 src 文件夹下,可以看到其文件的主要层级如下:
.src
├── plugins
│ ├── dispatch.ts // 同于生成 dispatch
│ ├── effects.ts // 同于处理异步 action
├── typings // 类型约束文件
├── utils // 工具函数
├── index.ts // 入口文件
├── pluginFactory.ts
├── redux.ts // 基础redux
├── rematch.ts // Rematch基类
然后打开 index.ts 文件,rematch 的 index 文件非常精简,现版本只存在两个具有实际应用价值的函数:
// 为给定对象创建model,然后返回作为参数接收的对象
export function createModel<S = any, M extends R.ModelConfig<S> = any>(model: M) {
return model
}
let count = 0
export const init = (initConfig: R.InitConfig = {}): R.RematchStore => {
// 如果不指定 name,就将其迭代的次数作为 name
const name = initConfig.name || count.toString()
count += 1
// 配置的对象,在这里使用 mergeConfig 来合并
const config: R.Config = mergeConfig({ ...initConfig, name })
// 在这里会先将 config 的信息传入 Rematch 函数中,然后会被 init 函数会执行,而它的结果也在此被返回,也就是我们新生成的 store
return new Rematch(config).init()
}
export default {
init,
}
index 文件中最为核心的就是 init 函数了,它主要做了以下工作:
上面的 index 文件到了 new Rematch(config).init() 就截然而止了,虽然我们知道他已经在此过程中,完成了一个 store 的创建,但这个过程我却并不知晓,因此接下来就是要去翻阅 rematch.ts 文件,首先扫一眼这个文件的大概情况,为后面的阅读做个铺垫:
import pluginFactory from './pluginFactory'
import dispatchPlugin from './plugins/dispatch'
import effectsPlugin from './plugins/effects'
import createRedux from './redux'
import * as R from './typings'
import validate from './utils/validate'
const corePlugins: R.Plugin[] = [dispatchPlugin, effectsPlugin]
/**
* Rematch class
*
* an instance of Rematch generated by "init"
*/
export default class Rematch {
protected config: R.Config
protected models: R.Model[]
private plugins: R.Plugin[] = []
private pluginFactory: R.PluginFactory
constructor(config: R.Config) {
}
public forEachPlugin(method: string, fn: (content: any) => void) {
}
public getModels(models: R.Models): R.Model[] {
}
public addModel(model: R.Model) {
}
public init() {
}
}
首先来看 rematch.ts 的类的声明部分:
export default class Rematch {
protected config: R.Config
protected models: R.Model[]
private plugins: R.Plugin[] = []
private pluginFactory: R.PluginFactory
constructor(config: R.Config) {
// 这里的 config 就是从 index.ts 里传入的 config
this.config = config
this.pluginFactory = pluginFactory(config)
// 遍历 corePlugins 以及 config 中的 plugins
// 对其中的每个 plugins 通过 pluginFactor.create 生成 plugins 数组
for (const plugin of corePlugins.concat(this.config.plugins)) {
this.plugins.push(this.pluginFactory.create(plugin))
}
// preStore: middleware, model hooks
// 将 middleware 执行一遍,并将 middleware 添加到 this.config.redux.middlewares 这个数组中
this.forEachPlugin('middleware', (middleware) => {
this.config.redux.middlewares.push(middleware)
})
}
... ...
}
通过上面的代码我们可以发现,当我们去实例化 Rematch 时,首先会去执行这里的构造函数。这个构造函数主要是为了处理 plugin,并对两类不同的 plugin 分别进行处理:
接下来就是 rematch 中定义的三个方法了:
public forEachPlugin(method: string, fn: (content: any) => void) {
for (const plugin of this.plugins) {
if (plugin[method]) {
fn(plugin[method])
}
}
}
public getModels(models: R.Models): R.Model[] {
return Object.keys(models).map((name: string) => ({
name,
...models[name],
reducers: models[name].reducers || {},
}))
}
public addModel(model: R.Model) {
validate([
[!model, 'model config is required'],
[typeof model.name !== 'string', 'model "name" [string] is required'],
[model.state === undefined, 'model "state" is required'],
])
// run plugin model subscriptions
this.forEachPlugin('onModel', (onModel) => onModel(model))
}
从这三个方法的名字我们就不难看出这是哪个方法的具体作用了,这三个方法主要是为了协助上面的构造函数以及下面 init() ,因此在此就不一一赘述了。下面就来重头戏 init() :
public init() {
// collect all models
// 通过 getModels 获取所有的 models
this.models = this.getModels(this.config.models)
// 遍历所有的 models 执行 addModels
for (const model of this.models) {
this.addModel(model)
}
// create a redux store with initialState
// merge in additional extra reducers
// 这里就是更新 state 的 reducer 了,后面具体会有分析
const redux = createRedux.call(this, {
redux: this.config.redux,
models: this.models,
})
const rematchStore = {
name: this.config.name,
...redux.store,
// dynamic loading of models with `replaceReducer`
model: (model: R.Model) => {
this.addModel(model)
redux.mergeReducers(redux.createModelReducer(model))
redux.store.replaceReducer(redux.createRootReducer(this.config.redux.rootReducers))
redux.store.dispatch({ type: '@@redux/REPLACE '})
},
}
this.forEachPlugin('onStoreCreated', (onStoreCreated) => {
const returned = onStoreCreated(rematchStore)
// if onStoreCreated returns an object value
// merge its returned value onto the store
if (returned) {
Object.keys(returned || {}).forEach((key) => {
rematchStore[key] = returned[key]
})
}
})
return rematchStore
}
init() 会先执行 getModels 从而获取所有的 models ,并返回给 this.model, 然后通过遍历 this.model,对其中的每个 models 都执行 addModel ,然后就会去调用 forEachPlugin。这块的执行逻辑稍微有点深,但其实本质上就是为了让所有的 models 都这么执行一次:
plugin.onModel(model)
同时这里也会根据 model 的不同的情况,去执行两种plugin:dispatchPlugin 和 effectPlugin。
其中 dispatchPlugin 的 onModel 处理如下:
// dispatch.ts
onModel(model: R.Model) {
this.dispatch[model.name] = {}
if (!model.reducers) {
return
}
for (const reducerName of Object.keys(model.reducers)) {
this.validate([
[
!!reducerName.match(/\/.+\//),
`Invalid reducer name (${model.name}/${reducerName})`,
],
[
typeof model.reducers[reducerName] !== 'function',
`Invalid reducer (${model.name}/${reducerName}). Must be a function`,
],
])
// 根据 model Name 和 reducer Name 生成相应的 dispatch 函数
this.dispatch[model.name][reducerName] = this.createDispatcher.apply(
this,
[model.name, reducerName]
)
}
}
我们可以看到 onModel 函数会遍历所有的 reducer,然后生成相应的 dispatch 函数(如何实现后面讨论)
而 effectsPlugin 的 onModel 则是这样的:
onModel(model: R.Model): void {
if (!model.effects) {
return
}
const effects =
typeof model.effects === 'function'
? model.effects(this.dispatch)
: model.effects
for (const effectName of Object.keys(effects)) {
this.validate([
[
!!effectName.match(/\//),
`Invalid effect name (${model.name}/${effectName})`,
],
[
typeof effects[effectName] !== 'function',
`Invalid effect (${model.name}/${effectName}). Must be a function`,
],
])
this.effects[`${model.name}/${effectName}`] = effects[effectName].bind(
this.dispatch[model.name]
)
// add effect to dispatch
// is assuming dispatch is available already... that the dispatch plugin is in there
this.dispatch[model.name][effectName] = this.createDispatcher.apply(
this,
[model.name, effectName]
)
// tag effects so they can be differentiated from normal actions
this.dispatch[model.name][effectName].isEffect = true
}
},
这两者的 onModel 其实都差不多,最大的却别就是 effectsPlugin 的 onModel 在最后标记了 isEffect 为 true 。然后我们就可以来看 this.createDispatcher 到底做了什么:
/**
* createDispatcher
*
* genereates an action creator for a given model & reducer
* @param modelName string
* @param reducerName string
*/
createDispatcher(modelName: string, reducerName: string) {
return async (payload?: any, meta?: any): Promise<any> => {
const action: R.Action = { type: `${modelName}/${reducerName}` }
if (typeof payload !== 'undefined') {
action.payload = payload
}
if (typeof meta !== 'undefined') {
action.meta = meta
}
return this.dispatch(action)
}
}
createDispatcher 函数的作用注释里说的很清楚,为 model 和 reducer 生成相应的 action creator,其内部实现是返回一个 async 函数,内部有由 model name 以及 reducer name 组成的套路化的 action type,然后再返回 dispatch(action) 的执行结果。这个 dispatch 又会到哪去呢?让我们将目光回到 rematch.ts 中的 init() 中去,我在上面的代码中有提到这么一段代码:
// 这里就是更新 state 的 reducer 了,后面具体会有分析
const redux = createRedux.call(this, {
redux: this.config.redux,
models: this.models,
})
实际上我们这段代码就相当于我们在 redux 中的 reducer ,他会接收到 dispatch(action) 从而变动 state。它的详情代码在 redux 中:
import * as Redux from 'redux'
import * as R from './typings'
import isListener from './utils/isListener'
const composeEnhancersWithDevtools = (
devtoolOptions: R.DevtoolOptions = {}
): any => {
const { disabled, ...options } = devtoolOptions
/* istanbul ignore next */
return !disabled &&
typeof window === 'object' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(options)
: Redux.compose
}
export default function({
redux,
models,
}: {
redux: R.ConfigRedux,
models: R.Model[],
}) {
const combineReducers = redux.combineReducers || Redux.combineReducers
const createStore: Redux.StoreCreator = redux.createStore || Redux.createStore
const initialState: any =
typeof redux.initialState !== 'undefined' ? redux.initialState : {}
this.reducers = redux.reducers
// combine models to generate reducers
this.mergeReducers = (nextReducers: R.ModelReducers = {}) => {
// merge new reducers with existing reducers
// 将已经新的 reducers 和 存在的 reducer 合并
this.reducers = { ...this.reducers, ...nextReducers }
// 如果没有 reducers 就直接返回 state
if (!Object.keys(this.reducers).length) {
// no reducers, just return state
return (state: any) => state
}
// 执行合并操作
return combineReducers(this.reducers)
}
this.createModelReducer = (model: R.Model) => {
const modelBaseReducer = model.baseReducer
const modelReducers = {}
// 遍历 model.reducers ,为其中的每个 reducer 创造一个命名空间,并将其赋值到 modelReducer 中去
for (const modelReducer of Object.keys(model.reducers || {})) {
const action = isListener(modelReducer)
? modelReducer
: `${model.name}/${modelReducer}`
modelReducers[action] = model.reducers[modelReducer]
}
const combinedReducer = (state: any = model.state, action: R.Action) => {
// handle effects
if (typeof modelReducers[action.type] === 'function') {
return modelReducers[action.type](state, action.payload, action.meta)
}
return state
}
this.reducers[model.name] = !modelBaseReducer
? combinedReducer
: (state: any, action: R.Action) =>
combinedReducer(modelBaseReducer(state, action), action)
}
// initialize model reducers
// 创建 model 的 reducer
for (const model of models) {
this.createModelReducer(model)
}
this.createRootReducer = (
rootReducers: R.RootReducers = {}
): Redux.Reducer<any, R.Action> => {
const mergedReducers: Redux.Reducer<any> = this.mergeReducers()
if (Object.keys(rootReducers).length) {
return (state, action) => {
const rootReducerAction = rootReducers[action.type]
if (rootReducers[action.type]) {
return mergedReducers(rootReducerAction(state, action), action)
}
return mergedReducers(state, action)
}
}
return mergedReducers
}
const rootReducer = this.createRootReducer(redux.rootReducers)
const middlewares = Redux.applyMiddleware(...redux.middlewares)
const enhancers = composeEnhancersWithDevtools(redux.devtoolOptions)(
...redux.enhancers,
middlewares
)
// 创建一个 redux store,并返回 this 对象
this.store = createStore(rootReducer, initialState, enhancers)
return this
}
总的来说,rematch 其实就是在 redux 的基础上进行了封装,将我们本来在 redux 中要写的诸如action creator等诸多样本代码给予封装,只需要关心 model 的划分以及写出合理的 reducers 以及 effects 即可。因此简单回答上面我看源码时所带的那两个问题:
五一假期在撸代码的时候用到cookie,感觉对浏览器的数据存储方案不是很了解,因此又去翻了两本大头书中间的关于浏览器端数据存储的章节,同时去MDN逛了逛,又看了几篇文章,算是对浏览器的数据存储方案有了一个了解,在此总结一下!
在浏览器端存储数据对我们是很有用,这相当于赋予浏览器记忆的功能,可以纪录用户的所有状态信息,增强用户体验。比如当纪录用户的登陆状态时,可以让用户能够更快的进行访问,而不是每次登陆时都需要去进行繁琐的操作。
总的来说,现在市面上最常见的数据存储方案是以下三种:
Cookie的又称是HTTP Cookie,最初是在客户端用于存储会话信息,从底层来看,它作为HTTP协议的一种扩展实现,Cookie数据会自动在web浏览器和web服务器之间传输,因此在服务器端脚本就可以读写存储的cookie的值,因此Cookie通常用于存储一些通用的数据,比如用户的登陆状态,首选项等。虽然随着时代的进步,HTML5所提供的web存储机制已经逐步替代了Cookie,但有些较为老的浏览器还是不兼容web存储机制,因此正处于这个老旧更替阶段的我们对于它还是要了解了解的。(比如我这个瓜皮还在用它,2333)
首先由于操作Cookie的API很早就已经定义和实现了,因此相比于其他的数据存储方式,Cookie的兼容性非常的好,兼容现在市面上所有的主流浏览器,我们在使用它的时候完全不用担心兼容问题。
说到Cookie的缺点,那就有点多了,不然也不会在Cookie后面出现web存储等新的数据存储的方案了。
总结起来Cookie的缺点主要是以下几点:
基本的Cookie操作主要有三个:读取,写入和删除。但在JavaScript中去处理cookie是一件很繁琐的事情,因为cookie中的所有的名字和值都是经过URI编码的,所以当我们必须使用decodeURICompoent来进行解码才能得到cookie的值。我们来看看CookieUtil对象是如何操纵cookie的:
var CookieUtil = {
// get可根据cookie的名字获取相应的值
get: function() {
const cookieName = encodeURIcOMPONET(name) + "=",
cookieStart = document.cookie.indexOf(cookieName),
cookieValue = null
if(cookieStart > -1) {
const cookieEnd = document.cookie.indexOf(";", cookieStart)
if(cookieEnd == -1) {
cookieEnd = document.cookie.length
}
cookieValue = decodeURICompoent(document.cookie.substring(cookieStart + cookieName.length, cookieEnd))
}
return cookieValue
}
// set设置一个cookie
set: function(name, value, expires, path, domain, secure) {
var cookieText = encodeURIComponet(name)+"="+encodeURIComponet(value)
if(expires instanceof Date) {
cookieText += "; expires=" + expires.toGMTString()
}
if(path) {
cookieText += ";path=" + path
}
if(domain) {
cookieText += "; domain" + domain
}
if(secure) {
cookieText += "; secure"
}
document.cookie = cookieText
}
// 删除已有的cookie
unset: function(name, path, domain, secure) {
this.set(name, "", new Date(0), path, domain, secure)
}
}
是不是很麻烦,无论是获取一个cookie的值或是设置一个cookie都是很麻烦的事情,这也成为了后续的浏览器数据存储方案出现的一大原因。
web存储机制最初作为HTML5的一部分被定义成API的形式,但又由于其本身的独特性与其他的一些原因而剥离了出来,成为独立的一个标准。web存储标准的API包括locaStorage对象和seesionStorage对象。它所产生的主要原因主要出于以下两个原因:
(注:其实在最初的web存储规范中包含了两种对象的定义:seesionStorage和globalStorage,这两个对象在支持这两个对象的浏览器中都是以windows对象属性的形式存在的)
locaStorage对象在修订过的HTML5规范中作为持久保存客户端数据的方案取代了我们上面所提到的globalStorage。从功能上来讲,我们可以通过locaStorage在浏览器端存储键值对数据,它相比于cookie而言,提供了更为直观的API,且在安全上相对好一点
,而且虽然locaStorage只能存储字符串,但它也可以存储字符串化的JSON数据,因此相比于cookie,locaStorage能存储更复杂的数据。总的来说相较于cookie,locaStorage有以下优势:
也正是出于以上这些原因,locaStorage被视为替代cookie的解决方案,但还是要注意不要在locaStorage中存储敏感信息。
locaStorage的基本操作很简单,示例如下:
// 使用方法存储数据
locaStorage.setItem("name", "Srtian")
// 使用属性存储数据
locaStorage.say = "Hello world"
// 使用方法读取数据
const name = locaStorage.getItem("name")
// 使用属性读取数据
const say = locaStorage.say
// 删除数据
locaStorage.removeItem("name")
但需要注意的是,我们上面的示例全是存储字符串格式的数据,当我们需要传输其他格式的数据时,我们就需要将这些数据全部转换为字符串格式,然后再进行存储:
const user = {name:"Srtian", age: 22}
localStorage.setItem("user", JSON.stringify(user))
当然,我们在获取值的时候也别忘了将其转化回来:
const age = JSON.parse(locaStorage.user)
通过locaStorage存储的数据时永久性的,除非我们使用removeItem来删除或者用户通过设置浏览器配置来删除,负责数据会一直保留在用户的电脑上,永不过期。
locaStorage的作用域限定在文档源级别的(意思就是同源的才能共享),同源的文档间会共享locaStorage的数据,他们可以互相读取对方的数据,甚至有时会覆盖对方的数据。当然,locaStorage的作用域同样也受浏览器的限制。
locaStorage的兼容如下表所示:
Feature Chrome Edge Firefox (Gecko) Internet Explorer Opera Safari (WebKit)
localStorage 4 (Yes) 3.5 8 10.50 4
sessionStorage 5 (Yes) 2 8 10.50 4
sessionStorage是web存储机制的另一大对象,sessionStorage 属性允许我们去访问一个 session Storage 对象。它与 localStorage 相似,不同之处在于 localStorage里面存储的数据没有过期时间设置,而Session Storage只存储当前会话页的数据,且只有当用户关闭当前会话页或浏览器时,数据才会被清除。
我们可以通过下面的语法,来保存,获取,删除数据,大体语法与:
// 保存数据到sessionStorage
sessionStorage.setItem('name', 'Srtian');
// 从sessionStorage获取数据
var data = sessionStorage.getItem('name');
// 从sessionStorage删除保存的数据
sessionStorage.removeItem('name');
// 从sessionStorage删除所有保存的数据
sessionStorage.clear();
下面的示例会自动保存一个文本输入框的内容,如果浏览器因偶然因素被刷新了,文本输入框里面的内容会被恢复,写入的内容不会丢失:
// 获取文本输入框
var field = document.getElementById("field")
// 检测是否存在 autosave 键值
// (这个会在页面偶然被刷新的情况下存在)
if (sessionStorage.getItem("autosave")) {
// 恢复文本输入框的内容
field.value = sessionStorage.getItem("autosave")
}
// 监听文本输入框的 change 事件
field.addEventListener("change", function() {
// 保存结果到 sessionStorage 对象中
sessionStorage.setItem("autosave", field.value)
})
在兼容性和优点方面,sessionStorage和locaStorage是差不多的,因此在此也就不多说了,下面我们来聊一聊IndexedDB。
虽然web存储机制对于存储较少量的数据非常便捷好用,但对于存储更大量的结构化数据来说,这种方法就不太满足开发者们的需求了。IndexedDB就是为了应对这个需求而产生的,它是由HTML5所提供的一种本地存储,用于在浏览器中储存较大数据结构的 Web API,并提供索引功能以实现高性能查找。它一般用于保存大量用户数据并要求数据之间有搜索需要的场景,当网络断开时,用户就可以做一些离线的操作。它较之SQL更为方便,不需要写一些特定的语法对数据进行操作,数据格式是JSON。
使用IndexedDB在浏览器端存储数据会比上述的其他方法更为复杂。首先,我们需要创建数据库,并指定这个数据库的版本号:
// 注意数据库的版本号只能是整数
const request = IndexedDB.open(databasename, version)
然后我们需要生成处理函数,需要注意的是onupgradeneeded 是我们唯一可以修改数据库结构的地方。在这里面,我们可以创建和删除对象存储空间以及构建和删除索引。
:
request.onerror = function() {
// 创建数据库失败时的回调函数
}
request.onsuccess = function() {
// 创建数据库成功时的回调函数
}
request.onupgradeneededd = function(e) {
// 当数据库改变时的回调函数
}
然后我们就可以建立对象存储空间了,对象存储空间仅调用createObjectStore()就可以创建。这个方法使用存储空间的名称,和一个对象参数。即便这个参数对象是可选的,它还是非常重要的,因为它可以让我们定义重要的可选属性和完善你希望创建的对象存储空间的类型。
request.onupgradeneeded = function(event) {
const db = event.target.result
const objectStore = db.createObjectStore('name', { keyPath:'id' })
}
对象的存储空间我们已经建立好了,接下来我们就可以进行一系列的*操作了,比如来个蛇皮走位!不不不,口误口误,比如添加数据:
addData: function(db, storename, data) {
const store = store = db.transaction(storename, 'readwrite').objectStore(storename)
for(let i = 0; i < data.length; i++) {
const request = store.add(data[i])
request.onerror = function() {
console.error('添加数据失败')
}
request.onsuccess = function() {
console.log('添加数据成功')
}
}
}
如果我们想要修改数据,语法与添加数据差不多,因为重复添加已存在的数据会更新原本的数据,但还是有细小的差别:
putData: function(db, storename, data) {
const store = store = db.transaction(storename, 'readwrite').objectStore(storename)
for(let i = 0; i < data.length; i++) {
const request = store.put(data[i])
request.onerror = function() {
console.error('修改数据失败')
}
request.onsuccess = function() {
console.log('修改数据成功')
}
}
}
获取数据:
getDataByKey: function(db, storename, key) {
const store = store = db.transaction(storename, 'readwrite').objectStore(storename)
const request = store.get(key)
request.onerror = function() {
console.error('获取数据失败')
}
request.onsuccess = function(e) {
const result = e.target.result
console.log(result)
}
}
删除数据:
deleteDate: function(db, storename, key) {
const store = store = db.transaction(storename, 'readwrite').objectStore(storename)
store.delete(key)
console.log('已删除存储空间' + storename + '中的' + key + '纪录')
}
关闭数据库:
db.close
了解了IndexedDB的优点,我们当然也要来聊一聊IndexedDB的局限性与适用的场景:
一个单独的数据库项目的大小没有限制。然而可能会限制每个 IndexedDB 数据库的大小。这个限制(以及用户界面对它进行断言的方式)在各个浏览器上也可能有所不同:
从上面的图我们可以看出对于IndexedDB的兼容来讲比前面所提及的存储方案要差不少,因此在使用IndexedDB时,我们也要好好的考虑兼容性的问题
indexedDB使用同源原则,这意味着它把存储空间绑定到了创建它的站点的源(典型情况下,就是站点的域或是子域),所以它不能被任何其他源访问。要着重指出的一点是 IndexedDB 不适用于从另一个站点加载进框架的内容 (不管是 还是 <iframe>。这是一项安全措施。详情请看这个:https://bugzilla.mozilla.org/show_bug.cgi?id=595307
除此之外,IndexedDB还存在诸如:不适合存储敏感数据,相较于web存储机制的操作更加复杂等问题,这都是我们在使用IndexedDB时需要考虑的。
最近在思考如何实现一个类型错误拦截的功能,意外的发现了基于装饰器实现的这个:
加之之前实现国际化以及之前用 python的时候也使用到了装饰器,因此利用闲余时间学习了学习装饰器的一些特质以及使用技巧,越发感受到了装饰器功能的强大,写上这么一篇文章作为总结与记录,不过鉴于JS装饰器的语法介绍文章已经很多了,因此在此就不多做赘述了,只将TS中使用装饰器时需要注意的总结一下。
关于装饰器模式的定义,我看到一个比较好的定义是:
Design Patterns - Decorator Pattern. Decorator pattern allows a user to add new functionality to an existing object without altering its structure
即可以在不改变原有结构的基础上添加新功能。而这种编程方式也有一个编程范式与之相对应:面向切面编程(AOP)。AOP允许我们分离横切关注点,以此达到增加模块化程度的目标,它可以在不修改代码自身的前提下,给已有代码增加额外的行为。
其优点在于:装饰器和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。
其实在JS中,装饰器本质上就是一个函数,他可以接受一些参数,并对这些参数进行修改,从而达到对被装饰对象进行赋能的目的。举个简单的栗子,我在下方声明了一个很简单的装饰器,其作用是打印一个字符串以及该class的constructor:
import * as React from "react";
import "./styles.css";
const decoratorDemo = (constructor: any) => {
console.log("it is a demo by decorator", constructor);
};
@decoratorDemo
export default class App extends React.Component {
render() {
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
</div>
);
}
}
// 输出:
// it is a demo by decorator
// function App() {}
在TypeScript中使用装饰器并无太多坑,但以下几点需要注意:
<App />
被调用了三次,但装饰器函数之被调用了一次)上面我们在进行装饰器的定义时,对于 constructor
使用了any类型,这显然不符合我们使用TS的初衷:类型提示以及类型检查。因此我们在TS中使用装饰器,第二步要做的就是将这个 any
类型给干掉,代码如下:
type IConstructor = new (...args: any[]) => any;
const decoratorDemo1 = <T extends IConstructor>(constructor: T) => {
return class extends constructor {
getName() {
console.log(this.name);
}
name = "srtian";
};
};
@decoratorDemo1
class Demo {
name = "Bob";
}
如此这般我们就可以愉快享受TS给予我们的提示了:
const decoratorDemo = <T extends IConstructor>(constructor: T) => {
console.log("it is a demo by decorator", constructor);
console.log(constructor.prototype); // -> 写这里的时候就会有如下的提示了
};
好了,咱们现在已经解决了,定义装饰器时,所需要定义的类型。但这里还有一个问题,当我们在使用它的时候还是会报错,比如我们这样写:
const decoratorDemo1 = <T extends IConstructor>(constructor: T) => {
return class extends constructor {
getName() {
console.log(this.name);
}
name = "srtian";
};
};
@decoratorDemo1
class Demo {
name = "Bob";
}
const demo = new Demo();
demo.getName() // <-这里会报错,如下
遇到这种问题,我们就需要使用函数柯里化来帮助我们解决这个任务:
const decoratorDemo2 = () =>
function<T extends IConstructor>(constructor: T) {
console.log("decorator Demo1");
return class extends constructor {
getName() {
console.log(this.name);
}
name = "srtian";
};
};
const demo3 = new demo2()
demo3.getName()
所有代码链接:
https://codesandbox.io/s/affectionate-darwin-5zmuj?file=/src/App.tsx
stack 和 heap 都用于变量的内存存储。对于大多数的编程人员来讲,都无需去关心内存是如何分配到 heap 和 stack 中的(实际上,对于 stack 和 heap 的区别,也很多人并不是很清楚)。譬如在JavaScript中,大多数人知道一个结论:基本数据类型放在 stack 中,而引用数据类型放在 heap 里面。但一旦问到,为何需要这样进行划分的时候,少有人可以说上一二。
那它们到底有什么区别呢? 首先它们虽然都是可供代码使用的内存,但结构是不同的。 stack是一个线性的数据结构,以放入值得顺序存储值并以相反的顺序取出值,也就是我们常说的:后进先出。其数据的输入和取出,则通常被称为 进栈、出栈。由于 stack
是线性的数据结构,所以 stack 中的所有数据都是必须占用已知且固定的大小。
而 heap 则是一个非线性的数据结构,是缺乏组织的。因此对于一些在初始时,大小未知或大小可能发生变化的数据,则可以放在堆中。当我们想要在堆中存储一些数据时,我们通常其实是请求一块大小合适的空间,然后操作系统在堆中搜索一个足够大的空间以匹配我们所请求的内存量,并将其标记为已用,并返回一个表示该位置地址的 **指针, **而这个指针也通常被存在 stack 中。这个过程也就是 堆内存分配,也常被称为 内存分配。
需要注意的是,将变量推入 stack 中,其实并不能被认为是内存分配,因为它本质上,只是按顺序压入 stack中,比不需要去进行显性的分配
由于它们数据结构不同、存储方式不同。也决定了,将值压入 stack 要比在 heap 上进行内存分配要来的快。因为入 stack 时,操作系统无需为新数据搜索内存空间,位置固定于 stack 顶部,只需压入即可。而堆内存分配则需要先找到一块足够存放数据的内存空间,然后才能将变量放入,生成指针(很多时候,还需要将指针压入 stack 中保存)。同理,访问 stack 的变量也比访问 heap 得数据要快。
搞清楚栈和堆得区别后,我们大致就可以清楚我们通常说的 GC 其实主要关注的就是 heap 上的内存的回收。而我们今天要说的的所有权,其实也是主要管理堆数据,例如:哪部分代码正在使用 heap上的哪些数据,最大限度的减少堆上的重复数据,清理堆上不再使用的数据确保不会耗尽空间。
所有的编程语言,都有着属于自己的管理计算机内存的方式。大体上可分为两个大的流派:
而Rust则没有走上面两条道路,而是通过所有权系统来管理内存,编译器在编译时会根据一系列的规则去对代码进行进行检查,确定变量的回收时机,因此,当程序运行时,所有权系统不会减慢程序。
而所有权系统具体有以下三点最重要的规则:
let a = 5 // a 是 5 的 owner
Rust 根据作用域管理指针,在作用域中申请内存,离开作用域则会释放作用域。Rust 中的作用域也非常简单, Rust 是词法作用域,以大括号为边界,一个大括号对应着一个作用域:
fn main() {
let content = String::from("Srtian");
println!("{}",content);
}
比如如上的代码,在 String::from
处为 content
申请了内存,而在离开大括号后,content也就离开了作用域后被释放掉。
上面几部分以及差不多将Rust的所有权系统简单的减少了一遍,接下来就让我们来看看,所有权系统到底是如何作用域 Rust 的内存管理的。
在Rust中,对于已知大小的值,将其进行复制到另一个值会很容易:
fn main() {
let a = "5" ;
let b = a ; //将值a复制到b
println!("{}", a) // 5
println!("{}", b) // 5
}
因此 a 存储在 stack
中,所以我们可以对其直接进行复制。但对于放在 heap
中的数据,我们就不能这么简单的进行复制了(放在 heap
中的数据,也就是被所有权系统所管理的数据):
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 将s1复制到s2
println!("{}", s1) // 这里会报错,因为s1在这里已经被释放了
println!("{}", s2) // hello
}
当我们运行上面代码时,会出现报错。这是因为,当我们复制存储在 heap
中的值时,Rust 为了防止诸如:二次释放这样的错误,它在处理这种场景时,会直接认为 s1 不再有效。这样 Rust 就无需再在 s1 离开作用域时再需要清理它。
熟悉诸如JavaScript等语言的朋友,应该对浅拷贝和深拷贝很熟悉,其实上述的这个操作有点浅拷贝的意思,它只会拷贝指针、长度和容量,而不会直接拷贝数据。但 Rust 同时也会让第一个变量直接无效,因此也不能粗暴的将其理解为浅拷贝。
还有个需要注意的: Rust 永远不会自动创建数据的"深拷贝"。因此,所有自动的复制,都可以认为对于运行时的性能影响较小
而当我们确实需要深拷贝去拷贝 heap 上的数据时,我们可以使用 clone
方法来对值进行深拷贝:
#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
}
所有权的移动或者变量的深拷贝并不能满足工程师们日常的开发需求,比如:
fn main(){
let contents = String::from("hello srtian");
some_process(contents);
println!("{}",contents); // error
}
fn some_process(word:String) {
println!("some_process {}",word);
}
在上面的代码中, some_process(contents) contents变量会『移动』给some_process的参数word,contents变量就不能再次使用了。而且对于每次内存的重新分配,许多资源在时间和空间的开销都太昂贵了,但在日常开发中,类似的需求还是很多的。因此在这种情况下, Rust 提供了借用的选项。
所有权的借用也非常简单,我们只需在借用的变量前,加&字符即可:
struct Person {
age: u8
}
fn main() {
let jake = Person { age: 18 };
let srtian = &jake;
println!("jake: {:?}\nsrtian: {:?}", jake, srtian);
}
在上述代码中,尽管没有 clone。但上面的代码仍然会编译并输出。同样,如果是不可复制的值被借用,可以将其作为参数传递给函数,也就是解决我们上面所说的那个问题:
fn sum(vector: &Vec<i32>) -> i32 {
let mut sum = 0;
for item in vector {
sum = sum + item
}
sum
}
fn main() {
let v = vec![1,2,3];
let v_ref = &v;
let s = sum(v_ref);
println!("sum of {:?}: {}", v_ref, s); // 不会报错
}
不过,需要注意的是,对于借用来的变量,我们是不能对其进行更改的。这其实也符合我们日常生活的基本常识,借来的东西,我们都需要原样进行返回。不过,Rust 也提供了方法来对借用来的变量进行更改:
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
我们需要将 s 修改为 mut。然后传入的参数以及接受的参数,都需要显式的表明是 mut 的。不过需要注意的是,在特定的作用域中,特定的数据只能有一个可变引用。这个限制允许可变性的存在,不过是以一种受限的方式允许的。这样做的好处在于 Rust 可以在编译时就避免 数据竞争。数据竞争类似于竞态条件,它可由三种行为造成:
有时候,我们会希望返回借来的值。比如我们想要返回字符串中较长的一个,我们可以写出如下的代码:
fn longest(x: &str, y: &str) -> &str {
if x.bytes().len() > y.bytes().len() {
x
} else {
y
}
}
fn main() {
let jake = "jake";
let srtian = "srtian";
println!("{}", longest(jake, srtian));
}
以上的代码不能成功编译,会报错:
fn longest(x: &str, y: &str) -> &str {
^ expected lifetime parameter
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
这就有关乎变量的生命周期了,生命周期是借用变量的有效范围。Rust 强大的编译器让我们在大多数情况下,无需显式的编写它们,而是通过推断去实现。但在一些需要生命周期参与的场景下,还是需要我们手动的去添加申明周期函数。譬如,我们想要解决上面的错误,就需要进行生命周期的手动声明:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.bytes().len() > y.bytes().len() {
x
} else {
y
}
fn main() {
let jake = "jake";
let srtian = "srtian";
println!("{}", longest(jake, srtian));
}
如此我们就能将借用的变量进行返回来。
最近几天对 redux 的中间件进行了一番梳理,又看了 redux-saga 的文档,和 redux-thunk 和 redux-promise 的源码,结合前段时间看的redux的源码的一些思考,感觉对 redux 中间件的有了更加深刻的认识,因此总结一下。
Redux本身就提供了非常强大的数据流管理功能,但这并不是它唯一的强大之处,它还提供了利用中间件来扩展自身功能,以满足用户的开发需求。首先我们来看中间件的定义:
It provides a third-party extension point between dispatching an action, and the moment it reaches
the reducer.
这是Dan Abramov 对 middleware 的描述。简单来讲,Redux middleware 提供了一个分类处理 action 的机会。在 middleware 中,我们可以检阅每一个流过的 action,并挑选出特定类型的 action 进行相应操作,以此来改变 action。这样说起来可能会有点抽象,我们直接来看图,这是在没有中间件情况下的 redux 的数据流:
上面是很典型的一次 redux 的数据流的过程,但在增加了 middleware 后,我们就可以在这途中对 action 进行截获,并进行改变。且由于业务场景的多样性,单纯的修改 dispatch 和 reduce 显然不能满足大家的需要,因此对 redux middleware 的设计理念是可以自由组合,自由插拔的插件机制。也正是由于这个机制,我们在使用 middleware 时,我们可以通过串联不同的 middleware 来满足日常的开发需求,每一个 middleware 都可以处理一个相对独立的业务需求且相互串联:
如上图所示,派发给 redux Store 的 action 对象,会被 Store 上的多个中间件依次处理,如果把 action 和当前的 state 交给 reducer 处理的过程看做默认存在的中间件,那么其实所有的对 action 的处理都可以有中间件组成的。值得注意的是这些中间件会按照指定的顺序依次处理传入的 action,只有排在前面的中间件完成任务后,后面的中间件才有机会继续处理 action,同样的,每个中间件都有自己的“熔断”处理,当它认为这个 action 不需要后面的中间件进行处理时,后面的中间件就不能再对这个 action 进行处理了。
而不同的中间件之所以可以组合使用,是因为 Redux 要求所有的中间件必须提供统一的接口,每个中间件的尉氏县逻辑虽然不一样,但只要遵循统一的接口就能和redux以及其他的中间件对话了。
由于redux 提供了 applyMiddleware 方法来加载 middleware,因此我们首先可以看一下 redux 中关于 applyMiddleware 的源码:
export default function applyMiddleware(...middlewares) {
return createStore => (...args) => {
// 利用传入的createStore和reducer和创建一个store
const store = createStore(...args)
let dispatch = () => {
throw new Error(
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
// 让每个 middleware 带着 middlewareAPI 这个参数分别执行一遍
const chain = middlewares.map(middleware => middleware(middlewareAPI))
// 接着 compose 将 chain 中的所有匿名函数,组装成一个新的函数,即新的 dispatch
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
我们可以看到applyMiddleware的源码非常简单,但却非常精彩,具体的解读可以看我的这篇文章:
redux源码解读
从上面的代码我们不难看出,applyMiddleware 这个函数的核心就在于在于组合 compose,通过将不同的 middlewares 一层一层包裹到原生的 dispatch 之上,然后对 middleware 的设计采用柯里化的方式,以便于compose ,从而可以动态产生 next 方法以及保持 store 的一致性。
说起来可能有点绕,直接来看一个啥都不干的中间件是如何实现的:
const doNothingMidddleware = (dispatch, getState) => next => action => next(action)
上面这个函数接受一个对象作为参数,对象的参数上有两个字段 dispatch 和 getState,分别代表着 Redux Store 上的两个同名函数,但需要注意的是并不是所有的中间件都会用到这两个函数。然后 doNothingMidddleware 返回的函数接受一个 next 类型的参数,这个 next 是一个函数,如果调用了它,就代表着这个中间件完成了自己的职能,并将对 action 控制权交予下一个中间件。但需要注意的是,这个函数还不是处理 action 对象的函数,它所返回的那个以 action 为参数的函数才是。最后以 action 为参数的函数对传入的 action 对象进行处理,在这个地方可以进行操作,比如:
在具有上面这些功能后,一个中间件就足够获取 Store 上的所有信息,也具有足够能力可用之数据的流转。看完上面这个最简单的中间件,下面我们来看一下 redux 中间件内,最出名的中间件 redux-thunk 的实现:
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
redux-thunk的代码很简单,它通过函数是变成的**来设计的,它让每个函数的功能都尽可能的小,然后通过函数的嵌套组合来实现复杂的功能,我上面写的那个最简单的中间件也是如此(当然,那是个瓜皮中间件)。redux-thunk 中间件的功能也很简单。首先检查参数 action 的类型,如果是函数的话,就执行这个 action 函数,并把 dispatch, getState, extraArgument 作为参数传递进去,否则就调用 next 让下一个中间件继续处理 action 。
需要注意的是,每个中间件最里层处理 action 参数的函数返回值都会影响 Store 上的 dispatch 函数的返回值,但每个中间件中这个函数返回值可能都不一样。就比如上面这个 react-thunk 中间件,返回的可能是一个 action 函数,也有可能返回的是下一个中间件返回的结果。因此,dispatch 函数调用的返回结果通常是不可控的,我们最好不要依赖于 dispatch 函数的返回值。
在多种中间件中,处理 redux 异步事件的中间件,绝对占有举足轻重的地位。从简单的 react-thunk 到 redux-promise 再到 redux-saga等等,都代表这各自解决redux异步流管理问题的方案
前面我们已经对redux-thunk进行了讨论,它通过多参数的 currying 以实现对函数的惰性求值,从而将同步的 action 转为异步的 action。在理解了redux-thunk后,我们在实现数据请求时,action就可以这么写了:
function getWeather(url, params) {
return (dispatch, getState) => {
fetch(url, params)
.then(result => {
dispatch({
type: 'GET_WEATHER_SUCCESS', payload: result,
});
})
.catch(err => {
dispatch({
type: 'GET_WEATHER_ERROR', error: err,
});
});
};
}
尽管redux-thunk很简单,而且也很实用,但人总是有追求的,都追求着使用更加优雅的方法来实现redux异步流的控制,这就有了redux-promise。
不同的中间件都有着自己的适用场景,react-thunk 比较适合于简单的API请求的场景,而 Promise 则更适合于输入输出操作,比较fetch函数返回的结果就是一个Promise对象,下面就让我们来看下最简单的 Promise 对象是怎么实现的:
import { isFSA } from 'flux-standard-action';
function isPromise(val) {
return val && typeof val.then === 'function';
}
export default function promiseMiddleware({ dispatch }) {
return next => action => {
if (!isFSA(action)) {
return isPromise(action)
? action.then(dispatch)
: next(action);
}
return isPromise(action.payload)
? action.payload.then(
result => dispatch({ ...action, payload: result }),
error => {
dispatch({ ...action, payload: error, error: true });
return Promise.reject(error);
}
)
: next(action);
};
}
它的逻辑也很简单主要是下面两部分:
结合 redux-promise 我们就可以利用 es7 的 async 和 await 语法,来简化异步操作了,比如这样:
const fetchData = (url, params) => fetch(url, params)
async function getWeather(url, params) {
const result = await fetchData(url, params)
if (result.error) {
return {
type: 'GET_WEATHER_ERROR', error: result.error,
}
}
return {
type: 'GET_WEATHER_SUCCESS', payload: result,
}
}
redux-saga是一个管理redux应用异步操作的中间件,用于代替 redux-thunk 的。它通过创建 Sagas 将所有异步操作逻辑存放在一个地方进行集中处理,以此将react中的同步操作与异步操作区分开来,以便于后期的管理与维护。对于Saga,我们可简单定义如下:
Saga = Worker + Watcher
redux-saga相当于在Redux原有数据流中多了一层,通过对Action进行监听,从而捕获到监听的Action,然后可以派生一个新的任务对state进行维护(这个看项目本身的需求),通过更改的state驱动View的变更。如下图所示:
saga特点:
function *getCurrCity(ip) {
const data = yield call('/api/getCurrCity.json', { ip })
yield put({
type: 'GET_CITY_SUCCESS', payload: data,
})
}
function * getWeather(cityId) {
const data = yield call('/api/getWeatherInfo.json', { cityId })
yield put({
type: 'GET_WEATHER_SUCCESS', payload: data,
})
}
function loadInitData(ip) {
yield getCurrCity(ip)
yield getWeather(getCityIdWithState(state))
yield put({
type: 'GET_DATA_SUCCESS',
})
}
总的来讲Redux Saga适用于对事件操作有细粒度需求的场景,同时它也提供了更好的可测试性,与可维护性,比较适合对异步处理要求高的大型项目,而小而简单的项目完全可以使用 redux-thunk 就足以满足自身需求了。毕竟 react-thunk 对于一个项目本身而言,毫无侵入,使用极其简单,只需引入这个中间件就行了。而 react-saga 则要求较高,难度较大,但胜在优雅(虽然我觉得asycn await的写法更优雅)。我现在也并没有掌握和实践这种异步流的管理方式,因此较为底层的东西先就不讨论了。
参考资料:
JavaScript中又两种源文件,这个区分从ES6开始:
其中脚本可以由浏览器或者node环境引入执行,而模块只能由JavaScript 代码用 import 引入执行。
因此从概念上来讲,我们可以认为脚本是具有主动性的 JavaScript 代码段,是控制宿主完成一定任务的代码;而模块则是被动性的代码段,等待被调用的库。
现代的浏览器都支持使用script标签引入模块或者脚本,如果要引入模块,就必须给script标签添加 type="module"
。
脚本中可以包含语句,而模块则是由三部分组成:
import声明有两种使用方式
直接import 一个模块,只能保证这个模块被执行,引用它的模块无法获得它的任何信息。
而 import from
则是引入模块中的一部分信息,可以将它们变成本地变量。他有三种用法:
import x from "./a.js" // 引入模块中导出的默认值。
import {a as x, modify} from "./a.js"; // 引入模块中的变量。
import * as x from "./a.js" // 把模块中所有的变量以类似对象属性的方式引入
// 第一种方式还可以和后两种组合使用
import d, {a as x, modify} from "./a.js"
import d, * as x from "./a.js"
需要注意的是使用没使用 as 的默认值永远在最前,且这里的as只是换一个名字而已,当变量被改变的时候,as所产生的值也会随着改变。
随着 react 最新的一个大版本中,给我们带来了 Hooks:React v16.8: The One With Hooks,从而将 Function component 的能力提高了一大截,成功的拥有了可以与 Class component 抗衡的能力。但话说回来,虽然 Hooks 看起来很美好,最近也有不少文章都讲解了Hooks这一“黑魔法”,但技术的不断演进,本身就是一个解决以往所存在问题的过程,因此我个人认为着眼于现在,回望过去,去看一看 react component 的发展之路,去看看 Class component 以及 Function component 为什么会出现以及它们出现的意义,所要解决的问题,也对于我们全面了解 react 是很有帮助的。
从 react component 的发展历程上来看,它主要是经历了一下三个阶段:
这个三个阶段也是react的组件不断走向轻量级的一个过程。其中 Class Component 完全替代了 createClass Component 成为了现在我们开发 react 组件的主流,而 Function Component 也在 Hooks 推出后磨刀霍霍,准备大干一场。下面就让我们去看看三者的具体情况吧~
注:这篇文章整体只是对React Component的发展历程的一个概括或者说是我自己学习后的一个整理,想要详细了解,还请看看我在文章贴的那些链接。
说实话,createClass Component 我也没用过,因为我接触到 react 的时候已经是2017年下半年了,那时候 ES6 已经大行其道,class component 也已经完全取代了 createClass Component。但现在看来 createClass Component 的语法也很简单,并不复杂:
import React from 'react'
const MyComponent = React.createClass({
// 通过proTypes对象和getDefaultProps()方法来设置和获取props
propTypes: {
name: React.PropTypes.string
},
getDefaultProps() {
return {
}
},
// 通过getInitialState()方法返回一个包含初始值的对象
getInitialState(){
return {
sayHello: 'Hello Srtian'
}
}
render() {
return (
<p></p>
)
}
})
export default MyComponent
react.createClass的语法并不复杂,它通过 createClass 来创建一个组件,并通过propTypes和getDefaultProps来获取props,通过通过getInitialState()方法返回一个包含初始值的对象,虽然从现在看来还是有点麻烦,但总体上来看代码也比较清晰,跟现在的 Class Component差别并不是太大。但 react.createClass 自从 react 15.5版本就不再为 react 官方所推介,而是想让大家的使用 class component 来代替它。而且在 react 16版本发布后,createClass 更是被废弃,当我们使用它的时候,会提示报错,也就是说,在 react 团队看来 createClass 已经完全没有存在的必要了。
其实 Class Component 完全替代 React.createClass 并不是说 React.createClass 有多坏,相反它还有一些 class Component 所没有的特性。它的废弃是由于ES6的出现,新增了 class 这一语法糖,让我们在 JavaScript 的开发中可以直接使用 extends 来扩展我们的对象,因此为了与标准的ES6接轨,原有的只在 react 中使用的 createClass 自然而然也成为了被抛弃的对象。但 class Component 在刚出现的时候也仍然存在的不小的争议,因为这两者还是存在一定的差别的,比如当时在Stack Overflow便出现了关于这两者的讨论,感兴趣的朋友可以去看看:
https://stackoverflow.com/questions/30668464/react-component-vs-react-createclass
总的来说,除了语法上存在差异外,Class Component 和 React.createClass 的区别主要是以下两点(详情可以看看上面的回答):
Class Component创建的方式也很简单,就是普通的ES6的class的语法,通过extends来创建一个新的对象来创建react组件,下面是使用class Component创建一个组件的例子(由于为了给后面聊一聊hooks,所以在这里我使用了antd的例子)
class Modal extends React.Component {
state = { visible: false }
showModal = () => {
this.setState({
visible: true,
});
}
handleOk = (e) => {
console.log(e);
this.setState({
visible: false,
});
}
handleCancel = (e) => {
console.log(e);
this.setState({
visible: false,
});
}
render() {
return (
<div>
<Button type="primary" onClick={this.showModal}>
Open Modal
</Button>
<Modal
title="Basic Modal"
visible={this.state.visible}
onOk={this.handleOk}
onCancel={this.handleCancel}
>
<p>this is a modal</p>
</Modal>
</div>
);
}
}
上面就是antd中一个简单的 modal 组件的例子,其内部就是通过维护 visible 的状态来控制这个 modal 是否显示。我们可以看到,其中的一些方法都是使用箭头函数的方式来将 this 绑定到正确的属性。(具体为什么要这么做,不清楚的朋友可以看看下面这篇文章:)
而类似于上面的这种组件,也是近两年来我们在日常开发中使用最多的组件开发的方式。那为什么到了现在,我们又开始要强调使用 Function Component 来进行开发了呢?主要是由于 Class Component 所开发的组件仍然存在以下一些问题:
<FirstContext.Consumer>
{first => (
<SecondContext.Consumer>
{second => (
<ThirdContext.Consumer>
{third => (
<Component />
)}
</ThirdContext.Consumer>
)}
</SecondContext.Consumer>
)}
</FirstContext.Consumer>
详细可以去看看知乎上的这个回答:https://www.zhihu.com/question/300049718
看到这里,大家对class Component所存在的一些问题也算是有一些了解了,但为什么它还能横行如此之久,一直占据着主流的地位呢?其本质上就是因为没有竞争对手嘛,Function Component 长期没有内部状态管理机制,只能通过外部来管理状态,因此组件的可测试性非常的高,写起来也简洁明了,符合现在前端函数式的大潮流,是个好同志。但也正是因为没有状态管理机制,所以无法和Class Component相抗衡,毕竟一旦组件内部的逻辑变得复杂之后,内部的状态管理机制是必须的。
因此 React 团队基于 Function Component 提出 Hooks 的概念,用以解决 Function Component 的内部状态管理,同时也希望通过 Hooks 来解决 Class Component 所存在的问题。下面就是使用 Hooks 针对 antd 中的 modal 进行的改写,大家可以自行感受一下:
const Modal = () => {
const [visible , changeVisible] = useState(false)
return (
<div>
<Button type="primary" onClick={()=>changeVisible(true)}>open</Button>
<Modal
title="Basic Modal"
visible={visible}
onOk={()=>changeVisible(false)}
onCancel={()=>changeVisible(false)}
>
<p>this is a modal</p>
</Modal>
</div>
)
}
我们可以看到,基于 Function Component 与 Hooks 所编写出来的组件代码是相当简洁明了的,也直接避免了我们上面所提到的 this 指向的问题。而对于上面所提到的嵌套地狱以及 Life-cycles 的问题,Hooks也提供了 useContext 和 useEffect(这个倒还是存在一些问题) 来解决,在这里我也不详细说了,详情可以去看官方文档或者是 Dan 的博客:
好了,看到这里我想大家都以为上面 Class Component 的问题都已经得到圆满解决了,Function Component好像已经圆满了,我们只管放心的使用它就好了。但世界上哪有这么好的事情,Function Component 仍然存在着下面几个 tip 是我们在使用前要知道的:
https://overreacted.io/how-are-function-components-different-from-classes/
参考资料:
JavaScript是一门基于对象的编程语言,其标准对基于对象的定义如下:
语言和宿主的基础设施由对象来提供,并且 JavaScript 程序即是一系列互相通讯的对象集合”
通过下图,也可以潜窥对象之于JavaScript的重要性:
对象其实并不是计算机领域凭空造出来的概念,而是对于人类思维模式的一种抽象,在《面向对象分析与设计》中从人类的认知角度出发,对象应该是以下事物的一种:
有了对对象的基本定义,语言的设计者们就可以通过相应的语言特性来对对象进行描述来,其最主要的实现方式有两种:
《面向对象分析与设计》一书中对于对象特征的描述主要有以下三点:
其中第一点,对象具有唯一标识性。一般来说,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识,JavaScript也不例外:
var o1 = { a: 1 };
var o2 = { a: 1 };
console.log(o1 == o2); // false
让对于对象的第二和第三哥特征,在不同语言中也会用不同的术语去描述他们,比如C++ 中的“成员变量”和“成员函数”,Java 中的属性”和“方法”。但JavaScript则和他们不同,它将状态和行为统一抽象为“属性”,这是由于在JavaScript中,函数其实也是作为一种特殊的对象存在,因此状态和行为都可以统一用属性来抽象。比如下面的代码,在javaScript中,d以及f都是属性,对于其对象o来说,并无太大区别。
var o = {
d: 1,
f() {
console.log(this.d)
}
}
其次在JavaScript中,对象具有高度的动态性,JavaScript赋予了使用者可以在运行时为对象动态的添加状态和行为的能力。而为了提高抽象能力,JavaScript属性又被设计为两大类,并用一组特征来描述属性:
数据属性
访问器属性
访问器属性使得属性在读和写时执行代码,它允许使用者在写和读属性时,得到完全不同的值。VUE2.0就是利用来对象的这一特性来实现双向绑定的。当我们想要改变属性的特征,或者定义访问器属性,我们可以使用 Object.defineProperty:
var o = { a: 1 };
Object.defineProperty(o, "b", {value: 2, writable: false, enumerable: false, configurable: true});
//a 和 b 都是数据属性,但特征值变化了
Object.getOwnPropertyDescriptor(o,"a"); // {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor(o,"b"); // {value: 2, writable: false, enumerable: false, configurable: true}
o.b = 3;
console.log(o.b); // 2
因此,我们可以将对象的运行时理解为一个“属性的集合”,属性以字符串或Symbol为key,以树枝属性特征值或访问器属性特征值为value。
因此由上我们不难看出,虽然JavaScript实现对象的方式与其他的传统的面向对象的编程语言Java等不一样,但其实本质上都满足来对象的基本特征,因此将JavaScript归类为面向对象的编程语言其实并不为过。
上面以及说明JavaScript是基于原型实现了自身的对象系统,那它和基于类所实现的对象系统有什么区别呢?
首先“基于类”,就是提倡使用一个关注和类之间关系开发模型,在这类实现中,总是先有类,再从类去实例化一个对象。而类与类之间又会形成继承,组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。
而基于“原型”则更提倡程序员去关注一系列对象实例的行为,然后再去关心如何将这些对象,划分到最近使用方式相似的原型对象,而不是将他们分成类。基于原型的面向对象的系统通过复制的方式来创建新的对象,从实现上来讲,原型系统的“复制操作”有两种实现思路:
很明显JavaScript是基于第一种方式实现复制操作的。
抛开JavaScript模拟Java的那些语法,原型系统可以用两句话来总结:
这个模型并无太大的区别,到了ES6提供了三个内置函数来直接访问和操作原型:
具体使用如下:
var cat = {
say(){
console.log("meow~");
},
jump(){
console.log("jump");
}
}
var tiger = Object.create(cat, {
say:{
writable:true,
configurable:true,
enumerable:true,
value:function(){
console.log("roar!");
}
}
})
var anotherCat = Object.create(cat);
anotherCat.say();
var anotherTiger = Object.create(tiger);
anotherTiger.say();
在早期版本(ES3以及更早的版本)中,类的定义是一个私有属性[[class]],语言标准为内置类型Number等都制定了[[class]]属性,而我们唯一能访问它的方式就是Object.prototype.toString,我们通常也是使用这个方式来判断数据类型。因此,在早期版本中,类是一个相当容的概念,仅仅是运行时的一个字符串属性。
在 ES5 开始,[[class]] 私有属性被 Symbol.toStringTag 代替,Object.prototype.toString 的意义从命名上不再跟 class 相关。我们甚至可以自定义 Object.prototype.toString 的行为,以下代码展示了使用 Symbol.toStringTag 来自定义 Object.prototype.toString 的行为:
var o = { [Symbol.toStringTag]: "MyObject" }
console.log(o + "");
对于new,我们不能说“new 运算是针对构造器对象,而不是类”,我们也要将其理解成JavaScript的面向对象的一部分。
new运算接受一个构造器和一组调用参数,实际上做了几件事:
new的出现,主要是试图让函数对象在语法上和类相似,它提供了两种方式来为对象添加属性:
function c1(){
this.p1 = 1;
this.p2 = function(){
console.log(this.p1);
}
}
var o1 = new c1;
o1.p2();
function c2(){
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
console.log(this.p1);
}
var o2 = new c2;
o2.p2();
在没有 Object.create、Object.setPrototypeOf 的早期版本中,new 运算是唯一一个可以指定 [[prototype]] 的方法。所以就有人使用这个方法来代替Object.create,比如这种Object.create的polyfill:
Object.create = function(prototype){
var cls = function(){}
cls.prototype = prototype
return new cls
}
这段代码就是创建了一个空函数作为类,并把传入的原型挂在他的prototype上,最后创建一个它的实例,这就产生例一个以传入的第一个参数作为原型的对象。
ES6中引入了class关键字,并在标准中删除了所有[[class]]相关的私有属性描述,类的概念正式从属性升级成为了语言的基础设施。
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}
JavaScript的对象大致可以分为以下几类:
JavaScript宿主对象会随着其运行环境的不同而不同,但前端最熟悉的当属浏览器环境中的宿主了。
在浏览器中,我们所熟悉的全局对象是window,而window上又有很多属性,如document。实际上,这个全局对象window上的属性,一部分来自javaScript语言本身,一部分来自浏览器环境。JavaScript标准规定了全局对象属性,w3c的各种标准中规定了window对象的其他属性。
宿主对象也分为固有的以及用户可创建两种,比如document.createElement就可以创建一些dom对象。宿主对象也会提供一些构造器,比如我们可以使用 new Image 来创建 img 元素。
固有对象是由标准规定的,随着JavaScript运行时创建而自动创建的对象实例。
固有对象在任何JS代码执行前就已经被创建出来了,他们通常扮演着基础库的角色。我们常说的类就是固有对象的一种,ECMA标准为我们提供类一份固有对象表(150+):
https://www.ecma-international.org/ecma-262/9.0/index.html#sec-well-known-intrinsic-objects
我们吧JavaScript中,能够通过语言本身的构造器创建的对象称为原生对象。在javaScript中,提供了30多个构造器:
这些构造器创建的对象多数使用了私有字段:
这些字段使得原型继承方法无法正常工作,所以,我们可以认为,所有这些原生对象都是为了特定能力或者性能,而设计出来的“特权对象”
在 JavaScript 中,还有一个看待对象的不同视角,这就是用对象来模拟函数和构造器。JavaScript 为这一类对象预留了私有字段机制,并规定了抽象的函数对象与构造器对象的概念。
函数对象的定义是:具有 [[call]] 私有字段的对象,构造器对象的定义是:具有私有字段 [[construct]] 的对象。
JavaScript 用对象模拟函数的设计代替了一般编程语言的函数,它们可以像其他语言的函数一样被调用,传参。任何宿主只要提供了[[call]]私有字段的对象,就可以被 JavaScript 函数调用语法支持。
[[call]] 私有字段必须是一个引擎中定义的函数,需要接受 this 值和调用参数,并且会产生域的切换,这些内容,我将会在属性访问和执行过程两个章节详细讲述。
我们可以说任何对象只要实现了[[call]],那么他就是一个函数对象,而如果实现了[[construct]],他就是一个构造器对象,可以作为构造器对象使用
对于这两个方法,我们也可以对其进行设置,来定制对象的具体调用方式的限制
对于宿主和内置对象来讲,,他们实现[[call]]和[[construct]]不总是一致的。比如内置对象 Date 在作为构造器调用时产生新的对象,作为函数调用是则产生字符串。
console.log(new Date); // 1
console.log(Date())
而浏览器宿主环境中,提供的 Image 构造七,则不允许作为函数调用。
需要注意的是,在 ES6 之后的箭头函数语法创建的函数仅仅只是函数,不能作为构造器使用。而对用使用 function 语法或者 Function 构造器创建的对象来讲,[[call]] 和 [[construct]] 的行为总是相似的。
我们可以大致认为,他们[[construct]]的执行过程如下:
这样的规则造成了个有趣的现象,如果我们的构造器返回了一个新的对象,那么 new 创建的新对象就变成了一个构造函数之外完全无法访问的对象,这一定程度上可以实现“私有”。
function cls(){
this.a = 100;
return {
getValue:() => this.a
}
}
var o = new cls;
o.getValue(); //100
//a 在外面永远无法访问到
一些特殊行为的对象:
B端产品由于其自身就具有一定的业务复杂度以及上手成本,因此在可用性方面,做好容错处理至关重要。一个好的容错处理既可以有效的提升用户使用产品的顺畅程度以及使用效率,也可以避免一些产品设计不够完善的问题的暴露。
下图是我在学习尼尔森十大可用性原则时所做的学习思维导图:
从上述的思维导图我们不难发现,尼尔森所强调的可用性原则里面,绝大部分其实都和容错处理息息相关。我个人理解,容错处理我们可以在一下几个方面进行设计:
接下来,我将从以上三个方面,来阐述如果进行合理的容错处理。
可能有些朋友会有疑惑:为何环境贴切原则会列到操作前的容错处理当中。但从我个人的经验以及理解来看:用户在进行一个操作时,所处的大环境会有效的有助于用户进行合理的自我下意识判断。首先我们要清楚的是,用户在进行操作的时候,大多数情况下,是下意思的行为,并不会在每个操作上都会去花费一定的心力成本去思考,这个操作进行后,会造成什么影响。因此一个熟悉的大环境或者是说熟悉的上下文环境,可以有利于用户不花费多余的心力成本就去完成一些操作。
这一点在AI平台就得到了很好的印证,早期AI平台由于产品规划的原因,只有英文版的。但实际上,我们的主要用户是公司内部的算法工程师,虽然这个群体普遍英文水平较高,但得到的反馈还是某些专业名词不够直观,不好理解。因此当时我就尝试去推动平台进行本地化,在原有的英文基础上,支持中文。在中文得到支持后,也得到了不少好的反馈,且从数据上来看,在支持中文以后,百分之90%以上的用户都使用中文版,且对于一些复杂表单的填写的错误率也下降了近三分之一。
除了上述的环境贴切原则,另一方面就是给予用户足够的人性化的帮助,主要是以下几个方面:
新手引导算是现在互联网中最常见的帮助方式了,非常有利于用户快速上手。而对于B端产品,主要的实现方式主要包括:
比如AI平台就给予了一个全面的帮助文档来让用户可以快速了解平台的基础功能,以及一些相关的专业名词的含义:
从数据录入的操作便捷程度来讲:单选 > 选择型下拉框(select) > 输入框。此外对于一些具有相应填写规则的表单,也需要给予一些提示,具体方式主要有以下几种:
placeholder
对输入内容进行提示对于一些比如删除等不可逆的操作时,我们也需要在用户进行操作前,给予有效的提醒,这样可以有效的减少用户的误点误删的事情发生:
此外,在表单填写时,也应该给予实时的提醒,让用户可以在填写时就知道自己的填写内容是错误的,从而及时的更正,而不是要等要点击提交按钮时才会抛出异常或者错误,这一块Ant Design的表单就做的很好:
这里的状态可见,从大的方面来讲,可以理解为一个任务的状态,在这个方面,大家都做的不错;但从小的方面来讲,状态可以是一次搜索,一次表单的填写等这样小的操作。比如搜索,如果没有搜索到对应的内容,在一些场景下就需要提醒:
以及我上面,在人性化帮助原则中第3点所提到的,表单的填写需要实时的提醒,其实也是状态可见原则的表现。
对于一些比较常发生的错误,我们也可以通过一些权限设置,或者是直接给予默认值等设置,来避免类似错误的发生。比如给表单设置默认项,不可操作的按钮进行置灰并给予相应的置灰解释:
这一项在AI 平台或者类似的一些平台所需要的,比如在AI平台发起一个编译任务,如果此项任务发生错误,就应该在展示错误状态外,还停供相关的错误信息,以方便用户快速进行定位,修复相关错误:
对于B端产品来说,用户经常也会出现误删资源的操作。因此如果在这些操作进行时,在进行提醒之外,应该还具有一定的回溯机制。比如在AI平台现在在Web端对一个资源进行删除,但其实只是暂时删除了一个映射关系,其底层的资源暂时是没有被删除的,仍然存储在数据库中,只有到了一定的时间,才会去清理相应的资源。这样做的好处就在于,虽然耗费了一定的空间,但大大的降低了数据损失的风险(这块除了这一处理方式,其实对于数据集这样的资源,本身就会对不太常用的数据集放入磁带,因此删除也只是解除映射关系之余将这个资源打入冷资源中)
除此之外,对于一些链路比较长的任务,我们也应该提供完善的回退机制,保证用户在当前阶段发生错误时,可以回退到上一步,而不至于整个任务全部失败,需要从头再来,比如以一个算法模型的半自动化的生产链路来讲:
这是一个很长的生产链路,所耗费的时也很长,因此在进行模型半自动化生产的过程中,如果因为其中一个任务出现了问题,而导致整个链路都需要重新开始,实在是太不友好了。因此需要将上述步骤提供完善的错误回退机制,比如预测任务发起失败,只需要提示用户预测任务发起失败,然后给予用户完善的错误信息提示,待用户解决问题后,直接重新发起预测任务即可。
类似的例子还有很多,比如PS,LR等操作软件,也提供了历史记录的功能,可以轻松回退的相应的地方
上述只是我个人在实践过程中的一些体会以及方法论,因为大家的产品特征不同,所要解决的问题,目标用户群体也不同。因此在具体的设计时,要根据自身的特点,去因地制宜的设计合理的容错处理机制。
安全问题的本质的信任的问题。
同源策略(Same Origin Policy)是一种约定,它是浏览器最核心也最基本的安全功能。浏览器的同源策略,限制了来自不同源的“document”或者脚本,对当前“document”读取或者设置某些属性。
影响源的因素有:
在浏览器中,<script>、、<iframe>、等标签都可以跨域加载资源,而不受同源策略的限制。这些带“src”属性的标签每次加载时,实际上是由浏览器发起了一次GET请求。不同于XMLHttpRequest的是,通过src属性加载的资源,浏览器限制了JavaScript的权限,使其不能读、写返回的内容。
这种在网页中插入一段恶意代码,利用浏览器漏洞执行任意代码的攻击方式,在黑客圈子里被形象地称为“挂马”。
在Windows系统中,浏览器密切结合DEP、ASLR、SafeSEH等操作系统提供的保护技术,对抗内存攻击。与此同时,浏览器还发展出了多进程架构,从安全性上有了很大的提高。浏览器的多进程架构,将浏览器的各个功能模块分开,各个浏览器实例分开,当一个进程崩溃时,也不会影响到其他的进程。
Google Chrome是第一个采取多进程架构的浏览器。Google Chrome的主要进程分为:浏览器进程、渲染进程、插件进程、扩展进程。插件进程如flash、java、pdf等与浏览器进程严格隔离,因此不会互相影响。[插图]Google Chrome的架构渲染引擎由Sandbox隔离,网页代码要与浏览器内核进程通信、与操作系统通信都需要通过IPC channel,在其中会进行一些安全检查。
Sandbox即沙箱,计算机技术发展到今天,Sandbox已经成为泛指“资源隔离类模块”的代名词。Sandbox的设计目的一般是为了让不可信任的代码运行在一定的环境中,限制不可信任的代码访问隔离区之外的资源。如果一定要跨越Sandbox边界产生数据交换,则只能通过指定的数据通道,比如经过封装的API来完成,在这些API中会严格检查请求的合法性。
目前各个浏览器的拦截恶意网址的功能都是基于“黑名单”的。
恶意网址拦截的工作原理很简单,一般都是浏览器周期性地从服务器端获取一份最新的恶意网址黑名单,如果用户上网时访问的网址存在于此黑名单中,浏览器就会弹出一个警告页面。
常见的恶意网址分为两类:一类是挂马网站,这些网站通常包含有恶意的脚本如JavaScript或Flash,通过利用浏览器的漏洞(包括一些插件、控件漏洞)执行shellcode,在用户电脑中植入木马;另一类是钓鱼网站,通过模仿知名网站的相似页面来欺骗用户。
跨站脚本攻击,英文全称是Cross Site Script,本来缩写是CSS,但是为了和层叠样式表(Cascading Style Sheet, CSS)有所区别,所以在安全领域叫做“XSS”。
XSS攻击,通常指黑客通过“HTML注入”篡改了网页,插入了恶意的脚本,从而在用户浏览网页时,控制用户浏览器的一种攻击。由于XSS破坏力强大,且产生场景复杂,业内达成的共识是:针对各种不同场景产生的XSS,需要区分情景对待。
反射型XSS只是简单地把用户输入的数据“反射”给浏览器。也就是说,黑客往往需要诱使用户“点击”一个恶意链接,才能攻击成功。反射型XSS也叫做“非持久型XSS”(Non-persistent XSS)。
存储型XSS会把用户输入的数据“存储”在服务器端。这种XSS具有很强的稳定性。比如:黑客在一篇 blog 中,写下了恶意代码,就可能会造成所有看到这篇 blog 的人都收到攻击
实际上也是反射形 XSS,但由于形成原因比较特别,因此单独划分,即:通过修改页面的 DOM 节点形成的 XSS,即称为 DOM Based XSS
XSS攻击成功后,攻击者能够对用户当前浏览的页面植入恶意脚本,通过恶意脚本,控制用户的浏览器。这些用以完成各种具体功能的恶意脚本,被称为“XSS Payload”。XSS Payload实际上就是JavaScript脚本(还可以是Flash或其他富客户端的脚本),所以任何JavaScript脚本能实现的功能,XSSPayload都能做到。
当我们需要使用Node.js进行爬虫爬取网页时,我们通常需要下载两个库request和cheerio来帮助我们队网页进行爬取:
cnpm i request cheerio
其中request帮助我们对网页进行加载,而cheerio则是为服务器特别定制的,快速、灵活、实施的jQuery核心实现。有了这两个库,爬取简单的网页就没有太大的问题了。
本次我的目标是爬取豆瓣电影Top250的第一页,因此打开浏览器对其页面结构进行了一番分析,就比如第一步电影——《肖申克的救赎》:
通过上面的页面结构,我们不难看出,每一部电影都是一个li,且li下面的class都是item,因此当我们需要爬取一部电影的数据时,可以先取得item,再对内部的数据进行获取。其次,每部电影的数据的class命名很明确,因此当我们获取数据时,直接可以根据页面的class命名进行数据获取。而根据对页面的分析,我打算爬取的数据如下:
既然确定所要获取的数据,我们就可以着手写代码了,首先我们需要引入上面我们下载好的两个包:
const request = require('request')
const cheerio = require('cheerio')
然后我们要创造一个类,用以保存我们想要获取的数据:
const Movie = function() {
this.name = ''
this.score = 0
this.quote = ''
this.ranking = 0
this.coverUrl = ''
}
然后我们就能根据我们在上面所创造的类以及利用cheerio来定义一个函数,来通过传入的元素对数据进行获取:
const getMovieFromDiv = (div) => {
const movie = new Movie()
const load = cheerio.load(div)
const pic = load('.pic')
movie.name = load('.title').text()
movie.score = load('.rating_num').text()
movie.quote = load('.inq').text()
movie.ranking = pic.find('em').text()
movie.coverUrl = pic.find('img').attr('src')
return movie
}
将数据获取到了后,当然就需要将其保存了,我们可以调用Node.js的fs模块来对数据进行保存。在这里我们同样也可以定义一个函数,用以保存数据,鉴于在前端界数据通常是JSON,因此在这里就将数据保存为JSON格式:
const saveMovie = (movies) => {
const fs = require('fs')
const path = 'DouBanTop25.json'
const s = JSON.stringify(movies, null, 2)
fs.writeFile(path, s, (error) => {
if (error === null) {
console.log('保存成功')
} else {
console.log('保存文件错误', error)
}
})
}
好了,上面两步主要为了处理数据以及保存数据。下面就是主要部分了,我们需要下载页面,并执行上面两个函数,已达到爬取网页数据并保存的目的:
const getMoviesFromUrl = (url) => {
request(url, (error, response, body) => {
if (error === null && response.statusCode == 200) {
const load = cheerio.load(body)
const movieDiv = load('.item')
const movies = []
for(let i = 0; i < movieDiv.length; i++) {
let element = movieDiv[i]
const div = load(element).html()
const movie = getMovieFromDiv(div)
movies.push(movie)
}
saveMovie(movies)
} else {
console.log('请求失败', error)
}
})
}
在上面,当我们下载好页面后,先利用cheerio.load解析页面,然后我们创建一个数组,用于保存电影的数据。再然后通for循环遍历页面的item,并通过getMovieFromDiv来对每个item内的数据进行获取,然后push到数组内。在循环结束后,使用saveMovie将存有数据的数组进行保存。
基本的爬取数据的代码完成了,现在让我们来启动这些函数进行页面爬取吧!
const getMovie = () =>{
const url = 'https://movie.douban.com/top250'
getMoviesFromUrl(url)
}
getMovie()
最后:
node doubantop25.js
Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用XMLHttpRequest执行 I/O (尽管responseXML和通道属性总是为空)。一旦创建, 一个worker 可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序 (反之亦然)。 —— MDN
众所周知,JavaScript是单线程的编程语言,也就是说,当我们在页面中进行一个较为耗时的计算的JavaScript代码时,在这段代码执行完毕之前,页面是无法响应用户操作的。也正是出于这个原因,HTML5为我们提供了 Web Workers 以解决这种问题,当我们需要在JavaScript中来进行耗时的计算或诸如此类的问题时,我们可以使用 Web Workers 在浏览器的后台启动一个独立的 Worker 线程来专门负责这段代码的运行,而不会阻碍后面代码的运行。
实例化运行一个 Worker 很简单,我们只需要 new 一个 Worker 全局对象即可。
var worker = new Worker('./worker.js')
它接受一个 filepathname String 参数,用于指定 Worker 脚本文件的路径。然后我们就可以在 worker.js 中写下一些代码:
console.log('my_WOEKER:', 'srtian')
另外,通过URL.createObjectURL()创建URL对象,也可以实现创建内嵌的worker:
var myTask = `
var i = 0;
var timedCount = () => {
i = i+1;
postMessage(i);
setTimeout(timedCount, 1000);
}
timedCount();
`;
var myblob = new Blob([myTask]);
var myWorker = new Worker(window.URL.createObjectURL(myblob));
需要注意的是,传入 Worker 构造函数的参数 URI 必须遵循同源策略。
此外因为Worker线程的创建的是异步的,所以主线程代码不会阻塞在这里等待 worker 线程去加载、执行指定的脚本文件,而是会立即向下继续执行后面代码这点也需要注意。
当我们实例化一个 Worker 线程后,Worker不会相互,或者与主程序共享任何作用域或资源——那会将所有的多线程编程的噩梦带到我们面前——取而代之的是一种连接它们的基本事件消息机制。因此他们需要通过基于事件监听机制的message来进行通信,我们在new Worker()后悔返回一个实例对象,它包含了一个postMessage的方法,我们可以通过调用这个方法来给worker线程传递信息,我们也可以给这个对象监听事件,从而在worker线程中出发事件通信的时候能接收到数据。
var worker = new worker('./worker.js')
worker.addEventListener('message', function(e) {
console.log('worker receive:', e.data )
}
worker.postMessage('hello worker,this is main.js')
然后在worker.js这个脚本中,我们就可以调用全局函数postMessage和全局的onmessage赋值来发送和监听数据和事件了。
// 监听事件
onmessage = function (e) {
console.log('WORKER RECEIVE:', e.data);
// 发送数据事件
postMessage('Hello, this is worker.js');
}
需要注意的是 worker 支持 JavaScript 中所有类型的数据传递,可以传递一个 Object 数据;但这里的数据传递(主要是 Object 类型)并不是共享,而是复制。发送端的数据和接收端的数据是复制而来,并不指向同一个对象,此外这里的复制不是简单的拷贝,而是通过两端的序列化/解序列化来实现的,一般来说浏览器会通过 JSON 编码/解码;当然,这里的更多细节部分会由浏览器来处理,我们并不需要关心这些,只需要明白两端的数据是复制而来,互相独立的就行了。
当 worker 出现运行中错误时,它的 onerror 事件处理函数会被调用。它会收到一个扩展了 ErrorEvent 接口的名为 error的事件。
该事件不会冒泡并且可以被取消;为了防止触发默认动作,worker 可以调用错误事件的 preventDefault() 方法。
错误事件有以下三个用户关心的字段:
实际操作如下:
var worker = new Worker('./worker.js');
// 监听消息事件
worker.addEventListener('message', function (e) {
console.log('MAIN RECEIVE: ', e.data);
});
// 也可以使用 onMessage 来监听事件:
// 监听 error 事件
worker.addEventListener('error', function (e) {
console.log('MAIN ERROR:', e);
console.log('MAIN ERROR:', 'filename:' + e.filename + '---message:' + e.message + '---lineno:' + e.lineno);
});
// 触发事件,传递信息给 Worker
worker.postMessage({
m: 'Hello Worker, this is main.js'
});
当我们在不需要 Worker 继续运行时,我就需要终止掉这个线程,这时候我们就可以调用 worker 的 terminate 方法:
worker.terminate()
worker 线程会被立即杀死,不会有任何机会让它完成自己的操作或清理工作。
而在worker线程中,workers 也可以调用自己的 close 方法进行关闭:
close()
由于Web Workers是HTML5所提供的,因此从兼容性上来说,还是需要注意的。总的兼容情况如下图所示:
图片来源:https://caniuse.com/#feat=webworkers
我们可以看到,虽然web worker很不错,但如果我们的代码执行在较老的浏览器中时,是缺乏支持的。但由于worker是一个API而不是语法,因此我门还是可以去填补它的。
这一块的详情可以去看——《你不知道的JavaScript中卷》关于 web worker 的那一节。
由于在 Worker 线程的运行环境中没有 window 全局对象,也无法访问 DOM 对象,所以一般来说我们在这只能执行纯JavaScript的计算操作,当然1我们那:
总的来说,Web Worker为我们带来了强大的计算能力,我们可以加载一个JavaScript进行大量的复杂计算,而用不挂起主进程。并通过postMessage,onmessage进行通信,这也解决了大量计算对UI渲染的阻塞问题。
Web Worker最简单的应用应该就是用来进行后台计算了,这对CPU密集型的场景再适合不过了。
通过使用从 canvas 中获取的数据,可以把图像分割成几个不同的区域并且把它们推送给并行的不同Workers来做计算,对图像进行像素级的处理,再把处理完成的图像数据返回给主页面。
目前mvvm框架越来越普及,基于数据驱动的开发模式也越愈发流行,未来大数据的处理也可能转向到前台,因此我们将大数据的处理交给在Web Worker也是很好的。
为优化的网站或 web 应用的数据加载时长,我们可以使用 Web Worker 预先获取一些数据,存储起来以备后续使用,因为它绝不会影响应用的 UI 体验。
由于在主线程中每启动一个XMLHttpRequest请求都会消耗资源,虽然在请求过程中浏览器另外开了一个线程,但是在交互过程中还是需要消耗主线程资源;而使用worker则不会过多占用主线程,只是启动worker过程时比较耗资源。
参考资料:
这篇文章主要是讨论一下 Serverless 的发展历程以及在前端的一些落地场景,以及我在看伯克利的这篇论文:《Cloud Programming Simplified: A Berkeley View onServerless Computing》 的一些记录。具体想要详细的了解 Serverless 也建议直接去阅读这篇论文,相信会有不少收获的。
讨论一个技术的具体使用场景,首先需要将其做一个定义,确定我们说要讨论的范围。确定讨论的范围,归根结底,首先需要了解 Serverless 所产生的背景以及其具体以什么方式解决了什么样的问题。
在云计算已经普及的今天, Serverless 已经是一个在各大技术论坛或者演讲上,出现频率非常高的技术名词了,在前端也不例外,几乎每个大的前端技术演讲会议,都会或多或少的提到它,并扯扯它和前端的一些交集或者结合的落地场景。
但溯本回源,Serverless 的产生,归根结底是要解决什么问题呢?要回答这个问题,我们不得不去回顾云计算的发展历程。在云计算刚刚兴起的时候,市场上主流的两种云服务方案分别是亚马逊推出的 EC2 和谷歌推出的 App Engine (GAE)。这两种方案分别代表了两种思路:
最终市场选择了AWS的 EC2,这主要是因为开发者们更倾向于使用和自己本地开发环境相同的环境来运行服务,这样做,开发好的代码基本不需要什么改动就可以轻易部署到云实例上去。但这种模式虽然极大的给予了开发者自由度,但也意味着几乎所有的运维操作都交由开发者自己去解决,**因此大部分的云服务的使用者在使用云服务的同时,不得不承担复杂的运维成本以及较低的硬件使用率。**Serverless的产生就是为了解决这些问题而产生的。
而回顾Serverless 这一名词的诞生,需要我们将时间线拉回到2012年,这是 Serverless 这一技术名词第一次出现在大家视野中的时间点。Ken 在他的文章:Why The Future Of Software And Apps Is Serverless 中提出了 Serverless 之一名词,开始让 Serverless 进入大家的视野。引述这篇文章的一段话,算是对 Serverless 早期定义的解释:
The phrase “serverless” doesn’t mean servers are no longer involved. It simply means that developers no longer have to think that much about them. Computing resources get used as services without having to manage around physical capacities or limits.
总的来说,这个时间点的 Serverless,更多的是关于对于计算机底层运维方面的抽象的讨论,也算是去思考如何解决 复杂的运维成本 这一问题。
而真正让 Serverless 名声大噪的是 Amazon 在 2015 年发布的 AWS Lambda ,提出了 **Cloud Function **的概念,让 Serverless 提高的一个全新的高度,它不仅仅通过抽象底层运维能力,来为云开发者提供运维能力的支持以及抽象,并且提供快速缩扩容以及按调用收费的机制,提升了资源利用率,降低使用者的成本。这也是第一个真正意义上我们今天所说的 Faas 平台,正是从那一年开始, Serverless 开始成为国际上炙手可热的名词,出现在各大云计算的会议之上。
而到了2017年,国内的 Paas 以及 laas 平台,也推出了自己的函数计算平台,加入到了 Serverless 的推广以及建设当中。
明确了Serverless的产生背景,当今社区上对Serverless的定义其实还是比较模糊的,但总的来讲,翻阅一些较为权威的资料,大体上还是较为相同的,譬如号称 Serverless 白皮书的:《Cloud Programming Simplified: A Berkeley View onServerless Computing》中关于 Serverless 的定义是:
Put simply, serverless computing = FaaS + BaaS,In our definition, for a service to be considered serverless, it must scaleautomatically with no need for explicit provisioning, and be billed based on usage.
提取几个关键字:serverless = FaaS + Baas,且必须能够实现自动缩扩容和按使用量计费。另外在《Serverless Architectures》,也是将 Serverless 视为 FaaS 和 BaaS 的结合:
因此在这里,我们在此讨论的 Serverless 也就按照 Serverless = FaaS + BaaS 的定义了(但其实我个人更倾向于将Serverless视为一种降低开发门槛,提升开发效率的架构模式) :
其中 FaaS(Functions as a Service)直译过来就是:函数即服务。FaaS 是无服务器计算的一种形式,当前使用最广泛的是 AWS 的 Lambada 函数计算平台。FaaS 本质上是一种事件驱动的由消息触发的服务,FaaS 供应商一般会集成各种同步和异步的事件源,通过订阅这些事件源,可以突发或者定期的触发函数运行。
而这里的函数,则是提供了比微服务更微细小的程序单元。比如,我们可以将微服务按照某个用户特定的一系列CRUD操作进行拆分。而在FaaS下,用户的每个操作,比如创建这一操作,就对应着我们在函数计算平台上的一个函数,只要通过触发器触发它,就可以执行操作事件。下面这个图就很形象的体现函数计算的特点:
(图来自:https://developer.aliyun.com/article/574222)
而 BaaS(Backend-as-a-Service)后端即服务,它是基于 API 的第三方服务,用于实现应用程序中的后端功能核心功能,包含常用的数据库、对象存储、消息队列、日志服务等等。
下面这个表格列举了一下传统的Serverful(也就是云计算)和 Serverless 的区别:
特性 |
AWS Serverless 云计算 | AWS Serverful 云计算 | |
---|---|---|---|
开发者 | 何时运行程序 | 由用户根据事件自行选择 | 除非明确停止,否则会一直运行。 |
编程语言 | JavaScript、Python、Java、Go等有限的语言 | 任何语言 | |
程序状态 | 保存在存储(无状态) | 任何地方(有状态或无状态) | |
最大内存大小 | 0.125~3GiB(用户自行选择) | 0.5~1952GiB(用户自行选择) | |
最大本地存储 | 0.5GiB | 0~3600 GiB (用户自行选择) | |
最长运行时间 | 900秒 | 随意 | |
最小计费单元 | 0.1秒 | 60秒 | |
每计费单元价格 | $0.0000002 | $0.0000867 - $0.4080000 | |
操作系统和库 | 云供应商选择 | 用户自行选择 | |
系统管理员 | 服务器实例 | 云供应商选择 | 用户自行选择 |
扩展 | 云供应商负责提供 | 用户自己负责 | |
部署 | 云供应商负责提供 | 用户自己负责 | |
容错 | 云供应商负责提供 | 用户自己负责 | |
监控 | 云供应商负责提供 | 用户自己负责 | |
日志 | 云供应商负责提供 | 用户自己负责 |
转自:https://www2.eecs.berkeley.edu/Pubs/TechRpts/2019/EECS-2019-3.pdf
总的来说,Serverless 相较于 serverful,有以下三个方面的巨大改变:
从实用角度出发,那Serverless从前端开发工程师的角度来讲,可以让我们更专注于业务开发,一些常见的服务端问题,我们都可以交给 Serverless 来解决,比如:
下面是伯克利的论文中,所列举出来的关于2018年 Serverless 的具体使用场景的分布:
按照上图中,Serverless使用场景中,占比最高的就是Web和API 服务,这方面比较典型的开发场景就是 小程序的云开发了。
在传统的小程序开发流程中,我们需要前端工程师对小程序端进行开发,而后端工程师进行服务端的开发。如果开发的团队规模较小,可能还需要前端工程师去将服务端的开发也完成了,但由于小程序的后端开发其实和其他的后端应用本质上是一样的,需要关心应用的负载均衡、容灾、监控等一些运维操作,但这些知识又触及到了大部分前端工程师的知识盲点,往往需要很多时间去了解和学习,完成的产品也往往不尽人意。
而在基于 Serverless 的小程序云开发的模式下,就可以做到让开发者只关心业务需求的实现,由一个前端工程师参与开发,在不具备完善的运维知识的情况下,使用云开发平台将后端功能封装而成的 BaaS 就可以完成整个应用的开发。以下是微信小程序云开发所提供的几项基础能力支持:
能力 | 作用 | 说明 |
---|---|---|
云函数 | 无需自建服务器 | 在云端运行的代码,微信私有协议天然鉴权,开发者只需编写自身业务逻辑代码 |
数据库 | 无需自建数据库 | 一个既可在小程序前端操作,也能在云函数中读写的 JSON 数据库 |
存储 | 无需自建存储和 CDN | 在小程序前端直接上传/下载云端文件,在云开发控制台可视化管理 |
云调用 | 原生微信服务集成 | 基于云函数免鉴权使用小程序开放接口的能力,包括服务端调用、获取开放数据等能力 |
微信支付 | 免鉴权原生使用微信支付 | 免签名计算、免 access_token 使用微信支付能力 |
(详情可看:微信官方文档-小程序-云开发)
具体实践案例:miniprogram-foodmap
BFF对于大多数的前端工程师已经不再陌生了,它的产生主要是基于:对不同的设备可能需要使用不同的后端 API,也可能对数据格式以及数据量有不同的要求。因此BFF所做的工作通常就是将后端的数据以及接口进行编排,适配成前端所需要的数据格式,提供给前端进行使用。具体的模型如下:
不过虽然这种模式虽然解决了接口协调的问题,但也带来了一些新的问题:
而 Serverless 则可以帮我们很好的解决这些问题。由于 BFF 只是做无状态的数据编排,因此它天然就是适合 FaaS 这种按需分配,弹性扩容,用完即毁的模型进行替换。我们可以使用一个个函数来实现对各个接口的聚合或者裁剪,前端向 BFF 发起请求,就相当于是 FaaS 的一个 HTTP 触发器,触发一个函数的执行,由这个函数来针对具体的业务逻辑来发起请求获取数据,再对数据进行聚合以及裁剪,最后将数据返回给前端。
这样做的好处就是一方面可以节省资源,降低成本,不用去一直维持Node服务的虚拟机的开销,另一方面,将运维的压力也从BFF转移到来FaaS 服务,前端无需关心BFF的维护以及并发等场景。此外,我们还可以在FaaS平台中去充分利用云服务提供商所提供的其他功能,从而实现服务编排,增强我们在SFF层的能力。
虽然现在 Serverless 这一概念已经被市场上的相关利益者吹的很火,仿佛已经马上就可以对传统的开发方式进行革命,但作为普通的开发者,我们需要保持相对理性,才能更为客观的去了解一门技术。
首先,Serverless 是真的已经火热到家喻户晓,成为一个大家都应该去了解的技术,去拥抱的开发模式了么?
下面是 Google Trends 对于三个名词的搜索热度的排名,可以看到与前端强相关的两个技术名词 graphql 和 BFF 都比 Serverless 的搜索热度高出不少。
其次,作为一个出自美国的技术,同时AWS 的 Lambda 无论在技术成熟度已经服务水平都比国内走在前面的情况下,按 Google Trends 区域搜索热度划分,也很有意思:其中**以100遥遥领先于其他国家,第二名的新加坡才17的热度。
因此,至少从这个数据来看,Serverless 在国内的关注度远高于国外的(当然这也与国内的实际项目发展需求有关,serverless天然的具有一定的落定场景)
那 Serverless 在经过这几年的发展,为什么没有真正的火起来呢,首先就是这项技术本身所存在的一些问题,下列是伯克利的论文中列举出的 Serverless 现今仍然存在的四个不足,或者说是阻碍它快速发展的因素:
而正式由于存在以上一些原因,导致复杂的企业级的业务系统无法基于现在这么简单的Faas来实现,只有一些业务场景较为简单的应用才是它现在的落地场景,而一个架构**想要成为主流,得到快速的发展,必须要应用在企业主要流程的业务系统之中,只有这样,才能体现它在企业中所带来的巨大价值与收益。因此如何结合Serverless 的**,落地于企业的核心业务场景中去,展现其真正价值,为企业带来降本增效的收益,并沉淀出强大的 Serverless 开发框架以及最佳实践,才能将 Serverless 推向云时代的主流架构这一宝座。
这里直接引用伯克利对与 Serverless 计算在未来十年的发展展望以及趋势的预测:
其中对我个人印象最为深刻的是最后一条:
Serverless computing will become the default computing paradigm of the Cloud Era, largely replacing serverful computing and thereby bringing closure to the Client-Server Era.
简单来说,他们认为 Serverless Computing 将会成为云时代的默认范例大面积的替换传统的云计算,并革命掉客户端-服务器端的时代。我对这个展望个人是比较认同的,因为从宏观角度来看,技术的发展必定是一个不断降低门槛的过程,抽象底层逻辑,提升开发效率的过程。而 Serverless 架构的核心**,按照 AWS 的 CTO 的说法, Serverless 作为一个架构模式,要做的到的是:
"Everyone wants just to focus on business logic."
而这也真符合商业发展对降本增效的诉求,因此从这个角度出发,Serverless架构的落地以及推广,未来可期。
参考链接
前端自动化测试所带来的收益:
前端现今主流测试框架:
Jest前端测试框架的优点:
Jest的下载非常简单,只需要在本地由Node.js的运行环境,然后就可以使用npm包来对Jest进行下载来:
> mkdir learnJest
> npm init
> npm install jest@24.8.0 -D
这里的-D就是保存到dev里边,而在线上就不使用它。
然后我们就可以在我们的基础目录下面新建一个叫做 demo.js
的文件以及一个叫做 demo.test.js的文件,然后敲些代码:
// demo.js
function add(a, b){
return a + b
}
function menus(a, b){
return a - b
}
module.exports = {
add,
menus
}
// demo.test.js
const demo = require("./demo.js");
const { add, menus } = demo;
test("测试add", () => {
expect(add(2, 5)).toBe(7);
});
test("测试add", () => {
expect(menus(3, 2)).toBe(1);
});
至此,我们就将我们的代码以及测试用例给完成了。没有使用过Jest的同学可能到这里就会要问,上面这种语法所代表的含义是什么呀?别慌,其实 expect
和 test
的内部逻辑大致是这样的:
const expect = result => {
return {
toBe: function(actual) {
if (result !== actual) {
throw new Error(`没有通过测试, 预期${actual}, 结果为${result}`)
}
}
}
}
const test = (desc, fn) => {
try {
fn()
console.log(`${desc} 通过测试`)
} catch(e) {
console.error(`${desc}没有通过测试, ${e}`)
}
}
接下来,我们就可以进行测试了。那么我们如何进行测试呢,其实也很简单,我们只需要将package.json 文件。将里面的 scripts
标签的值改一下就行了:
{
"name": "jesttest",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^24.8.0"
}
}
然后我们在终端中执行 npm run test
即可:
> npm run test
> [email protected] test /Users/ruotian.shen/My-Study/jestStydy
> jest
PASS src/demo.test.js
✓ 测试add (2ms)
✓ 测试add
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.468s
Ran all test suites.
我们可以使用来生成默认的 jest
文件:
> npx jest --init
然后需要回答三个问题,最后就可以生成一个叫做 jest.config.js
的配置文件。这个配置文件里面就可以去配置一些关于 Jest 的相关东西。其中有一项 coverageDirectory 的东西,就和我们经常听到的代码测试覆盖率有关。
所谓的代码测试覆盖率,就是我们的测试代码,对功能性代码和业务逻辑代码作了百分多少的测试,这个百分比,就叫做代码测试覆盖率。如果我们开启了这一项:
coverageDirectory : "coverage"
我们就可以生成对应的覆盖率:
> npx jest --coverage
PASS src/demo.test.js
✓ 测试add (2ms)
✓ 测试add (1ms)
----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files | 100 | 100 | 100 | 100 | |
demo.js | 100 | 100 | 100 | 100 | |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.715s
Ran all test suites.
除了生成一个终端的报表。其中:
% Stmts
表示的是语句覆盖率,即语句的测试覆盖范围% Branch
表示的是分支覆盖率,即if代码块测试覆盖范围% Funcs
表示的是函数覆盖率,即函数的测试覆盖范围% Lines
表示的是行覆盖率,即代码行数的测试覆盖范围这条命令还能会生成一个叫做 coverage
的文件夹,里面有个 index.html
的文文件,这里就有一个花里胡哨的页面,也可以展示对应的代码覆盖率:
此外,Jest默认支持的是 CommonJS
的语法,目前还不支持 import form
的语法,使用的时候会报错。如果我们想要使用ES6的语法,就需要使用babel来代码转成 CommonJS
代码。
npm install @babel/[email protected] @babel/[email protected] -D
然后我们新建一个 babelrc
的文件:
{
"presets":[
[
"@babel/preset-env",{
"targets":{
"node":"current"
}
}
]
]
}
这样我们就可以愉快的使用使用ES6的语法来用Jest了。
Jest匹配器在Jest中,可以说是最重要的功能之一,前面我们说的 toBe()
就是匹配器的一种,Jest 使用“匹配器”让你使用不同方式测试数值。
toBe()适配器,可以说是Jest中最常用的适配器,我们可以简单的将其理解为严格相等也就是我们常用的 ===
。
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
toBe和toEqual的区别就在与,toBe是严格相等的判断,而 toEqual 则是只要内容相等即可,比如这样:
test('测试严格相等',()=>{
const a = {name:'srtian'}
expect(a).toBe({name:'srtian'})
})
test('测试内容相等',()=>{
const a = {name:'srtian'}
expect(a).toEqual({name:'srtian'})
})
然后我们执行一下test,就可以发现这两个API之间的区别:
有时候,我们需要来判断匹配null、undefined、false。Jest也提供了相对应的 API 来进行匹配:
toBeNull
只匹配 null
toBeUndefined
只匹配 undefined
toBeDefined
是 toBeUndefined
反义词toBeTruthy
匹配任何 if
语句当作真值的表达式toBeFalsy
匹配任何 if
语句当作假值的表达式test('null', () => {
const n = null
expect(n).toBeNull()
expect(n).toBeDefined()
expect(n).not.toBeUndefined()
expect(n).not.toBeTruthy()
expect(n).toBeFalsy()
})
test('zero', () => {
const z = 0
expect(z).not.toBeNull()
expect(z).toBeDefined()
expect(z).not.toBeUndefined()
expect(z).not.toBeTruthy()
expect(z).toBeFalsy()
})
Jest针对 number
也提供了相关的api来进行匹配:
JavaScript
浮点精度错误的匹配器前面几个匹配器,看名字就非常直观,而最后一个匹配器 toBeCloseTo
是一个可以自动清除浮动 JavaScript
浮点精度错误的匹配器
test('toEqual匹配器',()=>{
const a = 0.1
const b = 0.2
expect(a + b).toEqual(0.3)
}) // 不会通过测试用例
test('toBeCloseTo匹配器',()=>{
const c = 0.1
const d = 0.2
expect(c + d).toBeCloseTo(0.3)
}) // 可以通过测试用例
当我们使用Jest来测试异步代码时,Jest需要知道当前测试的代码是否已经完成,若已经完成,它就可以转移到另一个测试。
test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
如果fetchData 不使用回调函数,而是返回一个Promise,我们可以这样测试它:
test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
需要注意的是,我们要将 Promise
作为 return
的值。如果不这样做的话,在 fetchData
返回的这个 Promise
被 resolve
和 then
执行结束之前,测试就已经被视为完成了。
如果我们向匹配 Promise
的状态为 rejected
,我们可以使用 catch
方法来捕获对应的错误信息。需要注意的是 要确保使用 expect.assertions
来验证一定数量的断言被调用。否则一个 fulfilled 状态的 Promise 不会让测试失败:
test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});
我们也可以使用 expect 语句中使用 resolves
匹配器, Jest
将等待此 Promise
解决。如果承诺被拒绝,则测试将自动失败:
// 匹配成功
test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
// 匹配失败
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});
既然可以使用Promise 来进行测试,那么使用async也同理可以了,并且写起来也很直观简单:
test('the data is peanut butter', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
});
也可以这样:
test('the data is peanut butter', async () => {
await expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
await expect(fetchData()).rejects.toThrow('error');
});
Jest中也提供了4个钩子函数,来帮助我们在执行测试期间去进行一些操作:
具体例子:
import { add, menus } from "./demo";
beforeAll(() => {
console.log("这个会最先执行");
});
beforeEach(() => {
console.log("这个会执行两次");
});
test("test add", () => {
expect(add(1, 2)).toBe(3);
});
test("test menus", () => {
expect(menus(3, 1)).toBe(2);
});
afterEach(() => {
console.log("这个也会执行两次");
});
afterAll(() => {
console.log("这个会最后执行");
});
这半年来,由于某些原因,我在开发之余,也承担了平台前端产品需求的挖掘,设计和梳理的工作,也做成了诸如公开数据集模块,模型自动化等需求的前端交互设计或者是总体的业务流程设计,有些还挺不错,受到了一些好评。做着做着就由感而发,也对B端的产品设计有了一些自己的思考,因此总结一下这半年来,在这方面的体会以及心路历程。
相较于传统的B端产品,AI平台最大的特点就是不确定,这个不确定表现在很多方面。譬如:
这些不确定性,都让我们在产品的探索过程中,不得不去不断试错,从错误和失败中吸取教训,并逐步完善自己对于产品设计的方法论。比如:
因此,总的来讲,对于像AI平台,这种不能充分吸取其他产品的设计经验、充满着不确定性的产品,首先要做的就是保持理性,保持克制,尽量减少试错成本,不断试错,快速迭代。(个人经验是如果方便的话,如果一个需求有对应的提出人,可以在完成一个简单的原型后就找到对应的需求提出人,确定基础功能的实现是符合他的预期的)
B端产品的一大特点就是普遍业务复杂度高,而AI平台在此基础上同时还有较高的学习成本。按照俞军老师的用户价值公式:
用户价值=新体验-旧体验-切换成本
因此如何增强用户价值,在做好新体验的同时,切换成本的缩小至关重要,这也是对于B端产品来说,易用性很重要的原因,因为它不仅可以作用于新体验,更可以在切换成本的缩小上提供巨大助力。
这里的成本,对于AI平台,我个人理解可以分为几个方面:
而减少这些成本,我们则可以从这几个方面入手(主要参考的是尼尔森十大可用性原则):
(具体后面再写个介绍一下这一块)
在B端,很多时候都会忽略情感化的事情,但据我这几个月的实践来看,情感化其实在B端也是非常重要的一环。
对于B端我们可以从马斯洛五层出发,将实现的层次分为三个:
一个好的情感化设计可以极大的提升用户体验,拉近平台与用户的距离。
对于情感化设计,我个人有以下一些心得:
如何去做:
本来很早前我就自认为对前端语义化有了一些了解,但在看完winter大大的专栏后才发现,自己对语义化的理解并不是很透彻,只存在于表面的理解而已,因此决定在学习winter老师的专栏后结合自己的一些理解,好好对其进行进行一个总结。
从维基百科所得到的结果:
The Semantic Web provides a common framework that allows data to be shared and reused across application, enterprise, and community boundaries. --Wikipedia
Web语义化提供了一个通用框架,允许跨应用的程序,企业和社区共享和复用数据。简单来说,Web语义化是指使用恰当语义的html标签、class类名等内容,让页面具有良好的结构与含义,从而让人和机器都能快速理解网页内容。
我们都知道,对于Web来说,HTML就是负责联系大部分的Web资源的承载体和纽带,也就是我们常说的内容的载体。但在web刚被设计出来之初,设计者也没想到web会达到现在如此巨大的规模,因此早期的的HTML标准中所提供的元素也并不多,只有常见的h1-h6、ul、ol等标签。但随着Web的快速发展,网页的数量以及开发规模越来越大,为了能让用户在使用搜索引擎时能够更快、更精确的定位所要查询的网页,以及减少一些开发的维护余合作的成本,HTML在后面又提供了诸多的语义化标签,从而让页面有更为良好的结构,让搜索引擎可以更好的去查询定位网页,同时也使人更易于去理解网页的结构,这在团队协作开发中很重要。
既然知道了上面是语义化,也大概的知晓的语义化为我们带来的一些好处,但这些好处对于我们来说好像并不够具体。因为好像我们在平常进行开发的时候就只使用了div和span,也能写出一个完整的页面出来,简称span、div一把嗦,这也是我平时的做法(当然,现在在使用react的时候更加如此了)。我一直觉得这种做法其实挺不好的,至少挺low的,毕竟很多类似的文章都告诉我们,要做好web语义化,做好SEO。但在winter老师的专栏中又对此进行了分析:
这样做行不行呢?毫无疑问答案是行。那这样做好不好呢?按照正确的套路,我应该说不好,但是在很多情况下,答案其实是好。
这是因为在现代互联网产品里, HTML 用于描述 “ 软件界面 ” 多过于 “ 富文本 ” ,而软件界面里的东西,实际上几乎是没有语义的。比如说,我们做了一个购物车功能,我们一定要给每个购物
车里的商品套上 ul 吗?比如说,加入购物车这个按钮,我们一定要用 Button 吗?
实际上我觉得没必要,因为这个场景里面,跟文本中的列表,以及表单中的 Button ,其实已经相差很远了,所以,我支持在任何 “ 软件界面 ” 的场景中,直接使用 div 和 span 。
但话虽这样说,winter老师所说的前提是在软件界面的条件下如此,如果是其他的工作场景,语义类的标签就拥有其自身的优点了:
前面提到,语义化是好的。但winter老师在专栏中也提到了,对于语义标签来说:“用对”比“不用”好,“不用”比“用错”好。因此如何正确的使用语义标签,是我们进行web语义化的重中之重。下面是winter老师所提到的比较重要的语义标签的使用场景。
所谓的作为自然语言延伸的语义类的标签,就是为自然语言和纯文本的补充,用来表达一定的结构或者消除歧义。比较常见的就是是em和strong这两者了。我们可以通过使用类似的标签来消除一些歧义,让读者与机器都能更为准确的把握我们一句话的意思。至于具体的区别可以看下面这篇文章,作者对em和strong的区别做了一个较为全面和详细的探讨:
此类标签的用途是用于表现文章的结构,让文章的目录结构显得更加清晰有序。在HTML里,我们通常使用h1-h6来作为最基本的标题,而当我们需要副标题的时候,我们就需要使用hgroup,以免让副标题也产生一个层级。
<hgroup>
<h1>这是一个主标题</h1>
<h2>这是一个副标题</h2>
</hgroup>
此外在HTML5也提供了section标签,它会改变 h1-h6 的语义。section 的嵌套会使得其中的 h1-h6 下降一级。
最后一个比较常见的场景就是很多浏览器所推出的“阅读模式”,以及各种非浏览器终端的出现,这让web语义化变得越来越重要。
而应用语义化标签来表现网页的结构,可以明确的表现出页面信息的主次关系,从而能更为精确的表现出阅读视图功能。也能让SEO更好。
<body>
<header>
<nav>
……
</nav>
</header>
<aside>
<nav>
……
</nav>
</aside>
<section>……</section>
<section>……</section>
<section>……</section>
<footer>
<address>……</address>
</footer>
</body>
上面这一块HTML就是一个语义化标签构成的body。其中 header 元素
代表“网页”或者“section”的页眉,通常包含h1-h6 元素或者 hgroup, 作为整个页面或者某个内容块的标题。也可以包裹一小节的目录,一个搜索框,一个nav等。需要注意的是:
至于nav元素则代表页面的导航链接区域,用于定义页面的主要导航部分。
aside 元素经常被包含在article元素中,作为主要内容的附属信息部分,其中的内容可以是与当前文章有关的相关资料,标签,名词解释等。
在article元素之外使用作为页面或站点全局的附属信息部分。最典型的是侧边栏,其中的内容可以是日志串连,其他组的导航,甚至广告,这些内容相关的页面。值得注意的是,aside 很容易被理解为侧边栏,实际上二者是包含关系,侧边栏是 aside , aside 不一定是侧边栏。aside 和 header 中都可能出现导航( nav 标签),二者的区别是, header 中的导航多数是到文章自己的目录,而 aside 中的导航多数是到关联页面或者是整站地图。
footer元素代表“网页”或任意“section”的页脚,通常含有该节的一些基本信息,譬如:作者,相关文档链接,版权资料。如果footer元素包含了整个节,那么它们就代表附录,索引,提拔,许可协议,标签,类别等一些其他类似信息。在这里它包含了 address ,这也是个非常容易被误用的标签。 这里的address 并非像 date 一样,表示一个给机器阅读的地址,而是表示 “ 文章(作者)的联系方式 ” , address 明确地只关联到 article 和 body 。
除此之外,还有 article ,article 代表一个在文档,页面或者网站中自成一体的内容,其目的是为了让开发者独立开发或重用。所以, article 和 body 具有相似的结构,除了它的内容,article通常也会有一个标题(通常会在header里),一个footer页脚等。同时,一个 HTML 页面中,可能有多个 article 存在。
一个典型的场景是多篇新闻展示在同一个新闻专题页面中,这种类似报纸的多文章结构适合用 article 来组织。
<article>
<header>
<h1>web 语义化的那些事儿</h1>
<p><time pubdate datetime="2018-03-23">2019-02-15</time></p>
</header>
<p>文章内容..</p>
<article>
<h2>评论</h2>
<article>
<header>
<h3>评论者: 太平洋水军</h3>
</header>
<p>楼下真帅</p>
</article>
<article>
<header>
<h3>评论者: 黄海水军</h3>
</header>
<p>楼上长得帅</p>
</article>
</article>
</article>
参考链接:
作为新一代的web标准,HTML5为我们提供了很多有用的东西,比如canvas,本地存储(已经分离出去了),多媒体编程接口,当然还有我们的WebSocket。WebSocket是HTML5开始提供的一种浏览器与服务器间进行全双工通讯(full-duplex)的网络技术,可以传输基于信息的文本和二进制的数据。它于2011年被IETF定为标准 RFC 6455,同时WebSocket API也被W3C定为标准。
web应用的信息交互过程我想大家或多或少都知道一些,通常是客户端通过浏览器发出一个请求,然后服务器端在接受和审核请求后,进行处理并将结果返回给客户端,最后由客户端的浏览器将信息呈现出来。这种通信机制在信息交互不是特别频繁的情况下并没有太大的问题,但对于那些实时性要求高、海量数据并发的应用来说,就显得捉襟见肘了,比如现在常见的网页游戏,证券网站,RSS订阅推送,网页实时对话,打车软件等。通常当客户端准备呈现一些信息时,这些信息在服务器端很有可能就已经过时了。为了满足以上那些场景,大佬们研究出来了一些折衷方案,其中最常用的就是普通轮询和Comet技术,而Comet技术实际上就是轮询的改进,细分起来Comet有两种实现方式:
长轮序是对普通轮询的改进和提高。普通轮询简单来说,就是客户端每隔一定的时间就向服务器端发送请求,从而以频繁请求的方式来保持客户端和服务器端的同步。这种同步方案的最大问题是,客户端已固定的频率发送请求时,很可能服务端的数据没有更新,产生很多无用的网络传输,非常低效。
为了减少无效的网络传输,长轮询对普通轮询进行了改进和提高,当服务器端没有数据更新时,链接会保持一段时间的周期,直到数据或状态发生改变或连接时间过期,通过这种机制我们就可以减少很多无效的客户端和服务器间的交互。当然,如果服务器端的数据变更非常频繁的话,这种机制并没有有效的提高性能,和普通轮询没有太大的区别,且长轮询也会耗费更多的资源,比如CPU,内存,带宽等。
流技术机制简单来说就是客户端的页面使用一个隐藏的窗口向服务端发出一个长连接的请求。服务器接到请求后作出回应,并不断更新状态,以保证客户端和服务器端的连接不过期。通过这种机制就可以将服务器端的信息不断传向客户端,从而保证信息的时效性。但这种机制对于用户体验并不友好,需要针对不同的浏览器升级不同的方案来改进用户体验,同时这种机制如果在并发情况下发生时,会对服务器的资源造成很大压力。
正是出于以上几种解决方案都有着各自的局限性,HTML5 WebSocket也就应运而生了,浏览器可以通过JavaScript借助现有的HTTP协议来向服务器发出WebSocket连接的请求,当连接建立后,客户端和服务器端就可以直接通过TCP连接来直接进行数据交换。这是由于websocket协议本质上就是一个TCP连接,所以在数据传输的稳定性和传输量上有所保证,且相对于以往的轮询和Comet技术在性能方面也有了长足的进步:
有一点需要注意的是虽然websocket在通信时需要借助HTTP,但它本质上和HTTP有着很大的区别:
他们的关系其实就和这张图表现的一样,虽然有相交的部分,但依然有着很大的区别:
由于每个服务器端的语言都有着自己的API,因此首先我们来讨论客户端的API:
// 创建一个socket实例:
const socket = new WebSocket(ws://localhost:9093')
// 打开socket
socket.onopen = (event) => {
// 发送一个初始化消息
socket.send('Hello Server!')
// 服务器有响应数据触发
socket.onmessage = (event) => {
console.log('Client received a message',event)
}
// 出错时触发,并且会关闭连接。这时可以根据错误信息进行按需处理
socket.onerror = (event) => {
console.log('error')
}
// 监听Socket的关闭
socket.onclose = (event) => {
console.log('Client notified socket has closed',event)
}
// 关闭Socket
socket.close(1000, 'closing normally')
}
是不是感觉HTML5 websocket所提供的API贼鸡儿简单,没错,就是这么简单。但有几点我们需要注意:
我们在上面提到过,创建一个socket实例时可以选填ws和wss来进行通信协议的确定。他们两个其实很像HTTP和HTTPS之间的关系。其中ws表示纯文本通信,而wss表示使用加密信道通信(TCP+TLS)。那为啥不直接使用HTTP而要自定义通信协议呢?这就要从WebSocket的目的说起来,WebSocket的主要功能就是为了给浏览器中的应用与服务器端提供优化的,双向的通信机制,但这不代表WebScoket只能局限于此,它当然还能够用于其他的场景,这就需要他可以通过非HTTP协议来进行数据交换,因此WebSocket也就采用了自定义URI模式,以确保就算没有HTTP,也能进行数据交换。
ws和wss:
注:有些HTTP中间设备有时候可能会不理解WebSocket,而导致各种诸如:盲目连接升级,乱修改内容等问题。而WSS就很好的解决了这个问题,它建立了一台哦端到端的安全通道,这个通道对中间设备模糊了数据,因此中间设备就不能感知到数据,也就无法对请求做一些特殊处理了。
以下是一个典型的WebSocket发起请求到响应请求的示例:
客户端到服务端:
GET / HTTP/1.1
Connection:Upgrade
Host:127.0.0.1:8088
Origin:null
Sec-WebSocket-Extensions:x-webkit-deflate-frame
Sec-WebSocket-Key:puVOuWb7rel6z2AVZBKnfw==
Sec-WebSocket-Version:13
Upgrade:websocket
服务端到客户端:
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Server:beetle websocket server
Upgrade:WebSocket
date: Thu, 10 May 2018 07:32:25 GMT
Access-Control-Allow-Credentials:true
Access-Control-Allow-Headers:content-type
Sec-WebSocket-Accept:FCKgUr8c7OsDsLFeJTWrJw6WO8Q=
我们可以看到,WebSocket协议和HTTP协议乍看并没有太大的区别,但细看下来,区别还是有些的,这其实是一个握手的http请求,首先请求和响应的,”Upgrade:WebSocket”表示请求的目的就是要将客户端和服务器端的通讯协议从 HTTP 协议升级到 WebSocket协议。从客户端到服务器端请求的信息里包含有”Sec-WebSocket-Extensions”、“Sec-WebSocket-Key”这样的头信息。这是客户端浏览器需要向服务器端提供的握手信息,服务器端解析这些头信息,并在握手的过程中依据这些信息生成一个28位的安全密钥并返回给客户端,以表明服务器端获取了客户端的请求,同意创建 WebSocket 连接。
当握手成功后,这个时候TCP连接就已经建立了,客户端与服务端就能够直接通过WebSocket直接进行数据传递。不过服务端还需要判断一次数据请求是什么时候开始的和什么时候是请求的结束的。在WebSocket中,由于浏览端和服务端已经打好招呼,如我发送的内容为utf-8 编码,如果我发送0x00,表示包的开始,如果发送了0xFF,就表示包的结束了。这就解决了黏包的问题。
浏览器 支持情况
Chrome Supported in version 4+
Firefox Supported in version 4+
Internet Explorer Supported in version 10+
Opera Supported in version 10+
Safari Supported in version 5+
简单来说Socket.IO就是对WebSocket的封装,并且实现了WebSocket的服务端代码。Socket.IO将WebSocket和轮询(Polling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,WebSocket仅仅是Socket.IO实现实时通信的一个子集。Socket.IO简化了WebSocket API,统一了返回传输的API。传输种类包括:
我们来看一下服务端的Socket.IO基本API:
// 引入socke.io
const io = require('socket.io')(80)
// 监听客户端连接,回调函数会传递本次连接的socket
io.on('connection',function(socket))
// 给所有客户端广播消息
io.sockets.emit('String',data)
// 给指定的客户端发送消息
io.sockets.socket(socketid).emit('String', data)
// 监听客户端发送的信息
socket.on('String',function(data))
// 给该socket的客户端发送消息
socket.emit('String', data)
另外,Socket.IO还提供了一个Node.JS API,它看起来很像客户端API。所以我们来看看它的实际应用吧:
// socket-server.js
// 需要使用HTTP模块来启动服务器和Socket.IO
const http= require('http'),
const io= require('socket.io')
const server= http.createServer(function(req, res){
// 发送HTML的headers和message
res.writeHead(200,{ 'Content-Type': 'text/html' })
res.end('<p>Hello Socket.IO!<p>')
});
// 在8080端口启动服务器
server.listen(8080)
// 创建一个Socket.IO实例,并把它传递给服务器
const socket= io.listen(server)
// 添加一个连接监听器
socket.on('connection', function(client) {
// 连接成功,开始监听
client.on('message',function(event){
console.log('Received message from client!',event)
})
// 连接失败
client.on('disconnect',function(){
clearInterval(interval)
console.log('Server has disconnected')
})
})
然后我们就可以启动这个文件了:
node socket-server.js
然后我们就可以创建一个每秒钟发送消息到客户端的发送器了;
var interval= setInterval(function() {
client.send('This is a message from the server,hello world' + new Date().getTime());
},1000);
注:需要注意的是,如果我们想在前端使用socket.IO,我们需要下载这个:
npm install socket.io-client --save
然后再连接网络:
import io from 'socket.io-client'
const socket = io('ws://localhost:8080')
前段时间无意中翻了一个老外对于项目代码文件组织形式的讨论,谈到了自己在基于技术组织文件所遇到的一些问题(具体文章找不见了)。那什么是基于技术的代码组织方式呢,其实很简单:就是根据具体的技术范畴,对代码进行管理。在这里随便找一个开源的前端项目举例:
大家可以看到,其实这个项目的文件划分以及是非常清楚了:
可以说是一个前端 Vue 项目 MVVM 的代码组织的典范了。这要做的好处在于:
所以很多项目都会按照这种方式去进行代码的组织与管理(包括我自己),BPM在这方面同样也是如此:
与上面也很类似:
但其实这样的文件组织方式,同样也存在一些问题,主要有两方面:
这也引起了我对于现在项目文件有了一些反思,加之前段时间总结了一下领域驱动设计,又回想起当年学习 Angular 所带来的一些收获,所以也开始思考:或许直接使用业务领域去组织文件会更好一些,就比如这样:
其主要实践如下:
这样做的优点在于:
当然,也会存在一些缺点:
这里需要注意的是,有人会说,可能一个模块的组件被多个地方所引用,那么按照这种方式去做,岂不是也会存在一个领域下,存在其他文件的代码?这里又需要引入领域驱动设计的另外一个概念了:通用域、支撑域、核心域。虽然这个域在后端开发中,往往是很大的一个概念了,但引用至我们前端开发的范畴,我们依然可以用这个思路去思考我们模块的划分:
要了解浏览器渲染页面的过程,首先得知道一个名词——关键路径渲染。关键渲染路径(Critical Rendering Path)是指与当前用户操作有关的内容。例如用户在浏览器中打开一个页面,其中页面所显示的东西就是当前用户操作相关的内容,也就是浏览器从服务器那收到的HTML,CSS,JavaScript等相关资源,然后经过一系列处理后渲染出来web页面。实际抽象出来理解可以将这些步骤看作一个函数,就输入HTML,经过一层层的处理,最后输出像素。
而浏览器渲染的过程主要包括以下几步:
需要注意的是,以上几个步骤并不一定是一次性顺序完成,比如 DOM 被修改时,亦或是哪个过程会重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。而在实际情况中,JavaScript和CSS的某些操作往往会多次修改DOM或者CSSOM。
值得注意的的是,在每个阶段,都会有对应的输入,处理,以及输出。下面我们就来详细的了解一下这几个过程及需要注意的事项。
因为浏览器无法直接使用HTML/SVG/XHTML,因此当浏览器客户端从服务器那接受到HTML文档后,就会遍历文档节点,然后对这些文档节点通过HTML解析器进行解析,最后生成DOM树,所生成的 DOM 树结构和HTML标签一一对应。在这其中HTML解析器会进行诸如:标记化算法,树构建算法等操作,其中的规范即遵循了W3C的相应规范,也都有浏览器引擎自己的一些特定的操作,详情可以翻阅这篇非常著名的文章:
值得注意的是,HTML解析器并不是等整个文档全部加载完之后才开始解析的,而是网络进程加载了多少数据,HTML解析器就会解析多少数据。相当于在网络进程与渲染进程会在这期间建立一个数据共享的管道,网络进程每次收到数据都会通过这个管道将数据转发到渲染进程,从而保证渲染进程中的HTML解析器可以源源不断的获取到用于渲染的数据。这个过程可以理解为下方这个流程:
每个页面的DOM树,我们也可以直接通过在控制台输入document 来进行访问:
对于DOM树,我们需要注意以下几点:
document.getElementsByTagName("h2")[0].innerText = "Hello World"
此外DOM 树在构建的过程中可能会被 CSS 和 JS 给阻塞住,也就是我们常说的阻塞渲染。这是因为HTML文件是通过HTML解析器转化成 DOM 树的,而在HTML解析器中如果遇到了 JavaScript 脚本,HTML 解析器会先执行 JavaScript 脚本,待这个脚本执行完成之后,再继续往下解析。因此我们常说,将script标签放在body下面,通常就是基于这种考虑的。但为什么CSS也有可能会阻塞DOM树的构建呢,可以看下面一个栗子:
<html>
<head>
<style type="text/css" src = "demo.css" />
</head>
<body>
<p>demo</p>
<script>
const p = document.getElementsByTagName('p')[0]
p.innerText = 'hello world'
p.style.color = 'red'
</script>
</body>
</html>
由于任何script代码都能改变HTML的结构,因此HTML每次遇到script都会停止解析,等待JavaScript引擎将这个JavaScript脚本执行完毕,然后再进行接下来的解析,这就是我们常说的JavaScript会阻塞渲染的原因。那为什么CSS也可能会阻塞DOM树的构建呢?这个是由于当我们通过 JavaScript 去进行样式操作的时候,这个 JavaScript 脚本执行完成的前提条件就成了需要现将样式信息加载以及确定下来,因此在这种情况下,HTML解析器在这里所需要等待的时间,也一定程度上取决于这个css文件的大小。这也是我们常说的,别在 JavaScript 中操作样式的原因。(这块的具体情况感兴趣的也可以去看《JavaScript忍者秘籍 第二版》)
为了优化这种情况,现代浏览器也做了一些优化,比如预解析操作。当渲染引擎接收到字节流后,会开启一个预解析线程,用来分析 HTML文件的代码中的JS,CSS文件,解析到相关文件的时候,预解析进行会提前下载这些资源。
对于处理这种事情,避免阻塞的产生,我们也有以下几点可以注意的:
在构建渲染树时,需要计算每一个呈现对象的可视化的属性值。而这个过程就被称为样式计算或者计算样式。这个过程主要是为了 DOM 树中每个节点的具体样式,大致可分为三大步骤:
和html一个道理,浏览器也无法直接去理解我们所写的那些CSS样式,因此浏览器在接收到CSS文件后,会将CSS文件转换为浏览器所能理解的 StyleSheet。转化了的 StyleSheet 我们同样也可以通过控制台来访问:
在这个过程中需要注意的是:
在将CSS文转化为浏览器能够理解的 styleSheet 后,就需要对期进行进行属性值的标准化操作了。这里的标准化的意思就是,我们在写css文件的时候,会写一些语义化的属性比如:red/bold等等。但其实这些词对于渲染引擎来说,却不是那么好理解的。因此在进行计算样式之前,浏览器还会这对这些不怎么好计算的值进行标准化,将其转化为渲染引擎容易理解的词,比如将red转化成为 rgb(255, 0, 0)等等。
计算出 DOM 树中每个节点的具体样式主要涉及的就是CSS继承规则和层叠规则了,对于继承规则其实比较好理解,就是,每个DOM节点都包含的父节点的样式。
而层叠规则也就是样式层叠就有点麻烦了,MDN是这么描述层叠的:
层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在CSS处于核心地位,CSS的全称层叠样式表正是强调了这一点。
层叠的具体细节在这里也不展开讲了(我自己现在还没搞清楚。。。),大家可以去CSS层叠看看其内部的一些规则。
在有了css继承规则和层叠规则后,样式计算的这个阶段就会在这两个规则的基础上对 DOM 节点中的每个元素计算处具体的样式,这个阶段中最终输出的结果会保存在 ComputedStyle 中,这个同样可以通过控制台进行查看:
**
通过前面两个阶段,我们已经得到了DOM树以及DOM树中具体每个元素的样式了,但对于每个元素所处的几何位置我们现在还是不知道的,因此接下来要做的就是计算出DOM树中可见元素的几何位置。这个过程可以分为两个阶段:
由于DOM树还包含很多不可见的元素,比如head标签,script标签,以及设置为display:none的属性,因为浏览器势必不能将所有的dom树的元素都全部拿来进行布局计算,因此在这个阶段,浏览器会额外构建一颗只包含可见元素的布局树。在构建布局树期间,浏览器大体会进行以下一些工作:
下面两个需要注意:
在已经获取了所有可见元素的树之后,就可以计算布局树节点的几何位置了。HTML是基于流的布局方式,因此大多数情况下,只需要进行一次遍历即刻计算出页面的几何信息。通常来说,处于流靠后的元素不会影响到靠前位置元素的几何特征,因此在进行布局计算的时候,通常是按从左至右,从上至下的顺序遍历文档(只是通常而言,比如表格啥的就不是这样)。
布局计算是一个递归的过程,它从根节点出发,然后递归遍历部分或所有的节点,为每一个需要计算的呈现器计算几何信息。这个计算量无疑是庞大的,因此为了避免一些较小的更改也会触发页面的整体布局计算,浏览器将布局方式分为了全局布局和增量布局。
在执行完布局计算后,会将布局计算的结果写入布局树中,因此这个过程可以理解为一种装饰者模式,输入输出都是一个布局树,只是在这个过程中会将布局计算的结果给加进去。
在有了布局树之后,浏览器的还是不能直接根据布局树来将页面给画出来,因为页面中还存在中一些特殊的效果,比如页面滚动,z-index等。为了能够方便的实现这些花里胡哨的功能,渲染引擎还需要进行一个分层处理,将特定节点生成转筒的图层,并生成一个图层树(LayerTree),这个我们也能通过浏览器的面板看到:
如上图所示,浏览器的页面实际上被分成了多个图层,这些图层叠加在一起就形成了我们最终所看到的页面。需要注意的是,并不是布局树中的每一个节点都会包含一个图层,因此如果一个节点没有所对应的图层,那么它就会从属于父节点的图层。如果一个节点需要有自己的图层,通常需要满足以下联合条件
在确定好图层之后,浏览器的渲染引擎会对图层树中的每个图层进行绘制,渲染引擎会将一个图层的绘制拆封成很多个小的绘制指令,然后会将这些绘制指令按照一定顺序组成一个待绘制列表。和布局相同,绘制也分为全局和增量两种,也是为了避免部分图层的改变而需要对整个图层树进行绘制。此外,CSS也对绘制顺序做了规定:
这里的栅格化是指将图转化为位图。绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际绘制操作是由渲染引擎中的合成线程来完成的。实际过程是当图层对应的绘制列表准备好之后,主线程会将绘制列表提交给合成线程。 合成线程会根据用户所能见的窗口范围对一些划分,将一些大的图层化分为图块。然后合成线程会根据用户所见范围附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。图块是栅格化执行的最小单元,渲染进程维护了一个栅格化的线程池,所有的图块栅格化操作都会在这个线程池里进行。
通常,栅格化会使用GPU进程中的GPU来进行加速,使用GPU进程生成位图的过程叫快速栅格化,通过这个方式生成的位图会被保存在GPU内存中。这样做的好处就在于,当渲染进程的主线程发生阻塞的时候,合成线程以及GPU进程不会受其影响,可以正常运行。这也是为啥有时候主线程卡住了,但CSS动画依然可以风*依旧的原因。
在所有的图块都被进行栅格化后,合成线程就会生成绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
我们都知道HTML默认是流式布局的,但CSS和JS会打破这种布局,改变DOM的外观样式以及大小和位置。因此我们就需要知道两个概念:
需要注意的是,display:none 会触发 reflow,而visibility: hidden属性则并不算是不可见属性,它的语义是隐藏元素,但元素仍然占据着布局空间,它会被渲染成一个空框,这在我们上面有提到过。所以visibility:hidden 只会触发 repaint,因为没有发生位置变化。
我们不能避免reflow,但还是能通过一些操作来减少回流:
另外有些情况下,比如修改了元素的样式,浏览器并不会立刻reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次 reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。
结合上文和我看到的一些文章,有以下几点可以优化渲染效率
《写给大家看的色彩书:设计配色基础》国内著名由设计师梁景红编写,主要谈及配色的基础技巧和必备理念。非常适合初学配色的专业人士来学习。仔细阅读此书后,我对配色有了非常全面的认识,也巩固了之前零散的色彩知识。很希望和大家交流分享。
这是一本非常不错的配色入门书,从色彩最基础的知识点讲起,逐渐深入到专业的理论,层层递进。配合相应的案例,虽然大部分在讲理论,却也一点都不枯燥。
之前都认为,配色只是一种感觉,只要根据常识和已有的审美走就可以了。其实不然,学习了解专业的理论知识,不仅能让你在配色过程中更加有自信,而且可以举一反三,从现有的经验推导至未知的领域,如果在工作上遇到以前没有接触过的内容,也可以轻松入手。
本书的主要内容包括:
1.颜色的基础只是,配色基础原则:不超过三种颜色
2.主色、辅色、点缀色的使用
3.色彩集合:图像、文字、图案、图表都可以看成一种颜色
4.在不确定的时候,使用黑、白、灰调色
5.基础色调:相近色调或对比色调
6.根据色彩情感的关键词,选择主色
7.根据关键词选择色调,避开主色谈配色
8.色彩素材的实践运用:提取色彩——分析色彩——实践运用
9.突破对立色彩感受:从一端到另一端
chapter1
色彩基础,让你拥有一个清晰的色彩概念:使用H(色相)S(饱和度)B(明度)的色相环调色
根据颜色三原色红黄蓝,可调出六大常用色,红、橙、黄、绿、蓝、紫(如上图)
互补色:相距180°;
对比色:相距120°;(红色与绿色)把两种颜色的纯度都设置高一些,两种颜色的特征就会都被对方完美的衬托出来。但是,由于对比色的视觉冲击还是比较大的,需要注意的是,两种颜色在面积上需要有区分。这其中紫色搭配黄色是比较容易让人接受的。此外,对比色的搭配需要用无彩色来进行调和,隔离,降低对视觉的刺激。
类似色:相距90°;
类比色:相距60°;
邻近色:相距45°;
同类色:相距30°
某种颜色的补色的两侧颜色是这种颜色的分裂互补色
相隔一个颜色的两个颜色为间隔色:(红色配黄色)
紧挨着的颜色为**相邻色(比如6色换中的红色与橙色):简单而有效,一种颜色纯度较高时,另外一种颜色选择纯度低或明度低的。颜色之间想过作用的力量发生变化,色彩就有了主次的关系,和谐度增加。此外,选择同一色相不同明度或者饱和度的颜色搭配也能够得到很好的效果。
**
从两色搭配中认识色彩(色彩搭配,就是色彩之间的相互衬托和相互作用。所以不会从单色开始学习色彩搭配)
红色使得白色很干净,白色使得红色更加注目。**
**
**
**
加入了黑色,白色的存在感降低。
黑色衬托出红色很热情,红色衬托出黑色很庄重,在这里红色与黑色的对比更加明显,使得眼睛在这两种颜色间游离,从而削弱了白色的存在感。
(黑白虽然为无彩色,但是在单色系的作品中,无彩色也在发挥非常重要的作用,否则,是无法体现单色的美好出来。)
从无彩色-有单色-两色-三色的搭配过程
第一幅:无彩色做主色调,且没有有彩色来点缀,给人消极的感受
第二幅单色,从观赏者角度看,只有一种颜色,单色搭配,显得比较单调。但是如果,绿色饱和度增加,与白色对比度增加,
观感上就会出现绿色与白色搭配的双色效果。
第三幅:双色,虽然黄色面积较大,但是首先映入眼帘的是红色,然后配以黄色为主。
第四幅:同样是双色搭配,但是首先映入眼帘的却是背景的深色,品红色为辅。
第五幅:三色搭配,画面更加丰富,饱满。
三色搭配的规律,使得你的作品配色更趋向与成熟的方向:有彩色的色相控制在三种以内。
1统一外观的相邻色搭配:保持色彩基调相同,搭配起来更加简单。比如在同一个纯度上,
或者同一个明度上搭配;
2高饱高亮的三色搭配:在色相环上等距取三种颜色,会形成让人愉悦的颜色组合
3互补取色组合:即使用分割互补色来搭配
颜色不复杂,处理起来才会比较容易,所以在优秀的作品中,往往颜色越少越好。可以去掉的颜色都要尽量避免存在。(比如下图的蓝色边框)
在标志中,多用两色三色搭配,这样比较容易识别,记忆。而在插画中则可以承载更多复杂的色彩变化。
但是,除了纯绘画与摄影等,就色相而言,也很少有超过三种搭配的作品。
chapter2 主色、辅色、点缀色
主色:色相决定整个作品的风格,确保正确的传达信息,比如蓝色传达冷静、效率;红色传达热情、刺激等
在色调相同的情况下,面积越大越显眼,也就更倾向于主色位置。而当两种颜色面积色调都区分不大时,我们称这两种颜色为双主色。
但是当色彩面积相同时,饱和度高一些的颜色力量更大,更接近于主色地位。即使面积地位较小,饱和度高的颜色也会呈现一定的力量感。
饱和度(纯度)高的颜色作为主色会比较稳定,所以通常设定饱和度高的颜色为主色。
此外,在画面中心位置的颜色更能吸引人的注意,更容易成为主色。
所以,十分“抢镜”的颜色有可能是主色,但是也有可能是点缀色,区别在于谁第一时间进入你的视野,影响你对整个作品的感官和印象。点缀色通常是面积小的部分,为了方面阅读提醒人们的注意而出现。
当不能明确主色是,可以根据色彩的基调判断,通过一个颜色的模糊概念,是柔和的,还是明快的,是冷静的,还是激烈的来区分主色的色彩基调。
辅色:帮助主色建立起更完整的形象,如果主色已经完成很好,辅色不是必要存在的。
所以判断辅色应用好的方法是:去掉它,画面不完整,有了它,主色更显优势。
辅色与双主色的区别:辅助色是可以被替代的,也可以是多种颜色;在双主色设计中,任何一个颜色都不可被替代。**
背景色是特殊的辅色:白色背景通常是阅读的需要而存在,带有颜色的背景往往可以一下子抓住人们的视线,使得画面更加丰富,而背景本身并不见得是主角。
辅助色可以辅助一种颜色,也可以辅助作品中的主体(形象,主图像等)存在
选择辅色的方法:1选择同类色,达成画面统一和谐;2选择对比色,使得画面刺激、活泼,也同样很稳定。
点缀色:
通常在细节上,多数情况是分散的,面积较小。在杂志中,常常作为牵引阅读,和提醒的作用。
特点1出现次数较多;2颜色非常跳跃;3引导阅读性4与其他颜色反差较大
左图中,红色是主色,确定画面颜色基调,
紫色是辅助色,辅助红色使得画面完整(由于画面中紫色体现较少,也可以看为点缀色),
黄色是点缀色,引导观者阅读从这里开始。
chapter3 色彩集合:图像、图案、文字和图表
图像色彩集合
左图中每种蔬菜分别看成一种颜色,并通过概括的颜色,来对于编辑文字。
一般而言,图片中的色彩信息是比较复杂的,这时候需要眯起眼睛,虚化形象,留下简单的色彩印象,什么颜色是大面积的,这种颜色就是这个图像中的主色。
色彩集合的搭配:
1从图像中选择的颜色搭配是最简单的搭配方法
2也可以采用大胆的对比色搭配
3如果一个色彩集合中,色彩分布比较零散,则需要一一做分析。从整体观察色彩布局、面积、呼应是否合理统一。或者用无彩色将它们整理连接起来,或在图像与图像之间填上色块,将图像与画面融合一体
图案色彩集合:
图案是一种有规律的色彩集合,可以看成一种颜色来进行搭配
也是一种有传播内涵的色彩,使用时要注意起表达的文化特征
图案的花色与色彩之间也有很紧密的联系,色冷的几何图案,适合成熟冷静;暖色可爱图案,则适合活泼、女性
文字色彩集合:
正文文字多用黑白灰无彩色,阅读感受上,会忽略文字有带有的无彩色,而改为有彩色时,则无法忽略文字色彩的存在。文字所展示的图像与带有的色彩会比文字本身的含义更加“抢镜”
而作为图形的文字,则具有更大的设计空间。
图标色彩集合:
设计好的图标,给人精湛专业的感觉
chapter4 黑、白、灰,天生的调和色
色彩调和的含义:
**1色彩调和是色彩配色的一种形态,使人感到愉悦,舒适好看的配色通常是色彩调和后的结果
2色彩调和是色彩搭配的一种手段,如果想要将看起来不和谐的颜色组合在一起,就需要进行色彩调和
任何两种颜色放在一起,其中一个颜色都会被当做其他颜色的参照物。它们之间的色彩对比关系是绝对的,色相、纯度、明度上的差异,必然会导致不同程度的对比。过分的对比需要加强共性来进行调和,过分暧昧的颜色配比需要加强对比来进行调和。
色彩调和的方法:
1面积调和(如右图)尽量避免1:1的对立面积出现,
一般使用5:3~3:1会比较好,
如果三种颜色则可以使用5:3:2
2点缀调和(颜色空间混合)。在对比强烈的色彩双方,互相点缀对方的色彩。这是一种视觉的空间混合
3互混调和:把两种颜色混合到一起,达到你中有我,我中有你的状态。从而得到第三种颜色。这第三种颜色,自然可以与前两种颜色都统一在一起。
4隔绝调和:画面中的色彩过分强烈或者色彩含混不清(图片较多),可运用黑、白、灰、金、银
等同一色线,把颜色进行勾边隔离。
5秩序调和:也就是按照颜色渐变的顺序进行排序调和(比如彩虹)
6最简单的方法:同一调和。包括同色相、同明度、同饱和度的调和。
同色相即指色相环中60°之内的色彩。
同明度是指被选定的色彩各色度明度相同
同饱和度是指颜色饱和度相同,基调一致,达成统一外观
黑、白、灰作为调和色更可以让作品稳定。综合运用可以使作品更受关注
白色作为背景常被忽略,白色可以使有彩色更轻盈、更透气
灰色作为辅助色,让画面有质感、有氛围,作用关键。与灰色差异大的色彩,更适合强烈的突出主题;与灰色差异小的色彩,更容易体现出高雅的氛围
黑色则可以使得任何复杂的颜色都稳定下来,使得画面有重心、有秩序。
**
chapter5相近色调或对比色调调和
在色彩搭配中,不要盲目进行,而是确定一个大的方向后,朝着这个方向寻找、调整、改良。在细节上花时间打磨。
色彩搭配有三个极端:
1单一色:本身单一色无法形成颜色搭配,单一色搭配是指相近色搭配。同类色基调是温和的,可以在同一颜色周围选色,或仅仅改变明度饱和度。这样的画面通常是柔和的基调;
2强烈的对比色:效果强烈,戏剧性,冲突非常明显。为了营造和突出某一个元素时比较适合
3五颜六色:这种情况可以使用无彩色辅助,不常用。
配色思路:没有思路的时候,从色彩基调入手
1寻找共性、统一的相近色调配色
基调统一,找到色彩之间共性的地方,达到同类色调外观统一(如右图第一张是同类色统一色调,第二张为图片取色统一基调)
2寻找强烈感受、戏剧冲突的对比色调配色思路
相似色调虽然画面统一,和谐,但它是一种比较温和的手段,如果需要创作更有戏剧性,更具对立色彩倾向的作品,就需要使用对比色调搭配。
面积上:可以使用小范围的对比,也可以使用大范围的对比,或者在面积对比上进行强化对比的关系。
3同时用两种思路进行色彩设计
同类色寻找色彩之间的共性,对比色强调戏剧性。同时运用,会使画面在和谐与冲突之间达到平衡。
chapter6色彩情感的运用
色彩的情感实际上是我们对于外界事物的一种审美感情
抽象色彩与具象色彩的区别在于,抽象色彩上附着了人的情感**
在人类漫长的历史中,人们看待色彩不是单纯的从色相上判断的,而是从色彩依附的载体、色彩的来源、使用色彩的族群和不同的文化中寻找更多的信息加以理解。
红色:成熟的苹果是红色,红色的苹果是具象的色彩,同时红色也代表了成熟。
红色代表食物中重要的颜色,同时也代表健康,积极,时尚,文化。淡雅的红色常用作女性色彩,娇艳的红色往往表示积极向上的精髓,偶尔也会表示恐怖,神秘感。
橙色:多数家庭的感觉,温暖,阳光,欢快。同时也是醒目的,没有太多负面情感的颜色。
黄色:最醒目、最明亮的颜色。适合儿童,有活泼,开放的心情。
绿色:春天的颜色,和平,生命,环保,常用语医疗和农业等。
**蓝色:****有很长的海岸线,沿海地方都比较喜欢使用蓝色
紫色:幻想色,不切实际的主题中。既优雅也温柔,庄重又华丽,成熟女人的象征。同时也是冷色,有阴暗、冷漠的一面。
粉色:少女的颜色
白色:是如空气一般的存在,设计中绝对少不了
灰色:与有彩色搭配,呈现出高雅,成熟一面,不同深浅的灰色搭配额,给人感受又十分消极
**黑色:**比较常用与男士,重要场合,不可侵犯的感觉。
原色中红色为暖色,蓝色为冷色。
同时在原色中加黄色颜色发暖,加蓝色发冷。
当画面处于基调是暖调时,
在冷色中加黄色达到偏暖的颜色也可以使用。
比如右图,湖蓝是一种暖色的蓝
深蓝、蓝灰就是冷色的蓝
阴影也有冷暖:
太阳与灯泡是光通常是暖光,日光灯通常是冷光。冷光产生的阴影发蓝,发冷;暖光产生的阴影发红、发暖。
每种颜色的冷暖色可产生两级情感:暖色的蓝给人干净、整洁、智慧 和柔和等,冷色的蓝虽然也有智慧的情况信息,但是更多的带有冷酷的、冷眼旁观的,公正的和商务的效果。
chapter7基调关键词,避开主色谈配色
关于配色的选择,除了根据主色,还可以根据色彩基调原则来确定
1纯色色调:积极健康的色调。使用纯色为主色,搭配饱和度低的颜色作为辅助色,此时需要扩大纯色面积,否则无法展现出纯色特征。
2明色色调:清爽明快的色调。在纯色的基础上加入白色得到色彩。
明色作为主色时,并不十分稳定,但是只要两种明色作为主色,配合小面积的其他颜色,也可以很出彩。
3淡色色调: 柔软天真的色调。明色加白色得到淡色色调。非常的轻柔,温柔
不建议作为主色,非常的不稳定。但如果一组单色的组合,也可以表达出轻柔的质感效果
4浊色色调:成熟优雅的色调。纯色中加灰色。在绘画中比较忌讳,加灰色会使得画面变脏。但是在计算机调色中,颜色展示的效果更纯,则可以一试。使用与成年人,形成稳重,素雅感。但是要注意加入灰色的量,注意色彩倾向的变化。浊色的开放度不够,可以配合补色达到稍许开放亲和的效果。
5淡浊色色调:优雅的色调。淡色加黑得到淡浊色。与浊色的情感相似。更加优雅,更偏重女性化
6暗色色调:强调商务的色调。纯色加黑。在那行相关产品中常出现。
确定颜色之前,先确定颜色明暗的基调。然后可以使用多种色调的组合,在健康的纯色中加入优雅的单色,可以消除纯色抵挡的感觉,转为质朴,同时增加了色彩的多层次感。
色调组合的原则:注意主色调与对抗色调的面积配比,面积较大的为主色调,也是重点表现的色调,而对抗色调要选择可以为主色补色的色调。
chapter8突破素材的形式,提取色彩-分析色彩-实践运用色彩
**经验是提高配色能力的重要指标。在时间不够的情况下,先要学习优秀的作品。分析和练习是非常必要的,吧看到的优秀配色从原本形式中提取出来,应用到新的形式中去。
我们可以从任何设计作品中获取配色的借鉴,包括网页,平面,服装,插画等等。
**
**
chapter9突破对立色彩的感受:从一端到另一端的选择
搭配出一种情感效果,很可能就无法得到另一种情感效果,在这种情况下就要合适的取舍,明确具体的配色目的。
同样是浊色➕明色,不同的颜色搭配,可以得出不同情感的颜色搭配效果。可以是活泼的,可以是平静的,也可以平时,华丽的。
最后:
色彩有冷暖之分,有性别之分,也有不同的感情基调。所以把色彩也当成有灵魂的“人”,在搭配的过程中,时时刻刻考虑到不同的色彩搭配在一起要“和谐相处”,才能构建出和谐的画面。
使用Hooks开发也有一年多了,期间踩了一些坑,也收获了一些体会,撑国庆有点时间小小的总结梳理了一下,后续有时间会一直补充。
useState 就不用多说了,它的作用就在于可以让函数式组件拥有状态。在使用它的时候,需要注意的是:永远不要将可以通过计算得到的值保存为 state。包括但不限于:
这样做的好处有:
useEffect,顾名思义即执行副作用的地方。很多情况下,使用过 class 组件的同学会将其与 class 组件中的一些生命周期函数相绑定。但其实它的机制是不太一样的,class 组件的生命周期函数是按照一定的标准去顺序执行的,而 useEffect 则是每次组件 render 完后判断其 **依赖 **然后执行的。
对于useEffect 的依赖,有以下几点可以参考的:
我自己在面试时,很喜欢问一些熟悉 react 技术栈的同学一个问题:在函数式组件中,是否需要对那些 inline 的函数都套上一层 useCallback 来提升性能?有不少同学回答的是:是,需要。
咋一看,因为react函数组件会在UI发生变化时重新执行一次。因此如果每次重新执行,都需要创建这些 inline 的函数,可能会对性能造成影响,因此需要用 useCallback 包上一层。但实际上,JavaScript对于创建函数式很快的(官方解释),相反纯粹的给一个组件套上 useCallback 只会更慢,因此 inline 函数无论如何都会创建,useCallback还需要比较依赖项的变化。
因此我们应该在以下一些场景下,才考虑使用 useCallback:
而 useMemo 也是用于缓存,和 useCallback 不同的是,useCallback 缓存的是函数引用,而useMemo则是缓存计算结果。通常用于:一个数据是根据其他数据计算而来的,且这个数据只有当其他数据发生变化时,发需要重新计算。
有意思的是,useCallback其实也可以通过useMemo来进行实现,因为它们本质上都是做同一件事情:建立一个结果和数据的依赖关系,只有依赖数据发生变化,结果才会发生改变。
const myUseCallback = useMemo(() => {
// 返回一个函数作为结果
return () => {}
}, [deps])
ref 是 react 中一直存在的。在class组件中,我们通常使用 createRef 来创建ref;而在函数组件中,我们则使用 useRef 来创建 ref。这会让人直观的感觉 useRef 就是 createRef 在函数组件的替代品,但其实它们还是存在差异的。useRef 的 current 像一个变量,其.current属性被初始化为传入的参数(initialValue),返回的对象将在组件的整个生命周期内持续存在。而createRef 则会在每次渲染都会返回一个新的引用,而 useRef 每次都会返回相同的引用。因此,当我们使用 useRef 时,通常我们是需要在多次渲染之间共享一个数据;除此之外,也可用于保存某个 DOM 节点的引用,对某个节点去进行操作。
至于其他需要注意的是:useRef 的内容发生变化时,它不会通知我们,且更改 current 属性也不会导致重新刷新,因为它只是一个引用而已。
自定义 hook 也是React开发中非常重要的一环。我们可以通过自定义hook来有效的提升我们代码的可复用性以及可维护性。在使用 hook 的过程中,我想大家不难发现,hook 带来的收益其实主要是在于两点:
而自定义hook,其实也主要是集中于这两点来进行扩展开发的,其使用场景大体上也主要是以下两种场景:
对通用逻辑进行封装,是自定义 hook 中非常常见的一种使用场景。譬如说,我们以往在 class 组件中经常使用 componentDidUpdate 来实现组件更新是才需要触发的一些逻辑。然而在函数组件中,我们并不能直接使用 useEffect 来实现这个功能,因为 useEffect 的执行时和 render 相关联的,因此在组件初次渲染时也会执行。在这种情况下,我们就可以封装一个 useDidUpdate 来实现类似于 componentDidUpdate 的功能:
const useDidUpdate = (callback: () => void, array: unknown[]) => {
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) {
callback();
} else {
mounted.current = true;
}
}, [...array]);
};
这是从通用逻辑的角度的自定义 hook 的封装,对于一些业务逻辑,我们同样也可以进行处理。譬如说在微前端方案中,主子应用的通信也是其中重要的一环,而 BPM 中单据的审批也需要主子应用进行通信,然后进行相应的回调函数的执行。考虑到这种情况,在经过一些调研后,我当时就选用了使用 CustomEvent 来作为我们主子应用的通信方案,用自定义 hook 便可对这种业务逻辑进行抽象,实现复用:
import { useEffect } from 'react';
type Options = boolean | AddEventListenerOptions;
interface CustomEventParams<T> {
key: string;
callback?: (e: CustomEvent<T>) => void;
options?: Options;
}
/**
* @param key - 一个表示 event 名字的字符串
* @return callback: (data) => void
*/
export const useEmitter = <T>(key: string) => {
const callback = (data: T) => {
const event = new CustomEvent(key, { detail: data });
window.dispatchEvent(event);
};
return callback;
};
/**
* @param key - 一个表示 event 名字的字符串
* @param callback - 回调函数
* @param options - 可选参数
* @return void
*/
export const useListener = <T>(
key: string,
callback: (e: CustomEvent<T>) => void,
options: Options = {}
) => {
useEffect(() => {
if (typeof callback === 'function') {
const fn = (e: Event) => {
callback(e as CustomEvent);
};
window.addEventListener(key, fn, options);
return () => window.removeEventListener(key, fn, options);
}
}, [key, callback, options]);
};
/**
* @param key - 一个表示 event 名字的字符串
* @param callback - 回调函数
* @param options - 可选参数
* @return useEmitter:(key: string)=> callback
*/
export const useCustomEvent = <T>({ key, callback, options = {} }: CustomEventParams<T>) => {
let fn;
if (typeof callback === 'function') {
fn = callback;
} else {
fn = () => console.log('no function');
}
useListener<T>(key, fn, options);
return useEmitter<T>(key);
};
大家看到以上主要有三个API组成:
其中,在BPM的业务场景中, useEmitter 单独使用的较少,而 useListener 则通常适用于监听业务方传递过来的相关消息,而 useCustomEvent 则主要使用于在 BPM 发出事件后,根据业务方的回传消息作出响应的场景。
我相信很多同学在接手一些老项目的代码时,时常会发现有些组件的代码量会超出自己的控制范围(譬如我接手的一个项目,一个组件写了7000行,里面嵌套的一个组件也有6000行)。这样的代码是非常不好维护的,因此保持每个组件的短小,是一个非常有用的最佳实践。
那如何做到不让单个组件变得太过于冗余呢?其实做法很简单,就是尽量将相关的逻辑做成独立的 hooks,然后在函数组件中使用这些 Hooks。其实在 Vue 中也有类似的做法, Vue 的文档对于这种代码组织的方式介绍的很清楚,在这里我也就不做过多的赘述了:why-composition-api 。其核心要点就在于,对单个大的组件,进行业务逻辑上的分离,将拥有共同逻辑的代码拆分为 hooks,从而降低代码的耦合程度,减少单个组件的代码量。
由于 Hooks 是通过闭包和数组组成的环形链表来实现的,因此在开发过程中,我们也需要遵循一些开发规范才行,包括:
这些规范虽然我们都知道,但难免有时会忘记,因此我们可以通过 eslint-plugin-react-hooks 来检查我们 hooks的使用情况,在下载完这个插件后,直接在配置文件加上这两个配置即可:
"react-hooks/exhaustive-deps": "warn",
"react-hooks/rules-of-hooks": "error",
对于自定义的 hooks,在定义完后尽量加上较为完备的注释,注释的方式也推荐使用 ts doc 的格式去进行声明,这样一方面有利于后来的开发者(也可能是你自己)可以较快的明白这个自定义hook 所要完成的目的,另一方面也可能让你自己在写这个 hooks 的时候,可以二次思考这个 hooks 的入参和返回值是否合理。譬如说,上述的 useCustomEvent:
我们只需看注释即可只要这个hooks的入参以及返回值。
参考资料:
随着技术的不断演进,近年来愈来越多的非前端开发语言诸如:Rust、Go 等也开始进入前端/跨端应用开发领域,并收获了不小的开源社区的关注。因此本次在此尝试对基于 Rust 的一些 前端/跨端 应用开发进行一些分析,来分析一下这种开发模式的技术基础、基本方案等情况,由于篇幅原因将分为两部分:第一部分,主要讨论 Rust 前端应用开发的基本现状、原理以及收益等,而第二部分则会讨论号称 Electron 终结者的 Tauri 的基本情况、最大卖点、基本架构组成等。
对于前端应用开发有一定了解的同学应该知道,可以在浏览器端运行的编程语言主要有两种:
而诸如 Rust、Golang 等这样的编程语言并不具备直接运行在浏览器端的能力,当今的 Rust 前端应用开发框架之所以能得到落地,最主要还是得益于 WebAssembly 近年来的快速发展。其主要的流程就是将 Rust 编译成 WebAssembly ,然后在浏览器端运行,大致如下:
因此讨论 Rust 来进行前端开发,WebAssembly 是无法绕开的一环,它的诸多特性也成为了 Rust/Go 等前端应用框架的卖点:
这几点特性也让使用 WebAssembly 开发前端应用在技术上已经成为了可能。而另一方面,Rust 由于其优秀的语言设计以及强劲的性能,已经多年稳坐最受欢迎语言的排名榜首,这让越来越多的开发者加入到了 Rust 学习以及开发的中去,让 Rust 技术社区不断壮大,也让诸多 Rust 开发者不断思考着其能够发光发热的领域。
而此时前端领域也遇到了自己的瓶颈,随着前端开发领域的不断扩展,前端所要实现的业务复杂度也在不断增长,这也对前端应用在性能以及安全性上提出了更高的要求。虽然经过多年来的不断改进,JavaScript 在性能上都得到了长足的进步,但相较于系统级语言还是存在较大的差距。而 Rust 作为一门系统级语言,在具有强大的性能的同时,还提供强大的所有权系统以及类型体系,对安全性提供了强力的保障,这无疑是对 JavaScript 极好的补充。于是一方是快速壮大的技术社区在寻求充分的发挥场景,一方是要求不断提升的前端领域,这二者的结合也就顺理成章了。
在介绍 Rust 开发前端应用之前,需要先补充一个小点,现在使用 Rust 开发前端应用有两种开发方式,我将其称称为激进派以及改良派。其中激进派的做法是整个应用全部使用 Rust 进行开发,然后将其编译为 WASM 运行在 WebView 或者浏览器中,这也是我们今天所讨论的开发方式。而改良派则倾向于将应用的一部分用 Rust 进行开发,然后将其作为一个 Module 和前端应用进行组合,这也是一种较为常见的开发方式,由于篇幅问题在此就不做过多介绍。
总的来说现今的开源社区的 Rust 前端框架基本呈现一超多强的局面,大体上有以下几个较为出名的开源项目:
总的来看,这些框架在设计上,大体上具有几个比较共同的特性:
光说不练假把式,本着实践是检验真理的唯一标准,我周末花了些时间使用 dixous 实现了一个 TodoMVC 应用,感兴趣的同学可以直接点看下面的链接,试着运行项目看看:
从个人的开发体验上来看,由于 Rust 本身的语言设计和 JavaScript 有较大的区别,因此对于熟悉使用 JS/TS 的开发者来说,会有一定的 Gap,比如说状态管理时的区别;至于其他的诸如 JSX 等,由于这些框架都在借鉴 React 等前端框架的设计理念,所以总的来说差异并不大。
至于这些 Rust 前端框架的大体架构,由于大体上都是借鉴了现代前端框架的开发模式,差距并不大,总的来讲都会有一个 html 的宏来负责对 virtual_dom 的处理以及映射,此外还会提供前端路由、状态管理、异步处理等能力,来提升框架的易用性以及能力。这里将以 dioxus 为例其主要构成大体如下:
正如之前所介绍的,dioxus 是基于 react 的风格所开发的前端框架,因此其中很多东西都和 react 生态系统保持了高度的一致:
mod hooks {
mod use_route;
mod use_router;
pub use use_route::*;
pub use use_router::*;
}
pub use hooks::*;
mod components {
mod link;
mod redirect;
mod route;
mod router;
pub use link::*;
pub use redirect::*;
pub use route::*;
pub use router::*;
}
pub use components::*;
#[derive(PartialEq, Eq)]
pub struct Element {
pub name: Ident,
pub key: Option<LitStr>,
pub attributes: Vec<ElementAttrNamed>,
pub children: Vec<BodyNode>,
pub _is_static: bool,
}
至于在跨端部分,dioxus使用的实际还是 Tauri 所提供的 wry 来进行 WebView 的侨接。这块儿在下一部分来讲,再次就不做过多赘述了。
Rust 作为系统级的编程语言,在性能上的优势是毋庸置疑的,swc、postcss-rs 等工具的兴起最大的原因就在于此。譬如postcss-rs 就给出的性能对比:
从上图的直观表现来看,rust 在性能上较之 JS 具有非常大的优势。但这里的性能差距并不能作为讨论的前端应用开发场景下的依据。前面也提到过 Rust 并不能直接运行在浏览器端,需要编译成 WebAssembly 才能运行在浏览器端,所以对比的对象应该是 WebAssembly 和 JavaScript。
而在Wasm方面,我们前面曾提到,虽然 WebAssembly 在性能上相较于 Javascript 有一定优势,但由于无法直接操作DOM,所以并不一定会在前端应用上有很好的表现,这也是很多早期 Wasm 用户所吐槽的点。但这个问题也只是暂时的,Interface Types 计划完全解决这个问题,且随着 WebAssembly 的不断发展,这一情况也会得到改善,我们可以直接使用框架之间的 benchmark 来进行对比(这里现在还不支持 dixous 进行对比,因此选用了 yew、sycamore 以及 wasm-bindgen ):
可以看到,Rust 系的前端框架在常见的DOM操作方面的性能上大多和传统的前端框架没有明显的区别,甚至在一些场景下要优于 Angular 和 React。而在其他方面也表现很不错:
因此在性能上来讲,Rust 系的前端框架还是保持着一个较为不错的表现的,并没有出现所谓的 wasm 在 dom 操作上性能非常差的现象。而对于计算密集的场景,WASM 则要优于 JS,具体讨论可以看下述文章,在此就不再做过多的讨论了:
how-fast-and-efficient-is-wasm。
总的来说,虽然WASM在操作DOM的性能上仍然存在较大的进步空间,但距传统前端框架的性能差距并不大;而在计算密集的场景下,则会有一定的优势。
基于 Rust 开发的应用在安全方面无疑也是具有竞争力的。首先 Rust 本身由于所有权系统以及生命周期来实现内存管理,没有运行时的GC,加之强大的编译器的代码检查,都为构建构建内存安全的应用提供了坚实的保障。
而对于 WebAssembly 来说,正如它自身的文档所说的,WebAssembly 的安全模型主要有两个目标:
首先在内存方面 WebAssembly 只提供一个沙盒化的线性内存(linear memory),致使其对内存的访问十分有限,只能对这个线性内存进行读写以及扩缩容,无法对其他内存进行操作。这虽然损失了一定的便利性,但在内存安全方面也提供了一些保障,对于 WASM 内存安全的详细介绍,可以参考下列文章:
https://hacks.mozilla.org/2017/07/memory-in-webassembly-and-why-its-safer-than-you-think/
而在控制访问方面。WASM 也做的非常的好:WebAssembly 代码本身是在一个由虚拟机管理的沙盒中封闭运行的,这让它与主机是相互隔离的,无法与主机直接进行交互。在这种情况下,如果想实现对系统资源的访问就只能通过虚拟机所提供的 WebAssembly 系统接口(WASI)来进行。而WASI 提供了基于能力的安全模型(Capability-based security),遵循最小权限原则,譬如在进行指定文件等资源的访问时,需要显示的在外部传入加有权限的文件描述符的引用,对于其他未授权的资源是无法访问的,这种依赖注入的方式可以避免很多传统安全模型的潜在风险。
总的来说,在安全方面 Rust + WebAssembly 的组合能帮助我们写出更加安全的应用,为用户的安全保驾护航。
综上所述,Rust 开发前端应用开发主要是通过将代码编译为 WebAssembly 从而实现在浏览器端运行甚至是跨端的目的。但需要注意的是,站在2022年的来看,这种开发模式仍然还是稚嫩的,存在不少的问题:
也正是由于以上原因,以 Rust 为主力语言进行前端应用开发的方式并没有取得很多的落地。取得落地的反而是一些混合应用,比较典型的就是 Figma:将部分计算密集或者是业务逻辑复杂的模块使用 Rust/Cpp 进行开发,在这些模块内实现计算逻辑,对外暴露出计算结果/指令,然后通过 canvas 去对页面进行绘制,这样可以有效的缓解 WebAssembly 操作 DOM 所带来的性能损耗从而保障性能。至于整体都使用 Rust 进行开发,我想还需要 WebAssembly 得到足够的普及以及得到更好的发展,才能实现。
好了,本文对 Rust 进行前端应用开发进行了一个简单的介绍,下文将开始介绍号称 Electron 杀手的 Tauri,看它是如果利用我们在本文做介绍的这些基础能力,成为当今炙手可热的客户端应用开发方案的。
参考资料
要想搞明白什么是浏览器的多进程,首先得知道什么是进程。按照维基百科的说法:
进程是计算机中已运行程序的实体。进程是线程的容器,进程本身不运行。程序本身只是指令的集合,进程才是程序(指令)的真正运行。每个程序可以有多个进程,每个进程都有自己的资源。
简单来讲,进程就是CPU资源分配的最小单位,而线程则是CPU调度的最小单位。那什么又是单线程和多线程呢,我们来看一小段代码:
var a = 1 + 10086
var b = 100 * 2
var c = (20 + 1) * 2
var d = 100/10
var e = a + b + c + d
console.log(e)
譬如上面的代码,如果是在单线程的运行环境比如JavaScript,就会需要将上面的计算一个个的去执行完成,然后得出运行结果,也就需要进行六步才能将e打印出来,但如果是在多线程的运行环境中则只需要使用四个线程来同时计算上面的四个运算,待上面的四个运算全部完成后再把他们相加,然后再打印出来。因此使用多线程的并行运算可以大大的提高程序的性能以及效率。
但虽然多线程可以有效的提高程序的运行效率,但它是不能单独存在的,它需要进程的启动与管理。简单来说,进程与线程之间会存在以下四种关系:
现在我们都知道浏览器都是多进程的,但其实回顾历史发展的历程,浏览器也经历了一个由单进程到多进程的发展历程。现在就让我们来理一理浏览器单线程到多线程的发展历程。
2007年之前,所有的浏览器都是单进程的,其中的典型代表就是IE6了。在ID6的时代,页面还是单标签的,一个页面一个窗口,一个窗口一个主线程。因此顾名思义,单进程的浏览器就是指打开一个浏览器,其中包含一个页面,而这个页面的所有的功能模块都运行在同一个进程里,这个模块包括但不限于:网络、渲染引擎、JavaScript运行环境、第三方插件等。具体架构如下图所示:
基于这种情况,单进程的浏览器就存在以下一些问题:
早期的浏览器都是通过各式各样的插件来实现诸如视频、游戏等功能的,而插件本身又很不靠谱,由于插件运行在浏览器进程之中,因此一个插件的意外崩溃就会导致整个浏览器的奔溃。同样的,渲染引擎也是这个道理,往往一个JavaScript的bu就可能导致整个页面崩溃。
因为所有页面的JavaScript线程、渲染模块、以及第三方插件都运行在同一个线程之中,因此同一时刻只有一个模块可以执行,这就很有可能出现一个模块发生阻塞的时候而导致其他模块无法运行的情况。此外当时国产的浏览器其实都是基于IE6来进行二次开发的,因此这些国产浏览器虽然基于自身需要,都采用的多标签页的形式,但这些多标签页其实也是跑在同一个线程里的,这就会导致其中一个标签页的卡顿会影响到整个浏览器。
不安全主要是处于两个方面的,一个是插件一个是页面脚本。页面中运行的插件可以读取电脑的资源,执行一些命令。而页面脚本则可以通过浏览器漏洞来获取系统权限,从而应发一系列安全问题
终于随着 Chrome 浏览器的发布,浏览器架构终于来到了多进程的时代。其中 Chrome 浏览器的进程架构如下:
其主要作用如下:
然后基于以上的架构,我们来看看他们是如何解决单进程浏览器所存在的问题的:
首先浏览器渲染进程本省就是分离开来的,每个Tab也面都是一个单独的进程,互不影响。其次将第三方插件进程也单独拎了出来,这样就算一个页面的插件出现可问题,也不会影响到这个页面的渲染进程,也就不会对浏览器造成影响了。
在多进程的架构下,JavaScript 只是运行在自己的渲染进程中的,因此即使 JavaScript 代码阻塞了渲染进程,受到影响的也只是当前所渲染的页面。脚本运行也是同样的道理。而对于常常引发性能问题的内存泄漏,在这种架构下,关闭一个页面,会将整个渲染进程给关闭,这时候操作系统就会回收这个进程所占用的内存,也就会不会存在内存泄漏的问题了。
采用多金策的架构的一个好处就是可以使用安全沙盒,沙盒通常严格控制其中的程序所能访问的资源,比如,沙盒可以提供用后即回收的磁盘及内存空间。在沙盒中,网络访问、对真实系统的访问、对输入设备的读取通常被禁止或是严格限制。而charome浏览器就将插件进程以及渲染进程所在了沙盒之中,这样即使插件进程以及渲染进程有恶意程序在执行,也无法突破沙盒去获取系统权限,对我们的电脑造成影响。
虽然现在的多进程的浏览器看起来很美好,解决了原先单进程浏览器所存在的诸多的问题,但同样不可避免的存在着一些问题:
为了解决现在浏览器所存在的资源占用高,体系更为复杂的问题,2016年 Chrome 团队就开始使用 “面向服务的架构”(SOA)的**来设计新的 Chrome 架构。
那什么是SOA呢,简单来说SOA就是一种组件模型,他将应用程序的不同功能单元通过这些服务之间所定义好的接口或者契约联系起来。接口采用中立的方式来进行定义,独立于硬件平台、操作系统以及编程语言。这使得构建各种各样的系统中的服务都可以以一种统一和通用的方式来进行交互。
也就是说, Chrome要做的就是将UI进程、设备、文件、Audio等等模块都编程基础服务,每个服务都可以在独立的进程中运行,而访问这些服务也必须使用定义好的接口,并通过IPC来进行通信。从而构建一个更内聚、松耦合、更易维护和扩展的系统。
用了TypeScript挺久了,工具链这边显著的特点就是不少模块由于业务原因亦或者是出于其他的一些考虑,后端所传输过来的数据很复杂,因此处理起来需要小心翼翼的,稍有不慎就会出现TypeError,因此之前在团队内部有做过一个小的分享,即如何使用TS让自己的数据处理更安全。这里将之前的PPT内容写成文章,此外也去除一些与具体业务有关的东西,方便后续补充以及沉淀~(话说PPT迷之找不到了,只剩下一些截图来和思维导图了)
interface IDataSet {
name: string
id: number
type: string
sampleImages: {
imagePath: ''
list: []
algoType: ''
}
}
const transformData = (data: IProps) => {
Object.keys(data).map(item => {
if(item === 'sampleImege') {
// ...做些小操作
}
// ...code
})
}
上面一段代码乍一看没啥问题,但其实永远也无法执行到小操作那里去,因为 sampleImages
这个单词我们错误拼写成了 sampleImege
,而类似的拼写错误也正是我们经常容易所忽视的,因此正确的做法是使用 keyof
,来保护我们的粗心大意:
type dataset = keyof IDataSet
const transformData = (data: IDataSet) => {
Object.keys(data).map((item: dataset) => {
if(item === 'sampleImage') {
return 'haha'
}
})
}
这样就可以写代码的时候获得相关提示:
(实际上在使用vscode的时候,只要打出了那个空字符串,就会提示有哪些可选的参数了,完全不用自己再去打一遍,十分方便快捷且安全)
同时,在获取对象的值的时候,我们也可以使用keyof来保护我们的代码,还是直接是用我们上面的 IDataSet
为例,这样写我们完成发现不了问题:
const getData = (data: IProps) => {
if(data['ids'] === 10086) {
...
}
}
这个时候我们就可以使用 由keyof
所实现的 get
来武装我们的代码,让我们的代码变得更安全:
function get<T extends object, K extends keyof T>(o: T, name: K): T[K] {
return o[name]
}
const getData = (data: IDataSet) => {
if(get(data, 'ids')) {
// ...code
}
}
随着 react 最新的一个大版本中,给我们带来了 Hooks:React v16.8: The One With Hooks,从而将 Function component 的能力提高了一大截,成功的拥有了可以与 Class component 抗衡的能力。但话说回来,虽然 Hooks 看起来很美好,最近也有不少文章都讲解了Hooks这一“黑魔法”,但技术的不断演进,本身就是一个解决以往所存在问题的过程,因此我个人认为着眼于现在,回望过去,去看一看 react component 的发展之路,去看看 Class component 以及 Function component 为什么会出现以及它们出现的意义,所要解决的问题,也对于我们全面了解 react 是很有帮助的。
从 react component 的发展历程上来看,它主要是经历了一下三个阶段:
这个三个阶段也是react的组件不断走向轻量级的一个过程。其中 Class Component 完全替代了 createClass Component 成为了现在我们开发 react 组件的主流,而 Function Component 也在 Hooks 推出后磨刀霍霍,准备大干一场。下面就让我们去看看三者的具体情况吧~
注:这篇文章整体只是对React Component的发展历程的一个概括或者说是我自己学习后的一个整理,想要详细了解,还请看看我在文章贴的那些链接。
说实话,createClass Component 我也没用过,因为我接触到 react 的时候已经是2017年下半年了,那时候 ES6 已经大行其道,class component 也已经完全取代了 createClass Component。但现在看来 createClass Component 的语法也很简单,并不复杂:
import React from 'react'
const MyComponent = React.createClass({
// 通过proTypes对象和getDefaultProps()方法来设置和获取props
propTypes: {
name: React.PropTypes.string
},
getDefaultProps() {
return {
}
},
// 通过getInitialState()方法返回一个包含初始值的对象
getInitialState(){
return {
sayHello: 'Hello Srtian'
}
}
render() {
return (
<p></p>
)
}
})
export default MyComponent
react.createClass的语法并不复杂,它通过 createClass 来创建一个组件,并通过propTypes和getDefaultProps来获取props,通过通过getInitialState()方法返回一个包含初始值的对象,虽然从现在看来还是有点麻烦,但总体上来看代码也比较清晰,跟现在的 Class Component差别并不是太大。但 react.createClass 自从 react 15.5版本就不再为 react 官方所推介,而是想让大家的使用 class component 来代替它。而且在 react 16版本发布后,createClass 更是被废弃,当我们使用它的时候,会提示报错,也就是说,在 react 团队看来 createClass 已经完全没有存在的必要了。
其实 Class Component 完全替代 React.createClass 并不是说 React.createClass 有多坏,相反它还有一些 class Component 所没有的特性。它的废弃是由于ES6的出现,新增了 class 这一语法糖,让我们在 JavaScript 的开发中可以直接使用 extends 来扩展我们的对象,因此为了与标准的ES6接轨,原有的只在 react 中使用的 createClass 自然而然也成为了被抛弃的对象。但 class Component 在刚出现的时候也仍然存在的不小的争议,因为这两者还是存在一定的差别的,比如当时在Stack Overflow便出现了关于这两者的讨论,感兴趣的朋友可以去看看:
https://stackoverflow.com/questions/30668464/react-component-vs-react-createclass
总的来说,除了语法上存在差异外,Class Component 和 React.createClass 的区别主要是以下两点(详情可以看看上面的回答):
Class Component创建的方式也很简单,就是普通的ES6的class的语法,通过extends来创建一个新的对象来创建react组件,下面是使用class Component创建一个组件的例子(由于为了给后面聊一聊hooks,所以在这里我使用了antd的例子)
class Modal extends React.Component {
state = { visible: false }
showModal = () => {
this.setState({
visible: true,
});
}
handleOk = (e) => {
console.log(e);
this.setState({
visible: false,
});
}
handleCancel = (e) => {
console.log(e);
this.setState({
visible: false,
});
}
render() {
return (
<div>
<Button type="primary" onClick={this.showModal}>
Open Modal
</Button>
<Modal
title="Basic Modal"
visible={this.state.visible}
onOk={this.handleOk}
onCancel={this.handleCancel}
>
<p>this is a modal</p>
</Modal>
</div>
);
}
}
上面就是antd中一个简单的 modal 组件的例子,其内部就是通过维护 visible 的状态来控制这个 modal 是否显示。我们可以看到,其中的一些方法都是使用箭头函数的方式来将 this 绑定到正确的属性。(具体为什么要这么做,不清楚的朋友可以看看下面这篇文章:)
而类似于上面的这种组件,也是近两年来我们在日常开发中使用最多的组件开发的方式。那为什么到了现在,我们又开始要强调使用 Function Component 来进行开发了呢?主要是由于 Class Component 所开发的组件仍然存在以下一些问题:
<FirstContext.Consumer>
{first => (
<SecondContext.Consumer>
{second => (
<ThirdContext.Consumer>
{third => (
<Component />
)}
</ThirdContext.Consumer>
)}
</SecondContext.Consumer>
)}
</FirstContext.Consumer>
详细可以去看看知乎上的这个回答:https://www.zhihu.com/question/300049718
看到这里,大家对class Component所存在的一些问题也算是有一些了解了,但为什么它还能横行如此之久,一直占据着主流的地位呢?其本质上就是因为没有竞争对手嘛,Function Component 长期没有内部状态管理机制,只能通过外部来管理状态,因此组件的可测试性非常的高,写起来也简洁明了,符合现在前端函数式的大潮流,是个好同志。但也正是因为没有状态管理机制,所以无法和Class Component相抗衡,毕竟一旦组件内部的逻辑变得复杂之后,内部的状态管理机制是必须的。
因此 React 团队基于 Function Component 提出 Hooks 的概念,用以解决 Function Component 的内部状态管理,同时也希望通过 Hooks 来解决 Class Component 所存在的问题。下面就是使用 Hooks 针对 antd 中的 modal 进行的改写,大家可以自行感受一下:
const Modal = () => {
const [visible , changeVisible] = useState(false)
return (
<div>
<Button type="primary" onClick={()=>changeVisible(true)}>open</Button>
<Modal
title="Basic Modal"
visible={visible}
onOk={()=>changeVisible(false)}
onCancel={()=>changeVisible(false)}
>
<p>this is a modal</p>
</Modal>
</div>
)
}
我们可以看到,基于 Function Component 与 Hooks 所编写出来的组件代码是相当简洁明了的,也直接避免了我们上面所提到的 this 指向的问题。而对于上面所提到的嵌套地狱以及 Life-cycles 的问题,Hooks也提供了 useContext 和 useEffect(这个倒还是存在一些问题) 来解决,在这里我也不详细说了,详情可以去看官方文档或者是 Dan 的博客:
好了,看到这里我想大家都以为上面 Class Component 的问题都已经得到圆满解决了,Function Component好像已经圆满了,我们只管放心的使用它就好了。但世界上哪有这么好的事情,Function Component 仍然存在着下面几个 tip 是我们在使用前要知道的:
https://overreacted.io/how-are-function-components-different-from-classes/
参考资料:
通常来讲,所有动态创建的对象都会被非配到堆内存中,但由于堆内存本身存在大小限制,因此当我们创建的对象的大小到达一定的数量时,就会出现内存不足而导致程序报错的问题;这时,我们就需要通过将那些被程序所不能访问到的对象所占用的内存进行释放,从而获得新的可用空间。对于内存的管理,现在市面上大致有以下三种方案:
这样的困惑在于:大多数的文章都是在告诉大家 V8 怎么做(How),但并没有去解释:这些东西是什么(What)以及为何要这样 (Why)。因此,本文将尝试从 标记清除算法开始去分析:标记清除算法本身是什么以及存在什么问题,以及为了解决这些问题又需要引入哪些算法。希望能通过这篇文章,让大家能对 V8 组成的大概架构有所了解,更希望能够通过V8 的设计思路,能对一些经典算法的应用以及设计**有所了解,从而应用到自己的开发中去。
标记清除算法可以说是应用最为广泛的 GC 算法之一,其核心**在于:首先对堆内存进行遍历进行标记,从而区分活动对象与非活动对象(这里需要补充的是活动对象与非活动对象,我们通常讲能被程序所访问到的对象称之为活动对象,反之不能被访问的则是非活动对象;且当一个活动对象转化为非活动对象后,这个过程将不可逆。),然后再进行一次遍历,将标记为非活动对象的内存进行回收,放入空闲链表中,最后在空闲链表中根据一定的信息进行分配内存;伪代码如下:
fn markSweep() {
mark() // mark all live objects, start from the roots
sweep()
}
fn mark() {
for(root in $roots) {
markObj(*root)
}
}
fn markObj(obj) {
if(obj.mark === FALSE) {
obj.mark = TRUE
for(Child in Children(obj)) {
markObj(*child)
}
}
}
fn sweep() {
for(root in $roots) {
if(root.mark === TRUE) {
// If it is found to be a live object
// reset it to FALSE and wait until the next GC
root.mark = FALSE
} else {
freeList.next = root
}
}
}
需要注意的是,我们在这里进行对象遍历时,通常使用的是深度优先搜索;这是因为较之广度优先,两者完成搜索所需的步数并不会有所区别,只取决于需要遍历的对象的数量;但在内存使用方面,深度优先所需的内存量是比广度优先要少的。
Difference between BFS and DFS - GeeksforGeeks
在完成非活动对象的释放后,我们还需要对已回收的垃圾进行再利用;由于我们在清除阶段就已经将垃圾对象链接到空闲链表了,所以进行分配时,我们只需要根据所需要的内存空间去空闲链表中遍历查找合适大小的分块即可。伪代码如下:
fn allocation(size) {
let chunk = pickupChunk(size, $freeList)
if(chunk !== NULL) {
return chunk
} else {
allocationFail()
}
}
fn pickupChunk(size, freeList) {
while(freeList) {
if(freeList.size >= size) {
return freeList.chunk
} else {
freeList = freeList.next
}
}
}
这里我们使用的策略是 First-fit,即发现大于等于 size 的 chunk 时,就立即返回该 chunk,除此之外还有 Best-fit 以及 Worst-fit。其中 Best-fit 是遍历空闲链表,返回大于等于 size 的最小 chunk。而 Worst-fit 则会找到空闲链表中的最大的 chunk,然后将其分割成 mutator 申请的大小和分割后剩余的大小,其目的是将分割后剩余 chunk 最大化,但由于这种方式很容易生成大量小的 chunk,所以一般不推介使用。其流程如下:
上述我们已经对 Mark-Sweeping GC 有了一个简单的了解了,这里我们可以先来讨论一下,Mark-Sweeping GC 的优劣:
优点:
也正是由于 Mark-Sweeping GC 存在以上一些问题,所以实际 V8 在实现内存回收上并没有单纯的使用 Mark-Sweeping GC 来进行内存回收,而是采取了一些非常巧妙的算法来帮助我们提升 GC 的效率。由于我们在标记清除的过程中,需要对整个堆内存去进行遍历,这无疑会增大我们遍历的成本,所以在这种情况下,我们就可以运用经典的算法**:缩小范围,来提升我们算法的运行效率,这也就是我们下一部分的主角:分代垃圾回收(Generational GC)的核心作用。
需要注意的是,具体的标记-清除算法实现,大都不是进行简单的标记以及清除,而是使用了 三色标记-清除,在原有的基础上增加了一种标记(灰色)来实现并行回收、提升效率,在这里就不做过多的介绍了,其核心**并没有太大的区别,感兴趣的同学可以自行了解一下~
分代垃圾回收主要通过引入“年纪”的概念,通过年纪来区分对象,从而优先回收容易成为垃圾的对象,从而提升垃圾回收效率。它基于以下几个考虑因素:
基于以上的考虑,分代垃圾回收会将对象分类为几代,然后针对不同的代使用不同的 GC 算法。通常我们会将刚生成的对象称之为新生代对象,而到了一定年纪(存活过一定时间)的对象成为老生代对象。
在进行了根据年纪的划分后,我们就能根据实际情况,来对内存进行因地制宜的管理了:
而在V8 的实现中,Major GC 主要使用的也正是 Mark-Sweep GC,这里设计的目的就在于:通过对对象进行分类,减少标记-清除所需遍历对象的数量(只需要遍历老生代对象)以及频率,从而减少标记清除所需要的时间。
上文我们提到,对于新生代对象的内存,由于其特性,因此 GC 触发的频率会比较频繁,所以在这种场景下,我们就需要一种足够高效的算法来帮助我们来管理内存了。GC复制算法(Copying GC)就属于这种算法。
GC复制算法是 Marvin L. Minsky 在 1963 年研究出来的算法,距今已有半个世纪,但其核心**仍然广泛的用于各个方面;简单来讲,就是将内存空间分为相等的两块区域,一块区域是对象空间(From),另一块区域则是空闲区域;当我们需要对新生代对象进行回收时,其实就是将对象区域的所有活动对象转移到空闲区域,而在转移完成后,对象区域剩余的则就可以视为垃圾回收掉,最后再将 From 和 To 进行反转,这样原有的空闲区域就变成新的对象区域可以继续使用了。具体步骤大致如下:
需要注意的是,第二阶段的 Garbage 并不是连续的,这里我只是为了方便画图以及理解,所以放在一块儿,实际情况下非活动对象是散落在 From 中的。另一方面,这里没有表现出我们在分代垃圾回收所提到的“晋升”(为了减少不必要的信息的干扰)。
而在分配阶段,Copying GC 也和 Mark-Sweeping GC 存在不同,前面我们有介绍,Mark-Sweeping GC 在分配内存时,大致使用的方式是:通过需要的内存大小,去遍历空闲链表,在找到满足条件的 chunk 后,进行返回。这样做的方式存在的问题在于:在最坏的情况下,我们可能需要遍历完整个空闲链表才能找到合适的空间。而 Copying GC 则不存在这种问题,如上图所示,当我们完成复制已经清理工作后,内存所摆放的位置将会变得有序,而空闲的空间也是连续的,因此我们只需要直接根据所申请的内存大小分配即可。因此较之 Mark-Sweeping GC 需要遍历空闲链表才能完成内存分配,Copying GC 的分配要简单的多,不需要进行遍历,可以理解为:O(n) 到 O(1) 的提升。
以上便是 Copying GC 的大致流程了,我们再来小结一下,Copying GC 的一些优缺点:
优点在于:
缺点则是:
综上所述,Copying GC 在效率上较之 Mark-Sweeping GC 会高上不少,但在内存使用率上还是存在较大缺陷,因此在V8的实践上,也并没有将其作为主要内存回收方式,而是主要用于新生代内存的管理,而且在内存分配上,新生代的内存空间也不大(空间越小,节省的空间越大),因此在 V8 中还隐含着一个条件:如果一个对象足够大时,会直接晋升到老生代中。而之前我们提到,老生代中使用的是 Mark-Sweeping GC,虽然我们使用了分代垃圾回收来减少 Mark-Sweeping GC 所需管理的对象的数量以及进行 GC 的频率,但仍然有个问题还没有解决,那就是碎片化的问题。要解决这个问题就可以引出我们的下一位主角:GC标记-压缩算法 了。
Mark Compact GC 其实是 Mark-Sweeping GC 和 Copying GC 的融合体。顾名思义,Mark-Compact 也是由 Mark 和 Compact 两个阶段组成的,这里的 Mark 阶段其实和我们之前所说的,遍历堆内存进行标记是一样的,不一样的是,然后,我们在堆上移动活动对象,目的是压缩堆,以便所有活动对象都位于堆的开头,而在这些对象之后的所有内存都可以分配。因此 Mark-Compact 解决了碎片问题,通常以内存开销和更长的运行时间为代价。而具体的 Compact 算法有很多种,比如:
在这里简单的介绍一些 Lisp 2 collector,先来介绍一些它的特性:
接下来,就让我们用伪代码来介绍一下这种算法吧。首先在调用上,正如上面所说的,它和 Mark-Sweeping 并无太大区别,分为两个阶段:
fn markCompact() {
mark() // mark all live objects, start from the roots
compact() // compact the heap
}
其中 mack 阶段之前并无什么区别,因此在这里就不再赘述了,而 compact 阶段则需要三次遍历堆内存,因此可以分为三部分:
fn compact() {
setForwardingPtr() // compute the forwarding address
adjustPtr() // update the roots and references
moveObj() // move the objects
}
在第一部中,我们会搜索整个堆内存,并给活动对象加上 forwarding 指针(初始情况下,我们的 forwarding 指针为 nul),大致步骤如下:
fn setForwardingPtr() {
let scan = $heapStart // a ptr to search for objects in the heap
let newAdress = $heapStart // a ptr to the target location
while(scan < $heapEnd) {
// set the pte to newAddress
// newAddress moves according to the object length
if(scan.mark == TRUE) {
scan.forwarding = newAddress
newAddress += scan.size
}
scan += scan.size
}
}
这里我们需要使用 forwarding 指针来记录空间的原因在于:Copying GC 会划分出 From 空间以及 To 空间,因此我们在移动的对象时,不用去关心对象覆盖的问题,而在 Mark-Compact 中,我们是在同一个空间实现对象的压缩的,因此可能会存在移动前对象会被覆盖的问题,因此在进行移动前,我们需要事先将各对象的指针全部更新到预计要移动的地址。这样当我们要进行移动操作时,只要顺序的去移动活动对象即可。因此接着我们就需要更新指针:
fn adjustPtr() {
// rewrite the pointer
for(root in $roots) {
*root = *root.forwarding
}
scan = $heapStart
// Rewrite the pointers of live obj
while(scan < $heapEnd) {
if(scan.mark == TRUE) {
for(child in children(scan)) {
*child = *child.forwarding
}
scan += scan.size
}
}
}
经过上述的步骤,我们就进行了指针的移动,最后我们就可以搜索整个堆内存,将活动对象移动到 forwarding 指针所指向的地址,从而实现活动对象的“压缩”:
fn moveObj() {
let scan = $heapStart
while(scan < $heapEnd) {
if(scan.mark == TRUE) {
let newAddress = scan.forwarding
move(newAddress, scan) // move the object itself there
newAddress.mark = FALSE // unmark it, to prepare for the next invocation of compact
}
scan += scan.size
}
}
优点:
缺点:
自此,我们就大体的粗缆了V8 GC 的算法部分的重要组成,并对这些算法进行了一定的介绍,相信看到这里你也会对其设计的思路有了一个大体的理解,对于这些算法,我的理解是:如果没有遇到相关问题时,只需要理解其算法**以及解决一些问题的思路即可,这才是这些算法的精华所在,譬如:
因此还是希望能从中吸取一些对自己开发能有用的东西吧,最后如果有同学对垃圾回收算法有兴趣的同学,推荐一本关于垃圾回收的书籍,相信看完后会对市面上常见的 GC 算法都有一些了解:《垃圾回收的算法与实践》
参考资料
一个JavaScript引擎会常驻内存中,它等待着我们把JavaScript代码或者函数传递给它。
按照JSC引擎术语,我们把宿主发起的任务称为宏观任务,将JavaScript引擎发起的任务称为微观任务。
JavaScript 引擎等待宿主环境分配宏观任务,在操作系统中,通常等待的行为都是一个事件循环,因此在 Node 术语中,也会把这个部分称为事件循环。
每次代码的执行过程,其实就是一个宏观任务,因此我们可以理解为:宏观任务的队列相当于事件循环。而在宏观任务中,JavaScript的Promise还会产生异步代码,而JS必须保证这些异步代码在一个宏观任务中完成,因此每个宏观任务都包含一个微观任务队列。
在有了宏观任务和微观任务机制,我们就可以实现 JS 引擎级和宿主级的任务了,例如:Promise 永远在队列尾部添加微观任务。setTimeout 等宿主 API,则会添加宏观任务
Promise的总体**是:需要进行io,等待或者其他异步操作的函数,不反回真实结果,而是返回一个承诺,函数的调用方可以在合适的实际,选择等待这个承诺的兑现。
async/await 是 ES2016 新加入的特性,它提供了用 for、if 等代码结构来编写异步的方式。它的运行时基础是 Promise。
闭包的英文单词是closure,在不同领域有这不同含义:
闭包可以简单理解为一个绑定了执行环境的函数,和普通函数的区别是,它携带了执行的环境
简单类比:人在外太空需要携带吸氧设备
JavaScript 标准把一段代码,执行所需的所有信息定义为“执行上下文”。
执行上下文在ES3中包括三个部分:
在ES5中,改进了命名方式:
ES2018中,this值被归入 lexical environment ,但增加了不少内容:
// 1.普通函数
function foo(){
// code
}
// 2。箭头函数
const foo = () => {
// code
}
// 3.方法:在class中定义的函数
class C {
foo(){
//code
}
}
// 4.生成器函数,用function*定义的函数
function* foo(){
// code
}
// 5. 类:用class定义的类,实际上也是函数
class Foo {
constructor(){
//code
}
}
// 6,7,8 异步函数
async function foo(){
// code
}
const foo = async () => {
// code
}
async function foo*(){
// code
}
对于普通变量来说买这些函数没有本质上的区别名都遵循“继承定义时环境”的原则,他们的一个行为差异在this关键字。
this是JavaScript的关键字,是执行上下文中很重要的一部分,同一个函数抵用方式不同,得到的this的值也不同,简单来讲就是:调用函数时使用的引用,决定函数执行时的this的值。
普通语句执行后,会得到 [[type]] 为 normal 的 Completion Record,JavaScript 引擎遇到这样的 Completion Record,会继续执行下一条语句。这些语句中,只有表达式语句会产生 [[value]],当然,从引擎控制的角度,这个 value 并没有什么用处
语句块就是拿大括号括起来的一组语句,它是一种语句的复合结构,可以嵌套
任何 JavaScript 语句是可以加标签的,在语句前加冒号即可
firstStatement: var i = 1;
做了一次React Fiber架构的分享Fiber架构分享.pptx,这是文字版的。
React 15 至 React 16 发生的一个主要的变化是,从原先的 Stack Reconciler 转为了 Fiber Reconciler。那为什么React 团队要进行这样的更改呢?先让我们看一下 Stack Reconciler 所存在的问题。
在Stack Reconciler 中,进行渲染时,父组件调用子组件,我们可以类比为函数递归。当组件进行 diff 后,会以递归的方式去深度优先的遍历整个组件树,从而达到将整个组件树全部计算和渲染的目的:
但这样做就会有一个问题,我们都知道浏览器给予用户一个不卡顿的体验,一般需要保证1秒60祯的刷新频率,换算下来,就是浏览器的每一帧生成的时间应该是在 16ms 左右。而大部分垃圾时间(janky),我们都可以归类于:同步任务所占用的CPU时间(主要是 JS 运算)+ 原始DOM更新。而Fiber的出现,主要所要解决的就是同步任务所占用的 CPU 时间(JS运算等等),其解决的方式是,将一个大块的同步任务(也就是我们上面所说的递归的去diff和计算虚拟DOM树),拆分成一个个小的同步任务,然后在每一个时间切片之间,去判断是否存在一些优先级更高的的事情,如果存在就优先去处理优先级高的任务,从而保证一次渲染计算不会占用太长的CPU运算的时间,让后续的 layout 和 paint 的时间是足够的:
对于Fiber架构的整体构成,我个人理解,主要可以分为以下两个部分:
其中Fiber调度算法,主要是用于任务优先级的确认(即高优先级的任务先指向,低优先级的任务的恢复亦或者直接放弃) 。而Fiber节点,则是一种数据结构,我们可以理解为一个对象,它是有虚拟DOM生成,用于存储一个渲染单位的一些基本信息,是Fiber实现的基石,其主要属性如下:
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// 静态数据结构属性
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
this.ref = null;
// 位置属性
this.return = null;
this.child = null;
this.sibling = null;
// 状态属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 记录 Effect 的属性
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优化优先级
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 用于实现双缓存的属性
this.alternate = null;
}
React Fiber下的组件创建与更新,其本质上就是去构建一个由多个Fiber 节点相连组成的 Fiber 节点树的过程。而创建和更新 Fiber 节点树,React又将其分为了两个阶段去完成:
React在Render阶段,主要做的一个事情是生成一个用于更新的 Fiber节点树,而在这个过程中,React不会做任何有副作用的事情,只会对副作用去做一个收集,等到Commit阶段再去做。这主要是由于我们上面所说的,React 的更新不再是一次性完成的了,而是可能会进行多次反复横跳,也就是说在Render阶段做的事情,可能会由于优先级的问题被执行多次。而具有副作用的一些操作,通常不是幂等的,如果被执行多次,可能会产生开发者预料不到的问题。而Commit阶段,则不会执行多次,因此React会将副作用操作进行收集,统一到Commit去完成,具体实现步骤可以看如下的思维导图:
具体总结就是在Render阶段会做两个事情:
在得到最后所需要渲染的 WorkInProgress 树后,React会进入Commit阶段,在这个阶段React主要会做以下一些事情:
而它内部将Commit也分成了三个不同的阶段:
主要做的事情如下思维导图:
(这里有一个小的Tip需要注意,Hook中的 useEffect 是在Commit阶段之后执行的,也就是说,实际上它调用的时机会比所有的生命周期函数都要晚)
首先就是Fiber的实现过于复杂,导致代码量以及源码的复杂度都直线上升,一方面增加了React的包的大小(相较于Vue),另一方面是让React源码的复杂度提升,更具有黑盒效益了。
另一方是,React Fiber所带来的收益往往没有我们想的那么大。具体的一些论述,可以去看尤大在Github上的一个回答,很精彩:
参考资料:
网页的易用性最重要的一点就是无需用户去思考。这也代表着交互一般劲量趋向于简单化,平整化,以减少用户的认知负担,减少对用户的干扰。
示例:亚马逊搜索没有提及一定要去搜索什么内容,只会对你所输入的进行分析,这样就会减少用户的思考的成本(淘宝也是如此,通过自然语言处理来对用户的输入进行分析)
比较典型的地方:
根据我自身使用web的时候,突然觉得这个很有道理。往往当我们在web上进行浏览的时候,只会对我们所关注的信息进行仔细的观察。也就是说,我们在访问一个网页时,会首先对整个网页进行一次扫描,在这个扫描的过程中,我们会对我们感兴趣的知识进行提取,而那些我们在扫描过程中不是很在意的东西就会进行忽略。
为什么扫描:
也真是由于用户只是扫描页面,因此其实用户并不会去真正的寻找最佳结构,而是满意即可,即选择第一个合理的选项。(我们在作出决策也是如此,只会寻找第一个合理的决策就可能去执行)
正是由于我们是扫描页面的,因此要采取以下五个原则:
一个视觉层次清楚的页面有三个特点:
首先他们很重要,正是由于习惯、所以符合用户的习惯。但由于设计师们需要造轮子来证明自己(其实工程师也是这样),所以他们一般不想直接使用习惯用法,而是设计出属于自己的一套方法论。
这可以让用户快速决定关注页面的那些位置(即重点扫描的区域)
颜色不同、质感不同等方式。别让用户到处去找
页面的难以理解最大的原因就在于视觉噪声,视觉噪声大致分为两大类:
一般来说用户在达到目标前所需要的点击次数不应过多(很多网站规定点击次数不应该超过5次)(通常来说网页越平整化越好)
注:三次无需思考,明确无误的点击相当与一次需要思考的地点击
去掉每个页面上一半的文字,然后把剩下的文字再去掉一般 —— krug可用性第三定律
去掉多余文字的好处:
那么我们可以做下面一些事情:
如果网页上让大家找不到方向,人们就不会使用你的网站。
那么重要的事情首先要做的事就是让相关的事物集中在一块,这样当用户在浏览网页的时候,就能快速定位自己所需要找的内容的大体位置。
我们浏览一个网页的过程:
导航的作用:
重要的是由这两者元素:主页和表单
此外要注意的是要保证,无论用户到哪都给他一个可以直接返回主页的按钮,这可以给用户安全感,不会迷失方向。
此外我们还有以下几点需要避免的:
页面名称需要注意的:
除了主页面的导航外,还有就是使用层级导航来帮助用户导航。关于层级导航有以下几点最佳实践:
标签的好处:
标签的绘制方法:
所谓的后备箱测试指的是页面设计良好,我们就能快速的答出以下的一些问题:
降低用户好感的方式:
提高好感的几种方式:
WebGL (Web Graphics Library) is a JavaScript API for rendering interactive 3D and 2D graphics within any compatible web browser without the use of plug-ins. WebGL does so by introducing an API that closely conforms to OpenGL ES 2.0 that can be used in HTML5 canvas elements. --MDN
以上是MDN对于WebGL的描述,简单来说,WebGL 就是一组基于 JavaScript 语言的图形规范,浏览器厂商按照这组规范进行实现,为 Web 开发者提供一套3D图形相关的 API。
我们可以通过这些API直接使用JavaScript直接和GPU进行通信,从而实现一些非常炫酷的图形。而webGL是在GPU上运行的,因此我们需要使用能够在GPU上运行的代码,首先我们需要一种叫做GLSL的语言,它是一种和C or CPP类似的强类型的语言,所以写起来很麻烦(这也是很多人吐槽WebGL的一个方面),其次这样的代码需要提供成对的方法,每对方法中,一个叫做顶点着色器,一个叫做片段着色器,这样的每一对组合起来就称作一个program(着色程序)。其中顶点着色器的作用是计算顶点的位置,根据计算出来的一系列的顶点的位置,WebGL就可以对点、线以及三角形在内的一些图元进行光栅化处理。当对这些图元进行光栅化处理的时候就需要使用片段着色器方法了,它的作用是计算出当前绘制图元中的每个像素的颜色值。
上面我们提到了GLSL,其中文的意思是OpenGL着色语言,它是用来在 OpenGL 编写着色器程序的语言,全称为 OpenGL Shading Language。而着色器程序则是在GPU上运行的简短的程序,代替了GPU固定渲染管线的一部分,使GPU渲染过程中的某些部分允许开发者通过编程进行控制。
而GPU渲染过程中具体允许我们对其进行控制的部分有以下几个方面:
上面对WebGL的基本情况进行了一个简单的概述,但好像也没有解答webGL将3D模型显示到屏幕上的工作原理及流程。其实这个过程就好比富士康工作流水一样,按照既定的工作流程来对原材料进行加工,从而生产出完整的产品。WebGL大致也是如此,按照工作流水线的方式,将3D的模型数据渲染到2D屏幕上的,这个渲染方式的过程一般被称之为图形管线或者渲染管线。
上面我们又说到过点、线、三角形这些基本图元,但我们经常看见很多通过WebGL所绘制出来的诸如球体、圆柱、各式的立方体等模型,也看见了很多炫酷、复杂的模型,很显然这些并不属于这些基本图元里面,但其实这些模型本质上都是有一个个顶点组成的,GPU将这些点用三角线图元绘制成一个个微小的小平面,然后通过这些小平面的互相连接,来组成各种各样的的立体模型。因此通常来说,我们首先要做的就是创建组成模型的顶点数据。
一般情况下,最初的顶点坐标是相对于模型中心的,我们需要对顶点坐标按照一系列步骤执行模型转换、视图转换、投影转换,在通过这一系列的转换后的坐标叫做裁剪空间坐标,这个坐标才是WebGL可以接受的坐标。我们把最后的变换矩阵和原始顶点坐标传递给GPU,GPU的渲染管线然后对他们执行流水工作,主要过程如下:
上面将WebGL的大致情况进行了描述,下面就是真刀实枪的来搞事情了,和Three.js一样(这是废话。),我们在使用WebGL进行开发的时候首先需要使用canvas,我们可以再HTML文件里的这样声明一个canvas。顺便对浏览器对canvas的支持情况进行一个检查:
<body onload="main()">
<canvas id="glcanvas" width="640" height="480">
Your browser doesn't appear to support the HTML5 <code><canvas></code> element.
</canvas>
</body>
webGL应用主要包含两个要素:JavaScript程序和着色器程序。首先让我们来准备着色器程序,使用GLSL编写顶点着色器和片元着色器。
顶点着色器的任务我们在上面已经说了,它主要是告诉GPU我们所要形成的图形在裁剪坐标系的位置,下面这个代码就是告诉GPU我们需要在裁剪坐标系的原点,即屏幕中心画一个大小为20的点:
void main(){
//声明顶点位置
gl_Position = vec4(0.0, 0.0, 0.0, 1.0)
//声明所需绘制的点的大小
gl_PointSize = 20.0
}
当顶点着色器中的数据经过图元装配和光栅化之后,来到了片元着色器,从而通过片元着色器将像素渲染成我们所需要的颜色:
void main(){
//设置像素的填充颜色为红色。
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0)
}
在这里,gl_Position、gl_PointSize、gl_FragColor 是 GLSL 的内置属性:
然后我们就可以着手写我们的JavaScript部分的代码了,首先我们需要获取webGL的绘图环境:
const canvas = document.querySelector('#canvas')
const gl = canvas.getContext('webgl')
然后创建顶点着色器:
// 获取顶点着色器源码
const vertexShaderSource = document.querySelector('#vertexShader').innerHTML
// 创建顶点着色器对象
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
// 将源码分配给顶点着色器对象
gl.shaderSource(vertexShader, vertexShaderSource)
// 编译顶点着色器程序
gl.compileShader(vertexShader)
再就是创建片元着色器:
// 获取片元着色器源码
const fragmentShaderSource = document.querySelector('#fragmentShader').innerHTML
// 创建片元着色器程序
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
// 将源码分配给片元着色器对象
gl.shaderSource(fragmentShader, fragmentShaderSource)
// 编译片元着色器
gl.compileShader(fragmentShader)
以上就将我们的着色器对象创建完成了,接下来我们就可以创建着色器程序了:
//创建着色器程序
const program = gl.createProgram()
//将顶点着色器挂载在着色器程序上。
gl.attachShader(program, vertexShader)
//将片元着色器挂载在着色器程序上。
gl.attachShader(program, fragmentShader)
//链接着色器程序
gl.linkProgram(program)
我们在进行webgl开发的时候,可能会在一个WebGL应用里包含多个program,因此我们在使用某狗program绘制前,要先启用它,才能进行绘制:
gl.useProgram(program)
// 绘制
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.POINTS, 0, 1)
如此我们完成了我们的第一个webGL代码了,效果如下:
完整代码如下:
<body onload="main()">
<!-- 顶点着色器源码 -->
<script type="shader-source" id="vertexShader">
void main(){
//声明顶点位置
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
//声明要绘制的点的大小。
gl_PointSize = 10.0;
}
</script>
<!-- 片元着色器源码 -->
<script type="shader-source" id="fragmentShader">
void main(){
//设置像素颜色为红色
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
</script>
<canvas id="canvas" width="640" height="480">
Your browser doesn't appear to support the HTML5 <code><canvas></code> element.
</canvas>
<script type="text/javascript">
function main() {
// 获取webGL的绘图环境
const canvas = document.querySelector("#canvas")
const gl = canvas.getContext("webgl")
// 创建顶点着色器
const vertexShaderSource = document.querySelector('#vertexShader').innerHTML
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
gl.shaderSource(vertexShader, vertexShaderSource)
gl.compileShader(vertexShader)
// 创建片元着色器
const fragmentShaderSource = document.querySelector('#fragmentShader').innerHTML
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
gl.shaderSource(fragmentShader, fragmentShaderSource)
gl.compileShader(fragmentShader)
// 创建着色器程序
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
gl.useProgram(program)
// 绘制
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.POINTS, 0, 1)
}
</script>
</body>
参考资料:
职业生涯是长期的,可以分为三个阶段:
初期要做的
我们总是会低估在未来才能兑现的好处,这种现象被称为‘时间贴现’(temporal discounting)。我们都不愿意用当前的痛苦,比如更辛苦的工作、更低的薪水、更低的声望等,来换取未来的某样东西,即使未来的好处其实更加丰厚。作为人类,我们本能地不相信未来的好处能够兑现,所以会在当前对它们打很大的折扣。”克里斯还在观察中发现了“损失厌恶”(loss aversion)的效应:“人们对后果和风险看得比好的方面更加清楚。我们的美梦很模糊,但噩梦却很清晰。
要搞清楚HTTP缓存,首先当然是要搞清楚缓存是啥,按照MDN的描述,缓存是这样的:
缓存是一种保存资源副本并在下次请求时直接使用该副本的技术。当 web 缓存发现请求的资源已经被存储,它会拦截请求,返回该资源的拷贝,而不会去源服务器重新下载。这样带来的好处有:缓解服务器端压力,提升性能(获取资源的耗时更短了)。对于网站来说,缓存是达到高性能的重要组成部分。缓存需要合理配置,因为并不是所有资源都是永久不变的:重要的是对一个资源的缓存应截止到其下一次发生改变(即不能缓存过期的资源)。
上面已经将会缓存是什么描述的很清楚了,而HTTP缓存顾名思义,就是通过HTTP协议,来实现对资源缓存的目的。总的来说,HTTP缓存主要通过两个HTTP头来实现的,其中Expires是由HTTP1.0提供的,而Cache-Control则是由HTTP1.1所提供的:
下面我们就来对这两个头进行一个了解。
Expires是由HTTP1.0所提供的支持HTTP缓存的头部,由服务器返回,用GMT格式的字符串表示:
expires: Tue, 14 Aug 2018 14:32:49 GMT
而读取缓存的条件则是:缓存过期时间(服务器的时间)< 当前时间(客户端的时间)
值得注意的是,我们在expires所设置的时间是一个绝对的时间,而且所参照的是用户电脑上所设置的时间。这种绝对的时间很容易出问题,当用户本地的时间不准确,或用户进行跨时区的移动时,这个时间很可能就会过期,而无法发挥它本应该发挥的作用。
其次,在HTTP1.0里,没有提供相应的配置缓存的方法,只是提供了这个强缓存的头部而已,不足以满足项目对缓存多样化的需求。也正是出于以上两个原因,在HTTP1.1中对HTTP缓存又进行了升级。
正是由于Expires存在着很多不足,所以HTTP1.1又为我们提供了
Cache-Control主要可配置的参数有以下几个:
上面已经将Catch-Control做了一个简单的介绍,而具体使用它们二者进行缓存操作的具体实现又分为强缓存与协商缓存。首先来聊一聊强缓存。
强缓存是利用Expires或者Cache-Control这两个http response header实现的,它们都用来表示资源在客户端缓存的有效期。在这个有效期内当浏览器对某个资源的请求命中了强缓存时,其返回的http状态为200,并且不会去对服务器进行请求,而是直接使用其本地的缓存。
具体实现如下:
expires: Tue, 21 Aug 2018 10:17:45 GMT
cache-control: max-age=691200
只要存在以上两个头部信息的其中一个,我们就可以对资源进行强缓存了。另外需要注意的是,当Catch-Control的优先级是要高于expires的。
总的来说,强缓存是前端性能优化的一大助力。当我们页面存在很多长期不变的静态资源时,都应该对其进行强缓存的处理,我们通常可以为这些静态资源全部配置一个超时时间很长的Expires或Cache-Control。当用户在访问网页时,就只会在第一次加载时从服务器请求静态资源,在往后访问该页面时,就只要缓存没有失效并且用户没有强制刷新的条件下都会从自己的缓存中加载。这样既节省了资源加载的时间的消耗,又不会去访问服务器,可以有效地为服务器减压。
不过强缓存也存在一个很大的弊端,那就是对于动态资源它就有点力不从心了。因为如果我们对动态资源进行了强缓存,那么很可能会在这动态资源更改后,浏览器还是会直接去请求没有更改前的动态资源。也这是由于这方面的考虑,在强缓存外还存在着协商缓存的缓存方案。
当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求响应返回的http状态为304并且会显示一个Not Modified的字符串;
若未命中请求,则将资源返回客户端,并更新本地缓存数据,并返回200的状态码。
除此之外,我们也可以设置为协商缓存,以解决动态资源缓存与更新的问题,首先我们来看一张关于协商缓存的图:
可以看到,在这个实现了协商缓存的Cache-Control中,设置了no-catch,当我们设置为no-catch时,我们就是可以直接去访问服务器,去查看该资源的更改情况,已确定是是否需要使用缓存。而在校验的这一步我们就需要使用以下几个头来帮助验证资源的更改情况了:
也正是由于 Last-Modified存在着缺陷,我们就需要ETag来帮助我们来对资源的更改进行判断:
这就是强缓存与协商缓存的大部分情况了,具体的流程可见下图:
原图链接:https://user-gold-cdn.xitu.io/2018/8/16/165411b79180df27?w=1041&h=650&f=png&s=85785
通常我们可以按照上面这张流程图来对HTTP缓存进行相应的配置,详情可以看这篇文章:
这位大佬对缓存的配置做了一个很好的阐述。
参考资料:
作为React全家桶的一份子,Redux为react提供了严谨周密的状态管理。但Redux本身是有点难度的,虽然学习了React也有一段时间了,自我感觉算是入了门,也知道redux的大概流程。但其背后诸如creatstore,applymiddleware等API背后到底发生了什么事情,我其实还是不怎么了解的,因此最近花了几天时间阅读了Redux的源码,写下文章纪录一下自己看源码的一些理解。(redux4.0版本)
Redux是出了名的短小精悍(恩,这个形容很贴切),只有2kb大小,且没有任何依赖。它将所有的脏活累活都交给了中间件去处理,自己保持着很好的纯洁性。再加上redux作者在redux的源码上,也附加了大量的注释,因此redux的源码读起来还是不算难的。
先来看看redux的源码结构,也就是src目录下的代码:
其中utils是工具函数,主要是作为辅助几个核心API,因此不作讨论。
(注:由于篇幅的问题,下面代码很多都删除了官方注释,和较长的warn)
index.js是redux的入口函数具体代码如下:
import createStore from './createStore'
import combineReducers from './combineReducers'
import bindActionCreators from './bindActionCreators'
import applyMiddleware from './applyMiddleware'
import compose from './compose'
import warning from './utils/warning'
import __DO_NOT_USE__ActionTypes from './utils/actionTypes'
function isCrushed() {}
if (
process.env.NODE_ENV !== 'production' &&
typeof isCrushed.name === 'string' &&
isCrushed.name !== 'isCrushed'
) {
warning(
)
}
export {
createStore,
combineReducers,
bindActionCreators,
applyMiddleware,
compose,
__DO_NOT_USE__ActionTypes
}
其中isCrushed函数是用于验证在非生产环境下 Redux 是否被压缩,如果被压缩就会给开发者一个 warn 的提示。
在最后index.js 会暴露 createStore, combineReducers, bindActionCreators, applyMiddleware, compose 这几个redux最主要的API以供大家使用。
createStore函数接受三个参数:
下面就是creactStore的源码,由于整体源码过长,且 subscribe 和 dispatch 函数也挺长的,所以就将 subscribe 和 dispatch 单独提出来细讲。
import $$observable from 'symbol-observable'
import ActionTypes from './utils/actionTypes'
import isPlainObject from './utils/isPlainObject'
export default function createStore(reducer, preloadedState, enhancer) {
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState
preloadedState = undefined
}
// enhancer应该为一个函数
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
//enhancer 接受 createStore 作为参数,对 createStore 的能力进行增强,并返回增强后的 createStore 。
// 然后再将 reducer 和 preloadedState 作为参数传给增强后的 createStore ,最终得到生成的 store
return enhancer(createStore)(reducer, preloadedState)
}
// reducer必须是函数
if (typeof reducer !== 'function') {
throw new Error('Expected the reducer to be a function.')
}
// 初始化参数
let currentReducer = reducer // 当前整个reducer
let currentState = preloadedState // 当前的state,也就是getState返回的值
let currentListeners = [] // 当前的订阅store的监听器
let nextListeners = currentListeners // 下一次的订阅
let isDispatching = false // 是否处于 dispatch action 状态中, 默认为false
// 这个函数用于确保currentListeners 和 nextListeners 是不同的引用
function ensureCanMutateNextListeners() {
if (nextListeners === currentListeners) {
nextListeners = currentListeners.slice()
}
}
// 返回state
function getState() {
if (isDispatching) {
throw new Error(
......
)
}
return currentState
}
// 添加订阅
function subscribe(listener) {
......
}
}
// 分发action
function dispatch(action) {
......
}
//这个函数主要用于 reducer 的热替换,用的少
function replaceReducer(nextReducer) {
if (typeof nextReducer !== 'function') {
throw new Error('Expected the nextReducer to be a function.')
}
// 替换reducer
currentReducer = nextReducer
// 重新进行初始化
dispatch({ type: ActionTypes.REPLACE })
}
// 没有研究,暂且放着,它是不直接暴露给开发者的,提供了给其他一些像观察者模式库的交互操作。
function observable() {
......
}
// 创建一个store时的默认state
// 用于填充初始的状态树
dispatch({ type: ActionTypes.INIT })
return {
dispatch,
subscribe,
getState,
replaceReducer,
[$$observable]: observable
}
}
function subscribe(listener) {
if (typeof listener !== 'function') {
throw new Error('Expected the listener to be a function.')
}
if (isDispatching) {
throw new Error(
......
)
}
let isSubscribed = true
// 如果 nextListeners 和 currentListeners 是一个引用,重新复制一个新的
ensureCanMutateNextListeners()
nextListeners.push(listener)
return function unsubscribe() {
if (!isSubscribed) {
return
}
if (isDispatching) {
throw new Error(
.......
)
}
isSubscribed = false
ensureCanMutateNextListeners()
const index = nextListeners.indexOf(listener)
// 从nextListeners里面删除,会在下次dispatch生效
nextListeners.splice(index, 1)
}
}
有时候有些人会觉得store.subscribe用的很少,其实不然,是react-redux隐式的为我们帮我们完成了这方面的工作。subscribe函数可以给 store 的状态添加订阅监听,一旦我们调用了 dispatch来分发action ,所有的监听函数就会执行。而 nextListeners 就是储存当前监听函数的列表,当调用 subscribe,传入一个函数作为参数时,就会给 nextListeners 列表 push 这个函数。同时调用 subscribe 函数会返回一个 unsubscribe 函数,用来解绑当前传入的函数,同时在 subscribe 函数定义了一个 isSubscribed 标志变量来判断当前的订阅是否已经被解绑,解绑的操作就是从 nextListeners 列表中删除当前的监听函数。
dispatch是redux中一个非常核心的方法,也是我们在日常开发中最常用的方法之一。dispatch函数是用来触发状态改变的,他接受一个 action 对象作为参数,然后 reducer 就可以根据 action 的属性以及当前 store 的状态,来生成一个新的状态,从而改变 store 的状态;
function dispatch(action) {
// action 必须是一个对象
if (!isPlainObject(action)) {
throw new Error(
......
)
}
// type必须要有属性,不能是undefined
if (typeof action.type === 'undefined') {
throw new Error(
......
)
}
// 禁止在reducers中进行dispatch,因为这样做可能导致分发死循环,同时也增加了数据流动的复杂度
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.')
}
try {
isDispatching = true
// 当前的状态和 action 传给当前的reducer,用于生成最新的 state
currentState = currentReducer(currentState, action)
} finally {
// 派发完毕
isDispatching = false
}
// 将nextListeners交给listeners
const listeners = (currentListeners = nextListeners)
// 在得到新的状态后,依次调用所有的监听器,通知状态的变更
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
return action
}
其中 currentState = currentReducer(currentState, action);这里的 currentReducer 是一个函数,他接受两个参数:
然后返回计算出来的新的状态。
compose 可以接受一组函数参数,从右到左来组合多个函数,然后返回一个组合函数。它的源码并不长,但设计的十分巧妙:
export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
compose函数的作用其实其源码的注释里讲的很清楚了,比如下面这样:
compose(funcA, funcB, funcC)
其实它与这样是等价的:
compose(funcA(funcB(funcC())))
ompose 做的只是让我们在写深度嵌套的函数时,避免了代码的向右偏移。
applyMiddleware也是redux中非常重要的一个函数,设计的也非常巧妙,让人叹为观止。
export default function applyMiddleware(...middlewares) {
return createStore => (...args) => {
// 利用传入的createStore和reducer和创建一个store
const store = createStore(...args)
let dispatch = () => {
throw new Error(
`Dispatching while constructing your middleware is not allowed. ` +
`Other middleware would not be applied to this dispatch.`
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
// 让每个 middleware 带着 middlewareAPI 这个参数分别执行一遍
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
通过上面的代码,我们可以看出 applyMiddleware 是个三级柯里化的函数。它将陆续的获得三个参数:第一个是 middlewares 数组,第二个是 Redux 原生的 createStore,最后一个是 reducer,也就是上面的...args;
applyMiddleware 利用 createStore 和 reducer 创建了一个 store,然后 store 的 getState 方法和 dispatch 方法又分别被直接和间接地赋值给 middlewareAPI 变量。
其中这一句我感觉是最核心的:
dispatch = compose(...chain)(store.dispatch)
我特意将compose与applyMiddleware放在一块,就是为了解释这段代码。因此上面那段核心代码中,本质上就是这样的(假设...chain有三个函数):
dispatch = f1(f2(f3(store.dispatch))))
combineReducers 这个辅助函数的作用就是,将一个由多个不同 reducer 函数作为 value 的 object合并成一个最终的 reducer 函数,然后我们就可以对这个 reducer 调用 createStore 方法了。这在createStore的源码的注释中也有提到过。
并且合并后的 reducer 可以调用各个子 reducer,并把它们返回的结果合并成一个 state 对象。 由 combineReducers() 返回的 state 对象,会将传入的每个 reducer 返回的 state 按其传递给 combineReducers() 时对应的 key 进行命名。
下面我们来看源码,下面的源码删除了一些的检查判断,只保留最主要的源码:
export default function combineReducers(reducers) {
const reducerKeys = Object.keys(reducers)
// 有效的 reducer 列表
const finalReducers = {}
for (let i = 0; i < reducerKeys.length; i++) {
const key = reducerKeys[i]
const finalReducerKeys = Object.keys(finalReducers)
// 返回最终生成的 reducer
return function combination(state = {}, action) {
let hasChanged = false
//定义新的nextState
const nextState = {}
// 1,遍历reducers对象中的有效key,
// 2,执行该key对应的value函数,即子reducer函数,并得到对应的state对象
// 3,将新的子state挂到新的nextState对象上,而key不变
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
// 遍历一遍看是否发生改变,发生改变了返回新的state,否则返回原先的state
return hasChanged ? nextState : state
}
}
bindActionCreators可以把一个 value 为不同 action creator 的对象,转成拥有同名 key 的对象。同时使用 dispatch 对每个 action creator 进行包装,以便可以直接调用它们。
bindActionCreators函数并不常用(反正我还没有怎么用过),惟一会使用到 bindActionCreators 的场景就是我们需要把 action creator 往下传到一个组件上,却不想让这个组件觉察到 Redux 的存在,并且不希望把 dispatch 或 Redux store 传给它。
// 核心代码,并通过apply将this绑定起来
function bindActionCreator(actionCreator, dispatch) {
return function() {
return dispatch(actionCreator.apply(this, arguments))
}
}
// 这个函数只是把actionCreators这个对象里面包含的每一个actionCreator按照原来的key的方式全部都封装了一遍,核心代码还是上面的
export default function bindActionCreators(actionCreators, dispatch) {
// 如果actionCreators是一个函数,则说明只有一个actionCreator,就直接调用bindActionCreator
if (typeof actionCreators === 'function') {
return bindActionCreator(actionCreators, dispatch)
}
// 如果是actionCreator是对象或者null的话,就会报错
if (typeof actionCreators !== 'object' || actionCreators === null) {
throw new Error(
... ...
}
// 遍历对象,然后对每个遍历项的 actionCreator 生成函数,将函数按照原来的 key 值放到一个对象中,最后返回这个对象
const keys = Object.keys(actionCreators)
const boundActionCreators = {}
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const actionCreator = actionCreators[key]
if (typeof actionCreator === 'function') {
boundActionCreators[key] = bindActionCreator(actionCreator, dispatch)
}
}
return boundActionCreators
}
看一遍redux,感觉设计十分巧秒,不愧是大佬的作品。这次看代码只是初看,往后随着自己学习的不断深入,还需多加研究,绝对还能得到更多的体会。
学习react已经有一段时间了,期间在阅读官方文档的基础上也看了不少文章,但感觉对很多东西的理解还是不够深刻,因此这段时间又在撸一个基于react全家桶的聊天App(现在还在瞎78写的阶段,在往这个聊天App这个方向写),通过实践倒是对react相关技术栈有了更为深刻的理解,而在使用react-redux的过程中,发现connect好像还挺有意思的,也真实感受到了高阶组件所带来的便利,出于自己写项目本身就是为了学习的目的,因此对高阶组件又进行了一番学习。写下这篇文章主要是对高阶组件的知识进行一个梳理与总结,如有错误疏漏之处,敬请指出,不胜感激。
要学习高阶组件首先我们要知道的就是高阶组件是什么,解决了什么样的问题。
React官方文档的对高阶组件的说明是这样的:
A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, perse. They are a pattern that emerges from React’s compositional nature.
从上面的说明我们可以看出,react的高阶组件并不是react API的一部分。它源自于react的生态。
简单来说,一个高阶组件就是一个函数,它接受一个组件作为输入,然后会返回一个新的组件作为结果,且所返回的新组件会进行相对增强。值得注意的是,我们在这说的组件并不是组件实例,而是一个组件类或者一个无状态组件的函数。就像这样:
import React from 'react'
function removeUserProp(WrappedComponent) {
//WrappingComponent这个组件名字并不重要,它至少一个局部变量,继承自React.Component
return class WrappingComponent extends React.Component {
render() {
// ES6的语法,可以将一个对象的特定字段过滤掉
const {user, ...otherProps} = this.props
return <WrappedComponent {...otherProps} />
}
}
}
了解设计模式的大佬们应该发现了,它其实就是的设计模式中的装饰者模式在react中的应用,它通过组合的方式从而达到很高的灵活程度和复用。
就像上面的代码,我们定义了一个叫做 removeUserProp 的高阶组件,传入一个叫做 WrappedComponent 的参数(代表一个组件类),然后返回一个新的组件 ,新的组件与原组件并没有太大的区别,只是将原组件中的 prop 值 user 给剔除了出来。
有了上面这个高阶组件的,当我们不希望某个组件接收到 user 时,我们就可以将这个组件作为参数传入 removeUserProp() 函数中,然后使用这个返回的组件就行了:
const NewComponent = removeUserProp(OldComponent)
这样 NewComponent 组件与 OldComponent 组件拥有完全一样的行为,唯一的区别就在于传入的name属性对这个组件没有任何作用,它会自动屏蔽这个属性。也就是说,我们这个高阶组件成功的为传入的组件增加了一个屏蔽某个prop的功能。
那么明白了什么是高阶组件后,我们接下来要做的是,弄清楚高阶组件主要解决的问题,或者说我们为什么需要高阶组件?总结起来主要是以下两个方面:
在很多情况下,react组件都需要共用同一个逻辑,我们在这个时候就可以把这部分共用的逻辑提取出来,然后利用高阶组件的形式将其组合,从而减少很多重复的组件代码。
2.修改React组件的行为
很多时候有些现成的react组件并不是我们自己撸出来的,而是来自于GitHub上的大佬们的开源贡献,而当我们要对这些组件进行复用的时候,我们往往都不想去触碰这些组件的内部逻辑,这时我们就能通过高阶组件产生新的组件满足自身需求,同时也对原组件没有任何损害。
现在我们对高阶组件有了一个较为直观的认识,知道了什么是高阶组件以及高阶组件的主要用途。接下来我们就要具体了解高阶组件的实现方式以及它的具体用途了。
对于高阶组件的实现方式我们可以根据作为参数传入的组件与返回的新组件的关系将高阶组件的实现方式分为以下两大类:
从高阶组件的使用频率来讲,我们使用的绝大多数的高阶组件都是代理方式的高阶组件,如react-redux中的connect,还有我们在上面所实现的那个removeUserProp。这类高阶组件的特点是返回的新组件类直接继承于 React.Component 类。新组建在其中扮演的角色是一个传入参数组件的代理,在新组建的render函数中,把被包裹的组件渲染出来。在此过程中,除了高阶组件自己需要做的工作,其他的工作都会交给被包裹的组件去完成。
代理方式的高阶组件具体而言,应用场景可以分为以下几个:
代理类型的高阶组件返回的新组件时,渲染过程也会被新组建的render函数所控制,而在此过程中,render函数相对于一个代理,完全决定该如何使用被包裹在其中的组件。在render函数中,this.props包含了新组件接受到的所有prop。因此最直观的用法就是接受到props,然后进行任何读取,增减,修改等控制props的自定义操作。
就比如我们上面的那个示例,就做到了删除prop的功能,当然我们也能实现一个添加prop的高阶组件:
function addNewProp(WrappedComponent, newProps) {
return class WrappingComponent extends React.Component {
render() {
return <WrappedComponent {...thisProps} {...newProps} />
}
}
}
这个addNewProp高阶组件与我们最开始举例的removeUserProp高阶组件在实现上并无太大的区别。唯一区别较大的就是我们传入的参数除了WrappedComponent组件类外,还新增了newProps参数。这样的高阶组件在复用性方面会跟友好,我们可以利用这样一个高阶组件给不同的组件添加不同的新属性,比如这样:
const FirstComponent = addNewProp(OldComponent,{num: First})
const LastComponent = addNewProp(NewComponent,{num: Last})
在上面的代码中,我们实现了让两个完全不同的组件分别通过高阶组件生成了两个完成不同的新的组件,而这其中唯一相同的是都添加了一个属性值,且这个属性还不相同。从上面的代码我们也不难发现,高阶组件可以重用在不同组件上,减少了重复的代码。当需要注意的是,在修改和删除 Props的时候,除非由特殊的要求,否则最好不要影响到原本传递给普通组件的 Props。
我们可以通过ref获取组件实例,但值得注意的是,React官方不提倡访问ref,我们只是讨论一下这个技术的可行性。在此我们写一个refsHOC的高阶组件,可以获得被包裹组件的ref,从而根据ref直接操纵被包裹组件的实例:
import React from 'react'
function refsHOC(WrappedComponent) => {
return class HOCComponent extends React.Component {
constructor() {
super(...arguments)
this.linkRef = this.linkRef.bind(this)
}
linkRef(wrappedInstance) {
this._root = wrappedInstance
}
render() {
const props = {...this.props, ref: this.linkRef}
return <WrappedComponent {...props}/>
}
}
}
export default refsHOC
这个refs高阶组件的工作原理其实也是增加传递给被包裹组件的props,不同的是利用了ref这个特殊的prop而已。我们通过linkRef来给被包裹组件传递ref值,linkRef被调用时,我们就可以得到被包裹组件的DOM实例。
这种高阶组件在用途上来讲可以说是无所不能的,因为只要能够获得对被包裹组件的引用,就能通过这个引用任意操纵一个组件的DOM元素,贼酸爽。但它从某个角度来讲也是啥也干不了的,因为react团队表示:不要过度使用 Refs。且我们也有更好的替代品——控制组件(Controlled Component)来解决相关问题,因此这个坑建议大家还是尽量少踩为好。
对于抽取状态,我想大家应该都不会很陌生。react-redux中的connect函数就实现了这种功能,它异常的强大,也成功吸引了我对高阶组件的注意力。但在这有一点需要明确的是:connect函数本身并不是高阶组件,connect函数执行的结果才是一个高阶组件。让我们来看看connect的源码的主要逻辑:
export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
return function wrapWithConnect(WrappedComponent) {
class Connect extends Component {
constructor(props, context) {
//参数获取
super(props, context)
this.store = props.store || context.store
const storeState = this.store.getState()
this.state = { storeState }
}
// 进行判断,当数据发生改变时,Component重新渲染
shouldComponentUpdate(nextProps, nextState) {
if (propsChanged || mapStateProducedChange || dispatchPropsChanged) {
this.updateState(nextProps)
return true
}
}
// 改变Component中的state
componentDidMount() {
this.store.subscribe(() = {
this.setState({
storeState: this.store.getState()
})
})
}
render(){
this.renderedElement = createElement(WrappedComponent,
this.mergedProps
)
return this.renderedElement
}
}
return hoistStatics(Connect, WrappedComponent)
}
}
从上面的代码我们不难看出connect模块的返回值wrapWithConnect是一个函数,而这个函数才是我们所认知的高阶组件。wrapWithConnect函数会返回一个ReactComponent对象Connect,Connect会重新render外部传入的原组件WrappedComponent,并把connect中所传入的mapStateToProps, mapDispatchToProps和this.props合并后结合成一个对象,通过属性的方式传给WrappedComponent,这才是最终的渲染结果。
在日常开发中我们所接触到的大多数的高阶组件都是通过修改props部分来对输入的组件进行相对增强的。但其实高阶组件还有其他的方式来增强组件,比如我们可以通过在render函数中的JSX引入其他元素,甚至将多个react组件合并起来,来获得更*气的样式或方法,例如我们可以给组件增加style来改变组件样式:
const styleHOC = (WrappedComponent, style) => {
return class HOCComponent extends React.Component {
render() {
return (
<div style={style}>
<WrappedComponent {...this.props} />
</div>
)
}
}
}
当我们想改变组件的样式的时候,我们就可以直接调用这个函数,比如这样:
const style = {
background-color: #f1fafa;
font-family: "微软雅黑";
font-size: 20px;
}
const BeautifulComponent = styleHOC(uglyComponent, style)
前面我们讨论了代理方式实现的高阶组件以及它们的主要使用方式,现在我们继续来讨论一下以继承方式实现的高阶组件。
。继承方式的高阶组件通过继承来关联作为参数传入的组件和返回的组件,比如传入的组件参数是OldComponent,那函数所返回的组件就直接继承于OldComponemt。
码界有句老话说的好:组合优于继承。在高阶组件里也不例外。
继承方式的高阶组件相对于代理方式的高阶组件有很多不足之处,比如输入的组件与输出的组件共有一个生命周期等,因此通常我们接触到的高阶组件大多是代理方式实现的高阶组件,也推荐大家首先考虑以代理方式来实现高阶组件。但我们还是需要去了解并学习它,毕竟它也是有可取之处的,比如在操作生命周期函数上它还是具有其优越性的。
说继承方式的高阶组件在操纵生命周期函数上有其优越性其实不够说明它在这个领域的地位,更准确地表达是:操作生命周期函数是继承方式的高阶组件所特有的功能。这是由于继承方式的高阶组件返回的新组件继承于作为参数传入的组件,两个组件的生命周期是共用的,因此可以重新定义组件的生命周期函数并作用于新组件。而代理方式的高阶组件作为参数输入的组件与输出的组件完全是两个生命周期,因此改变生命周期函数也就无从说起了。
例如我们可以定义一个让参数组件只有在用户登录时才显示的高阶组件:
const shouldLoggedInHOC = (WrappedComponent) => {
return class MyComponent extends WrappedComponent {
render() {
if (this.props.loggedIn) {
return super.render()
}
else {
return null
}
}
}
}
除了操作生命周期函数外,继承方式的高阶函数也能对Prop进行操作,但总的难说贼麻烦,当然也有简单的方式,比如这样:
function removeProps(WrappedComponent) {
return class NewComponent extends WrappedComponent {
render() {
const{ user, ...otherProps } = this.props
this.props = otherProps
return super.render()
}
}
}
虽然这样看起来很简单,但我们直接修改了this.props,这不是一个好的实践,可能会产生不可预料的后果,更好的操作办法是这样的:
function removeProps(WrappedComponent) {
return class NewComponent extends WrappedComponent {
render() {
const element =super.render()
const{ user, ...otherProps } = this.props
this.props = otherProps
return React.cloneElement(element, this.props, element.props.children)
}
}
}
我们可以通过React.cloneElement来传入新的props,让这些产生的组件重新渲染一次。但虽然这种方式可以解决直接修改this.props所带来的问题,但实现起来贼麻烦,唯一用得上的就是高阶组件需要根据参数组件WrappedComponent渲染结果来决定如何修改props时用得上,其他的时候显然使用代理模式更便捷清晰。
用 HOC 包裹了一个组件会使它失去原本 WrappedComponent 的名字,可能会影响开发和debug。
因此我们通常会用 WrappedComponent 的名字加上一些 前缀作为 HOC 的名字。我们来看看React-Redux是怎么做的:
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName ||
WrappedComponent.name ||
‘Component’
}
HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`
//或
class HOC extends ... {
static displayName = `HOC(${getDisplayName(WrappedComponent)})`
...
}
实际上我们不用自己来写getDisplayName这个函数,recompose 提供了这个函数,我们只要使用即可。
我们其他要注意的就是官方文档所说的几个约定与相关规范,在此我就不一一赘述了,感兴趣的可以自己去看看。最后很感谢能看到这里的朋友,因为水平有限,如果有错误敬请指正,十分感激!
在 BPM 中台设计前端渲染操作模板接口的时候,有借鉴参考一些 DSL 的**来设计模板接口,但对于 DSL 的认知还是有点松散,因此借着这篇文章整理一下自己对于 DSL 的一些的认知。
DSL 是 Domain Specific Language 的缩写,中文翻译为领域特定语言,是一种为特定领域设计的,具有受限表达性的**编程语言。**只能在特定领域解决特定任务,常见的如:SQL,HTML,CSS 等。用 Martin Fowler 对于 DSL 的定义就是:DSL 通过在表达能力上的限制换取在某一领域内的高效。
A computer programming language of limited expressiveness focused on a particular domain.
Martin 在讨论 DSL 的定义时,讲到了它的四个关键元素:
那它和传统的编程语言有什么不同呢?其实与 DSL 相对应的就是GPL(General Purpose Language),即通用编程语言,也就是我们所熟悉的JavaScript、Python、Golang、Rust等。而它们最大区别就在于,GPL 是图灵完备的语言,可以编写任意的代码,表达任何可被计算的逻辑。
那它为什么会出现呢?或者说,它给我们带来了什么?一方面是高级语言抽象带来的效率提升存在天花板,因此需要使用一些针对特定领域设计的语言来解决特定领域所存在的问题,从而提升我们在特定领域的开发效率。
这样说可能会比较模糊,但其实从宏观的角度来理解,语言编写的软件本质上都是要操作硬件去完成一系列目的的,而语言各自的语法其实就是一些系列的接口,我们可以通过这些接口去完成我们所要实现的目的。因此,我们在设计 DSL 时,本质上也只是在设计一种语法而已,只不过这种语法适用于特定的一些领域,并且可以具有**很好的表现能力。**这里引用 Martin Fowler 的《领域特定语言》中的创建一个 Computer 实例的代码:
Processor p = new Processor(2, 2500, Processor.Type.i386);
Disk d1 = new Disk(150, Disk.UNKNOWN_SPEED, null);
Disk d2 = new Disk(75, 7200, Disk.Interface.SATA);
return new Computer(p, d1, d2);
而使用内部 DSL 写出来则是这样的:
computer()
.processor()
.cores(2)
.speed(2500)
.i386()
.disk()
.size(150)
.disk()
.size(75)
.speed(7200)
.sata()
.end();
相较于 Java 传统的写法,DSL写出来的代码很明显具有声明式的味道,更专注于做什么而不是怎么做,成功的逻辑与意图进行了分离。这也有利于让我们在思考功能时变得更为清晰,而无需关心其中的具体实现。
另一方面在于,一旦我们有一个编译器(这主要是针对外部 DSL 来讲的),我们在此 DSL 所覆盖的软件开发的特定领域的工作将变得非常的高效,其中很典型的按理就是现在非常流行的 JSX,以及众多搭建平台用于描述页面的 DSL。我们通过在 React 写 JSX,就可以声明式的实现很多逻辑,这有效的提升了我们的开发效率以及开发体验。
需要注意的是,DSL 虽然给我们带来了诸多的便利,但也存在一些局限性。就显著的就是 DSL 只能用于一个特定的领域,虽然大多 DSL 学习起来较之 GPL 要来的容易,但仍然存在上手成本。 同时,如果使用到了 DSL 相关工具,即使工作效率有所提升,但学习和配置这些工具也需要一定的工作量。因此,如何在保证效率提升的同时,最大化的减少使用者的学习成本也是 DSL 需要考虑的事情,这也对设计者提出了更高的要求,需要同时对专业领域知识以及开发语言都一定的掌握,才能设计出合理的 DSL。
DSL 从实现的角度来讲,又可以分为两种:
上面是一种独立的编程语言,需要从解析器开始实现自己的编译工具,实现的成本较高。对于外部的 DSL 实现,很典型的就是诸多低代码平台,就用的是这种方式去实现自己的 DSL 去描述页面。至于如何设计一个 DSL,由于我之前只是进行了调研,并没有实际落地,所以在此也就不多扯了,具体可以参考 Phodal 大佬的文章:《领域特定语言设计技巧》。里面所提及的这五个步骤,我觉得很有道理(实际上,我在设计模板接口的时候也大致参考了它的这个步骤):
至于在语法解析方面,前端可以使用:Peg.js 来帮助我们去实现相应的解析功能:
感兴趣的同学可以去瞅瞅,我试了下的确很有意思。
是寄生于宿主语言的特殊 DSL,它与宿主语言共享编译与调试工具等基础设施,学习成本更低,也更容易被集成。他在语法上与宿主语言同源,但在运行时上需要做额外的封装。
说起来还是有点空洞,还是来举个例子吧,我们就以BPM中单据的审批来讲:我们期望实现一个这样的业务流程,这个流程是通过 iframe 接入的,但审批操作在 BPM 中台进行,期望在用户点击时发送消息给内部的iframe,由他们处理相关逻辑,然后在处理完之后会回传消息,然后再有BPM中台去执行平台方的审批操作,待审批成功后关闭页面。那么如果用链式的调用可以写成这样:
'approve'.postMessage().awaitMessageCallback().approve().closePage()
而具体这种链式调用也很简单, 我们只需在 String 原型上如此做即可:
String.prototype.postMessage = function() {
// 具体业务逻辑
console.log(this)
return this
}
此外,其实内部 DSL 的语言风格也有很多种,感兴趣的可以去看这篇文章:《前端 DSL 实践指南(上)—— 内部 DSL》,非常精彩。
参考链接:
之前在接手BPM的工作时,被大佬推荐学习一下领域驱动设计,因此花了不少时间阅读了 Eric 的这本经典著作,不少章节翻了很多次,也有不少章节则选择性的放弃了(有一说一,很多模式实在不太理解),自我感觉总结起来主要有两点核心的**:
这一过程从中收获了不少,也从中发现了现存的一些问题,在这里总结一下。
对于B端产品来说,核心的难点在于如何处理隐藏在业务中的复杂度。这些复杂度一方面来自于业务本身,另一方面则是在多次需求迭代中,需要保证一些关键的“知识”不在其中丢失。而降低这些复杂度,一个非常好的方式就是建立一套业务模型,通过模型来对复杂度进行简化与精炼。而这也正是领域驱动所提倡的方法论:通过领域模型来整理领域知识,从而使用领域模型来构造更易维护的软件。
总的来说,通过模型来驱动代码开发有以下三个优点:
上面总结了一下模型给我们带来的优点,那如何有效的进行建模呢?Eric 提到了以下五点:
其中前两点,是进行模型提炼的基础,而后三点则是构成了一个提炼知识的闭环。大致关系如下:
不得不说,这块最初读的还是去年,那会儿格局不够,没有体会到统一语言 & 领域建模的重要性,但随着今年开发的不断深入以及迭代的不断进行,遇到了很多问题,愈发对于统一语言和领域建模带来的收益,有了更为深刻的理解。
Eric 在他的知识消化中并没有去强调模型的好坏,而更多的强调模型与软件在具体实现上的关联。这里的原因在于:知识消化所提倡的方法,本质上是一种迭代改进的试错法,最初的模型可能不够完善,但通过一次次的迭代,逐步将其优化,因此比起模型最初时候的好坏,关联模型和代码的实现要显得格外重要。
另一个原因就是历史问题了。Eric 写书的时正是面向过程编程大行其道的时候,由此又可以引生出两个东西:充血模型和贫血模型。
提到这里,不由想起了前端近几年框架的一些修改,譬如 react hooks 和 Vue 的Componzition API 其实都有在逻辑聚合方面发力。
那么一个具体的模型又是由哪些元素组成的呢,下图是我在学习领域驱动设计时,所做的思维导图,可以参考参考:一个具体的领域模型具体会通过界限上下文划分为多个相互独立的子域,子域又由很多的实体组成。
在基于以上的方式建立起一套模型后,我们要做的就是基于我们所设计好的模型将我们的代码实现出来。需要注意的是,后续有关于模型方面的代码的变更,需要同步更新模型以及文档,反之亦然。只有做到这点,才能达到真正的模型和代码的绑定。
Eric 在他的书中在第二章用了整整一章,去阐述统一语言的重要性。所谓的统一语言,其实就是一种在项目内部,多方共同使用的语言,需要注意的是这里的多方可以包含项目中的所有成员。沟通中的共同语言其实有很多种,比如产品之间使用的用户旅程、用户画像等,也可以是研发之间的RPC、OpenAPI等黑话。不过在 DDD 中,统一语言特指的是根据领域模型构造出来的共同语言,且这种语言可为所有项目相关方进行使用。
但单纯基于模型构造出统一语言,会存在一些问题:因为模型其实本质上是一种数据结构,描述的是在不同业务维度下,数据将会如何改变,以及如何支撑起对应的计算与数据。而业务更为关心的是一些流程、交互、规则、所产生的价值等,这其中存在一定的 gap。因此,倘若单纯使用模型构建统一语言,会使其他各方不能很好的 get 到业务价值。
因此,更好的方式是,从模型的基础上,关联出一套可以准确描述业务价值的共同语言,它既能让模型在核心位置扮演关键角色,又能抹平不同角色因为自身背景而存在的代沟。
还有一点需要注意的是,上面我们强调了模型和代码的绑定,而在这里,我们则要做到模型与统一语言的关联。所以相对应的,当我们的代码发生变化时,模型也需要变化,所对应的统一语言应该也要随之变化。只有这样,让能更好的去描述事情,做到沟通信息无代差。
在领域模型中,有个很有意思的概念——上下文过载:指领域模型中的某个对象会在多个上下文中发挥重要的作用,甚至是聚合根。
当出现上下文过载时,往往会发生以下一些问题
因此我们将过载的上下文进行有效的分离很有必要。而对于将上下文的分离有很多的方法,其中比较常见的是增加上下文对象来对模型进行上下文隔离。
其实对应的不只是领域模型中的上下文过载,在很多其他的领域也存在一样的问题。
首先我们需要明确的是,上下文过载的根本问题在于:实体在不同的上下文中扮演的多个角色,再借由聚合关系,将不同上下文中的逻辑富集于实体中,就造成了上下文过载。
因此我们可以通过分离不同的上下文,增加上线对象,从而来对单个大的实体进行职责的分离。
当我们对模型进行分离时,算是有方法论对单个大的模型进行分离了。但在开发的过程中,还有一个需要注意的问题:如何组织领域逻辑和非领域逻辑,才能避免非领域逻辑对模型进行了污染?这里就不得不提及分层架构了。分层架构可以说是存在于软件开发领域的方方面面,小到组件,大到计算机网络,都有分层架构的思路在里面。它的目的也很简单,即:将不同关注点的逻辑封装到不同的层中。
而在领域驱动设计中,我们通常可以将系统分成四层,而依据主要是基于不同层之间需求变化的速率是不同的:
当然基础设施层也不一定是必要的,我们也可以通过一些诸如:能力提供商模式来对分层进行精简。
通过以上的几个步骤,我们终于可以将模型以及统一语言,直面于业务了。那么我们怎么通过在业务中的不断锤炼,来改进我们的模型呢。
在讨论这个之前,我们可以先了解一种建模方式:事件建模法,它是通过事件捕获系统中信息的改变,再发掘出发这些改变的源头,然后通过这些源头发现背后参与的实体与操作,最终完成对系统的建模。那么我们如何进行事件建模呢:
对于第一点,主要的问题在于,业务关心的是用户的行为,而模型关心的是数据,这两者存在一个的 gap 。而事件就是其中的桥梁。我们可以将事件看做行为的印记。而事件发生的时间点则是事件最重要的属性。当事件发生时,就可能存在数据的变更。
事件建模法的整体流程大致有以下五步:
这里也有几个名词需要解释一下:
而通过事件建模建立起的模型,我们就可以通过不断的事件风暴以及实践,去对事件进行收敛,从而提升我们的模型。
由于node.js 主线程是单线程的,因此当我们的 node 程序运行时,通常只启动了一个进程,只能在一个 CPU 中进行运算,无法运用服务器中的多核 CPU。这显然是对于多核CPU服务器性能的浪费,因此我们需要寻求一些解决方案,来充分利用服务器的多核CPU,从而提升我们程序的运行效率。而对于这种情况,我们通常的做法就是 多进程分发策略:即主进程接收所有的请求,通过一定的负载均衡策略分发到不同的子进程中。而这一方案,其实也有两种不同的实现方式:
而node 的 cluster 模块就是第一个方案的实现。cluster 模块是对child_process 模块的进一步封装,专用于解决单进程NodeJS Web服务器无法充分利用多核CPU的问题。
cluster 具体使用起来很简单,node官方文档也将的非常仔细,在此也就不多做赘述了:
其原理官方文档也有所提及:其工作进程是由 child_process.fork() 方法创建,因此它们可以使用 IPC 和 父进程通信,从而使其各进程交替处理连接服务。在 node 的主从模型中,master 主管监听端口,以及将对应任务分发给 worker 子进程,起着一个中枢的作用。按照我们通常的理解,如果根据使用各 worker 进程的负载情况来挑选woker来执行对应的任务,效率应该会比直接循环发放要来的高,但 node 文档中提到这种声明方式会受到操作系统的调度机制影响,使其分发变得不稳定,因此 node 也就将 循环法 作为了默认的分发策略。
需要注意的是,node 官方文档中使用 worker 来表示主进程fork出的子进程,这其实会让不少前端开发者会将其与浏览器环境中的 worker 多线程相混淆,但他们其实不是一个东西。
上面提到,cluster 是解决单进程的问题。但大家估计也听说过JavaScript是单线程的语言(实际上,我在面试一些候选人的时候,问到 node 的多进程实现,他们也会反问我JavaScript是单线程的,关多进程什么事情)。因此为了方便后续的讨论,我们再次还需要再说明一下,线程和进程的区别。
首先,进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程是资源分配的基本单位,而线程是独立调度的基本单位,一个进程中可以有多个线程,它们可以共享进程资源。它们的主要区别如下:
对于 cluster 模块,我们主要需要关注两部分的内容:
在 cluster 模式中,存在 master 和 worker 的概念。我在上面也提到了,这个 worker 并不是我们在浏览器中的worker。在这里,master 指的是主进程,而 worker 指的是子进程。它们的创建也非常简单:
在上述代码中,第一次 require 的 cluster 对象就模式是一个 master。对应的源码也非常简单,本质上是通过进程环境变量设置来进程判断,这是node的主进程在进行子进程管理时的标识,当调用cluster.fork()
时,会生成一个子进程时会以一个自增ID的形式生成这个环境变量。如果没有设置就是 master 进程,反之即为 worker:
https://github.com/nodejs/node/blob/master/lib/cluster.js
因此我们第一次调用 cluster 模块就是 master 进程,后续的则都为 worker。另外主进程和子进程 require 文件也不同:
先让我们来瞅瞅 master 进程的创建过程,由于代码其实还挺多,因此就不全部粘贴出来了,可自行去浏览:
可以看到,当我们执行 cluster.fork
时,一开始会调用 setupPrimary
方法,创建主进程。因为这个方法是通过 cluster.fork
调用,因此会调用多次,但该模块有个全局变量 initialized
用于区分是否为首次创建,如果是首次则进行创建,如果不是则跳过。代码如下:
而 cluster.fork
方法,其实也很简单,具体代码如下:
首先是进程进程的初始化,也就是创建 master。然后就是进行id的递增,再创建 worker 子进程。而在 createWorkerProcess
方法中,实际是使用 child_process
来创建子进程的。
需要注意的是,在初始化代码时,我们调用了两次 cluster.fork
方法,因此会创建两个子进程,在创建后又会调用我们项目根目录下的 cluster.js
启动一个新实例,但此时 cluster.isMaster
为 false,因此会 reuqire
到子进程的方法。
且由于是 worker 进程,因此代码会require('./app.js')
模块,在该模块中会监听具体的端口:
这里的 server.listen
会调用 net
模块中的 listenInCluster
方法,该方法中有一个关键信息:
上面代码中,首先判断是否为主进程,如果是就是真实的监听端口启动服务,而如果非主进程则调用 internal/cluster/child
中的cluster._getServer
方法。
然后就会通过一个send
方法,如果监听到 listening
就发送一个 messgae
给主线程,而主线程同样的也有一个 listening
时间,监听到该事件后将子进程通过 EventEmitter
绑定到主进程上,这样就完成了主子进程的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常说的 IPC 通信。
总结起来如下图所示:
(图转自:https://github.com/chyingp/nodejs-learning-guide/blob/master/%E6%A8%A1%E5%9D%97/cluster.md)
cluster 模块进行负载均衡处理,主要涉及两个模块:
cluster._getServer
方法,从而在 server.listen
中保证只有主进程监听端口,主子进程通过 IPC 进程通信,其次主进程根据平台或者协议不同,应用不同模块来进行分发请求给子进程处理。在早期编写JavaScript时,我们只需在 script 标签内写入JavaScript的代码就可以满足我们对页面交互的需要了。但随着时间的推移,时代的发展,原本的那种简单粗暴的编写方式所带来的诸如逻辑混乱,页面复杂,可维护性差,全局变量暴露等问题接踵而至,前辈们为了解决这些问题提出了很种的解决方案,其中之一就是JavaScript模块化编程。总的来说,它有以下四种优点:
2009年Node.js横空出世,将JavaScript带到了服务器端领域。而对于服务器端来说,没有模块化那可是不行的。因此CommonJs社区的大牛们开始发力了,制定了一个与社区同名的关于模块化的规范——CommonJs。它的规范主要如下:
根据CommonJS规范的规定,每个文件就是一个模块,有自己的作用域,也就是在一个文件里面定义的变量、函数、类,都是私有的,对其他文件是不可见的。通俗来讲,就是说在模块内定义的变量和函数是无法被其他的模块所读取的,除非定义为全局对象的属性。
// addA.js
const a = 1;
const addA = function(value) {
return value + a;
}
上面代码中,变量a和函数addA,是当前文件addA.js私有的,其他文件不可见。如果想在多个文件中分享变量a,必须定义为global对象的属性:
global.a = 1;
这样我们就能在其他的文件中访问变量a了,但这种写法不可取,输出模块对象最好的方式是module.exports:
// addA.js
var a = 1;
var addA = function(value) {
return value + x;
}
module.exports.addA = addA;
上面代码通过module.exports对象输出了一个函数,该函数就是模块外部与内部通信的桥梁。加载模块需要使用require方法,该方法读取一个文件并执行,最后返回文件内部的module.exports对象。
var example = require('./addA.js');
console.log(example.addA(1)); //2
CommonJs看起来是一个很不错的选择,拥有模块化所需要的严格的入口和出口,看起来一切都很美好,但它的一个特性却决定了它只能在服务器端大规模使用,而在浏览器端发挥不了太大的作用,那就是同步!这在服务器端不是什么问题,但放在浏览器端就出现问题了,因为文件都放在服务器上,如果网速不够快的话,前面的文件如果没有加载完成,浏览器就会失去响应!因此为了在浏览器上也实现模块化得来个异步的模块化才行!根据这个需求,我们的下一位主角——AMD就产生了!
AMD的全名叫做:Asynchronous Module Definition即异步模块定义。它采用了异步的方式来加载模块,然后在回调函数中执行主逻辑,因此模块的加载不影响它后面的模块的运行。它的规范如下:
define(id?, dependencies?, factory);
具体分析AMD我们通过require.js来进行。require.js是一个非常小巧的JavaScript模块载入框架,是AMD规范最好的实现者之一,require.js的出现主要是来解决两个问题:
使用require.js我们首先要加载它,为了避免浏览器未响应,我们在后面可以加上async,告诉浏览器这个文件需要异步加载(IE不支持该属性,所以需要把defer也加上):
<script src="js/require.js" defer async="true" ></script>
定义模块时,在require.js中我们可以使用define,但define对于需要定义的模块是否是独立的模块的写法是不同;所谓的独立模块就是指不依赖于其他模块的模块,而非独立模块就是指不依赖于其他模块的模块。
define在定义独立模块时有两种写法,一种是直接定义对象;另一种是定义一个函数,在函数内的返回值就是输出的模块了:
define({
method1: function() {},
method2: function() {},
});
//等价于
define(function () {
return {
method1: function() {},
method2: function() {},
}
});
如果define定义非独立模块,那么它的语法就规定一定是这样的:
define(['module1', 'module2'], function(m1, m2) {
return {
method: function() {
m1.methodA();
m2.methodB();
}
}
});
define在这个时候接受两个参数,第一个参数是module是一个数组,它的成员是我们当前定义的模块所依赖的模块,只有顺利加载了这些模块,我们新定义的模块才能成功运行。第二个参数是一个函数,当前面数组内的成员全部加载完之后它才运行,它的参数m与前面的module是一一对应的。这个函数必须返回一个对象,以供其他模块调用,需要注意的是,回调函数必须返回一个对象,这个对象就是你定义的模块。
在加载模块方面,AMD和CommonJs都是使用require。require.js也同样如此,它要求两个参数:module,callback:
require([module], callback);
第一个参数[module],是一个数组,里面的成员就是需要加载的模块;第二个参数callback,则是加载成功之后的回调函数。
require方法本身也是一个对象,它带有一个config方法,用来配置require.js运行参数。config方法接受一个对象作为参数。
//别名配置
requirejs.config({
paths: {
jquery: [ //如果第一个路径不能完成加载,就调到第二个路径继续进行加载
'//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
'lib/jquery' //本地文件中不需要写.js
]
}
});
//引入模块,用变量$表示jquery模块
requirejs(['jquery'], function ($) {
$('body').css('background-color','black');
});
虽然require.js实现了异步的模块化,但它仍然有一些不足的地方,在使用require.js的时候,我们必须要提前加载所有的依赖,然后才可以使用,而不是需要使用时再加载,使得初次加载其他模块的速度较慢,提高了开发成本。
CMD的全称是Common Module Definition,即通用模块定义。它是由蚂蚁金服的前端大佬——玉伯提出来的,实现的JavaScript库为sea.js。它和AMD的require.js很像,但加载方式不同,它是按需就近加载的,而不是在模块的开始全部加载完成。它有以下两大核心特点:
在CMD规范中,一个文件就是一个模块,代码书写的格式是这样的:
define(factory);
当factory为函数时,表示模块的构造方法,执行该方法,可以得到该模块对外提供的factory接口,factory 方法在执行时,默认会传入三个参数:require、exports 和 module:
// 所有模块都通过 define 来定义
define(function(require, exports, module) {
// 通过 require 引入依赖
var $ = require('jquery');
var Spinning = require('./spinning');
// 通过 exports 对外提供接口
exports.doSomething = ...
// 或者通过 module.exports 提供整个接口
module.exports = ...
});
它与AMD的具体区别其实我们也可以通过代码来表现出来,AMD需要在模块开始前就将依赖的模块加载出来,即依赖前置;而CMD则对模块按需加载,即依赖就近,只有在需要依赖该模块的时候再require就行了:
// AMD规范
define(['./a', './b'], function(a, b) { // 依赖必须一开始就写好
a.doSomething()
// 此处略去 100 行
b.doSomething()
...
});
// CMD规范
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
// 此处略去 100 行
var b = require('./b')
// 依赖可以就近书写
b.doSomething()
// ...
});
需要注意的是Sea.js的执行模块顺序也是严格按照模块在代码中出现(require)的顺序。
从运行速度的角度来讲,AMD虽然在第一次使用时较慢,但在后面再访问时速度会很快;而CMD第一次加载会相对快点,但后面的加载都是重新加载新的模块,所以速度会慢点。总的来说,
require.js的做法是并行加载所有依赖的模块, 等完成解析后, 再开始执行其他代码, 因此执行结果只会"停顿"1次, 而Sea.js在完成整个过程时则是每次需要相应模块都需要进行加载,这期间会停顿是多次的,因此require.js从整体而言相对会比Sea.js要快一些。
在ES6中将模块认为是自动运行在严格模式下并且没有办法退出运行的JavaScript代码。在一个模块中定义的变量不会自动被添加到全局共享的作用域之中,这个变量只能作用在这个作用域中。此外模块还必须导出一些外部文件可以访问的元素,以供其他模块或代码使用。
除了这个基本特性,ES6模块还有两大特性也十分重要,需要额外注意:
首先我们来看浏览器是如何加载模块的。其实在ES6规范出来之前,web浏览器就规定了三种方式来引入JavaScript文件:
// 第一种方式
<script type=""module>
import { add } from "./example";
let num = add(1, 1);
</script>
// 第二种方式
<script type="module" src="example.js">
// 第三种方式,以脚本的方式加载example.js
let worker = new Worker("example.js");
当HTML解析器遇到 script 元素的type="module"的时候,模块文件就开始下载,直到文件被完全解析完成才会去执行模块内的代码。模块文件是按照他们出现在HTML文件中顺序执行的,也就是说无论用何种方式引入模块,第一个 script type="module" 总是在第二个 script type="module" 之前执行。
在ES6中我们可以使用export关键字将一部分代码暴露给其他模块,以供其他模块或代码使用。先让我们来看看export关键字在MDN的定义吧:
export语句用于在创建JavaScript模块时,从模块中导出函数、对象或原始值,以便其他程序可以通过 import 语句使用它们。(
此特性目前仅在 Safari 和 Chrome 原生实现。它在许多转换器中实现,如Traceur Compiler,Babel或Rollup。)
通过MDN的定义我们可以知道:export关键字可以将其放在任何函数、对象或原始值前面,从而将它们从模块中导出。示例如下:
// ./example.js
// 导出变量
export var a = 1;
// 导出函数
export function addA(value) {
return value + a;
}
//导出类
export class add1 {
constructor(value) {
this.value = value + a;
}
}
//这个函数就是这个模块所私有的,在外部不能访问它
function say1() {
console.log('我是不是很帅');
}
//这又是个函数
function say2() {
console.log('没错我就是很帅');
}
//在后面对函数进行导出,它就不是私有的了
export say2;
需要注意的是:使用export导出的函数和类都需要一个名称,除非使用default关键字,否则就不能用这个方法导出匿名函数或类。所以当我们需要导出匿名的函数或者类时,我们可以这么做:
// ./example.js
//导出匿名函数
export default function(a, b) {
return a + b;
}
//或者导出匿名的类
export default class {
consturctor(value) {
this.value = value + 1;
}
}
具体关于default关键字的用法我会在后面做具体介绍,现在只需记住:当我们需要导出匿名的函数或者类时要使用export default语法。
在ES6中,从模块中导入的功能可以通过import关键字。import语句由两部分组成:要导入元素的标识符和元素应当从哪个模块导入。
// ./say.js
import { say2 } from "./example.js";
console.log(say2()); // '没错我就是很帅'
import 后面的大括号中的say2表示从规定模块导入的元素的名称。关键字from后面的字符串则表示要导入的模块的路径,这通常是包含模块的.js文件的相对或绝对路径名,需要注意的是只允许使用单引号和双引号的字符串来包裹路径,浏览器使用的路径格式与传给 script 元素的相同,所以必须把文件的扩展名也加上。
(注:由于Node.js遵循基于文件系统前缀以区分本地文件个包的惯例,即example是一个包,而./exampple.js是一个本地文件。为了更好的兼容多个浏览器Node.js环境,我们一定要在路径前包含./或../来表示要导入的文件。)
除此之外,我们还可以导入多个元素或者直接导入整个模块:
// 导入多个元素
improt { a, addA, say2 } from "./example.js";
console.log(a); // 1
sonsole.log(addA(1); // 2
// 导入整个模块
import * as example from "./example.js"
console.log(example.a); // 1
sonsole.log(example.addA(1); // 2
console.log(example.say2()); // '没错我就是很帅'
上面的导入整个模块就是把example.js中导出的所有元素全部加载到一个叫做example的对象中,而所导出的元素就会作为example的属性被访问。因为example对象是作为example.js中所导出成员的命名空间对象而被创建的,所以这种导入方式被称为命名空间导入(name space import)。
还有一点要注意的是,不管import语句把一个模块写了多少次,该模块只执行一次。意思就是,在首次执行导入模块后,实例化的模块就会被保存在内存中,只要使用import语句引用它就可以重复使用它:
// 首次导入需要加载模块example.js
import { a } from "./example.js"
// 下面的两个import将无需加载example.js了
import { addA } from "./example.js"
import { say2 } from "./example.js"
当从模块中导入一个元素时,它与const是一样无法定义另一个同名变量和导入一个同名元素,也无法在import语句前使用元素或者改变导出的元素的值:
//接上面的代码
say2 = 1 ; //会抛出一个错误
这是由于ES6的import语句为导入的元素创建的是只读绑定的标识符,而不是原始绑定。因此元素只有在被导出的模块中才可以被修改,即使是将该模块的全部导入也无法修改其中的元素。
// ./example.js
// 这是一个函数
export function setA(newA) {
a = newA;
}
// ./say.js
import { a, setA } from "./example";
console.log(a); // 1
a = 2; //抛出错误
// 所以我们得这么做
setA(2);
console.log(a); // 2
调用setA(2)时会返回到example.js中去执行,将a设置为2。由于say.js导入的只是a的只读绑定的标识符而已,因此会自动进行更改。
export和import在语法上还有一个重要的限制,那就是他们必须在条件语句和函数之外使用,例如:
if (ture) {
export var a = 1; //语法错误
}
function imp() {
import a from "./example.js"; //语法错误
}
由于模块语法存在的其中一个原因是让JavaScript引擎可以静态地确定哪些代码是可以导出的,因此export和import语句被设计成静态的,不能进行任何形式的动态导出或导入。
有时在开发中,我们在导入一些元素后不想使用它们的原始名称了,我们就可以在导出过程或者导入过程中去改变导出元素的名称:
// 导出过程
function add(a, b) {
return a + b;
}
export { add as add1 }; //在导入过程中必须使用add1作为名称
// 导入过程
import {add as add1 } from "./example"
console.log(add1(1,1)); // 2
console.log(typeof add); //undefined
在CommonJS等其他的模块化规范中,从模块中导出或导入默认值是一个常见的用法,因此在ES6中也延用了这种用法并进行了优化。在ES6中我们可以使用default关键字来指定默认值,并且一个模块只能默认一个导出值:
// ./example.js
// 第一种默认导出语法
export default function(a, b) {
return a + b;
}
// 第二种默认导出语法
function add(a, b) {
return a + b;
}
export default add;
// 第三种默认导出语法
function add(a, b) {
return a + b;
}
export { add as default };
需要注意的是第三种语法,default关键字虽然不能作为元素的名称,但可以作为元素的属性名称,因此可以使用as语法将add函数的属性设置为default。
导入默认值的语法则是这样的:
// 第一种语法
import add from "./example";
// 第二种语法
import { default as add } from "./example";
看到这里有些朋友可能会发现,我们的第一种语法中import关键字后面并没有加大括号,认为这是错误的。其实这是导入默认值的独特语法,在这的本地名称add用于表示模块导出的任何默认函数,这种语法是最纯净的,ES6标准创建团队的大佬们也希望这种语法能成为web主流的模块导入形式。
我们前面说的导入匿名函数也同样使用这种语法:
// ./example.js
//导出匿名函数
export default function(a, b) {
return a + b;
}
// ./say.js
import add from "./example";
console.log(add(1,1)); // 2
在这里本地名称add就是用于表示上面的匿名函数的。
我们同样可以在本模块内导出我们在本模块内导入的元素,有以下几种语法:
// 第一种语法
import { add } from ./example.js;
export { add };
// 第二种语法
export { add } from ./example.js;
//换一个名称导出
export { add as add1 } from ./example.js; //以add这个名称导入,再以add1的名称导出
// 导出整个模块
export * from ./example.js;
// 最后求一波暑期前端实习的坑位-_-||
Node.js模块分为三种:
require1函数用于在当前模块中加载和使用别的模块,传入一个模块名,返回一个模块导出对象。模块名可以使用相对路径(./)以及绝对路径(以/
或C:
之类的盘符开头)
// foo1至foo4中保存的是同一个模块的导出对象
var foo1 = require('./foo');
var foo2 = require('./foo.js');
var foo3 = require('/home/user/foo');
var foo4 = require('/home/user/foo.js');
export 对象是当前模块的导出对象,用于导出模块的共有方法和属性。别的模块通过require函数使用当前模块时得到的就是当前模块的export对象:
exports.sayhai = () => {
console.log("hi, node");
};
console.log(1);
通过module对象可以访问当前模块的一些相关信息,但用途最多的当属替换当前模块的导出对象:
module.exports = function () {
console.log('Hello node!');
};
以上代码,便将默认导出对象被替换为一个函数
一个模块的JS代码仅在模块第一次被使用时执行一次,并在执行过程中初始化模块的导出对象,之后,缓存起来的导出对象被重复利用
// example.js
let count = 0;
const add = () => {
return ++count;
};
exports.add = add;
// app.js
var example1 = require('./example');
var example2 = require('./example');
console.log(example1.add());
console.log(example2.add());
console.log(example1.add());
// 输出:1 2 3
由输出我们可以得出example.js并没有被require初始化两次。
具体的关于模块的记录:https://www.yuque.com/srtian/fe/agw824#TZgDs
上面已经知晓,require函数支持相对路径以及绝对路径,但这两种路径在模块之间建立了强耦合关系,一旦某个文件存放位置需要变更,使用该模块的其他模块的代码也需要调整。因此,require函数支持第三种路径,类似于 foo/bar
并依次按照下列规则解析路径,直到找到模块位置。
node_modules
目录存放模块。例如在该模块中使用require('foo/bar')
方式加载模块时,则NodeJS依次尝试使用以下路径: /home/user/node_modules/foo/bar
/home/node_modules/foo/bar
/node_modules/foo/bar
:
分隔,在Windows下使用;
分隔。例如定义了以下NODE_PATH环境变量:NODE_PATH=/home/user/lib:/home/lib
当使用require('foo/bar')
的方式加载模块时,则NodeJS依次尝试以下路径。
/home/user/lib/foo/bar
/home/lib/foo/bar
JS模块的基本单位是单个的JS文件,但复杂的模块往往会由多个子模块组成。为了方便管理以及使用,我们可以将多个子模块组成的大模块称之为包,并把所有子模块放在同一个目录里。
在组成一个包的所有子模块中,需要有一个入口模块,入口模块的导出对象被称为包的导出对象:
- /home/user/lib/
- cat/
head.js
body.js
main.js
上面的cat目录就定义了一个包,包括三个自模块,main作为入口模块:
var head = require('./head');
var body = require('./body');
exports.create = function (name) {
return {
name: name,
head: head.create(),
body: body.create()
};
};
在其他模块中使用包的时候,就需要加载包的入口模块,使用require('/home/user/lib/cat/main')
能达到目的,但是入口模块名称出现在路径里看上去不是个好主意。因此我们需要做点额外的工作,让包使用起来更像是单个模块。因此我们可以让模块的文件名为index.js,这样加载模块时可以使用模块所在目录的路径代替模块文件路径:
// 等价的:
var cat = require('/home/user/lib/cat');
var cat = require('/home/user/lib/cat/index');
此外我们可以使用package.json文件来指定入口模块的路径,比如这样:
// 路径
- /home/user/lib/
- cat/
+ doc/
- lib/
head.js
body.js
main.js
+ tests/
package.json
// package.json
{
"name": "cat",
"main": "./lib/main.js"
}
如此一来,就同样可以使用require('/home/user/lib/cat')
的方式加载模块。NodeJS会根据包目录下的package.json
找到入口模块所在位置。
NPM能解决很多Node.js代码部署上的问题,使用场景如下:
npm常用命令:
对于小文件的拷贝,我们可以使用fs.readFileSync从源路径读取内容,并使用fs.writeFileSync将文件内容写入目标路径。
process
是一个全局变量,可通过process.argv
获得命令行参数。由于argv[0]
固定等于NodeJS执行程序的绝对路径,argv[1]
固定等于主模块的绝对路径,因此第一个命令行参数从argv[2]
这个位置开始。
var fs = require('fs');
function copy(src, dst) {
fs.writeFileSync(dst, fs.readFileSync(src));
}
function main(argv) {
copy(argv[0], argv[1]);
}
main(process.argv.slice(2));
对于大文件,我们就不能一次将所有文件内容都读取到内存中然后一次写入磁盘,会导致内存爆仓。因此,我们只能读一点写一点,直到完成文件拷贝:
var fs = require('fs');
function copy(src, dst) {
fs.createReadStream(src).pipe(fs.createWriteStream(dst));
}
function main(argv) {
copy(argv[0], argv[1]);
}
main(process.argv.slice(2));
我们使用fs.createReadStream创建一个源文件的读取数据流,并使用fs.createWriteStream创建一个目标文件的只写数据流,并且用pipi方法将两个数据流连接起来。
Buffer
将JS的数据处理能力从字符串扩展到了任意二进制数据
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
bin[0]; // => 0x68;
var str = bin.toString('utf-8'); // => "hello"
var bin = new Buffer('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>
bin[0] = 0x48; //可更改
// slice方法不会返回一个新Buffer,而更像是返回了指向原Buffer中间的某个位置的指针
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var sub = bin.slice(2);
sub[0] = 0x65;
console.log(bin); // => <Buffer 68 65 65 6c 6f>
// 拷贝Buffer
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
var dup = new Buffer(bin.length);
bin.copy(dup);
dup[0] = 0x48;
console.log(bin); // => <Buffer 68 65 6c 6c 6f>
console.log(dup); // => <Buffer 48 65 65 6c 6f>
当内存中无法一次装下需要处理的数据,或者一边读取一边处理更加高效时,我们就需要使用数据流。NodeJS中通过各种Stream
来提供对数据流的操作。Stream
基于事件机制工作,所有Stream
的实例都继承于NodeJS提供的EventEmitter。
var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);
rs.on('data', function (chunk) {
if (ws.write(chunk) === false) {
rs.pause();
}
});
rs.on('end', function () {
ws.end();
});
// 根据drain事件来判断什么时候只写数据流已经将缓存中的数据写入目标
ws.on('drain', function () {
rs.resume();
});
fs
模块提供的API基本上可以分为以下三类:
fs.stat
、fs.chmod
、fs.chown
等等。fs.readFile
、fs.readdir
、fs.writeFile
、fs.mkdir
等等。fs.open
、fs.read
、fs.write
、fs.close
等等。基本上所有fs模块API的回调参数都有两个:
fs模块所有 API 都有对应的同步版本,用于无法使用异步操作,或者图标操作跟为方便的情况下使用,主要区别是:
Sync
try {
var data = fs.readFileSync(pathname);
// Deal with data.
} catch (err) {
// Deal with error.
}
NodeJS提供了path
内置模块来简化路径相关操作,并提升代码可读性:
var cache = {};
function store(key, value) {
// 将传入的路径转换为标准路径
cache[path.normalize(key)] = value;
}
store('foo/bar', 1);
store('foo//baz//../bar', 2);
console.log(cache); // => { "foo/bar": 2 }
// 将传入的多个路径拼接为标准路径
path.join('foo/', 'baz/', '../bar'); // => "foo/bar"
// 获取扩展名
path.extname('foo/bar.js'); // => ".js"
NodeJS内置了 http
模块,可以让我们实现一个简单的HTTP服务器:
const http = require("http");
http.createServer((request, response) => {
response.writeHead(200, { "Content-Type": "text-plain" });
response.end("Hello World
");
}).listen(8124);
http
模块提供了两种使用方式:
HTTP请求本质上是一个数据流,由请求头和请求体组成。HTTP请求在发送给服务器时,可以认为是按照从头到尾的顺序一个字节一个字节地以数据流方式发送的。而http模块创建的HTTP服务器在接收到完整的请求头后,就会调用回调函数,在回调函数中,除了可以使用request对象访问请求头数据外,还能把request对象当作一个只读数据流来访问请求体数据。
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.