热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

15分钟学会vue项目改造成SSR(小白教程)

这篇文章主要介绍了15分钟学会vue项目改造成SSR(小白教程),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

15分钟学会vue项目改造成SSR

Ps:网上看了好多服务器渲染的例子,基本都是从0开始的,用Nuxt或者vue官网推荐的ssr方案(vue-server-renderer),但是我们在开发过程中基本上是已经有了现有的项目了,我们所要做的是对现有项目的SSR改造。那么这里,跟我一起对一个vue-cil2.0生成的项目进行SSR改造

关于这篇文章的案例源代码我放在我的github上面,有兴趣的同学,也可以去我的github查看我之前写的博客。博客

一、改造技术的分析对比。

一般来说,我们做seo有两种方式:

1、预渲染

我在性能优化的博客中说过,预渲染的问题,预渲染是一个方案,使用爬虫技术。由于我们打包过后的都是一些js文件,使用一些技术(puppeteer)可以爬取到项目在chrome浏览器展示的页面,然后把它写入js,和打包文件一起。

类似prerender-spa-plugin 。最大的特点就是,所有获取的数据都是静态的,比如说你的页面首页有新闻,是通过接口获取的,当你在2019-11-30打包之后,不管用户在2020年也是看到的2019-11-30的新闻,当然的爬虫爬到的也是。

如果你只需要改善少数页面(例如 /, /about, /contact 等)的 SEO,那么你可能需要预渲染

2、服务端渲染

服务端渲染是将完整的 html 输出到客户端,又被认为是‘同构'或‘通用',如果你的项目有大量的detail页面,相互特别频繁,建议选择服务端渲染。

**服务端渲染除了SEO还有很多时候用作首屏优化,加快首屏速度,提高用户体验。**但是对服务器有要求,网络传输数据量大,占用部分服务器运算资源。

由于三大框架的兴起,SPA项目到处都是,所以涌现了一批nuxt.js、next.js这些服务器渲染的框架。但是这些框架构建出来的项目可能文件夹和我们现有的项目很大不一样,所以本文章主要是用vue-server-renderer来对现有项目进行改造,而不是去用框架。

ps:(划重点)单页面项目的ssr改造的原理:

vue项目是通过虚拟 DOM来挂载到html的,所以对spa项目,爬虫才会只看到初始结构。虚拟 DOM,最终要通过一定的方法将其转换为真实 DOM。虚拟 DOM 也就是 JS 对象,整个服务端的渲染流程就是通过虚拟 DOM 的编译成完整的html来完成的。

我们通过服务端渲染解析虚拟 DOM成html之后,你会发现页面的事件,都没法触发。那是因为服务端渲染vue-server-renderer插件并没有做这方面的处理,所以我们需要客户端再渲染一遍,简称同构。所以Vue服务端渲染其实是渲染了两遍。下面给出一个官方的图:

在这里插入图片描述

二、改造前后目录文件对比

在这里插入图片描述

黄线部分是改造后新增的文件,怎么样,是不是觉得差别不大,总体架构上只有6个文件的差别。(#.#) 我们来理一理这些新增的文件。

  • server.dev.conf.js 本地调试和热更新需要的配置文件
  • webpack.client.conf.js 客户端打包配置文件,ssr打包是生成分为客户端和服务端的两部分打包文件
  • webpack.server.conf.js 服务端打包配置文件,ssr打包是生成分为客户端和服务端的两部分打包文件
  • entry-client.js 客户端入口文件。spa的入口是main.js,ssr就分为两个入口(服务端和客户端)
  • entry-server.js 服务端入口文件。spa的入口是main.js,ssr就分为两个入口(服务端和客户端)
  • index.template.html 模板文件,因为服务端渲染是通过服务器把页面丢出来,所以我们需要一个模板,作为页面初始载体,然后往里面添加内容。
  • server.js 启动文件,服务端渲染我们需要启动一个node服务器,主要配置在这个文件里面。

三、webpack添加客户端与服务端配置

1.webpack客户端配置

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseCOnfig= require('./webpack.base.conf.js')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
 entry: './src/entry-client.js',
 plugins: [
  new webpack.optimize.CommonsChunkPlugin({
   name: "manifest",
   minChunks: Infinity
  }),
  // 此插件在输出目录中
  // 生成 `vue-ssr-client-manifest.json`。
  new VueSSRClientPlugin(),
  new HtmlWebpackPlugin({
   template: path.resolve(__dirname, './../src/index.template.html'),
   filename: 'index.template.html'
  })
 ]
})

