Change

Change 查看完整档案

杭州编辑  |  填写毕业院校同花顺  |  前端开发 编辑填写个人主网站
编辑

从不知道到知道,从知道到理解,从理解到深入探索,这是个有趣的过程

个人动态

Change 赞了文章 · 2020-08-11

Vue项目SSR改造实战

博客已全站升级到https,如果遇到无法访问,请手动加上https://前缀

我们先看“疗效”,你可以打开我的博客u3xyz.com,通过查看源代码来看SSR直出效果。我的博客已经快上线一年了,但不吹不黑,访问量非常地小,我也一直在想办法提升访问量(包括在sf写文章,哈哈)。当然,在PC端,搜索引擎一直都是一个重要的流量来源。这里就不得不提到SEO。下图是我的博客以前在百度的快照:

SSR前快照

细心的朋友会发现,这个快照非常简单,简单到几乎什么都没有。这也是没办法的事,博客是基于Vue的SPA页面,整个项目本来就是一个“空架子”,这个快照从博客2月份上线以来就一直是上面的样子,直到最近上线SSR。搜索引擎蜘蛛每次来抓取你的网站都是一个样子,慢慢得,它也就不会来了,相应的,网站的权重,排名肯定不会好。到目前为此,我的博客不用网址进行搜索都搜不到。在上线了SSR后,再加上一些SEO优化,百度快照终于更新了:

SSR后快照

为什么要做SSR

文章开始基本已经回答了为什么要做SSR这个问题,当然,还有另一个原因是SSR概念现在在前端非常火,无奈在实际项目中没有机会,也只有拿博客来练手了。下面将详细介绍本博客项目SSR全过程。

SSR改造实战

总的来说SSR改造还是相当容易的。推荐在动手之前,先了解官方文档官方Vue SSR Demo,这会让我们事半功倍。

1. 构建改造

VueSSR原理

上图是Vue官方的SSR原理介绍图片。从这张图片,我们可以知道:我们需要通过Webpack打包生成两份bundle文件:

  • Client Bundle,给浏览器用。和纯Vue前端项目Bundle类似
  • Server Bundle,供服务端SSR使用,一个json文件

不管你项目先前是什么样子,是否是使用vue-cli生成的。都会有这个构建改造过程。在构建改造这里会用到 vue-server-renderer 库,这里要注意的是 vue-server-renderer 版本要与Vue版本一样。下图是我的构建文件目录:

构建

  • util.js 提供一些公共方法
  • webpack.base.js是公共的配置
  • webpack.client.js 是生成Client Bundle的配置。核心配置如下:
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

// ...

const config = merge(baseConfig, {
  target: 'web',
  entry: './src/entry.client.js',
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"client"'
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vender',
      minChunks: 2
    }),
    // extract webpack runtime & manifest to avoid vendor chunk hash changing
    // on every build.
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest'
    }),
    new VueSSRClientPlugin()
  ]
})
  • webpack.server.js 是生成Server Bundle的配置,核心配置如下:
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

// ...

const config = merge(baseConfig, {
  target: 'node',
  devtool: '#source-map',
  entry: './src/entry.server.js',
  output: {
    libraryTarget: 'commonjs2',
    filename: 'server-bundle.js'
  },
  externals: nodeExternals({
    // do not externalize CSS files in case we need to import it from a dep
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
  ]
})

2. 代码改造

2.1 必须使用VueRouter, Vuex。ajax库建议使用axios

可能你的项目没有使用VueRouter或Vuex。但遗憾的是,Vue-SSR必须基于 Vue + VueRouter + Vuex。Vuex官方没有提,但其实文档和Demo都是基于Vuex。我的博客以前也没有用Vuex,但经过一翻折腾后,还是乖乖加上了Vuex。另外,因为代码要能同时在浏览器和Node.js环境中运行,所以ajax库建议使用axios这样的跨平台库。

2.2 两个打包入口(entry),重构app, store, router, 为每个对象增加工厂方法createXXX

每个用户通过浏览器访问Vue页面时,都是一个全新的上下文,但在服务端,应用启动后就一直运行着,处理每个用户请求的都是在同一个应用上下文中。为了不串数据,需要为每次SSR请求,创建全新的app, store, router

项目目录

上图是我的项目文件目录。

  • app.js, 通用的启动Vue应用代码
  • App.vue,Vue应用根组件
  • entry.client.js,浏览器环境入口
  • entry.server.js,服务器环境入口
  • index.html,html模板

再看一下具体实现的核心代码:

// app.js

import Vue from 'vue'
import App from './App.vue' // 根组件
import {createRouter} from './routers/index' 
import {createStore} from './vuex/store'
import {sync} from 'vuex-router-sync' // 把当VueRouter状态同步到Vuex中

// createApp工厂方法
export function createApp (ssrContext) {
  let router = createRouter() // 创建全新router实例
  let store = createStore() // 创建全新store实例

  // 同步路由状态到store中
  sync(store, router)
  
  // 创建Vue应用
  const app = new Vue({
    router,
    store,
    ssrContext,
    render: h => h(App)
  })
  return {app, router, store}
}
// entry.client.js 

import Vue from 'vue'
import { createApp } from './app'

const { app, router, store } = createApp()

// 如果有__INITIAL_STATE__变量,则将store的状态用它替换
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    
  // 通过路由勾子,执行拉取数据逻辑
  router.beforeResolve((to, from, next) => {
    // 找到增量组件,拉取数据 
    const matched = router.getMatchedComponents(to) 
    const prevMatched = router.getMatchedComponents(from) 
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })
    // 组件数据通过执行asyncData方法获取
    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }
    // 要注意asyncData方法要返回promise,asyncData调用的vuex action也必须返回promise
    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
      .then(() => {
        next()
      })
      .catch(next)
  })

  // 将Vue实例挂载到dom中,完成浏览器端应用启动
  app.$mount('#app')
})
// entry.server.js
import { createApp } from './app'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp(context)

    // 设置路由
    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // 执行asyncData方法,预拉取数据
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store: store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 将store的快照挂到ssr上下文上
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}
// createStore

import Vue from 'vue'
import Vuex from 'vuex'
// ...

Vue.use(Vuex)

// createStore工厂方法
export function createStore () {
  return new Vuex.Store({
    // rootstate
    state: {
      appName: 'appName',
      title: 'home'
    },

    modules: {
      // ...
    },

    strict: process.env.NODE_ENV !== 'production' // 线上环境关闭store检查
  })
}
// createRouter

import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)

// createRouter工厂方法
export function createRouter () {
  return new Router({
    mode: 'history', // 注意这里要使用history模式,因为hash不会发送到服务端
    fallback: false,
    routes: [
      {
        path: '/index',
        name: 'index',
        component: () => System.import('./index/index.vue') // 代码分片
      },
      {
        path: '/detail/:aid',
        name: 'detail',
        component: () => System.import('./detail/detail.vue')
      },
      // ...
      {
        path: '/',
        redirect: '/index'
      }
    ]
  })
}

3. 重构组件获取数据方式

关于状态管理,要严格遵守Redux思想。建议把应用所有状态都存于store中,组件使用时再mapState下来,状态更改严格使用action的方式。另一个要提一点的是,action要返回promise。这样我们就可以使用asyncData方法获取组件数据了

const actions = {
  getArticleList ({state, commit}, curPageNum) {
    commit(FETCH_ARTICLE_LIST, curPageNum)

    // action 要返回promise
    return apis.getArticleList({
      data: {
        size: state.pagi.itemsPerPage,
        page: curPageNum
      }
    }).then((res) => {
      // ...
    })
  }
}

// 组件asyncData实现
export default {
  asyncData ({ store }) {
    return store.dispatch('getArticleList', 1)
  }
}

3. SSR服务器实现

在完成构建和代码改造后,如果一切顺利。我们能得到下面的打包文件:

dist文件

这时,我们可以开始实现SSR服务端代码了。下面是我博客SSR实现(基于Koa)

// server.js
const Koa = require('koa')
const path = require('path')
const logger = require('./logger')
const server = new Koa()
const { createBundleRenderer } = require('vue-server-renderer')
const templateHtml = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')

let distPath = './dist'

const renderer = createBundleRenderer(require(`${distPath}/vue-ssr-server-bundle.json`), { 
  runInNewContext: false,
  template: templateHtml, 
  clientManifest: require(`${distPath}/vue-ssr-client-manifest.json`) 
})

server.use(function * (next) {
  let ctx = this
  const context = { url: ctx.req.url, pageTitle: 'default-title' }

  // cgi请求,前端资源请求不能转到这里来。这里可以通过nginx做
  if (/\.\w+$/.test(context.url)) {
    return yield next
  }

  // 注意这里也必须返回promise  
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, function (err, html) {
      if (err) {
        logger.error(`[error][ssr-error]: ` + err.stack)
        return reject(err)
      }
      ctx.status = 200
      ctx.type = 'text/html; charset=utf-8'
      ctx.body = html
      resolve(html)
    })
  })
})

// 错误处理
server.on('error', function (err) {
  logger.error('[error][server-error]: ' + err.stack)
})

let port = 80

server.listen(port, () => {
  logger.info(`[info]: server is deploy on port: ${port}`)
})

4. 服务器部署

服务器部署,跟你的项目架构有关。比如我的博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:

u3xyz架构

5. 遇到的问题及解决办法

加载不到组件的JS文件
[vue-router] Failed to resolve async component default: Error: Cannot find module 'js\main1.js'
[vue-router] uncaught error during route navigation:

解决办法:

去掉webpack配置中的output.chunkFilename: getFileName('js/main[name]-$hash.js')

if you are using CommonsChunkPlugin, make sure to use it only in the client config because the server bundle requires a single entry chunk.

所以对webpack.server.js不要对配置CommonsChunkPlugin,也不要设置output.chunkFilename

代码高亮codeMirror使用到navigator对象,只能在浏览器环境运行

把执行逻辑放到mounted回调中。实现不行,就封装一个异步组件,把组件的初始化放到mounted中:

mounted () {
  let paragraph = require('./paragraph.vue')
  Vue.component('paragraph', paragraph)
  new Vue().$mount('#paragraph')
},
串数据

dispatch的action没有返回promise,保证返回promise即可

路由跳转