这里面和spa项目有两点不同,第一是入口变了,变为了entry-client.js。第二是VueSSRClientPlugin,这个是生成一个vue-ssr-client-manifest.json客户端入口文件。

2.webpack服务端配置

const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseCOnfig= require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
 // 将 entry 指向应用程序的 server entry 文件
 entry: './src/entry-server.js',

 // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
 // 并且还会在编译 Vue 组件时,
 // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
 target: 'node',

 // 对 bundle renderer 提供 source map 支持
 devtool: 'source-map',

 // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
 output: {
  libraryTarget: 'commonjs2'
 },
 externals: nodeExternals({
  // 不要外置化 webpack 需要处理的依赖模块。
  // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
  // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
  whitelist: /\.css$/
 }),

 // 这是将服务器的整个输出
 // 构建为单个 JSON 文件的插件。
 // 默认文件名为 `vue-ssr-server-bundle.json`
 plugins: [
  new VueSSRServerPlugin()
 ]
})

这段代码一目了然,第一是是告诉webpack这是要打包node能运行的东西,第二是打包一个服务端入口vue-ssr-server-bundle.json

四、vue、router、store实例改造

当编写纯客户端 (client-only) 代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

nodejs是一个运行时,如果只是个单例的话,所有的请求都会共享这个单例,会造成状态污染。所以我们需要为每个请求创造一个vue,router,store实例。

第一步修改main.js

// main.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store/store.js'
import { sync } from 'vuex-router-sync'

export function createApp () {
 // 创建 router 实例
 const router = createRouter()
 const store = createStore()

 // 同步路由状态(route state)到 store
 sync(store, router)

 const app = new Vue({
  // 注入 router 到根 Vue 实例
  router,
  store,
  render: h => h(App)
 })

 // 返回 app 和 router
 return { app, router, store }
}

看到这个createApp没,没错,它就是我们熟悉的工厂模式。同样的store和router一样改造

// router.js
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'

Vue.use(Router)


export let createRouter = () => {
 let route = new Router({
  mode:'history',
  routes: []
 })
 return route
}
// store.js
// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
 return new Vuex.Store({
  state: {
  },
  actions: {
  },
  mutations: {
  }
 })
}

到这里,三个实例对象改造完成了。是不是很简单~

五、数据预取和存储

服务器渲染,可以理解为在被访问的时候,服务端做预渲染生成页面,上面说过,预渲染的缺点就是,实时数据的获取。所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。这个地方上面提过,叫同构(服务端渲染一遍,客户端拿到数据再渲染一遍)

因为我们用的vue框架嘛,那当然数据存储选vuex咯。然后我们来理一下总体的流程:

客户端访问网站 —> 服务器获取动态数据,生成页面,并把数据存入vuex中,然后返回html —> 客户端获取html(此时已经返回了完整的页面) —> 客户端获取到vuex的数据,并解析到vue里面,然后再一次找到根元素挂载vue,重复渲染页面。(同构阶段)

流程清楚之后,那我们怎么设定,哪个地方的代码,被服务端执行,并获取数据存入vuex呢? 我们分为三步:

1.自定义函数asyncData

官方的例子是定义一个asyncData函数(这个名字不是唯一的哈,是自己定义的,可以随便取,不要理解为内置的函数哈),这个函数写在路由组件里面。
假设有一个Item.vue组件(官网的例子)




2. 服务端入口entry-server.js配置

到这里,asyncData函数,我们知道它是放在哪里了。接下来,我们有了这个函数,我们服务器肯定要去读到这个函数,然后去获取数据吧?我们把目光放到entry-server.js,之前我们提到过,这是服务端的入口页面。那我们是不是能够在这里面处理asyncData呢。下面还是官网的例子:

// entry-server.js
import { createApp } from './app'

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

  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,
      route: router.currentRoute
     })
    }
   })).then(() => {
    // 在所有预取钩子(preFetch hook) resolve 后,
    // 我们的 store 现在已经填充入渲染应用程序所需的状态。
    // 当我们将状态附加到上下文,
    // 并且 `template` 选项用于 renderer 时,
    // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
    context.state = store.state

    resolve(app)
   }).catch(reject)
  }, reject)
 })
}

简单的读下这段代码。首先为什么是返回Promise呢?因为可能是异步路由和组件,我们得保证,服务器渲染之前,已经完全准备就绪了。 然后注意**matchedComponents **它是通过传入的地址,获取到和路由匹配到的组件,然后如果存在asyncData,我们就去执行它,然后注入到context(渲染上下文,可以在客户端获取)里面。

是不是简单?这一步我们就已经从服务器端取到动态数据了,同时丢到页面里面了。如果不是为了客户端数据同步,这一步我们已经搞完服务端渲染了~ = =

3.客户端入口client-server.js配置

搞完服务器端的配置,该客户端了,毕竟数据要同步嘛。我们来看看客户端的入口文件代码:

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

if (window.__INITIAL_STATE__) {
 store.replaceState(window.__INITIAL_STATE__)
}

之前服务端入口说过,状态将自动序列化为 window.__INITIAL_STATE__,并注入 HTML。
所以客户端我们获取到了,服务端已经搞好了数据了,我们拿过来直接替换现有的vuex就好了。

看到这里,不是已经完成啦,完整的流程。但是到此为止了吗?还没呢,既然是服务端渲染,你总要启动服务器吧…

Ps: 数据预期,我们刚才讲到的只是服务端预取,其实还有客户端预取。什么是客户端预取呢,简单的理解就是,我们可以在路由钩子里面,找有当前路由组件没有asyncData,有的话,就去请求,获取到数据后,填充完之后,再渲染页面。

六、启动服务(server.js)配置

服务端渲染,服务端,肯定要一个启动服务的文件哈,

const express = require("express");

const fs = require('fs');
let path = require("path");
const server = express()
const { createBundleRenderer } = require('vue-server-renderer')

let renderer

const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
function createRenderer (bundle, options) {

 return createBundleRenderer(bundle, Object.assign(options, {
  runInNewContext: false
 }))
}

const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
 template,
 clientManifest
})


server.use(express.static('./dist'))
// 在服务器处理函数中……
server.get('*', (req, res) => {
 const cOntext= { url: req.url }
 // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
 renderer.renderToString(context, (err, html) => {
  // 处理异常……
  res.end(html)
 })
})
server.listen(3001, () => {
  console.log('服务已开启')
})

这就是服务端的启动代码了,只需处理获取几个打包过后的参数(template模板和clientManifest),传入createBundleRenderer函数。然后通过renderToString,展现给客户端。

七、热更新与本地调试

上面一步是启动服务,但是我们本地调试的时候,不可能每次build之后,再启动,然后再修改,再build吧?那也太麻烦了。所以我们借助webpack搞一个热更新。这里在build里面添加一个文件
server.dev.conf.js

//server.dev.conf.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientCOnfig= require('./webpack.client.conf.js')
const serverCOnfig= require('./webpack.server.conf.js')

const readFile = (fs, file) => {
 try {
  return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
 } catch (e) {}
}

module.exports = function setupDevServer (app, templatePath, cb) {
 let bundle
 let template
 let clientManifest

 let ready
 const readyPromise = new Promise(r => { ready = r })
 const update = () => {
  if (bundle && clientManifest) {
   ready()
   cb(bundle, {
    template,
    clientManifest
   })
  }
 }

 // read template from disk and watch
 template = fs.readFileSync(templatePath, 'utf-8')
 chokidar.watch(templatePath).on('change', () => {
  template = fs.readFileSync(templatePath, 'utf-8')
  console.log('index.html template updated.')
  update()
 })

 // modify client config to work with hot middleware
 clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
 clientConfig.output.filename = '[name].js'
 clientConfig.plugins.push(
  new webpack.HotModuleReplacementPlugin(),
  new webpack.NoEmitOnErrorsPlugin()
 )

 // dev middleware
 const clientCompiler = webpack(clientConfig)
 const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
  noInfo: true
 })
 app.use(devMiddleware)
 clientCompiler.plugin('done', stats => {
  stats = stats.toJson()
  stats.errors.forEach(err => console.error(err))
  stats.warnings.forEach(err => console.warn(err))
  if (stats.errors.length) return
  clientManifest = JSON.parse(readFile(
   devMiddleware.fileSystem,
   'vue-ssr-client-manifest.json'
  ))
  update()
 })

 // hot middleware
 app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

 // watch and update server renderer
 const serverCompiler = webpack(serverConfig)
 const mfs = new MFS()
 serverCompiler.outputFileSystem = mfs
 serverCompiler.watch({}, (err, stats) => {
  if (err) throw err
  stats = stats.toJson()
  if (stats.errors.length) return

  // read bundle generated by vue-ssr-webpack-plugin
  bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
  update()
 })

 return readyPromise
}