路由跳转使用router方法或<router-link />标签,这两种方式能自适应浏览器端和服务端,不要使用a标签

小结

本文主要记录了我的博客u3xyz.comSSR过程:

  • 构建webpack改造
  • 代码改造
  • server端SSR实现
  • 上线部署

最后希望文章能对大家有些许帮助!

愿文地址:Vue项目SSR改造实战

查看原文

赞 78 收藏 138 评论 23

Change 收藏了文章 · 2020-07-15

webpack打包原理 ? 看完这篇你就懂了 !

webpack.jpeg

前言

[实践系列] 主要是让我们通过实践去加深对一些原理的理解。

[实践系列]前端路由

[实践系列]Babel 原理

[实践系列]实践这一次,彻底搞懂浏览器缓存机制

[实践系列]你能手写一个 Promise 吗?Yes I promise。

有兴趣的同学可以关注 [实践系列] 。 求 star 求 follow~

本文通过实现一个简单 webpack,来理解它的打包原理,看完不懂直接盘我 !!!
webpack.jpg

什么是 webpack ?

本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。

webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
webpack 通过 Tapable 来组织这条复杂的生产线。 webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。 -- 深入浅出 webpack 吴浩麟

webpack.png

webpack 核心概念

Entry

入口起点(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。

进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。

每个依赖项随即被处理,最后输出到称之为 bundles 的文件中。

Output

output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值为 ./dist。

基本上,整个应用程序结构,都会被编译到你指定的输出路径的文件夹中。

Module

模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。

Chunk

代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

Loader

loader 让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。

loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。

本质上,webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。

Plugin

loader 被用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。

插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。

webpack 构建流程

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程 :

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。
  3. 确定入口:根据配置中的 entry 找出所有的入口文件。
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。
  5. 完成模块编译:在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

实践加深理解,撸一个简易 webpack

1. 定义 Compiler 类

class Compiler {
  constructor(options) {
    // webpack 配置
    const { entry, output } = options
    // 入口
    this.entry = entry
    // 出口
    this.output = output
    // 模块
    this.modules = []
  }
  // 构建启动
  run() {}
  // 重写 require函数,输出bundle
  generate() {}
}

2. 解析入口文件,获取 AST

我们这里使用@babel/parser,这是 babel7 的工具,来帮助我们分析内部的语法,包括 es6,返回一个 AST 抽象语法树。

// webpack.config.js

const path = require('path')
module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'main.js'
  }
}
//
const fs = require('fs')
const parser = require('@babel/parser')
const options = require('./webpack.config')

const Parser = {
  getAst: path => {
    // 读取入口文件
    const content = fs.readFileSync(path, 'utf-8')
    // 将文件内容转为AST抽象语法树
    return parser.parse(content, {
      sourceType: 'module'
    })
  }
}

class Compiler {
  constructor(options) {
    // webpack 配置
    const { entry, output } = options
    // 入口
    this.entry = entry
    // 出口
    this.output = output
    // 模块
    this.modules = []
  }
  // 构建启动
  run() {
    const ast = Parser.getAst(this.entry)
  }
  // 重写 require函数,输出bundle
  generate() {}
}

new Compiler(options).run()

3. 找出所有依赖模块

Babel 提供了@babel/traverse(遍历)方法维护这 AST 树的整体状态,我们这里使用它来帮我们找出依赖模块。

const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default

const Parser = {
  getAst: path => {
    // 读取入口文件
    const content = fs.readFileSync(path, 'utf-8')
    // 将文件内容转为AST抽象语法树
    return parser.parse(content, {
      sourceType: 'module'
    })
  },
  getDependecies: (ast, filename) => {
    const dependecies = {}
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename)
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value)
        dependecies[node.source.value] = filepath
      }
    })
    return dependecies
  }
}

class Compiler {
  constructor(options) {
    // webpack 配置
    const { entry, output } = options
    // 入口
    this.entry = entry
    // 出口
    this.output = output
    // 模块
    this.modules = []
  }
  // 构建启动
  run() {
    const { getAst, getDependecies } = Parser
    const ast = getAst(this.entry)
    const dependecies = getDependecies(ast, this.entry)
  }
  // 重写 require函数,输出bundle
  generate() {}
}

new Compiler(options).run()

4. AST 转换为 code

将 AST 语法树转换为浏览器可执行代码,我们这里使用@babel/core 和 @babel/preset-env。

const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

const Parser = {
  getAst: path => {
    // 读取入口文件
    const content = fs.readFileSync(path, 'utf-8')
    // 将文件内容转为AST抽象语法树
    return parser.parse(content, {
      sourceType: 'module'
    })
  },
  getDependecies: (ast, filename) => {
    const dependecies = {}
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename)
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value)
        dependecies[node.source.value] = filepath
      }
    })
    return dependecies
  },
  getCode: ast => {
    // AST转换为code
    const { code } = transformFromAst(ast, null, {
      presets: ['@babel/preset-env']
    })
    return code
  }
}

class Compiler {
  constructor(options) {
    // webpack 配置
    const { entry, output } = options
    // 入口
    this.entry = entry
    // 出口
    this.output = output
    // 模块
    this.modules = []
  }
  // 构建启动
  run() {
    const { getAst, getDependecies, getCode } = Parser
    const ast = getAst(this.entry)
    const dependecies = getDependecies(ast, this.entry)
    const code = getCode(ast)
  }
  // 重写 require函数,输出bundle
  generate() {}
}

new Compiler(options).run()

5. 递归解析所有依赖项,生成依赖关系图

const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

const Parser = {
  getAst: path => {
    // 读取入口文件
    const content = fs.readFileSync(path, 'utf-8')
    // 将文件内容转为AST抽象语法树
    return parser.parse(content, {
      sourceType: 'module'
    })
  },
  getDependecies: (ast, filename) => {
    const dependecies = {}
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename)
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value)
        dependecies[node.source.value] = filepath
      }
    })
    return dependecies
  },
  getCode: ast => {
    // AST转换为code
    const { code } = transformFromAst(ast, null, {
      presets: ['@babel/preset-env']
    })
    return code
  }
}

class Compiler {
  constructor(options) {
    // webpack 配置
    const { entry, output } = options
    // 入口
    this.entry = entry
    // 出口
    this.output = output
    // 模块
    this.modules = []
  }
  // 构建启动
  run() {
    // 解析入口文件
    const info = this.build(this.entry)
    this.modules.push(info)
    this.modules.forEach(({ dependecies }) => {
      // 判断有依赖对象,递归解析所有依赖项
      if (dependecies) {
        for (const dependency in dependecies) {
          this.modules.push(this.build(dependecies[dependency]))
        }
      }
    })
    // 生成依赖关系图
    const dependencyGraph = this.modules.reduce(
      (graph, item) => ({
        ...graph,
        // 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容
        [item.filename]: {
          dependecies: item.dependecies,
          code: item.code
        }
      }),
      {}
    )
  }
  build(filename) {
    const { getAst, getDependecies, getCode } = Parser
    const ast = getAst(filename)
    const dependecies = getDependecies(ast, filename)
    const code = getCode(ast)
    return {
      // 文件路径,可以作为每个模块的唯一标识符
      filename,
      // 依赖对象,保存着依赖模块路径
      dependecies,
      // 文件内容
      code
    }
  }
  // 重写 require函数,输出bundle
  generate() {}
}

new Compiler(options).run()

6. 重写 require 函数,输出 bundle

const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

const Parser = {
  getAst: path => {
    // 读取入口文件
    const content = fs.readFileSync(path, 'utf-8')
    // 将文件内容转为AST抽象语法树
    return parser.parse(content, {
      sourceType: 'module'
    })
  },
  getDependecies: (ast, filename) => {
    const dependecies = {}
    // 遍历所有的 import 模块,存入dependecies
    traverse(ast, {
      // 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
      ImportDeclaration({ node }) {
        const dirname = path.dirname(filename)
        // 保存依赖模块路径,之后生成依赖关系图需要用到
        const filepath = './' + path.join(dirname, node.source.value)
        dependecies[node.source.value] = filepath
      }
    })
    return dependecies
  },
  getCode: ast => {
    // AST转换为code
    const { code } = transformFromAst(ast, null, {
      presets: ['@babel/preset-env']
    })
    return code
  }
}

class Compiler {
  constructor(options) {
    // webpack 配置
    const { entry, output } = options
    // 入口
    this.entry = entry
    // 出口
    this.output = output
    // 模块
    this.modules = []
  }
  // 构建启动
  run() {
    // 解析入口文件
    const info = this.build(this.entry)
    this.modules.push(info)
    this.modules.forEach(({ dependecies }) => {
      // 判断有依赖对象,递归解析所有依赖项
      if (dependecies) {
        for (const dependency in dependecies) {
          this.modules.push(this.build(dependecies[dependency]))
        }
      }
    })
    // 生成依赖关系图
    const dependencyGraph = this.modules.reduce(
      (graph, item) => ({
        ...graph,
        // 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容
        [item.filename]: {
          dependecies: item.dependecies,
          code: item.code
        }
      }),
      {}
    )
    this.generate(dependencyGraph)
  }
  build(filename) {
    const { getAst, getDependecies, getCode } = Parser
    const ast = getAst(filename)
    const dependecies = getDependecies(ast, filename)
    const code = getCode(ast)
    return {
      // 文件路径,可以作为每个模块的唯一标识符
      filename,
      // 依赖对象,保存着依赖模块路径
      dependecies,
      // 文件内容
      code
    }
  }
  // 重写 require函数 (浏览器不能识别commonjs语法),输出bundle
  generate(code) {
    // 输出文件路径
    const filePath = path.join(this.output.path, this.output.filename)
    // 懵逼了吗? 没事,下一节我们捋一捋
    const bundle = `(function(graph){
      function require(module){
        function localRequire(relativePath){
          return require(graph[module].dependecies[relativePath])
        }
        var exports = {};
        (function(require,exports,code){
          eval(code)
        })(localRequire,exports,graph[module].code);
        return exports;
      }
      require('${this.entry}')
    })(${JSON.stringify(code)})`

    // 把文件内容写入到文件系统
    fs.writeFileSync(filePath, bundle, 'utf-8')
  }
}

new Compiler(options).run()