这个代码基本上是从官方文档copy下来的,写的挺好的 哈哈。

怎么理解这段代码呢,这个代码封装了一个promise,因为代码更新后重新打包需要时间,所以我们在renderToString之前,需要等待一段处理的时间。这个代码对3部分进行了监控,template.html、vue业务代码、客户端配置代码。检测到有改动之后,就重新打包获取,然后返回。这里就是热更新部分代码,当然我们还要改动server.js部分代码,毕竟要处理开发模式和生成模式的不同。

//server.js
const express = require("express");

const fs = require('fs');
let path = require("path");
const server = express()
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
let renderer
let readyPromise
const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
function createRenderer (bundle, options) {
 return createBundleRenderer(bundle, Object.assign(options, {
  runInNewContext: false
 }))
}

if(isProd){
 const template = fs.readFileSync(templatePath, 'utf-8')
 const bundle = require('./dist/vue-ssr-server-bundle.json')
 const clientManifest = require('./dist/vue-ssr-client-manifest.json')
 renderer = createRenderer(bundle, {
  template,
  clientManifest
 })

}else{
 readyPromise = require('./build/server.dev.conf.js')(
  server,
  templatePath,
  (bundle, options) => {
   renderer = createRenderer(bundle, options)
  }
 )
}
server.use(express.static('./dist'))
// 在服务器处理函数中……
server.get('*', (req, res) => {
 const cOntext= { url: req.url }
 // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
 // 现在我们的服务器与应用程序已经解耦!
 if(isProd){
  renderer.renderToString(context, (err, html) => {
   // 处理异常……
   res.end(html)
  })
 }else{
  readyPromise.then(()=>{
   renderer.renderToString(context, (err, html) => {
    // 处理异常……
    res.end(html)
   })
  })
 }
})
server.listen(3001, () => {
  console.log('服务已开启')
})

从server.js的代码改动,我们可以看到,server进行了是否为生产环境的判断,如果是测试环境,就取运行server.dev.conf.js,获得返回的promise,然后再renderToString之前,把renderToString加入到promise链式调用里面,这样,热更新就完成了,每次调用路由的时候,都会去获取到最新的页面。

到这里所有的ssr改造已经完成了,当然我们还能优化,下面给出几个点,自己思考哈:

  • 服务器缓存,既然是node服务器,我们当然可以做服务器缓存拉。
  • 流式渲染 (Streaming) 用 renderToStream 替代 renderToString;当 renderer 遍历虚拟 DOM 树 (virtual DOM tree) 时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。