7. 看完这节,彻底搞懂 bundle 实现

我们通过下面的例子来进行讲解,先死亡凝视 30 秒

;(function(graph) {
  function require(moduleId) {
    function localRequire(relativePath) {
      return require(graph[moduleId].dependecies[relativePath])
    }
    var exports = {}
    ;(function(require, exports, code) {
      eval(code)
    })(localRequire, exports, graph[moduleId].code)
    return exports
  }
  require('./src/index.js')
})({
  './src/index.js': {
    dependecies: { './hello.js': './src/hello.js' },
    code: '"use strict";\n\nvar _hello = require("./hello.js");\n\ndocument.write((0, _hello.say)("webpack"));'
  },
  './src/hello.js': {
    dependecies: {},
    code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports.say = say;\n\nfunction say(name) {\n  return "hello ".concat(name);\n}'
  }
})

step 1 : 从入口文件开始执行

// 定义一个立即执行函数,传入生成的依赖关系图
;(function(graph) {
  // 重写require函数
  function require(moduleId) {
    console.log(moduleId) // ./src/index.js
  }
  // 从入口文件开始执行
  require('./src/index.js')
})({
  './src/index.js': {
    dependecies: { './hello.js': './src/hello.js' },
    code: '"use strict";\n\nvar _hello = require("./hello.js");\n\ndocument.write((0, _hello.say)("webpack"));'
  },
  './src/hello.js': {
    dependecies: {},
    code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports.say = say;\n\nfunction say(name) {\n  return "hello ".concat(name);\n}'
  }
})

step 2 : 使用 eval 执行代码

// 定义一个立即执行函数,传入生成的依赖关系图
;(function(graph) {
  // 重写require函数
  function require(moduleId) {
    ;(function(code) {
      console.log(code) // "use strict";\n\nvar _hello = require("./hello.js");\n\ndocument.write((0, _hello.say)("webpack"));
      eval(code) // Uncaught TypeError: Cannot read property 'code' of undefined
    })(graph[moduleId].code)
  }
  // 从入口文件开始执行
  require('./src/index.js')
})({
  './src/index.js': {
    dependecies: { './hello.js': './src/hello.js' },
    code: '"use strict";\n\nvar _hello = require("./hello.js");\n\ndocument.write((0, _hello.say)("webpack"));'
  },
  './src/hello.js': {
    dependecies: {},
    code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports.say = say;\n\nfunction say(name) {\n  return "hello ".concat(name);\n}'
  }
})

可以看到,我们在执行"./src/index.js"文件代码的时候报错了,这是因为 index.js 里引用依赖 hello.js,而我们没有对依赖进行处理,接下来我们对依赖引用进行处理。

step 3 : 依赖对象寻址映射,获取 exports 对象

// 定义一个立即执行函数,传入生成的依赖关系图
;(function(graph) {
  // 重写require函数
  function require(moduleId) {
    // 找到对应moduleId的依赖对象,调用require函数,eval执行,拿到exports对象
    function localRequire(relativePath) {
      return require(graph[moduleId].dependecies[relativePath]) // {__esModule: true, say: ƒ say(name)}
    }
    // 定义exports对象
    var exports = {}
    ;(function(require, exports, code) {
      // commonjs语法使用module.exports暴露实现,我们传入的exports对象会捕获依赖对象(hello.js)暴露的实现(exports.say = say)并写入
      eval(code)
    })(localRequire, exports, graph[moduleId].code)
    // 暴露exports对象,即暴露依赖对象对应的实现
    return exports
  }
  // 从入口文件开始执行
  require('./src/index.js')
})({
  './src/index.js': {
    dependecies: { './hello.js': './src/hello.js' },
    code: '"use strict";\n\nvar _hello = require("./hello.js");\n\ndocument.write((0, _hello.say)("webpack"));'
  },
  './src/hello.js': {
    dependecies: {},
    code:
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports.say = say;\n\nfunction say(name) {\n  return "hello ".concat(name);\n}'
  }
})

这下应该明白了吧 ~ 可以直接复制上面代码到控制台输出哦~

完整代码地址戳我 👈

总结

Webpack 是一个庞大的 Node.js 应用,如果你阅读过它的源码,你会发现实现一个完整的 Webpack 需要编写非常多的代码。 但你无需了解所有的细节,只需了解其整体架构和部分细节即可。

对 Webpack 的使用者来说,它是一个简单强大的工具; 对 Webpack 的开发者来说,它是一个扩展性的高系统。

Webpack 之所以能成功,在于它把复杂的实现隐藏了起来,给用户暴露出的只是一个简单的工具,让用户能快速达成目的。 同时整体架构设计合理,扩展性高,开发扩展难度不高,通过社区补足了大量缺失的功能,让 Webpack 几乎能胜任任何场景。

参考

webpack 中文文档

深入浅出 webpack

后记

如果你和我一样喜欢前端,也爱动手折腾,欢迎关注我一起玩耍啊~ ❤️

github 地址,欢迎 follow 哦~

博客

我的博客,点 star,不迷路~

公众号

前端时刻

qrcode.gif

查看原文

Change 收藏了文章 · 2019-12-12

程序员练级攻略(2018):前端基础和底层原理

图片描述

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!

这个是我订阅 陈皓老师在极客上的专栏《左耳听风》,我整理出来是为了自己方便学习,同时也分享给你们一起学习,当然如果有兴趣,可以去订阅,为了避免广告嫌疑,我这就不多说了!以下第一人称是指陈皓老师。

对于前端的学习和提高,我的基本思路是这样的。首先,前端的三个最基本的东西 HTML5、CSS3 和 JavaScript(ES6)是必须要学好的。这其中有很多很多的技术,比如,CSS3 引申出来的 Canvas(位图)、SVG(矢量图) 和 WebGL(3D 图),以及 CSS 的各种图形变换可以让你做出非常丰富的渲染效果和动画效果。

ES6 简直就是把 JavaScript 带到了一个新的台阶,JavaScript 语言的强大,大大释放了前端开发人员的生产力,让前端得以开发更为复杂的代码和程序,于是像 React 和 Vue 这样的框架开始成为前端编程的不二之选。

我一直认为学习任何知识都要从基础出发,所以我会有很大的篇幅在讲各种技术的基础知识和基本原理,尤其是如下的这些知识,都是前端程序员需要一块一块啃掉的硬骨头。

  • JavaScript 的核心原理。这里我会给出好些网上很不错的讲 JavaScript 的原理的文章或图书,你一定要学好语言的特性和其中的各种坑。
  • 浏览器的工作原理。这也是一块硬骨头,我觉得这是前端程序员需要了解和明白的东西,不然,你将无法深入下去。
  • 网络协议 HTTP。也是要着重了解的,尤其是 HTTP/2,还有 HTTP 的几种请求方式:短连接、长连接、Stream 连接、WebSocket 连接。
  • 前端性能调优。有了以上的这些基础后,你就可以进入前端性能调优的主题了,我相信你可以很容易上手各种性能调优技术的。
  • 框架学习。我只给了 React 和 Vue 两个框架。就这两个框架来说,Virtual DOM 技术是其底层技术,组件化是其思想,管理组件的状态是其重点。而对于 React 来说,函数式编程又是其编程思想,所以,这些基础技术都是你需要好好研究和学习的。
  • UI 设计。设计也是前端需要做的一个事,比如像 Google 的 Material UI,或是比较流行的 Atomic Design 等应该是前端工程师需要学习的。

而对于工具类的东西,这里我基本没怎么涉及,因为本文主要还是从原理和基础入手。那些工具我觉得都很简单,就像学习 Java 我没有让你去学习 Maven 一样,因为只要你去动手了,这种知识你自然就会获得,我们还是把精力重点放在更重要的地方。

下面我们从前端基础和底层原理开始讲起。先来讲讲 HTML5 相关的内容。

HTML5

  • HTML5 权威指南 ,本书面向初学者和中等水平 Web 开发人员,是牢固掌握 HTML5、CSS3 和 JavaScript 的必读之作。书看起来比较厚,是因为里面的代码很多。
  • HTML5 Canvas 核心技术 ,如果你要做 HTML5 游戏的话,这本书必读。

对于 SVG、Canvas 和 WebGL 这三个对应于矢量图、位图和 3D 图的渲染来说,给前端开发带来了重武器,很多 HTML5 小游戏也因此蓬勃发展。所以,你可以学习一下。

学习这三个技术,我个人觉得最好的地方是 MDN。

最后是几个资源列表。

CSS

在《程序员练级攻略(2018)》系列文章最开始,我们就推荐过 CSS 的在线学习文档,这里再推荐一下

MDN Web Doc - CSS 。我个人觉得只要你仔细读一下文档,CSS 并不难学。绝大多数觉得难的,一方面是文档没读透,另一方面是浏览器支持的标准不一致。所以,学好 CSS 最关键的还是要仔细地读文档。

之后,在写 CSS 的时候,你会发现,你的 CSS 中有很多看起来相似的东西。你的 DRY - Don’t Repeat Yourself 洁癖告诉你,这是不对的。所以,你需要学会使用 LESSSaSS
这两个 CSS 预处理工具,其可以帮你提高很多效率。

然后,你需要学习一下 CSS 的书写规范,前面的《程序员修养》一文中提到过一些,这里再补充几个。

如果你需要更有效率,那么你还需要使用一些 CSS Framework,其中最著名的就是 Twitter 公司的 Bootstrap,其有很多不错的 UI 组件,页面布局方案,可以让你非常方便也非常快速地开发页面。除此之外,还有,主打清新 UI 的 Semantic UI 、主打响应式界面的 Foundation 和基于 Flexbox 的 Bulma

当然,在使用 CSS 之前,你需要把你浏览器中的一些 HTML 标签给标准化掉。所以,推荐几个 Reset 或标准化的 CSS 库:NormalizeMiniRest.csssanitize.cssunstyle.css

关于更多的 CSS 框架,你可以参看 Awesome CSS Frameworks

接下来,是几个公司的 CSS 相关实践,供你参考。

CodePen’s CSS

Github 的 CSS

Medium’s CSS is actually pretty f*ing good

CSS at BBC Sport

Refining The Way We Structure Our CSS At Trello

最后是一个可以写出可扩展的 CSS 的阅读列表 A Scalable CSS Reading List

JavaScript

下面是学习 JavaScript 的一些图书和文章。

浏览器原理

你需要了解一下浏览器是怎么工作的,所以,你必需要看《How browsers work》。这篇文章受众之大,后来被人重新整理并发布为《How Browsers Work: Behind the scenes of modern web browsers》,其中还包括中文版。这篇文章非常非常长,所以,你要有耐心看完。如果你想看个精简版的,可以看我在 Coolshell 上发的《浏览器的渲染原理简介》或是看一下这个幻灯片

然后,是对 Virtual DOM 的学习。Virtual DOM 是 React 的一个非常核心的技术细节,它也是前端渲染和性能的关键技术。所以,你有必要要好好学习一下这个技术的实现原理和算法。当然,前提条件是你需要学习过前面我所推荐过的浏览器的工作原理。下面是一些不错的文章可以帮你学习这一技术。

网络协议

小结

总结一下今天的内容。我一直认为学习任何知识都要从基础出发,所以今天我主要讲述了 HTML5、CSS3 和 JavaScript(ES6)这三大基础核心,给出了大量的图书、文章以及其他一些相关的学习资源。之后,我建议你学习浏览器的工作原理和网络协议相关的内容。我认为,掌握这些原理也是学好前端知识的前提和基础。值得花时间,好好学习消化。

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

你们的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

查看原文

Change 收藏了文章 · 2019-12-12

灵活使用 console 让 js 调试更简单

个人专栏 ES6 深入浅出已上线,深入ES6 ,通过案例学习掌握 ES6 中新特性一些使用技巧及原理,持续更新中,←点击可订阅。

点赞再看,养成习惯

本文 GitHubhttps://github.com/qq44924588... 上已经收录,更多往期高赞文章的分类,也整理了很多我的文档,和教程资料。欢迎Star和完善,大家面试可以参照考点复习,希望我们一起有点东西。


为了保证的可读性,本文采用意译而非直译。

Web开发最常用的高度就是 console.log ,虽然 console.log 占有一席之地,但很多人并没有意识到 console 本身除了基本 log 方法之外还有很多其他方法。 适当使用这些方法可以使调试更容易,更快速,更直观。

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!

console.log()

console.log 中有很多人们意想不到的功能。虽然大多数人使用 console.log(object) 来查看对象,但是你也可以使用 console.log(object, otherObject, string),它会把它们都整齐地记录下来,偶尔也会很方便。

不仅如此,还有另一种格式化的: console.log(msg, values),这很像 C 或 PHP 中的sprintf

console.log('I like %s but I do not like %s.', 'Skittles', 'pus');

会像你预期的那样输出:

> I like Skittles but I do not like pus.

常见的占位符 %o (这是字母o,不是0),它接受对象,%s 接受字符串,%d 表示小数或整数。

图片描述

另一个有趣的是 %c,这可能与你所想不太相同,它实际上是CSS值的占位符。使用%c占位符时,对应的后面的参数必须是CSS语句,用来对输出内容进行CSS渲染。常见的输出方式有两种:文字样式、图片输出

console.log('I am a %cbutton', 'color: white; background-color: orange; padding: 2px 5px; border-radius: 2px');

clipboard.png

它并不优雅,也不是特别有用。当然,这并不是一个真正的按钮。

clipboard.png

它有用吗? 恩恩恩。

console.dir()

在大多数情况下,console.dir() 的函数非常类似于 log(),尽管它看起来略有不同。

clipboard.png

下拉小箭头将显示与上面相同的对象详细信息,这也可以从console.log 版本中看到。当你查看元素的结构时候,你会发现它们之间的差异更大,也更有趣。

let element = document.getElementById('2x-container');

使用 console.log 查看:

clipboard.png

打开了一些元素,这清楚地显示了 DOM,我们可以在其中导航。但是console.dir(element)给出了更加方便查看 DOM 结构的输出:

这是一种更客观地看待元素的方式。有时候,这可能是您真正想要的,更像是检查元素。

clipboard.png

console.warn()

可能是最明显的直接替换 log(),你可以以完全相同的方式使用 console.warn()。 唯一真正的区别是输出字的颜色是黄色的。 具体来说,输出处于警告级别而不是信息级别,因此浏览器将稍微区别对待它。 这具有使其在杂乱输出中更明显的效果。

clipboard.png

不过,还有一个更大的优势,因为输出是警告而不是信息,所以你可以过滤掉所有console.log并仅保留console.warn。 这对于偶尔会在浏览器中输出大量无用废话的应用程序尤其有用。 清除一些无用的信息可以让你更轻松地看到你想要的输出。

console.table()

令人惊讶的是,这并不是更为人所知,但是 console.table() 函数旨在以一种比仅仅转出原始对象数组更整洁的方式显示表格数据。

例如,这里有一个数据列表。

const data = [{
  id: "7cb1-e041b126-f3b8",
  seller: "WAL0412",
  buyer: "WAL3023",
  price: 203450,
  time: 1539688433
},
{
  id: "1d4c-31f8f14b-1571",
  seller: "WAL0452",
  buyer: "WAL3023",
  price: 348299,
  time: 1539688433
},
{
  id: "b12c-b3adf58f-809f",
  seller: "WAL0012",
  buyer: "WAL2025",
  price: 59240,
  time: 1539688433
}];

如果我们使用 console.log 来输出上面的内容,我们会得到一些非常无用的输出:

▶ (3) [{…}, {…}, {…}]

点击这个小箭头可以展开看到对象的内容,但是,它并不是我们想要的“一目了然”。

但是 console.table(data) 的输出要有用得多。

clipboard.png

第二个可选参数是所需列的列表。显然,所有列都是默认值,但我们也可以这样做:

> console.table(data, ["id", "price"]);

clipboard.png

这里要注意的是这是乱序的 - 最右边的列标题上的箭头显示了原因。 我点击该列进行排序。 找到列的最大或最小,或者只是对数据进行不同的查看非常方便。 顺便说一句,该功能与仅显示一些列无关,它总是可用的。

console.table() 只能处理最多1000行,因此它可能不适合所有数据集。

console.assert()

assert()log() 是相同的函数,assert()是对输入的表达式进行断言,只有表达式为false时,才输出相应的信息到控制台,示例如下:

var arr = [1, 2, 3];
console.assert(arr.length === 4);

clipboard.png

有时我们需要更复杂的条件句。例如,我们已经看到了用户 WAL0412 的数据问题,并希望仅显示来自这些数据的事务,这是直观的解决方案。

console.assert(tx.buyer === 'WAL0412', tx);

这看起来不错,但行不通。记住,条件必须为false,断言才会执行,更改如下:

console.assert(tx.buyer !== 'WAL0412', tx);

与其中一些类似,console.assert() 并不总是特别有用。但在特定的情况下,它可能是一个优雅的解决方案。

console.count()

另一个具有特殊用途的计数器,count只是作为一个计数器,或者作为一个命名计数器,可以统计代码被执行的次数。

for(let i = 0; i < 10000; i++) {
  if(i % 2) {
    console.count('odds');
  }
  if(!(i % 5)) {
    console.count('multiplesOfFive');
  }
  if(isPrime(i)) {
    console.count('prime');
  }
}

这不是有用的代码,而且有点抽象。这边也不打算演示 isPrime 函数,假设它是成立的。

执行后我们会得到一个列表:

odds: 1
odds: 2
prime: 1
odds: 3
multiplesOfFive: 1
prime: 2
odds: 4
prime: 3
odds: 5
multiplesOfFive: 2
...

还有一个相关的 console.countReset(),可以使用它重置计数器。

console.trace()

trace() 在简单的数据中很难演示。当您试图在类或库中找出是哪个实际调用者导致了这个问题时,它的优势就显现出来了。

例如,可能有 12 个不同的组件调用一个服务,但是其中一个组件没有正确地设置依赖项。

export default class CupcakeService {
    
  constructor(dataLib) {
    this.dataLib = dataLib;
    if(typeof dataLib !== 'object') {
      console.log(dataLib);
      console.trace();
    }
  }
  ...
}

这里使用 console.log() 仅告诉我们传递数据dataLib是什么 ,而没有具体的传递的路径。不过,console.trace() 会非常清楚地告诉我们问题出在 Dashboard.js,我们可以看到是 new CupcakeService(false) 导致错误。

console.time()

console.time() 是一个用于跟踪操作时间的专用函数,它是跟踪 JavaScript执行时间的好方法。

function slowFunction(number) {
  var functionTimerStart = new Date().getTime();
  // something slow or complex with the numbers. 
  // Factorials, or whatever.
  var functionTime = new Date().getTime() - functionTimerStart;
  console.log(`Function time: ${ functionTime }`);
}
var start = new Date().getTime();

for (i = 0; i < 100000; ++i) {
  slowFunction(i);
}

var time = new Date().getTime() - start;
console.log(`Execution time: ${ time }`);

这是一种老派的做法,我们使用 console.time() 来简化以上代码。

const slowFunction = number =>  {
  console.time('slowFunction');
  // something slow or complex with the numbers. 
  // Factorials, or whatever.
  console.timeEnd('slowFunction');
}
console.time();

for (i = 0; i < 100000; ++i) {
  slowFunction(i);
}
console.timeEnd();

我们现在不再需要做任何计算或设置临时变量。

console.group()

// this is the global scope
let number = 1;
console.group('OutsideLoop');
console.log(number);
console.group('Loop');
for (let i = 0; i < 5; i++) {
  number = i + number;
  console.log(number);
}
console.groupEnd();
console.log(number);
console.groupEnd();
console.log('All done now');

输出如下:

clipboard.png

并不是很有用,但是您可以看到其中一些是如何组合的。

class MyClass {
  constructor(dataAccess) {
    console.group('Constructor');
    console.log('Constructor executed');
    console.assert(typeof dataAccess === 'object', 
      'Potentially incorrect dataAccess object');
    this.initializeEvents();
    console.groupEnd();
  }
  initializeEvents() {
    console.group('events');
    console.log('Initialising events');
    console.groupEnd();
  }
}
let myClass = new MyClass(false);

clipboard.png

这是很多工作和很多调试信息的代码,可能不是那么有用。 但它仍然是一个有趣的想法,这样写使你的日志记录更加清晰。