推荐阅读
  • 分享css中提升优先级属性!important的用法总结
    web前端|css教程css!importantweb前端-css教程本文分享css中提升优先级属性!important的用法总结微信门店展示源码,vscode如何管理站点,ubu ... [详细]
  • Android实战——jsoup实现网络爬虫,糗事百科项目的起步
    本文介绍了Android实战中使用jsoup实现网络爬虫的方法,以糗事百科项目为例。对于初学者来说,数据源的缺乏是做项目的最大烦恼之一。本文讲述了如何使用网络爬虫获取数据,并以糗事百科作为练手项目。同时,提到了使用jsoup需要结合前端基础知识,以及如果学过JS的话可以更轻松地使用该框架。 ... [详细]
  • 如何实现织梦DedeCms全站伪静态
    本文介绍了如何通过修改织梦DedeCms源代码来实现全站伪静态,以提高管理和SEO效果。全站伪静态可以避免重复URL的问题,同时通过使用mod_rewrite伪静态模块和.htaccess正则表达式,可以更好地适应搜索引擎的需求。文章还提到了一些相关的技术和工具,如Ubuntu、qt编程、tomcat端口、爬虫、php request根目录等。 ... [详细]
  • VScode格式化文档换行或不换行的设置方法
    本文介绍了在VScode中设置格式化文档换行或不换行的方法,包括使用插件和修改settings.json文件的内容。详细步骤为:找到settings.json文件,将其中的代码替换为指定的代码。 ... [详细]
  • 本文介绍了在开发Android新闻App时,搭建本地服务器的步骤。通过使用XAMPP软件,可以一键式搭建起开发环境,包括Apache、MySQL、PHP、PERL。在本地服务器上新建数据库和表,并设置相应的属性。最后,给出了创建new表的SQL语句。这个教程适合初学者参考。 ... [详细]
  • 本文介绍了通过ABAP开发往外网发邮件的需求,并提供了配置和代码整理的资料。其中包括了配置SAP邮件服务器的步骤和ABAP写发送邮件代码的过程。通过RZ10配置参数和icm/server_port_1的设定,可以实现向Sap User和外部邮件发送邮件的功能。希望对需要的开发人员有帮助。摘要长度:184字。 ... [详细]
  • Java验证码——kaptcha的使用配置及样式
    本文介绍了如何使用kaptcha库来实现Java验证码的配置和样式设置,包括pom.xml的依赖配置和web.xml中servlet的配置。 ... [详细]
  • 使用正则表达式爬取36Kr网站首页新闻的操作步骤和代码示例
    本文介绍了使用正则表达式来爬取36Kr网站首页所有新闻的操作步骤和代码示例。通过访问网站、查找关键词、编写代码等步骤,可以获取到网站首页的新闻数据。代码示例使用Python编写,并使用正则表达式来提取所需的数据。详细的操作步骤和代码示例可以参考本文内容。 ... [详细]
  • 本文介绍了前端人员必须知道的三个问题,即前端都做哪些事、前端都需要哪些技术,以及前端的发展阶段。初级阶段包括HTML、CSS、JavaScript和jQuery的基础知识。进阶阶段涵盖了面向对象编程、响应式设计、Ajax、HTML5等新兴技术。高级阶段包括架构基础、模块化开发、预编译和前沿规范等内容。此外,还介绍了一些后端服务,如Node.js。 ... [详细]
  • 在Android中解析Gson解析json数据是很方便快捷的,可以直接将json数据解析成java对象或者集合。使用Gson解析json成对象时,默认将json里对应字段的值解析到java对象里对应字段的属性里面。然而,当我们自己定义的java对象里的属性名与json里的字段名不一样时,我们可以使用@SerializedName注解来将对象里的属性跟json里字段对应值匹配起来。本文介绍了使用@SerializedName注解解析json数据的方法,并给出了具体的使用示例。 ... [详细]
  • 背景应用安全领域,各类攻击长久以来都危害着互联网上的应用,在web应用安全风险中,各类注入、跨站等攻击仍然占据着较前的位置。WAF(Web应用防火墙)正是为防御和阻断这类攻击而存在 ... [详细]
  • Node.js学习笔记(一)package.json及cnpm
    本文介绍了Node.js中包的概念,以及如何使用包来统一管理具有相互依赖关系的模块。同时还介绍了NPM(Node Package Manager)的基本介绍和使用方法,以及如何通过NPM下载第三方模块。 ... [详细]
  • express工程中的json调用方法
    本文介绍了在express工程中如何调用json数据,包括建立app.js文件、创建数据接口以及获取全部数据和typeid为1的数据的方法。 ... [详细]
  • 本文介绍了Java后台Jsonp处理方法及其应用场景。首先解释了Jsonp是一个非官方的协议,它允许在服务器端通过Script tags返回至客户端,并通过javascript callback的形式实现跨域访问。然后介绍了JSON系统开发方法,它是一种面向数据结构的分析和设计方法,以活动为中心,将一连串的活动顺序组合成一个完整的工作进程。接着给出了一个客户端示例代码,使用了jQuery的ajax方法请求一个Jsonp数据。 ... [详细]
  • mui框架offcanvas侧滑超出部分隐藏无法滚动如何解决
    web前端|js教程off-canvas,部分,超出web前端-js教程mui框架中off-canvas侧滑的一个缺点就是无法出现滚动条,因为它主要用途是设置类似于qq界面的那种格 ... [详细]
author-avatar
CL_LC的小屋花_344
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有