选择DOM元素

如果熟悉jQuery,就会知道 $(‘.class’)$(‘#id’) 选择器有多么重要。它们根据与之关联的类或 ID 选择 DOM 元素。

但是当你没有引用 jQuery时,你仍然可以在谷歌开发控制台中进行同样的操作。

$(‘tagName’) $(‘.class’) $(‘#id’) and $(‘.class #id’) 等效于document.querySelector(‘ ‘),这将返回 DOM 中与选择器匹配的第一个元素。

可以使用 \$\$(tagName)\$\$(.class), 注意双元符号,根据特定的选择器选择DOM的所有元素。这也将它们放入数组中,你也可以通过指定数组中该元素的位置来从中选择特定的元素。

例如,&dollar;&dollar;(‘.className’) 获取具有类 className 的所有元素,而\$\$(‘.className’)[0]\$\$(‘.className’)[1]获取到分别是第一个和第二个元素。

clipboard.png

将浏览器转换为编辑器

你有多少次想知道你是否可以在浏览器中编辑一些文本? 答案是肯定的,你可以将浏览器转换为文本编辑器。 你可以在 DOM 中的任何位置添加文本和从中删除文本。

你不再需要检查元素并编辑HTML。相反,进入开发人员控制台并输入以下内容:

document.body.contentEditable=true 

这将使内容可编辑。现在,你几乎可以编辑DOM中的任何内容。

查找与DOM中的元素关联的事件

调试时,需要查找 DOM 中某个元素的事件侦听器感时,谷歌控制台了 getEventListeners使找到这些事件更加容易且直观。

getEventListeners($(‘selector’)) 返回一个对象数组,其中包含绑定到该元素的所有事件。你可以展开对象来查看事件:

clipboard.png

要找到特定事件的侦听器,可以这样做:

getEventListeners($(‘selector’)).eventName[0].listener 

这将显示与特定事件关联的侦听器。这里 eventName[0] 是一个数组,它列出了特定事件的所有事件。例如:

getEventListeners($(‘firstName’)).click[0].listener 

将显示与 ID 为 ‘firstName’ 的元素的单击事件关联的侦听器。

监控事件

如果希望在执行绑定到 DOM 中特定元素的事件时监视它们,也可以在控制台中这样做。你可以使用不同的命令来监控其中的一些或所有事件:

如果希望在执行绑定到DOM中特定元素的事件时监视它们,也可以在控制台中这样做。你可以使用不同的命令来监控其中的一些或所有事件:

  • monitorEvents($(‘selector’)) 将监视与选择器的元素关联的所有事件,然后在它们被触发时将它们打印到控制台。例如,monitore($(#firstName)) 将打印 IDfirstName元素的所有事件。
  • monitorEvents($(‘selector’),’eventName’) 将打印与元素绑定的特定事件。 你可以将事件名称作为参数传递给函数。 这将仅记录绑定到特定元素的特定事件。 例如,monitorEvents($(‘#firstName’),’click’) 将打印绑定到ID为'firstName'的元素的所有 click 事件。
  • monitore($(selector),[eventName1, eventName3', .])将根据您自己的需求记录多个事件。与其传递单个事件名作为参数,不如传递包含所有事件的字符串数组。例如monitore($(#firstName),[click, focus])将记录与ID firstName元素绑定的 click事件和focus事件。
  • unmonitorevent ($(selector)):这将停止监视和打印控制台中的事件。

检查 DOM 中的一个元素

你可以直接从控制台检查一个元素:

  • inspect($(‘selector’)) 将检查与选择器匹配的元素,并转到 Chrome Developer Tools中的 Elements 选项卡。 例如, inspect($(‘#firstName’)) 将检查 ID为'firstName' 的元素,spect($(‘a’)[3]) 将检查 DOM 中的第 4 个 a 元素。
  • $0, $1, $2 等可以帮助你获取最近检查过的元素。 例如,$0 表示最后检查的 DOM 元素,而$1 倒数第二个检查的 DOM 元素。

检索最后一个结果的值

你可以将控制台用作计算器。当你这样做的时候,你可能需要用第二个来跟踪一个计算。以下是如何从内存中检索先前计算的结果:

$_ 

过程如下:

2+3+4
9 //- The Answer of the SUM is 9

$_
9 // Gives the last Result

$_ * $_
81  // As the last Result was 9

Math.sqrt($_)
9 // As the last Result was 81

$_
9 // As the Last Result is 9

清除控制台和内存

如果你想清除控制台及其内存,输入如下:

 clear()

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug


原文:
https://medium.com/@mattburge...

https://medium.freecodecamp.o...

你的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

查看原文

Change 发布了文章 · 2019-11-18

v-model源码解析(超详细)

抛出问题

我们先来看一下下面这段代码

<template>
  <div>
    <div class="message">{{ info.message }}</div>
    <div><input v-model="info.message" type="text"></div>
    <button @click="change">click</button>
  </div>
</template>

<script>
  export default {
    data () {
      return {
        info: {}
      }
    },
    methods: {
      change () {
        this.info.message = 'hello world'
      }
    }
  }
</script>

上述代码很简单,就不做过多的解释了。如果这段代码都看不懂,那下面也没必要再看下去了

问题重现步骤

我现在对上述代码做两种操作:

  1. 一进页面先在输入框中输入hello vue
  2. 一进页面先点击click按钮进行赋值操作,再在输入框中输入hello vue

上述两种情况分别会出现什么现象呢?

第一种操作,当我们在输入框中输入hello vue的时候,class为message的div中会联动出现hello vue,也就是说info中的message属性是响应式的

第二种操作,当我们先进行赋值操作,之后无论在输入框中输入什么内容,class为message的div中都不会联动出现任何值,也就是说info中的message属性非响应式的

问题引发的猜想

查阅vue官方文档我们得知vue在初始化的时候会对data中所有已经定义的对象及其子属性进行遍历,给他们添加gettersetter,使得他们变成响应式的(关于响应式这块之后会单开文章进行解析),但是vue不能检测对象属性的添加或删除。但是,可以使用 Vue.set(object, propertyName, value)方法向嵌套对象添加响应式属性

基于上述描述,我们先看第一种操作。直接在输入框中输入hello vue,class为message的div中会联动出现hello vue。但是我们看data中只定义了info对象,其中并没有定义message属性,message属于新增属性。根据vue官方文档中说的,vue不能检测对象属性的添加或删除,所以我猜测vue底层在解析v-model指令的时候,每当触发表单元素的监听事件(例如input事件),就会有Vue.set()操作,从而触发setter

带着这个猜测,我们来看第二种操作。一进页面先点击click按钮,对info.message进行赋值,message属于新增属性,根据官方文档中说的,此时message并不是响应式的,没问题。但是我们接着在input输入框中输入值,class为message的div中没有联动出现任何值,根据我们对于第一种情况的猜测,当输入框监听到input事件的时候,会对info中的message进行Vue.set()操作,所以理论上就算一开始click中是对新增属性message直接赋值的,导致该属性并非响应式的,在经过输入框input事件中的Vue.set()操作之后,应该会变成响应式的,而现在呈现出来的情况并不是这样的啊,这是为什么呢?

聪明的你们应该已经猜到在Vue.set()底层源码中,应该是会判断message属性是否一开始就在info中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定gettersetter

但是光猜测肯定是不够的,我们要用事实说话,做到有理有据。接下来我们就去看下vue源码中v-model这块,看看是不是如我们猜想的一样

探索真相-源码分析

v-model指令使用分为两种情况:一种是在表单元素上使用,另外一种是在组件上使用。我们今天分析的是第一种情况,也就是在表单元素上使用

v-model实现机制

我们先简单说下v-model的机制:v-model会把它关联的响应式数据(如info.message),动态地绑定到表单元素的value属性上,然后监听表单元素的input事件:当v-model绑定的响应数据发生变化时,表单元素的value值也会同步变化;当表单元素接受用户的输入时,input事件会触发,input的回调逻辑会把表单元素value最新值同步赋值给v-model绑定的响应式数据。

v-model实现原理

我用来分析的源码是在vue官网安装模块里面下载的开发版本(2.6.10),便于调试

编译

我们今天讲的内容其实就是把模版编译成render函数的一个流程,这里不会对每步流程都展开讲解,我可以给出一个步骤实现的流程,大家有兴趣的话可以根据这个流程来阅读代码,提高效率
$mount()->compileToFunctions()->compile()->baseCompile()
真正的编译过程都是在这个baseCompile()里面执行,执行步骤可以分为三个过程

  1. 解析模版字符串生成AST
    const ast = parse(template.trim(), options)
  1. 优化语法树
    optimize(ast, options)
  1. 生成代码
    const code = generate(ast, options)

然后我们看下generate里面的代码,这也是我们今天讲的重点

    function generate (
    ast,
    options
  ) {
    var state = new CodegenState(options);
    var code = ast ? genElement(ast, state) : '_c("div")';
    return {
      render: ("with(this){return " + code + "}"),
      staticRenderFns: state.staticRenderFns
    }
  }

generate() 首先通过 genElement()->genData$2()->genDirectives() 生成code,再把codewith(this){return ${code}}} 包裹起来,最终的到render函数。
接下来我们从genDirectives()开始讲解

genDirectives

在模板的编译阶段,v-model跟其他指令一样,会被解析到 el.directives中,之后会通过genDirectives方法处理这些指令,我们这里从genDirectives()重点开始讲,至于怎么到这步,如果大家感兴趣的话,可以从generate()开始看

    function genDirectives (el, state) {
        var dirs = el.directives;
        if (!dirs) { return }
        var res = 'directives:[';
        var hasRuntime = false;
        var i, l, dir, needRuntime;
        for (i = 0, l = dirs.length; i < l; i++) {
          dir = dirs[i];
          needRuntime = true;
          var gen = state.directives[dir.name];
          if (gen) {
            // compile-time directive that manipulates AST.
            // returns true if it also needs a runtime counterpart.
            needRuntime = !!gen(el, dir, state.warn);
          }
          if (needRuntime) {
            hasRuntime = true;
            res += "{name:\"" + (dir.name) + "\",rawName:\"" + (dir.rawName) + "\"" + (dir.value ? (",value:(" + (dir.value) + "),expression:" + (JSON.stringify(dir.value))) : '') + (dir.arg ? (",arg:" + (dir.isDynamicArg ? dir.arg : ("\"" + (dir.arg) + "\""))) : '') + (dir.modifiers ? (",modifiers:" + (JSON.stringify(dir.modifiers))) : '') + "},";
          }
        }
        if (hasRuntime) {
          return res.slice(0, -1) + ']'
        }
    }

我对上面这个代码打个断点,结合我们上面的代码例子,这样子看的更清楚,如下图:
getDirectives.png
我们可以看到传进来的elAst语法树,el.directivesel上的指令,在我们这里就是el-model的相关参数,然后赋值给变量dirs

往下看代码,for循环中有段代码:

    var gen = state.directives[dir.name];
    if (gen) {
      // compile-time directive that manipulates AST.
      // returns true if it also needs a runtime counterpart.
      needRuntime = !!gen(el, dir, state.warn);
    }

这里面的state.dirctives是什么呢?打个断点看一下,如下图:
genDirectives_state.png
我们可以看到state.directives里面包含了很多指令方法,model就在其中,

    var gen = state.directives[dir.name];

其实就是等价于

    var gen = state.directives[model];

所以代码中的变量gen得到的是model()

    needRuntime = !!gen(el, dir, state.warn);

其实就是执行了model()

model

那我们再来看看model这个方法里面做了些什么事情,先上model的代码:

  function model (el,dir,_warn) {
    warn$1 = _warn;
    var value = dir.value;
    var modifiers = dir.modifiers;
    var tag = el.tag;
    var type = el.attrsMap.type;

    {
      // inputs with type="file" are read only and setting the input's
      // value will throw an error.
      if (tag === 'input' && type === 'file') {
        warn$1(
          "<" + (el.tag) + " v-model=\"" + value + "\" type=\"file\">:\n" +
          "File inputs are read only. Use a v-on:change listener instead.",
          el.rawAttrsMap['v-model']
        );
      }
    }

    if (el.component) {
      genComponentModel(el, value, modifiers);
      // component v-model doesn't need extra runtime
      return false
    } else if (tag === 'select') {
      genSelect(el, value, modifiers);
    } else if (tag === 'input' && type === 'checkbox') {
      genCheckboxModel(el, value, modifiers);
    } else if (tag === 'input' && type === 'radio') {
      genRadioModel(el, value, modifiers);
    } else if (tag === 'input' || tag === 'textarea') {
      genDefaultModel(el, value, modifiers);
    } else if (!config.isReservedTag(tag)) {
      genComponentModel(el, value, modifiers);
      // component v-model doesn't need extra runtime
      return false
    } else {
      warn$1(
        "<" + (el.tag) + " v-model=\"" + value + "\">: " +
        "v-model is not supported on this element type. " +
        'If you are working with contenteditable, it\'s recommended to ' +
        'wrap a library dedicated for that purpose inside a custom component.',
        el.rawAttrsMap['v-model']
      );
    }

    // ensure runtime directive metadata
    return true
  }

model方法根据传入的参数对tag的类型进行判断,调用不同的处理逻辑,本demo中tag的类型为input,所以会执行genDefaultModel方法

genDefaultModel

    function genDefaultModel (el,value,modifiers) {
        var type = el.attrsMap.type;
        {
          var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
          var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
          if (value$1 && !typeBinding) {
            var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
            warn$1(
              binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
              'because the latter already expands to a value binding internally',
              el.rawAttrsMap[binding]
            );
          }
        }

        var ref = modifiers || {};
        var lazy = ref.lazy;
        var number = ref.number;
        var trim = ref.trim;
        var needCompositionGuard = !lazy && type !== 'range';
        var event = lazy
          ? 'change'
          : type === 'range'
            ? RANGE_TOKEN
            : 'input';

        var valueExpression = '$event.target.value';
        if (trim) {
          valueExpression = "$event.target.value.trim()";
        }
        if (number) {
          valueExpression = "_n(" + valueExpression + ")";
        }

        var code = genAssignmentCode(value, valueExpression);
        if (needCompositionGuard) {
          code = "if($event.target.composing)return;" + code;
        }

        addProp(el, 'value', ("(" + value + ")"));
        addHandler(el, event, code, null, true);
        if (trim || number) {
          addHandler(el, 'blur', '$forceUpdate()');
        }
  }

我们对genDefaultModel()中的代码进行分块解析,首先看下面这段代码:

是否同时具有指令v-modelv-bind
    var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
    var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
    if (value$1 && !typeBinding) {
      var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
      warn$1(
        binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
        'because the latter already expands to a value binding internally',
        el.rawAttrsMap[binding]
      );
    }

这块代码其实就是解释表单元素是否同时有指令v-modelv-bind

    var ref = modifiers || {};
    var lazy = ref.lazy;
    var number = ref.number;
    var trim = ref.trim;
修饰符

这段代码就是获取修饰符lazy, number及trim

  1. .lazy 取代input监听change事件
  2. .number 输入字符串转为数字
  3. .trim 输入首尾空格过滤
var needCompositionGuard = !lazy && type !== 'range';

这里的needCompositionGuard后面再说有什么用,现在只用知道默认是true就行了

    var event = lazy
      ? 'change'
      : type === 'range'
        ? RANGE_TOKEN
        : 'input';

    var valueExpression = '$event.target.value';
    if (trim) {
      valueExpression = "$event.target.value.trim()";
    }
    if (number) {
      valueExpression = "_n(" + valueExpression + ")";
    }

上面这段代码中,event = ‘input’,定义变量valueExpression,修饰符trimnumber在我们这个demo中默认都没有,所以跳过往下看

genAssignmentCode
    var code = genAssignmentCode(value, valueExpression);
    if (needCompositionGuard) {
      code = "if($event.target.composing)return;" + code;
    }

这里涉及到一个函数genAssignmentCode,上源码:

  function genAssignmentCode (
    value,
    assignment
  ) {
    var res = parseModel(value);
    if (res.key === null) {
      return (value + "=" + assignment)
    } else {
      return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
    }
  }

这段代码是生成v-model绑定的value的值,看到这段代码,我们就知道离真相不远了,因为我们看到了$set()。现在我们通过断点具体分析下,如下图:
getAssignmentCode.png
通过断点我们可以很清楚的看到我们先执行parseModel('info.message')获取到一个对象res,由于我们的demo中绑定的值是路径形式的对象,即info.message,所以此时res通过parseModel解析出来就是{exp: "info", key: "message"}。那下面的判断就进入else,即:

    return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")

回到上面的getDefaultModel()中

    var code = genAssignmentCode(value, valueExpression);
    if (needCompositionGuard) {
      code = "if($event.target.composing)return;" + code;
    }

此时code获取到genAssignmentCode()返回的字符串值"$set(info, "message", $event.target.value)"

$event.target.composing

上面我说的到变量needCompositionGuard = true,经过拼接,最终code = “if($event.target.composing)return;$set(info, "message", $event.target.value)”

这里的$event.target.composing有什么用呢?其实就是用于判断此次input事件是否是IME构成触发的,如果是IME构成,直接return。IME 是输入法编辑器(Input Method Editor) 的英文缩写,IME构成指我们在输入文字时,处于未确认状态的文字。如图:
composing.png
带下划线的ceshi就属于IME构成,它会同样会触发input事件,但不会触发v-model更新数据。

继续往下看

    addProp(el, 'value', ("(" + value + ")"));
    addHandler(el, event, code, null, true);
    if (trim || number) {
      addHandler(el, 'blur', '$forceUpdate()');
    }
addProp

先说下addProp(el, 'value', ("(" + value + ")"))

    function addProp (el, name, value, range, dynamic) {
      (el.props || (el.props = [])).push(rangeSetItem({ name: name, value: value, dynamic: dynamic }, range));
      el.plain = false;
    }

照常打个断点看下:,如下图
addProp.png

可以看到此方法的功能为给el添加props,首先判断el上有没有props,如果没有的话创建props并赋值为一个空数组,随后拼接对象并推到props中,代码在此demo中相当于push{name: "value", value: "(info.message)"}

如果一直往下追,可以看到这个方法其实是在input输入框上绑定了value,对照我们的demo来看,就是将<input v-model="info.message" type="text">变成<input v-bind:value="info.message" type="text">

addHandler

同样的,addHandler()相当于在input上绑定了input事件,最终我们demo的模版就会被编译成

    <input v-bind:value="info.message" v-on:input="info.message=$event.target.value">
render

后续再根据一些指令拼接,我们最终的到的render如下:

with(this) {
    return _c('div', {
        attrs: {
            "id": "app-2"
        }
    }, [_c('div', [_v(_s(info.message))]), _v(" "), _c('div', [_c('input', {
        directives: [{
            name: "model",
            rawName: "v-model",
            value: (info.message),
            expression: "info.message"
        }],
        attrs: {
            "type": "text"
        },
        domProps: {
            "value": (info.message)
        },
        on: {
            "input": function ($event) {
                if ($event.target.composing) return;
                $set(info, "message", $event.target.value)
            }
        }
    })]), _v(" "), _c('button', {
        on: {
            "click": change
        }
    }, [_v("click")])])
}

最后通过createFunction()render代码串通过new Function的方式转换成可执行的函数,赋值给 vm.options.render,这样当组件通过vm._render的时候,就会执行这个render函数

至此,针对表单元素上的v-model指令从开始编译到最终生成render()并执行的过程就讲解完了,我们验证了在编译阶段,v-model会在监听到input事件时对我们绑定的value进行Vue.$set()操作

还记得我们上面说的对demo第二种操作情况么?先进行click操作赋值,那v-model中的Vue.$set()操作似乎没有作用了。我们当时猜测的是Vue.$set()底层源码中有应该是会判断message属性是否一开始就在info中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定gettersetter

现在我们就去Vue.$set()中看一下

set

先上代码:

/**
   * Set a property on an object. Adds the new property and
   * triggers change notification if the property doesn't
   * already exist.
   */
  function set (target, key, val) {
    if (isUndef(target) || isPrimitive(target)
    ) {
      warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
    }
    if (Array.isArray(target) && isValidArrayIndex(key)) {
      target.length = Math.max(target.length, key);
      target.splice(key, 1, val);
      return val
    }
    if (key in target && !(key in Object.prototype)) {
      target[key] = val;
      return val
    }
    var ob = (target).__ob__;
    if (target._isVue || (ob && ob.vmCount)) {
      warn(
        'Avoid adding reactive properties to a Vue instance or its root $data ' +
        'at runtime - declare it upfront in the data option.'
      );
      return val
    }
    if (!ob) {
      target[key] = val;
      return val
    }
    defineReactive$$1(ob.value, key, val);
    ob.dep.notify();
    return val
  }

看到这句代码了么?这就是证据,验证我们猜想的证据

if (key in target && !(key in Object.prototype)) {
    target[key] = val;
    return val
}
验证猜想

当我们首先点击click的时候,执行this.info.message = 'hello world',此时info对象中新增了一个message属性。当我们在input框中输入值并触发Vue.$set()时,key in targettrue,并且message又不是Object原型上的属性,所以!(key in Object.prototype)也为true,此时message属性并不是响应式属性,没有绑定setter,所以仅仅进行了单纯的赋值操作。

而当我们一进页面首次input中执行输入操作时,根据上面我们的分析input框监听到了input事件,先执行了Vue.$set()操作,因为时首次,所以info中还没有message属性,所以上面的key in targetfalse,跳过了赋值操作,到了下面的

defineReactive$$1(ob.value, key, val);
ob.dep.notify();

这个defineReactive的作用就是为message绑定了getter()setter(),之后再对message的赋值操作都会直接进入自身绑定的setter中进行响应式操作

一个意外的发现

我突然奇想把vue的版本换到了2.3.0,发现v-model不能对demo中的message属性实现响应化,跑去看了下vue更新日志,发现在2.5.0版本中,有这么一句话
now creates non-existent properties as reactive (non-recursive) e1da0d5, closes #5932 (See reasoning behind this change)
上面这句话的意思是从2.5.0版本开始支持将不存在的属性响应化,非递归的。
因为message属性一开始在info中并没有定义,在2.3.0中,还不支持将不存在的属性响应化的操作,所以对demo无效

总结

到这里,我们这篇文章就结束了 里面有一些细节如果大家有兴趣的话可以自己再去深究一下。有时候很小的一个问题,背后牵扯到的知识点也是很多的,尽量把每个不懂背后的逻辑搞清楚,才能尽快的成为你想成为的人

参考资料

https://segmentfault.com/a/11...
https://blog.csdn.net/fabulou...

查看原文

赞 7 收藏 4 评论 1

Change 发布了文章 · 2019-10-12

Vue 虚拟dom diff原理详解

前言

Vue 2.x 中模板渲染与 Vue 1.x 完全不同,1.x 中采用的 DocumentFragment ,而 2.x 中借鉴 React 的 Virtual DOM

Vue.js 2.x引入虚拟DOM,比Vue.js 1.0的渲染速度提升了2-4倍,并大大降低了内存消耗

要知道渲染真实DOM的开销是很大的,比如有时候我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,有没有可能我们只更新我们修改的那一小块dom而不要更新整个dom呢?diff算法能够帮助我们

本文我们将探讨为什么虚拟DOM能带来渲染速度及性能上的提升?

diff算法内部原理是怎样的?

模板转换成视图的过程

在正式介绍 Virtual Dom之前,我们先来看一张图

为了看懂这张图,我们有必要先了解下模板转换成视图的过程整个过程

  • Vue.js通过编译将template 模板转换成渲染函数(render ) ,执行渲染函数就可以得到一个虚拟节点树
  • 在对 Model 进行操作的时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。这个过程主要是将新旧虚拟节点进行差异对比,然后根据对比结果进行DOM操作来更新视图。

简单点讲,在Vue的底层实现上,Vue将模板编译成虚拟DOM渲染函数。结合Vue自带的响应系统,在状态改变时,Vue能够智能地计算出重新渲染组件的最小代价并应到DOM操作上。

模板转换成视图的过程

我们对上图几个概念加以解释:

  • 渲染函数:渲染函数是用来生成Virtual DOM的。Vue推荐使用模板来构建我们的应用界面,在底层实现中Vue会将模板编译成渲染函数,当然我们也可以不写模板,直接写渲染函数,以获得更好的控制。
  • VNode 虚拟节点:它可以代表一个真实的 dom 节点。通过 createElement 方法能将 VNode 渲染成 dom 节点。简单地说,vnode可以理解成节点描述对象,它描述了应该怎样去创建真实的DOM节点。
  • patch(也叫做patching算法):虚拟DOM最核心的部分,它可以将vnode渲染成真实的DOM,这个过程是对比新旧虚拟节点之间有哪些不同,然后根据对比结果找出需要更新的的节点进行更新。这点我们从单词含义就可以看出, patch本身就有补丁、修补的意思,其实际作用是在现有DOM上进行修改来实现更新视图的目的。Vue的Virtual DOM Patching算法是基于Snabbdom的实现,并在些基础上作了很多的调整和改进。

什么是虚拟DOM?

Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实的DOM上

下面就是一个真实DOM映射到虚拟DOM的例子:

        <ul id='list'>
          <li class='item'>Item 1</li>
          <li class='item'>Item 2</li>
          <li class='item'>Item 3</li>
        </ul>
    var element = {
        tagName: 'ul', // 节点标签名
        props: { // DOM的属性,用一个对象存储键值对
            id: 'list'
        },
        children: [ // 该节点的子节点
          {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
          {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
          {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
        ]
    }

为何需要Virtual DOM?

具备跨平台的优势
由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。

操作原生DOM慢,js运行效率高。我们可以将DOM对比操作放在JS层,提高效率。
因为DOM操作的执行速度远不如Javascript的运算速度快,因此,把大量的DOM操作搬运到Javascript中,运用patching算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。

Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)

提升渲染性能
Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

diff算法

vdom因为是纯粹的JS对象,所以操作它会很高效,但是vdom的变更最终会转换成DOM操作,为了实现高效的DOM操作,一套高效的虚拟DOM diff算法显得很有必要

diff算法包括一下几个步骤:

  • 用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文

档当中

  • 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较(diff),记录两棵树差异
  • 把2所记录的差异应用到步骤1所构建的真正的DOM树上(patch),视图就更新了

diff算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法

我们先看一下简单的diff是怎么设计的

逐个遍历newVdom的节点,找到它在oldVdom中的位置,如果找到了就移动对应的DOM元素,如果没找到说明是新增节点,则新建一个节点插入。遍历完成之后如果oldVdom中还有没处理过的节点,则说明这些节点在newVdom中被删除了,删除它们即可。

仔细思考一下,几乎每一步都要做移动DOM的操作,这在DOM整体结构变化不大时的开销是很大的,实际上DOM变化不大的情况现实中经常发生,很多时候我们只需要变更某个节点的文本而已

接下来我们看一下Vue的diff实现

Vue的diff实现

来看看patch是怎么打补丁的(代码只保留核心部分)

    function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // some code
    return vnode
    }

patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点

判断两节点是否值得比较,值得比较则执行patchVnode

    function sameVnode (a, b) {
      return (
        a.key === b.key &&  // key值
        a.tag === b.tag &&  // 标签名
        a.isComment === b.isComment &&  // 是否为注释节点
        // 是否都定义了data,data包含一些具体信息,例如onclick , style
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b) // 当标签是<input>的时候,type必须相同
      )
    }

不值得比较则用Vnode替换oldVnode

虽然这两个节点不一样但是他们的子节点一样怎么办?别忘了,diff可是逐层比较的,如果第一层不一样那么就不会继续深入比较第二层了。(我在想这算是一个缺点吗?相同子节点不能重复利用了)

当我们确定两个节点值得比较之后我们会对两个节点指定patchVnode方法。那么这个方法做了什么呢?

这个函数做了以下事情:

  • 找到对应的真实dom,称为el
  • 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要
  • 其他几个点都很好理解,我们详细来讲一下updateChildren

updateChildren

    updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (oldStartVnode == null) {   //对于vnode.key的比较,会把oldVnode = null
                oldStartVnode = oldCh[++oldStartIdx] 
            }else if (oldEndVnode == null) {
                oldEndVnode = oldCh[--oldEndIdx]
            }else if (newStartVnode == null) {
                newStartVnode = newCh[++newStartIdx]
            }else if (newEndVnode == null) {
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newStartVnode)) {
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            }else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldStartVnode, newEndVnode)) {
                patchVnode(oldStartVnode, newEndVnode)
                api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            }else if (sameVnode(oldEndVnode, newStartVnode)) {
                patchVnode(oldEndVnode, newStartVnode)
                api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            }else {
               // 使用key时的比较
                if (oldKeyToIdx === undefined) {
                    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
                }
                idxInOld = oldKeyToIdx[newStartVnode.key]
                if (!idxInOld) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    newStartVnode = newCh[++newStartIdx]
                }
                else {
                    elmToMove = oldCh[idxInOld]
                    if (elmToMove.sel !== newStartVnode.sel) {
                        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                    }else {
                        patchVnode(elmToMove, newStartVnode)
                        oldCh[idxInOld] = null
                        api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                    }
                    newStartVnode = newCh[++newStartIdx]
                }
            }
        }
        if (oldStartIdx > oldEndIdx) {
            before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
            addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
        }else if (newStartIdx > newEndIdx) {
            removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
        }
    }

代码量很大,不方便一行一行的讲解,所以下面结合一些示例图来描述一下

如上图的例子,更新前是1到10排列的Node列表,更新后是乱序排列的Node列表。罗列一下图中有以下几种类型的节点变化情况:

  • 头部相同、尾部相同的节点:如1、10
  • 头尾相同的节点:如2、9(处理完头部相同、尾部相同节点之后)
  • 新增的节点:11
  • 删除的节点:8
  • 其他节点:3、4、5、6、7

上图例子中设置了oldStart+oldEnd,newStart+newEnd这样2对指针,分别对应oldVdom和newVdom的起点和终点。Vue不断对vnode进行处理同时移动指针直到其中任意一对起点和终点相遇。处理过的节点Vue会在oldVdom和newVdom中同时将它标记为已处理(标记方法后文中有介绍)。Vue通过以下措施来提升diff的性能

1. 优先处理特殊场景

  • 头部的同类型节点、尾部的同类型节点

    这类节点更新前后位置没有发生变化,所以不用移动它们对应的DOM

  • 头尾/尾头的同类型节点

    这类节点位置很明确,不需要再花心思查找,直接移动DOM就好

处理了这些场景之后,一方面一些不需要做移动的DOM得到快速处理,另一方面待处理节点变少,缩小了后续操作的处理范围,性能也得到提升

2. “原地复用”

“原地复用”是指Vue会尽可能复用DOM,尽可能不发生DOM的移动。Vue在判断更新前后指针是否指向同一个节点,其实不要求它们真实引用同一个DOM节点,实际上它仅判断指向的是否是同类节点(比如2个不同的div,在DOM上它们是不一样的,但是它们属于同类节点),如果是同类节点,那么Vue会直接复用DOM,这样的好处是不需要移动DOM

“原地复用”在Vue的官方文档中有提到

[https://cn.vuejs.org/v2/guide...]()

[https://cn.vuejs.org/v2/guide...]()

"原地复用"应该就是设置key和不设置key的区别:

不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom

按步骤解析updateChildren过程

1. 处理头部的同类型节点,即oldStart和newStart指向同类节点的情况,如下图中的节点1

这种情况下,将节点1的变更更新到DOM,然后对其进行标记,标记方法是oldStart和newStart后移1位即可,过程中不需要移动DOM(更新DOM或许是要的,比如属性变更了,文本内容变更了等等)

2. 处理尾部的同类型节点,即oldEnd和newEnd指向同类节点的情况,如下图中的节点10

与情况(1)类似,这种情况下,将节点10的变更更新到DOM,然后oldEnd和newEnd前移1位进行标记,同样也不需要移动DOM

3. 处理头尾/尾头的同类型节点,即oldStart和newEnd,以及oldEnd和newStart指向同类节点的情况,如下图中的节点2和节点9

先看节点2,其实是往后移了,移到哪里?移到oldEnd指向的节点(即节点9)后面,移动之后标记该节点,将oldStart后移1位,newEnd前移一位

操作结束之后情况如下图

同样地,节点9也是类似的处理,处理完之后成了下面这样

4. 处理新增的节点

newStart来到了节点11的位置,在oldVdom中找不到节点11,说明它是新增的
那么就创建一个新的节点,插入DOM树,插到什么位置?插到oldStart指向的节点(即节点3)前面,然后将newStart后移1位标记为已处理(注意oldVdom中没有节点11,所以标记过程中它的指针不需要移动),处理之后如下图

5. 处理更新的节点

经过第(4)步之后,newStart来到了节点7的位置,在oldVdom中能找到它而且不在指针位置(查找oldVdom中oldStart到oldEnd区间内的节点),说明它的位置移动了

那么需要在DOM树中移动它,移到哪里?移到oldStart指向的节点(即节点3)前面,与此同时将节点标记为已处理,跟前面几种情况有点不同,newVdom中该节点在指针处,可以移动newStart进行标记,而在oldVdom中该节点不在指针处,所以采用设置为undefined的方式来标记(一定要标记吗?后面会提到)

处理之后就成了下面这样

6. 处理3、4、5、6节点

经过第(5)步处理之后,我们看到了令人欣慰的一幕,newStart和oldStart又指向了同一个节点(即都指向节点3),很简单,按照(1)中的做法只需移动指针即可,非常高效,3、4、5、6都如此处理,处理完之后如下图

7. 处理需删除的节点

经过前6步处理之后(实际上前6步是循环进行的),朋友们看newStart跨过了newEnd,它们相遇啦!而这个时候,oldStart和oldEnd还没有相遇,说明这2个指针之间的节点(包括它们指向的节点,即上图中的节点7、节点8)是此次更新中被删掉的节点。

OK,那我们在DOM树中将它们删除,再回到前面我们对节点7做了标记,为什么标记是必需的?标记的目的是告诉Vue它已经处理过了,是需要出现在新DOM中的节点,不要删除它,所以在这里只需删除节点8。

在应用中也可能会遇到oldVdom的起止点相遇了,但是newVdom的起止点没有相遇的情况,这个时候需要对newVdom中的未处理节点进行处理,这类节点属于更新中被加入的节点,需要将他们插入到DOM树中。

至此,整个diff过程结束了

整个过程是逐步找到更新前后vdom的差异,然后将差异反应到DOM树上(也就是patch),特别要提一下Vue的patch是即时的,并不是打包所有修改最后一起操作DOM(React则是将更新放入队列后集中处理),朋友们会问这样做性能很差吧?实际上现代浏览器对这样的DOM操作做了优化,并无差别

参考资料:

https://cloud.tencent.com/developer/article/1006029
https://blog.fundebug.com/2019/06/26/vue-virtual-dom/

查看原文

赞 9 收藏 6 评论 1

Change 关注了标签 · 2018-04-17

行业

行業,指主要根據職業、性質或具體事物,對社會各個領域稱呼。“行業”屬於中文表述的一種習慣,极少作为術語来表示特定的概念,有時與辭彙“产业”混淆使用,來表示同一概念,常指社会领域。行业属于概念广泛的常用中文词汇,其分类具有随意性。

关注 52424

Change 关注了标签 · 2018-04-17

segmentfault

SegmentFault (www.sf.gg) 是一个面向中文开发者的专业技术社区。社区采用良性、合理的机制来让开发者自由生长,希望通过最干净、简洁、优质的产品体验,来吸引国内优秀的开发者和技术人员,一起打造一个纯粹的技术交流社区。

我们希望为中文开发者提供一个纯粹、高质的技术交流平台,与开发者一起学习、交流与成长,创造属于开发者的时代!

网站产品

问答平台 专注高效地解决技术问题。确保内容质量的投票机制,合理区分的答案与回馈信息,用户参与改进的维基化内容,SegmentFault 帮你快捷地找到答案。

文章平台 简洁安静的技术经验分享。简约干净的界面,让你专注于内容的撰写;好用到爆的 Markdown 编辑器,和你的思维速度匹配无间。让你重新爱上写博客。

活动平台 在活动中找到志同道合的好基友。黑客马拉松、开发竞赛、线下沙龙、知识讲座、线上活动…… 总有一款适合你。

技术笔记 一个方便快捷的代码笔记本,使用 CodeMirror 编辑器,支持纯文本、Markdown、Java、CSS 等多种类型的文本渲染,还有神奇的笔记传送门。

程序员招聘平台 我们针对企业推出了面向开发者的专属招聘功能,企业组织可以展示自己的资料、团队成员、技术背景等内容。最重要的是,还能招募到契合的团队成员。

获得成就

  1. 3人创始团队创业初期利用1年时间独立开发底层框架,上线问答、博客、活动等社区平台,聚集十多万开发者。
  2. SegmentFault 团队将黑客马拉松活动引入中国,至今,已经在国内一线互联网城市以及台北、新加坡、硅谷等地区举办了超过 20 场黑客马拉松。SegmentFault 是目前中国最大的黑客马拉松组织方。
  3. SegmentFault 在 2013 中国新媒体创业大赛中获得全国决赛第二名,并入选微软创投加速器第 4 期,,并获得 IDG 资本数百万天使投资。
  4. SegmentFault 在 2014 年获得 IDG资本 数百万天使投资。
  5. SegmentFault 在 2015 年获得顶级 VC 赛富亚洲基金(软银赛富)领投、IDG资本 跟投的数千万 A 轮融资。

网站架构

SegmentFault 基于我们自己开发的 Typecho Framework 开源框架,这是一个简单、轻量、可扩展的 PHP 框架。其中引入了类似 JAVA 注入变量的概念,解决了 PHP 项目中模块的自由引用问题。存储采用 Redis、MySQL,搜索引擎选用 xunsearch,前端为响应式设计,使用了 Sass、Compass、jQuery 等技术。整个项目通过 GitHub、BaseCamp、Gmail 进行协作。

关注 53040

Change 关注了标签 · 2018-04-17

区块链

区块链(英语:Blockchain 或 Block chain)是一种分布式数据库,起源自比特币。区块链是一串使用密码学方法相关联产生的数据块,每一个数据块中包含了一次比特币网络交易的信息,用于验证其信息的有效性(防伪)和生成下一个区块。该概念在中本聪的白皮书中提出,中本聪创造第一个区块,即“创世区块”。

区块链在网络上是公开的,可以在每一个离线比特币钱包数据中查询。比特币钱包的功能依赖于与区块链的确认,一次有效检验称为一次确认。通常一次交易要获得数个确认才能进行。轻量级比特币钱包使用在线确认,即不会下载区块链数据到设备存储中。

比特币的众多竞争币也使用同样的设计,只是在工作量证明上和算法上略有不同。如,采用权益证明和 SCrypt 算法等等。

关注 51190

Change 关注了标签 · 2018-04-17

前端

Web前端开发是从网页制作演变而来的,名称上有很明显的时代特征。在互联网的演化进程中,网页制作是Web 1.0时代的产物,那时网站的主要内容都是静态的,用户使用网站的行为也以浏览为主。2005年以后,互联网进入Web 2.0时代,各种类似桌面软件的Web应用大量涌现,网站的前端由此发生了翻天覆地的变化。网页不再只是承载单一的文字和图片,各种富媒体让网页的内容更加生动,网页上软件化的交互形式为用户提供了更好的使用体验,这些都是基于前端技术实现的。

Web前端优化
  1. 尽量减少HTTP请求 (Make Fewer HTTP Requests)
  2. 减少 DNS 查找 (Reduce DNS Lookups)
  3. 避免重定向 (Avoid Redirects)
  4. 使得 Ajax 可缓存 (Make Ajax Cacheable)
  5. 延迟载入组件 (Post-load Components)
  6. 预载入组件 (Preload Components)
  7. 减少 DOM 元素数量 (Reduce the Number of DOM Elements)
  8. 切分组件到多个域 (Split Components Across Domains)
  9. 最小化 iframe 的数量 (Minimize the Number of iframes)
  10. 杜绝 http 404 错误 (No 404s)

关注 194501

认证与成就

  • 获得 65 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-02-23
个人主页被 639 人浏览